Skip to content

Commit aa43d9b

Browse files
lee-xydtclaude
andcommitted
fix: scope fuzzy-edit normalization to matched region only (#89994)
When any edit falls back to fuzzy matching, normalizeForFuzzyMatch was applied to the entire file, silently mutating unrelated lines (trailing whitespace stripped, smart quotes/dashes/spaces converted to ASCII, NFKC recomposed). The model-facing diff was computed against the already-normalized base, making the corruption invisible. Fix: use normalizeForFuzzyMatch only to locate match positions. Add findOriginalPosition (prefix-based binary search) and findOriginalSpan to map fuzzy match positions back to original content. Keep baseContent as the original LF-normalized content so untouched lines are preserved byte-for-byte. Closes #89994. Co-Authored-By: Claude <[email protected]>
1 parent 2e6e17f commit aa43d9b

3 files changed

Lines changed: 354 additions & 18 deletions

File tree

scripts/proof-issue-89994.ts

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
/**
2+
* Proof script for issue #89994 fix.
3+
*
4+
* Demonstrates that a fuzzy edit no longer silently normalizes
5+
* unrelated lines in the file.
6+
*
7+
* Usage: npx tsx scripts/proof-issue-89994.ts
8+
*/
9+
import {
10+
applyEditsToNormalizedContent,
11+
generateDiffString,
12+
} from "../src/agents/sessions/tools/edit-diff.js";
13+
14+
const divider = "=".repeat(60);
15+
16+
// ── Test 1: Em dash + trailing whitespace ─────────────────────────
17+
{
18+
console.log(divider);
19+
console.log("TEST 1: Fuzzy edit preserves em dash & trailing whitespace");
20+
console.log(divider);
21+
22+
const original =
23+
'const label = "a — b";\n' + // em dash in unrelated line
24+
"let x = 1; \n" + // trailing spaces in unrelated line
25+
"\n" +
26+
"function bar() {\n" +
27+
" return 2;\n" + // only line targeted by the edit
28+
"}\n";
29+
30+
// oldText uses non-breaking spaces (U+00A0) where the file has
31+
// regular spaces — this triggers the fuzzy matching path.
32+
const edits = [{ oldText: "  return 2;", newText: " return 3;" }];
33+
34+
const { baseContent, newContent } = applyEditsToNormalizedContent(original, edits, "/x.ts");
35+
const { diff } = generateDiffString(baseContent, newContent);
36+
37+
console.log("\nOriginal file (JSON-escaped):");
38+
console.log(JSON.stringify(original));
39+
console.log("\nFile after edit (JSON-escaped):");
40+
console.log(JSON.stringify(newContent));
41+
console.log("\nDiff shown to model:");
42+
console.log(diff);
43+
44+
// Checks
45+
const emDashPreserved = newContent.includes("—");
46+
const trailingSpacePreserved = newContent.includes("let x = 1; ");
47+
const editApplied = newContent.includes(" return 3;");
48+
const oldGone = !newContent.includes("return 2;");
49+
const baseIsOriginal = baseContent === original;
50+
51+
console.log("\nChecks:");
52+
console.log(` em dash preserved: ${emDashPreserved ? "PASS" : "FAIL"}`);
53+
console.log(` trailing spaces preserved: ${trailingSpacePreserved ? "PASS" : "FAIL"}`);
54+
console.log(` edit applied: ${editApplied ? "PASS" : "FAIL"}`);
55+
console.log(` old text removed: ${oldGone ? "PASS" : "FAIL"}`);
56+
console.log(` baseContent is original: ${baseIsOriginal ? "PASS" : "FAIL"}`);
57+
58+
if (emDashPreserved && trailingSpacePreserved && editApplied && oldGone && baseIsOriginal) {
59+
console.log("\n => ALL CHECKS PASSED");
60+
}
61+
}
62+
63+
// ── Test 2: Smart quotes ──────────────────────────────────────────
64+
{
65+
console.log("\n" + divider);
66+
console.log("TEST 2: Fuzzy edit preserves smart quotes");
67+
console.log(divider);
68+
69+
const original =
70+
'const msg = "“help”";\n' + // smart double quotes
71+
"\n" +
72+
"function foo() {\n" +
73+
" return 1;\n" +
74+
"}\n";
75+
76+
const edits = [{ oldText: "  return 1;", newText: " return 2;" }];
77+
78+
const { newContent } = applyEditsToNormalizedContent(original, edits, "/x.ts");
79+
80+
console.log("\nOriginal (JSON):", JSON.stringify(original));
81+
console.log("After edit (JSON):", JSON.stringify(newContent));
82+
83+
const smartQuotesPreserved = newContent.includes("“help”");
84+
console.log(`\n smart quotes preserved: ${smartQuotesPreserved ? "PASS" : "FAIL"}`);
85+
}
86+
87+
// ── Test 3: NFKC ligature ─────────────────────────────────────────
88+
{
89+
console.log("\n" + divider);
90+
console.log("TEST 3: Fuzzy edit preserves NFKC ligature");
91+
console.log(divider);
92+
93+
const original =
94+
'const ligature = "ffi";\n' + // ffi (LATIN SMALL LIGATURE FFI)
95+
"\n" +
96+
"function foo() {\n" +
97+
" return 1;\n" +
98+
"}\n";
99+
100+
const edits = [{ oldText: "  return 1;", newText: " return 2;" }];
101+
102+
const { newContent } = applyEditsToNormalizedContent(original, edits, "/x.ts");
103+
104+
console.log("\nOriginal (JSON):", JSON.stringify(original));
105+
console.log("After edit (JSON):", JSON.stringify(newContent));
106+
107+
const ligaturePreserved = newContent.includes("ffi");
108+
const notDecomposed = !newContent.includes("ffi");
109+
console.log(` ligature preserved: ${ligaturePreserved ? "PASS" : "FAIL"}`);
110+
console.log(` NOT decomposed to ffi: ${notDecomposed ? "PASS" : "FAIL"}`);
111+
}
112+
113+
// ── Summary ────────────────────────────────────────────────────────
114+
console.log("\n" + divider);
115+
console.log("SUMMARY: All fuzzy edits preserve unrelated lines byte-for-byte.");
116+
console.log("Fix verified on " + new Date().toISOString());
117+
console.log(divider);
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
/**
2+
* Regression tests for the fuzzy-edit normalization bug (#89994).
3+
*
4+
* When any edit falls back to fuzzy matching, normalizeForFuzzyMatch must be
5+
* used only to locate the match position — the base content must remain the
6+
* original so that unrelated lines are never silently mutated.
7+
*/
8+
import { describe, expect, it } from "vitest";
9+
import { applyEditsToNormalizedContent, generateDiffString } from "./edit-diff.js";
10+
11+
describe("applyEditsToNormalizedContent fuzzy matching", () => {
12+
// ── Core regression: issue #89994 repro ──────────────────────────
13+
it("preserves em dash and trailing whitespace on unrelated lines during fuzzy edit", () => {
14+
const original =
15+
'const label = "a — b";\n' + // em dash in unrelated line
16+
"let x = 1; \n" + // trailing spaces in unrelated line
17+
"\n" +
18+
"function bar() {\n" +
19+
" return 2;\n" + // only line targeted by the edit
20+
"}\n";
21+
22+
// oldText contains non-breaking spaces (U+00A0) where the file has
23+
// regular spaces, so exact match fails and fuzzy match takes over.
24+
const edits = [{ oldText: "  return 2;", newText: " return 3;" }];
25+
26+
const { baseContent, newContent } = applyEditsToNormalizedContent(original, edits, "/x.ts");
27+
28+
// baseContent must be the original, NOT fuzzy-normalized.
29+
expect(baseContent).toBe(original);
30+
31+
// Exact output: only the targeted line changes.
32+
const expected =
33+
'const label = "a — b";\n' +
34+
"let x = 1; \n" +
35+
"\n" +
36+
"function bar() {\n" +
37+
" return 3;\n" +
38+
"}\n";
39+
expect(newContent).toBe(expected);
40+
41+
// Additional spot checks.
42+
expect(newContent).toContain("—"); // em dash preserved
43+
expect(newContent).toContain("let x = 1; "); // trailing whitespace preserved
44+
expect(newContent).toContain(" return 3;"); // edit applied
45+
expect(newContent).not.toContain("return 2;"); // old value gone
46+
});
47+
48+
it("preserves smart quotes on unrelated lines during fuzzy edit", () => {
49+
const original =
50+
'const msg = "“help”";\n' + // smart double quotes
51+
"\n" +
52+
"function foo() {\n" +
53+
" return 1;\n" +
54+
"}\n";
55+
56+
const edits = [{ oldText: "  return 1;", newText: " return 2;" }];
57+
58+
const { newContent } = applyEditsToNormalizedContent(original, edits, "/x.ts");
59+
60+
// Exact output: smart quotes and unrelated lines preserved.
61+
const expected =
62+
'const msg = "“help”";\n' + "\n" + "function foo() {\n" + " return 2;\n" + "}\n";
63+
expect(newContent).toBe(expected);
64+
expect(newContent).toContain("“help”");
65+
expect(newContent).toContain(" return 2;");
66+
});
67+
68+
it("preserves NFKC-relevant Unicode on unrelated lines during fuzzy edit", () => {
69+
// The string "ffi" (U+FB03, LATIN SMALL LIGATURE FFI) is NFKC-normalized
70+
// to "ffi", so it would be silently corrupted by the old code.
71+
const original =
72+
'const ligature = "ffi";\n' + // ffi
73+
"\n" +
74+
"function foo() {\n" +
75+
" return 1;\n" +
76+
"}\n";
77+
78+
const edits = [{ oldText: "  return 1;", newText: " return 2;" }];
79+
80+
const { newContent } = applyEditsToNormalizedContent(original, edits, "/x.ts");
81+
82+
// Exact output: ligature preserved, only the targeted line changed.
83+
const expected =
84+
'const ligature = "ffi";\n' + "\n" + "function foo() {\n" + " return 2;\n" + "}\n";
85+
expect(newContent).toBe(expected);
86+
expect(newContent).toContain("ffi");
87+
expect(newContent).toContain(" return 2;");
88+
});
89+
90+
// ── Multiple edits with mixed match types ────────────────────────
91+
it("preserves exact-match lines when another edit in the same batch uses fuzzy", () => {
92+
const original =
93+
"const a = 1;\n" +
94+
'const label = "a — b";\n' + // em dash — should survive
95+
" const b = 2;\n" + // has leading spaces (matches NBSP in oldText)
96+
"const c = 3;\n";
97+
98+
// First edit matches exactly; second needs fuzzy (NBSP in oldText
99+
// normalizes to space, which matches the spaces in the original).
100+
const edits = [
101+
{ oldText: "const a = 1;", newText: "const a = 10;" },
102+
{
103+
oldText: "  const b = 2;",
104+
newText: " const b = 20;",
105+
},
106+
];
107+
108+
const { newContent } = applyEditsToNormalizedContent(original, edits, "/x.ts");
109+
110+
expect(newContent).toContain("const a = 10;");
111+
expect(newContent).toContain(" const b = 20;");
112+
expect(newContent).toContain("—"); // em dash preserved
113+
expect(newContent).toContain("const c = 3;");
114+
});
115+
116+
// ── Diff honesty ─────────────────────────────────────────────────
117+
it("produces a diff that reflects only the intended change, not normalization", () => {
118+
const original =
119+
'const label = "a — b";\n' +
120+
"let x = 1; \n" +
121+
"\n" +
122+
"function bar() {\n" +
123+
" return 2;\n" +
124+
"}\n";
125+
126+
const edits = [{ oldText: "  return 2;", newText: " return 3;" }];
127+
128+
const { baseContent, newContent } = applyEditsToNormalizedContent(original, edits, "/x.ts");
129+
const { diff } = generateDiffString(baseContent, newContent);
130+
131+
// The diff shows the intended change — return 2 → return 3.
132+
expect(diff).toContain("return 2;");
133+
expect(diff).toContain("return 3;");
134+
135+
// Unrelated lines must NOT appear as added (+) or removed (-) lines.
136+
// They may appear as context lines (prefix " N ") but NOT as changes.
137+
const changedLines = diff.split("\n").filter((l) => l.startsWith("+") || l.startsWith("-"));
138+
const changedStr = changedLines.join("\n");
139+
expect(changedStr).not.toContain("const label");
140+
expect(changedStr).not.toContain("let x = 1");
141+
});
142+
143+
// ── Exact match path: regression guard ───────────────────────────
144+
it("exact match preserves all content unchanged except the edited region", () => {
145+
const original =
146+
'const label = "a — b";\n' +
147+
"let x = 1; \n" +
148+
"function bar() {\n" +
149+
" return 2;\n" +
150+
"}\n";
151+
152+
const edits = [{ oldText: " return 2;", newText: " return 3;" }];
153+
154+
const { baseContent, newContent } = applyEditsToNormalizedContent(original, edits, "/x.ts");
155+
156+
// Exact match: baseContent should equal original.
157+
expect(baseContent).toBe(original);
158+
expect(newContent).toContain("—");
159+
expect(newContent).toContain("let x = 1; ");
160+
expect(newContent).toContain(" return 3;");
161+
});
162+
163+
// ── Error behaviour ──────────────────────────────────────────────
164+
it("still throws when oldText is not found even with fuzzy matching", () => {
165+
const original = "line one\nline two\nline three\n";
166+
167+
const edits = [{ oldText: "this text does not exist anywhere", newText: "nope" }];
168+
169+
expect(() => applyEditsToNormalizedContent(original, edits, "/x.ts")).toThrow(/Could not find/);
170+
});
171+
});

0 commit comments

Comments
 (0)