@fairfox/polly 0.80.0 → 0.81.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,743 @@
1
+ #!/usr/bin/env bun
2
+ import { createRequire } from "node:module";
3
+ var __defProp = Object.defineProperty;
4
+ var __returnValue = (v) => v;
5
+ function __exportSetter(name, newValue) {
6
+ this[name] = __returnValue.bind(null, newValue);
7
+ }
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, {
11
+ get: all[name],
12
+ enumerable: true,
13
+ configurable: true,
14
+ set: __exportSetter.bind(all, name)
15
+ });
16
+ };
17
+ var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
18
+ var __require = /* @__PURE__ */ createRequire(import.meta.url);
19
+
20
+ // tools/mutate/src/args.ts
21
+ var KNOWN_VERBS = new Set(["run", "report", "decisions", "verify", "init", "help"]);
22
+ var VALUE_FLAGS = new Set(["--config", "--report", "--decisions", "--db"]);
23
+ function parseMutateArgs(argv) {
24
+ const positionals = [];
25
+ const flags = new Map;
26
+ const bools = new Set;
27
+ let i = 0;
28
+ while (i < argv.length) {
29
+ const a = argv[i];
30
+ if (a === undefined)
31
+ break;
32
+ if (VALUE_FLAGS.has(a)) {
33
+ flags.set(a, argv[i + 1] ?? "");
34
+ i += 2;
35
+ } else {
36
+ if (a.startsWith("-"))
37
+ bools.add(a);
38
+ else
39
+ positionals.push(a);
40
+ i += 1;
41
+ }
42
+ }
43
+ const verbRaw = positionals[0];
44
+ const help = bools.has("--help") || bools.has("-h") || verbRaw === "help";
45
+ const verb = verbRaw === undefined ? "run" : KNOWN_VERBS.has(verbRaw) ? verbRaw : verbRaw;
46
+ return {
47
+ verb,
48
+ rest: positionals.slice(1),
49
+ config: flags.get("--config"),
50
+ report: flags.get("--report"),
51
+ decisions: flags.get("--decisions"),
52
+ db: flags.get("--db"),
53
+ noReport: bools.has("--no-report"),
54
+ run: bools.has("--run"),
55
+ force: bools.has("--force"),
56
+ help
57
+ };
58
+ }
59
+
60
+ // tools/mutate/src/config.ts
61
+ import { resolve as resolve2 } from "node:path";
62
+
63
+ // tools/test/src/coverage-policy/mutate-targets.ts
64
+ import { existsSync, readFileSync } from "node:fs";
65
+ import { join, resolve } from "node:path";
66
+ import { Glob } from "bun";
67
+ async function findStrykerConfigs(root) {
68
+ const found = [];
69
+ const single = join(root, "stryker.conf.json");
70
+ if (existsSync(single))
71
+ found.push(single);
72
+ const glob = new Glob("stryker/*.{json,conf.json}");
73
+ for await (const rel of glob.scan({ cwd: root, onlyFiles: true })) {
74
+ found.push(join(root, rel));
75
+ }
76
+ return found.sort();
77
+ }
78
+ function isGlob(pattern) {
79
+ return pattern.includes("*") || pattern.includes("?") || pattern.includes("[");
80
+ }
81
+ async function resolvesToFile(pattern, cwd) {
82
+ if (pattern.startsWith("!"))
83
+ return true;
84
+ if (!isGlob(pattern))
85
+ return existsSync(resolve(cwd, pattern));
86
+ const glob = new Glob(pattern);
87
+ for await (const _ of glob.scan({ cwd, onlyFiles: true }))
88
+ return true;
89
+ return false;
90
+ }
91
+ async function checkField(configPath, field, patterns, cwd) {
92
+ const issues = [];
93
+ for (const pattern of patterns ?? []) {
94
+ if (!await resolvesToFile(pattern, cwd)) {
95
+ issues.push({ config: configPath, field, pattern });
96
+ }
97
+ }
98
+ return issues;
99
+ }
100
+ async function validateMutateTargets(root) {
101
+ const configs = await findStrykerConfigs(root);
102
+ const issues = [];
103
+ for (const configPath of configs) {
104
+ const config = JSON.parse(readFileSync(configPath, "utf8"));
105
+ const testFiles = config.testFiles ?? config.bun?.testFiles;
106
+ issues.push(...await checkField(configPath, "mutate", config.mutate, root));
107
+ issues.push(...await checkField(configPath, "testFiles", testFiles, root));
108
+ }
109
+ return { configs, issues };
110
+ }
111
+
112
+ // tools/mutate/src/config.ts
113
+ var DEFAULT_REPORT = "reports/mutation/mutation.json";
114
+ var DEFAULT_DECISIONS = ".polly/test-debt/decisions.jsonl";
115
+ async function resolveMutateConfig(cwd, args) {
116
+ let strykerConfigPath = null;
117
+ if (args.config) {
118
+ strykerConfigPath = resolve2(cwd, args.config);
119
+ } else {
120
+ const found = await findStrykerConfigs(cwd);
121
+ strykerConfigPath = found[0] ?? null;
122
+ }
123
+ return {
124
+ cwd,
125
+ strykerConfigPath,
126
+ reportPath: resolve2(cwd, args.report ?? DEFAULT_REPORT),
127
+ dbPath: args.db ? resolve2(cwd, args.db) : ":memory:",
128
+ decisionsPath: resolve2(cwd, args.decisions ?? DEFAULT_DECISIONS)
129
+ };
130
+ }
131
+
132
+ // tools/mutate/src/decisions.ts
133
+ import { createHash } from "node:crypto";
134
+
135
+ // tools/mutate/src/ingest.ts
136
+ import { Database } from "bun:sqlite";
137
+ function buildDb(report, dbPath = ":memory:") {
138
+ const db = new Database(dbPath);
139
+ db.exec("PRAGMA journal_mode = WAL;");
140
+ db.exec(`
141
+ DROP TABLE IF EXISTS kills;
142
+ DROP TABLE IF EXISTS covers;
143
+ DROP TABLE IF EXISTS mutants;
144
+ DROP TABLE IF EXISTS tests;
145
+ CREATE TABLE tests (
146
+ id TEXT PRIMARY KEY,
147
+ file TEXT NOT NULL,
148
+ name TEXT NOT NULL,
149
+ line INTEGER
150
+ );
151
+ CREATE TABLE mutants (
152
+ id TEXT PRIMARY KEY,
153
+ mutator TEXT NOT NULL,
154
+ file TEXT NOT NULL,
155
+ line INTEGER,
156
+ status TEXT NOT NULL
157
+ );
158
+ -- the kill matrix: (mutant, test) where test detects mutant
159
+ CREATE TABLE kills (
160
+ mutant_id TEXT NOT NULL REFERENCES mutants(id),
161
+ test_id TEXT NOT NULL REFERENCES tests(id),
162
+ PRIMARY KEY (mutant_id, test_id)
163
+ );
164
+ -- coverage matrix (populated only when the runner reports coveredBy)
165
+ CREATE TABLE covers (
166
+ mutant_id TEXT NOT NULL REFERENCES mutants(id),
167
+ test_id TEXT NOT NULL REFERENCES tests(id),
168
+ PRIMARY KEY (mutant_id, test_id)
169
+ );
170
+ CREATE INDEX idx_kills_test ON kills(test_id);
171
+ CREATE INDEX idx_kills_mutant ON kills(mutant_id);
172
+ `);
173
+ const insTest = db.prepare("INSERT OR IGNORE INTO tests (id, file, name, line) VALUES (?, ?, ?, ?)");
174
+ const insMut = db.prepare("INSERT INTO mutants (id, mutator, file, line, status) VALUES (?, ?, ?, ?, ?)");
175
+ const insKill = db.prepare("INSERT OR IGNORE INTO kills (mutant_id, test_id) VALUES (?, ?)");
176
+ const insCover = db.prepare("INSERT OR IGNORE INTO covers (mutant_id, test_id) VALUES (?, ?)");
177
+ const ingest = db.transaction(() => {
178
+ for (const [file, tf] of Object.entries(report.testFiles ?? {})) {
179
+ for (const t of tf.tests) {
180
+ insTest.run(t.id, file || "(unknown)", t.name, t.location?.start.line ?? null);
181
+ }
182
+ }
183
+ for (const [file, f] of Object.entries(report.files)) {
184
+ for (const m of f.mutants) {
185
+ insMut.run(m.id, m.mutatorName, file, m.location.start.line, m.status);
186
+ for (const tid of m.killedBy ?? [])
187
+ insKill.run(m.id, tid);
188
+ for (const tid of m.coveredBy ?? [])
189
+ insCover.run(m.id, tid);
190
+ }
191
+ }
192
+ });
193
+ ingest();
194
+ return db;
195
+ }
196
+ function queryAll(db, sql) {
197
+ return db.query(sql).all();
198
+ }
199
+ function queryGet(db, sql) {
200
+ return db.query(sql).get();
201
+ }
202
+ async function loadMutationReport(path) {
203
+ return await Bun.file(path).json();
204
+ }
205
+ if (false) {}
206
+
207
+ // tools/mutate/src/decisions.ts
208
+ var DEFAULT_LOG = ".polly/test-debt/decisions.jsonl";
209
+ var VERDICTS = ["keep", "prune", "rewrite", "investigate"];
210
+ function normalizeSource(src) {
211
+ return src.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:])\/\/[^\n]*/g, "$1").replace(/\s+/g, " ").trim();
212
+ }
213
+ async function fileHash(file) {
214
+ const f = Bun.file(file);
215
+ if (!await f.exists())
216
+ return null;
217
+ return createHash("sha256").update(normalizeSource(await f.text())).digest("hex").slice(0, 16);
218
+ }
219
+ function fileSignals(db) {
220
+ const rows = queryAll(db, `
221
+ WITH file_kill AS (SELECT DISTINCT t.file file, k.mutant_id mid FROM kills k JOIN tests t ON t.id=k.test_id),
222
+ owner AS (SELECT mid, count(DISTINCT file) nf FROM file_kill GROUP BY mid)
223
+ SELECT fk.file file, count(DISTINCT fk.mid) kills,
224
+ sum(CASE WHEN o.nf=1 THEN 1 ELSE 0 END) unique_kills
225
+ FROM file_kill fk JOIN owner o ON o.mid=fk.mid GROUP BY fk.file`);
226
+ const m = new Map;
227
+ for (const r of rows)
228
+ m.set(r.file, {
229
+ kills: r.kills,
230
+ uniqueKills: r.unique_kills,
231
+ subsumed: r.kills > 0 && r.unique_kills === 0
232
+ });
233
+ return m;
234
+ }
235
+ async function loadDecisions(logPath) {
236
+ const m = new Map;
237
+ const f = Bun.file(logPath);
238
+ if (!await f.exists())
239
+ return m;
240
+ for (const line of (await f.text()).split(`
241
+ `)) {
242
+ const t = line.trim();
243
+ if (!t)
244
+ continue;
245
+ const d = JSON.parse(t);
246
+ m.set(d.file, d);
247
+ }
248
+ return m;
249
+ }
250
+ function staleReason(d, currentHash, current) {
251
+ if (!current)
252
+ return "file no longer kills any mutant (renamed/deleted/refactored?)";
253
+ if (d.hash !== currentHash)
254
+ return "test file changed since the decision";
255
+ if (d.signal.subsumed !== current.subsumed)
256
+ return `subsumption flipped (${d.signal.subsumed} → ${current.subsumed})`;
257
+ if (d.signal.uniqueKills > 0 !== current.uniqueKills > 0)
258
+ return `unique-kill status changed (${d.signal.uniqueKills} → ${current.uniqueKills})`;
259
+ return null;
260
+ }
261
+ async function status(db, logPath = DEFAULT_LOG) {
262
+ const signals = fileSignals(db);
263
+ const decisions = await loadDecisions(logPath);
264
+ const needsReview = [];
265
+ const stale = [];
266
+ const settled = [];
267
+ for (const [file, sig] of [...signals].sort((a, b) => b[1].kills - a[1].kills)) {
268
+ const d = decisions.get(file);
269
+ if (!d) {
270
+ if (sig.subsumed)
271
+ needsReview.push(` ${file} (${sig.kills} kills, 0 unique)`);
272
+ continue;
273
+ }
274
+ const reason = staleReason(d, await fileHash(file), sig);
275
+ if (reason)
276
+ stale.push(` ${file} [${d.verdict}] — ${reason}`);
277
+ else
278
+ settled.push(` ${file} [${d.verdict}] ${d.rationale ? `— ${d.rationale}` : ""}`);
279
+ }
280
+ for (const [file, d] of decisions) {
281
+ if (!signals.has(file))
282
+ stale.push(` ${file} [${d.verdict}] — file no longer in matrix`);
283
+ }
284
+ const out = [];
285
+ out.push(`NEEDS REVIEW (subsumed files with no decision — ${needsReview.length})`);
286
+ out.push(needsReview.length ? needsReview.join(`
287
+ `) : " none");
288
+ out.push(`
289
+ STALE (decision's basis drifted — re-review — ${stale.length})`);
290
+ out.push(stale.length ? stale.join(`
291
+ `) : " none");
292
+ out.push(`
293
+ SETTLED (fresh decisions, suppressed from action list — ${settled.length})`);
294
+ out.push(settled.length ? settled.join(`
295
+ `) : " none");
296
+ return out.join(`
297
+ `);
298
+ }
299
+ async function decide(file, verdict, rationale, db, logPath = DEFAULT_LOG) {
300
+ if (!VERDICTS.includes(verdict)) {
301
+ console.log(`✗ verdict must be one of: ${VERDICTS.join(", ")}`);
302
+ process.exit(1);
303
+ }
304
+ const sig = fileSignals(db).get(file);
305
+ if (!sig) {
306
+ console.log(`✗ ${file} is not a test file that kills any mutant in the current matrix`);
307
+ process.exit(1);
308
+ }
309
+ const decided = new Date().toISOString().slice(0, 10);
310
+ const record = {
311
+ file,
312
+ verdict,
313
+ rationale,
314
+ signal: sig,
315
+ hash: await fileHash(file),
316
+ decided
317
+ };
318
+ await Bun.write(logPath, (await Bun.file(logPath).exists() ? await Bun.file(logPath).text() : "") + JSON.stringify(record) + `
319
+ `);
320
+ console.log(`✓ recorded: ${file} [${verdict}]${rationale ? ` — ${rationale}` : ""}`);
321
+ }
322
+ if (false) {}
323
+
324
+ // tools/mutate/src/init.ts
325
+ import { existsSync as existsSync2 } from "node:fs";
326
+ import { join as join2, relative } from "node:path";
327
+ function resolvePluginRef(projectDir) {
328
+ const resolved = Bun.resolveSync("@fairfox/polly/stryker", projectDir);
329
+ const hoisted = resolved.includes("/node_modules/") && !resolved.includes("/.bun/");
330
+ return hoisted ? "@fairfox/polly/stryker" : relative(projectDir, resolved);
331
+ }
332
+ function detectMutate(projectDir) {
333
+ if (existsSync2(join2(projectDir, "src"))) {
334
+ return ["src/**/*.ts", "!src/**/*.test.ts", "!src/**/*.d.ts"];
335
+ }
336
+ return ["**/*.ts", "!**/*.test.ts", "!**/*.d.ts", "!node_modules/**", "!dist/**"];
337
+ }
338
+ function detectTestGlob(projectDir) {
339
+ for (const d of ["tests", "test"]) {
340
+ if (existsSync2(join2(projectDir, d)))
341
+ return `${d}/**/*.test.ts`;
342
+ }
343
+ return "**/*.test.ts";
344
+ }
345
+ async function initConfig(projectDir, opts = {}) {
346
+ const configPath = join2(projectDir, "stryker.conf.json");
347
+ const warnings = [];
348
+ let pluginRef;
349
+ try {
350
+ pluginRef = resolvePluginRef(projectDir);
351
+ } catch {
352
+ throw new Error("Cannot resolve @fairfox/polly from here. Run `polly mutate init` in a project that depends on @fairfox/polly.");
353
+ }
354
+ const mutate = detectMutate(projectDir);
355
+ const testGlob = detectTestGlob(projectDir);
356
+ if (!existsSync2(join2(projectDir, "src"))) {
357
+ warnings.push("no src/ directory — adjust the `mutate` globs to point at your source");
358
+ }
359
+ if (!existsSync2(join2(projectDir, "tests")) && !existsSync2(join2(projectDir, "test"))) {
360
+ warnings.push(`no tests/ directory — using "${testGlob}"; adjust bun.testFiles if needed`);
361
+ }
362
+ if (existsSync2(configPath) && !opts.force) {
363
+ return { configPath, created: false, pluginRef, mutate, testGlob, warnings };
364
+ }
365
+ const config = {
366
+ testRunner: "bun",
367
+ bun: { testFiles: [testGlob], timeout: 30000 },
368
+ plugins: ["stryker-mutator-bun-runner", pluginRef],
369
+ ignorers: ["polly-verify"],
370
+ coverageAnalysis: "all",
371
+ disableBail: true,
372
+ reporters: ["json", "clear-text"],
373
+ mutate,
374
+ thresholds: { high: 90, low: 70, break: null },
375
+ ignorePatterns: [
376
+ "**/node_modules/**",
377
+ "**/dist/**",
378
+ "**/.git/**",
379
+ "**/.stryker-tmp/**",
380
+ "**/reports/**"
381
+ ],
382
+ timeoutMS: 60000,
383
+ concurrency: 4
384
+ };
385
+ await Bun.write(configPath, `${JSON.stringify(config, null, 2)}
386
+ `);
387
+ return { configPath, created: true, pluginRef, mutate, testGlob, warnings };
388
+ }
389
+
390
+ // tools/mutate/src/run.ts
391
+ import { resolve as resolve3 } from "node:path";
392
+
393
+ // tools/mutate/src/report.ts
394
+ function greedyCover(units, universe) {
395
+ const remaining = new Set(universe);
396
+ const chosen = [];
397
+ const pool = new Map([...units].map(([k, v]) => [k, new Set(v)]));
398
+ while (remaining.size > 0) {
399
+ let best = null;
400
+ let bestGain = 0;
401
+ for (const [unit, set] of pool) {
402
+ let gain = 0;
403
+ for (const m of set)
404
+ if (remaining.has(m))
405
+ gain++;
406
+ if (gain > bestGain) {
407
+ bestGain = gain;
408
+ best = unit;
409
+ }
410
+ }
411
+ if (best === null || bestGain === 0)
412
+ break;
413
+ chosen.push(best);
414
+ for (const m of pool.get(best) ?? [])
415
+ remaining.delete(m);
416
+ pool.delete(best);
417
+ }
418
+ return chosen;
419
+ }
420
+ function report(db, opts = {}) {
421
+ const matrixComplete = opts.matrixComplete ?? true;
422
+ const out = [];
423
+ const p = (s = "") => out.push(s);
424
+ const tot = queryGet(db, "SELECT count(*) n, sum(status='Killed') killed, sum(status='Survived') survived, sum(status='Timeout') timeout, sum(status='NoCoverage') nocov FROM mutants") ?? { n: 0, killed: 0, survived: 0, timeout: 0, nocov: 0 };
425
+ const nTests = queryGet(db, "SELECT count(*) n FROM tests")?.n ?? 0;
426
+ const detected = tot.killed + tot.timeout;
427
+ const score = (detected / (tot.n - tot.nocov || 1) * 100).toFixed(1);
428
+ p(`MUTATION score ${score}% ${tot.killed} killed · ${tot.timeout} timeout · ${tot.survived} survived · ${tot.nocov} no-coverage (${tot.n} mutants, ${nTests} tests)`);
429
+ if (matrixComplete) {
430
+ const mult = queryAll(db, `
431
+ SELECT CASE WHEN c=1 THEN '1' WHEN c=2 THEN '2' WHEN c BETWEEN 3 AND 5 THEN '3-5' ELSE '6+' END bucket, count(*) n
432
+ FROM (SELECT mutant_id, count(*) c FROM kills GROUP BY mutant_id)
433
+ GROUP BY bucket ORDER BY min(c)`);
434
+ p(`
435
+ KILL MULTIPLICITY (killers per killed mutant — higher = more redundancy slack)`);
436
+ for (const r of mult)
437
+ p(` killed by ${String(r.bucket).padStart(3)} test(s): ${r.n}`);
438
+ const universe = new Set(queryAll(db, "SELECT DISTINCT mutant_id FROM kills").map((r) => r.mutant_id));
439
+ const byTest = new Map;
440
+ for (const r of queryAll(db, "SELECT test_id, mutant_id FROM kills")) {
441
+ const set = byTest.get(r.test_id) ?? new Set;
442
+ byTest.set(r.test_id, set);
443
+ set.add(r.mutant_id);
444
+ }
445
+ const byFile = new Map;
446
+ for (const r of queryAll(db, "SELECT t.file file, k.mutant_id mid FROM kills k JOIN tests t ON t.id=k.test_id")) {
447
+ const set = byFile.get(r.file) ?? new Set;
448
+ byFile.set(r.file, set);
449
+ set.add(r.mid);
450
+ }
451
+ const caseCover = greedyCover(byTest, universe);
452
+ const fileCover = greedyCover(byFile, universe);
453
+ p(`
454
+ REDUNDANCY RATIO (tests that kill something ÷ minimal covering set)`);
455
+ p(` case level: ${byTest.size} killing tests → ${caseCover.length} needed (${(byTest.size / Math.max(1, caseCover.length)).toFixed(2)}×, ~${byTest.size - caseCover.length} prunable)`);
456
+ p(` file level: ${byFile.size} killing files → ${fileCover.length} needed (${(byFile.size / Math.max(1, fileCover.length)).toFixed(2)}×, ~${byFile.size - fileCover.length} prunable)`);
457
+ const subsumedFiles = queryAll(db, `
458
+ WITH file_kill AS (SELECT DISTINCT t.file file, k.mutant_id mid FROM kills k JOIN tests t ON t.id=k.test_id),
459
+ owner AS (SELECT mid, count(DISTINCT file) nf, min(file) only_file FROM file_kill GROUP BY mid)
460
+ SELECT fk.file, count(DISTINCT fk.mid) kills,
461
+ sum(CASE WHEN o.nf=1 THEN 1 ELSE 0 END) unique_kills
462
+ FROM file_kill fk JOIN owner o ON o.mid=fk.mid
463
+ GROUP BY fk.file HAVING unique_kills=0 ORDER BY kills DESC`);
464
+ p(`
465
+ SUBSUMED FILES (file-level identity — safe-to-review deletion candidates)`);
466
+ if (subsumedFiles.length === 0)
467
+ p(` none — every test file kills at least one mutant no other file catches`);
468
+ for (const r of subsumedFiles)
469
+ p(` ${r.file} (${r.kills} kills, 0 unique)`);
470
+ const subsumedCases = queryAll(db, `
471
+ WITH mk AS (SELECT mutant_id, count(*) c FROM kills GROUP BY mutant_id),
472
+ tk AS (SELECT test_id, count(*) total, sum(CASE WHEN mk.c=1 THEN 1 ELSE 0 END) uniq
473
+ FROM kills JOIN mk USING(mutant_id) GROUP BY test_id)
474
+ SELECT t.file, t.name, tk.total FROM tk JOIN tests t ON t.id=tk.test_id
475
+ WHERE tk.total>0 AND tk.uniq=0 ORDER BY t.file, tk.total DESC`);
476
+ const byFileCases = new Map;
477
+ for (const r of subsumedCases) {
478
+ const rows = byFileCases.get(r.file) ?? [];
479
+ byFileCases.set(r.file, rows);
480
+ rows.push(r);
481
+ }
482
+ p(`
483
+ SUBSUMED CASES (${subsumedCases.length} individual tests whose every kill is caught elsewhere)`);
484
+ for (const [file, rows] of byFileCases) {
485
+ p(` ${file}`);
486
+ for (const r of rows)
487
+ p(` “${r.name}” (${r.total} kills, 0 unique)`);
488
+ }
489
+ } else {
490
+ p(`
491
+ ⚠ KILL MATRIX INCOMPLETE — redundancy/subsumption analysis suppressed.`);
492
+ p(` Every mutant has at most one recorded killer, so "which tests are redundant"`);
493
+ p(` cannot be derived. This needs the no-bail patched Bun runner (not shipped with`);
494
+ p(` @fairfox/polly). Run 'polly mutate verify' to diagnose. Score/gaps/theatre below still hold.`);
495
+ }
496
+ const srcFile = queryGet(db, "SELECT file FROM mutants LIMIT 1")?.file;
497
+ const gaps = queryAll(db, "SELECT line, mutator, count(*) n FROM mutants WHERE status='NoCoverage' GROUP BY line, mutator ORDER BY line");
498
+ const gapTotal = gaps.reduce((a, r) => a + r.n, 0);
499
+ p(`
500
+ GAPS (NoCoverage — no test executes this code: ${gapTotal})`);
501
+ if (gapTotal === 0)
502
+ p(` none — every mutable location is reached by some test`);
503
+ for (const r of gaps)
504
+ p(` ${srcFile}:${r.line} ${r.mutator}${r.n > 1 ? ` ×${r.n}` : ""}`);
505
+ const theatre = queryAll(db, "SELECT line, mutator, count(*) n FROM mutants WHERE status='Survived' GROUP BY line, mutator ORDER BY line");
506
+ const theatreTotal = theatre.reduce((a, r) => a + r.n, 0);
507
+ p(`
508
+ THEATRE (Survived — code is covered but no test detects the mutation: ${theatreTotal})`);
509
+ if (theatreTotal === 0)
510
+ p(` none`);
511
+ for (const r of theatre)
512
+ p(` ${srcFile}:${r.line} ${r.mutator}${r.n > 1 ? ` ×${r.n}` : ""}`);
513
+ return out.join(`
514
+ `);
515
+ }
516
+ if (false) {}
517
+
518
+ // tools/mutate/src/verify-matrix.ts
519
+ var DEFAULT_REPORT2 = "reports/mutation/mutation.json";
520
+ function isMatrixComplete(report2) {
521
+ const mutants = Object.values(report2.files).flatMap((f) => f.mutants);
522
+ if (mutants.length === 0)
523
+ return false;
524
+ const killed = mutants.filter((m) => m.status === "Killed");
525
+ const multiKiller = killed.some((m) => (m.killedBy ?? []).length > 1);
526
+ const noCov = mutants.filter((m) => m.status === "NoCoverage").length;
527
+ const coverageCollected = noCov < mutants.length;
528
+ return multiKiller && coverageCollected;
529
+ }
530
+ async function verifyMatrix(opts = {}) {
531
+ const reportPath = opts.reportPath ?? DEFAULT_REPORT2;
532
+ if (opts.run) {
533
+ console.log("Running mutation pass (this can take many minutes for a large mutate set)...");
534
+ const cmd = [
535
+ "bunx",
536
+ "stryker",
537
+ "run",
538
+ ...opts.strykerConfig ? ["--configFile", opts.strykerConfig] : []
539
+ ];
540
+ const proc = Bun.spawn(cmd, { stdout: "inherit", stderr: "inherit" });
541
+ const code = await proc.exited;
542
+ if (code !== 0) {
543
+ return { ok: false, checks: [{ name: "stryker run", ok: false, detail: `exited ${code}` }] };
544
+ }
545
+ }
546
+ if (!await Bun.file(reportPath).exists()) {
547
+ return {
548
+ ok: false,
549
+ checks: [
550
+ {
551
+ name: "report exists",
552
+ ok: false,
553
+ detail: `no report at ${reportPath}. Run with --run, or 'bun run mutation' first.`
554
+ }
555
+ ]
556
+ };
557
+ }
558
+ const report2 = await loadMutationReport(reportPath);
559
+ const checks = [];
560
+ const add = (name, ok, detail) => checks.push({ name, ok, detail });
561
+ add("schema version present", !!report2.schemaVersion, `schemaVersion=${report2.schemaVersion}`);
562
+ const tfKeys = Object.keys(report2.testFiles ?? {});
563
+ const realPaths = tfKeys.filter((k) => k && k !== "(unknown)");
564
+ add("testFiles keyed by real paths", realPaths.length > 0 && !tfKeys.includes(""), `${realPaths.length} file(s): ${realPaths.slice(0, 2).join(", ")}${realPaths.length > 2 ? "…" : ""}`);
565
+ const testIds = new Set(Object.values(report2.testFiles ?? {}).flatMap((tf) => tf.tests.map((t) => t.id)));
566
+ const mutants = Object.values(report2.files).flatMap((f) => f.mutants);
567
+ const killed = mutants.filter((m) => m.status === "Killed");
568
+ const danglingKb = killed.filter((m) => (m.killedBy ?? []).some((id) => !testIds.has(id)));
569
+ add("every killedBy id resolves to a test", danglingKb.length === 0, `${danglingKb.length} dangling`);
570
+ const withKb = killed.filter((m) => (m.killedBy ?? []).length > 0);
571
+ const multi = killed.filter((m) => (m.killedBy ?? []).length > 1);
572
+ add("killed mutants carry killedBy", killed.length > 0 && withKb.length === killed.length, `${withKb.length}/${killed.length}`);
573
+ add("matrix is complete (no-bail): >1 killer exists", multi.length > 0, `${multi.length} mutant(s) killed by >1 test — collapses to 0 if bail re-engages`);
574
+ const noCov = mutants.filter((m) => m.status === "NoCoverage").length;
575
+ add("coverage collected (not all NoCoverage)", noCov < mutants.length, `${noCov}/${mutants.length} NoCoverage — all-NoCoverage means the coverage dump broke`);
576
+ return { ok: checks.every((c) => c.ok), checks };
577
+ }
578
+ if (false) {}
579
+
580
+ // tools/mutate/src/run.ts
581
+ async function runStryker(cfg) {
582
+ const autoDiscoverable = cfg.strykerConfigPath === resolve3(cfg.cwd, "stryker.conf.json");
583
+ const configFlag = cfg.strykerConfigPath && !autoDiscoverable ? ["--configFile", cfg.strykerConfigPath] : [];
584
+ const proc = Bun.spawn(["bunx", "stryker", "run", ...configFlag], {
585
+ cwd: cfg.cwd,
586
+ stdout: "inherit",
587
+ stderr: "inherit",
588
+ stdin: "inherit",
589
+ env: process.env
590
+ });
591
+ return await proc.exited;
592
+ }
593
+ async function reportFromFile(cfg) {
594
+ if (!await Bun.file(cfg.reportPath).exists()) {
595
+ throw new Error(`no mutation report at ${cfg.reportPath}. Run 'polly mutate run' (or 'bun run mutation') first, or pass --report <path>.`);
596
+ }
597
+ const mreport = await loadMutationReport(cfg.reportPath);
598
+ const db = buildDb(mreport, cfg.dbPath);
599
+ try {
600
+ return report(db, { matrixComplete: isMatrixComplete(mreport) });
601
+ } finally {
602
+ db.close();
603
+ }
604
+ }
605
+ async function presetAdvisory(cfg) {
606
+ if (!cfg.strykerConfigPath?.endsWith(".json"))
607
+ return null;
608
+ try {
609
+ const conf = await Bun.file(cfg.strykerConfigPath).json();
610
+ if ((conf.plugins ?? []).includes("@fairfox/polly/stryker"))
611
+ return null;
612
+ return "Tip: spread pollyStrykerPreset (from @fairfox/polly/stryker) into your Stryker config — " + "verify primitives (requires/ensures/…) are runtime no-ops and their mutants always survive, " + "dragging the score down with noise. See polly#143.";
613
+ } catch {
614
+ return null;
615
+ }
616
+ }
617
+
618
+ // tools/mutate/src/cli.ts
619
+ var HELP = `polly mutate — mutation testing + useless-test detection
620
+
621
+ Usage:
622
+ polly mutate init [--force] Scaffold a stryker.conf.json for this project
623
+ polly mutate [run] Run Stryker, then print the useless-test report
624
+ polly mutate report Print the report from an existing mutation.json
625
+ polly mutate decisions Review verdicts: needs-review / stale / settled
626
+ polly mutate decisions decide <file> <keep|prune|rewrite|investigate> "<rationale>"
627
+ polly mutate verify [--run] Assert the kill-matrix contract (exit 1 on drift)
628
+ polly mutate help Show this help
629
+
630
+ Flags:
631
+ --config <path> Stryker config (default: auto-discovered)
632
+ --report <path> mutation report JSON (default: reports/mutation/mutation.json)
633
+ --decisions <path> verdict log (default: .polly/test-debt/decisions.jsonl)
634
+ --db <path> intermediate SQLite (default: in-memory)
635
+ --no-report 'run' only — skip the analysis
636
+ -h, --help
637
+
638
+ The redundancy/subsumption sections need a COMPLETE kill matrix (the no-bail
639
+ patched Bun runner). Without it the report degrades to score + gaps + theatre;
640
+ 'polly mutate verify' diagnoses the matrix.`;
641
+ async function openDb(reportPath, dbPath) {
642
+ if (!await Bun.file(reportPath).exists()) {
643
+ console.log(`no mutation report at ${reportPath}. Run 'polly mutate run' first, or pass --report <path>.`);
644
+ return null;
645
+ }
646
+ return buildDb(await loadMutationReport(reportPath), dbPath);
647
+ }
648
+ async function main() {
649
+ const args = parseMutateArgs(process.argv.slice(2));
650
+ if (args.help) {
651
+ console.log(HELP);
652
+ return 0;
653
+ }
654
+ const cwd = process.cwd();
655
+ const cfg = await resolveMutateConfig(cwd, args);
656
+ switch (args.verb) {
657
+ case "init": {
658
+ const result = await initConfig(cwd, { force: args.force });
659
+ if (!result.created) {
660
+ console.log(`stryker.conf.json already exists at ${result.configPath}. Re-run with --force to overwrite.`);
661
+ return 1;
662
+ }
663
+ console.log(`✓ wrote ${result.configPath}`);
664
+ console.log(` plugin: ${result.pluginRef}`);
665
+ console.log(` mutate: ${result.mutate.join(", ")}`);
666
+ console.log(` testFiles: ${result.testGlob}`);
667
+ for (const w of result.warnings)
668
+ console.log(` ⚠ ${w}`);
669
+ console.log(`
670
+ Next: polly mutate run`);
671
+ return 0;
672
+ }
673
+ case "run": {
674
+ const tip = await presetAdvisory(cfg);
675
+ if (tip)
676
+ console.log(`${tip}
677
+ `);
678
+ const code = await runStryker(cfg);
679
+ if (code !== 0)
680
+ return code;
681
+ if (args.noReport)
682
+ return 0;
683
+ console.log(`
684
+ ${await reportFromFile(cfg)}`);
685
+ return 0;
686
+ }
687
+ case "report": {
688
+ console.log(await reportFromFile(cfg));
689
+ return 0;
690
+ }
691
+ case "decisions": {
692
+ const db = await openDb(cfg.reportPath, cfg.dbPath);
693
+ if (!db)
694
+ return 1;
695
+ try {
696
+ if (args.rest[0] === "decide") {
697
+ const [, file, verdict, ...r] = args.rest;
698
+ if (!file || !verdict) {
699
+ console.log('usage: polly mutate decisions decide <file> <keep|prune|rewrite|investigate> "<rationale>"');
700
+ return 1;
701
+ }
702
+ await decide(file, verdict, r.join(" ").replace(/^"|"$/g, ""), db, cfg.decisionsPath);
703
+ } else {
704
+ console.log(await status(db, cfg.decisionsPath));
705
+ }
706
+ } finally {
707
+ db.close();
708
+ }
709
+ return 0;
710
+ }
711
+ case "verify": {
712
+ const result = await verifyMatrix({
713
+ reportPath: cfg.reportPath,
714
+ run: args.run,
715
+ strykerConfig: cfg.strykerConfigPath ?? undefined
716
+ });
717
+ console.log(`
718
+ Kill-matrix contract:`);
719
+ for (const c of result.checks)
720
+ console.log(` ${c.ok ? "✓" : "✗"} ${c.name} (${c.detail})`);
721
+ if (!result.ok) {
722
+ console.log(`
723
+ ✗ Contract broken. The pinned pair has drifted — re-check Bun version and that the patch applies (bun install).`);
724
+ return 1;
725
+ }
726
+ console.log(`
727
+ ✓ Kill-matrix contract holds.`);
728
+ return 0;
729
+ }
730
+ default:
731
+ console.log(`Unknown subcommand: ${args.verb}
732
+ `);
733
+ console.log(HELP);
734
+ return 1;
735
+ }
736
+ }
737
+ main().then((code) => process.exit(code)).catch((err) => {
738
+ console.log(`
739
+ ❌ ${err instanceof Error ? err.message : String(err)}`);
740
+ process.exit(1);
741
+ });
742
+
743
+ //# debugId=768AC36DADFC60B164756E2164756E21