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);
}