From 0c91f5af21435210f8dabe2c1d3492eb4d1a7faf Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 12 Feb 2026 22:53:05 +0100 Subject: [PATCH] chore(tooling): add make validate-content for schema validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Makefile | 8 ++- tooling/validate-content | 132 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+), 1 deletion(-) create mode 100755 tooling/validate-content diff --git a/Makefile b/Makefile index e9950d5b6..9f8ac26a8 100644 --- a/Makefile +++ b/Makefile @@ -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: diff --git a/tooling/validate-content b/tooling/validate-content new file mode 100755 index 000000000..8686778a7 --- /dev/null +++ b/tooling/validate-content @@ -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())