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.
@@ -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
+