#!/usr/bin/env python3 """ report_builder.py — model-free finalize pass for the doc-hygiene `check` skill. This is the deterministic step BETWEEN model classification and `StateStore.write_report` (design D1). The model supplies slim per-file classification PROPOSALS (judgment only); this assembler fills the four fields the model must NOT author and emits a full schema-valid machine report plus a deterministic human-report skeleton. The four guardrail fields owned here (never model-authored): - exact_edit.expected_sha256 — sha256 of the CURRENT whole file bytes - is_destructive / is_reversible (deterministic ops) — from KIND_TABLE[kind] - safety_tier — derive_safety_tier(...) imported from validate_report.py (single source of truth) - token_estimate.raw_tokens — from the local token estimator (invariant #6) Design references: - openspec/changes/add-check/design.md (D1, D3 step 4, Risks) - openspec/specs/report-schema/spec.md (the frozen contract) Key decisions (see design + advisor notes): * expected_sha256 is the hash of the WHOLE file bytes, not the anchored span. The anchors are LINE NUMBERS; an edit outside the span would silently shift the anchored lines, so the guard must cover the whole file (matches the spec's "content hash" wording for the mtime guard). * Per-entry exact_edit.generated_at = the instant THIS file's hash was taken (injected clock). It exists iff expected_sha256 exists — i.e. for anchor-bearing kinds only. insert-frontmatter (has_anchor=False) and generative entries carry neither. (insert-frontmatter is the one span case the design did not specify; raw_tokens is counted over the whole file — a documented deviation.) * tool_version is read from .claude-plugin/plugin.json (never hardcoded). Usage (CLI): python report_builder.py --scan --proposals \\ [--out-json ] [--out-md ] # paths default to stdin (a single JSON object {"scan":..,"proposals":..}) # and stdout (a JSON object {"machine_report":.., "human_report":..}). Exit codes: 0 — built successfully 1 — malformed proposal (structured error on stderr) 2 — usage / IO error """ from __future__ import annotations import argparse import hashlib import json import subprocess import sys from datetime import datetime, timezone from pathlib import Path from typing import Any, Callable, Optional, Protocol # --------------------------------------------------------------------------- # Import the single source of truth for safety-tier + kind table. # --------------------------------------------------------------------------- _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, derive_safety_tier # noqa: E402 from token_estimator import default_estimator, TokenEstimator # noqa: E402 SCHEMA_VERSION = "1.0" _PLUGIN_JSON = _SCRIPTS_DIR.parent / ".claude-plugin" / "plugin.json" _CONVENTIONS_JSON = _SCRIPTS_DIR.parent / "conventions.json" _VALID_OP_TYPES = ("deterministic", "generative") _STALE_SUBTYPES = {"contradicted", "orphaned", "superseded", "provisional", "completed-in-place", "duplicated"} _BLOAT_SUBTYPES = {"distill", "split", "freeze"} # --------------------------------------------------------------------------- # Errors # --------------------------------------------------------------------------- class MalformedProposalError(ValueError): """A classification proposal is structurally invalid (rejected pre-compute).""" def __init__(self, index: int, field: str, message: str) -> None: self.index = index self.field = field self.message = message super().__init__(f"proposal[{index}].{field}: {message}") def as_dict(self) -> dict: return {"index": self.index, "field": self.field, "message": self.message} # --------------------------------------------------------------------------- # Injectable seams: clock + file reader # --------------------------------------------------------------------------- class _Clock(Protocol): def now(self) -> datetime: ... class RealClock: """Production clock — UTC now().""" def now(self) -> datetime: return datetime.now(timezone.utc) class _FileReader(Protocol): def read_bytes(self, path: Path) -> bytes: ... def read_text(self, path: Path) -> str: ... class RealFileReader: """Production reader — raw bytes for hashing, decoded text for spans.""" def read_bytes(self, path: Path) -> bytes: return Path(path).read_bytes() def read_text(self, path: Path) -> str: return Path(path).read_text(encoding="utf-8", errors="replace") class _GitStateProvider(Protocol): def state(self, root: Path, rel_path: str) -> dict: ... class RealGitStateProvider: """Production git-state provider — `git ls-files` (tracked) + `git status --porcelain` (dirty), scoped to a single path. Advisory-fresh at report-build time; `patch_applier.py` re-verifies at apply time (design Decision 3 / ADR-0039 — never trust the rule's own claim).""" def state(self, root: Path, rel_path: str) -> dict: try: ls = subprocess.run( ["git", "ls-files", "--error-unmatch", "--", rel_path], cwd=str(root), capture_output=True, text=True, ) tracked = ls.returncode == 0 and bool(ls.stdout.strip()) except OSError: tracked = False if not tracked: return {"tracked": False, "clean": False} try: status = subprocess.run( ["git", "status", "--porcelain", "--", rel_path], cwd=str(root), capture_output=True, text=True, ) clean = status.returncode == 0 and status.stdout.strip() == "" except OSError: clean = False return {"tracked": True, "clean": clean} _LIFECYCLE_OP_KINDS = ("delete", "extract-then-delete") def _iso(dt: datetime) -> str: """Serialise a datetime as ISO-8601 UTC.""" if dt.tzinfo is None: dt = dt.replace(tzinfo=timezone.utc) return dt.isoformat() def _read_tool_version() -> tuple[str, bool]: """Return (tool_version, fell_back). Reads .claude-plugin/plugin.json.""" try: data = json.loads(_PLUGIN_JSON.read_text(encoding="utf-8")) version = data.get("version") if isinstance(version, str) and version: return version, False except (OSError, json.JSONDecodeError): pass return "0.0.0", True # --------------------------------------------------------------------------- # Report builder # --------------------------------------------------------------------------- class ReportBuilder: """Assembles a schema-valid machine report from a scan artifact + proposals. Parameters ---------- project_root: Resolved project root; entry paths are relative to it and files are read from here. clock: Injected clock. Each file-hash instant and the envelope `generated_at` come from successive `.now()` calls — so per-entry `generated_at` differs from the envelope stamp (use a monotonic clock to observe it). reader: Injected file reader (bytes for hashing, text for span extraction). estimator: Token estimator producing `token_estimate`. Defaults to the local deterministic estimator (no model, no network — invariant #6). tool_version: Override for the plugin version; defaults to reading plugin.json. """ def __init__( self, project_root: Path, clock: Optional[_Clock] = None, reader: Optional[_FileReader] = None, estimator: Optional[TokenEstimator] = None, tool_version: Optional[str] = None, git_state_provider: Optional[_GitStateProvider] = None, conventions_path: Optional[Path] = None, ) -> None: self._root = Path(project_root) self._clock = clock or RealClock() self._reader = reader or RealFileReader() self._estimator = estimator or default_estimator() self._git_state_provider = git_state_provider or RealGitStateProvider() self._conventions_path = ( Path(conventions_path) if conventions_path is not None else _CONVENTIONS_JSON ) if tool_version is not None: self._tool_version = tool_version self._tool_version_fell_back = False else: self._tool_version, self._tool_version_fell_back = _read_tool_version() @property def tool_version_fell_back(self) -> bool: """True if plugin.json could not be read and a fallback version is used.""" return self._tool_version_fell_back # ------------------------------------------------------------------ # Public # ------------------------------------------------------------------ def build(self, scan: dict, proposals: list) -> dict: """Return {"machine_report": , "human_report": }. Raises MalformedProposalError if any proposal is structurally invalid (validated BEFORE any hashing/IO). """ if not isinstance(scan, dict): raise MalformedProposalError(-1, "(scan)", "scan artifact must be a JSON object") if not isinstance(proposals, list): raise MalformedProposalError(-1, "(proposals)", "proposals must be a JSON array") # 1) Validate ALL proposals up-front (reject before computing anything). for i, proposal in enumerate(proposals): self._validate_proposal(proposal, i) # 2) Assemble entries (now safe to read files / hash / estimate). entries = [self._build_entry(p) for p in proposals] # 3) Envelope. The envelope generated_at is its own clock instant # (the run instant the skill also stamps as last_check). envelope_generated_at = _iso(self._clock.now()) scan_block = self._build_scan_block(scan) shortlist = list(scan.get("shortlist", [])) # verbatim machine_report = { "schema_version": SCHEMA_VERSION, "tool_version": self._tool_version, "generated_at": envelope_generated_at, "scan": scan_block, "shortlist": shortlist, "entries": entries, "promotion_candidates": self._build_promotion_candidates(entries), } human_report = self._build_human_report( machine_report, scan, proposals, envelope_generated_at ) return {"machine_report": machine_report, "human_report": human_report} # ------------------------------------------------------------------ # Proposal validation (pre-compute) # ------------------------------------------------------------------ def _validate_proposal(self, proposal: Any, idx: int) -> None: if not isinstance(proposal, dict): raise MalformedProposalError(idx, "(root)", "proposal must be a JSON object") for key in ("path", "category", "op", "op_type"): if key not in proposal: raise MalformedProposalError(idx, key, f"required field '{key}' is missing") path = proposal.get("path") if not isinstance(path, str) or not path: raise MalformedProposalError(idx, "path", "path must be a non-empty string") self._validate_category(proposal.get("category"), idx) op_type = proposal.get("op_type") if op_type not in _VALID_OP_TYPES: raise MalformedProposalError( idx, "op_type", f"op_type must be one of {list(_VALID_OP_TYPES)}, got {op_type!r}" ) has_exact_edit = "exact_edit" in proposal and proposal["exact_edit"] is not None if op_type == "generative": if has_exact_edit: raise MalformedProposalError( idx, "exact_edit", "generative proposal must NOT carry exact_edit" ) # reducible_range is required so we can count raw_tokens on real text. self._validate_range( proposal.get("reducible_range"), idx, "reducible_range", required=True ) else: # deterministic if not has_exact_edit: raise MalformedProposalError( idx, "exact_edit", "deterministic proposal MUST carry an exact_edit skeleton" ) self._validate_exact_edit_skeleton(proposal["exact_edit"], idx) def _validate_category(self, category: Any, idx: int) -> None: if not isinstance(category, dict): raise MalformedProposalError(idx, "category", "category must be an object with class+subtype") cls = category.get("class") subtype = category.get("subtype") if cls not in ("stale", "bloat"): raise MalformedProposalError(idx, "category.class", f"must be 'stale' or 'bloat', got {cls!r}") if cls == "stale" and subtype not in _STALE_SUBTYPES: raise MalformedProposalError( idx, "category.subtype", f"for class 'stale' must be one of {sorted(_STALE_SUBTYPES)}, got {subtype!r}" ) if cls == "bloat" and subtype not in _BLOAT_SUBTYPES: raise MalformedProposalError( idx, "category.subtype", f"for class 'bloat' must be one of {sorted(_BLOAT_SUBTYPES)}, got {subtype!r}" ) def _validate_exact_edit_skeleton(self, exact_edit: Any, idx: int) -> None: if not isinstance(exact_edit, dict): raise MalformedProposalError(idx, "exact_edit", "exact_edit must be a JSON object") kind = exact_edit.get("kind") if kind not in KIND_TABLE: raise MalformedProposalError( idx, "exact_edit.kind", f"unknown kind {kind!r}; valid: {sorted(KIND_TABLE)}" ) spec = KIND_TABLE[kind] # Reuse the validator's kind table so input + output rules agree. for req_field in spec.required_fields: if req_field not in exact_edit: raise MalformedProposalError( idx, f"exact_edit.{req_field}", f"kind '{kind}' requires field '{req_field}'" ) if spec.has_anchor: self._validate_range(exact_edit.get("anchor"), idx, "exact_edit.anchor", required=True) def _validate_range(self, rng: Any, idx: int, field_name: str, required: bool) -> None: if rng is None: if required: raise MalformedProposalError(idx, field_name, f"required range '{field_name}' is missing") return if not isinstance(rng, dict): raise MalformedProposalError(idx, field_name, "range must be an object with start_line+end_line") for k in ("start_line", "end_line"): v = rng.get(k) if not isinstance(v, int) or isinstance(v, bool): raise MalformedProposalError(idx, f"{field_name}.{k}", f"{k} must be an integer") if rng["start_line"] > rng["end_line"]: raise MalformedProposalError(idx, field_name, "start_line must be <= end_line") # ------------------------------------------------------------------ # Entry assembly # ------------------------------------------------------------------ def _build_entry(self, proposal: dict) -> dict: path = proposal["path"] op_type = proposal["op_type"] abs_path = self._root / path entry: dict[str, Any] = { "path": path, "category": dict(proposal["category"]), "signals": list(proposal.get("signals", [])), "op": proposal["op"], "op_type": op_type, } if op_type == "deterministic": kind = proposal["exact_edit"]["kind"] spec = KIND_TABLE[kind] # Guardrail fields: ALWAYS from the kind table, never from the proposal. entry["is_destructive"] = spec.is_destructive entry["is_reversible"] = spec.is_reversible entry["safety_tier"] = derive_safety_tier( op_type, spec.is_destructive, spec.is_reversible ) exact_edit = self._build_exact_edit(proposal["exact_edit"], spec, abs_path, path) entry["exact_edit"] = exact_edit if kind in _LIFECYCLE_OP_KINDS: entry["safety_tier"] = derive_safety_tier( op_type, spec.is_destructive, spec.is_reversible, lifecycle=exact_edit.get("lifecycle"), ) span_text = self._extract_span(proposal["exact_edit"], kind, abs_path) else: # generative # No exact_edit; (is_destructive, is_reversible) characterize the op. # A generative prose transform preserves history → non-destructive, # reversible; the tier is forced to confirm by op_type regardless. entry["is_destructive"] = False entry["is_reversible"] = True entry["safety_tier"] = derive_safety_tier(op_type, False, True) span_text = self._read_range(abs_path, proposal["reducible_range"]) entry["token_estimate"] = self._estimator.estimate_for_report(span_text) return entry def _build_exact_edit(self, skeleton: dict, spec, abs_path: Path, rel_path: str) -> dict: """Copy the skeleton and stamp the deterministic fields.""" kind = skeleton["kind"] exact_edit: dict[str, Any] = {"kind": kind} # Carry the model-supplied kind-specific fields verbatim. for key, value in skeleton.items(): if key == "kind": continue exact_edit[key] = value if spec.has_anchor: # expected_sha256 = hash of the CURRENT whole file bytes; generated_at # = that hash instant. Both exist iff the kind is anchor-bearing. hash_instant = self._clock.now() file_bytes = self._reader.read_bytes(abs_path) exact_edit["expected_sha256"] = hashlib.sha256(file_bytes).hexdigest() exact_edit["generated_at"] = _iso(hash_instant) if kind in _LIFECYCLE_OP_KINDS: # git_state is a guardrail field (like expected_sha256): NEVER # trust a proposal-supplied value — always recomputed against the # live worktree at build time. Advisory-fresh; patch_applier.py # re-verifies authoritatively at apply time (ADR-0039). lifecycle = dict(exact_edit.get("lifecycle") or {}) lifecycle["git_state"] = self._git_state_provider.state(self._root, rel_path) exact_edit["lifecycle"] = lifecycle return exact_edit # ------------------------------------------------------------------ # Span extraction (for raw_tokens) # ------------------------------------------------------------------ def _extract_span(self, skeleton: dict, kind: str, abs_path: Path) -> str: """Return the text whose tokens are counted for this deterministic op.""" if kind == "move-to-archive": # Whole file is relocated → count the whole file. return self._reader.read_text(abs_path) if kind == "insert-frontmatter": # No anchor; the doc being frozen is the relevant span (whole file). # NOTE: a documented deviation — the design did not specify a span # for insert-frontmatter. return self._reader.read_text(abs_path) if kind in _LIFECYCLE_OP_KINDS: # delete / extract-then-delete removes the whole file → count the # whole file, same rationale as move-to-archive. return self._reader.read_text(abs_path) # delete-range / replace-text / dedupe — count the anchored span. return self._read_range(abs_path, skeleton["anchor"]) def _read_range(self, abs_path: Path, rng: dict) -> str: """Return the inclusive 1-based line range [start_line, end_line].""" text = self._reader.read_text(abs_path) lines = text.splitlines() start = max(1, int(rng["start_line"])) end = min(len(lines), int(rng["end_line"])) if start > end: return "" return "\n".join(lines[start - 1:end]) # ------------------------------------------------------------------ # Promotion candidates (ADR-0041, determinism-promotion — no model) # ------------------------------------------------------------------ def _load_conventions(self) -> list: try: data = json.loads(self._conventions_path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): return [] return data if isinstance(data, list) else [] def _entry_lifecycle(self, entry: dict) -> Optional[dict]: """Return the entry's lifecycle object, whether it lives on the entry itself (a signal-only, non-deleting match) or nested under a delete/extract-then-delete exact_edit.""" if isinstance(entry.get("lifecycle"), dict): return entry["lifecycle"] exact_edit = entry.get("exact_edit") if isinstance(exact_edit, dict) and isinstance(exact_edit.get("lifecycle"), dict): return exact_edit["lifecycle"] return None def _build_promotion_candidates(self, entries: list) -> list: """Deterministic, model-free: for every entry whose lifecycle evidence is classifier-judged (served_when present, no served_when_path — which by construction means "not yet adopted", since adoption IS graduating to served_when_path), name every catalog convention as a promotion candidate.""" conventions = self._load_conventions() if not conventions: return [] candidates: list = [] for entry in entries: lifecycle = self._entry_lifecycle(entry) if lifecycle is None: continue has_served_when = bool(lifecycle.get("served_when")) has_served_when_path = bool(lifecycle.get("served_when_path")) if not has_served_when or has_served_when_path: continue for conv in conventions: if not isinstance(conv, dict): continue name = conv.get("name") pitch = conv.get("pitch") if not name or not pitch: continue candidates.append({"path": entry["path"], "name": name, "pitch": pitch}) return candidates # ------------------------------------------------------------------ # Scan block # ------------------------------------------------------------------ def _build_scan_block(self, scan: dict) -> dict: """Project the scan artifact onto the schema's `scan` object.""" return { "project_root": scan.get("project_root", str(self._root)), "scope_globs": scan.get("scope_globs", []), "excluded_dirs": scan.get("excluded_dirs", []), "files_scanned": scan.get("files_scanned", 0), } # ------------------------------------------------------------------ # Human report skeleton # ------------------------------------------------------------------ def _build_human_report( self, machine_report: dict, scan: dict, proposals: list, generated_at: str, ) -> str: entries = machine_report["entries"] shortlist = machine_report["shortlist"] # gloss: optional per-path "why" line a caller may attach to a proposal. gloss_by_path = { p["path"]: p["gloss"] for p in proposals if isinstance(p, dict) and isinstance(p.get("gloss"), str) } candidate_paths = {e["path"] for e in entries} cleared = [p for p in shortlist if p not in candidate_paths] scope_globs = scan.get("scope_globs", []) lines: list[str] = [] lines.append("# doc-hygiene check report") lines.append("") lines.append(f"- generated_at: {generated_at}") lines.append(f"- scope: {', '.join(scope_globs) if scope_globs else '(default)'}") lines.append(f"- files scanned: {scan.get('files_scanned', 0)}") lines.append(f"- candidates: {len(entries)}") lines.append(f"- cleared: {len(cleared)}") lines.append("") stale = [e for e in entries if e["category"].get("class") == "stale"] bloat = [e for e in entries if e["category"].get("class") == "bloat"] lines.extend(self._render_group("Stale", stale, gloss_by_path)) lines.extend(self._render_group("Bloat", bloat, gloss_by_path)) lines.append("## Cleared") lines.append("") if cleared: for path in cleared: lines.append(f"- {path}") else: lines.append("- (none)") lines.append("") lines.extend( self._render_promotion_candidates(machine_report.get("promotion_candidates", [])) ) return "\n".join(lines) def _render_group(self, title: str, group: list, gloss_by_path: dict) -> list: lines = [f"## {title}", ""] if not group: lines.append("- (none)") lines.append("") return lines for e in group: cat = e["category"] subtype = cat.get("subtype", "?") raw = e["token_estimate"].get("raw_tokens", 0) signal_names = ", ".join( s.get("name", "?") for s in e.get("signals", []) if isinstance(s, dict) ) or "(none)" lines.append( f"- {e['path']} · {cat.get('class')}/{subtype} · {e['op']} " f"· {e['safety_tier']} · ~{raw} tokens · {signal_names}" ) gloss = gloss_by_path.get(e["path"]) if gloss: lines.append(f" - {gloss}") lifecycle = self._entry_lifecycle(e) if lifecycle: rule_ref = lifecycle.get("rule_ref", "?") lifetime = lifecycle.get("lifetime", "?") served = ( f"served_when_path: {lifecycle['served_when_path']}" if lifecycle.get("served_when_path") else f"served_when: {lifecycle.get('served_when', '?')}" ) lines.append(f" - lifecycle: rule={rule_ref} · lifetime={lifetime} · {served}") lines.append("") return lines def _render_promotion_candidates(self, candidates: list) -> list: """Renders the deterministic promotion_candidates section (ADR-0041) into the human report — always present, even when empty, per the determinism-promotion spec.""" lines = ["## Promotion Candidates", ""] if not candidates: lines.append("- (none)") lines.append("") return lines for c in candidates: lines.append(f"- {c['path']} → adopt `{c['name']}`: {c['pitch']}") lines.append("") return lines # --------------------------------------------------------------------------- # CLI # --------------------------------------------------------------------------- def _load_json(path: Optional[str]) -> Any: if path is None or path == "-": return json.load(sys.stdin) with open(path, "r", encoding="utf-8") as fh: return json.load(fh) def main(argv: Optional[list] = None) -> int: parser = argparse.ArgumentParser( description="doc-hygiene report builder — model-free finalize pass." ) parser.add_argument("--scan", default=None, help="Path to scan-artifact JSON ('-' or omit = stdin).") parser.add_argument("--proposals", default=None, help="Path to proposals JSON.") parser.add_argument("--root", default=None, help="Project root (default: scan.project_root).") parser.add_argument("--out-json", default=None, help="Write machine report JSON here (default: stdout bundle).") parser.add_argument("--out-md", default=None, help="Write human report markdown here.") args = parser.parse_args(argv) # Input resolution: if neither --scan nor --proposals given, read a single # bundle {"scan":..,"proposals":..} from stdin. try: if args.scan is None and args.proposals is None: bundle = _load_json(None) if not isinstance(bundle, dict) or "scan" not in bundle or "proposals" not in bundle: print(json.dumps({"error": "stdin bundle must have 'scan' and 'proposals'"}), file=sys.stderr) return 2 scan = bundle["scan"] proposals = bundle["proposals"] else: scan = _load_json(args.scan) proposals = _load_json(args.proposals) except (OSError, json.JSONDecodeError) as exc: print(json.dumps({"error": f"failed to read input: {exc}"}), file=sys.stderr) return 2 root = Path(args.root) if args.root else Path(scan.get("project_root", ".")) builder = ReportBuilder(project_root=root) try: result = builder.build(scan, proposals) except MalformedProposalError as exc: print(json.dumps({"error": "malformed proposal", "detail": exc.as_dict()}), file=sys.stderr) return 1 machine_report = result["machine_report"] human_report = result["human_report"] if builder.tool_version_fell_back: print( json.dumps({"warning": "tool_version fell back; plugin.json unreadable"}), file=sys.stderr, ) wrote_file = False if args.out_json: Path(args.out_json).write_text(json.dumps(machine_report, indent=2), encoding="utf-8") wrote_file = True if args.out_md: Path(args.out_md).write_text(human_report, encoding="utf-8") wrote_file = True if not wrote_file: print(json.dumps({"machine_report": machine_report, "human_report": human_report}, indent=2)) return 0 if __name__ == "__main__": sys.exit(main())