probatio 0.3.0 → 0.5.1

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 (74) hide show
  1. package/README.md +11 -197
  2. package/bin/probatio.js +52 -0
  3. package/package.json +22 -29
  4. package/LICENSE +0 -21
  5. package/dist/cli.js +0 -721
  6. package/dist/contract.js +0 -32
  7. package/dist/flags.js +0 -87
  8. package/dist/golden/check.js +0 -188
  9. package/dist/golden/record.js +0 -395
  10. package/dist/golden/recorder.mjs +0 -78
  11. package/dist/ledger/build.js +0 -348
  12. package/dist/ledger/check.js +0 -133
  13. package/dist/matrix/report.js +0 -256
  14. package/dist/mcp.js +0 -188
  15. package/dist/memory/state.js +0 -222
  16. package/dist/mutate/affected.js +0 -173
  17. package/dist/mutate/child-lines.mjs +0 -39
  18. package/dist/mutate/coverage-map.js +0 -229
  19. package/dist/mutate/csharp-coverage.js +0 -161
  20. package/dist/mutate/fail-reporter.mjs +0 -65
  21. package/dist/mutate/find.js +0 -13
  22. package/dist/mutate/generate.js +0 -156
  23. package/dist/mutate/go-coverage.js +0 -100
  24. package/dist/mutate/ids.js +0 -22
  25. package/dist/mutate/java-coverage.js +0 -217
  26. package/dist/mutate/kill-label.js +0 -34
  27. package/dist/mutate/load-failure.mjs +0 -40
  28. package/dist/mutate/mocha-coverage.cjs +0 -77
  29. package/dist/mutate/node-batch-loader.mjs +0 -99
  30. package/dist/mutate/node-batch.mjs +0 -167
  31. package/dist/mutate/node-coverage.mjs +0 -77
  32. package/dist/mutate/operators.js +0 -181
  33. package/dist/mutate/patch.js +0 -136
  34. package/dist/mutate/precise-lines.mjs +0 -304
  35. package/dist/mutate/probatio-jacoco-run.java +0 -516
  36. package/dist/mutate/project-config.js +0 -58
  37. package/dist/mutate/py-pytest-reporter.py +0 -145
  38. package/dist/mutate/py-reporter.py +0 -79
  39. package/dist/mutate/run.js +0 -1335
  40. package/dist/mutate/rust-coverage.js +0 -144
  41. package/dist/mutate/sealed.js +0 -118
  42. package/dist/mutate/suite-decision.js +0 -130
  43. package/dist/mutate/suites.js +0 -1524
  44. package/dist/mutate/tally.js +0 -240
  45. package/dist/mutate/text-operators.js +0 -637
  46. package/dist/mutate/titles.js +0 -23
  47. package/dist/node-version.js +0 -13
  48. package/dist/swarm/check-kill.js +0 -114
  49. package/dist/swarm/queue.js +0 -68
  50. package/dist/verify/change.js +0 -340
  51. package/schemas/check-kill.schema.json +0 -93
  52. package/schemas/findings.add.schema.json +0 -53
  53. package/schemas/gap.fix.schema.json +0 -53
  54. package/schemas/gap.revert.schema.json +0 -53
  55. package/schemas/golden.check.schema.json +0 -89
  56. package/schemas/golden.compare.schema.json +0 -151
  57. package/schemas/golden.record.schema.json +0 -126
  58. package/schemas/guard.check.schema.json +0 -53
  59. package/schemas/ledger.build.schema.json +0 -132
  60. package/schemas/ledger.check.schema.json +0 -143
  61. package/schemas/matrix.report.schema.json +0 -129
  62. package/schemas/mcp.schema.json +0 -53
  63. package/schemas/mutate.generate.schema.json +0 -127
  64. package/schemas/mutate.help.schema.json +0 -53
  65. package/schemas/mutate.run.schema.json +0 -272
  66. package/schemas/mutate.sealed.schema.json +0 -53
  67. package/schemas/mutate.tally.schema.json +0 -137
  68. package/schemas/queue.claim.schema.json +0 -53
  69. package/schemas/queue.reap.schema.json +0 -71
  70. package/schemas/queue.seed.schema.json +0 -71
  71. package/schemas/schema.schema.json +0 -74
  72. package/schemas/seal.schema.json +0 -69
  73. package/schemas/status.schema.json +0 -103
  74. package/schemas/verify-change.schema.json +0 -222
@@ -1,348 +0,0 @@
1
- import { randomBytes } from "node:crypto";
2
- import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
3
- import { tmpdir } from "node:os";
4
- import path from "node:path";
5
- import { scrub } from "../contract.js";
6
- import { filesInDiff, git, patchApplies, readHeader } from "../mutate/patch.js";
7
- /** Revert each fix commit's src diff. A clean apply becomes a forward patch. A conflict is reported, never fuzzy-applied. */
8
- export function buildLedger(options) {
9
- const repo = realpathSync(path.resolve(options.repoDir));
10
- const packageDir = realpathSync(path.resolve(options.packageDir));
11
- const outDir = path.resolve(options.outDir);
12
- const resolved = git(repo, ["rev-parse", "--verify", `${options.commit}^{commit}`]);
13
- if (resolved.status !== 0)
14
- return fail(outDir, options.commit, "that commit does not resolve");
15
- const commit = resolved.stdout.trim();
16
- const packageRel = path.relative(repo, packageDir);
17
- if (packageRel.startsWith(".."))
18
- return fail(outDir, commit, "package is outside the repository");
19
- const srcPrefix = gitPath(packageRel, options.srcDir);
20
- const testsPrefix = gitPath(packageRel, options.testsDir);
21
- const collected = collect(repo, commit, srcPrefix, testsPrefix, options.maxCommits ?? null);
22
- const candidates = collected.found;
23
- mkdirSync(outDir, { recursive: true });
24
- const ids = assignIds(candidates.map((item) => item.commit));
25
- const disk = loadDisk(outDir);
26
- let workDir = null;
27
- try {
28
- const entries = [];
29
- const bodies = new Map();
30
- for (const candidate of candidates) {
31
- const id = ids.get(candidate.commit) ?? candidate.commit.slice(0, 7);
32
- if (candidate.lines > options.maxLines) {
33
- entries.push(entry(candidate, id, "skipped", [], `src diff is ${candidate.lines} lines, over the ${options.maxLines} line cap`));
34
- continue;
35
- }
36
- const fixDiff = git(repo, ["diff", `${candidate.commit}^`, candidate.commit, "--", srcPrefix]).stdout;
37
- const bugDiff = git(repo, ["diff", "-R", `${candidate.commit}^`, candidate.commit, "--", srcPrefix]).stdout;
38
- if (!bugDiff.trim())
39
- continue;
40
- if (!workDir) {
41
- const work = withWorktree(repo, commit);
42
- if ("error" in work)
43
- return fail(outDir, commit, work.error);
44
- workDir = work.dir;
45
- }
46
- // A hand-made mutant that names this fix wins over the history revert. The revert can be
47
- // unviable (it removes an export a test imports), and the hand mutant keeps the API.
48
- const named = disk.find((item) => item.kind === "hand" && item.fix === candidate.commit);
49
- if (named && handmadeApplies(workDir, named.raw)) {
50
- entries.push({ ...entry(candidate, id, "handmade", filesInDiff(named.raw)), patch: `${named.id}.patch` });
51
- continue;
52
- }
53
- const applied = git(workDir, ["apply", "--check", "--whitespace=nowarn", "-"], bugDiff);
54
- if (applied.status === 0) {
55
- bodies.set(id, historyPatch(candidate.commit, bugDiff));
56
- entries.push({ ...entry(candidate, id, "clean", filesInDiff(fixDiff)), patch: `${id}.patch` });
57
- continue;
58
- }
59
- const where = failedAt(applied.stderr) ?? filesInDiff(fixDiff)[0] ?? { file: srcPrefix, line: 1 };
60
- const owned = disk.find((item) => item.kind !== "history" && (item.fix === candidate.commit || item.id === id));
61
- if (owned && handmadeApplies(workDir, owned.raw)) {
62
- entries.push({ ...entry(candidate, id, "handmade", [where]), patch: `${owned.id}.patch` });
63
- continue;
64
- }
65
- const reason = owned ? "hand-made patch does not apply" : "patch does not apply";
66
- entries.push(entry(candidate, id, "hand", [where], reason));
67
- }
68
- writeOut(outDir, commit, packageRel || ".", entries, bodies, disk);
69
- return report(outDir, repo, packageDir, commit, entries, collected);
70
- }
71
- finally {
72
- if (workDir)
73
- removeWorktree(repo, workDir);
74
- }
75
- }
76
- function collect(repo, commit, srcPrefix, testsPrefix, maxCommits) {
77
- const args = ["rev-list", "--no-merges"];
78
- if (maxCommits !== null)
79
- args.push("-n", String(maxCommits + 1));
80
- args.push(commit);
81
- const list = git(repo, args);
82
- if (list.status !== 0)
83
- return { found: [], scanned: 0, budgetHit: false };
84
- const shas = list.stdout.split("\n").filter((sha) => sha.length > 0);
85
- const budgetHit = maxCommits !== null && shas.length > maxCommits;
86
- const limited = budgetHit ? shas.slice(0, maxCommits) : shas;
87
- const found = [];
88
- for (const sha of limited) {
89
- if (git(repo, ["rev-parse", "--verify", `${sha}^`]).status !== 0)
90
- continue;
91
- const rows = numstat(repo, sha);
92
- let lines = 0;
93
- let src = false;
94
- let tests = false;
95
- for (const row of rows) {
96
- if (under(row.file, srcPrefix)) {
97
- src = true;
98
- if (row.add !== "-" && row.del !== "-")
99
- lines += Number(row.add) + Number(row.del);
100
- }
101
- if (under(row.file, testsPrefix))
102
- tests = true;
103
- }
104
- if (!src)
105
- continue;
106
- const meta = git(repo, ["log", "-1", `--format=%cI%n%s%n%b`, sha]);
107
- if (meta.status !== 0)
108
- continue;
109
- const [date, subject, ...rest] = meta.stdout.replace(/\n$/, "").split("\n");
110
- const body = rest.join("\n");
111
- const hasTrailer = /^Fixes-bug:/m.test(body);
112
- if (!hasTrailer && !(src && tests))
113
- continue;
114
- if (!hasTrailer && lines === 0)
115
- continue;
116
- // A version bump ships whatever was finished. Reverting it puts back several changes, not one bug.
117
- if (!hasTrailer && bumpsVersion(repo, sha))
118
- continue;
119
- found.push({
120
- commit: sha,
121
- date: date ?? "",
122
- subject: subject ?? "",
123
- fixer: /^Made-by:[ \t]*\S+[ \t]*$/m.test(body) ? "agent" : "human",
124
- lines,
125
- });
126
- }
127
- return { found, scanned: limited.length, budgetHit };
128
- }
129
- const MANIFESTS = ["package.json", "Cargo.toml", "pyproject.toml", "setup.cfg"].map((name) => `:(glob)**/${name}`);
130
- /** True when the commit changes a project version line in a manifest. */
131
- function bumpsVersion(repo, sha) {
132
- const diff = git(repo, ["show", "--format=", "-U0", sha, "--", ...MANIFESTS]);
133
- if (diff.status !== 0)
134
- return false;
135
- return diff.stdout
136
- .split("\n")
137
- .filter((line) => /^[-+]/.test(line) && !line.startsWith("+++") && !line.startsWith("---"))
138
- .some((line) => /"version"\s*:/.test(line) || /^[-+]\s*version\s*=/.test(line));
139
- }
140
- function entry(candidate, id, status, files, reason) {
141
- return {
142
- id,
143
- commit: candidate.commit,
144
- date: candidate.date,
145
- subject: candidate.subject,
146
- fixer: candidate.fixer,
147
- status,
148
- lines: candidate.lines,
149
- files,
150
- ...(reason ? { reason } : {}),
151
- };
152
- }
153
- function writeOut(outDir, commit, packageRel, entries, bodies, disk) {
154
- const cleanIds = new Set(entries.filter((item) => item.status === "clean").map((item) => item.id));
155
- for (const item of disk) {
156
- if (item.kind === "history" && !cleanIds.has(item.id))
157
- unlinkSync(path.join(outDir, `${item.id}.patch`));
158
- }
159
- for (const item of entries) {
160
- const body = bodies.get(item.id);
161
- if (item.status !== "clean" || !body)
162
- continue;
163
- const dest = path.join(outDir, `${item.id}.patch`);
164
- const temp = `${dest}.${process.pid}.tmp`;
165
- writeFileSync(temp, body);
166
- renameSync(temp, dest);
167
- }
168
- const recorded = {
169
- commit,
170
- package: packageRel.split(path.sep).join("/"),
171
- entries,
172
- };
173
- const dest = path.join(outDir, "ledger.json");
174
- const temp = `${dest}.${process.pid}.tmp`;
175
- writeFileSync(temp, `${JSON.stringify(scrub(recorded), null, 2)}\n`);
176
- renameSync(temp, dest);
177
- }
178
- function report(outDir, repo, packageDir, commit, entries, collected) {
179
- const clean = entries.filter((item) => item.status === "clean").length;
180
- const handEntries = entries.filter((item) => item.status === "hand").sort(byNewest);
181
- const handmade = entries.filter((item) => item.status === "handmade").length;
182
- const skipped = entries.filter((item) => item.status === "skipped").length;
183
- const shown = handEntries.slice(0, 10).map((item) => ({
184
- id: item.id,
185
- commit: item.commit,
186
- file: item.files[0]?.file ?? "",
187
- line: item.files[0]?.line ?? 0,
188
- }));
189
- const parts = [`${clean} fixes reverse cleanly`];
190
- if (handEntries.length > 0)
191
- parts.push(`${handEntries.length} need a hand-made mutant`);
192
- if (handmade > 0)
193
- parts.push(`${handmade} hand-made mutants apply`);
194
- if (skipped > 0)
195
- parts.push(`${skipped} are over the line cap`);
196
- if (collected.budgetHit) {
197
- const commits = collected.scanned === 1 ? "1 commit" : `${collected.scanned} commits`;
198
- parts.push(`Stopped after ${commits}. History was not fully scanned`);
199
- }
200
- const summary = `${parts.join(". ")}.`;
201
- let next = "No fix commit to revert.";
202
- if (handEntries.length > 0) {
203
- const first = shown[0];
204
- next = first.file
205
- ? `First fix that needs a hand-made mutant is ${first.id} at ${first.file}:${first.line}.`
206
- : `First fix that needs a hand-made mutant is ${first.id}.`;
207
- }
208
- else if (clean + handmade > 0)
209
- next = "Run the ledger mutants.";
210
- const nextCall = handEntries.length === 0 && clean + handmade > 0
211
- ? {
212
- argv: [
213
- "mutate",
214
- "run",
215
- "--package",
216
- packageDir,
217
- "--repo",
218
- repo,
219
- "--commit",
220
- commit,
221
- "--patches",
222
- outDir,
223
- "--out",
224
- path.join(outDir, "runs"),
225
- ],
226
- }
227
- : null;
228
- return {
229
- ok: true,
230
- summary,
231
- next,
232
- nextCall,
233
- commit,
234
- clean,
235
- hand: handEntries.length,
236
- handmade,
237
- skipped,
238
- handNeeded: shown,
239
- rest: Math.max(handEntries.length - shown.length, 0),
240
- full: path.join(outDir, "ledger.json"),
241
- historyBudgetHit: collected.budgetHit,
242
- commitsScanned: collected.scanned,
243
- };
244
- }
245
- function fail(outDir, commit, error) {
246
- return {
247
- ok: false,
248
- summary: error,
249
- next: "Fix that and run the same command again.",
250
- nextCall: null,
251
- commit,
252
- clean: 0,
253
- hand: 0,
254
- handmade: 0,
255
- skipped: 0,
256
- handNeeded: [],
257
- rest: 0,
258
- full: path.join(outDir, "ledger.json"),
259
- historyBudgetHit: false,
260
- commitsScanned: 0,
261
- };
262
- }
263
- function historyPatch(commit, bugDiff) {
264
- const body = bugDiff.endsWith("\n") ? bugDiff : `${bugDiff}\n`;
265
- return `# probatio-mutant direction=forward meaning=apply-to-introduce-the-bug\n# probatio-ledger source=history fix=${commit}\n${body}`;
266
- }
267
- function handmadeApplies(worktree, raw) {
268
- const named = readHeader(raw).direction;
269
- if (named)
270
- return patchApplies(worktree, raw, named);
271
- return patchApplies(worktree, raw, "forward");
272
- }
273
- function failedAt(stderr) {
274
- const match = stderr.match(/patch failed: ([^:\n]+):(\d+)/);
275
- if (!match)
276
- return null;
277
- return { file: match[1], line: Number(match[2]) };
278
- }
279
- function numstat(repo, sha) {
280
- const result = git(repo, ["diff-tree", "-r", "--numstat", "--no-commit-id", sha]);
281
- if (result.status !== 0)
282
- return [];
283
- const rows = [];
284
- for (const line of result.stdout.split("\n")) {
285
- if (!line.trim())
286
- continue;
287
- const [add, del, file] = line.split("\t");
288
- if (!file)
289
- continue;
290
- rows.push({ add, del, file });
291
- }
292
- return rows;
293
- }
294
- function loadDisk(outDir) {
295
- if (!existsSync(outDir))
296
- return [];
297
- const found = [];
298
- for (const name of readdirSync(outDir)) {
299
- if (!name.endsWith(".patch"))
300
- continue;
301
- const raw = readFileSync(path.join(outDir, name), "utf8");
302
- const header = raw.split("\n").filter((line) => line.startsWith("#")).join("\n");
303
- const kind = /source=hand\b/.test(header) ? "hand" : /source=history\b/.test(header) ? "history" : "user";
304
- const fix = header.match(/\bfix=([0-9a-f]{7,40})\b/)?.[1] ?? null;
305
- found.push({ id: path.basename(name, ".patch"), raw, kind, fix });
306
- }
307
- return found;
308
- }
309
- function assignIds(shas) {
310
- const used = new Set();
311
- const ids = new Map();
312
- for (const sha of [...shas].sort()) {
313
- let size = Math.min(7, sha.length);
314
- let id = sha.slice(0, size);
315
- while (used.has(id) && size < sha.length) {
316
- size += 1;
317
- id = sha.slice(0, size);
318
- }
319
- used.add(id);
320
- ids.set(sha, id);
321
- }
322
- return ids;
323
- }
324
- function withWorktree(repo, commit) {
325
- const dir = path.join(tmpdir(), `probatio-ledger-${randomBytes(4).toString("hex")}`);
326
- rmSync(dir, { recursive: true, force: true });
327
- const added = git(repo, ["worktree", "add", "--detach", "--quiet", dir, commit]);
328
- if (added.status !== 0)
329
- return { error: (added.stderr || "worktree add failed").trim() };
330
- return { dir };
331
- }
332
- function removeWorktree(repo, dir) {
333
- git(repo, ["worktree", "remove", "--force", dir]);
334
- rmSync(dir, { recursive: true, force: true });
335
- git(repo, ["worktree", "prune"]);
336
- }
337
- function gitPath(packageRel, dir) {
338
- const joined = packageRel ? path.join(packageRel, dir) : dir;
339
- return joined.split(path.sep).join("/");
340
- }
341
- function under(file, prefix) {
342
- return file === prefix || file.startsWith(`${prefix}/`);
343
- }
344
- function byNewest(left, right) {
345
- if (left.date !== right.date)
346
- return right.date < left.date ? -1 : 1;
347
- return left.id < right.id ? -1 : left.id > right.id ? 1 : 0;
348
- }
@@ -1,133 +0,0 @@
1
- import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
- import path from "node:path";
3
- import { SCHEMA_VERSION } from "../contract.js";
4
- import { goldenChanges } from "../golden/check.js";
5
- import { normalizeResult, runMutants } from "../mutate/run.js";
6
- /** The contract is the outcome. The reason follows from it, so a renamed test does not fail the check. */
7
- const REASON = {
8
- killed: "A test the suite already had caught this ledger bug.",
9
- survived: "The suite ran the line and did not catch this ledger bug.",
10
- "no coverage": "No test runs the line this ledger bug changes.",
11
- unviable: "This ledger bug does not build as it stands. A hand-made mutant that keeps the API would guard it.",
12
- flaky: "A test failed once and passed on the rerun. That is not a catch.",
13
- timeout: "The run passed the baseline clock. That is not a catch.",
14
- error: "The ledger patch did not run. It may no longer apply: rebuild the ledger or refresh the hand-made mutant.",
15
- missing: "No result was written for this ledger bug.",
16
- };
17
- export async function checkLedger(options) {
18
- const fail = (summary, next = "Fix that and run the same command again.") => ({
19
- schemaVersion: SCHEMA_VERSION,
20
- ok: false,
21
- command: "ledger.check",
22
- summary,
23
- next,
24
- nextCall: null,
25
- });
26
- const ledgerFile = path.join(options.ledgerDir, "ledger.json");
27
- if (!existsSync(ledgerFile))
28
- return fail(`No ledger.json in ${options.ledgerDir}.`, "Run probatio ledger build first.");
29
- const ledger = JSON.parse(readFileSync(ledgerFile, "utf8"));
30
- const guarded = (ledger.entries ?? []).filter((item) => (item.status === "clean" || item.status === "handmade") && item.patch);
31
- if (guarded.length === 0)
32
- return fail("The ledger has no bug that applies.", "Rebuild the ledger, or write a hand-made mutant for a fix.");
33
- const outDir = path.resolve(options.outDir);
34
- rmSync(outDir, { recursive: true, force: true });
35
- const patchDir = path.join(outDir, "patches");
36
- mkdirSync(patchDir, { recursive: true });
37
- for (const item of guarded) {
38
- const source = path.join(options.ledgerDir, item.patch);
39
- if (!existsSync(source))
40
- return fail(`${item.patch} is listed in ledger.json and is not in ${options.ledgerDir}.`);
41
- copyFileSync(source, path.join(patchDir, item.patch));
42
- }
43
- const report = await runMutants({
44
- packageDir: options.packageDir,
45
- repoDir: options.repoDir,
46
- commit: options.commit,
47
- patchDirs: [patchDir],
48
- outDir: path.join(outDir, "run"),
49
- direction: "forward",
50
- workers: 1,
51
- concurrency: options.concurrency,
52
- maxMinutes: null,
53
- maxMutants: null,
54
- build: null,
55
- testsDir: "tests",
56
- unset: [],
57
- suiteTimeoutMs: options.suiteTimeoutMs,
58
- testTimeoutMs: options.testTimeoutMs,
59
- confirm: true,
60
- affected: false,
61
- agent: null,
62
- onProgress: options.onProgress,
63
- });
64
- if (!report.ok)
65
- return fail(`The ledger run did not score: ${report.summary}`, report.next);
66
- const allowed = goldenChanges(options.message);
67
- const rows = [];
68
- const regressed = [];
69
- const changed = [];
70
- const unrecorded = [];
71
- const unauthorized = [];
72
- for (const item of guarded) {
73
- const patchId = path.basename(item.patch, ".patch");
74
- const resultFile = path.join(outDir, "run", "results", `${patchId}.json`);
75
- const result = existsSync(resultFile) ? normalizeResult(JSON.parse(readFileSync(resultFile, "utf8"))) : null;
76
- const outcome = result?.outcome ?? "missing";
77
- const next = outcome === "killed" ? `Killed by ${(result?.killedBy ?? []).join(", ")}.` : result?.next || result?.error || REASON[outcome] || outcome;
78
- const actual = { ok: true, outcome, reason: REASON[outcome] ?? outcome, next };
79
- const goldenFile = path.join(options.ledgerDir, `${item.id}.golden.json`);
80
- const recorded = existsSync(goldenFile)
81
- ? (JSON.parse(readFileSync(goldenFile, "utf8")).rows?.[item.id]?.outcome ?? null)
82
- : null;
83
- rows.push({ id: item.id, recorded, actual: outcome, next });
84
- if (recorded === null)
85
- unrecorded.push(item.id);
86
- else if (recorded === "killed" && outcome !== "killed")
87
- regressed.push(item.id);
88
- else if (recorded !== outcome)
89
- changed.push(item.id);
90
- const differs = recorded !== outcome;
91
- if (differs && recorded !== null && !allowed.has(item.id))
92
- unauthorized.push(item.id);
93
- if (options.update && (!differs || recorded === null || allowed.has(item.id))) {
94
- writeFileSync(goldenFile, `${JSON.stringify({ rows: { [item.id]: actual } }, null, 2)}\n`);
95
- }
96
- }
97
- const killed = rows.filter((row) => row.actual === "killed").length;
98
- const written = options.update ? rows.filter((row) => !unauthorized.includes(row.id)).length : 0;
99
- const ok = options.update ? unauthorized.length === 0 : regressed.length === 0 && changed.length === 0 && unrecorded.length === 0;
100
- const parts = [`${killed} of ${rows.length} ledger bugs caught.`];
101
- if (regressed.length > 0)
102
- parts.push(`Regressed: ${regressed.join(", ")}.`);
103
- if (changed.length > 0)
104
- parts.push(`Changed: ${changed.join(", ")}.`);
105
- if (unrecorded.length > 0)
106
- parts.push(`Not recorded yet: ${unrecorded.join(", ")}.`);
107
- if (options.update)
108
- parts.push(`${written} golden row(s) written.`);
109
- let next = "Every ledger bug has the outcome recorded for it.";
110
- if (regressed.length > 0)
111
- next = `A fixed bug is back in reach of the suite: ${regressed[0]} was caught and is not now. Find the test that stopped guarding it.`;
112
- else if (unauthorized.length > 0 && options.update)
113
- next = `Not written: ${unauthorized.join(", ")} changed outcome. Add Golden-Change: <id>: <why> to --message for each.`;
114
- else if (options.update)
115
- next = "Re-recorded. Commit the goldens with the reason for each changed outcome.";
116
- else if (changed.length > 0 || unrecorded.length > 0)
117
- next = "Review the changed outcomes, then re-record with --update and a Golden-Change line for each changed id.";
118
- return {
119
- schemaVersion: SCHEMA_VERSION,
120
- ok,
121
- command: "ledger.check",
122
- summary: parts.join(" "),
123
- next,
124
- nextCall: null,
125
- caught: killed,
126
- total: rows.length,
127
- regressed,
128
- changed,
129
- unrecorded,
130
- rows,
131
- run: { summary: report.summary, commit: report.commit, full: report.full },
132
- };
133
- }