pi-hashline-edit-pro 0.2.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 +143 -0
- package/index.ts +64 -0
- package/package.json +52 -0
- package/prompts/edit-snippet.md +1 -0
- package/prompts/edit.md +58 -0
- package/prompts/read-guidelines.md +3 -0
- package/prompts/read-snippet.md +1 -0
- package/prompts/read.md +28 -0
- package/src/edit-diff.ts +234 -0
- package/src/edit-normalize.ts +68 -0
- package/src/edit-render.ts +280 -0
- package/src/edit-response.ts +531 -0
- package/src/edit.ts +689 -0
- package/src/file-kind.ts +161 -0
- package/src/fs-write.ts +105 -0
- package/src/hashline/apply.ts +660 -0
- package/src/hashline/hash.ts +192 -0
- package/src/hashline/index.ts +70 -0
- package/src/hashline/parse.ts +116 -0
- package/src/hashline/resolve.ts +552 -0
- package/src/path-utils.ts +13 -0
- package/src/read.ts +256 -0
- package/src/runtime.ts +3 -0
- package/src/snapshot.ts +29 -0
- package/src/utils.ts +11 -0
|
@@ -0,0 +1,660 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Application — edit span resolution, conflict detection, and assembly.
|
|
3
|
+
*
|
|
4
|
+
* This module owns the pipeline that turns resolved edits into character-level
|
|
5
|
+
* spans, detects conflicts, and applies the spans back-to-front to produce
|
|
6
|
+
* the final file content. It also owns the changed-line-range computation
|
|
7
|
+
* and the hashline region formatting used by read and edit responses.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { throwIfAborted } from "../runtime";
|
|
11
|
+
import { computeLineHashes } from "./hash";
|
|
12
|
+
import {
|
|
13
|
+
validateAnchorEdits,
|
|
14
|
+
assertNoBareHashPrefixLines,
|
|
15
|
+
maybeWarnSuspiciousUnicodeEscapePlaceholder,
|
|
16
|
+
formatMismatchError,
|
|
17
|
+
type ResolvedHashlineEdit,
|
|
18
|
+
type NoopEdit,
|
|
19
|
+
type HashlineEdit,
|
|
20
|
+
} from "./resolve";
|
|
21
|
+
|
|
22
|
+
// ─── Line index ─────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
type LineIndex = {
|
|
25
|
+
fileLines: string[];
|
|
26
|
+
lineStarts: number[];
|
|
27
|
+
hasTerminalNewline: boolean;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export function buildLineIndex(content: string): LineIndex {
|
|
31
|
+
const fileLines = content.split("\n");
|
|
32
|
+
const lineStarts: number[] = [];
|
|
33
|
+
let offset = 0;
|
|
34
|
+
|
|
35
|
+
for (let index = 0; index < fileLines.length; index++) {
|
|
36
|
+
lineStarts.push(offset);
|
|
37
|
+
offset += fileLines[index]!.length;
|
|
38
|
+
if (index < fileLines.length - 1) {
|
|
39
|
+
offset += 1;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
fileLines,
|
|
45
|
+
lineStarts,
|
|
46
|
+
hasTerminalNewline: content.endsWith("\n"),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ─── Edit span resolution ───────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
type ResolvedEditSpan = {
|
|
53
|
+
kind: "replace" | "insert";
|
|
54
|
+
index: number;
|
|
55
|
+
label: string;
|
|
56
|
+
start: number;
|
|
57
|
+
end: number;
|
|
58
|
+
replacement: string;
|
|
59
|
+
boundary?: number;
|
|
60
|
+
insertMode?: "append-empty-origin" | "prepend-empty-origin";
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
function assertDoesNotEmptyFile(originalContent: string, result: string): void {
|
|
64
|
+
if (originalContent.length > 0 && result.length === 0) {
|
|
65
|
+
throw new Error(
|
|
66
|
+
"[E_WOULD_EMPTY] Cannot empty a non-empty file via edit."
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function describeEdit(edit: ResolvedHashlineEdit): string {
|
|
72
|
+
switch (edit.op) {
|
|
73
|
+
case "replace":
|
|
74
|
+
return `replace ${edit.start.hash}-${edit.end.hash}`;
|
|
75
|
+
case "append":
|
|
76
|
+
return edit.pos ? `append after ${edit.pos.hash}` : "append at EOF";
|
|
77
|
+
case "prepend":
|
|
78
|
+
return edit.pos ? `prepend before ${edit.pos.hash}` : "prepend at BOF";
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function throwEditConflict(
|
|
83
|
+
left: { index: number; label: string },
|
|
84
|
+
right: { index: number; label: string },
|
|
85
|
+
reason: string,
|
|
86
|
+
): never {
|
|
87
|
+
throw new Error(
|
|
88
|
+
`[E_EDIT_CONFLICT] Edit ${left.index} (${left.label}) and edit ${right.index} (${right.label}) ${reason}.`
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function computeInsertionBoundary(
|
|
93
|
+
edit: Extract<ResolvedHashlineEdit, { op: "append" | "prepend" }>,
|
|
94
|
+
lineIndex: LineIndex,
|
|
95
|
+
): number {
|
|
96
|
+
switch (edit.op) {
|
|
97
|
+
case "append": {
|
|
98
|
+
const fileLineCount = lineIndex.fileLines.length;
|
|
99
|
+
const eofBoundary =
|
|
100
|
+
lineIndex.hasTerminalNewline && fileLineCount > 0
|
|
101
|
+
? fileLineCount - 1
|
|
102
|
+
: fileLineCount;
|
|
103
|
+
return edit.pos
|
|
104
|
+
? lineIndex.hasTerminalNewline && edit.pos.line === fileLineCount
|
|
105
|
+
? eofBoundary
|
|
106
|
+
: edit.pos.line
|
|
107
|
+
: eofBoundary;
|
|
108
|
+
}
|
|
109
|
+
case "prepend":
|
|
110
|
+
return edit.pos ? edit.pos.line - 1 : 0;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function resolveEditToSpan(
|
|
115
|
+
edit: ResolvedHashlineEdit,
|
|
116
|
+
index: number,
|
|
117
|
+
content: string,
|
|
118
|
+
lineIndex: LineIndex,
|
|
119
|
+
noopEdits: NoopEdit[],
|
|
120
|
+
): ResolvedEditSpan | null {
|
|
121
|
+
const { fileLines, lineStarts, hasTerminalNewline } = lineIndex;
|
|
122
|
+
|
|
123
|
+
switch (edit.op) {
|
|
124
|
+
case "replace": {
|
|
125
|
+
const startLine = edit.start.line;
|
|
126
|
+
const endLine = edit.end.line;
|
|
127
|
+
const originalLines = fileLines.slice(startLine - 1, endLine);
|
|
128
|
+
if (
|
|
129
|
+
originalLines.length === edit.lines.length &&
|
|
130
|
+
originalLines.every(
|
|
131
|
+
(line, lineIndex) => line === edit.lines[lineIndex],
|
|
132
|
+
)
|
|
133
|
+
) {
|
|
134
|
+
noopEdits.push({
|
|
135
|
+
editIndex: index,
|
|
136
|
+
loc: edit.start.hash,
|
|
137
|
+
currentContent: originalLines.join("\n"),
|
|
138
|
+
});
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (edit.lines.length > 0) {
|
|
143
|
+
return {
|
|
144
|
+
kind: "replace",
|
|
145
|
+
index,
|
|
146
|
+
label: describeEdit(edit),
|
|
147
|
+
start: lineStarts[startLine - 1]!,
|
|
148
|
+
end: lineStarts[endLine - 1]! + fileLines[endLine - 1]!.length,
|
|
149
|
+
replacement: edit.lines.join("\n"),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (startLine === 1 && endLine === fileLines.length) {
|
|
154
|
+
return {
|
|
155
|
+
kind: "replace",
|
|
156
|
+
index,
|
|
157
|
+
label: describeEdit(edit),
|
|
158
|
+
start: 0,
|
|
159
|
+
end: content.length,
|
|
160
|
+
replacement: "",
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (endLine < fileLines.length) {
|
|
165
|
+
return {
|
|
166
|
+
kind: "replace",
|
|
167
|
+
index,
|
|
168
|
+
label: describeEdit(edit),
|
|
169
|
+
start: lineStarts[startLine - 1]!,
|
|
170
|
+
end: lineStarts[endLine]!,
|
|
171
|
+
replacement: "",
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
kind: "replace",
|
|
177
|
+
index,
|
|
178
|
+
label: describeEdit(edit),
|
|
179
|
+
start: Math.max(0, lineStarts[startLine - 1]! - 1),
|
|
180
|
+
end: lineStarts[endLine - 1]! + fileLines[endLine - 1]!.length,
|
|
181
|
+
replacement: "",
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
case "append": {
|
|
185
|
+
if (edit.lines.length === 0) {
|
|
186
|
+
noopEdits.push({
|
|
187
|
+
editIndex: index,
|
|
188
|
+
loc: edit.pos ? edit.pos.hash : "EOF",
|
|
189
|
+
currentContent: edit.pos
|
|
190
|
+
? (fileLines[edit.pos.line - 1] ?? "")
|
|
191
|
+
: "",
|
|
192
|
+
});
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const insertedText = edit.lines.join("\n");
|
|
197
|
+
if (content.length === 0) {
|
|
198
|
+
return {
|
|
199
|
+
kind: "insert",
|
|
200
|
+
index,
|
|
201
|
+
label: describeEdit(edit),
|
|
202
|
+
start: 0,
|
|
203
|
+
end: 0,
|
|
204
|
+
replacement: insertedText,
|
|
205
|
+
boundary: computeInsertionBoundary(edit, lineIndex),
|
|
206
|
+
insertMode: "append-empty-origin",
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (!edit.pos) {
|
|
211
|
+
return {
|
|
212
|
+
kind: "insert",
|
|
213
|
+
index,
|
|
214
|
+
label: describeEdit(edit),
|
|
215
|
+
start: content.length,
|
|
216
|
+
end: content.length,
|
|
217
|
+
replacement: hasTerminalNewline
|
|
218
|
+
? `${insertedText}\n`
|
|
219
|
+
: `\n${insertedText}`,
|
|
220
|
+
boundary: computeInsertionBoundary(edit, lineIndex),
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const isSentinelAppend =
|
|
225
|
+
hasTerminalNewline && edit.pos.line === fileLines.length;
|
|
226
|
+
return {
|
|
227
|
+
kind: "insert",
|
|
228
|
+
index,
|
|
229
|
+
label: describeEdit(edit),
|
|
230
|
+
start: isSentinelAppend
|
|
231
|
+
? content.length
|
|
232
|
+
: lineStarts[edit.pos.line - 1]! +
|
|
233
|
+
fileLines[edit.pos.line - 1]!.length,
|
|
234
|
+
end: isSentinelAppend
|
|
235
|
+
? content.length
|
|
236
|
+
: lineStarts[edit.pos.line - 1]! +
|
|
237
|
+
fileLines[edit.pos.line - 1]!.length,
|
|
238
|
+
replacement: isSentinelAppend
|
|
239
|
+
? `${insertedText}\n`
|
|
240
|
+
: `\n${insertedText}`,
|
|
241
|
+
boundary: computeInsertionBoundary(edit, lineIndex),
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
case "prepend": {
|
|
245
|
+
if (edit.lines.length === 0) {
|
|
246
|
+
noopEdits.push({
|
|
247
|
+
editIndex: index,
|
|
248
|
+
loc: edit.pos ? edit.pos.hash : "BOF",
|
|
249
|
+
currentContent: edit.pos
|
|
250
|
+
? (fileLines[edit.pos.line - 1] ?? "")
|
|
251
|
+
: "",
|
|
252
|
+
});
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
const insertedText = edit.lines.join("\n");
|
|
256
|
+
const start = edit.pos ? lineStarts[edit.pos.line - 1]! : 0;
|
|
257
|
+
return {
|
|
258
|
+
kind: "insert",
|
|
259
|
+
index,
|
|
260
|
+
label: describeEdit(edit),
|
|
261
|
+
start,
|
|
262
|
+
end: start,
|
|
263
|
+
replacement:
|
|
264
|
+
content.length === 0 ? insertedText : `${insertedText}\n`,
|
|
265
|
+
boundary: computeInsertionBoundary(edit, lineIndex),
|
|
266
|
+
...(content.length === 0
|
|
267
|
+
? { insertMode: "prepend-empty-origin" as const }
|
|
268
|
+
: {}),
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function assertNoConflictingSpans(spans: ResolvedEditSpan[]): void {
|
|
275
|
+
for (let leftIndex = 0; leftIndex < spans.length; leftIndex++) {
|
|
276
|
+
const left = spans[leftIndex]!;
|
|
277
|
+
for (
|
|
278
|
+
let rightIndex = leftIndex + 1;
|
|
279
|
+
rightIndex < spans.length;
|
|
280
|
+
rightIndex++
|
|
281
|
+
) {
|
|
282
|
+
const right = spans[rightIndex]!;
|
|
283
|
+
|
|
284
|
+
if (left.kind === "insert" && right.kind === "insert") {
|
|
285
|
+
if (left.boundary === right.boundary) {
|
|
286
|
+
throwEditConflict(
|
|
287
|
+
left,
|
|
288
|
+
right,
|
|
289
|
+
"target the same insertion boundary",
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (left.kind === "replace" && right.kind === "replace") {
|
|
296
|
+
if (left.start < right.end && right.start < left.end) {
|
|
297
|
+
throwEditConflict(
|
|
298
|
+
left,
|
|
299
|
+
right,
|
|
300
|
+
"overlap on the same original line range",
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const replaceSpan = left.kind === "replace" ? left : right;
|
|
307
|
+
const insertSpan = left.kind === "insert" ? left : right;
|
|
308
|
+
if (
|
|
309
|
+
insertSpan.start >= replaceSpan.start &&
|
|
310
|
+
insertSpan.start < replaceSpan.end
|
|
311
|
+
) {
|
|
312
|
+
throwEditConflict(
|
|
313
|
+
left,
|
|
314
|
+
right,
|
|
315
|
+
"cannot be applied together because one inserts inside a replaced original range",
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Resolve validated edits into ordered, conflict-free character-level spans.
|
|
324
|
+
*
|
|
325
|
+
* Each edit is mapped through resolveEditToSpan (which may produce a noop),
|
|
326
|
+
* duplicate spans are deduplicated, conflicts are rejected, and the remaining
|
|
327
|
+
* spans are sorted back-to-front for safe in-place assembly.
|
|
328
|
+
*/
|
|
329
|
+
function resolveEditSpans(
|
|
330
|
+
edits: ResolvedHashlineEdit[],
|
|
331
|
+
content: string,
|
|
332
|
+
lineIndex: LineIndex,
|
|
333
|
+
noopEdits: NoopEdit[],
|
|
334
|
+
signal: AbortSignal | undefined,
|
|
335
|
+
): ResolvedEditSpan[] {
|
|
336
|
+
const seenSpanKeys = new Set<string>();
|
|
337
|
+
const resolvedSpans: ResolvedEditSpan[] = [];
|
|
338
|
+
for (const [index, edit] of edits.entries()) {
|
|
339
|
+
throwIfAborted(signal);
|
|
340
|
+
const span = resolveEditToSpan(
|
|
341
|
+
edit,
|
|
342
|
+
index,
|
|
343
|
+
content,
|
|
344
|
+
lineIndex,
|
|
345
|
+
noopEdits,
|
|
346
|
+
);
|
|
347
|
+
if (!span) {
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const spanKey =
|
|
352
|
+
span.kind === "insert"
|
|
353
|
+
? `insert:${span.boundary}:${span.replacement}`
|
|
354
|
+
: `replace:${span.start}:${span.end}:${span.replacement}`;
|
|
355
|
+
if (seenSpanKeys.has(spanKey)) {
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
seenSpanKeys.add(spanKey);
|
|
359
|
+
resolvedSpans.push(span);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
assertNoConflictingSpans(resolvedSpans);
|
|
363
|
+
|
|
364
|
+
return [...resolvedSpans].sort((left, right) => {
|
|
365
|
+
if (right.end !== left.end) {
|
|
366
|
+
return right.end - left.end;
|
|
367
|
+
}
|
|
368
|
+
if (left.kind !== right.kind) {
|
|
369
|
+
return left.kind === "replace" ? -1 : 1;
|
|
370
|
+
}
|
|
371
|
+
if (left.kind === "insert" && right.kind === "insert") {
|
|
372
|
+
return (
|
|
373
|
+
(right.boundary ?? -1) - (left.boundary ?? -1) ||
|
|
374
|
+
left.index - right.index
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
return left.index - right.index;
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Apply ordered spans to content in reverse (back-to-front) order so earlier
|
|
383
|
+
* spans' offsets stay valid.
|
|
384
|
+
*/
|
|
385
|
+
function assembleEditResult(
|
|
386
|
+
content: string,
|
|
387
|
+
spans: ResolvedEditSpan[],
|
|
388
|
+
signal: AbortSignal | undefined,
|
|
389
|
+
): string {
|
|
390
|
+
let result = content;
|
|
391
|
+
for (const span of spans) {
|
|
392
|
+
throwIfAborted(signal);
|
|
393
|
+
const replacement =
|
|
394
|
+
span.insertMode === "append-empty-origin"
|
|
395
|
+
? result.length === 0
|
|
396
|
+
? span.replacement
|
|
397
|
+
: `\n${span.replacement}`
|
|
398
|
+
: span.insertMode === "prepend-empty-origin"
|
|
399
|
+
? result.length === 0
|
|
400
|
+
? span.replacement
|
|
401
|
+
: `${span.replacement}\n`
|
|
402
|
+
: span.replacement;
|
|
403
|
+
result =
|
|
404
|
+
result.slice(0, span.start) + replacement + result.slice(span.end);
|
|
405
|
+
}
|
|
406
|
+
return result;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// ─── Main edit engine ───────────────────────────────────────────────────
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Apply hashline-anchored edits to file content.
|
|
413
|
+
*
|
|
414
|
+
* Three-phase pipeline:
|
|
415
|
+
* 1. validateAnchorEdits — resolve each hash to a line; mismatches are
|
|
416
|
+
* rejected with `[E_STALE_ANCHOR]` and collisions with
|
|
417
|
+
* `[E_AMBIGUOUS_ANCHOR]`
|
|
418
|
+
* 2. resolveEditSpans — map edits to character spans, dedup, conflict-detect, sort
|
|
419
|
+
* 3. assembleEditResult — apply spans back-to-front, compute changed range
|
|
420
|
+
*
|
|
421
|
+
* `precomputedHashes` is an optional per-line hash array from
|
|
422
|
+
* `computeLineHashes(content)`. When provided, the same array is used for
|
|
423
|
+
* validation AND for the stale-anchor retry block in mismatch errors, so
|
|
424
|
+
* the hashes the model sees on a stale-anchor failure match the hashes the
|
|
425
|
+
* runtime actually validated against. When omitted, hashes are computed
|
|
426
|
+
* once at the top of this function and threaded through all phases.
|
|
427
|
+
*/
|
|
428
|
+
export function applyHashlineEdits(
|
|
429
|
+
content: string,
|
|
430
|
+
edits: import("./resolve").HashlineEdit[],
|
|
431
|
+
signal?: AbortSignal,
|
|
432
|
+
precomputedHashes?: string[],
|
|
433
|
+
filePath?: string,
|
|
434
|
+
): {
|
|
435
|
+
content: string;
|
|
436
|
+
firstChangedLine: number | undefined;
|
|
437
|
+
lastChangedLine: number | undefined;
|
|
438
|
+
warnings?: string[];
|
|
439
|
+
noopEdits?: NoopEdit[];
|
|
440
|
+
} {
|
|
441
|
+
throwIfAborted(signal);
|
|
442
|
+
if (!edits.length)
|
|
443
|
+
return {
|
|
444
|
+
content,
|
|
445
|
+
firstChangedLine: undefined,
|
|
446
|
+
lastChangedLine: undefined,
|
|
447
|
+
};
|
|
448
|
+
|
|
449
|
+
// Normalize `replace` edits: a single-element `lines: [""]` is equivalent
|
|
450
|
+
// to `lines: []` (deletion). The "non-empty lines" span branch preserves
|
|
451
|
+
// the trailing newline of the last replaced line, which would leave an
|
|
452
|
+
// extra blank line behind when the user meant to delete. Models commonly
|
|
453
|
+
// emit `[""]` to mean "delete this", and the deletion branch handles the
|
|
454
|
+
// trailing newline correctly. (`append`/`prepend` are unaffected — there
|
|
455
|
+
// `[""]` legitimately means "insert a blank line".)
|
|
456
|
+
edits = edits.map((edit) =>
|
|
457
|
+
edit.op === "replace" &&
|
|
458
|
+
edit.lines.length === 1 &&
|
|
459
|
+
edit.lines[0] === ""
|
|
460
|
+
? { ...edit, lines: [] }
|
|
461
|
+
: edit,
|
|
462
|
+
);
|
|
463
|
+
|
|
464
|
+
const lineIndex = buildLineIndex(content);
|
|
465
|
+
const fileHashes = precomputedHashes ?? computeLineHashes(content);
|
|
466
|
+
const noopEdits: NoopEdit[] = [];
|
|
467
|
+
const warnings: string[] = [];
|
|
468
|
+
|
|
469
|
+
// Phase 1: validate anchors (and resolve to line numbers)
|
|
470
|
+
const { resolved, mismatches } = validateAnchorEdits(
|
|
471
|
+
edits,
|
|
472
|
+
lineIndex.fileLines,
|
|
473
|
+
fileHashes,
|
|
474
|
+
warnings,
|
|
475
|
+
signal,
|
|
476
|
+
);
|
|
477
|
+
if (mismatches.length) {
|
|
478
|
+
throw new Error(
|
|
479
|
+
formatMismatchError(mismatches, lineIndex.fileLines, fileHashes),
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
const barePrefixWarnings = assertNoBareHashPrefixLines(edits, lineIndex.fileLines, fileHashes, filePath);
|
|
484
|
+
warnings.push(...barePrefixWarnings);
|
|
485
|
+
maybeWarnSuspiciousUnicodeEscapePlaceholder(edits, warnings);
|
|
486
|
+
|
|
487
|
+
// Phase 2: resolve edits to ordered spans
|
|
488
|
+
const orderedSpans = resolveEditSpans(
|
|
489
|
+
resolved,
|
|
490
|
+
content,
|
|
491
|
+
lineIndex,
|
|
492
|
+
noopEdits,
|
|
493
|
+
signal,
|
|
494
|
+
);
|
|
495
|
+
|
|
496
|
+
// Phase 3: assemble result
|
|
497
|
+
const result = assembleEditResult(content, orderedSpans, signal);
|
|
498
|
+
assertDoesNotEmptyFile(content, result);
|
|
499
|
+
const changedRange = computeChangedLineRange(content, result);
|
|
500
|
+
|
|
501
|
+
return {
|
|
502
|
+
content: result,
|
|
503
|
+
firstChangedLine: changedRange?.firstChangedLine,
|
|
504
|
+
lastChangedLine: changedRange?.lastChangedLine,
|
|
505
|
+
...(warnings.length ? { warnings } : {}),
|
|
506
|
+
...(noopEdits.length ? { noopEdits } : {}),
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// ─── Affected-line computation (for returning anchors after edit) ───────
|
|
511
|
+
|
|
512
|
+
const ANCHOR_CONTEXT_LINES = 2;
|
|
513
|
+
const ANCHOR_MAX_OUTPUT_LINES = 12;
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Compute the post-edit line range covering changed lines plus context.
|
|
517
|
+
* Uses `firstChangedLine` and `lastChangedLine` from the edit result for
|
|
518
|
+
* precise bounds. Returns null if the range (with context) exceeds the
|
|
519
|
+
* output budget, signalling that the LLM should re-read instead.
|
|
520
|
+
*/
|
|
521
|
+
export function computeAffectedLineRange(params: {
|
|
522
|
+
firstChangedLine: number | undefined;
|
|
523
|
+
lastChangedLine: number | undefined;
|
|
524
|
+
resultLineCount: number;
|
|
525
|
+
contextLines?: number;
|
|
526
|
+
maxOutputLines?: number;
|
|
527
|
+
}): { start: number; end: number } | null {
|
|
528
|
+
const {
|
|
529
|
+
firstChangedLine,
|
|
530
|
+
lastChangedLine,
|
|
531
|
+
resultLineCount,
|
|
532
|
+
contextLines = ANCHOR_CONTEXT_LINES,
|
|
533
|
+
maxOutputLines = ANCHOR_MAX_OUTPUT_LINES,
|
|
534
|
+
} = params;
|
|
535
|
+
|
|
536
|
+
if (firstChangedLine === undefined || lastChangedLine === undefined) {
|
|
537
|
+
return null;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// Empty file after edit: no meaningful anchor block.
|
|
541
|
+
if (resultLineCount === 0) {
|
|
542
|
+
return null;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
const start = Math.max(1, firstChangedLine - contextLines);
|
|
546
|
+
const end = Math.min(resultLineCount, lastChangedLine + contextLines);
|
|
547
|
+
|
|
548
|
+
// Guard against inverted range (can happen when context pushes end below start).
|
|
549
|
+
if (end < start) {
|
|
550
|
+
return null;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
if (end - start + 1 > maxOutputLines) {
|
|
554
|
+
return null;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
return { start, end };
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* Format a list of lines as `HASH:content` rows.
|
|
562
|
+
*
|
|
563
|
+
* Used by the read tool's preview and the changed-mode anchor block. The
|
|
564
|
+
* hashes must be the precomputed per-line hashes for the file — see
|
|
565
|
+
* `computeLineHashes`. The line number is no longer part of the wire
|
|
566
|
+
* format; callers that need line numbers for pagination or context can
|
|
567
|
+
* compute them separately.
|
|
568
|
+
*/
|
|
569
|
+
export function formatHashlineRegion(
|
|
570
|
+
hashes: string[],
|
|
571
|
+
lines: string[],
|
|
572
|
+
): string {
|
|
573
|
+
if (hashes.length !== lines.length) {
|
|
574
|
+
throw new Error(
|
|
575
|
+
`formatHashlineRegion: hashes.length (${hashes.length}) must match lines.length (${lines.length}).`,
|
|
576
|
+
);
|
|
577
|
+
}
|
|
578
|
+
return lines
|
|
579
|
+
.map((line, index) => `${hashes[index]}:${line}`)
|
|
580
|
+
.join("\n");
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// ─── Changed line range computation ─────────────────────────────────
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* Compute first/last changed line numbers between two document versions.
|
|
587
|
+
* Uses character-level diff to locate the changed span, then maps to line
|
|
588
|
+
* numbers in the result document so downstream anchor chaining works.
|
|
589
|
+
*/
|
|
590
|
+
export function computeChangedLineRange(
|
|
591
|
+
original: string,
|
|
592
|
+
result: string,
|
|
593
|
+
): { firstChangedLine: number; lastChangedLine: number } | null {
|
|
594
|
+
if (original === result) return null;
|
|
595
|
+
|
|
596
|
+
function countVisibleLines(text: string): number {
|
|
597
|
+
if (text.length === 0) {
|
|
598
|
+
return 0;
|
|
599
|
+
}
|
|
600
|
+
const lines = text.split("\n");
|
|
601
|
+
return text.endsWith("\n") ? lines.length - 1 : lines.length;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
if (original.length === 0) {
|
|
605
|
+
return {
|
|
606
|
+
firstChangedLine: 1,
|
|
607
|
+
lastChangedLine: countVisibleLines(result),
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
if (result.startsWith(original) && original.endsWith("\n")) {
|
|
612
|
+
return {
|
|
613
|
+
firstChangedLine: countVisibleLines(original) + 1,
|
|
614
|
+
lastChangedLine: countVisibleLines(result),
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
let firstDiff = 0;
|
|
619
|
+
const minLen = Math.min(original.length, result.length);
|
|
620
|
+
while (firstDiff < minLen && original[firstDiff] === result[firstDiff]) {
|
|
621
|
+
firstDiff++;
|
|
622
|
+
}
|
|
623
|
+
if (firstDiff === minLen && original.length === result.length) return null;
|
|
624
|
+
|
|
625
|
+
let lastOrig = original.length - 1;
|
|
626
|
+
let lastRes = result.length - 1;
|
|
627
|
+
while (
|
|
628
|
+
lastOrig >= firstDiff &&
|
|
629
|
+
lastRes >= firstDiff &&
|
|
630
|
+
original[lastOrig] === result[lastRes]
|
|
631
|
+
) {
|
|
632
|
+
lastOrig--;
|
|
633
|
+
lastRes--;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
function indexToLine(charIdx: number, text: string): number {
|
|
637
|
+
let line = 1;
|
|
638
|
+
for (let i = 0; i < charIdx && i < text.length; i++) {
|
|
639
|
+
if (text[i] === "\n") line++;
|
|
640
|
+
}
|
|
641
|
+
return line;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
const firstChangedLine = indexToLine(firstDiff + 1, result);
|
|
645
|
+
let lastChangedLine: number;
|
|
646
|
+
if (lastRes < firstDiff) {
|
|
647
|
+
lastChangedLine = result.length === 0 ? 1 : countVisibleLines(result);
|
|
648
|
+
} else if (
|
|
649
|
+
firstDiff === 0 &&
|
|
650
|
+
original.length > 0 &&
|
|
651
|
+
result.endsWith(original)
|
|
652
|
+
) {
|
|
653
|
+
lastChangedLine = firstChangedLine;
|
|
654
|
+
} else {
|
|
655
|
+
lastChangedLine = indexToLine(lastRes + 1, result);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
return { firstChangedLine, lastChangedLine };
|
|
659
|
+
}
|
|
660
|
+
|