@kolisachint/hoocode-agent 0.5.49 → 0.5.50

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.
@@ -24,6 +24,138 @@ export function normalizeToLF(text) {
24
24
  export function restoreLineEndings(text, ending) {
25
25
  return ending === "\r\n" ? text.replace(/\n/g, "\r\n") : text;
26
26
  }
27
+ /** Combining marks attach to the preceding base character and normalize with it. */
28
+ const COMBINING_MARK = /\p{Mn}/u;
29
+ /**
30
+ * NFKC, applied per grapheme cluster so each output character can be traced to
31
+ * the cluster that produced it. Clustering keeps `e` + U+0301 composing into
32
+ * `é` the way whole-string NFKC would, while `fi` still expands to `fi` with both
33
+ * characters pointing at the single source ligature.
34
+ */
35
+ function nfkcWithMap(text) {
36
+ let out = "";
37
+ const map = [];
38
+ let i = 0;
39
+ while (i < text.length) {
40
+ const start = i;
41
+ const base = String.fromCodePoint(text.codePointAt(i));
42
+ i += base.length;
43
+ let cluster = base;
44
+ while (i < text.length) {
45
+ const next = String.fromCodePoint(text.codePointAt(i));
46
+ if (!COMBINING_MARK.test(next))
47
+ break;
48
+ cluster += next;
49
+ i += next.length;
50
+ }
51
+ const composed = cluster.normalize("NFKC");
52
+ for (let k = 0; k < composed.length; k++)
53
+ map.push(start);
54
+ out += composed;
55
+ }
56
+ map.push(text.length);
57
+ return { text: out, map };
58
+ }
59
+ /** CRLF and lone CR collapse to LF. */
60
+ function toLfWithMap(text) {
61
+ let out = "";
62
+ const map = [];
63
+ for (let i = 0; i < text.length; i++) {
64
+ const ch = text[i];
65
+ if (ch === "\r") {
66
+ map.push(i);
67
+ out += "\n";
68
+ if (text[i + 1] === "\n")
69
+ i++;
70
+ continue;
71
+ }
72
+ map.push(i);
73
+ out += ch;
74
+ }
75
+ map.push(text.length);
76
+ return { text: out, map };
77
+ }
78
+ /**
79
+ * The per-line whitespace pass: tabs widen to two spaces, interior runs of two
80
+ * or more spaces collapse to one (leading indentation is left alone), and
81
+ * trailing whitespace is dropped.
82
+ */
83
+ function normalizeLineWhitespaceWithMap(text) {
84
+ let out = "";
85
+ const map = [];
86
+ let lineStart = 0;
87
+ while (lineStart <= text.length) {
88
+ let lineEnd = text.indexOf("\n", lineStart);
89
+ const hasNewline = lineEnd !== -1;
90
+ if (!hasNewline)
91
+ lineEnd = text.length;
92
+ // Tabs first, so indentation is measured the way the old chain measured it.
93
+ let expanded = "";
94
+ const expandedMap = [];
95
+ for (let i = lineStart; i < lineEnd; i++) {
96
+ if (text[i] === "\t") {
97
+ expanded += " ";
98
+ expandedMap.push(i, i);
99
+ }
100
+ else {
101
+ expanded += text[i];
102
+ expandedMap.push(i);
103
+ }
104
+ }
105
+ const leadingLength = (expanded.match(/^\s*/)?.[0] ?? "").length;
106
+ let emitted = "";
107
+ const emittedMap = [];
108
+ for (let i = 0; i < expanded.length; i++) {
109
+ // Collapse only runs that start past the indentation.
110
+ if (i >= leadingLength && expanded[i] === " " && emitted.endsWith(" ") && emitted.length > leadingLength) {
111
+ continue;
112
+ }
113
+ emitted += expanded[i];
114
+ emittedMap.push(expandedMap[i]);
115
+ }
116
+ // trimEnd
117
+ let end = emitted.length;
118
+ while (end > 0 && /\s/.test(emitted[end - 1]))
119
+ end--;
120
+ out += emitted.slice(0, end);
121
+ for (let i = 0; i < end; i++)
122
+ map.push(emittedMap[i]);
123
+ if (hasNewline) {
124
+ out += "\n";
125
+ map.push(lineEnd);
126
+ lineStart = lineEnd + 1;
127
+ }
128
+ else {
129
+ break;
130
+ }
131
+ }
132
+ map.push(text.length);
133
+ return { text: out, map };
134
+ }
135
+ /** Compose `outer` (indices into `inner.text`) onto `inner`'s own source indices. */
136
+ function composeMaps(outer, inner) {
137
+ return outer.map((i) => inner[i] ?? inner[inner.length - 1]);
138
+ }
139
+ /**
140
+ * Normalize text for fuzzy matching, keeping an index back to the source for
141
+ * every character produced. Same output text as `normalizeForFuzzyMatch`.
142
+ */
143
+ function normalizeForFuzzyMatchWithMap(text) {
144
+ const nfkc = nfkcWithMap(text);
145
+ const lf = toLfWithMap(nfkc.text);
146
+ const lines = normalizeLineWhitespaceWithMap(lf.text);
147
+ // The final substitutions are one-for-one, so they leave the map untouched.
148
+ const substituted = applyCharSubstitutions(lines.text);
149
+ return { text: substituted, map: composeMaps(composeMaps(lines.map, lf.map), nfkc.map) };
150
+ }
151
+ /** The one-for-one Unicode substitutions: quotes, dashes and exotic spaces. */
152
+ function applyCharSubstitutions(text) {
153
+ return text
154
+ .replace(/[\u2018\u2019\u201A\u201B]/g, "'")
155
+ .replace(/[\u201C\u201D\u201E\u201F]/g, '"')
156
+ .replace(/[\u2010\u2011\u2012\u2013\u2014\u2015\u2212]/g, "-")
157
+ .replace(/[\u00A0\u2002-\u200A\u202F\u205F\u3000]/g, " ");
158
+ }
27
159
  /**
28
160
  * Normalize text for fuzzy matching. Applies progressive transformations:
29
161
  * - Normalize line endings to LF
@@ -80,82 +212,17 @@ function normalizeForFuzzyMatch(text) {
80
212
  normalizeCache.set(text, normalized);
81
213
  return normalized;
82
214
  }
83
- function buildLineIndex(text) {
84
- const starts = [];
85
- const lines = [];
86
- let start = 0;
87
- for (;;) {
88
- const newline = text.indexOf("\n", start);
89
- if (newline === -1) {
90
- starts.push(start);
91
- lines.push(text.slice(start));
92
- return { text, starts, lines };
93
- }
94
- starts.push(start);
95
- lines.push(text.slice(start, newline + 1));
96
- start = newline + 1;
97
- }
98
- }
99
- /** Index of the line containing `offset` (greatest `i` with `starts[i] <= offset`). */
100
- function lineIndexAt(starts, offset) {
101
- let low = 0;
102
- let high = starts.length - 1;
103
- while (low < high) {
104
- const mid = (low + high + 1) >> 1;
105
- if (starts[mid] <= offset)
106
- low = mid;
107
- else
108
- high = mid - 1;
109
- }
110
- return low;
215
+ /** Whether `index` in `text` is the first character of a line. */
216
+ function isAtLineStart(text, index) {
217
+ return index === 0 || text[index - 1] === "\n";
111
218
  }
112
219
  /**
113
- * Translate a match found in fuzzy-normalized space back into a span of the
114
- * original content.
115
- *
116
- * Normalization never adds or removes a newline, so normalized line N always
117
- * corresponds to original line N; only columns within a line can shift (a tab
118
- * widens to two spaces, a run of spaces collapses, NFKC changes a character's
119
- * length). Rather than track per-character offsets through those transforms,
120
- * the span is widened to whole original lines, and whatever of the first and
121
- * last line the match did not cover is re-attached around `newText` in
122
- * normalized form.
123
- *
124
- * The practical effect: a match that covers whole lines - by far the common
125
- * case - rewrites exactly those lines and leaves every other byte of the file
126
- * untouched. Only a partial-line fuzzy match normalizes anything the edit did
127
- * not ask for, and never beyond the lines it landed on.
220
+ * Indentation of `line`, with tabs widened the way `normalizeForFuzzyMatch`
221
+ * widens them, so a tab-indented file and a two-space rendering of it compare
222
+ * equal while genuinely different nesting levels do not.
128
223
  */
129
- function resolveFuzzySpans(original, normalized, normStarts, normLength, newText) {
130
- const spans = [];
131
- const lineOf = (offset) => lineIndexAt(normalized.starts, offset);
132
- let i = 0;
133
- while (i < normStarts.length) {
134
- const firstLine = lineOf(normStarts[i]);
135
- let lastLine = lineOf(normStarts[i] + normLength - 1);
136
- // Matches are ascending and non-overlapping. Absorb any that land inside
137
- // the same line block so the block is emitted once with every
138
- // substitution applied, rather than as colliding same-line spans.
139
- let end = i + 1;
140
- while (end < normStarts.length && lineOf(normStarts[end]) <= lastLine) {
141
- lastLine = Math.max(lastLine, lineOf(normStarts[end] + normLength - 1));
142
- end++;
143
- }
144
- const blockStart = normalized.starts[firstLine];
145
- const blockEnd = normalized.starts[lastLine] + normalized.lines[lastLine].length;
146
- let replacement = "";
147
- let cursor = blockStart;
148
- for (let k = i; k < end; k++) {
149
- replacement += normalized.text.slice(cursor, normStarts[k]) + newText;
150
- cursor = normStarts[k] + normLength;
151
- }
152
- replacement += normalized.text.slice(cursor, blockEnd);
153
- const matchIndex = original.starts[firstLine];
154
- const matchEnd = original.starts[lastLine] + original.lines[lastLine].length;
155
- spans.push({ matchIndex, matchLength: matchEnd - matchIndex, replacement });
156
- i = end;
157
- }
158
- return spans;
224
+ function normalizedIndent(line) {
225
+ return (line.match(/^[ \t]*/)?.[0] ?? "").replace(/\t/g, " ");
159
226
  }
160
227
  /**
161
228
  * Locate one edit's `oldText` in `content`, always returning spans in
@@ -167,9 +234,14 @@ function resolveFuzzySpans(original, normalized, normStarts, normLength, newText
167
234
  * be shorter, because two fuzzy matches sharing a line are emitted as one
168
235
  * rewrite of that line.
169
236
  */
170
- function findEditSpans(content, edit, lineIndexes) {
237
+ function findEditSpans(content, edit, fuzzyIndex) {
238
+ // An oldText that opens with indentation is a statement about a whole line, so
239
+ // it must not match mid-line inside a more deeply indented one - that lands the
240
+ // edit in a different block entirely. An oldText that opens with a non-blank
241
+ // character claims nothing about indentation, so every tier stays tolerant.
242
+ const anchored = /^[ \t]/.test(edit.oldText);
171
243
  // Tier 1: exact. Already in original coordinates.
172
- const exact = collectMatchIndices(content, edit.oldText);
244
+ const exact = collectMatchIndices(content, edit.oldText).filter((i) => !anchored || isAtLineStart(content, i));
173
245
  if (exact.length > 0) {
174
246
  return {
175
247
  spans: exact.map((matchIndex) => ({
@@ -180,11 +252,53 @@ function findEditSpans(content, edit, lineIndexes) {
180
252
  occurrences: exact.length,
181
253
  };
182
254
  }
183
- // Tier 2: fuzzy. Located in normalized space, then translated back.
184
- const indexes = lineIndexes();
185
- if (indexes) {
255
+ // Tier 2: fuzzy. Located in normalized space, then mapped straight back onto
256
+ // the bytes it matched - nothing outside the match is rewritten.
257
+ const normalized = fuzzyIndex();
258
+ if (normalized) {
186
259
  const fuzzyOldText = normalizeForFuzzyMatch(edit.oldText);
187
- const fuzzy = collectMatchIndices(indexes.normalized.text, fuzzyOldText);
260
+ const normalizedText = normalized.text;
261
+ const fuzzy = collectMatchIndices(normalizedText, fuzzyOldText).filter((i) => !anchored || isAtLineStart(normalizedText, i));
262
+ const spanAt = (index, replacement) => {
263
+ const start = normalized.map[index];
264
+ const end = normalized.map[index + fuzzyOldText.length];
265
+ return { matchIndex: start, matchLength: end - start, replacement };
266
+ };
267
+ /**
268
+ * Build the replacement for a fuzzy match.
269
+ *
270
+ * newText is written as the model wrote it, with one exception: indentation.
271
+ * A fuzzy match means oldText was not on disk byte-for-byte, so the whitespace
272
+ * in it is the model's rendering of the line rather than a statement about the
273
+ * file - and newText inherits that rendering. Writing it back re-indents lines
274
+ * the edit never meant to touch, which in a tab-indented file means every
275
+ * fuzzy edit silently converts tabs to spaces.
276
+ *
277
+ * So when newText's indentation says the same thing oldText's did, the file's
278
+ * own indentation is kept. An edit that means to re-indent says so by giving
279
+ * newText a different indentation from oldText, and that still applies.
280
+ */
281
+ const replacementFor = (index) => {
282
+ const start = normalized.map[index];
283
+ const end = normalized.map[index + fuzzyOldText.length];
284
+ if (!isAtLineStart(content, start))
285
+ return edit.newText;
286
+ const originalLines = content.slice(start, end).split("\n");
287
+ const newLines = edit.newText.split("\n");
288
+ const oldLines = edit.oldText.split("\n");
289
+ if (originalLines.length !== newLines.length || oldLines.length !== newLines.length) {
290
+ return edit.newText;
291
+ }
292
+ return newLines
293
+ .map((line, i) => {
294
+ const newIndent = line.match(/^[ \t]*/)?.[0] ?? "";
295
+ const oldIndent = oldLines[i].match(/^[ \t]*/)?.[0] ?? "";
296
+ if (normalizedIndent(newIndent) !== normalizedIndent(oldIndent))
297
+ return line;
298
+ return (originalLines[i].match(/^[ \t]*/)?.[0] ?? "") + line.slice(newIndent.length);
299
+ })
300
+ .join("\n");
301
+ };
188
302
  if (fuzzy.length > 0) {
189
303
  // oldText did not match the file byte-for-byte, so the whitespace the model
190
304
  // used is its own rendering rather than a statement about the file. If
@@ -192,22 +306,24 @@ function findEditSpans(content, edit, lineIndexes) {
192
306
  // span exactly as it is so the no-change error fires, instead of rewriting
193
307
  // the line's indentation to match the model's rendering of it.
194
308
  if (normalizeForFuzzyMatch(edit.newText) === fuzzyOldText) {
195
- return {
196
- spans: resolveFuzzySpans(indexes.original, indexes.normalized, fuzzy, fuzzyOldText.length, edit.newText).map((span) => ({
197
- ...span,
198
- replacement: content.slice(span.matchIndex, span.matchIndex + span.matchLength),
199
- })),
200
- occurrences: fuzzy.length,
201
- };
309
+ // oldText did not match byte-for-byte, so its whitespace is the model's
310
+ // rendering rather than a statement about the file, and newText asks for
311
+ // nothing the matcher can see. Leave the bytes alone and report the span
312
+ // so the caller can name the character the model failed to reproduce.
313
+ const untouched = fuzzy.map((index) => {
314
+ const span = spanAt(index, "");
315
+ return { ...span, replacement: content.slice(span.matchIndex, span.matchIndex + span.matchLength) };
316
+ });
317
+ return { spans: untouched, occurrences: fuzzy.length, noopFuzzySpan: untouched[0] };
202
318
  }
203
319
  return {
204
- spans: resolveFuzzySpans(indexes.original, indexes.normalized, fuzzy, fuzzyOldText.length, edit.newText),
320
+ spans: fuzzy.map((index) => spanAt(index, replacementFor(index))),
205
321
  occurrences: fuzzy.length,
206
322
  };
207
323
  }
208
324
  }
209
325
  // Tier 3: indentation-tolerant line blocks. Already in original coordinates.
210
- const blocks = findLineBlockMatches(content, edit.oldText).map((span) => ({
326
+ const blocks = findLineBlockMatches(content, edit.oldText, anchored).map((span) => ({
211
327
  matchIndex: span.matchIndex,
212
328
  matchLength: span.matchLength,
213
329
  replacement: edit.newText,
@@ -252,7 +368,7 @@ function blockNormalizeLine(line) {
252
368
  * trimmed lines equal the trimmed oldText lines. Replacement still happens in
253
369
  * the original content space, so surrounding formatting is preserved.
254
370
  */
255
- function findLineBlockMatches(content, oldText) {
371
+ function findLineBlockMatches(content, oldText, anchored = false) {
256
372
  const hadTrailingNewline = oldText.endsWith("\n");
257
373
  const oldLines = oldText.split("\n");
258
374
  if (hadTrailingNewline)
@@ -279,6 +395,12 @@ function findLineBlockMatches(content, oldText) {
279
395
  ok = false;
280
396
  break;
281
397
  }
398
+ // Tier 3 ignores indentation by design. When oldText stated its own
399
+ // indentation, honour that statement rather than matching any nesting level.
400
+ if (anchored && normalizedIndent(contentLines[i + j]) !== normalizedIndent(oldLines[j])) {
401
+ ok = false;
402
+ break;
403
+ }
282
404
  }
283
405
  if (!ok)
284
406
  continue;
@@ -311,6 +433,105 @@ function getEmptyOldTextError(path, editIndex, totalEdits) {
311
433
  }
312
434
  return new Error(`edits[${editIndex}].oldText must not be empty in ${path}.`);
313
435
  }
436
+ /**
437
+ * Characters the fuzzy matcher erases. When an edit matched only fuzzily and its
438
+ * newText normalizes to the same text, one of these is why: the file holds a
439
+ * character the model reproduced as its plain-ASCII lookalike, so the change it
440
+ * asked for is invisible to the matcher and can never be applied by retrying the
441
+ * same text. Naming the character is the whole recovery - resend oldText with it.
442
+ */
443
+ const NORMALIZED_AWAY_NAMES = new Map([
444
+ [0x0009, "TAB"],
445
+ [0x00a0, "NO-BREAK SPACE"],
446
+ [0x2002, "EN SPACE"],
447
+ [0x2003, "EM SPACE"],
448
+ [0x2009, "THIN SPACE"],
449
+ [0x200a, "HAIR SPACE"],
450
+ [0x2010, "HYPHEN"],
451
+ [0x2011, "NON-BREAKING HYPHEN"],
452
+ [0x2012, "FIGURE DASH"],
453
+ [0x2013, "EN DASH"],
454
+ [0x2014, "EM DASH"],
455
+ [0x2015, "HORIZONTAL BAR"],
456
+ [0x2018, "LEFT SINGLE QUOTATION MARK"],
457
+ [0x2019, "RIGHT SINGLE QUOTATION MARK"],
458
+ [0x201a, "SINGLE LOW-9 QUOTATION MARK"],
459
+ [0x201b, "SINGLE HIGH-REVERSED-9 QUOTATION MARK"],
460
+ [0x201c, "LEFT DOUBLE QUOTATION MARK"],
461
+ [0x201d, "RIGHT DOUBLE QUOTATION MARK"],
462
+ [0x201e, "DOUBLE LOW-9 QUOTATION MARK"],
463
+ [0x201f, "DOUBLE HIGH-REVERSED-9 QUOTATION MARK"],
464
+ [0x202f, "NARROW NO-BREAK SPACE"],
465
+ [0x205f, "MEDIUM MATHEMATICAL SPACE"],
466
+ [0x2212, "MINUS SIGN"],
467
+ [0x3000, "IDEOGRAPHIC SPACE"],
468
+ ]);
469
+ function formatCodePoint(ch) {
470
+ const cp = ch.codePointAt(0) ?? 0;
471
+ const hex = `U+${cp.toString(16).toUpperCase().padStart(4, "0")}`;
472
+ const name = NORMALIZED_AWAY_NAMES.get(cp);
473
+ if (name)
474
+ return `${hex} ${name}`;
475
+ // NFKC-only difference (ligature, full-width form, superscript, ...).
476
+ return `${hex} (normalizes to ${JSON.stringify(ch.normalize("NFKC"))})`;
477
+ }
478
+ /** Cap on how many offending characters one error names before summarising. */
479
+ const MAX_REPORTED_CHARS = 5;
480
+ /**
481
+ * Every character in `text` that fuzzy normalization would not leave alone.
482
+ *
483
+ * All of them are listed rather than just the first: the match span is widened to
484
+ * whole lines, so the first offender is often a leading tab the model never put
485
+ * in its oldText, while the character it actually needs sits further along the
486
+ * line. Naming one of them and guessing wrong is worse than naming them all.
487
+ */
488
+ function findNormalizedAwayChars(text) {
489
+ const found = [];
490
+ let index = 0;
491
+ for (const ch of text) {
492
+ const cp = ch.codePointAt(0) ?? 0;
493
+ if (NORMALIZED_AWAY_NAMES.has(cp) || ch.normalize("NFKC") !== ch) {
494
+ found.push({ ch, index });
495
+ }
496
+ index += ch.length;
497
+ }
498
+ return found;
499
+ }
500
+ /** 1-indexed line and column of `offset` within `content`. */
501
+ function lineAndColumn(content, offset) {
502
+ const before = content.slice(0, offset);
503
+ const line = before.split("\n").length;
504
+ const column = offset - (before.lastIndexOf("\n") + 1) + 1;
505
+ return { line, column };
506
+ }
507
+ /**
508
+ * The edit matched, but only after normalization erased the very difference it
509
+ * asked for. Retrying the same oldText can never succeed, so say which character
510
+ * is actually on disk and where.
511
+ */
512
+ function getFuzzyNoopError(path, editIndex, totalEdits, content, span) {
513
+ const matched = content.slice(span.matchIndex, span.matchIndex + span.matchLength);
514
+ const which = totalEdits === 1 ? "The edit" : `edits[${editIndex}]`;
515
+ const offenders = findNormalizedAwayChars(matched);
516
+ let detail;
517
+ if (offenders.length > 0) {
518
+ const shown = offenders.slice(0, MAX_REPORTED_CHARS).map((o) => {
519
+ const { line, column } = lineAndColumn(content, span.matchIndex + o.index);
520
+ return `${formatCodePoint(o.ch)} at line ${line}, column ${column}`;
521
+ });
522
+ const more = offenders.length - shown.length;
523
+ detail =
524
+ `the text it matched in ${path} contains ${shown.join("; ")}` +
525
+ `${more > 0 ? `; and ${more} more` : ""}, which your oldText spelled as plain-ASCII lookalikes. ` +
526
+ `Send oldText containing those exact characters and the replacement will apply.`;
527
+ }
528
+ else {
529
+ detail =
530
+ `oldText matched ${path} only after whitespace normalization, and newText normalizes to the same text, ` +
531
+ `so nothing would change. Send oldText exactly as the file spells it.`;
532
+ }
533
+ return new Error(`No changes made to ${path}. ${which} asked for a change that is invisible to matching: ${detail}`);
534
+ }
314
535
  function getNoChangeError(path, totalEdits) {
315
536
  if (totalEdits === 1) {
316
537
  return new Error(`No changes made to ${path}. The replacement produced identical content. This might indicate an issue with special characters or the text not existing as expected.`);
@@ -341,26 +562,30 @@ export function applyEditsToNormalizedContent(normalizedContent, edits, path) {
341
562
  }
342
563
  }
343
564
  const baseContent = normalizedContent;
344
- // Both line indexes are needed only if some edit reaches the fuzzy tier, and
345
- // they are identical for every edit, so build them at most once. Normalization
346
- // is line-preserving; if that ever fails to hold, the fuzzy tier is skipped
347
- // rather than risk translating a span against a mismatched line table.
348
- let lineIndexesCache;
349
- const lineIndexes = () => {
350
- if (lineIndexesCache === undefined) {
351
- const original = buildLineIndex(baseContent);
352
- const normalized = buildLineIndex(normalizeForFuzzyMatch(baseContent));
353
- lineIndexesCache = original.lines.length === normalized.lines.length ? { original, normalized } : null;
565
+ // Needed only if some edit reaches the fuzzy tier, and identical for every
566
+ // edit, so build it at most once. A map that does not line up with its own
567
+ // text would put spans in the wrong place, so the tier is skipped rather than
568
+ // trusted if that ever fails to hold.
569
+ let normalizedCache;
570
+ const fuzzyIndex = () => {
571
+ if (normalizedCache === undefined) {
572
+ const built = normalizeForFuzzyMatchWithMap(baseContent);
573
+ normalizedCache = built.map.length === built.text.length + 1 ? built : null;
354
574
  }
355
- return lineIndexesCache;
575
+ return normalizedCache;
356
576
  };
357
577
  const matchedEdits = [];
358
578
  for (let i = 0; i < normalizedEdits.length; i++) {
359
579
  const edit = normalizedEdits[i];
360
- const { spans, occurrences } = findEditSpans(baseContent, edit, lineIndexes);
580
+ const { spans, occurrences, noopFuzzySpan } = findEditSpans(baseContent, edit, fuzzyIndex);
361
581
  if (spans.length === 0) {
362
582
  throw getNotFoundError(path, i, normalizedEdits.length);
363
583
  }
584
+ // Raised per edit, not once for the whole call: a no-op hidden among edits
585
+ // that do change bytes used to be swallowed and reported as a success.
586
+ if (noopFuzzySpan) {
587
+ throw getFuzzyNoopError(path, i, normalizedEdits.length, baseContent, noopFuzzySpan);
588
+ }
364
589
  if (edit.replaceAll) {
365
590
  // Replace every occurrence so the shared reverse-order applier rewrites them all.
366
591
  for (const span of spans) {