Skip to content

JavaScript AOT compiler on 2core (arc parser → 2core IR → native BEAM) - #10

Closed
hiett wants to merge 257 commits into
mainfrom
experiment/js-frontend
Closed

JavaScript AOT compiler on 2core (arc parser → 2core IR → native BEAM)#10
hiett wants to merge 257 commits into
mainfrom
experiment/js-frontend

Conversation

@hiett

@hiett hiett commented Jul 12, 2026

Copy link
Copy Markdown
Owner

JavaScript AOT compiler on 2core

A JavaScript ahead-of-time compiler: arc's JS parser → twocore/frontend/js/lower (AST → 2core IR) → emit_core → Core Erlang → native .beam. JS function declarations become BEAM exports callable on native terms.

Speed model: every polymorphic operator lowers to a guarded dispatch — if IsNumber(a) && IsNumber(b) then native erlang:'+' else rt_js(op) — so hot numeric code is native BEAM arithmetic that the JIT specialises. Benchmarks vs an interpreter: ~48× recursion (fib), ~22× a numeric loop, ~5× string-building.

Supported surface (broad)

All operators (+ - * / % **, comparisons, && || ! ??, bitwise/shift, ?., unary, ?:), let/const/var + all compound assignments incl. logical &&= ||= ??=, destructuring (incl. nested), default + rest params, call spread f(...a); full control flow (if/while/do-while/for/for-of/for-in/switch/try/catch/throw, labeled break/continue); objects (spread, methods), arrays (spread, ~35 methods), strings (~20 methods + regex), Map/Set, closures/arrows/IIFEs/currying; classes (ctor, fields, methods, static methods, getters/setters, extends/super, instanceof); tagged templates + String.raw; Math/JSON/Object/Array/Number statics + constants; the globals NaN/Infinity/undefined.

This session

  • Adversarial correctness pass — two review agents over the runtime + lowering; every finding fixed with a spec-driven test. Notably a critical silent-wrong-result bug where compiler SSA temps (v0, L0) collided with user identifiers, plus ~12 rt_js spec-conformance bugs (Math.round, JSON parse/stringify, parseFloat, Array.includes(NaN), …).
  • New features: nested destructuring, NaN/Infinity/undefined globals, tagged templates + String.raw, reduceRight/codePointAt/fromCodePoint, logical assignment operators, Number constants, and class getters/setters.

Testing

187 spec-driven JS-compiler tests; the full gleam test suite is green (2403 passing). Tests assert against the ECMAScript spec, not the implementation. Known v1 deviations are documented in the lower.gleam module header.

hiett added 30 commits July 12, 2026 00:32
Extend the operator set toward completeness:

  * `++` / `--` (prefix and postfix) on local variables — the idiomatic
    `for (…; …; i++)` update now compiles. A postfix update used inside a
    larger expression yields the new value (documented deviation); as a
    statement or for-update, where the value is discarded, it is exact.
  * unary `void` (evaluate for effect, yield undefined).
  * bitwise/shift `& | ^ ~ << >> >>>` and their compound-assign forms
    (`&= |= ^= <<= >>= >>>=`), with real JS int32/uint32 semantics.

The bitwise ops are new `rt_js` primitives (`bit_and/or/xor/not`, `shl`,
`shr`, `ushr`) that coerce each operand with ToInt32 (ToUint32 for the left
of `>>>` and the shift counts) via the existing `coerce_num`, operate on
32-bit two's-complement, and return a signed int32 — except `>>>`, which
yields an unsigned uint32 (so `-1 >>> 0` is 4294967295).
Add JS `**` and `**=` via a new `rt_js:pow` op implementing
Number::exponentiate: `x ** ±0` is 1 (even for a NaN base), NaN/±Infinity
operands follow the spec table, a negative base with a non-integer exponent
is NaN, and the finite integer/integer case stays exact (native BEAM integer
power by squaring). `**` is right-associative per the parser.
Add JS arrays, represented as a cell holding `{js_array, Length, Map}` (so
`typeof` stays "object" and mutation is by reference, like objects):

  * array literals `[a, b, c]` (holes become `undefined`);
  * indexed get/set `arr[i]` — assigning past the end grows `.length`;
  * `.length` (read and truncating write);
  * `arr.push(...vals)` (returns the new length) and `arr.pop()`;
  * `Array.isArray`-style tagging, and a `to_string` that comma-joins the
    elements (`String([1,2,3])` is `"1,2,3"`).

`get_prop`/`set_prop`/`has_prop` now dispatch on the cell content (array vs
plain object). Method calls `recv.m(args)` gained a general lowering path
(currently dispatching the array mutators). String concatenation with an
object/array operand now follows ToPrimitive→ToString (`"" + [1,2,3]` is
`"1,2,3"`, `"s" + {}` is `"s[object Object]"`) instead of type-erroring;
the rt_js unit test that pinned the old limitation is updated to the
spec-correct result.
Function expressions and arrow functions become first-class values via
lambda lifting. A pre-pass finds every function/arrow expression, computes
its free variables, and lifts it to a named top-level IR function whose
LEADING parameters are its captures (per `MakeClosure`); the expression
lowers to `MakeClosure(name, capture_values, arity)`.

This enables closures, higher-order functions (`twice(n => n + 3, 10)`),
returned closures / currying (`f(1)(2)(3)`), and IIFEs (`(x => x + 1)(41)`);
a call whose callee is any expression now applies it with `CallClosure`.

Captures are BY VALUE (a snapshot at creation). Capturing a mutable
object/array is fine — the shared reference is captured — but capturing a
variable that is REASSIGNED in its scope (or by the closure) would diverge
from JS's capture-by-reference, so it is rejected with a typed error rather
than compiled to wrong code. Free-variable analysis, lambda collection, and
the value-vs-reference reasoning are documented in `js/lower`.
Lower `for (let x of iterable) body` by desugaring to an index loop —
`{ let $arr = iterable; for (let $i = 0; $i < $arr.length; $i = $i + 1)
{ let x = $arr[$i]; body } }` — so `break`/`continue` target the loop as
usual and nested for-of works. The lambda-collection and assigned-variable
analyses now descend into for-of/for-in bodies. v1 iterates array-likes
(anything with `.length` + integer indexing); other iterables fail at
runtime.
Lower `switch` to a run-once break-block whose body sets a `$fell` flag on
the first matching case (or the default, in whatever position, when no case
matches — precomputed via `$any`) and then runs each following consequent
while `$fell` holds, giving correct fall-through. A `break` targets the
switch (a new `Ctx.brk` break target, distinct from the continue target, so
`continue` inside a switch still targets the enclosing loop); mutations
escape via the block's carried variables.

Covered: single-match + break, fall-through, default in any position,
string discriminants, and a switch nested in a loop where `break` exits the
switch (not the loop).
Add the `Math` namespace: unary functions (floor, ceil, round, trunc, abs,
sign, sqrt, cbrt, exp, log/log2/log10, sin/cos/tan/asin/acos/atan), binary
(pow, atan2), variadic (min, max, hypot), `random()`, and the numeric
constants (PI, E, LN2, LN10, LOG2E, LOG10E, SQRT2, SQRT1_2, inlined at
compile time). Each function coerces with ToNumber and returns a JS number
over the sentinel-aware numeric domain. Note `Math.round` is
round-half-toward-+Infinity (`Math.round(-2.5)` is `-2`), which differs from
Erlang's `round/1` — implemented as `floor(x + 0.5)`.
Add the workhorse Array methods: map, filter, forEach, reduce (with and
without an initial value), some, every, find, findIndex, indexOf, includes,
join, slice (negative indices), concat (spreads array items), reverse,
shift, unshift, and sort (default ToString order or a comparator).

Callback-taking methods apply the JS callback with EXACTLY its own arity
(via fun_info, padding missing args with undefined) so `x => …`,
`(x, i) => …`, and `(x, i, arr) => …` — and `(acc, x) => …` for reduce —
all work despite BEAM funs being arity-strict. Accumulating into a shared
array/object through a callback is fine; accumulating into a captured
variable hits the existing value-capture rejection (use reduce instead).
Strings (UTF-8 binaries) gain `.length`, indexing `str[i]`, and methods:
charAt, charCodeAt, toUpperCase/toLowerCase, indexOf, includes, slice,
substring, split, trim/trimStart/trimEnd, repeat, startsWith/endsWith,
replace/replaceAll. `indexOf`/`includes`/`slice`/`concat` dispatch
polymorphically over arrays and strings in the runtime. `.length`, indexing,
charAt, slice, and substring use Unicode CODE POINTS (BMP-exact; an astral
char counts as 1 rather than JS's 2 UTF-16 units — a documented v1
deviation); substring search is byte-exact. for-of now iterates a string's
characters too (it already desugars to `.length` + indexing).
Add the global builtin functions parseInt (radix + 0x auto-detect),
parseFloat, isNaN/isFinite (coercing global forms), and the String/Number/
Boolean coercions; plus the statics Array.isArray, Array.of,
Object.keys/values/entries, and Number.isInteger/isNaN/isFinite (non-
coercing). A user-defined top-level function of the same name still shadows
the global (function/env lookup precedes the builtin). Object.keys/values/
entries return keys in the backing map's iteration order (a documented v1
deviation from JS insertion order).
Add `throw e` and `try { … } catch (e) { … }`, lowered to the IR's native
exception support: the module declares a single `js_exn` tag carrying one
term, `throw` raises it with the thrown value, and the try body + handler are
a `Try` whose arms each yield the block's mutated (carried) variables. A
`throw` inside the body — including across a call — transfers to the handler
with the value bound to `e`; `return`/`break`/`continue` inside the try body
transfer out of it correctly (the emit model disposes them through the
enclosing continuation).

v1 limits (documented): `finally` and a destructuring catch binding are
Unsupported; only an explicit `throw` is caught (a runtime type error
propagates); and a variable mutated in the try body before a throw keeps its
pre-try value in the handler.
  * `a ?? b` — yields `b` only when `a` is null/undefined (0/""/false are not
    nullish, unlike `||`), short-circuiting the rhs. Shares the guarded-`If`
    shape with `&&`/`||`, keyed on a new `is_nullish` op.
  * `a?.b` / `a?.[k]` / `f?.(args)` — optional chaining: a null/undefined base
    yields `undefined` without evaluating the key/args; otherwise a normal
    read/call.
  * `for (k in obj)` — iterate an object's own keys, desugared to for-of over
    `Object.keys(obj)`.
Support `function f(x, y = 5)` default parameters, lowered as a prologue
`if (y === undefined) { y = <default>; }` per defaulted param. A direct call
now fits its argument list to the callee's declared arity — missing args are
padded with `undefined` (so defaults apply and under-application works) and
extra args are dropped — since BEAM funs are arity-strict while JS is not. A
default may reference an earlier parameter. Arrow/function-expression
defaults apply when called with the full arity or via an array method (a
closure call site can't pad); top-level function defaults always apply.
  * `let [a, b] = e` / `let {x, y} = e` (and `{x: a}` renaming) — flat
    array/object destructuring, desugared to a temp plus one simple binding
    per name (holes skipped). Nested/defaulted/rest patterns stay Unsupported.
  * `[...a, x, ...b]` — array-literal spread: an array element contributes its
    elements and a string its characters, built incrementally via a new
    `array_spread_into` runtime op.

The declared-name analysis now walks binding patterns so a destructured local
is correctly a local (not a spurious closure capture).
Support class declarations without inheritance. Each class lifts to top-level
IR functions whose first parameter is `this`: `C$constructor(this, …)` (which
runs the instance-field initializers then the constructor body) and
`C$method(this, …)` per instance method. `new C(args)` builds a fresh object,
installs each method as a `this`-bound closure property (via MakeClosure), and
runs the constructor; `this` is the method's first parameter; `instance.m(a)`
dispatches by looking the method property up and applying it (which also makes
function-valued object properties callable). Instance fields with initializers,
methods that call sibling methods through `this`, and a closure inside a method
capturing `this` lexically all work.

Not yet: `extends`/`super`, static/getter/setter members, computed keys. A
regular function EXPRESSION captures `this` lexically (like an arrow) rather
than binding it dynamically — a documented v1 simplification.
Add `JSON.stringify` (a hand-written serializer over the JS term
representation — numbers, strings with escaping, booleans, null, arrays, and
objects; undefined/functions are omitted in objects and become null in
arrays; NaN/Infinity serialize as null) and `JSON.parse` (a compact
recursive-descent parser producing JS terms; malformed input is a type
error). Round-trips through both. Object key order in the output follows the
backing map, not insertion order (a documented deviation).
`{...src, k: v}` copies src's own properties into the new object (via a new
`object_assign_into` op), with later keys overriding earlier ones — spread
and explicit properties interleave in source order. Object method shorthand
`{ m(x) { … } }` now stores the method as a callable function property (it
flows through the normal property handler and the property-lookup method
dispatch), so `o.m(a)` works. Getters/setters in object literals are treated
as plain function-valued properties (a documented v1 simplification).
Support `class C extends P`: `new C(…)` installs the whole method chain
ancestors-first (so an overriding child method wins), each as a `this`-bound
closure resolving to its DEFINING class's function. `super(args)` in a
constructor and `super.method(args)` in a method call the superclass's
`P$constructor` / `P$method` on the current `this` (the superclass name is
threaded via `Ctx.current_super`). Method override, inherited methods/fields,
`super()` initialization, and `super.method()` chaining all work.

v1 ordering simplifications (documented): a derived class needs an explicit
constructor that calls `super(…)`, and field initializers run before `super()`
rather than after.
…e, Date.now)

Round out the standard library: `Array.from` (array/string/array-like) and
`arr.flat`/`fill`/`at` (negative indices); `str.padStart`/`padEnd`/`at`;
`String.fromCharCode(...codes)`; and `Date.now()`. `String` and `Date` join
the static-namespace dispatch (the `String(x)` global coercion is unaffected).
Add `arr.flatMap`, `arr.findLast`/`findLastIndex`, `arr.lastIndexOf`, and
`num.toFixed(d)` (fixed-point string). Rounds out the common array/number
method surface.
Add `Object.assign(target, ...sources)` (copy each source's own properties
into target, later sources winning; returns target) and
`Object.fromEntries(pairs)` (build an object from an array of [key, value]
arrays — the inverse of Object.entries).
Add end-to-end tests that combine many features on non-trivial programs: a
class with method chaining + reduce/filter/map closures capturing a param; a
for-loop-built array through a filter/map/reduce pipeline; a factory of arrow
functions sharing a captured mutable object; and a JSON stringify→parse
roundtrip through nested data. All pass, and the benchmark still shows ~48×
(recursion) / ~22× (loop) over arc — the feature build-out didn't touch the
guarded native-arithmetic fast path.
Support `static method() {}` — lifted to a plain `C$static$name` function (no
`this`) and called as `C.method(args)` (dispatched in the method path when the
receiver identifier is a known class with that static). Static methods can
call sibling statics through the class name.
  * `(a, b, c)` — the comma/sequence operator (evaluate all, yield the last).
  * `key in obj` — own-property test as a boolean.
  * `delete obj.p` / `obj[k]` — remove a property (new `delete_prop` op),
    yielding true.
  * `x instanceof C` — true when x carries the `@@is_<Class>` tag that `new`
    now stamps for every class in the instance's chain (so subclasses match).
`outer: for (…) { for (…) { break outer; continue outer; } }` — a labeled
loop registers its (break target, loop) under the JS label (via a pending
label consumed by lower_loop), so `break outer` exits it and `continue outer`
re-enters it (running its update). Threads carried variables correctly across
the nested loops.
`recv.toString()` dispatches to a user-defined `toString` method (a function
property, e.g. a class method) if present, else the default ToString; and
`num.toString(radix)` gives a base-2..36 integer string (`(255).toString(16)`
is `"ff"`).
Add regex literals `/pattern/flags` (compiled via Erlang's `re`/PCRE, with
flags i/m/s → caseless/multiline/dotall), stored as a `{js_regex, …}` cell
whose `typeof` is "object" and whose `.source`/`.flags`/`.global` read back.
Methods: `re.test(str)`; `str.match(re)` (all matches when global, else
`[full, groups…]`, or null); and `str.replace`/`str.split` now dispatch to
the regex path when the pattern argument is a regex — including `$1`/`$&`
backreferences in the replacement (translated to PCRE's `\1`/`\0`).
Add `new Map()`/`new Set()` (optionally seeded from an array), backed by a
`{js_map, Data}` / `{js_set, Data}` cell (keys use Erlang term equality — so
object identity for refs and value for primitives, and NaN keys work). The
methods `set`/`get`/`add`/`has`/`delete`/`clear`/`forEach` and the `.size`
property dispatch on the receiver type in the runtime; each op receives the
FULL argument list, so when the receiver is a plain object it DELEGATES to a
same-named user method with all its arguments intact (fixing the collision
where a class's own `add(name, price)` would otherwise be clobbered).
Iteration order follows the backing map, not insertion order (a v1 deviation).
  * Call spread `f(...args)` / `f(a, ...rest)` — on a spread argument it takes a
    separate path (the fixed-arity fast call is untouched): build the effective
    arguments as an array, flatten to a runtime list, then `console.log`/variadic
    `Math.*` consume it directly and functions/closures go through a new
    arity-adaptive `apply_fn` (a top-level function is `MakeClosure`'d first).
  * Rest parameters `function f(a, ...rest)` — the rest is an ordinary trailing
    array parameter; a direct call bundles the trailing arguments into that array
    (tracked via a new `Ctx.fn_rest`). The rest + spread forwarding idiom
    `function wrap(...args){ return inner(...args); }` works.

Both apply to top-level functions; a rest param on an arrow/method, or a spread
INTO a rest function, remains a documented v1 limitation.
Array/object destructuring now nests arbitrarily (`let [[a, b], c] = …`,
`let { p: { q } } = …`, `let { arr: [a, b] } = …`): a non-identifier element/
value pattern is emitted as a declarator whose id IS that pattern, which
lower_var_decls re-desugars recursively. Defaulted and rest patterns inside a
destructuring remain Unsupported.
hiett added 29 commits July 15, 2026 05:31
Add the Map.groupBy static method: iterate the items argument through the
iterator protocol (arrays, strings by code point, Sets/Maps/generators) and
collect each element into a fresh Map keyed by callbackfn(value, index). Key
equality is SameValueZero via mapkey_norm (COLLECTION coercion) so -0 folds to
+0, but no ToPropertyKey conversion is applied, keeping a number key distinct
from the equal string key. A non-callable callbackfn raises a TypeError before
any element is visited (GroupBy step 2).

Clears the six Map/groupBy test262 runtime failures (evenOdd, groupLength,
map-instance, negativeZero, string, toPropertyKey).
Implement Function.prototype.bind (ES2024 §20.2.3.2) for the reachable
subset: return a new function that prepends the saved bound arguments to
its own call arguments and applies the target. As with call/apply, the
thisArg is dropped (a plain function carries no receiver in this model)
and the bound function does not carry the target's own length/name/
prototype properties, which would need a full function-object model. The
bound closure's arity is the target length minus the number of bound
arguments (floored at 0, per SetFunctionLength) so remaining call
arguments forward correctly.

Raises test/built-ins/Function/prototype from 17 to 30 passing.
…ntics

The Boolean family's reachable behavior — the ToBoolean truthiness table
behind Boolean(x) (ECMA-262 §7.1.2 / §20.3.1.1), the wrapper default and
boxed [[BooleanData]] exposed through new Boolean(x).valueOf()/toString()
(§20.3.3.2/.3), and applying those prototype methods to a wrapper receiver
via .call — already computes the spec-correct result. Add spec-cited
regression tests asserting those values so future changes to the coercion
path or the wrapper dispatch cannot silently regress them.
Explicit Resource Management (ES2026) defines two additional well-known
Symbols, @@dispose and @@asyncDispose. Like the other well-known symbols
in this compiler they need to EXIST as fixed unique values: typeof each is
"symbol", each is === only to itself, and none are registered in the
global symbol registry, so Symbol.keyFor returns undefined for them.

Fixes test262 built-ins/Symbol/{dispose,asyncDispose}/no-key.js.
Reflect.set(target, key, V, receiver) now honours the receiver argument of
OrdinarySetWithOwnDescriptor (9.1.9.2). With the receiver omitted it defaults
to the target so the ordinary three-argument form still writes on the target;
a distinct receiver object diverts the data write there and leaves the target
untouched; a non-Object (primitive) receiver, a frozen receiver, or a
non-extensible receiver lacking the key reports false. Fixes the two
receiver-sensitive test262 Reflect/set assertions.
…p 3)

Map.prototype.getOrInsertComputed (and the WeakMap form) must throw a TypeError
when callbackfn is not callable, and the IsCallable check runs BEFORE the key
lookup so it fires even when the key is already present (the callback would
otherwise never be invoked). Previously the check was absent, so a present key
silently skipped the throw and an absent key crashed with badarg when trying to
call a non-function.

Fixes Map/prototype/getOrInsertComputed/not-a-function-callbackfn-throws.
A generator used in expression position — most commonly the immediately-invoked
form `(function*(){ … })()` that test262 uses to build a throwaway iterator —
was lowered as an ordinary function: its `is_generator` flag was only honoured
for function DECLARATIONS (in as_function_decl), so the expression body was never
rewritten to the resumable state machine. The call therefore returned `undefined`
(empty body) or raised "yield in an unsupported generator shape", and any later
`.next()`/iterator-helper call on the result threw a TypeError.

Rewrite a generator function expression in the lambda pre-pass exactly as a
declaration is rewritten, falling back to the untransformed body on an unsupported
shape so the surviving `yield` still reports a clean Unsupported error. This makes
`(function*(){…})()` produce a real generator, fixing the Iterator-helper
"already exhausted" cases (some/every/find/reduce/toArray) and letting the lazy
helpers compose over generator-expression sources.
Iterator-helper tests routinely close a source with `iterator.return()` before
building a helper over it; `.return` was undefined, so the call raised
{badfun,undefined}. Add gen_return across the three runtime layers (FFI impl +
grouped export, emit_core resolve_js allow-list, rt_js @external) and route
`x.return(v)` in the generator dispatch cluster.

Per §27.5.1.4 it closes the generator and returns {value: v, done: true} (v
defaults to undefined); a non-generator receiver delegates to a user `return`
method. Closing swaps the step for a permanently-done step while keeping the
{js_gen,_} tag, so the iterator helpers (map/filter/take/drop/…), which dispatch
on that tag, correctly see the closed source as an empty iterator.
# Conflicts:
#	test/js_compiler_test.gleam
# Conflicts:
#	test/js_compiler_test.gleam
# Conflicts:
#	test/js_compiler_test.gleam
# Conflicts:
#	test/js_compiler_test.gleam
# Conflicts:
#	test/js_compiler_test.gleam
A plain function's `this` was baked to globalThis at lower time, so it could
never be a call-time value. Give every top-level function whose body reads
`this` an internal `<name>%this` implementation that takes `this` as an
explicit leading parameter, and make the exported `<name>/n` a thin wrapper
that forwards the sloppy-mode default `this` (the globalThis singleton).

This is behaviour-identical on its own — an ordinary `f(...)` call still runs
with `this === globalThis` — but it is the groundwork for letting an iteration
method's thisArg or `.call`/`.apply`/`.bind` supply a real receiver: those
sites can now target the `%this` impl instead of dropping the receiver.
Now that a this-reading top-level function has a <name>%this implementation
taking this as a leading parameter, wire the sites that supply a dynamic
receiver to target it:

  - an Array iteration method (map/filter/forEach/some/every/find*) called with
    a NAMED callback + thisArg bakes the thisArg as the callback's this;
  - f.call(thisArg, ...args) becomes a direct call of f%this with the receiver;
  - f.apply(thisArg, argArray) applies a this-baked closure over f%this to the
    array's elements (via the now-exported apply_arg_list);
  - f.bind(thisArg, ...bound) returns a closure with this fixed to thisArg.

An arrow, a this-less function, or a plain-function VALUE variable is not
matched and keeps the prior behaviour (arrows correctly ignore a thisArg; a
value variable's thisArg is still dropped, a documented v1 gap).

apply_arg_list is exported from the runtime and registered in the emit js-op
allowlist so the apply path can reach it.
Update the module header and the call/apply/iteration dispatch docs to
describe the new %this-impl-plus-wrapper lowering, which sites thread a
dynamic thisArg, and what is still deferred (a thisArg for a callback held in
a variable, and sloppy-mode this->global / primitive-this boxing).
Number.prototype is itself a Number object whose [[NumberData]] is +0
(ES2023 21.1.3), so Number.prototype.toString() and .toString(radix) for
any radix 2-36 must return "0" per 21.1.3.6. Previously the receiver was
the opaque builtin_prototype("Number") marker, whose ToNumber is NaN, so
toString fell through to the generic object form and produced
"[object Object]".

Route Number.prototype.toString(...) through num_to_string_radix with a
literal 0 receiver. Clears the whole test262 Number/prototype/toString
S15.7.4.2_A1/_A2 family (37 files), which each open with a
Number.prototype.toString assertion before the boxed-wrapper cases.
Two SerializeJSONProperty conformance fixes (sec-serializejsonproperty):

- Step 4.a-4.c: a Number/String/Boolean wrapper object (new Number(n),
  new String(s), new Boolean(b)) now serializes as its wrapped primitive
  instead of an empty object, so JSON.stringify(new Boolean(true)) is
  "true", including wrappers nested in an object/array or returned from a
  toJSON/replacer. Adds json_unwrap_primitive; matches test262
  JSON/stringify/value-boolean-object.

- SerializeJSONObject/SerializeJSONArray step 1: a cyclical structure now
  throws a TypeError (catchable by JS try/catch) instead of looping
  forever. json_serialize_cell keeps an ancestor stack in the process
  dictionary, pushed on entry and popped on exit, so a value that merely
  appears twice in sibling positions (a diamond) is still serialized.
  Clears the six JSON/stringify circular-reference hangs.

Adds spec-driven tests mirroring the test262 cases.
Per ES2023 §23.1.3.18 step 3, when the separator argument is undefined the
join separator defaults to the single comma "," rather than being coerced
via ToString (which would yield "undefined"). The runtime was calling
to_string/1 unconditionally, so [1, 2].join(undefined) produced
"1undefined2" instead of "1,2". A null separator is still ToString'd
normally (to "null"), matching the spec.
Implement Object.groupBy (sec-object.groupby): group an iterable's elements
into a fresh null-prototype object keyed by ToPropertyKey(callback result),
with each key's elements collected into an Array in encounter order. A
non-callable callback throws a TypeError before any element is read (GroupBy
step 2). Route the static Object.hasOwn(obj, key) (sec-object.hasown) to the
existing own-property runtime op so it works with null-prototype receivers,
and Object.getPrototypeOf(obj) to the shared prototype op (null in this
prototype-less model).

Adds spec-driven tests covering bucketing, numeric/toString key coercion,
the null prototype, the non-callable TypeError, and Object.hasOwn.
String.prototype.startsWith/endsWith/substring dereferenced their receiver
directly via cps/1, so a null, undefined, or Symbol `this` (reachable through
.call) crashed with an Erlang badarg instead of the TypeError the spec mandates.

Per 22.1.3, every String.prototype method first performs RequireObjectCoercible
(null/undefined throw TypeError) then ToString (a Symbol throws TypeError, since
ToString of a Symbol is abrupt). Route these three methods through the existing
str_this/1 receiver coercion, and extend str_this/1 with the missing Symbol
clause so a Symbol receiver throws rather than stringifying to "[object Object]".

Fixes the startsWith/endsWith this-is-null / this-is-undefined /
return-abrupt-from-this-as-symbol and substring this-value-tostring-throws-symbol
test262 cases (String prototype pass 57 -> 65).
# Conflicts:
#	test/js_compiler_test.gleam
# Conflicts:
#	test/js_compiler_test.gleam
# Conflicts:
#	test/js_compiler_test.gleam
# Conflicts:
#	test/js_compiler_test.gleam
@hiett hiett closed this Jul 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant