Split env-bind! from env-set!: fix lexical scoping and closures

Two fundamental environment bugs fixed:

1. env-set! was used for both binding creation (let, define, params)
   and mutation (set!). Binding creation must NOT walk the scope chain
   — it should set on the immediate env. Only set! should walk.

   Fix: introduce env-bind! for all binding creation. env-set! now
   exclusively means "mutate existing binding, walk scope chain".
   Changed across spec (eval.sx, cek.sx, render.sx) and all web
   adapters (dom, html, sx, async, boot, orchestration, forms).

2. makeLambda/makeComponent/makeMacro/makeIsland used merge(env) to
   flatten the closure into a plain object, destroying the prototype
   chain. This meant set! inside closures couldn't reach the original
   binding — it modified a snapshot copy instead.

   Fix: store env directly as closure (no merge). The prototype chain
   is preserved, so set! walks up to the original scope.

Tests: 499/516 passing (96.7%), up from 485/516.
Fixed: define self-reference, let scope isolation, set! through
closures, counter-via-closure pattern, recursive functions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-15 11:38:35 +00:00
parent c20369b766
commit 5a03943b39
17 changed files with 188 additions and 139 deletions

View File

@@ -396,7 +396,7 @@
(let ((k (make-cek-continuation captured rest-kont)))
;; Evaluate shift body with k bound, continuation goes to rest-kont
(let ((shift-env (env-extend env)))
(env-set! shift-env k-name k)
(env-bind! shift-env k-name k)
(make-cek-state body shift-env rest-kont))))))
@@ -604,7 +604,7 @@
(body (get frame "body"))
(local (get frame "env")))
;; Bind the value
(env-set! local name value)
(env-bind! local name value)
;; More bindings?
(if (empty? remaining)
;; All bindings done — evaluate body
@@ -628,7 +628,7 @@
(effect-list (get frame "effect-list")))
(when (and (lambda? value) (nil? (lambda-name value)))
(set-lambda-name! value name))
(env-set! fenv name value)
(env-bind! fenv name value)
;; Effect annotation
(when has-effects
(let ((effect-names (if (= (type-of effect-list) "list")
@@ -640,7 +640,7 @@
(env-get fenv "*effect-annotations*")
(dict))))
(dict-set! effect-anns name effect-names)
(env-set! fenv "*effect-annotations*" effect-anns)))
(env-bind! fenv "*effect-annotations*" effect-anns)))
(make-cek-value value fenv rest-k))
;; --- SetFrame: value evaluated ---
@@ -969,10 +969,10 @@
" expects " (len params) " args, got " (len args)))
(do
(for-each
(fn (pair) (env-set! local (first pair) (nth pair 1)))
(fn (pair) (env-bind! local (first pair) (nth pair 1)))
(zip params args))
(for-each
(fn (p) (env-set! local p nil))
(fn (p) (env-bind! local p nil))
(slice params (len args)))
(make-cek-state (lambda-body f) local kont))))
@@ -983,10 +983,10 @@
(children (nth parsed 1))
(local (env-merge (component-closure f) env)))
(for-each
(fn (p) (env-set! local p (or (dict-get kwargs p) nil)))
(fn (p) (env-bind! local p (or (dict-get kwargs p) nil)))
(component-params f))
(when (component-has-children? f)
(env-set! local "children" children))
(env-bind! local "children" children))
(make-cek-state (component-body f) local kont))
:else (error (str "Not callable: " (inspect f))))))

View File

@@ -229,10 +229,10 @@
(do
;; Bind params — provided args first, then nil for missing
(for-each
(fn (pair) (env-set! local (first pair) (nth pair 1)))
(fn (pair) (env-bind! local (first pair) (nth pair 1)))
(zip params args))
(for-each
(fn (p) (env-set! local p nil))
(fn (p) (env-bind! local p nil))
(slice params (len args)))
;; Return thunk for TCO
(make-thunk (lambda-body f) local))))))
@@ -247,11 +247,11 @@
(local (env-merge (component-closure comp) env)))
;; Bind keyword params
(for-each
(fn (p) (env-set! local p (or (dict-get kwargs p) nil)))
(fn (p) (env-bind! local p (or (dict-get kwargs p) nil)))
(component-params comp))
;; Bind children if component accepts them
(when (component-has-children? comp)
(env-set! local "children" children))
(env-bind! local "children" children))
;; Return thunk — body evaluated in local env
(make-thunk (component-body comp) local))))
@@ -423,7 +423,7 @@
(let ((vname (if (= (type-of (first binding)) "symbol")
(symbol-name (first binding))
(first binding))))
(env-set! local vname (trampoline (eval-expr (nth binding 1) local)))))
(env-bind! local vname (trampoline (eval-expr (nth binding 1) local)))))
bindings)
;; Clojure-style
(let ((i 0))
@@ -433,7 +433,7 @@
(symbol-name (nth bindings (* pair-idx 2)))
(nth bindings (* pair-idx 2))))
(val-expr (nth bindings (inc (* pair-idx 2)))))
(env-set! local vname (trampoline (eval-expr val-expr local)))))
(env-bind! local vname (trampoline (eval-expr val-expr local)))))
nil
(range 0 (/ (len bindings) 2)))))
;; Evaluate body — last expression in tail position
@@ -480,7 +480,7 @@
(loop-fn (make-lambda params loop-body env)))
;; Self-reference: loop can call itself by name
(set-lambda-name! loop-fn loop-name)
(env-set! (lambda-closure loop-fn) loop-name loop-fn)
(env-bind! (lambda-closure loop-fn) loop-name loop-fn)
;; Evaluate initial values in enclosing env, then call
(let ((init-vals (map (fn (e) (trampoline (eval-expr e env))) inits)))
(call-lambda loop-fn init-vals env))))))
@@ -522,7 +522,7 @@
(value (trampoline (eval-expr (nth args val-idx) env))))
(when (and (lambda? value) (nil? (lambda-name value)))
(set-lambda-name! value (symbol-name name-sym)))
(env-set! env (symbol-name name-sym) value)
(env-bind! env (symbol-name name-sym) value)
;; Store effect annotation if declared
(when has-effects
(let ((effects-raw (nth args 2))
@@ -535,7 +535,7 @@
(env-get env "*effect-annotations*")
(dict))))
(dict-set! effect-anns (symbol-name name-sym) effect-list)
(env-set! env "*effect-annotations*" effect-anns)))
(env-bind! env "*effect-annotations*" effect-anns)))
value)))
@@ -570,8 +570,8 @@
(env-get env "*effect-annotations*")
(dict))))
(dict-set! effect-anns (symbol-name name-sym) effect-list)
(env-set! env "*effect-annotations*" effect-anns)))
(env-set! env (symbol-name name-sym) comp)
(env-bind! env "*effect-annotations*" effect-anns)))
(env-bind! env (symbol-name name-sym) comp)
comp))))
(define defcomp-kwarg
@@ -646,7 +646,7 @@
(params (first parsed))
(has-children (nth parsed 1)))
(let ((island (make-island comp-name params has-children body env)))
(env-set! env (symbol-name name-sym) island)
(env-bind! env (symbol-name name-sym) island)
island))))
@@ -659,7 +659,7 @@
(params (first parsed))
(rest-param (nth parsed 1)))
(let ((mac (make-macro params rest-param body env (symbol-name name-sym))))
(env-set! env (symbol-name name-sym) mac)
(env-bind! env (symbol-name name-sym) mac)
mac))))
(define parse-macro-params
@@ -688,7 +688,7 @@
;; (defstyle name expr) — bind name to evaluated expr (string, function, etc.)
(let ((name-sym (first args))
(value (trampoline (eval-expr (nth args 1) env))))
(env-set! env (symbol-name name-sym) value)
(env-bind! env (symbol-name name-sym) value)
value)))
@@ -749,7 +749,7 @@
(dict))))
(dict-set! registry type-name
(make-type-def type-name type-params body))
(env-set! env "*type-registry*" registry)
(env-bind! env "*type-registry*" registry)
nil))))
@@ -764,7 +764,7 @@
(list))))
(when (not (contains? registry effect-name))
(append! registry effect-name))
(env-set! env "*effect-registry*" registry)
(env-bind! env "*effect-registry*" registry)
nil)))
@@ -879,7 +879,7 @@
(first binding))))
(append! names vname)
(append! val-exprs (nth binding 1))
(env-set! local vname nil)))
(env-bind! local vname nil)))
bindings)
;; Clojure-style
(reduce
@@ -890,21 +890,21 @@
(val-expr (nth bindings (inc (* pair-idx 2)))))
(append! names vname)
(append! val-exprs val-expr)
(env-set! local vname nil)))
(env-bind! local vname nil)))
nil
(range 0 (/ (len bindings) 2))))
;; Second pass: evaluate values (they can see each other's names)
(let ((values (map (fn (e) (trampoline (eval-expr e local))) val-exprs)))
;; Bind final values
(for-each
(fn (pair) (env-set! local (first pair) (nth pair 1)))
(fn (pair) (env-bind! local (first pair) (nth pair 1)))
(zip names values))
;; Patch lambda closures so they see the final bindings
(for-each
(fn (val)
(when (lambda? val)
(for-each
(fn (n) (env-set! (lambda-closure val) n (env-get local n)))
(fn (n) (env-bind! (lambda-closure val) n (env-get local n)))
names)))
values))
;; Evaluate body
@@ -998,14 +998,14 @@
;; Bind positional params (unevaluated)
(for-each
(fn (pair)
(env-set! local (first pair)
(env-bind! local (first pair)
(if (< (nth pair 1) (len raw-args))
(nth raw-args (nth pair 1))
nil)))
(map-indexed (fn (i p) (list p i)) (macro-params mac)))
;; Bind &rest param
(when (macro-rest-param mac)
(env-set! local (macro-rest-param mac)
(env-bind! local (macro-rest-param mac)
(slice raw-args (len (macro-params mac)))))
;; Evaluate body → new AST
(trampoline (eval-expr (macro-body mac) local)))))
@@ -1153,7 +1153,8 @@
;; Environment:
;; (env-has? env name) → boolean
;; (env-get env name) → value
;; (env-set! env name val) → void (mutating)
;; (env-bind! env name val) → void (create binding on THIS env, no chain walk)
;; (env-set! env name val) → void (mutate existing binding, walks scope chain)
;; (env-extend env) → new env inheriting from env
;; (env-merge base overlay) → new env with overlay on top
;;

View File

@@ -184,7 +184,7 @@
(let ((name (if (= (type-of (first pair)) "symbol")
(symbol-name (first pair))
(str (first pair)))))
(env-set! local name (trampoline (eval-expr (nth pair 1) local))))))
(env-bind! local name (trampoline (eval-expr (nth pair 1) local))))))
bindings)
local)))