From b8b1f24fd755ae187a0fbaedf5c9657a2af1ef1e Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 26 Mar 2026 19:38:04 -0700 Subject: [PATCH] fix: handle addition-only hunks in V4A patch parser (#3325) V4A patches with only + lines (no context or - lines) were silently dropped because search_lines was empty and the 'if search_lines:' block was the only code path. Addition-only hunks are common when the model generates patches for new functions or blocks. Adds an else branch that inserts at the context_hint position when available, or appends at end of file. Includes 2 regression tests for addition-only hunks with and without context hints. Salvaged from PR #3092 by thakoreh. Co-authored-by: Hiren --- tests/tools/test_patch_parser.py | 68 ++++++++++++++++++++++++++++++++ tools/patch_parser.py | 17 ++++++++ 2 files changed, 85 insertions(+) diff --git a/tests/tools/test_patch_parser.py b/tests/tools/test_patch_parser.py index 77baab8dd..42e5129f5 100644 --- a/tests/tools/test_patch_parser.py +++ b/tests/tools/test_patch_parser.py @@ -185,3 +185,71 @@ class TestApplyUpdate: ' result = 1\n' ' return result + 1' ) + + +class TestAdditionOnlyHunks: + """Regression tests for #3081 — addition-only hunks were silently dropped.""" + + def test_addition_only_hunk_with_context_hint(self): + """A hunk with only + lines should insert at the context hint location.""" + patch = """\ +*** Begin Patch +*** Update File: src/app.py +@@ def main @@ ++def helper(): ++ return 42 +*** End Patch""" + ops, err = parse_v4a_patch(patch) + assert err is None + assert len(ops) == 1 + assert len(ops[0].hunks) == 1 + + hunk = ops[0].hunks[0] + # All lines should be additions + assert all(l.prefix == '+' for l in hunk.lines) + + # Apply to a file that contains the context hint + class FakeFileOps: + written = None + def read_file(self, path, **kw): + return SimpleNamespace( + content="def main():\n pass\n", + error=None, + ) + def write_file(self, path, content): + self.written = content + return SimpleNamespace(error=None) + + file_ops = FakeFileOps() + result = apply_v4a_operations(ops, file_ops) + assert result.success is True + assert "def helper():" in file_ops.written + assert "return 42" in file_ops.written + + def test_addition_only_hunk_without_context_hint(self): + """A hunk with only + lines and no context hint appends at end of file.""" + patch = """\ +*** Begin Patch +*** Update File: src/app.py ++def new_func(): ++ return True +*** End Patch""" + ops, err = parse_v4a_patch(patch) + assert err is None + + class FakeFileOps: + written = None + def read_file(self, path, **kw): + return SimpleNamespace( + content="existing = True\n", + error=None, + ) + def write_file(self, path, content): + self.written = content + return SimpleNamespace(error=None) + + file_ops = FakeFileOps() + result = apply_v4a_operations(ops, file_ops) + assert result.success is True + assert file_ops.written.endswith("def new_func():\n return True\n") + assert "existing = True" in file_ops.written diff --git a/tools/patch_parser.py b/tools/patch_parser.py index bef196e50..1a11f1413 100644 --- a/tools/patch_parser.py +++ b/tools/patch_parser.py @@ -419,6 +419,23 @@ def _apply_update(op: PatchOperation, file_ops: Any) -> Tuple[bool, str]: if error: return False, f"Could not apply hunk: {error}" + else: + # Addition-only hunk (no context or removed lines). + # Insert at the location indicated by the context hint, or at end of file. + insert_text = '\n'.join(replace_lines) + if hunk.context_hint: + hint_pos = new_content.find(hunk.context_hint) + if hint_pos != -1: + # Insert after the line containing the context hint + eol = new_content.find('\n', hint_pos) + if eol != -1: + new_content = new_content[:eol + 1] + insert_text + '\n' + new_content[eol + 1:] + else: + new_content = new_content + '\n' + insert_text + else: + new_content = new_content.rstrip('\n') + '\n' + insert_text + '\n' + else: + new_content = new_content.rstrip('\n') + '\n' + insert_text + '\n' # Write new content write_result = file_ops.write_file(op.file_path, new_content)