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

958 lines
38 KiB
Python

#!/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())