120 lines
4.4 KiB
Python
120 lines
4.4 KiB
Python
#!/usr/bin/env python3
|
|
"""Validate content catalogs against JSON Schema (CI + local).
|
|
|
|
Today: each object in content/skills/*.json top-level ``skills`` array vs
|
|
content/schemas/skill-def.schema.json. Extend this script as new catalogs land.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
from jsonschema import Draft202012Validator
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
SKILL_SCHEMA = REPO_ROOT / "content/schemas/skill-def.schema.json"
|
|
SKILLS_DIR = REPO_ROOT / "content/skills"
|
|
|
|
# Slice 1 prototype lock (NEO-33): exact ids + category coverage after schema passes.
|
|
PROTOTYPE_SLICE1_SKILL_IDS = frozenset({"salvage", "refine", "intrusion"})
|
|
|
|
|
|
def _prototype_slice1_gate(seen_ids: dict[str, str], id_to_category: dict[str, str]) -> str | None:
|
|
"""Return a human-readable error if Slice 1 contract fails, else None."""
|
|
ids = frozenset(seen_ids.keys())
|
|
if ids != PROTOTYPE_SLICE1_SKILL_IDS:
|
|
return (
|
|
"error: prototype Slice 1 expects exactly skill ids "
|
|
f"{sorted(PROTOTYPE_SLICE1_SKILL_IDS)!r}, got {sorted(ids)!r}"
|
|
)
|
|
cats = set(id_to_category.values())
|
|
if "gather" not in cats or "tech" not in cats:
|
|
return (
|
|
"error: prototype Slice 1 requires at least one gather and one tech skill; "
|
|
f"categories seen: {sorted(cats)!r}"
|
|
)
|
|
if "process" not in cats and "make" not in cats:
|
|
return (
|
|
"error: prototype Slice 1 requires at least one process or make skill; "
|
|
f"categories seen: {sorted(cats)!r}"
|
|
)
|
|
return None
|
|
|
|
|
|
def main() -> int:
|
|
if not SKILL_SCHEMA.is_file():
|
|
print(f"error: missing schema {SKILL_SCHEMA}", file=sys.stderr)
|
|
return 1
|
|
|
|
schema = json.loads(SKILL_SCHEMA.read_text(encoding="utf-8"))
|
|
validator = Draft202012Validator(schema)
|
|
|
|
if not SKILLS_DIR.is_dir():
|
|
print(f"error: missing directory {SKILLS_DIR}", file=sys.stderr)
|
|
return 1
|
|
|
|
json_files = sorted(SKILLS_DIR.glob("*.json"))
|
|
if not json_files:
|
|
print(f"error: no JSON files under {SKILLS_DIR}", file=sys.stderr)
|
|
return 1
|
|
|
|
seen_ids: dict[str, str] = {}
|
|
id_to_category: dict[str, str] = {}
|
|
errors = 0
|
|
|
|
for path in json_files:
|
|
data = json.loads(path.read_text(encoding="utf-8"))
|
|
skills = data.get("skills")
|
|
if not isinstance(skills, list):
|
|
print(f"error: {path.relative_to(REPO_ROOT)}: expected top-level 'skills' array", file=sys.stderr)
|
|
errors += 1
|
|
continue
|
|
for i, row in enumerate(skills):
|
|
if not isinstance(row, dict):
|
|
print(f"error: {path.relative_to(REPO_ROOT)}: skills[{i}] must be an object", file=sys.stderr)
|
|
errors += 1
|
|
continue
|
|
row_schema_errors = 0
|
|
for err in sorted(validator.iter_errors(row), key=lambda e: e.path):
|
|
loc = ".".join(str(p) for p in err.path) or "(root)"
|
|
print(
|
|
f"error: {path.relative_to(REPO_ROOT)} skills[{i}] {loc}: {err.message}",
|
|
file=sys.stderr,
|
|
)
|
|
row_schema_errors += 1
|
|
errors += 1
|
|
sid = row.get("id")
|
|
# Reserve seen_ids (and duplicate detection) only for schema-clean rows so
|
|
# fixing a broken row does not surface a spurious duplicate vs an earlier invalid row.
|
|
if isinstance(sid, str) and row_schema_errors == 0:
|
|
prev = seen_ids.get(sid)
|
|
if prev:
|
|
print(
|
|
f"error: duplicate skill id {sid!r} in {prev} and {path.relative_to(REPO_ROOT)}",
|
|
file=sys.stderr,
|
|
)
|
|
errors += 1
|
|
else:
|
|
seen_ids[sid] = str(path.relative_to(REPO_ROOT))
|
|
cat = row.get("category")
|
|
if isinstance(cat, str):
|
|
id_to_category[sid] = cat
|
|
|
|
if errors:
|
|
print(f"content validation failed with {errors} error(s)", file=sys.stderr)
|
|
return 1
|
|
|
|
slice1_err = _prototype_slice1_gate(seen_ids, id_to_category)
|
|
if slice1_err:
|
|
print(slice1_err, file=sys.stderr)
|
|
return 1
|
|
|
|
print(f"content OK: {len(json_files)} skill catalog file(s), {len(seen_ids)} unique skill id(s)")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|