feat(config): T-1262 — parity is facts and exit codes, not bytes
schemas.py becomes pydantic, so the reference domain is the normal pattern rather than an exception carrying a footnote. Frozen: a result is a statement about what was found, and nothing downstream should edit the finding on its way to being reported. pydantic stays off the --help path — test_lazy_domains still passes, which is precisely the assertion that it loads with the domain and not with the CLI. The acceptance criterion could not be met as written, and that is the finding worth keeping. It asked for byte-for-byte parity with the old script; D-263 was amended after this ticket to give reach a streaming model that puts the verdict on stderr, while the old script writes its success line to stdout. Measured: the text is byte-identical in text mode, only the stream differs. Matching both would mean abandoning streaming or special-casing every ported gate. So parity is redefined, and it is stronger than bytes where it counts: exit codes match exactly, no fact the old message carried is lost, and failures name a remedy as a structured field. That governs every port in T-1251, not just this one, so it is in D-263 rather than only here. test_check_parity.py runs three paths — ok, drift, missing file — through both implementations and compares. It builds a throwaway fixture repo and copies the OLD script into it, because that script resolves its root from __file__ and has no override; the new command just takes SR_REPO_ROOT. That asymmetry is part of why the port earns its keep. It also asserts the failing paths actually exit non-zero, without which "the exit codes matched" would be vacuous for two checks that both silently pass. Proven to fail twice before being trusted. Once by accident: the first version asserted the yaml version appears on every failing path, which the old script does not report when the client file is missing — the test was wrong, not the code, and it now derives expected facts from what the old output actually contains. Once on purpose: mutating the router to drop a version made it fail and name the missing fact. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1546,3 +1546,45 @@ AMENDED 2026-08-20 — item (5), the wall-clock budget assertion from E4, is WIT
|
||||
AMENDED 2026-08-20 — item (5), the wall-clock budget assertion from E4, is WITHDRAWN along with the 104 ms timing-parity budget it enforced (D-263 amendment, T-1251). It is replaced by the import-graph assertion: after reach --help, no domain service module and no heavy dependency (numpy, scipy, PIL, pydantic) may appear in sys.modules. That test is not flaky on a loaded machine and it names the offending import rather than reporting that something got slower.
|
||||
|
||||
FROM T-1261 (2026-08-30) — the negative-invocation check is yours, and it is currently unguarded. T-1261 verified by hand that nothing invokes the CLI through an interpreter or a path, but wrote no automated check, deliberately, so as not to take this ticket''s work. The exact command that passed: grep -rn for the patterns ''python -m tooling'', ''python3 -m tooling'' and ''.venv/bin/reach'' across Makefile, .config/hooks, .claude and docs — currently returns nothing. Automate that in the conformance suite. It matters most at T-1253, where 84 make targets become reach wrappers and one interpreter-prefixed invocation among them silently undoes the PATH guarantee. Also add PYTHON_VERSION drift detection while you are there: T-1261 found uv had installed the reach tool on CPython 3.11 while .venv and system python were 3.14, because uv picks the lowest interpreter satisfying requires-python. The Makefile now pins PYTHON_VERSION for both, but nothing detects the two environments diverging again — and that divergence is invisible until a version-specific bug appears in one and not the other.', NULL, '2026-08-30 12:36:17', '2026-08-30 12:36:17.515', '2026-08-30 12:36:17.515', NULL, '8371c59b1d68bce09567852d4ceb190e', 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 ('06G1S6986SDTTQNCYCAQGKKCAW', 'status', 'in_progress', 'done', NULL, '2026-08-30 12:36:33', '2026-08-30 12:36:33.821', '2026-08-30 12:36:33.821', NULL, '7665029a30b3f979f8336b4be2d2d622', 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 ('06G1S6A2D22PZVQJ0S0HT91E7W', 'status', 'backlog', 'in_progress', NULL, '2026-08-31 06:28:59', '2026-08-31 06:28:59.498', '2026-08-31 06:28:59.498', NULL, 'd15cff9d352ee1657a4b49b9bdfeedf7', 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 ('06G1S6A2D22PZVQJ0S0HT91E7W', 'status', 'in_progress', 'in_progress', NULL, '2026-08-31 06:29:33', '2026-08-31 06:29:33.965', '2026-08-31 06:29:33.965', NULL, '93de33d3c7e0e3c6dfdf4a018b02b564', 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 ('06G1S3DTRHSBYJS55NDT9YR4R0', 'description', 'The highest-frequency, highest-friction surface, and the one with a hard number attached. Baseline measured 2026-08-20: check-client-version 20 ms, check-dataflow-graph 38 ms, check-canvas-version 46 ms — about 104 ms for the three unconditional checks; check-systems-db-stamp runs only when systems.db is in the push. The ported gates must not exceed that. Pieces that become tickets: (1) port the four checks into domains/check/ — client-version, canvas-version, dataflow-graph, systems-db-stamp; (2) port validate-content, validate-checklist, validate-ron, check-fact-ids into domains/validate/; (3) rewire .config/hooks/pre-push to call reach, keeping the fail_check by-name reporting that hook deliberately has; (4) prove each gate still FAILS — a check that explains itself and exits 0 silently disables its own gate, which is exactly the clide failure recorded in D-263; test each one against a deliberately broken tree, not just a clean one; (5) the wall-clock assertion that turns the budget into a test; (6) confirm domains/check/ imports no pydantic — 87 ms against a 20 ms check is a 3.5x regression bought for nothing, four times per push. Depends on E1 (lazy registration) and E2 (the error contract) being real first.
|
||||
|
||||
AMENDED 2026-08-20 — the 104 ms ceiling in the description above is WITHDRAWN. D-263 was amended the same day: the acceptance criterion is OUTPUT PARITY, not timing parity. A ported check must produce the same output and the same exit code as the script it replaces; it is not required to be as fast. Reason: the only automatic consumer is the pre-push hook, which on a server or client push already runs cargo test or the gdUnit4 suite (minutes), and on a governance-only push totals about a second — so a few hundred ms is invisible either way. The replacement budget is a ceiling with headroom: a reach invocation should feel instant to a human, under about 250 ms, and the unconditional gate set stays comfortably under a second. Item (5) of this ticket, the wall-clock assertion, is REPLACED by an import-graph assertion — a timing test is flaky on a loaded machine and reports that something got slow rather than what; asserting sys.modules after reach --help names the offender. Item (6), the no-pydantic-in-check rule, is DROPPED — pydantic at 87 ms is unremarkable against a 250 ms ceiling, and the carve-out bought 87 ms at the cost of an exception to the layering.', 'The highest-frequency, highest-friction surface, and the one with a hard number attached. Baseline measured 2026-08-20: check-client-version 20 ms, check-dataflow-graph 38 ms, check-canvas-version 46 ms — about 104 ms for the three unconditional checks; check-systems-db-stamp runs only when systems.db is in the push. The ported gates must not exceed that. Pieces that become tickets: (1) port the four checks into domains/check/ — client-version, canvas-version, dataflow-graph, systems-db-stamp; (2) port validate-content, validate-checklist, validate-ron, check-fact-ids into domains/validate/; (3) rewire .config/hooks/pre-push to call reach, keeping the fail_check by-name reporting that hook deliberately has; (4) prove each gate still FAILS — a check that explains itself and exits 0 silently disables its own gate, which is exactly the clide failure recorded in D-263; test each one against a deliberately broken tree, not just a clean one; (5) the wall-clock assertion that turns the budget into a test; (6) confirm domains/check/ imports no pydantic — 87 ms against a 20 ms check is a 3.5x regression bought for nothing, four times per push. Depends on E1 (lazy registration) and E2 (the error contract) being real first.
|
||||
|
||||
AMENDED 2026-08-20 — the 104 ms ceiling in the description above is WITHDRAWN. D-263 was amended the same day: the acceptance criterion is OUTPUT PARITY, not timing parity. A ported check must produce the same output and the same exit code as the script it replaces; it is not required to be as fast. Reason: the only automatic consumer is the pre-push hook, which on a server or client push already runs cargo test or the gdUnit4 suite (minutes), and on a governance-only push totals about a second — so a few hundred ms is invisible either way. The replacement budget is a ceiling with headroom: a reach invocation should feel instant to a human, under about 250 ms, and the unconditional gate set stays comfortably under a second. Item (5) of this ticket, the wall-clock assertion, is REPLACED by an import-graph assertion — a timing test is flaky on a loaded machine and reports that something got slow rather than what; asserting sys.modules after reach --help names the offender. Item (6), the no-pydantic-in-check rule, is DROPPED — pydantic at 87 ms is unremarkable against a 250 ms ceiling, and the carve-out bought 87 ms at the cost of an exception to the layering.
|
||||
|
||||
FROM T-1262 (2026-08-31) — what ''output parity'' means was sharpened while porting the first gate, and it applies to every port in this epic. Byte-for-byte parity is IMPOSSIBLE under the streaming model and should not be attempted: D-263 puts the verdict on stderr as part of the event stream, while the scripts being replaced write their success line to stdout. Matching both would mean abandoning streaming or special-casing each ported gate. The enforceable definition, now in D-263: (a) EXIT CODES MATCH EXACTLY — the hook gates on this and it is the only part a caller can act on programmatically; (b) NO FACT IS LOST — every version number, path and reason the old message carried must appear in the new one, since a migration that silently drops a detail makes the failure harder to fix than before; (c) FAILURES NAME A REMEDY as a structured field, which the old scripts did only in prose. In text mode the success line is in fact byte-identical; only the stream differs. tooling/test_check_parity.py is the working pattern to copy for the other three checks — it builds a throwaway fixture repo (sentinel project.yaml plus client/project.godot), copies the OLD script into it so its __file__-relative root resolves there, points the new command at the same fixture via SR_REPO_ROOT, and compares. Note the asymmetry it exposes: the old scripts have NO root override, which is precisely why they are hard to test and part of why the port is worth doing.', NULL, '2026-08-31 11:45:04', '2026-08-31 11:45:04.554', '2026-08-31 11:45:04.554', NULL, '5a496cfd422d97c6adbceee5e7245e24', 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 ('06G1S6A2D22PZVQJ0S0HT91E7W', 'description', 'The one domain ported end to end as the pattern every later port copies, so it is worth more care than its size suggests. Port tooling/check-client-version (72 lines, reads project.yaml and client/project.godot, compares the mirrored version, prints a drift message naming the remedy) into tooling/domains/check/. Deliverables: service.py holding the logic, TRANSPORT-AGNOSTIC — it returns a result and never prints, never calls sys.exit, never imports typer, so it can be called by a test or another service directly; router.py holding the Typer sub-app, which parses, delegates and formats, with ZERO logic; schemas.py holding the result shape as a STDLIB frozen dataclass, not pydantic. That last point is deliberate and is half the value of this ticket as a reference: pydantic costs 87 ms against a whole gate check of 20 ms, so domains/check/ carries no pydantic at all, and schemas.py existing while being pydantic-free demonstrates both the layering and the budget rule at once. Keep the existing tooling/check-client-version script IN PLACE and still wired to the pre-push hook — retiring old paths is E6, and the deprecation window is the point. Acceptance: reach check client-version matches the old script byte for byte on both the OK and the drift path, including the exit code.
|
||||
|
||||
AMENDED 2026-08-20 — the pydantic carve-out is DROPPED. The description above justifies a stdlib dataclass in schemas.py on the grounds that pydantic costs 87 ms against a 20 ms check; that reasoning came from the timing-parity budget which D-263 withdrew the same day. Pydantic is now available to every domain including check. Use pydantic in schemas.py so the reference domain is a TRUE reference — the pattern every later port copies should be the normal one, not an exception carrying a footnote. Everything else in this ticket stands unchanged, and the acceptance criterion is now the primary one: byte-for-byte output parity with the old script on both the OK and drift paths, exit code included.
|
||||
|
||||
RESCOPED 2026-08-25 by T-1260. This is now a HARDENING ticket, not a port. T-1260''s acceptance test needed a real domain as its subject — a stub raising NotImplementedError would have been committed dead code — so tooling/domains/check/ already exists with router.py, service.py and schemas.py, and service.client_version() is ported from tooling/check-client-version and working. reach check client-version runs green. WHAT REMAINS YOURS, unchanged: (1) convert schemas.py from the stdlib frozen dataclass to pydantic, so the reference domain shows the normal pattern rather than an exception; (2) BYTE-FOR-BYTE OUTPUT PARITY against the old script on BOTH paths — the OK path and the drift path — including the exit code, which is the acceptance criterion and has NOT been verified yet, only the OK path has been eyeballed; (3) the failure-path tests, including a deliberately drifted client/project.godot, since a gate that has only ever passed has never been tested. Note the old tooling/check-client-version script is still in place and still wired to the pre-push hook — the deprecation window is deliberate and retiring it is T-1253.', 'The one domain ported end to end as the pattern every later port copies, so it is worth more care than its size suggests. Port tooling/check-client-version (72 lines, reads project.yaml and client/project.godot, compares the mirrored version, prints a drift message naming the remedy) into tooling/domains/check/. Deliverables: service.py holding the logic, TRANSPORT-AGNOSTIC — it returns a result and never prints, never calls sys.exit, never imports typer, so it can be called by a test or another service directly; router.py holding the Typer sub-app, which parses, delegates and formats, with ZERO logic; schemas.py holding the result shape as a STDLIB frozen dataclass, not pydantic. That last point is deliberate and is half the value of this ticket as a reference: pydantic costs 87 ms against a whole gate check of 20 ms, so domains/check/ carries no pydantic at all, and schemas.py existing while being pydantic-free demonstrates both the layering and the budget rule at once. Keep the existing tooling/check-client-version script IN PLACE and still wired to the pre-push hook — retiring old paths is E6, and the deprecation window is the point. Acceptance: reach check client-version matches the old script byte for byte on both the OK and the drift path, including the exit code.
|
||||
|
||||
AMENDED 2026-08-20 — the pydantic carve-out is DROPPED. The description above justifies a stdlib dataclass in schemas.py on the grounds that pydantic costs 87 ms against a 20 ms check; that reasoning came from the timing-parity budget which D-263 withdrew the same day. Pydantic is now available to every domain including check. Use pydantic in schemas.py so the reference domain is a TRUE reference — the pattern every later port copies should be the normal one, not an exception carrying a footnote. Everything else in this ticket stands unchanged, and the acceptance criterion is now the primary one: byte-for-byte output parity with the old script on both the OK and drift paths, exit code included.
|
||||
|
||||
RESCOPED 2026-08-25 by T-1260. This is now a HARDENING ticket, not a port. T-1260''s acceptance test needed a real domain as its subject — a stub raising NotImplementedError would have been committed dead code — so tooling/domains/check/ already exists with router.py, service.py and schemas.py, and service.client_version() is ported from tooling/check-client-version and working. reach check client-version runs green. WHAT REMAINS YOURS, unchanged: (1) convert schemas.py from the stdlib frozen dataclass to pydantic, so the reference domain shows the normal pattern rather than an exception; (2) BYTE-FOR-BYTE OUTPUT PARITY against the old script on BOTH paths — the OK path and the drift path — including the exit code, which is the acceptance criterion and has NOT been verified yet, only the OK path has been eyeballed; (3) the failure-path tests, including a deliberately drifted client/project.godot, since a gate that has only ever passed has never been tested. Note the old tooling/check-client-version script is still in place and still wired to the pre-push hook — the deprecation window is deliberate and retiring it is T-1253.
|
||||
|
||||
DONE 2026-08-31. All three remaining deliverables landed, and one of them changed a definition that applies well beyond this ticket.
|
||||
|
||||
(1) schemas.py IS NOW PYDANTIC.
|
||||
VersionCheck is a frozen pydantic BaseModel. Frozen deliberately: a result object is a statement about what was found, and nothing downstream should be able to edit the finding on its way to being reported. Verified pydantic stays OFF the --help path — test_lazy_domains still passes, which is the assertion that pydantic loads with the domain rather than with the CLI. The reference domain is now the normal pattern with no footnote.
|
||||
|
||||
(2) BYTE-FOR-BYTE PARITY IS IMPOSSIBLE, AND SHOULD NOT BE ATTEMPTED. This is the real finding.
|
||||
The acceptance criterion in the description predates the D-263 streaming amendment (2026-08-20, written after this ticket). Streaming puts the verdict on STDERR as an event; the old script writes its success line to STDOUT. Measured both: the TEXT is byte-identical in text mode — `check-client-version: OK — 0.4.13` from each — but the STREAM differs. Matching both would mean abandoning the streaming model or special-casing every ported gate, and neither is worth it.
|
||||
Replaced with a definition that is stronger where it counts, now recorded in D-263 and carried to T-1251 because it governs every gate port in that epic:
|
||||
(a) EXIT CODES MATCH EXACTLY — what the hook gates on, and the only part a caller can act on programmatically.
|
||||
(b) NO FACT IS LOST — every version number, path and reason the old message carried appears in the new one. A migration that silently drops a detail makes a failure HARDER to fix than it was before, which is the opposite of the point.
|
||||
(c) FAILURES NAME A REMEDY as a structured field, which the old scripts did only in prose.
|
||||
|
||||
(3) FAILURE-PATH TESTS — tooling/test_check_parity.py, wired into make test-tooling.
|
||||
Three paths, each run through BOTH implementations and compared: ok, drift, missing-godot-file. Builds a throwaway fixture repo per case (sentinel project.yaml with commentary quoting OTHER version numbers, mirroring the real file so an unanchored regex would be caught) and copies the OLD script into fixture/tooling/ so its __file__-relative root resolves there. The new command is pointed at the same fixture with SR_REPO_ROOT. Worth noting the asymmetry that exposed: the old scripts have NO root override, which is exactly why they are hard to test and part of why the port earns its keep.
|
||||
The test also asserts drift and missing-file actually exit NON-ZERO — without that, "the exit codes matched" would be vacuous for a pair of checks that both silently pass.
|
||||
Invokes `reach` by BARE NAME per T-1261''s negative criterion, and fails with "Fix: make install-reach" if it is not on PATH.
|
||||
|
||||
PROVEN TO FAIL, twice, which is the only reason the green means anything.
|
||||
First accidentally: the initial version asserted the yaml version appears on every failing path, but the old script does not report it when the client file is missing — so the test was wrong, not the code. Fixed by deriving expected facts from what the OLD OUTPUT ACTUALLY CONTAINS rather than a hardcoded list, which is a better statement of the rule anyway ("nothing the old message said is lost" — so the old message defines it).
|
||||
Then deliberately: mutated the router to print a literal instead of result.godot_version. The test failed and named the dropped fact with both outputs side by side. Restored, green again.
|
||||
|
||||
make test-tooling: PASS end to end with both new tests wired in.', NULL, '2026-08-31 11:45:25', '2026-08-31 11:45:25.451', '2026-08-31 11:45:25.451', NULL, '88db4a728c74df17f5659cc8bc6c4883', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
|
||||
@@ -1647,3 +1647,81 @@ INSERT INTO tickets (record_id, type, parent_record_id, title, description, stat
|
||||
AMENDED 2026-08-20 — item (5), the wall-clock budget assertion from E4, is WITHDRAWN along with the 104 ms timing-parity budget it enforced (D-263 amendment, T-1251). It is replaced by the import-graph assertion: after reach --help, no domain service module and no heavy dependency (numpy, scipy, PIL, pydantic) may appear in sys.modules. That test is not flaky on a loaded machine and it names the offending import rather than reporting that something got slower.
|
||||
|
||||
FROM T-1261 (2026-08-30) — the negative-invocation check is yours, and it is currently unguarded. T-1261 verified by hand that nothing invokes the CLI through an interpreter or a path, but wrote no automated check, deliberately, so as not to take this ticket''s work. The exact command that passed: grep -rn for the patterns ''python -m tooling'', ''python3 -m tooling'' and ''.venv/bin/reach'' across Makefile, .config/hooks, .claude and docs — currently returns nothing. Automate that in the conformance suite. It matters most at T-1253, where 84 make targets become reach wrappers and one interpreter-prefixed invocation among them silently undoes the PATH guarantee. Also add PYTHON_VERSION drift detection while you are there: T-1261 found uv had installed the reach tool on CPython 3.11 while .venv and system python were 3.14, because uv picks the lowest interpreter satisfying requires-python. The Makefile now pins PYTHON_VERSION for both, but nothing detects the two environments diverging again — and that divergence is invisible until a version-specific bug appears in one and not the other.', 'backlog', 'medium', NULL, NULL, 'D-263', '2026-08-20 00:26:34.952', '2026-08-30 12:36:17.515', NULL, 'fd68c72105e921c0a7d1fefd4bd69240', 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 ('06G1S6986SDTTQNCYCAQGKKCAW', 'task', '06G1S392DEG41MRGEFSYWJWP2W', 'The PATH guarantee: uv tool install, verified from a non-interactive shell', 'The load-bearing requirement of the whole initiative, and the one most likely to be declared done on the wrong evidence. A [project.scripts] entrypoint lands in .venv/bin, which is on PATH only when the venv is activated — and agents and git hooks never activate it. That is the same split VENV_PY papers over in the Makefile, and the same scar tea left: an absolute path breaks the Bash(tea *) rule and prompts every time; the fix was a bare name on PATH.
|
||||
|
||||
Deliverables: uv tool install --editable . from the MAIN checkout (uv 0.10.2 is at ~/.local/bin/uv, tool dir ~/.local/share/uv/tools, and ~/.local/bin is already on PATH); a make target wrapping it so the procedure is not folklore; documentation of the re-point step, because uv records the source path and an install made from a worktree breaks when that worktree is removed.
|
||||
|
||||
VERIFICATION IS THE TICKET: bare reach must resolve from (a) a non-interactive shell with no venv activation, (b) an actual git hook context, (c) an agent Bash call. A login-shell check proves nothing here — that is exactly the case that already worked. Green is not evidence unless the check could have failed: confirm reach is NOT merely resolving because a venv happens to be active.
|
||||
|
||||
NEGATIVE ACCEPTANCE CRITERION (added 2026-08-20), which is the one that will be violated by accident. Typer documentation routinely shows commands invoked as python main.py or through the typer CLI runner; both are dev conveniences and NEITHER is the shipping shape. No documented, scripted or committed invocation of this CLI may go through python, through python -m tooling, through .venv/bin/reach, or through any file path. The only supported form is the bare word reach.
|
||||
|
||||
Rationale: an absolute or interpreter-prefixed path breaks the Bash(reach *) permission rule and prompts every time — the exact failure already recorded for tea, where the fix was a bare name on PATH — and it reinstates the interpreter fork between a human shell and a hook that VENV_PY papers over today. The highest-risk site is E6: 84 make targets become reach wrappers, and a single VENV_PY-prefixed or python -m invocation among them silently undoes the initiative. Add a grep-shaped check to the E8 conformance suite: no python -m tooling and no .venv/bin/reach anywhere in the Makefile, hooks, skills or rules.
|
||||
|
||||
FINDING from T-1258 (2026-08-20): make setup-venv is already stale and this ticket owns the fix. It runs python3 -m venv .venv then .venv/bin/pip install -e .[dev] --quiet, but the venv on disk was created by uv (pyvenv.cfg records uv = 0.10.2) and therefore has NO pip at all, so that second line cannot work against it. Replace the target with the uv path rather than patching pip back in — uv is the direction of travel for this initiative anyway, and installing pip into a uv venv just to satisfy a stale recipe recreates the interpreter fork the ticket exists to remove. Related: the project itself had never actually been installed before T-1258 — site-packages held the dependencies but no settled-reach-tooling dist-info, which is consistent with there being no __init__.py to expose.
|
||||
|
||||
DONE 2026-08-30. `reach` is a bare name on PATH, verified in all three contexts with a negative control that actually fails.
|
||||
|
||||
VERIFICATION — the part this ticket said was the ticket.
|
||||
Baseline first, so the checks could fail: VIRTUAL_ENV was unset in agent Bash calls and `reach` was NOT on PATH. Then after `uv tool install --python 3.14 --editable .`:
|
||||
(a) Stripped non-interactive shell — `env -i HOME=$HOME PATH=$HOME/.local/bin:/usr/bin:/bin bash -c ''reach check client-version''` — resolves and runs. No profile sourcing, no venv.
|
||||
(b) REAL git hook, not a simulation — wrote a scratch hook and ran `git -c core.hooksPath=.cache/hooktest hook run pre-push`. Inside the hook process: VIRTUAL_ENV unset, `command -v reach` -> ~/.local/bin/reach, check ran, exit 0. Scratch hook deleted afterwards.
|
||||
(c) Agent Bash call — resolves at ~/.local/bin/reach with VIRTUAL_ENV unset.
|
||||
NEGATIVE CONTROL: `env -i PATH=/usr/bin:/bin bash -c ''command -v reach''` -> NOT-FOUND. So the three passes above are discriminating, not vacuous, and confirm reach is not merely resolving because a venv happens to be active.
|
||||
|
||||
FOUND AND FIXED — a silent interpreter fork, which is this initiative''s own failure mode.
|
||||
`uv tool install` without --python chose CPython 3.11 for the tool environment while .venv and system python3 are both 3.14.6. uv picks the lowest interpreter satisfying requires-python (">=3.11"). That is precisely the human-shell-versus-hook divergence D-263 exists to remove, just relocated: `reach` would have run on one interpreter and the test scripts on another, with different wheels for numpy/scipy/PIL, and any future 3.12+ syntax would break the tool while the venv stayed green. Reinstalled with --python 3.14 and introduced PYTHON_VERSION ?= 3.14 in the Makefile so setup-venv and install-reach share one pin. Verified the tool env is now lib/python3.14.
|
||||
|
||||
make setup-venv REBUILT (the T-1258 finding) — and the first fix was wrong.
|
||||
Replaced `python3 -m venv` + `.venv/bin/pip` with the uv path. First attempt used plain `uv venv`, which FAILS on an existing venv ("A virtual environment already exists at .venv. Use --clear to replace it") — so the target was not idempotent, while the python3 -m venv version it replaced had been. Caught by actually running it twice rather than dry-running it, which is how the old one rotted unnoticed in the first place. Fixed with --allow-existing. Ran it twice, both clean, and test_lazy_domains still passes against the rebuilt venv.
|
||||
|
||||
DELIVERED
|
||||
- make install-reach — wraps uv tool install with the pin and --editable, and SELF-CHECKS: fails loudly if `reach` is not on PATH afterwards, pointing at `uv tool update-shell`. Added to `make setup`.
|
||||
- make reach-repoint — same command, but the situation has a name now. uv records the source path at install time, so an install made from a worktree keeps resolving there after the worktree is gone: `reach` still runs, edits in the main checkout do nothing, and there is NO error message. That silent-wrong-source failure is the reason this deserves its own target rather than a doc line.
|
||||
- docs/DEVOPS.md — a Setup subsection covering why ~/.local/bin rather than .venv/bin, the bare-name rule, what --editable and --python buy, and the re-pointing trap.
|
||||
- make test-tooling passes end to end (test-tooling: PASS).
|
||||
|
||||
NEGATIVE ACCEPTANCE CRITERION — verified manually, NOT yet automated.
|
||||
`grep -rn "python -m tooling\|python3 -m tooling\|\.venv/bin/reach" Makefile .config/hooks .claude docs` returns nothing. The automated version belongs to T-1257''s conformance suite and has been recorded there with the exact pattern; deliberately not written here to avoid taking that ticket''s work.', 'done', 'high', NULL, NULL, 'D-263', '2026-08-20 00:36:34.486', '2026-08-30 12:36:33.820', NULL, '00afdc1b9e62622f714f50635638f890', 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 ('06G1S6A2D22PZVQJ0S0HT91E7W', 'task', '06G1S392DEG41MRGEFSYWJWP2W', 'Reference domain: check client-version, end to end through router and service', 'The one domain ported end to end as the pattern every later port copies, so it is worth more care than its size suggests. Port tooling/check-client-version (72 lines, reads project.yaml and client/project.godot, compares the mirrored version, prints a drift message naming the remedy) into tooling/domains/check/. Deliverables: service.py holding the logic, TRANSPORT-AGNOSTIC — it returns a result and never prints, never calls sys.exit, never imports typer, so it can be called by a test or another service directly; router.py holding the Typer sub-app, which parses, delegates and formats, with ZERO logic; schemas.py holding the result shape as a STDLIB frozen dataclass, not pydantic. That last point is deliberate and is half the value of this ticket as a reference: pydantic costs 87 ms against a whole gate check of 20 ms, so domains/check/ carries no pydantic at all, and schemas.py existing while being pydantic-free demonstrates both the layering and the budget rule at once. Keep the existing tooling/check-client-version script IN PLACE and still wired to the pre-push hook — retiring old paths is E6, and the deprecation window is the point. Acceptance: reach check client-version matches the old script byte for byte on both the OK and the drift path, including the exit code.
|
||||
|
||||
AMENDED 2026-08-20 — the pydantic carve-out is DROPPED. The description above justifies a stdlib dataclass in schemas.py on the grounds that pydantic costs 87 ms against a 20 ms check; that reasoning came from the timing-parity budget which D-263 withdrew the same day. Pydantic is now available to every domain including check. Use pydantic in schemas.py so the reference domain is a TRUE reference — the pattern every later port copies should be the normal one, not an exception carrying a footnote. Everything else in this ticket stands unchanged, and the acceptance criterion is now the primary one: byte-for-byte output parity with the old script on both the OK and drift paths, exit code included.
|
||||
|
||||
RESCOPED 2026-08-25 by T-1260. This is now a HARDENING ticket, not a port. T-1260''s acceptance test needed a real domain as its subject — a stub raising NotImplementedError would have been committed dead code — so tooling/domains/check/ already exists with router.py, service.py and schemas.py, and service.client_version() is ported from tooling/check-client-version and working. reach check client-version runs green. WHAT REMAINS YOURS, unchanged: (1) convert schemas.py from the stdlib frozen dataclass to pydantic, so the reference domain shows the normal pattern rather than an exception; (2) BYTE-FOR-BYTE OUTPUT PARITY against the old script on BOTH paths — the OK path and the drift path — including the exit code, which is the acceptance criterion and has NOT been verified yet, only the OK path has been eyeballed; (3) the failure-path tests, including a deliberately drifted client/project.godot, since a gate that has only ever passed has never been tested. Note the old tooling/check-client-version script is still in place and still wired to the pre-push hook — the deprecation window is deliberate and retiring it is T-1253.', 'in_progress', 'high', NULL, NULL, 'D-263', '2026-08-20 00:36:41.192', '2026-08-31 06:28:59.493', NULL, '717d2fc2a15ce71caf3f61a1eebe0319', 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 ('06G1S6A2D22PZVQJ0S0HT91E7W', 'task', '06G1S392DEG41MRGEFSYWJWP2W', 'Reference domain: check client-version, end to end through router and service', 'The one domain ported end to end as the pattern every later port copies, so it is worth more care than its size suggests. Port tooling/check-client-version (72 lines, reads project.yaml and client/project.godot, compares the mirrored version, prints a drift message naming the remedy) into tooling/domains/check/. Deliverables: service.py holding the logic, TRANSPORT-AGNOSTIC — it returns a result and never prints, never calls sys.exit, never imports typer, so it can be called by a test or another service directly; router.py holding the Typer sub-app, which parses, delegates and formats, with ZERO logic; schemas.py holding the result shape as a STDLIB frozen dataclass, not pydantic. That last point is deliberate and is half the value of this ticket as a reference: pydantic costs 87 ms against a whole gate check of 20 ms, so domains/check/ carries no pydantic at all, and schemas.py existing while being pydantic-free demonstrates both the layering and the budget rule at once. Keep the existing tooling/check-client-version script IN PLACE and still wired to the pre-push hook — retiring old paths is E6, and the deprecation window is the point. Acceptance: reach check client-version matches the old script byte for byte on both the OK and the drift path, including the exit code.
|
||||
|
||||
AMENDED 2026-08-20 — the pydantic carve-out is DROPPED. The description above justifies a stdlib dataclass in schemas.py on the grounds that pydantic costs 87 ms against a 20 ms check; that reasoning came from the timing-parity budget which D-263 withdrew the same day. Pydantic is now available to every domain including check. Use pydantic in schemas.py so the reference domain is a TRUE reference — the pattern every later port copies should be the normal one, not an exception carrying a footnote. Everything else in this ticket stands unchanged, and the acceptance criterion is now the primary one: byte-for-byte output parity with the old script on both the OK and drift paths, exit code included.
|
||||
|
||||
RESCOPED 2026-08-25 by T-1260. This is now a HARDENING ticket, not a port. T-1260''s acceptance test needed a real domain as its subject — a stub raising NotImplementedError would have been committed dead code — so tooling/domains/check/ already exists with router.py, service.py and schemas.py, and service.client_version() is ported from tooling/check-client-version and working. reach check client-version runs green. WHAT REMAINS YOURS, unchanged: (1) convert schemas.py from the stdlib frozen dataclass to pydantic, so the reference domain shows the normal pattern rather than an exception; (2) BYTE-FOR-BYTE OUTPUT PARITY against the old script on BOTH paths — the OK path and the drift path — including the exit code, which is the acceptance criterion and has NOT been verified yet, only the OK path has been eyeballed; (3) the failure-path tests, including a deliberately drifted client/project.godot, since a gate that has only ever passed has never been tested. Note the old tooling/check-client-version script is still in place and still wired to the pre-push hook — the deprecation window is deliberate and retiring it is T-1253.', 'in_progress', 'high', NULL, NULL, 'D-263', '2026-08-20 00:36:41.192', '2026-08-31 06:29:33.965', NULL, '67af8f489ca03bb6ca0c68a946f07aab', 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 ('06G1S3DTRHSBYJS55NDT9YR4R0', 'epic', '06G1S37Y1ARMV68RJT802Z3VPG', 'E4 — The gate family and the push hook, at output parity', 'The highest-frequency, highest-friction surface, and the one with a hard number attached. Baseline measured 2026-08-20: check-client-version 20 ms, check-dataflow-graph 38 ms, check-canvas-version 46 ms — about 104 ms for the three unconditional checks; check-systems-db-stamp runs only when systems.db is in the push. The ported gates must not exceed that. Pieces that become tickets: (1) port the four checks into domains/check/ — client-version, canvas-version, dataflow-graph, systems-db-stamp; (2) port validate-content, validate-checklist, validate-ron, check-fact-ids into domains/validate/; (3) rewire .config/hooks/pre-push to call reach, keeping the fail_check by-name reporting that hook deliberately has; (4) prove each gate still FAILS — a check that explains itself and exits 0 silently disables its own gate, which is exactly the clide failure recorded in D-263; test each one against a deliberately broken tree, not just a clean one; (5) the wall-clock assertion that turns the budget into a test; (6) confirm domains/check/ imports no pydantic — 87 ms against a 20 ms check is a 3.5x regression bought for nothing, four times per push. Depends on E1 (lazy registration) and E2 (the error contract) being real first.
|
||||
|
||||
AMENDED 2026-08-20 — the 104 ms ceiling in the description above is WITHDRAWN. D-263 was amended the same day: the acceptance criterion is OUTPUT PARITY, not timing parity. A ported check must produce the same output and the same exit code as the script it replaces; it is not required to be as fast. Reason: the only automatic consumer is the pre-push hook, which on a server or client push already runs cargo test or the gdUnit4 suite (minutes), and on a governance-only push totals about a second — so a few hundred ms is invisible either way. The replacement budget is a ceiling with headroom: a reach invocation should feel instant to a human, under about 250 ms, and the unconditional gate set stays comfortably under a second. Item (5) of this ticket, the wall-clock assertion, is REPLACED by an import-graph assertion — a timing test is flaky on a loaded machine and reports that something got slow rather than what; asserting sys.modules after reach --help names the offender. Item (6), the no-pydantic-in-check rule, is DROPPED — pydantic at 87 ms is unremarkable against a 250 ms ceiling, and the carve-out bought 87 ms at the cost of an exception to the layering.
|
||||
|
||||
FROM T-1262 (2026-08-31) — what ''output parity'' means was sharpened while porting the first gate, and it applies to every port in this epic. Byte-for-byte parity is IMPOSSIBLE under the streaming model and should not be attempted: D-263 puts the verdict on stderr as part of the event stream, while the scripts being replaced write their success line to stdout. Matching both would mean abandoning streaming or special-casing each ported gate. The enforceable definition, now in D-263: (a) EXIT CODES MATCH EXACTLY — the hook gates on this and it is the only part a caller can act on programmatically; (b) NO FACT IS LOST — every version number, path and reason the old message carried must appear in the new one, since a migration that silently drops a detail makes the failure harder to fix than before; (c) FAILURES NAME A REMEDY as a structured field, which the old scripts did only in prose. In text mode the success line is in fact byte-identical; only the stream differs. tooling/test_check_parity.py is the working pattern to copy for the other three checks — it builds a throwaway fixture repo (sentinel project.yaml plus client/project.godot), copies the OLD script into it so its __file__-relative root resolves there, points the new command at the same fixture via SR_REPO_ROOT, and compares. Note the asymmetry it exposes: the old scripts have NO root override, which is precisely why they are hard to test and part of why the port is worth doing.', 'backlog', 'high', NULL, NULL, 'D-263', '2026-08-20 00:24:05.572', '2026-08-31 11:45:04.553', NULL, 'b0847f6443ed27f26989088e3d5adbd9', 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 ('06G1S6A2D22PZVQJ0S0HT91E7W', 'task', '06G1S392DEG41MRGEFSYWJWP2W', 'Reference domain: check client-version, end to end through router and service', 'The one domain ported end to end as the pattern every later port copies, so it is worth more care than its size suggests. Port tooling/check-client-version (72 lines, reads project.yaml and client/project.godot, compares the mirrored version, prints a drift message naming the remedy) into tooling/domains/check/. Deliverables: service.py holding the logic, TRANSPORT-AGNOSTIC — it returns a result and never prints, never calls sys.exit, never imports typer, so it can be called by a test or another service directly; router.py holding the Typer sub-app, which parses, delegates and formats, with ZERO logic; schemas.py holding the result shape as a STDLIB frozen dataclass, not pydantic. That last point is deliberate and is half the value of this ticket as a reference: pydantic costs 87 ms against a whole gate check of 20 ms, so domains/check/ carries no pydantic at all, and schemas.py existing while being pydantic-free demonstrates both the layering and the budget rule at once. Keep the existing tooling/check-client-version script IN PLACE and still wired to the pre-push hook — retiring old paths is E6, and the deprecation window is the point. Acceptance: reach check client-version matches the old script byte for byte on both the OK and the drift path, including the exit code.
|
||||
|
||||
AMENDED 2026-08-20 — the pydantic carve-out is DROPPED. The description above justifies a stdlib dataclass in schemas.py on the grounds that pydantic costs 87 ms against a 20 ms check; that reasoning came from the timing-parity budget which D-263 withdrew the same day. Pydantic is now available to every domain including check. Use pydantic in schemas.py so the reference domain is a TRUE reference — the pattern every later port copies should be the normal one, not an exception carrying a footnote. Everything else in this ticket stands unchanged, and the acceptance criterion is now the primary one: byte-for-byte output parity with the old script on both the OK and drift paths, exit code included.
|
||||
|
||||
RESCOPED 2026-08-25 by T-1260. This is now a HARDENING ticket, not a port. T-1260''s acceptance test needed a real domain as its subject — a stub raising NotImplementedError would have been committed dead code — so tooling/domains/check/ already exists with router.py, service.py and schemas.py, and service.client_version() is ported from tooling/check-client-version and working. reach check client-version runs green. WHAT REMAINS YOURS, unchanged: (1) convert schemas.py from the stdlib frozen dataclass to pydantic, so the reference domain shows the normal pattern rather than an exception; (2) BYTE-FOR-BYTE OUTPUT PARITY against the old script on BOTH paths — the OK path and the drift path — including the exit code, which is the acceptance criterion and has NOT been verified yet, only the OK path has been eyeballed; (3) the failure-path tests, including a deliberately drifted client/project.godot, since a gate that has only ever passed has never been tested. Note the old tooling/check-client-version script is still in place and still wired to the pre-push hook — the deprecation window is deliberate and retiring it is T-1253.
|
||||
|
||||
DONE 2026-08-31. All three remaining deliverables landed, and one of them changed a definition that applies well beyond this ticket.
|
||||
|
||||
(1) schemas.py IS NOW PYDANTIC.
|
||||
VersionCheck is a frozen pydantic BaseModel. Frozen deliberately: a result object is a statement about what was found, and nothing downstream should be able to edit the finding on its way to being reported. Verified pydantic stays OFF the --help path — test_lazy_domains still passes, which is the assertion that pydantic loads with the domain rather than with the CLI. The reference domain is now the normal pattern with no footnote.
|
||||
|
||||
(2) BYTE-FOR-BYTE PARITY IS IMPOSSIBLE, AND SHOULD NOT BE ATTEMPTED. This is the real finding.
|
||||
The acceptance criterion in the description predates the D-263 streaming amendment (2026-08-20, written after this ticket). Streaming puts the verdict on STDERR as an event; the old script writes its success line to STDOUT. Measured both: the TEXT is byte-identical in text mode — `check-client-version: OK — 0.4.13` from each — but the STREAM differs. Matching both would mean abandoning the streaming model or special-casing every ported gate, and neither is worth it.
|
||||
Replaced with a definition that is stronger where it counts, now recorded in D-263 and carried to T-1251 because it governs every gate port in that epic:
|
||||
(a) EXIT CODES MATCH EXACTLY — what the hook gates on, and the only part a caller can act on programmatically.
|
||||
(b) NO FACT IS LOST — every version number, path and reason the old message carried appears in the new one. A migration that silently drops a detail makes a failure HARDER to fix than it was before, which is the opposite of the point.
|
||||
(c) FAILURES NAME A REMEDY as a structured field, which the old scripts did only in prose.
|
||||
|
||||
(3) FAILURE-PATH TESTS — tooling/test_check_parity.py, wired into make test-tooling.
|
||||
Three paths, each run through BOTH implementations and compared: ok, drift, missing-godot-file. Builds a throwaway fixture repo per case (sentinel project.yaml with commentary quoting OTHER version numbers, mirroring the real file so an unanchored regex would be caught) and copies the OLD script into fixture/tooling/ so its __file__-relative root resolves there. The new command is pointed at the same fixture with SR_REPO_ROOT. Worth noting the asymmetry that exposed: the old scripts have NO root override, which is exactly why they are hard to test and part of why the port earns its keep.
|
||||
The test also asserts drift and missing-file actually exit NON-ZERO — without that, "the exit codes matched" would be vacuous for a pair of checks that both silently pass.
|
||||
Invokes `reach` by BARE NAME per T-1261''s negative criterion, and fails with "Fix: make install-reach" if it is not on PATH.
|
||||
|
||||
PROVEN TO FAIL, twice, which is the only reason the green means anything.
|
||||
First accidentally: the initial version asserted the yaml version appears on every failing path, but the old script does not report it when the client file is missing — so the test was wrong, not the code. Fixed by deriving expected facts from what the OLD OUTPUT ACTUALLY CONTAINS rather than a hardcoded list, which is a better statement of the rule anyway ("nothing the old message said is lost" — so the old message defines it).
|
||||
Then deliberately: mutated the router to print a literal instead of result.godot_version. The test failed and named the dropped fact with both outputs side by side. Restored, green again.
|
||||
|
||||
make test-tooling: PASS end to end with both new tests wired in.', 'in_progress', 'high', NULL, NULL, 'D-263', '2026-08-20 00:36:41.192', '2026-08-31 11:45:25.451', NULL, 'ad72b0871a63801dc2597549712689f3', 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;
|
||||
|
||||
@@ -307,6 +307,10 @@ test-tooling:
|
||||
@mkdir -p .cache
|
||||
@$(VENV_PY) tooling/test_lazy_domains.py 2> .cache/test-tooling-lazy-domains.log || \
|
||||
{ echo " FAIL: reach lazy registration — log follows:"; cat .cache/test-tooling-lazy-domains.log; exit 1; }
|
||||
@echo " [test-tooling] reach/check-client-version parity (T-1262)..."
|
||||
@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; }
|
||||
@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 || \
|
||||
|
||||
@@ -2625,7 +2625,9 @@ tooling/
|
||||
|
||||
**Constraints on execution (these are why the work is sequenced, not why it is hard):**
|
||||
|
||||
- **The acceptance criterion is output parity, not timing parity** *(amended 2026-08-20, same day: the original text set the ported gates a hard ceiling of ~104 ms — the summed single-sample cost of the three unconditional checks — and that was wrong in kind. It imported "do not regress" as a requirement without asking who pays.)* **Who pays is the pre-push hook, and almost nobody else.** On a push touching `server/` or `client/` the hook runs `cargo test` or the gdUnit4 suite — minutes — so a few hundred milliseconds is invisible. On a governance-only push the whole hook is about a second. No human and no loop consumes these often enough for 100 ms versus 400 ms to register. **So a ported check must produce the same output and the same exit code as the script it replaces; it is not required to be as fast.**
|
||||
- **The acceptance criterion is output parity, not timing parity** *(amended 2026-08-20, same day: the original text set the ported gates a hard ceiling of ~104 ms — the summed single-sample cost of the three unconditional checks — and that was wrong in kind. It imported "do not regress" as a requirement without asking who pays.)* **Who pays is the pre-push hook, and almost nobody else.** On a push touching `server/` or `client/` the hook runs `cargo test` or the gdUnit4 suite — minutes — so a few hundred milliseconds is invisible. On a governance-only push the whole hook is about a second. No human and no loop consumes these often enough for 100 ms versus 400 ms to register. **So a ported check must carry the same information and the same exit code as the script it replaces; it is not required to be as fast.**
|
||||
|
||||
*Sharpened 2026-08-31 while porting the first gate (T-1262): "the same output" cannot mean byte-identical, and discovering that late would have forced a bad choice under time pressure.* The streaming model above puts the verdict on **stderr**; the scripts being replaced write their success line to **stdout**. The two cannot be byte-identical on the same stream without either abandoning streaming or special-casing every ported gate. **What parity means, concretely, and it is stronger than bytes where it counts:** (a) **exit codes match exactly** — that is what the hook gates on and the only part a caller can act on; (b) **no fact is lost** — every version number, path and reason the old message carried appears in the new one, because a migration that quietly drops a detail makes the failure harder to fix than it was before; (c) **failures name a remedy** as a structured field, which the old scripts only did in prose. In practice the success line comes out byte-identical in text mode anyway; only the stream differs.
|
||||
**The budget that replaces it is a ceiling with headroom, not a ratchet:** a `reach` invocation should stay **under ~500 ms** *(raised from 250 ms, 2026-08-23 — a ceiling is not a typical, and most invocations sit far below it; the tighter number was buying discipline that the import-graph assertion enforces better anyway)*. Stay smart about what gets loaded; stop worrying about tightness. That is loose enough that pydantic, a subprocess, or a DB open are all affordable, and tight enough that nobody imports scipy at module level. **Lazy registration stays mandatory**, justified by the real threat rather than by parity: an eager entrypoint would pay ~460 ms of numpy + scipy + PIL + pydantic before executing a line of its own, and would grow every time a domain was added.
|
||||
- **The `systems.db` stamp survives the move or the move does not land.** `tooling/generator_sources.py` SHAs the concatenated bytes of the generator's sources **sorted by path**, so renaming a file changes the stamp even when its content is byte-identical. The generator relocation must therefore land as **one commit** — registry paths updated, `make regen-db` run, stamp verified — never split across pushes, or the pre-push gate rejects an intermediate state that is in fact correct.
|
||||
- **Old paths are retired through a deprecation window, not deleted under the callers.** 160 markdown files under `.claude/` and `docs/`, 84 make targets, the pre-push hook and the skills all name `tooling/…` paths. Each retired path leaves a shim that prints the new command and exits non-zero — the failure contract applied to the migration itself — before the shims are removed.
|
||||
|
||||
@@ -1,18 +1,30 @@
|
||||
"""Data shapes for the `check` domain.
|
||||
|
||||
Stdlib dataclass for now. T-1262 converts this to pydantic as part of making
|
||||
this domain the reference implementation — pydantic is available to every
|
||||
domain since the D-263 budget amendment dropped the gate-path carve-out.
|
||||
pydantic, as every domain's `schemas.py` should be (D-263). The earlier stdlib
|
||||
dataclass here was a carve-out justified by a timing-parity budget that D-263
|
||||
withdrew on 2026-08-20 — and a reference implementation carrying a footnote is a
|
||||
worse reference than one that is simply normal.
|
||||
|
||||
Shapes are frozen. A result object is a statement about what was found, and
|
||||
nothing downstream should be able to edit the finding on its way to being
|
||||
reported.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VersionCheck:
|
||||
"""The outcome of comparing project.yaml against client/project.godot."""
|
||||
class VersionCheck(BaseModel):
|
||||
"""The outcome of comparing project.yaml against client/project.godot.
|
||||
|
||||
`problem` and `ok` are deliberately separate. A check can fail because the
|
||||
versions disagree (`ok=False`, both versions known) or because it could not
|
||||
read them at all (`problem` set) — and those want different messages, since
|
||||
only the first has a remedy the caller can act on.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
ok: bool
|
||||
yaml_version: str | None = None
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
#!/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
|
||||
|
||||
|
||||
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}"
|
||||
)
|
||||
|
||||
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")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user