Skip to content

Train pi 1: lowering fixes on pi's path (generic identity, Proxy slots, shell arguments, stdin as a stream, dictionaries and unions) - #3352

Open
cramforce wants to merge 102 commits into
mainfrom
train/pi-1
Open

cramforce wants to merge 102 commits into
mainfrom
train/pi-1

Conversation

@cramforce

@cramforce cramforce commented Nov 7, 2026 •

Copy link
Copy Markdown
Contributor

One rollup of the lowering fixes on pi's path that were open as separate PRs. Each member is merged at its PR head with its own signed commits; nothing was rebased. The members stay open until this merges.

Members

Not here: #3338 and #3179 were on the seed list and merged to main by themselves before the branch was cut.

What the train itself changes

Validation of this tree

All in Vercel Sandboxes.

pi

Unedited pi (e770f5efe), no forced islands, each build alone on one 32-vCPU Sandbox (Intel Xeon 2.90GHz):

treelowering errorstimepeak RSS
main 875ed8ec56361,206.74 s10.6 GB
this train on that main (33653725e2)271,211.78 s9.7 GB

Ten positions leave: ai/src/providers/all.ts:141 (#3339), ai/src/utils/headers.ts:5 (#3274), mcp/resources.ts:54 and :55 and mcp/runtime.ts:449 (#3274), external-editor.ts:28 and utils/child-process.ts:35 (#3180), rpc-mode.ts:91 and :138 (#3101), and theme.ts:653:39, where #3167's refusal gives way to the next one on the same line (:653:52, the one new position). pi still stops at the end of lowering on this tree: the other 26 positions belong to other areas (computed import() specifiers, Object.assign forms, library functions as values, FormData, WeakRef, Worker entry points, a WebSocket subclass) and to the two refusals the project keeps. A pi binary and its suite therefore come from the integration tree, not from this PR.

Not run

  • The Darwin lane and the docs gate.
  • Lanes and native bootstraps again after the two test commits and the merges of main that followed the full round (23f1b176f3, 8f47512e31, 04b7d6b0bb, ef8d82ef7f); the focused runs above cover what those touched. tests/harness/coverage.test.ts's whole-corpus case ran in the full round's lanes; on the later head, alone on an 8-vCPU Sandbox, it ran into its 600 s limit (a timeout, no failed assertion) and was not repeated on a larger machine.
  • The edited pi and its 137-case suite: the edited pi does not lower on main plus this train alone.

🤖 Generated with Claude Code

cramforce and others added 30 commits November 6, 2026 14:25
…n identity

A generic `function` declaration inside a function now compiles through the
function-scoped generic machinery that already compiles a generic `const`
arrow inside a function: its instances are module functions, the frame's
captured values travel as trailing parameters (or through a boxed closure
environment when the frame reassigns one), the declaration statement emits
nothing, and hoisting needs no guard because the declaration answers for a
call that comes first. The frame fences still refuse by name what the
machinery cannot carry: the declaration taken as a value, a capture whose
type depends on an enclosing type parameter, and `this`, `super` or
`new.target`.

Comparing a generic function against a value-typed operand answered false
natively where Node answers true: the comparison pinned the generic to the
operand's signature, so two uses of one declaration compared unequal. Each
declaration now has one identity root. Every instance of a declaration held as
a value at more than one type adopts the first instance as its identity root
(`identityRoot`/`identityLength` on `IrFunction`), which the runtime's
existing shared-root rule in `scr_closure_identity_equal` already honors for
`===`, Map keys, `indexOf`/`includes` and JSON property reads. The pin
machinery (`checkGenericIdentityPins` and the five pin records feeding it) is
deleted. A comparison against a held value still pins the operand to that
value's signature, and because the instances share one root it no longer
matters which instance answers.

IR: `IrFunction` gains `identityRoot` and `identityLength`; the closure
initializer's optional trailing fields carry them, so programs with no such
declaration emit byte-identical IR, LLVM and executables. Codec regenerated.

Tests: corpus 8721 (nested declaration shape from pi's rpc-mode: two
instantiations over a reassigned counter and a Map, a hoisted call, recursion,
sibling closures, `arguments.length` beside a trailing-parameter capture) and
corpus 8724 (identity roots across two signatures, `F | undefined` operands,
instantiation expressions, Map keys, `indexOf`/`includes`/`lastIndexOf`,
`Object.is`, module namespaces), both differential against Node; diagnostics
fixtures for the two refusals that remain; the three pin-shape refusal
fixtures and their snapshots removed.

The diagnostics corpus snapshot for tests/diagnostics/generics.ts drops the nested-declaration SC1090 clause (its join refusal stays), and generic-specialization-boundaries' per-module-location test now pins the refusal that remains — the declaration taken as a value — where it used to pin the declaration itself, which now compiles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A Proxy is a checked-dynamic value. A slot typed as a class holds native
instances only, so `show(new Proxy(...))` with `show(p: Palette)` built
without a diagnostic and threw "expected Palette at $, got object" when
the statement ran. A slot typed as an interface or object type took a
copy of the properties, read through the traps once: later writes to the
target and writes through the slot were lost, silently.

The lowering now refuses a Proxy it can see entering either kind of slot
(as an argument, a field, a return value, an element, an assertion, or
through a `const` binding that holds one) with SC1090 and a hint that
says what to write. A binding a Proxy initializes whose type is a class
is refused at its declaration, because a class method the trap returns
runs with the Proxy as `this` and fails its receiver check. A Proxy whose
handler is the empty literal adds nothing to its target, so a record
slot converts it as it converts the target, as before.

A binding typed as an interface keeps holding the Proxy itself. That was
true for module-level bindings and `var`; function-local `const` and
`let` copied, and now hold the Proxy too. A target written as an
asserted object literal (`{} as Config`) is an untyped object, as in
Node, instead of a typed record whose traps the runtime refuses or, for
a class, an assertion that throws.

The limitations page gets a Proxy entry.

Co-Authored-By: Claude Code <noreply@anthropic.com>
`if (options?.transform) headers = await options.transform(headers)` was
refused with SC1090 "method calls like 'options.transform'" when `options`
is a union of records: a generic function's options parameter bound to the
union of every per-kind option record, each declaring the member as the
same optional function. The record-field call answers a union receiver
only when every arm holds the member as a bare function, and the dynamic
dispatch declines these records, so the call fell through to the fence.

When every arm is a record that declares the member with one and the same
`F | undefined` type, and the checker narrowed the member to `F` at the
call, the call now reads the member with the shared-field union read,
extracts the function arm the narrowing proved, and calls through the
function value, as the single-record receiver's optional callback does.
It is tried last, after every other lowering declined, so no call that
compiles today takes it. Arms that disagree on the member keep the fence.

Corpus: tests/corpus/union-receiver-optional-callable.ts.
Diagnostics: tests/diagnostics/union-receiver-callable-members.ts.

Co-Authored-By: Claude Code <noreply@anthropic.com>
`const { _meta, ...rest } = item` inside `listed<T extends Base>(item: T)`
copied only the constraint's fields: with `T` bound to a wider record,
`JSON.stringify(rest)` printed `{"name":"w"}` where Node prints
`{"name":"w","extra":1}`. The checker types the rest as `Omit<T, K>` and
lists that alias's members through the type parameter's constraint, so the
packed record missed every field the binding adds. When the constraint
declares nothing but the consumed keys the type had no mapping at all, and
the binding was refused with SC1031 ("rest bindings whose packed type has
no static mapping").

The standard library's `Omit<X, K>` over one bound type parameter, alone
or intersected with object types, with literal keys, now maps to the
binding's record without K. A rest copy therefore holds what JavaScript's
CopyDataProperties copies, and agrees with the `Omit<T, K>` the call site
resolves for the function's result. An intersected type that would add a
member of its own to the result has no slot in the binding's layout: the
type stays unmapped when the member walk would also lose a field of the
binding, which refuses the rest binding instead of dropping fields, and is
left to that walk when it names them all.

Corpus: tests/corpus/generic-rest-wider-binding.ts.
Diagnostics: tests/diagnostics/generic-rest-asserted-member.ts.

Co-Authored-By: Claude Code <noreply@anthropic.com>
`spawnSync(command, args, options)` with a typed options value was refused
when `args` could be non-empty, and so was an inline `shell: true` beside
separate arguments: Node joins the list onto the command unescaped and
reports DEP0190, and the refusal assumed a native build could not report
it. It can. Node 24's normalizeSpawnArguments emits the warning exactly
when the shell option is on and the list is non-empty, once per process,
through process.emitWarning, whose tail the runtime already implements
(scr_warning_emit_deferred: the next tick, noDeprecation, throwDeprecation,
the 'warning' listeners and the default report).

`cp.shellLine(file, args)` answers the line Node runs: the file for an
empty list, otherwise `file + " " + args.join(" ")`, reporting DEP0190 for
the first non-empty list of the thread. A typed options value reads its
`shell` member at the call and passes the line with an empty argument
vector when it is on, the command and the list as they are when it is
off; an inline `shell: true` passes the line. The existing shell entry
points run it unchanged.

The entry lives in scr_child_shell.c, a unit of its own selected by the
childShellLine feature, so scr_child.c keeps its bytes (its comments
change with the same line count) and a program that never joins a line
links what it linked before. spawn and execFile keep their refusals; their
hint no longer says the warning cannot be reported.

Corpus: tests/corpus/spawnsync-shell-args.ts and
spawnsync-shell-args-listener.ts. tests/diagnostics/child-process.ts loses
the two spawnSync refusals.

Co-Authored-By: Claude Code <noreply@anthropic.com>
The shell line read each argument with the accessor that traps on a hole,
so a sparse list passed with the shell enabled ended the program (exit
134, "array index 1 is a hole"). Node builds the line with
args.join(" "), which reads a hole as the empty string: the hole leaves
its separator in the line and nothing else, and the list still counts as
non-empty for DEP0190. The join now borrows each element and skips a
missing one.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Two cases from review.

A function-local binding that a closure captures before its declaration,
or that its own handler captures, is declared early as a TDZ box with
the declared record type. The Proxy was then converted into that record
slot, which the previous commit refuses, so a valid program got SC1090.
The early declaration now gives the binding checked-dynamic storage, as
its own declaration does.

Both give that storage only where the declared type is a slot that
would refuse or copy the Proxy. A binding typed as a function keeps its
slot, which wraps a callable Proxy, so 7445-crypto-key-pair-options.mjs
is unchanged.

A `const` binding that holds a Proxy, read through a shorthand property
(`{ view }`), was not recognized as that Proxy and was still copied into
the record field. Reads are now recorded by the expression they lower
to, for identifier and shorthand reads alike, and the object literal's
own field conversion asks the same question as the two conversion
funnels.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Two conflicts. The generated IR codec's layout digest: the codec is regenerated from the merged ir.ts (pnpm generate:ir-codec). function-references.ts, where main added the walk that drops lifted functions nothing reaches: an instance names its identity root for itself, like a closure its jsval entry, so the walk reaches the root when it reaches an instance that adopts it, and an unreached instance does not keep its root.

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

In Node process.stdin is a Readable and process.stdout and process.stderr
are Writables, so a program may hand them to anything that takes one. A
natively compiled program represents process.stdin as a handle with its
own members, and passing it where `stream.Readable` is declared failed the
checked conversion at run time: `expected Readable at $, got
ProcessStream`.

The handle now answers its one stream, as process.stdout and
process.stderr already answer theirs under the general stream
representation and as a cipher handle answers its Transform:

- `stream.stdioReadable` is the thread's Readable for descriptor 0,
  created by the first conversion. The stdin unit stays the only reader
  of the descriptor: the stream registers with it when it is first read
  and pushes what arrives, so a chunk reaches a listener on process.stdin
  and a listener on the stream once each. The code is a unit of its own
  (scr_stream_stdin.c, link feature streamStdin), linked only by programs
  that convert.
- process.stdin flowing into a Readable slot (a parameter, a field, an
  annotated binding, a record member) is that stream.
- process.stdout and process.stderr flowing into a Writable slot ask for
  the general stream representation when nothing else in the program
  did; the flow was refused before (SC1090).
- A value of the stream class compared with the process stream it was
  read from compares the two streams, so `held === process.stdout` is
  true as in Node.

Listeners registered on process.stdin itself and on the stream are two
tables; the limitations page says what follows from that. A process
stream held as an untyped value still fails the conversion.

Co-Authored-By: Claude Code <noreply@anthropic.com>
… across objects, threads and library instances

The address of a thread-local is not a constant. LLVM accepts
`ptr @tls` in an initializer anyway, and the link then refuses it (GNU
ld, when the two globals are in different objects), or produces a value
that is no thread's copy: a crash at start or a silent wrong answer.
The identity roots of thread-local closures were such constants until
the previous commit; the emitter now refuses any module whose
initializer names a thread-local (thread-local-constants.ts, run on
every module's final text), so the next one is an internal error at
compile time on a small program.

The walk that drops lifted functions nothing reaches follows an
instance's identity root, tested beside it.

Evidence for the cases a one-declaration program does not reach:
- native-worker-generic-identity-objects: twelve declarations, each
  held at two signatures by two functions, so the closures and their
  roots are in different objects of the divided build (the refused
  link).
- native-worker-generic-function-identity: instantiation expressions,
  union operands, unknown, Object.is, Map and Set keys, searches, a
  callback slot, name and length, in the main thread and in a worker,
  and a function that cannot be posted.
- the thread-instanced library fixture: a generic held at two
  signatures answers as one function in each of the four instances.

Co-Authored-By: Claude Code <noreply@anthropic.com>
The one conflict is lowerer.ts: main added refuseSourceFieldDeletes beside the two calls of checkGenericIdentityPins, which this branch deletes. The new calls stay, the deleted check stays deleted.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Code <noreply@anthropic.com>
#3144, this branch's base, is on main as one squash (18e2ed4); its
files merge to main's side without a conflict, and what remains against
main is this branch's own two commits.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Each is a rule the neighbouring shape already had.

Promise.all over an optional entry that carries a value. The tuple form
took `Promise<void> | undefined` entries and refused
`Promise<T | undefined> | undefined` (`this.opening?.catch(() =>
undefined)`). The undefined side of such an entry now joins as a promise
of the same type fulfilled with undefined, so the builder reads the
position at one type whichever side settled. An optional entry whose
promise's value cannot be undefined would fulfill two layouts at one
position; it keeps a refusal, now with a name and the spelling that
compiles.

reduce and reduceRight on a checked-dynamic receiver. A TypeScript source
dispatched map, filter, find, some and every on the value's kind and
refused reduce with "method calls like 'xs.reduce'". The runtime already
runs both.

Object.keys and Object.entries over what `typeof v === "object"` leaves of
a JSON value. The value keeps its stored union (`Json[] | boolean | number
| string | { [key: string]: Json }`), and the union enumeration took record
arms only. An array or primitive arm beside the records now answers through
the checked-dynamic walk a lone array or primitive argument takes, which is
Node's own for each (an array's indices, a string's indices, nothing for a
number or a boolean), and an index signature's value boxes into an `any`
element as a declared field does. Object.values of the same value is typed
any[] and keeps its SC2020. So does every member in a program that gives
the dictionary type identity storage (it converts a fixed record into the
type and writes through it): there the narrowed type has only the
checked-dynamic form, and pi's own statement is in that case.

`left ?? right` where the right side is wider. After every existing rule
declines, the present value converts into the result with the conversion a
slot of that type applies, after the original nullish test; the default
stays lazy. `c ? left : right` already compiled this way.

A function created above `let x: T;`. The dead-zone box a forward-captured
`const` uses now also serves a `let` without an initializer when its slot
can hold the unassigned state (an undefined-armed union, a checked-dynamic
binding); the declaration is the store that ends the dead zone. A type
that cannot hold undefined keeps a refusal that says so (SC1030), the twin
of the forward `var` one.

Corpus: promise-all-optional-value-entries, reduce-checked-dynamic-array,
object-entries-narrowed-json-union, nullish-default-widens-array,
forward-captured-uninitialized-let. Diagnostics:
promise-all-optional-entry-value, object-values-narrowed-json-union,
forward-let-unassigned-state.

Co-Authored-By: Claude Code <noreply@anthropic.com>
The runtime has had the live Headers iterator since untyped values got it
(sf_headers_step: Fetch's sort-and-combine list, re-read on every step,
Set-Cookie values one by one). A typed Headers was still refused: the
profile listed keys, values, entries and [Symbol.iterator] as unsupported,
and iteration syntax over a Headers had a fence of its own.

The four profile rows are static now, each with an operation and the
static fixture as evidence. A direct call dispatches on the handle as
forEach does and answers the runtime's iterator; for...of, spread, array
destructuring, Object.fromEntries, new Map, new Set, Array.from and next()
then take the paths they take for any checked-dynamic iterator. The
bundled declaration gains the three methods. A method read as a value
stays fenced.

No runtime change. tests/corpus/headers-typed-iteration.ts pins the forms
against Node; the fetch fixture gains the iterator lines the profile's
evidence names; the surface manifest and the compatibility ledger are
regenerated (the Headers row gains the corpus program as evidence).

Co-Authored-By: Claude Code <noreply@anthropic.com>
…ing is set

After process.stdin.setEncoding Node delivers strings to 'data' listeners.
A listener is compiled for the type its parameter has, and a parameter
with no annotation takes the declaration's type, which is bytes. The
stdin unit handed such a listener the raw chunk although an encoding was
set, so `data += chunk` appended the bytes as decimal numbers: a piped
prompt read that way reached its consumer as "83,97,121,...", with no
error.

The delivery is now a TypeError with code SC2020 that names the fix:
declare the parameter as string, which receives the decoded text (split
multi-byte sequences kept whole). It fires only where Node would deliver
a string. The runtime's own byte consumers (readline, the stdin stream)
keep the raw chunk. The bundled declarations gain the string listener
overloads, so the fix can be written without @types/node.

Making an unannotated listener receive the string needs its parameter
compiled at a type the checker does not give it; that is not in this
change. process.stdin.read() and async iteration still answer bytes after
setEncoding; the limitations page says so.

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

After process.stdin.setEncoding Node's read() and async iteration hand
out strings. Both are compiled for bytes and have no string form, and
they handed out a Buffer (measured: typeof "object" where Node's is
"string"), silently. With an encoding set they now throw a TypeError
with code SC2020 that names the way out: leave the encoding unset and
decode the Buffers. The iteration step answers a promise rejected with
it, which the loop's await throws.

Co-Authored-By: Claude Code <noreply@anthropic.com>
`type Json = null | boolean | number | string | Json[] | JsonObject` with
`JsonObject = { [key: string]: Json }`: once a record enters `JsonObject`
and is written through it, the dictionary keeps checked identity and is
the checked-dynamic value. Every object held in `Json` was then empty:
`JSON.stringify(JSON.parse('{"b":1}') as Json)` printed `{}`, and so did
a literal and the dictionary itself.

The mapper walks a dictionary's value type before it answers that the
dictionary is checked-dynamic. The value type led back to the dictionary,
that back-reference got a placeholder record, and the union closed over
the placeholder, which nothing ever completed. A dictionary with a
symbol-keyed member had the same hole without any identity.

A back-reference to such a dictionary now reads the checked-dynamic value,
and so does a back-reference to a union that holds one: the union then
takes the existing `string | object` collapse, in either mapping order,
or stays unmapped when an arm cannot ride it (a Map beside the
dictionary), and the refusal names that arm.

Co-Authored-By: Claude Code <noreply@anthropic.com>
A union of records has a discriminant only when every literal of one
member belongs to one layout. Two members with one literal and different
layouts (`{ type: "retry"; source: "a" } | { type: "retry"; source: "b";
reason: string }`) cost the whole union its discriminant, and its values
were then placed by layout: a record that fits two arms by width was
refused (SC2003), threw at run time behind an assertion, and from JSON or
`unknown` entered the wrong arm.

The record types of a declared union that share a literal of its deciding
member are now a class, and every type of a class maps to one record over
the members of all of them, wherever the type is used. The union keeps one
arm per class and the member is a total discriminant. A value of one
member's type is the class's record, so it enters and leaves the union as
the same object.

- union-classes.ts finds the classes from the union types the program
  writes down, before any type is mapped (a whole-program fact, registered
  in program-facts.ts), and builds a class's record.
- The type mapper maps a class member to its class's record; a class whose
  members cannot be one record keeps its layouts.
- A member a class member requires that the record holds as optional is
  read through the checked extraction: a value asserted past its members
  throws a TypeError.
- A value whose type only has the layout of a class member would be copied
  into the class's record; that entry is refused by name (SC1090) unless it
  is a literal written at the slot.
- A conversion of `x as T` traps the arms the checker proved away at `x`,
  as a conversion of `x` does.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Review of the previous commit: the entries of a JSON value's object arm
are typed `[string, any][]`, and the index signature's values were boxed
into the `any` element by copy. `const [, list] = Object.entries(o)[0];
list.push(x)` then wrote to a copy, and `list === o.list` was false, where
Node hands back the object's own array.

The value is boxed by reference now (dynFrom's liveRef, the capsule form a
checked cast unwraps to the same object), so a write through an array or
record read this way reaches the object's own, as it already did for the
array arm's elements.

tests/corpus/object-entries-narrowed-json-union.ts pins the identity and
the writes for both arms.

Co-Authored-By: Claude Code <noreply@anthropic.com>
One small program per cell (what tells a union's arms apart, how many
arms, which value, how the value reaches the union, what is done with
it), run under Node and as a native binary, with a verdict per cell. It
is the instrument behind union-classes.ts: a cell is right, or refused at
build time.

Co-Authored-By: Claude Code <noreply@anthropic.com>
…s a union arm

A record with only an index signature that is converted to an untyped
value checks whether its key map has a null prototype. That read took the
map from the second field of the record, which is where it sits when the
record has a one-word header. A record that is an arm of a
pointer-represented union has a two-word header, so the read took the tag
word as a pointer: a segmentation fault for a dictionary that travels as
`unknown` into a union that holds it, and for a union value asserted to
its other arm.

The map is read at the field after the header, as the two other reads in
this helper already do.

Co-Authored-By: Claude Code <noreply@anthropic.com>
The stdin unit hands the stream raw byte chunks, and the stream's async
iteration yields a chunk as it was pushed, so `Buffer.isBuffer(chunk)`
was false in `for await (const chunk of stream)` where Node's stdin
yields Buffers ('data' listeners and read() already answered Buffers).
The chunk is marked a Buffer before it is pushed, as the unit marks the
chunks of its own iteration. Found by the review bot.

Co-Authored-By: Claude Code <noreply@anthropic.com>
…ss' into fix/stdin-encoding-bytes-listener

# Conflicts:
#	tests/harness/event-loop-cases.ts
Review of the previous commit: it boxed every index-signature value with
a live reference, which is only valid for a record, an array or a union
that holds one. `Object.entries` of `string[] | { [key: string]: number }`
then failed IR validation (SC9001, "live dynFrom of unsupported type
f64"), and so did an index signature of `boolean | string`.

The value now takes coerceToExpected, the conversion any value takes into
an untyped position: it hands a record or an array back by reference, also
through a union, and boxes a primitive as a primitive.

tests/corpus/object-entries-narrowed-json-union.ts gains index signatures
of primitives beside an array arm.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Code <noreply@anthropic.com>
The lowerer's journal inventory test lists every collection of the type
registries as journaled or as mapping scratch. The union registry's
back-reference answers unwind with the union's frame, before mapType
returns, like `unions.inProgress`.

Co-Authored-By: Claude Code <noreply@anthropic.com>
After process.stdin.setEncoding, read() and for await over process.stdin
were refused by name on entry, before it was known whether they hand out a
chunk. read(0), which Node uses to trigger a refill, and a read() with
nothing held hand out nothing, so there is nothing to mistype: they answer
null again, and the string 'data' listener behind them runs. An iteration
that gets no input ends instead of rejecting.

The refusal now sits where a chunk would reach the bytes consumer: in
read() after the size is known to be satisfiable, and in the delivery that
settles the iteration's parked step.

Co-Authored-By: Claude Code <noreply@anthropic.com>
A library build's sidecar names every occurrence of a composed union's
repeated arm and tracks an integer obligation per occurrence, which needs
a layout per occurrence: with the members of such a union stored as one
record, four obligations that must refuse (SC4022) were no longer seen.
Library builds pass `separateUnionMembers`, and the shared lowering facts
then hold no classes.

The classes are read from the round's module order only, as the other
shared facts are; a Lowerer built over a stub program has none.

Co-Authored-By: Claude Code <noreply@anthropic.com>
cramforce and others added 4 commits November 7, 2026 14:25
…2987, #2731) into train/pi-1

Co-Authored-By: Claude Code <noreply@anthropic.com>
Main's refusal lint (#3137) lists every refusal site of the runtime. The
baseline is generated: it gains the two stdin refusals of this branch
(a Buffer 'data' listener and read() after setEncoding) and the seven sites
main's own later merges added without it, and drops four sites that are gone.

Co-Authored-By: Claude Code <noreply@anthropic.com>
…ys after #3135 and its refusal baseline) into train/pi-1

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

One entry: the weak-collection refusal is raised in scr_weak_set now.

Co-Authored-By: Claude Code <noreply@anthropic.com>
cramforce and others added 2 commits November 7, 2026 14:50
Co-Authored-By: Claude Code <noreply@anthropic.com>
…vent names

One entry: #2766 reworded the event emitter's refusal for a symbol event name.

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

Copy link
Copy Markdown
Contributor Author

New head 52d490c11b. What the push adds, and the timing result promised above.

Compile time, second round (the head before this push, 473616d7db, against main 246041da22; five fixture runs and two pairs per self-compile shard; the members have not changed since): benchmarks +0.65% [95% CI +0.09%, +1.40%], self-compile +0.00% [-0.40%, +1.17%], fixtures -0.05% [-0.28%, +0.62%], all within tolerance. The +3.05% for the fixtures in the first round was one run's noise. The same round's identity, with the 127 rows listed: 5,338 identical, 157 listed, 125 fail to build on both sides, 25 added programs the base cannot build, nothing else differs.

What the push adds:

Checked on this tree in a Sandbox before the push, since pnpm lint is the first step of CI's lint job and hides what follows it: the tsc build; pnpm lint with the refusal lint; pnpm format:check; the code quality and CI policy contracts (39 tests); the generated AST getters, IR field readers and IR codec checks; pnpm test:ts7 --baselines-only; pnpm node-compat:check; 472 guard and unit tests; the members' corpus programs and the programs these merges of main add (144 and 152 cases); the weak-key tests of #3371 (18 tests and 48 corpus cases).

One of those guard tests is red, and it is main's: packages/compiler/test/builtin-attributes.test.ts, "every builtin says whether it can throw", names worker.shareEnv (#3023) and emitter.symName (#2766). Main declares both builtins and lists neither in MAY_THROW_LIB_FNS nor in NEVER_THROW_LIB_FNS. It is not fixed here: the decision belongs to those two builtins' owners. Expect it in the Linux shards that hold that file.

The zero-cost gate's identity on this head runs beside CI and follows here.

@cramforce

Copy link
Copy Markdown
Contributor Author

The zero-cost gate's identity on the pushed head (52d490c11b) against main 1dc939974b, whole corpus, the 157 listed programs: PASS. 5,362 identical, 157 listed (all differ, as before: the 30 members' rows and the 127 of main's #2801), 125 fail to build on both sides, 25 added programs the base compiler cannot build; nothing else differs and there is no runtime-only row outside the list. The compiler's own source builds with the candidate; 146 bytes per source byte at the end of a lowering pass (budget 146); +0.6% LLVM bytes and functions for +0.4% source bytes.

Compile time in this round, at one fixture run and one pair per self-compile shard: benchmarks +0.45% [95% CI -1.11%, +2.12%] and fixtures +1.32% [+0.11%, +2.52%], both called inconclusive at that run count; self-compile +0.35% [-2.01%, +1.45%], within tolerance. The round with five fixture runs on the head before this push (same members; the push added merges of main and a generated list) is the conclusive one: benchmarks +0.65%, self-compile +0.00%, fixtures -0.05%, all within tolerance.

The test compared the peak resident set of two runs of one fixture binary
(800,000 and 1,600,000 keys) with `large < small * 1.05 + 8 MB`. The peak of
a run is not one number. Measured with one binary on one 8-vCPU machine:
105.0 to 105.3 MB or 109.5 to 110.7 MB, for 200,000 keys and for 2,000,000
alike; 110.5 to 113.0 MB with eight or twenty-four processes at once. On
CI's runner and in a 24-vCPU lane box the smaller run peaked at 96.4 and
93.5 MB beside 111.3 and 110.7 MB for the larger one, and the bound was
missed by 1.9 and 4.4 MB with nothing leaked: the fixture's LLVM text is the
same from main's compiler and from this branch's, and both pass 6 of 6 in
one sandbox.

What a leak costs, measured with the cross-heap collector off (no entry is
released): 258, 431, 778 and 1,454 MB at 200,000, 400,000, 800,000 and
1,600,000 keys, 865 to 889 bytes per key; 964 to 970 between 50,000 and
150,000 keys.

The measure is now the difference of the two peaks divided by the number of
additional keys, over a longer lever for the same number of iterations
(400,000 against 2,000,000 keys, the collection cadence fixed at 20,000).
The bound is 32 bytes per key: 50 MB over the 1,600,000 additional keys,
where the levels can account for 18 MB (12 bytes per key) and the leak above
for 1.3 GB. The test also runs the leaking pair and requires more than ten
times the bound from it, so the measure is shown to see a leak.

Runs of the changed test: 20 of 20 pass (5 on this branch, 5 on train pi-2,
10 on plain main, two sandboxes, two at once). The fixture's pairs directly:
20 pairs with eight processes at once differ by at most 0.6 MB (0 bytes per
key), 24 pairs with twenty-four at once by at most 1.7 MB (1 byte per key).

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

Copy link
Copy Markdown
Contributor Author

New head b94769fbe5: one test-only commit on top of 52d490c11b. Nothing else changed, so the gate and the short list of the previous head stand.

CI on 52d490c11b, every red job by cause:

The weak-key memory bound. The test compared the peak resident set of two runs of one binary (800,000 and 1,600,000 keys) with large < small * 1.05 + 8 MB. Nothing on this branch can change what those entries retain: the fixture's LLVM text is identical from main's compiler and from this branch's (only the header comment naming the directory differs), and the two executables differ by 672 bytes, the stdin delivery code of the 127 gate rows. The peak is simply not one number:

  • One 8-vCPU Sandbox, plain main 50696d31ed beside this branch, the same binary each: 105.0 to 105.3 MB or 109.5 to 110.7 MB, for either key count, on both trees (10 pairs each, idle); 110.5 to 113.0 MB with eight or twenty-four processes at once (36 pairs each). The old test passes 6 of 6 on each tree there.
  • In CI the smaller run peaked at 96.4 MB beside 111.3 MB for the larger; in a 24-vCPU lane box (train pi-2's round) at 93.5 MB beside 110.7 MB. The bound was missed by 1.9 and 4.4 MB.
  • What a real leak costs, with the cross-heap collector off so that no entry is released: 258, 431, 778 and 1,454 MB at 200,000, 400,000, 800,000 and 1,600,000 keys, which is 865 to 889 bytes per key.

The test now measures bytes per additional key over a longer lever at the same cost (400,000 against 2,000,000 keys, the collection cadence fixed): the bound is 32 bytes per key, which is 50 MB over the 1,600,000 additional keys, where the levels account for at most 18 MB (12 bytes per key) and the leak above for 1.3 GB. It also runs the leaking pair (50,000 and 150,000 keys, collector off: 964 to 970 bytes per key measured) and requires more than ten times the bound from it, so the measure is shown to see a leak.

The changed test: 20 of 20 pass (5 on this branch, 5 on train pi-2, 10 on plain main; two Sandboxes, two runs at once). The fixture's pairs directly: 20 pairs with eight processes at once differ by at most 0.6 MB, 24 pairs with twenty-four at once by at most 1.7 MB (0 and 1 bytes per key). pnpm lint, pnpm format:check and the whole test file pass on the pushed tree. Not reproduced: the low level of the smaller run that CI's machines show; the open question of where the levels come from is in the follow-up queue.

…, train perf-2, #2719) into train/pi-1

lowerer.ts, the start of finishModule: main adds the variadic function check
after the generic identity pins call that #3101 deletes. The check stays, the
pins call stays deleted.

Co-Authored-By: Claude Code <noreply@anthropic.com>
cramforce and others added 2 commits November 7, 2026 18:11
…3381, #3383, #3385) into train/pi-1

Co-Authored-By: Claude Code <noreply@anthropic.com>
The PR's CI skips the island conformance lane (it touches no island path),
but main runs it on every push, and three island builds change with this
branch's lowering fixes:

- npm/types-boundary (island:rowparse) and npm/zod3-boundary (island:zod)
  were refused at build time with SC1090; both build and match Node now, so
  their entries leave the list.
- npm/zod4-boundary (island:zod) loses one of its two refusals:
  "SC1090,SC8010" becomes "SC8010".

Regenerated by the lane's update mode over the whole lane on this tree (419
programs ran); nothing else changes. The three and their neighbours pass
again without the update mode.

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

Copy link
Copy Markdown
Contributor Author

New head 448ebbafae. Two things since b94769fbe5:

Checked on this tree in a Sandbox before the push: the tsc build; pnpm lint with the refusal lint; pnpm format:check; the IR codec, IR field and generated AST checks; pnpm test:ts7 --baselines-only; pnpm node-compat:check; 523 guard and unit tests in 13 files (the peel guard, the builtin table, the code quality and CI policy contracts, the runtime define sites and pack matrix, the unit journal, the lowerer guards, the whole diagnostics corpus), all green. On the first of the two merges also the members' corpus programs and the programs main added (144 and 152 cases) and the weak-key tests (21 tests, 48 corpus cases).

The zero-cost gate on the head before this one (df2a5f2d71 against main 339481db26): 5,375 identical, the 157 listed differ, 229 fail to build on both sides, 25 added programs the base cannot build, and two rows outside the list. Both are corpus programs that train islands-1 added to main (8777-stdout-alias-patch-dyn-chunk.ts, 8786-stdout-write-bind-stream-slots/main.ts): they link scr_events_dispatch and so carry the stdin checks, the cause of the 127 rows above (+1,152 and +912 bytes, IR and LLVM identical). They are on the list now. Benchmarks' compile time +0.68% [95% CI -0.61%, +1.70%], within tolerance. The gate on this head runs beside CI and follows here.

cramforce and others added 2 commits November 7, 2026 19:09
…rain/pi-1

expressions/binary.ts, the import block: main reformats the class-to-primitive
import beside the generic-functions import from which #3101 removes the two
identity-pin names. Main's first import and #3101's one-name second import
stand. ir/codec.generated.ts is regenerated from the merged ir.ts.

Co-Authored-By: Claude Code <noreply@anthropic.com>
One entry of startup-1's changes with this branch beside it:
npm/hono-zod-validator with the validator in an island stopped at the
refusal of Headers iteration in a static build (SC2020). #3274 compiles that
iteration, so the program goes on and stops later, at a TypeError of the
Headers constructor for its first argument. Still exit 1 where Node exits 0,
still loud; the entry now names the new stop.

This branch's own three entries (npm/types-boundary, npm/zod3-boundary,
npm/zod4-boundary) are as in the previous regeneration. Update mode over the
whole lane on this tree (427 programs ran); the entries that moved pass in
check mode.

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

Copy link
Copy Markdown
Contributor Author

New head 45a103ff52: main 851b59b90f merged (trains startup-1, cli-4 and cli-5), and the generated files that merge touches regenerated.

  • Conflicts: ir/codec.generated.ts (regenerated from the merged ir.ts) and the import block of expressions/binary.ts (main reformats the class-to-primitive import beside the generic-functions import from which Compile nested generic function declarations and root generic function identity #3101 removes two names; main's first import and Compile nested generic function declarations and root generic function identity #3101's one-name second import stand). No statement of logic was resolved by hand.
  • The island conformance list merged without a conflict and was regenerated all the same, by the lane's update mode over the whole lane (427 programs). One entry of startup-1's moves with this branch beside it: npm/hono-zod-validator with the validator in an island stopped at the refusal of Headers iteration in a static build (SC2020); Compile six pi shapes: Headers iteration, optional Promise.all entries, dynamic reduce, JSON-union enumeration, a wider ?? default, a forward-captured let #3274 compiles that iteration, so the program goes on and stops at a TypeError of the Headers constructor for its first argument. Still exit 1 where Node exits 0, and still loud; the entry names the new stop. This branch's own three entries are as before.
  • Checked on this tree in a Sandbox before the push: the tsc build; the IR codec, IR field and generated AST checks; pnpm lint with the refusal lint; pnpm format:check; pnpm test:ts7 --baselines-only; pnpm node-compat:check; 540 guard and unit tests in 14 files; 194 corpus cases of the members; the conformance lane in update mode, then the entries that moved in check mode (14 pass).
  • Zero-cost gate on this head against main 851b59b90f, whole corpus, the 159 listed programs: PASS. 5,397 identical, 159 listed, 288 fail to build on both sides, 25 added programs the base cannot build; nothing else differs. Compile time at one run per measure: benchmarks -0.78% [95% CI -2.04%, +1.00%], within tolerance; self-compile +0.64% [-0.31%, +2.80%] and fixtures +0.25% [-1.78%, +2.28%], which the gate calls inconclusive at that run count (the five-run round above stands: all three within tolerance). The gate on the previous head (448ebbafae against 659a1caaa5): PASS, 5,383 identical, 159 listed, all three time measures within tolerance.
  • Native bootstraps. This PR's CI skips them (it touches no bootstrap path), so they ran in Sandboxes, as CI's phases, on the tree itself:
    • on 448ebbafae: plain (commands, rebuild) and sanitized (commands, rebuild-frontend, rebuild-emit), all five phases pass;
    • on 45a103ff52: the same five phases are running; the result follows here.

@cramforce

Copy link
Copy Markdown
Contributor Author

Native bootstraps on this head (45a103ff52), run in Sandboxes as CI's phases because this PR's CI skips them: all five pass. Plain: commands, rebuild. Sanitized: commands, rebuild-frontend, rebuild-emit. The tree in the Sandboxes was verified byte-equal to the pushed head.

CI on this head so far: every Linux job is green. One red job, "test (macOS arm64 LLVM artifacts, no clang, 4/5)": 9474-child-output-order-held.ts, main's program (#3343) whose two 'end' events of a failed spawn come in either order on macOS; the same program failed two other macOS jobs on an earlier head and passed them on another, and this branch changes only comments in scr_child.c.

The PR conflicts with main since islands-2 landed, in one test file (tests/harness/coverage.test.ts: both sides add a test at the same place). It is not re-merged here: this head lands through the rollup train/landing-1.

@cramforce

Copy link
Copy Markdown
Contributor Author

Hold: please do not merge this PR. Several of its test programs are being replaced with simpler ones that pin the same compiler behaviour; the train returns as a new PR, and this one is closed as superseded then.

🤖 Generated with Claude Code

This branch was successfully deployed

1 active deployment
Preview — 45a103ff 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