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.
@@ -1,34 +1,39 @@
1
- // Nameless line dump for a plain script. This is not a node:test process and it
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 { openLineSampler } from "./precise-lines.mjs"
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 = await openLineSampler((rel) => rel.endsWith("child-lines.mjs") || rel.endsWith("precise-lines.mjs"))
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
- let wrote = false
18
- const dump = () => {
19
- if (wrote) return
20
- wrote = true
21
- sampler.positiveLines().then((hits) => {
22
- const dir = `${mapPath}.children`
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
- path.join(dir, `${process.pid}-${randomBytes(4).toString("hex")}.json`),
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 merge = spawnSync(llvmProf, ["merge", "-sparse", "-o", merged, ...raws], { encoding: "utf8" });
113
- if (merge.status !== 0)
114
- return { ok: false, detail: (merge.stderr || merge.stdout || "llvm-profdata failed").trim() };
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
- function findTool(name) {
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
- const xcrunPath = fromXcrun.stdout?.trim() ?? "";
165
- if (fromXcrun.status === 0 && xcrunPath && existsSync(xcrunPath))
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
- const found = which.stdout?.trim() ?? "";
169
- if (which.status === 0 && found)
170
- return found;
171
- return null;
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 : ""
@@ -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 text = readFileSync(file, "utf8");
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
- const resolved = await nextResolve(specifier, context)
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 || files.includes(name)) continue
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 plain-script child does not run these hooks. It may leave a nameless hit list in
5
- // ${map}.children. afterEach stores that list under the parent test before the shard is written.
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 { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"
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
  }
@@ -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
- git(repo, ["clean", "-fd", "-e", "node_modules"]);
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
  }