-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathedit-file-diagnostics-plugin.ts
More file actions
343 lines (299 loc) · 11 KB
/
Copy pathedit-file-diagnostics-plugin.ts
File metadata and controls
343 lines (299 loc) · 11 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
import { readFile } from "node:fs/promises";
import type { ToolPlugin } from "@intx/tools-posix";
import type { ToolResult } from "@intx/types/runtime";
// Stock tools-posix messages — match tightly so permission/binary/ENOENT stay untouched.
const NOT_FOUND_RE = /^old_string not found in /;
const NOT_UNIQUE_RE = /^old_string is not unique \(\d+ occurrences\) in /;
const MAX_DIAGNOSTIC_CHARS = 2048;
const MAX_OCCURRENCES_LISTED = 10;
const PREVIEW_CHARS = 120;
const CLOSEST_LINE_COUNT = 3;
// Keep multi-line occurrence previews short enough that 10 of them fit under the cap.
const OCCURRENCE_PREVIEW_LINES = 3;
// read_file decorates as padStart(6) + "\t" + line; pasting that into old_string is the
// dominant failure mode for this ticket.
const LINE_PREFIX_RE = /^\s*\d+\t/;
export function editFileDiagnosticsPlugin(): ToolPlugin {
return {
middleware: (next) => async (call, signal) => {
const result = await next(call, signal);
if (call.name !== "edit_file" || result.isError !== true) {
return result;
}
const content = String(result.content);
const notFound = NOT_FOUND_RE.test(content);
const uniqueMatch = content.match(NOT_UNIQUE_RE);
if (!notFound && uniqueMatch === null) {
return result;
}
const path = call.arguments.path;
const oldString = call.arguments.old_string;
if (typeof path !== "string" || path.length === 0) {
return result;
}
if (typeof oldString !== "string") {
return result;
}
let fileText: string;
try {
// path-escape already resolved this to an absolute, sandbox-checked path.
const buf = await readFile(path, { signal });
if (buf.includes(0)) {
return result;
}
fileText = buf.toString("utf8");
} catch {
return result;
}
const diagnostic = notFound
? diagnoseNotFound(fileText, oldString)
: diagnoseNotUnique(fileText, oldString);
if (diagnostic.length === 0) {
return result;
}
return {
...result,
content: `${content}\n\n${truncateDiagnostic(diagnostic)}`,
} satisfies ToolResult;
},
};
}
function diagnoseNotFound(fileText: string, oldString: string): string {
const parts: string[] = [];
const stripped = stripLineNumberPrefixes(oldString);
if (stripped !== null) {
parts.push(
"old_string looks like it includes read_file line-number prefixes (NNNNNN\\t). " +
"Strip those prefixes before matching. Candidate without prefixes:",
fence(stripped),
);
}
// Prefer a near-miss on the raw needle; if prefixes contaminated it, retry on stripped.
const nearMiss =
findWhitespaceNearMiss(fileText, oldString) ??
(stripped !== null ? findWhitespaceNearMiss(fileText, stripped) : null);
if (nearMiss !== null) {
parts.push(
"Whitespace near-miss (unique; use this exact text as old_string):",
fence(nearMiss.text),
`(lines ${formatLineRange(nearMiss.startLine, nearMiss.endLine)}; whitespace differs from your old_string)`,
);
return parts.join("\n");
}
if (stripped !== null && fileText.includes(stripped)) {
parts.push(
"After stripping line-number prefixes, old_string matches the file exactly. Retry with the stripped text above.",
);
return parts.join("\n");
}
const closest = closestLines(fileText, oldString);
if (closest.length > 0) {
parts.push("No unique whitespace near-miss. Closest lines by token overlap (heuristic):");
for (const line of closest) {
parts.push(`line ${line.lineNumber}: ${preview(line.text)}`);
}
}
return parts.join("\n");
}
function diagnoseNotUnique(fileText: string, oldString: string): string {
const occurrences = findOccurrences(fileText, oldString);
if (occurrences.length === 0) {
return "";
}
const lines: string[] = [
`Occurrences (showing ${Math.min(occurrences.length, MAX_OCCURRENCES_LISTED)} of ${occurrences.length}):`,
];
for (const occ of occurrences.slice(0, MAX_OCCURRENCES_LISTED)) {
lines.push(`line ${occ.lineNumber}: ${preview(occ.preview)}`);
}
if (occurrences.length > MAX_OCCURRENCES_LISTED) {
lines.push(`… and ${occurrences.length - MAX_OCCURRENCES_LISTED} more`);
}
lines.push("Widen old_string with surrounding context so it matches exactly once, or pass replace_all=true.");
return lines.join("\n");
}
/**
* Per-line whitespace normalize for matching only; reported text is always original.
* Full trim + internal collapse so indent drift (the dominant failure mode) still
* near-matches; CR is stripped so CRLF files compare cleanly against LF needles.
*/
export function normalizeLine(line: string): string {
const noCr = line.replace(/\r/g, "");
return noCr.trim().replace(/[ \t]+/g, " ");
}
export type NearMiss = {
text: string;
startLine: number;
endLine: number;
};
/**
* Find a unique multi-line span whose per-line whitespace normalization equals
* the normalized old_string. Returns original (un-normalized) text.
*
* Leading/trailing empty lines on the needle are ignored for matching (models
* often paste a trailing newline) but do not expand the reported span.
*/
export function findWhitespaceNearMiss(fileText: string, oldString: string): NearMiss | null {
const needleLines = oldString.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
// Drop only edge blank lines so "foo\n" still matches a mid-file "foo".
// Mid-needle blanks stay so intentional empty lines still constrain the match.
const coreNorm = trimEdgeEmptyLines(needleLines.map(normalizeLine));
if (coreNorm.length === 0 || coreNorm.every((l) => l.length === 0)) {
return null;
}
const fileLines = fileText.split("\n");
const fileNorm = fileLines.map(normalizeLine);
const hits: Array<{ start: number; end: number }> = [];
const window = coreNorm.length;
for (let i = 0; i <= fileNorm.length - window; i++) {
let match = true;
for (let j = 0; j < window; j++) {
if (fileNorm[i + j] !== coreNorm[j]) {
match = false;
break;
}
}
if (match) {
hits.push({ start: i, end: i + window - 1 });
if (hits.length > 1) {
return null;
}
}
}
if (hits.length !== 1) {
return null;
}
const hit = hits[0]!;
// Reconstruct original span with "\n" join — matches how edit_file treats content.
const text = fileLines.slice(hit.start, hit.end + 1).join("\n");
// Exact identity is not a near-miss (stock tool would have matched).
if (text === oldString) {
return null;
}
return {
text,
startLine: hit.start + 1,
endLine: hit.end + 1,
};
}
/** Drop leading/trailing empty strings from a line array (not mid-array blanks). */
export function trimEdgeEmptyLines(lines: string[]): string[] {
let start = 0;
let end = lines.length;
while (start < end && lines[start] === "") start++;
while (end > start && lines[end - 1] === "") end--;
return lines.slice(start, end);
}
export type Occurrence = {
lineNumber: number;
preview: string;
};
export function findOccurrences(fileText: string, oldString: string): Occurrence[] {
if (oldString.length === 0) return [];
const out: Occurrence[] = [];
let from = 0;
while (from <= fileText.length) {
const idx = fileText.indexOf(oldString, from);
if (idx === -1) break;
const lineNumber = lineNumberAt(fileText, idx);
// Preview the matched span (up to a few lines), not just the start line, so
// multi-line old_string occurrences can be disambiguated.
const matched = fileText.slice(idx, idx + oldString.length);
const previewLines = matched.split("\n").slice(0, OCCURRENCE_PREVIEW_LINES);
const previewText =
matched.split("\n").length > OCCURRENCE_PREVIEW_LINES
? `${previewLines.join("\\n")}…`
: previewLines.join("\\n");
out.push({ lineNumber, preview: previewText });
from = idx + Math.max(oldString.length, 1);
}
return out;
}
function lineNumberAt(text: string, index: number): number {
let n = 1;
for (let i = 0; i < index; i++) {
if (text.charCodeAt(i) === 10) n++;
}
return n;
}
export type ClosestLine = {
lineNumber: number;
text: string;
};
export function closestLines(fileText: string, oldString: string): ClosestLine[] {
const needleTokens = tokenize(oldString);
if (needleTokens.size === 0) return [];
const lines = fileText.split("\n");
const scored: Array<{ lineNumber: number; text: string; score: number }> = [];
for (let i = 0; i < lines.length; i++) {
const text = lines[i] ?? "";
const tokens = tokenize(text);
if (tokens.size === 0) continue;
let overlap = 0;
for (const t of needleTokens) {
if (tokens.has(t)) overlap++;
}
if (overlap === 0) continue;
scored.push({ lineNumber: i + 1, text, score: overlap });
}
scored.sort((a, b) => b.score - a.score || a.lineNumber - b.lineNumber);
return scored.slice(0, CLOSEST_LINE_COUNT).map(({ lineNumber, text }) => ({ lineNumber, text }));
}
function tokenize(text: string): Set<string> {
const parts = text.match(/[A-Za-z_][A-Za-z0-9_]*|[0-9]+/g);
return new Set(parts ?? []);
}
/**
* If every non-empty line of old_string looks like a read_file decoration, return
* the stripped body. Otherwise null.
*/
export function stripLineNumberPrefixes(oldString: string): string | null {
const lines = oldString.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
if (lines.length === 0) return null;
let nonEmpty = 0;
const stripped: string[] = [];
for (const line of lines) {
if (line.length === 0) {
stripped.push("");
continue;
}
nonEmpty++;
if (!LINE_PREFIX_RE.test(line)) {
return null;
}
stripped.push(line.replace(LINE_PREFIX_RE, ""));
}
if (nonEmpty === 0) return null;
return stripped.join("\n");
}
function preview(text: string): string {
const flat = text.replace(/\r?\n/g, "\\n");
if (flat.length <= PREVIEW_CHARS) return flat;
return `${flat.slice(0, PREVIEW_CHARS - 1)}…`;
}
function formatLineRange(start: number, end: number): string {
return start === end ? String(start) : `${start}-${end}`;
}
function fence(body: string): string {
return `<<<\n${body}\n>>>`;
}
/**
* Prefer intact fences over a mid-body slice. When a near-miss span itself exceeds
* the budget, drop the body and tell the caller to re-read by line range instead of
* offering a half-truncated old_string that will fail again.
*/
export function truncateDiagnostic(text: string): string {
if (text.length <= MAX_DIAGNOSTIC_CHARS) return text;
// Replace oversized fenced bodies with a pointer, keeping headers and line ranges.
const withoutBodies = text.replace(
/<<<\n[\s\S]*?\n>>>/g,
"<<<\n… [span too large to inline; re-read the cited lines and copy exact text]\n>>>",
);
if (withoutBodies.length <= MAX_DIAGNOSTIC_CHARS) {
return withoutBodies;
}
const cut = withoutBodies.slice(0, MAX_DIAGNOSTIC_CHARS);
const lastNl = cut.lastIndexOf("\n");
const base = lastNl > 0 ? cut.slice(0, lastNl) : cut;
return `${base}\n… [diagnostic truncated]`;
}