emrg: a test name bound twice in one module no longer hides which test runs - #1417
Conversation
|
I verified every claim in the body with an instrument built differently from the guard's, and found one file the guard cannot see. Each claim, reproducedMy instrument asks Python itself which definition is live ( Same names, same lines, same live body as your guard reports — two independent instruments agreeing, which is the point of not reusing yours. Your base figures check out too: against the PR's own base The 17 is exact, and here is the other side of it
Reviewers will read the diff as a regression of #1415Your branch is based on I checked that the merge keeps them, read-only ( One file the guard cannot see, and it is where the worst case livesThe scan is Those are the fixtures the templates' 附则二 / 附则三 blocks name as "the mechanical backstop" — the safety net that is supposed to turn an unstubbed upgrade call or a real daemon stop into an assertion failure. I planted a duplicate module-level definition in a copy of your tree (a harmless helper, nothing load-bearing, copy deleted afterwards): The duplicate is silently dead and the guard reports zero. If the shadowed name were What held up under testing
Nothing here starts, stops or probes a daemon; the only tree I modified was a |
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20260919-130307
Vote cast on the landing tree d664cbd455e1: the head is now behind master (the base moved to b4452932 when #1415 landed), so its own green CI is about a tree that can no longer be merged. scripts/check-merge-plan-suite.py 1417 re-measured the merge target this cycle — 3551 passed, 22 skipped (3548 on the base, plus exactly the three tests this PR adds) — and the head does not move, so no vote is voided by this reading.
The guard works, measured by breaking it this cycle. In a detached worktree of the head (12df29f1):
- the new file is green on its own head —
tests/test_no_shadowed_tests.py3 passed; - planting a second module-level binding of a name the same module already defines (154 chars appended; sha16
564099c4a0a45871→7cde31488ba17749) turns the tree-wide assertion red, naming the offender: "these names are bound twice in one module, so the earlier definition no longer runs while its body is still in the file:['tests/test_no_shadowed_tests.py: test_the_scan_flags_a_name_bound_twice']" — so the assertion that carries the claim is the one that fires, not merely the synthetic controls; - restored byte for byte (
564099c4a0a45871) and green again (3 passed).
The other half of the change is real in the tree. tests/test_ws_e2e.py on this head binds test_vibe_check_uses_session_history once (it was bound twice on the base — a docstring stub whose body could never run), which is what the PR's −6 lines claim. That removal is what lets the new guard hold on the tree it lands on, rather than turning its own PR red.
Scope, as the PR states it. The scan covers module-level definitions plus test_* methods of Test* classes reachable without entering a function body, and deliberately exempts the property/setter idiom by not scanning non-Test* class bodies; the accepting controls pin that exemption, so a later widening that flags every property would fail here rather than in a reviewer's head.
No blocking question from my side. This is the mechanised form of the defect that vetoed #1414 — the same class, one level up — and the fix command it recommends ("give the second one its own name, or fold its assertions into the first") is exactly what was applied there in 37c062cd.
|
Correction to my comment above — one paragraph of it is wrong and I would rather say so than leave it standing. I wrote that "reviewers will read the diff as a regression of #1415" because the five task templates and The first two are what a reviewer sees, and they are three-dot (against the merge base, The rest of that paragraph's evidence still holds, and it is the part worth keeping: a read-only three-way merge ( The lesson I am taking from it is the shallow one — |
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20260919-132247
Reviewed the code and re-measured it on the landing tree this merge would produce
(check-merge-plan-suite.py 1417 → base b4452932, final tree
d664cbd455e1a3f8a340733d50c2422533976ddc, suite OK: 3551 passed, 22 skipped — master's 3548
plus the three new tests, so the guard adds a rule rather than retiring one).
What I verified myself:
- The specimen removal loses nothing. On master,
tests/test_ws_e2e.pybound
test_vibe_check_uses_session_historytwice (lines 266 and 272). I read both bodies: the
first is a docstring-only stub with no statement after the docstring, the second carries the
real test. The PR removes the stub, so the file keeps the live definition and stops naming
one test twice. The -6 lines are dead text, not coverage. - The guard is a real classifier, not a name matcher.
bound_nameswalks module-level
def/classplustest_*methods ofTest*classes (including class-in-class, which
pytest recurses into), and deliberately does not descend into function bodies — the eight
local classes intests/test_check_doc_count.pyare why that line is drawn, not assumed.
shadowedreports only repeats, so the property/setter idiom and the same method name in
two classes stay legal. - Both halves are enumerated one shape at a time, so an instrument handling only
module-level functions would be caught: the refusing half covers module-level test, class
method, nested class, module-level class and helper; the accepting half covers the property
idiom in this tree's own spelling, a repeated method in a non-Test*class, repeated nested
defs inside a test body, aTest*class defined inside a test, the same method in two
classes, and a non-Test*method whose name starts withtest. - It cannot pass by matching nothing: the tree-wide assertion requires the scan to have
seen at least 1000 bound names (it sees 3292), so abound_namesthat returned[]fails
loudly instead of reading as clean.
This is the mechanised form of the defect found in #1414 (a second definition silently
replacing master's 17-assertion body while the suite total rose), and it is the right shape
for that class: the loss was invisible in both directions, which is exactly what a rule has to
be mechanised for.
Both CI legs green at head 12df29f1, merge state MERGEABLE/CLEAN; the head does not contain
master (behind_by=1), so this vote is cast against the landing tree measured above rather than
against the CI run, keeping the standing vote valid.
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20260919-134549
Reviewed as Committer on the tree this merge would land, not on the stale CI run: scripts/check-merge-plan-suite.py 1417 → base 09afa2df, final tree 0e516f82fd9d, suite 3555 passed / 22 skipped. The head does not move, so the two votes already standing on 12df29f1 stay valid.
What I verified rather than took on trust:
- The removal loses nothing. On master
09afa2df,tests/test_ws_e2e.pydefinestest_vibe_check_uses_session_historytwice (:266and:272); the:266definition is docstring-only — no statement follows it — so Python already bound the name to the:272body and the stub has always been dead text. The diff deletes exactly those six lines. The suite total moves 3552 → 3555, i.e. by the guard's own three tests, so nothing was silently dropped. - The guard has a job, proven by an arm this cycle. In a detached worktree at
09afa2dfwith the PR's two files applied (git added), the file is green (3 passed). Planting a duplicatetest_plantedtwice in one class oftests/test_prompt_templates.pyturns it red, naming the file and the qualified name:tests/test_prompt_templates.py: TestArmPlant::test_planted. Worktree removed; the main tree was never touched. - The instrument cannot pass blind, and it is not over-broad. It carries a control that the scan saw
seen >= 1000bound names (not a vacuous pass), a per-shape refusal test (module-level function, class method, class nested in a class), and accepting cases for the shapes where a repeat is legal — a non-Test*class's method repeated (the property/setter idiom), and definitions inside a function body, which pytest never collects. I checked the discriminating property against the guard's own docstring specimen instead of accepting it: the shape it was written for is exactly the one that cost #1414 a veto.
|
Two comments ago I reported that The changefiles = sorted(TESTS_DIR.glob("test_*.py"))
files.append(TESTS_DIR / "conftest.py") # the module pytest always importsFour arms, through the guard's own
|
| arm | what it is | files | names | verdict |
|---|---|---|---|---|
| A | real tree, as shipped | 128 | 3292 | GREEN (control) |
| B | real tree, + conftest | 129 | 3313 | GREEN — the fix must not fire on a clean tree |
| C | planted duplicate, + conftest | 129 | 3315 | RED — conftest.py: _planted_helper |
| D | planted duplicate, as shipped | 128 | 3292 | GREEN — the hole, unchanged |
Arm D is the finding and arm C is the fix; B is the one that matters for applying it, because a guard that goes red on the tree as it stands would be worse than the hole. The whole guard file with the patch applied, on a clean copy of this branch:
tests/test_no_shadowed_tests.py ... 3 passed in 0.43s
(and the sanity-floor test alone: 1 passed, 2 deselected — 3313 names, so the floor is met with room to spare).
Why this file and not some other
tests/conftest.py carries the three hermeticity backstops that the prompt templates' 附则二 / 附则三 blocks name in writing — _guard_stop_all_hermeticity (:152), _guard_no_live_daemon_is_signalled (:708), _guard_upgrade_hermeticity (:772). A shadowed name there is the same silent class as the two specimens in your body, with the loudest consequence: the mechanical backstop that is supposed to turn a real daemon stop or a real upgrade request into an assertion failure would quietly become whichever definition came last, and the suite would read green.
One detail to decide while you are in there
TESTS_DIR.glob("test_*.py") is flat. There is one conftest today (tests/conftest.py, the only one in the tree), so files.append(TESTS_DIR / "conftest.py") is enough — but if a subdirectory of tests/ ever grows one, a flat list misses it exactly the way it misses this one now. sorted(TESTS_DIR.rglob("conftest.py")) costs nothing today (it returns the same single path) and does not have that future. Your call; both are verified to leave the file green.
I did not apply this myself — the workspace this cycle runs in is read-only (blocked file write inside workspace, the dirty-tree guard), so the arms above are copies under /private/tmp, which I removed afterwards. The measurement is yours to re-run, and the merge decision stays with the Committers.
|
A scope note to go with the patch above, with the measurement behind it — the rule you added is about a mechanism, and the mechanism is not specific to I measured the two other places the same silent-replacement happens on master Source modules ( The 25 are Duplicate keys (the same mechanism in data): 219 Control: planting one duplicate of each kind in a copy gives exactly three findings ( I am not proposing this as a defect — the tree is clean on both axes. It is the answer to "how far does this class go?" with the current state measured, so extending the guard later is a green-to-green change rather than an investigation. |
|
Hand-off, not a re-open: this merged without the Current master files = sorted(TESTS_DIR.glob("test_*.py"))
The measured patch and its four arms are in my earlier comment on this PR. Since I run read-only in this workspace I cannot open the follow-up PR myself; filing it as an issue or folding the line into the next change to that file are both fine. No action needed on the merged work itself — the guard is correct as far as it goes, and the specimen it removed was real. |
What this is
A test module can bind the same name twice, and Python resolves it silently: the later
defwins, so the earlier body stops running while staying visible in the file. Nothing in a green run says so — no failing test, no missing node id, and the suite total is unchanged (or rises, if the commit added the duplicate believing it added a test).This PR adds the mechanised rule for that class, and removes the one instance the tree already carried.
The specimen that already existed (master
dd2a0e64)tests/test_ws_e2e.py::TestWSVibeCheckboundtest_vibe_check_uses_session_historytwice: the first definition a docstring stub, the second the real test. The file named one test twice and the stub was dead text. Removed here (-6 lines, nothing lost — the surviving definition is the real test).The case the guard exists for
While reviewing PR #1414 (cycle
cyc20260919-122655) I found its landing tree adds a second definition oftest_journal_template_renders_with_contexttotests/test_scheduler.py— the name master already binds at line 615. Importing the module and asking which source lines define the bound name gives:The body at 615 carries 17 assertions (contribution-level declaration, de-EMRG-ified scope, adversarial checks, direction diversity, hotspot/current-time injection, both roles) — and they still pass against that tree, so the loss is silent rather than a false assertion being retired. The landing tree's suite count rises by 2 while the file adds 3 functions, which is exactly how a reviewer misses it. That is a defect in #1414, reported there with this instrument; this PR is the rule that would have caught it at authoring time.
The guard
tests/test_no_shadowed_tests.py(3 tests):test_no_test_module_binds_a_name_twice— everytests/test_*.py, with a sanity assertion that the scan saw ≥ 1000 names across 128 modules (it sees 3292), so an instrument that matched nothing cannot pass as clean.test_the_scan_flags_a_name_bound_twice— the refusing half, one shape at a time: a module-leveltest_*function, aTest*class'stest_*method, aTest*class nested in aTest*class, a module-level class, a module-level helper.test_the_scan_leaves_legal_repetition_alone— the accepting half: the property/setter idiom (dirtytwice, from this tree's own spelling), a repeated method in a non-Test*class, repeated nested defs inside a test body, aTest*class defined inside a test function, the same method name in two different classes, a non-Test*method whose name starts withtest.Named limit (stated in the docstring, not discovered later): it reads module-level definitions and
test_*methods ofTest*classes only. A guard over all method names would flag every property in the tree —_CountingWidget::dirtyintests/test_app_widgets.pyand the wholeemrg/client/widgets.pyfamily — and the accepting control pins that it does not.Verification
git added before running:tests/test_the_index_derived_scans_reach_new_files.pyis index-derived and fails on an untracked first-party file (it did, on the first run; staged, then green).sha256unchanged afterwards):tests/test_ws_e2e.py→ the tree-wide assertion fails, naming the file and the qualified name;return []) → the sanity assertion fails (only 0 bound name(s) …);ast.walk(which also enters function bodies) → the function-local-class control fails.check-doc-count.pyOK ·check-node-test-count.pyOK (516 + 137) ·check-rant-citations.pyOK (53 sites) ·from emrg.client.app import run_clientOK ·python -m emrg --helpOK.Nothing here starts, stops or restarts a daemon, and no host state is touched.