Skip to content

Train lang 2: one identity for empty arrays, every argument through function slots, copy warnings from the lowered program, holed arrays in generic loops, typed promise resolution, built-in member writes refused where read natively - #3368

Merged
ctate merged 90 commits into
mainfrom
train/lang-2
Nov 8, 2026

Conversation

@cramforce

@cramforce cramforce commented Nov 7, 2026 •

Copy link
Copy Markdown
Contributor

Eight language fixes, most of them for programs that compiled and then printed something other than Node with no diagnostic, validated together in one round and landed as one branch. Each was proven by its owner on its own head; this is the eight merged on main, with their conflicts resolved once. A ninth member of the round, #3156, was merged by itself while the round ran and is main's now.

What lands

Not in this train: #3156, #3345, #3310 and #3169 (merged by themselves), #3150 (its gate proof needs the allow pattern \bawait\b, which would hide other members' rows) and #3053 (it conflicts with main in the runtime pack matrix and is a train by itself).

Conflicts and fix-ups on the train

Validation

All in Vercel Sandboxes. One full round on the train, then the checks on the tree that is pushed.

1. The full round (aa197fe248 = main 4e3f07164b plus the nine members, #3156 among them at the time), 49.0 minutes

  • Type check: pnpm -r build passes.
  • Lanes: plain 23,097 passed, sanitized 19,760 passed. What failed is not the train's, each with its control:
    • 108 cells of tests/harness/island-value-matrix.test.ts in both lanes: main's table is stale at that commit. The table regenerated on plain main 4e3f07164b is byte-identical to the table regenerated on the train (same sha256), so the train changes no cell; another train's round on a main of the same hour fails the same 108 cells, and a round on main 4a71162f4c fails none.
    • tests/harness/effect4.test.ts, cluster-entity.ts, sanitized lane only: the compile step hit the test's time limit (542 s) on a loaded lane box. Alone on one Sandbox (Intel Xeon 2.50GHz, 8 vCPU) the same test passes in 326.8 s on plain main and in 319.1 s on the train.
  • Native bootstraps: both pass. Plain: commands 1,251 s, rebuild 1,797 s. Sanitized: commands 1,581 s, rebuild-emit 2,577 s, rebuild-frontend 2,676 s.
  • Zero-cost gate, --gate-corpus=all, 5,580 programs: 5,389 identical, 121 fail to build on both sides, 45 allowed, no runtime-only row, no program of main's that differs outside the allow list, and no program of main's gained a dynamic-tier symbol. The gate also compiles the corpus programs a PR adds with main: 11 of the train's do not build on main, and 14 build and print something else, which is what they pin; the tool counts those 14 as "differs", so its verdict line reads FAIL and it stopped the self-compile and fixture shards. The gate-only round on the pushed tree (below) has them in the allow list by id and measures those shards.
  • Test262 (Function, JSON, Object, Promise, Proxy, Reflect; 10,396 variants): 0 regressed, 0 gained.
  • bench-runtime, 66 workloads: 66 neutral, geomean -0.3%.
  • Focused, on the same tree: the 29 corpus programs the train adds with their neighbours, differential.test.ts and llvm-differential.test.ts 82 of 82 plain, differential.test.ts 41 of 41 sanitized; 87 unit and harness test files, 1,258 tests; scripts/gen-builtin-object-members.mjs --check; docs gate 25 of 25.

An earlier round of the same train (17:26, with #3345, #3310 and #3169 still in it) is what found the two guard rows listed under fix-ups; it passed both bootstraps too.

Allow list

45 allowed rows; every row matches the list of the member that explains it. Executables of programs that exist on main change by -14.41% to +3.11% (one of #3288's own new programs, which main builds with the copying lift, is 45.17% smaller):

2. The pushed tree (258566b418 = that train merged with main fdf68fdf57)

Main gained fourteen merges after the round started (#3156 among them). Two merges of main into the train: cc77cb7a1b (main e7378e4fa1, no conflict) and 258566b418 (main fdf68fdf57; one conflict, ir/builtin-never-throw.ts, where main's #2832 and the f5 branch each add a row: both stay).

  • On cc77cb7a1b (Intel Xeon 2.50GHz, 8 vCPU): pnpm -r build (the type check) passes; pnpm generate:ir-codec, pnpm manifest, pnpm node-compat --local and scripts/gen-builtin-object-members.mjs --check change no generated file; pnpm lint, pnpm format:check, pnpm node-compat:check pass; pnpm test:ts7 --baselines-only: every fixture has a baseline. Main's guard tests and the members' unit tests (85 files, 1,282 tests) pass. The corpus programs the train adds, with their neighbours: differential.test.ts and llvm-differential.test.ts 76 of 76 plain, differential.test.ts 38 of 38 sanitized.

  • On 258566b418 (the pushed head): the build, the generated-file checks, lint, format and baselines again; the guard tests (builtin-attributes, outer-expressions, executable-cache, program-facts, function-references, the launcher's option test, copy-soundness): 97 of 97; the f5 program and main's existsSync programs: 5 of 5.

  • Zero-cost gate on 258566b418 against main fdf68fdf57, --gate-corpus=all (ended after the PR was opened): 5,416 identical, 123 fail to build on both sides, 62 allowed, 8 of the train's new programs that main cannot build, none that differs outside the allow list, no runtime-only row. The allowed rows are the members' lists above, the train's own new programs (main builds 29 of them and prints something else), self/cli, fixture/llvm-emitter and fixture/llvm-layouts (Give a function every argument of the final call, whatever slots it passed through #3263's recorded exception and Judge generic loops by each instance's array facts; reject missing elements in array conversions with a TypeError #3243's rows in the compiler's own source). The tool's verdict line reads FAIL by two size lines, both programs the train adds, compared with what main makes of them (8751-builtin-polyfill-guards-js.cjs +24.8%, 8765-fn-view-array-methods.ts +8.4%), and by one of four self-compile pairs (+2.76%). Compile time: benchmarks +0.90% [95% CI +0.39%, +1.75%], within tolerance; self-compile +1.49% [+0.27%, +2.76%] and fixtures +0.37% [-1.90%, +2.64%], which the gate calls inconclusive. The train adds 1.3% to the compiler's own source; compiling that source costs 1.9% more, with 1.0% more LLVM text and 1.1% more functions.

3. The review fix (3bf19d3309) and the head after it (74e48e3548 = main cb9665e11a merged)

The review bot found a silent difference in #3241's fold on this PR: a polyfill guard that compares a built-in member with void e was decided without evaluating e. if (Array.prototype.at === void note()) { … } under Node calls note(); the compiled program did not (reproduced on 258566b418: four note lines under Node, none compiled, in the if form and in the || and && statement forms). Fixed on the train: a void whose operand is not a literal counts as an operand that runs, so the statement form is not a dead polyfill and an if guard keeps the test's evaluation, where void with an effectful operand in value position is refused by name (SC1090), as everywhere else. void 0 decides the test as before. Pinned by tests/diagnostics/builtin-polyfill-guard-void-operand.ts (five shapes) and tests/corpus/builtin-polyfill-guard-void-literal.ts.

On 74e48e3548 (Intel Xeon 2.50GHz, 8 vCPU): the build (the type check) passes; the diagnostics snapshots, 337 of 337 with the new one; the built-in member programs with the new corpus program, differential.test.ts and llvm-differential.test.ts 20 of 20 plain, differential.test.ts 10 of 10 sanitized; scripts/checker-identity.mjs for the new program; main's guard tests 97 of 97; pnpm lint, pnpm format:check, pnpm test:ts7 --baselines-only, pnpm node-compat:check; no generated file changes. The merge of main cb9665e11a brings main's regenerated island value matrix; it had no conflict. A gate-only round on this head runs beside CI.

4. The second review fix, main's new refusal, and the head after them (4f6d08df91, which contains main 0f154df0e1)

The fold's rule, once (0d3aab12e6). The review bot's second thread on #3241's fold: if (!Object.getPrototypeOf([note()]).at) { Array.prototype.at = … } calls note() under Node and did not when compiled (reproduced on 74e48e3548 in eleven shapes, statement forms and new Map(note()) among them). Both threads are one question, answered by one predicate: a feature test is decided without evaluating it only when nothing in it runs. Going through every operand position found a third silent one: a const alias of a built-in object read before its declaration has run (function probe() { if (!proto.at) { … } } probe(); const proto = Array.prototype; throws a ReferenceError under Node and did nothing compiled). Where something runs, the guard of an if keeps its test's evaluation and a statement form is lowered as any other statement. An early-exit guard (if (X.m) return; in front of a polyfill) always evaluated its test, so it stays decided with an operand that runs (4f6d08df91). The other positions (a computed key, a comma, a call argument, the key of in, a call in front of .prototype) were never resolved by the fold; each was measured under Node and natively and is evaluated or refused by name. new X(…) always counts as running, so if (!Object.getPrototypeOf(new Map()).get) { … } is no longer decided: its test is evaluated and stops at the existing run-time refusal of that call, loudly, as it does on main. Pinned by tests/corpus/builtin-polyfill-guard-prototype-operand.ts, tests/corpus/builtin-polyfill-guard-operand-positions.ts (one case per position) and tests/diagnostics/builtin-polyfill-prototype-operand.ts.

CI's one failure on 74e48e3548 that was not main's: copy-soundness.test.ts. CI tests the merge with main, and main's #2761 now refuses a width copy the program writes through (SC2021). #3221's family has such a case, partial/declaration/increment (const n: T = w; n.a++ where T makes a field of w's type optional): a copy the family counted, a named refusal now. That is main's intended refusal meeting the train's test, not something that built on main and stopped building: no program of main's changes. The case joins the baseline's refused list and the counts are regenerated by the test's update mode: aliasable width copies 364 to 363, nothing else (715a3d473c).

Merges. Main 37323b08f8 (355145304d): no conflict, and it did not build: main added three calls of withoutParenthesesAssertionsNonNull to builtins/module-bindings.ts, a file #3302 moved to withoutParenthesesTypeWrappers; the three calls use the file's helper (4904372f64). The branch of #3371 (main's fix for its weak-collection tests and the runtime refusal baseline; on main since) as the same commits, and main 0f154df0e1 (5012504325), both without a conflict.

On 5012504325 (Intel Xeon 2.50GHz, 8 vCPU): the build (the type check) passes; pnpm generate:ir-codec, pnpm manifest, pnpm node-compat --local, scripts/gen-builtin-object-members.mjs --check change no generated file; pnpm format:check, pnpm node-compat:check, pnpm test:ts7 --baselines-only pass. Guard and unit tests, copy-soundness, the diagnostics snapshots (339) and the three test files #3371 fixes: 518 of 519. The built-in member and polyfill programs with the two new ones, main's record-width-alias and three members' programs: differential.test.ts and llvm-differential.test.ts 26 of 26 plain, differential.test.ts 13 of 13 sanitized. Two things fail, and both are main's at 0f154df0e1, with nothing of the train in them:

On the tree before the last merge of main (c2c4044afb with the two later commits' files): the members' unit and harness tests, 86 files and 1,302 tests; copy-soundness plain and sanitized, 12 of 12 each; the members' corpus programs, 84 plain and 42 sanitized; scripts/checker-identity.mjs for the polyfill programs. After that, one more commit (4f6d08df91: the early-exit guard, one predicate call and two fixtures), checked on its tree: the build; the 41 repro programs under Node and natively (each prints what Node prints or is refused by name); the diagnostics snapshots, 344 of 344, and the two polyfill fixtures sanitized; the built-in member and polyfill programs, 16 of 16 plain and 8 of 8 sanitized; copy-soundness and two guard tests, 35 of 35; scripts/checker-identity.mjs; format:check, oxlint, the ts7 baselines. Main has since fixed both of its failures (#3373), which CI's merge with main picks up. A gate-only round on 4f6d08df91 against main 0f154df0e1 runs beside CI; its result follows as a comment.

5. The third review fix: the fold's table (a332810c74), and the head after it (0463579800, which contains main 14960424a1)

The review bot's third thread on #3241's fold: Math.PI = Math.PI || 3.14 was erased as a polyfill that never runs. It does run, it assigns the member its own value, and in strict code that assignment is a TypeError when the member is read-only. Node throws; the compiled program did nothing.

That was the fourth thing the fold dropped that Node does (the void operand, the value behind a prototype lookup, an alias read before its declaration, and this). So the fold got the table it should have had from the start: every guard and assignment form it decides (17) by the member's attributes (9: a writable function, read-only data, read-only falsy data, an accessor, an inherited function, a missing member, a member of a frozen object, of a sealed object, writable falsy data), in strict (.mjs) and sloppy (.cjs) code: 306 programs, one per cell, each under Node and compiled, on the train and on main.

  • Before the fix (4f6d08df91): four silent cells, one class: X.m = X.m || f and X.m = X.m ?? f in strict code, over a read-only member and over a member of a frozen object.
  • The rule now: the fold erases a statement only where Node writes nothing, or where the write does nothing whatever the object's state. X.m ||= f and X.m ??= f never assign over a member that is there: erased in both modes. X.m = X.m || f, X.m = X.m ?? f and X.m = X.m assign the member's own value: erased in sloppy code over a data member the object itself holds (the write changes nothing or is ignored), lowered as the assignment it is in strict code, where the runtime answers as Node does. X.m = X.m is no longer refused as a replacement (SC1092).
  • After the fix: no silent cell, and no cell where main prints what Node prints and the train does not; seven cells where the train matches Node and main does not. Of the 306 cells, 223 print what Node prints, 2 are refused by name at build time, 73 stop with a run-time refusal (an accessor of RegExp.prototype, Map.prototype.hasOwnProperty and the like, on main too), and in 8 both sides throw the TypeError and the message names the object differently, on main too.
  • What it costs: in a strict source, String.prototype.trimStart = String.prototype.trimStart || function … is not erased and meets SC1090 (an assignment to a member of String.prototype has no lowering), as on main today. Refuse a write to a built-in member only where the program also reads it natively #3241's own program 8738 had that line in a module; it is gone from there and pinned where it runs: 8751 (sloppy, erased) and the new tests/corpus/builtin-polyfill-keep-value-assignment.mjs (strict, lowered) with its sloppy twin. Deciding it in strict code again needs the census to record freezes and redefinitions that keep the value (FOLLOWUPS.md).

Merges. Main ee6d7f7240 (fe192725fd), which carries train islands-1: one conflict, lowerer.ts, where islands-1 removed the stdioPatchSites field beside #3241's three census fields and #3221's loweredFunctions; the field and its two mentions are gone, the train's lines stay. Main 14960424a1 (0463579800): no conflict, no file in common.

On fe192725fd with the 8738 change (Intel Xeon 2.50GHz, 8 vCPU): the build (the type check); no generated file changes (generate:ir-codec, generate:ir-fields, manifest, node-compat --local, gen-builtin-object-members --check); pnpm lint (390 runtime refusal sites, none missing), format:check, node-compat:check, the ts7 baselines; guard and unit tests with the diagnostics snapshots, copy-soundness and main's formerly red files, 93 files, 1,343 tests; copy-soundness sanitized 12 of 12; the members' corpus programs, 96 of 96 plain (differential and llvm-differential) and 48 of 48 sanitized; scripts/checker-identity.mjs for the seven polyfill programs; the 306-cell table and the 41 repro programs.

On 0463579800, the pushed head: the build; no generated file changes; pnpm lint, format:check, node-compat:check, the ts7 baselines; the guard tests with copy-soundness, dyncheck and the diagnostics snapshots, 17 files, 621 tests; the built-in member and polyfill programs with five other members' programs, 34 of 34 plain and 17 of 17 sanitized; the 306-cell table again: no silent cell. A gate-only round on this head against main 14960424a1 runs beside CI; its result follows as a comment.

Zero-cost gate on 4f6d08df91 against main 0f154df0e1, --gate-corpus=all (the head before this fix; the fix changes only what the fold erases): 5,461 identical, 125 fail to build on both sides, 65 allowed, 8 of the train's new programs that main cannot build, none that differs outside the allow list, no runtime-only row. The tool's verdict reads FAIL by the same two size lines as before, both programs the train adds (8751 +24.9%, 8765 +8.4% against what main makes of them). Compile time: benchmarks +1.04% [95% CI +0.08%, +1.87%], within tolerance; self-compile +3.02% [+1.82%, +5.93%] and fixtures +0.91% [-0.76%, +2.58%], which the gate calls inconclusive.

6. Main's variadic rule meets #3263's function views (5b32646f38, which contains main 851b59b90f)

Three more trains landed on main (cli-5, startup-1, cli-4) and one earlier one (perf-2) brought a rule that meets #3263 on the same conversion: a variadic function (a rest function, a reader of arguments, a bound partial application) stored under a fixed signature is refused at the end of lowering (SC1090) when the program hands a function of that type to a caller that passes more arguments. The merge was clean in text and refused three of #3263's own corpus programs and one dyncheck fixture, whose views had made some of those stores answer as Node does.

Which rule holds was measured, not argued: 90 programs, 9 function kinds (untyped rest held by a constant, returned by a call, declared; a reader of arguments; typed rest three ways; optional parameters; a bound function) by 10 callers (a wider function type, a chain of two, a cast call, map, forEach, reduce and findIndex, through a function, a record slot, an untyped call, apply and call), each stored as (x: number) => number first, under Node and compiled, on three trees:

treesame as Noderefused at buildloud at run timesilent
main 952d9fb2be756027
this train before the merge (0463579800)41231214
this train merged with main186435
  • With main's refusal left out of the merged tree, six of main's eight pinned shapes are silently wrong ([3, 1, 2].map(one) prints 1,1,1): a store main's variadic adapter made is not a view. So the rule stays exactly as main has it: one rule decides a variadic store, and no compiler source changes on the train.
  • The five silent cells of the merged tree are silent on plain main with the same output (an untyped call loose(1, 2, 3) of a stored optional, typed-rest or arguments function, and apply of a stored arguments function). Nothing is silent here that main does not have, and 22 of main's 27 silent cells are gone. The five are written down for the untyped-call branch that follows this train.
  • The train's own 14 silent cells from before the merge are refused by main's rule now (9) or are the same five.
  • The price: 27 cells this train answered as Node does are SC1090 for now (a typed rest function, a bound function or an untyped rest constant stored under a shorter signature and then widened, cast, passed through a function, held in a record slot or mapped). Give a function every argument of the final call, whatever slots it passed through #3263's sentence "a function receives every argument of the final call" holds for functions with fixed and optional parameters and for bound methods. The shapes main refuses left Give a function every argument of the final call, whatever slots it passed through #3263's tests (8762, 8765, 8916, one dyncheck fixture); main's tests/diagnostics/variadic-store-longer-call.ts pins the refusal.
  • The three loud cells are Give a function every argument of the final call, whatever slots it passed through #3263's checked view: [3, 1, 2].map(one) over a function with three optional number parameters hands it the array as its third argument, which its parameter type excludes.

Merges. Main 952d9fb2be (d10eec1a8b): three conflicts, each two additions at one place, both kept (diagnostics/diagnostic.ts, diagnostics/render.ts, the import lines of frontend/program.ts). Main 851b59b90f (5b32646f38): ir/codec.generated.ts only, regenerated on the merged sources (pnpm generate:ir-codec).

On 5b32646f38, the pushed head (Intel Xeon 2.50GHz, 8 vCPU): the build (the type check); no generated file changes (generate:ir-codec, generate:ir-fields, manifest, node-compat --local, gen-builtin-object-members --check); pnpm lint (390 runtime refusal sites, none missing), format:check, node-compat:check, the ts7 baselines; guard and unit tests with the diagnostics snapshots, dyncheck, copy-soundness and the function-view tests, 93 files, 1,367 tests; the members' corpus programs with main's variadic programs, differential.test.ts and llvm-differential.test.ts 114 of 114 plain, differential.test.ts 57 of 57 sanitized. The Node island conformance lane in check mode (which CI skips for a PR that touches no island path) ran on d10eec1a8b, the merge with 952d9fb2be: four shards, 422 passed, none failed, the list untouched; it runs again on the pushed head. A full round on the pushed head (zero-cost gate, both lanes, both native bootstraps) runs beside CI; its result follows as a comment.

Not run

On the merged trees and after the review fixes: the native bootstraps, bench-runtime, Test262 and the full lanes (they ran in step 1; CI runs the lanes on the merge). Anywhere: the macOS lanes, the Node island conformance lane (no member touches island code), a pi build (the f5 branch and the narrowing were measured against pi by their owners).

🤖 Generated with Claude Code

cramforce and others added 30 commits November 6, 2026 22:25
… elements in array conversions with a TypeError

An array that can hold a hole or undefined, read by a for-of loop inside a
generic function, method or class, aborted with `scriptc: RangeError: array
index N is a hole` (or `is undefined`) where Node carries on or throws a
catchable error. The element-state analysis keeps its facts per element key,
a generic array has no key outside an instance, and the loop's decision was
stored once per syntax node, so every instance read with the proven getter.

- The analysis records, for each loop it judged present, the generic arrays
  the answer read, including those a slot that owns a copy was built from.
  When an instance lowers the loop, the same query runs with the instance's
  type-parameter bindings; a key that can hold a hole or undefined gets the
  checked read a concrete function already gets.
- At a call of a generic function, method or constructor, an argument key
  that can hold holes gives every array the call can reach, and its result,
  hole and undefined facts: the body may keep the holes under another
  element type or copy them as present undefined.
- The copying array conversion tests the element state when the source key
  can hold a missing element, and reads a dynamic source element as
  undefined. A target that cannot hold undefined throws a TypeError.

Co-Authored-By: Claude Code <noreply@anthropic.com>
… positions

A generic function that maps an array held in a field of its argument
(`box: { items: T[] }`) still returned the holes under another element type
with no fact recorded. The generic-call rule now enters the fields of a
record or class instance one level deep where the callee's declared
parameter or result type mentions a type parameter, and follows map and set
values.

Co-Authored-By: Claude Code <noreply@anthropic.com>
…he array-fact rules

A bare `v: T` argument gives the generic body no view of its fields, so the
generic-call rule no longer enters them: `publish(graph)` marked every array
field of the published object. A parameter typed by a type parameter whose
constraint is generic (`B extends Box<T>`) is still entered.

An implicitly typed JavaScript parameter is no longer a generic source for
the per-instance loop decision. Its instance lowers bindings the analysis
did not plan as optional, and a captured one has no lowering yet
(`7748-npm-any-bytes-receiver`). Those instances read as before.

Co-Authored-By: Claude Code <noreply@anthropic.com>
…a generic array was read

The generic-call rule read the "some key can hold holes" fact at every call
site, so the first holed key made every call site of the program stale. It
now decides whether the callee is generic first, and computes the reachable
keys only when some key can hold holes. A receiver's fields are entered only
for methods of a generic class or interface.

`elementsPresent` and `loopSources` judge an array a second time only when
the first judgement read a generic array.

Co-Authored-By: Claude Code <noreply@anthropic.com>
…neric ones

A generic function that only reads its array, or returns it, creates no
fact its caller does not already have: `first(xs)`, a visitor, a hole-punch
helper. The analysis now records the functions and classes whose code builds
an array from a generic array's elements or stores them into another array
(a spread, a copying method, a rest pattern, a push of a spread), directly
or by calling a generic function that does, and the rule fires only for
those callees and for callees with no body in the program.

Co-Authored-By: Claude Code <noreply@anthropic.com>
…e two program facts

A function held in a parameter is not a generic callee: `visit(child)` inside
a generic visitor made the visitor a builder of arrays, through the path for
callees with no body, and with it every generic helper that takes a
callback. Records reached through a callback's signature and functions held
in fields are no longer entered when collecting the keys a call can reach.

A loop source that has no element key even where the loop lowers is judged
as the loop was.

The generic-call rule and the "elements present" answer for arrays built
from generic ones are registered in program-facts.ts.

Co-Authored-By: Claude Code <noreply@anthropic.com>
… means Node's behaviour

A value converted to another record, array or class layout is copied where
JavaScript keeps one object, so a write through one name is lost and `===`
is false. SC6001 came from a scan of the syntax tree that knew five
spellings and looked for a write through the same binding: of 56 probed
forms that differ from Node it named 7.

The conversions are now read off the functions the lowering produced
(frontend/lowering/width-copies.ts). Every call of a copy helper is one;
helpers that only route a value hand their copies to the site that calls
them. A conversion is reported when the converted value is aliasable (not
a literal, a spread, another copy, or a value that only has a member read)
and the whole program observes either layout: it writes a field or element,
compares values of the copy's type by identity, or hands either to code
the scan cannot follow. That is a statement about types, so no path of the
copy escapes it; it can name a conversion on a path the program does not
take. No emitted code changes.

A record something else can reach, converted to a class instance in a
program that writes either type, is refused (SC1090): nothing keeps that
value one object, and no corpus program or pi site has one.

tests/harness/copy-soundness.test.ts builds 291 generated cases and
asserts that a case with no SC60xx warning prints what Node prints. Its
baseline holds the aliasable conversions per kind, which may only go
down, and the one case known to differ silently (a promise payload
converted at the checked boundary).

The corpus sweep in coverage.test.ts keeps "no divergence warning on a
corpus program" for every code but SC6001, whose counts per program are
in corpus-width-copies.json (58 programs of 4,743 entries).

scriptc build renders at most 25 warnings and counts the rest by code.

Co-Authored-By: Claude Code <noreply@anthropic.com>
The scan formatted the type of every field write, array write and
identity comparison it met, then kept the description only for the first
one under each key. A program writes the same fields many times, and the
compiler's own source formats large node unions. The description is now
built when the observation is recorded. The facts are unchanged.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Compiled code calls a built-in member's native entry point and never reads the member from its object. `Array.prototype.push = f` built without a diagnostic, in TypeScript and JavaScript sources, and `xs.push(1)` then ran the built-in: the binary printed `1,2,3 0` where Node prints `1,2,3 2`, both with exit status 0.

Existing members of built-in objects are now read-only. A write the build can see that replaces or deletes one is refused with SC1092: an assignment, a compound assignment, an update, `delete`, `Object.defineProperty`, `Object.defineProperties`, `Object.assign` or a `Reflect` call whose target is a built-in constructor, its prototype, a namespace object or the global object, through `globalThis`, a `const` alias, a literal key, a well-known symbol or the prototype of a value the source spells out. The refusal is a build error in JavaScript sources too, where a deferred fence could be caught and the built-in would keep running. A member the object inherits from another built-in counts, and so do `toJSON`, `then` and well-known symbol keys, which the runtime's own readers do not look up on a built-in's prototype object. Redefining a member with its own value and assigning `Error.stackTraceLimit` keep their lowering, and so does adding a member to `Array.prototype`, a constructor or a namespace object.

Adding a member to any other built-in prototype is refused with SC1093: only untyped arrays read a member from their prototype object, so `Map.prototype.added = 7` left `new Map().added` undefined where Node answers 7. Like other refusals it becomes a run-time fence in a JavaScript source.

"Existing" is a fact of the pinned Node release, generated by scripts/gen-builtin-object-members.mjs. Because an existing member cannot change, a feature test of one has one answer, so a polyfill behind `if (!Array.prototype.at)`, `typeof X.m !== "function"`, `X.m || (X.m = f)`, `X.m = X.m || f`, `X.m ??= f` or an early return lowers to nothing. The fold applies only where the code that never runs contains a refused write.

A write the build cannot see throws at run time: when a program reads a built-in prototype as a value (Array, String, Number, Boolean, Date, Map, Set, WeakMap, WeakSet, DataView), the object's members get the fenced bit the namespace objects already use. The bit is set at the value entry points, by a header helper that keeps no state outside the object, so a program that never holds a built-in prototype keeps its exact executable. Array.prototype has its own entry point for that (dyn.arrayPrototypeValue), because the method table also installs for programs that never hold the object.

Four corpus programs whose subject was replacing an existing member are removed and three are trimmed; their shapes are diagnostics snapshots now.

Co-Authored-By: Claude Code <noreply@anthropic.com>
…assed through

A function held at a type with fewer parameters than it has is a view: the
signature adapter that calls it with undefined for the parameters the type
lacks. A later conversion to a type with more parameters wrapped that view
in a second adapter, which dropped the arguments the first type lacks, so a
function with an optional parameter stored in a one-parameter slot and then
in a two-parameter slot never received the second argument. JavaScript has
one function object throughout.

When lowering ends, every adapter that makes a view of a function type is
known with the type of the function it forwards to. A widening adapter now
tests, per such view, whether its operand is that view (a comparison of the
closure's code) and builds its result from the function the view forwards
to, through the conversions the two adapters already are with the extra
parameters threaded through. Two IR expressions carry it: viewOf and
viewSource. A checked-dynamic callable held at a function type (a bound
function) is looked through the same way, boxing a view into a
checked-dynamic value boxes the function it stands for, and a function
asserted to a type with more parameters converts instead of erasing, so a
call through the assertion passes its extra arguments.

An extra argument the function's parameter cannot hold takes the stranded
TypeError when the wide view is invoked. Nothing changes where a function
is narrowed or called; a widening adapter changes only in a program that
holds a view of its source type that can be handed more.

Co-Authored-By: Claude Code <noreply@anthropic.com>
…a narrower slot

JavaScript calls every array-method callback with the element, its index
and the array. The method's helper passes the parameters the callback's
type has, so a function with an index parameter that came through a slot
declaring only the element never received it: items.map(cb) with
cb: (x) => string holding format(x, index?).

Each shared array-method helper (the one callback values use; a literal
callback has its own) records what it was built from. When its callback
type has a view of a longer function, the helper tests for that view and
calls the same helper built for the full parameter list with the function
the view forwards to. A helper with tests leaves the registry pipeline
fusion reads, so a fused chain does not skip them.

A function whose parameters past the element cannot hold what the method
passes, handed over where the checker cannot object, was refused as a
value "that cannot be compiled yet"; the refusal now names the parameter
and the rewrite.

Co-Authored-By: Claude Code <noreply@anthropic.com>
…ir gets none

Co-Authored-By: Claude Code <noreply@anthropic.com>
…member

The run-time fence on prototype objects a program holds, the refusal of members added to built-in prototypes (SC1093), of protocol keys and of copies from a source the build cannot read move to a change of their own, stacked on this one.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Both sides added a check at the start of finishModule (the refusal of a
record-to-class copy the program can tell, and #3097's refusal of a
source field delete); both stay.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Code <noreply@anthropic.com>
Two programs merged since this branch was cut hold a conversion the scan
reports: 63 programs, 251 warnings, of 4,804 entries.

Co-Authored-By: Claude Code <noreply@anthropic.com>
…at typed slots

`const o = { files: [] }; const f: string[] = o.files; f.push("x")` left
`o.files` empty: the field was the unit-element array and every typed slot
its value reached received a fresh array from the empty-array lift, so two
names for one JavaScript array were two arrays. No diagnostic, exit 0.

An empty array has one identity, so its element representation is now
decided once, where it is written:

- A property `name: []` (also `[] as const`) takes the array type of its
  contextual type, or the one the typed slots its value reaches agree on.
  The search (empty-array-places.ts) follows the value out of the literal
  that holds it, through unannotated bindings and function results, every
  read of the property in the program, aliases, conditional and logical
  operands, spreads, assertions through `unknown`, callback results and
  generic calls, until a declared type names the element.
- The copying lift is removed (`emptyArr`, `%arr.empty`). The empty literal
  itself still becomes its destination's empty array; any other
  unit-element array at a typed slot is refused by name, SC2015, with the
  fix in the hint. That is what remains when one array is used at two
  element types or reaches a slot through a path the search does not follow.
- A binding, a declared function's result, a function-valued binding, an
  alias property and a spread copy that the checker types `never[]`
  because they read such an array take the array's type instead of never's
  f64 element array.

Co-Authored-By: Claude Code <noreply@anthropic.com>
The per-program list broke for whoever merged second: three corpus
programs that carry SC6001 landed on main in the four hours this branch
was open, and each one fails the sweep until it is listed. The sweep
keeps asserting that a corpus program has no other divergence warning.
The conversions that copy and the warnings that were not needed are
counted by copy-soundness.test.ts, whose programs no other change adds
to.

Co-Authored-By: Claude Code <noreply@anthropic.com>
…replayed verdict, and bound the warning's type text

Three findings on the width-copy scan:

- A copy-helper result counted as fresh, so a value that reaches a third
  layout through a second (`w` to N to M in one expression, or through a
  function that returns its converted parameter) was reported only for
  the first conversion. A write through M was then lost with no warning
  when the program wrote neither N nor W. A copy of an aliasable value is
  now as aliasable as the value. (Vercel bot review.)
- The launcher replays an unchanged program's coverage verdict without
  loading the compiler. It hid SC6001 for --no-copy-warning and then let
  --fail-on=divergences pass, where the full CLI refuses the pair. It now
  leaves that pair to the full CLI. (Vercel bot review.)
- The compiler's own source carries several thousand SC6001, each naming
  up to three types in full: the self-hosting tests that return a compile
  result from a child overflowed the child's 16 MiB stdout. The message
  cuts a type's text at 120 characters, and the child's buffer is 64 MiB.

Co-Authored-By: Claude Code <noreply@anthropic.com>
No conflicts.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Code <noreply@anthropic.com>
A guard such as `effect() && !Array.prototype.at` is false whatever `effect()` answers, so the polyfill behind it is still dead, but `effect()` runs. The test was folded to a literal and the call was lost. A decided guard now lowers its test whenever an operand runs for its effects, and only the dead arm is dropped; a logical expression in statement position and an early-exit guard fold only when every operand that runs is a feature test.

Co-Authored-By: Claude Code <noreply@anthropic.com>
…ter at any depth

An untyped empty array in a generic function or class kept the array type
of the first instance when the slot's element only contained the type
parameter (`{ value: T }[]`), so later instances were refused. The answer
is no longer kept across instances for a literal inside a generic
declaration. Found by the Vercel bot on #3288.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Code <noreply@anthropic.com>
The family gained the case the review asked for (a value that reaches a
third layout through a second): two more record conversions that copy
an aliasable value, 364 in all.

Co-Authored-By: Claude Code <noreply@anthropic.com>
lowerer.ts: main settles the x.constructor identity helpers in the two
reachability fixpoints where this branch processes function views. Both
steps are kept, identity first; the loop ends when neither changed.

Co-Authored-By: Claude Code <noreply@anthropic.com>
A write or delete through `<T>process.env` changed a snapshot copy, not the environment: `isAmbientProcessEnv` looked through parentheses, `as`, `satisfies` and `!` but not the angle-bracket assertion. `require("node:path") satisfies T` was refused (SC1090) and `fork(path satisfies string)` was refused (SC2020) where the `as` spellings compile: the require and fork-target recognizers left out `satisfies`. The name in "x is not iterable" was lost behind `<T>x`.

Each of these wrappers leaves no trace at run time, so each site now looks through the same set as its `as` spelling.

Co-Authored-By: Claude Code <noreply@anthropic.com>
`function strict(): undefined { return loose(); }` with `loose(): any` returning 5 printed `undefined`; Node prints 5. A result declared `undefined` has the void representation, so the value was dropped without a look, while a binding declared `undefined` checks it.

A `return` and a concise arrow body now test an untyped value in a function whose declared result is `undefined` and throw a TypeError unless it is undefined. Functions declared `void`, typed operands and async functions lower as before.

Co-Authored-By: Claude Code <noreply@anthropic.com>
`class Proc { read = (x: number) => x + 1 }` named its arrow "" wherever the name was read from a boxed value: `const f: unknown = io.read; f.name` and `console.log(new Proc())` as `unknown` printed `[Function (anonymous)]`, where Node prints "read".

The lifted function now gets a displayName from the field's key (identifier, string, number or private name), as an object literal's method does, in modules that box a function without a static name.

Co-Authored-By: Claude Code <noreply@anthropic.com>
cramforce and others added 7 commits November 7, 2026 14:24
A polyfill guard was decided without evaluating its test when the test
read the member through the prototype of a value made for it:

    if (!Object.getPrototypeOf([note()]).at) { Array.prototype.at = ... }
    Object.getPrototypeOf([note()]).at || (Array.prototype.at = ...)

Node calls note() each time; the compiled program never did, with no
diagnostic, and `new Promise(note())` there threw under Node and not
compiled (#3241's fold, found by the review bot on #3368; the same family
as the `void` operand). The same held for a `const` alias of a built-in
object read before its declaration has run, where Node throws a
ReferenceError.

The rule is now one predicate, readRuns: evaluating the member access or
object a test reads can do something besides answer when it reads the
prototype of a value that is not an inert literal (any call, binding,
spread, computed key or `new X(...)` in it), or starts at an alias that is
not declared by an earlier statement of a list that holds the read. Every
place that decides a test asks it (member, typeof, comparison with
undefined, `in`, hasOwnProperty, `??`, `||=`, `X.m = X.m || f`): the guard
of an `if` keeps the test's evaluation, as `effect() && !X.m` already did,
and a statement form is no longer a polyfill that does nothing. An object
literal that names its prototype (`{ __proto__: null }`) is not an
Object.prototype object. Every other part of a decided test is a name or a
literal key: a computed key, a comma, a call argument or a call in front of
`.prototype` is not resolved, so the test is lowered as any other.

tests/corpus/builtin-polyfill-guard-prototype-operand.ts pins the shapes
that keep their evaluation, tests/corpus/builtin-polyfill-guard-operand-
positions.ts one case per operand position, and tests/diagnostics/builtin-
polyfill-prototype-operand.ts the statement forms that are refused by name
(SC1092) instead of being dropped.

Co-Authored-By: Claude Code <noreply@anthropic.com>
No conflict. Main's #2761 (SC2021: a width copy of a value the program
writes through is refused) meets #3221's copy-soundness family here: the
baseline follows in the next commit.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Main added three reads to module-bindings.ts (the awaited builtin dynamic
import) that peel with withoutParenthesesAssertionsNonNull. The train's
#3302 moved this file to withoutParenthesesTypeWrappers, which also peels
`<T>x`, and dropped the other import: the merge was clean and did not
build (TS2304). The three new reads use the file's helper.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Main's fix for its own red (the weak-collection island blocks of #3325
against #3135's single build of scr_json.c, and the runtime refusal
baseline), merged as the same commits. No conflict.

Co-Authored-By: Claude Code <noreply@anthropic.com>
…ns program

A guard the compiler decides records the member it read, and a live write
of that member in front of it is refused (SC1092). The program mixed both
over one member. Its undecided cases now write Array.prototype.findLast and
its decided cases read Array.prototype.findLastIndex; the cases and what
they print are the same.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Main's #2761 refuses a width copy the program writes through (SC2021).
The family case partial/declaration/increment is such a copy:

    interface S { a: number; b: string }
    type T = { a: number; b?: string };
    const w: S = { a: 1, b: "x" }; const n: T = w; n.a++;

It was a copy the family counted; it is a refusal now, so it joins the
baseline's refused list. Regenerated with SCRIPTC_UPDATE_COPY_BASELINE=1
on the merge with main 37323b0: the aliasable width copies go from 364
to 363 and nothing else changes; the family passes plain and sanitized.

Co-Authored-By: Claude Code <noreply@anthropic.com>
No conflict. Main now holds #3371, whose branch the train had merged: the
same commits.

Co-Authored-By: Claude Code <noreply@anthropic.com>
…d that runs

`if (Object.getPrototypeOf([note()]).indexOf) return;` in front of a
polyfill lowers as any other `if`, so its test is evaluated there and
note() runs; what the guard decides is only that the statements behind it
never run. The previous commit asked for a test with nothing to run here
as well, which turned this shape from a program that prints what Node
prints into a refused write. The guard now accepts an operand that runs,
as the guard of an `if` with a dead arm does.

The shape moves from the diagnostics fixture to the corpus program, where
it prints what Node prints, plain and sanitized.

Co-Authored-By: Claude Code <noreply@anthropic.com>
cramforce and others added 4 commits November 7, 2026 16:25
`Math.PI = Math.PI || 3.14` was erased as a polyfill that never runs. It
does run: it assigns the member its own value. In strict code that
assignment throws a TypeError when the member is read-only, or when the
program froze the object, and the compiled program did nothing (#3241's
fold, found by the review bot on #3368).

A table of every guard and assignment form the fold decides, by the
member's attributes (writable, read-only, falsy, accessor, inherited,
missing, on a frozen and on a sealed object), in strict and in sloppy
code, each under Node and compiled, has these as its only silent cells:
the two keep-value forms over a read-only member and over a frozen
object, in strict code. Whether a member is writable when the statement
runs is not a fact of the build (a freeze, or a redefinition that keeps
the value, is not a write the census records), so:

- In strict code the statement is no longer erased. It lowers as the
  assignment it is, and the runtime answers as Node does: nothing for a
  writable member, a TypeError otherwise.
- In sloppy code it is still erased, over a data member the object
  itself holds: there the write changes nothing or is ignored.
- `X.m ||= f` and `X.m ??= f` never assign over a member that is there,
  and stay erased in both.
- `X.m = X.m` over a data member counts as keeping the member's value
  too, so it is no longer refused as a replacement (SC1092); it lowers
  as on main, and is erased where the keep-value forms are.

After the change the table has no silent cell, and no cell where main
prints what Node prints and this branch does not.

tests/corpus/builtin-polyfill-keep-value-assignment.mjs (strict) and
builtin-polyfill-keep-value-assignment-sloppy.cjs pin the three
assignment forms over a writable member, a read-only member and a frozen
object, beside the logical assignments.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Main now holds train islands-1 (#3377).

Conflict, frontend/lowering/lowerer.ts: islands-1 removed the lowerer's
stdioPatchSites field and what reported it (a patched process.stdout.write
now works beside a Node island); #3241's three built-in census fields and
#3221's loweredFunctions sat beside those lines. The field and its two
mentions are gone, the train's lines stay.

Co-Authored-By: Claude Code <noreply@anthropic.com>
8738-builtin-polyfill-guards.ts is a module, so its
`String.prototype.trimStart = String.prototype.trimStart || function …`
is strict code: it is no longer erased, and an assignment to a member of
String.prototype has no lowering (SC1090), as on main. The form is pinned
where it runs: in sloppy code by 8751-builtin-polyfill-guards-js.cjs
(erased), in strict code by builtin-polyfill-keep-value-assignment.mjs
(lowered). What 8738 prints does not change.

Co-Authored-By: Claude Code <noreply@anthropic.com>
No conflict.

Co-Authored-By: Claude Code <noreply@anthropic.com>
@cramforce

Copy link
Copy Markdown
Contributor Author

Zero-cost gate on the pushed head 0463579800 against main 14960424a1, --gate-corpus=all (gate-only round, Vercel Sandboxes): 5,463 identical, 229 fail to build on both sides (the island programs, which the gate builds without a libnode pack on both sides), 67 allowed, 8 of the train's new programs that main cannot build, none that differs outside the allow list, no runtime-only row.

  • The allowed rows are the members' lists of the PR body, the corpus programs the train adds (main builds 34 of them and prints something else, which is what they pin; the five the review fixes added are among them), self/cli, fixture/llvm-emitter and fixture/llvm-layouts.
  • The tool's verdict reads FAIL by two size lines, the same two as in every round of this PR, both programs the train adds, compared with what main makes of them: 8751-builtin-polyfill-guards-js.cjs and 8765-fn-view-array-methods.ts.
  • Compile time, all within tolerance in this round: benchmarks +0.39% [95% CI -0.13%, +1.03%], self-compile +1.37% [+0.08%, +1.90%], fixtures +0.66% [-0.04%, +1.37%].

The type check of the round (the candidate's pnpm -r build on a fresh tree) passes.

@cramforce

Copy link
Copy Markdown
Contributor Author

CI on 0463579800: one red job, "test (Linux, plain, 9/20)", and it is a race in a program of main's, not the train.

  • What failed: tests/harness/llvm-differential.test.ts, 5547-fs-write-stream.cjs, the dev build: the program's last line printed unknown-encoding stream opened its file false where Node prints true. That line asks whether a write stream whose constructor threw (encoding: "buffer") has created its file; the open is scheduled before the throw and nothing in the program waits for it.
  • Not the train: no member touches the fs streams, the worker pool or that program, and the merge resolution in lowerer.ts removes a field main removed.
  • Measured, the program alone, in two Sandboxes (8 vCPU), the pushed head and plain main 14960424a1: the llvm-differential case (release and dev) 12 of 12 on each; the dev binary 800 runs sixteen at a time on each, last line true 800 of 800 and the whole output equal to Node's 800 of 800; the release binary 320 runs eight at a time on each, 320 of 320. It does not reproduce on either tree.
  • Seen before: the same program failed once in CI on another PR for the same unawaited open (its file appearing while the program's last line removes the directory).

I will re-run the failed job once the workflow run has finished.

cramforce and others added 3 commits November 7, 2026 18:23
Main now holds train cli-5 (#3386).

Conflicts, each two additions at one place, both kept:
- diagnostics/diagnostic.ts: cli-5's SC6008 and SC6201 diagnostics in
  front of the SC6001 function whose description #3221 rewrote.
- diagnostics/render.ts: #3221's WARNINGS_RENDERED and cli-5's
  renderStartupWarnings.
- frontend/program.ts: the import lines. #3302's
  withoutParenthesesTypeWrappers stays the file's peel helper, beside the
  two imports cli-5 adds.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Main's variadic rework (0d53262) refuses, at the end of lowering, a
variadic function stored under a fixed signature in a program that hands a
function of that type to a caller that passes more arguments (SC1090): a
rest function, a reader of `arguments`, a bound partial application.
#3263's views made some of those stores answer as Node does and its tests
pinned them; the rule now refuses those programs.

Measured on the merged tree (90 programs, 9 function kinds by 10 callers,
under Node and compiled): with the refusal left out, six of main's eight
pinned shapes are silently wrong, because a store main's variadic adapter
made is not a view. So the rule stays as main has it, one rule for a
variadic store, and the refused shapes leave 8762, 8765, 8916 and one
dyncheck fixture. No compiler source changes.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Main now holds trains startup-1 (#3350) and cli-4 (#3384).

Conflict: ir/codec.generated.ts only (both sides changed the IR). The file
is regenerated on the merged sources with pnpm generate:ir-codec (layout
dfa62312528c7983); ir/fields.generated.ts regenerates to itself.

Co-Authored-By: Claude Code <noreply@anthropic.com>
@cramforce

Copy link
Copy Markdown
Contributor Author

Full round on the pushed head 5b32646f38 against main 851b59b90f (Vercel Sandboxes, 54.2 minutes):

  • Type check: passes.
  • Lanes: plain 24,183 passed, none failed; sanitized 20,827 passed, none failed (95 jobs).
  • Native bootstraps: both pass. Job times, Sandbox start included: plain commands 19.8 min, rebuild 35.4 min; sanitized commands 30.4 min, rebuild-emit 33.3 min, rebuild-frontend 48.0 min.
  • Zero-cost gate, --gate-corpus=all: 5,497 identical, 288 fail to build on both sides (the island programs, built without a libnode pack on both sides), 68 allowed, 8 of the train's new programs that main cannot build, nothing that differs outside the allow list, no runtime-only row. The tool's verdict reads FAIL by the same two size lines as in every round of this PR, both programs the train adds, against what main makes of them (8751-builtin-polyfill-guards-js.cjs +24.7%, 8765-fn-view-array-methods.ts +7.5%). Compile time: benchmarks -0.08% [95% CI -0.83%, +1.17%] and fixtures +0.93% [+0.35%, +1.52%], within tolerance; self-compile +2.55% [+1.10%, +4.13%], which the gate calls inconclusive.
  • Node island conformance lane (CI skips it for a PR that touches no island path), check mode, four shards, on the same head: 430 passed, none failed, the list untouched.

Not run in this round: bench-runtime and Test262 (they ran in the round of section 1 of the description and on no later head).

@ctate
ctate merged commit 0fbc0c3 into main Nov 8, 2026
86 checks passed
@ctate
ctate deleted the train/lang-2 branch November 8, 2026 07:07
@cramforce

Copy link
Copy Markdown
Contributor Author

Landed through #3395 (the landing rollup, merge commit 3dddae7): this train's head 5b32646 is merged there as it was.

This branch was successfully deployed

1 active deployment
Preview — 5b32646f Deployed Nov 8, 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