STAGE 2 / Language semantics · LESSON 08
Where you are
You can write small programs. This stage asks what their execution means. Begin with names: which binding does an expression resolve, and which state does an assignment change?
Language contract
Top-level script bindings live in global scope. Functions and control-flow blocks introduce lexical scopes. Duplicate declarations in the same scope are rejected; a declaration in a nested scope may shadow an outer one. Name resolution chooses the nearest visible binding.
Closures capture snapshots of the nearest visible lexical bindings. Parent assignment after capture does not update an existing closure. Aliases and repeated calls of the same closure share captured cells; separate closures have independent snapshots. Captured mutability is retained: capturing a let or const binding does not make it mutable. This is not a universal shared-memory model for asynchronous work.
Read the example
The inner label deliberately hides the outer label. The closure created there resolves the inner name; leaving the block exposes the original outer name again. This is shadowing, not mutation. The unchanged outer value is part of the test.
For accumulated state, declare a mutable binding at the scope where the result should survive. Reassign that existing binding rather than accidentally creating a new declaration with the same name. In larger functions, use different names when shadowing would make the intent difficult to follow.
VM drill
Run this example normally and with the interpreter flag. Compare the output, but do not conclude that every closure scenario is identical because this one passes. A parity test establishes agreement only for the behavior it exercises. A test that calls a closure immediately does not prove how detached work observes later updates.
Professional pattern
Prefer explicit context dictionaries for callbacks that cross module or scheduling boundaries. Keep captured state small. Return results to the owner rather than coordinating a workflow through hidden mutable globals. When state must be shared, choose a documented coordination primitive and test the lifecycle.
Common mistakes
An assignment to a mutable outer name and a new inner declaration are not interchangeable. Duplicate declarations are not a way to reset a variable. A name that prints successfully inside a block may be undefined outside it.
Working example
let label := "outer"
if true {
let label := "inner"
let captured := func() { return label }
assert_equal(captured(), "inner")
print(captured())
}
assert_equal(label, "outer")
print(label)
Run it
From the course repository root, use the pinned Kujo 1.9.0 runtime.
kujo check examples/08.kujo
kujo run --untrusted examples/08.kujo
Captured output
inner
outer
Break it and diagnose it
Two declarations target the same lexical scope. This is a duplicate declaration, not valid shadowing.
let label := "first"
let label := "second"
kujo run --untrusted examples/08-break.kujo
Exit status: 4. Captured diagnostic:
[KUJOVM001] [vm] Runtime Error: Duplicate declaration in the same scope: label
--> 0:0
Exercise
Create a nested function with two visible names and predict which binding each reference uses. Compare VM and interpreter output. Refactor one capture into an explicit parameter and explain the improved boundary.
Checkpoint
- I can trace nearest-binding resolution.
- I distinguish shadowing from reassignment.
- I keep parity claims limited to tested behavior.
Release boundary drill
Kujo 1.4.0 fixes repeated loop-local declarations: the loop_declaration probe now prints 1 and 2 in both runtimes. The same probe failed on the second VM iteration in 1.3.1. Duplicate declarations within one scope remain errors. See the current and historical results in the evidence ledger.
Caller locals are not lexical context
Kujo 1.5.0 fixes interpreter calls that could read or overwrite unrelated caller-local bindings. A top-level function sees globals and its own locals; a nested function retains its captured lexical environment. This applies to direct, indirect, callback, and pipe calls.
examples/supplemental/v1-5-scope.kujo declares a global label and shadows it in the caller. The top-level lookup still returns the global label. The course verifies that assertion in both runtimes. Pass a value explicitly when the function should depend on the caller's data.
Verified 1.5.0 example
let scope_label := "global"
func lookup() { return scope_label }
func caller() {
let scope_label := "caller"
return lookup()
}
assert(caller() == "global")
print("lexical scope verified")
Download this example. The repository release verifier supplies its fixtures and records success and failure diagnostics.
Captured state drill for 1.6
Run the closure regression in the VM and interpreter. It checks repeated calls, an alias of the same closure, and an independent factory result. The course records exact matching output. Use explicit task/shared-state contracts when crossing scheduling boundaries.
Verified 2026-10-22 · Official Kujo 1.9.0 release · Source contract · Download example