Constant-rename equivalence check: prove a rename changed no behaviour - #585
Merged
Merged
Conversation
WilfordGrimley
force-pushed
the
feat/constant-rename-equivalence-check
branch
from
July 29, 2026 17:41
f5d7855 to
3837d80
Compare
WilfordGrimley
added a commit
that referenced
this pull request
Jul 29, 2026
…sion Owner rulings on PR #585's two open questions, 2026-07-29. Q1 - `references` as a required branch-protection check: yes in principle, but it could NOT be required as configured. GitHub leaves a required status check that never RUNS in `pending` forever rather than treating it as passed, so the workflow's `paths: "**/*.py"` filter would have made every docs-only and frontend-only PR permanently unmergeable the moment an owner marked it required. This repo merges plenty of both. 1. Dropped the `paths:` filter from `on: pull_request` for the WHOLE workflow, not just the `references` job - leaving it on the siblings would only relocate the same trap for whoever marks the next one required. The reasoning is written into the workflow header in a block that says not to add one back. 2. Added `--changed-since REV` so the no-op path is cheap and explicit: when no *.py changed it prints "nothing to check - no *.py file changed between X and Y" and exits before any parsing. Measured 63ms, versus 4.7s for the full scan. 3. Made the gate incapable of wedging the check it protects: an unresolvable REV (shallow clone, fork without base history) degrades to running the full check with a printed note, never to an error. The gate is an optimisation, never a correctness input. 4. A clean run now reports what it DID - "123 reference(s) to constants matching /.../ all resolve at HEAD; 373 modules scanned" - and a tree with no matching references says "nothing to check" explicitly. "clean" alone is indistinguishable from a check that silently stopped finding anything. Q2 - default pattern breadth: KEEP IT BROAD (owner: "i want the CI to do as much troubleshooting for us as possible. we can cull it, and slice it up to specific scopes later"). No code change. Recorded in the doc as a deliberate ruling with the one-line narrowing path spelled out, so a future reader does not "tidy" the generic families out of it. Also, prompted by the concurrent audit that found BOTH roster tethers in docs_lint.py using a non-recursive `src_dir.glob("*.py")` - which hid `scryfall-tagger-v1` in management/commands/ from them - audited this script for the same mistake. It does not have it: the scan is `git ls-tree -r` over the whole tree at the revision, with no directory filtering. Made that deliberate rather than incidental: - read_python_tree() now documents why recursion AND tests/ inclusion are both load-bearing here. tests/ is exactly where these two tools diverge: the tethers ask "is this production value documented?", so fixture declarations are noise; this tool asks "does the reference still resolve?", and four of the six modules broken by the #567/#568 merge were test modules. Excluding them would have hidden two thirds of the incident. - New TestScanCoverage pins a nested management/commands/ module and a tests/ module are both scanned, plus a derivation guard against the real tree - without it every real-repo assertion could pass vacuously if the listing ever stopped recursing. Branch protection itself is NOT touched - that is governed by docs/infrastructure.md and is an owner action in the GitHub UI. Verified: 45 unit tests pass (was 37); docs-lint --strict clean; pre-commit --all-files clean; no-op path 63ms, full scan 4.7s. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013NhYmT1PxCcyemA16dFDxN
WilfordGrimley
force-pushed
the
feat/constant-rename-equivalence-check
branch
from
July 30, 2026 08:48
c67f864 to
825939c
Compare
WilfordGrimley
added a commit
that referenced
this pull request
Jul 30, 2026
…sion Owner rulings on PR #585's two open questions, 2026-07-29. Q1 - `references` as a required branch-protection check: yes in principle, but it could NOT be required as configured. GitHub leaves a required status check that never RUNS in `pending` forever rather than treating it as passed, so the workflow's `paths: "**/*.py"` filter would have made every docs-only and frontend-only PR permanently unmergeable the moment an owner marked it required. This repo merges plenty of both. 1. Dropped the `paths:` filter from `on: pull_request` for the WHOLE workflow, not just the `references` job - leaving it on the siblings would only relocate the same trap for whoever marks the next one required. The reasoning is written into the workflow header in a block that says not to add one back. 2. Added `--changed-since REV` so the no-op path is cheap and explicit: when no *.py changed it prints "nothing to check - no *.py file changed between X and Y" and exits before any parsing. Measured 63ms, versus 4.7s for the full scan. 3. Made the gate incapable of wedging the check it protects: an unresolvable REV (shallow clone, fork without base history) degrades to running the full check with a printed note, never to an error. The gate is an optimisation, never a correctness input. 4. A clean run now reports what it DID - "123 reference(s) to constants matching /.../ all resolve at HEAD; 373 modules scanned" - and a tree with no matching references says "nothing to check" explicitly. "clean" alone is indistinguishable from a check that silently stopped finding anything. Q2 - default pattern breadth: KEEP IT BROAD (owner: "i want the CI to do as much troubleshooting for us as possible. we can cull it, and slice it up to specific scopes later"). No code change. Recorded in the doc as a deliberate ruling with the one-line narrowing path spelled out, so a future reader does not "tidy" the generic families out of it. Also, prompted by the concurrent audit that found BOTH roster tethers in docs_lint.py using a non-recursive `src_dir.glob("*.py")` - which hid `scryfall-tagger-v1` in management/commands/ from them - audited this script for the same mistake. It does not have it: the scan is `git ls-tree -r` over the whole tree at the revision, with no directory filtering. Made that deliberate rather than incidental: - read_python_tree() now documents why recursion AND tests/ inclusion are both load-bearing here. tests/ is exactly where these two tools diverge: the tethers ask "is this production value documented?", so fixture declarations are noise; this tool asks "does the reference still resolve?", and four of the six modules broken by the #567/#568 merge were test modules. Excluding them would have hidden two thirds of the incident. - New TestScanCoverage pins a nested management/commands/ module and a tests/ module are both scanned, plus a derivation guard against the real tree - without it every real-repo assertion could pass vacuously if the listing ever stopped recursing. Branch protection itself is NOT touched - that is governed by docs/infrastructure.md and is an owner action in the GitHub UI. Verified: 45 unit tests pass (was 37); docs-lint --strict clean; pre-commit --all-files clean; no-op path 63ms, full scan 4.7s. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013NhYmT1PxCcyemA16dFDxN
A throwaway script written for PR #567 caught a real production-breaking bug that git, the 3,036-test backend suite and the committed lint chain all passed over. It lived only in a session scratchpad. This commits it. The incident (2026-07-29): #567 renamed SLOW_PATH_TO_REVIEW_REASON to SLOW_PATH_TO_REVIEW_SKIP_REASON; #568 concurrently added BRAND NEW code in catalog_stats.py importing and using the OLD name. Git auto-merged with no conflict - #568 only added lines, #567 only touched a nearby docstring, so the rename and the new references never textually collided. The merged result would have raised ImportError at module-import time in a module reached by the catalog-stats view and the hourly warm_catalog_stats job. Thirteen reference sites, six modules. The generalisation: a textual merge cannot see a name graph. 1. .github/scripts/constant_rename_equivalence.py - two checks. --check-references (one revision, no judgment calls) resolves every matching constant reference: a `from x import NAME` where x declares no NAME is an ImportError, unconditionally. The equivalence check (two revisions) normalises each module at both - inlining matching constants with the map built across the WHOLE tree so cross-module imports resolve, deleting those declarations/__all__ entries/imports, deleting docstrings, constant-folding f-strings and string concatenation - then compares ast.dump() trees. 2. Generalised past skip reasons: --pattern is a regex searched against ALL-CAPS module-level names, defaulting to the families this repo actually refactors (SKIP_REASON|ANONYMOUS_ID|_VERSION|_WEIGHT| _THRESHOLD|_PREFIX|_REASON). --pattern '.' inlines everything. 3. Revisions are arguments; default is HEAD vs its merge-base with origin/master, with fallbacks so it works in a worktree with no remote. --paths narrows, --all widens. 4. Failures name the module, the AST node path, and both sides unparsed back to source - not "these trees differ" on a 2,000-line file. 5. Scope includes modules the diff never touched. A changed-files-only scope would have missed catalog_stats.py, which is the whole point: in the merge that broke, the rename half touched only local_calculate_verdicts.py. CI wiring is deliberately two jobs with different trigger characters. `references` is an unconditional invariant that runs on every Python PR and can genuinely fail (same posture as protected-core-license). `equivalence` gates ITSELF: with no renamed or removed constant in the diff it prints "nothing to prove" and exits 0, rather than being a job that runs always and passes always. Tests follow test_docs_lint.py's conventions: fixture git repos, every rule with a passing AND a failing case, including genuine behaviour changes (a renamed constant whose value also moved; a comparison operand change alongside a rename; a frozenset membership change), plus a guard that a rename OUTSIDE the pattern is not normalised away. Two real-repo tests pin the incident. Two normaliser gaps were found and closed while running it against real history, both real: - matching had to widen to any name CONTAINING the pattern, because LANDS_PHASH_SKIP_REASON_PREFIX ends in _PREFIX, not _REASON; - the f-string folder has to fold a SINGLE interpolated constant back into the surrounding literal, not just whole f-strings, because `f"phash-{r}"` and `f"{PREFIX}{r}"` are the same value in different shapes. Without this the real #567 commit reported a false difference. Nothing was weakened to make the repo pass: the one remaining difference reported against #567 is docs_lint.py, which that same PR legitimately grew by 129 lines of new lint code. Documented in docs/reference/constant-rename-equivalence.md (indexed from docs/README.md and docs/MANIFEST.md), cross-referenced from docs/reference/skip-reasons.md, promoted into docs/lessons.md per that file's own triage ritual, and added to CLAUDE.md's task-end checks. Verified: 37 unit tests pass; docs-lint --strict clean; pre-commit clean; the tool reproduces the incident (6 ImportError findings plus 6 tree differences on a reconstructed auto-merge) and detects a rename-plus-behaviour-change (7 modules, 'to-review' -> 'to-review-v2'). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013NhYmT1PxCcyemA16dFDxN
…sion Owner rulings on PR #585's two open questions, 2026-07-29. Q1 - `references` as a required branch-protection check: yes in principle, but it could NOT be required as configured. GitHub leaves a required status check that never RUNS in `pending` forever rather than treating it as passed, so the workflow's `paths: "**/*.py"` filter would have made every docs-only and frontend-only PR permanently unmergeable the moment an owner marked it required. This repo merges plenty of both. 1. Dropped the `paths:` filter from `on: pull_request` for the WHOLE workflow, not just the `references` job - leaving it on the siblings would only relocate the same trap for whoever marks the next one required. The reasoning is written into the workflow header in a block that says not to add one back. 2. Added `--changed-since REV` so the no-op path is cheap and explicit: when no *.py changed it prints "nothing to check - no *.py file changed between X and Y" and exits before any parsing. Measured 63ms, versus 4.7s for the full scan. 3. Made the gate incapable of wedging the check it protects: an unresolvable REV (shallow clone, fork without base history) degrades to running the full check with a printed note, never to an error. The gate is an optimisation, never a correctness input. 4. A clean run now reports what it DID - "123 reference(s) to constants matching /.../ all resolve at HEAD; 373 modules scanned" - and a tree with no matching references says "nothing to check" explicitly. "clean" alone is indistinguishable from a check that silently stopped finding anything. Q2 - default pattern breadth: KEEP IT BROAD (owner: "i want the CI to do as much troubleshooting for us as possible. we can cull it, and slice it up to specific scopes later"). No code change. Recorded in the doc as a deliberate ruling with the one-line narrowing path spelled out, so a future reader does not "tidy" the generic families out of it. Also, prompted by the concurrent audit that found BOTH roster tethers in docs_lint.py using a non-recursive `src_dir.glob("*.py")` - which hid `scryfall-tagger-v1` in management/commands/ from them - audited this script for the same mistake. It does not have it: the scan is `git ls-tree -r` over the whole tree at the revision, with no directory filtering. Made that deliberate rather than incidental: - read_python_tree() now documents why recursion AND tests/ inclusion are both load-bearing here. tests/ is exactly where these two tools diverge: the tethers ask "is this production value documented?", so fixture declarations are noise; this tool asks "does the reference still resolve?", and four of the six modules broken by the #567/#568 merge were test modules. Excluding them would have hidden two thirds of the incident. - New TestScanCoverage pins a nested management/commands/ module and a tests/ module are both scanned, plus a derivation guard against the real tree - without it every real-repo assertion could pass vacuously if the listing ever stopped recursing. Branch protection itself is NOT touched - that is governed by docs/infrastructure.md and is an owner action in the GitHub UI. Verified: 45 unit tests pass (was 37); docs-lint --strict clean; pre-commit --all-files clean; no-op path 63ms, full scan 4.7s. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013NhYmT1PxCcyemA16dFDxN
WilfordGrimley
force-pushed
the
feat/constant-rename-equivalence-check
branch
from
July 30, 2026 09:03
825939c to
a40e956
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
A throwaway script written for PR #567 caught a real production-breaking bug that git, the 3,036-test backend suite, and the committed lint chain all passed over. It lived only in a session scratchpad. This commits it somewhere it will be used rather than rediscovered.
The incident (2026-07-29). PR #567 renamed
SLOW_PATH_TO_REVIEW_REASONtoSLOW_PATH_TO_REVIEW_SKIP_REASON. Concurrently, PR #568 added brand new code incatalog_stats.pyimporting and using the old name. Git auto-merged with no conflict — #568 only ADDED lines, #567 only touched a nearby docstring, so the rename and the new references never textually collided. The merged result would have raised, at module-import time:in a module reached by the catalog-stats view and the hourly
warm_catalog_statsjob. Thirteen reference sites across six modules.The generalisation: a textual merge cannot see a name graph.
What ships
.github/scripts/constant_rename_equivalence.py— stdlib only, reads source viagit cat-file, never imports or executes it. Two checks:--check-references(one revision, no judgment calls): everyfrom x import NAMEmatching the pattern must actually be declared inx; every matching ALL-CAPS name read must resolve somewhere. A violation is anImportError/NameError, unconditionally.__all__entries / imports, delete docstrings, constant-fold f-strings and string+— then compareast.dump()trees.Generalised past skip reasons:
--patternis a regex searched against ALL-CAPS module-level names, defaulting toSKIP_REASON|ANONYMOUS_ID|_VERSION|_WEIGHT|_THRESHOLD|_PREFIX|_REASON. Revisions are arguments (defaultHEADvs merge-base withorigin/master).--pathsnarrows,--allwidens. Failures name the module, the AST node path, and both sides unparsed back to source.Scope deliberately includes modules the diff never touched — a changed-files-only scope would have missed
catalog_stats.py, which is the whole point.CI wiring — two jobs, different trigger characters
referencesis an unconditional invariant on every Python PR that can genuinely fail (same posture asprotected-core-license).equivalencegates itself: with no renamed or removed constant in the diff it prints "nothing to prove" and exits 0, rather than being a job that runs always and passes always.On a
pull_requesteventactions/checkouthands you the merge result — exactly the revision the #567/#568 bug existed in and neither contributing branch did.Can it fail?
Reconstructed the real auto-merge (master @
9952865b+ only #567's rename half):--check-references: 6 findings, e.g.catalog_stats.py::from cardpicker.local_calculate_verdicts import SLOW_PATH_TO_REVIEW_REASON — ... declares no SLOW_PATH_TO_REVIEW_REASON. This is an ImportError at module-import time.'to-review'became an unresolvableSLOW_PATH_TO_REVIEW_REASON.Rename plus a genuine behaviour change (
"to-review"→"to-review-v2"on top of the real #567 commit): 7 modules flagged, each pinpointing the value change. Nothing else in the repo catches that one — the rename is valid and the tests use the constant.Two real normaliser gaps found and closed
Both while running against real history, neither by weakening anything:
LANDS_PHASH_SKIP_REASON_PREFIXends in_PREFIX, not_REASON;f"phash-{r}"andf"{PREFIX}{r}"are the same value in different shapes. Without this the real Adopt a *_SKIP_REASON declaration convention; tether the roster to a doc #567 commit reported a false difference.The one remaining difference reported against #567 is
docs_lint.py, which that same PR legitimately grew by 129 lines of new lint code. Correctly reported, not excluded.Tests
.github/scripts/tests/test_constant_rename_equivalence.py— 37 tests followingtest_docs_lint.py's conventions. Fixture git repos; every rule has a passing and a failing case, including genuine behaviour changes and a guard that a rename outside the pattern is not normalised away. Two real-repo tests pin the incident.Docs
docs/reference/constant-rename-equivalence.md(where it lives, when to run it, the incident, and its honest limits), indexed fromdocs/README.md+docs/MANIFEST.md, cross-referenced fromdocs/reference/skip-reasons.md, promoted intodocs/lessons.mdper that file's own triage ritual, and added toCLAUDE.md's task-end checks.Verification
37 unit tests pass ·
docs_lint.py --strictclean ·test_docs_lint.pyOK ·pre-commit run --all-filesclean.🤖 Generated with Claude Code
https://claude.ai/code/session_013NhYmT1PxCcyemA16dFDxN
Update — owner rulings applied (2026-07-29)
Q1:
referencesas a required branch-protection check — yes in principle, but it could not be required as configured. GitHub leaves a required status check that never runs inpendingforever rather than treating it as passed, so the workflow'spaths: "**/*.py"filter would have made every docs-only or frontend-only PR permanently unmergeable the moment it was marked required.paths:fromon: pull_requestfor the whole workflow, not just that job — leaving it on the siblings would only relocate the trap for whoever marks the next one required. The reasoning is in the workflow header, in a block that says not to add one back.--changed-since REV: when no*.pychanged it printsnothing to check — no *.py file changed between X and Yand exits before any parsing. Measured 63ms, versus 4.7s for the full scan.123 reference(s) to constants matching /.../ all resolve at HEAD (05d92ed2); 373 modules scanned."clean" alone is indistinguishable from a check that silently stopped finding anything.The code side is ready. Branch protection was not touched — that is governed by
docs/infrastructure.mdand is an owner action in the GitHub UI (Settings → Branches →master→ Require status checks → addEvery matching constant reference resolves). The other two jobs are equally safe to require now, but only that one was ruled on.Q2: default pattern breadth — keep it broad. No code change. Recorded in the doc as a deliberate ruling, with the one-line narrowing path (
SKIP_REASON|ANONYMOUS_ID) spelled out so a future reader does not "tidy" the generic families out of it.Self-audit against the concurrent
docs_lint.pyfinding. That audit found both roster tethers using a non-recursivesrc_dir.glob("*.py"), hidingscryfall-tagger-v1inmanagement/commands/. This script does not share the defect — it scans viagit ls-tree -rover the whole tree with no directory filtering. Made that deliberate rather than incidental:read_python_tree()now documents why recursion andtests/inclusion are both load-bearing, and newTestScanCoveragepins a nestedmanagement/commands/module and atests/module are both scanned, plus a derivation guard against the real tree.tests/is exactly where the two tools diverge: the tethers ask "is this production value documented?", so fixture declarations are noise; this tool asks "does the reference still resolve?" — and four of the six modules broken by the #567/#568 merge were test modules. Excluding them would have hidden two thirds of the incident.Tests: 45 (was 37). All 11 checks green.