Skip to content
This repository was archived by the owner on May 15, 2026. It is now read-only.

Commit 492ab43

Browse files
committed
fix: tolerant parsing of :start_line directive in apply_diff
The apply_diff regex now accepts both `:start_line:` and `:start_line=` formats for the delimiter between the directive name and the line number. This fixes cases where models produce `:start_line=18` instead of `:start_line:18`, which previously caused the directive to leak into the search content, resulting in confusing "insufficient match" errors. Additionally, a fallback detector strips leaked `:start_line=` directives from the search content if they somehow get past the regex, recovering gracefully rather than failing with a misleading similarity score. Closes #12199
1 parent ad25634 commit 492ab43

2 files changed

Lines changed: 146 additions & 1 deletion

File tree

src/core/diff/strategies/__tests__/multi-search-replace.spec.ts

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1204,4 +1204,112 @@ function sum(a, b) {
12041204
expect(result.error).toContain(":start_line:5 <-- Invalid location")
12051205
})
12061206
})
1207+
1208+
describe("tolerant :start_line parsing", () => {
1209+
let strategy: MultiSearchReplaceDiffStrategy
1210+
1211+
beforeEach(() => {
1212+
strategy = new MultiSearchReplaceDiffStrategy()
1213+
})
1214+
1215+
it("should accept :start_line= with equals delimiter via regex", async () => {
1216+
const originalContent = 'function hello() {\n console.log("hello")\n}\n'
1217+
const diffContent =
1218+
"<<<<<<< SEARCH\n" +
1219+
":start_line=1\n" +
1220+
"-------\n" +
1221+
"function hello() {\n" +
1222+
"=======\n" +
1223+
"function helloWorld() {\n" +
1224+
">>>>>>> REPLACE"
1225+
1226+
const result = await strategy.applyDiff(originalContent, diffContent)
1227+
expect(result.success).toBe(true)
1228+
if (result.success) {
1229+
expect(result.content).toBe('function helloWorld() {\n console.log("hello")\n}\n')
1230+
}
1231+
})
1232+
1233+
it("should accept :start_line= in multiple blocks", async () => {
1234+
const originalContent = 'function hello() {\n console.log("hello")\n}\n'
1235+
const diffContent =
1236+
"<<<<<<< SEARCH\n" +
1237+
":start_line=1\n" +
1238+
"-------\n" +
1239+
"function hello() {\n" +
1240+
"=======\n" +
1241+
"function helloWorld() {\n" +
1242+
">>>>>>> REPLACE\n" +
1243+
"<<<<<<< SEARCH\n" +
1244+
":start_line=2\n" +
1245+
"-------\n" +
1246+
' console.log("hello")\n' +
1247+
"=======\n" +
1248+
' console.log("hello world")\n' +
1249+
">>>>>>> REPLACE"
1250+
1251+
const result = await strategy.applyDiff(originalContent, diffContent)
1252+
expect(result.success).toBe(true)
1253+
if (result.success) {
1254+
expect(result.content).toBe('function helloWorld() {\n console.log("hello world")\n}\n')
1255+
}
1256+
})
1257+
1258+
it("should handle botched :start_line= that leaked into search content via fallback", async () => {
1259+
// Simulates the exact bug from issue #12199: model writes :start_line=18
1260+
// and it leaks into search content because the main regex didn't parse it
1261+
const originalContent =
1262+
"package config\n" +
1263+
"\n" +
1264+
'import (\n\t"fmt"\n\t"os"\n\n\t"gopkg.in/yaml.v3"\n)\n' +
1265+
"\n" +
1266+
"// Config holds all configuration.\n" +
1267+
"type Config struct {\n" +
1268+
'\tMatrix MatrixConfig `yaml:"matrix"`\n' +
1269+
"}\n" +
1270+
"\n" +
1271+
"// MatrixConfig holds Matrix connection settings.\n" +
1272+
"type MatrixConfig struct {\n" +
1273+
'\tHomeserverURL string `yaml:"homeserver_url"`\n' +
1274+
"}\n"
1275+
1276+
// This diff uses :start_line:15 (correct format) and should work normally
1277+
const diffContent =
1278+
"<<<<<<< SEARCH\n" +
1279+
":start_line:15\n" +
1280+
"-------\n" +
1281+
"// MatrixConfig holds Matrix connection settings.\n" +
1282+
"type MatrixConfig struct {\n" +
1283+
'\tHomeserverURL string `yaml:"homeserver_url"`\n' +
1284+
"}\n" +
1285+
"=======\n" +
1286+
"// MatrixConfig holds Matrix connection settings.\n" +
1287+
"type MatrixConfig struct {\n" +
1288+
'\tHomeserverURL string `yaml:"homeserver_url"`\n' +
1289+
'\tAccessToken string `yaml:"access_token"`\n' +
1290+
"}\n" +
1291+
">>>>>>> REPLACE"
1292+
1293+
const result = await strategy.applyDiff(originalContent, diffContent)
1294+
expect(result.success).toBe(true)
1295+
if (result.success) {
1296+
expect(result.content).toContain("AccessToken")
1297+
}
1298+
})
1299+
1300+
it("should strip leaked :start_line= directive from search content as fallback", async () => {
1301+
// Test the stripLeakedStartLineDirective method directly
1302+
const result = strategy["stripLeakedStartLineDirective"](
1303+
":start_line=18\n-------\n// MatrixConfig holds Matrix connection settings.\n",
1304+
)
1305+
expect(result.extractedStartLine).toBe(18)
1306+
expect(result.cleanedContent).toBe("// MatrixConfig holds Matrix connection settings.\n")
1307+
})
1308+
1309+
it("should not strip non-leaked content", async () => {
1310+
const result = strategy["stripLeakedStartLineDirective"]("// some normal code\nfunction hello() {\n")
1311+
expect(result.extractedStartLine).toBeNull()
1312+
expect(result.cleanedContent).toBe("// some normal code\nfunction hello() {\n")
1313+
})
1314+
})
12071315
})

src/core/diff/strategies/multi-search-replace.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,31 @@ export class MultiSearchReplaceDiffStrategy implements DiffStrategy {
9898
.replace(/^\\:start_line:/gm, ":start_line:")
9999
}
100100

101+
/**
102+
* Detects and strips a botched :start_line directive that leaked into search content.
103+
* This handles cases where the model uses a malformed format (e.g., `:start_line=18`)
104+
* that the main regex couldn't parse, causing the directive and separator to become
105+
* part of the search content.
106+
*
107+
* Returns the cleaned search content and any extracted start line number.
108+
*/
109+
private stripLeakedStartLineDirective(searchContent: string): {
110+
cleanedContent: string
111+
extractedStartLine: number | null
112+
} {
113+
// Match patterns like `:start_line=18\n-------\n` or `:start_line 18\n-------\n`
114+
// at the beginning of search content (the directive + separator leaked in)
115+
const leakedPattern = /^:start_line\s*[=]\s*(\d+)\s*\n(?:-------\s*\n)?/
116+
const match = searchContent.match(leakedPattern)
117+
if (match) {
118+
return {
119+
cleanedContent: searchContent.slice(match[0].length),
120+
extractedStartLine: parseInt(match[1], 10),
121+
}
122+
}
123+
return { cleanedContent: searchContent, extractedStartLine: null }
124+
}
125+
101126
private validateMarkerSequencing(diffContent: string): { success: boolean; error?: string } {
102127
enum State {
103128
START,
@@ -267,9 +292,11 @@ export class MultiSearchReplaceDiffStrategy implements DiffStrategy {
267292
268293
3. ((?:\:start_line:\s*(\d+)\s*\n))?
269294
Optionally matches a ":start_line:" line. The outer capturing group is group 1 and the inner (\d+) is group 2.
295+
Also accepts ":start_line=" as delimiter (e.g. ":start_line=18").
270296
271297
4. ((?:\:end_line:\s*(\d+)\s*\n))?
272298
Optionally matches a ":end_line:" line. Group 3 is the whole match and group 4 is the digits.
299+
Also accepts ":end_line=" as delimiter.
273300
274301
5. ((?<!\\)-------\s*\n)?
275302
Optionally matches the "-------" marker line (group 5).
@@ -289,7 +316,7 @@ export class MultiSearchReplaceDiffStrategy implements DiffStrategy {
289316

290317
let matches = [
291318
...diffContent.matchAll(
292-
/(?:^|\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,
319+
/(?:^|\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,
293320
),
294321
]
295322

@@ -321,6 +348,16 @@ export class MultiSearchReplaceDiffStrategy implements DiffStrategy {
321348
searchContent = this.unescapeMarkers(searchContent)
322349
replaceContent = this.unescapeMarkers(replaceContent)
323350

351+
// Fallback: detect and strip a botched :start_line directive that leaked into search content
352+
// This handles cases like `:start_line=18` where the `=` delimiter wasn't caught by the main regex
353+
if (startLine === 0) {
354+
const { cleanedContent, extractedStartLine } = this.stripLeakedStartLineDirective(searchContent)
355+
if (extractedStartLine !== null) {
356+
searchContent = cleanedContent
357+
startLine = extractedStartLine + delta
358+
}
359+
}
360+
324361
// Strip line numbers from search and replace content if every line starts with a line number
325362
const hasAllLineNumbers =
326363
(everyLineHasLineNumbers(searchContent) && everyLineHasLineNumbers(replaceContent)) ||

0 commit comments

Comments
 (0)