From 05425163128ef5f878ce522421bbaa65969eaad9 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Sat, 11 Jul 2026 18:19:26 -0500 Subject: [PATCH] feat(content-build): emit routing + secrecy lint + writer --- tools/content_build/emit.py | 92 +++++++++++++++++++ .../tests/fixtures/valid/world.md | 58 ++++++++++++ tools/content_build/tests/test_emit.py | 64 +++++++++++++ 3 files changed, 214 insertions(+) create mode 100644 tools/content_build/emit.py create mode 100644 tools/content_build/tests/fixtures/valid/world.md create mode 100644 tools/content_build/tests/test_emit.py diff --git a/tools/content_build/emit.py b/tools/content_build/emit.py new file mode 100644 index 0000000..ffd3339 --- /dev/null +++ b/tools/content_build/emit.py @@ -0,0 +1,92 @@ +"""Route each canon entry's fields into the client tree (content/world) and the +server-only tree (content/server), per the schema's role split. build_trees is +pure (returns dicts); write_trees does the I/O; check_secrecy is the +after-routing defense-in-depth lint.""" + +import json +from pathlib import Path + +from .errors import BuildError +from .model import Entry +from .resolve import World +from .rungs import LADDER_ID + + +def dump_json(obj) -> str: + return json.dumps(obj, indent=2, sort_keys=True) + "\n" + + +def _client_relpath(e: Entry) -> str: + if e.is_npc_layer: + return f"npcs/{e.slug}.json" + if e.is_knowledge: + return f"topics/{e.slug}.json" + return f"canon/{e.slug}.json" + + +def _client_record(e: Entry) -> dict: + if e.is_npc_layer: + return { + "id": e.id, + "type": e.type, + "start_disposition": e.start_disposition, + "related": e.related, + "knows": [{"fact_id": k.fact_id, "gate": k.gate} for k in e.knows], + } + if e.is_knowledge: + return {"id": e.id, "type": e.type, "related": e.related} + rec = {"id": e.id, "type": e.type, "related": e.related} + if e.body is not None: + rec["body"] = e.body + if e.id == LADDER_ID and e.rungs: + rec["rungs"] = e.rungs + return rec + + +def _server_entry(e: Entry): + """Return (relpath, record) for the server tree, or None for canon entities + (their bodies are public and ship client-side).""" + if e.is_npc_layer: + rec = {"id": e.id, "persona": e.body} + if e.disposition_notes is not None: + rec["disposition_notes"] = e.disposition_notes + return f"npcs/{e.slug}.json", rec + if e.is_knowledge: + return f"topics/{e.slug}.json", {"id": e.id, "body": e.body, + "secrecy": e.secrecy} + return None + + +def build_trees(world: World) -> tuple[dict, dict]: + client: dict[str, dict] = {} + server: dict[str, dict] = {} + for e in world.entries: + if e.status != "canon": + continue + client[_client_relpath(e)] = _client_record(e) + se = _server_entry(e) + if se is not None: + relpath, rec = se + server[relpath] = rec + return client, server + + +def check_secrecy(client_files: dict, world: World) -> None: + for relpath, rec in client_files.items(): + if "body" in rec and world.by_id[rec["id"]].secrecy >= 3: + e = world.by_id[rec["id"]] + raise BuildError( + f"secrecy {e.secrecy} body routed to client artifact {relpath}", + source=e.source, entry_id=e.id) + + +def write_trees(client_files: dict, server_files: dict, + world_dir: Path, server_dir: Path) -> None: + for relpath, obj in client_files.items(): + p = Path(world_dir) / relpath + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(dump_json(obj)) + for relpath, obj in server_files.items(): + p = Path(server_dir) / relpath + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(dump_json(obj)) diff --git a/tools/content_build/tests/fixtures/valid/world.md b/tools/content_build/tests/fixtures/valid/world.md new file mode 100644 index 0000000..ee661b8 --- /dev/null +++ b/tools/content_build/tests/fixtures/valid/world.md @@ -0,0 +1,58 @@ +# Test bible + +```yaml +id: rule.disposition-ladder +type: rule +status: canon +secrecy: 0 +related: [] +rungs: [hostile, cold, neutral, warm, trusted] +body: > + The five-rung ladder. +``` + +```yaml +id: town.testburg +type: town +status: canon +secrecy: 0 +related: [] +body: > + A test town. +``` + +```yaml +id: rumor.something +type: rumor +status: canon +secrecy: 1 +related: [town.testburg] +body: > + A rumor body. +``` + +```yaml +id: secret.big-twist +type: secret +status: canon +secrecy: 4 +related: [town.testburg] +body: > + The twist body. Server-only. +``` + +```yaml +id: npc.tess +type: person +status: canon +secrecy: 0 +start_disposition: cold +related: [town.testburg] +body: > + Tess persona. +knows: + - {fact: rumor.something, gate: neutral} + - {fact: secret.big-twist, gate: never} +disposition_notes: > + Author notes. +``` diff --git a/tools/content_build/tests/test_emit.py b/tools/content_build/tests/test_emit.py new file mode 100644 index 0000000..8a96dd4 --- /dev/null +++ b/tools/content_build/tests/test_emit.py @@ -0,0 +1,64 @@ +from pathlib import Path + +import pytest + +from content_build.emit import build_trees, check_secrecy, write_trees, dump_json +from content_build.model import entry_from_raw +from content_build.parse import parse_bible +from content_build.resolve import resolve +from content_build.errors import BuildError + +FIXTURE = Path(__file__).parent / "fixtures" / "valid" / "world.md" + + +def _world(): + raws = parse_bible(FIXTURE) + return resolve([entry_from_raw(r) for r in raws]) + + +def test_npc_skeleton_has_no_persona_client_side(): + client, _ = build_trees(_world()) + npc = client["npcs/tess.json"] + assert "persona" not in npc and "body" not in npc + assert npc["start_disposition"] == "cold" + assert {"fact_id": "secret.big-twist", "gate": "never"} in npc["knows"] + + +def test_persona_goes_server_side(): + _, server = build_trees(_world()) + assert server["npcs/tess.json"]["persona"].startswith("Tess") + + +def test_secret_body_only_server_side(): + client, server = build_trees(_world()) + assert "topics/big-twist.json" in server + assert server["topics/big-twist.json"]["body"].startswith("The twist") + # client topic skeleton carries no body + assert "body" not in client["topics/big-twist.json"] + + +def test_canon_entity_body_ships_client_side(): + client, _ = build_trees(_world()) + assert client["canon/testburg.json"]["body"].startswith("A test town") + assert client["canon/disposition-ladder.json"]["rungs"][0] == "hostile" + + +def test_check_secrecy_passes_on_valid_world(): + client, _ = build_trees(_world()) + check_secrecy(client, _world()) # no raise + + +def test_check_secrecy_fails_when_secret_body_reaches_client(): + world = _world() + client, _ = build_trees(world) + # simulate a routing regression: a secrecy-4 body in a client record + client["canon/leak.json"] = {"id": "secret.big-twist", "body": "leak"} + with pytest.raises(BuildError): + check_secrecy(client, world) + + +def test_write_trees_serializes_deterministically(tmp_path): + client, server = build_trees(_world()) + write_trees(client, server, tmp_path / "world", tmp_path / "server") + written = (tmp_path / "world" / "npcs" / "tess.json").read_text() + assert written == dump_json(client["npcs/tess.json"])