chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
#!/usr/bin/env node
|
||||
// check-mutation-test-coverage — guards against tap.testFiles drift.
|
||||
//
|
||||
// WHY: Stryker (nightly-mutation) only runs the test files listed in
|
||||
// stryker.conf.json `tap.testFiles` against each mutant. When a NEW unit test
|
||||
// that covers a mutated module is added (or an existing one is split/renamed)
|
||||
// but NOT added to tap.testFiles, that test's kills stop counting. The mutants
|
||||
// it would kill then go COVERED-but-unkilled = SURVIVED on a cold run, so the
|
||||
// module's COVERED mutation score collapses and the blocking mutationScore
|
||||
// ratchet (nightly-mutation.yml) false-fails — but only on cold-cache nights,
|
||||
// because the warm incremental run reuses the older (passing) verdicts. The
|
||||
// pass/fail then tracks GitHub cache state, not code quality. Root cause:
|
||||
// tap.testFiles is a hand-maintained list with no drift guard. This is it.
|
||||
//
|
||||
// INVARIANT: every UNIT test (tests/unit/**) that imports a mutated module
|
||||
// (stryker.conf.json `mutate`) MUST be in `tap.testFiles`. Integration/e2e
|
||||
// tests are intentionally excluded (the tap-runner runs node:test units only).
|
||||
//
|
||||
// MODE: advisory by default (prints drift, exit 0). `--strict` exits 1 on drift
|
||||
// so CI can block. Skip-graceful (exit 0) if stryker.conf.json is absent.
|
||||
//
|
||||
// USAGE:
|
||||
// node scripts/check/check-mutation-test-coverage.mjs # advisory
|
||||
// node scripts/check/check-mutation-test-coverage.mjs --strict # blocking
|
||||
|
||||
import fs from "node:fs";
|
||||
import { execFileSync } from "node:child_process";
|
||||
|
||||
/** 3-segment path suffix without extension (unique enough; matches relative + alias imports). */
|
||||
export function moduleFragment(modulePath) {
|
||||
return modulePath.replace(/\.ts$/, "").split("/").slice(-3).join("/");
|
||||
}
|
||||
|
||||
/**
|
||||
* True if `content` imports the module identified by `fragment`, in any form:
|
||||
* static `... from "…fragment…"`, dynamic `import("…fragment…")` (incl. split
|
||||
* across lines), or `require("…fragment…")`. The fragment must appear INSIDE the
|
||||
* import string literal — a bare mention in a comment does not match.
|
||||
*/
|
||||
export function testImportsModule(content, fragment) {
|
||||
const esc = fragment.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const re = new RegExp(
|
||||
"(?:from\\s+|\\bimport\\s*\\(\\s*|\\brequire\\s*\\(\\s*)[\"'][^\"']*" + esc
|
||||
);
|
||||
return re.test(content);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{mutate: string[], tapTestFiles: string[], unitTests: {path:string, content:string}[]}} input
|
||||
* @returns {Record<string,string[]>} module -> covering unit tests NOT in tap.testFiles (drift)
|
||||
*/
|
||||
export function findCoverageDrift({ mutate, tapTestFiles, unitTests }) {
|
||||
const tap = new Set(tapTestFiles);
|
||||
const drift = {};
|
||||
for (const mod of mutate) {
|
||||
if (mod.startsWith("_") || !mod.endsWith(".ts")) continue; // skip comment/non-ts entries
|
||||
const frag = moduleFragment(mod);
|
||||
const missing = unitTests
|
||||
.filter((t) => testImportsModule(t.content, frag) && !tap.has(t.path))
|
||||
.map((t) => t.path)
|
||||
.sort();
|
||||
if (missing.length > 0) drift[mod] = missing;
|
||||
}
|
||||
return drift;
|
||||
}
|
||||
|
||||
function listUnitTests() {
|
||||
// Static argv — no shell, no interpolation.
|
||||
const out = execFileSync("git", ["ls-files", "tests/unit"], { encoding: "utf8" });
|
||||
return out
|
||||
.split("\n")
|
||||
.filter((f) => /\.test\.ts$/.test(f))
|
||||
// Exclude tests/unit/build/: these test the build TOOLING (scripts/), not the
|
||||
// mutated runtime modules. They legitimately embed module paths as fixture
|
||||
// strings (e.g. this gate's own test), which would otherwise false-match.
|
||||
.filter((f) => !f.startsWith("tests/unit/build/"))
|
||||
.map((path) => ({ path, content: fs.readFileSync(path, "utf8") }));
|
||||
}
|
||||
|
||||
function main() {
|
||||
const STRICT = process.argv.includes("--strict");
|
||||
let conf;
|
||||
try {
|
||||
conf = JSON.parse(fs.readFileSync("stryker.conf.json", "utf8"));
|
||||
} catch {
|
||||
console.warn("[mutation-test-coverage] stryker.conf.json not found — skipping (advisory).");
|
||||
process.exit(0);
|
||||
}
|
||||
const mutate = (conf.mutate || []).filter((m) => typeof m === "string");
|
||||
const tapTestFiles = conf.tap?.testFiles || [];
|
||||
const unitTests = listUnitTests();
|
||||
|
||||
const drift = findCoverageDrift({ mutate, tapTestFiles, unitTests });
|
||||
const modules = Object.keys(drift);
|
||||
const total = modules.reduce((n, m) => n + drift[m].length, 0);
|
||||
|
||||
console.log("Mutation test-coverage gate — tap.testFiles drift detection");
|
||||
console.log("===========================================================");
|
||||
console.log(
|
||||
`Scanned ${unitTests.length} unit test file(s) against ${mutate.filter((m) => m.endsWith(".ts")).length} mutated module(s).`
|
||||
);
|
||||
|
||||
if (total === 0) {
|
||||
console.log("✓ No drift — every covering unit test is listed in tap.testFiles.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
for (const m of modules) {
|
||||
console.log(`\n ${m}`);
|
||||
for (const t of drift[m]) console.log(` + ${t}`);
|
||||
}
|
||||
const msg = `${total} covering unit test(s) across ${modules.length} module(s) are missing from stryker.conf.json tap.testFiles.`;
|
||||
if (STRICT) {
|
||||
console.error(`\n✗ ${msg} Add them so their mutant kills count (--strict).`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.warn(`\n⚠ ${msg} Re-run with --strict to fail. Add them to tap.testFiles.`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) main();
|
||||
Reference in New Issue
Block a user