@mmerterden/multi-agent-pipeline 20.23.1 → 20.24.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.
Files changed (74) hide show
  1. package/CHANGELOG.md +30 -1
  2. package/README.md +7 -6
  3. package/README.tr.md +6 -5
  4. package/docs/architecture.md +3 -3
  5. package/docs/ecosystem.md +5 -5
  6. package/docs/facts.json +3 -3
  7. package/install/catalog-history.json +1 -1
  8. package/install/templates/claude-hooks.json +14 -1
  9. package/manifest.json +77 -56
  10. package/package.json +1 -1
  11. package/pipeline/commands/multi-agent/analysis-jira/SKILL.md +11 -2
  12. package/pipeline/commands/multi-agent/postmortem/SKILL.md +78 -0
  13. package/pipeline/commands/multi-agent/setup/SKILL.md +1 -1
  14. package/pipeline/commands/multi-agent/sync/SKILL.md +3 -3
  15. package/pipeline/lib/analysis-jira-write.sh +105 -22
  16. package/pipeline/lib/decisions.mjs +130 -0
  17. package/pipeline/lib/plan-todos.sh +156 -33
  18. package/pipeline/lib/task-graph.mjs +446 -0
  19. package/pipeline/lib/tdd-evidence.mjs +579 -0
  20. package/pipeline/multi-agent-refs/analysis-template-corporate.md +2 -0
  21. package/pipeline/multi-agent-refs/analysis-template.md +4 -2
  22. package/pipeline/multi-agent-refs/cross-cli-contract.md +4 -4
  23. package/pipeline/multi-agent-refs/features/analysis-jira.md +51 -3
  24. package/pipeline/multi-agent-refs/features/autopilot-decisions.md +34 -0
  25. package/pipeline/multi-agent-refs/features/model-fallback.md +4 -2
  26. package/pipeline/multi-agent-refs/features/mutation-probe.md +58 -0
  27. package/pipeline/multi-agent-refs/features/plan-checks.md +67 -0
  28. package/pipeline/multi-agent-refs/features/plan-todos.md +40 -1
  29. package/pipeline/multi-agent-refs/features/rework-escalation.md +49 -0
  30. package/pipeline/multi-agent-refs/features/scope-check.md +1 -0
  31. package/pipeline/multi-agent-refs/generate-issue.md +1 -1
  32. package/pipeline/multi-agent-refs/help/en.md +1 -0
  33. package/pipeline/multi-agent-refs/help/tr.md +1 -0
  34. package/pipeline/multi-agent-refs/phases/phase-1-plan.md +2 -3
  35. package/pipeline/multi-agent-refs/phases/phase-2-dev.md +5 -7
  36. package/pipeline/multi-agent-refs/phases/phase-3-review.md +13 -11
  37. package/pipeline/multi-agent-refs/phases/phase-5-report.md +3 -3
  38. package/pipeline/multi-agent-refs/picker-contract.md +3 -2
  39. package/pipeline/multi-agent-refs/response-shape.md +65 -0
  40. package/pipeline/rules/response-shape.md +18 -0
  41. package/pipeline/schemas/agent-state.schema.json +31 -1
  42. package/pipeline/schemas/plan-todos.schema.json +101 -0
  43. package/pipeline/schemas/planning-output.schema.json +46 -0
  44. package/pipeline/schemas/prefs.schema.json +117 -1
  45. package/pipeline/scripts/_jsonl.mjs +151 -0
  46. package/pipeline/scripts/_plan-overlap.mjs +231 -0
  47. package/pipeline/scripts/_redact.mjs +124 -10
  48. package/pipeline/scripts/analysis-story-body.mjs +26 -10
  49. package/pipeline/scripts/analysis-story-tree.mjs +210 -9
  50. package/pipeline/scripts/autopilot-publish.mjs +12 -1
  51. package/pipeline/scripts/cost-analyze.mjs +6 -24
  52. package/pipeline/scripts/decisions-log.mjs +118 -0
  53. package/pipeline/scripts/edit-syntax-gate.mjs +240 -0
  54. package/pipeline/scripts/edit-syntax-gate.sh +29 -0
  55. package/pipeline/scripts/find-polluter.mjs +281 -0
  56. package/pipeline/scripts/gate-ledger.mjs +46 -13
  57. package/pipeline/scripts/mutation-probe.mjs +484 -0
  58. package/pipeline/scripts/plan-complexity.mjs +259 -0
  59. package/pipeline/scripts/review-decision-gate.mjs +33 -1
  60. package/pipeline/scripts/rework-escalation.mjs +264 -0
  61. package/pipeline/scripts/test-strength.mjs +5 -3
  62. package/pipeline/scripts/transcript-index.mjs +950 -0
  63. package/pipeline/scripts/validate-planning.mjs +48 -6
  64. package/pipeline/skills/.skill-manifest.json +11 -7
  65. package/pipeline/skills/.skills-index.json +12 -1
  66. package/pipeline/skills/shared/README.md +4 -3
  67. package/pipeline/skills/shared/core/multi-agent-analysis-jira/SKILL.md +11 -2
  68. package/pipeline/skills/shared/core/multi-agent-postmortem/SKILL.md +66 -0
  69. package/pipeline/skills/shared/core/multi-agent-setup/SKILL.md +1 -1
  70. package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +3 -3
  71. package/pipeline/skills/shared/external/app-store-optimization/SKILL.md +15 -0
  72. package/pipeline/skills/shared/external/app-store-optimization/evals/evals.json +13 -0
  73. package/pipeline/skills/shared/external/debugging-strategies/SKILL.md +8 -0
  74. package/pipeline/skills/skills-index.md +2 -1
@@ -0,0 +1,484 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * mutation-probe.mjs - would the tests notice if a changed line were wrong?
4
+ *
5
+ * A passing suite says the changed code runs, not that the tests check what it
6
+ * does. This makes a handful of small textual mutants, only on lines the diff
7
+ * added or changed, runs the test command against each, and reports every
8
+ * mutant the tests did not catch. A survivor is a line whose behaviour can be
9
+ * inverted without one test going red.
10
+ *
11
+ * Mutations are textual and limited to patterns that read the same in most
12
+ * languages, each needing whitespace on both sides of the operator so generics,
13
+ * arrows and unary signs are never touched:
14
+ *
15
+ * comparison == != === !== < <= > >= flipped to the opposite
16
+ * boolean true/false, True/False negated
17
+ * arithmetic binary + and - swapped
18
+ * return `return <number>` 0 becomes 1, anything else 0
19
+ *
20
+ * Comment lines and matches inside a string literal or after `//` are skipped.
21
+ * Only source files are mutated (SOURCE_EXTENSIONS): docs and config such as
22
+ * package.json, Markdown or YAML carry `true` and ` - ` too, but flipping them
23
+ * says nothing about the tests. Test files (every stack adapter's
24
+ * testFilePatterns) are never mutated, and neither is a symlink or any path
25
+ * whose real location is outside the temporary worktree.
26
+ * Mutants are spread across lines: one per changed line in diff order, then a
27
+ * second per line, until --max-mutants.
28
+ *
29
+ * The caller's checkout is never written: mutants are applied in a detached
30
+ * worktree under --work-root (the system temp directory by default, never
31
+ * inside the repo), which is removed again, including on SIGINT/SIGTERM. Only
32
+ * `.git/worktrees` metadata is written, and it is pruned on the way out.
33
+ *
34
+ * Usage:
35
+ * node mutation-probe.mjs --repo <dir> --base <ref> --test-cmd "<command>"
36
+ * [--head <ref>|worktree] [--max-mutants n] [--timeout-sec n]
37
+ * [--link <repo-relative path>]... [--work-root <dir>] [--out <file>] [--json]
38
+ *
39
+ * The test command runs from the worktree root through /bin/sh. --head
40
+ * worktree probes the uncommitted working tree (test-strength.mjs snapshot).
41
+ * --max-mutants and --timeout-sec default to `mutationProbe.maxMutants` and
42
+ * `mutationProbe.timeoutSec` in the prefs file ($MULTI_AGENT_PREFS, else
43
+ * ~/.claude/multi-agent-preferences.json), then to 5 and 120.
44
+ *
45
+ * Output: {base, head, testCommand, baseline, mutants[], findings[]}. Each
46
+ * finding is reviewer-shaped (severity, file, line, issue, fix) plus
47
+ * `mutation`, `testCommand` and `tag: "mutation_probe"`, so Phase 3 Step 3.0
48
+ * merges it like the other deterministic findings.
49
+ *
50
+ * Exit: 0 every mutant killed (or timed out) · 1 at least one survivor ·
51
+ * 2 usage or setup error · 4 nothing judged (no mutable changed line,
52
+ * or the test command does not pass before mutation)
53
+ *
54
+ * @module pipeline/scripts/mutation-probe
55
+ */
56
+
57
+ import { spawnSync } from "node:child_process";
58
+ import {
59
+ existsSync,
60
+ lstatSync,
61
+ mkdtempSync,
62
+ readFileSync,
63
+ realpathSync,
64
+ rmSync,
65
+ symlinkSync,
66
+ writeFileSync,
67
+ } from "node:fs";
68
+ import { homedir, tmpdir } from "node:os";
69
+ import { extname, join, relative, resolve, sep } from "node:path";
70
+
71
+ import { invokedDirectly } from "../lib/invoked-directly.mjs";
72
+ import { parseDiffFiles } from "./_diff-impact.mjs";
73
+ import { testFileMatcher } from "./_stack-adapter.mjs";
74
+ import { runCommand, snapshotWorktree } from "./test-strength.mjs";
75
+
76
+ const DEFAULT_MAX_MUTANTS = 5;
77
+ const DEFAULT_TIMEOUT_SEC = 120;
78
+ const WORKTREE_HEAD = "worktree";
79
+
80
+ class UsageError extends Error {}
81
+
82
+ const COMPARISON = [
83
+ ["===", "!=="],
84
+ ["!==", "==="],
85
+ ["==", "!="],
86
+ ["!=", "=="],
87
+ ["<=", ">"],
88
+ [">=", "<"],
89
+ ["<", ">="],
90
+ [">", "<="],
91
+ ];
92
+
93
+ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
94
+
95
+ const RULES = [
96
+ ...COMPARISON.map(([from, to]) => ({
97
+ re: new RegExp(`(?<=\\s)${escapeRe(from)}(?=\\s)`, "g"),
98
+ to,
99
+ })),
100
+ { re: /\btrue\b/g, to: "false" },
101
+ { re: /\bfalse\b/g, to: "true" },
102
+ { re: /\bTrue\b/g, to: "False" },
103
+ { re: /\bFalse\b/g, to: "True" },
104
+ { re: /(?<=\s)\+(?=\s)/g, to: "-" },
105
+ { re: /(?<=\s)-(?=\s)/g, to: "+" },
106
+ ];
107
+
108
+ const SOURCE_EXTENSIONS = new Set(
109
+ "swift kt kts java js jsx mjs cjs ts tsx mts cts py go rb rs c h cc cpp hpp m mm cs dart php scala"
110
+ .split(" ")
111
+ .map((e) => `.${e}`),
112
+ );
113
+
114
+ /** True for a path whose extension names a programming-language source file. */
115
+ export function isSourceFile(path) {
116
+ return SOURCE_EXTENSIONS.has(extname(path).toLowerCase());
117
+ }
118
+
119
+ const RETURN_NUMBER = /\breturn\s+(-?\d+(?:\.\d+)?)\b/g;
120
+ const COMMENT_START = /^\s*(?:\/\/|\/\*|\*|#|--|<!--)/;
121
+
122
+ /** True when `index` sits inside a quoted string or after a `//` comment. */
123
+ function shielded(line, index) {
124
+ let quote = null;
125
+ for (let i = 0; i < index; i++) {
126
+ const c = line[i];
127
+ if (quote) {
128
+ if (c === "\\") i++;
129
+ else if (c === quote) quote = null;
130
+ continue;
131
+ }
132
+ if (c === '"' || c === "'" || c === "`") quote = c;
133
+ else if (c === "/" && line[i + 1] === "/") return true;
134
+ }
135
+ return quote !== null;
136
+ }
137
+
138
+ /**
139
+ * Every single-site mutation of one source line, in rule order.
140
+ * @param {string} line
141
+ * @returns {Array<{index: number, from: string, to: string, replacement: string}>}
142
+ */
143
+ export function candidateMutations(line) {
144
+ if (COMMENT_START.test(line)) return [];
145
+ const out = [];
146
+ const add = (index, from, to) => {
147
+ if (shielded(line, index)) return;
148
+ out.push({
149
+ index,
150
+ from,
151
+ to,
152
+ replacement: line.slice(0, index) + to + line.slice(index + from.length),
153
+ });
154
+ };
155
+ for (const { re, to } of RULES) {
156
+ for (const m of line.matchAll(re)) add(m.index, m[0], to);
157
+ }
158
+ for (const m of line.matchAll(RETURN_NUMBER)) {
159
+ const num = m[1];
160
+ const at = m.index + m[0].length - num.length;
161
+ add(at, num, Number(num) === 0 ? "1" : "0");
162
+ }
163
+ return out;
164
+ }
165
+
166
+ /**
167
+ * New-side lines the diff added or changed, per surviving file.
168
+ * @param {string} diffText
169
+ * @returns {Array<{file: string, lines: number[]}>}
170
+ */
171
+ export function changedLines(diffText) {
172
+ const out = [];
173
+ for (const f of parseDiffFiles(diffText)) {
174
+ if (!f.newPath) continue;
175
+ const lines = [];
176
+ for (const r of f.newRanges) {
177
+ if (r.gap) continue;
178
+ for (let l = r.start; l <= r.end; l++) lines.push(l);
179
+ }
180
+ if (lines.length) out.push({ file: f.newPath, lines });
181
+ }
182
+ return out;
183
+ }
184
+
185
+ /**
186
+ * Up to `max` mutants, one per changed line in diff order before any line gets
187
+ * a second.
188
+ * @param {Array<{file: string, lines: number[]}>} changed
189
+ * @param {Map<string, string>} sources file -> content
190
+ * @param {number} max
191
+ */
192
+ export function pickMutants(changed, sources, max) {
193
+ const perLine = [];
194
+ for (const { file, lines } of changed) {
195
+ const text = sources.get(file);
196
+ if (typeof text !== "string") continue;
197
+ const rows = text.split("\n");
198
+ for (const line of lines) {
199
+ const src = rows[line - 1];
200
+ if (src === undefined) continue;
201
+ const c = candidateMutations(src);
202
+ if (c.length) perLine.push({ file, line, candidates: c });
203
+ }
204
+ }
205
+ const picked = [];
206
+ for (let round = 0; picked.length < max; round++) {
207
+ let any = false;
208
+ for (const entry of perLine) {
209
+ const c = entry.candidates[round];
210
+ if (!c) continue;
211
+ any = true;
212
+ picked.push({
213
+ file: entry.file,
214
+ line: entry.line,
215
+ mutation: `"${c.from}" -> "${c.to}"`,
216
+ replacement: c.replacement,
217
+ });
218
+ if (picked.length >= max) break;
219
+ }
220
+ if (!any) break;
221
+ }
222
+ return picked;
223
+ }
224
+
225
+ function git(cwd, args, { timeout = 60000 } = {}) {
226
+ const res = spawnSync("git", ["-C", cwd, ...args], {
227
+ encoding: "utf8",
228
+ timeout,
229
+ maxBuffer: 64 * 1024 * 1024,
230
+ });
231
+ return { ok: res.status === 0, out: (res.stdout || "").trim(), err: (res.stderr || "").trim() };
232
+ }
233
+
234
+ function readProbePrefs() {
235
+ const candidates = [
236
+ process.env.MULTI_AGENT_PREFS,
237
+ join(process.env.HOME || homedir(), ".claude", "multi-agent-preferences.json"),
238
+ ].filter(Boolean);
239
+ for (const p of candidates) {
240
+ if (!existsSync(p)) continue;
241
+ try {
242
+ const g = JSON.parse(readFileSync(p, "utf8"))?.global ?? {};
243
+ return {
244
+ maxMutants: g.mutationProbe?.maxMutants,
245
+ timeoutSec: g.mutationProbe?.timeoutSec,
246
+ };
247
+ } catch {
248
+ return {};
249
+ }
250
+ }
251
+ return {};
252
+ }
253
+
254
+ const positive = (v) => (Number.isInteger(v) && v > 0 ? v : undefined);
255
+
256
+ function parseArgs(argv) {
257
+ const opts = { links: [], head: "HEAD" };
258
+ for (let i = 0; i < argv.length; i++) {
259
+ const a = argv[i];
260
+ const val = () => {
261
+ const v = argv[++i];
262
+ if (v === undefined) throw new UsageError(`${a} needs a value`);
263
+ return v;
264
+ };
265
+ if (a === "--repo") opts.repo = val();
266
+ else if (a === "--base") opts.base = val();
267
+ else if (a === "--head") opts.head = val();
268
+ else if (a === "--test-cmd") opts.testCmd = val();
269
+ else if (a === "--max-mutants") opts.maxMutants = Number(val());
270
+ else if (a === "--timeout-sec") opts.timeoutSec = Number(val());
271
+ else if (a === "--link") opts.links.push(val());
272
+ else if (a === "--work-root") opts.workRoot = val();
273
+ else if (a === "--out") opts.out = val();
274
+ else if (a === "--json") opts.json = true;
275
+ else throw new UsageError(`unexpected argument ${a}`);
276
+ }
277
+ if (!opts.base) throw new UsageError("--base is required");
278
+ if (!opts.testCmd) throw new UsageError("--test-cmd is required");
279
+ const prefs = readProbePrefs();
280
+ opts.maxMutants ??= positive(prefs.maxMutants) ?? DEFAULT_MAX_MUTANTS;
281
+ opts.timeoutSec ??= positive(prefs.timeoutSec) ?? DEFAULT_TIMEOUT_SEC;
282
+ if (!positive(opts.maxMutants)) throw new UsageError("--max-mutants must be a positive integer");
283
+ if (!(opts.timeoutSec > 0)) throw new UsageError("--timeout-sec must be a positive number");
284
+ return opts;
285
+ }
286
+
287
+ function within(child, parent) {
288
+ const rel = relative(parent, child);
289
+ return rel === "" || (!rel.startsWith("..") && !rel.startsWith(sep) && rel !== "..");
290
+ }
291
+
292
+ /**
293
+ * True when `full` is a regular file (not a symlink) whose real path lies
294
+ * inside `realRoot`, so writing it cannot reach anything outside the worktree.
295
+ */
296
+ function writableInside(full, realRoot) {
297
+ let st;
298
+ try {
299
+ st = lstatSync(full);
300
+ } catch {
301
+ return false;
302
+ }
303
+ if (!st.isFile()) return false;
304
+ let real;
305
+ try {
306
+ real = realpathSync(full);
307
+ } catch {
308
+ return false;
309
+ }
310
+ return real !== realRoot && within(real, realRoot);
311
+ }
312
+
313
+ function prepare(opts) {
314
+ const repoDir = resolve(opts.repo || process.cwd());
315
+ const top = git(repoDir, ["rev-parse", "--show-toplevel"]);
316
+ if (!top.ok) throw new UsageError(`not a git repository: ${repoDir}`);
317
+ const root = realpathSync(top.out);
318
+ const sha = (ref) => {
319
+ const r = git(root, ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]);
320
+ if (!r.ok) throw new UsageError(`cannot resolve ${ref}`);
321
+ return r.out;
322
+ };
323
+ const base = sha(opts.base);
324
+ const head = opts.head === WORKTREE_HEAD ? snapshotWorktree(root) : sha(opts.head);
325
+ const workRoot = realpathSync(resolve(opts.workRoot || tmpdir()));
326
+ if (within(workRoot, root)) {
327
+ throw new UsageError(`--work-root ${workRoot} is inside the repository; refusing`);
328
+ }
329
+ return { root, base, head, workRoot };
330
+ }
331
+
332
+ /**
333
+ * @returns {Promise<{exit: number, report: object}>}
334
+ */
335
+ export async function mutationProbe(argv) {
336
+ const opts = parseArgs(argv);
337
+ const { root, base, head, workRoot } = prepare(opts);
338
+ const timeoutMs = Math.round(opts.timeoutSec * 1000);
339
+ const isTest = testFileMatcher();
340
+
341
+ const diff = git(root, ["diff", "--no-renames", "-U0", base, head]);
342
+ if (!diff.ok) throw new UsageError(`git diff failed: ${diff.err}`);
343
+ const changed = changedLines(diff.out).filter((c) => isSourceFile(c.file) && !isTest(c.file));
344
+
345
+ const report = {
346
+ base,
347
+ head,
348
+ testCommand: opts.testCmd,
349
+ maxMutants: opts.maxMutants,
350
+ baseline: null,
351
+ mutants: [],
352
+ findings: [],
353
+ worktreeRemoved: null,
354
+ };
355
+
356
+ const holder = mkdtempSync(join(workRoot, "mutation-probe-"));
357
+ const worktree = join(holder, "wt");
358
+ const cleanup = () => {
359
+ git(root, ["worktree", "remove", "--force", worktree]);
360
+ rmSync(holder, { recursive: true, force: true });
361
+ git(root, ["worktree", "prune"]);
362
+ };
363
+ let activeGroup = 0;
364
+ const track = { onSpawn: (pid) => (activeGroup = pid) };
365
+ const onSignal = (sig) => {
366
+ // The test command runs detached, in its own process group, so it would
367
+ // outlive this process and keep writing into the worktree being removed.
368
+ if (activeGroup) {
369
+ try {
370
+ process.kill(-activeGroup, "SIGKILL");
371
+ } catch (err) {
372
+ if (err.code !== "ESRCH") throw err;
373
+ }
374
+ }
375
+ cleanup();
376
+ process.exit(sig === "SIGINT" ? 130 : 143);
377
+ };
378
+ process.once("SIGINT", onSignal);
379
+ process.once("SIGTERM", onSignal);
380
+
381
+ let exit;
382
+ try {
383
+ const add = git(root, ["worktree", "add", "--detach", worktree, head], { timeout: 120000 });
384
+ if (!add.ok) throw new Error(`git worktree add failed: ${add.err}`);
385
+ for (const rel of opts.links) {
386
+ const src = join(root, rel);
387
+ const dst = join(worktree, rel);
388
+ if (existsSync(src) && !existsSync(dst) && within(dst, worktree)) symlinkSync(src, dst);
389
+ }
390
+
391
+ const realWorktree = realpathSync(worktree);
392
+ const sources = new Map();
393
+ for (const { file } of changed) {
394
+ const full = join(worktree, file);
395
+ if (writableInside(full, realWorktree)) sources.set(file, readFileSync(full, "utf8"));
396
+ }
397
+ const mutants = pickMutants(changed, sources, opts.maxMutants);
398
+ if (mutants.length === 0) {
399
+ report.reason = "no changed line carries a supported mutation";
400
+ return { exit: 4, report };
401
+ }
402
+
403
+ const base0 = await runCommand(opts.testCmd, worktree, timeoutMs, track);
404
+ activeGroup = 0;
405
+ report.baseline = base0.timedOut ? "timeout" : base0.code === 0 ? "pass" : "fail";
406
+ if (report.baseline !== "pass") {
407
+ report.reason = "the test command does not pass before mutation, so a red run proves nothing";
408
+ return { exit: 4, report };
409
+ }
410
+
411
+ for (const m of mutants) {
412
+ const full = join(worktree, m.file);
413
+ if (!writableInside(full, realWorktree)) continue;
414
+ const original = sources.get(m.file);
415
+ const rows = original.split("\n");
416
+ rows[m.line - 1] = m.replacement;
417
+ writeFileSync(full, rows.join("\n"));
418
+ let run;
419
+ try {
420
+ run = await runCommand(opts.testCmd, worktree, timeoutMs, track);
421
+ } finally {
422
+ activeGroup = 0;
423
+ writeFileSync(full, original);
424
+ }
425
+ const status = run.timedOut
426
+ ? "timeout"
427
+ : run.code === null
428
+ ? "error"
429
+ : run.code === 0
430
+ ? "survived"
431
+ : "killed";
432
+ report.mutants.push({ file: m.file, line: m.line, mutation: m.mutation, status });
433
+ if (status === "survived") {
434
+ report.findings.push({
435
+ severity: "important",
436
+ file: m.file,
437
+ line: m.line,
438
+ issue: `Mutant survived: ${m.mutation} on this changed line and \`${opts.testCmd}\` still passes, so no test checks what this line decides.`,
439
+ fix: "Add an assertion that fails when this line's operator or value is inverted.",
440
+ mutation: m.mutation,
441
+ testCommand: opts.testCmd,
442
+ tag: "mutation_probe",
443
+ });
444
+ }
445
+ }
446
+ exit = report.findings.length > 0 ? 1 : 0;
447
+ } finally {
448
+ cleanup();
449
+ process.removeListener("SIGINT", onSignal);
450
+ process.removeListener("SIGTERM", onSignal);
451
+ report.worktreeRemoved = !existsSync(worktree) && !existsSync(holder);
452
+ }
453
+ return { exit, report };
454
+ }
455
+
456
+ async function main(argv) {
457
+ let result;
458
+ try {
459
+ result = await mutationProbe(argv);
460
+ } catch (err) {
461
+ process.stderr.write(`mutation-probe: ${err.message}\n`);
462
+ if (err instanceof UsageError) {
463
+ process.stderr.write(
464
+ "usage: mutation-probe.mjs --repo <dir> --base <ref> --test-cmd <cmd> [--head <ref>|worktree] [--max-mutants n] [--timeout-sec n] [--link path] [--work-root dir] [--out file] [--json]\n",
465
+ );
466
+ }
467
+ return 2;
468
+ }
469
+ const { exit, report } = result;
470
+ const outIdx = argv.indexOf("--out");
471
+ if (outIdx !== -1) writeFileSync(argv[outIdx + 1], `${JSON.stringify(report, null, 2)}\n`);
472
+ if (argv.includes("--json")) process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
473
+ else {
474
+ for (const m of report.mutants) {
475
+ process.stdout.write(`${m.status.padEnd(8)} ${m.file}:${m.line} ${m.mutation}\n`);
476
+ }
477
+ if (report.reason) process.stdout.write(`${report.reason}\n`);
478
+ }
479
+ return exit;
480
+ }
481
+
482
+ if (invokedDirectly(import.meta.url)) {
483
+ main(process.argv.slice(2)).then((code) => process.exit(code));
484
+ }