probatio 0.2.0 → 0.5.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 (47) hide show
  1. package/README.md +10 -169
  2. package/bin/probatio.js +52 -0
  3. package/package.json +21 -25
  4. package/LICENSE +0 -21
  5. package/dist/cli.js +0 -610
  6. package/dist/contract.js +0 -31
  7. package/dist/flags.js +0 -87
  8. package/dist/golden/check.js +0 -188
  9. package/dist/ledger/build.js +0 -341
  10. package/dist/matrix/report.js +0 -256
  11. package/dist/mcp.js +0 -188
  12. package/dist/memory/state.js +0 -222
  13. package/dist/mutate/affected.js +0 -173
  14. package/dist/mutate/child-lines.mjs +0 -39
  15. package/dist/mutate/coverage-map.js +0 -229
  16. package/dist/mutate/csharp-coverage.js +0 -161
  17. package/dist/mutate/fail-reporter.mjs +0 -65
  18. package/dist/mutate/find.js +0 -13
  19. package/dist/mutate/generate.js +0 -153
  20. package/dist/mutate/go-coverage.js +0 -100
  21. package/dist/mutate/ids.js +0 -22
  22. package/dist/mutate/java-coverage.js +0 -217
  23. package/dist/mutate/kill-label.js +0 -34
  24. package/dist/mutate/load-failure.mjs +0 -40
  25. package/dist/mutate/mocha-coverage.cjs +0 -77
  26. package/dist/mutate/node-batch-loader.mjs +0 -99
  27. package/dist/mutate/node-batch.mjs +0 -167
  28. package/dist/mutate/node-coverage.mjs +0 -77
  29. package/dist/mutate/operators.js +0 -122
  30. package/dist/mutate/patch.js +0 -136
  31. package/dist/mutate/precise-lines.mjs +0 -304
  32. package/dist/mutate/probatio-jacoco-run.java +0 -516
  33. package/dist/mutate/project-config.js +0 -58
  34. package/dist/mutate/py-pytest-reporter.py +0 -145
  35. package/dist/mutate/py-reporter.py +0 -79
  36. package/dist/mutate/run.js +0 -1333
  37. package/dist/mutate/rust-coverage.js +0 -144
  38. package/dist/mutate/sealed.js +0 -118
  39. package/dist/mutate/suite-decision.js +0 -130
  40. package/dist/mutate/suites.js +0 -1524
  41. package/dist/mutate/tally.js +0 -240
  42. package/dist/mutate/text-operators.js +0 -547
  43. package/dist/mutate/titles.js +0 -23
  44. package/dist/node-version.js +0 -13
  45. package/dist/swarm/check-kill.js +0 -114
  46. package/dist/swarm/queue.js +0 -68
  47. package/dist/verify/change.js +0 -340
@@ -1,1333 +0,0 @@
1
- import { spawn } from "node:child_process";
2
- import { createHash, randomBytes } from "node:crypto";
3
- import { appendFileSync, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, 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 { writeCsharpCoverage } from "./csharp-coverage.js";
10
- import { writeGoCoverage } from "./go-coverage.js";
11
- import { writeJavaCoverage } from "./java-coverage.js";
12
- import { writeRustCoverage } from "./rust-coverage.js";
13
- import { applyPatch, changedLines, chooseDirection, filesInDiff, git, restoreTree } from "./patch.js";
14
- import { killLabel } from "./kill-label.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";
17
- import { commandSuite, discoverSuite, nodeTestTimeoutMs, runDiscoveredSuite, sourceCandidates } from "./suites.js";
18
- import { duplicateTitles } from "./titles.js";
19
- export const reporterPath = fileURLToPath(new URL("./fail-reporter.mjs", import.meta.url));
20
- export function parseReport(stdout) {
21
- const lines = stdout
22
- .split("\n")
23
- .map((line) => line.trim())
24
- .filter((line) => line.startsWith("{"));
25
- if (lines.length === 0)
26
- return null;
27
- try {
28
- const parsed = JSON.parse(lines[lines.length - 1]);
29
- if (!Array.isArray(parsed.failed) || !Array.isArray(parsed.names))
30
- return null;
31
- return parsed;
32
- }
33
- catch {
34
- return null;
35
- }
36
- }
37
- export async function runMutants(options) {
38
- const repo = realpathSync(path.resolve(options.repoDir));
39
- const packageDir = realpathSync(path.resolve(options.packageDir));
40
- const sha = git(repo, ["rev-parse", "--verify", `${options.commit}^{commit}`]);
41
- if (sha.status !== 0)
42
- return failReport(options.outDir, "", options.commit, "that commit does not resolve");
43
- const commit = sha.stdout.trim();
44
- mkdirSync(options.outDir, { recursive: true });
45
- const run = loadRun(options, commit);
46
- if ("error" in run)
47
- return failReport(options.outDir, run.id, commit, run.error);
48
- const listed = listPatches(options.patchDirs);
49
- if ("error" in listed)
50
- return failReport(options.outDir, run.id, commit, listed.error);
51
- const patchFiles = options.onlyPatch
52
- ? listed.files.filter((file) => path.basename(file, ".patch") === options.onlyPatch)
53
- : listed.files;
54
- if (patchFiles.length === 0)
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.`);
59
- const gate = resolveSuite(packageDir, options);
60
- if (gate.action === "stop")
61
- return failReport(options.outDir, run.id, commit, gate.summary, undefined, gate.next, gate.suiteCommand);
62
- const testsHere = options.onlyTests && options.onlyTests.length > 0 ? options.onlyTests : gate.spec.files;
63
- if (testsHere.length === 0)
64
- return failReport(options.outDir, run.id, commit, `no tests in ${options.testsDir}`);
65
- const scope = options.maxMutants === null ? patchFiles : patchFiles.slice(0, options.maxMutants);
66
- const meta = loadMeta(options.patchDirs);
67
- const pending = scope.filter((file) => !existsSync(resultPath(options.outDir, path.basename(file, ".patch"))));
68
- const progress = (line) => options.onProgress?.(line);
69
- const history = loadHistory(options.historyPath);
70
- const worktrees = await createWorktrees(repo, packageDir, run.id, commit, options.workers, options.outDir);
71
- if ("error" in worktrees)
72
- return failReport(options.outDir, run.id, commit, worktrees.error);
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);
81
- const budgetMs = options.budgetMs ?? (options.maxMinutes == null ? null : options.maxMinutes * 60_000);
82
- let budgetStarted = 0;
83
- const overBudget = () => budgetMs !== null && budgetStarted !== 0 && Date.now() - budgetStarted > budgetMs;
84
- try {
85
- const pkg = packageIn(worktrees.dirs[0], repo, packageDir);
86
- const prep = await prepare(pkg, options, progress);
87
- if (!prep.ok)
88
- return failReport(options.outDir, run.id, commit, prep.error, prep.baselineFailed, prep.next, prep.suiteCommand);
89
- // The baseline already ran. A slow suite with no line map does not start the batch.
90
- // A kind that can collect a map still refuses when that map was not written.
91
- const mapped = prep.coverage !== null && Object.keys(prep.coverage.files).length > 0 && hasLineMap(prep.spec.kind);
92
- const scale = scaleStop({ kind: mapped ? prep.spec.kind : "unmapped", baselineMs: prep.baselineMs, pending: pending.length });
93
- if (scale.action === "stop") {
94
- const ids = pending.map((file) => path.basename(file, ".patch"));
95
- return failReport(options.outDir, run.id, commit, scale.summary, undefined, scale.next, prep.spec.command, prep.baselineMs, ids);
96
- }
97
- let budgetHit = false;
98
- let startedMutants = 0;
99
- const queue = [...pending];
100
- budgetStarted = Date.now();
101
- const worker = async (worktree) => {
102
- const treePkg = packageIn(worktree, repo, packageDir);
103
- let batch = null;
104
- const dropBatch = () => {
105
- batch?.stop();
106
- batch = null;
107
- };
108
- try {
109
- for (;;) {
110
- if (startedMutants > 0 && overBudget()) {
111
- budgetHit = true;
112
- return;
113
- }
114
- const patch = queue.shift();
115
- if (!patch)
116
- return;
117
- if (startedMutants > 0 && overBudget()) {
118
- queue.unshift(patch);
119
- budgetHit = true;
120
- return;
121
- }
122
- const id = path.basename(patch, ".patch");
123
- const raw = readFileSync(patch, "utf8");
124
- const prior = history.get(id);
125
- if (prior && reusable(prior, raw, treePkg)) {
126
- startedMutants += 1;
127
- const reused = normalizeResult(reusedResult(prior, commit, run.id, options.agent));
128
- writeResult(options.outDir, reused);
129
- progress(`reused ${id}`);
130
- continue;
131
- }
132
- startedMutants += 1;
133
- progress(`${id} start`);
134
- const plan = batchPlan(raw, prep, options);
135
- if (plan) {
136
- if (!batch || batch.dead) {
137
- dropBatch();
138
- try {
139
- batch = await NodeBatch.start(treePkg, options, options.outDir);
140
- }
141
- catch {
142
- batch = null;
143
- }
144
- }
145
- }
146
- const launch = plan && batch
147
- ? (pkg, files, names, timeoutMs) => batch.run(pkg, files, names, timeoutMs, id, options.outDir)
148
- : undefined;
149
- const result = await runOne(worktree, treePkg, patch, options, commit, run.id, prep, launch);
150
- writeResult(options.outDir, result);
151
- if (batch && (batch.dead || result.error === "suite process crashed"))
152
- dropBatch();
153
- progress(`${id} ${result.outcome}`);
154
- }
155
- }
156
- finally {
157
- dropBatch();
158
- }
159
- };
160
- await Promise.all(worktrees.dirs.map((dir) => worker(dir)));
161
- const results = scope.map((file) => readResult(options.outDir, path.basename(file, ".patch"))).filter((item) => item !== null);
162
- const notStarted = scope
163
- .map((file) => path.basename(file, ".patch"))
164
- .filter((id) => !existsSync(resultPath(options.outDir, id)));
165
- writeHistory(options.outDir, results);
166
- return reportOf(results, {
167
- outDir: options.outDir,
168
- commit,
169
- runId: run.id,
170
- budgetHit,
171
- meta,
172
- pendingLeft: notStarted.length,
173
- notStarted,
174
- suiteCommand: prep.spec.command,
175
- baselineMs: prep.baselineMs,
176
- });
177
- }
178
- finally {
179
- process.removeListener("SIGTERM", onSignal);
180
- process.removeListener("SIGINT", onSignal);
181
- removeWorktrees(repo, worktrees.dirs, options.outDir);
182
- }
183
- }
184
- async function prepare(pkg, options, progress) {
185
- const gate = resolveSuite(pkg, options);
186
- if (gate.action === "stop")
187
- return { ok: false, error: gate.summary, next: gate.next, suiteCommand: gate.suiteCommand };
188
- const tests = options.onlyTests && options.onlyTests.length > 0 ? [...options.onlyTests].sort() : gate.spec.files;
189
- if (tests.length === 0)
190
- return { ok: false, error: `no tests in ${options.testsDir}` };
191
- const nodeTests = tests.filter((file) => isNodeTestFile(file));
192
- const dupes = duplicateTitles(nodeTests.map((file) => ({ file, text: readFileSync(path.join(pkg, file), "utf8") })));
193
- if (dupes.length > 0) {
194
- const first = dupes[0];
195
- return { ok: false, error: `duplicate test title "${first.title}" in ${first.files.join(", ")}` };
196
- }
197
- const spec = gate.spec;
198
- progress(`suite ${spec.command}`);
199
- const baselineFile = path.join(options.outDir, "baseline.json");
200
- const mapPath = path.join(options.outDir, "coverage-map.json");
201
- const profileDir = path.join(options.outDir, "llvm-profile");
202
- const historyMap = historyCoverage(options.historyPath);
203
- if (historyMap && !existsSync(mapPath))
204
- copyFileSync(historyMap, mapPath);
205
- let baselineFailed = [];
206
- let baselineMs = null;
207
- if (!existsSync(baselineFile)) {
208
- if (options.build) {
209
- progress(`build ${options.build.join(" ")}`);
210
- const built = await spawnCollected(options.build[0], options.build.slice(1), pkg, childEnv(options.unset), Math.min(options.suiteTimeoutMs, 120_000));
211
- if (built.timedOut || built.code !== 0) {
212
- return { ok: false, error: built.timedOut ? "baseline build timed out" : `baseline build failed (${clip(built.stderr || built.stdout)})` };
213
- }
214
- }
215
- progress("baseline start");
216
- const collect = !existsSync(mapPath);
217
- if (collect)
218
- mkdirSync(profileDir, { recursive: true });
219
- const startedAt = Date.now();
220
- const baseline = await runSuite(pkg, tests, options, null, null, spec, options.suiteTimeoutMs, false, collect ? mapPath : null, collect ? path.join(profileDir, "baseline-%p.profraw") : null);
221
- baselineMs = Date.now() - startedAt;
222
- if (baseline.timedOut)
223
- return { ok: false, error: "baseline suite timed out" };
224
- if (baseline.compileToken)
225
- return { ok: false, error: `baseline build failed (${baseline.compileToken})` };
226
- if (!baseline.report)
227
- return { ok: false, error: baselineReportError(baseline.detail, baseline.command || spec.command) };
228
- baselineFailed = baseline.report.failed.map((item) => item.name);
229
- if (collect && !existsSync(mapPath)) {
230
- const binary = binaryFromCommand(baseline.command || spec.command, pkg);
231
- const written = writeLlvmCoverage(pkg, profileDir, binary, mapPath);
232
- if (!written.ok && written.detail !== "no profraw" && written.detail !== "no profile directory" && written.detail !== "no instrumented binary") {
233
- writeFileSync(path.join(options.outDir, "coverage-error.txt"), `${written.detail}\n`);
234
- }
235
- }
236
- writeFileSync(baselineFile, `${JSON.stringify({ ok: baselineFailed.length === 0, tests: baseline.report.tests, failed: baselineFailed, command: spec.command, durationMs: baselineMs }, null, 2)}\n`);
237
- progress(`suite report tests=${baseline.report.tests}`);
238
- progress(baselineFailed.length > 0 ? `baseline failing ${baselineFailed.length}` : "baseline pass");
239
- if (baselineFailed.length > 0) {
240
- const tool = missingToolFrom(baseline.detail, baseline.command || spec.command);
241
- const unnamed = baselineFailed.every((name) => name === "command" || name === "::command");
242
- if (tool && unnamed)
243
- return { ok: false, error: `missing tool: ${tool}. baseline suite did not return a test report` };
244
- return { ok: false, error: "baseline already failing", baselineFailed, suiteCommand: spec.command };
245
- }
246
- if (collect && !existsSync(mapPath) && languageMapKind(spec.kind)) {
247
- progress(languageMapLabel(spec.kind));
248
- const written = writeLanguageMap(spec.kind, pkg, baseline.report.names, mapPath, spec.project);
249
- if (!written.ok)
250
- writeFileSync(path.join(options.outDir, "coverage-error.txt"), `${written.detail}\n`);
251
- }
252
- }
253
- else {
254
- const stored = JSON.parse(readFileSync(baselineFile, "utf8"));
255
- baselineMs = typeof stored.durationMs === "number" ? stored.durationMs : null;
256
- if (stored.ok !== true) {
257
- const failed = Array.isArray(stored.failed) ? stored.failed.filter((name) => typeof name === "string") : [];
258
- return { ok: false, error: "baseline already failing", baselineFailed: failed, suiteCommand: spec.command };
259
- }
260
- }
261
- const affectedMap = options.affected ? affectedTests(pkg, { testsDir: options.testsDir }) : null;
262
- return { ok: true, tests, affectedMap, baselineMs, spec, coverage: readCoverageMap(mapPath) };
263
- }
264
- async function runOne(worktree, pkg, patchPath, options, commit, runId, prep, launch) {
265
- const id = path.basename(patchPath, ".patch");
266
- const started = Date.now();
267
- const raw = readFileSync(patchPath, "utf8");
268
- const files = filesInDiff(raw);
269
- const edited = changedLines(raw);
270
- const located = edited.length > 0 ? edited : files;
271
- const mutantHash = sha256(raw);
272
- const base = () => ({
273
- id,
274
- outcome: "error",
275
- direction: null,
276
- files: located,
277
- killedBy: [],
278
- cause: null,
279
- next: "",
280
- flaky: [],
281
- seconds: (Date.now() - started) / 1000,
282
- commit,
283
- agent: options.agent,
284
- runId,
285
- command: "",
286
- wholeSuite: false,
287
- reused: false,
288
- selectedTests: [],
289
- mutantHash,
290
- testHashes: {},
291
- });
292
- const direction = chooseDirection(worktree, raw, options.direction);
293
- if (!direction)
294
- return { ...base(), error: "patch does not apply forward or in reverse" };
295
- const applied = applyPatch(worktree, raw, direction);
296
- if (!applied.ok)
297
- return { ...base(), direction, error: applied.error };
298
- const multiple = options.timeoutMultiple ?? TIMEOUT_MULTIPLE;
299
- const floor = options.timeoutFloorMs ?? TIMEOUT_FLOOR_MS;
300
- const limit = prep.baselineMs == null ? options.suiteTimeoutMs : Math.min(options.suiteTimeoutMs, timeoutLimitMs(prep.baselineMs, multiple, floor));
301
- try {
302
- if (options.build) {
303
- const built = await spawnCollected(options.build[0], options.build.slice(1), pkg, childEnv(options.unset), Math.min(options.suiteTimeoutMs, 120_000));
304
- if (built.timedOut)
305
- return { ...base(), direction, outcome: "timeout", error: "build timed out", next: TIMEOUT_NEXT };
306
- // The baseline build already succeeded. A mutant that no longer builds cannot pass the suite.
307
- if (built.code !== 0)
308
- return finishMutant(unviable(base, direction, "build"), pkg, "", false, []);
309
- }
310
- const picked = selectionFor(prep.coverage, edited.length > 0 ? edited : files);
311
- const forced = options.onlyNames && options.onlyNames.length > 0
312
- ? prep.spec.kind === "node" ? options.onlyNames.map(nodeTitle) : options.onlyNames
313
- : null;
314
- if (!forced && picked.state === "uncovered") {
315
- return {
316
- ...base(),
317
- direction,
318
- outcome: "no coverage",
319
- next: noCoverageNext(picked.file, picked.line),
320
- };
321
- }
322
- const names = forced ?? (picked.state === "covered" ? picked.tests.filter((name) => name !== WHOLE_PROGRAM_TEST) : null);
323
- const forceWhole = picked.state === "covered" && (names === null || names.length === 0);
324
- // node --test loads every file it is given, so a name pattern still pays for the other files.
325
- const selected = narrowNodeFiles(pkg, selectTests(files, prep.tests, prep.affectedMap), names, prep.spec.kind);
326
- const commands = [];
327
- const named = names && names.length > 0 ? names : null;
328
- const main = launch
329
- ? await launch(pkg, selected, named, limit)
330
- : await runSuite(pkg, selected, options, null, named, prep.spec, limit, forceWhole, null, null);
331
- if (main.command)
332
- commands.push(main.command);
333
- if (main.crashed) {
334
- return finishMutant({ ...base(), direction, outcome: "error", error: "suite process crashed" }, pkg, commands.join(" | "), false, main.selectedTests);
335
- }
336
- if (main.timedOut) {
337
- return finishMutant({ ...base(), direction, outcome: "timeout", error: "suite timed out", next: TIMEOUT_NEXT }, pkg, commands.join(" | "), main.wholeSuite || forceWhole, main.selectedTests);
338
- }
339
- // A compile error can leave the previous run's report on disk. That report is not this mutant.
340
- if (main.compileToken)
341
- return finishMutant(unviable(base, direction, main.compileToken), pkg, commands.join(" | "), false, []);
342
- if (!main.report) {
343
- const detail = main.detail ? clip(main.detail) : "";
344
- const tool = missingToolFrom(detail, main.command);
345
- const error = tool
346
- ? `missing tool: ${tool}`
347
- : detail
348
- ? `test reporter returned nothing (${detail})`
349
- : "test reporter returned nothing";
350
- return { ...base(), direction, error, command: commands.join(" | ") };
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
- }
357
- const whole = main.wholeSuite || forceWhole;
358
- const chosen = main.selectedTests;
359
- if (main.report.failed.length === 0)
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, []);
366
- if (!options.confirm)
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);
369
- const killedBy = confirmed.killed.map((item) => failureId(pkg, item));
370
- const flaky = confirmed.flaky.map((item) => failureId(pkg, item));
371
- const outcome = killedBy.length > 0 ? "killed" : "flaky";
372
- const label = killLabel(killedBy);
373
- return finishMutant({ ...base(), direction, outcome, killedBy, flaky, cause: label?.cause ?? null, next: label?.next ?? "" }, pkg, commands.join(" | "), whole || confirmed.wholeSuite, chosen);
374
- }
375
- finally {
376
- restoreTree(worktree);
377
- }
378
- }
379
- function finishMutant(result, pkg, command, wholeSuite, selectedTests) {
380
- const next = wholeSuite && !result.next.includes("cannot take a test name")
381
- ? `${result.next}${result.next ? " " : ""}${WHOLE_SUITE_NEXT}`.trim()
382
- : result.next;
383
- return {
384
- ...result,
385
- command,
386
- wholeSuite,
387
- selectedTests,
388
- next,
389
- testHashes: hashesFor(pkg, result.killedBy),
390
- };
391
- }
392
- function killed(base, direction, killedBy) {
393
- const label = killLabel(killedBy);
394
- return { ...base(), direction, outcome: "killed", killedBy, cause: label?.cause ?? "test", next: label?.next ?? "" };
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
- }
409
- async function confirmFailures(pkg, options, failed, prep, timeoutMs, commands) {
410
- const counts = new Map();
411
- let wholeSuite = false;
412
- const named = new Map();
413
- const hungFiles = new Map();
414
- const unloaded = new Map();
415
- for (const failure of failed) {
416
- const key = failure.file || "*";
417
- // A name filter skips the tests that left the handle open, so the file
418
- // timeout never comes back. Rerun that file with no name pattern.
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;
421
- const list = bucket.get(key) ?? [];
422
- list.push(failure);
423
- bucket.set(key, list);
424
- }
425
- for (const [file, failures] of named) {
426
- const pattern = `^(${failures.map((item) => escapeRegExp(item.name)).join("|")})$`;
427
- if (await rerunGroup(pkg, options, file, failures, counts, pattern, prep, timeoutMs, commands))
428
- wholeSuite = true;
429
- }
430
- for (const [file, failures] of hungFiles) {
431
- if (await rerunGroup(pkg, options, file, failures, counts, null, prep, timeoutMs, commands))
432
- wholeSuite = true;
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
- }
438
- const killed = [];
439
- const flaky = [];
440
- for (const failure of failed)
441
- (counts.get(failureId(pkg, failure)) === 2 ? killed : flaky).push(failure);
442
- return { killed, flaky, wholeSuite };
443
- }
444
- async function rerunGroup(pkg, options, file, failures, counts, pattern, prep, timeoutMs, commands, fileFailure = "timeout") {
445
- const files = file === "*" ? listTestFiles(pkg, options.testsDir, prep.spec) : [testArg(pkg, file, options.testsDir)];
446
- const isolated = { ...options, concurrency: 1 };
447
- let wholeSuite = false;
448
- // Pytest confirms by node id. Maven confirms by class#method. A regex is not a test name for either.
449
- const directNames = prep.spec.kind === "pytest" || prep.spec.kind === "maven" ? failures.map((item) => item.name) : null;
450
- for (let attempt = 0; attempt < 2; attempt++) {
451
- const rerun = await runSuite(pkg, files, isolated, directNames ? null : pattern, directNames, prep.spec, timeoutMs, false, null, null);
452
- if (rerun.wholeSuite)
453
- wholeSuite = true;
454
- if (rerun.command)
455
- commands.push(rerun.command);
456
- if (pattern === null && fileFailure === "timeout" && rerun.timedOut) {
457
- for (const id of new Set(failures.map((item) => failureId(pkg, item))))
458
- bump(counts, id);
459
- continue;
460
- }
461
- if (!rerun.report)
462
- continue;
463
- const seen = new Set();
464
- for (const hit of rerun.report.failed) {
465
- if (pattern === null && !(fileFailure === "timeout" ? hit.fileTimeout : hit.fileLoad))
466
- continue;
467
- const id = failureId(pkg, hit);
468
- if (failures.some((item) => failureId(pkg, item) === id))
469
- seen.add(id);
470
- }
471
- for (const id of seen)
472
- bump(counts, id);
473
- }
474
- return wholeSuite;
475
- }
476
- function bump(counts, id) {
477
- counts.set(id, (counts.get(id) ?? 0) + 1);
478
- }
479
- async function runSuite(pkg, files, options, pattern, names, spec, timeoutMs, forceWhole, coverageMap, profileFile) {
480
- const result = await runDiscoveredSuite(pkg, files, options.testsDir, pattern, timeoutMs, childEnv(options.unset), {
481
- reporterPath,
482
- testTimeoutMs: options.testTimeoutMs,
483
- concurrency: options.concurrency,
484
- }, {
485
- spec,
486
- names,
487
- coverageMap,
488
- profileFile,
489
- });
490
- return {
491
- report: result.report,
492
- timedOut: result.timedOut,
493
- detail: result.detail,
494
- compileToken: result.compileToken,
495
- command: result.command,
496
- wholeSuite: result.wholeSuite || forceWhole,
497
- selectedTests: result.selectedTests,
498
- };
499
- }
500
- function batchPlan(raw, prep, options) {
501
- if (options.workers !== 1 || prep.spec.kind !== "node")
502
- return null;
503
- if (!prep.coverage || Object.keys(prep.coverage.files).length === 0)
504
- return null;
505
- const edited = changedLines(raw);
506
- const files = filesInDiff(raw);
507
- if (new Set(files.map((item) => item.file)).size !== 1)
508
- return null;
509
- const picked = selectionFor(prep.coverage, edited.length > 0 ? edited : files);
510
- if (picked.state !== "covered")
511
- return null;
512
- const names = picked.tests.filter((name) => name !== WHOLE_PROGRAM_TEST && name.length > 0);
513
- if (names.length === 0)
514
- return null;
515
- return { names };
516
- }
517
- /** Covered node mutants of one file share this process. A crash sets dead and the next mutant starts another. */
518
- class NodeBatch {
519
- pid;
520
- dead = false;
521
- testTimeoutMs;
522
- proc;
523
- constructor(proc, pid, testTimeoutMs) {
524
- this.proc = proc;
525
- this.pid = pid;
526
- this.testTimeoutMs = testTimeoutMs;
527
- proc.on("close", () => {
528
- this.dead = true;
529
- });
530
- }
531
- static async start(pkg, options, outDir) {
532
- const dir = path.join(outDir, "batch");
533
- mkdirSync(dir, { recursive: true });
534
- const ready = path.join(dir, `ready-${process.pid}-${Date.now()}.json`);
535
- const gen = path.join(dir, `gen-${process.pid}-${Date.now()}.txt`);
536
- writeFileSync(gen, "0\n");
537
- const script = fileURLToPath(new URL("./node-batch.mjs", import.meta.url));
538
- const env = childEnv(options.unset);
539
- env.PB_READY = ready;
540
- env.PB_GEN = gen;
541
- env.PB_ROOT = pkg;
542
- const proc = spawn(process.execPath, [script], { cwd: pkg, env, stdio: ["pipe", "ignore", "pipe"] });
543
- const started = Date.now();
544
- while (!existsSync(ready)) {
545
- if (proc.exitCode !== null)
546
- throw new Error("batch process exited before ready");
547
- if (Date.now() - started > 10_000) {
548
- proc.kill("SIGKILL");
549
- throw new Error("batch process did not start");
550
- }
551
- await new Promise((resolve) => setTimeout(resolve, 20));
552
- }
553
- const body = JSON.parse(readFileSync(ready, "utf8"));
554
- return new NodeBatch(proc, body.pid || proc.pid || 0, options.testTimeoutMs);
555
- }
556
- stop() {
557
- this.dead = true;
558
- try {
559
- this.proc.stdin?.end();
560
- }
561
- catch {
562
- // The pipe is already closed.
563
- }
564
- try {
565
- if (this.proc.pid)
566
- process.kill(this.proc.pid, "SIGTERM");
567
- }
568
- catch {
569
- // The process has already exited.
570
- }
571
- }
572
- async run(pkg, files, names, timeoutMs, id, outDir) {
573
- const selected = names ?? [];
574
- const replyFile = path.join(outDir, "batch", `${id}.reply.json`);
575
- rmSync(replyFile, { force: true });
576
- appendFileSync(path.join(outDir, "batch-processes.jsonl"), `${JSON.stringify({ id, pid: this.pid })}\n`);
577
- const job = { id, root: pkg, tests: files, names: selected, timeoutMs, testTimeoutMs: nodeTestTimeoutMs(pkg, files, this.testTimeoutMs, timeoutMs), replyFile };
578
- try {
579
- this.proc.stdin?.write(`${JSON.stringify(job)}\n`);
580
- }
581
- catch {
582
- this.dead = true;
583
- return crashedCall(selected);
584
- }
585
- const waited = await waitForReply(replyFile, this.proc, timeoutMs);
586
- if (waited === "timeout") {
587
- this.dead = true;
588
- this.stop();
589
- return { report: null, timedOut: true, detail: "suite timed out", compileToken: null, command: "", wholeSuite: false, selectedTests: selected };
590
- }
591
- if (!waited)
592
- return crashedCall(selected);
593
- const failed = waited.failed ?? [];
594
- const seen = waited.names ?? [];
595
- if (seen.length === 0 && failed.length === 0) {
596
- return {
597
- report: null,
598
- timedOut: false,
599
- detail: waited.error || (selected.length > 0 ? `no requested test ran (${selected.join(", ")})` : "test reporter returned nothing"),
600
- compileToken: null,
601
- command: waited.command || "",
602
- wholeSuite: false,
603
- selectedTests: selected,
604
- };
605
- }
606
- return {
607
- 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 },
608
- timedOut: false,
609
- detail: "",
610
- compileToken: null,
611
- command: waited.command || "",
612
- wholeSuite: false,
613
- selectedTests: selected,
614
- };
615
- }
616
- }
617
- function crashedCall(selected) {
618
- return { report: null, timedOut: false, detail: "suite process crashed", compileToken: null, command: "", wholeSuite: false, selectedTests: selected, crashed: true };
619
- }
620
- function waitForReply(file, proc, timeoutMs) {
621
- return new Promise((resolve) => {
622
- let settled = false;
623
- const finish = (value) => {
624
- if (settled)
625
- return;
626
- settled = true;
627
- clearTimeout(timer);
628
- clearInterval(poll);
629
- proc.removeListener("close", onClose);
630
- resolve(value);
631
- };
632
- const read = () => {
633
- try {
634
- return JSON.parse(readFileSync(file, "utf8"));
635
- }
636
- catch {
637
- return null;
638
- }
639
- };
640
- const onClose = () => {
641
- const reply = existsSync(file) ? read() : null;
642
- finish(reply);
643
- };
644
- const timer = setTimeout(() => finish("timeout"), timeoutMs);
645
- const poll = setInterval(() => {
646
- if (!existsSync(file))
647
- return;
648
- const reply = read();
649
- if (reply)
650
- finish(reply);
651
- }, 20);
652
- if (proc.exitCode !== null || proc.signalCode !== null)
653
- onClose();
654
- else
655
- proc.once("close", onClose);
656
- });
657
- }
658
- function isNodeTestFile(file) {
659
- const base = path.posix.basename(file);
660
- return /\.(test|spec)\.(ts|tsx|mts|js|mjs|cjs)$/.test(base) || /_test\.(ts|tsx|js|mjs)$/.test(base);
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
- }
667
- function narrowNodeFiles(pkg, files, names, kind) {
668
- if (kind !== "node" || !names || names.length === 0)
669
- return files;
670
- const hit = files.filter((file) => {
671
- let text;
672
- try {
673
- text = readFileSync(path.join(pkg, file), "utf8");
674
- }
675
- catch {
676
- return false;
677
- }
678
- return names.some((name) => text.includes(`"${name}"`) || text.includes(`'${name}'`) || text.includes("`" + name + "`"));
679
- });
680
- return hit.length > 0 ? hit : files;
681
- }
682
- function selectTests(files, allTests, affectedMap) {
683
- if (!affectedMap)
684
- return allTests;
685
- const selected = new Set();
686
- for (const file of files) {
687
- const key = Object.keys(affectedMap).find((candidate) => file.file === candidate || file.file.endsWith(`/${candidate}`));
688
- if (!key)
689
- return allTests;
690
- for (const test of affectedMap[key] ?? [])
691
- selected.add(test);
692
- }
693
- if (selected.size === 0)
694
- return allTests;
695
- return [...selected].sort();
696
- }
697
- function childEnv(unset) {
698
- const env = { ...process.env };
699
- // A suite launched from inside node:test inherits this and then refuses to run.
700
- for (const key of Object.keys(env)) {
701
- if (key === "NODE_TEST_CONTEXT" || key.startsWith("NODE_TEST_"))
702
- delete env[key];
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
- }
716
- for (const key of unset)
717
- delete env[key];
718
- return env;
719
- }
720
- function spawnCollected(bin, args, cwd, env, timeoutMs) {
721
- return new Promise((resolve) => {
722
- const child = spawn(bin, args, { cwd, env, detached: true, stdio: ["ignore", "pipe", "pipe"] });
723
- let stdout = "";
724
- let stderr = "";
725
- let settled = false;
726
- let timedOut = false;
727
- const finish = (code) => {
728
- if (settled)
729
- return;
730
- settled = true;
731
- clearTimeout(timer);
732
- resolve({ code, stdout, stderr, timedOut });
733
- };
734
- const timer = setTimeout(() => {
735
- timedOut = true;
736
- try {
737
- if (child.pid)
738
- process.kill(-child.pid, "SIGKILL");
739
- }
740
- catch {
741
- child.kill("SIGKILL");
742
- }
743
- }, timeoutMs);
744
- child.stdout?.on("data", (chunk) => {
745
- stdout += chunk;
746
- });
747
- child.stderr?.on("data", (chunk) => {
748
- stderr += chunk;
749
- });
750
- child.on("error", (error) => finishFromError(error));
751
- child.on("close", (code) => finish(code ?? 1));
752
- function finishFromError(error) {
753
- stderr += error.message;
754
- finish(1);
755
- }
756
- });
757
- }
758
- function listPatches(dirs) {
759
- const files = [];
760
- for (const dir of dirs) {
761
- if (!existsSync(dir))
762
- return { error: `patch directory not found: ${dir}` };
763
- for (const name of readdirSync(dir))
764
- if (name.endsWith(".patch"))
765
- files.push(path.join(dir, name));
766
- }
767
- files.sort((left, right) => path.basename(left).localeCompare(path.basename(right)));
768
- const names = files.map((file) => path.basename(file));
769
- const duplicate = names.find((name, index) => names.indexOf(name) !== index);
770
- if (duplicate)
771
- return { error: `two patches share the name ${duplicate}` };
772
- if (files.length === 0)
773
- return { error: "no patches" };
774
- return { files };
775
- }
776
- function loadMeta(dirs) {
777
- const meta = new Map();
778
- for (const dir of dirs) {
779
- const file = path.join(dir, "mutants.json");
780
- if (!existsSync(file))
781
- continue;
782
- const parsed = JSON.parse(readFileSync(file, "utf8"));
783
- for (const mutant of parsed.mutants ?? [])
784
- meta.set(mutant.id, mutant);
785
- }
786
- return meta;
787
- }
788
- function loadRun(options, commit) {
789
- const file = path.join(options.outDir, "run.json");
790
- if (existsSync(file)) {
791
- const stored = JSON.parse(readFileSync(file, "utf8"));
792
- if (stored.commit !== commit)
793
- return { error: `out dir is for commit ${stored.commit.slice(0, 12)}`, id: stored.id };
794
- return { id: stored.id };
795
- }
796
- const id = randomBytes(4).toString("hex");
797
- const record = {
798
- id,
799
- commit,
800
- agent: options.agent,
801
- started: new Date().toISOString(),
802
- package: options.packageDir,
803
- patches: options.patchDirs,
804
- out: options.outDir,
805
- repo: options.repoDir,
806
- workers: options.workers,
807
- confirm: options.confirm,
808
- testsDir: options.testsDir,
809
- suiteTimeoutMs: options.suiteTimeoutMs,
810
- testTimeoutMs: options.testTimeoutMs,
811
- direction: options.direction,
812
- suiteCommand: options.suiteCommand ?? null,
813
- };
814
- writeFileSync(file, `${JSON.stringify(record, null, 2)}\n`);
815
- return { id };
816
- }
817
- async function createWorktrees(repo, packageDir, runId, commit, workers, outDir) {
818
- removeWorktrees(repo, recordedWorktrees(outDir), outDir);
819
- const dirs = [];
820
- for (let index = 0; index < workers; index++) {
821
- const dir = path.join(tmpdir(), `probatio-${runId}-${index}`);
822
- if (!probatioWorktreeDir(dir)) {
823
- removeWorktrees(repo, dirs, outDir);
824
- return { error: "worktree path is outside the probatio temp prefix" };
825
- }
826
- rmSync(dir, { recursive: true, force: true });
827
- const added = git(repo, ["worktree", "add", "--detach", dir, commit]);
828
- if (added.status !== 0) {
829
- removeWorktrees(repo, dirs, outDir);
830
- return { error: (added.stderr || "worktree add failed").trim() };
831
- }
832
- dirs.push(dir);
833
- }
834
- writeFileSync(path.join(outDir, "worktrees.json"), `${JSON.stringify(dirs)}\n`);
835
- const sources = nodeModuleSources(repo, packageDir);
836
- const config = path.join(packageDir, CONFIG_FILE);
837
- for (const dir of dirs) {
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));
842
- for (const source of sources) {
843
- const dest = path.join(pkgRoot, source.rel);
844
- if (existsSync(dest))
845
- continue;
846
- mkdirSync(path.dirname(dest), { recursive: true });
847
- symlinkSync(source.abs, dest, "dir");
848
- }
849
- }
850
- return { dirs };
851
- }
852
- function hidePaths(dirs, repo, packageDir, hide) {
853
- for (const dir of dirs) {
854
- const pkg = packageIn(dir, repo, packageDir);
855
- const root = path.resolve(pkg);
856
- for (const rel of hide) {
857
- if (!rel || path.isAbsolute(rel) || rel.split(/[\\/]/).includes(".."))
858
- continue;
859
- const target = path.resolve(root, rel);
860
- if (target !== root && !target.startsWith(root + path.sep))
861
- continue;
862
- rmSync(target, { force: true });
863
- }
864
- }
865
- }
866
- /** A worktree Probatio created: one directory under the OS temp dir, named probatio- plus hex and an index. */
867
- export function probatioWorktreeDir(dir) {
868
- let resolved = path.resolve(dir);
869
- try {
870
- resolved = realpathSync(dir);
871
- }
872
- catch {
873
- // The directory is created on the next line. The unresolved path is still checked.
874
- }
875
- const roots = new Set([path.resolve(tmpdir())]);
876
- try {
877
- roots.add(realpathSync(tmpdir()));
878
- }
879
- catch {
880
- // tmpdir() itself is enough when realpath is unavailable.
881
- }
882
- const inside = [...roots].some((root) => resolved === root || resolved.startsWith(root + path.sep));
883
- if (!inside)
884
- return false;
885
- return /^probatio-[0-9a-f]+-\d+$/.test(path.basename(resolved));
886
- }
887
- function removeWorktrees(repo, dirs, outDir) {
888
- for (const dir of dirs) {
889
- if (!probatioWorktreeDir(dir))
890
- continue;
891
- git(repo, ["worktree", "remove", "--force", dir]);
892
- rmSync(dir, { recursive: true, force: true });
893
- }
894
- git(repo, ["worktree", "prune"]);
895
- rmSync(path.join(outDir, "worktrees.json"), { force: true });
896
- }
897
- function recordedWorktrees(outDir) {
898
- const file = path.join(outDir, "worktrees.json");
899
- if (!existsSync(file))
900
- return [];
901
- try {
902
- const parsed = JSON.parse(readFileSync(file, "utf8"));
903
- return Array.isArray(parsed) ? parsed.filter((item) => typeof item === "string") : [];
904
- }
905
- catch {
906
- return [];
907
- }
908
- }
909
- /**
910
- * Installed dependency directories to mirror into a fresh worktree.
911
- * A package can keep node_modules at its root or under a nested example.
912
- * A run that starts from a worktree looks beside the main checkout too.
913
- */
914
- function nodeModuleSources(repo, packageDir) {
915
- const roots = [packageDir];
916
- const main = mainCheckoutPackage(repo, packageDir);
917
- if (main && path.resolve(main) !== path.resolve(packageDir))
918
- roots.push(main);
919
- const found = [];
920
- const seen = new Set();
921
- for (const root of roots) {
922
- for (const abs of findNodeModules(root)) {
923
- const rel = path.relative(root, abs);
924
- if (seen.has(rel))
925
- continue;
926
- seen.add(rel);
927
- found.push({ rel, abs });
928
- }
929
- }
930
- return found;
931
- }
932
- function mainCheckoutPackage(repo, packageDir) {
933
- const common = git(repo, ["rev-parse", "--path-format=absolute", "--git-common-dir"]);
934
- if (common.status !== 0)
935
- return null;
936
- const gitDir = common.stdout.trim();
937
- if (!gitDir.endsWith(`${path.sep}.git`) && !gitDir.endsWith("/.git"))
938
- return null;
939
- const rel = path.relative(repo, packageDir);
940
- if (rel.startsWith(".."))
941
- return null;
942
- return path.join(path.dirname(gitDir), rel);
943
- }
944
- function findNodeModules(root) {
945
- const out = [];
946
- const visit = (dir) => {
947
- let entries;
948
- try {
949
- entries = readdirSync(dir);
950
- }
951
- catch {
952
- return;
953
- }
954
- for (const name of entries) {
955
- if (name === ".git" || name === "target" || name === "dist")
956
- continue;
957
- const full = path.join(dir, name);
958
- let info;
959
- try {
960
- info = statSync(full);
961
- }
962
- catch {
963
- continue;
964
- }
965
- if (!info.isDirectory())
966
- continue;
967
- if (name === "node_modules") {
968
- out.push(full);
969
- continue;
970
- }
971
- visit(full);
972
- }
973
- };
974
- visit(root);
975
- return out;
976
- }
977
- function packageIn(worktree, repo, packageDir) {
978
- const rel = path.relative(repo, packageDir);
979
- return rel && !rel.startsWith("..") ? path.join(worktree, rel) : worktree;
980
- }
981
- function listTestFiles(pkg, testsDir, spec) {
982
- if (spec && spec.files.length > 0 && spec.files[0] !== "suite-command")
983
- return spec.files;
984
- return discoverSuite(pkg, testsDir)?.files ?? [];
985
- }
986
- function resultPath(outDir, id) {
987
- return path.join(outDir, "results", `${id}.json`);
988
- }
989
- function writeResult(outDir, result) {
990
- const dest = resultPath(outDir, result.id);
991
- mkdirSync(path.dirname(dest), { recursive: true });
992
- const temp = `${dest}.${process.pid}.tmp`;
993
- writeFileSync(temp, `${JSON.stringify(result, null, 2)}\n`);
994
- renameSync(temp, dest);
995
- }
996
- function readResult(outDir, id) {
997
- const file = resultPath(outDir, id);
998
- if (!existsSync(file))
999
- return null;
1000
- return normalizeResult(JSON.parse(readFileSync(file, "utf8")));
1001
- }
1002
- function reportOf(results, context) {
1003
- const count = (outcome) => results.filter((result) => result.outcome === outcome).length;
1004
- const killed = count("killed");
1005
- const unviableCount = count("unviable");
1006
- const survived = count("survived");
1007
- const flaky = count("flaky");
1008
- const timeouts = count("timeout");
1009
- const noCoverage = count("no coverage");
1010
- const errors = results.filter((result) => result.outcome === "error" || result.outcome === "build-failed").length;
1011
- const gapResults = results.filter((result) => result.outcome === "survived");
1012
- const gaps = gapResults.slice(0, 10).map((result) => {
1013
- const known = context.meta.get(result.id);
1014
- const file = known?.file ?? result.files[0]?.file ?? "";
1015
- const line = known?.line ?? result.files[0]?.line ?? 0;
1016
- return { id: result.id, file, line, ...(known?.op ? { op: known.op } : {}) };
1017
- });
1018
- const budgetHit = context.budgetHit || context.pendingLeft > 0;
1019
- const summary = campaignSummary({ noCoverage, survived, killed, unviable: unviableCount, flaky, timeouts, errors, finished: results.length });
1020
- const notes = [];
1021
- const uncovered = results.find((result) => result.outcome === "no coverage");
1022
- if (uncovered?.next)
1023
- notes.push(uncovered.next);
1024
- if (gapResults.length > 0)
1025
- notes.push(`First gap is ${gaps[0].id} at ${gaps[0].file}:${gaps[0].line}. A survivor is not a pass.`);
1026
- if (timeouts > 0)
1027
- notes.push(TIMEOUT_NEXT);
1028
- if (budgetHit)
1029
- notes.push(resumeNext(context.notStarted));
1030
- if (results.some((result) => result.wholeSuite))
1031
- notes.push(WHOLE_SUITE_NEXT);
1032
- const broken = results.find((result) => (result.outcome === "error" || result.outcome === "build-failed") && result.error);
1033
- if (broken?.error)
1034
- notes.push(`${broken.id}: ${broken.error}`);
1035
- if (notes.length === 0)
1036
- notes.push("No survivor in this batch.");
1037
- return {
1038
- ok: true,
1039
- summary,
1040
- next: notes.join(" "),
1041
- budgetHit,
1042
- commit: context.commit,
1043
- runId: context.runId,
1044
- killed,
1045
- unviable: unviableCount,
1046
- unviableIds: results.filter((result) => result.outcome === "unviable").slice(0, 10).map((result) => result.id),
1047
- survived,
1048
- flaky,
1049
- timeouts,
1050
- errors,
1051
- noCoverage,
1052
- notStarted: context.notStarted,
1053
- suiteCommand: context.suiteCommand,
1054
- baselineMs: context.baselineMs,
1055
- commands: results.map((result) => ({
1056
- id: result.id,
1057
- outcome: result.outcome,
1058
- command: result.command,
1059
- wholeSuite: result.wholeSuite,
1060
- reused: result.reused,
1061
- cause: result.cause,
1062
- next: result.next,
1063
- })),
1064
- gaps,
1065
- rest: Math.max(gapResults.length - gaps.length, 0),
1066
- flakyIds: results.filter((result) => result.outcome === "flaky").slice(0, 10).map((result) => result.id),
1067
- kills: results
1068
- .filter((result) => result.outcome === "killed" && result.cause !== null)
1069
- .map((result) => ({ id: result.id, cause: result.cause, killedBy: result.killedBy, next: result.next, files: result.files })),
1070
- full: path.join(context.outDir, "results"),
1071
- };
1072
- }
1073
- function languageMapKind(kind) {
1074
- return kind === "go" || kind === "maven" || kind === "cargo" || kind === "dotnet";
1075
- }
1076
- function languageMapLabel(kind) {
1077
- if (kind === "go")
1078
- return "go line map";
1079
- if (kind === "maven")
1080
- return "java line map";
1081
- if (kind === "cargo")
1082
- return "rust line map";
1083
- return "csharp line map";
1084
- }
1085
- function writeLanguageMap(kind, pkg, names, dest, project) {
1086
- if (kind === "go")
1087
- return writeGoCoverage(pkg, names, dest);
1088
- if (kind === "maven")
1089
- return writeJavaCoverage(pkg, names, dest);
1090
- if (kind === "cargo")
1091
- return writeRustCoverage(pkg, names, dest);
1092
- if (kind === "dotnet")
1093
- return writeCsharpCoverage(pkg, names, dest, project);
1094
- return { ok: false, detail: "no line map writer" };
1095
- }
1096
- function failReport(outDir, runId, commit, error, baselineFailed, next, suiteCommand = "", baselineMs = null, notStarted = []) {
1097
- const summary = baselineFailed && baselineFailed.length > 0 ? `${error}: ${baselineFailed.join("; ")}` : error;
1098
- return {
1099
- ok: false,
1100
- summary,
1101
- next: next ?? "Fix that and run the same command again.",
1102
- budgetHit: false,
1103
- commit,
1104
- runId,
1105
- killed: 0,
1106
- unviable: 0,
1107
- unviableIds: [],
1108
- survived: 0,
1109
- flaky: 0,
1110
- timeouts: 0,
1111
- errors: 0,
1112
- noCoverage: 0,
1113
- notStarted,
1114
- suiteCommand,
1115
- baselineMs,
1116
- commands: [],
1117
- gaps: [],
1118
- rest: 0,
1119
- flakyIds: [],
1120
- kills: [],
1121
- full: path.join(outDir, "results"),
1122
- };
1123
- }
1124
- function resolveSuite(pkg, options) {
1125
- const discovered = discoverSuite(pkg, options.testsDir);
1126
- const makefilePath = path.join(pkg, "Makefile");
1127
- const makefile = existsSync(makefilePath) ? readFileSync(makefilePath, "utf8") : null;
1128
- const decision = decideSuite({
1129
- discovered: discovered ? { kind: discovered.kind, command: discovered.command } : null,
1130
- suiteCommand: options.suiteCommand ?? null,
1131
- candidates: sourceCandidates(pkg),
1132
- makefile,
1133
- artifactExists: (rel) => existsSync(path.resolve(pkg, rel)),
1134
- });
1135
- if (decision.action === "unknown")
1136
- return { action: "stop", summary: decision.summary, next: decision.next, suiteCommand: "" };
1137
- if (decision.action === "cannot-run")
1138
- return { action: "stop", summary: decision.summary, next: decision.next, suiteCommand: decision.command };
1139
- if (decision.action === "no-tests") {
1140
- return { action: "stop", summary: `no tests in ${options.testsDir}`, next: "Fix that and run the same command again.", suiteCommand: "" };
1141
- }
1142
- const command = options.suiteCommand?.trim() ?? "";
1143
- if (command) {
1144
- const files = discovered && discovered.files.length > 0 ? discovered.files : sourceCandidates(pkg);
1145
- return { action: "run", spec: commandSuite(command, files) };
1146
- }
1147
- if (!discovered)
1148
- return { action: "stop", summary: `no tests in ${options.testsDir}`, next: "Fix that and run the same command again.", suiteCommand: "" };
1149
- return { action: "run", spec: discovered };
1150
- }
1151
- function loadHistory(historyPath) {
1152
- const found = new Map();
1153
- if (!historyPath)
1154
- return found;
1155
- const file = historyPath.endsWith(".json") ? historyPath : path.join(historyPath, "history.json");
1156
- if (!existsSync(file))
1157
- return found;
1158
- try {
1159
- const parsed = JSON.parse(readFileSync(file, "utf8"));
1160
- for (const entry of parsed.entries ?? []) {
1161
- if (entry && typeof entry.id === "string")
1162
- found.set(entry.id, entry);
1163
- }
1164
- }
1165
- catch {
1166
- return found;
1167
- }
1168
- return found;
1169
- }
1170
- function historyCoverage(historyPath) {
1171
- if (!historyPath)
1172
- return null;
1173
- const dir = historyPath.endsWith(".json") ? path.dirname(historyPath) : historyPath;
1174
- const file = path.join(dir, "coverage-map.json");
1175
- return existsSync(file) ? file : null;
1176
- }
1177
- function reusable(entry, raw, pkg) {
1178
- if (!entry.mutantHash || entry.mutantHash !== sha256(raw))
1179
- return false;
1180
- if (entry.outcome === "error" || entry.outcome === "build-failed")
1181
- return false;
1182
- for (const [file, hash] of Object.entries(entry.testHashes ?? {})) {
1183
- if (hashFile(pkg, file) !== hash)
1184
- return false;
1185
- }
1186
- return true;
1187
- }
1188
- function reusedResult(entry, commit, runId, agent) {
1189
- return {
1190
- id: entry.id,
1191
- outcome: entry.outcome,
1192
- direction: entry.direction,
1193
- files: entry.files ?? [],
1194
- killedBy: entry.killedBy ?? [],
1195
- cause: entry.cause,
1196
- next: entry.next ?? "",
1197
- flaky: entry.flaky ?? [],
1198
- error: entry.error,
1199
- seconds: 0,
1200
- commit,
1201
- agent,
1202
- runId,
1203
- command: "",
1204
- wholeSuite: entry.wholeSuite === true,
1205
- reused: true,
1206
- selectedTests: entry.selectedTests ?? [],
1207
- mutantHash: entry.mutantHash,
1208
- testHashes: entry.testHashes ?? {},
1209
- };
1210
- }
1211
- function writeHistory(outDir, results) {
1212
- const entries = results.map((result) => ({
1213
- id: result.id,
1214
- outcome: result.outcome,
1215
- direction: result.direction,
1216
- files: result.files,
1217
- killedBy: result.killedBy,
1218
- cause: result.cause,
1219
- next: result.next,
1220
- flaky: result.flaky,
1221
- error: result.error,
1222
- command: result.command,
1223
- wholeSuite: result.wholeSuite,
1224
- selectedTests: result.selectedTests,
1225
- mutantHash: result.mutantHash,
1226
- testHashes: result.testHashes,
1227
- }));
1228
- writeFileSync(path.join(outDir, "history.json"), `${JSON.stringify({ entries }, null, 2)}\n`);
1229
- }
1230
- function hashesFor(pkg, killedBy) {
1231
- const out = {};
1232
- for (const id of killedBy) {
1233
- const mark = id.indexOf("::");
1234
- if (mark <= 0)
1235
- continue;
1236
- const file = id.slice(0, mark);
1237
- const full = findTestFile(pkg, file);
1238
- if (!full)
1239
- continue;
1240
- const rel = path.relative(pkg, full).split(path.sep).join("/");
1241
- const key = rel.startsWith("..") ? file : rel;
1242
- out[key] = sha256(readFileSync(full));
1243
- }
1244
- return out;
1245
- }
1246
- function hashFile(pkg, rel) {
1247
- const full = findTestFile(pkg, rel);
1248
- if (!full)
1249
- return null;
1250
- return sha256(readFileSync(full));
1251
- }
1252
- function findTestFile(pkg, rel) {
1253
- 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);
1254
- return candidates.find((file) => existsSync(file)) ?? null;
1255
- }
1256
- function sha256(text) {
1257
- return createHash("sha256").update(text).digest("hex");
1258
- }
1259
- const BARE_SUITE = new Set(["command", "suite", "pytest", "unittest", "cobol", "make-test", "c", "cpp"]);
1260
- function failureId(pkg, failure) {
1261
- // Pytest already reports a node id (tests/test_gate.py::test_low). Prepending the
1262
- // file doubles it, and pytest then exits 4 and collects nothing.
1263
- if (pytestNodeId(failure.name))
1264
- return failure.name;
1265
- // Cargo and dotnet report a bare test name. Go reports a package path that is
1266
- // not a file, and JUnit reports a classname. The runnable id is the name itself.
1267
- // An unnamed shell suite stays ::command so a tally does not treat it as a test.
1268
- if (!failure.file)
1269
- return BARE_SUITE.has(failure.name) ? `::${failure.name}` : failure.name;
1270
- if (!testFileOnDisk(pkg, failure.file))
1271
- return failure.name;
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)));
1274
- const file = !rel || rel.startsWith("..") ? path.basename(failure.file) : rel.split(path.sep).join("/");
1275
- return `${file}::${failure.name}`;
1276
- }
1277
- function realOrSame(file) {
1278
- try {
1279
- return realpathSync(file);
1280
- }
1281
- catch {
1282
- return file;
1283
- }
1284
- }
1285
- function testFileOnDisk(pkg, file) {
1286
- if (path.isAbsolute(file))
1287
- return existsSync(file);
1288
- return existsSync(path.join(pkg, file));
1289
- }
1290
- function pytestNodeId(name) {
1291
- const mark = name.indexOf("::");
1292
- if (mark <= 0)
1293
- return false;
1294
- return name.slice(0, mark).endsWith(".py");
1295
- }
1296
- function testArg(pkg, file, testsDir) {
1297
- const rel = path.relative(pkg, file);
1298
- if (rel && !rel.startsWith(".."))
1299
- return rel.split(path.sep).join("/");
1300
- return path.join(testsDir, path.basename(file));
1301
- }
1302
- function escapeRegExp(value) {
1303
- return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1304
- }
1305
- function missingToolFrom(detail, command) {
1306
- const spawnMiss = /spawn ([^\s]+) ENOENT/.exec(detail);
1307
- if (spawnMiss)
1308
- return path.basename(spawnMiss[1]);
1309
- const shellMiss = /(?:^|\s)([A-Za-z0-9_./+-]+): (?:command not found|not found)\b/.exec(detail);
1310
- if (shellMiss)
1311
- return path.basename(shellMiss[1]);
1312
- if (/No such file or directory|ENOENT/.test(detail)) {
1313
- const bin = command.trim().split(/\s+/)[0];
1314
- if (bin)
1315
- return path.basename(bin);
1316
- }
1317
- return null;
1318
- }
1319
- function baselineReportError(detail, command = "") {
1320
- const tool = missingToolFrom(detail, command);
1321
- const line = detail.trim();
1322
- // The first line of a Python or Node crash is a stack fragment. The summary
1323
- // states the limit and does not carry that fragment.
1324
- const base = !line || isStackFragment(line) ? "baseline suite did not return a test report" : `baseline suite did not return a test report (${line})`;
1325
- return tool ? `missing tool: ${tool}. ${base}` : base;
1326
- }
1327
- function isStackFragment(line) {
1328
- return line.startsWith("Traceback") || /^\s+at \S/.test(line) || /File ".+", line \d+/.test(line);
1329
- }
1330
- function clip(text) {
1331
- const line = text.trim().split("\n").find((item) => item.trim()) ?? "build failed";
1332
- return line.slice(0, 240);
1333
- }