probatio 0.0.1 → 0.0.3
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 +13 -12
- package/dist/mcp.js +39 -42
- package/dist/mutate/go-coverage.js +100 -0
- package/dist/mutate/java-coverage.js +217 -0
- package/dist/mutate/probatio-jacoco-run.java +507 -0
- package/dist/mutate/run.js +33 -29
- package/dist/mutate/suite-decision.js +8 -1
- package/dist/mutate/suites.js +78 -6
- package/dist/mutate/tally.js +18 -6
- package/dist/swarm/check-kill.js +30 -1
- package/dist/verify/change.js +2 -1
- package/package.json +2 -2
package/dist/mutate/suites.js
CHANGED
|
@@ -274,6 +274,9 @@ async function launch(pkg, spec, files, pattern, names, timeoutMs, env, hooks) {
|
|
|
274
274
|
const tests = selection.map(surefireTest).filter((name) => name.length > 0);
|
|
275
275
|
const args = ["-B", "test", ...(tests.length > 0 ? [`-Dtest=${tests.join(",")}`] : [])];
|
|
276
276
|
const whole = wantsNames && tests.length === 0;
|
|
277
|
+
// Surefire leaves the previous run's XML in place. A confirm that did not
|
|
278
|
+
// re-execute a class would otherwise read that class as failed again.
|
|
279
|
+
rmSync(path.join(pkg, "target", "surefire-reports"), { recursive: true, force: true });
|
|
277
280
|
return tag(spawnCollected("mvn", args, pkg, javaEnv(env), timeoutMs), shown("mvn", args), whole, selection);
|
|
278
281
|
}
|
|
279
282
|
if (spec.kind === "dotnet") {
|
|
@@ -284,9 +287,11 @@ async function launch(pkg, spec, files, pattern, names, timeoutMs, env, hooks) {
|
|
|
284
287
|
return tag(runDotnetExe(pkg, spec, files, timeoutMs, env), spec.command, wantsNames, selection);
|
|
285
288
|
if (spec.kind === "mocha")
|
|
286
289
|
return tag(runMocha(pkg, files, timeoutMs, env), spec.command, wantsNames, selection);
|
|
287
|
-
//
|
|
288
|
-
if (spec.kind === "script")
|
|
289
|
-
|
|
290
|
+
// A tty is only for a suite that runs `docker run -it` (tini). Everything else is bash.
|
|
291
|
+
if (spec.kind === "script") {
|
|
292
|
+
const launched = scriptInvocation(pkg);
|
|
293
|
+
return tag(runScript(pkg, timeoutMs, env, launched), launched.command, wantsNames, selection);
|
|
294
|
+
}
|
|
290
295
|
if (spec.kind === "c" || spec.kind === "cpp")
|
|
291
296
|
return tag(compileAndRun(pkg, files, spec.kind === "cpp", timeoutMs, env), spec.command, wantsNames, selection);
|
|
292
297
|
if (spec.kind === "java")
|
|
@@ -549,7 +554,72 @@ function clearBytecode(pkg) {
|
|
|
549
554
|
}
|
|
550
555
|
}
|
|
551
556
|
}
|
|
552
|
-
|
|
557
|
+
let gnuScriptCache = null;
|
|
558
|
+
function suiteNeedsTty(pkg) {
|
|
559
|
+
const files = new Set();
|
|
560
|
+
const rootScript = path.join(pkg, "run_tests.sh");
|
|
561
|
+
if (existsSync(rootScript))
|
|
562
|
+
files.add(rootScript);
|
|
563
|
+
try {
|
|
564
|
+
for (const name of readdirSync(pkg)) {
|
|
565
|
+
if (name.endsWith(".sh"))
|
|
566
|
+
files.add(path.join(pkg, name));
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
catch {
|
|
570
|
+
// A missing directory is not a tty.
|
|
571
|
+
}
|
|
572
|
+
let entry = "";
|
|
573
|
+
try {
|
|
574
|
+
entry = readFileSync(rootScript, "utf8");
|
|
575
|
+
}
|
|
576
|
+
catch {
|
|
577
|
+
entry = "";
|
|
578
|
+
}
|
|
579
|
+
for (const match of entry.matchAll(/(?:^|[\s"'`/])([\w.-]+\.sh)\b/g)) {
|
|
580
|
+
const full = path.resolve(pkg, match[1]);
|
|
581
|
+
if (full.startsWith(pkg + path.sep) && existsSync(full))
|
|
582
|
+
files.add(full);
|
|
583
|
+
}
|
|
584
|
+
for (const file of files) {
|
|
585
|
+
let body = "";
|
|
586
|
+
try {
|
|
587
|
+
body = readFileSync(file, "utf8");
|
|
588
|
+
}
|
|
589
|
+
catch {
|
|
590
|
+
continue;
|
|
591
|
+
}
|
|
592
|
+
if (dockerWantsTty(body))
|
|
593
|
+
return true;
|
|
594
|
+
}
|
|
595
|
+
return false;
|
|
596
|
+
}
|
|
597
|
+
function dockerWantsTty(text) {
|
|
598
|
+
return text.split("\n").some((line) => {
|
|
599
|
+
if (!/\bdocker\s+run\b/.test(line))
|
|
600
|
+
return false;
|
|
601
|
+
return /(?:^|\s)--tty(?:\s|=|$)/.test(line) || /(?:^|\s)-[A-Za-z]*t[A-Za-z]*(?:\s|$)/.test(line);
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
function gnuScript() {
|
|
605
|
+
if (gnuScriptCache !== null)
|
|
606
|
+
return gnuScriptCache;
|
|
607
|
+
const probe = spawnSync("script", ["--version"], { encoding: "utf8", timeout: 2000 });
|
|
608
|
+
const text = `${probe.stdout ?? ""}\n${probe.stderr ?? ""}`;
|
|
609
|
+
gnuScriptCache = probe.status === 0 && /util-linux|\bGNU\b/.test(text);
|
|
610
|
+
return gnuScriptCache;
|
|
611
|
+
}
|
|
612
|
+
function scriptInvocation(pkg) {
|
|
613
|
+
if (!suiteNeedsTty(pkg))
|
|
614
|
+
return { bin: "bash", args: ["run_tests.sh"], command: "bash run_tests.sh" };
|
|
615
|
+
if (gnuScript()) {
|
|
616
|
+
const args = ["-q", "-c", "bash run_tests.sh", "/dev/null"];
|
|
617
|
+
return { bin: "script", args, command: shown("script", args) };
|
|
618
|
+
}
|
|
619
|
+
const args = ["-q", "/dev/null", "bash", "run_tests.sh"];
|
|
620
|
+
return { bin: "script", args, command: shown("script", args) };
|
|
621
|
+
}
|
|
622
|
+
function runScript(pkg, timeoutMs, env, launched) {
|
|
553
623
|
const next = { ...env };
|
|
554
624
|
const shim = writeDockerShim(pkg);
|
|
555
625
|
if (shim)
|
|
@@ -560,7 +630,7 @@ function runScript(pkg, timeoutMs, env) {
|
|
|
560
630
|
if (!next.ARCH_NATIVE && existsSync(inner) && existsSync(build) && readFileSync(build, "utf8").includes("ARCH_NATIVE")) {
|
|
561
631
|
next.ARCH_NATIVE = "1";
|
|
562
632
|
}
|
|
563
|
-
return spawnCollected(
|
|
633
|
+
return spawnCollected(launched.bin, launched.args, pkg, next, timeoutMs);
|
|
564
634
|
}
|
|
565
635
|
function writeDockerShim(pkg) {
|
|
566
636
|
const found = spawnSync("sh", ["-c", "command -v docker"], { encoding: "utf8" });
|
|
@@ -1132,7 +1202,9 @@ function parseDotnet(text, code) {
|
|
|
1132
1202
|
const names = [];
|
|
1133
1203
|
const failed = [];
|
|
1134
1204
|
for (const line of text.split("\n")) {
|
|
1135
|
-
|
|
1205
|
+
// A result line carries a duration: "Passed StaysClosed [4 ms]".
|
|
1206
|
+
// "Failed to load ..." is a diagnostic, not a test, and must not redden the baseline.
|
|
1207
|
+
const match = line.match(/^\s*(Passed|Failed)\s+(.+?)\s+\[(?:<\s*)?\d+(?:\.\d+)?\s*(?:ms|s)\]\s*$/);
|
|
1136
1208
|
if (!match)
|
|
1137
1209
|
continue;
|
|
1138
1210
|
names.push(match[2]);
|
package/dist/mutate/tally.js
CHANGED
|
@@ -3,11 +3,11 @@ import path from "node:path";
|
|
|
3
3
|
import { SCHEMA_VERSION } from "../contract.js";
|
|
4
4
|
import { advisePrune } from "../matrix/report.js";
|
|
5
5
|
import { isBuildName } from "./kill-label.js";
|
|
6
|
-
import { WHOLE_PROGRAM_TEST } from "./suite-decision.js";
|
|
6
|
+
import { leadSummary, TIMEOUT_NEXT, WHOLE_PROGRAM_TEST } from "./suite-decision.js";
|
|
7
7
|
const NOT_A_TEST = new Set(["suite", "pytest", "unittest", "command", WHOLE_PROGRAM_TEST]);
|
|
8
8
|
/**
|
|
9
9
|
* Read a finished mutate run. Name tests that killed a mutant, tests that killed
|
|
10
|
-
* nothing, and one gap per survivor
|
|
10
|
+
* nothing, and one gap per survivor. An unseen line is not a gap. This never deletes a file.
|
|
11
11
|
*/
|
|
12
12
|
export function tallyRun(outDir) {
|
|
13
13
|
const resultsDir = path.join(outDir, "results");
|
|
@@ -75,18 +75,30 @@ export function tallyRun(outDir) {
|
|
|
75
75
|
.map(([name]) => name)
|
|
76
76
|
.sort();
|
|
77
77
|
const drop = pruning.advice;
|
|
78
|
+
const noCoverage = results.filter((result) => result.outcome === "no coverage").length;
|
|
79
|
+
const survived = results.filter((result) => result.outcome === "survived").length;
|
|
80
|
+
const timeouts = results.filter((result) => result.outcome === "timeout").length;
|
|
78
81
|
const gaps = results
|
|
79
|
-
.filter((result) => result.outcome === "survived"
|
|
82
|
+
.filter((result) => result.outcome === "survived")
|
|
80
83
|
.map((result) => ({
|
|
81
84
|
id: result.id,
|
|
82
85
|
file: result.files?.[0]?.file ?? "",
|
|
83
86
|
line: result.files?.[0]?.line ?? 0,
|
|
84
|
-
outcome:
|
|
87
|
+
outcome: "survived",
|
|
85
88
|
}))
|
|
86
89
|
.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || a.id.localeCompare(b.id));
|
|
87
90
|
const shown = gaps.slice(0, 10);
|
|
88
|
-
const summary = `${keep.length} tests to keep, ${drop.length} tests killed nothing, ${gaps.length} gaps. No test file was deleted
|
|
89
|
-
const
|
|
91
|
+
const summary = leadSummary(noCoverage, survived, "survived", `${keep.length} tests to keep, ${drop.length} tests killed nothing, ${gaps.length} gaps. No test file was deleted.`);
|
|
92
|
+
const notes = [];
|
|
93
|
+
if (noCoverage > 0)
|
|
94
|
+
notes.push(`${noCoverage} no coverage. That line was not seen. It is not a pass and not a gap.`);
|
|
95
|
+
if (gaps.length > 0)
|
|
96
|
+
notes.push(`First gap is ${gaps[0].id} at ${gaps[0].file}:${gaps[0].line}. A survivor is not a pass.`);
|
|
97
|
+
if (timeouts > 0)
|
|
98
|
+
notes.push(TIMEOUT_NEXT);
|
|
99
|
+
if (notes.length === 0)
|
|
100
|
+
notes.push("No survivor in this run. No test file was deleted.");
|
|
101
|
+
const next = notes.join(" ");
|
|
90
102
|
const complete = {
|
|
91
103
|
schemaVersion: SCHEMA_VERSION,
|
|
92
104
|
ok: true,
|
package/dist/swarm/check-kill.js
CHANGED
|
@@ -5,6 +5,7 @@ import { directImporters } from "../mutate/affected.js";
|
|
|
5
5
|
import { filesInDiff } from "../mutate/patch.js";
|
|
6
6
|
import { killLabel } from "../mutate/kill-label.js";
|
|
7
7
|
import { runMutants } from "../mutate/run.js";
|
|
8
|
+
import { campaignSummary } from "../mutate/suite-decision.js";
|
|
8
9
|
/** The gap is done only when this mutant is killed and the baseline suite is green. */
|
|
9
10
|
export async function checkKill(id, options) {
|
|
10
11
|
const importers = importersOf(id, options);
|
|
@@ -28,6 +29,21 @@ export async function checkKill(id, options) {
|
|
|
28
29
|
const baseline = JSON.parse(readFileSync(baselineFile, "utf8"));
|
|
29
30
|
suiteGreen = baseline.ok === true;
|
|
30
31
|
}
|
|
32
|
+
// A red baseline stops inside the run. Do not read a result that names the broken test.
|
|
33
|
+
if (!report.ok) {
|
|
34
|
+
return {
|
|
35
|
+
schemaVersion: SCHEMA_VERSION,
|
|
36
|
+
ok: false,
|
|
37
|
+
command: "check-kill",
|
|
38
|
+
summary: `${id} is not done. ${report.summary}`,
|
|
39
|
+
next: "The task is not done.",
|
|
40
|
+
nextCall: null,
|
|
41
|
+
done: false,
|
|
42
|
+
outcome: null,
|
|
43
|
+
suiteGreen,
|
|
44
|
+
killedBy: [],
|
|
45
|
+
};
|
|
46
|
+
}
|
|
31
47
|
const resultFile = path.join(options.outDir, "results", `${id}.json`);
|
|
32
48
|
let outcome = null;
|
|
33
49
|
let killedBy = [];
|
|
@@ -38,11 +54,12 @@ export async function checkKill(id, options) {
|
|
|
38
54
|
}
|
|
39
55
|
const done = suiteGreen && outcome === "killed";
|
|
40
56
|
const label = done ? killLabel(killedBy) : null;
|
|
57
|
+
const head = checkHead(report);
|
|
41
58
|
return {
|
|
42
59
|
schemaVersion: SCHEMA_VERSION,
|
|
43
60
|
ok: done,
|
|
44
61
|
command: "check-kill",
|
|
45
|
-
summary: done ? `${id} is dead and the suite is green.` : `${id} is not done
|
|
62
|
+
summary: done ? `${head} ${id} is dead and the suite is green.` : `${head} ${id} is not done.`,
|
|
46
63
|
next: label?.next ?? (done ? "The gap-fixing task is done." : "The task is not done."),
|
|
47
64
|
nextCall: null,
|
|
48
65
|
done,
|
|
@@ -60,6 +77,18 @@ function importersOf(id, options) {
|
|
|
60
77
|
const rels = filesInDiff(readFileSync(patch, "utf8")).map((item) => packageRelative(options.repoDir, options.packageDir, item.file));
|
|
61
78
|
return directImporters(options.packageDir, rels, options.testsDir);
|
|
62
79
|
}
|
|
80
|
+
function checkHead(report) {
|
|
81
|
+
const finished = report.killed + report.survived + report.flaky + report.timeouts + report.errors + report.noCoverage;
|
|
82
|
+
return campaignSummary({
|
|
83
|
+
noCoverage: report.noCoverage,
|
|
84
|
+
survived: report.survived,
|
|
85
|
+
killed: report.killed,
|
|
86
|
+
flaky: report.flaky,
|
|
87
|
+
timeouts: report.timeouts,
|
|
88
|
+
errors: report.errors,
|
|
89
|
+
finished,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
63
92
|
function packageRelative(repoDir, packageDir, file) {
|
|
64
93
|
const norm = file.split(path.sep).join("/");
|
|
65
94
|
let repo = repoDir;
|
package/dist/verify/change.js
CHANGED
|
@@ -7,6 +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
11
|
import { discoverSuite } from "../mutate/suites.js";
|
|
11
12
|
/** Diff-scoped mutants, the tests that can see the edit, and golden contract rows that changed. */
|
|
12
13
|
export async function verifyChange(options) {
|
|
@@ -112,7 +113,7 @@ export async function verifyChange(options) {
|
|
|
112
113
|
function bodyOf(outDir, ok, affected, ran, notRan, caught, missed, other, goldenContract, notRun, suiteCommand) {
|
|
113
114
|
const uncovered = other.filter((item) => item.outcome === "no coverage");
|
|
114
115
|
const summary = ok
|
|
115
|
-
?
|
|
116
|
+
? leadSummary(uncovered.length, missed.length, "missed", `${caught.length} caught, ${notRun.length} not run, ${affected.length} affected tests, ${goldenContract.length} golden contract changes.`)
|
|
116
117
|
: "verify-change did not finish.";
|
|
117
118
|
const first = missed[0] ?? uncovered[0] ?? notRun[0] ?? caught[0];
|
|
118
119
|
return {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "probatio",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.3",
|
|
4
4
|
"description": "Mutation runner for AI agents. Scores a test suite by the bugs it catches.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
"url": "https://github.com/IronAdamant/Probatio/issues"
|
|
19
19
|
},
|
|
20
20
|
"scripts": {
|
|
21
|
-
"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 dist/mutate/",
|
|
21
|
+
"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
22
|
"test": "tsx --test tests/*.test.ts",
|
|
23
23
|
"probatio": "tsx src/cli.ts"
|
|
24
24
|
},
|