SUBSET.md — Feature support matrix for Stator
Overview
This document specifies which language features Stator compiles in each mode, and how. The four verdict values are:
- static — compiled to unboxed machine values (i32, f64, struct fields, function pointers); the default fast path.
- dynamic — compiled via tagged NaN-boxed values with shape tables and inline caches; used for untyped code and type-narrowed unions in
jsmode. - error(CODE) — compile error, by design, with a permanent diagnostic code. Features marked as errors will never be supported in that mode.
- not-yet(CODE, phase) — planned feature; the diagnostic code and phase name indicate when support arrives.
Each row becomes ≥1 decision test (§4 Task 1.4 in plan.md) per mode, executable via stator explain --json.
This matrix operationalizes plan.md §1 (product spec). Rows must not contradict the starting matrix in plan.md §4 Task 1.1. Rows marked [proposed] represent language features not listed there but that implementers will encounter early (e.g., for-of, template literals, destructuring, exceptions); each is documented in the "Codes allocated by this document" section with a defensible verdict consistent with the plan's philosophy.
Test262 feature-tag coverage
| Feature | ts mode | js mode | Notes |
|---|---|---|---|
A pinned Test262 features: tag listed as generic not-yet in tests/test262/features.ts | not-yet(STA1214) | not-yet(STA1214) | Test262 tags identify broad semantic surfaces, often much wider than a landed Stator slice (for example, a tag for all typed-array or class-field semantics). The conformance runner therefore skips the whole tagged test until this matrix promises that full surface. The list is deliberately closed and regenerated only after reviewing a pinned corpus bump; an unknown tag is a runner error, never an implicit skip. |
Mode semantics
ts mode rejects untyped code entirely; js mode accepts both .ts and .js files, treating untyped code as Unknown and lowering to the dynamic path (shape tables, inline caches, tagged values). One pipeline, two policy layers: file acceptance, diagnostic table, and typing of unresolved constructs.
Syntax & control flow
| Feature | ts mode | js mode | Notes |
|---|---|---|---|
let, const bindings | static | static if typed, else dynamic | Binding semantics are static; initialization and narrowing drive the static/dynamic split. |
| Function declarations, function expressions, arrow functions | static | static if typed, else dynamic | Closure capture via environment structs; untyped parameters widen to Unknown. In js mode calls may provide too many or too few arguments: extras are ignored and missing parameters receive undefined; ts mode retains the checker arity error. Capturing a binding declared inside a loop clones the heap environment each iteration (perIterationEnv) so each closure sees that iteration's value. An initializer's closure may read its own binding (const g = (n) => … g(n - 1), const o = { f: () => o.f() }, plan.md §9 Task 6.28) when the function is only handed on, through parentheses, as, ?:, &&/||/??, an array element or an object-literal member. One the initializer may call before it finishes (passed to a call, coerced by +, spread) is not-yet (STA1214): Node throws a TDZ ReferenceError there, and the compiled program has no run-time TDZ check. Fixtures subset_self_reference_initializer*; goldens `ts |
if statements | static | static | Condition is evaluated; predicate narrowing is type-driven. |
while, do/while loops | static | static | Control flow only. |
for loop (C-style) [proposed] | static | static | Loop variable and bounds are typed; control flow is unboxed. |
for...of loop over an array, a string, a Map, a Set, a Uint8Array, or a user class with [Symbol.iterator]() | static if the yielded type is typed, else dynamic | same | Specialized loops (docs/VALUE.md §4.13), no iterator object. Array length is re-read each step so a body that shortens the array stops early; strings are immutable so the bound is jsrt_string_length. A string yields code points, not UTF-16 units ("👍" is one iteration). A Set yields the element. A Map yields a [key, value] pair, which the HIR has no tuple for, so that binding is Unknown and a file that uses it is dynamic even when K and V are typed. Labels work on it like any other loop. A user class whose [Symbol.iterator]() returns a Generator (or a boxed specialized iterator) calls that method and drives the result with the existing iterator walk. for-of over anything with no static walk -- an Unknown value, a union, or a checker-refused non-iterable js mode admitted -- lowers through a get-iterator node to the runtime GetIterator dispatch (docs/VALUE.md §4.13): collections box, generators and stored iterators drive as-is, a user-iterable method resolves and runs, and the rest throw Node's catchable TypeError (plan.md §8 step 2a(c)). The explicit c[Symbol.iterator] spelling dispatches statically on a known user-iterable class (the read is the method's value, the call its invocation); Symbol.iterator as any other stored value stays not-yet(STA1212), and the same spelling on an unknown receiver stays not-yet(STA1214, Phase 8) -- that spelling names the method without calling it, which is a value the dispatch does not build. |
for...in loop | static if the object is typed, else dynamic | same | Implemented (Phase 5 step 12 family b). Desugars to Object.keys plus a counting for; key order is the Object.keys walk. An index-signature annotation is still not-yet (family d). |
switch statement [proposed] | static | static if cases are typed, else dynamic | Control flow; case evaluation is type-driven. A clause that falls through into a later non-empty clause is a ts-mode error (STA0012, noFallthroughCasesInSwitch) and legal in js mode: it is valid JavaScript, so refusing it there would break §1.2's contract that untyped code is dynamic and never rejected (plan-notes 175). |
break and continue [proposed] | static | static | Unlabeled and labeled control-flow redirection. |
Labeled statements and labeled break/continue | static | static | Implemented (Phase 5 step 12 family b for non-loop labels). Compile to plain goto. A label on a block is breakable, not continuable. Unlabeled jumps use goto too, so a break inside a switch inside a loop cannot be captured by C's own binding rules. |
Method value (const f = o.m) | static | static if the method is typed, else dynamic | Implemented (Phase 5 step 12e, 2026-09-12). The method's own JSRTClosure with has_receiver: jsrt_call prepends undefined when the receiver is omitted so g(1, 2) keeps argument positions; virtual dispatch uses jsrt_method. Unbound this that touches a property raises Node's TypeError (jsrt_object_get_field). Not Function.prototype.bind. Evidence: plan-notes 230. super.m is the same node with the receiver parameter as target and dispatch forced direct (plan.md §8 step 42; see the overriding row). |
fn.length on a function value | static | static if the function is typed, else dynamic | Implemented (plan.md §8 step 21b). The declared arity, which never counts a method's receiver (docs/VALUE.md §4.16): a typed receiver reads the closure directly (function-length HIR node, jsrt_closure_arity), an untyped one answers through the dynamic property path (jsrt_get_prop). |
| The callee of a call is an ordinary expression | static | static if the callee is typed, else dynamic | Implemented (Phase 5 step 12 family e). (up ? inc : dec)(x), fns[0](x), pick(false)(x), an IIFE, a function read out of a shape and then called. Nothing below the gate changed: CallExpr.callee was always an Expression, the emitter always evaluated it into its own rooted slot ahead of the arguments, and the verifier already required its type to be fn or Unknown (STA4041). Evaluation order is callee-then-arguments. The argument count is deliberately unchecked — JavaScript drops extras and fills missing ones with undefined, and the calling convention does that at runtime. A call through a class field (o.run(1) where run is a field holding a function) is also this row — field-access then call. Method values (const g = o.m) are a separate row below. A callee of statically-known non-function type throws Node's catchable TypeError after evaluating the callee and the arguments left to right (js-mode suppression of TS2349, plan.md §8 step 37) — x is not a function, named by the source spelling; an Unknown callee that turns out non-function aborts STA2006 as before. Call-side spread is the rest-parameters row below. |
Assignment to a function declaration's binding (function log() {} … log = {}) | error(STA0012) | dynamic | Implemented (plan-notes 297). The binding is mutable (§10.2.11); a compiled namespace IIFE publishes its object through it (})(log = Debug.log || (Debug.log = {})), TypeScript's own _tsc.js). js mode drops TS2630 and widens the binding to Unknown, so its slot holds the function and then whatever replaces it. ts mode keeps TS2630. Tests: `subset_function_binding_assign_js |
new on an ordinary function (function P(x) { this.x = x; } … new P(1)), and x instanceof P | error(STA1003) — a .ts function has no construct signature, so new P() is an implicit any (TS7009 beside it as STA0012); classes are how ts mode constructs | dynamic | Implemented (plan.md §11c T11.4 family 4, plan-notes 310; docs/VALUE.md §4.20). jsrt_construct builds a dynamic object whose [[Prototype]] is P.prototype, runs P with it as this, and answers P's return when that is an object (§10.2.2), so a factory-style constructor returning { … } works and TS2350 is a js-mode runtime code. The callee may be any function VALUE the program wrote — new Ctor() through a variable, an allocator's getSymbolConstructor() — or an Unknown; an arrow, a method or a non-function is Node's catchable X is not a constructor. instanceof P walks the left side's prototype chain against P.prototype; a right side with no object prototype (an arrow) is Node's Function has non-object prototype 'undefined' in instanceof check. An instance prints as P { x: 1 }. A spread argument to new stays not-yet (the construct path takes no built list; docs/VALUE.md §4.23). Fixtures subset_function_construct_js / _ts, subset_function_instanceof_js; golden js/function_constructors. |
Properties of an ordinary function: f.count = 0, P.prototype.m = function () { … }, assert.same = … and assert.same(a, b) | not-yet(STA1214, Phase 8) — ts mode would need a typed property layout on a closure | dynamic | Implemented (plan.md §11c T11.4 family 4, plan-notes 310; docs/VALUE.md §4.20). A function's own properties live in a dynamic table on its closure, created on the first write; prototype is its own slot, created on first observation, whose hidden constructor is the function. Reads, writes, the compound forms and calls (the function as the receiver) all go through the shape table, and an instance finds P.prototype's members on its chain, getters and setters included. F.prototype = { … } replaces the prototype for later new F() (TS2565 is a js-mode runtime code); name and length stay read-only: the write is a TypeError whose message quotes the function's name where Node quotes its source text (Function.prototype.toString is not modelled). A function with properties prints them: [Function: assert] { same: [Function (anonymous)] }. f.call/f.apply/f.bind are Function.prototype members, not own properties, and stay not-yet (step 9). Fixtures subset_function_prototype_js, subset_function_props_ts; golden js/function_constructors. |
| Function declaration inside a block, loop or branch | static | dynamic | Implemented (Phase 5 step 12 family e). A module is always strict, so the declaration is BLOCK-scoped (ES2015) and Annex B's var-scoped alias does not exist. Hoisted to the top of its own block: { f(); function f() {} } resolves, mutual recursion across one block works, and a loop body's declaration closes over that iteration's bindings because the initialisation re-runs per iteration. A switch's clause list is ONE scope, not one per clause, so its declarations hoist across every clause and ahead of the dispatch. Shadowing landed 2026-09-11 (plan.md §8 step 14, plan-notes 216): a block-level declaration that reuses a visible name — function, let, const or parameter alike — gets a HIR name of its own at the lowering, so it has a slot of its own and the outer binding survives the block. subset_block_function_shadow_{ts,js} moved from not-yet to static; tests/golden/js/block_shadow.js is the byte-for-byte proof. |
| Template literals [proposed] | static | static if interpolations are typed, else dynamic | String concatenation of typed values compiles to direct C concatenation; untyped interpolations use jsrt_string_coerce(). |
User toString / valueOf: an object becoming a string (`${o}`, String(o), ''.concat(o), [o].join()) or a number ('' + o, Number(o), <, ==, parseInt(o)) | static | static, or dynamic where the object is | Implemented (plan.md §9 Task 6.27, plan-notes 345). ECMA-262 §7.1.1 ToPrimitive and §7.1.1.1 OrdinaryToPrimitive: hint string tries toString first, hint default (+, ==) and number try valueOf first; a Date's default is string. A method that is not callable is skipped, one that throws propagates as a catchable exception, and when both answer objects the conversion is Node's TypeError: Cannot convert object to primitive value. A class method, an object-literal method, an inherited one and a constructor's prototype method all answer. When the receiver's class declares the method the hint tries first, with no parameter and a primitive return, lowering emits the call itself (virtual where a subclass overrides it); anything else -- an interface, a union, an untyped value -- asks the object at run time (jsrt_to_primitive), and every conversion of an operand that may be an object is followed by a pending check. Symbol.toPrimitive stays refused until Phase 5 (STA1212, or STA1214 for the computed member name that spells it). Arithmetic other than + on an operand the checker types as an object stays refused (STA0012, TS2362/TS2365, or STA1214), so the numeric operators meet a user valueOf only through an untyped value. Goldens ts/to_primitive, js/to_primitive; decision tests subset_to_primitive_*. |
Spread operator ... in array literals | static | static if spread source is typed, else dynamic | Implemented (Phase 5 step 12). Typed array/tuple operands lower to nested jsrt_array_concat over literal runs; [...a] shallow-copies via concat(a, []), with a source's holes read as undefined (a spread iterates; docs/VALUE.md §4.4). A literal hole ([1, , 3]) stays not-yet. Any iterable landed (plan.md §11c T11.4 step 5, docs/VALUE.md §4.23): an Unknown, string, Map, Set, iterator or tuple operand drains through the ... row (jsrt_spread_operand) before the concat, and a non-iterable throws TypeError (its message names the value, not V8's source text). A NARROWED match array (RegExpExecArray/RegExpMatchArray) spreads element-wise in both modes: the checker calls it an interface but at run time it is a dense array, so a first-position spread folds into [].concat(m) like every other position (plan.md §8 step 44a); an un-narrowed one stays the checker's TS2488. |
Rest parameters ...args | static | static if all consumers are typed, else dynamic | Implemented (Phase 5 step 12 family a). Callers already pass extra arguments; the callee packs argv[i..] into an array (jsrt_args_rest). Function.length excludes the rest parameter. Destructuring rest (...[a, b]) stays not-yet. Call-side spread landed (plan.md §11c T11.4 step 5, docs/VALUE.md §4.23): f(...xs), o.m(a, ...xs) and arr.push(...xs) build the argument list as one array and call through jsrt_call_spread_at; a method is read through the shape table with its object as this, so a method spread is dynamic even over typed operands; a list whose pieces share one element type keeps a function call static. TS2556 is a js-mode runtime code. Still not-yet(STA1214): a spread into new or super(...), inside an optional chain, into a builtin namespace or fixed-arity builtin op (string methods, collection ops, the array callback ops, toSpliced), and in js mode into a function with an annotated TypeScript parameter (the call edge's STA2001 has no position to check). Fixtures subset_spread_call_*, subset_spread_method_call_*, subset_spread_super_call_*, subset_spread_optional_call_*, subset_spread_collection_*, subset_spread_array_callback_*, subset_spread_annotated_callee_js; goldens ts/iterable_spreads, js/iterable_spreads. |
| Default parameter values | static | static if typed, else dynamic | Implemented (Phase 5 step 12 family a). Evaluated when the argument is undefined (missing or explicit). Function.length stops at the first default. |
Optional parameters x? | static | static if typed, else dynamic | Implemented (Phase 5 step 12 family a). A missing argument is undefined; no initializer runs. Function.length still counts the parameter. JavaScript has no signature-order restriction, so a JSDoc optional parameter may precede a required one in js mode; ts mode keeps TypeScript's error. |
Declaration without initializer let x; | static | dynamic | Implemented (Phase 5 step 12 family a). The slot starts as undefined. A typed read before a write (let x: number; console.log(x)) is TS2454: ts mode keeps the refusal (STA0012); js mode suppresses it and widens the binding to Unknown, so the read answers undefined and receivers go through the shape table -- a method call on the still-unassigned binding throws Node's catchable TypeError -- rather than trust the annotation (plan.md §8 step 2a(c)). A binding assigned before every use is never widened and stays fully static. |
| Object destructuring | static | static if source object is typed, else dynamic | Implemented (Phase 5 step 12 family a). A shallow pattern of identifiers desugars to field reads. Nested patterns, rename rest (...r), and defaults in the pattern stay not-yet. |
| Array destructuring | static | static if source array is typed, else dynamic | Implemented (Phase 5 step 12 family a). A shallow pattern of identifiers desugars to index reads. Nested patterns, rest, and holes-with-defaults stay not-yet. For-of destructuring stays Phase 6. |
try/catch/finally/throw | static | static; catch binding is Unknown in .js without JSDoc type | Implemented (Task 3.10). Exception unwinding via landing pads (docs/VALUE.md §4.9): jsrt_pending() checks after every call, a per-try completion code routes return/break/continue through finally. The catch binding is Unknown in BOTH modes — anything can be thrown — so reading it is a narrowing point. What differs is whether the CHECKER lets you read it without narrowing: ts mode keeps useUnknownInCatchVariables and reports STA0012, js mode does not, because catch (e) { e.message } is ordinary JavaScript and §1.2 forbids rejecting it (plan-notes 175). A shallow identifier pattern on the catch binding is implemented (Phase 5 step 12 family a). An uncaught exception prints Uncaught <value> on stderr and exits 1. |
async function declarations and expressions, await | static | static if typed, else dynamic | Implemented (Task 4.6). The body lowers to a state machine: one C function re-entered at a numbered resume point, with the locals that outlive an await living in a heap environment rather than on the stack. await is a suspend/resume pair over jsrt_await — it subscribes a native continuation to jsrt_promise_resolve(operand), so awaiting a non-promise is legal and still costs exactly one tick, which is what makes an interleaving match Node's rather than merely finishing with the same answer. The prefix up to the first await runs synchronously on the caller's stack (observable, and the difference between an async function and a callback). A throw that escapes the body rejects its own promise via the landing pad, so try/catch around an await works with no new protocol. main drains the queue once after the module body: there are no timers and no I/O, so there is no macrotask phase. Deferred: for await, async generators, and async arrow/method forms not covered by the declaration and expression shapes. |
Generator functions and yield | static | static if typed, else dynamic | Implemented (Phase 5 step 8). A function* is Task 4.6's resume machine driven by the caller: gen() only allocates, the body runs on the first next(), and yield e parks e then pops the C frame. A later next(v) is the value of that yield. Locals live in the heap environment for the same reason an async function's do. for-of over the generator discards the completion value; next() after return answers { value, done: true }. Uncaught throws stay pending for the call site of next(). .return(v)/.throw(e) re-enter the parked body with an injection rather than a value: throw rethrows at the yield's own landing pad (the body's try/catch sees it exactly as if the yield threw), and return parks v and routes through every enclosing finally the way a real return at that point would — a finally that yields delays the answer, exactly per ECMA-262 27.5.1.3. On an unstarted or completed generator neither enters the body: return(v) answers { value: v, done: true }, throw(e) rethrows to the caller. Deferred: generator methods and async generators (STA1201, receiver-in-env), yield* (STA1214), and return/throw on a boxed SPECIALIZED iterator (arr.keys()), whose object does not carry them (Node answers a TypeError the runtime cannot raise yet). |
Types & typing policy
| Feature | ts mode | js mode | Notes |
|---|---|---|---|
Type annotations (: number, : string[], etc.) | static | static (where present) | Types are strictly required in ts mode; optional in js mode (inferred from code or JSDoc). |
| Interfaces and type aliases | static | static (where present) | Compile-time constructs; no runtime representation. |
Explicit any, as any | error(STA1001) | dynamic | ts mode forbids any entirely (use unknown instead); js mode lowers to tagged values with dynamic dispatch. |
Implicit any | error(STA1003) | dynamic | ts mode rejects untyped parameters and variables under strict mode; js mode lowers to Unknown (tagged, dynamic). |
| Union types | dynamic (tagged; narrowing = runtime check) | dynamic | Implemented (Task 3.5), by having no union at all. The HType model has no union node, so string | number IS Unknown — and narrowing one therefore lands on exactly the machinery an unknown already needed, with no separate feature. A union whose constituents all map to ONE HType is that type instead: "a" | "b" is a string, which is what makes typeof usable (its own type is a union of eight string literals). Discriminated unions checking a discriminant TAG are still deferred — that needs a union the model can see the constituents of. |
unknown type | dynamic (narrowing = runtime check) | dynamic | Implemented (Task 3.5). A narrowed read emits jsrt_check_number/string/boolean, which returns the value or raises STA2001 with a file:line:col. The check is per USE, not per binding: nothing proves the value did not change between two reads. An UNnarrowed unknown gets no check — printing one asks nothing of it — and a narrowing to a type no tag settles in constant time (an object, an array, a signature) leaves the value unknown on the dynamic path rather than being refused. |
as casts | dynamic (boundary-checked) | dynamic | Implemented (Task 3.5). A cast off an unknown to a checkable type emits jsrt_check_*. An identity cast and a widening to unknown assert nothing and lower to the operand alone. A cast to a type no tag settles is DROPPED rather than believed: the value stays unknown and every operation downstream stays on the dynamic path, which is sound and costs nothing that compiles today. The older <T>x spelling is not accepted — it is ambiguous with JSX and adds a second syntax for a construct that has one. |
JSON.parse(), FFI returns | dynamic (boundary-checked, always) | dynamic | JSON.parse implemented (Task 4.2, tenth slice) — see its own row below; it lands unknown, and the checks its uses need are exactly the ones as and narrowing already emit. FFI returns are still Phase 6. |
| Generic function declarations | static (monomorphized per concrete type tuple) | static, identically — a .js file cannot spell a type parameter, so a js-mode generic is a .ts file and gets the same specializations | Specialization happens AT the lowering, so no type parameter ever enters the HIR. Instantiations are shared by HType identity, which collapses literal types for free: box(1) and box(2) are one function. Depth is capped at 16 (STA2003) — f<T> calling f<T[]> has no fixed point. |
| Generic function used as a value, generic arrows, constrained or defaulted type parameters, explicit type arguments | static, or dynamic where the tuple is | same | Constrained, defaulted and explicit arguments specialize like any other call (the constraint is enforced by the checker, the default fills what no call determines). A generic read as a value takes the canonical tuple — every parameter its default, else Unknown — sharing its specialization with an undetermined call: printing it names the source declaration and dynamic dispatch through it runs the Unknown body, so the read is dynamic. typeof a generic folds to "function" without building a value. A callback to any call — ordinary, method, or receiver op (arr.map(box) passes box<number>) — specializes at the parameter's type, and an inline generic arrow or function expression passed directly as an argument does the same under a position-derived key (arrow@file#offset<tuple>): the literal is its own use site, so one tuple suffices. Still not-yet(STA1214): an unassigned arrow or function expression anywhere but a direct call argument (a let, a nesting, a branch, a spread, a constructor argument — no single parameter type to read), one at an argument whose body reads an enclosing scope or a same-file let/const/var (no binding a module-level specialization could read), a NAMED declaration or homed arrow whose body reads the same (specializations lower before their file's statements, so the same reads refuse there too — plan-notes 280), a generic that escapes further (returned, stored — no use site names a tuple), a generic call at a generic type (self-application box(box) infers an argument type no monomorphic copy can spell), and instanceof against a generic class (one descriptor per tuple, so the bare name identifies nothing to compare against). |
| Generic classes | static (one descriptor per concrete tuple) | static, identically — a .js file cannot spell a type parameter, so a js-mode generic is a .ts file and gets the same specializations | A generic class specializes per concrete tuple at each new site, inferred or explicit; fields, methods, accessors and statics substitute with it. A non-generic subclass names one complete tuple explicitly (class Sub extends Box<number>): the base's layout is grounded once, and the descriptor, the inherited-method owner, the vtable and the super-call all name that tuple's specialization. A generic class nested in a function or block specializes in place, scoped to that evaluation exactly like a nested ordinary class; its name must be unique across the program and no enclosing scope may bind a type parameter the tuple would close over. Deferred, all not-yet(STA1214): a generic subclass of a generic base (per-tuple bases), a raw or partial bound, an argument nothing binds, a nested subclass of a generic base, and instanceof against a generic class (one descriptor per tuple, so the bare name identifies nothing to compare against). |
Objects & classes
| Feature | ts mode | js mode | Notes |
|---|---|---|---|
| Classes with fixed shape (no getters/setters) | static (fixed slot offsets) | static if fully typed, else dynamic | Implemented (rung 6a). An instance is one allocation: a pointer to a file-scope JSRTClass descriptor followed by its slots, with declaration order as slot order. A field read is an offset load, not a lookup. The slot list comes from the CHECKER's property list, not from the member nodes, which is why a .js class — whose fields are declared by this.x = … in the constructor and have no member node at all — gets the same layout with unknown field types. Methods occupy no slot: one function is shared by every instance and the call resolves at compile time to that class's method, so o.m() is a direct call with the receiver as argument zero. Statics, #private, inheritance, overriding, accessors and object literals have all landed on top of this layout — see the rows below, each of which says what it reduced to rather than what it added. In js mode (the checker's TS2339 suppressed, plan.md §8 step 37) a name the class never declared lives in the instance's overflow table (docs/VALUE.md §4.24): c.missing = 2 grows it, a read answers what was stored or undefined, c.missing() calls what was stored or throws Node's TypeError, and delete c.missing removes it. Only the element spelling with a static key, c["missing"] = 2, still waits (not-yet(STA1214, Phase 8)). Fixtures subset_absent_class_member*. |
| Classes with getters/setters | static (the accessor is a call; the fields keep their slots) | static if typed, else dynamic | Implemented (rung 6b). An accessor is a pair of METHODS under a name no source can spell — get x, set x, where the space does what the dot does for a static. So o.x is a call to get x, o.x = v is a call to set x, and the property occupies no slot, which is exactly what the property MEANS. The reduction is why accessors needed no HIR node, no verifier case and no emitter case: they inherit dispatch, the method table, arity padding and the receiver parameter unchanged, and util.inspect never prints them because there is no slot to print. This is a correction to what this row used to claim — an accessor does NOT force the class onto the dynamic path, and the other fields keep their fixed slots (plan-notes 69). A static accessor is one pair of plain functions per declaring class under mangled static bindings, and a static #private accessor likewise (plan.md §8 step 12(d)); a literal-typed computed accessor name (get [k] with k: "m") is the name the direct spelling writes. Accessor overriding and abstract accessors landed (plan.md §8 step 12(d)): each half keeps its slot and takes over the entry, and the call is virtual wherever any chain containing the receiver's class declares the half twice — so a base-typed read or write runs the runtime class's override (`tests/golden/ts |
Class inheritance and super(...) | static (prefix layout; direct dispatch) | static if types are present, else dynamic | Implemented (rung 6b). A subclass's slots START with its base's, in the base's own slot order, so a base-typed read of a subclass instance lands on the right slot and a Dog is assignable wherever an Animal is declared. The slot list is rebuilt root-first from the chain — the checker lists a subclass's OWN properties first, which is the opposite of what a prefix layout needs — and a name an ancestor already claimed keeps the ancestor's slot, which is what makes a .js field assigned in both the base and the subclass one slot rather than two. super(...) is the base constructor run against the receiver this one was handed; a derived class that writes no constructor gets JavaScript's implicit constructor(...args) { super(...args) }, taking the nearest declared ancestor constructor's parameters. Field initializers run AFTER super(...), so an initializer may read a field the base wrote. Branch and switch calls (plan.md §8 step 12(d)): a class with NO field initializers may call super(...) from if/else arms or switch clauses instead of one fixed position — one call per path, every path covered (a switch needs a non-empty default for its unmatched path), no this/super read before the call on any path, and no path falling through into a second call (a fall-through re-run is Node's ReferenceError: Super constructor may only be called once, so the gate refuses the chain; empty grouped cases sharing one call are fine). A return completes the construction, so a returning path before the call is refused like a skipped one; a try-guarded call stays not-yet(STA1214) because a try body can abort after the call as well as before it and no one handler is right for both; and a constructor that returns a VALUE stays not-yet(STA1214) — Node substitutes a returned object for new's result and this subset's new yields the allocated object (codegen/index.ts), base constructors included. A method call names the class that DECLARES the method, so an inherited method is a direct call to the one function that exists. An alias base landed (plan.md §8 step 43): extends K where const K = C (possibly chained, possibly generic with one complete tuple) grounds to the target's layout, so the descriptor, the inherited-method owner, the vtable and the super-call all name C. A member-expression base (NS.C) that names one class declaration is accepted by the heritage rule, though the namespace itself stays not-yet. A computed base (mixin(C)) is a value, not a declaration, and stays not-yet(STA1214, Phase 5), as does a let alias. An expression base (extends C on const C = class …) grounds to the expression's layout the same way (plan.md §8 step 12(d), plan-notes 278). |
Method overriding and super.method() | static (method-table dispatch) | static if types are present, else dynamic | Implemented (rung 6b). One name, one slot, a different entry per class: a subclass's method table BEGINS with its base's, in the base's order, so a slot resolved against a receiver's static type indexes the right method on every descendant. A call is virtual exactly where it has to be — the lowering asks the whole file whether any chain containing the receiver's class declares that method twice, so a method nothing overrides keeps rung 6a's direct call and costs nothing. super.m() is a call on the SAME receiver that skips the override, which is why it stays direct even where every other call to m is virtual; a virtual call there would find the override again and recur. super.m as a VALUE (plan.md §8 step 42) is the same method as an unbound closure -- a method-value node with the receiver parameter as target, the declaring class as owner, dispatch forced direct -- so a bare call drops the receiver per has_receiver (docs/VALUE.md §4.16) exactly as const g = o.m; g() does. Bare super stays refused, and so does super.m in a static member, where there is no receiver to read. The table lives in the class descriptor and its entries are file-scope closure constants, which is what forces the two deferrals: overriding inside a function (a method there may capture, and a captured environment is per evaluation, so there is no one table) and re-declaring a FIELD (a field is a slot, and two declarations of one slot have two initializers racing for it). super.x on a field is refused for the sharper version of that: super.x and this.x are the same slot. In js mode noImplicitOverride is off — it would demand a JSDoc @override tag on ordinary JavaScript — while ts mode keeps it, since there the modifier is real syntax and an accidental override is exactly what a table makes silent. |
| Static methods and static class members | static | static if typed, else dynamic | Implemented (rung 6b). A static belongs to the class object, not to any instance, so it is not a slot: it is ONE binding for the whole program, under a name no source can spell (C.count — the dot is what makes it unspellable, the same trick the receiver parameter plays with a leading space). That reduction is why statics needed no HIR node, no verifier case and no emitter case of their own — a static read is an identifier, a write is an assignment, and C.m() is an ordinary call. The binding is named by the DECLARING class, because statics are inherited: Sub.count and Base.count are the same static, and mangling by the receiver's spelling would make a write through one invisible through the other. The initializer runs where the class declaration sits, and forward references between statics work for the reason function declarations hoist. Deferred: a static {} initialization block, and this inside a static member — both need the class object this model does not build. |
Private fields (class #field syntax) | static | static if typed, else dynamic | Implemented (rung 6b). A #private member is an ordinary member: #count takes a slot, #step() is a member function, static #next is a static binding. Privacy is not a layout property — the checker has already rejected every access from outside the class body, so nothing below the gate has to enforce it. The one place it survives is printing: util.inspect omits #private fields, so the runtime printer skips a descriptor field name that starts with #, and a class whose fields are ALL private prints as C {}. The # cannot collide with a public name because no public property may be spelled with one. A subclass re-declaring an ancestor's #private name landed (plan.md §8 step 12(d)): each declaring class gets its own slot under a per-class name (#x@A vs #x@B), and every use resolves lexically to the slot of the class whose body spells it -- reads, writes, calls and brand checks alike. Still deferred: an instance field re-declared under one class name twice in a single chain (one mangled slot for two fields), and a #private accessor re-declaring an ancestor's. |
Class expressions (const C = class …) | static | static if typed, else dynamic | Implemented (plan.md §8 step 12(d), plan-notes 278). A bound expression emits the same descriptor a declaration does, under Node's .name (the inner name, else the variable), and binds no value: every in-place use (new C, C.static, o instanceof C, extends C, the inner name in the class body) erases to the expression, so no class object is built and nothing below the HIR knows the spelling. Shadowing renames through the same alpha-renaming declarations use (plan.md §8 step 23); printing answers Node's name (D { … } for const C = class D). A let/var formation is one too when nothing in its file writes the binding (Rolldown spells every top-level class var X = class { … }; plan.md §11d T12.3). Still not-yet(STA1214): an unbound expression (no identity), a let/var formation the file repoints, a generic expression (no home to specialize under — step 12(f)), an anonymous default-export declaration, and any opaque use (f(C), const K = C, typeof C), which reads the class object of step 12(e). |
| Object literals with static keys | static (fixed slot offsets) | static if all values are typed, else dynamic | Implemented (rung 6b). A literal is a CLASS whose declaration is a type: the shape comes from checker.getTypeAtLocation, and the entries the source wrote are its slots, in written order. That is the whole reduction — the same JSRTClass descriptor, the same allocation, the same offset load for p.x, and no HIR node beyond ObjectLiteral itself. Two literals with the same field names and types get the same STRUCTURAL descriptor name ({x: number, y: string}, whose leading brace is what no source identifier may start with), so one descriptor is emitted and shared. The descriptor's name is the empty string, and the printer treats an empty name as "no constructor name", which is what makes { x: 1 } print without a prefix where a class instance prints Point { x: 1 }. Shorthand, non-identifier keys, and spread landed 2026-09-03 (plan.md §8 step 12 family c, plan-notes 181): { x } desugars to { x: x }, a string-literal key takes a slot like any other and o["a-b"] reads it back, and { ...a, y: 1 } expands to one field read per field of a's shape. That last one forced apart two facts this row used to conflate — the LAYOUT (the type's field order, what o.x resolves against) and the ENUMERATION order (§10.1.11, what console.log and Object.keys answer). A key order that differs from the layout is no longer a different shape; it is the same layout with a JSRTClass::key_order (docs/VALUE.md §4.5), which is what makes const o: { y: number; x: string } = { x: "s", y: 2 } both read and print the way Node does — it silently swapped the two field values before. Method members landed 2026-09-12 (plan.md §8 step 12c, plan-notes 227): { m() { … } } lowers to the same hidden-class descriptor and method table as a class instance — one shared function per name, receiver as parameter zero, o.m() passes this === o. A method that captures a local or parameter landed (plan.md §8 step 36): every method of a literal rides in a hidden #-prefixed trailing slot bound per evaluation (docs/VALUE.md §4.5), so counter().get() reads the construction-site environment with the call-site receiver. Method-as-value (const f = o.m) stays not-yet (step 12e). Deferred: a spread of a value with no fixed shape (a literal { ...null }, an array beside an own key), of a dropped as assertion's unknown operand, and the methods-order shapes (all not-yet(STA1214) — the dynamic tier, plan.md §8 step 39). A spread of an untyped value landed (plan-notes 297): in js mode a literal the checker types any — an any operand, or one it refused as TS2698 — takes the dynamic path, and the operand folds through the shape-table assign the array spread uses, which copies own enumerable keys and skips undefined, null and key-less primitives as CopyDataProperties does (subset_spread_unknown_object_js, `subset_spread_refused_operand_js |
Calls of an object's function-valued field: o.f(a) where f is a field (not a method) holding a closure -- { f: () => … }, { f: function () { … } }, the shorthand { scan } | static | static if the field and the closure are typed, else dynamic | Implemented (plan.md §11c T11.4, plan-notes 310). One field-call HIR node: the callee loads from the field's slot (jsrt_object_get_field), and the call passes the receiver as argument zero exactly when the loaded closure declares one (has_receiver, docs/VALUE.md §4.16), as a dynamic method call does. A plain function stored in a field therefore sees the object as this, while an arrow ignores it. Only a field typed as a function (or Unknown) qualifies (callableFieldSlot in hir/types.ts, shared by the gate and the lowering). A field of any other type stays not-yet(STA1214) ("method calls"), where Node throws a TypeError at the call. Fixtures subset_field_call_*; goldens ts/field_calls, js/field_calls. |
Methods of a string or number the compiler only knows as Unknown: text.slice(1) with text untyped, this.name.toUpperCase() inside an object literal's function | dynamic | dynamic | Implemented (plan.md §11c T11.4, plan-notes 310). The call is the dynamic method call. A primitive has no shape table, so jsrt_get_prop answers the method itself: every landed String.prototype op (STRING_OPS) and Number.prototype.toString/toFixed, as a closure bound to the receiver, with the spec's name and length. A RegExp is the same case (plan-notes 314): narrowing an unknown with instanceof RegExp, or a `string |
Compound assignment to a member of a dynamic receiver: o.n += 1, `o.s | = v, o.n ??= v, o.n++, --o.nwhereo` is Unknown or a dynamic shape | dynamic | |
Assignment to an array's length: xs.length = n, xs.length -= 1, xs.length--, (xs.length = n) | dynamic | dynamic | Implemented (plan.md §11c T11.4 family 3, plan-notes 310). ECMA-262 §10.4.2.4 ArraySetLength through the runtime's property-write entry, so a typed and an untyped array take one path: a value that ToUint32 changes is RangeError: Invalid array length, a smaller length drops the tail, an equal one is a no-op, and a larger one would leave holes, so it aborts STA2002 as a write past the end does. The value is coerced once where the spec coerces it twice, which only a valueOf with side effects can observe. Fixtures subset_array_length_assign_*. |
Destructuring assignment: ({ a, b: c } = rhs), [a, , b] = rhs as a statement | static (object form); dynamic (array form, as const [a, b] = rhs is) | static if the source is typed, else dynamic | Implemented (plan.md §11c T11.4 family 3, plan-notes 310). The right side runs once into a temporary, then each target variable is assigned what the declaration form const { a, b: c } = rhs would bind, in source order. Still not-yet(STA1214): a default, a rest, a nested pattern, a member target, and an assignment whose value is used. Fixtures subset_destructuring_assign_*, subset_array_destructuring_assign_*, subset_destructuring_assign_default_*. |
Several declarators in one let/const: let a = 1, b;, for (let i = 0, n = xs.length; …) | static | static if typed, else dynamic | Implemented (plan-notes 310). Each declarator is its own declaration, in order, binding into the enclosing scope (a for header's into the loop's). Before this a list of two was an internal STA4032 in both modes. Fixtures subset_multi_declarator_*. |
Assignment to a property the object's shape does not declare: const o = { a: 1 }; o.extra = 2 | error (STA0012, TS2339) with the member refused first (not-yet(STA1214)) | dynamic | Implemented (T11.4 family 7). The name lands in the object's overflow table, a dynamic object created on the first such write (docs/VALUE.md §4.24); compound, logical and update forms read it first, a read of a name never stored answers undefined, a call of one calls it with the object as receiver, and delete removes it. Reflection (Object.keys, for-in, JSON.stringify, console.log, object spread) lists the grown names after every declared one. Fixtures subset_object_grow_*. |
Computed key on a fixed shape: table[node.kind], levels[level] = v | error (STA0012, TS7053) | dynamic | Implemented (T11.4 family 7). A string or number key reads and writes by NAME through the degrading index entry points: a declared name answers its slot, any other the overflow table, or undefined (docs/VALUE.md §4.24). A static key naming no member (o["extra"]) is still not-yet(STA1214). Fixtures subset_object_computed_key_*. |
| Object literals with optional properties, index signatures | dynamic (shape table + inline cache) | dynamic | Implemented (Task 4.1). The CONTEXTUAL type decides — and since 2026-09-11 an INTERFACE counts exactly like its anonymous twin (interface O { x?: number } and { x?: number } are one type, plan.md §8 step 16): in const o: { x?: number } = { x: 1 } the literal's own type is a layout, but every later read of o goes through the annotation, so the literal builds a JSRTDynObject and each property SITE resolves the name through the shape table with its own inline cache (docs/VALUE.md §4.10). Reads of an absent property answer undefined; every read types Unknown and narrows like a Map.get. Every assignment form writes it: the compound, logical and update forms fold to a read and a write of the same entry through a receiver evaluated once (plan-notes 310; the row "Compound assignment to a member of a dynamic receiver"). In js mode the shape can come from JSDoc (@type {{x?: number}}), which the checker honors in a .js file. Honesty clause: structural aliasing can hand a FIXED-shape object to a dynamic site (const a = { x: 1 }; const b: { x?: number } = a; b.x). A read or write of an existing field walks the class descriptor and answers; growing a new key (b.y = 2) still aborts with STA2004 (Phase 8) rather than invent a slot. An empty {} is dynamic so it can grow. An Unknown receiver (function f(o) { return o.x }) uses the same shape-table path, including computed index and call (a non-function callee is STA2006). In js mode an optional property may also hold undefined explicitly ({ x: undefined }, o.x = undefined): exactOptionalPropertyTypes stays ON, but its three codes (2375/2379/2412) are js-mode runtime codes since 2026-09-03 (plan.md §8 step 2a(b), plan-notes 189) — JavaScript permits the value and the shape model keeps the distinction EOPT polices, so {} and { x: undefined } still differ in Object.keys and in console.log. ts mode keeps the refusal (STA0012). Computed keys and methods landed (plan.md §8 step 22): { [k]: v } with a runtime key builds the dynamic entries through jsrt_dyn_index_set, a method beside one is an own data property holding the method's closure in written position (so it enumerates there), and o.m() on an Unknown receiver loads the name through the shape table and passes the receiver when the loaded closure declares one (has_receiver, docs/VALUE.md §4.16) -- a non-function callee is STA2006, as for an ordinary call. A method no longer vetoes the dynamic path: a literal with both a method and an accessor (or an optional property) builds dynamic, which the method table could never hold. A __proto__ data property sends its literal here too (plan.md §8 step 33): the definition spelling { __proto__: v } / { "__proto__": v } is the prototype setter, not an own property, so the shape carries every key but that one and the setter value evaluates in written position with nothing stored. A computed ["__proto__"], a shorthand and a method under the name stay own data properties through the same table. Installing an object or null prototype needs a [[Prototype]] slot neither layout has, so prototype reads (o.__proto__, inheritance) wait on Phase 8's prototype surface; enumeration, stringify and the inspector already agree with Node. Deferred: delete. |
| Getters/setters on object literals | dynamic | dynamic | Implemented 2026-09-04 (plan.md §8 step 12(c), docs/VALUE.md §4.15, plan-notes 192). Accessor descriptors cannot be compiled to fixed field access, so the literal builds Task 4.1's JSRTDynObject for the same reason an optional property does — a fixed shape's reads are slot indices decided at build time, and an accessor has no slot. The get/set pair rides in the object's SLOT as a JSRTAccessorCell, not on the shape: a shape is shared by every object with the same key history, while a getter can capture, so three objects built by one literal in a loop have one shape and must have three getters. JSRTShape, JSRTIC and JSRTClass are all untouched — a read tests the value it just loaded against &jsrt_class_accessor. Each half is an ordinary function unit with the receiver as parameter zero, so this inside is an Unknown receiver and this.x is a shape-table read. Node's semantics, pinned byte-for-byte: util.inspect does NOT call the getter ([Getter], [Setter], [Getter/Setter]), Object.keys does not either while Object.values/entries/JSON.stringify do, and a write to a get-only property throws TypeError: Cannot set property x of #<Object> which has only a getter (compiled modules are always strict). Cost: an accessor deoptimizes its whole literal, so a sibling data property becomes an IC lookup — the trade the row above already accepts. Deferred: an accessor-bearing literal in a position typed as a fixed SHAPE (const o: { at: number } = { get at() {…} }), which TypeScript calls assignable and no runtime conversion can honor — refused as not-yet rather than emitted and read back as garbage. |
delete o.k / delete o[e] on a dynamic-shape object | dynamic (jsrt_delete) | dynamic | Implemented 2026-09-09 (plan.md §8 step 2a(c), docs/VALUE.md §4.10). In ts mode a delete that type-checks can only name a DYNAMIC shape, and the two rules meet to make that so: TS2790 requires the operand to be an optional property, and an optional property is exactly what sends an anonymous object type to the shape table (the row above). Removal REBUILDS the receiver's shape chain from the root without the key instead of editing a node — shapes are shared immortal metadata — which is O(keys), preserves insertion order, lands two objects that deleted the same key on ONE shape, and invalidates stale inline caches for free, since an JSRTIC is trusted by shape-POINTER compare. The operator answers Node's boolean: true when the key is gone or was never there. A frozen property is non-configurable and raises Node's TypeError; a fixed-shape receiver is refused instead (STA1108 / STA1205 / STA2007). An array element is the row "Array(n), new Array(...) and array holes" |
Collections & data structures
| Feature | ts mode | js mode | Notes |
|---|---|---|---|
| Arrays: dense, homogeneous element type | static (dense buffer, bounds-checked; elision when provably in-range is Phase 3 Task 3.5) | static if typed, else dynamic | […] literals, .length, for-of and index WRITE are all static. js mode is not "always dynamic": [1, 2, 3] infers number[] with or without the annotation, so it compiles on the same path. |
Arrays: empty literal [] under an array context (annotation, as, return type, parameter, assignment target) | static | static if the context is typed (JSDoc @type/@returns/@param count), else dynamic | Implemented (plan-notes 322). [] alone is the checker's never[], whose element is Unknown; the CONTEXT is the array the literal will be read as, so const out: string[] = [], [] as string[], return [] under number[] and count([]) are that array from the start. A context naming a generic callee's type parameter keeps the checker's answer. |
Arrays: index READ a[i] | dynamic | dynamic | noUncheckedIndexedAccess types the read T | undefined, because out of range it really is undefined. That union is Unknown, and Task 3.5 (boundary-check insertion) is what narrows it back to T. for (const x of a) binds the ELEMENT type and stays static — it is the typed way to read an array today (plan-notes 53). An index is a canonical numeric string, not a ToNumber coercion: booleans, null, "01" and object keys all miss to undefined (plan.md §8 step 44b). An OBJECT-typed key on any receiver coerces via ToPropertyKey in js mode (the read-side twin of the suppressed-2464 write coercion; ts keeps TS2538): fixed shapes route through the degrading index entry points, custom toString dispatch staying the shared Phase-8 ceiling. |
| Arrays: sparse (a write more than one past the end) | not-yet(STA2002, raised at runtime) | not-yet(STA2002, raised at runtime) | ECMA-262 leaves the skipped indices absent — console.log prints <2 empty items>, not undefined — and a dense buffer cannot be absent, so the write refuses loudly rather than printing a different program's output. In-range writes and a[a.length] = v are unaffected. Growing length by assignment (a.length = 10) refuses the same way (plan-notes 310). Lifts with the object model. |
| Arrays: heterogeneous or untyped | dynamic (element type Unknown, tagged values) | dynamic | Elements stored as tagged jsrt_value; shape check on access to narrow type. |
Map with primitive keys (string, number, boolean, null, undefined) | static (open-addressed hash table; get/set/has/delete/clear/size) | static if typed, else dynamic | Implemented (rung 7). A Map and a Set are ONE structure under two JSRTClass descriptors — jsrt_class_map and jsrt_class_set, distinguished by descriptor pointer the way object literals are — because the value half is the only difference between them and SameValueZero is the only comparison either needs. There is no separate "specialized primitive" table, and this row used to claim one: a NaN-boxed primitive key already IS its unboxed bits and an object key already IS its pointer, so one comparison serves both key kinds and two implementations would have been two copies of the same code (plan-notes 72). A key of -0 is normalized to +0 on the way IN, per the spec's own step in set/add — comparison could never show the difference, but the key a forEach callback receives can (plan-notes 98). Entries are appended to a dense array and indexed by an open-addressed probe table kept at load ≤ ½, so INSERTION ORDER is iteration and print order — including across a delete-then-reinsert, which appends at the end rather than reclaiming the hole, and across the growth compaction. The type arguments must be on the CONSTRUCTION: const m: Map<string, number> = new Map() types the call itself Map<any, any>, which is Unknown, so only new Map<string, number>() is static. .get is dynamic wherever it appears — noUncheckedIndexedAccess aside, the lib types it V | undefined, and that union has no HType. forEach landed with the Task 4.2 addendum (2026-08-30): it takes a CALLBACK, not an iterator, and the runtime walks the entry array calling it through jsrt_call — the same closure ABI the Array.prototype callback ops already use, so it needed no protocol the subset lacks. It passes the spec's triple ((value, key, collection); for a Set the value IS the key), stops on a pending exception, and sees mutation during the walk the way the spec requires: an entry deleted before it is reached is skipped, one appended during the walk IS visited, and a clear() ends the walk. That last part is why the table suppresses its growth COMPACTION while a walk is in flight (JSRTMap.iterating) — compaction renumbers entries, and the walk holds an index. The thisArg form is deferred like every other callback op's. keys/values/entries land with Phase 5 step 8 (plan-notes 150): for-of inlines; a stored iterator boxes. Map entries()/for-of yield a two-element array, so a file that uses the pair is dynamic (no tuple). Deferred: constructing from an iterable (new Map([['a', 1]]) — the user-iterable protocol). WeakMap/WeakSet are the next row. |
WeakMap and WeakSet: new WeakMap(), new WeakSet(), get/set/has/delete/add | static if typed, else dynamic | static if typed, else dynamic | Implemented (plan.md §11c T11.4 step 4b, plan-notes 310). The same JSRTMap under two more descriptors (docs/VALUE.md §4.22): the HType is a Map/Set, since the checker already keeps size, forEach, clear and the iterators off a weak collection, and CollectionNew.weak picks the allocator. set/add throw Node's TypeError on a primitive key, with a pending check only where the key type is open. console.log prints WeakMap { <items unknown> }, and instanceof tells all four collections apart. Known divergence: keys are held strongly, so nothing is collected while the collection lives. Constructing from an iterable is deferred with the Map row's. Fixtures subset_weak_collections_*; goldens js/builtin_constructors, ts/builtin_constructors. |
Error and its four standard subclasses (TypeError, RangeError, ReferenceError, SyntaxError) — new TypeError(msg), .name, .message, instanceof | static | static | Implemented (Phase 5 step 2a(c)). Five JSRTClass descriptors in runtime/src/jsrt_error.c whose parent is Error, so the subclass relation IS the pointer walk jsrt_instanceof already did for user classes — there is no error-specific mechanism, no tag and no struct. name and message are slots 0 and 1, which makes e.message the ordinary fixed-shape read and left jsrt_shape.c untouched; errorHType is the emitter's copy of that layout and STA4095 is the check they have not drifted. The five interfaces map to that layout in tsTypeToHType too (2026-09-11, plan.md §8 step 16): they used to fall through to Unknown because of stack?: string, so new Error('x').message inline was a dynamic read on a concretely-typed target (STA4059) and every e.message went through the shape table. The runtime's own TypeErrors are raised through jsrt_throw_error, so a catch reads name/message/instanceof as it does in Node, with Node's exact wording. Three deliberate divergences — the two slots are enumerable where Node's are not (Object.defineProperty is a Phase 8 not-yet, so the subset has no non-enumerability at all), console.log(err) prints the object rather than a stack trace this runtime does not have, and the jsrt_panic sites still abort where Node throws (plan-notes 195). The cause options argument is STA1214: it needs a third slot |
A reference to a name nothing declares — missingName, and typeof missingName | error (STA0012) | dynamic | Implemented (Phase 5 step 2a(c)). js mode drops TS2304/TS2552 and lowers the read to a reference-error node, which throws ReferenceError: <name> is not defined through jsrt_reference_error — catchable, with Node's wording and a working instanceof, because the Error model landed first. typeof is the exception the language makes and the lowering makes it too: §13.5.1.1 short-circuits before the reference is resolved, so typeof missingName answers "undefined" without throwing. ts mode keeps the checker's refusal. The divergence to know: a name Node WOULD have (process, setTimeout — globals this runtime does not implement) also throws here rather than working, which is the honest answer for a runtime that does not have them, not a claim they are undefined in Node |
Map with object or unknown keys | dynamic (the KEY TYPE is Unknown; the table underneath is the same one) | dynamic | Implemented (rung 7). Not a separate path: SameValueZero on a non-primitive IS pointer identity, so two instances with identical fields are two keys and the table needs no object model to say so. The verdict on the FILE still turns on the type — Map<object, V> is Unknown, because object describes no layout — but that is the type model talking, not the table. |
Set with primitive elements | static (the Map table with the value half unused) | static if typed, else dynamic | Implemented (rung 7). add rather than set, and one argument rather than two; everything else is the row above. The seven ES2025 set operations landed with the Task 4.2 addendum (2026-08-30): union intersection difference symmetricDifference answer a NEW Set (neither operand is touched), and isSubsetOf isSupersetOf isDisjointFrom answer a boolean. Their argument is the only place in the subset where a collection operation takes another collection, and it must be a real Set: the spec accepts any SET-LIKE object (a size, a has, a keys) and reads it by CALLING keys(), which is the iterator protocol still deferred — so a set-like object is not-yet(STA1214) while a Set is read straight out of the second table. Result order is normative and is not always the receiver's: intersection walks whichever collection is smaller and answers in THAT one's order, which the spec spells out and the pinned Node observes (plan-notes 100). |
Set with object elements | dynamic (the ELEMENT TYPE is Unknown; the table is the same one) | dynamic | Implemented (rung 7). See the two rows above — the element kind changes nothing about the structure. |
Promise.resolve, Promise.reject, Promise.all | static | static | Implemented (Task 4.6). The three statics that never call back, which is why they land while the constructor does not. Promise.resolve of a promise answers the SAME promise (§27.2.4.6, identity preserved); of anything else, an already-fulfilled one. Promise.all fills its result BY INDEX rather than by arrival, so input order survives however the elements settle, a plain value in the list counts as already-resolved, an empty list settles on the spot, and the first rejection wins (later settles are ignored because settling is idempotent). Its argument must be an array — an iterable is the deferred iterator protocol — and a rejection with nothing subscribed to it aborts at the end of the drain (STA2005) rather than being swallowed. |
new Promise(executor), .then/.catch/.finally | static | static | Implemented (Phase 5 step 11). jsrt_call_protected turns a pending throw into a completion value so a handler's throw rejects the derived promise instead of unwinding into library C. then/catch/finally are clients of jsrt_promise_subscribe. new Promise(executor) runs the executor now; a throw rejects the constructed promise; a non-function executor throws TypeError. Combinators (allSettled/any/race/withResolvers/try) stay not-yet. |
new Date(ms), new Date(isoString), new Date(other), new Date() | static | static | Implemented (Task 4.2, slice A). A Date is one double on the heap -- milliseconds since the epoch, or NaN for an Invalid Date -- behind a jsrt_class_date descriptor, so every member below is a direct runtime call and nothing about it is dynamic. One C entry point (jsrt_date_from_value) covers all three argument forms because the discrimination is a tag test; anything else becomes an Invalid Date, which is what the spec's ToPrimitive-then-ToNumber path amounts to. TimeClip (§21.4.1.31) applies: a magnitude past 8.64e15 is an Invalid Date. The ZERO-argument form reads the clock and is accepted -- nondeterminism is a proof problem, not an acceptance problem -- and the lowering desugars it to new Date(Date.now()), which §21.4.2.1 step 2 defines it as; its proof is tests/unit/date-clock.test.ts, not a golden fixture. The COMPONENT form new Date(y, m, …) is local time and landed with slice B (row below). |
Date.prototype UTC surface: getTime, valueOf, setTime, the 8 getUTC*, the 7 setUTC*, toISOString, toJSON, toUTCString | static | static | Implemented (Task 4.2, slice A). The TZ-INDEPENDENT core, on the DATE_OPS table discipline: every member is a pure function of the stored double, so the answer on any machine is the answer on the pinned Node -- which is what makes the whole slice golden-testable without a TZ pin. Calendar arithmetic is Hinnant's days_from_civil/civil_from_days over the proleptic Gregorian calendar §21.4.1 defines, so 1900 is not a leap year, 2000 is, and pre-epoch times floor rather than truncate. Setters take their trailing components optionally (the lowering pads with undefined, which the spec's "if present" step reads identically) and ROLL rather than clamp; setUTCFullYear on an Invalid Date recovers per §21.4.4.21 while every other setter leaves it invalid. toJSON answers null for an Invalid Date (§21.4.4.37) even though lib.es5.d.ts declares it (): string -- a lib divergence, and what makes JSON.stringify(new Date(NaN)) the string "null" rather than an abort. console.log(d) prints the ISO string, nested included; JSON.stringify routes through toJSON. Known ceiling: toISOString on an Invalid Date throws a RangeError (Phase 5 step 11); generated C checks jsrt_pending() after the op. |
Date.UTC, Date.parse, Date.now | static | static | Implemented (Task 4.2, slice A). Date.UTC takes 1-7 arguments -- the spec defaults month to 0, date to 1 and the rest to 0, filled at LOWER time so the runtime entry stays fixed-arity, the Math-fold model -- and applies the §21.4.3.4 two-digit-year rule (0-99 means 19xx). Date.parse accepts the §21.4.1.32 Date Time String Format ONLY; anything else answers NaN. That is a documented divergence from Node, which applies non-ISO heuristics whose results are TZ-dependent and implementation-defined; a date-time with no offset is local time and answers NaN for the same reason, and neither is exercised in a golden fixture. Date.now reads the wall clock and lands under the determinism carve-out with the zero-argument constructor. |
Date.prototype local surface: the 8 local getters, getTimezoneOffset, the 7 local setters, toDateString, new Date(y, m, …) | static | static | Implemented (Task 4.2, slice B). Same calendar arithmetic as the UTC rows over a reading shifted by the host zone: local_time(t) = t + LocalTZA(t) with the offset from libc localtime_r, which OWNS the tzdb -- the runtime carries no zone rules of its own. The setters and the component constructor need the inverse, and the inverse is not a bijection: a wall-clock reading in a DST gap names no instant and one in a fold names two. §21.4.1.26 resolves both with the offset in effect BEFORE the transition -- the earlier instant when there are two -- which the runtime finds by probing the offset one day either side, the spec's own window. getTimezoneOffset reports minutes WEST, so UTC+2 answers -120. toDateString is here rather than with the ICU family because its output carries no zone name. Proof split: the golden runner pins TZ=UTC on both sides (the compiled binary AND the Node ground truth), which is what stops a tzdata skew between libc and Node's ICU from surfacing as a byte diff that looks like a semantics bug; under that pin the goldens prove the wiring and the arithmetic, and every zone-dependent claim -- gaps, folds, half-hour offsets, transitions a setter crosses -- is proved in tests/unit/date-local.test.ts under an explicit non-UTC TZ. |
Date.prototype.toString, toTimeString, toLocaleString, toLocaleDateString, toLocaleTimeString, the call form Date() | not-yet(STA1210, feature build) | not-yet(STA1210, feature build) | All that is left under STA1210, and it carries no phase (2026-09-01, plan-notes 136): a feature build is a flag to turn on, not a release to wait for. toString and toTimeString are NOT locale-dependent but are ICU-dependent all the same: both append the host zone's long display name (Mon Jul 15 2024 14:00:00 GMT+0200 (Central European Summer Time)), which Node reads from ICU and libc cannot produce -- %Z gives the abbreviation CEST (measured, plan-notes 133). So they join the toLocale* family on Task 4.4's intl_* feature surface -- non-blocking, the same shape as the rest of the flag build (STA1215 model). |
d2 - d1 | error(STA0012) | dynamic | Date ARITHMETIC needs no rule of this compiler's in ts mode: it relies on §21.4.4.45's [Symbol.toPrimitive] hint, lib.es5.d.ts types both operands Date, and strict TypeScript refuses the operator before the gate is asked. The sanctioned spelling is d2.getTime() - d1.getTime(). [Symbol.toPrimitive] itself stays out: Symbol-keyed members are out of every surface list. |
RegExp literals | static | static | Implemented (Task 4.3). /ab+c/gi lowers to a regexp-literal carrying the pattern and the flags as TEXT: nothing above the C boundary parses either, so nothing above it can disagree with the vendored engine (quickjs-ng's libregexp, runtime/vendor/quickjs-ng) about what they mean. Compiled at EVERY evaluation, never hoisted — §22.2.4.1 makes each evaluation a fresh object, and it has to be, because lastIndex is mutable state on it. An invalid pattern or flag string is settled where it is compiled: at run time, loudly (STA2005 pattern), because the spec throws SyntaxError and builtins cannot raise yet. new RegExp(...) is the next row. |
new RegExp(pattern, flags) and RegExp(...) | static | static | Implemented (plan.md §11c T11.4 step 4b, plan-notes 310). A global-call row (jsrt_regexp_construct, docs/VALUE.md §4.21): the pattern is compiled at run time, a RegExp argument lends its source and (when no flags are given) its flags, and source is escaped as V8 escapes it (new RegExp('a/b').source is a\/b). A bad pattern or flag string throws a catchable SyntaxError with V8's head (Invalid regular expression: /(/: …); the reason is V8's where libregexp's maps to exactly one, libregexp's otherwise (known divergence). Fixtures subset_regexp_ctor_*; goldens js/builtin_constructors, ts/builtin_constructors. |
RegExp.prototype.test | static | static | Implemented (Task 4.3). The whole landed surface, on the REGEXP_OPS table discipline: one runtime function with a fixed C signature, a callee only, never a value. lastIndex is read and written by a /g or /y pattern and reset to 0 on a failed match, which is what makes while (re.test(s)) terminate. The subject follows JSON.parse's rule — a string, or untyped and settled by the bridge's own tag check (STA2005), which is the js-mode norm. Coverage: same dashboard. |
RegExp.prototype.exec, String.prototype.match | dynamic | dynamic | Implemented (Task 4.1, array-with-properties slice). exec answers the MATCH ARRAY: a dense array of the capture groups that also carries index, input and groups as named properties — so JSRTArray gained the dynamic object's property table (shape + out-of-line slots, NULL until something hangs a property off it), and a match read resolves through the same shape chain and the same per-site inline cache a { } receiver uses. groups is a NULL-PROTOTYPE object where the pattern declares named captures and undefined where it does not, and it PRINTS as one ([Object: null prototype] { w: 'a' }) because Node does. A capture that did not participate is undefined in the array, not a missing element. lastIndex moves exactly as test moves it — exec and test are one algorithm with two answers. match without /g IS exec; with /g it answers a plain array of whole-match strings, built by CreateArrayFromList and therefore carrying no properties, or null. The verdict is dynamic, not static: the answer is a match OR null, a union the HIR does not model, so the binding is Unknown even though every read off it lowers to a fixed runtime call. |
| A match array's members | dynamic | dynamic | Exactly four names: index, input, groups (properties of the match) and length (the array header's own). The receiver's HIR type is Unknown, so what proves a receiver is a match is the CHECKER — isMatchReceiver in src/frontend/gate.ts, the discipline isStringReceiver follows — and the runtime settles it a second time, because a tag jsrt_get_prop cannot walk is a panic rather than a misread. m[0] indexes it like the dense array it is. Everything else on a match (m.map, m.slice, spreading it) is not-yet(STA1214, Phase 5): it waits for the match array to have an HIR type of its own, which is the union work. |
RegExp.prototype data properties, toString | static | static | Implemented (Task 4.2). source, flags, lastIndex and the eight flag predicates, on a closed table of their own (REGEXP_FIELDS) beside the method table. Every one is DERIVED from the compiled regexp — two normalized strings, a header field, and one bit test each — so a read is a struct load behind a fixed C signature, never a shape-table lookup, and it is static even in js mode. flags is the spec's canonical dgimsuvy ORDER, not the order the source wrote: /a/ig has .flags === "gi" and prints /a/gi, and the runtime normalizes once at construction so flags, toString and console.log(re) cannot disagree. lastIndex is READ-only: a write is an assignment target, which the assignment gate does not admit for a builtin. unicodeSets is in the table but UNREACHABLE — it is declared in lib.es2024.regexp.d.ts and this project's lib is es2023, so the checker cannot type the read. |
RegExp.prototype.compile | not-yet(STA1211, Phase 8) | not-yet(STA1211, Phase 8) | The one member outside both tables. Annex B §B.2.4 legacy that RE-INITIALIZES a regexp in place -- the mutate-a-built-object surface Phase 8 owns with STA1204, which is why it left Phase 4 on 2026-09-01 (plan-notes 121, 136). Its optional second argument, which the fixed-arity op table cannot express, is a second reason. |
Uint8Array and ArrayBuffer: new ArrayBuffer(n), new Uint8Array(n | buffer[, offset[, length]] | typedArray | iterable | arrayLike), u[i] / u[i] = v, length, byteLength, byteOffset, buffer, subarray, slice, set, ArrayBuffer.prototype.byteLength / slice, for-of | static (the element READ is number | undefined under noUncheckedIndexedAccess, so it is dynamic exactly as an array read is) | static if typed, else dynamic | Implemented (plan.md §11c T11.1). The storage lives in the Zig memory core (runtime/src/jsrt_typed.zig, docs/VALUE.md §4.19): an ArrayBuffer is a header plus a pointer-free (atomic) byte block, a Uint8Array is a view {buffer, byteOffset, length} over one, so subarray shares bytes and slice copies them. Every member is one TYPED_OPS row (hir/nodes.ts) and one typed-op HIR node; the element store converts with ToUint8 (300 stores 44, -1 stores 255) and an out-of-range index or a non-canonical numeric key is a no-op, as §10.4.5 says. The constructor's ToIndex, buffer-bounds and set offset checks throw the RangeErrors V8 writes; an array-like or iterable source (array, Set, Map iterator, generator, { length } object, another view) is drained before allocation. Untyped receivers reach the same entry points through jsrt_typed_get_prop and jsrt_dyn_index_get/_set. console.log prints Node's Uint8Array(n) [ … ] (grouped, capped at 100) and ArrayBuffer { [Uint8Contents]: <…>, [byteLength]: n }; String(u) joins with commas, JSON.stringify(u) is the index-keyed object and an ArrayBuffer stringifies to {}. Known ceilings: an expando write (u.foo = 1) on an UNTYPED receiver panics with STA2004, as on every other statically shaped builtin; a dynamic call of an unlanded method (untyped.fill(0)) reads undefined and throws the TypeError a missing method throws; Object.keys and friends on a typed array are refused by the existing argument-type gate. |
The rest of %TypedArray%.prototype (fill, at, map, indexOf, join, toString, …), Uint8Array.from / of / fromBase64 / fromHex, BYTES_PER_ELEMENT, ArrayBuffer.isView, resizable / transferable buffers, a method read as a value, the other ten element types (Int8Array, Float64Array, …), DataView, SharedArrayBuffer | not-yet(STA1214, Phase 11) | not-yet(STA1214, Phase 11) | Refused by its qualified name (Uint8Array.prototype.fill is not yet supported); a constructor of another element type falls under the generic new refusal. They land as the T11.x corpus needs them -- the builtins_coverage.json namespaces Uint8Array, Uint8Array.prototype, ArrayBuffer and ArrayBuffer.prototype count every one as missing until it does. |
Symbol primitive [proposed] | not-yet(STA1212, Phase 5) | not-yet(STA1212, Phase 5) | Well-known symbols as values, Symbol() / Symbol.for / Symbol.keyFor, and the registry. [Symbol.iterator]() as a class method name is admitted for user-iterable for-of (Phase 5 step 8) without landing the primitive. |
BigInt primitive [proposed] | not-yet(STA1213, Phase 5) | not-yet(STA1213, Phase 5) | Separate numeric type with dedicated arithmetic. Deferred past MVP. The gate refuses a literal (1n) and every read of a value whose declared type may be a bigint (BigInt(5), a bigint parameter), in build and explain alike. A typeof u === 'bigint' narrowing of an unknown is not a use: no compiled program can produce a bigint to enter that branch. |
Numbers & operators
| Feature | ts mode | js mode | Notes |
|---|---|---|---|
number type, i32/f64 split per NUMERIC.md | static (i32 where range-provable; overflow to f64; all double by default) | static / dynamic | Typed numbers compile to machine integers and doubles; untyped numbers use dynamic boxing. |
string type | static | static / dynamic | Typed strings are JSString pointers; untyped strings use tagged jsrt_value. |
boolean type | static | static | Booleans always unboxed (1-bit tag in NaN-box). |
Arithmetic operators: +, -, *, /, % [proposed] | static (where operands are typed) | static if typed, else dynamic | Typed numeric operands use fast C arithmetic; untyped route via ToNumber() coercion. Statically-known primitive operands coerce inline ("5" * 1 is 5, true * 2 is 2, null * 2 is 0 — js-mode suppression of TS2362/TS2363, plan.md §8 step 37); composite operands reach ToPrimitive, which runs user code, and wait on Phase 8 (not-yet(STA1214, Phase 8)). |
Unary +, -, ~ (bitwise NOT), ! (logical NOT), void | static | static if operand is typed, else dynamic | void implemented (Phase 5 step 12 family b) and answers undefined. Negation and bitwise complement depend on operand type knowledge. |
Bitwise operators: &, |, ^, <<, >>, >>> [proposed] | static (apply ToInt32/ToUint32 per ES spec) | static if typed, else dynamic | Always apply spec-mandated integer coercion; >>> produces uint32, may need f64. |
Exponentiation operator ** | static | static if typed, else dynamic | Implemented (Phase 5 step 12 family b). Same helper as Math.pow (jsrt_math_pow); C pow is wrong on ±1 ** ±Infinity and 1 ** NaN. |
==, != loose equality | static where operand types decide it, else dynamic (full ToPrimitive coercion on the dynamic path) | same | Typed operands (primitives or disjoint types) compile to direct comparison; untyped route via jsrt_equal_loose(). Mode split on TS 2367 ("this comparison appears to be unintentional because the types have no overlap"): ts mode keeps it — both operand types are known and disjoint, so the comparison cannot be anything but a bug — and js mode suppresses it, because "" == 0 is not a mistake in JavaScript, it is the coercion table, and running that table is most of what js mode is for. Same judgement as 2339/2551/2353/2349, about an operator rather than a member (plan-notes 177). |
===, !== strict equality [proposed] | static | static | Unboxed comparison (pointer equality for objects, value equality for primitives). |
Comparison operators: <, >, <=, >= [proposed] | static where types allow, else dynamic | same | Typed primitives use C comparison; untyped route via ToPrimitive() and jsrt_compare_*(). |
Logical operators: &&, || [proposed] | static (control flow; short-circuit optimization) | static (same) | Compiled to conditional branches. |
typeof operator | static | static | Implemented (Task 3.5). Its own HIR node, not a UnaryOp: it coerces nothing (it is total on every value, where every other prefix operator runs ToNumber or ToBoolean) and its result is a string, where UnaryOp's is fixed to number or boolean. Not constant-folded — typeof asks the VALUE, and a value whose static type is number may still be an unchecked unknown underneath, which is the entire reason a guard is worth compiling. |
instanceof operator | static | static | Implemented (rung 6b; builtins in Phase 5 step 12 family b). The right operand is a class NAME or a built-in constructor (Array, Object, Function, Date, Map, Set, WeakMap, WeakSet, RegExp, Promise, Uint8Array, ArrayBuffer). User classes compare descriptor pointers; built-ins test a tag or a well-known JSRTClass. x instanceof (cond ? A : B) still has no descriptor to name. The left operand is anything at all — every primitive answers false. |
Optional chaining ?. | static (a nullish check around the guarded access; non-nullable bases lower to the plain access) | static / dynamic (a nullishable base guards; dynamic content stays dynamic) | Implemented (Phase 5 step 24). OptionalChain + OptionalBase HIR nodes; the nullish test short-circuits the rest of the chain, including computed keys and call arguments. A method value or call through ?. on a nullable single-class receiver dispatches statically against the non-nullish class (the chain guards the base). Any other method call on a class instance or a builtin receiver through ?., and an index into a string/number/boolean through ?.[], stay STA1214 (builtin string methods have no dynamic table yet; string indexing has no HIR node yet). Dynamic dispatch through Unknown landed (plan.md §8 step 45): o?.m and o?.m?.() where o is Unknown holding an object-literal method or class instance (including inherited) resolve through hidden #method: slots and descriptor method tables with receiver passing. |
Nullish coalescing ?? [proposed] | static (compiles to null/undefined check) | static (same) | Binary operator; RHS evaluated only if LHS is null/undefined. |
Conditional (ternary) ? : | static | static if branches are typed, else dynamic | Implemented (Phase 5 step 12 family b). ConditionalExpr; ToBoolean on the condition. |
Comma operator , | static | static if the right operand is typed, else dynamic | Implemented (Phase 5 step 12 family b). Evaluates left for effects, yields right. |
in operator | static | dynamic | Implemented (Phase 5 step 12 family b). jsrt_in; throws on null/undefined. |
Globals & builtins
| Feature | ts mode | js mode | Notes |
|---|---|---|---|
console (15 members): log info debug warn error dir group groupEnd count countReset assert table time timeEnd trace | static | static | One HIR node per call, not a property lookup on a console object -- there is no console value to look anything up on. CONSOLE_METHODS in packages/compiler/src/hir/nodes.ts is the single table the gate, the lowering, the verifier and the emitter all read: each member declares its arity, how many of its trailing arguments are optional, and the C entry point. log/info/debug/warn/error are variadic (plan.md §8 step 18): any width is static, Node's one-space joining is the runtime's jsrt_print_many/jsrt_eprint_many, and the one-argument call keeps its entry point; dir stays unary. Format placeholders landed (plan.md §9 Task 6.24, plan-notes 337): with two or more arguments and a string first, the variadic entry point runs Node's util.format loop — %s (a number keeps -0, another primitive is String(), an object inspects at depth 0), %d/%i/%f (Number/parseInt/parseFloat, -0 visible), %j (JSON.stringify, undefined for a function, [Circular] for a cycle), %O (inspect), %o on a primitive, %c (consumed, prints nothing) and %%; a % with no argument left stays as written, and arguments past the last placeholder are appended as before (goldens ts/console_format, js/console_format). %s of an object whose toString is not a builtin's -- its own, or one inherited from a class the program wrote -- is String(obj), which runs that method, and %d/%i/%f run an object's own valueOf/toString and answer a Date's time value (plan.md §9 Task 6.27, Node's hasBuiltInToString; subset_console_format_user_tostring_*). A case the printer cannot match is refused, never approximated: %o of an object (showHidden/depth: 4 output) and %s of a function or class (its source text) are not-yet(STA1214) when the format is a literal (subset_console_format_*), and abort with STA2005 when only the run sees the format; so does %s of an object whose inherited toString may have been written by a class named like a JavaScript builtin (Node inspects such an object instead of calling the method) when a capturing method leaves the runtime unable to tell which class in the chain wrote it. An omitted optional argument pads to undefined at lowering only where explicit undefined means what absence means -- true of count/countReset, false of group/assert, whose short forms get their own C entry point instead (Node prints console.group(undefined) as undefined and console.assert(c, undefined) as Assertion failed undefined, where the omitted forms print nothing). log/info/debug inspect onto STDOUT and warn/error onto STDERR -- Node's own split, which the golden runner holds to byte-for-byte by comparing both streams. dir is log's formatting WITHOUT its one exception, so a top-level string keeps its quotes. group/groupEnd maintain a runtime indent that prefixes EVERY line of a multi-line inspect, and an unmatched groupEnd is a no-op, as in Node. table (landed 2026-09-01) draws Node's box grid: a leading (index) column, one column per key seen across the rows in FIRST-SEEN order, and a trailing Values column for rows that are not objects. Cells are inspect form -- a string cell is quoted, where the index label is not, because a key is not a value -- a missing key leaves the cell empty rather than printing undefined, an ARRAY row contributes its indices as columns, and a value that is not a collection of rows falls back to log, which is Node's own rule. Padding is by DISPLAY width, so a CJK cell is two columns per character. Its layout is a pure function of the data, which is what separates it from the three below. Deferred: console.table on a Map or Set, which Node draws with an (iteration index) column -- and a Map with a second Key column -- a different table rather than a wider one (STA1214). Under the determinism carve-out (plan-notes 116, 124, 127), time/timeEnd/trace landed 2026-09-01 with a shape assertion rather than a golden test, because a duration measures this machine on this run and a stack is frames this runtime has no unwinder to produce. time prints nothing and keeps the ORIGINAL start when a running label is re-timed; timeEnd prints label: <duration> on stdout using Node's own unit ladder (ms below a second, s below a minute, m:ss.mmm above one) and prints NOTHING for a label that was never started, which is what Node writes to stdout for that case; trace prints Trace[: message] on STDERR without frames -- the same decision jsrt_uncaught already made, that inventing frames is worse than omitting them. Proof: tests/unit/console-carveout.test.ts (the label is echoed, a duration follows it, the unit is ms, more work measures longer, and the stream split holds). |
undefined | static | static | Not a global reference at all: it lowers to an undefined-literal. A user binding that shadows the name wins, exactly as it does at runtime. |
Math (exactly-specified operations): abs ceil floor round sign sqrt trunc pow min max clz32 imul fround; approximated operations acos acosh asin asinh atan atan2 atanh cbrt cos cosh exp expm1 log log10 log1p log2 sin sinh tan tanh; binary hypot; random; all 8 constants; globals NaN, Infinity | static | static | Implemented (Task 4.2). Number → number, ECMA-262 §21.3.2 exactly, with one jsrt_math_* C function per operation. The wrappers preserve the named ECMA/libm edge cases (round, sign/min/max, and pow special cases). Variadic min/max fold left into nested binary nodes; hypot accepts zero/one/two arguments with the specified degenerate forms and intentionally gates larger arities because it is not associative. The implementation-approximated operations use the vendored V8 fdlibm port, avoiding host-libm last-ulp drift. Math.random is nondeterministic by specification and is proved by range/distribution assertions in tests/unit/math-random.test.ts, not byte-for-byte golden output. Constants and NaN/Infinity fold to number literals. Coverage: tests/golden/builtins_coverage.json, rendered by pnpm run test:builtins. |
Number: n.toString(radix), n.toFixed(digits); Number.parseInt, Number.parseFloat; the 8 constants (MAX_VALUE, MIN_VALUE, EPSILON, MAX_SAFE_INTEGER, MIN_SAFE_INTEGER, POSITIVE_INFINITY, NEGATIVE_INFINITY, NaN) | static | static | Implemented (plan.md §11c T11.4, plan-notes 310). Each method is one number-op HIR node (NUMBER_OPS, verifier STA4103) over jsrt_number_to_string_radix or jsrt_number_to_fixed. The argument is padded with undefined, which reads as the spec's default (radix 10, zero digits). Radix 10 is ToString. Other radices port V8's DoubleToRadixCString: fraction digits stop at half an ulp of the input, and integer digits past 2^53 print as zeros. toFixed rounds the exact decimal value half-up, as §21.1.3.3 says, which printf's half-even does not. A radix outside 2..36 or a digit count outside 0..100 throws Node's RangeError, with Node's message. Number.parseInt and Number.parseFloat are the global functions themselves and lower to the same global-call. The constants fold to literals, like Math.PI. A 20 000-line differential (400 random doubles x every radix and 15 digit counts) matched Node byte for byte. Still not-yet(STA1214), refused by name: toPrecision, toExponential, toLocaleString, valueOf, Number.isInteger, isSafeInteger, isFinite, isNaN, and a number method used as a value. Fixtures subset_number_methods_*, subset_number_method_other_js, subset_number_static_other_ts; goldens ts/number_methods, js/number_methods. |
String.prototype (39 methods): charAt charCodeAt indexOf lastIndexOf includes startsWith endsWith slice substring split replace replaceAll repeat search normalize padStart padEnd trim trimStart trimEnd toLowerCase toUpperCase at codePointAt concat toString valueOf | static | static | Implemented (Task 4.2, second slice). A closed op table (STRING_OPS in packages/compiler/src/hir/nodes.ts) shared by the gate, the lowering, and the verifier — one vocabulary, three consumers. UTF-16 code-unit semantics throughout; a method is a callee only, never a value. Omitted optional arguments are padded with undefined literals at lowering, sound because ECMA-262 treats an explicit undefined as absent for every op in the set. replace/replaceAll implement GetSubstitution ($$ $& $` $'; $n stays literal without a RegExp) because Node honors it for plain-string patterns. repeat/padStart/padEnd throw a catchable RangeError for a negative or infinite count, or a result past the string length cap, matching Node byte-for-byte (plan-notes 200; they participate in the pending-check protocol). Variadic concat landed (plan.md §8 step 19): zero arguments answer the receiver, one is the single form, and more fold left into nested singles at lowering — pure concatenation, so no runtime entry point is involved. One honesty clause still raises STA2005 at runtime rather than answer wrongly: normalize with a form that is not one of the four (spec says RangeError; that op has not joined the throw protocol yet). Task 4.3 second slice: the RegExp-taking forms of split/replace/replaceAll are the SAME op node as their string forms -- the runtime dispatches on the pattern's tag, because a regexp pattern is a scan (repeated lre_exec) and a string one is a substring search -- and search joined the table, which has no string form at all (the spec builds a RegExp out of its argument, with a constructor this compiler does not have). GetSubstitution grew $n/$nn, capped at the pattern's group count so an out-of-range number stays literal. Task 4.3 third slice: toUpperCase/toLowerCase above ASCII and normalize now go through the vendored libunicode tables (packages/runtime/src/jsrt_unicode.c), in code POINTS rather than code units -- one code point can map to three (ß -> SS), and the Final_Sigma rule makes Σ lowercase differently at the end of a word than inside one. Task 4.4: localeCompare, toLocaleUpperCase and toLocaleLowerCase joined the table behind the ICU FEATURE BUILD -- collation is a per-locale order and tailored casing a per-locale exception, both of them CLDR data the default runtime does not carry. Without just runtime-intl + STATOR_RUNTIME=intl the gate refuses all three with STA1215, which names the flag rather than a phase; with it, they are accepted only with an EXPLICIT locale, because the absent-locales form reads the HOST's default and would make a compiled program's output depend on the machine that runs it. Task 4.1 (array-with-properties slice): match joined the table, dispatching to the engine like search does. matchAll lands as a boxed specialized iterator of match arrays (Phase 5 step 8, plan-notes 151): a /g regexp is required (STA2005 otherwise, the spec's TypeError); the matcher is cloned so the original lastIndex is untouched; a non-regexp argument stays deferred (RegExpCreate). Deferred members: split with a limit, a replacer FUNCTION (it runs user code per match), and the locales-as-array / options forms of the locale trio, which are Intl negotiation this compiler does not model. Coverage: same dashboard, where the trio's fixtures are intl_* and proven by pnpm run test:intl. |
Array.prototype (20 methods): push pop shift unshift at indexOf lastIndexOf includes join slice concat reverse fill forEach map filter some every find findIndex reduce reduceRight sort flat flatMap splice copyWithin findLast findLastIndex toReversed toSorted toSpliced toString with keys values entries | static | static | Implemented (Task 4.2, third slice). The non-callback surface, on the STRING_OPS discipline: a closed table (ARRAY_OPS in packages/compiler/src/hir/nodes.ts) shared by gate, lowering, verifier (STA4082), and emitter. includes searches with SameValueZero and therefore finds NaN where indexOf's strict equality cannot — the spec's own asymmetry. reverse and fill mutate in place and return the receiver; pop/shift/at are typed Unknown (the IndexAccess rule — an empty array really answers undefined). Undefined-padding applies except for lastIndexOf, whose absent fromIndex means length - 1 while an explicit undefined means 0 — it lands with one argument or two, the position form calling its own entry point. The variadic forms landed (plan.md §8 step 19): push/unshift take any count (zero answers the length unchanged), concat spreads each array argument and appends each non-array one as a single element (zero answers a shallow copy), and splice takes any count — zero spells the two-argument form over undefined, one deletes to the end on its own entry point, two remove only, and three or more remove the span and insert the run at its start. The callback methods (fourth-slice addendum, 2026-08-30) call back into compiled code through jsrt_call — the same closure ABI every compiled call site dispatches through, so no new protocol exists: each passes the spec's (element, index, array) triple, caches length at entry, and coerces predicate answers through jsrt_truthy (ToBoolean). map/filter keep the CHECKER's result type (result kind mapped) — map's element is the callback's to choose, and a type-guard filter legitimately narrows below the receiver's. The thisArg form is deferred for all of them, and the callback argument must be function-typed (an any callback in js mode defers rather than reaching jsrt_call unvetted). reduce/reduceRight land in their WITH-initial form (result kind checker: the accumulator type is whatever the callback and initial agreed on, nothing pinned); the zero-initial form is deferred because the first element becomes the seed there and an explicit undefined initial IS an initial — the forms cannot share an undefined-padded signature. sort is a stable merge (stability is normative; the scratch is a real jsrt array so a collector can see elements mid-merge), and an absent comparator equals an explicit undefined one — both spell the ToString default, [10, 9] staying [10, 9]; SortCompare's undefined rule sinks undefined elements to the end before any comparator runs. flat's depth pads safely (explicit undefined means the default 1); flatMap spreads an array answer one level and appends anything else; splice lands in every form (zero arguments spell the two-argument form over undefined, one deletes to the END on its own entry point because a padded undefined deleteCount would delete nothing — the lastIndexOf rule — and three or more insert the run at the span's start); copyWithin's whole range pads safely. keys/values/entries land as specialized iterators (Phase 5 step 8, plan-notes 150): a for-of operand inlines and allocates nothing; a stored iterator is a JSRTIterator whose next() answers { value, done }. Deferred: toSpliced keeps its two-argument form — the insertion variant that returns a new array. A spread argument calls the bound method with the built list (plan.md §11c T11.4 step 5), except for the callback ops and toSpliced, which stay not-yet. |
Array(n), new Array(...) and array holes: new Array(3), Array(1, 2), a write past the end, delete a[i] | dynamic (an Unknown element; a typed one is not-yet(STA1214)) | dynamic (a typed element is not-yet(STA1214)) | Implemented (plan.md §11c T11.4 step 4b, plan-notes 310). An absent element is the JSRT_HOLE box, which no program can produce (docs/VALUE.md §4.4). Every read hands it out as undefined; in, Object.keys, the callback methods, flat and sort skip it as the spec says; console.log prints <n empty items>. Only an array of an UNKNOWN element may hold one — a typed element has no value for "absent" — so Array(n) with a typed element (new Array<number>(3)) and delete on one (delete xs[0] with xs: number[]) are refused by name, and a typed write past the end still aborts STA2002. Array(n) is a global-call row: a number that is not a uint32 throws RangeError: Invalid array length, any other single argument is a one-element array, and zero or several arguments lower to the array literal. delete a.length throws Node's TypeError. Still not-yet: a literal hole ([1, , 3]) and a.length = n growing an array (STA2002). Fixtures subset_array_ctor_*, subset_array_delete_typed_js; goldens js/builtin_constructors, ts/builtin_constructors. |
Object.keys / Object.values / Object.entries | static (entries: dynamic) | static (entries: dynamic) | Implemented (Task 4.2, fourth slice). One runtime walk over the two object layouts: a fixed shape's keys are its class descriptor's field list (declaration order), a dynamic shape's are its shape chain (insertion order) — both ARE the spec's enumeration order, because identifier keys never trigger the integer-first reorder. keys is string[]; values keeps the checker's element; entries produces [string, T] pairs the HIR cannot type (no tuple), so its element is honestly Unknown and the verdict says dynamic. Arguments outside the two layouts (Maps, primitives) stay deferred — Node answers differently for each than an object walk would. Arrays and strings enumerate since plan.md §8 step 38: indices first per OrdinaryOwnPropertyKeys (then named extras on arrays), and the for-in desugar reads through the total forInKeys entry — objects, arrays and strings visit their keys, every other primitive visits nothing — while Object.keys off-layout stays a loud STA4084. Coverage: same dashboard. |
Object.getOwnPropertyNames / Object.hasOwn / Object.fromEntries | static (fromEntries: dynamic) | static (fromEntries: dynamic) | Implemented (Task 4.2, eleventh slice). getOwnPropertyNames IS the keys walk for every object the subset can build — both layouts hold only string-keyed, enumerable own properties, and the two entry points diverge only when non-enumerable properties become expressible. hasOwn asks the shape chain or the class descriptor directly: neither layout has a prototype the subset can reach, so “own” needs no second question; the key must be string-typed (a symbol or number is the ToPropertyKey the object model owns). fromEntries is the mirror of the walkers — it takes the ARRAY of pairs and BUILDS a dynamic shape, so its result is Unknown, its verdict is dynamic, and a duplicate key resolves the way the shape table already resolves one (last value, first position). The ObjectStaticCall node carries an argument LIST from this slice on, arity fixed per method by the gate's OBJECT_STATICS table and restated in the verifier. Coverage: same dashboard. |
Object.assign | dynamic | not-yet(STA1214) | Implemented (Task 4.2, thirteenth slice), two-argument form. The source's own enumerable string keys are copied onto the target through the same Object.entries walk, so the copy's order and Object.entries(source) can never disagree; an overwrite keeps the key's original position and only a new key appends; the return value IS the target. The TARGET must be a growable shape — one with an optional property or an index signature, whose lookups go through the shape table. A fixed shape's reads compile to slot indices decided at build time, so a key assign added could never be read back, and refusing the write is the only sound answer short of deoptimizing every literal into a shape table. That is also why the verdict is dynamic in ts mode and why js mode cannot reach it at all: growability comes only from an ANNOTATION, and inference from a literal always produces an all-required shape. The variadic form (three or more arguments) is deferred. Coverage: same dashboard. |
Object.freeze / Object.isFrozen | static | static | Implemented (Phase 5 step 11). A frozen bit on JSRTObject / JSRTDynObject; a write throws TypeError through jsrt_throw and generated C's pending check after the store. seal/isSealed still wait on a distinct [[Sealed]] bit. Builtin objects (Date, Promise, Map, …) are not this landing. |
Object.create | error(STA1001) | not-yet(STA1204, Phase 8) | lib.es5.d.ts types it any, which ts mode bans outright, so the ts-mode answer is the any diagnostic rather than a subset one. Its only in-subset spelling, Object.create(null) for a prototype-less map, asks for something every object here already is — neither layout has a reachable prototype. It is STA1204's prototype surface, not a Phase 4 gap (plan-notes 125). |
JSON.stringify | static | static | Implemented (Task 4.2, fifth slice), single-argument form. Recursive runtime walk (jsrt_json_stringify): scalars, strings with full JSON escaping (short escapes, \u00XX controls, lone surrogates as \udXXX — well-formed JSON.stringify — and UTF-8 for the rest), arrays, both object layouts via the Object.entries walk, class instances. -0 spells "0", NaN/Infinity spell null. Unserializable values (undefined, functions): skipped as object values, null as array elements — and REFUSED by the gate as the top-level argument, where the spec answers undefined against a string type. Cycles abort loudly at run time (STA2005 pattern). The throw mechanism exists as of Phase 5 step 11; converting this abort is follow-up. The replacer/space forms are deferred. Coverage: same dashboard. |
JSON.parse | dynamic | dynamic | Implemented (Task 4.2, tenth slice), single-argument form. Recursive-descent parser over the text's UTF-16 code units (jsrt_json_parse): JSON objects become dynamic-shape objects (duplicate keys: last value wins, first position kept), arrays become jsrt arrays, and the leaves are strings (escapes decoded, surrogate pairs preserved), numbers (strtod on the ASCII the grammar admitted), true/false/null. The result is typed Unknown — the text is data the checker cannot see into — so the verdict is dynamic and every use is a boundary the program checks (typeof narrowing or an as cast, the Task 3.5 machinery). In ts mode this means an ANNOTATED binding: const v: unknown = JSON.parse(t); unannotated, the lib's any return is refused as STA1003 at the declaration before the gate speaks. Malformed text, a key containing U+0000, nesting past 512, and a non-string argument reaching the call at run time each abort loudly (STA2005 pattern). The throw mechanism exists as of Phase 5 step 11; converting this abort is follow-up. The reviver form is deferred — it runs user code at every node of the result. |
Global functions: String(x), Number(x), Boolean(x), parseInt(s, r), parseFloat(s), isNaN(x), isFinite(x) as callees; Array.isArray(x); typeof of a language global; the property name of an object binding pattern | static | static | Implemented (plan.md §11c T11.4, plan-notes 310). No global object is read. The three conversions lower to the operation each one is: String(x) is a template hole (ToString, not the valueOf-first "" + x), Number(x) is unary +, Boolean(x) is !!x, and with no argument each is its constant ("", 0, false). The four number functions are one global-call HIR node each (GLOBAL_CALLS in packages/compiler/src/hir/nodes.ts, verifier STA4102) over the C entry points jsrt_global_parse_int, jsrt_global_parse_float, jsrt_global_is_nan and jsrt_global_is_finite; an omitted argument is undefined. parseInt rounds exactly for radix 10 (strtod over the digit run) and for the power-of-two radices (V8's single round-to-nearest-even), and follows V8's 32-bit chunked multiply-add for the rest, which the spec leaves implementation-defined past 20 digits -- so large results print as Node prints them. parseFloat converts its longest StrDecimalLiteral prefix through the same literal parser Number(s) uses. Array.isArray(x) is the builtin x instanceof Array (one realm, no Proxy: the two agree). typeof JSON, typeof parseInt and the rest of ECMA-262's globals fold to "object" or "function"; typeof Symbol keeps Symbol's refusal. const { length: n } = xs reads xs.length -- the name length resolves to a lib declaration but reads no binding. Array.from(x) is the jsrt_array_from row (plan.md §11c T11.4 step 5, docs/VALUE.md §4.23): an iterable drains like a spread, another object reads as an array-like (subset_array_from_*). Still not-yet(STA1214): a spread argument, an argument past the last parameter (Array.from's mapping function), a global function as a value, and every Array member but isArray and from, each refused by name. Array(...) and RegExp(...) as callees are their own rows. Fixtures subset_global_functions_*, subset_global_typeof_*, subset_array_isarray_*, subset_binding_property_name_*; goldens ts/global_functions, js/global_functions. |
Every other global: JSON, globalThis, encodeURI, escape, console as a value, and any global function read as a value | not-yet(STA1214) | not-yet(STA1214) | These need the global object. The catch-all now names Phase 5 (2026-09-01, plan-notes 136): Phase 4's builtin surface is complete, and what is left of the global set is Symbol and the iterator protocol around it, which is Phase 5 step 8. globalThis and Reflect are Phase 8's and Proxy is a ts-mode never, so neither moves the majority a catch-all has to answer for. Deferred at the gate, deliberately: the lowering builds bindings only for declarations it lowers, so accepting one produced STA4035 — an internal error, for legal source (plan-notes 61). Date and RegExp methods keep their own codes (STA1210, STA1211). Exception: String.fromCharCode(...codes) landed (plan.md §8 step 19) as a string-static HIR node (STRING_STATICS in packages/compiler/src/hir/nodes.ts, verifier STA4099) — one UTF-16 unit per argument, each ToUint16 of its value. Every other String member stays deferred by name; String(x) the converter landed with the global functions above. |
| Reference to an undeclared name, including the receiver of a property assignment | error(STA0012) | dynamic | Raises a catchable ReferenceError at the read. typeof missing alone answers "undefined"; typeof missing.a still throws. A property write reads its receiver before evaluating the key or RHS, unlike missing = rhs(), which evaluates the RHS before throwing. TypeScript's inferred namespace from missing.a = 1 is not a runtime declaration (Phase 5 step 2a(c), plan-notes 199). |
| A user binding that shadows a global name | static | static | The rule is "declared nowhere in the module", not "spelled like a global". |
Modules & imports (ESM only)
| Feature | ts mode | js mode | Notes |
|---|---|---|---|
import declarations (plain named) | static | static | Implemented (Task 3.11; per-module namespaces plan.md §11c T11.5a). The files of a program lower in topological order, each in a scope of its own: an import binds its local name to the exporting module's own binding (a live binding — the importer reads the same slot the exporter writes). Relative specifiers must name their file extension (Node's own ESM rule; extensionless is error STA1113). Bare (package) specifiers are not-yet(STA1214, Phase 7). |
A .js value reaching an annotated .ts binding, parameter or return of another type | error(STA1002) for the .js import; the all-.ts spelling is error(STA0012) | dynamic (boundary-checked) | Implemented (plan-notes 301, 308). The checker's TS2322 is suppressed in js mode, but a .ts variable annotated number, string or boolean keeps its type: the declaration and assignment edges wrap the value in jsrt_check_*, so a value that matches passes and one that does not raises STA2001 at run time. Before this, the binding was widened to Unknown and const n: number = label(10) printed 10. An annotation no tag settles (object, union) still widens. Plan-notes 308 extends the check to the call edge (TS2345 against a function, method or constructor parameter a TypeScript file annotated) and the return edge (TS2322 on a return or an arrow's concise body, when a TypeScript file annotated the return; async functions and generators excluded): inc(label(1)) printed 11 and function g(): number { return label(2) } returned "2". A .js callee's JSDoc keeps Node's coercion (golden js/argument_mismatch). Decision fixtures subset_annotated_binding_boundary_{js,ts}, subset_call_return_boundary_{js,ts}; golden js/boundary_inferred, js/boundary_call_return. |
Renamed/default/namespace imports (x as y, import d from, import * as ns) | static | static | Implemented (plan.md §11c T11.5a). A renamed or default import aliases the exporter's binding, so it is live and costs nothing. import * as ns binds the module's namespace object, and ns.x — through an import, an import() result in a binding, a nested ns.inner, a destructuring or a literal key — resolves at compile time to the export's own binding, so every such read is live. The object itself (what ns is when it escapes: printed, passed to Object.keys) is a snapshot built once, when the importer starts, and prints as a plain object, not Node's [Module: null prototype]. An exported class is no namespace member: reaching one through the namespace (new ns.C(), ns.C.s, x instanceof ns.C) reads a class object and stays not-yet(STA1214, Phase 5) — import the class by name. Fixtures subset_import_renamed_*, subset_import_default_*, subset_import_namespace_*. Two imports are SyntaxErrors in ES and are refused in both modes (plan.md §9 Task 6.29). An imported binding named eval or arguments is error(STA3005), raised before the gate and any bundle step. A default import from a module with no default export is TS1192 (error STA0012) in ts mode. In js mode it is also error(STA3004) when the module is a syntax-free .js ES module: TypeScript takes that file for CommonJS. Fixtures subset_import_binding_eval_*, subset_import_default_missing_*. |
export declarations (named) | static | static | Implemented (Task 3.11; renamed specifiers plan.md §11c T11.5a). export marks a binding the module already has; export { a as b } publishes it under a second name, which importers alias like any other. Fixtures subset_export_renamed_*. |
export default <expression> | static | static | Implemented (Task 3.11; any expression plan.md §11c T11.5a). export default function/class binds the declaration's own name (an anonymous default function binds ES's *default* and prints as default); export default <expression> evaluates the expression once into a hidden *default* binding, so a later write to a variable it named is not seen through the default import (ES semantics). An anonymous default CLASS stays not-yet(STA1214, Phase 5) like any anonymous class, and export = stays not-yet(STA1214, Phase 5). Fixtures subset_export_default_expression_*. |
Re-exports (export { x } from 'y', export *, export * as ns from) | static | static | Implemented (plan.md §11c T11.5a). A re-export is a graph edge and an alias of the target's binding, through any chain of renames. export * follows ES's ambiguity rule: a name two star re-exports bind differently is no export — the namespace leaves it out, and importing or re-exporting it by name is error(STA3003) in js mode (Node's link-time SyntaxError); ts mode refuses the second export * itself (TS2308, error STA0012). Fixtures subset_re_exports_*, subset_export_star_*. |
| Cross-file top-level name collisions | static | static | Implemented (plan.md §11c T11.5a). Every module keeps its own top-level namespace: two files may declare the same name, and a file's unexported names are invisible to the others (the dependency's binding takes a fresh internal name; diagnostics, stack names and printed class names keep the source spelling). Same-named generic functions specialize apart. The one holdout: a generic class whose name another module's class also uses stays not-yet(STA1214, Phase 11) — its specializations are keyed by the source name. Fixtures subset_cross_module_collision_*. |
import() dynamic import, literal specifier | static | static | Implemented (Phase 5 step 10; one namespace object plan.md §11c T11.5a). A module namespace is an HObject with namespace: true; literal import("./m.ts") is Promise.resolve of the module's namespace object — the same object import * as ns binds — and the specifier is a value-import edge so m's top-level has already run. Member reads resolve statically and are live, exactly as for import * as ns. Printing / Object.keys / the spec's Module exotic object are not this landing. Computed specifier stays not-yet (STA1207, Phase 8). |
Top-level await | static | static if typed, else dynamic | Implemented (Phase 5 step 9). The program's module bodies run as one async unit on Task 4.6's resume points: named bindings stay globals, temps live in a heap environment so they survive a suspension, and main starts that unit then drains the microtask queue. Init order is Task 3.11's topological order — Stator does not interleave sibling subgraphs the way Node does (docs/MODES.md). |
| Cyclic module imports | error(STA3001), source-located | error(STA3001), source-located | Implemented (Task 3.11). Cycles rejected outright; the diagnostic spells the cycle (a.ts → b.ts → a.ts). Never a silently-picked order: ESM gives cycles well-defined semantics only via live bindings + TDZ, which a whole-program initialization order cannot express honestly. |
A package import (import { pad } from 'leftpad', a bare specifier that is not node:*, a built-in or std/*) | not-yet(STA1214) | through the bundler: its exports are typed from the bundle, so static where that JavaScript infers, else dynamic | Implemented in js mode (plan.md §11d T12.1, docs/BUNDLER.md). The adapter (--bundler, default vite) bundles every package the graph imports into one virtual ESM module, __stator_vendor__.js next to the entry, and each project import declaration of a package is rewritten in place to import from it, every line kept. Named, default, namespace and side-effect imports, named re-exports (export { a } from 'p', export * as ns from 'p') and attributed imports (with { type: 'json' }) take this path. export * from 'p' re-exports the names the built bundle exports for p, for one such package per build (plan.md §11d T12.3; docs/BUNDLER.md §5). import('p'), export * from a second package and the deprecated assert attributes stay not-yet(STA1214), as does everything under --bundler=none. A diagnostic inside the bundle names the package's own file and line through the bundle's source map, or <package bundle> for a bundler helper. Package code is evaluated before every project module, not interleaved in import order (docs/MODES.md §5). |
Dynamic escape hatches (errors or not-yet)
| Feature | ts mode | js mode | Notes |
|---|---|---|---|
var declarations, hoisting | error(STA1104) | static (function-scoped, hoisted, initialized undefined) | var forbidden in ts mode (use let/const); in js mode, lowered to function scope with hoist semantics. |
arguments binding | error(STA1105) | not-yet(STA1202, Phase 5 stretch) | arguments forbidden in ts mode (use rest parameters); js mode support deferred due to lack of fast path for untyped arrays. |
eval() function | error(STA1101), permanent | not-yet(STA1206, Phase 8) | Permanent error in ts mode by design (no dynamic code generation). js mode defers to Phase 8 (interpreter tier with QuickJS-NG). |
new Function() | error(STA1103), permanent | not-yet(STA1206, Phase 8) | Permanent error in ts mode. js mode depends on Phase 8 interpreter tier. |
Proxy object | error(STA1106) | not-yet(STA1203, Phase 8) | Proxies intercept all property access via traps; no static analysis of their behavior exists. ts mode rejects; js mode defers to Phase 8 (interpreter tier). |
Prototype mutation: Object.setPrototypeOf(), __proto__ writes | error(STA1107) | not-yet(STA1204, Phase 8) | Prototype changes after object construction break compile-time shape assumptions. Forbidden in ts mode; deferred in js mode. This row is post-construction writes only: a { __proto__: v } DEFINITION in an object literal is the prototype setter, not a write, and compiles to the dynamic shape with no own property for the key (see Objects & classes). |
delete on a statically-shaped object (class field, fixed literal) | error(STA1108) | not-yet(STA1205, Phase 8) | The refused REMAINDER of delete; the dynamic-shape case landed (see Objects & classes). A fixed layout lists its fields at compile-time offsets and has no way to spell one as missing, so the field cannot go. ts mode forbids it — that layout is the mode — and in practice only a class field can reach the diagnostic, since TS2790 makes every other type-correct receiver dynamic. js mode defers to the Phase-8 dictionary-mode escape. An Unknown receiver that turns out fixed at run time aborts with STA2007. |
with statement, sloppy mode | error(STA1109) | error(STA1109) | ESM is always strict, and with is illegal in strict mode — so this is not a Stator restriction, it is the language's. |
CommonJS require() | error(STA1110), with or without --node | error(STA1110), with or without --node; under it a CommonJS file's require is the bundle's createRequire(import.meta.url) | Narrowed (plan.md §11c T11.5, plan-notes 316). ESM is the only module system in ts mode and without --node. Under --node a js-mode CommonJS project file — .cjs, .js under "type": "commonjs", or a .js with no ES-module syntax that reads require, module.exports or exports — never reaches this row: it goes to the bundler whole and joins the program as ESM (docs/BUNDLER.md §4, plan-notes 320, 315), and its require becomes createRequire(import.meta.url), a require over built-ins that throws MODULE_NOT_FOUND for anything else (docs/MODES.md §6). Without --node it is not routed and reaches this row. A free require that reaches it under --node is an ES module's, which Node does not define either; an ES module calls createRequire(import.meta.url) instead (both modes, goldens node_module). The rule is on a require no program declaration binds: a user's own function require is an ordinary function. Fixtures subset_commonjs_require_ts / _js and subset_commonjs_require_node_ts / _js (the four mode × platform cells). |
CommonJS module.exports / exports | error(STA1110), with or without --node | error(STA1110) without --node; with it a CommonJS project file goes to the bundler whole, and a read that still reaches the gate (beside ES-module syntax, or under --bundler=none) is error(STA1110) | Decided 2026-10-02 (plan-notes 315): --node gates CommonJS routing of project files. A free module.exports or exports (TypeScript's own CommonJS model of a .js file counts as free; a parameter or const named exports is the user's). typeof module / typeof exports is a probe, not a use, and passes. A CommonJS file that reads none of the three compiles as written. Packages under node_modules are bundled with or without the flag. A property, member, binding-pattern or label NAME spelled exports, module or require reads no binding and passes (subset_commonjs_names_ts / _js). Fixtures subset_commonjs_file_ts / _node_ts / _js (a .cjs); the js + --node cell needs an adapter and is proved in unit/bundler.test.ts. |
__filename / __dirname | error(STA1110), with or without --node | error(STA1110) without --node; with it the vendor module's reads are injected, and a read in an ES module is error(STA1110) | Landed (plan.md §11c T11.5, plan-notes 316). Node defines them in a CommonJS module only. Under --node a CommonJS file goes to the bundler, which leaves the reads free in the vendor module, and the frontend rewrites each into a run-time value relative to the executable: the binary's directory joined with the path of the file the read was written in (docs/MODES.md §6). No build-machine path reaches the binary. A read that reaches the gate is in an ES module, where Node throws ReferenceError, or in a CommonJS file the bundler did not take. Fixtures subset_node_filename_*, subset_node_dirname_* (four cells each); the injection is unit/bundler.test.ts. |
import.meta.url / .filename / .dirname | not-yet(STA1214) without --node; static with it | not-yet(STA1214) without --node; static with it | Landed (plan.md §11c T11.5, plan-notes 316). Under --node the three are where the module sits relative to the executable, resolved at run time: filename is the binary's directory joined with the module's path relative to the entry, dirname its directory, url its file: URL (docs/MODES.md §6). Other import.meta members are the checker's error. Fixtures subset_node_import_meta_* (the files read as dynamic because the helpers reach node:path's graph); goldens node_module. |
FFI — calling C from TS (Phase 7)
The extern surface contract lives in docs/FFI.md (plan §10 Task 7.1 steps
1–2, docs-before-code); the rows below are its feature × mode projection.
Steps 4–7 have landed: the declaration row compiles (direct C calls, error conventions,
the explain flag), opaque pointers cross borrow-only (step 6), headers and libraries ride
the @statorLink pragma plus --link= (step 7, docs/FFI.md §9), and every refusal row
is a live gate verdict with decision fixtures in
both modes. What still defers is extern-as-value and the optional call (STA1217), and
anything steps 5+ do not cover. Task 7.2 steps 1–2 have landed alongside: --emit-header
generates the reverse-direction header (docs/FFI.md §8), and its refusal rows are live
verdicts of the header step with unit tests (not decision fixtures — the header step runs
only when the flag is passed, which the decision runner never passes).
| Feature | ts mode | js mode | Notes |
|---|---|---|---|
Extern function declaration (declare + @statorExtern in a .d.ts) and direct calls to it | static (+ unchecked-boundary flag) | static + flag when every argument is statically typed, else dynamic + flag | Marker, C-symbol override, and .d.ts-only rule: docs/FFI.md §1. Dynamic arguments get STA2001 checks at the call (a check no handle needs — pointers cross unboxed, so a handle argument never contributes dynamic by itself). Branded-pointer (T*) signatures compile borrow-only since step 6: void * across, never dereferenced, never retained (docs/FFI.md §6). |
@statorLink pragma (link flags + #include) and --link= | link configuration, not code | link configuration, not code | One spelling per .d.ts (docs/FFI.md §9): flags append in program order, the header replaces the forward declaration with the real prototype, duplicates drop first-wins. Pragma flags are limited to -l<name>, -L<dir>, -framework <name> and -Wl,-rpath,<dir>; anything else goes through --link=. Malformed lines, a flag outside that list, a header path no #include can spell, stray pragmas, and second headers are error(STA1119). libm needs no pragma — -lm already rides every link. |
unknown in an extern signature | error(STA1114) | error(STA1114) | Would need boxing; refused rather than silently boxed (docs/FFI.md §2). |
| Object type in an extern signature | error(STA1115) | error(STA1115) | No C layout to pass. A C-owned struct is a branded pointer; text is CString. |
| Array type in an extern signature | error(STA1116) | error(STA1116) | A managed array is not a C buffer; spell pointer + length as ABI types. |
Uint8Array parameter in an extern signature | static | static if the argument is typed, else dynamic | Implemented (plan.md §11c T11.3a). One TS parameter, two C arguments: the view's bytes in place (uint8_t *, buffer data plus byteOffset) and its length (size_t), valid for the call, never copied (docs/FFI.md §2). Every call guards the layout read with jsrt_check_uint8array (STA2001 on a non-view), because a js-mode caller can reach an annotated .ts parameter with anything; a dynamic argument takes the lowering's check instead, which is what makes its file dynamic. Fixtures subset_extern_bytes_ts / _js / _dynamic_js; golden extern_bytes (1 MiB in one call); unit/extern-bytes.test.ts. |
Uint8Array as an extern return | error(STA1119) | error(STA1119) | Parameter-only: a returned buffer has no owner and no length in a C signature. The caller passes a view and the callee fills it. Fixtures subset_extern_bytesret_ts / _js. |
| Function/closure type in an extern signature | error(STA1117) | error(STA1117) | v0 has no trampoline; C calls in via Task 7.2 exports instead. |
Bare string in an extern signature | error(STA1118) | error(STA1118) | UTF-16 in, bytes out is never inferred; use CString (borrow) or CStringOwned (transfer). |
Any other type outside the ABI table (struct by value, T** spelled any way other than Out<brand>, void as a parameter, CStringOwned as a return, void over a value-returning C function) | error(STA1119) | error(STA1119) | The catch-all that keeps every diagnostic stable-coded (§1.3). A future widening splits a kind out with a NEW code. T** spelled Out<brand> left this row for STA1125 (see the Out<T> rows below). |
Variadic (printf-style) extern declaration | error(STA1120) | error(STA1120) | No sound signature — permanent, plan §10 out-of-scope table. |
Extern declaration outside a .d.ts | error(STA1121) | error(STA1121) | Keeps Task 7.3's generator output a drop-in; keeps the trust boundary greppable. |
Out<T> out-parameter call (slot from bare-global outSlot<T>(), passed to an Out<T> parameter, read via .value) | static | static | T** spelled Out<brand>, parameter-only (docs/FFI.md §2). The slot lives in locals and its address is rooted for the call. |
Out with a non-brand inner type | error(STA1125) | error(STA1125) | Only a branded pointer names a C address; anything else has no T** to pass. |
Non-slot argument to an Out parameter; slot passed to a non-Out parameter | error(STA1125), except the checker owns ts mode (STA0012, like extern arity) | error(STA1125) | Slots live only in locals, pass only to Out<T> parameters, and read only through .value. |
Dynamically-typed argument to an Out parameter | error(STA1125) | error(STA1125) | An out-param WRITES through the pointer, so unlike a T* read no runtime check can verify a slot address — only a proven slot is sound. |
Slot returned, stored in an object/array, or bound to an annotated non-Out name; unannotated aliasing | error(STA1125), except unannotated aliasing is static | error(STA1125), except unannotated aliasing is static | An escaped slot outlives the rooted call frame; a bare const s2 = s keeps the slot type and stays static. |
Exported Out value | error(STA1126) | error(STA1126) | An out-slot is a call-local rooted address that dies with the call, so it has no C-observable meaning across the boundary. |
Out<CString> tail-parameter shape | static | static | For const char**: the callee writes a borrowed string pointer, the runtime copies on read via .value, and NULL is guarded by the error convention. |
A CString value away from the extern edge: a binding or an arrow return inferred from an extern's CString return, an x as CString argument | static (a string) | static (a string) | Implemented (plan-notes 322). A returned CString was copied into a runtime string at the boundary and an argument is one until the call copies it out (docs/FFI.md §3), so the brand is a phantom only the extern signature reads. Its string SURFACE (.length, methods) is still not-yet(STA1214, Phase 5): the gate tests string-ness by the checker's StringLike flag, which the brand intersection lacks — assign it to a string first. |
stator explain marks extern calls as an unchecked boundary | yes (alongside the verdict) | yes (alongside the verdict) | The flag rides ALONGSIDE the verdict — externCalls: [{name, line}] in --json, an unchecked boundary line on the terminal. The four-verdict vocabulary (docs/MODES.md §6) is unchanged. A C return can never be runtime-checked (§0.2 asymmetry, docs/FFI.md §5). |
--emit-header on exported functions (Task 7.2 steps 1–2) | header + object | header + object | One table, two directions: a whole-signature-in-table function spells plain C types (stator_<unit>_<name>); any other position spells jsrt_value. -o names a relocatable object (clang -c); nothing links. Stubs and stator_init_<unit> arrive with steps 3–5. |
--emit-header on exported const primitives | header + object | header + object | number/boolean spell extern const double/bool, CString spells extern const char *, string/null/undefined spell extern const jsrt_value. |
Class, closure, generic, or re-exported export under --emit-header | error(STA1122) | error(STA1122) | Declaration shapes with no C spelling are refused, never half-emitted. |
Mutable or non-primitive exported state under --emit-header | error(STA1123) | error(STA1123) | let/var, destructured or uninitialized consts, consts of non-primitive type. |
Two exports colliding on one C symbol under --emit-header | error(STA1124) | error(STA1124) | A collision is a compile error, never last-writer-wins. |
std — systems library (plan.md §11c T11.2)
docs/STD.md is the contract. std/* is a reserved import prefix that resolves to the
first-party packages/std (STD.md §1): each module is strict TypeScript over Zig backings in
libjsrt_std.a, linked only into a program that imports one. A std module is ordinary source
once resolved, so an importer's verdict covers the std functions it pulls in, and explain
flags each backing call as an unchecked boundary (docs/FFI.md §5), exactly as for user FFI.
| Feature | ts mode | js mode | Notes |
|---|---|---|---|
import … from 'std/env' (has/get/set/unset/cwd) | dynamic | dynamic | get answers string | undefined, a union the HIR boxes; every other function is static. Fixtures subset_std_env_ts / _js; golden std_env. |
import … from 'std/path' (isAbsolute/basename/dirname/join) | static | static | Pure TypeScript, no backing; POSIX basename(3)/dirname(3) semantics (STD.md §5). Fixtures subset_std_path_ts / _js; golden std_path. |
import … from 'std/process' (argv/execPath/platform/arch/pid/ppid/hrtimeNs/memoryUsage/exitCode/setExitCode/exit/abort) | static | static | MemoryUsage is a class, like Stat. Fixtures subset_std_process_ts / _js and subset_std_process_n1_ts / _js; golden std_process; real arguments, a non-zero exit/setExitCode, abort and the per-run values in unit/std.test.ts. |
import … from 'std/fs' (readText/writeText/readBytes/stat/exists/realpath/utimes/readdir/mkdir/unlink/rmdir, descriptors open/read/write/close) | static | static | Sync; text is UTF-8, bytes are a Uint8Array (FFI.md §2 row); Stat is a class so it compiles static. Fixtures subset_std_fs_ts / _js and subset_std_fs_fd_ts / _js; golden std_fs. |
import { readTextAsync, … } from 'std/fs' (the Promise twins readTextAsync/writeTextAsync/statAsync/mkdirAsync/unlinkAsync/rmdirAsync) | not-yet (STA1214, Phase 10, T10.2) | not-yet (same) | They run on T10.2's thread pool; sync calls under async would block main (STD.md §2). Any other name std/fs does not export stays the checker's error (STA0012). Fixtures subset_std_fs_async_ts / _js, subset_std_fs_missing_ts. |
import … from 'std/time' (nowMs/sleepMs) | static | static | Fixtures subset_std_time_ts / _js; golden std_time. A js-mode importer is golden js/std_js. |
import … from 'std/os' (platform/arch/release/hostname/homedir/tmpdir/cpuCount/totalMemory/eol) | static | static | Each answer is the pinned Node's node:os answer on the same machine (STD.md §5). Fixtures subset_std_os_ts / _js; golden std_os. |
import … from 'std/io' (stdin/stdout/stderr, write/writeBytes/read/isatty/terminalSize) | static | static | Raw descriptor I/O; a Uint8Array crosses as its own storage (FFI.md §2 row, STD.md §6), so it keeps its NULs; TerminalSize is a class so it compiles static. Fixtures subset_std_io_ts / _js; golden std_io; console ordering, stdin and a pseudo-terminal in unit/std.test.ts. |
import … from 'std/encoding' (utf8ToBytes/bytesToUtf8, latin1ToBytes/bytesToLatin1, base64ToBytes/bytesToBase64, base64urlToBytes/bytesToBase64url, hexToBytes/bytesToHex) | static | static | Node's Buffer conversions (STD.md §5). Fixtures subset_std_encoding_ts / _js; golden std_encoding. |
import … from 'std/hash' (sha256/sha1/md5/randomBytes) | dynamic | dynamic | A digest takes Uint8Array | string, a union the HIR boxes; randomBytes is static. Fixtures subset_std_hash_ts / _js; golden std_hash; randomBytes ranges in unit/std.test.ts. |
import … from 'std/sync' / 'std/thread' | not-yet (STA1214, Phase 10, T10.2) | not-yet (same) | Needs the OS-threads ↔ async bridge (STD.md §5). Fixtures subset_std_thread_ts / _js. |
import … from 'std/<anything else>' | error (STA3002) | error (same) | The prefix is reserved: an unknown name — including the library's own std/internal/… and std/native/… — is never a package lookup. Fixtures subset_std_unknown_ts / _js. |
Node built-ins — the --node platform (plan.md §11c T11.5)
--node is a platform flag, orthogonal to the mode (docs/MODES.md §6). A Node built-in is
node:<id> for any public id of the pinned Node's builtinModules, or the bare <id> where
Node accepts one (path, fs/promises); a bare built-in is never a package (docs/BUNDLER.md
§1). Under --node both spellings resolve to packages/node/src/<id>.ts, strict TypeScript over
std, and compile like any other module. docs/NODE.md is the per-member coverage.
| Feature | ts mode | js mode | Notes |
|---|---|---|---|
A Node built-in without --node | not-yet (STA1214, no phase) | not-yet (same) | The message names the flag rather than a phase: the answer is a flag to turn on, not a release to wait for (support/phases.ts). Fixtures subset_node_builtin_ts / _js. |
A Node built-in under --node that packages/node has not landed | not-yet (STA1214, Phase 11, T11.6) | not-yet (same) | Under either spelling. So explain --node lists the platform's gaps beside the language's. Fixtures subset_node_unlanded_node_ts / _js. |
| A member the landed module does not export yet, but the pinned Node's module does | not-yet (STA1214, Phase 11, T11.6) | not-yet (same) | A member Node itself does not have stays the checker's error (STA0012). Proof: unit/node-platform.test.ts, against a stub package. |
A landed built-in under --node | as its source compiles | as its source compiles | Resolution is a paths entry on the program's own options, as for std/, so the checker, the gate and the module graph agree. Proof: unit/node-platform.test.ts builds and runs one through a stub package; the first real module is T11.6's node:path. |
import … from 'node:path' / 'path' / 'node:path/posix' under --node (basename/delimiter/dirname/extname/format/isAbsolute/join/normalize/parse/posix/relative/resolve/sep/toNamespacedPath, and the default export) | dynamic | dynamic | Landed (plan.md §11c T11.6). Node's POSIX semantics byte for byte, over std/env.cwd. Dynamic because the graph holds std/env (get answers a union) and basename's optional suffix is a union. win32 and matchesGlob are not-yet (STA1214, T11.6). Fixtures subset_node_path_node_ts / _js, subset_node_path_win32_node_ts; goldens ts/node_path, ts/node_path_posix, js/node_path. |
import … from 'node:fs' / 'fs' under --node (closeSync/existsSync/mkdirSync/openSync/readFileSync/readdirSync/realpathSync/rmdirSync/statSync/unlinkSync/utimesSync/writeFileSync/writeSync, Stats, Dirent, and the default export) | dynamic | dynamic | Landed (plan.md §11c T11.6, plan-notes 317). The synchronous subset tsc calls, over std/fs, with Node's system errors: code, errno (Darwin's or Linux's), syscall, path and the message <CODE>: <description>, <syscall> '<path>', byte for byte. Dynamic because the overloads take unions (an encoding or options object, number | Date) and a std failure is caught as unknown. Not landed: watch/watchFile/unwatchFile (the event loop, N2) and every other fs member (STA1214, T11.6); Buffer/URL paths; readFileSync without an encoding answers a Uint8Array until Buffer lands. Fixtures subset_node_fs_node_ts / _js; goldens ts/node_fs, js/node_fs. |
import … from 'node:assert' / 'assert' under --node (AssertionError/deepStrictEqual/fail/match/notStrictEqual/ok/rejects/strictEqual/throws, and the default export) | dynamic | dynamic | Landed (plan.md §11c T11.7). Node's codes, operators, custom messages and missing-exception messages; a generated message prints primitives only and carries none of Node v26's appended diff. Dynamic because every assertion takes unknown. Three subset limits show: the default export is an object, so assert(value) is not callable yet (a function with properties is STA1214); AssertionError does not extend Error (extending a built-in is STA1214); throws takes a RegExp, a validation object or a validation function, not a class. Fixtures subset_node_assert_node_ts / _js; goldens ts/node_assert, js/node_assert. |
Out of scope for v1
| Feature | ts mode | js mode | Notes |
|---|---|---|---|
.tsx, .jsx JSX syntax | error(STA1111) | error(STA1111) | JSX deferred; .tsx and .jsx files rejected in both modes. React and JSX-using libraries out of scope for MVP. (A .js file in ts mode is a different diagnostic: STA1002, which points at --mode=js.) |
| Decorators [proposed] | error(STA1112) | error(STA1112) | Decorators (TC39 stage 3) are a v1 non-goal in both modes. A never code, not a not-yet one: no phase currently promises them. |
Codes allocated by this document
docs/DIAGNOSTICS.md is the authoritative allocator (plan §4 Task 1.3): every code below has a row there with its exact message template. This section records which codes the matrix needed and why, not what they say.
"Never" class (errors by design, STA10xx/STA11xx)
All codes in this range reused from plan.md except the four below.
| Code | Feature | Mode | Meaning |
|---|---|---|---|
| STA1106 | Proxy | ts | Proxy objects prevent static analysis; forbidden in strict-static mode. |
| STA1107 | Prototype mutation (Object.setPrototypeOf, __proto__ write) | ts | Prototype changes after construction break shape assumptions; forbidden in strict-static mode. |
| STA1108 | delete on a fixed shape (in practice a class field) | ts | Deleting from a fixed layout requires runtime shape changes; struct layout is compile-time fixed. Dynamic-shape delete is accepted. |
| STA1112 | Decorators | both | A v1 non-goal in both modes; no phase promises them, so this is a never code. |
| STA1114 | unknown in an extern signature | both | Would need boxing; the v0 ABI table is primitives + branded pointers + CString by design (docs/FFI.md §2). |
| STA1115 | Object type in an extern signature | both | No C layout to pass; branded pointer or CString instead. |
| STA1116 | Array type in an extern signature | both | A managed array is not a C buffer; pointer + length instead. |
| STA1117 | Function/closure type in an extern signature | both | v0 has no closure trampoline; Task 7.2 exports are the supported call-in direction. |
| STA1118 | Bare string in an extern signature | both | The conversion is spelled (CString/CStringOwned), never inferred. |
| STA1119 | Anything else outside the extern ABI table | both | The catch-all (struct by value, misplaced void/CStringOwned, discarding returns). T** out-params split out to STA1125 when the table widened to Out<T> (docs/FFI.md §2). Future widenings split out NEW codes. |
| STA1120 | Variadic extern declaration | both | No sound signature — permanent (plan §10 out-of-scope table). |
| STA1121 | Extern declaration outside a .d.ts | both | Placement rule that keeps Task 7.3 generator output a drop-in. |
| STA1122 | Non-exportable declaration shape under --emit-header | both | Class, closure-valued const, generic function, rest/destructured parameter, export default, re-export and other non-declaration export forms (plan §10 Task 7.2 step 2). |
| STA1123 | Mutable or non-primitive exported state under --emit-header | both | let/var, destructured or uninitialized consts, consts of non-primitive type. Only const number/boolean/CString/string/null/undefined are exportable. |
| STA1124 | Two exports colliding on one C symbol under --emit-header | both | A collision is a compile error, never last-wins (plan §10 Task 7.2 step 7). |
| STA1125 | Out<T> out-slot used outside its contract | both | Slots are created by outSlot<T>(), live in locals, pass to Out<T> parameters, and read through .value (docs/FFI.md §2). Split out of STA1119 when the table widened to T**. |
| STA1126 | Exported Out<T> out-slot under --emit-header | both | A slot names a call-local address with no cross-boundary meaning (plan §10 Task 7.2 step 2). |
"Not yet" class (planned, STA12xx)
All codes in this range reused from plan.md except those listed below.
| Code | Feature | Mode | Phase | Meaning |
|---|---|---|---|---|
| STA1201 | Generator methods, async generators, for await | both | Phase 5 | function* declarations and unnamed expressions landed with step 8 (plan-notes 152). What remains is a receiver in the heap environment (methods), both machines at once (async generators), and the async iterator protocol (for await). |
| STA1202 | arguments binding | js | Phase 5 stretch | Untyped arguments arrays lack a fast path; support deferred. |
| STA1203 | Proxy | js | Phase 8 | Requires interpreter tier (QuickJS-NG) for trap interception. |
| STA1204 | Prototype mutation | js | Phase 8 | Interpreter tier allows runtime shape changes. |
| STA1205 | delete on a statically-shaped object | js | Phase 8 | Narrowed 2026-09-09: dynamic-shape delete landed; the dictionary-mode escape allows deleting from a fixed layout. |
| STA1206 | eval() and new Function() | js | Phase 8 | One code, because both land with the same interpreter tier and neither can ship without it. |
| STA1207 | import() with a computed specifier | both | Phase 8 | Literal-specifier half landed in Phase 5 step 10. Computed specifier needs runtime module resolution the whole-program model lacks. |
| STA1208 | Top-level await | both | — | Landed (Phase 5 step 9, plan-notes 155). No longer emitted. The module body is an async unit; init is Task 3.11's topological order, not Node's sibling interleaving. The code stays allocated so it is never reused. |
| STA1210 | Date residue: the ICU-dependent string forms | both | none (feature build) · Phase 5 for arity/spread | Slices A and B both landed 2026-09-01 (plan-notes 132, 133), so what is left under this code is exactly the members whose output needs ICU data: toString, toTimeString (the zone's long display name) and the three toLocale*. Nothing time-zone-dependent remains -- the local family landed under a TZ=UTC golden pin plus non-UTC runtime unit tests. Date.now() and zero-argument new Date() are ACCEPTED under the determinism carve-out, not deferred. The member residue carries no phase as of 2026-09-01 (plan-notes 136) -- a feature build is a flag to turn on, not a release to wait for, the STA1215 model -- while the code's arity and spread refusals are ordinary lowering work under Phase 5. |
| STA1211 | RegExp.prototype.compile | both | Phase 8 | Everything else under this code has landed: test() with Task 4.3, exec()/match() with Task 4.1's array-with-properties slice, and the eleven data properties plus toString with Task 4.2. compile is Annex B §B.2.4 legacy that RE-INITIALIZES an existing RegExp in place, which is the mutate-a-built-object surface Phase 8 owns with STA1204 -- not a builtin Phase 4 declined to write, which is why it moved off Phase 4 on 2026-09-01 (plan-notes 121, 136). Its variadic arity is a second reason the current op table cannot express it. String.prototype.matchAll landed with Phase 5 step 8 (plan-notes 151). |
| STA1212 | Symbol primitive type | both | Phase 5 | Well-known symbols as values, Symbol.for/Symbol.keyFor, and registry. [Symbol.iterator]() as a class method name is not this code. |
| STA1213 | BigInt primitive type | both | Phase 5 | Separate numeric type with dedicated arithmetic. |
| STA1216 | other Promise.prototype members; new Promise not arity 1 | both | Phase 5 | then/catch/finally and arity-1 new Promise landed in step 11. The code stays allocated for the rest. |
| STA1217 | Extern function declarations and calls | both | Phase 7 | The FFI surface code (plan §10 Task 7.1 steps 1–2). Steps 4–7 landed the lowering, the borrow-only pointer crossing (step 6), and header/link plumbing (step 7), so it now names only extern-as-value and optional-call positions — never the call itself. The STA1114–STA1121 refusals above are the design limits that stay never after it. |
STA1102 appeared in an early draft of this matrix for eval-in-js-mode. It is retired and must never be reused — it put a "not yet" verdict inside the STA11xx "never" range, which is exactly the confusion the two ranges exist to prevent. See the retired-codes table in docs/DIAGNOSTICS.md.
Notes and clarifications
- Decidability: Every row is testable via
stator explain --jsonon a minimal program exercising that feature. Pre-Phase-1-implementation tests carry// @expected-fail: trueand are reported (never hidden) by the test runner (§4 Task 1.4). - Boundary checks: All "dynamic" verdicts rely on
jsrt_check_*()functions at type-narrowing points. A runtime type mismatch is a located error, never silent corruption. - Specialization: Features marked "static if typed, else dynamic" route to the static fast path only when the TS compiler can infer a concrete type. Untyped code in
jsmode takes the dynamic path using taggedjsrt_value. - Phase clock: "Not yet" features are gated by the phase system in plan.md; out-of-scope features are permanently off v1's roadmap. No feature moves earlier than its phase without a plan amendment (plan §15.3).
- Decision boundary: The same feature may differ between modes because
tsmode rejects untyped code outright (error), whilejsmode lowers it to the dynamic path (tagged values, shape tables, inline caches). This is by design (plan §1).