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/README.md
CHANGED
|
@@ -5,7 +5,7 @@ Probatio (Latin: a testing, a proof) is a free, open-source testing toolkit buil
|
|
|
5
5
|
it remembers what was fixed so agents with small context do not redo or undo work; and it finds
|
|
6
6
|
the bugs a test suite would miss instead of asking anyone to read more tests.
|
|
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 returns the same JSON as the CLI.
|
|
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.
|
|
9
9
|
|
|
10
10
|
Read in this order:
|
|
11
11
|
|
|
@@ -23,19 +23,19 @@ One JSON object on stdout. `schemaVersion`, `ok`, `summary`, `next`, `nextCall`.
|
|
|
23
23
|
|
|
24
24
|
```bash
|
|
25
25
|
npx probatio mutate generate --package . --out .probatio/generate
|
|
26
|
-
npx probatio mutate run --package . --patches .probatio/generate/mutants --out .probatio/runs
|
|
26
|
+
npx probatio mutate run --package . --patches .probatio/generate/mutants --out .probatio/runs
|
|
27
27
|
npx probatio mutate tally --out .probatio/runs
|
|
28
28
|
```
|
|
29
29
|
|
|
30
30
|
`generate` writes operator mutants (conditions, `&&`/`||`, `===`/`!==`, boundaries, booleans, a dropped `!`). The same class is written for COBOL (`.cob`, `.cbl`), Rust, C, C++, Java, Go, Python, JavaScript, and C#. Strings and comments are not mutated. A COBOL `*>` comment and a fixed-format line whose column 7 is `*` or `/` are comments. Each patch is forward: apply it to introduce the bug, and the file says so. When mutants were written, `next` states the count, the suite command if one was discovered, and whether the first run collects a line map.
|
|
31
31
|
|
|
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.
|
|
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. A later mutant runs
|
|
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.
|
|
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
|
|
|
38
|
-
`tally` reads that run directory. It names tests that killed a mutant
|
|
38
|
+
`tally` reads that run directory. The summary leads with the no-coverage count, then the survivors. It names tests that killed a mutant and tests that killed nothing. A gap is a survivor. An unseen line is not a gap and not a pass. A timeout is neither a kill nor a gap. `deletedTests` is 0. It does not delete a file.
|
|
39
39
|
|
|
40
40
|
## ledger
|
|
41
41
|
|
package/dist/cli.js
CHANGED
|
@@ -143,12 +143,7 @@ async function runCommand(flags) {
|
|
|
143
143
|
if (direction !== "auto" && direction !== "forward" && direction !== "reverse") {
|
|
144
144
|
throw new Error("--direction must be auto, forward, or reverse");
|
|
145
145
|
}
|
|
146
|
-
const
|
|
147
|
-
const build = !buildValues || buildValues.length === 0
|
|
148
|
-
? ["npm", "run", "build:mcp", "--silent"]
|
|
149
|
-
: buildValues[buildValues.length - 1] === false
|
|
150
|
-
? null
|
|
151
|
-
: String(buildValues[buildValues.length - 1]).split(" ").filter(Boolean);
|
|
146
|
+
const build = buildCommand(flags);
|
|
152
147
|
const report = await runMutants({
|
|
153
148
|
packageDir,
|
|
154
149
|
repoDir,
|
|
@@ -444,12 +439,7 @@ async function checkKillCommand(flags, id) {
|
|
|
444
439
|
const patchDirs = texts(flags, "patches").map((dir) => path.resolve(dir));
|
|
445
440
|
if (patchDirs.length === 0)
|
|
446
441
|
throw new Error("--patches is required");
|
|
447
|
-
const
|
|
448
|
-
const build = !buildValues || buildValues.length === 0
|
|
449
|
-
? null
|
|
450
|
-
: buildValues[buildValues.length - 1] === false
|
|
451
|
-
? null
|
|
452
|
-
: String(buildValues[buildValues.length - 1]).split(" ").filter(Boolean);
|
|
442
|
+
const build = buildCommand(flags);
|
|
453
443
|
return checkKill(id, {
|
|
454
444
|
packageDir,
|
|
455
445
|
repoDir: path.resolve(text(flags, "repo") ?? gitRoot(packageDir)),
|
|
@@ -496,3 +486,14 @@ function finish(envelope, human) {
|
|
|
496
486
|
process.stdout.write(render(envelope, human));
|
|
497
487
|
process.exit(envelope.ok ? 0 : 1);
|
|
498
488
|
}
|
|
489
|
+
/** Omitted `--build` and `--no-build` both mean no build. `--build <cmd>` opts in. */
|
|
490
|
+
function buildCommand(flags) {
|
|
491
|
+
const values = flags.get("build");
|
|
492
|
+
if (!values || values.length === 0)
|
|
493
|
+
return null;
|
|
494
|
+
const last = values[values.length - 1];
|
|
495
|
+
if (typeof last !== "string")
|
|
496
|
+
return null;
|
|
497
|
+
const parts = last.split(" ").filter(Boolean);
|
|
498
|
+
return parts.length > 0 ? parts : null;
|
|
499
|
+
}
|
package/dist/mcp.js
CHANGED
|
@@ -1,35 +1,39 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { spawnSync } from "node:child_process";
|
|
3
|
-
import { existsSync } from "node:fs";
|
|
3
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
7
|
-
|
|
7
|
+
const version = readVersion();
|
|
8
|
+
const tool = {
|
|
9
|
+
name: "probatio",
|
|
10
|
+
description: "Run one probatio command. argv is the CLI words and flags.",
|
|
11
|
+
inputSchema: {
|
|
12
|
+
type: "object",
|
|
13
|
+
properties: { argv: { type: "array", items: { type: "string" } } },
|
|
14
|
+
required: ["argv"],
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
let buffer = "";
|
|
18
|
+
process.stdin.setEncoding("utf8");
|
|
8
19
|
process.stdin.on("data", (chunk) => {
|
|
9
|
-
buffer
|
|
20
|
+
buffer += chunk;
|
|
10
21
|
drain();
|
|
11
22
|
});
|
|
12
|
-
process.stdin.on("end", () =>
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
const
|
|
21
|
-
if (!
|
|
22
|
-
buffer = buffer.subarray(start);
|
|
23
|
+
process.stdin.on("end", () => {
|
|
24
|
+
drain(true);
|
|
25
|
+
process.exit(0);
|
|
26
|
+
});
|
|
27
|
+
function drain(flush = false) {
|
|
28
|
+
const lines = buffer.split("\n");
|
|
29
|
+
buffer = flush ? "" : lines.pop() ?? "";
|
|
30
|
+
for (const line of lines) {
|
|
31
|
+
const text = line.replace(/\r$/, "").trim();
|
|
32
|
+
if (!text)
|
|
23
33
|
continue;
|
|
24
|
-
}
|
|
25
|
-
const length = Number(match[1]);
|
|
26
|
-
if (buffer.length < start + length)
|
|
27
|
-
return;
|
|
28
|
-
const body = buffer.subarray(start, start + length).toString("utf8");
|
|
29
|
-
buffer = buffer.subarray(start + length);
|
|
30
34
|
let message;
|
|
31
35
|
try {
|
|
32
|
-
message = JSON.parse(
|
|
36
|
+
message = JSON.parse(text);
|
|
33
37
|
}
|
|
34
38
|
catch {
|
|
35
39
|
continue;
|
|
@@ -49,29 +53,14 @@ function handle(message) {
|
|
|
49
53
|
result: {
|
|
50
54
|
protocolVersion: "2024-11-05",
|
|
51
55
|
capabilities: { tools: {} },
|
|
52
|
-
serverInfo: { name: "probatio", version
|
|
56
|
+
serverInfo: { name: "probatio", version },
|
|
57
|
+
tools: [tool],
|
|
53
58
|
},
|
|
54
59
|
});
|
|
55
60
|
return;
|
|
56
61
|
}
|
|
57
62
|
if (message.method === "tools/list") {
|
|
58
|
-
send({
|
|
59
|
-
jsonrpc: "2.0",
|
|
60
|
-
id: message.id,
|
|
61
|
-
result: {
|
|
62
|
-
tools: [
|
|
63
|
-
{
|
|
64
|
-
name: "probatio",
|
|
65
|
-
description: "Run one probatio command. argv is the CLI words and flags.",
|
|
66
|
-
inputSchema: {
|
|
67
|
-
type: "object",
|
|
68
|
-
properties: { argv: { type: "array", items: { type: "string" } } },
|
|
69
|
-
required: ["argv"],
|
|
70
|
-
},
|
|
71
|
-
},
|
|
72
|
-
],
|
|
73
|
-
},
|
|
74
|
-
});
|
|
63
|
+
send({ jsonrpc: "2.0", id: message.id, result: { tools: [tool] } });
|
|
75
64
|
return;
|
|
76
65
|
}
|
|
77
66
|
if (message.method === "tools/call") {
|
|
@@ -112,8 +101,16 @@ function cliCommand() {
|
|
|
112
101
|
const tsx = path.resolve(here, "..", "node_modules", ".bin", "tsx");
|
|
113
102
|
return { bin: tsx, args: [path.join(here, "cli.ts")] };
|
|
114
103
|
}
|
|
104
|
+
function readVersion() {
|
|
105
|
+
try {
|
|
106
|
+
const raw = JSON.parse(readFileSync(path.resolve(here, "..", "package.json"), "utf8"));
|
|
107
|
+
return typeof raw.version === "string" && raw.version.length > 0 ? raw.version : "0.0.1";
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return "0.0.1";
|
|
111
|
+
}
|
|
112
|
+
}
|
|
115
113
|
function send(message) {
|
|
116
|
-
const
|
|
117
|
-
process.stdout.write(
|
|
118
|
-
process.stdout.write(payload);
|
|
114
|
+
const line = JSON.stringify(message);
|
|
115
|
+
process.stdout.write(`${line}\n`);
|
|
119
116
|
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
/** Blocks in a Go cover profile whose count is above zero. */
|
|
5
|
+
export function parseCoverProfile(text, modulePath) {
|
|
6
|
+
const hits = [];
|
|
7
|
+
const prefix = modulePath ? `${modulePath}/` : "";
|
|
8
|
+
for (const raw of text.split("\n")) {
|
|
9
|
+
const line = raw.trim();
|
|
10
|
+
if (!line || line.startsWith("mode:"))
|
|
11
|
+
continue;
|
|
12
|
+
const match = /^(.+):(\d+)\.\d+,(\d+)\.\d+\s+\d+\s+(\d+)$/.exec(line);
|
|
13
|
+
if (!match)
|
|
14
|
+
continue;
|
|
15
|
+
if (Number(match[4]) <= 0)
|
|
16
|
+
continue;
|
|
17
|
+
let file = match[1];
|
|
18
|
+
if (prefix && file.startsWith(prefix))
|
|
19
|
+
file = file.slice(prefix.length);
|
|
20
|
+
file = file.split("\\").join("/");
|
|
21
|
+
const start = Number(match[2]);
|
|
22
|
+
const end = Number(match[3]);
|
|
23
|
+
for (let number = start; number <= end; number++)
|
|
24
|
+
hits.push({ file, line: number });
|
|
25
|
+
}
|
|
26
|
+
return hits;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* One compiled test binary per package, then one profile per test name.
|
|
30
|
+
* A stock cover profile does not name the test, so each test gets its own.
|
|
31
|
+
*/
|
|
32
|
+
export function writeGoCoverage(pkg, testNames, dest) {
|
|
33
|
+
const wanted = new Set(testNames.filter((name) => name.length > 0 && !name.includes("/")));
|
|
34
|
+
if (wanted.size === 0)
|
|
35
|
+
return { ok: false, detail: "go coverage: no test names" };
|
|
36
|
+
const mod = run("go", ["list", "-m"], pkg);
|
|
37
|
+
if (mod.status !== 0)
|
|
38
|
+
return { ok: false, detail: clip(mod.stderr || mod.stdout || mod.error?.message || "go list -m failed") };
|
|
39
|
+
const modulePath = mod.stdout.trim().split("\n")[0]?.trim() ?? "";
|
|
40
|
+
const listed = run("go", ["list", "-f", "{{.ImportPath}}\t{{.Dir}}\t{{len .TestGoFiles}}\t{{len .XTestGoFiles}}", "./..."], pkg);
|
|
41
|
+
if (listed.status !== 0)
|
|
42
|
+
return { ok: false, detail: clip(listed.stderr || listed.stdout || listed.error?.message || "go list failed") };
|
|
43
|
+
const work = path.join(path.dirname(dest), "go-cover");
|
|
44
|
+
mkdirSync(work, { recursive: true });
|
|
45
|
+
const files = {};
|
|
46
|
+
const packages = listed.stdout.split("\n").map((line) => line.trim()).filter(Boolean);
|
|
47
|
+
for (const row of packages) {
|
|
48
|
+
const [importPath, dir, testFiles, xTestFiles] = row.split("\t");
|
|
49
|
+
if (!importPath || !dir)
|
|
50
|
+
continue;
|
|
51
|
+
if (Number(testFiles ?? "0") + Number(xTestFiles ?? "0") <= 0)
|
|
52
|
+
continue;
|
|
53
|
+
const bin = path.join(work, `${safe(importPath)}.test`);
|
|
54
|
+
const compiled = run("go", ["test", "-c", "-covermode=set", "-o", bin, importPath], pkg);
|
|
55
|
+
if (compiled.status !== 0 || compiled.error)
|
|
56
|
+
continue;
|
|
57
|
+
const listedTests = run(bin, ["-test.list", "."], dir);
|
|
58
|
+
if (listedTests.status !== 0)
|
|
59
|
+
continue;
|
|
60
|
+
for (const name of listedTests.stdout.split("\n").map((item) => item.trim()).filter((item) => wanted.has(item))) {
|
|
61
|
+
const profile = path.join(work, `${safe(importPath)}-${safe(name)}.out`);
|
|
62
|
+
run(bin, ["-test.run", `^${escapeRegExp(name)}$`, "-test.coverprofile", profile], dir);
|
|
63
|
+
let text = "";
|
|
64
|
+
try {
|
|
65
|
+
text = readFileSync(profile, "utf8");
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
for (const hit of parseCoverProfile(text, modulePath))
|
|
71
|
+
addHit(files, hit.file, hit.line, name);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (Object.keys(files).length === 0)
|
|
75
|
+
return { ok: false, detail: "go coverage: no covered lines" };
|
|
76
|
+
writeFileSync(dest, `${JSON.stringify({ files }, null, 2)}\n`);
|
|
77
|
+
return { ok: true, detail: `${Object.keys(files).length} files` };
|
|
78
|
+
}
|
|
79
|
+
function addHit(files, file, line, test) {
|
|
80
|
+
const lines = files[file] ?? {};
|
|
81
|
+
const key = String(line);
|
|
82
|
+
const names = lines[key] ?? [];
|
|
83
|
+
if (!names.includes(test))
|
|
84
|
+
names.push(test);
|
|
85
|
+
lines[key] = names;
|
|
86
|
+
files[file] = lines;
|
|
87
|
+
}
|
|
88
|
+
function safe(value) {
|
|
89
|
+
return value.replace(/[^A-Za-z0-9._-]+/g, "_");
|
|
90
|
+
}
|
|
91
|
+
function escapeRegExp(value) {
|
|
92
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
93
|
+
}
|
|
94
|
+
function run(bin, args, cwd) {
|
|
95
|
+
return spawnSync(bin, args, { cwd, encoding: "utf8", timeout: 120_000 });
|
|
96
|
+
}
|
|
97
|
+
function clip(text) {
|
|
98
|
+
const line = text.trim().split("\n").find((item) => item.trim()) ?? "go coverage failed";
|
|
99
|
+
return line.slice(0, 240);
|
|
100
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
const JACOCO = "0.8.15";
|
|
6
|
+
const ASM = "9.10.1";
|
|
7
|
+
const RUNNER = fileURLToPath(new URL("./probatio-jacoco-run.java", import.meta.url));
|
|
8
|
+
/**
|
|
9
|
+
* One JVM, one JaCoCo dump per Surefire test method.
|
|
10
|
+
* The default jacoco.exec file does not name the test.
|
|
11
|
+
*/
|
|
12
|
+
export function writeJavaCoverage(pkg, testNames, dest) {
|
|
13
|
+
const tests = testNames.map(junitMethod).filter((name) => name !== null);
|
|
14
|
+
if (tests.length === 0)
|
|
15
|
+
return { ok: false, detail: "java coverage: no class.method test names" };
|
|
16
|
+
const classDirs = findClassDirs(pkg);
|
|
17
|
+
if (classDirs.length === 0)
|
|
18
|
+
return { ok: false, detail: "java coverage: no target/classes" };
|
|
19
|
+
const work = path.join(path.dirname(dest), "java-cover");
|
|
20
|
+
mkdirSync(work, { recursive: true });
|
|
21
|
+
const jars = resolveJars(pkg, work);
|
|
22
|
+
if ("error" in jars)
|
|
23
|
+
return { ok: false, detail: jars.error };
|
|
24
|
+
const source = path.join(work, "ProbatioJacocoRun.java");
|
|
25
|
+
writeFileSync(source, readFileSync(RUNNER));
|
|
26
|
+
const compileCp = [jars.agent, jars.junit, jars.core, jars.asm, jars.asmCommons, jars.asmTree].join(path.delimiter);
|
|
27
|
+
const compiled = run(javaTool("javac"), ["-encoding", "UTF-8", "-cp", compileCp, "-d", work, source], pkg);
|
|
28
|
+
if (compiled.status !== 0)
|
|
29
|
+
return { ok: false, detail: clip(compiled.stderr || compiled.stdout || compiled.error?.message || "javac failed") };
|
|
30
|
+
const testsFile = path.join(work, "tests.txt");
|
|
31
|
+
writeFileSync(testsFile, `${tests.join("\n")}\n`);
|
|
32
|
+
const projectCp = buildClasspath(pkg, work);
|
|
33
|
+
if ("error" in projectCp)
|
|
34
|
+
return { ok: false, detail: projectCp.error };
|
|
35
|
+
// Surefire brings the platform launcher. A project's test classpath often has the Jupiter engine and not the launcher.
|
|
36
|
+
const launcher = platformLauncherJar(pkg, work, projectCp.cp);
|
|
37
|
+
if ("error" in launcher)
|
|
38
|
+
return { ok: false, detail: launcher.error };
|
|
39
|
+
// Our asm and JaCoCo must win over a project that still ships an older asm. That older reader rejects Java 27 class files.
|
|
40
|
+
const cp = [work, jars.core, jars.asm, jars.asmCommons, jars.asmTree, jars.junit, launcher.jar, ...classDirs, ...testClassDirs(pkg), projectCp.cp].filter(Boolean).join(path.delimiter);
|
|
41
|
+
const dump = path.join(work, "hits.txt");
|
|
42
|
+
const ran = run(javaTool("java"), [`-javaagent:${jars.agent}=output=none,excludes=ProbatioJacocoRun:org.junit.*:org.apiguardian.*:org.opentest4j.*`, "-cp", cp, "ProbatioJacocoRun", classDirs.join(path.delimiter), testsFile, dump, pkg], pkg, 900_000);
|
|
43
|
+
if (ran.error && ran.error.code === "ETIMEDOUT") {
|
|
44
|
+
return { ok: false, detail: "java coverage: timed out" };
|
|
45
|
+
}
|
|
46
|
+
if (ran.status !== 0 || !existsSync(dump)) {
|
|
47
|
+
return { ok: false, detail: clip(ran.stderr || ran.stdout || ran.error?.message || "java coverage runner failed") };
|
|
48
|
+
}
|
|
49
|
+
const files = hitsToMap(readFileSync(dump, "utf8"));
|
|
50
|
+
if (Object.keys(files).length === 0)
|
|
51
|
+
return { ok: false, detail: "java coverage: no covered lines" };
|
|
52
|
+
writeFileSync(dest, `${JSON.stringify({ files }, null, 2)}\n`);
|
|
53
|
+
return { ok: true, detail: `${Object.keys(files).length} files` };
|
|
54
|
+
}
|
|
55
|
+
export function hitsToMap(text) {
|
|
56
|
+
const files = {};
|
|
57
|
+
for (const raw of text.split("\n")) {
|
|
58
|
+
const line = raw.trim();
|
|
59
|
+
if (!line.startsWith("HIT "))
|
|
60
|
+
continue;
|
|
61
|
+
const parts = line.slice(4).split(" ");
|
|
62
|
+
if (parts.length < 3)
|
|
63
|
+
continue;
|
|
64
|
+
const test = parts.pop() ?? "";
|
|
65
|
+
const number = parts.pop() ?? "";
|
|
66
|
+
const file = parts.join(" ");
|
|
67
|
+
if (!file || !/^\d+$/.test(number) || !test)
|
|
68
|
+
continue;
|
|
69
|
+
const lines = files[file] ?? {};
|
|
70
|
+
const names = lines[number] ?? [];
|
|
71
|
+
if (!names.includes(test))
|
|
72
|
+
names.push(test);
|
|
73
|
+
lines[number] = names;
|
|
74
|
+
files[file] = lines;
|
|
75
|
+
}
|
|
76
|
+
return files;
|
|
77
|
+
}
|
|
78
|
+
function junitMethod(name) {
|
|
79
|
+
const leaf = name.split("::").pop() ?? name;
|
|
80
|
+
const dot = leaf.lastIndexOf(".");
|
|
81
|
+
if (dot <= 0 || dot === leaf.length - 1)
|
|
82
|
+
return null;
|
|
83
|
+
if (leaf.startsWith("maven-") || leaf === "javac")
|
|
84
|
+
return null;
|
|
85
|
+
return `${leaf.slice(0, dot)}#${leaf.slice(dot + 1)}`;
|
|
86
|
+
}
|
|
87
|
+
function findClassDirs(root) {
|
|
88
|
+
const found = [];
|
|
89
|
+
const walk = (dir, depth) => {
|
|
90
|
+
if (depth > 4)
|
|
91
|
+
return;
|
|
92
|
+
let entries = [];
|
|
93
|
+
try {
|
|
94
|
+
entries = readdirSync(dir);
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
for (const name of entries) {
|
|
100
|
+
if (name === "node_modules" || name === ".git" || name === "java-cover")
|
|
101
|
+
continue;
|
|
102
|
+
const full = path.join(dir, name);
|
|
103
|
+
if (name === "target") {
|
|
104
|
+
const classes = path.join(full, "classes");
|
|
105
|
+
if (existsSync(classes))
|
|
106
|
+
found.push(classes);
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (name.startsWith("."))
|
|
110
|
+
continue;
|
|
111
|
+
walk(full, depth + 1);
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
walk(root, 0);
|
|
115
|
+
return found;
|
|
116
|
+
}
|
|
117
|
+
function testClassDirs(root) {
|
|
118
|
+
return findClassDirs(root).map((dir) => path.join(path.dirname(dir), "test-classes")).filter((dir) => existsSync(dir));
|
|
119
|
+
}
|
|
120
|
+
function resolveJars(pkg, work) {
|
|
121
|
+
const copies = [
|
|
122
|
+
[`org.jacoco:org.jacoco.agent:${JACOCO}:jar:runtime`, "agent.jar"],
|
|
123
|
+
[`org.jacoco:org.jacoco.core:${JACOCO}`, "core.jar"],
|
|
124
|
+
[`org.ow2.asm:asm:${ASM}`, "asm.jar"],
|
|
125
|
+
[`org.ow2.asm:asm-commons:${ASM}`, "asm-commons.jar"],
|
|
126
|
+
[`org.ow2.asm:asm-tree:${ASM}`, "asm-tree.jar"],
|
|
127
|
+
["junit:junit:4.13.2", "junit.jar"],
|
|
128
|
+
];
|
|
129
|
+
const paths = {};
|
|
130
|
+
for (const [artifact, name] of copies) {
|
|
131
|
+
const dest = path.join(work, name);
|
|
132
|
+
if (!existsSync(dest)) {
|
|
133
|
+
const before = new Set(readdirSync(work));
|
|
134
|
+
const copied = run("mvn", ["-B", "-q", "org.apache.maven.plugins:maven-dependency-plugin:3.6.1:copy", `-Dartifact=${artifact}`, `-DoutputDirectory=${work}`], pkg);
|
|
135
|
+
if (copied.status !== 0)
|
|
136
|
+
return { error: clip(copied.stderr || copied.stdout || copied.error?.message || `mvn copy ${artifact} failed`) };
|
|
137
|
+
const created = readdirSync(work).find((item) => !before.has(item) && item.endsWith(".jar"));
|
|
138
|
+
if (!created)
|
|
139
|
+
return { error: `java coverage: missing ${artifact}` };
|
|
140
|
+
writeFileSync(dest, readFileSync(path.join(work, created)));
|
|
141
|
+
}
|
|
142
|
+
paths[name] = dest;
|
|
143
|
+
}
|
|
144
|
+
return {
|
|
145
|
+
agent: paths["agent.jar"],
|
|
146
|
+
core: paths["core.jar"],
|
|
147
|
+
asm: paths["asm.jar"],
|
|
148
|
+
asmCommons: paths["asm-commons.jar"],
|
|
149
|
+
asmTree: paths["asm-tree.jar"],
|
|
150
|
+
junit: paths["junit.jar"],
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
/** Launcher jar matching the project's junit-platform-engine, or none when this suite is not Jupiter. */
|
|
154
|
+
function platformLauncherJar(pkg, work, projectCp) {
|
|
155
|
+
const entries = projectCp.split(path.delimiter).filter(Boolean);
|
|
156
|
+
if (entries.some((item) => path.basename(item).startsWith("junit-platform-launcher")))
|
|
157
|
+
return { jar: "" };
|
|
158
|
+
const engine = entries.find((item) => path.basename(item).startsWith("junit-platform-engine-"));
|
|
159
|
+
if (!engine)
|
|
160
|
+
return { jar: "" };
|
|
161
|
+
const version = path.basename(engine).replace(/^junit-platform-engine-/, "").replace(/\.jar$/, "");
|
|
162
|
+
if (!version)
|
|
163
|
+
return { error: "java coverage: junit-platform-engine version missing" };
|
|
164
|
+
const dest = path.join(work, "launcher.jar");
|
|
165
|
+
if (!existsSync(dest)) {
|
|
166
|
+
const artifact = `org.junit.platform:junit-platform-launcher:${version}`;
|
|
167
|
+
const before = new Set(readdirSync(work));
|
|
168
|
+
const copied = run("mvn", ["-B", "-q", "org.apache.maven.plugins:maven-dependency-plugin:3.6.1:copy", `-Dartifact=${artifact}`, `-DoutputDirectory=${work}`], pkg);
|
|
169
|
+
if (copied.status !== 0)
|
|
170
|
+
return { error: clip(copied.stderr || copied.stdout || copied.error?.message || `mvn copy ${artifact} failed`) };
|
|
171
|
+
const created = readdirSync(work).find((item) => !before.has(item) && item.endsWith(".jar"));
|
|
172
|
+
if (!created)
|
|
173
|
+
return { error: `java coverage: missing ${artifact}` };
|
|
174
|
+
writeFileSync(dest, readFileSync(path.join(work, created)));
|
|
175
|
+
}
|
|
176
|
+
return { jar: dest };
|
|
177
|
+
}
|
|
178
|
+
function buildClasspath(pkg, work) {
|
|
179
|
+
const file = path.join(work, "classpath.txt");
|
|
180
|
+
const built = run("mvn", ["-B", "-q", "dependency:build-classpath", "-DincludeScope=test", `-Dmdep.outputFile=${file}`], pkg);
|
|
181
|
+
if (built.status !== 0)
|
|
182
|
+
return { error: clip(built.stderr || built.stdout || built.error?.message || "mvn classpath failed") };
|
|
183
|
+
try {
|
|
184
|
+
return { cp: readFileSync(file, "utf8").trim() };
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
return { error: "java coverage: classpath file missing" };
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
function javaTool(name) {
|
|
191
|
+
const home = process.env.JAVA_HOME;
|
|
192
|
+
if (home) {
|
|
193
|
+
const candidate = path.join(home, "bin", name);
|
|
194
|
+
if (existsSync(candidate) && toolWorks(candidate))
|
|
195
|
+
return candidate;
|
|
196
|
+
}
|
|
197
|
+
if (toolWorks(name))
|
|
198
|
+
return name;
|
|
199
|
+
for (const dir of ["/opt/homebrew/opt/openjdk/bin", "/usr/local/opt/openjdk/bin"]) {
|
|
200
|
+
const candidate = path.join(dir, name);
|
|
201
|
+
if (existsSync(candidate) && toolWorks(candidate))
|
|
202
|
+
return candidate;
|
|
203
|
+
}
|
|
204
|
+
return name;
|
|
205
|
+
}
|
|
206
|
+
function toolWorks(bin) {
|
|
207
|
+
const probed = spawnSync(bin, ["-version"], { encoding: "utf8" });
|
|
208
|
+
const text = `${probed.stdout ?? ""}${probed.stderr ?? ""}`;
|
|
209
|
+
return probed.status === 0 && !/Unable to locate a Java Runtime/i.test(text);
|
|
210
|
+
}
|
|
211
|
+
function run(bin, args, cwd, timeout = 180_000) {
|
|
212
|
+
return spawnSync(bin, args, { cwd, encoding: "utf8", timeout });
|
|
213
|
+
}
|
|
214
|
+
function clip(text) {
|
|
215
|
+
const line = text.trim().split("\n").find((item) => item.trim()) ?? "java coverage failed";
|
|
216
|
+
return line.slice(0, 240);
|
|
217
|
+
}
|