Skip to content

fix: a function assigning a module-top-level let now updates it (#671) - #673

Merged
Masterplanner25 merged 1 commit into
mainfrom
fix/671-top-level-let-assignment
Aug 30, 2026
Merged

Masterplanner25 merged 1 commit into
mainfrom
fix/671-top-level-let-assignment

Conversation

@Masterplanner25

Copy link
Copy Markdown
Owner

Closes #671.

A function assigning to a module-top-level let silently wrote a frame-local. The top-level value kept its old value, with no error and no warning.

let g = 7i
fn setit() { g = 99i }
setit()          // was 7. Now 99.

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.

  1. SymbolTable._resolve_upvalue_in returned None whenever there was no enclosing function scope — the case for every function declared at module level. So a module-level let was never found, and Assign 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.

Measured independently rather than reasoned about:

reverted result
site 1 only 8 tests red — the compiler emits STORE_LOCAL_IDX, so the VM change is unreachable
site 2 only 13 tests red — the read starts resolving, the write is still swallowed by the frame

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 prevents it, and four cases are pinned as still shadowing:

caught: boom
after catch, global e = global-e      <- catch variable
after temper, global v = global-v     <- function-local let

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.py asserts that store_name routes through the shared rule and reaches every writable namespace load_name does, 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 as CLAUDE.md warns.

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.md and 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

  • full suite 3017 passed, 8 skipped, 266 subtests, 0 failed
  • nodus_gate --all green — closed-issues 4/4, runtime 264 blocks
  • ruff check src/ tests/ tools/ and mypy src/nodus/ clean
  • each fix site verified to turn the new suite red on its own

🤖 Generated with Claude Code

https://claude.ai/code/session_013uyCMZqPCwvXnjkgc1mSFJ

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
@Masterplanner25
Masterplanner25 merged commit 7340213 into main Aug 30, 2026
4 checks passed
@Masterplanner25
Masterplanner25 deleted the fix/671-top-level-let-assignment branch August 30, 2026 04:58
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.

A function assigning to a module-top-level let silently writes a frame-local; the global never changes

1 participant