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