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

572 lines
23 KiB
Python
Raw Normal View History

#!/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 <scan.json> --proposals <proposals.json> \\
[--out-json <path>] [--out-md <path>]
# 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 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"
_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")
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,
) -> None:
self._root = Path(project_root)
self._clock = clock or RealClock()
self._reader = reader or RealFileReader()
self._estimator = estimator or default_estimator()
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": <dict>, "human_report": <str>}.
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,
}
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)
entry["exact_edit"] = exact_edit
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) -> 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)
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)
# 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])
# ------------------------------------------------------------------
# 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("")
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}")
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())