probatio 0.0.5 → 0.1.2

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.
@@ -0,0 +1,188 @@
1
+ // Inspector line samples shared by the node:test collector, the Mocha collector,
2
+ // and a plain-script child. This file has no test hooks and writes no shard.
3
+ import { Session } from "node:inspector/promises"
4
+ import { findSourceMap } from "node:module"
5
+ import path from "node:path"
6
+ import { fileURLToPath } from "node:url"
7
+
8
+ const B64 = new Int32Array(128)
9
+ for (let i = 0; i < 64; i++) B64["ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/".charCodeAt(i)] = i
10
+
11
+ function decodeSegments(row) {
12
+ const segments = []
13
+ let i = 0
14
+ while (i < row.length) {
15
+ const fields = []
16
+ while (i < row.length && row.charCodeAt(i) !== 44) {
17
+ let value = 0
18
+ let shift = 0
19
+ let digit = 0
20
+ do {
21
+ digit = B64[row.charCodeAt(i++)]
22
+ value |= (digit & 31) << shift
23
+ shift += 5
24
+ } while (digit & 32)
25
+ fields.push(value & 1 ? -(value >> 1) : value >> 1)
26
+ }
27
+ if (row.charCodeAt(i) === 44) i++
28
+ if (fields.length > 0) segments.push(fields)
29
+ }
30
+ return segments
31
+ }
32
+
33
+ /**
34
+ * @param {(rel: string) => boolean} [skipRel]
35
+ * @returns {Promise<{ positiveLines: () => Promise<Array<{ file: string, line: number }>>, reset: () => Promise<void> }>}
36
+ */
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()
45
+ const indexCache = new Map()
46
+
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
+ function relPath(url) {
61
+ if (!url || url.startsWith("node:")) return null
62
+ let abs
63
+ try {
64
+ abs = fileURLToPath(url.split("?")[0].split("#")[0])
65
+ } catch {
66
+ return null
67
+ }
68
+ const rel = path.relative(process.cwd(), abs).split(path.sep).join("/")
69
+ if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) return null
70
+ if (rel.split("/").includes("node_modules")) return null
71
+ if (skipRel && skipRel(rel)) return null
72
+ return rel
73
+ }
74
+
75
+ function indexFor(scriptId, text, sm) {
76
+ if (indexCache.has(scriptId)) return indexCache.get(scriptId)
77
+ const mapped = sm && sm.payload && typeof sm.payload.mappings === "string" ? linesFromMappings(text, sm.payload.mappings) : null
78
+ const lines = mapped && hasLine(mapped) ? mapped : linesFromNewlines(text, sm)
79
+ indexCache.set(scriptId, lines)
80
+ return lines
81
+ }
82
+
83
+ function hasLine(lines) {
84
+ for (let i = 0; i < lines.length; i++) if (lines[i] > 0) return true
85
+ return false
86
+ }
87
+
88
+ function linesFromNewlines(text, sm) {
89
+ const lines = new Int32Array(text.length + 1)
90
+ if (!sm) {
91
+ let line = 1
92
+ for (let i = 0; i < text.length; i++) {
93
+ lines[i] = line
94
+ if (text.charCodeAt(i) === 10) line++
95
+ }
96
+ return lines
97
+ }
98
+ let genLine = 0
99
+ let col = 0
100
+ for (let i = 0; i < text.length; i++) {
101
+ const entry = sm.findEntry(genLine, col)
102
+ if (entry && Number.isInteger(entry.originalLine) && entry.originalLine >= 0) lines[i] = entry.originalLine + 1
103
+ if (text.charCodeAt(i) === 10) {
104
+ genLine++
105
+ col = 0
106
+ } else {
107
+ col++
108
+ }
109
+ }
110
+ return lines
111
+ }
112
+
113
+ function linesFromMappings(text, mappings) {
114
+ const lines = new Int32Array(text.length + 1)
115
+ const starts = [0]
116
+ for (let i = 0; i < text.length; i++) if (text.charCodeAt(i) === 10) starts.push(i + 1)
117
+ let carryLine = 0
118
+ const rows = mappings.split(";")
119
+ for (let genLine = 0; genLine < rows.length && genLine < starts.length; genLine++) {
120
+ const start = starts[genLine]
121
+ const next = genLine + 1 < starts.length ? starts[genLine + 1] : text.length
122
+ let col = 0
123
+ let cursor = start
124
+ let active = -1
125
+ for (const fields of decodeSegments(rows[genLine])) {
126
+ col += fields[0]
127
+ const at = Math.min(next, start + Math.max(0, col))
128
+ if (active >= 0) {
129
+ for (let i = cursor; i < at; i++) lines[i] = active + 1
130
+ }
131
+ cursor = at
132
+ if (fields.length >= 4) {
133
+ carryLine += fields[2]
134
+ active = carryLine
135
+ } else {
136
+ active = -1
137
+ }
138
+ }
139
+ if (active >= 0) {
140
+ for (let i = cursor; i < next; i++) lines[i] = active + 1
141
+ }
142
+ }
143
+ return lines
144
+ }
145
+
146
+ function applyRanges(text, script, lines) {
147
+ const ranges = []
148
+ for (const fn of script.functions || []) {
149
+ for (const range of fn.ranges || []) ranges.push(range)
150
+ }
151
+ ranges.sort((a, b) => a.startOffset - b.startOffset || b.endOffset - a.endOffset)
152
+ const offsets = new Int32Array(text.length)
153
+ for (const range of ranges) {
154
+ const end = Math.min(text.length, range.endOffset)
155
+ for (let i = Math.max(0, range.startOffset); i < end; i++) offsets[i] = range.count
156
+ }
157
+ const counts = new Map()
158
+ for (let i = 0; i < text.length; i++) {
159
+ const line = lines[i]
160
+ if (line <= 0 || offsets[i] <= 0) continue
161
+ if (offsets[i] > (counts.get(line) || 0)) counts.set(line, offsets[i])
162
+ }
163
+ return counts
164
+ }
165
+
166
+ async function positiveLines() {
167
+ const cov = await session.post("Profiler.takePreciseCoverage")
168
+ const found = []
169
+ 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
+ }
178
+ }
179
+ return found
180
+ }
181
+
182
+ return {
183
+ positiveLines,
184
+ reset: async () => {
185
+ await session.post("Profiler.takePreciseCoverage")
186
+ },
187
+ }
188
+ }
@@ -252,11 +252,17 @@ public class ProbatioJacocoRun {
252
252
  Class<?> selectorType = Class.forName("org.junit.platform.engine.DiscoverySelector");
253
253
  Class<?> selectorsType = Class.forName("org.junit.platform.engine.discovery.DiscoverySelectors");
254
254
  java.lang.reflect.Method select = selectMethodWithTypes(selectorsType);
255
+ boolean typed = select.getParameterCount() == 3;
255
256
  Object selectors = Array.newInstance(selectorType, unique.size());
256
257
  int index = 0;
257
258
  for (JupiterSpec spec : unique.values()) {
258
259
  Class<?> type = loadTestClass(spec.className);
259
- Object selector = select.invoke(null, new Object[] { type, spec.simple, spec.types });
260
+ // Platform 1.7 has selectMethod(Class, String) only. 5.14's two-argument
261
+ // form drops the parameter list and can run nothing, so keep the types
262
+ // when that overload exists.
263
+ Object selector = typed
264
+ ? select.invoke(null, new Object[] { type, spec.simple, spec.types })
265
+ : select.invoke(null, new Object[] { type, spec.simple });
260
266
  Array.set(selectors, index++, selector);
261
267
  }
262
268
  Class<?> builderType = Class.forName("org.junit.platform.launcher.core.LauncherDiscoveryRequestBuilder");
@@ -426,13 +432,16 @@ public class ProbatioJacocoRun {
426
432
  }
427
433
 
428
434
  static java.lang.reflect.Method selectMethodWithTypes(Class<?> selectorsType) throws NoSuchMethodException {
435
+ java.lang.reflect.Method byName = null;
429
436
  for (java.lang.reflect.Method candidate : selectorsType.getMethods()) {
430
437
  if (!candidate.getName().equals("selectMethod")) continue;
431
438
  Class<?>[] params = candidate.getParameterTypes();
432
439
  if (params.length == 3 && params[0] == Class.class && params[1] == String.class && params[2].isArray() && params[2].getComponentType() == Class.class) {
433
440
  return candidate;
434
441
  }
442
+ if (params.length == 2 && params[0] == Class.class && params[1] == String.class) byName = candidate;
435
443
  }
444
+ if (byName != null) return byName;
436
445
  throw new NoSuchMethodException("selectMethod(Class, String, Class[])");
437
446
  }
438
447
 
@@ -1,6 +1,6 @@
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, statSync, symlinkSync, writeFileSync } from "node:fs";
3
+ import { appendFileSync, 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";
@@ -13,7 +13,7 @@ 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
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";
16
- import { commandSuite, discoverSuite, runDiscoveredSuite, sourceCandidates } from "./suites.js";
16
+ import { commandSuite, discoverSuite, nodeTestTimeoutMs, runDiscoveredSuite, sourceCandidates } from "./suites.js";
17
17
  import { duplicateTitles } from "./titles.js";
18
18
  export const reporterPath = fileURLToPath(new URL("./fail-reporter.mjs", import.meta.url));
19
19
  export function parseReport(stdout) {
@@ -89,34 +89,61 @@ export async function runMutants(options) {
89
89
  budgetStarted = Date.now();
90
90
  const worker = async (worktree) => {
91
91
  const treePkg = packageIn(worktree, repo, packageDir);
92
- for (;;) {
93
- if (startedMutants > 0 && overBudget()) {
94
- budgetHit = true;
95
- return;
96
- }
97
- const patch = queue.shift();
98
- if (!patch)
99
- return;
100
- if (startedMutants > 0 && overBudget()) {
101
- queue.unshift(patch);
102
- budgetHit = true;
103
- return;
104
- }
105
- const id = path.basename(patch, ".patch");
106
- const raw = readFileSync(patch, "utf8");
107
- const prior = history.get(id);
108
- if (prior && reusable(prior, raw, treePkg)) {
92
+ let batch = null;
93
+ const dropBatch = () => {
94
+ batch?.stop();
95
+ batch = null;
96
+ };
97
+ try {
98
+ for (;;) {
99
+ if (startedMutants > 0 && overBudget()) {
100
+ budgetHit = true;
101
+ return;
102
+ }
103
+ const patch = queue.shift();
104
+ if (!patch)
105
+ return;
106
+ if (startedMutants > 0 && overBudget()) {
107
+ queue.unshift(patch);
108
+ budgetHit = true;
109
+ return;
110
+ }
111
+ const id = path.basename(patch, ".patch");
112
+ const raw = readFileSync(patch, "utf8");
113
+ const prior = history.get(id);
114
+ if (prior && reusable(prior, raw, treePkg)) {
115
+ startedMutants += 1;
116
+ const reused = reusedResult(prior, commit, run.id, options.agent);
117
+ writeResult(options.outDir, reused);
118
+ progress(`reused ${id}`);
119
+ continue;
120
+ }
109
121
  startedMutants += 1;
110
- const reused = reusedResult(prior, commit, run.id, options.agent);
111
- writeResult(options.outDir, reused);
112
- progress(`reused ${id}`);
113
- continue;
122
+ progress(`${id} start`);
123
+ const plan = batchPlan(raw, prep, options);
124
+ if (plan) {
125
+ if (!batch || batch.dead) {
126
+ dropBatch();
127
+ try {
128
+ batch = await NodeBatch.start(treePkg, options, options.outDir);
129
+ }
130
+ catch {
131
+ batch = null;
132
+ }
133
+ }
134
+ }
135
+ const launch = plan && batch
136
+ ? (pkg, files, names, timeoutMs) => batch.run(pkg, files, names, timeoutMs, id, options.outDir)
137
+ : undefined;
138
+ const result = await runOne(worktree, treePkg, patch, options, commit, run.id, prep, launch);
139
+ writeResult(options.outDir, result);
140
+ if (batch && (batch.dead || result.error === "suite process crashed"))
141
+ dropBatch();
142
+ progress(`${id} ${result.outcome}`);
114
143
  }
115
- startedMutants += 1;
116
- progress(`${id} start`);
117
- const result = await runOne(worktree, treePkg, patch, options, commit, run.id, prep);
118
- writeResult(options.outDir, result);
119
- progress(`${id} ${result.outcome}`);
144
+ }
145
+ finally {
146
+ dropBatch();
120
147
  }
121
148
  };
122
149
  await Promise.all(worktrees.dirs.map((dir) => worker(dir)));
@@ -217,7 +244,7 @@ async function prepare(pkg, options, progress) {
217
244
  const affectedMap = options.affected ? affectedTests(pkg, { testsDir: options.testsDir }) : null;
218
245
  return { ok: true, tests, affectedMap, baselineMs, spec, coverage: readCoverageMap(mapPath) };
219
246
  }
220
- async function runOne(worktree, pkg, patchPath, options, commit, runId, prep) {
247
+ async function runOne(worktree, pkg, patchPath, options, commit, runId, prep, launch) {
221
248
  const id = path.basename(patchPath, ".patch");
222
249
  const started = Date.now();
223
250
  const raw = readFileSync(patchPath, "utf8");
@@ -263,7 +290,8 @@ async function runOne(worktree, pkg, patchPath, options, commit, runId, prep) {
263
290
  return finishMutant(killed(base, direction, ["build"]), pkg, "", false, []);
264
291
  }
265
292
  const picked = selectionFor(prep.coverage, edited.length > 0 ? edited : files);
266
- if (picked.state === "uncovered") {
293
+ const forced = options.onlyNames && options.onlyNames.length > 0 ? options.onlyNames : null;
294
+ if (!forced && picked.state === "uncovered") {
267
295
  return {
268
296
  ...base(),
269
297
  direction,
@@ -271,14 +299,20 @@ async function runOne(worktree, pkg, patchPath, options, commit, runId, prep) {
271
299
  next: noCoverageNext(picked.file, picked.line),
272
300
  };
273
301
  }
274
- const names = picked.state === "covered" ? picked.tests.filter((name) => name !== WHOLE_PROGRAM_TEST) : null;
302
+ const names = forced ?? (picked.state === "covered" ? picked.tests.filter((name) => name !== WHOLE_PROGRAM_TEST) : null);
275
303
  const forceWhole = picked.state === "covered" && (names === null || names.length === 0);
276
304
  // node --test loads every file it is given, so a name pattern still pays for the other files.
277
305
  const selected = narrowNodeFiles(pkg, selectTests(files, prep.tests, prep.affectedMap), names, prep.spec.kind);
278
306
  const commands = [];
279
- const main = await runSuite(pkg, selected, options, null, names && names.length > 0 ? names : null, prep.spec, limit, forceWhole, null, null);
307
+ const named = names && names.length > 0 ? names : null;
308
+ const main = launch
309
+ ? await launch(pkg, selected, named, limit)
310
+ : await runSuite(pkg, selected, options, null, named, prep.spec, limit, forceWhole, null, null);
280
311
  if (main.command)
281
312
  commands.push(main.command);
313
+ if (main.crashed) {
314
+ return finishMutant({ ...base(), direction, outcome: "error", error: "suite process crashed" }, pkg, commands.join(" | "), false, main.selectedTests);
315
+ }
282
316
  if (main.timedOut) {
283
317
  return finishMutant({ ...base(), direction, outcome: "timeout", error: "suite timed out", next: TIMEOUT_NEXT }, pkg, commands.join(" | "), main.wholeSuite || forceWhole, main.selectedTests);
284
318
  }
@@ -403,6 +437,164 @@ async function runSuite(pkg, files, options, pattern, names, spec, timeoutMs, fo
403
437
  selectedTests: result.selectedTests,
404
438
  };
405
439
  }
440
+ function batchPlan(raw, prep, options) {
441
+ if (options.workers !== 1 || prep.spec.kind !== "node")
442
+ return null;
443
+ if (!prep.coverage || Object.keys(prep.coverage.files).length === 0)
444
+ return null;
445
+ const edited = changedLines(raw);
446
+ const files = filesInDiff(raw);
447
+ if (new Set(files.map((item) => item.file)).size !== 1)
448
+ return null;
449
+ const picked = selectionFor(prep.coverage, edited.length > 0 ? edited : files);
450
+ if (picked.state !== "covered")
451
+ return null;
452
+ const names = picked.tests.filter((name) => name !== WHOLE_PROGRAM_TEST && name.length > 0);
453
+ if (names.length === 0)
454
+ return null;
455
+ return { names };
456
+ }
457
+ /** Covered node mutants of one file share this process. A crash sets dead and the next mutant starts another. */
458
+ class NodeBatch {
459
+ pid;
460
+ dead = false;
461
+ testTimeoutMs;
462
+ proc;
463
+ constructor(proc, pid, testTimeoutMs) {
464
+ this.proc = proc;
465
+ this.pid = pid;
466
+ this.testTimeoutMs = testTimeoutMs;
467
+ proc.on("close", () => {
468
+ this.dead = true;
469
+ });
470
+ }
471
+ static async start(pkg, options, outDir) {
472
+ const dir = path.join(outDir, "batch");
473
+ mkdirSync(dir, { recursive: true });
474
+ const ready = path.join(dir, `ready-${process.pid}-${Date.now()}.json`);
475
+ const gen = path.join(dir, `gen-${process.pid}-${Date.now()}.txt`);
476
+ writeFileSync(gen, "0\n");
477
+ const script = fileURLToPath(new URL("./node-batch.mjs", import.meta.url));
478
+ const env = childEnv(options.unset);
479
+ env.PB_READY = ready;
480
+ env.PB_GEN = gen;
481
+ env.PB_ROOT = pkg;
482
+ const proc = spawn(process.execPath, [script], { cwd: pkg, env, stdio: ["pipe", "ignore", "pipe"] });
483
+ const started = Date.now();
484
+ while (!existsSync(ready)) {
485
+ if (proc.exitCode !== null)
486
+ throw new Error("batch process exited before ready");
487
+ if (Date.now() - started > 10_000) {
488
+ proc.kill("SIGKILL");
489
+ throw new Error("batch process did not start");
490
+ }
491
+ await new Promise((resolve) => setTimeout(resolve, 20));
492
+ }
493
+ const body = JSON.parse(readFileSync(ready, "utf8"));
494
+ return new NodeBatch(proc, body.pid || proc.pid || 0, options.testTimeoutMs);
495
+ }
496
+ stop() {
497
+ this.dead = true;
498
+ try {
499
+ this.proc.stdin?.end();
500
+ }
501
+ catch {
502
+ // The pipe is already closed.
503
+ }
504
+ try {
505
+ if (this.proc.pid)
506
+ process.kill(this.proc.pid, "SIGTERM");
507
+ }
508
+ catch {
509
+ // The process has already exited.
510
+ }
511
+ }
512
+ async run(pkg, files, names, timeoutMs, id, outDir) {
513
+ const selected = names ?? [];
514
+ const replyFile = path.join(outDir, "batch", `${id}.reply.json`);
515
+ rmSync(replyFile, { force: true });
516
+ appendFileSync(path.join(outDir, "batch-processes.jsonl"), `${JSON.stringify({ id, pid: this.pid })}\n`);
517
+ const job = { id, root: pkg, tests: files, names: selected, timeoutMs, testTimeoutMs: nodeTestTimeoutMs(pkg, files, this.testTimeoutMs, timeoutMs), replyFile };
518
+ try {
519
+ this.proc.stdin?.write(`${JSON.stringify(job)}\n`);
520
+ }
521
+ catch {
522
+ this.dead = true;
523
+ return crashedCall(selected);
524
+ }
525
+ const waited = await waitForReply(replyFile, this.proc, timeoutMs);
526
+ if (waited === "timeout") {
527
+ this.dead = true;
528
+ this.stop();
529
+ return { report: null, timedOut: true, detail: "suite timed out", compileToken: null, command: "", wholeSuite: false, selectedTests: selected };
530
+ }
531
+ if (!waited)
532
+ return crashedCall(selected);
533
+ const failed = waited.failed ?? [];
534
+ const seen = waited.names ?? [];
535
+ if (seen.length === 0 && failed.length === 0) {
536
+ return {
537
+ report: null,
538
+ timedOut: false,
539
+ detail: waited.error || "test reporter returned nothing",
540
+ compileToken: null,
541
+ command: waited.command || "",
542
+ wholeSuite: false,
543
+ selectedTests: selected,
544
+ };
545
+ }
546
+ return {
547
+ report: { tests: Math.max(seen.length, failed.length), pass: Math.max(0, seen.length - failed.length), fail: failed.length, failed, names: seen.length > 0 ? seen : selected },
548
+ timedOut: false,
549
+ detail: "",
550
+ compileToken: null,
551
+ command: waited.command || "",
552
+ wholeSuite: false,
553
+ selectedTests: selected,
554
+ };
555
+ }
556
+ }
557
+ function crashedCall(selected) {
558
+ return { report: null, timedOut: false, detail: "suite process crashed", compileToken: null, command: "", wholeSuite: false, selectedTests: selected, crashed: true };
559
+ }
560
+ function waitForReply(file, proc, timeoutMs) {
561
+ return new Promise((resolve) => {
562
+ let settled = false;
563
+ const finish = (value) => {
564
+ if (settled)
565
+ return;
566
+ settled = true;
567
+ clearTimeout(timer);
568
+ clearInterval(poll);
569
+ proc.removeListener("close", onClose);
570
+ resolve(value);
571
+ };
572
+ const read = () => {
573
+ try {
574
+ return JSON.parse(readFileSync(file, "utf8"));
575
+ }
576
+ catch {
577
+ return null;
578
+ }
579
+ };
580
+ const onClose = () => {
581
+ const reply = existsSync(file) ? read() : null;
582
+ finish(reply);
583
+ };
584
+ const timer = setTimeout(() => finish("timeout"), timeoutMs);
585
+ const poll = setInterval(() => {
586
+ if (!existsSync(file))
587
+ return;
588
+ const reply = read();
589
+ if (reply)
590
+ finish(reply);
591
+ }, 20);
592
+ if (proc.exitCode !== null || proc.signalCode !== null)
593
+ onClose();
594
+ else
595
+ proc.once("close", onClose);
596
+ });
597
+ }
406
598
  function isNodeTestFile(file) {
407
599
  const base = path.posix.basename(file);
408
600
  return /\.(test|spec)\.(ts|tsx|mts|js|mjs|cjs)$/.test(base) || /_test\.(ts|tsx|js|mjs)$/.test(base);
@@ -927,15 +1119,28 @@ function findTestFile(pkg, rel) {
927
1119
  function sha256(text) {
928
1120
  return createHash("sha256").update(text).digest("hex");
929
1121
  }
1122
+ const BARE_SUITE = new Set(["command", "suite", "pytest", "unittest", "cobol", "make-test", "c", "cpp"]);
930
1123
  function failureId(pkg, failure) {
931
1124
  // Pytest already reports a node id (tests/test_gate.py::test_low). Prepending the
932
1125
  // file doubles it, and pytest then exits 4 and collects nothing.
933
1126
  if (pytestNodeId(failure.name))
934
1127
  return failure.name;
935
- const rel = failure.file ? path.relative(pkg, failure.file) : "";
936
- const file = !rel || rel.startsWith("..") ? path.basename(failure.file || "") : rel.split(path.sep).join("/");
1128
+ // Cargo and dotnet report a bare test name. Go reports a package path that is
1129
+ // not a file, and JUnit reports a classname. The runnable id is the name itself.
1130
+ // An unnamed shell suite stays ::command so a tally does not treat it as a test.
1131
+ if (!failure.file)
1132
+ return BARE_SUITE.has(failure.name) ? `::${failure.name}` : failure.name;
1133
+ if (!testFileOnDisk(pkg, failure.file))
1134
+ return failure.name;
1135
+ const rel = path.relative(pkg, failure.file);
1136
+ const file = !rel || rel.startsWith("..") ? path.basename(failure.file) : rel.split(path.sep).join("/");
937
1137
  return `${file}::${failure.name}`;
938
1138
  }
1139
+ function testFileOnDisk(pkg, file) {
1140
+ if (path.isAbsolute(file))
1141
+ return existsSync(file);
1142
+ return existsSync(path.join(pkg, file));
1143
+ }
939
1144
  function pytestNodeId(name) {
940
1145
  const mark = name.indexOf("::");
941
1146
  if (mark <= 0)
@@ -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", "cargo", "dotnet"]);
33
+ const LINE_MAP_KINDS = new Set(["node", "pytest", "c", "go", "maven", "cargo", "dotnet", "mocha"]);
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. */