Repository navigation
fix: a function assigning a module-top-level let now updates it (#671) - #673
Merged
Merged
Conversation
It used to silently write a frame-local. The top-level value kept its old value,
with no error and no warning. Where the right-hand side also read the variable,
the fresh uninitialised local surfaced as `Cannot add nil and int` -- a type
error naming arithmetic rather than scoping, which is the only signal a user
ever got, and why it was diagnosed as everything except what it was.
let g = 7i
fn setit() { g = 99i }
setit() // was 7. Now 99.
Two sites answered "where does this name live" and disagreed, and NEITHER FIX
ALONE IS SUFFICIENT -- which is why the bug looked unfixable from either end.
1. SymbolTable._resolve_upvalue_in returned None whenever there was no
enclosing *function* scope, which is the case for every function declared
at module level. So a module-level `let` was never found and the Assign
branch fell through to symbols.define(name), allocating a frame slot. The
`scope == "global"` branch directly beneath the bail handles exactly this
case and was simply unreachable.
2. VM.store_name wrote into the current frame's locals whenever a frame
existed, while load_name walked on to module_globals.
With only 1, the closure compiles to a correct global STORE, the read starts
resolving, and the write is still swallowed. With only 2, the compiler emits
STORE_LOCAL_IDX and the VM change is never reached. Both were measured
independently rather than reasoned about.
The rule is named once now -- VM.binding_namespace -- and both paths consult it.
Only the two *writable* namespaces are in it: `functions` and `host_globals` are
readable sources a program may not assign through, so they stay in load_name
alone, and the test pins that boundary rather than demanding the two be
identical.
The negative half mattered as much as the positive one. STORE is emitted at
thirteen compiler sites, several legitimately frame-local inside a function --
catch variables, destructuring temps, match pattern bindings. A fix that wrote
the global unconditionally would clobber a same-named module binding from any of
them. The `name not in locals_` guard is what prevents it, and a catch variable,
a parameter, a loop variable and a function-local `let` are each pinned as still
shadowing.
tests/test_name_resolution_agreement.py covers behaviour, the shadowing
negatives, the function-scoped controls, and the source-level property -- the
last because this bug IS two implementations of one question, and reads were
correct throughout, so a behaviour-only suite passes on the read path while the
write path is broken. That is exactly how it survived. Each site was verified to
turn the suite red on its own: site 1 alone fails 8 tests, site 2 alone fails 13.
The warm bytecode cache is covered too. It has been the forgotten third path
three times (#521, #400, #394), and during this work the site-1 patch appeared
inert until `.nodus/` was cleared.
Docs updated where they carried the old claim: CLAUDE.md's language-quirks
section and all four published skill artifacts, which stated the restriction one
scope too wide and sent readers to an unnecessary map workaround. They now say
which scope, which version, and that function-scoped upvalue mutation always
worked. DESIGN-006 (#156) is already closed as superseded.
Named a behaviour change in the CHANGELOG rather than filed under fixes: a
program that relied on the old silence will now see its globals mutate. Nothing
can have depended on a write disappearing, but running code does change.
Full suite 3017 passed, 8 skipped, 0 failed. Gate --all green (closed-issues
4/4). ruff and mypy clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013uyCMZqPCwvXnjkgc1mSFJ
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.
Closes #671.
A function assigning to a module-top-level
letsilently wrote a frame-local. The top-level value kept its old value, with no error and no warning.Where the right-hand side also read the variable, the fresh uninitialised local surfaced as
Cannot add nil and int— a type error naming arithmetic, not scoping, which is the only signal a user ever got, and why this was diagnosed as everything except what it was.Two sites, and neither fix alone is sufficient
That is why it looked unfixable from either end.
SymbolTable._resolve_upvalue_inreturnedNonewhenever there was no enclosing function scope — the case for every function declared at module level. So a module-levelletwas never found, andAssignfell through tosymbols.define(name), allocating a frame slot. Thescope == "global"branch directly beneath the bail handles exactly this case and was simply unreachable.VM.store_namewrote into the current frame'slocalswhenever a frame existed, whileload_namewalked on tomodule_globals.Measured independently rather than reasoned about:
STORE_LOCAL_IDX, so the VM change is unreachableThe rule is named once now —
VM.binding_namespace— and both paths consult it. Only the two writable namespaces are in it;functionsandhost_globalsare readable sources a program may not assign through, so they stay inload_namealone, and the test pins that boundary rather than demanding the two be identical.The negative half mattered as much as the positive one
STOREis emitted at thirteen compiler sites, several legitimately frame-local inside a function —catchvariables, destructuring temps,matchpattern bindings. A fix that wrote the global unconditionally would clobber a same-named module binding from any of them. Thename not in locals_guard prevents it, and four cases are pinned as still shadowing:plus a parameter and a loop variable.
Why there is a source-level assertion
This bug is two implementations of one question, and reads were correct throughout — so a behaviour-only suite passes on the read path while the write path is broken. That is precisely how it survived for the life of the feature.
tests/test_name_resolution_agreement.pyasserts thatstore_nameroutes through the shared rule and reaches every writable namespaceload_namedoes, and that the resolver no longer bails before module scope.The warm bytecode cache is covered too. It has been the forgotten third path three times (#521, #400, #394), and during this work the site-1 patch appeared inert until
.nodus/was cleared — exactly asCLAUDE.mdwarns.Controls
Function-scoped upvalue mutation always worked and is pinned so it stays that way: escaping counter closures, two closures sharing one captured variable, two-level nesting,
+=, and mutation from a spawned coroutine. DESIGN-006 (#156) claimed otherwise for years and is already closed as superseded.Docs
CLAUDE.mdand all four published skill artifacts stated the restriction one scope too wide and sent readers to an unnecessary map workaround. They now say which scope, which version, and that function-scoped mutation was never affected.Filed as a behaviour change, not a fix, in the CHANGELOG: a program relying on the old silence will now see its globals mutate. Nothing can have depended on a write disappearing, but running code does change.
Verification
nodus_gate --allgreen — closed-issues 4/4, runtime 264 blocksruff check src/ tests/ tools/andmypy src/nodus/clean🤖 Generated with Claude Code
https://claude.ai/code/session_013uyCMZqPCwvXnjkgc1mSFJ