From 15abf4ed8fe311bfca6faf25e7548f2000f13471 Mon Sep 17 00:00:00 2001 From: teyrebaz33 Date: Sun, 22 Mar 2026 18:12:01 +0300 Subject: [PATCH] feat(patch): add 'did you mean?' feedback when patch fails to match When patch_replace() cannot find old_string in a file, the error message now includes the closest matching lines from the file with line numbers and context. This helps the LLM self-correct without a separate read_file call. Implements Phase 1 of #536: enhanced patch error feedback with no architectural changes. - tools/fuzzy_match.py: new find_closest_lines() using SequenceMatcher - tools/file_operations.py: attach closest-lines hint to patch errors - tests/tools/test_fuzzy_match.py: 5 new tests for find_closest_lines --- tests/tools/test_fuzzy_match.py | 32 +++++++++++++++++ tools/file_operations.py | 16 +++++---- tools/fuzzy_match.py | 62 +++++++++++++++++++++++++++++++++ 3 files changed, 104 insertions(+), 6 deletions(-) diff --git a/tests/tools/test_fuzzy_match.py b/tests/tools/test_fuzzy_match.py index 7a03065f4e..9db45b7a5e 100644 --- a/tests/tools/test_fuzzy_match.py +++ b/tests/tools/test_fuzzy_match.py @@ -230,3 +230,35 @@ class TestEscapeDriftGuard: new, count, strategy, err = fuzzy_find_and_replace(content, old_string, new_string) assert err is None assert count == 1 + + +class TestFindClosestLines: + def setup_method(self): + from tools.fuzzy_match import find_closest_lines + self.find_closest_lines = find_closest_lines + + def test_finds_similar_line(self): + content = "def foo():\n pass\ndef bar():\n return 1\n" + result = self.find_closest_lines("def baz():", content) + assert "def foo" in result or "def bar" in result + + def test_returns_empty_for_no_match(self): + content = "completely different content here" + result = self.find_closest_lines("xyzzy_no_match_possible_!!!", content) + assert result == "" + + def test_returns_empty_for_empty_inputs(self): + assert self.find_closest_lines("", "some content") == "" + assert self.find_closest_lines("old string", "") == "" + + def test_includes_context_lines(self): + content = "line1\nline2\ndef target():\n pass\nline5\n" + result = self.find_closest_lines("def target():", content) + assert "target" in result + + def test_includes_line_numbers(self): + content = "line1\nline2\ndef foo():\n pass\n" + result = self.find_closest_lines("def foo():", content) + # Should include line numbers in format "N| content" + assert "|" in result + diff --git a/tools/file_operations.py b/tools/file_operations.py index 59070d7ce0..c9b5d3d644 100644 --- a/tools/file_operations.py +++ b/tools/file_operations.py @@ -738,12 +738,16 @@ class ShellFileOperations(FileOperations): content, old_string, new_string, replace_all ) - if error: - return PatchResult(error=error) - - if match_count == 0: - return PatchResult(error=f"Could not find match for old_string in {path}") - + if error or match_count == 0: + err_msg = error or f"Could not find match for old_string in {path}" + try: + from tools.fuzzy_match import find_closest_lines + hint = find_closest_lines(old_string, content) + if hint: + err_msg += "\n\nDid you mean one of these sections?\n" + hint + except Exception: + pass + return PatchResult(error=err_msg) # Write back write_result = self.write_file(path, new_content) if write_result.error: diff --git a/tools/fuzzy_match.py b/tools/fuzzy_match.py index a9dc4272ef..301794644e 100644 --- a/tools/fuzzy_match.py +++ b/tools/fuzzy_match.py @@ -619,3 +619,65 @@ def _map_normalized_positions(original: str, normalized: str, original_matches.append((orig_start, min(orig_end, len(original)))) return original_matches + + +def find_closest_lines(old_string: str, content: str, context_lines: int = 2, max_results: int = 3) -> str: + """Find lines in content most similar to old_string for "did you mean?" feedback. + + Returns a formatted string showing the closest matching lines with context, + or empty string if no useful match is found. + """ + if not old_string or not content: + return "" + + old_lines = old_string.splitlines() + content_lines = content.splitlines() + + if not old_lines or not content_lines: + return "" + + # Use first line of old_string as anchor for search + anchor = old_lines[0].strip() + if not anchor: + # Try second line if first is blank + candidates = [l.strip() for l in old_lines if l.strip()] + if not candidates: + return "" + anchor = candidates[0] + + # Score each line in content by similarity to anchor + scored = [] + for i, line in enumerate(content_lines): + stripped = line.strip() + if not stripped: + continue + ratio = SequenceMatcher(None, anchor, stripped).ratio() + if ratio > 0.3: + scored.append((ratio, i)) + + if not scored: + return "" + + # Take top matches + scored.sort(key=lambda x: -x[0]) + top = scored[:max_results] + + parts = [] + seen_ranges = set() + for _, line_idx in top: + start = max(0, line_idx - context_lines) + end = min(len(content_lines), line_idx + len(old_lines) + context_lines) + key = (start, end) + if key in seen_ranges: + continue + seen_ranges.add(key) + snippet = "\n".join( + f"{start + j + 1:4d}| {content_lines[start + j]}" + for j in range(end - start) + ) + parts.append(snippet) + + if not parts: + return "" + + return "\n---\n".join(parts)