Parent: #459 (JavaScriptCore drop-in compatibility)
Problem
Every failed call in zig-js produces one invariant string:
TypeError: value is not a functionJavaScriptCore names the callee and describes the value it actually held:
TypeError: b1.m is not a function. (In 'b1.m()', 'b1.m' is undefined)
TypeError: a1 is not a function. (In 'a1()', 'a1' is 1)
TypeError: this.#p is not a function. (In 'this.#p()', 'this.#p' is 1)This is not cosmetic. Error text is a real drop-in-compat surface: ported test
suites and library code match on it, and X is not a function is the most
frequently matched runtime TypeError in the ecosystem. A 54-case probe against
the JSC oracle (home-tool run) currently matches on 4/54 cases; every one
of the 50 misses is this single message.
new X() already matches JSC exactly (1 is not a constructor), so this issue
is only about [[Call]].
What JSC actually emits
The message is built from raw source text, not from a re-rendered AST. The
probe settles this beyond doubt — JSC preserves interior comments, newlines, and
original spacing, and slices the callee up to the ( rather than to the end of
the last callee token:
| source | JSC message |
|---|---|
z1 . m ( 1 , 2 ) | z1 . m is not a function. (In 'z1 . m ( 1 , 2 )', 'z1 . m ' is 1) |
z3/*c*/.m() | z3/*c*/.m is not a function. (In 'z3/*c*/.m()', 'z3/*c*/.m' is 1) |
(zr.m)(1) | (zr.m) is not a function. (In '(zr.m)(1)', '(zr.m)' is 1) |
f2.m?.() | f2.m is not a function. (In 'f2.m?.()', 'f2.m' is 1) |
So the two strings are exactly:
- callee text —
source[callee_start .. lparen_pos](?.(bounds it at the?.); - call text —
source[callee_start .. rparen_end].
The callee text is a prefix of the call text, so one slice plus a prefix length carries both.
Value description
'<callee>' is <description>, where description is:
| value | description |
|---|---|
undefined / null | undefined / null |
| boolean | true / false |
| number | ToString — 1, 1.5, 0 (for -0), NaN, Infinity, 1e+300, 1e-7 |
| BigInt | decimal digits, no n suffix — -1 |
| string | " + the raw string + ", unescaped (a"b prints as "a"b") |
| symbol | a Symbol |
| object | an instance of <name> |
The object <name> is JSC's calculatedClassName: a string Symbol.toStringTag
if present (Map, JSON, Math, Reflect, Generator, Array Iterator),
otherwise the name of the constructor found on the prototype
(Object.create({constructor: function Nope(){}}) → Nope), otherwise the
internal class name (Object.create(null) → Object). A Proxy reports
ProxyObject regardless of its target.
Why this is now cheap
The blocker recorded previously was "ast.Node has no spans". That framing was
too pessimistic: the parser already retains the source buffer for the whole
parse and function nodes already carry source: []const u8 slices into it for
Function.prototype.toString (Parser.sourceFrom). Retaining a call
expression's own slice is the same established pattern, not new machinery.
It is also free in memory: ast.Node is 80 bytes, sized by class_expr (72).
The call payload is 32; adding source: []const u8 + callee_len: u32 takes
it to 56, still under the existing maximum, so ast.Node does not grow.
Required implementation
ast.zig/parser.zig: retain the exact call-expression source slice and the callee prefix length onast.Node.call, bounding the callee at the((or the?.of an optional call) so trailing trivia is included as JSC includes it.interpreter.zig: thread the site into the[[Call]]boundary and render the JSC text.[[Call]]s that no CallExpression owns — builtin callbacks, the C API,Reflect.apply— keep a value-only form, which is what JSC does there too.bytecode.zig/compiler.zig/vm.zig: the same site must reach the VM tier or the two tiers disagree on message text, which is exactly the class of divergence a green corpus hides. A sparse instruction-indexed table consulted only on the throw path keeps the hot path unchanged.- Both tiers must be verified to produce identical text, and per-subtree
test262 --diagmust show no behavioural flips.
Out of scope (tracked separately)
(evaluating '...')on the nullish/not-an-object family — the same retained source unlocks it, but it is a different family.- Tagged templates, where JSC uses a value-first form with
(near '...').