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>
This commit is contained in:
2026-08-31 17:38:54 +02:00
co-authored by Claude Opus 5
parent e359cfa841
commit b88791705c
6 changed files with 250 additions and 2 deletions
@@ -2085,3 +2085,6 @@ SEQUENCING SET 2026-08-31 by decision. (1) T-1264 (streaming, as decorators) lan
WAVE 1 FILED 2026-08-31: T-1281 check (four remaining gates), T-1282 validate, T-1283 godot and visual together. Deliberately NOT filing all fourteen domains at once the standard port acceptance is defined once on T-1281 and referenced, and the remaining tickets (atlas, starmap, planet, db, wiki, assets, character, generate, dev, pr) get written after wave 1 lands, when the real friction of a port is known rather than guessed. Four descriptions written from experience beat ten written from a map. T-1272 was also RESCOPED today: the hyphenated directories are emptied by the domain moves rather than renamed, so it becomes a closing verification instead of an opening step.
SCOPE CORRECTION 2026-08-31, found on starting the first port and materially larger than the epic assumed. SEVENTEEN of the 33 extensionless executables are BASH, not Python about 900 lines. This description and the domain map both assumed a Python tree, so those are REWRITES, not moves. Decided with Jeroen: rewrite all of them in Python rather than wrap any. Wrapping would achieve one door while leaving half the CLI surface outside the contract no @command, no remedy on failure, no streaming, no testable service so reach --help would list verbs that behave differently from the ones beside them, which is worse than two doors because the inconsistency is invisible until something fails. D-263 amended. The bash inventory, by shape: THIN WRAPPERS that mostly invoke something else (atlas-systems-done 9, atlas-names 17, generate-brands 23, atlas 24, generate-corporations 23, blender 28) — these become routers calling the same thing, nearly free. LOGIC that computes a verdict (check-fact-ids 89, validate-ron 137, godot-parse-sweep 64, godot-cold-parse 96, pr-watchlist-diff 37, atlas-update-field 59, atlas-commit-and-sync 60, tea-comment 41) — these gain the most from becoming services. ENVIRONMENT scripts (install-godot 108, install-rust 33, worktree-setup 49) — these gain least and carry the most regression risk, because downloading and unzipping a Godot build or driving rustup is awkward to exercise in a gate; port their DECISION logic into a testable service and keep the irreducible external calls behind core/process. Per-domain port tickets must state which of their sources are bash, since a bash source changes a port from mechanical to a rewrite needing its own parity evidence.', NULL, '2026-08-31 15:33:16', '2026-08-31 15:33:16.713', '2026-08-31 15:33:16.713', NULL, '158c68d98b9221d0ebe6d876be60918e', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5GWCTS89VSST07DFVBVG064', 'description', 'Finish what T-1262 started. domains/check/ currently has one verb (client-version); the other four gates join it: check-canvas-version, check-systems-db-stamp, check-fact-ids and check-dataflow-graph.py. Per the domain map, canvas_sources.py and generator_sources.py move alongside as module-level REGISTRIES, not verbs — nothing types them, and putting them in reach --help would answer a question nobody has. schema_version.py goes to the db domain where it is defined; check imports it, which is what a service layer is for. STANDARD PORT ACCEPTANCE, applying to every per-domain ticket under this epic: (a) service.py holds the logic and is transport-agnostic — no printing, no sys.exit, no typer; (b) router.py holds no logic and every command wears @command; (c) a parity test per gate, copying the pattern in tooling/test_check_parity.py, which builds a throwaway fixture repo and runs BOTH implementations — exit codes must match exactly and no fact the old message carried may be lost; (d) the old scripts are RETIRED once parity passes, not left alongside; (e) that domain''s tooling make targets are retired too rather than left as wrappers, per the D-263 make/reach split — build orchestration stays with make. Note check-dataflow-graph.py and check-canvas-version both parse git output, so their fixtures need a git repo rather than a bare directory; that is the one thing here harder than client-version was.', 'Finish what T-1262 started. domains/check/ currently has one verb (client-version); the other four gates join it: check-canvas-version, check-systems-db-stamp, check-fact-ids and check-dataflow-graph.py. Per the domain map, canvas_sources.py and generator_sources.py move alongside as module-level REGISTRIES, not verbs — nothing types them, and putting them in reach --help would answer a question nobody has. schema_version.py goes to the db domain where it is defined; check imports it, which is what a service layer is for. STANDARD PORT ACCEPTANCE, applying to every per-domain ticket under this epic: (a) service.py holds the logic and is transport-agnostic — no printing, no sys.exit, no typer; (b) router.py holds no logic and every command wears @command; (c) a parity test per gate, copying the pattern in tooling/test_check_parity.py, which builds a throwaway fixture repo and runs BOTH implementations — exit codes must match exactly and no fact the old message carried may be lost; (d) the old scripts are RETIRED once parity passes, not left alongside; (e) that domain''s tooling make targets are retired too rather than left as wrappers, per the D-263 make/reach split — build orchestration stays with make. Note check-dataflow-graph.py and check-canvas-version both parse git output, so their fixtures need a git repo rather than a bare directory; that is the one thing here harder than client-version was.
PROGRESS 2026-08-31 fact-ids ported, the first bash-to-Python rewrite under the D-263 decision. It was 89 lines of grep/sed pipeline; it is now service.fact_ids() returning a FactIdCheck, with a router that renders it. PARITY IS EXACT ON THE LIVE TREE: both implementations print ''check-fact-ids: OK 6 references validated against 61 canonical facts'' and exit 0. The matching COUNTS are the real evidence if the line-matching regex differed from the grep chain even slightly, 6 and 61 would diverge. Kept line-matched rather than YAML-parsed on purpose: parsing properly would change which lines count (anchors, merge keys, multi-document files would start contributing ids the old check never saw), which is a different check wearing the same name. Three parity cases added to test_check_parity.py ok, unknown fact_id, and the advisory mode where catalogs are unpopulated. Proven to fail: removing the entity-attributes.yaml exclusion was caught, and interestingly by the ADVISORY case rather than the exclusion assertion including that file made the catalog non-empty, so the new implementation went to enforcing mode while the old stayed advisory. A real behavioural divergence, caught by exit code. RETIREMENT DEFERRED to the end of this ticket, and there is a reason worth recording. The rule is retire per DOMAIN, and 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 when the old script goes, its parity case goes with it and is replaced by a unit test of the new behaviour. Same will apply to every port under T-1250. NOTE the pre-commit hook calls tooling/check-fact-ids directly, so retirement means editing that hook and a mistake there is felt on every commit rather than every push. Do it together with validate-content (T-1282), which is in the same hook, so it is edited once.', NULL, '2026-08-31 15:38:42', '2026-08-31 15:38:42.109', '2026-08-31 15:38:42.109', NULL, '0ea9d87f8863bc38b0020df38923641c', 2) ON CONFLICT(hash) DO NOTHING;
+3
View File
@@ -2435,3 +2435,6 @@ SEQUENCING SET 2026-08-31 by decision. (1) T-1264 (streaming, as decorators) lan
WAVE 1 FILED 2026-08-31: T-1281 check (four remaining gates), T-1282 validate, T-1283 godot and visual together. Deliberately NOT filing all fourteen domains at once the standard port acceptance is defined once on T-1281 and referenced, and the remaining tickets (atlas, starmap, planet, db, wiki, assets, character, generate, dev, pr) get written after wave 1 lands, when the real friction of a port is known rather than guessed. Four descriptions written from experience beat ten written from a map. T-1272 was also RESCOPED today: the hyphenated directories are emptied by the domain moves rather than renamed, so it becomes a closing verification instead of an opening step.
SCOPE CORRECTION 2026-08-31, found on starting the first port and materially larger than the epic assumed. SEVENTEEN of the 33 extensionless executables are BASH, not Python about 900 lines. This description and the domain map both assumed a Python tree, so those are REWRITES, not moves. Decided with Jeroen: rewrite all of them in Python rather than wrap any. Wrapping would achieve one door while leaving half the CLI surface outside the contract no @command, no remedy on failure, no streaming, no testable service so reach --help would list verbs that behave differently from the ones beside them, which is worse than two doors because the inconsistency is invisible until something fails. D-263 amended. The bash inventory, by shape: THIN WRAPPERS that mostly invoke something else (atlas-systems-done 9, atlas-names 17, generate-brands 23, atlas 24, generate-corporations 23, blender 28) — these become routers calling the same thing, nearly free. LOGIC that computes a verdict (check-fact-ids 89, validate-ron 137, godot-parse-sweep 64, godot-cold-parse 96, pr-watchlist-diff 37, atlas-update-field 59, atlas-commit-and-sync 60, tea-comment 41) — these gain the most from becoming services. ENVIRONMENT scripts (install-godot 108, install-rust 33, worktree-setup 49) — these gain least and carry the most regression risk, because downloading and unzipping a Godot build or driving rustup is awkward to exercise in a gate; port their DECISION logic into a testable service and keep the irreducible external calls behind core/process. Per-domain port tickets must state which of their sources are bash, since a bash source changes a port from mechanical to a rewrite needing its own parity evidence.', 'in_progress', 'medium', NULL, NULL, 'D-263', '2026-08-20 00:23:58.880', '2026-08-31 15:33:16.713', NULL, '42606b68b3a882a1ba18a2c9dad2e848', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5GWCTS89VSST07DFVBVG064', 'task', '06G1S3D0M1TQW0GMFBBPQZG3ZM', 'Port the check domain — the four remaining gates', 'Finish what T-1262 started. domains/check/ currently has one verb (client-version); the other four gates join it: check-canvas-version, check-systems-db-stamp, check-fact-ids and check-dataflow-graph.py. Per the domain map, canvas_sources.py and generator_sources.py move alongside as module-level REGISTRIES, not verbs — nothing types them, and putting them in reach --help would answer a question nobody has. schema_version.py goes to the db domain where it is defined; check imports it, which is what a service layer is for. STANDARD PORT ACCEPTANCE, applying to every per-domain ticket under this epic: (a) service.py holds the logic and is transport-agnostic — no printing, no sys.exit, no typer; (b) router.py holds no logic and every command wears @command; (c) a parity test per gate, copying the pattern in tooling/test_check_parity.py, which builds a throwaway fixture repo and runs BOTH implementations — exit codes must match exactly and no fact the old message carried may be lost; (d) the old scripts are RETIRED once parity passes, not left alongside; (e) that domain''s tooling make targets are retired too rather than left as wrappers, per the D-263 make/reach split — build orchestration stays with make. Note check-dataflow-graph.py and check-canvas-version both parse git output, so their fixtures need a git repo rather than a bare directory; that is the one thing here harder than client-version was.
PROGRESS 2026-08-31 fact-ids ported, the first bash-to-Python rewrite under the D-263 decision. It was 89 lines of grep/sed pipeline; it is now service.fact_ids() returning a FactIdCheck, with a router that renders it. PARITY IS EXACT ON THE LIVE TREE: both implementations print ''check-fact-ids: OK 6 references validated against 61 canonical facts'' and exit 0. The matching COUNTS are the real evidence if the line-matching regex differed from the grep chain even slightly, 6 and 61 would diverge. Kept line-matched rather than YAML-parsed on purpose: parsing properly would change which lines count (anchors, merge keys, multi-document files would start contributing ids the old check never saw), which is a different check wearing the same name. Three parity cases added to test_check_parity.py ok, unknown fact_id, and the advisory mode where catalogs are unpopulated. Proven to fail: removing the entity-attributes.yaml exclusion was caught, and interestingly by the ADVISORY case rather than the exclusion assertion including that file made the catalog non-empty, so the new implementation went to enforcing mode while the old stayed advisory. A real behavioural divergence, caught by exit code. RETIREMENT DEFERRED to the end of this ticket, and there is a reason worth recording. The rule is retire per DOMAIN, and 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 when the old script goes, its parity case goes with it and is replaced by a unit test of the new behaviour. Same will apply to every port under T-1250. NOTE the pre-commit hook calls tooling/check-fact-ids directly, so retirement means editing that hook and a mistake there is felt on every commit rather than every push. Do it together with validate-content (T-1282), which is in the same hook, so it is edited once.', 'in_progress', 'high', NULL, NULL, 'D-263', '2026-08-31 15:30:35.338', '2026-08-31 15:38:42.109', NULL, '9e8af2a6085ac6f63a902ce84ce8619d', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
+41
View File
@@ -64,3 +64,44 @@ def client_version() -> None:
)
console.verdict(f"check-client-version: OK — {result.yaml_version}")
@app.command("fact-ids")
@command
def fact_ids() -> None:
"""Fail if content references a fact_id no knowledge catalog defines."""
result = service.fact_ids()
if result.advisory:
# Advisory rather than failing: until the catalogs hold definitions,
# failing every commit would teach people to bypass the hook, and a gate
# people route around protects nothing.
console.event(
"check-fact-ids: WARNING — no canonical fact_ids in knowledge catalogs",
level="warn",
)
for fact in result.referenced:
console.event(f" {fact}", level="warn")
console.verdict(
f"check-fact-ids: advisory — catalogs unpopulated, "
f"{len(result.referenced)} reference(s) unchecked"
)
return
if result.unknown:
detail = "\n".join(
f" {fact.fact_id}\n" + "".join(f" {where}\n" for where in fact.locations)
for fact in result.unknown
)
raise ReachError(
f"check-fact-ids: {len(result.unknown)} unknown fact_id(s)\n{detail}",
fix=(
"define them in server/content/global/knowledge/*.yaml, or correct "
"the references above"
),
)
console.verdict(
f"check-fact-ids: OK — {len(result.referenced)} references validated "
f"against {result.canonical_count} canonical facts"
)
+30
View File
@@ -15,6 +15,36 @@ from __future__ import annotations
from pydantic import BaseModel, ConfigDict
class UnknownFact(BaseModel):
"""A fact_id referenced by content that no knowledge catalog defines."""
model_config = ConfigDict(frozen=True)
fact_id: str
locations: list[str] = []
class FactIdCheck(BaseModel):
"""The outcome of validating content fact_id references against the catalogs.
`advisory` is the mode where the catalogs hold no definitions at all. The
check then reports what it found and exits 0 — deliberately, because failing
every commit until the catalogs are populated would train people to bypass
the hook, and a gate people route around protects nothing.
"""
model_config = ConfigDict(frozen=True)
advisory: bool
canonical_count: int
referenced: list[str] = []
unknown: list[UnknownFact] = []
@property
def ok(self) -> bool:
return not self.unknown
class VersionCheck(BaseModel):
"""The outcome of comparing project.yaml against client/project.godot.
+81 -1
View File
@@ -12,7 +12,7 @@ import re
from pathlib import Path
from tooling.core import config
from tooling.domains.check.schemas import VersionCheck
from tooling.domains.check.schemas import FactIdCheck, UnknownFact, VersionCheck
# Anchored to line start so the commentary above `version:` (which mentions
# earlier versions by number) can never be mistaken for the field itself.
@@ -52,6 +52,86 @@ def client_version() -> VersionCheck:
)
# Ported from the bash `check-fact-ids` (D-263: the shell scripts are rewritten,
# not wrapped). Deliberately still LINE-MATCHED rather than YAML-parsed: the
# original was grep-based, and parsing YAML properly would change which lines
# count — anchors, merge keys and multi-document files would all start
# contributing ids the old check never saw. That is a different check wearing
# the same name, and this port is not the place to make it.
_FACT_ID = re.compile(r"^\s*(?:-\s*)?fact_id:\s*(.+?)\s*$")
# Its schema is attribute keys, not fact_ids — excluded by the original too.
_NOT_A_CATALOG = "entity-attributes.yaml"
def fact_ids() -> FactIdCheck:
"""Validate fact_id references in campaign content against the catalogs."""
root = config.repo_root()
canonical = _collect(root / "server" / "content" / "global" / "knowledge", catalogs=True)
referenced = _collect(root / "server" / "content" / "campaigns", catalogs=False)
if not canonical:
return FactIdCheck(
advisory=True,
canonical_count=0,
referenced=sorted(referenced),
)
unknown = [
UnknownFact(fact_id=ref, locations=_locate(root, ref))
for ref in sorted(referenced)
if ref not in canonical
]
return FactIdCheck(
advisory=False,
canonical_count=len(canonical),
referenced=sorted(referenced),
unknown=unknown,
)
def _collect(directory: Path, *, catalogs: bool) -> set[str]:
found: set[str] = set()
if not directory.is_dir():
return found
for path in directory.rglob("*.yaml"):
if catalogs and path.name == _NOT_A_CATALOG:
continue
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
if line.lstrip().startswith("#"):
continue
match = _FACT_ID.match(line)
if match:
value = _clean(match.group(1))
if value:
found.add(value)
return found
def _clean(value: str) -> str:
"""Strip a trailing comment and surrounding quotes, as the sed chain did."""
value = re.sub(r"\s+#.*$", "", value).strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
value = value[1:-1]
return value.strip()
def _locate(root: Path, fact_id: str) -> list[str]:
"""Repo-relative file:line for every reference, so a failure is actionable."""
content = root / "server" / "content" / "campaigns"
hits: list[str] = []
if not content.is_dir():
return hits
for path in sorted(content.rglob("*.yaml")):
for number, line in enumerate(
path.read_text(encoding="utf-8", errors="replace").splitlines(), start=1
):
match = _FACT_ID.match(line)
if match and _clean(match.group(1)) == fact_id:
hits.append(f"{path.relative_to(root)}:{number}")
return hits
def _read(path: Path, pattern: re.Pattern[str], label: str) -> tuple[str | None, str | None]:
"""Return (value, problem). Exactly one of the two is ever set."""
if not path.exists():
+92 -1
View File
@@ -109,6 +109,92 @@ def _run_new(root: Path) -> tuple[int, str, str]:
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(
@@ -182,13 +268,18 @@ def main() -> int:
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 — {len(CASES)} paths agree on exit code and content")
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