More tolerant search/replace match (#6537)

Co-authored-by: Roo Code <roomote@roocode.com>
This commit is contained in:
Matt Rubens 2025-08-01 11:58:57 -04:00 committed by GitHub
parent 305a5da369
commit ebfd384ac4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 64 additions and 13 deletions

View file

@ -13,6 +13,51 @@ describe("MultiSearchReplaceDiffStrategy", () => {
expect(strategy["validateMarkerSequencing"](diff).success).toBe(true)
})
it("validates correct marker sequence with extra > in SEARCH", () => {
const diff = "<<<<<<< SEARCH>\n" + "some content\n" + "=======\n" + "new content\n" + ">>>>>>> REPLACE"
expect(strategy["validateMarkerSequencing"](diff).success).toBe(true)
})
it("validates correct marker sequence with multiple > in SEARCH", () => {
const diff = "<<<<<<< SEARCH>>\n" + "some content\n" + "=======\n" + "new content\n" + ">>>>>>> REPLACE"
expect(strategy["validateMarkerSequencing"](diff).success).toBe(false)
})
it("validates mixed cases with and without extra > in the same diff", () => {
const diff =
"<<<<<<< SEARCH>\n" +
"content1\n" +
"=======\n" +
"new1\n" +
">>>>>>> REPLACE\n\n" +
"<<<<<<< SEARCH\n" +
"content2\n" +
"=======\n" +
"new2\n" +
">>>>>>> REPLACE"
expect(strategy["validateMarkerSequencing"](diff).success).toBe(true)
})
it("validates extra > with whitespace variations", () => {
const diff1 = "<<<<<<< SEARCH> \n" + "some content\n" + "=======\n" + "new content\n" + ">>>>>>> REPLACE"
expect(strategy["validateMarkerSequencing"](diff1).success).toBe(true)
const diff2 = "<<<<<<< SEARCH >\n" + "some content\n" + "=======\n" + "new content\n" + ">>>>>>> REPLACE"
expect(strategy["validateMarkerSequencing"](diff2).success).toBe(false)
})
it("validates extra > with line numbers", () => {
const diff =
"<<<<<<< SEARCH>\n" +
":start_line:10\n" +
"-------\n" +
"content1\n" +
"=======\n" +
"new1\n" +
">>>>>>> REPLACE"
expect(strategy["validateMarkerSequencing"](diff).success).toBe(true)
})
it("validates multiple correct marker sequences", () => {
const diff =
"<<<<<<< SEARCH\n" +

View file

@ -259,7 +259,10 @@ Each file requires its own path, start_line, and diff elements.
const state = { current: State.START, line: 0 }
const SEARCH = "<<<<<<< SEARCH"
// Pattern allows optional '>' after SEARCH to handle AI-generated diffs
// (e.g., Sonnet 4 sometimes adds an extra '>')
const SEARCH_PATTERN = /^<<<<<<< SEARCH>?$/
const SEARCH = SEARCH_PATTERN.source.replace(/[\^$]/g, "") // Remove regex anchors for display
const SEP = "======="
const REPLACE = ">>>>>>> REPLACE"
const SEARCH_PREFIX = "<<<<<<< "
@ -329,7 +332,7 @@ Each file requires its own path, start_line, and diff elements.
})
const lines = diffContent.split("\n")
const searchCount = lines.filter((l) => l.trim() === SEARCH).length
const searchCount = lines.filter((l) => SEARCH_PATTERN.test(l.trim())).length
const sepCount = lines.filter((l) => l.trim() === SEP).length
const replaceCount = lines.filter((l) => l.trim() === REPLACE).length
@ -357,12 +360,12 @@ Each file requires its own path, start_line, and diff elements.
: reportMergeConflictError(SEP, SEARCH)
if (marker === REPLACE) return reportInvalidDiffError(REPLACE, SEARCH)
if (marker.startsWith(REPLACE_PREFIX)) return reportMergeConflictError(marker, SEARCH)
if (marker === SEARCH) state.current = State.AFTER_SEARCH
if (SEARCH_PATTERN.test(marker)) state.current = State.AFTER_SEARCH
else if (marker.startsWith(SEARCH_PREFIX)) return reportMergeConflictError(marker, SEARCH)
break
case State.AFTER_SEARCH:
if (marker === SEARCH) return reportInvalidDiffError(SEARCH, SEP)
if (SEARCH_PATTERN.test(marker)) return reportInvalidDiffError(SEARCH_PATTERN.source, SEP)
if (marker.startsWith(SEARCH_PREFIX)) return reportMergeConflictError(marker, SEARCH)
if (marker === REPLACE) return reportInvalidDiffError(REPLACE, SEP)
if (marker.startsWith(REPLACE_PREFIX)) return reportMergeConflictError(marker, SEARCH)
@ -370,7 +373,7 @@ Each file requires its own path, start_line, and diff elements.
break
case State.AFTER_SEPARATOR:
if (marker === SEARCH) return reportInvalidDiffError(SEARCH, REPLACE)
if (SEARCH_PATTERN.test(marker)) return reportInvalidDiffError(SEARCH_PATTERN.source, REPLACE)
if (marker.startsWith(SEARCH_PREFIX)) return reportMergeConflictError(marker, REPLACE)
if (marker === SEP)
return likelyBadStructure
@ -456,7 +459,7 @@ Each file requires its own path, start_line, and diff elements.
/* Regex parts:
1. (?:^|\n) Ensures the first marker starts at the beginning of the file or right after a newline.
2. (?<!\\)<<<<<<< SEARCH\s*\n Matches the line "<<<<<<< SEARCH" (ignoring any trailing spaces) the negative lookbehind makes sure it isn't escaped.
2. (?<!\\)<<<<<<< SEARCH>?\s*\n Matches the line "<<<<<<< SEARCH" with optional '>' (ignoring any trailing spaces) the negative lookbehind makes sure it isn't escaped.
3. ((?:\:start_line:\s*(\d+)\s*\n))? Optionally matches a ":start_line:" line. The outer capturing group is group 1 and the inner (\d+) is group 2.
4. ((?:\:end_line:\s*(\d+)\s*\n))? Optionally matches a ":end_line:" line. Group 3 is the whole match and group 4 is the digits.
5. ((?<!\\)-------\s*\n)? Optionally matches the "-------" marker line (group 5).
@ -467,7 +470,7 @@ Each file requires its own path, start_line, and diff elements.
*/
let matches = [
...diffContent.matchAll(
/(?:^|\n)(?<!\\)<<<<<<< SEARCH\s*\n((?:\:start_line:\s*(\d+)\s*\n))?((?:\:end_line:\s*(\d+)\s*\n))?((?<!\\)-------\s*\n)?([\s\S]*?)(?:\n)?(?:(?<=\n)(?<!\\)=======\s*\n)([\s\S]*?)(?:\n)?(?:(?<=\n)(?<!\\)>>>>>>> REPLACE)(?=\n|$)/g,
/(?:^|\n)(?<!\\)<<<<<<< SEARCH>?\s*\n((?:\:start_line:\s*(\d+)\s*\n))?((?:\:end_line:\s*(\d+)\s*\n))?((?<!\\)-------\s*\n)?([\s\S]*?)(?:\n)?(?:(?<=\n)(?<!\\)=======\s*\n)([\s\S]*?)(?:\n)?(?:(?<=\n)(?<!\\)>>>>>>> REPLACE)(?=\n|$)/g,
),
]

View file

@ -198,7 +198,10 @@ Only use a single line of '=======' between search and replacement content, beca
}
const state = { current: State.START, line: 0 }
const SEARCH = "<<<<<<< SEARCH"
// Pattern allows optional '>' after SEARCH to handle AI-generated diffs
// (e.g., Sonnet 4 sometimes adds an extra '>')
const SEARCH_PATTERN = /^<<<<<<< SEARCH>?$/
const SEARCH = SEARCH_PATTERN.source.replace(/[\^$]/g, "") // Remove regex anchors for display
const SEP = "======="
const REPLACE = ">>>>>>> REPLACE"
const SEARCH_PREFIX = "<<<<<<<"
@ -268,7 +271,7 @@ Only use a single line of '=======' between search and replacement content, beca
})
const lines = diffContent.split("\n")
const searchCount = lines.filter((l) => l.trim() === SEARCH).length
const searchCount = lines.filter((l) => SEARCH_PATTERN.test(l.trim())).length
const sepCount = lines.filter((l) => l.trim() === SEP).length
const replaceCount = lines.filter((l) => l.trim() === REPLACE).length
@ -296,12 +299,12 @@ Only use a single line of '=======' between search and replacement content, beca
: reportMergeConflictError(SEP, SEARCH)
if (marker === REPLACE) return reportInvalidDiffError(REPLACE, SEARCH)
if (marker.startsWith(REPLACE_PREFIX)) return reportMergeConflictError(marker, SEARCH)
if (marker === SEARCH) state.current = State.AFTER_SEARCH
if (SEARCH_PATTERN.test(marker)) state.current = State.AFTER_SEARCH
else if (marker.startsWith(SEARCH_PREFIX)) return reportMergeConflictError(marker, SEARCH)
break
case State.AFTER_SEARCH:
if (marker === SEARCH) return reportInvalidDiffError(SEARCH, SEP)
if (SEARCH_PATTERN.test(marker)) return reportInvalidDiffError(SEARCH_PATTERN.source, SEP)
if (marker.startsWith(SEARCH_PREFIX)) return reportMergeConflictError(marker, SEARCH)
if (marker === REPLACE) return reportInvalidDiffError(REPLACE, SEP)
if (marker.startsWith(REPLACE_PREFIX)) return reportMergeConflictError(marker, SEARCH)
@ -309,7 +312,7 @@ Only use a single line of '=======' between search and replacement content, beca
break
case State.AFTER_SEPARATOR:
if (marker === SEARCH) return reportInvalidDiffError(SEARCH, REPLACE)
if (SEARCH_PATTERN.test(marker)) return reportInvalidDiffError(SEARCH_PATTERN.source, REPLACE)
if (marker.startsWith(SEARCH_PREFIX)) return reportMergeConflictError(marker, REPLACE)
if (marker === SEP)
return likelyBadStructure
@ -378,7 +381,7 @@ Only use a single line of '=======' between search and replacement content, beca
let matches = [
...diffContent.matchAll(
/(?:^|\n)(?<!\\)<<<<<<< SEARCH\s*\n((?:\:start_line:\s*(\d+)\s*\n))?((?:\:end_line:\s*(\d+)\s*\n))?((?<!\\)-------\s*\n)?([\s\S]*?)(?:\n)?(?:(?<=\n)(?<!\\)=======\s*\n)([\s\S]*?)(?:\n)?(?:(?<=\n)(?<!\\)>>>>>>> REPLACE)(?=\n|$)/g,
/(?:^|\n)(?<!\\)<<<<<<< SEARCH>?\s*\n((?:\:start_line:\s*(\d+)\s*\n))?((?:\:end_line:\s*(\d+)\s*\n))?((?<!\\)-------\s*\n)?([\s\S]*?)(?:\n)?(?:(?<=\n)(?<!\\)=======\s*\n)([\s\S]*?)(?:\n)?(?:(?<=\n)(?<!\\)>>>>>>> REPLACE)(?=\n|$)/g,
),
]