#!/usr/bin/env python3 """Validate every season file against the schema, plus the rules a JSON Schema cannot express on its own. Run it locally exactly as CI does: pip install jsonschema python scripts/validate.py Exits non-zero and prints every problem it found, rather than stopping at the first one — a contributor fixing a hand-edited file should see the whole list in one pass. """ from __future__ import annotations import json import sys from pathlib import Path from jsonschema import Draft202012Validator, FormatChecker REPO = Path(__file__).resolve().parent.parent SCHEMA_PATH = REPO / "schema" / "sprite-catalog.schema.json" SEASONS_DIR = REPO / "seasons" def check_season_file(path: Path, validator: Draft202012Validator) -> list[str]: """Return a list of human-readable problems with one season file.""" problems: list[str] = [] try: data = json.loads(path.read_text(encoding="utf-8")) except json.JSONDecodeError as exc: return [f"{path.name}: invalid JSON — {exc}"] # Schema first. Everything below assumes the shape is broadly right. schema_errors = sorted(validator.iter_errors(data), key=lambda e: list(e.path)) for err in schema_errors: location = "/".join(str(p) for p in err.path) or "(root)" problems.append(f"{path.name}: {location}: {err.message}") if schema_errors: return problems # The filename is the season identifier. Letting these drift means the bot # loads a file whose contents disagree with its name. if data["season"] != path.stem: problems.append( f"{path.name}: season is {data['season']!r} but the filename says " f"{path.stem!r} — they must match" ) seen_bases: dict[str, int] = {} for i, sprite in enumerate(data["sprites"]): base = sprite["base_name"] if base in seen_bases: problems.append( f"{path.name}: sprites[{i}]: duplicate base_name {base!r} " f"(first seen at sprites[{seen_bases[base]}])" ) seen_bases[base] = i seen_variants: dict[str, int] = {} for j, variant in enumerate(sprite["variants"]): name = variant["variant"] where = f"sprites[{i}].variants[{j}]" if name in seen_variants: problems.append( f"{path.name}: {where}: duplicate variant {name!r} for base " f"{base!r} (first seen at index {seen_variants[name]})" ) seen_variants[name] = j # A released sprite without a release date breaks the "new sprite" # giveaway trigger, which keys off released_at. if variant["status"] == "released" and "released_at" not in variant: problems.append( f"{path.name}: {where}: status is released but released_at " f"is missing" ) # Claiming a real owner for something not yet obtainable is always # a mistake, usually a copy-paste one. if variant["status"] == "unreleased" and variant.get("confirmed"): problems.append( f"{path.name}: {where}: confirmed is true but status is " f"unreleased — an unreleased sprite cannot have a " f"confirmed owner" ) return problems def main() -> int: if not SCHEMA_PATH.exists(): print(f"error: schema not found at {SCHEMA_PATH}", file=sys.stderr) return 1 schema = json.loads(SCHEMA_PATH.read_text(encoding="utf-8")) validator = Draft202012Validator(schema, format_checker=FormatChecker()) season_files = sorted(SEASONS_DIR.glob("*.json")) if SEASONS_DIR.exists() else [] if not season_files: print("No season files yet — nothing to validate.") return 0 all_problems: list[str] = [] for path in season_files: all_problems.extend(check_season_file(path, validator)) if all_problems: print(f"{len(all_problems)} problem(s) found:\n", file=sys.stderr) for problem in all_problems: print(f" {problem}", file=sys.stderr) return 1 print(f"OK — {len(season_files)} season file(s) valid.") return 0 if __name__ == "__main__": sys.exit(main())