From f84e55ab4bf856ac970f373d73e7224ba44dc9e1 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Sat, 11 Jul 2026 19:01:28 -0500 Subject: [PATCH] fix(content-build): --check orphan detection; canon secrecy-0 + value-type guards - --check now detects orphaned/stale build artifacts in build-owned dirs (client canon/ + topics/ fully owned; client npcs/ shared with legacy hand-authored files, only npc.* ids are treated as build-owned) - resolve() enforces the schema rule that canon entities must be secrecy 0 - resolve() and ladder_rungs() guard against malformed value TYPES (non-int secrecy, non-list related/rungs) raising BuildError instead of crashing Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01HuHRPE7VfppUJEaoGBEUqZ --- tools/content_build/__main__.py | 28 +++++++++++++++++++++++ tools/content_build/resolve.py | 9 ++++++++ tools/content_build/rungs.py | 2 +- tools/content_build/tests/test_cli.py | 19 +++++++++++++++ tools/content_build/tests/test_resolve.py | 21 +++++++++++++++++ 5 files changed, 78 insertions(+), 1 deletion(-) diff --git a/tools/content_build/__main__.py b/tools/content_build/__main__.py index 5e958ee..ffa9ac8 100644 --- a/tools/content_build/__main__.py +++ b/tools/content_build/__main__.py @@ -3,6 +3,7 @@ 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 @@ -36,6 +37,31 @@ def _diff(path: Path, obj) -> list[str]: 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) @@ -45,6 +71,8 @@ def check(lore_dir, world_dir, server_dir) -> int: 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 diff --git a/tools/content_build/resolve.py b/tools/content_build/resolve.py index e778083..fa728bc 100644 --- a/tools/content_build/resolve.py +++ b/tools/content_build/resolve.py @@ -24,12 +24,21 @@ def resolve(entries: list[Entry]) -> World: by_id[e.id] = e for e in entries: + if isinstance(e.secrecy, bool) or not isinstance(e.secrecy, int): + raise BuildError("secrecy must be an integer", + source=e.source, entry_id=e.id) + if not isinstance(e.related, list): + raise BuildError("related must be a list", + source=e.source, entry_id=e.id) if e.status not in STATUSES: raise BuildError(f"illegal status '{e.status}'", source=e.source, entry_id=e.id) if e.namespace not in LEGAL_NAMESPACES: raise BuildError(f"illegal id namespace '{e.namespace}'", source=e.source, entry_id=e.id) + if e.is_canon_entity and e.secrecy != 0: + raise BuildError("canon entity must have secrecy 0", + source=e.source, entry_id=e.id) if e.namespace == "npc": if e.type != "person": raise BuildError("npc.* entries must have type 'person'", diff --git a/tools/content_build/rungs.py b/tools/content_build/rungs.py index 518470d..45bd908 100644 --- a/tools/content_build/rungs.py +++ b/tools/content_build/rungs.py @@ -13,7 +13,7 @@ def ladder_rungs(entries: list[Entry]) -> list[str]: if len(ladders) != 1: raise BuildError( f"exactly one '{LADDER_ID}' entry required, found {len(ladders)}") - if not ladders[0].rungs: + if not isinstance(ladders[0].rungs, list) or not ladders[0].rungs: raise BuildError("disposition-ladder needs a non-empty 'rungs:' list", source=ladders[0].source, entry_id=LADDER_ID) return list(ladders[0].rungs) diff --git a/tools/content_build/tests/test_cli.py b/tools/content_build/tests/test_cli.py index 840a538..4d30bf9 100644 --- a/tools/content_build/tests/test_cli.py +++ b/tools/content_build/tests/test_cli.py @@ -52,3 +52,22 @@ def test_invalid_bible_fails_build(tmp_path): argv = ["--lore", str(lore), "--world", str(tmp_path / "w"), "--server", str(tmp_path / "s")] assert main(argv) == 1 + + +def test_check_detects_orphan_file(tmp_path): + lore = _lore(tmp_path) + world, server = tmp_path / "world", tmp_path / "server" + argv = ["--lore", str(lore), "--world", str(world), "--server", str(server)] + assert main(argv) == 0 + (world / "canon" / "ghost.json").write_text('{"id": "town.ghost"}\n') + assert main(argv + ["--check"]) == 1 + + +def test_check_ignores_legacy_npc_files(tmp_path): + lore = _lore(tmp_path) + world, server = tmp_path / "world", tmp_path / "server" + argv = ["--lore", str(lore), "--world", str(world), "--server", str(server)] + assert main(argv) == 0 + (world / "npcs").mkdir(parents=True, exist_ok=True) + (world / "npcs" / "legacy.json").write_text('{"id": "legacy"}\n') # bare id + assert main(argv + ["--check"]) == 0 # legacy file is not a build orphan diff --git a/tools/content_build/tests/test_resolve.py b/tools/content_build/tests/test_resolve.py index 6972b89..f7c87c6 100644 --- a/tools/content_build/tests/test_resolve.py +++ b/tools/content_build/tests/test_resolve.py @@ -131,3 +131,24 @@ def test_empty_rungs_rejected(): secrecy=0, related=[], body="x", source="t:1", rungs=[]) with pytest.raises(BuildError): resolve([empty]) + + +def test_canon_entity_nonzero_secrecy_rejected(): + bad = Entry(id="town.a", type="town", status="canon", secrecy=2, + related=[], body="x", source="t:9") + with pytest.raises(BuildError): + resolve([ladder(), bad]) + + +def test_non_int_secrecy_raises(): + bad = Entry(id="town.a", type="town", status="canon", secrecy="high", + related=[], body="x", source="t:9") + with pytest.raises(BuildError): + resolve([ladder(), bad]) + + +def test_non_list_rungs_raises(): + bad_ladder = Entry(id="rule.disposition-ladder", type="rule", status="canon", + secrecy=0, related=[], body="x", source="t:1", rungs="trusted") + with pytest.raises(BuildError): + resolve([bad_ladder])