11. Bare assignment, lexical block scope, nearest-binding rule
- Status: Accepted
- Date: 2026-09-02
Context
How are variables introduced and where do they live? Options for introduction: a
declaration keyword (let/var/const), or bare assignment (x = 1 both
creates and updates). Options for scope: function scope with hoisting (old
JavaScript), block scope, or dynamic scope.
The project owner chose bare x = 5 — no let/var. That settles introduction
and forces a decision about what a bare assignment means when an outer scope
already has that name.
Decision
x = valueis the only binding form. No declaration keyword.- Scope is lexical, and only functions introduce a scope. The global level is
one scope; each function body (with its parameters) is a scope.
if/elif/else/while/forblocks do not — a name first assigned inside one of them stays visible after it, in the enclosing function or global scope. - Nearest-binding rule: evaluating
x = valuefirst looks for an existing binding ofx, searching the current function scope then each enclosing function scope out to the global scope.- Found ⇒ that binding is updated, wherever it lives.
- Not found ⇒ a new binding is created in the current scope.
- Reading
xuses the same nearest-binding search; an unresolved read isE0301. - A
forloop variable is an ordinary binding in the enclosing scope; after the loop it holds the last value iterated. - Closures capture by reference to the binding, so a closure can mutate a variable from an enclosing function.
There are no global / nonlocal keywords — the nearest-binding rule makes
"assign to the outer x" the default when an outer x exists.
Consequences
- No keyword, no "declared but unused" ceremony, closures-that-mutate just work.
- Function-only scoping matches Python and most scripting languages, so it holds
no surprises:
if cond:\n result = ...followed byprint(result)works. - You cannot shadow an outer variable by assigning the same name in an inner block — the inner assignment updates the outer one. To get a fresh variable, use a fresh name. This is the deliberate "one obvious way" tradeoff and the opposite of Python's local-by-default rule.
- A typo in an assignment target that happens to match an outer name silently mutates that name. The resolver can warn on suspicious cases later; not in M1.
- Closures over a
forvariable all see its final value (the well-known Python behaviour), because the variable is one binding, not one per iteration. If per-iteration capture is wanted, wrap the body in an immediately-called function. This may get a dedicated fix later. - A future bytecode VM will want per-use slot/depth resolution; the
resolverpass is where that goes when the time comes (ADR 0004).
Amendment (2026-09-04): resolution is lexical
Milestone 3 found that the nearest-binding rule as implemented here decides
at run time whether an assignment targets a local or an enclosing binding, so
the same line in the same function can mean different things on different calls.
That cannot be compiled to a slot. ADR 0022
narrows this ADR: the rule itself is unchanged, but "is there an enclosing
binding of this name?" is now answered from the enclosing program text rather
than from the live scope chain. It also lands the slot resolution this ADR
anticipated in the compiler rather than in the resolver pass, which stays a
validation-only pass.
Alternatives considered
- Python's rule (assignment always creates a local unless
global/nonlocal). Enables shadowing but needs two extra keywords to write to an outer scope, and theUnboundLocalErrorsurprise is a classic beginner trap. letfor new bindings,=for update (Swift-ish). Clear, but the owner explicitly wants no declaration keyword, and it's two forms for one idea.- Block scope for
if/while/forbodies. Cleaner in theory (a loop temp does not outlive the loop), but it makes the extremely common "compute a value in a branch, use it afterwards" pattern fail, which is a worse surprise than the one it prevents. Rejected. - Function scope with hoisting. Rejected outright — a known mistake.