Skip to content

Fix silent divergences: constructor returns, arr && arr[0], ES5 class names, builtin function values, uninitialized let writes, module-export this, 1 ** Infinity - #1351

Open
cramforce wants to merge 9 commits into
mainfrom
opt/silent-bugs
Open

cramforce wants to merge 9 commits into
mainfrom
opt/silent-bugs

Conversation

@cramforce

Copy link
Copy Markdown
Contributor

Each of these compiled and ran but behaved differently from Node, with no diagnostic. Each commit fixes one of them and adds corpus programs that pin Node's behavior. The corpus programs pass in the plain and sanitized lanes.

1. Constructors that return a value

new C() now follows Node's rule: when the constructor returns an object, new yields that object; when it returns a primitive or nothing, new yields this. Before this change the returned value was evaluated and then dropped, in TypeScript and JavaScript alike, whenever tsc accepted the class. For example, if (C.instance) return C.instance; built a second instance.

  • An instance of the class itself (singleton and cache patterns, typed C, or a JavaScript value that holds a C instance at run time): the return stores the instance in a per-class result slot. Every construction site reads and clears that slot through one helper, %C.%constructed. That covers direct new, class values, new this.constructor(), the checked dynamic construction dispatchers and record-to-class coercions.
  • Any other object (a record, a function, a plain object): a C instance cannot represent it, so the return refuses with SC1090. TypeScript gets the error at compile time. JavaScript gets it as a deferred runtime fence, like other statement fences. A JavaScript value typed only at run time is tested when it returns. A primitive yields this, an instance of the class takes the slot, and any other object fences.
  • super() into a class that can return an existing instance refuses with SC1090, because Node would rebind the subclass's this. A return inside try … finally uses the fence instead of the slot.

This builds on #1309: its lowerValueReturningConstructorNew still runs first for the narrow shape it handles, a member-less class whose constructor never reads this. This change covers every other shape. #1309's corpus programs still pass in both lanes.

2. arr && arr[0] with an array, object or function on the left

When the left operand of && was a plain array, object or function, and the result type was a union (list && list[0], where the element read may be undefined), the left operand was coerced into the result union. That coercion trapped at run time with "a 'number[]' value is not representable in the target union". Every object is truthy, so the expression now evaluates the left operand for its effects and yields the right operand. The this.xs && this.xs[1] method shape had the same cause and is fixed too.

3. Objects built by ES5 constructor functions printed without their class name

console.log(new F(2)) printed { a: 2 } where Node prints F { a: 2 }. Node names an object after the function held by the first own constructor data property on its prototype chain (getConstructorName). A construction through a plain function, in scr_dyn_construct, now stamps that prototype with the function's static name using the runtime class-name field the dyn printer already reads. Stamped prototypes stay alive and are cleared before release at exit. new F() boxes F under its name. The printer's code is unchanged, so only programs that construct through plain functions change. One gap remains: an Object.create(F.prototype) printed before any new F() prints plainly.

4. uuid 11: TypeError: native.randomUUID is not a function

Root cause: in JavaScript, a builtin module function used as a value without a function-value adapter was lowered to a string identity token ("[builtin crypto.randomUUID]"). uuid's native.js does export default { randomUUID }, so the record held a string. Calling it threw an ordinary TypeError, and typeof answered "string".

  • crypto.randomUUID and crypto.randomUUIDv7 now have value adapters. randomUUID's adapter calls the options-validating entry point through a new valueFn surface field. Boxed adapters carry the builtin's name, so they print [Function: randomUUID].
  • Any other builtin module function used as a JavaScript value is now a real function that throws the named SC1090 fence when called. typeof, identity (===, Set membership) and inspection match Node.
  • uuid 11.1.1 v4() now matches Node. v5() reaches the next layer, a named SC2001 fence for (string) => string values.

5. let f: T; f = () => … f(…) threw a TDZ error

A module-scope let with a function (pointer-guarded) type and no initializer kept the null-pointer TDZ guard on its writes. Its first assignment, which Node runs as an ordinary write because the declaration initialized the binding to undefined, therefore threw "Cannot access 'f' before initialization". Such bindings now get the existing initialization flag. The flag is set when the declaration runs, writes that can run earlier check it, and writes no longer test the empty pointer. A write that really runs before the declaration still throws. This is a different cause from the TDZ fix for dynamic let globals that are filled at module entry.

6. Module exports that read this, called through the module binding

m.f(), where m is const m = require("./x") or import * as m from "./x", lowers to a direct call of the exported function. A function that read this therefore saw no receiver, or another frame's, where Node passes the module object. exports.hello = function () { return this.name; } threw a TypeError natively, and typeof this answered "undefined" instead of "object". Both binding forms now refuse such calls with SC1090, deferred in JavaScript. Exports that do not read this compile as before, and the check runs only at compile time. The fences are pinned in tests/harness/errors.test.ts. Where the compiler has a real exports object (#1329), passing it as this would be the full fix; until then this is a precise refusal instead of a wrong receiver.

7. ** with a base of ±1 and a NaN or infinite exponent

1 ** x with x = Infinity printed 1 where Node prints NaN. The operator lowered to libm's pow, which answers 1 for pow(±1, ±Infinity) and pow(1, NaN); LLVM also folds pow(1.0, x) to 1.

This changes typed code. When the exponent is not a finite literal and the base is not a literal other than ±1, ** now emits an inline guard after the direct pow call: fabs(base) == 1 && !isfinite(exponent) selects NaN. That is two fabs, two compares and two selects; there is no call to scr_math_pow and no branch. It is a correctness fix, because Node answers NaN there. A finite literal exponent (x ** 2, x ** -1) or a literal base other than ±1 (2 ** k) cannot reach the case and keeps the bare pow call.

On the benchmark workloads the only gate difference is self/cli's transferPow in library/int-infer.ts. It computes a.hi ** b.hi over abstract ranges whose bounds can be 1 and Infinity, so the native compiler could compute a different range than the Node-run compiler; the guard fixes that too. In the corpus, one program with **= on dynamic operands changes. x ** 0, NaN ** 0 and the negative-zero cases already agreed with Node and are pinned in exponent-unit-base.ts.

Not changed

  • JS e.constructor.name on a caught runtime error prints undefined; Node prints TypeError, SyntaxError and so on. It overlaps Fix silent wrong output: object identity, Object.prototype values, Date parsing, stored builtins #1336, which adds inherited constructor reads on dyn objects. Re-check after Fix silent wrong output: object identity, Object.prototype values, Date parsing, stored builtins #1336 merges.

  • Writing e.name = … through an Error-typed JS reference into a subclass with a name setter is an internal compiler error (SC9001): loud, not silent. Reads through such references call the subclass getter correctly.

  • A regex groups object stored in a local printing without [Object: null prototype] was already fixed by Read named groups through optional chains and keep their null prototype #1299. Every probe matches Node. Sibling audit: Object.assign({}, groups), [groups], { groups }, matchAll groups and Object.create(null) all match. { ...groups } and Object.entries(groups) refuse with named compile errors.

  • Object literal key order versus interface order (reported during this work) is not fixed here. It is the known divergence class that SC6002 warns about. Records take the field order of their interned shape, which is the declaration order of the first type with that field set. JSON.stringify, Object.keys, for…in, console.log and spread all enumerate in that order. SC6002 warns when local flow shows the order is observed. An unannotated literal that interns into an interface's shape, such as const v = { name, id } when interface User { id; name } exists, diverges without a warning. A real fix needs a per-value key order for shapes whose literals are written in another order, plus every enumerating walker honoring it. That needs a separate design.

Sibling silent divergences found and not fixed

  • Async and generator functions print [Function: f] instead of [AsyncFunction: f] and [GeneratorFunction: f].
  • Circular JavaScript objects print expanded to the depth limit instead of <ref *1> and [Circular *1].
  • Object-literal methods ({ f() {} }, g: function () {}, arrows) print [Function (anonymous)] instead of their names.
  • JavaScript new Array(3) prints [ undefined, undefined, undefined ] instead of [ <3 empty items> ].
  • A dyn object holding a RegExp prints a partial line ({ p: }) before its "util.inspect of a dynamic RegExp" fence.

Console colors

Node colors console output under FORCE_COLOR and on TTYs; scriptc does not. That fix is not in this PR. It changed most console-printing binaries and cost about 2% on heavy number logging, so it waits for a zero-cost design.

Zero cost

I ran scripts/zero-cost-gate.mjs in a Linux Vercel Sandbox against origin/main aa9289b, with this branch merged onto it.

  • bench + self: 29 of 30 identical, all 29 benchmark workloads included. self/cli differs in one function, transferPow, from the ** guard in fix 7. Compile time: bench geomean +0.07% (CI −1.20% to +1.11%), self −0.25%.
  • corpus (TS and JS, 3077 programs): 3052 identical. The 25 that differ are all at constructs this PR changes:
    • 17 programs that construct through the dynamic path. scr_dyn_construct gained the prototype stamp from fix 3; their IR and LLVM are identical.
    • 7 JavaScript programs that box a builtin function value or a plain-function constructor, from fixes 3 and 4: 3073, 5178, 5185, array-buffer-storage, node24-file-url-errors, optional-native-callables, stored-native-builtins.
    • 1 program with **= on dynamic operands, from fix 7: compound-field-receivers.

Validation

  • Lint and format pass. pnpm test:ts7 --baselines-only passes; new fixtures only.
  • The new corpus and Compile chalk 5 through the dynamic object model #1309's corpus pass in the plain and sanitized lanes.
  • The wide differential subset passes: classes, constructors, console, inspect, prototypes, CJS and ESM, require, namespaces, TDZ and cycles; 667 programs. The 18 failures there also fail on main's build in this sandbox; Node 24.15 differs from the pinned version on them.
  • These suites pass on the merged branch: errors.test.ts, diagnostics.test.ts, chalk.test.ts, all backend llvm unit tests, and the runtime-pack and executable-cache tests; 850 tests.
  • Building the self-hosted compiler's cli.ts to IR produces no diagnostics on the merged branch. self-hosting-native-driver.test.ts passed on the branch before the merge.

🤖 Generated with Claude Code

cramforce and others added 8 commits October 18, 2026 14:42
A class constructor's `return expr` was evaluated and dropped, so
`new C()` answered the constructed instance even where Node answers the
returned object (`if (C.instance) return C.instance;` built a second
instance).

A return that yields an instance of the class itself stores it in a
per-class result slot, read and cleared by one helper at every
construction site (direct new, class values, the checked dynamic
construction dispatchers, record-to-class coercion). A return of any
other object refuses with SC1090 (compile time in TypeScript, a
deferred fence in JavaScript); a JavaScript value typed only at run time
is tested when it returns. super() into a class that can return an
existing instance refuses, since Node would rebind the subclass's this.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`list && list[0]` with an array, object or function on the left and a
union result (the element read may be undefined) coerced the left
operand into that union, which traps at run time ("a 'number[]' value
is not representable in the target union") where Node answers the
element. Every object is truthy: evaluate the left for its effects and
answer the right.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
console.log(new F(2)) printed `{ a: 2 }` where Node prints `F { a: 2 }`.
A construction through a plain function now stamps the first prototype
on the receiver's chain whose own `constructor` data property holds a
named function with that name, as Node's getConstructorName finds it.
The dyn printer already reads such runtime class names, so its code is
unchanged; stamped prototypes stay alive and are cleared before release
at exit. `new F()` boxes F under its name so the prototype's constructor
carries it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
uuid 11 threw `TypeError: native.randomUUID is not a function` where
Node works. Root cause: a builtin module function used as a JavaScript
value without a function-value adapter lowered to a string identity
token ("[builtin crypto.randomUUID]"); uuid's native.js stores it in an
exported record (`export default { randomUUID }`), so the call hit a
string and typeof answered "string".

crypto.randomUUID and randomUUIDv7 get value adapters (randomUUID's
through its options-validating entry point, the new valueFn surface
field), boxed adapters carry the builtin's name, and every other
builtin module function value is one function per builtin that throws
the named SC1090 fence when called, keeping identity, typeof and
inspection Node's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`let f: T; f = (n) => ... f(n - 1)` at module scope threw "Cannot access
'f' before initialization": a function-typed (pointer-guarded) binding
kept the null-pointer TDZ test on its writes, but the declaration
already initialized it (to undefined) when it ran. Such bindings now
carry the existing initialization flag: the declaration sets it, writes
that can run earlier check it, and writes no longer test the empty
pointer. A write that really precedes the declaration still throws.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…inding

`m.f()` where `m` is `const m = require("./x")` or `import * as m from
"./x"` lowers to a direct call of the exported function, so a function
that reads `this` saw no receiver (or another frame's) where Node passes
the module object: `exports.hello = function () { return this.name; }`
threw a TypeError natively. Such calls now refuse with SC1090 (deferred
in JavaScript); exports that do not read this compile as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`1 ** x` with x = Infinity printed 1 where Node prints NaN: the operator
lowered to libm's pow, which answers 1 for pow(±1, ±Infinity) and
pow(1, NaN) (and LLVM folds pow(1.0, x) to 1.0). Such operands now keep
the direct pow call behind an inline guard that selects NaN when the
base is ±1 and the exponent is not finite. A finite literal exponent
(x ** 2) or a literal base other than ±1 (2 ** k) cannot meet those
cases and keeps the bare call.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts:
#	tests/harness/errors.test.ts
@vercel

vercel Bot commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
scriptc Ready Ready Preview, v0 Oct 19, 2026 2:32am UTC

@vercel-security-reviewer

Copy link
Copy Markdown

Security review details

@cramforce
cramforce requested a review from ctate October 19, 2026 00:43
…it returns another one

A base constructor that returns an instance of its class gets a result
slot, and super() from a subclass refused. three.js's Color constructor
ends in `return this.set(r, g, b)`, where set returns this, so every
construction of Color4 (`super(r, g, b)` during three.webgpu.js's module
initialization) threw the refusal, while Node constructs the subclass's
own instance.

In JavaScript, super() into such a class now calls a per-subclass helper
after the base constructor: it reads and clears the base's result slot,
and refuses at run time only when the base returned an instance other
than the subclass's this, where Node would rebind it. TypeScript
subclasses keep the compile-time refusal.

This branch was successfully deployed

1 active deployment
Preview — 907d481a Deployed Oct 19, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants