Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/vscode-e2e/src/fixtures/apply-diff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export function addApplyDiffResultFixtures(mock: InstanceType<typeof LLMock>) {
},
{
toolCallId: "call_apply_diff_error_001",
expected: ["No sufficiently similar match found at line: 1", "This content does not exist"],
expected: ["No sufficiently similar match found at line: 1", "- Best Match Location: "],
result: "The apply_diff operation on `apply-diff-tool-fixture/error-handling.txt` was rejected - the search content did not match any content in the file, so it was not modified.",
id: "call_apply_diff_error_002",
},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import { MultiSearchReplaceDiffStrategy } from "../multi-search-replace"

// Guards the POST-FAILURE "LIKELY CAUSE" hint for mistyped start-line markers.
// The heuristic must never act as an upfront rejection gate.
describe("MultiSearchReplaceDiffStrategy malformed start-line marker hint", () => {
let strategy: MultiSearchReplaceDiffStrategy

beforeEach(() => {
strategy = new MultiSearchReplaceDiffStrategy()
})

const nonMatchingFile = ["alpha", "beta", "gamma", "delta", "epsilon"].join("\n")

function buildDiff(searchBody: string, replaceBody: string): string {
return `<<<<<<< SEARCH\n${searchBody}\n=======\n${replaceBody}\n>>>>>>> REPLACE`
}

function firstFailureError(result: Awaited<ReturnType<MultiSearchReplaceDiffStrategy["applyDiff"]>>): string {
expect(result.success).toBe(false)
const failures = result.failParts ?? []
expect(failures.length).toBeGreaterThan(0)
const failure = failures[0]
if (failure.success || !failure.error) {
throw new Error("Expected a failed block with error details")
}
return failure.error
}

it("adds LIKELY CAUSE when the first search line is a bare ':3' marker and the block fails", async () => {
const diffContent = buildDiff(":3\nnot-in-the-file", "replacement")
const result = await strategy.applyDiff(nonMatchingFile, diffContent)

const errorText = firstFailureError(result)
expect(errorText).toContain("LIKELY CAUSE")
expect(errorText).toContain(":start_line:")
})

const markerVariants = ["start_line:3", ":start-line 3", ": start_line : 3", ":START_LINE:3", "Start_Line: 3"]

it.each(markerVariants)("adds LIKELY CAUSE for the malformed marker variant %j", async (markerVariant) => {
const diffContent = buildDiff(`${markerVariant}\nnot-in-the-file`, "replacement")
const result = await strategy.applyDiff(nonMatchingFile, diffContent)

expect(firstFailureError(result)).toContain("LIKELY CAUSE")
})

it.each([": 3", ":42", "startline:3", "start_line:42"])(
"adds LIKELY CAUSE for the spacing, multi-digit or separator-less marker %j",
async (markerVariant) => {
const diffContent = buildDiff(`${markerVariant}\nnot-in-the-file`, "replacement")
const result = await strategy.applyDiff(nonMatchingFile, diffContent)

const errorText = firstFailureError(result)
expect(errorText).toContain("first line of your search content looks like a malformed start-line marker")
expect(errorText).not.toContain(markerVariant)
},
)

it("skips the marker hint for an overlong whitespace-padded first line", async () => {
const paddedLine = `startline${" ".repeat(40_000)}3`
const diffContent = buildDiff(`${paddedLine}\nnot-in-the-file`, "replacement")

const startedAt = performance.now()
const errorText = firstFailureError(await strategy.applyDiff(nonMatchingFile, diffContent))

expect(errorText).not.toContain("LIKELY CAUSE")
expect(performance.now() - startedAt).toBeLessThan(2_000)
})

it("still hints for a padded marker within the length guard", async () => {
const diffContent = buildDiff(`start_line:${" ".repeat(40)}3\nnot-in-the-file`, "replacement")

expect(firstFailureError(await strategy.applyDiff(nonMatchingFile, diffContent))).toContain("LIKELY CAUSE")
})

it.each(["value:3", ":3 trailing", "x start_line:3"])(
"omits LIKELY CAUSE when %j only contains a marker-like fragment",
async (firstLine) => {
const diffContent = buildDiff(`${firstLine}\nnot-in-the-file`, "replacement")
const result = await strategy.applyDiff(nonMatchingFile, diffContent)

expect(firstFailureError(result)).not.toContain("LIKELY CAUSE")
},
)

it("does not claim a separator was swallowed when none follows the marker", async () => {
const diffContent = buildDiff(":3\nnot-in-the-file", "replacement")
const result = await strategy.applyDiff(nonMatchingFile, diffContent)

const errorText = firstFailureError(result)
expect(errorText).toContain(
"looks like a malformed start-line marker and was therefore searched for as literal file text. The only recognized form",
)
expect(errorText).not.toContain("together with")
})

it("detects a swallowed separator even when it is indented", async () => {
const diffContent = buildDiff(" :start_line:3\n -------\nnot-in-the-file", "replacement")
const result = await strategy.applyDiff(nonMatchingFile, diffContent)

expect(firstFailureError(result)).toContain('together with the "-------" line after it')
})

it("reports the real similarity, threshold and distance for a near miss", async () => {
const diffContent = buildDiff("alpha\nbetx", "replacement")
const result = await strategy.applyDiff(nonMatchingFile, diffContent)

const errorText = firstFailureError(result)
const similarity = /\((\d+)% similar, needs 100%\)\n\nDebug Info:\n- Similarity Score: (\d+)%\n/.exec(errorText)
expect(similarity).not.toBeNull()
expect(Number(similarity?.[1])).toBeGreaterThan(50)
expect(similarity?.[2]).toBe(similarity?.[1])
expect(errorText).toMatch(/- Levenshtein Distance: [1-9]\d* characters\n/)
})

it("still applies cleanly when the file genuinely contains a ':3' line (hint is not a gate)", async () => {
const fileWithMarkerLikeLine = ["alpha", ":3", "beta", "gamma"].join("\n")
const diffContent = buildDiff(":3\nbeta", ":3\nBETA")

const result = await strategy.applyDiff(fileWithMarkerLikeLine, diffContent)

expect(result.success).toBe(true)
if (!result.success) {
throw new Error("Expected marker-like file content to match")
}
expect(result.content).toBe(["alpha", ":3", "BETA", "gamma"].join("\n"))
expect(result.content).not.toContain("LIKELY CAUSE")
expect(result.failParts ?? []).toHaveLength(0)
})

it("reports the parsed block count when repair splits an inline SEARCH marker", async () => {
const original = ["alpha", "beta"].join("\n")
const diffContent = "<<<<<<< SEARCH\nalpha\n=======\nALPHA x <<<<<<< SEARCH\nmissing\n>>>>>>> REPLACE"

const result = await strategy.applyDiff(original, diffContent)

expect(result.success).toBe(true)
if (!result.success) {
throw new Error("Expected the first repaired block to apply")
}
expect(result.content).toBe(["ALPHA x", "beta"].join("\n"))
expect(result.blockCount).toBe(2)
expect(result.failParts?.filter((part) => !part.success)).toHaveLength(1)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
})

it("omits the hint for ordinary non-matching content but keeps the file-changed tip", async () => {
const diffContent = buildDiff("completely unrelated content\nsecond line", "replacement")
const result = await strategy.applyDiff(nonMatchingFile, diffContent)

const errorText = firstFailureError(result)
expect(errorText).not.toContain("LIKELY CAUSE")
expect(errorText).toContain(
"- Tried both standard and aggressive line number stripping\n" +
"- Tip: Use the read_file tool to get the latest content of the file before attempting " +
"to use the apply_diff tool again, as the file content may have changed\n" +
"- Tip: The SEARCH block must contain complete, whole lines — partial-line (substring) matching is not supported. " +
"If the target line is very long, copy the entire line into the SEARCH block",
)
})

it("does not flag the correct ':start_line:3' marker form", async () => {
const diffContent = `<<<<<<< SEARCH\n:start_line:3\n-------\nnot-in-the-file\n=======\nreplacement\n>>>>>>> REPLACE`
const result = await strategy.applyDiff(nonMatchingFile, diffContent)

expect(firstFailureError(result)).not.toContain("LIKELY CAUSE")
})

it("adds LIKELY CAUSE when a well-formed ':start_line:N' line goes unparsed and swallows its separator", async () => {
// Indenting the marker makes the parser miss it, so both it and the "-------" become search text.
const diffContent = buildDiff(" :start_line:3\n-------\nnot-in-the-file", "replacement")
const result = await strategy.applyDiff(nonMatchingFile, diffContent)

const errorText = firstFailureError(result)
expect(errorText).toContain("LIKELY CAUSE")
expect(errorText).toContain('together with the "-------" line after it')
})
})
34 changes: 25 additions & 9 deletions src/core/diff/strategies/__tests__/multi-search-replace.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -900,7 +900,7 @@ replacement content here
expect(error).toContain("No sufficiently similar match found")
expect(error).toContain("at line: 999")
expect(error).toContain("Search Range: starting at line 999")
expect(error).toContain("Best Match Found:\n(no match)")
expect(error).toContain("Best Match Location: none")
expect(error).toContain("Levenshtein Distance: N/A")
expect(error).toContain("Best Match Length: 0 characters")
})
Expand Down Expand Up @@ -928,7 +928,7 @@ replacement content here
expect(error).toContain("Tip: The SEARCH block must contain complete, whole lines")
})

it("should include scoped original content when search fails with start_line that has a low-score match", async () => {
it("reports only numeric diagnostics when search fails with start_line that has a low-score match", async () => {
const originalContent = "function existing() {\n return 42;\n}\n"
const diffContent = `test.ts
<<<<<<< SEARCH
Expand All @@ -954,8 +954,9 @@ function newVersion() {
expect(error).toContain("No sufficiently similar match found")
expect(error).toContain("at line: 1")
expect(error).toContain("Search Range: starting at line 1")
expect(error).toContain("Best Match Found:")
expect(error).toContain("Original Content:\n1 | function existing()")
expect(error).toContain("Best Match Location: lines 1-")
expect(error).not.toContain("function existing()")
expect(error).not.toContain("function different()")
})

it("should include best-match debug info when unscoped search is below threshold", async () => {
Expand Down Expand Up @@ -984,7 +985,9 @@ function processUsers(data) {
expect(error).toContain("Levenshtein Distance:")
expect(error).toContain("characters")
expect(error).toContain("Best Match Length:")
expect(error).toContain("Best Match Found:\n1 | function processUsers(data)")
expect(error).toContain("Best Match Location: lines 1-3")
expect(error).not.toContain("user.username")
expect(error).not.toContain("user.name")
})

it("should include zero-match info when unscoped search finds no similarity at all", async () => {
Expand All @@ -1007,11 +1010,24 @@ aaaaaa
: ""
expect(error).toContain("No sufficiently similar match found")
expect(error).toContain("Search Range: start to end")
expect(error).toContain("Best Match Found:\n(no match)")
expect(error).toContain("Best Match Location: none")
expect(error).toContain("Levenshtein Distance: N/A")
expect(error).toContain("Best Match Length: 0 characters")
expect(error).toContain("Original Content:")
expect(error).toContain("1 | xxxxxx")
expect(error).not.toContain("xxxxxx")
expect(error).not.toContain("!!!!!!")
})

it("never echoes search, best-match or surrounding file text in failure diagnostics", async () => {
const originalContent = "const a = 1\nAPI_KEY=FILE-SECRET-1\nconst b = 2\nAPI_KEY=FILE-SECRET-2\n"
const diffContent =
"<<<<<<< SEARCH\n:start_line:2\n-------\nAPI_KEY=SEARCH-SECRET-0\n=======\nAPI_KEY=x\n>>>>>>> REPLACE"

const result = await strategy.applyDiff(originalContent, diffContent)
const serialized = JSON.stringify(result)

expect(result.success).toBe(false)
expect(serialized).toContain("No sufficiently similar match found")
expect(serialized).not.toMatch(/FILE-SECRET|SEARCH-SECRET/)
})

it("should match content with extra whitespace", async () => {
Expand Down Expand Up @@ -1610,7 +1626,7 @@ function sum(a, b) {
expect(failedPart).toHaveProperty("error")
const errorMsg = (failedPart as { error: string }).error
expect(errorMsg).toContain("No sufficiently similar match found")
expect(errorMsg).toContain("Best Match Found:")
expect(errorMsg).toContain("Best Match Location:")
expect(errorMsg).toContain("Levenshtein Distance:")
expect(errorMsg).toContain("Search Range: start to end")
})
Expand Down
72 changes: 37 additions & 35 deletions src/core/diff/strategies/multi-search-replace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@

import { ToolProgressStatus, DEFAULT_DIFF_FUZZY_THRESHOLD } from "@roo-code/types"

import { addLineNumbers, everyLineHasLineNumbers, stripLineNumbers } from "../../../integrations/misc/extract-text"
import { everyLineHasLineNumbers, stripLineNumbers } from "../../../integrations/misc/extract-text"
import { ToolUse, DiffStrategy, DiffResult } from "../../../shared/tools"
import { normalizeString } from "../../../utils/text-normalization"

const BUFFER_LINES = 40 // Number of extra context lines to show before and after matches
// Real malformed markers are short; longer lines skip the hint regex entirely.
const MAX_MARKER_HINT_LINE_LENGTH = 64

function getSimilarity(original: string, search: string): number {
// Empty searches are no longer supported
Expand Down Expand Up @@ -468,8 +470,6 @@
continue
}

const endLine = replacement.startLine + searchLines.length - 1

// Initialize search variables
let matchIndex = -1
let bestMatchScore = 0
Expand Down Expand Up @@ -538,46 +538,47 @@
searchLines = aggressiveSearchLines
replaceLines = replaceContent ? replaceContent.split(/\r?\n/) : []
} else {
// No match found with either method
const originalContentSection =
startLine && endLine
? `\n\nOriginal Content:\n${addLineNumbers(
resultLines
.slice(
Math.max(0, startLine - 1 - this.bufferLines),
Math.min(resultLines.length, endLine + this.bufferLines),
)
.join("\n"),
Math.max(1, startLine - this.bufferLines),
)}`
: `\n\nOriginal Content:\n${addLineNumbers(
resultLines
.slice(
Math.max(0, (matchIndex >= 0 ? matchIndex : 0) - this.bufferLines),
Math.min(
resultLines.length,
(matchIndex >= 0 ? matchIndex : 0) +
searchLines.length +
this.bufferLines,
),
)
.join("\n"),
Math.max(1, (matchIndex >= 0 ? matchIndex : 0) - this.bufferLines + 1),
)}`

const bestMatchSection = bestMatchContent
? `\n\nBest Match Found:\n${addLineNumbers(bestMatchContent, matchIndex + 1)}`
: `\n\nBest Match Found:\n(no match)`

// File and search text are never echoed: they may hold secrets. Report only numbers and constants.
const lineRange = startLine ? ` at line: ${startLine}` : ""
const bestMatchLocation =
bestMatchContent && matchIndex >= 0

Check warning on line 544 in src/core/diff/strategies/multi-search-replace.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/diff/strategies/multi-search-replace.ts:544: 2 mutation test gaps; example: Survived LogicalOperator mutant (replacement: bestMatchContent || matchIndex >= 0). See the job summary for the complete list and resolution guidance.
? `lines ${matchIndex + 1}-${matchIndex + bestMatchContent.split("\n").length}`
: "none"

const levenDist = bestMatchContent
? distance(normalizeString(searchChunk), normalizeString(bestMatchContent))
: -1

// Only diagnose markers after matching fails: marker-like text can be valid file content.
const hintLines = searchChunk.split("\n")
const firstSearchLine = hintLines[0]?.trim() ?? ""

Check warning on line 554 in src/core/diff/strategies/multi-search-replace.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/diff/strategies/multi-search-replace.ts:554: 2 mutation test gaps; example: NoCoverage StringLiteral mutant (replacement: "Stryker was here!"). See the job summary for the complete list and resolution guidance.
const separatorSwallowed = hintLines[1]?.trim() === "-------"
// Length guard: adjacent `\s*` groups backtrack quadratically on long whitespace runs.
const looksLikeMalformedMarker =
firstSearchLine.length <= MAX_MARKER_HINT_LINE_LENGTH &&

Check warning on line 558 in src/core/diff/strategies/multi-search-replace.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/diff/strategies/multi-search-replace.ts:558: Survived EqualityOperator mutant (replacement: firstSearchLine.length < MAX_MARKER_HINT_LINE_LENGTH). See the job summary for the complete list and resolution guidance.
/^(?::\s*\d{1,9}:?|:?\s*start[_-]?line\s*:?\s*\d{1,9}:?)$/i.test(firstSearchLine)
const malformedMarkerHint = looksLikeMalformedMarker
? `\n- LIKELY CAUSE: the first line of your search content looks like a malformed start-line marker and was therefore searched for as literal file text${separatorSwallowed ? ', together with the "-------" line after it' : ""}. The only recognized form is ":start_line:<number>" on the line immediately after "<<<<<<< SEARCH", optionally followed by a "-------" line. Remove or correct that line and retry.`
: ""

diffResults.push({
success: false,
error: `No sufficiently similar match found${lineRange} (${Math.floor(bestMatchScore * 100)}% similar, needs ${Math.floor(this.fuzzyThreshold * 100)}%)\n\nDebug Info:\n- Similarity Score: ${Math.floor(bestMatchScore * 100)}%\n- Required Threshold: ${Math.floor(this.fuzzyThreshold * 100)}%\n- Search Range: ${startLine ? `starting at line ${startLine}` : "start to end"}\n- Levenshtein Distance: ${levenDist >= 0 ? `${levenDist} characters` : "N/A"}\n- Search Length: ${searchChunk.length} characters\n- Best Match Length: ${bestMatchContent ? bestMatchContent.length : 0} characters\n- Tried both standard and aggressive line number stripping\n- Tip: Use the read_file tool to get the latest content of the file before attempting to use the apply_diff tool again, as the file content may have changed\n- Tip: The SEARCH block must contain complete, whole lines — partial-line (substring) matching is not supported. If the target line is very long, copy the entire line into the SEARCH block\n\nSearch Content:\n${searchChunk}${bestMatchSection}${originalContentSection}`,
error:
`No sufficiently similar match found${lineRange} ` +
`(${Math.floor(bestMatchScore * 100)}% similar, needs ${Math.floor(this.fuzzyThreshold * 100)}%)\n\n` +
`Debug Info:\n` +
`- Similarity Score: ${Math.floor(bestMatchScore * 100)}%\n` +
`- Required Threshold: ${Math.floor(this.fuzzyThreshold * 100)}%\n` +
`- Search Range: ${startLine ? `starting at line ${startLine}` : "start to end"}\n` +
`- Levenshtein Distance: ${levenDist >= 0 ? `${levenDist} characters` : "N/A"}\n` +

Check warning on line 573 in src/core/diff/strategies/multi-search-replace.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/diff/strategies/multi-search-replace.ts:573: Survived EqualityOperator mutant (replacement: levenDist > 0). See the job summary for the complete list and resolution guidance.
`- Search Length: ${searchChunk.length} characters\n` +
`- Best Match Location: ${bestMatchLocation}\n` +
`- Best Match Length: ${bestMatchContent ? bestMatchContent.length : 0} characters\n` +
`- Tried both standard and aggressive line number stripping${malformedMarkerHint}\n` +
`- Tip: Use the read_file tool to get the latest content of the file before attempting ` +
`to use the apply_diff tool again, as the file content may have changed\n` +
`- Tip: The SEARCH block must contain complete, whole lines — partial-line (substring) matching is not supported. ` +
`If the target line is very long, copy the entire line into the SEARCH block`,
})
continue
}
Expand Down Expand Up @@ -641,6 +642,7 @@
success: true,
content: finalContent,
failParts: diffResults,
blockCount: matches.length,
}
}

Expand Down
Loading
Loading