Python script validates campaign YAML files against JSON schemas in content/_schema/. Maps files to schemas by directory context (npcs/ → npc-profile.schema.json, etc.). Skips comment-only placeholder stubs. Addresses Hoshe #2 review item. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
133 lines
4.0 KiB
Python
Executable File
133 lines
4.0 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Validate content YAML files against their JSON schemas.
|
|
|
|
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 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 main() -> int:
|
|
errors = 0
|
|
validated = 0
|
|
skipped = 0
|
|
|
|
# Cache loaded schemas
|
|
schema_cache: dict[str, dict] = {}
|
|
|
|
campaigns_dir = CONTENT_DIR / "campaigns"
|
|
if not campaigns_dir.exists():
|
|
print(f"No campaigns directory at {campaigns_dir}", file=sys.stderr)
|
|
return 1
|
|
|
|
yaml_files = sorted(campaigns_dir.rglob("*.yaml"))
|
|
if not yaml_files:
|
|
print("No YAML files found under campaigns/", file=sys.stderr)
|
|
return 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
|
|
|
|
# Load schema (cached)
|
|
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]
|
|
|
|
# Load YAML
|
|
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:
|
|
# Comment-only or empty placeholder files are valid stubs
|
|
skipped += 1
|
|
continue
|
|
|
|
# Validate
|
|
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
|
|
|
|
print(f"\nValidated {validated} files, {skipped} skipped, {errors} errors")
|
|
return 1 if errors else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|