Files
settled-reach/tooling/test_validate.py
T
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

181 lines
6.1 KiB
Python

#!/usr/bin/env python3
"""Behaviour of the `reach validate` verbs (T-1282).
Parity against the scripts these replaced was established on the live tree —
`content` reproduces the original's output byte for byte including its counts,
`name-collisions` likewise — and recorded in the ticket. What is pinned here is
the behaviour those runs could not reach: the failure paths.
That gap matters more than usual for this domain. `validate-content` currently
FAILS on the live tree (13 missing schemas, pre-existing), so its success path
is the one nothing exercises; `name-collisions` currently PASSES, so its
detection path is the one nothing exercises. A live run proves whichever half
the repo happens to be in.
Run: python3 tooling/test_validate.py
"""
import os
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
CULTURE = '''(
id: "{cid}",
naming: (
given_names: [
// a comment mentioning "decoy" which must not be collected
{given}
],
family_names: [
{family}
],
),
)
'''
def _reach(*args: str, root: Path | None = None) -> subprocess.CompletedProcess[str]:
env = {**os.environ, "SR_OUTPUT_FORMAT": "text"}
if root is not None:
env["SR_REPO_ROOT"] = str(root)
return subprocess.run(
["reach", "validate", *args],
capture_output=True,
text=True,
cwd=REPO_ROOT,
env=env,
)
def _cultures(directory: Path, pools: dict[str, tuple[list[str], list[str]]]) -> None:
directory.mkdir(parents=True, exist_ok=True)
for cid, (given, family) in pools.items():
(directory / f"culture-{cid}.ron").write_text(
CULTURE.format(
cid=cid,
given=", ".join(f'"{n}"' for n in given),
family=", ".join(f'"{n}"' for n in family),
),
encoding="utf-8",
)
def test_collision_detected(failures: list[str]) -> None:
"""Two cultures sharing a name is a failure that names both."""
with tempfile.TemporaryDirectory() as tmp:
directory = Path(tmp) / "global"
_cultures(
directory,
{
"alpha": (["Ada", "Shared"], ["Alpha"]),
"beta": (["Bo", "Shared"], ["Beta"]),
},
)
result = _reach("name-collisions", str(directory))
combined = result.stdout + result.stderr
if result.returncode == 0:
failures.append(
"[collision] a shared name exited 0 — a collision that reports "
"success makes generated NPCs ambiguous about their origin, "
"invisibly"
)
if "Shared" not in combined:
failures.append("[collision] the colliding name is not in the output")
for culture in ("alpha", "beta"):
if culture not in combined:
failures.append(
f"[collision] {culture} is not named — a collision report that "
"omits an owner cannot be acted on"
)
if "decoy" in combined:
failures.append(
"[collision] a name inside a // comment was collected; comments "
"must be stripped before extracting quoted strings"
)
def test_no_collision(failures: list[str]) -> None:
with tempfile.TemporaryDirectory() as tmp:
directory = Path(tmp) / "global"
_cultures(directory, {"alpha": (["Ada"], ["Alpha"]), "beta": (["Bo"], ["Beta"])})
result = _reach("name-collisions", str(directory))
if result.returncode != 0:
failures.append(
f"[no-collision] exited {result.returncode} with disjoint pools"
)
def test_empty_directory(failures: list[str]) -> None:
"""No culture files is not a failure — there is nothing to contradict."""
with tempfile.TemporaryDirectory() as tmp:
directory = Path(tmp) / "global"
directory.mkdir(parents=True)
result = _reach("name-collisions", str(directory))
if result.returncode != 0:
failures.append(
f"[empty] exited {result.returncode} on a directory with no cultures"
)
def test_missing_directory(failures: list[str]) -> None:
result = _reach("name-collisions", "/definitely/not/a/directory")
if result.returncode == 0:
failures.append("[missing-dir] a nonexistent directory exited 0")
def test_ron_argument_errors(failures: list[str]) -> None:
"""Bad arguments fail before anything is executed.
Both cases matter because the alternative is invoking cargo to discover
them, which is slow and reports the mistake in the validator's vocabulary
rather than the caller's.
"""
unknown = _reach("ron", "server/content/global/culture-osse.ron", "not_a_schema")
if unknown.returncode != 2:
failures.append(
f"[ron-schema] unknown schema exited {unknown.returncode}, expected 2"
)
if "zone_type" not in (unknown.stdout + unknown.stderr):
failures.append(
"[ron-schema] the rejection does not list the accepted schemas — the "
"closed-set rule D-263 exists for"
)
missing = _reach("ron", "/definitely/not/a/file.ron", "culture")
if missing.returncode == 0:
failures.append("[ron-file] a nonexistent file exited 0")
def main() -> int:
if shutil.which("reach") is None:
print(
"test_validate: `reach` is not on PATH.\n Fix: make install-reach",
file=sys.stderr,
)
return 1
failures: list[str] = []
test_collision_detected(failures)
test_no_collision(failures)
test_empty_directory(failures)
test_missing_directory(failures)
test_ron_argument_errors(failures)
if failures:
print("test_validate: FAIL", file=sys.stderr)
for failure in failures:
print(f" - {failure}", file=sys.stderr)
return 1
print("test_validate: OK — collisions detected and named, argument errors caught")
return 0
if __name__ == "__main__":
sys.exit(main())