probatio 0.0.4 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -5
- package/dist/cli.js +46 -2
- package/dist/mutate/csharp-coverage.js +161 -0
- package/dist/mutate/probatio-jacoco-run.java +10 -1
- package/dist/mutate/run.js +136 -22
- package/dist/mutate/rust-coverage.js +159 -0
- package/dist/mutate/sealed.js +25 -0
- package/dist/mutate/suite-decision.js +1 -1
- package/dist/mutate/suites.js +168 -25
- package/dist/mutate/tally.js +1 -1
- package/dist/verify/change.js +17 -8
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
# Probatio
|
|
2
2
|
|
|
3
3
|
Probatio (Latin: a testing, a proof) is a free testing toolkit under the MIT license, built for AI agents
|
|
4
|
-
(Claude, Grok, ChatGPT, others), working alone or as swarms. Every claim it makes is checkable
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
(Claude, Grok, ChatGPT, others), working alone or as swarms. Every claim it makes is checkable.
|
|
5
|
+
It remembers what was fixed so agents with small context do not redo or undo work, and it shows
|
|
6
|
+
which tests already touch a change.
|
|
7
7
|
|
|
8
|
-
Status (2026-10-
|
|
8
|
+
Status (2026-10-06): `mutate run` discovers the project suite and scores it. Node, pytest, C (when the binary was built with LLVM coverage), Go, Maven/Java, Rust, and C# collect a line map on the baseline. A line no test executes is `no coverage`, and the suite is not started. A line that runs only in a child process the map cannot see is `no coverage`. A suite with no line map, a baseline of 5 seconds or more, and more than 30 mutants stops before the first mutant. `verify-change` names the tests the line map ran. `ran` stays the direct-importer list. `mutate tally` does not delete a test. A kill is an assertion the suite already had. A sealed miss means the suite did not see the bug. The kill count is not a merge gate. On Commons CSV (Apache-2.0, commit `2c83a308`), two sealed runs agreed: with the new test file hidden, `CSVFormatTest.testFormatThrowsNullPointerException` failed on the reverted printer. `deletedTests` stayed 0. That older JUnit platform did not write a line map, so the first pass was the whole suite and confirm reran the older test. Auspex, markupsafe, and a hand-planted fixture are not that result. The package version in this repository is 0.1.0. The copy on npm is 0.0.5. The BugsInPy search row is still open, and the rows that apply only when a sealed run misses do not apply, because this catch held. The MCP server speaks one JSON object per line and returns the same JSON as the CLI.
|
|
9
9
|
|
|
10
10
|
Read in this order:
|
|
11
11
|
|
|
@@ -31,7 +31,7 @@ npx probatio mutate tally --out .probatio/runs
|
|
|
31
31
|
|
|
32
32
|
`run` discovers the suite: `run_tests.sh`, Cargo, Go, Swift, Maven, dotnet, pytest, unittest, node:test, Mocha, then `make test` when COBOL tests sit under that Makefile. An unknown layout stops and asks for `--suite-command`. It does not compile one file and call that the suite. `make test` that would curl or wget a missing file stops, and nothing is fetched. A worktree that has no `node_modules` uses the main checkout's. Omitting `--build` runs no build. Pass `--build` with a command when the suite needs one first.
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
Node, pytest, Go, Maven/Java, Rust, and C# collect a line map on the baseline. C collects one when LLVM coverage was instrumented. The map stores the file, the line, and the test names that hit that line. A later mutant runs only those tests (`go test -run`, Maven `-Dtest`, `cargo test`, `dotnet test --filter`). A line no test executed is `no coverage`, and the suite is not started. A child process the map cannot see is `no coverage`. A baseline of 5 seconds or more, with no line map and more than 30 mutants still pending, stops before the first mutant. `next` names that baseline and tells you to narrow `--src` or pass a smaller patch directory. A one-file C, C++, Java, COBOL, or assembly launcher is not a suite discovery returns.
|
|
35
35
|
|
|
36
36
|
The mutant timeout is the baseline duration times 5, and at least 20 seconds, capped by `--suite-timeout-ms`. A timeout is not a kill. `--budget-ms` stops mutant work after the baseline. The first mutant still runs, except for that no-line-map stop. A kill is the test that failed, or a compiler token when the mutant did not build. Confirm is on by default and reruns the failing names. Pytest names that `-k` cannot express are passed as node ids. A usage error, including pytest exit 4, is not a kill. `verify-change` does not confirm a second time. Add `--affected` to limit the file list to tests that can see the change. The default, once a line map exists, runs the tests on the changed line.
|
|
37
37
|
|
package/dist/cli.js
CHANGED
|
@@ -10,6 +10,7 @@ import { appendFinding, chooseSealed, fixGap, guardAllows, lineHash, loadState,
|
|
|
10
10
|
import { git } from "./mutate/patch.js";
|
|
11
11
|
import { generateMutants } from "./mutate/generate.js";
|
|
12
12
|
import { runMutants } from "./mutate/run.js";
|
|
13
|
+
import { LABEL_LEAK_SUMMARY, labelLeaked, readSealedLabel } from "./mutate/sealed.js";
|
|
13
14
|
import { generateNext } from "./mutate/suite-decision.js";
|
|
14
15
|
import { discoverSuite } from "./mutate/suites.js";
|
|
15
16
|
import { tallyRun } from "./mutate/tally.js";
|
|
@@ -23,6 +24,8 @@ try {
|
|
|
23
24
|
finish(await generateCommand(parsed.flags), parsed.human);
|
|
24
25
|
if (group === "mutate" && action === "run")
|
|
25
26
|
finish(await runCommand(parsed.flags), parsed.human);
|
|
27
|
+
if (group === "mutate" && action === "sealed")
|
|
28
|
+
finish(await sealedCommand(parsed.flags), parsed.human);
|
|
26
29
|
if (group === "mutate" && action === "tally")
|
|
27
30
|
finish(tallyCommand(parsed.flags), parsed.human);
|
|
28
31
|
if (group === "ledger" && action === "build")
|
|
@@ -72,7 +75,7 @@ function usage(ok, summary) {
|
|
|
72
75
|
ok,
|
|
73
76
|
command: "mutate.help",
|
|
74
77
|
summary,
|
|
75
|
-
next: "probatio mutate generate writes operator mutants. probatio mutate run confirms each kill twice. probatio mutate tally reads a finished run and does not delete a test. probatio ledger build reverts fix commits. probatio matrix report reads a recorded score table.",
|
|
78
|
+
next: "probatio mutate generate writes operator mutants. probatio mutate run confirms each kill twice. probatio mutate sealed runs one forward bug and keeps the label file out of the report. probatio mutate tally reads a finished run and does not delete a test. probatio ledger build reverts fix commits. probatio matrix report reads a recorded score table.",
|
|
76
79
|
nextCall: null,
|
|
77
80
|
};
|
|
78
81
|
}
|
|
@@ -129,7 +132,30 @@ async function generateCommand(flags) {
|
|
|
129
132
|
full: path.join(patches, "mutants.json"),
|
|
130
133
|
};
|
|
131
134
|
}
|
|
132
|
-
async function runCommand(flags) {
|
|
135
|
+
async function runCommand(flags, hide = null) {
|
|
136
|
+
const onlyNames = texts(flags, "only-test");
|
|
137
|
+
if (onlyNames.some((id) => id === "::command" || id.endsWith("::command"))) {
|
|
138
|
+
return {
|
|
139
|
+
schemaVersion: SCHEMA_VERSION,
|
|
140
|
+
ok: false,
|
|
141
|
+
command: "mutate.run",
|
|
142
|
+
summary: "::command is not a test id.",
|
|
143
|
+
next: "Pass a test name the suite can collect.",
|
|
144
|
+
nextCall: null,
|
|
145
|
+
killed: 0,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
if (onlyNames.length > 0 && text(flags, "suite-command")) {
|
|
149
|
+
return {
|
|
150
|
+
schemaVersion: SCHEMA_VERSION,
|
|
151
|
+
ok: false,
|
|
152
|
+
command: "mutate.run",
|
|
153
|
+
summary: "--only-test cannot be combined with --suite-command.",
|
|
154
|
+
next: "Drop --suite-command and pass the keep ids with --only-test.",
|
|
155
|
+
nextCall: null,
|
|
156
|
+
killed: 0,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
133
159
|
const packageDir = path.resolve(requireText(flags, "package"));
|
|
134
160
|
const patchDirs = texts(flags, "patches").map((dir) => path.resolve(dir));
|
|
135
161
|
if (patchDirs.length === 0)
|
|
@@ -164,10 +190,12 @@ async function runCommand(flags) {
|
|
|
164
190
|
affected: bool(flags, "affected", false),
|
|
165
191
|
agent: text(flags, "agent") ?? null,
|
|
166
192
|
suiteCommand: text(flags, "suite-command") ?? null,
|
|
193
|
+
onlyNames: onlyNames.length > 0 ? onlyNames : null,
|
|
167
194
|
historyPath: text(flags, "history") ? path.resolve(text(flags, "history")) : null,
|
|
168
195
|
timeoutMultiple: int(flags, "timeout-multiple") ?? 5,
|
|
169
196
|
timeoutFloorMs: int(flags, "timeout-floor-ms") ?? 20_000,
|
|
170
197
|
budgetMs: int(flags, "budget-ms") ?? null,
|
|
198
|
+
hide,
|
|
171
199
|
onProgress: (line) => process.stderr.write(`${line}\n`),
|
|
172
200
|
});
|
|
173
201
|
const resume = report.budgetHit
|
|
@@ -203,6 +231,22 @@ async function runCommand(flags) {
|
|
|
203
231
|
function tallyCommand(flags) {
|
|
204
232
|
return tallyRun(path.resolve(requireText(flags, "out")));
|
|
205
233
|
}
|
|
234
|
+
async function sealedCommand(flags) {
|
|
235
|
+
const label = readSealedLabel(path.resolve(requireText(flags, "label")));
|
|
236
|
+
const hide = texts(flags, "hide");
|
|
237
|
+
const report = await runCommand(flags, hide);
|
|
238
|
+
const body = JSON.stringify(report);
|
|
239
|
+
if (!labelLeaked(body, label))
|
|
240
|
+
return report;
|
|
241
|
+
return {
|
|
242
|
+
schemaVersion: SCHEMA_VERSION,
|
|
243
|
+
ok: false,
|
|
244
|
+
command: "mutate.sealed",
|
|
245
|
+
summary: LABEL_LEAK_SUMMARY,
|
|
246
|
+
next: "Keep the label in its own file. Run the same command again.",
|
|
247
|
+
nextCall: null,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
206
250
|
async function ledgerCommand(flags) {
|
|
207
251
|
const packageDir = path.resolve(requireText(flags, "package"));
|
|
208
252
|
const outDir = path.resolve(requireText(flags, "out"));
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
/**
|
|
5
|
+
* One Coverlet run per test name. The collector's report does not name the test,
|
|
6
|
+
* so each name gets its own results directory.
|
|
7
|
+
*/
|
|
8
|
+
export function writeCsharpCoverage(pkg, testNames, dest, project) {
|
|
9
|
+
const names = [...new Set(testNames.map((name) => name.trim()).filter((name) => name.length > 0))];
|
|
10
|
+
if (names.length === 0)
|
|
11
|
+
return { ok: false, detail: "csharp coverage: no test names" };
|
|
12
|
+
const work = path.join(path.dirname(dest), "csharp-cover");
|
|
13
|
+
mkdirSync(work, { recursive: true });
|
|
14
|
+
const settings = path.join(work, "coverlet.runsettings");
|
|
15
|
+
// Coverlet skips the test assembly unless asked. A one-project suite keeps the code there.
|
|
16
|
+
writeFileSync(settings, [
|
|
17
|
+
'<?xml version="1.0" encoding="utf-8"?>',
|
|
18
|
+
"<RunSettings>",
|
|
19
|
+
" <DataCollectionRunSettings>",
|
|
20
|
+
" <DataCollectors>",
|
|
21
|
+
' <DataCollector friendlyName="XPlat Code Coverage">',
|
|
22
|
+
" <Configuration>",
|
|
23
|
+
" <Format>cobertura</Format>",
|
|
24
|
+
" <IncludeTestAssembly>true</IncludeTestAssembly>",
|
|
25
|
+
" </Configuration>",
|
|
26
|
+
" </DataCollector>",
|
|
27
|
+
" </DataCollectors>",
|
|
28
|
+
" </DataCollectionRunSettings>",
|
|
29
|
+
"</RunSettings>",
|
|
30
|
+
"",
|
|
31
|
+
].join("\n"));
|
|
32
|
+
const files = {};
|
|
33
|
+
const env = dotnetEnv();
|
|
34
|
+
for (const name of names) {
|
|
35
|
+
const results = path.join(work, safe(name));
|
|
36
|
+
mkdirSync(results, { recursive: true });
|
|
37
|
+
const args = ["test", "--nologo", "--verbosity", "quiet", "--settings", settings];
|
|
38
|
+
if (project)
|
|
39
|
+
args.push(project);
|
|
40
|
+
args.push("--filter", `FullyQualifiedName~${name}`, "--collect", "XPlat Code Coverage", "--results-directory", results);
|
|
41
|
+
const ran = spawnSync("dotnet", args, { cwd: pkg, encoding: "utf8", env, timeout: 180_000, maxBuffer: 16 * 1024 * 1024 });
|
|
42
|
+
if (ran.status !== 0 || ran.error)
|
|
43
|
+
continue;
|
|
44
|
+
const report = findCobertura(results);
|
|
45
|
+
if (!report)
|
|
46
|
+
continue;
|
|
47
|
+
let text = "";
|
|
48
|
+
try {
|
|
49
|
+
text = readFileSync(report, "utf8");
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
addCobertura(files, text, pkg, name);
|
|
55
|
+
}
|
|
56
|
+
if (Object.keys(files).length === 0)
|
|
57
|
+
return { ok: false, detail: "csharp coverage: no covered lines" };
|
|
58
|
+
writeFileSync(dest, `${JSON.stringify({ files }, null, 2)}\n`);
|
|
59
|
+
return { ok: true, detail: `${Object.keys(files).length} files` };
|
|
60
|
+
}
|
|
61
|
+
function addCobertura(files, text, pkg, test) {
|
|
62
|
+
const sources = [...text.matchAll(/<source>([^<]+)<\/source>/g)].map((match) => match[1].trim());
|
|
63
|
+
for (const part of text.split(/<class\b/).slice(1)) {
|
|
64
|
+
const headEnd = part.indexOf(">");
|
|
65
|
+
if (headEnd < 0)
|
|
66
|
+
continue;
|
|
67
|
+
const filename = /filename="([^"]+)"/.exec(part.slice(0, headEnd))?.[1];
|
|
68
|
+
if (!filename)
|
|
69
|
+
continue;
|
|
70
|
+
const bodyEnd = part.indexOf("</class>");
|
|
71
|
+
const body = bodyEnd >= 0 ? part.slice(0, bodyEnd) : part;
|
|
72
|
+
const rel = coberturaRel(filename, sources, pkg);
|
|
73
|
+
if (!rel)
|
|
74
|
+
continue;
|
|
75
|
+
for (const tag of body.match(/<line\b[^>]*\/?>/g) ?? []) {
|
|
76
|
+
const number = /number="(\d+)"/.exec(tag);
|
|
77
|
+
const hits = /hits="(\d+)"/.exec(tag);
|
|
78
|
+
if (!number || !hits || Number(hits[1]) <= 0)
|
|
79
|
+
continue;
|
|
80
|
+
addHit(files, rel, Number(number[1]), test);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
function coberturaRel(filename, sources, pkg) {
|
|
85
|
+
const norm = filename.split("\\").join("/");
|
|
86
|
+
const candidates = norm.startsWith("/") || /^[A-Za-z]:\//.test(norm) ? [norm] : [norm];
|
|
87
|
+
for (const source of sources) {
|
|
88
|
+
const root = source.endsWith("/") || source.endsWith("\\") ? source : `${source}/`;
|
|
89
|
+
candidates.push(path.normalize(`${root}${norm}`));
|
|
90
|
+
}
|
|
91
|
+
for (const candidate of candidates) {
|
|
92
|
+
const rel = underPkg(candidate, pkg);
|
|
93
|
+
if (rel)
|
|
94
|
+
return rel;
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
function underPkg(filename, pkg) {
|
|
99
|
+
if (!existsSync(filename))
|
|
100
|
+
return null;
|
|
101
|
+
let file = filename;
|
|
102
|
+
let root = pkg;
|
|
103
|
+
try {
|
|
104
|
+
file = realpathSync(filename);
|
|
105
|
+
root = realpathSync(pkg);
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
const rel = path.relative(root, file);
|
|
111
|
+
if (!rel || rel.startsWith(".."))
|
|
112
|
+
return null;
|
|
113
|
+
return rel.split(path.sep).join("/");
|
|
114
|
+
}
|
|
115
|
+
function findCobertura(dir) {
|
|
116
|
+
const found = [];
|
|
117
|
+
const visit = (current) => {
|
|
118
|
+
let entries;
|
|
119
|
+
try {
|
|
120
|
+
entries = readdirSync(current);
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
for (const entry of entries) {
|
|
126
|
+
const full = path.join(current, entry);
|
|
127
|
+
let info;
|
|
128
|
+
try {
|
|
129
|
+
info = statSync(full);
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (info.isDirectory()) {
|
|
135
|
+
visit(full);
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
if (entry === "coverage.cobertura.xml")
|
|
139
|
+
found.push(full);
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
visit(dir);
|
|
143
|
+
return found[0] ?? null;
|
|
144
|
+
}
|
|
145
|
+
function addHit(files, file, line, test) {
|
|
146
|
+
const lines = files[file] ?? {};
|
|
147
|
+
const key = String(line);
|
|
148
|
+
const names = lines[key] ?? [];
|
|
149
|
+
if (!names.includes(test))
|
|
150
|
+
names.push(test);
|
|
151
|
+
lines[key] = names;
|
|
152
|
+
files[file] = lines;
|
|
153
|
+
}
|
|
154
|
+
function dotnetEnv() {
|
|
155
|
+
if (process.env.DOTNET_ROLL_FORWARD)
|
|
156
|
+
return process.env;
|
|
157
|
+
return { ...process.env, DOTNET_ROLL_FORWARD: "Major" };
|
|
158
|
+
}
|
|
159
|
+
function safe(value) {
|
|
160
|
+
return value.replace(/[^A-Za-z0-9._-]+/g, "_");
|
|
161
|
+
}
|
|
@@ -252,11 +252,17 @@ public class ProbatioJacocoRun {
|
|
|
252
252
|
Class<?> selectorType = Class.forName("org.junit.platform.engine.DiscoverySelector");
|
|
253
253
|
Class<?> selectorsType = Class.forName("org.junit.platform.engine.discovery.DiscoverySelectors");
|
|
254
254
|
java.lang.reflect.Method select = selectMethodWithTypes(selectorsType);
|
|
255
|
+
boolean typed = select.getParameterCount() == 3;
|
|
255
256
|
Object selectors = Array.newInstance(selectorType, unique.size());
|
|
256
257
|
int index = 0;
|
|
257
258
|
for (JupiterSpec spec : unique.values()) {
|
|
258
259
|
Class<?> type = loadTestClass(spec.className);
|
|
259
|
-
|
|
260
|
+
// Platform 1.7 has selectMethod(Class, String) only. 5.14's two-argument
|
|
261
|
+
// form drops the parameter list and can run nothing, so keep the types
|
|
262
|
+
// when that overload exists.
|
|
263
|
+
Object selector = typed
|
|
264
|
+
? select.invoke(null, new Object[] { type, spec.simple, spec.types })
|
|
265
|
+
: select.invoke(null, new Object[] { type, spec.simple });
|
|
260
266
|
Array.set(selectors, index++, selector);
|
|
261
267
|
}
|
|
262
268
|
Class<?> builderType = Class.forName("org.junit.platform.launcher.core.LauncherDiscoveryRequestBuilder");
|
|
@@ -426,13 +432,16 @@ public class ProbatioJacocoRun {
|
|
|
426
432
|
}
|
|
427
433
|
|
|
428
434
|
static java.lang.reflect.Method selectMethodWithTypes(Class<?> selectorsType) throws NoSuchMethodException {
|
|
435
|
+
java.lang.reflect.Method byName = null;
|
|
429
436
|
for (java.lang.reflect.Method candidate : selectorsType.getMethods()) {
|
|
430
437
|
if (!candidate.getName().equals("selectMethod")) continue;
|
|
431
438
|
Class<?>[] params = candidate.getParameterTypes();
|
|
432
439
|
if (params.length == 3 && params[0] == Class.class && params[1] == String.class && params[2].isArray() && params[2].getComponentType() == Class.class) {
|
|
433
440
|
return candidate;
|
|
434
441
|
}
|
|
442
|
+
if (params.length == 2 && params[0] == Class.class && params[1] == String.class) byName = candidate;
|
|
435
443
|
}
|
|
444
|
+
if (byName != null) return byName;
|
|
436
445
|
throw new NoSuchMethodException("selectMethod(Class, String, Class[])");
|
|
437
446
|
}
|
|
438
447
|
|
package/dist/mutate/run.js
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { createHash, randomBytes } from "node:crypto";
|
|
3
|
-
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
7
|
import { affectedTests } from "./affected.js";
|
|
8
8
|
import { binaryFromCommand, readCoverageMap, selectionFor, writeLlvmCoverage } from "./coverage-map.js";
|
|
9
|
+
import { writeCsharpCoverage } from "./csharp-coverage.js";
|
|
9
10
|
import { writeGoCoverage } from "./go-coverage.js";
|
|
10
11
|
import { writeJavaCoverage } from "./java-coverage.js";
|
|
12
|
+
import { writeRustCoverage } from "./rust-coverage.js";
|
|
11
13
|
import { applyPatch, changedLines, chooseDirection, filesInDiff, git, restoreTree } from "./patch.js";
|
|
12
14
|
import { killLabel } from "./kill-label.js";
|
|
13
15
|
import { TIMEOUT_FLOOR_MS, TIMEOUT_MULTIPLE, TIMEOUT_NEXT, WHOLE_PROGRAM_TEST, WHOLE_SUITE_NEXT, campaignSummary, decideSuite, hasLineMap, noCoverageNext, resumeNext, scaleStop, timeoutLimitMs, } from "./suite-decision.js";
|
|
@@ -64,6 +66,7 @@ export async function runMutants(options) {
|
|
|
64
66
|
const worktrees = await createWorktrees(repo, packageDir, run.id, commit, options.workers, options.outDir);
|
|
65
67
|
if ("error" in worktrees)
|
|
66
68
|
return failReport(options.outDir, run.id, commit, worktrees.error);
|
|
69
|
+
hidePaths(worktrees.dirs, repo, packageDir, options.hide ?? []);
|
|
67
70
|
const budgetMs = options.budgetMs ?? (options.maxMinutes == null ? null : options.maxMinutes * 60_000);
|
|
68
71
|
let budgetStarted = 0;
|
|
69
72
|
const overBudget = () => budgetMs !== null && budgetStarted !== 0 && Date.now() - budgetStarted > budgetMs;
|
|
@@ -196,11 +199,9 @@ async function prepare(pkg, options, progress) {
|
|
|
196
199
|
if (baselineFailed.length > 0) {
|
|
197
200
|
return { ok: false, error: "baseline already failing", baselineFailed, suiteCommand: spec.command };
|
|
198
201
|
}
|
|
199
|
-
if (collect && !existsSync(mapPath) && (spec.kind
|
|
200
|
-
progress(spec.kind
|
|
201
|
-
const written = spec.kind
|
|
202
|
-
? writeGoCoverage(pkg, baseline.report.names, mapPath)
|
|
203
|
-
: writeJavaCoverage(pkg, baseline.report.names, mapPath);
|
|
202
|
+
if (collect && !existsSync(mapPath) && languageMapKind(spec.kind)) {
|
|
203
|
+
progress(languageMapLabel(spec.kind));
|
|
204
|
+
const written = writeLanguageMap(spec.kind, pkg, baseline.report.names, mapPath, spec.project);
|
|
204
205
|
if (!written.ok)
|
|
205
206
|
writeFileSync(path.join(options.outDir, "coverage-error.txt"), `${written.detail}\n`);
|
|
206
207
|
}
|
|
@@ -262,7 +263,8 @@ async function runOne(worktree, pkg, patchPath, options, commit, runId, prep) {
|
|
|
262
263
|
return finishMutant(killed(base, direction, ["build"]), pkg, "", false, []);
|
|
263
264
|
}
|
|
264
265
|
const picked = selectionFor(prep.coverage, edited.length > 0 ? edited : files);
|
|
265
|
-
|
|
266
|
+
const forced = options.onlyNames && options.onlyNames.length > 0 ? options.onlyNames : null;
|
|
267
|
+
if (!forced && picked.state === "uncovered") {
|
|
266
268
|
return {
|
|
267
269
|
...base(),
|
|
268
270
|
direction,
|
|
@@ -270,7 +272,7 @@ async function runOne(worktree, pkg, patchPath, options, commit, runId, prep) {
|
|
|
270
272
|
next: noCoverageNext(picked.file, picked.line),
|
|
271
273
|
};
|
|
272
274
|
}
|
|
273
|
-
const names = picked.state === "covered" ? picked.tests.filter((name) => name !== WHOLE_PROGRAM_TEST) : null;
|
|
275
|
+
const names = forced ?? (picked.state === "covered" ? picked.tests.filter((name) => name !== WHOLE_PROGRAM_TEST) : null);
|
|
274
276
|
const forceWhole = picked.state === "covered" && (names === null || names.length === 0);
|
|
275
277
|
// node --test loads every file it is given, so a name pattern still pays for the other files.
|
|
276
278
|
const selected = narrowNodeFiles(pkg, selectTests(files, prep.tests, prep.affectedMap), names, prep.spec.kind);
|
|
@@ -541,14 +543,29 @@ async function createWorktrees(repo, packageDir, runId, commit, workers, outDir)
|
|
|
541
543
|
dirs.push(dir);
|
|
542
544
|
}
|
|
543
545
|
writeFileSync(path.join(outDir, "worktrees.json"), `${JSON.stringify(dirs)}\n`);
|
|
544
|
-
const
|
|
546
|
+
const sources = nodeModuleSources(repo, packageDir);
|
|
545
547
|
for (const dir of dirs) {
|
|
546
|
-
const
|
|
547
|
-
|
|
548
|
-
|
|
548
|
+
const pkgRoot = packageIn(dir, repo, packageDir);
|
|
549
|
+
for (const source of sources) {
|
|
550
|
+
const dest = path.join(pkgRoot, source.rel);
|
|
551
|
+
if (existsSync(dest))
|
|
552
|
+
continue;
|
|
553
|
+
mkdirSync(path.dirname(dest), { recursive: true });
|
|
554
|
+
symlinkSync(source.abs, dest, "dir");
|
|
555
|
+
}
|
|
549
556
|
}
|
|
550
557
|
return { dirs };
|
|
551
558
|
}
|
|
559
|
+
function hidePaths(dirs, repo, packageDir, hide) {
|
|
560
|
+
for (const dir of dirs) {
|
|
561
|
+
const pkg = packageIn(dir, repo, packageDir);
|
|
562
|
+
for (const rel of hide) {
|
|
563
|
+
if (!rel || path.isAbsolute(rel))
|
|
564
|
+
continue;
|
|
565
|
+
rmSync(path.join(pkg, rel), { force: true });
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
}
|
|
552
569
|
function removeWorktrees(repo, dirs, outDir) {
|
|
553
570
|
for (const dir of dirs) {
|
|
554
571
|
git(repo, ["worktree", "remove", "--force", dir]);
|
|
@@ -569,11 +586,30 @@ function recordedWorktrees(outDir) {
|
|
|
569
586
|
return [];
|
|
570
587
|
}
|
|
571
588
|
}
|
|
572
|
-
/**
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
589
|
+
/**
|
|
590
|
+
* Installed dependency directories to mirror into a fresh worktree.
|
|
591
|
+
* A package can keep node_modules at its root or under a nested example.
|
|
592
|
+
* A run that starts from a worktree looks beside the main checkout too.
|
|
593
|
+
*/
|
|
594
|
+
function nodeModuleSources(repo, packageDir) {
|
|
595
|
+
const roots = [packageDir];
|
|
596
|
+
const main = mainCheckoutPackage(repo, packageDir);
|
|
597
|
+
if (main && path.resolve(main) !== path.resolve(packageDir))
|
|
598
|
+
roots.push(main);
|
|
599
|
+
const found = [];
|
|
600
|
+
const seen = new Set();
|
|
601
|
+
for (const root of roots) {
|
|
602
|
+
for (const abs of findNodeModules(root)) {
|
|
603
|
+
const rel = path.relative(root, abs);
|
|
604
|
+
if (seen.has(rel))
|
|
605
|
+
continue;
|
|
606
|
+
seen.add(rel);
|
|
607
|
+
found.push({ rel, abs });
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
return found;
|
|
611
|
+
}
|
|
612
|
+
function mainCheckoutPackage(repo, packageDir) {
|
|
577
613
|
const common = git(repo, ["rev-parse", "--path-format=absolute", "--git-common-dir"]);
|
|
578
614
|
if (common.status !== 0)
|
|
579
615
|
return null;
|
|
@@ -583,8 +619,40 @@ function nodeModulesOf(repo, packageDir) {
|
|
|
583
619
|
const rel = path.relative(repo, packageDir);
|
|
584
620
|
if (rel.startsWith(".."))
|
|
585
621
|
return null;
|
|
586
|
-
|
|
587
|
-
|
|
622
|
+
return path.join(path.dirname(gitDir), rel);
|
|
623
|
+
}
|
|
624
|
+
function findNodeModules(root) {
|
|
625
|
+
const out = [];
|
|
626
|
+
const visit = (dir) => {
|
|
627
|
+
let entries;
|
|
628
|
+
try {
|
|
629
|
+
entries = readdirSync(dir);
|
|
630
|
+
}
|
|
631
|
+
catch {
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
for (const name of entries) {
|
|
635
|
+
if (name === ".git" || name === "target" || name === "dist")
|
|
636
|
+
continue;
|
|
637
|
+
const full = path.join(dir, name);
|
|
638
|
+
let info;
|
|
639
|
+
try {
|
|
640
|
+
info = statSync(full);
|
|
641
|
+
}
|
|
642
|
+
catch {
|
|
643
|
+
continue;
|
|
644
|
+
}
|
|
645
|
+
if (!info.isDirectory())
|
|
646
|
+
continue;
|
|
647
|
+
if (name === "node_modules") {
|
|
648
|
+
out.push(full);
|
|
649
|
+
continue;
|
|
650
|
+
}
|
|
651
|
+
visit(full);
|
|
652
|
+
}
|
|
653
|
+
};
|
|
654
|
+
visit(root);
|
|
655
|
+
return out;
|
|
588
656
|
}
|
|
589
657
|
function packageIn(worktree, repo, packageDir) {
|
|
590
658
|
const rel = path.relative(repo, packageDir);
|
|
@@ -672,10 +740,33 @@ function reportOf(results, context) {
|
|
|
672
740
|
flakyIds: results.filter((result) => result.outcome === "flaky").slice(0, 10).map((result) => result.id),
|
|
673
741
|
kills: results
|
|
674
742
|
.filter((result) => result.outcome === "killed" && result.cause !== null)
|
|
675
|
-
.map((result) => ({ id: result.id, cause: result.cause, killedBy: result.killedBy, next: result.next })),
|
|
743
|
+
.map((result) => ({ id: result.id, cause: result.cause, killedBy: result.killedBy, next: result.next, files: result.files })),
|
|
676
744
|
full: path.join(context.outDir, "results"),
|
|
677
745
|
};
|
|
678
746
|
}
|
|
747
|
+
function languageMapKind(kind) {
|
|
748
|
+
return kind === "go" || kind === "maven" || kind === "cargo" || kind === "dotnet";
|
|
749
|
+
}
|
|
750
|
+
function languageMapLabel(kind) {
|
|
751
|
+
if (kind === "go")
|
|
752
|
+
return "go line map";
|
|
753
|
+
if (kind === "maven")
|
|
754
|
+
return "java line map";
|
|
755
|
+
if (kind === "cargo")
|
|
756
|
+
return "rust line map";
|
|
757
|
+
return "csharp line map";
|
|
758
|
+
}
|
|
759
|
+
function writeLanguageMap(kind, pkg, names, dest, project) {
|
|
760
|
+
if (kind === "go")
|
|
761
|
+
return writeGoCoverage(pkg, names, dest);
|
|
762
|
+
if (kind === "maven")
|
|
763
|
+
return writeJavaCoverage(pkg, names, dest);
|
|
764
|
+
if (kind === "cargo")
|
|
765
|
+
return writeRustCoverage(pkg, names, dest);
|
|
766
|
+
if (kind === "dotnet")
|
|
767
|
+
return writeCsharpCoverage(pkg, names, dest, project);
|
|
768
|
+
return { ok: false, detail: "no line map writer" };
|
|
769
|
+
}
|
|
679
770
|
function failReport(outDir, runId, commit, error, baselineFailed, next, suiteCommand = "", baselineMs = null, notStarted = []) {
|
|
680
771
|
const summary = baselineFailed && baselineFailed.length > 0 ? `${error}: ${baselineFailed.join("; ")}` : error;
|
|
681
772
|
return {
|
|
@@ -837,11 +928,34 @@ function findTestFile(pkg, rel) {
|
|
|
837
928
|
function sha256(text) {
|
|
838
929
|
return createHash("sha256").update(text).digest("hex");
|
|
839
930
|
}
|
|
931
|
+
const BARE_SUITE = new Set(["command", "suite", "pytest", "unittest", "cobol", "make-test", "c", "cpp"]);
|
|
840
932
|
function failureId(pkg, failure) {
|
|
841
|
-
|
|
842
|
-
|
|
933
|
+
// Pytest already reports a node id (tests/test_gate.py::test_low). Prepending the
|
|
934
|
+
// file doubles it, and pytest then exits 4 and collects nothing.
|
|
935
|
+
if (pytestNodeId(failure.name))
|
|
936
|
+
return failure.name;
|
|
937
|
+
// Cargo and dotnet report a bare test name. Go reports a package path that is
|
|
938
|
+
// not a file, and JUnit reports a classname. The runnable id is the name itself.
|
|
939
|
+
// An unnamed shell suite stays ::command so a tally does not treat it as a test.
|
|
940
|
+
if (!failure.file)
|
|
941
|
+
return BARE_SUITE.has(failure.name) ? `::${failure.name}` : failure.name;
|
|
942
|
+
if (!testFileOnDisk(pkg, failure.file))
|
|
943
|
+
return failure.name;
|
|
944
|
+
const rel = path.relative(pkg, failure.file);
|
|
945
|
+
const file = !rel || rel.startsWith("..") ? path.basename(failure.file) : rel.split(path.sep).join("/");
|
|
843
946
|
return `${file}::${failure.name}`;
|
|
844
947
|
}
|
|
948
|
+
function testFileOnDisk(pkg, file) {
|
|
949
|
+
if (path.isAbsolute(file))
|
|
950
|
+
return existsSync(file);
|
|
951
|
+
return existsSync(path.join(pkg, file));
|
|
952
|
+
}
|
|
953
|
+
function pytestNodeId(name) {
|
|
954
|
+
const mark = name.indexOf("::");
|
|
955
|
+
if (mark <= 0)
|
|
956
|
+
return false;
|
|
957
|
+
return name.slice(0, mark).endsWith(".py");
|
|
958
|
+
}
|
|
845
959
|
function testArg(pkg, file, testsDir) {
|
|
846
960
|
const rel = path.relative(pkg, file);
|
|
847
961
|
if (rel && !rel.startsWith(".."))
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync, mkdirSync, realpathSync, writeFileSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
/**
|
|
5
|
+
* One instrumented test binary, then one profile per test name.
|
|
6
|
+
* A single cargo test profile does not say which test hit the line.
|
|
7
|
+
*/
|
|
8
|
+
export function writeRustCoverage(pkg, testNames, dest) {
|
|
9
|
+
const names = [...new Set(testNames.map((name) => name.trim()).filter((name) => name.length > 0 && !/\s/.test(name)))];
|
|
10
|
+
if (names.length === 0)
|
|
11
|
+
return { ok: false, detail: "rust coverage: no test names" };
|
|
12
|
+
const llvmCov = findTool("llvm-cov");
|
|
13
|
+
const llvmProf = findTool("llvm-profdata");
|
|
14
|
+
if (!llvmCov || !llvmProf)
|
|
15
|
+
return { ok: false, detail: "rust coverage: llvm tool missing" };
|
|
16
|
+
const env = { ...process.env, RUSTFLAGS: "-C instrument-coverage", CARGO_INCREMENTAL: "0" };
|
|
17
|
+
const built = spawnSync("cargo", ["test", "--no-run", "--message-format=json"], {
|
|
18
|
+
cwd: pkg,
|
|
19
|
+
encoding: "utf8",
|
|
20
|
+
env,
|
|
21
|
+
timeout: 180_000,
|
|
22
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
23
|
+
});
|
|
24
|
+
if (built.status !== 0 || built.error)
|
|
25
|
+
return { ok: false, detail: clip(built.stderr || built.stdout || built.error?.message || "cargo test --no-run failed") };
|
|
26
|
+
const exes = testExecutables(built.stdout);
|
|
27
|
+
if (exes.length === 0)
|
|
28
|
+
return { ok: false, detail: "rust coverage: no test executable" };
|
|
29
|
+
const work = path.join(path.dirname(dest), "rust-cover");
|
|
30
|
+
mkdirSync(work, { recursive: true });
|
|
31
|
+
const files = {};
|
|
32
|
+
for (const name of names) {
|
|
33
|
+
for (const exe of exes) {
|
|
34
|
+
const raw = path.join(work, `${safe(name)}-${safe(path.basename(exe))}.profraw`);
|
|
35
|
+
const ran = spawnSync(exe, [name, "--exact", "--test-threads=1"], {
|
|
36
|
+
cwd: pkg,
|
|
37
|
+
encoding: "utf8",
|
|
38
|
+
env: { ...env, LLVM_PROFILE_FILE: raw },
|
|
39
|
+
timeout: 60_000,
|
|
40
|
+
});
|
|
41
|
+
if (!existsSync(raw))
|
|
42
|
+
continue;
|
|
43
|
+
if (!new RegExp(`^test ${escapeRegExp(name)} \\.\\.\\. ok`, "m").test(`${ran.stdout}\n${ran.stderr}`))
|
|
44
|
+
continue;
|
|
45
|
+
const merged = path.join(work, `${safe(name)}-${safe(path.basename(exe))}.profdata`);
|
|
46
|
+
const merge = spawnSync(llvmProf, ["merge", "-sparse", "-o", merged, raw], { encoding: "utf8" });
|
|
47
|
+
if (merge.status !== 0)
|
|
48
|
+
continue;
|
|
49
|
+
const exported = spawnSync(llvmCov, ["export", exe, `-instr-profile=${merged}`], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
|
|
50
|
+
if (exported.status !== 0)
|
|
51
|
+
continue;
|
|
52
|
+
for (const hit of llvmHits(exported.stdout, pkg))
|
|
53
|
+
addHit(files, hit.file, hit.line, name);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (Object.keys(files).length === 0)
|
|
57
|
+
return { ok: false, detail: "rust coverage: no covered lines" };
|
|
58
|
+
writeFileSync(dest, `${JSON.stringify({ files }, null, 2)}\n`);
|
|
59
|
+
return { ok: true, detail: `${Object.keys(files).length} files` };
|
|
60
|
+
}
|
|
61
|
+
function testExecutables(stdout) {
|
|
62
|
+
const found = [];
|
|
63
|
+
for (const line of stdout.split("\n")) {
|
|
64
|
+
if (!line.startsWith("{"))
|
|
65
|
+
continue;
|
|
66
|
+
try {
|
|
67
|
+
const msg = JSON.parse(line);
|
|
68
|
+
if (msg.reason !== "compiler-artifact" || !msg.executable || !msg.profile?.test)
|
|
69
|
+
continue;
|
|
70
|
+
if (!found.includes(msg.executable))
|
|
71
|
+
found.push(msg.executable);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return found;
|
|
78
|
+
}
|
|
79
|
+
function llvmHits(stdout, pkg) {
|
|
80
|
+
const start = stdout.indexOf("{");
|
|
81
|
+
if (start < 0)
|
|
82
|
+
return [];
|
|
83
|
+
let parsed;
|
|
84
|
+
try {
|
|
85
|
+
parsed = JSON.parse(stdout.slice(start));
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return [];
|
|
89
|
+
}
|
|
90
|
+
const hits = [];
|
|
91
|
+
for (const group of parsed.data ?? []) {
|
|
92
|
+
for (const file of group.files ?? []) {
|
|
93
|
+
if (!file.filename)
|
|
94
|
+
continue;
|
|
95
|
+
const rel = underPkg(file.filename, pkg);
|
|
96
|
+
if (!rel)
|
|
97
|
+
continue;
|
|
98
|
+
for (const segment of file.segments ?? []) {
|
|
99
|
+
const line = segment[0];
|
|
100
|
+
const count = segment[2] ?? 0;
|
|
101
|
+
if (!line || count <= 0)
|
|
102
|
+
continue;
|
|
103
|
+
hits.push({ file: rel, line });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return hits;
|
|
108
|
+
}
|
|
109
|
+
function underPkg(filename, pkg) {
|
|
110
|
+
let file = filename;
|
|
111
|
+
let root = pkg;
|
|
112
|
+
try {
|
|
113
|
+
if (existsSync(filename))
|
|
114
|
+
file = realpathSync(filename);
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
// keep the path llvm reported
|
|
118
|
+
}
|
|
119
|
+
try {
|
|
120
|
+
root = realpathSync(pkg);
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
// keep the package path
|
|
124
|
+
}
|
|
125
|
+
const rel = path.relative(root, file);
|
|
126
|
+
if (!rel || rel.startsWith(".."))
|
|
127
|
+
return null;
|
|
128
|
+
return rel.split(path.sep).join("/");
|
|
129
|
+
}
|
|
130
|
+
function addHit(files, file, line, test) {
|
|
131
|
+
const lines = files[file] ?? {};
|
|
132
|
+
const key = String(line);
|
|
133
|
+
const names = lines[key] ?? [];
|
|
134
|
+
if (!names.includes(test))
|
|
135
|
+
names.push(test);
|
|
136
|
+
lines[key] = names;
|
|
137
|
+
files[file] = lines;
|
|
138
|
+
}
|
|
139
|
+
function findTool(name) {
|
|
140
|
+
const fromXcrun = spawnSync("xcrun", ["--find", name], { encoding: "utf8" });
|
|
141
|
+
const xcrunPath = fromXcrun.stdout?.trim() ?? "";
|
|
142
|
+
if (fromXcrun.status === 0 && xcrunPath && existsSync(xcrunPath))
|
|
143
|
+
return xcrunPath;
|
|
144
|
+
const which = spawnSync("sh", ["-c", `command -v ${name}`], { encoding: "utf8" });
|
|
145
|
+
const found = which.stdout?.trim() ?? "";
|
|
146
|
+
if (which.status === 0 && found)
|
|
147
|
+
return found;
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
function safe(value) {
|
|
151
|
+
return value.replace(/[^A-Za-z0-9._-]+/g, "_");
|
|
152
|
+
}
|
|
153
|
+
function escapeRegExp(value) {
|
|
154
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
155
|
+
}
|
|
156
|
+
function clip(text) {
|
|
157
|
+
const line = text.trim().split("\n").find((item) => item.trim()) ?? "rust coverage failed";
|
|
158
|
+
return line.slice(0, 240);
|
|
159
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
/** The label stays in its own file. The run report must not repeat these three values. */
|
|
3
|
+
export function readSealedLabel(file) {
|
|
4
|
+
let text = "";
|
|
5
|
+
try {
|
|
6
|
+
text = readFileSync(file, "utf8");
|
|
7
|
+
}
|
|
8
|
+
catch {
|
|
9
|
+
throw new Error("label file could not be read");
|
|
10
|
+
}
|
|
11
|
+
const bugId = field(text, "bug_id");
|
|
12
|
+
const issueId = field(text, "issue_id");
|
|
13
|
+
const expectedFail = field(text, "expected_fail");
|
|
14
|
+
if (!bugId || !issueId || !expectedFail)
|
|
15
|
+
throw new Error("label file needs bug_id, issue_id, and expected_fail");
|
|
16
|
+
return { bugId, issueId, expectedFail };
|
|
17
|
+
}
|
|
18
|
+
export function labelLeaked(body, label) {
|
|
19
|
+
return body.includes(label.bugId) || body.includes(label.issueId) || body.includes(label.expectedFail);
|
|
20
|
+
}
|
|
21
|
+
export const LABEL_LEAK_SUMMARY = "The label was copied into the report.";
|
|
22
|
+
function field(text, key) {
|
|
23
|
+
const match = text.match(new RegExp(`^${key}=(.*)$`, "m"));
|
|
24
|
+
return match?.[1]?.trim() ?? "";
|
|
25
|
+
}
|
|
@@ -30,7 +30,7 @@ export function timeoutLimitMs(baselineMs, multiple, floorMs) {
|
|
|
30
30
|
return Math.max(floor, scaled);
|
|
31
31
|
}
|
|
32
32
|
/** Kinds that collect a per-line map on the baseline. Other kinds pay the whole suite. */
|
|
33
|
-
const LINE_MAP_KINDS = new Set(["node", "pytest", "c", "go", "maven"]);
|
|
33
|
+
const LINE_MAP_KINDS = new Set(["node", "pytest", "c", "go", "maven", "cargo", "dotnet"]);
|
|
34
34
|
/** A baseline this long, with no line map, is too expensive to repeat for a large batch. */
|
|
35
35
|
export const SLOW_BASELINE_MS = 5_000;
|
|
36
36
|
/** Pending mutants above this count, on a slow unmapped suite, are not started. */
|
package/dist/mutate/suites.js
CHANGED
|
@@ -131,10 +131,12 @@ function nodeSuite(pkg) {
|
|
|
131
131
|
return { kind: "node", files, command: "node --test" };
|
|
132
132
|
}
|
|
133
133
|
function mochaSuite(pkg) {
|
|
134
|
-
|
|
135
|
-
if (files.length === 0 || !usesMocha(pkg))
|
|
134
|
+
if (!usesMocha(pkg))
|
|
136
135
|
return null;
|
|
137
|
-
|
|
136
|
+
const files = mochaFiles(pkg);
|
|
137
|
+
if (files.length === 0)
|
|
138
|
+
return null;
|
|
139
|
+
return { kind: "mocha", files, command: shown("mocha", buildMochaArgs(pkg, files)) };
|
|
138
140
|
}
|
|
139
141
|
/** A Makefile `test` target that links the project's own COBOL tests, instead of one file. */
|
|
140
142
|
function makeTestSuite(pkg) {
|
|
@@ -185,14 +187,119 @@ export function sourceCandidates(pkg) {
|
|
|
185
187
|
return walk(pkg, (rel) => SOURCE_EXT.test(rel) && (hasSegment(rel, "tests") || hasSegment(rel, "test")));
|
|
186
188
|
}
|
|
187
189
|
function pythonTests(pkg, testsDir) {
|
|
188
|
-
const
|
|
190
|
+
const configured = pytestTestPaths(pkg);
|
|
191
|
+
const roots = new Set(configured ?? [testsDir, "tests", "test"]);
|
|
189
192
|
return walk(pkg, (rel) => {
|
|
190
193
|
const base = path.posix.basename(rel);
|
|
191
|
-
if (!/^test_.*\.py$/.test(base) && !/_test\.py$/.test(base))
|
|
194
|
+
if (!/^test_.*\.py$/.test(base) && !/_test\.py$/.test(base) && !/^tests_.*\.py$/.test(base))
|
|
192
195
|
return false;
|
|
196
|
+
if (configured)
|
|
197
|
+
return configured.some((root) => rel === root || rel.startsWith(`${root}/`));
|
|
193
198
|
return rel.split("/").some((part) => roots.has(part));
|
|
194
199
|
});
|
|
195
200
|
}
|
|
201
|
+
/** Directories pytest itself would collect. A missing setting keeps the broader walk. */
|
|
202
|
+
function pytestTestPaths(pkg) {
|
|
203
|
+
const fromPyproject = readPyprojectTestPaths(path.join(pkg, "pyproject.toml"));
|
|
204
|
+
if (fromPyproject)
|
|
205
|
+
return fromPyproject;
|
|
206
|
+
for (const file of ["pytest.ini", "tox.ini", "setup.cfg"]) {
|
|
207
|
+
const found = readIniTestPaths(path.join(pkg, file));
|
|
208
|
+
if (found)
|
|
209
|
+
return found;
|
|
210
|
+
}
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
function readPyprojectTestPaths(file) {
|
|
214
|
+
if (!existsSync(file))
|
|
215
|
+
return null;
|
|
216
|
+
const text = readFileSync(file, "utf8");
|
|
217
|
+
const section = /\[tool\.pytest\.ini_options\][^\n]*\n([\s\S]*?)(?:\n\[|\s*$)/.exec(text)?.[1];
|
|
218
|
+
if (!section)
|
|
219
|
+
return null;
|
|
220
|
+
const key = /testpaths\s*=\s*([\s\S]*?)(?:\n[A-Za-z]|\s*$)/.exec(section)?.[1];
|
|
221
|
+
if (!key)
|
|
222
|
+
return null;
|
|
223
|
+
const quoted = [...key.matchAll(/"([^"]+)"|'([^']+)'/g)].map((match) => (match[1] ?? match[2]).trim()).filter(Boolean);
|
|
224
|
+
return quoted.length > 0 ? quoted : null;
|
|
225
|
+
}
|
|
226
|
+
function readIniTestPaths(file) {
|
|
227
|
+
if (!existsSync(file))
|
|
228
|
+
return null;
|
|
229
|
+
const text = readFileSync(file, "utf8");
|
|
230
|
+
const section = /\[(?:pytest|tool:pytest)\][^\n]*\n([\s\S]*?)(?:\n\[|\s*$)/.exec(text)?.[1];
|
|
231
|
+
if (!section)
|
|
232
|
+
return null;
|
|
233
|
+
const key = /testpaths\s*=\s*([\s\S]*?)(?:\n[A-Za-z]|\s*$)/.exec(section)?.[1];
|
|
234
|
+
if (!key)
|
|
235
|
+
return null;
|
|
236
|
+
const paths = key
|
|
237
|
+
.split(/\s+/)
|
|
238
|
+
.map((item) => item.trim())
|
|
239
|
+
.filter((item) => item.length > 0 && !item.startsWith("#"));
|
|
240
|
+
return paths.length > 0 ? paths : null;
|
|
241
|
+
}
|
|
242
|
+
const MOCHA_SKIP_DIR = new Set(["support", "fixtures", "helpers", "helper"]);
|
|
243
|
+
/** Mocha files the project would run: node-style names, TypeScript under test/, or describe/it scripts. */
|
|
244
|
+
function mochaFiles(pkg) {
|
|
245
|
+
return walk(pkg, (rel) => {
|
|
246
|
+
const parts = rel.split("/");
|
|
247
|
+
if (parts.some((part) => MOCHA_SKIP_DIR.has(part.toLowerCase())))
|
|
248
|
+
return false;
|
|
249
|
+
const inTest = parts.some((part) => part === "test" || part === "tests");
|
|
250
|
+
if (!inTest)
|
|
251
|
+
return false;
|
|
252
|
+
if (isNodeTest(rel))
|
|
253
|
+
return true;
|
|
254
|
+
if (parts.includes("test") && /\.tsx?$/.test(rel))
|
|
255
|
+
return true;
|
|
256
|
+
if (!/\.(?:js|mjs|cjs)$/.test(rel))
|
|
257
|
+
return false;
|
|
258
|
+
let text = "";
|
|
259
|
+
try {
|
|
260
|
+
text = readFileSync(path.join(pkg, rel), "utf8");
|
|
261
|
+
}
|
|
262
|
+
catch {
|
|
263
|
+
return false;
|
|
264
|
+
}
|
|
265
|
+
return /\bdescribe\s*\(/.test(text) || /\bit\s*\(/.test(text);
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
/** Args after the mocha binary: reporter, the project's own --require, and files when package.json has no mocha.spec. */
|
|
269
|
+
export function buildMochaArgs(pkg, files) {
|
|
270
|
+
const config = readMochaConfig(pkg);
|
|
271
|
+
const args = ["--reporter", "json", "--timeout", "20000"];
|
|
272
|
+
const fromConfig = new Set((config?.require ?? []).filter((name) => name !== "esm"));
|
|
273
|
+
for (const name of scriptRequires(pkg)) {
|
|
274
|
+
if (name === "esm" || fromConfig.has(name))
|
|
275
|
+
continue;
|
|
276
|
+
args.push("--require", name);
|
|
277
|
+
}
|
|
278
|
+
const hasSpec = Boolean(config?.spec && config.spec.length > 0);
|
|
279
|
+
if (!hasSpec)
|
|
280
|
+
args.push(...files);
|
|
281
|
+
return args;
|
|
282
|
+
}
|
|
283
|
+
function scriptRequires(pkg) {
|
|
284
|
+
const file = path.join(pkg, "package.json");
|
|
285
|
+
if (!existsSync(file))
|
|
286
|
+
return [];
|
|
287
|
+
let script = "";
|
|
288
|
+
try {
|
|
289
|
+
const body = JSON.parse(readFileSync(file, "utf8"));
|
|
290
|
+
script = body.scripts?.test ?? "";
|
|
291
|
+
}
|
|
292
|
+
catch {
|
|
293
|
+
return [];
|
|
294
|
+
}
|
|
295
|
+
const found = [];
|
|
296
|
+
for (const match of script.matchAll(/(?:^|\s)--require(?:=|\s+)(\S+)/g)) {
|
|
297
|
+
const name = match[1].replace(/^['"]|['"]$/g, "");
|
|
298
|
+
if (name && name !== "esm" && !found.includes(name))
|
|
299
|
+
found.push(name);
|
|
300
|
+
}
|
|
301
|
+
return found;
|
|
302
|
+
}
|
|
196
303
|
function isPytest(pkg, testsDir, files) {
|
|
197
304
|
if (existsSync(path.join(pkg, "pytest.ini")) || existsSync(path.join(pkg, "conftest.py")))
|
|
198
305
|
return true;
|
|
@@ -224,6 +331,11 @@ function tag(result, command, wholeSuite, selectedTests) {
|
|
|
224
331
|
function shown(bin, args) {
|
|
225
332
|
return [bin, ...args.map((arg) => (/\s/.test(arg) ? JSON.stringify(arg) : arg))].join(" ");
|
|
226
333
|
}
|
|
334
|
+
function shellQuote(value) {
|
|
335
|
+
if (/^[A-Za-z0-9_:.]+$/.test(value))
|
|
336
|
+
return value;
|
|
337
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
338
|
+
}
|
|
227
339
|
async function launch(pkg, spec, files, pattern, names, timeoutMs, env, hooks) {
|
|
228
340
|
const selection = names && names.length > 0 ? names : namesFromPattern(pattern);
|
|
229
341
|
const wantsNames = selection.length > 0;
|
|
@@ -256,12 +368,15 @@ async function launch(pkg, spec, files, pattern, names, timeoutMs, env, hooks) {
|
|
|
256
368
|
return tag(runPython(pkg, files, k, timeoutMs, env, "py-pytest-reporter.py", plan.nodeIds), command, false, selection);
|
|
257
369
|
}
|
|
258
370
|
if (spec.kind === "cargo") {
|
|
259
|
-
if (selection.length
|
|
260
|
-
return tag(spawnCollected("cargo", ["test", "--", "--test-threads=1"], pkg, env, timeoutMs), "cargo test -- --test-threads=1",
|
|
371
|
+
if (selection.length === 0) {
|
|
372
|
+
return tag(spawnCollected("cargo", ["test", "--", "--test-threads=1"], pkg, env, timeoutMs), "cargo test -- --test-threads=1", false, selection);
|
|
373
|
+
}
|
|
374
|
+
if (selection.length === 1) {
|
|
375
|
+
const args = ["test", selection[0], "--", "--exact", "--test-threads=1"];
|
|
376
|
+
return tag(spawnCollected("cargo", args, pkg, env, timeoutMs), shown("cargo", args), false, selection);
|
|
261
377
|
}
|
|
262
|
-
const
|
|
263
|
-
|
|
264
|
-
return tag(spawnCollected("cargo", args, pkg, env, timeoutMs), shown("cargo", args), false, selection);
|
|
378
|
+
const script = selection.map((name) => `cargo test ${shellQuote(name)} -- --exact --test-threads=1`).join(" && ");
|
|
379
|
+
return tag(spawnCollected("sh", ["-c", script], pkg, env, timeoutMs), script, false, selection);
|
|
265
380
|
}
|
|
266
381
|
if (spec.kind === "go") {
|
|
267
382
|
const filter = goRunFilter(names, pattern);
|
|
@@ -320,6 +435,10 @@ function pytestLeaf(name) {
|
|
|
320
435
|
function pytestPlan(selection) {
|
|
321
436
|
if (selection.length === 0)
|
|
322
437
|
return { pattern: null, nodeIds: null };
|
|
438
|
+
// A doubled keep id (file.py::tests/file.py::test_low) has more than one ::.
|
|
439
|
+
// Reducing it to the leaf would run -k test_low and still kill the mutant.
|
|
440
|
+
if (selection.some((name) => name.split("::").length > 2))
|
|
441
|
+
return { pattern: null, nodeIds: selection };
|
|
323
442
|
const leaves = selection.map(pytestLeaf);
|
|
324
443
|
if (leaves.every((leaf) => PYTEST_KEYWORD.test(leaf)))
|
|
325
444
|
return { pattern: leaves.join(" or "), nodeIds: null };
|
|
@@ -349,9 +468,9 @@ function goRunFilter(names, pattern) {
|
|
|
349
468
|
function surefireTest(name) {
|
|
350
469
|
const leaf = name.split("::").pop() ?? name;
|
|
351
470
|
const dot = leaf.lastIndexOf(".");
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
return
|
|
471
|
+
const body = dot <= 0 ? leaf : `${leaf.slice(0, dot)}#${leaf.slice(dot + 1)}`;
|
|
472
|
+
// testShut[0] collects nothing. method(Parser)[1] keeps the parameter types.
|
|
473
|
+
return body.replace(/\[[^\]]*\]$/, "");
|
|
355
474
|
}
|
|
356
475
|
function escapeRegExp(value) {
|
|
357
476
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
@@ -436,20 +555,15 @@ async function runMocha(pkg, files, timeoutMs, env) {
|
|
|
436
555
|
if (prepared && (prepared.code !== 0 || prepared.timedOut))
|
|
437
556
|
return prepared;
|
|
438
557
|
const config = readMochaConfig(pkg);
|
|
439
|
-
const args = [mocha,
|
|
440
|
-
if (config) {
|
|
558
|
+
const args = [mocha, ...buildMochaArgs(pkg, files)];
|
|
559
|
+
if (config && config.require.includes("esm")) {
|
|
441
560
|
// 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.
|
|
442
561
|
const requires = config.require.filter((name) => name !== "esm");
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
args.push("--package", override);
|
|
449
|
-
}
|
|
450
|
-
}
|
|
451
|
-
else {
|
|
452
|
-
args.push(...files);
|
|
562
|
+
const dir = path.join(pkg, ".probatio-suite");
|
|
563
|
+
mkdirSync(dir, { recursive: true });
|
|
564
|
+
const override = path.join(dir, "mocha-package.json");
|
|
565
|
+
writeFileSync(override, `${JSON.stringify({ mocha: { extension: config.extension, require: requires, spec: config.spec } }, null, 2)}\n`);
|
|
566
|
+
args.push("--package", override);
|
|
453
567
|
}
|
|
454
568
|
return spawnCollected(process.execPath, args, pkg, env, timeoutMs);
|
|
455
569
|
}
|
|
@@ -1038,6 +1152,9 @@ function adapt(kind, pkg, stdout, stderr, code) {
|
|
|
1038
1152
|
return parseMocha(stdout);
|
|
1039
1153
|
if (kind === "make-test" || kind === "command") {
|
|
1040
1154
|
const text = `${stdout}\n${stderr}`;
|
|
1155
|
+
const ctest = parseCtest(text);
|
|
1156
|
+
if (ctest)
|
|
1157
|
+
return ctest;
|
|
1041
1158
|
if (/Tests run:\s*\d+/.test(text))
|
|
1042
1159
|
return parseMakeTest(text);
|
|
1043
1160
|
if (kind === "make-test")
|
|
@@ -1157,6 +1274,32 @@ function parseMocha(stdout) {
|
|
|
1157
1274
|
return null;
|
|
1158
1275
|
}
|
|
1159
1276
|
}
|
|
1277
|
+
/** CTest prints `Test #N: name` and `N - name (Failed)`. Those names are the kill, not `::command`. */
|
|
1278
|
+
function parseCtest(text) {
|
|
1279
|
+
if (!/Test #\d+:|The following tests FAILED:/.test(text))
|
|
1280
|
+
return null;
|
|
1281
|
+
const names = [];
|
|
1282
|
+
const failed = [];
|
|
1283
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
1284
|
+
const line = raw.trim();
|
|
1285
|
+
const ran = /^\d+\/\d+\s+Test\s+#\d+:\s+(\S+)/.exec(line);
|
|
1286
|
+
if (ran && !names.includes(ran[1]))
|
|
1287
|
+
names.push(ran[1]);
|
|
1288
|
+
if (ran && /\*\*\*Failed/.test(line) && !failed.some((item) => item.name === ran[1])) {
|
|
1289
|
+
failed.push({ name: ran[1], file: "", line: 0 });
|
|
1290
|
+
}
|
|
1291
|
+
const listed = /^\d+\s+-\s+(\S+)\s+\(Failed\)/.exec(line);
|
|
1292
|
+
if (listed && !failed.some((item) => item.name === listed[1]))
|
|
1293
|
+
failed.push({ name: listed[1], file: "", line: 0 });
|
|
1294
|
+
}
|
|
1295
|
+
if (names.length === 0 && failed.length === 0)
|
|
1296
|
+
return null;
|
|
1297
|
+
for (const item of failed) {
|
|
1298
|
+
if (!names.includes(item.name))
|
|
1299
|
+
names.push(item.name);
|
|
1300
|
+
}
|
|
1301
|
+
return { tests: names.length, pass: Math.max(names.length - failed.length, 0), fail: failed.length, failed, names };
|
|
1302
|
+
}
|
|
1160
1303
|
function parseMakeTest(text) {
|
|
1161
1304
|
const run = text.match(/Tests run:\s*(\d+)/);
|
|
1162
1305
|
if (!run)
|
package/dist/mutate/tally.js
CHANGED
package/dist/verify/change.js
CHANGED
|
@@ -7,7 +7,7 @@ import { findInSource } from "../mutate/find.js";
|
|
|
7
7
|
import { mutantId } from "../mutate/ids.js";
|
|
8
8
|
import { forwardDiff, git } from "../mutate/patch.js";
|
|
9
9
|
import { runMutants } from "../mutate/run.js";
|
|
10
|
-
import { leadSummary } from "../mutate/suite-decision.js";
|
|
10
|
+
import { WHOLE_PROGRAM_TEST, leadSummary } from "../mutate/suite-decision.js";
|
|
11
11
|
import { discoverSuite } from "../mutate/suites.js";
|
|
12
12
|
/** Diff-scoped mutants, the tests that can see the edit, and golden contract rows that changed. */
|
|
13
13
|
export async function verifyChange(options) {
|
|
@@ -102,15 +102,17 @@ export async function verifyChange(options) {
|
|
|
102
102
|
other = results
|
|
103
103
|
.filter((item) => item.outcome !== "killed" && item.outcome !== "survived")
|
|
104
104
|
.map((item) => ({ id: item.id, outcome: item.outcome, file: byId.get(item.id)?.file ?? "", line: byId.get(item.id)?.line ?? 0 }));
|
|
105
|
+
const executed = executedNames(results);
|
|
106
|
+
const envelope = bodyOf(options.outDir, true, affected, ran, notRan, caught, missed, other, goldenContract, notRun, suiteCommand, executed);
|
|
107
|
+
writeFileSync(path.join(options.outDir, "verify.json"), `${JSON.stringify(envelope, null, 2)}\n`);
|
|
108
|
+
return envelope;
|
|
105
109
|
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
}
|
|
109
|
-
const envelope = bodyOf(options.outDir, true, affected, ran, notRan, caught, missed, other, goldenContract, notRun, suiteCommand);
|
|
110
|
+
notRun.push(...written);
|
|
111
|
+
const envelope = bodyOf(options.outDir, true, affected, ran, notRan, caught, missed, other, goldenContract, notRun, suiteCommand, []);
|
|
110
112
|
writeFileSync(path.join(options.outDir, "verify.json"), `${JSON.stringify(envelope, null, 2)}\n`);
|
|
111
113
|
return envelope;
|
|
112
114
|
}
|
|
113
|
-
function bodyOf(outDir, ok, affected, ran, notRan, caught, missed, other, goldenContract, notRun, suiteCommand) {
|
|
115
|
+
function bodyOf(outDir, ok, affected, ran, notRan, caught, missed, other, goldenContract, notRun, suiteCommand, executed) {
|
|
114
116
|
const uncovered = other.filter((item) => item.outcome === "no coverage");
|
|
115
117
|
const summary = ok
|
|
116
118
|
? leadSummary(uncovered.length, missed.length, "missed", `${caught.length} caught, ${notRun.length} not run, ${affected.length} affected tests, ${goldenContract.length} golden contract changes.`)
|
|
@@ -127,6 +129,8 @@ function bodyOf(outDir, ok, affected, ran, notRan, caught, missed, other, golden
|
|
|
127
129
|
ran,
|
|
128
130
|
notRan,
|
|
129
131
|
suiteCommand,
|
|
132
|
+
executed,
|
|
133
|
+
confirmed: false,
|
|
130
134
|
caught,
|
|
131
135
|
missed,
|
|
132
136
|
other,
|
|
@@ -158,6 +162,8 @@ function fail(outDir, summary, extra) {
|
|
|
158
162
|
ran: extra?.ran ?? [],
|
|
159
163
|
notRan: extra?.notRan ?? [],
|
|
160
164
|
suiteCommand: extra?.suiteCommand ?? "",
|
|
165
|
+
executed: [],
|
|
166
|
+
confirmed: false,
|
|
161
167
|
caught: [],
|
|
162
168
|
missed: [],
|
|
163
169
|
other: [],
|
|
@@ -173,14 +179,17 @@ function readOutcomes(outDir, ids) {
|
|
|
173
179
|
for (const id of ids) {
|
|
174
180
|
try {
|
|
175
181
|
const result = JSON.parse(readFileSync(path.join(file, `${id}.json`), "utf8"));
|
|
176
|
-
out.push({ id, outcome: result.outcome });
|
|
182
|
+
out.push({ id, outcome: result.outcome, selectedTests: result.selectedTests ?? [] });
|
|
177
183
|
}
|
|
178
184
|
catch {
|
|
179
|
-
out.push({ id, outcome: "missing" });
|
|
185
|
+
out.push({ id, outcome: "missing", selectedTests: [] });
|
|
180
186
|
}
|
|
181
187
|
}
|
|
182
188
|
return out;
|
|
183
189
|
}
|
|
190
|
+
function executedNames(results) {
|
|
191
|
+
return [...new Set(results.flatMap((item) => item.selectedTests))].filter((name) => name.length > 0 && name !== WHOLE_PROGRAM_TEST).sort();
|
|
192
|
+
}
|
|
184
193
|
function goldenDiffs(repo, base, commit, files) {
|
|
185
194
|
const found = [];
|
|
186
195
|
for (const file of files) {
|