pi-agent-edit 0.0.0-stage → 0.2.3

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/engine.ts ADDED
@@ -0,0 +1,1237 @@
1
+ /**
2
+ * pi-agent-edit — core engine (pure, no Pi dependency, unit-testable)
3
+ *
4
+ * Design principles:
5
+ * 1. Normalization is ONLY used to *locate* text. Untouched characters are never
6
+ * rewritten (fixes the built-in fuzzy path converting fullwidth CJK punctuation
7
+ * / curly quotes to ASCII on touched lines).
8
+ * 2. Match cascade: exact → trailing-whitespace → indentation-insensitive (with
9
+ * smart relative re-indent of newText) → loose (whitespace runs + Unicode/NFKC folding)
10
+ * → line-number-prefix stripped fallback (when models copy " 12│" from error output).
11
+ * 3. Every failure returns something the model can act on without re-reading the
12
+ * file: closest candidate with line numbers, all ambiguous locations, or a hint
13
+ * that the edit was already applied.
14
+ * 4. All edits in one call resolve against the ORIGINAL file and are applied
15
+ * atomically (all-or-nothing).
16
+ */
17
+
18
+ export type MatchLevel = "exact" | "trailing-ws" | "indent" | "loose" | "lines";
19
+
20
+ export interface EditSpec {
21
+ oldText?: string;
22
+ newText: string;
23
+ replaceAll?: boolean;
24
+ nearLine?: number;
25
+ startLine?: number;
26
+ endLine?: number;
27
+ afterLine?: number;
28
+ }
29
+
30
+ export interface Decoded {
31
+ text: string; // BOM stripped; CRLF → LF when the file is purely CRLF
32
+ bom: boolean;
33
+ eol: "\n" | "\r\n";
34
+ }
35
+
36
+ export interface AppliedRange {
37
+ editIndex: number;
38
+ level: MatchLevel;
39
+ start: number;
40
+ end: number;
41
+ replacement: string;
42
+ reindented?: { from: string; to: string };
43
+ strippedLinePrefixes?: boolean;
44
+ }
45
+
46
+ export interface Hunk {
47
+ editIndex: number;
48
+ level: MatchLevel;
49
+ oldStartLine: number;
50
+ newStartLine: number;
51
+ removed: string[];
52
+ added: string[];
53
+ before: string[];
54
+ after: string[];
55
+ }
56
+
57
+ export interface ApplyResult {
58
+ text: string;
59
+ ranges: AppliedRange[];
60
+ hunks: Hunk[];
61
+ added: number;
62
+ removed: number;
63
+ /** True when some hunk changed its line count, i.e. lines after it moved. */
64
+ linesShifted: boolean;
65
+ notes: string[];
66
+ }
67
+
68
+ export interface ApplyOptions {
69
+ /**
70
+ * Whether line numbers the model remembers can be trusted (fresh read, no shift
71
+ * since). When false, nearLine is not used to pick among several matches.
72
+ * Defaults to true.
73
+ */
74
+ lineNumbersTrusted?: boolean;
75
+ /** Human-readable reason shown when nearLine is refused. */
76
+ untrustedReason?: string;
77
+ /**
78
+ * Line numbers a previous ambiguity error showed for THIS exact file content. A nearLine
79
+ * equal to one of them (and to an actual match) is accepted even when lineNumbersTrusted
80
+ * is false, so the model can pick an occurrence without re-reading.
81
+ */
82
+ offeredNearLines?: ReadonlySet<number>;
83
+ }
84
+
85
+ export class EditError extends Error {
86
+ /**
87
+ * Line numbers this error showed for ambiguous oldText matches. They were computed from
88
+ * the current file, so they are safe to use as nearLine while the file stays unchanged.
89
+ */
90
+ nearLineOffers?: number[];
91
+ }
92
+
93
+ // ───────────────────────────── encoding ─────────────────────────────
94
+
95
+ export function decode(raw: string): Decoded {
96
+ let bom = false;
97
+ if (raw.charCodeAt(0) === 0xfeff) {
98
+ bom = true;
99
+ raw = raw.slice(1);
100
+ }
101
+ let crlf = 0;
102
+ let lf = 0;
103
+ for (let i = 0; i < raw.length; i++) {
104
+ if (raw.charCodeAt(i) === 10) {
105
+ lf++;
106
+ if (i > 0 && raw.charCodeAt(i - 1) === 13) crlf++;
107
+ }
108
+ }
109
+ // Only normalize when the file is *purely* CRLF, so the round trip is lossless.
110
+ if (crlf > 0 && crlf === lf) {
111
+ return { text: raw.replace(/\r\n/g, "\n"), bom, eol: "\r\n" };
112
+ }
113
+ return { text: raw, bom, eol: "\n" };
114
+ }
115
+
116
+ export function encode(d: Decoded, text: string): string {
117
+ // decode() turned every CRLF into LF and resolveEdit() normalizes newText the same
118
+ // way, so every LF here maps back to exactly one CRLF. Using /\r?\n/ would also
119
+ // swallow a lone CR that precedes the LF ("x\r\r\n" → "x\r\n") on untouched lines.
120
+ const body = d.eol === "\r\n" ? text.replace(/\n/g, "\r\n") : text;
121
+ return (d.bom ? "\ufeff" : "") + body;
122
+ }
123
+
124
+ // ───────────────────────────── line index ─────────────────────────────
125
+
126
+ class LineIndex {
127
+ readonly lines: string[];
128
+ readonly starts: number[];
129
+ readonly count: number;
130
+
131
+ constructor(readonly text: string) {
132
+ if (text.length === 0) {
133
+ this.lines = [];
134
+ this.starts = [0];
135
+ this.count = 0;
136
+ return;
137
+ }
138
+ this.lines = text.split("\n");
139
+ this.starts = new Array(this.lines.length);
140
+ let p = 0;
141
+ for (let i = 0; i < this.lines.length; i++) {
142
+ this.starts[i] = p;
143
+ p += this.lines[i].length + 1;
144
+ }
145
+ // A trailing newline produces an empty last element that is not a real line.
146
+ this.count = text.endsWith("\n") && this.lines.length > 1 ? this.lines.length - 1 : this.lines.length;
147
+ }
148
+
149
+ /** 0-based line containing offset */
150
+ lineOf(off: number): number {
151
+ if (this.count === 0) return 0;
152
+ if (off <= 0) return 0;
153
+ if (off >= this.text.length) return this.count - 1;
154
+ let lo = 0;
155
+ let hi = this.count - 1;
156
+ while (lo < hi) {
157
+ const mid = (lo + hi + 1) >> 1;
158
+ if (this.starts[mid] <= off) lo = mid;
159
+ else hi = mid - 1;
160
+ }
161
+ return lo;
162
+ }
163
+
164
+ /** offset just past the last char of line i (i.e. position of its "\n" or text end) */
165
+ lineEnd(i: number): number {
166
+ if (this.count === 0) return 0;
167
+ return this.starts[i] + this.lines[i].length;
168
+ }
169
+
170
+ /** Line i is terminated by CRLF (only possible in mixed-EOL files, which are not decoded). */
171
+ isCrlfLine(i: number): boolean {
172
+ return i >= 0 && i < this.count && this.lines[i].endsWith("\r") && this.lineEnd(i) < this.text.length;
173
+ }
174
+
175
+ private _indent?: { style?: "tabs" | "spaces"; step?: number };
176
+ /** Dominant indentation style and step of the whole file (computed on demand). */
177
+ get indentInfo(): { style?: "tabs" | "spaces"; step?: number } {
178
+ if (!this._indent) {
179
+ const ws = this.lines.slice(0, this.count).filter((l) => l.trim() !== "").map(leadingWs);
180
+ this._indent = { style: wsStyle(ws), step: commonStep(ws) };
181
+ }
182
+ return this._indent;
183
+ }
184
+ }
185
+
186
+ // ───────────────────────────── normalizers ─────────────────────────────
187
+
188
+ const QUOTE_MAP: Record<string, string> = {
189
+ "\u2018": "'", "\u2019": "'", "\u201a": "'", "\u201b": "'", "\u2032": "'",
190
+ "\u201c": '"', "\u201d": '"', "\u201e": '"', "\u201f": '"', "\u2033": '"',
191
+ "\u2010": "-", "\u2011": "-", "\u2012": "-", "\u2013": "-", "\u2014": "-",
192
+ "\u2015": "-", "\u2212": "-",
193
+ "\u00a0": " ", "\u3000": " ", "\u2009": " ", "\u200a": " ", "\u202f": " ",
194
+ "\u200b": "", "\ufeff": "",
195
+ };
196
+
197
+ const FOLD_RE = /[\u2018\u2019\u201a\u201b\u2032\u201c\u201d\u201e\u201f\u2033\u2010-\u2015\u2212\u00a0\u3000\u2009\u200a\u202f\u200b\ufeff]/g;
198
+
199
+ function fold(s: string): string {
200
+ return s.normalize("NFKC").replace(FOLD_RE, (c) => QUOTE_MAP[c] ?? c);
201
+ }
202
+
203
+ const NORMALIZERS: Record<Exclude<MatchLevel, "exact" | "lines">, (l: string) => string> = {
204
+ "trailing-ws": (l) => l.replace(/\s+$/, ""),
205
+ indent: (l) => l.trim(),
206
+ loose: (l) => fold(l).trim().replace(/\s+/g, " "),
207
+ };
208
+
209
+ // ───────────────────────────── similarity (diagnostics) ─────────────────────────────
210
+
211
+ type BigramMap = Map<string, number>;
212
+
213
+ function bigrams(s: string): BigramMap {
214
+ const m = new Map<string, number>();
215
+ for (let i = 0; i < s.length - 1; i++) {
216
+ const b = s.slice(i, i + 2);
217
+ m.set(b, (m.get(b) || 0) + 1);
218
+ }
219
+ return m;
220
+ }
221
+
222
+ function similarityWithMap(a: string, A: BigramMap, b: string, B?: BigramMap): number {
223
+ if (a === b) return 1;
224
+ if (a.length < 2 || b.length < 2) return a.trim() === b.trim() ? 1 : 0;
225
+ const mapB = B ?? bigrams(b);
226
+ let inter = 0;
227
+ for (const [k, v] of A) {
228
+ const bv = mapB.get(k);
229
+ if (bv !== undefined) inter += v < bv ? v : bv;
230
+ }
231
+ return (2 * inter) / (a.length - 1 + (b.length - 1));
232
+ }
233
+
234
+ export function similarity(a: string, b: string): number {
235
+ if (a === b) return 1;
236
+ if (a.length < 2 || b.length < 2) return a.trim() === b.trim() ? 1 : 0;
237
+ return similarityWithMap(a, bigrams(a), b);
238
+ }
239
+
240
+ // ───────────────────────────── helpers ─────────────────────────────
241
+
242
+ function splitBody(s: string): { lines: string[]; trailingNL: boolean } {
243
+ const trailingNL = s.endsWith("\n");
244
+ const body = trailingNL ? s.slice(0, -1) : s;
245
+ return { lines: body === "" && trailingNL ? [""] : body.split("\n"), trailingNL };
246
+ }
247
+
248
+ function leadingWs(s: string): string {
249
+ return /^[ \t]*/.exec(s)![0];
250
+ }
251
+
252
+ /** Columns per tab when tabs and spaces have to be compared or converted. */
253
+ const TAB = 4;
254
+
255
+ function indentCols(ws: string, tabWidth = TAB): number {
256
+ let cols = 0;
257
+ for (let i = 0; i < ws.length; i++) {
258
+ cols += ws.charCodeAt(i) === 9 ? tabWidth : 1;
259
+ }
260
+ return cols;
261
+ }
262
+
263
+ /** "tabs" / "spaces" by majority of indented lines (a leading tab means tab-indented). */
264
+ function wsStyle(wss: string[]): "tabs" | "spaces" | undefined {
265
+ let tabs = 0;
266
+ let spaces = 0;
267
+ for (const w of wss) {
268
+ if (!w) continue;
269
+ if (w.charCodeAt(0) === 9) tabs++;
270
+ else spaces++;
271
+ }
272
+ if (!tabs && !spaces) return undefined;
273
+ return tabs >= spaces ? "tabs" : "spaces";
274
+ }
275
+
276
+ /** Most frequent positive indentation increase (in columns) between consecutive non-blank lines. */
277
+ function commonStep(wss: string[]): number | undefined {
278
+ const freq = new Map<number, number>();
279
+ let prev: number | undefined;
280
+ for (const w of wss) {
281
+ const c = indentCols(w);
282
+ if (prev !== undefined && c > prev) freq.set(c - prev, (freq.get(c - prev) ?? 0) + 1);
283
+ prev = c;
284
+ }
285
+ let best: number | undefined;
286
+ let bestN = 0;
287
+ for (const [step, n] of freq) {
288
+ if (n > bestN || (n === bestN && best !== undefined && step > best)) {
289
+ best = step;
290
+ bestN = n;
291
+ }
292
+ }
293
+ return best;
294
+ }
295
+
296
+ function renderWs(cols: number, useTabs: boolean): string {
297
+ const c = Math.max(0, Math.round(cols));
298
+ return useTabs ? "\t".repeat(Math.floor(c / TAB)) + " ".repeat(c % TAB) : " ".repeat(c);
299
+ }
300
+
301
+ interface ReindentPlan {
302
+ /** Reference (shallowest) line's whitespace in oldText and in the file; used for notes. */
303
+ from: string;
304
+ to: string;
305
+ /** Exact whitespace mapping observed on the matched lines (only unambiguous entries). */
306
+ exact: Map<string, string>;
307
+ /** fileCols = a + s * modelCols */
308
+ a: number;
309
+ s: number;
310
+ useTabs: boolean;
311
+ }
312
+
313
+ /**
314
+ * Work out how the model's indentation maps onto the file's, from the line-aligned
315
+ * oldText ↔ file pairs. Whitespace is always rebuilt as a whole in the file's own
316
+ * style, so tab-indented newText never leaks tabs into a space-indented file (or vice
317
+ * versa), which previously produced mixed indentation such as " \treturn".
318
+ */
319
+ function planReindent(idx: LineIndex, firstLine: number, oldLines: string[], newText: string): ReindentPlan | undefined {
320
+ const pairs: { m: string; f: string; mc: number; fc: number }[] = [];
321
+ for (let j = 0; j < oldLines.length; j++) {
322
+ if (oldLines[j].trim() === "") continue;
323
+ const m = leadingWs(oldLines[j]);
324
+ const f = leadingWs(idx.lines[firstLine + j]);
325
+ pairs.push({ m, f, mc: indentCols(m), fc: indentCols(f) });
326
+ }
327
+ if (!pairs.length || pairs.every((p) => p.m === p.f)) return undefined;
328
+
329
+ const newWs = newText.split("\n").filter((l) => l.trim() !== "").map(leadingWs);
330
+ const modelWs = [...pairs.map((p) => p.m), ...newWs];
331
+ const regionStyle = wsStyle(pairs.map((p) => p.f));
332
+ const fileStyle = regionStyle ?? idx.indentInfo.style;
333
+ const modelStyle = wsStyle(modelWs);
334
+ const useTabs = (fileStyle ?? modelStyle) === "tabs";
335
+
336
+ const exact = new Map<string, string>();
337
+ const conflicting = new Set<string>();
338
+ for (const p of pairs) {
339
+ const seen = exact.get(p.m);
340
+ if (seen !== undefined && seen !== p.f) conflicting.add(p.m);
341
+ else exact.set(p.m, p.f);
342
+ }
343
+ for (const k of conflicting) exact.delete(k);
344
+
345
+ let ref = pairs[0];
346
+ let hi = pairs[0];
347
+ for (const p of pairs) {
348
+ if (p.mc < ref.mc) ref = p;
349
+ if (p.mc > hi.mc) hi = p;
350
+ }
351
+
352
+ let s: number;
353
+ if (hi.mc !== ref.mc) {
354
+ s = (hi.fc - ref.fc) / (hi.mc - ref.mc);
355
+ } else if (!fileStyle || !modelStyle || fileStyle === modelStyle) {
356
+ s = 1;
357
+ } else {
358
+ // One observed nesting level establishes an offset, not a unit ratio.
359
+ const fileStep = idx.indentInfo.step ?? commonStep(pairs.map((p) => p.f)) ?? TAB;
360
+ const modelStep = commonStep(modelWs) ?? TAB;
361
+ s = fileStep / modelStep;
362
+ }
363
+ let a = ref.fc - s * ref.mc;
364
+ const linear = s > 0 && pairs.every((p) => Math.abs(a + s * p.mc - p.fc) < 0.5);
365
+ if (!linear) {
366
+ // Not a clean linear relation: fall back to a plain shift of the reference line.
367
+ s = 1;
368
+ a = ref.fc - ref.mc;
369
+ }
370
+ return { from: ref.m, to: ref.f, exact, a, s, useTabs };
371
+ }
372
+
373
+ function applyReindent(newText: string, plan: ReindentPlan): string {
374
+ return newText
375
+ .split("\n")
376
+ .map((l) => {
377
+ if (l.trim() === "") return "";
378
+ const ws = leadingWs(l);
379
+ const mapped = plan.exact.get(ws);
380
+ const out = mapped !== undefined ? mapped : renderWs(plan.a + plan.s * indentCols(ws), plan.useTabs);
381
+ return out + l.slice(ws.length);
382
+ })
383
+ .join("\n");
384
+ }
385
+
386
+ // Gutters produced by this tool: " 12│text" (diagnostics / context) and "+ 12│text"
387
+ // (added lines in a success diff). Models copy both.
388
+ const LINE_PREFIX_RE = /^\+?[ \t]*\d+[ \t]*[│|]/;
389
+
390
+ /**
391
+ * requireAll: every non-blank line must carry a gutter (used to decide whether oldText was
392
+ * copied with gutters). Without it, only lines that carry one are stripped (used for newText,
393
+ * where the model often mixes copied gutter lines with freshly typed ones).
394
+ */
395
+ function tryStripLinePrefixes(text: string, separatorSpace = false, requireAll = true): string | null {
396
+ const { lines, trailingNL } = splitBody(text);
397
+ if (lines.length === 0) return null;
398
+ const nonEmpty = lines.filter((l) => l.trim() !== "");
399
+ if (nonEmpty.length === 0) return null;
400
+ if (requireAll ? !nonEmpty.every((l) => LINE_PREFIX_RE.test(l)) : !nonEmpty.some((l) => LINE_PREFIX_RE.test(l))) return null;
401
+ const stripped = lines.map((l) => {
402
+ if (!LINE_PREFIX_RE.test(l)) return l;
403
+ const body = l.replace(LINE_PREFIX_RE, "");
404
+ return separatorSpace && body.startsWith(" ") ? body.slice(1) : body;
405
+ }).join("\n");
406
+ return trailingNL ? stripped + "\n" : stripped;
407
+ }
408
+
409
+ function fmtLines(ls: number[]): string {
410
+ const shown = ls.slice(0, 12).join(", ");
411
+ return ls.length > 12 ? `${shown}, … (${ls.length} total)` : shown;
412
+ }
413
+
414
+ function clip(s: string, n = 160): string {
415
+ return s.length > n ? s.slice(0, n) + "…" : s;
416
+ }
417
+
418
+ // ───────────────────────────── matching ─────────────────────────────
419
+
420
+ interface Match {
421
+ start: number;
422
+ end: number;
423
+ level: MatchLevel;
424
+ lineMatch?: { firstLine: number; lastLine: number };
425
+ }
426
+
427
+ function findExact(idx: LineIndex, oldText: string): Match[] {
428
+ const out: Match[] = [];
429
+ let from = 0;
430
+ for (;;) {
431
+ const i = idx.text.indexOf(oldText, from);
432
+ if (i < 0) break;
433
+ out.push({ start: i, end: i + oldText.length, level: "exact" });
434
+ from = i + 1; // Include overlapping candidates; application rejects overlaps.
435
+ }
436
+ return out;
437
+ }
438
+
439
+ function findLines(idx: LineIndex, oldText: string, level: keyof typeof NORMALIZERS): Match[] {
440
+ const norm = NORMALIZERS[level];
441
+ const { lines: oldLines } = splitBody(oldText);
442
+ const k = oldLines.length;
443
+ const n = idx.count;
444
+ if (k === 0 || k > n) return [];
445
+
446
+ const target = oldLines.map(norm);
447
+ if (target.every((t) => t === "")) return [];
448
+
449
+ const anchorIdx = target.findIndex((t) => t !== "");
450
+ const anchorText = target[anchorIdx];
451
+
452
+ const fileNorm = idx.lines.slice(0, n).map(norm);
453
+ const out: Match[] = [];
454
+
455
+ for (let i = 0; i + k <= n; ) {
456
+ if (fileNorm[i + anchorIdx] !== anchorText) {
457
+ i++;
458
+ continue;
459
+ }
460
+ let ok = true;
461
+ for (let j = 0; j < k; j++) {
462
+ if (j !== anchorIdx && fileNorm[i + j] !== target[j]) {
463
+ ok = false;
464
+ break;
465
+ }
466
+ }
467
+ if (ok) {
468
+ // Re-indentation is planned later in resolveEdit, where newText is known.
469
+ out.push({
470
+ start: idx.starts[i],
471
+ end: idx.lineEnd(i + k - 1),
472
+ level,
473
+ lineMatch: { firstLine: i, lastLine: i + k - 1 },
474
+ });
475
+ }
476
+ i++;
477
+ }
478
+ return out;
479
+ }
480
+
481
+ const CASCADE: (keyof typeof NORMALIZERS)[] = ["trailing-ws", "indent", "loose"];
482
+
483
+ function locate(idx: LineIndex, oldText: string): Match[] {
484
+ const exact = findExact(idx, oldText);
485
+ if (exact.length) return exact;
486
+ for (const level of CASCADE) {
487
+ const m = findLines(idx, oldText, level);
488
+ if (m.length) return m;
489
+ }
490
+ return [];
491
+ }
492
+
493
+ // ───────────────────────────── diagnostics ─────────────────────────────
494
+
495
+ function diagnoseMissing(idx: LineIndex, edit: EditSpec, i: number): string {
496
+ const oldText = edit.oldText!;
497
+ const parts: string[] = [
498
+ `Edit #${i + 1}: oldText not found in file (tried exact, trailing-whitespace, indentation-insensitive and loose Unicode/whitespace matching).`,
499
+ ];
500
+
501
+ // "Already applied?" is only meaningful when newText is distinctive: it must not be part
502
+ // of oldText (deleting a line leaves newText ⊂ oldText, and the rest of the file usually
503
+ // still contains it), and it must occur exactly once. Even then this is only a hint: the
504
+ // closest candidate is still reported so a false positive cannot hide the real problem.
505
+ const nt = edit.newText;
506
+ if (nt.trim() !== "") {
507
+ const looseBlock = (s: string) => splitBody(s).lines.map(NORMALIZERS.loose).join("\n").trim();
508
+ if (!looseBlock(oldText).includes(looseBlock(nt))) {
509
+ const already = locate(idx, nt);
510
+ if (already.length === 1) {
511
+ const ln = idx.lineOf(already[0].start) + 1;
512
+ parts.push(
513
+ `Hint: newText already exists at line ${ln} — if you already made this change, do not repeat it; ` +
514
+ `otherwise fix oldText using the closest candidate below.`,
515
+ );
516
+ }
517
+ }
518
+ }
519
+
520
+ const n = idx.count;
521
+ if (n === 0) {
522
+ parts.push("The file is currently empty (0 lines). Use afterLine: 0 to insert content.");
523
+ return parts.join("\n");
524
+ }
525
+
526
+ const { lines: oldLines } = splitBody(oldText);
527
+ const k = Math.min(oldLines.length, n);
528
+ const tgt = oldLines.slice(0, k).map(NORMALIZERS.loose);
529
+ const tgtBigrams = tgt.map(bigrams);
530
+ const fileNorm = idx.lines.slice(0, n).map(NORMALIZERS.loose);
531
+
532
+ const fileBigrams = new Array<BigramMap | undefined>(n);
533
+ const getFileBigram = (lineIdx: number): BigramMap => {
534
+ let m = fileBigrams[lineIdx];
535
+ if (!m) {
536
+ m = bigrams(fileNorm[lineIdx]);
537
+ fileBigrams[lineIdx] = m;
538
+ }
539
+ return m;
540
+ };
541
+
542
+ let best = { score: -1, line: 0 };
543
+ const budget = 3_000_000;
544
+ const totalOps = (n - k + 1) * k;
545
+ const step = Math.max(1, Math.ceil(totalOps / budget));
546
+
547
+ for (let s = 0; s + k <= n; s += step) {
548
+ let sc = 0;
549
+ for (let j = 0; j < k; j++) {
550
+ if (sc + (k - j) <= best.score * k) break;
551
+ sc += similarityWithMap(tgt[j], tgtBigrams[j], fileNorm[s + j], getFileBigram(s + j));
552
+ }
553
+ sc /= k;
554
+ if (sc > best.score) best = { score: sc, line: s };
555
+ }
556
+
557
+ if (step > 1 && best.score > 0) {
558
+ const lo = Math.max(0, best.line - step);
559
+ const hi = Math.min(n - k, best.line + step);
560
+ for (let s = lo; s <= hi; s++) {
561
+ let sc = 0;
562
+ for (let j = 0; j < k; j++) {
563
+ if (sc + (k - j) <= best.score * k) break;
564
+ sc += similarityWithMap(tgt[j], tgtBigrams[j], fileNorm[s + j], getFileBigram(s + j));
565
+ }
566
+ sc /= k;
567
+ if (sc > best.score) best = { score: sc, line: s };
568
+ }
569
+ }
570
+
571
+ if (oldLines.length === 1 && best.score < 0.9) {
572
+ const t = tgt[0];
573
+ for (let s = 0; s < n; s++) {
574
+ if (t.length >= 4 && fileNorm[s].includes(t)) {
575
+ best = { score: 0.95, line: s };
576
+ break;
577
+ }
578
+ }
579
+ }
580
+
581
+ if (best.score < 0.5) {
582
+ parts.push("No similar region found. The file has likely changed or you are editing the wrong file — re-read it before retrying.");
583
+ return parts.join("\n");
584
+ }
585
+
586
+ const a = best.line;
587
+ const b = Math.min(n - 1, a + oldLines.length - 1);
588
+ parts.push(`Closest candidate (${Math.round(best.score * 100)}% similar) at lines ${a + 1}-${b + 1}. Actual file content:`);
589
+ for (let l = a; l <= b; l++) parts.push(`${String(l + 1).padStart(5)}│${idx.lines[l]}`);
590
+ const diffs: string[] = [];
591
+ for (let j = 0; j < oldLines.length && a + j <= b; j++) {
592
+ if (idx.lines[a + j] !== oldLines[j]) {
593
+ diffs.push(` line ${a + j + 1}\n file : ${JSON.stringify(clip(idx.lines[a + j]))}\n yours: ${JSON.stringify(clip(oldLines[j]))}`);
594
+ if (diffs.length >= 4) break;
595
+ }
596
+ }
597
+ if (diffs.length) parts.push("First differences:", ...diffs);
598
+ parts.push("Copy oldText from the actual content above (without the ' 12│' line number prefix) and retry.");
599
+ return parts.join("\n");
600
+ }
601
+
602
+ // ───────────────────────────── resolve one edit ─────────────────────────────
603
+
604
+ /**
605
+ * Mixed-EOL files are not decoded, so a CRLF line's "\r" is part of the line string.
606
+ * When lines first..last are replaced as whole lines, keep the CR of the last line (it is
607
+ * that line's terminator, not content) and use CRLF inside the replacement when every
608
+ * replaced line used it. No-op for decoded (purely CRLF) and purely LF files.
609
+ */
610
+ function crlfAdjust(idx: LineIndex, crlfMode: boolean, first: number, last: number, end: number, rep: string): { end: number; rep: string } {
611
+ if (crlfMode || !idx.isCrlfLine(last)) return { end, rep };
612
+ let all = true;
613
+ for (let l = first; l <= last; l++) {
614
+ if (!idx.isCrlfLine(l)) {
615
+ all = false;
616
+ break;
617
+ }
618
+ }
619
+ return { end: end - 1, rep: all ? rep.replace(/\r?\n/g, "\r\n") : rep };
620
+ }
621
+
622
+ /** How many occurrences get a snippet, how far context may grow, how long a suggestion may be. */
623
+ const MAX_SHOWN_OCCURRENCES = 5;
624
+ const MAX_CONTEXT_GROWTH = 6;
625
+ const MAX_SUGGESTION_CHARS = 400;
626
+ /** fmtLines lists this many line numbers; only listed numbers are offered as nearLine. */
627
+ const MAX_LISTED_LINES = 12;
628
+
629
+ function countExact(text: string, needle: string, limit = 2): number {
630
+ let n = 0;
631
+ for (let from = 0; n < limit; n++) {
632
+ const i = text.indexOf(needle, from);
633
+ if (i < 0) break;
634
+ from = i + 1;
635
+ }
636
+ return n;
637
+ }
638
+
639
+ /**
640
+ * Smallest whole-line window around one occurrence whose exact text is unique in the
641
+ * file. It is copied from the file, so it always matches exactly.
642
+ */
643
+ function uniqueContext(idx: LineIndex, first: number, last: number): { first: number; last: number; text: string } | undefined {
644
+ for (let k = 0; k <= MAX_CONTEXT_GROWTH; k++) {
645
+ for (let up = 0; up <= k; up++) {
646
+ const a = first - up;
647
+ const b = last + (k - up);
648
+ if (a < 0 || b >= idx.count) continue;
649
+ const text = idx.text.slice(idx.starts[a], idx.lineEnd(b));
650
+ if (text.length > MAX_SUGGESTION_CHARS) continue;
651
+ if (countExact(idx.text, text) === 1) return { first: a, last: b, text };
652
+ }
653
+ }
654
+ return undefined;
655
+ }
656
+
657
+ function matchLines(idx: LineIndex, m: Match): { first: number; last: number } {
658
+ if (m.lineMatch) return { first: m.lineMatch.firstLine, last: m.lineMatch.lastLine };
659
+ const first = idx.lineOf(m.start);
660
+ return { first, last: Math.max(first, idx.lineOf(m.end > m.start ? m.end - 1 : m.end)) };
661
+ }
662
+
663
+ /** Numbered snippet of each occurrence plus a ready-to-use unique oldText for it. */
664
+ function describeOccurrences(idx: LineIndex, matches: Match[]): string {
665
+ const parts: string[] = [];
666
+ const starts = matches.map((m) => idx.lineOf(m.start));
667
+ matches.slice(0, MAX_SHOWN_OCCURRENCES).forEach((m, k) => {
668
+ const { first, last } = matchLines(idx, m);
669
+ const span = first === last ? `line ${first + 1}` : `lines ${first + 1}-${last + 1}`;
670
+ parts.push(`[${k + 1}] ${span}:`);
671
+ const a = Math.max(0, first - 1);
672
+ const b = Math.min(idx.count - 1, last + 1);
673
+ for (let l = a; l <= b; l++) {
674
+ if (l > first + 2 && l < last - 1) {
675
+ if (l === first + 3) parts.push(` … ${last - first - 4} lines …`);
676
+ continue;
677
+ }
678
+ parts.push(`${String(l + 1).padStart(5)}│${clip(idx.lines[l].replace(/\r$/, ""))}`);
679
+ }
680
+ const u = uniqueContext(idx, first, last);
681
+ parts.push(
682
+ u
683
+ ? ` unique oldText (lines ${u.first + 1}-${u.last + 1}): ${JSON.stringify(u.text)}`
684
+ : ` no unique oldText within ${MAX_CONTEXT_GROWTH} extra lines / ${MAX_SUGGESTION_CHARS} chars.`,
685
+ );
686
+ parts.push(
687
+ starts.filter((l) => l === first).length > 1
688
+ ? " Multiple matches start on this line; nearLine cannot distinguish them. Use unique oldText and change only the intended occurrence in newText."
689
+ : ` use nearLine: ${first + 1}`,
690
+ );
691
+ });
692
+ if (matches.length > MAX_SHOWN_OCCURRENCES) parts.push(`… ${matches.length - MAX_SHOWN_OCCURRENCES} more occurrence(s) not shown.`);
693
+ return parts.join("\n");
694
+ }
695
+
696
+ function ambiguityOptions(count: number, stale: boolean): string {
697
+ return [
698
+ "Fix it without re-reading, using one of:",
699
+ "- oldText: the \"unique oldText\" of the occurrence you mean; repeat its extra context lines unchanged in newText.",
700
+ `- nearLine: the line shown for that occurrence (keep oldText as is); only works when matches start on different lines.${stale ? " These numbers come from the current file, so they are accepted while it stays unchanged." : ""}`,
701
+ `- replaceAll: true to change all ${count} occurrences.`,
702
+ ].join("\n");
703
+ }
704
+
705
+ /** Largest oldLines × newLines table for LCS; never silently drop preservation above it. */
706
+ const MAX_LCS_CELLS = 4_000_000;
707
+
708
+ /**
709
+ * Pairs (oldIndex, newIndex) of lines the model left unchanged, in order.
710
+ * Longest common subsequence on exact line equality, so moved or inserted lines
711
+ * around an untouched line do not break the pairing.
712
+ */
713
+ function unchangedPairs(oldLines: string[], newLines: string[]): [number, number][] {
714
+ const n = oldLines.length;
715
+ const m = newLines.length;
716
+ if (n * m > MAX_LCS_CELLS) {
717
+ // Peel exact common boundaries first: a large insertion must not normalize
718
+ // thousands of otherwise untouched file lines. Fail closed if still too large.
719
+ let prefix = 0;
720
+ while (prefix < Math.min(n, m) && oldLines[prefix] === newLines[prefix]) prefix++;
721
+ let suffix = 0;
722
+ while (suffix < Math.min(n, m) - prefix && oldLines[n - suffix - 1] === newLines[m - suffix - 1]) suffix++;
723
+ if (!prefix && !suffix) {
724
+ throw new EditError("No changes written: tolerant edit exceeds the safe line-alignment budget. Split it into smaller edits or use exact file text.");
725
+ }
726
+ const out: [number, number][] = [];
727
+ for (let k = 0; k < prefix; k++) out.push([k, k]);
728
+ for (const [a, b] of unchangedPairs(oldLines.slice(prefix, n - suffix), newLines.slice(prefix, m - suffix))) {
729
+ out.push([a + prefix, b + prefix]);
730
+ }
731
+ for (let k = suffix; k > 0; k--) out.push([n - k, m - k]);
732
+ return out;
733
+ }
734
+ const w = m + 1;
735
+ const dp = new Uint32Array((n + 1) * w);
736
+ for (let a = n - 1; a >= 0; a--) {
737
+ for (let b = m - 1; b >= 0; b--) {
738
+ dp[a * w + b] = oldLines[a] === newLines[b]
739
+ ? dp[(a + 1) * w + b + 1] + 1
740
+ : Math.max(dp[(a + 1) * w + b], dp[a * w + b + 1]);
741
+ }
742
+ }
743
+ const out: [number, number][] = [];
744
+ let a = 0;
745
+ let b = 0;
746
+ while (a < n && b < m) {
747
+ if (oldLines[a] === newLines[b]) {
748
+ out.push([a, b]);
749
+ a++;
750
+ b++;
751
+ } else if (dp[(a + 1) * w + b] >= dp[a * w + b + 1]) a++;
752
+ else b++;
753
+ }
754
+ return out;
755
+ }
756
+
757
+ /**
758
+ * In a tolerant line match, a newText line identical to its oldText line is a line the
759
+ * model did not mean to change. Write the FILE's version of it back verbatim, so tolerant
760
+ * matching never rewrites whitespace inside untouched lines (string literals, Makefile
761
+ * tabs, alignment spaces, curly quotes …). Only lines the model actually changed are
762
+ * re-indented or taken from newText.
763
+ */
764
+ function keepUntouchedLines(idx: LineIndex, firstLine: number, oldLines: string[], modelLines: string[], rep: string): string {
765
+ const out = rep.split("\n");
766
+ if (out.length !== modelLines.length) return rep; // defensive: alignment unknown
767
+ let changed = false;
768
+ const lastLine = firstLine + oldLines.length - 1;
769
+ for (const [o, n] of unchangedPairs(oldLines, modelLines)) {
770
+ let fileLine = idx.lines[firstLine + o];
771
+ // Mixed-EOL files keep a CRLF line's "\r" inside its line string, so a preserved line
772
+ // keeps its own CRLF. Only in the last slot, when the block's last line is CRLF, is that
773
+ // CR already kept outside the replacement by crlfAdjust and must not be doubled.
774
+ if (idx.isCrlfLine(firstLine + o) && n === out.length - 1 && idx.isCrlfLine(lastLine)) fileLine = fileLine.slice(0, -1);
775
+ if (out[n] !== fileLine) {
776
+ out[n] = fileLine;
777
+ changed = true;
778
+ }
779
+ }
780
+ return changed ? out.join("\n") : rep;
781
+ }
782
+
783
+ function resolveEdit(idx: LineIndex, edit: EditSpec, i: number, crlfMode: boolean, opts: ApplyOptions): AppliedRange[] {
784
+ const tag = `Edit #${i + 1}`;
785
+ let newText = crlfMode ? edit.newText.replace(/\r\n/g, "\n") : edit.newText;
786
+ const hasOld = edit.oldText !== undefined && edit.oldText !== null;
787
+ const hasRange = edit.startLine !== undefined;
788
+ const hasInsert = edit.afterLine !== undefined;
789
+ const modes = [hasOld, hasRange, hasInsert].filter(Boolean).length;
790
+ if (modes !== 1) {
791
+ throw new EditError(`${tag}: specify exactly one of oldText, startLine(/endLine), or afterLine.`);
792
+ }
793
+ for (const field of ["startLine", "endLine", "afterLine", "nearLine"] as const) {
794
+ const value = edit[field];
795
+ if (value !== undefined && !Number.isSafeInteger(value)) {
796
+ throw new EditError(`${tag}: ${field} must be a safe integer.`);
797
+ }
798
+ }
799
+ // Options that do not belong to the chosen locator used to be ignored silently.
800
+ if (!hasRange && edit.endLine !== undefined) {
801
+ throw new EditError(`${tag}: endLine only works together with startLine.`);
802
+ }
803
+ if (!hasOld && (edit.replaceAll || edit.nearLine !== undefined)) {
804
+ throw new EditError(`${tag}: replaceAll and nearLine only apply to oldText edits.`);
805
+ }
806
+ const n = idx.count;
807
+
808
+ // ── insert after line ──
809
+ if (hasInsert) {
810
+ const N = edit.afterLine!;
811
+ if (N < 0 || N > n) throw new EditError(`${tag}: afterLine ${N} out of range (0-${n}).`);
812
+ if (newText === "") {
813
+ const pos = N === 0 ? 0 : idx.lineEnd(N - 1);
814
+ return [{ editIndex: i, level: "lines", start: pos, end: pos, replacement: "" }];
815
+ }
816
+ const body = splitBody(newText).lines.join("\n");
817
+ if (N === 0) {
818
+ const rep = body + "\n";
819
+ const crlf = !crlfMode && idx.isCrlfLine(0);
820
+ return [{ editIndex: i, level: "lines", start: 0, end: 0, replacement: crlf ? rep.replace(/\r?\n/g, "\r\n") : rep }];
821
+ }
822
+ if (!crlfMode && idx.isCrlfLine(N - 1)) {
823
+ // Insert before the CR so the previous line keeps its CRLF and the new lines get one too.
824
+ const pos = idx.lineEnd(N - 1) - 1;
825
+ return [{ editIndex: i, level: "lines", start: pos, end: pos, replacement: ("\n" + body).replace(/\r?\n/g, "\r\n") }];
826
+ }
827
+ const pos = idx.lineEnd(N - 1);
828
+ return [{ editIndex: i, level: "lines", start: pos, end: pos, replacement: "\n" + body }];
829
+ }
830
+
831
+ // ── replace / delete line range ──
832
+ if (hasRange) {
833
+ const s = edit.startLine!;
834
+ const e = edit.endLine ?? s;
835
+ if (s < 1 || e < s || e > n) throw new EditError(`${tag}: line range ${s}-${e} invalid (file has ${n} lines).`);
836
+ let start = idx.starts[s - 1];
837
+ let end = idx.lineEnd(e - 1);
838
+ if (newText === "") {
839
+ if (end < idx.text.length) end += 1;
840
+ else if (start > 0) start -= idx.text.slice(start - 2, start) === "\r\n" ? 2 : 1;
841
+ return [{ editIndex: i, level: "lines", start, end, replacement: "" }];
842
+ }
843
+ const adj = crlfAdjust(idx, crlfMode, s - 1, e - 1, end, splitBody(newText).lines.join("\n"));
844
+ return [{ editIndex: i, level: "lines", start, end: adj.end, replacement: adj.rep }];
845
+ }
846
+
847
+ // ── oldText replacement ──
848
+ let oldText = edit.oldText!;
849
+ if (crlfMode) oldText = oldText.replace(/\r\n/g, "\n");
850
+ if (oldText === "") throw new EditError(`${tag}: oldText is empty. Use afterLine to insert text.`);
851
+ if (oldText === newText) throw new EditError(`${tag}: oldText and newText are identical — nothing to change.`);
852
+
853
+ let matches = locate(idx, oldText);
854
+ let strippedLinePrefixes = false;
855
+
856
+ if (!matches.length) {
857
+ // The space after the gutter may be display padding OR code indentation.
858
+ // Prefer a complete-line match, never an indented substring of that line.
859
+ const candidates = [false, true].map((separatorSpace) => ({
860
+ separatorSpace, text: tryStripLinePrefixes(oldText, separatorSpace),
861
+ })).filter((c) => c.text !== null && c.text !== oldText);
862
+ let chosen: typeof candidates[number] | undefined;
863
+ for (const level of CASCADE) {
864
+ for (const candidate of candidates) {
865
+ const found = findLines(idx, candidate.text!, level);
866
+ if (found.length) {
867
+ chosen = candidate;
868
+ matches = found;
869
+ break;
870
+ }
871
+ }
872
+ if (chosen) break;
873
+ }
874
+ if (chosen) {
875
+ oldText = chosen.text!;
876
+ strippedLinePrefixes = true;
877
+ // newText often mixes copied gutter lines with freshly typed ones; strip every
878
+ // gutter that is present instead of requiring all lines to carry one (otherwise
879
+ // the literal " 12│" ended up in the file).
880
+ const strippedNew = tryStripLinePrefixes(newText, chosen.separatorSpace, false);
881
+ if (strippedNew !== null) newText = strippedNew;
882
+ }
883
+ }
884
+
885
+ if (!matches.length) throw new EditError(diagnoseMissing(idx, { ...edit, oldText, newText }, i));
886
+
887
+ if (matches.length > 1 && !edit.replaceAll) {
888
+ const lines = matches.map((m) => idx.lineOf(m.start) + 1);
889
+ const ambiguous = (lead: string, options: string) => {
890
+ const err = new EditError(`${tag}: ${lead}\n\n${describeOccurrences(idx, matches)}\n\n${options}`);
891
+ err.nearLineOffers = lines.slice(0, MAX_LISTED_LINES);
892
+ return err;
893
+ };
894
+ const offered = (n: number) => !!opts.offeredNearLines?.has(n) && lines.includes(n);
895
+ if (edit.nearLine !== undefined) {
896
+ if (opts.lineNumbersTrusted === false && !offered(edit.nearLine)) {
897
+ throw ambiguous(
898
+ `oldText matches ${matches.length} places (lines ${fmtLines(lines)}). nearLine ${edit.nearLine} was not used ` +
899
+ `because line numbers may be stale (${opts.untrustedReason ?? "the file was not read since its lines last moved"}).`,
900
+ ambiguityOptions(matches.length, true),
901
+ );
902
+ }
903
+ const d = lines.map((l) => Math.abs(l - edit.nearLine!));
904
+ const min = Math.min(...d);
905
+ const winners = d.filter((x) => x === min).length;
906
+ if (winners > 1) {
907
+ throw ambiguous(
908
+ `oldText matches ${matches.length} places (lines ${fmtLines(lines)}); nearLine ${edit.nearLine} is equally close to several.`,
909
+ ambiguityOptions(matches.length, false),
910
+ );
911
+ }
912
+ matches = [matches[d.indexOf(min)]];
913
+ } else {
914
+ throw ambiguous(`oldText matches ${matches.length} places (lines ${fmtLines(lines)}).`, ambiguityOptions(matches.length, false));
915
+ }
916
+ }
917
+
918
+ const { lines: oldLines, trailingNL: oldTrailingNL } = splitBody(oldText);
919
+ return matches.map((m): AppliedRange => {
920
+ let rep = newText;
921
+ let end = m.end;
922
+ let reindented: { from: string; to: string } | undefined;
923
+ if (m.lineMatch) {
924
+ const { firstLine, lastLine } = m.lineMatch;
925
+ if (oldTrailingNL && rep === "") {
926
+ let start = m.start;
927
+ if (end < idx.text.length) end += 1;
928
+ else if (start > 0) start -= idx.text.slice(start - 2, start) === "\r\n" ? 2 : 1;
929
+ return { editIndex: i, level: m.level, start, end, replacement: "", strippedLinePrefixes };
930
+ }
931
+ const modelLines = (rep.endsWith("\n") ? rep.slice(0, -1) : rep).split("\n");
932
+ if (m.level === "indent" || m.level === "loose") {
933
+ const plan = planReindent(idx, firstLine, oldLines, rep);
934
+ if (plan) {
935
+ rep = applyReindent(rep, plan);
936
+ reindented = { from: plan.from, to: plan.to };
937
+ }
938
+ }
939
+ // A line match covers whole lines without their terminator, so exactly one trailing
940
+ // newline of newText is redundant ("X\n" → no blank line, "X\n\n" → one blank line).
941
+ if (rep.endsWith("\n")) rep = rep.slice(0, -1);
942
+ rep = keepUntouchedLines(idx, firstLine, oldLines, modelLines, rep);
943
+ ({ end, rep } = crlfAdjust(idx, crlfMode, firstLine, lastLine, end, rep));
944
+ }
945
+ return {
946
+ editIndex: i,
947
+ level: m.level,
948
+ start: m.start,
949
+ end,
950
+ replacement: rep,
951
+ reindented,
952
+ strippedLinePrefixes,
953
+ };
954
+ });
955
+ }
956
+
957
+ // ───────────────────────────── hunks for the report ─────────────────────────────
958
+
959
+ function buildHunks(idx: LineIndex, final: LineIndex, ranges: AppliedRange[]): Hunk[] {
960
+ // Include the line at end as well: deleting its preceding newline can join it
961
+ // to the start line. Ranges sharing an original line must use one final block.
962
+ const groups: { first: number; last: number; ranges: AppliedRange[] }[] = [];
963
+ for (const r of ranges) {
964
+ const first = idx.lineOf(r.start);
965
+ const last = idx.lineOf(r.end);
966
+ const previous = groups[groups.length - 1];
967
+ if (previous && first <= previous.last) {
968
+ previous.last = Math.max(previous.last, last);
969
+ previous.ranges.push(r);
970
+ } else groups.push({ first, last, ranges: [r] });
971
+ }
972
+
973
+ // Count actual LF boundaries, rather than deriving offsets from hunk sizes.
974
+ // Both scans advance monotonically, so mapping all windows is linear.
975
+ let scan = 0;
976
+ let line = 0;
977
+ const lineAtBoundary = (offset: number): number => {
978
+ while (scan < offset) {
979
+ if (final.text.charCodeAt(scan++) === 10) line++;
980
+ }
981
+ return offset === final.text.length ? final.count : line;
982
+ };
983
+ let shift = 0;
984
+ return groups.map((group) => {
985
+ const start = idx.starts[group.first] ?? 0;
986
+ const endLine = Math.min(idx.count, group.last + 1);
987
+ const end = endLine < idx.count ? idx.starts[endLine] : idx.text.length;
988
+ // At an empty-file insertion the start is line zero, not final.count.
989
+ const newFirst = start + shift === 0 ? 0 : lineAtBoundary(start + shift);
990
+ for (const r of group.ranges) shift += r.replacement.length - (r.end - r.start);
991
+ const newEnd = lineAtBoundary(end + shift);
992
+ const oldLines = idx.lines.slice(group.first, endLine);
993
+ const newLines = final.lines.slice(newFirst, newEnd);
994
+ let pre = 0;
995
+ while (pre < oldLines.length && pre < newLines.length && oldLines[pre] === newLines[pre]) pre++;
996
+ let suf = 0;
997
+ while (
998
+ suf < oldLines.length - pre && suf < newLines.length - pre &&
999
+ oldLines[oldLines.length - 1 - suf] === newLines[newLines.length - 1 - suf]
1000
+ ) suf++;
1001
+ const newStart = newFirst + pre;
1002
+ const added = newLines.slice(pre, newLines.length - suf);
1003
+ const r = group.ranges[0];
1004
+ return {
1005
+ editIndex: r.editIndex,
1006
+ level: r.level,
1007
+ oldStartLine: group.first + pre + 1,
1008
+ newStartLine: newStart + 1,
1009
+ removed: oldLines.slice(pre, oldLines.length - suf),
1010
+ added,
1011
+ before: final.lines.slice(Math.max(0, newStart - 2), newStart),
1012
+ after: final.lines.slice(newStart + added.length, Math.min(final.count, newStart + added.length + 2)),
1013
+ };
1014
+ });
1015
+ }
1016
+
1017
+ // ───────────────────────────── main entry ─────────────────────────────
1018
+
1019
+ export function applyEdits(decoded: Decoded, edits: EditSpec[], opts: ApplyOptions = {}): ApplyResult {
1020
+ if (!Array.isArray(edits) || edits.length === 0) throw new EditError("edits must be a non-empty array.");
1021
+ const idx = new LineIndex(decoded.text);
1022
+ const crlf = decoded.eol === "\r\n";
1023
+
1024
+ const ranges: AppliedRange[] = [];
1025
+ const errors: string[] = [];
1026
+ const offers: number[] = [];
1027
+ edits.forEach((e, i) => {
1028
+ try {
1029
+ ranges.push(...resolveEdit(idx, e, i, crlf, opts));
1030
+ } catch (err) {
1031
+ if (!(err instanceof EditError)) throw err;
1032
+ errors.push(err.message);
1033
+ if (err.nearLineOffers) offers.push(...err.nearLineOffers);
1034
+ }
1035
+ });
1036
+ if (errors.length) {
1037
+ const ok = edits.length - errors.length;
1038
+ const err = new EditError(
1039
+ (edits.length > 1 ? `No changes written (edits are all-or-nothing; ${ok} of ${edits.length} edits were OK).\n\n` : "No changes written.\n\n") +
1040
+ errors.join("\n\n"),
1041
+ );
1042
+ if (offers.length) err.nearLineOffers = [...new Set(offers)];
1043
+ throw err;
1044
+ }
1045
+
1046
+ ranges.sort((x, y) => x.start - y.start || x.end - y.end || x.editIndex - y.editIndex);
1047
+ const dedupedRanges: AppliedRange[] = [];
1048
+ for (const r of ranges) {
1049
+ const prev = dedupedRanges[dedupedRanges.length - 1];
1050
+ if (prev && prev.start === r.start && prev.end === r.end && prev.replacement === r.replacement) {
1051
+ continue;
1052
+ }
1053
+ dedupedRanges.push(r);
1054
+ }
1055
+
1056
+ for (let k = 1; k < dedupedRanges.length; k++) {
1057
+ const p = dedupedRanges[k - 1];
1058
+ const c = dedupedRanges[k];
1059
+ if (c.start < p.end) {
1060
+ const lp = idx.lineOf(p.start) + 1;
1061
+ const lc = idx.lineOf(c.start) + 1;
1062
+ throw new EditError(
1063
+ `No changes written. Edit #${p.editIndex + 1} (line ${lp}) and edit #${c.editIndex + 1} (line ${lc}) overlap. ` +
1064
+ `Merge them into a single edit; every edit is matched against the original file.`,
1065
+ );
1066
+ }
1067
+ if (c.start === p.end && c.start === c.end && p.start === p.end) {
1068
+ const lp = idx.lineOf(p.start) + 1;
1069
+ const lc = idx.lineOf(c.start) + 1;
1070
+ throw new EditError(
1071
+ `No changes written. Edit #${p.editIndex + 1} (line ${lp}) and edit #${c.editIndex + 1} (line ${lc}) both insert at the same position. ` +
1072
+ `Merge them into a single edit; every edit is matched against the original file.`,
1073
+ );
1074
+ }
1075
+ }
1076
+
1077
+ let out = "";
1078
+ let cursor = 0;
1079
+ for (const r of dedupedRanges) {
1080
+ out += idx.text.slice(cursor, r.start) + r.replacement;
1081
+ cursor = r.end;
1082
+ }
1083
+ out += idx.text.slice(cursor);
1084
+
1085
+ if (out === idx.text) throw new EditError("No changes written: the edits produce an identical file.");
1086
+
1087
+ const hunks = buildHunks(idx, new LineIndex(out), dedupedRanges);
1088
+ const added = hunks.reduce((sum, h) => sum + h.added.length, 0);
1089
+ const removed = hunks.reduce((sum, h) => sum + h.removed.length, 0);
1090
+ // Reporting merges adjacent ranges. Never use those merged hunks for trust:
1091
+ // opposing deltas can cancel while lines inside the merged block still move.
1092
+ const newlineCount = (s: string) => s.split("\n").length - 1;
1093
+ const unterminated = (s: string) => s.length > 0 && !s.endsWith("\n") ? 1 : 0;
1094
+ const linesShifted = dedupedRanges.some((r) => {
1095
+ let delta = newlineCount(r.replacement) - newlineCount(idx.text.slice(r.start, r.end));
1096
+ if (r.end === idx.text.length) {
1097
+ const tail = r.replacement || idx.text.slice(0, r.start);
1098
+ delta += unterminated(tail) - unterminated(idx.text);
1099
+ }
1100
+ return delta !== 0;
1101
+ });
1102
+
1103
+ const notes: string[] = [];
1104
+ const byEdit = new Map<number, AppliedRange[]>();
1105
+ for (const r of dedupedRanges) byEdit.set(r.editIndex, [...(byEdit.get(r.editIndex) || []), r]);
1106
+ for (const [ei, rs] of [...byEdit].sort((a, b) => a[0] - b[0])) {
1107
+ const lv = rs[0].level;
1108
+ if (rs[0].strippedLinePrefixes) {
1109
+ notes.push(`edit #${ei + 1}: stripped accidental line-number prefixes ("12│") from oldText/newText.`);
1110
+ }
1111
+ if (lv === "trailing-ws") notes.push(`edit #${ei + 1}: matched ignoring trailing whitespace.`);
1112
+ if (lv === "indent" || lv === "loose") {
1113
+ const what = lv === "indent" ? "ignoring indentation" : "ignoring whitespace/Unicode punctuation differences";
1114
+ const ri = rs[0].reindented;
1115
+ const fix = !ri ? "" : ri.from !== ri.to
1116
+ ? `; newText re-indented ${JSON.stringify(ri.from)} → ${JSON.stringify(ri.to)}`
1117
+ : "; newText re-indented to the file's indentation style";
1118
+ notes.push(`edit #${ei + 1}: matched ${what}${fix}. Verify the result below.`);
1119
+ }
1120
+ if (rs.length > 1) notes.push(`edit #${ei + 1}: replaced ${rs.length} occurrences.`);
1121
+ }
1122
+
1123
+ return { text: out, ranges: dedupedRanges, hunks, added, removed, linesShifted, notes };
1124
+ }
1125
+
1126
+ // ───────────────────────────── report formatting ─────────────────────────────
1127
+
1128
+ export function formatReport(res: ApplyResult, opts: { path: string; dryRun?: boolean; maxHunks?: number; maxHunkLines?: number }): string {
1129
+ const maxHunks = opts.maxHunks ?? 12;
1130
+ const maxLines = opts.maxHunkLines ?? 40;
1131
+ let total = 0;
1132
+ if (res.text !== "") {
1133
+ for (let i = 0; i < res.text.length; i++) { if (res.text.charCodeAt(i) === 10) total++; }
1134
+ if (!res.text.endsWith("\n")) total++;
1135
+ }
1136
+ const head = `${opts.dryRun ? "DRY RUN — nothing written. Would edit" : "Edited"} ${opts.path}: ${res.hunks.length} change(s), +${res.added} -${res.removed} lines; file now ${total} lines.`;
1137
+ const out: string[] = [head];
1138
+ if (res.notes.length) out.push(...res.notes.map((n) => "note: " + n));
1139
+
1140
+ const num = (n: number) => String(n).padStart(5);
1141
+ res.hunks.slice(0, maxHunks).forEach((h) => {
1142
+ out.push("");
1143
+ out.push(`@@ edit #${h.editIndex + 1} [${h.level}] old L${h.oldStartLine} → new L${h.newStartLine}`);
1144
+ const body: string[] = [];
1145
+ h.before.forEach((l, k) => body.push(` ${num(h.newStartLine - h.before.length + k)}│${l}`));
1146
+ h.removed.forEach((l, k) => body.push(`- ${num(h.oldStartLine + k)}│${l}`));
1147
+ h.added.forEach((l, k) => body.push(`+ ${num(h.newStartLine + k)}│${l}`));
1148
+ h.after.forEach((l, k) => body.push(` ${num(h.newStartLine + h.added.length + k)}│${l}`));
1149
+ if (body.length > maxLines) {
1150
+ const keep = Math.floor(maxLines / 2);
1151
+ out.push(...body.slice(0, keep), ` … ${body.length - 2 * keep} lines omitted …`, ...body.slice(-keep));
1152
+ } else out.push(...body);
1153
+ });
1154
+ if (res.hunks.length > maxHunks) out.push("", `… ${res.hunks.length - maxHunks} more change(s) not shown.`);
1155
+ out.push("", "Line numbers above (new L…) reflect the file after this edit; other line numbers you saw earlier may have shifted.");
1156
+ return out.join("\n");
1157
+ }
1158
+
1159
+ // ───────────────────────────── whitespace-sensitive files ─────────────────────────────
1160
+
1161
+ type WsKind = "python" | "yaml" | "make";
1162
+
1163
+ function wsKind(filePath: string): WsKind | undefined {
1164
+ const base = filePath.replace(/\\/g, "/").split("/").pop() ?? "";
1165
+ if (/\.(py|pyi|pyw)$/i.test(base)) return "python";
1166
+ if (/\.(ya?ml)$/i.test(base)) return "yaml";
1167
+ if (/^(GNUmakefile|makefile|Makefile)$/.test(base) || /\.mk$/i.test(base)) return "make";
1168
+ return undefined;
1169
+ }
1170
+
1171
+ const WS_LABEL: Record<WsKind, string> = { python: "Python", yaml: "YAML", make: "Makefile" };
1172
+
1173
+ /** Rule header such as "build: deps" (not ":=" / "::=" assignments, not "X = a:b"). */
1174
+ const MAKE_RULE_RE = /^[^\s#=][^=]*?:(?!=)/;
1175
+
1176
+ function lineList(ls: number[]): string {
1177
+ return ls.length > 8 ? `${ls.slice(0, 8).join(", ")}, …` : ls.join(", ");
1178
+ }
1179
+
1180
+ /**
1181
+ * Cheap, textual checks for files whose whitespace carries meaning. They do not parse the
1182
+ * language; they flag the edits most likely to have changed meaning silently, so the model
1183
+ * (and the user) look at the diff or run the parser/tests instead of trusting the match.
1184
+ * Only lines added by this edit are inspected, so pre-existing issues are not reported.
1185
+ */
1186
+ export function whitespaceWarnings(filePath: string, res: ApplyResult): string[] {
1187
+ const kind = wsKind(filePath);
1188
+ if (!kind) return [];
1189
+ const label = WS_LABEL[kind];
1190
+ const out: string[] = [];
1191
+
1192
+ const tolerant = [...new Set(res.ranges.filter((r) => r.level === "indent" || r.level === "loose").map((r) => r.editIndex + 1))];
1193
+ if (tolerant.length) {
1194
+ out.push(
1195
+ `${label} is whitespace-sensitive and edit ${tolerant.map((n) => "#" + n).join(", ")} matched only after ignoring ` +
1196
+ `indentation/whitespace. Re-indentation is textual, not syntax-aware: check the diff below and run the parser or tests.`,
1197
+ );
1198
+ }
1199
+
1200
+ const finalLines = res.text.split("\n").map((l) => l.replace(/\r$/, ""));
1201
+ const fileStyle = wsStyle(finalLines.filter((l) => l.trim() !== "").map(leadingWs));
1202
+ const mixed: number[] = [];
1203
+ const offStyle: number[] = [];
1204
+ const yamlTabs: number[] = [];
1205
+ const makeSpaces: number[] = [];
1206
+
1207
+ for (const h of res.hunks) {
1208
+ h.added.forEach((raw, k) => {
1209
+ const line = raw.replace(/\r$/, "");
1210
+ if (line.trim() === "") return;
1211
+ const ln = h.newStartLine + k;
1212
+ const ws = leadingWs(line);
1213
+ if (kind === "python") {
1214
+ if (ws.includes("\t") && ws.includes(" ")) mixed.push(ln);
1215
+ else if (ws && fileStyle && (ws[0] === "\t" ? "tabs" : "spaces") !== fileStyle) offStyle.push(ln);
1216
+ } else if (kind === "yaml") {
1217
+ if (ws.includes("\t")) yamlTabs.push(ln);
1218
+ } else if (ws.startsWith(" ")) {
1219
+ // A recipe line indented with spaces is the classic "missing separator" error.
1220
+ let p = ln - 2;
1221
+ while (p >= 0 && finalLines[p].trim() === "") p--;
1222
+ const prev = p >= 0 ? finalLines[p] : "";
1223
+ if (prev.startsWith("\t") || MAKE_RULE_RE.test(prev)) makeSpaces.push(ln);
1224
+ }
1225
+ });
1226
+ }
1227
+
1228
+ if (mixed.length) out.push(`Python: new line(s) ${lineList(mixed)} mix tabs and spaces in their indentation (TabError risk).`);
1229
+ if (offStyle.length) {
1230
+ out.push(`Python: new line(s) ${lineList(offStyle)} are indented with ${fileStyle === "tabs" ? "spaces" : "tabs"} but the file uses ${fileStyle}.`);
1231
+ }
1232
+ if (yamlTabs.length) out.push(`YAML: new line(s) ${lineList(yamlTabs)} use tabs for indentation, which YAML does not allow.`);
1233
+ if (makeSpaces.length) {
1234
+ out.push(`Makefile: new line(s) ${lineList(makeSpaces)} look like recipe lines but start with spaces; recipes must start with a tab.`);
1235
+ }
1236
+ return out;
1237
+ }