probatio 0.1.3 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +93 -8
  2. package/dist/cli.js +136 -6
  3. package/dist/contract.js +2 -1
  4. package/dist/golden/record.js +395 -0
  5. package/dist/golden/recorder.mjs +78 -0
  6. package/dist/ledger/build.js +21 -0
  7. package/dist/ledger/check.js +133 -0
  8. package/dist/mcp.js +76 -27
  9. package/dist/mutate/child-lines.mjs +23 -18
  10. package/dist/mutate/fail-reporter.mjs +21 -0
  11. package/dist/mutate/find.js +3 -3
  12. package/dist/mutate/generate.js +5 -2
  13. package/dist/mutate/load-failure.mjs +40 -0
  14. package/dist/mutate/mocha-coverage.cjs +7 -1
  15. package/dist/mutate/node-batch-loader.mjs +15 -1
  16. package/dist/mutate/node-batch.mjs +40 -3
  17. package/dist/mutate/node-coverage.mjs +7 -32
  18. package/dist/mutate/operators.js +60 -1
  19. package/dist/mutate/patch.js +2 -1
  20. package/dist/mutate/precise-lines.mjs +147 -31
  21. package/dist/mutate/probatio-jacoco-run.java +2 -2
  22. package/dist/mutate/project-config.js +58 -0
  23. package/dist/mutate/run.js +100 -19
  24. package/dist/mutate/sealed.js +54 -0
  25. package/dist/mutate/suite-decision.js +2 -1
  26. package/dist/mutate/suites.js +87 -155
  27. package/dist/mutate/tally.js +54 -26
  28. package/dist/mutate/text-operators.js +92 -2
  29. package/dist/swarm/check-kill.js +14 -7
  30. package/dist/verify/change.js +1 -1
  31. package/package.json +8 -4
  32. package/schemas/check-kill.schema.json +93 -0
  33. package/schemas/findings.add.schema.json +53 -0
  34. package/schemas/gap.fix.schema.json +53 -0
  35. package/schemas/gap.revert.schema.json +53 -0
  36. package/schemas/golden.check.schema.json +89 -0
  37. package/schemas/golden.compare.schema.json +151 -0
  38. package/schemas/golden.record.schema.json +126 -0
  39. package/schemas/guard.check.schema.json +53 -0
  40. package/schemas/ledger.build.schema.json +132 -0
  41. package/schemas/ledger.check.schema.json +143 -0
  42. package/schemas/matrix.report.schema.json +129 -0
  43. package/schemas/mcp.schema.json +53 -0
  44. package/schemas/mutate.generate.schema.json +127 -0
  45. package/schemas/mutate.help.schema.json +53 -0
  46. package/schemas/mutate.run.schema.json +272 -0
  47. package/schemas/mutate.sealed.schema.json +53 -0
  48. package/schemas/mutate.tally.schema.json +137 -0
  49. package/schemas/queue.claim.schema.json +53 -0
  50. package/schemas/queue.reap.schema.json +71 -0
  51. package/schemas/queue.seed.schema.json +71 -0
  52. package/schemas/schema.schema.json +74 -0
  53. package/schemas/seal.schema.json +69 -0
  54. package/schemas/status.schema.json +103 -0
  55. package/schemas/verify-change.schema.json +222 -0
package/README.md CHANGED
@@ -20,19 +20,61 @@ Each command prints one JSON object and exits 0 only when `ok` is true.
20
20
 
21
21
  ```json
22
22
  {
23
- "schemaVersion": 1,
23
+ "schemaVersion": 2,
24
24
  "ok": true,
25
25
  "summary": "0 no coverage, 0 survived, 1 killed, 0 flaky, 0 timed out, 0 errored, of 1 finished.",
26
26
  "next": "No survivor in this batch.",
27
- "nextCall": { "argv": ["mutate", "tally", "--out", ".probatio/runs"] }
27
+ "nextCall": null
28
28
  }
29
29
  ```
30
30
 
31
+ Every command's output has a JSON Schema in [schemas/](schemas/), and `probatio schema <command>` prints it. `schemaVersion` is 2. It changes when a field is renamed, removed, or changes meaning, and a new field does not change it.
32
+
31
33
  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
34
 
33
35
  ## Status
34
36
 
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.
37
+ What a result means:
38
+
39
+ - **killed**: a test the suite already had failed an assertion on the mutant. It is not proof the change is safe, and the count is not a merge gate.
40
+ - **survived**: a test ran the line and did not notice. That is a gap. Add a test that fails on the mutant, then rerun.
41
+ - **no coverage**: no test executed that line, so the suite was not started. It is not a pass and not a gap.
42
+ - **did not build** (`unviable`): the compiler rejected the mutant, or on Node a test file no longer links (a removed export, a missing module, a file that does not parse). No test code ran, so it is not a kill and not a gap. Java, Rust, Go, and C# reject the same change at compile time, so every language gets the same verdict. For a ledger fix it means the reverted commit cannot be a regression check as it stands: write a hand-made mutant (`source=hand`) that keeps the API and puts the old behaviour back.
43
+ - **timed out**: the baseline clock ran out. It is not a kill.
44
+ - A red baseline, or a baseline with no test report, stops before the first mutant.
45
+
46
+ What runs today:
47
+
48
+ - Discovery finds the project's own suite: `run_tests.sh`, Cargo, Go, Swift, Maven, dotnet, pytest, unittest, node:test, Mocha, and `make test` for COBOL. An unknown layout asks for `--suite-command`.
49
+ - Node, pytest, Go, Maven/Java, Rust, C#, Mocha, and C with LLVM coverage collect a line map on the baseline. A mutant runs only the tests that hit its line.
50
+ - A Node child process started by a test is mapped under that test, with no import in the child and even when it ends with `process.exit`. A child started with a cleared environment cannot be seen and stays `no coverage`.
51
+ - A slow suite with no line map (baseline 5 seconds or more, more than 30 mutants pending) stops before the first mutant.
52
+ - A project that needs its own environment or test-line format says so in `.probatio.json` (below). Probatio does not patch a project's files or environment to make one repository pass.
53
+
54
+ Sealed runs. A sealed run hides the test that came with a fix, puts the bug back, and asks whether the rest of the suite notices. Seven subjects are recorded (2026-10-05 and 10-06):
55
+
56
+ | Subject | Result |
57
+ |---|---|
58
+ | QuixBugs, Python program | no coverage: the planted line was not executed |
59
+ | QuixBugs, Java program | survived |
60
+ | Commons CSV, one bug | survived |
61
+ | tqdm, one bug | survived |
62
+ | First one-command `mutate sealed` run | survived, both runs |
63
+ | Commons CSV `c15a06ee` (CSV-288) | the suite stayed green with the test hidden |
64
+ | Commons CSV `2c83a308` (CSV-271) | killed on 10-06 with no line map; survived on 10-08 with the map; see below |
65
+
66
+ The CSV-271 kill came from `CSVFormatTest.testFormatThrowsNullPointerException`. The fix commit itself edited that test: it changed the asserted stack-frame class from `CSVFormat` to `java.util.Objects`. With the test as it was before the fix, all 92 `CSVFormatTest` tests pass on the reverted code, and no other test fails because of the bug. So no older test caught it. The honest record is that these suites did not notice a hidden real bug, which is what a sealed run is for. A kill that pins an internal detail, such as which class threw, is not the same as a test that checks behaviour.
67
+
68
+ A blind run of the whole loop (gaps, new tests, then a hidden real bug) on Commons CSV is written up in [docs/demo-commons-csv.md](docs/demo-commons-csv.md). The new tests closed real gaps, and they did not catch the hidden bug. The page says why.
69
+
70
+ Known limits:
71
+
72
+ - The sealed list (`seal`) is a plain id list in the state dir. An agent that can read that directory can see it. Keep the state dir and every `mutate sealed` label file outside the workspace of the agents being scored.
73
+ - `mutate sealed` checks whether the fix commit edited the killing test (`fixEdited`, `olderTestCatch`). The fix commit is the scored commit, or `--fix <commit>` when later work sits on top of it. It matches a kill to a file by path, class name, or a test name written in that file. A test renamed by the fix can slip past that match.
74
+ - The Java line map comes from JaCoCo, which does not count a line as executed when a call on that line throws. A test that reaches a line only through an exception is not selected for it, so a mutant there can survive a test that would fail. On Commons CSV-271 the map leaves out `testFormatThrowsNullPointerException` at `CSVPrinter.java:284`, and the sealed run reports survived.
75
+ - The default operator set (`core`) is small: condition swaps, relational swaps, booleans, a dropped `!`. `--operators wide` adds arithmetic, integer constants, and (TypeScript only) a dropped call statement, so a clean batch means more. A summary says which set ran.
76
+
77
+ The package version in this repository is in `package.json`. The npm badge above is the published version.
36
78
 
37
79
  ## mutate
38
80
 
@@ -44,15 +86,35 @@ npx probatio mutate run --package . --patches .probatio/generate/mutants --out .
44
86
  npx probatio mutate tally --out .probatio/runs
45
87
  ```
46
88
 
89
+ `--operators wide` (on `generate`, `verify-change`, and `golden compare`) adds arithmetic swaps (`+`/`-`, `*`/`/`, `%`), integer constants (`0`→`1`, `1`→`0`, `n`→`n+1`), and a dropped statement. In TypeScript a call statement becomes `void 0`. In Java, C, C++, C#, JavaScript, and Rust, a call, an assignment, or an increment that is a whole statement on its own line becomes `;`. Text languages need the operator spaced on both sides, so `++`, `+=`, `->`, `//`, `**`, unary minus, and pointer stars are left alone. A `+` beside a string literal is concatenation and is left alone. Hex, float, and suffixed literals are not constants. COBOL and assembly get no wide operators, and Python gets no dropped statement. `core` is the default, so ids and counts from earlier runs do not move. `mutants.json` records the set.
90
+
47
91
  `generate` writes operator mutants (conditions, `&&`/`||`, `===`/`!==`, boundaries, booleans, a dropped `!`). The same class is written for COBOL (`.cob`, `.cbl`), Rust, C, C++, Java, Go, Python, JavaScript, and C#. Strings and comments are not mutated. A COBOL `*>` comment and a fixed-format line whose column 7 is `*` or `/` are comments. Each patch is forward: apply it to introduce the bug, and the file says so. When mutants were written, `next` states the count, the suite command if one was discovered, and whether the first run collects a line map.
48
92
 
49
93
  `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.
50
94
 
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.
95
+ Discovery reads the package root. An example project inside it, such as a directory with its own `go.mod` or `pytest.ini`, is a different project and is not collected. Go needs a `go.mod` at or above the package.
96
+
97
+ 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 Node child process started by a mapped test loads a small collector through `NODE_OPTIONS`. It writes the lines it ran when it exits, including after `process.exit`, and the parent test stores them under its own name. Each test process has its own dump directory, so parallel test files do not take each other's children. A child started with a cleared environment stays `no coverage`. On Node, a test filter that matches nothing is an error, not a survivor, and only the requested tests can be credited with a kill. A test file that fails before any test runs is one of two things. If it does not link or parse (a removed export, a missing module), no code ran, and the mutant is `unviable` unless some other test failed. If its own top-level code ran the mutated program and threw, that is a kill by that file, and confirm reruns the whole file. 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.
98
+
99
+ 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. A mutant the compiler rejects is `unviable`: the summary says `N did not build`, `unviable` and `unviableIds` list them, and they are neither kills nor gaps. 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.
52
100
 
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.
101
+ `tally` reads that run directory. The summary leads with the no-coverage count, then the survivors. `keep` names each test that killed a mutant, once, as an id `--only-test` can run. `noKillsYet` names tests that saw no mutant die in this batch. That is not a reason to delete them: one batch is not evidence, and the Auspex experiment caught 10 of 18 sealed bugs after pruning by kill evidence. `pruning.advice` stays empty and `deletedTests` is 0. A gap is a survivor. An unseen line is not a gap and not a pass. A timeout is neither a kill nor a gap.
54
102
 
55
- `tally` reads that run directory. The summary leads with the no-coverage count, then the survivors. It names tests that killed a mutant and tests that killed nothing. A gap is a survivor. An unseen line is not a gap and not a pass. A timeout is neither a kill nor a gap. `deletedTests` is 0. It does not delete a file.
103
+ `nextCall` writes to a fresh out dir next to the old one, because the old out dir would skip every finished mutant and repeat the old result. With a gap, it reruns the batch on `HEAD` (`<out>.after-gaps`) once the new test is committed. With no gap, it rescores the keep ids at the same commit with `--only-test` (`<out>.rescore`).
104
+
105
+ ## .probatio.json
106
+
107
+ A project can say how its suite runs. The file sits at the package root and is read even when it is not committed.
108
+
109
+ ```json
110
+ {
111
+ "env": { "ARCH_NATIVE": "1" },
112
+ "testLine": "^(Testing .+|Running .+)$",
113
+ "passLine": "All done, tests as expected"
114
+ }
115
+ ```
116
+
117
+ `env` is added to the suite's environment, for a flag the project's own CI sets. `testLine` names the tests of a `run_tests.sh` suite: each matching line is a test, and the first capture group is the name when there is one. When the suite exits non-zero, the last test line is the one that failed, unless `passLine` was printed. Without `testLine`, a shell suite that prints TAP (`ok 1 - name`, `not ok 2 - name`) is read as TAP. Otherwise it is one command: exit 0 passes, and a failure is `::command`, not an invented test name. An invalid file stops the run and names the field.
56
118
 
57
119
  ## ledger
58
120
 
@@ -60,7 +122,15 @@ The mutant timeout is the baseline duration times 5, and at least 20 seconds, ca
60
122
  npx probatio ledger build --package . --commit HEAD --out .probatio/ledger
61
123
  ```
62
124
 
63
- A fix commit has a `Fixes-bug:` trailer, or it changes both `src` and a test. `ledger build` reverts that commit's src diff onto the tree you name. A diff that applies is written as a forward patch (`source=history`): apply it to put the bug back. A diff that does not apply is reported, and left for a hand-made patch in the same directory (`source=hand`, `fix=<commit>`). A rebuild keeps those hand-made files. Nothing is fuzzy-applied. A src diff over `--max-lines` (default 300) is skipped. `--max-commits N` reads only the newest N non-merge commits and says when older history was not scanned. The default reads the whole history. A tree is checked out only when a fix inside that cap has to be applied.
125
+ A fix commit has a `Fixes-bug:` trailer, or it changes both `src` and a test. A commit that changes the version in `package.json`, `Cargo.toml`, `pyproject.toml`, or `setup.cfg` is a release, and it is not a fix unless it has the trailer: reverting it would put back several changes, not one bug. `ledger build` reverts that commit's src diff onto the tree you name. A diff that applies is written as a forward patch (`source=history`): apply it to put the bug back. A diff that does not apply is reported, and left for a hand-made patch in the same directory (`source=hand`, `fix=<commit>`). A rebuild keeps those hand-made files. Nothing is fuzzy-applied. A src diff over `--max-lines` (default 300) is skipped. `--max-commits N` reads only the newest N non-merge commits and says when older history was not scanned. The default reads the whole history. A tree is checked out only when a fix inside that cap has to be applied.
126
+
127
+ ### ledger check
128
+
129
+ ```bash
130
+ npx probatio ledger check --package . --ledger ledger --out .probatio/ledger-check
131
+ ```
132
+
133
+ Runs every ledger bug that applies (history reverts and hand-made mutants) against the suite, with confirm on, and compares each outcome with `ledger/<id>.golden.json`. A bug that was caught and is not caught now is a regression: `ok` is false and `next` says to find the test that stopped guarding it. Any other change (a survivor now caught, a patch that no longer applies) also fails until it is re-recorded with `--update`, and a changed row needs `Golden-Change: <id>: <why>` in `--message`. A bug with no golden yet is recorded by `--update`. A hand-made mutant whose header says `source=hand fix=<commit>` replaces that fix's history revert, which is how a revert that does not build still guards its bug. Run it where the toolchain is complete: a test that skips for a missing tool can turn a kill into a survivor. This repository's goldens are the outcomes on the CI toolchain job's tools. This repository runs it on every push to `main` and nightly (`.github/workflows/self-score.yml`).
64
134
 
65
135
  ## matrix
66
136
 
@@ -78,6 +148,17 @@ npx probatio golden check --recorded table.json --actual now.json --update wordi
78
148
 
79
149
  `next` and `nextLead` are wording. `ok`, `reason`, `status`, `hostChanged`, and `nextCall` are the contract. `--update wording` re-records a changed `next` or `nextLead` and leaves the contract fields alone. A file whose case names are the top-level keys is written back in that shape. A changed `ok` or `reason` also needs `Golden-Change: <row>: <why>` in `--message`. A home path, or `ok` without a reason, fails and nothing is written.
80
150
 
151
+ ### golden record and golden compare
152
+
153
+ ```bash
154
+ npx probatio golden record --package . --module src/text.ts --tests tests/text.test.ts
155
+ npx probatio golden compare --package . --module src/text.ts --tests tests/text.test.ts --golden tests/golden/text.golden.test.ts --out .probatio/golden-compare --operators wide
156
+ ```
157
+
158
+ `golden record` runs those unit tests once, in a throwaway worktree, with every exported function of the module wrapped. Each call whose arguments and result are plain data becomes a row in `tests/golden/<module>.golden.json`: the function, the arguments, and what it returned, resolved, or threw. A module that reads the clock gets the recorded instant on each row, and the replay pins it. A call with a callback, a class instance, or another hidden input is counted and left out: the tests built on it stay as code. A call that answered two ways for the same arguments is left out as unstable. Recording from a red run is refused. It also writes `tests/golden/<module>.golden.test.ts`, a node:test file that replays every row and does not import Probatio. A changed row fails. That is a behaviour change: fix the code, or re-record and review the JSON diff.
159
+
160
+ `golden compare` mutates the module and scores the same mutants twice, once with only the unit tests and once with only the replay, with confirm on. It reports how many of the unit tests' kills the table also makes, and lists the mutants only the unit tests kill. When that list is empty, the table catches what those tests catch on these mutants. Replacing them is still a decision: keep any test with hidden inputs, run a sealed or ledger check, and give the reason in the commit. Nothing is deleted. Node and TypeScript ESM modules only for now.
161
+
81
162
  ## status
82
163
 
83
164
  ```bash
@@ -100,7 +181,7 @@ An item is `.probatio/queue/<id>.json`. `queue claim` renames it to `claimed/<ag
100
181
 
101
182
  ## MCP
102
183
 
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.
184
+ `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. A tool call runs in the background, so `ping` and other calls are answered while a long `mutate run` works. `notifications/cancelled` with that call's `requestId` stops the command and its suite, removes its worktrees, and sends no reply. The server answers with the client's `protocolVersion` when it is `2024-11-05`, `2025-03-26`, or `2025-06-18`.
104
185
 
105
186
  ```json
106
187
  {
@@ -118,3 +199,7 @@ That block is the Claude, Cursor, and Grok shape. The tool runs the CLI and retu
118
199
  `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
200
 
120
201
  `verify-change` does not score uncommitted edits when `--base` and `--commit` are the same. A clean empty diff says `No diff-scoped mutant.`
202
+
203
+ ## Releases
204
+
205
+ [docs/releasing.md](docs/releasing.md): green `test` and `self-score` on the version commit, a pushed `v<version>` tag, then `npm publish`. `prepublishOnly` refuses anything else.
package/dist/cli.js CHANGED
@@ -1,16 +1,19 @@
1
1
  #!/usr/bin/env node
2
2
  import path from "node:path";
3
- import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
4
+ import { fileURLToPath } from "node:url";
4
5
  import { SCHEMA_VERSION, render } from "./contract.js";
5
6
  import { bool, int, parseArgs, requireText, text, texts } from "./flags.js";
6
7
  import { asTable, checkGolden, goldenShape, readGolden, writeGolden } from "./golden/check.js";
8
+ import { compareGoldens, recordGoldens } from "./golden/record.js";
7
9
  import { buildLedger } from "./ledger/build.js";
10
+ import { checkLedger } from "./ledger/check.js";
8
11
  import { readKills, reportMatrix } from "./matrix/report.js";
9
12
  import { appendFinding, chooseSealed, fixGap, guardAllows, lineHash, loadState, parseGuard, readSourceLine, revertGap, saveState, sealIds, statusEnvelope, } from "./memory/state.js";
10
13
  import { git } from "./mutate/patch.js";
11
14
  import { generateMutants } from "./mutate/generate.js";
12
15
  import { runMutants } from "./mutate/run.js";
13
- import { LABEL_LEAK_SUMMARY, labelLeaked, readSealedLabel, sealedTreeLeaked } from "./mutate/sealed.js";
16
+ import { fixEditedKillers, LABEL_LEAK_SUMMARY, labelLeaked, readSealedLabel, sealedTreeLeaked } from "./mutate/sealed.js";
14
17
  import { generateNext } from "./mutate/suite-decision.js";
15
18
  import { discoverSuite } from "./mutate/suites.js";
16
19
  import { tallyRun } from "./mutate/tally.js";
@@ -47,10 +50,16 @@ else
47
50
  finish(tallyCommand(parsed.flags), parsed.human);
48
51
  if (group === "ledger" && action === "build")
49
52
  finish(await ledgerCommand(parsed.flags), parsed.human);
53
+ if (group === "ledger" && action === "check")
54
+ finish(await ledgerCheckCommand(parsed.flags), parsed.human);
50
55
  if (group === "matrix" && action === "report")
51
56
  finish(matrixCommand(parsed.flags), parsed.human);
52
57
  if (group === "golden" && action === "check")
53
58
  finish(goldenCommand(parsed.flags), parsed.human);
59
+ if (group === "golden" && action === "record")
60
+ finish(await goldenRecordCommand(parsed.flags), parsed.human);
61
+ if (group === "golden" && action === "compare")
62
+ finish(await goldenCompareCommand(parsed.flags), parsed.human);
54
63
  if (group === "gap" && action === "fix")
55
64
  finish(gapFixCommand(parsed.flags), parsed.human);
56
65
  if (group === "gap" && action === "revert")
@@ -73,7 +82,9 @@ else
73
82
  finish(await checkKillCommand(parsed.flags, action), parsed.human);
74
83
  if (group === "verify-change")
75
84
  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);
85
+ if (group === "schema")
86
+ finish(schemaCommand(action), parsed.human);
87
+ finish(usage(false, "Use mutate, ledger, matrix, golden, gap, guard, findings, status, queue, check-kill, verify-change, or schema."), parsed.human);
77
88
  }
78
89
  catch (error) {
79
90
  const message = error instanceof Error ? error.message : String(error);
@@ -112,6 +123,7 @@ async function generateCommand(flags) {
112
123
  skipFiles: texts(flags, "skip-file"),
113
124
  commit: text(flags, "commit") ?? "HEAD",
114
125
  workingTree: bool(flags, "working-tree", false),
126
+ operators: operatorSet(flags),
115
127
  });
116
128
  if (result.error) {
117
129
  return {
@@ -158,6 +170,7 @@ async function generateCommand(flags) {
158
170
  ? null
159
171
  : { argv: ["mutate", "run", "--package", packageDir, "--patches", patches, "--out", path.join(outDir, "runs")] },
160
172
  mutantCount: result.mutants.length,
173
+ operators: operatorSet(flags),
161
174
  stringLiteralMutants: 0,
162
175
  filesVisited: result.filesVisited,
163
176
  budgetHit: result.budgetHit,
@@ -256,6 +269,8 @@ async function runCommand(flags, hide = null) {
256
269
  next: report.next,
257
270
  nextCall: report.ok ? resume : null,
258
271
  killed: report.killed,
272
+ unviable: report.unviable,
273
+ unviableIds: report.unviableIds,
259
274
  survived: report.survived,
260
275
  flaky: report.flaky,
261
276
  timeouts: report.timeouts,
@@ -283,9 +298,32 @@ async function sealedCommand(flags) {
283
298
  const hide = texts(flags, "hide");
284
299
  const outDir = path.resolve(requireText(flags, "out"));
285
300
  const report = await runCommand(flags, hide);
286
- const body = JSON.stringify(report);
301
+ const packageDir = path.resolve(requireText(flags, "package"));
302
+ const repoDir = path.resolve(text(flags, "repo") ?? gitRoot(packageDir));
303
+ const kills = Array.isArray(report.kills) ? report.kills : [];
304
+ // The fix commit is the scored commit unless --fix names an earlier one (new tests landed on top of it).
305
+ const scored = typeof report.commit === "string" ? report.commit : "";
306
+ const fixRef = text(flags, "fix");
307
+ const fixSha = fixRef ? git(repoDir, ["rev-parse", "--verify", `${fixRef}^{commit}`]) : null;
308
+ if (fixSha && fixSha.status !== 0)
309
+ throw new Error(`--fix ${fixRef} does not resolve`);
310
+ const commit = fixSha ? fixSha.stdout.trim() : scored;
311
+ const edited = report.ok && commit ? fixEditedKillers(repoDir, packageDir, commit, hide, kills) : { files: [], olderTestCatch: false };
312
+ const fromFix = kills.length > 0 && !edited.olderTestCatch;
313
+ const judged = {
314
+ ...report,
315
+ ...(fromFix
316
+ ? {
317
+ summary: `${report.summary} Every kill came from a test edited by the fix commit (${edited.files.join(", ")}). That is the fix's own test, so no older test caught the bug.`,
318
+ next: "Record this as a miss. Do not restore the hidden test.",
319
+ }
320
+ : {}),
321
+ fixEdited: edited.files,
322
+ olderTestCatch: edited.olderTestCatch,
323
+ };
324
+ const body = JSON.stringify(judged);
287
325
  if (!labelLeaked(body, label) && !sealedTreeLeaked(outDir, label))
288
- return report;
326
+ return judged;
289
327
  return {
290
328
  schemaVersion: SCHEMA_VERSION,
291
329
  ok: false,
@@ -554,9 +592,53 @@ async function checkKillCommand(flags, id) {
554
592
  onProgress: (line) => process.stderr.write(`${line}\n`),
555
593
  });
556
594
  }
595
+ async function ledgerCheckCommand(flags) {
596
+ const packageDir = path.resolve(requireText(flags, "package"));
597
+ return checkLedger({
598
+ packageDir,
599
+ repoDir: path.resolve(text(flags, "repo") ?? gitRoot(packageDir)),
600
+ commit: text(flags, "commit") ?? "HEAD",
601
+ ledgerDir: path.resolve(packageDir, text(flags, "ledger") ?? "ledger"),
602
+ outDir: path.resolve(requireText(flags, "out")),
603
+ update: bool(flags, "update", false),
604
+ message: text(flags, "message") ?? "",
605
+ concurrency: int(flags, "concurrency") ?? 3,
606
+ suiteTimeoutMs: int(flags, "suite-timeout-ms") ?? 600_000,
607
+ testTimeoutMs: int(flags, "test-timeout-ms") ?? 60_000,
608
+ onProgress: (line) => process.stderr.write(`${line}\n`),
609
+ });
610
+ }
611
+ async function goldenRecordCommand(flags) {
612
+ const packageDir = path.resolve(requireText(flags, "package"));
613
+ return recordGoldens({
614
+ packageDir,
615
+ repoDir: path.resolve(text(flags, "repo") ?? gitRoot(packageDir)),
616
+ commit: text(flags, "commit") ?? "HEAD",
617
+ modules: texts(flags, "module"),
618
+ tests: texts(flags, "tests"),
619
+ outDir: text(flags, "dir") ?? "tests/golden",
620
+ timeoutMs: int(flags, "suite-timeout-ms") ?? 600_000,
621
+ });
622
+ }
623
+ async function goldenCompareCommand(flags) {
624
+ const packageDir = path.resolve(requireText(flags, "package"));
625
+ return compareGoldens({
626
+ packageDir,
627
+ repoDir: path.resolve(text(flags, "repo") ?? gitRoot(packageDir)),
628
+ commit: text(flags, "commit") ?? "HEAD",
629
+ modules: texts(flags, "module"),
630
+ tests: texts(flags, "tests"),
631
+ golden: texts(flags, "golden"),
632
+ outDir: path.resolve(requireText(flags, "out")),
633
+ operators: operatorSet(flags),
634
+ timeoutMs: int(flags, "suite-timeout-ms") ?? 600_000,
635
+ onProgress: (line) => process.stderr.write(`${line}\n`),
636
+ });
637
+ }
557
638
  async function verifyCommand(flags) {
558
639
  const packageDir = path.resolve(requireText(flags, "package"));
559
- return verifyChange({
640
+ const operators = operatorSet(flags);
641
+ const envelope = await verifyChange({
560
642
  packageDir,
561
643
  repoDir: path.resolve(text(flags, "repo") ?? gitRoot(packageDir)),
562
644
  outDir: path.resolve(requireText(flags, "out")),
@@ -565,8 +647,56 @@ async function verifyCommand(flags) {
565
647
  maxMutants: int(flags, "max-mutants") ?? 4,
566
648
  maxMinutes: int(flags, "max-minutes") ?? 3,
567
649
  maxTests: int(flags, "max-tests") ?? null,
650
+ operators,
568
651
  onProgress: (line) => process.stderr.write(`${line}\n`),
569
652
  });
653
+ return { ...envelope, operators };
654
+ }
655
+ /** `--operators core` (default) or `--operators wide`. Anything else is a usage error, not a silent default. */
656
+ function operatorSet(flags) {
657
+ const value = text(flags, "operators") ?? "core";
658
+ if (value !== "core" && value !== "wide")
659
+ throw new Error("--operators must be core or wide");
660
+ return value;
661
+ }
662
+ /** The published JSON Schema of one command's output. MCP-only agents read the contract this way. */
663
+ function schemaCommand(name) {
664
+ const dir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "schemas");
665
+ const commands = existsSync(dir)
666
+ ? readdirSync(dir).filter((file) => file.endsWith(".schema.json")).map((file) => file.slice(0, -".schema.json".length)).sort()
667
+ : [];
668
+ if (!name) {
669
+ return {
670
+ schemaVersion: SCHEMA_VERSION,
671
+ ok: commands.length > 0,
672
+ command: "schema",
673
+ summary: `${commands.length} commands have a published schema.`,
674
+ next: "Pass one name, for example: probatio schema mutate.run",
675
+ nextCall: null,
676
+ commands,
677
+ };
678
+ }
679
+ if (!commands.includes(name)) {
680
+ return {
681
+ schemaVersion: SCHEMA_VERSION,
682
+ ok: false,
683
+ command: "schema",
684
+ summary: `No schema named ${name}.`,
685
+ next: `Use one of: ${commands.join(", ")}.`,
686
+ nextCall: null,
687
+ commands,
688
+ };
689
+ }
690
+ return {
691
+ schemaVersion: SCHEMA_VERSION,
692
+ ok: true,
693
+ command: "schema",
694
+ summary: `JSON Schema for ${name}, schemaVersion ${SCHEMA_VERSION}.`,
695
+ next: "Validate a command's stdout against it. A new field does not change schemaVersion. A renamed or removed one does.",
696
+ nextCall: null,
697
+ commands,
698
+ schema: JSON.parse(readFileSync(path.join(dir, `${name}.schema.json`), "utf8")),
699
+ };
570
700
  }
571
701
  function gitRoot(cwd) {
572
702
  const result = git(cwd, ["rev-parse", "--show-toplevel"]);
package/dist/contract.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { homedir } from "node:os";
2
- export const SCHEMA_VERSION = 1;
2
+ /** Bumped when a field is renamed, removed, or changes meaning. 2: `drop` became `noKillsYet`, and a compile or link failure became `unviable`, not `killed`. */
3
+ export const SCHEMA_VERSION = 2;
3
4
  /** Replace the home directory so a result never carries a user's name or home path. */
4
5
  export function scrub(value, home = homedir(), keepPaths = false) {
5
6
  if (typeof value === "string") {