From 3a268e72773830770620eda7bbed46026ac39af4 Mon Sep 17 00:00:00 2001 From: giles Date: Sun, 15 Mar 2026 18:05:00 +0000 Subject: [PATCH] Data-first HO forms, fix plan pages, aser error handling (1080/1080) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- hosts/python/platform.py | 17 +- shared/static/scripts/sx-browser.js | 48 ++++-- shared/sx/pages.py | 40 ++++- shared/sx/primitives.py | 29 ++++ shared/sx/tests/test_aser_errors.py | 245 ++++++++++++++++++++++++++++ spec/evaluator.sx | 97 +++++++---- spec/tests/test-cek-advanced.sx | 97 +++++++++++ sx/sx/page-functions.sx | 3 + sx/sxc/pages/__init__.py | 11 +- web/tests/test-aser.sx | 79 +++++++++ 10 files changed, 615 insertions(+), 51 deletions(-) create mode 100644 shared/sx/tests/test_aser_errors.py diff --git a/hosts/python/platform.py b/hosts/python/platform.py index 460b531..4c23683 100644 --- a/hosts/python/platform.py +++ b/hosts/python/platform.py @@ -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: - return base.extend() + 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 diff --git a/shared/static/scripts/sx-browser.js b/shared/static/scripts/sx-browser.js index 149410b..ee8c433 100644 --- a/shared/static/scripts/sx-browser.js +++ b/shared/static/scripts/sx-browser.js @@ -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"); diff --git a/shared/sx/pages.py b/shared/sx/pages.py index 59183ab..c88c96b 100644 --- a/shared/sx/pages.py +++ b/shared/sx/pages.py @@ -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\n" @@ -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() diff --git a/shared/sx/primitives.py b/shared/sx/primitives.py index ca09d1c..7c98f7e 100644 --- a/shared/sx/primitives.py +++ b/shared/sx/primitives.py @@ -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() + diff --git a/shared/sx/tests/test_aser_errors.py b/shared/sx/tests/test_aser_errors.py new file mode 100644 index 0000000..876862c --- /dev/null +++ b/shared/sx/tests/test_aser_errors.py @@ -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)) diff --git a/spec/evaluator.sx b/spec/evaluator.sx index 72658fd..f5966a7 100644 --- a/spec/evaluator.sx +++ b/spec/evaluator.sx @@ -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,24 +1994,36 @@ (make-cek-value value fenv rest-k) ;; Apply next form to value (let ((form (first remaining)) - (rest-forms (rest remaining))) - (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))) - (all-args (cons value rargs))) - (cond - (and (callable? f) (not (lambda? f))) (apply f all-args) - (lambda? f) (trampoline (call-lambda f all-args fenv)) - :else (error (str "-> form not callable: " (inspect f))))) - (let ((f (trampoline (eval-expr form fenv)))) - (cond - (and (callable? f) (not (lambda? f))) (f value) - (lambda? f) (trampoline (call-lambda f (list value) fenv)) - :else (error (str "-> form not callable: " (inspect f)))))))) - (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))))))) + (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))) + (all-args (cons value rargs))) + (cond + (and (callable? f) (not (lambda? f))) (apply f all-args) + (lambda? f) (trampoline (call-lambda f all-args fenv)) + :else (error (str "-> form not callable: " (inspect f))))) + (let ((f (trampoline (eval-expr form fenv)))) + (cond + (and (callable? f) (not (lambda? f))) (f value) + (lambda? f) (trampoline (call-lambda f (list value) fenv)) + :else (error (str "-> form not callable: " (inspect f)))))))) + (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)))))))) ;; --- ArgFrame: head or arg evaluated --- (= ft "arg") diff --git a/spec/tests/test-cek-advanced.sx b/spec/tests/test-cek-advanced.sx index ff2534e..f1b3188 100644 --- a/spec/tests/test-cek-advanced.sx +++ b/spec/tests/test-cek-advanced.sx @@ -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))))) diff --git a/sx/sx/page-functions.sx b/sx/sx/page-functions.sx index 08f3629..3658481 100644 --- a/sx/sx/page-functions.sx +++ b/sx/sx/page-functions.sx @@ -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) diff --git a/sx/sxc/pages/__init__.py b/sx/sxc/pages/__init__.py index 9f313f8..cf42fcc 100644 --- a/sx/sxc/pages/__init__.py +++ b/sx/sxc/pages/__init__.py @@ -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") diff --git a/web/tests/test-aser.sx b/web/tests/test-aser.sx index c175c89..1b310fc 100644 --- a/web/tests/test-aser.sx +++ b/web/tests/test-aser.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\"))"))))