pi-warden 0.64.1 → 0.71.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.
- package/CHANGELOG.md +95 -0
- package/README.md +49 -93
- package/dist/adaptive.d.ts +132 -0
- package/dist/adaptive.js +274 -0
- package/dist/atomic.d.ts +10 -0
- package/dist/atomic.js +51 -0
- package/dist/config.d.ts +40 -1
- package/dist/config.js +37 -9
- package/dist/evidence.d.ts +93 -0
- package/dist/evidence.js +409 -0
- package/dist/extension.js +213 -40
- package/dist/extension.js.bak +668 -102
- package/dist/index.d.ts +10 -2
- package/dist/index.js +5 -1
- package/dist/loops.d.ts +10 -0
- package/dist/loops.js +15 -5
- package/dist/prefs.d.ts +8 -0
- package/dist/prefs.js +15 -5
- package/dist/rules-lint.d.ts +84 -0
- package/dist/rules-lint.js +191 -0
- package/dist/rules-log.d.ts +98 -0
- package/dist/rules-log.js +190 -0
- package/dist/rules-report.d.ts +52 -0
- package/dist/rules-report.js +83 -0
- package/dist/rules.d.ts +24 -2
- package/dist/rules.js +154 -33
- package/dist/shape.d.ts +1 -1
- package/dist/shape.js +14 -3
- package/dist/stuck.d.ts +34 -11
- package/dist/stuck.js +20 -8
- package/dist/trace.d.ts +1 -1
- package/dist/waste.d.ts +98 -0
- package/dist/waste.js +582 -0
- package/package.json +4 -2
package/dist/evidence.js
ADDED
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
// Structured evidence for the stuck judge: what a run failed on, and what an edit changed, in a shape a judge can
|
|
2
|
+
// compare across attempts. The parser is generic: it knows the shapes of common runners and compilers (node test/TAP,
|
|
3
|
+
// jest, vitest, pytest, tsc, eslint, cargo, go, a `make test` script, Playwright, Python and Node tracebacks) and
|
|
4
|
+
// falls back to an error line (`generic`) or to the head and tail of the raw output (`unparsed`).
|
|
5
|
+
//
|
|
6
|
+
// Nothing here depends on the Pi runtime, and nothing here redacts: the caller redacts what it keeps, and every string
|
|
7
|
+
// in the built evidence is redacted before it enters a request.
|
|
8
|
+
import { redact } from "./redact.js";
|
|
9
|
+
const MAX_ITEMS = 6;
|
|
10
|
+
const DIFF_CAP = 600;
|
|
11
|
+
const FALLBACK_HEAD = 300;
|
|
12
|
+
const FALLBACK_TAIL = 300;
|
|
13
|
+
/** Byte cap for the whole `evidence` object; over it, the oldest runs go first, then the oldest edits. */
|
|
14
|
+
export const EVIDENCE_LIMIT = 4096;
|
|
15
|
+
/** Test, build, lint and type-check runners, and the ad-hoc scripts a project uses as checks. */
|
|
16
|
+
const RUNNER = /\b(?:(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:test|check|lint|typecheck|build|e2e)|(?:npx|pnpm|bunx)\s+(?:tsc|jest|vitest|eslint|playwright)|node\s+--test|pytest|jest|vitest|tsc|eslint|cargo\s+(?:test|check|build|clippy)|go\s+(?:test|vet|build)|make\s+\w+|sbcl\s+--script)\b/;
|
|
17
|
+
const EDIT_TOOLS = new Set(["edit", "write"]);
|
|
18
|
+
function clip(value, limit = 200) {
|
|
19
|
+
return (value ?? "").replace(/\s+/g, " ").trim().slice(0, limit);
|
|
20
|
+
}
|
|
21
|
+
function uniq(values) {
|
|
22
|
+
return [...new Set(values.map(value => clip(value, 160)).filter(Boolean))];
|
|
23
|
+
}
|
|
24
|
+
function headTail(text, head, tail) {
|
|
25
|
+
return text.length <= head + tail ? text : `${text.slice(0, head)}… [${text.length - head - tail} middle chars] …${text.slice(-tail)}`;
|
|
26
|
+
}
|
|
27
|
+
function tailOf(text, limit) {
|
|
28
|
+
return text.length <= limit ? text : `[${text.length - limit} earlier chars] …${text.slice(-limit)}`;
|
|
29
|
+
}
|
|
30
|
+
function exitCode(text) {
|
|
31
|
+
const matches = [...text.matchAll(/(?:exited with code|exit code:?|exit status|Error)\s+(\d+)\s*$/gim)];
|
|
32
|
+
return matches.length ? Number(matches.at(-1)[1]) : undefined;
|
|
33
|
+
}
|
|
34
|
+
function result(format, fields, exit) {
|
|
35
|
+
const failing = uniq(fields.failing ?? []);
|
|
36
|
+
const errors = uniq(fields.errors ?? []);
|
|
37
|
+
return {
|
|
38
|
+
format,
|
|
39
|
+
failing: failing.slice(0, MAX_ITEMS),
|
|
40
|
+
...(failing.length > MAX_ITEMS ? { failing_more: failing.length - MAX_ITEMS } : {}),
|
|
41
|
+
errors: errors.slice(0, MAX_ITEMS),
|
|
42
|
+
...(fields.location ? { location: clip(fields.location, 160) } : {}),
|
|
43
|
+
...(fields.summary ? { summary: clip(fields.summary, 160) } : {}),
|
|
44
|
+
...(exit !== undefined ? { exit } : {}),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/** Lines after index `from`, up to (not including) the first line that matches `stop`. */
|
|
48
|
+
function blockAfter(lines, from, stop) {
|
|
49
|
+
const out = [];
|
|
50
|
+
for (let i = from + 1; i < lines.length && !stop.test(lines[i]); i++)
|
|
51
|
+
out.push(lines[i]);
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
const PARSERS = [
|
|
55
|
+
["tsc", text => {
|
|
56
|
+
const matches = [...text.matchAll(/^(\S+?)\((\d+),(\d+)\): error (TS\d+): (.+)$/gm)];
|
|
57
|
+
if (!matches.length)
|
|
58
|
+
return undefined;
|
|
59
|
+
return { errors: matches.map(m => `${m[4]} ${m[1]}: ${m[5]}`), location: `${matches[0][1]}:${matches[0][2]}:${matches[0][3]}`, summary: `${matches.length} type error${matches.length === 1 ? "" : "s"}` };
|
|
60
|
+
}],
|
|
61
|
+
["cargo", text => {
|
|
62
|
+
const tests = [...text.matchAll(/^test (\S+) \.\.\. FAILED$/gm)].map(m => m[1]);
|
|
63
|
+
const compile = [...text.matchAll(/^error(\[E\d+\])?: (.+)\n\s*--> (\S+)/gm)];
|
|
64
|
+
if (!tests.length && !compile.length)
|
|
65
|
+
return undefined;
|
|
66
|
+
const panics = [...text.matchAll(/panicked at (\S+?):\n((?:.+\n?){1,3})/g)].map(m => ({ at: m[1], msg: m[2].split("\n").filter(line => line.trim() && !/^note:/.test(line)).join(" ") }));
|
|
67
|
+
const summary = /^test result: FAILED\. (.+?); finished/m.exec(text)?.[1];
|
|
68
|
+
return {
|
|
69
|
+
failing: tests,
|
|
70
|
+
errors: [...compile.map(m => `${m[1] ?? ""}${m[2]}`), ...panics.map(p => p.msg)],
|
|
71
|
+
location: compile[0]?.[3] ?? panics[0]?.at,
|
|
72
|
+
summary: summary ?? (compile.length ? "build failed" : undefined),
|
|
73
|
+
};
|
|
74
|
+
}],
|
|
75
|
+
["go", text => {
|
|
76
|
+
const fails = [...text.matchAll(/^\s*--- FAIL: (\S+)/gm)].map(m => m[1]);
|
|
77
|
+
const build = [...text.matchAll(/^(\S+\.go):(\d+):(\d+): (.+)$/gm)];
|
|
78
|
+
if (!fails.length && !build.length)
|
|
79
|
+
return undefined;
|
|
80
|
+
const details = [...text.matchAll(/^\s+(\S+_test\.go:\d+): (.+)$/gm)];
|
|
81
|
+
const packages = [...text.matchAll(/^FAIL\t(\S+)(?:\t| \[)/gm)].map(m => m[1]);
|
|
82
|
+
return {
|
|
83
|
+
failing: fails,
|
|
84
|
+
errors: details.length ? details.map(m => m[2]) : build.map(m => `${m[1]}: ${m[4]}`),
|
|
85
|
+
location: details[0]?.[1] ?? (build.length ? `${build[0][1]}:${build[0][2]}` : undefined),
|
|
86
|
+
summary: packages.length ? `FAIL ${[...new Set(packages)].join(", ")}${/\[build failed\]/.test(text) ? " [build failed]" : ""}` : undefined,
|
|
87
|
+
};
|
|
88
|
+
}],
|
|
89
|
+
["pytest", text => {
|
|
90
|
+
const failed = [...text.matchAll(/^(?:FAILED|ERROR) (\S+)(?: - (.+))?$/gm)];
|
|
91
|
+
const summary = /(\d+ failed(?:, \d+ passed)?(?:, \d+ errors?)?) in [\d.]+s/.exec(text)?.[1];
|
|
92
|
+
if (!failed.length && !summary)
|
|
93
|
+
return undefined;
|
|
94
|
+
// The short summary carries one message per failure; the `E` lines are the fallback when it is off (-rN).
|
|
95
|
+
const messages = failed.map(m => m[2]).filter((message) => Boolean(message));
|
|
96
|
+
const eLines = [...text.matchAll(/^E[ \t]+(\S.*)$/gm)].map(m => m[1]);
|
|
97
|
+
const loc = /^(\S+\.py):(\d+): \w+/m.exec(text);
|
|
98
|
+
return { failing: failed.map(m => m[1]), errors: messages.length ? messages : eLines.slice(0, 3), location: loc ? `${loc[1]}:${loc[2]}` : undefined, summary };
|
|
99
|
+
}],
|
|
100
|
+
["eslint", text => {
|
|
101
|
+
const lines = text.split("\n");
|
|
102
|
+
const errors = [];
|
|
103
|
+
let file;
|
|
104
|
+
for (const line of lines) {
|
|
105
|
+
if (/^\/?\S+\.\w+$/.test(line.trim()) && !/^\s/.test(line))
|
|
106
|
+
file = line.trim();
|
|
107
|
+
const m = /^\s+(\d+):(\d+)\s+(error|warning)\s+(.+?)\s{2,}(\S+)$/.exec(line);
|
|
108
|
+
if (m && m[3] === "error")
|
|
109
|
+
errors.push(`${file ? `${file.split("/").slice(-2).join("/")}:${m[1]}` : m[1]} ${m[5]}: ${m[4]}`);
|
|
110
|
+
}
|
|
111
|
+
if (!errors.length)
|
|
112
|
+
return undefined;
|
|
113
|
+
return { errors, summary: /✖ (\d+ problems? \(.+?\))/.exec(text)?.[1] };
|
|
114
|
+
}],
|
|
115
|
+
["tap", text => {
|
|
116
|
+
const lines = text.split("\n");
|
|
117
|
+
const failing = [];
|
|
118
|
+
const errors = [];
|
|
119
|
+
let location;
|
|
120
|
+
lines.forEach((line, i) => {
|
|
121
|
+
const m = /^\s*not ok \d+ - (.+?)(?:\s+#.*)?$/.exec(line);
|
|
122
|
+
if (!m)
|
|
123
|
+
return;
|
|
124
|
+
failing.push(m[1]);
|
|
125
|
+
const block = blockAfter(lines, i, /^\s*(?:\.\.\.|ok \d+|not ok \d+|# Subtest)/);
|
|
126
|
+
const errorAt = block.findIndex(candidate => /^\s+error:/.test(candidate));
|
|
127
|
+
if (errorAt >= 0) {
|
|
128
|
+
const inline = /^\s+error:\s*(?:\|-?)?\s*(.*)$/.exec(block[errorAt])[1];
|
|
129
|
+
const message = inline && !/^\|/.test(inline) ? inline.replace(/^['"]|['"]$/g, "") : block.slice(errorAt + 1).map(candidate => candidate.trim()).filter(candidate => candidate && !/^[+-] (?:actual|expected)|^\^$/.test(candidate)).slice(0, 3).join(" ");
|
|
130
|
+
errors.push(message);
|
|
131
|
+
}
|
|
132
|
+
location ??= /^\s+location:\s*'?(.+?)'?$/m.exec(block.join("\n"))?.[1];
|
|
133
|
+
});
|
|
134
|
+
const spec = [...text.matchAll(/^\s*✖ (.+?)(?: \([\d.]+m?s\))?$/gm)].map(m => m[1]).filter(name => !/^failing tests:?$|^\d+ problems? \(/i.test(name));
|
|
135
|
+
if (!failing.length && !spec.length)
|
|
136
|
+
return undefined;
|
|
137
|
+
const fail = /^# fail (\d+)$/m.exec(text)?.[1];
|
|
138
|
+
const tests = /^# tests (\d+)$/m.exec(text)?.[1];
|
|
139
|
+
return { failing: failing.length ? failing : spec, errors, location, summary: fail ? `${fail} of ${tests} tests failed` : undefined };
|
|
140
|
+
}],
|
|
141
|
+
["playwright", text => {
|
|
142
|
+
const heads = [...text.matchAll(/^\s+\d+\) \[([^\]]+)\] › (.+?) ─+$/gm)];
|
|
143
|
+
if (!heads.length)
|
|
144
|
+
return undefined;
|
|
145
|
+
const lines = text.split("\n");
|
|
146
|
+
const errors = [];
|
|
147
|
+
lines.forEach((line, i) => {
|
|
148
|
+
if (!/^\s+\d+\) \[[^\]]+\] › /.test(line))
|
|
149
|
+
return;
|
|
150
|
+
const block = blockAfter(lines, i, /^\s+\d+\) \[|^\s+\d+ failed$/);
|
|
151
|
+
const error = block.find(candidate => /^\s+Error: /.test(candidate));
|
|
152
|
+
const detail = block.filter(candidate => /^\s+(?:Expected|Received)(?: string)?:/.test(candidate)).map(candidate => candidate.trim());
|
|
153
|
+
if (error)
|
|
154
|
+
errors.push([error.trim(), ...detail].join(" "));
|
|
155
|
+
});
|
|
156
|
+
const loc = /^\s+at (\S+:\d+:\d+)$/m.exec(text)?.[1];
|
|
157
|
+
const failed = /^\s+(\d+) failed$/m.exec(text)?.[1];
|
|
158
|
+
const passed = /^\s+(\d+) passed/m.exec(text)?.[1];
|
|
159
|
+
return { failing: heads.map(m => `[${m[1]}] ${m[2]}`), errors, location: loc, summary: failed ? `${failed} failed${passed ? `, ${passed} passed` : ""}` : undefined };
|
|
160
|
+
}],
|
|
161
|
+
["jest", text => {
|
|
162
|
+
const bullets = [...text.matchAll(/^\s*● (.+)$/gm)].map(m => m[1].trim()).filter(name => name !== "Console");
|
|
163
|
+
if (!bullets.length)
|
|
164
|
+
return undefined;
|
|
165
|
+
const lines = text.split("\n");
|
|
166
|
+
const errors = [];
|
|
167
|
+
lines.forEach((line, i) => {
|
|
168
|
+
if (!/^\s*● /.test(line) || /● Console/.test(line))
|
|
169
|
+
return;
|
|
170
|
+
const block = blockAfter(lines, i, /^\s*● |^(?:PASS|FAIL) |^Test Suites:/).map(candidate => candidate.trim()).filter(Boolean);
|
|
171
|
+
const first = block.find(candidate => !/^\d+ \||^>|^\|/.test(candidate));
|
|
172
|
+
const detail = block.filter(candidate => /^(?:Expected|Received):|^[-+] {2,}"/.test(candidate)).slice(0, 4);
|
|
173
|
+
if (first)
|
|
174
|
+
errors.push([first, ...detail].join(" "));
|
|
175
|
+
});
|
|
176
|
+
const suiteFail = [...text.matchAll(/^FAIL (\S+)/gm)].map(m => m[1]);
|
|
177
|
+
const failing = bullets.map(bullet => (bullet === "Test suite failed to run" ? `${suiteFail[0] ?? "suite"} (suite failed to run)` : bullet));
|
|
178
|
+
const loc = /at .*?\(?((?:[\w.\-~]+\/)*[\w.\-]+\.[cm]?[jt]sx?:\d+:\d+)\)?/.exec(text)?.[1];
|
|
179
|
+
const summary = /^Tests:\s+(.+)$/m.exec(text)?.[1];
|
|
180
|
+
return { failing, errors, location: loc, summary };
|
|
181
|
+
}],
|
|
182
|
+
["vitest", text => {
|
|
183
|
+
const fails = [...text.matchAll(/^ FAIL {1,2}(\S+ > .+)$/gm)].map(m => m[1]);
|
|
184
|
+
const crosses = [...text.matchAll(/^\s+× (.+?)(?: \d+m?s)?$/gm)].map(m => m[1]);
|
|
185
|
+
if (!fails.length && !crosses.length)
|
|
186
|
+
return undefined;
|
|
187
|
+
const errors = [...text.matchAll(/^(\w*Error: .+)$/gm)].map(m => m[1]);
|
|
188
|
+
const loc = /❯ (\S+:\d+:\d+)/.exec(text)?.[1];
|
|
189
|
+
const summary = /^\s+Tests\s+(.+)$/m.exec(text)?.[1];
|
|
190
|
+
return { failing: fails.length ? fails : crosses, errors, location: loc, summary };
|
|
191
|
+
}],
|
|
192
|
+
["test-script", text => {
|
|
193
|
+
const fails = [...text.matchAll(/^FAIL (\S+?): (.+)$/gm)];
|
|
194
|
+
if (!fails.length)
|
|
195
|
+
return undefined;
|
|
196
|
+
return { failing: fails.map(m => m[1]), errors: fails.map(m => m[2]), summary: /^(\d+ passed, \d+ failed)$/m.exec(text)?.[1] };
|
|
197
|
+
}],
|
|
198
|
+
["python-traceback", text => {
|
|
199
|
+
if (!/^Traceback \(most recent call last\):/m.test(text))
|
|
200
|
+
return undefined;
|
|
201
|
+
const last = [...text.matchAll(/^([\w.]*(?:Error|Exception|Exit|Interrupt)\w*): (.*)$/gm)].at(-1);
|
|
202
|
+
const frames = [...text.matchAll(/^\s+File "(.+?)", line (\d+)/gm)];
|
|
203
|
+
return { errors: last ? [`${last[1]}: ${last[2]}`] : [], location: frames.length ? `${frames.at(-1)[1]}:${frames.at(-1)[2]}` : undefined };
|
|
204
|
+
}],
|
|
205
|
+
["lisp-condition", text => {
|
|
206
|
+
const m = /^Unhandled (\S+)(?: in thread[^\n]*)?\n(?:\s+\{[^}]*\}>:\n)?\s*(.+)$/m.exec(text);
|
|
207
|
+
if (!m)
|
|
208
|
+
return undefined;
|
|
209
|
+
return { errors: [`${m[1]}: ${m[2]}`] };
|
|
210
|
+
}],
|
|
211
|
+
["node-error", text => {
|
|
212
|
+
const m = /^(\w*Error)(?: \[\w+\])?: (.+)$/m.exec(text);
|
|
213
|
+
if (!m || !/^\s+at /m.test(text))
|
|
214
|
+
return undefined;
|
|
215
|
+
const loc = /^\s+at (?:.*?\()?((?:file:\/\/)?\S+?:\d+:\d+)\)?$/m.exec(text.split("\n").filter(line => !/node:/.test(line)).join("\n"))?.[1];
|
|
216
|
+
return { errors: [`${m[1]}: ${m[2]}`], location: loc };
|
|
217
|
+
}],
|
|
218
|
+
];
|
|
219
|
+
/** Parses a failed or check output into failing tests, error lines, a location, a summary and an exit code. */
|
|
220
|
+
export function parseFailure(text) {
|
|
221
|
+
const t = text.replace(/\r/g, "");
|
|
222
|
+
const exit = exitCode(t);
|
|
223
|
+
for (const [format, parse] of PARSERS) {
|
|
224
|
+
const found = parse(t);
|
|
225
|
+
if (found)
|
|
226
|
+
return result(format, found, exit);
|
|
227
|
+
}
|
|
228
|
+
const line = t.split("\n").map(candidate => candidate.trim()).find(candidate => /\b(?:\w*Error|ERR!|error|FAIL(?:ED)?|Exception|panic|fatal|cannot|not found|No such file|denied)\b/.test(candidate) && !/^Command exited with code/.test(candidate));
|
|
229
|
+
if (line)
|
|
230
|
+
return result("generic", { errors: [line] }, exit);
|
|
231
|
+
return result("unparsed", {}, exit);
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Normalises what changes between identical runs: durations, clock times and dates, addresses, long numbers, thread
|
|
235
|
+
* ids, temp paths, and line:column positions. Other digits stay, so "expected 3" and "expected 4" still differ.
|
|
236
|
+
*/
|
|
237
|
+
export function normaliseFailureText(value) {
|
|
238
|
+
return (value ?? "")
|
|
239
|
+
.replace(/(?:\/private)?\/(?:tmp|var\/folders)\/[^\s'"):]+/g, "<tmp>")
|
|
240
|
+
.replace(/\bpytest-\d+\b/g, "pytest-#")
|
|
241
|
+
.replace(/\.tmp[0-9a-zA-Z]+/g, ".tmp#")
|
|
242
|
+
.replace(/\b\d{4}[-/]\d{2}[-/]\d{2}[T ]\d{2}:\d{2}(?::\d{2}(?:[.,]\d+)?)?Z?/g, "<time>")
|
|
243
|
+
.replace(/\b\d{2}:\d{2}:\d{2}\b/g, "<time>")
|
|
244
|
+
.replace(/\brun=[0-9a-f]{6,}\b/g, "run=#")
|
|
245
|
+
.replace(/\b\d+(?:\.\d+)?\s*(?:ms|s|m|h|µs|us|ns)\b/g, "#t")
|
|
246
|
+
.replace(/0x[0-9a-fA-F]+/g, "0x#")
|
|
247
|
+
.replace(/\(\d+\)/g, "(#)")
|
|
248
|
+
.replace(/\d{5,}/g, "#")
|
|
249
|
+
.replace(/:\d+(?::\d+)?\b/g, ":#")
|
|
250
|
+
.replace(/\(\d+,\d+\)/g, "(#)")
|
|
251
|
+
.replace(/\s+/g, " ")
|
|
252
|
+
.trim();
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Order-free signature of a failure: which tests failed and with which errors, both normalised. Pass counts are left
|
|
256
|
+
* out, so a run narrowed by a flag (`-x`, a test filter) still matches the full run that failed the same way.
|
|
257
|
+
*/
|
|
258
|
+
export function failureSignature(parsed, raw = "") {
|
|
259
|
+
if (parsed.format === "unparsed")
|
|
260
|
+
return JSON.stringify(["unparsed", normaliseFailureText(raw.slice(-400))]);
|
|
261
|
+
return JSON.stringify([[...parsed.failing].map(normaliseFailureText).sort(), [...parsed.errors].map(normaliseFailureText).sort()]);
|
|
262
|
+
}
|
|
263
|
+
/** The edit pairs of an `edit` input, as `[oldText, newText]`: the `edits` array, or one top-level pair. */
|
|
264
|
+
export function editPairs(input) {
|
|
265
|
+
if (Array.isArray(input.edits))
|
|
266
|
+
return input.edits.map(edit => {
|
|
267
|
+
const pair = (edit ?? {});
|
|
268
|
+
return [String(pair.oldText ?? ""), String(pair.newText ?? "")];
|
|
269
|
+
});
|
|
270
|
+
if (input.oldText !== undefined || input.newText !== undefined)
|
|
271
|
+
return [[String(input.oldText ?? ""), String(input.newText ?? "")]];
|
|
272
|
+
return [];
|
|
273
|
+
}
|
|
274
|
+
/** Removed and added lines of one edit, as a multiset difference, so unchanged context lines drop out. */
|
|
275
|
+
export function lineDelta(oldText, newText) {
|
|
276
|
+
const count = new Map();
|
|
277
|
+
for (const line of oldText.split("\n"))
|
|
278
|
+
count.set(line, (count.get(line) ?? 0) + 1);
|
|
279
|
+
const added = [];
|
|
280
|
+
for (const line of newText.split("\n")) {
|
|
281
|
+
const n = count.get(line) ?? 0;
|
|
282
|
+
if (n > 0)
|
|
283
|
+
count.set(line, n - 1);
|
|
284
|
+
else
|
|
285
|
+
added.push(line);
|
|
286
|
+
}
|
|
287
|
+
const removed = [];
|
|
288
|
+
for (const [line, n] of count)
|
|
289
|
+
for (let i = 0; i < n; i++)
|
|
290
|
+
removed.push(line);
|
|
291
|
+
return { removed, added };
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* The change an `edit` or `write` made. Every string is as the agent wrote it: the caller redacts what leaves the
|
|
295
|
+
* machine. The diff is the whole added and removed text, not a line-by-line patch, because the judge compares one
|
|
296
|
+
* change against the next.
|
|
297
|
+
*/
|
|
298
|
+
export function editDiff(tool, input) {
|
|
299
|
+
const path = String(input.path ?? "?");
|
|
300
|
+
if (tool === "write") {
|
|
301
|
+
const lines = String(input.content ?? "").split("\n");
|
|
302
|
+
const text = lines.map(line => `+${line}`).join("\n");
|
|
303
|
+
return { path, added: lines.length, removed: 0, removedLines: [], addedLines: lines, diff: text.length > DIFF_CAP ? `${text.slice(0, DIFF_CAP)}… [diff capped]` : text };
|
|
304
|
+
}
|
|
305
|
+
const removedLines = [];
|
|
306
|
+
const addedLines = [];
|
|
307
|
+
for (const [oldText, newText] of editPairs(input)) {
|
|
308
|
+
const delta = lineDelta(oldText, newText);
|
|
309
|
+
removedLines.push(...delta.removed);
|
|
310
|
+
addedLines.push(...delta.added);
|
|
311
|
+
}
|
|
312
|
+
const text = [...removedLines.map(line => `-${line}`), ...addedLines.map(line => `+${line}`)].join("\n");
|
|
313
|
+
return { path, added: addedLines.length, removed: removedLines.length, removedLines, addedLines, diff: text.length > DIFF_CAP ? `${text.slice(0, DIFF_CAP)}… [diff capped]` : text };
|
|
314
|
+
}
|
|
315
|
+
/** The parsed view of one failed run, keyed as the judge reads it. Every string is redacted. */
|
|
316
|
+
function failureView(output, parsed) {
|
|
317
|
+
if (parsed.format === "unparsed")
|
|
318
|
+
return { output_head_tail: redact(headTail(output.trim(), FALLBACK_HEAD, FALLBACK_TAIL)) };
|
|
319
|
+
const view = { parser: parsed.format };
|
|
320
|
+
if (parsed.failing.length)
|
|
321
|
+
view.failing_tests = parsed.failing.map(redact);
|
|
322
|
+
if (parsed.failing_more)
|
|
323
|
+
view.failing_tests_more = parsed.failing_more;
|
|
324
|
+
if (parsed.errors.length)
|
|
325
|
+
view.errors = parsed.errors.map(redact);
|
|
326
|
+
if (parsed.location)
|
|
327
|
+
view.location = redact(parsed.location);
|
|
328
|
+
if (parsed.summary)
|
|
329
|
+
view.summary = redact(parsed.summary);
|
|
330
|
+
if (parsed.format === "generic")
|
|
331
|
+
view.output_tail = redact(tailOf(output.trim(), FALLBACK_TAIL));
|
|
332
|
+
return view;
|
|
333
|
+
}
|
|
334
|
+
function byteLength(value) {
|
|
335
|
+
return Buffer.byteLength(JSON.stringify(value));
|
|
336
|
+
}
|
|
337
|
+
/** The digest over the runs and edits that are still in the evidence. */
|
|
338
|
+
function digestOf(runs, edits, informationCalls) {
|
|
339
|
+
const failures = runs.filter(run => run.signature !== undefined).map(run => ({ n: run.view.n, signature: run.signature }));
|
|
340
|
+
const first = failures[0]?.n ?? 0;
|
|
341
|
+
const last = failures.at(-1)?.n ?? 0;
|
|
342
|
+
const latest = failures.at(-1)?.signature;
|
|
343
|
+
return {
|
|
344
|
+
failed_runs: failures.length,
|
|
345
|
+
distinct_failures: new Set(failures.map(failure => failure.signature)).size,
|
|
346
|
+
same_command_runs: runs.filter(run => run.repeatCommand).map(run => run.view.n),
|
|
347
|
+
latest_failure_seen_before: failures.length > 1 && failures.slice(0, -1).some(failure => failure.signature === latest),
|
|
348
|
+
edits_between_failed_runs: edits.filter(edit => edit.n > first && edit.n < last).length,
|
|
349
|
+
information_calls_between_failed_runs: failures.length ? informationCalls.filter(n => n > first).length : 0,
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* Builds the evidence for one stuck window: a run per failed call and per passing check command, an edit per
|
|
354
|
+
* `edit`/`write`, and a digest. The oldest runs are dropped first when the whole object is over `EVIDENCE_LIMIT`
|
|
355
|
+
* bytes, then the oldest edits; the digest is then recomputed over what is left, and a run that pointed at a dropped
|
|
356
|
+
* run points at nothing. The floor is one run plus the digest, which is far under the cap.
|
|
357
|
+
*/
|
|
358
|
+
export function buildStuckEvidence(attempts, limit = EVIDENCE_LIMIT) {
|
|
359
|
+
let runs = [];
|
|
360
|
+
let edits = [];
|
|
361
|
+
const informationCalls = [];
|
|
362
|
+
const firstCommandRun = new Map();
|
|
363
|
+
attempts.forEach((attempt, index) => {
|
|
364
|
+
const n = index + 1;
|
|
365
|
+
if (EDIT_TOOLS.has(attempt.tool)) {
|
|
366
|
+
const change = attempt.change;
|
|
367
|
+
if (change)
|
|
368
|
+
edits.push({ n, path: change.path, added_lines: change.added, removed_lines: change.removed, diff: change.diff });
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
if (!attempt.failed && !RUNNER.test(attempt.call)) {
|
|
372
|
+
if (runs.some(run => run.signature !== undefined))
|
|
373
|
+
informationCalls.push(n);
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
const command = normaliseFailureText(attempt.call);
|
|
377
|
+
const seen = firstCommandRun.get(command);
|
|
378
|
+
const run = { view: { n, outcome: attempt.failed ? "failed" : "ok" }, repeatCommand: seen !== undefined };
|
|
379
|
+
if (seen === undefined)
|
|
380
|
+
firstCommandRun.set(command, n);
|
|
381
|
+
if (attempt.failed) {
|
|
382
|
+
const parsed = parseFailure(attempt.text);
|
|
383
|
+
const signature = failureSignature(parsed, attempt.text);
|
|
384
|
+
const earlier = runs.find(candidate => candidate.signature === signature);
|
|
385
|
+
Object.assign(run.view, failureView(attempt.text, parsed), { same_failure_as_run: earlier?.view.n ?? null });
|
|
386
|
+
if (parsed.exit !== undefined)
|
|
387
|
+
run.view.exit_code = parsed.exit;
|
|
388
|
+
run.signature = signature;
|
|
389
|
+
}
|
|
390
|
+
runs.push(run);
|
|
391
|
+
});
|
|
392
|
+
for (;;) {
|
|
393
|
+
const present = new Set(runs.map(run => run.view.n));
|
|
394
|
+
const kept = {
|
|
395
|
+
runs: runs.map(run => {
|
|
396
|
+
const reference = run.view.same_failure_as_run;
|
|
397
|
+
return reference !== undefined && reference !== null && !present.has(reference) ? { ...run.view, same_failure_as_run: null } : run.view;
|
|
398
|
+
}),
|
|
399
|
+
edits,
|
|
400
|
+
digest: digestOf(runs, edits, informationCalls),
|
|
401
|
+
};
|
|
402
|
+
if (byteLength(kept) <= limit || (runs.length <= 1 && edits.length <= 1))
|
|
403
|
+
return kept;
|
|
404
|
+
if (runs.length > 1)
|
|
405
|
+
runs = runs.slice(1);
|
|
406
|
+
else
|
|
407
|
+
edits = edits.slice(1);
|
|
408
|
+
}
|
|
409
|
+
}
|