probatio 0.1.0 → 0.1.3
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/README.md +50 -14
- package/dist/cli.js +101 -53
- package/dist/contract.js +7 -5
- package/dist/mcp.js +23 -0
- package/dist/mutate/child-lines.mjs +34 -0
- package/dist/mutate/coverage-map.js +75 -18
- package/dist/mutate/generate.js +25 -4
- package/dist/mutate/mocha-coverage.cjs +71 -0
- package/dist/mutate/node-batch-loader.mjs +85 -0
- package/dist/mutate/node-batch.mjs +130 -0
- package/dist/mutate/node-coverage.mjs +37 -180
- package/dist/mutate/precise-lines.mjs +188 -0
- package/dist/mutate/run.js +319 -47
- package/dist/mutate/rust-coverage.js +4 -19
- package/dist/mutate/sealed.js +40 -1
- package/dist/mutate/suite-decision.js +1 -1
- package/dist/mutate/suites.js +88 -12
- package/dist/mutate/tally.js +38 -1
- package/dist/node-version.js +13 -0
- package/dist/swarm/queue.js +13 -7
- package/dist/verify/change.js +71 -2
- package/package.json +6 -4
package/README.md
CHANGED
|
@@ -1,21 +1,38 @@
|
|
|
1
1
|
# Probatio
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
(Claude, Grok, ChatGPT, others), working alone or as swarms. Every claim it makes is checkable.
|
|
5
|
-
It remembers what was fixed so agents with small context do not redo or undo work, and it shows
|
|
6
|
-
which tests already touch a change.
|
|
3
|
+
[](https://www.npmjs.com/package/probatio)
|
|
7
4
|
|
|
8
|
-
|
|
5
|
+
Probatio scores a test suite by the bugs it already catches. A kill is an assertion the suite had. The kill count is not a merge gate.
|
|
9
6
|
|
|
10
|
-
|
|
7
|
+
Node 22.6 or newer:
|
|
11
8
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
9
|
+
```bash
|
|
10
|
+
npm install probatio
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npx probatio mutate generate --package . --out .probatio/generate
|
|
15
|
+
npx probatio mutate run --package . --patches .probatio/generate/mutants --out .probatio/runs
|
|
16
|
+
npx probatio mutate tally --out .probatio/runs
|
|
17
|
+
```
|
|
16
18
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
+
Each command prints one JSON object and exits 0 only when `ok` is true.
|
|
20
|
+
|
|
21
|
+
```json
|
|
22
|
+
{
|
|
23
|
+
"schemaVersion": 1,
|
|
24
|
+
"ok": true,
|
|
25
|
+
"summary": "0 no coverage, 0 survived, 1 killed, 0 flaky, 0 timed out, 0 errored, of 1 finished.",
|
|
26
|
+
"next": "No survivor in this batch.",
|
|
27
|
+
"nextCall": { "argv": ["mutate", "tally", "--out", ".probatio/runs"] }
|
|
28
|
+
}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Agents changing this repo should read [AGENTS.md](AGENTS.md). The MCP server is `probatio mcp`. Snippets are in the MCP section below. People building Probatio should read [docs/builders.md](docs/builders.md). Small suites live in [examples/](examples/).
|
|
32
|
+
|
|
33
|
+
## Status
|
|
34
|
+
|
|
35
|
+
Status (2026-10-06): `mutate run` discovers the project suite and scores it. Node, pytest, C (when the binary was built with LLVM coverage), Go, Maven/Java, Rust, C#, and Mocha collect a line map on the baseline. Mocha's map comes from its own root hooks. Loading the node:test collector under Mocha does not name Mocha tests. A line no test executes is `no coverage`, and the suite is not started. A plain Node script can write the lines it ran, and the parent test that reads that dump stores them under its own name. A child the parent does not read stays `no coverage`, and the suite is not started. A suite with no line map, a baseline of 5 seconds or more, and more than 30 mutants stops before the first mutant. `verify-change` names the tests the line map ran. It scores `src/`, a `lib/` file when the package tests import `lib/`, and a Go file beside `go.mod`. Tests, docs, `dist/`, and `node_modules` stay unseen. `ran` stays the direct-importer list. Covered mutants of one Node file run one after another inside the suite process that is already running. `--workers` stays 1. A Node test that sets its own `timeout` keeps that time when `--test-timeout-ms` is shorter. A mutant that crashes or leaves that process dirty ends it, and the next mutant starts clean. An uncovered line still does not start the suite. `mutate tally` does not delete a test. A kill is an assertion the suite already had. A sealed miss means the suite did not see the bug. The kill count is not a merge gate. On Commons CSV (Apache-2.0, commit `2c83a308`), two sealed runs agreed: with the new test file hidden, `CSVFormatTest.testFormatThrowsNullPointerException` failed on the reverted printer. `deletedTests` stayed 0. That older JUnit platform did not write a line map, so the first pass was the whole suite and confirm reran the older test. Auspex, markupsafe, and a hand-planted fixture are not that result. The package version in this repository is 0.1.3. npm latest is 0.1.2 (`29301bc`). The BugsInPy search row is still open, and the rows that apply only when a sealed run misses do not apply, because this catch held. The MCP server speaks one JSON object per line and returns the same JSON as the CLI.
|
|
19
36
|
|
|
20
37
|
## mutate
|
|
21
38
|
|
|
@@ -31,7 +48,7 @@ npx probatio mutate tally --out .probatio/runs
|
|
|
31
48
|
|
|
32
49
|
`run` discovers the suite: `run_tests.sh`, Cargo, Go, Swift, Maven, dotnet, pytest, unittest, node:test, Mocha, then `make test` when COBOL tests sit under that Makefile. An unknown layout stops and asks for `--suite-command`. It does not compile one file and call that the suite. `make test` that would curl or wget a missing file stops, and nothing is fetched. A worktree that has no `node_modules` uses the main checkout's. Omitting `--build` runs no build. Pass `--build` with a command when the suite needs one first.
|
|
33
50
|
|
|
34
|
-
Node, pytest, Go, Maven/Java, Rust, and
|
|
51
|
+
Node, pytest, Go, Maven/Java, Rust, C#, and Mocha collect a line map on the baseline. C collects one when LLVM coverage was instrumented. Mocha writes its own `{ files }` record from root hooks, keyed by the Mocha title. The node:test collector is a different file and does not name Mocha tests. The map stores the file, the line, and the test names that hit that line. A later mutant runs only those tests (`go test -run`, Maven `-Dtest`, `cargo test`, `dotnet test --filter`, Mocha `--grep`). A line no test executed is `no coverage`, and the suite is not started. A plain Node script can leave a nameless list of the lines it ran. The parent test that reads that list stores them under its own name. A child the parent does not read stays `no coverage`. Covered mutants of one Node file are applied one after another in the suite process already running, and the line map is reset between them. `--workers` stays 1. A crash or a process left dirty ends that process. The next mutant starts clean. A baseline of 5 seconds or more, with no line map and more than 30 mutants still pending, stops before the first mutant. `next` names that baseline and tells you to narrow `--src` or pass a smaller patch directory. A one-file C, C++, Java, COBOL, or assembly launcher is not a suite discovery returns.
|
|
35
52
|
|
|
36
53
|
The mutant timeout is the baseline duration times 5, and at least 20 seconds, capped by `--suite-timeout-ms`. A timeout is not a kill. `--budget-ms` stops mutant work after the baseline. The first mutant still runs, except for that no-line-map stop. A kill is the test that failed, or a compiler token when the mutant did not build. Confirm is on by default and reruns the failing names. Pytest names that `-k` cannot express are passed as node ids. A usage error, including pytest exit 4, is not a kill. `verify-change` does not confirm a second time. Add `--affected` to limit the file list to tests that can see the change. The default, once a line map exists, runs the tests on the changed line.
|
|
37
54
|
|
|
@@ -81,4 +98,23 @@ Lists the tests that can see the diff, runs mutants on the changed lines, and re
|
|
|
81
98
|
|
|
82
99
|
An item is `.probatio/queue/<id>.json`. `queue claim` renames it to `claimed/<agent>-<id>.json` with a lease. `queue reap` moves an expired lease back. `check-kill <id>` runs the tests that directly import the mutated file and confirms the killing test again. It is done only when that mutant dies and that suite is still green. `verify-change` is the edit-sized run: it uses the line map and does not confirm a second time.
|
|
83
100
|
|
|
84
|
-
|
|
101
|
+
## MCP
|
|
102
|
+
|
|
103
|
+
`probatio mcp` and the `probatio-mcp` bin speak stdio JSON-RPC, one JSON object per line. The tool name is `probatio`. `argv` is the CLI words. `ping` returns `{}`. An unknown method returns JSON-RPC `-32601` with the same id.
|
|
104
|
+
|
|
105
|
+
```json
|
|
106
|
+
{
|
|
107
|
+
"mcpServers": {
|
|
108
|
+
"probatio": {
|
|
109
|
+
"command": "probatio",
|
|
110
|
+
"args": ["mcp"]
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
That block is the Claude, Cursor, and Grok shape. The tool runs the CLI and returns that command's JSON. `nextCall.argv` keeps absolute paths so a later spawn does not have to expand `~`.
|
|
117
|
+
|
|
118
|
+
`mutate generate` reads the commit (`HEAD` unless you pass `--commit`). `--working-tree` reads the checkout on disk and says so. `mutate run` scores the commit either way.
|
|
119
|
+
|
|
120
|
+
`verify-change` does not score uncommitted edits when `--base` and `--commit` are the same. A clean empty diff says `No diff-scoped mutant.`
|
package/dist/cli.js
CHANGED
|
@@ -10,65 +10,82 @@ import { appendFinding, chooseSealed, fixGap, guardAllows, lineHash, loadState,
|
|
|
10
10
|
import { git } from "./mutate/patch.js";
|
|
11
11
|
import { generateMutants } from "./mutate/generate.js";
|
|
12
12
|
import { runMutants } from "./mutate/run.js";
|
|
13
|
-
import { LABEL_LEAK_SUMMARY, labelLeaked, readSealedLabel } from "./mutate/sealed.js";
|
|
13
|
+
import { LABEL_LEAK_SUMMARY, labelLeaked, readSealedLabel, sealedTreeLeaked } from "./mutate/sealed.js";
|
|
14
14
|
import { generateNext } from "./mutate/suite-decision.js";
|
|
15
15
|
import { discoverSuite } from "./mutate/suites.js";
|
|
16
16
|
import { tallyRun } from "./mutate/tally.js";
|
|
17
17
|
import { checkKill } from "./swarm/check-kill.js";
|
|
18
18
|
import { claimItem, reapClaims, seedQueue } from "./swarm/queue.js";
|
|
19
19
|
import { verifyChange } from "./verify/change.js";
|
|
20
|
-
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
finish(await generateCommand(parsed.flags), parsed.human);
|
|
25
|
-
if (group === "mutate" && action === "run")
|
|
26
|
-
finish(await runCommand(parsed.flags), parsed.human);
|
|
27
|
-
if (group === "mutate" && action === "sealed")
|
|
28
|
-
finish(await sealedCommand(parsed.flags), parsed.human);
|
|
29
|
-
if (group === "mutate" && action === "tally")
|
|
30
|
-
finish(tallyCommand(parsed.flags), parsed.human);
|
|
31
|
-
if (group === "ledger" && action === "build")
|
|
32
|
-
finish(await ledgerCommand(parsed.flags), parsed.human);
|
|
33
|
-
if (group === "matrix" && action === "report")
|
|
34
|
-
finish(matrixCommand(parsed.flags), parsed.human);
|
|
35
|
-
if (group === "golden" && action === "check")
|
|
36
|
-
finish(goldenCommand(parsed.flags), parsed.human);
|
|
37
|
-
if (group === "gap" && action === "fix")
|
|
38
|
-
finish(gapFixCommand(parsed.flags), parsed.human);
|
|
39
|
-
if (group === "gap" && action === "revert")
|
|
40
|
-
finish(gapRevertCommand(parsed.flags), parsed.human);
|
|
41
|
-
if (group === "guard" && action === "check")
|
|
42
|
-
finish(guardCommand(parsed.flags), parsed.human);
|
|
43
|
-
if (group === "findings" && action === "add")
|
|
44
|
-
finish(findingsCommand(parsed.flags), parsed.human);
|
|
45
|
-
if (group === "status")
|
|
46
|
-
finish(statusCommand(parsed.flags), parsed.human);
|
|
47
|
-
if (group === "seal")
|
|
48
|
-
finish(sealCommand(parsed.flags), parsed.human);
|
|
49
|
-
if (group === "queue" && action === "seed")
|
|
50
|
-
finish(queueSeedCommand(parsed.flags), parsed.human);
|
|
51
|
-
if (group === "queue" && action === "claim")
|
|
52
|
-
finish(queueClaimCommand(parsed.flags), parsed.human);
|
|
53
|
-
if (group === "queue" && action === "reap")
|
|
54
|
-
finish(queueReapCommand(parsed.flags), parsed.human);
|
|
55
|
-
if (group === "check-kill")
|
|
56
|
-
finish(await checkKillCommand(parsed.flags, action), parsed.human);
|
|
57
|
-
if (group === "verify-change")
|
|
58
|
-
finish(await verifyCommand(parsed.flags), parsed.human);
|
|
59
|
-
finish(usage(false, "Use mutate, ledger, matrix, golden, gap, guard, findings, status, queue, check-kill, or verify-change."), parsed.human);
|
|
60
|
-
}
|
|
61
|
-
catch (error) {
|
|
62
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
63
|
-
finish({
|
|
20
|
+
import { nodeTooOld } from "./node-version.js";
|
|
21
|
+
const tooOld = nodeTooOld(process.versions.node);
|
|
22
|
+
if (tooOld) {
|
|
23
|
+
process.stdout.write(`${JSON.stringify({
|
|
64
24
|
schemaVersion: SCHEMA_VERSION,
|
|
65
25
|
ok: false,
|
|
66
|
-
command:
|
|
67
|
-
summary:
|
|
68
|
-
next: "
|
|
26
|
+
command: "mutate.help",
|
|
27
|
+
summary: tooOld,
|
|
28
|
+
next: "Install Node 22.6 or newer and run the command again.",
|
|
69
29
|
nextCall: null,
|
|
70
|
-
}
|
|
30
|
+
})}\n`);
|
|
31
|
+
process.exit(1);
|
|
71
32
|
}
|
|
33
|
+
const parsed = parseArgs(process.argv.slice(2));
|
|
34
|
+
const [group, action] = parsed.command;
|
|
35
|
+
if (group === "mcp" && !action) {
|
|
36
|
+
await import("./mcp.js");
|
|
37
|
+
}
|
|
38
|
+
else
|
|
39
|
+
try {
|
|
40
|
+
if (group === "mutate" && action === "generate")
|
|
41
|
+
finish(await generateCommand(parsed.flags), parsed.human);
|
|
42
|
+
if (group === "mutate" && action === "run")
|
|
43
|
+
finish(await runCommand(parsed.flags), parsed.human);
|
|
44
|
+
if (group === "mutate" && action === "sealed")
|
|
45
|
+
finish(await sealedCommand(parsed.flags), parsed.human);
|
|
46
|
+
if (group === "mutate" && action === "tally")
|
|
47
|
+
finish(tallyCommand(parsed.flags), parsed.human);
|
|
48
|
+
if (group === "ledger" && action === "build")
|
|
49
|
+
finish(await ledgerCommand(parsed.flags), parsed.human);
|
|
50
|
+
if (group === "matrix" && action === "report")
|
|
51
|
+
finish(matrixCommand(parsed.flags), parsed.human);
|
|
52
|
+
if (group === "golden" && action === "check")
|
|
53
|
+
finish(goldenCommand(parsed.flags), parsed.human);
|
|
54
|
+
if (group === "gap" && action === "fix")
|
|
55
|
+
finish(gapFixCommand(parsed.flags), parsed.human);
|
|
56
|
+
if (group === "gap" && action === "revert")
|
|
57
|
+
finish(gapRevertCommand(parsed.flags), parsed.human);
|
|
58
|
+
if (group === "guard" && action === "check")
|
|
59
|
+
finish(guardCommand(parsed.flags), parsed.human);
|
|
60
|
+
if (group === "findings" && action === "add")
|
|
61
|
+
finish(findingsCommand(parsed.flags), parsed.human);
|
|
62
|
+
if (group === "status")
|
|
63
|
+
finish(statusCommand(parsed.flags), parsed.human);
|
|
64
|
+
if (group === "seal")
|
|
65
|
+
finish(sealCommand(parsed.flags), parsed.human);
|
|
66
|
+
if (group === "queue" && action === "seed")
|
|
67
|
+
finish(queueSeedCommand(parsed.flags), parsed.human);
|
|
68
|
+
if (group === "queue" && action === "claim")
|
|
69
|
+
finish(queueClaimCommand(parsed.flags), parsed.human);
|
|
70
|
+
if (group === "queue" && action === "reap")
|
|
71
|
+
finish(queueReapCommand(parsed.flags), parsed.human);
|
|
72
|
+
if (group === "check-kill")
|
|
73
|
+
finish(await checkKillCommand(parsed.flags, action), parsed.human);
|
|
74
|
+
if (group === "verify-change")
|
|
75
|
+
finish(await verifyCommand(parsed.flags), parsed.human);
|
|
76
|
+
finish(usage(false, "Use mutate, ledger, matrix, golden, gap, guard, findings, status, queue, check-kill, or verify-change."), parsed.human);
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
80
|
+
finish({
|
|
81
|
+
schemaVersion: SCHEMA_VERSION,
|
|
82
|
+
ok: false,
|
|
83
|
+
command: `${group ?? "mutate"}.${action ?? "help"}`,
|
|
84
|
+
summary: message,
|
|
85
|
+
next: "Fix the command and run it again.",
|
|
86
|
+
nextCall: null,
|
|
87
|
+
}, parsed.human);
|
|
88
|
+
}
|
|
72
89
|
function usage(ok, summary) {
|
|
73
90
|
return {
|
|
74
91
|
schemaVersion: SCHEMA_VERSION,
|
|
@@ -93,7 +110,19 @@ async function generateCommand(flags) {
|
|
|
93
110
|
maxMutants: int(flags, "max-mutants") ?? null,
|
|
94
111
|
maxMinutes: int(flags, "max-minutes") ?? null,
|
|
95
112
|
skipFiles: texts(flags, "skip-file"),
|
|
113
|
+
commit: text(flags, "commit") ?? "HEAD",
|
|
114
|
+
workingTree: bool(flags, "working-tree", false),
|
|
96
115
|
});
|
|
116
|
+
if (result.error) {
|
|
117
|
+
return {
|
|
118
|
+
schemaVersion: SCHEMA_VERSION,
|
|
119
|
+
ok: false,
|
|
120
|
+
command: "mutate.generate",
|
|
121
|
+
summary: result.error,
|
|
122
|
+
next: "Commit the file, or pass --working-tree to mutate the checkout on disk. mutate run scores the commit.",
|
|
123
|
+
nextCall: null,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
97
126
|
if (result.violations.length > 0) {
|
|
98
127
|
const first = result.violations[0];
|
|
99
128
|
return {
|
|
@@ -117,9 +146,14 @@ async function generateCommand(flags) {
|
|
|
117
146
|
summary: stopped
|
|
118
147
|
? `Stopped by the time budget after ${result.filesVisited} files. ${result.mutants.length} mutants written.`
|
|
119
148
|
: `${result.mutants.length} mutants in ${result.filesVisited} files. No string-literal mutant.`,
|
|
120
|
-
next:
|
|
121
|
-
|
|
122
|
-
|
|
149
|
+
next: [
|
|
150
|
+
stopped
|
|
151
|
+
? "Narrow --src or raise --max-minutes, then run the same command."
|
|
152
|
+
: generateNext(result.mutants.length, suite ? { kind: suite.kind, command: suite.command } : null),
|
|
153
|
+
result.source === "working-tree"
|
|
154
|
+
? "These patches match the working tree. mutate run scores the commit, so commit the files before mutate run."
|
|
155
|
+
: "",
|
|
156
|
+
].filter((line) => line.length > 0).join(" "),
|
|
123
157
|
nextCall: result.mutants.length === 0
|
|
124
158
|
? null
|
|
125
159
|
: { argv: ["mutate", "run", "--package", packageDir, "--patches", patches, "--out", path.join(outDir, "runs")] },
|
|
@@ -133,6 +167,19 @@ async function generateCommand(flags) {
|
|
|
133
167
|
};
|
|
134
168
|
}
|
|
135
169
|
async function runCommand(flags, hide = null) {
|
|
170
|
+
const hidden = hide ?? texts(flags, "hide");
|
|
171
|
+
const escaped = hidden.find((rel) => rel.split(/[\\/]/).includes(".."));
|
|
172
|
+
if (escaped) {
|
|
173
|
+
return {
|
|
174
|
+
schemaVersion: SCHEMA_VERSION,
|
|
175
|
+
ok: false,
|
|
176
|
+
command: "mutate.run",
|
|
177
|
+
summary: `--hide stays inside the package. Refused ${escaped}.`,
|
|
178
|
+
next: "Pass a relative path with no .. segment.",
|
|
179
|
+
nextCall: null,
|
|
180
|
+
killed: 0,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
136
183
|
const onlyNames = texts(flags, "only-test");
|
|
137
184
|
if (onlyNames.some((id) => id === "::command" || id.endsWith("::command"))) {
|
|
138
185
|
return {
|
|
@@ -234,9 +281,10 @@ function tallyCommand(flags) {
|
|
|
234
281
|
async function sealedCommand(flags) {
|
|
235
282
|
const label = readSealedLabel(path.resolve(requireText(flags, "label")));
|
|
236
283
|
const hide = texts(flags, "hide");
|
|
284
|
+
const outDir = path.resolve(requireText(flags, "out"));
|
|
237
285
|
const report = await runCommand(flags, hide);
|
|
238
286
|
const body = JSON.stringify(report);
|
|
239
|
-
if (!labelLeaked(body, label))
|
|
287
|
+
if (!labelLeaked(body, label) && !sealedTreeLeaked(outDir, label))
|
|
240
288
|
return report;
|
|
241
289
|
return {
|
|
242
290
|
schemaVersion: SCHEMA_VERSION,
|
package/dist/contract.js
CHANGED
|
@@ -1,18 +1,20 @@
|
|
|
1
1
|
import { homedir } from "node:os";
|
|
2
2
|
export const SCHEMA_VERSION = 1;
|
|
3
3
|
/** Replace the home directory so a result never carries a user's name or home path. */
|
|
4
|
-
export function scrub(value, home = homedir()) {
|
|
4
|
+
export function scrub(value, home = homedir(), keepPaths = false) {
|
|
5
5
|
if (typeof value === "string") {
|
|
6
|
-
if (!home || !value.includes(home))
|
|
6
|
+
if (keepPaths || !home || !value.includes(home))
|
|
7
7
|
return value;
|
|
8
8
|
return value.split(home).join("~");
|
|
9
9
|
}
|
|
10
10
|
if (Array.isArray(value))
|
|
11
|
-
return value.map((item) => scrub(item, home));
|
|
11
|
+
return value.map((item) => scrub(item, home, keepPaths));
|
|
12
12
|
if (value && typeof value === "object") {
|
|
13
13
|
const out = {};
|
|
14
|
-
for (const [key, item] of Object.entries(value))
|
|
15
|
-
|
|
14
|
+
for (const [key, item] of Object.entries(value)) {
|
|
15
|
+
// nextCall.argv is executed. A tilde is not expanded by spawn or by MCP.
|
|
16
|
+
out[key] = scrub(item, home, keepPaths || key === "nextCall");
|
|
17
|
+
}
|
|
16
18
|
return out;
|
|
17
19
|
}
|
|
18
20
|
return value;
|
package/dist/mcp.js
CHANGED
|
@@ -3,6 +3,19 @@ import { spawnSync } from "node:child_process";
|
|
|
3
3
|
import { existsSync, readFileSync } from "node:fs";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { nodeTooOld } from "./node-version.js";
|
|
7
|
+
const tooOld = nodeTooOld(process.versions.node);
|
|
8
|
+
if (tooOld) {
|
|
9
|
+
process.stdout.write(`${JSON.stringify({
|
|
10
|
+
schemaVersion: 1,
|
|
11
|
+
ok: false,
|
|
12
|
+
command: "mcp",
|
|
13
|
+
summary: tooOld,
|
|
14
|
+
next: "Install Node 22.6 or newer and run the command again.",
|
|
15
|
+
nextCall: null,
|
|
16
|
+
})}\n`);
|
|
17
|
+
process.exit(1);
|
|
18
|
+
}
|
|
6
19
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
7
20
|
const version = readVersion();
|
|
8
21
|
const tool = {
|
|
@@ -63,6 +76,10 @@ function handle(message) {
|
|
|
63
76
|
send({ jsonrpc: "2.0", id: message.id, result: { tools: [tool] } });
|
|
64
77
|
return;
|
|
65
78
|
}
|
|
79
|
+
if (message.method === "ping") {
|
|
80
|
+
send({ jsonrpc: "2.0", id: message.id, result: {} });
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
66
83
|
if (message.method === "tools/call") {
|
|
67
84
|
const argv = message.params?.arguments?.argv;
|
|
68
85
|
if (!Array.isArray(argv) || argv.some((item) => typeof item !== "string")) {
|
|
@@ -92,7 +109,13 @@ function handle(message) {
|
|
|
92
109
|
isError: envelope.ok !== true,
|
|
93
110
|
},
|
|
94
111
|
});
|
|
112
|
+
return;
|
|
95
113
|
}
|
|
114
|
+
send({
|
|
115
|
+
jsonrpc: "2.0",
|
|
116
|
+
id: message.id,
|
|
117
|
+
error: { code: -32601, message: `method not found: ${message.method}` },
|
|
118
|
+
});
|
|
96
119
|
}
|
|
97
120
|
function cliCommand() {
|
|
98
121
|
const compiled = path.join(here, "cli.js");
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Nameless line dump for a plain script. This is not a node:test process and it
|
|
2
|
+
// does not write a coverage shard. The parent test's afterEach reads the dump.
|
|
3
|
+
import { randomBytes } from "node:crypto"
|
|
4
|
+
import { mkdirSync, writeFileSync } from "node:fs"
|
|
5
|
+
import path from "node:path"
|
|
6
|
+
import { openLineSampler } from "./precise-lines.mjs"
|
|
7
|
+
|
|
8
|
+
const mapPath = process.env.PROBATIO_COVERAGE_MAP
|
|
9
|
+
if (mapPath) {
|
|
10
|
+
let sampler = null
|
|
11
|
+
try {
|
|
12
|
+
sampler = await openLineSampler((rel) => rel.endsWith("child-lines.mjs") || rel.endsWith("precise-lines.mjs"))
|
|
13
|
+
} catch (err) {
|
|
14
|
+
writeFileSync(`${mapPath}.error`, `${err instanceof Error ? err.message : String(err)}\n`)
|
|
15
|
+
}
|
|
16
|
+
if (sampler) {
|
|
17
|
+
let wrote = false
|
|
18
|
+
const dump = () => {
|
|
19
|
+
if (wrote) return
|
|
20
|
+
wrote = true
|
|
21
|
+
sampler.positiveLines().then((hits) => {
|
|
22
|
+
const dir = `${mapPath}.children`
|
|
23
|
+
mkdirSync(dir, { recursive: true })
|
|
24
|
+
writeFileSync(
|
|
25
|
+
path.join(dir, `${process.pid}-${randomBytes(4).toString("hex")}.json`),
|
|
26
|
+
JSON.stringify({ hits }),
|
|
27
|
+
)
|
|
28
|
+
}).catch((err) => {
|
|
29
|
+
writeFileSync(`${mapPath}.error`, `${err instanceof Error ? err.message : String(err)}\n`)
|
|
30
|
+
})
|
|
31
|
+
}
|
|
32
|
+
process.on("beforeExit", dump)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -104,17 +104,10 @@ export function writeLlvmCoverage(pkg, profileDir, binary, dest) {
|
|
|
104
104
|
return { ok: false, detail: "no profraw" };
|
|
105
105
|
if (!binary)
|
|
106
106
|
return { ok: false, detail: "no instrumented binary" };
|
|
107
|
-
const llvmCov = findTool("llvm-cov");
|
|
108
|
-
const llvmProf = findTool("llvm-profdata");
|
|
109
|
-
if (!llvmCov || !llvmProf)
|
|
110
|
-
return { ok: false, detail: `llvm tool missing (${llvmCov ? "llvm-profdata" : "llvm-cov"})` };
|
|
111
107
|
const merged = path.join(profileDir, "baseline.profdata");
|
|
112
|
-
const
|
|
113
|
-
if (
|
|
114
|
-
return
|
|
115
|
-
const exported = spawnSync(llvmCov, ["export", binary, `-instr-profile=${merged}`], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
|
|
116
|
-
if (exported.status !== 0)
|
|
117
|
-
return { ok: false, detail: (exported.stderr || exported.stdout || "llvm-cov failed").trim() };
|
|
108
|
+
const exported = exportLlvmProfile(binary, raws, merged);
|
|
109
|
+
if (!exported.ok)
|
|
110
|
+
return exported;
|
|
118
111
|
const map = llvmExportToMap(exported.stdout, pkg);
|
|
119
112
|
if (!map)
|
|
120
113
|
return { ok: false, detail: "llvm-cov export was not a coverage map" };
|
|
@@ -159,14 +152,78 @@ function llvmExportToMap(stdout, pkg) {
|
|
|
159
152
|
return null;
|
|
160
153
|
return { files };
|
|
161
154
|
}
|
|
162
|
-
|
|
155
|
+
/**
|
|
156
|
+
* clang and rustc write different profraw versions. Each pair is one LLVM install.
|
|
157
|
+
* The first pair that can merge the profile wins.
|
|
158
|
+
*/
|
|
159
|
+
export function llvmToolsPresent() {
|
|
160
|
+
return llvmToolPairs().length > 0;
|
|
161
|
+
}
|
|
162
|
+
export function exportLlvmProfile(binary, raws, merged) {
|
|
163
|
+
const pairs = llvmToolPairs();
|
|
164
|
+
if (pairs.length === 0)
|
|
165
|
+
return { ok: false, detail: "llvm tool missing (llvm-cov)" };
|
|
166
|
+
let detail = "llvm-profdata failed";
|
|
167
|
+
for (const { cov, prof } of pairs) {
|
|
168
|
+
const merge = spawnSync(prof, ["merge", "-sparse", "-o", merged, ...raws], { encoding: "utf8" });
|
|
169
|
+
if (merge.status !== 0) {
|
|
170
|
+
detail = (merge.stderr || merge.stdout || "llvm-profdata failed").trim();
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
const exported = spawnSync(cov, ["export", binary, `-instr-profile=${merged}`], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
|
|
174
|
+
if (exported.status !== 0) {
|
|
175
|
+
detail = (exported.stderr || exported.stdout || "llvm-cov failed").trim();
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
return { ok: true, stdout: exported.stdout };
|
|
179
|
+
}
|
|
180
|
+
return { ok: false, detail };
|
|
181
|
+
}
|
|
182
|
+
function llvmToolPairs() {
|
|
183
|
+
const covs = toolCandidates("llvm-cov");
|
|
184
|
+
const profs = toolCandidates("llvm-profdata");
|
|
185
|
+
const pairs = [];
|
|
186
|
+
const seen = new Set();
|
|
187
|
+
for (const cov of covs) {
|
|
188
|
+
const prof = profs.find((item) => path.dirname(item) === path.dirname(cov));
|
|
189
|
+
if (!prof)
|
|
190
|
+
continue;
|
|
191
|
+
const key = `${cov}\0${prof}`;
|
|
192
|
+
if (seen.has(key))
|
|
193
|
+
continue;
|
|
194
|
+
seen.add(key);
|
|
195
|
+
pairs.push({ cov, prof });
|
|
196
|
+
}
|
|
197
|
+
return pairs;
|
|
198
|
+
}
|
|
199
|
+
function toolCandidates(name) {
|
|
200
|
+
const out = [];
|
|
201
|
+
const add = (file) => {
|
|
202
|
+
if (!file || out.includes(file) || !existsSync(file))
|
|
203
|
+
return;
|
|
204
|
+
out.push(file);
|
|
205
|
+
};
|
|
163
206
|
const fromXcrun = spawnSync("xcrun", ["--find", name], { encoding: "utf8" });
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
return xcrunPath;
|
|
207
|
+
if (fromXcrun.status === 0)
|
|
208
|
+
add((fromXcrun.stdout ?? "").trim());
|
|
167
209
|
const which = spawnSync("sh", ["-c", `command -v ${name}`], { encoding: "utf8" });
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
210
|
+
if (which.status === 0)
|
|
211
|
+
add((which.stdout ?? "").trim());
|
|
212
|
+
const rustc = spawnSync("rustc", ["--print", "sysroot"], { encoding: "utf8" });
|
|
213
|
+
if (rustc.status === 0) {
|
|
214
|
+
const root = path.join(rustc.stdout.trim(), "lib", "rustlib");
|
|
215
|
+
if (existsSync(root)) {
|
|
216
|
+
for (const host of readdirSync(root))
|
|
217
|
+
add(path.join(root, host, "bin", name));
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
const usr = "/usr/lib";
|
|
221
|
+
if (existsSync(usr)) {
|
|
222
|
+
for (const dir of readdirSync(usr)) {
|
|
223
|
+
if (dir.startsWith("llvm-"))
|
|
224
|
+
add(path.join(usr, dir, "bin", name));
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
add(path.join("/usr/bin", name));
|
|
228
|
+
return out;
|
|
172
229
|
}
|
package/dist/mutate/generate.js
CHANGED
|
@@ -8,8 +8,9 @@ export function generateMutants(options) {
|
|
|
8
8
|
const started = Date.now();
|
|
9
9
|
const limit = options.maxMinutes;
|
|
10
10
|
const over = () => limit != null && Date.now() - started >= limit * 60_000;
|
|
11
|
+
const sourceDefault = options.workingTree ? "working-tree" : "commit";
|
|
11
12
|
if (over())
|
|
12
|
-
return { mutants: [], violations: [], filesVisited: 0, budgetHit: true };
|
|
13
|
+
return { mutants: [], violations: [], filesVisited: 0, budgetHit: true, source: sourceDefault };
|
|
13
14
|
const srcRoot = path.resolve(options.packageDir, options.srcDir);
|
|
14
15
|
const walked = walkSources(srcRoot, over);
|
|
15
16
|
const files = walked.files.filter((file) => !options.skipFiles.includes(path.basename(file)));
|
|
@@ -18,13 +19,18 @@ export function generateMutants(options) {
|
|
|
18
19
|
const chosen = [];
|
|
19
20
|
let filesVisited = 0;
|
|
20
21
|
let budgetHit = walked.stopped;
|
|
22
|
+
let source = sourceDefault;
|
|
21
23
|
for (const file of files) {
|
|
22
24
|
if (over()) {
|
|
23
25
|
budgetHit = true;
|
|
24
26
|
break;
|
|
25
27
|
}
|
|
26
28
|
filesVisited += 1;
|
|
27
|
-
const
|
|
29
|
+
const loaded = fileText(options.packageDir, file, options);
|
|
30
|
+
if ("error" in loaded)
|
|
31
|
+
return { mutants: [], violations: [], filesVisited, budgetHit, source: "commit", error: loaded.error };
|
|
32
|
+
source = loaded.source;
|
|
33
|
+
const text = loaded.text;
|
|
28
34
|
const rel = posix(path.relative(options.packageDir, file));
|
|
29
35
|
const found = findInSource(rel, text);
|
|
30
36
|
violations.push(...found.violations);
|
|
@@ -34,7 +40,7 @@ export function generateMutants(options) {
|
|
|
34
40
|
chosen.push({ rel, text, point });
|
|
35
41
|
}
|
|
36
42
|
if (violations.length > 0)
|
|
37
|
-
return { mutants: [], violations, filesVisited, budgetHit };
|
|
43
|
+
return { mutants: [], violations, filesVisited, budgetHit, source };
|
|
38
44
|
const capped = options.maxMutants === null ? chosen : chosen.slice(0, options.maxMutants);
|
|
39
45
|
const packageReal = real(options.packageDir);
|
|
40
46
|
const patchRoot = gitRoot(packageReal);
|
|
@@ -55,7 +61,22 @@ export function generateMutants(options) {
|
|
|
55
61
|
mutants.push({ id, file: item.rel, line: item.point.line, op: item.point.op, patch });
|
|
56
62
|
}
|
|
57
63
|
writeFileSync(path.join(mutantsDir, "mutants.json"), `${JSON.stringify({ schemaVersion: 1, seed: options.seed, mutants }, null, 2)}\n`);
|
|
58
|
-
return { mutants, violations, filesVisited, budgetHit };
|
|
64
|
+
return { mutants, violations, filesVisited, budgetHit, source };
|
|
65
|
+
}
|
|
66
|
+
function fileText(packageDir, absFile, options) {
|
|
67
|
+
if (options.workingTree)
|
|
68
|
+
return { text: readFileSync(absFile, "utf8"), source: "working-tree" };
|
|
69
|
+
const root = gitRoot(packageDir);
|
|
70
|
+
if (!root)
|
|
71
|
+
return { text: readFileSync(absFile, "utf8"), source: "working-tree" };
|
|
72
|
+
const rel = posix(path.relative(root, real(absFile)));
|
|
73
|
+
const commit = options.commit && options.commit.length > 0 ? options.commit : "HEAD";
|
|
74
|
+
const shown = spawnSync("git", ["-C", root, "show", `${commit}:${rel}`], { encoding: "utf8" });
|
|
75
|
+
if (shown.status !== 0) {
|
|
76
|
+
const shownRel = posix(path.relative(packageDir, absFile));
|
|
77
|
+
return { error: `${shownRel} is not in ${commit}. Pass --working-tree to read the checkout.` };
|
|
78
|
+
}
|
|
79
|
+
return { text: shown.stdout, source: "commit" };
|
|
59
80
|
}
|
|
60
81
|
function walkSources(dir, over) {
|
|
61
82
|
if (!statExists(dir))
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Mocha root hooks. This file is not node-coverage.mjs. Mocha does not run
|
|
2
|
+
// node:test afterEach/after, so that collector records no Mocha title.
|
|
3
|
+
// These hooks key inspector hits by the current Mocha title and write { files }.
|
|
4
|
+
const { mkdirSync, writeFileSync } = require("node:fs")
|
|
5
|
+
const path = require("node:path")
|
|
6
|
+
|
|
7
|
+
const mapPath = process.env.PROBATIO_COVERAGE_MAP
|
|
8
|
+
if (mapPath) {
|
|
9
|
+
/** @type {Map<string, Map<number, Set<string>>>} */
|
|
10
|
+
const byFile = new Map()
|
|
11
|
+
/** @type {Array<{ file: string, line: number }>} */
|
|
12
|
+
let prelude = []
|
|
13
|
+
/** @type {{ positiveLines: () => Promise<Array<{ file: string, line: number }>> } | null} */
|
|
14
|
+
let sampler = null
|
|
15
|
+
|
|
16
|
+
function add(hits, name) {
|
|
17
|
+
if (!name) return
|
|
18
|
+
for (const hit of hits) {
|
|
19
|
+
let lines = byFile.get(hit.file)
|
|
20
|
+
if (!lines) {
|
|
21
|
+
lines = new Map()
|
|
22
|
+
byFile.set(hit.file, lines)
|
|
23
|
+
}
|
|
24
|
+
let names = lines.get(hit.line)
|
|
25
|
+
if (!names) {
|
|
26
|
+
names = new Set()
|
|
27
|
+
lines.set(hit.line, names)
|
|
28
|
+
}
|
|
29
|
+
names.add(name)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function titleOf(test) {
|
|
34
|
+
if (!test) return ""
|
|
35
|
+
if (typeof test.fullTitle === "function") return test.fullTitle()
|
|
36
|
+
return typeof test.title === "string" ? test.title : ""
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
exports.mochaHooks = {
|
|
40
|
+
async beforeAll() {
|
|
41
|
+
try {
|
|
42
|
+
const opened = await import("./precise-lines.mjs")
|
|
43
|
+
sampler = await opened.openLineSampler((rel) => rel.endsWith("mocha-coverage.cjs") || rel.endsWith("precise-lines.mjs") || rel.endsWith("node-coverage.mjs"))
|
|
44
|
+
prelude = await sampler.positiveLines()
|
|
45
|
+
} catch (err) {
|
|
46
|
+
writeFileSync(`${mapPath}.error`, `${err instanceof Error ? err.message : String(err)}\n`)
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
async afterEach() {
|
|
50
|
+
if (!sampler) return
|
|
51
|
+
const title = titleOf(this.currentTest)
|
|
52
|
+
try {
|
|
53
|
+
add(prelude, title)
|
|
54
|
+
add(await sampler.positiveLines(), title)
|
|
55
|
+
} catch (err) {
|
|
56
|
+
writeFileSync(`${mapPath}.error`, `${err instanceof Error ? err.message : String(err)}\n`)
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
afterAll() {
|
|
60
|
+
if (byFile.size === 0) return
|
|
61
|
+
const files = {}
|
|
62
|
+
for (const [file, lines] of byFile) {
|
|
63
|
+
const bucket = {}
|
|
64
|
+
for (const [line, names] of lines) bucket[String(line)] = [...names].sort()
|
|
65
|
+
files[file] = bucket
|
|
66
|
+
}
|
|
67
|
+
mkdirSync(path.dirname(mapPath), { recursive: true })
|
|
68
|
+
writeFileSync(mapPath, `${JSON.stringify({ files }, null, 2)}\n`)
|
|
69
|
+
},
|
|
70
|
+
}
|
|
71
|
+
}
|