reach validate content / checklist / ron / name-collisions. The three old scripts are retired, their make targets with them. Print statements go through the logging sink rather than a collector. The validators emit their findings as console events as they run, so a long content validation streams instead of going quiet and dumping at the end — the message strings and their order are unchanged, only the destination. That also satisfies the conformance rule forbidding print() in the package, which is what forced the question. validate-ron was three languages deep: bash dispatching on a flag, a Python heredoc doing collision detection, cargo run for schema validation. Logic embedded in a shell string cannot be imported, tested, or found by anything that indexes Python, so it became Python; the cargo call became a guarded exec. It also split into two verbs, because --check-name-collisions answered a different question from the default path: whether the SET of cultures is coherent, versus whether ONE file is well-formed. The move broke something, quietly, which is the point of doing these one at a time. validate-checklist computed ROOT as Path(__file__).parent.parent — the repo root while it lived at tooling/validate-checklist, and tooling/domains once moved. Both its schema and gauntlet paths silently repointed at nothing, the gauntlet directory "did not exist", and it reported success having checked zero files. Caught by running it beside the original: old exit 1, new exit 0. Now config.repo_root(), and load_schema raises ReachError instead of calling sys.exit, which a service must not do. Parity on the live tree: content reproduces the original byte for byte including its counts, name-collisions likewise. Tests pin what those runs cannot reach — the detection path, since the repo currently has no collisions, and the argument errors. Two things found and left alone: validate-content FAILS on the live tree with 13 missing schemas, pre-existing and unrelated to this port; and the ticket's claim that validate-content sits in the pre-commit hook is wrong — that hook runs only check-fact-ids and pql decisions validate, so there was no shared edit to coordinate. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
207 lines
6.2 KiB
Python
Executable File
207 lines
6.2 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Validate Gauntlet checklist YAML files against the checklist JSON schema.
|
|
|
|
Usage:
|
|
validate-checklist Validate all checklists + print summary
|
|
validate-checklist --check Schema validation only (for pre-PR chain)
|
|
|
|
Exit code 0 = all valid, 1 = validation errors found.
|
|
"""
|
|
|
|
import json
|
|
from pathlib import Path
|
|
|
|
from tooling.core import config, console
|
|
from tooling.core.errors import ReachError
|
|
|
|
import jsonschema
|
|
import yaml
|
|
|
|
# config.repo_root(), NOT __file__-relative. The original computed
|
|
# Path(__file__).parent.parent, which meant the repo root while this file lived
|
|
# at tooling/validate-checklist and means tooling/domains now. Moving the file
|
|
# silently repointed both paths at nothing, the gauntlet directory "did not
|
|
# exist", and the validator reported success having checked zero files — the
|
|
# exact shape of failure this whole initiative keeps finding.
|
|
ROOT = config.repo_root()
|
|
SCHEMA_PATH = ROOT / "server" / "content" / "_schema" / "checklist.schema.json"
|
|
GAUNTLET_DIR = ROOT / "server" / "content" / "gauntlet"
|
|
|
|
|
|
|
|
|
|
|
|
def load_schema():
|
|
if not SCHEMA_PATH.exists():
|
|
# ReachError, not sys.exit: a service must not decide to end the
|
|
# process, and the caller gets a remedy rather than a bare 1.
|
|
raise ReachError(
|
|
f"checklist schema not found at {_rel(SCHEMA_PATH)}",
|
|
fix="expected server/content/_schema/checklist.schema.json — "
|
|
"restore it or correct the path",
|
|
)
|
|
with open(SCHEMA_PATH) as f:
|
|
return json.load(f)
|
|
|
|
|
|
def find_checklists():
|
|
"""Find all checklist YAML files under content/gauntlet/."""
|
|
files = []
|
|
if not GAUNTLET_DIR.exists():
|
|
return files
|
|
for path in sorted(GAUNTLET_DIR.rglob("checklist.yaml")):
|
|
files.append(path)
|
|
cross = GAUNTLET_DIR / "cross_room_checks.yaml"
|
|
if cross.exists():
|
|
files.append(cross)
|
|
return files
|
|
|
|
|
|
def _rel(path: Path) -> str:
|
|
try:
|
|
return str(path.relative_to(ROOT))
|
|
except ValueError:
|
|
return str(path)
|
|
|
|
|
|
def validate_schema(files, schema):
|
|
"""Pass 1: JSON Schema validation. Returns (validated, errors)."""
|
|
errors = 0
|
|
validated = 0
|
|
for path in files:
|
|
rel = _rel(path)
|
|
try:
|
|
with open(path) as f:
|
|
data = yaml.safe_load(f)
|
|
except yaml.YAMLError as e:
|
|
console.event(f"YAML ERROR: {rel}: {e}")
|
|
errors += 1
|
|
continue
|
|
|
|
if data is None:
|
|
console.event(f"EMPTY: {rel}")
|
|
errors += 1
|
|
continue
|
|
|
|
try:
|
|
jsonschema.validate(instance=data, schema=schema)
|
|
validated += 1
|
|
except jsonschema.ValidationError as e:
|
|
console.event(f"INVALID: {rel}")
|
|
console.event(f" Error: {e.message}")
|
|
if e.absolute_path:
|
|
console.event(f" Path: {'.'.join(str(p) for p in e.absolute_path)}")
|
|
errors += 1
|
|
|
|
return validated, errors
|
|
|
|
|
|
def check_id_uniqueness(files):
|
|
"""Pass 2: Condition ID uniqueness within and across files."""
|
|
errors = 0
|
|
global_ids: dict[str, Path] = {}
|
|
|
|
for path in files:
|
|
rel = _rel(path)
|
|
try:
|
|
with open(path) as f:
|
|
data = yaml.safe_load(f)
|
|
except (yaml.YAMLError, OSError):
|
|
continue
|
|
if not data or not isinstance(data, dict):
|
|
continue
|
|
|
|
local_seen: set[str] = set()
|
|
for cond in data.get("conditions", []):
|
|
cid = cond.get("id")
|
|
if not cid:
|
|
continue
|
|
if cid in local_seen:
|
|
console.event(f'ID ERROR: duplicate condition id "{cid}" in {rel}')
|
|
errors += 1
|
|
local_seen.add(cid)
|
|
|
|
if cid in global_ids and global_ids[cid] != path:
|
|
console.event(f'ID ERROR: condition id "{cid}" used in multiple files')
|
|
console.event(f" First: {_rel(global_ids[cid])}")
|
|
console.event(f" Also: {rel}")
|
|
errors += 1
|
|
elif cid not in global_ids:
|
|
global_ids[cid] = path
|
|
|
|
return errors
|
|
|
|
|
|
def summarize(files):
|
|
"""Print per-room condition counts and type breakdown."""
|
|
total = 0
|
|
type_counts: dict[str, int] = {}
|
|
|
|
for path in files:
|
|
try:
|
|
with open(path) as f:
|
|
data = yaml.safe_load(f)
|
|
except (yaml.YAMLError, OSError):
|
|
continue
|
|
if not data or not isinstance(data, dict):
|
|
continue
|
|
|
|
conditions = data.get("conditions", [])
|
|
count = len(conditions)
|
|
total += count
|
|
room = data.get("room_id", "cross_room")
|
|
console.event(f" {room}: {count} conditions")
|
|
|
|
for cond in conditions:
|
|
ct = cond.get("condition_type", "unknown")
|
|
type_counts[ct] = type_counts.get(ct, 0) + 1
|
|
|
|
console.event(f"\n Total: {total} conditions across {len(files)} files")
|
|
if type_counts:
|
|
console.event(" By type:")
|
|
for ct in sorted(type_counts):
|
|
console.event(f" {ct}: {type_counts[ct]}")
|
|
|
|
|
|
def validate(check_only: bool = False) -> int:
|
|
|
|
if not GAUNTLET_DIR.exists():
|
|
console.event(f"No gauntlet directory at {_rel(GAUNTLET_DIR)}")
|
|
console.event("Checklist validation skipped (no content yet).")
|
|
return 0
|
|
|
|
files = find_checklists()
|
|
if not files:
|
|
console.event("No checklist files found under content/gauntlet/.")
|
|
console.event("Checklist validation skipped.")
|
|
return 0
|
|
|
|
schema = load_schema()
|
|
|
|
# Pass 1: Schema validation
|
|
validated, schema_errors = validate_schema(files, schema)
|
|
console.event(f"Pass 1 (schema): {validated} valid, {schema_errors} errors")
|
|
|
|
if schema_errors > 0:
|
|
console.event(f"\nSchema validation failed ({schema_errors} errors) — skipping ID checks")
|
|
return 1
|
|
|
|
# Pass 2: Condition ID uniqueness
|
|
id_errors = check_id_uniqueness(files)
|
|
if id_errors > 0:
|
|
console.event(f"Pass 2 (IDs): {id_errors} errors")
|
|
|
|
total_errors = schema_errors + id_errors
|
|
console.event(f"\nChecklist validation: {validated} valid, {total_errors} errors")
|
|
|
|
if total_errors > 0:
|
|
return 1
|
|
|
|
if not check_only:
|
|
console.event("\nChecklist summary:")
|
|
summarize(files)
|
|
|
|
return 0
|
|
|
|
|