probatio 0.0.5 → 0.1.2
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/child-lines.mjs +34 -0
- package/dist/mutate/mocha-coverage.cjs +71 -0
- package/dist/mutate/node-batch-loader.mjs +85 -0
- package/dist/mutate/node-batch.mjs +130 -0
- package/dist/mutate/node-coverage.mjs +37 -180
- package/dist/mutate/precise-lines.mjs +188 -0
- package/dist/mutate/probatio-jacoco-run.java +10 -1
- package/dist/mutate/run.js +239 -34
- package/dist/mutate/suite-decision.js +1 -1
- package/dist/mutate/suites.js +75 -7
- package/dist/mutate/tally.js +1 -1
- package/dist/verify/change.js +83 -10
- package/package.json +2 -2
package/dist/mutate/suites.js
CHANGED
|
@@ -400,8 +400,11 @@ async function launch(pkg, spec, files, pattern, names, timeoutMs, env, hooks) {
|
|
|
400
400
|
}
|
|
401
401
|
if (spec.kind === "dotnet-exe")
|
|
402
402
|
return tag(runDotnetExe(pkg, spec, files, timeoutMs, env), spec.command, wantsNames, selection);
|
|
403
|
-
if (spec.kind === "mocha")
|
|
404
|
-
|
|
403
|
+
if (spec.kind === "mocha") {
|
|
404
|
+
const grep = selection.length > 0 ? selection.map(escapeRegExp).join("|") : "";
|
|
405
|
+
const command = grep ? `${spec.command} --grep ${JSON.stringify(grep)}` : spec.command;
|
|
406
|
+
return tag(runMocha(pkg, files, timeoutMs, env, selection), command, false, selection);
|
|
407
|
+
}
|
|
405
408
|
// A tty is only for a suite that runs `docker run -it` (tini). Everything else is bash.
|
|
406
409
|
if (spec.kind === "script") {
|
|
407
410
|
const launched = scriptInvocation(pkg);
|
|
@@ -435,6 +438,10 @@ function pytestLeaf(name) {
|
|
|
435
438
|
function pytestPlan(selection) {
|
|
436
439
|
if (selection.length === 0)
|
|
437
440
|
return { pattern: null, nodeIds: null };
|
|
441
|
+
// A doubled keep id (file.py::tests/file.py::test_low) has more than one ::.
|
|
442
|
+
// Reducing it to the leaf would run -k test_low and still kill the mutant.
|
|
443
|
+
if (selection.some((name) => name.split("::").length > 2))
|
|
444
|
+
return { pattern: null, nodeIds: selection };
|
|
438
445
|
const leaves = selection.map(pytestLeaf);
|
|
439
446
|
if (leaves.every((leaf) => PYTEST_KEYWORD.test(leaf)))
|
|
440
447
|
return { pattern: leaves.join(" or "), nodeIds: null };
|
|
@@ -464,9 +471,9 @@ function goRunFilter(names, pattern) {
|
|
|
464
471
|
function surefireTest(name) {
|
|
465
472
|
const leaf = name.split("::").pop() ?? name;
|
|
466
473
|
const dot = leaf.lastIndexOf(".");
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
return
|
|
474
|
+
const body = dot <= 0 ? leaf : `${leaf.slice(0, dot)}#${leaf.slice(dot + 1)}`;
|
|
475
|
+
// testShut[0] collects nothing. method(Parser)[1] keeps the parameter types.
|
|
476
|
+
return body.replace(/\[[^\]]*\]$/, "");
|
|
470
477
|
}
|
|
471
478
|
function escapeRegExp(value) {
|
|
472
479
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
@@ -477,18 +484,46 @@ function runShell(pkg, command, timeoutMs, env) {
|
|
|
477
484
|
const child = /\b(java|javac|mvn)\b/.test(command) ? javaEnv(env) : env;
|
|
478
485
|
return spawnCollected("sh", ["-c", command], pkg, { ...child, PYTHONDONTWRITEBYTECODE: "1" }, timeoutMs);
|
|
479
486
|
}
|
|
487
|
+
/**
|
|
488
|
+
* Node's `--test-timeout` caps the file, not only a test that left the option unset.
|
|
489
|
+
* A test that sets a longer `timeout` is cancelled, and the failure name is the file path.
|
|
490
|
+
* The flag stays at least that long, and it stays inside the suite cap.
|
|
491
|
+
*/
|
|
492
|
+
export function nodeTestTimeoutMs(pkg, files, testTimeoutMs, suiteTimeoutMs) {
|
|
493
|
+
let declared = 0;
|
|
494
|
+
for (const rel of files) {
|
|
495
|
+
let text = "";
|
|
496
|
+
try {
|
|
497
|
+
text = readFileSync(path.join(pkg, rel), "utf8");
|
|
498
|
+
}
|
|
499
|
+
catch {
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
502
|
+
for (const match of text.matchAll(/timeout\s*:\s*(\d[\d_]*)/g)) {
|
|
503
|
+
const value = Number(match[1].replaceAll("_", ""));
|
|
504
|
+
if (Number.isFinite(value) && value > declared)
|
|
505
|
+
declared = value;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
const floor = Number.isFinite(testTimeoutMs) && testTimeoutMs > 0 ? testTimeoutMs : 0;
|
|
509
|
+
let chosen = Math.max(floor, declared);
|
|
510
|
+
if (Number.isFinite(suiteTimeoutMs) && suiteTimeoutMs > 0 && chosen > suiteTimeoutMs)
|
|
511
|
+
chosen = suiteTimeoutMs;
|
|
512
|
+
return chosen > 0 ? chosen : floor;
|
|
513
|
+
}
|
|
480
514
|
async function runNode(pkg, files, pattern, timeoutMs, env, hooks) {
|
|
481
515
|
const tsx = path.join(pkg, "node_modules", ".bin", "tsx");
|
|
482
516
|
const bin = existsSync(tsx) ? tsx : process.execPath;
|
|
483
517
|
const prefix = existsSync(tsx) ? [] : ["--experimental-strip-types"];
|
|
484
518
|
const hook = env.PROBATIO_COVERAGE_MAP ? fileURLToPath(new URL("./node-coverage.mjs", import.meta.url)) : null;
|
|
519
|
+
const fileTimeout = nodeTestTimeoutMs(pkg, files, hooks.testTimeoutMs, timeoutMs);
|
|
485
520
|
const result = await spawnCollected(bin, [
|
|
486
521
|
...prefix,
|
|
487
522
|
...(hook ? ["--import", hook] : []),
|
|
488
523
|
"--test",
|
|
489
524
|
`--test-reporter=${hooks.reporterPath}`,
|
|
490
525
|
`--test-concurrency=${hooks.concurrency}`,
|
|
491
|
-
`--test-timeout=${
|
|
526
|
+
`--test-timeout=${fileTimeout}`,
|
|
492
527
|
...(pattern ? [`--test-name-pattern=${pattern}`] : []),
|
|
493
528
|
...files,
|
|
494
529
|
], pkg, env, timeoutMs);
|
|
@@ -541,7 +576,7 @@ function runPython(pkg, files, pattern, timeoutMs, env, script, nodeIds) {
|
|
|
541
576
|
const targets = nodeIds && nodeIds.length > 0 ? nodeIds : files;
|
|
542
577
|
return spawnCollected("python3", [reporter, ...targets, ...(pattern ? ["--pattern", pattern] : [])], pkg, next, timeoutMs);
|
|
543
578
|
}
|
|
544
|
-
async function runMocha(pkg, files, timeoutMs, env) {
|
|
579
|
+
async function runMocha(pkg, files, timeoutMs, env, names = null) {
|
|
545
580
|
const mocha = ["mocha.js", "mocha"]
|
|
546
581
|
.map((name) => path.join(pkg, "node_modules", "mocha", "bin", name))
|
|
547
582
|
.find((file) => existsSync(file));
|
|
@@ -552,6 +587,10 @@ async function runMocha(pkg, files, timeoutMs, env) {
|
|
|
552
587
|
return prepared;
|
|
553
588
|
const config = readMochaConfig(pkg);
|
|
554
589
|
const args = [mocha, ...buildMochaArgs(pkg, files)];
|
|
590
|
+
if (names && names.length > 0)
|
|
591
|
+
args.push("--grep", names.map(escapeRegExp).join("|"));
|
|
592
|
+
if (env.PROBATIO_COVERAGE_MAP)
|
|
593
|
+
args.push("--require", fileURLToPath(new URL("./mocha-coverage.cjs", import.meta.url)));
|
|
555
594
|
if (config && config.require.includes("esm")) {
|
|
556
595
|
// 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.
|
|
557
596
|
const requires = config.require.filter((name) => name !== "esm");
|
|
@@ -1148,6 +1187,9 @@ function adapt(kind, pkg, stdout, stderr, code) {
|
|
|
1148
1187
|
return parseMocha(stdout);
|
|
1149
1188
|
if (kind === "make-test" || kind === "command") {
|
|
1150
1189
|
const text = `${stdout}\n${stderr}`;
|
|
1190
|
+
const ctest = parseCtest(text);
|
|
1191
|
+
if (ctest)
|
|
1192
|
+
return ctest;
|
|
1151
1193
|
if (/Tests run:\s*\d+/.test(text))
|
|
1152
1194
|
return parseMakeTest(text);
|
|
1153
1195
|
if (kind === "make-test")
|
|
@@ -1267,6 +1309,32 @@ function parseMocha(stdout) {
|
|
|
1267
1309
|
return null;
|
|
1268
1310
|
}
|
|
1269
1311
|
}
|
|
1312
|
+
/** CTest prints `Test #N: name` and `N - name (Failed)`. Those names are the kill, not `::command`. */
|
|
1313
|
+
function parseCtest(text) {
|
|
1314
|
+
if (!/Test #\d+:|The following tests FAILED:/.test(text))
|
|
1315
|
+
return null;
|
|
1316
|
+
const names = [];
|
|
1317
|
+
const failed = [];
|
|
1318
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
1319
|
+
const line = raw.trim();
|
|
1320
|
+
const ran = /^\d+\/\d+\s+Test\s+#\d+:\s+(\S+)/.exec(line);
|
|
1321
|
+
if (ran && !names.includes(ran[1]))
|
|
1322
|
+
names.push(ran[1]);
|
|
1323
|
+
if (ran && /\*\*\*Failed/.test(line) && !failed.some((item) => item.name === ran[1])) {
|
|
1324
|
+
failed.push({ name: ran[1], file: "", line: 0 });
|
|
1325
|
+
}
|
|
1326
|
+
const listed = /^\d+\s+-\s+(\S+)\s+\(Failed\)/.exec(line);
|
|
1327
|
+
if (listed && !failed.some((item) => item.name === listed[1]))
|
|
1328
|
+
failed.push({ name: listed[1], file: "", line: 0 });
|
|
1329
|
+
}
|
|
1330
|
+
if (names.length === 0 && failed.length === 0)
|
|
1331
|
+
return null;
|
|
1332
|
+
for (const item of failed) {
|
|
1333
|
+
if (!names.includes(item.name))
|
|
1334
|
+
names.push(item.name);
|
|
1335
|
+
}
|
|
1336
|
+
return { tests: names.length, pass: Math.max(names.length - failed.length, 0), fail: failed.length, failed, names };
|
|
1337
|
+
}
|
|
1270
1338
|
function parseMakeTest(text) {
|
|
1271
1339
|
const run = text.match(/Tests run:\s*(\d+)/);
|
|
1272
1340
|
if (!run)
|
package/dist/mutate/tally.js
CHANGED
package/dist/verify/change.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { SCHEMA_VERSION } from "../contract.js";
|
|
4
4
|
import { asTable } from "../golden/check.js";
|
|
@@ -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) {
|
|
@@ -26,9 +26,10 @@ export async function verifyChange(options) {
|
|
|
26
26
|
const goldenContract = goldenDiffs(repo, options.base, options.commit, changed);
|
|
27
27
|
const packageFiles = [];
|
|
28
28
|
const chosen = [];
|
|
29
|
+
const libImported = testsImportLib(packageDir);
|
|
29
30
|
for (const [file, lines] of ranges) {
|
|
30
31
|
const rel = toPackage(prefix, file);
|
|
31
|
-
if (!rel || !rel
|
|
32
|
+
if (!rel || !isVerifySource(rel, packageDir, libImported))
|
|
32
33
|
continue;
|
|
33
34
|
packageFiles.push(rel);
|
|
34
35
|
const text = showFile(repo, options.commit, file);
|
|
@@ -102,15 +103,17 @@ export async function verifyChange(options) {
|
|
|
102
103
|
other = results
|
|
103
104
|
.filter((item) => item.outcome !== "killed" && item.outcome !== "survived")
|
|
104
105
|
.map((item) => ({ id: item.id, outcome: item.outcome, file: byId.get(item.id)?.file ?? "", line: byId.get(item.id)?.line ?? 0 }));
|
|
106
|
+
const executed = executedNames(results);
|
|
107
|
+
const envelope = bodyOf(options.outDir, true, affected, ran, notRan, caught, missed, other, goldenContract, notRun, suiteCommand, executed);
|
|
108
|
+
writeFileSync(path.join(options.outDir, "verify.json"), `${JSON.stringify(envelope, null, 2)}\n`);
|
|
109
|
+
return envelope;
|
|
105
110
|
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
}
|
|
109
|
-
const envelope = bodyOf(options.outDir, true, affected, ran, notRan, caught, missed, other, goldenContract, notRun, suiteCommand);
|
|
111
|
+
notRun.push(...written);
|
|
112
|
+
const envelope = bodyOf(options.outDir, true, affected, ran, notRan, caught, missed, other, goldenContract, notRun, suiteCommand, []);
|
|
110
113
|
writeFileSync(path.join(options.outDir, "verify.json"), `${JSON.stringify(envelope, null, 2)}\n`);
|
|
111
114
|
return envelope;
|
|
112
115
|
}
|
|
113
|
-
function bodyOf(outDir, ok, affected, ran, notRan, caught, missed, other, goldenContract, notRun, suiteCommand) {
|
|
116
|
+
function bodyOf(outDir, ok, affected, ran, notRan, caught, missed, other, goldenContract, notRun, suiteCommand, executed) {
|
|
114
117
|
const uncovered = other.filter((item) => item.outcome === "no coverage");
|
|
115
118
|
const summary = ok
|
|
116
119
|
? leadSummary(uncovered.length, missed.length, "missed", `${caught.length} caught, ${notRun.length} not run, ${affected.length} affected tests, ${goldenContract.length} golden contract changes.`)
|
|
@@ -127,6 +130,8 @@ function bodyOf(outDir, ok, affected, ran, notRan, caught, missed, other, golden
|
|
|
127
130
|
ran,
|
|
128
131
|
notRan,
|
|
129
132
|
suiteCommand,
|
|
133
|
+
executed,
|
|
134
|
+
confirmed: false,
|
|
130
135
|
caught,
|
|
131
136
|
missed,
|
|
132
137
|
other,
|
|
@@ -158,6 +163,8 @@ function fail(outDir, summary, extra) {
|
|
|
158
163
|
ran: extra?.ran ?? [],
|
|
159
164
|
notRan: extra?.notRan ?? [],
|
|
160
165
|
suiteCommand: extra?.suiteCommand ?? "",
|
|
166
|
+
executed: [],
|
|
167
|
+
confirmed: false,
|
|
161
168
|
caught: [],
|
|
162
169
|
missed: [],
|
|
163
170
|
other: [],
|
|
@@ -173,14 +180,17 @@ function readOutcomes(outDir, ids) {
|
|
|
173
180
|
for (const id of ids) {
|
|
174
181
|
try {
|
|
175
182
|
const result = JSON.parse(readFileSync(path.join(file, `${id}.json`), "utf8"));
|
|
176
|
-
out.push({ id, outcome: result.outcome });
|
|
183
|
+
out.push({ id, outcome: result.outcome, selectedTests: result.selectedTests ?? [] });
|
|
177
184
|
}
|
|
178
185
|
catch {
|
|
179
|
-
out.push({ id, outcome: "missing" });
|
|
186
|
+
out.push({ id, outcome: "missing", selectedTests: [] });
|
|
180
187
|
}
|
|
181
188
|
}
|
|
182
189
|
return out;
|
|
183
190
|
}
|
|
191
|
+
function executedNames(results) {
|
|
192
|
+
return [...new Set(results.flatMap((item) => item.selectedTests))].filter((name) => name.length > 0 && name !== WHOLE_PROGRAM_TEST).sort();
|
|
193
|
+
}
|
|
184
194
|
function goldenDiffs(repo, base, commit, files) {
|
|
185
195
|
const found = [];
|
|
186
196
|
for (const file of files) {
|
|
@@ -260,3 +270,66 @@ function toPackage(prefix, file) {
|
|
|
260
270
|
return null;
|
|
261
271
|
return file.slice(prefix.length + 1);
|
|
262
272
|
}
|
|
273
|
+
const SKIPPED_SOURCE = new Set(["node_modules", "dist", "docs", "tests", "test"]);
|
|
274
|
+
/** src/, a lib/ tree the tests import, or a Go file beside go.mod. Tests, docs, dist, and node_modules stay out. */
|
|
275
|
+
function isVerifySource(rel, packageDir, libImported) {
|
|
276
|
+
if (rel.endsWith(".d.ts"))
|
|
277
|
+
return false;
|
|
278
|
+
const parts = rel.split("/");
|
|
279
|
+
if (parts.some((part) => SKIPPED_SOURCE.has(part)))
|
|
280
|
+
return false;
|
|
281
|
+
if (rel.startsWith("src/"))
|
|
282
|
+
return true;
|
|
283
|
+
if (libImported && rel.startsWith("lib/"))
|
|
284
|
+
return true;
|
|
285
|
+
if (!rel.includes("/") && rel.endsWith(".go") && !rel.endsWith("_test.go") && existsSync(path.join(packageDir, "go.mod")))
|
|
286
|
+
return true;
|
|
287
|
+
return false;
|
|
288
|
+
}
|
|
289
|
+
function testsImportLib(packageDir) {
|
|
290
|
+
const pattern = /(?:require\s*\(\s*|from\s+)['"](?:@[^'"]+\/)?(?:\.\.\/|\.\/)*lib\//;
|
|
291
|
+
for (const dirName of ["tests", "test"]) {
|
|
292
|
+
const dir = path.join(packageDir, dirName);
|
|
293
|
+
if (!existsSync(dir))
|
|
294
|
+
continue;
|
|
295
|
+
if (walkText(dir).some((text) => pattern.test(text)))
|
|
296
|
+
return true;
|
|
297
|
+
}
|
|
298
|
+
return false;
|
|
299
|
+
}
|
|
300
|
+
function walkText(dir) {
|
|
301
|
+
const out = [];
|
|
302
|
+
const visit = (current) => {
|
|
303
|
+
let entries;
|
|
304
|
+
try {
|
|
305
|
+
entries = readdirSync(current);
|
|
306
|
+
}
|
|
307
|
+
catch {
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
for (const name of entries) {
|
|
311
|
+
if (name === "node_modules" || name === "dist")
|
|
312
|
+
continue;
|
|
313
|
+
const full = path.join(current, name);
|
|
314
|
+
let info;
|
|
315
|
+
try {
|
|
316
|
+
info = statSync(full);
|
|
317
|
+
}
|
|
318
|
+
catch {
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
if (info.isDirectory())
|
|
322
|
+
visit(full);
|
|
323
|
+
else if (info.isFile() && info.size < 1_000_000) {
|
|
324
|
+
try {
|
|
325
|
+
out.push(readFileSync(full, "utf8"));
|
|
326
|
+
}
|
|
327
|
+
catch {
|
|
328
|
+
// An unreadable test file does not count as an import of lib/.
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
visit(dir);
|
|
334
|
+
return out;
|
|
335
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "probatio",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Mutation runner for AI agents. Scores a test suite by the bugs it catches.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
"url": "https://github.com/IronAdamant/Probatio/issues"
|
|
20
20
|
},
|
|
21
21
|
"scripts": {
|
|
22
|
-
"build": "tsc -p tsconfig.json && mkdir -p dist/mutate && cp src/mutate/fail-reporter.mjs src/mutate/node-coverage.mjs src/mutate/py-reporter.py src/mutate/py-pytest-reporter.py src/mutate/probatio-jacoco-run.java dist/mutate/",
|
|
22
|
+
"build": "tsc -p tsconfig.json && mkdir -p dist/mutate && cp src/mutate/fail-reporter.mjs src/mutate/node-coverage.mjs src/mutate/precise-lines.mjs src/mutate/child-lines.mjs src/mutate/mocha-coverage.cjs src/mutate/node-batch.mjs src/mutate/node-batch-loader.mjs src/mutate/py-reporter.py src/mutate/py-pytest-reporter.py src/mutate/probatio-jacoco-run.java dist/mutate/",
|
|
23
23
|
"test": "tsx --test tests/*.test.ts",
|
|
24
24
|
"probatio": "tsx src/cli.ts"
|
|
25
25
|
},
|