feat(content-build): validation gate (rungs + resolve)
This commit is contained in:
86
tools/content_build/resolve.py
Normal file
86
tools/content_build/resolve.py
Normal file
@@ -0,0 +1,86 @@
|
||||
"""The validation gate. Pure function over parsed entries: builds the id map and
|
||||
enforces every schema rule, raising BuildError on the first violation. Emits
|
||||
nothing. Validates ALL entries (candidate + canon) so authors get errors early;
|
||||
emit (Task 4) filters to canon."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .errors import BuildError
|
||||
from .model import Entry, KNOWLEDGE_TYPES, LEGAL_NAMESPACES, STATUSES
|
||||
from .rungs import ladder_rungs, legal_gates
|
||||
|
||||
|
||||
@dataclass
|
||||
class World:
|
||||
entries: list # list[Entry]
|
||||
by_id: dict # id -> Entry
|
||||
|
||||
|
||||
def resolve(entries: list[Entry]) -> World:
|
||||
by_id: dict[str, Entry] = {}
|
||||
for e in entries:
|
||||
if e.id in by_id:
|
||||
raise BuildError("duplicate id", source=e.source, entry_id=e.id)
|
||||
by_id[e.id] = e
|
||||
|
||||
for e in entries:
|
||||
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.namespace == "npc":
|
||||
if e.type != "person":
|
||||
raise BuildError("npc.* entries must have type 'person'",
|
||||
source=e.source, entry_id=e.id)
|
||||
elif e.type != e.namespace:
|
||||
raise BuildError(
|
||||
f"id namespace '{e.namespace}' must equal type '{e.type}'",
|
||||
source=e.source, entry_id=e.id)
|
||||
|
||||
for e in entries:
|
||||
for rid in e.related:
|
||||
if rid not in by_id:
|
||||
raise BuildError(f"related id '{rid}' does not resolve",
|
||||
source=e.source, entry_id=e.id)
|
||||
|
||||
for e in entries:
|
||||
if e.is_npc_layer:
|
||||
if e.start_disposition is None:
|
||||
raise BuildError("npc.* requires start_disposition",
|
||||
source=e.source, entry_id=e.id)
|
||||
if not e.knows:
|
||||
raise BuildError("npc.* requires a non-empty knows list",
|
||||
source=e.source, entry_id=e.id)
|
||||
else:
|
||||
if e.knows:
|
||||
raise BuildError("non-npc entry must not carry a knows list",
|
||||
source=e.source, entry_id=e.id)
|
||||
if e.start_disposition is not None:
|
||||
raise BuildError("non-npc entry must not carry start_disposition",
|
||||
source=e.source, entry_id=e.id)
|
||||
|
||||
gates = legal_gates(entries) # enforces the single ladder + rungs
|
||||
rungs = set(ladder_rungs(entries))
|
||||
for e in entries:
|
||||
if not e.is_npc_layer:
|
||||
continue
|
||||
if e.start_disposition not in rungs:
|
||||
raise BuildError(
|
||||
f"start_disposition '{e.start_disposition}' is not a legal rung",
|
||||
source=e.source, entry_id=e.id)
|
||||
for link in e.knows:
|
||||
if link.fact_id not in by_id:
|
||||
raise BuildError(f"knows fact '{link.fact_id}' does not resolve",
|
||||
source=e.source, entry_id=e.id)
|
||||
target = by_id[link.fact_id]
|
||||
if target.type not in KNOWLEDGE_TYPES:
|
||||
raise BuildError(
|
||||
f"knows target '{link.fact_id}' is not a knowledge entry "
|
||||
f"(is '{target.type}')", source=e.source, entry_id=e.id)
|
||||
if link.gate not in gates:
|
||||
raise BuildError(f"illegal gate '{link.gate}'",
|
||||
source=e.source, entry_id=e.id)
|
||||
|
||||
return World(entries=entries, by_id=by_id)
|
||||
23
tools/content_build/rungs.py
Normal file
23
tools/content_build/rungs.py
Normal file
@@ -0,0 +1,23 @@
|
||||
"""The rung vocabulary, sourced from the bible's disposition-ladder entry. The
|
||||
bible owns rung names/order; band NUMBERS live in client code and never here."""
|
||||
|
||||
from .errors import BuildError
|
||||
from .model import Entry
|
||||
|
||||
LADDER_ID = "rule.disposition-ladder"
|
||||
NEVER = "never"
|
||||
|
||||
|
||||
def ladder_rungs(entries: list[Entry]) -> list[str]:
|
||||
ladders = [e for e in entries if e.id == LADDER_ID]
|
||||
if len(ladders) != 1:
|
||||
raise BuildError(
|
||||
f"exactly one '{LADDER_ID}' entry required, found {len(ladders)}")
|
||||
if 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)
|
||||
|
||||
|
||||
def legal_gates(entries: list[Entry]) -> set[str]:
|
||||
return set(ladder_rungs(entries)) | {NEVER}
|
||||
107
tools/content_build/tests/test_resolve.py
Normal file
107
tools/content_build/tests/test_resolve.py
Normal file
@@ -0,0 +1,107 @@
|
||||
import pytest
|
||||
|
||||
from content_build.errors import BuildError
|
||||
from content_build.model import Entry, KnowsLink
|
||||
from content_build.resolve import resolve
|
||||
|
||||
|
||||
def ladder():
|
||||
return Entry(id="rule.disposition-ladder", type="rule", status="canon",
|
||||
secrecy=0, related=[], body="x", source="t:1",
|
||||
rungs=["hostile", "cold", "neutral", "warm", "trusted"])
|
||||
|
||||
|
||||
def town():
|
||||
return Entry(id="town.a", type="town", status="canon", secrecy=0,
|
||||
related=[], body="A town.", source="t:2")
|
||||
|
||||
|
||||
def rumor():
|
||||
return Entry(id="rumor.x", type="rumor", status="canon", secrecy=1,
|
||||
related=["town.a"], body="A rumor.", source="t:3")
|
||||
|
||||
|
||||
def npc(**over):
|
||||
base = dict(id="npc.t", type="person", status="canon", secrecy=0,
|
||||
related=["town.a"], body="Persona.", source="t:4",
|
||||
start_disposition="cold",
|
||||
knows=[KnowsLink("rumor.x", "neutral")])
|
||||
base.update(over)
|
||||
return Entry(**base)
|
||||
|
||||
|
||||
def test_valid_world_resolves():
|
||||
world = resolve([ladder(), town(), rumor(), npc()])
|
||||
assert world.by_id["npc.t"].id == "npc.t"
|
||||
|
||||
|
||||
def test_duplicate_id():
|
||||
with pytest.raises(BuildError):
|
||||
resolve([ladder(), town(), town()])
|
||||
|
||||
|
||||
def test_illegal_namespace():
|
||||
bad = Entry(id="shrine.s", type="place", status="canon", secrecy=0,
|
||||
related=[], body="x", source="t:9")
|
||||
with pytest.raises(BuildError):
|
||||
resolve([ladder(), bad])
|
||||
|
||||
|
||||
def test_id_type_mismatch():
|
||||
bad = Entry(id="place.s", type="town", status="canon", secrecy=0,
|
||||
related=[], body="x", source="t:9")
|
||||
with pytest.raises(BuildError):
|
||||
resolve([ladder(), bad])
|
||||
|
||||
|
||||
def test_npc_type_must_be_person():
|
||||
with pytest.raises(BuildError):
|
||||
resolve([ladder(), town(), rumor(), npc(type="town")])
|
||||
|
||||
|
||||
def test_dangling_related():
|
||||
with pytest.raises(BuildError):
|
||||
resolve([ladder(), town(), rumor(),
|
||||
npc(related=["town.nowhere"])])
|
||||
|
||||
|
||||
def test_person_record_with_knows_rejected():
|
||||
rec = Entry(id="person.p", type="person", status="canon", secrecy=0,
|
||||
related=[], body="x", source="t:9",
|
||||
knows=[KnowsLink("rumor.x", "warm")])
|
||||
with pytest.raises(BuildError):
|
||||
resolve([ladder(), town(), rumor(), rec])
|
||||
|
||||
|
||||
def test_npc_missing_start_disposition():
|
||||
with pytest.raises(BuildError):
|
||||
resolve([ladder(), town(), rumor(), npc(start_disposition=None)])
|
||||
|
||||
|
||||
def test_knows_target_must_be_knowledge_entry():
|
||||
# points at the town (a canon entity), not a knowledge entry
|
||||
with pytest.raises(BuildError):
|
||||
resolve([ladder(), town(), rumor(),
|
||||
npc(knows=[KnowsLink("town.a", "cold")])])
|
||||
|
||||
|
||||
def test_illegal_gate():
|
||||
with pytest.raises(BuildError):
|
||||
resolve([ladder(), town(), rumor(),
|
||||
npc(knows=[KnowsLink("rumor.x", "besties")])])
|
||||
|
||||
|
||||
def test_never_is_a_legal_gate():
|
||||
world = resolve([ladder(), town(), rumor(),
|
||||
npc(knows=[KnowsLink("rumor.x", "never")])])
|
||||
assert world.by_id["npc.t"].knows[0].gate == "never"
|
||||
|
||||
|
||||
def test_missing_ladder():
|
||||
with pytest.raises(BuildError):
|
||||
resolve([town()])
|
||||
|
||||
|
||||
def test_illegal_start_disposition():
|
||||
with pytest.raises(BuildError):
|
||||
resolve([ladder(), town(), rumor(), npc(start_disposition="besties")])
|
||||
Reference in New Issue
Block a user