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
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:
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
245
shared/sx/tests/test_aser_errors.py
Normal file
245
shared/sx/tests/test_aser_errors.py
Normal 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))
|
||||
@@ -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")
|
||||
|
||||
@@ -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)))))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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\"))"))))
|
||||
|
||||
Reference in New Issue
Block a user