decorated-pi 0.3.0 → 0.4.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.
@@ -11,7 +11,7 @@
11
11
 
12
12
  import * as fs from "node:fs";
13
13
  import * as path from "node:path";
14
- import * as os from "node:os";
14
+ import * as fsPromises from "node:fs/promises";
15
15
 
16
16
  // ═══════════════════════════════════════════════════════════════════════════
17
17
  // Types
@@ -45,6 +45,8 @@ export interface PatchResult {
45
45
  replacements: Map<string, ReplacementInfo[]>;
46
46
  /** Original file lines per file, for diff context generation */
47
47
  originalLines: Map<string, string[]>;
48
+ /** Pre-generated diff string (set by applyEdits to avoid re-reading files) */
49
+ diff: string;
48
50
  }
49
51
 
50
52
  /** Records a single old_str→new_str replacement within a file */
@@ -63,6 +65,8 @@ export interface ReplacementInfo {
63
65
  newLines: string[];
64
66
  /** Optional anchor text (first line only, for hunk display) */
65
67
  anchor?: string;
68
+ /** Anchor was provided but not found, and patch fell back to global old_str search */
69
+ anchorMissing?: boolean;
66
70
  }
67
71
 
68
72
  // ═══════════════════════════════════════════════════════════════════════════
@@ -90,6 +94,7 @@ export async function applyPatch(patch: FilePatch, cwd: string): Promise<PatchRe
90
94
  warnings: [],
91
95
  replacements: new Map(),
92
96
  originalLines: new Map(),
97
+ diff: "",
93
98
  };
94
99
 
95
100
  const absPath = resolveAbsPath(cwd, patch.path);
@@ -119,6 +124,7 @@ export async function applyPatches(patches: FilePatch[], cwd: string): Promise<P
119
124
  warnings: [],
120
125
  replacements: new Map(),
121
126
  originalLines: new Map(),
127
+ diff: "",
122
128
  };
123
129
 
124
130
  for (const p of patches) {
@@ -185,82 +191,106 @@ async function applyEdits(
185
191
  }
186
192
 
187
193
  const rawContent = fs.readFileSync(absPath, "utf8");
188
- // Store original lines for diff context generation later
189
- const origLines = rawContent.split("\n");
190
- // Remove trailing empty line from split (trailing newline), but keep lines for content-less files
191
- if (origLines.length > 1 && origLines[origLines.length - 1] === "") origLines.pop();
192
- result.originalLines.set(displayPath, origLines);
193
-
194
194
  const lineEnding = detectLineEnding(rawContent);
195
195
  let content = normalizeLineEndings(rawContent);
196
196
 
197
197
  // Precompute line offsets for O(log n) line number lookups
198
- const rawLineOffsets = buildLineOffsets(rawContent);
198
+ const lineOffsets = buildLineOffsets(rawContent);
199
+ const totalLines = lineOffsets.length - 1;
199
200
 
200
201
  // Track cumulative offset for mapping current positions back to original
201
202
  let cumulativeOffset = 0;
202
203
  const replacements: ReplacementInfo[] = [];
204
+ const neededRanges: LineRange[] = [];
203
205
 
204
206
  for (const edit of edits) {
205
207
  if (!edit.old_str) {
206
208
  throw new ApplyError(`old_str must not be empty in ${displayPath}.`);
207
209
  }
208
210
 
209
- const oldNorm = normalizeLineEndings(edit.old_str);
210
- const newNorm = normalizeLineEndings(edit.new_str);
211
+ let oldNorm = normalizeLineEndings(edit.old_str);
212
+ let newNorm = normalizeLineEndings(edit.new_str);
211
213
 
212
214
  // Determine search range
213
215
  let searchFrom = 0;
216
+ let displayAnchor: string | undefined;
217
+ let anchorMissing = false;
218
+ let anchorNotFoundMessage: string | undefined;
214
219
 
215
220
  if (edit.anchor) {
216
221
  const anchorNorm = normalizeLineEndings(edit.anchor);
217
222
 
218
- // Find anchor — must be unique
223
+ // Find anchor — must be unique when present
219
224
  const anchorIdx = content.indexOf(anchorNorm);
220
225
  if (anchorIdx === -1) {
221
- throw new ApplyError(
222
- `Anchor not found in ${displayPath}: "${truncate(edit.anchor)}".`
223
- );
224
- }
225
- // Check uniqueness
226
- const secondAnchor = content.indexOf(anchorNorm, anchorIdx + 1);
227
- if (secondAnchor !== -1) {
228
- throw new ApplyError(
229
- `Anchor is not unique in ${displayPath}: "${truncate(edit.anchor)}" ` +
230
- `found at multiple locations. Choose a more specific anchor.`
231
- );
226
+ anchorNotFoundMessage = `Anchor not found in ${displayPath}: "${truncate(edit.anchor)}".`;
227
+ } else {
228
+ const secondAnchor = content.indexOf(anchorNorm, anchorIdx + 1);
229
+ if (secondAnchor !== -1) {
230
+ anchorNotFoundMessage = `Anchor is not unique in ${displayPath}: "${truncate(edit.anchor)}".`;
231
+ } else {
232
+ searchFrom = Math.max(0, anchorIdx - (oldNorm.length - 1));
233
+ displayAnchor = edit.anchor;
234
+ anchorMissing = false;
235
+ }
232
236
  }
233
-
234
- // Search from anchor start position onward (anchor narrows search range)
235
- // old_str may start at anchor position (anchor is a prefix of old_str)
236
- searchFrom = anchorIdx;
237
237
  }
238
238
 
239
239
  // Find old_str in range — must be unique
240
- const matchIdx = content.indexOf(oldNorm, searchFrom);
240
+ let matchIdx = anchorNotFoundMessage ? -1 : content.indexOf(oldNorm, searchFrom);
241
+ if (matchIdx === -1 && anchorNotFoundMessage) {
242
+ // Anchor was missing/unusable — try global exact match first
243
+ displayAnchor = edit.anchor;
244
+ anchorMissing = true;
245
+ matchIdx = content.indexOf(oldNorm, 0);
246
+ if (matchIdx !== -1) {
247
+ const secondGlobalMatch = content.indexOf(oldNorm, matchIdx + 1);
248
+ if (secondGlobalMatch !== -1) {
249
+ const dupDiag = diagnoseOldStrNotUnique(oldNorm, content);
250
+ throw new ApplyError(`${anchorNotFoundMessage}\n${dupDiag}`);
251
+ }
252
+ }
253
+ }
254
+
241
255
  if (matchIdx === -1) {
242
- throw new ApplyError(
243
- `old_str not found in ${displayPath}` +
244
- (edit.anchor ? ` after anchor "${truncate(edit.anchor)}"` : "") +
245
- `: "${truncate(edit.old_str)}". ` +
246
- `The file may have changed — re-read it and try again.`
247
- );
256
+ // Fuzzy match fallback: normalize tab↔space + trailing whitespace
257
+ const searchLine = searchFrom === 0 ? 0 : content.substring(0, searchFrom).split("\n").length - 1;
258
+ const fuzzy = tryFuzzyLineMatch(oldNorm, content, searchLine);
259
+ if (fuzzy) {
260
+ oldNorm = fuzzy.matched;
261
+ matchIdx = fuzzy.idx;
262
+ newNorm = normalizeIndentForFuzzy(fuzzy.matched.split("\n")[0] ?? "", newNorm);
263
+ } else if (anchorNotFoundMessage) {
264
+ const diag = diagnoseOldStrMismatch(oldNorm, content);
265
+ throw new ApplyError(
266
+ `${anchorNotFoundMessage}\nold_str not found in ${displayPath}: "${truncate(edit.old_str)}".\n${diag}`
267
+ );
268
+ } else {
269
+ const diag = diagnoseOldStrMismatch(oldNorm, content);
270
+ throw new ApplyError(
271
+ `old_str not found in ${displayPath}` +
272
+ (edit.anchor ? ` after anchor "${truncate(edit.anchor)}"` : "") +
273
+ `: "${truncate(edit.old_str)}".\n${diag}`
274
+ );
275
+ }
248
276
  }
249
277
 
250
- // Check uniqueness
251
- const secondMatch = content.indexOf(oldNorm, matchIdx + 1);
252
- if (secondMatch !== -1) {
253
- throw new ApplyError(
254
- `old_str is not unique in ${displayPath}: "${truncate(edit.old_str)}". ` +
255
- `Add more context to old_str or use an anchor to narrow the search.`
256
- );
278
+ // Check uniqueness in anchor-narrowed / plain search path only when anchor was used normally
279
+ if (!anchorNotFoundMessage) {
280
+ const secondMatch = content.indexOf(oldNorm, matchIdx + 1);
281
+ if (secondMatch !== -1) {
282
+ const dupDiag = diagnoseOldStrNotUnique(oldNorm, content);
283
+ throw new ApplyError(
284
+ `${dupDiag}`
285
+ );
286
+ }
257
287
  }
258
288
 
259
289
  // Compute line numbers in the original file for diff generation (O(log n) via binary search)
260
290
  // matchIdx is in the modified content; subtract cumulative offset to map back to original
261
291
  const origMatchIdx = matchIdx - cumulativeOffset;
262
- const oldStartLine = lineAtOffset(rawLineOffsets, origMatchIdx);
263
- const oldEndLine = lineAtOffset(rawLineOffsets, origMatchIdx + oldNorm.length - 1);
292
+ const oldStartLine = lineAtOffset(lineOffsets, origMatchIdx);
293
+ const oldEndLine = lineAtOffset(lineOffsets, origMatchIdx + oldNorm.length - 1);
264
294
 
265
295
  // Apply replacement
266
296
  content =
@@ -283,10 +313,36 @@ async function applyEdits(
283
313
  newEndLine,
284
314
  oldLines: oldNorm.split("\n").filter((l, i, arr) => !(i === arr.length - 1 && l === "")),
285
315
  newLines: newNorm.split("\n").filter((l, i, arr) => !(i === arr.length - 1 && l === "")),
286
- anchor: edit.anchor ? edit.anchor.split("\n")[0] : undefined,
316
+ anchor: displayAnchor ? displayAnchor.split("\n")[0] : undefined,
317
+ anchorMissing,
318
+ });
319
+
320
+ // Collect context range for this edit
321
+ neededRanges.push({
322
+ startLine: Math.max(1, oldStartLine - CONTEXT_LINES),
323
+ endLine: Math.min(totalLines, oldEndLine + CONTEXT_LINES),
287
324
  });
288
325
  }
289
326
 
327
+ // Generate diff using only needed context lines (no full-file split)
328
+ const mergedRanges = mergeRanges(neededRanges);
329
+ const currentLineOffsets = buildLineOffsets(content);
330
+ const neededLines: Map<number, string> = new Map();
331
+ for (const range of mergedRanges) {
332
+ const lines = extractLineRange(content, currentLineOffsets, range.startLine, range.endLine);
333
+ for (let i = 0; i < lines.length; i++) {
334
+ neededLines.set(range.startLine + i, lines[i]);
335
+ }
336
+ }
337
+
338
+ // Build diff for this file and append to result
339
+ const fileDiff = generateLocalDiff(displayPath, replacements, neededLines, totalLines);
340
+ if (result.diff) {
341
+ result.diff += "\n" + fileDiff;
342
+ } else {
343
+ result.diff = fileDiff;
344
+ }
345
+
290
346
  // Restore line endings
291
347
  const finalContent = restoreLineEndings(content, lineEnding);
292
348
 
@@ -334,47 +390,99 @@ export async function computePatchPreview(
334
390
  return { error: "File not found" };
335
391
  }
336
392
 
337
- const rawContent = fs.readFileSync(absPath, "utf8");
338
- const origLines = rawContent.split("\n");
339
- if (origLines.length > 1 && origLines[origLines.length - 1] === "") origLines.pop();
340
- let content = normalizeLineEndings(rawContent);
341
- const rawLineOffsets = buildLineOffsets(rawContent);
342
- const allReplacements: ReplacementInfo[] = [];
343
- let cumulativeOffset = 0;
393
+ const rawContent = await fsPromises.readFile(absPath, "utf8");
394
+ const lineOffsets = buildLineOffsets(rawContent);
395
+ const totalLines = lineOffsets.length - 1;
396
+ let content = normalizeLineEndings(rawContent);
397
+ const allReplacements: ReplacementInfo[] = [];
398
+ const neededRanges: LineRange[] = [];
399
+ let cumulativeOffset = 0;
344
400
 
345
401
  for (const edit of patch.edits) {
346
402
  if (!edit.old_str) continue;
347
- const oldNorm = normalizeLineEndings(edit.old_str);
348
- const newNorm = normalizeLineEndings(edit.new_str);
403
+ let oldNorm = normalizeLineEndings(edit.old_str);
404
+ let newNorm = normalizeLineEndings(edit.new_str);
349
405
 
350
406
  let searchFrom = 0;
407
+ let displayAnchor: string | undefined;
408
+ let anchorMissing = false;
409
+ let anchorNotFoundMessage: string | undefined;
351
410
  if (edit.anchor) {
352
411
  const anchorNorm = normalizeLineEndings(edit.anchor);
353
412
  const idx = content.indexOf(anchorNorm);
354
413
  if (idx === -1) {
355
- return { error: `Anchor not found: "${truncate(edit.anchor)}"` };
414
+ anchorNotFoundMessage = `Anchor not found: "${truncate(edit.anchor)}"`;
415
+ } else {
416
+ const secondAnchor = content.indexOf(anchorNorm, idx + 1);
417
+ if (secondAnchor !== -1) {
418
+ anchorNotFoundMessage = `Anchor is not unique: "${truncate(edit.anchor)}"`;
419
+ } else {
420
+ searchFrom = Math.max(0, idx - (oldNorm.length - 1));
421
+ displayAnchor = edit.anchor;
422
+ anchorMissing = false;
423
+ }
424
+ }
425
+ }
426
+
427
+ let matchIdx = anchorNotFoundMessage ? -1 : content.indexOf(oldNorm, searchFrom);
428
+ if (matchIdx === -1 && anchorNotFoundMessage) {
429
+ displayAnchor = edit.anchor;
430
+ anchorMissing = true;
431
+ matchIdx = content.indexOf(oldNorm, 0);
432
+ if (matchIdx !== -1) {
433
+ const secondGlobalMatch = content.indexOf(oldNorm, matchIdx + 1);
434
+ if (secondGlobalMatch !== -1) {
435
+ const dupDiag = diagnoseOldStrNotUnique(oldNorm, content);
436
+ return { error: `${anchorNotFoundMessage}\n${dupDiag}` };
437
+ }
356
438
  }
357
- searchFrom = idx;
358
439
  }
359
440
 
360
- const matchIdx = content.indexOf(oldNorm, searchFrom);
361
441
  if (matchIdx === -1) {
362
- return { error: `old_str not found: "${truncate(edit.old_str)}"` };
442
+ const searchLine = 0;
443
+ const fuzzy = tryFuzzyLineMatch(oldNorm, content, searchLine);
444
+ if (fuzzy) {
445
+ oldNorm = fuzzy.matched;
446
+ matchIdx = fuzzy.idx;
447
+ newNorm = normalizeIndentForFuzzy(fuzzy.matched.split("\n")[0] ?? "", newNorm);
448
+ } else if (anchorNotFoundMessage) {
449
+ const diag = diagnoseOldStrMismatch(oldNorm, content);
450
+ return { error: `${anchorNotFoundMessage}\nold_str not found: "${truncate(edit.old_str)}"\n${diag}` };
451
+ } else {
452
+ const diag = diagnoseOldStrMismatch(oldNorm, content);
453
+ return { error: `old_str not found: "${truncate(edit.old_str)}".\n${diag}` };
454
+ }
363
455
  }
364
456
 
365
457
  const origMatchIdx = matchIdx - cumulativeOffset;
366
- const oldStartLine = lineAtOffset(rawLineOffsets, origMatchIdx);
367
- const oldEndLine = lineAtOffset(rawLineOffsets, origMatchIdx + oldNorm.length - 1);
458
+ const oldStartLine = lineAtOffset(lineOffsets, origMatchIdx);
459
+ const oldEndLine = lineAtOffset(lineOffsets, origMatchIdx + oldNorm.length - 1);
368
460
  const oldLines = oldNorm.split("\n").filter((l, i, arr) => !(i === arr.length - 1 && l === ""));
369
461
  const newLines = newNorm.split("\n").filter((l, i, arr) => !(i === arr.length - 1 && l === ""));
370
462
  content = content.substring(0, matchIdx) + newNorm + content.substring(matchIdx + oldNorm.length);
371
463
  const newStartLine = charOffsetToLine(content, matchIdx);
372
464
  const newEndLine = charOffsetToLine(content, matchIdx + newNorm.length - 1);
373
- allReplacements.push({ oldStartLine, oldEndLine, newStartLine, newEndLine, oldLines, newLines, anchor: edit.anchor ? edit.anchor.split("\n")[0] : undefined });
465
+ // Record needed context range around this edit
466
+ neededRanges.push({
467
+ startLine: Math.max(1, oldStartLine - CONTEXT_LINES),
468
+ endLine: Math.min(totalLines, oldEndLine + CONTEXT_LINES),
469
+ });
470
+ allReplacements.push({ oldStartLine, oldEndLine, newStartLine, newEndLine, oldLines, newLines, anchor: displayAnchor ? displayAnchor.split("\n")[0] : undefined, anchorMissing });
374
471
  cumulativeOffset += newNorm.length - oldNorm.length;
375
472
  }
376
473
 
377
- const diff = generateReplacementDiff(patch.path, allReplacements, origLines);
474
+ // Merge needed ranges and extract only those lines
475
+ const mergedRanges = mergeRanges(neededRanges);
476
+ const currentLineOffsets = buildLineOffsets(content);
477
+ const neededLines: Map<number, string> = new Map();
478
+ for (const range of mergedRanges) {
479
+ const lines = extractLineRange(content, currentLineOffsets, range.startLine, range.endLine);
480
+ for (let i = 0; i < lines.length; i++) {
481
+ neededLines.set(range.startLine + i, lines[i]);
482
+ }
483
+ }
484
+
485
+ const diff = generateLocalDiff(patch.path, allReplacements, neededLines, totalLines);
378
486
  return { diff };
379
487
  } else {
380
488
  return { error: "Must provide edits[] or overwrite:true" };
@@ -400,6 +508,12 @@ export async function computePatchPreviewMulti(
400
508
 
401
509
 
402
510
  export function generatePatchDiff(result: PatchResult): string {
511
+ // If applyEdits pre-generated the diff, use it directly (avoids re-reading files)
512
+ if (result.diff) {
513
+ return result.diff;
514
+ }
515
+
516
+ // Fallback: reconstruct diff from stored originalLines (legacy path)
403
517
  const parts: string[] = [];
404
518
  for (const [filePath, reps] of result.replacements) {
405
519
  const origLines = result.originalLines.get(filePath) ?? [];
@@ -438,14 +552,29 @@ function buildReplacementChunks(
438
552
  return chunks;
439
553
  }
440
554
 
441
- function getChunkAnchors(chunk: ReplacementChunk): string[] {
442
- return [
443
- ...new Set(
444
- chunk.reps
445
- .map((rep) => rep.anchor?.trim())
446
- .filter((anchor): anchor is string => Boolean(anchor)),
447
- ),
448
- ];
555
+ interface ChunkAnchor {
556
+ text: string;
557
+ missing: boolean;
558
+ }
559
+
560
+ function getChunkAnchors(chunk: ReplacementChunk): ChunkAnchor[] {
561
+ const byText = new Map<string, ChunkAnchor>();
562
+ for (const rep of chunk.reps) {
563
+ const text = rep.anchor?.trim();
564
+ if (!text) continue;
565
+ const existing = byText.get(text);
566
+ if (!existing) {
567
+ byText.set(text, { text, missing: Boolean(rep.anchorMissing) });
568
+ } else if (!rep.anchorMissing) {
569
+ // If any replacement successfully used this anchor, do not mark it missing.
570
+ existing.missing = false;
571
+ }
572
+ }
573
+ return [...byText.values()];
574
+ }
575
+
576
+ function formatAnchorLabel(anchor: ChunkAnchor): string {
577
+ return anchor.text + (anchor.missing ? " (missing)" : "");
449
578
  }
450
579
 
451
580
  function formatChunkHeader(chunk: ReplacementChunk): string {
@@ -459,7 +588,7 @@ function formatChunkHeader(chunk: ReplacementChunk): string {
459
588
  }
460
589
 
461
590
  if (anchors.length === 1) {
462
- return `@@ lines ${range} @@ anchor: ${anchors[0]}`;
591
+ return `@@ lines ${range} @@ anchor: ${formatAnchorLabel(anchors[0]!)}`;
463
592
  }
464
593
 
465
594
  return `@@ lines ${range} @@`;
@@ -471,7 +600,7 @@ function formatChunkMetadataLines(chunk: ReplacementChunk): string[] {
471
600
 
472
601
  const shown = anchors.slice(0, 2);
473
602
  const remaining = anchors.length - shown.length;
474
- const lines = ["anchors:", ...shown.map((anchor) => ` - ${anchor}`)];
603
+ const lines = ["anchors:", ...shown.map((anchor) => ` - ${formatAnchorLabel(anchor)}`)];
475
604
  if (remaining > 0) {
476
605
  lines.push(` - +${remaining} more`);
477
606
  }
@@ -581,7 +710,18 @@ function restoreLineEndings(text: string, ending: string): string {
581
710
  return ending === "\r\n" ? text.replace(/\n/g, "\r\n") : text;
582
711
  }
583
712
 
584
- /** Precompute character offsets for each line start. offsets[i] = char offset of line i+1 (1-based). */
713
+ // ═══════════════════════════════════════════════════════════════════════════
714
+ // Line range utilities (for partial file reading)
715
+ // ═══════════════════════════════════════════════════════════════════════════
716
+
717
+ const CONTEXT_LINES = 3;
718
+
719
+ interface LineRange {
720
+ startLine: number;
721
+ endLine: number;
722
+ }
723
+
724
+ /** Build line offset table: offsets[i] = character offset of line i+1 (1-based) */
585
725
  function buildLineOffsets(content: string): number[] {
586
726
  const offsets = [0];
587
727
  for (let i = 0; i < content.length; i++) {
@@ -590,8 +730,8 @@ function buildLineOffsets(content: string): number[] {
590
730
  return offsets;
591
731
  }
592
732
 
593
- /** Binary search: find 1-based line number containing the given char offset.
594
- * Uses precomputed line offsets instead of scanning from the beginning. */
733
+
734
+ /** Binary search: find 1-based line number containing charOffset */
595
735
  function lineAtOffset(lineOffsets: number[], charOffset: number): number {
596
736
  let lo = 0, hi = lineOffsets.length - 1;
597
737
  while (lo < hi) {
@@ -599,10 +739,51 @@ function lineAtOffset(lineOffsets: number[], charOffset: number): number {
599
739
  if (lineOffsets[mid] <= charOffset) lo = mid;
600
740
  else hi = mid - 1;
601
741
  }
602
- return lo + 1; // 1-based
742
+ return lo + 1;
743
+ }
744
+
745
+ /** Binary search: find line start offset given 1-based line number */
746
+ function offsetAtLine(lineOffsets: number[], lineNum: number): number {
747
+ if (lineNum <= 1) return 0;
748
+ if (lineNum > lineOffsets.length) return lineOffsets[lineOffsets.length - 1];
749
+ return lineOffsets[lineNum - 1];
750
+ }
751
+
752
+ /** Extract a range of lines from content (1-based, inclusive) */
753
+ function extractLineRange(content: string, lineOffsets: number[], startLine: number, endLine: number): string[] {
754
+ const lines: string[] = [];
755
+ for (let i = startLine; i <= endLine; i++) {
756
+ const start = offsetAtLine(lineOffsets, i);
757
+ const end = offsetAtLine(lineOffsets, i + 1);
758
+ // Remove trailing \n from last line if present
759
+ const lineText = content.slice(start, end).replace(/\n$/, "");
760
+ lines.push(lineText);
761
+ }
762
+ return lines;
763
+ }
764
+
765
+
766
+ /** Merge overlapping/adjacent line ranges */
767
+ function mergeRanges(ranges: LineRange[]): LineRange[] {
768
+ if (ranges.length === 0) return [];
769
+ const sorted = [...ranges].sort((a, b) => a.startLine - b.startLine);
770
+ const merged: LineRange[] = [];
771
+ for (const r of sorted) {
772
+ const last = merged[merged.length - 1];
773
+ if (last && r.startLine <= last.endLine + 1) {
774
+ last.endLine = Math.max(last.endLine, r.endLine);
775
+ } else {
776
+ merged.push({ ...r });
777
+ }
778
+ }
779
+ return merged;
603
780
  }
604
781
 
605
- /** Convert a character offset to a 1-based line number. O(n) prefer lineAtOffset for repeated calls on same content. */
782
+ function randomId(): string {
783
+ return Math.random().toString(36).slice(2, 10);
784
+ }
785
+
786
+ /** Convert a character offset to a 1-based line number. */
606
787
  function charOffsetToLine(content: string, offset: number): number {
607
788
  let line = 1;
608
789
  for (let i = 0; i < offset && i < content.length; i++) {
@@ -611,8 +792,225 @@ function charOffsetToLine(content: string, offset: number): number {
611
792
  return line;
612
793
  }
613
794
 
614
- function randomId(): string {
615
- return Math.random().toString(36).slice(2, 10);
795
+ /**
796
+ * Generate diff using only the needed lines (partial file context).
797
+ */
798
+ function generateLocalDiff(
799
+ filePath: string,
800
+ reps: ReplacementInfo[],
801
+ neededLines: Map<number, string>,
802
+ totalLines: number,
803
+ ): string {
804
+ if (reps.length === 0) return "";
805
+
806
+ const parts: string[] = [];
807
+ parts.push(`--- ${filePath}`);
808
+ parts.push(`+++ ${filePath}`);
809
+
810
+ // Calculate dynamic width based on max line number
811
+ const maxLineNum = Math.max(totalLines, ...reps.map(r => r.oldEndLine));
812
+ const numWidth = String(maxLineNum).length;
813
+
814
+ // Merge replacement chunks
815
+ const chunks = buildReplacementChunks(reps, totalLines, CONTEXT_LINES);
816
+ for (let c = 0; c < chunks.length; c++) {
817
+ if (c > 0) parts.push("");
818
+ const chunk = chunks[c]!;
819
+ parts.push(formatChunkHeader(chunk));
820
+ parts.push(...formatChunkMetadataLines(chunk));
821
+
822
+ // Output context + removed + added
823
+ let cursor = chunk.startLine;
824
+ for (const rep of chunk.reps) {
825
+ // Context before this replacement
826
+ for (let i = cursor; i < rep.oldStartLine; i++) {
827
+ const lineText = neededLines.get(i);
828
+ if (lineText !== undefined) {
829
+ parts.push(` ${String(i).padStart(numWidth, " ")} ${lineText}`);
830
+ }
831
+ }
832
+ // Removed lines
833
+ for (let i = 0; i < rep.oldLines.length; i++) {
834
+ parts.push(`-${String(rep.oldStartLine + i).padStart(numWidth, " ")} ${rep.oldLines[i]}`);
835
+ }
836
+ // Added lines
837
+ for (let i = 0; i < rep.newLines.length; i++) {
838
+ parts.push(`+${String(rep.oldStartLine + i).padStart(numWidth, " ")} ${rep.newLines[i]}`);
839
+ }
840
+ cursor = rep.oldEndLine + 1;
841
+ }
842
+ // Trailing context
843
+ for (let i = cursor; i <= chunk.endLine; i++) {
844
+ const lineText = neededLines.get(i);
845
+ if (lineText !== undefined) {
846
+ parts.push(` ${String(i).padStart(numWidth, " ")} ${lineText}`);
847
+ }
848
+ }
849
+ }
850
+
851
+ return parts.join("\n");
852
+ }
853
+
854
+ // ─── old_str mismatch diagnostics ─────────────────────────────────────────
855
+
856
+ /** Detect tab width from the file by analyzing indentation columns of tab-only lines. */
857
+ function detectTabWidth(content: string): number {
858
+ const lines = content.split("\n");
859
+ const cols: number[] = [];
860
+ for (const line of lines) {
861
+ const nonTabIdx = line.search(/[^\t]/);
862
+ if (nonTabIdx === -1 || nonTabIdx === 0) continue;
863
+ cols.push(nonTabIdx);
864
+ }
865
+ if (cols.length < 2) return 0;
866
+ const diffs: number[] = [];
867
+ for (let i = 1; i < cols.length; i++) {
868
+ if (cols[i] === cols[i - 1] || cols[i]! > cols[i - 1]! + 8) continue;
869
+ diffs.push(cols[i]! - cols[i - 1]!);
870
+ }
871
+ if (diffs.length === 0) return 0;
872
+ const sorted = [...diffs].sort((a, b) => a - b);
873
+ const median = sorted[Math.floor(sorted.length / 2)]!;
874
+ return [2, 4, 8].reduce((best, w) => Math.abs(w - median) < Math.abs(best - median) ? w : best, 4);
875
+ }
876
+
877
+ export function diagnoseOldStrNotUnique(oldNorm: string, content: string): string {
878
+ const fileLines = content.split("\n");
879
+ const firstOldLine = (oldNorm.split("\n")[0] ?? "").trim();
880
+ const occurrences: number[] = [];
881
+ let idx = 0;
882
+ while ((idx = content.indexOf(oldNorm, idx)) !== -1) {
883
+ const lineNum = content.substring(0, idx).split("\n").length;
884
+ occurrences.push(lineNum);
885
+ idx++;
886
+ }
887
+ if (occurrences.length === 0) return "";
888
+ const shown = occurrences.slice(0, 5);
889
+ const extra = occurrences.length - shown.length;
890
+ const lines = shown.map((n) => ` line ${n}: "${(fileLines[n - 1] ?? "").replace(/\t/g, "\\t").slice(0, 60)}"`);
891
+ if (extra > 0) lines.push(` and ${extra} more occurrence(s)`);
892
+ return `old_str appears ${occurrences.length} times:\n${lines.join("\n")}\nAdd more surrounding context to make it unique.`;
893
+ }
894
+
895
+ /** Try fuzzy match: normalize tab↔space and trailing whitespace, then search line-by-line. */
896
+ function tryFuzzyLineMatch(
897
+ oldNorm: string,
898
+ content: string,
899
+ searchLineStart: number,
900
+ ): { idx: number; matched: string } | undefined {
901
+ const oldLines = oldNorm.split("\n");
902
+ const fileLines = content.split("\n");
903
+
904
+ const fuzzyEq = (fileLine: string, oldLine: string): boolean => {
905
+ if (fileLine === oldLine) return true;
906
+ for (const tw of [8, 4, 2]) {
907
+ if (fileLine.replace(/\t/g, " ".repeat(tw)) === oldLine.replace(/\t/g, " ".repeat(tw))) return true;
908
+ }
909
+ if (fileLine.replace(/[\t ]+$/, "") === oldLine.replace(/[\t ]+$/, "")) return true;
910
+ return false;
911
+ };
912
+
913
+ for (let i = searchLineStart; i <= fileLines.length - oldLines.length; i++) {
914
+ let ok = true;
915
+ for (let j = 0; j < oldLines.length; j++) {
916
+ if (!fuzzyEq(fileLines[i + j] ?? "", oldLines[j] ?? "")) { ok = false; break; }
917
+ }
918
+ if (ok) {
919
+ let idx = 0;
920
+ for (let k = 0; k < i; k++) idx += (fileLines[k] ?? "").length + 1;
921
+ const matched = oldLines.map((_, j) => fileLines[i + j]).join("\n");
922
+ // Check uniqueness in the fuzzy-matched range
923
+ const secondIdx = content.indexOf(matched, idx + 1);
924
+ if (secondIdx === -1) return { idx, matched };
925
+ }
926
+ }
927
+ return undefined;
928
+ }
929
+
930
+ /** Replace new_str's leading whitespace with the actual file line's leading whitespace style. */
931
+ function normalizeIndentForFuzzy(actualLine: string, newLine: string): string {
932
+ const actualLeading = actualLine.match(/^[\t ]*/)?.[0] ?? "";
933
+ const newLeading = newLine.match(/^[\t ]*/)?.[0] ?? "";
934
+ if (actualLeading === newLeading) return newLine;
935
+ return actualLeading + newLine.slice(newLeading.length);
936
+ }
937
+
938
+ export function diagnoseOldStrMismatch(oldNorm: string, content: string, isConfigFile?: boolean): string {
939
+ const oldLines = oldNorm.split("\n");
940
+ const fileLines = content.split("\n");
941
+ const firstOldLine = oldLines[0] ?? "";
942
+ const parts: string[] = [];
943
+
944
+ // Find the closest matching line in the file
945
+ let bestMatchIdx = -1;
946
+ let bestMatchType = "";
947
+
948
+ for (let i = 0; i < fileLines.length; i++) {
949
+ const fileLine = fileLines[i] ?? "";
950
+
951
+ if (fileLine === firstOldLine) {
952
+ bestMatchIdx = i;
953
+ bestMatchType = "";
954
+ break;
955
+ }
956
+
957
+ if (fileLine.replace(/\t/g, " ") === firstOldLine ||
958
+ fileLine.replace(/\t/g, " ") === firstOldLine ||
959
+ fileLine.replace(/\t/g, " ") === firstOldLine) {
960
+ bestMatchIdx = i;
961
+ bestMatchType = "tab vs space (file has tabs, old_str has spaces)";
962
+ break;
963
+ }
964
+
965
+ if (fileLine.replace(/[\t ]+$/, "") === firstOldLine.replace(/[\t ]+$/, "")) {
966
+ bestMatchIdx = i;
967
+ bestMatchType = "trailing whitespace mismatch";
968
+ break;
969
+ }
970
+
971
+ if (fileLine.toLowerCase() === firstOldLine.toLowerCase()) {
972
+ bestMatchIdx = i;
973
+ bestMatchType = "case mismatch";
974
+ break;
975
+ }
976
+
977
+ const trimmedOld = firstOldLine.trim();
978
+ if (trimmedOld.length > 3 && fileLine.includes(trimmedOld)) {
979
+ if (bestMatchIdx === -1) {
980
+ bestMatchIdx = i;
981
+ bestMatchType = "indent mismatch (content matches, whitespace differs)";
982
+ }
983
+ }
984
+ }
985
+
986
+ if (bestMatchIdx >= 0 && bestMatchType) {
987
+ parts.push(`Hint: ${bestMatchType} at line ${bestMatchIdx + 1}.`);
988
+ parts.push(` actual: ${JSON.stringify(fileLines[bestMatchIdx])}`);
989
+ parts.push(` expected: ${JSON.stringify(firstOldLine)}`);
990
+ } else if (bestMatchIdx >= 0) {
991
+ // First line matched, but full old_str block does not — find the first mismatching line
992
+ const oldArr = oldNorm.split("\n");
993
+ let mismatchLine = 0;
994
+ for (let j = 1; j < oldArr.length; j++) {
995
+ const fileLine = fileLines[bestMatchIdx + j] ?? "<EOF>";
996
+ const oldLine = oldArr[j] ?? "";
997
+ if (fileLine !== oldLine) {
998
+ mismatchLine = bestMatchIdx + j + 1;
999
+ parts.push(`Line ${bestMatchIdx + 1} matches, but diff at line ${mismatchLine}:`);
1000
+ parts.push(` actual: ${JSON.stringify(fileLine)}`);
1001
+ parts.push(` expected: ${JSON.stringify(oldLine)}`);
1002
+ break;
1003
+ }
1004
+ }
1005
+ if (mismatchLine === 0) {
1006
+ parts.push(`First line matches at line ${bestMatchIdx + 1}, but full ${oldArr.length}-line block does not.`);
1007
+ }
1008
+ } else if (firstOldLine.trim().length > 3) {
1009
+ parts.push(`Content "${firstOldLine.trim().slice(0, 60)}" not found anywhere in the file.`);
1010
+ parts.push(`File may have changed — re-read it and try again.`);
1011
+ }
1012
+
1013
+ return parts.join("\n");
616
1014
  }
617
1015
 
618
1016
  function truncate(s: string, maxLen = 60): string {