diff --git a/package.json b/package.json index 470f78efa4..20fce8d615 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/check/check-mutation-ratchet.mjs b/scripts/check/check-mutation-ratchet.mjs new file mode 100644 index 0000000000..09ea607125 --- /dev/null +++ b/scripts/check/check-mutation-ratchet.mjs @@ -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 ... --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} + */ +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."].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} + */ +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); +} diff --git a/scripts/quality/mutation-radiography.mjs b/scripts/quality/mutation-radiography.mjs new file mode 100644 index 0000000000..9ba7954c85 --- /dev/null +++ b/scripts/quality/mutation-radiography.mjs @@ -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 [ ...] + * 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 [ ...] [--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); +} diff --git a/tests/unit/build/check-mutation-ratchet.test.ts b/tests/unit/build/check-mutation-ratchet.test.ts new file mode 100644 index 0000000000..f7870a8193 --- /dev/null +++ b/tests/unit/build/check-mutation-ratchet.test.ts @@ -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. 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 }); +}); diff --git a/tests/unit/build/mutation-radiography.test.ts b/tests/unit/build/mutation-radiography.test.ts new file mode 100644 index 0000000000..102fdaa81c --- /dev/null +++ b/tests/unit/build/mutation-radiography.test.ts @@ -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"); +});