Repository navigation
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
Conversation
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
Contributor
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
…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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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,newyields that object; when it returns a primitive or nothing,newyieldsthis. 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.C, or a JavaScript value that holds aCinstance 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 directnew, class values,new this.constructor(), the checked dynamic construction dispatchers and record-to-class coercions.Cinstance 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 yieldsthis, 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'sthis. A return insidetry … finallyuses the fence instead of the slot.This builds on #1309: its
lowerValueReturningConstructorNewstill runs first for the narrow shape it handles, a member-less class whose constructor never readsthis. 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 leftWhen 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. Thethis.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 printsF { a: 2 }. Node names an object after the function held by the first ownconstructordata property on its prototype chain (getConstructorName). A construction through a plain function, inscr_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()boxesFunder its name. The printer's code is unchanged, so only programs that construct through plain functions change. One gap remains: anObject.create(F.prototype)printed before anynew F()prints plainly.4. uuid 11:
TypeError: native.randomUUID is not a functionRoot 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'snative.jsdoesexport default { randomUUID }, so the record held a string. Calling it threw an ordinary TypeError, andtypeofanswered"string".crypto.randomUUIDandcrypto.randomUUIDv7now have value adapters. randomUUID's adapter calls the options-validating entry point through a newvalueFnsurface field. Boxed adapters carry the builtin's name, so they print[Function: randomUUID].typeof, identity (===, Set membership) and inspection match Node.v4()now matches Node.v5()reaches the next layer, a named SC2001 fence for(string) => stringvalues.5.
let f: T; f = () => … f(…)threw a TDZ errorA module-scope
letwith 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 dynamicletglobals that are filled at module entry.6. Module exports that read
this, called through the module bindingm.f(), wheremisconst m = require("./x")orimport * as m from "./x", lowers to a direct call of the exported function. A function that readthistherefore saw no receiver, or another frame's, where Node passes the module object.exports.hello = function () { return this.name; }threw a TypeError natively, andtypeof thisanswered"undefined"instead of"object". Both binding forms now refuse such calls with SC1090, deferred in JavaScript. Exports that do not readthiscompile as before, and the check runs only at compile time. The fences are pinned intests/harness/errors.test.ts. Where the compiler has a real exports object (#1329), passing it asthiswould 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 exponent1 ** xwithx = Infinityprinted1where Node printsNaN. The operator lowered to libm'spow, which answers 1 forpow(±1, ±Infinity)andpow(1, NaN); LLVM also foldspow(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 directpowcall:fabs(base) == 1 && !isfinite(exponent)selects NaN. That is twofabs, two compares and two selects; there is no call toscr_math_powand 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 barepowcall.On the benchmark workloads the only gate difference is
self/cli'stransferPowinlibrary/int-infer.ts. It computesa.hi ** b.hiover 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 ** 0and the negative-zero cases already agreed with Node and are pinned inexponent-unit-base.ts.Not changed
JS
e.constructor.nameon a caught runtime error printsundefined; Node printsTypeError,SyntaxErrorand so on. It overlaps Fix silent wrong output: object identity, Object.prototype values, Date parsing, stored builtins #1336, which adds inheritedconstructorreads 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 anError-typed JS reference into a subclass with anamesetter is an internal compiler error (SC9001): loud, not silent. Reads through such references call the subclass getter correctly.A regex
groupsobject 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 },matchAllgroups andObject.create(null)all match.{ ...groups }andObject.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 asconst v = { name, id }wheninterface 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
[Function: f]instead of[AsyncFunction: f]and[GeneratorFunction: f].<ref *1>and[Circular *1].{ f() {} },g: function () {}, arrows) print[Function (anonymous)]instead of their names.new Array(3)prints[ undefined, undefined, undefined ]instead of[ <3 empty items> ].{ p: }) before its "util.inspect of a dynamic RegExp" fence.Console colors
Node colors console output under
FORCE_COLORand 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.mjsin a Linux Vercel Sandbox againstorigin/mainaa9289b, with this branch merged onto it.self/clidiffers 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%.scr_dyn_constructgained the prototype stamp from fix 3; their IR and LLVM are identical.**=on dynamic operands, from fix 7: compound-field-receivers.Validation
pnpm test:ts7 --baselines-onlypasses; new fixtures only.errors.test.ts,diagnostics.test.ts,chalk.test.ts, all backend llvm unit tests, and the runtime-pack and executable-cache tests; 850 tests.cli.tsto IR produces no diagnostics on the merged branch.self-hosting-native-driver.test.tspassed on the branch before the merge.🤖 Generated with Claude Code