band-aider-pub/tests/judgement/test_file_patcher.py
2026-10-17 11:57:37 -07:00

1459 lines
48 KiB
Python

# SPDX-FileCopyrightText: 2026 Art Sea LLC
#
# SPDX-License-Identifier: AGPL-3.0-only
from __future__ import annotations
from band_aider.judgement.code_editor import EditorRecord
from pathlib import Path
import json
import pytest
from band_aider.judgement.file_patcher import (
_PATCH_TRANSITIONS,
_StructuralToken,
FilePatcher,
QuickRetryError,
)
from band_aider.context.resume_state import configure_resume_settings
from band_aider.judgement.repo_exec import current_repo_exec
from tests.support.memory_repo_exec import MemoryRepoExec
@pytest.fixture
def patch_memory_root() -> Path:
"""Bind each patch example to a fresh lexical repository held entirely in memory."""
adapter = MemoryRepoExec()
configure_resume_settings(
hydrate=False,
checkpoint_dir=adapter.root / "checkpoints",
repo_exec=adapter,
)
return adapter.root
def _write(path: Path, text: str) -> Path:
"""Write one fixture file through the current in-memory repository boundary."""
current_repo_exec().write_text(path, text)
return path
def _read(path: Path) -> str:
"""Read fixture text through the current in-memory repository boundary."""
return current_repo_exec().read_text(path)
def _read_bytes(path: Path) -> bytes:
"""Read exact fixture bytes through the current in-memory repository boundary."""
return current_repo_exec().read_bytes(path)
def test_file_patcher_applies_one_exact_replacement_to_editable_file(patch_memory_root: Path) -> None:
"""Verify one editable file is patched by exact SEARCH/REPLACE text."""
target = _write(patch_memory_root / "pkg" / "mod.py", "value = 1\n")
_edit_result = FilePatcher(patch_memory_root, repo_exec=current_repo_exec()).apply(
"\n".join(
[
"SUMMARY: Update value.",
"FILE: pkg/mod.py",
"<<<<<<< SEARCH",
"value = 1",
"=======",
"value = 2",
">>>>>>> REPLACE",
]
),
editable_files=[target],
readonly_files=[],
)
changed, summary = _edit_result.changed_paths, _edit_result.summary
assert changed == [target]
assert _read(target) == "value = 2\n"
assert summary == "Update value."
def test_file_patcher_rejects_patch_targeting_readonly_file(patch_memory_root: Path) -> None:
"""Verify readonly patch attempts are returned as quick-retry issues."""
readonly = _write(patch_memory_root / "pkg" / "ro.py", "value = 1\n")
with pytest.raises(QuickRetryError, match="unresolved blocks"):
FilePatcher(patch_memory_root, repo_exec=current_repo_exec()).apply(
"\n".join(
[
"FILE: pkg/ro.py",
"<<<<<<< SEARCH",
"value = 1",
"=======",
"value = 2",
">>>>>>> REPLACE",
]
),
editable_files=[],
readonly_files=[readonly],
)
def test_file_patcher_rejects_patch_targeting_path_outside_repo_root(patch_memory_root: Path) -> None:
"""Verify path traversal outside the repository root becomes a quick-retry issue."""
outside = _write(patch_memory_root.parent / "outside.py", "value = 1\n")
with pytest.raises(QuickRetryError, match="unresolved blocks"):
FilePatcher(patch_memory_root, repo_exec=current_repo_exec()).apply(
"\n".join(
[
"FILE: ../outside.py",
"<<<<<<< SEARCH",
"value = 1",
"=======",
"value = 2",
">>>>>>> REPLACE",
]
),
editable_files=[outside],
readonly_files=[],
)
def test_file_patcher_rejects_missing_search_text(patch_memory_root: Path) -> None:
"""Verify missing SEARCH text requests a quick retry instead of failing hard."""
target = _write(patch_memory_root / "pkg" / "mod.py", "value = 1\n")
with pytest.raises(QuickRetryError, match="unresolved blocks"):
FilePatcher(patch_memory_root, repo_exec=current_repo_exec()).apply(
"\n".join(
[
"FILE: pkg/mod.py",
"<<<<<<< SEARCH",
"value = 9",
"=======",
"value = 2",
">>>>>>> REPLACE",
]
),
editable_files=[target],
readonly_files=[],
)
def test_file_patcher_rejects_duplicate_search_text(patch_memory_root: Path) -> None:
"""Verify duplicate SEARCH matches request a quick retry instead of failing hard."""
target = _write(patch_memory_root / "pkg" / "mod.py", "value = 1\nvalue = 1\n")
with pytest.raises(QuickRetryError, match="unresolved blocks"):
FilePatcher(patch_memory_root).apply(
"\n".join(
[
"FILE: pkg/mod.py",
"<<<<<<< SEARCH",
"value = 1",
"=======",
"value = 2",
">>>>>>> REPLACE",
]
),
editable_files=[target],
readonly_files=[],
)
def test_whitespace_hint_rejects_until_the_existing_string_is_copied_exactly(patch_memory_root: Path) -> None:
"""Whitespace-only similarity diagnoses a rejected clause; the corrected exact clause applies."""
original = " alpha\n beta\n"
target = _write(patch_memory_root / "samples" / "message.txt", original)
patcher = FilePatcher(patch_memory_root, repo_exec=current_repo_exec())
wrong = "\n".join((
"FILE: samples/message.txt", "<<<<<<< SEARCH",
" alpha", " beta",
"=======", " gamma", " beta", ">>>>>>> REPLACE",
))
with pytest.raises(QuickRetryError) as raised:
patcher.apply(wrong, editable_files=[target], readonly_files=[])
hint = raised.value.retry_feedback
assert "WHITESPACE HINT" in hint
assert "Supplied leading whitespace per line: [6, 6] character(s)" in hint
assert "Found leading whitespace per line: [2, 2] character(s)" in hint
assert "The existing string has 2 on each line: 4 fewer per line." in hint
assert "FOUND STRING (verbatim):\n alpha\n beta\nEND FOUND STRING" in hint
assert "Do not reuse the previous SEARCH string." in hint
assert "Whitespace must match exactly" in hint
assert "This is only a diagnostic comparison; this block was not applied." in hint
assert raised.value.changed_paths == []
assert raised.value.records == []
assert _read_bytes(target) == original.encode()
corrected = "\n".join((
"FILE: samples/message.txt", "<<<<<<< SEARCH",
" alpha", " beta",
"=======", " gamma", " beta", ">>>>>>> REPLACE",
))
result = patcher.apply(corrected, editable_files=[target], readonly_files=[])
assert result.changed_paths == [target]
assert _read(target) == " gamma\n beta\n"
@pytest.mark.parametrize("source, search, found_whitespace, supplied_whitespace", (
("alpha beta\n", "alpha beta", " ", " "),
("\talpha\n", " alpha", "\t", " "),
(" alpha \n", " alpha\t", " ", " \t"),
("alpha\nbeta\n", "alpha beta", "\n", " "),
("alpha\r\nbeta\n", "alpha beta", "\r\n", " "),
("\u00a0alpha\n", "\talpha", "\u00a0", "\t"),
))
def test_whitespace_hint_describes_string_characters_without_applying(
patch_memory_root: Path, source: str, search: str,
found_whitespace: str, supplied_whitespace: str,
) -> None:
"""Tabs, internal/trailing spaces, line breaks and Unicode whitespace remain exact-match errors."""
target = _write(patch_memory_root / "samples" / "message.txt", source)
output = "\n".join((
"FILE: samples/message.txt", "<<<<<<< SEARCH", search,
"=======", "changed", ">>>>>>> REPLACE",
))
with pytest.raises(QuickRetryError) as raised:
FilePatcher(patch_memory_root, repo_exec=current_repo_exec()).apply(
output, editable_files=[target], readonly_files=[],
)
hint = raised.value.retry_feedback
assert "WHITESPACE HINT" in hint
assert f"Found whitespace: {len(found_whitespace)} character(s) {json.dumps(found_whitespace)}" in hint
assert f"Supplied whitespace: {len(supplied_whitespace)} character(s) {json.dumps(supplied_whitespace)}" in hint
assert "Whitespace must match exactly, including its characters and positions." in hint
assert raised.value.changed_paths == []
assert _read_bytes(target) == source.encode()
def test_whitespace_hint_lists_every_occurrence_without_selecting_one(patch_memory_root: Path) -> None:
"""Several whitespace-free occurrences remain rejected and retain each distinct whitespace description."""
source = " alpha\n alpha\n"
target = _write(patch_memory_root / "samples" / "message.txt", source)
output = "\n".join((
"FILE: samples/message.txt", "<<<<<<< SEARCH", " alpha",
"=======", "changed", ">>>>>>> REPLACE",
))
with pytest.raises(QuickRetryError) as raised:
FilePatcher(patch_memory_root, repo_exec=current_repo_exec()).apply(
output, editable_files=[target], readonly_files=[],
)
hint = raised.value.retry_feedback
assert "occur at 2 locations in the existing string" in hint
assert "Occurrence 1 at character 1:" in hint
assert "Occurrence 2 at character 8:" in hint
assert "Found leading whitespace per line: [1] character(s)" in hint
assert "Found leading whitespace per line: [3] character(s)" in hint
assert raised.value.changed_paths == []
assert _read_bytes(target) == source.encode()
def test_whitespace_hint_keeps_overlapping_occurrences(patch_memory_root: Path) -> None:
"""Overlapping compact-string occurrences are diagnostic alternatives rather than an implicit selection."""
source = "a a a"
target = _write(patch_memory_root / "samples" / "message.txt", source)
output = "\n".join((
"FILE: samples/message.txt", "<<<<<<< SEARCH", " a a ",
"=======", "changed", ">>>>>>> REPLACE",
))
with pytest.raises(QuickRetryError) as raised:
FilePatcher(patch_memory_root, repo_exec=current_repo_exec()).apply(
output, editable_files=[target], readonly_files=[],
)
hint = raised.value.retry_feedback
assert "occur at 2 locations in the existing string" in hint
assert 'Found string (escaped): "a a "' in hint
assert 'Found string (escaped): " a a"' in hint
assert _read_bytes(target) == source.encode()
@pytest.mark.parametrize("source, search", (("alpha beta", "alpha gamma"), ("\t", " ")))
def test_unrelated_or_whitespace_only_search_has_no_similarity_hint(
patch_memory_root: Path, source: str, search: str,
) -> None:
"""Non-whitespace differences and whitespace-only clauses cannot claim a corresponding string."""
target = _write(patch_memory_root / "samples" / "message.txt", source)
output = "\n".join((
"FILE: samples/message.txt", "<<<<<<< SEARCH", search,
"=======", "changed", ">>>>>>> REPLACE",
))
with pytest.raises(QuickRetryError) as raised:
FilePatcher(patch_memory_root, repo_exec=current_repo_exec()).apply(
output, editable_files=[target], readonly_files=[],
)
assert raised.value.issues == ["SEARCH text was not found exactly once in samples/message.txt"]
assert "WHITESPACE HINT" not in raised.value.retry_feedback
assert _read_bytes(target) == source.encode()
def test_whitespace_hint_uses_the_prospective_string_and_preserves_exact_prior_edits(patch_memory_root: Path) -> None:
"""A later rejected clause diagnoses current prospective text while earlier exact clauses keep their existing behavior."""
target = _write(patch_memory_root / "samples" / "message.txt", "alpha old\n")
output = "\n".join((
"FILE: samples/message.txt", "<<<<<<< SEARCH", "alpha old",
"=======", "alpha new", ">>>>>>> REPLACE",
"FILE: samples/message.txt", "<<<<<<< SEARCH", "alpha new",
"=======", "changed", ">>>>>>> REPLACE",
))
with pytest.raises(QuickRetryError) as raised:
FilePatcher(patch_memory_root, repo_exec=current_repo_exec()).apply(
output, editable_files=[target], readonly_files=[],
)
assert 'Found string (escaped): "alpha new"' in raised.value.retry_feedback
assert raised.value.changed_paths == [target]
assert len(raised.value.records) == 1
assert _read(target) == "alpha new\n"
def test_file_patcher_applies_multiple_blocks_to_one_file_sequentially(patch_memory_root: Path) -> None:
"""Verify multiple blocks for one file are applied in order in memory."""
target = _write(patch_memory_root / "pkg" / "mod.py", "alpha = 1\nbeta = alpha + 1\n")
_edit_result = FilePatcher(patch_memory_root, repo_exec=current_repo_exec()).apply(
"\n".join(
[
"FILE: pkg/mod.py",
"<<<<<<< SEARCH",
"alpha = 1",
"=======",
"alpha = 2",
">>>>>>> REPLACE",
"",
"FILE: pkg/mod.py",
"<<<<<<< SEARCH",
"beta = alpha + 1",
"=======",
"beta = alpha + 3",
">>>>>>> REPLACE",
]
),
editable_files=[target],
readonly_files=[],
)
changed, _ = _edit_result.changed_paths, _edit_result.summary
assert changed == [target]
assert _read(target) == "alpha = 2\nbeta = alpha + 3\n"
def test_file_patcher_inherits_last_file_for_adjacent_blocks(patch_memory_root: Path) -> None:
"""Verify adjacent SEARCH/REPLACE blocks inherit the most recent FILE line."""
target = _write(patch_memory_root / "pkg" / "mod.py", "alpha = 1\nbeta = alpha + 1\n")
_edit_result = FilePatcher(patch_memory_root, repo_exec=current_repo_exec()).apply(
"\n".join(
[
"FILE: pkg/mod.py",
"<<<<<<< SEARCH",
"alpha = 1",
"=======",
"alpha = 2",
">>>>>>> REPLACE",
"<<<<<<< SEARCH",
"beta = alpha + 1",
"=======",
"beta = alpha + 3",
">>>>>>> REPLACE",
]
),
editable_files=[target],
readonly_files=[],
)
changed, _ = _edit_result.changed_paths, _edit_result.summary
assert changed == [target]
assert _read(target) == "alpha = 2\nbeta = alpha + 3\n"
def test_file_patcher_stops_inheriting_when_new_file_line_appears(patch_memory_root: Path) -> None:
"""Verify a later FILE line switches inherited block context."""
first = _write(patch_memory_root / "pkg" / "first.py", "alpha = 1\nbeta = alpha + 1\n")
second = _write(patch_memory_root / "pkg" / "second.py", "gamma = 1\ndelta = gamma + 1\n")
_edit_result = FilePatcher(patch_memory_root, repo_exec=current_repo_exec()).apply(
"\n".join(
[
"FILE: pkg/first.py",
"<<<<<<< SEARCH",
"alpha = 1",
"=======",
"alpha = 2",
">>>>>>> REPLACE",
"FILE: pkg/second.py",
"<<<<<<< SEARCH",
"gamma = 1",
"=======",
"gamma = 2",
">>>>>>> REPLACE",
"<<<<<<< SEARCH",
"delta = gamma + 1",
"=======",
"delta = gamma + 3",
">>>>>>> REPLACE",
]
),
editable_files=[first, second],
readonly_files=[],
)
changed, _ = _edit_result.changed_paths, _edit_result.summary
assert changed == [first, second]
assert _read(first) == "alpha = 2\nbeta = alpha + 1\n"
assert _read(second) == "gamma = 2\ndelta = gamma + 3\n"
def test_file_patcher_rolls_back_all_files_when_later_block_fails(patch_memory_root: Path) -> None:
"""Verify valid blocks can apply before later non-matching blocks trigger a quick retry."""
first = _write(patch_memory_root / "pkg" / "first.py", "value = 1\n")
second = _write(patch_memory_root / "pkg" / "second.py", "other = 1\n")
with pytest.raises(QuickRetryError, match="unresolved blocks"):
FilePatcher(patch_memory_root, repo_exec=current_repo_exec()).apply(
"\n".join(
[
"FILE: pkg/first.py",
"<<<<<<< SEARCH",
"value = 1",
"=======",
"value = 2",
">>>>>>> REPLACE",
"",
"FILE: pkg/second.py",
"<<<<<<< SEARCH",
"missing = 1",
"=======",
"missing = 2",
">>>>>>> REPLACE",
]
),
editable_files=[first, second],
readonly_files=[],
)
assert _read(first) == "value = 2\n"
assert _read(second) == "other = 1\n"
def test_file_patcher_does_not_second_guess_language_syntax(patch_memory_root: Path) -> None:
"""Verify patch application is language agnostic after FileManager discovery."""
target = _write(patch_memory_root / "pkg" / "mod.rb", "value = 1\n")
_edit_result = FilePatcher(patch_memory_root, repo_exec=current_repo_exec()).apply(
"\n".join(
[
"FILE: pkg/mod.rb",
"<<<<<<< SEARCH",
"value = 1",
"=======",
"def valid_ruby(value)\n value\nend\n",
">>>>>>> REPLACE",
]
),
editable_files=[target],
readonly_files=[],
)
changed, _ = _edit_result.changed_paths, _edit_result.summary
assert changed == [target]
assert _read(target) == "def valid_ruby(value)\n value\nend\n\n"
def test_file_patcher_rejects_no_patch(patch_memory_root: Path) -> None:
"""Verify NO_PATCH responses become a recoverable quick retry signal."""
target = _write(patch_memory_root / "pkg" / "mod.py", "value = 1\n")
with pytest.raises(QuickRetryError, match="unresolved blocks"):
FilePatcher(patch_memory_root, repo_exec=current_repo_exec()).apply(
"NO_PATCH: ambiguous edit request",
editable_files=[target],
readonly_files=[],
)
def test_file_patcher_returns_changed_paths_only_for_files_that_actually_changed(patch_memory_root: Path) -> None:
"""Verify unchanged files are excluded from the returned changed-path list."""
first = _write(patch_memory_root / "pkg" / "first.py", "value = 1\n")
second = _write(patch_memory_root / "pkg" / "second.py", "name = 'x'\n")
_edit_result = FilePatcher(patch_memory_root, repo_exec=current_repo_exec()).apply(
"\n".join(
[
"FILE: pkg/first.py",
"<<<<<<< SEARCH",
"value = 1",
"=======",
"value = 2",
">>>>>>> REPLACE",
"",
"FILE: pkg/second.py",
"<<<<<<< SEARCH",
"name = 'x'",
"=======",
"name = 'x'",
">>>>>>> REPLACE",
]
),
editable_files=[first, second],
readonly_files=[],
)
changed, _ = _edit_result.changed_paths, _edit_result.summary
assert changed == [first]
assert _read(first) == "value = 2\n"
assert _read(second) == "name = 'x'\n"
assert _edit_result.records == [EditorRecord("", "value = 1", "value = 2")]
def test_file_patcher_allows_short_search_block_when_it_matches_exactly(patch_memory_root: Path) -> None:
"""Verify short SEARCH blocks apply when they match exactly once."""
target = _write(
patch_memory_root / "pkg" / "mod.py",
"line1\nline2\nline3\nline4\nline5\nline6\nline7\n",
)
_edit_result = FilePatcher(patch_memory_root, repo_exec=current_repo_exec()).apply(
"\n".join(
[
"FILE: pkg/mod.py",
"<<<<<<< SEARCH",
"line3",
"line4",
"line5",
"=======",
"line3",
"changed",
"line5",
">>>>>>> REPLACE",
]
),
editable_files=[target],
readonly_files=[],
)
changed, _ = _edit_result.changed_paths, _edit_result.summary
assert changed == [target]
def test_file_patcher_allows_short_search_block_at_file_boundary(patch_memory_root: Path) -> None:
"""Verify boundary edits can use shorter SEARCH excerpts when full context is unavailable."""
target = _write(
patch_memory_root / "pkg" / "mod.py",
"line1\nline2\nline3\nline4\nline5\nline6\n",
)
_edit_result = FilePatcher(patch_memory_root).apply(
"\n".join(
[
"FILE: pkg/mod.py",
"<<<<<<< SEARCH",
"line1",
"line2",
"line3",
"=======",
"line1",
"changed",
"line3",
">>>>>>> REPLACE",
]
),
editable_files=[target],
readonly_files=[],
)
changed, _ = _edit_result.changed_paths, _edit_result.summary
assert changed == [target]
def test_file_patcher_aborts_at_file_inside_unterminated_block(patch_memory_root: Path) -> None:
"""Verify FILE cannot resynchronize an unterminated block."""
first = _write(patch_memory_root / "pkg" / "first.py", "alpha = 1\n")
second = _write(patch_memory_root / "pkg" / "second.py", "beta = 1\n")
with pytest.raises(QuickRetryError, match="unresolved blocks"):
FilePatcher(patch_memory_root, repo_exec=current_repo_exec()).apply(
"\n".join(
[
"FILE: pkg/first.py",
"<<<<<<< SEARCH",
"alpha = 1",
"=======",
"alpha = 2",
"FILE: pkg/second.py",
"<<<<<<< SEARCH",
"beta = 1",
"=======",
"beta = 2",
">>>>>>> REPLACE",
]
),
editable_files=[first, second],
readonly_files=[],
)
assert _read(first) == "alpha = 1\n"
assert _read(second) == "beta = 1\n"
def test_file_patcher_rejects_stray_search_marker_before_file_context(patch_memory_root: Path) -> None:
"""Editor validation rejects SEARCH before a FILE declaration."""
target = _write(patch_memory_root / "pkg" / "mod.py", "value = 1\n")
with pytest.raises(QuickRetryError, match="unresolved blocks"):
FilePatcher(patch_memory_root, repo_exec=current_repo_exec()).apply(
"\n".join(
[
"<<<<<<< SEARCH",
"noise",
"=======",
"more noise",
">>>>>>> REPLACE",
"FILE: pkg/mod.py",
"<<<<<<< SEARCH",
"value = 1",
"=======",
"value = 2",
">>>>>>> REPLACE",
]
),
editable_files=[target],
readonly_files=[],
)
assert _read(target) == "value = 1\n"
def test_file_patcher_aborts_leading_inherited_block_without_file_context(patch_memory_root: Path) -> None:
"""Verify a leading SEARCH token aborts instead of recovering at a later FILE."""
target = _write(patch_memory_root / "pkg" / "mod.py", "value = 1\nother = value + 1\n")
with pytest.raises(QuickRetryError, match="unresolved blocks"):
FilePatcher(patch_memory_root).apply(
"\n".join(
[
"<<<<<<< SEARCH",
"noise",
"=======",
"more noise",
">>>>>>> REPLACE",
"FILE: pkg/mod.py",
"<<<<<<< SEARCH",
"value = 1",
"=======",
"value = 2",
">>>>>>> REPLACE",
"<<<<<<< SEARCH",
"other = value + 1",
"=======",
"other = value + 3",
">>>>>>> REPLACE",
]
),
editable_files=[target],
readonly_files=[],
)
assert _read(target) == "value = 1\nother = value + 1\n"
def test_file_patcher_aborts_at_second_file_before_search(patch_memory_root: Path) -> None:
"""Verify FILE cannot be repeated before SEARCH is accepted."""
first = _write(patch_memory_root / "pkg" / "first.py", "alpha = 1\n")
second = _write(patch_memory_root / "pkg" / "second.py", "beta = 1\n")
with pytest.raises(QuickRetryError, match="unresolved blocks"):
FilePatcher(patch_memory_root).apply(
"\n".join(
[
"FILE: pkg/first.py",
"alpha = 1",
"FILE: pkg/second.py",
"<<<<<<< SEARCH",
"beta = 1",
"=======",
"beta = 2",
">>>>>>> REPLACE",
]
),
editable_files=[first, second],
readonly_files=[],
)
assert _read(first) == "alpha = 1\n"
assert _read(second) == "beta = 1\n"
def test_file_patcher_aborts_at_file_before_replace_terminator(patch_memory_root: Path) -> None:
"""Verify FILE cannot resynchronize before REPLACE is accepted."""
first = _write(patch_memory_root / "pkg" / "first.py", "alpha = 1\n")
second = _write(patch_memory_root / "pkg" / "second.py", "beta = 1\n")
with pytest.raises(QuickRetryError, match="unresolved blocks"):
FilePatcher(patch_memory_root).apply(
"\n".join(
[
"FILE: pkg/first.py",
"<<<<<<< SEARCH",
"alpha = 1",
"=======",
"alpha = 2",
"FILE: pkg/second.py",
"<<<<<<< SEARCH",
"beta = 1",
"=======",
"beta = 2",
">>>>>>> REPLACE",
]
),
editable_files=[first, second],
readonly_files=[],
)
assert _read(first) == "alpha = 1\n"
assert _read(second) == "beta = 1\n"
def test_file_patcher_aborts_at_first_malformed_candidate(patch_memory_root: Path) -> None:
"""Verify malformed structural output prevents later candidates from applying."""
first = _write(patch_memory_root / "pkg" / "first.py", "alpha = 1\n")
second = _write(patch_memory_root / "pkg" / "second.py", "beta = 1\n")
third = _write(patch_memory_root / "pkg" / "third.py", "gamma = 1\n")
with pytest.raises(QuickRetryError, match="unresolved blocks"):
FilePatcher(patch_memory_root).apply(
"\n".join(
[
"garbage",
"FILE: pkg/first.py",
"missing search marker",
"FILE: pkg/second.py",
"<<<<<<< SEARCH",
"beta = 1",
"FILE: pkg/third.py",
"<<<<<<< SEARCH",
"gamma = 1",
"=======",
"gamma = 2",
">>>>>>> REPLACE",
]
),
editable_files=[first, second, third],
readonly_files=[],
)
assert _read(first) == "alpha = 1\n"
assert _read(second) == "beta = 1\n"
assert _read(third) == "gamma = 1\n"
def test_file_patcher_applies_valid_blocks_even_when_no_patch_line_is_present(patch_memory_root: Path) -> None:
"""Verify NO_PATCH becomes an issue while valid blocks in the same response still apply."""
target = _write(patch_memory_root / "pkg" / "mod.py", "value = 1\n")
with pytest.raises(QuickRetryError, match="unresolved blocks"):
FilePatcher(patch_memory_root).apply(
"\n".join(
[
"NO_PATCH: one region remains ambiguous",
"FILE: pkg/mod.py",
"<<<<<<< SEARCH",
"value = 1",
"=======",
"value = 2",
">>>>>>> REPLACE",
]
),
editable_files=[target],
readonly_files=[],
)
assert _read(target) == "value = 2\n"
def test_patch_transitions_are_the_editor_validation_contract() -> None:
"""Every legal reserved-token transition belongs to editor validation."""
assert _PATCH_TRANSITIONS == {
_StructuralToken.START: frozenset({_StructuralToken.FILE}),
_StructuralToken.FILE: frozenset({_StructuralToken.SEARCH}),
_StructuralToken.SEARCH: frozenset({_StructuralToken.SEPARATOR}),
_StructuralToken.SEPARATOR: frozenset({_StructuralToken.REPLACE}),
_StructuralToken.REPLACE: frozenset(
{
_StructuralToken.SEARCH,
_StructuralToken.FILE,
_StructuralToken.EOF,
}
),
}
@pytest.mark.parametrize(
("raw_output", "expected_text", "completed_blocks"),
[
(
"FILE: src/a.py\nFILE: src/b.py",
"`<<<<<<< SEARCH`",
0,
),
(
"\n".join(
[
"FILE: src/a.py",
"<<<<<<< SEARCH",
"old",
"=======",
"new",
">>>>>>> REPLACE",
">>>>>>> REPLACE",
]
),
"`FILE`, `<<<<<<< SEARCH`, `EOF`",
1,
),
],
)
def test_file_patcher_reports_invalid_token_transition(
patch_memory_root: Path,
raw_output: str,
expected_text: str,
completed_blocks: int,
) -> None:
"""Invalid token transitions report editor-validation expectations."""
_, blocks, issues = FilePatcher(patch_memory_root)._parse(raw_output)
assert len(blocks) == completed_blocks
assert expected_text in issues[0]
assert "Editor validation stopped" in issues[0]
@pytest.mark.parametrize(
("payload_state", "offending_token", "expected_token"),
[
("search", "<<<<<<< SEARCH", "======="),
("search", ">>>>>>> REPLACE", "======="),
("replace", "<<<<<<< SEARCH", ">>>>>>> REPLACE"),
("replace", "=======", ">>>>>>> REPLACE"),
],
)
def test_file_patcher_aborts_on_reserved_token_out_of_order(
patch_memory_root: Path,
payload_state: str,
offending_token: str,
expected_token: str,
) -> None:
"""Verify an out-of-order reserved token discards the block and later output."""
target = _write(patch_memory_root / "src" / "mod.py", "value = 1\n")
payload = ["old", offending_token] if payload_state == "search" else ["new", offending_token]
lines = [
"FILE: src/mod.py",
"<<<<<<< SEARCH",
*payload[:1],
]
if payload_state == "search":
lines.append(offending_token)
else:
lines.extend(["=======", *payload[1:]])
lines.extend(
[
"=======" if payload_state == "search" else ">>>>>>> REPLACE",
"newer",
">>>>>>> REPLACE",
"FILE: src/mod.py",
"<<<<<<< SEARCH",
"value = 1",
"=======",
"value = 2",
">>>>>>> REPLACE",
]
)
with pytest.raises(QuickRetryError) as raised:
FilePatcher(patch_memory_root).apply(
"\n".join(lines),
editable_files=[target],
readonly_files=[],
)
issue = "\n".join(raised.value.issues)
assert raised.value.records == []
assert offending_token in issue
assert expected_token in issue
offending_line = 4 if payload_state == "search" else 5
assert f"line {offending_line}" in issue
assert "Editor validation stopped" in issue
assert "nested SEARCH/REPLACE blocks are not allowed" in issue
assert _read(target) == "value = 1\n"
def test_file_patcher_retains_completed_blocks_before_validation_failure(patch_memory_root: Path) -> None:
"""Only blocks completed before editor validation stops can apply."""
first = _write(patch_memory_root / "src" / "first.py", "first = 1\n")
second = _write(patch_memory_root / "src" / "second.py", "second = 1\n")
third = _write(patch_memory_root / "src" / "third.py", "third = 1\n")
output = "\n".join(
[
"FILE: src/first.py",
"<<<<<<< SEARCH",
"first = 1",
"=======",
"first = 2",
">>>>>>> REPLACE",
"FILE: src/second.py",
"<<<<<<< SEARCH",
"second = 1",
"<<<<<<< SEARCH",
"=======",
"second = 2",
">>>>>>> REPLACE",
"FILE: src/third.py",
"<<<<<<< SEARCH",
"third = 1",
"=======",
"third = 2",
">>>>>>> REPLACE",
]
)
with pytest.raises(QuickRetryError) as raised:
FilePatcher(patch_memory_root).apply(
output,
editable_files=[first, second, third],
readonly_files=[],
)
assert raised.value.changed_paths == [first]
assert _read(first) == "first = 2\n"
assert _read(second) == "second = 1\n"
assert _read(third) == "third = 1\n"
def test_file_patcher_accepts_marker_strings_inside_payload_lines(patch_memory_root: Path) -> None:
"""Verify only exact standalone protocol lines are structural."""
target = _write(
patch_memory_root / "src" / "mod.py",
'separator = "======="\n'
'text = "<<<<<<< SEARCH"\n'
'text = ">>>>>>> REPLACE"\n',
)
output = "\n".join(
[
"FILE: src/mod.py",
"<<<<<<< SEARCH",
'separator = "======="',
'text = "<<<<<<< SEARCH"',
'text = ">>>>>>> REPLACE"',
"=======",
'separator = "changed"',
'text = "clean"',
">>>>>>> REPLACE",
]
)
_edit_result = FilePatcher(patch_memory_root).apply(
output,
editable_files=[target],
readonly_files=[],
)
changed, _ = _edit_result.changed_paths, _edit_result.summary
assert changed == [target]
assert _read(target) == 'separator = "changed"\ntext = "clean"\n'
def test_ambiguous_search_atomically_returns_every_unique_patch_choice(
patch_memory_root: Path,
) -> None:
"""Every duplicate match becomes a choice while valid blocks remain unwritten."""
source = (
"header = 'old'\n\n"
"class Example:\n"
" def first(self):\n"
" result = build(\n"
" manager=self.manager,\n"
" )\n"
" return result\n\n"
" def second(self):\n"
" result = build(\n"
" manager=self.manager,\n"
" )\n"
" return result\n"
)
target = _write(patch_memory_root / "pkg" / "mod.py", source)
output = "\n".join(
[
"FILE: pkg/mod.py",
"<<<<<<< SEARCH",
"header = 'old'",
"=======",
"header = 'new'",
">>>>>>> REPLACE",
"FILE: pkg/mod.py",
"<<<<<<< SEARCH",
" result = build(",
" manager=self.manager,",
"=======",
" result = build(manager=self.manager)",
">>>>>>> REPLACE",
]
)
with pytest.raises(QuickRetryError) as raised:
FilePatcher(patch_memory_root, repo_exec=current_repo_exec()).apply(
output,
editable_files=[target],
readonly_files=[],
)
issue = "\n".join(raised.value.issues)
first_search = (
" def first(self):\n"
" result = build(\n"
" manager=self.manager,\n"
" )\n"
)
first_replace = (
" def first(self):\n"
" result = build(manager=self.manager)\n"
" )\n"
)
second_search = (
" def second(self):\n"
" result = build(\n"
" manager=self.manager,\n"
" )\n"
)
second_replace = (
" def second(self):\n"
" result = build(manager=self.manager)\n"
" )\n"
)
assert raised.value.changed_paths == []
assert _read_bytes(target) == source.encode()
assert "FILE: pkg/mod.py" in issue
assert "MATCH COUNT: 2" in issue
assert "SEARCH text appeared more than once in pkg/mod.py" in issue
assert issue.index("MATCH 1:") < issue.index("MATCH 2:")
assert first_search in issue
assert first_replace in issue
assert second_search in issue
assert second_replace in issue
assert source.count(first_search) == 1
assert source.count(second_search) == 1
assert "⋮" not in issue
def test_ambiguous_search_expands_identical_lines_until_each_choice_is_unique(
patch_memory_root: Path,
) -> None:
"""Literal line expansion distinguishes matches without language-specific parsing."""
source = (
"class Alpha:\n"
" def build(self):\n"
" return factory(manager=self.manager)\n\n"
"class Beta:\n"
" def build(self):\n"
" return factory(manager=self.manager)\n"
)
target = _write(patch_memory_root / "pkg" / "mod.py", source)
with pytest.raises(QuickRetryError) as raised:
FilePatcher(patch_memory_root, repo_exec=current_repo_exec()).apply(
"\n".join(
[
"FILE: pkg/mod.py",
"<<<<<<< SEARCH",
" return factory(manager=self.manager)",
"=======",
" return factory(manager=manager)",
">>>>>>> REPLACE",
]
),
editable_files=[target],
readonly_files=[],
)
issue = "\n".join(raised.value.issues)
assert "MATCH 1:\n\nFILE: pkg/mod.py\n<<<<<<< SEARCH" in issue
assert "MATCH 2:\n\nFILE: pkg/mod.py\n<<<<<<< SEARCH" in issue
assert "class Beta:\n def build(self):" in issue
assert source.count(
" def build(self):\n"
" return factory(manager=self.manager)\n\n"
) == 1
assert source.count(
"class Beta:\n"
" def build(self):\n"
" return factory(manager=self.manager)\n"
) == 1
def test_ambiguous_search_returns_all_choices_with_identical_anchors(
patch_memory_root: Path,
) -> None:
"""N duplicate matches produce N blocks whose added anchors remain unchanged."""
source = (
"first:\n"
" value = old\n"
"second:\n"
" value = old\n"
"third:\n"
" value = old\n"
)
target = _write(patch_memory_root / "notes.txt", source)
with pytest.raises(QuickRetryError) as raised:
FilePatcher(patch_memory_root, repo_exec=current_repo_exec()).apply(
"\n".join(
[
"FILE: notes.txt",
"<<<<<<< SEARCH",
" value = old",
"=======",
" value = new",
">>>>>>> REPLACE",
]
),
editable_files=[target],
readonly_files=[],
)
issue = "\n".join(raised.value.issues)
assert "MATCH COUNT: 3" in issue
assert issue.count("MATCH 1:") == 1
assert issue.count("MATCH 2:") == 1
assert issue.count("MATCH 3:") == 1
assert (
"first:\n value = old\nsecond:\n"
"=======\n"
"first:\n value = new\nsecond:"
) in issue
assert (
"second:\n value = old\nthird:\n"
"=======\n"
"second:\n value = new\nthird:"
) in issue
assert (
" value = old\nthird:\n value = old\n"
"=======\n"
" value = old\nthird:\n value = new"
) in issue
def test_ambiguity_expands_every_choice_on_one_shared_frontier(
patch_memory_root: Path,
) -> None:
"""A collision keeps every candidate on the same line-expansion frontier."""
source = (
"unique value = old tail\n"
"alpha\n"
"value = old\n"
"beta\n"
"value = old\n"
"gamma\n"
)
target = _write(patch_memory_root / "notes.txt", source)
with pytest.raises(QuickRetryError) as raised:
FilePatcher(patch_memory_root, repo_exec=current_repo_exec()).apply(
"\n".join(
[
"FILE: notes.txt",
"<<<<<<< SEARCH",
"value = old",
"=======",
"value = new",
">>>>>>> REPLACE",
]
),
editable_files=[target],
readonly_files=[],
)
issue = "\n".join(raised.value.issues)
assert (
"unique value = old tail\nalpha\nvalue = old\n"
"=======\n"
"unique value = new tail\nalpha\nvalue = old"
) in issue
assert "MATCH COUNT: 3" in issue
assert issue.count("MATCH 1:") == 1
assert issue.count("MATCH 2:") == 1
assert issue.count("MATCH 3:") == 1
def test_shared_frontier_uses_unique_whole_file_clause_at_identical_boundaries(
patch_memory_root: Path,
) -> None:
"""Repeated boundary lines terminate with applicable whole-file choices."""
source = "value = old\nvalue = old\n"
target = _write(patch_memory_root / "notes.txt", source)
ambiguous = "\n".join(
[
"FILE: notes.txt",
"<<<<<<< SEARCH",
"value = old",
"=======",
"value = new",
">>>>>>> REPLACE",
]
)
with pytest.raises(QuickRetryError) as raised:
FilePatcher(patch_memory_root, repo_exec=current_repo_exec()).apply(
ambiguous,
editable_files=[target],
readonly_files=[],
)
issue = "\n".join(raised.value.issues)
second_choice = issue.partition("MATCH 2:\n\n")[2].partition(
"\n\nSelect the match required"
)[0]
_edit_result = FilePatcher(patch_memory_root, repo_exec=current_repo_exec()).apply(
second_choice,
editable_files=[target],
readonly_files=[],
)
changed, _ = _edit_result.changed_paths, _edit_result.summary
assert changed == [target]
assert _read(target) == "value = old\nvalue = new\n"
def test_unique_context_selected_after_ambiguity_changes_only_intended_line(
patch_memory_root: Path,
) -> None:
"""Expanded unchanged context resumes the ordinary unique replacement path."""
source = (
"class Example:\n"
" def first(self):\n"
" return factory(manager=self.manager)\n\n"
" def second(self):\n"
" return factory(manager=self.manager)\n"
)
target = _write(patch_memory_root / "pkg" / "mod.py", source)
search = (
" def second(self):\n"
" return factory(manager=self.manager)"
)
replace = (
" def second(self):\n"
" return factory(manager=manager)"
)
_edit_result = FilePatcher(patch_memory_root, repo_exec=current_repo_exec()).apply(
"\n".join(
[
"FILE: pkg/mod.py",
"<<<<<<< SEARCH",
search,
"=======",
replace,
">>>>>>> REPLACE",
]
),
editable_files=[target],
readonly_files=[],
)
changed, _ = _edit_result.changed_paths, _edit_result.summary
assert changed == [target]
assert _read(target) == source.replace(
" def second(self):\n return factory(manager=self.manager)",
" def second(self):\n return factory(manager=manager)",
1,
)
def test_expanded_ambiguity_choice_is_an_applicable_minimal_patch(
patch_memory_root: Path,
) -> None:
"""A selected choice preserves its anchors and changes only its original payload."""
source = (
"first:\n"
" value = old\n"
"second:\n"
" value = old\n"
)
target = _write(patch_memory_root / "notes.txt", source)
ambiguous = "\n".join(
[
"FILE: notes.txt",
"<<<<<<< SEARCH",
" value = old",
"=======",
" value = new",
">>>>>>> REPLACE",
]
)
with pytest.raises(QuickRetryError) as raised:
FilePatcher(patch_memory_root, repo_exec=current_repo_exec()).apply(
ambiguous,
editable_files=[target],
readonly_files=[],
)
issue = "\n".join(raised.value.issues)
second_choice = issue.partition("MATCH 2:\n\n")[2].partition(
"\n\nSelect the match required"
)[0]
_edit_result = FilePatcher(patch_memory_root, repo_exec=current_repo_exec()).apply(
second_choice,
editable_files=[target],
readonly_files=[],
)
changed, _ = _edit_result.changed_paths, _edit_result.summary
assert changed == [target]
assert _read(target) == (
"first:\n"
" value = old\n"
"second:\n"
" value = new\n"
)
def test_file_patcher_owns_ordinary_retry_feedback(patch_memory_root: Path) -> None:
"""Ordinary patch failure evidence is complete at the patcher boundary."""
target = _write(patch_memory_root / "pkg" / "mod.py", "value = 1\n")
raw_output = "\n".join(
[
"FILE: pkg/mod.py",
"<<<<<<< SEARCH",
"value = 9",
"=======",
"value = 2",
">>>>>>> REPLACE",
]
)
with pytest.raises(QuickRetryError) as raised:
FilePatcher(patch_memory_root, repo_exec=current_repo_exec()).apply(
raw_output,
editable_files=[target],
readonly_files=[],
)
assert raised.value.decision_reason == "patch application left no candidate changes"
assert "Patch application stage: PATCH APPLICATION DID NOT APPLY" in (
raised.value.retry_feedback
)
assert "SEARCH text was not found exactly once in pkg/mod.py" in (
raised.value.retry_feedback
)
assert f"Previous model patch output:\n{raw_output}" in raised.value.retry_feedback
def test_file_patcher_owns_ambiguity_retry_feedback(patch_memory_root: Path) -> None:
"""Ambiguity classification and its self-contained evidence stay in FilePatcher."""
target = _write(
patch_memory_root / "pkg" / "mod.py",
"def first():\n return value\n\ndef second():\n return value\n",
)
raw_output = "\n".join(
[
"FILE: pkg/mod.py",
"<<<<<<< SEARCH",
" return value",
"=======",
" return fixed",
">>>>>>> REPLACE",
]
)
with pytest.raises(QuickRetryError) as raised:
FilePatcher(patch_memory_root, repo_exec=current_repo_exec()).apply(
raw_output,
editable_files=[target],
readonly_files=[],
)
assert raised.value.decision_reason == (
"SEARCH payload is ambiguous and no candidate changes exist"
)
assert raised.value.retry_feedback == "\n".join(raised.value.issues)
assert "SEARCH AMBIGUITY" in raised.value.retry_feedback
assert "Previous model patch output" not in raised.value.retry_feedback
def test_prospective_ambiguity_rejects_the_complete_response(patch_memory_root: Path) -> None:
"""A prior prospective edit cannot turn later ambiguity into a partial write."""
source = "first = value\nsecond = other\n"
target = _write(patch_memory_root / "pkg" / "mod.py", source)
raw_output = "\n".join(
[
"FILE: pkg/mod.py",
"<<<<<<< SEARCH",
"second = other",
"=======",
"first = value",
">>>>>>> REPLACE",
"<<<<<<< SEARCH",
"first = value",
"=======",
"first = fixed",
">>>>>>> REPLACE",
]
)
with pytest.raises(QuickRetryError) as raised:
FilePatcher(patch_memory_root, repo_exec=current_repo_exec()).apply(
raw_output,
editable_files=[target],
readonly_files=[],
)
assert raised.value.changed_paths == []
assert "MATCH COUNT: 2" in raised.value.retry_feedback
assert _read(target) == source