Decouple core evaluator from web platform, extract libraries

The core evaluator (spec/evaluator.sx) is now the irreducible computational
core with zero web, rendering, or type-system knowledge. 2531 → 2313 lines.

- Add extensible special form registry (*custom-special-forms* + register-special-form!)
- Add render dispatch hooks (*render-check* / *render-fn*) replacing hardcoded render-active?/is-render-expr?/render-expr
- Extract freeze scopes → spec/freeze.sx (library, not core)
- Extract content addressing → spec/content.sx (library, not core)
- Move sf-deftype/sf-defeffect → spec/types.sx (self-registering)
- Move sf-defstyle → web/forms.sx (self-registering with all web forms)
- Move web tests (defpage, streaming) → web/tests/test-forms.sx
- Add is-else-clause? helper (replaces 5 inline patterns)
- Make escape-html/escape-attr library functions in render.sx (pure SX, not platform-provided)
- Add foundations plan: Step 3.5 (data representations), Step 3.7 (verified components), OCaml for Step 4d
- Update all three bootstrappers (JS 957/957, Python 744/744, OCaml 952/952)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-16 08:37:50 +00:00
parent 5ab3ecb7e0
commit 06666ac8c4
21 changed files with 886 additions and 603 deletions

View File

@@ -131,6 +131,8 @@ def compile_ref_to_js(
# evaluator.sx = merged frames + eval utilities + CEK machine
sx_files = [
("evaluator.sx", "evaluator (frames + eval + CEK)"),
("freeze.sx", "freeze (serializable state boundaries)"),
("content.sx", "content (content-addressed computation)"),
("render.sx", "render (core)"),
]
for name in ("parser", "html", "sx", "dom", "engine", "orchestration", "boot"):

View File

@@ -13,7 +13,14 @@ from shared.sx.types import Symbol
def extract_defines(source: str) -> list[tuple[str, list]]:
"""Parse .sx source, return list of (name, define-expr) for top-level defines."""
"""Parse .sx source, return list of (name, expr) for top-level forms.
Extracts (define name ...) forms with their name, plus selected
non-define top-level expressions (e.g. register-special-form! calls)
with a synthetic name for the comment.
"""
# Top-level calls that should be transpiled (not special forms)
_TOPLEVEL_CALLS = {"register-special-form!"}
exprs = parse_all(source)
defines = []
for expr in exprs:
@@ -21,6 +28,10 @@ def extract_defines(source: str) -> list[tuple[str, list]]:
if expr[0].name == "define":
name = expr[1].name if isinstance(expr[1], Symbol) else str(expr[1])
defines.append((name, expr))
elif expr[0].name in _TOPLEVEL_CALLS:
# Top-level call expression (e.g. register-special-form!)
call_name = expr[0].name
defines.append((f"({call_name} ...)", expr))
return defines
ADAPTER_FILES = {
@@ -283,9 +294,11 @@ ASYNC_IO_JS = '''
if (hname === "map-indexed") return asyncRenderMapIndexed(expr, env, ns);
if (hname === "for-each") return asyncRenderMap(expr, env, ns);
// define/defcomp/defmacro — eval for side effects
// define/defcomp/defmacro and custom special forms — eval for side effects
if (hname === "define" || hname === "defcomp" || hname === "defmacro" ||
hname === "defstyle" || hname === "defhandler") {
hname === "defstyle" || hname === "defhandler" ||
hname === "deftype" || hname === "defeffect" ||
(typeof _customSpecialForms !== "undefined" && _customSpecialForms[hname])) {
trampoline(evalExpr(expr, env));
return null;
}
@@ -1412,10 +1425,7 @@ PLATFORM_JS_POST = '''
var dict_fn = PRIMITIVES["dict"];
// HTML rendering helpers
function escapeHtml(s) {
return String(s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");
}
function escapeAttr(s) { return escapeHtml(s); }
// escape-html and escape-attr are now library functions defined in render.sx
function rawHtmlContent(r) { return r.html; }
function makeRawHtml(s) { return { _raw: true, html: s }; }
function sxExprSource(x) { return x && x.source ? x.source : String(x); }
@@ -1429,7 +1439,8 @@ PLATFORM_JS_POST = '''
function isDefinitionForm(name) {
return name === "define" || name === "defcomp" || name === "defmacro" ||
name === "defstyle" || name === "defhandler";
name === "defstyle" || name === "defhandler" ||
name === "deftype" || name === "defeffect";
}
function indexOf_(s, ch) {
@@ -1703,6 +1714,11 @@ PLATFORM_DOM_JS = """
_renderExprFn = function(expr, env) { return renderToDom(expr, env, null); };
_renderMode = true; // Browser always evaluates in render context.
// Wire CEK render hooks — evaluator checks _renderCheck/_renderFn instead of
// the old renderActiveP()/isRenderExpr()/renderExpr() triple.
_renderCheck = function(expr, env) { return isRenderExpr(expr); };
_renderFn = function(expr, env) { return renderToDom(expr, env, null); };
var SVG_NS = "http://www.w3.org/2000/svg";
var MATH_NS = "http://www.w3.org/1998/Math/MathML";

View File

@@ -93,6 +93,11 @@
"dispose-computed" "disposeComputed"
"with-island-scope" "withIslandScope"
"register-in-scope" "registerInScope"
"*custom-special-forms*" "_customSpecialForms"
"register-special-form!" "registerSpecialForm"
"*render-check*" "_renderCheck"
"*render-fn*" "_renderFn"
"is-else-clause?" "isElseClause"
"*batch-depth*" "_batchDepth"
"*batch-queue*" "_batchQueue"
"*store-registry*" "_storeRegistry"
@@ -181,7 +186,6 @@
"ho-some" "hoSome"
"ho-every" "hoEvery"
"ho-for-each" "hoForEach"
"sf-defstyle" "sfDefstyle"
"kf-name" "kfName"
"special-form?" "isSpecialForm"
"ho-form?" "isHoForm"

View File

@@ -43,16 +43,30 @@ PREAMBLE = """\
open Sx_types
open Sx_runtime
(* Trampoline — evaluates thunks via the CEK machine.
eval_expr is defined in the transpiled block below. *)
let trampoline v = v (* CEK machine doesn't produce thunks *)
(* Trampoline — forward ref, resolved after eval_expr is defined. *)
let trampoline_fn : (value -> value) ref = ref (fun v -> v)
let trampoline v = !trampoline_fn v
(* === Mutable state for strict mode === *)
(* These are defined as top-level refs because the transpiler cannot handle
global set! mutation (it creates local refs that shadow the global). *)
let _strict_ref = ref (Bool false)
let _prim_param_types_ref = ref Nil
"""
# OCaml fixups — override iterative CEK run
# OCaml fixups — wire up trampoline + iterative CEK run
FIXUPS = """\
(* Wire up trampoline to resolve thunks via the CEK machine *)
let () = trampoline_fn := (fun v ->
match v with
| Thunk (expr, env) -> eval_expr expr (Env env)
| _ -> v)
(* Override recursive cek_run with iterative loop *)
let cek_run_iterative state =
let s = ref state in
@@ -122,7 +136,63 @@ def compile_spec_to_ml(spec_dir: str | None = None) -> str:
parts.append(result)
parts.append(FIXUPS)
return "\n".join(parts)
output = "\n".join(parts)
# Post-process: fix mutable globals that the transpiler can't handle.
# The transpiler emits local refs for set! targets within functions,
# but top-level globals (*strict*, *prim-param-types*) need to use
# the pre-declared refs from the preamble.
import re
# Fix *strict*: use _strict_ref instead of immutable let rec binding
output = re.sub(
r'and _strict_ =\n \(Bool false\)',
'and _strict_ = !_strict_ref',
output,
)
# Fix set-strict!: use _strict_ref instead of local ref
output = re.sub(
r'and set_strict_b val\' =\n let _strict_ = ref Nil in \(_strict_ := val\'; Nil\)',
"and set_strict_b val' =\n _strict_ref := val'; Nil",
output,
)
# Fix *prim-param-types*: use _prim_param_types_ref
output = re.sub(
r'and _prim_param_types_ =\n Nil',
'and _prim_param_types_ = !_prim_param_types_ref',
output,
)
# Fix set-prim-param-types!: use _prim_param_types_ref
output = re.sub(
r'and set_prim_param_types_b types =\n let _prim_param_types_ = ref Nil in \(_prim_param_types_ := types; Nil\)',
"and set_prim_param_types_b types =\n _prim_param_types_ref := types; Nil",
output,
)
# Fix all runtime reads of _strict_ and _prim_param_types_ to deref
# the mutable refs instead of using the stale let-rec bindings.
# This is needed because let-rec value bindings capture initial values.
# Use regex with word boundary to avoid replacing _strict_ref with
# !_strict_refref.
def fix_mutable_reads(text):
lines = text.split('\n')
fixed = []
for line in lines:
# Skip the definition lines
stripped = line.strip()
if stripped.startswith('and _strict_ =') or stripped.startswith('and _prim_param_types_ ='):
fixed.append(line)
continue
# Replace _strict_ as a standalone identifier only (not inside
# other names like set_strict_b). Match when preceded by space,
# paren, or start-of-line, and followed by space, paren, or ;.
line = re.sub(r'(?<=[ (])_strict_(?=[ );])', '!_strict_ref', line)
line = re.sub(r'(?<=[ (])_prim_param_types_(?=[ );])', '!_prim_param_types_ref', line)
fixed.append(line)
return '\n'.join(fixed)
output = fix_mutable_reads(output)
return output
def main():

File diff suppressed because one or more lines are too long

View File

@@ -297,10 +297,26 @@ let scope_pop _name = Nil
let provide_push name value = ignore name; ignore value; Nil
let provide_pop _name = Nil
(* Render mode stubs *)
let render_active_p () = Bool false
let render_expr _expr _env = Nil
let is_render_expr _expr = Bool false
(* Custom special forms registry — mutable dict *)
let custom_special_forms = Dict (Hashtbl.create 4)
(* register-special-form! — add a handler to the custom registry *)
let register_special_form name handler =
(match custom_special_forms with
| Dict tbl -> Hashtbl.replace tbl (value_to_str name) handler; handler
| _ -> raise (Eval_error "custom_special_forms not a dict"))
(* Render check/fn hooks — nil by default, set by platform if needed *)
let render_check = Nil
let render_fn = Nil
(* is-else-clause? — check if a cond/case test is an else marker *)
let is_else_clause v =
match v with
| Keyword k -> Bool (k = "else" || k = "default")
| Symbol s -> Bool (s = "else" || s = "default")
| Bool true -> Bool true
| _ -> Bool false
(* Signal accessors *)
let signal_value s = match s with Signal sig' -> sig'.s_value | _ -> raise (Eval_error "not a signal")

View File

@@ -123,9 +123,11 @@
"provide-push!" "provide_push"
"provide-pop!" "provide_pop"
"sx-serialize" "sx_serialize"
"render-active?" "render_active_p"
"is-render-expr?" "is_render_expr"
"render-expr" "render_expr"
"*custom-special-forms*" "custom_special_forms"
"register-special-form!" "register_special_form"
"*render-check*" "render_check"
"*render-fn*" "render_fn"
"is-else-clause?" "is_else_clause"
"HTML_TAGS" "html_tags"
"VOID_ELEMENTS" "void_elements"
"BOOLEAN_ATTRS" "boolean_attrs"
@@ -192,15 +194,12 @@
"cek-call" "cek-run" "sx-call" "sx-apply"
"collect!" "collected" "clear-collected!" "context" "emit!" "emitted"
"scope-push!" "scope-pop!" "provide-push!" "provide-pop!"
"render-active?" "render-expr" "is-render-expr?"
"with-island-scope" "register-in-scope"
"signal-value" "signal-set-value" "signal-subscribers"
"signal-add-sub!" "signal-remove-sub!" "signal-deps" "signal-set-deps"
"notify-subscribers" "flush-subscribers" "dispose-computed"
"continuation?" "continuation-data" "make-cek-continuation"
"dynamic-wind-call" "strip-prefix"
"sf-defhandler" "sf-defpage" "sf-defquery" "sf-defaction"
"make-handler-def" "make-query-def" "make-action-def" "make-page-def"
"component-set-param-types!" "parse-comp-params" "parse-macro-params"
"parse-keyword-args"))
@@ -215,6 +214,15 @@
;; Check _known_defines (set by bootstrap.py)
(some (fn (d) (= d name)) _known_defines)))))
;; Dynamic globals — top-level defines that hold SX values (not functions).
;; When these appear as callees, use cek_call for dynamic dispatch.
(define ml-dynamic-globals
(list "*render-check*" "*render-fn*"))
(define ml-is-dyn-global?
(fn ((name :as string))
(some (fn (g) (= g name)) ml-dynamic-globals)))
;; Check if a variable is "dynamic" — locally bound to a non-function expression.
;; These variables hold SX values (from eval-expr, get, etc.) and need cek_call
;; when used as callees. We encode this in the set-vars list as "dyn:name".
@@ -421,8 +429,12 @@
(let ((head (first expr))
(args (rest expr)))
(if (not (= (type-of head) "symbol"))
;; Data list
(str "[" (join "; " (map (fn (x) (ml-expr-inner x set-vars)) expr)) "]")
;; Non-symbol head: if head is a list (call expr), dispatch via cek_call;
;; otherwise treat as data list
(if (list? head)
(str "(cek_call (" (ml-expr-inner head set-vars)
") (List [" (join "; " (map (fn (x) (ml-expr-inner x set-vars)) args)) "]))")
(str "[" (join "; " (map (fn (x) (ml-expr-inner x set-vars)) expr)) "]"))
(let ((op (symbol-name head)))
(cond
;; fn/lambda
@@ -607,8 +619,8 @@
;; Regular function call
:else
(let ((callee (ml-mangle op)))
(if (ml-is-dyn-var? op set-vars)
;; Dynamic callee (local var bound to non-fn expr) — dispatch via cek_call
(if (or (ml-is-dyn-var? op set-vars) (ml-is-dyn-global? op))
;; Dynamic callee (local var or dynamic global) — dispatch via cek_call
(str "(cek_call (" callee ") (List [" (join "; " (map (fn (x) (ml-expr-inner x set-vars)) args)) "]))")
;; Static callee — direct OCaml call
(if (empty? args)

View File

@@ -179,6 +179,11 @@ class PyEmitter:
"*batch-depth*": "_batch_depth",
"*batch-queue*": "_batch_queue",
"*store-registry*": "_store_registry",
"*custom-special-forms*": "_custom_special_forms",
"*render-check*": "_render_check",
"*render-fn*": "_render_fn",
"register-special-form!": "register_special_form_b",
"is-else-clause?": "is_else_clause_p",
"def-store": "def_store",
"use-store": "use_store",
"clear-stores": "clear_stores",

View File

@@ -612,13 +612,7 @@ def inspect(x):
return repr(x)
def escape_html(s):
s = str(s)
return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")
def escape_attr(s):
return escape_html(s)
# escape_html and escape_attr are now library functions defined in render.sx
def raw_html_content(x):
@@ -842,7 +836,7 @@ def _sx_parse_int(v, default=0):
"stdlib.text": '''
# stdlib.text
PRIMITIVES["pluralize"] = lambda n, s="", p="s": s if n == 1 else p
PRIMITIVES["escape"] = escape_html
PRIMITIVES["escape"] = lambda s: str(s).replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")
PRIMITIVES["strip-tags"] = lambda s: _strip_tags(str(s))
import re as _re
@@ -1647,12 +1641,15 @@ SPEC_MODULES = {
"signals": ("signals.sx", "signals (reactive signal runtime)"),
"page-helpers": ("page-helpers.sx", "page-helpers (pure data transformation helpers)"),
"types": ("types.sx", "types (gradual type system)"),
"freeze": ("freeze.sx", "freeze (serializable state boundaries)"),
"content": ("content.sx", "content (content-addressed computation)"),
}
# Note: frames and cek are now part of evaluator.sx (always loaded as core)
# Explicit ordering for spec modules with dependencies.
# freeze depends on signals; content depends on freeze.
SPEC_MODULE_ORDER = [
"deps", "engine", "page-helpers", "router", "signals", "types",
"deps", "engine", "page-helpers", "router", "signals", "types", "freeze", "content",
]
EXTENSION_NAMES = {"continuations"}

View File

@@ -172,9 +172,6 @@ env["sf-lambda"] = sx_ref.sf_lambda
env["sf-defcomp"] = sx_ref.sf_defcomp
env["sf-defisland"] = sx_ref.sf_defisland
env["sf-defmacro"] = sx_ref.sf_defmacro
env["sf-defstyle"] = sx_ref.sf_defstyle
env["sf-deftype"] = sx_ref.sf_deftype
env["sf-defeffect"] = sx_ref.sf_defeffect
env["sf-letrec"] = sx_ref.sf_letrec
env["sf-named-let"] = sx_ref.sf_named_let
env["sf-dynamic-wind"] = sx_ref.sf_dynamic_wind
@@ -194,10 +191,25 @@ env["ho-every"] = sx_ref.ho_every
env["ho-for-each"] = sx_ref.ho_for_each
env["call-fn"] = sx_ref.call_fn
# Render-related (stub for testing — no active rendering)
env["render-active?"] = lambda: False
env["is-render-expr?"] = lambda expr: False
env["render-expr"] = lambda expr, env: NIL
# Render dispatch globals — evaluator checks *render-check* and *render-fn*
env["*render-check*"] = NIL
env["*render-fn*"] = NIL
# Custom special forms registry — modules register forms at load time
env["*custom-special-forms*"] = {}
def _register_special_form(name, handler):
env["*custom-special-forms*"][name] = handler
return NIL
env["register-special-form!"] = _register_special_form
# is-else-clause? — check if a cond/case test is an else marker
def _is_else_clause(test):
if isinstance(test, Keyword) and test.name == "else":
return True
if isinstance(test, Symbol) and test.name in ("else", ":else"):
return True
return False
env["is-else-clause?"] = _is_else_clause
# Scope primitives
env["scope-push!"] = sx_ref.PRIMITIVES.get("scope-push!", lambda *a: NIL)
@@ -214,15 +226,12 @@ env["call-thunk"] = lambda f, e: f() if callable(f) else trampoline(eval_expr([f
# Mutation helpers used by parse-keyword-args etc
env["dict-get"] = lambda d, k: d.get(k, NIL) if isinstance(d, dict) else NIL
# defhandler, defpage, defquery, defaction — these are registrations
# Use the bootstrapped versions if they exist, otherwise stub
for name in ["sf-defhandler", "sf-defpage", "sf-defquery", "sf-defaction"]:
pyname = name.replace("-", "_")
fn = getattr(sx_ref, pyname, None)
if fn:
env[name] = fn
else:
env[name] = lambda args, e, _n=name: NIL
# defstyle, defhandler, defpage, defquery, defaction — now registered via
# register-special-form! by forms.sx at load time. Stub them here in case
# forms.sx is not loaded (CEK tests don't load it).
for form_name in ["defstyle", "defhandler", "defpage", "defquery", "defaction"]:
if form_name not in env["*custom-special-forms*"]:
env["*custom-special-forms*"][form_name] = lambda args, e, _n=form_name: NIL
# Load test framework
with open(os.path.join(_SPEC_TESTS, "test-framework.sx")) as f:

View File

@@ -248,9 +248,26 @@ env["macro-closure"] = lambda m: m.closure
env["symbol-name"] = lambda s: s.name if isinstance(s, Symbol) else str(s)
env["keyword-name"] = lambda k: k.name if isinstance(k, Keyword) else str(k)
env["sx-serialize"] = sx_ref.sx_serialize if hasattr(sx_ref, "sx_serialize") else lambda x: str(x)
env["is-render-expr?"] = lambda expr: False
env["render-active?"] = lambda: False
env["render-expr"] = lambda expr, env: NIL
# Render dispatch globals — evaluator checks *render-check* and *render-fn*
env["*render-check*"] = NIL
env["*render-fn*"] = NIL
# Custom special forms registry — modules register forms at load time
env["*custom-special-forms*"] = {}
def _register_special_form(name, handler):
env["*custom-special-forms*"][name] = handler
return NIL
env["register-special-form!"] = _register_special_form
# is-else-clause? — check if a cond/case test is an else marker
def _is_else_clause(test):
if isinstance(test, Keyword) and test.name == "else":
return True
if isinstance(test, Symbol) and test.name in ("else", ":else"):
return True
return False
env["is-else-clause?"] = _is_else_clause
# Strict mode stubs (not yet bootstrapped to Python — no-ops for now)
env["set-strict!"] = lambda val: NIL

View File

@@ -93,6 +93,11 @@
"*batch-depth*" "_batch_depth"
"*batch-queue*" "_batch_queue"
"*store-registry*" "_store_registry"
"*custom-special-forms*" "_custom_special_forms"
"*render-check*" "_render_check"
"*render-fn*" "_render_fn"
"register-special-form!" "register_special_form_b"
"is-else-clause?" "is_else_clause_p"
"def-store" "def_store"
"use-store" "use_store"
"clear-stores" "clear_stores"