chore(tooling): add make validate-content for schema validation

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>
This commit is contained in:
2026-02-12 22:53:05 +01:00
co-authored by Claude Opus 4.6
parent 0157d1fa38
commit 0c91f5af21
2 changed files with 139 additions and 1 deletions
+7 -1
View File
@@ -2,7 +2,7 @@ GODOT := $(shell command -v godot4 2>/dev/null || command -v godot 2>/dev/null)
.PHONY: help setup build client server test lint ci ci-client ci-server clean \
decisions-sync decisions-coverage decisions-active decisions-orphan \
db-backup db-install
db-backup db-install validate-content
# --- Configuration ---
@@ -30,6 +30,7 @@ help:
@echo " make decisions-coverage Decision-to-ticket coverage by domain"
@echo " make decisions-active List active decisions"
@echo " make decisions-orphan Decisions without implementing tickets"
@echo " make validate-content Validate content YAML against schemas"
@echo ""
@echo " GODOT_VERSION=4.6 make setup Override Godot version"
@@ -127,6 +128,11 @@ decisions-active:
decisions-orphan:
@db/connectors/sqlite-query "SELECT id, title FROM decisions WHERE type='confirmed' AND status='active' AND id NOT IN (SELECT DISTINCT decision_ref FROM tickets WHERE decision_ref IS NOT NULL)"
# --- Content Validation ---
validate-content:
@tooling/validate-content
# --- Clean ---
clean:
+132
View File
@@ -0,0 +1,132 @@
#!/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())