#!/usr/bin/env python3 """Build the ADR review ledger from source-inventory.json and source files.""" from __future__ import annotations import argparse import json import re from pathlib import Path import sys from typing import Any from render import split_frontmatter from source_inventory import _repo_paths, _read_json BARE_ADR = re.compile(r"(?i)\bADR-0*(\d+)\b") REQUIRED = ("id", "title", "status", "owner") FIRST_PUBLISH = { ("policy-nexus", "docs/adr/ADR-0001-addressing-and-permanence.md"), *( ( "railiance-master", f"docs/adr/ADR-000{n}-{slug}.md", ) for n, slug in ( (1, "repository-prefix-architecture"), (2, "rail-kubernetes-wave-1-boundary"), (3, "rapp-first-wave-selection"), (4, "first-wave-reef-rollout"), (5, "derived-rail-composition"), (6, "reef-production-admission"), (7, "rapp-declaration-contract"), (8, "private-by-default-exposure"), ) ), } SPECIALS = { ("policy-nexus", "docs/adr/ADR-0001-addressing-and-permanence.md"): ( "First publish set. This repo's addressing contract." ), ("the-custodian", "canon/architecture/adr-008-multi-tenancy-model.md"): ( "Relocated to net-kingdom Tenancy Posture. Duplicate-claim candidate (kind 6)." ), } for n, slug in ( (1, "repository-prefix-architecture"), (2, "rail-kubernetes-wave-1-boundary"), (3, "rapp-first-wave-selection"), (4, "first-wave-reef-rollout"), (5, "derived-rail-composition"), (6, "reef-production-admission"), (7, "rapp-declaration-contract"), (8, "private-by-default-exposure"), ): SPECIALS[("railiance-master", f"docs/adr/ADR-000{n}-{slug}.md")] = ( "First publish set. Publication-ready front-matter." ) def _bare_adr(source_path: str, meta_id: str) -> str: for text in (meta_id, Path(source_path).name): match = BARE_ADR.search(text) if match: return f"ADR-{int(match.group(1)):04d}" return "" def _missing(meta: dict[str, str]) -> list[str]: missing = [field for field in REQUIRED if not meta.get(field)] if not (meta.get("revision") or meta.get("version")): missing.append("revision|version") if not ((meta.get("last_reviewed") or meta.get("updated")) and meta.get("review_interval")): missing.append("review") return missing def _frontmatter(path: Path) -> dict[str, str]: if not path.is_file(): return {} meta, _body = split_frontmatter(path.read_text(encoding="utf-8", errors="replace")) return {key: value for key, value in meta.items() if value} def _load_rulings(path: Path | None) -> dict[tuple[str, str], dict[str, Any]]: if path is None or not path.exists(): return {} payload = _read_json(path) rulings: dict[tuple[str, str], dict[str, Any]] = {} for ruling in payload.get("rulings", []): key = (ruling["source_repo"], ruling["source_path"]) if key in rulings: raise ValueError(f"duplicate ruling: {key[0]}/{key[1]}") rulings[key] = ruling return rulings def build_ledger( inventory_path: Path, config_path: Path, *, policy_root: Path, source_root: Path, rulings_path: Path | None = None, ) -> dict[str, Any]: inventory = _read_json(inventory_path) config = _read_json(config_path) rulings = _load_rulings(rulings_path) paths = _repo_paths(config, policy_root=policy_root, source_root=source_root) rows: list[dict[str, Any]] = [] for source in inventory.get("sources", []): key = (source["source_repo"], source["source_path"]) repo = paths.get(source["source_repo"]) file_path = (repo / source["source_path"]) if repo is not None else None present = bool(file_path and file_path.is_file()) meta = _frontmatter(file_path) if present and file_path is not None else {} rows.append( { "source_repo": source["source_repo"], "source_path": source["source_path"], "inventory_disposition": source.get("disposition", ""), "inventory_reason": source.get("reason", ""), "file_present": present, "frontmatter": { field: meta.get(field, "") for field in ( "id", "title", "status", "owner", "revision", "version", "last_reviewed", "updated", "review_interval", ) }, "missing_fields": _missing(meta) if present else ["file-missing"], "bare_adr": _bare_adr(source["source_path"], meta.get("id", "")), "id_collisions": [], "bare_adr_collisions": [], "notes": SPECIALS.get(key, ""), "proposed_disposition": "publish" if key in FIRST_PUBLISH else "unreviewed", "conflict_kinds": [], "successor": "", "review_notes": "", } ) by_id: dict[str, list[str]] = {} by_bare: dict[str, list[str]] = {} for row in rows: label = f"{row['source_repo']}/{row['source_path']}" meta_id = row["frontmatter"]["id"] if meta_id: by_id.setdefault(meta_id, []).append(label) if row["bare_adr"]: by_bare.setdefault(row["bare_adr"], []).append(label) for row in rows: label = f"{row['source_repo']}/{row['source_path']}" meta_id = row["frontmatter"]["id"] if meta_id: row["id_collisions"] = [other for other in by_id.get(meta_id, []) if other != label] if row["bare_adr"]: row["bare_adr_collisions"] = [ other for other in by_bare.get(row["bare_adr"], []) if other != label ] key = (row["source_repo"], row["source_path"]) ruling = rulings.get(key) if ruling: for field in ( "proposed_disposition", "conflict_kinds", "successor", "review_notes", ): if field in ruling: row[field] = ruling[field] if row["id_collisions"] and 1 not in row["conflict_kinds"]: row["conflict_kinds"] = [*row["conflict_kinds"], 1] if not row["review_notes"]: row["review_notes"] = ( f"Front-matter id {row['frontmatter']['id']!r} is shared; " "pick a repo-prefixed publication id before publish. " "Who rules: owning repo." ) return { "schema_version": 1, "protocol": "docs/adr-review/protocol.md", "source_inventory": "source-inventory.json", "rulings": str(rulings_path) if rulings_path else "", "rows": rows, } def _summary(ledger: dict[str, Any]) -> str: rows = ledger["rows"] dispositions = {} inventory = {} for row in rows: dispositions[row["proposed_disposition"]] = ( dispositions.get(row["proposed_disposition"], 0) + 1 ) inventory[row["inventory_disposition"]] = inventory.get(row["inventory_disposition"], 0) + 1 missing_files = [row for row in rows if not row["file_present"]] id_hits = [row for row in rows if row["id_collisions"]] bare_hits = [row for row in rows if row["bare_adr_collisions"]] lines = [ "# ADR review ledger summary", "", f"Rows: {len(rows)}", "", "## Inventory dispositions", "", ] for name, count in sorted(inventory.items()): lines.append(f"- `{name}`: {count}") lines.extend(["", "## Proposed dispositions", ""]) for name, count in sorted(dispositions.items()): lines.append(f"- `{name}`: {count}") lines.extend(["", "## Front-matter `id` collisions", ""]) if not id_hits: lines.append("None.") else: seen = set() for row in id_hits: key = row["frontmatter"]["id"] if key in seen: continue seen.add(key) others = ", ".join([f"{row['source_repo']}/{row['source_path']}", *row["id_collisions"]]) lines.append(f"- `{key}`: {others}") lines.extend(["", "## Bare ADR-NNNN collisions", ""]) if not bare_hits: lines.append("None.") else: seen = set() for row in bare_hits: key = row["bare_adr"] if key in seen: continue seen.add(key) others = ", ".join( [f"{row['source_repo']}/{row['source_path']}", *row["bare_adr_collisions"]] ) lines.append(f"- `{key}`: {others}") conflicts = [row for row in rows if row["proposed_disposition"] == "conflict"] lines.extend(["", "## Conflict rows", ""]) if not conflicts: lines.append("None.") else: for row in conflicts: kinds = ",".join(str(kind) for kind in row["conflict_kinds"]) or "-" lines.append( f"- `{row['source_repo']}/{row['source_path']}` " f"(kinds {kinds}): {row['review_notes']}" ) lines.extend(["", "## Missing files", ""]) if not missing_files: lines.append("None.") else: for row in missing_files: lines.append(f"- `{row['source_repo']}/{row['source_path']}`") lines.append("") return "\n".join(lines) def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--config", type=Path, default=Path("source-inventory.config.json")) parser.add_argument("--inventory", type=Path, default=Path("source-inventory.json")) parser.add_argument("--output", type=Path, default=Path("docs/adr-review/ledger.json")) parser.add_argument("--summary", type=Path, default=Path("docs/adr-review/SUMMARY.md")) parser.add_argument("--rulings", type=Path, default=Path("docs/adr-review/rulings.json")) parser.add_argument("--policy-root", type=Path) parser.add_argument("--source-root", type=Path) args = parser.parse_args(argv) config_path = args.config.resolve() policy_root = (args.policy_root or config_path.parent).resolve() source_root = (args.source_root or policy_root.parent).resolve() try: ledger = build_ledger( args.inventory.resolve(), config_path, policy_root=policy_root, source_root=source_root, rulings_path=args.rulings.resolve() if args.rulings else None, ) args.output.parent.mkdir(parents=True, exist_ok=True) args.output.write_text( json.dumps(ledger, indent=2, sort_keys=True) + "\n", encoding="utf-8" ) args.summary.write_text(_summary(ledger), encoding="utf-8") print(f"{args.output}: {len(ledger['rows'])} row(s)") return 0 except (OSError, ValueError) as exc: print(f"adr review ledger failed: {exc}", file=sys.stderr) return 1 if __name__ == "__main__": raise SystemExit(main())