Spaces:
Runtime error
Runtime error
File size: 12,483 Bytes
cd8bd0a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 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 β see
* docs/ops/MUTATION_GATE_SPIKE_VERDICT.md):
*
* π΄ 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);
}
|