Files
jpmschweitzerandClaude Opus 5 7f20bd303b feat(config): T-1282 — the validate domain, and a move that broke a root
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>
2026-09-02 12:48:56 +02:00

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