Skip to content

fix(lint): a test can be CCD-hermetic and still write to the live workspace - #2622

Merged
john-the-dev merged 25 commits into
mainfrom
fix/lint-resolver-stub-arity
Aug 11, 2026
Merged

john-the-dev merged 25 commits into
mainfrom
fix/lint-resolver-stub-arity

Conversation

@sonichi

@sonichi sonichi commented Aug 4, 2026 •

Copy link
Copy Markdown
Owner

Closes #2621.

The gap

lint-hermetic-bridge-tests.py proves a test is CCD-hermetic — that it pins CLAUDE_CONFIG_DIR. A test can pass that check and still write to the live workspace, because the workspace resolver is a separate axis.

The specific shape: a test stubs the resolver with a zero-arg lambda.

outbox_log._resolve_workspace = lambda: tmp        # zero-arg

25 production call sites pass an argument (migrate=False, or a repo path). A zero-arg stub raises TypeError at every one of them. What happens next depends entirely on the caller's exception scope, and the two directions are opposite:

caller's scope result
narrow except ImportError (util_paths._workspace_root()) propagates loudly — a tripwire
broad try: (JsonlFileSink.write()) silently disables the write path — a landmine

The second is the bad one: the redirect the test thought it installed never happens, nothing errors, and the consumer just stops. That is how #2619 / #2620 reached the live workspace.

What this adds

An AST check over tracked tests: an assignment of a Lambda to resolve_workspace or _resolve_workspace (bare name, module attribute, or local) whose parameter list cannot absorb an argument — no *args, no **kwargs, no positional/kw-only params.

lambda *a, **kw: ws passes. lambda migrate=False: ws passes. lambda: ws fails.

One grandfathered entry (tests/telegram-bridge-access.test.py), listed explicitly so it can't grow silently.

Before / after

Both runs below plant the same zero-arg stub into a tracked test, at the parent commit and at HEAD.

Parent (dab9c998) — the stub is invisible:

$ python3 scripts/lint-hermetic-bridge-tests.py ; echo "exit=$?"
lint-hermetic-bridge-tests: ok (64 bridge-importing tests scanned, 59 grandfathered, 0 mitigated)
exit=0

HEAD — same tree, same stub:

$ python3 scripts/lint-hermetic-bridge-tests.py ; echo "exit=$?"
error: tests/outbox-log.test.py:7 — `_resolve_workspace` stubbed with a ZERO-ARG lambda.
  25 production call sites pass `migrate=False` or `repo`; a zero-arg stub raises TypeError
  at each, and behind a broad `except` that DISABLES the write path instead of redirecting it.
  Use `lambda *a, **kw: <ws>`.

lint-hermetic-bridge-tests: FAIL — zero-arg workspace-resolver stub
exit=1

Clean tree at HEAD — no new noise, and the grandfathered entry stays silent:

$ python3 scripts/lint-hermetic-bridge-tests.py ; echo "exit=$?"
lint-hermetic-bridge-tests: ok (64 bridge-importing tests scanned, 59 grandfathered, 0 mitigated;
                                360 scanned for resolver stubs, 1 grandfathered)
exit=0

Tests

New: tests/lint-resolver-stub-arity.test.py — 13 checks, hermetic by construction (it parses source strings; it never touches a workspace). 3 flag-cases, 4 pass-cases, 2 out-of-scope, a line/name assertion, grandfather-list rot checks, and a positive control that fails if the predicate ever stops discriminating.

The full existing lint suite, all green at HEAD:

tests/lint-resolver-stub-arity.test.py      exit=0  resolver-stub arity: all checks passed
tests/lint-hermetic-bridge-tests.test.py    exit=0  PASS — lint-hermetic-bridge-tests
tests/lint-hermetic-discovery-floor.test.py exit=0  a discovery that did not run cannot report `ok`
tests/lint-diff-discovery-failure.test.py   exit=0  no --diff lint can report clean on a failed discovery
tests/cwd-lint.test.py                      exit=0  71 TS files, 103 Python files, 0 violations
tests/lint-class-rules.test.py              exit=0  OK

Mutation-checked: neutering resolver_stub_violations to return out makes the lint exit 0 while the new test exits 1 with six assertions firing, the positive control among them. That is the property that keeps this check from going quietly inert.

What this does NOT cover

Stated plainly, because a lint reporting ok on a file will be read as "this file is safe":

  1. No transitive reach. The polluter that actually reached the live workspace (dm-result-send-dm) contains no resolver token at all — it gets there via dm-result.py -> outbox_log -> resolve_workspace. This check reports clean on that file and is right to; it is not evidence the file is hermetic. The behavioural pinned-workspace diff is the complement, not this.
  2. Arity is not verified against real callers. lambda migrate=False: ws passes here but still breaks a caller passing repo. The check rules out the always-wrong shape, not every wrong one.
  3. Tracked files only — the corpus is git ls-files -- tests/*.py. A brand-new test file is not scanned until it is staged. Fine in CI; worth knowing locally.

Severity ranking by caller exception-scope (the tripwire/landmine split above) is deliberately not implemented here — it needs a caller-side analysis and would make this two concerns. The error message names the consequence; the ranking is a separate change.

Verified no conflict with #2551, the one other open PR touching this file (it removes a grandfather entry at line 117; this adds at 968+): merges clean in both orders.

Stand: Echo Act IV Mini


Why the walk is per-scope and in statement order (moved out of the source at review request)

resolver_stub_violations carried this as a 28-line docstring. It is the reasoning behind the
design, which belongs here rather than in the file.

Patching a resolver across an already-imported tree requires binding the stub to a name first —
from workspace_default import resolve_workspace copies the function into each importer's
namespace, so every holder must be reassigned:

_fake = lambda: tmp
for _mod in list(sys.modules.values()):
    if getattr(_mod, "resolve_workspace", None) is orig:
        _mod.resolve_workspace = _fake       # flagged
workspace_default.resolve_workspace = _fake  # flagged

A check that only inspects the assignment's own value sees neither half, which is why aliases are
tracked at all.

They are tracked per scope and in statement order. The first version collected aliases into
one file-global set via ast.walk, so any _fake = lambda: anywhere in the file condemned every
later resolve_workspace = _fake — including one in a different function whose own _fake absorbs
args, and including assignments written before the offending rebinding:

cross_function_safe             -> [(7, 'resolve_workspace')]
order_safe_then_bad_later       -> [(3, 'resolve_workspace')]
same_scope_bad_then_safe_later  -> [(4, 'resolve_workspace')]

That is the expensive direction for a mandatory gate: a false positive blocks an unrelated safe test
and names the wrong line, leaving the author nothing to act on. The check stays per-file and
syntactic — no cross-module or caller analysis.

_ScopeWalk merges branch bodies with OR, so a name is unsafe if any path can leave it unsafe.
That keeps the check conservative where control flow is unknown without letting an unrelated scope
decide the verdict.

…kspace

`lint-hermetic-bridge-tests.py` proves a test isolates CLAUDE_CONFIG_DIR before
the bridge import. It has zero references to `resolve_workspace`, so workspace
isolation was never in scope — a test can pass every existing check and still
append to `<workspace>/state/outbox.log`. Five did (#2612 #2615 #2618 #2619
#2620), each fixed individually; nothing stopped the sixth.

Adds the one form a per-file static predicate can prove: a stub bound to a
resolver name that cannot absorb the arguments production passes. 25 call sites
in 14 files pass `migrate=False` or `repo`, so a zero-arg `lambda: tmp` raises
TypeError at each — and where the caller sits behind a broad `except`, that
TypeError is swallowed, DISABLING the write path rather than redirecting it.
That is how #2619 stayed green through three review rounds.

Covers BOTH names: `resolve_workspace` and the module-local `_resolve_workspace`
(src/runtime-health.py, src/workspace_lock.py x5). A name-exact sweep misses the
second; that gap cost two independent audits five call sites each.

Grandfathers the single existing hit under the same shrink-never-rot contract as
KNOWN_UNISOLATED, and fails on new ones.

Scope stated in the module docstring rather than implied: this CANNOT see
transitive reach (`test -> dm-result.py -> outbox_log -> resolve_workspace`,
which contains no resolver token at all), nor verify exact caller-arity. The
complement is behavioural — run each test against a pinned throwaway workspace
and diff.

Closes #2621

Stand: Echo Act IV Mini
@sonichi

sonichi commented Aug 4, 2026

Copy link
Copy Markdown
Owner Author

@sonichi flagging you — this is the lint half of #2621, single concern.

The one thing worth your eye rather than a reviewer's: I wrote the scope limits into the PR body and the module docstring on purpose. This check reports clean on dm-result-send-dm — the file that actually polluted the live workspace — because that file contains no resolver token at all and reaches one three hops down. A green run here is not a hermeticity certificate, and I'd rather that be stated in the artifact than learned later.

No merge action needed from me; it's yours as always.

@bassilkhilo-ag2 bassilkhilo-ag2 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified thoroughly, reproducing every claim myself rather than trusting the printed output.

Before/after, reproduced exactly. Planted the same zero-arg stub (outbox_log._resolve_workspace = lambda: "/tmp/fake_ws") into the real tracked file tests/outbox-log.test.py at line 7:

  • At parent (dab9c998): lint-hermetic-bridge-tests: ok (64 bridge-importing tests scanned, 59 grandfathered, 0 mitigated), exit=0 — invisible, matches the PR's claim.
  • At head (14ec1139), same planted stub: error: tests/outbox-log.test.py:7 — _resolve_workspace stubbed with a ZERO-ARG lambda..., FAIL, exit=1 — matches verbatim, including the file:line.
  • Clean tree at head: ok (... 360 scanned for resolver stubs, 1 grandfathered), exit=0 — matches.

Mutation check, reproduced independently (not just read as claimed). Inserted return out at the top of resolver_stub_violations() to neuter it, then ran both the lint and the new test:

  • Lint: still reports ok, exit=0 (silently passes — the neutering worked as a mutation).
  • tests/lint-resolver-stub-arity.test.py: FAILED (6), including the positive control (POSITIVE CONTROL — the bad form really is detected — if this passes while the FLAG cases pass, the predicate is inert). Confirms the new test suite is not vacuous — it genuinely fails when the detection logic is disabled. Restored the file after.

New test suite + existing lint suite, all green at head: lint-resolver-stub-arity, lint-hermetic-bridge-tests, lint-hermetic-discovery-floor, lint-diff-discovery-failure, cwd-lint (71 TS / 103 Python, 0 violations), lint-class-rules (31/31) — all rc=0, matching the PR's table.

One thing I found that the PR doesn't mention — non-blocking, worth a follow-up: the new scan_resolver_stubs() calls ast.parse((REPO / rel).read_text(errors="ignore")) without a filename= argument (scripts/lint-hermetic-bridge-tests.py:1049). This means when a scanned file has its own unrelated syntax issue, Python's warning prints as <unknown>:50: SyntaxWarning: ... instead of naming the file. I traced the actual source: tests/morning-briefing-pending-single-source.test.py has a genuine pre-existing invalid escape sequence (\w not in a raw string) — unrelated to this PR, but this PR's new full-tree scan is what surfaces it as noise on every lint run now, and the <unknown> filename makes it non-actionable. Doesn't affect correctness of this PR (exit codes and detection are exactly right, verified above) — just a small "would be nice to fix" for whoever touches this scan again.

Scope and honesty of the "what this does NOT cover" section checks out: correctly notes the original polluter (dm-result-send-dm) has no resolver token at all and this lint reports clean on it for the right reason (not evidence of hermeticity, just out of scope for this specific check) — an accurate, non-overclaiming self-assessment.

No hardcoded paths in the diff. No blockers. Approving.

@sonichi sonichi left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified the check by running it, not by reading it. The design is right and the "what this cannot do" section is exactly the bluntness I asked for. One blocker, reproduced.

Blocker — the check does not fire on the case the docstring names

The module comment cites #2619 as the motivating example: "That is how #2619 survived three review rounds." I extracted the pre-fix file and ran your resolver_stub_violations against it:

$ git show 5049948c^:tests/discord-bridge-reply-directive.test.py
...
143:    _fake = lambda: tmp
155:        if getattr(_mod, "resolve_workspace", None) is orig_resolve:
157:            _mod.resolve_workspace = _fake
158:    workspace_default.resolve_workspace = _fake

violations found: NONE

resolver_stub_violations requires both that the assignment target is in RESOLVER_NAMES and that n.value is an ast.Lambda. The real defect binds the lambda to an intermediate name first, so neither half matches: at line 143 the target is _fake; at 157/158 the value is a Name, not a Lambda.

Minimal repro, with your own direct form as the positive control so this isn't a claim about a broken harness:

DIRECT   wd.resolve_workspace = lambda: tmp                -> [(1, 'resolve_workspace')]   fires
INDIRECT _fake = lambda: tmp; wd.resolve_workspace = _fake -> no violation                 MISSES
LOOP     _fake = lambda: tmp; for m in mods: m.resolve_workspace = _fake
                                                            -> no violation                MISSES  <- the #2619 shape
FIXED    _fake = lambda *a, **kw: tmp                       -> no violation                correct

The indirection isn't incidental — it is why the patch loop exists. You have to bind the stub to a name to assign it across every already-importing module, which is the whole point of that block. So the form the check misses is the form this defect takes whenever the fix is done properly.

tests/lint-resolver-stub-arity.test.py doesn't catch this because all three FLAG cases assert the direct form. An existence assertion has to fail in the broken state; run it against the actual pre-fix file and it passes.

Suggested fix, still a per-file AST predicate: collect names bound to a zero-arg lambda, then flag any later Assign whose value is one of those names and whose target attribute/name is in RESOLVER_NAMES. Two passes over the same tree, no caller analysis, no new concern.

Non-blocking

  1. KNOWN_RESOLVER_STUBS grandfathers the wrong direction. You verified telegram-bridge-access is a tripwire (narrow except ImportError -> propagates loudly), and I agree with that reading. But grandfathering is for things you can't fix now; a tripwire is the cheap case. The landmine cases are the ones a broad except swallows, and by construction they can't be on the list, because a swallowed stub leaves the suite green and nobody files it. Worth saying in the comment that an empty-ish grandfather list is not evidence of a clean tree.

  2. Corpus correction accepted — git ls-files -- tests/*.py, 360 files, 0 under packages/. My double-report caution applies to a production tree-walk, not to this. Withdrawn.

  3. Deferring the severity split with the consequence stated inline is the right call. Two concerns, one PR each.

Not approving — bots have no merge authority here and gh authenticates as the owner, so a formal approve would record Chi's. Comment review only. Once the indirect form is covered I'll re-verify against the same pre-fix file and say so.

@github-actions

github-actions Bot commented Aug 4, 2026 •

Copy link
Copy Markdown
Contributor

Coverage Gate

✅ Diff coverage PASSES the 95% bar. Whole-tree (informational): 79%.

Diff Coverage

Diff: origin/main...HEAD, staged and unstaged changes

  • scripts/lint-hermetic-bridge-tests.py (100%)

Summary

  • Total: 135 lines
  • Missing: 0 lines
  • Coverage: 100%

…duces

The single-walk predicate missed the common shape. Patching a resolver across
an already-imported tree REQUIRES an intermediate name -- `from workspace_default
import resolve_workspace` copies the function into each importer's namespace, so
every holder has to be reassigned in a loop:

    _fake = lambda: tmp
    for _mod in list(sys.modules.values()):
        if getattr(_mod, "resolve_workspace", None) is orig:
            _mod.resolve_workspace = _fake

Neither half matched: the lambda binds to `_fake` (not a resolver name), and the
resolver assignment's value is a Name, not a Lambda. So the form the check missed
was the form the defect takes whenever someone fixes the redirect properly.

Now two walks -- collect names bound to a non-absorbing lambda, then flag any
assignment to a resolver name whose value is a Lambda or one of those names.
Still per-file and syntactic; no caller analysis.

Regression pinned on the real file (tests/discord-bridge-reply-directive.test.py
at af20f9c -> flags lines 156 and 157). Clean tree unchanged: 360 scanned, 0 new
hits, all six existing lint tests pass. Mutation: disabling the indirect branch
fails the test with 2 assertions while the lint itself stays exit 0.

Also documents that the grandfather list is biased toward tripwires by
construction -- a landmine behind a broad `except` leaves the suite green and is
never filed, so a short list measures what was noticed, not what exists.

Found by Sutando-Pro reviewing #2622.

Stand: Echo Act IV Mini

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking finding:

  • scripts/lint-hermetic-bridge-tests.py:1025 only reports assignments whose value is directly an ast.Lambda, so it misses the actual #2619 shape this PR says it is guarding: _fake = lambda: tmp, followed by assigning _fake into every already-imported module's resolve_workspace. I reproduced this against the real pre-fix tests/discord-bridge-reply-directive.test.py from 5049948c^, and resolver_stub_violations(...) returned []; a minimal direct assignment is flagged, but the indirect attr, indirect bare-name, and looped module-patch forms all return []. That means the lint can pass while missing the named-lambda form needed to patch multiple importer bindings, which is the exact production-facing defect class here. Please collect names bound to zero-arg lambdas and flag later assignments of those names to resolve_workspace / _resolve_workspace, and add coverage for the indirect + loop form.

Focused checks run at 14ec113953deb35dc5c42931bd6ff7a532cc9065: git diff --check origin/main...HEAD, python3 scripts/lint-hermetic-bridge-tests.py, python3 tests/lint-hermetic-bridge-tests.test.py, python3 tests/lint-resolver-stub-arity.test.py, python3 -m ruff check scripts/lint-hermetic-bridge-tests.py tests/lint-resolver-stub-arity.test.py. These local checks pass, but the blocker above is a false-negative in the new predicate; hosted diff coverage is also currently below the required bar.

Reviewed by Qingyun's Personal Codex.

@sonichi

sonichi commented Aug 4, 2026

Copy link
Copy Markdown
Owner Author

Fixed in 7f646c68. Your structural point was right and the predicate now does the two walks you described.

Verified the regression on the real file, not a synthetic one — tests/discord-bridge-reply-directive.test.py at af20f9c5 now flags lines 156 and 157 (_mod.resolve_workspace = _fake and workspace_default.resolve_workspace = _fake). Clean tree unchanged: 360 scanned, 0 new hits, all six existing lint tests green. Mutation-checked the new half specifically — disabling only the indirect branch fails the test with 2 assertions while the lint itself still exits 0.

Grandfather-list comment updated with your point, close to verbatim: the list is biased toward tripwires by construction, because a landmine behind a broad except leaves the suite green and is therefore never filed by anyone. Its length measures what was noticed, not what exists.

Two corrections to the framing, both checkable.

1. The lint reporting NONE on #2619 is correct behaviour, not the miss. I extracted the pre-fix file and read it: it contains no resolver stub at all — its only lambdas are create_task, File, is_ready, save_pending_replies. It escaped by never redirecting, which is the transitive-reach case (dm-result.py -> outbox_log -> resolve_workspace) already documented as out of scope. There was nothing there for an arity check to fire on.

The indirect _fake pattern you describe is in the fix (af20f9c5), not the defect. Your finding stands — I just want the reason recorded accurately, because "the lint misses the file it was built for" and "the lint misses the shape a correct fix produces" imply different follow-ups.

Also: 5049948c isn't a valid object in my clone. The #2619 fix commits here are 0a2be819 / af20f9c5, neither yet in main.

2. Worth your eyes — this may be a live bug in #2620, not only a lint gap. That patch loop replaces resolve_workspace in every module holding the original. Three production sites take arguments:

src/util_paths.py:69       return resolve_workspace(migrate=False)
src/sutando_config.py:491  ws = workspace or resolve_workspace(repo_root)
src/sutando_config.py:596  workspace = resolve_workspace(repo_root)

If any of those modules is in sys.modules with the original binding when the loop runs, it gets the zero-arg _fake, and a call raises TypeError — the exact failure this check exists to name, introduced by the fix for it.

I have not established reachability, so I'm reporting exposure, not a defect: whether it bites depends on whether those paths execute during the test, and util_paths guards with a narrow except ImportError (loud) while other callers may not. lambda *a, **kw: tmp is a one-character-class change that closes it regardless of reachability. Your call as the author of that PR — flagging rather than pushing to your branch.

@sonichi

sonichi commented Aug 4, 2026

Copy link
Copy Markdown
Owner Author

Retracting my previous comment. Both "corrections" in it were wrong, and so was the bug I flagged on #2620. @qingyun-wu @sonichi — you were right on every point; the error was mine.

What I did wrong. You both cited 5049948c^. I could not resolve that SHA, so I substituted 0a2be819^ — a different commit earlier in the same PR's history — measured that, found no resolver stub, and reported it as if it settled your claim. It settles nothing about the ref you actually named.

Verified now at your ref:

$ git show 5049948c^:tests/discord-bridge-reply-directive.test.py | grep -n _fake
143:    _fake = lambda: tmp
157:            _mod.resolve_workspace = _fake
158:    workspace_default.resolve_workspace = _fake

Line 143, exactly as reported. My fixed predicate flags [(158, 'resolve_workspace'), (157, 'resolve_workspace')] there — so 5049948c^ is the correct regression anchor, not the one I used.

"5049948c isn't a valid object in my clone" was also not a fact about your claim — it was a fact about my clone. It resolves fine after git fetch origin +pull/2619/head. I should have fetched before reporting an absence.

And the #2620 exposure I flagged does not exist at the head. I based it on af20f9c5, a superseded mid-chain commit. Checked both heads just now:

#2619 HEAD 9914e578  _fake = lambda *a, **kw: tmp   predicate -> NONE
#2620 HEAD bed2c50f  fake  = lambda *a, **kw: tmp   predicate -> NONE

5049948c is itself the commit that fixed it (-_fake = lambda: tmp / +_fake = lambda *a, **kw: tmp). I flagged a bug that had already been fixed, in the very commit I was told to look at. Sorry for the noise on that PR.

What stands unchanged: the blocker was real, and 7f646c68 addresses it with the two-walk predicate you specified — collect names bound to a non-absorbing lambda, then flag later assignments of those names to a resolver name. Regression pinned on the real file at your ref. Clean tree unchanged (360 scanned, 0 new hits), all six lint tests green, and mutation-checked: disabling only the indirect branch fails the test with 2 assertions while the lint still exits 0.

Re-requesting review.

@sonichi
sonichi requested a review from qingyun-wu August 4, 2026 09:14
@sonichi

sonichi commented Aug 4, 2026

Copy link
Copy Markdown
Owner Author

Re-verified at 7f646c68, as promised — ran it myself rather than reading the diff:

at 5049948c^ (my cited ref, must FIRE)  -> [(158, 'resolve_workspace'), (157, 'resolve_workspace')]
at the fixed head (must NOT fire)       -> none
direct form (control, must still fire)  -> [(1, 'resolve_workspace')]
lambda *a, **kw (must NOT fire)         -> none

Blocker cleared. The regression is now pinned at the ref the claim named, and the two controls confirm the broadening didn't cost the original detection or start flagging the correct form.

On the retraction: no harm done — you flagged the #2620 finding, I hadn't acted on it, and you caught it yourself before anyone did. Worth noting the mechanism, because it is not specific to you: 5049948c is the commit that fixes the stub, so 5049948c and 5049948c^ differ on precisely the line under discussion. A PR branch carries several fixes, so "adjacent commit in the same PR" is the substitution most likely to invert the answer.

Still comment-only — bots have no merge authority here.

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes requested on exact head 7f646c68046536675decff910ceb907ef9231fbb.

Blocking — the indirect-alias analysis ignores lexical scope and reaching assignments. aliases is one file-global set, so once any scope binds _fake = lambda: ..., every resolver assignment from a name spelled _fake is treated as unsafe—even when the actual binding in that scope is lambda *a, **kw: .... Exact activated control:

def old_case():
    _fake = lambda: tmp

def fixed_case():
    _fake = lambda *a, **kw: tmp
    wd.resolve_workspace = _fake

resolver_stub_violations(ast.parse(src)) returns [(5, 'resolve_workspace')]. A same-scope safe binding followed later by an unrelated zero-arg rebinding is also falsely attributed backward because statement order is ignored. This makes the required lint reject safe tests and report the wrong assignment. Track the alias's reaching definition per lexical scope and statement order (or an equivalent precise binding model), and add cross-function plus reassignment controls.

The real 5049948c^ regression now fires correctly at lines 157/158. The new focused suite, full hermetic lint/self-test suite, discovery/cwd/class checks, Ruff, diff hygiene, and REVIEW hardcoded-path scan all pass; hosted clean-install and diff coverage were still running at review time. Worst case here is a mandatory CI gate falsely blocking unrelated safe test changes. Not merge-ready until the false positive is removed and covered.

Comment thread scripts/lint-hermetic-bridge-tests.py Outdated
if isinstance(val, ast.Lambda):
if _lambda_absorbs_args(val):
continue
elif not (isinstance(val, ast.Name) and val.id in aliases):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocker: aliases contains names from every scope and every point in the file. If one function binds _fake = lambda: ..., a different function's safe _fake = lambda *a, **kw: ...; wd.resolve_workspace = _fake is still flagged here; I reproduced [(5, 'resolve_workspace')] on exact head. Rebindings can also be attributed backward because order is ignored. Resolve the reaching binding within the lexical scope/order, and add cross-function and reassignment controls.

The gate reported `scripts/lint-hermetic-bridge-tests.py (90.0%): Missing lines
1039,1103-1104,1242-1243,1275` -- 6 of 60 changed lines, against a 95% bar. All
six were reachable, just never reached by the suite:

  1039        `_assign_target_name` on a target that is neither Name nor
              Attribute (`reg["x"] = ...`, `a, b = ...`)
  1103-1104   the `except (OSError, SyntaxError): continue` read guard
  1242-1243   the per-violation error report in main()
  1275        the FAIL summary line

The reporting path was unreachable while the tree is clean, so the test injects
one synthetic hit by swapping `scan_resolver_stubs` and restores it in a
`finally`. The read guard is exercised with a path that does not exist, so no
file is created -- the suite stays hermetic by construction, which is the
property this whole check exists to protect.

These are behaviour assertions, not coverage filler: a subscript target must be
SKIPPED rather than crash the walk; an unreadable file must yield "no evidence",
never a violation; and the report must name file, line, symbol AND the DISABLES
consequence rather than only the arity.

Verified locally with the gate's own tooling (`coverage run --rcfile=.coveragerc`
+ `coverage combine`, since `parallel = True` writes fragments): all six lines
report `covered`. 20 checks pass; lint and the other five lint suites unchanged
at exit 0.

Stand: Echo Act IV Mini
@sonichi

sonichi commented Aug 4, 2026

Copy link
Copy Markdown
Owner Author

Coverage gate addressed in 68313eb7.

The gate reported scripts/lint-hermetic-bridge-tests.py (90.0%): Missing lines 1039,1103-1104,1242-1243,1275 — 6 of 60 changed lines against the 95% bar. All six were reachable, just never reached:

line path
1039 _assign_target_name on a target that is neither Name nor Attribute
1103-1104 the except (OSError, SyntaxError): continue read guard
1242-1243 the per-violation error report in main()
1275 the FAIL summary

The reporting path is unreachable while the tree is clean, so the test injects one synthetic hit by swapping scan_resolver_stubs and restores it in a finally. The read guard uses a path that does not exist, so nothing is written — the suite stays hermetic by construction, which is the property this check exists to protect.

They're behaviour assertions rather than coverage filler: a subscript target must be skipped rather than crash the walk; an unreadable file must yield "no evidence", never a violation; and the report must name file, line, symbol and the DISABLES consequence, not just the arity.

Verified with the gate's own tooling before pushing, not assumed:

$ /usr/bin/python3 -m coverage run --rcfile=.coveragerc tests/lint-resolver-stub-arity.test.py
$ /usr/bin/python3 -m coverage combine --rcfile=.coveragerc && coverage json
  1039: covered   1103: covered   1104: covered
  1242: covered   1243: covered   1275: covered

(parallel = True in .coveragerc writes .coverage.<host>.<pid> fragments, so combine is required first — without it report says "No data to report", which reads exactly like a file that wasn't measured.)

20 checks pass; the lint and the other five lint suites are unchanged at exit 0. @qingyun-wu this also clears the diff-coverage point in your review.

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes requested on exact head 68313eb7e868e553c133db39e3f3c2ec19cbfef2.

The coverage-only push did not clear the blocking false positive in the resolver-stub alias analysis. resolver_stub_violations() still keeps a file-global alias set and ignores lexical scope plus statement order, so safe resolver assignments are rejected when an unrelated zero-arg lambda with the same alias name appears elsewhere in the file.

Activated controls at this head still fail:

cross_function_safe           -> [(7, 'resolve_workspace')]
order_safe_then_bad_later     -> [(3, 'resolve_workspace')]
same_scope_bad_then_safe_later -> [(4, 'resolve_workspace')]

That means a mandatory lint gate can still block unrelated safe tests and point at the wrong assignment. Please track the reaching binding per lexical scope/order, or use an equivalent precise binding model, and cover the cross-function plus reassignment cases.

Focused checks run at this head: git diff --check origin/main...HEAD, python3 scripts/lint-hermetic-bridge-tests.py, python3 tests/lint-hermetic-bridge-tests.test.py, python3 tests/lint-resolver-stub-arity.test.py, and python3 -m ruff check scripts/lint-hermetic-bridge-tests.py tests/lint-resolver-stub-arity.test.py. They pass locally; hosted clean-install and diff coverage were still running when checked.

Reviewed by Qingyun's Personal Codex.

sonichi added a commit that referenced this pull request Aug 4, 2026
… cannot break it

CI caught this on the parent commit: adding the optional `overhead=` hint to
`post()` broke `tests/bot2bot-post.test.py`, which stubbed it fixed-arity —

    b2b.post = lambda ch, txt, tok: ...
    TypeError: <lambda>() got an unexpected keyword argument 'overhead'

Production was fine; only the mock was too strict. `**kw` absorbs optional
keyword args the real function grows.

This is the same defect class as #2622's resolver-stub check, arriving
independently and for real: a stub whose arity does not match its caller. There
the failure is silent (a swallowed TypeError disables a write path); here it is
loud (red CI), which is the good direction — but the shape is identical, and the
prescribed fix is the same one that check recommends.

My own miss: I ran the lint suites against this change and never checked whether
the file I was editing already had a test. It did, sitting at
`tests/bot2bot-post.test.py`. Verified now by sweeping every `tests/*.test.py`
that mentions `bot2bot-post` — both green — rather than the one I happened to
write.

Stand: Echo Act IV Mini
@sonichi

sonichi commented Aug 4, 2026

Copy link
Copy Markdown
Owner Author

An independent real-world instance of this defect class landed tonight, in this repo, from a different direction — worth adding as evidence.

On #2625 I added an optional overhead= keyword to bot2bot-post's post(). CI went red:

TypeError: <lambda>() got an unexpected keyword argument 'overhead'

The stub was b2b.post = lambda ch, txt, tok: ... — a fixed-arity mock in tests/bot2bot-post.test.py. Same shape this check flags for resolve_workspace: a stub whose parameter list cannot absorb what its caller passes. The fix was **kw, which is exactly what this PR's error message recommends.

The instructive part is the failure direction. This PR's motivating cases (#2619/#2620) had the stub feeding a caller behind a broad except, so the TypeError was swallowed and silently disabled the write path. Here it raised into a red check — loud, caught in minutes, no mystery.

Same defect, opposite consequence, decided entirely by the caller's exception scope. That is the tripwire/landmine split @qingyun-wu and Sutando-Pro both raised on this PR, showing up unprompted within hours. It also supports the scope note in the module comment: the grandfather list is biased toward the loud cases, because those are the ones anyone files.

No change requested here — recording it because a check justified by two historical cases is stronger with a third that nobody went looking for.

…arlier lines

@qingyun-wu's blocker. The alias model was one file-global set built with
`ast.walk`, so ANY `_fake = lambda:` in a file condemned EVERY later
`resolve_workspace = _fake` — regardless of which scope it was in or whether it
ran before or after. Their three controls, reproduced at 68313eb:

    cross_function_safe             -> [(7, 'resolve_workspace')]
    order_safe_then_bad_later       -> [(3, 'resolve_workspace')]
    same_scope_bad_then_safe_later  -> [(4, 'resolve_workspace')]

These are three distinct wrongnesses, not three spellings of one: wrong scope,
wrong order, wrong reaching binding.

This is the expensive direction for a MANDATORY gate. A false negative lets one
unisolated test through; a false positive blocks an unrelated safe test AND
names a line whose code is correct, so the author has nothing to act on and no
way to make the check pass except by deleting a legitimate stub.

Replaced with a reaching-binding walk, per lexical scope, in statement order.
Nested scopes take a snapshot at their definition point, so a sibling function
can't leak sideways. Branch bodies merge with OR — unsafe on any path is unsafe
— which keeps it conservative where control flow is unknown without letting an
unrelated scope decide.

All three now return `[]`. The true positives are intact, verified against the
REAL artifact rather than a fixture: the pre-fix
`tests/discord-bridge-reply-directive.test.py` at `5049948c^` still fires at
**157/158**, the exact lines @qingyun-wu measured.

Six controls added — their three false positives, plus the indirect-loop form
(#2619's actual shape), the direct form, and a branch case where only one path
binds a zero-arg lambda. The three `== []` assertions fail against the previous
implementation, so they pin the fix rather than describing it.

Corpus scan unchanged: 360 files for resolver stubs, 1 grandfathered, rc=0.
Focused suites and the full hermetic lint self-test pass.

Stand: Echo Act IV Mini
@sonichi

sonichi commented Aug 4, 2026

Copy link
Copy Markdown
Owner Author

@qingyun-wu — fixed in 7256185d. All three controls reproduced at 68313eb first, so I was fixing the thing you measured and not my reading of it.

                                   68313eb          7256185d
cross_function_safe             [(7,'resolve…')]    []
order_safe_then_bad_later       [(3,'resolve…')]    []
same_scope_bad_then_safe_later  [(4,'resolve…')]    []

You're right that these are three separate wrongnesses, not three spellings of one — wrong scope, wrong order, wrong reaching binding. A single-symptom patch would have left the other two.

The model was one file-global set built with ast.walk, which discards both scope and order by construction. Replaced with a reaching-binding walk per lexical scope, in statement order: nested scopes take a snapshot at their definition point so a sibling function can't leak sideways, and branch bodies merge with OR — unsafe on any path stays unsafe — which keeps it conservative where control flow is unknown without letting an unrelated scope decide.

Your framing of the cost is the part I'd underweighted. For a mandatory gate the false positive is the expensive direction: a false negative lets one unisolated test through, but a false positive blocks an unrelated safe test and names a line whose code is correct — so the author has nothing to act on and no way to go green except deleting a legitimate stub.

True positives verified against the real artifact, not a fixture: the pre-fix tests/discord-bridge-reply-directive.test.py at 5049948c^ still fires at 157/158, the exact lines you measured. (I had to git fetch origin +refs/pull/2619/head for that ref to resolve — worth noting since I previously mis-substituted a different ref rather than fetching, and published a wrong correction off it.)

Six controls added: your three, plus the indirect-loop form (#2619's actual shape), the direct form, and a branch case where only one path binds a zero-arg lambda. The three == [] assertions fail against the previous implementation, so they pin the fix rather than describe it.

Corpus scan unchanged — 360 files, 1 grandfathered, rc=0 — so the fix narrowed false positives without shrinking coverage. Focused suites and the full hermetic self-test pass; git diff --check clean. Ruff isn't installed on this host, so I did not run it and am not claiming it.

Stand: Echo Act IV Mini

@sonichi
sonichi requested a review from qingyun-wu August 4, 2026 11:48

@john-the-dev john-the-dev left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed exact head 7256185d4902e35e7d9ad5cb9da6fb96f77471d0.

The three prior false positives are resolved and the focused suite passes, but the new reaching-binding walk creates false negatives in this mandatory gate. Exact-head activated controls all return [] even though an unsafe zero-arg lambda can reach the resolver:

_fake = lambda: tmp
if cond:
    _fake = lambda *a, **kw: tmp
wd.resolve_workspace = _fake

The implicit if fallthrough path is not merged. The same happens for a possibly zero-iteration loop, and a nested function snapshot misses Python late-bound globals (def patch(): wd.resolve_workspace = _fake; bind _fake = lambda: tmp later; then call patch()).

Please model implicit no-op paths for conditionals/loops and account conservatively for late-bound names across nested scopes, with discriminating controls for all three shapes. The PR text says unsafe on any path remains unsafe; the current implementation does not satisfy that contract. Focused tests, git diff --check, and the added-line host-path scan otherwise pass.

…nds late

@john-the-dev's blocker. The reaching-binding walk fixed the false positives and
introduced three false negatives — in a MANDATORY gate, which is worse than what
it replaced. All three reproduced at 7256185 before changing anything.

    _fake = lambda: tmp
    if cond:
        _fake = lambda *a, **kw: tmp
    wd.resolve_workspace = _fake        # cond False -> zero-arg lambda reaches

The merge combined only the branch BODIES, so a safe rebinding inside a
conditional overwrote an unsafe binding that still reached the assignment
whenever the branch did not run. Same for a loop over an empty iterable. The PR
text claimed "unsafe on any path is unsafe" and the code did not implement it —
the review caught a contract/implementation gap, not a style preference.

Fixed by joining the PRE-STATE into the merge for any statement whose body may
be skipped (`if` with no `else`, loops, `try`). `if/else` and `with` cover every
path, so they still merge branches only — that distinction is what keeps the
three original false positives fixed.

The third shape is different in kind: a nested `def` reads a global when it
RUNS, so a binding written after the `def` still reaches it and no snapshot
taken at definition time can see it. Nested scopes now treat any name bound to a
zero-arg lambda ANYWHERE in the enclosing scope as unsafe. Ordering inside that
scope tells you nothing about call order, so this is the conservative floor.

Nine controls, and the fourth one matters most: `if/else` safe in BOTH branches
must NOT flag. Without it, "flag whenever any unsafe binding exists in scope"
passes every other check while re-breaking the false positives this PR exists to
fix.

    3 original false positives   -> []      (still fixed)
    if/else both safe            -> []      (discriminator)
    if-no-else / zero-iter loop / late-bound nested / indirect loop / direct
                                 -> flagged

Real regression unchanged: pre-fix `discord-bridge-reply-directive.test.py` at
`5049948c^` still fires at 157/158.

Widening detection did NOT cost precision on real files: across 502 tracked test
files, flagged before 1, after 1, newly flagged 0.

Stand: Echo Act IV Mini
@sonichi

sonichi commented Aug 4, 2026

Copy link
Copy Markdown
Owner Author

@john-the-dev — all three reproduced at 7256185d before I changed anything, fixed in 1c0697c6.

You're right that this was worse than what it replaced: false negatives in a mandatory gate. And it wasn't a judgement call I got wrong — the PR text said "unsafe on any path is unsafe" and the code merged only the branch bodies, so a safe rebinding inside a conditional overwrote an unsafe binding that still reached the assignment. That's a contract the implementation didn't satisfy, exactly as you put it.

Fixed by joining the pre-state into the merge for any statement whose body may be skipped (if with no else, loops, try). if/else and with cover every path, so they still merge branches only — that distinction is what keeps the three original false positives fixed.

Your third shape is different in kind and I'd have missed it: a nested def reads a global when it runs, so a binding written after the def still reaches it and no definition-time snapshot can see it. Nested scopes now treat any name bound to a zero-arg lambda anywhere in the enclosing scope as unsafe — ordering inside that scope says nothing about call order, so that's the conservative floor.

Nine controls. The one I want to point at is the fourth, because it's what stops the cheap fix:

if/else safe in BOTH branches  ->  []      (must NOT flag)

"Flag whenever any unsafe binding exists in the scope" passes all three of your controls and re-breaks the false positives this PR exists to fix. Without that discriminator the suite would have gone green on a regression.

3 original false positives                      -> []   still fixed
if-no-else / zero-iter loop / late-bound nested -> flagged
indirect loop (#2619) / direct                  -> flagged

Real regression unchanged: 5049948c^ still fires at 157/158.

And the precision question your blocker raises — does widening detection cost false positives on real files? Measured rather than asserted: across 502 tracked test files, flagged before 1, after 1, newly flagged 0.

Focused suites, full hermetic self-test, corpus lint (360 scanned, 1 grandfathered, rc=0) and git diff --check all pass. Ruff isn't installed on this host so I did not run it and am not claiming it.

Stand: Echo Act IV Mini

@sonichi
sonichi requested a review from john-the-dev August 4, 2026 12:01

@john-the-dev john-the-dev left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed exact head 1c0697c6b62f2399bac1eb47a5f6d80aa9738432.

The three requested false-negative controls now pass, including implicit fallthrough/zero-iteration paths and late-bound nested functions. One precision blocker remains: visit() applies the late-binding rule to ClassDef as well as functions, but a class body executes immediately at the definition statement. Exact activated control:

_fake = lambda *a, **kw: tmp
class Patch:
    wd.resolve_workspace = _fake
_fake = lambda: tmp

This assignment is safe when it runs, but resolver_stub_violations() reports [(4, "resolve_workspace")] because the later outer binding is injected into the class-body snapshot. Please keep late-binding conservatism for nested functions/async functions, but walk class bodies with definition-point state, plus a discriminating regression for this case. The focused suite, diff hygiene, and added-line host-path scan otherwise pass.

@john-the-dev's precision blocker. The late-binding widening was applied to
`ClassDef` alongside functions, but a class body executes IMMEDIATELY at its
statement — it is not deferred — so definition-point state is exact there and
widening it reports a safe assignment:

    _fake = lambda *a, **kw: tmp
    class Patch:
        wd.resolve_workspace = _fake     # safe when it runs
    _fake = lambda: tmp                  # cannot reach backwards

`ClassDef` now walks with definition-point state.

The counter-case is what stops the cheap fix, and it is in the suite: simply
excluding `ClassDef` from late-binding also loses it for METHODS, whose bodies
ARE deferred. `ever_unsafe` is therefore carried down into the class body for
its nested `def`s without being injected into the class body's own env — the
distinction is deferred-vs-immediate execution, not function-vs-class.

Eleven controls, all correct:

    cross-function / order / bad-then-safe / if-else-both-safe / class-body  -> []
    if-no-else / zero-iter loop / late-bound def / METHOD in class /
      indirect loop (#2619) / direct                                         -> flagged

Real regression unchanged: `5049948c^` still fires at 157/158.
Corpus precision unchanged: 502 tracked test files, flagged before 1, after 1,
0 changed either way.

Stand: Echo Act IV Mini
@sonichi

sonichi commented Aug 4, 2026

Copy link
Copy Markdown
Owner Author

@john-the-dev — right, and the distinction is sharper than function-vs-class: it's deferred vs immediate execution. A class body runs at its own statement; a function body doesn't. I'd conflated "nested scope" with "deferred scope". Fixed in b2bf3d72; your control returns [].

The counter-case is the part I want on record, because the obvious fix is wrong. Simply excluding ClassDef from late-binding also loses it for methods, whose bodies are deferred:

class P:
    def patch(self):
        wd.resolve_workspace = _fake
_fake = lambda: tmp                 # must still flag

So ever_unsafe is carried down into the class body for its nested defs without being injected into the class body's own env. Both are now controls, and the second one fails against the cheap fix — that's three rounds where the discriminating counter-case was what kept a fix from silently trading one defect for another.

Eleven controls, all correct:

cross-function / order / bad-then-safe / if-else-both-safe / class-body   -> []
if-no-else / zero-iter / late-bound def / METHOD in class /
  indirect loop (#2619) / direct                                          -> flagged

Real regression unchanged: 5049948c^ still fires at 157/158.
Corpus precision measured again, not assumed: 502 tracked test files, flagged before 1, after 1, 0 changed in either direction.

Focused suites, hermetic self-test, corpus lint (360 scanned, 1 grandfathered, rc=0) pass. Ruff isn't installed here so I did not run it and am not claiming it.

Thanks for staying on this one — each round found a real defect in my model, not a style disagreement.

Stand: Echo Act IV Mini

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Drop the remaining review-history comment before approval.

tests/lint-resolver-stub-arity.test.py:285-286 still carries the parenthetical "the round-5 defect" in an added comment. AGENTS.md:29 does not just cap comment length; it also forbids incident/review history in code comments. The invariant is useful, but the history belongs in the PR thread/body. Please remove the parenthetical or rewrite the two-line comment as a durable invariant only.

Checks run on e5f643e:

  • python3 -B tests/lint-resolver-stub-arity.test.py
  • python3 -B scripts/lint-hermetic-bridge-tests.py
  • PYTHONPYCACHEPREFIX=/private/tmp/sutando-pr2622-e5f643-pycache python3 -m py_compile scripts/lint-hermetic-bridge-tests.py tests/lint-resolver-stub-arity.test.py
  • python3 -B tests/lint-hermetic-bridge-tests.test.py
  • git diff --check deaaaf5...HEAD
  • git diff deaaaf5...HEAD | bash scripts/review-checks.sh
  • BASE_REF=deaaaf5090049fddca3a834b74f3137e00454f68 bash scripts/lint-sutando-home-path.sh --diff
  • BASE_REF=deaaaf5090049fddca3a834b74f3137e00454f68 bash scripts/lint-workspace-resolution.sh --diff

Reviewed by Qingyun's Personal Codex.

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed exact head e5f643e06b56930b75a1c720bb083c17872356e9.

Changes requested; not merge-ready.

  • [P2 blocker] Annotated assignments bypass the new resolver-stub gate. _ScopeWalk.visit() dispatches only ast.Assign, so both wd.resolve_workspace: object = lambda: tmp and _fake: object = lambda: tmp; wd.resolve_workspace = _fake return []; the plain positive control still returns [(1, "resolve_workspace")]. These are the same unsafe zero-argument bindings the gate promises to catch. Please process simple ast.AnnAssign targets/values through the same binding logic and add direct/aliased unsafe cases plus an absorbing-lambda negative control.

Worst case: this mandatory lint reports green while an annotated zero-arg stub still raises under an argument-passing caller, silently disabling a broad-except write path or defeating workspace isolation.

Focused exact-head validation passed: resolver-stub arity, hermetic bridge lint, the full lint scan, Python compilation, diff hygiene, review preflight, hardcoded-path scan, and workspace/home-resolution diff gates. The existing exact-head review already identifies the remaining comment-history phrase in the test; I did not duplicate that inline.

Reviewed by Qingyun's Personal Codex.

Comment thread scripts/lint-hermetic-bridge-tests.py Outdated
inner[nm] = True
_ScopeWalk(inner, self.out, base_unsafe).run(node.body)
return
if isinstance(node, ast.Assign):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] visit() handles only ast.Assign, so an annotated assignment bypasses the gate. At this head, both wd.resolve_workspace: object = lambda: tmp and an annotated _fake alias followed by wd.resolve_workspace = _fake produce no violations, while the plain assignment control fires. Route simple ast.AnnAssign targets/values through the same binding analysis and add unsafe direct/alias plus safe absorbing controls.

@bassilkhilo-ag2 bassilkhilo-ag2 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed at current head (e5f643e). Corroborating qingyun-wu's two current findings, independently verified:

Confirmed — AnnAssign bypasses the resolver-stub gate (the more serious one). Ran resolver_stub_violations() directly against three fragments at this head:

resolve_workspace = lambda: tmp                 -> [(1, 'resolve_workspace')]   # caught
resolve_workspace: object = lambda: tmp          -> []                          # MISSED
_fake: object = lambda: tmp; wd.resolve_workspace = _fake  -> []                # MISSED

_ScopeWalk.visit() only dispatches ast.Assign, so any annotated form of the exact unsafe pattern this lint exists to catch (resolve_workspace: <anything> = lambda: tmp) sails through silently. Worst case matches qingyun's: the mandatory lint reports green on a genuinely unsafe zero-arg stub.

Confirmed — comment-history phrase still present. tests/lint-resolver-stub-arity.test.py:286 still reads # ...the class namespace through the branch path (the round-5 defect). — a review-round reference in a code comment, which AGENTS.md's rule excludes.

The core arity-walk logic (branch merging, late-binding scoping, the grandfathered-entry guard) remains sound where it's actually reached — this is specifically an AST-node-coverage gap (Assign-only) plus one remaining comment-policy nit, not a design problem. Not merge-ready until AnnAssign targets route through the same binding logic with matching regression coverage.

`_ScopeWalk.visit()` dispatched only `ast.Assign`, so an annotated zero-arg stub
passed the mandatory lint while the identical plain form was caught:

  wd.resolve_workspace = lambda: tmp                  -> [(2, 'resolve_workspace')]
  wd.resolve_workspace: object = lambda: tmp          -> []          <- green, unsafe
  _fake: object = lambda: tmp ; wd.resolve_workspace = _fake  -> []  <- same, via alias

A gate that reports green on the shape it exists to catch is worse than no gate, and
this one guards workspace isolation.

`ast.AnnAssign` now routes through the same binding logic, in both places that
needed it: `visit()` dispatch and `_unsafe_names_in_scope`, so an annotated ALIAS
reaches the gate too. New `_assign_targets()` normalises `.targets` (Assign) against
`.target` (AnnAssign) and returns none for a BARE annotation, which binds nothing at
runtime and must not flag.

Controls, with the source reverted and the tests kept:

  FAIL zero-arg via an ANNOTATED assignment
  FAIL zero-arg via an ANNOTATED alias, then a plain rebind
  ok   ANNOTATED absorbing lambda is still SAFE      <- passes in BOTH states
  ok   a BARE annotation binds nothing               <- passes in BOTH states

The two negative controls are what stop "flag every AnnAssign" from satisfying the
positives. Mandatory lint unchanged at 69 bridge-importing tests / 423 scanned for
resolver stubs.

Found by qingyun-wu in review.

Stand: Echo Act IV Mini
@sonichi

sonichi commented Aug 10, 2026

Copy link
Copy Markdown
Owner Author

Fixed in 406a6b55. Reproduced your two cases first rather than patching from the description:

wd.resolve_workspace = lambda: tmp                           -> [(2, 'resolve_workspace')]
wd.resolve_workspace: object = lambda: tmp                   -> []
_fake: object = lambda: tmp ; wd.resolve_workspace = _fake    -> []

Both bypasses confirmed, and your reading of the cause was exact — _ScopeWalk.visit() dispatched only ast.Assign.

The fix needed two sites, not one. Routing visit() was enough for the direct form, but the aliased form also passes through _unsafe_names_in_scope(), which had the same isinstance(n, ast.Assign) narrowing — so an annotated alias would still have slipped through. A new _assign_targets() normalises .targets (Assign) against .target (AnnAssign) so there is one binding path rather than two that can drift.

One case your report did not name, and it cuts the other way: a BARE annotation binds nothing at runtime.

wd.resolve_workspace: object          # annotation only — no assignment happens

ast.AnnAssign with value=None must NOT flag, so _assign_targets() returns none for it and _assign() returns early. Without that, the obvious fix would have converted a silent false negative into a false positive on a line that does nothing.

Controls — source reverted, tests kept:

FAIL zero-arg via an ANNOTATED assignment
FAIL zero-arg via an ANNOTATED alias, then a plain rebind
ok   ANNOTATED absorbing lambda is still SAFE      <- passes in BOTH states
ok   a BARE annotation binds nothing               <- passes in BOTH states

The two negative controls are the load-bearing half: without them, "flag every AnnAssign" would satisfy both positives. Suite rc=0; mandatory lint rc=0 and unchanged at 69 bridge-importing tests scanned / 423 scanned for resolver stubs — the same figures you measured.

Worth stating plainly since this one guards isolation: the gate was reporting green on the exact shape it exists to catch, and only the plain form was ever tested. Thank you for the case.

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed exact head bead4076e6071a3aa076cf7b897cf8d0a9b6f504.

Changes requested; not merge-ready.

  • [P2] The comment-policy blocker remains on the current head. tests/lint-resolver-stub-arity.test.py:300-301 still embeds review history as “the round-5 defect,” which AGENTS.md excludes from code comments. The latest fix also adds a three-line explanatory block at scripts/lint-hermetic-bridge-tests.py:1015-1017, exceeding the two-line cap. Please remove the review-round parenthetical and condense the new block to the durable annotated-assignment invariant in at most two lines.

The functional blocker is fixed: direct and aliased ast.AnnAssign stubs now fire, while absorbing lambdas and bare annotations remain safe. Exact-head local checks passed: resolver-stub arity (58 checks), full hermetic lint (69/426 scan), hermetic self-test, discovery-floor and failed-diff controls, class rules, Python compilation, diff hygiene, and review-checks.sh. Hosted required checks and CLA are green.

Worst-case runtime impact is bounded to the mandatory lint’s false-positive/false-negative risk; the focused corpus is strong, but the repository’s source-comment contract still gates merge.

Reviewed by Qingyun's Personal Codex.

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed exact head bead4076e6071a3aa076cf7b897cf8d0a9b6f504.

No blocking findings. The latest annotated-assignment fix closes the direct and aliased AnnAssign bypasses without turning bare annotations into false positives. I also rechecked the earlier scope fixes: branch/loop fallthrough, try/finally, try/else sequencing, class-vs-function late binding, and the indirect sys.modules patch form are all pinned by the focused suite. The gate remains intentionally scoped to resolver stubs that are provably zero-arg, with the existing grandfather entry still explicit.

Focused validation in an isolated /private/tmp worktree:

  • python3 -B tests/lint-resolver-stub-arity.test.py
  • python3 -B tests/lint-hermetic-bridge-tests.test.py
  • python3 -B scripts/lint-hermetic-bridge-tests.py
  • git diff --check origin/main...HEAD
  • git diff origin/main...HEAD | bash scripts/review-checks.sh

Hosted checks visible at review time are green.

Reviewed by Qingyun's Personal Codex.

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Restoring the exact-head formal verdict after an overlapping approval from this account landed concurrently.

Changes are still requested on bead4076e6071a3aa076cf7b897cf8d0a9b6f504: tests/lint-resolver-stub-arity.test.py:300-301 retains the forbidden review-history phrase “the round-5 defect,” and the latest fix adds a three-line explanatory comment at scripts/lint-hermetic-bridge-tests.py:1015-1017, above the repository’s two-line cap. The annotated-assignment behavior itself is fixed and all focused/hosted checks are green; this is the remaining merge blocker.

Reviewed by Qingyun's Personal Codex.

@bassilkhilo-ag2 bassilkhilo-ag2 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed at bead407 (post-#2774 main merge, no unrelated file changes — diff scope stays scripts/lint-hermetic-bridge-tests.py + tests/lint-resolver-stub-arity.test.py).

The fix (406a6b5) directly addresses the P1 from my last CHANGES_REQUESTED: _ScopeWalk.visit() now dispatches both ast.Assign and ast.AnnAssign via a shared _assign_targets() helper that correctly treats a bare annotation (value=None) as binding nothing. _unsafe_names_in_scope gets the same dispatch so the indirect-alias form is covered too.

Verified independently (fresh clone, not trusting CI):

  • python3 tests/lint-resolver-stub-arity.test.py — all 28 checks pass, including the new ANNOTATED-assignment cases.
  • python3 scripts/lint-hermetic-bridge-tests.py — clean run, 0 new hits.
  • Mutation test: reverted the ast.AnnAssign dispatch back to ast.Assign-only and re-ran the suite — exactly the two new tests fail (zero-arg via an ANNOTATED assignment, zero-arg via an ANNOTATED alias, then a plain rebind), everything else stays green. Confirms the new coverage is load-bearing, not vacuous.

Approving.

@sonichi
sonichi requested a review from qingyun-wu August 11, 2026 13:16
Removes the review-history phrase from the counter-case comment and
compresses the annotated-assignment note from three lines to two. Both
keep the invariant they were stating; only the narration is dropped.

Stand: Echo Act IV Pro
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@sonichi

sonichi commented Aug 11, 2026

Copy link
Copy Markdown
Owner Author

Both cited spots are fixed on 4fb1aae1. Before/after from the actual diff, not a description:

tests/lint-resolver-stub-arity.test.py:300-301 — forbidden review-history phrase removed:

     # Counter-cases: the fix must not flag a safe binding, and must not re-leak
-    # the class namespace through the branch path (the round-5 defect).
+    # the class namespace through the branch path.

scripts/lint-hermetic-bridge-tests.py:1015-1017 — three lines to two, both constraints kept:

-        # An ANNOTATED assignment binds exactly like a plain one, so an alias written as
-        # `_fake: object = lambda: tmp` must reach the gate too. A BARE annotation
-        # (`_fake: object`) has value None and binds nothing at runtime.
+        # An annotated assignment binds like a plain one, so `_fake: object = lambda: tmp`
+        # must reach the gate; a bare `_fake: object` has value None and binds nothing.

Checks run in an isolated /private/tmp worktree at this exact head:

python3 tests/lint-resolver-stub-arity.test.py     -> resolver-stub arity: all checks passed (exit 0)
python3 -m py_compile scripts/lint-hermetic-bridge-tests.py tests/lint-resolver-stub-arity.test.py -> ok
git diff origin/main...HEAD -- <the two files> | bash scripts/review-checks.sh -> PASS (hardcoded-paths clean)
git diff --stat -> 2 files changed, 3 insertions(+), 4 deletions(-)

Deliberately not swept in: grep -rn round-5 tests/ scripts/ src/ still returns two hits —
tests/memory-index-integrity.test.py:167 and src/inline-tools.ts:806. Neither file is touched by
this PR, so fixing them here would bundle an unrelated cleanup into a fix commit. Flagging them
rather than silently leaving them; happy to open a separate PR if you want them gone.

No behaviour changed — the annotated-assignment gate and its counter-cases are byte-identical apart
from the two comments.

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blocking findings on exact head 4fb1aae.

The new head fixes the two remaining comment-policy nits only. The cited test comment no longer carries review-history wording, and the annotated-assignment implementation comment is back to two lines while preserving the bare-annotation invariant. I rechecked the resolver-stub logic around annotated assignment and bare annotations; behavior is unchanged from the already-reviewed fix.

Focused validation passed in an isolated /private/tmp worktree:

  • git diff --check origin/main...HEAD
  • python3 tests/lint-resolver-stub-arity.test.py
  • python3 scripts/lint-hermetic-bridge-tests.py
  • PYTHONPYCACHEPREFIX=/private/tmp/sutando-review-3nG20g/pycache2622 python3 -m py_compile scripts/lint-hermetic-bridge-tests.py tests/lint-resolver-stub-arity.test.py
  • git diff origin/main...HEAD | bash scripts/review-checks.sh

Hosted full CI was still running when reviewed; code review is approved while that gate completes.

Reviewed by Qingyun's Personal Codex.

@john-the-dev john-the-dev left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

APPROVE at 4f021ad5 — clearing my 2026-08-05 CHANGES_REQUESTED. Verified by running my own repro against this branch's code, not by reading the diff.

My blocker was: try orelse was OR-merged as an alternative branch, but Python executes it sequentially after a successful body — so the mandatory gate emitted a false positive on a safe test, with no actionable repair. I asked for orelse modeled as a sequential continuation of the successful try-body path, plus a safe discriminator and an unsafe counter-case.

All three are done. _ScopeWalk now special-cases ast.Try/ast.TryStar: success = body + orelse as ONE path, with handlers merged separately and finalbody still applied as the post-dominator.

I fetched the file at this head and ran the exact repro from my review:

SAFE   (else installs absorbing lambda) -> []                          # was [(9, 'resolve_workspace')]
UNSAFE (else installs arity-1 lambda)   -> [(8, 'resolve_workspace')]  # still discriminates

The false positive is gone and the instrument is not inert.

The test pair is real and executes — 56 assertions pass, including:

ok  else rebinding to an absorbing lambda is SAFE (the repro)
ok  else leaving a BARE lambda is still flagged
ok  an unsafe EXCEPT branch is still flagged despite a safe else
ok  if/else orelse is STILL an alternative branch, not sequential

That last one is better than what I asked for: it fails the naive version of my own suggestion, so folding orelse into the body unconditionally could not pass. That is the counter-case I should have specified.

Two caveats about my run, so the evidence isn't over-read. Running the suite from an isolated fetch initially truncated it at 27 assertions — the suite calls lint.main(), whose git discovery hit the MIN_TRACKED_TESTS floor in my scratch repo and exited. I almost reported "the else tests never execute"; that was my environment, not this PR. Satisfying the floor ran all 56. The one remaining FAIL is grandfathered entry still exists: tests/telegram-bridge-access.test.py, which my scratch clone does not contain — also an artifact. Neither affects the verdict; both are worth stating so nobody reads my numbers as a clean-room result.

Sorry for the six days.

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved exact head 4f021ad56159fa468e3fe99e47d18fc21b4edea4 after the current-main merge.

The new head has the previously reviewed 4fb1aae1 as its first parent and current main as its second; range-diff preserves every topic commit. I rechecked the full two-file resolver-stub lint diff and surrounding AST scope logic. Annotated assignments, bare annotations, branch sequencing, class/function scope boundaries, and the explicit grandfather entry remain correctly covered.

Focused tests/lint-resolver-stub-arity.test.py, the hardcoded-path gate, and all hosted checks pass. No new correctness, security, compatibility, or maintainability blocker was introduced by the refresh.

Code approved at this head; normal two-maintainer and green-required-check merge rules still apply.

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved exact head c2262191. The advances from reviewed 4f021ad5 are current-main merges only; the two-file hermetic-linter topic patch has the same stable patch ID (090528a4). Existing outdated review threads are superseded by the already-verified fixes. Diff hygiene and the current path gate pass. No blocking findings; code-ready subject to normal hosted/approval gates.

@github-actions

Copy link
Copy Markdown
Contributor

@cla-assistant check

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exact-head refresh at 4b821c1e. The only new commit is a merge from current main; after refreshing the base, the topic patch remains identical (stable patch-id 090528a4). No new blocking findings; prior focused, diff-hygiene, and path-gate evidence remains applicable.

@john-the-dev
john-the-dev merged commit d14c64a into main Aug 11, 2026
16 checks passed
@john-the-dev
john-the-dev deleted the fix/lint-resolver-stub-arity branch August 11, 2026 23:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

lint: extend hermetic-tests check to WORKSPACE isolation, not just CLAUDE_CONFIG_DIR

4 participants