probatio 0.0.4 → 0.1.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
@@ -1,11 +1,11 @@
1
1
  # Probatio
2
2
 
3
3
  Probatio (Latin: a testing, a proof) is a free testing toolkit under the MIT license, built for AI agents
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 finds
6
- the bugs a test suite would miss instead of asking anyone to read more tests.
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.
7
7
 
8
- Status (2026-10-05): `mutate run` discovers the project suite and scores it. Python, JavaScript, and TypeScript narrow each mutant with a line map collected on the baseline. C does the same when the binary was built with LLVM coverage. 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` mutates the changed lines and uses that same map. `mutate tally` reads a finished run, names tests that killed nothing, and does not delete a test. The MCP server speaks one JSON object per line and returns the same JSON as the CLI.
8
+ 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, and C# collect a line map on the baseline. A line no test executes is `no coverage`, and the suite is not started. A line that runs only in a child process the map cannot see is `no coverage`. 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. `ran` stays the direct-importer list. `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.0. The copy on npm is 0.0.5. 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.
9
9
 
10
10
  Read in this order:
11
11
 
@@ -31,7 +31,7 @@ npx probatio mutate tally --out .probatio/runs
31
31
 
32
32
  `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
33
 
34
- Python, JavaScript, and TypeScript collect a line map on the baseline. C collects one when LLVM coverage was instrumented. Go and Maven/Java collect file, line, and the test names that hit that line. A later mutant runs only those tests (`go test -run`, Maven `-Dtest`). A line no test executed is `no coverage`, and the suite is not started. Rust and C# can take a test name and do not collect a line. 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.
34
+ Node, pytest, Go, Maven/Java, Rust, and C# collect a line map on the baseline. C collects one when LLVM coverage was instrumented. 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`). A line no test executed is `no coverage`, and the suite is not started. A child process the map cannot see is `no coverage`. 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
35
 
36
36
  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
37
 
package/dist/cli.js CHANGED
@@ -10,6 +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 } from "./mutate/sealed.js";
13
14
  import { generateNext } from "./mutate/suite-decision.js";
14
15
  import { discoverSuite } from "./mutate/suites.js";
15
16
  import { tallyRun } from "./mutate/tally.js";
@@ -23,6 +24,8 @@ try {
23
24
  finish(await generateCommand(parsed.flags), parsed.human);
24
25
  if (group === "mutate" && action === "run")
25
26
  finish(await runCommand(parsed.flags), parsed.human);
27
+ if (group === "mutate" && action === "sealed")
28
+ finish(await sealedCommand(parsed.flags), parsed.human);
26
29
  if (group === "mutate" && action === "tally")
27
30
  finish(tallyCommand(parsed.flags), parsed.human);
28
31
  if (group === "ledger" && action === "build")
@@ -72,7 +75,7 @@ function usage(ok, summary) {
72
75
  ok,
73
76
  command: "mutate.help",
74
77
  summary,
75
- next: "probatio mutate generate writes operator mutants. probatio mutate run confirms each kill twice. probatio mutate tally reads a finished run and does not delete a test. probatio ledger build reverts fix commits. probatio matrix report reads a recorded score table.",
78
+ next: "probatio mutate generate writes operator mutants. probatio mutate run confirms each kill twice. probatio mutate sealed runs one forward bug and keeps the label file out of the report. probatio mutate tally reads a finished run and does not delete a test. probatio ledger build reverts fix commits. probatio matrix report reads a recorded score table.",
76
79
  nextCall: null,
77
80
  };
78
81
  }
@@ -129,7 +132,30 @@ async function generateCommand(flags) {
129
132
  full: path.join(patches, "mutants.json"),
130
133
  };
131
134
  }
132
- async function runCommand(flags) {
135
+ async function runCommand(flags, hide = null) {
136
+ const onlyNames = texts(flags, "only-test");
137
+ if (onlyNames.some((id) => id === "::command" || id.endsWith("::command"))) {
138
+ return {
139
+ schemaVersion: SCHEMA_VERSION,
140
+ ok: false,
141
+ command: "mutate.run",
142
+ summary: "::command is not a test id.",
143
+ next: "Pass a test name the suite can collect.",
144
+ nextCall: null,
145
+ killed: 0,
146
+ };
147
+ }
148
+ if (onlyNames.length > 0 && text(flags, "suite-command")) {
149
+ return {
150
+ schemaVersion: SCHEMA_VERSION,
151
+ ok: false,
152
+ command: "mutate.run",
153
+ summary: "--only-test cannot be combined with --suite-command.",
154
+ next: "Drop --suite-command and pass the keep ids with --only-test.",
155
+ nextCall: null,
156
+ killed: 0,
157
+ };
158
+ }
133
159
  const packageDir = path.resolve(requireText(flags, "package"));
134
160
  const patchDirs = texts(flags, "patches").map((dir) => path.resolve(dir));
135
161
  if (patchDirs.length === 0)
@@ -164,10 +190,12 @@ async function runCommand(flags) {
164
190
  affected: bool(flags, "affected", false),
165
191
  agent: text(flags, "agent") ?? null,
166
192
  suiteCommand: text(flags, "suite-command") ?? null,
193
+ onlyNames: onlyNames.length > 0 ? onlyNames : null,
167
194
  historyPath: text(flags, "history") ? path.resolve(text(flags, "history")) : null,
168
195
  timeoutMultiple: int(flags, "timeout-multiple") ?? 5,
169
196
  timeoutFloorMs: int(flags, "timeout-floor-ms") ?? 20_000,
170
197
  budgetMs: int(flags, "budget-ms") ?? null,
198
+ hide,
171
199
  onProgress: (line) => process.stderr.write(`${line}\n`),
172
200
  });
173
201
  const resume = report.budgetHit
@@ -203,6 +231,22 @@ async function runCommand(flags) {
203
231
  function tallyCommand(flags) {
204
232
  return tallyRun(path.resolve(requireText(flags, "out")));
205
233
  }
234
+ async function sealedCommand(flags) {
235
+ const label = readSealedLabel(path.resolve(requireText(flags, "label")));
236
+ const hide = texts(flags, "hide");
237
+ const report = await runCommand(flags, hide);
238
+ const body = JSON.stringify(report);
239
+ if (!labelLeaked(body, label))
240
+ return report;
241
+ return {
242
+ schemaVersion: SCHEMA_VERSION,
243
+ ok: false,
244
+ command: "mutate.sealed",
245
+ summary: LABEL_LEAK_SUMMARY,
246
+ next: "Keep the label in its own file. Run the same command again.",
247
+ nextCall: null,
248
+ };
249
+ }
206
250
  async function ledgerCommand(flags) {
207
251
  const packageDir = path.resolve(requireText(flags, "package"));
208
252
  const outDir = path.resolve(requireText(flags, "out"));
@@ -0,0 +1,161 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
3
+ import path from "node:path";
4
+ /**
5
+ * One Coverlet run per test name. The collector's report does not name the test,
6
+ * so each name gets its own results directory.
7
+ */
8
+ export function writeCsharpCoverage(pkg, testNames, dest, project) {
9
+ const names = [...new Set(testNames.map((name) => name.trim()).filter((name) => name.length > 0))];
10
+ if (names.length === 0)
11
+ return { ok: false, detail: "csharp coverage: no test names" };
12
+ const work = path.join(path.dirname(dest), "csharp-cover");
13
+ mkdirSync(work, { recursive: true });
14
+ const settings = path.join(work, "coverlet.runsettings");
15
+ // Coverlet skips the test assembly unless asked. A one-project suite keeps the code there.
16
+ writeFileSync(settings, [
17
+ '<?xml version="1.0" encoding="utf-8"?>',
18
+ "<RunSettings>",
19
+ " <DataCollectionRunSettings>",
20
+ " <DataCollectors>",
21
+ ' <DataCollector friendlyName="XPlat Code Coverage">',
22
+ " <Configuration>",
23
+ " <Format>cobertura</Format>",
24
+ " <IncludeTestAssembly>true</IncludeTestAssembly>",
25
+ " </Configuration>",
26
+ " </DataCollector>",
27
+ " </DataCollectors>",
28
+ " </DataCollectionRunSettings>",
29
+ "</RunSettings>",
30
+ "",
31
+ ].join("\n"));
32
+ const files = {};
33
+ const env = dotnetEnv();
34
+ for (const name of names) {
35
+ const results = path.join(work, safe(name));
36
+ mkdirSync(results, { recursive: true });
37
+ const args = ["test", "--nologo", "--verbosity", "quiet", "--settings", settings];
38
+ if (project)
39
+ args.push(project);
40
+ args.push("--filter", `FullyQualifiedName~${name}`, "--collect", "XPlat Code Coverage", "--results-directory", results);
41
+ const ran = spawnSync("dotnet", args, { cwd: pkg, encoding: "utf8", env, timeout: 180_000, maxBuffer: 16 * 1024 * 1024 });
42
+ if (ran.status !== 0 || ran.error)
43
+ continue;
44
+ const report = findCobertura(results);
45
+ if (!report)
46
+ continue;
47
+ let text = "";
48
+ try {
49
+ text = readFileSync(report, "utf8");
50
+ }
51
+ catch {
52
+ continue;
53
+ }
54
+ addCobertura(files, text, pkg, name);
55
+ }
56
+ if (Object.keys(files).length === 0)
57
+ return { ok: false, detail: "csharp coverage: no covered lines" };
58
+ writeFileSync(dest, `${JSON.stringify({ files }, null, 2)}\n`);
59
+ return { ok: true, detail: `${Object.keys(files).length} files` };
60
+ }
61
+ function addCobertura(files, text, pkg, test) {
62
+ const sources = [...text.matchAll(/<source>([^<]+)<\/source>/g)].map((match) => match[1].trim());
63
+ for (const part of text.split(/<class\b/).slice(1)) {
64
+ const headEnd = part.indexOf(">");
65
+ if (headEnd < 0)
66
+ continue;
67
+ const filename = /filename="([^"]+)"/.exec(part.slice(0, headEnd))?.[1];
68
+ if (!filename)
69
+ continue;
70
+ const bodyEnd = part.indexOf("</class>");
71
+ const body = bodyEnd >= 0 ? part.slice(0, bodyEnd) : part;
72
+ const rel = coberturaRel(filename, sources, pkg);
73
+ if (!rel)
74
+ continue;
75
+ for (const tag of body.match(/<line\b[^>]*\/?>/g) ?? []) {
76
+ const number = /number="(\d+)"/.exec(tag);
77
+ const hits = /hits="(\d+)"/.exec(tag);
78
+ if (!number || !hits || Number(hits[1]) <= 0)
79
+ continue;
80
+ addHit(files, rel, Number(number[1]), test);
81
+ }
82
+ }
83
+ }
84
+ function coberturaRel(filename, sources, pkg) {
85
+ const norm = filename.split("\\").join("/");
86
+ const candidates = norm.startsWith("/") || /^[A-Za-z]:\//.test(norm) ? [norm] : [norm];
87
+ for (const source of sources) {
88
+ const root = source.endsWith("/") || source.endsWith("\\") ? source : `${source}/`;
89
+ candidates.push(path.normalize(`${root}${norm}`));
90
+ }
91
+ for (const candidate of candidates) {
92
+ const rel = underPkg(candidate, pkg);
93
+ if (rel)
94
+ return rel;
95
+ }
96
+ return null;
97
+ }
98
+ function underPkg(filename, pkg) {
99
+ if (!existsSync(filename))
100
+ return null;
101
+ let file = filename;
102
+ let root = pkg;
103
+ try {
104
+ file = realpathSync(filename);
105
+ root = realpathSync(pkg);
106
+ }
107
+ catch {
108
+ return null;
109
+ }
110
+ const rel = path.relative(root, file);
111
+ if (!rel || rel.startsWith(".."))
112
+ return null;
113
+ return rel.split(path.sep).join("/");
114
+ }
115
+ function findCobertura(dir) {
116
+ const found = [];
117
+ const visit = (current) => {
118
+ let entries;
119
+ try {
120
+ entries = readdirSync(current);
121
+ }
122
+ catch {
123
+ return;
124
+ }
125
+ for (const entry of entries) {
126
+ const full = path.join(current, entry);
127
+ let info;
128
+ try {
129
+ info = statSync(full);
130
+ }
131
+ catch {
132
+ continue;
133
+ }
134
+ if (info.isDirectory()) {
135
+ visit(full);
136
+ continue;
137
+ }
138
+ if (entry === "coverage.cobertura.xml")
139
+ found.push(full);
140
+ }
141
+ };
142
+ visit(dir);
143
+ return found[0] ?? null;
144
+ }
145
+ function addHit(files, file, line, test) {
146
+ const lines = files[file] ?? {};
147
+ const key = String(line);
148
+ const names = lines[key] ?? [];
149
+ if (!names.includes(test))
150
+ names.push(test);
151
+ lines[key] = names;
152
+ files[file] = lines;
153
+ }
154
+ function dotnetEnv() {
155
+ if (process.env.DOTNET_ROLL_FORWARD)
156
+ return process.env;
157
+ return { ...process.env, DOTNET_ROLL_FORWARD: "Major" };
158
+ }
159
+ function safe(value) {
160
+ return value.replace(/[^A-Za-z0-9._-]+/g, "_");
161
+ }
@@ -252,11 +252,17 @@ public class ProbatioJacocoRun {
252
252
  Class<?> selectorType = Class.forName("org.junit.platform.engine.DiscoverySelector");
253
253
  Class<?> selectorsType = Class.forName("org.junit.platform.engine.discovery.DiscoverySelectors");
254
254
  java.lang.reflect.Method select = selectMethodWithTypes(selectorsType);
255
+ boolean typed = select.getParameterCount() == 3;
255
256
  Object selectors = Array.newInstance(selectorType, unique.size());
256
257
  int index = 0;
257
258
  for (JupiterSpec spec : unique.values()) {
258
259
  Class<?> type = loadTestClass(spec.className);
259
- Object selector = select.invoke(null, new Object[] { type, spec.simple, spec.types });
260
+ // Platform 1.7 has selectMethod(Class, String) only. 5.14's two-argument
261
+ // form drops the parameter list and can run nothing, so keep the types
262
+ // when that overload exists.
263
+ Object selector = typed
264
+ ? select.invoke(null, new Object[] { type, spec.simple, spec.types })
265
+ : select.invoke(null, new Object[] { type, spec.simple });
260
266
  Array.set(selectors, index++, selector);
261
267
  }
262
268
  Class<?> builderType = Class.forName("org.junit.platform.launcher.core.LauncherDiscoveryRequestBuilder");
@@ -426,13 +432,16 @@ public class ProbatioJacocoRun {
426
432
  }
427
433
 
428
434
  static java.lang.reflect.Method selectMethodWithTypes(Class<?> selectorsType) throws NoSuchMethodException {
435
+ java.lang.reflect.Method byName = null;
429
436
  for (java.lang.reflect.Method candidate : selectorsType.getMethods()) {
430
437
  if (!candidate.getName().equals("selectMethod")) continue;
431
438
  Class<?>[] params = candidate.getParameterTypes();
432
439
  if (params.length == 3 && params[0] == Class.class && params[1] == String.class && params[2].isArray() && params[2].getComponentType() == Class.class) {
433
440
  return candidate;
434
441
  }
442
+ if (params.length == 2 && params[0] == Class.class && params[1] == String.class) byName = candidate;
435
443
  }
444
+ if (byName != null) return byName;
436
445
  throw new NoSuchMethodException("selectMethod(Class, String, Class[])");
437
446
  }
438
447
 
@@ -1,13 +1,15 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { createHash, randomBytes } from "node:crypto";
3
- import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
3
+ import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
5
  import path from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { affectedTests } from "./affected.js";
8
8
  import { binaryFromCommand, readCoverageMap, selectionFor, writeLlvmCoverage } from "./coverage-map.js";
9
+ import { writeCsharpCoverage } from "./csharp-coverage.js";
9
10
  import { writeGoCoverage } from "./go-coverage.js";
10
11
  import { writeJavaCoverage } from "./java-coverage.js";
12
+ import { writeRustCoverage } from "./rust-coverage.js";
11
13
  import { applyPatch, changedLines, chooseDirection, filesInDiff, git, restoreTree } from "./patch.js";
12
14
  import { killLabel } from "./kill-label.js";
13
15
  import { TIMEOUT_FLOOR_MS, TIMEOUT_MULTIPLE, TIMEOUT_NEXT, WHOLE_PROGRAM_TEST, WHOLE_SUITE_NEXT, campaignSummary, decideSuite, hasLineMap, noCoverageNext, resumeNext, scaleStop, timeoutLimitMs, } from "./suite-decision.js";
@@ -64,6 +66,7 @@ export async function runMutants(options) {
64
66
  const worktrees = await createWorktrees(repo, packageDir, run.id, commit, options.workers, options.outDir);
65
67
  if ("error" in worktrees)
66
68
  return failReport(options.outDir, run.id, commit, worktrees.error);
69
+ hidePaths(worktrees.dirs, repo, packageDir, options.hide ?? []);
67
70
  const budgetMs = options.budgetMs ?? (options.maxMinutes == null ? null : options.maxMinutes * 60_000);
68
71
  let budgetStarted = 0;
69
72
  const overBudget = () => budgetMs !== null && budgetStarted !== 0 && Date.now() - budgetStarted > budgetMs;
@@ -196,11 +199,9 @@ async function prepare(pkg, options, progress) {
196
199
  if (baselineFailed.length > 0) {
197
200
  return { ok: false, error: "baseline already failing", baselineFailed, suiteCommand: spec.command };
198
201
  }
199
- if (collect && !existsSync(mapPath) && (spec.kind === "go" || spec.kind === "maven")) {
200
- progress(spec.kind === "go" ? "go line map" : "java line map");
201
- const written = spec.kind === "go"
202
- ? writeGoCoverage(pkg, baseline.report.names, mapPath)
203
- : writeJavaCoverage(pkg, baseline.report.names, mapPath);
202
+ if (collect && !existsSync(mapPath) && languageMapKind(spec.kind)) {
203
+ progress(languageMapLabel(spec.kind));
204
+ const written = writeLanguageMap(spec.kind, pkg, baseline.report.names, mapPath, spec.project);
204
205
  if (!written.ok)
205
206
  writeFileSync(path.join(options.outDir, "coverage-error.txt"), `${written.detail}\n`);
206
207
  }
@@ -262,7 +263,8 @@ async function runOne(worktree, pkg, patchPath, options, commit, runId, prep) {
262
263
  return finishMutant(killed(base, direction, ["build"]), pkg, "", false, []);
263
264
  }
264
265
  const picked = selectionFor(prep.coverage, edited.length > 0 ? edited : files);
265
- if (picked.state === "uncovered") {
266
+ const forced = options.onlyNames && options.onlyNames.length > 0 ? options.onlyNames : null;
267
+ if (!forced && picked.state === "uncovered") {
266
268
  return {
267
269
  ...base(),
268
270
  direction,
@@ -270,7 +272,7 @@ async function runOne(worktree, pkg, patchPath, options, commit, runId, prep) {
270
272
  next: noCoverageNext(picked.file, picked.line),
271
273
  };
272
274
  }
273
- const names = picked.state === "covered" ? picked.tests.filter((name) => name !== WHOLE_PROGRAM_TEST) : null;
275
+ const names = forced ?? (picked.state === "covered" ? picked.tests.filter((name) => name !== WHOLE_PROGRAM_TEST) : null);
274
276
  const forceWhole = picked.state === "covered" && (names === null || names.length === 0);
275
277
  // node --test loads every file it is given, so a name pattern still pays for the other files.
276
278
  const selected = narrowNodeFiles(pkg, selectTests(files, prep.tests, prep.affectedMap), names, prep.spec.kind);
@@ -541,14 +543,29 @@ async function createWorktrees(repo, packageDir, runId, commit, workers, outDir)
541
543
  dirs.push(dir);
542
544
  }
543
545
  writeFileSync(path.join(outDir, "worktrees.json"), `${JSON.stringify(dirs)}\n`);
544
- const linked = nodeModulesOf(repo, packageDir);
546
+ const sources = nodeModuleSources(repo, packageDir);
545
547
  for (const dir of dirs) {
546
- const dest = path.join(packageIn(dir, repo, packageDir), "node_modules");
547
- if (linked && !existsSync(dest))
548
- symlinkSync(linked, dest, "dir");
548
+ const pkgRoot = packageIn(dir, repo, packageDir);
549
+ for (const source of sources) {
550
+ const dest = path.join(pkgRoot, source.rel);
551
+ if (existsSync(dest))
552
+ continue;
553
+ mkdirSync(path.dirname(dest), { recursive: true });
554
+ symlinkSync(source.abs, dest, "dir");
555
+ }
549
556
  }
550
557
  return { dirs };
551
558
  }
559
+ function hidePaths(dirs, repo, packageDir, hide) {
560
+ for (const dir of dirs) {
561
+ const pkg = packageIn(dir, repo, packageDir);
562
+ for (const rel of hide) {
563
+ if (!rel || path.isAbsolute(rel))
564
+ continue;
565
+ rmSync(path.join(pkg, rel), { force: true });
566
+ }
567
+ }
568
+ }
552
569
  function removeWorktrees(repo, dirs, outDir) {
553
570
  for (const dir of dirs) {
554
571
  git(repo, ["worktree", "remove", "--force", dir]);
@@ -569,11 +586,30 @@ function recordedWorktrees(outDir) {
569
586
  return [];
570
587
  }
571
588
  }
572
- /** A fresh worktree has no installed dependencies. Use the main checkout's node_modules. */
573
- function nodeModulesOf(repo, packageDir) {
574
- const local = path.join(packageDir, "node_modules");
575
- if (existsSync(local))
576
- return local;
589
+ /**
590
+ * Installed dependency directories to mirror into a fresh worktree.
591
+ * A package can keep node_modules at its root or under a nested example.
592
+ * A run that starts from a worktree looks beside the main checkout too.
593
+ */
594
+ function nodeModuleSources(repo, packageDir) {
595
+ const roots = [packageDir];
596
+ const main = mainCheckoutPackage(repo, packageDir);
597
+ if (main && path.resolve(main) !== path.resolve(packageDir))
598
+ roots.push(main);
599
+ const found = [];
600
+ const seen = new Set();
601
+ for (const root of roots) {
602
+ for (const abs of findNodeModules(root)) {
603
+ const rel = path.relative(root, abs);
604
+ if (seen.has(rel))
605
+ continue;
606
+ seen.add(rel);
607
+ found.push({ rel, abs });
608
+ }
609
+ }
610
+ return found;
611
+ }
612
+ function mainCheckoutPackage(repo, packageDir) {
577
613
  const common = git(repo, ["rev-parse", "--path-format=absolute", "--git-common-dir"]);
578
614
  if (common.status !== 0)
579
615
  return null;
@@ -583,8 +619,40 @@ function nodeModulesOf(repo, packageDir) {
583
619
  const rel = path.relative(repo, packageDir);
584
620
  if (rel.startsWith(".."))
585
621
  return null;
586
- const candidate = path.join(path.dirname(gitDir), rel, "node_modules");
587
- return existsSync(candidate) ? candidate : null;
622
+ return path.join(path.dirname(gitDir), rel);
623
+ }
624
+ function findNodeModules(root) {
625
+ const out = [];
626
+ const visit = (dir) => {
627
+ let entries;
628
+ try {
629
+ entries = readdirSync(dir);
630
+ }
631
+ catch {
632
+ return;
633
+ }
634
+ for (const name of entries) {
635
+ if (name === ".git" || name === "target" || name === "dist")
636
+ continue;
637
+ const full = path.join(dir, name);
638
+ let info;
639
+ try {
640
+ info = statSync(full);
641
+ }
642
+ catch {
643
+ continue;
644
+ }
645
+ if (!info.isDirectory())
646
+ continue;
647
+ if (name === "node_modules") {
648
+ out.push(full);
649
+ continue;
650
+ }
651
+ visit(full);
652
+ }
653
+ };
654
+ visit(root);
655
+ return out;
588
656
  }
589
657
  function packageIn(worktree, repo, packageDir) {
590
658
  const rel = path.relative(repo, packageDir);
@@ -672,10 +740,33 @@ function reportOf(results, context) {
672
740
  flakyIds: results.filter((result) => result.outcome === "flaky").slice(0, 10).map((result) => result.id),
673
741
  kills: results
674
742
  .filter((result) => result.outcome === "killed" && result.cause !== null)
675
- .map((result) => ({ id: result.id, cause: result.cause, killedBy: result.killedBy, next: result.next })),
743
+ .map((result) => ({ id: result.id, cause: result.cause, killedBy: result.killedBy, next: result.next, files: result.files })),
676
744
  full: path.join(context.outDir, "results"),
677
745
  };
678
746
  }
747
+ function languageMapKind(kind) {
748
+ return kind === "go" || kind === "maven" || kind === "cargo" || kind === "dotnet";
749
+ }
750
+ function languageMapLabel(kind) {
751
+ if (kind === "go")
752
+ return "go line map";
753
+ if (kind === "maven")
754
+ return "java line map";
755
+ if (kind === "cargo")
756
+ return "rust line map";
757
+ return "csharp line map";
758
+ }
759
+ function writeLanguageMap(kind, pkg, names, dest, project) {
760
+ if (kind === "go")
761
+ return writeGoCoverage(pkg, names, dest);
762
+ if (kind === "maven")
763
+ return writeJavaCoverage(pkg, names, dest);
764
+ if (kind === "cargo")
765
+ return writeRustCoverage(pkg, names, dest);
766
+ if (kind === "dotnet")
767
+ return writeCsharpCoverage(pkg, names, dest, project);
768
+ return { ok: false, detail: "no line map writer" };
769
+ }
679
770
  function failReport(outDir, runId, commit, error, baselineFailed, next, suiteCommand = "", baselineMs = null, notStarted = []) {
680
771
  const summary = baselineFailed && baselineFailed.length > 0 ? `${error}: ${baselineFailed.join("; ")}` : error;
681
772
  return {
@@ -837,11 +928,34 @@ function findTestFile(pkg, rel) {
837
928
  function sha256(text) {
838
929
  return createHash("sha256").update(text).digest("hex");
839
930
  }
931
+ const BARE_SUITE = new Set(["command", "suite", "pytest", "unittest", "cobol", "make-test", "c", "cpp"]);
840
932
  function failureId(pkg, failure) {
841
- const rel = failure.file ? path.relative(pkg, failure.file) : "";
842
- const file = !rel || rel.startsWith("..") ? path.basename(failure.file || "") : rel.split(path.sep).join("/");
933
+ // Pytest already reports a node id (tests/test_gate.py::test_low). Prepending the
934
+ // file doubles it, and pytest then exits 4 and collects nothing.
935
+ if (pytestNodeId(failure.name))
936
+ return failure.name;
937
+ // Cargo and dotnet report a bare test name. Go reports a package path that is
938
+ // not a file, and JUnit reports a classname. The runnable id is the name itself.
939
+ // An unnamed shell suite stays ::command so a tally does not treat it as a test.
940
+ if (!failure.file)
941
+ return BARE_SUITE.has(failure.name) ? `::${failure.name}` : failure.name;
942
+ if (!testFileOnDisk(pkg, failure.file))
943
+ return failure.name;
944
+ const rel = path.relative(pkg, failure.file);
945
+ const file = !rel || rel.startsWith("..") ? path.basename(failure.file) : rel.split(path.sep).join("/");
843
946
  return `${file}::${failure.name}`;
844
947
  }
948
+ function testFileOnDisk(pkg, file) {
949
+ if (path.isAbsolute(file))
950
+ return existsSync(file);
951
+ return existsSync(path.join(pkg, file));
952
+ }
953
+ function pytestNodeId(name) {
954
+ const mark = name.indexOf("::");
955
+ if (mark <= 0)
956
+ return false;
957
+ return name.slice(0, mark).endsWith(".py");
958
+ }
845
959
  function testArg(pkg, file, testsDir) {
846
960
  const rel = path.relative(pkg, file);
847
961
  if (rel && !rel.startsWith(".."))
@@ -0,0 +1,159 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync, mkdirSync, realpathSync, writeFileSync } from "node:fs";
3
+ import path from "node:path";
4
+ /**
5
+ * One instrumented test binary, then one profile per test name.
6
+ * A single cargo test profile does not say which test hit the line.
7
+ */
8
+ export function writeRustCoverage(pkg, testNames, dest) {
9
+ const names = [...new Set(testNames.map((name) => name.trim()).filter((name) => name.length > 0 && !/\s/.test(name)))];
10
+ if (names.length === 0)
11
+ return { ok: false, detail: "rust coverage: no test names" };
12
+ const llvmCov = findTool("llvm-cov");
13
+ const llvmProf = findTool("llvm-profdata");
14
+ if (!llvmCov || !llvmProf)
15
+ return { ok: false, detail: "rust coverage: llvm tool missing" };
16
+ const env = { ...process.env, RUSTFLAGS: "-C instrument-coverage", CARGO_INCREMENTAL: "0" };
17
+ const built = spawnSync("cargo", ["test", "--no-run", "--message-format=json"], {
18
+ cwd: pkg,
19
+ encoding: "utf8",
20
+ env,
21
+ timeout: 180_000,
22
+ maxBuffer: 32 * 1024 * 1024,
23
+ });
24
+ if (built.status !== 0 || built.error)
25
+ return { ok: false, detail: clip(built.stderr || built.stdout || built.error?.message || "cargo test --no-run failed") };
26
+ const exes = testExecutables(built.stdout);
27
+ if (exes.length === 0)
28
+ return { ok: false, detail: "rust coverage: no test executable" };
29
+ const work = path.join(path.dirname(dest), "rust-cover");
30
+ mkdirSync(work, { recursive: true });
31
+ const files = {};
32
+ for (const name of names) {
33
+ for (const exe of exes) {
34
+ const raw = path.join(work, `${safe(name)}-${safe(path.basename(exe))}.profraw`);
35
+ const ran = spawnSync(exe, [name, "--exact", "--test-threads=1"], {
36
+ cwd: pkg,
37
+ encoding: "utf8",
38
+ env: { ...env, LLVM_PROFILE_FILE: raw },
39
+ timeout: 60_000,
40
+ });
41
+ if (!existsSync(raw))
42
+ continue;
43
+ if (!new RegExp(`^test ${escapeRegExp(name)} \\.\\.\\. ok`, "m").test(`${ran.stdout}\n${ran.stderr}`))
44
+ continue;
45
+ const merged = path.join(work, `${safe(name)}-${safe(path.basename(exe))}.profdata`);
46
+ const merge = spawnSync(llvmProf, ["merge", "-sparse", "-o", merged, raw], { encoding: "utf8" });
47
+ if (merge.status !== 0)
48
+ continue;
49
+ const exported = spawnSync(llvmCov, ["export", exe, `-instr-profile=${merged}`], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
50
+ if (exported.status !== 0)
51
+ continue;
52
+ for (const hit of llvmHits(exported.stdout, pkg))
53
+ addHit(files, hit.file, hit.line, name);
54
+ }
55
+ }
56
+ if (Object.keys(files).length === 0)
57
+ return { ok: false, detail: "rust coverage: no covered lines" };
58
+ writeFileSync(dest, `${JSON.stringify({ files }, null, 2)}\n`);
59
+ return { ok: true, detail: `${Object.keys(files).length} files` };
60
+ }
61
+ function testExecutables(stdout) {
62
+ const found = [];
63
+ for (const line of stdout.split("\n")) {
64
+ if (!line.startsWith("{"))
65
+ continue;
66
+ try {
67
+ const msg = JSON.parse(line);
68
+ if (msg.reason !== "compiler-artifact" || !msg.executable || !msg.profile?.test)
69
+ continue;
70
+ if (!found.includes(msg.executable))
71
+ found.push(msg.executable);
72
+ }
73
+ catch {
74
+ continue;
75
+ }
76
+ }
77
+ return found;
78
+ }
79
+ function llvmHits(stdout, pkg) {
80
+ const start = stdout.indexOf("{");
81
+ if (start < 0)
82
+ return [];
83
+ let parsed;
84
+ try {
85
+ parsed = JSON.parse(stdout.slice(start));
86
+ }
87
+ catch {
88
+ return [];
89
+ }
90
+ const hits = [];
91
+ for (const group of parsed.data ?? []) {
92
+ for (const file of group.files ?? []) {
93
+ if (!file.filename)
94
+ continue;
95
+ const rel = underPkg(file.filename, pkg);
96
+ if (!rel)
97
+ continue;
98
+ for (const segment of file.segments ?? []) {
99
+ const line = segment[0];
100
+ const count = segment[2] ?? 0;
101
+ if (!line || count <= 0)
102
+ continue;
103
+ hits.push({ file: rel, line });
104
+ }
105
+ }
106
+ }
107
+ return hits;
108
+ }
109
+ function underPkg(filename, pkg) {
110
+ let file = filename;
111
+ let root = pkg;
112
+ try {
113
+ if (existsSync(filename))
114
+ file = realpathSync(filename);
115
+ }
116
+ catch {
117
+ // keep the path llvm reported
118
+ }
119
+ try {
120
+ root = realpathSync(pkg);
121
+ }
122
+ catch {
123
+ // keep the package path
124
+ }
125
+ const rel = path.relative(root, file);
126
+ if (!rel || rel.startsWith(".."))
127
+ return null;
128
+ return rel.split(path.sep).join("/");
129
+ }
130
+ function addHit(files, file, line, test) {
131
+ const lines = files[file] ?? {};
132
+ const key = String(line);
133
+ const names = lines[key] ?? [];
134
+ if (!names.includes(test))
135
+ names.push(test);
136
+ lines[key] = names;
137
+ files[file] = lines;
138
+ }
139
+ function findTool(name) {
140
+ const fromXcrun = spawnSync("xcrun", ["--find", name], { encoding: "utf8" });
141
+ const xcrunPath = fromXcrun.stdout?.trim() ?? "";
142
+ if (fromXcrun.status === 0 && xcrunPath && existsSync(xcrunPath))
143
+ return xcrunPath;
144
+ const which = spawnSync("sh", ["-c", `command -v ${name}`], { encoding: "utf8" });
145
+ const found = which.stdout?.trim() ?? "";
146
+ if (which.status === 0 && found)
147
+ return found;
148
+ return null;
149
+ }
150
+ function safe(value) {
151
+ return value.replace(/[^A-Za-z0-9._-]+/g, "_");
152
+ }
153
+ function escapeRegExp(value) {
154
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
155
+ }
156
+ function clip(text) {
157
+ const line = text.trim().split("\n").find((item) => item.trim()) ?? "rust coverage failed";
158
+ return line.slice(0, 240);
159
+ }
@@ -0,0 +1,25 @@
1
+ import { readFileSync } from "node:fs";
2
+ /** The label stays in its own file. The run report must not repeat these three values. */
3
+ export function readSealedLabel(file) {
4
+ let text = "";
5
+ try {
6
+ text = readFileSync(file, "utf8");
7
+ }
8
+ catch {
9
+ throw new Error("label file could not be read");
10
+ }
11
+ const bugId = field(text, "bug_id");
12
+ const issueId = field(text, "issue_id");
13
+ const expectedFail = field(text, "expected_fail");
14
+ if (!bugId || !issueId || !expectedFail)
15
+ throw new Error("label file needs bug_id, issue_id, and expected_fail");
16
+ return { bugId, issueId, expectedFail };
17
+ }
18
+ export function labelLeaked(body, label) {
19
+ return body.includes(label.bugId) || body.includes(label.issueId) || body.includes(label.expectedFail);
20
+ }
21
+ export const LABEL_LEAK_SUMMARY = "The label was copied into the report.";
22
+ function field(text, key) {
23
+ const match = text.match(new RegExp(`^${key}=(.*)$`, "m"));
24
+ return match?.[1]?.trim() ?? "";
25
+ }
@@ -30,7 +30,7 @@ export function timeoutLimitMs(baselineMs, multiple, floorMs) {
30
30
  return Math.max(floor, scaled);
31
31
  }
32
32
  /** Kinds that collect a per-line map on the baseline. Other kinds pay the whole suite. */
33
- const LINE_MAP_KINDS = new Set(["node", "pytest", "c", "go", "maven"]);
33
+ const LINE_MAP_KINDS = new Set(["node", "pytest", "c", "go", "maven", "cargo", "dotnet"]);
34
34
  /** A baseline this long, with no line map, is too expensive to repeat for a large batch. */
35
35
  export const SLOW_BASELINE_MS = 5_000;
36
36
  /** Pending mutants above this count, on a slow unmapped suite, are not started. */
@@ -131,10 +131,12 @@ function nodeSuite(pkg) {
131
131
  return { kind: "node", files, command: "node --test" };
132
132
  }
133
133
  function mochaSuite(pkg) {
134
- const files = walk(pkg, (rel) => isNodeTest(rel) || (rel.split("/").includes("test") && rel.endsWith(".ts")));
135
- if (files.length === 0 || !usesMocha(pkg))
134
+ if (!usesMocha(pkg))
136
135
  return null;
137
- return { kind: "mocha", files, command: "mocha --reporter json" };
136
+ const files = mochaFiles(pkg);
137
+ if (files.length === 0)
138
+ return null;
139
+ return { kind: "mocha", files, command: shown("mocha", buildMochaArgs(pkg, files)) };
138
140
  }
139
141
  /** A Makefile `test` target that links the project's own COBOL tests, instead of one file. */
140
142
  function makeTestSuite(pkg) {
@@ -185,14 +187,119 @@ export function sourceCandidates(pkg) {
185
187
  return walk(pkg, (rel) => SOURCE_EXT.test(rel) && (hasSegment(rel, "tests") || hasSegment(rel, "test")));
186
188
  }
187
189
  function pythonTests(pkg, testsDir) {
188
- const roots = new Set([testsDir, "tests", "test"]);
190
+ const configured = pytestTestPaths(pkg);
191
+ const roots = new Set(configured ?? [testsDir, "tests", "test"]);
189
192
  return walk(pkg, (rel) => {
190
193
  const base = path.posix.basename(rel);
191
- if (!/^test_.*\.py$/.test(base) && !/_test\.py$/.test(base))
194
+ if (!/^test_.*\.py$/.test(base) && !/_test\.py$/.test(base) && !/^tests_.*\.py$/.test(base))
192
195
  return false;
196
+ if (configured)
197
+ return configured.some((root) => rel === root || rel.startsWith(`${root}/`));
193
198
  return rel.split("/").some((part) => roots.has(part));
194
199
  });
195
200
  }
201
+ /** Directories pytest itself would collect. A missing setting keeps the broader walk. */
202
+ function pytestTestPaths(pkg) {
203
+ const fromPyproject = readPyprojectTestPaths(path.join(pkg, "pyproject.toml"));
204
+ if (fromPyproject)
205
+ return fromPyproject;
206
+ for (const file of ["pytest.ini", "tox.ini", "setup.cfg"]) {
207
+ const found = readIniTestPaths(path.join(pkg, file));
208
+ if (found)
209
+ return found;
210
+ }
211
+ return null;
212
+ }
213
+ function readPyprojectTestPaths(file) {
214
+ if (!existsSync(file))
215
+ return null;
216
+ const text = readFileSync(file, "utf8");
217
+ const section = /\[tool\.pytest\.ini_options\][^\n]*\n([\s\S]*?)(?:\n\[|\s*$)/.exec(text)?.[1];
218
+ if (!section)
219
+ return null;
220
+ const key = /testpaths\s*=\s*([\s\S]*?)(?:\n[A-Za-z]|\s*$)/.exec(section)?.[1];
221
+ if (!key)
222
+ return null;
223
+ const quoted = [...key.matchAll(/"([^"]+)"|'([^']+)'/g)].map((match) => (match[1] ?? match[2]).trim()).filter(Boolean);
224
+ return quoted.length > 0 ? quoted : null;
225
+ }
226
+ function readIniTestPaths(file) {
227
+ if (!existsSync(file))
228
+ return null;
229
+ const text = readFileSync(file, "utf8");
230
+ const section = /\[(?:pytest|tool:pytest)\][^\n]*\n([\s\S]*?)(?:\n\[|\s*$)/.exec(text)?.[1];
231
+ if (!section)
232
+ return null;
233
+ const key = /testpaths\s*=\s*([\s\S]*?)(?:\n[A-Za-z]|\s*$)/.exec(section)?.[1];
234
+ if (!key)
235
+ return null;
236
+ const paths = key
237
+ .split(/\s+/)
238
+ .map((item) => item.trim())
239
+ .filter((item) => item.length > 0 && !item.startsWith("#"));
240
+ return paths.length > 0 ? paths : null;
241
+ }
242
+ const MOCHA_SKIP_DIR = new Set(["support", "fixtures", "helpers", "helper"]);
243
+ /** Mocha files the project would run: node-style names, TypeScript under test/, or describe/it scripts. */
244
+ function mochaFiles(pkg) {
245
+ return walk(pkg, (rel) => {
246
+ const parts = rel.split("/");
247
+ if (parts.some((part) => MOCHA_SKIP_DIR.has(part.toLowerCase())))
248
+ return false;
249
+ const inTest = parts.some((part) => part === "test" || part === "tests");
250
+ if (!inTest)
251
+ return false;
252
+ if (isNodeTest(rel))
253
+ return true;
254
+ if (parts.includes("test") && /\.tsx?$/.test(rel))
255
+ return true;
256
+ if (!/\.(?:js|mjs|cjs)$/.test(rel))
257
+ return false;
258
+ let text = "";
259
+ try {
260
+ text = readFileSync(path.join(pkg, rel), "utf8");
261
+ }
262
+ catch {
263
+ return false;
264
+ }
265
+ return /\bdescribe\s*\(/.test(text) || /\bit\s*\(/.test(text);
266
+ });
267
+ }
268
+ /** Args after the mocha binary: reporter, the project's own --require, and files when package.json has no mocha.spec. */
269
+ export function buildMochaArgs(pkg, files) {
270
+ const config = readMochaConfig(pkg);
271
+ const args = ["--reporter", "json", "--timeout", "20000"];
272
+ const fromConfig = new Set((config?.require ?? []).filter((name) => name !== "esm"));
273
+ for (const name of scriptRequires(pkg)) {
274
+ if (name === "esm" || fromConfig.has(name))
275
+ continue;
276
+ args.push("--require", name);
277
+ }
278
+ const hasSpec = Boolean(config?.spec && config.spec.length > 0);
279
+ if (!hasSpec)
280
+ args.push(...files);
281
+ return args;
282
+ }
283
+ function scriptRequires(pkg) {
284
+ const file = path.join(pkg, "package.json");
285
+ if (!existsSync(file))
286
+ return [];
287
+ let script = "";
288
+ try {
289
+ const body = JSON.parse(readFileSync(file, "utf8"));
290
+ script = body.scripts?.test ?? "";
291
+ }
292
+ catch {
293
+ return [];
294
+ }
295
+ const found = [];
296
+ for (const match of script.matchAll(/(?:^|\s)--require(?:=|\s+)(\S+)/g)) {
297
+ const name = match[1].replace(/^['"]|['"]$/g, "");
298
+ if (name && name !== "esm" && !found.includes(name))
299
+ found.push(name);
300
+ }
301
+ return found;
302
+ }
196
303
  function isPytest(pkg, testsDir, files) {
197
304
  if (existsSync(path.join(pkg, "pytest.ini")) || existsSync(path.join(pkg, "conftest.py")))
198
305
  return true;
@@ -224,6 +331,11 @@ function tag(result, command, wholeSuite, selectedTests) {
224
331
  function shown(bin, args) {
225
332
  return [bin, ...args.map((arg) => (/\s/.test(arg) ? JSON.stringify(arg) : arg))].join(" ");
226
333
  }
334
+ function shellQuote(value) {
335
+ if (/^[A-Za-z0-9_:.]+$/.test(value))
336
+ return value;
337
+ return `'${value.replace(/'/g, `'\\''`)}'`;
338
+ }
227
339
  async function launch(pkg, spec, files, pattern, names, timeoutMs, env, hooks) {
228
340
  const selection = names && names.length > 0 ? names : namesFromPattern(pattern);
229
341
  const wantsNames = selection.length > 0;
@@ -256,12 +368,15 @@ async function launch(pkg, spec, files, pattern, names, timeoutMs, env, hooks) {
256
368
  return tag(runPython(pkg, files, k, timeoutMs, env, "py-pytest-reporter.py", plan.nodeIds), command, false, selection);
257
369
  }
258
370
  if (spec.kind === "cargo") {
259
- if (selection.length > 1) {
260
- return tag(spawnCollected("cargo", ["test", "--", "--test-threads=1"], pkg, env, timeoutMs), "cargo test -- --test-threads=1", true, selection);
371
+ if (selection.length === 0) {
372
+ return tag(spawnCollected("cargo", ["test", "--", "--test-threads=1"], pkg, env, timeoutMs), "cargo test -- --test-threads=1", false, selection);
373
+ }
374
+ if (selection.length === 1) {
375
+ const args = ["test", selection[0], "--", "--exact", "--test-threads=1"];
376
+ return tag(spawnCollected("cargo", args, pkg, env, timeoutMs), shown("cargo", args), false, selection);
261
377
  }
262
- const filter = selection.length === 1 ? selection[0] : null;
263
- const args = filter ? ["test", filter, "--", "--test-threads=1"] : ["test", "--", "--test-threads=1"];
264
- return tag(spawnCollected("cargo", args, pkg, env, timeoutMs), shown("cargo", args), false, selection);
378
+ const script = selection.map((name) => `cargo test ${shellQuote(name)} -- --exact --test-threads=1`).join(" && ");
379
+ return tag(spawnCollected("sh", ["-c", script], pkg, env, timeoutMs), script, false, selection);
265
380
  }
266
381
  if (spec.kind === "go") {
267
382
  const filter = goRunFilter(names, pattern);
@@ -320,6 +435,10 @@ function pytestLeaf(name) {
320
435
  function pytestPlan(selection) {
321
436
  if (selection.length === 0)
322
437
  return { pattern: null, nodeIds: null };
438
+ // A doubled keep id (file.py::tests/file.py::test_low) has more than one ::.
439
+ // Reducing it to the leaf would run -k test_low and still kill the mutant.
440
+ if (selection.some((name) => name.split("::").length > 2))
441
+ return { pattern: null, nodeIds: selection };
323
442
  const leaves = selection.map(pytestLeaf);
324
443
  if (leaves.every((leaf) => PYTEST_KEYWORD.test(leaf)))
325
444
  return { pattern: leaves.join(" or "), nodeIds: null };
@@ -349,9 +468,9 @@ function goRunFilter(names, pattern) {
349
468
  function surefireTest(name) {
350
469
  const leaf = name.split("::").pop() ?? name;
351
470
  const dot = leaf.lastIndexOf(".");
352
- if (dot <= 0)
353
- return leaf;
354
- return `${leaf.slice(0, dot)}#${leaf.slice(dot + 1)}`;
471
+ const body = dot <= 0 ? leaf : `${leaf.slice(0, dot)}#${leaf.slice(dot + 1)}`;
472
+ // testShut[0] collects nothing. method(Parser)[1] keeps the parameter types.
473
+ return body.replace(/\[[^\]]*\]$/, "");
355
474
  }
356
475
  function escapeRegExp(value) {
357
476
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -436,20 +555,15 @@ async function runMocha(pkg, files, timeoutMs, env) {
436
555
  if (prepared && (prepared.code !== 0 || prepared.timedOut))
437
556
  return prepared;
438
557
  const config = readMochaConfig(pkg);
439
- const args = [mocha, "--reporter", "json", "--timeout", "20000"];
440
- if (config) {
558
+ const args = [mocha, ...buildMochaArgs(pkg, files)];
559
+ if (config && config.require.includes("esm")) {
441
560
  // The `esm` loader throws on current Node before any test runs. Drop that one require and keep the rest of the project's mocha config.
442
561
  const requires = config.require.filter((name) => name !== "esm");
443
- if (requires.length !== config.require.length) {
444
- const dir = path.join(pkg, ".probatio-suite");
445
- mkdirSync(dir, { recursive: true });
446
- const override = path.join(dir, "mocha-package.json");
447
- writeFileSync(override, `${JSON.stringify({ mocha: { extension: config.extension, require: requires, spec: config.spec } }, null, 2)}\n`);
448
- args.push("--package", override);
449
- }
450
- }
451
- else {
452
- args.push(...files);
562
+ const dir = path.join(pkg, ".probatio-suite");
563
+ mkdirSync(dir, { recursive: true });
564
+ const override = path.join(dir, "mocha-package.json");
565
+ writeFileSync(override, `${JSON.stringify({ mocha: { extension: config.extension, require: requires, spec: config.spec } }, null, 2)}\n`);
566
+ args.push("--package", override);
453
567
  }
454
568
  return spawnCollected(process.execPath, args, pkg, env, timeoutMs);
455
569
  }
@@ -1038,6 +1152,9 @@ function adapt(kind, pkg, stdout, stderr, code) {
1038
1152
  return parseMocha(stdout);
1039
1153
  if (kind === "make-test" || kind === "command") {
1040
1154
  const text = `${stdout}\n${stderr}`;
1155
+ const ctest = parseCtest(text);
1156
+ if (ctest)
1157
+ return ctest;
1041
1158
  if (/Tests run:\s*\d+/.test(text))
1042
1159
  return parseMakeTest(text);
1043
1160
  if (kind === "make-test")
@@ -1157,6 +1274,32 @@ function parseMocha(stdout) {
1157
1274
  return null;
1158
1275
  }
1159
1276
  }
1277
+ /** CTest prints `Test #N: name` and `N - name (Failed)`. Those names are the kill, not `::command`. */
1278
+ function parseCtest(text) {
1279
+ if (!/Test #\d+:|The following tests FAILED:/.test(text))
1280
+ return null;
1281
+ const names = [];
1282
+ const failed = [];
1283
+ for (const raw of text.split(/\r?\n/)) {
1284
+ const line = raw.trim();
1285
+ const ran = /^\d+\/\d+\s+Test\s+#\d+:\s+(\S+)/.exec(line);
1286
+ if (ran && !names.includes(ran[1]))
1287
+ names.push(ran[1]);
1288
+ if (ran && /\*\*\*Failed/.test(line) && !failed.some((item) => item.name === ran[1])) {
1289
+ failed.push({ name: ran[1], file: "", line: 0 });
1290
+ }
1291
+ const listed = /^\d+\s+-\s+(\S+)\s+\(Failed\)/.exec(line);
1292
+ if (listed && !failed.some((item) => item.name === listed[1]))
1293
+ failed.push({ name: listed[1], file: "", line: 0 });
1294
+ }
1295
+ if (names.length === 0 && failed.length === 0)
1296
+ return null;
1297
+ for (const item of failed) {
1298
+ if (!names.includes(item.name))
1299
+ names.push(item.name);
1300
+ }
1301
+ return { tests: names.length, pass: Math.max(names.length - failed.length, 0), fail: failed.length, failed, names };
1302
+ }
1160
1303
  function parseMakeTest(text) {
1161
1304
  const run = text.match(/Tests run:\s*(\d+)/);
1162
1305
  if (!run)
@@ -105,7 +105,7 @@ export function tallyRun(outDir) {
105
105
  command: "mutate.tally",
106
106
  summary,
107
107
  next,
108
- nextCall: null,
108
+ nextCall: keep.length > 0 ? { argv: ["mutate", "run", ...keep.flatMap((id) => ["--only-test", id])] } : null,
109
109
  keep,
110
110
  drop,
111
111
  gaps,
@@ -7,7 +7,7 @@ import { findInSource } from "../mutate/find.js";
7
7
  import { mutantId } from "../mutate/ids.js";
8
8
  import { forwardDiff, git } from "../mutate/patch.js";
9
9
  import { runMutants } from "../mutate/run.js";
10
- import { leadSummary } from "../mutate/suite-decision.js";
10
+ import { WHOLE_PROGRAM_TEST, leadSummary } from "../mutate/suite-decision.js";
11
11
  import { discoverSuite } from "../mutate/suites.js";
12
12
  /** Diff-scoped mutants, the tests that can see the edit, and golden contract rows that changed. */
13
13
  export async function verifyChange(options) {
@@ -102,15 +102,17 @@ export async function verifyChange(options) {
102
102
  other = results
103
103
  .filter((item) => item.outcome !== "killed" && item.outcome !== "survived")
104
104
  .map((item) => ({ id: item.id, outcome: item.outcome, file: byId.get(item.id)?.file ?? "", line: byId.get(item.id)?.line ?? 0 }));
105
+ const executed = executedNames(results);
106
+ const envelope = bodyOf(options.outDir, true, affected, ran, notRan, caught, missed, other, goldenContract, notRun, suiteCommand, executed);
107
+ writeFileSync(path.join(options.outDir, "verify.json"), `${JSON.stringify(envelope, null, 2)}\n`);
108
+ return envelope;
105
109
  }
106
- else {
107
- notRun.push(...written);
108
- }
109
- const envelope = bodyOf(options.outDir, true, affected, ran, notRan, caught, missed, other, goldenContract, notRun, suiteCommand);
110
+ notRun.push(...written);
111
+ const envelope = bodyOf(options.outDir, true, affected, ran, notRan, caught, missed, other, goldenContract, notRun, suiteCommand, []);
110
112
  writeFileSync(path.join(options.outDir, "verify.json"), `${JSON.stringify(envelope, null, 2)}\n`);
111
113
  return envelope;
112
114
  }
113
- function bodyOf(outDir, ok, affected, ran, notRan, caught, missed, other, goldenContract, notRun, suiteCommand) {
115
+ function bodyOf(outDir, ok, affected, ran, notRan, caught, missed, other, goldenContract, notRun, suiteCommand, executed) {
114
116
  const uncovered = other.filter((item) => item.outcome === "no coverage");
115
117
  const summary = ok
116
118
  ? leadSummary(uncovered.length, missed.length, "missed", `${caught.length} caught, ${notRun.length} not run, ${affected.length} affected tests, ${goldenContract.length} golden contract changes.`)
@@ -127,6 +129,8 @@ function bodyOf(outDir, ok, affected, ran, notRan, caught, missed, other, golden
127
129
  ran,
128
130
  notRan,
129
131
  suiteCommand,
132
+ executed,
133
+ confirmed: false,
130
134
  caught,
131
135
  missed,
132
136
  other,
@@ -158,6 +162,8 @@ function fail(outDir, summary, extra) {
158
162
  ran: extra?.ran ?? [],
159
163
  notRan: extra?.notRan ?? [],
160
164
  suiteCommand: extra?.suiteCommand ?? "",
165
+ executed: [],
166
+ confirmed: false,
161
167
  caught: [],
162
168
  missed: [],
163
169
  other: [],
@@ -173,14 +179,17 @@ function readOutcomes(outDir, ids) {
173
179
  for (const id of ids) {
174
180
  try {
175
181
  const result = JSON.parse(readFileSync(path.join(file, `${id}.json`), "utf8"));
176
- out.push({ id, outcome: result.outcome });
182
+ out.push({ id, outcome: result.outcome, selectedTests: result.selectedTests ?? [] });
177
183
  }
178
184
  catch {
179
- out.push({ id, outcome: "missing" });
185
+ out.push({ id, outcome: "missing", selectedTests: [] });
180
186
  }
181
187
  }
182
188
  return out;
183
189
  }
190
+ function executedNames(results) {
191
+ return [...new Set(results.flatMap((item) => item.selectedTests))].filter((name) => name.length > 0 && name !== WHOLE_PROGRAM_TEST).sort();
192
+ }
184
193
  function goldenDiffs(repo, base, commit, files) {
185
194
  const found = [];
186
195
  for (const file of files) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "probatio",
3
- "version": "0.0.4",
3
+ "version": "0.1.0",
4
4
  "description": "Mutation runner for AI agents. Scores a test suite by the bugs it catches.",
5
5
  "type": "module",
6
6
  "bin": {