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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HuHRPE7VfppUJEaoGBEUqZ
This commit is contained in:
2026-07-11 19:01:28 -05:00
parent c2d67be76a
commit f84e55ab4b
5 changed files with 78 additions and 1 deletions

View File

@@ -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

View File

@@ -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'",

View File

@@ -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)

View File

@@ -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

View File

@@ -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])