1459 lines
48 KiB
Python
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
|