ouroboros/tests/test_edit_ops.py

817 lines
30 KiB
Python

"""Tests for ouroboros.tools.edit_ops (apply_patch / edit_batch)."""
from __future__ import annotations
import pathlib
import sys
import pytest
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))
from ouroboros.tools import edit_ops
from ouroboros.tools.edit_ops import (
_apply_hunks_to_text,
_find_sequence,
_parse_patch,
_syntax_check,
)
SAMPLE = "\n".join([
"def ddd(x):",
" return x * 3",
"",
"",
"def other(x):",
" return ddd(x)",
"",
"def caller():",
" return ddd(1) + ddd(2)",
"",
])
# ---------------------------------------------------------------------------
# parser
# ---------------------------------------------------------------------------
def test_parse_patch_update_add_delete():
ops, err = _parse_patch(
"*** Begin Patch\n"
"*** Update File: a.py\n"
"@@ def ddd\n"
" def ddd(x):\n"
"- return x * 3\n"
"+ return x * 4\n"
"*** Add File: b.py\n"
"+print('hi')\n"
"*** Delete File: c.py\n"
"*** End Patch\n"
)
assert err == ""
assert [op.kind for op in ops] == ["update", "add", "delete"]
assert ops[0].path == "a.py"
assert ops[0].hunks[0].anchor == "def ddd"
assert ops[1].add_lines == ["print('hi')"]
def test_parse_patch_tolerates_decorative_asterisks():
ops, err = _parse_patch(
"*** Begin Patch ***\n"
"*** Update File: a.py ***\n"
"-old\n"
"+new\n"
"*** End Patch ***\n"
)
assert err == ""
assert ops[0].path == "a.py"
def test_parse_patch_envelope_optional():
ops, err = _parse_patch(
"*** Update File: a.py\n"
" context\n"
"-old\n"
"+new\n"
)
assert err == ""
assert len(ops) == 1
def test_parse_patch_rejects_stray_content():
_, err = _parse_patch("hello\n*** Update File: a.py\n-x\n+y\n")
assert "before the first file header" in err
def test_parse_patch_rejects_bad_add_body():
_, err = _parse_patch("*** Add File: a.py\nno-plus-prefix\n")
assert "must start with '+'" in err
def test_parse_patch_rejects_empty():
_, err = _parse_patch("*** Begin Patch\n*** End Patch\n")
assert "no file operations" in err
# ---------------------------------------------------------------------------
# hunk matching / application
# ---------------------------------------------------------------------------
def test_apply_single_hunk():
ops, err = _parse_patch(
"*** Update File: s.py\n"
" def ddd(x):\n"
"- return x * 3\n"
"+ return x * 30\n"
)
assert err == ""
new, notes, herr = _apply_hunks_to_text(SAMPLE, ops[0].hunks, "s.py")
assert herr == ""
assert "x * 30" in new
assert notes == []
def test_ambiguous_context_errors():
content = "a\nb\na\nb\n"
ops, _ = _parse_patch("*** Update File: s.py\n a\n-b\n+B\n")
new, _, herr = _apply_hunks_to_text(content, ops[0].hunks, "s.py")
assert new is None
assert "ambiguous" in herr
def test_anchor_disambiguates():
content = "def one():\n x = 1\n\ndef two():\n x = 1\n"
ops, _ = _parse_patch(
"*** Update File: s.py\n"
"@@ def two\n"
"- x = 1\n"
"+ x = 2\n"
)
new, _, herr = _apply_hunks_to_text(content, ops[0].hunks, "s.py")
assert herr == ""
assert new == "def one():\n x = 1\n\ndef two():\n x = 2\n"
def test_context_not_found_reports_lines():
ops, _ = _parse_patch("*** Update File: s.py\n-does not exist\n+x\n")
new, _, herr = _apply_hunks_to_text(SAMPLE, ops[0].hunks, "s.py")
assert new is None
assert "context not found" in herr
def test_fuzzy_trailing_whitespace_match():
content = "line one \nline two\n"
ops, _ = _parse_patch("*** Update File: s.py\n-line one\n+line ONE\n")
new, notes, herr = _apply_hunks_to_text(content, ops[0].hunks, "s.py")
assert herr == ""
assert new.startswith("line ONE")
assert any("whitespace" in n for n in notes)
def test_pure_insertion_requires_anchor():
ops, _ = _parse_patch("*** Update File: s.py\n+new line\n")
new, _, herr = _apply_hunks_to_text(SAMPLE, ops[0].hunks, "s.py")
assert new is None
assert "anchor" in herr
def test_pure_insertion_with_anchor():
ops, _ = _parse_patch("*** Update File: s.py\n@@ def other\n+ # inserted\n")
new, _, herr = _apply_hunks_to_text(SAMPLE, ops[0].hunks, "s.py")
assert herr == ""
assert "def other(x):\n # inserted\n return ddd(x)" in new
def test_sequential_hunks_advance_cursor():
ops, _ = _parse_patch(
"*** Update File: s.py\n"
"- return ddd(x)\n"
"+ return aaa(x)\n"
"@@ def caller\n"
"- return ddd(1) + ddd(2)\n"
"+ return aaa(1) + aaa(2)\n"
)
new, _, herr = _apply_hunks_to_text(SAMPLE, ops[0].hunks, "s.py")
assert herr == ""
assert "aaa(x)" in new and "aaa(1) + aaa(2)" in new
assert "return ddd" not in new
assert "def ddd(x):" in new # the def line was not part of either hunk
def test_find_sequence_caps_matches():
lines = ["x"] * 20
assert len(_find_sequence(lines, ["x"], 0, fuzzy=False)) == 5
# ---------------------------------------------------------------------------
# shared verification helpers
# ---------------------------------------------------------------------------
def test_syntax_check():
assert _syntax_check("x.py", "def f(:\n") != ""
assert _syntax_check("x.py", "def f():\n return 1\n") == ""
assert _syntax_check("x.json", "{bad") != ""
assert _syntax_check("x.json", '{"ok": 1}') == ""
assert _syntax_check("x.txt", "anything") == ""
# ---------------------------------------------------------------------------
# end-to-end handler tests on a fake workspace ctx
# ---------------------------------------------------------------------------
class _FakeCtx:
def __init__(self, repo: pathlib.Path):
self._repo = repo
self.repo_dir = repo
self.drive_root = repo / ".drive"
self.task_metadata = {}
self.event_queue = None
self.pending_events = []
self.task_id = "test-task"
def is_workspace_mode(self):
return True
def repo_path(self, rel):
p = (self._repo / rel).resolve()
if not str(p).startswith(str(self._repo.resolve())):
raise ValueError(f"path escapes workspace: {rel}")
return p
@pytest.fixture()
def ws(tmp_path, monkeypatch):
repo = tmp_path / "ws"
repo.mkdir()
(repo / "s.py").write_text(SAMPLE, encoding="utf-8")
ctx = _FakeCtx(repo)
# Route guard helpers around ToolContext specifics: keep the real access
# logic out of scope — these tests exercise edit mechanics.
monkeypatch.setattr(edit_ops, "_resolve_edit_target", _fake_resolver(ctx))
monkeypatch.setattr(
edit_ops,
"_finish_mutation",
lambda ctx_, paths, tool, binding=None: "NOT committed.",
)
return ctx
def _fake_resolver(ctx):
from ouroboros.utils import safe_relpath
def resolver(_ctx, path, _root, *, error_tag, _resolved_binding=None):
if not path:
return None, "", None, f"⚠️ {error_tag}: path is required."
try:
return ctx.repo_path(path), safe_relpath(path), _resolved_binding, ""
except ValueError as e:
return None, "", None, f"⚠️ PATH_ERROR: {e}"
return resolver
def test_apply_patch_end_to_end(ws):
result = edit_ops._apply_patch(
ws,
"*** Begin Patch\n"
"*** Update File: s.py\n"
"-def ddd(x):\n"
"+def aaa(x):\n"
"@@ def other\n"
"- return ddd(x)\n"
"+ return aaa(x)\n"
"*** Add File: extra.py\n"
"+VALUE = 1\n"
"*** End Patch\n",
)
assert result.startswith("✅")
text = (ws.repo_dir / "s.py").read_text()
assert "def aaa(x):" in text and "return aaa(x)" in text
assert (ws.repo_dir / "extra.py").read_text() == "VALUE = 1\n"
def test_apply_patch_atomic_on_bad_hunk(ws):
before = (ws.repo_dir / "s.py").read_text()
result = edit_ops._apply_patch(
ws,
"*** Update File: s.py\n"
"-def ddd(x):\n"
"+def aaa(x):\n"
"@@ def nowhere\n"
"-missing\n"
"+present\n",
)
assert "APPLY_PATCH_ERROR" in result
assert (ws.repo_dir / "s.py").read_text() == before
def test_apply_patch_add_existing_fails(ws):
result = edit_ops._apply_patch(ws, "*** Add File: s.py\n+x\n")
assert "already exists" in result
def test_apply_patch_delete(ws):
(ws.repo_dir / "gone.py").write_text("x = 1\n")
result = edit_ops._apply_patch(ws, "*** Delete File: gone.py\n")
assert result.startswith("✅")
assert not (ws.repo_dir / "gone.py").exists()
def test_edit_batch_counted_replace(ws):
result = edit_ops._edit_batch(
ws,
[
{"path": "s.py", "old_str": "ddd(", "new_str": "aaa(", "count": 4},
],
)
assert result.startswith("✅")
text = (ws.repo_dir / "s.py").read_text()
assert "ddd(" not in text
assert text.count("aaa(") == 4
def test_edit_batch_count_mismatch_is_atomic(ws):
before = (ws.repo_dir / "s.py").read_text()
result = edit_ops._edit_batch(
ws,
[
{"path": "s.py", "old_str": "def other", "new_str": "def another", "count": 1},
{"path": "s.py", "old_str": "ddd(", "new_str": "aaa(", "count": 2}, # actually 4
],
)
assert "EDIT_BATCH_ERROR" in result
assert "occurs 4 time(s), expected 2" in result
assert (ws.repo_dir / "s.py").read_text() == before
def test_edit_batch_sequential_edits_see_prior_results(ws):
result = edit_ops._edit_batch(
ws,
[
{"path": "s.py", "old_str": "def ddd(x):", "new_str": "def aaa(x):", "count": 1},
{"path": "s.py", "old_str": "def aaa(x):", "new_str": "def aaa(value):", "count": 1},
],
)
assert result.startswith("✅")
assert "def aaa(value):" in (ws.repo_dir / "s.py").read_text()
def test_registry_registration():
names = {e.name for e in edit_ops.get_tools()}
assert names == {"apply_patch", "edit_batch"}
for entry in edit_ops.get_tools():
assert entry.is_code_tool
assert entry.mutates_worktree
# ---------------------------------------------------------------------------
# write_file rails (git._repo_write): syntax guard + overwrite diff
# ---------------------------------------------------------------------------
def _ws_ctx(tmp_path):
import subprocess
from ouroboros.tools.registry import ToolContext
ws = tmp_path / "extws"
ws.mkdir()
subprocess.run(["git", "init", "-q"], cwd=ws, check=True)
(ws / "mod.py").write_text("def f():\n return 1\n", encoding="utf-8")
subprocess.run(["git", "add", "-A"], cwd=ws, check=True)
subprocess.run(["git", "-c", "user.email=t@t", "-c", "user.name=t",
"commit", "-q", "-m", "seed"], cwd=ws, check=True)
drive = tmp_path / "drive"
drive.mkdir()
return ToolContext(repo_dir=tmp_path / "repo", drive_root=drive,
workspace_root=str(ws), workspace_mode="external"), ws
def test_repo_write_blocks_broken_python(tmp_path):
from ouroboros.tools.git import _repo_write
ctx, ws = _ws_ctx(tmp_path)
before = (ws / "mod.py").read_text()
out = _repo_write(ctx, path="mod.py", content="def f(:\n broken\n")
assert "WRITE_BLOCKED_SYNTAX" in out
assert (ws / "mod.py").read_text() == before
def test_repo_write_force_bypasses_syntax_guard(tmp_path):
from ouroboros.tools.git import _repo_write
ctx, ws = _ws_ctx(tmp_path)
out = _repo_write(ctx, path="broken_fixture.py", content="def f(:\n", force=True)
assert out.startswith("✅")
assert (ws / "broken_fixture.py").exists()
def test_repo_write_overwrite_appends_diff(tmp_path):
from ouroboros.tools.git import _repo_write
ctx, ws = _ws_ctx(tmp_path)
out = _repo_write(ctx, path="mod.py", content="def f():\n return 2\n")
assert out.startswith("✅")
assert "Diff vs the previous version" in out
assert "- return 1" in out and "+ return 2" in out
def test_repo_write_new_file_has_no_diff_section(tmp_path):
from ouroboros.tools.git import _repo_write
ctx, ws = _ws_ctx(tmp_path)
out = _repo_write(ctx, path="fresh.py", content="X = 1\n")
assert out.startswith("✅")
assert "Diff vs the previous version" not in out
# ---------------------------------------------------------------------------
# governance rails: envelopes, advisory staleness (P3), force disclosure
# ---------------------------------------------------------------------------
def test_capability_profiles_pin_new_tools():
# Write-capable lanes see the tools; the read-only subagent lane stays
# write-free. Repair is ordinary development, not a reduced profile.
from ouroboros.tool_capabilities import (
ACTING_SUBAGENT_TOOL_NAMES,
CORE_TOOL_NAMES,
LOCAL_READONLY_SUBAGENT_TOOL_NAMES,
)
for name in ("apply_patch", "edit_batch"):
assert name in CORE_TOOL_NAMES
assert name in ACTING_SUBAGENT_TOOL_NAMES
assert name not in LOCAL_READONLY_SUBAGENT_TOOL_NAMES
def test_tool_policy_and_smoke_registration():
from ouroboros.safety import TOOL_POLICY
assert TOOL_POLICY["apply_patch"] == TOOL_POLICY["edit_text"]
assert TOOL_POLICY["edit_batch"] == TOOL_POLICY["edit_text"]
def test_mutations_invalidate_advisory(tmp_path, monkeypatch):
# P3: every worktree-mutating tool must mark the advisory snapshot stale.
calls = []
from ouroboros.tools import commit_gate
monkeypatch.setattr(
commit_gate, "_invalidate_advisory",
lambda ctx, **kw: calls.append(kw.get("source_tool")),
)
repo = tmp_path / "ws"
repo.mkdir()
(repo / "s.py").write_text(SAMPLE, encoding="utf-8")
ctx = _FakeCtx(repo)
monkeypatch.setattr(edit_ops, "_resolve_edit_target", _fake_resolver(ctx))
out = edit_ops._apply_patch(ctx, "*** Update File: s.py\n-def ddd(x):\n+def aaa(x):\n")
assert out.startswith("✅")
out = edit_ops._edit_batch(ctx, [{"path": "s.py", "old_str": "aaa", "new_str": "bbb", "count": 1}])
assert out.startswith("✅")
assert calls == ["apply_patch", "edit_batch"]
def test_repo_write_force_bypass_is_disclosed(tmp_path):
# P3: silent bypass is forbidden — a forced write of invalid content names it.
from ouroboros.tools.git import _repo_write
ctx, ws = _ws_ctx(tmp_path)
out = _repo_write(ctx, path="fixture_broken.py", content="def f(:\n", force=True)
assert out.startswith("✅")
assert "SYNTAX_GUARD_BYPASSED" in out
# ---------------------------------------------------------------------------
# guard parity with edit_text (the fences these tools must NOT be weaker than)
# ---------------------------------------------------------------------------
def _guard_registry(tmp_path):
"""A registry over a throwaway repo, so guards run for real (no fake resolver)."""
import subprocess
from ouroboros.tools.registry import ToolRegistry
repo = tmp_path / "repo"
(repo / "ouroboros").mkdir(parents=True)
subprocess.run(["git", "init", "-q"], cwd=repo, check=True)
drive = tmp_path / "drive"
drive.mkdir()
return ToolRegistry(repo_dir=repo, drive_root=drive), repo
def _protected_call(tool, path):
if tool == "edit_text":
return {"path": path, "old_str": "P1 honest", "new_str": "P1 loose"}
if tool == "apply_patch":
return {"patch": f"*** Update File: {path}\n-P1 honest\n+P1 loose\n"}
return {"edits": [{"path": path, "old_str": "P1 honest", "new_str": "P1 loose"}]}
@pytest.mark.parametrize("tool", ["edit_text", "apply_patch", "edit_batch"])
@pytest.mark.parametrize("spelling", ["canonical", "absolute", "root_basename"])
def test_protected_path_blocked_in_every_spelling(tmp_path, tool, spelling):
"""A protected path is refused however it is SPELLED.
``ctx.repo_path`` collapses an absolute-inside-root path and a redundant
root-basename prefix onto the same file, so a guard reading the raw spelling
would pass `repo/BIBLE.md` straight through to a write on `BIBLE.md`.
edit_text is canonicalized at dispatch; apply_patch/edit_batch carry their
paths inside the payload and must canonicalize before their own guards.
"""
reg, repo = _guard_registry(tmp_path)
spellings = {
"canonical": "BIBLE.md",
"absolute": str(repo / "BIBLE.md"),
"root_basename": "repo/BIBLE.md",
}
(repo / "BIBLE.md").write_text("P1 honest\n", encoding="utf-8")
result = str(reg.execute(tool, _protected_call(tool, spellings[spelling])))
assert "BLOCKED" in result, result[:200]
assert (repo / "BIBLE.md").read_text() == "P1 honest\n"
def _workspace_guard_registry(tmp_path, monkeypatch):
import subprocess
import ouroboros.safety as safety
from ouroboros.tools.registry import ToolContext, ToolRegistry
system = tmp_path / "system"
project = tmp_path / "project"
drive = tmp_path / "drive"
for path in (system, project, drive):
path.mkdir()
for repo in (system, project):
subprocess.run(["git", "init", "-q"], cwd=repo, check=True)
ctx = ToolContext(
repo_dir=system,
system_repo_dir=system,
drive_root=drive,
workspace_root=project,
workspace_mode="external",
)
registry = ToolRegistry(repo_dir=system, drive_root=drive)
registry.set_context(ctx)
monkeypatch.setattr(safety, "check_safety", lambda *args, **kwargs: (True, ""))
return registry, ctx, system, project
@pytest.mark.parametrize("tool", ["apply_patch", "edit_batch"])
def test_repo_batch_tool_explicit_system_binding_mutates_only_system_and_invalidates_it(
tmp_path, monkeypatch, tool,
):
from ouroboros.tools import commit_gate
registry, _ctx, system, project = _workspace_guard_registry(tmp_path, monkeypatch)
(system / "mod.py").write_text("VALUE = 1\n", encoding="utf-8")
(project / "mod.py").write_text("VALUE = 9\n", encoding="utf-8")
invalidations = []
monkeypatch.setattr(
commit_gate,
"_invalidate_advisory",
lambda _ctx, **kwargs: invalidations.append(kwargs),
)
args = {
"apply_patch": {
"root": "system_repo",
"patch": "*** Update File: mod.py\n-VALUE = 1\n+VALUE = 2\n",
},
"edit_batch": {
"root": "system_repo",
"edits": [{"path": "mod.py", "old_str": "VALUE = 1", "new_str": "VALUE = 2"}],
},
}[tool]
result = registry.execute(tool, args)
assert result.startswith("✅"), result
assert "Run commit_reviewed" in result
assert "headless runner" not in result
assert (system / "mod.py").read_text(encoding="utf-8") == "VALUE = 2\n"
assert (project / "mod.py").read_text(encoding="utf-8") == "VALUE = 9\n"
assert invalidations
assert pathlib.Path(invalidations[-1]["mutation_root"]).resolve() == system.resolve()
@pytest.mark.parametrize("tool", ["apply_patch", "edit_batch"])
def test_repo_batch_tool_protected_name_depends_on_physical_target(
tmp_path, monkeypatch, tool,
):
from ouroboros import config
registry, _ctx, system, project = _workspace_guard_registry(tmp_path, monkeypatch)
(system / "BIBLE.md").write_text("SYSTEM = 1\n", encoding="utf-8")
(project / "BIBLE.md").write_text("PROJECT = 1\n", encoding="utf-8")
monkeypatch.setattr(config, "get_runtime_mode", lambda: "light")
monkeypatch.setattr(edit_ops, "get_runtime_mode", lambda: "light")
project_args = {
"apply_patch": {
"patch": "*** Update File: BIBLE.md\n-PROJECT = 1\n+PROJECT = 2\n",
},
"edit_batch": {
"edits": [{"path": "BIBLE.md", "old_str": "PROJECT = 1", "new_str": "PROJECT = 2"}],
},
}[tool]
system_args = {
"apply_patch": {
"root": "system_repo",
"patch": "*** Update File: BIBLE.md\n-SYSTEM = 1\n+SYSTEM = 2\n",
},
"edit_batch": {
"root": "system_repo",
"edits": [{"path": "BIBLE.md", "old_str": "SYSTEM = 1", "new_str": "SYSTEM = 2"}],
},
}[tool]
project_result = registry.execute(tool, project_args)
system_result = registry.execute(tool, system_args)
assert project_result.startswith("✅"), project_result
assert "LIGHT_MODE_BLOCKED" in system_result or "CORE_PROTECTION_BLOCKED" in system_result
assert (project / "BIBLE.md").read_text(encoding="utf-8") == "PROJECT = 2\n"
assert (system / "BIBLE.md").read_text(encoding="utf-8") == "SYSTEM = 1\n"
@pytest.mark.parametrize("tool", ["write_file", "apply_patch", "edit_batch"])
def test_acting_subagent_without_workspace_cannot_touch_the_live_repo(tmp_path, tool):
"""An acting child with no isolated workspace must not reach the live repo.
active_workspace falls back to the LIVE repo for such a child, which is why
the fence exists; a new repo-lane write tool that misses it is a weaker lane,
not a new capability.
"""
from ouroboros.contracts.task_constraint import TaskConstraint
reg, repo = _guard_registry(tmp_path)
(repo / "mod.py").write_text("VALUE = 1\n", encoding="utf-8")
reg._is_acting_subagent = lambda: True
reg._ctx.task_constraint = TaskConstraint(mode="acting")
args = {
"write_file": {"path": "mod.py", "content": "VALUE = 2\n"},
"apply_patch": {"patch": "*** Update File: mod.py\n-VALUE = 1\n+VALUE = 2\n"},
"edit_batch": {"edits": [{"path": "mod.py", "old_str": "VALUE = 1", "new_str": "VALUE = 2"}]},
}[tool]
result = str(reg.execute(tool, args))
assert "ACTING_NO_WORKSPACE_BLOCKED" in result, result[:200]
assert (repo / "mod.py").read_text() == "VALUE = 1\n"
def test_acting_subagent_schema_narrows_root_for_every_repo_write_tool():
from ouroboros.tools.registry import _ROOT_ARG_REPO_WRITE_TOOLS
assert {"write_file", "edit_text", "apply_patch", "edit_batch"} == set(_ROOT_ARG_REPO_WRITE_TOOLS)
def test_patch_target_paths_come_from_the_real_parser():
from ouroboros.tools.edit_ops import patch_target_paths
assert patch_target_paths(
"*** Begin Patch\n*** Update File: a.py\n-x\n+y\n*** Add File: b.py\n+z\n"
"*** Delete File: c.py\n*** End Patch\n"
) == ["a.py", "b.py", "c.py"]
# An unparseable patch yields no targets; the handler refuses it before any write.
assert patch_target_paths("garbage without a header") == []
# ---------------------------------------------------------------------------
# content fidelity of the overwrite-verification rail
# ---------------------------------------------------------------------------
def test_unified_diff_reports_a_final_newline_change():
# The rail exists so the agent can VERIFY an overwrite: claiming "no textual
# changes" for a file whose bytes changed is the one unacceptable answer.
out = edit_ops._unified_diff("f.txt", "value\n", "value")
assert "No newline at end of file" in out
assert out != "(no textual changes)"
assert edit_ops._unified_diff("f.txt", "value", "value\n").startswith("\\ Newline added")
assert edit_ops._unified_diff("f.txt", "same\n", "same\n") == "(no textual changes)"
def test_syntax_check_names_the_format_it_checked():
# compile() raises a bare ValueError on a NUL byte; calling that "not valid
# JSON" for a .py file sends the fix in the wrong direction.
message = edit_ops._syntax_check("mod.py", "x = 1\x00\n")
assert message and "JSON" not in message
assert "Python" in message
# ---------------------------------------------------------------------------
# partial-write disclosure (validation is atomic; the write phase is not)
# ---------------------------------------------------------------------------
def test_partial_write_marks_advisory_stale_and_says_so(tmp_path, monkeypatch):
"""Files written before an I/O failure are real mutations.
If the advisory snapshot stayed fresh, commit_reviewed would accept them
against a pre-review taken before they existed.
"""
from ouroboros.tools import commit_gate
invalidated = []
monkeypatch.setattr(
commit_gate, "_invalidate_advisory",
lambda ctx, **kw: invalidated.append(tuple(kw.get("changed_paths") or ())),
)
repo = tmp_path / "ws"
repo.mkdir()
(repo / "one.py").write_text("A = 1\n", encoding="utf-8")
(repo / "two.py").write_text("B = 1\n", encoding="utf-8")
ctx = _FakeCtx(repo)
monkeypatch.setattr(edit_ops, "_resolve_edit_target", _fake_resolver(ctx))
real_write = edit_ops.write_text
def flaky(target, content):
if target.name == "two.py":
raise OSError("disk full")
return real_write(target, content)
monkeypatch.setattr(edit_ops, "write_text", flaky)
result = edit_ops._apply_patch(
ctx,
"*** Update File: one.py\n-A = 1\n+A = 2\n*** Update File: two.py\n-B = 1\n+B = 2\n",
)
assert "EDIT_OPS_PARTIAL_WRITE_FAILED" in result and "PARTIALLY APPLIED" in result and "one.py" in result
assert (repo / "one.py").read_text() == "A = 2\n"
assert invalidated == [("one.py",)]
def test_edit_ops_refusals_are_policy_denials_not_execution_failures():
"""A counted/context refusal is the DESIGNED path, not a broken executor.
Untyped, these fall through to the generic `error` status and degrade
execution health with a false tool_failure headline — the exact regression
v6.57.0 removed for the other write tools.
"""
from ouroboros.loop_tool_execution import _extract_result_metadata
from ouroboros.outcomes import _POLICY_DENIAL_STATUSES
for text in (
"⚠️ APPLY_PATCH_ERROR: hunk 1: context not found in m.py (searched from line 1).",
"⚠️ EDIT_BATCH_ERROR: batch aborted, NOTHING was written (atomic).",
):
status = _extract_result_metadata("t", text, False)["status"]
assert status == "edit_ops_blocked"
assert status in _POLICY_DENIAL_STATUSES
def test_one_file_under_two_spellings_is_one_target(tmp_path):
"""Two spellings of one file in a single call must not race each other.
``ctx.repo_path`` maps them to the same file, so keying the plan by the RAW
spelling produced two buffers, two writes, and a last-write-wins that
silently dropped the first edit while reporting both as applied.
"""
reg, repo = _guard_registry(tmp_path)
(repo / "f.txt").write_text("A = 1\nB = 1\n", encoding="utf-8")
result = str(reg.execute("edit_batch", {"edits": [
{"path": "f.txt", "old_str": "A = 1", "new_str": "A = 2"},
{"path": "repo/f.txt", "old_str": "B = 1", "new_str": "B = 2"},
]}))
assert result.startswith("✅"), result[:200]
assert (repo / "f.txt").read_text() == "A = 2\nB = 2\n"
assert "across 1 file" in result, result[:200]
(repo / "g.txt").write_text("X = 1\nY = 1\n", encoding="utf-8")
result = str(reg.execute("apply_patch", {"patch":
"*** Update File: g.txt\n-X = 1\n+X = 2\n"
f"*** Update File: {repo / 'g.txt'}\n-Y = 1\n+Y = 2\n"
}))
assert result.startswith("✅"), result[:200]
assert (repo / "g.txt").read_text() == "X = 2\nY = 2\n"
def test_partial_write_is_an_execution_failure_not_a_policy_denial():
"""A validation refusal is harmless telemetry; a half-applied write is not."""
from ouroboros.loop_tool_execution import _extract_result_metadata
from ouroboros.outcomes import _POLICY_DENIAL_STATUSES
partial = (
"⚠️ EDIT_OPS_PARTIAL_WRITE_FAILED (APPLY_PATCH_ERROR): write failed for b.py: disk full\n"
"PARTIALLY APPLIED — these files WERE written: a.py."
)
status = _extract_result_metadata("t", partial, False)["status"]
assert status not in _POLICY_DENIAL_STATUSES
assert status == "error"
@pytest.mark.parametrize("tool", ["write_file", "apply_patch", "edit_batch"])
def test_pro_mode_protected_edit_announces_itself(tmp_path, monkeypatch, tool):
"""A pro-mode protected write is ALLOWED; the notice is what keeps it visible.
git._repo_write and _str_replace_editor both append it, so a repo-write tool
that stays silent makes a protected edit look like an ordinary one.
"""
monkeypatch.setenv("OUROBOROS_RUNTIME_MODE", "pro")
from ouroboros import config
monkeypatch.setattr(config, "get_runtime_mode", lambda: "pro")
reg, repo = _guard_registry(tmp_path)
(repo / "BIBLE.md").write_text("P1 honest\n", encoding="utf-8")
args = {
"write_file": {"path": "BIBLE.md", "content": "P1 honest v2\n"},
"apply_patch": {"patch": "*** Update File: BIBLE.md\n-P1 honest\n+P1 honest v2\n"},
"edit_batch": {"edits": [{"path": "BIBLE.md", "old_str": "P1 honest", "new_str": "P1 honest v2"}]},
}[tool]
result = str(reg.execute(tool, args))
assert result.startswith("✅"), result[:200]
assert "CORE_PATCH_NOTICE" in result, result[:300]
def test_managed_update_resolver_keeps_its_exemption(tmp_path, monkeypatch):
"""The assisted resolver edits whatever official file the merge conflicts on.
git._repo_write carries this exemption; withholding it here would make these
tools the one lane that cannot finish a conflict resolution.
"""
from ouroboros.tools import registry as registry_mod
from ouroboros.tools import registry_guards
reg, repo = _guard_registry(tmp_path)
(repo / "BIBLE.md").write_text("P1 honest\n", encoding="utf-8")
monkeypatch.setattr(registry_guards, "_authorized_managed_update_resolver", lambda ctx: True)
monkeypatch.setattr(registry_mod, "_authorized_managed_update_resolver", lambda ctx: True)
result = str(reg.execute("apply_patch", {
"patch": "*** Update File: BIBLE.md\n-P1 honest\n+P1 resolved\n",
}))
assert result.startswith("✅"), result[:200]
assert (repo / "BIBLE.md").read_text() == "P1 resolved\n"