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:
@@ -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:
|
||||
|
||||
Executable
+132
@@ -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())
|
||||
Reference in New Issue
Block a user