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

@@ -229,7 +229,7 @@
;; Build env: closure + caller env + params
(let ((local (env-merge (component-closure comp) env)))
(for-each
(fn (p) (env-set! local p (if (dict-has? kwargs p) (dict-get kwargs p) nil)))
(fn (p) (env-bind! local p (if (dict-has? kwargs p) (dict-get kwargs p) nil)))
(component-params comp))
;; Pre-render children to raw HTML
(when (component-has-children? comp)
@@ -237,7 +237,7 @@
(for-each
(fn (c) (append! parts (async-render c env ctx)))
children)
(env-set! local "children"
(env-bind! local "children"
(make-raw-html (join "" parts)))))
(async-render (component-body comp) local ctx)))))
@@ -254,7 +254,7 @@
(let ((local (env-merge (component-closure island) env))
(island-name (component-name island)))
(for-each
(fn (p) (env-set! local p (if (dict-has? kwargs p) (dict-get kwargs p) nil)))
(fn (p) (env-bind! local p (if (dict-has? kwargs p) (dict-get kwargs p) nil)))
(component-params island))
;; Pre-render children
(when (component-has-children? island)
@@ -262,7 +262,7 @@
(for-each
(fn (c) (append! parts (async-render c env ctx)))
children)
(env-set! local "children"
(env-bind! local "children"
(make-raw-html (join "" parts)))))
(let ((body-html (async-render (component-body island) local ctx))
(state-json (serialize-island-state kwargs)))
@@ -283,7 +283,7 @@
(fn ((f :as lambda) (args :as list) (env :as dict) ctx)
(let ((local (env-merge (lambda-closure f) env)))
(for-each-indexed
(fn (i p) (env-set! local p (nth args i)))
(fn (i p) (env-bind! local p (nth args i)))
(lambda-params f))
(async-render (lambda-body f) local ctx))))
@@ -517,7 +517,7 @@
(let ((name (if (= (type-of (first pair)) "symbol")
(symbol-name (first pair))
(str (first pair)))))
(env-set! local name (async-eval (nth pair 1) local ctx)))))
(env-bind! local name (async-eval (nth pair 1) local ctx)))))
bindings)
;; Clojure-style: (name val name val ...)
(async-process-bindings-flat bindings local ctx)))
@@ -538,7 +538,7 @@
(symbol-name item)
(str item))))
(when (< (inc i) (len bindings))
(env-set! local name
(env-bind! local name
(async-eval (nth bindings (inc i)) local ctx))))
(set! skip true)
(set! i (inc i)))))
@@ -735,7 +735,7 @@
(lambda? f)
(let ((local (env-merge (lambda-closure f) env)))
(for-each-indexed
(fn (i p) (env-set! local p (nth evaled-args i)))
(fn (i p) (env-bind! local p (nth evaled-args i)))
(lambda-params f))
(async-aser (lambda-body f) local ctx))
(component? f)
@@ -807,7 +807,7 @@
(async-parse-aser-kw-args args kwargs children env ctx)
(let ((local (env-merge (component-closure comp) env)))
(for-each
(fn (p) (env-set! local p (if (dict-has? kwargs p) (dict-get kwargs p) nil)))
(fn (p) (env-bind! local p (if (dict-has? kwargs p) (dict-get kwargs p) nil)))
(component-params comp))
(when (component-has-children? comp)
(let ((child-parts (list)))
@@ -823,7 +823,7 @@
(when (not (nil? result))
(append! child-parts (serialize result))))))
children)
(env-set! local "children"
(env-bind! local "children"
(make-sx-expr (str "(<> " (join " " child-parts) ")")))))
(async-aser (component-body comp) local ctx)))))
@@ -1033,7 +1033,7 @@
;; set!
(= name "set!")
(let ((value (async-eval (nth args 1) env ctx)))
(env-set! env (symbol-name (first args)) value)
(env-bind! env (symbol-name (first args)) value)
value)
;; map
@@ -1197,7 +1197,7 @@
(lambda? f)
(let ((local (env-merge (lambda-closure f) env)))
(for-each-indexed
(fn (i p) (env-set! local p (nth args i)))
(fn (i p) (env-bind! local p (nth args i)))
(lambda-params f))
(async-eval (lambda-body f) local ctx))
:else
@@ -1217,7 +1217,7 @@
(fn (item)
(if (lambda? f)
(let ((local (env-merge (lambda-closure f) env)))
(env-set! local (first (lambda-params f)) item)
(env-bind! local (first (lambda-params f)) item)
(append! results (async-aser (lambda-body f) local ctx)))
(append! results (async-invoke f item))))
coll)
@@ -1234,8 +1234,8 @@
(fn (item)
(if (lambda? f)
(let ((local (env-merge (lambda-closure f) env)))
(env-set! local (first (lambda-params f)) i)
(env-set! local (nth (lambda-params f) 1) item)
(env-bind! local (first (lambda-params f)) i)
(env-bind! local (nth (lambda-params f) 1) item)
(append! results (async-aser (lambda-body f) local ctx)))
(append! results (async-invoke f i item)))
(set! i (inc i)))
@@ -1252,7 +1252,7 @@
(fn (item)
(if (lambda? f)
(let ((local (env-merge (lambda-closure f) env)))
(env-set! local (first (lambda-params f)) item)
(env-bind! local (first (lambda-params f)) item)
(append! results (async-aser (lambda-body f) local ctx)))
(append! results (async-invoke f item))))
coll)

View File

@@ -307,7 +307,7 @@
;; Bind params from kwargs
(for-each
(fn (p)
(env-set! local p (if (dict-has? kwargs p) (dict-get kwargs p) nil)))
(env-bind! local p (if (dict-has? kwargs p) (dict-get kwargs p) nil)))
(component-params comp))
;; If component accepts children, pre-render them to a fragment
@@ -320,7 +320,7 @@
(when (not (spread? result))
(dom-append child-frag result))))
children)
(env-set! local "children" child-frag)))
(env-bind! local "children" child-frag)))
(render-to-dom (component-body comp) local ns)))))
@@ -687,7 +687,7 @@
(let ((local (env-merge (lambda-closure f) env)))
(for-each-indexed
(fn (i p)
(env-set! local p (nth args i)))
(env-bind! local p (nth args i)))
(lambda-params f))
(render-to-dom (lambda-body f) local ns))))
@@ -734,7 +734,7 @@
;; Bind params from kwargs
(for-each
(fn (p)
(env-set! local p (if (dict-has? kwargs p) (dict-get kwargs p) nil)))
(env-bind! local p (if (dict-has? kwargs p) (dict-get kwargs p) nil)))
(component-params island))
;; If island accepts children, pre-render them to a fragment
@@ -743,7 +743,7 @@
(for-each
(fn (c) (dom-append child-frag (render-to-dom c env ns)))
children)
(env-set! local "children" child-frag)))
(env-bind! local "children" child-frag)))
;; Create the island container element
(let ((container (dom-create-element "span" nil))

View File

@@ -277,7 +277,7 @@
(let ((local (env-merge (lambda-closure f) env)))
(for-each-indexed
(fn (i p)
(env-set! local p (nth args i)))
(env-bind! local p (nth args i)))
(lambda-params f))
(render-to-html (lambda-body f) local))))
@@ -315,11 +315,11 @@
;; Bind params from kwargs
(for-each
(fn (p)
(env-set! local p (if (dict-has? kwargs p) (dict-get kwargs p) nil)))
(env-bind! local p (if (dict-has? kwargs p) (dict-get kwargs p) nil)))
(component-params comp))
;; If component accepts children, pre-render them to raw HTML
(when (component-has-children? comp)
(env-set! local "children"
(env-bind! local "children"
(make-raw-html (join "" (map (fn (c) (render-to-html c env)) children)))))
(render-to-html (component-body comp) local)))))
@@ -481,12 +481,12 @@
;; Bind params from kwargs
(for-each
(fn (p)
(env-set! local p (if (dict-has? kwargs p) (dict-get kwargs p) nil)))
(env-bind! local p (if (dict-has? kwargs p) (dict-get kwargs p) nil)))
(component-params island))
;; If island accepts children, pre-render them to raw HTML
(when (component-has-children? island)
(env-set! local "children"
(env-bind! local "children"
(make-raw-html (join "" (map (fn (c) (render-to-html c env)) children)))))
;; Render the island body as HTML

View File

@@ -289,7 +289,7 @@
(map (fn (item)
(if (lambda? f)
(let ((local (env-merge (lambda-closure f) env)))
(env-set! local (first (lambda-params f)) item)
(env-bind! local (first (lambda-params f)) item)
(aser (lambda-body f) local))
(cek-call f (list item))))
coll))
@@ -301,8 +301,8 @@
(map-indexed (fn (i item)
(if (lambda? f)
(let ((local (env-merge (lambda-closure f) env)))
(env-set! local (first (lambda-params f)) i)
(env-set! local (nth (lambda-params f) 1) item)
(env-bind! local (first (lambda-params f)) i)
(env-bind! local (nth (lambda-params f) 1) item)
(aser (lambda-body f) local))
(cek-call f (list i item))))
coll))
@@ -315,7 +315,7 @@
(for-each (fn (item)
(if (lambda? f)
(let ((local (env-merge (lambda-closure f) env)))
(env-set! local (first (lambda-params f)) item)
(env-bind! local (first (lambda-params f)) item)
(append! results (aser (lambda-body f) local)))
(cek-call f (list item))))
coll)

View File

@@ -361,7 +361,7 @@
;; Bind params from kwargs
(for-each
(fn ((p :as string))
(env-set! local p (if (dict-has? kwargs p) (dict-get kwargs p) nil)))
(env-bind! local p (if (dict-has? kwargs p) (dict-get kwargs p) nil)))
(component-params comp))
;; Render the island body in a reactive scope

View File

@@ -98,7 +98,7 @@
(params (get parsed "params"))
(body (get parsed "body")))
(let ((hdef (make-handler-def name params body env opts)))
(env-set! env (str "handler:" name) hdef)
(env-bind! env (str "handler:" name) hdef)
hdef))))
@@ -117,7 +117,7 @@
(doc (if has-doc (nth args 2) ""))
(body (if has-doc (nth args 3) (nth args 2))))
(let ((qdef (make-query-def name params doc body env)))
(env-set! env (str "query:" name) qdef)
(env-bind! env (str "query:" name) qdef)
qdef))))
@@ -135,7 +135,7 @@
(doc (if has-doc (nth args 2) ""))
(body (if has-doc (nth args 3) (nth args 2))))
(let ((adef (make-action-def name params doc body env)))
(env-set! env (str "action:" name) adef)
(env-bind! env (str "action:" name) adef)
adef))))
@@ -163,7 +163,7 @@
(nth args (+ idx 1))))))
(range 1 max-i 2)))
(let ((pdef (make-page-def name slots env)))
(env-set! env (str "page:" name) pdef)
(env-bind! env (str "page:" name) pdef)
pdef))))
@@ -266,7 +266,7 @@
(bindings (stream-chunk-bindings chunk)))
(for-each
(fn ((key :as string))
(env-set! env (normalize-binding-key key)
(env-bind! env (normalize-binding-key key)
(get bindings key)))
(keys bindings))
env)))

View File

@@ -1103,9 +1103,9 @@
(dom-listen el event-name
(fn (e)
(let ((handler-env (env-extend (dict))))
(env-set! handler-env "event" e)
(env-set! handler-env "this" el)
(env-set! handler-env "detail" (event-detail e))
(env-bind! handler-env "event" e)
(env-bind! handler-env "this" el)
(env-bind! handler-env "detail" (event-detail e))
(for-each
(fn (expr) (eval-expr expr handler-env))
exprs))))))))))

View File

@@ -39,7 +39,7 @@
(defsuite "deref signal without reactive-reset"
(deftest "deref signal returns current value"
(let ((s (signal 99)))
(env-set! (test-env) "test-sig" s)
(env-bind! (test-env) "test-sig" s)
(let ((result (eval-expr-cek
(sx-parse-one "(deref test-sig)")
(test-env))))
@@ -47,7 +47,7 @@
(deftest "deref signal in expression returns computed value"
(let ((s (signal 10)))
(env-set! (test-env) "test-sig" s)
(env-bind! (test-env) "test-sig" s)
(let ((result (eval-expr-cek
(sx-parse-one "(+ 5 (deref test-sig))")
(test-env))))
@@ -67,7 +67,7 @@
(make-cek-state
(sx-parse-one "(deref test-sig)")
(let ((e (env-extend (test-env))))
(env-set! e "test-sig" s)
(env-bind! e "test-sig" s)
e)
(list (make-reactive-reset-frame
(test-env)
@@ -83,7 +83,7 @@
;; Set up reactive-reset with tracking update-fn
(scope-push! "sx-island-scope" nil)
(let ((e (env-extend (test-env))))
(env-set! e "test-sig" s)
(env-bind! e "test-sig" s)
(cek-run
(make-cek-state
(sx-parse-one "(deref test-sig)")
@@ -107,7 +107,7 @@
(update-calls (list)))
(scope-push! "sx-island-scope" nil)
(let ((e (env-extend (test-env))))
(env-set! e "test-sig" s)
(env-bind! e "test-sig" s)
;; (str "val=" (deref test-sig)) — continuation captures (str "val=" [HOLE])
(let ((result (cek-run
(make-cek-state
@@ -137,7 +137,7 @@
;; Create island scope with collector that accumulates disposers
(scope-push! "sx-island-scope" (fn (d) (append! disposers d)))
(let ((e (env-extend (test-env))))
(env-set! e "test-sig" s)
(env-bind! e "test-sig" s)
(cek-run
(make-cek-state
(sx-parse-one "(deref test-sig)")
@@ -266,7 +266,7 @@
(deftest "for-each through CEK"
(let ((log (list)))
(env-set! (test-env) "test-log" log)
(env-bind! (test-env) "test-log" log)
(eval-expr-cek
(sx-parse-one "(for-each (fn (x) (append! test-log x)) (list 1 2 3))")
(test-env))