probatio 0.0.3 → 0.0.5
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/LICENSE +21 -0
- package/README.md +1 -1
- package/dist/cli.js +22 -2
- package/dist/mutate/csharp-coverage.js +161 -0
- package/dist/mutate/run.js +118 -18
- 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 +135 -23
- package/package.json +2 -1
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Aron Amos
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Probatio
|
|
2
2
|
|
|
3
|
-
Probatio (Latin: a testing, a proof) is a free
|
|
3
|
+
Probatio (Latin: a testing, a proof) is a free testing toolkit under the MIT license, built for AI agents
|
|
4
4
|
(Claude, Grok, ChatGPT, others), working alone or as swarms. Every claim it makes is checkable;
|
|
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.
|
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,7 @@ 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) {
|
|
133
136
|
const packageDir = path.resolve(requireText(flags, "package"));
|
|
134
137
|
const patchDirs = texts(flags, "patches").map((dir) => path.resolve(dir));
|
|
135
138
|
if (patchDirs.length === 0)
|
|
@@ -168,6 +171,7 @@ async function runCommand(flags) {
|
|
|
168
171
|
timeoutMultiple: int(flags, "timeout-multiple") ?? 5,
|
|
169
172
|
timeoutFloorMs: int(flags, "timeout-floor-ms") ?? 20_000,
|
|
170
173
|
budgetMs: int(flags, "budget-ms") ?? null,
|
|
174
|
+
hide,
|
|
171
175
|
onProgress: (line) => process.stderr.write(`${line}\n`),
|
|
172
176
|
});
|
|
173
177
|
const resume = report.budgetHit
|
|
@@ -203,6 +207,22 @@ async function runCommand(flags) {
|
|
|
203
207
|
function tallyCommand(flags) {
|
|
204
208
|
return tallyRun(path.resolve(requireText(flags, "out")));
|
|
205
209
|
}
|
|
210
|
+
async function sealedCommand(flags) {
|
|
211
|
+
const label = readSealedLabel(path.resolve(requireText(flags, "label")));
|
|
212
|
+
const hide = texts(flags, "hide");
|
|
213
|
+
const report = await runCommand(flags, hide);
|
|
214
|
+
const body = JSON.stringify(report);
|
|
215
|
+
if (!labelLeaked(body, label))
|
|
216
|
+
return report;
|
|
217
|
+
return {
|
|
218
|
+
schemaVersion: SCHEMA_VERSION,
|
|
219
|
+
ok: false,
|
|
220
|
+
command: "mutate.sealed",
|
|
221
|
+
summary: LABEL_LEAK_SUMMARY,
|
|
222
|
+
next: "Keep the label in its own file. Run the same command again.",
|
|
223
|
+
nextCall: null,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
206
226
|
async function ledgerCommand(flags) {
|
|
207
227
|
const packageDir = path.resolve(requireText(flags, "package"));
|
|
208
228
|
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
|
+
}
|
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
|
}
|
|
@@ -541,14 +542,29 @@ async function createWorktrees(repo, packageDir, runId, commit, workers, outDir)
|
|
|
541
542
|
dirs.push(dir);
|
|
542
543
|
}
|
|
543
544
|
writeFileSync(path.join(outDir, "worktrees.json"), `${JSON.stringify(dirs)}\n`);
|
|
544
|
-
const
|
|
545
|
+
const sources = nodeModuleSources(repo, packageDir);
|
|
545
546
|
for (const dir of dirs) {
|
|
546
|
-
const
|
|
547
|
-
|
|
548
|
-
|
|
547
|
+
const pkgRoot = packageIn(dir, repo, packageDir);
|
|
548
|
+
for (const source of sources) {
|
|
549
|
+
const dest = path.join(pkgRoot, source.rel);
|
|
550
|
+
if (existsSync(dest))
|
|
551
|
+
continue;
|
|
552
|
+
mkdirSync(path.dirname(dest), { recursive: true });
|
|
553
|
+
symlinkSync(source.abs, dest, "dir");
|
|
554
|
+
}
|
|
549
555
|
}
|
|
550
556
|
return { dirs };
|
|
551
557
|
}
|
|
558
|
+
function hidePaths(dirs, repo, packageDir, hide) {
|
|
559
|
+
for (const dir of dirs) {
|
|
560
|
+
const pkg = packageIn(dir, repo, packageDir);
|
|
561
|
+
for (const rel of hide) {
|
|
562
|
+
if (!rel || path.isAbsolute(rel))
|
|
563
|
+
continue;
|
|
564
|
+
rmSync(path.join(pkg, rel), { force: true });
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
}
|
|
552
568
|
function removeWorktrees(repo, dirs, outDir) {
|
|
553
569
|
for (const dir of dirs) {
|
|
554
570
|
git(repo, ["worktree", "remove", "--force", dir]);
|
|
@@ -569,11 +585,30 @@ function recordedWorktrees(outDir) {
|
|
|
569
585
|
return [];
|
|
570
586
|
}
|
|
571
587
|
}
|
|
572
|
-
/**
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
588
|
+
/**
|
|
589
|
+
* Installed dependency directories to mirror into a fresh worktree.
|
|
590
|
+
* A package can keep node_modules at its root or under a nested example.
|
|
591
|
+
* A run that starts from a worktree looks beside the main checkout too.
|
|
592
|
+
*/
|
|
593
|
+
function nodeModuleSources(repo, packageDir) {
|
|
594
|
+
const roots = [packageDir];
|
|
595
|
+
const main = mainCheckoutPackage(repo, packageDir);
|
|
596
|
+
if (main && path.resolve(main) !== path.resolve(packageDir))
|
|
597
|
+
roots.push(main);
|
|
598
|
+
const found = [];
|
|
599
|
+
const seen = new Set();
|
|
600
|
+
for (const root of roots) {
|
|
601
|
+
for (const abs of findNodeModules(root)) {
|
|
602
|
+
const rel = path.relative(root, abs);
|
|
603
|
+
if (seen.has(rel))
|
|
604
|
+
continue;
|
|
605
|
+
seen.add(rel);
|
|
606
|
+
found.push({ rel, abs });
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
return found;
|
|
610
|
+
}
|
|
611
|
+
function mainCheckoutPackage(repo, packageDir) {
|
|
577
612
|
const common = git(repo, ["rev-parse", "--path-format=absolute", "--git-common-dir"]);
|
|
578
613
|
if (common.status !== 0)
|
|
579
614
|
return null;
|
|
@@ -583,8 +618,40 @@ function nodeModulesOf(repo, packageDir) {
|
|
|
583
618
|
const rel = path.relative(repo, packageDir);
|
|
584
619
|
if (rel.startsWith(".."))
|
|
585
620
|
return null;
|
|
586
|
-
|
|
587
|
-
|
|
621
|
+
return path.join(path.dirname(gitDir), rel);
|
|
622
|
+
}
|
|
623
|
+
function findNodeModules(root) {
|
|
624
|
+
const out = [];
|
|
625
|
+
const visit = (dir) => {
|
|
626
|
+
let entries;
|
|
627
|
+
try {
|
|
628
|
+
entries = readdirSync(dir);
|
|
629
|
+
}
|
|
630
|
+
catch {
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
for (const name of entries) {
|
|
634
|
+
if (name === ".git" || name === "target" || name === "dist")
|
|
635
|
+
continue;
|
|
636
|
+
const full = path.join(dir, name);
|
|
637
|
+
let info;
|
|
638
|
+
try {
|
|
639
|
+
info = statSync(full);
|
|
640
|
+
}
|
|
641
|
+
catch {
|
|
642
|
+
continue;
|
|
643
|
+
}
|
|
644
|
+
if (!info.isDirectory())
|
|
645
|
+
continue;
|
|
646
|
+
if (name === "node_modules") {
|
|
647
|
+
out.push(full);
|
|
648
|
+
continue;
|
|
649
|
+
}
|
|
650
|
+
visit(full);
|
|
651
|
+
}
|
|
652
|
+
};
|
|
653
|
+
visit(root);
|
|
654
|
+
return out;
|
|
588
655
|
}
|
|
589
656
|
function packageIn(worktree, repo, packageDir) {
|
|
590
657
|
const rel = path.relative(repo, packageDir);
|
|
@@ -672,10 +739,33 @@ function reportOf(results, context) {
|
|
|
672
739
|
flakyIds: results.filter((result) => result.outcome === "flaky").slice(0, 10).map((result) => result.id),
|
|
673
740
|
kills: results
|
|
674
741
|
.filter((result) => result.outcome === "killed" && result.cause !== null)
|
|
675
|
-
.map((result) => ({ id: result.id, cause: result.cause, killedBy: result.killedBy, next: result.next })),
|
|
742
|
+
.map((result) => ({ id: result.id, cause: result.cause, killedBy: result.killedBy, next: result.next, files: result.files })),
|
|
676
743
|
full: path.join(context.outDir, "results"),
|
|
677
744
|
};
|
|
678
745
|
}
|
|
746
|
+
function languageMapKind(kind) {
|
|
747
|
+
return kind === "go" || kind === "maven" || kind === "cargo" || kind === "dotnet";
|
|
748
|
+
}
|
|
749
|
+
function languageMapLabel(kind) {
|
|
750
|
+
if (kind === "go")
|
|
751
|
+
return "go line map";
|
|
752
|
+
if (kind === "maven")
|
|
753
|
+
return "java line map";
|
|
754
|
+
if (kind === "cargo")
|
|
755
|
+
return "rust line map";
|
|
756
|
+
return "csharp line map";
|
|
757
|
+
}
|
|
758
|
+
function writeLanguageMap(kind, pkg, names, dest, project) {
|
|
759
|
+
if (kind === "go")
|
|
760
|
+
return writeGoCoverage(pkg, names, dest);
|
|
761
|
+
if (kind === "maven")
|
|
762
|
+
return writeJavaCoverage(pkg, names, dest);
|
|
763
|
+
if (kind === "cargo")
|
|
764
|
+
return writeRustCoverage(pkg, names, dest);
|
|
765
|
+
if (kind === "dotnet")
|
|
766
|
+
return writeCsharpCoverage(pkg, names, dest, project);
|
|
767
|
+
return { ok: false, detail: "no line map writer" };
|
|
768
|
+
}
|
|
679
769
|
function failReport(outDir, runId, commit, error, baselineFailed, next, suiteCommand = "", baselineMs = null, notStarted = []) {
|
|
680
770
|
const summary = baselineFailed && baselineFailed.length > 0 ? `${error}: ${baselineFailed.join("; ")}` : error;
|
|
681
771
|
return {
|
|
@@ -838,10 +928,20 @@ function sha256(text) {
|
|
|
838
928
|
return createHash("sha256").update(text).digest("hex");
|
|
839
929
|
}
|
|
840
930
|
function failureId(pkg, failure) {
|
|
931
|
+
// Pytest already reports a node id (tests/test_gate.py::test_low). Prepending the
|
|
932
|
+
// file doubles it, and pytest then exits 4 and collects nothing.
|
|
933
|
+
if (pytestNodeId(failure.name))
|
|
934
|
+
return failure.name;
|
|
841
935
|
const rel = failure.file ? path.relative(pkg, failure.file) : "";
|
|
842
936
|
const file = !rel || rel.startsWith("..") ? path.basename(failure.file || "") : rel.split(path.sep).join("/");
|
|
843
937
|
return `${file}::${failure.name}`;
|
|
844
938
|
}
|
|
939
|
+
function pytestNodeId(name) {
|
|
940
|
+
const mark = name.indexOf("::");
|
|
941
|
+
if (mark <= 0)
|
|
942
|
+
return false;
|
|
943
|
+
return name.slice(0, mark).endsWith(".py");
|
|
944
|
+
}
|
|
845
945
|
function testArg(pkg, file, testsDir) {
|
|
846
946
|
const rel = path.relative(pkg, file);
|
|
847
947
|
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);
|
|
@@ -357,8 +472,10 @@ function escapeRegExp(value) {
|
|
|
357
472
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
358
473
|
}
|
|
359
474
|
function runShell(pkg, command, timeoutMs, env) {
|
|
475
|
+
// A command can be pytest. Baseline writes a .pyc, and a same-size patch in that same second leaves it valid.
|
|
476
|
+
clearBytecode(pkg);
|
|
360
477
|
const child = /\b(java|javac|mvn)\b/.test(command) ? javaEnv(env) : env;
|
|
361
|
-
return spawnCollected("sh", ["-c", command], pkg, child, timeoutMs);
|
|
478
|
+
return spawnCollected("sh", ["-c", command], pkg, { ...child, PYTHONDONTWRITEBYTECODE: "1" }, timeoutMs);
|
|
362
479
|
}
|
|
363
480
|
async function runNode(pkg, files, pattern, timeoutMs, env, hooks) {
|
|
364
481
|
const tsx = path.join(pkg, "node_modules", ".bin", "tsx");
|
|
@@ -434,20 +551,15 @@ async function runMocha(pkg, files, timeoutMs, env) {
|
|
|
434
551
|
if (prepared && (prepared.code !== 0 || prepared.timedOut))
|
|
435
552
|
return prepared;
|
|
436
553
|
const config = readMochaConfig(pkg);
|
|
437
|
-
const args = [mocha,
|
|
438
|
-
if (config) {
|
|
554
|
+
const args = [mocha, ...buildMochaArgs(pkg, files)];
|
|
555
|
+
if (config && config.require.includes("esm")) {
|
|
439
556
|
// 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.
|
|
440
557
|
const requires = config.require.filter((name) => name !== "esm");
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
args.push("--package", override);
|
|
447
|
-
}
|
|
448
|
-
}
|
|
449
|
-
else {
|
|
450
|
-
args.push(...files);
|
|
558
|
+
const dir = path.join(pkg, ".probatio-suite");
|
|
559
|
+
mkdirSync(dir, { recursive: true });
|
|
560
|
+
const override = path.join(dir, "mocha-package.json");
|
|
561
|
+
writeFileSync(override, `${JSON.stringify({ mocha: { extension: config.extension, require: requires, spec: config.spec } }, null, 2)}\n`);
|
|
562
|
+
args.push("--package", override);
|
|
451
563
|
}
|
|
452
564
|
return spawnCollected(process.execPath, args, pkg, env, timeoutMs);
|
|
453
565
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "probatio",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.5",
|
|
4
4
|
"description": "Mutation runner for AI agents. Scores a test suite by the bugs it catches.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
"url": "git+https://github.com/IronAdamant/Probatio.git"
|
|
15
15
|
},
|
|
16
16
|
"homepage": "https://github.com/IronAdamant/Probatio#readme",
|
|
17
|
+
"license": "MIT",
|
|
17
18
|
"bugs": {
|
|
18
19
|
"url": "https://github.com/IronAdamant/Probatio/issues"
|
|
19
20
|
},
|