Skip to content

Give a project its Node island decision: sections, rules, a check and a project key - #3225

Open
cramforce wants to merge 197 commits into
feat/node-island-decision-wp1from
feat/node-island-decision-2
Open

cramforce wants to merge 197 commits into
feat/node-island-decision-wp1from
feat/node-island-decision-2

Conversation

@cramforce

@cramforce cramforce commented Nov 7, 2026 •

Copy link
Copy Markdown
Contributor

What

#3117 lets a build keep its Node island decision in a file and start the next build from it. This PR makes that something a project has rather than a flag someone passes, and closes the cases where a recorded island could outlive its reason.

Base: feat/node-island-decision-wp1, which is #2881 with #3117 merged in (the two conflict in executable/prepare.ts; that branch is the resolution). The diff here is only this PR. It retargets to main when those two land.

The file serves a project. It holds one section per entry file, target platform and set of options that steer the decision (--node-island, --native, --npm-static, --tier), so a project with several programs, built on macOS and Linux, keeps one file and each build rewrites only its own section. A section lists every island of the program with the rule that put it there: load for the islands the first decision makes from the load, and refusal, preflight, lowering, esm-importer, fence-report or fence-sure for the ones a round grew. The growth rules stamp the rule on what they add.

A replay asks what the record cannot know.

  • Does the program still import each recorded island? The first round asks its load's scan. An island nothing imports leaves the set, with every ES-module importer that was an island only because of it. Before, such a package stayed an island for good, with its files in the executable.
  • Do the islands a fence rule added still stand? The fence report runs only for a build that fails, and a fence that surely fires islands its package only when the build with the island compiles. So a section records how its build ended (only while it holds such islands), and when a replayed build ends the other way those islands are dropped and the build is decided once more from the rest, which is the round a build from scratch would be in. A fence-report island recorded by a build that compiled is what made it compile and never goes stale this way.
  • A section another compiler release recorded is decided from scratch.
  • A build that stops before its lowering (a type error) decided nothing and leaves the file as it is.

What a replay still cannot notice is a still-imported package that would now compile: only lowering it natively can tell, which is the work the file saves. That is what the check is for.

A check. scriptc build <entry> --node-island-decision-check decides from scratch with the frontend only, compares with the section a build would write, prints one line per island that differs and exits 1 if any does. It writes nothing and generates no code. scripts/island-decision-sweep.mjs records, replays and checks every corpus program and npm-suite case.

A project key. "scriptc": { "nodeIslands": { "decision": true } } in package.json names scriptc-islands.json beside it (a string names another file). With the key, build and run replay and record without a flag. coverage replays the file and never writes it. --node-island-decision=<file> overrides the key, and --ignore-node-island-decision turns it off for one command.

What a build prints. Nothing when the replayed decision stands. When it writes the file, one line on stderr and one per island that changed:

scriptc: updated the Node island decision in scriptc-islands.json (no islands)
  ~ outcome: compiles -> none
  - ejs@7.0.2: the program no longer imports it

How

  • node-islands/decision-file.ts: the sections, the per-entry checks at read (readIslandDecision), the import check (admitRecorded), the outcome rule (staleByOutcome, two lines in holdsWhile), the write (re-reads the file and replaces its own section) and the comparison.
  • executable/prepare.ts: prepareExecutableBuild runs the rounds from the record, applies the outcome rule at most once, and writes. In the first round of a replay, when the load shows a recorded island is gone, the round returns retry with the rest; the next round decides from the captured load, so nothing loads twice. Each round reports whether it reached its lowering and every island it ran. checkIslandDecision is the same rounds from scratch.
  • node-islands/growth.ts, graph-islands.ts: each rule stamps rule (and follows) on the IslandPackage it adds.
  • frontend/analysis.ts, node-islands/report.ts: an analysis lowers with the recorded packages, applies the same two checks, and its island report names a replayed package with the class and reason the build recorded (it never lowers that package natively, so it has no refusal of its own to show).
  • project-config.ts, cli/*, index.ts, native/*: the key, the three options, checkIslandDecision for both hosts. Options are resolved once where compile and analyze begin, so every cache key reads the file from one field. The native executable cache no longer stands aside for a decision file: its key is the generated LLVM, which a different decision changes.

Proof

Against the base feat/node-island-decision-wp1 (f9242bac8), in 32 vCPU Vercel Sandboxes (Intel Xeon @ 2.90GHz). The sweep, the island-program comparison, the large-program builds and the lanes ran on df95bba22; the head, e1dd21d7f, adds the lock around the file's write and two tests, and the tests, the gate and the bootstraps were run again on it.

  • Tests. packages/compiler/test/island-decision-replay.test.ts builds real island fixtures through prepareExecutableBuild from scratch and from the record, and uses checkIslandDecision as the oracle throughout: a replay is one round with the same serialized IR, warnings and island list, or the same failure, for a fence-report island, a lowering refusal and a sure-fence island; a package the program stopped importing leaves the set in one lowering and the result is the build from scratch's; a failing build's fence-report island is decided again when the program compiles (a package that compiles natively ends native, as from scratch) and comes back when the error does; a sure-fence island is decided again when the program stops compiling; a build that stops at a type error leaves the file byte for byte; an island the load decides is listed and not replayed; an analysis lowers once instead of twice with the record and reports the same statements and diagnostics. node-islands/decision-file.test.ts covers the file (sections, other sections left byte for byte, the compiler release, each way an entry leaves, each kind of difference the comparison names, the project key). packages/cli/test/node-island-decision.test.ts covers the command line: the project key records and replays without a flag, --ignore-node-island-decision, a second entry's section, coverage, the check's output and exit status for a match, a missing file, a missing island, an extra island and a program that fails before its lowering, and that the check writes nothing. 278 tests pass across these, the island round suites and tests/harness/node-island.test.ts.

  • The whole corpus and the npm suite: record, replay, check. node scripts/island-decision-sweep.mjs --jobs=30: 5,422 programs and cases, 5,421 recorded a decision, 85 with an island (87 island packages, 12 island modules), 1 stops before its lowering. 0 differ: every replay writes the IR and prints the diagnostics of its recording build, leaves the file as it is, and the check from scratch reports no difference. 1,043 s.

  • Without a decision file nothing changes. Zero-cost gate, --gate-corpus=all, no --gate-allow: PASS on this head: 5,207 programs identical (IR, LLVM, release executables), 96 fail on both sides, 0 differ. Compile time within tolerance: benchmarks −0.56 % [95 % CI −1.79 %, +1.17 %], the compiler compiling itself −0.88 % [−3.02 %, +0.88 %], fixtures −0.70 % [−1.40 %, +0.01 %]. (The same gate on df95bba22 was also identical for all 5,207, with the self-compile time +1.64 % [+0.10 %, +4.91 %], which the gate called inconclusive; this run settles it.) The gate links executables, so it cannot compare programs that need a libnode pack: separately, all 85 island programs build to byte-identical IR and diagnostics with the base compiler and with this one.

  • A large multi-package program (14 island packages, 2 island modules, stops at the end of lowering with 54 errors), one build at a time in one sandbox, the same diagnostics byte for byte in every build:

    build wall peak RSS rounds
    no option 819.39 s 8.15 GB 2
    option given, no file yet: decides from scratch and records 640.95 s 8.06 GB 2
    replay 315.38 s 6.30 GB 1
    --node-island-decision-check: matches, exit 0 725.00 s 7.66 GB 2
    replay 361.33 s 5.21 GB 1

    The file is byte for byte the same after both replays. The three runs that decide from scratch do the same work and range from 641 to 819 s, so read the gain as 1.8 to 2.6 times. The recorded section lists 7 lowering, 5 fence-report, 1 esm-importer and 1 load package.

  • Lanes with the libnode pack: plain 21,674 passed, 0 failed. Sanitized 18,439 passed, 3 failed, all in the one shard that also runs the sanitized LLVM-emitter bootstrap (2,576 s there): two are test timeouts (npm-static Effect generator at 900 s, three.js nodes at 1,200 s) and one is a compile of an Effect 4 fixture that did not finish. None builds with a decision file or an island. Run again on their own under the sanitizer on this head, all three pass (195 s, 382 s and 697 s).

  • Native bootstraps (self-hosting-native-driver, self-hosting-llvm-emitter; the driver's round loop and the decision file are compiled by the self-hosted compiler). On df95bba22: plain pass (3 tests, 2,397 s), and with SCRIPTC_SAN=1 pass (3 tests, 4,155 s). On this head: plain pass (3 tests, 2,845 s), and with SCRIPTC_SAN=1 pass (3 tests, 4,197 s).

  • pnpm lint, pnpm format:check: clean.

Not in this PR

🤖 Generated with Claude Code

cramforce and others added 30 commits November 4, 2026 20:44
…d copy-out

toIsland decided a refused value's crossing from its TypeScript arms, so a
union with a primitive arm beside an object the checker cannot map
("auto" | ToolChoiceObject, string | Part[]) was refused although its native
representation is already checked-dynamic, where the runtime copies the
primitive arm and exports the object itself. Crossing now follows the
representation: a dyn value crosses as any values do, and a union of copied
values with kinds the island export table holds crosses arm by arm through
its dyn box. exportedByReference accepts copied arms by their flags, matching
its comment, and the remaining refusals name the arm with no export instead
of claiming the value "would cross as a copy".

An island object converted into a program record type was a silent deep copy:
identity and every later island write were lost (K1). fromIsland now refuses
it with SC8010, naming the record and both exact alternatives: declare the
island's type (or unknown/any) to keep the handle, or state the copy with
structuredClone, which is exact. The refusal covers what builds a native
record only: promises are adopted, class instances and closures unbox their
own native value, and a conversion whose value nobody reads is unobservable.

Corpus: 8811 pins the union crossing and the object arm's identity, 8812
pins the explicit structuredClone copy, and the programs that copied out
(6836, 6899, 6900, 8809) now state it. node-island-copy-out pins the refusal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`module.exports = Object.getOwnPropertyDescriptor` (gopd@1.2.0's gOPD.js)
stores one standard library function as the module's whole export. Two
paths refused it. The exporting statement lowered, but the requirer's
binding resolved through the checker's alias to the stdlib declaration
the exported expression names, which has no storage in the program, so
every read of the binding was an unresolved-reference error.

The binding now reconnects to the module's `export=` symbol, whose
declaration is the export statement itself: the statement lowering
already assigns that statement's snapshot global, so requirer reads
share the value Node's requirer sees. The same reconnection covers a
factory call and a class-free identifier export.

The second path is a mutable source. `var $gOPD = ...; ...;
module.exports = $gOPD` (gopd@1.2.0's index.js) exports the value the
binding holds at the statement, while a later write to the binding never
reaches the export. A source binding that is reassigned somewhere in its
module now gets its own snapshot slot, assigned at the export, instead
of the refusal that asked for a `const` declaration. A module that also
attaches member exports to the same object keeps that refusal: those
writes would have to reach the object the snapshot holds.
…wrappers

An inline require() of a module whose whole export is a reassigned binding
read the binding's value after the module finished, not the value the
module.exports statement stored. wholeExportValue lowered the exported
identifier, and that read resolves to the live binding; it now reads the
snapshot global the statement assigns. A module.exports = <split require
binding> export answers the module it names the same way.

A module that re-exports another module with module.exports = require()
owns no snapshot of its own, so requirer bindings classified it as a
namespace binding and followed the inner source binding past the snapshot.
The snapshot lookup now follows the re-export chain to the module that owns
the storage, and a wrapper with live member exports is not followed.

The corpus program requires the snapshot module inline, through a require()
wrapper and through a split-binding wrapper. Lowerer.unjournaled lists the
requirer-binding cache as a pure memo, and the coverage test expects only
the table-export refusals: the reassigned whole-export binding compiles.
A module that re-exports another module's whole export by naming a require
binding (`module.exports = snap`) exports the value that module's export
statement stored. The binding already reconnects to that storage, so the
statement is alias plumbing rather than an unsupported shape and requirers
read the same slot.

A split require binding (`var m; m = require("./x")`) whose target stores
its whole export in a snapshot global reads that storage too, instead of the
source binding's value after a later write. The corpus program requires the
snapshot module through a wrapper, a split binding and an inline require.
V8 and libc learn which stack a thread is on from its pthread record: macOS binds libnode's calls inside libnode, so the bridge's v8::base::Stack definitions, which answer that on Linux, cannot answer there. The record named Node's stack only while Node started, though every frame of Node runs on it.
backtrace() is the reader that acts on it beyond V8's single ask: Node's diagnostic report calls it, and with the main stack recorded it takes the alternate-stack path, whose only bound is one megabyte per frame above the last, and walks whatever an island frame links to. The record now names Node's stack for the length of each job that runs on it, nested jobs included, and is restored when the job returns to the thread's own stack.

The context of that stack stops inheriting the creating stack's frame pointer too: getcontext captures it, macOS's makecontext leaves it in place, and the island's outermost frame would link to a frame on another stack. The stack starts empty, so the walk ends at its top.

A differential harness child killed by a signal now reports its signal. execFile yields no exit code and no output of its own for such a child -- whatever it wrote to stderr is already inside the message, and its stdout is gone -- so the signal is the one fact the report was dropping, and it is what tells a crash from a program that chose a nonzero exit. It is all the failure of 8830-process-report-island said in the macOS LLVM release lane.
…his`

wave-2 decisions 3b = A and 6 = C.

3b: the `this` fence in an object literal's method now runs after the
checked-dynamic route, so a literal whose method reads its own `this` reaches
the dynamic build whenever its type is a checked-identity view — the marking
index already marks a receiver-dependent literal's own and contextual types.
The receiver is JavaScript's own: the call's receiver, `undefined` when
detached, and the other object when the method is copied.

6 = C: a program record type that receives island values is a
checked-identity view, so the value keeps the island's handle instead of
being deep-copied. ClassProtocols indexes the reads rooted at an island
handle and marks their declared types: unions and intersections by their
object parts, island-declared parts skipped (they are handles already), and
mapped types by instance so the shared `__type` symbol never spreads the
fact to every Pick, Partial and Record in the program. fromIsland's
record-copy refusal (SC8010, #3005) then never fires for a marked type: the
existing dyn route returns the handle. Island membership is per round, so the
index answers it per round, and a program without islands collects nothing.

Evidence: corpus 8999 pins both pi rpc-mode.ts:235 shapes, the detached and
copied methods, and a data-member receiver; corpus 9000 pins anthropic's
side-member read around an island write, theme's program-function flow with
`vars ?? {}`, and identity probes. The superseded node-island-copy-out
diagnostics fixture is removed, and unsafe-record-lie now pins the check at
the read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
pnpm test:ts7 --baselines-only requires a preflight/order baseline for
every fixture. 8999 and 9000 each lower as one module with no preflight
diagnostics, so both baselines are the minimal entry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`delete comp.k`, where the assertion-erased operand's type is a closed
record that does not declare `k`, has nothing to delete in the operand's
own layout — but Node's object still holds the property, and a checked
record keeps its source's undeclared own keys in the kept-source view
that Object.keys, `in`, hasOwn, JSON, spread and inspect already read.
The delete now removes the key there (one representation: the same table
every other reflective surface answers from), answers `delete`'s boolean
— true for a key the record never held — and refuses a frozen record the
way strict JavaScript does (`Cannot delete property 'k' of #<Object>`,
sloppy code answering false instead).

What still refuses, by name:
- a field the operand's type declares REQUIRED ("the operand's type
  '{...}' declares 'y' required, so it cannot be absent"), the corrected
  message for a form the old text called "required record fields" even
  when the key was not declared at all;
- a record that cannot keep a source (accessor slots and the like):
  "a key 'k' the record's layout does not declare";
- a key of several literals and a non-constant key, unchanged.

Runtime: `scr_record_source_delete` beside the other kept-source
writers; a record view deletes from the record it views, and a narrowed
width copy follows its link to the record whose kept source holds the
key, because Node has one object where these are two (a frozen copy
refuses the removal for the same reason). Compiler: `record.sourceDelete`
in the lib signature/symbol tables with its own emission (the frozen
refusal is a pending check) and the
`erasedDeletePlan`/`recordDeletePlan` wiring that sets
`recordSourcesPossible`. Only a program that previously refused builds
any new IR, so typed code is byte-identical.

Evidence: `tests/corpus/8998-delete-undeclared-record-key.ts`
differential against Node (the coding agent's v1-to-v2 session migration
shape, held/missing/multi-key deletes and their order, the value-position
answer, a frozen target's strict TypeError, and a key a frozen record
never had), and `tests/corpus/8999-delete-linked-record-copy.ts` (the
same delete through an asserted optional view and through a narrowed
width copy, and the frozen copy's strict TypeError); corpus costs
recorded; diagnostics snapshots for the four refusals and the corrected
message; the new programs' ts7 order baselines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A Darwin link's debug map ties an object's symbols to the source its
compilation units name. A program built as several shard objects gave every
one of them the same unit name, so a later object's symbols resolved against
the first object's debug information.

Each physical output now names its compilation units after itself. The shard
splitter points every unit at a copy of the unit's file named after the shard.
The helper names each partition object's units after the partition, after
loading an unoptimized partition, after a ThinLTO import, and in split code
generation; output 0 of a code-generation split keeps the module's own unit
names because it is the object a single-output build produces. Functions,
variables and source locations keep the original file, so line information
and breakpoints are unchanged. Object cache keys follow the bytes they key.

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
Closes #2760
`delete (narrow as Narrow & { k?: T }).k` removes `k` from the operand's
kept source. That is Node's delete only while the operand is the one
record the object has. A narrowing conversion builds a copy of a wider
record, and a checked conversion of a boxed record answers a view or a
copy of it; when the wider record's type declares `k`, the property is
that record's field, which the delete cannot reach through the narrower
type. It answered `true` and left `wide.k` in place, where main refuses
the program with SC1090.

The deletes are now checked once every function is lowered
(refuseSourceFieldDeletes, before the record links are pruned to the
observed ones): a delete is refused by name when a record type that
declares the key can reach the operand's type through any chain of
narrowing conversions, or of a boxed record converted back by a checked
conversion into a type whose required properties it has. The test is by
type, so it also refuses a program whose wider records never reach the
delete; it accepts none that would answer wrongly. A key the record
keeps in its own source still deletes.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Code <noreply@anthropic.com>
scr_record_source_delete lived in scr_json.c, which most programs link:
adding it moved that object in 3,615 executables that never delete an
undeclared key (the zero-cost gate's runtime-only rows, IR and LLVM
identical). It is now scr_record_source_delete.c, selected by the
recordSourceDelete runtime feature, which the record.sourceDelete library
function turns on, the way scr_dyn_integrity.c is selected. It reaches
the table through scr_record_source_private.h and otherwise uses the
public property accessors, so scr_json.c is byte for byte what the
private-interface change left.

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

`await later<Rec>()`, where an island function returns `Promise<T>` and
the program instantiates `T` with its own record, compiled and then
rejected at run time with "TypeError: expected object at $, got Object".

A promise the island returns is adopted, never converted: it is the same
promise on both sides, and the value that crosses is the one it settles
with, where the program awaits it or a reaction receives it. That value
is an island object, and its declared type is the promise's type
argument. The marking pass for island-received types (wave-2 6 = C) saw
only the promise: `Promise<Rec>` is a library object type, so it fell to
the last case of `markReceivedView` and was recorded as a view itself,
while `Rec` stayed a native record. The awaited island object then met a
record type it can never satisfy.

`markReceivedView` now looks through a promise to the type it settles
with (`getAwaitedType`, which unwraps `Promise` and `PromiseLike` and
distributes over unions) and marks that. With `Rec` a live view,
`Promise<Rec>` maps to a promise of a dynamic value, so `fromIsland`
takes its existing adoption route (`dyn.promiseResolve`) and the awaited
value is the island's own object: identity holds across the boundary and
the island's later writes are visible, as in Node.

tests/corpus/8887-node-island-promise-record-view awaits the payload
around an island write, and receives it through `then`, `Promise.all`,
`race`, `any`, `finally`, a `then`/`catch` chain, an async function's
return, a `Promise<Rec>` binding and a `Rec | undefined` payload; a
rejection keeps its error, a literal the island keeps comes back as the
island's copy, and the program's own record comes back as itself.

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

Co-Authored-By: Claude Code <noreply@anthropic.com>
The predicates that decide whether a type converts to or from a checked
dynamic value recursed without end on two shapes, and the compiler died
with a stack overflow (seen compiling the yaml package):

- A union reached again through its own function arm, such as
  `type Step = (() => Step) | number`. canDynCheckTo marked records and
  arrays as in progress but not unions, so the cycle union -> function
  result -> union had no mark on it.
- A recursive record with a member that one rule declines and a later rule
  accepts, such as a method taking a generator and returning the record.
  The in-progress mark of a record shared the `checked:` prefix of the
  per-query success memo, and a failed alternative clears that memo, so the
  record lost its own mark and was entered again.

In-progress marks now have their own prefix, canDynCheckTo marks unions,
and the union branch of canConvertToDyn, which had the same shape, marks
the union it is inside. Recursive types answer coinductively, as recursive
records already did. Types that ended before answer as before.

Co-Authored-By: Claude Code <noreply@anthropic.com>
`import { execArgv } from "node:process"` in a JavaScript source ended in
an internal compiler error (seen compiling execa): `arrIntrinsic length on
non-array string`. A named import of a process data member had no lowering.
TypeScript refused it by name; JavaScript handed back a builtin identity
token, the string `"[builtin process.execArgv]"`, and the `.length` and
element reads dispatched on the checker's `string[]`.

- A named import of a data member of the process module (argv, execArgv,
  execPath, platform, arch, pid, versions, allowedNodeEnvironmentFlags)
  reads what `process.<member>` reads, in both source languages. `env`
  stays refused: its spelled read is a snapshot record and a write through
  the import must reach the environment.
- In a JavaScript source, a builtin module member whose type is data (a
  primitive, an array or a tuple) no longer gets an identity token. The
  token was read as the data: its text printed, its length taken. The read
  is refused by name (SC2020, deferred to the statement like every
  JavaScript fence). Classes and other objects keep their tokens.

Co-Authored-By: Claude Code <noreply@anthropic.com>
`scriptc coverage` on a 6,000-line project did not finish (19 minutes and
8 GB when stopped). The time was in mapCanonical, which commits a slot
whose type has no static lowering through its canonical form. Canonical
nodes intern by checker type, so a recursive type is a cycle in the graph,
and the walk had no memory: it mapped the same nodes again along every
path that led back to them, down to its depth cap of 24. A tagged tree
with a leaf that does not compile took 1.5 s with three branch arms, 20 s
with five, and the project's tree has seventeen.

One walk now keeps the mapping of each object node per depth (the depth
is part of the key because the cap cuts a mapping off at a distance that
depends on it). The answers are the ones the repeated walk computed.

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

A same-shape clone `{ ...r, k: v }` of a type that a checked conversion also
builds, in a program that observes the type's keys, copied the source's own
keys into a dynamic object, added a placeholder per override and noted the
result on the clone, so that a kept source (undeclared keys, another key
order) survives the copy. It did so for every clone, although only a record
that keeps a source has anything to carry: a tree rewriter over such a type
ran 34 times slower than the same program without the conversion.

The clone now tests its source when it reads it and takes the snapshot only
when the plain clone would not reproduce the source's own keys: the record
keeps a source (`record.sourceHas`), or it lacks a key an override creates,
which Node lists after the source's keys while the shape's table may list
it earlier. Any other source holds exactly its declared keys in its own
order and every override replaces a key in place, which is what the clone
helper copies. The test runs once, before the override values evaluate, as
the snapshot did.

Corpus: 8948-clone-source-test.ts covers clones of built records, of parsed
records in table order, in another order and with undeclared keys, overrides
of required and of optional keys, and an override whose value changes the
source, under Node and as a compiled binary.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Constructing a typed array from a view of another kind, set() from one and
TypedArray.from(view) converted every element of an integer source through a
double: the store was `(uint16_t)scr_numeric_to_u32(value)`, which decodes
the double's exponent and significand, or the out-of-line round-half-to-even
clamp, even when `value` was a uint8_t. That loop was 80% of the
typed-array-convert workload.

An integer source now converts directly. Its element is an integer in
[-2^31, 2^32), so ToUint32 of it is the value modulo 2^32, and the store is
that modulo the destination's width: C's conversion to the unsigned type of
that width, which is also the bit pattern of the signed kinds. A
Uint8ClampedArray destination clamps the integer to 0..255; an integer has
no fraction to round. Float sources keep their path (ToUint32 by exponent,
the rounding clamp), and float destinations convert the integer as before.

Corpus: tests/corpus/typed-array-conversion-all-pairs.ts pins all 100
(source kind, destination kind) pairs of the ten numeric kinds through
new D(src), d.set(src, 1) and D.from(src), with the edges of every kind as
source values. It passes before and after this change, plain and sanitized.

Sandbox, 8 vCPU Xeon 2.50 GHz, 131,072 elements x 200 rounds of set(),
before -> after (Node 24.22.1): Uint8 to Uint16 61.2 -> 1.9 ms (5.1),
Uint16 to Uint8 61.3 -> 1.4 (1.4), Int32 to Int8 83.0 -> 4.5 (6.0),
Uint32 to Uint8Clamped 25.6 -> 6.6 (7.6), Int16 to Int32 77.0 -> 3.9 (3.9).
typed-array-convert: 279.6 -> 137.5 ms (-50.8%, 95% CI -55.8% .. -43.9%);
steady-state against Node 3.33x -> 1.78x. What is left is the
Float32 to Uint8Clamped loop (82 ms against Node's 40), which this change
does not touch.

Co-Authored-By: Claude Code <noreply@anthropic.com>
An uncaught Error, and an unhandled rejection whose reason is an Error,
printed one line: `Uncaught RangeError: negative input: -2`. A program
that reads a stack trace keeps source frames in every function that can
throw, and each Error copies them when it is created, so the frames were
already in the Error and the report dropped them.

The two reports now print them under the heading, one `at` line each, as
Node prints its stack trace:

    Uncaught RangeError: negative input: -2
        at helper (/path/program.ts)
        at level2 (/path/program.ts)

scr_error_report_frames (scr_error.c) writes an Error's captured frames;
scr_exc_print_uncaught and the unhandled-rejection report call it, the
latter for typed and checked-dynamic reasons. A program that reads no
stack captures no frames and prints exactly what it printed before. An
Error thrown by Node island code is still reported by Node.

No emitted code changes: IR and LLVM are identical for every program.
The three runtime units are linked by every executable, so every
executable changes once, by the size of the added calls.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Lowering a call probes its receiver (tryLowerExpression) to pick a form,
and a later form lowers the receiver again. Two lowerings per link made a
call chain cost 2^links. `scriptc coverage` on concurrently, whose yargs
setup is one 25-link chain on a refused import, never finished (stopped
after 13 minutes at 100% of one core and flat memory).

- A refused probe that left nothing behind (no local or lifted function
  declared, no closure name taken, no diagnostic kept) is recorded for as
  long as the expression or statement that asked is being lowered. A later
  request for the same operand inside a probe, under the same function
  context and type bindings and with the same overrides, optional chains,
  narrowed types, staged arguments and scopes around it, is refused from
  the record with the diagnostics the probe captured. A chain on a root
  that cannot be lowered is polynomial in its length: 16 links took 32 s
  and take 0.8 s.
- Chains that do lower still cost two or three lowerings per link (an
  array-method chain reaches a minute at 12 links and the heap limit at
  14). An attempt budget per outermost statement (5 million lowerings, or
  2,000 per node of the statement when that is more) ends them in a named
  SC1090 at the statement.

Reusing a successful probe is not in this change. A first version did, for
probes that left nothing behind, and two corpus programs showed why it is
not safe as a general rule: the same node lowers differently once an
expression override is installed around it.

Co-Authored-By: Claude Code <noreply@anthropic.com>
…island-stress-values

The island value matrix tests the crossing rule of #3005 and #3093, so it stacks on them.

Co-Authored-By: Claude Code <noreply@anthropic.com>
The island value-crossing matrix (next commit) compares every observation
of a value on both sides of a Node island boundary with Node. These are
the differences that were local to the island lowering and the island
runtime units:

- `table[key] === undefined`, `list[i] === undefined` and `v.k == null`
  on an island object or array compared the declared type, so a missing
  key answered false. The island's value now decides, before it meets the
  declared type.
- `{ ...islandObject }` and Object.assign copied nothing from an island
  object. They copy its own enumerable string keys, read in Node.
- `x instanceof Array | Map | Date | RegExp | ...` answered false for an
  island value held as `unknown`. The island tests its own global of that
  name, as it already did for the Error classes; an identifier typed by
  the island's declarations also takes the native test, so an island
  error subclass a callback receives is `instanceof Error`.
- A program record inside the island answered undefined for the names
  every object inherits (`constructor`, `toString`, `hasOwnProperty`,
  `valueOf`, ...), so `String(record)` threw. Those reads are left to V8's
  Object.prototype, and they are no longer reported as own properties.
- `Object.prototype.toString.call(v)` answered "[object Object]" for an
  island array, Map or Date. Node answers.
- `structuredClone(v)` of an island object held as `unknown` threw
  DataCloneError, and dereferenced a null string for an object with a
  null prototype. The handle's clone operation is Node's structuredClone.
- An `Iterable<T>` or a union with a generator arm passed into an island
  reached the IR validator as SC9001. It is the named SC8010.

The checked conversion's TypeError for an island object where a native
class or record is expected now says the value comes from a Node island
and what holds it.

scr_json.c changes only under SCR_NODE_ISLANDS, the variant island
programs already select, so every other program keeps its object.

Co-Authored-By: Claude Code <noreply@anthropic.com>
A function whose number parameter bounds a numeric loop gets a copy that
takes the parameter as an int32, entered after a test at the top of the
function. Which loops count as numeric is decided by two lists of IR node
kinds. They admitted class field writes (`fieldSet`) and not record field
writes (`recordSet`), nor the sequence expression (`seqExpr`) that a
compound assignment to a field lowers to. n-body's `advance(bodies, n, dt)`
is that shape (`bi.vx -= dx * bj.mass * mag`), so its pair loop kept a double
counter, compared it with a double bound and converted it for every index.

Both kinds are now admitted. The lists are a cost choice, not a safety
condition, and the comment on versionLoopBoundParameters now says why: the
entry test reads only the parameters before any statement runs, `p | 0` is
the same number as `p` when it passes, and the copy runs the same body. A
write through a reference cannot change a versioned parameter, which is an
unboxed local of the frame (boxed and TDZ locals and capturing functions
are excluded); it can change what the loop reads next, which the copy and
the body read alike. Whether the copy's slot becomes an i32 is decided
separately by the slot analysis over the copy's body, which sees every
assignment to the parameter.

Corpus: loop-bound-versions-record-writes.ts runs plain writes, compound
assignments, increments, an assignment used as a value, a string field,
two references to one record and a bound reassigned from a written field,
with integers, fractions, NaN, -0, negatives, infinities and integers past
the int32 range. Plain and sanitized. Unit: parameter-version-emission.

Zero-cost gate (--corpus=all, base 714482d): 5175 programs identical, 96
fail to build on both sides, 1 differs: bench/n-body (`advance` gains its
version; executable +600 B). The self-hosted compiler's LLVM is identical.
Both native bootstraps pass (plain 2226 s, sanitized 3953 s, 32 vCPU).

n-body, Sandbox 8 vCPU Xeon 2.50 GHz, --layouts=4 --runs=16: 158.4 ->
132.5 ms (-16.4%, 95% CI -31.6% .. -10.7%); at scale 4, 590.8 -> 486.4 ms,
where Node takes 699 to 724 ms (steady-state against Node 0.91x -> 0.81x).
With the call site written `bodies.length | 0`, which needs no entry test,
the same run takes 460 ms.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Code <noreply@anthropic.com>
Both sides add a gated runtime unit beside scr_dyn_integrity.c; main's scr_dyn_reflect_proto.c keeps its place and scr_record_source_delete.c follows it.

Co-Authored-By: Claude Code <noreply@anthropic.com>
The runtime's Proxy constructor takes an untyped object as the handler,
and an untyped object, a function or a plain record as the target. A
TypeScript handler whose traps take their types from ProxyHandler<T>
compiles to a typed record, and a class instance or an array is typed
storage too, so `new Proxy(counts, { get(target, key) { … } })` built
without a diagnostic, got "fully static" from coverage, and ended with
"non-plain targets or handlers on a native Proxy is not supported yet"
when the statement ran.

The lowering now reads how each argument reaches the constructor and
refuses the forms that cannot succeed with SC1090 and a hint that says
how to write the handler so it compiles. Handlers typed any, empty
handlers, and plain-record or untyped targets compile and run as before.

Co-Authored-By: Claude Code <noreply@anthropic.com>
cramforce and others added 5 commits November 7, 2026 04:10
Three conflicts with #3218 (--emit-lowered / --from-lowered): the build
command's option list takes both sides, and the frontend that main wraps in
prepareLoweredProgram calls prepareExecutableBuild, in the Node driver
(index.ts) and in the native one (native/prepare.ts). The executable cache
key keeps main's tier field beside the decision file's digest.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Main's guard (#3260) asks every build and run option either to change the
executable cache's keys or to say why it need not. The decision file is in
the island key with the digest of its bytes, the option that ignores the
project's file is in the same key, and the check builds no executable.

The three options of #3218 that the guard also names on main are answered
by #3306, not here.

Co-Authored-By: Claude Code <noreply@anthropic.com>
A build that replays a recorded island decision asks its first load which
recorded islands the program still imports, and drops the others. It asked
the island scan, and the scan is not the list of files the lowering
compiles: it follows the startup module order and literal import() calls,
and it collects package edges only when the first load holds an npm package.
An island whose only import is in a module the lowering reaches another way
(a module behind a wrapper around import()), or in a program whose packages
only import() loads, had no edge. The check dropped it, the lowering refused
its use again, and the replay paid the lowering round the record exists to
save. The set it ended with was the recorded one, so the file and the
diagnostics showed nothing.

The question now goes to the load itself (Frontend.islandImports): the
imports of the recorded packages in every implementation file the program
holds. That is a superset of what the lowering compiles, so the check cannot
drop an island the lowering would find again. The island scan and the first
decision read what they read before.

Two things the replays that now really replay showed:

- The file listed the islands by name, and a round emits its SC8001 notes
  in the order of the set it starts from, so a replay printed the notes of
  the same build in another order. The file now keeps the order the rounds
  grew the islands in; the islands the load decides follow, by name.
- When the import check ends the first round, the next round decides from
  the capture of that load. The capture is now given the scan that decision
  asks for, instead of the one the check happened to request.

An island the load decides (a marked package, a module that patches a
builtin, a reader of the patched object) is decided again from the first
load in every round, which costs no round: a test pins that such islands
are listed, never replayed, cost a replay nothing, and are named by the
check when the load no longer decides them.

Co-Authored-By: Claude Code <noreply@anthropic.com>
The sweep compared a replay's IR, diagnostics and file with its recording
build's. A replay that dropped its islands and found them again passed all
three and lowered the program twice. The sweep now counts each build's
lowerings (SCRIPTC_TIMING's lower phase) and fails a replay that lowers more
than once. One kind lowers again by design and is reported apart: a native
build that tries a package as an island because a fence in it surely fires,
and keeps itself when the attempt fails (SC8023).

--executables builds every program that records an island three ways (no
decision file, recording, replaying), each with its own cache, and requires
the three executables to be the same bytes.

Co-Authored-By: Claude Code <noreply@anthropic.com>
… the import check

04d5c37 makes a coverage analysis lower with the recorded user modules as
well as the recorded packages. Its admission of the record takes this
branch's question to the load (Frontend.islandImports) instead of the island
scan: the packages by their imports in every file of the program, the
modules by the files the program holds.

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

cramforce commented Nov 7, 2026 •

Copy link
Copy Markdown
Contributor Author

A replay that lowered twice: the import check dropped islands the program still imports

Found on a large program: its replay lowered the program twice. The cause is in this PR, and three commits on top of a merge of main fix it. The reviewer's 04d5c3727 is merged in on top.

Cause. A replay asks its first load which recorded islands the program still imports and drops the others (admitRecorded). It asked the island scan, and the scan is not the list of files the lowering compiles: it follows the startup module order and literal import() calls, and it collects package edges only when the first load holds an npm package. An island whose only import is in a module the lowering reaches another way, or in a program whose packages only import() loads, had no edge. The check dropped it, the lowering refused its use again (SC2013), and the replay paid the round the record exists to save. It ended with the recorded set, so the file, the IR and the diagnostics showed nothing; the sweep did not count rounds.

On the large program the check dropped four of 17 recorded packages. All four are static imports of one module that the program reaches through a wrapper around import().

Small reproduction, an existing npm-suite case, tests/fixtures/npm/cases/dynamic-import (two packages that only await import("pkg") loads):

recording build replay
before (e1dd21d7f) 2 lowerings 2 lowerings: round 1 admit islands=[], then both found again
now 2 lowerings 1 lowering

Fix (d8d1ac632).

  • The question goes to the load itself: Frontend.islandImports(names) returns the imports of the recorded packages in every implementation file the loaded program holds. That is a superset of what the lowering compiles, so the check cannot drop an island the lowering would find again. The island scan and the first decision read what they read before. A file the program holds and never lowers can keep an island a build from scratch would not have; the check reports it like any island the program no longer needs.
  • The file listed the islands by name. A round emits its SC8001 notes in the order of the set it starts from, so a replay that really replayed printed the notes of the same build in another order (greeter, cjszoo against cjszoo, greeter on the case above). The file now keeps the order the rounds grew the islands in; the islands the load decides follow, by name.
  • When the import check ends the first round, the next round decides from the capture of that load. The capture now gets the scan that decision asks for (with main's marked option it was given another one).

load entries are not involved: the first decision makes them again from the first load in every round, which costs no round, so they cannot be stale. A new test builds a program with a module that patches fs, a user module and a package that read through the patched object, and a lowering island beside them: the three are listed as load, the replay is one lowering with the same result, and when the patch goes the check names each (- direct-reader@1.0.0, - module loader.ts, - module read.ts).

The sweep (f8e1d668f) now counts each build's lowerings and fails a replay that lowers more than once, and with --executables it builds every program that records an island three ways (no decision file, recording, replaying) and requires the same bytes. One kind of build lowers again by design and is reported apart: a native build that tries a package as an island because a fence in it surely fires, and keeps itself when that attempt fails (SC8023, npm/cases/create-require). Nothing cheap says the attempt would fail again, and skipping it on a stale record would leave a native package whose fence throws at run time, so every build makes the attempt.

Proof

In 32 vCPU Vercel Sandboxes (Intel Xeon @ 2.90GHz), on 47fb01435 (the head before the registry row below: the three commits, and a merge of the reviewer's 04d5c3727, which was pushed while the proof ran; one conflict, at the admission call in analysis.ts).

  • The whole corpus and the npm suite: record, replay, check, with rounds counted. node scripts/island-decision-sweep.mjs --jobs=30 --executables: 5,521 programs and cases, 5,520 record a decision, 95 with an island (96 island packages, 13 island modules), 1 stops before its lowering. 0 differ. Every replay lowers the program once, except the one SC8023 case (it records no island and lowers twice with or without a record). 25 of the 95 lower once where their recording build lowers twice (24 before the fix; the 25th is dynamic-import). The other 70 are decided at the load and lower once either way.
  • Executables. The same run builds each of the 95 three ways, each with its own cache: 94 link, and for each the build without a decision file, the recording build and the replay write the same bytes; 1 (npm/cases/2584-cjs-conditional-exports) fails to build the same way three times.
  • Before the fix, on the merge of main (1346abd42), the same sweep counts 2 lowerings for the replay of dynamic-import; its IR, file, check and executables agree, which is why nothing reported it. (That earlier run's comparison of diagnostics text is not usable: the script did not yet drop the timing lines it now turns on.)
  • Tests. The decision suites, the island round suites and the command-line tests: 278 pass; the one failure is main's (below). New: islands only an import() loads are replayed in one round, with the same result and the same file, and one of them leaves with its import(); the load islands of a patched builtin are listed, decided again and checked; the unit tests of the import check cover a deferred import and one that does not resolve. Under the sanitizer the replay tests pass too (19).
  • Island harness and island corpus, plain and sanitized, with a libnode pack: tests/harness/node-island*.test.ts 109 pass plain and 109 sanitized; the island programs of differential.test.ts 114 pass plain and 114 sanitized; npm.test.ts 153 pass plain.
  • Without a decision file nothing changes. Zero-cost gate against main 50bcfe1b3, --gate-corpus=all, no --gate-allow: PASS, 5,295 programs identical (IR, LLVM, release executables), 108 fail on both sides, 0 differ. Compile time: benchmarks +0.25 % [95 % CI −1.21 %, +2.07 %], the compiler compiling itself +0.85 % [+0.12 %, +1.64 %], fixtures +0.36 % [−0.54 %, +1.25 %]. (The round on f8e1d668f, before the reviewer's commit was merged in: also 5,295 identical, benchmarks +0.38 % [−0.33 %, +0.92 %], self −0.70 % [−2.25 %, +2.94 %], fixtures −0.95 % [−2.02 %, +0.12 %].) The base is main, so this round also covers what the branch holds of Island rounds: reuse the first load's decision inputs and decide a retrying round from its push-down pass #2881 and Keep a build's Node island decision in a file and replay it #3117.
  • Native bootstraps (self-hosting-native-driver, self-hosting-llvm-emitter; the driver's first round and the frontend's new question are compiled by the self-hosted compiler): plain pass (3 tests, 2,073 s), and with SCRIPTC_SAN=1 pass (3 tests, 3,023 s). The self-hosting IR check compiles (exit 0, no error SC).
  • pnpm lint, pnpm format:check: clean.

The merge

4e8caafb6's parent 1346abd42 merges main (50bcfe1b3). Three conflicts with #3218: the build command's option list takes both sides, and the frontend that main wraps in prepareLoweredProgram calls prepareExecutableBuild, in the Node driver and in the native one. 4e8caafb6 gives the three decision options their rows in main's cache-key guard (#3260). Until feat/node-island-decision-wp1 also takes main, this PR's diff shows main's commits beside its own.

After the first CI run

97db6d1eb adds one row to main's registry of whole-program scans (program-facts.ts): the frontend's new answer iterates the program's implementation files, a third scan in loadFrontend. Without the row program-facts.test.ts fails, which the first run on 47fb01435 showed (Linux plain and san, shard 11/20). That guard is newer than this PR's old base and was not among the suites run before the push; the fix is the row, nothing else changed.

The other failures of that run are main's own: the cache-key guard for #3218's three options (below, #3306), and five .ctor cells of tests/harness/island-value-matrix.test.ts that #3165 left pinned as refused (#3298).

One test is red on main itself and here for the same reason: executable-cache.test.ts > "every build and run option is in the cache keys or says why it need not be" names emit-lowered, from-lowered and from-lowered-accept; #3306 adds those rows. With #3306's rows applied the test passes, this PR's three included.

@socket-security

socket-security Bot commented Nov 7, 2026 •

Copy link
Copy Markdown

Main's registry of whole-program scans (program-facts.ts) counts every
iteration of the program's source files by the declaration that holds it.
The frontend's new answer to a replayed island decision iterates the
implementation files for the imports of the recorded packages: a third scan
in loadFrontend, of the discovery kind (it finds which packages the program
imports and decides nothing about how other code compiles).

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

This branch was successfully deployed

1 active deployment
Preview — 97db6d1e Deployed Nov 7, 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