@jerryan/pi-hashline-edit 0.7.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/LICENSE +21 -0
- package/README.md +114 -0
- package/index.ts +15 -0
- package/package.json +53 -0
- package/prompts/edit-snippet.md +1 -0
- package/prompts/edit.md +23 -0
- package/prompts/read-guidelines.md +2 -0
- package/prompts/read-snippet.md +1 -0
- package/prompts/read.md +5 -0
- package/src/edit-diff.ts +428 -0
- package/src/edit-response.ts +226 -0
- package/src/edit.ts +642 -0
- package/src/file-kind.ts +167 -0
- package/src/fs-write.ts +76 -0
- package/src/hashline.ts +1058 -0
- package/src/path-utils.ts +13 -0
- package/src/read.ts +222 -0
- package/src/runtime.ts +3 -0
- package/src/snapshot.ts +29 -0
package/src/hashline.ts
ADDED
|
@@ -0,0 +1,1058 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hashline engine — hash-anchored line editing.
|
|
3
|
+
*
|
|
4
|
+
* Originally vendored & adapted from oh-my-pi (MIT, github.com/can1357/oh-my-pi).
|
|
5
|
+
* Hash algorithm replaced with inline FNV-1a; lineIndex always incorporated.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { throwIfAborted } from "./runtime";
|
|
9
|
+
|
|
10
|
+
// --- Types ---
|
|
11
|
+
|
|
12
|
+
export type Anchor = { line: number; hash: string; textHint?: string };
|
|
13
|
+
export type HashlineEdit =
|
|
14
|
+
| { op: "replace"; pos: Anchor; end?: Anchor; lines: string[] }
|
|
15
|
+
| { op: "append"; pos?: Anchor; lines: string[] }
|
|
16
|
+
| { op: "prepend"; pos?: Anchor; lines: string[] }
|
|
17
|
+
| { op: "replace_text"; oldText: string; newText: string };
|
|
18
|
+
|
|
19
|
+
interface HashMismatch {
|
|
20
|
+
line: number;
|
|
21
|
+
expected: string;
|
|
22
|
+
actual: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface NoopEdit {
|
|
26
|
+
editIndex: number;
|
|
27
|
+
loc: string;
|
|
28
|
+
currentContent: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// --- Hash computation ---
|
|
32
|
+
|
|
33
|
+
const HEX = "0123456789ABCDEF";
|
|
34
|
+
const HASH_ALPHABET_RE = /^[0-9A-F]+$/;
|
|
35
|
+
|
|
36
|
+
const DICT = Array.from({ length: 256 }, (_, i) => {
|
|
37
|
+
const h = i >>> 4;
|
|
38
|
+
const l = i & 0x0f;
|
|
39
|
+
return `${HEX[h]}${HEX[l]}`;
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// FNV-1a 32-bit constants
|
|
43
|
+
const FNV_OFFSET = 0x811c9dc5;
|
|
44
|
+
const FNV_PRIME = 0x01000193;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Patterns used to detect (and reject) hashline display prefixes inside edit
|
|
48
|
+
* payloads. The runtime no longer strips them — the model must send literal
|
|
49
|
+
* file content. Matching any of these triggers `[E_INVALID_PATCH]`.
|
|
50
|
+
*/
|
|
51
|
+
const HASHLINE_PREFIX_RE =
|
|
52
|
+
/^\s*(?:>>>|>>)?\s*(?:\d+\s*#\s*|#\s*)[0-9A-F]{2}:/;
|
|
53
|
+
const HASHLINE_PREFIX_PLUS_RE =
|
|
54
|
+
/^\+\s*(?:\d+\s*#\s*|#\s*)[0-9A-F]{2}:/;
|
|
55
|
+
const DIFF_MINUS_RE = /^-\s*\d+\s{4}/;
|
|
56
|
+
|
|
57
|
+
export function computeLineHash(idx: number, line: string): string {
|
|
58
|
+
line = line.replace(/\r/g, "").trimEnd();
|
|
59
|
+
// FNV-1a with lineIndex incorporated unconditionally
|
|
60
|
+
let hash = (FNV_OFFSET ^ idx) >>> 0;
|
|
61
|
+
for (let i = 0; i < line.length; i++) {
|
|
62
|
+
hash = Math.imul(hash ^ line.charCodeAt(i), FNV_PRIME);
|
|
63
|
+
}
|
|
64
|
+
return DICT[hash & 0xff];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Shared fuzzy-match Unicode replacement regexes (also used by edit-diff.ts). */
|
|
68
|
+
export const FUZZY_SINGLE_QUOTES_RE = /[\u2018\u2019\u201A\u201B]/g;
|
|
69
|
+
export const FUZZY_DOUBLE_QUOTES_RE = /[\u201C\u201D\u201E\u201F]/g;
|
|
70
|
+
export const FUZZY_HYPHENS_RE = /[\u2010\u2011\u2012\u2013\u2014\u2015\u2212]/g;
|
|
71
|
+
export const FUZZY_UNICODE_SPACES_RE = /[\u00A0\u2002-\u200A\u202F\u205F\u3000]/g;
|
|
72
|
+
|
|
73
|
+
function normalizeFuzzyLine(text: string): string {
|
|
74
|
+
return text
|
|
75
|
+
.trimEnd()
|
|
76
|
+
.replace(FUZZY_SINGLE_QUOTES_RE, "'")
|
|
77
|
+
.replace(FUZZY_DOUBLE_QUOTES_RE, '"')
|
|
78
|
+
.replace(FUZZY_HYPHENS_RE, "-")
|
|
79
|
+
.replace(FUZZY_UNICODE_SPACES_RE, " ");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function isFuzzyEquivalentLine(expected: string, actual: string): boolean {
|
|
83
|
+
return normalizeFuzzyLine(expected) === normalizeFuzzyLine(actual);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ─── Parsing ────────────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
function diagnoseLineRef(ref: string): string {
|
|
89
|
+
const trimmed = ref.trim();
|
|
90
|
+
const core = ref.replace(/^\s*[>+-]*\s*/, "").trim();
|
|
91
|
+
|
|
92
|
+
if (!core.length) {
|
|
93
|
+
return `[E_BAD_REF] Invalid line reference "${ref}". Expected "LINE#HASH" (e.g. "5#MQ").`;
|
|
94
|
+
}
|
|
95
|
+
if (/^\d+\s*$/.test(core)) {
|
|
96
|
+
return `[E_BAD_REF] Invalid line reference "${ref}": missing hash, use "LINE#HASH" from read output (e.g. "5#MQ").`;
|
|
97
|
+
}
|
|
98
|
+
if (/^\d+\s*:/.test(core)) {
|
|
99
|
+
return `[E_BAD_REF] Invalid line reference "${ref}": wrong separator, use "LINE#HASH" instead of "LINE:...".`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const hashMatch = core.match(/^(\d+)\s*#\s*([^\s:]+)(?:\s*:.*)?$/);
|
|
103
|
+
if (hashMatch) {
|
|
104
|
+
const line = Number.parseInt(hashMatch[1]!, 10);
|
|
105
|
+
const hash = hashMatch[2]!;
|
|
106
|
+
if (line < 1) {
|
|
107
|
+
return `[E_BAD_REF] Line number must be >= 1, got ${line} in "${ref}".`;
|
|
108
|
+
}
|
|
109
|
+
if (hash.length !== 2) {
|
|
110
|
+
return `[E_BAD_REF] Invalid line reference "${ref}": hash must be exactly 2 characters from 0-9 A-F.`;
|
|
111
|
+
}
|
|
112
|
+
if (!HASH_ALPHABET_RE.test(hash)) {
|
|
113
|
+
return `[E_BAD_REF] Invalid line reference "${ref}": hash uses invalid characters, hashes use alphabet 0-9 A-F only.`;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const missingHashMatch = core.match(/^(\d+)\s*#\s*$/);
|
|
118
|
+
if (missingHashMatch) {
|
|
119
|
+
return `[E_BAD_REF] Invalid line reference "${ref}": missing hash after "#", use "LINE#HASH" from read output.`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (/^0+\s*#/.test(core)) {
|
|
123
|
+
return `[E_BAD_REF] Line number must be >= 1, got 0 in "${ref}".`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return `[E_BAD_REF] Invalid line reference "${trimmed || ref}". Expected "LINE#HASH" (e.g. "5#MQ").`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function parseLineRef(ref: string): { line: number; hash: string } {
|
|
130
|
+
// Match LINE#HASH format, tolerating:
|
|
131
|
+
// - leading ">+" and whitespace (from mismatch/diff display)
|
|
132
|
+
// - optional trailing display suffix (":..." content)
|
|
133
|
+
const parsed = parseAnchorRef(ref);
|
|
134
|
+
return { line: parsed.line, hash: parsed.hash };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function parseAnchorRef(ref: string): Anchor {
|
|
138
|
+
const core = ref.replace(/^\s*[>+-]*\s*/, "").trimEnd();
|
|
139
|
+
const match = core.match(/^([0-9]+)\s*#\s*([^\s:]+)(?:\s*:(.*))?$/s);
|
|
140
|
+
if (!match) {
|
|
141
|
+
throw new Error(diagnoseLineRef(ref));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const line = Number.parseInt(match[1]!, 10);
|
|
145
|
+
if (line < 1) {
|
|
146
|
+
throw new Error(`[E_BAD_REF] Line number must be >= 1, got ${line} in "${ref}".`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const hash = match[2]!;
|
|
150
|
+
if (hash.length !== 2) {
|
|
151
|
+
throw new Error(`[E_BAD_REF] Invalid line reference "${ref}": hash must be exactly 2 characters from 0-9 A-F.`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (!HASH_ALPHABET_RE.test(hash)) {
|
|
155
|
+
throw new Error(
|
|
156
|
+
`[E_BAD_REF] Invalid line reference "${ref}": hash uses invalid characters, hashes use alphabet 0-9 A-F only.`,
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const textHint = match[3];
|
|
161
|
+
return {
|
|
162
|
+
line,
|
|
163
|
+
hash,
|
|
164
|
+
...(textHint !== undefined ? { textHint } : {}),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ─── Mismatch formatting ────────────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
function formatMismatchError(
|
|
171
|
+
mismatches: HashMismatch[],
|
|
172
|
+
fileLines: string[],
|
|
173
|
+
retryLines: ReadonlySet<number> = new Set<number>(),
|
|
174
|
+
): string {
|
|
175
|
+
const retryLineSet = new Set<number>(retryLines);
|
|
176
|
+
for (const m of mismatches) {
|
|
177
|
+
retryLineSet.add(m.line);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const displayLines = new Set<number>();
|
|
181
|
+
for (const m of mismatches) {
|
|
182
|
+
for (
|
|
183
|
+
let i = Math.max(1, m.line - 2);
|
|
184
|
+
i <= Math.min(fileLines.length, m.line + 2);
|
|
185
|
+
i++
|
|
186
|
+
) {
|
|
187
|
+
displayLines.add(i);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
for (const line of retryLineSet) {
|
|
191
|
+
displayLines.add(line);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const sorted = [...displayLines].sort((a, b) => a - b);
|
|
195
|
+
const maxDisplayLine = sorted[sorted.length - 1] ?? 1;
|
|
196
|
+
const lineNumberWidth = String(maxDisplayLine).length;
|
|
197
|
+
const out: string[] = [
|
|
198
|
+
`[E_STALE_ANCHOR] ${mismatches.length} stale anchor${mismatches.length > 1 ? "s" : ""}. Retry with the >>> LINE#HASH lines below; keep both endpoints for range replaces.`,
|
|
199
|
+
"",
|
|
200
|
+
];
|
|
201
|
+
|
|
202
|
+
let prev = -1;
|
|
203
|
+
for (const num of sorted) {
|
|
204
|
+
if (prev !== -1 && num > prev + 1) out.push(" ...");
|
|
205
|
+
prev = num;
|
|
206
|
+
const content = fileLines[num - 1];
|
|
207
|
+
const hash = computeLineHash(num, content);
|
|
208
|
+
const prefix = `${String(num).padStart(lineNumberWidth, " ")}#${hash}`;
|
|
209
|
+
out.push(
|
|
210
|
+
retryLineSet.has(num)
|
|
211
|
+
? `>>> ${prefix}:${content}`
|
|
212
|
+
: ` ${prefix}:${content}`,
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return out.join("\n");
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ─── Content preprocessing ─────────────────────────────────────────────────────
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Reject hashline display prefixes in edit payloads. Strict semantics: the
|
|
223
|
+
* model must send literal file content for `lines`, not the rendered read /
|
|
224
|
+
* diff form. Silent stripping is no longer performed — see AGENTS.md.
|
|
225
|
+
*/
|
|
226
|
+
function assertNoDisplayPrefixes(lines: string[]): void {
|
|
227
|
+
for (const line of lines) {
|
|
228
|
+
if (!line.length) continue;
|
|
229
|
+
if (
|
|
230
|
+
HASHLINE_PREFIX_RE.test(line) ||
|
|
231
|
+
HASHLINE_PREFIX_PLUS_RE.test(line) ||
|
|
232
|
+
DIFF_MINUS_RE.test(line)
|
|
233
|
+
) {
|
|
234
|
+
throw new Error(
|
|
235
|
+
`[E_INVALID_PATCH] "lines" must contain literal file content, not rendered "LINE#HASH:" or diff "+/-" prefixes. Offending line: ${JSON.stringify(line)}`,
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Parse replacement text into lines.
|
|
243
|
+
*
|
|
244
|
+
* String input is normalized to LF and drops exactly one trailing newline,
|
|
245
|
+
* matching read-preview style content. Array input is preserved verbatim so
|
|
246
|
+
* explicitly provided blank lines remain intact. Display prefixes are
|
|
247
|
+
* rejected by `assertNoDisplayPrefixes`, never silently stripped.
|
|
248
|
+
*/
|
|
249
|
+
export function hashlineParseText(edit: string[] | string | null): string[] {
|
|
250
|
+
if (edit === null) return [];
|
|
251
|
+
const lines = typeof edit === "string"
|
|
252
|
+
? (edit.endsWith("\n") ? edit.slice(0, -1) : edit).replaceAll("\r", "").split("\n")
|
|
253
|
+
: edit;
|
|
254
|
+
assertNoDisplayPrefixes(lines);
|
|
255
|
+
return lines;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Map flat tool-schema edits into typed internal representations.
|
|
260
|
+
*
|
|
261
|
+
* Strict: provided anchors must parse successfully. Missing anchors are
|
|
262
|
+
* fine for append (→ EOF) and prepend (→ BOF), but a malformed anchor
|
|
263
|
+
* that was explicitly supplied is always an error.
|
|
264
|
+
*
|
|
265
|
+
* - replace + pos only → single-line replace
|
|
266
|
+
* - replace + pos + end → range replace
|
|
267
|
+
* - append + pos → append after that anchor
|
|
268
|
+
* - prepend + pos → prepend before that anchor
|
|
269
|
+
* - replace_text + oldText/newText → exact unique text replace
|
|
270
|
+
* - no anchors → file-level append/prepend (only for those ops)
|
|
271
|
+
*
|
|
272
|
+
* Unknown or missing ops are rejected explicitly.
|
|
273
|
+
*/
|
|
274
|
+
export function resolveEditAnchors(edits: HashlineToolEdit[]): HashlineEdit[] {
|
|
275
|
+
const result: HashlineEdit[] = [];
|
|
276
|
+
for (const edit of edits) {
|
|
277
|
+
const op = edit.op;
|
|
278
|
+
if (
|
|
279
|
+
op !== "replace" &&
|
|
280
|
+
op !== "append" &&
|
|
281
|
+
op !== "prepend" &&
|
|
282
|
+
op !== "replace_text"
|
|
283
|
+
) {
|
|
284
|
+
throw new Error(
|
|
285
|
+
`[E_BAD_OP] Unknown edit op "${op}". Expected "replace", "append", "prepend", or "replace_text".`,
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
switch (op) {
|
|
290
|
+
case "replace": {
|
|
291
|
+
if (!edit.pos) {
|
|
292
|
+
throw new Error('[E_BAD_OP] Replace requires a "pos" anchor.');
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
result.push({
|
|
296
|
+
op: "replace",
|
|
297
|
+
pos: parseAnchorRef(edit.pos),
|
|
298
|
+
...(edit.end ? { end: parseAnchorRef(edit.end) } : {}),
|
|
299
|
+
lines: hashlineParseText(edit.lines ?? null),
|
|
300
|
+
});
|
|
301
|
+
break;
|
|
302
|
+
}
|
|
303
|
+
case "append": {
|
|
304
|
+
if (edit.end !== undefined) {
|
|
305
|
+
throw new Error('[E_BAD_OP] Append does not support "end". Use "pos" or omit it for EOF.');
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
result.push({
|
|
309
|
+
op: "append",
|
|
310
|
+
...(edit.pos ? { pos: parseAnchorRef(edit.pos) } : {}),
|
|
311
|
+
lines: hashlineParseText(edit.lines ?? null),
|
|
312
|
+
});
|
|
313
|
+
break;
|
|
314
|
+
}
|
|
315
|
+
case "prepend": {
|
|
316
|
+
if (edit.end !== undefined) {
|
|
317
|
+
throw new Error('[E_BAD_OP] Prepend does not support "end". Use "pos" or omit it for BOF.');
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
result.push({
|
|
321
|
+
op: "prepend",
|
|
322
|
+
...(edit.pos ? { pos: parseAnchorRef(edit.pos) } : {}),
|
|
323
|
+
lines: hashlineParseText(edit.lines ?? null),
|
|
324
|
+
});
|
|
325
|
+
break;
|
|
326
|
+
}
|
|
327
|
+
case "replace_text": {
|
|
328
|
+
const oldText = normalizeExactText(edit.oldText);
|
|
329
|
+
const newText = normalizeExactText(edit.newText);
|
|
330
|
+
if (oldText === undefined || newText === undefined) {
|
|
331
|
+
throw new Error('[E_BAD_OP] replace_text requires string "oldText" and "newText" fields.');
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
result.push({
|
|
335
|
+
op: "replace_text",
|
|
336
|
+
oldText,
|
|
337
|
+
newText,
|
|
338
|
+
});
|
|
339
|
+
break;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
return result;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// ─── Main edit engine ───────────────────────────────────────────────────
|
|
347
|
+
|
|
348
|
+
/** Schema-level edit as received from the tool layer (pos/end are tag strings, lines may be string|null). */
|
|
349
|
+
export type HashlineToolEdit = {
|
|
350
|
+
op: string;
|
|
351
|
+
pos?: string;
|
|
352
|
+
end?: string;
|
|
353
|
+
lines?: string[] | string | null;
|
|
354
|
+
oldText?: string;
|
|
355
|
+
newText?: string;
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
function normalizeExactText(text: string | undefined): string | undefined {
|
|
359
|
+
if (typeof text !== "string") {
|
|
360
|
+
return undefined;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
return text.replaceAll("\r\n", "\n").replaceAll("\r", "\n");
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function maybeWarnSuspiciousUnicodeEscapePlaceholder(
|
|
367
|
+
edits: HashlineEdit[],
|
|
368
|
+
warnings: string[],
|
|
369
|
+
): void {
|
|
370
|
+
for (const edit of edits) {
|
|
371
|
+
if (edit.op === "replace_text") {
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
if (edit.lines.some((line) => /\\uDDDD/i.test(line))) {
|
|
375
|
+
warnings.push(
|
|
376
|
+
"Detected literal \\uDDDD in edit content; no autocorrection applied. Verify whether this should be a real Unicode escape or plain text.",
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
type ResolvedEditSpan = {
|
|
383
|
+
kind: "replace" | "insert";
|
|
384
|
+
index: number;
|
|
385
|
+
label: string;
|
|
386
|
+
start: number;
|
|
387
|
+
end: number;
|
|
388
|
+
replacement: string;
|
|
389
|
+
boundary?: number;
|
|
390
|
+
insertMode?: "append-empty-origin" | "prepend-empty-origin";
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
type LineIndex = {
|
|
394
|
+
fileLines: string[];
|
|
395
|
+
lineStarts: number[];
|
|
396
|
+
hasTerminalNewline: boolean;
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
function buildLineIndex(content: string): LineIndex {
|
|
400
|
+
const fileLines = content.split("\n");
|
|
401
|
+
const lineStarts: number[] = [];
|
|
402
|
+
let offset = 0;
|
|
403
|
+
|
|
404
|
+
for (let index = 0; index < fileLines.length; index++) {
|
|
405
|
+
lineStarts.push(offset);
|
|
406
|
+
offset += fileLines[index]!.length;
|
|
407
|
+
if (index < fileLines.length - 1) {
|
|
408
|
+
offset += 1;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
return {
|
|
413
|
+
fileLines,
|
|
414
|
+
lineStarts,
|
|
415
|
+
hasTerminalNewline: content.endsWith("\n"),
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function previewText(text: string): string {
|
|
420
|
+
const compact = text.replaceAll("\n", "\\n");
|
|
421
|
+
return compact.length > 32 ? `${compact.slice(0, 29)}...` : compact;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function describeEdit(edit: HashlineEdit): string {
|
|
425
|
+
switch (edit.op) {
|
|
426
|
+
case "replace":
|
|
427
|
+
return edit.end
|
|
428
|
+
? `replace ${edit.pos.line}#${edit.pos.hash}-${edit.end.line}#${edit.end.hash}`
|
|
429
|
+
: `replace ${edit.pos.line}#${edit.pos.hash}`;
|
|
430
|
+
case "append":
|
|
431
|
+
return edit.pos
|
|
432
|
+
? `append after ${edit.pos.line}#${edit.pos.hash}`
|
|
433
|
+
: "append at EOF";
|
|
434
|
+
case "prepend":
|
|
435
|
+
return edit.pos
|
|
436
|
+
? `prepend before ${edit.pos.line}#${edit.pos.hash}`
|
|
437
|
+
: "prepend at BOF";
|
|
438
|
+
case "replace_text":
|
|
439
|
+
return `replace_text \"${previewText(edit.oldText)}\"`;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function throwEditConflict(
|
|
444
|
+
left: { index: number; label: string },
|
|
445
|
+
right: { index: number; label: string },
|
|
446
|
+
reason: string,
|
|
447
|
+
): never {
|
|
448
|
+
throw new Error(
|
|
449
|
+
`[E_EDIT_CONFLICT] Conflicting edits in a single request: edit ${left.index} (${left.label}) and edit ${right.index} (${right.label}) ${reason}. Merge them into one non-overlapping change or split the request.`,
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function cloneHashlineEdit(edit: HashlineEdit): HashlineEdit {
|
|
454
|
+
switch (edit.op) {
|
|
455
|
+
case "replace":
|
|
456
|
+
return {
|
|
457
|
+
op: "replace",
|
|
458
|
+
pos: { ...edit.pos },
|
|
459
|
+
...(edit.end ? { end: { ...edit.end } } : {}),
|
|
460
|
+
lines: [...edit.lines],
|
|
461
|
+
};
|
|
462
|
+
case "append":
|
|
463
|
+
return {
|
|
464
|
+
op: "append",
|
|
465
|
+
...(edit.pos ? { pos: { ...edit.pos } } : {}),
|
|
466
|
+
lines: [...edit.lines],
|
|
467
|
+
};
|
|
468
|
+
case "prepend":
|
|
469
|
+
return {
|
|
470
|
+
op: "prepend",
|
|
471
|
+
...(edit.pos ? { pos: { ...edit.pos } } : {}),
|
|
472
|
+
lines: [...edit.lines],
|
|
473
|
+
};
|
|
474
|
+
case "replace_text":
|
|
475
|
+
return {
|
|
476
|
+
op: "replace_text",
|
|
477
|
+
oldText: edit.oldText,
|
|
478
|
+
newText: edit.newText,
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function computeInsertionBoundary(
|
|
484
|
+
edit: Extract<HashlineEdit, { op: "append" | "prepend" }>,
|
|
485
|
+
lineIndex: LineIndex,
|
|
486
|
+
): number {
|
|
487
|
+
switch (edit.op) {
|
|
488
|
+
case "append": {
|
|
489
|
+
const fileLineCount = lineIndex.fileLines.length;
|
|
490
|
+
const eofBoundary = lineIndex.hasTerminalNewline && fileLineCount > 0
|
|
491
|
+
? fileLineCount - 1
|
|
492
|
+
: fileLineCount;
|
|
493
|
+
return edit.pos
|
|
494
|
+
? lineIndex.hasTerminalNewline && edit.pos.line === fileLineCount
|
|
495
|
+
? eofBoundary
|
|
496
|
+
: edit.pos.line
|
|
497
|
+
: eofBoundary;
|
|
498
|
+
}
|
|
499
|
+
case "prepend":
|
|
500
|
+
return edit.pos ? edit.pos.line - 1 : 0;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function findExactUniqueTextMatch(
|
|
505
|
+
content: string,
|
|
506
|
+
oldText: string,
|
|
507
|
+
): { start: number; end: number } {
|
|
508
|
+
if (oldText.length === 0) {
|
|
509
|
+
throw new Error("[E_BAD_OP] replace_text requires non-empty oldText.");
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
const matches: number[] = [];
|
|
513
|
+
let from = 0;
|
|
514
|
+
while (from <= content.length - oldText.length) {
|
|
515
|
+
const index = content.indexOf(oldText, from);
|
|
516
|
+
if (index === -1) {
|
|
517
|
+
break;
|
|
518
|
+
}
|
|
519
|
+
matches.push(index);
|
|
520
|
+
from = index + 1;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
for (let index = 1; index < matches.length; index++) {
|
|
524
|
+
if (matches[index]! - matches[index - 1]! < oldText.length) {
|
|
525
|
+
throw new Error(
|
|
526
|
+
"[E_MULTI_MATCH] replace_text found overlapping exact matches; re-read and use hashline edits.",
|
|
527
|
+
);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
if (matches.length === 0) {
|
|
532
|
+
throw new Error("[E_NO_MATCH] replace_text found no exact unique match in the current file.");
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
if (matches.length > 1) {
|
|
536
|
+
throw new Error(
|
|
537
|
+
"[E_MULTI_MATCH] replace_text found multiple exact matches in the current file. Re-read and use hashline edits.",
|
|
538
|
+
);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
const start = matches[0]!;
|
|
542
|
+
return {
|
|
543
|
+
start,
|
|
544
|
+
end: start + oldText.length,
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function resolveEditToSpan(
|
|
549
|
+
edit: HashlineEdit,
|
|
550
|
+
index: number,
|
|
551
|
+
content: string,
|
|
552
|
+
lineIndex: LineIndex,
|
|
553
|
+
noopEdits: NoopEdit[],
|
|
554
|
+
): ResolvedEditSpan | null {
|
|
555
|
+
const { fileLines, lineStarts, hasTerminalNewline } = lineIndex;
|
|
556
|
+
|
|
557
|
+
switch (edit.op) {
|
|
558
|
+
case "replace": {
|
|
559
|
+
const startLine = edit.pos.line;
|
|
560
|
+
const endLine = edit.end?.line ?? edit.pos.line;
|
|
561
|
+
const originalLines = fileLines.slice(startLine - 1, endLine);
|
|
562
|
+
if (
|
|
563
|
+
originalLines.length === edit.lines.length &&
|
|
564
|
+
originalLines.every((line, lineIndex) => line === edit.lines[lineIndex])
|
|
565
|
+
) {
|
|
566
|
+
noopEdits.push({
|
|
567
|
+
editIndex: index,
|
|
568
|
+
loc: `${edit.pos.line}#${edit.pos.hash}`,
|
|
569
|
+
currentContent: originalLines.join("\n"),
|
|
570
|
+
});
|
|
571
|
+
return null;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
if (edit.lines.length > 0) {
|
|
575
|
+
return {
|
|
576
|
+
kind: "replace",
|
|
577
|
+
index,
|
|
578
|
+
label: describeEdit(edit),
|
|
579
|
+
start: lineStarts[startLine - 1]!,
|
|
580
|
+
end: lineStarts[endLine - 1]! + fileLines[endLine - 1]!.length,
|
|
581
|
+
replacement: edit.lines.join("\n"),
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
if (startLine === 1 && endLine === fileLines.length) {
|
|
586
|
+
return {
|
|
587
|
+
kind: "replace",
|
|
588
|
+
index,
|
|
589
|
+
label: describeEdit(edit),
|
|
590
|
+
start: 0,
|
|
591
|
+
end: content.length,
|
|
592
|
+
replacement: "",
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
if (endLine < fileLines.length) {
|
|
597
|
+
return {
|
|
598
|
+
kind: "replace",
|
|
599
|
+
index,
|
|
600
|
+
label: describeEdit(edit),
|
|
601
|
+
start: lineStarts[startLine - 1]!,
|
|
602
|
+
end: lineStarts[endLine]!,
|
|
603
|
+
replacement: "",
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
return {
|
|
608
|
+
kind: "replace",
|
|
609
|
+
index,
|
|
610
|
+
label: describeEdit(edit),
|
|
611
|
+
start: Math.max(0, lineStarts[startLine - 1]! - 1),
|
|
612
|
+
end: lineStarts[endLine - 1]! + fileLines[endLine - 1]!.length,
|
|
613
|
+
replacement: "",
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
case "append": {
|
|
617
|
+
if (edit.lines.length === 0) {
|
|
618
|
+
noopEdits.push({
|
|
619
|
+
editIndex: index,
|
|
620
|
+
loc: edit.pos ? `${edit.pos.line}#${edit.pos.hash}` : "EOF",
|
|
621
|
+
currentContent: edit.pos ? fileLines[edit.pos.line - 1] ?? "" : "",
|
|
622
|
+
});
|
|
623
|
+
return null;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
const insertedText = edit.lines.join("\n");
|
|
627
|
+
if (content.length === 0) {
|
|
628
|
+
return {
|
|
629
|
+
kind: "insert",
|
|
630
|
+
index,
|
|
631
|
+
label: describeEdit(edit),
|
|
632
|
+
start: 0,
|
|
633
|
+
end: 0,
|
|
634
|
+
replacement: insertedText,
|
|
635
|
+
boundary: computeInsertionBoundary(edit, lineIndex),
|
|
636
|
+
insertMode: "append-empty-origin",
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
if (!edit.pos) {
|
|
641
|
+
return {
|
|
642
|
+
kind: "insert",
|
|
643
|
+
index,
|
|
644
|
+
label: describeEdit(edit),
|
|
645
|
+
start: content.length,
|
|
646
|
+
end: content.length,
|
|
647
|
+
replacement: hasTerminalNewline ? `${insertedText}\n` : `\n${insertedText}`,
|
|
648
|
+
boundary: computeInsertionBoundary(edit, lineIndex),
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
const isSentinelAppend = hasTerminalNewline && edit.pos.line === fileLines.length;
|
|
653
|
+
return {
|
|
654
|
+
kind: "insert",
|
|
655
|
+
index,
|
|
656
|
+
label: describeEdit(edit),
|
|
657
|
+
start: isSentinelAppend
|
|
658
|
+
? content.length
|
|
659
|
+
: lineStarts[edit.pos.line - 1]! + fileLines[edit.pos.line - 1]!.length,
|
|
660
|
+
end: isSentinelAppend
|
|
661
|
+
? content.length
|
|
662
|
+
: lineStarts[edit.pos.line - 1]! + fileLines[edit.pos.line - 1]!.length,
|
|
663
|
+
replacement: isSentinelAppend ? `${insertedText}\n` : `\n${insertedText}`,
|
|
664
|
+
boundary: computeInsertionBoundary(edit, lineIndex),
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
case "prepend": {
|
|
668
|
+
if (edit.lines.length === 0) {
|
|
669
|
+
noopEdits.push({
|
|
670
|
+
editIndex: index,
|
|
671
|
+
loc: edit.pos ? `${edit.pos.line}#${edit.pos.hash}` : "BOF",
|
|
672
|
+
currentContent: edit.pos ? fileLines[edit.pos.line - 1] ?? "" : "",
|
|
673
|
+
});
|
|
674
|
+
return null;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
const insertedText = edit.lines.join("\n");
|
|
678
|
+
const start = edit.pos ? lineStarts[edit.pos.line - 1]! : 0;
|
|
679
|
+
return {
|
|
680
|
+
kind: "insert",
|
|
681
|
+
index,
|
|
682
|
+
label: describeEdit(edit),
|
|
683
|
+
start,
|
|
684
|
+
end: start,
|
|
685
|
+
replacement: content.length === 0 ? insertedText : `${insertedText}\n`,
|
|
686
|
+
boundary: computeInsertionBoundary(edit, lineIndex),
|
|
687
|
+
...(content.length === 0 ? { insertMode: "prepend-empty-origin" as const } : {}),
|
|
688
|
+
};
|
|
689
|
+
}
|
|
690
|
+
case "replace_text": {
|
|
691
|
+
const match = findExactUniqueTextMatch(content, edit.oldText);
|
|
692
|
+
if (edit.oldText === edit.newText) {
|
|
693
|
+
noopEdits.push({
|
|
694
|
+
editIndex: index,
|
|
695
|
+
loc: `replace_text \"${previewText(edit.oldText)}\"`,
|
|
696
|
+
currentContent: edit.oldText,
|
|
697
|
+
});
|
|
698
|
+
return null;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
return {
|
|
702
|
+
kind: "replace",
|
|
703
|
+
index,
|
|
704
|
+
label: describeEdit(edit),
|
|
705
|
+
start: match.start,
|
|
706
|
+
end: match.end,
|
|
707
|
+
replacement: edit.newText,
|
|
708
|
+
};
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
function assertNoConflictingSpans(spans: ResolvedEditSpan[]): void {
|
|
714
|
+
for (let leftIndex = 0; leftIndex < spans.length; leftIndex++) {
|
|
715
|
+
const left = spans[leftIndex]!;
|
|
716
|
+
for (let rightIndex = leftIndex + 1; rightIndex < spans.length; rightIndex++) {
|
|
717
|
+
const right = spans[rightIndex]!;
|
|
718
|
+
|
|
719
|
+
if (left.kind === "insert" && right.kind === "insert") {
|
|
720
|
+
if (left.boundary === right.boundary) {
|
|
721
|
+
throwEditConflict(left, right, "target the same insertion boundary");
|
|
722
|
+
}
|
|
723
|
+
continue;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
if (left.kind === "replace" && right.kind === "replace") {
|
|
727
|
+
if (left.start < right.end && right.start < left.end) {
|
|
728
|
+
throwEditConflict(left, right, "overlap on the same original line range");
|
|
729
|
+
}
|
|
730
|
+
continue;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
const replaceSpan = left.kind === "replace" ? left : right;
|
|
734
|
+
const insertSpan = left.kind === "insert" ? left : right;
|
|
735
|
+
if (insertSpan.start >= replaceSpan.start && insertSpan.start < replaceSpan.end) {
|
|
736
|
+
throwEditConflict(
|
|
737
|
+
left,
|
|
738
|
+
right,
|
|
739
|
+
"cannot be applied together because one inserts inside a replaced original range",
|
|
740
|
+
);
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
export function applyHashlineEdits(
|
|
747
|
+
content: string,
|
|
748
|
+
edits: HashlineEdit[],
|
|
749
|
+
signal?: AbortSignal,
|
|
750
|
+
): {
|
|
751
|
+
content: string;
|
|
752
|
+
firstChangedLine: number | undefined;
|
|
753
|
+
lastChangedLine: number | undefined;
|
|
754
|
+
warnings?: string[];
|
|
755
|
+
noopEdits?: NoopEdit[];
|
|
756
|
+
} {
|
|
757
|
+
throwIfAborted(signal);
|
|
758
|
+
if (!edits.length) return { content, firstChangedLine: undefined, lastChangedLine: undefined };
|
|
759
|
+
|
|
760
|
+
const workingEdits = edits.map(cloneHashlineEdit);
|
|
761
|
+
const lineIndex = buildLineIndex(content);
|
|
762
|
+
const noopEdits: NoopEdit[] = [];
|
|
763
|
+
const warnings: string[] = [];
|
|
764
|
+
|
|
765
|
+
const mismatches: HashMismatch[] = [];
|
|
766
|
+
const retryLines = new Set<number>();
|
|
767
|
+
const acceptedFuzzyRefs = new Set<string>();
|
|
768
|
+
function validate(ref: Anchor): boolean {
|
|
769
|
+
if (ref.line < 1 || ref.line > lineIndex.fileLines.length) {
|
|
770
|
+
throw new Error(`[E_RANGE_OOB] Line ${ref.line} does not exist (file has ${lineIndex.fileLines.length} lines)`);
|
|
771
|
+
}
|
|
772
|
+
const line = lineIndex.fileLines[ref.line - 1]!;
|
|
773
|
+
const actual = computeLineHash(ref.line, line);
|
|
774
|
+
if (actual === ref.hash) return true;
|
|
775
|
+
if (ref.textHint !== undefined) {
|
|
776
|
+
const hintedHash = computeLineHash(ref.line, ref.textHint);
|
|
777
|
+
if (hintedHash === ref.hash && isFuzzyEquivalentLine(ref.textHint, line)) {
|
|
778
|
+
const key = `${ref.line}:${ref.hash}:${ref.textHint}`;
|
|
779
|
+
if (!acceptedFuzzyRefs.has(key)) {
|
|
780
|
+
acceptedFuzzyRefs.add(key);
|
|
781
|
+
warnings.push(
|
|
782
|
+
`Accepted fuzzy anchor validation at line ${ref.line}: exact hash mismatched, but the copied line content still matched after whitespace/Unicode normalization.`,
|
|
783
|
+
);
|
|
784
|
+
}
|
|
785
|
+
return true;
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
mismatches.push({ line: ref.line, expected: ref.hash, actual });
|
|
789
|
+
retryLines.add(ref.line);
|
|
790
|
+
return false;
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
for (const edit of workingEdits) {
|
|
794
|
+
throwIfAborted(signal);
|
|
795
|
+
switch (edit.op) {
|
|
796
|
+
case "replace": {
|
|
797
|
+
if (edit.end) {
|
|
798
|
+
if (edit.pos.line > edit.end.line) {
|
|
799
|
+
throw new Error(
|
|
800
|
+
`[E_BAD_RANGE] Range start line ${edit.pos.line} must be <= end line ${edit.end.line}`,
|
|
801
|
+
);
|
|
802
|
+
}
|
|
803
|
+
const startOk = validate(edit.pos);
|
|
804
|
+
const endOk = validate(edit.end);
|
|
805
|
+
if (!startOk && endOk) {
|
|
806
|
+
retryLines.add(edit.end.line);
|
|
807
|
+
}
|
|
808
|
+
if (startOk && !endOk) {
|
|
809
|
+
retryLines.add(edit.pos.line);
|
|
810
|
+
}
|
|
811
|
+
if (!startOk || !endOk) continue;
|
|
812
|
+
} else if (!validate(edit.pos)) {
|
|
813
|
+
continue;
|
|
814
|
+
}
|
|
815
|
+
const startLine = edit.pos.line;
|
|
816
|
+
const endLine = edit.end?.line ?? edit.pos.line;
|
|
817
|
+
|
|
818
|
+
// Check both boundaries for duplication
|
|
819
|
+
const checkBoundary = (candidate: string | undefined, boundary: string | undefined, label: string) => {
|
|
820
|
+
if (!candidate || !boundary) return;
|
|
821
|
+
const c = candidate.trim();
|
|
822
|
+
const b = boundary.trim();
|
|
823
|
+
if (c && /[\p{L}\p{N}]/u.test(c) && c === b) {
|
|
824
|
+
warnings.push(
|
|
825
|
+
`Potential boundary duplication ${label} ${describeEdit(edit)}: the replacement ${label === "after" ? "ends" : "starts"} with a line that matches the ${label === "after" ? "next surviving" : "preceding"} line after trim.`,
|
|
826
|
+
);
|
|
827
|
+
}
|
|
828
|
+
};
|
|
829
|
+
checkBoundary(edit.lines.at(-1), lineIndex.fileLines[endLine], "after");
|
|
830
|
+
if (startLine > 1) checkBoundary(edit.lines[0], lineIndex.fileLines[startLine - 2], "before");
|
|
831
|
+
break;
|
|
832
|
+
}
|
|
833
|
+
case "append": {
|
|
834
|
+
if (edit.pos && !validate(edit.pos)) continue;
|
|
835
|
+
if (edit.lines.length === 0) {
|
|
836
|
+
throw new Error(
|
|
837
|
+
"[E_BAD_OP] Append with empty lines payload. Provide content to insert or remove the edit.",
|
|
838
|
+
);
|
|
839
|
+
}
|
|
840
|
+
break;
|
|
841
|
+
}
|
|
842
|
+
case "prepend": {
|
|
843
|
+
if (edit.pos && !validate(edit.pos)) continue;
|
|
844
|
+
if (edit.lines.length === 0) {
|
|
845
|
+
throw new Error(
|
|
846
|
+
"[E_BAD_OP] Prepend with empty lines payload. Provide content to insert or remove the edit.",
|
|
847
|
+
);
|
|
848
|
+
}
|
|
849
|
+
break;
|
|
850
|
+
}
|
|
851
|
+
case "replace_text":
|
|
852
|
+
break;
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
if (mismatches.length) {
|
|
856
|
+
throw new Error(formatMismatchError(mismatches, lineIndex.fileLines, retryLines));
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
maybeWarnSuspiciousUnicodeEscapePlaceholder(workingEdits, warnings);
|
|
860
|
+
|
|
861
|
+
const seenSpanKeys = new Set<string>();
|
|
862
|
+
const resolvedSpans: ResolvedEditSpan[] = [];
|
|
863
|
+
for (const [index, edit] of workingEdits.entries()) {
|
|
864
|
+
throwIfAborted(signal);
|
|
865
|
+
const span = resolveEditToSpan(edit, index, content, lineIndex, noopEdits);
|
|
866
|
+
if (!span) {
|
|
867
|
+
continue;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
const spanKey = span.kind === "insert"
|
|
871
|
+
? `insert:${span.boundary}:${span.replacement}`
|
|
872
|
+
: `replace:${span.start}:${span.end}:${span.replacement}`;
|
|
873
|
+
if (seenSpanKeys.has(spanKey)) {
|
|
874
|
+
continue;
|
|
875
|
+
}
|
|
876
|
+
seenSpanKeys.add(spanKey);
|
|
877
|
+
resolvedSpans.push(span);
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
assertNoConflictingSpans(resolvedSpans);
|
|
881
|
+
|
|
882
|
+
const orderedSpans = [...resolvedSpans].sort((left, right) => {
|
|
883
|
+
if (right.end !== left.end) {
|
|
884
|
+
return right.end - left.end;
|
|
885
|
+
}
|
|
886
|
+
if (left.kind !== right.kind) {
|
|
887
|
+
return left.kind === "replace" ? -1 : 1;
|
|
888
|
+
}
|
|
889
|
+
if (left.kind === "insert" && right.kind === "insert") {
|
|
890
|
+
return (right.boundary ?? -1) - (left.boundary ?? -1) || left.index - right.index;
|
|
891
|
+
}
|
|
892
|
+
return left.index - right.index;
|
|
893
|
+
});
|
|
894
|
+
|
|
895
|
+
let result = content;
|
|
896
|
+
for (const span of orderedSpans) {
|
|
897
|
+
throwIfAborted(signal);
|
|
898
|
+
const replacement = span.insertMode === "append-empty-origin"
|
|
899
|
+
? result.length === 0
|
|
900
|
+
? span.replacement
|
|
901
|
+
: `\n${span.replacement}`
|
|
902
|
+
: span.insertMode === "prepend-empty-origin"
|
|
903
|
+
? result.length === 0
|
|
904
|
+
? span.replacement
|
|
905
|
+
: `${span.replacement}\n`
|
|
906
|
+
: span.replacement;
|
|
907
|
+
result = result.slice(0, span.start) + replacement + result.slice(span.end);
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
const changedRange = computeChangedLineRange(content, result);
|
|
911
|
+
return {
|
|
912
|
+
content: result,
|
|
913
|
+
firstChangedLine: changedRange?.firstChangedLine,
|
|
914
|
+
lastChangedLine: changedRange?.lastChangedLine,
|
|
915
|
+
...(warnings.length ? { warnings } : {}),
|
|
916
|
+
...(noopEdits.length ? { noopEdits } : {}),
|
|
917
|
+
};
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
// ─── Affected-line computation (for returning anchors after edit) ───────
|
|
921
|
+
|
|
922
|
+
const ANCHOR_CONTEXT_LINES = 2;
|
|
923
|
+
const ANCHOR_MAX_OUTPUT_LINES = 12;
|
|
924
|
+
|
|
925
|
+
/**
|
|
926
|
+
* Compute the post-edit line range covering changed lines plus context.
|
|
927
|
+
* Uses `firstChangedLine` and `lastChangedLine` from the edit result for
|
|
928
|
+
* precise bounds. Returns null if the range (with context) exceeds the
|
|
929
|
+
* output budget, signalling that the LLM should re-read instead.
|
|
930
|
+
*/
|
|
931
|
+
export function computeAffectedLineRange(params: {
|
|
932
|
+
firstChangedLine: number | undefined;
|
|
933
|
+
lastChangedLine: number | undefined;
|
|
934
|
+
resultLineCount: number;
|
|
935
|
+
contextLines?: number;
|
|
936
|
+
maxOutputLines?: number;
|
|
937
|
+
}): { start: number; end: number } | null {
|
|
938
|
+
const {
|
|
939
|
+
firstChangedLine,
|
|
940
|
+
lastChangedLine,
|
|
941
|
+
resultLineCount,
|
|
942
|
+
contextLines = ANCHOR_CONTEXT_LINES,
|
|
943
|
+
maxOutputLines = ANCHOR_MAX_OUTPUT_LINES,
|
|
944
|
+
} = params;
|
|
945
|
+
|
|
946
|
+
if (firstChangedLine === undefined || lastChangedLine === undefined) {
|
|
947
|
+
return null;
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
// Empty file after edit: no meaningful anchor block.
|
|
951
|
+
if (resultLineCount === 0) {
|
|
952
|
+
return null;
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
const start = Math.max(1, firstChangedLine - contextLines);
|
|
956
|
+
const end = Math.min(resultLineCount, lastChangedLine + contextLines);
|
|
957
|
+
|
|
958
|
+
// Guard against inverted range (can happen when context pushes end below start).
|
|
959
|
+
if (end < start) {
|
|
960
|
+
return null;
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
if (end - start + 1 > maxOutputLines) {
|
|
964
|
+
return null;
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
return { start, end };
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
export function formatHashlineRegion(
|
|
971
|
+
lines: string[],
|
|
972
|
+
startLine: number,
|
|
973
|
+
): string {
|
|
974
|
+
const lineNumberWidth = String(
|
|
975
|
+
startLine + Math.max(0, lines.length - 1),
|
|
976
|
+
).length;
|
|
977
|
+
return lines
|
|
978
|
+
.map((line, index) => {
|
|
979
|
+
const lineNumber = startLine + index;
|
|
980
|
+
const paddedLineNumber = String(lineNumber).padStart(lineNumberWidth, " ");
|
|
981
|
+
return `${paddedLineNumber}#${computeLineHash(lineNumber, line)}:${line}`;
|
|
982
|
+
})
|
|
983
|
+
.join("\n");
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
// ─── Legacy edit line range computation ─────────────────────────────
|
|
987
|
+
|
|
988
|
+
/**
|
|
989
|
+
* Compute first/last changed line numbers for legacy (oldText/newText) edits.
|
|
990
|
+
* Uses character-level diff to locate the changed span, then maps to line
|
|
991
|
+
* numbers in the result document so downstream anchor chaining works.
|
|
992
|
+
*/
|
|
993
|
+
function computeChangedLineRange(
|
|
994
|
+
original: string,
|
|
995
|
+
result: string,
|
|
996
|
+
): { firstChangedLine: number; lastChangedLine: number } | null {
|
|
997
|
+
if (original === result) return null;
|
|
998
|
+
|
|
999
|
+
function countVisibleLines(text: string): number {
|
|
1000
|
+
if (text.length === 0) {
|
|
1001
|
+
return 0;
|
|
1002
|
+
}
|
|
1003
|
+
const lines = text.split("\n");
|
|
1004
|
+
return text.endsWith("\n") ? lines.length - 1 : lines.length;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
if (original.length === 0) {
|
|
1008
|
+
return {
|
|
1009
|
+
firstChangedLine: 1,
|
|
1010
|
+
lastChangedLine: countVisibleLines(result),
|
|
1011
|
+
};
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
if (result.startsWith(original) && original.endsWith("\n")) {
|
|
1015
|
+
return {
|
|
1016
|
+
firstChangedLine: countVisibleLines(original) + 1,
|
|
1017
|
+
lastChangedLine: countVisibleLines(result),
|
|
1018
|
+
};
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
let firstDiff = 0;
|
|
1022
|
+
const minLen = Math.min(original.length, result.length);
|
|
1023
|
+
while (firstDiff < minLen && original[firstDiff] === result[firstDiff]) {
|
|
1024
|
+
firstDiff++;
|
|
1025
|
+
}
|
|
1026
|
+
if (firstDiff === minLen && original.length === result.length) return null;
|
|
1027
|
+
|
|
1028
|
+
let lastOrig = original.length - 1;
|
|
1029
|
+
let lastRes = result.length - 1;
|
|
1030
|
+
while (
|
|
1031
|
+
lastOrig >= firstDiff &&
|
|
1032
|
+
lastRes >= firstDiff &&
|
|
1033
|
+
original[lastOrig] === result[lastRes]
|
|
1034
|
+
) {
|
|
1035
|
+
lastOrig--;
|
|
1036
|
+
lastRes--;
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
function indexToLine(charIdx: number, text: string): number {
|
|
1040
|
+
let line = 1;
|
|
1041
|
+
for (let i = 0; i < charIdx && i < text.length; i++) {
|
|
1042
|
+
if (text[i] === "\n") line++;
|
|
1043
|
+
}
|
|
1044
|
+
return line;
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
const firstChangedLine = indexToLine(firstDiff + 1, result);
|
|
1048
|
+
let lastChangedLine: number;
|
|
1049
|
+
if (lastRes < firstDiff) {
|
|
1050
|
+
lastChangedLine = result.length === 0 ? 1 : countVisibleLines(result);
|
|
1051
|
+
} else if (firstDiff === 0 && original.length > 0 && result.endsWith(original)) {
|
|
1052
|
+
lastChangedLine = firstChangedLine;
|
|
1053
|
+
} else {
|
|
1054
|
+
lastChangedLine = indexToLine(lastRes + 1, result);
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
return { firstChangedLine, lastChangedLine };
|
|
1058
|
+
}
|