"""CLI entry: `python -m content_build [--check]`. Orchestrates parse -> model -> resolve -> emit. --check builds in memory and diffs against the on-disk committed JSON (stale-or-invalid -> exit 1).""" import argparse import json import sys from pathlib import Path from .emit import build_trees, check_secrecy, dump_json, write_trees from .errors import BuildError from .model import entry_from_raw from .parse import parse_bible from .resolve import resolve def _load_world(lore_dir: Path): raws = [] for md in sorted(Path(lore_dir).glob("*.md")): raws.extend(parse_bible(md)) return resolve([entry_from_raw(r) for r in raws]) def build(lore_dir, world_dir, server_dir) -> None: world = _load_world(lore_dir) client_files, server_files = build_trees(world) check_secrecy(client_files, world) write_trees(client_files, server_files, world_dir, server_dir) def _diff(path: Path, obj) -> list[str]: want = dump_json(obj) if not path.exists(): return [f"missing {path}"] if path.read_text() != want: return [f"differs {path}"] return [] def _orphans(root, expected_relpaths: set, is_client: bool) -> list[str]: """Build-owned .json files on disk that the build no longer produces. Client npcs/ is shared with legacy hand-authored files (bare ids), so there only generated npc.* files are treated as build-owned.""" owned = ["canon", "topics", "npcs"] if is_client else ["npcs", "topics"] out: list[str] = [] for sub in owned: d = Path(root) / sub if not d.is_dir(): continue for f in sorted(d.glob("*.json")): relpath = f"{sub}/{f.name}" if relpath in expected_relpaths: continue if is_client and sub == "npcs": try: data = json.loads(f.read_text()) except (OSError, ValueError): continue if not str(data.get("id", "")).startswith("npc."): continue # legacy hand-authored file — not build-owned out.append(f"orphan {f}") return out def check(lore_dir, world_dir, server_dir) -> int: world = _load_world(lore_dir) client_files, server_files = build_trees(world) check_secrecy(client_files, world) stale: list[str] = [] for relpath, obj in client_files.items(): stale += _diff(Path(world_dir) / relpath, obj) for relpath, obj in server_files.items(): stale += _diff(Path(server_dir) / relpath, obj) stale += _orphans(world_dir, set(client_files.keys()), is_client=True) stale += _orphans(server_dir, set(server_files.keys()), is_client=False) for s in stale: print("STALE:", s, file=sys.stderr) return 1 if stale else 0 def main(argv=None) -> int: ap = argparse.ArgumentParser(prog="content_build") ap.add_argument("--check", action="store_true", help="verify committed JSON is fresh + valid; no writes") ap.add_argument("--lore", default="content/lore") ap.add_argument("--world", default="content/world") ap.add_argument("--server", default="content/server") args = ap.parse_args(argv) try: if args.check: return check(args.lore, args.world, args.server) build(args.lore, args.world, args.server) return 0 except BuildError as e: print("BUILD FAILED:", e, file=sys.stderr) return 1 if __name__ == "__main__": raise SystemExit(main())