refactor(config): T-1281 — the check domain retires its five scripts

All five gates are ported, tested against their failure paths, and the
originals are gone. reach check is the only way to run them.

Parity first, then deletion. Every case in test_check.py began as a parity case
running the new implementation beside the script it replaced; that evidence is
in the ticket. With the scripts retired there is nothing left to compare
against, so the assertions become the spec and the file drops its "_parity"
name. A parity test is scaffolding with a defined lifetime — keeping one after
its subject is deleted would mean keeping the subject alive to be compared
with, which is the opposite of a migration.

Two gates could not be parity-tested in a fixture at all, and both reasons are
findings rather than obstacles. canvas-version: canvas_sources globs from a
__file__ root while the service resolves git through config.repo_root(), so a
fixture would diff one tree and glob another — real history is used instead,
including two genuine instances of the regression the gate exists to catch.
systems-db-stamp: generator_sources raises at IMPORT time when the economy-db
tree is absent, so the old script died before reaching any logic in every
fixture. The ported service imports it lazily and after the absent/unstamped
checks, which is exactly why those states are testable now and were not before.

Hooks rewired: pre-commit runs reach check fact-ids, pre-push runs the other
four. Both pass --no-input, because a hook has no TTY and a prompt there does
not wait, it crashes. Both guard on `command -v reach` and skip with a message
rather than blocking every commit on a missing tool.

Make targets are RETIRED, not wrapped, per the D-263 split — with the mapping
left as a comment where they used to be. Wrapping would leave two ways to
invoke each gate, and reach --help would stop being the answer to "what tooling
exists" while the Makefile remained a competing index. pre-pr-validate and
pre-pr-content keep their orchestration role and lose the individual target.

Sprint archives and workshop notes still name the old paths and are left alone:
they record what was true when written.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-31 20:41:33 +02:00
co-authored by Claude Opus 5
parent 793a5239cd
commit cb5d3f1335
12 changed files with 399 additions and 1040 deletions
+11 -1
View File
@@ -22,7 +22,17 @@ run_check() {
}
# --- Checks ---
run_check "tooling/check-fact-ids" "fact_id validation"
# Ported to the reach CLI (T-1281). --no-input because a hook has no TTY and a
# prompt there does not wait, it crashes. If reach is missing the check is
# skipped with a message rather than blocking every commit on a tooling install.
if command -v reach >/dev/null 2>&1; then
if ! reach --no-input check fact-ids; then
ERRORS=$((ERRORS + 1))
fi
else
echo "pre-commit: WARNING — fact_id validation skipped (reach not on PATH)"
echo " Run 'make install-reach'."
fi
# --- pql: decision integrity + durable planning changelog ---
# Replaces the old SQLite decisions-sync hook. Decisions are markdown-sourced (D-8):
+8 -8
View File
@@ -257,10 +257,10 @@ fi
# first push). We compare against origin/main — which always exists — so the
# check covers the first-push case.
DB_IN_PUSH=$(git diff --name-only origin/main...HEAD -- server/data/systems.db 2>/dev/null | wc -l)
if [ "$DB_IN_PUSH" -gt 0 ] && [ -f "$REPO_ROOT/tooling/check-systems-db-stamp" ]; then
if [ "$DB_IN_PUSH" -gt 0 ] && command -v reach >/dev/null 2>&1; then
echo "pre-push: checking systems.db stamp..."
rc=0
python3 "$REPO_ROOT/tooling/check-systems-db-stamp" || rc=$?
reach --no-input check systems-db-stamp || rc=$?
if [ "$rc" -eq 1 ]; then
# rc=1 means stale / unknown generator / missing source; message on stderr
echo " Fix: run 'make regen-db' then stage server/data/systems.db"
@@ -289,9 +289,9 @@ fi
# introduced by touching one file without the other, but it PERSISTS on main
# until someone notices, so gating on "was either file in this push" would let
# an existing drift ride along indefinitely. The check is two file reads.
if [ -f "$REPO_ROOT/tooling/check-client-version" ]; then
if command -v reach >/dev/null 2>&1; then
echo "pre-push: checking client version mirror..."
if python3 "$REPO_ROOT/tooling/check-client-version"; then
if reach --no-input check client-version; then
:
else
fail_check "client version mirror (drifted from project.yaml)"
@@ -307,9 +307,9 @@ fi
#
# Self-check first: the registry fails closed on an empty glob, and a registry
# that cannot load must not be read as "nothing to enforce".
if [ -f "$REPO_ROOT/tooling/check-canvas-version" ]; then
if command -v reach >/dev/null 2>&1; then
echo "pre-push: checking canvas-generation version pairing..."
if python3 "$REPO_ROOT/tooling/check-canvas-version"; then
if reach --no-input check canvas-version; then
:
else
fail_check "canvas generation changed without a project.yaml version bump"
@@ -321,9 +321,9 @@ fi
# moves, so the map keeps asserting a layout that is no longer true. This checks
# only that the paths still resolve; whether an EDGE still means what it says is
# a human check against the tool's source. Cheap (no subprocess beyond python).
if [ -f "$REPO_ROOT/tooling/check-dataflow-graph.py" ]; then
if command -v reach >/dev/null 2>&1; then
echo "pre-push: checking data-flow diagram paths..."
if python3 "$REPO_ROOT/tooling/check-dataflow-graph.py"; then
if reach --no-input check dataflow-graph; then
:
else
fail_check "a path named in a data-flow diagram no longer resolves"
+1 -1
View File
@@ -4,7 +4,7 @@ A top-down life-sim — asymmetric information, occlusion-based perception, sing
**Official Title:** The Settled Reach
**Repository name:** settled-reach
**Version source of truth:** `project.yaml` (root `version` field, scheme: `0.{phase}.{n}`), mirrored into `client/project.godot`'s `application/config/version` — the client can't read `project.yaml` at runtime (an exported build has no repo root), and the Atlas disk cache keys its only invalidation signal on that value. Bump both; `make check-client-version` (run by the pre-push hook) fails on drift. See `client/scripts/build_version.gd` (T-1241). **Changing canvas generation requires bumping this version** — otherwise warm caches keep serving canvases built by code that no longer exists (five regressions, most recently T-1239). The pre-push hook enforces it via `make check-canvas-version` against the path registry in `tooling/canvas_sources.py` (T-1242); there is no override, and bumping when unsure costs one cache miss.
**Version source of truth:** `project.yaml` (root `version` field, scheme: `0.{phase}.{n}`), mirrored into `client/project.godot`'s `application/config/version` — the client can't read `project.yaml` at runtime (an exported build has no repo root), and the Atlas disk cache keys its only invalidation signal on that value. Bump both; `reach check client-version` (run by the pre-push hook) fails on drift. See `client/scripts/build_version.gd` (T-1241). **Changing canvas generation requires bumping this version** — otherwise warm caches keep serving canvases built by code that no longer exists (five regressions, most recently T-1239). The pre-push hook enforces it via `reach check canvas-version` against the path registry in `tooling/canvas_sources.py` (T-1242); there is no override, and bumping when unsure costs one cache miss.
## Project Structure
+21 -28
View File
@@ -3,8 +3,8 @@ GODOT := $(shell command -v godot4 2>/dev/null || command -v godot 2>/dev/null)
.PHONY: help setup build client server game atlas stop test test-tooling lint lint-python setup-venv ci ci-client ci-server clean \
install-reach reach-repoint \
decisions-sync decisions-active decisions-validate \
validate-content check-fact-ids setup-hooks install-hooks \
audit deny atlas-verify economy-db regen-db check-systems-db check-client-version check-canvas-version \
validate-content setup-hooks install-hooks \
audit deny atlas-verify economy-db regen-db \
pre-pr pre-pr-lint pre-pr-build pre-pr-test pre-pr-validate pre-pr-fixtures \
pre-pr-server pre-pr-client pre-pr-content \
fixtures-client fixtures-gauntlet golden-diff golden-update \
@@ -13,7 +13,7 @@ GODOT := $(shell command -v godot4 2>/dev/null || command -v godot 2>/dev/null)
perf-baseline debug-schedule \
test-ipc-fixtures test-ipc-protocol test-ipc-integration test-ipc-benchmark \
screenshot visual-movie test-visual visual-update \
manifest diagrams check-diagrams check-dataflow-graph
manifest diagrams check-diagrams
# --- Configuration ---
@@ -56,15 +56,11 @@ help:
@echo " make audit Run cargo audit (security advisory check)"
@echo " make deny Run cargo deny check (license/ban policy)"
@echo " make validate-content Validate content YAML against schemas"
@echo " make check-fact-ids Check fact_id references against knowledge catalogs"
@echo " make atlas-verify Verify atlas proposal JSONs (all in docs/atlas/proposals/)"
@echo " make star-map-data Regenerate client/data/star_map_data.json from systems.db + wiki"
@echo " make check-star-map Assert star_map_data.json is up to date (part of pre-pr-client)"
@echo " make economy-db Import economics data into systems.db (TOML/JSON → SQLite)"
@echo " make regen-db Regenerate systems.db from all sources + stamp meta table (#855)"
@echo " make check-systems-db Verify systems.db meta stamp matches current generator sources"
@echo " make check-client-version Verify client/project.godot version mirrors project.yaml"
@echo " make check-canvas-version Verify canvas-generation changes carry a version bump"
@echo " make install-hooks Install pre-push + pre-commit git hooks (once per clone)"
@echo " make fixtures-client Generate GDScript->Rust cross-encoder fixtures (#475)"
@echo " make golden-diff Show diff if golden file output has changed"
@@ -319,10 +315,10 @@ test-tooling:
@mkdir -p .cache
@$(VENV_PY) tooling/test_conformance.py 2> .cache/test-tooling-conformance.log || \
{ echo " FAIL: reach conformance — log follows:"; cat .cache/test-tooling-conformance.log; exit 1; }
@echo " [test-tooling] reach/check-client-version parity (T-1262)..."
@echo " [test-tooling] reach check gates — behaviour (T-1281)..."
@mkdir -p .cache
@$(VENV_PY) tooling/test_check_parity.py 2> .cache/test-tooling-check-parity.log || \
{ echo " FAIL: check parity — log follows:"; cat .cache/test-tooling-check-parity.log; exit 1; }
@$(VENV_PY) tooling/test_check.py 2> .cache/test-tooling-check.log || \
{ echo " FAIL: check gates — log follows:"; cat .cache/test-tooling-check.log; exit 1; }
@echo " [test-tooling] economy_import.traits validation units (T-995/PR #173 H2)..."
@mkdir -p .cache
@python3 tooling/economy-db/test_traits.py 2> .cache/test-tooling-traits.log || \
@@ -382,7 +378,7 @@ pre-pr-build: build-server build-client
pre-pr-test: test-server test-client
@echo "--- Tests: PASS ---"
pre-pr-validate: validate-content check-fact-ids check-star-map
pre-pr-validate: validate-content check-star-map
@echo "--- Content validation: PASS ---"
pre-pr-fixtures:
@@ -427,7 +423,7 @@ pre-pr-server: lint-server build-server test-server pre-pr-fixtures audit deny
pre-pr-client: lint-client build-client test-client check-star-map
@echo "=== Client pre-PR: PASSED ==="
pre-pr-content: validate-content check-fact-ids checklist-validate atlas-verify
pre-pr-content: validate-content checklist-validate atlas-verify
@echo "=== Content pre-PR: PASSED ==="
# --- CI (run locally) ---
@@ -464,14 +460,19 @@ regen-db: ## Regenerate systems.db from all sources and stamp meta table (#855,
echo " regen-db complete — systems.db is up to date and stamped."; \
echo " Stage it with: git add server/data/systems.db"
check-systems-db: ## Verify systems.db meta stamp matches current generator sources (#857)
@python3 tooling/check-systems-db-stamp --verbose
check-client-version: ## Verify client/project.godot's baked version matches project.yaml (T-1241)
@python3 tooling/check-client-version
check-canvas-version: ## Verify canvas-generation changes carry a project.yaml version bump (T-1242)
@python3 tooling/check-canvas-version
# The five gate targets that lived here are RETIRED, not wrapped (D-263,
# T-1281). They are tooling, and tooling has one door:
#
# make check-systems-db -> reach check systems-db-stamp
# make check-client-version -> reach check client-version
# make check-canvas-version -> reach check canvas-version
# make check-fact-ids -> reach check fact-ids
# make check-dataflow-graph -> reach check dataflow-graph
#
# Wrapping them would leave two ways to invoke each, and `reach --help` would
# stop being the answer to "what tooling exists" because the Makefile would
# still be a competing index. make keeps build and test ORCHESTRATION; it does
# not keep aliases for individual tools.
econ-sim: ## Build the economics simulation binary (Layer 1+2: Leontief + tâtonnement trade)
@cargo build --manifest-path tooling/econ-sim/Cargo.toml --release
@@ -503,9 +504,6 @@ decisions-validate:
validate-content:
@tooling/validate-content
check-fact-ids:
@tooling/check-fact-ids
atlas-verify:
@tooling/atlas-verify docs/atlas/proposals/*.json
@@ -616,11 +614,6 @@ check-diagrams:
test $$fail -eq 0 || { echo "Run 'make diagrams'."; exit 1; }; \
echo "All diagrams rendered."
# D-262: every repo path named in a hand-authored data-flow diagram must still
# resolve. Catches the stale half of a diagram; edge MEANING is a human check.
check-dataflow-graph:
@python3 tooling/check-dataflow-graph.py
# --- Clean ---
clean:
+5 -5
View File
@@ -98,7 +98,7 @@ match and will prompt. That is accepted rather than worked around: an
environment override is a genuine departure from the normal invocation, and the
ordinary form is what needs to be frictionless. Tests that need overrides
should pass them through the subprocess environment rather than the command
string, as `tooling/test_check_parity.py` does.
string, as `tooling/test_check.py` does.
### Build
@@ -285,7 +285,7 @@ make regen-db
After every successful non-dry-run, the generator writes a row to the `meta` table in
`systems.db` recording the SHA-1 of its source files and the schema file. The source
registry is the `GENERATOR_SOURCES` dict in `tooling/check-systems-db-stamp`.
registry is the `GENERATOR_SOURCES` dict in `tooling/generator_sources.py`.
```bash
make check-systems-db # Verify the stamp is fresh (exit 1 = stale)
@@ -316,7 +316,7 @@ never be invalidated — see T-1241, and T-1239 for what a stale canvas cache ac
costs.
```bash
make check-client-version # exit 1 = the two files disagree
reach check client-version # exit 1 = the two files disagree
```
The pre-push hook runs this unconditionally (drift persists on `main` once
@@ -334,7 +334,7 @@ one took eight days to find (T-1239) because the failure is invisible to its aut
reproduces only where a warm cache exists.
```bash
make check-canvas-version # exit 1 = generation changed, version didn't
reach check canvas-version # exit 1 = generation changed, version didn't
```
The path registry is `tooling/canvas_sources.py` — globbed, not hand-listed, so a
@@ -372,7 +372,7 @@ git config core.hooksPath .config/hooks
| Check | Script | Behavior |
|-------|--------|----------|
| fact_id validation | `tooling/check-fact-ids` | Warns if catalogs are stubs; fails on unknown fact_ids when populated |
| fact_id validation | `reach check fact-ids` | Warns if catalogs are stubs; fails on unknown fact_ids when populated |
| Decision records | `pql decisions validate` | Blocks on malformed decision records (warns if pql not on PATH) |
| Planning changelog | `pql plan export --stage` | Flushes ticket mutations to `.pql/changelog/` and stages them into the commit (warn-only on failure) |
| cargo audit | `cargo audit` | Only when `Cargo.toml`/`Cargo.lock` is staged; blocks on advisories (warns if cargo-audit not installed) |
-164
View File
@@ -1,164 +0,0 @@
#!/usr/bin/env python3
"""Fail if a push changes canvas generation without moving project.yaml's version.
`project.yaml`'s `version:` is the Atlas disk cache's only invalidation signal.
Change how a canvas is generated without moving it and every warm cache keeps
serving canvases built by code that no longer exists — silently, and only on
machines that have a warm cache, so the author never sees it. That has happened
five times (see tooling/canvas_sources.py for the roll-call); T-1239 is what the
last one cost.
The rule: if the push touches anything in the canvas-generation registry, the
`version:` line in project.yaml must change in the SAME range.
Deliberately no override flag. The ticket's ruling (T-1242) is that a false
positive is cheap — one version bump, one round of cache misses — and a false
negative is another week of a wrong map. An escape hatch would be reached for
exactly when someone is sure their change is harmless, which is the state of mind
that produced all five regressions.
Exit: 0 = fine (or nothing relevant in range), 1 = version bump required.
"""
import argparse
import subprocess
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from canvas_sources import relative_paths # noqa: E402
REPO_ROOT = Path(__file__).resolve().parent.parent
DEFAULT_BASE = "origin/main"
def git(*args: str) -> str | None:
"""Run a git command, returning stdout, or None if it failed."""
result = subprocess.run(
["git", "-C", str(REPO_ROOT), *args],
capture_output=True,
text=True,
)
if result.returncode != 0:
return None
return result.stdout
def changed_files(commit_range: str) -> list[str] | None:
out = git("diff", "--name-only", commit_range)
if out is None:
return None
return [line for line in out.splitlines() if line]
def diff_has_version_bump(diff_text: str) -> bool:
"""Does this project.yaml diff actually move the `version:` field?
Pure, so the property is testable without constructing git history
(tooling/test_canvas_version_check.py).
Matched on the diff body rather than on the file appearing in --name-only:
project.yaml carries a long comment block documenting past bumps — including
lines that quote old version NUMBERS — so editing that commentary, or any
other field in the file, must NOT count as bumping the version.
Requires the ADDED side: a lone deletion means the field was removed, not
moved. Diff context/metadata lines such as `+++ b/project.yaml` must not
match either, which is why this anchors on `+version:` exactly.
"""
for line in diff_text.splitlines():
if line.startswith("+++"):
continue # diff header, not content
if line.startswith("+version:"):
return True
return False
def version_line_changed(commit_range: str) -> bool:
"""Did project.yaml's `version:` line itself change in this range?"""
out = git("diff", "-U0", commit_range, "--", "project.yaml")
if out is None:
return False
return diff_has_version_bump(out)
def main() -> int:
parser = argparse.ArgumentParser(
description="Require a project.yaml version bump alongside canvas-generation changes"
)
parser.add_argument(
"--base",
default=DEFAULT_BASE,
help=f"Base ref to compare against (default: {DEFAULT_BASE})",
)
parser.add_argument(
"--head",
default="HEAD",
help="Head ref to compare (default: HEAD)",
)
args = parser.parse_args()
# Three-dot: what HEAD added since the merge base, matching the systems.db
# stamp check's own convention in .config/hooks/pre-push.
commit_range = f"{args.base}...{args.head}"
if git("rev-parse", "--verify", args.base) is None:
# No base to compare against (fresh clone, no remote yet). Skipping is
# correct rather than failing: there is no "range" to judge.
print(
f"check-canvas-version: {args.base} not found — skipping (nothing to compare)"
)
return 0
changed = changed_files(commit_range)
if changed is None:
print(
f"check-canvas-version: could not diff {commit_range} — skipping",
file=sys.stderr,
)
return 0
registry = set(relative_paths())
touched = sorted(set(changed) & registry)
if not touched:
print("check-canvas-version: no canvas-generation changes in range — OK")
return 0
if version_line_changed(commit_range):
print(
f"check-canvas-version: OK — {len(touched)} canvas-generation file(s) "
"changed and project.yaml's version moved with them"
)
return 0
shown = touched[:10]
remainder = len(touched) - len(shown)
print(
"check-canvas-version: canvas generation changed without a version bump\n"
"\n"
f" Range: {commit_range}\n"
" Changed canvas-generation files:\n"
+ "".join(f" {p}\n" for p in shown)
+ (f" ... and {remainder} more\n" if remainder else "")
+ "\n"
"project.yaml's `version:` is the Atlas disk cache's ONLY invalidation\n"
"signal. Without a bump, every warm cache keeps serving canvases built by\n"
"the code you just changed — silently, and only on machines that have a\n"
"warm cache, so you will not see it on a cold checkout.\n"
"\n"
"Fix: bump `version:` in project.yaml (scheme 0.{phase}.{n}), add a line to\n"
"the comment block above it saying what the old entries carried, and mirror\n"
"the new value into client/project.godot's config/version.\n"
"\n"
"If you are certain this change cannot alter canvas bytes, bump it anyway:\n"
"the cost is one round of cache misses. That trade is the point — this has\n"
"shipped broken five times, most recently T-1239, which took eight days to\n"
"find.",
file=sys.stderr,
)
return 1
if __name__ == "__main__":
sys.exit(main())
-71
View File
@@ -1,71 +0,0 @@
#!/usr/bin/env python3
"""Fail if client/project.godot's baked version has drifted from project.yaml.
project.yaml is the version source of truth (CLAUDE.md). The client cannot read
it at runtime — an exported build has no repo root — so the value is mirrored
into `application/config/version` in client/project.godot, which Godot bakes
into the PCK (T-1241).
A mirror nobody checks is worse than the bug it replaced: the old code failed
LOUDLY in an export ("?.?.?" everywhere, no cache invalidation ever), whereas a
stale mirror fails SILENTLY — the Atlas disk cache would keep serving canvases
under a version that stopped matching the build. That is precisely the T-1239
failure, which cost eight days of a map drawn from a canvas whose generating
code no longer existed. Hence this check, wired into the pre-push hook.
Exit: 0 = in sync, 1 = drifted or unreadable.
"""
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
PROJECT_YAML = ROOT / "project.yaml"
PROJECT_GODOT = ROOT / "client" / "project.godot"
# Anchored to line start so the commentary above `version:` (which mentions
# earlier versions by number) can never be mistaken for the field itself.
YAML_VERSION = re.compile(r"^version:\s*(\S+)\s*$", re.MULTILINE)
GODOT_VERSION = re.compile(r'^config/version\s*=\s*"([^"]*)"\s*$', re.MULTILINE)
def read(path: Path, pattern: re.Pattern, label: str) -> str | None:
if not path.exists():
print(f"check-client-version: {path} not found", file=sys.stderr)
return None
match = pattern.search(path.read_text(encoding="utf-8"))
if not match:
print(f"check-client-version: no {label} in {path}", file=sys.stderr)
return None
return match.group(1)
def main() -> int:
yaml_version = read(PROJECT_YAML, YAML_VERSION, "`version:` line")
godot_version = read(PROJECT_GODOT, GODOT_VERSION, "`config/version=` line")
if yaml_version is None or godot_version is None:
return 1
if yaml_version != godot_version:
print(
"check-client-version: version drift\n"
f" project.yaml {yaml_version}\n"
f" client/project.godot {godot_version}\n"
"\n"
"project.yaml is the source of truth. Set config/version in\n"
"client/project.godot's [application] section to match it.\n"
"\n"
"This matters beyond cosmetics: the Atlas disk cache keys its\n"
"invalidation on this version, so a stale mirror makes a shipped\n"
"build serve canvases generated by code it no longer runs (T-1239).",
file=sys.stderr,
)
return 1
print(f"check-client-version: OK — {yaml_version}")
return 0
if __name__ == "__main__":
sys.exit(main())
-149
View File
@@ -1,149 +0,0 @@
#!/usr/bin/env python3
"""Assert that every repo path named in a hand-authored data-flow diagram exists.
A diagram that names files goes stale SILENTLY — nothing fails when a path
moves, so the map keeps asserting a layout that is no longer true. This closes
the cheap half of that gap.
WHAT IT CAN CHECK: that each path mentioned in a `.d2` node label still resolves
on disk (allowing `*`/`{...}` globs and `·`-separated lists).
WHAT IT CANNOT CHECK: whether an EDGE still means what it says. If
`wiki_sync.py` stops writing body pages tomorrow, every path here still exists
and this script still passes. Edge semantics are verified by reading the tool's
source, which is a human job — see D-262.
Generated `.d2` files are skipped: their correctness is the generator's problem
(the same source-canonical rule as .claude/rules/asset-pipeline.md), and they
name star-system ids rather than repo paths.
Usage:
python3 tooling/check-dataflow-graph.py [--verbose]
"""
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
DIAGRAM_ROOT = REPO_ROOT / "docs" / "diagrams"
# Hand-authored diagrams whose labels name real repo paths. Add a diagram here
# when it starts naming files; a diagram absent from this list is not checked.
CHECKED_DIAGRAMS = [
"data-flow/wiki-generator-flow.d2",
]
# Generated sources — skipped even if listed above. See .claude/rules/diagrams.md.
GENERATED_PREFIXES = ("design/star-map-",)
# A token looks like a path if it contains a slash and a plausible name char.
# Node labels in these diagrams carry paths such as:
# "wiki/economics/\nTOMLs + 37 commodity pages"
# "heightmap · reliefmap · globe\nterrain.npz · markers.json"
# "tooling/atlas add-body\nTHE bodies catalog origin"
PATH_TOKEN = re.compile(r"[A-Za-z0-9_.\-*{}/]*/[A-Za-z0-9_.\-*{}/]+")
# A token only counts as a path if its first segment is a real top-level entry
# in the repo. Without this, legend prose such as "dashed one-time or
# bootstrap" yields the token `one-time/bootstrap` and fails the check.
TOP_LEVEL = {p.name for p in REPO_ROOT.iterdir()}
def label_strings(d2_text: str) -> list[str]:
"""Every double-quoted label in the d2 source."""
return re.findall(r'"((?:[^"\\]|\\.)*)"', d2_text)
def candidate_paths(label: str) -> list[str]:
"""Extract path-looking tokens from one label."""
# Labels use \n for line breaks and · to separate sibling files.
flat = label.replace("\\n", " ").replace("·", " ")
out = []
for tok in PATH_TOKEN.findall(flat):
tok = tok.strip(".,;:")
if not tok or tok.split("/", 1)[0] not in TOP_LEVEL:
continue
out.append(tok)
return out
def resolves(token: str) -> bool:
"""True if the token resolves on disk, treating * and {..} as wildcards."""
direct = REPO_ROOT / token
if direct.exists():
return True
# `bodies/{id}/index.md` -> `bodies/*/index.md`; then glob it.
pattern = re.sub(r"\{[^}]*\}", "*", token)
if "*" in pattern:
try:
return any(REPO_ROOT.glob(pattern))
except (ValueError, OSError):
return False
# A bare filename inside a directory that was named elsewhere in the
# diagram (e.g. `terrain.npz` under a body dir) — search narrowly.
return False
def check(diagram: str, verbose: bool) -> list[str]:
path = DIAGRAM_ROOT / diagram
if not path.exists():
return [f"{diagram}: diagram not found"]
failures = []
checked = 0
for label in label_strings(path.read_text(encoding="utf-8")):
for token in candidate_paths(label):
checked += 1
if resolves(token):
if verbose:
print(f" ok {token}")
else:
failures.append(f"{diagram}: path does not resolve: {token}")
if checked == 0:
# A diagram listed for checking that yields no paths means the label
# format changed and this script silently stopped checking anything.
failures.append(
f"{diagram}: no path-like tokens found — the checker is not "
f"actually checking this diagram"
)
elif verbose:
print(f" {checked} path tokens checked in {diagram}")
return failures
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--verbose", action="store_true")
args = ap.parse_args()
failures: list[str] = []
for diagram in CHECKED_DIAGRAMS:
if diagram.startswith(GENERATED_PREFIXES):
continue
failures.extend(check(diagram, args.verbose))
if failures:
print("check-dataflow-graph: FAILED", file=sys.stderr)
for f in failures:
print(f" {f}", file=sys.stderr)
print(
"\nA path named in a diagram no longer exists. Either the path "
"moved (update the diagram) or the diagram was always wrong.",
file=sys.stderr,
)
return 1
print(f"check-dataflow-graph: OK — {len(CHECKED_DIAGRAMS)} diagram(s)")
return 0
if __name__ == "__main__":
sys.exit(main())
-89
View File
@@ -1,89 +0,0 @@
#!/usr/bin/env bash
# Validate fact_id references in content YAML against canonical knowledge catalogs.
# Part of pre-commit checks (ticket #393). Grep-based, targets <2s runtime.
#
# Modes:
# Advisory — when knowledge catalogs have no fact definitions yet (exit 0)
# Enforcing — when catalogs are populated; fails on unknown fact_ids (exit 1)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(dirname "$SCRIPT_DIR")"
KNOWLEDGE_DIR="$REPO_ROOT/server/content/global/knowledge"
CONTENT_DIR="$REPO_ROOT/server/content/campaigns"
# --- Extract canonical fact_ids from knowledge catalogs ---
# Matches YAML lines like: fact_id: some_value or fact_id: "some_value"
# Excludes entity-attributes.yaml (different schema: attribute keys, not fact_ids)
CANONICAL_IDS=""
if [ -d "$KNOWLEDGE_DIR" ]; then
CANONICAL_IDS=$(
grep -rh 'fact_id:\s*' "$KNOWLEDGE_DIR" \
--include='*.yaml' \
--exclude='entity-attributes.yaml' \
| grep -v '^\s*#' \
| sed 's/.*fact_id:\s*//' \
| sed 's/\s*#.*//' \
| sed "s/^[\"']\(.*\)[\"']$/\1/" \
| sed 's/[[:space:]]*$//' \
| sort -u \
|| true
)
fi
CANONICAL_COUNT=$(echo "$CANONICAL_IDS" | grep -c '\S' || true)
# --- Extract referenced fact_ids from campaign content ---
# Covers monologue prerequisites.facts[].fact_id and dialogue knowledge_grant.fact_id
REFERENCED=""
if [ -d "$CONTENT_DIR" ]; then
REFERENCED=$(
grep -rh 'fact_id:\s*' "$CONTENT_DIR" \
--include='*.yaml' \
| grep -v '^\s*#' \
| sed 's/.*fact_id:\s*//' \
| sed 's/\s*#.*//' \
| sed "s/^[\"']\(.*\)[\"']$/\1/" \
| sed 's/[[:space:]]*$//' \
|| true
)
fi
REFERENCED_UNIQUE=$(echo "$REFERENCED" | sort -u | grep '\S' || true)
REF_COUNT=$(echo "$REFERENCED_UNIQUE" | grep -c '\S' || true)
# --- Compare ---
if [ "$CANONICAL_COUNT" -eq 0 ]; then
echo "check-fact-ids: WARNING — no canonical fact_ids in knowledge catalogs"
echo " Catalogs not yet populated. Check is advisory only."
if [ "$REF_COUNT" -gt 0 ]; then
echo " $REF_COUNT unique fact_ids referenced in content:"
echo "$REFERENCED_UNIQUE" | sed 's/^/ /'
fi
exit 0
fi
# Enforcing mode: catalogs have definitions
ERRORS=0
while IFS= read -r ref_id; do
[ -z "$ref_id" ] && continue
if ! echo "$CANONICAL_IDS" | grep -qxF "$ref_id"; then
echo "ERROR: unknown fact_id '$ref_id' — not in knowledge catalogs"
grep -rn "fact_id:\s*$ref_id" "$CONTENT_DIR" --include='*.yaml' \
| sed "s|$REPO_ROOT/||" \
| sed 's/^/ /'
ERRORS=$((ERRORS + 1))
fi
done <<< "$REFERENCED_UNIQUE"
if [ "$ERRORS" -gt 0 ]; then
echo ""
echo "check-fact-ids: FAILED — $ERRORS unknown fact_id(s)"
echo " Canonical fact_ids defined in: content/global/knowledge/*.yaml"
echo " Run 'make check-fact-ids' to recheck."
exit 1
fi
echo "check-fact-ids: OK — $REF_COUNT references validated against $CANONICAL_COUNT canonical facts"
exit 0
-160
View File
@@ -1,160 +0,0 @@
#!/usr/bin/env python3
"""
check-systems-db-stamp — verify that server/data/systems.db is up to date.
Reads the meta table from systems.db and checks that the stored SHA-1 of each
generator's source file(s) matches the current file content on disk.
Exit codes:
0 — DB is stamped and all generator SHAs match current sources
1 — DB is stale, has an unknown generator, or references a missing source file
2 — DB does not have a meta table (treat as unstamped — run make regen-db)
Usage (called by .config/hooks/pre-push):
tooling/check-systems-db-stamp
Usage (interactive):
tooling/check-systems-db-stamp --verbose
Decision refs: #855 (generator versioning), #857 (pre-push hook)
"""
import re
import sqlite3
import sys
from pathlib import Path
# semver pattern: MAJOR.MINOR.PATCH (no pre-release or build metadata)
_SEMVER_RE = re.compile(r"^\d+\.\d+\.\d+$")
REPO_ROOT = Path(__file__).resolve().parent.parent
DB_PATH = REPO_ROOT / "server" / "data" / "systems.db"
# The generator-source registry and the SHA helper live in the shared module
# tooling/generator_sources.py (T-1067) — the single source of truth, also
# imported by the importer's stamp writer and consumed by /pr-process via
# `python3 tooling/generator_sources.py --list`.
sys.path.insert(0, str(Path(__file__).resolve().parent))
from generator_sources import GENERATOR_SOURCES, file_sha1 # noqa: E402
def check(verbose: bool = False) -> int:
"""Return exit code: 0 = fresh, 1 = stale, 2 = no meta table."""
if not DB_PATH.exists():
if verbose:
print(f"check-systems-db-stamp: {DB_PATH} not found — skipping check")
return 0
try:
conn = sqlite3.connect(str(DB_PATH))
rows = conn.execute(
"SELECT generator_name, schema_version, generator_sha FROM meta"
).fetchall()
conn.close()
except sqlite3.OperationalError:
# meta table does not exist
if verbose:
print("check-systems-db-stamp: no meta table — systems.db has not been stamped")
print(" Run: make regen-db")
return 2
if not rows:
if verbose:
print("check-systems-db-stamp: meta table is empty — systems.db has not been stamped")
print(" Run: make regen-db")
return 2
stale: list[str] = []
unknown: list[str] = []
bad_version: list[str] = []
seen_versions: dict[str, str] = {} # generator_name -> schema_version
for generator_name, schema_version, stored_sha in rows:
seen_versions[generator_name] = schema_version
# Validate schema_version is a semver string (#888).
# Old DBs may still carry a SHA-1 hex (40-char) — flag them as stale
# so the user knows to run make regen-db rather than getting a silent pass.
if not _SEMVER_RE.match(schema_version or ""):
bad_version.append(
f"{generator_name}: schema_version='{schema_version}' "
f"(expected semver like '1.0.0' — run make regen-db)"
)
sources = GENERATOR_SOURCES.get(generator_name)
if sources is None:
# Unknown generator — fail closed (T6). A future branch adding a
# new generator without registering it here must update this map
# before the check will pass, preventing the "silent no-op" trap.
unknown.append(generator_name)
continue
try:
current_sha = file_sha1(*sources)
except FileNotFoundError as exc:
# Source file moved/deleted — explicit failure instead of
# silent empty-hash (H2).
print(
f"check-systems-db-stamp: BROKEN — {generator_name}: {exc}",
file=sys.stderr,
)
return 1
if current_sha != stored_sha:
stale.append(generator_name)
if verbose:
print(
f"check-systems-db-stamp: STALE — {generator_name}"
f"\n stored: {stored_sha}"
f"\n current: {current_sha}"
)
if bad_version:
for msg in bad_version:
print(f"check-systems-db-stamp: BAD schema_version — {msg}", file=sys.stderr)
return 1
# All generators must agree on the same schema_version (#888 defense-in-depth).
# If they differ, the DB was partially regenerated with different source trees.
unique_versions = set(seen_versions.values())
if len(unique_versions) > 1:
print(
"check-systems-db-stamp: CONFLICT — generators disagree on schema_version:",
file=sys.stderr,
)
for gen, ver in sorted(seen_versions.items()):
print(f" {gen}: {ver}", file=sys.stderr)
print(" Run: make regen-db", file=sys.stderr)
return 1
if unknown:
print(
"check-systems-db-stamp: UNKNOWN generator(s) in meta table: "
f"{unknown}",
file=sys.stderr,
)
print(
" Update GENERATOR_SOURCES in tooling/generator_sources.py to "
"register them before pushing.",
file=sys.stderr,
)
return 1
if stale:
if not verbose:
print(
"systems.db is stale — run `make regen-db` before pushing.",
file=sys.stderr,
)
print(f" Stale generators: {stale}", file=sys.stderr)
return 1
if verbose:
print(f"check-systems-db-stamp: OK — {len(rows)} generator(s) up to date")
return 0
def main() -> None:
verbose = "--verbose" in sys.argv or "-v" in sys.argv
sys.exit(check(verbose=verbose))
if __name__ == "__main__":
main()
+353
View File
@@ -0,0 +1,353 @@
#!/usr/bin/env python3
"""Behaviour of the five `reach check` gates (T-1281).
**This file used to be test_check_parity.py.** Every case here began as a parity
case, running the new implementation beside the shell or Python script it
replaced and requiring identical exit codes and no lost facts. That evidence is
recorded in T-1262 and T-1281; the scripts themselves are now retired, so there
is nothing left to compare against and these assertions become the spec.
A parity test is scaffolding with a defined lifetime. Keeping one after its
subject is deleted would mean keeping the subject alive to be compared with,
which is the opposite of a migration.
What did NOT change: every case still exercises a failure, not just a pass. A
gate that has only ever been run against a healthy tree has never been tested,
and four of these five gates exist because something shipped broken.
Run: python3 tooling/test_check.py
"""
import json
import os
import shutil
import sqlite3
import subprocess
import sys
import tempfile
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
def _reach(*args: str, root: Path | None = None) -> subprocess.CompletedProcess[str]:
"""Invoke by BARE NAME — never a path or interpreter (T-1261)."""
env = {**os.environ, "SR_OUTPUT_FORMAT": "text"}
if root is not None:
env["SR_REPO_ROOT"] = str(root)
return subprocess.run(
["reach", "check", *args],
capture_output=True,
text=True,
cwd=REPO_ROOT,
env=env,
)
def _out(result: subprocess.CompletedProcess[str]) -> str:
return result.stdout + result.stderr
# --- client-version -------------------------------------------------------
VERSION_CASES = [
("ok", "0.9.1", "0.9.1", 0),
("drift", "0.9.1", "0.8.0", 1),
("missing-godot-file", "0.9.1", None, 1),
]
def _version_fixture(tmp: Path, yaml_version: str, godot_version: str | None) -> Path:
root = tmp / "fixture"
(root / "client").mkdir(parents=True)
# Commentary quoting OTHER version numbers, mirroring the real file: a regex
# not anchored to line start would match these and read a comment edit as a
# version bump.
(root / "project.yaml").write_text(
"# history: 0.1.0, 0.2.0, 0.3.0 were pre-cascade\n"
f"version: {yaml_version}\nname: fixture\n",
encoding="utf-8",
)
if godot_version is not None:
(root / "client" / "project.godot").write_text(
f'[application]\n\nconfig/name="Fixture"\nconfig/version="{godot_version}"\n',
encoding="utf-8",
)
return root
def check_client_version(failures: list[str]) -> None:
for label, yaml_version, godot_version, expected in VERSION_CASES:
with tempfile.TemporaryDirectory() as tmpdir:
root = _version_fixture(Path(tmpdir), yaml_version, godot_version)
result = _reach("client-version", root=root)
if result.returncode != expected:
failures.append(
f"[client-{label}] exited {result.returncode}, expected {expected}"
)
if label == "drift":
for fact in (yaml_version, godot_version):
if fact not in _out(result):
failures.append(
f"[client-{label}] output omits {fact!r} — a drift report "
"that does not name both versions cannot be acted on"
)
# --- fact-ids -------------------------------------------------------------
FACT_CASES = [
("ok", ["alpha", "beta"], ["alpha"], 0),
("unknown", ["alpha"], ["alpha", "ghost"], 1),
("advisory", [], ["alpha"], 0), # catalogs unpopulated: advisory, exit 0
]
def _fact_fixture(tmp: Path, catalog: list[str], referenced: list[str]) -> Path:
root = tmp / "fixture"
knowledge = root / "server" / "content" / "global" / "knowledge"
campaigns = root / "server" / "content" / "campaigns"
knowledge.mkdir(parents=True)
campaigns.mkdir(parents=True)
(root / "project.yaml").write_text("version: 0.0.0\n", encoding="utf-8")
if catalog:
(knowledge / "facts.yaml").write_text(
"# a comment mentioning fact_id: decoy_should_not_count\n"
+ "".join(f" - fact_id: {name}\n" for name in catalog),
encoding="utf-8",
)
# Its schema is attribute keys, not fact_ids. If this ever starts counting,
# the canonical set inflates and the advisory case flips to enforcing.
(knowledge / "entity-attributes.yaml").write_text(
" - fact_id: attribute_not_a_fact\n", encoding="utf-8"
)
(campaigns / "story.yaml").write_text(
"".join(f' - fact_id: "{name}"\n' for name in referenced), encoding="utf-8"
)
return root
def check_fact_ids(failures: list[str]) -> None:
for label, catalog, referenced, expected in FACT_CASES:
with tempfile.TemporaryDirectory() as tmpdir:
root = _fact_fixture(Path(tmpdir), catalog, referenced)
result = _reach("fact-ids", root=root)
combined = _out(result)
if result.returncode != expected:
failures.append(
f"[fact-{label}] exited {result.returncode}, expected {expected}"
)
if label == "unknown" and "ghost" not in combined:
failures.append(
"[fact-unknown] output does not name the unknown fact_id, so the "
"failure says something is wrong without saying what"
)
if "attribute_not_a_fact" in combined:
failures.append(
f"[fact-{label}] entity-attributes.yaml was counted as a catalog"
)
# --- canvas-version -------------------------------------------------------
#
# Ranges from this repo's own history. Two of them are GENUINE instances of the
# regression this gate exists to catch — canvas generation changed, the version
# was bumped only after the fact. The history that produced the check, used as
# its own fixture, so nothing can drift from the thing it models.
CANVAS_CASES = [
("needs-bump", "4e503c356~1", "4e503c356", 1), # T-1237 river courses
("needs-bump-2", "566b56651~1", "566b56651", 1), # T-1194 relief texture
("bumped", "9b146f9e1~1", "9b146f9e1", 0),
("clean", "6b31111cd~1", "6b31111cd", 0), # governance only
("no-base", "no-such-ref-anywhere", "HEAD", 0), # a skip, not a failure
]
CANVAS_EXPECTED_FILES = {
"needs-bump": ["server/src/atlas/step_canvas.rs", "server/src/atlas/river_course.rs"],
}
def check_canvas_version(failures: list[str]) -> None:
for label, base, head, expected in CANVAS_CASES:
result = _reach("canvas-version", "--base", base, "--head", head)
if result.returncode != expected:
failures.append(
f"[canvas-{label}] exited {result.returncode}, expected {expected}"
+ (
" — if history was rewritten this range no longer sets up the case"
if expected == 1
else ""
)
)
for path in CANVAS_EXPECTED_FILES.get(label, []):
if path not in _out(result):
failures.append(
f"[canvas-{label}] output omits {path} — the changed-file list is "
"the actionable half of this failure"
)
# --- dataflow-graph -------------------------------------------------------
DIAGRAM_CASES = [
("ok", '"server/data/systems.db\\nthe snapshot"', True, 0),
("stale", '"server/data/gone-away.db\\nmoved"', False, 1),
# No slash means no path token: the checker finds nothing to check and must
# FAIL, because a gate that silently stops checking reports success forever.
("no-tokens", '"just prose, no paths"', False, 1),
]
def _diagram_fixture(tmp: Path, label: str, create: bool) -> Path:
root = tmp / "fixture"
diagram_dir = root / "docs" / "diagrams" / "data-flow"
diagram_dir.mkdir(parents=True)
(root / "project.yaml").write_text("version: 0.0.0\n", encoding="utf-8")
# A token only counts if its first segment is a real top-level entry, so
# server/ must exist for the token to even be considered.
(root / "server" / "data").mkdir(parents=True)
if create:
(root / "server" / "data" / "systems.db").write_text("", encoding="utf-8")
(diagram_dir / "wiki-generator-flow.d2").write_text(f"a: {label}\n", encoding="utf-8")
return root
def check_dataflow_graph(failures: list[str]) -> None:
for label, text, create, expected in DIAGRAM_CASES:
with tempfile.TemporaryDirectory() as tmpdir:
root = _diagram_fixture(Path(tmpdir), text, create)
result = _reach("dataflow-graph", root=root)
if result.returncode != expected:
failures.append(
f"[diagram-{label}] exited {result.returncode}, expected {expected}"
)
# --- systems-db-stamp -----------------------------------------------------
#
# The old script could not be tested in a fixture at all: generator_sources
# raises at IMPORT time when the economy-db tree is absent, so every case died
# before reaching any logic. The ported service imports it lazily and after the
# absent/unstamped checks, which is why these states are reachable here.
# STALE still is not — it needs the whole registered source set — but it is the
# state the live repo exercises on every push.
STAMP_CASES = [
("absent", "no-db", 0),
("unstamped", None, 2), # exit 2 is unique to this state (T-857)
("empty", [], 2),
("unknown", [("not_a_registered_generator", "1.0.0", "abc")], 1),
("bad-version", [("not_a_registered_generator", "deadbeef", "abc")], 1),
]
def _stamp_fixture(tmp: Path, rows) -> Path:
root = tmp / "fixture"
(root / "server" / "data").mkdir(parents=True)
(root / "project.yaml").write_text("version: 0.0.0\n", encoding="utf-8")
if rows == "no-db":
return root
connection = sqlite3.connect(str(root / "server" / "data" / "systems.db"))
if rows is None:
connection.execute("CREATE TABLE unrelated (x INTEGER)") # no meta table
else:
connection.execute(
"CREATE TABLE meta (generator_name TEXT, schema_version TEXT, "
"generator_sha TEXT)"
)
connection.executemany("INSERT INTO meta VALUES (?, ?, ?)", rows)
connection.commit()
connection.close()
return root
def check_systems_db_stamp(failures: list[str]) -> None:
for label, rows, expected in STAMP_CASES:
with tempfile.TemporaryDirectory() as tmpdir:
root = _stamp_fixture(Path(tmpdir), rows)
result = _reach("systems-db-stamp", root=root)
if result.returncode != expected:
note = (
" — exit 2 is the unstamped signal the pre-push hook has relied "
"on since T-857"
if expected == 2
else ""
)
failures.append(
f"[stamp-{label}] exited {result.returncode}, expected "
f"{expected}{note}"
)
# --- every failure names a remedy ----------------------------------------
def check_failures_carry_remedies(failures: list[str]) -> None:
"""A failing gate must say what to do next, structurally.
The conformance suite proves every ReachError is RAISED with fix=; this
proves the remedy actually reaches the caller, which is a different claim
and the one that matters at 3am.
"""
with tempfile.TemporaryDirectory() as tmpdir:
root = _version_fixture(Path(tmpdir), "0.9.1", "0.8.0")
result = subprocess.run(
["reach", "check", "client-version"],
capture_output=True,
text=True,
cwd=REPO_ROOT,
env={**os.environ, "SR_REPO_ROOT": str(root), "SR_OUTPUT_FORMAT": "json"},
)
verdicts = [
json.loads(line)
for line in result.stderr.splitlines()
if line.strip().startswith("{")
]
verdict = next((v for v in verdicts if v.get("kind") == "verdict"), None)
if verdict is None:
failures.append("[remedy] a failing gate emitted no verdict event")
elif not verdict.get("fix"):
failures.append(
"[remedy] a failing gate's verdict carries no fix — the D-263 "
"contract requires every non-zero exit to name the next command"
)
def main() -> int:
if shutil.which("reach") is None:
print(
"test_check: `reach` is not on PATH.\n Fix: make install-reach",
file=sys.stderr,
)
return 1
failures: list[str] = []
check_client_version(failures)
check_fact_ids(failures)
check_canvas_version(failures)
check_dataflow_graph(failures)
check_systems_db_stamp(failures)
check_failures_carry_remedies(failures)
if failures:
print("test_check: FAIL", file=sys.stderr)
for failure in failures:
print(f" - {failure}", file=sys.stderr)
return 1
total = (
len(VERSION_CASES)
+ len(FACT_CASES)
+ len(CANVAS_CASES)
+ len(DIAGRAM_CASES)
+ len(STAMP_CASES)
)
print(f"test_check: OK — {total} cases across five gates, failures included")
return 0
if __name__ == "__main__":
sys.exit(main())
-364
View File
@@ -1,364 +0,0 @@
#!/usr/bin/env python3
"""Parity between `reach check client-version` and the script it replaces (T-1262).
Both implementations are live: the old `tooling/check-client-version` is still
wired to the pre-push hook, and the deprecation window is deliberate (T-1253
retires it). While both exist they must agree, or the migration silently changes
what the gate does.
WHAT PARITY MEANS HERE, because the ticket asked for byte-for-byte and that is
no longer the right requirement. D-263 was amended after this ticket was written
to give `reach` a streaming output model: the event stream — progress and the
final verdict — goes to **stderr** as JSONL, and **stdout** is reserved for a
command's actual data so `reach ... | jq` keeps working. The old script writes
its OK line to stdout. So the two cannot be byte-identical on the same stream
without either abandoning the streaming model or special-casing this one
command, and neither is worth it.
What is enforced instead, and is strictly stronger where it counts:
1. EXIT CODES MATCH EXACTLY, on every path. This is what the hook gates on,
and it is the only part a caller can act on programmatically.
2. EVERY FACT the old output carries appears in the new output — both version
numbers on drift, the failing path when a file is missing. A migration that
drops a detail from a failure message makes the failure harder to fix.
3. THE NEW OUTPUT NAMES A REMEDY on failure, which D-263 requires and the old
script only partly does.
In text mode the OK line is in fact byte-identical; only the stream differs.
Run: python3 tooling/test_check_parity.py
"""
import json
import os
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
OLD_SCRIPT_NAME = "check-client-version"
# (label, yaml version, godot version or None to omit the file entirely)
CASES = [
("ok", "0.9.1", "0.9.1"),
("drift", "0.9.1", "0.8.0"),
("missing-godot-file", "0.9.1", None),
]
def _build_fixture(tmp: Path, yaml_version: str, godot_version: str | None) -> Path:
"""A miniature repo root: sentinel, client file, and a copy of the old script.
The old script resolves its root as `Path(__file__).parent.parent`, so
copying it into `<fixture>/tooling/` is what re-points it — there is no
override to pass it. The new command takes SR_REPO_ROOT. That asymmetry is
itself part of why the port is worth doing.
"""
root = tmp / "fixture"
(root / "tooling").mkdir(parents=True)
(root / "client").mkdir()
# project.yaml carries commentary quoting OTHER version numbers, mirroring
# the real file — a regex not anchored to line start would match those.
(root / "project.yaml").write_text(
"# history: 0.1.0, 0.2.0, 0.3.0 were pre-cascade\n"
f"version: {yaml_version}\n"
"name: fixture\n",
encoding="utf-8",
)
if godot_version is not None:
(root / "client" / "project.godot").write_text(
"[application]\n\n"
'config/name="Fixture"\n'
f'config/version="{godot_version}"\n',
encoding="utf-8",
)
shutil.copy2(REPO_ROOT / "tooling" / OLD_SCRIPT_NAME, root / "tooling" / OLD_SCRIPT_NAME)
return root
def _run_old(root: Path) -> tuple[int, str]:
result = subprocess.run(
[sys.executable, str(root / "tooling" / OLD_SCRIPT_NAME)],
capture_output=True,
text=True,
)
return result.returncode, result.stdout + result.stderr
def _run_new(root: Path) -> tuple[int, str, str]:
"""Invoke the shipped command by BARE NAME — never a path (T-1261)."""
env = {**os.environ, "SR_REPO_ROOT": str(root), "SR_OUTPUT_FORMAT": "text"}
result = subprocess.run(
["reach", "check", "client-version"],
capture_output=True,
text=True,
env=env,
)
json_env = {**os.environ, "SR_REPO_ROOT": str(root), "SR_OUTPUT_FORMAT": "json"}
as_json = subprocess.run(
["reach", "check", "client-version"],
capture_output=True,
text=True,
env=json_env,
)
return result.returncode, result.stdout + result.stderr, as_json.stderr
FACT_CASES = [
# (label, catalog fact_ids, referenced fact_ids, expect non-zero)
("fact-ok", ["alpha", "beta"], ["alpha"], False),
("fact-unknown", ["alpha"], ["alpha", "ghost"], True),
("fact-advisory", [], ["alpha"], False), # no catalogs: advisory, exit 0
]
def _build_fact_fixture(tmp: Path, catalog: list[str], referenced: list[str]) -> Path:
"""A miniature repo with knowledge catalogs and campaign content."""
root = tmp / "fixture"
knowledge = root / "server" / "content" / "global" / "knowledge"
campaigns = root / "server" / "content" / "campaigns"
knowledge.mkdir(parents=True)
campaigns.mkdir(parents=True)
(root / "project.yaml").write_text("version: 0.0.0\n", encoding="utf-8")
(root / "tooling").mkdir()
if catalog:
(knowledge / "facts.yaml").write_text(
"# a comment mentioning fact_id: decoy_should_not_count\n"
+ "".join(f" - fact_id: {name}\n" for name in catalog),
encoding="utf-8",
)
# Excluded by name in both implementations — if either starts counting it,
# the canonical totals diverge and this case catches it.
(knowledge / "entity-attributes.yaml").write_text(
" - fact_id: attribute_not_a_fact\n", encoding="utf-8"
)
(campaigns / "story.yaml").write_text(
"".join(f' - fact_id: "{name}"\n' for name in referenced), encoding="utf-8"
)
shutil.copy2(REPO_ROOT / "tooling" / "check-fact-ids", root / "tooling" / "check-fact-ids")
(root / "tooling" / "check-fact-ids").chmod(0o755)
return root
def check_fact_ids(failures: list[str]) -> None:
"""Parity for the first bash-to-Python rewrite (D-263).
Worth more scrutiny than a Python move: nothing here was relocated, it was
reimplemented, so the two could agree on the happy path and diverge on
exactly the inputs the grep chain handled by accident — quoted values,
trailing comments, the excluded catalog.
"""
for label, catalog, referenced, expect_failure in FACT_CASES:
with tempfile.TemporaryDirectory() as tmpdir:
root = _build_fact_fixture(Path(tmpdir), catalog, referenced)
old = subprocess.run(
[str(root / "tooling" / "check-fact-ids")],
capture_output=True,
text=True,
)
env = {**os.environ, "SR_REPO_ROOT": str(root), "SR_OUTPUT_FORMAT": "text"}
new = subprocess.run(
["reach", "check", "fact-ids"],
capture_output=True,
text=True,
env=env,
)
if (old.returncode != 0) != expect_failure:
failures.append(
f"[{label}] the OLD script exited {old.returncode}; the fixture "
"does not set up the case it claims to"
)
if old.returncode != new.returncode:
failures.append(
f"[{label}] exit differs: old={old.returncode} new={new.returncode}"
)
combined = new.stdout + new.stderr
if expect_failure and "ghost" not in combined:
failures.append(
f"[{label}] the new output does not name the unknown fact_id, so a "
"failure says something is wrong without saying what"
)
if "attribute_not_a_fact" in combined:
failures.append(
f"[{label}] entity-attributes.yaml was counted as a catalog — its "
"schema is attribute keys, and including it inflates the canonical set"
)
# Ranges from the repo's own history, chosen so both implementations see
# identical input with nothing mutated. A fixture repo would not work here: the
# canvas registry globs from a __file__-derived root, so under SR_REPO_ROOT the
# new service would diff the fixture while globbing the real tree. Real history
# sidesteps that asymmetry entirely — and the failure cases below are genuine
# instances of the regression this gate exists to catch, not constructed ones.
#
# SHAs are hardcoded because history is immutable. If a rebase ever invalidates
# one, the test says so by name rather than silently checking nothing.
CANVAS_CASES = [
# (label, base, head, expect non-zero)
("canvas-needs-bump", "4e503c356~1", "4e503c356", True), # T-1237: bumped later
("canvas-needs-bump-2", "566b56651~1", "566b56651", True), # T-1194: same
("canvas-bumped", "9b146f9e1~1", "9b146f9e1", False), # changed AND bumped
("canvas-clean", "6b31111cd~1", "6b31111cd", False), # governance only
("canvas-no-base", "no-such-ref-anywhere", "HEAD", False), # skip, not fail
]
def check_canvas_version(failures: list[str]) -> None:
"""Parity for the gate with the worst track record — five shipped regressions.
Its failure path is the one that matters, so two of the five cases are real
commits that changed canvas generation without bumping the version. Both
were bumped after the fact, which is precisely the history that produced
this check.
"""
for label, base, head, expect_failure in CANVAS_CASES:
old = subprocess.run(
[
sys.executable,
str(REPO_ROOT / "tooling" / "check-canvas-version"),
"--base",
base,
"--head",
head,
],
capture_output=True,
text=True,
cwd=REPO_ROOT,
)
new = subprocess.run(
["reach", "check", "canvas-version", "--base", base, "--head", head],
capture_output=True,
text=True,
cwd=REPO_ROOT,
env={**os.environ, "SR_OUTPUT_FORMAT": "text"},
)
if (old.returncode != 0) != expect_failure:
failures.append(
f"[{label}] the OLD script exited {old.returncode}; this range no "
"longer sets up the case it claims to — history may have been rewritten"
)
if old.returncode != new.returncode:
failures.append(
f"[{label}] exit differs: old={old.returncode} new={new.returncode}"
)
if expect_failure:
# The changed files are the actionable half of this failure; a
# message that says "something changed" without saying what leaves
# the reader to re-derive the intersection by hand.
for stream in (old.stdout + old.stderr,):
for line in stream.splitlines():
token = line.strip()
if token.endswith((".rs", ".gd")) and token not in (
new.stdout + new.stderr
):
failures.append(
f"[{label}] new output omits a changed file the old one "
f"named: {token}"
)
def main() -> int:
if shutil.which("reach") is None:
print(
"test_check_parity: `reach` is not on PATH.\n"
" Fix: make install-reach",
file=sys.stderr,
)
return 1
failures: list[str] = []
for label, yaml_version, godot_version in CASES:
with tempfile.TemporaryDirectory() as tmpdir:
root = _build_fixture(Path(tmpdir), yaml_version, godot_version)
old_code, old_text = _run_old(root)
new_code, new_text, new_json = _run_new(root)
# 1. Exit codes must match exactly — the hook gates on this.
if old_code != new_code:
failures.append(
f"[{label}] exit code differs: old={old_code} new={new_code}"
)
# A case that cannot fail proves nothing: drift and missing-file must
# actually be non-zero, or "they matched" would be vacuous.
if label != "ok" and new_code == 0:
failures.append(
f"[{label}] expected a NON-ZERO exit — a gate that only ever "
"passes has never been tested"
)
if label == "ok" and new_code != 0:
failures.append(f"[{label}] expected exit 0, got {new_code}")
# 2. Every fact the old message carried must survive the port.
#
# Derived from what the old output ACTUALLY contains, not from a
# hardcoded list — the first version of this asserted the yaml
# version on every failing path, which the old script does not
# report when the client file is missing. The rule is "nothing the
# old message said is lost", so the old message has to define it.
candidates = {yaml_version, godot_version, str(root / "client" / "project.godot")}
expected_facts = [f for f in candidates if f and f in old_text]
for fact in expected_facts:
if fact not in new_text:
failures.append(
f"[{label}] new output drops a fact the old one carried: {fact!r}\n"
f" old: {old_text.strip()!r}\n"
f" new: {new_text.strip()!r}"
)
# 3. Failures must name a remedy (D-263), structurally not just in prose.
if label != "ok":
verdicts = [
json.loads(line)
for line in new_json.splitlines()
if line.strip().startswith("{")
]
verdict = next((v for v in verdicts if v.get("kind") == "verdict"), None)
if verdict is None:
failures.append(f"[{label}] no verdict event on the stream")
elif not verdict.get("fix"):
failures.append(
f"[{label}] verdict carries no `fix` — D-263 requires a failure "
"to name the command that resolves it"
)
# The OK line is byte-identical in text mode; only the stream differs.
if label == "ok" and old_text.strip() != new_text.strip():
failures.append(
f"[ok] text differs\n old: {old_text.strip()!r}\n"
f" new: {new_text.strip()!r}"
)
check_fact_ids(failures)
check_canvas_version(failures)
if failures:
print("test_check_parity: FAIL", file=sys.stderr)
for failure in failures:
print(f" - {failure}", file=sys.stderr)
return 1
print(
f"test_check_parity: OK — client-version {len(CASES)} paths, "
f"fact-ids {len(FACT_CASES)} paths, canvas-version {len(CANVAS_CASES)} paths, "
"all agreeing on exit code and content"
)
return 0
if __name__ == "__main__":
sys.exit(main())