From 376629d25464801723238d5f51d5459d0bd14cee Mon Sep 17 00:00:00 2001 From: Rohit Behera <126186063+r0h1tb@users.noreply.github.com> Date: Sun, 25 Oct 2026 10:22:35 +0530 Subject: [PATCH] fix: only treat whole lines as SEARCH/REPLACE markers The standard diff pattern matched "=======\n" and ">>>>>>> REPLACE" anywhere, so a SEARCH section containing a banner comment such as "# =========" was cut at that comment and the block silently failed to apply, and a comment containing ">>>>>>> REPLACE" ended the replacement early. The delimiter validation already reads markers as whole lines (allowing trailing whitespace and CRLF), but extraction still needed LF right after each marker, so such responses were rejected as unmatched. Extract standard blocks from the same whole-line markers the validation uses. Custom diff patterns are unchanged. --- openevolve/utils/code_utils.py | 49 +++++++++++++++++++++++----------- tests/test_code_utils.py | 28 +++++++++++++++---- 2 files changed, 57 insertions(+), 20 deletions(-) diff --git a/openevolve/utils/code_utils.py b/openevolve/utils/code_utils.py index 5ef3f7cf0..faf09c0ed 100644 --- a/openevolve/utils/code_utils.py +++ b/openevolve/utils/code_utils.py @@ -206,24 +206,43 @@ def extract_diffs( ValueError: The standard SEARCH/REPLACE response has an extra or unmatched delimiter line. Custom diff patterns retain their own grammar. """ - matches = list(re.finditer(diff_pattern, diff_text, re.DOTALL)) if diff_pattern == _STANDARD_DIFF_PATTERN: - # A permissive regex can absorb a second separator into replacement - # text and then insert it as code. Validate the complete response's - # delimiter lines before applying any matched block. - cursor = 0 - for match in matches: - if _STANDARD_DIFF_MARKER.search(diff_text[cursor : match.start()]): - raise ValueError("Unmatched SEARCH/REPLACE delimiter outside a diff block") - block_markers = [ - marker.group(1) for marker in _STANDARD_DIFF_MARKER.finditer(match.group(0)) - ] - if block_markers != ["<<<<<<< SEARCH", "=======", ">>>>>>> REPLACE"]: + return _extract_standard_diffs(diff_text) + matches = re.finditer(diff_pattern, diff_text, re.DOTALL) + return [(match.group(1).rstrip(), match.group(2).rstrip()) for match in matches] + + +def _extract_standard_diffs(diff_text: str) -> List[Tuple[str, str]]: + """ + Extract SEARCH/REPLACE blocks delimited by whole marker lines + + A marker only counts on a line of its own, so code such as a "# =====" banner + comment stays part of a block. Every marker line in the response is checked + before any block is returned: a permissive match could otherwise absorb a + second separator into replacement text and insert it as code. + """ + markers = ["<<<<<<< SEARCH", "=======", ">>>>>>> REPLACE"] + blocks: List[Tuple[str, str]] = [] + texts: List[str] = [] # SEARCH and REPLACE text of the block being read + expected = 0 + section_start = 0 + for marker in _STANDARD_DIFF_MARKER.finditer(diff_text): + token = marker.group(1) + if token != markers[expected]: + if token == markers[1] and expected == 2: raise ValueError("Malformed SEARCH/REPLACE delimiter sequence") - cursor = match.end() - if _STANDARD_DIFF_MARKER.search(diff_text[cursor:]): raise ValueError("Unmatched SEARCH/REPLACE delimiter outside a diff block") - return [(match.group(1).rstrip(), match.group(2).rstrip()) for match in matches] + if expected > 0: + texts.append(diff_text[section_start : marker.start()].rstrip()) + # A section's text starts on the line after its marker + section_start = marker.end() + 1 + expected = (expected + 1) % 3 + if expected == 0: + blocks.append((texts[0], texts[1])) + texts = [] + if expected: + raise ValueError("Unmatched SEARCH/REPLACE delimiter outside a diff block") + return blocks def parse_full_rewrite(llm_response: str, language: str = "python") -> Optional[str]: diff --git a/tests/test_code_utils.py b/tests/test_code_utils.py index d1d08f7a9..ac023ab2a 100644 --- a/tests/test_code_utils.py +++ b/tests/test_code_utils.py @@ -237,12 +237,14 @@ def test_separator_with_trailing_whitespace_raises(self): with self.assertRaises(ValueError): extract_diffs(text) - def test_crlf_marker_is_recognised(self): - # Standard pattern needs LF after markers, so a CRLF response yields no - # match; its marker lines are then reported rather than silently ignored. + def test_crlf_response_is_parsed(self): text = "<<<<<<< SEARCH\r\nx = 1\r\n=======\r\nx = 2\r\n>>>>>>> REPLACE\r\n" - with self.assertRaises(ValueError): - extract_diffs(text) + self.assertEqual(extract_diffs(text), [("x = 1", "x = 2")]) + self.assertEqual(apply_diff("x = 1\ny = 1", text), "x = 2\ny = 1") + + def test_marker_lines_with_trailing_whitespace_are_parsed(self): + text = "<<<<<<< SEARCH \nx = 1\n=======\t\nx = 2\n>>>>>>> REPLACE \n" + self.assertEqual(extract_diffs(text), [("x = 1", "x = 2")]) def test_apply_diff_leaves_no_partial_changes(self): # A valid first block must not be applied when a later block is malformed. @@ -266,6 +268,22 @@ def test_separator_inside_code_line_is_allowed(self): text = f"<<<<<<< SEARCH\nx = 1\n=======\n{replace}\n>>>>>>> REPLACE\n" self.assertEqual(extract_diffs(text), [("x = 1", replace)]) + def test_code_line_ending_in_separator_does_not_split_search(self): + # A banner comment such as "# =====" ends with the separator text; only a + # line that is the separator ends the SEARCH section. + banner = "# " + "=" * 25 + search = f"{banner}\n# Helpers\n{banner}\ndef f(x):\n return x + 1" + replace = f"{banner}\n# Helpers\n{banner}\ndef f(x):\n return x + 2" + text = f"<<<<<<< SEARCH\n{search}\n=======\n{replace}\n>>>>>>> REPLACE\n" + self.assertEqual(extract_diffs(text), [(search, replace)]) + self.assertEqual(apply_diff(search + "\n", text), replace + "\n") + + def test_code_line_ending_in_marker_text_is_not_a_marker(self): + search = "x = 1 # <<<<<<< SEARCH" + replace = "x = 2 # >>>>>>> REPLACE" + text = f"<<<<<<< SEARCH\n{search}\n=======\n{replace}\n>>>>>>> REPLACE\n" + self.assertEqual(extract_diffs(text), [(search, replace)]) + def test_marker_words_in_prose_are_allowed(self): text = ( "I use the ======= separator and >>>>>>> REPLACE marker below.\n"