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 +5 -5
- package/dist/cli.js +24 -0
- package/dist/mutate/probatio-jacoco-run.java +10 -1
- package/dist/mutate/run.js +18 -4
- package/dist/mutate/suites.js +36 -3
- package/dist/mutate/tally.js +1 -1
- package/dist/verify/change.js +17 -8
- package/package.json +1 -1
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
|
-
|
|
6
|
-
|
|
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-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
package/dist/mutate/run.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
936
|
-
|
|
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)
|
package/dist/mutate/suites.js
CHANGED
|
@@ -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
|
-
|
|
468
|
-
|
|
469
|
-
return
|
|
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)
|
package/dist/mutate/tally.js
CHANGED
package/dist/verify/change.js
CHANGED
|
@@ -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
|
-
|
|
107
|
-
|
|
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) {
|