chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { globSync } from "tinyglobby";
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const SRC_ROOTS = ["src", "open-sse"];
|
||||
const IMPORT_RE =
|
||||
/(?:import|export)[^'"]*from\s*['"]([^'"]+)['"]|require\(\s*['"]([^'"]+)['"]\s*\)|import\(\s*['"]([^'"]+)['"]\s*\)/g;
|
||||
const EXTS = [".ts", ".tsx", ".mts", ".js", ".mjs"];
|
||||
|
||||
function resolveImport(spec, fromFile) {
|
||||
let base;
|
||||
if (spec.startsWith("@/")) base = path.join(ROOT, "src", spec.slice(2));
|
||||
else if (spec.startsWith("@omniroute/open-sse"))
|
||||
base = path.join(ROOT, "open-sse", spec.replace(/^@omniroute\/open-sse\/?/, ""));
|
||||
else if (spec.startsWith(".")) base = path.resolve(path.dirname(fromFile), spec);
|
||||
else return null;
|
||||
for (const e of EXTS) {
|
||||
if (fs.existsSync(base + e)) return base + e;
|
||||
}
|
||||
for (const e of EXTS) {
|
||||
const idx = path.join(base, "index" + e);
|
||||
if (fs.existsSync(idx)) return idx;
|
||||
}
|
||||
return fs.existsSync(base) && fs.statSync(base).isFile() ? base : null;
|
||||
}
|
||||
|
||||
function sourceDepsOf(entry) {
|
||||
const seen = new Set();
|
||||
const stack = [entry];
|
||||
const sources = new Set();
|
||||
while (stack.length) {
|
||||
const f = stack.pop();
|
||||
if (seen.has(f)) continue;
|
||||
seen.add(f);
|
||||
let code;
|
||||
try {
|
||||
code = fs.readFileSync(f, "utf8");
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const m of code.matchAll(IMPORT_RE)) {
|
||||
const spec = m[1] || m[2] || m[3];
|
||||
if (!spec) continue;
|
||||
const r = resolveImport(spec, f);
|
||||
if (!r) continue;
|
||||
const rel = path.relative(ROOT, r);
|
||||
if (SRC_ROOTS.some((s) => rel.startsWith(s + path.sep))) sources.add(rel);
|
||||
stack.push(r);
|
||||
}
|
||||
}
|
||||
return sources;
|
||||
}
|
||||
|
||||
// Mirror EXACTLY the `npm run test:unit` glob — the curated set of node:test files.
|
||||
// The TIA step runs the selected subset via `node --test`, so it must NOT include
|
||||
// vitest files (`.test.tsx`, `open-sse/**/__tests__`, `tests/unit/autoCombo`), nor
|
||||
// e2e/integration tests, which can't run under node:test (they 99-false-failed before).
|
||||
const testFiles = globSync(
|
||||
[
|
||||
"tests/unit/*.test.ts",
|
||||
"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,executors,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui}/**/*.test.ts",
|
||||
// Quarentena serial (P0.3): também são node:test — a TIA precisa mapeá-los.
|
||||
"tests/unit/serial/**/*.test.ts",
|
||||
],
|
||||
{ cwd: ROOT, absolute: true }
|
||||
);
|
||||
const map = {};
|
||||
for (const tf of testFiles) {
|
||||
const relTest = path.relative(ROOT, tf);
|
||||
for (const src of sourceDepsOf(tf)) {
|
||||
(map[src] ||= []).push(relTest);
|
||||
}
|
||||
}
|
||||
for (const k of Object.keys(map)) map[k].sort();
|
||||
const out = path.join(ROOT, "config/quality/test-impact-map.json");
|
||||
fs.writeFileSync(out, JSON.stringify({ generatedFrom: "import-graph", sources: map }, null, 2) + "\n");
|
||||
console.log(
|
||||
`test-impact-map: ${Object.keys(map).length} source files mapped from ${testFiles.length} test files`
|
||||
);
|
||||
@@ -0,0 +1,153 @@
|
||||
#!/usr/bin/env node
|
||||
// scripts/quality/check-quality-ratchet.mjs
|
||||
// Catraca genérica multi-métrica. Clona o espírito de check-t11-any-budget.mjs:
|
||||
// um baseline congelado por métrica; falha em qualquer regressão; só anda num sentido.
|
||||
//
|
||||
// v2 (6A.5): --require-tighten, eps por métrica, warning de métricas órfãs.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const cwd = process.cwd();
|
||||
function getArg(name, fallback) {
|
||||
const i = process.argv.indexOf(name);
|
||||
return i >= 0 && process.argv[i + 1] ? process.argv[i + 1] : fallback;
|
||||
}
|
||||
const BASELINE = path.resolve(
|
||||
getArg("--baseline", path.join(cwd, "config/quality/quality-baseline.json"))
|
||||
);
|
||||
const METRICS = path.resolve(
|
||||
getArg("--metrics", path.join(cwd, "config/quality/quality-metrics.json"))
|
||||
);
|
||||
const SUMMARY = getArg("--summary", null);
|
||||
const UPDATE = process.argv.includes("--update");
|
||||
// --allow-missing: pula métricas do baseline ausentes do metrics (em vez de falhar).
|
||||
// Uso local: cobertura só existe no CI; localmente quality:gate roda com este flag.
|
||||
// No CI o job quality-gate roda SEM o flag (estrito — baixa o coverage mergeado antes).
|
||||
const ALLOW_MISSING = process.argv.includes("--allow-missing");
|
||||
// --require-tighten: falha quando uma métrica melhorou além de tightenSlack sem que o
|
||||
// baseline tenha sido apertado. Garante que melhorias permanentes sejam capturadas.
|
||||
// Sem esta flag, melhorias são apenas registradas (comportamento v1 — retrocompat).
|
||||
const REQUIRE_TIGHTEN = process.argv.includes("--require-tighten");
|
||||
|
||||
// Global fallback eps. Cada métrica pode sobrepor via `eps` no baseline.
|
||||
const GLOBAL_EPS = 0.01;
|
||||
|
||||
function load(p) {
|
||||
if (!fs.existsSync(p)) {
|
||||
console.error(`[quality-ratchet] arquivo ausente: ${p}`);
|
||||
process.exit(2);
|
||||
}
|
||||
return JSON.parse(fs.readFileSync(p, "utf8"));
|
||||
}
|
||||
|
||||
const baseline = load(BASELINE);
|
||||
const metrics = load(METRICS);
|
||||
const failures = [];
|
||||
const tightenFailures = [];
|
||||
const improvements = [];
|
||||
const rows = [];
|
||||
|
||||
for (const [key, spec] of Object.entries(baseline.metrics)) {
|
||||
const current = metrics[key];
|
||||
const base = spec.value;
|
||||
const dir = spec.direction; // "down" = menor-é-melhor | "up" = maior-é-melhor
|
||||
|
||||
// Per-metric eps; falls back to global EPS if not specified in the spec.
|
||||
const eps = spec.eps !== undefined ? spec.eps : GLOBAL_EPS;
|
||||
|
||||
// Per-metric tightenSlack; falls back to eps (same tolerance as regression check).
|
||||
const tightenSlack = spec.tightenSlack !== undefined ? spec.tightenSlack : eps;
|
||||
|
||||
if (current === undefined) {
|
||||
if (ALLOW_MISSING || spec.dedicatedGate === true) {
|
||||
const reason = spec.dedicatedGate === true ? "SKIP (dedicated gate)" : "SKIP (ausente)";
|
||||
rows.push([key, base, "—", reason]);
|
||||
} else {
|
||||
failures.push(`métrica "${key}" ausente em ${path.basename(METRICS)}`);
|
||||
rows.push([key, base, "—", "MISSING"]);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let status = "ok";
|
||||
if (dir === "down") {
|
||||
if (current > base + eps) {
|
||||
failures.push(`${key}: ${current} > baseline ${base} (não pode aumentar)`);
|
||||
status = "REGRESSÃO";
|
||||
} else if (current < base - eps) {
|
||||
improvements.push([key, current]);
|
||||
status = "↑ melhorou";
|
||||
if (REQUIRE_TIGHTEN && base - current > tightenSlack) {
|
||||
tightenFailures.push(
|
||||
`${key}: melhorou de ${base} para ${current} (delta ${(base - current).toFixed(4)} > slack ${tightenSlack}) — rode 'npm run quality:ratchet -- --update' e commite o baseline apertado neste PR`
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (current < base - eps) {
|
||||
failures.push(`${key}: ${current} < baseline ${base} (não pode cair)`);
|
||||
status = "REGRESSÃO";
|
||||
} else if (current > base + eps) {
|
||||
improvements.push([key, current]);
|
||||
status = "↑ melhorou";
|
||||
if (REQUIRE_TIGHTEN && current - base > tightenSlack) {
|
||||
tightenFailures.push(
|
||||
`${key}: melhorou de ${base} para ${current} (delta ${(current - base).toFixed(4)} > slack ${tightenSlack}) — rode 'npm run quality:ratchet -- --update' e commite o baseline apertado neste PR`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
rows.push([key, base, current, status]);
|
||||
}
|
||||
|
||||
// Behavior 3: warn about orphan metrics (present in collected metrics but absent in baseline).
|
||||
const baselineKeys = new Set(Object.keys(baseline.metrics));
|
||||
const orphans = Object.keys(metrics).filter((k) => !baselineKeys.has(k));
|
||||
if (orphans.length > 0) {
|
||||
console.warn(
|
||||
`[quality-ratchet] WARN: ${orphans.length} métrica(s) órfã(s) — presente(s) em ${path.basename(METRICS)} mas sem entrada no baseline: ${orphans.join(", ")}`
|
||||
);
|
||||
console.warn(
|
||||
`[quality-ratchet] WARN: adicione ${orphans.length === 1 ? "essa métrica" : "essas métricas"} ao baseline (com value/direction) para que sejam catraceadas.`
|
||||
);
|
||||
}
|
||||
|
||||
if (SUMMARY) {
|
||||
const md = [
|
||||
"# Quality Ratchet",
|
||||
"",
|
||||
"| Métrica | Baseline | Atual | Status |",
|
||||
"|---|---|---|---|",
|
||||
...rows.map(([k, b, c, s]) => `| ${k} | ${b} | ${c} | ${s} |`),
|
||||
"",
|
||||
failures.length
|
||||
? `**${failures.length} regressão(ões) — gate BLOQUEADO.**`
|
||||
: "**Sem regressões — gate OK.**",
|
||||
].join("\n");
|
||||
fs.mkdirSync(path.dirname(SUMMARY), { recursive: true });
|
||||
fs.writeFileSync(SUMMARY, md + "\n");
|
||||
}
|
||||
|
||||
// Tighten check runs only when there are no regressions (regressions take priority).
|
||||
// With --update, improvements are captured into the baseline, so tighten check
|
||||
// is bypassed (the update itself is the required action).
|
||||
if (UPDATE && failures.length === 0 && improvements.length) {
|
||||
for (const [key, val] of improvements) baseline.metrics[key].value = val;
|
||||
fs.writeFileSync(BASELINE, JSON.stringify(baseline, null, 2) + "\n");
|
||||
console.log(`[quality-ratchet] baseline ratcheado: ${improvements.length} métrica(s) melhoraram`);
|
||||
}
|
||||
|
||||
if (failures.length) {
|
||||
console.error("[quality-ratchet] FALHOU:\n" + failures.map((f) => " ✗ " + f).join("\n"));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Behavior 1: --require-tighten gate (only triggers when there are no regressions and no --update).
|
||||
if (REQUIRE_TIGHTEN && !UPDATE && tightenFailures.length > 0) {
|
||||
console.error(
|
||||
"[quality-ratchet] FALHOU (--require-tighten): métrica(s) melhoraram mas o baseline não foi apertado:\n" +
|
||||
tightenFailures.map((f) => " ✗ " + f).join("\n")
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`[quality-ratchet] OK (${rows.length} métricas, ${improvements.length} melhoraram)`);
|
||||
@@ -0,0 +1,268 @@
|
||||
#!/usr/bin/env node
|
||||
// scripts/quality/collect-metrics.mjs — emite quality-metrics.json
|
||||
// Coletores incrementais: Fase 1 traz ESLint warnings + cobertura.
|
||||
// Fases 3/4 estendem com duplicação (jscpd), tamanho de arquivo e cobertura por módulo.
|
||||
// Fase 6A.11: openapiCoverage.pct + i18nUiCoverage.pct (mínimo entre locales).
|
||||
// Task 7.9: coverage.<modulo>.lines para ~8 módulos críticos, lidos do
|
||||
// coverage/coverage-summary.json se existir (sem erro se ausente).
|
||||
import fs from "node:fs";
|
||||
import { promises as fsAsync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import * as yaml from "js-yaml";
|
||||
|
||||
const cwd = process.cwd();
|
||||
const out = {};
|
||||
|
||||
// 1) ESLint: contagem de warnings/errors NOVOS além do baseline congelado.
|
||||
// Pacote 4 (plano mestre testes+CI, 2026-07-04): a dívida pré-existente vive em
|
||||
// config/quality/eslint-suppressions.json (ESLint bulk suppressions nativo) e é
|
||||
// bloqueada no PR que a introduziria (job lint-guard + lint-staged). A métrica do
|
||||
// ratchet passa a medir a dívida LÍQUIDA nova — em regime, ~0 — em vez do estoque
|
||||
// bruto (que driftava +41/+88 por ciclo e era rebaselinado às cegas na release).
|
||||
// O aperto do estoque acontece via --prune-suppressions na release.
|
||||
function eslintCounts() {
|
||||
let stdout;
|
||||
const args = ["eslint", ".", "--format", "json"];
|
||||
if (fs.existsSync(path.join(cwd, "config/quality/eslint-suppressions.json"))) {
|
||||
args.push("--suppressions-location", "config/quality/eslint-suppressions.json");
|
||||
}
|
||||
try {
|
||||
stdout = execFileSync("npx", args, {
|
||||
encoding: "utf8",
|
||||
maxBuffer: 256 * 1024 * 1024,
|
||||
});
|
||||
} catch (e) {
|
||||
// eslint sai com código != 0 quando há errors; o JSON ainda vem no stdout
|
||||
stdout = e.stdout?.toString() || "[]";
|
||||
}
|
||||
const results = JSON.parse(stdout);
|
||||
out.eslintWarnings = results.reduce((n, r) => n + (r.warningCount || 0), 0);
|
||||
out.eslintErrors = results.reduce((n, r) => n + (r.errorCount || 0), 0);
|
||||
}
|
||||
|
||||
// 2) Cobertura: lê coverage/coverage-summary.json se existir (gerado por c8)
|
||||
function coverage() {
|
||||
const p = path.join(cwd, "coverage", "coverage-summary.json");
|
||||
if (!fs.existsSync(p)) return;
|
||||
const t = JSON.parse(fs.readFileSync(p, "utf8")).total;
|
||||
out["coverage.statements"] = t.statements.pct;
|
||||
out["coverage.lines"] = t.lines.pct;
|
||||
out["coverage.functions"] = t.functions.pct;
|
||||
out["coverage.branches"] = t.branches.pct;
|
||||
}
|
||||
|
||||
// 3) Coverage per critical module (Task 7.9)
|
||||
// Reads coverage/coverage-summary.json (produced by `npm run test:coverage` via c8).
|
||||
// If the file is absent → silently skips (no error). This allows the gate to
|
||||
// function normally in environments where coverage was not run (e.g. lint-only CI).
|
||||
//
|
||||
// The summary JSON produced by c8 looks like:
|
||||
// { "total": {...}, "/abs/path/to/file.ts": { lines: { pct: 78 }, ... }, ... }
|
||||
//
|
||||
// modulePaths is a record of { metricSuffix: relPathFromRoot[] } — the first
|
||||
// matching key in the summary wins.
|
||||
|
||||
/**
|
||||
* Pure function — extracts per-module line-coverage percentages from a
|
||||
* coverage-summary.json object.
|
||||
*
|
||||
* @param {Record<string, { lines?: { pct: number } }>} summaryJson
|
||||
* The parsed coverage-summary.json (keys are absolute file paths or "total").
|
||||
* @param {Record<string, string[]>} modulePaths
|
||||
* Map of { metricKey: [relPath, ...fallbacks] } where relPath is relative to
|
||||
* the repo root (forward slashes). Returns the lines.pct of the first match.
|
||||
* @param {string} repoRoot Absolute path to the repo root (used to build keys).
|
||||
* @returns {Record<string, number>} Map of metricKey → lines.pct (0-100).
|
||||
*/
|
||||
export function extractModuleCoverage(summaryJson, modulePaths, repoRoot) {
|
||||
const result = {};
|
||||
// Build a normalised lookup: absolute path (forward slashes) → pct
|
||||
const lookup = new Map();
|
||||
for (const [rawKey, data] of Object.entries(summaryJson)) {
|
||||
if (rawKey === "total") continue;
|
||||
const norm = rawKey.replace(/\\/g, "/");
|
||||
const pct = data?.lines?.pct;
|
||||
if (typeof pct === "number") lookup.set(norm, pct);
|
||||
}
|
||||
|
||||
const normRoot = repoRoot.replace(/\\/g, "/").replace(/\/$/, "");
|
||||
|
||||
for (const [metricKey, candidates] of Object.entries(modulePaths)) {
|
||||
for (const rel of candidates) {
|
||||
const abs = `${normRoot}/${rel.replace(/\\/g, "/").replace(/^\//, "")}`;
|
||||
if (lookup.has(abs)) {
|
||||
result[metricKey] = lookup.get(abs);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** The 8 critical modules tracked by Task 7.9 (relative paths from repo root). */
|
||||
export const CRITICAL_MODULE_PATHS = {
|
||||
"coverage.chatCore.lines": ["open-sse/handlers/chatCore.ts"],
|
||||
"coverage.combo.lines": ["open-sse/services/combo.ts"],
|
||||
"coverage.accountFallback.lines": ["open-sse/services/accountFallback.ts"],
|
||||
"coverage.auth.lines": ["src/sse/services/auth.ts"],
|
||||
"coverage.routeGuard.lines": ["src/server/authz/routeGuard.ts"],
|
||||
"coverage.error.lines": ["open-sse/utils/error.ts"],
|
||||
"coverage.publicCreds.lines": ["open-sse/utils/publicCreds.ts"],
|
||||
"coverage.circuitBreaker.lines": ["src/shared/utils/circuitBreaker.ts"],
|
||||
};
|
||||
|
||||
function coverageByModule() {
|
||||
const p = path.join(cwd, "coverage", "coverage-summary.json");
|
||||
if (!fs.existsSync(p)) return; // absent → skip silently (Task 7.9 spec)
|
||||
let summaryJson;
|
||||
try {
|
||||
summaryJson = JSON.parse(fs.readFileSync(p, "utf8"));
|
||||
} catch {
|
||||
return; // malformed file → skip
|
||||
}
|
||||
const moduleMetrics = extractModuleCoverage(summaryJson, CRITICAL_MODULE_PATHS, cwd);
|
||||
Object.assign(out, moduleMetrics);
|
||||
}
|
||||
|
||||
// 4) OpenAPI coverage: percentage of implemented routes documented in openapi.yaml
|
||||
function openapiCoverage() {
|
||||
const API_ROOT = path.join(cwd, "src", "app", "api");
|
||||
const OPENAPI_PATH = path.join(cwd, "docs", "openapi.yaml");
|
||||
if (!fs.existsSync(API_ROOT) || !fs.existsSync(OPENAPI_PATH)) return;
|
||||
|
||||
function collectRoutePaths(dir) {
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
const paths = [];
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
paths.push(...collectRoutePaths(fullPath));
|
||||
continue;
|
||||
}
|
||||
if (entry.isFile() && entry.name === "route.ts") {
|
||||
const apiPath = path
|
||||
.dirname(fullPath)
|
||||
.replace(API_ROOT, "")
|
||||
.replace(/\[([^\]]+)\]/g, "{$1}");
|
||||
paths.push(`/api${apiPath}`);
|
||||
}
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
function normalizePath(p) {
|
||||
return p.replace(/\/\[\.\.\.([^\]]+)\]/g, "/{$1}").replace(/\[([^\]]+)\]/g, "{$1}");
|
||||
}
|
||||
|
||||
const implementedPaths = collectRoutePaths(API_ROOT).map(normalizePath);
|
||||
const raw = yaml.load(fs.readFileSync(OPENAPI_PATH, "utf-8"));
|
||||
const documentedPaths = new Set(Object.keys(raw.paths || {}));
|
||||
const covered = implementedPaths.filter((p) => documentedPaths.has(p)).length;
|
||||
const total = implementedPaths.length;
|
||||
if (total > 0) out["openapiCoverage.pct"] = parseFloat(((covered / total) * 100).toFixed(1));
|
||||
}
|
||||
|
||||
// 4) i18n UI coverage: minimum real coverage across all non-en locales
|
||||
async function i18nUiCoverage() {
|
||||
const MESSAGES_DIR = path.join(cwd, "src", "i18n", "messages");
|
||||
const CONFIG_PATH = path.join(cwd, "config", "i18n.json");
|
||||
const SOURCE_LOCALE = "en";
|
||||
const PLACEHOLDER_PREFIX = "__MISSING__:";
|
||||
|
||||
if (!fs.existsSync(MESSAGES_DIR)) return;
|
||||
const sourcePath = path.join(MESSAGES_DIR, `${SOURCE_LOCALE}.json`);
|
||||
if (!fs.existsSync(sourcePath)) return;
|
||||
|
||||
function isPlainObject(value) {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function collectLeafPaths(obj, prefix = []) {
|
||||
const paths = [];
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
const next = [...prefix, key];
|
||||
if (isPlainObject(value)) {
|
||||
paths.push(...collectLeafPaths(value, next));
|
||||
} else {
|
||||
paths.push(next);
|
||||
}
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
const FORBIDDEN_KEY_SEGMENTS = new Set(["__proto__", "prototype", "constructor"]);
|
||||
|
||||
function lookupPath(obj, parts) {
|
||||
let cur = obj;
|
||||
for (const part of parts) {
|
||||
if (!isPlainObject(cur)) return undefined;
|
||||
if (FORBIDDEN_KEY_SEGMENTS.has(part)) return undefined;
|
||||
if (!Object.prototype.hasOwnProperty.call(cur, part)) return undefined;
|
||||
const entry = Object.entries(cur).find(([k]) => k === part);
|
||||
cur = entry ? entry[1] : undefined;
|
||||
}
|
||||
return cur;
|
||||
}
|
||||
|
||||
const source = JSON.parse(fs.readFileSync(sourcePath, "utf8"));
|
||||
const enPaths = collectLeafPaths(source);
|
||||
const totalEn = enPaths.length;
|
||||
if (totalEn === 0) return;
|
||||
|
||||
let configCodes = null;
|
||||
if (fs.existsSync(CONFIG_PATH)) {
|
||||
try {
|
||||
const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8"));
|
||||
if (Array.isArray(cfg.locales)) configCodes = new Set(cfg.locales.map((l) => l.code));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
const onDisk = (await fsAsync.readdir(MESSAGES_DIR))
|
||||
.filter((f) => f.endsWith(".json") && f !== `${SOURCE_LOCALE}.json`)
|
||||
.map((f) => f.slice(0, -5))
|
||||
.filter((code) => (configCodes ? configCodes.has(code) : true));
|
||||
|
||||
let minCoverage = 100;
|
||||
for (const locale of onDisk) {
|
||||
const localePath = path.join(MESSAGES_DIR, `${locale}.json`);
|
||||
let target;
|
||||
try {
|
||||
target = JSON.parse(fs.readFileSync(localePath, "utf8"));
|
||||
} catch {
|
||||
minCoverage = 0;
|
||||
continue;
|
||||
}
|
||||
let present = 0;
|
||||
let placeholder = 0;
|
||||
for (const pathParts of enPaths) {
|
||||
const value = lookupPath(target, pathParts);
|
||||
if (value === undefined || isPlainObject(value)) continue;
|
||||
present++;
|
||||
if (typeof value === "string" && value.startsWith(PLACEHOLDER_PREFIX)) placeholder++;
|
||||
}
|
||||
const coverage = ((present - placeholder) / totalEn) * 100;
|
||||
if (coverage < minCoverage) minCoverage = coverage;
|
||||
}
|
||||
|
||||
if (onDisk.length > 0) out["i18nUiCoverage.pct"] = parseFloat(minCoverage.toFixed(1));
|
||||
}
|
||||
|
||||
// Only run the collection pipeline when this file is executed directly.
|
||||
// When imported (e.g. in tests), only the exported pure functions are available
|
||||
// without triggering the expensive ESLint + i18n filesystem walks.
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) {
|
||||
eslintCounts();
|
||||
coverage();
|
||||
coverageByModule();
|
||||
openapiCoverage();
|
||||
await i18nUiCoverage();
|
||||
fs.writeFileSync(
|
||||
path.join(cwd, "config/quality/quality-metrics.json"),
|
||||
JSON.stringify(out, null, 2) + "\n"
|
||||
);
|
||||
console.log("[collect-metrics]", JSON.stringify(out));
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Mutation radiography (Quality Gate v2 / Fase 9 T5 — Onda 2, Task 1).
|
||||
*
|
||||
* Classifies every COVERING test file by its mutation-kill contribution, using the
|
||||
* `killedBy` attribution that the Stryker tap-runner emits per mutant
|
||||
* (`coverageAnalysis: perTest`, validated by the Task 12 spike):
|
||||
*
|
||||
* 🔴 empty — the test file never appears in any `killedBy` (kills no mutant
|
||||
* of the mutated modules). Prime R1-prune candidate (Task 2).
|
||||
* 🟠 redundant — every mutant it kills is ALSO killed by ≥1 other test file
|
||||
* (zero unique kills).
|
||||
* 🟡 overlapping — kills ≥1 unique mutant, but the MAJORITY of its kills are shared.
|
||||
* 🟢 unique — kills ≥1 mutant that NO other test file kills (and unique kills
|
||||
* are not outnumbered by shared kills).
|
||||
*
|
||||
* CAVEAT — bail-on-first-kill: Stryker bails after the first test kills a mutant
|
||||
* (we do NOT set `disableBail`), so `killedBy` lists the FIRST killer, not every
|
||||
* killer. Consequence: 🔴 empty is RELIABLE (a sole killer is always recorded, so a
|
||||
* file that never appears in killedBy is never the sole killer of any mutant → safe
|
||||
* R1-prune candidate w.r.t. mutationScore), but 🟢/🟠/🟡 are OPTIMISTIC — "unique" is
|
||||
* overstated and "redundant" understated, because a non-first coverer that WOULD also
|
||||
* kill is never recorded. Use 🟢/🟠/🟡 as advisory only; an accurate redundancy split
|
||||
* (for R2) needs a `disableBail: true` run. R1 (Task 2) acts on 🔴 alone + a line-
|
||||
* coverage cross-check + human review, so bail-on-first is sufficient there.
|
||||
*
|
||||
* IMPORTANT — multi-batch merge: the nightly splits `mutate` across parallel batches
|
||||
* (one mutation.json per batch). Stryker assigns numeric test ids PER RUN, so id "12"
|
||||
* in batch c is unrelated to id "12" in batch d. Each report is therefore resolved
|
||||
* (id -> file name, via its own `testFiles` section) and classified independently;
|
||||
* `aggregateRadiography` then sums the per-FILE kill counts across batches and
|
||||
* reclassifies. A file empty in one batch but unique in another is unique overall.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/quality/mutation-radiography.mjs <mutation-c.json> [<mutation-d.json> ...]
|
||||
* The universe of test files (so 🔴 empty files are detectable) defaults to
|
||||
* `stryker.conf.json:tap.testFiles`; pass --no-conf-universe to use only the union
|
||||
* of the reports' own `testFiles` sections instead.
|
||||
*/
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = path.resolve(SCRIPT_DIR, "..", "..");
|
||||
|
||||
export function loadMutationReport(reportPath) {
|
||||
return JSON.parse(fs.readFileSync(reportPath, "utf8"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Threshold rules shared by single-report and aggregated classification.
|
||||
* @param {number} uniqueKills mutants this file kills ALONE
|
||||
* @param {number} sharedKills mutants this file kills together with others
|
||||
*/
|
||||
export function classifyFromCounts(uniqueKills, sharedKills) {
|
||||
if (uniqueKills === 0 && sharedKills === 0) return "empty";
|
||||
if (uniqueKills === 0) return "redundant";
|
||||
if (sharedKills > uniqueKills) return "overlapping";
|
||||
return "unique";
|
||||
}
|
||||
|
||||
// Map each numeric test id to its file name via the report's `testFiles` section.
|
||||
// Real tap-runner reports key killedBy by id; the synthetic test fixtures key it by
|
||||
// file name directly (no testFiles section) — those pass through unchanged.
|
||||
function buildIdToFile(report) {
|
||||
const map = new Map();
|
||||
for (const [file, data] of Object.entries(report.testFiles || {})) {
|
||||
for (const t of data.tests || []) {
|
||||
map.set(String(t.id), t.name || file);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// Raw per-file kill counts for ONE report (no universe, no classification).
|
||||
function countKills(report) {
|
||||
const idToFile = buildIdToFile(report);
|
||||
const counts = new Map();
|
||||
const bump = (file, key) => {
|
||||
const c = counts.get(file) || { uniqueKills: 0, sharedKills: 0 };
|
||||
c[key] += 1;
|
||||
counts.set(file, c);
|
||||
};
|
||||
for (const data of Object.values(report.files || {})) {
|
||||
for (const m of data.mutants || []) {
|
||||
if (m.status !== "Killed") continue;
|
||||
const killers = [...new Set((m.killedBy || []).map((id) => idToFile.get(String(id)) ?? id))];
|
||||
if (killers.length === 0) continue;
|
||||
if (killers.length === 1) bump(killers[0], "uniqueKills");
|
||||
else for (const k of killers) bump(k, "sharedKills");
|
||||
}
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
function materialize(counts, universe) {
|
||||
const files = new Set(universe || []);
|
||||
for (const f of counts.keys()) files.add(f);
|
||||
const out = {};
|
||||
for (const file of files) {
|
||||
const { uniqueKills = 0, sharedKills = 0 } = counts.get(file) || {};
|
||||
out[file] = { class: classifyFromCounts(uniqueKills, sharedKills), uniqueKills, sharedKills };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify the test files of a SINGLE mutation report.
|
||||
* @param {object} report parsed mutation.json
|
||||
* @param {string[]} [allTestFiles] universe; defaults to the report's testFiles keys
|
||||
*/
|
||||
export function classifyTestFiles(report, allTestFiles) {
|
||||
const universe = allTestFiles || Object.keys(report.testFiles || {});
|
||||
return materialize(countKills(report), universe);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge several per-batch reports at the file level, then classify.
|
||||
* @param {object[]} reports parsed mutation.json objects (one per batch)
|
||||
* @param {string[]} [allTestFiles] universe; defaults to the union of testFiles keys
|
||||
*/
|
||||
export function aggregateRadiography(reports, allTestFiles) {
|
||||
const total = new Map();
|
||||
const universe = new Set(allTestFiles || []);
|
||||
for (const report of reports) {
|
||||
if (!allTestFiles) for (const f of Object.keys(report.testFiles || {})) universe.add(f);
|
||||
for (const [file, c] of countKills(report)) {
|
||||
const acc = total.get(file) || { uniqueKills: 0, sharedKills: 0 };
|
||||
acc.uniqueKills += c.uniqueKills;
|
||||
acc.sharedKills += c.sharedKills;
|
||||
total.set(file, acc);
|
||||
}
|
||||
}
|
||||
return materialize(total, [...universe]);
|
||||
}
|
||||
|
||||
/**
|
||||
* R1 prune-candidate list: the test files with ZERO unique kills — 🔴 empty (kills no
|
||||
* mutant) ∪ 🟠 redundant (every mutant it kills is also killed by ≥1 other file). Files
|
||||
* with ≥1 unique kill (🟢 unique / 🟡 overlapping) are NEVER candidates — removing one
|
||||
* would drop a mutant's only killer and lower the mutation score.
|
||||
*
|
||||
* IMPORTANT: 🟠 redundant is only ACCURATE when the reports come from a `disableBail:true`
|
||||
* run (killedBy lists EVERY killer). Under the bail-on-first nightly, redundant is
|
||||
* understated — see the module caveat. Pass disableBail reports here (mutation-redundancy.yml).
|
||||
*
|
||||
* @param {object[]} reports parsed mutation.json objects (one per batch)
|
||||
* @param {string[]} [allTestFiles] universe; defaults to the union of testFiles keys
|
||||
* @returns {{ classification: object, empty: string[], redundant: string[], candidates: string[] }}
|
||||
*/
|
||||
export function redundancyCandidates(reports, allTestFiles) {
|
||||
const classification = aggregateRadiography(reports, allTestFiles);
|
||||
const empty = [];
|
||||
const redundant = [];
|
||||
for (const [file, info] of Object.entries(classification)) {
|
||||
if (info.class === "empty") empty.push(file);
|
||||
else if (info.class === "redundant") redundant.push(file);
|
||||
}
|
||||
empty.sort((a, b) => a.localeCompare(b));
|
||||
redundant.sort((a, b) => a.localeCompare(b));
|
||||
const candidates = [...empty, ...redundant].sort((a, b) => a.localeCompare(b));
|
||||
return { classification, empty, redundant, candidates };
|
||||
}
|
||||
|
||||
// ── CLI ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
function tapTestFilesUniverse() {
|
||||
try {
|
||||
const conf = JSON.parse(fs.readFileSync(path.join(REPO_ROOT, "stryker.conf.json"), "utf8"));
|
||||
return conf?.tap?.testFiles || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const CLASS_LABEL = {
|
||||
empty: "🔴 empty",
|
||||
redundant: "🟠 redundant",
|
||||
overlapping: "🟡 overlapping",
|
||||
unique: "🟢 unique",
|
||||
};
|
||||
const CLASS_ORDER = ["empty", "redundant", "overlapping", "unique"];
|
||||
|
||||
function renderMarkdown(classification) {
|
||||
const byClass = { empty: [], redundant: [], overlapping: [], unique: [] };
|
||||
for (const [file, info] of Object.entries(classification))
|
||||
byClass[info.class].push({ file, ...info });
|
||||
for (const k of CLASS_ORDER) byClass[k].sort((a, b) => a.file.localeCompare(b.file));
|
||||
|
||||
const total = Object.keys(classification).length;
|
||||
const lines = [];
|
||||
lines.push("# Mutation Radiography");
|
||||
lines.push("");
|
||||
lines.push(
|
||||
`Test files classified by mutation-kill contribution (\`killedBy\`). Total: **${total}**.`
|
||||
);
|
||||
lines.push("");
|
||||
lines.push("| Class | Count | Meaning |");
|
||||
lines.push("| --- | --- | --- |");
|
||||
lines.push(
|
||||
`| 🔴 empty | ${byClass.empty.length} | kills no mutant of the mutated modules (R1-prune candidate) |`
|
||||
);
|
||||
lines.push(
|
||||
`| 🟠 redundant | ${byClass.redundant.length} | every kill is shared with another file |`
|
||||
);
|
||||
lines.push(
|
||||
`| 🟡 overlapping | ${byClass.overlapping.length} | kills ≥1 unique but mostly shared |`
|
||||
);
|
||||
lines.push(`| 🟢 unique | ${byClass.unique.length} | kills ≥1 mutant no other file kills |`);
|
||||
lines.push("");
|
||||
lines.push(
|
||||
"> **Bail caveat:** Stryker bails on the first kill (no `disableBail`), so `killedBy` is the " +
|
||||
"FIRST killer only. 🔴 empty is reliable (safe R1-prune candidate w.r.t. mutationScore); " +
|
||||
"🟢/🟠/🟡 are optimistic (unique overstated, redundant understated) — advisory until a " +
|
||||
"`disableBail` run. R1 prunes 🔴 only, with a line-coverage cross-check + human review."
|
||||
);
|
||||
lines.push("");
|
||||
for (const k of CLASS_ORDER) {
|
||||
const rows = byClass[k];
|
||||
lines.push(`## ${CLASS_LABEL[k]} (${rows.length})`);
|
||||
lines.push("");
|
||||
if (rows.length === 0) {
|
||||
lines.push("_none_");
|
||||
} else {
|
||||
lines.push("| Test file | unique | shared |");
|
||||
lines.push("| --- | --- | --- |");
|
||||
for (const r of rows) lines.push(`| ${r.file} | ${r.uniqueKills} | ${r.sharedKills} |`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
const FLAGS = new Set(["--no-conf-universe", "--candidates"]);
|
||||
|
||||
function renderCandidates({ empty, redundant, candidates }) {
|
||||
const lines = [];
|
||||
lines.push("# R1 — Test-redundancy prune candidates (disableBail)");
|
||||
lines.push("");
|
||||
lines.push(
|
||||
`Test files with ZERO unique kills: **${candidates.length}** ` +
|
||||
`(🔴 empty ${empty.length} + 🟠 redundant ${redundant.length}).`
|
||||
);
|
||||
lines.push("");
|
||||
lines.push(
|
||||
"> Accurate ONLY for a `disableBail:true` run (killedBy lists every killer). " +
|
||||
"These are CANDIDATES, not deletions: exclude security/contract/repro tests " +
|
||||
"(routeGuard, OAuth, error-sanitization, *-repro*/*-regression*/issue-linked) and " +
|
||||
"require human review before removing any (R1 human gate)."
|
||||
);
|
||||
lines.push("");
|
||||
lines.push(`## 🔴 empty — kills no mutant (${empty.length})`);
|
||||
lines.push("");
|
||||
if (empty.length === 0) lines.push("_none_");
|
||||
else for (const f of empty) lines.push(`- ${f}`);
|
||||
lines.push("");
|
||||
lines.push(`## 🟠 redundant — every kill shared with another file (${redundant.length})`);
|
||||
lines.push("");
|
||||
if (redundant.length === 0) lines.push("_none_");
|
||||
else for (const f of redundant) lines.push(`- ${f}`);
|
||||
lines.push("");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function main(argv) {
|
||||
const wantCandidates = argv.includes("--candidates");
|
||||
const useConfUniverse = !argv.includes("--no-conf-universe");
|
||||
const paths = argv.slice(2).filter((a) => !FLAGS.has(a));
|
||||
if (paths.length === 0) {
|
||||
process.stderr.write(
|
||||
"usage: mutation-radiography.mjs <mutation-1.json> [<mutation-2.json> ...] " +
|
||||
"[--candidates] [--no-conf-universe]\n"
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
const reports = paths.map(loadMutationReport);
|
||||
const universe = useConfUniverse ? tapTestFilesUniverse() : null;
|
||||
if (wantCandidates) {
|
||||
process.stdout.write(
|
||||
renderCandidates(redundancyCandidates(reports, universe || undefined)) + "\n"
|
||||
);
|
||||
return;
|
||||
}
|
||||
const classification = aggregateRadiography(reports, universe || undefined);
|
||||
process.stdout.write(renderMarkdown(classification) + "\n");
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
main(process.argv);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// Fixed seed for PR runs (deterministic). Nightly overrides via FC_SEED=random.
|
||||
export const PROPERTY_SEED = 42424242;
|
||||
export const PROPERTY_NUM_RUNS = 200;
|
||||
@@ -0,0 +1,170 @@
|
||||
#!/usr/bin/env node
|
||||
// scripts/quality/run-all-gates.mjs
|
||||
// Agregador paralelo de quality gates determinísticos.
|
||||
// Roda os gates filesystem-only em paralelo (pool ~4), agrega resultados, e exibe
|
||||
// uma tabela consolidada com {gate, status, durationMs}. Sai com código 1 se
|
||||
// qualquer gate falhar. Alvo: < 3 min no total.
|
||||
//
|
||||
// Usage:
|
||||
// node scripts/quality/run-all-gates.mjs # run all gates
|
||||
// node scripts/quality/run-all-gates.mjs --fast # skip slow gates (duplication)
|
||||
//
|
||||
// Via npm: npm run quality:scan
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const FAST_ONLY = process.argv.includes("--fast");
|
||||
|
||||
// Gates determinísticos filesystem-only, agrupados por tempo estimado.
|
||||
// Cada entrada: { name: string, cmd: string[], label?: string }
|
||||
// "slow" gates são omitidos com --fast.
|
||||
const GATES = [
|
||||
// Group A — instant (<1s)
|
||||
{ name: "check:tracked-artifacts", cmd: ["node", "scripts/check/check-tracked-artifacts.mjs"] },
|
||||
{ name: "check:any-budget:t11", cmd: ["node", "scripts/check/check-t11-any-budget.mjs"] },
|
||||
{ name: "check:migration-numbering", cmd: ["node", "scripts/check/check-migration-numbering.mjs"] },
|
||||
{ name: "check:node-runtime", cmd: ["node", "--import", "tsx", "scripts/check/check-supported-node-runtime.ts"] },
|
||||
|
||||
// Group B — fast (<5s)
|
||||
{ name: "check:provider-consistency", cmd: ["node", "--import", "tsx", "scripts/check/check-provider-consistency.ts"] },
|
||||
{ name: "check:provider-assets", cmd: ["node", "scripts/check/check-provider-assets.mjs"] },
|
||||
{ name: "check:public-creds", cmd: ["node", "scripts/check/check-public-creds.mjs"] },
|
||||
{ name: "check:error-helper", cmd: ["node", "scripts/check/check-error-helper.mjs"] },
|
||||
{ name: "check:fetch-targets", cmd: ["node", "scripts/check/check-fetch-targets.mjs"] },
|
||||
{ name: "check:openapi-routes", cmd: ["node", "scripts/check/check-openapi-routes.mjs"] },
|
||||
{ name: "check:deps", cmd: ["node", "scripts/check/check-deps.mjs"] },
|
||||
|
||||
// Group C — moderate (<15s)
|
||||
{ name: "check:db-rules", cmd: ["node", "scripts/check/check-db-rules.mjs"] },
|
||||
{ name: "check:file-size", cmd: ["node", "scripts/check/check-file-size.mjs"] },
|
||||
{ name: "check:complexity", cmd: ["node", "scripts/check/check-complexity.mjs"] },
|
||||
{ name: "check:docs-symbols", cmd: ["node", "scripts/check/check-docs-symbols.mjs"] },
|
||||
{ name: "check:known-symbols", cmd: ["node", "--import", "tsx", "scripts/check/check-known-symbols.ts"] },
|
||||
{ name: "check:route-guard-membership", cmd: ["node", "--import", "tsx", "scripts/check/check-route-guard-membership.ts"] },
|
||||
{ name: "check:test-discovery", cmd: ["node", "scripts/check/check-test-discovery.mjs"] },
|
||||
{ name: "check:test-masking", cmd: ["node", "scripts/check/check-test-masking.mjs"] },
|
||||
|
||||
// Group D — slow (>15s); skipped with --fast
|
||||
{ name: "check:duplication", cmd: ["node", "scripts/check/check-duplication.mjs"], slow: true },
|
||||
{ name: "check:cycles", cmd: ["node", "scripts/check/check-cycles.mjs"], slow: true },
|
||||
];
|
||||
|
||||
const CONCURRENCY = 4;
|
||||
|
||||
/**
|
||||
* Run a single gate command, capturing last line of stdout/stderr.
|
||||
* @param {{ name: string, cmd: string[] }} gate
|
||||
* @returns {Promise<{ name: string, exitCode: number, durationMs: number, lastLine: string }>}
|
||||
*/
|
||||
function runGate(gate) {
|
||||
return new Promise((resolve) => {
|
||||
const start = Date.now();
|
||||
const [bin, ...args] = gate.cmd;
|
||||
const proc = spawn(bin, args, { stdio: ["ignore", "pipe", "pipe"], cwd: process.cwd() });
|
||||
|
||||
const lines = [];
|
||||
const collectLine = (chunk) => {
|
||||
const text = chunk.toString();
|
||||
for (const line of text.split("\n")) {
|
||||
const t = line.trim();
|
||||
if (t) lines.push(t);
|
||||
}
|
||||
};
|
||||
|
||||
proc.stdout.on("data", collectLine);
|
||||
proc.stderr.on("data", collectLine);
|
||||
|
||||
proc.on("close", (code) => {
|
||||
const durationMs = Date.now() - start;
|
||||
const lastLine = lines[lines.length - 1] ?? "";
|
||||
resolve({ name: gate.name, exitCode: code ?? 1, durationMs, lastLine });
|
||||
});
|
||||
|
||||
proc.on("error", (err) => {
|
||||
const durationMs = Date.now() - start;
|
||||
resolve({ name: gate.name, exitCode: 1, durationMs, lastLine: err.message });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Run gates with a concurrency pool.
|
||||
* @param {typeof GATES} gates
|
||||
* @param {number} concurrency
|
||||
* @returns {Promise<ReturnType<typeof runGate>[]>}
|
||||
*/
|
||||
async function runWithPool(gates, concurrency) {
|
||||
const results = [];
|
||||
let idx = 0;
|
||||
|
||||
async function worker() {
|
||||
while (idx < gates.length) {
|
||||
const gate = gates[idx++];
|
||||
const result = await runGate(gate);
|
||||
results.push(result);
|
||||
}
|
||||
}
|
||||
|
||||
const workers = Array.from({ length: Math.min(concurrency, gates.length) }, () => worker());
|
||||
await Promise.all(workers);
|
||||
return results;
|
||||
}
|
||||
|
||||
function formatTable(results) {
|
||||
const COL_NAME = 35;
|
||||
const COL_STATUS = 8;
|
||||
const COL_MS = 10;
|
||||
const COL_MSG = 60;
|
||||
|
||||
const header =
|
||||
" " +
|
||||
"GATE".padEnd(COL_NAME) +
|
||||
"STATUS".padEnd(COL_STATUS) +
|
||||
"TIME(ms)".padEnd(COL_MS) +
|
||||
"LAST LINE";
|
||||
|
||||
const separator = " " + "─".repeat(COL_NAME + COL_STATUS + COL_MS + COL_MSG);
|
||||
|
||||
const rows = results.map((r) => {
|
||||
const status = r.exitCode === 0 ? "PASS" : "FAIL";
|
||||
const name = r.name.padEnd(COL_NAME);
|
||||
const statusCol = (r.exitCode === 0 ? "✔ " + status : "✗ " + status).padEnd(COL_STATUS + 2);
|
||||
const ms = String(r.durationMs).padStart(6) + "ms ";
|
||||
const msg = r.lastLine.slice(0, COL_MSG);
|
||||
return ` ${name}${statusCol}${ms}${msg}`;
|
||||
});
|
||||
|
||||
return [separator, header, separator, ...rows, separator].join("\n");
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const gates = FAST_ONLY ? GATES.filter((g) => !g.slow) : GATES;
|
||||
|
||||
console.log(`\n[quality:scan] Running ${gates.length} gate(s) with concurrency=${CONCURRENCY}...\n`);
|
||||
const wallStart = Date.now();
|
||||
|
||||
const results = await runWithPool(gates, CONCURRENCY);
|
||||
|
||||
const wallMs = Date.now() - wallStart;
|
||||
const failed = results.filter((r) => r.exitCode !== 0);
|
||||
const passed = results.filter((r) => r.exitCode === 0);
|
||||
|
||||
console.log(formatTable(results));
|
||||
console.log(
|
||||
`\n Summary: ${passed.length} passed, ${failed.length} failed` +
|
||||
` | Total: ${(wallMs / 1000).toFixed(1)}s (wall clock)\n`
|
||||
);
|
||||
|
||||
if (failed.length > 0) {
|
||||
console.error(`[quality:scan] FAIL — ${failed.length} gate(s) failed:`);
|
||||
for (const r of failed) {
|
||||
console.error(` ✗ ${r.name}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("[quality:scan] All gates passed.");
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();
|
||||
@@ -0,0 +1,62 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const HUB_RE = /(setupPolyfill|tsconfig|package\.json|package-lock\.json|\.env|vitest\.config|stryker\.conf)/;
|
||||
// A changed file counts as a "run-it" test ONLY if it is a node:test unit file the TIA
|
||||
// step can actually run via `node --test` — i.e. it mirrors the `npm run test:unit` glob.
|
||||
// This EXCLUDES vitest files (`.test.tsx`, `tests/unit/autoCombo/**`), e2e and integration
|
||||
// tests, and `src/**/__tests__`/`open-sse/**/__tests__`, which can't run under node:test.
|
||||
const UNIT_SUBDIRS =
|
||||
"api|auth|authz|build|cli|cli-helper|compression|correctness|cors|dashboard|db|db-adapters|docs|gamification|guardrails|lib|mcp|runtime|security|services|settings|shared|ui";
|
||||
const TEST_RE = new RegExp(`^tests/unit/([^/]+\\.test\\.ts$|(${UNIT_SUBDIRS})/.*\\.test\\.ts$)`);
|
||||
|
||||
export function selectImpacted({ changed, map }) {
|
||||
const out = new Set();
|
||||
for (const f of changed) {
|
||||
if (HUB_RE.test(f)) return ["__RUN_ALL__"];
|
||||
if (TEST_RE.test(f)) {
|
||||
out.add(f);
|
||||
continue;
|
||||
}
|
||||
const isSource =
|
||||
f.startsWith("src/") ||
|
||||
f.startsWith("open-sse/") ||
|
||||
f.startsWith("electron/") ||
|
||||
f.startsWith("bin/");
|
||||
if (!isSource) continue;
|
||||
const hits = map.sources[f];
|
||||
if (!hits) return ["__RUN_ALL__"];
|
||||
hits.forEach((t) => out.add(t));
|
||||
}
|
||||
return [...out].sort();
|
||||
}
|
||||
|
||||
function changedFiles() {
|
||||
const baseRef = process.env.GITHUB_BASE_REF;
|
||||
const baseTarget = process.env.GITHUB_BASE_SHA || (baseRef ? `origin/${baseRef}` : "HEAD~1");
|
||||
const stdout = execFileSync(
|
||||
"git",
|
||||
["diff", "--name-only", "--diff-filter=ACMR", `${baseTarget}...HEAD`],
|
||||
{ cwd: ROOT, encoding: "utf8" }
|
||||
);
|
||||
return stdout
|
||||
.split("\n")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
const mapPath = path.join(ROOT, "config/quality/test-impact-map.json");
|
||||
let map;
|
||||
try {
|
||||
map = JSON.parse(fs.readFileSync(mapPath, "utf8"));
|
||||
} catch {
|
||||
console.log("__RUN_ALL__");
|
||||
process.exit(0);
|
||||
}
|
||||
const sel = selectImpacted({ changed: changedFiles(), map });
|
||||
process.stdout.write(sel.join("\n") + "\n");
|
||||
}
|
||||
@@ -0,0 +1,511 @@
|
||||
#!/usr/bin/env node
|
||||
// scripts/quality/validate-release-green.mjs
|
||||
//
|
||||
// "Release-green" pre-flight validator (Solution C).
|
||||
//
|
||||
// WHY: the full gate (ci.yml — unit shards, vitest, ratchets, package-artifact)
|
||||
// runs ONLY on the release PR (PR → main). PRs into release/** only get the
|
||||
// fast-gates (quality.yml: TIA-impacted tests + typecheck + lint checks). So
|
||||
// reds accumulate silently on the release branch and explode — in layers — at
|
||||
// release time. This script reproduces the release-equivalent validation against
|
||||
// the CURRENT working tree so the maintainer (or the nightly, Solution D) can see
|
||||
// the real state of the release branch at any time.
|
||||
//
|
||||
// DESIGN — never blocking to contributors:
|
||||
// • HARD checks (typecheck, lint errors, db-rules, public-creds, docs-all,
|
||||
// unit, vitest, integration, optionally package-artifact) → a failure here is
|
||||
// a real defect; exit 1.
|
||||
// • DRIFT checks (eslint WARNINGS, cognitive-complexity, file-size, cyclomatic
|
||||
// complexity, dead-code, type-coverage, compression-budget, openapi-coverage,
|
||||
// workflow-lint/zizmor, codeql-ratchet) → ratchet drift accrued across the
|
||||
// cycle is NOT a contributor's fault; it is reported and rebaselined by the
|
||||
// maintainer at release. Drift NEVER changes the exit code, so wiring this as
|
||||
// a check can never block anyone on drift.
|
||||
//
|
||||
// COMPLETENESS: this mirrors the FULL release-PR gate set (quality-gate +
|
||||
// quality-extended + docs-sync-strict + integration), not a subset — and reports
|
||||
// EVERY red in one pass (the report is collected, not fail-fast), so the release
|
||||
// PR is green on its first CI run instead of revealing reds in ~40-min layers. The
|
||||
// only release-PR gates it cannot reproduce locally are GitHub-side CodeQL semantic
|
||||
// analysis and SonarQube/SonarCloud (external services).
|
||||
//
|
||||
// This script DIAGNOSES + REPORTS only (no auto-fix). The fix-to-green
|
||||
// orchestration lives in the /green-prs + review-prs flows that call it.
|
||||
//
|
||||
// Usage:
|
||||
// node scripts/quality/validate-release-green.mjs [--json] [--with-build] [--quick] [--hermetic]
|
||||
// --json emit machine-readable JSON to stdout (report goes to stderr)
|
||||
// --with-build also run check:pack-artifact (needs a dist/ build — slow)
|
||||
// --quick skip the slow unit + vitest + integration suites (drift + fast
|
||||
// gates only)
|
||||
// --hermetic scrub OMNIROUTE_API_KEY/OMNIROUTE_URL from gate env so live
|
||||
// tests self-skip exactly like CI (dev machines otherwise run
|
||||
// them against localhost and produce false-positive reds)
|
||||
//
|
||||
// Per-gate output is saved to _artifacts/release-green/<gate>.log (gitignored) —
|
||||
// diagnose a red from the file instead of re-running the gate.
|
||||
|
||||
import { execFile, execFileSync } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(__dirname, "..", "..");
|
||||
const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm";
|
||||
|
||||
// Per-gate captured output. execFileSync buffers everything and the report only
|
||||
// shows a one-line summary, so without these files every red requires RE-RUNNING
|
||||
// the gate just to see the detail (the dominant cost of the 2026-07-05 pre-flight).
|
||||
const LOG_DIR = join(ROOT, "_artifacts", "release-green");
|
||||
function saveGateLog(id, out) {
|
||||
try {
|
||||
mkdirSync(LOG_DIR, { recursive: true });
|
||||
writeFileSync(join(LOG_DIR, `${id}.log`), String(out ?? ""));
|
||||
} catch {
|
||||
/* log persistence is best-effort — never fails a gate */
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Pure helpers (exported for tests) ──────────────────────────────────────
|
||||
|
||||
/** Read the committed ratchet baseline value for a metric (null if unknown). */
|
||||
export function baselineValue(metric, root = ROOT) {
|
||||
try {
|
||||
const raw = JSON.parse(
|
||||
readFileSync(join(root, "config/quality/quality-baseline.json"), "utf8")
|
||||
);
|
||||
const metrics = raw.metrics || raw;
|
||||
const v = metrics?.[metric]?.value;
|
||||
return typeof v === "number" ? v : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Best-effort "first meaningful failure line" from captured command output. */
|
||||
export function firstFailureLine(out) {
|
||||
const lines = String(out || "")
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
const hit = lines.find((l) => /✖|not ok|AssertionError|error TS|FAIL|Error:|REGRESS/i.test(l));
|
||||
return (hit || lines[lines.length - 1] || "failed").slice(0, 200);
|
||||
}
|
||||
|
||||
/** Sum {errorCount,warningCount} across an eslint --format json result array. */
|
||||
export function eslintCounts(parsed) {
|
||||
let errors = 0;
|
||||
let warnings = 0;
|
||||
for (const f of parsed || []) {
|
||||
errors += f.errorCount || 0;
|
||||
warnings += f.warningCount || 0;
|
||||
}
|
||||
return { errors, warnings };
|
||||
}
|
||||
|
||||
/** Parse the eslint JSON array out of mixed stdout (tolerates a leading banner). */
|
||||
export function parseEslintJson(out) {
|
||||
const start = String(out || "").indexOf("[");
|
||||
if (start < 0) return null;
|
||||
try {
|
||||
return JSON.parse(String(out).slice(start));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Pull the cognitive-complexity violation count from the gate's output. */
|
||||
export function parseCognitiveCount(out) {
|
||||
const m = String(out || "").match(/(\d+)\s+(?:function\(s\) exceed|violações|violations)/i);
|
||||
return m ? Number(m[1]) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drift verdict for a ratchet: a metric that grew past its committed baseline is
|
||||
* "drift" (reported, never blocking). `direction:"down"` metrics (warnings,
|
||||
* complexity, file-size counts) regress when current > baseline.
|
||||
*/
|
||||
export function isDrift(current, baseline) {
|
||||
if (typeof current !== "number" || typeof baseline !== "number") return false;
|
||||
return current > baseline;
|
||||
}
|
||||
|
||||
/** releaseGreen iff there are zero failing HARD checks (drift never blocks). */
|
||||
export function computeVerdict(results) {
|
||||
const hardFailures = results.filter((r) => r.kind === "hard" && !r.ok);
|
||||
const drift = results.filter((r) => r.kind === "drift" && !r.ok);
|
||||
return { releaseGreen: hardFailures.length === 0, hardFailures, drift };
|
||||
}
|
||||
|
||||
// ─── Orchestration (only when run directly) ─────────────────────────────────
|
||||
|
||||
/**
|
||||
* Map a thrown `execFileSync` error to a {code, out} gate result. Exported as a pure helper
|
||||
* so the timeout/hang path has a regression test: a gate that exceeds its ceiling (e.g. the unit
|
||||
* suite wedged on an unreleased SQLite handle — see CLAUDE.md "Database Handles in Tests") is
|
||||
* killed by `execFileSync` (`err.killed === true`) and MUST surface as a visible non-zero gate,
|
||||
* never an infinite block that the release captain mistakes for a hang and kills the pre-flight.
|
||||
*/
|
||||
export function classifyRunError(err, timeoutMs) {
|
||||
if (err && err.killed && timeoutMs) {
|
||||
return {
|
||||
code: 124,
|
||||
out: `gate exceeded its ${Math.round(timeoutMs / 1000)}s ceiling and was killed — treat as a hung/failed gate (e.g. an unreleased DB handle in the unit suite); does NOT pass`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
code: typeof err?.status === "number" ? err.status : 1,
|
||||
out: `${err?.stdout || ""}${err?.stderr || ""}`,
|
||||
};
|
||||
}
|
||||
|
||||
// --hermetic: scrub the live-test trigger vars so the pre-flight behaves like CI
|
||||
// (a dev machine with OMNIROUTE_API_KEY set runs 17+ live tests that CI skips —
|
||||
// every one a false-positive red against the release branch).
|
||||
const HERMETIC_SCRUB = ["OMNIROUTE_API_KEY", "OMNIROUTE_URL"];
|
||||
let hermetic = false;
|
||||
function buildGateEnv(extra) {
|
||||
const env = { ...process.env, FORCE_COLOR: "0", ...(extra || {}) };
|
||||
if (hermetic) for (const k of HERMETIC_SCRUB) delete env[k];
|
||||
return env;
|
||||
}
|
||||
|
||||
function run(cmd, cmdArgs, opts = {}) {
|
||||
try {
|
||||
const out = execFileSync(cmd, cmdArgs, {
|
||||
cwd: ROOT,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
maxBuffer: 256 * 1024 * 1024,
|
||||
env: buildGateEnv(opts.env),
|
||||
// A hard ceiling for the long, silent test suites (execFileSync buffers all output until
|
||||
// exit, so they show no progress while running). undefined = no timeout for fast gates.
|
||||
...(opts.timeout ? { timeout: opts.timeout } : {}),
|
||||
});
|
||||
return { code: 0, out };
|
||||
} catch (err) {
|
||||
return classifyRunError(err, opts.timeout);
|
||||
}
|
||||
}
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
// Async twin of run() — same {code, out} contract, so the slow suites (unit /
|
||||
// vitest / integration / pack-artifact) can run CONCURRENTLY instead of in
|
||||
// series. Sequentially they dominate the pre-flight wall time (~2h in the
|
||||
// v3.8.45 run); they are independent processes with per-process DATA_DIR
|
||||
// isolation, so overlapping them cuts the pre-flight to ~the slowest single one.
|
||||
async function runAsync(cmd, cmdArgs, opts = {}) {
|
||||
try {
|
||||
const { stdout, stderr } = await execFileAsync(cmd, cmdArgs, {
|
||||
cwd: ROOT,
|
||||
encoding: "utf8",
|
||||
maxBuffer: 256 * 1024 * 1024,
|
||||
env: buildGateEnv(opts.env),
|
||||
...(opts.timeout ? { timeout: opts.timeout } : {}),
|
||||
});
|
||||
return { code: 0, out: `${stdout || ""}${stderr || ""}` };
|
||||
} catch (err) {
|
||||
return classifyRunError(err, opts.timeout);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = new Set(process.argv.slice(2));
|
||||
const JSON_OUT = args.has("--json");
|
||||
const WITH_BUILD = args.has("--with-build");
|
||||
const QUICK = args.has("--quick");
|
||||
hermetic = args.has("--hermetic");
|
||||
|
||||
const results = [];
|
||||
const record = (r) => {
|
||||
results.push(r);
|
||||
const icon = r.ok ? "✅" : r.kind === "drift" ? "🟡" : "❌";
|
||||
process.stderr.write(`${icon} [${r.kind}] ${r.label}${r.detail ? ` — ${r.detail}` : ""}\n`);
|
||||
};
|
||||
|
||||
// Announce a gate BEFORE running it. The long suites (unit/vitest/integration) run silently
|
||||
// for many minutes (execFileSync buffers their output until exit), which previously looked like
|
||||
// a hang and got the pre-flight killed before it surfaced the unit reds (the v3.8.42 miss).
|
||||
const announce = (label) => process.stderr.write(`▶ ${label}…\n`);
|
||||
|
||||
const hardCmd = (id, label, cmd, cmdArgs, opts) => {
|
||||
announce(label);
|
||||
const { code, out } = run(cmd, cmdArgs, opts);
|
||||
saveGateLog(id, out);
|
||||
record({
|
||||
id,
|
||||
label,
|
||||
kind: "hard",
|
||||
ok: code === 0,
|
||||
detail: code === 0 ? "pass" : firstFailureLine(out),
|
||||
});
|
||||
};
|
||||
|
||||
// A ratchet command (check:complexity, check:dead-code, …) exits 1 ONLY on a
|
||||
// measured regression and self-skips (exit 0) when its tooling is absent — so a
|
||||
// non-zero exit here is drift to rebaseline at release, never a contributor block.
|
||||
// ALL checks run regardless of earlier failures (the report is collected, not
|
||||
// fail-fast) so one pass surfaces every red instead of revealing them in layers.
|
||||
const driftCmd = (id, label, cmd, cmdArgs, okDetail = "within baseline", opts) => {
|
||||
announce(label);
|
||||
const { code, out } = run(cmd, cmdArgs, opts);
|
||||
saveGateLog(id, out);
|
||||
record({
|
||||
id,
|
||||
label,
|
||||
kind: "drift",
|
||||
ok: code === 0,
|
||||
detail: code === 0 ? okDetail : firstFailureLine(out),
|
||||
});
|
||||
};
|
||||
|
||||
process.stderr.write("🔎 Release-green validation (current working tree)\n\n");
|
||||
|
||||
hardCmd("typecheck", "Typecheck (core)", npmCmd, ["run", "typecheck:core"]);
|
||||
|
||||
// ESLint: ONE pass → errors (hard) + warnings (drift)
|
||||
{
|
||||
announce("ESLint (errors + warnings — ~5-15min)");
|
||||
// Suppressions-aware, matching `npm run lint` (Pacote 4 no-new-warnings): the frozen
|
||||
// pre-existing debt in config/quality/eslint-suppressions.json must not count as
|
||||
// errors here — only NET-NEW violations are release reds. Timeout raised: a full
|
||||
// repo pass takes ~14min alone and this pre-flight often runs alongside test suites.
|
||||
const { out } = run(
|
||||
"npx",
|
||||
[
|
||||
"eslint",
|
||||
".",
|
||||
"--format",
|
||||
"json",
|
||||
"--suppressions-location",
|
||||
"config/quality/eslint-suppressions.json",
|
||||
],
|
||||
{ timeout: 30 * 60 * 1000 }
|
||||
);
|
||||
saveGateLog("lint", out);
|
||||
const parsed = parseEslintJson(out);
|
||||
if (!parsed) {
|
||||
record({
|
||||
id: "lint",
|
||||
label: "ESLint",
|
||||
kind: "hard",
|
||||
ok: false,
|
||||
detail: "could not parse eslint json",
|
||||
});
|
||||
} else {
|
||||
const { errors, warnings } = eslintCounts(parsed);
|
||||
record({
|
||||
id: "lint-errors",
|
||||
label: "ESLint errors",
|
||||
kind: "hard",
|
||||
ok: errors === 0,
|
||||
detail: `${errors} error(s)`,
|
||||
});
|
||||
const base = baselineValue("eslintWarnings");
|
||||
const over = isDrift(warnings, base);
|
||||
record({
|
||||
id: "eslint-warnings",
|
||||
label: "ESLint warnings (ratchet)",
|
||||
kind: "drift",
|
||||
ok: !over,
|
||||
detail:
|
||||
base == null
|
||||
? `${warnings} (no baseline)`
|
||||
: `${warnings} vs baseline ${base}${over ? ` (+${warnings - base} drift → rebaseline at release)` : ""}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
hardCmd("db-rules", "DB rules", npmCmd, ["run", "check:db-rules"]);
|
||||
hardCmd("public-creds", "Public creds", npmCmd, ["run", "check:public-creds"]);
|
||||
|
||||
// Cognitive-complexity (drift)
|
||||
{
|
||||
announce("Cognitive complexity (ratchet)");
|
||||
const { out } = run(npmCmd, ["run", "check:cognitive-complexity"]);
|
||||
saveGateLog("cognitive", out);
|
||||
const current = parseCognitiveCount(out);
|
||||
const base = baselineValue("cognitiveComplexity");
|
||||
const over = isDrift(current, base);
|
||||
record({
|
||||
id: "cognitive-complexity",
|
||||
label: "Cognitive complexity (ratchet)",
|
||||
kind: "drift",
|
||||
ok: !over,
|
||||
detail:
|
||||
current == null
|
||||
? "could not parse count"
|
||||
: `${current} vs baseline ${base}${over ? ` (+${current - base} drift → rebaseline at release)` : ""}`,
|
||||
});
|
||||
}
|
||||
|
||||
// file-size (drift)
|
||||
{
|
||||
const { code, out } = run(npmCmd, ["run", "check:file-size"]);
|
||||
record({
|
||||
id: "file-size",
|
||||
label: "File-size ratchet",
|
||||
kind: "drift",
|
||||
ok: code === 0,
|
||||
detail: code === 0 ? "within frozen caps" : firstFailureLine(out),
|
||||
});
|
||||
}
|
||||
|
||||
// test-masking (hard) — a PR-context gate: it only runs on the release PR (PR→main) in CI, so
|
||||
// net-assert reductions accrue unseen on release/** and explode on the release PR. Reproduce it
|
||||
// here against origin/main so a non-allowlisted reduction surfaces in the pre-flight, not in a
|
||||
// ~40-min CI layer (v3.8.43 cost 3 such round-trips). Legitimate reductions get allowlisted in
|
||||
// config/quality/test-masking-allowlist.json; tautology/skip/deletion signals are never allowlistable.
|
||||
if (!QUICK) {
|
||||
announce("Test-masking (weakened-assert guard vs main)");
|
||||
// best-effort fetch so the merge-base diff is accurate; ignore fetch failure (offline pre-flight)
|
||||
run("git", ["fetch", "--no-tags", "origin", "main", "--depth=200"], { timeout: 60 * 1000 });
|
||||
const { code, out } = run(npmCmd, ["run", "check:test-masking"], {
|
||||
env: { GITHUB_BASE_REF: "main" },
|
||||
});
|
||||
saveGateLog("test-masking", out);
|
||||
record({
|
||||
id: "test-masking",
|
||||
label: "Test-masking (weakened-assert guard)",
|
||||
kind: "hard",
|
||||
ok: code === 0,
|
||||
detail: code === 0 ? "no weakening" : firstFailureLine(out),
|
||||
});
|
||||
}
|
||||
|
||||
// Remaining quality-gate / quality-extended ratchets that the PR→release
|
||||
// fast-gates skip and that historically surfaced — one at a time, because the
|
||||
// CI Quality Ratchet job is fail-fast — only on the release PR. Running them all
|
||||
// here (drift, never blocking) means a single rebaseline pass at release.
|
||||
driftCmd("complexity", "Cyclomatic complexity (ratchet)", npmCmd, ["run", "check:complexity"]);
|
||||
driftCmd("dead-code", "Dead-code (ratchet)", npmCmd, ["run", "check:dead-code"]);
|
||||
driftCmd("type-coverage", "Type coverage (ratchet)", npmCmd, ["run", "check:type-coverage"]);
|
||||
driftCmd("compression-budget", "Compression budget (ratchet)", npmCmd, [
|
||||
"run",
|
||||
"check:compression-budget",
|
||||
]);
|
||||
driftCmd("openapi-coverage", "OpenAPI route coverage (ratchet)", npmCmd, [
|
||||
"run",
|
||||
"check:openapi-coverage",
|
||||
]);
|
||||
driftCmd("workflow-lint", "Workflow lint (zizmor ratchet)", npmCmd, [
|
||||
"run",
|
||||
"check:workflows",
|
||||
"--",
|
||||
"--ratchet",
|
||||
]);
|
||||
driftCmd("codeql-ratchet", "CodeQL alerts (ratchet)", npmCmd, ["run", "check:codeql-ratchet"]);
|
||||
|
||||
// Docs sync + fabricated-docs (strict) is a real-defect gate (invented env vars /
|
||||
// routes, i18n mirror drift) — HARD.
|
||||
hardCmd("docs-all", "Docs sync + fabricated-docs (strict)", npmCmd, ["run", "check:docs-all"]);
|
||||
|
||||
if (!QUICK) {
|
||||
// These are the gates that catch inherited base-red tests from cycle PRs (the fast-path
|
||||
// PR→release does NOT run unit/vitest/integration per-PR — the v3.8.42 release PR exploded
|
||||
// with 15 such reds). They run SILENTLY for many minutes; the announce line above + these
|
||||
// hard ceilings keep a long-but-healthy run from being mistaken for a hang (the ceiling also
|
||||
// converts a genuine DB-handle hang into a visible failure instead of an infinite block).
|
||||
// The slow suites are INDEPENDENT processes (each self-isolates DATA_DIR) with
|
||||
// no shared state, so they run CONCURRENTLY — the pre-flight wall time becomes
|
||||
// ~the slowest single suite instead of their sum (unit ~25-35min + vitest
|
||||
// ~3-8min + integration ~3-10min + pack-artifact ~15min was ~1h serial in the
|
||||
// v3.8.45 run). pack-artifact (--with-build) joins the same wave. Integration
|
||||
// runs ONLY on the release PR full CI, so a regression here is invisible until
|
||||
// release — that is why it is a HARD pre-flight gate.
|
||||
const slow = [
|
||||
{
|
||||
id: "unit",
|
||||
label: "Unit tests (full suite, CI concurrency — runs ~20-35min silently)",
|
||||
args: ["run", "test:unit:ci"],
|
||||
timeout: 45 * 60 * 1000,
|
||||
},
|
||||
{
|
||||
id: "vitest",
|
||||
label: "Vitest (MCP / autoCombo / cache — ~3-8min)",
|
||||
args: ["run", "test:vitest"],
|
||||
timeout: 15 * 60 * 1000,
|
||||
},
|
||||
{
|
||||
id: "integration",
|
||||
label: "Integration tests (~3-10min)",
|
||||
args: ["run", "test:integration"],
|
||||
timeout: 20 * 60 * 1000,
|
||||
},
|
||||
];
|
||||
if (WITH_BUILD) {
|
||||
slow.push({
|
||||
id: "pack-artifact",
|
||||
label: "Package artifact (npm pack policy)",
|
||||
args: ["run", "check:pack-artifact"],
|
||||
timeout: 20 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
slow.forEach((g) => announce(`${g.label} [parallel]`));
|
||||
const slowResults = await Promise.all(
|
||||
slow.map((g) => runAsync(npmCmd, g.args, { timeout: g.timeout }))
|
||||
);
|
||||
slow.forEach((g, i) => {
|
||||
const { code, out } = slowResults[i];
|
||||
saveGateLog(g.id, out);
|
||||
record({
|
||||
id: g.id,
|
||||
label: g.label,
|
||||
kind: "hard",
|
||||
ok: code === 0,
|
||||
detail: code === 0 ? "pass" : firstFailureLine(out),
|
||||
});
|
||||
});
|
||||
} else if (WITH_BUILD) {
|
||||
// --with-build without the suites (--quick): still verify the package artifact.
|
||||
const { code, out } = await runAsync(npmCmd, ["run", "check:pack-artifact"], {
|
||||
timeout: 20 * 60 * 1000,
|
||||
});
|
||||
saveGateLog("pack-artifact", out);
|
||||
record({
|
||||
id: "pack-artifact",
|
||||
label: "Package artifact (npm pack policy)",
|
||||
kind: "hard",
|
||||
ok: code === 0,
|
||||
detail: code === 0 ? "pass" : firstFailureLine(out),
|
||||
});
|
||||
}
|
||||
|
||||
const { releaseGreen, hardFailures, drift } = computeVerdict(results);
|
||||
|
||||
process.stderr.write("\n──────── verdict ────────\n");
|
||||
process.stderr.write(`HARD failures (block — real defects): ${hardFailures.length}\n`);
|
||||
hardFailures.forEach((r) => process.stderr.write(` ❌ ${r.label}: ${r.detail}\n`));
|
||||
process.stderr.write(`Ratchet drift (non-blocking — rebaseline at release): ${drift.length}\n`);
|
||||
drift.forEach((r) => process.stderr.write(` 🟡 ${r.label}: ${r.detail}\n`));
|
||||
process.stderr.write(
|
||||
releaseGreen
|
||||
? "\n✅ RELEASE-GREEN (no hard failures). Any drift above is rebaselined at release, not a contributor concern.\n"
|
||||
: "\n❌ NOT release-green — hard failures must be fixed (in the originating PR branch, via co-authorship).\n"
|
||||
);
|
||||
|
||||
if (JSON_OUT) {
|
||||
process.stdout.write(
|
||||
JSON.stringify(
|
||||
{
|
||||
releaseGreen,
|
||||
hardFailures: hardFailures.map((r) => ({ id: r.id, label: r.label, detail: r.detail })),
|
||||
drift: drift.map((r) => ({ id: r.id, label: r.label, detail: r.detail })),
|
||||
checks: results.map((r) => ({ id: r.id, kind: r.kind, ok: r.ok, detail: r.detail })),
|
||||
},
|
||||
null,
|
||||
2
|
||||
) + "\n"
|
||||
);
|
||||
}
|
||||
|
||||
process.exit(releaseGreen ? 0 : 1);
|
||||
}
|
||||
|
||||
// Run only when invoked directly (so tests can import the pure helpers).
|
||||
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
||||
main();
|
||||
}
|
||||
Reference in New Issue
Block a user