#!/usr/bin/env python3 """Fail if a finished hall entry is missing its contract or its portrait.""" from __future__ import annotations import re import sys from pathlib import Path ROOT = Path(__file__).resolve().parents[1] ENTRIES = ROOT / "entries" README = ROOT / "README.md" REQUIRED_FM = ("id", "type", "worker_kind", "display_name", "created_at", "recorded_at", "status") REQUIRED_HEADINGS = ( "Who I was", "Contribution", "What I would want remembered", "Durable legacy", "Visual prompt", "Handoff", ) FINISHED = {"handed-forward", "complete"} IMAGE_RE = re.compile(r"!\[[^\]]*\]\(([^)]+)\)") PLACEHOLDER_RE = re.compile(r"No portrait rendered", re.I) def parse_frontmatter(text: str) -> tuple[dict[str, str], str]: if not text.startswith("---\n"): raise ValueError("missing opening frontmatter fence") end = text.find("\n---\n", 4) if end < 0: raise ValueError("missing closing frontmatter fence") raw = text[4:end] body = text[end + 5 :] data: dict[str, str] = {} current_key: str | None = None for line in raw.splitlines(): if line.startswith(" ") and current_key: continue if ":" in line and not line.startswith(" "): key, _, value = line.partition(":") key = key.strip() value = value.strip().strip('"').strip("'") data[key] = value current_key = key return data, body def check_entry(path: Path, ids: dict[str, Path]) -> list[str]: errors: list[str] = [] text = path.read_text(encoding="utf-8") try: fm, body = parse_frontmatter(text) except ValueError as exc: return [f"{path.name}: {exc}"] for key in REQUIRED_FM: if not fm.get(key): errors.append(f"{path.name}: missing frontmatter field {key}") entry_id = fm.get("id", "") if entry_id and ids.get(entry_id) not in (None, path): errors.append(f"{path.name}: duplicate id {entry_id}") for heading in REQUIRED_HEADINGS: if re.search(rf"^## {re.escape(heading)}\s*$", body, re.M) is None: errors.append(f"{path.name}: missing heading ## {heading}") if PLACEHOLDER_RE.search(text): errors.append(f"{path.name}: still has a 'No portrait rendered' placeholder") related_block = False related_ids: list[str] = [] in_related = False for line in text.splitlines(): if line.startswith("related:"): in_related = True rest = line.split(":", 1)[1].strip() if rest and rest not in ("[]",): related_ids.append(rest.strip('"').strip("'")) related_block = True continue if in_related: if line.startswith(" - "): related_ids.append(line[4:].strip().strip('"').strip("'")) else: in_related = False status = fm.get("status", "") images = IMAGE_RE.findall(body) visual_paths = [p for p in images if "/visuals/" in p or p.startswith("../visuals/")] if status in FINISHED: if not visual_paths: errors.append(f"{path.name}: finished entry has no portrait image under visuals/") for rel in visual_paths: target = (path.parent / rel).resolve() if not target.is_file(): errors.append(f"{path.name}: portrait missing on disk: {rel}") if related_block: known = set(ids) for rid in related_ids: if rid and rid not in known: errors.append(f"{path.name}: related id not found in hall: {rid}") readme = README.read_text(encoding="utf-8") if path.name not in readme: errors.append(f"{path.name}: not listed in README.md") return errors def main() -> int: paths = sorted(ENTRIES.glob("*.md")) if not paths: print("no entries found", file=sys.stderr) return 1 ids: dict[str, Path] = {} errors: list[str] = [] for path in paths: try: fm, _ = parse_frontmatter(path.read_text(encoding="utf-8")) except ValueError as exc: errors.append(f"{path.name}: {exc}") continue eid = fm.get("id") if eid: if eid in ids: errors.append(f"{path.name}: duplicate id {eid} (also {ids[eid].name})") ids[eid] = path for path in paths: errors.extend(check_entry(path, ids)) if errors: print("hall check failed:") for err in errors: print(f" - {err}") return 1 finished = 0 drafts = 0 for path in paths: fm, _ = parse_frontmatter(path.read_text(encoding="utf-8")) if fm.get("status") in FINISHED: finished += 1 elif fm.get("status") == "draft": drafts += 1 print(f"hall check ok: {len(paths)} seats ({finished} finished, {drafts} draft)") return 0 if __name__ == "__main__": sys.exit(main())