feat(quality): Onda 2 mutation-gate tooling — radiography classifier (T1) + mutationScore ratchet (T3) (#4234)

Integrated into release/v3.8.29
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-19 00:01:48 -03:00
committed by GitHub
parent 5847610cc7
commit 35166dcfe3
5 changed files with 586 additions and 0 deletions

View File

@@ -146,6 +146,7 @@
"check:lockfile": "node scripts/check/check-lockfile.mjs",
"check:bundle-size": "node scripts/check/check-bundle-size.mjs",
"check:circular-deps": "node scripts/check/check-circular-deps.mjs",
"check:mutation-ratchet": "node scripts/check/check-mutation-ratchet.mjs",
"check:licenses": "node scripts/check/check-licenses.mjs",
"check:pr-evidence": "node scripts/check/check-pr-evidence.mjs",
"check:vuln-ratchet": "node scripts/check/check-vuln-ratchet.mjs",

View File

@@ -0,0 +1,157 @@
#!/usr/bin/env node
// scripts/check/check-mutation-ratchet.mjs
// Catraca de mutationScore (Quality Gate v2 / Fase 9 T5 — Onda 2, Task 3).
//
// Mirrors check-bundle-size.mjs: ADVISORY by default (always exit 0), BLOCKING only
// with --ratchet — and even then exits 1 SE — E SOMENTE SE — um módulo medido REGREDIU
// vs o baseline (direction: UP, o score só pode subir). Skip gracioso (exit 0) quando
// não há mutation.json (ex.: o nightly não rodou) ou não há baseline para o módulo —
// falta de dados NUNCA bloqueia, só uma regressão medida bloqueia.
//
// Score por módulo = COVERED score = detected / (detected + survived), onde
// detected = Killed + Timeout. NoCoverage é EXCLUÍDO do denominador (é uma lacuna de
// cobertura, não um sinal de qualidade-de-teste) — mesmo denominador que a radiografia
// (scripts/quality/mutation-radiography.mjs).
//
// O nightly divide o `mutate` em batches paralelos (um reports/mutation/mutation.json
// por job). Este script roda DENTRO de cada job sobre o report daquele batch e compara
// só os módulos presentes nele. Aceita vários paths para uso local/agregado.
//
// Uso:
// node scripts/check/check-mutation-ratchet.mjs (advisory; report default)
// node scripts/check/check-mutation-ratchet.mjs reports/mutation/mutation.json
// node scripts/check/check-mutation-ratchet.mjs <a.json> <b.json> ... --ratchet
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = process.cwd();
const BASELINE_PATH = path.join(ROOT, "config/quality/quality-baseline.json");
const DEFAULT_REPORT = path.join(ROOT, "reports/mutation/mutation.json");
const BASELINE_PREFIX = "mutationScore.";
const RATCHET = process.argv.includes("--ratchet");
const DETECTED = new Set(["Killed", "Timeout"]);
const SURVIVED = new Set(["Survived"]);
/**
* Avalia o score MEDIDO de um módulo contra o baseline. Direction: UP (o score só
* pode subir — menor = regressão).
* @param {number} current
* @param {number} baseline
* @returns {{ regressed: boolean, improved: boolean }}
*/
export function evaluateMutationRatchet(current, baseline) {
return {
regressed: current < baseline,
improved: current > baseline,
};
}
/**
* Covered mutation score de um arquivo: detected/(detected+survived)*100.
* NoCoverage/Ignored/RuntimeError/CompileError ficam fora do denominador.
* @param {{mutants?: Array<{status: string}>}} fileData
* @returns {number|null} score em %, ou null se não houver mutantes cobertos
*/
export function mutationScoreForFile(fileData) {
let detected = 0;
let survived = 0;
for (const m of fileData?.mutants || []) {
if (DETECTED.has(m.status)) detected += 1;
else if (SURVIVED.has(m.status)) survived += 1;
}
const denom = detected + survived;
return denom === 0 ? null : (detected / denom) * 100;
}
/**
* Score por arquivo a partir de um ou mais reports (batches). Arquivos sem mutante
* coberto (score null) são omitidos.
* @param {object|object[]} reportOrReports parsed mutation.json (ou array)
* @returns {Record<string, number>}
*/
export function measureMutationScores(reportOrReports) {
const reports = Array.isArray(reportOrReports) ? reportOrReports : [reportOrReports];
const out = {};
for (const report of reports) {
for (const [file, data] of Object.entries(report?.files || {})) {
const score = mutationScoreForFile(data);
if (score !== null) out[file] = score;
}
}
return out;
}
/**
* Lê metrics["mutationScore.<path>"].value do quality-baseline.json.
* Retorna {} se o arquivo ou as chaves estiverem ausentes (sem baseline não há
* ratchet possível — o caller trata como SKIP gracioso, exit 0).
* @param {string} baselinePath
* @returns {Record<string, number>}
*/
export function readBaselineMutationScores(baselinePath = BASELINE_PATH) {
if (!fs.existsSync(baselinePath)) return {};
let baselineJson;
try {
baselineJson = JSON.parse(fs.readFileSync(baselinePath, "utf8"));
} catch {
return {};
}
const metrics = baselineJson?.metrics || {};
const out = {};
for (const [key, val] of Object.entries(metrics)) {
if (key.startsWith(BASELINE_PREFIX) && val && typeof val.value === "number") {
out[key.slice(BASELINE_PREFIX.length)] = val.value;
}
}
return out;
}
function loadReport(p) {
return JSON.parse(fs.readFileSync(p, "utf8"));
}
function main(argv) {
const paths = argv.slice(2).filter((a) => !a.startsWith("--"));
const reportPaths = paths.length > 0 ? paths : [DEFAULT_REPORT];
const existing = reportPaths.filter((p) => fs.existsSync(p));
if (existing.length === 0) {
process.stdout.write("mutationScore=SKIP reason=no-report\n");
process.exit(0);
}
const measured = measureMutationScores(existing.map(loadReport));
const baseline = readBaselineMutationScores();
const regressions = [];
const modules = Object.keys(measured).sort();
for (const mod of modules) {
const current = measured[mod];
if (!(mod in baseline)) {
process.stdout.write(`mutationScore.${mod}=${current.toFixed(2)} (no baseline — advisory)\n`);
continue;
}
const { regressed } = evaluateMutationRatchet(current, baseline[mod]);
const tag = regressed ? "REGRESSED" : "ok";
process.stdout.write(
`mutationScore.${mod}=${current.toFixed(2)} baseline=${baseline[mod].toFixed(2)} ${tag}\n`
);
if (regressed) regressions.push({ mod, current, baseline: baseline[mod] });
}
if (regressions.length > 0 && RATCHET) {
process.stderr.write(
`\nMutation ratchet FAILED — ${regressions.length} module(s) dropped below baseline:\n`
);
for (const r of regressions) {
process.stderr.write(` ${r.mod}: ${r.current.toFixed(2)} < ${r.baseline.toFixed(2)}\n`);
}
process.exit(1);
}
process.exit(0);
}
if (import.meta.url === `file://${process.argv[1]}` || process.argv[1] === fileURLToPath(import.meta.url)) {
main(process.argv);
}

View File

@@ -0,0 +1,218 @@
#!/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]);
}
// ── 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");
}
function main(argv) {
const args = argv.filter((a) => a !== "--no-conf-universe");
const useConfUniverse = !argv.includes("--no-conf-universe");
const paths = args.slice(2);
if (paths.length === 0) {
process.stderr.write(
"usage: mutation-radiography.mjs <mutation-1.json> [<mutation-2.json> ...] [--no-conf-universe]\n"
);
process.exit(2);
}
const reports = paths.map(loadMutationReport);
const universe = useConfUniverse ? tapTestFilesUniverse() : null;
const classification = aggregateRadiography(reports, universe || undefined);
process.stdout.write(renderMarkdown(classification) + "\n");
}
if (import.meta.url === `file://${process.argv[1]}`) {
main(process.argv);
}

View File

@@ -0,0 +1,91 @@
import { test } from "node:test";
import assert from "node:assert";
import os from "node:os";
import fs from "node:fs";
import path from "node:path";
import {
evaluateMutationRatchet,
mutationScoreForFile,
measureMutationScores,
readBaselineMutationScores,
} from "../../../scripts/check/check-mutation-ratchet.mjs";
// ── evaluateMutationRatchet: direction UP (score can only improve) ───────────
test("mutation ratchet flags a drop (direction up)", () => {
assert.equal(evaluateMutationRatchet(72.0, 75.0).regressed, true);
assert.equal(evaluateMutationRatchet(76.0, 75.0).regressed, false);
assert.equal(evaluateMutationRatchet(76.0, 75.0).improved, true);
assert.equal(evaluateMutationRatchet(75.0, 75.0).regressed, false); // equal holds
assert.equal(evaluateMutationRatchet(75.0, 75.0).improved, false);
});
// ── mutationScoreForFile: covered score = detected/(detected+survived),
// NoCoverage EXCLUDED (it is a coverage gap, not a test-quality signal). ─────
test("mutationScoreForFile computes the covered score and excludes NoCoverage", () => {
const fileData = {
mutants: [
{ status: "Killed" },
{ status: "Killed" },
{ status: "Killed" },
{ status: "Timeout" }, // Timeout counts as detected
{ status: "Survived" },
{ status: "Survived" },
{ status: "NoCoverage" }, // excluded from denominator
{ status: "NoCoverage" },
{ status: "Ignored" }, // excluded (not a valid mutant)
],
};
// detected = 4 (3 Killed + 1 Timeout); denom = 6 (+2 Survived); NoCoverage/Ignored out.
assert.ok(Math.abs(mutationScoreForFile(fileData) - (4 / 6) * 100) < 1e-9);
});
test("mutationScoreForFile returns null when there are no covered mutants", () => {
assert.equal(mutationScoreForFile({ mutants: [{ status: "NoCoverage" }] }), null);
assert.equal(mutationScoreForFile({ mutants: [] }), null);
});
// ── measureMutationScores: per-file scores from a report (and merges batches) ─
test("measureMutationScores maps each mutated file to its score", () => {
const report = {
files: {
"src/a.ts": { mutants: [{ status: "Killed" }, { status: "Survived" }] }, // 50
"src/b.ts": { mutants: [{ status: "Killed" }, { status: "Killed" }] }, // 100
"src/empty.ts": { mutants: [{ status: "NoCoverage" }] }, // null -> omitted
},
};
const scores = measureMutationScores(report);
assert.equal(scores["src/a.ts"], 50);
assert.equal(scores["src/b.ts"], 100);
assert.equal("src/empty.ts" in scores, false);
});
test("measureMutationScores accepts several reports (per-batch) and unions them", () => {
const c = { files: { "src/a.ts": { mutants: [{ status: "Killed" }, { status: "Survived" }] } } };
const g = { files: { "src/b.ts": { mutants: [{ status: "Killed" }] } } };
const scores = measureMutationScores([c, g]);
assert.equal(scores["src/a.ts"], 50);
assert.equal(scores["src/b.ts"], 100);
});
// ── readBaselineMutationScores: graceful skip when the file/keys are absent ──
test("readBaselineMutationScores returns {} when the baseline file is missing", () => {
assert.deepEqual(readBaselineMutationScores("/no/such/baseline.json"), {});
});
test("readBaselineMutationScores extracts mutationScore.<path> metric values", () => {
// Write a tiny baseline to a temp file and read it back.
const tmp = path.join(os.tmpdir(), `mut-baseline-${process.pid}.json`);
fs.writeFileSync(
tmp,
JSON.stringify({
metrics: {
eslintWarnings: { value: 10, direction: "down" },
"mutationScore.src/a.ts": { value: 70, direction: "up", dedicatedGate: true },
"mutationScore.src/b.ts": { value: 80, direction: "up", dedicatedGate: true },
},
})
);
const base = readBaselineMutationScores(tmp);
assert.deepEqual(base, { "src/a.ts": 70, "src/b.ts": 80 });
fs.rmSync(tmp, { force: true });
});

View File

@@ -0,0 +1,119 @@
import { test } from "node:test";
import assert from "node:assert";
import {
classifyTestFiles,
aggregateRadiography,
classifyFromCounts,
} from "../../../scripts/quality/mutation-radiography.mjs";
// ── classifyTestFiles: the plan's canonical fixture ──────────────────────────
// 2 testFiles that kill mutants + 1 that kills nothing.
// m1 killed ONLY by A -> A gets a unique kill
// m2 killed by A AND B -> both get a shared kill
// m3 survived -> nobody
// Universe = [A, B, C]; C never appears in any killedBy -> empty.
test("classifies unique / redundant / empty test files from killedBy (file-name killedBy)", () => {
const report = {
files: {
"open-sse/utils/error.ts": {
mutants: [
{ id: "m1", status: "Killed", killedBy: ["A.test.ts"] },
{ id: "m2", status: "Killed", killedBy: ["A.test.ts", "B.test.ts"] },
{ id: "m3", status: "Survived", killedBy: [] },
],
},
},
};
const allTestFiles = ["A.test.ts", "B.test.ts", "C.test.ts"];
const r = classifyTestFiles(report, allTestFiles);
assert.equal(r["A.test.ts"].class, "unique"); // mata m1 sozinho
assert.equal(r["A.test.ts"].uniqueKills, 1);
assert.equal(r["A.test.ts"].sharedKills, 1);
assert.equal(r["B.test.ts"].class, "redundant"); // só m2 (compartilhado)
assert.equal(r["B.test.ts"].uniqueKills, 0);
assert.equal(r["C.test.ts"].class, "empty"); // não mata nada
});
// ── id resolution: real Stryker tap-runner reports use numeric test ids in
// killedBy and a testFiles{} section mapping id -> file name. ────────────────
test("resolves numeric killedBy ids to file names via the testFiles section", () => {
const report = {
testFiles: {
"tests/unit/x.test.ts": { tests: [{ id: "0", name: "tests/unit/x.test.ts" }] },
"tests/unit/y.test.ts": { tests: [{ id: "1", name: "tests/unit/y.test.ts" }] },
"tests/unit/z.test.ts": { tests: [{ id: "2", name: "tests/unit/z.test.ts" }] },
},
files: {
"src/m.ts": {
mutants: [
{ id: "m1", status: "Killed", killedBy: ["0"] }, // x alone -> unique
{ id: "m2", status: "Killed", killedBy: ["0", "1"] }, // x + y shared
],
},
},
};
// allTestFiles defaults to the testFiles keys when omitted.
const r = classifyTestFiles(report);
assert.equal(r["tests/unit/x.test.ts"].class, "unique");
assert.equal(r["tests/unit/y.test.ts"].class, "redundant");
assert.equal(r["tests/unit/z.test.ts"].class, "empty");
});
// ── overlapping (🟡): kills ≥1 unique but the majority of its kills are shared.
test("classifies overlapping when shared kills outnumber unique kills", () => {
const report = {
files: {
"src/m.ts": {
mutants: [
{ id: "m1", status: "Killed", killedBy: ["D"] }, // D unique
{ id: "m2", status: "Killed", killedBy: ["D", "E"] }, // D shared
{ id: "m3", status: "Killed", killedBy: ["D", "E", "F"] }, // D shared
],
},
},
};
const r = classifyTestFiles(report, ["D", "E", "F"]);
assert.equal(r["D"].uniqueKills, 1);
assert.equal(r["D"].sharedKills, 2);
assert.equal(r["D"].class, "overlapping"); // 2 shared > 1 unique
assert.equal(r["E"].class, "redundant");
assert.equal(r["F"].class, "redundant");
});
// ── classifyFromCounts: the pure threshold helper. ───────────────────────────
test("classifyFromCounts applies the threshold rules", () => {
assert.equal(classifyFromCounts(0, 0), "empty");
assert.equal(classifyFromCounts(0, 3), "redundant");
assert.equal(classifyFromCounts(2, 1), "unique"); // shared not > unique
assert.equal(classifyFromCounts(1, 1), "unique"); // tie -> unique
assert.equal(classifyFromCounts(1, 5), "overlapping"); // shared > unique
});
// ── aggregateRadiography: merge per-batch reports at the FILE level (ids are
// per-run, so each report is classified independently then summed). A file can
// be empty in one batch but unique in another -> unique overall. ─────────────
test("aggregateRadiography sums per-file kills across batches and reclassifies", () => {
const batchC = {
files: {
"src/routeGuard.ts": {
mutants: [{ id: "c1", status: "Killed", killedBy: ["A"] }], // A unique here
},
},
};
const batchG = {
files: {
"src/chatCore/x.ts": {
mutants: [
{ id: "g1", status: "Killed", killedBy: ["A", "B"] }, // A shared, B shared
],
},
},
};
// B only ever shares; A is unique in C and shared in G -> A unique overall.
const agg = aggregateRadiography([batchC, batchG], ["A", "B", "C"]);
assert.equal(agg["A"].uniqueKills, 1);
assert.equal(agg["A"].sharedKills, 1);
assert.equal(agg["A"].class, "unique");
assert.equal(agg["B"].class, "redundant");
assert.equal(agg["C"].class, "empty");
});