Add catalog validator covering schema plus cross-field rules

This commit is contained in:
2026-07-31 17:50:27 +01:00
parent a2e2875449
commit 1e77308e6c
+123
View File
@@ -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())