mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-16 11:52:26 +03:00
chore(ci): gate the two blind spots that let silent debt accumulate
check:vitest-exclusions — a file in vitest.config.ts's exclude list is a test that does not run, indistinguishable from one that does not exist except that it looks like coverage. 62 files accumulated behind '// #8618 — pre-existing failure', while #8618 itself was closed on 2026-08-11 and the list grew from 45 to 62. The gate requires every exclusion to name a tracker and to appear in config/quality/vitest-exclusions.json with its measured status, so growth is a reviewable diff in a dedicated file. It does not re-run the tests — that is a periodic job, and the inventory records when each was last measured. check-new-key-coverage — sibling of check-ui-value-drift. That one catches a rewritten English value leaving stale translations; this one catches a new English key that some locales never received. check-ui-keys-coverage cannot: it is a percentage floor per locale, and 11 absent keys out of ~13,000 leaves it at 99.9%. Proven retroactively against the real incident — with BASE_REF set before the Phase 3 merge it flags all 11 canvas keys across exactly the nine EU locales that missed them, plus 3 keys from other features with the same problem. Both are diff-aware against the merge base, so pre-existing gaps stay frozen and neither needed a migration. Both wired into ci.yml and documented in docs/architecture/QUALITY_GATES.md, with 14 tests covering the pure cores. Refs #13204
This commit is contained in:
156
scripts/check/check-vitest-exclusions.mjs
Normal file
156
scripts/check/check-vitest-exclusions.mjs
Normal file
@@ -0,0 +1,156 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* OmniRoute — Vitest exclusion gate (CI gate, blocking).
|
||||
*
|
||||
* Every file parked in `vitest.config.ts`'s `exclude` list is a test that does not run.
|
||||
* A skipped test is indistinguishable from a test that does not exist, with the added
|
||||
* hazard of LOOKING like coverage to whoever reads the file tree.
|
||||
*
|
||||
* Why this gate exists (the incident it encodes): 62 files accumulated behind the comment
|
||||
* `// #8618 — pre-existing failure; remove this exclusion when fixed`. Issue #8618 was
|
||||
* CLOSED on 2026-08-11 while the list it tracked kept growing — from 45 entries to 62 —
|
||||
* each new exclusion inheriting a comment that pointed at a dead issue. When the list was
|
||||
* finally measured file by file (#13204), **51 of the 62 passed against the current tree
|
||||
* with no source change**: the exclusions had outlived the failures that justified them by
|
||||
* months, and nothing in CI could say so.
|
||||
*
|
||||
* The gate enforces the two properties that would have caught it:
|
||||
*
|
||||
* 1. Every excluded path that resolves to a real file carries an issue reference
|
||||
* (`#<number>`) in a trailing comment. An exclusion without a tracker is invisible
|
||||
* debt.
|
||||
* 2. The set of excluded files matches the checked-in inventory
|
||||
* (`config/quality/vitest-exclusions.json`). Adding an exclusion becomes a visible,
|
||||
* reviewable diff in a dedicated file instead of one more line lost in a 60-entry
|
||||
* array.
|
||||
*
|
||||
* What it deliberately does NOT do: re-run the excluded tests to see whether they pass
|
||||
* again. That costs ~10 minutes and belongs in a periodic job, not in a per-PR gate. The
|
||||
* inventory records the measured status and the date so a reader knows how stale it is.
|
||||
*
|
||||
* Standard tooling exclusions (`node_modules/**`, glob patterns, the live-server E2E specs
|
||||
* that have their own runner) are exempt — they are configuration, not debt.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/check/check-vitest-exclusions.mjs # strict, exit 1 on violation
|
||||
* node scripts/check/check-vitest-exclusions.mjs --json # machine-readable
|
||||
*/
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = path.resolve(SCRIPT_DIR, "..", "..");
|
||||
const CONFIG = path.join(ROOT, "vitest.config.ts");
|
||||
const INVENTORY = path.join(ROOT, "config/quality/vitest-exclusions.json");
|
||||
|
||||
/** Exclusions that are tooling configuration rather than parked debt. */
|
||||
const EXEMPT = new Set([
|
||||
"node_modules/**",
|
||||
"dist/**",
|
||||
"cypress/**",
|
||||
".idea/**",
|
||||
".git/**",
|
||||
".cache/**",
|
||||
// Live-server E2E: their own runner + vitest.e2e-live.config.ts, never this jsdom job.
|
||||
"tests/e2e/ecosystem.test.ts",
|
||||
"tests/e2e/protocol-clients.test.ts",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Parse the `exclude` array out of vitest.config.ts, keeping each entry's trailing comment.
|
||||
*
|
||||
* @returns {Array<{ pattern: string, comment: string }>}
|
||||
*/
|
||||
export function parseExclusions(source) {
|
||||
const block = source.match(/exclude:\s*\[([\s\S]*?)\n {4}\]/);
|
||||
if (!block) return [];
|
||||
const out = [];
|
||||
for (const line of block[1].split("\n")) {
|
||||
const pattern = line.match(/"([^"]+)"/);
|
||||
if (!pattern) continue;
|
||||
const comment = line.slice(line.indexOf(pattern[0]) + pattern[0].length);
|
||||
out.push({ pattern: pattern[1], comment: comment.trim() });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure core: which exclusions violate the gate?
|
||||
*
|
||||
* @param {Array<{pattern: string, comment: string}>} entries
|
||||
* @param {(p: string) => boolean} exists
|
||||
* @param {string[]} inventory paths recorded in the checked-in inventory
|
||||
*/
|
||||
export function findViolations(entries, exists, inventory) {
|
||||
const tracked = new Set(inventory);
|
||||
const untracked = [];
|
||||
const unreferenced = [];
|
||||
const seen = new Set();
|
||||
|
||||
for (const { pattern, comment } of entries) {
|
||||
if (EXEMPT.has(pattern) || pattern.includes("*")) continue;
|
||||
if (!exists(pattern)) continue; // a stale path excludes nothing
|
||||
seen.add(pattern);
|
||||
if (!/#\d+/.test(comment)) unreferenced.push(pattern);
|
||||
if (!tracked.has(pattern)) untracked.push(pattern);
|
||||
}
|
||||
|
||||
const orphaned = inventory.filter((p) => !seen.has(p));
|
||||
return { unreferenced, untracked, orphaned };
|
||||
}
|
||||
|
||||
function main() {
|
||||
const json = process.argv.includes("--json");
|
||||
const entries = parseExclusions(fs.readFileSync(CONFIG, "utf8"));
|
||||
const inventory = fs.existsSync(INVENTORY)
|
||||
? JSON.parse(fs.readFileSync(INVENTORY, "utf8")).excluded.map((e) => e.file)
|
||||
: [];
|
||||
|
||||
const { unreferenced, untracked, orphaned } = findViolations(
|
||||
entries,
|
||||
(p) => fs.existsSync(path.join(ROOT, p)),
|
||||
inventory
|
||||
);
|
||||
|
||||
if (json) {
|
||||
console.log(JSON.stringify({ unreferenced, untracked, orphaned }, null, 2));
|
||||
}
|
||||
|
||||
const failed = unreferenced.length + untracked.length + orphaned.length;
|
||||
if (!failed) {
|
||||
console.log(
|
||||
`[vitest-exclusions] OK — ${inventory.length} excluded file(s), each tracked and referenced.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (unreferenced.length) {
|
||||
console.error(
|
||||
`\n[vitest-exclusions] FAIL — ${unreferenced.length} exclusion(s) carry no issue reference:`
|
||||
);
|
||||
for (const p of unreferenced) console.error(` ✗ ${p}`);
|
||||
console.error(
|
||||
" Add a trailing comment naming an OPEN tracking issue, e.g. // #13204 — reason"
|
||||
);
|
||||
}
|
||||
if (untracked.length) {
|
||||
console.error(
|
||||
`\n[vitest-exclusions] FAIL — ${untracked.length} exclusion(s) missing from ${path.relative(ROOT, INVENTORY)}:`
|
||||
);
|
||||
for (const p of untracked) console.error(` ✗ ${p}`);
|
||||
console.error(" Record it there with its measured status, so the debt is reviewable.");
|
||||
}
|
||||
if (orphaned.length) {
|
||||
console.error(
|
||||
`\n[vitest-exclusions] FAIL — ${orphaned.length} inventory entr(ies) no longer excluded:`
|
||||
);
|
||||
for (const p of orphaned) console.error(` ✗ ${p}`);
|
||||
console.error(" The test runs again — drop it from the inventory.");
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) main();
|
||||
201
scripts/i18n/check-new-key-coverage.mjs
Normal file
201
scripts/i18n/check-new-key-coverage.mjs
Normal file
@@ -0,0 +1,201 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* OmniRoute — NEW-key i18n coverage gate (CI gate, blocking).
|
||||
*
|
||||
* Sibling of `check-ui-value-drift.mjs`. That one catches an English value that was
|
||||
* REWRITTEN while translations were left behind; this one catches an English key that was
|
||||
* ADDED while some locales never received it.
|
||||
*
|
||||
* Why no existing gate sees this (the incident it encodes): Phase 3 of the Orchestration
|
||||
* Canvas added eleven keys and translated them across the 42 locales that existed at the
|
||||
* time. Hours later the EU-language batch (#13044) took the repo to 51 locales. The nine
|
||||
* new files — el, et, ga, hr, lt, lv, mt, sl, sr — never received those eleven keys, so the
|
||||
* compare-runs panel rendered in English for those users.
|
||||
*
|
||||
* `check-ui-keys-coverage.mjs` could not catch it: it enforces an 80% floor PER LOCALE, and
|
||||
* eleven absent keys out of ~13,000 leaves coverage at 99.9%. A percentage per language
|
||||
* cannot express "this feature shipped untranslated" — an entire feature can land in a new
|
||||
* locale with no text and never move the number.
|
||||
*
|
||||
* `deepMergeFallback` (src/i18n/request.ts) does substitute English for an absent key, so
|
||||
* the failure mode is untranslated UI rather than blank UI. That is a real defect, not a
|
||||
* cosmetic one, and it is silent by construction.
|
||||
*
|
||||
* How this gate works: DIFF-AWARE, like its sibling. It compares the English catalog at the
|
||||
* merge base against the working tree; every key that is NEW in English must be present and
|
||||
* non-placeholder in every locale. Pre-existing gaps are deliberately frozen — this gate
|
||||
* judges only what the current change adds, so it can be turned on without a migration.
|
||||
*
|
||||
* Escape hatch, same as the sibling: set the value to `__MISSING__:<english>` to make the
|
||||
* runtime fall back to correct English and queue the key for the translation pipeline.
|
||||
* NOTE that `vi` bans placeholders (tests/unit/i18n-vi-completeness.test.ts), so `vi` needs
|
||||
* a real translation.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/i18n/check-new-key-coverage.mjs # strict, exit 1
|
||||
* node scripts/i18n/check-new-key-coverage.mjs --warn # report, exit 0
|
||||
* node scripts/i18n/check-new-key-coverage.mjs --json
|
||||
* BASE_REF=origin/release/vX.Y.Z node scripts/i18n/check-new-key-coverage.mjs
|
||||
*
|
||||
* Graceful SKIP (exit 0) when the base catalog cannot be resolved — shallow clone, or a
|
||||
* brand-new catalog. Mirrors the SKIP in check-ui-value-drift.mjs.
|
||||
*/
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = path.resolve(SCRIPT_DIR, "..", "..");
|
||||
const MESSAGES_REL = "src/i18n/messages";
|
||||
const PLACEHOLDER_PREFIX = "__MISSING__:";
|
||||
|
||||
/** Flatten a nested catalog into `{ "a.b.c": value }`. */
|
||||
export function flattenLeaves(node, prefix = "", out = {}) {
|
||||
for (const [key, value] of Object.entries(node ?? {})) {
|
||||
const dotted = prefix ? `${prefix}.${key}` : key;
|
||||
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
|
||||
flattenLeaves(value, dotted, out);
|
||||
} else {
|
||||
out[dotted] = value;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure core: which (key, locale) pairs are keys new in English that a locale never got?
|
||||
*
|
||||
* A `__MISSING__:` placeholder counts as satisfied — it is the documented, runtime-correct
|
||||
* way to defer a translation.
|
||||
*
|
||||
* @param {object} args
|
||||
* @param {object} args.baseEn en.json at the base ref
|
||||
* @param {object} args.headEn en.json in the working tree
|
||||
* @param {Record<string, object>} args.headLocales locale -> catalog in the working tree
|
||||
* @returns {Array<{ key: string, locale: string }>} sorted, stable
|
||||
*/
|
||||
export function findUntranslatedNewKeys({ baseEn, headEn, headLocales }) {
|
||||
const base = flattenLeaves(baseEn);
|
||||
const head = flattenLeaves(headEn);
|
||||
const newKeys = Object.keys(head).filter(
|
||||
(k) => !(k in base) && typeof head[k] === "string" && head[k].trim() !== ""
|
||||
);
|
||||
if (!newKeys.length) return [];
|
||||
|
||||
const gaps = [];
|
||||
for (const [locale, catalog] of Object.entries(headLocales)) {
|
||||
const flat = flattenLeaves(catalog);
|
||||
for (const key of newKeys) {
|
||||
const value = flat[key];
|
||||
const satisfied =
|
||||
typeof value === "string" && (value.trim() !== "" || value.startsWith(PLACEHOLDER_PREFIX));
|
||||
if (!satisfied) gaps.push({ key, locale });
|
||||
}
|
||||
}
|
||||
gaps.sort((a, b) => a.key.localeCompare(b.key) || a.locale.localeCompare(b.locale));
|
||||
return gaps;
|
||||
}
|
||||
|
||||
function git(args) {
|
||||
return execFileSync("git", args, {
|
||||
cwd: ROOT,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
}
|
||||
|
||||
function readPackageVersion() {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf8")).version;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function defaultBaseRef() {
|
||||
const v = readPackageVersion();
|
||||
return v && /^\d+\.\d+\.\d+$/.test(v) ? `origin/release/v${v}` : null;
|
||||
}
|
||||
|
||||
function resolveDiffBase(baseRef) {
|
||||
try {
|
||||
return git(["merge-base", "HEAD", baseRef]).trim();
|
||||
} catch {
|
||||
return baseRef;
|
||||
}
|
||||
}
|
||||
|
||||
function readCatalogAtRef(ref, relPath) {
|
||||
try {
|
||||
return JSON.parse(git(["show", `${ref}:${relPath}`]));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
const argv = process.argv.slice(2);
|
||||
const opts = { json: argv.includes("--json"), warn: argv.includes("--warn") };
|
||||
const baseRef = process.env.BASE_REF || defaultBaseRef();
|
||||
|
||||
const skip = (reason) => {
|
||||
if (opts.json) process.stdout.write(JSON.stringify({ ok: true, skipped: true, reason }) + "\n");
|
||||
else console.log(`[i18n-new-keys] SKIP reason=${reason}`);
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
if (!baseRef) skip("base-unresolved");
|
||||
const base = resolveDiffBase(baseRef);
|
||||
const baseEn = readCatalogAtRef(base, `${MESSAGES_REL}/en.json`);
|
||||
if (!baseEn) skip("base-catalog-unreadable");
|
||||
|
||||
const dir = path.join(ROOT, MESSAGES_REL);
|
||||
const headEn = JSON.parse(fs.readFileSync(path.join(dir, "en.json"), "utf8"));
|
||||
const headLocales = {};
|
||||
for (const file of fs.readdirSync(dir)) {
|
||||
if (!file.endsWith(".json") || file === "en.json") continue;
|
||||
try {
|
||||
headLocales[file.replace(/\.json$/, "")] = JSON.parse(
|
||||
fs.readFileSync(path.join(dir, file), "utf8")
|
||||
);
|
||||
} catch {
|
||||
/* a malformed catalog is another gate's problem */
|
||||
}
|
||||
}
|
||||
|
||||
const gaps = findUntranslatedNewKeys({ baseEn, headEn, headLocales });
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({ ok: gaps.length === 0, gaps }, null, 2));
|
||||
}
|
||||
|
||||
if (!gaps.length) {
|
||||
console.log(
|
||||
`[i18n-new-keys] PASS — every key new in English reached all ${Object.keys(headLocales).length} locale(s).`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const byKey = new Map();
|
||||
for (const g of gaps) {
|
||||
if (!byKey.has(g.key)) byKey.set(g.key, []);
|
||||
byKey.get(g.key).push(g.locale);
|
||||
}
|
||||
const label = opts.warn ? "WARN" : "FAIL";
|
||||
console.error(
|
||||
`\n[i18n-new-keys] ${label} — ${byKey.size} new English key(s) missing from some locales:`
|
||||
);
|
||||
for (const [key, locales] of byKey) {
|
||||
console.error(` ✗ ${key} — missing in ${locales.length}: ${locales.join(", ")}`);
|
||||
}
|
||||
console.error(
|
||||
"\n Translate them, or set `__MISSING__:<english>` to defer (the runtime then falls back\n" +
|
||||
" to English). `vi` bans placeholders — it needs a real translation."
|
||||
);
|
||||
if (!opts.warn) process.exit(1);
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) main();
|
||||
Reference in New Issue
Block a user