WIP: pre-existing changes from WASM browser work + test infrastructure

Accumulated changes from WASM browser development sessions:
- sx_runtime.ml: signal subscription + notify, env unwrap tolerance
- sx_browser.bc.js: rebuilt js_of_ocaml browser kernel
- sx_browser.bc.wasm.js + assets: WASM browser kernel build
- sx-platform.js browser tests (test_js, test_platform, test_wasm)
- Playwright sx-inspect.js: interactive page inspector tool
- harness-web.sx: DOM assertion updates
- deploy.sh, Dockerfile, dune-project: build config updates
- test-stepper.sx: stepper unit tests
- reader-macro-demo plan update

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-26 16:40:38 +00:00
parent 10576f86d1
commit c72a5af04d
47 changed files with 5485 additions and 1728 deletions

View File

@@ -63,8 +63,12 @@ for app in "${BUILD[@]}"; do
echo "=== $app ==="
docker build -f "$dir/Dockerfile" -t "$REGISTRY/$app:latest" .
docker push "$REGISTRY/$app:latest"
docker service update --force "coop_$app" 2>/dev/null \
|| echo " (service coop_$app not running — will start on next stack deploy)"
case "$app" in
sx_docs) svc="sx-web_sx_docs" ;;
*) svc="coop_$app" ;;
esac
docker service update --force "$svc" 2>/dev/null \
|| echo " (service $svc not running — will start on next stack deploy)"
echo ""
done

View File

@@ -14,6 +14,7 @@ services:
RELOAD: "true"
SX_USE_REF: "1"
SX_USE_OCAML: "1"
SX_USE_WASM: "1"
SX_OCAML_BIN: "/app/bin/sx_server"
SX_BOUNDARY_STRICT: "1"
SX_DEV: "1"

View File

@@ -306,6 +306,31 @@ if (fullBuild) {
}
}
}
// Load web harnesses (DOM mocking, signals, rendering awareness)
const webDir = path.join(projectDir, "web");
for (const webFile of ["harness-web.sx", "harness-reactive.sx"]) {
const wp = path.join(webDir, webFile);
if (fs.existsSync(wp)) {
const src = fs.readFileSync(wp, "utf8");
const exprs = Sx.parse(src);
for (const expr of exprs) {
try { Sx.eval(expr, env); } catch (e) {
console.error(`Error loading ${webFile}: ${e.message}`);
}
}
}
}
// Load stepper-lib (shared stepper functions used by lib/tests/test-stepper.sx)
const stepperLibPath = path.join(projectDir, "sx", "sx", "stepper-lib.sx");
if (fs.existsSync(stepperLibPath)) {
const src = fs.readFileSync(stepperLibPath, "utf8");
const exprs = Sx.parse(src);
for (const expr of exprs) {
try { Sx.eval(expr, env); } catch (e) {
console.error(`Error loading stepper-lib.sx: ${e.message}`);
}
}
}
}
// Determine which tests to run

View File

@@ -0,0 +1,62 @@
#!/usr/bin/env node
// Test js_of_ocaml build of SX kernel
const path = require('path');
require(path.join(__dirname, '../_build/default/browser/sx_browser.bc.js'));
const sx = globalThis.SxKernel;
console.log('Engine:', sx.engine());
const tests = [
['(+ 1 2)', 3],
['(- 10 3)', 7],
['(* 6 7)', 42],
['(/ 10 2)', 5],
['(= 5 5)', true],
['(< 3 5)', true],
['(> 5 3)', true],
['(not false)', true],
['(inc 5)', 6],
['(dec 5)', 4],
['(len (list 1 2 3))', 3],
['(len "hello")', 5],
['(first (list 10 20))', 10],
['(nth "hello" 0)', 'h'],
['(nth "hello" 4)', 'o'],
['(str "a" "b")', 'ab'],
['(join ", " (list "a" "b" "c"))', 'a, b, c'],
['(let ((x 10) (y 20)) (+ x y))', 30],
['(if true "yes" "no")', 'yes'],
['(cond (= 1 2) "one" :else "other")', 'other'],
['(case 2 1 "one" 2 "two" :else "other")', 'two'],
['(render-to-html (list (quote div) "hello"))', '<div>hello</div>'],
['(render-to-html (list (quote span) (list (quote b) "bold")))', '<span><b>bold</b></span>'],
// Lambda + closure
['(let ((add (fn (a b) (+ a b)))) (add 3 4))', 7],
['(let ((x 10)) (let ((f (fn () x))) (f)))', 10],
// Higher-order
['(len (filter (fn (x) (> x 2)) (list 1 2 3 4 5)))', 3],
// Recursion
['(let ((fact (fn (n) (if (<= n 1) 1 (* n (fact (- n 1))))))) (fact 5))', 120],
];
let passed = 0, failed = 0;
for (const [expr, expected] of tests) {
try {
const result = sx.eval(expr);
const ok = typeof expected === 'object'
? result && result._type === expected._type
: result === expected;
if (ok) {
passed++;
} else {
console.log(` FAIL: ${expr} = ${JSON.stringify(result)} (expected ${JSON.stringify(expected)})`);
failed++;
}
} catch (e) {
console.log(` ERROR: ${expr}: ${e.message || e}`);
failed++;
}
}
console.log(`${passed} passed, ${failed} failed`);
process.exit(failed > 0 ? 1 : 0);

View File

@@ -0,0 +1,134 @@
#!/usr/bin/env node
/**
* Test the full WASM + platform stack in Node.
* Loads the kernel, registers FFI stubs, loads .sx web files.
*/
const path = require('path');
const fs = require('fs');
// Load js_of_ocaml kernel (WASM needs browser; JS works in Node)
require(path.join(__dirname, '../_build/default/browser/sx_browser.bc.js'));
const K = globalThis.SxKernel;
console.log('Engine:', K.engine());
// Register FFI stubs (no real DOM in Node, but the primitives must exist)
K.registerNative("host-global", (args) => {
const name = args[0];
return globalThis[name] || null;
});
K.registerNative("host-get", (args) => {
const [obj, prop] = args;
if (obj == null) return null;
const v = obj[prop];
return v === undefined ? null : v;
});
K.registerNative("host-set!", (args) => {
const [obj, prop, val] = args;
if (obj != null) obj[prop] = val;
});
K.registerNative("host-call", (args) => {
const [obj, method, ...rest] = args;
if (obj == null) return null;
if (typeof obj[method] === 'function') {
try { return obj[method].apply(obj, rest); } catch(e) { return null; }
}
return null;
});
K.registerNative("host-new", (args) => null);
K.registerNative("host-callback", (args) => {
const fn = args[0];
if (typeof fn === 'function') return fn;
if (fn && fn.__sx_handle !== undefined)
return (...a) => K.callFn(fn, a);
return () => {};
});
K.registerNative("host-typeof", (args) => {
const obj = args[0];
if (obj == null) return "nil";
return typeof obj;
});
K.registerNative("host-await", (args) => {
const [promise, callback] = args;
if (promise && typeof promise.then === 'function') {
const cb = typeof callback === 'function' ? callback :
(callback && callback.__sx_handle !== undefined) ?
(v) => K.callFn(callback, [v]) : () => {};
promise.then(cb);
}
});
// Load .sx web files in order
const root = path.join(__dirname, '../../..');
const sxFiles = [
'spec/render.sx', // HTML_TAGS, VOID_ELEMENTS, BOOLEAN_ATTRS, parse-element-args
'web/signals.sx',
'web/deps.sx',
'web/router.sx',
'web/page-helpers.sx',
'lib/bytecode.sx',
'lib/compiler.sx',
'lib/vm.sx',
'web/lib/dom.sx',
'web/lib/browser.sx',
'web/adapter-html.sx',
'web/adapter-sx.sx',
// Skip adapter-dom.sx, engine.sx, orchestration.sx, boot.sx — need real DOM
];
let totalExprs = 0;
for (const f of sxFiles) {
const src = fs.readFileSync(path.join(root, f), 'utf8');
const result = K.load(src);
if (typeof result === 'string' && result.startsWith('Error')) {
console.error(` FAIL loading ${f}: ${result}`);
process.exit(1);
}
totalExprs += (typeof result === 'number' ? result : 0);
}
console.log(`Loaded ${totalExprs} expressions from ${sxFiles.length} .sx files`);
// Test the loaded stack
const tests = [
// Signals
['(let ((s (signal 0))) (reset! s 42) (deref s))', 42],
['(let ((s (signal 10))) (swap! s inc) (deref s))', 11],
// Computed
['(let ((a (signal 2)) (b (computed (fn () (* (deref a) 3))))) (deref b))', 6],
// Render (OCaml renderer uses XHTML-style void tags)
['(render-to-html (quote (div :class "foo" "bar")))', '<div class="foo">bar</div>'],
['(render-to-html (quote (br)))', '<br />'],
// Compiler + VM
['(let ((c (compile (quote (+ 1 2))))) (get c "bytecode"))', { check: v => v && v._type === 'list' }],
// dom.sx loaded (functions exist even without real DOM)
['(type-of dom-create-element)', 'lambda'],
['(type-of dom-listen)', 'lambda'],
// browser.sx loaded
['(type-of console-log)', 'lambda'],
];
let passed = 0, failed = 0;
for (const [expr, expected] of tests) {
try {
const result = K.eval(expr);
let ok;
if (expected && typeof expected === 'object' && expected.check) {
ok = expected.check(result);
} else {
ok = result === expected;
}
if (ok) {
passed++;
} else {
console.log(` FAIL: ${expr}`);
console.log(` got: ${JSON.stringify(result)}, expected: ${JSON.stringify(expected)}`);
failed++;
}
} catch(e) {
console.log(` ERROR: ${expr}: ${e.message || e}`);
failed++;
}
}
console.log(`\n${passed} passed, ${failed} failed`);
process.exit(failed > 0 ? 1 : 0);

View File

@@ -0,0 +1,73 @@
#!/usr/bin/env node
// Test WASM build of SX kernel
const path = require('path');
const build_dir = path.join(__dirname, '../_build/default/browser');
async function main() {
// Load WASM module — require.main.filename must point to build dir
// so the WASM loader finds .wasm assets via path.dirname(require.main.filename)
require.main.filename = path.join(build_dir, 'test_wasm.js');
require(path.join(build_dir, 'sx_browser.bc.wasm.js'));
// Wait for WASM init
await new Promise(r => setTimeout(r, 2000));
const sx = globalThis.SxKernel;
if (!sx) {
console.error('FAIL: SxKernel not available');
process.exit(1);
}
console.log('Engine:', sx.engine());
// Basic tests
const tests = [
['(+ 1 2)', 3],
['(- 10 3)', 7],
['(* 6 7)', 42],
['(/ 10 2)', 5],
['(= 5 5)', true],
['(< 3 5)', true],
['(> 5 3)', true],
['(not false)', true],
['(inc 5)', 6],
['(dec 5)', 4],
['(len (list 1 2 3))', 3],
['(len "hello")', 5],
['(first (list 10 20))', 10],
['(nth "hello" 0)', 'h'],
['(nth "hello" 4)', 'o'],
['(str "a" "b")', 'ab'],
['(join ", " (list "a" "b" "c"))', 'a, b, c'],
['(let ((x 10) (y 20)) (+ x y))', 30],
['(if true "yes" "no")', 'yes'],
['(cond (= 1 2) "one" :else "other")', 'other'],
['(case 2 1 "one" 2 "two" :else "other")', 'two'],
['(render-to-html (list (quote div) "hello"))', '<div>hello</div>'],
['(render-to-html (list (quote span) (list (quote b) "bold")))', '<span><b>bold</b></span>'],
['(let ((add (fn (a b) (+ a b)))) (add 3 4))', 7],
['(let ((x 10)) (let ((f (fn () x))) (f)))', 10],
['(len (filter (fn (x) (> x 2)) (list 1 2 3 4 5)))', 3],
['(let ((fact (fn (n) (if (<= n 1) 1 (* n (fact (- n 1))))))) (fact 5))', 120],
];
let passed = 0, failed = 0;
for (const [expr, expected] of tests) {
const result = sx.eval(expr);
const ok = typeof expected === 'object'
? result && result._type === expected._type
: result === expected;
if (ok) {
console.log(` PASS: ${expr} = ${JSON.stringify(result)}`);
passed++;
} else {
console.log(` FAIL: ${expr} = ${JSON.stringify(result)} (expected ${JSON.stringify(expected)})`);
failed++;
}
}
console.log(`\n${passed} passed, ${failed} failed`);
process.exit(failed > 0 ? 1 : 0);
}
main().catch(e => { console.error(e); process.exit(1); });

View File

@@ -1,2 +1,2 @@
(lang dune 3.0)
(lang dune 3.19)
(name sx)

View File

@@ -361,11 +361,44 @@ let signal_value s = match s with
| _ -> raise (Eval_error "not a signal")
let signal_set_value s v = match s with Signal sig' -> sig'.s_value <- v; v | _ -> raise (Eval_error "not a signal")
let signal_subscribers s = match s with Signal sig' -> List (List.map (fun _ -> Nil) sig'.s_subscribers) | _ -> List []
let signal_add_sub_b _s _f = Nil
let signal_remove_sub_b _s _f = Nil
let signal_deps _s = List []
let signal_set_deps _s _d = Nil
let notify_subscribers _s = Nil
let signal_add_sub_b s f =
match s with
| Dict d ->
(match Hashtbl.find_opt d "subscribers" with
| Some (ListRef r) -> r := !r @ [f]; Nil
| Some (List items) -> Hashtbl.replace d "subscribers" (ListRef (ref (items @ [f]))); Nil
| _ -> Hashtbl.replace d "subscribers" (ListRef (ref [f])); Nil)
| _ -> Nil
let signal_remove_sub_b s f =
match s with
| Dict d ->
(match Hashtbl.find_opt d "subscribers" with
| Some (ListRef r) -> r := List.filter (fun x -> x != f) !r; Nil
| Some (List items) -> Hashtbl.replace d "subscribers" (List (List.filter (fun x -> x != f) items)); Nil
| _ -> Nil)
| _ -> Nil
let signal_deps s =
match s with
| Dict d -> (match Hashtbl.find_opt d "deps" with Some v -> v | None -> List [])
| _ -> List []
let signal_set_deps s d =
match s with
| Dict tbl -> Hashtbl.replace tbl "deps" d; Nil
| _ -> Nil
let notify_subscribers s =
let subs = match s with
| Dict d -> (match Hashtbl.find_opt d "subscribers" with
| Some (ListRef { contents = items }) | Some (List items) -> items
| _ -> [])
| _ -> []
in
List.iter (fun f -> ignore (sx_call f [])) subs;
Nil
let flush_subscribers _s = Nil
let dispose_computed _s = Nil

186
lib/tests/test-stepper.sx Normal file
View File

@@ -0,0 +1,186 @@
(defsuite
"stepper-lib"
(deftest
"split-tag: string literal becomes leaf step"
(let
((result (list)))
(split-tag "hello" result)
(assert-equal 1 (len result))
(assert-equal "leaf" (get (first result) "type"))
(assert-equal "hello" (get (first result) "expr"))))
(deftest
"split-tag: number literal becomes leaf step"
(let
((result (list)))
(split-tag 42 result)
(assert-equal 1 (len result))
(assert-equal "leaf" (get (first result) "type"))
(assert-equal 42 (get (first result) "expr"))))
(deftest
"split-tag: empty list produces nothing"
(let
((result (list)))
(split-tag (list) result)
(assert-equal 0 (len result))))
(deftest
"split-tag: simple div becomes open+close"
(let
((result (list)) (parsed (sx-parse "(div)")))
(split-tag (first parsed) result)
(assert-equal 2 (len result))
(assert-equal "open" (get (first result) "type"))
(assert-equal "div" (get (first result) "tag"))
(assert-equal "close" (get (nth result 1) "type"))))
(deftest
"split-tag: div with text child becomes open+leaf+close"
(let
((result (list)) (parsed (sx-parse "(div \"hello\")")))
(split-tag (first parsed) result)
(assert-equal 3 (len result))
(assert-equal "open" (get (first result) "type"))
(assert-equal "leaf" (get (nth result 1) "type"))
(assert-equal "hello" (get (nth result 1) "expr"))
(assert-equal "close" (get (nth result 2) "type"))))
(deftest
"split-tag: keyword attrs captured in open step"
(let
((result (list)) (parsed (sx-parse "(div :class \"foo\" \"text\")")))
(split-tag (first parsed) result)
(assert-equal 3 (len result))
(assert-equal "open" (get (first result) "type"))
(let
((attrs (get (first result) "attrs")))
(assert-equal 2 (len attrs)))))
(deftest
"split-tag: nested tags produce nested open/close pairs"
(let
((result (list)) (parsed (sx-parse "(div (span \"hi\"))")))
(split-tag (first parsed) result)
(assert-equal 5 (len result))
(assert-equal "open" (get (first result) "type"))
(assert-equal "div" (get (first result) "tag"))
(assert-equal "open" (get (nth result 1) "type"))
(assert-equal "span" (get (nth result 1) "tag"))
(assert-equal "leaf" (get (nth result 2) "type"))
(assert-equal "close" (get (nth result 3) "type"))
(assert-equal "close" (get (nth result 4) "type"))))
(deftest
"split-tag: component call becomes expr step"
(let
((result (list)) (parsed (sx-parse "(~my/comp :title \"hi\")")))
(split-tag (first parsed) result)
(assert-equal 1 (len result))
(assert-equal "expr" (get (first result) "type"))))
(deftest
"split-tag: component inside div becomes spread"
(let
((result (list))
(parsed (sx-parse "(div (~cssx/tw :tokens \"foo\") \"text\")")))
(split-tag (first parsed) result)
(let
((open-step (first result)))
(assert-equal "open" (get open-step "type"))
(assert-equal 1 (len (get open-step "spreads"))))))
(deftest
"build-code-tokens: string expr produces quoted token"
(let
((tokens (list)) (ref (dict "v" 0)))
(build-code-tokens "hello" tokens ref 0)
(assert-equal 1 (len tokens))
(assert-equal "\"hello\"" (get (first tokens) "text"))
(assert-equal "text-emerald-700" (get (first tokens) "cls"))
(assert-equal 0 (get (first tokens) "step"))))
(deftest
"build-code-tokens: number expr produces token"
(let
((tokens (list)) (ref (dict "v" 0)))
(build-code-tokens 42 tokens ref 0)
(assert-equal 1 (len tokens))
(assert-equal "42" (get (first tokens) "text"))
(assert-equal "text-amber-700" (get (first tokens) "cls"))))
(deftest
"build-code-tokens: symbol produces token with name"
(let
((tokens (list)) (ref (dict "v" 0)))
(build-code-tokens (make-symbol "foo") tokens ref 0)
(assert-equal 1 (len tokens))
(assert-equal "foo" (get (first tokens) "text"))
(assert-equal "text-stone-700" (get (first tokens) "cls"))))
(deftest
"build-code-tokens: html tag symbol gets sky color"
(let
((tokens (list)) (ref (dict "v" 0)))
(build-code-tokens (make-symbol "div") tokens ref 0)
(assert-equal "text-sky-700 font-semibold" (get (first tokens) "cls"))))
(deftest
"build-code-tokens: component symbol gets rose color"
(let
((tokens (list)) (ref (dict "v" 0)))
(build-code-tokens (make-symbol "~my/comp") tokens ref 0)
(assert-equal "text-rose-600 font-semibold" (get (first tokens) "cls"))))
(deftest
"build-code-tokens: list produces open-paren + children + close-paren"
(let
((tokens (list))
(ref (dict "v" 0))
(parsed (sx-parse "(div \"hi\")")))
(build-code-tokens (first parsed) tokens ref 0)
(assert-equal "(" (get (first tokens) "text"))
(assert-equal ")" (get (last tokens) "text"))))
(deftest
"build-code-tokens: step counter increments for strings"
(let
((tokens (list)) (ref (dict "v" 5)))
(build-code-tokens "hello" tokens ref 0)
(assert-equal 5 (get (first tokens) "step"))
(assert-equal 6 (get ref "v"))))
(deftest
"steps-to-preview: empty steps returns nil"
(assert-equal nil (steps-to-preview (list) 5)))
(deftest
"steps-to-preview: target 0 returns nil"
(let
((result (list)))
(split-tag "hello" result)
(assert-equal nil (steps-to-preview result 0))))
(deftest
"steps-to-preview: single leaf step reconstructs value"
(let
((result (list)))
(split-tag "hello" result)
(assert-equal "hello" (steps-to-preview result 1))))
(deftest
"steps-to-preview: open+close reconstructs empty tag"
(let
((result (list)) (parsed (sx-parse "(div)")))
(split-tag (first parsed) result)
(let
((preview (steps-to-preview result 2)))
(assert-equal "div" (symbol-name (first preview))))))
(deftest
"steps-to-preview: partial steps build partial tree"
(let
((result (list)) (parsed (sx-parse "(div (span \"a\") (span \"b\"))")))
(split-tag (first parsed) result)
(let
((preview (steps-to-preview result 4)))
(assert-equal "div" (symbol-name (first preview)))
(assert-equal true (> (len preview) 1)))))
(deftest
"steps-to-preview: full steps build complete tree"
(let
((result (list)) (parsed (sx-parse "(div \"hello\")")))
(split-tag (first parsed) result)
(let
((preview (steps-to-preview result (len result))))
(assert-equal "div" (symbol-name (first preview)))
(assert-equal "hello" (nth preview 1))))))
(deftest
"split-tag result is a render leak (list of dicts)"
(let
((result (list)) (parsed (sx-parse "(div \"hello\")")))
(split-tag (first parsed) result)
(assert-equal true (is-render-leak? result))
(assert-equal true (> (len result) 0))
(assert-equal true (dict? (first result)))))

View File

@@ -14,7 +14,7 @@
// =========================================================================
var NIL = Object.freeze({ _nil: true, toString: function() { return "nil"; } });
var SX_VERSION = "2026-03-26T11:30:03Z";
var SX_VERSION = "2026-03-26T16:13:53Z";
function isNil(x) { return x === NIL || x === null || x === undefined; }
function isSxTruthy(x) { return x !== false && !isNil(x); }
@@ -6583,6 +6583,400 @@ PRIMITIVES["bridge-event"] = bridgeEvent;
PRIMITIVES["resource"] = resource;
// === Transpiled from types (gradual type system) ===
// base-types
var baseTypes = ["number", "string", "boolean", "nil", "symbol", "keyword", "element", "any", "never", "list", "dict", "lambda", "component", "island", "macro", "signal"];
PRIMITIVES["base-types"] = baseTypes;
// type-any?
var typeAny_p = function(t) { return (t == "any"); };
PRIMITIVES["type-any?"] = typeAny_p;
// type-never?
var typeNever_p = function(t) { return (t == "never"); };
PRIMITIVES["type-never?"] = typeNever_p;
// type-nullable?
var typeNullable_p = function(t) { return (isSxTruthy((t == "any")) ? true : (isSxTruthy((t == "nil")) ? true : (isSxTruthy((isSxTruthy((typeOf(t) == "string")) && endsWith(t, "?"))) ? true : (isSxTruthy((isSxTruthy((typeOf(t) == "list")) && isSxTruthy(!isSxTruthy(isEmpty(t))) && (first(t) == "or"))) ? contains(rest(t), "nil") : false)))); };
PRIMITIVES["type-nullable?"] = typeNullable_p;
// nullable-base
var nullableBase = function(t) { return (isSxTruthy((isSxTruthy((typeOf(t) == "string")) && isSxTruthy(endsWith(t, "?")) && !isSxTruthy((t == "?")))) ? slice(t, 0, (stringLength(t) - 1)) : t); };
PRIMITIVES["nullable-base"] = nullableBase;
// subtype?
var subtype_p = function(a, b) { return (isSxTruthy(typeAny_p(b)) ? true : (isSxTruthy(typeNever_p(a)) ? true : (isSxTruthy(typeAny_p(a)) ? false : (isSxTruthy((a == b)) ? true : (isSxTruthy((a == "nil")) ? typeNullable_p(b) : (isSxTruthy((isSxTruthy((typeOf(b) == "string")) && endsWith(b, "?"))) ? (function() {
var base = nullableBase(b);
return sxOr((a == base), (a == "nil"));
})() : (isSxTruthy((isSxTruthy((typeOf(a) == "list")) && isSxTruthy(!isSxTruthy(isEmpty(a))) && (first(a) == "or"))) ? isEvery(function(member) { return subtype_p(member, b); }, rest(a)) : (isSxTruthy((isSxTruthy((typeOf(b) == "list")) && isSxTruthy(!isSxTruthy(isEmpty(b))) && (first(b) == "or"))) ? some(function(member) { return subtype_p(a, member); }, rest(b)) : (isSxTruthy((isSxTruthy((typeOf(a) == "list")) && isSxTruthy((typeOf(b) == "list")) && isSxTruthy((len(a) == 2)) && isSxTruthy((len(b) == 2)) && isSxTruthy((first(a) == "list-of")) && (first(b) == "list-of"))) ? subtype_p(nth(a, 1), nth(b, 1)) : (isSxTruthy((isSxTruthy((a == "list")) && isSxTruthy((typeOf(b) == "list")) && isSxTruthy((len(b) == 2)) && (first(b) == "list-of"))) ? typeAny_p(nth(b, 1)) : (isSxTruthy((isSxTruthy((typeOf(a) == "list")) && isSxTruthy((len(a) == 2)) && isSxTruthy((first(a) == "list-of")) && (b == "list"))) ? true : false))))))))))); };
PRIMITIVES["subtype?"] = subtype_p;
// type-union
var typeUnion = function(a, b) { return (isSxTruthy((a == b)) ? a : (isSxTruthy(typeAny_p(a)) ? "any" : (isSxTruthy(typeAny_p(b)) ? "any" : (isSxTruthy(typeNever_p(a)) ? b : (isSxTruthy(typeNever_p(b)) ? a : (isSxTruthy(subtype_p(a, b)) ? b : (isSxTruthy(subtype_p(b, a)) ? a : (isSxTruthy((a == "nil")) ? (isSxTruthy((isSxTruthy((typeOf(b) == "string")) && !isSxTruthy(endsWith(b, "?")))) ? (String(b) + String("?")) : ["or", a, b]) : (isSxTruthy((b == "nil")) ? (isSxTruthy((isSxTruthy((typeOf(a) == "string")) && !isSxTruthy(endsWith(a, "?")))) ? (String(a) + String("?")) : ["or", a, b]) : ["or", a, b]))))))))); };
PRIMITIVES["type-union"] = typeUnion;
// narrow-type
var narrowType = function(t, predicateName) { return (isSxTruthy((predicateName == "nil?")) ? ["nil", narrowExcludeNil(t)] : (isSxTruthy((predicateName == "string?")) ? ["string", narrowExclude(t, "string")] : (isSxTruthy((predicateName == "number?")) ? ["number", narrowExclude(t, "number")] : (isSxTruthy((predicateName == "list?")) ? ["list", narrowExclude(t, "list")] : (isSxTruthy((predicateName == "dict?")) ? ["dict", narrowExclude(t, "dict")] : (isSxTruthy((predicateName == "boolean?")) ? ["boolean", narrowExclude(t, "boolean")] : [t, t])))))); };
PRIMITIVES["narrow-type"] = narrowType;
// narrow-exclude-nil
var narrowExcludeNil = function(t) { return (isSxTruthy((t == "nil")) ? "never" : (isSxTruthy((t == "any")) ? "any" : (isSxTruthy((isSxTruthy((typeOf(t) == "string")) && endsWith(t, "?"))) ? nullableBase(t) : (isSxTruthy((isSxTruthy((typeOf(t) == "list")) && isSxTruthy(!isSxTruthy(isEmpty(t))) && (first(t) == "or"))) ? (function() {
var members = filter(function(m) { return !isSxTruthy((m == "nil")); }, rest(t));
return (isSxTruthy((len(members) == 1)) ? first(members) : (isSxTruthy(isEmpty(members)) ? "never" : cons("or", members)));
})() : t)))); };
PRIMITIVES["narrow-exclude-nil"] = narrowExcludeNil;
// narrow-exclude
var narrowExclude = function(t, excluded) { return (isSxTruthy((t == excluded)) ? "never" : (isSxTruthy((t == "any")) ? "any" : (isSxTruthy((isSxTruthy((typeOf(t) == "list")) && isSxTruthy(!isSxTruthy(isEmpty(t))) && (first(t) == "or"))) ? (function() {
var members = filter(function(m) { return !isSxTruthy((m == excluded)); }, rest(t));
return (isSxTruthy((len(members) == 1)) ? first(members) : (isSxTruthy(isEmpty(members)) ? "never" : cons("or", members)));
})() : t))); };
PRIMITIVES["narrow-exclude"] = narrowExclude;
// infer-type
var inferType = function(node, typeEnv, primTypes, typeRegistry) { return (function() {
var kind = typeOf(node);
return (isSxTruthy((kind == "number")) ? "number" : (isSxTruthy((kind == "string")) ? "string" : (isSxTruthy((kind == "boolean")) ? "boolean" : (isSxTruthy(isNil(node)) ? "nil" : (isSxTruthy((kind == "keyword")) ? "keyword" : (isSxTruthy((kind == "symbol")) ? (function() {
var name = symbolName(node);
return (isSxTruthy(dictHas(typeEnv, name)) ? get(typeEnv, name) : (isSxTruthy((name == "true")) ? "boolean" : (isSxTruthy((name == "false")) ? "boolean" : (isSxTruthy((name == "nil")) ? "nil" : (isSxTruthy(dictHas(primTypes, name)) ? get(primTypes, name) : "any")))));
})() : (isSxTruthy((kind == "dict")) ? "dict" : (isSxTruthy((kind == "list")) ? inferListType(node, typeEnv, primTypes, typeRegistry) : "any"))))))));
})(); };
PRIMITIVES["infer-type"] = inferType;
// infer-list-type
var inferListType = function(node, typeEnv, primTypes, typeRegistry) { return (isSxTruthy(isEmpty(node)) ? "list" : (function() {
var head = first(node);
var args = rest(node);
return (isSxTruthy(!isSxTruthy((typeOf(head) == "symbol"))) ? "any" : (function() {
var name = symbolName(head);
return (isSxTruthy((name == "if")) ? inferIfType(args, typeEnv, primTypes, typeRegistry) : (isSxTruthy((name == "when")) ? (isSxTruthy((len(args) >= 2)) ? typeUnion(inferType(last(args), typeEnv, primTypes, typeRegistry), "nil") : "nil") : (isSxTruthy(sxOr((name == "cond"), (name == "case"))) ? "any" : (isSxTruthy((name == "let")) ? inferLetType(args, typeEnv, primTypes, typeRegistry) : (isSxTruthy(sxOr((name == "do"), (name == "begin"))) ? (isSxTruthy(isEmpty(args)) ? "nil" : inferType(last(args), typeEnv, primTypes, typeRegistry)) : (isSxTruthy(sxOr((name == "lambda"), (name == "fn"))) ? "lambda" : (isSxTruthy((name == "and")) ? (isSxTruthy(isEmpty(args)) ? "boolean" : inferType(last(args), typeEnv, primTypes, typeRegistry)) : (isSxTruthy((name == "or")) ? (isSxTruthy(isEmpty(args)) ? "boolean" : reduce(typeUnion, "never", map(function(a) { return inferType(a, typeEnv, primTypes, typeRegistry); }, args))) : (isSxTruthy((name == "map")) ? (isSxTruthy((len(args) >= 2)) ? (function() {
var fnType = inferType(first(args), typeEnv, primTypes, typeRegistry);
return (isSxTruthy((isSxTruthy((typeOf(fnType) == "list")) && (first(fnType) == "->"))) ? ["list-of", last(fnType)] : "list");
})() : "list") : (isSxTruthy((name == "filter")) ? (isSxTruthy((len(args) >= 2)) ? inferType(nth(args, 1), typeEnv, primTypes, typeRegistry) : "list") : (isSxTruthy((name == "reduce")) ? "any" : (isSxTruthy((name == "list")) ? "list" : (isSxTruthy((name == "dict")) ? "dict" : (isSxTruthy((name == "quote")) ? "any" : (isSxTruthy((name == "str")) ? "string" : (isSxTruthy((name == "not")) ? "boolean" : (isSxTruthy((name == "get")) ? (isSxTruthy((isSxTruthy((len(args) >= 2)) && !isSxTruthy(isNil(typeRegistry)))) ? (function() {
var dictType = inferType(first(args), typeEnv, primTypes, typeRegistry);
var keyArg = nth(args, 1);
var keyName = (isSxTruthy((typeOf(keyArg) == "keyword")) ? keywordName(keyArg) : (isSxTruthy((typeOf(keyArg) == "string")) ? keyArg : NIL));
return (isSxTruthy((isSxTruthy(keyName) && isSxTruthy((typeOf(dictType) == "string")) && dictHas(typeRegistry, dictType))) ? (function() {
var resolved = resolveType(dictType, typeRegistry);
return (isSxTruthy((isSxTruthy((typeOf(resolved) == "dict")) && dictHas(resolved, keyName))) ? get(resolved, keyName) : "any");
})() : "any");
})() : "any") : (isSxTruthy(startsWith(name, "~")) ? "element" : (isSxTruthy(dictHas(primTypes, name)) ? get(primTypes, name) : "any")))))))))))))))))));
})());
})()); };
PRIMITIVES["infer-list-type"] = inferListType;
// infer-if-type
var inferIfType = function(args, typeEnv, primTypes, typeRegistry) { return (isSxTruthy((len(args) < 2)) ? "nil" : (function() {
var thenType = inferType(nth(args, 1), typeEnv, primTypes, typeRegistry);
return (isSxTruthy((len(args) >= 3)) ? typeUnion(thenType, inferType(nth(args, 2), typeEnv, primTypes, typeRegistry)) : typeUnion(thenType, "nil"));
})()); };
PRIMITIVES["infer-if-type"] = inferIfType;
// infer-let-type
var inferLetType = function(args, typeEnv, primTypes, typeRegistry) { return (isSxTruthy((len(args) < 2)) ? "nil" : (function() {
var bindings = first(args);
var body = last(args);
var extended = merge(typeEnv, {});
{ var _c = bindings; for (var _i = 0; _i < _c.length; _i++) { var binding = _c[_i]; if (isSxTruthy((isSxTruthy((typeOf(binding) == "list")) && (len(binding) >= 2)))) {
(function() {
var name = (isSxTruthy((typeOf(first(binding)) == "symbol")) ? symbolName(first(binding)) : (String(first(binding))));
var valType = inferType(nth(binding, 1), extended, primTypes, typeRegistry);
return dictSet(extended, name, valType);
})();
} } }
return inferType(body, extended, primTypes, typeRegistry);
})()); };
PRIMITIVES["infer-let-type"] = inferLetType;
// make-diagnostic
var makeDiagnostic = function(level, message, component, expr) { return {"level": level, "component": component, "expr": expr, "message": message}; };
PRIMITIVES["make-diagnostic"] = makeDiagnostic;
// check-primitive-call
var checkPrimitiveCall = function(name, args, typeEnv, primTypes, primParamTypes, compName, typeRegistry) { return (function() {
var diagnostics = [];
if (isSxTruthy((isSxTruthy(!isSxTruthy(isNil(primParamTypes))) && dictHas(primParamTypes, name)))) {
(function() {
var sig = get(primParamTypes, name);
var positional = get(sig, "positional");
var restType = get(sig, "rest-type");
return forEach(function(idx) { return (isSxTruthy((idx < len(args))) ? (isSxTruthy((idx < len(positional))) ? (function() {
var paramInfo = nth(positional, idx);
var argExpr = nth(args, idx);
return (function() {
var expectedType = nth(paramInfo, 1);
return (isSxTruthy(!isSxTruthy(isNil(expectedType))) ? (function() {
var actual = inferType(argExpr, typeEnv, primTypes, typeRegistry);
return (isSxTruthy((isSxTruthy(!isSxTruthy(typeAny_p(expectedType))) && isSxTruthy(!isSxTruthy(typeAny_p(actual))) && !isSxTruthy(subtypeResolved_p(actual, expectedType, typeRegistry)))) ? append_b(diagnostics, makeDiagnostic("error", (String("Argument ") + String((idx + 1)) + String(" of `") + String(name) + String("` expects ") + String(expectedType) + String(", got ") + String(actual)), compName, argExpr)) : NIL);
})() : NIL);
})();
})() : (isSxTruthy(!isSxTruthy(isNil(restType))) ? (function() {
var argExpr = nth(args, idx);
var actual = inferType(argExpr, typeEnv, primTypes, typeRegistry);
return (isSxTruthy((isSxTruthy(!isSxTruthy(typeAny_p(restType))) && isSxTruthy(!isSxTruthy(typeAny_p(actual))) && !isSxTruthy(subtypeResolved_p(actual, restType, typeRegistry)))) ? append_b(diagnostics, makeDiagnostic("error", (String("Argument ") + String((idx + 1)) + String(" of `") + String(name) + String("` expects ") + String(restType) + String(", got ") + String(actual)), compName, argExpr)) : NIL);
})() : NIL)) : NIL); }, range(0, len(args), 1));
})();
}
return diagnostics;
})(); };
PRIMITIVES["check-primitive-call"] = checkPrimitiveCall;
// check-component-call
var checkComponentCall = function(compName, comp, callArgs, typeEnv, primTypes, typeRegistry) { return (function() {
var diagnostics = [];
var paramTypes = componentParamTypes(comp);
var params = componentParams(comp);
if (isSxTruthy((isSxTruthy(!isSxTruthy(isNil(paramTypes))) && !isSxTruthy(isEmpty(keys(paramTypes)))))) {
(function() {
var i = 0;
var providedKeys = [];
{ var _c = range(0, len(callArgs), 1); for (var _i = 0; _i < _c.length; _i++) { var idx = _c[_i]; if (isSxTruthy((idx < len(callArgs)))) {
(function() {
var arg = nth(callArgs, idx);
return (isSxTruthy((typeOf(arg) == "keyword")) ? (function() {
var keyName = keywordName(arg);
providedKeys.push(keyName);
return (isSxTruthy(((idx + 1) < len(callArgs))) ? (function() {
var valExpr = nth(callArgs, (idx + 1));
return (isSxTruthy(dictHas(paramTypes, keyName)) ? (function() {
var expected = get(paramTypes, keyName);
var actual = inferType(valExpr, typeEnv, primTypes, typeRegistry);
return (isSxTruthy((isSxTruthy(!isSxTruthy(typeAny_p(expected))) && isSxTruthy(!isSxTruthy(typeAny_p(actual))) && !isSxTruthy(subtypeResolved_p(actual, expected, typeRegistry)))) ? append_b(diagnostics, makeDiagnostic("error", (String("Keyword :") + String(keyName) + String(" of ") + String(compName) + String(" expects ") + String(expected) + String(", got ") + String(actual)), compName, valExpr)) : NIL);
})() : NIL);
})() : NIL);
})() : NIL);
})();
} } }
{ var _c = params; for (var _i = 0; _i < _c.length; _i++) { var paramName = _c[_i]; if (isSxTruthy((isSxTruthy(dictHas(paramTypes, paramName)) && isSxTruthy(!isSxTruthy(contains(providedKeys, paramName))) && !isSxTruthy(typeNullable_p(get(paramTypes, paramName)))))) {
diagnostics.push(makeDiagnostic("warning", (String("Required param :") + String(paramName) + String(" of ") + String(compName) + String(" not provided")), compName, NIL));
} } }
return forEach(function(key) { return (isSxTruthy(!isSxTruthy(contains(params, key))) ? append_b(diagnostics, makeDiagnostic("warning", (String("Unknown keyword :") + String(key) + String(" passed to ") + String(compName)), compName, NIL)) : NIL); }, providedKeys);
})();
}
return diagnostics;
})(); };
PRIMITIVES["check-component-call"] = checkComponentCall;
// check-body-walk
var checkBodyWalk = function(node, compName, typeEnv, primTypes, primParamTypes, env, diagnostics, typeRegistry, effectAnnotations) { return (function() {
var kind = typeOf(node);
return (isSxTruthy((kind == "list")) ? (isSxTruthy(!isSxTruthy(isEmpty(node))) ? (function() {
var head = first(node);
var args = rest(node);
if (isSxTruthy((typeOf(head) == "symbol"))) {
(function() {
var name = symbolName(head);
if (isSxTruthy(startsWith(name, "~"))) {
(function() {
var compVal = envGet(env, name);
return (isSxTruthy((typeOf(compVal) == "component")) ? forEach(function(d) { return append_b(diagnostics, d); }, checkComponentCall(name, compVal, args, typeEnv, primTypes, typeRegistry)) : NIL);
})();
if (isSxTruthy(!isSxTruthy(isNil(effectAnnotations)))) {
(function() {
var callerEffects = getEffects(compName, effectAnnotations);
return forEach(function(d) { return append_b(diagnostics, d); }, checkEffectCall(name, callerEffects, effectAnnotations, compName));
})();
}
}
if (isSxTruthy((isSxTruthy(!isSxTruthy(startsWith(name, "~"))) && isSxTruthy(!isSxTruthy(isNil(primParamTypes))) && dictHas(primParamTypes, name)))) {
{ var _c = checkPrimitiveCall(name, args, typeEnv, primTypes, primParamTypes, compName, typeRegistry); for (var _i = 0; _i < _c.length; _i++) { var d = _c[_i]; diagnostics.push(d); } }
}
if (isSxTruthy((isSxTruthy(!isSxTruthy(startsWith(name, "~"))) && !isSxTruthy(isNil(effectAnnotations))))) {
(function() {
var callerEffects = getEffects(compName, effectAnnotations);
return forEach(function(d) { return append_b(diagnostics, d); }, checkEffectCall(name, callerEffects, effectAnnotations, compName));
})();
}
if (isSxTruthy(sxOr((name == "let"), (name == "let*")))) {
if (isSxTruthy((len(args) >= 2))) {
(function() {
var bindings = first(args);
var bodyExprs = rest(args);
var extended = merge(typeEnv, {});
{ var _c = bindings; for (var _i = 0; _i < _c.length; _i++) { var binding = _c[_i]; if (isSxTruthy((isSxTruthy((typeOf(binding) == "list")) && (len(binding) >= 2)))) {
(function() {
var bname = (isSxTruthy((typeOf(first(binding)) == "symbol")) ? symbolName(first(binding)) : (String(first(binding))));
var valType = inferType(nth(binding, 1), extended, primTypes, typeRegistry);
return dictSet(extended, bname, valType);
})();
} } }
return forEach(function(body) { return checkBodyWalk(body, compName, extended, primTypes, primParamTypes, env, diagnostics, typeRegistry, effectAnnotations); }, bodyExprs);
})();
}
}
return (isSxTruthy((name == "define")) ? (isSxTruthy((len(args) >= 2)) ? (function() {
var defName = (isSxTruthy((typeOf(first(args)) == "symbol")) ? symbolName(first(args)) : NIL);
var defVal = nth(args, 1);
if (isSxTruthy(defName)) {
typeEnv[defName] = inferType(defVal, typeEnv, primTypes, typeRegistry);
}
return checkBodyWalk(defVal, compName, typeEnv, primTypes, primParamTypes, env, diagnostics, typeRegistry, effectAnnotations);
})() : NIL) : NIL);
})();
}
return forEach(function(child) { return checkBodyWalk(child, compName, typeEnv, primTypes, primParamTypes, env, diagnostics, typeRegistry, effectAnnotations); }, args);
})() : NIL) : NIL);
})(); };
PRIMITIVES["check-body-walk"] = checkBodyWalk;
// check-component
var checkComponent = function(compName, env, primTypes, primParamTypes, typeRegistry, effectAnnotations) { return (function() {
var comp = envGet(env, compName);
var diagnostics = [];
if (isSxTruthy((typeOf(comp) == "component"))) {
(function() {
var body = componentBody(comp);
var params = componentParams(comp);
var paramTypes = componentParamTypes(comp);
var typeEnv = {};
{ var _c = params; for (var _i = 0; _i < _c.length; _i++) { var p = _c[_i]; typeEnv[p] = (isSxTruthy((isSxTruthy(!isSxTruthy(isNil(paramTypes))) && dictHas(paramTypes, p))) ? get(paramTypes, p) : "any"); } }
if (isSxTruthy(componentHasChildren(comp))) {
typeEnv["children"] = ["list-of", "element"];
}
return checkBodyWalk(body, compName, typeEnv, primTypes, primParamTypes, env, diagnostics, typeRegistry, effectAnnotations);
})();
}
return diagnostics;
})(); };
PRIMITIVES["check-component"] = checkComponent;
// check-all
var checkAll = function(env, primTypes, primParamTypes, typeRegistry, effectAnnotations) { return (function() {
var allDiagnostics = [];
{ var _c = keys(env); 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(d) { return append_b(allDiagnostics, d); }, checkComponent(name, env, primTypes, primParamTypes, typeRegistry, effectAnnotations)) : NIL);
})(); } }
return allDiagnostics;
})(); };
PRIMITIVES["check-all"] = checkAll;
// build-type-registry
var buildTypeRegistry = function(primDeclarations, ioDeclarations) { return (function() {
var registry = {};
{ var _c = primDeclarations; for (var _i = 0; _i < _c.length; _i++) { var decl = _c[_i]; (function() {
var name = get(decl, "name");
var returns = get(decl, "returns");
return (isSxTruthy((isSxTruthy(!isSxTruthy(isNil(name))) && !isSxTruthy(isNil(returns)))) ? dictSet(registry, name, returns) : NIL);
})(); } }
{ var _c = ioDeclarations; for (var _i = 0; _i < _c.length; _i++) { var decl = _c[_i]; (function() {
var name = get(decl, "name");
var returns = get(decl, "returns");
return (isSxTruthy((isSxTruthy(!isSxTruthy(isNil(name))) && !isSxTruthy(isNil(returns)))) ? dictSet(registry, name, returns) : NIL);
})(); } }
return registry;
})(); };
PRIMITIVES["build-type-registry"] = buildTypeRegistry;
// type-def-name
var typeDefName = function(td) { return get(td, "name"); };
PRIMITIVES["type-def-name"] = typeDefName;
// type-def-params
var typeDefParams = function(td) { return get(td, "params"); };
PRIMITIVES["type-def-params"] = typeDefParams;
// type-def-body
var typeDefBody = function(td) { return get(td, "body"); };
PRIMITIVES["type-def-body"] = typeDefBody;
// resolve-type
var resolveType = function(t, registry) { return (isSxTruthy(isNil(registry)) ? t : (isSxTruthy((typeOf(t) == "string")) ? (isSxTruthy(dictHas(registry, t)) ? (function() {
var td = get(registry, t);
return (function() {
var params = typeDefParams(td);
var body = typeDefBody(td);
return (isSxTruthy(isEmpty(params)) ? resolveType(body, registry) : t);
})();
})() : t) : (isSxTruthy((typeOf(t) == "list")) ? (isSxTruthy(isEmpty(t)) ? t : (function() {
var head = first(t);
return (isSxTruthy(sxOr((head == "or"), (head == "list-of"), (head == "->"), (head == "dict-of"))) ? cons(head, map(function(m) { return resolveType(m, registry); }, rest(t))) : (isSxTruthy((isSxTruthy((typeOf(head) == "string")) && dictHas(registry, head))) ? (function() {
var td = get(registry, head);
var params = typeDefParams(td);
var body = typeDefBody(td);
var args = rest(t);
return (isSxTruthy((len(params) == len(args))) ? resolveType(substituteTypeVars(body, params, args), registry) : t);
})() : t));
})()) : (isSxTruthy((typeOf(t) == "dict")) ? mapDict(function(k, v) { return resolveType(v, registry); }, t) : t)))); };
PRIMITIVES["resolve-type"] = resolveType;
// substitute-type-vars
var substituteTypeVars = function(body, params, args) { return (function() {
var subst = {};
{ var _c = range(0, len(params), 1); for (var _i = 0; _i < _c.length; _i++) { var i = _c[_i]; subst[nth(params, i)] = nth(args, i); } }
return substituteInType(body, subst);
})(); };
PRIMITIVES["substitute-type-vars"] = substituteTypeVars;
// substitute-in-type
var substituteInType = function(t, subst) { return (isSxTruthy((typeOf(t) == "string")) ? (isSxTruthy(dictHas(subst, t)) ? get(subst, t) : t) : (isSxTruthy((typeOf(t) == "list")) ? map(function(m) { return substituteInType(m, subst); }, t) : (isSxTruthy((typeOf(t) == "dict")) ? mapDict(function(k, v) { return substituteInType(v, subst); }, t) : t))); };
PRIMITIVES["substitute-in-type"] = substituteInType;
// subtype-resolved?
var subtypeResolved_p = function(a, b, registry) { return (isSxTruthy(isNil(registry)) ? subtype_p(a, b) : (function() {
var ra = resolveType(a, registry);
var rb = resolveType(b, registry);
return (isSxTruthy((isSxTruthy((typeOf(ra) == "dict")) && (typeOf(rb) == "dict"))) ? isEvery(function(key) { return (isSxTruthy(dictHas(ra, key)) && subtypeResolved_p(get(ra, key), get(rb, key), registry)); }, keys(rb)) : subtype_p(ra, rb));
})()); };
PRIMITIVES["subtype-resolved?"] = subtypeResolved_p;
// get-effects
var getEffects = function(name, effectAnnotations) { return (isSxTruthy(isNil(effectAnnotations)) ? NIL : (isSxTruthy(dictHas(effectAnnotations, name)) ? get(effectAnnotations, name) : NIL)); };
PRIMITIVES["get-effects"] = getEffects;
// effects-subset?
var effectsSubset_p = function(calleeEffects, callerEffects) { return (isSxTruthy(isNil(callerEffects)) ? true : (isSxTruthy(isNil(calleeEffects)) ? true : isEvery(function(e) { return contains(callerEffects, e); }, calleeEffects))); };
PRIMITIVES["effects-subset?"] = effectsSubset_p;
// check-effect-call
var checkEffectCall = function(calleeName, callerEffects, effectAnnotations, compName) { return (function() {
var diagnostics = [];
var calleeEffects = getEffects(calleeName, effectAnnotations);
if (isSxTruthy((isSxTruthy(!isSxTruthy(isNil(callerEffects))) && isSxTruthy(!isSxTruthy(isNil(calleeEffects))) && !isSxTruthy(effectsSubset_p(calleeEffects, callerEffects))))) {
diagnostics.push(makeDiagnostic("error", (String("`") + String(calleeName) + String("` has effects ") + String(join(", ", calleeEffects)) + String(" but `") + String(compName) + String("` only allows ") + String((isSxTruthy(isEmpty(callerEffects)) ? "[pure]" : join(", ", callerEffects)))), compName, NIL));
}
return diagnostics;
})(); };
PRIMITIVES["check-effect-call"] = checkEffectCall;
// build-effect-annotations
var buildEffectAnnotations = function(ioDeclarations) { return (function() {
var annotations = {};
{ var _c = ioDeclarations; for (var _i = 0; _i < _c.length; _i++) { var decl = _c[_i]; (function() {
var name = get(decl, "name");
return (isSxTruthy(!isSxTruthy(isNil(name))) ? dictSet(annotations, name, ["io"]) : NIL);
})(); } }
return annotations;
})(); };
PRIMITIVES["build-effect-annotations"] = buildEffectAnnotations;
// check-component-effects
var checkComponentEffects = function(compName, env, effectAnnotations) { return (function() {
var comp = envGet(env, compName);
var diagnostics = [];
if (isSxTruthy((typeOf(comp) == "component"))) {
(function() {
var body = componentBody(comp);
return checkBodyWalk(body, compName, {}, {}, NIL, env, diagnostics, NIL, effectAnnotations);
})();
}
return diagnostics;
})(); };
PRIMITIVES["check-component-effects"] = checkComponentEffects;
// check-all-effects
var checkAllEffects = function(env, effectAnnotations) { return (function() {
var allDiagnostics = [];
{ var _c = keys(env); 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(d) { return append_b(allDiagnostics, d); }, checkComponentEffects(name, env, effectAnnotations)) : NIL);
})(); } }
return allDiagnostics;
})(); };
PRIMITIVES["check-all-effects"] = checkAllEffects;
// =========================================================================
// Post-transpilation fixups
// =========================================================================

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{"version":3,"sources":["/builtin/blackbox.ml","/root/.opam/5.2.0/lib/ocaml/std_exit.ml"],"sourcesContent":["(* generated code *)","(**************************************************************************)\n(* *)\n(* OCaml *)\n(* *)\n(* Xavier Leroy, projet Cristal, INRIA Rocquencourt *)\n(* *)\n(* Copyright 1996 Institut National de Recherche en Informatique et *)\n(* en Automatique. *)\n(* *)\n(* All rights reserved. This file is distributed under the terms of *)\n(* the GNU Lesser General Public License version 2.1, with the *)\n(* special exception on linking described in the file LICENSE. *)\n(* *)\n(**************************************************************************)\n\n(* Ensure that [at_exit] functions are called at the end of every program *)\n\nlet _ = do_at_exit()\n"],"names":[],"mappings":"6GCiBQ,IDjBR,QCiBQ,cAAY","ignoreList":[0]}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@@ -43,6 +43,11 @@ RUN cp -r sx-app-tmp/app.py sx-app-tmp/path_setup.py \
([ -d sx-app-tmp/sx ] && cp -r sx-app-tmp/sx ./sx || true) && \
rm -rf sx-app-tmp
# SX spec, library, and web adapter files (loaded by OCaml kernel)
COPY spec/ ./spec/
COPY lib/ ./lib/
COPY web/ ./web/
# Sibling models for cross-domain SQLAlchemy imports
COPY blog/__init__.py ./blog/__init__.py
COPY blog/models/ ./blog/models/

View File

@@ -6,10 +6,10 @@
(div :class "grid grid-cols-1 md:grid-cols-2 gap-4"
(div
(p :class "text-xs text-stone-500 uppercase tracking-wider mb-1" "SX Source")
(~docs/code :code (highlight sx-source "lisp")))
(~docs/code :src (highlight sx-source "lisp")))
(div
(p :class "text-xs text-stone-500 uppercase tracking-wider mb-1" "SMT-LIB Output (live from z3.sx)")
(~docs/code :code (highlight smt-output "lisp")))))
(~docs/code :src (highlight smt-output "lisp")))))
(defcomp ~plans/reader-macro-demo/plan-reader-macro-demo-content ()
(~docs/page :title "Reader Macro Demo: #z3"
@@ -75,12 +75,12 @@
"Below is the live SMT-LIB output from translating the full " (code "primitives.sx") " — all 87 primitive declarations. The composition is pure SX: " (code "(z3-translate-file (sx-parse (read-spec-file \"primitives.sx\")))") " — read the file, parse it, translate it. No Python glue.")
(~docs/subsection :title "primitives.sx (87 primitives)"
(~docs/code :code (highlight (z3-translate-file (sx-parse (read-spec-file "primitives.sx"))) "lisp"))))
(~docs/code :src (highlight (z3-translate-file (sx-parse (read-spec-file "primitives.sx"))) "lisp"))))
(~docs/section :title "The translator: z3.sx" :id "z3-source"
(p :class "text-stone-600"
"The entire translator is a single SX file — s-expressions that walk other s-expressions and emit strings. No host language logic. The same file runs in Python (server) and could run in JavaScript (browser) via the bootstrapped evaluator.")
(~docs/code :code (highlight (read-spec-file "z3.sx") "lisp"))
(~docs/code :src (highlight (read-spec-file "z3.sx") "lisp"))
(p :class "text-stone-600 mt-4"
"359 lines. The key functions: " (code "z3-sort") " maps SX types to SMT-LIB sorts. " (code "z3-expr") " recursively translates expressions — identity ops pass through unchanged, " (code "max") "/" (code "min") " become " (code "ite") ", predicates get renamed. " (code "z3-translate") " dispatches on form type. " (code "z3-translate-file") " filters and batch-translates."))

View File

@@ -0,0 +1,739 @@
#!/usr/bin/env node
// sx-inspect.js — SX-aware Playwright page inspector
// Usage: node sx-inspect.js '{"mode":"...","url":"/",...}'
// Modes: inspect, diff, hydrate, eval, interact, screenshot
// Output: JSON to stdout
const { chromium } = require('playwright');
const BASE_URL = process.env.SX_TEST_URL || 'http://localhost:8013';
const SCREENSHOT_DIR = process.env.SX_SCREENSHOT_DIR || '/tmp';
// Code display markers — elements that intentionally show SX source
const CODE_DISPLAY_SELECTORS = [
'[data-code-view]',
'pre code',
'.sx-source',
'[data-sx-source]',
'.font-mono[style*="font-size:0.5rem"]', // stepper code view
].join(', ');
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Recursive DOM tree snapshot — tag, id, classes, data-sx-* attrs, text */
function snapshotScript() {
return `(function snapshot(el) {
if (el.nodeType === 3) {
const text = el.textContent.trim();
return text ? { type: 'text', value: text } : null;
}
if (el.nodeType !== 1) return null;
const node = { tag: el.tagName.toLowerCase() };
if (el.id) node.id = el.id;
const cls = Array.from(el.classList).sort().join(' ');
if (cls) node.cls = cls;
const island = el.getAttribute('data-sx-island');
if (island) node.island = island;
const lake = el.getAttribute('data-sx-lake');
if (lake) node.lake = lake;
const marsh = el.getAttribute('data-sx-marsh');
if (marsh) node.marsh = marsh;
const signal = el.getAttribute('data-sx-signal');
if (signal) node.signal = signal;
const reactiveAttrs = el.getAttribute('data-sx-reactive-attrs');
if (reactiveAttrs) node.reactiveAttrs = reactiveAttrs;
const style = el.getAttribute('style');
if (style) node.style = style;
const children = [];
for (const child of el.childNodes) {
const s = snapshot(child);
if (s) children.push(s);
}
if (children.length) node.children = children;
return node;
})`;
}
/** Diff two tree snapshots, returning list of changes */
function diffTrees(a, b, path = '') {
const changes = [];
if (!a && !b) return changes;
if (!a) { changes.push({ path, kind: 'added', node: summarize(b) }); return changes; }
if (!b) { changes.push({ path, kind: 'removed', node: summarize(a) }); return changes; }
if (a.type === 'text' && b.type === 'text') {
if (a.value !== b.value)
changes.push({ path: path || 'text', kind: 'text-changed', ssr: a.value.substring(0, 120), hydrated: b.value.substring(0, 120) });
return changes;
}
if (a.type === 'text' || b.type === 'text') {
changes.push({ path, kind: 'type-changed', ssr: summarize(a), hydrated: summarize(b) });
return changes;
}
if (a.tag !== b.tag) changes.push({ path, kind: 'tag-changed', ssr: a.tag, hydrated: b.tag });
if (a.cls !== b.cls) changes.push({ path: path || a.tag, kind: 'class-changed', ssr: a.cls || '', hydrated: b.cls || '' });
if (a.island !== b.island) changes.push({ path, kind: 'island-changed', ssr: a.island, hydrated: b.island });
if (a.style !== b.style) changes.push({ path: path || a.tag, kind: 'style-changed', ssr: a.style || '', hydrated: b.style || '' });
const ac = a.children || [];
const bc = b.children || [];
const maxLen = Math.max(ac.length, bc.length);
for (let i = 0; i < maxLen; i++) {
const childPath = path ? `${path} > [${i}]` : `[${i}]`;
const aTag = ac[i]?.tag || ac[i]?.type || '';
const bTag = bc[i]?.tag || bc[i]?.type || '';
const label = bTag ? `${childPath} <${bTag}>` : childPath;
changes.push(...diffTrees(ac[i] || null, bc[i] || null, label));
}
return changes;
}
function summarize(node) {
if (!node) return 'null';
if (node.type === 'text') return `"${node.value.substring(0, 60)}"`;
let s = `<${node.tag}`;
if (node.island) s += ` island="${node.island}"`;
if (node.lake) s += ` lake="${node.lake}"`;
if (node.id) s += ` #${node.id}`;
s += '>';
return s;
}
async function waitForHydration(page) {
try {
await page.waitForSelector('[data-sx-island]', { timeout: 8000 });
await page.waitForTimeout(1500);
} catch (e) {
// No islands on page — that's OK
}
}
// ---------------------------------------------------------------------------
// Leak detection — excludes code display elements
// ---------------------------------------------------------------------------
function leakDetectionScript(codeSelectors) {
return `(function(els) {
const codeEls = new Set();
document.querySelectorAll(${JSON.stringify(codeSelectors)}).forEach(el => {
// Mark all code-display elements and their descendants
el.querySelectorAll('*').forEach(d => codeEls.add(d));
codeEls.add(el);
});
return els.flatMap(el => {
const name = el.getAttribute('data-sx-island');
const leaks = [];
// Walk text nodes, skipping code display areas
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, {
acceptNode: (node) => {
let p = node.parentElement;
while (p && p !== el) {
if (codeEls.has(p)) return NodeFilter.FILTER_REJECT;
p = p.parentElement;
}
return NodeFilter.FILTER_ACCEPT;
}
});
let fullText = '';
let node;
while (node = walker.nextNode()) fullText += node.textContent;
// Raw dict patterns (SX keyword-keyed dicts)
const dictMatch = fullText.match(/\\{:(?:type|tag|expr|spreads|attrs)\\s[^}]{0,80}/);
if (dictMatch) leaks.push({ island: name, kind: 'raw-dict', sample: dictMatch[0] });
// Unresolved component calls
const compMatch = fullText.match(/\\(~[\\w/]+[^)]{0,60}/);
if (compMatch) leaks.push({ island: name, kind: 'unresolved-component', sample: compMatch[0] });
// CSSX tokens leaked as text
const cssxMatch = fullText.match(/:tokens\\s+"[^"]{0,60}/);
if (cssxMatch) leaks.push({ island: name, kind: 'cssx-leak', sample: cssxMatch[0] });
// String-keyed dicts (JS evaluator format)
const strDictMatch = fullText.match(/\\{"(?:type|tag|expr|text|cls|step)"\\s[^}]{0,80}/);
if (strDictMatch) leaks.push({ island: name, kind: 'raw-dict-str', sample: strDictMatch[0] });
// Raw SX list as text (parens around tag names)
const sxListMatch = fullText.match(/\\((?:div|span|h[1-6]|p|a|button|ul|li|section|article)\\s+:(?:class|id|style)/);
if (sxListMatch) leaks.push({ island: name, kind: 'raw-sx-element', sample: sxListMatch[0] });
return leaks;
});
})`;
}
// ---------------------------------------------------------------------------
// Handler audit — check if event handlers are wired
// ---------------------------------------------------------------------------
function handlerAuditScript() {
return `(function(island) {
const buttons = island.querySelectorAll('button, [on-click], [data-sx-on-click]');
const inputs = island.querySelectorAll('input, textarea, select');
const results = [];
buttons.forEach((btn, i) => {
const tag = btn.tagName.toLowerCase();
const text = btn.textContent.trim().substring(0, 30);
const hasReactiveClass = btn.hasAttribute('data-sx-reactive-attrs');
// Check for attached event listeners via getEventListeners (Chrome DevTools only)
// Fallback: check for onclick attribute or __sx handler
const hasOnclick = btn.hasAttribute('onclick') || btn.onclick !== null;
const hasSxHandler = !!(btn.__sx_listeners || btn._sxListeners);
// Check all registered listeners
const listeners = [];
try {
const evts = getEventListeners ? getEventListeners(btn) : {};
for (const [evt, handlers] of Object.entries(evts)) {
listeners.push({ event: evt, count: handlers.length });
}
} catch(e) { /* getEventListeners not available outside DevTools */ }
results.push({
element: tag,
index: i,
text: text,
reactiveAttrs: hasReactiveClass,
hasOnclick: hasOnclick,
listenerCount: listeners.length || (hasOnclick ? 1 : 0),
wired: hasOnclick || listeners.length > 0
});
});
inputs.forEach((inp, i) => {
const tag = inp.tagName.toLowerCase();
const type = inp.type || '';
const hasBind = inp.hasAttribute('data-sx-bind');
const hasOnInput = inp.oninput !== null;
results.push({
element: tag,
type: type,
index: i,
bind: hasBind,
hasOnInput: hasOnInput,
wired: hasOnInput || hasBind
});
});
return results;
})`;
}
// ---------------------------------------------------------------------------
// Mode: inspect
// ---------------------------------------------------------------------------
async function modeInspect(page, url, islandFilter) {
await page.goto(BASE_URL + url, { waitUntil: 'networkidle', timeout: 15000 });
await waitForHydration(page);
const pageErrors = [];
page.on('pageerror', err => pageErrors.push(err.message));
const selector = islandFilter
? `[data-sx-island*="${islandFilter}"]`
: '[data-sx-island]';
const islands = await page.$$eval(selector, (els, codeSelectors) => {
return els.map(el => {
const name = el.getAttribute('data-sx-island');
// Lakes with detail
const lakes = [...el.querySelectorAll('[data-sx-lake]')].map(l => {
const id = l.getAttribute('data-sx-lake');
const html = l.innerHTML;
const hasElements = l.children.length > 0;
const textOnly = !hasElements && l.textContent.trim().length > 0;
const looksLikeSx = /^\s*\(/.test(l.textContent.trim());
return {
id,
htmlLength: html.length,
childElements: l.children.length,
textPreview: l.textContent.trim().substring(0, 100),
status: hasElements ? 'rendered' : (looksLikeSx ? 'raw-sx-text' : (textOnly ? 'text-only' : 'empty'))
};
});
// Marshes
const marshes = [...el.querySelectorAll('[data-sx-marsh]')].map(m => ({
id: m.getAttribute('data-sx-marsh'),
transform: m.getAttribute('data-sx-transform') || null
}));
// Signals
const signals = [...el.querySelectorAll('[data-sx-signal]')].map(s => {
const spec = s.getAttribute('data-sx-signal');
const colonIdx = spec.indexOf(':');
return {
store: colonIdx > 0 ? spec.substring(0, colonIdx) : spec,
value: colonIdx > 0 ? spec.substring(colonIdx + 1).substring(0, 50) : null
};
});
// Reactive attrs
const reactiveEls = [...el.querySelectorAll('[data-sx-reactive-attrs]')].map(r => ({
tag: r.tagName.toLowerCase(),
attrs: r.getAttribute('data-sx-reactive-attrs'),
preview: r.outerHTML.substring(0, 80)
}));
return {
name,
tag: el.tagName.toLowerCase(),
stateSize: (el.getAttribute('data-sx-state') || '').length,
textLength: el.textContent.length,
textPreview: el.textContent.replace(/\s+/g, ' ').trim().substring(0, 150),
lakes,
marshes,
signals,
reactiveElements: reactiveEls.length,
reactiveDetail: reactiveEls.slice(0, 10)
};
});
}, CODE_DISPLAY_SELECTORS);
// Leak detection with code display exclusion
const leaks = await page.$$eval(selector, (els, codeSelectors) => {
// Build the leak detection inline (can't pass function refs to $$eval)
const codeEls = new Set();
document.querySelectorAll(codeSelectors).forEach(el => {
el.querySelectorAll('*').forEach(d => codeEls.add(d));
codeEls.add(el);
});
return els.flatMap(el => {
const name = el.getAttribute('data-sx-island');
const leaks = [];
// Walk text nodes, skipping code display areas
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, {
acceptNode: (node) => {
let p = node.parentElement;
while (p && p !== el) {
if (codeEls.has(p)) return NodeFilter.FILTER_REJECT;
p = p.parentElement;
}
return NodeFilter.FILTER_ACCEPT;
}
});
let fullText = '';
let node;
while (node = walker.nextNode()) fullText += node.textContent;
const dictMatch = fullText.match(/\{:(?:type|tag|expr|spreads|attrs)\s[^}]{0,80}/);
if (dictMatch) leaks.push({ island: name, kind: 'raw-dict', sample: dictMatch[0] });
const compMatch = fullText.match(/\(~[\w/]+[^)]{0,60}/);
if (compMatch) leaks.push({ island: name, kind: 'unresolved-component', sample: compMatch[0] });
const cssxMatch = fullText.match(/:tokens\s+"[^"]{0,60}/);
if (cssxMatch) leaks.push({ island: name, kind: 'cssx-leak', sample: cssxMatch[0] });
const strDictMatch = fullText.match(/\{"(?:type|tag|expr|text|cls|step)"\s[^}]{0,80}/);
if (strDictMatch) leaks.push({ island: name, kind: 'raw-dict-str', sample: strDictMatch[0] });
const sxListMatch = fullText.match(/\((?:div|span|h[1-6]|p|a|button|ul|li|section|article)\s+:(?:class|id|style)/);
if (sxListMatch) leaks.push({ island: name, kind: 'raw-sx-element', sample: sxListMatch[0] });
return leaks;
});
}, CODE_DISPLAY_SELECTORS);
// Handler audit for each island
const handlers = await page.$$eval(selector, (els) => {
return els.map(el => {
const name = el.getAttribute('data-sx-island');
const buttons = [...el.querySelectorAll('button')];
const inputs = [...el.querySelectorAll('input, textarea, select')];
const buttonAudit = buttons.map((btn, i) => {
const hasOnclick = btn.onclick !== null;
const hasReactive = btn.hasAttribute('data-sx-reactive-attrs');
// SX wires handlers via addEventListener, not onclick property.
// We can't detect addEventListener from JS. Heuristic: if the
// button has reactive attrs, it was rendered by an island and
// likely has handlers. Only flag buttons with NO island markers.
const inIsland = !!btn.closest('[data-sx-island]');
const likelyWired = hasOnclick || (inIsland && hasReactive);
return {
text: btn.textContent.trim().substring(0, 20),
hasOnclick,
reactiveAttrs: hasReactive,
inIsland,
wired: likelyWired ? 'yes' : (inIsland ? 'probable' : 'unknown')
};
});
const inputAudit = inputs.map((inp, i) => {
const hasBind = inp.hasAttribute('data-sx-bind');
const hasOnInput = inp.oninput !== null;
const inIsland = !!inp.closest('[data-sx-island]');
return {
type: inp.type || inp.tagName.toLowerCase(),
hasBind,
hasOnInput,
inIsland,
wired: hasOnInput || hasBind ? 'yes' : (inIsland ? 'probable' : 'unknown')
};
});
const suspectButtons = buttonAudit.filter(b => b.wired === 'unknown');
const suspectInputs = inputAudit.filter(i => i.wired === 'unknown');
return {
island: name,
buttons: buttonAudit.length,
inputs: inputAudit.length,
suspectButtons: suspectButtons.length,
suspectInputs: suspectInputs.length,
allButtons: buttonAudit,
detail: suspectButtons.length + suspectInputs.length > 0
? { suspectButtons, suspectInputs }
: undefined
};
});
});
const globalLeaks = await page.$eval('#sx-root', el => {
const text = el.textContent;
const leaks = [];
const dictMatch = text.match(/\{:(?:type|tag|expr)\s[^}]{0,80}/);
if (dictMatch) leaks.push({ kind: 'raw-dict-outside-island', sample: dictMatch[0] });
return leaks;
}).catch(() => []);
return { url, islands, leaks: [...leaks, ...globalLeaks], handlers, pageErrors };
}
// ---------------------------------------------------------------------------
// Mode: diff — SSR vs hydrated DOM (full page)
// ---------------------------------------------------------------------------
async function modeDiff(browser, url) {
const snap = snapshotScript();
const ssrCtx = await browser.newContext({ javaScriptEnabled: false });
const ssrPage = await ssrCtx.newPage();
await ssrPage.goto(BASE_URL + url, { waitUntil: 'domcontentloaded', timeout: 15000 });
const ssrTree = await ssrPage.evaluate(`${snap}(document.getElementById('sx-root'))`);
const ssrText = await ssrPage.evaluate(() => {
const root = document.getElementById('sx-root');
return root ? root.innerText.replace(/\s+/g, ' ').trim().substring(0, 500) : '';
});
await ssrCtx.close();
const hydCtx = await browser.newContext({ javaScriptEnabled: true });
const hydPage = await hydCtx.newPage();
const pageErrors = [];
hydPage.on('pageerror', err => pageErrors.push(err.message));
await hydPage.goto(BASE_URL + url, { waitUntil: 'networkidle', timeout: 15000 });
await waitForHydration(hydPage);
const hydTree = await hydPage.evaluate(`${snap}(document.getElementById('sx-root'))`);
const hydText = await hydPage.evaluate(() => {
const root = document.getElementById('sx-root');
return root ? root.innerText.replace(/\s+/g, ' ').trim().substring(0, 500) : '';
});
await hydCtx.close();
const changes = diffTrees(ssrTree, hydTree);
const textMatch = ssrText === hydText;
return {
url,
ssrTextPreview: ssrText.substring(0, 300),
hydratedTextPreview: hydText.substring(0, 300),
textMatch,
structuralChanges: changes.length,
changes: changes.slice(0, 50),
pageErrors
};
}
// ---------------------------------------------------------------------------
// Mode: hydrate — SSR vs hydrated comparison focused on lakes
// ---------------------------------------------------------------------------
async function modeHydrate(browser, url) {
// SSR: capture lake innerHTML without JS
const ssrCtx = await browser.newContext({ javaScriptEnabled: false });
const ssrPage = await ssrCtx.newPage();
await ssrPage.goto(BASE_URL + url, { waitUntil: 'domcontentloaded', timeout: 15000 });
const ssrLakes = await ssrPage.$$eval('[data-sx-lake]', els =>
els.map(el => ({
id: el.getAttribute('data-sx-lake'),
island: el.closest('[data-sx-island]')?.getAttribute('data-sx-island') || null,
html: el.innerHTML.substring(0, 500),
hasElements: el.children.length > 0,
text: el.textContent.trim().substring(0, 200)
})));
const ssrIslands = await ssrPage.$$eval('[data-sx-island]', els =>
els.map(el => ({
name: el.getAttribute('data-sx-island'),
text: el.textContent.replace(/\s+/g, ' ').trim().substring(0, 200),
hasError: /Island error:|Undefined symbol:/.test(el.textContent)
})));
await ssrCtx.close();
// Hydrated: capture same after JS runs
const hydCtx = await browser.newContext({ javaScriptEnabled: true });
const hydPage = await hydCtx.newPage();
const pageErrors = [];
hydPage.on('pageerror', err => pageErrors.push(err.message));
await hydPage.goto(BASE_URL + url, { waitUntil: 'networkidle', timeout: 15000 });
await waitForHydration(hydPage);
const hydLakes = await hydPage.$$eval('[data-sx-lake]', els =>
els.map(el => ({
id: el.getAttribute('data-sx-lake'),
island: el.closest('[data-sx-island]')?.getAttribute('data-sx-island') || null,
html: el.innerHTML.substring(0, 500),
hasElements: el.children.length > 0,
text: el.textContent.trim().substring(0, 200)
})));
const hydIslands = await hydPage.$$eval('[data-sx-island]', els =>
els.map(el => ({
name: el.getAttribute('data-sx-island'),
text: el.textContent.replace(/\s+/g, ' ').trim().substring(0, 200),
hasError: /Island error:|Undefined symbol:/.test(el.textContent)
})));
await hydCtx.close();
// Compare lakes
const lakeReport = [];
const ssrMap = Object.fromEntries(ssrLakes.map(l => [l.id, l]));
const hydMap = Object.fromEntries(hydLakes.map(l => [l.id, l]));
const allIds = new Set([...ssrLakes.map(l => l.id), ...hydLakes.map(l => l.id)]);
for (const id of allIds) {
const ssr = ssrMap[id];
const hyd = hydMap[id];
const entry = { id, island: ssr?.island || hyd?.island };
if (!ssr) { entry.status = 'added-by-hydration'; }
else if (!hyd) { entry.status = 'removed-by-hydration'; }
else if (ssr.html === hyd.html) { entry.status = 'identical'; }
else {
const ssrHasEls = ssr.hasElements;
const hydHasEls = hyd.hasElements;
if (ssrHasEls && !hydHasEls) {
entry.status = 'CLOBBERED';
entry.detail = 'SSR had DOM elements, hydration replaced with text';
entry.ssrPreview = ssr.text.substring(0, 100);
entry.hydPreview = hyd.text.substring(0, 100);
} else if (!ssrHasEls && hydHasEls) {
entry.status = 'upgraded';
entry.detail = 'SSR had text, hydration rendered DOM';
} else {
entry.status = 'changed';
entry.ssrPreview = ssr.text.substring(0, 100);
entry.hydPreview = hyd.text.substring(0, 100);
}
}
lakeReport.push(entry);
}
// Compare islands
const islandReport = [];
const ssrIslandMap = Object.fromEntries(ssrIslands.map(i => [i.name, i]));
const hydIslandMap = Object.fromEntries(hydIslands.map(i => [i.name, i]));
for (const name of new Set([...ssrIslands.map(i => i.name), ...hydIslands.map(i => i.name)])) {
const ssr = ssrIslandMap[name];
const hyd = hydIslandMap[name];
const entry = { name };
if (ssr?.hasError) entry.ssrError = true;
if (hyd?.hasError) entry.hydrationError = true;
if (ssr && hyd && ssr.text === hyd.text) entry.textMatch = true;
else if (ssr && hyd) {
entry.textMatch = false;
entry.ssrPreview = ssr.text.substring(0, 100);
entry.hydPreview = hyd.text.substring(0, 100);
}
islandReport.push(entry);
}
const clobbered = lakeReport.filter(l => l.status === 'CLOBBERED');
return {
url,
summary: {
lakes: lakeReport.length,
identical: lakeReport.filter(l => l.status === 'identical').length,
clobbered: clobbered.length,
changed: lakeReport.filter(l => l.status === 'changed').length
},
lakes: lakeReport,
islands: islandReport,
pageErrors
};
}
// ---------------------------------------------------------------------------
// Mode: eval — evaluate JS expression
// ---------------------------------------------------------------------------
async function modeEval(page, url, expr) {
await page.goto(BASE_URL + url, { waitUntil: 'networkidle', timeout: 15000 });
await waitForHydration(page);
const result = await page.evaluate(expr);
return { url, expr, result };
}
// ---------------------------------------------------------------------------
// Mode: interact — action sequence
// ---------------------------------------------------------------------------
async function modeInteract(page, url, actionsStr) {
await page.goto(BASE_URL + url, { waitUntil: 'networkidle', timeout: 15000 });
await waitForHydration(page);
const actions = actionsStr.split(';').map(a => a.trim()).filter(Boolean);
const results = [];
for (const action of actions) {
const [cmd, ...rest] = action.split(':');
const arg = rest.join(':');
try {
switch (cmd) {
case 'click':
await page.locator(arg).first().click();
results.push({ action: 'click', selector: arg, ok: true });
break;
case 'fill': {
const [sel, ...valParts] = arg.split(':');
const val = valParts.join(':');
await page.locator(sel).first().fill(val);
results.push({ action: 'fill', selector: sel, value: val, ok: true });
break;
}
case 'wait':
await page.waitForTimeout(parseInt(arg) || 500);
results.push({ action: 'wait', ms: parseInt(arg) || 500 });
break;
case 'text': {
const text = await page.locator(arg).first().textContent();
results.push({ action: 'text', selector: arg, value: text?.trim() });
break;
}
case 'html': {
const html = await page.locator(arg).first().innerHTML();
results.push({ action: 'html', selector: arg, value: html?.substring(0, 500) });
break;
}
case 'attrs': {
const attrs = await page.locator(arg).first().evaluate(el => {
const a = {};
for (const attr of el.attributes) a[attr.name] = attr.value.substring(0, 200);
return a;
});
results.push({ action: 'attrs', selector: arg, value: attrs });
break;
}
case 'screenshot': {
const ts = Date.now();
const path = `${SCREENSHOT_DIR}/sx-inspect-${ts}.png`;
if (arg) {
await page.locator(arg).first().screenshot({ path });
} else {
await page.screenshot({ path });
}
results.push({ action: 'screenshot', selector: arg || 'full-page', path });
break;
}
case 'count': {
const count = await page.locator(arg).count();
results.push({ action: 'count', selector: arg, value: count });
break;
}
case 'visible': {
const visible = await page.locator(arg).first().isVisible().catch(() => false);
results.push({ action: 'visible', selector: arg, value: visible });
break;
}
default:
results.push({ action: cmd, error: 'unknown action' });
}
} catch (e) {
results.push({ action: cmd, selector: arg, error: e.message });
}
}
return { url, results };
}
// ---------------------------------------------------------------------------
// Mode: screenshot
// ---------------------------------------------------------------------------
async function modeScreenshot(page, url, selector) {
await page.goto(BASE_URL + url, { waitUntil: 'networkidle', timeout: 15000 });
await waitForHydration(page);
const ts = Date.now();
const path = `${SCREENSHOT_DIR}/sx-screenshot-${ts}.png`;
if (selector) {
await page.locator(selector).first().screenshot({ path });
} else {
await page.screenshot({ path });
}
return { url, selector: selector || 'full-page', path };
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
const argsJson = process.argv[2] || '{}';
let args;
try {
args = JSON.parse(argsJson);
} catch (e) {
console.log(JSON.stringify({ error: `Invalid JSON args: ${e.message}` }));
process.exit(1);
}
const mode = args.mode || 'inspect';
const url = args.url || '/';
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage();
try {
let result;
switch (mode) {
case 'inspect':
result = await modeInspect(page, url, args.island);
break;
case 'diff':
result = await modeDiff(browser, url);
break;
case 'hydrate':
result = await modeHydrate(browser, url);
break;
case 'eval':
result = await modeEval(page, url, args.expr || 'document.title');
break;
case 'interact':
result = await modeInteract(page, url, args.actions || '');
break;
case 'screenshot':
result = await modeScreenshot(page, url, args.selector);
break;
default:
result = { error: `Unknown mode: ${mode}` };
}
console.log(JSON.stringify(result, null, 2));
} catch (e) {
console.log(JSON.stringify({ error: e.message, stack: e.stack?.split('\n').slice(0, 5) }));
} finally {
await browser.close();
}
}
main();

View File

@@ -1,154 +1,320 @@
;; ==========================================================================
;; web/harness-web.sx — Web platform testing extensions
;;
;; Extends spec/harness.sx with DOM mocking, event simulation, and
;; web-specific assertions. Depends on web/signals.sx for reactive features.
;;
;; Mock DOM: lightweight element stubs that record operations.
;; No real browser needed — runs on any host.
;; ==========================================================================
(define
mock-element
:effects ()
(fn ((tag :as string) &key class id) {:children (list) :listeners {} :event-log (list) :tag tag :text "" :attrs (merge {} (if class {:class class} {}) (if id {:id id} {}))}))
(define
mock-set-text!
:effects (mutation)
(fn (el (text :as string)) (dict-set! el "text" text)))
;; --------------------------------------------------------------------------
;; Mock DOM elements
;; --------------------------------------------------------------------------
(define
mock-append-child!
:effects (mutation)
(fn (parent child) (append! (get parent "children") child)))
;; Create a mock element with tag name, attrs dict, children list, and event log
(define mock-element :effects []
(fn ((tag :as string) &key class id)
{:tag tag
:attrs (merge {} (if class {:class class} {}) (if id {:id id} {}))
:children (list)
:text ""
:event-log (list)
:listeners {}}))
(define
mock-set-attr!
:effects (mutation)
(fn (el (name :as string) value) (dict-set! (get el "attrs") name value)))
;; Set text content on mock element
(define mock-set-text! :effects [mutation]
(fn (el (text :as string))
(dict-set! el "text" text)))
(define
mock-get-attr
:effects ()
(fn (el (name :as string)) (get (get el "attrs") name)))
;; Append child to mock element
(define mock-append-child! :effects [mutation]
(fn (parent child)
(append! (get parent "children") child)))
;; Set attribute on mock element
(define mock-set-attr! :effects [mutation]
(fn (el (name :as string) value)
(dict-set! (get el "attrs") name value)))
;; Get attribute from mock element
(define mock-get-attr :effects []
(fn (el (name :as string))
(get (get el "attrs") name)))
;; Add event listener to mock element
(define mock-add-listener! :effects [mutation]
(fn (el (event-name :as string) (handler :as lambda))
(let ((listeners (get el "listeners")))
(when (not (has-key? listeners event-name))
(define
mock-add-listener!
:effects (mutation)
(fn
(el (event-name :as string) (handler :as lambda))
(let
((listeners (get el "listeners")))
(when
(not (has-key? listeners event-name))
(dict-set! listeners event-name (list)))
(append! (get listeners event-name) handler))))
;; --------------------------------------------------------------------------
;; Event simulation
;; --------------------------------------------------------------------------
;; Simulate a click event on a mock element
(define simulate-click :effects [mutation]
(fn (el)
(let ((handlers (get (get el "listeners") "click")))
(when handlers
(for-each (fn (h) (cek-call h (list {:type "click" :target el})))
handlers))
(define
simulate-click
:effects (mutation)
(fn
(el)
(let
((handlers (get (get el "listeners") "click")))
(when
handlers
(for-each (fn (h) (cek-call h (list {:target el :type "click"}))) handlers))
(append! (get el "event-log") {:type "click"}))))
;; Simulate an input event with a value
(define simulate-input :effects [mutation]
(fn (el (value :as string))
(define
simulate-input
:effects (mutation)
(fn
(el (value :as string))
(mock-set-attr! el "value" value)
(let ((handlers (get (get el "listeners") "input")))
(when handlers
(for-each (fn (h) (cek-call h (list {:type "input" :target el})))
handlers))
(append! (get el "event-log") {:type "input" :value value}))))
(let
((handlers (get (get el "listeners") "input")))
(when
handlers
(for-each (fn (h) (cek-call h (list {:target el :type "input"}))) handlers))
(append! (get el "event-log") {:value value :type "input"}))))
;; Simulate a custom event (for lake→island bridge)
(define simulate-event :effects [mutation]
(fn (el (event-name :as string) detail)
(let ((handlers (get (get el "listeners") event-name)))
(when handlers
(for-each (fn (h) (cek-call h (list {:type event-name :detail detail :target el})))
handlers))
(append! (get el "event-log") {:type event-name :detail detail}))))
(define
simulate-event
:effects (mutation)
(fn
(el (event-name :as string) detail)
(let
((handlers (get (get el "listeners") event-name)))
(when
handlers
(for-each (fn (h) (cek-call h (list {:target el :detail detail :type event-name}))) handlers))
(append! (get el "event-log") {:detail detail :type event-name}))))
;; --------------------------------------------------------------------------
;; DOM assertions
;; --------------------------------------------------------------------------
;; Assert mock element has specific text content
(define assert-text :effects []
(fn (el (expected :as string))
(let ((actual (get el "text")))
(assert= actual expected
(define
assert-text
:effects ()
(fn
(el (expected :as string))
(let
((actual (get el "text")))
(assert=
actual
expected
(str "Expected text \"" expected "\", got \"" actual "\"")))))
;; Assert mock element has an attribute with expected value
(define assert-attr :effects []
(fn (el (name :as string) expected)
(let ((actual (mock-get-attr el name)))
(assert= actual expected
(define
assert-attr
:effects ()
(fn
(el (name :as string) expected)
(let
((actual (mock-get-attr el name)))
(assert=
actual
expected
(str "Expected attr " name "=\"" expected "\", got \"" actual "\"")))))
;; Assert mock element has a CSS class
(define assert-class :effects []
(fn (el (class-name :as string))
(let ((classes (or (mock-get-attr el "class") "")))
(assert (contains? (split classes " ") class-name)
(define
assert-class
:effects ()
(fn
(el (class-name :as string))
(let
((classes (or (mock-get-attr el "class") "")))
(assert
(contains? (split classes " ") class-name)
(str "Expected class \"" class-name "\" in \"" classes "\"")))))
;; Assert mock element does NOT have a CSS class
(define assert-no-class :effects []
(fn (el (class-name :as string))
(let ((classes (or (mock-get-attr el "class") "")))
(assert (not (contains? (split classes " ") class-name))
(define
assert-no-class
:effects ()
(fn
(el (class-name :as string))
(let
((classes (or (mock-get-attr el "class") "")))
(assert
(not (contains? (split classes " ") class-name))
(str "Expected no class \"" class-name "\" but found in \"" classes "\"")))))
;; Assert mock element has N children
(define assert-child-count :effects []
(fn (el (n :as number))
(let ((actual (len (get el "children"))))
(assert= actual n
(str "Expected " n " children, got " actual)))))
(define
assert-child-count
:effects ()
(fn
(el (n :as number))
(let
((actual (len (get el "children"))))
(assert= actual n (str "Expected " n " children, got " actual)))))
;; Assert an event was fired on mock element
(define assert-event-fired :effects []
(fn (el (event-name :as string))
(assert (some (fn (e) (= (get e "type") event-name)) (get el "event-log"))
(define
assert-event-fired
:effects ()
(fn
(el (event-name :as string))
(assert
(some (fn (e) (= (get e "type") event-name)) (get el "event-log"))
(str "Expected event \"" event-name "\" to have been fired"))))
;; Assert an event was NOT fired on mock element
(define assert-no-event :effects []
(fn (el (event-name :as string))
(assert (not (some (fn (e) (= (get e "type") event-name)) (get el "event-log")))
(define
assert-no-event
:effects ()
(fn
(el (event-name :as string))
(assert
(not
(some (fn (e) (= (get e "type") event-name)) (get el "event-log")))
(str "Expected event \"" event-name "\" to NOT have been fired"))))
;; Count how many times an event was fired
(define event-fire-count :effects []
(fn (el (event-name :as string))
(len (filter (fn (e) (= (get e "type") event-name)) (get el "event-log")))))
(define
event-fire-count
:effects ()
(fn
(el (event-name :as string))
(len
(filter (fn (e) (= (get e "type") event-name)) (get el "event-log")))))
;; --------------------------------------------------------------------------
;; Web harness constructor — extends make-harness with DOM mock state
;; --------------------------------------------------------------------------
(define make-web-harness :effects []
(fn (&key platform)
(let ((h (make-harness :platform platform)))
(harness-set! h "dom" {:root (mock-element "div" :id "root")
:elements {}})
(define
make-web-harness
:effects ()
(fn
(&key platform)
(let
((h (make-harness :platform platform)))
(harness-set! h "dom" {:elements {} :root (mock-element "div" :id "root")})
h)))
(define
is-renderable?
:effects ()
(fn
(value)
(cond
(nil? value)
true
(string? value)
true
(number? value)
true
(boolean? value)
true
(dict? value)
false
(not (list? value))
false
(empty? value)
true
:else (let
((head (first value)))
(and (= (type-of head) "symbol") (not (dict? head)))))))
(define
is-render-leak?
:effects ()
(fn (value) (and (not (nil? value)) (not (is-renderable? value)))))
(define
assert-renderable
:effects ()
(fn
(value label)
(assert
(is-renderable? value)
(str
"Render leak in "
label
": "
(type-of value)
(cond
(dict? value)
" — dict would appear as {:key val} text in output"
(and (list? value) (not (empty? value)) (dict? (first value)))
" — list of dicts would appear as raw data in output"
:else " — non-renderable value would appear as text")))))
(define
render-body-audit
:effects ()
(fn
(values)
(let
((leaks (list)))
(for-each
(fn (v) (when (is-render-leak? v) (append! leaks {:leak-kind (cond (dict? v) "dict" (and (list? v) (not (empty? v)) (dict? (first v))) "list-of-dicts" :else "other") :value-type (type-of v)})))
values)
leaks)))
(define
assert-render-body-clean
:effects ()
(fn
(values label)
(let
((leaks (render-body-audit values)))
(assert
(empty? leaks)
(str
"Render body has "
(len leaks)
" leak(s) in "
label
". "
"render-to-html/render-to-dom render ALL body expressions — "
"put side effects in let bindings, not body expressions.")))))
(define
mock-render
:effects (mutation)
(fn
(expr)
(cond
(nil? expr)
nil
(string? expr)
(let ((el (mock-element "TEXT"))) (mock-set-text! el expr) el)
(number? expr)
(let ((el (mock-element "TEXT"))) (mock-set-text! el (str expr)) el)
(not (list? expr))
nil
(empty? expr)
nil
:else (let
((head (first expr)))
(if
(not (= (type-of head) "symbol"))
nil
(let
((el (mock-element (symbol-name head))))
(let
loop
((args (rest expr)))
(when
(not (empty? args))
(let
((arg (first args)))
(if
(= (type-of arg) "keyword")
(when
(not (empty? (rest args)))
(mock-set-attr! el (keyword-name arg) (nth args 1))
(loop (rest (rest args))))
(do
(let
((child-el (mock-render arg)))
(when child-el (mock-append-child! el child-el)))
(loop (rest args)))))))
el))))))
(define
mock-render-fragment
:effects (mutation)
(fn
(exprs)
(filter (fn (el) (not (nil? el))) (map mock-render exprs))))
(define
assert-single-render-root
:effects ()
(fn
(exprs label)
(let
((rendered (mock-render-fragment exprs)))
(assert
(= (len rendered) 1)
(str
"Expected single render root in "
label
" but got "
(len rendered)
" element(s). "
"Multi-body let/begin in render-to-html/render-to-dom renders "
"ALL expressions — put side effects in let bindings.")))))
(define
assert-tag
:effects ()
(fn
(el expected-tag)
(assert
(= (get el "tag") expected-tag)
(str "Expected <" expected-tag "> but got <" (get el "tag") ">"))))