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