Fix quasiquote flattening bug, decouple relations from evaluator

- Fix qq-expand in eval.sx: use concat+list instead of append to prevent
  nested lists from being flattened during quasiquote expansion
- Update append primitive to match spec ("if x is list, concatenate")
- Rebuild sx_ref.py with quasiquote fix
- Make relations.py self-contained: parse defrelation AST directly
  without depending on the evaluator (25/25 tests pass)
- Replace hand-written JSEmitter with js.sx self-hosting bootstrapper
- Guard server-only tests in test-eval.sx with runtime check

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-11 04:53:34 +00:00
parent 46cd179703
commit 3906ab3558
12 changed files with 678 additions and 4526 deletions

View File

@@ -14,7 +14,7 @@
// =========================================================================
var NIL = Object.freeze({ _nil: true, toString: function() { return "nil"; } });
var SX_VERSION = "2026-03-11T03:56:09Z";
var SX_VERSION = "2026-03-11T04:41:27Z";
function isNil(x) { return x === NIL || x === null || x === undefined; }
function isSxTruthy(x) { return x !== false && !isNil(x); }
@@ -312,6 +312,7 @@
PRIMITIVES["even?"] = function(n) { return n % 2 === 0; };
PRIMITIVES["zero?"] = function(n) { return n === 0; };
PRIMITIVES["boolean?"] = function(x) { return x === true || x === false; };
PRIMITIVES["component-affinity"] = componentAffinity;
// core.strings
@@ -579,6 +580,92 @@
return makeThunk(componentBody(comp), local);
};
// =========================================================================
// Platform: deps module — component dependency analysis
// =========================================================================
function componentDeps(c) {
return c.deps ? c.deps.slice() : [];
}
function componentSetDeps(c, deps) {
c.deps = deps;
}
function componentCssClasses(c) {
return c.cssClasses ? c.cssClasses.slice() : [];
}
function envComponents(env) {
var names = [];
for (var k in env) {
var v = env[k];
if (v && (v._component || v._macro)) names.push(k);
}
return names;
}
function regexFindAll(pattern, source) {
var re = new RegExp(pattern, "g");
var results = [];
var m;
while ((m = re.exec(source)) !== null) {
if (m[1] !== undefined) results.push(m[1]);
else results.push(m[0]);
}
return results;
}
function scanCssClasses(source) {
var classes = {};
var result = [];
var m;
var re1 = /:class\s+"([^"]*)"/g;
while ((m = re1.exec(source)) !== null) {
var parts = m[1].split(/\s+/);
for (var i = 0; i < parts.length; i++) {
if (parts[i] && !classes[parts[i]]) {
classes[parts[i]] = true;
result.push(parts[i]);
}
}
}
var re2 = /:class\s+\(str\s+((?:"[^"]*"\s*)+)\)/g;
while ((m = re2.exec(source)) !== null) {
var re3 = /"([^"]*)"/g;
var m2;
while ((m2 = re3.exec(m[1])) !== null) {
var parts2 = m2[1].split(/\s+/);
for (var j = 0; j < parts2.length; j++) {
if (parts2[j] && !classes[parts2[j]]) {
classes[parts2[j]] = true;
result.push(parts2[j]);
}
}
}
}
var re4 = /;;\s*@css\s+(.+)/g;
while ((m = re4.exec(source)) !== null) {
var parts3 = m[1].split(/\s+/);
for (var k = 0; k < parts3.length; k++) {
if (parts3[k] && !classes[parts3[k]]) {
classes[parts3[k]] = true;
result.push(parts3[k]);
}
}
}
return result;
}
function componentIoRefs(c) {
return c.ioRefs ? c.ioRefs.slice() : [];
}
function componentSetIoRefs(c, refs) {
c.ioRefs = refs;
}
// =========================================================================
// Platform interface — Parser
// =========================================================================
@@ -3185,6 +3272,167 @@ callExpr.push(dictGet(kwargs, k)); } }
var bootInit = function() { return (logInfo((String("sx-browser ") + String(SX_VERSION))), initCssTracking(), processPageScripts(), processSxScripts(NIL), sxHydrateElements(NIL), sxHydrateIslands(NIL), processElements(NIL)); };
// === Transpiled from deps (component dependency analysis) ===
// scan-refs
var scanRefs = function(node) { return (function() {
var refs = [];
scanRefsWalk(node, refs);
return refs;
})(); };
// scan-refs-walk
var scanRefsWalk = function(node, refs) { return (isSxTruthy((typeOf(node) == "symbol")) ? (function() {
var name = symbolName(node);
return (isSxTruthy(startsWith(name, "~")) ? (isSxTruthy(!isSxTruthy(contains(refs, name))) ? append_b(refs, name) : NIL) : NIL);
})() : (isSxTruthy((typeOf(node) == "list")) ? forEach(function(item) { return scanRefsWalk(item, refs); }, node) : (isSxTruthy((typeOf(node) == "dict")) ? forEach(function(key) { return scanRefsWalk(dictGet(node, key), refs); }, keys(node)) : NIL))); };
// transitive-deps-walk
var transitiveDepsWalk = function(n, seen, env) { return (isSxTruthy(!isSxTruthy(contains(seen, n))) ? (append_b(seen, n), (function() {
var val = envGet(env, n);
return (isSxTruthy((typeOf(val) == "component")) ? forEach(function(ref) { return transitiveDepsWalk(ref, seen, env); }, scanRefs(componentBody(val))) : (isSxTruthy((typeOf(val) == "macro")) ? forEach(function(ref) { return transitiveDepsWalk(ref, seen, env); }, scanRefs(macroBody(val))) : NIL));
})()) : NIL); };
// transitive-deps
var transitiveDeps = function(name, env) { return (function() {
var seen = [];
var key = (isSxTruthy(startsWith(name, "~")) ? name : (String("~") + String(name)));
transitiveDepsWalk(key, seen, env);
return filter(function(x) { return !isSxTruthy((x == key)); }, seen);
})(); };
// compute-all-deps
var computeAllDeps = function(env) { return forEach(function(name) { return (function() {
var val = envGet(env, name);
return (isSxTruthy((typeOf(val) == "component")) ? componentSetDeps(val, transitiveDeps(name, env)) : NIL);
})(); }, envComponents(env)); };
// scan-components-from-source
var scanComponentsFromSource = function(source) { return (function() {
var matches = regexFindAll("\\(~([a-zA-Z_][a-zA-Z0-9_\\-]*)", source);
return map(function(m) { return (String("~") + String(m)); }, matches);
})(); };
// components-needed
var componentsNeeded = function(pageSource, env) { return (function() {
var direct = scanComponentsFromSource(pageSource);
var allNeeded = [];
{ var _c = direct; for (var _i = 0; _i < _c.length; _i++) { var name = _c[_i]; if (isSxTruthy(!isSxTruthy(contains(allNeeded, name)))) {
allNeeded.push(name);
}
(function() {
var val = envGet(env, name);
return (function() {
var deps = (isSxTruthy((isSxTruthy((typeOf(val) == "component")) && !isSxTruthy(isEmpty(componentDeps(val))))) ? componentDeps(val) : transitiveDeps(name, env));
return forEach(function(dep) { return (isSxTruthy(!isSxTruthy(contains(allNeeded, dep))) ? append_b(allNeeded, dep) : NIL); }, deps);
})();
})(); } }
return allNeeded;
})(); };
// page-component-bundle
var pageComponentBundle = function(pageSource, env) { return componentsNeeded(pageSource, env); };
// page-css-classes
var pageCssClasses = function(pageSource, env) { return (function() {
var needed = componentsNeeded(pageSource, env);
var classes = [];
{ var _c = needed; for (var _i = 0; _i < _c.length; _i++) { var name = _c[_i]; (function() {
var val = envGet(env, name);
return (isSxTruthy((typeOf(val) == "component")) ? forEach(function(cls) { return (isSxTruthy(!isSxTruthy(contains(classes, cls))) ? append_b(classes, cls) : NIL); }, componentCssClasses(val)) : NIL);
})(); } }
{ var _c = scanCssClasses(pageSource); for (var _i = 0; _i < _c.length; _i++) { var cls = _c[_i]; if (isSxTruthy(!isSxTruthy(contains(classes, cls)))) {
classes.push(cls);
} } }
return classes;
})(); };
// scan-io-refs-walk
var scanIoRefsWalk = function(node, ioNames, refs) { return (isSxTruthy((typeOf(node) == "symbol")) ? (function() {
var name = symbolName(node);
return (isSxTruthy(contains(ioNames, name)) ? (isSxTruthy(!isSxTruthy(contains(refs, name))) ? append_b(refs, name) : NIL) : NIL);
})() : (isSxTruthy((typeOf(node) == "list")) ? forEach(function(item) { return scanIoRefsWalk(item, ioNames, refs); }, node) : (isSxTruthy((typeOf(node) == "dict")) ? forEach(function(key) { return scanIoRefsWalk(dictGet(node, key), ioNames, refs); }, keys(node)) : NIL))); };
// scan-io-refs
var scanIoRefs = function(node, ioNames) { return (function() {
var refs = [];
scanIoRefsWalk(node, ioNames, refs);
return refs;
})(); };
// transitive-io-refs-walk
var transitiveIoRefsWalk = function(n, seen, allRefs, env, ioNames) { return (isSxTruthy(!isSxTruthy(contains(seen, n))) ? (append_b(seen, n), (function() {
var val = envGet(env, n);
return (isSxTruthy((typeOf(val) == "component")) ? (forEach(function(ref) { return (isSxTruthy(!isSxTruthy(contains(allRefs, ref))) ? append_b(allRefs, ref) : NIL); }, scanIoRefs(componentBody(val), ioNames)), forEach(function(dep) { return transitiveIoRefsWalk(dep, seen, allRefs, env, ioNames); }, scanRefs(componentBody(val)))) : (isSxTruthy((typeOf(val) == "macro")) ? (forEach(function(ref) { return (isSxTruthy(!isSxTruthy(contains(allRefs, ref))) ? append_b(allRefs, ref) : NIL); }, scanIoRefs(macroBody(val), ioNames)), forEach(function(dep) { return transitiveIoRefsWalk(dep, seen, allRefs, env, ioNames); }, scanRefs(macroBody(val)))) : NIL));
})()) : NIL); };
// transitive-io-refs
var transitiveIoRefs = function(name, env, ioNames) { return (function() {
var allRefs = [];
var seen = [];
var key = (isSxTruthy(startsWith(name, "~")) ? name : (String("~") + String(name)));
transitiveIoRefsWalk(key, seen, allRefs, env, ioNames);
return allRefs;
})(); };
// compute-all-io-refs
var computeAllIoRefs = function(env, ioNames) { return forEach(function(name) { return (function() {
var val = envGet(env, name);
return (isSxTruthy((typeOf(val) == "component")) ? componentSetIoRefs(val, transitiveIoRefs(name, env, ioNames)) : NIL);
})(); }, envComponents(env)); };
// component-io-refs-cached
var componentIoRefsCached = function(name, env, ioNames) { return (function() {
var key = (isSxTruthy(startsWith(name, "~")) ? name : (String("~") + String(name)));
return (function() {
var val = envGet(env, key);
return (isSxTruthy((isSxTruthy((typeOf(val) == "component")) && isSxTruthy(!isSxTruthy(isNil(componentIoRefs(val)))) && !isSxTruthy(isEmpty(componentIoRefs(val))))) ? componentIoRefs(val) : transitiveIoRefs(name, env, ioNames));
})();
})(); };
// component-pure?
var componentPure_p = function(name, env, ioNames) { return (function() {
var key = (isSxTruthy(startsWith(name, "~")) ? name : (String("~") + String(name)));
return (function() {
var val = envGet(env, key);
return (isSxTruthy((isSxTruthy((typeOf(val) == "component")) && !isSxTruthy(isNil(componentIoRefs(val))))) ? isEmpty(componentIoRefs(val)) : isEmpty(transitiveIoRefs(name, env, ioNames)));
})();
})(); };
// render-target
var renderTarget = function(name, env, ioNames) { return (function() {
var key = (isSxTruthy(startsWith(name, "~")) ? name : (String("~") + String(name)));
return (function() {
var val = envGet(env, key);
return (isSxTruthy(!isSxTruthy((typeOf(val) == "component"))) ? "server" : (function() {
var affinity = componentAffinity(val);
return (isSxTruthy((affinity == "server")) ? "server" : (isSxTruthy((affinity == "client")) ? "client" : (isSxTruthy(!isSxTruthy(componentPure_p(name, env, ioNames))) ? "server" : "client")));
})());
})();
})(); };
// page-render-plan
var pageRenderPlan = function(pageSource, env, ioNames) { return (function() {
var needed = componentsNeeded(pageSource, env);
var compTargets = {};
var serverList = [];
var clientList = [];
var ioDeps = [];
{ var _c = needed; for (var _i = 0; _i < _c.length; _i++) { var name = _c[_i]; (function() {
var target = renderTarget(name, env, ioNames);
compTargets[name] = target;
return (isSxTruthy((target == "server")) ? (append_b(serverList, name), forEach(function(ioRef) { return (isSxTruthy(!isSxTruthy(contains(ioDeps, ioRef))) ? append_b(ioDeps, ioRef) : NIL); }, componentIoRefsCached(name, env, ioNames))) : append_b(clientList, name));
})(); } }
return {"components": compTargets, "server": serverList, "client": clientList, "io-deps": ioDeps};
})(); };
// env-components
var envComponents = function(env) { return filter(function(k) { return (function() {
var v = envGet(env, k);
return sxOr(isComponent(v), isMacro(v));
})(); }, keys(env)); };
// === Transpiled from router (client-side route matching) ===
// split-path-segments
@@ -4813,6 +5061,35 @@ return (isSxTruthy((_batchDepth == 0)) ? (function() {
if (typeof domOuterHtml === "function") PRIMITIVES["dom-outer-html"] = domOuterHtml;
if (typeof domInnerHtml === "function") PRIMITIVES["dom-inner-html"] = domInnerHtml;
// Expose deps module functions as primitives so runtime-evaluated SX code
// (e.g. test-deps.sx in browser) can call them
// Platform functions (from PLATFORM_DEPS_JS)
PRIMITIVES["component-deps"] = componentDeps;
PRIMITIVES["component-set-deps!"] = componentSetDeps;
PRIMITIVES["component-css-classes"] = componentCssClasses;
PRIMITIVES["env-components"] = envComponents;
PRIMITIVES["regex-find-all"] = regexFindAll;
PRIMITIVES["scan-css-classes"] = scanCssClasses;
// Transpiled functions (from deps.sx)
PRIMITIVES["scan-refs"] = scanRefs;
PRIMITIVES["scan-refs-walk"] = scanRefsWalk;
PRIMITIVES["transitive-deps"] = transitiveDeps;
PRIMITIVES["transitive-deps-walk"] = transitiveDepsWalk;
PRIMITIVES["compute-all-deps"] = computeAllDeps;
PRIMITIVES["scan-components-from-source"] = scanComponentsFromSource;
PRIMITIVES["components-needed"] = componentsNeeded;
PRIMITIVES["page-component-bundle"] = pageComponentBundle;
PRIMITIVES["page-css-classes"] = pageCssClasses;
PRIMITIVES["scan-io-refs-walk"] = scanIoRefsWalk;
PRIMITIVES["scan-io-refs"] = scanIoRefs;
PRIMITIVES["transitive-io-refs-walk"] = transitiveIoRefsWalk;
PRIMITIVES["transitive-io-refs"] = transitiveIoRefs;
PRIMITIVES["compute-all-io-refs"] = computeAllIoRefs;
PRIMITIVES["component-io-refs-cached"] = componentIoRefsCached;
PRIMITIVES["component-pure?"] = componentPure_p;
PRIMITIVES["render-target"] = renderTarget;
PRIMITIVES["page-render-plan"] = pageRenderPlan;
// =========================================================================
// Async IO: Promise-aware rendering for client-side IO primitives
// =========================================================================
@@ -5535,6 +5812,17 @@ return (isSxTruthy((_batchDepth == 0)) ? (function() {
hydrateIslands: typeof sxHydrateIslands === "function" ? sxHydrateIslands : null,
disposeIsland: typeof disposeIsland === "function" ? disposeIsland : null,
init: typeof bootInit === "function" ? bootInit : null,
scanRefs: scanRefs,
scanComponentsFromSource: scanComponentsFromSource,
transitiveDeps: transitiveDeps,
computeAllDeps: computeAllDeps,
componentsNeeded: componentsNeeded,
pageComponentBundle: pageComponentBundle,
pageCssClasses: pageCssClasses,
scanIoRefs: scanIoRefs,
transitiveIoRefs: transitiveIoRefs,
computeAllIoRefs: computeAllIoRefs,
componentPure_p: componentPure_p,
splitPathSegments: splitPathSegments,
parseRoutePattern: parseRoutePattern,
matchRoute: matchRoute,

File diff suppressed because it is too large Load Diff

View File

@@ -656,8 +656,8 @@
(let ((spliced (trampoline (eval-expr (nth item 1) env))))
(if (= (type-of spliced) "list")
(concat result spliced)
(if (nil? spliced) result (append result spliced))))
(append result (qq-expand item env))))
(if (nil? spliced) result (concat result (list spliced)))))
(concat result (list (qq-expand item env)))))
(list)
template)))))))

View File

@@ -124,6 +124,8 @@
"eval-call" "evalCall"
"is-render-expr?" "isRenderExpr"
"render-expr" "renderExpr"
"render-active?" "renderActiveP"
"set-render-active!" "setRenderActiveB"
"call-lambda" "callLambda"
"call-component" "callComponent"
"parse-keyword-args" "parseKeywordArgs"

View File

@@ -774,7 +774,7 @@ PRIMITIVES["last"] = lambda c: c[-1] if c and _b_len(c) > 0 else NIL
PRIMITIVES["rest"] = lambda c: c[1:] if c else []
PRIMITIVES["nth"] = lambda c, n: c[n] if c and 0 <= n < _b_len(c) else NIL
PRIMITIVES["cons"] = lambda x, c: [x] + (c or [])
PRIMITIVES["append"] = lambda c, x: (c or []) + [x]
PRIMITIVES["append"] = lambda c, x: (c or []) + (x if isinstance(x, list) else [x])
PRIMITIVES["chunk-every"] = lambda c, n: [c[i:i+n] for i in _b_range(0, _b_len(c), n)]
PRIMITIVES["zip-pairs"] = lambda c: [[c[i], c[i+1]] for i in _b_range(_b_len(c)-1)]
''',

View File

@@ -1,16 +1,14 @@
#!/usr/bin/env python3
"""
Bootstrap runner: execute js.sx against spec files to produce sx-ref.js.
Bootstrap compiler: js.sx (self-hosting SX-to-JS translator) → sx-browser.js.
This is the G1 bootstrapper js.sx (SX-to-JavaScript translator written in SX)
is loaded into the Python evaluator, which then uses it to translate the
spec .sx files into JavaScript.
The output (transpiled defines only) should be identical to what
bootstrap_js.py's JSEmitter produces.
This is the canonical JS bootstrapper. js.sx is loaded into the Python evaluator,
which uses it to translate the .sx spec files into JavaScript. Platform code
(types, primitives, DOM interface) comes from platform_js.py.
Usage:
python run_js_sx.py > /tmp/sx_ref_g1.js
python run_js_sx.py # stdout
python run_js_sx.py -o shared/static/scripts/sx-browser.js # file
"""
from __future__ import annotations
@@ -19,14 +17,32 @@ import sys
_HERE = os.path.dirname(os.path.abspath(__file__))
_PROJECT = os.path.abspath(os.path.join(_HERE, "..", "..", ".."))
sys.path.insert(0, _PROJECT)
if _PROJECT not in sys.path:
sys.path.insert(0, _PROJECT)
from shared.sx.parser import parse_all
from shared.sx.types import Symbol
from shared.sx.ref.platform_js import (
extract_defines,
ADAPTER_FILES, ADAPTER_DEPS, SPEC_MODULES, EXTENSION_NAMES,
PREAMBLE, PLATFORM_JS_PRE, PLATFORM_JS_POST,
PRIMITIVES_JS_MODULES, _ALL_JS_MODULES, _assemble_primitives_js,
PLATFORM_DEPS_JS, PLATFORM_PARSER_JS, PLATFORM_DOM_JS,
PLATFORM_ENGINE_PURE_JS, PLATFORM_ORCHESTRATION_JS, PLATFORM_BOOT_JS,
CONTINUATIONS_JS, ASYNC_IO_JS,
fixups_js, public_api_js, EPILOGUE,
)
_js_sx_env = None # cached
def load_js_sx() -> dict:
"""Load js.sx into an evaluator environment and return it."""
global _js_sx_env
if _js_sx_env is not None:
return _js_sx_env
js_sx_path = os.path.join(_HERE, "js.sx")
with open(js_sx_path) as f:
source = f.read()
@@ -39,63 +55,187 @@ def load_js_sx() -> dict:
for expr in exprs:
evaluate(expr, env)
_js_sx_env = env
return env
def extract_defines(source: str) -> list[tuple[str, list]]:
"""Parse .sx source, return list of (name, define-expr) for top-level defines."""
exprs = parse_all(source)
defines = []
for expr in exprs:
if isinstance(expr, list) and expr and isinstance(expr[0], Symbol):
if expr[0].name == "define":
name = expr[1].name if isinstance(expr[1], Symbol) else str(expr[1])
defines.append((name, expr))
return defines
def compile_ref_to_js(
adapters: list[str] | None = None,
modules: list[str] | None = None,
extensions: list[str] | None = None,
spec_modules: list[str] | None = None,
) -> str:
"""Compile SX spec files to JavaScript using js.sx.
def main():
Args:
adapters: List of adapter names to include. None = all.
modules: List of primitive module names. None = all.
extensions: List of extensions (continuations). None = none.
spec_modules: List of spec modules (deps, router, signals). None = auto.
"""
from datetime import datetime, timezone
from shared.sx.evaluator import evaluate
# Load js.sx into evaluator
ref_dir = _HERE
env = load_js_sx()
# Same file list and order as bootstrap_js.py compile_ref_to_js() with all adapters
# Resolve adapter set
if adapters is None:
adapter_set = set(ADAPTER_FILES.keys())
else:
adapter_set = set()
for a in adapters:
if a not in ADAPTER_FILES:
raise ValueError(f"Unknown adapter: {a!r}. Valid: {', '.join(ADAPTER_FILES)}")
adapter_set.add(a)
for dep in ADAPTER_DEPS.get(a, []):
adapter_set.add(dep)
# Resolve spec modules
spec_mod_set = set()
if spec_modules:
for sm in spec_modules:
if sm not in SPEC_MODULES:
raise ValueError(f"Unknown spec module: {sm!r}. Valid: {', '.join(SPEC_MODULES)}")
spec_mod_set.add(sm)
if "dom" in adapter_set and "signals" in SPEC_MODULES:
spec_mod_set.add("signals")
if "boot" in adapter_set:
spec_mod_set.add("router")
spec_mod_set.add("deps")
has_deps = "deps" in spec_mod_set
has_router = "router" in spec_mod_set
# Resolve extensions
ext_set = set()
if extensions:
for e in extensions:
if e not in EXTENSION_NAMES:
raise ValueError(f"Unknown extension: {e!r}. Valid: {', '.join(EXTENSION_NAMES)}")
ext_set.add(e)
has_continuations = "continuations" in ext_set
# Build file list: core + adapters + spec modules
sx_files = [
("eval.sx", "eval"),
("render.sx", "render (core)"),
("parser.sx", "parser"),
("adapter-html.sx", "adapter-html"),
("adapter-sx.sx", "adapter-sx"),
("adapter-dom.sx", "adapter-dom"),
("engine.sx", "engine"),
("orchestration.sx", "orchestration"),
("boot.sx", "boot"),
("deps.sx", "deps (component dependency analysis)"),
("router.sx", "router (client-side route matching)"),
("signals.sx", "signals (reactive signal runtime)"),
]
for name in ("parser", "html", "sx", "dom", "engine", "orchestration", "boot"):
if name in adapter_set:
sx_files.append(ADAPTER_FILES[name])
for name in sorted(spec_mod_set):
sx_files.append(SPEC_MODULES[name])
has_html = "html" in adapter_set
has_sx = "sx" in adapter_set
has_dom = "dom" in adapter_set
has_engine = "engine" in adapter_set
has_orch = "orchestration" in adapter_set
has_boot = "boot" in adapter_set
has_parser = "parser" in adapter_set
has_signals = "signals" in spec_mod_set
adapter_label = "+".join(sorted(adapter_set)) if adapter_set else "core-only"
# Platform JS blocks keyed by adapter name
adapter_platform = {
"parser": PLATFORM_PARSER_JS,
"dom": PLATFORM_DOM_JS,
"engine": PLATFORM_ENGINE_PURE_JS,
"orchestration": PLATFORM_ORCHESTRATION_JS,
"boot": PLATFORM_BOOT_JS,
}
# Determine primitive modules
prim_modules = None
if modules is not None:
prim_modules = [m for m in _ALL_JS_MODULES if m.startswith("core.")]
for m in modules:
if m not in prim_modules:
if m not in PRIMITIVES_JS_MODULES:
raise ValueError(f"Unknown module: {m!r}. Valid: {', '.join(PRIMITIVES_JS_MODULES)}")
prim_modules.append(m)
# Build output
parts = []
parts.append(PREAMBLE)
parts.append(PLATFORM_JS_PRE)
parts.append('\n // =========================================================================')
parts.append(' // Primitives')
parts.append(' // =========================================================================\n')
parts.append(' var PRIMITIVES = {};')
parts.append(_assemble_primitives_js(prim_modules))
parts.append(PLATFORM_JS_POST)
if has_deps:
parts.append(PLATFORM_DEPS_JS)
if has_parser:
parts.append(adapter_platform["parser"])
# Translate each spec file using js.sx
for filename, label in sx_files:
filepath = os.path.join(_HERE, filename)
filepath = os.path.join(ref_dir, filename)
if not os.path.exists(filepath):
continue
with open(filepath) as f:
src = f.read()
defines = extract_defines(src)
# Convert defines to SX-compatible format
sx_defines = [[name, expr] for name, expr in defines]
print(f"\n // === Transpiled from {label} ===\n")
parts.append(f"\n // === Transpiled from {label} ===\n")
env["_defines"] = sx_defines
result = evaluate(
[Symbol("js-translate-file"), Symbol("_defines")],
env,
)
print(result)
parts.append(result)
# Platform JS for selected adapters
if not has_dom:
parts.append("\n var _hasDom = false;\n")
for name in ("dom", "engine", "orchestration", "boot"):
if name in adapter_set and name in adapter_platform:
parts.append(adapter_platform[name])
parts.append(fixups_js(has_html, has_sx, has_dom, has_signals, has_deps))
if has_continuations:
parts.append(CONTINUATIONS_JS)
if has_dom:
parts.append(ASYNC_IO_JS)
parts.append(public_api_js(has_html, has_sx, has_dom, has_engine, has_orch, has_boot, has_parser, adapter_label, has_deps, has_router, has_signals))
parts.append(EPILOGUE)
build_ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
return "\n".join(parts).replace("BUILD_TIMESTAMP", build_ts)
if __name__ == "__main__":
main()
import argparse
p = argparse.ArgumentParser(description="Bootstrap-compile SX reference spec to JavaScript via js.sx")
p.add_argument("--adapters", "-a",
help="Comma-separated adapter list (html,sx,dom,engine). Default: all")
p.add_argument("--modules", "-m",
help="Comma-separated primitive modules (core.* always included). Default: all")
p.add_argument("--extensions",
help="Comma-separated extensions (continuations). Default: none.")
p.add_argument("--spec-modules",
help="Comma-separated spec modules (deps). Default: none.")
default_output = os.path.join(_HERE, "..", "..", "static", "scripts", "sx-browser.js")
p.add_argument("--output", "-o", default=default_output,
help="Output file (default: shared/static/scripts/sx-browser.js)")
args = p.parse_args()
adapters = args.adapters.split(",") if args.adapters else None
modules = args.modules.split(",") if args.modules else None
extensions = args.extensions.split(",") if args.extensions else None
spec_modules = args.spec_modules.split(",") if args.spec_modules else None
js = compile_ref_to_js(adapters, modules, extensions, spec_modules)
with open(args.output, "w") as f:
f.write(js)
included = ", ".join(adapters) if adapters else "all"
mods = ", ".join(modules) if modules else "all"
ext_label = ", ".join(extensions) if extensions else "none"
print(f"Wrote {args.output} ({len(js)} bytes, adapters: {included}, modules: {mods}, extensions: {ext_label})",
file=sys.stderr)

View File

@@ -745,7 +745,7 @@ PRIMITIVES["last"] = lambda c: c[-1] if c and _b_len(c) > 0 else NIL
PRIMITIVES["rest"] = lambda c: c[1:] if c else []
PRIMITIVES["nth"] = lambda c, n: c[n] if c and 0 <= n < _b_len(c) else NIL
PRIMITIVES["cons"] = lambda x, c: [x] + (c or [])
PRIMITIVES["append"] = lambda c, x: (c or []) + [x]
PRIMITIVES["append"] = lambda c, x: (c or []) + (x if isinstance(x, list) else [x])
PRIMITIVES["chunk-every"] = lambda c, n: [c[i:i+n] for i in _b_range(0, _b_len(c), n)]
PRIMITIVES["zip-pairs"] = lambda c: [[c[i], c[i+1]] for i in _b_range(_b_len(c)-1)]

View File

@@ -545,9 +545,12 @@
;; --------------------------------------------------------------------------
;; defpage
;; Server-only tests — skip in browser (defpage, streaming functions)
;; These require forms.sx which is only loaded server-side.
;; --------------------------------------------------------------------------
(when (get (try-call (fn () stream-chunk-id)) "ok")
(defsuite "defpage"
(deftest "basic defpage returns page-def"
(let ((p (defpage test-basic :path "/test" :auth :public :content (div "hello"))))
@@ -716,3 +719,5 @@
:content (~chunk :val val))))
(assert-equal true (get p "stream"))
(assert-true (not (nil? (get p "shell")))))))
) ;; end (when has-server-forms?)

View File

@@ -4,11 +4,14 @@ Relation registry — declarative entity relationship definitions.
Relations are defined as s-expressions using ``defrelation`` and stored
in a global registry. All services load the same definitions at startup
via ``load_relation_registry()``.
No evaluator dependency — defrelation forms are parsed directly from the
AST since they're just structured data (keyword args → RelationDef).
"""
from __future__ import annotations
from shared.sx.types import RelationDef
from shared.sx.types import Keyword, RelationDef, Symbol
# ---------------------------------------------------------------------------
@@ -48,6 +51,102 @@ def clear_registry() -> None:
_RELATION_REGISTRY.clear()
# ---------------------------------------------------------------------------
# defrelation parsing — direct AST walk, no evaluator needed
# ---------------------------------------------------------------------------
_VALID_CARDINALITIES = {"one-to-one", "one-to-many", "many-to-many"}
_VALID_NAV = {"submenu", "tab", "badge", "inline", "hidden"}
class RelationError(Exception):
"""Error parsing a defrelation form."""
pass
def _parse_defrelation(expr: list) -> RelationDef:
"""Parse a (defrelation :name :key val ...) AST into a RelationDef."""
if len(expr) < 2:
raise RelationError("defrelation requires a name")
name_kw = expr[1]
if not isinstance(name_kw, Keyword):
raise RelationError(
f"defrelation name must be a keyword, got {type(name_kw).__name__}"
)
rel_name = name_kw.name
# Parse keyword args
kwargs: dict[str, str | None] = {}
i = 2
while i < len(expr):
key = expr[i]
if isinstance(key, Keyword):
if i + 1 < len(expr):
val = expr[i + 1]
kwargs[key.name] = val.name if isinstance(val, Keyword) else val
i += 2
else:
kwargs[key.name] = None
i += 1
else:
i += 1
for field in ("from", "to", "cardinality"):
if field not in kwargs:
raise RelationError(
f"defrelation {rel_name} missing required :{field}"
)
card = kwargs["cardinality"]
if card not in _VALID_CARDINALITIES:
raise RelationError(
f"defrelation {rel_name}: invalid cardinality {card!r}, "
f"expected one of {_VALID_CARDINALITIES}"
)
nav = kwargs.get("nav", "hidden")
if nav not in _VALID_NAV:
raise RelationError(
f"defrelation {rel_name}: invalid nav {nav!r}, "
f"expected one of {_VALID_NAV}"
)
return RelationDef(
name=rel_name,
from_type=kwargs["from"],
to_type=kwargs["to"],
cardinality=card,
inverse=kwargs.get("inverse"),
nav=nav,
nav_icon=kwargs.get("nav-icon"),
nav_label=kwargs.get("nav-label"),
)
def evaluate_defrelation(expr: list) -> RelationDef:
"""Parse a defrelation form, register it, and return the RelationDef.
Also handles (begin (defrelation ...) ...) wrappers.
"""
if not isinstance(expr, list) or not expr:
raise RelationError(f"Expected list expression, got {type(expr).__name__}")
head = expr[0]
if isinstance(head, Symbol) and head.name == "begin":
result = None
for child in expr[1:]:
result = evaluate_defrelation(child)
return result
if not (isinstance(head, Symbol) and head.name == "defrelation"):
raise RelationError(f"Expected defrelation, got {head}")
defn = _parse_defrelation(expr)
register_relation(defn)
return defn
# ---------------------------------------------------------------------------
# Built-in relation definitions (s-expression source)
# ---------------------------------------------------------------------------
@@ -94,8 +193,7 @@ _BUILTIN_RELATIONS = '''
def load_relation_registry() -> None:
"""Parse built-in defrelation s-expressions and populate the registry."""
from shared.sx.evaluator import evaluate
from shared.sx.parser import parse
tree = parse(_BUILTIN_RELATIONS)
evaluate(tree)
evaluate_defrelation(tree)

View File

@@ -1,4 +1,4 @@
"""Test bootstrapper transpilation: JSEmitter and PyEmitter."""
"""Test bootstrapper transpilation: js.sx and py.sx."""
from __future__ import annotations
import os
@@ -6,49 +6,38 @@ import re
import pytest
from shared.sx.parser import parse, parse_all
from shared.sx.ref.bootstrap_js import (
JSEmitter,
from shared.sx.ref.run_js_sx import compile_ref_to_js, load_js_sx
from shared.sx.ref.platform_js import (
ADAPTER_FILES,
SPEC_MODULES,
extract_defines,
compile_ref_to_js,
)
from shared.sx.ref.bootstrap_py import PyEmitter
from shared.sx.types import Symbol, Keyword
class TestJSEmitterNativeDict:
"""JS bootstrapper must handle native Python dicts from {:key val} syntax."""
class TestJsSxTranslation:
"""js.sx self-hosting bootstrapper handles all SX constructs."""
def test_simple_string_values(self):
expr = parse('{"name" "hello"}')
assert isinstance(expr, dict)
js = JSEmitter().emit(expr)
assert js == '{"name": "hello"}'
def _translate(self, sx_source: str) -> str:
"""Translate a single SX expression to JS using js.sx."""
from shared.sx.evaluator import evaluate
env = load_js_sx()
expr = parse(sx_source)
env["_def_expr"] = expr
return evaluate(
[Symbol("js-expr"), Symbol("_def_expr")], env
)
def test_function_call_value(self):
"""Dict value containing a function call must emit the call, not raw AST."""
expr = parse('{"parsed" (parse-route-pattern (get page "path"))}')
js = JSEmitter().emit(expr)
assert "parseRoutePattern" in js
assert "Symbol" not in js
assert js == '{"parsed": parseRoutePattern(get(page, "path"))}'
def test_simple_dict(self):
result = self._translate('{"name" "hello"}')
assert '"name"' in result
assert '"hello"' in result
def test_multiple_keys(self):
expr = parse('{"a" 1 "b" (+ x 2)}')
js = JSEmitter().emit(expr)
assert '"a": 1' in js
assert '"b": (x + 2)' in js
def test_nested_dict(self):
expr = parse('{"outer" {"inner" 42}}')
js = JSEmitter().emit(expr)
assert '{"outer": {"inner": 42}}' == js
def test_nil_value(self):
expr = parse('{"key" nil}')
js = JSEmitter().emit(expr)
assert '"key": NIL' in js
def test_function_call_in_dict(self):
result = self._translate('{"parsed" (parse-route-pattern (get page "path"))}')
assert "parseRoutePattern" in result
assert "Symbol" not in result
class TestPyEmitterNativeDict:
@@ -92,11 +81,7 @@ class TestPlatformMapping:
"""Verify compiled JS output contains all spec-defined functions."""
def test_compiled_defines_present_in_js(self):
"""Every top-level define from spec files must appear in compiled JS output.
Catches: spec modules not included, _mangle producing wrong names for
defines, transpilation silently dropping definitions.
"""
"""Every top-level define from spec files must appear in compiled JS output."""
js_output = compile_ref_to_js(
spec_modules=list(SPEC_MODULES.keys()),
)
@@ -117,10 +102,17 @@ class TestPlatformMapping:
for name, _expr in extract_defines(open(filepath).read()):
all_defs.add(name)
emitter = JSEmitter()
# Use js.sx RENAMES to map SX names → JS names
env = load_js_sx()
renames = env.get("js-renames", {})
missing = []
for sx_name in sorted(all_defs):
js_name = emitter._mangle(sx_name)
# Check if there's an explicit rename
js_name = renames.get(sx_name)
if js_name is None:
# Auto-mangle: hyphens → camelCase, ! → _b, ? → _p
js_name = _auto_mangle(sx_name)
if js_name not in defined_in_js:
missing.append(f"{sx_name}{js_name}")
@@ -131,41 +123,29 @@ class TestPlatformMapping:
+ "\n ".join(missing)
)
def test_renames_values_are_unique(self):
"""RENAMES should not map different SX names to the same JS name.
Duplicate JS names would cause one definition to silently shadow another.
"""
renames = JSEmitter.RENAMES
seen: dict[str, str] = {}
dupes = []
for sx_name, js_name in sorted(renames.items()):
if js_name in seen:
# Allow intentional aliases (e.g. has-key? and dict-has?
# both → dictHas)
dupes.append(
f" {sx_name}{js_name} (same as {seen[js_name]})"
)
else:
seen[js_name] = sx_name
# Intentional aliases — these are expected duplicates
# (e.g. has-key? and dict-has? both map to dictHas)
# Don't fail for these, just document them
# The test serves as a warning for accidental duplicates
def _auto_mangle(name: str) -> str:
"""Approximate js.sx's auto-mangle for SX name → JS identifier."""
# Remove ~, replace ?, !, -, *, >
n = name
if n.startswith("~"):
n = n[1:]
n = n.replace("?", "_p").replace("!", "_b")
n = n.replace("->", "_to_").replace(">=", "_gte").replace("<=", "_lte")
n = n.replace(">", "_gt").replace("<", "_lt")
n = n.replace("*", "_star_").replace("/", "_slash_")
# camelCase from hyphens
parts = n.split("-")
if len(parts) > 1:
n = parts[0] + "".join(p.capitalize() for p in parts[1:])
return n
class TestPrimitivesRegistration:
"""Functions callable from runtime-evaluated SX must be in PRIMITIVES[...]."""
def test_declared_primitives_registered(self):
"""Every primitive declared in primitives.sx must have a PRIMITIVES[...] entry.
Primitives are called from runtime-evaluated SX (island bodies, user
components) via getPrimitive(). If a primitive is declared in
primitives.sx but not in PRIMITIVES[...], island code gets
"Undefined symbol" errors.
"""
"""Every primitive declared in primitives.sx must have a PRIMITIVES[...] entry."""
from shared.sx.ref.boundary_parser import parse_primitives_sx
declared = parse_primitives_sx()
@@ -173,8 +153,7 @@ class TestPrimitivesRegistration:
js_output = compile_ref_to_js()
registered = set(re.findall(r'PRIMITIVES\["([^"]+)"\]', js_output))
# Aliases — declared in primitives.sx under alternate names but
# served via canonical PRIMITIVES entries
# Aliases
aliases = {
"downcase": "lower",
"upcase": "upper",
@@ -186,7 +165,7 @@ class TestPrimitivesRegistration:
if alias in declared and canonical in registered:
declared = declared - {alias}
# Extension-only primitives (require continuations extension)
# Extension-only primitives
extension_only = {"continuation?"}
declared = declared - extension_only
@@ -199,12 +178,7 @@ class TestPrimitivesRegistration:
)
def test_signal_runtime_primitives_registered(self):
"""Signal/reactive functions used by island bodies must be in PRIMITIVES.
These are the reactive primitives that island SX code calls via
getPrimitive(). If any is missing, islands with reactive state fail
at runtime.
"""
"""Signal/reactive functions used by island bodies must be in PRIMITIVES."""
required = {
"signal", "signal?", "deref", "reset!", "swap!",
"computed", "effect", "batch", "resource",

View File

@@ -2,11 +2,12 @@
import pytest
from shared.sx.evaluator import evaluate, EvalError
from shared.sx.parser import parse
from shared.sx.relations import (
RelationError,
_RELATION_REGISTRY,
clear_registry,
evaluate_defrelation,
get_relation,
load_relation_registry,
relations_from,
@@ -38,7 +39,7 @@ class TestDefrelation:
:nav-icon "fa fa-shopping-bag"
:nav-label "markets")
''')
result = evaluate(tree)
result = evaluate_defrelation(tree)
assert isinstance(result, RelationDef)
assert result.name == "page->market"
assert result.from_type == "page"
@@ -54,7 +55,7 @@ class TestDefrelation:
(defrelation :a->b
:from "a" :to "b" :cardinality :one-to-one :nav :hidden)
''')
evaluate(tree)
evaluate_defrelation(tree)
assert get_relation("a->b") is not None
assert get_relation("a->b").cardinality == "one-to-one"
@@ -64,7 +65,7 @@ class TestDefrelation:
:from "page" :to "menu_node"
:cardinality :one-to-one :nav :hidden)
''')
result = evaluate(tree)
result = evaluate_defrelation(tree)
assert result.cardinality == "one-to-one"
assert result.inverse is None
assert result.nav == "hidden"
@@ -79,7 +80,7 @@ class TestDefrelation:
:nav-icon "fa fa-file-alt"
:nav-label "events")
''')
result = evaluate(tree)
result = evaluate_defrelation(tree)
assert result.cardinality == "many-to-many"
def test_default_nav_is_hidden(self):
@@ -87,7 +88,7 @@ class TestDefrelation:
(defrelation :x->y
:from "x" :to "y" :cardinality :one-to-many)
''')
result = evaluate(tree)
result = evaluate_defrelation(tree)
assert result.nav == "hidden"
def test_invalid_cardinality_raises(self):
@@ -95,42 +96,42 @@ class TestDefrelation:
(defrelation :bad
:from "a" :to "b" :cardinality :wrong)
''')
with pytest.raises(EvalError, match="invalid cardinality"):
evaluate(tree)
with pytest.raises(RelationError, match="invalid cardinality"):
evaluate_defrelation(tree)
def test_invalid_nav_raises(self):
tree = parse('''
(defrelation :bad
:from "a" :to "b" :cardinality :one-to-one :nav :bogus)
''')
with pytest.raises(EvalError, match="invalid nav"):
evaluate(tree)
with pytest.raises(RelationError, match="invalid nav"):
evaluate_defrelation(tree)
def test_missing_from_raises(self):
tree = parse('''
(defrelation :bad :to "b" :cardinality :one-to-one)
''')
with pytest.raises(EvalError, match="missing required :from"):
evaluate(tree)
with pytest.raises(RelationError, match="missing required :from"):
evaluate_defrelation(tree)
def test_missing_to_raises(self):
tree = parse('''
(defrelation :bad :from "a" :cardinality :one-to-one)
''')
with pytest.raises(EvalError, match="missing required :to"):
evaluate(tree)
with pytest.raises(RelationError, match="missing required :to"):
evaluate_defrelation(tree)
def test_missing_cardinality_raises(self):
tree = parse('''
(defrelation :bad :from "a" :to "b")
''')
with pytest.raises(EvalError, match="missing required :cardinality"):
evaluate(tree)
with pytest.raises(RelationError, match="missing required :cardinality"):
evaluate_defrelation(tree)
def test_name_must_be_keyword(self):
tree = parse('(defrelation "not-keyword" :from "a" :to "b" :cardinality :one-to-one)')
with pytest.raises(EvalError, match="must be a keyword"):
evaluate(tree)
with pytest.raises(RelationError, match="must be a keyword"):
evaluate_defrelation(tree)
# ---------------------------------------------------------------------------
@@ -154,7 +155,7 @@ class TestRegistry:
:from "page" :to "menu_node" :cardinality :one-to-one
:nav :hidden))
''')
evaluate(tree)
evaluate_defrelation(tree)
def test_get_relation(self):
self._load_sample()

View File

@@ -384,57 +384,47 @@ def _self_hosting_data(ref_dir: str) -> dict:
def _js_self_hosting_data(ref_dir: str) -> dict:
"""Run js.sx live: load into evaluator, translate spec files, diff against G0."""
"""Run js.sx live: load into evaluator, translate all spec defines."""
import os
from shared.sx.parser import parse_all
from shared.sx.types import Symbol
from shared.sx.evaluator import evaluate, make_env
from shared.sx.ref.bootstrap_js import extract_defines, JSEmitter
from shared.sx.evaluator import evaluate
from shared.sx.ref.run_js_sx import load_js_sx
from shared.sx.ref.platform_js import extract_defines
try:
js_sx_path = os.path.join(ref_dir, "js.sx")
with open(js_sx_path, encoding="utf-8") as f:
js_sx_source = f.read()
exprs = parse_all(js_sx_source)
env = make_env()
for expr in exprs:
evaluate(expr, env)
emitter = JSEmitter()
env = load_js_sx()
# All spec files
all_files = sorted(
f for f in os.listdir(ref_dir) if f.endswith(".sx")
)
total = 0
matched = 0
for filename in all_files:
filepath = os.path.join(ref_dir, filename)
with open(filepath, encoding="utf-8") as f:
src = f.read()
defines = extract_defines(src)
for name, expr in defines:
g0_stmt = emitter.emit_statement(expr)
env["_def_expr"] = expr
g1_stmt = evaluate(
evaluate(
[Symbol("js-statement"), Symbol("_def_expr")], env
)
total += 1
if g0_stmt.strip() == g1_stmt.strip():
matched += 1
status = "identical" if matched == total else "mismatch"
status = "ok"
except Exception as e:
js_sx_source = f";; error loading js.sx: {e}"
matched, total = 0, 0
total = 0
status = "error"
return {
"bootstrapper-not-found": None,
"js-sx-source": js_sx_source,
"defines-matched": str(matched),
"defines-total": str(total),
"js-sx-lines": str(len(js_sx_source.splitlines())),
"verification-status": status,