cc-os/plugins/os-doc-hygiene/scripts/patch_applier.py

958 lines
38 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
"""
patch_applier.py deterministic patch applier for doc-hygiene clean skill.
Consumes a filtered list of DETERMINISTIC, APPROVED report entries and applies
them to the project files. The applier NEVER sees generative entries and NEVER
makes approval decisions those gates belong to the skill.
Safety invariants honoured:
#6 No model — purely mechanical transformations.
#7 Safety-tier values come from KIND_TABLE, not entries.
#8 mtime guard — sha256 of whole-file bytes verified before any write;
content changed since check skip entire file, continue others.
Per-file transaction read once / verify / apply descending / write once.
Incompatible-ops detection move-to-archive + anything else, or overlapping
anchors skip whole file, structured report.
CLI:
python patch_applier.py --report <path> --apply-indices 0,2,3 \\
[--project-root <path>]
--report Path to the machine-report JSON produced by report_builder.
--apply-indices Comma-separated 0-based indices into report.entries[] to
apply. The skill is responsible for filtering to only
deterministic, approved indices before calling here.
--project-root Override project root (default: report.scan.project_root).
Exit codes:
0 all attempted entries applied successfully.
1 partial: at least one entry was skipped or failed (check output JSON).
2 usage / internal error (malformed args, missing report file, bad JSON).
Output (stdout, JSON):
{
"applied": [{path, kind, entry_index}],
"skipped": [{path, kind, entry_index, reason, recommend}],
"failed": [{path, kind, entry_index, error}],
"staged_paths": [<paths the caller should git-add; move-to-archive already
staged by git mv>]
}
Git responsibility split:
- move-to-archive: applier runs `git mv src dest` (stages both sides).
dest_path is included in staged_paths as an informational record.
- all other kinds: applier writes the file; the skill does `git add <path>`
using staged_paths to know exactly which files to stage.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import subprocess
import sys
import tempfile
from pathlib import Path
from typing import Any, Optional, Protocol
_SCRIPTS_DIR = Path(__file__).resolve().parent
if str(_SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(_SCRIPTS_DIR))
from validate_report import KIND_TABLE # noqa: E402
# ---------------------------------------------------------------------------
# Injectable seams
# ---------------------------------------------------------------------------
class _FileSystem(Protocol):
"""Minimal FS surface used by PatchApplier — injectable for tests."""
def read_bytes(self, path: Path) -> bytes: ...
def write_bytes(self, path: Path, data: bytes) -> None: ...
def exists(self, path: Path) -> bool: ...
def mkdir(self, path: Path) -> None: ...
class RealFileSystem:
"""Production filesystem — delegates directly to pathlib."""
def read_bytes(self, path: Path) -> bytes:
return Path(path).read_bytes()
def write_bytes(self, path: Path, data: bytes) -> None:
# Atomic write: write to sibling temp, fsync, os.replace.
parent = Path(path).parent
fd, tmp = tempfile.mkstemp(dir=parent)
try:
os.write(fd, data)
os.fsync(fd)
finally:
os.close(fd)
os.replace(tmp, path)
def exists(self, path: Path) -> bool:
return Path(path).exists()
def mkdir(self, path: Path) -> None:
Path(path).mkdir(parents=True, exist_ok=True)
class _Git(Protocol):
"""Git surface — injectable for tests."""
def mv(self, src: Path, dest: Path, cwd: Path) -> None: ...
def rm(self, path: Path, cwd: Path, recursive: bool = False) -> None: ...
class RealGit:
"""Production git — runs `git mv`/`git rm` via subprocess."""
def mv(self, src: Path, dest: Path, cwd: Path) -> None:
result = subprocess.run(
["git", "mv", str(src), str(dest)],
cwd=str(cwd),
capture_output=True,
text=True,
)
if result.returncode != 0:
raise RuntimeError(
f"git mv failed ({result.returncode}): {result.stderr.strip()}"
)
def rm(self, path: Path, cwd: Path, recursive: bool = False) -> None:
cmd = ["git", "rm"]
if recursive:
cmd.append("-r")
cmd.append(str(path))
result = subprocess.run(
cmd,
cwd=str(cwd),
capture_output=True,
text=True,
)
if result.returncode != 0:
raise RuntimeError(
f"git rm failed ({result.returncode}): {result.stderr.strip()}"
)
class _GitState(Protocol):
"""Runtime git tracked/dirty query surface — injectable for tests.
Used ONLY to re-verify lifecycle delete/extract-then-delete entries at
apply time (ADR-0039): the applier never trusts a report's cached tier
or git_state field for these kinds it re-asks git, per path, right
before applying.
"""
def is_tracked(self, path: str, cwd: Path) -> bool: ...
def is_dirty(self, path: str, cwd: Path) -> bool: ...
class RealGitState:
"""Production git-state checker — `git ls-files` + `git status --porcelain`."""
def is_tracked(self, path: str, cwd: Path) -> bool:
result = subprocess.run(
["git", "ls-files", "--", path],
cwd=str(cwd),
capture_output=True,
text=True,
)
return bool(result.stdout.strip())
def is_dirty(self, path: str, cwd: Path) -> bool:
result = subprocess.run(
["git", "status", "--porcelain", "--", path],
cwd=str(cwd),
capture_output=True,
text=True,
)
return bool(result.stdout.strip())
# ---------------------------------------------------------------------------
# Result records
# ---------------------------------------------------------------------------
def _applied(path: str, kind: str, entry_index: int) -> dict:
return {"path": path, "kind": kind, "entry_index": entry_index}
def _skipped(path: str, kind: str, entry_index: int, reason: str, recommend: str) -> dict:
return {
"path": path,
"kind": kind,
"entry_index": entry_index,
"reason": reason,
"recommend": recommend,
}
def _failed(path: str, kind: str, entry_index: int, error: str) -> dict:
return {
"path": path,
"kind": kind,
"entry_index": entry_index,
"error": error,
}
# ---------------------------------------------------------------------------
# Frontmatter helpers
# ---------------------------------------------------------------------------
def _parse_frontmatter(text: str) -> tuple[dict, int]:
"""
Parse YAML frontmatter from *text*.
Returns (kv_dict, end_line) where end_line is the 0-based line index
AFTER the closing '---' (i.e. the first non-frontmatter line index).
Returns ({}, 0) if no frontmatter is present.
"""
lines = text.splitlines(keepends=True)
if not lines or lines[0].rstrip() != "---":
return {}, 0
kv: dict[str, str] = {}
for i in range(1, len(lines)):
line = lines[i].rstrip()
if line == "---":
return kv, i + 1
if ":" in line:
k, _, v = line.partition(":")
kv[k.strip()] = v.strip()
# Unclosed frontmatter — treat as absent.
return {}, 0
def _insert_frontmatter_key(text: str, key: str, value: str) -> str:
"""
Insert ``key: value`` into the YAML frontmatter of *text*.
- If frontmatter exists: insert the key as the LAST entry inside the block.
- If no frontmatter: create a minimal ``---\\nkey: value\\n---`` block at top.
"""
lines = text.splitlines(keepends=True)
if lines and lines[0].rstrip() == "---":
# Find closing '---'.
for i in range(1, len(lines)):
if lines[i].rstrip() == "---":
new_line = f"{key}: {value}\n"
lines.insert(i, new_line)
return "".join(lines)
# Unclosed — append key before treating remainder as body.
# Append inside the unclosed block (at end).
lines.append(f"{key}: {value}\n")
return "".join(lines)
else:
header = f"---\n{key}: {value}\n---\n"
return header + text
# ---------------------------------------------------------------------------
# Core line-manipulation helpers (1-based inclusive)
# ---------------------------------------------------------------------------
def _delete_lines(lines: list[str], start: int, end: int) -> list[str]:
"""Remove inclusive 1-based line range [start, end] from *lines*."""
s = max(0, start - 1)
e = min(len(lines), end)
return lines[:s] + lines[e:]
def _replace_in_span(
lines: list[str], start: int, end: int, match: str, replacement: str
) -> tuple[list[str], bool]:
"""
Replace the FIRST occurrence of *match* in the inclusive 1-based span.
Returns (new_lines, found) where found=False means the match was absent
(treated as no-op success by the caller).
"""
s = max(0, start - 1)
e = min(len(lines), end)
found = False
for i in range(s, e):
if match in lines[i]:
lines[i] = lines[i].replace(match, replacement, 1)
found = True
break
return lines, found
def _ranges_overlap(a_start: int, a_end: int, b_start: int, b_end: int) -> bool:
"""Return True if two inclusive 1-based ranges overlap."""
return a_start <= b_end and b_start <= a_end
# ---------------------------------------------------------------------------
# Lifecycle deletion kinds (delete / extract-then-delete)
# ---------------------------------------------------------------------------
# These two kinds are NOT looked up in KIND_TABLE (owned by report_builder.py /
# validate_report.py) — they carry no anchor and are handled as their own
# atomic per-path operation, mirroring move-to-archive's special-casing.
_LIFECYCLE_DELETE_KINDS = {"delete", "extract-then-delete"}
# exact_edit.extraction_target value ("cross-repo") that means the content
# physically left the repo (vault write via /os-vault:write) and therefore
# needs a discoverable extracted.md pointer. The other valid value,
# "repo-durable" (ADR/CLAUDE.md/docs residue), is already discoverable
# in-repo and gets no index entry (lifecycle-spec §1). This field is part
# of the frozen machine-report schema (validate_report.py KIND_TABLE
# required_fields for extract-then-delete) — reused here rather than
# duplicated under a new name.
_VAULT_EXTRACTION_TARGET = "cross-repo"
_POINTER_REQUIRED_FIELDS = ("vault_note", "reason", "date")
def _extracted_md_relpath(path: str) -> str:
"""Return the `extracted.md` path in the same directory as *path*."""
parent = os.path.dirname(path)
return f"{parent}/extracted.md" if parent else "extracted.md"
def _valid_extraction_pointer(pointer: Any) -> bool:
"""True iff *pointer* has non-empty vault_note/reason/date strings."""
if not isinstance(pointer, dict):
return False
for key in _POINTER_REQUIRED_FIELDS:
value = pointer.get(key)
if not isinstance(value, str) or not value.strip():
return False
return True
def _format_extracted_pointer(pointer: dict, source_filename: str) -> str:
"""Format the extracted.md pointer line per lifecycle-spec §1:
- [[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)
"""
vault_note = pointer["vault_note"]
reason = pointer["reason"]
date = pointer["date"]
return (
f"- [[vault: {vault_note}]] — {reason} "
f"(extracted from `{source_filename}`, {date})"
)
# ---------------------------------------------------------------------------
# PatchApplier
# ---------------------------------------------------------------------------
class PatchApplier:
"""Apply a batch of deterministic report entries to project files.
Parameters
----------
project_root:
Absolute path to the project root. All entry paths are relative to it.
fs:
Injected filesystem. Defaults to RealFileSystem().
git:
Injected git runner. Defaults to RealGit().
"""
def __init__(
self,
project_root: Path,
fs: Optional[_FileSystem] = None,
git: Optional[_Git] = None,
git_state: Optional[_GitState] = None,
) -> None:
self._root = Path(project_root)
self._fs = fs or RealFileSystem()
self._git = git or RealGit()
self._git_state = git_state or RealGitState()
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def apply(self, entries: list[dict]) -> dict:
"""Apply *entries* (already filtered to deterministic, approved).
Entry indices in output records are 0-based positions within *entries*.
Use apply_indexed() when original report indices must be preserved.
Returns a result dict with keys: applied, skipped, failed, staged_paths.
"""
indexed = list(enumerate(entries))
return self.apply_indexed(indexed)
def apply_indexed(self, indexed_entries: list[tuple[int, dict]]) -> dict:
"""Apply entries given as (original_report_index, entry) pairs.
This preserves provenance: output records carry the original report
index rather than a position in the caller's filtered list.
Returns a result dict with keys: applied, skipped, failed, staged_paths.
"""
applied: list[dict] = []
skipped: list[dict] = []
failed: list[dict] = []
staged_paths: list[str] = []
# Group by path (preserving original indices).
by_path: dict[str, list[tuple[int, dict]]] = {}
for idx, entry in indexed_entries:
path = entry.get("path", "")
by_path.setdefault(path, []).append((idx, entry))
for path, batch in by_path.items():
a, sk, fa, sp = self._apply_file_batch(path, batch)
applied.extend(a)
skipped.extend(sk)
failed.extend(fa)
staged_paths.extend(sp)
return {
"applied": applied,
"skipped": skipped,
"failed": failed,
"staged_paths": staged_paths,
}
# ------------------------------------------------------------------
# Per-file transaction
# ------------------------------------------------------------------
def _apply_file_batch(
self, path: str, batch: list[tuple[int, dict]]
) -> tuple[list, list, list, list]:
"""Process all entries for a single file as ONE atomic transaction."""
applied: list[dict] = []
skipped_out: list[dict] = []
failed_out: list[dict] = []
staged: list[str] = []
abs_path = self._root / path
# Step 0: Validate all entries have valid / deterministic kinds.
# Check generative FIRST (no exact_edit, so kind lookup would be empty).
# Collect per-entry failures; any failure skips the WHOLE file (all
# entries reported), so valid siblings are never silently dropped.
step0_failures: list[dict] = []
for idx, entry in batch:
if entry.get("op_type") == "generative":
step0_failures.append(_skipped(
path, "(generative)", idx,
"generative-entry-rejected",
"Applier only processes deterministic entries.",
))
continue
ee = entry.get("exact_edit", {})
kind = ee.get("kind", "")
if kind in _LIFECYCLE_DELETE_KINDS:
# Handled by _apply_lifecycle_delete below, not KIND_TABLE.
continue
if kind not in KIND_TABLE:
step0_failures.append(_skipped(
path, kind or "(unknown)", idx,
"unknown-kind",
"Remove this entry or correct the kind field.",
))
if step0_failures:
# Report the bad entries with their specific reasons.
# Report valid siblings as skipped too (batch-aborted reason) so
# the skill knows no entry on this file was applied.
bad_indices = {s["entry_index"] for s in step0_failures}
skipped_out.extend(step0_failures)
for idx, entry in batch:
if idx not in bad_indices:
ee = entry.get("exact_edit", {})
kind = ee.get("kind", "(unknown)")
skipped_out.append(_skipped(
path, kind, idx,
"batch-aborted-due-to-invalid-sibling",
"Fix or remove the invalid entry in this batch and re-apply.",
))
return applied, skipped_out, failed_out, staged
# Step 0b: lifecycle delete / extract-then-delete — handled as their
# own atomic per-path operation (no anchor, no KIND_TABLE lookup).
lifecycle_batch = [
(i, e) for i, e in batch
if e.get("exact_edit", {}).get("kind") in _LIFECYCLE_DELETE_KINDS
]
if lifecycle_batch:
if len(batch) > 1:
for idx, entry in batch:
kind = entry.get("exact_edit", {}).get("kind", "(unknown)")
skipped_out.append(_skipped(
path, kind, idx,
"incompatible-ops-on-file",
"delete/extract-then-delete cannot be combined with "
"other ops on the same path; re-run check to re-classify.",
))
return applied, skipped_out, failed_out, staged
idx, entry = lifecycle_batch[0]
return self._apply_lifecycle_delete(path, idx, entry)
# Step 1: Incompatible-ops detection (structural — before any IO).
incompatibility = self._check_incompatible_ops(path, batch)
if incompatibility:
reason, recommend = incompatibility
for idx, entry in batch:
kind = entry["exact_edit"]["kind"]
skipped_out.append(_skipped(path, kind, idx, reason, recommend))
return applied, skipped_out, failed_out, staged
# Separate anchored and anchorless entries.
anchored = [(i, e) for i, e in batch if KIND_TABLE[e["exact_edit"]["kind"]].has_anchor]
anchorless = [(i, e) for i, e in batch if not KIND_TABLE[e["exact_edit"]["kind"]].has_anchor]
# --- move-to-archive (special: git mv, no in-memory editing) ---
move_entries = [(i, e) for i, e in anchored if e["exact_edit"]["kind"] == "move-to-archive"]
if move_entries:
# Incompatibility detection already ensured only one entry for this file
# if move-to-archive is present. There should be exactly one.
idx, entry = move_entries[0]
ee = entry["exact_edit"]
dest_rel = ee["dest_path"]
dest_abs = self._root / dest_rel
# Idempotency: source already gone + dest exists → already done.
if not self._fs.exists(abs_path) and self._fs.exists(dest_abs):
applied.append(_applied(path, "move-to-archive", idx))
staged.append(dest_rel)
return applied, skipped_out, failed_out, staged
# Verify hash before moving.
try:
file_bytes = self._fs.read_bytes(abs_path)
except OSError as exc:
failed_out.append(_failed(path, "move-to-archive", idx, str(exc)))
return applied, skipped_out, failed_out, staged
expected = ee.get("expected_sha256", "")
actual = hashlib.sha256(file_bytes).hexdigest()
if actual != expected:
skipped_out.append(_skipped(
path, "move-to-archive", idx,
"content-changed-since-check",
"Re-run hygiene check to refresh the report.",
))
return applied, skipped_out, failed_out, staged
# Create destination directory.
try:
self._fs.mkdir(dest_abs.parent)
self._git.mv(abs_path, dest_abs, self._root)
except Exception as exc: # noqa: BLE001
failed_out.append(_failed(path, "move-to-archive", idx, str(exc)))
return applied, skipped_out, failed_out, staged
applied.append(_applied(path, "move-to-archive", idx))
staged.append(dest_rel)
return applied, skipped_out, failed_out, staged
# --- non-move anchored entries ---
# Step 2: Read file once.
try:
file_bytes = self._fs.read_bytes(abs_path)
except OSError as exc:
for idx, entry in batch:
kind = entry["exact_edit"]["kind"]
failed_out.append(_failed(path, kind, idx, str(exc)))
return applied, skipped_out, failed_out, staged
# Step 3: Verify sha256 for EVERY anchored entry against the current
# whole-file bytes. All entries from the same check run share the
# same hash, but entries constructed from different runs may carry
# different hashes — verify each one and reject any mismatch.
if anchored:
actual_sha = hashlib.sha256(file_bytes).hexdigest()
mismatched = [
(idx, e) for idx, e in anchored
if e["exact_edit"].get("expected_sha256", "") != actual_sha
]
if mismatched:
for idx, entry in batch:
kind = entry["exact_edit"]["kind"]
skipped_out.append(_skipped(
path, kind, idx,
"content-changed-since-check",
"Re-run hygiene check to refresh the report.",
))
return applied, skipped_out, failed_out, staged
# Step 4: Decode file strictly — errors="replace" would silently rewrite
# non-UTF8 bytes that the user never asked to touch, bypassing the hash
# guard. Skip the file instead (hash verified above, so this is rare).
try:
text = file_bytes.decode("utf-8")
except UnicodeDecodeError:
for idx, entry in batch:
kind = entry["exact_edit"]["kind"]
skipped_out.append(_skipped(
path, kind, idx,
"non-utf8-content",
"File contains non-UTF-8 bytes; manual editing required.",
))
return applied, skipped_out, failed_out, staged
lines = text.splitlines(keepends=True)
sorted_anchored = sorted(
anchored,
key=lambda t: t[1]["exact_edit"]["anchor"]["start_line"],
reverse=True,
)
for idx, entry in sorted_anchored:
ee = entry["exact_edit"]
kind = ee["kind"]
start = ee["anchor"]["start_line"]
end = ee["anchor"]["end_line"]
try:
if kind == "delete-range":
lines = _delete_lines(lines, start, end)
applied.append(_applied(path, kind, idx))
elif kind == "replace-text":
match = ee["match"]
replacement = ee["replacement"]
lines, found = _replace_in_span(lines, start, end, match, replacement)
# match absent → no-op success (already applied or match moved).
applied.append(_applied(path, kind, idx))
elif kind == "dedupe":
lines = _delete_lines(lines, start, end)
applied.append(_applied(path, kind, idx))
else:
# Should not reach here (incompatibility + kind checks above).
skipped_out.append(_skipped(
path, kind, idx,
f"unexpected-kind-in-anchored-pass:{kind}",
"Internal error — re-run.",
))
except Exception as exc: # noqa: BLE001
failed_out.append(_failed(path, kind, idx, str(exc)))
# Step 5: Apply insert-frontmatter entries LAST (prepend shifts all lines).
for idx, entry in anchorless:
ee = entry["exact_edit"]
kind = ee["kind"]
key = ee["key"]
value = ee["value"]
try:
# Re-derive freshness at apply time: re-read current in-memory text.
current_text = "".join(lines)
fm_kv, _ = _parse_frontmatter(current_text)
if key in fm_kv:
if fm_kv[key] == str(value):
# Idempotent: key already has target value.
applied.append(_applied(path, kind, idx))
else:
# Key present with DIFFERENT value — never overwrite.
skipped_out.append(_skipped(
path, kind, idx,
"frontmatter-key-conflict",
f"Key '{key}' already set to '{fm_kv[key]}'; manual resolution required.",
))
else:
new_text = _insert_frontmatter_key(current_text, key, value)
lines = new_text.splitlines(keepends=True)
applied.append(_applied(path, kind, idx))
except Exception as exc: # noqa: BLE001
failed_out.append(_failed(path, kind, idx, str(exc)))
# Step 6: Write file ONCE (only if anything changed — i.e. no pure no-ops
# made it to applied without actually changing lines).
# We always write when we have applied entries that modified lines,
# or inserted frontmatter. The simplest correct approach: write if
# there are any non-failed, non-skipped anchored/anchorless entries.
if applied:
new_bytes = "".join(lines).encode("utf-8")
if new_bytes != file_bytes:
try:
self._fs.write_bytes(abs_path, new_bytes)
staged.append(path)
except OSError as exc:
# Demote all applied to failed since write did not succeed.
for rec in applied:
failed_out.append(_failed(
rec["path"], rec["kind"], rec["entry_index"], str(exc)
))
applied.clear()
return applied, skipped_out, failed_out, staged
# ------------------------------------------------------------------
# Lifecycle delete / extract-then-delete (ADR-0039)
# ------------------------------------------------------------------
def _apply_lifecycle_delete(
self, path: str, idx: int, entry: dict
) -> tuple[list, list, list, list]:
"""Apply a single `delete` or `extract-then-delete` entry.
Re-verifies live git tracked/clean state at apply time whenever the
entry's cached `safety_tier` is `auto` — never trusting the report's
cached tier or `lifecycle.git_state` (ADR-0039). A `confirm`-tier
entry was already gated through explicit human approval upstream
(doc-clean's batch-confirm), so its tracked/dirty state is expected
and is NOT re-verified here only auto verdicts get downgraded.
`extract-then-delete` additionally fails closed per-entry: the git rm
only happens once the caller (clean skill / subagent) has marked the
extraction step complete via `extraction_complete: true` on the
entry. This applier never performs the generative extraction itself.
When `exact_edit.extraction_target == "cross-repo"` (the vault case
content physically left the repo via `/os-vault:write`), the git rm
additionally requires a valid top-level `extraction_pointer` dict
(`vault_note`, `reason`, `date`, all non-empty strings), and a pointer
line is appended to the deleted file's directory `extracted.md`
(creating it if absent) BEFORE the git rm, staged into the same
commit. `extraction_target == "repo-durable"` (ADR/CLAUDE.md/docs
residue) writes no index entry it is already discoverable in-repo.
A failed append (or missing/invalid pointer metadata) skips the
delete, same fail-closed semantics as an unconfirmed extraction.
Directory-rule aggregate entries (`exact_edit.is_directory: true`)
bypass the content-hash guard entirely (no single file to hash) but
NEVER bypass the git-state re-verification.
"""
applied: list[dict] = []
skipped_out: list[dict] = []
failed_out: list[dict] = []
staged: list[str] = []
ee = entry.get("exact_edit", {})
kind = ee.get("kind", "")
is_directory = bool(ee.get("is_directory", False))
abs_path = self._root / path
tier = entry.get("safety_tier")
if tier == "auto":
tracked = self._git_state.is_tracked(path, self._root)
dirty = self._git_state.is_dirty(path, self._root) if tracked else False
if not tracked or dirty:
skipped_out.append(_skipped(
path, kind, idx,
"git-state-changed-since-check",
"Path is no longer tracked+clean since the check ran; "
"re-run hygiene check to re-verify before deleting.",
))
return applied, skipped_out, failed_out, staged
if kind == "extract-then-delete" and not entry.get("extraction_complete", False):
skipped_out.append(_skipped(
path, kind, idx,
"extraction-not-confirmed",
"Extraction step has not been marked complete; the delete "
"is withheld for this entry (fails closed, not a hard failure).",
))
return applied, skipped_out, failed_out, staged
# extract-then-delete, vault destination: append a discoverable
# pointer to the deleted file's directory `extracted.md` BEFORE the
# git rm, so there is never a window where the doc is gone but
# undiscoverable (lifecycle-spec §1 / design decision #7). Repo-
# durable destinations (ADR/CLAUDE.md/docs, or no destination at all)
# are already discoverable in-repo and get no index entry.
if (
kind == "extract-then-delete"
and ee.get("extraction_target") == _VAULT_EXTRACTION_TARGET
):
pointer = entry.get("extraction_pointer")
if not _valid_extraction_pointer(pointer):
skipped_out.append(_skipped(
path, kind, idx,
"extraction-pointer-invalid",
"extraction_target is \"cross-repo\" (vault) but "
"extraction_pointer is missing or incomplete (needs "
"vault_note, reason, date); the delete is withheld so the "
"doc is never gone-but-unindexed.",
))
return applied, skipped_out, failed_out, staged
extracted_rel = _extracted_md_relpath(path)
extracted_abs = self._root / extracted_rel
pointer_line = _format_extracted_pointer(pointer, Path(path).name)
try:
existing = (
self._fs.read_bytes(extracted_abs)
if self._fs.exists(extracted_abs) else b""
)
if existing and not existing.endswith(b"\n"):
existing += b"\n"
self._fs.write_bytes(
extracted_abs, existing + pointer_line.encode("utf-8") + b"\n"
)
except Exception as exc: # noqa: BLE001
skipped_out.append(_skipped(
path, kind, idx,
"extracted-md-append-failed",
f"Could not append pointer to {extracted_rel}: {exc}; the "
"delete is withheld so the doc is never gone-but-unindexed.",
))
return applied, skipped_out, failed_out, staged
staged.append(extracted_rel)
try:
self._git.rm(abs_path, self._root, recursive=is_directory)
except Exception as exc: # noqa: BLE001
failed_out.append(_failed(path, kind, idx, str(exc)))
return applied, skipped_out, failed_out, staged
applied.append(_applied(path, kind, idx))
staged.append(path)
return applied, skipped_out, failed_out, staged
# ------------------------------------------------------------------
# Incompatible-ops detection
# ------------------------------------------------------------------
def _check_incompatible_ops(
self, path: str, batch: list[tuple[int, dict]]
) -> Optional[tuple[str, str]]:
"""
Detect structurally incompatible combinations on a single file.
Returns (reason, recommend) if incompatible, None otherwise.
Incompatible combinations:
1. move-to-archive combined with ANY other op (file leaves; targets vanish).
2. Overlapping anchor ranges between any two anchored entries.
"""
has_move = any(e["exact_edit"]["kind"] == "move-to-archive" for _, e in batch)
if has_move and len(batch) > 1:
return (
"incompatible-ops-on-file",
"move-to-archive cannot be combined with other ops; re-run check to re-classify.",
)
# Check overlapping anchor ranges.
anchored_ranges: list[tuple[int, int, int]] = [] # (start, end, idx)
for idx, entry in batch:
ee = entry["exact_edit"]
kind = ee["kind"]
if KIND_TABLE[kind].has_anchor:
start = ee["anchor"]["start_line"]
end = ee["anchor"]["end_line"]
for a_start, a_end, a_idx in anchored_ranges:
if _ranges_overlap(start, end, a_start, a_end):
return (
"incompatible-ops-on-file",
f"Overlapping anchor ranges (entries {a_idx} and {idx}); re-run check.",
)
anchored_ranges.append((start, end, idx))
return None
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
class _UsageError(Exception):
"""Signals a CLI usage / IO error (exit code 2). Caught by main()."""
def _load_report(path: str) -> dict:
try:
with open(path, "r", encoding="utf-8") as fh:
return json.load(fh)
except FileNotFoundError:
print(json.dumps({"error": f"Report file not found: {path}"}), file=sys.stderr)
raise _UsageError(f"Report file not found: {path}")
except json.JSONDecodeError as exc:
print(json.dumps({"error": f"Invalid JSON in report: {exc}"}), file=sys.stderr)
raise _UsageError(f"Invalid JSON in report: {exc}")
def _parse_indices(raw: str) -> list[int]:
"""Parse '0,2,3' → [0, 2, 3]. Raises _UsageError on bad input."""
if not raw.strip():
return []
try:
return [int(x.strip()) for x in raw.split(",")]
except ValueError:
print(json.dumps({"error": f"Invalid --apply-indices value: {raw!r}"}), file=sys.stderr)
raise _UsageError(f"Invalid --apply-indices value: {raw!r}")
def main(argv: Optional[list] = None) -> int:
try:
return _main_inner(argv)
except _UsageError:
return 2
def _main_inner(argv: Optional[list] = None) -> int:
parser = argparse.ArgumentParser(
description="doc-hygiene patch applier — deterministic file patching, no model."
)
parser.add_argument(
"--report", required=True,
help="Path to the machine-report JSON (output of report_builder).",
)
parser.add_argument(
"--apply-indices", required=True,
help="Comma-separated 0-based indices into report.entries[] to apply.",
)
parser.add_argument(
"--project-root", default=None,
help="Override project root (default: report.scan.project_root).",
)
args = parser.parse_args(argv)
report = _load_report(args.report)
entries = report.get("entries", [])
if not isinstance(entries, list):
msg = "report.entries must be an array"
print(json.dumps({"error": msg}), file=sys.stderr)
raise _UsageError(msg)
indices = _parse_indices(args.apply_indices)
max_idx = len(entries) - 1
invalid = [i for i in indices if i < 0 or (len(entries) == 0) or i > max_idx]
if invalid:
msg = f"Index/indices out of range: {invalid}; report has {len(entries)} entries"
print(json.dumps({"error": msg}), file=sys.stderr)
raise _UsageError(msg)
selected = [entries[i] for i in indices]
project_root_str = args.project_root or report.get("scan", {}).get("project_root")
if not project_root_str:
msg = "Cannot determine project_root; pass --project-root"
print(json.dumps({"error": msg}), file=sys.stderr)
raise _UsageError(msg)
project_root = Path(project_root_str)
applier = PatchApplier(project_root=project_root)
# Build indexed pairs so output entry_index is the original report index.
indexed_pairs = list(zip(indices, selected))
result = applier.apply_indexed(indexed_pairs)
print(json.dumps(result, indent=2))
has_problems = bool(result["skipped"] or result["failed"])
return 1 if has_problems else 0
if __name__ == "__main__":
sys.exit(main())