Skip to content

Evaluate island-closed module-level constants inside the island - #3264

Closed
cramforce wants to merge 35 commits into
mainfrom
feat/closed-island-init
Closed

cramforce wants to merge 35 commits into
mainfrom
feat/closed-island-init

Conversation

@cramforce

@cramforce cramforce commented Nov 7, 2026 •

Copy link
Copy Markdown
Contributor

Stacked on #3201 (probe order) and #3212 (unset environment variables, which now holds #3222's single evaluation mode); the two merge first. This PR's own commits are "Evaluate island-closed module-level constants inside the island", the review fixes on top of it ("Address review feedback", "Record a refusal the island package catches, and keep one form of the closed constants"), and the two merges that bring in the heads of #3201 and #3212.

What changes

import { Type } from "schema-pkg"; // an island package
const item = Type.Object({ id: Type.String() });
export const list = Type.Array(item);

Lowered call by call, each initializer crosses into the island once per call with its literal arguments, and the first of them starts Node while the program's modules still initialize, whatever the build deferred. An initializer built only from literals, island imports, other such constants, and member reads, calls and new on those holds no native value, so the island can evaluate it itself. The build now writes each such initializer into an island module of its own, which ships in the module tree:

// .scriptc/closed/main.<digest>.0.mjs
import { Type } from "../../node_modules/schema-pkg/index.mjs";
export const item = Type.Object({ id: Type.String() });

// .scriptc/closed/main.<digest>.1.mjs
import { Type } from "../../node_modules/schema-pkg/index.mjs";
import { item } from "./main.<digest>.0.mjs";
export const list = Type.Array(item);

The module loads where its constant is declared, through nodeIsland.defer, and the constant's initializer becomes a read of its export, met with the conversion the original expression's value met. Everything after that is the existing deferral of a static island import: the build-time probe decides whether the module evaluates there or on first use, the interface snapshot stands in until Node starts, and reconciliation gives the stub its identity. The native constant is unchanged: the same global, type, exports and initialization flag, so a read before the declaration still throws Node's ReferenceError.

A program that declares such constants and does not use them on a run no longer starts Node for them.

What counts as closed

A top-level const statement of an ES module written in TypeScript that declares one constant, an identifier, whose initializer is:

  • built from: literals; undefined, NaN, Infinity; island imports (named, default, an ES module's namespace, also through a native module's export { x } from "<island>"); other closed constants; a.b, a[k], calls, new; object and array literals of those (shorthand of a closed constant, spreads of closed parts). Type syntax is dropped;
  • itself an island operation (the existing islandRooted) with at least one call or new;
  • of a type whose value is the island's own value on both sides: an island handle or an immutable primitive. A constant of the program's own record type (unsafe<Colors>({ fg: "red" })) is converted from native data, which an island literal is not, so it stays as it is;
  • with operands that are initialized before it in every execution: an earlier statement of the same module, or another module's binding without an initialization flag (an operand of an import cycle is refused: Node throws at an early read, the island would evaluate the operand instead).

Anything else keeps the call-by-call lowering: let/var/destructuring, a native binding, an enum or namespace member, a global such as JSON, this, await, closures, operators, conditionals, templates with substitutions, optional chains, a statement that declares more than one constant, CommonJS and JavaScript source modules.

One module per constant: each initializer evaluates between the native statements around it exactly as before. A native statement between two closed constants still runs between them, and when a later initializer makes the island call back into the program (a listener the program registered), that code finds the earlier constants assigned (node-island-closed-const-callback).

Why it is the same program

When the load is eager it evaluates the initializer against the same island objects, at the position the statement had. Deferring it is decided by the probe as for any island module (no throw, no effect, no input). Two things are specific to generated modules, because their code, unlike a package's top level, computes from state other island code left behind (registry.count(), an id counter):

  • They defer only where the program reaches them at one fixed point of its start: before anything awaited, outside every condition, in a module initializer that only the static import graph calls. There the modules that evaluated before it in the probe are exactly the ones that evaluate before it in the program (Probe deferred island modules in the order the program evaluates them #3201's order). In a dynamically imported module, after a top-level await, or under a condition, the module evaluates where its constants are declared.
  • The build does not perform the program's effects, nor read its inputs. The probe evaluates the program's own calls here, so for these modules the recorder refuses what it would otherwise record and perform: the first effect or input (output, a file write or an open for writing, a read of a file, a timer, a child process, the network, process.exit) is recorded and then thrown, and the module is not deferred. Recorded first, so that a package that catches the refusal and goes on with a fallback does not have its fallback taken for the constant (8932-node-island-closed-const-caught-refusal). Harness tests check that a file an initializer writes, or only opens for writing, does not exist after the build and does after the run.

A load reached while Node already runs evaluates at its position (#3222's single mode, in #3212), so a constant declared after the program's own island calls sees them: registry.add("x"); const n = registry.count(); reads 1 (8939-node-island-closed-const-after-call).

One difference from main, loud and narrow

A literal in a closed initializer is now made by the island instead of crossing as a native dynamic object. If the island hands it back through an untyped value and the program asserts it to a native record type (registry.get("k") as Config), the checked conversion throws a TypeError where it converted before. This is what main does for every object island code made, and #3029 removes it for all of them. Reads typed by the island's declarations are unaffected (#3093: a program type that receives island values there is a live view of the handle); 6899-node-island-unsafe-record and 6900-node-island-merged-record-in pin the two neighbouring cases and pass.

Before Node starts

  • typeof X: the stub's kind, no Node.
  • X === Y: stub identity; the probe numbers objects across all modules of its run, so two constants bound to one island object are one stub, and reconciliation keeps it.
  • X instanceof C: starts Node.
  • a primitive-typed constant (const major: number = semver.major("1.2.3")): the snapshot's value through the checked conversion, no Node; none when the evaluation read the clock or random numbers (then the declaration starts Node, as today).

Files

  • frontend/lowering/closed-island-init.ts (new): the analysis, the printer of closed expressions, the load and the initializer's read, and SCRIPTC_NODE_ISLANDS_TRACE=closed (one line per module-level constant whose initializer is an island operation: closed, or what keeps it native).
  • lower-node-island.ts: five exports and one call at the top of lowerIslandExpr. lower-modules.ts: one call in lowerFileInit. lowerer.ts: two memos, and generated loads get their tree path.
  • node-islands/generated.ts (new), modules.ts: generated files in the module tree; the IR's tree entry carries their text (generated), codec regenerated.
  • node-islands/snapshot.ts: probe copies in a private temporary directory, the fixed-point rule, no boot guard for a copy. probe-recorder.ts: the generated files the probe may evaluate, and the withholding.
  • No runtime change.

After review

Four findings of the review bot on this PR, each reproduced with a program first. The maintainer's commit on this branch (64fea0b) is kept and built on.

  • An open for writing was performed during the build (fs.openSync(path, "w") went through a recorder wrapper that records only after the call). Fixed in 64fea0b; reproduced on e66fcd9: the file exists after the build. Harness test effects-open.
  • A callback saw earlier constants of the same run unassigned. bus.on(() => { seen = first.label; }); const first = make("first"); const count = bus.emit(); printed first 1 under Node and died with "Cannot access 'first' before initialization" natively. Fixed in 64fea0b by one module per constant (node-island-closed-const-callback); 9e542a7 deletes the run type that was left with one element.
  • A refusal the island package catches. 64fea0b's two wrappers refused without recording, so try { return fs.readFileSync(file) } catch { return "no motto" } completed with an empty record and the executable printed the fallback (reproduced at 64fea0b). 9e542a7 routes every refusal through the function that records before it throws (8932-node-island-closed-const-caught-refusal, a recorder unit test, a harness test).
  • From Probe deferred island modules in the order the program evaluates them #3201's review, merged here: a module is not deferred when a probed load it shares a file with has effects, and each load's file list is complete. For this PR that means a generated module that names one whose evaluation has effects stays with it (8918: all three modules evaluate where their constants are declared).

Proof

The focused suites ran on the head (ccc383a). The gate and the lanes ran on dff0374, two merges earlier: what came after is 700632c (one condition in the recorder and its test) and #3201's 41f67e2 (where the walk places the conditional part of an expression), each covered by the focused suites.

  • 15 corpus programs, each matching Node in stdout, stderr and exit status, plain and sanitized, in the differential and llvm-differential suites: constants never used (8910: libnode is never mapped) and used (8911); a chain across two modules (8915); one constant seen through two importers, identical before and after Node starts (8916); an initializer whose island function prints, between two native console.log (8918); one that throws, uncaught, exit 1 (8919); process.exit before any use, exit 3 (8920); a read before the declaration, ReferenceError as in Node (8930); a refusal the island package catches (8932); primitive constants from a CommonJS package (8935); constants declared before and after the program's own island calls (8939); a native operand and an island array changed through its handle (8940); constants of a dynamically imported module (8941); a listener that reads an earlier constant (node-island-closed-const-callback).
  • tests/harness/node-island-closed-init.test.ts, 9 tests, from the build's traces and the run's boot trace: deferred generated modules and Node never starts when the constants are unused; primitive constants without Node; exit before use; initializers with effects evaluate in place; a file an initializer writes, or only opens for writing, does not exist after the build and exists after the run; a caught refusal does not make the fallback the constant; the closed trace names what keeps a constant native; a constant declared after Node started evaluates there, not from its snapshot.
  • Unit tests in node-islands (the generated text and its probe copy, the decisions for generated modules, the recorder's refusals and file lists): 220 of 220.
  • The whole island corpus under the libnode pack: 133 of 133, plain (in both suites) and sanitized. Island harness suites: 109 of 109.
  • Zero-cost gate with --gate-corpus=all against 75cca60 (the commit below this PR's first one; the candidate also holds the heads of Probe deferred island modules in the order the program evaluates them #3201 and Defer island modules that read only unset environment variables, and evaluate deferred modules in Node's order #3212): PASS, 5,237 programs identical, 108 fail to build on both sides, none differ; compile time within tolerance (bench +0.21%, fixtures -0.17%; self +0.75% with a confidence interval that spans zero). The gate builds without a libnode pack, so the programs this change touches are the island programs, which it leaves out; they are the corpus programs above.
  • Both test lanes with the libnode pack: plain 22,069 passed, 0 failed, including the plain native bootstrap; sanitized 18,832 passed, 1 failed: 2963-child-fork-dispatch in the sanitized llvm-differential suite, the known flake of Stop two corpus programs racing, and set three harness budgets from measured times #3300 (the program prints at 'close', which Node may emit before 'disconnect' on a busy machine); alone on this head it passes 3 of 3. The sanitized native bootstrap of this run was still going when this was written; its result follows in a comment.
  • pnpm lint, pnpm format:check, pnpm test:ts7 --baselines-only, the IR codec check: clean.

After merging main

Head 70aae07 holds main at 97d3722. The one conflict was the generated IR codec (main's file, regenerated with this branch's generated field). On the merged head: lint, format, the codec check and the baselines are clean; unit tests in node-islands 220 of 220; the closed-constants harness file 9 of 9; the island corpus 139 of 139, plain (both suites) and sanitized; the island harness suites 112 of 113, the one being node-island-deferral.test.ts "all of them when Node starts…", a test of #3222 that compares a whole boot trace and meets main's new first-run line "installing the module tree" (#3216). It fails on any tree with both; the last commit leaves that line out of the comparison, and the file passes on a fresh and on a warm cache. The island value matrix shows main's five known cells (#3298) and nothing else. The gate and the lanes were not repeated on the merged head.

What it does for start-up, and what it does not do yet

Neutral programs (corpus programs of this PR; main 979a652 against this branch at e66fcd9, Intel Xeon @ 2.90GHz, 8 vCPU, 30 warm runs each):

programbeforeafter
three closed constants, never used (8910)58.5 ms, 90 MB, Node starts0.9 ms, 13 MB, Node never starts
three primitive closed constants, used (8935)49.7 ms, 90 MB, Node starts0.6 ms, 13 MB, Node never starts
closed constants whose values the program uses (8911)52.4 ms55.7 ms (same, within the spread)

pi's start-up does not move with this PR. Measured on the pi run tree (a scratch tree with the open pi PRs, not this branch; Intel Xeon @ 2.50GHz, 32 vCPU), both binaries with #3201 and #3212: pi --version warm 1,160 and 1,300 ms before, 1,300 and 1,250 ms after (two measurements each, alternating); cold 2,530 and 2,680 ms before, 2,720 and 2,550 ms after. Crossings made by native initializers during start go from 337 to 297 function-value calls, 391 to 332 method calls and 1,014 to 893 property reads. Two reasons, both visible with the traces this PR adds:

  • 20 of pi's module-level constants are closed (16 generated modules when consecutive constants still shared one, which is when this was measured; 20 now); 32 more are island operations that stay native, because an operand is a native constant (an array of literals, or a "properties" object such as { a: Type.Number() } held as a native record), or a template has substitutions. SCRIPTC_NODE_ISLANDS_TRACE=closed prints one line per such constant, closed or what keeps it native.
  • None of pi's 16 generated modules defers. Every one of them sits in a module that a dynamically imported module also imports, so the build cannot promise the point at which the program reaches it, and each evaluates where its constants are declared (in one crossing instead of several). The follow-up replaces the build-time rule with a guard checked at run time (the loads registered before the module must be the ones the probe evaluated before it), which is exact in every run; it is stacked on this PR.

🤖 Generated with Claude Code

cramforce and others added 16 commits November 6, 2026 21:15
A statically imported Node island module stayed eager whenever its load
read an environment variable outside scriptc.nodeIslands.publicEnv, even
when the variable was unset at build time. The boot guard of an unset
variable records only that it was unset (E <key> 0), never a value, and
the runtime already checks it, so such a load now defers without a
listing. A run that sets the variable fails the guard and loads the
module at its import, as before. A variable that has a value at build
time still needs publicEnv, or the module stays eager.

The late evaluation restores only the variables the program changed
since the import. It used to put every guarded variable back to its
value from before the evaluation, which erased a write the module's own
evaluation made to a variable it had read.

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

Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Code <noreply@anthropic.com>
The build decides which statically imported island modules defer from one
run of its probe, which loads every candidate in one process. A file two
candidates share evaluates in the load of whichever candidate comes first:
its effects, inputs and environment reads are recorded as that module's,
and the other finds the file in the module cache and records nothing. The
candidates were probed in the order of the IR's functions, where a
module's initializer comes after those of the modules it imports, so an
importer's first island import was probed after the island imports of its
later dependencies.

  import { a } from "pkg-a";      // requires common-pkg, whose load prints
  import "./between.ts";          // a native module that prints
  import { kind } from "./dep.ts"; // imports pkg-b, which requires common-pkg

The probe loaded pkg-b first, recorded the print as pkg-b's and deferred
pkg-a with an empty record. The program then printed the native module's
line before common-pkg's, where Node prints them the other way round. An
environment read, a file read or a builtin mutation of a shared file was
hidden the same way, and "a later island module's load changes its
exports" compared against the wrong earlier modules.

The probe now takes the candidates in the program's evaluation order,
read from the IR: the entry's statements in order, into each module
initializer where it is called, an initializer that awaits continued after
the code that called it. Loads the walk does not reach (inside functions,
dynamic imports) keep the IR's order after the others.

Across the 106 island programs of the corpus and the deferral fixtures
(60 with deferral decisions), the only program whose set of deferred
modules changes is the new one.

Co-Authored-By: Claude Code <noreply@anthropic.com>
When every static island import of a program deferred, each group of
modules evaluated at the first use of one of its stubs (per-module
mode). Node evaluated each of them at its import, before any call, and
a call into a module that already evaluated can write island state (a
global, a patched builtin) that a later load reads. Such a program
printed what Node does not: corpus 8750 calls a function that patches
a builtin and then uses a package that captured that builtin at its
top level.

There is one mode now. Starting Node evaluates every deferred module
registered so far, in import order, before the operation that needed
Node, and a deferred import reached after that loads at its position.
No island code other than deferred loads runs before a deferred load,
so the island executes the loads in Node's order. The M snapshot
record, the component computation and the runtime's per-module state
are gone. A program that never uses an island still never starts Node.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Code <noreply@anthropic.com>
The probe loads a file two island modules share once, in the load of
whichever module reaches it first, and records what that evaluation
read (an environment variable, the terminal, the clock, an input no
guard covers) in that module only. The other module's exports can hold
values computed from the same read, but its record was empty: it got no
guard and kept its constants. With the variable set at run time the
first module failed its guard and loaded at its import, while the
second could stay deferred and answer a constant from the build's
snapshot (corpus 8751: `constants debug info` where Node prints
`constants debug debug`).

Modules that share a file now carry each other's reads: the
environment and terminal guards, the inputs that keep a module eager,
and the clock or random reads that keep constants out of a snapshot.

Co-Authored-By: Claude Code <noreply@anthropic.com>
  import { Type } from "schema-pkg";                  // an island package
  const item = Type.Object({ id: Type.String() });
  export const list = Type.Array(item);

Lowered call by call, each initializer crosses into the island once per
call with its literal arguments, and the first of them starts Node while
the program's modules still initialize, whatever the build deferred. An
initializer built only from literals, island imports, other such
constants, and member reads, calls and `new` on those holds no native
value, so the island can evaluate it itself.

The build writes the initializers of each run of consecutive such
constants into an island module of the shipped tree
(.scriptc/closed/<module>.<digest>.<n>.mjs), loads it where the run's
first constant is declared through nodeIsland.defer, and each constant's
initializer becomes a read of the module's export, met with the conversion
the original expression's value met. The rest is the deferral of a static
island import: the probe decides, the interface snapshot stands in until
Node starts, reconciliation keeps identity. The native constant keeps its
global, type, exports and initialization flag. One module per run: a
native statement between two closed constants still runs between them.

A constant is closed when its value is the island's own on both sides (a
handle or an immutable primitive) and every operand is initialized before
it in every execution; anything else keeps the call-by-call lowering.

A generated module computes from state other island code left behind, so:
- it defers only where the program reaches it at one fixed point of its
  start (before any await, outside conditions, in an initializer only the
  static import graph calls), where the modules probed before it are the
  ones evaluated before it; elsewhere it evaluates at its position;
- a program with generated modules does not use per-module mode, where a
  snapshot would answer for a module while Node runs;
- the probe withholds the effects and inputs of these modules instead of
  performing them: the build does not run the program's effects.

A literal in a closed initializer is now made by the island. On main an
island-made object fails the checked conversion when the program receives
it through an untyped value and asserts it to a native record type; that
case becomes a TypeError until island-made values pass checked
conversions (#3029). Reads typed by the island's declarations are views.

No runtime change.

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

# Conflicts:
#	packages/compiler/src/node-islands/snapshot.ts
…ile tests

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

Two island packages share a dependency whose top level reads an
environment variable, and a native module imported between them sets
the variable. The second package fails its guard and loads at its
import. The first, still deferred, has to evaluate before it, against
the environment of its own import, as Node evaluated the dependency at
the first import. With per-module evaluation the second load evaluated
the dependency first, with the variable set.

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

vercel Bot commented Nov 7, 2026 •

Copy link
Copy Markdown
Contributor

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

Project Deployment Actions Updated
scriptc Ready Ready Preview, v0 Nov 7, 2026 4:11pm UTC

@vercel-internal-security-reviewer

Copy link
Copy Markdown

Security review details

cramforce and others added 6 commits November 7, 2026 02:17
The walk that orders the probe followed a load where it is a whole
statement, through the sequence an inline require() lowers to, and
through the two operands of a string comparison. A require() the module's
own evaluation runs anywhere else inside an expression,

  const isFunction = typeof require("pkg-a").a === "function";

sits inside a member read inside a `typeof` inside the comparison, was not
reached, and kept the IR's order after every load the walk did reach.
With a file shared between that package and one a later module imports,
the later one was probed first again and the wrong build of the first
commit came back: the shared file's output moved past the native module
between them (tests/corpus/8707-node-island-probe-order-nested).

One rule instead of a case per expression kind: the walk follows what an
expression holds, in the order it is written. A sequence keeps its own
case, as the only expression that holds statements; the string comparison
is the general rule now.

Across the 103 island programs of the corpus and the deferral fixtures,
the only program whose deferral decisions change is the new one.

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

Three follow-ups to the review fixes of the commit below.

A refusal that is caught. For a generated module the probe refuses the
program's file reads and write-opens before performing them. The two
filesystem wrappers threw without recording the call, so when the island
package caught the refusal and went on with a fallback,

  exports.motto = () => {
    try { return fs.readFileSync(file, "utf8").trim(); } catch { return "no motto"; }
  };
  const text: string = motto();          // a closed constant

the evaluation completed with nothing in its record, the module was
deferred, and the executable printed the probe's "no motto" where Node
prints the file. The wrappers now refuse through `note`, as every other
withheld call does: it records the call before it throws, so the record
names it whether or not the package catches, and the module evaluates in
the run (tests/corpus/8736-node-island-closed-const-caught-refusal, a
recorder unit test). Timers are refused the same way: a closed
initializer sets no timer in the build.

One form. With one module per constant, a "run" holds exactly one
constant; the run type, its list and the lookups through it are deleted,
and a constant carries its own module key and index. No behaviour change.

Tests. The harness test of the printing initializers expected two
generated modules; with one per constant there are three (two that print
and stay where they are, one that only names the second and defers). The
test that the build does not perform an initializer's effects runs for a
second program, whose initializer opens a file for writing: the file does
not exist after the build and exists after the run.

Co-Authored-By: Claude Code <noreply@anthropic.com>
The walk of the fixed-point rule takes the probe-order branch's descent
into expressions (renamed to `inside` here, where `within` is the
initializer being walked); a load inside an expression counts as reached
under a condition. The recorder tests of both sides are kept.

Co-Authored-By: Claude Code <noreply@anthropic.com>
…/closed-island-init

#3212's head now holds the one evaluation mode (#3222). The per-module
mode and its condition for programs with generated modules are gone with
it: a load reached while Node runs evaluates at its position in every
program.

Co-Authored-By: Claude Code <noreply@anthropic.com>
8736 is taken on main (8736-new-set-map-dyn-iterable.cjs); the program of
the commit before last is tests/corpus/8932-node-island-closed-const-caught-refusal.

Co-Authored-By: Claude Code <noreply@anthropic.com>
cramforce and others added 3 commits November 7, 2026 04:00
…o fix/island-probe-order-main

41f67e2 places the conditional part of an expression after the loads
the walk reaches. That is right for a run that skips it and wrong for a
run that takes it (the 'taken' pair of 8746-node-island-probe-order-conditional),
which is why the decisions rest on the shared-file rule and not on the
order; both are kept, and the walk's comment says what the order is.

Co-Authored-By: Claude Code <noreply@anthropic.com>
…at/closed-island-init

700632c keeps a generated module eager whenever a call was withheld,
caught or not. It sits beside the recording of the call (the refusal goes
through note): the decision says why, and the record names the call. The
tests of both are kept; the ones that read the reason expect the new text.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Brings 41f67e2's ordering of the conditional parts of expressions. In
the fixed-point walk such a part is reached under a condition, with the
initializer it stands in.

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

Copy link
Copy Markdown
Contributor Author

The sanitized native bootstrap of the validation run named in the description (dff0374 against 75cca60) finished: passed. Final lane totals of that run: plain 22,069 passed, 0 failed; sanitized 18,835 passed, 1 failed, which is the known flake 2963-child-fork-dispatch (#3300), 3 of 3 alone on the head.

cramforce and others added 2 commits November 7, 2026 05:48
The one conflict was the generated IR codec; it is main's file,
regenerated (node scripts/generate-ir-codec.mjs) with this branch's
`generated` field of a module tree entry.

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

The test of #3222 (in #3212's branch) compares a run's whole boot trace.
Since #3216 on main, the first run of an executable also prints
"installing the module tree", so the test fails on any tree that has both,
on a fresh cache. The line is left out of the comparison.

Co-Authored-By: Claude Code <noreply@anthropic.com>
cramforce added a commit that referenced this pull request Nov 7, 2026
Brings main (through #3264's merge of it, with the regenerated IR codec).

Co-Authored-By: Claude Code <noreply@anthropic.com>
cramforce added a commit that referenced this pull request Nov 7, 2026
…lands

Brings main (through #3313 and #3264).

Co-Authored-By: Claude Code <noreply@anthropic.com>
cramforce and others added 3 commits November 7, 2026 07:10
islandRooted stays exported (closed-island-init.ts asks it) and takes
main's name for the helper that looks through parentheses and type
wrappers; closed-island-init.ts calls that helper too, now that
typed-arguments.ts no longer has its own.

Co-Authored-By: Claude Code <noreply@anthropic.com>
With zod forced into a Node island the program used to end at its first
schema: `z.strictObject({ … })` handed the island a native record, which
the boundary refuses ("this native object cannot cross into a Node island
yet"). Its three schemas are island-closed constants now, so the island
builds them from its own literals and the program goes on: it prints 11 of
its lines and then ends where a handler receives zod's parsed value, an
island object, as the program's own record type ("expected object at $,
got Object from a Node island"). That conversion is the boundary's rule
for every object an island makes, on main too; the program only did not
reach it before.

The entry is the lane's own output for this head (SCRIPTC_ISLAND_CONFORMANCE
=update for this program, then a run in check mode: 4 of 4). Still a
failure, one step later.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Main builds again (#3338). The IR codec is regenerated for the merged
ir.ts (main's changes and this branch's `generated` field of a module
tree file).

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

Copy link
Copy Markdown
Contributor Author

Pushed 04d0005: main merged in (the wrapper helper #3254 renamed, used here by closed-island-init.ts and islandRooted; the IR codec regenerated for the merged ir.ts), and one conformance entry.

The red "Node island conformance 3/4" job (npm/hono-zod-validator > island:zod) was this PR's, not main's. Reproduced on 70aae07 against main's build of the same program with --node-island=zod:

  • main: 0 closed constants; the program exits 1 at its first schema with "this native object cannot cross into a Node island yet" and prints nothing.
  • this branch: the three schemas (Book, ListQuery, IdParam) are island-closed constants, so the island builds them from its own literals. The program prints 11 of its lines and then exits 1 where a handler receives zod's parsed value, an island object, as the program's own record type: "expected object at $, got Object from a Node island".

So the forced-island build still fails, one step later, at the boundary's rule for every object an island makes (the difference this PR's description accepts; #3029 lifts it). The entry in known-failures.json is now the lane's own output for this head (SCRIPTC_ISLAND_CONFORMANCE=update for the program, then check mode: 4 of 4 ways pass).

Checked on the pushed tree in a Sandbox: pnpm build, codec, lint, format and baselines clean; unit tests of node-islands, outer-expressions and executable-cache 297 of 297; the closed-init, deferral, order and island harness suites 94 of 94; the island corpus 149 of 149; the conformance lane for hono-zod-validator and hono-zod-default-hook 8 of 8. packages/semver > island:semver is main's entry (re-pinned on main after the commit merged here); CI's merge with main has it.

@cramforce

Copy link
Copy Markdown
Contributor Author

Closing as superseded by #3350, the startup landing train, which carries this PR's commits merged with eight related PRs and current main (train head 14771d5234; validated once as a whole: zero-cost gate PASS with every benchmark byte-identical, both lanes, both native bootstraps, the island conformance lane with every moved entry explained). Review and merge happen there. If #3350 is abandoned, this PR is reopened.

🤖 Generated with Claude Code

@cramforce cramforce closed this Nov 7, 2026
cramforce added a commit that referenced this pull request Nov 7, 2026
Main gained the two ci-tests trains (#3349, #3351), #3324, #3203, #3135,
#3333, #3109, #3103 and #3102. Two conflicts, both sides kept:
- frontend/lowering/lower-node-island.ts: main's fromIsland (#3203: the
  declaration that types an island value), exported as the closed-constants
  lowering needs it (#3264).
- frontend/lowering/module-namespace-elements.ts: #3290's in-progress guard
  with main's lookup of a lifted function through the index (#3192's guard).
The runtime feature tables take #3211's deferredFence beside main's
conversionTrace (#3324): named keys, no slot shared.

Co-Authored-By: Claude Code <noreply@anthropic.com>
cramforce added a commit that referenced this pull request Nov 7, 2026
Regenerated by the lane's update mode on the train's tree (the train plus main
293036c) and compared with #3367's list, which was generated on the same
main. Three entries differ, and the value matrix is identical:
- npm/hono-zod-validator, island:zod: no entry. With #3264's closed constants
  and main's conversions of island values the program matches Node.
- npm/hono-zod-default-hook and npm/hono-zod-validator,
  island:@hono/zod-validator: #3312 fixes the build bug behind main's SC8006
  (the island tree listed a file that is on no disk); the programs build and
  differ from Node at run time (a 500 answer; exit 1, Malformed JSON).
The entry #3290 added for corpus/js-package-tuple-reflection is gone: with
main's JSON.stringify of island values the program matches Node.

Checked on that tree: conformance lane 432 passed, 68 skipped, 0 failed; value
matrix 239 of 239.

Co-Authored-By: Claude Code <noreply@anthropic.com>
cramforce added a commit that referenced this pull request Nov 7, 2026
The build writes the probe's copies of generated island modules (#3264) into
the temporary directory and tells its own modules from the program's files by
their paths. Node names a module by its real path, so where the temporary
directory is reached through a link the copy was taken for a file of the
program ('the build does not evaluate …, a file of the program'), and every
island-closed constant stayed eager. macOS is such a system (/var links to
/private/var): there the feature was off, and ten tests of
node-island-closed-init.test.ts failed in CI's macOS jobs on this branch's
first head. Linux was not affected.

The scratch directory is taken by its real path. The new test points TMPDIR at
a link outside any node_modules directory (the probe never refuses a file
under one): it fails without the change with that message and passes with it.

Co-Authored-By: Claude Code <noreply@anthropic.com>
cramforce added a commit that referenced this pull request Nov 8, 2026
The fixture's `const session = open("x", 30);` is a module-level constant
closed over an island package: this branch evaluates it inside the island
(#3264), so it is no longer a statement of the top-level initializer,
which is in the dynamic tier. With `let session`, which is not closed, the
report says 11 as on main. The list of checked conversions that trust an
unverified type is unchanged. Written by the test's update mode.

Co-Authored-By: Claude Code <noreply@anthropic.com>
cramforce added a commit that referenced this pull request Nov 8, 2026
One conflict, tests/harness/island-conformance/known-failures.json:
main rewrote the signature of npm/hono-zod-validator (island:zod) for
the diagnostic code refusals now name; this branch has no entry there,
because the program matches Node here (#3264 builds its zod schemas
inside the island). Kept: no entry. Every other entry is main's.

Co-Authored-By: Claude Code <noreply@anthropic.com>
cramforce added a commit that referenced this pull request Nov 8, 2026
A closed island constant (#3264) is a module the build writes, which
Node loads when the program reaches the declaration. Main's #3377 lets a
program replace `process.stdout.write` beside a module of its own that
patches a builtin (`fs.readFileSync`). With both, the constant's module
was loaded after the patch, Node's loader read the generated file
through it, and the program ended with `ReferenceError: patched is not
defined` where main prints its lines
(tests/harness/node-island.test.ts, "replacing process.stdout.write
beside an island builds, and the island's write is the program's").

When a module of the program is in the island because it patches a
builtin module, no constant of the program is closed: every initializer
is evaluated where it is written, as on main. The pre-scan's marker is
carried on the island module record for that.

Not covered: a package that patches the loader's inputs at run time
without the pre-scan seeing it. A generated module is still read through
Node's public loader.

Co-Authored-By: Claude Code <noreply@anthropic.com>
yowainwright pushed a commit to yowainwright/scriptc that referenced this pull request Nov 8, 2026
…osed island constants, their boot guard, deferred program modules

Train startup-1 members. Head b26b0df of feat/defer-program-islands, which
contains vercel-labs#3264 (70aae07) and vercel-labs#3313 (d9be99b).

Conflicts with vercel-labs#3201's later commits (d82f116, 5d63c2e), resolved once:
- snapshot.ts: one loadReads, vercel-labs#3201's (reads and effects shared by the loads
  of a file, the clock rule), in place of the stack's sharedEffects and its
  earlier copy of loadReads; the stack's unset-variable guard, P guard and
  probe copies kept.
- probe-recorder.test.ts: both added tests kept.
- lower-node-island.ts: islandRooted exported, with main's
  withoutParenthesesTypeWrappers.

Co-Authored-By: Claude Code <noreply@anthropic.com>
yowainwright pushed a commit to yowainwright/scriptc that referenced this pull request Nov 8, 2026
…ones

vercel-labs#3201 keeps a module eager when a load it shares a file with read the clock,
and reports shared effects as 'a load it shares a file with has effects'.
vercel-labs#3212's shared-file test and vercel-labs#3264's closed-constants test pinned the
behaviour before that rule.

Co-Authored-By: Claude Code <noreply@anthropic.com>
yowainwright pushed a commit to yowainwright/scriptc that referenced this pull request Nov 8, 2026
…plan computed at build time

Train startup-1 member. Head 3bfe420 of feat/island-link.

Conflict in node-islands/modules.ts (readNodeIslandModules): the tree's bytes
come from the build's own files (the load plan, vercel-labs#3236) first, then a
placeholder, then a generated module's text (vercel-labs#3264), then the disk.

Co-Authored-By: Claude Code <noreply@anthropic.com>
yowainwright pushed a commit to yowainwright/scriptc that referenced this pull request Nov 8, 2026
…ormat one test

closed-island-init.ts (vercel-labs#3264) imported stripTypeWrappers from
typed-arguments.ts, which main's vercel-labs#3254 replaced by
withoutParenthesesTypeWrappers in outer-expressions.ts (the same wrapper set).
load-plan-hooks.test.ts (vercel-labs#3236) had one line the formatter rewraps.

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

This branch was successfully deployed

1 active deployment
Preview — 04d00051 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