2026-07-03 15:12:31 +00:00
|
|
|
"""
|
|
|
|
|
Tests for scripts/patch_applier.py — the deterministic file-patch applier.
|
|
|
|
|
|
|
|
|
|
Safety net covering:
|
|
|
|
|
- each kind's transformation on a fixture
|
|
|
|
|
- multi-entry-one-file descending-order correctness (two delete-ranges)
|
|
|
|
|
- invariant #8: content changed since check → skip-file, continue other files
|
|
|
|
|
- incompatible-ops detection: move-to-archive + other op → whole file skipped;
|
|
|
|
|
overlapping anchors → skipped
|
|
|
|
|
- insert-frontmatter idempotency + key-conflict-preserve
|
|
|
|
|
- move-to-archive via real `git mv` in a tmp git repo
|
|
|
|
|
- replace-text no-op when match absent
|
|
|
|
|
- unknown kind rejected
|
|
|
|
|
- exit codes for all-success / partial / usage-error (via CLI main())
|
|
|
|
|
|
|
|
|
|
sys.path is patched here (no shared conftest) matching repo test style.
|
|
|
|
|
"""
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import hashlib
|
|
|
|
|
import json
|
|
|
|
|
import os
|
|
|
|
|
import subprocess
|
|
|
|
|
import sys
|
|
|
|
|
import tempfile
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
from typing import Any
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Path bootstrap
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
_SCRIPTS_DIR = Path(__file__).parent.parent / "scripts"
|
|
|
|
|
if str(_SCRIPTS_DIR) not in sys.path:
|
|
|
|
|
sys.path.insert(0, str(_SCRIPTS_DIR))
|
|
|
|
|
|
|
|
|
|
from patch_applier import ( # noqa: E402
|
|
|
|
|
PatchApplier,
|
|
|
|
|
RealFileSystem,
|
|
|
|
|
_delete_lines,
|
|
|
|
|
_insert_frontmatter_key,
|
|
|
|
|
_parse_frontmatter,
|
|
|
|
|
_replace_in_span,
|
|
|
|
|
_ranges_overlap,
|
|
|
|
|
main as applier_main,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Test doubles
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
class MemFS:
|
|
|
|
|
"""In-memory filesystem for hermetic tests (no real disk I/O)."""
|
|
|
|
|
|
|
|
|
|
def __init__(self, files: dict[str, bytes] | None = None) -> None:
|
|
|
|
|
self._files: dict[str, bytes] = dict(files or {})
|
|
|
|
|
self.writes: dict[str, bytes] = {}
|
|
|
|
|
self.mkdirs: list[str] = []
|
|
|
|
|
|
|
|
|
|
def read_bytes(self, path: Path) -> bytes:
|
|
|
|
|
key = str(path)
|
|
|
|
|
if key not in self._files:
|
|
|
|
|
raise FileNotFoundError(f"MemFS: {key} not found")
|
|
|
|
|
return self._files[key]
|
|
|
|
|
|
|
|
|
|
def write_bytes(self, path: Path, data: bytes) -> None:
|
|
|
|
|
key = str(path)
|
|
|
|
|
self._files[key] = data
|
|
|
|
|
self.writes[key] = data
|
|
|
|
|
|
|
|
|
|
def exists(self, path: Path) -> bool:
|
|
|
|
|
return str(path) in self._files
|
|
|
|
|
|
|
|
|
|
def mkdir(self, path: Path) -> None:
|
|
|
|
|
self.mkdirs.append(str(path))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class RecordingGit:
|
|
|
|
|
"""Records git mv calls without touching disk."""
|
|
|
|
|
|
|
|
|
|
def __init__(self, fail: bool = False) -> None:
|
|
|
|
|
self.calls: list[tuple[str, str]] = []
|
|
|
|
|
self._fail = fail
|
|
|
|
|
|
|
|
|
|
def mv(self, src: Path, dest: Path, cwd: Path) -> None:
|
|
|
|
|
if self._fail:
|
|
|
|
|
raise RuntimeError("git mv simulated failure")
|
|
|
|
|
self.calls.append((str(src), str(dest)))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Helpers
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
def _sha256(data: bytes) -> str:
|
|
|
|
|
return hashlib.sha256(data).hexdigest()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _make_file(content: str) -> tuple[bytes, str]:
|
|
|
|
|
"""Return (bytes, sha256_hex) for a text content."""
|
|
|
|
|
b = content.encode("utf-8")
|
|
|
|
|
return b, _sha256(b)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _entry(
|
|
|
|
|
path: str,
|
|
|
|
|
kind: str,
|
|
|
|
|
extra: dict | None = None,
|
|
|
|
|
sha: str = "",
|
|
|
|
|
start: int = 1,
|
|
|
|
|
end: int = 1,
|
|
|
|
|
) -> dict:
|
|
|
|
|
"""Build a minimal deterministic entry dict."""
|
|
|
|
|
from validate_report import KIND_TABLE
|
|
|
|
|
spec = KIND_TABLE[kind]
|
|
|
|
|
exact_edit: dict[str, Any] = {"kind": kind}
|
|
|
|
|
if spec.has_anchor:
|
|
|
|
|
exact_edit["anchor"] = {"start_line": start, "end_line": end}
|
|
|
|
|
exact_edit["expected_sha256"] = sha
|
|
|
|
|
if extra:
|
|
|
|
|
exact_edit.update(extra)
|
|
|
|
|
return {
|
|
|
|
|
"path": path,
|
|
|
|
|
"op": f"test op {kind}",
|
|
|
|
|
"op_type": "deterministic",
|
|
|
|
|
"is_destructive": spec.is_destructive,
|
|
|
|
|
"is_reversible": spec.is_reversible,
|
|
|
|
|
"exact_edit": exact_edit,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _applier(root: Path, fs=None, git=None) -> PatchApplier:
|
|
|
|
|
return PatchApplier(project_root=root, fs=fs, git=git)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _init_git_repo(tmp_path: Path) -> Path:
|
|
|
|
|
"""Create a real git repo in tmp_path; configure minimal identity."""
|
|
|
|
|
subprocess.run(["git", "init", str(tmp_path)], check=True, capture_output=True)
|
|
|
|
|
subprocess.run(
|
|
|
|
|
["git", "config", "user.email", "test@test.com"],
|
|
|
|
|
cwd=str(tmp_path), check=True, capture_output=True,
|
|
|
|
|
)
|
|
|
|
|
subprocess.run(
|
|
|
|
|
["git", "config", "user.name", "Test"],
|
|
|
|
|
cwd=str(tmp_path), check=True, capture_output=True,
|
|
|
|
|
)
|
|
|
|
|
return tmp_path
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ===========================================================================
|
|
|
|
|
# Unit: pure helper functions
|
|
|
|
|
# ===========================================================================
|
|
|
|
|
|
|
|
|
|
class TestHelpers:
|
|
|
|
|
|
|
|
|
|
def test_delete_lines_removes_inclusive_range(self):
|
|
|
|
|
lines = ["a\n", "b\n", "c\n", "d\n", "e\n"]
|
|
|
|
|
result = _delete_lines(lines, 2, 4)
|
|
|
|
|
assert result == ["a\n", "e\n"]
|
|
|
|
|
|
|
|
|
|
def test_delete_lines_whole_file(self):
|
|
|
|
|
lines = ["a\n", "b\n"]
|
|
|
|
|
assert _delete_lines(lines, 1, 2) == []
|
|
|
|
|
|
|
|
|
|
def test_delete_lines_single_line(self):
|
|
|
|
|
lines = ["a\n", "b\n", "c\n"]
|
|
|
|
|
assert _delete_lines(lines, 2, 2) == ["a\n", "c\n"]
|
|
|
|
|
|
|
|
|
|
def test_replace_in_span_finds_first_occurrence(self):
|
|
|
|
|
lines = ["foo bar\n", "foo baz\n", "other\n"]
|
|
|
|
|
out, found = _replace_in_span(lines, 1, 2, "foo", "qux")
|
|
|
|
|
assert found is True
|
|
|
|
|
assert out[0] == "qux bar\n" # first occurrence replaced
|
|
|
|
|
assert out[1] == "foo baz\n" # second unchanged (first only)
|
|
|
|
|
|
|
|
|
|
def test_replace_in_span_not_found(self):
|
|
|
|
|
lines = ["a\n", "b\n"]
|
|
|
|
|
out, found = _replace_in_span(lines, 1, 2, "missing", "x")
|
|
|
|
|
assert found is False
|
|
|
|
|
assert out == ["a\n", "b\n"]
|
|
|
|
|
|
|
|
|
|
def test_ranges_overlap_adjacent_not_overlapping(self):
|
|
|
|
|
assert not _ranges_overlap(1, 3, 4, 6)
|
|
|
|
|
|
|
|
|
|
def test_ranges_overlap_touching_end(self):
|
|
|
|
|
assert _ranges_overlap(1, 4, 4, 6)
|
|
|
|
|
|
|
|
|
|
def test_ranges_overlap_contained(self):
|
|
|
|
|
assert _ranges_overlap(1, 10, 3, 5)
|
|
|
|
|
|
|
|
|
|
def test_parse_frontmatter_present(self):
|
|
|
|
|
text = "---\nhygiene: frozen\nauthor: jared\n---\nbody\n"
|
|
|
|
|
kv, end = _parse_frontmatter(text)
|
|
|
|
|
assert kv == {"hygiene": "frozen", "author": "jared"}
|
|
|
|
|
assert end == 4 # line index after closing ---
|
|
|
|
|
|
|
|
|
|
def test_parse_frontmatter_absent(self):
|
|
|
|
|
kv, end = _parse_frontmatter("# heading\nbody\n")
|
|
|
|
|
assert kv == {}
|
|
|
|
|
assert end == 0
|
|
|
|
|
|
|
|
|
|
def test_insert_frontmatter_key_creates_block(self):
|
|
|
|
|
text = "# heading\nbody\n"
|
|
|
|
|
result = _insert_frontmatter_key(text, "hygiene", "frozen")
|
|
|
|
|
assert result.startswith("---\nhygiene: frozen\n---\n")
|
|
|
|
|
|
|
|
|
|
def test_insert_frontmatter_key_appends_to_existing(self):
|
|
|
|
|
text = "---\nauthor: jared\n---\nbody\n"
|
|
|
|
|
result = _insert_frontmatter_key(text, "hygiene", "frozen")
|
|
|
|
|
assert "hygiene: frozen" in result
|
|
|
|
|
assert "author: jared" in result
|
|
|
|
|
# closing --- must still appear after new key
|
|
|
|
|
lines = result.splitlines()
|
|
|
|
|
fm_end = lines.index("---", 1)
|
|
|
|
|
assert "hygiene: frozen" in lines[fm_end - 1]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ===========================================================================
|
|
|
|
|
# Each kind's transformation
|
|
|
|
|
# ===========================================================================
|
|
|
|
|
|
|
|
|
|
class TestDeleteRange:
|
|
|
|
|
|
|
|
|
|
def test_removes_specified_lines(self, tmp_path):
|
|
|
|
|
content = "line1\nline2\nline3\nline4\nline5\n"
|
|
|
|
|
file_bytes, sha = _make_file(content)
|
|
|
|
|
fs = MemFS({str(tmp_path / "doc.md"): file_bytes})
|
|
|
|
|
applier = _applier(tmp_path, fs=fs)
|
|
|
|
|
|
|
|
|
|
entry = _entry("doc.md", "delete-range", sha=sha, start=2, end=4)
|
|
|
|
|
result = applier.apply([entry])
|
|
|
|
|
|
|
|
|
|
assert len(result["applied"]) == 1
|
|
|
|
|
assert result["applied"][0]["kind"] == "delete-range"
|
|
|
|
|
written = fs.writes[str(tmp_path / "doc.md")].decode()
|
|
|
|
|
assert written == "line1\nline5\n"
|
|
|
|
|
|
|
|
|
|
def test_records_staged_path(self, tmp_path):
|
|
|
|
|
content = "a\nb\nc\n"
|
|
|
|
|
file_bytes, sha = _make_file(content)
|
|
|
|
|
fs = MemFS({str(tmp_path / "x.md"): file_bytes})
|
|
|
|
|
result = _applier(tmp_path, fs=fs).apply([
|
|
|
|
|
_entry("x.md", "delete-range", sha=sha, start=2, end=2)
|
|
|
|
|
])
|
|
|
|
|
assert "x.md" in result["staged_paths"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestReplaceText:
|
|
|
|
|
|
|
|
|
|
def test_replaces_match_in_span(self, tmp_path):
|
|
|
|
|
content = "see old.md here\nother line\n"
|
|
|
|
|
file_bytes, sha = _make_file(content)
|
|
|
|
|
fs = MemFS({str(tmp_path / "doc.md"): file_bytes})
|
|
|
|
|
entry = _entry("doc.md", "replace-text", sha=sha, start=1, end=1,
|
|
|
|
|
extra={"match": "old.md", "replacement": "new.md"})
|
|
|
|
|
result = _applier(tmp_path, fs=fs).apply([entry])
|
|
|
|
|
|
|
|
|
|
assert result["applied"][0]["kind"] == "replace-text"
|
|
|
|
|
written = fs.writes[str(tmp_path / "doc.md")].decode()
|
|
|
|
|
assert "new.md" in written
|
|
|
|
|
assert "old.md" not in written
|
|
|
|
|
|
|
|
|
|
def test_match_absent_is_noop_success(self, tmp_path):
|
|
|
|
|
"""replace-text: match absent in span → no-op success, no write."""
|
|
|
|
|
content = "nothing relevant here\n"
|
|
|
|
|
file_bytes, sha = _make_file(content)
|
|
|
|
|
fs = MemFS({str(tmp_path / "doc.md"): file_bytes})
|
|
|
|
|
entry = _entry("doc.md", "replace-text", sha=sha, start=1, end=1,
|
|
|
|
|
extra={"match": "old.md", "replacement": "new.md"})
|
|
|
|
|
result = _applier(tmp_path, fs=fs).apply([entry])
|
|
|
|
|
|
|
|
|
|
assert result["applied"][0]["kind"] == "replace-text"
|
|
|
|
|
assert result["skipped"] == []
|
|
|
|
|
assert result["failed"] == []
|
|
|
|
|
# No write needed (content unchanged).
|
|
|
|
|
assert str(tmp_path / "doc.md") not in fs.writes
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestDedupe:
|
|
|
|
|
|
|
|
|
|
def test_removes_duplicate_span(self, tmp_path):
|
|
|
|
|
content = "header\ndupe1\ndupe2\nfooter\n"
|
|
|
|
|
file_bytes, sha = _make_file(content)
|
|
|
|
|
fs = MemFS({str(tmp_path / "doc.md"): file_bytes})
|
|
|
|
|
entry = _entry("doc.md", "dedupe", sha=sha, start=2, end=3,
|
|
|
|
|
extra={"canonical_ref": "other.md#section"})
|
|
|
|
|
result = _applier(tmp_path, fs=fs).apply([entry])
|
|
|
|
|
|
|
|
|
|
assert result["applied"][0]["kind"] == "dedupe"
|
|
|
|
|
written = fs.writes[str(tmp_path / "doc.md")].decode()
|
|
|
|
|
assert "dupe1" not in written
|
|
|
|
|
assert "header" in written
|
|
|
|
|
assert "footer" in written
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestInsertFrontmatter:
|
|
|
|
|
|
|
|
|
|
def test_inserts_key_into_existing_block(self, tmp_path):
|
|
|
|
|
content = "---\nauthor: jared\n---\nbody\n"
|
|
|
|
|
file_bytes, _ = _make_file(content)
|
|
|
|
|
fs = MemFS({str(tmp_path / "doc.md"): file_bytes})
|
|
|
|
|
entry = _entry("doc.md", "insert-frontmatter",
|
|
|
|
|
extra={"key": "hygiene", "value": "frozen"})
|
|
|
|
|
result = _applier(tmp_path, fs=fs).apply([entry])
|
|
|
|
|
|
|
|
|
|
assert result["applied"][0]["kind"] == "insert-frontmatter"
|
|
|
|
|
written = fs.writes[str(tmp_path / "doc.md")].decode()
|
|
|
|
|
assert "hygiene: frozen" in written
|
|
|
|
|
assert "author: jared" in written
|
|
|
|
|
|
|
|
|
|
def test_creates_frontmatter_block_when_absent(self, tmp_path):
|
|
|
|
|
content = "# heading\nbody\n"
|
|
|
|
|
file_bytes, _ = _make_file(content)
|
|
|
|
|
fs = MemFS({str(tmp_path / "doc.md"): file_bytes})
|
|
|
|
|
entry = _entry("doc.md", "insert-frontmatter",
|
|
|
|
|
extra={"key": "hygiene", "value": "frozen"})
|
|
|
|
|
result = _applier(tmp_path, fs=fs).apply([entry])
|
|
|
|
|
|
|
|
|
|
written = fs.writes[str(tmp_path / "doc.md")].decode()
|
|
|
|
|
assert written.startswith("---\nhygiene: frozen\n---\n")
|
|
|
|
|
|
|
|
|
|
def test_idempotent_when_key_already_correct(self, tmp_path):
|
|
|
|
|
"""Key present with target value → success, no write."""
|
|
|
|
|
content = "---\nhygiene: frozen\n---\nbody\n"
|
|
|
|
|
file_bytes, _ = _make_file(content)
|
|
|
|
|
fs = MemFS({str(tmp_path / "doc.md"): file_bytes})
|
|
|
|
|
entry = _entry("doc.md", "insert-frontmatter",
|
|
|
|
|
extra={"key": "hygiene", "value": "frozen"})
|
|
|
|
|
result = _applier(tmp_path, fs=fs).apply([entry])
|
|
|
|
|
|
|
|
|
|
assert result["applied"][0]["kind"] == "insert-frontmatter"
|
|
|
|
|
assert result["skipped"] == []
|
|
|
|
|
# Idempotent → content unchanged → no write.
|
|
|
|
|
assert str(tmp_path / "doc.md") not in fs.writes
|
|
|
|
|
|
|
|
|
|
def test_key_conflict_preserves_existing(self, tmp_path):
|
|
|
|
|
"""Key present with DIFFERENT value → skipped, existing value untouched."""
|
|
|
|
|
content = "---\nhygiene: ignored\n---\nbody\n"
|
|
|
|
|
file_bytes, _ = _make_file(content)
|
|
|
|
|
fs = MemFS({str(tmp_path / "doc.md"): file_bytes})
|
|
|
|
|
entry = _entry("doc.md", "insert-frontmatter",
|
|
|
|
|
extra={"key": "hygiene", "value": "frozen"})
|
|
|
|
|
result = _applier(tmp_path, fs=fs).apply([entry])
|
|
|
|
|
|
|
|
|
|
assert result["applied"] == []
|
|
|
|
|
assert result["skipped"][0]["reason"] == "frontmatter-key-conflict"
|
|
|
|
|
# File must NOT have been modified.
|
|
|
|
|
assert str(tmp_path / "doc.md") not in fs.writes
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestMoveToArchive:
|
|
|
|
|
|
|
|
|
|
def test_git_mv_called_with_correct_args(self, tmp_path):
|
|
|
|
|
content = "# old plan\nbody\n"
|
|
|
|
|
file_bytes, sha = _make_file(content)
|
|
|
|
|
fs = MemFS({
|
|
|
|
|
str(tmp_path / "docs" / "old.md"): file_bytes,
|
|
|
|
|
str(tmp_path / "archive" / "docs" / "old.md"): b"", # simulate post-mv
|
|
|
|
|
})
|
|
|
|
|
git = RecordingGit()
|
|
|
|
|
entry = _entry("docs/old.md", "move-to-archive", sha=sha, start=1, end=2,
|
|
|
|
|
extra={"dest_path": "archive/docs/old.md"})
|
|
|
|
|
result = _applier(tmp_path, fs=fs, git=git).apply([entry])
|
|
|
|
|
|
|
|
|
|
assert result["applied"][0]["kind"] == "move-to-archive"
|
|
|
|
|
assert len(git.calls) == 1
|
|
|
|
|
src, dest = git.calls[0]
|
|
|
|
|
assert src == str(tmp_path / "docs" / "old.md")
|
|
|
|
|
assert dest == str(tmp_path / "archive" / "docs" / "old.md")
|
|
|
|
|
|
|
|
|
|
def test_staged_paths_contains_dest_path(self, tmp_path):
|
|
|
|
|
content = "x\n"
|
|
|
|
|
file_bytes, sha = _make_file(content)
|
|
|
|
|
fs = MemFS({str(tmp_path / "old.md"): file_bytes})
|
|
|
|
|
git = RecordingGit()
|
|
|
|
|
entry = _entry("old.md", "move-to-archive", sha=sha, start=1, end=1,
|
|
|
|
|
extra={"dest_path": "archive/old.md"})
|
|
|
|
|
result = _applier(tmp_path, fs=fs, git=git).apply([entry])
|
|
|
|
|
|
|
|
|
|
assert "archive/old.md" in result["staged_paths"]
|
|
|
|
|
|
|
|
|
|
def test_real_git_mv_stages_both_sides(self, tmp_path):
|
|
|
|
|
"""Integration: real git mv in a real repo; file is moved + staged."""
|
|
|
|
|
repo = _init_git_repo(tmp_path)
|
|
|
|
|
src = repo / "old.md"
|
|
|
|
|
src.write_bytes(b"old content\n")
|
|
|
|
|
subprocess.run(["git", "add", "old.md"], cwd=str(repo), check=True, capture_output=True)
|
|
|
|
|
subprocess.run(
|
|
|
|
|
["git", "commit", "-m", "add file"],
|
|
|
|
|
cwd=str(repo), check=True, capture_output=True
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
file_bytes = src.read_bytes()
|
|
|
|
|
sha = _sha256(file_bytes)
|
|
|
|
|
|
|
|
|
|
entry = _entry("old.md", "move-to-archive", sha=sha, start=1, end=1,
|
|
|
|
|
extra={"dest_path": "archive/old.md"})
|
|
|
|
|
result = PatchApplier(project_root=repo).apply([entry])
|
|
|
|
|
|
|
|
|
|
assert result["applied"][0]["kind"] == "move-to-archive"
|
|
|
|
|
assert not src.exists(), "source should be gone after git mv"
|
|
|
|
|
assert (repo / "archive" / "old.md").exists(), "dest should exist"
|
|
|
|
|
|
|
|
|
|
# Verify git staging.
|
|
|
|
|
status = subprocess.run(
|
|
|
|
|
["git", "status", "--porcelain"],
|
|
|
|
|
cwd=str(repo), capture_output=True, text=True
|
|
|
|
|
).stdout
|
|
|
|
|
# Expect a rename staging entry (R or D+A lines).
|
|
|
|
|
assert "old.md" in status
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ===========================================================================
|
|
|
|
|
# Per-file transaction: descending-order correctness (multi-entry)
|
|
|
|
|
# ===========================================================================
|
|
|
|
|
|
|
|
|
|
class TestMultiEntryDescendingOrder:
|
|
|
|
|
|
|
|
|
|
def test_two_delete_ranges_on_one_file_applied_correctly(self, tmp_path):
|
|
|
|
|
"""
|
|
|
|
|
Deleting lines 5-6 then 2-3 (descending) must not shift line numbers.
|
|
|
|
|
|
|
|
|
|
File (1-indexed):
|
|
|
|
|
1: header
|
|
|
|
|
2: remove-me-a
|
|
|
|
|
3: remove-me-b
|
|
|
|
|
4: keep-middle
|
|
|
|
|
5: remove-me-c
|
|
|
|
|
6: remove-me-d
|
|
|
|
|
7: footer
|
|
|
|
|
Expected after both deletes:
|
|
|
|
|
header
|
|
|
|
|
keep-middle
|
|
|
|
|
footer
|
|
|
|
|
"""
|
|
|
|
|
content = "header\nremove-me-a\nremove-me-b\nkeep-middle\nremove-me-c\nremove-me-d\nfooter\n"
|
|
|
|
|
file_bytes, sha = _make_file(content)
|
|
|
|
|
fs = MemFS({str(tmp_path / "doc.md"): file_bytes})
|
|
|
|
|
|
|
|
|
|
entries = [
|
|
|
|
|
_entry("doc.md", "delete-range", sha=sha, start=2, end=3), # lower range
|
|
|
|
|
_entry("doc.md", "delete-range", sha=sha, start=5, end=6), # higher range
|
|
|
|
|
]
|
|
|
|
|
result = _applier(tmp_path, fs=fs).apply(entries)
|
|
|
|
|
|
|
|
|
|
assert len(result["applied"]) == 2
|
|
|
|
|
assert result["skipped"] == []
|
|
|
|
|
written = fs.writes[str(tmp_path / "doc.md")].decode()
|
|
|
|
|
assert written == "header\nkeep-middle\nfooter\n"
|
|
|
|
|
|
|
|
|
|
def test_delete_range_then_insert_frontmatter_correct_order(self, tmp_path):
|
|
|
|
|
"""
|
|
|
|
|
insert-frontmatter must apply AFTER anchored edits.
|
|
|
|
|
File: 3 content lines. Delete line 3, then insert frontmatter.
|
|
|
|
|
Result: frontmatter block + line1 + line2.
|
|
|
|
|
"""
|
|
|
|
|
content = "line1\nline2\nline3\n"
|
|
|
|
|
file_bytes, sha = _make_file(content)
|
|
|
|
|
fs = MemFS({str(tmp_path / "doc.md"): file_bytes})
|
|
|
|
|
|
|
|
|
|
entries = [
|
|
|
|
|
_entry("doc.md", "insert-frontmatter",
|
|
|
|
|
extra={"key": "hygiene", "value": "frozen"}),
|
|
|
|
|
_entry("doc.md", "delete-range", sha=sha, start=3, end=3),
|
|
|
|
|
]
|
|
|
|
|
result = _applier(tmp_path, fs=fs).apply(entries)
|
|
|
|
|
|
|
|
|
|
assert len(result["applied"]) == 2
|
|
|
|
|
written = fs.writes[str(tmp_path / "doc.md")].decode()
|
|
|
|
|
assert written.startswith("---\nhygiene: frozen\n---\n")
|
|
|
|
|
assert "line1" in written
|
|
|
|
|
assert "line2" in written
|
|
|
|
|
assert "line3" not in written
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ===========================================================================
|
|
|
|
|
# Invariant #8: content changed since check → skip file, continue others
|
|
|
|
|
# ===========================================================================
|
|
|
|
|
|
|
|
|
|
class TestContentChangedGuard:
|
|
|
|
|
|
|
|
|
|
def test_changed_file_skipped_others_continue(self, tmp_path):
|
|
|
|
|
"""
|
|
|
|
|
Two files: doc-a.md has changed since check, doc-b.md is unchanged.
|
|
|
|
|
doc-a.md must be skipped; doc-b.md must be applied.
|
|
|
|
|
"""
|
|
|
|
|
# doc-a: content is different from the sha in the entry.
|
|
|
|
|
doc_a_bytes = b"new content not matching sha\n"
|
|
|
|
|
doc_a_stale_sha = _sha256(b"original content\n") # sha from check time
|
|
|
|
|
|
|
|
|
|
# doc-b: content matches.
|
|
|
|
|
doc_b_content = "line1\nline2\nline3\n"
|
|
|
|
|
doc_b_bytes, doc_b_sha = _make_file(doc_b_content)
|
|
|
|
|
|
|
|
|
|
fs = MemFS({
|
|
|
|
|
str(tmp_path / "doc-a.md"): doc_a_bytes,
|
|
|
|
|
str(tmp_path / "doc-b.md"): doc_b_bytes,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
entries = [
|
|
|
|
|
_entry("doc-a.md", "delete-range", sha=doc_a_stale_sha, start=1, end=1),
|
|
|
|
|
_entry("doc-b.md", "delete-range", sha=doc_b_sha, start=2, end=2),
|
|
|
|
|
]
|
|
|
|
|
result = _applier(tmp_path, fs=fs).apply(entries)
|
|
|
|
|
|
|
|
|
|
# doc-a skipped
|
|
|
|
|
assert any(s["path"] == "doc-a.md" and s["reason"] == "content-changed-since-check"
|
|
|
|
|
for s in result["skipped"])
|
|
|
|
|
# doc-b applied
|
|
|
|
|
assert any(a["path"] == "doc-b.md" for a in result["applied"])
|
|
|
|
|
# doc-a NOT written
|
|
|
|
|
assert str(tmp_path / "doc-a.md") not in fs.writes
|
|
|
|
|
# doc-b IS written
|
|
|
|
|
assert str(tmp_path / "doc-b.md") in fs.writes
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ===========================================================================
|
|
|
|
|
# Incompatible-ops detection
|
|
|
|
|
# ===========================================================================
|
|
|
|
|
|
|
|
|
|
class TestIncompatibleOps:
|
|
|
|
|
|
|
|
|
|
def test_move_to_archive_plus_delete_range_skips_whole_file(self, tmp_path):
|
|
|
|
|
content = "content\n"
|
|
|
|
|
file_bytes, sha = _make_file(content)
|
|
|
|
|
fs = MemFS({str(tmp_path / "doc.md"): file_bytes})
|
|
|
|
|
git = RecordingGit()
|
|
|
|
|
|
|
|
|
|
entries = [
|
|
|
|
|
_entry("doc.md", "move-to-archive", sha=sha, start=1, end=1,
|
|
|
|
|
extra={"dest_path": "archive/doc.md"}),
|
|
|
|
|
_entry("doc.md", "delete-range", sha=sha, start=1, end=1),
|
|
|
|
|
]
|
|
|
|
|
result = _applier(tmp_path, fs=fs, git=git).apply(entries)
|
|
|
|
|
|
|
|
|
|
assert result["applied"] == []
|
|
|
|
|
assert all(s["reason"] == "incompatible-ops-on-file" for s in result["skipped"])
|
|
|
|
|
assert len(result["skipped"]) == 2 # both entries skipped
|
|
|
|
|
# git mv must NOT have been called.
|
|
|
|
|
assert git.calls == []
|
|
|
|
|
|
|
|
|
|
def test_overlapping_anchors_skips_whole_file(self, tmp_path):
|
|
|
|
|
content = "a\nb\nc\nd\ne\n"
|
|
|
|
|
file_bytes, sha = _make_file(content)
|
|
|
|
|
fs = MemFS({str(tmp_path / "doc.md"): file_bytes})
|
|
|
|
|
|
|
|
|
|
# Ranges 2-4 and 3-5 overlap.
|
|
|
|
|
entries = [
|
|
|
|
|
_entry("doc.md", "delete-range", sha=sha, start=2, end=4),
|
|
|
|
|
_entry("doc.md", "dedupe", sha=sha, start=3, end=5,
|
|
|
|
|
extra={"canonical_ref": "other.md"}),
|
|
|
|
|
]
|
|
|
|
|
result = _applier(tmp_path, fs=fs).apply(entries)
|
|
|
|
|
|
|
|
|
|
assert result["applied"] == []
|
|
|
|
|
assert all(s["reason"] == "incompatible-ops-on-file" for s in result["skipped"])
|
|
|
|
|
# File must not be written.
|
|
|
|
|
assert str(tmp_path / "doc.md") not in fs.writes
|
|
|
|
|
|
|
|
|
|
def test_non_overlapping_anchors_allowed(self, tmp_path):
|
|
|
|
|
content = "a\nb\nc\nd\ne\n"
|
|
|
|
|
file_bytes, sha = _make_file(content)
|
|
|
|
|
fs = MemFS({str(tmp_path / "doc.md"): file_bytes})
|
|
|
|
|
|
|
|
|
|
# Ranges 1-2 and 4-5 do NOT overlap.
|
|
|
|
|
entries = [
|
|
|
|
|
_entry("doc.md", "delete-range", sha=sha, start=1, end=2),
|
|
|
|
|
_entry("doc.md", "delete-range", sha=sha, start=4, end=5),
|
|
|
|
|
]
|
|
|
|
|
result = _applier(tmp_path, fs=fs).apply(entries)
|
|
|
|
|
|
|
|
|
|
assert result["skipped"] == []
|
|
|
|
|
assert len(result["applied"]) == 2
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ===========================================================================
|
|
|
|
|
# Unknown kind rejected
|
|
|
|
|
# ===========================================================================
|
|
|
|
|
|
|
|
|
|
class TestUnknownKind:
|
|
|
|
|
|
|
|
|
|
def test_unknown_kind_skipped_with_structured_reason(self, tmp_path):
|
|
|
|
|
fs = MemFS()
|
|
|
|
|
entry = {
|
|
|
|
|
"path": "doc.md",
|
|
|
|
|
"op": "nuke it",
|
|
|
|
|
"op_type": "deterministic",
|
|
|
|
|
"is_destructive": True,
|
|
|
|
|
"is_reversible": False,
|
|
|
|
|
"exact_edit": {"kind": "nuke-it", "anchor": {"start_line": 1, "end_line": 1}},
|
|
|
|
|
}
|
|
|
|
|
result = _applier(tmp_path, fs=fs).apply([entry])
|
|
|
|
|
|
|
|
|
|
assert result["applied"] == []
|
|
|
|
|
assert result["skipped"][0]["reason"] == "unknown-kind"
|
|
|
|
|
|
|
|
|
|
def test_generative_entry_rejected(self, tmp_path):
|
|
|
|
|
fs = MemFS()
|
|
|
|
|
entry = {
|
|
|
|
|
"path": "doc.md",
|
|
|
|
|
"op": "distill",
|
|
|
|
|
"op_type": "generative",
|
|
|
|
|
"is_destructive": False,
|
|
|
|
|
"is_reversible": True,
|
|
|
|
|
# No exact_edit (as per spec for generative).
|
|
|
|
|
}
|
|
|
|
|
result = _applier(tmp_path, fs=fs).apply([entry])
|
|
|
|
|
|
|
|
|
|
assert result["applied"] == []
|
|
|
|
|
assert result["skipped"][0]["reason"] == "generative-entry-rejected"
|
|
|
|
|
|
|
|
|
|
def test_unknown_kind_mixed_with_valid_sibling_reports_all(self, tmp_path):
|
|
|
|
|
"""
|
|
|
|
|
One invalid entry + one valid entry on the same file: neither must be
|
|
|
|
|
applied, and both must appear in skipped (valid sibling must not vanish).
|
|
|
|
|
"""
|
|
|
|
|
content = "line1\nline2\nline3\n"
|
|
|
|
|
file_bytes, sha = _make_file(content)
|
|
|
|
|
fs = MemFS({str(tmp_path / "doc.md"): file_bytes})
|
|
|
|
|
|
|
|
|
|
entries = [
|
|
|
|
|
{ # valid delete-range
|
|
|
|
|
"path": "doc.md",
|
|
|
|
|
"op": "delete line 2",
|
|
|
|
|
"op_type": "deterministic",
|
|
|
|
|
"exact_edit": {
|
|
|
|
|
"kind": "delete-range",
|
|
|
|
|
"anchor": {"start_line": 2, "end_line": 2},
|
|
|
|
|
"expected_sha256": sha,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
{ # invalid kind
|
|
|
|
|
"path": "doc.md",
|
|
|
|
|
"op": "nuke",
|
|
|
|
|
"op_type": "deterministic",
|
|
|
|
|
"exact_edit": {"kind": "nuke-it", "anchor": {"start_line": 3, "end_line": 3}},
|
|
|
|
|
},
|
|
|
|
|
]
|
|
|
|
|
result = _applier(tmp_path, fs=fs).apply(entries)
|
|
|
|
|
|
|
|
|
|
assert result["applied"] == []
|
|
|
|
|
# Both entries must appear in skipped.
|
|
|
|
|
skipped_indices = {s["entry_index"] for s in result["skipped"]}
|
|
|
|
|
assert {0, 1} == skipped_indices
|
|
|
|
|
# File must NOT have been written.
|
|
|
|
|
assert str(tmp_path / "doc.md") not in fs.writes
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestNonUtf8Content:
|
|
|
|
|
|
|
|
|
|
def test_non_utf8_file_skipped_not_corrupted(self, tmp_path):
|
|
|
|
|
"""
|
|
|
|
|
File with non-UTF-8 bytes: skip with reason, do not write a corrupted
|
|
|
|
|
version. The hash guard passes (we hash raw bytes) — this is the
|
|
|
|
|
edge case that errors="replace" would silently corrupt.
|
|
|
|
|
"""
|
|
|
|
|
# Craft bytes that are valid Latin-1 but invalid UTF-8.
|
|
|
|
|
raw_bytes = b"line1\nline2 with \xff non-utf8\nline3\n"
|
|
|
|
|
sha = _sha256(raw_bytes)
|
|
|
|
|
fs = MemFS({str(tmp_path / "doc.md"): raw_bytes})
|
|
|
|
|
|
|
|
|
|
entry = _entry("doc.md", "delete-range", sha=sha, start=2, end=2)
|
|
|
|
|
result = _applier(tmp_path, fs=fs).apply([entry])
|
|
|
|
|
|
|
|
|
|
assert result["applied"] == []
|
|
|
|
|
assert result["skipped"][0]["reason"] == "non-utf8-content"
|
|
|
|
|
# Must not have written anything.
|
|
|
|
|
assert str(tmp_path / "doc.md") not in fs.writes
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ===========================================================================
|
|
|
|
|
# move-to-archive idempotency (source already gone)
|
|
|
|
|
# ===========================================================================
|
|
|
|
|
|
|
|
|
|
class TestMoveToArchiveIdempotent:
|
|
|
|
|
|
|
|
|
|
def test_source_already_moved_is_success(self, tmp_path):
|
|
|
|
|
"""
|
|
|
|
|
If source is gone and dest exists, treat as idempotent success.
|
|
|
|
|
"""
|
|
|
|
|
dest_bytes = b"already archived\n"
|
|
|
|
|
sha = _sha256(b"original\n") # sha from check time (source is gone)
|
|
|
|
|
fs = MemFS({
|
|
|
|
|
# Source NOT in fs (already moved).
|
|
|
|
|
str(tmp_path / "archive" / "doc.md"): dest_bytes,
|
|
|
|
|
})
|
|
|
|
|
git = RecordingGit()
|
|
|
|
|
|
|
|
|
|
entry = _entry("doc.md", "move-to-archive", sha=sha, start=1, end=1,
|
|
|
|
|
extra={"dest_path": "archive/doc.md"})
|
|
|
|
|
result = _applier(tmp_path, fs=fs, git=git).apply([entry])
|
|
|
|
|
|
|
|
|
|
assert result["applied"][0]["kind"] == "move-to-archive"
|
|
|
|
|
assert result["failed"] == []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ===========================================================================
|
|
|
|
|
# Exit codes via CLI main()
|
|
|
|
|
# ===========================================================================
|
|
|
|
|
|
|
|
|
|
class TestExitCodes:
|
|
|
|
|
|
|
|
|
|
def _write_report(self, path: Path, entries: list) -> None:
|
|
|
|
|
report = {
|
|
|
|
|
"schema_version": "1.0",
|
|
|
|
|
"tool_version": "0.1.0",
|
|
|
|
|
"generated_at": "2026-06-24T00:00:00+00:00",
|
|
|
|
|
"scan": {
|
|
|
|
|
"project_root": str(path.parent),
|
|
|
|
|
"scope_globs": ["**/*.md"],
|
|
|
|
|
"excluded_dirs": [],
|
|
|
|
|
"files_scanned": 1,
|
|
|
|
|
},
|
|
|
|
|
"shortlist": ["doc.md"],
|
|
|
|
|
"entries": entries,
|
|
|
|
|
}
|
|
|
|
|
path.write_text(json.dumps(report), encoding="utf-8")
|
|
|
|
|
|
|
|
|
|
def test_exit_0_all_applied(self, tmp_path):
|
|
|
|
|
content = "line1\nline2\nline3\n"
|
|
|
|
|
file_bytes, sha = _make_file(content)
|
|
|
|
|
(tmp_path / "doc.md").write_bytes(file_bytes)
|
|
|
|
|
|
|
|
|
|
report_path = tmp_path / "report.json"
|
|
|
|
|
self._write_report(report_path, [
|
|
|
|
|
{
|
|
|
|
|
"path": "doc.md",
|
|
|
|
|
"op": "delete line 2",
|
|
|
|
|
"op_type": "deterministic",
|
|
|
|
|
"is_destructive": True,
|
|
|
|
|
"is_reversible": False,
|
|
|
|
|
"safety_tier": "confirm",
|
|
|
|
|
"signals": [],
|
|
|
|
|
"category": {"class": "stale", "subtype": "contradicted"},
|
|
|
|
|
"token_estimate": {"raw_tokens": 10},
|
|
|
|
|
"exact_edit": {
|
|
|
|
|
"kind": "delete-range",
|
|
|
|
|
"anchor": {"start_line": 2, "end_line": 2},
|
|
|
|
|
"expected_sha256": sha,
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
rc = applier_main([
|
|
|
|
|
"--report", str(report_path),
|
|
|
|
|
"--apply-indices", "0",
|
|
|
|
|
"--project-root", str(tmp_path),
|
|
|
|
|
])
|
|
|
|
|
assert rc == 0
|
|
|
|
|
|
|
|
|
|
def test_exit_1_partial_skipped(self, tmp_path):
|
|
|
|
|
"""Content changed → skip → exit 1."""
|
|
|
|
|
actual_bytes = b"different content\n"
|
|
|
|
|
stale_sha = _sha256(b"original content\n")
|
|
|
|
|
(tmp_path / "doc.md").write_bytes(actual_bytes)
|
|
|
|
|
|
|
|
|
|
report_path = tmp_path / "report.json"
|
|
|
|
|
self._write_report(report_path, [
|
|
|
|
|
{
|
|
|
|
|
"path": "doc.md",
|
|
|
|
|
"op": "delete line 1",
|
|
|
|
|
"op_type": "deterministic",
|
|
|
|
|
"is_destructive": True,
|
|
|
|
|
"is_reversible": False,
|
|
|
|
|
"safety_tier": "confirm",
|
|
|
|
|
"signals": [],
|
|
|
|
|
"category": {"class": "stale", "subtype": "contradicted"},
|
|
|
|
|
"token_estimate": {"raw_tokens": 5},
|
|
|
|
|
"exact_edit": {
|
|
|
|
|
"kind": "delete-range",
|
|
|
|
|
"anchor": {"start_line": 1, "end_line": 1},
|
|
|
|
|
"expected_sha256": stale_sha,
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
rc = applier_main([
|
|
|
|
|
"--report", str(report_path),
|
|
|
|
|
"--apply-indices", "0",
|
|
|
|
|
"--project-root", str(tmp_path),
|
|
|
|
|
])
|
|
|
|
|
assert rc == 1
|
|
|
|
|
|
|
|
|
|
def test_exit_2_missing_report(self, tmp_path):
|
|
|
|
|
rc = applier_main([
|
|
|
|
|
"--report", str(tmp_path / "nonexistent.json"),
|
|
|
|
|
"--apply-indices", "0",
|
|
|
|
|
"--project-root", str(tmp_path),
|
|
|
|
|
])
|
|
|
|
|
assert rc == 2
|
|
|
|
|
|
|
|
|
|
def test_exit_2_out_of_range_index(self, tmp_path):
|
|
|
|
|
content = "x\n"
|
|
|
|
|
(tmp_path / "doc.md").write_bytes(content.encode())
|
|
|
|
|
report_path = tmp_path / "report.json"
|
|
|
|
|
self._write_report(report_path, []) # 0 entries
|
|
|
|
|
|
|
|
|
|
rc = applier_main([
|
|
|
|
|
"--report", str(report_path),
|
|
|
|
|
"--apply-indices", "5",
|
|
|
|
|
"--project-root", str(tmp_path),
|
|
|
|
|
])
|
|
|
|
|
assert rc == 2
|
|
|
|
|
|
|
|
|
|
def test_exit_2_invalid_indices_string(self, tmp_path):
|
|
|
|
|
# Write a minimal valid report.
|
|
|
|
|
report_path = tmp_path / "report.json"
|
|
|
|
|
report = {
|
|
|
|
|
"schema_version": "1.0", "tool_version": "0.1.0",
|
|
|
|
|
"generated_at": "2026-06-24T00:00:00+00:00",
|
|
|
|
|
"scan": {"project_root": str(tmp_path), "scope_globs": [], "excluded_dirs": [], "files_scanned": 0},
|
|
|
|
|
"shortlist": [], "entries": [],
|
|
|
|
|
}
|
|
|
|
|
report_path.write_text(json.dumps(report))
|
|
|
|
|
rc = applier_main([
|
|
|
|
|
"--report", str(report_path),
|
|
|
|
|
"--apply-indices", "abc",
|
|
|
|
|
"--project-root", str(tmp_path),
|
|
|
|
|
])
|
|
|
|
|
assert rc == 2
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ===========================================================================
|
|
|
|
|
# apply_indexed preserves original report indices
|
|
|
|
|
# ===========================================================================
|
|
|
|
|
|
|
|
|
|
class TestApplyIndexed:
|
|
|
|
|
|
|
|
|
|
def test_entry_index_matches_original_report_index(self, tmp_path):
|
|
|
|
|
content = "line1\nline2\nline3\n"
|
|
|
|
|
file_bytes, sha = _make_file(content)
|
|
|
|
|
fs = MemFS({str(tmp_path / "doc.md"): file_bytes})
|
|
|
|
|
|
|
|
|
|
entry = _entry("doc.md", "delete-range", sha=sha, start=2, end=2)
|
|
|
|
|
# original report index is 7 (not 0).
|
|
|
|
|
result = _applier(tmp_path, fs=fs).apply_indexed([(7, entry)])
|
|
|
|
|
|
|
|
|
|
assert result["applied"][0]["entry_index"] == 7
|
2026-07-15 11:41:59 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ===========================================================================
|
|
|
|
|
# Lifecycle deletion ops: `delete` and `extract-then-delete` (ADR-0039)
|
|
|
|
|
# ===========================================================================
|
|
|
|
|
#
|
|
|
|
|
# These two kinds are NOT drawn from KIND_TABLE (owned by report_builder.py /
|
|
|
|
|
# validate_report.py, group 3's file) — they are constructed directly here so
|
|
|
|
|
# these tests do not depend on that concurrent work landing first.
|
|
|
|
|
|
|
|
|
|
class RecordingGitState:
|
|
|
|
|
"""Injectable git-state double: fixed tracked/dirty answers per test."""
|
|
|
|
|
|
|
|
|
|
def __init__(self, tracked: bool = True, dirty: bool = False) -> None:
|
|
|
|
|
self.tracked = tracked
|
|
|
|
|
self.dirty = dirty
|
|
|
|
|
self.is_tracked_calls: list[tuple[str, str]] = []
|
|
|
|
|
self.is_dirty_calls: list[tuple[str, str]] = []
|
|
|
|
|
|
|
|
|
|
def is_tracked(self, path: str, cwd: Path) -> bool:
|
|
|
|
|
self.is_tracked_calls.append((path, str(cwd)))
|
|
|
|
|
return self.tracked
|
|
|
|
|
|
|
|
|
|
def is_dirty(self, path: str, cwd: Path) -> bool:
|
|
|
|
|
self.is_dirty_calls.append((path, str(cwd)))
|
|
|
|
|
return self.dirty
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _lifecycle_entry(
|
|
|
|
|
path: str,
|
|
|
|
|
kind: str,
|
|
|
|
|
safety_tier: str = "auto",
|
|
|
|
|
is_directory: bool = False,
|
|
|
|
|
extraction_complete: bool | None = None,
|
|
|
|
|
lifecycle: dict | None = None,
|
2026-07-15 18:59:58 +00:00
|
|
|
extraction_target: str | None = None,
|
|
|
|
|
extraction_pointer: dict | None = None,
|
2026-07-15 11:41:59 +00:00
|
|
|
) -> dict:
|
|
|
|
|
"""Build a minimal `delete` / `extract-then-delete` report entry.
|
|
|
|
|
|
|
|
|
|
Deliberately independent of KIND_TABLE (see module docstring above).
|
2026-07-15 18:59:58 +00:00
|
|
|
`extraction_target` (when given) mirrors the pre-existing frozen-schema
|
|
|
|
|
field validate_report.py already requires on extract-then-delete entries
|
|
|
|
|
(`"repo-durable"` or `"cross-repo"`, the vault case).
|
2026-07-15 11:41:59 +00:00
|
|
|
"""
|
|
|
|
|
exact_edit: dict[str, Any] = {"kind": kind}
|
|
|
|
|
if is_directory:
|
|
|
|
|
exact_edit["is_directory"] = True
|
2026-07-15 18:59:58 +00:00
|
|
|
if extraction_target is not None:
|
|
|
|
|
exact_edit["extraction_target"] = extraction_target
|
2026-07-15 11:41:59 +00:00
|
|
|
if extraction_complete is not None:
|
|
|
|
|
exact_edit["extraction_complete"] = extraction_complete
|
|
|
|
|
entry_extraction_complete = extraction_complete
|
|
|
|
|
entry: dict[str, Any] = {
|
|
|
|
|
"path": path,
|
|
|
|
|
"op": f"test op {kind}",
|
|
|
|
|
"op_type": "deterministic",
|
|
|
|
|
"is_destructive": True,
|
|
|
|
|
"is_reversible": False,
|
|
|
|
|
"safety_tier": safety_tier,
|
|
|
|
|
"exact_edit": exact_edit,
|
|
|
|
|
"lifecycle": lifecycle or {
|
|
|
|
|
"rule_ref": "temporary/test-rule",
|
|
|
|
|
"lifetime": "temporary",
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
if extraction_complete is not None:
|
|
|
|
|
entry["extraction_complete"] = extraction_complete
|
2026-07-15 18:59:58 +00:00
|
|
|
if extraction_pointer is not None:
|
|
|
|
|
entry["extraction_pointer"] = extraction_pointer
|
2026-07-15 11:41:59 +00:00
|
|
|
return entry
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _applier_ls(root: Path, fs=None, git=None, git_state=None) -> PatchApplier:
|
|
|
|
|
return PatchApplier(project_root=root, fs=fs, git=git, git_state=git_state)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class RecordingGitRm:
|
|
|
|
|
"""Records git rm calls (and optionally mv, unused here)."""
|
|
|
|
|
|
|
|
|
|
def __init__(self, fail: bool = False) -> None:
|
|
|
|
|
self.rm_calls: list[tuple[str, str, bool]] = []
|
|
|
|
|
self.mv_calls: list[tuple[str, str]] = []
|
|
|
|
|
self._fail = fail
|
|
|
|
|
|
|
|
|
|
def mv(self, src: Path, dest: Path, cwd: Path) -> None:
|
|
|
|
|
self.mv_calls.append((str(src), str(dest)))
|
|
|
|
|
|
|
|
|
|
def rm(self, path: Path, cwd: Path, recursive: bool = False) -> None:
|
|
|
|
|
if self._fail:
|
|
|
|
|
raise RuntimeError("git rm simulated failure")
|
|
|
|
|
self.rm_calls.append((str(path), str(cwd), recursive))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestLifecycleDeleteApplyTimeReverification:
|
|
|
|
|
"""Task 4.1(a): apply-time re-verification — never trust cached tier."""
|
|
|
|
|
|
|
|
|
|
def test_auto_tier_tracked_clean_applies_via_git_rm(self, tmp_path):
|
|
|
|
|
fs = MemFS()
|
|
|
|
|
git = RecordingGitRm()
|
|
|
|
|
git_state = RecordingGitState(tracked=True, dirty=False)
|
|
|
|
|
entry = _lifecycle_entry("docs/old.md", "delete", safety_tier="auto")
|
|
|
|
|
|
|
|
|
|
result = _applier_ls(tmp_path, fs=fs, git=git, git_state=git_state).apply([entry])
|
|
|
|
|
|
|
|
|
|
assert result["applied"][0]["kind"] == "delete"
|
|
|
|
|
assert result["skipped"] == []
|
|
|
|
|
assert len(git.rm_calls) == 1
|
|
|
|
|
rm_path, rm_cwd, recursive = git.rm_calls[0]
|
|
|
|
|
assert rm_path == str(tmp_path / "docs" / "old.md")
|
|
|
|
|
assert recursive is False
|
|
|
|
|
assert "docs/old.md" in result["staged_paths"]
|
|
|
|
|
# Re-verification actually consulted live git state for this path.
|
|
|
|
|
assert git_state.is_tracked_calls == [("docs/old.md", str(tmp_path))]
|
|
|
|
|
|
|
|
|
|
def test_auto_tier_downgraded_to_skip_when_untracked_since_check(self, tmp_path):
|
|
|
|
|
fs = MemFS()
|
|
|
|
|
git = RecordingGitRm()
|
|
|
|
|
git_state = RecordingGitState(tracked=False)
|
|
|
|
|
entry = _lifecycle_entry("docs/old.md", "delete", safety_tier="auto")
|
|
|
|
|
|
|
|
|
|
result = _applier_ls(tmp_path, fs=fs, git=git, git_state=git_state).apply([entry])
|
|
|
|
|
|
|
|
|
|
assert result["applied"] == []
|
|
|
|
|
assert result["skipped"][0]["reason"] == "git-state-changed-since-check"
|
|
|
|
|
assert git.rm_calls == []
|
|
|
|
|
|
|
|
|
|
def test_auto_tier_downgraded_to_skip_when_dirty_since_check(self, tmp_path):
|
|
|
|
|
fs = MemFS()
|
|
|
|
|
git = RecordingGitRm()
|
|
|
|
|
git_state = RecordingGitState(tracked=True, dirty=True)
|
|
|
|
|
entry = _lifecycle_entry("docs/old.md", "delete", safety_tier="auto")
|
|
|
|
|
|
|
|
|
|
result = _applier_ls(tmp_path, fs=fs, git=git, git_state=git_state).apply([entry])
|
|
|
|
|
|
|
|
|
|
assert result["applied"] == []
|
|
|
|
|
assert result["skipped"][0]["reason"] == "git-state-changed-since-check"
|
|
|
|
|
assert git.rm_calls == []
|
|
|
|
|
|
|
|
|
|
def test_other_entries_on_other_paths_continue_after_a_skip(self, tmp_path):
|
|
|
|
|
"""One entry's guard-skip must not block a sibling path's delete."""
|
|
|
|
|
fs = MemFS()
|
|
|
|
|
git = RecordingGitRm()
|
|
|
|
|
git_state = RecordingGitState(tracked=False) # everything looks untracked
|
|
|
|
|
entries = [
|
|
|
|
|
_lifecycle_entry("docs/a.md", "delete", safety_tier="auto"),
|
|
|
|
|
_lifecycle_entry("docs/b.md", "delete", safety_tier="auto"),
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
result = _applier_ls(tmp_path, fs=fs, git=git, git_state=git_state).apply(entries)
|
|
|
|
|
|
|
|
|
|
assert result["applied"] == []
|
|
|
|
|
assert len(result["skipped"]) == 2
|
|
|
|
|
reasons = {s["reason"] for s in result["skipped"]}
|
|
|
|
|
assert reasons == {"git-state-changed-since-check"}
|
|
|
|
|
|
|
|
|
|
def test_confirm_tier_lifecycle_delete_is_not_re_verified(self, tmp_path):
|
|
|
|
|
"""
|
|
|
|
|
A confirm-tier entry was already gated through explicit human
|
|
|
|
|
approval upstream (batch-confirm). The applier must not silently
|
|
|
|
|
downgrade/skip it just because the path is untracked or dirty —
|
|
|
|
|
that dirty/untracked state is precisely why it was confirm-tier.
|
|
|
|
|
"""
|
|
|
|
|
fs = MemFS()
|
|
|
|
|
git = RecordingGitRm()
|
|
|
|
|
git_state = RecordingGitState(tracked=False, dirty=True)
|
|
|
|
|
entry = _lifecycle_entry("docs/old.md", "delete", safety_tier="confirm")
|
|
|
|
|
|
|
|
|
|
result = _applier_ls(tmp_path, fs=fs, git=git, git_state=git_state).apply([entry])
|
|
|
|
|
|
|
|
|
|
assert result["applied"][0]["kind"] == "delete"
|
|
|
|
|
assert len(git.rm_calls) == 1
|
|
|
|
|
# No git-state query needed at all for an already-confirmed entry.
|
|
|
|
|
assert git_state.is_tracked_calls == []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestLifecycleDeleteGitRm:
|
|
|
|
|
"""Task 4.1(b): `delete` performs true git rm, staged precisely."""
|
|
|
|
|
|
|
|
|
|
def test_directory_aggregate_entry_uses_recursive_git_rm(self, tmp_path):
|
|
|
|
|
fs = MemFS()
|
|
|
|
|
git = RecordingGitRm()
|
|
|
|
|
git_state = RecordingGitState(tracked=True, dirty=False)
|
|
|
|
|
entry = _lifecycle_entry(
|
|
|
|
|
"autoresearch/run-042", "delete", safety_tier="auto", is_directory=True
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
result = _applier_ls(tmp_path, fs=fs, git=git, git_state=git_state).apply([entry])
|
|
|
|
|
|
|
|
|
|
assert result["applied"][0]["kind"] == "delete"
|
|
|
|
|
rm_path, rm_cwd, recursive = git.rm_calls[0]
|
|
|
|
|
assert rm_path == str(tmp_path / "autoresearch" / "run-042")
|
|
|
|
|
assert recursive is True
|
|
|
|
|
|
|
|
|
|
def test_directory_delete_bypasses_content_hash_guard(self, tmp_path):
|
|
|
|
|
"""
|
|
|
|
|
A directory aggregate entry carries no expected_sha256 (no single
|
|
|
|
|
file to hash) — it must still apply cleanly via git rm, never
|
|
|
|
|
rejected for "missing" hash fields.
|
|
|
|
|
"""
|
|
|
|
|
fs = MemFS()
|
|
|
|
|
git = RecordingGitRm()
|
|
|
|
|
git_state = RecordingGitState(tracked=True, dirty=False)
|
|
|
|
|
entry = _lifecycle_entry(
|
|
|
|
|
"autoresearch/run-001", "delete", safety_tier="auto", is_directory=True
|
|
|
|
|
)
|
|
|
|
|
assert "expected_sha256" not in entry["exact_edit"]
|
|
|
|
|
|
|
|
|
|
result = _applier_ls(tmp_path, fs=fs, git=git, git_state=git_state).apply([entry])
|
|
|
|
|
|
|
|
|
|
assert result["applied"][0]["kind"] == "delete"
|
|
|
|
|
assert result["failed"] == []
|
|
|
|
|
|
|
|
|
|
def test_git_rm_failure_reported_as_failed(self, tmp_path):
|
|
|
|
|
fs = MemFS()
|
|
|
|
|
git = RecordingGitRm(fail=True)
|
|
|
|
|
git_state = RecordingGitState(tracked=True, dirty=False)
|
|
|
|
|
entry = _lifecycle_entry("docs/old.md", "delete", safety_tier="auto")
|
|
|
|
|
|
|
|
|
|
result = _applier_ls(tmp_path, fs=fs, git=git, git_state=git_state).apply([entry])
|
|
|
|
|
|
|
|
|
|
assert result["applied"] == []
|
|
|
|
|
assert result["failed"][0]["kind"] == "delete"
|
|
|
|
|
assert "git rm simulated failure" in result["failed"][0]["error"]
|
|
|
|
|
|
|
|
|
|
def test_real_git_rm_stages_deletion(self, tmp_path):
|
|
|
|
|
"""Integration: real git rm in a real repo removes + stages the file."""
|
|
|
|
|
repo = _init_git_repo(tmp_path)
|
|
|
|
|
target = repo / "old.md"
|
|
|
|
|
target.write_bytes(b"stale content\n")
|
|
|
|
|
subprocess.run(["git", "add", "old.md"], cwd=str(repo), check=True, capture_output=True)
|
|
|
|
|
subprocess.run(
|
|
|
|
|
["git", "commit", "-m", "add file"],
|
|
|
|
|
cwd=str(repo), check=True, capture_output=True,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
entry = _lifecycle_entry("old.md", "delete", safety_tier="auto")
|
|
|
|
|
result = PatchApplier(project_root=repo).apply([entry])
|
|
|
|
|
|
|
|
|
|
assert result["applied"][0]["kind"] == "delete"
|
|
|
|
|
assert not target.exists(), "file should be gone after git rm"
|
|
|
|
|
|
|
|
|
|
status = subprocess.run(
|
|
|
|
|
["git", "status", "--porcelain"],
|
|
|
|
|
cwd=str(repo), capture_output=True, text=True,
|
|
|
|
|
).stdout
|
|
|
|
|
# Staged deletion shows as "D old.md" (space, not "D " in worktree column).
|
|
|
|
|
assert "D old.md" in status
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestExtractThenDeleteFailsClosed:
|
|
|
|
|
"""Task 4.1(c): extract-then-delete only deletes once extraction is done."""
|
|
|
|
|
|
|
|
|
|
def test_skipped_when_extraction_not_marked_complete(self, tmp_path):
|
|
|
|
|
fs = MemFS()
|
|
|
|
|
git = RecordingGitRm()
|
|
|
|
|
git_state = RecordingGitState(tracked=True, dirty=False)
|
|
|
|
|
entry = _lifecycle_entry(
|
|
|
|
|
"docs/old-guide.md", "extract-then-delete", safety_tier="auto",
|
|
|
|
|
extraction_complete=False,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
result = _applier_ls(tmp_path, fs=fs, git=git, git_state=git_state).apply([entry])
|
|
|
|
|
|
|
|
|
|
assert result["applied"] == []
|
|
|
|
|
assert result["skipped"][0]["reason"] == "extraction-not-confirmed"
|
|
|
|
|
assert git.rm_calls == []
|
|
|
|
|
|
|
|
|
|
def test_skipped_when_extraction_flag_absent(self, tmp_path):
|
|
|
|
|
"""No extraction_complete field at all → treated as not confirmed."""
|
|
|
|
|
fs = MemFS()
|
|
|
|
|
git = RecordingGitRm()
|
|
|
|
|
git_state = RecordingGitState(tracked=True, dirty=False)
|
|
|
|
|
entry = _lifecycle_entry(
|
|
|
|
|
"docs/old-guide.md", "extract-then-delete", safety_tier="auto",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
result = _applier_ls(tmp_path, fs=fs, git=git, git_state=git_state).apply([entry])
|
|
|
|
|
|
|
|
|
|
assert result["applied"] == []
|
|
|
|
|
assert result["skipped"][0]["reason"] == "extraction-not-confirmed"
|
|
|
|
|
assert git.rm_calls == []
|
|
|
|
|
|
|
|
|
|
def test_applies_git_rm_once_extraction_confirmed(self, tmp_path):
|
|
|
|
|
fs = MemFS()
|
|
|
|
|
git = RecordingGitRm()
|
|
|
|
|
git_state = RecordingGitState(tracked=True, dirty=False)
|
|
|
|
|
entry = _lifecycle_entry(
|
|
|
|
|
"docs/old-guide.md", "extract-then-delete", safety_tier="auto",
|
|
|
|
|
extraction_complete=True,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
result = _applier_ls(tmp_path, fs=fs, git=git, git_state=git_state).apply([entry])
|
|
|
|
|
|
|
|
|
|
assert result["applied"][0]["kind"] == "extract-then-delete"
|
|
|
|
|
assert len(git.rm_calls) == 1
|
|
|
|
|
|
|
|
|
|
def test_failed_extraction_is_per_entry_skip_not_run_level_failure(self, tmp_path):
|
|
|
|
|
"""
|
|
|
|
|
A failed extraction for one entry must not prevent a sibling path's
|
|
|
|
|
entry (independent extraction) from applying in the same run.
|
|
|
|
|
"""
|
|
|
|
|
fs = MemFS()
|
|
|
|
|
git = RecordingGitRm()
|
|
|
|
|
git_state = RecordingGitState(tracked=True, dirty=False)
|
|
|
|
|
entries = [
|
|
|
|
|
_lifecycle_entry(
|
|
|
|
|
"docs/not-extracted.md", "extract-then-delete", safety_tier="auto",
|
|
|
|
|
extraction_complete=False,
|
|
|
|
|
),
|
|
|
|
|
_lifecycle_entry(
|
|
|
|
|
"docs/extracted.md", "extract-then-delete", safety_tier="auto",
|
|
|
|
|
extraction_complete=True,
|
|
|
|
|
),
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
result = _applier_ls(tmp_path, fs=fs, git=git, git_state=git_state).apply(entries)
|
|
|
|
|
|
|
|
|
|
assert len(result["applied"]) == 1
|
|
|
|
|
assert result["applied"][0]["path"] == "docs/extracted.md"
|
|
|
|
|
assert len(result["skipped"]) == 1
|
|
|
|
|
assert result["skipped"][0]["reason"] == "extraction-not-confirmed"
|
|
|
|
|
|
|
|
|
|
def test_confirm_tier_extract_then_delete_still_fails_closed_on_extraction(self, tmp_path):
|
|
|
|
|
"""
|
|
|
|
|
Even for an already-approved confirm-tier entry, extraction
|
|
|
|
|
confirmation is unconditional — the applier itself never performs
|
|
|
|
|
the generative extraction, so it cannot proceed without the flag.
|
|
|
|
|
"""
|
|
|
|
|
fs = MemFS()
|
|
|
|
|
git = RecordingGitRm()
|
|
|
|
|
git_state = RecordingGitState(tracked=False, dirty=True)
|
|
|
|
|
entry = _lifecycle_entry(
|
|
|
|
|
"docs/old-guide.md", "extract-then-delete", safety_tier="confirm",
|
|
|
|
|
extraction_complete=False,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
result = _applier_ls(tmp_path, fs=fs, git=git, git_state=git_state).apply([entry])
|
|
|
|
|
|
|
|
|
|
assert result["applied"] == []
|
|
|
|
|
assert result["skipped"][0]["reason"] == "extraction-not-confirmed"
|
|
|
|
|
assert git.rm_calls == []
|
|
|
|
|
|
|
|
|
|
|
2026-07-15 18:59:58 +00:00
|
|
|
class WriteFailsFS(MemFS):
|
|
|
|
|
"""MemFS that raises on write_bytes for a chosen path (simulates an
|
|
|
|
|
unwritable extracted.md — e.g. its directory was removed)."""
|
|
|
|
|
|
|
|
|
|
def __init__(self, files: dict[str, bytes] | None = None, fail_path: str | None = None) -> None:
|
|
|
|
|
super().__init__(files)
|
|
|
|
|
self._fail_path = fail_path
|
|
|
|
|
|
|
|
|
|
def write_bytes(self, path: Path, data: bytes) -> None:
|
|
|
|
|
if self._fail_path is not None and str(path) == self._fail_path:
|
|
|
|
|
raise OSError("simulated write failure: directory removed")
|
|
|
|
|
super().write_bytes(path, data)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestExtractThenDeleteVaultPointerAppend:
|
|
|
|
|
"""Task 4.2: vault-destination extractions append an extracted.md pointer,
|
|
|
|
|
staged into the same transaction as the git rm; repo-durable destinations
|
|
|
|
|
get no index entry; a failed append (or missing/invalid pointer
|
|
|
|
|
metadata) skips the delete (fail closed, never gone-but-unindexed)."""
|
|
|
|
|
|
|
|
|
|
def _pointer(self, **overrides) -> dict:
|
|
|
|
|
base = {
|
|
|
|
|
"vault_note": "tool/graphify-clustering-behavior",
|
|
|
|
|
"reason": "why HDBSCAN over-merges doc clusters; read before "
|
|
|
|
|
"tuning any graphify clustering params",
|
|
|
|
|
"date": "2026-07-15",
|
|
|
|
|
}
|
|
|
|
|
base.update(overrides)
|
|
|
|
|
return base
|
|
|
|
|
|
|
|
|
|
def test_vault_extraction_appends_pointer_and_creates_extracted_md(self, tmp_path):
|
|
|
|
|
fs = MemFS()
|
|
|
|
|
git = RecordingGitRm()
|
|
|
|
|
git_state = RecordingGitState(tracked=True, dirty=False)
|
|
|
|
|
entry = _lifecycle_entry(
|
|
|
|
|
"docs/graphify-clustering-notes.md", "extract-then-delete",
|
|
|
|
|
safety_tier="auto", extraction_complete=True,
|
|
|
|
|
extraction_target="cross-repo", extraction_pointer=self._pointer(),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
result = _applier_ls(tmp_path, fs=fs, git=git, git_state=git_state).apply([entry])
|
|
|
|
|
|
|
|
|
|
assert result["applied"][0]["kind"] == "extract-then-delete"
|
|
|
|
|
assert result["skipped"] == []
|
|
|
|
|
assert len(git.rm_calls) == 1
|
|
|
|
|
|
|
|
|
|
extracted_path = str(tmp_path / "docs" / "extracted.md")
|
|
|
|
|
assert extracted_path in fs.writes
|
|
|
|
|
content = fs.writes[extracted_path].decode("utf-8")
|
|
|
|
|
expected_line = (
|
|
|
|
|
"- [[vault: tool/graphify-clustering-behavior]] — why HDBSCAN "
|
|
|
|
|
"over-merges doc clusters; read before tuning any graphify "
|
|
|
|
|
"clustering params (extracted from `graphify-clustering-notes.md`, "
|
|
|
|
|
"2026-07-15)"
|
|
|
|
|
)
|
|
|
|
|
assert expected_line in content
|
|
|
|
|
assert "docs/extracted.md" in result["staged_paths"]
|
|
|
|
|
assert "docs/graphify-clustering-notes.md" in result["staged_paths"]
|
|
|
|
|
|
|
|
|
|
def test_vault_extraction_appends_to_existing_extracted_md(self, tmp_path):
|
|
|
|
|
existing_path = str(tmp_path / "docs" / "extracted.md")
|
|
|
|
|
existing_content = (
|
|
|
|
|
"- [[vault: tool/old-note]] — prior entry "
|
|
|
|
|
"(extracted from `old.md`, 2026-07-01)\n"
|
|
|
|
|
)
|
|
|
|
|
fs = MemFS({existing_path: existing_content.encode("utf-8")})
|
|
|
|
|
git = RecordingGitRm()
|
|
|
|
|
git_state = RecordingGitState(tracked=True, dirty=False)
|
|
|
|
|
entry = _lifecycle_entry(
|
|
|
|
|
"docs/graphify-clustering-notes.md", "extract-then-delete",
|
|
|
|
|
safety_tier="auto", extraction_complete=True,
|
|
|
|
|
extraction_target="cross-repo", extraction_pointer=self._pointer(),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
result = _applier_ls(tmp_path, fs=fs, git=git, git_state=git_state).apply([entry])
|
|
|
|
|
|
|
|
|
|
assert result["applied"][0]["kind"] == "extract-then-delete"
|
|
|
|
|
content = fs.writes[existing_path].decode("utf-8")
|
|
|
|
|
assert "tool/old-note" in content
|
|
|
|
|
assert "tool/graphify-clustering-behavior" in content
|
|
|
|
|
# Prior entry preserved before the newly appended one.
|
|
|
|
|
assert content.index("tool/old-note") < content.index("tool/graphify-clustering-behavior")
|
|
|
|
|
|
|
|
|
|
def test_repo_durable_extraction_writes_no_extracted_md(self, tmp_path):
|
|
|
|
|
fs = MemFS()
|
|
|
|
|
git = RecordingGitRm()
|
|
|
|
|
git_state = RecordingGitState(tracked=True, dirty=False)
|
|
|
|
|
entry = _lifecycle_entry(
|
|
|
|
|
"docs/old-guide.md", "extract-then-delete",
|
|
|
|
|
safety_tier="auto", extraction_complete=True,
|
|
|
|
|
extraction_target="repo-durable",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
result = _applier_ls(tmp_path, fs=fs, git=git, git_state=git_state).apply([entry])
|
|
|
|
|
|
|
|
|
|
assert result["applied"][0]["kind"] == "extract-then-delete"
|
|
|
|
|
assert fs.writes == {}
|
|
|
|
|
assert result["staged_paths"] == ["docs/old-guide.md"]
|
|
|
|
|
|
|
|
|
|
def test_no_extraction_target_writes_no_extracted_md(self, tmp_path):
|
|
|
|
|
"""Absence of extraction_target (or any value other than
|
|
|
|
|
"cross-repo") is treated as repo-durable — no pointer append."""
|
|
|
|
|
fs = MemFS()
|
|
|
|
|
git = RecordingGitRm()
|
|
|
|
|
git_state = RecordingGitState(tracked=True, dirty=False)
|
|
|
|
|
entry = _lifecycle_entry(
|
|
|
|
|
"docs/old-guide.md", "extract-then-delete",
|
|
|
|
|
safety_tier="auto", extraction_complete=True,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
result = _applier_ls(tmp_path, fs=fs, git=git, git_state=git_state).apply([entry])
|
|
|
|
|
|
|
|
|
|
assert result["applied"][0]["kind"] == "extract-then-delete"
|
|
|
|
|
assert fs.writes == {}
|
|
|
|
|
|
|
|
|
|
def test_failed_append_skips_the_delete(self, tmp_path):
|
|
|
|
|
extracted_path = str(tmp_path / "docs" / "extracted.md")
|
|
|
|
|
fs = WriteFailsFS(fail_path=extracted_path)
|
|
|
|
|
git = RecordingGitRm()
|
|
|
|
|
git_state = RecordingGitState(tracked=True, dirty=False)
|
|
|
|
|
entry = _lifecycle_entry(
|
|
|
|
|
"docs/graphify-clustering-notes.md", "extract-then-delete",
|
|
|
|
|
safety_tier="auto", extraction_complete=True,
|
|
|
|
|
extraction_target="cross-repo", extraction_pointer=self._pointer(),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
result = _applier_ls(tmp_path, fs=fs, git=git, git_state=git_state).apply([entry])
|
|
|
|
|
|
|
|
|
|
assert result["applied"] == []
|
|
|
|
|
assert git.rm_calls == []
|
|
|
|
|
assert result["skipped"][0]["reason"] == "extracted-md-append-failed"
|
|
|
|
|
|
|
|
|
|
def test_missing_pointer_metadata_skips_the_delete(self, tmp_path):
|
|
|
|
|
fs = MemFS()
|
|
|
|
|
git = RecordingGitRm()
|
|
|
|
|
git_state = RecordingGitState(tracked=True, dirty=False)
|
|
|
|
|
entry = _lifecycle_entry(
|
|
|
|
|
"docs/graphify-clustering-notes.md", "extract-then-delete",
|
|
|
|
|
safety_tier="auto", extraction_complete=True,
|
|
|
|
|
extraction_target="cross-repo",
|
|
|
|
|
# No extraction_pointer at all.
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
result = _applier_ls(tmp_path, fs=fs, git=git, git_state=git_state).apply([entry])
|
|
|
|
|
|
|
|
|
|
assert result["applied"] == []
|
|
|
|
|
assert git.rm_calls == []
|
|
|
|
|
assert result["skipped"][0]["reason"] == "extraction-pointer-invalid"
|
|
|
|
|
assert fs.writes == {}
|
|
|
|
|
|
|
|
|
|
def test_incomplete_pointer_metadata_skips_the_delete(self, tmp_path):
|
|
|
|
|
fs = MemFS()
|
|
|
|
|
git = RecordingGitRm()
|
|
|
|
|
git_state = RecordingGitState(tracked=True, dirty=False)
|
|
|
|
|
entry = _lifecycle_entry(
|
|
|
|
|
"docs/graphify-clustering-notes.md", "extract-then-delete",
|
|
|
|
|
safety_tier="auto", extraction_complete=True,
|
|
|
|
|
extraction_target="cross-repo",
|
|
|
|
|
extraction_pointer={"vault_note": "tool/x"}, # missing reason/date
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
result = _applier_ls(tmp_path, fs=fs, git=git, git_state=git_state).apply([entry])
|
|
|
|
|
|
|
|
|
|
assert result["applied"] == []
|
|
|
|
|
assert git.rm_calls == []
|
|
|
|
|
assert result["skipped"][0]["reason"] == "extraction-pointer-invalid"
|
|
|
|
|
assert fs.writes == {}
|
|
|
|
|
|
|
|
|
|
def test_extraction_complete_false_skips_before_any_pointer_logic(self, tmp_path):
|
|
|
|
|
"""extraction_complete: false still skips everything, even with a
|
|
|
|
|
fully-formed vault pointer present."""
|
|
|
|
|
fs = MemFS()
|
|
|
|
|
git = RecordingGitRm()
|
|
|
|
|
git_state = RecordingGitState(tracked=True, dirty=False)
|
|
|
|
|
entry = _lifecycle_entry(
|
|
|
|
|
"docs/graphify-clustering-notes.md", "extract-then-delete",
|
|
|
|
|
safety_tier="auto", extraction_complete=False,
|
|
|
|
|
extraction_target="cross-repo", extraction_pointer=self._pointer(),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
result = _applier_ls(tmp_path, fs=fs, git=git, git_state=git_state).apply([entry])
|
|
|
|
|
|
|
|
|
|
assert result["applied"] == []
|
|
|
|
|
assert git.rm_calls == []
|
|
|
|
|
assert result["skipped"][0]["reason"] == "extraction-not-confirmed"
|
|
|
|
|
assert fs.writes == {}
|
|
|
|
|
|
|
|
|
|
def test_root_level_file_uses_root_extracted_md(self, tmp_path):
|
|
|
|
|
fs = MemFS()
|
|
|
|
|
git = RecordingGitRm()
|
|
|
|
|
git_state = RecordingGitState(tracked=True, dirty=False)
|
|
|
|
|
entry = _lifecycle_entry(
|
|
|
|
|
"graphify-clustering-notes.md", "extract-then-delete",
|
|
|
|
|
safety_tier="auto", extraction_complete=True,
|
|
|
|
|
extraction_target="cross-repo", extraction_pointer=self._pointer(),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
result = _applier_ls(tmp_path, fs=fs, git=git, git_state=git_state).apply([entry])
|
|
|
|
|
|
|
|
|
|
assert result["applied"][0]["kind"] == "extract-then-delete"
|
|
|
|
|
extracted_path = str(tmp_path / "extracted.md")
|
|
|
|
|
assert extracted_path in fs.writes
|
|
|
|
|
assert "extracted.md" in result["staged_paths"]
|
|
|
|
|
|
|
|
|
|
|
2026-07-15 11:41:59 +00:00
|
|
|
class TestLifecycleConfirmTierGating:
|
|
|
|
|
"""Task 4.1(d): confirm-tier lifecycle entries never auto-apply silently."""
|
|
|
|
|
|
|
|
|
|
def test_confirm_tier_entry_only_applies_when_explicitly_indexed(self, tmp_path):
|
|
|
|
|
"""
|
|
|
|
|
Mirrors the existing --apply-indices gating convention: the applier
|
|
|
|
|
only ever touches entries the caller explicitly selected. A
|
|
|
|
|
confirm-tier lifecycle entry sitting in report.entries but NOT
|
|
|
|
|
passed via --apply-indices must never be applied.
|
|
|
|
|
"""
|
|
|
|
|
content = "line1\n"
|
|
|
|
|
file_bytes, sha = _make_file(content)
|
|
|
|
|
fs = MemFS({str(tmp_path / "doc.md"): file_bytes})
|
|
|
|
|
git = RecordingGitRm()
|
|
|
|
|
git_state = RecordingGitState(tracked=True, dirty=False)
|
|
|
|
|
|
|
|
|
|
report_entries = [
|
|
|
|
|
_entry("doc.md", "delete-range", sha=sha, start=1, end=1), # index 0, auto
|
|
|
|
|
_lifecycle_entry("docs/risky.md", "delete", safety_tier="confirm"), # index 1
|
|
|
|
|
]
|
|
|
|
|
report = {
|
|
|
|
|
"schema_version": "1.0", "tool_version": "0.1.0",
|
|
|
|
|
"generated_at": "2026-07-14T00:00:00+00:00",
|
|
|
|
|
"scan": {"project_root": str(tmp_path), "scope_globs": [], "excluded_dirs": [], "files_scanned": 0},
|
|
|
|
|
"shortlist": [], "entries": report_entries,
|
|
|
|
|
}
|
|
|
|
|
report_path = tmp_path / "report.json"
|
|
|
|
|
report_path.write_text(json.dumps(report))
|
|
|
|
|
|
|
|
|
|
# Caller (skill) only approved index 0 — the confirm-tier delete was
|
|
|
|
|
# never approved and must not be passed.
|
|
|
|
|
applier = _applier_ls(tmp_path, fs=fs, git=git, git_state=git_state)
|
|
|
|
|
indexed = [(0, report_entries[0])]
|
|
|
|
|
result = applier.apply_indexed(indexed)
|
|
|
|
|
|
|
|
|
|
assert result["applied"][0]["entry_index"] == 0
|
|
|
|
|
assert git.rm_calls == [] # confirm-tier delete never touched
|
|
|
|
|
|
|
|
|
|
def test_confirm_tier_entry_applies_once_explicitly_approved_and_indexed(self, tmp_path):
|
|
|
|
|
fs = MemFS()
|
|
|
|
|
git = RecordingGitRm()
|
|
|
|
|
git_state = RecordingGitState(tracked=True, dirty=False)
|
|
|
|
|
entry = _lifecycle_entry("docs/risky.md", "delete", safety_tier="confirm")
|
|
|
|
|
|
|
|
|
|
# Caller explicitly included this index after the user approved it
|
|
|
|
|
# in the batch-confirm prompt.
|
|
|
|
|
result = _applier_ls(tmp_path, fs=fs, git=git, git_state=git_state).apply_indexed(
|
|
|
|
|
[(1, entry)]
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert result["applied"][0]["entry_index"] == 1
|
|
|
|
|
assert result["applied"][0]["kind"] == "delete"
|
|
|
|
|
assert len(git.rm_calls) == 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestLifecycleDeleteIncompatibleWithOtherOps:
|
|
|
|
|
"""A lifecycle delete cannot be combined with any other op on the same path."""
|
|
|
|
|
|
|
|
|
|
def test_delete_combined_with_another_op_on_same_path_is_incompatible(self, tmp_path):
|
|
|
|
|
content = "line1\n"
|
|
|
|
|
file_bytes, sha = _make_file(content)
|
|
|
|
|
fs = MemFS({str(tmp_path / "doc.md"): file_bytes})
|
|
|
|
|
git = RecordingGitRm()
|
|
|
|
|
git_state = RecordingGitState(tracked=True, dirty=False)
|
|
|
|
|
|
|
|
|
|
entries = [
|
|
|
|
|
_lifecycle_entry("doc.md", "delete", safety_tier="auto"),
|
|
|
|
|
_entry("doc.md", "insert-frontmatter", extra={"key": "hygiene", "value": "frozen"}),
|
|
|
|
|
]
|
|
|
|
|
result = _applier_ls(tmp_path, fs=fs, git=git, git_state=git_state).apply(entries)
|
|
|
|
|
|
|
|
|
|
assert result["applied"] == []
|
|
|
|
|
reasons = {s["reason"] for s in result["skipped"]}
|
|
|
|
|
assert reasons == {"incompatible-ops-on-file"}
|
|
|
|
|
assert git.rm_calls == []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestUnknownKindStillRejectedAlongsideLifecycleKinds:
|
|
|
|
|
"""Regression: adding the lifecycle carve-out must not swallow real unknowns."""
|
|
|
|
|
|
|
|
|
|
def test_truly_unknown_kind_still_reported(self, tmp_path):
|
|
|
|
|
fs = MemFS()
|
|
|
|
|
entry = {
|
|
|
|
|
"path": "doc.md",
|
|
|
|
|
"op": "nuke it",
|
|
|
|
|
"op_type": "deterministic",
|
|
|
|
|
"exact_edit": {"kind": "nuke-it", "anchor": {"start_line": 1, "end_line": 1}},
|
|
|
|
|
}
|
|
|
|
|
result = _applier(tmp_path, fs=fs).apply([entry])
|
|
|
|
|
|
|
|
|
|
assert result["skipped"][0]["reason"] == "unknown-kind"
|