probatio 0.1.3 → 0.2.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/README.md CHANGED
@@ -24,7 +24,7 @@ Each command prints one JSON object and exits 0 only when `ok` is true.
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
 
@@ -32,7 +32,45 @@ Agents changing this repo should read [AGENTS.md](AGENTS.md). The MCP server is
32
32
 
33
33
  ## Status
34
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.
35
+ What a result means:
36
+
37
+ - **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.
38
+ - **survived**: a test ran the line and did not notice. That is a gap. Add a test that fails on the mutant, then rerun.
39
+ - **no coverage**: no test executed that line, so the suite was not started. It is not a pass and not a gap.
40
+ - **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.
41
+ - **timed out**: the baseline clock ran out. It is not a kill.
42
+ - A red baseline, or a baseline with no test report, stops before the first mutant.
43
+
44
+ What runs today:
45
+
46
+ - 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`.
47
+ - 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.
48
+ - 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`.
49
+ - A slow suite with no line map (baseline 5 seconds or more, more than 30 mutants pending) stops before the first mutant.
50
+ - 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.
51
+
52
+ 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):
53
+
54
+ | Subject | Result |
55
+ |---|---|
56
+ | QuixBugs, Python program | no coverage: the planted line was not executed |
57
+ | QuixBugs, Java program | survived |
58
+ | Commons CSV, one bug | survived |
59
+ | tqdm, one bug | survived |
60
+ | First one-command `mutate sealed` run | survived, both runs |
61
+ | Commons CSV `c15a06ee` (CSV-288) | the suite stayed green with the test hidden |
62
+ | Commons CSV `2c83a308` (CSV-271) | killed on 10-06 with no line map; survived on 10-08 with the map; see below |
63
+
64
+ 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.
65
+
66
+ Known limits:
67
+
68
+ - 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.
69
+ - `mutate sealed` checks whether the fix commit edited the killing test (`fixEdited`, `olderTestCatch`). 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.
70
+ - 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.
71
+ - The operator set is small: condition swaps, relational swaps, booleans, a dropped `!`. No arithmetic, statement deletion, or constants.
72
+
73
+ The package version in this repository is in `package.json`. The npm badge above is the published version.
36
74
 
37
75
  ## mutate
38
76
 
@@ -48,11 +86,29 @@ npx probatio mutate tally --out .probatio/runs
48
86
 
49
87
  `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
88
 
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.
89
+ 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.
90
+
91
+ 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.
52
92
 
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.
93
+ 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.
94
+
95
+ `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.
96
+
97
+ `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`).
98
+
99
+ ## .probatio.json
100
+
101
+ A project can say how its suite runs. The file sits at the package root and is read even when it is not committed.
102
+
103
+ ```json
104
+ {
105
+ "env": { "ARCH_NATIVE": "1" },
106
+ "testLine": "^(Testing .+|Running .+)$",
107
+ "passLine": "All done, tests as expected"
108
+ }
109
+ ```
54
110
 
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.
111
+ `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
112
 
57
113
  ## ledger
58
114
 
@@ -60,7 +116,7 @@ The mutant timeout is the baseline duration times 5, and at least 20 seconds, ca
60
116
  npx probatio ledger build --package . --commit HEAD --out .probatio/ledger
61
117
  ```
62
118
 
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.
119
+ 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.
64
120
 
65
121
  ## matrix
66
122
 
@@ -100,7 +156,7 @@ An item is `.probatio/queue/<id>.json`. `queue claim` renames it to `claimed/<ag
100
156
 
101
157
  ## MCP
102
158
 
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.
159
+ `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
160
 
105
161
  ```json
106
162
  {
package/dist/cli.js CHANGED
@@ -10,7 +10,7 @@ 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, sealedTreeLeaked } from "./mutate/sealed.js";
13
+ import { fixEditedKillers, 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";
@@ -256,6 +256,8 @@ async function runCommand(flags, hide = null) {
256
256
  next: report.next,
257
257
  nextCall: report.ok ? resume : null,
258
258
  killed: report.killed,
259
+ unviable: report.unviable,
260
+ unviableIds: report.unviableIds,
259
261
  survived: report.survived,
260
262
  flaky: report.flaky,
261
263
  timeouts: report.timeouts,
@@ -283,9 +285,26 @@ async function sealedCommand(flags) {
283
285
  const hide = texts(flags, "hide");
284
286
  const outDir = path.resolve(requireText(flags, "out"));
285
287
  const report = await runCommand(flags, hide);
286
- const body = JSON.stringify(report);
288
+ const packageDir = path.resolve(requireText(flags, "package"));
289
+ const repoDir = path.resolve(text(flags, "repo") ?? gitRoot(packageDir));
290
+ const kills = Array.isArray(report.kills) ? report.kills : [];
291
+ const commit = typeof report.commit === "string" ? report.commit : "";
292
+ const edited = report.ok && commit ? fixEditedKillers(repoDir, packageDir, commit, hide, kills) : { files: [], olderTestCatch: false };
293
+ const fromFix = kills.length > 0 && !edited.olderTestCatch;
294
+ const judged = {
295
+ ...report,
296
+ ...(fromFix
297
+ ? {
298
+ 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.`,
299
+ next: "Record this as a miss. Do not restore the hidden test.",
300
+ }
301
+ : {}),
302
+ fixEdited: edited.files,
303
+ olderTestCatch: edited.olderTestCatch,
304
+ };
305
+ const body = JSON.stringify(judged);
287
306
  if (!labelLeaked(body, label) && !sealedTreeLeaked(outDir, label))
288
- return report;
307
+ return judged;
289
308
  return {
290
309
  schemaVersion: SCHEMA_VERSION,
291
310
  ok: false,
@@ -106,6 +106,9 @@ function collect(repo, commit, srcPrefix, testsPrefix, maxCommits) {
106
106
  continue;
107
107
  if (!hasTrailer && lines === 0)
108
108
  continue;
109
+ // A version bump ships whatever was finished. Reverting it puts back several changes, not one bug.
110
+ if (!hasTrailer && bumpsVersion(repo, sha))
111
+ continue;
109
112
  found.push({
110
113
  commit: sha,
111
114
  date: date ?? "",
@@ -116,6 +119,17 @@ function collect(repo, commit, srcPrefix, testsPrefix, maxCommits) {
116
119
  }
117
120
  return { found, scanned: limited.length, budgetHit };
118
121
  }
122
+ const MANIFESTS = ["package.json", "Cargo.toml", "pyproject.toml", "setup.cfg"].map((name) => `:(glob)**/${name}`);
123
+ /** True when the commit changes a project version line in a manifest. */
124
+ function bumpsVersion(repo, sha) {
125
+ const diff = git(repo, ["show", "--format=", "-U0", sha, "--", ...MANIFESTS]);
126
+ if (diff.status !== 0)
127
+ return false;
128
+ return diff.stdout
129
+ .split("\n")
130
+ .filter((line) => /^[-+]/.test(line) && !line.startsWith("+++") && !line.startsWith("---"))
131
+ .some((line) => /"version"\s*:/.test(line) || /^[-+]\s*version\s*=/.test(line));
132
+ }
119
133
  function entry(candidate, id, status, files, reason) {
120
134
  return {
121
135
  id,
package/dist/mcp.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { spawnSync } from "node:child_process";
2
+ import { spawn } 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";
@@ -16,6 +16,8 @@ if (tooOld) {
16
16
  })}\n`);
17
17
  process.exit(1);
18
18
  }
19
+ /** Protocol versions this server speaks. The client's choice wins when it is one of these. */
20
+ const PROTOCOLS = ["2024-11-05", "2025-03-26", "2025-06-18"];
19
21
  const here = path.dirname(fileURLToPath(import.meta.url));
20
22
  const version = readVersion();
21
23
  const tool = {
@@ -28,6 +30,9 @@ const tool = {
28
30
  },
29
31
  };
30
32
  let buffer = "";
33
+ let closing = false;
34
+ /** Tool calls still running, by request id. A cancel kills the child and drops the reply. */
35
+ const running = new Map();
31
36
  process.stdin.setEncoding("utf8");
32
37
  process.stdin.on("data", (chunk) => {
33
38
  buffer += chunk;
@@ -35,7 +40,10 @@ process.stdin.on("data", (chunk) => {
35
40
  });
36
41
  process.stdin.on("end", () => {
37
42
  drain(true);
38
- process.exit(0);
43
+ closing = true;
44
+ // A call already running still gets its reply before the server exits.
45
+ if (running.size === 0)
46
+ process.exit(0);
39
47
  });
40
48
  function drain(flush = false) {
41
49
  const lines = buffer.split("\n");
@@ -57,6 +65,22 @@ function drain(flush = false) {
57
65
  function handle(message) {
58
66
  if (!message.method)
59
67
  return;
68
+ if (message.method === "notifications/cancelled") {
69
+ const key = message.params?.requestId;
70
+ if (key === undefined)
71
+ return;
72
+ const child = running.get(String(key));
73
+ running.delete(String(key));
74
+ if (child?.pid) {
75
+ try {
76
+ process.kill(-child.pid, "SIGTERM");
77
+ }
78
+ catch {
79
+ child.kill("SIGTERM");
80
+ }
81
+ }
82
+ return;
83
+ }
60
84
  if (message.id === undefined || message.id === null)
61
85
  return;
62
86
  if (message.method === "initialize") {
@@ -64,7 +88,7 @@ function handle(message) {
64
88
  jsonrpc: "2.0",
65
89
  id: message.id,
66
90
  result: {
67
- protocolVersion: "2024-11-05",
91
+ protocolVersion: PROTOCOLS.includes(message.params?.protocolVersion ?? "") ? message.params?.protocolVersion : PROTOCOLS[0],
68
92
  capabilities: { tools: {} },
69
93
  serverInfo: { name: "probatio", version },
70
94
  tools: [tool],
@@ -86,29 +110,7 @@ function handle(message) {
86
110
  send({ jsonrpc: "2.0", id: message.id, error: { code: -32602, message: "argv must be an array of strings" } });
87
111
  return;
88
112
  }
89
- const cli = cliCommand();
90
- const child = spawnSync(cli.bin, [...cli.args, ...argv], { encoding: "utf8" });
91
- const text = child.stdout ?? "";
92
- let envelope = null;
93
- try {
94
- envelope = JSON.parse(text);
95
- }
96
- catch {
97
- envelope = null;
98
- }
99
- if (!envelope || typeof envelope !== "object") {
100
- send({ jsonrpc: "2.0", id: message.id, error: { code: -32000, message: "the command did not return one JSON object" } });
101
- return;
102
- }
103
- send({
104
- jsonrpc: "2.0",
105
- id: message.id,
106
- result: {
107
- content: [{ type: "text", text: text.trim() }],
108
- structuredContent: envelope,
109
- isError: envelope.ok !== true,
110
- },
111
- });
113
+ call(message.id, argv);
112
114
  return;
113
115
  }
114
116
  send({
@@ -117,6 +119,53 @@ function handle(message) {
117
119
  error: { code: -32601, message: `method not found: ${message.method}` },
118
120
  });
119
121
  }
122
+ /** One CLI command per call. It runs beside other calls, so ping and cancel are answered meanwhile. */
123
+ function call(id, argv) {
124
+ const key = String(id);
125
+ const cli = cliCommand();
126
+ // Its own process group, so a cancel also stops the suite the command started.
127
+ const child = spawn(cli.bin, [...cli.args, ...argv], { stdio: ["ignore", "pipe", "ignore"], detached: true });
128
+ running.set(key, child);
129
+ let text = "";
130
+ child.stdout?.setEncoding("utf8");
131
+ child.stdout?.on("data", (chunk) => {
132
+ text += chunk;
133
+ });
134
+ const settle = (failure) => {
135
+ // A cancelled call is no longer in the map. Its reply is dropped.
136
+ const live = running.get(key) === child;
137
+ running.delete(key);
138
+ if (live)
139
+ reply(id, text, failure);
140
+ if (closing && running.size === 0)
141
+ process.exit(0);
142
+ };
143
+ child.on("error", (error) => settle(error.message));
144
+ child.on("close", () => settle(null));
145
+ }
146
+ function reply(id, text, failure) {
147
+ let envelope = null;
148
+ try {
149
+ envelope = JSON.parse(text);
150
+ }
151
+ catch {
152
+ envelope = null;
153
+ }
154
+ if (!envelope || typeof envelope !== "object") {
155
+ const message = failure ? `the command did not start: ${failure}` : "the command did not return one JSON object";
156
+ send({ jsonrpc: "2.0", id, error: { code: -32000, message } });
157
+ return;
158
+ }
159
+ send({
160
+ jsonrpc: "2.0",
161
+ id,
162
+ result: {
163
+ content: [{ type: "text", text: text.trim() }],
164
+ structuredContent: envelope,
165
+ isError: envelope.ok !== true,
166
+ },
167
+ });
168
+ }
120
169
  function cliCommand() {
121
170
  const compiled = path.join(here, "cli.js");
122
171
  if (existsSync(compiled))
@@ -1,34 +1,39 @@
1
- // Nameless line dump for a plain script. This is not a node:test process and it
1
+ // Nameless line dump for a node child of a test. This is not a node:test process and it
2
2
  // does not write a coverage shard. The parent test's afterEach reads the dump.
3
+ // The test-process collector puts this file in NODE_OPTIONS, so the child needs no import.
4
+ // The dump is written on `exit`, which also runs after process.exit(), so it is synchronous.
3
5
  import { randomBytes } from "node:crypto"
4
6
  import { mkdirSync, writeFileSync } from "node:fs"
5
7
  import path from "node:path"
6
- import { openLineSampler } from "./precise-lines.mjs"
8
+ import { openSyncLineSampler } from "./precise-lines.mjs"
7
9
 
8
10
  const mapPath = process.env.PROBATIO_COVERAGE_MAP
9
- if (mapPath) {
11
+ if (mapPath && !globalThis.__probatioChildLines) {
12
+ globalThis.__probatioChildLines = true
10
13
  let sampler = null
11
14
  try {
12
- sampler = await openLineSampler((rel) => rel.endsWith("child-lines.mjs") || rel.endsWith("precise-lines.mjs"))
15
+ sampler = openSyncLineSampler(
16
+ (rel) => rel.endsWith("child-lines.mjs") || rel.endsWith("precise-lines.mjs"),
17
+ process.env.PROBATIO_PACKAGE_ROOT || process.cwd(),
18
+ )
13
19
  } catch (err) {
14
20
  writeFileSync(`${mapPath}.error`, `${err instanceof Error ? err.message : String(err)}\n`)
15
21
  }
16
22
  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
+ process.on("exit", () => {
24
+ // A node:test process that loaded this through an inherited NODE_OPTIONS has its own
25
+ // hooks. Its hits are already named. A nameless dump would land on the wrong test.
26
+ if (globalThis.__probatioTestHooks) return
27
+ try {
28
+ const hits = sampler.positiveLines()
29
+ // A child started by a mapped test writes where that test's process reads.
30
+ // A child that was not (a script that imports this file itself) uses the shared dir.
31
+ const dir = process.env.PROBATIO_CHILD_DIR || `${mapPath}.children`
23
32
  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) => {
33
+ writeFileSync(path.join(dir, `${process.pid}-${randomBytes(4).toString("hex")}.json`), JSON.stringify({ hits }))
34
+ } catch (err) {
29
35
  writeFileSync(`${mapPath}.error`, `${err instanceof Error ? err.message : String(err)}\n`)
30
- })
31
- }
32
- process.on("beforeExit", dump)
36
+ }
37
+ })
33
38
  }
34
39
  }
@@ -1,12 +1,21 @@
1
1
  // node --test custom reporter. One JSON object on stdout: failures with the file
2
2
  // the runner itself recorded. TAP headings are not used.
3
+ import { staticLoadFailure } from "./load-failure.mjs"
4
+
3
5
  export default async function* reporter(source) {
6
+ // A file that fails to load prints its error on stderr before the file-level failure.
7
+ const stderr = new Map()
4
8
  const failed = []
5
9
  const names = []
6
10
  let tests = 0
7
11
  let pass = 0
8
12
  let fail = 0
9
13
  for await (const event of source) {
14
+ if (event.type === "test:stderr") {
15
+ const key = String(event.data?.file ?? "")
16
+ stderr.set(key, `${stderr.get(key) ?? ""}${String(event.data?.message ?? "")}`)
17
+ continue
18
+ }
10
19
  if (event.type === "test:pass" || event.type === "test:fail") {
11
20
  const data = event.data ?? {}
12
21
  if (data.details && data.details.type === "suite") continue
@@ -18,6 +27,12 @@ export default async function* reporter(source) {
18
27
  // An open handle is reported as a timeout whose name is the file path.
19
28
  // failureType lives on the error; the text is on error.cause, not message.
20
29
  if (fileTimedOut(name, file, data.details?.error)) failure.fileTimeout = true
30
+ // The file itself failed and no test in it ran: it did not load.
31
+ else if (isFileName(name, file)) {
32
+ failure.fileLoad = true
33
+ // It did not link (removed export, missing module, no parse): no test code ran.
34
+ if (staticLoadFailure(stderr.get(file) ?? "")) failure.staticLoad = true
35
+ }
21
36
  failed.push(failure)
22
37
  }
23
38
  } else if (event.type === "test:diagnostic") {
@@ -33,6 +48,12 @@ export default async function* reporter(source) {
33
48
  yield JSON.stringify({ tests, pass, fail, failed, names })
34
49
  }
35
50
 
51
+ function isFileName(name, file) {
52
+ const base = String(file).split(/[/\\]/).pop()
53
+ if (!base) return false
54
+ return name === file || name === base || name.endsWith(`/${base}`) || name.endsWith(`\\${base}`)
55
+ }
56
+
36
57
  function fileTimedOut(name, file, error) {
37
58
  if (!error || typeof error !== "object") return false
38
59
  const cause = typeof error.cause === "string" ? error.cause : ""
@@ -0,0 +1,40 @@
1
+ // A test file that failed before any test ran either did not link or threw while loading.
2
+ // Did not link: a removed export, a missing module, a file that does not parse. No code ran.
3
+ // A compiled language rejects the same change at compile time, so that mutant is unviable.
4
+ // Threw while loading: the file's own top-level code ran the program and it broke. That is a kill.
5
+
6
+ const STATIC_ERROR = /^(?:SyntaxError|(?:Type)?Error \[ERR_(?:MODULE_NOT_FOUND|UNKNOWN_FILE_EXTENSION|UNSUPPORTED_DIR_IMPORT|PACKAGE_PATH_NOT_EXPORTED)\]):/
7
+
8
+ /**
9
+ * `text` is the error as printed: a `Name: message` line, then `at` frames.
10
+ * Static when the error is a link or parse error and no frame is in the project's own code.
11
+ * A SyntaxError thrown by running code (JSON.parse at the top of a test) has a frame in that file.
12
+ */
13
+ export function staticLoadFailure(text) {
14
+ const lines = String(text).split(/\r?\n/)
15
+ const head = lines.findIndex((line) => STATIC_ERROR.test(line.trim()))
16
+ if (head === -1) return false
17
+ for (const raw of lines.slice(head + 1)) {
18
+ const line = raw.trim()
19
+ if (!line.startsWith("at ")) break
20
+ if (projectFrame(line)) return false
21
+ }
22
+ return true
23
+ }
24
+
25
+ /** The error object a node:test event carries, printed the way node prints it. */
26
+ export function errorText(error) {
27
+ if (!error || typeof error !== "object") return ""
28
+ const name = typeof error.name === "string" ? error.name : "Error"
29
+ const code = typeof error.code === "string" ? ` [${error.code}]` : ""
30
+ const message = typeof error.message === "string" ? error.message : ""
31
+ const stack = typeof error.stack === "string" ? error.stack.split("\n").slice(1).join("\n") : ""
32
+ return `${name}${code}: ${message}\n${stack}`
33
+ }
34
+
35
+ function projectFrame(frame) {
36
+ if (/\bnode:/.test(frame) || frame.includes("<anonymous>")) return false
37
+ const location = /(?:file:\/\/)?(\/[^\s)]+|[A-Za-z]:\\[^\s)]+):\d+:\d+\)?$/.exec(frame)
38
+ if (!location) return false
39
+ return !location[1].split(/[\\/]/).includes("node_modules")
40
+ }
@@ -12,6 +12,8 @@ if (mapPath) {
12
12
  let prelude = []
13
13
  /** @type {{ positiveLines: () => Promise<Array<{ file: string, line: number }>> } | null} */
14
14
  let sampler = null
15
+ /** @type {((mapPath: string) => Array<{ file: string, line: number }>) | null} */
16
+ let takeChildHits = null
15
17
 
16
18
  function add(hits, name) {
17
19
  if (!name) return
@@ -40,7 +42,10 @@ if (mapPath) {
40
42
  async beforeAll() {
41
43
  try {
42
44
  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"))
45
+ sampler = await opened.openLineSampler((rel) => rel.endsWith("mocha-coverage.cjs") || rel.endsWith("precise-lines.mjs") || rel.endsWith("node-coverage.mjs") || rel.endsWith("child-lines.mjs"))
46
+ // A node child of a Mocha test is mapped under that test, the same as under node:test.
47
+ opened.exposeChildLines()
48
+ takeChildHits = opened.takeChildHits
44
49
  prelude = await sampler.positiveLines()
45
50
  } catch (err) {
46
51
  writeFileSync(`${mapPath}.error`, `${err instanceof Error ? err.message : String(err)}\n`)
@@ -52,6 +57,7 @@ if (mapPath) {
52
57
  try {
53
58
  add(prelude, title)
54
59
  add(await sampler.positiveLines(), title)
60
+ if (takeChildHits) add(takeChildHits(mapPath), title)
55
61
  } catch (err) {
56
62
  writeFileSync(`${mapPath}.error`, `${err instanceof Error ? err.message : String(err)}\n`)
57
63
  }
@@ -40,8 +40,22 @@ function underPackage(abs) {
40
40
  return real === root || real.startsWith(root.endsWith(path.sep) ? root : root + path.sep)
41
41
  }
42
42
 
43
+ /** TypeScript with NodeNext writes `./limit.js` for `limit.ts`. tsx resolves that, so this loader does too. */
44
+ function typeScriptSibling(specifier) {
45
+ if (!/^(\.{1,2}\/|\/|file:)/.test(specifier)) return null
46
+ const swapped = specifier.replace(/\.js$/, ".ts").replace(/\.mjs$/, ".mts").replace(/\.cjs$/, ".cts").replace(/\.jsx$/, ".tsx")
47
+ return swapped === specifier ? null : swapped
48
+ }
49
+
43
50
  export async function resolve(specifier, context, nextResolve) {
44
- const resolved = await nextResolve(specifier, context)
51
+ let resolved
52
+ try {
53
+ resolved = await nextResolve(specifier, context)
54
+ } catch (error) {
55
+ const sibling = typeScriptSibling(specifier)
56
+ if (!sibling) throw error
57
+ resolved = await nextResolve(sibling, context)
58
+ }
45
59
  const gen = generation()
46
60
  if (!gen) return resolved
47
61
  let abs
@@ -1,10 +1,11 @@
1
1
  // One suite process for covered mutants of one file. Jobs arrive on stdin.
2
2
  // A mutant that exits this process ends it. The parent starts a clean process for the next mutant.
3
3
  import { createInterface } from "node:readline"
4
- import { realpathSync, writeFileSync } from "node:fs"
4
+ import { readFileSync, realpathSync, writeFileSync } from "node:fs"
5
5
  import { register } from "node:module"
6
6
  import { run } from "node:test"
7
7
  import path from "node:path"
8
+ import { errorText, staticLoadFailure } from "./load-failure.mjs"
8
9
  import { openLineSampler } from "./precise-lines.mjs"
9
10
 
10
11
  const loader = new URL("./node-batch-loader.mjs", import.meta.url).href
@@ -33,6 +34,12 @@ async function runJob(job) {
33
34
  const patterns = (job.names || []).filter((name) => name.length > 0).map((name) => new RegExp(`^${escapeRegExp(name)}$`))
34
35
  const failed = []
35
36
  const names = []
37
+ // run() with isolation "none" ignores testNamePatterns on Node 22, so every test in the file runs.
38
+ // Only a requested test, or a subtest of one, is a result. Another test failing is not a kill.
39
+ const wanted = new Set((job.names || []).filter((name) => name.length > 0))
40
+ const stack = []
41
+ const requested = (name, nesting) =>
42
+ wanted.size === 0 || wanted.has(name) || stack.slice(0, nesting).some((parent) => wanted.has(parent))
36
43
  const testTimeout = job.testTimeoutMs > 0 ? job.testTimeoutMs : job.timeoutMs
37
44
  const stream = run({
38
45
  files,
@@ -47,10 +54,28 @@ async function runJob(job) {
47
54
  const collected = (async () => {
48
55
  for await (const event of stream) {
49
56
  lastEvent = Date.now()
50
- if (event.type !== "test:pass" && event.type !== "test:fail") continue
51
57
  const data = event.data || {}
58
+ const nesting = Number.isInteger(data.nesting) ? data.nesting : 0
59
+ if (event.type === "test:start") {
60
+ stack.length = nesting
61
+ stack[nesting] = typeof data.name === "string" ? data.name : ""
62
+ continue
63
+ }
64
+ if (event.type !== "test:pass" && event.type !== "test:fail") continue
52
65
  const name = typeof data.name === "string" ? data.name : ""
53
- if (!name || files.includes(name)) continue
66
+ if (!name) continue
67
+ if (files.includes(name)) {
68
+ // The file itself failed: it did not load (a removed export, a throw at import time).
69
+ // No test in it ran. That is a failure of the file, the same as node --test reports it.
70
+ if (event.type === "test:fail" && fileHasRequested(name, wanted)) {
71
+ const posix = path.relative(job.root, name).split(path.sep).join("/")
72
+ const linked = !staticLoadFailure(errorText(data.details && data.details.error))
73
+ failed.push({ name: posix, file: name, line: 1, fileLoad: true, ...(linked ? {} : { staticLoad: true }) })
74
+ if (!names.includes(posix)) names.push(posix)
75
+ }
76
+ continue
77
+ }
78
+ if (!requested(name, nesting)) continue
54
79
  if (!names.includes(name)) names.push(name)
55
80
  if (event.type === "test:fail") {
56
81
  const error = data.details && data.details.error
@@ -101,6 +126,18 @@ async function runJob(job) {
101
126
  return { id: job.id, pid: process.pid, failed, names, command, dirty }
102
127
  }
103
128
 
129
+ /** A file with no requested test in it does not speak for the requested tests. */
130
+ function fileHasRequested(file, wanted) {
131
+ if (wanted.size === 0) return true
132
+ let text = ""
133
+ try {
134
+ text = readFileSync(file, "utf8")
135
+ } catch {
136
+ return true
137
+ }
138
+ return [...wanted].some((name) => text.includes(name))
139
+ }
140
+
104
141
  function fileNameIs(name, file) {
105
142
  const base = String(file).split(/[/\\]/).pop()
106
143
  if (!base) return false