From 27e337e34755863390f38b9732918df25a506d17 Mon Sep 17 00:00:00 2001 From: can1357 Date: Sun, 11 Jan 2026 07:32:39 +0100 Subject: [PATCH] fix(coding-agent): added fallback matching for inconsistent indentation and refactored fuzzy matching logic - Added fallback matching without indent depth when file and target have inconsistent indentation patterns. - Refactored fuzzy matching logic into findBestFuzzyMatchCore helper function. - Added comprehensive test suite for findEditMatch covering exact matching, tab/space normalization, and edge cases. --- packages/coding-agent/CHANGELOG.md | 3 + .../coding-agent/src/core/tools/edit-diff.ts | 65 +++++--- packages/coding-agent/test/edit-diff.test.ts | 150 ++++++++++++++++++ 3 files changed, 197 insertions(+), 21 deletions(-) create mode 100644 packages/coding-agent/test/edit-diff.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 9908c2bdf..8e0867349 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,6 +1,9 @@ # Changelog ## [Unreleased] +### Fixed + +- Improved fuzzy matching accuracy for edit operations when file and target have inconsistent indentation patterns ## [4.2.3] - 2026-01-11 diff --git a/packages/coding-agent/src/core/tools/edit-diff.ts b/packages/coding-agent/src/core/tools/edit-diff.ts index 53265ea26..49c17e119 100644 --- a/packages/coding-agent/src/core/tools/edit-diff.ts +++ b/packages/coding-agent/src/core/tools/edit-diff.ts @@ -49,9 +49,9 @@ function countLeadingWhitespace(line: string): number { const char = line[i]; if (char === " " || char === "\t") { count++; - continue; + } else { + break; } - break; } return count; } @@ -80,15 +80,16 @@ function computeRelativeIndentDepths(lines: string[]): number[] { }); } -function normalizeLinesForMatch(lines: string[]): string[] { - const indentDepths = computeRelativeIndentDepths(lines); +function normalizeLinesForMatch(lines: string[], includeDepth = true): string[] { + const indentDepths = includeDepth ? computeRelativeIndentDepths(lines) : null; return lines.map((line, index) => { const trimmed = line.trim(); + const prefix = indentDepths ? `${indentDepths[index]}|` : "|"; if (trimmed.length === 0) { - return `${indentDepths[index]}|`; + return prefix; } const collapsed = trimmed.replace(/[ \t]+/g, " "); - return `${indentDepths[index]}|${collapsed}`; + return `${prefix}${collapsed}`; }); } @@ -148,22 +149,14 @@ function computeLineOffsets(lines: string[]): number[] { return offsets; } -function findBestFuzzyMatch( - content: string, - target: string, +function findBestFuzzyMatchCore( + contentLines: string[], + targetLines: string[], + offsets: number[], threshold: number, + includeDepth: boolean, ): { best?: EditMatch; aboveThresholdCount: number } { - const contentLines = content.split("\n"); - const targetLines = target.split("\n"); - if (targetLines.length === 0 || target.length === 0) { - return { aboveThresholdCount: 0 }; - } - if (targetLines.length > contentLines.length) { - return { aboveThresholdCount: 0 }; - } - - const targetNormalized = normalizeLinesForMatch(targetLines); - const offsets = computeLineOffsets(contentLines); + const targetNormalized = normalizeLinesForMatch(targetLines, includeDepth); let best: EditMatch | undefined; let bestScore = -1; @@ -171,7 +164,7 @@ function findBestFuzzyMatch( for (let start = 0; start <= contentLines.length - targetLines.length; start++) { const windowLines = contentLines.slice(start, start + targetLines.length); - const windowNormalized = normalizeLinesForMatch(windowLines); + const windowNormalized = normalizeLinesForMatch(windowLines, includeDepth); let score = 0; for (let i = 0; i < targetLines.length; i++) { score += similarityScore(targetNormalized[i], windowNormalized[i]); @@ -196,6 +189,36 @@ function findBestFuzzyMatch( return { best, aboveThresholdCount }; } +const FALLBACK_THRESHOLD = 0.8; + +function findBestFuzzyMatch( + content: string, + target: string, + threshold: number, +): { best?: EditMatch; aboveThresholdCount: number } { + const contentLines = content.split("\n"); + const targetLines = target.split("\n"); + if (targetLines.length === 0 || target.length === 0) { + return { aboveThresholdCount: 0 }; + } + if (targetLines.length > contentLines.length) { + return { aboveThresholdCount: 0 }; + } + + const offsets = computeLineOffsets(contentLines); + + let result = findBestFuzzyMatchCore(contentLines, targetLines, offsets, threshold, true); + + if (result.best && result.best.confidence < threshold && result.best.confidence >= FALLBACK_THRESHOLD) { + const noDepthResult = findBestFuzzyMatchCore(contentLines, targetLines, offsets, threshold, false); + if (noDepthResult.best && noDepthResult.best.confidence > result.best.confidence) { + result = noDepthResult; + } + } + + return result; +} + export function findEditMatch( content: string, target: string, diff --git a/packages/coding-agent/test/edit-diff.test.ts b/packages/coding-agent/test/edit-diff.test.ts new file mode 100644 index 000000000..f025dae9c --- /dev/null +++ b/packages/coding-agent/test/edit-diff.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, test } from "bun:test"; +import { findEditMatch, DEFAULT_FUZZY_THRESHOLD } from "../src/core/tools/edit-diff"; + +describe("findEditMatch", () => { + describe("exact matching", () => { + test("finds exact match", () => { + const content = "line1\nline2\nline3"; + const target = "line2"; + const result = findEditMatch(content, target, { allowFuzzy: false }); + expect(result.match).toBeDefined(); + expect(result.match!.confidence).toBe(1); + expect(result.match!.startLine).toBe(2); + }); + + test("reports multiple occurrences", () => { + const content = "foo\nbar\nfoo"; + const target = "foo"; + const result = findEditMatch(content, target, { allowFuzzy: false }); + expect(result.match).toBeUndefined(); + expect(result.occurrences).toBe(2); + }); + + test("returns empty for no match", () => { + const content = "line1\nline2"; + const target = "notfound"; + const result = findEditMatch(content, target, { allowFuzzy: false }); + expect(result.match).toBeUndefined(); + expect(result.occurrences).toBeUndefined(); + }); + }); + + describe("tab/space normalization", () => { + test("matches tabs in file with spaces in target", () => { + const content = "\tfoo\n\t\tbar\n\tbaz"; + const target = " foo\n bar\n baz"; + const result = findEditMatch(content, target, { allowFuzzy: true }); + expect(result.match).toBeDefined(); + expect(result.match!.confidence).toBeGreaterThanOrEqual(DEFAULT_FUZZY_THRESHOLD); + }); + + test("matches spaces in file with tabs in target", () => { + const content = " foo\n bar\n baz"; + const target = "\tfoo\n\t\tbar\n\tbaz"; + const result = findEditMatch(content, target, { allowFuzzy: true }); + expect(result.match).toBeDefined(); + expect(result.match!.confidence).toBeGreaterThanOrEqual(DEFAULT_FUZZY_THRESHOLD); + }); + + test("matches different space counts with same relative structure", () => { + const content = " foo\n bar\n baz"; + const target = " foo\n bar\n baz"; + const result = findEditMatch(content, target, { allowFuzzy: true }); + expect(result.match).toBeDefined(); + expect(result.match!.confidence).toBeGreaterThanOrEqual(DEFAULT_FUZZY_THRESHOLD); + }); + + test("matches single line with different indentation", () => { + const content = 'prefix\n\t\t\t"value",\nsuffix'; + const target = ' "value",'; + const result = findEditMatch(content, target, { allowFuzzy: true }); + expect(result.match).toBeDefined(); + expect(result.match!.confidence).toBeGreaterThanOrEqual(DEFAULT_FUZZY_THRESHOLD); + }); + }); + + describe("fallback for inconsistent indentation", () => { + test("matches despite one line with wrong indentation in file", () => { + const content = "\t\t\tline1\n\t\t\tline2\n\t\tline3\n\t\t\tline4"; + const target = " line1\n line2\n line3\n line4"; + const result = findEditMatch(content, target, { allowFuzzy: true }); + expect(result.match).toBeDefined(); + expect(result.match!.confidence).toBeGreaterThanOrEqual(DEFAULT_FUZZY_THRESHOLD); + }); + + test("matches when target has consistent indent but file varies", () => { + const content = " a\n b\n c\n d"; + const target = " a\n b\n c\n d"; + const result = findEditMatch(content, target, { allowFuzzy: true }); + expect(result.match).toBeDefined(); + }); + }); + + describe("content matching", () => { + test("collapses internal whitespace", () => { + const content = "foo bar baz"; + const target = "foo bar baz"; + const result = findEditMatch(content, target, { allowFuzzy: true }); + expect(result.match).toBeDefined(); + expect(result.match!.confidence).toBeGreaterThanOrEqual(DEFAULT_FUZZY_THRESHOLD); + }); + + test("matches with trailing whitespace differences", () => { + const content = "line1 \nline2\t"; + const target = "line1\nline2"; + const result = findEditMatch(content, target, { allowFuzzy: true }); + expect(result.match).toBeDefined(); + }); + }); + + describe("threshold behavior", () => { + test("respects custom similarity threshold", () => { + const content = "function foo() {}"; + const target = "function bar() {}"; + const strictResult = findEditMatch(content, target, { + allowFuzzy: true, + similarityThreshold: 0.99, + }); + expect(strictResult.match).toBeUndefined(); + + const lenientResult = findEditMatch(content, target, { + allowFuzzy: true, + similarityThreshold: 0.7, + }); + expect(lenientResult.match).toBeDefined(); + }); + + test("reports fuzzyMatches count when multiple above threshold", () => { + const content = " item1\n item2\n item3"; + const target = " itemX"; + const result = findEditMatch(content, target, { + allowFuzzy: true, + similarityThreshold: 0.7, + }); + expect(result.fuzzyMatches).toBeGreaterThan(1); + }); + }); + + describe("edge cases", () => { + test("handles empty target", () => { + const content = "some content"; + const result = findEditMatch(content, "", { allowFuzzy: true }); + expect(result).toEqual({}); + }); + + test("handles empty lines in content", () => { + const content = "line1\n\nline3"; + const target = "line1\n\nline3"; + const result = findEditMatch(content, target, { allowFuzzy: false }); + expect(result.match).toBeDefined(); + expect(result.match!.confidence).toBe(1); + }); + + test("handles target longer than content", () => { + const content = "short"; + const target = "this is much longer than the content"; + const result = findEditMatch(content, target, { allowFuzzy: true }); + expect(result.match).toBeUndefined(); + }); + }); +});