#!/usr/bin/env python3 """ validate_report.py — Machine-report schema validator for doc-hygiene. Usage: python validate_report.py Exit codes: 0 — report is valid 1 — report is invalid (violations reported in JSON output) 2 — usage error (missing argument, file not found, not JSON) Output: structured JSON on stdout with keys: valid: bool violations: list of {field, message} """ import json import sys from dataclasses import dataclass, field from typing import Any # --------------------------------------------------------------------------- # Safety-tier derivation (invariant #10) # --------------------------------------------------------------------------- def derive_safety_tier(op_type: str, is_destructive: bool, is_reversible: bool) -> str: """ Deterministic derivation of safety_tier from op characterisation. Never returns 'auto' for a generative, destructive, or irreversible op. """ if op_type == "generative": return "confirm" if is_destructive: return "confirm" if not is_reversible: return "confirm" return "auto" # --------------------------------------------------------------------------- # Kind table: closed enum + per-kind required fields + inherent booleans # --------------------------------------------------------------------------- @dataclass class KindSpec: is_destructive: bool is_reversible: bool required_fields: list # fields required directly on exact_edit (besides kind) has_anchor: bool # whether anchor sub-object is required KIND_TABLE: dict[str, KindSpec] = { "delete-range": KindSpec( is_destructive=True, is_reversible=False, required_fields=["anchor"], has_anchor=True, ), "move-to-archive": KindSpec( is_destructive=False, is_reversible=True, required_fields=["anchor", "dest_path"], has_anchor=True, ), "insert-frontmatter": KindSpec( is_destructive=False, is_reversible=True, required_fields=["key", "value"], has_anchor=False, ), "replace-text": KindSpec( is_destructive=False, is_reversible=True, required_fields=["anchor", "match", "replacement"], has_anchor=True, ), "dedupe": KindSpec( is_destructive=False, is_reversible=True, required_fields=["anchor", "canonical_ref"], has_anchor=True, ), } STALE_SUBTYPES = {"contradicted", "orphaned", "superseded", "provisional", "completed-in-place", "duplicated"} BLOAT_SUBTYPES = {"distill", "split", "freeze"} # --------------------------------------------------------------------------- # Validator # --------------------------------------------------------------------------- class ReportValidator: """Validates a machine-report JSON dict against the frozen schema contract.""" def __init__(self, report: Any): self._report = report self._violations: list[dict] = [] def _add(self, field_path: str, message: str) -> None: self._violations.append({"field": field_path, "message": message}) def validate(self) -> list[dict]: """Run all checks; collect all violations. Returns list of violations.""" r = self._report if not isinstance(r, dict): self._add("(root)", "Report must be a JSON object") return self._violations # Rule 1: Envelope fields self._check_envelope(r) # Rule 2: shortlist precedes entries shortlist = r.get("shortlist", []) if not isinstance(shortlist, list): self._add("shortlist", "Must be an array") shortlist = [] entries = r.get("entries", []) if not isinstance(entries, list): self._add("entries", "Must be an array") entries = [] shortlist_set = set(shortlist) if isinstance(shortlist, list) else set() for i, entry in enumerate(entries): if isinstance(entry, dict): self._check_entry(entry, i, shortlist_set) else: self._add(f"entries[{i}]", "Entry must be a JSON object") return self._violations def _check_envelope(self, r: dict) -> None: for key in ("schema_version", "tool_version", "generated_at", "scan", "shortlist", "entries"): if key not in r: self._add(key, f"Required top-level field '{key}' is missing") scan = r.get("scan") if scan is not None: if not isinstance(scan, dict): self._add("scan", "Must be a JSON object") else: for key in ("project_root", "scope_globs", "excluded_dirs", "files_scanned"): if key not in scan: self._add(f"scan.{key}", f"Required scan field '{key}' is missing") def _check_entry(self, entry: dict, idx: int, shortlist_set: set) -> None: pfx = f"entries[{idx}]" # Rule 3: Required entry fields required = ( "path", "category", "signals", "op", "op_type", "is_destructive", "is_reversible", "safety_tier", "token_estimate", ) for f in required: if f not in entry: self._add(f"{pfx}.{f}", f"Required entry field '{f}' is missing") # Rule 2: entry path must be in shortlist path = entry.get("path") if path is not None and shortlist_set and path not in shortlist_set: self._add(f"{pfx}.path", f"Entry path '{path}' is not in shortlist") # Rule 4: Category closed enum category = entry.get("category") if category is not None: self._check_category(category, pfx) # Rule 5: Scalar enums op_type = entry.get("op_type") if op_type is not None and op_type not in ("deterministic", "generative"): self._add(f"{pfx}.op_type", f"op_type must be 'deterministic' or 'generative', got '{op_type}'") safety_tier = entry.get("safety_tier") if safety_tier is not None and safety_tier not in ("auto", "confirm"): self._add(f"{pfx}.safety_tier", f"safety_tier must be 'auto' or 'confirm', got '{safety_tier}'") is_destructive = entry.get("is_destructive") if is_destructive is not None and not isinstance(is_destructive, bool): self._add(f"{pfx}.is_destructive", "is_destructive must be a boolean") is_reversible = entry.get("is_reversible") if is_reversible is not None and not isinstance(is_reversible, bool): self._add(f"{pfx}.is_reversible", "is_reversible must be a boolean") # Rules 6, 7, 8, 9: exact_edit checks (only if op_type valid) if op_type in ("deterministic", "generative"): self._check_exact_edit_biconditional(entry, pfx, op_type) # Rule 9: safety_tier derivation match (only when all inputs are valid) if ( op_type in ("deterministic", "generative") and isinstance(is_destructive, bool) and isinstance(is_reversible, bool) and safety_tier in ("auto", "confirm") ): expected_tier = derive_safety_tier(op_type, is_destructive, is_reversible) if safety_tier != expected_tier: self._add( f"{pfx}.safety_tier", f"safety_tier mismatch: recorded '{safety_tier}' but derivation yields '{expected_tier}' " f"for (op_type={op_type}, is_destructive={is_destructive}, is_reversible={is_reversible})", ) # Rule 10: token_estimate.raw_tokens required token_estimate = entry.get("token_estimate") if token_estimate is not None: self._check_token_estimate(token_estimate, pfx) def _check_category(self, category: Any, pfx: str) -> None: if not isinstance(category, dict): self._add(f"{pfx}.category", "category must be a JSON object with 'class' and 'subtype'") return cls = category.get("class") subtype = category.get("subtype") if cls is None: self._add(f"{pfx}.category.class", "category.class is required") elif cls not in ("stale", "bloat"): self._add(f"{pfx}.category.class", f"category.class must be 'stale' or 'bloat', got '{cls}'") if subtype is None: self._add(f"{pfx}.category.subtype", "category.subtype is required") elif cls == "stale" and subtype not in STALE_SUBTYPES: self._add( f"{pfx}.category.subtype", f"For class 'stale', subtype must be one of {sorted(STALE_SUBTYPES)}, got '{subtype}'", ) elif cls == "bloat" and subtype not in BLOAT_SUBTYPES: self._add( f"{pfx}.category.subtype", f"For class 'bloat', subtype must be one of {sorted(BLOAT_SUBTYPES)}, got '{subtype}'", ) def _check_exact_edit_biconditional(self, entry: dict, pfx: str, op_type: str) -> None: """Rule 6: exact_edit present IFF op_type == deterministic.""" has_exact_edit = "exact_edit" in entry exact_edit = entry.get("exact_edit") if op_type == "generative" and has_exact_edit: self._add( f"{pfx}.exact_edit", "generative entry must NOT carry exact_edit (invariant #11)", ) return # No point validating the structure of an edit that shouldn't exist if op_type == "deterministic" and not has_exact_edit: self._add( f"{pfx}.exact_edit", "deterministic entry MUST carry exact_edit (invariant #11)", ) return if op_type == "deterministic" and has_exact_edit: self._check_exact_edit(exact_edit, pfx, entry) def _check_exact_edit(self, exact_edit: Any, pfx: str, entry: dict) -> None: """Rules 7 and 8: kind enum, per-kind required fields, characterisation match.""" if not isinstance(exact_edit, dict): self._add(f"{pfx}.exact_edit", "exact_edit must be a JSON object") return kind = exact_edit.get("kind") if kind is None: self._add(f"{pfx}.exact_edit.kind", "exact_edit.kind is required") return if kind not in KIND_TABLE: self._add( f"{pfx}.exact_edit.kind", f"Unknown exact_edit.kind '{kind}'; valid kinds: {sorted(KIND_TABLE)}", ) return spec = KIND_TABLE[kind] # Rule 7: Per-kind required sub-fields for req_field in spec.required_fields: if req_field not in exact_edit: self._add( f"{pfx}.exact_edit.{req_field}", f"exact_edit of kind '{kind}' requires field '{req_field}'", ) # Anchor must have start_line and end_line if spec.has_anchor and "anchor" in exact_edit: anchor = exact_edit["anchor"] if not isinstance(anchor, dict): self._add(f"{pfx}.exact_edit.anchor", "anchor must be a JSON object") else: for ak in ("start_line", "end_line"): if ak not in anchor: self._add(f"{pfx}.exact_edit.anchor.{ak}", f"anchor.{ak} is required for kind '{kind}'") # expected_sha256 required for anchor-bearing kinds if spec.has_anchor and "expected_sha256" not in exact_edit: self._add( f"{pfx}.exact_edit.expected_sha256", f"exact_edit of kind '{kind}' (anchor-bearing) requires 'expected_sha256'", ) # Rule 8: Kind characterisation match — entry's booleans must equal kind's inherent pair entry_is_destructive = entry.get("is_destructive") entry_is_reversible = entry.get("is_reversible") if isinstance(entry_is_destructive, bool) and entry_is_destructive != spec.is_destructive: self._add( f"{pfx}.is_destructive", f"kind='{kind}' requires is_destructive={spec.is_destructive}, got {entry_is_destructive}", ) if isinstance(entry_is_reversible, bool) and entry_is_reversible != spec.is_reversible: self._add( f"{pfx}.is_reversible", f"kind='{kind}' requires is_reversible={spec.is_reversible}, got {entry_is_reversible}", ) def _check_token_estimate(self, token_estimate: Any, pfx: str) -> None: if not isinstance(token_estimate, dict): self._add(f"{pfx}.token_estimate", "token_estimate must be a JSON object") return if "raw_tokens" not in token_estimate: self._add(f"{pfx}.token_estimate.raw_tokens", "token_estimate.raw_tokens is required in v1") else: raw = token_estimate["raw_tokens"] if not isinstance(raw, (int, float)): self._add(f"{pfx}.token_estimate.raw_tokens", "raw_tokens must be a number") # --------------------------------------------------------------------------- # Entry point # --------------------------------------------------------------------------- def main() -> None: if len(sys.argv) != 2: print(json.dumps({"error": "Usage: validate_report.py "})) sys.exit(2) path = sys.argv[1] try: with open(path, "r", encoding="utf-8") as fh: report = json.load(fh) except FileNotFoundError: print(json.dumps({"error": f"File not found: {path}"})) sys.exit(2) except json.JSONDecodeError as exc: print(json.dumps({"error": f"Invalid JSON: {exc}"})) sys.exit(2) validator = ReportValidator(report) violations = validator.validate() result = { "valid": len(violations) == 0, "violations": violations, } print(json.dumps(result, indent=2)) sys.exit(0 if result["valid"] else 1) if __name__ == "__main__": main()