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,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: 1,
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
- }