probatio 0.2.0 → 0.5.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 +10 -169
- package/bin/probatio.js +52 -0
- package/package.json +21 -25
- package/LICENSE +0 -21
- package/dist/cli.js +0 -610
- package/dist/contract.js +0 -31
- package/dist/flags.js +0 -87
- package/dist/golden/check.js +0 -188
- package/dist/ledger/build.js +0 -341
- package/dist/matrix/report.js +0 -256
- package/dist/mcp.js +0 -188
- package/dist/memory/state.js +0 -222
- package/dist/mutate/affected.js +0 -173
- package/dist/mutate/child-lines.mjs +0 -39
- package/dist/mutate/coverage-map.js +0 -229
- package/dist/mutate/csharp-coverage.js +0 -161
- package/dist/mutate/fail-reporter.mjs +0 -65
- package/dist/mutate/find.js +0 -13
- package/dist/mutate/generate.js +0 -153
- package/dist/mutate/go-coverage.js +0 -100
- package/dist/mutate/ids.js +0 -22
- package/dist/mutate/java-coverage.js +0 -217
- package/dist/mutate/kill-label.js +0 -34
- package/dist/mutate/load-failure.mjs +0 -40
- package/dist/mutate/mocha-coverage.cjs +0 -77
- package/dist/mutate/node-batch-loader.mjs +0 -99
- package/dist/mutate/node-batch.mjs +0 -167
- package/dist/mutate/node-coverage.mjs +0 -77
- package/dist/mutate/operators.js +0 -122
- package/dist/mutate/patch.js +0 -136
- package/dist/mutate/precise-lines.mjs +0 -304
- package/dist/mutate/probatio-jacoco-run.java +0 -516
- package/dist/mutate/project-config.js +0 -58
- package/dist/mutate/py-pytest-reporter.py +0 -145
- package/dist/mutate/py-reporter.py +0 -79
- package/dist/mutate/run.js +0 -1333
- package/dist/mutate/rust-coverage.js +0 -144
- package/dist/mutate/sealed.js +0 -118
- package/dist/mutate/suite-decision.js +0 -130
- package/dist/mutate/suites.js +0 -1524
- package/dist/mutate/tally.js +0 -240
- package/dist/mutate/text-operators.js +0 -547
- package/dist/mutate/titles.js +0 -23
- package/dist/node-version.js +0 -13
- package/dist/swarm/check-kill.js +0 -114
- package/dist/swarm/queue.js +0 -68
- package/dist/verify/change.js +0 -340
package/dist/mutate/generate.js
DELETED
|
@@ -1,153 +0,0 @@
|
|
|
1
|
-
import { spawnSync } from "node:child_process";
|
|
2
|
-
import { mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
-
import path from "node:path";
|
|
4
|
-
import { mutantId, makeRng, shuffle } from "./ids.js";
|
|
5
|
-
import { findInSource, isGeneratedSource } from "./find.js";
|
|
6
|
-
import { forwardDiff } from "./patch.js";
|
|
7
|
-
export function generateMutants(options) {
|
|
8
|
-
const started = Date.now();
|
|
9
|
-
const limit = options.maxMinutes;
|
|
10
|
-
const over = () => limit != null && Date.now() - started >= limit * 60_000;
|
|
11
|
-
const sourceDefault = options.workingTree ? "working-tree" : "commit";
|
|
12
|
-
if (over())
|
|
13
|
-
return { mutants: [], violations: [], filesVisited: 0, budgetHit: true, source: sourceDefault };
|
|
14
|
-
const srcRoot = path.resolve(options.packageDir, options.srcDir);
|
|
15
|
-
const walked = walkSources(srcRoot, over);
|
|
16
|
-
const files = walked.files.filter((file) => !options.skipFiles.includes(path.basename(file)));
|
|
17
|
-
const rng = makeRng(options.seed);
|
|
18
|
-
const violations = [];
|
|
19
|
-
const chosen = [];
|
|
20
|
-
let filesVisited = 0;
|
|
21
|
-
let budgetHit = walked.stopped;
|
|
22
|
-
let source = sourceDefault;
|
|
23
|
-
for (const file of files) {
|
|
24
|
-
if (over()) {
|
|
25
|
-
budgetHit = true;
|
|
26
|
-
break;
|
|
27
|
-
}
|
|
28
|
-
filesVisited += 1;
|
|
29
|
-
const loaded = fileText(options.packageDir, file, options);
|
|
30
|
-
if ("error" in loaded)
|
|
31
|
-
return { mutants: [], violations: [], filesVisited, budgetHit, source: "commit", error: loaded.error };
|
|
32
|
-
source = loaded.source;
|
|
33
|
-
const text = loaded.text;
|
|
34
|
-
const rel = posix(path.relative(options.packageDir, file));
|
|
35
|
-
const found = findInSource(rel, text);
|
|
36
|
-
violations.push(...found.violations);
|
|
37
|
-
const ordered = shuffle(found.points, rng);
|
|
38
|
-
const slice = options.perFile === null ? ordered.slice(options.skip) : ordered.slice(options.skip, options.skip + options.perFile);
|
|
39
|
-
for (const point of slice)
|
|
40
|
-
chosen.push({ rel, text, point });
|
|
41
|
-
}
|
|
42
|
-
if (violations.length > 0)
|
|
43
|
-
return { mutants: [], violations, filesVisited, budgetHit, source };
|
|
44
|
-
const capped = options.maxMutants === null ? chosen : chosen.slice(0, options.maxMutants);
|
|
45
|
-
const packageReal = real(options.packageDir);
|
|
46
|
-
const patchRoot = gitRoot(packageReal);
|
|
47
|
-
const prefix = patchRoot ? path.relative(patchRoot, packageReal) : "";
|
|
48
|
-
const mutantsDir = path.join(options.outDir, "mutants");
|
|
49
|
-
rmSync(mutantsDir, { recursive: true, force: true });
|
|
50
|
-
mkdirSync(mutantsDir, { recursive: true });
|
|
51
|
-
const mutants = [];
|
|
52
|
-
for (const item of capped) {
|
|
53
|
-
const id = mutantId(item.rel, item.point.start, item.point.end, item.point.op);
|
|
54
|
-
const mutated = item.text.slice(0, item.point.start) + item.point.replacement + item.text.slice(item.point.end);
|
|
55
|
-
const label = prefix && !prefix.startsWith("..") ? posix(path.join(prefix, item.rel)) : item.rel;
|
|
56
|
-
const diff = forwardDiff(label, item.text, mutated);
|
|
57
|
-
if (!diff)
|
|
58
|
-
continue;
|
|
59
|
-
const patch = `${id}.patch`;
|
|
60
|
-
writeFileSync(path.join(mutantsDir, patch), diff);
|
|
61
|
-
mutants.push({ id, file: item.rel, line: item.point.line, op: item.point.op, patch });
|
|
62
|
-
}
|
|
63
|
-
writeFileSync(path.join(mutantsDir, "mutants.json"), `${JSON.stringify({ schemaVersion: 1, seed: options.seed, mutants }, null, 2)}\n`);
|
|
64
|
-
return { mutants, violations, filesVisited, budgetHit, source };
|
|
65
|
-
}
|
|
66
|
-
function fileText(packageDir, absFile, options) {
|
|
67
|
-
if (options.workingTree)
|
|
68
|
-
return { text: readFileSync(absFile, "utf8"), source: "working-tree" };
|
|
69
|
-
const root = gitRoot(packageDir);
|
|
70
|
-
if (!root)
|
|
71
|
-
return { text: readFileSync(absFile, "utf8"), source: "working-tree" };
|
|
72
|
-
const rel = posix(path.relative(root, real(absFile)));
|
|
73
|
-
const commit = options.commit && options.commit.length > 0 ? options.commit : "HEAD";
|
|
74
|
-
const shown = spawnSync("git", ["-C", root, "show", `${commit}:${rel}`], { encoding: "utf8" });
|
|
75
|
-
if (shown.status !== 0) {
|
|
76
|
-
const shownRel = posix(path.relative(packageDir, absFile));
|
|
77
|
-
return { error: `${shownRel} is not in ${commit}. Pass --working-tree to read the checkout.` };
|
|
78
|
-
}
|
|
79
|
-
return { text: shown.stdout, source: "commit" };
|
|
80
|
-
}
|
|
81
|
-
function walkSources(dir, over) {
|
|
82
|
-
if (!statExists(dir))
|
|
83
|
-
return { files: [], stopped: false };
|
|
84
|
-
const out = [];
|
|
85
|
-
let stopped = false;
|
|
86
|
-
const walk = (current) => {
|
|
87
|
-
if (over()) {
|
|
88
|
-
stopped = true;
|
|
89
|
-
return;
|
|
90
|
-
}
|
|
91
|
-
let entries;
|
|
92
|
-
try {
|
|
93
|
-
entries = readdirSync(current).sort();
|
|
94
|
-
}
|
|
95
|
-
catch {
|
|
96
|
-
return;
|
|
97
|
-
}
|
|
98
|
-
for (const entry of entries) {
|
|
99
|
-
if (over()) {
|
|
100
|
-
stopped = true;
|
|
101
|
-
return;
|
|
102
|
-
}
|
|
103
|
-
if (entry === "node_modules" || entry === ".git")
|
|
104
|
-
continue;
|
|
105
|
-
const full = path.join(current, entry);
|
|
106
|
-
let info;
|
|
107
|
-
try {
|
|
108
|
-
info = statSync(full);
|
|
109
|
-
}
|
|
110
|
-
catch {
|
|
111
|
-
continue;
|
|
112
|
-
}
|
|
113
|
-
if (info.isDirectory()) {
|
|
114
|
-
walk(full);
|
|
115
|
-
if (stopped)
|
|
116
|
-
return;
|
|
117
|
-
}
|
|
118
|
-
else if (isSource(entry))
|
|
119
|
-
out.push(full);
|
|
120
|
-
}
|
|
121
|
-
};
|
|
122
|
-
walk(dir);
|
|
123
|
-
return { files: out, stopped };
|
|
124
|
-
}
|
|
125
|
-
function isSource(name) {
|
|
126
|
-
return isGeneratedSource(name);
|
|
127
|
-
}
|
|
128
|
-
function statExists(file) {
|
|
129
|
-
try {
|
|
130
|
-
statSync(file);
|
|
131
|
-
return true;
|
|
132
|
-
}
|
|
133
|
-
catch {
|
|
134
|
-
return false;
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
function gitRoot(cwd) {
|
|
138
|
-
const result = spawnSync("git", ["-C", cwd, "rev-parse", "--show-toplevel"], { encoding: "utf8" });
|
|
139
|
-
if (result.status !== 0)
|
|
140
|
-
return null;
|
|
141
|
-
return real(result.stdout.trim());
|
|
142
|
-
}
|
|
143
|
-
function real(file) {
|
|
144
|
-
try {
|
|
145
|
-
return realpathSync(file);
|
|
146
|
-
}
|
|
147
|
-
catch {
|
|
148
|
-
return path.resolve(file);
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
function posix(file) {
|
|
152
|
-
return file.split(path.sep).join("/");
|
|
153
|
-
}
|
|
@@ -1,100 +0,0 @@
|
|
|
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
|
-
}
|
package/dist/mutate/ids.js
DELETED
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
2
|
-
/** Stable across reruns: hash of the package-relative file, the span, and the operator. */
|
|
3
|
-
export function mutantId(file, start, end, op) {
|
|
4
|
-
const hex = createHash("sha256").update(`${file}\0${start}\0${end}\0${op}`).digest("hex").slice(0, 12);
|
|
5
|
-
return `m${hex}`;
|
|
6
|
-
}
|
|
7
|
-
/** Same sequence for the same seed. Not the Auspex experiment's floating-point shuffle. */
|
|
8
|
-
export function makeRng(seed) {
|
|
9
|
-
let state = seed >>> 0;
|
|
10
|
-
return () => {
|
|
11
|
-
state = (Math.imul(state, 1103515245) + 12345) >>> 0;
|
|
12
|
-
return state / 4294967296;
|
|
13
|
-
};
|
|
14
|
-
}
|
|
15
|
-
export function shuffle(items, rng) {
|
|
16
|
-
const out = [...items];
|
|
17
|
-
for (let index = out.length - 1; index > 0; index--) {
|
|
18
|
-
const swap = Math.floor(rng() * (index + 1));
|
|
19
|
-
[out[index], out[swap]] = [out[swap], out[index]];
|
|
20
|
-
}
|
|
21
|
-
return out;
|
|
22
|
-
}
|
|
@@ -1,217 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
/** Names that mean the process failed to compile. These are not test titles. */
|
|
2
|
-
const BUILD_NAMES = new Set([
|
|
3
|
-
"build",
|
|
4
|
-
"javac",
|
|
5
|
-
"compiler",
|
|
6
|
-
"cobc",
|
|
7
|
-
"nasm",
|
|
8
|
-
"clang",
|
|
9
|
-
"cargo",
|
|
10
|
-
"tsc",
|
|
11
|
-
"dotnet",
|
|
12
|
-
"swiftc",
|
|
13
|
-
]);
|
|
14
|
-
/**
|
|
15
|
-
* A named test and a compile failure are different facts.
|
|
16
|
-
* The build next step says the build failed and does not ask for a test
|
|
17
|
-
* named after the compiler.
|
|
18
|
-
*/
|
|
19
|
-
export function killLabel(killedBy) {
|
|
20
|
-
if (killedBy.length === 0)
|
|
21
|
-
return null;
|
|
22
|
-
if (killedBy.every((item) => BUILD_NAMES.has(bareName(item)))) {
|
|
23
|
-
return { cause: "build", next: "Killed because the build failed. Record it as a compile kill." };
|
|
24
|
-
}
|
|
25
|
-
return { cause: "test", next: `Killed by ${killedBy.join(", ")}.` };
|
|
26
|
-
}
|
|
27
|
-
/** True when the id names a compiler, not a test. */
|
|
28
|
-
export function isBuildName(id) {
|
|
29
|
-
return BUILD_NAMES.has(bareName(id));
|
|
30
|
-
}
|
|
31
|
-
function bareName(id) {
|
|
32
|
-
const mark = id.lastIndexOf("::");
|
|
33
|
-
return (mark >= 0 ? id.slice(mark + 2) : id).trim().toLowerCase();
|
|
34
|
-
}
|
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
// A test file that failed before any test ran either did not link or threw while loading.
|
|
2
|
-
// Did not link: a removed export, a missing module, a file that does not parse. No code ran.
|
|
3
|
-
// A compiled language rejects the same change at compile time, so that mutant is unviable.
|
|
4
|
-
// Threw while loading: the file's own top-level code ran the program and it broke. That is a kill.
|
|
5
|
-
|
|
6
|
-
const STATIC_ERROR = /^(?:SyntaxError|(?:Type)?Error \[ERR_(?:MODULE_NOT_FOUND|UNKNOWN_FILE_EXTENSION|UNSUPPORTED_DIR_IMPORT|PACKAGE_PATH_NOT_EXPORTED)\]):/
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* `text` is the error as printed: a `Name: message` line, then `at` frames.
|
|
10
|
-
* Static when the error is a link or parse error and no frame is in the project's own code.
|
|
11
|
-
* A SyntaxError thrown by running code (JSON.parse at the top of a test) has a frame in that file.
|
|
12
|
-
*/
|
|
13
|
-
export function staticLoadFailure(text) {
|
|
14
|
-
const lines = String(text).split(/\r?\n/)
|
|
15
|
-
const head = lines.findIndex((line) => STATIC_ERROR.test(line.trim()))
|
|
16
|
-
if (head === -1) return false
|
|
17
|
-
for (const raw of lines.slice(head + 1)) {
|
|
18
|
-
const line = raw.trim()
|
|
19
|
-
if (!line.startsWith("at ")) break
|
|
20
|
-
if (projectFrame(line)) return false
|
|
21
|
-
}
|
|
22
|
-
return true
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/** The error object a node:test event carries, printed the way node prints it. */
|
|
26
|
-
export function errorText(error) {
|
|
27
|
-
if (!error || typeof error !== "object") return ""
|
|
28
|
-
const name = typeof error.name === "string" ? error.name : "Error"
|
|
29
|
-
const code = typeof error.code === "string" ? ` [${error.code}]` : ""
|
|
30
|
-
const message = typeof error.message === "string" ? error.message : ""
|
|
31
|
-
const stack = typeof error.stack === "string" ? error.stack.split("\n").slice(1).join("\n") : ""
|
|
32
|
-
return `${name}${code}: ${message}\n${stack}`
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function projectFrame(frame) {
|
|
36
|
-
if (/\bnode:/.test(frame) || frame.includes("<anonymous>")) return false
|
|
37
|
-
const location = /(?:file:\/\/)?(\/[^\s)]+|[A-Za-z]:\\[^\s)]+):\d+:\d+\)?$/.exec(frame)
|
|
38
|
-
if (!location) return false
|
|
39
|
-
return !location[1].split(/[\\/]/).includes("node_modules")
|
|
40
|
-
}
|
|
@@ -1,77 +0,0 @@
|
|
|
1
|
-
// Mocha root hooks. This file is not node-coverage.mjs. Mocha does not run
|
|
2
|
-
// node:test afterEach/after, so that collector records no Mocha title.
|
|
3
|
-
// These hooks key inspector hits by the current Mocha title and write { files }.
|
|
4
|
-
const { mkdirSync, writeFileSync } = require("node:fs")
|
|
5
|
-
const path = require("node:path")
|
|
6
|
-
|
|
7
|
-
const mapPath = process.env.PROBATIO_COVERAGE_MAP
|
|
8
|
-
if (mapPath) {
|
|
9
|
-
/** @type {Map<string, Map<number, Set<string>>>} */
|
|
10
|
-
const byFile = new Map()
|
|
11
|
-
/** @type {Array<{ file: string, line: number }>} */
|
|
12
|
-
let prelude = []
|
|
13
|
-
/** @type {{ positiveLines: () => Promise<Array<{ file: string, line: number }>> } | null} */
|
|
14
|
-
let sampler = null
|
|
15
|
-
/** @type {((mapPath: string) => Array<{ file: string, line: number }>) | null} */
|
|
16
|
-
let takeChildHits = null
|
|
17
|
-
|
|
18
|
-
function add(hits, name) {
|
|
19
|
-
if (!name) return
|
|
20
|
-
for (const hit of hits) {
|
|
21
|
-
let lines = byFile.get(hit.file)
|
|
22
|
-
if (!lines) {
|
|
23
|
-
lines = new Map()
|
|
24
|
-
byFile.set(hit.file, lines)
|
|
25
|
-
}
|
|
26
|
-
let names = lines.get(hit.line)
|
|
27
|
-
if (!names) {
|
|
28
|
-
names = new Set()
|
|
29
|
-
lines.set(hit.line, names)
|
|
30
|
-
}
|
|
31
|
-
names.add(name)
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function titleOf(test) {
|
|
36
|
-
if (!test) return ""
|
|
37
|
-
if (typeof test.fullTitle === "function") return test.fullTitle()
|
|
38
|
-
return typeof test.title === "string" ? test.title : ""
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
exports.mochaHooks = {
|
|
42
|
-
async beforeAll() {
|
|
43
|
-
try {
|
|
44
|
-
const opened = await import("./precise-lines.mjs")
|
|
45
|
-
sampler = await opened.openLineSampler((rel) => rel.endsWith("mocha-coverage.cjs") || rel.endsWith("precise-lines.mjs") || rel.endsWith("node-coverage.mjs") || rel.endsWith("child-lines.mjs"))
|
|
46
|
-
// A node child of a Mocha test is mapped under that test, the same as under node:test.
|
|
47
|
-
opened.exposeChildLines()
|
|
48
|
-
takeChildHits = opened.takeChildHits
|
|
49
|
-
prelude = await sampler.positiveLines()
|
|
50
|
-
} catch (err) {
|
|
51
|
-
writeFileSync(`${mapPath}.error`, `${err instanceof Error ? err.message : String(err)}\n`)
|
|
52
|
-
}
|
|
53
|
-
},
|
|
54
|
-
async afterEach() {
|
|
55
|
-
if (!sampler) return
|
|
56
|
-
const title = titleOf(this.currentTest)
|
|
57
|
-
try {
|
|
58
|
-
add(prelude, title)
|
|
59
|
-
add(await sampler.positiveLines(), title)
|
|
60
|
-
if (takeChildHits) add(takeChildHits(mapPath), title)
|
|
61
|
-
} catch (err) {
|
|
62
|
-
writeFileSync(`${mapPath}.error`, `${err instanceof Error ? err.message : String(err)}\n`)
|
|
63
|
-
}
|
|
64
|
-
},
|
|
65
|
-
afterAll() {
|
|
66
|
-
if (byFile.size === 0) return
|
|
67
|
-
const files = {}
|
|
68
|
-
for (const [file, lines] of byFile) {
|
|
69
|
-
const bucket = {}
|
|
70
|
-
for (const [line, names] of lines) bucket[String(line)] = [...names].sort()
|
|
71
|
-
files[file] = bucket
|
|
72
|
-
}
|
|
73
|
-
mkdirSync(path.dirname(mapPath), { recursive: true })
|
|
74
|
-
writeFileSync(mapPath, `${JSON.stringify({ files }, null, 2)}\n`)
|
|
75
|
-
},
|
|
76
|
-
}
|
|
77
|
-
}
|