Skip to content

Name island reasons, run host-class modules in Node islands, and ship Worker entries into the island tree - #2592

Draft
cramforce wants to merge 19 commits into
mainfrom
fix/island-reasons-workers
Draft

cramforce wants to merge 19 commits into
mainfrom
fix/island-reasons-workers

Conversation

@cramforce

Copy link
Copy Markdown
Contributor

Stacked on

fix/sloppy-builtin-patch-islands (#2591). Review and land that PR first; this branch adds five commits on top of it and is a second PR on the same work.

What this does

  • Named island reasons. The refusal sites that predate reason ids now map to their rule, so a build reports why a module is an island and the pi census reads the same vocabulary: dynamic-resolve (a runtime-computed specifier, SC2020 in the census and SC1090 elsewhere), worker-entry (a Worker entry path), cjs-namespace (a CommonJS namespace import of a program module) and host-class-base (a class extending a base class the program does not declare, R1.6). Of the 27 refusals the pi re-measure lists under its Node island class, five are needs of the JavaScript runtime itself and now carry a reason an island can serve; without a reason id the report counted every one as a gap. cjs-namespace is the reason the B3 implementer needs.
  • An untraceable island load is SC8006, never a crash. When an island entry's load cannot be traced back to a module the build knows, the compiler reports a named diagnostic instead of failing inside the loader.
  • Host-class modules run in a Node island (corpus 6900-node-island-host-class): a module whose class extends a base class only the JavaScript runtime provides (the census's class extends WebSocket shape; net.Socket and friends look the same) is an island, so the base class is the real one and the entry stays native.
  • A Worker entry ships its subtree into the island (corpus 6901-island-worker-entry): when an island module starts a Worker, the entry file and the modules it pulls in are written into the island tree, so the worker runs from the same island copy the parent loaded from. The worker scan reads the program rather than the TypeScript parser, so a worker entry named in plain JavaScript is found too.

Zero cost

The reasons are recorded, not decided by: a program with no island compiles to the same IR, LLVM and executable as before. The worker-entry and host-class decisions only run for modules already classified as islands.

Tests

  • Corpus, Node and native, byte-identical stdout/stderr/exit, plain and sanitized: 6900-node-island-host-class, 6901-island-worker-entry.
  • tests/harness/node-island.test.ts grows by ~286 lines: the reason names, SC8006 for an untraceable load, host-class modules, and worker entries with their subtree (needs a libnode pack), plain and SCRIPTC_SAN=1.
  • Unit tests: node-islands/classify.test.ts, node-islands/modules.test.ts.
  • pnpm lint, pnpm format:check, pnpm test:ts7 --baselines-only.

Gate

bin/validate islands-reasons-workers --skip=lanes,bench,t262,san-bootstrap vs base 794ede51d: PASS — 3415 identical, 50 both-fail over 7 shards, zero runtime-only, allowed or differs program lines; the candidate compiler source builds with the candidate (700.3s); self +1.34% within tolerance (bench timing came out inconclusive under a loaded pool, with byte-identical binaries).

🤖 Generated with Claude Code

cramforce and others added 12 commits October 29, 2026 18:16
A file the island decision put in the set is no longer compiled natively:
it is keyed by its absolute path, its declarations type the program as
foreign, its diagnostics do not fail the program the build does not
compile, and every import or require of it lowers to an island load.
`--node-island=<path>` therefore takes effect for a user module, not only
for a package.

The recovery stays one round: when the lowering refuses a user JavaScript
module, the reload adds every such module at once and each one is reported
once as an SC8001 note naming the module and its reason. The build reports
the islands it ran (packages and modules) with its result, and an
executable cache hit reports them again, so a runner can tell an island
result from a native one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Three pre-scan markers name JavaScript whose semantics need the engine:
a direct call of `eval` (it reads and writes the caller's scope), a
function declaration in a block in sloppy code (Annex B.3.2 also binds the
name as a `var` in the enclosing function), and `this` in a sloppy plain
function (a plain call gets the global object). `with` now marks only
sloppy code, where strict code is an early error Node refuses anyway.

The `this` marker is conservative by design: a file cannot see its call
sites, so every `this` in a sloppy plain function marks the module, except
the shapes the file itself proves are receiver calls — a `new`-only
constructor and an `X.prototype.name = function` method, neither of which
is also called bare (decision S5). Markers narrow, never move, the native
passes: inside a function body a block-level declaration is ordinary, the
comma and `globalThis` spellings of `eval` stay indirect, and a file that
does not parse as sloppy JavaScript keeps none of the sloppy markers, so a
parse-negative test stays the compile refusal it is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Under the default `auto` mode a user JavaScript module whose pre-scan
carries a sloppy-only construct no longer stays a refusal: it joins the
island set, so the program runs it in Node at the position it is required
(decision S3). The marked forms (`--node-island=<path>`,
`scriptc.nodeIslands.modules`) keep working unchanged, as does the
existing lowering-driven recovery.

The entry stays native: a program whose entry is an island would run the
whole file in Node, which is a different feature and not this slice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Four programs whose user module needs the engine, each run under Node and
as a compiled binary with byte-identical stdout, stderr and exit status:
`with` (including the shadowed and nested lookups), Annex B.3.2 block
functions (before, after and inside a loop), direct `eval` (reading and
writing the caller's scope, and the `var` it declares landing in the
caller's function), and `this` in a plain call (plain, `call`, `apply`,
bound, and an arrow function's enclosing `this`).

Each entry stays native and requires its module, so the programs exercise
the module island path end to end rather than a whole-file island.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The profile gets an `--islands` lane (decision S4). It lifts exactly the
five host exclusions a Node island can express — eval, Function,
arguments, script-environment, globalThis — on sloppy variants only;
harness limits, the assertion surface and `$262` stay excluded, because
the adapter still runs inside the island.

An island variant runs as a tiny native entry that imports the adapted
test, so the pre-scan can island the test module. When the build reports
that module in its islands, the outcome is `island-pass` or
`island-fail`: Node proved the semantics, so it is never counted as a
native pass and an island failure of a lifted variant is not a native
regression. The report records `islands` and `liftedVariants`, `--list`
marks lifted cases, and the harness exports each assertion helper so the
island can rebuild `assert` in JavaScript (a native function crosses the
bridge as a callable without its namespace members).

`--filter` is now repeatable, so one run can select a measured set of
files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Detect writes through a builtin module's CommonJS object (direct,
aliased, namespace, defineProperty, Object.assign, delete) as the new
island reason `builtin-patch` (R2.5), in Node islands they are collected
alongside npm and user modules, and the SC8001 note names the patched
member. A patching entry module is refused with SC8006 ("move the patch
into a module of its own"), as is a patcher whose TypeScript needs more
than Node's type stripping. A native module that reads a patched member
through the CommonJS object gets SC8020, and npm packages that read one
join the island set. Program modules also ship data files they name
beside themselves, since an island loads from the extracted tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`8423-accessor-member-kind-mismatch.ts` already owns 8423 on main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The pi re-measure (CENSUS-2026-10-29-int3) lists 27 refusals under its
Node island class. Read as reasons, they split into two very different
groups: five are needs of the JavaScript runtime itself (dynamic module
loading, a worker entry path, a host class base, a require() the runtime
owns), which an island can serve, and the rest are compiler gaps or
another family's work. Without a reason id the report cannot tell them
apart and counts every one as a gap.

The refusal sites that predate reason ids now map to their rule:
'import.meta.resolve with a runtime-computed specifier' and 'this Worker
entry point' are dynamic-resolve and worker-entry (both SC2020 in the
census, SC1090 elsewhere, so a shape may name both codes), the CJS
namespace import of a program module is cjs-namespace, and 'extending
classes not declared in the program' is host-class-base (R1.6). The
test table pins the exact census message shapes, taken from
results/pi-run/cov/build-rem3-2026-10-29.txt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The island module tree refuses a program module whose loads it cannot
trace: the module runs in Node from the shipped tree, where nothing else
provides what it loads, so a computed import() or require() would fail
there where the native build ran. The refusal pushes SC8006 and throws
PoisonError, which a statement fence around a module body catches.

Collection runs before those fences: collectIslandImport and
collectIslandRequires are called from collectNpmImports, outside any
fence, so the same refusal escaped as an uncaught PoisonError and the CLI
died instead of reporting. A module that patches a builtin (H3) and
loads a computed specifier is exactly that shape, and it is the first
thing a migration of the pi census sites meets. The two collection
entry points now catch their own PoisonError: the diagnostic the loader
pushed is the build's answer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A user TypeScript module that extends a class only the JavaScript runtime
has ('extending classes not declared in the program') needs the runtime
itself: the base class exists in Node, and native code cannot inherit it.
The island module tree carries such a module as written, so it is the one
runtime need an automatic island satisfies today, and the pi census's
openai-codex-responses site is exactly it.

Under --node-islands=auto, loweringModules now grows an island module for
that reason, as it already does for sloppy-only JavaScript modules. A
TypeScript module joins only when Node can run it as written: the scan's
stripProblem check refuses a file that needs a transform or resolves a
relative import without its extension, and the refusal then stays the
build's answer instead of a broken island. A load the shipped tree
cannot trace stays refused too (SC8006), so nothing diverges silently.

The other refusal reasons an island could serve are deliberately not
grown: a computed specifier, a worker entry path or a program-owned CJS
namespace needs the tree to ship what the module loads, which is a
shipping decision, not a lowering one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A module that runs in a Node island starts Node's own Worker, in a thread
with its own module instances and its own loader hooks. The island tree
ships program modules the island also uses as placeholders that only the
island thread's hooks answer, so a `new Worker(new URL("./worker.ts",
import.meta.url))` in an island module could not load its entry at all.

The tree now ships the entry and its static import subtree as real
sources: the collector asks a new scan (frontend/island-worker-entry.ts)
for the worker entries a module's shipped copy starts, and a fresh module
is shipped as its source even where the program provides a placeholder
(the worker thread never runs the island thread's hooks). Entries are
accepted only in the form the tree keeps, a path relative to the module
its own URL names; a computed entry, or a path that names the build
machine, stays refused as SC8006. A TypeScript file that ships this way
must pass stripProblem (a file the build never put in the program is
parsed and checked syntactically), because Node erases types in a worker
thread too, and refuses to below node_modules.

Corpus 6901 covers it: an island module starts a worker on a program module
that imports a helper, messages both ways, and the worker's helper state
stays separate from the program's, identical under Node and native.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The scan parsed a file the program never held with the parser island's
rpc-api module. The compiler is itself compiled natively, where rpc-api and
rpc-process cannot lower, so the self-hosted build failed on surface those
modules use (process.report, a computed createRequire, a spawn stdio
option). The scan reads only the program's own sources now: a worker entry
is a program root and its import graph is in the program, so the files the
tree ships are the ones the checker bound. A module outside the program has
no checked source, and its Worker stays Node's own business, as before the
tree carried it; the limitations page says so beside the refusal.

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

vercel Bot commented Oct 30, 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 11:32am UTC

@vercel-internal-security-reviewer

Copy link
Copy Markdown

Security review details

@cramforce
cramforce marked this pull request as draft October 30, 2026 03:46
@cramforce

Copy link
Copy Markdown
Contributor Author

Draft while the head branch catches up: fix/island-reasons-workers is being rebased onto fix/sloppy-builtin-patch-islands (#2591) and will be force-pushed from that rebased history, at which point this PR is retargeted at #2591 as its base and marked ready.

…ip nested worker entries, and keep one compareArray

- A native module that destructures a patched builtin's CommonJS object
  (`const { readFileSync } = require("fs")`, nested patterns, an alias of
  the object) or reads it through require() directly
  (`require("fs").readFileSync`) now records those member reads, so the
  patch check reports SC8020 for them as it does for `fs.readFileSync`.
- An island module ending in .mts is an ES module and one ending in .cts
  is CommonJS, whatever the nearest package.json says (isEsmFile and the
  effect gate's copy), so a default import of an islanded .mts file in a
  CommonJS package is its default export.
- A module of a worker entry's subtree runs in the worker thread from the
  shipped tree even when the program also compiles it, so its Worker
  constructions are scanned and their entries ship. The compiled-file
  guard now applies only outside a worker subtree.
- The Test262 harness exported assert's compareArray as `compareArray`
  beside assert.js's bare `compareArray` function, a duplicate identifier
  that made every harness compile refuse. Assert's member is now
  `assertCompareArray`, and the island lane binds the bare function too.

detect.test.ts covers the destructured and direct reads; node-island.test.ts
covers SC8020 through destructuring, the .mts default export run against
Node's output, and the nested worker entry in the shipped tree.
@cramforce

Copy link
Copy Markdown
Contributor Author

Merged origin/main: kept main's bindingAt-based recordCjsRead in node-islands/detect.ts and ported this branch's direct require("fs").member read onto it (covered by this branch's detect.test.ts case); the destructured-read harness test now expects SC8006 instead of SC8020, because main's entry-read rule makes an entry-module read of a patched member an error — the same program shape as main's own 'the entry module reading a patched member is SC8006' test. Zero-cost gate: PASS (5124 identical, no differs, no runtime-only).

@cramforce

Copy link
Copy Markdown
Contributor Author

Follow-up after the merge: the reason-id map now names the refusal behind the fenced-SDK coverage fixture (R1.6 host-class-base), so the report classes ni-sdk as runtime rather than the unnamed gap; the coverage snapshot and that test's class expectation are updated to match (Linux CI had failed on exactly that snapshot).

@cramforce

Copy link
Copy Markdown
Contributor Author

Follow-up on the merge: the lowerer's journal test required the branch's two Node island memos (islandWorkerScans, islandWorkerCompiled) by name; they are first-use caches over the program's modules and a checked moduleOrder table, so they are listed in Lowerer.unjournaled.

One conflict in packages/compiler/src/node-islands/detect.ts.
Both sides generalised CJS read recording in the same place: main through
its `bindingAt` lookup (BindingValue builtin/cjs/createRequire) and the
branch through `cjsBuiltinObjects`. Kept main's `bindingAt`-based
`recordCjsRead` and ported the branch's additions onto it: the branch's
direct `require("fs").readFileSync` case (a chain with no identifier root)
now resolves through `isCjsObjectValue`/`builtinOf`, `isCjsObjectMember`
asks `isCjsObjectValue(root)`, and `memberBaseOf`,
`STANDALONE_SUBPATH_MODULES`, and the `recordCjsPatternReads` walk are
unchanged.

The branch's destructured-read test expected SC8020, but main's entry-read
rule makes any entry-module read of a patched member SC8006, and the test's
program reads in the entry: the same shape as main's "the entry module
reading a patched member is SC8006" test. The test now asserts SC8006.

Merged, the branch's reason-id map now names the refusal behind the
fenced-SDK fixture's island (R1.6 host-class-base), so the report classes
ni-sdk "runtime" — it needs the JavaScript runtime — instead of the unnamed
"gap" a refusal without a reason id falls back to. The coverage snapshot and
that test's class expectation were updated to match.

The lowerer journal test also required the branch's two island memo fields
by name: islandWorkerScans and islandWorkerCompiled are made on first use
from the program's modules, and the compiled set is made again whenever
moduleOrder grows (a checked table), so they are listed as caches in
Lowerer.unjournaled.

The npm suite's web-streams fixture expected a deferred SC1090 refusal, which
predates this merge: the branch's worker-entry scan ships the modules an
island starts, so the package builds and runs. Its entry is now the island
form the branch's node24-util-types change uses, so the harness compares the
program against Node with a libnode pack and expects SC8005 without one.

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

Copy link
Copy Markdown
Contributor Author

Follow-ups on the merge: kept main's bindingAt/recordCjsRead in detect.ts and ported the branch's cjsBuiltinObjects additions onto it; the fenced-SDK island now reports R1.6 host-class-base (class "runtime"); the lowerer's two island memos are listed as caches in Lowerer.unjournaled; and the npm suite's web-streams case (a pre-existing red on this branch) is now the island form its node24-util-types twin uses, since the worker-entry scan ships the package and the program runs.

One conflict, in lowerer.ts: the list of Lowerer fields a recorded
lowering unit may leave unset. This branch added islandWorkerScans and
islandWorkerCompiled where main added fixedFieldFamilies,
familyDeletedNames and familyInstanceAnswers; the merged list has all
five.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Main moved while the previous merge was being tested and conflicted
again, in the Node island scan's options (pipeline.ts and scan.ts).
Both sides gave scanFiles one more way to ask for a TypeScript
module's strip problem: this branch `stripFor`, the files a build is
considering for an island, and main `marked`, the modules the policy
marks (#3176). The two are passed by different callers. The merged
scan takes both and computes the strip problem when either asks.

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

Copy link
Copy Markdown
Contributor Author

Merged main into this branch, twice, because main moved while the first merge was in test: a0c4b380d (main c8767817e) and 0aec3986d (main 7ffedc5d0). Still a draft; nothing else changed.

What conflicted. Both conflicts were two sides adding an entry at the same place.

  • lowerer.ts, the list of Lowerer fields a recorded lowering unit may leave unset: this branch added islandWorkerScans and islandWorkerCompiled where main added fixedFieldFamilies, familyDeletedNames and familyInstanceAnswers. The merged list has all five.
  • pipeline.ts and node-islands/scan.ts, the options of the island scan: this branch added stripFor (the files a build is considering for an island) where main's Honor or refuse every --node-island mark instead of ignoring it #3176 added marked (the modules the policy marks). Both ask scanFiles for the strip problem of more TypeScript modules, and different callers pass them (prepare.ts: the host-class check passes stripFor, the policy scan passes marked). The merged scan takes both and computes the strip problem when either asks.

One thing for whoever takes this PR out of draft: stripFor and marked are now two inputs with one effect. Folding them into one ("these modules need their strip problem") would touch main's side as well, so the merge does not do it.

Re-run on 0aec3986d (Linux x64 Sandbox, Node 24.22.1, a libnode pack assembled from this tree's bridge):

  • Build of the workspace and the runtime pack; pnpm lint, pnpm format:check, pnpm test:ts7 --baselines-only: clean.
  • packages/compiler/src/node-islands (with this PR's classify, detect and modules tests) and packages/libnode-common: 183 passed.
  • The eight tests/harness/node-island*.test.ts files: all pass, plain and with SCRIPTC_SAN=1 (node-island.test.ts with this PR's ten added cases).
  • Every corpus program with island in its name, 6900-node-island-host-class and 6901-island-worker-entry among them, in differential.test.ts and llvm-differential.test.ts: 222 of 222 plain and sanitized.
  • The tests this PR changes elsewhere: the island cases of coverage.test.ts (6 of 6) and node24-util-types and web-streams in npm.test.ts (2 of 2, both as Node islands).
  • On the first merge only (a0c4b380d): the whole-corpus coverage sweep of coverage.test.ts, 6 of 6 shards.

Two failures that are main's, not this branch's. Both reproduce on an untouched main 7ffedc5d0 with the same pack:

Not re-run: the zero-cost gate and the native bootstraps. The resolutions add list entries and one more optional input to a scan; no lowering, runtime or backend code was resolved by hand.

This branch was successfully deployed

1 active deployment
Preview — 0aec3986 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