# SX Language Core Review — spec/ semantics Reviewer axis: LANGUAGE CORE (spec/evaluator.sx CEK machine, parser.sx, primitives.sx, render.sx, special-forms.sx, eval-rules.sx, stdlib.sx, signals.sx, coroutines.sx, canonical.sx). Note: brief mentions spec/types.sx — it no longer exists; strict-typing machinery lives in evaluator.sx. Status: COMPLETE — all 8 dimension sweeps merged (CEK core, env/scope, HO forms, special forms/macros, parser/serializer/canonical, primitives/stdlib, render modes, strict typing + signals/coroutines/harness). TOTALS: 104 CONFIRMED findings (3 critical, 26 high, 40 medium, 2 low-medium, 33 low) + 5 SUSPECTED. Every CONFIRMED item has a runtime repro (fresh sx_server.exe unless noted). All 3 criticals additionally re-verified on the shipped WASM browser kernel (see CROSS-LANE CHECK). THEMES a ranker should know: 1. **Nested cek-run instead of CEK frames** is one root cause behind ≥4 findings (shift-k double-execution, threading guard/IO break, + suspected macro/let-values/qq boundaries). 2. **Handler still installed while handler runs** explains the guard/handler-bind hang family. 3. **Name-before-env dispatch** explains the ~60 unshadowable names + HO-not-first-class family. 4. **Global mutable stacks popped only on normal exit** explains provide/winder/batch leak family (scope stack, *winders*, *batch-depth* — none unwind-safe). 5. **Test-runner-only bindings** make whole suites (values, canonical floats, batch, coroutines) green for features the shipped runtime doesn't have; one test passes *vacuously because of the very bug it tests* (signal-return). 6. **Per-host re-bound platform primitives** (parse-number, char-code, escape-string, split, get…) are the drift engine behind parser AST divergence + harness/runtime divergence. CROSS-LANE CHECK (vs /tmp/sx-review/hosts.md and conformance.md, done 2026-07-03): - **All 3 criticals re-verified on the shipped WASM browser kernel** (js_of_ocaml build browsers actually load; probe harness from the conformance lane): guard re-raise HANGS (node killed at 25s), signal-condition → `42` (same kont drop), shift repro → identical double-execution trace `(99 ("r=escaped" "after-k" "r=99"))`. Kernel-family bugs: native server AND production browser. Conformance F-2 (corpus never runs on WASM) explains why nothing caught them there. - **Not masked by the JIT**: hosts J2/J9 confirm guard-installing lambdas are interpret-only and any raise/call-cc in JIT'd code falls back to the CEK — my criticals are the live path. - **Three independent double-side-effect mechanisms now on record**: my shift-k nested cek-run (critical #3), hosts J1 (`->` miscompiled under serving-JIT, steps re-run), hosts J2 (JIT-fallback re-runs whole call). Same user-visible symptom, three distinct fixes. - **One of my findings corrected** (apply — see the finding below); expt int63 wrap corroborated by conformance F-1 (WASM is worse: `(expt 2 62)` → 0); unshadowable-HO finding extended by hosts J8 (the VM DOES honor local bindings — CEK/VM divergence within one host); render dom/html attr parity independently confirmed as hosts C19; values/eq?/eqv? runner-only bindings corroborated by conformance F-7/F-9. - **No contradiction on canonical/CIDs**: conformance's working `cid-from-sx` is a native kernel primitive (verified: works with spec/canonical.sx not loaded). My canonical.sx finding concerns the spec guest implementation — production CIDs bypass it. Two parallel CID implementations, only the native one exercised; spec-vs-native canonical-form agreement is untested (conformance F-3 checked native-vs-WASM only). Verification recipe: `sx_harness_eval` (MCP) cross-checked against fresh real-runtime processes `printf '(epoch 1)\n(eval "...")\n' | timeout 30 hosts/ocaml/_build/default/bin/sx_server.exe`. TOOLING CAVEATS found during review (also listed as handoffs): (1) the MCP harness primitive table diverges from the real runtime; (2) `sx_harness_eval` is NOT a fresh sandbox — state persists and cross-contaminates calls; (3) sx_read_subtree ignores `path`, sx_read_tree ignores `max_lines`. All critical/divergent probes were re-verified on fresh `sx_server.exe` processes. --- ## CONFIRMED findings (most severe first) ### [critical] [CONFIRMED×2] Any raise/error inside a guard clause body or handler-bind handler loops forever — handler runs with its own handler frame still installed - Location: spec/evaluator.sx, `raise-eval` case of step-continue (~4547-4573) + `kont-unwind-to-handler` (236-259); inherited by `step-sf-guard` (~1693) - What: `kont-unwind-to-handler` returns `{:handler match :kont kont}` where `kont` still contains the matched handler frame; the handler is invoked with that kont. A `raise` inside the handler re-matches the same handler → infinite loop. Not just explicit re-raise: ANY error while a handler/clause body runs (`(error ...)`, a raised different value) hangs instead of propagating. CL/R7RS: handlers run with the enclosing (outer) handler set. `guard` desugars clause bodies to run INSIDE the handler-bind extent (`(__guard-k (cond ...))` — clauses evaluate before the escape), so the memory'd gotcha "`(raise e)` in a guard clause hangs" is exactly this. Contrast: the no-matching-clause auto-reraise is R7RS-correct (`(guard (outer (true outer)) (guard (e ((= e 1) "one")) (raise 2)))` → outer catches 2) because the sentinel re-raise happens after the call/cc return, OUTSIDE handler-bind — which is exactly how clause bodies should also run. - Repro (bounded CLI, all timeout exit 124): `(guard (e (true (raise e))) (raise 42))`; `(handler-bind (((fn (c) true) (fn (c) (raise c)))) (raise 1))`; same with `(raise "different")` and `(error "again")`. - Cross-check: reproduced on the shipped WASM browser kernel (hangs, killed at 25s) — affects production browsers, not just the server. Hosts lane J2/J9: guard-installing lambdas are interpret-only, so the JIT never masks this. - Coverage: test-r7rs.sx guard suite + test-conditions.sx cover happy paths only; no test raises from within a handler. test-cek-try-seq.sx "error in error handler propagates" passes because `cek-try` is a different mechanism. ### [critical] [CONFIRMED] signal-return frame key mismatch drops the caller's continuation — continuable signal/raise-continuable returns the handler value as the WHOLE program's result; the covering test passes vacuously - Location: spec/evaluator.sx, `make-signal-return-frame` (line 182, stores saved kont under `:f`) vs `signal-return` case of step-continue (~4509-4512, reads `(get frame "saved-kont")`); mirrored in hosts/ocaml/lib/sx_runtime.ml:210-231 (CekFrame get has no `"saved-kont"` mapping → Nil) - What: the resume kont is always nil, so after the handler returns, its value becomes the terminal value of the entire CEK run — every frame outside the signal site (arithmetic, enclosing lists, asserts) is silently discarded. - Repro: `(list "outer" (handler-bind (((fn (c) true) (fn (c) 42))) (+ 1 (signal-condition 5))) "end")` → `42`; expected `("outer" 43 "end")`. Same with `raise-continuable` → `42`. The shipped test expr `(handler-bind (((fn (c) true) (fn (c) (* c 10)))) (+ 1 (signal-condition 5)))` → `50` on both CLI and harness, yet the test asserting `51` PASSES under run_tests.exe — the dropped continuation includes the `assert-equal` frame itself, so the assertion never executes (vacuous pass). - Cross-check: reproduced byte-identically (`42`) on the shipped WASM browser kernel. - Coverage: test-conditions.sx "signal returns handler value to call site" — passing vacuously; the bug defeats its own test. ### [critical] [CONFIRMED] Invoking a shift-captured continuation uses a nested cek-run — escaping across that boundary re-executes the outer program tail (double execution, duplicated side effects); raising inside a resumed k can't reach outer handlers - Location: spec/evaluator.sx, `continue-with-call`, `continuation?` branch (~4708-4716): `(let ((result (cek-run (make-cek-value arg env captured)))) (make-cek-value result env kont))` - What: the nested run's kont ends at the captured frames; (a) a call/cc escape invoked inside the resumed extent rewrites the kont *inside the nested run*, which then runs the rest of the program to completion, returns that as the value of `(k arg)`, and the outer run executes the program tail again; (b) handler frames in the outer kont are invisible to `kont-unwind-to-handler` inside the nested run. - Repro (a): `(do (define log (list)) (define r (call/cc (fn (esc) (reset (do (shift k (do (k 1) (set! log (append log (list "after-k"))) 99)) (esc "escaped") "unreached"))))) (set! log (append log (list (str "r=" r)))) (list r log))` → actual `(99 ("r=escaped" "after-k" "r=99"))` (tail executed twice); expected `("escaped" ("r=escaped"))`. - Repro (b): `(guard (e (true (list "caught" e))) (reset (do (shift k (k 1)) (raise "boom"))))` → `Unhandled exception: "boom"`; expected `("caught" "boom")`. - Cross-check: repro (a) reproduced with the identical wrong trace `(99 ("r=escaped" "after-k" "r=99"))` on the shipped WASM browser kernel. Note: hosts J1/J2 are two FURTHER independent double-side-effect mechanisms (JIT `->` miscompile; JIT-fallback re-run) — three distinct fixes needed for "side effects ran twice" reports. - Coverage: not covered (test-cek-advanced.sx shift/reset tests never cross the boundary with call/cc or raise). ### [high] [CONFIRMED] Caller's immediate frame leaks into interpreted lambda calls — partial dynamic scoping, and the JIT disagrees - Location: spec/evaluator.sx, `continue-with-call` (`(local (env-merge (lambda-closure f) env))` ~4739; same in `call-lambda` ~896) + `env_merge` in hosts/ocaml/lib/sx_types.ml:390 - What: when the call-site env is NOT a descendant of the lambda's closure env, `env_merge` copies the caller's **top frame** bindings into the lambda's local env. Free variables in the body resolve to the caller's locals — a lexical-scoping violation. Depth-1 only (a binding one frame deeper does not leak). The JIT path disagrees: a VM-compiled body raises "VM undefined" for the same program — behavior flips depending on whether the body got JIT-compiled. - Repro: `(do (define mg (fn () (fn () (guard (e (true e)) leakedz)))) (define gz (mg)) (let ((leakedz 66)) (gz)))` → **66** (guard forces interpretation); expected undefined-symbol error. Without guard → `"VM undefined: leaked"` (JIT). Depth-2 variant → Undefined symbol. - Coverage: not covered — test-scope.sx "environment-isolation" tests only the lambda→caller direction. ### [high] [CONFIRMED] letrec injects its bindings into foreign lambdas' closure envs — permanent global contamination - Location: spec/evaluator.sx, `sf-letrec` (~1370: `(env-bind! (lambda-closure val) n (env-get local n))`) - What: after evaluating inits, letrec binds ALL letrec names into the closure env of every lambda **value**. `make_lambda` stores the defining env directly, so a letrec whose value is a pre-existing (e.g. top-level) lambda writes the letrec names into that lambda's closure — the **global env** — permanently. - Repro: `(do (define idf (fn (x) x)) (letrec ((zzq idf) (zzn 55)) nil) zzn)` → **55**; expected "Undefined symbol: zzn". (This leak also polluted the shared MCP harness image across calls during verification.) - Coverage: not covered — test-scope.sx "letrec-edge" only binds lambdas created inside the letrec (extra binds are no-ops there). ### [high] [CONFIRMED] Named let leaks its loop name into the enclosing env frame and clobbers same-name bindings - Location: spec/evaluator.sx, `sf-named-let` (~1035: `(env-bind! (lambda-closure loop-fn) loop-name loop-fn)`) - What: `lambda-closure loop-fn` IS the enclosing env (no fresh frame), so the loop name is bound into the surrounding scope: visible after the form, and it clobbers (not shadows) an existing binding of the same name. - Repro: `(do (let lp ((i 0)) i) (lambda? lp))` → **true** (expected unbound). `(let ((lp2 5)) (let lp2 ((i 0)) i) lp2)` → loop lambda in interpreter, **nil** under JIT — never the expected 5. - Coverage: not covered — test-named-let-sx locks set!-accumulator patterns only. ### [high] [CONFIRMED×3] ~60 special-form/HO names are silently unshadowable — define/let/defmacro accepted, call-position dispatch ignores them - Location: spec/evaluator.sx `step-eval-list` (~1801-1958) — head-name `match` runs before any env lookup; only the `_` fallthrough (custom special forms, ~1959) checks `(not (env-has? env name))` - What: list-head dispatch checks built-in special/HO forms BEFORE env lookup. `(define bind (fn (a b) "mine"))` succeeds (`type-of` says lambda) but `(bind 1 2)` → `1` (special form runs). `(define map ...)`, `(let ((map ...)) ...)`, `(defmacro if ...)`, `(defmacro map ...)` — all silently ignored in call position while honored in value position. Regular primitives ARE properly shadowable (`(define get ...)`, `(define inc ...)` → user def wins) — only this name set is hijacked, and custom special forms DO respect user bindings, making built-ins doubly inconsistent. - Unshadowable names (extracted from dispatch): `if when cond case and or let let* lambda fn define defcomp defisland defmacro defio define-foreign io begin do guard quote quasiquote -> ->> |> as-> set! letrec reset shift deref scope provide peek provide! context bind emit! emitted handler-bind restart-case signal-condition invoke-restart match let-match dynamic-wind map map-indexed filter reduce some every? for-each raise raise-continuable call/cc call-with-current-continuation perform define-library import define-record-type define-protocol implement parameterize syntax-rules define-syntax`. Collision-prone short ones: `map filter reduce some bind match peek context deref guard io do case`. - Repro: `(do (define map (fn (f xs) "mine")) (map (fn (x) (* x 10)) (list 1 2)))` → `(10 20)`; `(let ((map (fn (a b) 42))) (map 1 2))` → `Error: rest: 1 list arg`; `(let ((-> (fn (a b) 99))) (-> 1 2))` → `Not callable: nil`; `(do (defmacro if (a b c) 99) (if true 1 2))` → 1. - Coverage: not covered anywhere. Memory gotcha "bind/conj/disj shadowed" confirmed for `bind`; `conj`/`disj` aren't core primitives (guest-worktree lore). ### [high] [CONFIRMED] cond grammar is ambiguous — an all-clauses-len-2 heuristic silently switches modes; multi-expr clause bodies are dropped or crash; flat-intent code can silently return the wrong value - Location: spec/evaluator.sx, `step-sf-cond` (a `scheme?` detection binding selects clause-mode vs flat-pair mode) - What: `cond` supports flat pairs `(cond t1 r1 ... :else d)` (the only documented syntax — eval-rules.sx:64) plus an undocumented Scheme clause mode auto-detected iff **every** arg is a 2-element list (or `(test => proc)`). All verified consequences: - Single clause with multi-expr body: `(cond ((= 1 1) (set! a 1) (set! b 2)))` → nil, **neither set! runs** — silent total drop of side effects. - Multi-expr body + other clauses: `(cond ((= 1 1) "a" "b") (:else "no"))` → `Not callable: true` — one len≠2 clause anywhere flips the WHOLE cond to flat mode. - Silent misinterpretation: `(do (define x false) (define y true) (cond (not x) (list 1) (not y) (list 2)))` → `false` (clause-mode reads `(not x)` as test=`not`, result=`x`); flat reading gives `(1)`. Wrong answer, no error. - Test-only clause `(cond (5))` → nil (Scheme: 5); poisons detection: `(cond (true "t") (5))` → `Not callable: nil`. - Trailing odd flat arg silently ignored, never evaluated: `(cond (set! a 99))` leaves a unchanged. - Coverage: flat tested (test-eval.sx:306-312); clause mode only via cond-arrow suite (test-r7rs.sx:135-145). Ambiguity/multi-expr/test-only uncovered; clause mode entirely undocumented. ### [high] [CONFIRMED] `(unquote-splicing x)` longhand silently no-splices; only `splice-unquote` is recognized - Location: spec/evaluator.sx, `qq-expand` (checks `(symbol-name (first item)) = "splice-unquote"` only) - What: `,@` sugar parses to `splice-unquote` and works; the R7RS-standard longhand `unquote-splicing` fails dispatch, is recursed into as an ordinary list, and is emitted literally — silent zero-splice. `(unquote x)` longhand works. - Repro: `(quasiquote (a (unquote-splicing xs)))` → `(a (unquote-splicing xs))`; `` `(a ,@xs) `` and `(splice-unquote xs)` → `(a 1 2)`. - Coverage: not covered — worse, test-macros.sx tests are NAMED "unquote-splicing …" (lines 43-63) while all using `,@` sugar, actively reinforcing the trap. (Confirms memory gotcha; root cause now located.) ### [high] [CONFIRMED] dynamic-wind before-thunks never re-run on continuation re-entry; global length-based winder stack corrupts across sibling wind contexts (afters skipped/duplicated) - Location: spec/evaluator.sx, `continue-with-call` callcc-continuation branch (~4702-4707: `(do (wind-escape-to w-len) ...)`), `wind-escape-to` (261-271) - What: invoking a captured continuation only pops after-thunks down to the captured *length* of the global `*winders*` stack. No common-ancestor computation, no before-thunks on entry (R7RS requires before/after along the path between extents). Lengths from unrelated wind contexts collide: resuming a k captured inside wind A while inside wind B (equal depth) unwinds nothing, then A's `wind-after` frame pops B's winder. - Repro 1 (re-entry): capture k inside wind, escape, re-invoke → `(2 ("b" "a" "a"))`; expected `(2 ("b" "a" "b" "a"))` (before not re-run; after ran twice). - Repro 2 (sibling): capture in wind A, re-invoke from inside wind B → `(2 ("A-in" "A-out" "B-in" "A-out"))`; expected B-out + A-in before final A-out. B's after silently never runs (resource-leak class), A's runs twice. - Coverage: test-dynamic-wind.sx (8 tests): normal return, raise, one-shot escape only. ### [high] [CONFIRMED×2] guard re-raise sentinel is forgeable — a body/clause legitimately returning `(list '__guard-reraise__ X)` is misinterpreted as a re-raise of X - Location: spec/evaluator.sx, `step-sf-guard` (~1693-1767): sentinel `(make-symbol "__guard-reraise__")`, detected by structural `=` on any 2-element list escaping the guard - Repro: `(guard (e (true (list (quote __guard-reraise__) 42))) (raise 1))` → `Unhandled exception: 42`; `(guard (e (true "handled")) (list (quote __guard-reraise__) 7))` → `Unhandled exception: 7` — the guard *body's* return value converted into a raise. Should be an unforgeable/gensym'd token. (Severity judged high by one reviewer, low by another — data-dependent conversion of values into raises; rank accordingly.) - Coverage: not covered. ### [high] [CONFIRMED] `->`/`->>` non-HO steps run in a nested CEK with empty kont — guard and IO suspension broken through threading - Location: spec/evaluator.sx, `thread-insert-arg`/`thread-insert-arg-last` (72–89) call `eval-expr` (4828: `cek-run` with kont `(list)`); "thread" frame handler (~4074) stays CEK-native only for `ho-form-name?` heads - What: a threaded non-HO step evaluates in a fresh machine that can't see outer guard frames and can't suspend. (a) `raise` inside a threaded call escapes an enclosing `guard`; (b) IO/effects inside a threaded step hard-crash instead of suspending. The HO path is CEK-native and correct — same expression works or fails depending on the step's head symbol. Same root pattern as the shift-k critical. - Repro: `(define boom (fn (x) (raise "T"))) (guard (e (else "caught")) (-> 1 boom))` → `Unhandled exception: "T"` (map version → caught). `(-> {:op "noop"} (perform))` → `Error: Sx_vm.VmSuspended(_,_)` (map version suspends/resumes fine). - Coverage: not covered ### [high] [CONFIRMED] 2-arg `(reduce f coll)` silently returns the collection unchanged - Location: spec/evaluator.sx, `ho-setup-dispatch` "reduce" branch (~3671) + `ho-swap-args` (~3557) - What: fn-first 2-arg reduce makes `init` the collection and `coll` nil → returns init. Expected: fold with first element as init (Scheme/Clojure) or arity error. Asymmetrically, data-first `(reduce coll f)` DOES fold — with nil init (works only via nil-coercion in `+`/`str`). - Repro: `(reduce + (list 1 2 3))` → `(1 2 3)` (expected 6 or error); `(reduce (list 1 2 3) +)` → `6`. - Coverage: not covered (tests only use 3-arg forms) ### [high] [CONFIRMED] ho-swap-args misreads `(reduce init f coll)` — breaks `(-> init (reduce f coll))` - Location: spec/evaluator.sx, `ho-swap-args` reduce branch: `(list b (nth evaled 2) a)` - What: with non-callable arg0, `(reduce init f coll)` treats arg0 as coll and arg2 as init — the threaded scalar seed becomes the "collection" → cryptic host error. The thread handler inserts the threaded value FIRST for HO forms, so any `->` reduce with a scalar seed hits this. - Repro: `(-> 0 (reduce + (list 1 2 3)))` → `Error: rest: 1 list arg` (expected 6); same for `(reduce 0 + (list 1 2 3))`. - Coverage: not covered — thread-ho suite only tests `(-> coll (reduce + 0))` (test-cek-advanced.sx:673) ### [high] [CONFIRMED] Data-first ho-swap-args silently drops all args beyond the second - Location: spec/evaluator.sx, `ho-swap-args` non-reduce branch: `(list b a)` - What: when arg0 is data and arg1 callable, everything after arg1 is discarded — a data-first multi-collection map silently maps over only the first collection; with no lambda-arity enforcement, garbage results, not errors. - Repro: `(map (list 1 2) (fn (x) (* x 10)) (list 3 4))` → `(10 20)`; `(map (list 1 2) (fn (x y) (+ x y)) (list 30 40))` → `(1 2)` (y → nil, `(+ 1 nil)` = 1). - Coverage: not covered ### [high] [CONFIRMED] Infinite recursive component hangs the renderer — no depth guard - Location: web/adapter-html.sx `render-html-component`/`render-list-to-html`; spec/render.sx has no recursion bound - What: a self-referencing component with no base case (or data-driven cycle) recurses forever — one render pins the server thread indefinitely. No depth limit or cycle detection. - Repro: `(do (defcomp ~loop () (div (~loop))) (render-to-html '(~loop) (current-env)))` → never returns (killed at 20s). Bounded `(~nest :n 3)` renders fine. - Coverage: not covered (needs a depth limit + test) ### [high] [CONFIRMED] append! silently no-ops on all derived lists - Location: spec/primitives.sx `append!` (+ OCaml impl) - What: `append!` mutates only literal `(list ...)` cells. Lists produced by `map`, `filter`, `rest`, `reverse`, `append` are silently unappendable — no error, mutation lost. `append!` returns the appended *value*, masking the failure. - Repro: `(let ((xs (map (fn (x) x) (list 1 2)))) (append! xs 3) xs)` → `(1 2)`; literal list → `(1 2 3)`. - Coverage: test-primitives.sx:339 uses `append!` only on a literal-list accumulator. ### [high] [CONFIRMED] expt silently wraps at 63-bit int; inconsistent with +/* which promote to float - Location: spec/primitives.sx `expt` - Repro: `(expt 2 62)` → `-4611686018427387904`; `(expt 2 100)` → `0`; but `(* 4611686018427387904 4)` → float and `(+ 9223372036854775807 1)` → float. `(expt 2.0 100)` correct. - Coverage: test-math.sx:66-71 — overflow not covered. ### [high] [CONFIRMED] MCP harness primitive table diverges from real runtime — invalidates harness-based verification - Location: hosts/ocaml/bin/mcp_tree.ml (own primitive table, e.g. `bind "contains?"` L484, `bind "split"` L563) vs hosts/ocaml/lib/sx_primitives.ml (sx_server) - What: sx_harness_eval runs a *parallel implementation* of many primitives. Divergences (harness → runtime): `(empty? "")`/`(empty? {})` false → **true** (test-primitives.sx:89 asserts true — harness contradicts a passing test); `(get {:a 1} :a 99)` **nil even for present key** → 1; `(get {:a 1} :zz 99)` nil → 99; `(get (list 10 20) 1)` nil → 20; `(split "a--b" "--")` char-class → substring; `(split "abc" "")` crash → `("a" "b" "c")`; `equal?` undefined → defined; `(contains? {:a 1} :a)` true → **error**; `(keyword-name :kw)` `""` → error. CLAUDE.md mandates harness verification, so this drift silently produces false findings/passes. - Coverage: nothing tests harness/runtime parity. (Cross-lane: host tooling — see handoffs — but it's the spec-mandated verification path.) ### [high] [CONFIRMED] contains? does not support dicts in the real runtime, contradicting its spec doc - Location: spec/primitives.sx `contains?` (":doc … Dicts: key check"); sx_primitives.ml - Repro: `(contains? {:a 1} :a)` → `Unhandled exception: "contains?: 2 args"` (misleading arity error); lists/strings work. - Coverage: list membership only (run_tests.ml:1255); no dict case. ### [high] [CONFIRMED] canonical.sx depends on test-runner-only helpers — content addressing fails on ANY number outside run_tests - Location: spec/canonical.sx, `canonical-number` (46-59) calls `contains-char?` (defined only in run_tests.ml:728 / run_tests.js:85) and `trim-right` (run_tests.js:87 only — not even OCaml run_tests). Neither exists in sx_primitives.ml, sx_server.ml, or mcp_tree.ml. - What: `canonical-serialize`/`content-id` on the production server errors on any number. In the OCaml test runner the trim-right branch (floats with trailing zeros) is unreachable-but-passing because tests only canonicalise integers. - Repro: fresh sx_server: `(load "spec/canonical.sx")` `(canonical-serialize 42)` → `Undefined symbol: contains-char?`; with a shim, `(canonical-serialize 0.1)` → `Undefined symbol: trim-right`. - Coverage: test-canonical.sx covers ints/dict-sorting/CIDs — never a non-`.0` float; failure mode invisible to all suites. ### [high] [CONFIRMED] Serializer emits dict keys unescaped — non-identifier keys produce unparseable/wrong output; canonical form not a fixed point (CID hazard) - Location: spec/parser.sx `sx-serialize-dict` (emits `(str ":" key)`); spec/canonical.sx `canonical-dict` (~79, same pattern) - What: dict keys are strings; both serializers print `:` + raw key. Keys with spaces/parens/non-ident chars produce output that reparses differently or errors. Since `canonical-serialize` feeds sha3-256, CIDs exist for values whose canonical form violates `canonical(parse(canonical(x))) = canonical(x)`. The native reader accepts string keys `{"a b" 1}`, so such dicts are creatable from plain source. - Repro: dict with key `"hello world"` → `"{:hello world 2 :k 1}"` → reparse errors; `{(+ 1 2) 5}` → key `"(+ 1 2)"` → serializes `{:(+ 1 2) 5}` → garbage. - Coverage: "serialize dict round-trips" uses keyword-shaped keys only. ### [high] [CONFIRMED] Same source parses to different ASTs across the four ident/number classifier variants - Location: hosts/ocaml/lib/sx_parser.ml:36-46 (native), hosts/ocaml/bin/sx_server.ml:1330-1348, hosts/ocaml/bin/mcp_tree.ml:391-410, hosts/javascript/platform.py:2622-2626 — four different ident-start/ident-char tables feeding the one spec grammar - What (all verified live): `(a,b)` → single symbol on native/mcp/JS but `(a (unquote b))` on sx_server guest; unicode idents accepted by mcp guest only (forbidden by the production reader); `$x`/`|y|` symbols on sx_server guest only; `0x10`/`0b101`/`1_000` → numbers 16/5/1000 on native (undocumented C-style acceptance) vs number 0 + symbol `x10` on guest/JS (silent token split); `inf`/`nan`/`-inf` are float literals on native (can't be variable names!) vs symbols on guest/JS; `1+`/`1abc` single symbols native vs silent `1` + symbol split guest/JS (`(1+ 2)` → 3-element list); `#t`/`#f` booleans native vs `Undefined symbol: reader-macro-get` on OCaml guest vs "Unknown reader macro" on JS; `{1 2}` rejected native vs silently stringified key `"1"` guest/JS. - Coverage: none of these tokens appear in any test file — suites exercise only the intersection. ### [high] [CONFIRMED] `1e` bare-exponent numbers silently parse to nil in the guest parser - Location: spec/parser.sx `read-number` → `parse-number` fallthrough (nil emitted as a value, no error) - Repro: `(sx-parse "1e")` → `(nil)`; JS `parseAll('1e')` → `[null]`; native reader yields `Symbol "1e"` — a third behavior. `(foo 1e)` becomes `(foo nil)` silently. - Coverage: only valid exponent forms tested. ### [high] [CONFIRMED] Guest parser cannot produce rationals on server/tooling hosts — `1/2` throws - Location: spec/parser.sx `read-number` (215-231); sx_server.ml:1325 and mcp_tree.ml:384 override `parse-number` to always return float, shadowing the Integer-aware sx_primitives version; `make-rational` rejects (Number,Number) - Repro: fresh sx_server: `(load "spec/parser.sx")` `(sx-parse "1/2")` → `make-rational: expected 2 integers`. Works only in run_tests env. Native reader parses `1/2` fine. - Coverage: test-rationals.sx (62 tests) never uses `sx-parse`; test-parser.sx has zero rational tests. ### [high] [CONFIRMED] Strict mode: HO-form callbacks bypass type checks entirely - Location: spec/evaluator.sx `step-continue` — map/filter/reduce/for-each/some/every/multi-map frames call `continue-with-call` directly; only the "arg" frame runs `strict-check-args` (enforcement site 4152-4194). Same in sx_ref.ml:1009. - What: with strict on and types declared for `f`, `(f "a")` errors but `(map f coll)`/`(filter f coll)`/`(reduce f init coll)`/`(for-each f coll)`/`(every? f coll)`/`(some f coll)` silently pass mistyped elements. Also unchecked: cond `=>` arrow calls, call/cc continuation invocation, exception-handler invocation, signal-subscriber cek-calls. - Repro: `hh` typed `(x number)`: `(hh "abc")` → type error; `(map hh (list "a" "b"))` → `("a" "b")` silently. - Coverage: test-strict.sx checks direct calls only. ### [high] [CONFIRMED] Strict mode: `apply` bypasses type checks on the target function - Location: hosts/ocaml/lib/sx_primitives.ml:1534 / sx_server.ml:1240 — native prim spreads args and calls directly - Repro: `(apply hh (list "a"))` → `"a"` (no error); direct `(hh "a")` errors. - Coverage: not covered. ### [high] [CONFIRMED] `dispose-computed` is a no-op — computed signals leak subscriptions after disposal - Location: spec/signals.sx, `dispose-computed` — `(signal-remove-sub! dep nil)` passes **nil** as the subscriber; the actual `recompute` closure is trapped in `computed`'s letrec and unreachable. The island-scope disposer registered by `computed` is therefore broken (contrast `effect`, whose dispose-fn works). - Repro: computed on `a2` (1 run); `(dispose-computed c2)`; `(reset! a2 5)` → runs=2, value updated. Expected: runs=1, unchanged. Subscriber leak in island teardown. - Coverage: no dispose-computed test exists. ### [high] [CONFIRMED] Exception inside `batch` permanently wedges the reactive system - Location: spec/signals.sx, `batch` — increments `*batch-depth*`, runs thunk with no unwind protection; decrement skipped on throw - What: after any error escapes a batch thunk (even if caught outside), `*batch-depth*` stays >0 — every future `notify-subscribers` queues forever and never flushes; all reactivity dead. Related: `(import (sx signals))` copies value bindings rather than aliasing, so the top-level `*batch-depth*` reads 0 while the library-internal one is 1 (exported mutable state vars are misleading). - Repro: effect on `a3` (fired=1); `(guard (e (true "caught")) (batch (fn () (error "boom"))))` → caught; `(reset! a3 2)` → fired stays 1. Control test without error flushes correctly. - Coverage: not covered. ### [high] [CONFIRMED — surfaced by hosts lane, verified here] emit!/emitted state accumulates across evaluator invocations — cross-request contamination on the server - Location: spec/evaluator.sx scope/emit frame handlers + the process-global scope stacks (hosts: sx_primitives.ml `_scope_stacks`) - What: `(scope (emit! :k 1) (emit! :k 2) (len (emitted :k)))` returns 2, then 4, then 6 on successive epoch-server evals — the emit accumulator for a normally-exited scope persists in process-global state and each new scope sees prior invocations' values. On the HTTP server this means one request's emitted values are visible to the next (correctness + information-leak class). Complements the provide/raise leak finding: the scope facility's global stacks are neither unwind-safe NOR invocation-scoped. (My in-eval probe showed no leak *within* one evaluation — the leak is across evaluator entries.) - Repro: three identical `(eval "(scope (emit! :k 1) (emit! :k 2) (len (emitted :k)))")` epochs on one fresh sx_server → `2`, `4`, `6`. JIT disabled, so not a VM bug. - Coverage: scope/emit!/emitted have zero tests (noted previously); cross-invocation behavior untested anywhere. ### [medium] [CONFIRMED] provide's dynamic value permanently leaks on non-local exit (raise, shift) - Location: spec/evaluator.sx, `step-sf-provide` (:3344 `scope-push!`) + "provide" frame handler (:4293, `scope-pop!` only on normal completion); no pop during raise/guard/shift unwinding - What: `provide` pushes onto a global per-name stack, popped only on normal frame completion. Any non-local exit through the body skips the pop — the value stays on the global stack **forever**, and `context` prefers `scope-peek`, so all later code sees the stale value. - Repro: `(do (guard (e (true "caught")) (provide "kk" 42 (raise "boom"))) (context "kk"))` → **42** (expected nil). `(do (reset (provide "esc" 9 (shift k 77))) (context "esc"))` → **9**. - Coverage: test-unified-reactive.sx covers provide/context nesting for normal exits only. ### [medium] [CONFIRMED] provide! outside any enclosing provide installs a permanent ambient global - Location: spec/evaluator.sx, "provide-set" frame handler (:4334-4346: pop-then-push); host `scope-pop!` on empty stack is a no-op (sx_primitives.ml:1998) - Repro: `(do (provide! "pk" 7) nil)` then, in a later top-level eval, `(context "pk")` → **7**. - Coverage: provide! tests all run inside provide scopes; bare case uncovered. ### [medium] [CONFIRMED×2] set! on unbound name silently creates a binding — contradicting both spec docs — and JIT vs interpreter write different global tables (split brain) - Location: spec/evaluator.sx `step-sf-set!` + hosts/ocaml/lib/sx_types.ml `env_set_id` (:378 root-create fallback) vs sx_vm.ml OP_GLOBAL_SET (:606 writes `vm.globals`); contradicted docs: spec/eval-rules.sx:112 ("Error if name is not bound"), spec/special-forms.sx:141 ("must already be bound") - What: (a) interpreted `set!` on unbound silently creates a root binding — typo'd set! hides bugs, and directly contradicts both spec documents (test-scope.sx:196 locks the create behavior, so impl-vs-doc conflict must be resolved one way or the other). (b) inside a JIT-compiled lambda the same `set!` writes the VM's separate `vm.globals` table — visible to VM code, **invisible to interpreted code**. - Repro: `(set! never-defined-var 5)` → 5 (readable after). Split brain: `(do (define setter (fn () (set! q5 42))) (define reader (fn () q5)) (setter) (reader))` → **"Undefined symbol: q5"** (yet q5 reads as 42 inside setter). - Coverage: test-scope.sx:196 asserts creation only; visibility split uncovered. ### [medium] [CONFIRMED] Quasiquote has no depth tracking — nested quasiquote evaluates inner unquotes early; `,,x` errors - Location: spec/evaluator.sx, `qq-expand` (no level parameter) - Repro: `(let ((x 7)) (quasiquote (a (quasiquote (b (unquote x))))))` → `(a (quasiquote (b 7)))` (Scheme: unquote preserved); `` `(a `(b ,,x)) `` → `Undefined symbol: unquote`. - Coverage: test-cek-advanced.sx:486 "nested unquote" is single-level despite its name. ### [medium] [CONFIRMED] Quasiquote does not traverse dict literals — `,v` inside `{...}` stays literal - Location: spec/evaluator.sx, `qq-expand` (non-list templates returned as-is) - Repro: `(let ((v 3)) (quasiquote {:k (unquote v)}))` → `{:k (unquote v)}`. Inconsistent with dict eval rule ("values are evaluated", eval-rules.sx:40). - Coverage: not covered. ### [medium] [CONFIRMED] guard clause bodies: multi-expr → crash; multi-expr `else` → "Undefined symbol: else" - Location: spec/evaluator.sx, `step-sf-guard` — clauses spliced verbatim into a generated `cond`, inheriting the cond dual-mode defect - Repro: `(guard (e (true 1 2)) (raise 9))` → `Not callable: nil`; `(guard (e (else 1 2 3)) (raise 9))` → `Undefined symbol: else`. R7RS requires body sequencing. `=>` receiver works. - Coverage: only single-expr clause bodies tested. ### [medium] [CONFIRMED] defmacro/fn `&key` params silently misbind — keyword names ignored, off-by-one positional binding - Location: spec/evaluator.sx, macro/lambda param binding (&key pairing implemented only for components) - Repro: `(defmacro mk2 (&key a b) ...)`: `(mk2 :a 10 :b 20)` → a=10, b=`:b` (the keyword itself); `(mk2 :b 20 :a 10)` → a=20 despite the `:b` label. Plain `(fn (&key a b) ...)` treats `&key` as a positional param name → "expects 3 args, got 4". Accepted without error, misbehaves. - Coverage: not covered. ### [medium] [CONFIRMED] Splicing a non-list silently wraps it; malformed splice forms pass through literally - Location: spec/evaluator.sx, `qq-expand` - Repro: `(quasiquote (a (splice-unquote 5)))` → `(a 5)` (Scheme: error); `(splice-unquote xs ys)` (arity 3) → stays literal; `(unquote a b)` silently drops b. - Coverage: not covered. ### [medium] [CONFIRMED] `do` misparses a first form whose head is a list (IIFE) as a Scheme do-loop - Location: spec/evaluator.sx, step-eval-list "do" branch (~1843): dispatches to do-loop when `(list? (first (first args)))` - Repro: `(do ((fn (x) x) 5) 99)` → error `"first: expected list, got 5"`; expected 99. - Coverage: not covered. ### [medium] [CONFIRMED] scope's `:value` parameter is parsed but unreadable — dead feature + dead frame type - Location: spec/evaluator.sx, `step-sf-scope` (:3318) / `make-scope-acc-frame` (:120); `context`/`peek` never consult scope-acc frames. Pre-CEK `sf-scope` (:1495) did `scope-push!`; the CEK rewrite dropped it. Frame type "scope" (make-scope-frame :111, handler :4279) is never pushed by any live path. - Repro: `(scope "v" :value 10 (list (context "v") (peek "v")))` → `(nil nil)`. - Coverage: scope/emit!/emitted have ZERO tests in spec/tests (doc example only, eval-rules.sx:200). ### [medium] [CONFIRMED] Host-level errors are uncatchable by guard (only SX-level raise is) - Location: spec/evaluator.sx raise/handler machinery vs host primitive errors - What: errors from host primitives (`rest: 1 list arg`, `Undefined symbol`, arity errors) escape enclosing `guard` entirely; only guest `(raise ...)` unwinds to handlers. Guest code cannot write defensive wrappers around primitive misuse. - Repro: `(guard (e (true "caught")) (undefined-symbol-xyz))` → propagates, guard never fires. - Coverage: test-errors.sx/test-conditions.sx exercise guest raise only. ### [medium] [CONFIRMED] `values`/`call-with-values` bound only inside the test runner — Undefined symbol on every real runtime surface; `let-values`/`define-values` unusable - Location: spec/evaluator.sx `values` (2093), `call-with-values` (1392), `sf-let-values` (1403), `sf-define-values` (1437); hosts/ocaml/bin/run_tests.ml:1131 (`bind "values"` — test env only) - Repro: `(call-with-values (fn () (values 1 2)) +)` on CLI → `Undefined symbol: call-with-values`; same expr under run_tests → PASS. test-values.sx (22 tests) overstates the shipped runtime. - Coverage: green only in the runner environment. ### [medium] [CONFIRMED] map/filter/map-indexed are O(n²) - Location: spec/evaluator.sx, "map"/"filter" continue handlers (~4364, ~4397): `(append results (list value))` per element; map-indexed also recomputes `(len new-results)` each step - Repro: fresh sx_server: 10k → 0.58s, 20k → 2.56s, 40k → 13.6s (≈×4.7 per doubling); 100k map DNF in 120s while `(reduce + 0 (in-range 100000))` takes 0.32s. Stack-safe — purely time. - Coverage: not covered (no perf tests) ### [medium] [CONFIRMED] HO form names are not first-class — value position yields nil with a misleading type - Location: spec/evaluator.sx, symbol lookup (~1650) vs special-cased call dispatch - Repro: `(define f2 map) (f2 (fn (x) x) (list 1 2))` → `Not callable: nil`; yet `(type-of map)` → `"function"`. - Coverage: not covered ### [medium] [CONFIRMED] Cryptic uncatchable errors for bad HO data: dicts, both-args-callable - Location: spec/evaluator.sx, `seq-to-list` `(else x)` passthrough (~3573) + `ho-setup-dispatch` - Repro: `(map (fn (kv) kv) {:a 1 :b 2})` → `rest: 1 list arg`; `(map (fn (x) 1) (fn (y) 2))` → same. Expected: iterate dict entries or a clear "map: cannot iterate X". - Coverage: not covered ### [medium] [CONFIRMED] Multi-collection map rejects strings/vectors that single-collection map accepts - Location: spec/evaluator.sx, `ho-setup-dispatch` "map" N-coll branch skips `seq-to-list` - Repro: `(map + (vector 1 2) (vector 10 20))` → `first: expected list, got #(1 2)`; single-collection vector/string map works. - Coverage: list multi-map covered (test-r7rs.sx:110–124); strings/vectors not ### [medium] [CONFIRMED] Threading a lambda literal returns a silently malformed lambda - Location: spec/evaluator.sx, `thread-insert-arg` — splices the value into the params position of `(fn ...)` - Repro: `((-> 5 (fn (y) (+ y 1))) 7)` → `Undefined symbol: y`. Should error at thread time. - Coverage: not covered ### [medium] [CONFIRMED] Attribute names are never escaped/validated — spreading an untrusted-keyed dict injects attributes (XSS class) - Location: spec/render.sx, `render-attrs` (emits key raw) + `merge-spread-attrs` (copies spread-dict keys verbatim) - What: attribute *values* are escaped; attribute *names* are concatenated raw. Keys reach render-attrs via the spread operator, so spreading a dict built from user data yields event-handler injection. - Repro: `(render-attrs {"x onload=alert(1) y" "1"})` → ` x onload=alert(1) y="1"`. Values confirmed safe. - Coverage: not covered ### [medium] [CONFIRMED] Five void elements unrenderable — in VOID_ELEMENTS but missing from HTML_TAGS - Location: spec/render.sx, `VOID_ELEMENTS` vs `HTML_TAGS` - Repro: `area base embed param track` fall through to function-call dispatch: `(render-to-html '(base :href "x") ...)` → `Undefined symbol: base`. - Coverage: void suite tests br/hr/img/input/meta/link/source/col/wbr only ### [medium] [CONFIRMED] aser serialises list-valued keyword args as bare unquoted lists → breaks on client re-evaluation - Location: web/adapter-sx.sx `aser-call` - Repro: `(aser '(~tags :items (list "a" "b")) env)` → `(~tags :items ("a" "b"))`; re-evaluating the wire form → `Not callable: nil`. Dicts round-trip fine; only lists break. Should emit `(quote (...))` or `(list ...)`. - Coverage: test-aser covers lists as children, not as kwarg values ### [medium] [CONFIRMED-html / SUSPECTED-dom — independently double-confirmed] render-to-dom disagrees with render-to-html on non-boolean attrs valued true/false (hydration mismatch) - Location: web/adapter-dom.sx (attr cond ~357) vs spec/render.sx `render-attrs` - What: for attrs NOT in BOOLEAN_ATTRS, HTML mode stringifies (`data-flag="true"`, `data-off="false"`), DOM mode omits `false` and emits `true` as an empty attr. SSR HTML and hydrated DOM differ. HTML side executed; DOM side code-read (dom adapter not loadable in harness). Cross-check: hosts lane C19 found the same defect independently (same conclusion, same confidence split) — treat as confirmed pending a browser-side execution. - Repro: `(render-to-html '(div :data-flag true :data-off false) ...)` → `