Add catalog validator covering schema plus cross-field rules
This commit is contained in:
@@ -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())
|
||||||
Reference in New Issue
Block a user