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

113 lines
4.0 KiB
Python

"""RON validation — ported from the bash `validate-ron` (D-263).
The old script was three languages deep: bash dispatching on a flag, a Python
heredoc doing collision detection, and `cargo run` for schema validation. The
heredoc is the reason this is a rewrite rather than a move — logic embedded in
a shell string cannot be imported, cannot be tested, and cannot be read by
anything that indexes Python.
What stayed shell-shaped is the one genuine OS interaction: running the Rust
validator. That goes through `core.process.run`, which is the sanctioned exec.
"""
from __future__ import annotations
import re
from pathlib import Path
from tooling.core import config, console, process
from tooling.core.errors import ReachError
SCHEMAS = ("zone", "zone_type", "culture")
def name_collisions(directory: Path) -> int:
"""Report names shared between culture name pools. Returns an exit code.
A name appearing in two cultures' pools makes generated NPCs ambiguous
about where they are from, which is invisible until someone notices two
cultures producing the same surnames.
"""
if not directory.is_dir():
raise ReachError(
f"directory not found: {directory}",
fix="pass a directory containing culture-*.ron files",
)
files = sorted(directory.glob("culture-*.ron"))
if not files:
console.event(f"No culture-*.ron files found in: {directory}")
return 0
given: dict[str, set[str]] = {}
family: dict[str, set[str]] = {}
for path in files:
text = path.read_text(encoding="utf-8")
match = re.search(r'\bid\s*:\s*"([^"]+)"', text)
culture = match.group(1) if match else path.name
given[culture] = set(_names(text, "given_names"))
family[culture] = set(_names(text, "family_names"))
collisions = False
for field, pools in (("given_names", given), ("family_names", family)):
for name, cultures in sorted(_shared(pools).items()):
collisions = True
console.event(
f'COLLISION {field}: "{name}" in {", ".join(sorted(cultures))}',
level="error",
)
if collisions:
return 1
console.verdict(
f"OK: no name collisions across {len(given)} culture(s): "
f"{', '.join(sorted(given))}"
)
return 0
def ron_file(path: Path, schema: str) -> int:
"""Validate one .ron file against a Rust struct schema."""
if schema not in SCHEMAS:
raise ReachError(
f"unknown schema {schema!r}",
fix=f"choose one of: {', '.join(SCHEMAS)}",
exit_code=2,
)
if not path.is_file():
# Checked before resolving, because realpath on a missing file gives an
# error about the path rather than about the file — the old script made
# the same distinction and it is worth keeping.
raise ReachError(
f"file not found: {path}",
fix="check the path, or pass a directory to `reach validate name-collisions`",
)
result = process.run(
["cargo", "run", "--quiet", "--bin", "validate_ron", "--", str(path.resolve()), schema],
cwd=config.path("server"),
check=False,
capture=False,
missing_fix="install Rust — make setup-rust",
)
return result.returncode
def _names(text: str, field: str) -> list[str]:
"""Quoted strings from a named RON array field, comments stripped."""
match = re.search(rf"\b{re.escape(field)}\s*:\s*\[([^\]]*)\]", text, re.DOTALL)
if not match:
return []
block = re.sub(r"//[^\n]*", "", match.group(1))
return re.findall(r'"([^"]+)"', block)
def _shared(pools: dict[str, set[str]]) -> dict[str, list[str]]:
"""name -> the cultures claiming it, for names claimed more than once."""
owners: dict[str, list[str]] = {}
for culture, names in pools.items():
for name in names:
owners.setdefault(name, []).append(culture)
return {name: cultures for name, cultures in owners.items() if len(cultures) > 1}