probatio 0.1.2 → 0.2.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 +109 -17
- package/dist/cli.js +122 -55
- package/dist/contract.js +7 -5
- package/dist/ledger/build.js +14 -0
- package/dist/mcp.js +98 -26
- package/dist/mutate/child-lines.mjs +23 -18
- package/dist/mutate/coverage-map.js +75 -18
- package/dist/mutate/fail-reporter.mjs +21 -0
- package/dist/mutate/generate.js +25 -4
- package/dist/mutate/load-failure.mjs +40 -0
- package/dist/mutate/mocha-coverage.cjs +7 -1
- package/dist/mutate/node-batch-loader.mjs +15 -1
- package/dist/mutate/node-batch.mjs +40 -3
- package/dist/mutate/node-coverage.mjs +7 -32
- package/dist/mutate/patch.js +2 -1
- package/dist/mutate/precise-lines.mjs +147 -31
- package/dist/mutate/probatio-jacoco-run.java +2 -2
- package/dist/mutate/project-config.js +58 -0
- package/dist/mutate/run.js +192 -32
- package/dist/mutate/rust-coverage.js +4 -19
- package/dist/mutate/sealed.js +94 -1
- package/dist/mutate/suite-decision.js +2 -1
- package/dist/mutate/suites.js +136 -163
- package/dist/mutate/tally.js +85 -20
- package/dist/node-version.js +13 -0
- package/dist/swarm/check-kill.js +14 -7
- package/dist/swarm/queue.js +13 -7
- package/dist/verify/change.js +5 -0
- package/package.json +6 -4
|
@@ -1,34 +1,39 @@
|
|
|
1
|
-
// Nameless line dump for a
|
|
1
|
+
// Nameless line dump for a node child of a test. This is not a node:test process and it
|
|
2
2
|
// does not write a coverage shard. The parent test's afterEach reads the dump.
|
|
3
|
+
// The test-process collector puts this file in NODE_OPTIONS, so the child needs no import.
|
|
4
|
+
// The dump is written on `exit`, which also runs after process.exit(), so it is synchronous.
|
|
3
5
|
import { randomBytes } from "node:crypto"
|
|
4
6
|
import { mkdirSync, writeFileSync } from "node:fs"
|
|
5
7
|
import path from "node:path"
|
|
6
|
-
import {
|
|
8
|
+
import { openSyncLineSampler } from "./precise-lines.mjs"
|
|
7
9
|
|
|
8
10
|
const mapPath = process.env.PROBATIO_COVERAGE_MAP
|
|
9
|
-
if (mapPath) {
|
|
11
|
+
if (mapPath && !globalThis.__probatioChildLines) {
|
|
12
|
+
globalThis.__probatioChildLines = true
|
|
10
13
|
let sampler = null
|
|
11
14
|
try {
|
|
12
|
-
sampler =
|
|
15
|
+
sampler = openSyncLineSampler(
|
|
16
|
+
(rel) => rel.endsWith("child-lines.mjs") || rel.endsWith("precise-lines.mjs"),
|
|
17
|
+
process.env.PROBATIO_PACKAGE_ROOT || process.cwd(),
|
|
18
|
+
)
|
|
13
19
|
} catch (err) {
|
|
14
20
|
writeFileSync(`${mapPath}.error`, `${err instanceof Error ? err.message : String(err)}\n`)
|
|
15
21
|
}
|
|
16
22
|
if (sampler) {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
const
|
|
23
|
+
process.on("exit", () => {
|
|
24
|
+
// A node:test process that loaded this through an inherited NODE_OPTIONS has its own
|
|
25
|
+
// hooks. Its hits are already named. A nameless dump would land on the wrong test.
|
|
26
|
+
if (globalThis.__probatioTestHooks) return
|
|
27
|
+
try {
|
|
28
|
+
const hits = sampler.positiveLines()
|
|
29
|
+
// A child started by a mapped test writes where that test's process reads.
|
|
30
|
+
// A child that was not (a script that imports this file itself) uses the shared dir.
|
|
31
|
+
const dir = process.env.PROBATIO_CHILD_DIR || `${mapPath}.children`
|
|
23
32
|
mkdirSync(dir, { recursive: true })
|
|
24
|
-
writeFileSync(
|
|
25
|
-
|
|
26
|
-
JSON.stringify({ hits }),
|
|
27
|
-
)
|
|
28
|
-
}).catch((err) => {
|
|
33
|
+
writeFileSync(path.join(dir, `${process.pid}-${randomBytes(4).toString("hex")}.json`), JSON.stringify({ hits }))
|
|
34
|
+
} catch (err) {
|
|
29
35
|
writeFileSync(`${mapPath}.error`, `${err instanceof Error ? err.message : String(err)}\n`)
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
process.on("beforeExit", dump)
|
|
36
|
+
}
|
|
37
|
+
})
|
|
33
38
|
}
|
|
34
39
|
}
|
|
@@ -104,17 +104,10 @@ export function writeLlvmCoverage(pkg, profileDir, binary, dest) {
|
|
|
104
104
|
return { ok: false, detail: "no profraw" };
|
|
105
105
|
if (!binary)
|
|
106
106
|
return { ok: false, detail: "no instrumented binary" };
|
|
107
|
-
const llvmCov = findTool("llvm-cov");
|
|
108
|
-
const llvmProf = findTool("llvm-profdata");
|
|
109
|
-
if (!llvmCov || !llvmProf)
|
|
110
|
-
return { ok: false, detail: `llvm tool missing (${llvmCov ? "llvm-profdata" : "llvm-cov"})` };
|
|
111
107
|
const merged = path.join(profileDir, "baseline.profdata");
|
|
112
|
-
const
|
|
113
|
-
if (
|
|
114
|
-
return
|
|
115
|
-
const exported = spawnSync(llvmCov, ["export", binary, `-instr-profile=${merged}`], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
|
|
116
|
-
if (exported.status !== 0)
|
|
117
|
-
return { ok: false, detail: (exported.stderr || exported.stdout || "llvm-cov failed").trim() };
|
|
108
|
+
const exported = exportLlvmProfile(binary, raws, merged);
|
|
109
|
+
if (!exported.ok)
|
|
110
|
+
return exported;
|
|
118
111
|
const map = llvmExportToMap(exported.stdout, pkg);
|
|
119
112
|
if (!map)
|
|
120
113
|
return { ok: false, detail: "llvm-cov export was not a coverage map" };
|
|
@@ -159,14 +152,78 @@ function llvmExportToMap(stdout, pkg) {
|
|
|
159
152
|
return null;
|
|
160
153
|
return { files };
|
|
161
154
|
}
|
|
162
|
-
|
|
155
|
+
/**
|
|
156
|
+
* clang and rustc write different profraw versions. Each pair is one LLVM install.
|
|
157
|
+
* The first pair that can merge the profile wins.
|
|
158
|
+
*/
|
|
159
|
+
export function llvmToolsPresent() {
|
|
160
|
+
return llvmToolPairs().length > 0;
|
|
161
|
+
}
|
|
162
|
+
export function exportLlvmProfile(binary, raws, merged) {
|
|
163
|
+
const pairs = llvmToolPairs();
|
|
164
|
+
if (pairs.length === 0)
|
|
165
|
+
return { ok: false, detail: "llvm tool missing (llvm-cov)" };
|
|
166
|
+
let detail = "llvm-profdata failed";
|
|
167
|
+
for (const { cov, prof } of pairs) {
|
|
168
|
+
const merge = spawnSync(prof, ["merge", "-sparse", "-o", merged, ...raws], { encoding: "utf8" });
|
|
169
|
+
if (merge.status !== 0) {
|
|
170
|
+
detail = (merge.stderr || merge.stdout || "llvm-profdata failed").trim();
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
const exported = spawnSync(cov, ["export", binary, `-instr-profile=${merged}`], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
|
|
174
|
+
if (exported.status !== 0) {
|
|
175
|
+
detail = (exported.stderr || exported.stdout || "llvm-cov failed").trim();
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
return { ok: true, stdout: exported.stdout };
|
|
179
|
+
}
|
|
180
|
+
return { ok: false, detail };
|
|
181
|
+
}
|
|
182
|
+
function llvmToolPairs() {
|
|
183
|
+
const covs = toolCandidates("llvm-cov");
|
|
184
|
+
const profs = toolCandidates("llvm-profdata");
|
|
185
|
+
const pairs = [];
|
|
186
|
+
const seen = new Set();
|
|
187
|
+
for (const cov of covs) {
|
|
188
|
+
const prof = profs.find((item) => path.dirname(item) === path.dirname(cov));
|
|
189
|
+
if (!prof)
|
|
190
|
+
continue;
|
|
191
|
+
const key = `${cov}\0${prof}`;
|
|
192
|
+
if (seen.has(key))
|
|
193
|
+
continue;
|
|
194
|
+
seen.add(key);
|
|
195
|
+
pairs.push({ cov, prof });
|
|
196
|
+
}
|
|
197
|
+
return pairs;
|
|
198
|
+
}
|
|
199
|
+
function toolCandidates(name) {
|
|
200
|
+
const out = [];
|
|
201
|
+
const add = (file) => {
|
|
202
|
+
if (!file || out.includes(file) || !existsSync(file))
|
|
203
|
+
return;
|
|
204
|
+
out.push(file);
|
|
205
|
+
};
|
|
163
206
|
const fromXcrun = spawnSync("xcrun", ["--find", name], { encoding: "utf8" });
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
return xcrunPath;
|
|
207
|
+
if (fromXcrun.status === 0)
|
|
208
|
+
add((fromXcrun.stdout ?? "").trim());
|
|
167
209
|
const which = spawnSync("sh", ["-c", `command -v ${name}`], { encoding: "utf8" });
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
210
|
+
if (which.status === 0)
|
|
211
|
+
add((which.stdout ?? "").trim());
|
|
212
|
+
const rustc = spawnSync("rustc", ["--print", "sysroot"], { encoding: "utf8" });
|
|
213
|
+
if (rustc.status === 0) {
|
|
214
|
+
const root = path.join(rustc.stdout.trim(), "lib", "rustlib");
|
|
215
|
+
if (existsSync(root)) {
|
|
216
|
+
for (const host of readdirSync(root))
|
|
217
|
+
add(path.join(root, host, "bin", name));
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
const usr = "/usr/lib";
|
|
221
|
+
if (existsSync(usr)) {
|
|
222
|
+
for (const dir of readdirSync(usr)) {
|
|
223
|
+
if (dir.startsWith("llvm-"))
|
|
224
|
+
add(path.join(usr, dir, "bin", name));
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
add(path.join("/usr/bin", name));
|
|
228
|
+
return out;
|
|
172
229
|
}
|
|
@@ -1,12 +1,21 @@
|
|
|
1
1
|
// node --test custom reporter. One JSON object on stdout: failures with the file
|
|
2
2
|
// the runner itself recorded. TAP headings are not used.
|
|
3
|
+
import { staticLoadFailure } from "./load-failure.mjs"
|
|
4
|
+
|
|
3
5
|
export default async function* reporter(source) {
|
|
6
|
+
// A file that fails to load prints its error on stderr before the file-level failure.
|
|
7
|
+
const stderr = new Map()
|
|
4
8
|
const failed = []
|
|
5
9
|
const names = []
|
|
6
10
|
let tests = 0
|
|
7
11
|
let pass = 0
|
|
8
12
|
let fail = 0
|
|
9
13
|
for await (const event of source) {
|
|
14
|
+
if (event.type === "test:stderr") {
|
|
15
|
+
const key = String(event.data?.file ?? "")
|
|
16
|
+
stderr.set(key, `${stderr.get(key) ?? ""}${String(event.data?.message ?? "")}`)
|
|
17
|
+
continue
|
|
18
|
+
}
|
|
10
19
|
if (event.type === "test:pass" || event.type === "test:fail") {
|
|
11
20
|
const data = event.data ?? {}
|
|
12
21
|
if (data.details && data.details.type === "suite") continue
|
|
@@ -18,6 +27,12 @@ export default async function* reporter(source) {
|
|
|
18
27
|
// An open handle is reported as a timeout whose name is the file path.
|
|
19
28
|
// failureType lives on the error; the text is on error.cause, not message.
|
|
20
29
|
if (fileTimedOut(name, file, data.details?.error)) failure.fileTimeout = true
|
|
30
|
+
// The file itself failed and no test in it ran: it did not load.
|
|
31
|
+
else if (isFileName(name, file)) {
|
|
32
|
+
failure.fileLoad = true
|
|
33
|
+
// It did not link (removed export, missing module, no parse): no test code ran.
|
|
34
|
+
if (staticLoadFailure(stderr.get(file) ?? "")) failure.staticLoad = true
|
|
35
|
+
}
|
|
21
36
|
failed.push(failure)
|
|
22
37
|
}
|
|
23
38
|
} else if (event.type === "test:diagnostic") {
|
|
@@ -33,6 +48,12 @@ export default async function* reporter(source) {
|
|
|
33
48
|
yield JSON.stringify({ tests, pass, fail, failed, names })
|
|
34
49
|
}
|
|
35
50
|
|
|
51
|
+
function isFileName(name, file) {
|
|
52
|
+
const base = String(file).split(/[/\\]/).pop()
|
|
53
|
+
if (!base) return false
|
|
54
|
+
return name === file || name === base || name.endsWith(`/${base}`) || name.endsWith(`\\${base}`)
|
|
55
|
+
}
|
|
56
|
+
|
|
36
57
|
function fileTimedOut(name, file, error) {
|
|
37
58
|
if (!error || typeof error !== "object") return false
|
|
38
59
|
const cause = typeof error.cause === "string" ? error.cause : ""
|
package/dist/mutate/generate.js
CHANGED
|
@@ -8,8 +8,9 @@ export function generateMutants(options) {
|
|
|
8
8
|
const started = Date.now();
|
|
9
9
|
const limit = options.maxMinutes;
|
|
10
10
|
const over = () => limit != null && Date.now() - started >= limit * 60_000;
|
|
11
|
+
const sourceDefault = options.workingTree ? "working-tree" : "commit";
|
|
11
12
|
if (over())
|
|
12
|
-
return { mutants: [], violations: [], filesVisited: 0, budgetHit: true };
|
|
13
|
+
return { mutants: [], violations: [], filesVisited: 0, budgetHit: true, source: sourceDefault };
|
|
13
14
|
const srcRoot = path.resolve(options.packageDir, options.srcDir);
|
|
14
15
|
const walked = walkSources(srcRoot, over);
|
|
15
16
|
const files = walked.files.filter((file) => !options.skipFiles.includes(path.basename(file)));
|
|
@@ -18,13 +19,18 @@ export function generateMutants(options) {
|
|
|
18
19
|
const chosen = [];
|
|
19
20
|
let filesVisited = 0;
|
|
20
21
|
let budgetHit = walked.stopped;
|
|
22
|
+
let source = sourceDefault;
|
|
21
23
|
for (const file of files) {
|
|
22
24
|
if (over()) {
|
|
23
25
|
budgetHit = true;
|
|
24
26
|
break;
|
|
25
27
|
}
|
|
26
28
|
filesVisited += 1;
|
|
27
|
-
const
|
|
29
|
+
const loaded = fileText(options.packageDir, file, options);
|
|
30
|
+
if ("error" in loaded)
|
|
31
|
+
return { mutants: [], violations: [], filesVisited, budgetHit, source: "commit", error: loaded.error };
|
|
32
|
+
source = loaded.source;
|
|
33
|
+
const text = loaded.text;
|
|
28
34
|
const rel = posix(path.relative(options.packageDir, file));
|
|
29
35
|
const found = findInSource(rel, text);
|
|
30
36
|
violations.push(...found.violations);
|
|
@@ -34,7 +40,7 @@ export function generateMutants(options) {
|
|
|
34
40
|
chosen.push({ rel, text, point });
|
|
35
41
|
}
|
|
36
42
|
if (violations.length > 0)
|
|
37
|
-
return { mutants: [], violations, filesVisited, budgetHit };
|
|
43
|
+
return { mutants: [], violations, filesVisited, budgetHit, source };
|
|
38
44
|
const capped = options.maxMutants === null ? chosen : chosen.slice(0, options.maxMutants);
|
|
39
45
|
const packageReal = real(options.packageDir);
|
|
40
46
|
const patchRoot = gitRoot(packageReal);
|
|
@@ -55,7 +61,22 @@ export function generateMutants(options) {
|
|
|
55
61
|
mutants.push({ id, file: item.rel, line: item.point.line, op: item.point.op, patch });
|
|
56
62
|
}
|
|
57
63
|
writeFileSync(path.join(mutantsDir, "mutants.json"), `${JSON.stringify({ schemaVersion: 1, seed: options.seed, mutants }, null, 2)}\n`);
|
|
58
|
-
return { mutants, violations, filesVisited, budgetHit };
|
|
64
|
+
return { mutants, violations, filesVisited, budgetHit, source };
|
|
65
|
+
}
|
|
66
|
+
function fileText(packageDir, absFile, options) {
|
|
67
|
+
if (options.workingTree)
|
|
68
|
+
return { text: readFileSync(absFile, "utf8"), source: "working-tree" };
|
|
69
|
+
const root = gitRoot(packageDir);
|
|
70
|
+
if (!root)
|
|
71
|
+
return { text: readFileSync(absFile, "utf8"), source: "working-tree" };
|
|
72
|
+
const rel = posix(path.relative(root, real(absFile)));
|
|
73
|
+
const commit = options.commit && options.commit.length > 0 ? options.commit : "HEAD";
|
|
74
|
+
const shown = spawnSync("git", ["-C", root, "show", `${commit}:${rel}`], { encoding: "utf8" });
|
|
75
|
+
if (shown.status !== 0) {
|
|
76
|
+
const shownRel = posix(path.relative(packageDir, absFile));
|
|
77
|
+
return { error: `${shownRel} is not in ${commit}. Pass --working-tree to read the checkout.` };
|
|
78
|
+
}
|
|
79
|
+
return { text: shown.stdout, source: "commit" };
|
|
59
80
|
}
|
|
60
81
|
function walkSources(dir, over) {
|
|
61
82
|
if (!statExists(dir))
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// A test file that failed before any test ran either did not link or threw while loading.
|
|
2
|
+
// Did not link: a removed export, a missing module, a file that does not parse. No code ran.
|
|
3
|
+
// A compiled language rejects the same change at compile time, so that mutant is unviable.
|
|
4
|
+
// Threw while loading: the file's own top-level code ran the program and it broke. That is a kill.
|
|
5
|
+
|
|
6
|
+
const STATIC_ERROR = /^(?:SyntaxError|(?:Type)?Error \[ERR_(?:MODULE_NOT_FOUND|UNKNOWN_FILE_EXTENSION|UNSUPPORTED_DIR_IMPORT|PACKAGE_PATH_NOT_EXPORTED)\]):/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* `text` is the error as printed: a `Name: message` line, then `at` frames.
|
|
10
|
+
* Static when the error is a link or parse error and no frame is in the project's own code.
|
|
11
|
+
* A SyntaxError thrown by running code (JSON.parse at the top of a test) has a frame in that file.
|
|
12
|
+
*/
|
|
13
|
+
export function staticLoadFailure(text) {
|
|
14
|
+
const lines = String(text).split(/\r?\n/)
|
|
15
|
+
const head = lines.findIndex((line) => STATIC_ERROR.test(line.trim()))
|
|
16
|
+
if (head === -1) return false
|
|
17
|
+
for (const raw of lines.slice(head + 1)) {
|
|
18
|
+
const line = raw.trim()
|
|
19
|
+
if (!line.startsWith("at ")) break
|
|
20
|
+
if (projectFrame(line)) return false
|
|
21
|
+
}
|
|
22
|
+
return true
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The error object a node:test event carries, printed the way node prints it. */
|
|
26
|
+
export function errorText(error) {
|
|
27
|
+
if (!error || typeof error !== "object") return ""
|
|
28
|
+
const name = typeof error.name === "string" ? error.name : "Error"
|
|
29
|
+
const code = typeof error.code === "string" ? ` [${error.code}]` : ""
|
|
30
|
+
const message = typeof error.message === "string" ? error.message : ""
|
|
31
|
+
const stack = typeof error.stack === "string" ? error.stack.split("\n").slice(1).join("\n") : ""
|
|
32
|
+
return `${name}${code}: ${message}\n${stack}`
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function projectFrame(frame) {
|
|
36
|
+
if (/\bnode:/.test(frame) || frame.includes("<anonymous>")) return false
|
|
37
|
+
const location = /(?:file:\/\/)?(\/[^\s)]+|[A-Za-z]:\\[^\s)]+):\d+:\d+\)?$/.exec(frame)
|
|
38
|
+
if (!location) return false
|
|
39
|
+
return !location[1].split(/[\\/]/).includes("node_modules")
|
|
40
|
+
}
|
|
@@ -12,6 +12,8 @@ if (mapPath) {
|
|
|
12
12
|
let prelude = []
|
|
13
13
|
/** @type {{ positiveLines: () => Promise<Array<{ file: string, line: number }>> } | null} */
|
|
14
14
|
let sampler = null
|
|
15
|
+
/** @type {((mapPath: string) => Array<{ file: string, line: number }>) | null} */
|
|
16
|
+
let takeChildHits = null
|
|
15
17
|
|
|
16
18
|
function add(hits, name) {
|
|
17
19
|
if (!name) return
|
|
@@ -40,7 +42,10 @@ if (mapPath) {
|
|
|
40
42
|
async beforeAll() {
|
|
41
43
|
try {
|
|
42
44
|
const opened = await import("./precise-lines.mjs")
|
|
43
|
-
sampler = await opened.openLineSampler((rel) => rel.endsWith("mocha-coverage.cjs") || rel.endsWith("precise-lines.mjs") || rel.endsWith("node-coverage.mjs"))
|
|
45
|
+
sampler = await opened.openLineSampler((rel) => rel.endsWith("mocha-coverage.cjs") || rel.endsWith("precise-lines.mjs") || rel.endsWith("node-coverage.mjs") || rel.endsWith("child-lines.mjs"))
|
|
46
|
+
// A node child of a Mocha test is mapped under that test, the same as under node:test.
|
|
47
|
+
opened.exposeChildLines()
|
|
48
|
+
takeChildHits = opened.takeChildHits
|
|
44
49
|
prelude = await sampler.positiveLines()
|
|
45
50
|
} catch (err) {
|
|
46
51
|
writeFileSync(`${mapPath}.error`, `${err instanceof Error ? err.message : String(err)}\n`)
|
|
@@ -52,6 +57,7 @@ if (mapPath) {
|
|
|
52
57
|
try {
|
|
53
58
|
add(prelude, title)
|
|
54
59
|
add(await sampler.positiveLines(), title)
|
|
60
|
+
if (takeChildHits) add(takeChildHits(mapPath), title)
|
|
55
61
|
} catch (err) {
|
|
56
62
|
writeFileSync(`${mapPath}.error`, `${err instanceof Error ? err.message : String(err)}\n`)
|
|
57
63
|
}
|
|
@@ -40,8 +40,22 @@ function underPackage(abs) {
|
|
|
40
40
|
return real === root || real.startsWith(root.endsWith(path.sep) ? root : root + path.sep)
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
/** TypeScript with NodeNext writes `./limit.js` for `limit.ts`. tsx resolves that, so this loader does too. */
|
|
44
|
+
function typeScriptSibling(specifier) {
|
|
45
|
+
if (!/^(\.{1,2}\/|\/|file:)/.test(specifier)) return null
|
|
46
|
+
const swapped = specifier.replace(/\.js$/, ".ts").replace(/\.mjs$/, ".mts").replace(/\.cjs$/, ".cts").replace(/\.jsx$/, ".tsx")
|
|
47
|
+
return swapped === specifier ? null : swapped
|
|
48
|
+
}
|
|
49
|
+
|
|
43
50
|
export async function resolve(specifier, context, nextResolve) {
|
|
44
|
-
|
|
51
|
+
let resolved
|
|
52
|
+
try {
|
|
53
|
+
resolved = await nextResolve(specifier, context)
|
|
54
|
+
} catch (error) {
|
|
55
|
+
const sibling = typeScriptSibling(specifier)
|
|
56
|
+
if (!sibling) throw error
|
|
57
|
+
resolved = await nextResolve(sibling, context)
|
|
58
|
+
}
|
|
45
59
|
const gen = generation()
|
|
46
60
|
if (!gen) return resolved
|
|
47
61
|
let abs
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
// One suite process for covered mutants of one file. Jobs arrive on stdin.
|
|
2
2
|
// A mutant that exits this process ends it. The parent starts a clean process for the next mutant.
|
|
3
3
|
import { createInterface } from "node:readline"
|
|
4
|
-
import { realpathSync, writeFileSync } from "node:fs"
|
|
4
|
+
import { readFileSync, realpathSync, writeFileSync } from "node:fs"
|
|
5
5
|
import { register } from "node:module"
|
|
6
6
|
import { run } from "node:test"
|
|
7
7
|
import path from "node:path"
|
|
8
|
+
import { errorText, staticLoadFailure } from "./load-failure.mjs"
|
|
8
9
|
import { openLineSampler } from "./precise-lines.mjs"
|
|
9
10
|
|
|
10
11
|
const loader = new URL("./node-batch-loader.mjs", import.meta.url).href
|
|
@@ -33,6 +34,12 @@ async function runJob(job) {
|
|
|
33
34
|
const patterns = (job.names || []).filter((name) => name.length > 0).map((name) => new RegExp(`^${escapeRegExp(name)}$`))
|
|
34
35
|
const failed = []
|
|
35
36
|
const names = []
|
|
37
|
+
// run() with isolation "none" ignores testNamePatterns on Node 22, so every test in the file runs.
|
|
38
|
+
// Only a requested test, or a subtest of one, is a result. Another test failing is not a kill.
|
|
39
|
+
const wanted = new Set((job.names || []).filter((name) => name.length > 0))
|
|
40
|
+
const stack = []
|
|
41
|
+
const requested = (name, nesting) =>
|
|
42
|
+
wanted.size === 0 || wanted.has(name) || stack.slice(0, nesting).some((parent) => wanted.has(parent))
|
|
36
43
|
const testTimeout = job.testTimeoutMs > 0 ? job.testTimeoutMs : job.timeoutMs
|
|
37
44
|
const stream = run({
|
|
38
45
|
files,
|
|
@@ -47,10 +54,28 @@ async function runJob(job) {
|
|
|
47
54
|
const collected = (async () => {
|
|
48
55
|
for await (const event of stream) {
|
|
49
56
|
lastEvent = Date.now()
|
|
50
|
-
if (event.type !== "test:pass" && event.type !== "test:fail") continue
|
|
51
57
|
const data = event.data || {}
|
|
58
|
+
const nesting = Number.isInteger(data.nesting) ? data.nesting : 0
|
|
59
|
+
if (event.type === "test:start") {
|
|
60
|
+
stack.length = nesting
|
|
61
|
+
stack[nesting] = typeof data.name === "string" ? data.name : ""
|
|
62
|
+
continue
|
|
63
|
+
}
|
|
64
|
+
if (event.type !== "test:pass" && event.type !== "test:fail") continue
|
|
52
65
|
const name = typeof data.name === "string" ? data.name : ""
|
|
53
|
-
if (!name
|
|
66
|
+
if (!name) continue
|
|
67
|
+
if (files.includes(name)) {
|
|
68
|
+
// The file itself failed: it did not load (a removed export, a throw at import time).
|
|
69
|
+
// No test in it ran. That is a failure of the file, the same as node --test reports it.
|
|
70
|
+
if (event.type === "test:fail" && fileHasRequested(name, wanted)) {
|
|
71
|
+
const posix = path.relative(job.root, name).split(path.sep).join("/")
|
|
72
|
+
const linked = !staticLoadFailure(errorText(data.details && data.details.error))
|
|
73
|
+
failed.push({ name: posix, file: name, line: 1, fileLoad: true, ...(linked ? {} : { staticLoad: true }) })
|
|
74
|
+
if (!names.includes(posix)) names.push(posix)
|
|
75
|
+
}
|
|
76
|
+
continue
|
|
77
|
+
}
|
|
78
|
+
if (!requested(name, nesting)) continue
|
|
54
79
|
if (!names.includes(name)) names.push(name)
|
|
55
80
|
if (event.type === "test:fail") {
|
|
56
81
|
const error = data.details && data.details.error
|
|
@@ -101,6 +126,18 @@ async function runJob(job) {
|
|
|
101
126
|
return { id: job.id, pid: process.pid, failed, names, command, dirty }
|
|
102
127
|
}
|
|
103
128
|
|
|
129
|
+
/** A file with no requested test in it does not speak for the requested tests. */
|
|
130
|
+
function fileHasRequested(file, wanted) {
|
|
131
|
+
if (wanted.size === 0) return true
|
|
132
|
+
let text = ""
|
|
133
|
+
try {
|
|
134
|
+
text = readFileSync(file, "utf8")
|
|
135
|
+
} catch {
|
|
136
|
+
return true
|
|
137
|
+
}
|
|
138
|
+
return [...wanted].some((name) => text.includes(name))
|
|
139
|
+
}
|
|
140
|
+
|
|
104
141
|
function fileNameIs(name, file) {
|
|
105
142
|
const base = String(file).split(/[/\\]/).pop()
|
|
106
143
|
if (!base) return false
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
// Baseline-only per-test line map for node --test. Loaded with --import in each
|
|
2
2
|
// test-file process. Writes one shard; the runner merges shards after the suite.
|
|
3
3
|
// takePreciseCoverage resets counters, so each sample is only what ran since the last take.
|
|
4
|
-
// A
|
|
5
|
-
//
|
|
4
|
+
// A node child of a test does not run these hooks. exposeChildLines puts child-lines.mjs in
|
|
5
|
+
// its NODE_OPTIONS, so it leaves a nameless hit list in ${map}.children when it exits.
|
|
6
|
+
// afterEach stores that list under the parent test before the shard is written.
|
|
6
7
|
import { randomBytes } from "node:crypto"
|
|
7
|
-
import {
|
|
8
|
+
import { mkdirSync, writeFileSync } from "node:fs"
|
|
8
9
|
import { after, afterEach, before } from "node:test"
|
|
9
10
|
import path from "node:path"
|
|
10
|
-
import { openLineSampler } from "./precise-lines.mjs"
|
|
11
|
+
import { exposeChildLines, openLineSampler, takeChildHits } from "./precise-lines.mjs"
|
|
11
12
|
|
|
12
13
|
const mapPath = process.env.PROBATIO_COVERAGE_MAP
|
|
13
14
|
if (mapPath) {
|
|
@@ -19,6 +20,7 @@ if (mapPath) {
|
|
|
19
20
|
}
|
|
20
21
|
|
|
21
22
|
if (sampler) {
|
|
23
|
+
exposeChildLines()
|
|
22
24
|
/** @type {Map<string, Map<number, Set<string>>>} */
|
|
23
25
|
const byFile = new Map()
|
|
24
26
|
/** @type {Array<{ file: string, line: number }>} */
|
|
@@ -41,33 +43,6 @@ if (mapPath) {
|
|
|
41
43
|
}
|
|
42
44
|
}
|
|
43
45
|
|
|
44
|
-
function takeChildHits() {
|
|
45
|
-
const dir = `${mapPath}.children`
|
|
46
|
-
if (!existsSync(dir)) return []
|
|
47
|
-
/** @type {Array<{ file: string, line: number }>} */
|
|
48
|
-
const hits = []
|
|
49
|
-
for (const name of readdirSync(dir)) {
|
|
50
|
-
if (!name.endsWith(".json")) continue
|
|
51
|
-
const full = path.join(dir, name)
|
|
52
|
-
try {
|
|
53
|
-
const parsed = JSON.parse(readFileSync(full, "utf8"))
|
|
54
|
-
const list = parsed && Array.isArray(parsed.hits) ? parsed.hits : []
|
|
55
|
-
for (const hit of list) {
|
|
56
|
-
if (!hit || typeof hit.file !== "string" || !Number.isInteger(hit.line)) continue
|
|
57
|
-
hits.push({ file: hit.file, line: hit.line })
|
|
58
|
-
}
|
|
59
|
-
} catch {
|
|
60
|
-
// A torn dump is ignored. The parent does not invent a line.
|
|
61
|
-
}
|
|
62
|
-
try {
|
|
63
|
-
unlinkSync(full)
|
|
64
|
-
} catch {
|
|
65
|
-
// The next test must not inherit this dump.
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
return hits
|
|
69
|
-
}
|
|
70
|
-
|
|
71
46
|
before(async () => {
|
|
72
47
|
try {
|
|
73
48
|
prelude = await sampler.positiveLines()
|
|
@@ -80,7 +55,7 @@ if (mapPath) {
|
|
|
80
55
|
try {
|
|
81
56
|
add(prelude, t.name)
|
|
82
57
|
add(await sampler.positiveLines(), t.name)
|
|
83
|
-
add(takeChildHits(), t.name)
|
|
58
|
+
add(takeChildHits(mapPath), t.name)
|
|
84
59
|
} catch (err) {
|
|
85
60
|
writeFileSync(`${mapPath}.error`, `${err instanceof Error ? err.message : String(err)}\n`)
|
|
86
61
|
}
|
package/dist/mutate/patch.js
CHANGED
|
@@ -131,5 +131,6 @@ export function applyPatch(repo, raw, direction) {
|
|
|
131
131
|
/** Put a throwaway worktree back. A diff label is not trusted for the path list. */
|
|
132
132
|
export function restoreTree(repo) {
|
|
133
133
|
git(repo, ["checkout", "--", "."]);
|
|
134
|
-
|
|
134
|
+
// .probatio.json may be the user's uncommitted copy, put there when the worktree was made.
|
|
135
|
+
git(repo, ["clean", "-fd", "-e", "node_modules", "-e", ".probatio.json"]);
|
|
135
136
|
}
|