@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.
- package/CHANGELOG.md +30 -1
- package/README.md +7 -6
- package/README.tr.md +6 -5
- package/docs/architecture.md +3 -3
- package/docs/ecosystem.md +5 -5
- package/docs/facts.json +3 -3
- package/install/catalog-history.json +1 -1
- package/install/templates/claude-hooks.json +14 -1
- package/manifest.json +77 -56
- package/package.json +1 -1
- package/pipeline/commands/multi-agent/analysis-jira/SKILL.md +11 -2
- package/pipeline/commands/multi-agent/postmortem/SKILL.md +78 -0
- package/pipeline/commands/multi-agent/setup/SKILL.md +1 -1
- package/pipeline/commands/multi-agent/sync/SKILL.md +3 -3
- package/pipeline/lib/analysis-jira-write.sh +105 -22
- package/pipeline/lib/decisions.mjs +130 -0
- package/pipeline/lib/plan-todos.sh +156 -33
- package/pipeline/lib/task-graph.mjs +446 -0
- package/pipeline/lib/tdd-evidence.mjs +579 -0
- package/pipeline/multi-agent-refs/analysis-template-corporate.md +2 -0
- package/pipeline/multi-agent-refs/analysis-template.md +4 -2
- package/pipeline/multi-agent-refs/cross-cli-contract.md +4 -4
- package/pipeline/multi-agent-refs/features/analysis-jira.md +51 -3
- package/pipeline/multi-agent-refs/features/autopilot-decisions.md +34 -0
- package/pipeline/multi-agent-refs/features/model-fallback.md +4 -2
- package/pipeline/multi-agent-refs/features/mutation-probe.md +58 -0
- package/pipeline/multi-agent-refs/features/plan-checks.md +67 -0
- package/pipeline/multi-agent-refs/features/plan-todos.md +40 -1
- package/pipeline/multi-agent-refs/features/rework-escalation.md +49 -0
- package/pipeline/multi-agent-refs/features/scope-check.md +1 -0
- package/pipeline/multi-agent-refs/generate-issue.md +1 -1
- package/pipeline/multi-agent-refs/help/en.md +1 -0
- package/pipeline/multi-agent-refs/help/tr.md +1 -0
- package/pipeline/multi-agent-refs/phases/phase-1-plan.md +2 -3
- package/pipeline/multi-agent-refs/phases/phase-2-dev.md +5 -7
- package/pipeline/multi-agent-refs/phases/phase-3-review.md +13 -11
- package/pipeline/multi-agent-refs/phases/phase-5-report.md +3 -3
- package/pipeline/multi-agent-refs/picker-contract.md +3 -2
- package/pipeline/multi-agent-refs/response-shape.md +65 -0
- package/pipeline/rules/response-shape.md +18 -0
- package/pipeline/schemas/agent-state.schema.json +31 -1
- package/pipeline/schemas/plan-todos.schema.json +101 -0
- package/pipeline/schemas/planning-output.schema.json +46 -0
- package/pipeline/schemas/prefs.schema.json +117 -1
- package/pipeline/scripts/_jsonl.mjs +151 -0
- package/pipeline/scripts/_plan-overlap.mjs +231 -0
- package/pipeline/scripts/_redact.mjs +124 -10
- package/pipeline/scripts/analysis-story-body.mjs +26 -10
- package/pipeline/scripts/analysis-story-tree.mjs +210 -9
- package/pipeline/scripts/autopilot-publish.mjs +12 -1
- package/pipeline/scripts/cost-analyze.mjs +6 -24
- package/pipeline/scripts/decisions-log.mjs +118 -0
- package/pipeline/scripts/edit-syntax-gate.mjs +240 -0
- package/pipeline/scripts/edit-syntax-gate.sh +29 -0
- package/pipeline/scripts/find-polluter.mjs +281 -0
- package/pipeline/scripts/gate-ledger.mjs +46 -13
- package/pipeline/scripts/mutation-probe.mjs +484 -0
- package/pipeline/scripts/plan-complexity.mjs +259 -0
- package/pipeline/scripts/review-decision-gate.mjs +33 -1
- package/pipeline/scripts/rework-escalation.mjs +264 -0
- package/pipeline/scripts/test-strength.mjs +5 -3
- package/pipeline/scripts/transcript-index.mjs +950 -0
- package/pipeline/scripts/validate-planning.mjs +48 -6
- package/pipeline/skills/.skill-manifest.json +11 -7
- package/pipeline/skills/.skills-index.json +12 -1
- package/pipeline/skills/shared/README.md +4 -3
- package/pipeline/skills/shared/core/multi-agent-analysis-jira/SKILL.md +11 -2
- package/pipeline/skills/shared/core/multi-agent-postmortem/SKILL.md +66 -0
- package/pipeline/skills/shared/core/multi-agent-setup/SKILL.md +1 -1
- package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +3 -3
- package/pipeline/skills/shared/external/app-store-optimization/SKILL.md +15 -0
- package/pipeline/skills/shared/external/app-store-optimization/evals/evals.json +13 -0
- package/pipeline/skills/shared/external/debugging-strategies/SKILL.md +8 -0
- 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
|
+
}
|