feat(content-build): emit routing + secrecy lint + writer
This commit is contained in:
92
tools/content_build/emit.py
Normal file
92
tools/content_build/emit.py
Normal file
@@ -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))
|
||||
58
tools/content_build/tests/fixtures/valid/world.md
vendored
Normal file
58
tools/content_build/tests/fixtures/valid/world.md
vendored
Normal file
@@ -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.
|
||||
```
|
||||
64
tools/content_build/tests/test_emit.py
Normal file
64
tools/content_build/tests/test_emit.py
Normal file
@@ -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"])
|
||||
Reference in New Issue
Block a user