Data-first HO forms, fix plan pages, aser error handling (1080/1080)
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled

Evaluator: data-first higher-order forms — ho-swap-args auto-detects
(map coll fn) vs (map fn coll), both work. Threading + HO: (-> data
(map fn)) dispatches through CEK HO machinery via quoted-value splice.
17 new tests in test-cek-advanced.sx.

Fix plan pages: add mother-language, isolated-evaluator, rust-wasm-host
to page-functions.sx plan() — were in defpage but missing from URL router.

Aser error handling: pages.py now catches EvalError separately, renders
visible error banner instead of silently sending empty content. All
except blocks include traceback in logs.

Scope primitives: register collect!/collected/clear-collected!/emitted/
emit!/context in shared/sx/primitives.py so hand-written _aser can
resolve them (fixes ~cssx/flush expansion failure).

New test file: shared/sx/tests/test_aser_errors.py — 19 pytest tests
for error propagation through all aser control flow forms.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-15 18:05:00 +00:00
parent bdbf594bc8
commit 3a268e7277
10 changed files with 615 additions and 51 deletions

View File

@@ -525,13 +525,24 @@ def env_merge(base, overlay):
if base is overlay:
# Same env — just extend with empty local scope for params
return base.extend()
# Check if base is an ancestor of overlay — if so, no need to merge
# (common for self-recursive calls where closure == caller's ancestor)
# Check if base is an ancestor of overlay — if so, overlay contains
# everything in base. But overlay scopes between overlay and base may
# have extra local bindings (e.g. page helpers injected at request time).
# Only take the shortcut if no intermediate scope has local bindings.
p = overlay
depth = 0
while p is not None and depth < 100:
if p is base:
q = overlay
has_extra = False
while q is not base:
if hasattr(q, '_bindings') and q._bindings:
has_extra = True
break
q = getattr(q, '_parent', None)
if not has_extra:
return base.extend()
break
p = getattr(p, '_parent', None)
depth += 1
# MergedEnv: reads walk base then overlay; set! walks base only

View File

@@ -14,7 +14,7 @@
// =========================================================================
var NIL = Object.freeze({ _nil: true, toString: function() { return "nil"; } });
var SX_VERSION = "2026-03-15T16:12:31Z";
var SX_VERSION = "2026-03-15T17:07:09Z";
function isNil(x) { return x === NIL || x === null || x === undefined; }
function isSxTruthy(x) { return x !== false && !isNil(x); }
@@ -1628,32 +1628,55 @@ PRIMITIVES["reactive-shift-deref"] = reactiveShiftDeref;
})(); };
PRIMITIVES["step-eval-call"] = stepEvalCall;
// ho-form-name?
var hoFormName_p = function(name) { return sxOr((name == "map"), (name == "map-indexed"), (name == "filter"), (name == "reduce"), (name == "some"), (name == "every?"), (name == "for-each")); };
PRIMITIVES["ho-form-name?"] = hoFormName_p;
// ho-fn?
var hoFn_p = function(v) { return sxOr(isCallable(v), isLambda(v)); };
PRIMITIVES["ho-fn?"] = hoFn_p;
// ho-swap-args
var hoSwapArgs = function(hoType, evaled) { return (isSxTruthy((hoType == "reduce")) ? (function() {
var a = first(evaled);
var b = nth(evaled, 1);
return (isSxTruthy((isSxTruthy(!isSxTruthy(hoFn_p(a))) && hoFn_p(b))) ? [b, nth(evaled, 2), a] : evaled);
})() : (function() {
var a = first(evaled);
var b = nth(evaled, 1);
return (isSxTruthy((isSxTruthy(!isSxTruthy(hoFn_p(a))) && hoFn_p(b))) ? [b, a] : evaled);
})()); };
PRIMITIVES["ho-swap-args"] = hoSwapArgs;
// ho-setup-dispatch
var hoSetupDispatch = function(hoType, evaled, env, kont) { return (function() {
var f = first(evaled);
var ordered = hoSwapArgs(hoType, evaled);
return (function() {
var f = first(ordered);
return (isSxTruthy((hoType == "map")) ? (function() {
var coll = nth(evaled, 1);
var coll = nth(ordered, 1);
return (isSxTruthy(isEmpty(coll)) ? makeCekValue([], env, kont) : continueWithCall(f, [first(coll)], env, [], kontPush(makeMapFrame(f, rest(coll), [], env), kont)));
})() : (isSxTruthy((hoType == "map-indexed")) ? (function() {
var coll = nth(evaled, 1);
var coll = nth(ordered, 1);
return (isSxTruthy(isEmpty(coll)) ? makeCekValue([], env, kont) : continueWithCall(f, [0, first(coll)], env, [], kontPush(makeMapIndexedFrame(f, rest(coll), [], env), kont)));
})() : (isSxTruthy((hoType == "filter")) ? (function() {
var coll = nth(evaled, 1);
var coll = nth(ordered, 1);
return (isSxTruthy(isEmpty(coll)) ? makeCekValue([], env, kont) : continueWithCall(f, [first(coll)], env, [], kontPush(makeFilterFrame(f, rest(coll), [], first(coll), env), kont)));
})() : (isSxTruthy((hoType == "reduce")) ? (function() {
var init = nth(evaled, 1);
var coll = nth(evaled, 2);
var init = nth(ordered, 1);
var coll = nth(ordered, 2);
return (isSxTruthy(isEmpty(coll)) ? makeCekValue(init, env, kont) : continueWithCall(f, [init, first(coll)], env, [], kontPush(makeReduceFrame(f, rest(coll), env), kont)));
})() : (isSxTruthy((hoType == "some")) ? (function() {
var coll = nth(evaled, 1);
var coll = nth(ordered, 1);
return (isSxTruthy(isEmpty(coll)) ? makeCekValue(false, env, kont) : continueWithCall(f, [first(coll)], env, [], kontPush(makeSomeFrame(f, rest(coll), env), kont)));
})() : (isSxTruthy((hoType == "every")) ? (function() {
var coll = nth(evaled, 1);
var coll = nth(ordered, 1);
return (isSxTruthy(isEmpty(coll)) ? makeCekValue(true, env, kont) : continueWithCall(f, [first(coll)], env, [], kontPush(makeEveryFrame(f, rest(coll), env), kont)));
})() : (isSxTruthy((hoType == "for-each")) ? (function() {
var coll = nth(evaled, 1);
var coll = nth(ordered, 1);
return (isSxTruthy(isEmpty(coll)) ? makeCekValue(NIL, env, kont) : continueWithCall(f, [first(coll)], env, [], kontPush(makeForEachFrame(f, rest(coll), env), kont)));
})() : error((String("Unknown HO type: ") + String(hoType))))))))));
})();
})(); };
PRIMITIVES["ho-setup-dispatch"] = hoSetupDispatch;
@@ -1771,7 +1794,8 @@ PRIMITIVES["step-ho-for-each"] = stepHoForEach;
return (isSxTruthy(isEmpty(remaining)) ? makeCekValue(value, fenv, restK) : (function() {
var form = first(remaining);
var restForms = rest(remaining);
return (function() {
var newKont = (isSxTruthy(isEmpty(rest(remaining))) ? restK : kontPush(makeThreadFrame(rest(remaining), fenv), restK));
return (isSxTruthy((isSxTruthy((typeOf(form) == "list")) && isSxTruthy(!isSxTruthy(isEmpty(form))) && isSxTruthy((typeOf(first(form)) == "symbol")) && hoFormName_p(symbolName(first(form))))) ? makeCekState(cons(first(form), cons([new Symbol("quote"), value], rest(form))), fenv, newKont) : (function() {
var result = (isSxTruthy((typeOf(form) == "list")) ? (function() {
var f = trampoline(evalExpr(first(form), fenv));
var rargs = map(function(a) { return trampoline(evalExpr(a, fenv)); }, rest(form));
@@ -1782,7 +1806,7 @@ PRIMITIVES["step-ho-for-each"] = stepHoForEach;
return (isSxTruthy((isSxTruthy(isCallable(f)) && !isSxTruthy(isLambda(f)))) ? f(value) : (isSxTruthy(isLambda(f)) ? trampoline(callLambda(f, [value], fenv)) : error((String("-> form not callable: ") + String(inspect(f))))));
})());
return (isSxTruthy(isEmpty(restForms)) ? makeCekValue(result, fenv, restK) : makeCekValue(result, fenv, kontPush(makeThreadFrame(restForms, fenv), restK)));
})();
})());
})());
})() : (isSxTruthy((ft == "arg")) ? (function() {
var f = get(frame, "f");

View File

@@ -23,11 +23,28 @@ import logging
import os
from typing import Any
from .types import PageDef
import traceback
from .types import EvalError, PageDef
logger = logging.getLogger("sx.pages")
def _eval_error_sx(e: EvalError, context: str) -> str:
"""Render an EvalError as SX content that's visible to the developer."""
from .ref.sx_ref import escape_html as _esc
msg = _esc(str(e))
ctx = _esc(context)
return (
f'(div :class "sx-eval-error" :style '
f'"background:#fef2f2;border:1px solid #fca5a5;'
f'color:#991b1b;padding:1rem;margin:1rem 0;'
f'border-radius:0.5rem;font-family:monospace;white-space:pre-wrap"'
f' (p :style "font-weight:700;margin:0 0 0.5rem" "SX EvalError in {ctx}")'
f' (p :style "margin:0" "{msg}"))'
)
# ---------------------------------------------------------------------------
# Registry — service → page-name → PageDef
# ---------------------------------------------------------------------------
@@ -511,8 +528,12 @@ async def execute_page_streaming(
aside_sx = await _eval_slot(page_def.aside_expr, data_env, ctx) if page_def.aside_expr else ""
menu_sx = await _eval_slot(page_def.menu_expr, data_env, ctx) if page_def.menu_expr else ""
await _stream_queue.put(("data-single", content_sx, filter_sx, aside_sx, menu_sx))
except EvalError as e:
logger.error("Streaming data task failed (EvalError): %s\n%s", e, traceback.format_exc())
error_sx = _eval_error_sx(e, "page content")
await _stream_queue.put(("data-single", error_sx, "", "", ""))
except Exception as e:
logger.error("Streaming data task failed: %s", e)
logger.error("Streaming data task failed: %s\n%s", e, traceback.format_exc())
await _stream_queue.put(("data-done",))
async def _eval_headers():
@@ -524,7 +545,7 @@ async def execute_page_streaming(
menu = await layout.mobile_menu(tctx, **layout_kwargs)
await _stream_queue.put(("headers", rows, menu))
except Exception as e:
logger.error("Streaming headers task failed: %s", e)
logger.error("Streaming headers task failed: %s\n%s", e, traceback.format_exc())
await _stream_queue.put(("headers", "", ""))
data_task = asyncio.create_task(_eval_data_and_content())
@@ -629,7 +650,7 @@ async def execute_page_streaming(
elif kind == "data-done":
remaining -= 1
except Exception as e:
logger.error("Streaming resolve failed for %s: %s", kind, e)
logger.error("Streaming resolve failed for %s: %s\n%s", kind, e, traceback.format_exc())
yield "\n</body>\n</html>"
@@ -733,8 +754,13 @@ async def execute_page_streaming_oob(
await _stream_queue.put(("data-done",))
return
await _stream_queue.put(("data-done",))
except EvalError as e:
logger.error("Streaming OOB data task failed (EvalError): %s\n%s", e, traceback.format_exc())
error_sx = _eval_error_sx(e, "page content")
await _stream_queue.put(("data", "stream-content", error_sx))
await _stream_queue.put(("data-done",))
except Exception as e:
logger.error("Streaming OOB data task failed: %s", e)
logger.error("Streaming OOB data task failed: %s\n%s", e, traceback.format_exc())
await _stream_queue.put(("data-done",))
async def _eval_oob_headers():
@@ -745,7 +771,7 @@ async def execute_page_streaming_oob(
else:
await _stream_queue.put(("headers", ""))
except Exception as e:
logger.error("Streaming OOB headers task failed: %s", e)
logger.error("Streaming OOB headers task failed: %s\n%s", e, traceback.format_exc())
await _stream_queue.put(("headers", ""))
data_task = asyncio.create_task(_eval_data())
@@ -836,7 +862,7 @@ async def execute_page_streaming_oob(
elif kind == "data-done":
remaining -= 1
except Exception as e:
logger.error("Streaming OOB resolve failed for %s: %s", kind, e)
logger.error("Streaming OOB resolve failed for %s: %s\n%s", kind, e, traceback.format_exc())
return _stream_oob_chunks()

View File

@@ -573,3 +573,32 @@ def prim_json_encode(value) -> str:
import json
return json.dumps(value, indent=2)
# ---------------------------------------------------------------------------
# Scope primitives — delegate to sx_ref.py's scope stack implementation
# (shared global state between transpiled and hand-written evaluators)
# ---------------------------------------------------------------------------
def _lazy_scope_primitives():
"""Register scope/provide/collect primitives from sx_ref.py.
Called at import time — if sx_ref.py isn't built yet, silently skip.
These are needed by the hand-written _aser in async_eval.py when
expanding components that use scoped effects (e.g. ~cssx/flush).
"""
try:
from .ref.sx_ref import (
sx_collect, sx_collected, sx_clear_collected,
sx_emitted, sx_emit, sx_context,
)
_PRIMITIVES["collect!"] = sx_collect
_PRIMITIVES["collected"] = sx_collected
_PRIMITIVES["clear-collected!"] = sx_clear_collected
_PRIMITIVES["emitted"] = sx_emitted
_PRIMITIVES["emit!"] = sx_emit
_PRIMITIVES["context"] = sx_context
except ImportError:
pass
_lazy_scope_primitives()

View File

@@ -0,0 +1,245 @@
"""Tests for aser (SX wire format) error propagation.
Verifies that evaluation errors inside control flow forms (case, cond, if,
when, let, begin) propagate correctly — they must throw, not silently
produce wrong output or fall through to :else branches.
This test file targets the production bug where a case body referencing an
undefined symbol was silently swallowed, causing the case to appear to fall
through to :else instead of raising an error.
"""
from __future__ import annotations
import pytest
from shared.sx.ref.sx_ref import (
aser,
sx_parse as parse_all,
make_env,
eval_expr,
trampoline,
serialize as sx_serialize,
)
from shared.sx.types import NIL, EvalError
def _render_sx(source: str, env=None) -> str:
"""Parse SX source and serialize via aser (sync)."""
if env is None:
env = make_env()
exprs = parse_all(source)
result = ""
for expr in exprs:
val = aser(expr, env)
if isinstance(val, str):
result += val
elif val is None or val is NIL:
pass
else:
result += sx_serialize(val)
return result
# ---------------------------------------------------------------------------
# Case — matched branch errors must throw, not fall through
# ---------------------------------------------------------------------------
class TestCaseErrorPropagation:
def test_matched_branch_undefined_symbol_throws(self):
"""If the matched case body references an undefined symbol, the aser
must throw — NOT silently skip to :else."""
with pytest.raises(Exception, match="Undefined symbol"):
_render_sx('(case "x" "x" undefined_sym :else "fallback")')
def test_else_branch_error_throws(self):
with pytest.raises(Exception, match="Undefined symbol"):
_render_sx('(case "miss" "x" "ok" :else undefined_sym)')
def test_matched_branch_nested_error_throws(self):
"""Error inside a tag within the matched body must propagate."""
with pytest.raises(Exception, match="Undefined symbol"):
_render_sx('(case "a" "a" (div (p undefined_nested)) :else (p "index"))')
def test_unmatched_correctly_falls_through(self):
"""Verify :else works when no clause matches (happy path)."""
result = _render_sx('(case "miss" "x" "found" :else "fallback")')
assert "fallback" in result
def test_matched_branch_succeeds(self):
"""Verify the happy path: matched branch evaluates normally."""
result = _render_sx('(case "ok" "ok" (p "matched") :else "fallback")')
assert "matched" in result
# ---------------------------------------------------------------------------
# Cond — matched branch errors must throw
# ---------------------------------------------------------------------------
class TestCondErrorPropagation:
def test_matched_branch_error_throws(self):
with pytest.raises(Exception, match="Undefined symbol"):
_render_sx('(cond true undefined_cond_sym :else "fallback")')
def test_else_branch_error_throws(self):
with pytest.raises(Exception, match="Undefined symbol"):
_render_sx('(cond false "skip" :else undefined_cond_sym)')
# ---------------------------------------------------------------------------
# If / When — body errors must throw
# ---------------------------------------------------------------------------
class TestIfWhenErrorPropagation:
def test_if_true_branch_error_throws(self):
with pytest.raises(Exception, match="Undefined symbol"):
_render_sx('(if true undefined_if_sym "fallback")')
def test_when_body_error_throws(self):
with pytest.raises(Exception, match="Undefined symbol"):
_render_sx('(when true undefined_when_sym)')
# ---------------------------------------------------------------------------
# Let — binding or body errors must throw
# ---------------------------------------------------------------------------
class TestLetErrorPropagation:
def test_binding_error_throws(self):
with pytest.raises(Exception, match="Undefined symbol"):
_render_sx('(let ((x undefined_let_sym)) (p x))')
def test_body_error_throws(self):
with pytest.raises(Exception, match="Undefined symbol"):
_render_sx('(let ((x 1)) (p undefined_let_body_sym))')
# ---------------------------------------------------------------------------
# Begin/Do — body errors must throw
# ---------------------------------------------------------------------------
class TestBeginErrorPropagation:
def test_do_body_error_throws(self):
with pytest.raises(Exception, match="Undefined symbol"):
_render_sx('(do "ok" undefined_do_sym)')
# ---------------------------------------------------------------------------
# Sync aser: components serialize WITHOUT expansion (by design)
# ---------------------------------------------------------------------------
class TestSyncAserComponentSerialization:
"""The sync aser serializes component calls as SX wire format without
expanding the body. This is correct — expansion only happens in the
async path with expand_components=True."""
def test_component_in_case_serializes_without_expanding(self):
"""Sync aser should serialize the component call, not expand it."""
result = _render_sx(
'(do (defcomp ~broken (&key title) (div (p title) (p no_such_helper)))'
' (case "slug" "slug" (~broken :title "test") '
' :else "index"))'
)
# Component call is serialized as SX, not expanded — no error
assert "~broken" in result
def test_working_component_in_case_serializes(self):
result = _render_sx(
'(do (defcomp ~working (&key title) (div (p title)))'
' (case "ok" "ok" (~working :title "hello") '
' :else "index"))'
)
assert "~working" in result
def test_unmatched_case_falls_through_correctly(self):
result = _render_sx(
'(do (defcomp ~page (&key x) (div x))'
' (case "miss" "hit" (~page :x "found") '
' :else "index"))'
)
assert "index" in result
# ---------------------------------------------------------------------------
# Async aser with expand_components=True — the production path
# ---------------------------------------------------------------------------
class TestAsyncAserComponentExpansion:
"""Tests the production code path: async aser with component expansion
enabled. Errors in expanded component bodies must propagate, not be
silently swallowed."""
def _async_render(self, source: str) -> str:
"""Render via the async aser with component expansion enabled."""
import asyncio
from shared.sx.ref.sx_ref import async_aser, _expand_components_cv
exprs = parse_all(source)
env = make_env()
async def run():
token = _expand_components_cv.set(True)
try:
result = ""
for expr in exprs:
val = await async_aser(expr, env, None)
if isinstance(val, str):
result += val
elif val is None or val is NIL:
pass
else:
result += sx_serialize(val)
return result
finally:
_expand_components_cv.reset(token)
return asyncio.run(run())
def test_expanded_component_with_undefined_symbol_throws(self):
"""When expand_components is True and the component body references
an undefined symbol, the error must propagate — not be swallowed."""
with pytest.raises(Exception, match="Undefined symbol"):
self._async_render(
'(do (defcomp ~broken (&key title) '
' (div (p title) (p no_such_helper)))'
' (case "slug" "slug" (~broken :title "test") '
' :else "index"))'
)
def test_expanded_working_component_succeeds(self):
result = self._async_render(
'(do (defcomp ~working (&key title) (div (p title)))'
' (case "ok" "ok" (~working :title "hello") '
' :else "index"))'
)
assert "hello" in result
def test_expanded_unmatched_falls_through(self):
result = self._async_render(
'(do (defcomp ~page (&key x) (div x))'
' (case "miss" "hit" (~page :x "found") '
' :else "index"))'
)
assert "index" in result
def test_hand_written_aser_also_propagates(self):
"""Test the hand-written _aser in async_eval.py (the production
path used by page rendering)."""
import asyncio
from shared.sx.async_eval import (
async_eval_slot_to_sx, RequestContext,
)
from shared.sx.ref.sx_ref import aser
env = make_env()
# Define the component via sync aser
for expr in parse_all(
'(defcomp ~broken (&key title) (div (p title) (p no_such_helper)))'
):
aser(expr, env)
case_expr = parse_all(
'(case "slug" "slug" (~broken :title "test") :else "index")'
)[0]
ctx = RequestContext()
with pytest.raises(Exception, match="Undefined symbol"):
asyncio.run(async_eval_slot_to_sx(case_expr, dict(env), ctx))

View File

@@ -1684,62 +1684,91 @@
;; (no nested eval-expr calls). When all args are evaluated, the
;; HoSetupFrame dispatch in step-continue sets up the iteration frame.
;; ho-form-name? — is this symbol name a higher-order special form?
(define ho-form-name?
(fn (name)
(or (= name "map") (= name "map-indexed") (= name "filter")
(= name "reduce") (= name "some") (= name "every?")
(= name "for-each"))))
;; ho-fn? — is this value usable as a HO callback?
(define ho-fn?
(fn (v) (or (callable? v) (lambda? v))))
;; ho-swap-args: normalise data-first arg order
;; 2-arg forms: (coll fn) → (fn coll)
;; 3-arg reduce: (coll fn init) → (fn init coll)
(define ho-swap-args
(fn (ho-type evaled)
(if (= ho-type "reduce")
(let ((a (first evaled))
(b (nth evaled 1)))
(if (and (not (ho-fn? a)) (ho-fn? b))
(list b (nth evaled 2) a)
evaled))
(let ((a (first evaled))
(b (nth evaled 1)))
(if (and (not (ho-fn? a)) (ho-fn? b))
(list b a)
evaled)))))
;; ho-setup-dispatch: all HO args evaluated, set up iteration
(define ho-setup-dispatch
(fn (ho-type evaled env kont)
(let ((f (first evaled)))
(let ((ordered (ho-swap-args ho-type evaled)))
(let ((f (first ordered)))
(cond
(= ho-type "map")
(let ((coll (nth evaled 1)))
(let ((coll (nth ordered 1)))
(if (empty? coll)
(make-cek-value (list) env kont)
(continue-with-call f (list (first coll)) env (list)
(kont-push (make-map-frame f (rest coll) (list) env) kont))))
(= ho-type "map-indexed")
(let ((coll (nth evaled 1)))
(let ((coll (nth ordered 1)))
(if (empty? coll)
(make-cek-value (list) env kont)
(continue-with-call f (list 0 (first coll)) env (list)
(kont-push (make-map-indexed-frame f (rest coll) (list) env) kont))))
(= ho-type "filter")
(let ((coll (nth evaled 1)))
(let ((coll (nth ordered 1)))
(if (empty? coll)
(make-cek-value (list) env kont)
(continue-with-call f (list (first coll)) env (list)
(kont-push (make-filter-frame f (rest coll) (list) (first coll) env) kont))))
(= ho-type "reduce")
(let ((init (nth evaled 1))
(coll (nth evaled 2)))
(let ((init (nth ordered 1))
(coll (nth ordered 2)))
(if (empty? coll)
(make-cek-value init env kont)
(continue-with-call f (list init (first coll)) env (list)
(kont-push (make-reduce-frame f (rest coll) env) kont))))
(= ho-type "some")
(let ((coll (nth evaled 1)))
(let ((coll (nth ordered 1)))
(if (empty? coll)
(make-cek-value false env kont)
(continue-with-call f (list (first coll)) env (list)
(kont-push (make-some-frame f (rest coll) env) kont))))
(= ho-type "every")
(let ((coll (nth evaled 1)))
(let ((coll (nth ordered 1)))
(if (empty? coll)
(make-cek-value true env kont)
(continue-with-call f (list (first coll)) env (list)
(kont-push (make-every-frame f (rest coll) env) kont))))
(= ho-type "for-each")
(let ((coll (nth evaled 1)))
(let ((coll (nth ordered 1)))
(if (empty? coll)
(make-cek-value nil env kont)
(continue-with-call f (list (first coll)) env (list)
(kont-push (make-for-each-frame f (rest coll) env) kont))))
:else (error (str "Unknown HO type: " ho-type))))))
:else (error (str "Unknown HO type: " ho-type)))))))
(define step-ho-map
(fn (args env kont)
@@ -1965,7 +1994,19 @@
(make-cek-value value fenv rest-k)
;; Apply next form to value
(let ((form (first remaining))
(rest-forms (rest remaining)))
(rest-forms (rest remaining))
(new-kont (if (empty? (rest remaining)) rest-k
(kont-push (make-thread-frame (rest remaining) fenv) rest-k))))
;; Check if form is a HO call like (map fn)
(if (and (= (type-of form) "list")
(not (empty? form))
(= (type-of (first form)) "symbol")
(ho-form-name? (symbol-name (first form))))
;; HO form — splice value as quoted arg, dispatch via CEK
(make-cek-state
(cons (first form) (cons (list 'quote value) (rest form)))
fenv new-kont)
;; Normal: tree-walk eval + apply
(let ((result (if (= (type-of form) "list")
(let ((f (trampoline (eval-expr (first form) fenv)))
(rargs (map (fn (a) (trampoline (eval-expr a fenv))) (rest form)))
@@ -1982,7 +2023,7 @@
(if (empty? rest-forms)
(make-cek-value result fenv rest-k)
(make-cek-value result fenv
(kont-push (make-thread-frame rest-forms fenv) rest-k)))))))
(kont-push (make-thread-frame rest-forms fenv) rest-k))))))))
;; --- ArgFrame: head or arg evaluated ---
(= ft "arg")

View File

@@ -598,3 +598,100 @@
n
(+ (fib (- n 1)) (fib (- n 2))))))
(fib 7))"))))
;; --------------------------------------------------------------------------
;; 8. Data-first higher-order forms
;; --------------------------------------------------------------------------
(defsuite "data-first-ho"
(deftest "map — data-first arg order"
(assert-equal (list 2 4 6)
(map (list 1 2 3) (fn (x) (* x 2)))))
(deftest "filter — data-first arg order"
(assert-equal (list 3 4 5)
(filter (list 1 2 3 4 5) (fn (x) (> x 2)))))
(deftest "reduce — data-first arg order"
(assert-equal 10
(reduce (list 1 2 3 4) + 0)))
(deftest "some — data-first arg order"
(assert-true
(some (list 1 2 3) (fn (x) (> x 2))))
(assert-false
(some (list 1 2 3) (fn (x) (> x 5)))))
(deftest "every? — data-first arg order"
(assert-true
(every? (list 2 4 6) (fn (x) (> x 1))))
(assert-false
(every? (list 2 4 6) (fn (x) (> x 3)))))
(deftest "for-each — data-first arg order"
(let ((acc (list)))
(for-each (list 10 20 30)
(fn (x) (set! acc (append acc (list x)))))
(assert-equal (list 10 20 30) acc)))
(deftest "map-indexed — data-first arg order"
(assert-equal (list "0:a" "1:b" "2:c")
(map-indexed (list "a" "b" "c")
(fn (i v) (str i ":" v)))))
(deftest "fn-first still works — map"
(assert-equal (list 2 4 6)
(map (fn (x) (* x 2)) (list 1 2 3))))
(deftest "fn-first still works — reduce"
(assert-equal 10
(reduce + 0 (list 1 2 3 4)))))
;; --------------------------------------------------------------------------
;; 9. Threading with HO forms
;; --------------------------------------------------------------------------
(defsuite "thread-ho"
(deftest "-> map"
(assert-equal (list 2 4 6)
(-> (list 1 2 3) (map (fn (x) (* x 2))))))
(deftest "-> filter"
(assert-equal (list 3 4 5)
(-> (list 1 2 3 4 5) (filter (fn (x) (> x 2))))))
(deftest "-> filter then map pipeline"
(assert-equal (list 30 40 50)
(-> (list 1 2 3 4 5)
(filter (fn (x) (> x 2)))
(map (fn (x) (* x 10))))))
(deftest "-> reduce"
(assert-equal 15
(-> (list 1 2 3 4 5) (reduce + 0))))
(deftest "-> map then reduce"
(assert-equal 12
(-> (list 1 2 3)
(map (fn (x) (* x 2)))
(reduce + 0))))
(deftest "-> some"
(assert-true
(-> (list 1 2 3) (some (fn (x) (> x 2)))))
(assert-false
(-> (list 1 2 3) (some (fn (x) (> x 5))))))
(deftest "-> every?"
(assert-true
(-> (list 2 4 6) (every? (fn (x) (> x 1))))))
(deftest "-> full pipeline: map filter reduce"
;; Double each, keep > 4, sum
(assert-equal 24
(-> (list 1 2 3 4 5)
(map (fn (x) (* x 2)))
(filter (fn (x) (> x 4)))
(reduce + 0)))))

View File

@@ -541,6 +541,9 @@
"sx-forge" '(~plans/sx-forge/plan-sx-forge-content)
"sx-swarm" '(~plans/sx-swarm/plan-sx-swarm-content)
"sx-proxy" '(~plans/sx-proxy/plan-sx-proxy-content)
"mother-language" '(~plans/mother-language/plan-mother-language-content)
"isolated-evaluator" '(~plans/isolated-evaluator/plan-isolated-evaluator-content)
"rust-wasm-host" '(~plans/rust-wasm-host/plan-rust-wasm-host-content)
"async-eval-convergence" '(~plans/async-eval-convergence/plan-async-eval-convergence-content)
"wasm-bytecode-vm" '(~plans/wasm-bytecode-vm/plan-wasm-bytecode-vm-content)
"generative-sx" '(~plans/generative-sx/plan-generative-sx-content)

View File

@@ -14,11 +14,20 @@ def setup_sx_pages() -> None:
def _load_sx_page_files() -> None:
"""Load defpage definitions from sx/sxc/pages/*.sx."""
import os
from shared.sx.pages import load_page_dir
from shared.sx.pages import load_page_dir, get_page_helpers
from shared.sx.jinja_bridge import load_sx_dir, watch_sx_dir, load_service_components
_sxc_dir = os.path.dirname(os.path.dirname(__file__)) # sx/sxc/
service_root = os.path.dirname(_sxc_dir) # sx/
load_service_components(service_root, service_name="sx")
load_sx_dir(_sxc_dir)
watch_sx_dir(_sxc_dir)
# Register page helpers as primitives so the CEK machine can find them
# during nested async component expansion (e.g. highlight inside ~docs/code
# inside a plan component inside ~layouts/doc). Without this, the env_merge
# chain loses page helpers because component closures don't capture them.
from shared.sx.ref.sx_ref import PRIMITIVES
helpers = get_page_helpers("sx")
for name, fn in helpers.items():
PRIMITIVES[name] = fn
import logging; logging.getLogger("sx.pages").info("Injected %d page helpers as primitives: %s", len(helpers), list(helpers.keys())[:5])
load_page_dir(os.path.dirname(__file__), "sx")

View File

@@ -344,3 +344,82 @@
(deftest "scope pops correctly after body"
(assert-equal "outer"
(render-sx "(scope \"sc-pop\" :value \"outer\" (scope \"sc-pop\" :value \"inner\" \"ignore\") (context \"sc-pop\"))"))))
;; --------------------------------------------------------------------------
;; Error propagation — errors in aser control flow must throw, not silently
;; produce wrong output or fall through to :else branches.
;; --------------------------------------------------------------------------
(defsuite "aser-error-propagation"
;; --- case: matched branch errors must throw, not fall through to :else ---
(deftest "case — error in matched branch throws, not falls through"
;; If the matched case body references an undefined symbol, the aser must
;; throw an error — NOT silently skip to :else.
(assert-throws
(fn () (render-sx "(case \"x\" \"x\" undefined-symbol-xyz :else \"fallback\")"))))
(deftest "case — :else body error also throws"
(assert-throws
(fn () (render-sx "(case \"no-match\" \"x\" \"ok\" :else undefined-symbol-xyz)"))))
(deftest "case — matched branch with nested error throws"
;; Error inside a tag within the matched body must propagate.
(assert-throws
(fn () (render-sx "(case \"a\" \"a\" (div (p undefined-sym-abc)) :else (p \"index\"))"))))
;; --- cond: matched branch errors must throw ---
(deftest "cond — error in matched branch throws"
(assert-throws
(fn () (render-sx "(cond true undefined-cond-sym :else \"fallback\")"))))
(deftest "cond — error in :else branch throws"
(assert-throws
(fn () (render-sx "(cond false \"skip\" :else undefined-cond-sym)"))))
;; --- if/when: body errors must throw ---
(deftest "if — error in true branch throws"
(assert-throws
(fn () (render-sx "(if true undefined-if-sym \"fallback\")"))))
(deftest "when — error in body throws"
(assert-throws
(fn () (render-sx "(when true undefined-when-sym)"))))
;; --- let: binding or body errors must throw ---
(deftest "let — error in binding throws"
(assert-throws
(fn () (render-sx "(let ((x undefined-let-sym)) (p x))"))))
(deftest "let — error in body throws"
(assert-throws
(fn () (render-sx "(let ((x 1)) (p undefined-let-body-sym))"))))
;; --- begin/do: body errors must throw ---
(deftest "do — error in body throws"
(assert-throws
(fn () (render-sx "(do \"ok\" undefined-do-sym)"))))
;; --- component expansion inside case: the production bug ---
;; --- sync aser serializes components without expansion ---
(deftest "case — component in matched branch serializes unexpanded"
;; Sync aser serializes component calls as SX wire format.
;; Expansion only happens in async path with expand-components.
(assert-equal "(~broken :title \"test\")"
(render-sx
"(do (defcomp ~broken (&key title) (div (p title) (p no-such-helper)))
(case \"slug\" \"slug\" (~broken :title \"test\") :else \"index\"))")))
(deftest "case — unmatched falls through to :else correctly"
(assert-equal "index"
(render-sx
"(do (defcomp ~page (&key x) (div x))
(case \"miss\" \"hit\" (~page :x \"found\") :else \"index\"))"))))