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

@@ -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))