Files
settled-reach/tooling/test_check_parity.py
T
jpmschweitzerandClaude Opus 5 b88791705c feat(config): T-1281 — check fact-ids, the first bash rewrite
89 lines of grep/sed pipeline become a service returning a FactIdCheck and a
router that renders it. Parity on the live tree is exact: both implementations
print "check-fact-ids: OK — 6 references validated against 61 canonical facts"
and exit 0. The matching counts are the real evidence — a line-matching regex
that differed from the grep chain even slightly would move 6 or 61.

Kept line-matched rather than YAML-parsed on purpose. Parsing properly would
change which lines count: anchors, merge keys and multi-document files would
start contributing ids the old check never saw. That is a different check
wearing the same name, and a port is not the place to make it.

Three parity cases: ok, unknown fact_id, and the advisory mode where the
catalogs hold no definitions and the gate deliberately exits 0 — failing every
commit until they are populated would teach people to bypass the hook, and a
gate people route around protects nothing.

Proven to fail by removing the entity-attributes.yaml exclusion, and caught in
a way worth noting: not by the assertion aimed at it, but by the advisory case,
where including that file made the catalog non-empty so the new implementation
enforced while the old stayed advisory. A real behavioural divergence, surfaced
by exit code.

Retirement waits for the whole domain, per the per-domain rule — three gates
remain. It also resolves a tension: the parity test copies the old script into
its fixture, so deleting the script early would delete the test's own subject.
A parity test is scaffolding with a defined lifetime.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 17:38:54 +02:00

288 lines
12 KiB
Python

#!/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"
)
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)
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, all agreeing on exit code and content"
)
return 0
if __name__ == "__main__":
sys.exit(main())