- --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
96 lines
4.1 KiB
Python
96 lines
4.1 KiB
Python
"""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 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'",
|
|
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)
|