Files
settled-reach/tooling/validate-content
T
jpmschweitzerandClaude Opus 4.6 4af2197c59 fix(ci): address PR review — filter comments, trim whitespace, warn on missing scripts
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>
2026-02-18 00:23:21 +01:00

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())