Remove invoke from platform interfaces, add cek-call integration tests

- platform_js.py: remove invoke function definition and PRIMITIVES
  registration, switch domListen handler wrapping to cek-call
- platform_py.py: remove invoke function definition
- run_signal_tests.py: remove invoke patch, use cek_call in batch wrapper
- run_cek_reactive_tests.py: remove invoke, fix primitive lookup to use
  two-level is_primitive/get_primitive, increase recursion limit for
  interpreted CEK evaluation
- test-cek-reactive.sx: add 7 new tests covering cek-call dispatch with
  computed, effect, cleanup, batch coalescing

All 79 tests pass (20 signal + 43 CEK + 16 CEK reactive).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-14 10:29:32 +00:00
parent 6581211a10
commit d0a5ce1070
7 changed files with 70 additions and 70 deletions

View File

@@ -14,7 +14,7 @@
// ========================================================================= // =========================================================================
var NIL = Object.freeze({ _nil: true, toString: function() { return "nil"; } }); var NIL = Object.freeze({ _nil: true, toString: function() { return "nil"; } });
var SX_VERSION = "2026-03-14T10:16:03Z"; var SX_VERSION = "2026-03-14T10:27:39Z";
function isNil(x) { return x === NIL || x === null || x === undefined; } function isNil(x) { return x === NIL || x === null || x === undefined; }
function isSxTruthy(x) { return x !== false && !isNil(x); } function isSxTruthy(x) { return x !== false && !isNil(x); }
@@ -218,17 +218,6 @@
return new Island(name, params, hasChildren, body, merge(env)); return new Island(name, params, hasChildren, body, merge(env));
} }
// invoke — call any callable (native fn or SX lambda) with args.
// Transpiled code emits direct calls f(args) which fail on SX lambdas
// from runtime-evaluated island bodies. invoke dispatches correctly.
function invoke() {
var f = arguments[0];
var args = Array.prototype.slice.call(arguments, 1);
if (isLambda(f)) return trampoline(callLambda(f, args, lambdaClosure(f)));
if (typeof f === 'function') return f.apply(null, args);
return NIL;
}
// JSON / dict helpers for island state serialization // JSON / dict helpers for island state serialization
function jsonSerialize(obj) { function jsonSerialize(obj) {
return JSON.stringify(obj); return JSON.stringify(obj);
@@ -5192,8 +5181,8 @@ return (function() {
// If lambda takes 0 params, call without event arg (convenience for on-click handlers) // If lambda takes 0 params, call without event arg (convenience for on-click handlers)
var wrapped = isLambda(handler) var wrapped = isLambda(handler)
? (lambdaParams(handler).length === 0 ? (lambdaParams(handler).length === 0
? function(e) { try { invoke(handler); } catch(err) { console.error("[sx-ref] domListen handler error:", name, err); } } ? function(e) { try { cekCall(handler, NIL); } catch(err) { console.error("[sx-ref] domListen handler error:", name, err); } }
: function(e) { try { invoke(handler, e); } catch(err) { console.error("[sx-ref] domListen handler error:", name, err); } }) : function(e) { try { cekCall(handler, [e]); } catch(err) { console.error("[sx-ref] domListen handler error:", name, err); } })
: handler; : handler;
if (name === "click") logInfo("domListen: click on <" + (el.tagName||"?").toLowerCase() + "> text=" + (el.textContent||"").substring(0,20) + " isLambda=" + isLambda(handler)); if (name === "click") logInfo("domListen: click on <" + (el.tagName||"?").toLowerCase() + "> text=" + (el.textContent||"").substring(0,20) + " isLambda=" + isLambda(handler));
el.addEventListener(name, wrapped); el.addEventListener(name, wrapped);
@@ -6393,7 +6382,6 @@ return (function() {
PRIMITIVES["stop-propagation"] = stopPropagation_; PRIMITIVES["stop-propagation"] = stopPropagation_;
PRIMITIVES["error-message"] = errorMessage; PRIMITIVES["error-message"] = errorMessage;
PRIMITIVES["schedule-idle"] = scheduleIdle; PRIMITIVES["schedule-idle"] = scheduleIdle;
PRIMITIVES["invoke"] = invoke;
PRIMITIVES["error"] = function(msg) { throw new Error(msg); }; PRIMITIVES["error"] = function(msg) { throw new Error(msg); };
PRIMITIVES["filter"] = filter; PRIMITIVES["filter"] = filter;
// DOM primitives for sx-on:* handlers and data-init scripts // DOM primitives for sx-on:* handlers and data-init scripts

View File

@@ -1250,17 +1250,6 @@ PLATFORM_JS_PRE = '''
return new Island(name, params, hasChildren, body, merge(env)); return new Island(name, params, hasChildren, body, merge(env));
} }
// invoke — call any callable (native fn or SX lambda) with args.
// Transpiled code emits direct calls f(args) which fail on SX lambdas
// from runtime-evaluated island bodies. invoke dispatches correctly.
function invoke() {
var f = arguments[0];
var args = Array.prototype.slice.call(arguments, 1);
if (isLambda(f)) return trampoline(callLambda(f, args, lambdaClosure(f)));
if (typeof f === 'function') return f.apply(null, args);
return NIL;
}
// JSON / dict helpers for island state serialization // JSON / dict helpers for island state serialization
function jsonSerialize(obj) { function jsonSerialize(obj) {
return JSON.stringify(obj); return JSON.stringify(obj);
@@ -1809,8 +1798,8 @@ PLATFORM_DOM_JS = """
// If lambda takes 0 params, call without event arg (convenience for on-click handlers) // If lambda takes 0 params, call without event arg (convenience for on-click handlers)
var wrapped = isLambda(handler) var wrapped = isLambda(handler)
? (lambdaParams(handler).length === 0 ? (lambdaParams(handler).length === 0
? function(e) { try { invoke(handler); } catch(err) { console.error("[sx-ref] domListen handler error:", name, err); } } ? function(e) { try { cekCall(handler, NIL); } catch(err) { console.error("[sx-ref] domListen handler error:", name, err); } }
: function(e) { try { invoke(handler, e); } catch(err) { console.error("[sx-ref] domListen handler error:", name, err); } }) : function(e) { try { cekCall(handler, [e]); } catch(err) { console.error("[sx-ref] domListen handler error:", name, err); } })
: handler; : handler;
if (name === "click") logInfo("domListen: click on <" + (el.tagName||"?").toLowerCase() + "> text=" + (el.textContent||"").substring(0,20) + " isLambda=" + isLambda(handler)); if (name === "click") logInfo("domListen: click on <" + (el.tagName||"?").toLowerCase() + "> text=" + (el.textContent||"").substring(0,20) + " isLambda=" + isLambda(handler));
el.addEventListener(name, wrapped); el.addEventListener(name, wrapped);
@@ -3023,7 +3012,6 @@ def fixups_js(has_html, has_sx, has_dom, has_signals=False, has_deps=False, has_
PRIMITIVES["stop-propagation"] = stopPropagation_; PRIMITIVES["stop-propagation"] = stopPropagation_;
PRIMITIVES["error-message"] = errorMessage; PRIMITIVES["error-message"] = errorMessage;
PRIMITIVES["schedule-idle"] = scheduleIdle; PRIMITIVES["schedule-idle"] = scheduleIdle;
PRIMITIVES["invoke"] = invoke;
PRIMITIVES["error"] = function(msg) { throw new Error(msg); }; PRIMITIVES["error"] = function(msg) { throw new Error(msg); };
PRIMITIVES["filter"] = filter; PRIMITIVES["filter"] = filter;
// DOM primitives for sx-on:* handlers and data-init scripts // DOM primitives for sx-on:* handlers and data-init scripts

View File

@@ -467,15 +467,6 @@ def is_identical(a, b):
def invoke(f, *args):
"""Call f with args — handles both native callables and SX lambdas.
In Python, all transpiled lambdas are natively callable, so this is
just a direct call. The JS host needs dispatch logic here because
SX lambdas from runtime-evaluated code are objects, not functions.
"""
return f(*args)
def json_serialize(obj): def json_serialize(obj):
import json import json

View File

@@ -6,6 +6,7 @@ import os, sys
_HERE = os.path.dirname(os.path.abspath(__file__)) _HERE = os.path.dirname(os.path.abspath(__file__))
_PROJECT = os.path.abspath(os.path.join(_HERE, "..", "..", "..")) _PROJECT = os.path.abspath(os.path.join(_HERE, "..", "..", ".."))
sys.path.insert(0, _PROJECT) sys.path.insert(0, _PROJECT)
sys.setrecursionlimit(20000)
from shared.sx.parser import parse_all from shared.sx.parser import parse_all
from shared.sx.ref import sx_ref from shared.sx.ref import sx_ref
@@ -138,8 +139,8 @@ env["env-merge"] = env_merge
env["symbol-name"] = lambda s: s.name if isinstance(s, Symbol) else str(s) env["symbol-name"] = lambda s: s.name if isinstance(s, Symbol) else str(s)
env["keyword-name"] = lambda k: k.name if isinstance(k, Keyword) else str(k) env["keyword-name"] = lambda k: k.name if isinstance(k, Keyword) else str(k)
env["type-of"] = sx_ref.type_of env["type-of"] = sx_ref.type_of
env["primitive?"] = lambda n: n in sx_ref.PRIMITIVES env["primitive?"] = sx_ref.is_primitive
env["get-primitive"] = lambda n: sx_ref.PRIMITIVES.get(n) env["get-primitive"] = sx_ref.get_primitive
env["strip-prefix"] = lambda s, p: s[len(p):] if s.startswith(p) else s env["strip-prefix"] = lambda s, p: s[len(p):] if s.startswith(p) else s
env["inspect"] = repr env["inspect"] = repr
env["debug-log"] = lambda *args: None env["debug-log"] = lambda *args: None
@@ -201,16 +202,6 @@ env["call-thunk"] = lambda f, e: f() if callable(f) else trampoline(eval_expr([f
env["dict-get"] = lambda d, k: d.get(k, NIL) if isinstance(d, dict) else NIL env["dict-get"] = lambda d, k: d.get(k, NIL) if isinstance(d, dict) else NIL
env["identical?"] = lambda a, b: a is b env["identical?"] = lambda a, b: a is b
def _invoke(f, *args):
"""Call f with args — handles both native callables and SX lambdas."""
if isinstance(f, Lambda):
return trampoline(sx_ref.call_lambda(f, list(args), env))
elif callable(f):
return f(*args)
return NIL
env["invoke"] = _invoke
# defhandler, defpage, defquery, defaction stubs # defhandler, defpage, defquery, defaction stubs
for name in ["sf-defhandler", "sf-defpage", "sf-defquery", "sf-defaction"]: for name in ["sf-defhandler", "sf-defpage", "sf-defquery", "sf-defaction"]:
pyname = name.replace("-", "_") pyname = name.replace("-", "_")

View File

@@ -1,7 +1,7 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Run test-signals.sx using the bootstrapped evaluator with signal primitives. """Run test-signals.sx using the bootstrapped evaluator with signal primitives.
Uses bootstrapped signal functions from sx_ref.py directly, patching invoke Uses bootstrapped signal functions from sx_ref.py directly, patching apply
to handle SX lambdas from the interpreter (test expressions create lambdas to handle SX lambdas from the interpreter (test expressions create lambdas
that need evaluator dispatch). that need evaluator dispatch).
""" """
@@ -25,14 +25,9 @@ sx_ref.trampoline = trampoline
# Build env with primitives # Build env with primitives
env = make_env() env = make_env()
# --- Patch invoke and apply BEFORE anything else --- # --- Patch apply BEFORE anything else ---
# Test expressions create SX Lambdas that bootstrapped code calls via invoke/apply. # Test expressions create SX Lambdas that bootstrapped code calls via apply.
# Patch the module-level functions so all bootstrapped functions see them. # Patch the module-level function so all bootstrapped functions see it.
def _invoke(f, *args):
if isinstance(f, Lambda):
return trampoline(eval_expr([f] + list(args), env))
return f(*args)
sx_ref.__dict__["invoke"] = _invoke
# apply is used by swap! and other forms to call functions with arg lists # apply is used by swap! and other forms to call functions with arg lists
def _apply(f, args): def _apply(f, args):
@@ -124,7 +119,7 @@ env["effect"] = sx_ref.effect
# Wrap it to work correctly in the test context. # Wrap it to work correctly in the test context.
def _batch(thunk): def _batch(thunk):
sx_ref._batch_depth = getattr(sx_ref, '_batch_depth', 0) + 1 sx_ref._batch_depth = getattr(sx_ref, '_batch_depth', 0) + 1
_invoke(thunk) sx_ref.cek_call(thunk, None)
sx_ref._batch_depth -= 1 sx_ref._batch_depth -= 1
if sx_ref._batch_depth == 0: if sx_ref._batch_depth == 0:
queue = list(sx_ref._batch_queue) queue = list(sx_ref._batch_queue)
@@ -145,7 +140,6 @@ env["flush-subscribers"] = sx_ref.flush_subscribers
env["dispose-computed"] = sx_ref.dispose_computed env["dispose-computed"] = sx_ref.dispose_computed
env["with-island-scope"] = sx_ref.with_island_scope env["with-island-scope"] = sx_ref.with_island_scope
env["register-in-scope"] = sx_ref.register_in_scope env["register-in-scope"] = sx_ref.register_in_scope
env["invoke"] = _invoke
env["callable?"] = sx_ref.is_callable env["callable?"] = sx_ref.is_callable
# Load test framework # Load test framework

View File

@@ -426,15 +426,6 @@ def is_identical(a, b):
def invoke(f, *args):
"""Call f with args — handles both native callables and SX lambdas.
In Python, all transpiled lambdas are natively callable, so this is
just a direct call. The JS host needs dispatch logic here because
SX lambdas from runtime-evaluated code are objects, not functions.
"""
return f(*args)
def json_serialize(obj): def json_serialize(obj):
import json import json

View File

@@ -152,3 +152,60 @@
;; Change signal — no update should fire ;; Change signal — no update should fire
(reset! s 999) (reset! s 999)
(assert-equal 0 (len update-calls))))) (assert-equal 0 (len update-calls)))))
;; --------------------------------------------------------------------------
;; cek-call integration — computed/effect use cek-call dispatch
;; --------------------------------------------------------------------------
(defsuite "cek-call dispatch"
(deftest "cek-call invokes native function"
(let ((log (list)))
(cek-call (fn (x) (append! log x)) (list 42))
(assert-equal (list 42) log)))
(deftest "cek-call invokes zero-arg lambda"
(let ((result (cek-call (fn () (+ 1 2)) nil)))
(assert-equal 3 result)))
(deftest "cek-call with nil function returns nil"
(assert-nil (cek-call nil nil)))
(deftest "computed tracks deps via cek-call"
(let ((s (signal 10)))
(let ((c (computed (fn () (* 2 (deref s))))))
(assert-equal 20 (deref c))
(reset! s 5)
(assert-equal 10 (deref c)))))
(deftest "effect runs and re-runs via cek-call"
(let ((s (signal "a"))
(log (list)))
(effect (fn () (append! log (deref s))))
(assert-equal (list "a") log)
(reset! s "b")
(assert-equal (list "a" "b") log)))
(deftest "effect cleanup runs on re-trigger"
(let ((s (signal 0))
(log (list)))
(effect (fn ()
(let ((val (deref s)))
(append! log (str "run:" val))
;; Return cleanup function
(fn () (append! log (str "clean:" val))))))
(assert-equal (list "run:0") log)
(reset! s 1)
(assert-equal (list "run:0" "clean:0" "run:1") log)))
(deftest "batch coalesces via cek-call"
(let ((s (signal 0))
(count (signal 0)))
(effect (fn () (do (deref s) (swap! count inc))))
(assert-equal 1 (deref count))
(batch (fn ()
(reset! s 1)
(reset! s 2)
(reset! s 3)))
;; batch should coalesce — effect runs once, not three times
(assert-equal 2 (deref count)))))