diff --git a/scripts/validate.py b/scripts/validate.py new file mode 100644 index 0000000..586ec84 --- /dev/null +++ b/scripts/validate.py @@ -0,0 +1,123 @@ +#!/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())