Skip to content

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

Description

@Masterplanner25

Summary

A function that assigns to a module-top-level let silently writes a frame-local instead. The global keeps its old value, and there is no error, no warning, and no diagnostic.

let g = 7i
fn setit() { g = 99i }
setit()
print("g = \(g)")        // g = 7

The same happens through a closure (let setit = fn() { g = 99i }), and with compound assignment (g += 1i).

Reads are fine — a function can read a top-level let and gets the right value. Mutation at top level is fine — g = 9i outside any function works. Only assignment from inside a function is broken, and it is broken silently.

When the right-hand side also reads the variable, the failure surfaces as a type error that points at arithmetic rather than at scoping, because the freshly-created local is uninitialised:

let g = 0i
let bump = fn() { g = g + 1i }
bump()
Type error at up4.nd:2:27: Cannot add nil and int

That message is the only signal a user ever gets, and it names the wrong thing. Where the RHS does not read the variable, there is no signal at all.

Reproduction

Verified against main at 69cd15c (5.7.1 dev source), CLI and disassembler.

Case Expected Actual
fn setit() { g = 99i } on top-level let g = 7i g == 99 g == 7, silent
let setit = fn() { g = 99i } g == 99 g == 7, silent
let bump = fn() { g = g + 1i } g == 1 Cannot add nil and int
fn show() { print(g) } (read only) prints 7 prints 7 ✓
g = 9i at top level g == 9 g == 9 ✓
A let inside a function, captured and mutated by a closure works works ✓

The last row matters: ordinary upvalue capture is correct. An escaping counter closure, two closures sharing one captured variable, two-level nesting, mutation from inside a spawned coroutine, and += all behave correctly when the variable is function-scoped. This is specifically about module scope.

Evidence

nodus run --dump-bytecode on the named-function case:

Function setit:
  0: FRAME_SIZE 1
  1: PUSH_CONST 99
  2: STORE_LOCAL_IDX 0        <- writes a local slot
...
<main>:
  1: STORE g                  <- the global

The function was given a frame slot for g and wrote there.

Root cause — two sites, and they disagree

This is the recurring shape CLAUDE.md documents: one question — where does this name's value live? — answered independently in two places.

Site 1, compiler. SymbolTable._resolve_upvalue_in (compiler/symbol_table.py) opens with:

enclosing = self._enclosing_function_scope(func_scope)
if enclosing is None:
    return None

A function declared at module level has no enclosing function scope, so this bails before ever looking at the module scope — even though the walk immediately below it is written to handle exactly that case (if symbol.scope == "global": return symbol) and would return the right symbol if reached.

resolve_local cannot cover for it: it breaks at the first function-kind scope.

So resolve('g') returns None, and Compiler.compile_expr's Assign branch then does:

symbol = self.resolve_symbol(expr.name)
if symbol is None and self.symbols is not None:
    self.symbols.define(expr.name)      # <- defines a NEW LOCAL in this function

Instrumented, the sequence is unambiguous — g is plainly visible in the module scope and is still not found:

resolve('g') -> None                                  | chain: block[] <- function[] <- module['g','setit']
resolve('g') -> Symbol(scope='local', index=0)        | chain: block['g'] <- function[] <- module['g','setit']

Site 2, VM. Fixing the compiler alone is not enough. With site 1 patched, the closure correctly compiles to LOAD g / STORE g, and the write still does not reach the global, because VM.store_name (vm/vm.py) writes into the frame whenever there is one:

def store_name(self, name, value):
    locals_ = self.current_locals()
    if locals_ is not None:
        ...
        locals_[name] = value          # <- any frame at all captures the write
    else:
        self.module_globals[name] = value

load_name walks locals → module_globals → functions → host_globals. store_name stops at locals. Read and write are asymmetric, which is why reads work and writes vanish.

Measured with only site 1 patched: let g = 0i; let bump = fn() { g = g + 1i }; bump() stops erroring and prints 0 — the read is now correct, the write is still lost.

Fix direction

Both sites, or neither — patching one leaves the bug reachable through the other, which is the point of the shape.

  1. _resolve_upvalue_in should not bail when there is no enclosing function scope; it should still walk outward to module scope and return a global symbol when it finds one. (Confirmed to change behaviour: reads start resolving.)
  2. store_name needs to answer the same question load_name does. A STORE for a name the compiler resolved as global must reach module_globals rather than being captured by whatever frame happens to be on the stack.

The durable form is to name the resolution rule once and have both load_name and store_name drive off it, with a source-level test asserting they agree — a behaviour-only test passes on the read path alone, which is how this survived.

Worth deciding alongside: whether assigning an undeclared name inside a function should keep silently creating a local at all, or become an error. That fallback is what turns a scoping miss into a silent no-op rather than a diagnostic.

Note for whoever picks this up

The bytecode cache will make a compiler edit look inert. During this investigation the site-1 patch appeared to do nothing until .nodus/ was removed — CLAUDE.md records this trap and it fired exactly as described. rm -rf .nodus between runs.

Relationship to #156

#156 (DESIGN-006, filed 2026-06-07) says closures cannot assign to outer let variables at all. That is no longer true — function-scoped upvalue mutation works, including escaping closures. This issue is the part that is still broken, and it is a different defect with a different failure mode: not a refused assignment, a silent wrong one. #156 is being rewritten to point here.

CLAUDE.md's language-quirks section carries the same stale claim and prescribes a map-with-quoted-keys workaround that is unnecessary for the function-scoped case; corrected in the same change.

Affected versions

5.7.1 (current) and, on the evidence of #156's age, every earlier version. Not a regression.

Activity

  1. Masterplanner25 commented on Aug 30, 2026

    @Masterplanner25
    OwnerAuthor

    A candidate fix exists and the full suite is green under it — 2997 passed, 0 failed

    Not committed; recorded so the work is scoped rather than guessed at. Both sites patched together, since either alone is insufficient (measured — see below).

    Site 1, SymbolTable._resolve_upvalue_in — do not bail when there is no enclosing function scope; walk outward to module scope and return a global symbol if found:

    enclosing = self._enclosing_function_scope(func_scope)
    if enclosing is None:
        scope = func_scope.parent
        while scope:
            if name in scope.symbols and scope.symbols[name].scope == "global":
                return scope.symbols[name]
            scope = scope.parent
        return None

    Site 2, VM.store_name — mirror load_name's precedence, so an existing module global that this frame does not shadow is written there rather than captured by the frame:

    elif name not in locals_ and name in self.module_globals:
        if isinstance(self.module_globals[name], LiveBinding):
            cast("LiveBinding", self.module_globals[name]).set(value)
        else:
            self.module_globals[name] = value

    About twelve lines between them.

    Results

    result
    fn setit() { g = 99i } on top-level let g = 7i g = 99 ✔
    let setit = fn() { g = 99i } g = 99 ✔
    let bump = fn() { g = g + 1i } 1 ✔ (was Cannot add nil and int)
    full suite 2997 passed, 8 skipped, 261 subtests, 0 failed (7m18s)

    Neither site alone is enough, and this is worth recording because it is the thing that makes the bug look unfixable from one end: with only site 1, the closure compiles to a correct global STORE, the read starts resolving, and the write is still swallowed by the frame. With only site 2, the compiler never emits a STORE at all — it emits STORE_LOCAL_IDX, so the VM change is unreachable.

    The edge case that had to be checked, and holds

    STORE is emitted at 13 sites in the compiler, several of which are legitimately frame-local inside a function — catch variables, destructuring temps, match pattern bindings. A naive "always write the global" would clobber a same-named module global from any of them. Probed:

    let e = "global-e"
    let v = "global-v"
    fn risky()  { try { throw "boom" } catch e { print("caught: \(e.message)") } }
    fn temper() { let v = "local-v"; return v }
    
    caught: boom
    after catch, global e = global-e      <- not clobbered
    local-v
    after temper, global v = global-v     <- not clobbered
    

    The name not in locals_ guard is what makes that hold, and a let inside a function still compiles to STORE_LOCAL_IDX so it shadows correctly.

    What this still needs before it is a PR

    • A source-level assertion, not just behaviour. The project's own doctrine applies here more than usual: this bug is two implementations of "where does this name live" disagreeing, so a behaviour-only test passes on whichever path is already correct. The test should assert that store_name and load_name drive off the same precedence rule.
    • A regression test per row of the table above, including the negative shadowing cases, each verified to fail against the unfixed tree.
    • A decision on how to describe it in the changelog. Programs that currently silently no-op will start mutating their globals. That is the fix, and nobody can be relying on a silent no-op, but it is a behaviour change and should be named as one rather than filed under "fixes".
    • A check of whether the opcode freeze constrains anything here. It does not for this shape — no new opcode is needed — but the alternative design (a distinct global-store opcode) would need the seven-step process in FREEZE_PROPOSAL.md, so it should be ruled out explicitly rather than by omission.

    Suite runs and probes were done with .nodus/ cleared between runs; the bytecode cache makes a compiler edit here look inert, as noted above.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions