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,256 +0,0 @@
1
- import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
- import path from "node:path";
3
- import { SCHEMA_VERSION } from "../contract.js";
4
- import { filesInDiff } from "../mutate/patch.js";
5
- const METRIC = /^(holdout|real \(train\)|synthetic-1|synthetic-2):\s+(\d+)\/(\d+)(?:\s+\([^)]*\))?(?:\s+missed\s+(.+))?$/;
6
- const PREFIX = /^(?:ho|r|h|v)-/;
7
- /** Names of tests that killed nothing. This never deletes a file. */
8
- export function advisePrune(kills) {
9
- const advice = [...kills.entries()]
10
- .filter(([, killed]) => killed === 0)
11
- .map(([name]) => name)
12
- .sort();
13
- return { mode: "advisory", deletedTests: 0, advice };
14
- }
15
- export function parseUnionTsv(text) {
16
- const lines = text.split(/\r?\n/).filter((line) => line.trim().length > 0);
17
- if (lines.length === 0)
18
- return [];
19
- const columns = lines[0].split("\t").map(parseHeader);
20
- return lines.slice(1).map((line) => {
21
- const cells = line.split("\t");
22
- const row = emptyRow("union");
23
- for (let i = 0; i < columns.length; i++) {
24
- const column = columns[i];
25
- const cell = (cells[i] ?? "").trim();
26
- if (column.kind === "set") {
27
- row.set = cell;
28
- row.name = cell;
29
- }
30
- else if (column.kind === "lines") {
31
- const lines = Number(cell);
32
- if (!Number.isInteger(lines))
33
- throw new Error(`lines is not an integer: ${cell}`);
34
- row.lines = lines;
35
- }
36
- else if (column.total === null) {
37
- throw new Error(`column ${column.kind} has no total`);
38
- }
39
- else {
40
- assign(row, column.kind, parseCaught(cell, column.total));
41
- }
42
- }
43
- return row;
44
- });
45
- }
46
- export function parseDirectScores(text) {
47
- const rows = [];
48
- let current = null;
49
- for (const raw of text.split(/\r?\n/)) {
50
- const line = raw.trim();
51
- if (!line)
52
- continue;
53
- const metric = line.match(METRIC);
54
- if (!metric) {
55
- current = emptyRow("direct");
56
- current.name = line;
57
- current.set = line.split("(")[0].trim().split(/\s+/).join("");
58
- rows.push(current);
59
- continue;
60
- }
61
- if (!current)
62
- throw new Error(`metric before a suite: ${line}`);
63
- assign(current, metricKind(metric[1]), { caught: Number(metric[2]), total: Number(metric[3]) });
64
- if (metric[4])
65
- current.missed.push(...metric[4].trim().split(/\s+/).filter(Boolean));
66
- }
67
- return rows;
68
- }
69
- export function reportMatrix(input) {
70
- const rows = [...parseUnionTsv(input.scores), ...parseDirectScores(input.direct)];
71
- if (rows.length === 0) {
72
- return {
73
- schemaVersion: SCHEMA_VERSION,
74
- ok: false,
75
- command: "matrix.report",
76
- summary: "The score files have no suite rows.",
77
- next: "Pass --scores and --direct that contain the recorded table.",
78
- nextCall: null,
79
- };
80
- }
81
- const reference = rows.find((row) => row.source === "union" && row.holdout) ?? null;
82
- const gapRow = weakestHoldout(rows.filter((row) => row.source === "direct"));
83
- const gaps = resolveGaps(gapRow?.missed ?? [], input.patchDirs);
84
- const shown = gaps.slice(0, 10);
85
- const collapse = {
86
- batch2: rows
87
- .filter((row) => row.source === "union" && lowerRate(row.synthetic2, row.synthetic1))
88
- .map((row) => ({ set: row.set, synthetic1: row.synthetic1, synthetic2: row.synthetic2 })),
89
- holdoutBelowReference: reference
90
- ? rows
91
- .filter((row) => row !== reference && lowerRate(row.holdout, reference.holdout))
92
- .map((row) => ({ set: row.set, holdout: row.holdout, reference: reference.holdout }))
93
- : [],
94
- };
95
- const pruning = advisePrune(input.kills);
96
- const fullPath = path.join(input.outDir, "matrix.json");
97
- const complete = {
98
- schemaVersion: SCHEMA_VERSION,
99
- ok: true,
100
- command: "matrix.report",
101
- summary: summarize(gapRow, collapse.batch2, gaps.length, pruning.deletedTests),
102
- next: nextSentence(gaps),
103
- nextCall: null,
104
- rows,
105
- collapse,
106
- gaps,
107
- rest: 0,
108
- pruning,
109
- full: fullPath,
110
- };
111
- mkdirSync(input.outDir, { recursive: true });
112
- writeFileSync(fullPath, `${JSON.stringify(complete, null, 2)}\n`);
113
- return {
114
- ...complete,
115
- gaps: shown,
116
- rest: gaps.length - shown.length,
117
- };
118
- }
119
- export function readKills(file) {
120
- const parsed = JSON.parse(readFileSync(file, "utf8"));
121
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
122
- throw new Error("--kills must be a JSON object of test name to kill count");
123
- const kills = new Map();
124
- for (const [name, value] of Object.entries(parsed)) {
125
- if (typeof value !== "number" || !Number.isInteger(value) || value < 0) {
126
- throw new Error(`kill count for ${name} must be a non-negative integer`);
127
- }
128
- kills.set(name, value);
129
- }
130
- return kills;
131
- }
132
- function summarize(gapRow, batch2, gapCount, deletedTests) {
133
- const parts = [];
134
- if (gapRow?.holdout) {
135
- const train = gapRow.real ? ` Train real is ${gapRow.real.caught}/${gapRow.real.total}.` : "";
136
- parts.push(`${gapRow.set} holdout is ${gapRow.holdout.caught}/${gapRow.holdout.total}.${train}`);
137
- }
138
- if (batch2.length > 0) {
139
- const bits = batch2.map((item) => {
140
- const left = item.synthetic1;
141
- const right = item.synthetic2;
142
- return `${item.set} synthetic-1 ${left?.caught}/${left?.total} against synthetic-2 ${right?.caught}/${right?.total}`;
143
- });
144
- parts.push(`Cover collapse on batch 2: ${bits.join("; ")}.`);
145
- }
146
- parts.push(`${gapCount} gaps. Pruning is advisory and deleted ${deletedTests} tests.`);
147
- return parts.join(" ");
148
- }
149
- function nextSentence(gaps) {
150
- const first = gaps.find((gap) => gap.file !== null && gap.line !== null) ?? gaps[0];
151
- if (!first)
152
- return "No gaps.";
153
- if (first.file === null || first.line === null)
154
- return `${first.id} has no patch, so there is no file:line.`;
155
- return `${first.id} at ${first.file}:${first.line}`;
156
- }
157
- function weakestHoldout(rows) {
158
- let best = null;
159
- let bestRate = Number.POSITIVE_INFINITY;
160
- for (const row of rows) {
161
- const value = rate(row.holdout);
162
- if (value === null || value >= bestRate)
163
- continue;
164
- best = row;
165
- bestRate = value;
166
- }
167
- return best;
168
- }
169
- function lowerRate(left, right) {
170
- const a = rate(left);
171
- const b = rate(right);
172
- return a !== null && b !== null && a < b;
173
- }
174
- function rate(count) {
175
- if (!count || count.total === 0)
176
- return null;
177
- return count.caught / count.total;
178
- }
179
- function resolveGaps(ids, patchDirs) {
180
- const unique = [];
181
- const seen = new Set();
182
- for (const id of ids) {
183
- if (seen.has(id))
184
- continue;
185
- seen.add(id);
186
- unique.push(id);
187
- }
188
- unique.sort();
189
- return unique.map((id) => locate(id, patchDirs));
190
- }
191
- function locate(id, patchDirs) {
192
- const bare = id.replace(PREFIX, "");
193
- const names = [...new Set([`${id}.patch`, `r-${bare}.patch`, `h-${bare}.patch`, `${bare}.patch`, `ho-${bare}.patch`])];
194
- for (const dir of patchDirs) {
195
- for (const name of names) {
196
- const candidate = path.join(dir, name);
197
- let raw;
198
- try {
199
- raw = readFileSync(candidate, "utf8");
200
- }
201
- catch {
202
- continue;
203
- }
204
- const locations = filesInDiff(raw);
205
- const first = locations[0];
206
- return { id, file: first?.file ?? null, line: first?.line ?? null, locations };
207
- }
208
- }
209
- return { id, file: null, line: null, locations: [] };
210
- }
211
- function parseHeader(cell) {
212
- const match = cell.trim().match(/^(.*?)\s*\((\d+)\)$/);
213
- if (!match)
214
- return { kind: cell.trim(), total: null };
215
- return { kind: metricKind(match[1].trim()), total: Number(match[2]) };
216
- }
217
- function metricKind(name) {
218
- if (name === "synthetic-1")
219
- return "synthetic1";
220
- if (name === "synthetic-2")
221
- return "synthetic2";
222
- if (name === "real (train)" || name === "real")
223
- return "real";
224
- return name;
225
- }
226
- function parseCaught(cell, total) {
227
- const match = cell.match(/^(\d+)/);
228
- if (!match)
229
- throw new Error(`cannot read a count from "${cell}"`);
230
- return { caught: Number(match[1]), total };
231
- }
232
- function assign(row, kind, count) {
233
- if (kind === "holdout")
234
- row.holdout = count;
235
- else if (kind === "real")
236
- row.real = count;
237
- else if (kind === "synthetic1")
238
- row.synthetic1 = count;
239
- else if (kind === "synthetic2")
240
- row.synthetic2 = count;
241
- else
242
- throw new Error(`unknown score column ${kind}`);
243
- }
244
- function emptyRow(source) {
245
- return {
246
- set: "",
247
- name: "",
248
- source,
249
- lines: null,
250
- holdout: null,
251
- real: null,
252
- synthetic1: null,
253
- synthetic2: null,
254
- missed: [],
255
- };
256
- }
package/dist/mcp.js DELETED
@@ -1,188 +0,0 @@
1
- #!/usr/bin/env node
2
- import { spawn } from "node:child_process";
3
- import { existsSync, readFileSync } from "node:fs";
4
- import path from "node:path";
5
- import { fileURLToPath } from "node:url";
6
- import { nodeTooOld } from "./node-version.js";
7
- const tooOld = nodeTooOld(process.versions.node);
8
- if (tooOld) {
9
- process.stdout.write(`${JSON.stringify({
10
- schemaVersion: 2,
11
- ok: false,
12
- command: "mcp",
13
- summary: tooOld,
14
- next: "Install Node 22.6 or newer and run the command again.",
15
- nextCall: null,
16
- })}\n`);
17
- process.exit(1);
18
- }
19
- /** Protocol versions this server speaks. The client's choice wins when it is one of these. */
20
- const PROTOCOLS = ["2024-11-05", "2025-03-26", "2025-06-18"];
21
- const here = path.dirname(fileURLToPath(import.meta.url));
22
- const version = readVersion();
23
- const tool = {
24
- name: "probatio",
25
- description: "Run one probatio command. argv is the CLI words and flags.",
26
- inputSchema: {
27
- type: "object",
28
- properties: { argv: { type: "array", items: { type: "string" } } },
29
- required: ["argv"],
30
- },
31
- };
32
- let buffer = "";
33
- let closing = false;
34
- /** Tool calls still running, by request id. A cancel kills the child and drops the reply. */
35
- const running = new Map();
36
- process.stdin.setEncoding("utf8");
37
- process.stdin.on("data", (chunk) => {
38
- buffer += chunk;
39
- drain();
40
- });
41
- process.stdin.on("end", () => {
42
- drain(true);
43
- closing = true;
44
- // A call already running still gets its reply before the server exits.
45
- if (running.size === 0)
46
- process.exit(0);
47
- });
48
- function drain(flush = false) {
49
- const lines = buffer.split("\n");
50
- buffer = flush ? "" : lines.pop() ?? "";
51
- for (const line of lines) {
52
- const text = line.replace(/\r$/, "").trim();
53
- if (!text)
54
- continue;
55
- let message;
56
- try {
57
- message = JSON.parse(text);
58
- }
59
- catch {
60
- continue;
61
- }
62
- handle(message);
63
- }
64
- }
65
- function handle(message) {
66
- if (!message.method)
67
- return;
68
- if (message.method === "notifications/cancelled") {
69
- const key = message.params?.requestId;
70
- if (key === undefined)
71
- return;
72
- const child = running.get(String(key));
73
- running.delete(String(key));
74
- if (child?.pid) {
75
- try {
76
- process.kill(-child.pid, "SIGTERM");
77
- }
78
- catch {
79
- child.kill("SIGTERM");
80
- }
81
- }
82
- return;
83
- }
84
- if (message.id === undefined || message.id === null)
85
- return;
86
- if (message.method === "initialize") {
87
- send({
88
- jsonrpc: "2.0",
89
- id: message.id,
90
- result: {
91
- protocolVersion: PROTOCOLS.includes(message.params?.protocolVersion ?? "") ? message.params?.protocolVersion : PROTOCOLS[0],
92
- capabilities: { tools: {} },
93
- serverInfo: { name: "probatio", version },
94
- tools: [tool],
95
- },
96
- });
97
- return;
98
- }
99
- if (message.method === "tools/list") {
100
- send({ jsonrpc: "2.0", id: message.id, result: { tools: [tool] } });
101
- return;
102
- }
103
- if (message.method === "ping") {
104
- send({ jsonrpc: "2.0", id: message.id, result: {} });
105
- return;
106
- }
107
- if (message.method === "tools/call") {
108
- const argv = message.params?.arguments?.argv;
109
- if (!Array.isArray(argv) || argv.some((item) => typeof item !== "string")) {
110
- send({ jsonrpc: "2.0", id: message.id, error: { code: -32602, message: "argv must be an array of strings" } });
111
- return;
112
- }
113
- call(message.id, argv);
114
- return;
115
- }
116
- send({
117
- jsonrpc: "2.0",
118
- id: message.id,
119
- error: { code: -32601, message: `method not found: ${message.method}` },
120
- });
121
- }
122
- /** One CLI command per call. It runs beside other calls, so ping and cancel are answered meanwhile. */
123
- function call(id, argv) {
124
- const key = String(id);
125
- const cli = cliCommand();
126
- // Its own process group, so a cancel also stops the suite the command started.
127
- const child = spawn(cli.bin, [...cli.args, ...argv], { stdio: ["ignore", "pipe", "ignore"], detached: true });
128
- running.set(key, child);
129
- let text = "";
130
- child.stdout?.setEncoding("utf8");
131
- child.stdout?.on("data", (chunk) => {
132
- text += chunk;
133
- });
134
- const settle = (failure) => {
135
- // A cancelled call is no longer in the map. Its reply is dropped.
136
- const live = running.get(key) === child;
137
- running.delete(key);
138
- if (live)
139
- reply(id, text, failure);
140
- if (closing && running.size === 0)
141
- process.exit(0);
142
- };
143
- child.on("error", (error) => settle(error.message));
144
- child.on("close", () => settle(null));
145
- }
146
- function reply(id, text, failure) {
147
- let envelope = null;
148
- try {
149
- envelope = JSON.parse(text);
150
- }
151
- catch {
152
- envelope = null;
153
- }
154
- if (!envelope || typeof envelope !== "object") {
155
- const message = failure ? `the command did not start: ${failure}` : "the command did not return one JSON object";
156
- send({ jsonrpc: "2.0", id, error: { code: -32000, message } });
157
- return;
158
- }
159
- send({
160
- jsonrpc: "2.0",
161
- id,
162
- result: {
163
- content: [{ type: "text", text: text.trim() }],
164
- structuredContent: envelope,
165
- isError: envelope.ok !== true,
166
- },
167
- });
168
- }
169
- function cliCommand() {
170
- const compiled = path.join(here, "cli.js");
171
- if (existsSync(compiled))
172
- return { bin: process.execPath, args: [compiled] };
173
- const tsx = path.resolve(here, "..", "node_modules", ".bin", "tsx");
174
- return { bin: tsx, args: [path.join(here, "cli.ts")] };
175
- }
176
- function readVersion() {
177
- try {
178
- const raw = JSON.parse(readFileSync(path.resolve(here, "..", "package.json"), "utf8"));
179
- return typeof raw.version === "string" && raw.version.length > 0 ? raw.version : "0.0.1";
180
- }
181
- catch {
182
- return "0.0.1";
183
- }
184
- }
185
- function send(message) {
186
- const line = JSON.stringify(message);
187
- process.stdout.write(`${line}\n`);
188
- }
@@ -1,222 +0,0 @@
1
- import { createHash } from "node:crypto";
2
- import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
- import path from "node:path";
4
- import { SCHEMA_VERSION } from "../contract.js";
5
- import { makeRng, shuffle } from "../mutate/ids.js";
6
- export const YARDSTICK = "these tests fit the yardstick, not the code.";
7
- const FINDING_STATUS = new Set(["open", "fixed", "equivalent", "wont-fix"]);
8
- export function lineHash(line) {
9
- return createHash("sha256").update(line).digest("hex").slice(0, 16);
10
- }
11
- export function readSourceLine(file, line) {
12
- if (!existsSync(file))
13
- return null;
14
- const lines = readFileSync(file, "utf8").split(/\r?\n/);
15
- if (line < 1 || line > lines.length)
16
- return null;
17
- return lines[line - 1];
18
- }
19
- /** `Guard-Change: <guard>: <why>`. A guard such as `connect.test.ts:118` keeps its colon. */
20
- export function guardChanges(message) {
21
- const named = new Map();
22
- for (const match of message.matchAll(/^Guard-Change:\s*(.+?):\s+(.+)$/gm))
23
- named.set(match[1], match[2]);
24
- return named;
25
- }
26
- export function emptyState() {
27
- return { schemaVersion: SCHEMA_VERSION, gaps: [], decisions: [] };
28
- }
29
- export function loadState(dir) {
30
- const file = path.join(dir, "state.json");
31
- if (!existsSync(file))
32
- return emptyState();
33
- const parsed = JSON.parse(readFileSync(file, "utf8"));
34
- return { schemaVersion: SCHEMA_VERSION, gaps: parsed.gaps ?? [], decisions: parsed.decisions ?? [] };
35
- }
36
- export function saveState(dir, state) {
37
- mkdirSync(dir, { recursive: true });
38
- const ordered = {
39
- schemaVersion: SCHEMA_VERSION,
40
- gaps: [...state.gaps].sort((a, b) => a.id.localeCompare(b.id)),
41
- decisions: [...state.decisions].sort((a, b) => a.id.localeCompare(b.id)),
42
- };
43
- writeFileSync(path.join(dir, "state.json"), `${JSON.stringify(ordered, null, 2)}\n`);
44
- }
45
- export function loadSealed(dir) {
46
- const file = path.join(dir, "sealed.json");
47
- if (!existsSync(file))
48
- return [];
49
- const parsed = JSON.parse(readFileSync(file, "utf8"));
50
- return [...new Set(parsed.ids ?? [])].sort();
51
- }
52
- export function sealIds(dir, ids) {
53
- const merged = [...new Set([...loadSealed(dir), ...ids])].sort();
54
- mkdirSync(dir, { recursive: true });
55
- writeFileSync(path.join(dir, "sealed.json"), `${JSON.stringify({ ids: merged }, null, 2)}\n`);
56
- }
57
- /** A stable share of the ids. The same seed returns the same slice. */
58
- export function chooseSealed(ids, fraction, seed) {
59
- if (ids.length === 0 || fraction <= 0)
60
- return [];
61
- const count = Math.min(ids.length, Math.max(1, Math.round(ids.length * fraction)));
62
- return shuffle([...ids], makeRng(seed)).slice(0, count).sort();
63
- }
64
- export function parseGuard(guard) {
65
- const match = guard.match(/^(.*):(\d+)$/);
66
- if (!match)
67
- throw new Error("guard must be file:line");
68
- return { file: match[1], line: Number(match[2]) };
69
- }
70
- export function fixGap(state, input) {
71
- const located = parseGuard(input.guard);
72
- const absolute = path.resolve(input.root, located.file);
73
- const text = readSourceLine(absolute, located.line);
74
- if (text === null)
75
- throw new Error("guard line does not exist");
76
- const hash = lineHash(text);
77
- const gaps = state.gaps.filter((gap) => gap.id !== input.id);
78
- gaps.push({
79
- id: input.id,
80
- status: "fixed",
81
- file: input.file,
82
- line: input.line,
83
- guard: input.guard,
84
- fixedIn: input.commit,
85
- guardFile: located.file,
86
- guardLineNo: located.line,
87
- guardLine: text,
88
- guardHash: hash,
89
- });
90
- const decisions = state.decisions.filter((item) => item.id !== input.id);
91
- decisions.push({ id: input.id, file: located.file, line: located.line, hash, status: "fixed", reason: `guarded by ${input.guard}` });
92
- return { ...state, gaps, decisions };
93
- }
94
- export function revertGap(state, id) {
95
- const gap = state.gaps.find((item) => item.id === id);
96
- if (!gap || !gap.fixedIn || !gap.guard)
97
- throw new Error("that id is not a fixed gap");
98
- const text = `fixed in ${gap.fixedIn}, guarded by ${gap.guard}; the guard broke`;
99
- const gaps = state.gaps.map((item) => (item.id === id ? { ...item, status: "regression" } : item));
100
- return { state: { ...state, gaps }, text };
101
- }
102
- export function guardAllows(state, guard, message, root) {
103
- const owners = state.gaps.filter((gap) => gap.guard === guard && (gap.status === "fixed" || gap.status === "regression"));
104
- if (owners.length === 0)
105
- return { ok: true, summary: `${guard} guards nothing.` };
106
- const owner = owners[0];
107
- if (guardChanges(message).has(guard))
108
- return { ok: true, summary: `${guard} changed with a Guard-Change trailer.` };
109
- const file = owner.guardFile ? path.resolve(root, owner.guardFile) : "";
110
- const line = owner.guardLineNo && file ? readSourceLine(file, owner.guardLineNo) : null;
111
- const weakened = line === null || (owner.guardLine !== null && line !== owner.guardLine);
112
- if (!weakened)
113
- return { ok: true, summary: `${guard} is intact.` };
114
- return { ok: false, summary: `Refusing to change ${guard}. It is the guard of ${owner.id}.` };
115
- }
116
- export function appendFinding(dir, finding) {
117
- if (!FINDING_STATUS.has(finding.status))
118
- throw new Error("status must be open, fixed, equivalent, or wont-fix");
119
- if (!finding.reason || !finding.reason.trim())
120
- throw new Error("a finding needs a reason");
121
- mkdirSync(dir, { recursive: true });
122
- appendFileSync(path.join(dir, "findings.jsonl"), `${JSON.stringify(finding)}\n`);
123
- }
124
- export function loadFindings(dir) {
125
- const file = path.join(dir, "findings.jsonl");
126
- if (!existsSync(file))
127
- return [];
128
- return readFileSync(file, "utf8")
129
- .split(/\r?\n/)
130
- .filter((line) => line.trim().length > 0)
131
- .map((line) => JSON.parse(line));
132
- }
133
- export function yardstickSentence(score) {
134
- if (!score)
135
- return null;
136
- if (score.visible.after > score.visible.before && score.sealed.after <= score.sealed.before)
137
- return YARDSTICK;
138
- return null;
139
- }
140
- export function statusView(dir, root) {
141
- const state = loadState(dir);
142
- const sealed = new Set(loadSealed(dir));
143
- const hidden = (id) => sealed.has(id);
144
- const open = state.gaps.filter((gap) => gap.status === "open" && !hidden(gap.id));
145
- const shown = open.slice(0, 10);
146
- const fixes = state.gaps
147
- .filter((gap) => gap.status === "fixed" && !hidden(gap.id))
148
- .map((gap) => ({ id: gap.id, fixedIn: gap.fixedIn, guard: gap.guard }));
149
- const guards = [...new Set(state.gaps.filter((gap) => gap.guard && !hidden(gap.id)).map((gap) => gap.guard))].sort();
150
- const decisions = state.decisions
151
- .filter((item) => !hidden(item.id))
152
- .map((item) => {
153
- const current = readSourceLine(path.resolve(root, item.file), item.line);
154
- const review = current !== null && lineHash(current) === item.hash ? "current" : "stale";
155
- return { ...item, review };
156
- });
157
- const regressions = state.gaps
158
- .filter((gap) => gap.status === "regression" && !hidden(gap.id) && gap.fixedIn && gap.guard)
159
- .map((gap) => ({ id: gap.id, text: `fixed in ${gap.fixedIn}, guarded by ${gap.guard}; the guard broke` }));
160
- const findings = loadFindings(dir).filter((item) => !hidden(item.id)).slice(-10);
161
- return {
162
- gaps: shown,
163
- rest: Math.max(open.length - shown.length, 0),
164
- recentFixes: fixes,
165
- protectedGuards: guards,
166
- decisions,
167
- regressions,
168
- findings,
169
- sealedCount: sealed.size,
170
- };
171
- }
172
- export function renderPage(view, summary) {
173
- const lines = ["# PROBATIO", "", summary, "", "## Gaps"];
174
- if (view.gaps.length === 0)
175
- lines.push("None.");
176
- for (const gap of view.gaps)
177
- lines.push(`- ${gap.id} at ${gap.file}:${gap.line}`);
178
- if (view.rest > 0)
179
- lines.push(`- ${view.rest} more`);
180
- lines.push("", "## Recent fixes");
181
- if (view.recentFixes.length === 0)
182
- lines.push("None.");
183
- for (const fix of view.recentFixes)
184
- lines.push(`- ${fix.id} fixed in ${fix.fixedIn}, guard ${fix.guard}`);
185
- lines.push("", "## Protected guards");
186
- if (view.protectedGuards.length === 0)
187
- lines.push("None.");
188
- for (const guard of view.protectedGuards)
189
- lines.push(`- ${guard}`);
190
- lines.push("", "## Decisions");
191
- if (view.decisions.length === 0)
192
- lines.push("None.");
193
- for (const decision of view.decisions)
194
- lines.push(`- ${decision.id} ${decision.status} ${decision.review} ${decision.reason}`);
195
- lines.push("", "## Regressions");
196
- if (view.regressions.length === 0)
197
- lines.push("None.");
198
- for (const item of view.regressions)
199
- lines.push(`- ${item.id}: ${item.text}`);
200
- lines.push("");
201
- return lines.join("\n");
202
- }
203
- export function statusEnvelope(dir, root, score, agent) {
204
- const view = statusView(dir, root);
205
- const yard = yardstickSentence(score);
206
- const summary = yard ?? `${view.gaps.length} open gaps, ${view.recentFixes.length} fixes, ${view.decisions.length} decisions.`;
207
- const first = view.regressions[0]?.text ?? (view.gaps[0] ? `${view.gaps[0].id} at ${view.gaps[0].file}:${view.gaps[0].line}` : "Nothing waiting.");
208
- const page = renderPage(view, summary);
209
- return {
210
- page,
211
- envelope: {
212
- schemaVersion: SCHEMA_VERSION,
213
- ok: true,
214
- command: "status",
215
- summary,
216
- next: first,
217
- nextCall: null,
218
- agent: agent ?? "unknown",
219
- ...view,
220
- },
221
- };
222
- }