Address all review comments from Hoshe and Tyre on PR #27: - Remove 2>/dev/null from pre-pr-fixtures (critical: swallowed errors) - Remove dead _file_type function - Check 4: error on districts with no locations declared - Check 5: print advisory message when skipping - Check 8: cross-file line ID uniqueness (not just per-file) - Check 9: document D-034 asymmetric relationships in docstring - Document regex fallback rationale in _scan_knowledge - Add D-035 decision trace to schema descriptions - Use concrete protocol version in DEVOPS.md example - Amend D-035 with focused (9th mood) and greeting (14th situation) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
564 lines
22 KiB
Python
Executable File
564 lines
22 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Validate content YAML files against their JSON schemas and cross-references.
|
|
|
|
Pass 1: Schema validation (per-file against JSON Schema)
|
|
Pass 2: Cross-reference validation (9 checks across all files)
|
|
|
|
Schema mapping is by directory context:
|
|
campaign.yaml -> campaign.schema.json
|
|
system.yaml -> system.schema.json
|
|
station.yaml -> station.schema.json
|
|
district.yaml -> district.schema.json
|
|
npcs/*.yaml -> npc-profile.schema.json
|
|
locations/*.yaml -> location.schema.json
|
|
triangles/*.yaml -> triangle.schema.json
|
|
dialogue/**/*.yaml -> dialogue-pool.schema.json
|
|
monologue/**/*.yaml -> monologue-pool.schema.json
|
|
routines/*.yaml -> routine.schema.json
|
|
|
|
Files under global/ and content.yaml are skipped (no schema yet).
|
|
|
|
Exit code 0 = all valid, 1 = validation errors found.
|
|
"""
|
|
|
|
import json
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import jsonschema
|
|
import yaml
|
|
|
|
CONTENT_DIR = Path(__file__).resolve().parent.parent / "content"
|
|
SCHEMA_DIR = CONTENT_DIR / "_schema"
|
|
|
|
# Map directory parent name (or filename) to schema file
|
|
FILENAME_SCHEMAS = {
|
|
"campaign.yaml": "campaign.schema.json",
|
|
"system.yaml": "system.schema.json",
|
|
"station.yaml": "station.schema.json",
|
|
"district.yaml": "district.schema.json",
|
|
}
|
|
|
|
DIR_SCHEMAS = {
|
|
"npcs": "npc-profile.schema.json",
|
|
"locations": "location.schema.json",
|
|
"triangles": "triangle.schema.json",
|
|
"dialogue": "dialogue-pool.schema.json",
|
|
"monologue": "monologue-pool.schema.json",
|
|
"routines": "routine.schema.json",
|
|
}
|
|
|
|
|
|
def resolve_schema(yaml_path: Path) -> Path | None:
|
|
"""Determine which schema applies to a content YAML file."""
|
|
name = yaml_path.name
|
|
if name in FILENAME_SCHEMAS:
|
|
return SCHEMA_DIR / FILENAME_SCHEMAS[name]
|
|
|
|
# Walk up parents to find a matching directory name
|
|
rel = yaml_path.relative_to(CONTENT_DIR)
|
|
for part in reversed(rel.parts[:-1]):
|
|
if part in DIR_SCHEMAS:
|
|
return SCHEMA_DIR / DIR_SCHEMAS[part]
|
|
|
|
return None
|
|
|
|
|
|
def _rel(path: Path) -> str:
|
|
"""Return path relative to CONTENT_DIR for display."""
|
|
try:
|
|
return str(path.relative_to(CONTENT_DIR))
|
|
except ValueError:
|
|
return str(path)
|
|
|
|
|
|
def _load_yaml(path: Path) -> dict | list | None:
|
|
"""Load a YAML file, returning None for empty/comment-only files."""
|
|
try:
|
|
with open(path) as f:
|
|
return yaml.safe_load(f)
|
|
except yaml.YAMLError:
|
|
return None
|
|
|
|
|
|
def _district_dir(path: Path) -> Path | None:
|
|
"""Find the district directory containing this file."""
|
|
parts = path.resolve().parts
|
|
for i, part in enumerate(parts):
|
|
if part == "districts" and i + 1 < len(parts):
|
|
return Path(*parts[: i + 2])
|
|
return None
|
|
|
|
|
|
class ContentIndex:
|
|
"""Builds an index of all defined entities for cross-referencing."""
|
|
|
|
def __init__(self, campaigns_dir: Path):
|
|
self.campaigns_dir = campaigns_dir
|
|
self.npcs: dict[str, Path] = {} # canonical_id -> defining file
|
|
self.locations: dict[str, set] = {} # district_dir str -> set of location slugs
|
|
self.triangle_slugs: dict[str, set] = {} # district_dir str -> set of triangle file stems
|
|
self.fact_ids: set[str] = set() # from knowledge catalogs
|
|
self.dialogue_files: list[tuple[Path, dict]] = []
|
|
self.monologue_files: list[tuple[Path, dict]] = []
|
|
self.npc_files: list[tuple[Path, dict]] = []
|
|
self.district_files: list[tuple[Path, dict]] = []
|
|
|
|
def build(self):
|
|
"""Scan all content files and populate the index."""
|
|
self._scan_npcs()
|
|
self._scan_districts()
|
|
self._scan_triangles()
|
|
self._scan_knowledge()
|
|
self._scan_dialogue()
|
|
self._scan_monologue()
|
|
|
|
def _scan_npcs(self):
|
|
for path in sorted(self.campaigns_dir.rglob("npcs/*.yaml")):
|
|
data = _load_yaml(path)
|
|
if data and isinstance(data, dict):
|
|
self.npc_files.append((path, data))
|
|
cid = data.get("canonical_id")
|
|
if cid:
|
|
self.npcs[cid] = path
|
|
|
|
def _scan_districts(self):
|
|
for path in sorted(self.campaigns_dir.rglob("district.yaml")):
|
|
data = _load_yaml(path)
|
|
if data and isinstance(data, dict):
|
|
self.district_files.append((path, data))
|
|
district_dir = str(path.parent.resolve())
|
|
locs = data.get("locations", [])
|
|
if isinstance(locs, list):
|
|
self.locations[district_dir] = set(locs)
|
|
|
|
def _scan_triangles(self):
|
|
for path in sorted(self.campaigns_dir.rglob("triangles/*.yaml")):
|
|
data = _load_yaml(path)
|
|
if data and isinstance(data, dict):
|
|
district_dir = str(path.parent.parent.resolve())
|
|
if district_dir not in self.triangle_slugs:
|
|
self.triangle_slugs[district_dir] = set()
|
|
self.triangle_slugs[district_dir].add(path.stem)
|
|
|
|
def _scan_knowledge(self):
|
|
knowledge_dir = CONTENT_DIR / "global" / "knowledge"
|
|
if not knowledge_dir.exists():
|
|
return
|
|
for path in sorted(knowledge_dir.glob("*.yaml")):
|
|
if path.name == "entity-attributes.yaml":
|
|
continue
|
|
data = _load_yaml(path)
|
|
if not data or not isinstance(data, dict):
|
|
# Stub catalogs (comment-only or flat key-value) don't parse
|
|
# as dicts. Fall back to regex extraction of fact_id lines.
|
|
try:
|
|
with open(path) as f:
|
|
for line in f:
|
|
line = line.strip()
|
|
if line.startswith("#") or not line:
|
|
continue
|
|
m = re.match(r"fact_id:\s*[\"']?([^\"'#]+)", line)
|
|
if m:
|
|
self.fact_ids.add(m.group(1).strip())
|
|
except OSError:
|
|
pass
|
|
continue
|
|
# Handle structured catalogs
|
|
self._extract_fact_ids_from_dict(data)
|
|
|
|
def _extract_fact_ids_from_dict(self, data, prefix=""):
|
|
"""Recursively extract fact_id values from knowledge catalog structures."""
|
|
if isinstance(data, dict):
|
|
for key, val in data.items():
|
|
if key == "fact_id" and isinstance(val, str):
|
|
self.fact_ids.add(val)
|
|
elif isinstance(val, (dict, list)):
|
|
self._extract_fact_ids_from_dict(val)
|
|
elif isinstance(data, list):
|
|
for item in data:
|
|
self._extract_fact_ids_from_dict(item)
|
|
|
|
def _scan_dialogue(self):
|
|
for path in sorted(self.campaigns_dir.rglob("dialogue/**/*.yaml")):
|
|
if path.name.startswith("."):
|
|
continue
|
|
data = _load_yaml(path)
|
|
if data and isinstance(data, dict):
|
|
self.dialogue_files.append((path, data))
|
|
|
|
def _scan_monologue(self):
|
|
for path in sorted(self.campaigns_dir.rglob("monologue/**/*.yaml")):
|
|
if path.name.startswith("."):
|
|
continue
|
|
data = _load_yaml(path)
|
|
if data and isinstance(data, dict):
|
|
self.monologue_files.append((path, data))
|
|
|
|
def validate_references(self) -> tuple[int, int]:
|
|
"""Check all cross-references. Returns (error_count, warning_count)."""
|
|
errors = 0
|
|
warnings = 0
|
|
|
|
errors += self._check_1_canonical_id_uniqueness()
|
|
errors += self._check_2_relationship_targets()
|
|
errors += self._check_3_location_slugs()
|
|
errors += self._check_4_dialogue_locations()
|
|
errors += self._check_5_fact_ids()
|
|
errors += self._check_6_triangle_membership()
|
|
warnings += self._check_7_npc_count()
|
|
errors += self._check_8_dialogue_line_ids()
|
|
warnings += self._check_9_bidirectional_relationships()
|
|
|
|
return errors, warnings
|
|
|
|
def _check_1_canonical_id_uniqueness(self) -> int:
|
|
"""Check that canonical_id values are unique across all NPC files."""
|
|
errors = 0
|
|
seen: dict[str, Path] = {}
|
|
for path, data in self.npc_files:
|
|
cid = data.get("canonical_id")
|
|
if not cid:
|
|
continue
|
|
if cid in seen:
|
|
print(f'XREF ERROR: duplicate canonical_id "{cid}"')
|
|
print(f" Defined in: {_rel(seen[cid])}")
|
|
print(f" Duplicate in: {_rel(path)}")
|
|
errors += 1
|
|
else:
|
|
seen[cid] = path
|
|
return errors
|
|
|
|
def _check_2_relationship_targets(self) -> int:
|
|
"""Check that all relationship targets resolve to known canonical_ids."""
|
|
errors = 0
|
|
for path, data in self.npc_files:
|
|
rels = data.get("relationships", [])
|
|
if not isinstance(rels, list):
|
|
continue
|
|
for rel in rels:
|
|
if not isinstance(rel, dict):
|
|
continue
|
|
target = rel.get("target")
|
|
if target and target not in self.npcs:
|
|
known = sorted(self.npcs.keys())
|
|
print(f'XREF ERROR: unresolved relationship target "{target}"')
|
|
print(f" In: {_rel(path)}")
|
|
print(f" Known canonical_ids: {', '.join(known[:10])}"
|
|
+ (f" (and {len(known) - 10} more)" if len(known) > 10 else ""))
|
|
errors += 1
|
|
return errors
|
|
|
|
def _check_3_location_slugs(self) -> int:
|
|
"""Check that district location slugs correspond to actual location files."""
|
|
errors = 0
|
|
for path, data in self.district_files:
|
|
locs = data.get("locations", [])
|
|
if not isinstance(locs, list):
|
|
continue
|
|
locations_dir = path.parent / "locations"
|
|
for slug in locs:
|
|
expected = locations_dir / f"{slug}.yaml"
|
|
if not expected.exists():
|
|
print(f'XREF ERROR: location slug "{slug}" not found')
|
|
print(f" In: {_rel(path)}")
|
|
print(f" Expected file: {_rel(expected)}")
|
|
errors += 1
|
|
return errors
|
|
|
|
def _check_4_dialogue_locations(self) -> int:
|
|
"""Check that dialogue file locations match district locations."""
|
|
errors = 0
|
|
for path, data in self.dialogue_files:
|
|
location = data.get("location")
|
|
if not location:
|
|
continue
|
|
district_dir = _district_dir(path)
|
|
if not district_dir:
|
|
continue
|
|
district_key = str(district_dir)
|
|
district_locs = self.locations.get(district_key, set())
|
|
if not district_locs:
|
|
print(f'XREF ERROR: dialogue location "{location}" references district with no locations declared')
|
|
print(f" In: {_rel(path)}")
|
|
print(f" District: {_rel(district_dir / 'district.yaml')}")
|
|
errors += 1
|
|
elif location not in district_locs:
|
|
print(f'XREF ERROR: dialogue location "{location}" not in district')
|
|
print(f" In: {_rel(path)}")
|
|
print(f" District locations: {', '.join(sorted(district_locs))}")
|
|
errors += 1
|
|
return errors
|
|
|
|
def _check_5_fact_ids(self) -> int:
|
|
"""Check that all referenced fact_ids resolve to knowledge catalogs."""
|
|
if not self.fact_ids:
|
|
print(" Check 5 (fact_ids): SKIPPED — no canonical fact_ids in knowledge catalogs yet")
|
|
return 0
|
|
|
|
errors = 0
|
|
# Collect all fact_id references from dialogue, monologue, NPC, and item files
|
|
refs = self._collect_all_fact_id_refs()
|
|
|
|
for fact_id, sources in sorted(refs.items()):
|
|
if fact_id not in self.fact_ids:
|
|
print(f'XREF ERROR: unknown fact_id "{fact_id}"')
|
|
for src in sources[:3]:
|
|
print(f" In: {_rel(src)}")
|
|
if len(sources) > 3:
|
|
print(f" ...and {len(sources) - 3} more files")
|
|
print(f" Canonical fact_ids: {len(self.fact_ids)} defined")
|
|
errors += 1
|
|
return errors
|
|
|
|
def _collect_all_fact_id_refs(self) -> dict[str, list[Path]]:
|
|
"""Collect all fact_id references from content files."""
|
|
refs: dict[str, list[Path]] = {}
|
|
|
|
def add_ref(fact_id: str, path: Path):
|
|
if fact_id:
|
|
refs.setdefault(fact_id, []).append(path)
|
|
|
|
# Dialogue: lines[].knowledge_grant.fact_id
|
|
for path, data in self.dialogue_files:
|
|
for line in data.get("lines", []):
|
|
if not isinstance(line, dict):
|
|
continue
|
|
kg = line.get("knowledge_grant")
|
|
if isinstance(kg, dict):
|
|
add_ref(kg.get("fact_id", ""), path)
|
|
|
|
# Monologue: entries with prerequisites.facts[].fact_id
|
|
for path, data in self.monologue_files:
|
|
self._walk_fact_id_refs(data, path, add_ref)
|
|
|
|
# NPC: information.knows[]
|
|
for path, data in self.npc_files:
|
|
info = data.get("information", {})
|
|
if isinstance(info, dict):
|
|
knows = info.get("knows", [])
|
|
if isinstance(knows, list):
|
|
for fid in knows:
|
|
if isinstance(fid, str):
|
|
add_ref(fid, path)
|
|
|
|
# Items: scan for fact_id in any nested structure
|
|
for path in sorted(self.campaigns_dir.rglob("items/*.yaml")):
|
|
data = _load_yaml(path)
|
|
if data:
|
|
self._walk_fact_id_refs(data, path, add_ref)
|
|
|
|
return refs
|
|
|
|
def _walk_fact_id_refs(self, data, path: Path, add_ref):
|
|
"""Recursively find fact_id references in nested structures."""
|
|
if isinstance(data, dict):
|
|
if "fact_id" in data and isinstance(data["fact_id"], str):
|
|
add_ref(data["fact_id"], path)
|
|
for val in data.values():
|
|
if isinstance(val, (dict, list)):
|
|
self._walk_fact_id_refs(val, path, add_ref)
|
|
elif isinstance(data, list):
|
|
for item in data:
|
|
if isinstance(item, (dict, list)):
|
|
self._walk_fact_id_refs(item, path, add_ref)
|
|
|
|
def _check_6_triangle_membership(self) -> int:
|
|
"""Check that NPC triangle_membership slugs correspond to triangle files."""
|
|
errors = 0
|
|
for path, data in self.npc_files:
|
|
memberships = data.get("triangle_membership", [])
|
|
if not isinstance(memberships, list):
|
|
continue
|
|
district_dir = _district_dir(path)
|
|
if not district_dir:
|
|
continue
|
|
district_key = str(district_dir)
|
|
available = self.triangle_slugs.get(district_key, set())
|
|
for slug in memberships:
|
|
if not isinstance(slug, str):
|
|
continue
|
|
if slug not in available:
|
|
print(f'XREF ERROR: triangle "{slug}" not found')
|
|
print(f" In: {_rel(path)}")
|
|
if available:
|
|
print(f" Available triangles: {', '.join(sorted(available))}")
|
|
else:
|
|
print(f" No triangles found in {_rel(district_dir / 'triangles')}")
|
|
errors += 1
|
|
return errors
|
|
|
|
def _check_7_npc_count(self) -> int:
|
|
"""Check that district npc_count matches actual NPC file count."""
|
|
warnings = 0
|
|
for path, data in self.district_files:
|
|
declared = data.get("npc_count")
|
|
if declared is None:
|
|
continue
|
|
npcs_dir = path.parent / "npcs"
|
|
if not npcs_dir.exists():
|
|
continue
|
|
actual = len(list(npcs_dir.glob("*.yaml")))
|
|
if declared != actual:
|
|
print(f"XREF WARNING: npc_count mismatch")
|
|
print(f" Declared: {declared}")
|
|
print(f" Actual NPC files: {actual}")
|
|
print(f" In: {_rel(path)}")
|
|
warnings += 1
|
|
return warnings
|
|
|
|
def _check_8_dialogue_line_ids(self) -> int:
|
|
"""Check that dialogue line IDs are unique across all dialogue files."""
|
|
errors = 0
|
|
# Cross-file uniqueness: same line ID in two files is an error
|
|
global_seen: dict[str, Path] = {}
|
|
for path, data in self.dialogue_files:
|
|
lines = data.get("lines", [])
|
|
if not isinstance(lines, list):
|
|
continue
|
|
local_seen: dict[str, int] = {}
|
|
for i, line in enumerate(lines, 1):
|
|
if not isinstance(line, dict):
|
|
continue
|
|
line_id = line.get("id")
|
|
if not line_id:
|
|
continue
|
|
# Per-file duplicate
|
|
if line_id in local_seen:
|
|
print(f'XREF ERROR: duplicate dialogue line id "{line_id}"')
|
|
print(f" In: {_rel(path)}")
|
|
print(f" First: line {local_seen[line_id]}")
|
|
print(f" Duplicate: line {i}")
|
|
errors += 1
|
|
else:
|
|
local_seen[line_id] = i
|
|
# Cross-file duplicate
|
|
if line_id in global_seen and global_seen[line_id] != path:
|
|
print(f'XREF ERROR: dialogue line id "{line_id}" used in multiple files')
|
|
print(f" First: {_rel(global_seen[line_id])}")
|
|
print(f" Also in: {_rel(path)}")
|
|
errors += 1
|
|
elif line_id not in global_seen:
|
|
global_seen[line_id] = path
|
|
return errors
|
|
|
|
def _check_9_bidirectional_relationships(self) -> int:
|
|
"""Check that NPC relationships have reciprocal entries.
|
|
|
|
WARNING severity: asymmetric relationships are valid by design (D-034
|
|
allows one-directional awareness). This check flags them for review,
|
|
not as errors."""
|
|
warnings = 0
|
|
# Build map: canonical_id -> set of relationship targets
|
|
npc_rels: dict[str, set[str]] = {}
|
|
for _path, data in self.npc_files:
|
|
cid = data.get("canonical_id")
|
|
if not cid:
|
|
continue
|
|
rels = data.get("relationships", [])
|
|
if isinstance(rels, list):
|
|
targets = set()
|
|
for rel in rels:
|
|
if isinstance(rel, dict) and "target" in rel:
|
|
targets.add(rel["target"])
|
|
npc_rels[cid] = targets
|
|
|
|
# Check reciprocity
|
|
checked = set()
|
|
for cid, targets in npc_rels.items():
|
|
for target in targets:
|
|
pair = tuple(sorted([cid, target]))
|
|
if pair in checked:
|
|
continue
|
|
checked.add(pair)
|
|
if target in npc_rels and cid not in npc_rels.get(target, set()):
|
|
print(f"XREF WARNING: {cid} has relationship to {target} but no reciprocal found")
|
|
warnings += 1
|
|
return warnings
|
|
|
|
|
|
def schema_validate(campaigns_dir: Path) -> tuple[int, int, int]:
|
|
"""Pass 1: Schema validation. Returns (validated, skipped, errors)."""
|
|
errors = 0
|
|
validated = 0
|
|
skipped = 0
|
|
schema_cache: dict[str, dict] = {}
|
|
|
|
yaml_files = sorted(campaigns_dir.rglob("*.yaml"))
|
|
if not yaml_files:
|
|
print("No YAML files found under campaigns/", file=sys.stderr)
|
|
return 0, 0, 1
|
|
|
|
for yaml_path in yaml_files:
|
|
schema_path = resolve_schema(yaml_path)
|
|
if schema_path is None:
|
|
skipped += 1
|
|
continue
|
|
|
|
if not schema_path.exists():
|
|
print(f"MISSING SCHEMA: {schema_path.name} for {yaml_path.relative_to(CONTENT_DIR)}")
|
|
errors += 1
|
|
continue
|
|
|
|
schema_key = str(schema_path)
|
|
if schema_key not in schema_cache:
|
|
with open(schema_path) as f:
|
|
schema_cache[schema_key] = json.load(f)
|
|
schema = schema_cache[schema_key]
|
|
|
|
try:
|
|
with open(yaml_path) as f:
|
|
data = yaml.safe_load(f)
|
|
except yaml.YAMLError as e:
|
|
print(f"YAML ERROR: {yaml_path.relative_to(CONTENT_DIR)}: {e}")
|
|
errors += 1
|
|
continue
|
|
|
|
if data is None:
|
|
skipped += 1
|
|
continue
|
|
|
|
try:
|
|
jsonschema.validate(instance=data, schema=schema)
|
|
validated += 1
|
|
except jsonschema.ValidationError as e:
|
|
rel = yaml_path.relative_to(CONTENT_DIR)
|
|
print(f"INVALID: {rel}")
|
|
print(f" Schema: {schema_path.name}")
|
|
print(f" Error: {e.message}")
|
|
if e.absolute_path:
|
|
print(f" Path: {'.'.join(str(p) for p in e.absolute_path)}")
|
|
errors += 1
|
|
|
|
return validated, skipped, errors
|
|
|
|
|
|
def main() -> int:
|
|
campaigns_dir = CONTENT_DIR / "campaigns"
|
|
if not campaigns_dir.exists():
|
|
print(f"No campaigns directory at {campaigns_dir}", file=sys.stderr)
|
|
return 1
|
|
|
|
# Pass 1: Schema validation
|
|
validated, skipped, schema_errors = schema_validate(campaigns_dir)
|
|
print(f"\nPass 1 (schema): {validated} validated, {skipped} skipped, {schema_errors} errors")
|
|
|
|
if schema_errors > 0:
|
|
print(f"\nSchema validation failed ({schema_errors} errors) -- skipping cross-references")
|
|
return 1
|
|
|
|
# Pass 2: Cross-reference validation
|
|
print("\nPass 2 (cross-references):")
|
|
index = ContentIndex(campaigns_dir)
|
|
index.build()
|
|
xref_errors, xref_warnings = index.validate_references()
|
|
|
|
total_errors = schema_errors + xref_errors
|
|
print(f"\nValidated {validated} files: {total_errors} errors, {xref_warnings} warnings")
|
|
return 1 if total_errors > 0 else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|