What was wrong
A build and a coverage report disagreed about Node islands by construction, in both directions (measured on 714482d49).
- Report says island, build says native. Coverage listed every package with any reason to need Node as a "would-be" island. A build keeps a package native unless it was refused before lowering, holds a refusal in its own module, or carries a runtime fence that surely fires. For uuid, date-fns, hono, commander and zod the report said island, the build said native with deferred throws, and the build printed nothing about them.
- Report says blocker, build succeeds. Coverage kept the SC2013 of a refused package the build runs in an island (yargs:
success: falsefor a program that builds), did not make the failed-build retry (lodash: SC1090 on a use the build resolves through the island), checked preflight on the first load rather than the island reload, and never islanded a program module. - On pi the report named 34 islands and 72 reached errors where the build runs 14 islands and reports 66.
What changes
One decision. The functions that decide a build's islands move from executable/prepare.ts to node-islands/decide.ts, and frontend/analysis.ts runs them in the build's order, round by round: the first-load decision (packages and modules), the refusals a load still has to place, the preflight rule, the lowering, and growth.ts's rules over what the lowering saw, including the one retry a failed lowering gets from the packages' island classes. The report names the set the last round lowered with (mode: "build"), with the build's class and reason. A package that stays native keeps its first reason in the report, so nothing the old list showed is lost. Coverage no longer seeds its set from the module graph alone, because a build does not; decideNodeIslands is the place to do that for both.
No fallback. When a build moves a package into an island for its fences and the island build fails, the build fails with that build's errors and SC8023, which names the packages and --npm-static as the way to keep them native. It no longer returns to the native build with a warning.
Nothing silent.
- A package a build keeps native with fences the program can reach gets an SC6006 warning, one per package:
npm package 'uuid' compiles with an operation the program can reach that does not compile: it throws when the program reaches it (first SC2020: 'unescape' …). It is in the divergence band, socoverage --fail-on=divergencesgates on it. A package named in--npm-staticasked for its fences and is not reported. - Coverage no longer calls a program with reachable deferred sites "fully static".
- Where the policy forbids an island the fence rule asks for (
--node-islands=offorexplicit,--native=<pkg>), the build fails with SC8002 or SC8003 instead of shipping the throw, as the SC8001 note's hint already said.
The fence rule is one predicate and does not change. fenceIslandsPackage in growth.ts answers what main answers: a fence in module code, or one that needs the runtime itself. Answering true for every reached fence is the intended rule and is one change there. It waits because of what running five such packages in Node does today:
| program | native on main | package in a Node island |
|---|---|---|
uuid v5() | throws SC2020 at the first call | matches Node |
| zod parse | matches Node | matches Node |
| commander (the docs' example) | the action path matches; --help throws | an island's process.argv is ["node"]; with that fixed, --help matches and the action path throws expected object at $[1], got Object (the options object into the callback's record-typed parameter) |
date-fns format | throws SC1090 at the first call | throws "this native object cannot cross into a Node island yet" (a native Date) |
| Hono starter | every request answers 500, nothing on stderr | the same silent 500, from the boundary refusal for the native Request |
All three boundary failures come from builds that succeed, and an island needs a libnode pack. The repros are in FOLLOWUPS (2026-11-07, build-island-policy).
The Node islands docs page (#3131)
Of the limits that page lists, this change removes or changes three:
- Coverage reports: the section no longer lists a package a build compiles natively; it lists the build's islands, and a native package keeps its first reason on its own line.
- Keeping a package native:
--native=<pkg>and--node-islands=offfail the build with SC8003 or SC8002 for a package the default would have run in an island (measured with ejs 7.0.2, which on main builds and dies at start-up with SC1090). A package whose refusals sit only inside function bodies still builds, with SC6006. - Values across the boundary: an SC8010 on an automatically chosen island stops the build, with SC8023 naming the package; it no longer falls back to the native compilation with a warning (measured with the page's
WeakMapexample).
Unchanged: command-line arguments, island objects in native APIs, the libnode pack, platforms, start-up and memory, worker threads, your own modules.
Also found, not fixed here
mustache 4.2.0 compiles natively and renders "". It has no deferred site: it is a miscompile in the JavaScript tier that reproduces in eleven lines without a package (FOLLOWUPS 2026-11-07, r11.mjs): a variable a function assigns is lowered as the literal undefined when passed to a method of a constructor-function instance. After this change coverage shows mustache as native with object-model: R2.1 constructor functions, as the build has it.
Order with other work
- #2881 also rewrites
executable/prepare.ts. This lands after it; the conflict is the round structure inprepareExecutableModule, and the rules themselves live innode-islands/decide.tsandgrowth.ts. - The retry a failed lowering gets from the packages' island classes is unchanged here and sits in one place (
growFromClassesand theexplainflag ofgrowIslandSet, with one call site in the build and one in coverage). Its removal is separate work (island-decide-once slices 5 and 6); because coverage runs the same function, removing it there removes it from both.
Evidence
packages/compiler/src/node-islands/growth.test.ts: SC6006 per package,--npm-static, the refusals underoff,explicitand--native.tests/harness/node-island.test.ts: a fence in module code islands its package and is refused by name where the policy forbids it; a function-body fence stays native with SC6006.tests/harness/coverage.test.tsand its four snapshots: the report names the build's islands; a program whose refused package the build islands has no blocker.- Coverage against an IR build over the 94 corpus programs that need a libnode pack and the 182 npm cases: the same island set and the same verdict for all 276. On main 96 differ, 30 on the verdict and 66 on the set.
tests/harness/node-island.test.tskeeps seven of them as a test, one per way a set is decided. - pi (
packages/coding-agent/src/cli.ts, the usual--npm-staticlist): the report's 15 islands and 2 modules are the build's, and its 64 blocker sites are the build's 64 error sites. On main the report has 34 islands against the build's 14, and 70 sites against 64. - An internal error in the native compilation of a package the fence rule moves into an island no longer fails the build: underscore 1.13.8 is two SC9001 on main and matches Node here.
Cost
Measured in one sandbox (32 vCPU, Intel Xeon @ 2.90GHz), base 714482d49, other jobs running beside both sides.
- pi build: 863.4 s to 866.7 s, three rounds both, the same 66 errors line for line. Its island set goes from 14 to 15:
@stablelib/base64has a fence in module code, and a failed lowering now applies the fence rule in the same round as its diagnostics, where main applied it only after a lowering that succeeded. - pi coverage: 739 s to 1,041 s. It now makes the build's three rounds; main made two lowerings and reported a different program. The unreached remainder costs 8 to 17 s per round, so the time is the rounds. Deciding the set before lowering (
node-islands/decide.tsis the one place now) shortens both. - Coverage of programs without packages: 300 corpus programs in 40.3 s and 40.7 s against 39.9 s and 40.4 s on main.
Validation
Run against base 4bd24a1cd; the branch was then rebased onto 2fb13a01f without conflicts.
- Zero-cost gate,
--gate-corpus=all: PASS. 5,183 programs identical, none differ; 96 fail to build on both sides (they need a libnode pack, and the gate has none). Bench geomean -0.06%, self -0.01%, fixtures -0.20%. The candidate compiles its own source. - Lanes, plain and sanitized, with a libnode pack, both native bootstraps included: plain 21,532 passed, sanitized 18,305 passed, 3 failed in each. Two test groups pinned what this change replaces and are updated in a later commit (rerun: pass). The third,
packages/runtime/test/regex.test.ts, is a 60 s hook timeout under lane load; it passes on main and on this branch when run alone. - After the rebase: workspace build, lint, format, the docs gate, and 160 island, npm and coverage tests pass.
- The last commit answers two review findings in the report (a package only an automatically islanded module imports is inside that island; a report that has only native packages' reasons is kept), with unit tests in
node-islands/report.test.ts;tests/harness/coverage.test.tsand the island unit tests pass with it.
🤖 Generated with Claude Code
Activity
17b537b76666d694185a641Brought up to date with main: merge commit 4820eabee, one test commit, and two commits for the island conformance list. Head 1411c8af7.
What conflicted. #3176 ("Honor or refuse every --node-island mark") edits moduleIslands and decideNodeIslands in executable/prepare.ts. This branch had moved both functions to node-islands/decide.ts, so git saw an edit on one side and a deletion on the other.
How it is resolved. prepare.ts stays as this branch has it, and #3176's change is applied to the moved functions in decide.ts, unchanged: a marked TypeScript module runs in an island, and a mark the build cannot honor (the entry module, a module Node cannot strip, an unmatched command-line path, a package the program does not load from node_modules) is SC8006. Checked mechanically: the moved functions differ from main's by exactly the lines they differed by before the merge.
Two things follow from coverage calling the same functions:
- Coverage now reports those SC8006 refusals as the build does. #3176 noted that coverage showed a marked entry or TypeScript module as an island the build would not run; with one decision that difference is gone.
- The island scan takes the policy's marks since #3176, and coverage caches the scan per analysis. The cache key now includes whether a request carries the marks (
scanOnceinfrontend/analysis.ts), so the report's own scan does not get the decision's answer.
No diagnostic number is used twice: SC6006 is this branch's, #3176 added messages under the existing SC8006, and codes.ts has no duplicate.
Re-run on the merged head (Linux x64 Sandbox, 32 vCPU, Intel Xeon @ 2.50GHz, Node 24.22.1, a libnode pack built from the merged tree's bridge):
- Workspace build,
pnpm lint,pnpm format:check,pnpm test:ts7 --baselines-only: pass. - Zero-cost gate (
--gate-corpus=all, againstdfa7753d3, on the merge commit): PASS. 5,247 programs identical, 103 both-fail (they need a libnode pack, which the gate does not have), none differ, no runtime-only row. Bench geomean +0.05% [-0.93%, +1.46%], self -0.01% [-1.35%, +1.37%], fixtures +0.56% [-0.19%, +1.32%]: all within tolerance. The candidate compiler's source builds with the candidate, so the mergeddecide.tscompiles natively. The commits after the merge change tests and the conformance list only. - Coverage and build agree (the check this PR describes:
scriptc coverageagainst an IR build, island set and verdict): 283 programs (every corpus program that needs a libnode pack and every npm case), 0 disagree. For the marks #3176 added, ten cases, 0 disagree: the entry mark, an unmatched path, an unloaded package, a package scriptc compiles from its own implementation, a marked.tsand.jsmodule, and the three programs whose marks are inpackage.json. Three of those are now intests/harness/node-island.test.ts("coverage names the islands a build runs"). - Island unit tests (
packages/compiler/src/node-islands,coverage-island-prederive,packages/libnode-common,packages/compiler/src/cli): 201 pass. - Island harness suites (the nine
node-island*.test.tsfiles, #3176'snode-island-marksamong them, andisland-value-matrix): 351 pass plain; sanitized, all ten files pass. - npm suite (
npm.test.ts,npm-shape-overlay.test.ts): 306 pass. - Island corpus (
differential.test.tsandllvm-differential.test.ts,-t island): 218 pass plain and 218 sanitized. tests/harness/coverage.test.ts,npm-static.test.ts,diagnostics.test.ts: pass, with one test changed. "Node island policy from the command line" gave--node-islanda package the program does not import and expected the report's "not imported" note; a build of that command line now stops with SC8006, and so does the analysis. The test keeps the note for--nativeand pins the refusal for--node-island. (The whole-corpus sweep in that file was run as CI runs it, in twenty shards: all pass.)
The island conformance lane (new on main with #3176) fails on 13 entries on the merged tree. Eleven are not this branch's: main at dfa7753d3 fails the same eleven with nothing applied, because the list was recorded before the island crossing changes merged and the lane does not run on pushes to main. Two follow from this branch's "no fallback" rule: npm/create-require with dual in an island leaves the list (the default build now fails with the same codes as the forced one), and packages/debug with debug in an island joins it (the forced build fails on the program's own SC1080 exactly as on main; the default build now also reports SC8023, so the lane no longer treats the two refusals as one). The list is regenerated with SCRIPTC_ISLAND_CONFORMANCE=update, in two commits so the two causes can be read apart: b94647bc2 is main's own refresh, 1411c8af7 this branch's two entries. A separate run in check mode and the update run observed the same failures.
Not re-run: the full test lanes, the two native bootstraps and the pi build. The resolution changes the island decision only (no lowering, IR or runtime change).
🤖 Generated with Claude Code
Brought up to date with main again (d2bbe4d99): merge commit 11f3e7045 and one commit for the island conformance list. Head ff65e3d25.
What conflicted. packages/compiler/src/diagnostics/diagnostic.ts only. #3234 added nodeIslandNativeUseDiag (SC8010) directly above nodeIslandRetryFailedDiag, the helper this branch replaced with the SC6006 warning and the SC8023 error. The resolution keeps main's new helper and this branch's two. nodeIslandRetryFailedDiag has no caller left on either side.
Re-run on the merged tree (Linux x64 Sandbox, 32 vCPU, Intel Xeon @ 2.90GHz, Node 24.22.1, a libnode pack built from the merged tree's bridge):
- Workspace build,
pnpm lint,pnpm format:check,pnpm test:ts7 --baselines-only: pass. - Coverage and build agree (this PR's check:
scriptc coverageagainst an IR build, island set and verdict): 286 programs (every corpus program that needs a libnode pack and every npm case), 0 disagree. - Island unit tests (
packages/compiler/src/node-islands,coverage-island-prederive,packages/libnode-common,packages/compiler/src/cli): 201 pass. - Island harness suites (the nine
node-island*.test.tsfiles andisland-value-matrix): 350 pass, 5 fail, plain and sanitized alike. The five are the value-matrix cells that fail onmainsince #3165 (class.control,class.p,class.w,class.u,error-class.control); #3298 re-pins them, and this branch does not touch that table. - npm suite (
npm.test.ts,npm-shape-overlay.test.ts): 306 pass. - Island corpus (
differential.test.tsandllvm-differential.test.ts,-t island): 224 pass plain, 224 sanitized. tests/harness/coverage.test.ts,npm-static.test.ts,diagnostics.test.ts: 529 pass, and the whole-corpus coverage sweep passes in CI's twenty shards (unsharded it exceeds its 600 s limit on one machine, as before).
The island conformance list. On the merged tree two entries no longer matched the list. Both move on main at d2bbe4d99 with nothing applied (measured: the lane in update mode on main alone): npm/hono-zod-default-hook with zod in an island prints its TypeError without brackets, and packages/semver now runs semver in an island by default, because the island build that failed with SC2013 at 19372cbc6 succeeds at d2bbe4d99 (#3266, #3234 and #3261 lie between; #3234 is the likely one, not bisected); its forced-island entry leaves. ff65e3d25 regenerates the list (SCRIPTC_ISLAND_CONFORMANCE=update). The result is main's own regenerated list plus this branch's two entries (npm/create-require, packages/debug), checked entry by entry. #3293 was generated before the semver change, so main needs that one entry on top of it.
Not re-run on the merged tree: the zero-cost gate (the resolution is in a diagnostics helper; no lowering, IR or runtime source changed by hand, and the gate passed on the previous merge), the full test lanes, the two native bootstraps and the pi build.
One failure CI will show that is not from this branch: packages/compiler/src/executable/executable-cache.test.ts ("every build and run option is in the cache keys…") fails on main itself for --emit-lowered, --from-lowered and --from-lowered-accept; #3306 fixes it.
🤖 Generated with Claude Code
The latest updates on your projects. Learn more about Vercel for GitHub.