probatio 0.0.5 → 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
@@ -133,6 +133,29 @@ async function generateCommand(flags) {
133
133
  };
134
134
  }
135
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
+ }
136
159
  const packageDir = path.resolve(requireText(flags, "package"));
137
160
  const patchDirs = texts(flags, "patches").map((dir) => path.resolve(dir));
138
161
  if (patchDirs.length === 0)
@@ -167,6 +190,7 @@ async function runCommand(flags, hide = null) {
167
190
  affected: bool(flags, "affected", false),
168
191
  agent: text(flags, "agent") ?? null,
169
192
  suiteCommand: text(flags, "suite-command") ?? null,
193
+ onlyNames: onlyNames.length > 0 ? onlyNames : null,
170
194
  historyPath: text(flags, "history") ? path.resolve(text(flags, "history")) : null,
171
195
  timeoutMultiple: int(flags, "timeout-multiple") ?? 5,
172
196
  timeoutFloorMs: int(flags, "timeout-floor-ms") ?? 20_000,
@@ -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
 
@@ -263,7 +263,8 @@ async function runOne(worktree, pkg, patchPath, options, commit, runId, prep) {
263
263
  return finishMutant(killed(base, direction, ["build"]), pkg, "", false, []);
264
264
  }
265
265
  const picked = selectionFor(prep.coverage, edited.length > 0 ? edited : files);
266
- if (picked.state === "uncovered") {
266
+ const forced = options.onlyNames && options.onlyNames.length > 0 ? options.onlyNames : null;
267
+ if (!forced && picked.state === "uncovered") {
267
268
  return {
268
269
  ...base(),
269
270
  direction,
@@ -271,7 +272,7 @@ async function runOne(worktree, pkg, patchPath, options, commit, runId, prep) {
271
272
  next: noCoverageNext(picked.file, picked.line),
272
273
  };
273
274
  }
274
- 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);
275
276
  const forceWhole = picked.state === "covered" && (names === null || names.length === 0);
276
277
  // node --test loads every file it is given, so a name pattern still pays for the other files.
277
278
  const selected = narrowNodeFiles(pkg, selectTests(files, prep.tests, prep.affectedMap), names, prep.spec.kind);
@@ -927,15 +928,28 @@ function findTestFile(pkg, rel) {
927
928
  function sha256(text) {
928
929
  return createHash("sha256").update(text).digest("hex");
929
930
  }
931
+ const BARE_SUITE = new Set(["command", "suite", "pytest", "unittest", "cobol", "make-test", "c", "cpp"]);
930
932
  function failureId(pkg, failure) {
931
933
  // Pytest already reports a node id (tests/test_gate.py::test_low). Prepending the
932
934
  // file doubles it, and pytest then exits 4 and collects nothing.
933
935
  if (pytestNodeId(failure.name))
934
936
  return failure.name;
935
- const rel = failure.file ? path.relative(pkg, failure.file) : "";
936
- const file = !rel || rel.startsWith("..") ? path.basename(failure.file || "") : rel.split(path.sep).join("/");
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("/");
937
946
  return `${file}::${failure.name}`;
938
947
  }
948
+ function testFileOnDisk(pkg, file) {
949
+ if (path.isAbsolute(file))
950
+ return existsSync(file);
951
+ return existsSync(path.join(pkg, file));
952
+ }
939
953
  function pytestNodeId(name) {
940
954
  const mark = name.indexOf("::");
941
955
  if (mark <= 0)
@@ -435,6 +435,10 @@ function pytestLeaf(name) {
435
435
  function pytestPlan(selection) {
436
436
  if (selection.length === 0)
437
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 };
438
442
  const leaves = selection.map(pytestLeaf);
439
443
  if (leaves.every((leaf) => PYTEST_KEYWORD.test(leaf)))
440
444
  return { pattern: leaves.join(" or "), nodeIds: null };
@@ -464,9 +468,9 @@ function goRunFilter(names, pattern) {
464
468
  function surefireTest(name) {
465
469
  const leaf = name.split("::").pop() ?? name;
466
470
  const dot = leaf.lastIndexOf(".");
467
- if (dot <= 0)
468
- return leaf;
469
- 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(/\[[^\]]*\]$/, "");
470
474
  }
471
475
  function escapeRegExp(value) {
472
476
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -1148,6 +1152,9 @@ function adapt(kind, pkg, stdout, stderr, code) {
1148
1152
  return parseMocha(stdout);
1149
1153
  if (kind === "make-test" || kind === "command") {
1150
1154
  const text = `${stdout}\n${stderr}`;
1155
+ const ctest = parseCtest(text);
1156
+ if (ctest)
1157
+ return ctest;
1151
1158
  if (/Tests run:\s*\d+/.test(text))
1152
1159
  return parseMakeTest(text);
1153
1160
  if (kind === "make-test")
@@ -1267,6 +1274,32 @@ function parseMocha(stdout) {
1267
1274
  return null;
1268
1275
  }
1269
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
+ }
1270
1303
  function parseMakeTest(text) {
1271
1304
  const run = text.match(/Tests run:\s*(\d+)/);
1272
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.5",
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": {