@mmerterden/multi-agent-pipeline 20.23.2 → 20.25.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 +46 -0
- package/README.md +8 -6
- package/README.tr.md +7 -5
- package/docs/architecture.md +3 -3
- package/docs/ecosystem.md +5 -5
- package/docs/facts.json +7 -6
- package/docs/features.md +9 -4
- package/install/catalog-history.json +1 -1
- package/install/templates/claude-hooks.json +14 -1
- package/install/templates/codex-hooks.json +18 -0
- package/manifest.json +126 -98
- package/package.json +1 -1
- package/pipeline/agents/task-clarifier.md +1 -1
- package/pipeline/commands/multi-agent/analysis-jira/SKILL.md +4 -2
- package/pipeline/commands/multi-agent/deps/SKILL.md +61 -0
- package/pipeline/commands/multi-agent/model/SKILL.md +2 -2
- package/pipeline/commands/multi-agent/postmortem/SKILL.md +90 -0
- package/pipeline/commands/multi-agent/review/SKILL.md +5 -5
- package/pipeline/commands/multi-agent/route-on/SKILL.md +1 -1
- package/pipeline/commands/multi-agent/setup/SKILL.md +7 -5
- package/pipeline/commands/multi-agent/sync/SKILL.md +4 -4
- package/pipeline/commands/multi-agent/testflight-validation/SKILL.md +2 -3
- package/pipeline/contract/fixtures/runs-failed.json +1 -1
- package/pipeline/contract/fixtures/runs-old-schema.json +1 -1
- package/pipeline/contract/fixtures/runs-pr-opened-redacted.json +1 -1
- package/pipeline/contract/fixtures/runs-pr-opened.json +1 -1
- package/pipeline/contract/fixtures/runs-running.json +1 -1
- package/pipeline/lib/analysis-jira-write.sh +70 -0
- package/pipeline/lib/credential-inventory.sh +5 -5
- package/pipeline/lib/decisions.mjs +130 -0
- package/pipeline/lib/fetch-crashlytics.sh +6 -8
- package/pipeline/lib/model-rung.sh +1 -1
- package/pipeline/lib/plan-todos.sh +177 -37
- package/pipeline/lib/post-pr-review.sh +4 -6
- package/pipeline/lib/route-state.sh +1 -1
- package/pipeline/lib/shadow-git.sh +4 -5
- 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 +10 -11
- package/pipeline/multi-agent-refs/features/analysis-jira.md +42 -0
- package/pipeline/multi-agent-refs/features/autopilot-decisions.md +34 -0
- package/pipeline/multi-agent-refs/features/cost-analysis.md +4 -0
- package/pipeline/multi-agent-refs/features/dep-bump.md +176 -0
- package/pipeline/multi-agent-refs/features/model-fallback.md +21 -14
- 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/help/en.md +2 -0
- package/pipeline/multi-agent-refs/help/tr.md +2 -0
- package/pipeline/multi-agent-refs/phases/phase-0-init.md +1 -1
- package/pipeline/multi-agent-refs/phases/phase-1-plan.md +2 -3
- package/pipeline/multi-agent-refs/phases/phase-2-dev.md +7 -7
- package/pipeline/multi-agent-refs/phases/phase-3-review.md +18 -16
- 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/multi-agent-refs/tracker-contract.md +6 -6
- package/pipeline/rules/response-shape.md +18 -0
- package/pipeline/schemas/agent-state.schema.json +36 -1
- package/pipeline/schemas/phases.json +4 -0
- package/pipeline/schemas/plan-todos.schema.json +104 -3
- package/pipeline/schemas/planning-output.schema.json +46 -0
- package/pipeline/schemas/prefs.schema.json +122 -3
- package/pipeline/scripts/_code-graph.mjs +2 -2
- 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/_transcript-hosts.mjs +406 -0
- package/pipeline/scripts/analysis-story-tree.mjs +153 -8
- package/pipeline/scripts/autopilot-publish.mjs +12 -1
- package/pipeline/scripts/build-stack-plugins.mjs +3 -3
- package/pipeline/scripts/capture-flush.sh +7 -7
- package/pipeline/scripts/code-graph-rules/android.json +1 -1
- package/pipeline/scripts/cost-analyze.mjs +32 -43
- package/pipeline/scripts/cost-table.json +35 -19
- package/pipeline/scripts/decisions-log.mjs +118 -0
- package/pipeline/scripts/dep-bump.mjs +1556 -0
- package/pipeline/scripts/edit-syntax-gate.mjs +362 -0
- package/pipeline/scripts/edit-syntax-gate.sh +32 -0
- package/pipeline/scripts/find-polluter.mjs +281 -0
- package/pipeline/scripts/gate-ledger.mjs +46 -13
- package/pipeline/scripts/gc-abandoned.sh +2 -2
- package/pipeline/scripts/gen-mode-dispatch.mjs +3 -3
- package/pipeline/scripts/git-path.mjs +1 -2
- package/pipeline/scripts/host-hooks.mjs +265 -0
- package/pipeline/scripts/jira-attach.sh +1 -1
- package/pipeline/scripts/keychain.py +3 -3
- package/pipeline/scripts/mutation-probe.mjs +484 -0
- package/pipeline/scripts/output-quality-check.sh +1 -1
- package/pipeline/scripts/phase-tracker.sh +5 -6
- package/pipeline/scripts/phase0-exit-gate.mjs +3 -3
- package/pipeline/scripts/plan-complexity.mjs +259 -0
- package/pipeline/scripts/pre-push-check.sh +3 -6
- 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 +1131 -0
- package/pipeline/scripts/uninstall.mjs +90 -4
- package/pipeline/scripts/update-issue-progress.sh +4 -6
- package/pipeline/scripts/usage-report.mjs +2 -2
- package/pipeline/scripts/validate-planning.mjs +48 -6
- package/pipeline/skills/.skill-manifest.json +20 -12
- package/pipeline/skills/.skills-index.json +23 -1
- package/pipeline/skills/shared/README.md +5 -3
- package/pipeline/skills/shared/core/multi-agent/SKILL.md +6 -6
- package/pipeline/skills/shared/core/multi-agent-analysis-jira/SKILL.md +4 -2
- package/pipeline/skills/shared/core/multi-agent-deps/SKILL.md +60 -0
- package/pipeline/skills/shared/core/multi-agent-model/SKILL.md +2 -2
- package/pipeline/skills/shared/core/multi-agent-postmortem/SKILL.md +69 -0
- package/pipeline/skills/shared/core/multi-agent-review/SKILL.md +5 -5
- package/pipeline/skills/shared/core/multi-agent-route-on/SKILL.md +1 -1
- package/pipeline/skills/shared/core/multi-agent-setup/SKILL.md +7 -5
- package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +4 -4
- package/pipeline/skills/shared/core/multi-agent-testflight-validation/SKILL.md +2 -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/shared/external/ios-coding-standard/references/rules.yml +2 -2
- package/pipeline/skills/skills-index.md +3 -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
|
+
}
|
|
@@ -121,7 +121,7 @@ try:
|
|
|
121
121
|
except Exception:
|
|
122
122
|
sys.exit(0)
|
|
123
123
|
# named, decimal and hex - a pattern covering only the named forms would miss
|
|
124
|
-
#
|
|
124
|
+
# the decimal and hex spellings.
|
|
125
125
|
pat = re.compile(r'&(amp|lt|gt|quot|apos|nbsp|mdash|ndash|hellip|#[0-9]+|#[xX][0-9a-fA-F]+);')
|
|
126
126
|
for p in d.get('phases', []):
|
|
127
127
|
n = p.get('name', '')
|
|
@@ -230,8 +230,8 @@ usage_live_ping() {
|
|
|
230
230
|
#
|
|
231
231
|
# The program is fed through a QUOTED heredoc, not `node -e '...'`. Inside a
|
|
232
232
|
# single-quoted shell string every apostrophe in the JS (and in its comments)
|
|
233
|
-
# terminates the string early; shellcheck flags it as SC2140 and
|
|
234
|
-
#
|
|
233
|
+
# terminates the string early; shellcheck flags it as SC2140, and such code works
|
|
234
|
+
# only by accident of where the quotes happen to fall. A quoted
|
|
235
235
|
# heredoc passes the body through verbatim, so the JS can use any quote it likes.
|
|
236
236
|
decode_entities() {
|
|
237
237
|
local raw="$1" out
|
|
@@ -561,7 +561,7 @@ release_state_lock() {
|
|
|
561
561
|
trap release_state_lock EXIT
|
|
562
562
|
|
|
563
563
|
# The cost table is keyed by family ("opus", "sonnet", "gpt-5.6"), but the name
|
|
564
|
-
# an agent has at hand is the full id ("claude-opus-5"). Storing the id verbatim
|
|
564
|
+
# an agent has at hand is the full id ("claude-opus-5-5"). Storing the id verbatim
|
|
565
565
|
# priced every phase at "-" while the tracker looked perfectly healthy, so the
|
|
566
566
|
# family is resolved here, once, and an unresolvable name is said out loud
|
|
567
567
|
# instead of quietly costing nothing.
|
|
@@ -1183,9 +1183,8 @@ case "$ACTION" in
|
|
|
1183
1183
|
esac
|
|
1184
1184
|
# Accounting gate. A phase that ran LLM work and recorded nothing produces a
|
|
1185
1185
|
# completion line with nothing to say and a cost breakdown that reads as
|
|
1186
|
-
# unavailable
|
|
1187
|
-
#
|
|
1188
|
-
# for the CALL and nothing checks that the call happened. Refusing the
|
|
1186
|
+
# unavailable. The gates lint the docs for the CALL; only this point can
|
|
1187
|
+
# check that the call happened. Refusing the
|
|
1189
1188
|
# completion is recoverable (record, then re-run) and loses no work; a
|
|
1190
1189
|
# phase that genuinely made no LLM call says so with --no-llm.
|
|
1191
1190
|
if [ "$STATUS" = "completed" ] && [ "$NO_LLM" -eq 0 ]; then
|
|
@@ -146,9 +146,9 @@ export function evaluate(state, extraInput = "", { env = process.env } = {}) {
|
|
|
146
146
|
);
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
-
// Step 3 picks the base branch and Step 8 creates the worktree. A run that
|
|
150
|
-
// both develops on whatever the checkout
|
|
151
|
-
//
|
|
149
|
+
// Step 3 picks the base branch and Step 8 creates the worktree. A run that skips
|
|
150
|
+
// both develops on whatever the checkout happens to have: a Jira task implemented
|
|
151
|
+
// on a stale local branch with no picker ever shown. The state file
|
|
152
152
|
// is the only durable evidence those steps ran, so it has to carry their output.
|
|
153
153
|
//
|
|
154
154
|
// `local` provider runs still choose a base branch; what they skip is push and PR.
|