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:
@@ -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
@@ -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)))))))
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)]
|
||||
''',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)]
|
||||
|
||||
|
||||
@@ -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?)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user