probatio 0.0.0-stage → 0.0.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,868 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createHash, randomBytes } from "node:crypto";
3
+ import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import path from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import { affectedTests } from "./affected.js";
8
+ import { binaryFromCommand, readCoverageMap, selectionFor, writeLlvmCoverage } from "./coverage-map.js";
9
+ import { writeGoCoverage } from "./go-coverage.js";
10
+ import { writeJavaCoverage } from "./java-coverage.js";
11
+ import { applyPatch, changedLines, chooseDirection, filesInDiff, git, restoreTree } from "./patch.js";
12
+ import { killLabel } from "./kill-label.js";
13
+ 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";
14
+ import { commandSuite, discoverSuite, runDiscoveredSuite, sourceCandidates } from "./suites.js";
15
+ import { duplicateTitles } from "./titles.js";
16
+ export const reporterPath = fileURLToPath(new URL("./fail-reporter.mjs", import.meta.url));
17
+ export function parseReport(stdout) {
18
+ const lines = stdout
19
+ .split("\n")
20
+ .map((line) => line.trim())
21
+ .filter((line) => line.startsWith("{"));
22
+ if (lines.length === 0)
23
+ return null;
24
+ try {
25
+ const parsed = JSON.parse(lines[lines.length - 1]);
26
+ if (!Array.isArray(parsed.failed) || !Array.isArray(parsed.names))
27
+ return null;
28
+ return parsed;
29
+ }
30
+ catch {
31
+ return null;
32
+ }
33
+ }
34
+ export async function runMutants(options) {
35
+ const repo = realpathSync(path.resolve(options.repoDir));
36
+ const packageDir = realpathSync(path.resolve(options.packageDir));
37
+ const sha = git(repo, ["rev-parse", "--verify", `${options.commit}^{commit}`]);
38
+ if (sha.status !== 0)
39
+ return failReport(options.outDir, "", options.commit, "that commit does not resolve");
40
+ const commit = sha.stdout.trim();
41
+ mkdirSync(options.outDir, { recursive: true });
42
+ const run = loadRun(options.outDir, commit, options.agent);
43
+ if ("error" in run)
44
+ return failReport(options.outDir, run.id, commit, run.error);
45
+ const listed = listPatches(options.patchDirs);
46
+ if ("error" in listed)
47
+ return failReport(options.outDir, run.id, commit, listed.error);
48
+ const patchFiles = options.onlyPatch
49
+ ? listed.files.filter((file) => path.basename(file, ".patch") === options.onlyPatch)
50
+ : listed.files;
51
+ if (patchFiles.length === 0)
52
+ return failReport(options.outDir, run.id, commit, "no patches");
53
+ const gate = resolveSuite(packageDir, options);
54
+ if (gate.action === "stop")
55
+ return failReport(options.outDir, run.id, commit, gate.summary, undefined, gate.next, gate.suiteCommand);
56
+ const testsHere = options.onlyTests && options.onlyTests.length > 0 ? options.onlyTests : gate.spec.files;
57
+ if (testsHere.length === 0)
58
+ return failReport(options.outDir, run.id, commit, `no tests in ${options.testsDir}`);
59
+ const scope = options.maxMutants === null ? patchFiles : patchFiles.slice(0, options.maxMutants);
60
+ const meta = loadMeta(options.patchDirs);
61
+ const pending = scope.filter((file) => !existsSync(resultPath(options.outDir, path.basename(file, ".patch"))));
62
+ const progress = (line) => options.onProgress?.(line);
63
+ const history = loadHistory(options.historyPath);
64
+ const worktrees = await createWorktrees(repo, packageDir, run.id, commit, options.workers, options.outDir);
65
+ if ("error" in worktrees)
66
+ return failReport(options.outDir, run.id, commit, worktrees.error);
67
+ const budgetMs = options.budgetMs ?? (options.maxMinutes == null ? null : options.maxMinutes * 60_000);
68
+ let budgetStarted = 0;
69
+ const overBudget = () => budgetMs !== null && budgetStarted !== 0 && Date.now() - budgetStarted > budgetMs;
70
+ try {
71
+ const pkg = packageIn(worktrees.dirs[0], repo, packageDir);
72
+ const prep = await prepare(pkg, options, progress);
73
+ if (!prep.ok)
74
+ return failReport(options.outDir, run.id, commit, prep.error, prep.baselineFailed, prep.next, prep.suiteCommand);
75
+ // The baseline already ran. A slow suite with no line map does not start the batch.
76
+ // A kind that can collect a map still refuses when that map was not written.
77
+ const mapped = prep.coverage !== null && Object.keys(prep.coverage.files).length > 0 && hasLineMap(prep.spec.kind);
78
+ const scale = scaleStop({ kind: mapped ? prep.spec.kind : "unmapped", baselineMs: prep.baselineMs, pending: pending.length });
79
+ if (scale.action === "stop") {
80
+ const ids = pending.map((file) => path.basename(file, ".patch"));
81
+ return failReport(options.outDir, run.id, commit, scale.summary, undefined, scale.next, prep.spec.command, prep.baselineMs, ids);
82
+ }
83
+ let budgetHit = false;
84
+ let startedMutants = 0;
85
+ const queue = [...pending];
86
+ budgetStarted = Date.now();
87
+ const worker = async (worktree) => {
88
+ const treePkg = packageIn(worktree, repo, packageDir);
89
+ for (;;) {
90
+ if (startedMutants > 0 && overBudget()) {
91
+ budgetHit = true;
92
+ return;
93
+ }
94
+ const patch = queue.shift();
95
+ if (!patch)
96
+ return;
97
+ if (startedMutants > 0 && overBudget()) {
98
+ queue.unshift(patch);
99
+ budgetHit = true;
100
+ return;
101
+ }
102
+ const id = path.basename(patch, ".patch");
103
+ const raw = readFileSync(patch, "utf8");
104
+ const prior = history.get(id);
105
+ if (prior && reusable(prior, raw, treePkg)) {
106
+ startedMutants += 1;
107
+ const reused = reusedResult(prior, commit, run.id, options.agent);
108
+ writeResult(options.outDir, reused);
109
+ progress(`reused ${id}`);
110
+ continue;
111
+ }
112
+ startedMutants += 1;
113
+ progress(`${id} start`);
114
+ const result = await runOne(worktree, treePkg, patch, options, commit, run.id, prep);
115
+ writeResult(options.outDir, result);
116
+ progress(`${id} ${result.outcome}`);
117
+ }
118
+ };
119
+ await Promise.all(worktrees.dirs.map((dir) => worker(dir)));
120
+ const results = scope.map((file) => readResult(options.outDir, path.basename(file, ".patch"))).filter((item) => item !== null);
121
+ const notStarted = scope
122
+ .map((file) => path.basename(file, ".patch"))
123
+ .filter((id) => !existsSync(resultPath(options.outDir, id)));
124
+ writeHistory(options.outDir, results);
125
+ return reportOf(results, {
126
+ outDir: options.outDir,
127
+ commit,
128
+ runId: run.id,
129
+ budgetHit,
130
+ meta,
131
+ pendingLeft: notStarted.length,
132
+ notStarted,
133
+ suiteCommand: prep.spec.command,
134
+ baselineMs: prep.baselineMs,
135
+ });
136
+ }
137
+ finally {
138
+ removeWorktrees(repo, worktrees.dirs, options.outDir);
139
+ }
140
+ }
141
+ async function prepare(pkg, options, progress) {
142
+ const gate = resolveSuite(pkg, options);
143
+ if (gate.action === "stop")
144
+ return { ok: false, error: gate.summary, next: gate.next, suiteCommand: gate.suiteCommand };
145
+ const tests = options.onlyTests && options.onlyTests.length > 0 ? [...options.onlyTests].sort() : gate.spec.files;
146
+ if (tests.length === 0)
147
+ return { ok: false, error: `no tests in ${options.testsDir}` };
148
+ const nodeTests = tests.filter((file) => isNodeTestFile(file));
149
+ const dupes = duplicateTitles(nodeTests.map((file) => ({ file, text: readFileSync(path.join(pkg, file), "utf8") })));
150
+ if (dupes.length > 0) {
151
+ const first = dupes[0];
152
+ return { ok: false, error: `duplicate test title "${first.title}" in ${first.files.join(", ")}` };
153
+ }
154
+ const spec = gate.spec;
155
+ progress(`suite ${spec.command}`);
156
+ const baselineFile = path.join(options.outDir, "baseline.json");
157
+ const mapPath = path.join(options.outDir, "coverage-map.json");
158
+ const profileDir = path.join(options.outDir, "llvm-profile");
159
+ const historyMap = historyCoverage(options.historyPath);
160
+ if (historyMap && !existsSync(mapPath))
161
+ copyFileSync(historyMap, mapPath);
162
+ let baselineFailed = [];
163
+ let baselineMs = null;
164
+ if (!existsSync(baselineFile)) {
165
+ if (options.build) {
166
+ progress(`build ${options.build.join(" ")}`);
167
+ const built = await spawnCollected(options.build[0], options.build.slice(1), pkg, childEnv(options.unset), Math.min(options.suiteTimeoutMs, 120_000));
168
+ if (built.timedOut || built.code !== 0) {
169
+ return { ok: false, error: built.timedOut ? "baseline build timed out" : `baseline build failed (${clip(built.stderr || built.stdout)})` };
170
+ }
171
+ }
172
+ progress("baseline start");
173
+ const collect = !existsSync(mapPath);
174
+ if (collect)
175
+ mkdirSync(profileDir, { recursive: true });
176
+ const startedAt = Date.now();
177
+ const baseline = await runSuite(pkg, tests, options, null, null, spec, options.suiteTimeoutMs, false, collect ? mapPath : null, collect ? path.join(profileDir, "baseline-%p.profraw") : null);
178
+ baselineMs = Date.now() - startedAt;
179
+ if (baseline.timedOut)
180
+ return { ok: false, error: "baseline suite timed out" };
181
+ if (baseline.compileToken)
182
+ return { ok: false, error: `baseline build failed (${baseline.compileToken})` };
183
+ if (!baseline.report)
184
+ return { ok: false, error: baselineReportError(baseline.detail) };
185
+ baselineFailed = baseline.report.failed.map((item) => item.name);
186
+ if (collect && !existsSync(mapPath)) {
187
+ const binary = binaryFromCommand(baseline.command || spec.command, pkg);
188
+ const written = writeLlvmCoverage(pkg, profileDir, binary, mapPath);
189
+ if (!written.ok && written.detail !== "no profraw" && written.detail !== "no profile directory" && written.detail !== "no instrumented binary") {
190
+ writeFileSync(path.join(options.outDir, "coverage-error.txt"), `${written.detail}\n`);
191
+ }
192
+ }
193
+ writeFileSync(baselineFile, `${JSON.stringify({ ok: baselineFailed.length === 0, tests: baseline.report.tests, failed: baselineFailed, command: spec.command, durationMs: baselineMs }, null, 2)}\n`);
194
+ progress(`suite report tests=${baseline.report.tests}`);
195
+ progress(baselineFailed.length > 0 ? `baseline failing ${baselineFailed.length}` : "baseline pass");
196
+ if (baselineFailed.length > 0) {
197
+ return { ok: false, error: "baseline already failing", baselineFailed, suiteCommand: spec.command };
198
+ }
199
+ if (collect && !existsSync(mapPath) && (spec.kind === "go" || spec.kind === "maven")) {
200
+ progress(spec.kind === "go" ? "go line map" : "java line map");
201
+ const written = spec.kind === "go"
202
+ ? writeGoCoverage(pkg, baseline.report.names, mapPath)
203
+ : writeJavaCoverage(pkg, baseline.report.names, mapPath);
204
+ if (!written.ok)
205
+ writeFileSync(path.join(options.outDir, "coverage-error.txt"), `${written.detail}\n`);
206
+ }
207
+ }
208
+ else {
209
+ const stored = JSON.parse(readFileSync(baselineFile, "utf8"));
210
+ baselineMs = typeof stored.durationMs === "number" ? stored.durationMs : null;
211
+ if (stored.ok !== true) {
212
+ const failed = Array.isArray(stored.failed) ? stored.failed.filter((name) => typeof name === "string") : [];
213
+ return { ok: false, error: "baseline already failing", baselineFailed: failed, suiteCommand: spec.command };
214
+ }
215
+ }
216
+ const affectedMap = options.affected ? affectedTests(pkg, { testsDir: options.testsDir }) : null;
217
+ return { ok: true, tests, affectedMap, baselineMs, spec, coverage: readCoverageMap(mapPath) };
218
+ }
219
+ async function runOne(worktree, pkg, patchPath, options, commit, runId, prep) {
220
+ const id = path.basename(patchPath, ".patch");
221
+ const started = Date.now();
222
+ const raw = readFileSync(patchPath, "utf8");
223
+ const files = filesInDiff(raw);
224
+ const edited = changedLines(raw);
225
+ const mutantHash = sha256(raw);
226
+ const base = () => ({
227
+ id,
228
+ outcome: "error",
229
+ direction: null,
230
+ files,
231
+ killedBy: [],
232
+ cause: null,
233
+ next: "",
234
+ flaky: [],
235
+ seconds: (Date.now() - started) / 1000,
236
+ commit,
237
+ agent: options.agent,
238
+ runId,
239
+ command: "",
240
+ wholeSuite: false,
241
+ reused: false,
242
+ selectedTests: [],
243
+ mutantHash,
244
+ testHashes: {},
245
+ });
246
+ const direction = chooseDirection(worktree, raw, options.direction);
247
+ if (!direction)
248
+ return { ...base(), error: "patch does not apply forward or in reverse" };
249
+ const applied = applyPatch(worktree, raw, direction);
250
+ if (!applied.ok)
251
+ return { ...base(), direction, error: applied.error };
252
+ const multiple = options.timeoutMultiple ?? TIMEOUT_MULTIPLE;
253
+ const floor = options.timeoutFloorMs ?? TIMEOUT_FLOOR_MS;
254
+ const limit = prep.baselineMs == null ? options.suiteTimeoutMs : Math.min(options.suiteTimeoutMs, timeoutLimitMs(prep.baselineMs, multiple, floor));
255
+ try {
256
+ if (options.build) {
257
+ const built = await spawnCollected(options.build[0], options.build.slice(1), pkg, childEnv(options.unset), Math.min(options.suiteTimeoutMs, 120_000));
258
+ if (built.timedOut)
259
+ return { ...base(), direction, outcome: "timeout", error: "build timed out", next: TIMEOUT_NEXT };
260
+ // The baseline build already succeeded. A mutant that no longer builds cannot pass the suite.
261
+ if (built.code !== 0)
262
+ return finishMutant(killed(base, direction, ["build"]), pkg, "", false, []);
263
+ }
264
+ const picked = selectionFor(prep.coverage, edited.length > 0 ? edited : files);
265
+ if (picked.state === "uncovered") {
266
+ return {
267
+ ...base(),
268
+ direction,
269
+ outcome: "no coverage",
270
+ next: noCoverageNext(picked.file, picked.line),
271
+ };
272
+ }
273
+ const names = picked.state === "covered" ? picked.tests.filter((name) => name !== WHOLE_PROGRAM_TEST) : null;
274
+ const forceWhole = picked.state === "covered" && (names === null || names.length === 0);
275
+ // node --test loads every file it is given, so a name pattern still pays for the other files.
276
+ const selected = narrowNodeFiles(pkg, selectTests(files, prep.tests, prep.affectedMap), names, prep.spec.kind);
277
+ const commands = [];
278
+ const main = await runSuite(pkg, selected, options, null, names && names.length > 0 ? names : null, prep.spec, limit, forceWhole, null, null);
279
+ if (main.command)
280
+ commands.push(main.command);
281
+ if (main.timedOut) {
282
+ return finishMutant({ ...base(), direction, outcome: "timeout", error: "suite timed out", next: TIMEOUT_NEXT }, pkg, commands.join(" | "), main.wholeSuite || forceWhole, main.selectedTests);
283
+ }
284
+ // A compile error can leave the previous run's report on disk. That report is not this mutant.
285
+ if (main.compileToken)
286
+ return finishMutant(killed(base, direction, [main.compileToken]), pkg, commands.join(" | "), false, []);
287
+ if (!main.report)
288
+ return { ...base(), direction, error: "test reporter returned nothing", command: commands.join(" | ") };
289
+ const whole = main.wholeSuite || forceWhole;
290
+ const chosen = main.selectedTests;
291
+ if (main.report.failed.length === 0)
292
+ return finishMutant({ ...base(), direction, outcome: "survived" }, pkg, commands.join(" | "), whole, chosen);
293
+ if (!options.confirm)
294
+ return finishMutant(killed(base, direction, main.report.failed.map((item) => failureId(pkg, item))), pkg, commands.join(" | "), whole, chosen);
295
+ const confirmed = await confirmFailures(pkg, options, main.report.failed, prep, limit, commands);
296
+ const killedBy = confirmed.killed.map((item) => failureId(pkg, item));
297
+ const flaky = confirmed.flaky.map((item) => failureId(pkg, item));
298
+ const outcome = killedBy.length > 0 ? "killed" : "flaky";
299
+ const label = killLabel(killedBy);
300
+ return finishMutant({ ...base(), direction, outcome, killedBy, flaky, cause: label?.cause ?? null, next: label?.next ?? "" }, pkg, commands.join(" | "), whole || confirmed.wholeSuite, chosen);
301
+ }
302
+ finally {
303
+ restoreTree(worktree);
304
+ }
305
+ }
306
+ function finishMutant(result, pkg, command, wholeSuite, selectedTests) {
307
+ const next = wholeSuite && !result.next.includes("cannot take a test name")
308
+ ? `${result.next}${result.next ? " " : ""}${WHOLE_SUITE_NEXT}`.trim()
309
+ : result.next;
310
+ return {
311
+ ...result,
312
+ command,
313
+ wholeSuite,
314
+ selectedTests,
315
+ next,
316
+ testHashes: hashesFor(pkg, result.killedBy),
317
+ };
318
+ }
319
+ function killed(base, direction, killedBy) {
320
+ const label = killLabel(killedBy);
321
+ return { ...base(), direction, outcome: "killed", killedBy, cause: label?.cause ?? "test", next: label?.next ?? "" };
322
+ }
323
+ async function confirmFailures(pkg, options, failed, prep, timeoutMs, commands) {
324
+ const counts = new Map();
325
+ let wholeSuite = false;
326
+ const named = new Map();
327
+ const hungFiles = new Map();
328
+ for (const failure of failed) {
329
+ const key = failure.file || "*";
330
+ // A name filter skips the tests that left the handle open, so the file
331
+ // timeout never comes back. Rerun that file with no name pattern.
332
+ const bucket = failure.fileTimeout ? hungFiles : named;
333
+ const list = bucket.get(key) ?? [];
334
+ list.push(failure);
335
+ bucket.set(key, list);
336
+ }
337
+ for (const [file, failures] of named) {
338
+ const pattern = `^(${failures.map((item) => escapeRegExp(item.name)).join("|")})$`;
339
+ if (await rerunGroup(pkg, options, file, failures, counts, pattern, prep, timeoutMs, commands))
340
+ wholeSuite = true;
341
+ }
342
+ for (const [file, failures] of hungFiles) {
343
+ if (await rerunGroup(pkg, options, file, failures, counts, null, prep, timeoutMs, commands))
344
+ wholeSuite = true;
345
+ }
346
+ const killed = [];
347
+ const flaky = [];
348
+ for (const failure of failed)
349
+ (counts.get(failureId(pkg, failure)) === 2 ? killed : flaky).push(failure);
350
+ return { killed, flaky, wholeSuite };
351
+ }
352
+ async function rerunGroup(pkg, options, file, failures, counts, pattern, prep, timeoutMs, commands) {
353
+ const files = file === "*" ? listTestFiles(pkg, options.testsDir, prep.spec) : [testArg(pkg, file, options.testsDir)];
354
+ const isolated = { ...options, concurrency: 1 };
355
+ let wholeSuite = false;
356
+ // Pytest confirms by node id. Maven confirms by class#method. A regex is not a test name for either.
357
+ const directNames = prep.spec.kind === "pytest" || prep.spec.kind === "maven" ? failures.map((item) => item.name) : null;
358
+ for (let attempt = 0; attempt < 2; attempt++) {
359
+ const rerun = await runSuite(pkg, files, isolated, directNames ? null : pattern, directNames, prep.spec, timeoutMs, false, null, null);
360
+ if (rerun.wholeSuite)
361
+ wholeSuite = true;
362
+ if (rerun.command)
363
+ commands.push(rerun.command);
364
+ if (pattern === null && rerun.timedOut) {
365
+ for (const failure of failures)
366
+ bump(counts, failureId(pkg, failure));
367
+ continue;
368
+ }
369
+ if (!rerun.report)
370
+ continue;
371
+ for (const hit of rerun.report.failed) {
372
+ if (pattern === null && !hit.fileTimeout)
373
+ continue;
374
+ const id = failureId(pkg, hit);
375
+ if (failures.some((item) => failureId(pkg, item) === id))
376
+ bump(counts, id);
377
+ }
378
+ }
379
+ return wholeSuite;
380
+ }
381
+ function bump(counts, id) {
382
+ counts.set(id, (counts.get(id) ?? 0) + 1);
383
+ }
384
+ async function runSuite(pkg, files, options, pattern, names, spec, timeoutMs, forceWhole, coverageMap, profileFile) {
385
+ const result = await runDiscoveredSuite(pkg, files, options.testsDir, pattern, timeoutMs, childEnv(options.unset), {
386
+ reporterPath,
387
+ testTimeoutMs: options.testTimeoutMs,
388
+ concurrency: options.concurrency,
389
+ }, {
390
+ spec,
391
+ names,
392
+ coverageMap,
393
+ profileFile,
394
+ });
395
+ return {
396
+ report: result.report,
397
+ timedOut: result.timedOut,
398
+ detail: result.detail,
399
+ compileToken: result.compileToken,
400
+ command: result.command,
401
+ wholeSuite: result.wholeSuite || forceWhole,
402
+ selectedTests: result.selectedTests,
403
+ };
404
+ }
405
+ function isNodeTestFile(file) {
406
+ const base = path.posix.basename(file);
407
+ return /\.(test|spec)\.(ts|tsx|mts|js|mjs|cjs)$/.test(base) || /_test\.(ts|tsx|js|mjs)$/.test(base);
408
+ }
409
+ function narrowNodeFiles(pkg, files, names, kind) {
410
+ if (kind !== "node" || !names || names.length === 0)
411
+ return files;
412
+ const hit = files.filter((file) => {
413
+ let text;
414
+ try {
415
+ text = readFileSync(path.join(pkg, file), "utf8");
416
+ }
417
+ catch {
418
+ return false;
419
+ }
420
+ return names.some((name) => text.includes(`"${name}"`) || text.includes(`'${name}'`) || text.includes("`" + name + "`"));
421
+ });
422
+ return hit.length > 0 ? hit : files;
423
+ }
424
+ function selectTests(files, allTests, affectedMap) {
425
+ if (!affectedMap)
426
+ return allTests;
427
+ const selected = new Set();
428
+ for (const file of files) {
429
+ const key = Object.keys(affectedMap).find((candidate) => file.file === candidate || file.file.endsWith(`/${candidate}`));
430
+ if (!key)
431
+ return allTests;
432
+ for (const test of affectedMap[key] ?? [])
433
+ selected.add(test);
434
+ }
435
+ if (selected.size === 0)
436
+ return allTests;
437
+ return [...selected].sort();
438
+ }
439
+ function childEnv(unset) {
440
+ const env = { ...process.env };
441
+ // A suite launched from inside node:test inherits this and then refuses to run.
442
+ for (const key of Object.keys(env)) {
443
+ if (key === "NODE_TEST_CONTEXT" || key.startsWith("NODE_TEST_"))
444
+ delete env[key];
445
+ }
446
+ for (const key of unset)
447
+ delete env[key];
448
+ return env;
449
+ }
450
+ function spawnCollected(bin, args, cwd, env, timeoutMs) {
451
+ return new Promise((resolve) => {
452
+ const child = spawn(bin, args, { cwd, env, detached: true, stdio: ["ignore", "pipe", "pipe"] });
453
+ let stdout = "";
454
+ let stderr = "";
455
+ let settled = false;
456
+ let timedOut = false;
457
+ const finish = (code) => {
458
+ if (settled)
459
+ return;
460
+ settled = true;
461
+ clearTimeout(timer);
462
+ resolve({ code, stdout, stderr, timedOut });
463
+ };
464
+ const timer = setTimeout(() => {
465
+ timedOut = true;
466
+ try {
467
+ if (child.pid)
468
+ process.kill(-child.pid, "SIGKILL");
469
+ }
470
+ catch {
471
+ child.kill("SIGKILL");
472
+ }
473
+ }, timeoutMs);
474
+ child.stdout?.on("data", (chunk) => {
475
+ stdout += chunk;
476
+ });
477
+ child.stderr?.on("data", (chunk) => {
478
+ stderr += chunk;
479
+ });
480
+ child.on("error", (error) => finishFromError(error));
481
+ child.on("close", (code) => finish(code ?? 1));
482
+ function finishFromError(error) {
483
+ stderr += error.message;
484
+ finish(1);
485
+ }
486
+ });
487
+ }
488
+ function listPatches(dirs) {
489
+ const files = [];
490
+ for (const dir of dirs) {
491
+ if (!existsSync(dir))
492
+ return { error: `patch directory not found: ${dir}` };
493
+ for (const name of readdirSync(dir))
494
+ if (name.endsWith(".patch"))
495
+ files.push(path.join(dir, name));
496
+ }
497
+ files.sort((left, right) => path.basename(left).localeCompare(path.basename(right)));
498
+ const names = files.map((file) => path.basename(file));
499
+ const duplicate = names.find((name, index) => names.indexOf(name) !== index);
500
+ if (duplicate)
501
+ return { error: `two patches share the name ${duplicate}` };
502
+ if (files.length === 0)
503
+ return { error: "no patches" };
504
+ return { files };
505
+ }
506
+ function loadMeta(dirs) {
507
+ const meta = new Map();
508
+ for (const dir of dirs) {
509
+ const file = path.join(dir, "mutants.json");
510
+ if (!existsSync(file))
511
+ continue;
512
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
513
+ for (const mutant of parsed.mutants ?? [])
514
+ meta.set(mutant.id, mutant);
515
+ }
516
+ return meta;
517
+ }
518
+ function loadRun(outDir, commit, agent) {
519
+ const file = path.join(outDir, "run.json");
520
+ if (existsSync(file)) {
521
+ const stored = JSON.parse(readFileSync(file, "utf8"));
522
+ if (stored.commit !== commit)
523
+ return { error: `out dir is for commit ${stored.commit.slice(0, 12)}`, id: stored.id };
524
+ return { id: stored.id };
525
+ }
526
+ const id = randomBytes(4).toString("hex");
527
+ writeFileSync(file, `${JSON.stringify({ id, commit, agent, started: new Date().toISOString() }, null, 2)}\n`);
528
+ return { id };
529
+ }
530
+ async function createWorktrees(repo, packageDir, runId, commit, workers, outDir) {
531
+ removeWorktrees(repo, recordedWorktrees(outDir), outDir);
532
+ const dirs = [];
533
+ for (let index = 0; index < workers; index++) {
534
+ const dir = path.join(tmpdir(), `probatio-${runId}-${index}`);
535
+ rmSync(dir, { recursive: true, force: true });
536
+ const added = git(repo, ["worktree", "add", "--detach", dir, commit]);
537
+ if (added.status !== 0) {
538
+ removeWorktrees(repo, dirs, outDir);
539
+ return { error: (added.stderr || "worktree add failed").trim() };
540
+ }
541
+ dirs.push(dir);
542
+ }
543
+ writeFileSync(path.join(outDir, "worktrees.json"), `${JSON.stringify(dirs)}\n`);
544
+ const linked = nodeModulesOf(repo, packageDir);
545
+ for (const dir of dirs) {
546
+ const dest = path.join(packageIn(dir, repo, packageDir), "node_modules");
547
+ if (linked && !existsSync(dest))
548
+ symlinkSync(linked, dest, "dir");
549
+ }
550
+ return { dirs };
551
+ }
552
+ function removeWorktrees(repo, dirs, outDir) {
553
+ for (const dir of dirs) {
554
+ git(repo, ["worktree", "remove", "--force", dir]);
555
+ rmSync(dir, { recursive: true, force: true });
556
+ }
557
+ git(repo, ["worktree", "prune"]);
558
+ rmSync(path.join(outDir, "worktrees.json"), { force: true });
559
+ }
560
+ function recordedWorktrees(outDir) {
561
+ const file = path.join(outDir, "worktrees.json");
562
+ if (!existsSync(file))
563
+ return [];
564
+ try {
565
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
566
+ return Array.isArray(parsed) ? parsed.filter((item) => typeof item === "string") : [];
567
+ }
568
+ catch {
569
+ return [];
570
+ }
571
+ }
572
+ /** A fresh worktree has no installed dependencies. Use the main checkout's node_modules. */
573
+ function nodeModulesOf(repo, packageDir) {
574
+ const local = path.join(packageDir, "node_modules");
575
+ if (existsSync(local))
576
+ return local;
577
+ const common = git(repo, ["rev-parse", "--path-format=absolute", "--git-common-dir"]);
578
+ if (common.status !== 0)
579
+ return null;
580
+ const gitDir = common.stdout.trim();
581
+ if (!gitDir.endsWith(`${path.sep}.git`) && !gitDir.endsWith("/.git"))
582
+ return null;
583
+ const rel = path.relative(repo, packageDir);
584
+ if (rel.startsWith(".."))
585
+ return null;
586
+ const candidate = path.join(path.dirname(gitDir), rel, "node_modules");
587
+ return existsSync(candidate) ? candidate : null;
588
+ }
589
+ function packageIn(worktree, repo, packageDir) {
590
+ const rel = path.relative(repo, packageDir);
591
+ return rel && !rel.startsWith("..") ? path.join(worktree, rel) : worktree;
592
+ }
593
+ function listTestFiles(pkg, testsDir, spec) {
594
+ if (spec && spec.files.length > 0 && spec.files[0] !== "suite-command")
595
+ return spec.files;
596
+ return discoverSuite(pkg, testsDir)?.files ?? [];
597
+ }
598
+ function resultPath(outDir, id) {
599
+ return path.join(outDir, "results", `${id}.json`);
600
+ }
601
+ function writeResult(outDir, result) {
602
+ const dest = resultPath(outDir, result.id);
603
+ mkdirSync(path.dirname(dest), { recursive: true });
604
+ const temp = `${dest}.${process.pid}.tmp`;
605
+ writeFileSync(temp, `${JSON.stringify(result, null, 2)}\n`);
606
+ renameSync(temp, dest);
607
+ }
608
+ function readResult(outDir, id) {
609
+ const file = resultPath(outDir, id);
610
+ if (!existsSync(file))
611
+ return null;
612
+ return JSON.parse(readFileSync(file, "utf8"));
613
+ }
614
+ function reportOf(results, context) {
615
+ const count = (outcome) => results.filter((result) => result.outcome === outcome).length;
616
+ const killed = count("killed");
617
+ const survived = count("survived");
618
+ const flaky = count("flaky");
619
+ const timeouts = count("timeout");
620
+ const noCoverage = count("no coverage");
621
+ const errors = results.filter((result) => result.outcome === "error" || result.outcome === "build-failed").length;
622
+ const gapResults = results.filter((result) => result.outcome === "survived");
623
+ const gaps = gapResults.slice(0, 10).map((result) => {
624
+ const known = context.meta.get(result.id);
625
+ const file = known?.file ?? result.files[0]?.file ?? "";
626
+ const line = known?.line ?? result.files[0]?.line ?? 0;
627
+ return { id: result.id, file, line, ...(known?.op ? { op: known.op } : {}) };
628
+ });
629
+ const budgetHit = context.budgetHit || context.pendingLeft > 0;
630
+ const summary = campaignSummary({ noCoverage, survived, killed, flaky, timeouts, errors, finished: results.length });
631
+ const notes = [];
632
+ const uncovered = results.find((result) => result.outcome === "no coverage");
633
+ if (uncovered?.next)
634
+ notes.push(uncovered.next);
635
+ if (gapResults.length > 0)
636
+ notes.push(`First gap is ${gaps[0].id} at ${gaps[0].file}:${gaps[0].line}. A survivor is not a pass.`);
637
+ if (timeouts > 0)
638
+ notes.push(TIMEOUT_NEXT);
639
+ if (budgetHit)
640
+ notes.push(resumeNext(context.notStarted));
641
+ if (results.some((result) => result.wholeSuite))
642
+ notes.push(WHOLE_SUITE_NEXT);
643
+ if (notes.length === 0)
644
+ notes.push("No survivor in this batch.");
645
+ return {
646
+ ok: true,
647
+ summary,
648
+ next: notes.join(" "),
649
+ budgetHit,
650
+ commit: context.commit,
651
+ runId: context.runId,
652
+ killed,
653
+ survived,
654
+ flaky,
655
+ timeouts,
656
+ errors,
657
+ noCoverage,
658
+ notStarted: context.notStarted,
659
+ suiteCommand: context.suiteCommand,
660
+ baselineMs: context.baselineMs,
661
+ commands: results.map((result) => ({
662
+ id: result.id,
663
+ outcome: result.outcome,
664
+ command: result.command,
665
+ wholeSuite: result.wholeSuite,
666
+ reused: result.reused,
667
+ cause: result.cause,
668
+ next: result.next,
669
+ })),
670
+ gaps,
671
+ rest: Math.max(gapResults.length - gaps.length, 0),
672
+ flakyIds: results.filter((result) => result.outcome === "flaky").slice(0, 10).map((result) => result.id),
673
+ kills: results
674
+ .filter((result) => result.outcome === "killed" && result.cause !== null)
675
+ .map((result) => ({ id: result.id, cause: result.cause, killedBy: result.killedBy, next: result.next })),
676
+ full: path.join(context.outDir, "results"),
677
+ };
678
+ }
679
+ function failReport(outDir, runId, commit, error, baselineFailed, next, suiteCommand = "", baselineMs = null, notStarted = []) {
680
+ const summary = baselineFailed && baselineFailed.length > 0 ? `${error}: ${baselineFailed.join("; ")}` : error;
681
+ return {
682
+ ok: false,
683
+ summary,
684
+ next: next ?? "Fix that and run the same command again.",
685
+ budgetHit: false,
686
+ commit,
687
+ runId,
688
+ killed: 0,
689
+ survived: 0,
690
+ flaky: 0,
691
+ timeouts: 0,
692
+ errors: 0,
693
+ noCoverage: 0,
694
+ notStarted,
695
+ suiteCommand,
696
+ baselineMs,
697
+ commands: [],
698
+ gaps: [],
699
+ rest: 0,
700
+ flakyIds: [],
701
+ kills: [],
702
+ full: path.join(outDir, "results"),
703
+ };
704
+ }
705
+ function resolveSuite(pkg, options) {
706
+ const discovered = discoverSuite(pkg, options.testsDir);
707
+ const makefilePath = path.join(pkg, "Makefile");
708
+ const makefile = existsSync(makefilePath) ? readFileSync(makefilePath, "utf8") : null;
709
+ const decision = decideSuite({
710
+ discovered: discovered ? { kind: discovered.kind, command: discovered.command } : null,
711
+ suiteCommand: options.suiteCommand ?? null,
712
+ candidates: sourceCandidates(pkg),
713
+ makefile,
714
+ artifactExists: (rel) => existsSync(path.resolve(pkg, rel)),
715
+ });
716
+ if (decision.action === "unknown")
717
+ return { action: "stop", summary: decision.summary, next: decision.next, suiteCommand: "" };
718
+ if (decision.action === "cannot-run")
719
+ return { action: "stop", summary: decision.summary, next: decision.next, suiteCommand: decision.command };
720
+ if (decision.action === "no-tests") {
721
+ return { action: "stop", summary: `no tests in ${options.testsDir}`, next: "Fix that and run the same command again.", suiteCommand: "" };
722
+ }
723
+ const command = options.suiteCommand?.trim() ?? "";
724
+ if (command) {
725
+ const files = discovered && discovered.files.length > 0 ? discovered.files : sourceCandidates(pkg);
726
+ return { action: "run", spec: commandSuite(command, files) };
727
+ }
728
+ if (!discovered)
729
+ return { action: "stop", summary: `no tests in ${options.testsDir}`, next: "Fix that and run the same command again.", suiteCommand: "" };
730
+ return { action: "run", spec: discovered };
731
+ }
732
+ function loadHistory(historyPath) {
733
+ const found = new Map();
734
+ if (!historyPath)
735
+ return found;
736
+ const file = historyPath.endsWith(".json") ? historyPath : path.join(historyPath, "history.json");
737
+ if (!existsSync(file))
738
+ return found;
739
+ try {
740
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
741
+ for (const entry of parsed.entries ?? []) {
742
+ if (entry && typeof entry.id === "string")
743
+ found.set(entry.id, entry);
744
+ }
745
+ }
746
+ catch {
747
+ return found;
748
+ }
749
+ return found;
750
+ }
751
+ function historyCoverage(historyPath) {
752
+ if (!historyPath)
753
+ return null;
754
+ const dir = historyPath.endsWith(".json") ? path.dirname(historyPath) : historyPath;
755
+ const file = path.join(dir, "coverage-map.json");
756
+ return existsSync(file) ? file : null;
757
+ }
758
+ function reusable(entry, raw, pkg) {
759
+ if (!entry.mutantHash || entry.mutantHash !== sha256(raw))
760
+ return false;
761
+ if (entry.outcome === "error" || entry.outcome === "build-failed")
762
+ return false;
763
+ for (const [file, hash] of Object.entries(entry.testHashes ?? {})) {
764
+ if (hashFile(pkg, file) !== hash)
765
+ return false;
766
+ }
767
+ return true;
768
+ }
769
+ function reusedResult(entry, commit, runId, agent) {
770
+ return {
771
+ id: entry.id,
772
+ outcome: entry.outcome,
773
+ direction: entry.direction,
774
+ files: entry.files ?? [],
775
+ killedBy: entry.killedBy ?? [],
776
+ cause: entry.cause,
777
+ next: entry.next ?? "",
778
+ flaky: entry.flaky ?? [],
779
+ error: entry.error,
780
+ seconds: 0,
781
+ commit,
782
+ agent,
783
+ runId,
784
+ command: "",
785
+ wholeSuite: entry.wholeSuite === true,
786
+ reused: true,
787
+ selectedTests: entry.selectedTests ?? [],
788
+ mutantHash: entry.mutantHash,
789
+ testHashes: entry.testHashes ?? {},
790
+ };
791
+ }
792
+ function writeHistory(outDir, results) {
793
+ const entries = results.map((result) => ({
794
+ id: result.id,
795
+ outcome: result.outcome,
796
+ direction: result.direction,
797
+ files: result.files,
798
+ killedBy: result.killedBy,
799
+ cause: result.cause,
800
+ next: result.next,
801
+ flaky: result.flaky,
802
+ error: result.error,
803
+ command: result.command,
804
+ wholeSuite: result.wholeSuite,
805
+ selectedTests: result.selectedTests,
806
+ mutantHash: result.mutantHash,
807
+ testHashes: result.testHashes,
808
+ }));
809
+ writeFileSync(path.join(outDir, "history.json"), `${JSON.stringify({ entries }, null, 2)}\n`);
810
+ }
811
+ function hashesFor(pkg, killedBy) {
812
+ const out = {};
813
+ for (const id of killedBy) {
814
+ const mark = id.indexOf("::");
815
+ if (mark <= 0)
816
+ continue;
817
+ const file = id.slice(0, mark);
818
+ const full = findTestFile(pkg, file);
819
+ if (!full)
820
+ continue;
821
+ const rel = path.relative(pkg, full).split(path.sep).join("/");
822
+ const key = rel.startsWith("..") ? file : rel;
823
+ out[key] = sha256(readFileSync(full));
824
+ }
825
+ return out;
826
+ }
827
+ function hashFile(pkg, rel) {
828
+ const full = findTestFile(pkg, rel);
829
+ if (!full)
830
+ return null;
831
+ return sha256(readFileSync(full));
832
+ }
833
+ function findTestFile(pkg, rel) {
834
+ const candidates = [path.isAbsolute(rel) ? rel : "", path.join(pkg, rel), path.join(pkg, "tests", path.basename(rel)), path.join(pkg, "test", path.basename(rel))].filter((file) => file.length > 0);
835
+ return candidates.find((file) => existsSync(file)) ?? null;
836
+ }
837
+ function sha256(text) {
838
+ return createHash("sha256").update(text).digest("hex");
839
+ }
840
+ function failureId(pkg, failure) {
841
+ const rel = failure.file ? path.relative(pkg, failure.file) : "";
842
+ const file = !rel || rel.startsWith("..") ? path.basename(failure.file || "") : rel.split(path.sep).join("/");
843
+ return `${file}::${failure.name}`;
844
+ }
845
+ function testArg(pkg, file, testsDir) {
846
+ const rel = path.relative(pkg, file);
847
+ if (rel && !rel.startsWith(".."))
848
+ return rel.split(path.sep).join("/");
849
+ return path.join(testsDir, path.basename(file));
850
+ }
851
+ function escapeRegExp(value) {
852
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
853
+ }
854
+ function baselineReportError(detail) {
855
+ const line = detail.trim();
856
+ // The first line of a Python or Node crash is a stack fragment. The summary
857
+ // states the limit and does not carry that fragment.
858
+ if (!line || isStackFragment(line))
859
+ return "baseline suite did not return a test report";
860
+ return `baseline suite did not return a test report (${line})`;
861
+ }
862
+ function isStackFragment(line) {
863
+ return line.startsWith("Traceback") || /^\s+at \S/.test(line) || /File ".+", line \d+/.test(line);
864
+ }
865
+ function clip(text) {
866
+ const line = text.trim().split("\n").find((item) => item.trim()) ?? "build failed";
867
+ return line.slice(0, 240);
868
+ }