probatio 0.1.3 → 0.3.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.
Files changed (55) hide show
  1. package/README.md +93 -8
  2. package/dist/cli.js +136 -6
  3. package/dist/contract.js +2 -1
  4. package/dist/golden/record.js +395 -0
  5. package/dist/golden/recorder.mjs +78 -0
  6. package/dist/ledger/build.js +21 -0
  7. package/dist/ledger/check.js +133 -0
  8. package/dist/mcp.js +76 -27
  9. package/dist/mutate/child-lines.mjs +23 -18
  10. package/dist/mutate/fail-reporter.mjs +21 -0
  11. package/dist/mutate/find.js +3 -3
  12. package/dist/mutate/generate.js +5 -2
  13. package/dist/mutate/load-failure.mjs +40 -0
  14. package/dist/mutate/mocha-coverage.cjs +7 -1
  15. package/dist/mutate/node-batch-loader.mjs +15 -1
  16. package/dist/mutate/node-batch.mjs +40 -3
  17. package/dist/mutate/node-coverage.mjs +7 -32
  18. package/dist/mutate/operators.js +60 -1
  19. package/dist/mutate/patch.js +2 -1
  20. package/dist/mutate/precise-lines.mjs +147 -31
  21. package/dist/mutate/probatio-jacoco-run.java +2 -2
  22. package/dist/mutate/project-config.js +58 -0
  23. package/dist/mutate/run.js +100 -19
  24. package/dist/mutate/sealed.js +54 -0
  25. package/dist/mutate/suite-decision.js +2 -1
  26. package/dist/mutate/suites.js +87 -155
  27. package/dist/mutate/tally.js +54 -26
  28. package/dist/mutate/text-operators.js +92 -2
  29. package/dist/swarm/check-kill.js +14 -7
  30. package/dist/verify/change.js +1 -1
  31. package/package.json +8 -4
  32. package/schemas/check-kill.schema.json +93 -0
  33. package/schemas/findings.add.schema.json +53 -0
  34. package/schemas/gap.fix.schema.json +53 -0
  35. package/schemas/gap.revert.schema.json +53 -0
  36. package/schemas/golden.check.schema.json +89 -0
  37. package/schemas/golden.compare.schema.json +151 -0
  38. package/schemas/golden.record.schema.json +126 -0
  39. package/schemas/guard.check.schema.json +53 -0
  40. package/schemas/ledger.build.schema.json +132 -0
  41. package/schemas/ledger.check.schema.json +143 -0
  42. package/schemas/matrix.report.schema.json +129 -0
  43. package/schemas/mcp.schema.json +53 -0
  44. package/schemas/mutate.generate.schema.json +127 -0
  45. package/schemas/mutate.help.schema.json +53 -0
  46. package/schemas/mutate.run.schema.json +272 -0
  47. package/schemas/mutate.sealed.schema.json +53 -0
  48. package/schemas/mutate.tally.schema.json +137 -0
  49. package/schemas/queue.claim.schema.json +53 -0
  50. package/schemas/queue.reap.schema.json +71 -0
  51. package/schemas/queue.seed.schema.json +71 -0
  52. package/schemas/schema.schema.json +74 -0
  53. package/schemas/seal.schema.json +69 -0
  54. package/schemas/status.schema.json +103 -0
  55. package/schemas/verify-change.schema.json +222 -0
@@ -1,5 +1,7 @@
1
1
  // Inspector line samples shared by the node:test collector, the Mocha collector,
2
2
  // and a plain-script child. This file has no test hooks and writes no shard.
3
+ import { existsSync, readdirSync, readFileSync, unlinkSync } from "node:fs"
4
+ import { Session as SyncSession } from "node:inspector"
3
5
  import { Session } from "node:inspector/promises"
4
6
  import { findSourceMap } from "node:module"
5
7
  import path from "node:path"
@@ -31,32 +33,14 @@ function decodeSegments(row) {
31
33
  }
32
34
 
33
35
  /**
36
+ * Offsets to source lines, shared by the async sampler (test processes) and the sync one (children).
37
+ * Paths are relative to `root`. node_modules and paths outside root are left out.
34
38
  * @param {(rel: string) => boolean} [skipRel]
35
- * @returns {Promise<{ positiveLines: () => Promise<Array<{ file: string, line: number }>>, reset: () => Promise<void> }>}
39
+ * @param {string} [root]
36
40
  */
37
- export async function openLineSampler(skipRel) {
38
- const session = new Session()
39
- session.connect()
40
- await session.post("Profiler.enable")
41
- await session.post("Debugger.enable")
42
- await session.post("Profiler.startPreciseCoverage", { callCount: true, detailed: true })
43
-
44
- const textCache = new Map()
41
+ function lineConverter(skipRel, root = process.cwd()) {
45
42
  const indexCache = new Map()
46
43
 
47
- async function scriptText(script) {
48
- if (textCache.has(script.scriptId)) return textCache.get(script.scriptId)
49
- let text = ""
50
- try {
51
- const got = await session.post("Debugger.getScriptSource", { scriptId: script.scriptId })
52
- text = got.scriptSource || ""
53
- } catch {
54
- text = ""
55
- }
56
- textCache.set(script.scriptId, text)
57
- return text
58
- }
59
-
60
44
  function relPath(url) {
61
45
  if (!url || url.startsWith("node:")) return null
62
46
  let abs
@@ -65,7 +49,7 @@ export async function openLineSampler(skipRel) {
65
49
  } catch {
66
50
  return null
67
51
  }
68
- const rel = path.relative(process.cwd(), abs).split(path.sep).join("/")
52
+ const rel = path.relative(root, abs).split(path.sep).join("/")
69
53
  if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) return null
70
54
  if (rel.split("/").includes("node_modules")) return null
71
55
  if (skipRel && skipRel(rel)) return null
@@ -163,18 +147,55 @@ export async function openLineSampler(skipRel) {
163
147
  return counts
164
148
  }
165
149
 
150
+ /** Lines with a positive count in one script, or null when the script is not ours. */
151
+ function scriptLines(script, text) {
152
+ const file = relPath(script.url)
153
+ if (!file || !text) return null
154
+ const counts = applyRanges(text, script, indexFor(script.scriptId, text, findSourceMap(script.url)))
155
+ const found = []
156
+ for (const [line, count] of counts) {
157
+ if (count > 0) found.push({ file, line })
158
+ }
159
+ return found
160
+ }
161
+
162
+ return { relPath, scriptLines }
163
+ }
164
+
165
+ /**
166
+ * @param {(rel: string) => boolean} [skipRel]
167
+ * @returns {Promise<{ positiveLines: () => Promise<Array<{ file: string, line: number }>>, reset: () => Promise<void> }>}
168
+ */
169
+ export async function openLineSampler(skipRel) {
170
+ const session = new Session()
171
+ session.connect()
172
+ await session.post("Profiler.enable")
173
+ await session.post("Debugger.enable")
174
+ await session.post("Profiler.startPreciseCoverage", { callCount: true, detailed: true })
175
+
176
+ const textCache = new Map()
177
+ const convert = lineConverter(skipRel)
178
+
179
+ async function scriptText(script) {
180
+ if (textCache.has(script.scriptId)) return textCache.get(script.scriptId)
181
+ let text = ""
182
+ try {
183
+ const got = await session.post("Debugger.getScriptSource", { scriptId: script.scriptId })
184
+ text = got.scriptSource || ""
185
+ } catch {
186
+ text = ""
187
+ }
188
+ textCache.set(script.scriptId, text)
189
+ return text
190
+ }
191
+
166
192
  async function positiveLines() {
167
193
  const cov = await session.post("Profiler.takePreciseCoverage")
168
194
  const found = []
169
195
  for (const script of cov.result || []) {
170
- const file = relPath(script.url)
171
- if (!file) continue
172
- const text = await scriptText(script)
173
- if (!text) continue
174
- const counts = applyRanges(text, script, indexFor(script.scriptId, text, findSourceMap(script.url)))
175
- for (const [line, count] of counts) {
176
- if (count > 0) found.push({ file, line })
177
- }
196
+ if (!convert.relPath(script.url)) continue
197
+ const lines = convert.scriptLines(script, await scriptText(script))
198
+ if (lines) found.push(...lines)
178
199
  }
179
200
  return found
180
201
  }
@@ -186,3 +207,98 @@ export async function openLineSampler(skipRel) {
186
207
  },
187
208
  }
188
209
  }
210
+
211
+ /**
212
+ * The same sampler with synchronous calls, so an `exit` handler can read it.
213
+ * `exit` runs after process.exit(), where a promise never settles.
214
+ * @param {(rel: string) => boolean} [skipRel]
215
+ * @param {string} [root]
216
+ */
217
+ export function openSyncLineSampler(skipRel, root) {
218
+ const session = new SyncSession()
219
+ session.connect()
220
+ const post = (method, params) => {
221
+ let result
222
+ let failure = null
223
+ let done = false
224
+ session.post(method, params, (err, value) => {
225
+ failure = err
226
+ result = value
227
+ done = true
228
+ })
229
+ if (!done) throw new Error(`inspector ${method} did not answer synchronously`)
230
+ if (failure) throw failure
231
+ return result
232
+ }
233
+ post("Profiler.enable")
234
+ post("Debugger.enable")
235
+ post("Profiler.startPreciseCoverage", { callCount: true, detailed: true })
236
+ const convert = lineConverter(skipRel, root)
237
+ return {
238
+ positiveLines() {
239
+ const cov = post("Profiler.takePreciseCoverage")
240
+ const found = []
241
+ for (const script of cov.result || []) {
242
+ if (!convert.relPath(script.url)) continue
243
+ let text = ""
244
+ try {
245
+ text = post("Debugger.getScriptSource", { scriptId: script.scriptId }).scriptSource || ""
246
+ } catch {
247
+ text = ""
248
+ }
249
+ const lines = convert.scriptLines(script, text)
250
+ if (lines) found.push(...lines)
251
+ }
252
+ return found
253
+ },
254
+ }
255
+ }
256
+
257
+ /**
258
+ * Called inside a test process that collects a line map. Every node child it starts
259
+ * loads child-lines.mjs through NODE_OPTIONS, with no import in the child's own code.
260
+ * The marker tells child-lines that this process already has test hooks.
261
+ */
262
+ export function exposeChildLines() {
263
+ globalThis.__probatioTestHooks = true
264
+ // One dump dir per test process. Test files can run in parallel, and a child's lines
265
+ // belong to the test in the process that started it, not to a test in another file.
266
+ const mapPath = process.env.PROBATIO_COVERAGE_MAP
267
+ if (mapPath) process.env.PROBATIO_CHILD_DIR = childDir(mapPath)
268
+ const childLines = new URL("./child-lines.mjs", import.meta.url).href
269
+ const flag = `--import=${childLines}`
270
+ const current = process.env.NODE_OPTIONS || ""
271
+ if (!current.includes(flag)) process.env.NODE_OPTIONS = current ? `${current} ${flag}` : flag
272
+ if (!process.env.PROBATIO_PACKAGE_ROOT) process.env.PROBATIO_PACKAGE_ROOT = process.cwd()
273
+ }
274
+
275
+ export function childDir(mapPath) {
276
+ return path.join(`${mapPath}.children`, String(process.pid))
277
+ }
278
+
279
+ /** Line dumps the children of the current test left. Each one is read once. */
280
+ export function takeChildHits(mapPath) {
281
+ const dir = childDir(mapPath)
282
+ if (!existsSync(dir)) return []
283
+ const hits = []
284
+ for (const name of readdirSync(dir)) {
285
+ if (!name.endsWith(".json")) continue
286
+ const full = path.join(dir, name)
287
+ try {
288
+ const parsed = JSON.parse(readFileSync(full, "utf8"))
289
+ const list = parsed && Array.isArray(parsed.hits) ? parsed.hits : []
290
+ for (const hit of list) {
291
+ if (!hit || typeof hit.file !== "string" || !Number.isInteger(hit.line)) continue
292
+ hits.push({ file: hit.file, line: hit.line })
293
+ }
294
+ } catch {
295
+ // A torn dump is ignored. The parent does not invent a line.
296
+ }
297
+ try {
298
+ unlinkSync(full)
299
+ } catch {
300
+ // The next test must not inherit this dump.
301
+ }
302
+ }
303
+ return hits
304
+ }
@@ -85,8 +85,8 @@ public class ProbatioJacocoRun {
85
85
  System.err.println("java coverage: tests ran but JaCoCo recorded no classes");
86
86
  System.exit(1);
87
87
  }
88
- // jsoup's integration tests leave Netty loops running. A normal return would
89
- // wait on those threads until the parent clock kills the dump.
88
+ // Integration tests can leave non-daemon threads running (jsoup leaves Netty loops).
89
+ // A normal return would wait on those threads until the parent clock kills the dump.
90
90
  System.exit(0);
91
91
  }
92
92
 
@@ -0,0 +1,58 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ /**
4
+ * `.probatio.json` at the package root. How to run this project's suite, written by the
5
+ * project and visible in review. Probatio does not patch a project's files or environment
6
+ * to make one repository pass. A project that needs that says so here.
7
+ */
8
+ export const CONFIG_FILE = ".probatio.json";
9
+ const EMPTY = { env: {}, testLine: null, passLine: null };
10
+ export function readProjectConfig(pkg) {
11
+ const file = path.join(pkg, CONFIG_FILE);
12
+ if (!existsSync(file))
13
+ return EMPTY;
14
+ let raw;
15
+ try {
16
+ raw = JSON.parse(readFileSync(file, "utf8"));
17
+ }
18
+ catch (error) {
19
+ return { error: `${CONFIG_FILE} is not JSON (${error instanceof Error ? error.message : String(error)})` };
20
+ }
21
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
22
+ return { error: `${CONFIG_FILE} must be one JSON object` };
23
+ const body = raw;
24
+ const env = {};
25
+ if (body.env !== undefined) {
26
+ if (!body.env || typeof body.env !== "object" || Array.isArray(body.env))
27
+ return { error: `${CONFIG_FILE} env must be an object of strings` };
28
+ for (const [key, value] of Object.entries(body.env)) {
29
+ if (typeof value !== "string")
30
+ return { error: `${CONFIG_FILE} env.${key} must be a string` };
31
+ env[key] = value;
32
+ }
33
+ }
34
+ const testLine = pattern(body.testLine, "testLine");
35
+ if (typeof testLine === "string")
36
+ return { error: testLine };
37
+ const passLine = pattern(body.passLine, "passLine");
38
+ if (typeof passLine === "string")
39
+ return { error: passLine };
40
+ return { env, testLine, passLine };
41
+ }
42
+ /** The config, or the empty one when the file is unreadable. The runner refuses that case earlier. */
43
+ export function projectConfig(pkg) {
44
+ const read = readProjectConfig(pkg);
45
+ return "error" in read ? EMPTY : read;
46
+ }
47
+ function pattern(value, name) {
48
+ if (value === undefined || value === null)
49
+ return null;
50
+ if (typeof value !== "string" || value.length === 0)
51
+ return `${CONFIG_FILE} ${name} must be a non-empty string`;
52
+ try {
53
+ return new RegExp(value);
54
+ }
55
+ catch (error) {
56
+ return `${CONFIG_FILE} ${name} is not a regular expression (${error instanceof Error ? error.message : String(error)})`;
57
+ }
58
+ }
@@ -12,7 +12,8 @@ import { writeJavaCoverage } from "./java-coverage.js";
12
12
  import { writeRustCoverage } from "./rust-coverage.js";
13
13
  import { applyPatch, changedLines, chooseDirection, filesInDiff, git, restoreTree } from "./patch.js";
14
14
  import { killLabel } from "./kill-label.js";
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";
15
+ import { CONFIG_FILE, readProjectConfig } from "./project-config.js";
16
+ import { UNVIABLE_NEXT, TIMEOUT_FLOOR_MS, TIMEOUT_MULTIPLE, TIMEOUT_NEXT, WHOLE_PROGRAM_TEST, WHOLE_SUITE_NEXT, campaignSummary, decideSuite, hasLineMap, noCoverageNext, resumeNext, scaleStop, timeoutLimitMs, } from "./suite-decision.js";
16
17
  import { commandSuite, discoverSuite, nodeTestTimeoutMs, runDiscoveredSuite, sourceCandidates } from "./suites.js";
17
18
  import { duplicateTitles } from "./titles.js";
18
19
  export const reporterPath = fileURLToPath(new URL("./fail-reporter.mjs", import.meta.url));
@@ -52,6 +53,9 @@ export async function runMutants(options) {
52
53
  : listed.files;
53
54
  if (patchFiles.length === 0)
54
55
  return failReport(options.outDir, run.id, commit, "no patches");
56
+ const config = readProjectConfig(packageDir);
57
+ if ("error" in config)
58
+ return failReport(options.outDir, run.id, commit, config.error, undefined, `Fix ${CONFIG_FILE} and run the same command again.`);
55
59
  const gate = resolveSuite(packageDir, options);
56
60
  if (gate.action === "stop")
57
61
  return failReport(options.outDir, run.id, commit, gate.summary, undefined, gate.next, gate.suiteCommand);
@@ -67,6 +71,13 @@ export async function runMutants(options) {
67
71
  if ("error" in worktrees)
68
72
  return failReport(options.outDir, run.id, commit, worktrees.error);
69
73
  hidePaths(worktrees.dirs, repo, packageDir, options.hide ?? []);
74
+ // A cancelled run (MCP cancel, Ctrl-C) still removes its worktrees from the user's repo.
75
+ const onSignal = (signal) => {
76
+ removeWorktrees(repo, worktrees.dirs, options.outDir);
77
+ process.exit(signal === "SIGINT" ? 130 : 143);
78
+ };
79
+ process.once("SIGTERM", onSignal);
80
+ process.once("SIGINT", onSignal);
70
81
  const budgetMs = options.budgetMs ?? (options.maxMinutes == null ? null : options.maxMinutes * 60_000);
71
82
  let budgetStarted = 0;
72
83
  const overBudget = () => budgetMs !== null && budgetStarted !== 0 && Date.now() - budgetStarted > budgetMs;
@@ -113,7 +124,7 @@ export async function runMutants(options) {
113
124
  const prior = history.get(id);
114
125
  if (prior && reusable(prior, raw, treePkg)) {
115
126
  startedMutants += 1;
116
- const reused = reusedResult(prior, commit, run.id, options.agent);
127
+ const reused = normalizeResult(reusedResult(prior, commit, run.id, options.agent));
117
128
  writeResult(options.outDir, reused);
118
129
  progress(`reused ${id}`);
119
130
  continue;
@@ -165,6 +176,8 @@ export async function runMutants(options) {
165
176
  });
166
177
  }
167
178
  finally {
179
+ process.removeListener("SIGTERM", onSignal);
180
+ process.removeListener("SIGINT", onSignal);
168
181
  removeWorktrees(repo, worktrees.dirs, options.outDir);
169
182
  }
170
183
  }
@@ -292,10 +305,12 @@ async function runOne(worktree, pkg, patchPath, options, commit, runId, prep, la
292
305
  return { ...base(), direction, outcome: "timeout", error: "build timed out", next: TIMEOUT_NEXT };
293
306
  // The baseline build already succeeded. A mutant that no longer builds cannot pass the suite.
294
307
  if (built.code !== 0)
295
- return finishMutant(killed(base, direction, ["build"]), pkg, "", false, []);
308
+ return finishMutant(unviable(base, direction, "build"), pkg, "", false, []);
296
309
  }
297
310
  const picked = selectionFor(prep.coverage, edited.length > 0 ? edited : files);
298
- const forced = options.onlyNames && options.onlyNames.length > 0 ? options.onlyNames : null;
311
+ const forced = options.onlyNames && options.onlyNames.length > 0
312
+ ? prep.spec.kind === "node" ? options.onlyNames.map(nodeTitle) : options.onlyNames
313
+ : null;
299
314
  if (!forced && picked.state === "uncovered") {
300
315
  return {
301
316
  ...base(),
@@ -323,7 +338,7 @@ async function runOne(worktree, pkg, patchPath, options, commit, runId, prep, la
323
338
  }
324
339
  // A compile error can leave the previous run's report on disk. That report is not this mutant.
325
340
  if (main.compileToken)
326
- return finishMutant(killed(base, direction, [main.compileToken]), pkg, commands.join(" | "), false, []);
341
+ return finishMutant(unviable(base, direction, main.compileToken), pkg, commands.join(" | "), false, []);
327
342
  if (!main.report) {
328
343
  const detail = main.detail ? clip(main.detail) : "";
329
344
  const tool = missingToolFrom(detail, main.command);
@@ -334,13 +349,23 @@ async function runOne(worktree, pkg, patchPath, options, commit, runId, prep, la
334
349
  : "test reporter returned nothing";
335
350
  return { ...base(), direction, error, command: commands.join(" | ") };
336
351
  }
352
+ // A node name pattern that matches nothing passes the file. That is not a survivor.
353
+ // A requested describe block reports its leaf tests, so any test that ran counts.
354
+ if (forced && prep.spec.kind === "node" && main.report.names.every((name) => isNodeTestFile(name))) {
355
+ return { ...base(), direction, error: `no requested test ran (${forced.join(", ")})`, command: commands.join(" | ") };
356
+ }
337
357
  const whole = main.wholeSuite || forceWhole;
338
358
  const chosen = main.selectedTests;
339
359
  if (main.report.failed.length === 0)
340
360
  return finishMutant({ ...base(), direction, outcome: "survived" }, pkg, commands.join(" | "), whole, chosen);
361
+ // A test file that no longer links ran no test. It is the build failing, the same as javac rejecting it.
362
+ // Only a failure where code ran can kill. When none did, the mutant did not build.
363
+ const ran = main.report.failed.filter((item) => !item.staticLoad);
364
+ if (ran.length === 0)
365
+ return finishMutant(unviable(base, direction, "compiler"), pkg, commands.join(" | "), false, []);
341
366
  if (!options.confirm)
342
- return finishMutant(killed(base, direction, main.report.failed.map((item) => failureId(pkg, item))), pkg, commands.join(" | "), whole, chosen);
343
- const confirmed = await confirmFailures(pkg, options, main.report.failed, prep, limit, commands);
367
+ return finishMutant(killed(base, direction, ran.map((item) => failureId(pkg, item))), pkg, commands.join(" | "), whole, chosen);
368
+ const confirmed = await confirmFailures(pkg, options, ran, prep, limit, commands);
344
369
  const killedBy = confirmed.killed.map((item) => failureId(pkg, item));
345
370
  const flaky = confirmed.flaky.map((item) => failureId(pkg, item));
346
371
  const outcome = killedBy.length > 0 ? "killed" : "flaky";
@@ -368,16 +393,31 @@ function killed(base, direction, killedBy) {
368
393
  const label = killLabel(killedBy);
369
394
  return { ...base(), direction, outcome: "killed", killedBy, cause: label?.cause ?? "test", next: label?.next ?? "" };
370
395
  }
396
+ /** The compiler rejected the mutant. `killedBy` names the compiler so the record says which one. */
397
+ function unviable(base, direction, compiler) {
398
+ return { ...base(), direction, outcome: "unviable", killedBy: [compiler], cause: "build", next: UNVIABLE_NEXT };
399
+ }
400
+ /**
401
+ * Results written before `unviable` existed stored a compile failure as killed with cause build.
402
+ * Read them the new way so an old out dir does not count a compiler as a test.
403
+ */
404
+ export function normalizeResult(result) {
405
+ if (result.outcome === "killed" && result.cause === "build")
406
+ return { ...result, outcome: "unviable", next: UNVIABLE_NEXT };
407
+ return result;
408
+ }
371
409
  async function confirmFailures(pkg, options, failed, prep, timeoutMs, commands) {
372
410
  const counts = new Map();
373
411
  let wholeSuite = false;
374
412
  const named = new Map();
375
413
  const hungFiles = new Map();
414
+ const unloaded = new Map();
376
415
  for (const failure of failed) {
377
416
  const key = failure.file || "*";
378
417
  // A name filter skips the tests that left the handle open, so the file
379
418
  // timeout never comes back. Rerun that file with no name pattern.
380
- const bucket = failure.fileTimeout ? hungFiles : named;
419
+ // A file that did not load has no test name to filter by either.
420
+ const bucket = failure.fileTimeout ? hungFiles : failure.fileLoad ? unloaded : named;
381
421
  const list = bucket.get(key) ?? [];
382
422
  list.push(failure);
383
423
  bucket.set(key, list);
@@ -391,13 +431,17 @@ async function confirmFailures(pkg, options, failed, prep, timeoutMs, commands)
391
431
  if (await rerunGroup(pkg, options, file, failures, counts, null, prep, timeoutMs, commands))
392
432
  wholeSuite = true;
393
433
  }
434
+ for (const [file, failures] of unloaded) {
435
+ if (await rerunGroup(pkg, options, file, failures, counts, null, prep, timeoutMs, commands, "load"))
436
+ wholeSuite = true;
437
+ }
394
438
  const killed = [];
395
439
  const flaky = [];
396
440
  for (const failure of failed)
397
441
  (counts.get(failureId(pkg, failure)) === 2 ? killed : flaky).push(failure);
398
442
  return { killed, flaky, wholeSuite };
399
443
  }
400
- async function rerunGroup(pkg, options, file, failures, counts, pattern, prep, timeoutMs, commands) {
444
+ async function rerunGroup(pkg, options, file, failures, counts, pattern, prep, timeoutMs, commands, fileFailure = "timeout") {
401
445
  const files = file === "*" ? listTestFiles(pkg, options.testsDir, prep.spec) : [testArg(pkg, file, options.testsDir)];
402
446
  const isolated = { ...options, concurrency: 1 };
403
447
  let wholeSuite = false;
@@ -409,7 +453,7 @@ async function rerunGroup(pkg, options, file, failures, counts, pattern, prep, t
409
453
  wholeSuite = true;
410
454
  if (rerun.command)
411
455
  commands.push(rerun.command);
412
- if (pattern === null && rerun.timedOut) {
456
+ if (pattern === null && fileFailure === "timeout" && rerun.timedOut) {
413
457
  for (const id of new Set(failures.map((item) => failureId(pkg, item))))
414
458
  bump(counts, id);
415
459
  continue;
@@ -418,7 +462,7 @@ async function rerunGroup(pkg, options, file, failures, counts, pattern, prep, t
418
462
  continue;
419
463
  const seen = new Set();
420
464
  for (const hit of rerun.report.failed) {
421
- if (pattern === null && !hit.fileTimeout)
465
+ if (pattern === null && !(fileFailure === "timeout" ? hit.fileTimeout : hit.fileLoad))
422
466
  continue;
423
467
  const id = failureId(pkg, hit);
424
468
  if (failures.some((item) => failureId(pkg, item) === id))
@@ -552,7 +596,7 @@ class NodeBatch {
552
596
  return {
553
597
  report: null,
554
598
  timedOut: false,
555
- detail: waited.error || "test reporter returned nothing",
599
+ detail: waited.error || (selected.length > 0 ? `no requested test ran (${selected.join(", ")})` : "test reporter returned nothing"),
556
600
  compileToken: null,
557
601
  command: waited.command || "",
558
602
  wholeSuite: false,
@@ -615,6 +659,11 @@ function isNodeTestFile(file) {
615
659
  const base = path.posix.basename(file);
616
660
  return /\.(test|spec)\.(ts|tsx|mts|js|mjs|cjs)$/.test(base) || /_test\.(ts|tsx|js|mjs)$/.test(base);
617
661
  }
662
+ /** A node keep id may carry its file (`tests/gate.test.ts::zero stays shut`). node:test matches the title. */
663
+ export function nodeTitle(id) {
664
+ const match = /^[^\s:][^:]*\.(?:[cm]?[jt]s|[jt]sx)::(.+)$/.exec(id);
665
+ return match ? match[1] : id;
666
+ }
618
667
  function narrowNodeFiles(pkg, files, names, kind) {
619
668
  if (kind !== "node" || !names || names.length === 0)
620
669
  return files;
@@ -652,6 +701,18 @@ function childEnv(unset) {
652
701
  if (key === "NODE_TEST_CONTEXT" || key.startsWith("NODE_TEST_"))
653
702
  delete env[key];
654
703
  }
704
+ // This run may itself be a child of a suite that another Probatio run is mapping.
705
+ // That outer map is not this run's. Only the baseline sets a map, through runDiscoveredSuite.
706
+ delete env.PROBATIO_COVERAGE_MAP;
707
+ delete env.PROBATIO_PACKAGE_ROOT;
708
+ delete env.PROBATIO_CHILD_DIR;
709
+ if (env.NODE_OPTIONS) {
710
+ const kept = env.NODE_OPTIONS.split(/\s+/).filter((part) => part.length > 0 && !/child-lines\.mjs$/.test(part)).join(" ");
711
+ if (kept)
712
+ env.NODE_OPTIONS = kept;
713
+ else
714
+ delete env.NODE_OPTIONS;
715
+ }
655
716
  for (const key of unset)
656
717
  delete env[key];
657
718
  return env;
@@ -753,7 +814,7 @@ function loadRun(options, commit) {
753
814
  writeFileSync(file, `${JSON.stringify(record, null, 2)}\n`);
754
815
  return { id };
755
816
  }
756
- async function createWorktrees(repo, packageDir, runId, commit, workers, outDir) {
817
+ export async function createWorktrees(repo, packageDir, runId, commit, workers, outDir) {
757
818
  removeWorktrees(repo, recordedWorktrees(outDir), outDir);
758
819
  const dirs = [];
759
820
  for (let index = 0; index < workers; index++) {
@@ -772,8 +833,12 @@ async function createWorktrees(repo, packageDir, runId, commit, workers, outDir)
772
833
  }
773
834
  writeFileSync(path.join(outDir, "worktrees.json"), `${JSON.stringify(dirs)}\n`);
774
835
  const sources = nodeModuleSources(repo, packageDir);
836
+ const config = path.join(packageDir, CONFIG_FILE);
775
837
  for (const dir of dirs) {
776
838
  const pkgRoot = packageIn(dir, repo, packageDir);
839
+ // How to run the suite is not the code under test. An uncommitted .probatio.json still applies.
840
+ if (existsSync(config) && !existsSync(path.join(pkgRoot, CONFIG_FILE)))
841
+ copyFileSync(config, path.join(pkgRoot, CONFIG_FILE));
777
842
  for (const source of sources) {
778
843
  const dest = path.join(pkgRoot, source.rel);
779
844
  if (existsSync(dest))
@@ -819,7 +884,7 @@ export function probatioWorktreeDir(dir) {
819
884
  return false;
820
885
  return /^probatio-[0-9a-f]+-\d+$/.test(path.basename(resolved));
821
886
  }
822
- function removeWorktrees(repo, dirs, outDir) {
887
+ export function removeWorktrees(repo, dirs, outDir) {
823
888
  for (const dir of dirs) {
824
889
  if (!probatioWorktreeDir(dir))
825
890
  continue;
@@ -909,7 +974,7 @@ function findNodeModules(root) {
909
974
  visit(root);
910
975
  return out;
911
976
  }
912
- function packageIn(worktree, repo, packageDir) {
977
+ export function packageIn(worktree, repo, packageDir) {
913
978
  const rel = path.relative(repo, packageDir);
914
979
  return rel && !rel.startsWith("..") ? path.join(worktree, rel) : worktree;
915
980
  }
@@ -932,11 +997,12 @@ function readResult(outDir, id) {
932
997
  const file = resultPath(outDir, id);
933
998
  if (!existsSync(file))
934
999
  return null;
935
- return JSON.parse(readFileSync(file, "utf8"));
1000
+ return normalizeResult(JSON.parse(readFileSync(file, "utf8")));
936
1001
  }
937
1002
  function reportOf(results, context) {
938
1003
  const count = (outcome) => results.filter((result) => result.outcome === outcome).length;
939
1004
  const killed = count("killed");
1005
+ const unviableCount = count("unviable");
940
1006
  const survived = count("survived");
941
1007
  const flaky = count("flaky");
942
1008
  const timeouts = count("timeout");
@@ -950,7 +1016,7 @@ function reportOf(results, context) {
950
1016
  return { id: result.id, file, line, ...(known?.op ? { op: known.op } : {}) };
951
1017
  });
952
1018
  const budgetHit = context.budgetHit || context.pendingLeft > 0;
953
- const summary = campaignSummary({ noCoverage, survived, killed, flaky, timeouts, errors, finished: results.length });
1019
+ const summary = campaignSummary({ noCoverage, survived, killed, unviable: unviableCount, flaky, timeouts, errors, finished: results.length });
954
1020
  const notes = [];
955
1021
  const uncovered = results.find((result) => result.outcome === "no coverage");
956
1022
  if (uncovered?.next)
@@ -976,6 +1042,8 @@ function reportOf(results, context) {
976
1042
  commit: context.commit,
977
1043
  runId: context.runId,
978
1044
  killed,
1045
+ unviable: unviableCount,
1046
+ unviableIds: results.filter((result) => result.outcome === "unviable").slice(0, 10).map((result) => result.id),
979
1047
  survived,
980
1048
  flaky,
981
1049
  timeouts,
@@ -1035,6 +1103,8 @@ function failReport(outDir, runId, commit, error, baselineFailed, next, suiteCom
1035
1103
  commit,
1036
1104
  runId,
1037
1105
  killed: 0,
1106
+ unviable: 0,
1107
+ unviableIds: [],
1038
1108
  survived: 0,
1039
1109
  flaky: 0,
1040
1110
  timeouts: 0,
@@ -1199,10 +1269,19 @@ function failureId(pkg, failure) {
1199
1269
  return BARE_SUITE.has(failure.name) ? `::${failure.name}` : failure.name;
1200
1270
  if (!testFileOnDisk(pkg, failure.file))
1201
1271
  return failure.name;
1202
- const rel = path.relative(pkg, failure.file);
1272
+ // macOS temp dirs are /var and /private/var. A report can name either, so compare real paths.
1273
+ const rel = path.relative(realOrSame(pkg), realOrSame(path.resolve(pkg, failure.file)));
1203
1274
  const file = !rel || rel.startsWith("..") ? path.basename(failure.file) : rel.split(path.sep).join("/");
1204
1275
  return `${file}::${failure.name}`;
1205
1276
  }
1277
+ function realOrSame(file) {
1278
+ try {
1279
+ return realpathSync(file);
1280
+ }
1281
+ catch {
1282
+ return file;
1283
+ }
1284
+ }
1206
1285
  function testFileOnDisk(pkg, file) {
1207
1286
  if (path.isAbsolute(file))
1208
1287
  return existsSync(file);
@@ -1215,7 +1294,9 @@ function pytestNodeId(name) {
1215
1294
  return name.slice(0, mark).endsWith(".py");
1216
1295
  }
1217
1296
  function testArg(pkg, file, testsDir) {
1218
- const rel = path.relative(pkg, file);
1297
+ // Compare real paths. A report can name /private/var while the package is /var on macOS, and the
1298
+ // basename fallback below would then rerun tests/<name> instead of tests/unit/<name>.
1299
+ const rel = path.relative(realOrSame(pkg), realOrSame(path.resolve(pkg, file)));
1219
1300
  if (rel && !rel.startsWith(".."))
1220
1301
  return rel.split(path.sep).join("/");
1221
1302
  return path.join(testsDir, path.basename(file));
@@ -1,3 +1,4 @@
1
+ import { spawnSync } from "node:child_process";
1
2
  import { readdirSync, readFileSync, statSync } from "node:fs";
2
3
  import path from "node:path";
3
4
  /** The label stays in its own file. The run report must not repeat these three values. */
@@ -62,3 +63,56 @@ function field(text, key) {
62
63
  const match = text.match(new RegExp(`^${key}=(.*)$`, "m"));
63
64
  return match?.[1]?.trim() ?? "";
64
65
  }
66
+ /**
67
+ * A sealed run hides the revealing test, but a fix commit often edits an older test too.
68
+ * A kill by that edited test is the fix's own test, not an older test that caught the bug.
69
+ * Returns the edited test files that killed, and whether any kill came from a test the fix left alone.
70
+ */
71
+ export function fixEditedKillers(repo, packageDir, commit, hide, kills) {
72
+ if (kills.length === 0)
73
+ return { files: [], olderTestCatch: false };
74
+ const diff = spawnSync("git", ["-C", repo, "diff", "--name-only", `${commit}^`, commit], { encoding: "utf8" });
75
+ // No parent, or git failed: nothing is known to be edited, so every kill stays an older-test catch.
76
+ if (diff.status !== 0)
77
+ return { files: [], olderTestCatch: true };
78
+ const hidden = new Set(hide.map((rel) => rel.split(path.sep).join("/")));
79
+ const edited = diff.stdout
80
+ .split("\n")
81
+ .filter((rel) => rel.length > 0)
82
+ .map((rel) => ({ repoRel: rel, pkgRel: path.relative(packageDir, path.join(repo, rel)).split(path.sep).join("/") }))
83
+ .filter((item) => !item.pkgRel.startsWith("..") && !hidden.has(item.pkgRel));
84
+ const text = new Map();
85
+ const contents = (repoRel) => {
86
+ if (!text.has(repoRel)) {
87
+ const shown = spawnSync("git", ["-C", repo, "show", `${commit}:${repoRel}`], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
88
+ text.set(repoRel, shown.status === 0 ? shown.stdout : "");
89
+ }
90
+ return text.get(repoRel) ?? "";
91
+ };
92
+ const fileFor = (id) => edited.find((item) => killerInFile(id, item.pkgRel, () => contents(item.repoRel)));
93
+ const files = new Set();
94
+ let olderTestCatch = false;
95
+ for (const kill of kills) {
96
+ const owners = kill.killedBy.map(fileFor);
97
+ for (const owner of owners)
98
+ if (owner)
99
+ files.add(owner.pkgRel);
100
+ if (owners.some((owner) => !owner))
101
+ olderTestCatch = true;
102
+ }
103
+ return { files: [...files].sort(), olderTestCatch };
104
+ }
105
+ /** True when a kill id names this file, its class, or a test written in it. */
106
+ function killerInFile(id, rel, read) {
107
+ if (id.includes(rel) || id.startsWith(`${path.posix.basename(rel)}::`))
108
+ return true;
109
+ const stem = path.posix.basename(rel).replace(/\.[^.]+$/, "");
110
+ // JUnit `pkg.ClassTest.method` or `ClassTest#method`, Go and Rust paths.
111
+ if (stem.length > 0 && id.split(/[.#:/]+/).includes(stem))
112
+ return true;
113
+ const leaf = id.split(/::|#/).pop()?.split(/\.(?=[^.]+$)/).pop()?.replace(/\[.*\]$/, "").trim() ?? "";
114
+ if (leaf.length < 4)
115
+ return false;
116
+ const body = read();
117
+ return [`"${leaf}"`, `'${leaf}'`, `\`${leaf}\``, `${leaf}(`, `def ${leaf}`, `func ${leaf}`, `fn ${leaf}`].some((needle) => body.includes(needle));
118
+ }