Skip to content

DESIGN-006: Closures cannot assign to outer let variables — no upvalue mutation #156

Description

@Masterplanner25

Summary

Attempting to assign to an outer let variable inside a nested function creates a new local, silently shadowing the outer. Error message when arithmetic fails is indirect: "Cannot add nil and int" (no reference to shadowing).

Reproduction

let count = 0i
fn increment() {
    count = count + 1i  // creates a new local 'count', doesn't mutate outer
}
increment()
print(count)  // prints 0, not 1 — no error, just wrong behavior

Workaround:

let state = {"count": 0i}
fn increment() {
    state["count"] = state["count"] + 1i  // map mutation works
}

Expected behavior

Either upvalue mutation should work (like Python nonlocal), or the attempt to assign an outer variable should produce a clear error: "Cannot assign to outer variable 'count' — use a map to share mutable state between functions."

Fix direction

Two options:

  1. Implement upvalue mutation (close over by reference, not value) — significant VM change, v5
  2. Detect the shadowing at compile time and emit a clear error or warning

Deferred to v5.

Affected versions

v4.0.0 (current when filed).

Activity

  1. added this to the v5.0 milestone on Jun 8, 2026
  2. Masterplanner25 commented on Aug 17, 2026

    @Masterplanner25
    OwnerAuthor

    Triage 2026-08-17 (against v5.0.2). Still reproduces, with the exact error this issue quotes:

    $ nodus run c1.nd
    Type error at c1.nd:2:34: Cannot add nil and int
    

    from

    let count = 0i
    let inc = fn() { count = count + 1i }
    inc()
    

    Unchanged through 5.0.2. Note this is the same defect as #177 — see the note there.

  3. removed this from the v5.0 milestone on Aug 25, 2026
  4. Masterplanner25 commented on Aug 30, 2026

    @Masterplanner25
    OwnerAuthor

    Half of this is fixed; the other half is real and now has a root cause. Superseded by #671.

    Re-verified against main at 69cd15c (5.7.1 dev source) by running each case, not by reading the code.

    The title and summary are no longer true

    "Closures cannot assign to outer let variables — no upvalue mutation." Upvalue mutation works, and has for some time:

    fn make_counter() {
        let n = 0i
        return fn() { n = n + 1i; return n }
    }
    
    a: 1
    b: 2
    

    Also verified working: two closures sharing one captured variable (acc: 12), a two-level nested closure (two-level: 100), mutation from inside a spawned coroutine (from coroutine: 42), and compound assignment (n += 5i twice → 10). STORE_UPVALUE exists in the frozen opcode set and is exercised.

    So the general claim, and the "significant VM change" framing in the fix direction, are both stale. The MAKE_CLOSURE / Cell-boxing machinery this issue asks for is built.

    But this issue's own reproduction still fails — because it is top-level

    let count = 0i
    fn increment() { count = count + 1i }
    increment()
    print(count)      // 0
    

    That is not a closure over a function-local. count is a module-scope let, and that case is still broken — silently, exactly as described here. The behaviour you documented was real; the diagnosis generalised one scope too far.

    The distinction is sharp:

    assignment works?
    let inside a function, mutated by a closure yes
    let at module top level, assigned from any function no — silent no-op
    let at module top level, read from a function yes
    let at module top level, mutated at top level yes

    Root cause, which this issue did not have

    Two sites, and they disagree — the shape CLAUDE.md catalogues.

    1. SymbolTable._resolve_upvalue_in returns None immediately when there is no enclosing function scope, so a module-level let is invisible from a top-level function — even though the walk directly beneath it handles scope == "global" correctly and would return the right symbol if reached. Assign then falls back to self.symbols.define(name), creating a local. The disassembly shows STORE_LOCAL_IDX 0 inside the function against STORE g at top level.

    2. VM.store_name writes into the current frame's locals whenever a frame exists. So even with site 1 patched — confirmed by experiment — the compiler emits a correct global STORE, the read starts resolving, and the write is still swallowed. load_name walks locals → module_globals → functions → host_globals; store_name stops at locals. Read and write are asymmetric, which is precisely why reads work and writes vanish.

    Where this leaves the two fix options proposed here

    • Option 1, "implement upvalue mutation — significant VM change, v5": done, and it was not what this needed.
    • Option 2, "detect the shadowing at compile time and emit a clear error": still open, and A function assigning to a module-top-level let silently writes a frame-local; the global never changes #671 raises it as a question worth deciding — whether assigning an undeclared name inside a function should keep silently creating a local at all. That fallback is what converts a scoping miss into a silent no-op instead of a diagnostic.

    The suggested error text here ("use a map to share mutable state between functions") should not be adopted as-is: the map workaround is unnecessary for the function-scoped case, which works natively. CLAUDE.md's language-quirks section carries the same stale advice and is being corrected in the same change.

    Closing as superseded

    #671 carries the surviving defect with the reproduction, the disassembly, both root-cause sites, and the note that the bytecode cache makes a compiler edit here look inert. Keeping both open would be two issues for one bug; closing this one rather than rewriting it keeps the history of what was believed and when.

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

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions