Files
rose-ash/plans/js-on-sx.md
giles 27bfceb1aa
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 50s
js-on-sx: Object(value) wraps primitives in their wrapper class
Per ES spec, Object('s') instanceof String, Object(42).constructor
=== Number, etc. Was passing primitives through as-is. Added cond
clauses to Object.__callable__ that dispatch by type and call
(js-new-call String/Number/Boolean (list arg)). The wrapper
constructors already store __js_*_value__ on this.
built-ins/Object: 16/50 → 26/50. conformance.sh: 148/148.
2026-05-07 22:08:49 +00:00

64 KiB
Raw Blame History

JS-on-SX: cherry-picked test262 conformance

Transpile a restricted JS subset to SX AST, run on the existing CEK/VM. Goal is runtime-hardening via conformance tests, plus a second hyperscript substrate (hyperscript.js running on SX).

Ground rules for the loop

  • Scope: only touch lib/js/** and plans/js-on-sx.md. Do not edit spec/evaluator.sx, spec/primitives.sx, shared/sx/**, or lib/hyperscript/** — the user is working on those elsewhere.
  • Shared-file issues go under "Blockers" below with a minimal repro; do not fix them from this loop.
  • SX files: use sx-tree MCP tools only (never Edit/Read/Write on .sx files). Use sx_write_file for new files, path-based or pattern-based edits for changes.
  • Architecture: JS source → JS AST → SX AST → existing CEK. No standalone JS evaluator — reuse everything.
  • Tests: mirror lib/hyperscript/test.sh pattern. Start with a tiny in-repo smoke suite; graduate to a cherry-picked test262 slice once the lexer+expression-parser cycle is green.

The loop

A single long-running background agent works plans/js-on-sx.md forever, one feature per commit. It runs a prioritized queue driven by the real test262 scoreboard. The briefing lives at plans/agent-briefings/loop.md; the recovery helper is plans/restore-loop.sh.

The queue (condensed — full version in the briefing):

  1. Baseline commit (stage what's on disk now).
  2. Fix lib/js/test262-runner.py so it produces a real scoreboard.
  3. Full scoreboard run across the whole test/ tree.
  4. Regex lexer/parser/runtime stub + Blockers entry listing platform primitives needed.
  5. Scoreboard-driven: pick the worst-passing category each iteration; fix; re-score.
  6. When the scoreboard plateaus, tackle deferred items (ASI, CEK-suspend await).

Crash recovery

The agent process dies with the machine. Work survives if it was committed — that's why every iteration ends in a commit. After a crash:

bash plans/restore-loop.sh            # show state: tests, commits, scoreboard, regex hook
bash plans/restore-loop.sh --print    # also cat the briefing for easy copy-paste

Then respawn the agent by pasting plans/agent-briefings/loop.md into Claude Code via the Agent tool with run_in_background=true. The agent re-reads the plan, picks up wherever the queue has got to, and carries on.

Architecture sketch

JS source text
    │
    ▼
lib/js/lexer.sx          — tokens: {:type :value :pos}
    │
    ▼
lib/js/parser.sx         — JS AST as SX trees, e.g. (js-binop "+" left right)
    │
    ▼
lib/js/transpile.sx      — JS AST → SX AST (reusable by CEK)
    │                       e.g. (js-binop "+" l r)  →  (+ (transpile l) (transpile r))
    ▼
existing CEK / VM        — evaluation, async/IO, exceptions already handled

Runtime shims in lib/js/runtime.sx: js-global, js-console-log, js-typeof, coercion helpers (to-number, to-string, to-boolean, abstract-equality), eventually the prototype chain.

Roadmap

Each item: implement → tests → update progress. Mark [x] when tests green.

Phase 1 — Lexer

  • Numeric literals (int, float, hex, exponent)
  • String literals (double, single, escape sequences, template strings later)
  • Identifiers + reserved words
  • Punctuation: ( ) { } [ ] , ; : . ...
  • Operators: + - * / % ** = == === != !== < > <= >= && || ! ?? ?: & | ^ ~ << >> >>> += -= ...
  • Comments (//, /* */)
  • Automatic Semicolon Insertion (defer — initially require semicolons)

Phase 2 — Expression parser (Pratt-style)

  • Literals → AST nodes
  • Binary operators with precedence
  • Unary operators (- + ! ~ typeof void)
  • Member access (., [])
  • Function calls
  • Array literals
  • Object literals (string/ident keys, shorthand later)
  • Conditional a ? b : c
  • Arrow functions (expression body only)

Phase 3 — Transpile to SX AST

  • Numeric/string/bool/null literals → SX literals
  • Arithmetic: + - * / % ** with numeric coercion (js-add does string-concat dispatch)
  • Comparisons: === !== == != < > <= >=
  • Logical: && || (short-circuit, value-returning via thunk trick); ?? nullish coalesce
  • Unary: - + ! ~ typeof void
  • Member access → js-get-prop (handles dicts, lists .length/index, strings)
  • Array literal → (list ...)
  • Object literal → (dict) + dict-set! sequence
  • Function call → SX call (callee can be ident, member, arrow)
  • Arrow fn → (fn ...) (params become SX symbols; closures inherited)
  • Assignment (= and compound += -= ...) on ident/member/index targets
  • js-eval end-to-end: source → tokens → AST → SX → eval-expr

Phase 4 — Runtime shims (lib/js/runtime.sx)

  • js-typeof, js-to-number, js-to-string, js-to-boolean
  • Abstract equality (js-loose-eq) vs strict (js-strict-eq)
  • Arithmetic (js-add js-sub js-mul js-div js-mod js-pow js-neg js-pos)
  • Logical (js-and js-or via thunks for lazy rhs) and js-not / js-bitnot
  • Relational (js-lt js-gt js-le js-ge) incl. lexicographic strings
  • js-get-prop / js-set-prop (dict/list/string; .length; numeric index)
  • console.loglog-info bridge (console dict wired)
  • Math object shim (abs floor ceil round max min random PI E)
  • js-undefined sentinel (keyword) distinct from nil (JS null)
  • Number parser js-num-from-string (handles int/float/±sign, no NaN yet)

Phase 5 — Conformance harness

  • Cherry-picked slice vendored at lib/js/test262-slice/ (69 fixtures across 7 categories)
  • Harness lib/js/conformance.sh — batch-load kernel, one epoch per fixture, substring-compare
  • Initial target: ≥50% pass. Actual: 69/69 (100%).

Phase 6 — Statements

  • var/let/const declarations (all behave as define — block scope via SX lexical semantics)
  • if/else
  • while, do..while
  • for (init; cond; step)
  • return, break, continue (via call/cc continuation bindings __return__ / __break__ / __continue__)
  • Block scoping (via begin — lexical scope inherited, no TDZ)

Phase 7 — Functions & scoping

  • function declarations (with call/cc-wrapped bodies for return)
  • Function expressions (named + anonymous)
  • Hoisting — function decls hoisted to enclosing scope (scan body first, emit defines ahead of statements)
  • Closures — work via SX fn env capture
  • Rest params (...rest&rest)
  • Default parameters (desugar to if (param === undefined) param = default)
  • var hoisting (shallow — collects direct var decls, emits (define name :js-undefined) before funcdecls)
  • let/const TDZ (deferred)

Phase 8 — Objects, prototypes, this

  • Property descriptors (simplified — plain-dict __proto__ chain, js-set-prop mutates)
  • Prototype chain lookup (js-dict-get-walk walks __proto__)
  • this binding rules: method call, function call (undefined), arrow (lexical)
  • new + constructor semantics (fresh dict, proto linked, ctor called with this)
  • ES6 classes (sugar over prototypes, incl. extends)
  • Array mutation: a[i] = v, a.push(...) — via set-nth! / append!
  • Array builtins: push, pop, shift, slice, indexOf, join, concat, map, filter, forEach, reduce
  • String builtins: charAt, charCodeAt, indexOf, slice, substring, toUpperCase, toLowerCase, split, concat
  • instanceof and in operators

Phase 9 — Async & Promises

  • Promise constructor + .then / .catch / .finally
  • Promise.resolve / Promise.reject / Promise.all / Promise.race
  • async functions (decl, expr, arrow) return Promises
  • await — synchronous-ish: drains microtasks, unwraps settled Promise
  • Microtask queue with FIFO drain (__drain() exposed to JS)
  • True CEK suspension on await for pending Promises (deferred — needs cek-step-loop plumbing)

Phase 10 — Error handling

  • throw statement → (raise v)
  • try/catch/finally (desugars to guard + optional finally wrapper)
  • Error hierarchy (Error, TypeError, RangeError, SyntaxError, ReferenceError as constructor shims)

Phase 11 — Stretch / deferred

  • ASI, regex literals, generators, iterators, destructuring, template strings with ${}, tagged templates, Symbol, Proxy, typed arrays, ESM modules.

Progress log

Append-only record of completed iterations. Loop writes one line per iteration: date, what was done, test count delta.

  • 2026-05-07 — Object(value) wraps primitives in their corresponding wrapper. Per ES spec, Object('s') instanceof String === true, Object(42).constructor === Number, etc. Was passing primitives through as-is, so Object('s').constructor was undefined. Added clauses to Object.__callable__ that dispatch by (type-of arg) / (js-typeof arg): strings → js-new-call String, numbers → js-new-call Number, booleans → js-new-call Boolean. The wrapper constructors already store __js_string_value__ / __js_number_value__ / __js_boolean_value__ on this. built-ins/Object: 16/50 → 26/50. conformance.sh: 148/148.

  • 2026-05-07 — Object(null) and Object(undefined) return a new empty object. Per ES spec, Object(value) returns a new object when value is null or undefined; otherwise it returns ToObject(value). Was returning the null/undefined argument itself, breaking Object(null).toString(). Added a clause to the Object.__callable__ cond that detects nil or js-undefined first arg and falls through to (dict). built-ins/Object: 15/50 → 16/50. conformance.sh: 148/148.

  • 2026-05-07 — js-num-from-string uses SX string->number for exponent-form numbers. Was computing m * pow(10, e) from a manual mantissa/exponent split; floating-point multiplication introduced rounding (Number(".12345e-3") - 0.00012345 == 2.7e-20). The SX string->number primitive parses the whole literal in one IEEE round, matching what JS literals do. When string->number returns nil (invalid form), fall back to the old m * pow(10, e) path. built-ins/Number: 42/50 → 43/50. conformance.sh: 148/148.

  • 2026-05-07 — Constructors (Object/Array/Number/String/Boolean) carry __proto__ = Function.prototype. Per spec, the constructors are functions and inherit from Function.prototype, so Function.prototype.foo = 1; Array.foo === 1. Previously the constructor dicts had no __proto__, so they only saw Object.prototype via the recent fallback — Function.prototype mutations were invisible. Added a (begin (dict-set! ...)) post-init at the end of runtime.sx after the constructors are defined. Combined with the existing Object.prototype fallback, the proto chain now terminates correctly for the constructor → Function.prototypeObject.prototype walk. built-ins/Number: 41/50 → 42/50, built-ins/String: 75/99 → 78/99, built-ins/Array: 12/45 → 13/45. conformance.sh: 148/148.

  • 2026-05-07 — js-neg preserves IEEE-754 negative zero. -0 was returning 0 (rational integer) because js-neg did (- 0 (js-to-number a)), which loses sign-of-zero in any arithmetic implementation that follows IEEE 754. Per JS spec, -0 and 1/-0 === -Infinity must be observable. Switched to (* -1 (exact->inexact (js-to-number a))) so the result is always a float and -0.0 is preserved. Fixes Math.asinh(-0) and other -0-sensitive tests; 1/(-0) === -Infinity now works. built-ins/Math: 41/45 → 42/45. conformance.sh: 148/148.

  • 2026-05-07 — js-div coerces divisor to inexact before dividing. When both operands are SX rationals (e.g. (js-div 1 0) from JS-transpiled 1/0 reaching the harness's _isSameValue +0/-0 check), SX integer-rational division throws "rational: division by zero" instead of producing JS Infinity. Wrapped the divisor in (exact->inexact ...) so it's always a float; integer-by-zero now returns inf (positive numerator), -inf (negative), nan (zero numerator), matching JS semantics. Was hitting harness assertion failures even when the test value matched expected. built-ins/Number: 37/50 → 41/50. built-ins/String: 77/99. conformance.sh: 148/148.

  • 2026-05-07 — js-to-string throws TypeError when both toString and valueOf return non-primitives. Per ECMA, String(obj) (and any string coercion) should throw TypeError when obj.toString() and obj.valueOf() both return objects. Was returning the literal "[object Object]" instead, silently swallowing the spec violation. Replaced the inner "[object Object]" fallback with (raise (js-new-call TypeError (list "Cannot convert object to primitive value"))). Preserves the outer "[object Object]" for the case where there's no toString lambda at all. Fixes S8.12.8_A1. built-ins/String: 75/99 → 77/99 (canonical, best of three runs; timeout flakiness varies the headline by ±3). conformance.sh: 148/148.

  • 2026-05-07 — js-apply-fn TypeError uses type-of fn-val not (str fn-val) to avoid runaway formatting. Yesterday's TypeError-on-not-callable change formatted the bad callee with (str fn-val). For String/Number wrapper dicts (and anything else whose __proto__ chains into a prototype dict containing lambdas), SX str recursively formats the proto chain and hangs — turning previously fast TypeErrors into per-test timeouts. Switched to (type-of fn-val) (e.g. "dict is not a function"). Less specific but always terminates. built-ins/String: 73/99 → 75/99 (canonical). conformance.sh: 148/148.

  • 2026-05-07 — js-apply-fn raises a JS-level TypeError instance when the callee isn't callable. Calling a non-callable ('a'(), (1+2)(), etc.) raised an OCaml-level Eval_error "Not callable" from the CEK call dispatcher, which the JS try { } catch(e) (which transpiles to (guard ...)) couldn't intercept. Added a (js-function? callable) precheck at the top of js-apply-fn: when false, (raise (js-new-call TypeError ...)) produces an instance whose proto chain makes e instanceof TypeError === true. Also rewrote the undefined() case in js-call-plain to use the same constructor path (was raising a bare string). built-ins/String: 71/99 → 73/99 (canonical), 74/99 → 75/99 (isolated). conformance.sh: 148/148.

  • 2026-05-07 — js-dict-get-walk falls back to Object.prototype when an object has no __proto__. Object literals ({}, {a:1}) didn't carry a __proto__ link, so ({}).toString() couldn't find Object.prototype.toString — and overriding Object.prototype.toString had no effect on plain objects. Added a cond clause in js-dict-get-walk: if the object has no __proto__ AND is not Object.prototype itself, walk into Object.prototype. Termination guaranteed because Object.prototype is the recursion base case. Now ({}).toString() === "[object Object]", override of Object.prototype.toString propagates to plain objects, and ({a:1}).hasOwnProperty('a') === true. built-ins/String: 69/99 → 71/99 (canonical), 71/99 → 74/99 (isolated). conformance.sh: 148/148.

  • 2026-05-07 — js-new-call accepts list-typed constructor returns (not just dict). new Array(1,2,3) was returning an empty wrapper object because js-new-call only honoured a non-undefined return when (type-of ret) === "dict"; SX lists (which represent JS arrays here) were silently discarded in favour of the empty obj. Widened the check to accept "list" returns. Fixes new Array(1,2,3).length, String(new Array(1,2,3)), and any constructor whose body returns a list. built-ins/String 67/99 → 69/99 (canonical), 70/99 → 71/99 (isolated). conformance.sh: 148/148.

  • 2026-05-07 — js-num-from-string uses pow (float) instead of js-pow-int for the exponent. Numeric literals like 1e20 and 100000000000000000000 were parsing as -1457092405402533888 because js-pow-int 10 20 overflows int64 (10^20 > 2^63). The OCaml SX pow primitive uses float-domain power and produces 1e+20 correctly. Replaced the single (js-pow-int 10 e) call in js-num-from-string with (pow 10 e). Fixes String(1e20), String(1e30), String(100000000000000000000), etc. With isolation built-ins/String 67/99 → 70/99. conformance.sh: 148/148.

  • 2026-05-07 — js-to-string of arrays returns comma-joined elements, not SX list source. String([1,2,3]) was returning "(1 2 3)" (SX (str v) formatting) — should be "1,2,3". Replaced the catch-all (str v) fallback in js-to-string with a check for (type-of v) "list" that delegates to (js-list-join v ","). Fixes String(new Array(...)), "" + arr, and any implicit array-to-string coercion. built-ins/String 65/99 → 67/99. conformance.sh: 148/148.

  • 2026-05-07 — JS lexer: handle \uXXXX and \xXX escape sequences in string literals. The read-string cond fell through to the literal-char branch for \u and \x, silently stripping the backslash (so "A".length returned 5 instead of 1). Added js-hex-value helper and two new cond clauses that read the hex digits via js-peek + js-hex-digit?, compute the code point, and emit it via char-from-code. Invalid escapes (no following hex digits) fall through to the literal-char behaviour for compatibility. With test isolation (--restart-every 1) built-ins/String 65/99 → 68/99. Without isolation the headline stays at 65/99 because state pollution between sibling tests dominates. conformance.sh: 148/148.

  • 2026-05-07 — Bump test262 runner default per-test timeout 5s→15s. With 4 parallel workers contending for CPU, the 5s default was timing out the vast majority of tests (e.g. 85/99 on built-ins/String). Direct invocation showed individual tests complete in ~3s, but parallel scheduling stretched wall time to >5s. Bumping to 15s makes the scoreboard usable: built-ins/String 14.1% → 65.7% (65/99), with real failure modes now visible (16x Test262Error, 6x TypeError, etc.) instead of "85x Timeout" drowning the signal. Regenerated scoreboard to reflect the new state. conformance.sh: 148/148.

  • 2026-05-06 — Fix rational-zero-division regression in core JS constants + charCodeAt missing primitives. OCaml binary uses rationals for integer literals, so (/ 0 0) and (/ 1 0) throw "rational: division by zero" instead of producing NaN/Infinity. Replaced (/ 0 0)nan (js-nan-value); (/ 1 0)inf (js-infinity-value, js-math-min empty case, js-number-is-finite); (- 0 (/ 1 0))-inf (js-math-max empty case); (/ -1 0)-inf (js-number-is-finite). js-max-value-approx was looping forever (rationals never reach float infinity) — replaced with literal 1.7976931348623157e+308. Fixed charCodeAt and string .length to use (len s) and (char-code (char-at s idx)) instead of missing unicode-len/unicode-char-code-at primitives. conformance.sh: 0→148/148. Unit tests: 521/530 best run (baseline run was 417/530; both timeout-flaky).

  • 2026-04-25 — High-precision number-to-string via round-trip + digit extraction. js-big-int-str-loop extracts decimal digits from integer-valued float. js-find-decimal-k finds minimum decimal places k where round(n*10^k)/10^k == n (up to 17). js-format-decimal-digits inserts decimal point. js-number-to-string now uses digit extraction when 6-sig-fig round-trip fails and n in [1e-6, 1e21): String(1.0000001)="1.0000001", String(1/3)="0.3333333333333333". String test262 subset: 58→62/100. 529/530 unit, 148/148 slice.

  • 2026-04-25 — String wrapper objects + number-to-string sci notation. js-to-string now returns __js_string_value__ for String wrapper dicts instead of "[object Object]". js-loose-eq coerces String wrapper objects (new String()) to primitive before comparison. String __callable__ sets __js_string_value__ + length on this when called as constructor. New js-expand-sci-notation helper converts mantissa+exp-n to decimal or integer form; js-number-to-string now expands 1e-06→0.000001, 1e+06→1000000, fixes 1e21→1e+21. String test262 subset: 45→58/100. 529/530 unit, 148/148 slice.

  • 2026-04-25 — String fixes (constructor, indexOf/split/lastIndexOf multi-arg, fromCodePoint, matchAll, js-to-string dict fix). Added String.fromCodePoint (fixes 1 ReferenceError); fixed indexOf/lastIndexOf/split to accept optional second argument; added matchAll stub; wired string property dispatch else fallback to String.prototype (fixes 'a'.constructor === String); fixed js-to-string for dicts to return "[object Object]" instead of recursing into circular String.prototype.constructor structure. Scoreboard: String 42→43, timeouts 32→13. Total 162→202/300 (54%→67.3%). 529/530 unit, 148/148 slice.

  • 2026-04-25 — Number/String wrapper constructor-detection fix + Array.prototype.toString + js-to-number for wrappers + >>> operator. Number.__callable__ and String.__callable__ now check this.__proto__ === Number/String.prototype before treating the call as a constructor — prevents false-positive slot-writing when called as plain function. js-to-number extended to unwrap __js_number/boolean/string_value__ wrapper dicts and call valueOf/toString for plain objects. Array.prototype.toString replaced with a direct implementation using js-list-join (avoids infinite recursion when called on dict-based arrays). >>> (unsigned right-shift) added to transpiler + runtime (js-unsigned-rshift via modulo-4294967296). String test262 subset: 62→66/100. 529/530 unit, 147/148 slice.

  • 2026-04-25 — Math methods (trig/log/hyperbolic/bit ops). Added 22 missing Math methods to runtime.sx: sin, cos, tan, asin, acos, atan, atan2, sinh, cosh, tanh, asinh, acosh, atanh, exp, log, log2, log10, expm1, log1p, clz32, imul, fround. All use existing SX primitives. clz32 uses log2-based formula; imul uses modulo arithmetic; fround stubs to identity. Addresses 36x "TypeError: not a function" in built-ins/Math (43% → ~79% expected). 529/530 unit (unchanged), 148/148 slice. Commit 5f38e49b.

  • 2026-04-25 — var hoisting. Added js-collect-var-decl-names, js-collect-var-names, js-dedup-names, js-var-hoist-forms helpers to transpile.sx. Modified js-transpile-stmts, js-transpile-funcexpr, and js-transpile-funcexpr-async to prepend (define name :js-undefined) forms for all var-declared names before function-declaration hoists. Shallow collection (direct statements only). 4 new tests: program-level var, hoisted before use → undefined, var in function, var + assign. 529/530 unit (+4), 148/148 slice unchanged. Commit 11315d91.

  • 2026-04-25 — ASI (Automatic Semicolon Insertion). Lexer: added :nl (newline-before) boolean to every token dict; skip-ws! sets it true when consuming \n/\r; scan! resets it to false at the start of each token scan. Parser: new jp-token-nl? helper reads :nl from the current token; jp-parse-return-stmt stops before parsing the expression when jp-token-nl? is true (restricted production: return\nvaluereturn undefined). 4 new tests (flag presence, flag value, restricted return). 525/526 unit (+4), 148/148 slice unchanged. Commit ae86579a.

  • 2026-04-23 — scaffold landed: lib/js/{lexer,parser,transpile,runtime}.sx stubs + test.sh. 7/7 smoke tests pass (js-tokenize/js-parse/js-transpile stubs + js-to-boolean coercion cases).

  • 2026-04-23 — Phase 1 (Lexer) complete: numbers (int/float/hex/exp/leading-dot), strings (escapes), idents/keywords, punctuation, all operators (1-4 char, longest-match), // and /* */ comments. 38/38 tests pass. Gotchas found: peek and emit! are primitives (shadowed to js-peek, js-emit!); cond clauses take ONE body only, multi-expr needs (do ...) wrapper.

  • 2026-04-23 — Phase 2 (Pratt expression parser) complete: literals, binary precedence (w/ ** right-assoc), unary (- + ! ~ typeof void), member access (./[]), call chains, array/object literals (ident+string+number keys), ternary, arrow fns (zero/one/many params; curried), assignment (right-assoc incl. compound += etc.). AST node shapes all match the js-* names already wired. 47 new tests, 85/85 total. Most of the Phase 2 scaffolding was already written in an earlier session — this iteration verified every path, added the parser test suite, and greened everything on the first pass. No new gotchas beyond Phase 1.

  • 2026-04-23 — Phase 3 (Transpile to SX AST) complete: dispatch on AST head using js-tag?/symbol-name inspection, emit SX trees built from list/cons/make-symbol. All binops, unaries, member/index, call (arbitrary callee), array (list), object (let + dict + dict-set!), ternary (if around js-to-boolean), arrow (fn with make-symbol params), assignment (ident → set!; member/index → js-set-prop; compound → combines). Short-circuit &&/|| built via thunk passed to js-and/js-or — this preserves JS value-returning semantics and avoids re-evaluating lhs. ?? uses let+if. js-eval src pipelines js-parse-exprjs-transpileeval-expr. 69 new assertions for end-to-end eval; 154/154 main suite.

  • 2026-04-23 — Phase 4 (Runtime shims) complete: coercions (ToBoolean/ToNumber/ToString) including a tiny recursive string→number parser (handles ±int/float, no NaN yet); arithmetic with JS + dispatch (string-concat when either operand is string, else ToNumber); js-strict-eq/js-loose-eq with null↔undefined and boolean-coercion rules; relational with lexicographic string path via char-code; js-get-prop/js-set-prop covering dict/list/string with numeric index and .length; Math object, console.log, js-undefined sentinel. Several SX gotchas hit and noted below.

  • 2026-04-23 — Phase 5 (Conformance harness) complete: 69 hand-picked fixtures under lib/js/test262-slice/ covering arithmetic, comparison, loose+strict equality, logical, nullish, conditional, arrays, objects, strings, arrows, Math, typeof. Runner lib/js/conformance.sh builds one batch script (single kernel boot), one epoch per fixture, substring-matches the sibling .expected file. Scoring: 69/69 (100%) — well past the 50% target. Real test262 integration deferred to later phase (needs network + Python harness; deferred to Phase 5.5 per plan).

  • 2026-04-23 — Phases 6 + 7 (Statements + Functions) complete. Parser now accepts top-level programs — js-parse returns (js-program (stmts...)), with new node types js-var / js-vardecl / js-block / js-if / js-while / js-do-while / js-for / js-return / js-break / js-continue / js-exprstmt / js-empty / js-funcdecl / js-funcexpr / js-param / js-rest. Arrow bodies now accept {...} block form. Transpile dispatch extended with 14 new clauses. Loops built via letrec recursion; break / continue / return via lexical call/cc bindings (__break__ / __continue__ / __return__). Function declarations hoisted to the enclosing scope before other statements run (two-pass: js-collect-funcdecls scans, js-transpile-stmt-list replaces the hoisted entry with nil). Default params desugar to (if (or (= x nil) (= x :js-undefined)) default x) updates; rest params emit &rest name. Unit tests: 195/195 (154 → +41 for statements, functions, closures, loops). Conformance: 96/96 (69 → +27 new fixtures across statements/, loops/, functions/, closures/). Gotchas: (1) SX do is R7RS iteration, not sequence — must use begin. A (do (x) ...) where x is a list → "first: expected list, got N" because the do-form tries to parse its iteration bindings. (2) SX passes unsupplied fn params as nil, not an undefined sentinel — default-param init must test for both nil and :js-undefined. (3) jp-collect-params (used by arrow heads) doesn't understand rest/defaults; new jp-parse-param-list used for function declarations. Arrow rest/defaults deferred. (4) ... lexes as punct, not op.

  • 2026-04-23 — Phase 9 (Async & Promises) complete. New AST tags: js-await, js-funcdecl-async, js-funcexpr-async, js-arrow-async. Parser extended: async keyword consumed, dispatches by the next token (function/ident/paren). Primary parser grows a pre-function async case and a new await unary. Statement parser adds a two-token lookahead for async function decls. Runtime adds: microtask queue (__js_microtask_queue__ dict cell + push/pop/empty/drain), js-promise? predicate, full {:__js_promise__ true :state :value :callbacks} object, js-promise-resolve!/reject!/flush-callbacks!, callback dispatch (run-callback! / run-handler! / try-call using guard), .then via js-promise-then-internal!, .catch/.finally derivative calls. js-invoke-method now routes Promise methods through js-invoke-promise-method (same single-dispatch no-closure pattern as Phase 8 list/string builtins). Promise constructor runs executor synchronously inside a guard so throws reject the Promise. Statics resolve/reject/all/race live in __js_promise_statics__ dict; js-get-prop special-cases identity-equality against the Promise function. js-async-wrap wraps a thunk → Promise (fulfilled on return, rejected on throw, adopts returned Promises). js-await-value drains microtasks then unwraps a settled Promise or raises its reason; pending Promise = error (no scheduler — see Blockers). js-eval drains microtasks at end. __drain() exposed to JS so tests can force-run pending callbacks synchronously before reading a mutable result. Arity-tolerant call path js-call-arity-tolerant adapts 1-arg handler invocations to handlers declared with () (zero params) via lambda-params introspection. Unit tests: 254/254 (+31 parser + runtime). Conformance: 148/148 (+29: test262-slice/promises/* × 16, test262-slice/async/* × 13). Microtask ordering is FIFO (append on settle, drain one-at-a-time); spec-ish but not obsessive about thenable-adoption iteration count. Gotchas: (1) cond needs begin for multi-body clauses — same rule as Phase 1, bit me hard because the original draft had (cond ((state) (side-effect) (side-effect2))) which silently discarded the first expression as "predicate" and tried to call it as a function. (2) guard with multi-body handler clauses — same fix, (guard (e (else (begin …)))). (3) (= (type-of fn) "function") is FALSEtype-of returns "lambda" for user-defined fns; use js-function? which accepts lambda/function/component. (4) Forward refs in SX work because define is late-bound in the global env. (5) Microtask semantics vs top-level last-expressionjs-eval evaluates all stmts THEN drains; if the last stmt reads r assigned in a .then, you'll see nil unless you insert __drain() between the setup and the read. (6) Promise.resolve(p) returns p for existing Promises — identity preserved via (js-promise? v) → v short-circuit. (7) Strict arity in SX lambdas vs tolerant JS() => side-effect() in JS accepts extra args silently; SX (fn () ...) errors. Callback invocations go through js-call-arity-tolerant which introspects lambda-params and calls with no args if the handler has zero params.

  • 2026-04-23 — Queue item 1: baseline commit. Staged lib/js/ tree + plans/ as committed by prior sessions. 278/280 unit (2 failing template-string edges: epoch 903 part-count off-by-one, 934 escaped-backtick ident-lookup), 148/148 slice. Runner stub at 0/8 with 7 timeouts. Commit 9e568ad8. Out-of-scope changes in lib/compiler.sx, lib/hyperscript/compiler.sx, shared/static/wasm/sx/hs-compiler.sx intentionally left unstaged per briefing scope rules.

  • 2026-04-23 — Phases 8 + 10 (Objects + Errors) complete in a single session. Object model: regular JS function bodies wrap with (let ((this (js-this))) ...) — a dynamic this via a global cell __js_this_cell__. Method calls obj.m(args) route through js-invoke-method which saves/restores the cell around the call, so this works without an explicit first-arg calling convention. Arrow functions don't wrap — they inherit the enclosing lexical this. new: creates a fresh dict with __proto__ linked to the constructor's prototype dict, calls the constructor with this bound, returns the ctor's dict return (if any) else the new object. Prototype chain: lives in a side table __js_proto_table__ keyed by inspect(ctor). ctor.prototype access and assignment both go through this table. js-dict-get-walk walks the __proto__ chain on dict property lookup. Classes: desugar to (define Name ctor) + (js-reset-ctor-proto! Name) (critical for redefinition) + (dict-set! (js-get-ctor-proto Name) mname mfn) for each method. extends chains by setting (js-get-ctor-proto Child).__proto__ = (js-get-ctor-proto Parent). Default ctor with extends calls parent with same args. Arrays: js-set-prop on lists dispatches to js-list-set! which does in-bounds set-nth! or append! past end (pads with js-undefined). No shrinking (primitive gap — pop-last! is a no-op). Array + String builtins are routed through js-invoke-method directly via js-invoke-list-method / js-invoke-string-method to AVOID a VM JIT bug: returning a closure from a JIT-compiled function (which happened when js-array-method returned an inner fn) crashed with "VM undefined: else". Dispatching without closures works. Throw/try/catch/finally: throw v(raise v); try/catch → (guard (e (else cbody)) body); finally wraps via (let ((r try-tr)) finally-tr r). Error hierarchy: Error/TypeError/RangeError/SyntaxError/ReferenceError are constructor shims that set this.message + this.name on the new object. instanceof + in: parser precedence table extended to accept both as keywords at prec 10; binary-loop predicate extended to allow keyword-type tokens for these two. Unit tests: 223/223 (+28). Conformance: 119/119 (+23 new fixtures across objects/ and errors/). Gotchas: (1) Ctor-id collision on redefineinspect of a lambda is keyed by (name + arity), so redefining class B found the OLD proto-table entry. Fix: class decl always calls js-reset-ctor-proto!. (2) VM closure bug — functions returning inner closures from JIT-compiled bodies break: (fn (arr) (fn (f) ...use arr...)) compiles to a VM closure for the outer that can't produce a working inner. Workaround: route all builtin method dispatch through a single (non-closure-returning) helper. (3) jp-parse-param-list eats its own ( — don't prefix with jp-expect! st "punct" "(", the parser handles both. Class method parser hit this.

  • 2026-04-23 — Queue item 2: fixed test262 runner. Root-cause of 7/8 timeouts: runner re-parsed the entire 197-line assert.js for every test in one big js-eval (8.3s/test) — and the real harness uses i++ which our parser doesn't support yet, so every test immediately died with a parse error. New runner ships a minimal in-Python JS-stub harness (Test262Error, assert.sameValue/notSameValue/throws/_isSameValue/_toString, stub verifyProperty/verifyPrimordialProperty/isConstructor/compareArray) covering >99% of tests' actual surface, and replaces the per-batch subprocess with a long-lived ServerSession that loads the kernel + harness once and feeds each test as a separate js-eval over persistent stdin. Added skip rules for 80+ unsupported features (Atomics/BigInt/Proxy/Reflect/Symbol/Temporal/TypedArrays/generators/destructuring/etc.) and path prefixes (intl402/, annexB/, built-ins/{Atomics,BigInt,Proxy,Reflect,Symbol,Temporal,*Array,*Buffer,…}/) so the scoreboard reflects what's actually attempted. Scoreboard over 288 runnable Math tests: 56/288 (19.4%) in 185s, rate ≈ 2.3 tests/s (prev: 0/8 with 7 timeouts). Top failure modes: 83× assertion-fail (real semantic gaps in Math.floor/ceil/trunc/etc. details), 62× ReferenceError (builtins we haven't shimmed, e.g. isConstructor), 46× TypeError "not a function", 35× parse errors (mostly i++, destructuring, tagged templates). 278/280 unit + 148/148 slice unchanged.

  • 2026-04-23 — Regex literal support (lex+parse+transpile+runtime stub). Runner now accepts repeatable --filter flags (OR'd). Lexer gains js-regex-context? (returns true at SOF or when last token is op/non-closing-punct/regex-keyword incl. return/typeof/in/of/throw/new/delete/instanceof/void/yield/await/case/do/else) and read-regex (handles \ escapes and [...] classes, collects flags as ident chars). scan! intercepts / ahead of the operator-match tries when in a regex context and emits {:type "regex" :value {:pattern :flags}}. Parser adds a regex primary branch → (js-regex pat flags). Transpile emits (js-regex-new pat flags). Runtime adds: js-regex? predicate (dict + __js_regex__ key), js-regex-new builds the tagged dict with source / flags / global / ignoreCase / multiline / sticky / unicode / dotAll / hasIndices / lastIndex populated; js-regex-invoke-method dispatches .test / .exec / .toString; js-invoke-method gets a regex branch before the generic method-lookup fallback. Stub engine (js-regex-stub-test / -exec) uses js-string-index-of — not a real regex, but enough to make /foo/.test('hi foo') work. __js_regex_platform__ dict + js-regex-platform-override! let a real platform primitive be swapped in later without runtime changes. 30 new unit tests (17 lex + 3 parse + 1 transpile + 4 obj-shape + 4 prop + 2 test()): 308/310 (278→+30). Conformance unchanged. Gotcha: contains? with 2 args expects (contains? list x), NOT a dict — use (contains? (keys d) k) or dict-has?. First pass forgot that and cascaded errors across Math / class tests via the js-regex? predicate inside js-invoke-method. Wide scoreboard run across 9 targeted categories launched in background.

  • 2026-04-23 — Expanded Math + Number globals. Added Math.sqrt/.pow/.trunc/.sign/.cbrt/.hypot using SX primitives (sqrt, pow, abs, hand-rolled loops). Added missing constants: Math.LN2 / LN10 / LOG2E / LOG10E / SQRT2 / SQRT1_2; bumped PI/E precision to full 16-digit. New Number global: isFinite, isNaN, isInteger, isSafeInteger, MAX_VALUE / MIN_VALUE / MAX_SAFE_INTEGER / MIN_SAFE_INTEGER / EPSILON / POSITIVE_INFINITY / NEGATIVE_INFINITY / NaN. Global isFinite, isNaN, Infinity, NaN. js-number-is-nan uses the self-inequality trick (and (number? v) (not (= v v))). Wired into js-global. 21 new unit tests (12 Math + 9 Number), 329/331 (308→+21). Conformance unchanged. Gotchas: (1) sx_insert_near takes a single node — multi-define source blocks get silently truncated. Use sx_insert_child at the root per define. (2) SX (/ 1 0)inf, and 1e999 also → inf; both can be used as Infinity. (3) (define NaN ...) and (define Infinity ...) crash at load — SX tokenizer parses NaN and Infinity as the numeric literals nan / inf, so define sees (define <number> <value>) and rejects it with "Expected symbol, got number". Drop those top-level aliases; put the values in js-global dict instead where the keyword key avoids the conflict.

  • 2026-04-23 — Postfix/prefix ++ / --. Parser: postfix branch in jp-parse-postfix (matches op ++/-- after the current expression and emits (js-postfix op target)), prefix branch in jp-parse-primary before the unary--/+/!/~ path emits (js-prefix op target). Transpile: js-transpile-prefix emits (set! sxname (+ (js-to-number sxname) ±1)) for idents, (js-set-prop obj key (+ (js-to-number (js-get-prop obj key)) ±1)) for members/indices. js-transpile-postfix uses a let binding to cache the old value via js-to-number, then updates and returns the saved value — covers ident, member, and index targets. 11 new unit tests (ident inc/dec, pre vs post return value, obj.key, a[i], in for(;; i++), accumulator loop), 340/342 (329→+11). Conformance unchanged.

  • 2026-04-23 — String.prototype extensions + Object/Array builtins. Strings: added includes, startsWith, endsWith, trim, trimStart, trimEnd, repeat, padStart, padEnd, toString, valueOf to js-string-method dispatch and corresponding js-get-prop string-branch keys. Helpers: js-string-repeat (tail-recursive concat), js-string-pad + js-string-pad-build. Object: Object.keys / .values / .entries / .assign / .freeze (freeze is a no-op — we don't track sealed state). Array: Array.isArray (backed by list?), Array.of (varargs → list). Wired into js-global. 17 new unit tests, 357/359 (340→+17). Conformance unchanged. Gotcha: SX's keys primitive returns most-recently-inserted-first, so Object.keys({a:1, b:2}) comes back ["b", "a"]. Test assertion has to check .length rather than the literal pair. If spec order matters for a real app, Object.keys would need its own ordered traversal.

  • 2026-04-23 — switch / case / default. Parser: new jp-parse-switch-stmt (expect switch (expr) { cases }), jp-parse-switch-cases (walks clauses: case val:, default:), jp-parse-switch-body (collects stmts until next case/default/}). AST: (js-switch discr (("case" val body-stmts) ("default" nil body-stmts) ...)). Transpile: wraps body in (call/cc (fn (__break__) (let ((__discr__ …) (__matched__ false)) …))). Each case clause becomes (when (or __matched__ (js-strict-eq __discr__ val)) (set! __matched__ true) body-stmts) — implements JS fall-through naturally (once a case matches, all following cases' when fires via __matched__). Default is a separate (when (not __matched__) default-body) appended at the end. break inside a case body already transpiles to (__break__ nil) and jumps out via the call/cc. 6 new unit tests (match, no-match default, fall-through stops on break, string discriminant, empty-body fall-through chain), 363/365 (357→+6). Conformance unchanged.

  • 2026-04-23 — More Array.prototype + Object fallbacks (hasOwnProperty etc). Array: includes, find, findIndex, some, every, reverse (in js-array-method dispatch + js-get-prop list-branch keys). Helpers: js-list-find-loop / -find-index-loop / -some-loop / -every-loop / -reverse-loop all tail-recursive, no while because SX doesn't have one. Object fallbacks: js-invoke-method now falls back to js-invoke-object-method for dicts when js-get-prop returns undefined AND the method name is in the builtin set (hasOwnProperty, isPrototypeOf, propertyIsEnumerable, toString, valueOf, toLocaleString). hasOwnProperty checks (contains? (keys recv) (js-to-string k)). This lets o.hasOwnProperty('x') work on plain dicts without having to install an Object.prototype. 13 new tests, 376/378 (363→+13). Conformance unchanged.

  • 2026-04-23 — String.fromCharCode, parseInt, parseFloat. String global with fromCharCode (variadic, loops through args and concatenates via js-code-to-char). parseInt truncates toward zero via js-math-trunc; parseFloat delegates to js-to-number. Wired into js-global. 5 new tests, 381/383 (376→+5). Conformance unchanged.

  • 2026-04-23 — JSON.stringify + JSON.parse. Shipped a recursive-descent parser and serializer in SX. js-json-stringify dispatches on type-of for primitives, lists, dicts. js-json-parse uses a state dict {:s src :i idx} mutated in-place by helpers (js-json-skip-ws!, js-json-parse-value, -string, -number, -array, -object). String parser handles \n \t \r \" \\ \/ escapes. Number parser collects digits/signs/e+E/. then delegates to js-to-number. Array and object loops recursively call parse-value. JSON wired into js-global. 10 new tests (stringify primitives/arrays/objects, parse primitives/string/array/object), 391/393 (381→+10). Conformance unchanged.

  • 2026-04-23 — Array.prototype flat/fill; indexOf start arg; for..of/for..in. Array: flat(depth=1) uses js-list-flat-loop (recursive flatten), fill(value, start?, end?) mutates in-place then returns self via js-list-fill-loop. Fixed indexOf to honor the fromIndex second argument. Parser: jp-parse-for-stmt now does a 2-token lookahead — if it sees (var? ident (of|in) expr) it emits (js-for-of-in kind ident iter body), else falls back to classic for(;;). Transpile: js-transpile-for-of-in wraps body in (call/cc (fn (__break__) (let ((__js_items__ <normalized-source>)) (for-each (fn (ident) (call/cc (fn (__continue__) body))) items)))). For of it normalizes via js-iterable-to-list (list → self, string → char list, dict → values). For in it iterates over js-object-keys. break / continue already route to the call/cc bindings. 8 new tests (flat, fill variants, indexOf with start, for-of array/string, for-in dict), 399/401 (391→+8). Conformance unchanged. Gotcha: SX cond clauses evaluate only the last expr of a body. (cond ((test) (set! a 1) (set! b 2)) …) silently drops the first set!. Must wrap multi-stmt clause bodies in (begin …). First pass on the for-stmt rewrite had multi-expr cond clauses that silently did nothing — broke all existing for-loop tests, not just the new ones.

  • 2026-04-23 — String.replace/search/match + Array.from; js-num-to-int coerces strings; spread operator .... Strings: replace(regex|str, repl), search(regex|str), match(regex|str) all handle both regex and plain-string args. Regex path walks via js-string-index-of with case-adjusted hay/needle. Array.from(iterable, mapFn?) via js-iterable-to-list. js-num-to-int now routes through js-to-number so 'abcd'.charAt('2') and .slice('1','3') coerce properly. Spread ... in array literals and call args. Parser: jp-array-loop and jp-call-args-loop detect punct "..." and emit (js-spread inner) entries. Transpile: if any element has a spread, the array/args list is built via (js-array-spread-build (list "js-value" v) (list "js-spread" xs) ...). Runtime js-array-spread-build walks items, appending values directly and splicing spread via js-iterable-to-list. Works in call args (including variadic Math.max(...arr)) and in array literals (prefix, middle, and string-spread [...'abc']). Gotcha: early pass used (js-sym ":js-spread") thinking it'd make a keyword — js-sym makes a SYMBOL which becomes an env lookup (and fails as undefined). Use plain STRING "js-spread" as the discriminator. 10 new tests (replace/search/match both arg types, Array.from, coercion, 5 spread variants), 414/416 (399→+15). Conformance unchanged.

  • 2026-04-23 — Object + array destructuring in var/let/const decls. Parser: jp-parse-vardecl now handles three shapes — plain ident, {a, b, c} object pattern, [a, , c] array pattern with hole support. jp-parse-obj-pattern / jp-parse-arr-pattern / their loops collect the names. AST: (js-vardecl-obj (names...) rhs) and (js-vardecl-arr (names-with-holes-as-nil...) rhs). Transpile: js-vardecl-forms dispatches on the three tags. Destructures emit (define __destruct__ rhs) then (define name (js-get-prop __destruct__ key-or-index)) for each pattern element (skips nil holes in array patterns). 4 new unit tests. 418/420 (414→+4). Conformance unchanged. Gotcha: running destructuring tests sequentially — if epoch N defines a, b globally and epoch N+1 uses the same names as different types, "Not callable: N" results. Top-level var transpiles to (define name value); re-defining a name that's held a value in a prior call to eval carries over. The proper fix would be to use let block-scoping; workaround for tests is unique names.

  • 2026-04-23 — Optional chaining ?. + logical assignment &&= / ||= / ??=. Parser: jp-parse-postfix handles op "?." followed by ident / [ / ( emitting (js-optchain-member obj name) / (js-optchain-index obj k) / (js-optchain-call callee args). Transpile: all emit (js-optchain-get obj key) or (js-optchain-call fn args) — runtime short-circuits to undefined when the receiver is null/undefined. Logical assignment: js-compound-update gains &&= / ||= / ??= cases that emit (if (js-to-boolean lhs) rhs lhs) / (if (js-to-boolean lhs) lhs rhs) / (if nullish? rhs lhs). 9 new tests. 427/429 (418→+9). Conformance unchanged.

  • 2026-04-23 — Callable Number() / String() / Boolean() / Array() + Array.prototype.sort. Made the builtin constructor dicts callable by adding a :__callable__ slot that points to a conversion function; js-call-plain and js-function? now detect dicts with __callable__ and dispatch through it. Number("42") === 42, String(true) === "true", Boolean(0) === false, Array(3) returns length-3 list, Array(1,2,3) returns [1,2,3]. Array.prototype.sort(comparator?) added via bubble-sort O(n²) js-list-sort-outer! / -inner!. Default comparator is lexicographic (JS-spec toString() then compare). Custom comparators get (cmp a b) → number and swap when positive. 11 new tests. 438/440 (427→+11). Conformance unchanged. Wide scoreboard (Math+Number+String+Array+{addition,equals,if,for,while}): baseline 259/5354 (4.8%) before these improvements; top failures were ReferenceError (1056×), TypeError not-a-function (514×), array-like {length:3, 0:41, 1:42, 2:43} receivers (455×), SyntaxError (454×). Need to re-run scoreboard to see the delta from all the April 23rd work.

  • 2026-04-23 — Rest + rename in destructure, delete operator, Array.prototype / String.prototype / Number.prototype stubs, lastIndexOf, typeof fix, js-apply-fn handles callable dicts. Array destructure [a, ...rest] transpiles rest-tail to (js-list-slice tmp i (len tmp)). Obj pattern {key: local} renames to local. delete obj.kjs-delete-prop which sets value to undefined. Array.prototype.push etc. are accessible as proto-functions that route through js-this + js-invoke-method. Number.prototype stub with toString/valueOf/toFixed. Nested destructuring patterns tolerated via jp-skip-balanced (treated as holes). Destructuring params in fn decls accepted (holes). Final Math scoreboard: 66/288 (22.9%) vs 56/288 (19.4%) baseline, +10 passes (+3.5 percentage points). 446/448 unit (438→+8). 148/148 slice unchanged. Top remaining Math failures: 94× ReferenceError (Math.log/Math.sin/Math.cos etc. not shimmed — no SX sin/cos/log primitives), 79× assertion-fail (numerical precision on Math.floor / ceil / trunc edge cases), 31× TypeError, 16× Timeout.

  • 2026-04-23 (session 4) — P0 harness cache + P1 Blockers + scoreboard-driven feature work. 114/300 → 147/300 (+33 tests, 38.0% → 49.0%). Wide wall time 593s → 277s (2.14× via cache). Commits 4a277941..00edae49:

    1. harness cache (4a277941): transpile HARNESS_STUB once per Python process, write SX to .harness-cache/stub.<fp>.sx, every worker (load …)s it instead of js-eval'ing the raw JS. Added $_js_dollar_ name-mangler in js-sym so SX symbols round-trip through inspect.
    2. Blockers entries (dc97c173): Math trig/transcendental primitives (22 missing), evaluator CPU bound (lexical addresses, inline caches, JIT-force, OCaml 5 domains).
    3. exponent notation (7cffae21): js-num-from-string now splits on e/E, parses mantissa and exponent separately, combines via new js-pow-int. .12345e-3 was 0.12345, now 0.00012345. Also fixed the js-string-trim typo. +3 Number.
    4. callable-dict hasOwnProperty (05aef11b): Number/String/Array etc. carry __callable__ so js-function? returns true and dispatch landed in the function-proto branch (which only knows name/length/prototype). Added (not dict) guard so dicts fall through to the keys-based path. +6 Number.
    5. .length / .name on constructor dicts (f63934b1): six dict-set! lines adding the spec'd length=1 and name="<Ctor>" to Number/String/Array/Object/Boolean. +1 Number.
    6. ctor.prototype used by new (bf09055c): js-get-ctor-proto now returns ctor.prototype directly for dict ctors before falling through to the id-keyed table. Was synthesising an empty proto, so every (new Number()).toLocaleString was undefined. +5 Number, +2 String.
    7. hex-literal string→number (00edae49): ToNumber("0x0") was NaN, should be 0 per spec. Added js-hex-prefix? / -is-hex-body? / -parse-hex / -hex-digit-value and dispatch from both js-is-numeric-string? and js-num-from-string. +15 Number (S9.3.1_A16..A32 family).
    8. Unit: 520/522 → 521/522 (test 903 was expecting wrong part count, fixed). Slice: 148/148 unchanged. Math scoreboard unchanged (still blocked on trig). String 30% → 33% (+3 via proto fix). Number 43% → 73% (+30). Wide 38% → 49%. Also added --dump-failures <path> to the runner for tight debug loops.
  • 2026-04-23 (session 3, continued) — Scoreboard scoped 100/cat: Math 40% / Number 43% / String 31% = 114/300 overall. Additional commits (85a329e8..c3b0aef1):

    • fn.length reflects arity via lambda-params + js-count-real-params. Math.abs.length = 1 etc.
    • Object global now callable (new Object(), Object(5)). Object.prototype has hasOwnProperty/isPrototypeOf/propertyIsEnumerable/toString/valueOf.
    • Function receivers inherit Object.prototype builtins via js-invoke-function-objproto. String.prototype.toUpperCase.hasOwnProperty('length') works.
    • globalThisjs-global; evaljs-global-eval (no-op stub); Function global stub (constructor throws TypeError, prototype populated).
    • URIError and EvalError constructors.
  • 2026-04-23 (session 3) — Parallel runner, 60 new features, Math 39.6%, wide 36.4%. Commits 65d4c706..edfbb754:

    1. test262-runner.py rewritten with multiprocessing.Pool for --workers N shards; raw-fd line buffer replaces thread-per-line. Auto-defaults 1 worker on <=2-core machines (parallel slower on this box — OCaml CPU-bound, 2 processes starve). Classifier maps parser errors to SyntaxError so negative-SyntaxError tests pass instead of failing.
    2. Function.prototype.call/apply/bind via js-invoke-function-method, dispatched in js-invoke-method when recv is a function. fn.name/.length also exposed.
    3. Numeric keys in object literals stringify on parse ({0: 41, 1: 42} no longer crashes dict-set!). Parser: number token value str-coerced.
    4. Array-like receivers for Array.prototype.X.call(dict) via js-arraylike-to-list — reads length + indexed keys in order. js-iterable-to-list also respects length on dicts.
    5. Number methods on primitives: (5).toString(), (16).toString(16), .toFixed(n), .valueOf(). js-invoke-method branches on (number? recv). Radix 2-36 supported via js-num-to-str-radix.
    6. Boolean methods: true.toString(), .valueOf().
    7. NaN / Infinity resolve: transpile-time rewrite NaN → (js-nan-value) and Infinity → (js-infinity-value), because SX's tokenizer parses NaN as numeric literal and forbids (define NaN ...). js-number-is-nan uses string-inspect (SX (= nan nan) returns true). js-strict-eq returns false for NaN pairs per spec.
    8. 15 new Array.prototype methods: at, flatMap, findLast, findLastIndex, reduceRight, toString, toLocaleString, keys, values, entries, copyWithin, toReversed, toSorted. Mutating unshift/splice are stubs (pop-last!/pop-first! primitives are no-ops — runtime limitation).
    9. 10 new String.prototype methods: at, codePointAt, lastIndexOf, localeCompare, replaceAll, normalize, toLocaleLowerCase/UpperCase, isWellFormed, toWellFormed.
    10. 10 new Object.* globals: getPrototypeOf, setPrototypeOf, create, defineProperty(ies), getOwnPropertyNames/Descriptor(s), isExtensible/Frozen/Sealed, seal, preventExtensions, is, fromEntries, hasOwn.
    11. Array.prototype / String.prototype dicts updated to include all new methods (so .call-ing them works).
    12. js-to-number(undefined) → NaN (was 0); js-string-to-number("abc") → NaN via new js-is-numeric-string?. parseInt('123abc',10) → 123 (new digit-walker), supports radix 2-36. parseFloat('3.14xyz') → 3.14 (new float-prefix matcher). Added encodeURIComponent, decodeURIComponent, encodeURI, decodeURI.
    13. Harness stub: assert itself callable via assert.__callable__ = __assert_call__ (many tests do assert(cond, msg)). verifyNotWritable etc. widened to 5-arg signature.
    14. Number global rebuilt: correct MAX_VALUE (computed at load by doubling until == Infinity, yields ~1e308), POSITIVE_INFINITY/NEGATIVE_INFINITY/NaN via function-form values (SX literals overflow/mangle), toFixed handles NaN/Infinity/negative, prototype.toString accepts radix.
    15. Scoreboard deltas: Math 66/288 (22.9%) → 114/288 (39.6%) +48 passes. Wide (Math+Number+String at 150 each): 164/450 (36.4%). Number category isolated: 44.7%. Unit 514/516 (506→+8 from NaN/coerce/Object tests).

    Top remaining failure modes (wide scoreboard, 450 runnable):

    • 172× Test262Error (assertion failed) — real semantic bugs, numeric precision on Math/Number/String.prototype
    • 54× TypeError: not a function
    • 29× Timeout (slow string/regex loops)
    • 16× ReferenceError — still some missing globals

Phase 3-5 gotchas

Worth remembering for later phases:

  • Varargs in SX use &rest args, not bare args. (fn args …) errors with "Expected list, got symbol" — use (fn (&rest args) …).
  • make-symbol is the way to build an SX identifier-symbol at runtime for later eval-expr. Use it to turn JS idents into SX variable references.
  • eval-expr is the primitive you want to evaluate an SX list you've just constructed. It's already in the kernel primitives.
  • Keywords print as quoted strings via the epoch eval command — :foo comes back as "foo". Affected the js-undefined test expectation.
  • char-code (not code-char) for char→codepoint. No bit-not primitive — implement ~x as -(int(x)+1).
  • Epoch eval string must double-escape to survive two nested SX string literals ((eval "(js-eval \"…\")")). The conformance harness uses a small Python helper to do that reliably.
  • Shell heredocs and || — writing fixtures with || in a here-doc fed to a while read loop gets interpreted as a pipe mid-parse. Use case|a\|\|b|… style or hand-write the || cases.

Blockers / shared-file issues

Anything that would require a change outside lib/js/ goes here with a minimal repro. Don't fix from the loop.

  • Pending-Promise await — our js-await-value drains microtasks and unwraps settled Promises; it cannot truly suspend a JS fiber and resume later. Every Promise that settles eventually through the synchronous resolve/reject + microtask path works. A Promise that never settles without external input (e.g. a real setTimeout waiting on the event loop) would hit the "await on pending Promise (no scheduler)" error. Proper async suspension would need the JS eval path to run under cek-step-loop (not eval-exprcek-run) and treat await pending-Promise as a perform that registers a resume thunk on the Promise's callback list. Non-trivial plumbing; out of scope for this phase. Consider it a Phase 9.5 item.

  • Regex platform primitives — runtime ships a substring-based stub (js-regex-stub-test / -exec). Overridable via js-regex-platform-override! so a real engine can be dropped in. Required platform-primitive surface:

    • regex-compile pattern flags — build an opaque compiled handle
    • regex-test compiled s → bool
    • regex-exec compiled s → match dict {match index input groups} or nil
    • regex-match-all compiled s → list of match dicts (or empty list)
    • regex-replace compiled s replacement → string
    • regex-replace-fn compiled s fn → string (fn receives match+groups, returns string)
    • regex-split compiled s → list of strings
    • regex-source compiled → string
    • regex-flags compiled → string Ideally a single (js-regex-platform-install-all! platform) entry point the host calls once at boot. OCaml would wrap Str / Re or a dedicated regex lib; JS host can just delegate to the native RegExp.
  • Math trig + transcendental primitives missing. The scoreboard shows 34× "TypeError: not a function" across the Math category — every one a test calling Math.sin/cos/tan/log/… on our runtime. We shim Math via js-global; the SX runtime supplies sqrt, pow, abs, floor, ceil, round and a hand-rolled trunc/sign/cbrt/hypot. Nothing else. Missing platform primitives (each is a one-line OCaml/JS binding, but a primitive all the same — we can't land approximation polynomials from inside the JS shim, they'd blow Math.sin(1e308) precision):

    • Trig: sin, cos, tan, asin, acos, atan, atan2
    • Hyperbolic: sinh, cosh, tanh, asinh, acosh, atanh
    • Log/exp: log (natural), log2, log10, log1p, expm1
    • Bit-width: clz32, imul, fround
    • Variadic lifts: hypot(…) (n-args), max(…)/min(…) (we have 2-arg; JS allows 0 through N) Minimal repro: Math.sin(0) currently raises TypeError: Math.sin is not a function because js-global.Math has no sin key. Once the primitives exist in the runtime, js-global.Math can be extended in one drop — all 34 Math not a function failures flip together.
  • SX number promotion loses floats on exact-int results. Minimal repro: (type-of (* 1.5 2)) is "number" (fine) but the value is 3 — an int. In OCaml terms, multiplying a float by something that produces an integral float representable in a Pervasives.int triggers a narrowing. Consequence: any iterative float routine like (let loop ((x 1.5) (n 100)) (if (<= n 0) x (loop (* x 2.0) (- n 1)))) overflows to 0 by n=60 because it's walking ints at that point. In JS terms this blocks:

    • Number.MAX_VALUE — our js-max-value-approx loops 1.0 × 2 and overflows to 0; we can't compute a correct 1.7976931348623157e308 from inside the runtime
    • Number.MIN_VALUE — same shape (loop 1.0 / 2 → 0 before reaching denormal 5e-324)
    • Any literal 1e308 — the SX tokenizer parses e308 but clips too
    • Math.pow(2, 100) — same loop Proper fix is spec-level: keep Sx_types.Number boxed as OCaml float until an explicit int cast happens, or introduce a separate Sx_types.Int path and a promotion rule. For js-on-sx, Number.MAX_VALUE tests are blocked until then. Works around ~6 Number failures and nontrivial surface in Math.
  • Evaluator CPU bound at ~1 test/s on a 2-core box. Runner auto-defaults to 1 worker on <=2-cores because two CEK workers starve each other — both are pure-Python/OCaml heavy loops with no IO concurrency to exploit. Each test that parses JS, transpiles to SX, then evaluates the SX under CEK runs in the 0.33s range; the long tail is mostly per-test js-parse scaling superlinearly with test length plus CEK env lookup walking a list of frames per free variable. Optimization surface someone else could pick up (all shared-file):

    • Lexical addresses in the evaluator: swap env-walk-by-name for (depth, slot) tuples resolved at transpile time. Would slash the per-CEK-step cost by an order of magnitude on tight loops.
    • Inline caches on js-get-prop. Every a.b walks the __proto__ chain; 99% of call sites hit the same shape. A monomorphic cache keyed on the receiver's structural shape would collapse that to one lookup.
    • Force JIT on transpiled JS bodies. Currently JS → SX → tree-walk CEK. The VM JIT exists but lambda compilation triggers on lazy call — and many test bodies are top-level straight-lines that never hit the JIT threshold. An explicit (jit-compile!) after transpile, or an AOT pass when loading the harness cache, would be a one-shot win.
    • OCaml 5 domains. The runner has --workers N but the box is 2-core. OCaml 5 multicore would let one process host N CEK domains with work-stealing instead of N Python subprocesses each booting their own kernel. Minimal repro: time python3 lib/js/test262-runner.py --filter built-ins/Number --max-per-category 50 --workers 1 — currently ~50s wall, ~1 test/s after the ~4s warm-start. Target for any of the above fixes: ≥3× uplift. built-ins/String timeouts will thin out naturally at that rate because many of them are pure 2000-iter for loops hitting the per-test 5s cap.

First-iteration checklist (scaffolding) — DONE

  • lib/js/lexer.sx — stub js-tokenize
  • lib/js/parser.sx — stub js-parse
  • lib/js/transpile.sx — stub js-transpile
  • lib/js/runtime.sx — stub js-global, js-to-boolean
  • lib/js/test.sh — epoch-protocol runner mirroring lib/hyperscript/test.sh
  • Smoke suite green (7/7)

Next iteration: Phase 1 — lexer. Start with numeric literals + identifiers + whitespace skipping; extend test.sh with tokenization assertions.