From 0dcf0e95d32711238158915150227b45713ebaae Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Thu, 22 Oct 2026 18:49:54 -0700 Subject: [PATCH 1/8] =?UTF-8?q?Route=20more=20packages=20and=20values=20th?= =?UTF-8?q?rough=20Node=20islands=20(NI-4c=E2=80=934g)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Node islands stack, part 7 of 11 (scriptc-perf/results/islands-prs/PLAN.md). Co-Authored-By: Claude Opus 5.5 --- .../compiler/src/backend/llvm/lib-shared.ts | 3 +- .../compiler/src/backend/native/executable.ts | 5 +- .../compiler/src/diagnostics/diagnostic.ts | 22 +++ packages/compiler/src/diagnostics/envelope.ts | 20 ++- packages/compiler/src/diagnostics/render.ts | 20 ++- packages/compiler/src/executable/prepare.ts | 133 ++++++++++++++++-- .../src/frontend/lowering/lower-calls.ts | 16 ++- .../src/frontend/lowering/lower-modules.ts | 7 +- .../frontend/lowering/lower-node-island.ts | 110 ++++++++++++++- .../frontend/lowering/lower-shared-modules.ts | 41 ++++++ .../compiler/src/frontend/lowering/lowerer.ts | 38 ++++- packages/compiler/src/frontend/program.ts | 11 +- .../compiler/src/ir/builtin-signatures.ts | 3 + packages/compiler/src/node-islands/libnode.ts | 2 +- .../6818-node-island-mixed/main.ts.json | 6 + .../6819-node-island-fenced/main.ts.json | 6 + .../6825-node-island-init-fence/main.ts.json | 6 + .../main.ts.json | 6 + .../libnode-common/bridge/scr_node_bridge.cc | 39 +++++ .../runtime-pack-matrix.mjs | 13 +- packages/runtime/src/scr_exception.c | 13 ++ packages/runtime/src/scr_json.c | 3 + packages/runtime/src/scr_node_bridge.h | 7 +- packages/runtime/src/scr_node_island.h | 8 ++ packages/runtime/src/scr_node_island_handle.c | 101 ++++++++++++- .../corpus/6331-jose-root-entry/package.json | 1 + .../6610-npm-execa-child-mixin/package.json | 1 + tests/corpus/6818-node-island-mixed/main.ts | 49 +++++++ .../node_modules/islandlib/index.d.ts | 27 ++++ .../node_modules/islandlib/index.js | 17 +++ .../node_modules/islandlib/lib/counter.js | 10 ++ .../node_modules/islandlib/package.json | 1 + .../node_modules/wraplib/index.d.ts | 1 + .../node_modules/wraplib/index.js | 9 ++ .../node_modules/wraplib/package.json | 1 + tests/corpus/6819-node-island-fenced/main.ts | 10 ++ .../node_modules/fencedkit/index.d.ts | 7 + .../node_modules/fencedkit/index.js | 29 ++++ .../node_modules/fencedkit/package.json | 1 + .../6825-node-island-init-fence/main.ts | 16 +++ .../node_modules/bloblike/index.d.ts | 8 ++ .../node_modules/bloblike/index.js | 19 +++ .../node_modules/bloblike/package.json | 1 + .../node_modules/oncelike/once.js | 17 +++ .../node_modules/oncelike/package.json | 1 + .../node_modules/pumplike/index.js | 17 +++ .../node_modules/pumplike/package.json | 1 + .../node_modules/tarlike/index.d.ts | 1 + .../node_modules/tarlike/index.js | 13 ++ .../node_modules/tarlike/package.json | 1 + .../6826-node-island-undefined-args/main.ts | 82 +++++++++++ .../node_modules/argprobe/index.d.ts | 23 +++ .../node_modules/argprobe/index.js | 70 +++++++++ .../node_modules/argprobe/package.json | 1 + tests/harness/node-island.test.ts | 2 +- tests/harness/npm.test.ts | 86 ++++++++--- 56 files changed, 1104 insertions(+), 58 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6818-node-island-mixed/main.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6819-node-island-fenced/main.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6825-node-island-init-fence/main.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6826-node-island-undefined-args/main.ts.json create mode 100644 tests/corpus/6331-jose-root-entry/package.json create mode 100644 tests/corpus/6610-npm-execa-child-mixin/package.json create mode 100644 tests/corpus/6818-node-island-mixed/main.ts create mode 100644 tests/corpus/6818-node-island-mixed/node_modules/islandlib/index.d.ts create mode 100644 tests/corpus/6818-node-island-mixed/node_modules/islandlib/index.js create mode 100644 tests/corpus/6818-node-island-mixed/node_modules/islandlib/lib/counter.js create mode 100644 tests/corpus/6818-node-island-mixed/node_modules/islandlib/package.json create mode 100644 tests/corpus/6818-node-island-mixed/node_modules/wraplib/index.d.ts create mode 100644 tests/corpus/6818-node-island-mixed/node_modules/wraplib/index.js create mode 100644 tests/corpus/6818-node-island-mixed/node_modules/wraplib/package.json create mode 100644 tests/corpus/6819-node-island-fenced/main.ts create mode 100644 tests/corpus/6819-node-island-fenced/node_modules/fencedkit/index.d.ts create mode 100644 tests/corpus/6819-node-island-fenced/node_modules/fencedkit/index.js create mode 100644 tests/corpus/6819-node-island-fenced/node_modules/fencedkit/package.json create mode 100644 tests/corpus/6825-node-island-init-fence/main.ts create mode 100644 tests/corpus/6825-node-island-init-fence/node_modules/bloblike/index.d.ts create mode 100644 tests/corpus/6825-node-island-init-fence/node_modules/bloblike/index.js create mode 100644 tests/corpus/6825-node-island-init-fence/node_modules/bloblike/package.json create mode 100644 tests/corpus/6825-node-island-init-fence/node_modules/oncelike/once.js create mode 100644 tests/corpus/6825-node-island-init-fence/node_modules/oncelike/package.json create mode 100644 tests/corpus/6825-node-island-init-fence/node_modules/pumplike/index.js create mode 100644 tests/corpus/6825-node-island-init-fence/node_modules/pumplike/package.json create mode 100644 tests/corpus/6825-node-island-init-fence/node_modules/tarlike/index.d.ts create mode 100644 tests/corpus/6825-node-island-init-fence/node_modules/tarlike/index.js create mode 100644 tests/corpus/6825-node-island-init-fence/node_modules/tarlike/package.json create mode 100644 tests/corpus/6826-node-island-undefined-args/main.ts create mode 100644 tests/corpus/6826-node-island-undefined-args/node_modules/argprobe/index.d.ts create mode 100644 tests/corpus/6826-node-island-undefined-args/node_modules/argprobe/index.js create mode 100644 tests/corpus/6826-node-island-undefined-args/node_modules/argprobe/package.json diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index c07b0b96d..107fccf1b 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -958,13 +958,14 @@ export const LIB_FN_SYMS: Record = { "nodeIsland.defer": "scr_node_island_defer", "nodeIsland.import": "scr_ni_import", "nodeIsland.get": "scr_node_island_get", - "nodeIsland.getKey": "scr_ni_get", + "nodeIsland.getKey": "scr_node_island_get_key", "nodeIsland.set": "scr_node_island_set", "nodeIsland.setKey": "scr_node_island_set_key", "nodeIsland.invoke": "scr_node_island_invoke", "nodeIsland.apply": "scr_node_island_apply", "nodeIsland.construct": "scr_node_island_construct", "nodeIsland.instanceof": "scr_node_island_instanceof", + "nodeIsland.isArray": "scr_ni_is_array", "nodeIsland.classMethods": "scr_node_island_class_methods", "nodeIsland.subclass": "scr_node_island_subclass", "nodeIsland.superConstruct": "scr_node_island_super_construct", diff --git a/packages/compiler/src/backend/native/executable.ts b/packages/compiler/src/backend/native/executable.ts index 07a4ce965..4d12ef5ad 100644 --- a/packages/compiler/src/backend/native/executable.ts +++ b/packages/compiler/src/backend/native/executable.ts @@ -181,7 +181,10 @@ const RUNTIME_UNITS: readonly RuntimeUnit[] = [ ), unit((u) => u.opts.assert || u.regex || u.opts.symbol, "scr_assert.c"), unit((u) => u.opts.inspect, "scr_inspect.c", "scr_console_native.c"), - unit((u) => u.opts.dynInvoke || u.opts.workers || u.nativeFetch, "scr_dyn_invoke.c"), + unit( + (u) => u.opts.dynInvoke || u.opts.workers || u.nativeFetch || u.opts.nodeIsland === true, + "scr_dyn_invoke.c", + ), // Buffer.prototype methods called on checked-dynamic values. unit((u) => u.opts.dynBuffer === true && u.opts.dynInvoke, "scr_bytes_dyn.c"), // Builtin functions read as values in JavaScript dependencies. diff --git a/packages/compiler/src/diagnostics/diagnostic.ts b/packages/compiler/src/diagnostics/diagnostic.ts index 06ede8c9a..62d323237 100644 --- a/packages/compiler/src/diagnostics/diagnostic.ts +++ b/packages/compiler/src/diagnostics/diagnostic.ts @@ -105,6 +105,11 @@ export interface ScrDiagnostic { * alone does not say it (a computed require(), a native addon, `with`). * Report-only: never part of the rendered message. */ reason?: string; + /** Runtime fences only: the fenced site is in a module's top-level code + * (outside every function body), so it runs, and throws, when the module + * evaluates. Island selection treats such a fence like one that needs the + * runtime (scope-B NI-4). */ + moduleInit?: true; } /** SC3002–SC3004 — native-helper target, installation, and execution @@ -337,6 +342,23 @@ export function nodeIslandBoundaryDiag(what: string, loc: SrcLoc): ScrDiagnostic }; } +/** SC8023 — the build that ran fenced packages as Node islands failed, so + * the program was built natively with those runtime fences (each throws + * when reached). A warning on the native build, with the island build's + * first error. */ +export function nodeIslandRetryFailedDiag( + packages: readonly string[], + first: ScrDiagnostic, + loc: SrcLoc, +): ScrDiagnostic { + return { + code: "SC8023", + message: `running ${packages.map((p) => `'${p}'`).join(", ")} in Node islands failed to compile (${first.code} at ${first.loc.file}:${first.loc.start}: ${first.message}); built natively instead, where their runtime fences throw when reached`, + loc, + hint: "fix the island build's error, or mark the packages with --node-island= to see every error", + }; +} + /** SC8003 — a package marked "native" is outside the native subset. */ export function nativePackageDiag(pkg: string, why: string, loc: SrcLoc): ScrDiagnostic { return { diff --git a/packages/compiler/src/diagnostics/envelope.ts b/packages/compiler/src/diagnostics/envelope.ts index 61203b40e..25206701c 100644 --- a/packages/compiler/src/diagnostics/envelope.ts +++ b/packages/compiler/src/diagnostics/envelope.ts @@ -199,6 +199,19 @@ export interface NodeIslandsEnvelope { unknownPackages: string[]; } +/** Node island notices of a build that succeeds (scope-B): SC8001 and + * SC8021 notes (a package runs in a Node island, an island evaluates at + * boot) and the SC8020 warning (a stateful module duplicated across the + * boundary). The envelope reports all three as warnings. */ +export function isNodeIslandNoticeCode(code: string): boolean { + return code === "SC8001" || code === "SC8020" || code === "SC8021" || code === "SC8023"; +} + +/** SC8001 and SC8021 are notes, not warnings. */ +export function isNodeIslandNoteCode(code: string): boolean { + return code === "SC8001" || code === "SC8021"; +} + /** The divergence band: compiles, but the program can behave differently * from Node. Warnings, never build failures. */ export function isDivergenceCode(code: string): boolean { @@ -331,7 +344,12 @@ export function locateDiagnostic( return { code: diag.code, category: categoryOf(diag), - severity: isDivergenceCode(diag.code) || isPerformanceCode(diag.code) ? "warning" : "error", + severity: + isDivergenceCode(diag.code) || + isPerformanceCode(diag.code) || + isNodeIslandNoticeCode(diag.code) + ? "warning" + : "error", scope, file: diag.loc.file, line: start.line, diff --git a/packages/compiler/src/diagnostics/render.ts b/packages/compiler/src/diagnostics/render.ts index e5a75c6bc..232af1b44 100644 --- a/packages/compiler/src/diagnostics/render.ts +++ b/packages/compiler/src/diagnostics/render.ts @@ -4,6 +4,7 @@ * longer than FRAME_MAX_WIDTH (minified sources) shows a window of that * width around the diagnostic's column, with "…" where text is cut. */ +import { isNodeIslandNoteCode, isNodeIslandNoticeCode } from "./envelope.js"; import type { ScrDiagnostic } from "./diagnostic.js"; export interface RenderOptions { @@ -102,16 +103,23 @@ export function renderDiagnostic( frame.push( c(DIM, ` ${" ".repeat(gutterWidth)} | `) + " ".repeat(caretCol) + - c(/^SC6\d{3}$/.test(diag.code) ? YELLOW : RED, "^" + "~".repeat(spanOnLine - 1)), + c( + /^SC6\d{3}$/.test(diag.code) || isNodeIslandNoticeCode(diag.code) ? YELLOW : RED, + "^" + "~".repeat(spanOnLine - 1), + ), ); emitLine(lineNum + 1); } // The divergence band (SC6xxx) is the one warning severity: the program // compiles, and the site can behave differently from Node. - const warning = /^SC6\d{3}$/.test(diag.code); + // Node island notices of a successful build: SC8020 warns, SC8001 and + // SC8021 are notes. + const note = isNodeIslandNoteCode(diag.code); + const warning = /^SC6\d{3}$/.test(diag.code) || diag.code === "SC8020" || diag.code === "SC8023"; + const severity = note ? c(CYAN, "note") : warning ? c(YELLOW, "warning") : c(RED, "error"); out.push( - `${c(BOLD, `${diag.loc.file}:${lineNum}:${colNum}`)} - ${warning ? c(YELLOW, "warning") : c(RED, "error")} ${c(BOLD, diag.code)}: ${diag.message}`, + `${c(BOLD, `${diag.loc.file}:${lineNum}:${colNum}`)} - ${severity} ${c(BOLD, diag.code)}: ${diag.message}`, ); if (frame.length) { out.push("", ...frame); @@ -159,9 +167,11 @@ export function renderWarnings( sourceTextByFile: Map, opts: RenderOptions = {}, ): string { - const n = diags.length; + const n = diags.filter((d) => !isNodeIslandNoteCode(d.code)).length; + const rendered = renderDiagnostics([...diags], sourceTextByFile, opts); + if (n === 0) return rendered; return ( - renderDiagnostics([...diags], sourceTextByFile, opts) + + rendered + `\n\n${n} warning${n === 1 ? "" : "s"}: the program compiled, but ${n === 1 ? "this site" : "these sites"} can behave differently from Node.` ); } diff --git a/packages/compiler/src/executable/prepare.ts b/packages/compiler/src/executable/prepare.ts index a01bf303c..90504d0ef 100644 --- a/packages/compiler/src/executable/prepare.ts +++ b/packages/compiler/src/executable/prepare.ts @@ -4,6 +4,7 @@ import { frontendDisposed } from "../frontend/dispose-hook.js"; import { checkerPanicDiag, nodeIslandNoteDiag, + nodeIslandRetryFailedDiag, npmStaticRefusalDiag, iceDiag, isCheckerPanic, @@ -70,6 +71,8 @@ export function prepareExecutableModule( let lowered: LowerResult; let sourceTexts: Map; let disposed = false; + let fenceGrown: Map | null = null; + const fenceSure = new Set(); // The frontend (and its tsgo server) is released as soon as lowering // ends — clang and the link never hold it open. try { @@ -152,6 +155,31 @@ export function prepareExecutableModule( retryFences, ); } + // A failed program whose compiled packages need the runtime or a + // mutable object model (NI-2's report): try them as islands, and + // report this failure if that build fails too. + const marked = retryFences + ? fenceIslands(entryPath, opts, fe, lowered, lowerIslands, false) + : null; + if (marked !== null) { + const diagnostics = lowered.diagnostics; + const texts = fe.sourceTexts(); + fe.dispose(); + frontendDisposed(); + disposed = true; + const islanded = prepareExecutableModule( + entryPath, + opts, + ffi, + buildPlatform, + createFrontend, + timing, + marked, + false, + ); + if (islanded.ok) return islanded; + return { ok: false, diagnostics, sourceTexts: texts }; + } return fail(lowered.diagnostics); } @@ -181,6 +209,8 @@ export function prepareExecutableModule( if (checked.diagnostics.length > 0) return fail(checked.diagnostics); if (checked.contracts) lowered.module.contracts = checked.contracts; } + if (retryFences) + fenceGrown = fenceIslands(entryPath, opts, fe, lowered, lowerIslands, true, fenceSure); } finally { if (!disposed) { fe.dispose(); @@ -200,17 +230,21 @@ export function prepareExecutableModule( ...(lowered.performance ?? []), ], }; - // Runtime fences in a compiled package that need the JavaScript runtime - // itself (R1: code from strings, computed import()/require(), addons, - // loader hooks) can never run natively: build again with those packages - // as islands, and keep this build when that one cannot compile. Gap - // fences stay deferred refusals until decision 8 (D6) retires them. - const runtimeFences = lowered.runtimeFences.filter( - (d) => classifyDiagnostic(d, "fence").class === "runtime", - ); - if (retryFences && runtimeFences.length > 0) { - const grown = loweringIslands(entryPath, opts, fe, runtimeFences, lowerIslands); - if (grown !== null) { + // Packages compiled with runtime fences that need the JavaScript + // runtime or a mutable object model (fenceIslands): build again with + // them as islands, and keep this build when that one cannot compile. + // If that build fails, retry with only the packages whose fences fire + // for sure (module-init or runtime fences); if that fails too, the native + // build carries an SC8023 warning with the island build's first error, + // never a silent fallback. + if (fenceGrown !== null) { + const attempts = [fenceGrown]; + const sure = new Map( + [...fenceGrown].filter(([name]) => lowerIslands.has(name) || fenceSure.has(name)), + ); + if (sure.size > lowerIslands.size && sure.size < fenceGrown.size) attempts.push(sure); + let firstError: ScrDiagnostic | null = null; + for (const islands of attempts) { const islanded = prepareExecutableModule( entryPath, opts, @@ -218,10 +252,17 @@ export function prepareExecutableModule( buildPlatform, createFrontend, timing, - grown, + islands, false, ); if (islanded.ok) return islanded; + firstError ??= islanded.diagnostics[0] ?? null; + } + if (firstError !== null) { + const added = [...fenceGrown.keys()].filter((name) => !lowerIslands.has(name)); + prepared.warnings.push( + nodeIslandRetryFailedDiag(added, firstError, { file: entryPath, start: 0, end: 0 }), + ); } } return prepared; @@ -417,3 +458,71 @@ function preflightIslands( } return grown.size === known.size ? null : grown; } + +/** Packages compiled natively with runtime fences (refusals deferred to the + * operation that reaches them) that should run in islands instead: those + * whose island class is runtime (R1) or object-model (R2), by NI-2's report + * over the pre-scan markers and the fences, or with a fence that itself + * needs the runtime. Gap-only packages keep their fences until decision 8 + * (D6) retires them. `--node-islands=auto` only; null for none. */ +function fenceIslands( + entryPath: string, + opts: CompileRequestOptions, + fe: ReturnType, + lowered: LowerResult, + known: ReadonlyMap, + requireFence = true, + sure?: Set, +): Map | null { + if (fe.nodeIslandScan === undefined) return null; + if (requireFence && lowered.runtimeFences.length === 0) return null; + const policy = nodeIslandPolicy(readProjectConfig(entryPath), opts.nodeIslands); + if (policy.mode !== "auto" || known.size > 32) return null; + // An explicit --npm-static request keeps its package native. + const explicit = new Set( + Array.isArray(opts.npmStatic) ? opts.npmStatic.map((n) => n.replace(/\.\.\.$/, "")) : [], + ); + const compiled = new Set( + fe.npmStatic + .filter((e) => e.status === "static" && !explicit.has(e.package)) + .map((e) => e.package), + ); + const fenced = new Map(); + for (const d of lowered.runtimeFences) { + const at = packageDirOf(d.loc.file); + if (at === null || !compiled.has(at.name) || known.has(at.name)) continue; + if (policy.forcedNative.has(at.name) || policy.unloaded.has(at.name)) continue; + // A fence in top-level module code throws when the module evaluates: + // it counts like one that needs the runtime. + const runtime = d.moduleInit === true || classifyDiagnostic(d, "fence").class === "runtime"; + fenced.set(at.name, (fenced.get(at.name) ?? false) || runtime); + if (runtime) sure?.add(at.name); + } + if (requireFence && fenced.size === 0) return null; + const report = buildNodeIslandReport({ + entry: entryPath, + scan: fe.nodeIslandScan({ userJs: false }), + npm: fe.npmStatic, + diagnostics: lowered.diagnostics, + fences: lowered.runtimeFences, + policy, + }); + const grown = new Map(known); + for (const island of report.islands) { + const runtimeFence = requireFence + ? fenced.get(island.package) + : (fenced.get(island.package) ?? false); + if (runtimeFence === undefined || island.path === null) continue; + if (!compiled.has(island.package) || known.has(island.package)) continue; + if (policy.forcedNative.has(island.package) || policy.unloaded.has(island.package)) continue; + if (!runtimeFence && island.class !== "runtime" && island.class !== "object-model") continue; + grown.set(island.package, { + name: island.package, + version: island.version, + dir: island.path, + class: island.class, + reason: island.reason, + }); + } + return grown.size === known.size ? null : grown; +} diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index 9ac451788..67ebb7f05 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -4602,6 +4602,9 @@ function lowerArrayIsArrayCall( // itself instead of folding on the extracted type (typeof's rule). if (arg.kind === "dynCheck" && arg.value.type.kind === "dyn") arg = arg.value; if (arg.type.kind === "dyn") { + // With Node islands the value may be an island array (scope-B NI-4). + if (lowerer.nodeIslands !== null) + return { kind: "libCall", fn: "nodeIsland.isArray", args: [arg], type: BOOL, loc }; return { kind: "dynTest", test: "array", value: arg, type: BOOL, loc }; } if (arg.type.kind === "union") { @@ -4816,11 +4819,22 @@ function lowerUnionToStringCall( access: ts.PropertyAccessExpression, ): IrExpr | null { if (call.questionDotToken || access.questionDotToken) return null; - if (access.name.text !== "toString" || call.arguments.nodes.length !== 0) return null; + if (access.name.text !== "toString") return null; + // `.toString("utf8")` on `string | Buffer` (a Buffer's default decode; + // String.prototype.toString ignores its arguments): the same helper. + const args = call.arguments.nodes; + const utf8Arg = + args.length === 1 && ts.isStringLiteralLike(args[0]!) && /^utf-?8$/i.test(args[0].text); + if (args.length !== 0 && !utf8Arg) return null; const recvT = lowerer.mapTypeOf(lowerer.typeOf(access.expression)); if (recvT?.kind !== "union") return null; if (!lowerer.isStdlibMember(access)) return null; const def = lowerer.unions.get(recvT.unionId); + if ( + utf8Arg && + !def?.arms.every((a) => a.kind === "string" || (a.kind === "bytes" && a.elem === "u8")) + ) + return null; const stringable = def?.arms.every( (a) => a.kind === "string" || diff --git a/packages/compiler/src/frontend/lowering/lower-modules.ts b/packages/compiler/src/frontend/lowering/lower-modules.ts index b8e67d79e..817ee73c3 100644 --- a/packages/compiler/src/frontend/lowering/lower-modules.ts +++ b/packages/compiler/src/frontend/lowering/lower-modules.ts @@ -1,7 +1,7 @@ import { isIslandPromisifyCall, noteIslandSubclass } from "./lower-node-island.js"; import { isIslandSubclassDecl } from "../../node-islands/boundary.js"; import { THREADS_MODULE } from "../threads-import.js"; -import { collectIslandImport } from "./lower-node-island.js"; +import { collectIslandImport, collectIslandRequires } from "./lower-node-island.js"; import { NODE_API_MODULE } from "../node-api-import.js"; import { positionTag } from "./structural-positions.js"; import { EXPERIMENTAL_MODULE } from "../experimental-import.js"; @@ -699,7 +699,10 @@ export function collectNpmImports(lowerer: Lowerer, parts: FileParts[]): void { } } } - for (const fp of parts) collectDynamicImports(lowerer, fp.sf); + for (const fp of parts) { + collectDynamicImports(lowerer, fp.sf); + collectIslandRequires(lowerer, fp.sf); + } } /** Literal dynamic-import collection: every diff --git a/packages/compiler/src/frontend/lowering/lower-node-island.ts b/packages/compiler/src/frontend/lowering/lower-node-island.ts index d192f2460..40fb46f8f 100644 --- a/packages/compiler/src/frontend/lowering/lower-node-island.ts +++ b/packages/compiler/src/frontend/lowering/lower-node-island.ts @@ -356,10 +356,32 @@ export function collectIslandImport( // module evaluated. const clause = ts.isImportDeclaration(stmt) ? stmt.importClause : undefined; const named = clause?.namedBindings; + const snapshotNames: string[] = []; if (!binding.esm && named !== undefined && ts.isNamedImports(named)) { for (const el of named.elements.nodes) { if (el.isTypeOnly) continue; - const name = el.propertyName?.text ?? el.name.text; + snapshotNames.push(el.propertyName?.text ?? el.name.text); + } + } + // A CommonJS namespace import's members are the same snapshot: the + // members the file reads (`ns.name`). + if (!binding.esm && named !== undefined && ts.isNamespaceImport(named)) { + const nsSymbol = lowerer.checker.getSymbolAtLocation(named.name); + ts.walkPreorder(sf, (node) => { + if ( + ts.isPropertyAccessExpression(node) && + ts.isIdentifier(node.expression) && + ts.isIdentifier(node.name) && + node.expression.text === named.name.text && + lowerer.checker.getSymbolAtLocation(node.expression) === nsSymbol && + !snapshotNames.includes(node.name.text) + ) + snapshotNames.push(node.name.text); + return undefined; + }); + } + { + for (const name of snapshotNames) { if (name === "default") continue; let g = binding.snapshots.get(name); if (g === undefined) { @@ -394,6 +416,7 @@ function islandGet(target: IrExpr, key: string, loc: SrcLoc): IrExpr { } type IslandBinding = + | { kind: "required"; module: IslandModuleBinding } | { kind: "namespace"; module: IslandModuleBinding } | { kind: "default"; module: IslandModuleBinding } | { kind: "named"; module: IslandModuleBinding; name: string }; @@ -424,7 +447,13 @@ function islandBindingOf(lowerer: Lowerer, ident: ts.Identifier): IslandBinding const name = decl.propertyName?.text ?? decl.name.text; binding = name === "default" ? "default" : { name }; } else { - return null; + // `const x = require("pkg")` (a CommonJS module of a native package + // requiring an island package): x is module.exports. + const spec = requireSpecOf(decl); + if (spec === null) return null; + const path = islandModuleFile(islands, ts.getSourceFileOfNode(decl).fileName, spec, "require"); + const module = path === null ? undefined : lowerer.islandModules.get(path); + return module === undefined ? null : { kind: "required", module }; } const spec = moduleSpecifierOf(importDecl); if (spec === null || importDecl === undefined) return null; @@ -451,6 +480,8 @@ function islandBindingValue( ): IrExpr { const ns = varRef(b.module.global.id, DYN, loc); switch (b.kind) { + case "required": + return ns; case "namespace": if (!b.module.esm) boundaryFail(lowerer, "the namespace object of a CommonJS island module as a value", node); @@ -530,6 +561,8 @@ function copiedType(lowerer: Lowerer, t: IrType): boolean { case "bigint": case "nullT": case "undefinedT": + // Buffers and typed arrays share their memory with the V8 view. + case "bytes": return true; case "union": { const def = lowerer.unions.get(t.unionId); @@ -939,6 +972,11 @@ function freshLiteral( node: ts.ObjectLiteralExpression | ts.ArrayLiteralExpression, ): IrExpr { const loc = locOf(node); + // Spreads, methods, accessors and computed keys: the literal lowers as + // the program's own object, which crosses by reference (NI-5 exports); + // nothing else holds it, so that is exact. + if (!plainLiteral(node)) + return lowerer.coerceToExpected(lowerer.lowerExprExpecting(node, DYN), DYN); if (ts.isArrayLiteralExpression(node)) { const elems = node.elements.nodes.map((el) => { if (ts.isSpreadElement(el) || ts.isOmittedExpression(el)) @@ -1433,6 +1471,8 @@ function islandReceiver( if (b !== null && b.kind === "namespace" && !b.module.esm) { const exports = varRef(b.module.global.id, DYN, loc); if (member === "default") return { value: exports, direct: exports }; + const snapshot = b.module.snapshots.get(member); + if (snapshot !== undefined) return { value: exports, direct: varRef(snapshot.id, DYN, loc) }; return { value: exports }; } } @@ -1761,6 +1801,72 @@ export function lowerIslandSuperMethodCall( return fromIsland(lowerer, raw, call); } +/** A literal freshLiteral copies member by member: identifier or string + * keys with values, shorthand properties, array elements without spreads + * or holes. */ +function plainLiteral(node: ts.ObjectLiteralExpression | ts.ArrayLiteralExpression): boolean { + if (ts.isArrayLiteralExpression(node)) + return node.elements.nodes.every( + (el) => !ts.isSpreadElement(el) && !ts.isOmittedExpression(el), + ); + return node.properties.nodes.every( + (p) => + (ts.isPropertyAssignment(p) && (ts.isIdentifier(p.name) || ts.isStringLiteral(p.name))) || + ts.isShorthandPropertyAssignment(p), + ); +} + +/** The specifier of `x = require("spec")` (a variable declaration binding + * an identifier to a literal require), or null. */ +function requireSpecOf(decl: ts.Node): string | null { + if ( + !ts.isVariableDeclaration(decl) || + !ts.isIdentifier(decl.name) || + decl.initializer === undefined + ) + return null; + let init: ts.Expression = decl.initializer; + while (ts.isParenthesizedExpression(init)) init = init.expression; + if ( + !ts.isCallExpression(init) || + !ts.isIdentifier(init.expression) || + init.expression.text !== "require" || + init.arguments.nodes.length !== 1 + ) + return null; + const arg = init.arguments.nodes[0]!; + return ts.isStringLiteralLike(arg) ? arg.text : null; +} + +/** Top-level `const x = require("pkg")` statements of island packages in a + * compiled module (a native package requiring an island package): load the + * island module in the module's %init prelude (shortcut: before the body, + * not at the statement). */ +export function collectIslandRequires(lowerer: Lowerer, sf: ts.SourceFile): void { + const islands = lowerer.nodeIslands; + if (islands === null) return; + for (const stmt of sf.statements.nodes) { + if (!ts.isVariableStatement(stmt)) continue; + for (const decl of stmt.declarationList.declarations.nodes) { + const spec = requireSpecOf(decl); + if (spec === null) continue; + const path = islandModuleFile(islands, sf.fileName, spec, "require"); + if (path === null) continue; + lowerer.usesNodeIsland = true; + const loc = locOf(decl); + const binding = islandModuleGlobal(lowerer, path); + const actions = lowerer.jsonInitActions.get(sf) ?? []; + lowerer.jsonInitActions.set(sf, actions); + actions.push({ + kind: "assign", + localId: binding.global.id, + value: loadIslandModule(path, loc), + loc, + }); + } + } +} + /* `x instanceof Error` for values that may be island objects (NI-5c). */ const INTRINSIC_ERRORS = new Set([ "Error", diff --git a/packages/compiler/src/frontend/lowering/lower-shared-modules.ts b/packages/compiler/src/frontend/lowering/lower-shared-modules.ts index 4a63beb91..653526879 100644 --- a/packages/compiler/src/frontend/lowering/lower-shared-modules.ts +++ b/packages/compiler/src/frontend/lowering/lower-shared-modules.ts @@ -275,6 +275,11 @@ export function statefulReason(sf: ts.SourceFile): string | null { ) continue; if (ts.isExpressionStatement(stmt) && ts.isStringLiteral(stmt.expression)) continue; // directives + // CommonJS export statements (`module.exports = f`, `exports.x = v`, + // `module.exports.x = v`) and expandos on the module's own functions + // (`f.sync = g`) declare the module's surface; they are not effects. + if (ts.isExpressionStatement(stmt) && isCjsDeclaringAssignment(stmt.expression, functions(sf))) + continue; return "its evaluation has effects beyond declarations"; } // Writes from function bodies to module-level bindings. @@ -527,3 +532,39 @@ export function sharedModuleRegistrations(lowerer: Lowerer): IrStmt[] { }); return out; } + +/** The names of a module's top-level function declarations. */ +function functions(sf: ts.SourceFile): Set { + const names = new Set(); + for (const stmt of sf.statements.nodes) + if (ts.isFunctionDeclaration(stmt) && stmt.name !== undefined) names.add(stmt.name.text); + return names; +} + +/** `module.exports = …`, `exports.x = …`, `module.exports.x = …` (also + * chained: `exports = module.exports = …`), or `f.x = …` for a top-level + * function `f`. */ +function isCjsDeclaringAssignment(e: ts.Expression, fns: Set): boolean { + while (ts.isParenthesizedExpression(e)) e = e.expression; + if (!ts.isBinaryExpression(e) || e.operatorToken.kind !== ts.SyntaxKind.EqualsToken) return false; + const left = e.left; + const isModuleExports = (n: ts.Expression): boolean => + ts.isPropertyAccessExpression(n) && + ts.isIdentifier(n.expression) && + n.expression.text === "module" && + ts.isIdentifier(n.name) && + n.name.text === "exports"; + const target = + (ts.isIdentifier(left) && left.text === "exports") || + isModuleExports(left) || + (ts.isPropertyAccessExpression(left) && + ((ts.isIdentifier(left.expression) && + (left.expression.text === "exports" || fns.has(left.expression.text))) || + isModuleExports(left.expression))); + if (!target) return false; + // A chained assignment declares too. + const right = e.right; + if (ts.isBinaryExpression(right) && right.operatorToken.kind === ts.SyntaxKind.EqualsToken) + return isCjsDeclaringAssignment(right, fns); + return true; +} diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 22be5cd06..7d97131e5 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -1031,12 +1031,48 @@ export function lowerToIr( ): LowerResult { setActiveNodeIslands(options.nodeIslands ?? null); try { - return lowerToIrWithIslands(program, entry, moduleOrder, options); + const result = lowerToIrWithIslands(program, entry, moduleOrder, options); + markModuleInitFences(program, result.runtimeFences); + return result; } finally { setActiveNodeIslands(null); } } +/** Marks the runtime fences in npm package code whose site is top-level + * module code (outside every function body): they throw when the module + * evaluates (ScrDiagnostic.moduleInit). */ +function markModuleInitFences(program: ts.Program, fences: readonly ScrDiagnostic[]): void { + for (const d of fences) { + if (!d.loc.file.includes("/node_modules/")) continue; + const sf = program.getSourceFile(d.loc.file); + if (sf === undefined) continue; + let inFunction = false; + let node: ts.Node | undefined = sf; + while (node !== undefined && !inFunction) { + let next: ts.Node | undefined; + ts.forEachChild(node, (child) => { + if ( + next === undefined && + ts.tokenStartOfNode(child) <= d.loc.start && + d.loc.start < child.end + ) + next = child; + return undefined; + }); + if (next === undefined) break; + if ( + ts.isFunctionLike(next) || + (ts.isPropertyDeclaration(next) && + !next.modifiers?.nodes.some((m) => m.kind === ts.SyntaxKind.StaticKeyword)) + ) + inFunction = true; + node = next; + } + if (!inFunction) d.moduleInit = true; + } +} + function lowerToIrWithIslands( program: ts.Program, entry: ts.SourceFile, diff --git a/packages/compiler/src/frontend/program.ts b/packages/compiler/src/frontend/program.ts index 37e944899..f22edc35b 100644 --- a/packages/compiler/src/frontend/program.ts +++ b/packages/compiler/src/frontend/program.ts @@ -5505,7 +5505,11 @@ function preflight7(load: LoadResult): { // optional-dependency try/require pattern), so no fence. // Binding forms keep the fence: their downstream reads // would need the module that never loads. - if (req.decl !== null || probeNodeRequireRefusal(sf.fileName, req.spec) === null) { + if ( + (req.decl !== null || probeNodeRequireRefusal(sf.fileName, req.spec) === null) && + // A Node island package: loaded in Node at the require (NI-4). + !isFrontendIsland(packageNameOfSpecifier(req.spec)) + ) { diags.push(unsupportedDiag("SC1010", loc, unsupportedModuleFeatureOf(req.spec))); } } @@ -5562,7 +5566,10 @@ function preflight7(load: LoadResult): { if (canonicalBuiltinModule(spec) === null && !processModuleAliasRequire7(spec, null)) { // Binding-less by construction — same require-site throw // channel as the statement-level form above. - if (probeNodeRequireRefusal(sf.fileName, spec) === null) { + if ( + probeNodeRequireRefusal(sf.fileName, spec) === null && + !isFrontendIsland(packageNameOfSpecifier(spec)) + ) { diags.push(unsupportedDiag("SC1010", loc, unsupportedModuleFeatureOf(spec))); } } diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index 585f7798d..53333b9c2 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -201,6 +201,9 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ "nodeIsland.apply": { argTypes: [DYN, DYN], result: DYN }, "nodeIsland.construct": { argTypes: [DYN, DYN], result: DYN }, "nodeIsland.instanceof": { argTypes: [DYN, DYN], result: BOOL }, + /** Array.isArray of a dyn value in a program with Node islands: island + * arrays answer true too. */ + "nodeIsland.isArray": { argTypes: [DYN], result: BOOL }, /** NI-5: a class instance crossing into an island, with the builder of * its class's method table (run once per class). Answers the value. */ "nodeIsland.classMethods": { argTypes: [DYN, DYN], result: DYN }, diff --git a/packages/compiler/src/node-islands/libnode.ts b/packages/compiler/src/node-islands/libnode.ts index 68967a619..d0b46c08c 100644 --- a/packages/compiler/src/node-islands/libnode.ts +++ b/packages/compiler/src/node-islands/libnode.ts @@ -23,7 +23,7 @@ import { findInstalledLibnodePack, hostLibnodeTarget, libnodePackPackage } from export const NODE_ISLAND_MIN_NODE = "24.13.1"; /** The bridge ABI this compiler's runtime speaks (scr_node_bridge.h). */ -export const NODE_BRIDGE_ABI = 8; +export const NODE_BRIDGE_ABI = 9; /** The prefix of a recorded path relative to the executable's directory. */ export const ORIGIN = "$ORIGIN/"; diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6818-node-island-mixed/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6818-node-island-mixed/main.ts.json new file mode 100644 index 000000000..faf99c163 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6818-node-island-mixed/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6818-node-island-mixed/main.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6819-node-island-fenced/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6819-node-island-fenced/main.ts.json new file mode 100644 index 000000000..16d9cc12b --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6819-node-island-fenced/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6819-node-island-fenced/main.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6825-node-island-init-fence/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6825-node-island-init-fence/main.ts.json new file mode 100644 index 000000000..e2367ec2e --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6825-node-island-init-fence/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6825-node-island-init-fence/main.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6826-node-island-undefined-args/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6826-node-island-undefined-args/main.ts.json new file mode 100644 index 000000000..a864c36e1 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6826-node-island-undefined-args/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6826-node-island-undefined-args/main.ts" + ], + "diags": [] +} diff --git a/packages/libnode-common/bridge/scr_node_bridge.cc b/packages/libnode-common/bridge/scr_node_bridge.cc index a2a708467..584325d50 100644 --- a/packages/libnode-common/bridge/scr_node_bridge.cc +++ b/packages/libnode-common/bridge/scr_node_bridge.cc @@ -1550,6 +1550,41 @@ Local ToV8(Isolate *isolate, Local context, const ScrNodeValue * } } +// Node's arrow header for an exception's throw site, recorded once on the +// error object (as Node decorates an error the first time it is thrown): +// ":\n\n", the caret under the +// message's columns with tabs kept. Printed by the runtime when the error +// ends the program uncaught (ErrorInfo's `arrow`). +Local ArrowKey(Isolate *isolate) { + return v8::Private::ForApi(isolate, Utf8(isolate, "scriptc.node-island.arrow")); +} + +void RecordArrow(Isolate *isolate, Local context, const TryCatch &try_catch, + Local exception) { + if (!exception->IsObject()) return; + Local error = exception.As(); + Local key = ArrowKey(isolate); + if (error->HasPrivate(context, key).FromMaybe(true)) return; + // Node decorates from the error's own message (its construction site, + // as Exception::CreateMessage answers for an Error with a stack), not + // from where the throw left the call. + Local message = v8::Exception::CreateMessage(isolate, exception); + if (message.IsEmpty()) message = try_catch.Message(); + if (message.IsEmpty()) return; + Local source; + if (!message->GetSourceLine(context).ToLocal(&source)) return; + std::string line = *String::Utf8Value(isolate, source); + std::string resource = *String::Utf8Value(isolate, message->GetScriptResourceName()); + int linenum = message->GetLineNumber(context).FromMaybe(0); + int start = message->GetStartColumn(context).FromMaybe(0); + int end = message->GetEndColumn(context).FromMaybe(start + 1); + std::string arrow = resource + ":" + std::to_string(linenum) + "\n" + line + "\n"; + for (int i = 0; i < start; i++) + arrow += (i < static_cast(line.size()) && line[i] == '\t') ? '\t' : ' '; + for (int i = start; i < end || i == start; i++) arrow += '^'; + (void)error->SetPrivate(context, key, Utf8(isolate, arrow.data(), arrow.size())); +} + // One entry from the runtime: the isolate scope, V8's stack limit for the // caller's stack, and the out-value scratch (reset by outermost entries). struct Op { @@ -1567,6 +1602,7 @@ struct Op { } if (try_catch.HasCaught() && !try_catch.HasTerminated()) { Local exception = try_catch.Exception(); + RecordArrow(e.isolate, e.context, try_catch, exception); try_catch.Reset(); FromV8(e.isolate, e.context, exception, out); } else { @@ -1828,6 +1864,9 @@ void ErrorInfo(uint32_t h, ScrNodeErrorInfo *out) { read("message", &out->message, &out->message_len, false); read("stack", &out->stack, &out->stack_len, true); read("code", &out->code, &out->code_len, true); + Local arrow; + if (error->GetPrivate(op.e.context, ArrowKey(op.e.isolate)).ToLocal(&arrow) && arrow->IsString()) + out->arrow = Scratch(Wtf8(op.e.isolate, arrow.As()), &out->arrow_len); } void DropHandle(uint32_t h) { diff --git a/packages/runtime-pack-common/runtime-pack-matrix.mjs b/packages/runtime-pack-common/runtime-pack-matrix.mjs index 6a6eab132..c15b707d6 100644 --- a/packages/runtime-pack-common/runtime-pack-matrix.mjs +++ b/packages/runtime-pack-common/runtime-pack-matrix.mjs @@ -60,7 +60,9 @@ export const OPTIONAL_RUNTIME_SOURCES = [ ["scr_assert.c", any("assert", "regex", "symbol")], ["scr_inspect.c", "inspect"], ["scr_console_native.c", "inspect"], - ["scr_dyn_invoke.c", any("dynInvoke", "nativeFetch")], + // Island libCalls on native values typed by island declarations invoke + // through the checked-dynamic path (scr_node_island_handle.c). + ["scr_dyn_invoke.c", any("dynInvoke", "nativeFetch", "nodeIsland")], // Buffer.prototype methods called on checked-dynamic values. ["scr_bytes_dyn.c", all("dynBuffer", "dynInvoke")], // Builtin functions read as values in JavaScript dependencies. @@ -191,8 +193,13 @@ export function variantsFor(source) { defines: ["SCR_JS_TIER", "SCR_NODE_ISLANDS"], }); } - // util.inspect and instanceof of Node island handles. - if (source === "scr_inspect.c" || source === "scr_dyn_builtin_class.c") { + // util.inspect and instanceof of Node island handles; Node's report for + // an uncaught island error. + if ( + source === "scr_inspect.c" || + source === "scr_dyn_builtin_class.c" || + source === "scr_exception.c" + ) { variants.push({ id: "node-island", when: { nodeIsland: true }, diff --git a/packages/runtime/src/scr_exception.c b/packages/runtime/src/scr_exception.c index c7ff71349..bccb1b941 100644 --- a/packages/runtime/src/scr_exception.c +++ b/packages/runtime/src/scr_exception.c @@ -9,6 +9,9 @@ * dummy returns); this file only stores, renders, and releases the value. */ #include "scr_runtime.h" +#ifdef SCR_NODE_ISLANDS +#include "scr_node_island.h" +#endif #include #include @@ -684,6 +687,16 @@ void scr_exc_print_uncaught(void) { /* Settle any runtime-internal stdout fragment before the stderr line when * both share an fd. JavaScript-visible writes already flush themselves. */ fflush(stdout); +#ifdef SCR_NODE_ISLANDS + /* An error thrown by Node island code: Node's own report (the throw + * site's source line, the stack, the Node.js version). */ + if (scr_exc_kind == SCR_EXC_OBJ && scr_error_is(scr_exc_payload) && + scr_ni_report_uncaught((ScrError *)scr_exc_payload)) { + scr_exc_reset(); /* the payload counts as live until released */ + scr_exc_cell_changed(); + return; + } +#endif fputs("Uncaught ", stderr); switch (scr_exc_kind) { case SCR_EXC_F64: { diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index d72038480..dcb459785 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -3791,6 +3791,9 @@ static bool scr_native_iterator_over(const ScrDyn *value, unsigned kind); /* Node util.types reads internal brands, never user properties or prototypes. */ bool scr_dyn_util_type_is(const ScrDyn *value, const ScrStr *probe) { if (!value) return false; +#ifdef SCR_NODE_ISLANDS + if (scr_ni_is_handle(value)) return scr_ni_util_type_is(value, probe); +#endif #define PROBE(name) (probe->len == sizeof(name) - 1 && memcmp(probe->data, name, sizeof(name) - 1) == 0) if (PROBE("isAnyArrayBuffer")) return scr_buffer_storage_is(value); diff --git a/packages/runtime/src/scr_node_bridge.h b/packages/runtime/src/scr_node_bridge.h index 969477b58..830b67193 100644 --- a/packages/runtime/src/scr_node_bridge.h +++ b/packages/runtime/src/scr_node_bridge.h @@ -17,7 +17,7 @@ #include #include -#define SCR_NODE_BRIDGE_ABI 8u +#define SCR_NODE_BRIDGE_ABI 9u /* The symbol a bridge library exports: `const ScrNodeBridge *(void)`. */ #define SCR_NODE_BRIDGE_ENTRY "scr_node_bridge_v1" @@ -149,6 +149,11 @@ typedef struct ScrNodeErrorInfo { uint32_t kind; const char *name, *message, *stack, *code; size_t name_len, message_len, stack_len, code_len; + /* Node's arrow header for the throw site (":\n\n"), recorded when the error was thrown out of island + * code; NULL when none. */ + const char *arrow; + size_t arrow_len; } ScrNodeErrorInfo; /* What the runtime provides to the bridge. */ diff --git a/packages/runtime/src/scr_node_island.h b/packages/runtime/src/scr_node_island.h index 99eab37eb..926128222 100644 --- a/packages/runtime/src/scr_node_island.h +++ b/packages/runtime/src/scr_node_island.h @@ -73,6 +73,13 @@ ScrDyn *scr_ni_call_method(ScrDyn *obj, const char *key, size_t len, ScrDyn *con size_t argc); ScrDyn *scr_ni_construct(ScrDyn *ctor, ScrDyn *const *args, size_t argc); ScrStr *scr_ni_typeof(ScrDyn *value); +/* util.types.(value) answered by Node for an island handle. */ +bool scr_ni_util_type_is(const ScrDyn *value, const ScrStr *probe); +/* Array.isArray of a dyn value, island arrays included. */ +bool scr_ni_is_array(ScrDyn *value); +/* Prints Node's uncaught-exception report for an error thrown by island + * code (true), or nothing (false: not an island error). */ +bool scr_ni_report_uncaught(ScrError *error); int scr_ni_instanceof(ScrDyn *value, ScrDyn *ctor); bool scr_ni_is_handle(const ScrDyn *value); /* ToPrimitive of an island object (hint SCR_DYN_HINT_*), run by V8: @@ -172,6 +179,7 @@ ScrDyn *scr_node_island_apply(ScrDyn *fn, ScrDyn *args); ScrDyn *scr_node_island_construct(ScrDyn *ctor, ScrDyn *args); void scr_node_island_define_field(ScrDyn *self, ScrStr *key, ScrDyn *value); ScrDyn *scr_node_island_get(ScrDyn *obj, ScrStr *key); +ScrDyn *scr_node_island_get_key(ScrDyn *obj, ScrDyn *key); bool scr_node_island_instanceof(ScrDyn *value, ScrDyn *ctor); bool scr_node_island_instanceof_global(ScrDyn *value, ScrStr *name); ScrDyn *scr_node_island_invoke(ScrDyn *obj, ScrStr *key, ScrDyn *args); diff --git a/packages/runtime/src/scr_node_island_handle.c b/packages/runtime/src/scr_node_island_handle.c index 3374bfe9d..4d03a4898 100644 --- a/packages/runtime/src/scr_node_island_handle.c +++ b/packages/runtime/src/scr_node_island_handle.c @@ -1402,6 +1402,58 @@ void scr_ni_handles_install(void) { scr_atexit(nh_cleanup); } +/* ── NI-4d: util.types, Array.isArray and uncaught reports ─────────────── */ + +static ScrDyn *nh_util_types = NULL; /* Node's util.types (held to exit) */ + +static void nh_util_types_cleanup(void) { + scr_dyn_release(nh_util_types); + nh_util_types = NULL; +} + +bool scr_ni_util_type_is(const ScrDyn *value, const ScrStr *probe) { + if (nh_util_types == NULL) { + ScrStr *path = scr_str_new("node:util", 9); + ScrDyn *util = scr_ni_require(path); + scr_str_release(path); + if (util == NULL) return false; + nh_util_types = scr_ni_get_str(util, "types", 5); + if (nh_util_types != NULL) scr_atexit(nh_util_types_cleanup); + scr_dyn_release(util); + if (nh_util_types == NULL) return false; + } + ScrDyn *args[1] = {(ScrDyn *)value}; + ScrDyn *r = scr_ni_call_method(nh_util_types, probe->data, probe->len, args, 1); + if (r == NULL) return false; + bool answer = r->kind == SCR_DYN_BOOL && r->v.b; + scr_dyn_release(r); + return answer; +} + +bool scr_ni_is_array(ScrDyn *value) { + if (value == NULL) return false; + if (value->kind == SCR_DYN_ARR) return true; + if (!scr_ni_is_handle(value)) return value->kind == SCR_DYN_TYPED_REF && scr_dyn_ref_is_array(value); + return (((ScrNodeRef *)value->v.handle.ptr)->flags & SCR_NVF_ARRAY) != 0; +} + +bool scr_ni_report_uncaught(ScrError *error) { + ScrNodeRef *ref = nh_pinned(error, NH_PIN_ERROR); + const ScrNodeBridge *bridge = scr_ni_bridge_loaded(); + if (ref == NULL || ref->id == 0 || bridge == NULL) return false; + ScrNodeErrorInfo info; + bridge->error_info(ref->id, &info); + if (info.stack == NULL) return false; + if (info.arrow != NULL && info.arrow_len > 0) { + fwrite(info.arrow, 1, info.arrow_len, stderr); + fputs("\n\n", stderr); + } + fwrite(info.stack, 1, info.stack_len, stderr); + fprintf(stderr, "\n\nNode.js v%s\n", bridge->node_version); + fflush(stderr); + return true; +} + /* ── NI-4a: libCall entry points of compiled island code ───────────────── * The compiler (frontend/lowering/lower-node-island.ts) lowers member * operations on island values to `nodeIsland.*` libCalls, which pass @@ -1418,31 +1470,70 @@ static size_t ni_libcall_args(ScrDyn *args, ScrDyn *const **items) { return args->v.arr.len; } +/* Routing follows the value: a value typed by an island's declarations + * may still be a native object (an island interface annotating a native + * literal, a native error handed to an island callback). Those take the + * ordinary checked-dynamic operations instead of the bridge. */ +ScrDyn *scr_dyn_reflect_get(ScrDyn *target, ScrDyn *raw_key, ScrDyn *receiver); + +static bool ni_lc_island(const ScrDyn *v) { return v != NULL && scr_ni_is_handle(v); } + +/* A NUL-terminated copy of `key` for the dyn invoke path (heap only for long names). */ +static ScrDyn *ni_lc_native_invoke(ScrDyn *obj, ScrStr *key, ScrDyn *const *items, size_t argc) { + char small[128]; + char *name = key->len < sizeof small ? small : malloc(key->len + 1); + if (name == NULL) scr_trap("scriptc: out of memory\n"); + memcpy(name, key->data, key->len); + name[key->len] = '\0'; + ScrDyn *result = scr_dyn_invoke(obj, name, items, argc, name); + if (name != small) free(name); + return result; +} + ScrDyn *scr_node_island_get(ScrDyn *obj, ScrStr *key) { - return scr_ni_get_str(obj, key->data, key->len); + if (ni_lc_island(obj)) return scr_ni_get_str(obj, key->data, key->len); + ScrDyn *k = scr_dyn_new_str(key); + ScrDyn *value = scr_dyn_reflect_get(obj, k, obj); + scr_dyn_release(k); + return value; +} + +ScrDyn *scr_node_island_get_key(ScrDyn *obj, ScrDyn *key) { + if (ni_lc_island(obj)) return scr_ni_get(obj, key); + return scr_dyn_reflect_get(obj, key, obj); } void scr_node_island_set(ScrDyn *obj, ScrStr *key, ScrDyn *value) { - (void)scr_ni_set_str(obj, key->data, key->len, value); + if (ni_lc_island(obj)) (void)scr_ni_set_str(obj, key->data, key->len, value); + else scr_dyn_key_set(obj, key, value); } void scr_node_island_set_key(ScrDyn *obj, ScrDyn *key, ScrDyn *value) { - (void)scr_ni_set(obj, key, value); + if (ni_lc_island(obj)) (void)scr_ni_set(obj, key, value); + else scr_dyn_key_set_computed(obj, key, value); } ScrDyn *scr_node_island_invoke(ScrDyn *obj, ScrStr *key, ScrDyn *args) { ScrDyn *const *items; size_t argc = ni_libcall_args(args, &items); - return scr_ni_call_method(obj, key->data, key->len, items, argc); + if (ni_lc_island(obj)) return scr_ni_call_method(obj, key->data, key->len, items, argc); + return ni_lc_native_invoke(obj, key, items, argc); } ScrDyn *scr_node_island_apply(ScrDyn *fn, ScrDyn *args) { ScrDyn *const *items; size_t argc = ni_libcall_args(args, &items); - return scr_ni_call(fn, NULL, items, argc); + if (ni_lc_island(fn)) return scr_ni_call(fn, NULL, items, argc); + return scr_dyn_call(fn, items, argc, "value"); } ScrDyn *scr_node_island_construct(ScrDyn *ctor, ScrDyn *args) { + if (!ni_lc_island(ctor)) { + ScrStr *what = scr_str_new("value", 5); + ScrDyn *result = scr_dyn_construct(ctor, args, what); + scr_str_release(what); + return result; + } ScrDyn *const *items; size_t argc = ni_libcall_args(args, &items); return scr_ni_construct(ctor, items, argc); diff --git a/tests/corpus/6331-jose-root-entry/package.json b/tests/corpus/6331-jose-root-entry/package.json new file mode 100644 index 000000000..30d3e0af0 --- /dev/null +++ b/tests/corpus/6331-jose-root-entry/package.json @@ -0,0 +1 @@ +{ "type": "module", "scriptc": { "packages": { "jose": "native" } } } diff --git a/tests/corpus/6610-npm-execa-child-mixin/package.json b/tests/corpus/6610-npm-execa-child-mixin/package.json new file mode 100644 index 000000000..5a418a30c --- /dev/null +++ b/tests/corpus/6610-npm-execa-child-mixin/package.json @@ -0,0 +1 @@ +{ "type": "module", "scriptc": { "packages": { "run-node": "native" } } } diff --git a/tests/corpus/6818-node-island-mixed/main.ts b/tests/corpus/6818-node-island-mixed/main.ts new file mode 100644 index 000000000..4c1e05d76 --- /dev/null +++ b/tests/corpus/6818-node-island-mixed/main.ts @@ -0,0 +1,49 @@ +// @requires-libnode +// A Node island package used from both sides: a natively compiled package +// requires it; island-declared interfaces type native objects too (member +// operations follow the value, not the declared type); options built with +// spread and method members cross in by reference; a `string | Buffer` +// member reads back from an island object; a rejection's error is read in +// a native callback. +import { twice } from "wraplib"; +import { + countFiles, + describeFile, + blob, + text, + render, + failLater, + type Files, + type File, + type FailError, +} from "islandlib"; + +console.log("twice", twice(5)); + +const files: Files = {}; +files["a.txt"] = { mode: 420, data: "alpha" }; +files["b.txt"] = blob("beta"); +console.log("files", countFiles(files), files["a.txt"]!.mode, Object.keys(files).join(",")); +const local: File = files["a.txt"]!; +console.log("local", local.mode, describeFile(local), describeFile(files["b.txt"]!)); + +const fromIsland = blob("gamma"); +const data = fromIsland.data; +console.log("buffer", typeof data === "string" ? data : data.toString("utf8"), describeFile(text("delta"))); + +const base = { name: "job", retries: 1 }; +console.log( + "render", + render({ + ...base, + retries: 3, + format(name: string): string { + return name.toUpperCase(); + }, + }), +); + +failLater("nope").catch((e: unknown) => { + const err = e as FailError; + console.log("caught", err.message, err.code, (e as { message?: string }).message); +}); diff --git a/tests/corpus/6818-node-island-mixed/node_modules/islandlib/index.d.ts b/tests/corpus/6818-node-island-mixed/node_modules/islandlib/index.d.ts new file mode 100644 index 000000000..1940c427d --- /dev/null +++ b/tests/corpus/6818-node-island-mixed/node_modules/islandlib/index.d.ts @@ -0,0 +1,27 @@ +export declare class Counter { + constructor(start: number); + value: number; + inc(): number; +} +export interface File { + mode: number; + data: string | Buffer; +} +export interface Files { + [path: string]: File; +} +export interface RenderOptions { + name: string; + retries: number; + format(name: string): string; +} +export interface FailError extends Error { + code: string; +} +export declare function makeCounter(start: number): Counter; +export declare function countFiles(files: Files): number; +export declare function describeFile(file: File): string; +export declare function blob(text: string): File; +export declare function text(text: string): File; +export declare function render(opts: RenderOptions): string; +export declare function failLater(message: string): Promise; diff --git a/tests/corpus/6818-node-island-mixed/node_modules/islandlib/index.js b/tests/corpus/6818-node-island-mixed/node_modules/islandlib/index.js new file mode 100644 index 000000000..a9ec7e11d --- /dev/null +++ b/tests/corpus/6818-node-island-mixed/node_modules/islandlib/index.js @@ -0,0 +1,17 @@ +"use strict"; +// Loads its parts with a computed specifier (R1.2): a Node island. +const { createRequire } = require("node:module"); +const load = createRequire(__filename); +const { Counter } = load(["./lib", "counter"].join("/")); + +exports.makeCounter = (start) => new Counter(start); +exports.Counter = Counter; +// A files map: native callers build their own with the same interface. +exports.countFiles = (files) => Object.keys(files).length; +exports.describeFile = (file) => `${file.mode}:${file.data.toString("utf8")}`; +exports.blob = (text) => ({ mode: 420, data: Buffer.from(text) }); +exports.text = (text) => ({ mode: 384, data: text }); +// Takes options built from spread and method members. +exports.render = (opts) => `${opts.name}/${opts.retries}/${opts.format(opts.name)}`; +// Settles a promise with an Error the caller inspects in .catch. +exports.failLater = (message) => Promise.reject(Object.assign(new Error(message), { code: "EFAIL" })); diff --git a/tests/corpus/6818-node-island-mixed/node_modules/islandlib/lib/counter.js b/tests/corpus/6818-node-island-mixed/node_modules/islandlib/lib/counter.js new file mode 100644 index 000000000..def5f80e4 --- /dev/null +++ b/tests/corpus/6818-node-island-mixed/node_modules/islandlib/lib/counter.js @@ -0,0 +1,10 @@ +"use strict"; +class Counter { + constructor(start) { + this.value = start; + } + inc() { + return ++this.value; + } +} +exports.Counter = Counter; diff --git a/tests/corpus/6818-node-island-mixed/node_modules/islandlib/package.json b/tests/corpus/6818-node-island-mixed/node_modules/islandlib/package.json new file mode 100644 index 000000000..b3a5a4e8b --- /dev/null +++ b/tests/corpus/6818-node-island-mixed/node_modules/islandlib/package.json @@ -0,0 +1 @@ +{ "name": "islandlib", "version": "2.0.0", "main": "index.js", "types": "index.d.ts" } diff --git a/tests/corpus/6818-node-island-mixed/node_modules/wraplib/index.d.ts b/tests/corpus/6818-node-island-mixed/node_modules/wraplib/index.d.ts new file mode 100644 index 000000000..225a4106b --- /dev/null +++ b/tests/corpus/6818-node-island-mixed/node_modules/wraplib/index.d.ts @@ -0,0 +1 @@ +export declare function twice(start: number): number; diff --git a/tests/corpus/6818-node-island-mixed/node_modules/wraplib/index.js b/tests/corpus/6818-node-island-mixed/node_modules/wraplib/index.js new file mode 100644 index 000000000..6c010494f --- /dev/null +++ b/tests/corpus/6818-node-island-mixed/node_modules/wraplib/index.js @@ -0,0 +1,9 @@ +"use strict"; +// A plain package scriptc compiles natively that requires an island package. +const islandlib = require("islandlib"); + +exports.twice = function twice(start) { + const c = islandlib.makeCounter(start); + c.inc(); + return c.inc(); +}; diff --git a/tests/corpus/6818-node-island-mixed/node_modules/wraplib/package.json b/tests/corpus/6818-node-island-mixed/node_modules/wraplib/package.json new file mode 100644 index 000000000..5679ba18c --- /dev/null +++ b/tests/corpus/6818-node-island-mixed/node_modules/wraplib/package.json @@ -0,0 +1 @@ +{ "name": "wraplib", "version": "1.0.0", "main": "index.js", "types": "index.d.ts", "dependencies": { "islandlib": "2.0.0" } } diff --git a/tests/corpus/6819-node-island-fenced/main.ts b/tests/corpus/6819-node-island-fenced/main.ts new file mode 100644 index 000000000..7416c5ce4 --- /dev/null +++ b/tests/corpus/6819-node-island-fenced/main.ts @@ -0,0 +1,10 @@ +// @requires-libnode +// A package scriptc compiles natively only with runtime fences that need +// a mutable object model (getters over live bindings, a constructor +// function with prototype methods) runs in a Node island automatically. +import { bump, current, Tally } from "fencedkit"; +import kit from "fencedkit"; + +console.log(current, bump(), bump(), kit.current); +const t = new Tally(1); +console.log(t.add(2) === t, t.add(3).total, t instanceof Tally); diff --git a/tests/corpus/6819-node-island-fenced/node_modules/fencedkit/index.d.ts b/tests/corpus/6819-node-island-fenced/node_modules/fencedkit/index.d.ts new file mode 100644 index 000000000..817efabc9 --- /dev/null +++ b/tests/corpus/6819-node-island-fenced/node_modules/fencedkit/index.d.ts @@ -0,0 +1,7 @@ +export declare const current: number; +export declare function bump(): number; +export declare class Tally { + constructor(start: number); + total: number; + add(n: number): Tally; +} diff --git a/tests/corpus/6819-node-island-fenced/node_modules/fencedkit/index.js b/tests/corpus/6819-node-island-fenced/node_modules/fencedkit/index.js new file mode 100644 index 000000000..a6dac5623 --- /dev/null +++ b/tests/corpus/6819-node-island-fenced/node_modules/fencedkit/index.js @@ -0,0 +1,29 @@ +"use strict"; +// The esbuild CommonJS bundle prologue: getters over live `let` bindings, +// plus a constructor function with prototype methods (R2.1). scriptc +// compiles it natively only with runtime fences, so it runs in a Node +// island. +var __defProp = Object.defineProperty; +var __export = (target, all) => { + for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); +}; +var src_exports = {}; +__export(src_exports, { + Tally: () => Tally, + current: () => current, + bump: () => bump, +}); +module.exports = src_exports; +let current = 0; +function bump() { + current += 1; + return current; +} +function Tally(start) { + this.total = start; +} +Tally.prototype.add = function (n) { + this.total += n; + return this; +}; +0 && (module.exports = { Tally, bump, current }); diff --git a/tests/corpus/6819-node-island-fenced/node_modules/fencedkit/package.json b/tests/corpus/6819-node-island-fenced/node_modules/fencedkit/package.json new file mode 100644 index 000000000..51fc5639c --- /dev/null +++ b/tests/corpus/6819-node-island-fenced/node_modules/fencedkit/package.json @@ -0,0 +1 @@ +{ "name": "fencedkit", "version": "1.0.0", "main": "index.js", "types": "index.d.ts" } diff --git a/tests/corpus/6825-node-island-init-fence/main.ts b/tests/corpus/6825-node-island-init-fence/main.ts new file mode 100644 index 000000000..7e5ba8391 --- /dev/null +++ b/tests/corpus/6825-node-island-init-fence/main.ts @@ -0,0 +1,16 @@ +// @requires-libnode +// A package whose top-level code always hits a runtime fence (pump's +// `var` function exported by reference), reached through a natively +// compiled package, runs in a Node island automatically; a `string | +// Buffer` member read from an island object decodes with toString("utf8"); +// a CommonJS helper required on both sides is duplicated quietly (its +// exports are declarations). +import { pack } from "tarlike"; +import { fromText, fromBytes, onceTotal } from "bloblike"; + +console.log("pack", pack([1, 2, 3])); +for (const blob of [fromText("plain"), fromBytes("bytes")]) { + const data = blob.data; + console.log("blob", blob.mode.toString(8), data.toString("utf8"), typeof data === "string"); +} +console.log("once", onceTotal([4, 5])); diff --git a/tests/corpus/6825-node-island-init-fence/node_modules/bloblike/index.d.ts b/tests/corpus/6825-node-island-init-fence/node_modules/bloblike/index.d.ts new file mode 100644 index 000000000..3858e4a0c --- /dev/null +++ b/tests/corpus/6825-node-island-init-fence/node_modules/bloblike/index.d.ts @@ -0,0 +1,8 @@ +export declare class FileBlob { + constructor(data: string | Buffer); + data: string | Buffer; + mode: number; +} +export declare function fromText(text: string): FileBlob; +export declare function fromBytes(text: string): FileBlob; +export declare function onceTotal(values: number[]): number; diff --git a/tests/corpus/6825-node-island-init-fence/node_modules/bloblike/index.js b/tests/corpus/6825-node-island-init-fence/node_modules/bloblike/index.js new file mode 100644 index 000000000..31ccad123 --- /dev/null +++ b/tests/corpus/6825-node-island-init-fence/node_modules/bloblike/index.js @@ -0,0 +1,19 @@ +"use strict"; +// Loads its parts with a computed specifier (R1.2): a Node island. It +// also requires the CommonJS helper the native side uses. +const { createRequire } = require("node:module"); +const once = createRequire(__filename)(["once", "like"].join("")); + +class FileBlob { + constructor(data) { + this.data = data; + this.mode = 0o644; + } +} +exports.FileBlob = FileBlob; +exports.fromText = (text) => new FileBlob(text); +exports.fromBytes = (text) => new FileBlob(Buffer.from(text)); +exports.onceTotal = (values) => { + const sum = once(() => values.reduce((a, b) => a + b, 0)); + return sum() + sum(); +}; diff --git a/tests/corpus/6825-node-island-init-fence/node_modules/bloblike/package.json b/tests/corpus/6825-node-island-init-fence/node_modules/bloblike/package.json new file mode 100644 index 000000000..b9db808e4 --- /dev/null +++ b/tests/corpus/6825-node-island-init-fence/node_modules/bloblike/package.json @@ -0,0 +1 @@ +{ "name": "bloblike", "version": "1.0.0", "main": "index.js", "types": "index.d.ts", "dependencies": { "oncelike": "1.4.0" } } diff --git a/tests/corpus/6825-node-island-init-fence/node_modules/oncelike/once.js b/tests/corpus/6825-node-island-init-fence/node_modules/oncelike/once.js new file mode 100644 index 000000000..b1f19e69a --- /dev/null +++ b/tests/corpus/6825-node-island-init-fence/node_modules/oncelike/once.js @@ -0,0 +1,17 @@ +// A CommonJS helper both native code and an island require: its exports +// are declarations, so each side's copy is unobservable. +module.exports = once; +once.strict = onceStrict; +function once(fn) { + var f = function () { + if (f.called) return f.value; + f.called = true; + return (f.value = fn.apply(this, arguments)); + }; + f.called = false; + return f; +} +function onceStrict(fn) { + var f = once(fn); + return f; +} diff --git a/tests/corpus/6825-node-island-init-fence/node_modules/oncelike/package.json b/tests/corpus/6825-node-island-init-fence/node_modules/oncelike/package.json new file mode 100644 index 000000000..852d69c5a --- /dev/null +++ b/tests/corpus/6825-node-island-init-fence/node_modules/oncelike/package.json @@ -0,0 +1 @@ +{ "name": "oncelike", "version": "1.4.0", "main": "once.js" } diff --git a/tests/corpus/6825-node-island-init-fence/node_modules/pumplike/index.js b/tests/corpus/6825-node-island-init-fence/node_modules/pumplike/index.js new file mode 100644 index 000000000..01bf4da9e --- /dev/null +++ b/tests/corpus/6825-node-island-init-fence/node_modules/pumplike/index.js @@ -0,0 +1,17 @@ +// pump's shape: a function stored in a module-level `var` and exported by +// reference after it is defined. +var once = require("oncelike"); + +var noop = function () {}; + +var pumpish = function () { + var steps = Array.prototype.slice.call(arguments); + var done = once(typeof steps[steps.length - 1] === "function" ? steps.pop() : noop); + var total = steps.reduce(function (acc, n) { + return acc + n; + }, 0); + done(total); + return total; +}; + +module.exports = pumpish; diff --git a/tests/corpus/6825-node-island-init-fence/node_modules/pumplike/package.json b/tests/corpus/6825-node-island-init-fence/node_modules/pumplike/package.json new file mode 100644 index 000000000..40b2cc4e2 --- /dev/null +++ b/tests/corpus/6825-node-island-init-fence/node_modules/pumplike/package.json @@ -0,0 +1 @@ +{ "name": "pumplike", "version": "3.0.3", "main": "index.js", "dependencies": { "oncelike": "1.4.0" } } diff --git a/tests/corpus/6825-node-island-init-fence/node_modules/tarlike/index.d.ts b/tests/corpus/6825-node-island-init-fence/node_modules/tarlike/index.d.ts new file mode 100644 index 000000000..9de17307a --- /dev/null +++ b/tests/corpus/6825-node-island-init-fence/node_modules/tarlike/index.d.ts @@ -0,0 +1 @@ +export declare function pack(sizes: number[]): string; diff --git a/tests/corpus/6825-node-island-init-fence/node_modules/tarlike/index.js b/tests/corpus/6825-node-island-init-fence/node_modules/tarlike/index.js new file mode 100644 index 000000000..22eb47020 --- /dev/null +++ b/tests/corpus/6825-node-island-init-fence/node_modules/tarlike/index.js @@ -0,0 +1,13 @@ +"use strict"; +// A typed package scriptc compiles natively that requires pump's shape. +const pump = require("pumplike"); +const once = require("oncelike"); + +exports.pack = function pack(sizes) { + let reported = 0; + const report = once((n) => { + reported = n; + }); + const total = pump(...sizes, report); + return `${total}/${reported}`; +}; diff --git a/tests/corpus/6825-node-island-init-fence/node_modules/tarlike/package.json b/tests/corpus/6825-node-island-init-fence/node_modules/tarlike/package.json new file mode 100644 index 000000000..877fe5f27 --- /dev/null +++ b/tests/corpus/6825-node-island-init-fence/node_modules/tarlike/package.json @@ -0,0 +1 @@ +{ "name": "tarlike", "version": "2.1.5", "main": "index.js", "types": "index.d.ts", "dependencies": { "pumplike": "3.0.3", "oncelike": "1.4.0" } } diff --git a/tests/corpus/6826-node-island-undefined-args/main.ts b/tests/corpus/6826-node-island-undefined-args/main.ts new file mode 100644 index 000000000..94a350e67 --- /dev/null +++ b/tests/corpus/6826-node-island-undefined-args/main.ts @@ -0,0 +1,82 @@ +// @requires-libnode +// Arguments crossing into a Node island keep JavaScript's exact shape: +// explicit `undefined` arguments count (arguments.length, default +// parameters), an omitted optional field is absent while one holding +// `undefined` is present (`in`, hasOwnProperty, Object.keys), and native +// number arrays arrive as arrays. +import { + probe, + defaults, + version, + inspect, + nodeVersionFor, + cwd, + envValue, + type Config, +} from "argprobe"; +import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +interface Settings { + nodeVersion?: string; +} +function settingsFor(flag: boolean): Settings | undefined { + return flag ? { nodeVersion: "20.x" } : undefined; +} + +console.log(probe()); +console.log(probe(undefined)); +console.log(probe("a", undefined)); +console.log(probe("a", {}, undefined)); +console.log(probe("a", { nodeVersion: undefined }, undefined, [18, 20, 22])); +const settings = settingsFor(false); +console.log(probe("a", { nodeVersion: settings?.nodeVersion }, undefined, [22])); +console.log(probe("a", { label: "x", nodeVersion: undefined })); + +console.log(defaults()); +console.log(defaults(undefined)); +console.log(defaults(undefined, "y")); +console.log(defaults("x", undefined, "r")); + +const available: number[] = [18, 20, 22]; +console.log(version("/work", "unused", { nodeVersion: settings?.nodeVersion }, undefined, available)); +console.log(version("/work", "unused", { nodeVersion: settingsFor(true)?.nodeVersion }, undefined, available)); +console.log(version("/work", undefined, undefined, undefined, available)); +console.log(inspect({ nodeVersion: undefined, list: [1, undefined, 3] })); + +// A native object typed by the island's own interface, read with `?.`. +function projectSettings(flag: boolean): Config | undefined { + return flag ? { nodeVersion: "20.x", label: "proj" } : undefined; +} +const project = projectSettings(true); +const none = projectSettings(false); +console.log(version("/work", "unused", { nodeVersion: project?.nodeVersion }, undefined, available)); +console.log(version("/work", "unused", { nodeVersion: none?.nodeVersion }, undefined, available)); +console.log(probe("p", { nodeVersion: project?.nodeVersion }), probe("n", { nodeVersion: none?.nodeVersion })); + +async function main(): Promise { + // A package.json the program writes just before the island reads it. + const dir = mkdtempSync(join(tmpdir(), "argprobe-")); + try { + writeFileSync(join(dir, "package.json"), JSON.stringify({ engines: { node: "22.x" } })); + console.log(await nodeVersionFor(dir, "unused", { nodeVersion: settings?.nodeVersion }, undefined, available)); + rmSync(join(dir, "package.json")); + console.log(await nodeVersionFor(dir, "unused", { nodeVersion: "18.x" }, { isDev: true })); + try { + await nodeVersionFor(dir, "unused", { nodeVersion: settings?.nodeVersion }, undefined, available); + } catch (e) { + console.log("rejected", (e as Error).message); + } + // Process state the program changes after Node loaded is Node's too. + const before = process.cwd(); + process.chdir(dir); + console.log("cwd", cwd() === process.cwd(), cwd() === before); + process.chdir(before); + process.env["ARGPROBE_SETTING"] = "set-natively"; + console.log("env", envValue("ARGPROBE_SETTING")); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} +void main(); diff --git a/tests/corpus/6826-node-island-undefined-args/node_modules/argprobe/index.d.ts b/tests/corpus/6826-node-island-undefined-args/node_modules/argprobe/index.d.ts new file mode 100644 index 000000000..72cde09b4 --- /dev/null +++ b/tests/corpus/6826-node-island-undefined-args/node_modules/argprobe/index.d.ts @@ -0,0 +1,23 @@ +export interface Config { + nodeVersion?: string; + label?: string; +} +export declare function probe(a?: unknown, b?: Config, c?: unknown, d?: number[]): string; +export declare function defaults(x?: string, y?: string, ...rest: string[]): string; +export declare function version( + path: string, + fallback?: string, + config?: Config, + meta?: { nodeVersion?: string }, + available?: number[], +): string; +export declare function inspect(v: unknown): string; +export declare function nodeVersionFor( + dir: string, + fallback?: string, + config?: Config, + meta?: { isDev?: boolean }, + available?: number[], +): Promise; +export declare function cwd(): string; +export declare function envValue(name: string): string | undefined; diff --git a/tests/corpus/6826-node-island-undefined-args/node_modules/argprobe/index.js b/tests/corpus/6826-node-island-undefined-args/node_modules/argprobe/index.js new file mode 100644 index 000000000..5a9e4245e --- /dev/null +++ b/tests/corpus/6826-node-island-undefined-args/node_modules/argprobe/index.js @@ -0,0 +1,70 @@ +"use strict"; +// Loads its parts with a computed specifier (R1.2): a Node island. Its +// functions report exactly what arrives: argument counts, explicit +// undefined, key presence of undefined-valued fields, arrays. +const { createRequire } = require("node:module"); +const util = createRequire(__filename)(["node", "util"].join(":")); + +function describeValue(v) { + if (v === undefined) return "undefined"; + if (Array.isArray(v)) return `array(${v.length}:${v.map(describeValue).join(",")})`; + if (v !== null && typeof v === "object") + return `{${Object.keys(v) + .map((k) => `${k}=${describeValue(v[k])}`) + .join(",")}}`; + return JSON.stringify(v); +} + +exports.probe = function probe(a, b, c, d) { + return [ + `len=${arguments.length}`, + `a=${describeValue(a)}`, + `b=${describeValue(b)}`, + `in=${b !== undefined && b !== null && "nodeVersion" in b}`, + `own=${b !== undefined && b !== null && Object.prototype.hasOwnProperty.call(b, "nodeVersion")}`, + `c=${describeValue(c)}`, + `d=${describeValue(d)}`, + ].join(" "); +}; + +exports.defaults = function defaults(x = "dx", y = "dy", ...rest) { + return `${x}|${y}|rest=${rest.length}|len=${arguments.length}`; +}; + +exports.version = function version(path, fallback = "unused", config = {}, meta, available) { + const wanted = config.nodeVersion ?? (meta && meta.nodeVersion) ?? null; + const list = Array.isArray(available) ? available : []; + if (wanted === null) { + const latest = Math.max(...list); + return `${path}: ${list.includes(latest) ? `${latest}.x` : fallback}`; + } + return `${path}: ${wanted}`; +}; + +exports.inspect = (v) => util.inspect(v); + +// A version-lookup helper's shape: async, defaults for omitted +// arguments, engines.node from the package.json in `dir` first. +exports.nodeVersionFor = async function nodeVersionFor( + dir, + fallback = "unused", + config = {}, + meta = {}, + available = [16], +) { + const fs = require("node:fs/promises"); + const path = require("node:path"); + let engines = null; + try { + engines = JSON.parse(await fs.readFile(path.join(dir, "package.json"), "utf8")).engines ?? null; + } catch { + engines = null; + } + if (engines && engines.node) return `engines:${engines.node}:${Math.max(...available)}`; + const configured = config.nodeVersion || fallback; + if (configured === "unused") throw new Error(`Found invalid or discontinued Node.js Version: "${configured}"`); + return `configured:${configured}:${meta.isDev === true}`; +}; + +exports.cwd = () => process.cwd(); +exports.envValue = (name) => process.env[name]; diff --git a/tests/corpus/6826-node-island-undefined-args/node_modules/argprobe/package.json b/tests/corpus/6826-node-island-undefined-args/node_modules/argprobe/package.json new file mode 100644 index 000000000..4b7cb717e --- /dev/null +++ b/tests/corpus/6826-node-island-undefined-args/node_modules/argprobe/package.json @@ -0,0 +1 @@ +{ "name": "argprobe", "version": "1.0.0", "main": "index.js", "types": "index.d.ts" } diff --git a/tests/harness/node-island.test.ts b/tests/harness/node-island.test.ts index 1f6b525cb..f205e8f68 100644 --- a/tests/harness/node-island.test.ts +++ b/tests/harness/node-island.test.ts @@ -160,7 +160,7 @@ describe.skipIf(skip)("Node islands in-process", () => { const result = await run(binary, [], { ...process.env, SCRIPTC_LIBNODE: dir }); expect(result.code).toBe(1); expect(result.stderr).toBe( - `scriptc: the libnode pack at ${dir} has an incompatible bridge (ABI 999; this program needs 8)\n`, + `scriptc: the libnode pack at ${dir} has an incompatible bridge (ABI 999; this program needs 9)\n`, ); }); diff --git a/tests/harness/npm.test.ts b/tests/harness/npm.test.ts index 7533693cc..d08a4c0fb 100644 --- a/tests/harness/npm.test.ts +++ b/tests/harness/npm.test.ts @@ -45,20 +45,20 @@ const KNOWN: Record = { "any-chaining": { build: "SC2003" }, "assert-shims": { run: "SC1043" }, "buffer-shims": { run: "SC2020" }, - "bytes-exit": { build: "SC2004" }, + "bytes-exit": { island: true }, "cjs-esm-named": { island: true }, "cjs-lexer-aligned": { island: true }, "commander-calc": { run: "SC1090", argvs: [["--help"]] }, "commander-calc-async": { run: "SC1090", argvs: [["--help"]] }, "create-require": { run: "SC1090" }, - "dotfolder": { run: "SC1090" }, + "dotfolder": { island: true }, "dual-package-kinds": { island: true }, "dynamic-import": { island: true }, "dynamic-import-local": { build: "SC2012" }, "esbuild-require": { island: true }, "esm-missing-default": { build: "SC1090" }, "esm-missing-export": { build: "SC1090" }, - "fetch-destructure": { run: "SC1031" }, + "fetch-destructure": { island: true }, "fs-shims": { run: "SC1101" }, "import-refusal": { build: "SC2020" }, "island-registry": { build: "SC2011" }, @@ -66,42 +66,46 @@ const KNOWN: Record = { divergence: "Timeout handles are numbers (typeof) and lose their ref flag once fired or cleared", }, + "hot-shots-udp": { + divergence: + "as a Node island (its runtime fences pick one), output stops after the socket-not-running error; the built-in hot-shots shim (#1836) compiles it natively", + }, "island-web-plumbing": { run: "SC2020" }, "lazy-traps": { island: true }, - "misc-shims": { run: "SC2020" }, + "misc-shims": { island: true }, namespace: { island: true }, "node24-async-events": { run: "SC2020" }, "node24-commonjs-metadata": { island: true }, "node24-crypto-kdf": { run: "SC2020" }, - "node24-deep-equality": { run: "SC2020" }, + "node24-deep-equality": { island: true }, "node24-diagnostics-async": { run: "SC1090" }, "node24-diagnostics-tracing": { run: "SC1090" }, - "node24-env": { run: "SC1090" }, - "node24-esm-metadata": { run: "SC1090" }, + "node24-env": { island: true }, + "node24-esm-metadata": { island: true }, "node24-event-emitter": { run: "SC2020" }, "node24-file-url-conversion": { island: true }, "node24-fs-descriptors": { run: "SC2020" }, - "node24-mime": { run: "SC2020" }, - "node24-mime-errors": { run: "SC1090" }, + "node24-mime": { island: true }, + "node24-mime-errors": { island: true }, "node24-no-error-assertions": { run: "SC2020" }, - "node24-parseargs": { run: "SC1090" }, + "node24-parseargs": { island: true }, "node24-promise-timers": { run: "SC2020" }, "node24-string-decoder": { run: "SC2020" }, - "node24-style-text": { run: "SC2020" }, + "node24-style-text": { island: true }, "node24-system-errors": { run: "SC1090" }, "node24-text-encoding": { run: "SC2020" }, "node24-text-utilities": { run: "SC2020" }, "node24-timers-unref": { run: "SC2020" }, "node24-url-factories": { run: "SC2020" }, "node24-url-mutation": { run: "SC1090" }, - "node24-util-adapters": { run: "Object.create with this value is not supported yet" }, - "node24-util-types": { run: "SC1090" }, + "node24-util-adapters": { island: true }, + "node24-util-types": { island: true }, "node24-web-stream-encoding": { run: "SC1090" }, "node24-web-stream-errors": { run: "SC1090" }, "node24-web-stream-lifecycle": { run: "SC1090" }, "node24-web-stream-pipe-abort": { run: "SC2020" }, "node24-web-stream-pipe-errors": { run: "SC1090" }, - "node24-web-stream-pipes": { run: "SC1090" }, + "node24-web-stream-pipes": { island: true }, "node24-web-stream-queues": { run: "SC1090" }, "node24-web-stream-readable-lifecycle": { run: "SC2020" }, "node24-web-stream-readers": { run: "SC1090" }, @@ -110,8 +114,8 @@ const KNOWN: Record = { "package-imports": { island: true }, "promise-bridge": { build: "SC1090" }, "self-name": { build: "SC2013" }, - "stream-shims": { run: "SC1090" }, - "util-shims": { run: "SC2020" }, + "stream-shims": { island: true }, + "util-shims": { island: true }, "web-streams": { run: "SC1090" }, "workspace-copied": { island: true }, "workspace-unclean": { build: "SC2013" }, @@ -189,7 +193,45 @@ async function build(entry: string): Promise { }; } -async function expectMatchesNode(c: NpmCase, binary: string): Promise { +/** The two stderr texts with their stack frame runs cut to the frames + * they share: an uncaught error from a Node island prints Node's report, + * whose frames match Node's from the throw site up to the first native + * frame, which has no Node-identical spelling (DESIGN.md §2.10). Every + * other line, and each run's common leading frames, compare exactly. */ + +/** Node's uncaught-error report ends with `Node.js v`: the island + * prints libnode's release, which may differ from the reference Node's + * patch release. */ +function normalizeNodeVersion([a, b]: [string, string]): [string, string] { + const re = /^Node\.js v\d+\.\d+\.\d+$/m; + return [a.replace(re, "Node.js v"), b.replace(re, "Node.js v")]; +} + +function sharedFrames(native: Buffer, node: Buffer): [string, string] { + const frame = (line: string): boolean => /^ {4}at /.test(line); + const a = native.toString("utf8").split("\n"); + const b = node.toString("utf8").split("\n"); + const outA: string[] = []; + const outB: string[] = []; + let i = 0; + let j = 0; + while (i < a.length || j < b.length) { + if (i < a.length && j < b.length && frame(a[i]!) && frame(b[j]!)) { + while (i < a.length && j < b.length && frame(a[i]!) && a[i] === b[j]) { + outA.push(a[i++]!); + outB.push(b[j++]!); + } + while (i < a.length && frame(a[i]!)) i++; + while (j < b.length && frame(b[j]!)) j++; + continue; + } + if (i < a.length) outA.push(a[i++]!); + if (j < b.length) outB.push(b[j++]!); + } + return [outA.join("\n"), outB.join("\n")]; +} + +async function expectMatchesNode(c: NpmCase, binary: string, islandFrames = false): Promise { const cwd = dirname(c.entry); for (const argv of c.argvs ?? [[]]) { const [nodeRes, nativeRes] = await Promise.all([ @@ -202,7 +244,13 @@ async function expectMatchesNode(c: NpmCase, binary: string): Promise { expect.unreachable("stdout differed at byte level but not after utf8 decode"); } expect(nativeRes.exitCode, label).toBe(nodeRes.exitCode); - if (c.compareStderr) expect(programStderr(nativeRes.stderr), label).toEqual(nodeRes.stderr); + if (c.compareStderr && islandFrames) { + const [native, node] = normalizeNodeVersion( + sharedFrames(programStderr(nativeRes.stderr), nodeRes.stderr), + ); + expect(native, label).toBe(node); + } else if (c.compareStderr) + expect(programStderr(nativeRes.stderr), label).toEqual(nodeRes.stderr); } } @@ -231,7 +279,7 @@ describe(`npm native (${cases.length} programs${sanitize ? ", sanitized" : ""}${ return; } if (!built.ok) throw new Error(`npm fixture failed to compile:\n${built.text}`); - await expectMatchesNode(c, built.binary); + await expectMatchesNode(c, built.binary, true); }, 120_000); continue; } From b388156593cdfecddead67818ec0f5574bbd9fc4 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Thu, 22 Oct 2026 19:08:34 -0700 Subject: [PATCH 2/8] Make packages islands only for fences that surely fire; keep jose and run-node native without pins A gap fence inside a function body may never run, so it no longer makes its package a Node island under --node-islands=auto: only fences in top-level module code and fences that need the JavaScript runtime do. The package's island class still decides after a failed build. Typed packages like jose stay native (DECISIONS.md, 2026-10-21), so the corpus pins of jose and run-node are gone. Co-Authored-By: Claude Opus 5.5 --- packages/compiler/src/executable/prepare.ts | 16 ++++++++++++---- tests/corpus/6331-jose-root-entry/package.json | 1 - .../6610-npm-execa-child-mixin/package.json | 1 - 3 files changed, 12 insertions(+), 6 deletions(-) delete mode 100644 tests/corpus/6331-jose-root-entry/package.json delete mode 100644 tests/corpus/6610-npm-execa-child-mixin/package.json diff --git a/packages/compiler/src/executable/prepare.ts b/packages/compiler/src/executable/prepare.ts index 90504d0ef..5f3e45c9a 100644 --- a/packages/compiler/src/executable/prepare.ts +++ b/packages/compiler/src/executable/prepare.ts @@ -461,10 +461,13 @@ function preflightIslands( /** Packages compiled natively with runtime fences (refusals deferred to the * operation that reaches them) that should run in islands instead: those - * whose island class is runtime (R1) or object-model (R2), by NI-2's report - * over the pre-scan markers and the fences, or with a fence that itself - * needs the runtime. Gap-only packages keep their fences until decision 8 - * (D6) retires them. `--node-islands=auto` only; null for none. */ + * with a fence that surely fires (one in top-level module code, which runs + * when the module evaluates) or that itself needs the runtime (R1). Other + * fences stay deferred refusals until decision 8 (D6) retires them, so + * typed packages like jose stay native. With `requireFence` false (after a + * failed build) the package's island class by NI-2's report decides + * instead: runtime (R1) or object-model (R2). `--node-islands=auto` only; + * null for none. */ function fenceIslands( entryPath: string, opts: CompileRequestOptions, @@ -515,6 +518,11 @@ function fenceIslands( if (runtimeFence === undefined || island.path === null) continue; if (!compiled.has(island.package) || known.has(island.package)) continue; if (policy.forcedNative.has(island.package) || policy.unloaded.has(island.package)) continue; + // With fences (requireFence), only fences that surely fire count: a + // gap fence inside a function body may never run (jose's typed paths + // stay native, DECISIONS.md 2026-10-21). After a failed build + // (!requireFence), the package's report class decides. + if (requireFence && !runtimeFence) continue; if (!runtimeFence && island.class !== "runtime" && island.class !== "object-model") continue; grown.set(island.package, { name: island.package, diff --git a/tests/corpus/6331-jose-root-entry/package.json b/tests/corpus/6331-jose-root-entry/package.json deleted file mode 100644 index 30d3e0af0..000000000 --- a/tests/corpus/6331-jose-root-entry/package.json +++ /dev/null @@ -1 +0,0 @@ -{ "type": "module", "scriptc": { "packages": { "jose": "native" } } } diff --git a/tests/corpus/6610-npm-execa-child-mixin/package.json b/tests/corpus/6610-npm-execa-child-mixin/package.json deleted file mode 100644 index 5a418a30c..000000000 --- a/tests/corpus/6610-npm-execa-child-mixin/package.json +++ /dev/null @@ -1 +0,0 @@ -{ "type": "module", "scriptc": { "packages": { "run-node": "native" } } } From 45c8c292afec43f43475c16c42851b533bc323b4 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Thu, 22 Oct 2026 19:15:49 -0700 Subject: [PATCH 3/8] Native plain objects inherit Object.prototype directly in Node islands A plain object a program passes into an island is an object-template instance, which inherits from its implicit constructor's prototype object; island code that checks for plain objects (jose's isObject: Object.getPrototypeOf(o) === Object.prototype) rejected it. The instance now inherits Object.prototype directly. Corpus 6826 checks it. Co-Authored-By: Claude Opus 5.5 --- packages/libnode-common/bridge/scr_node_bridge.cc | 5 +++++ tests/corpus/6826-node-island-undefined-args/main.ts | 3 +++ .../node_modules/argprobe/index.d.ts | 1 + .../node_modules/argprobe/index.js | 8 ++++++++ 4 files changed, 17 insertions(+) diff --git a/packages/libnode-common/bridge/scr_node_bridge.cc b/packages/libnode-common/bridge/scr_node_bridge.cc index 584325d50..373b51688 100644 --- a/packages/libnode-common/bridge/scr_node_bridge.cc +++ b/packages/libnode-common/bridge/scr_node_bridge.cc @@ -1383,6 +1383,11 @@ Local ExportValue(Isolate *isolate, Local context, const ScrNode } else { obj = ExportTemplate(isolate)->NewInstance(context).ToLocalChecked(); obj->SetInternalField(0, data); + // A template instance inherits from its implicit constructor's own + // prototype object; a native plain object is a plain object to island + // code (`Object.getPrototypeOf(o) === Object.prototype`, as jose's + // isObject checks), so it inherits Object.prototype directly. + obj->SetPrototypeV2(context, v8::Object::New(isolate)->GetPrototypeV2()).FromMaybe(false); } if (!obj->IsProxy()) obj->SetPrivate(context, ExportKey(isolate), data).Check(); Global &slot = g_exports[id]; diff --git a/tests/corpus/6826-node-island-undefined-args/main.ts b/tests/corpus/6826-node-island-undefined-args/main.ts index 94a350e67..5f6fb53e9 100644 --- a/tests/corpus/6826-node-island-undefined-args/main.ts +++ b/tests/corpus/6826-node-island-undefined-args/main.ts @@ -12,6 +12,7 @@ import { nodeVersionFor, cwd, envValue, + plain, type Config, } from "argprobe"; import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; @@ -54,6 +55,8 @@ const none = projectSettings(false); console.log(version("/work", "unused", { nodeVersion: project?.nodeVersion }, undefined, available)); console.log(version("/work", "unused", { nodeVersion: none?.nodeVersion }, undefined, available)); console.log(probe("p", { nodeVersion: project?.nodeVersion }), probe("n", { nodeVersion: none?.nodeVersion })); +const claims = { role: "admin" }; +console.log("plain", plain({ sub: "alice" }), plain(claims)); async function main(): Promise { // A package.json the program writes just before the island reads it. diff --git a/tests/corpus/6826-node-island-undefined-args/node_modules/argprobe/index.d.ts b/tests/corpus/6826-node-island-undefined-args/node_modules/argprobe/index.d.ts index 72cde09b4..6ce43de4d 100644 --- a/tests/corpus/6826-node-island-undefined-args/node_modules/argprobe/index.d.ts +++ b/tests/corpus/6826-node-island-undefined-args/node_modules/argprobe/index.d.ts @@ -21,3 +21,4 @@ export declare function nodeVersionFor( ): Promise; export declare function cwd(): string; export declare function envValue(name: string): string | undefined; +export declare function plain(v: object): string; diff --git a/tests/corpus/6826-node-island-undefined-args/node_modules/argprobe/index.js b/tests/corpus/6826-node-island-undefined-args/node_modules/argprobe/index.js index 5a9e4245e..5cf8f9301 100644 --- a/tests/corpus/6826-node-island-undefined-args/node_modules/argprobe/index.js +++ b/tests/corpus/6826-node-island-undefined-args/node_modules/argprobe/index.js @@ -68,3 +68,11 @@ exports.nodeVersionFor = async function nodeVersionFor( exports.cwd = () => process.cwd(); exports.envValue = (name) => process.env[name]; + +// A native plain object is a plain object here (jose's isObject). +exports.plain = (v) => + [ + Object.getPrototypeOf(v) === Object.prototype, + Object.prototype.toString.call(v), + Object.getPrototypeOf(Object.getPrototypeOf(v)) === null, + ].join(" "); From 47f44c3dfde89a01952b8327b7346692d2114d78 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Thu, 22 Oct 2026 19:38:49 -0700 Subject: [PATCH 4/8] npm harness: cases that stay native under the fence rule keep their deferred-refusal expectations Co-Authored-By: Claude Opus 5.5 --- tests/harness/npm.test.ts | 28 ++++++++++++---------------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/tests/harness/npm.test.ts b/tests/harness/npm.test.ts index d08a4c0fb..3643a9859 100644 --- a/tests/harness/npm.test.ts +++ b/tests/harness/npm.test.ts @@ -66,46 +66,42 @@ const KNOWN: Record = { divergence: "Timeout handles are numbers (typeof) and lose their ref flag once fired or cleared", }, - "hot-shots-udp": { - divergence: - "as a Node island (its runtime fences pick one), output stops after the socket-not-running error; the built-in hot-shots shim (#1836) compiles it natively", - }, "island-web-plumbing": { run: "SC2020" }, "lazy-traps": { island: true }, - "misc-shims": { island: true }, + "misc-shims": { run: "SC2020" }, namespace: { island: true }, "node24-async-events": { run: "SC2020" }, "node24-commonjs-metadata": { island: true }, "node24-crypto-kdf": { run: "SC2020" }, - "node24-deep-equality": { island: true }, + "node24-deep-equality": { run: "SC2020" }, "node24-diagnostics-async": { run: "SC1090" }, "node24-diagnostics-tracing": { run: "SC1090" }, - "node24-env": { island: true }, + "node24-env": { run: "SC1090" }, "node24-esm-metadata": { island: true }, "node24-event-emitter": { run: "SC2020" }, "node24-file-url-conversion": { island: true }, "node24-fs-descriptors": { run: "SC2020" }, - "node24-mime": { island: true }, - "node24-mime-errors": { island: true }, + "node24-mime": { run: "SC2020" }, + "node24-mime-errors": { run: "SC1090" }, "node24-no-error-assertions": { run: "SC2020" }, - "node24-parseargs": { island: true }, + "node24-parseargs": { run: "SC1090" }, "node24-promise-timers": { run: "SC2020" }, "node24-string-decoder": { run: "SC2020" }, - "node24-style-text": { island: true }, + "node24-style-text": { run: "SC2020" }, "node24-system-errors": { run: "SC1090" }, "node24-text-encoding": { run: "SC2020" }, "node24-text-utilities": { run: "SC2020" }, "node24-timers-unref": { run: "SC2020" }, "node24-url-factories": { run: "SC2020" }, "node24-url-mutation": { run: "SC1090" }, - "node24-util-adapters": { island: true }, - "node24-util-types": { island: true }, + "node24-util-adapters": { run: "Object.create with this value is not supported yet" }, + "node24-util-types": { run: "SC1090" }, "node24-web-stream-encoding": { run: "SC1090" }, "node24-web-stream-errors": { run: "SC1090" }, "node24-web-stream-lifecycle": { run: "SC1090" }, "node24-web-stream-pipe-abort": { run: "SC2020" }, "node24-web-stream-pipe-errors": { run: "SC1090" }, - "node24-web-stream-pipes": { island: true }, + "node24-web-stream-pipes": { run: "SC1090" }, "node24-web-stream-queues": { run: "SC1090" }, "node24-web-stream-readable-lifecycle": { run: "SC2020" }, "node24-web-stream-readers": { run: "SC1090" }, @@ -114,8 +110,8 @@ const KNOWN: Record = { "package-imports": { island: true }, "promise-bridge": { build: "SC1090" }, "self-name": { build: "SC2013" }, - "stream-shims": { island: true }, - "util-shims": { island: true }, + "stream-shims": { run: "SC1090" }, + "util-shims": { run: "SC2020" }, "web-streams": { run: "SC1090" }, "workspace-copied": { island: true }, "workspace-unclean": { build: "SC2013" }, From 7687cf2d26c068f7c623e23f0af588966955f4ec Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 25 Oct 2026 17:50:36 -0700 Subject: [PATCH 5/8] Type island packages from their declarations in failed builds and generics When a native build failed and the packages it compiled with runtime fences then failed as islands too, the native build's diagnostics were reported. That build types those packages from their JavaScript, where declared-only types (interfaces, `Static`-style aliases) are `any`, so typed user code reported cascades the island build does not have (a schema library's `TSchema`). The island build's errors are reported instead. In a generic function instance over island types (``), a `T` value bound to dyn now crosses into the island the way `any` does, instead of being refused as a native object. Corpus 6829 types user code with an island package's interfaces, generic aliases and a generic function; node-island.test.ts pins the reported diagnostics of a failed build. Co-Authored-By: Claude Opus 5.5 --- packages/compiler/src/executable/prepare.ts | 14 +++---- .../frontend/lowering/lower-node-island.ts | 16 ++++++-- .../main.ts.json | 6 +++ .../6829-node-island-declared-types/main.ts | 39 +++++++++++++++++++ .../node_modules/schemakit/index.d.ts | 27 +++++++++++++ .../node_modules/schemakit/index.js | 37 ++++++++++++++++++ .../node_modules/schemakit/package.json | 1 + tests/fixtures/node-island/declared-types.ts | 27 +++++++++++++ .../main.ts.json | 1 + tests/harness/node-island.test.ts | 25 ++++++++++++ 10 files changed, 183 insertions(+), 10 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6829-node-island-declared-types/main.ts.json create mode 100644 tests/corpus/6829-node-island-declared-types/main.ts create mode 100644 tests/corpus/6829-node-island-declared-types/node_modules/schemakit/index.d.ts create mode 100644 tests/corpus/6829-node-island-declared-types/node_modules/schemakit/index.js create mode 100644 tests/corpus/6829-node-island-declared-types/node_modules/schemakit/package.json create mode 100644 tests/fixtures/node-island/declared-types.ts create mode 100644 tests/harness/corpus-costs/6829-node-island-declared-types/main.ts.json diff --git a/packages/compiler/src/executable/prepare.ts b/packages/compiler/src/executable/prepare.ts index 8a5d11caa..2abf95ffb 100644 --- a/packages/compiler/src/executable/prepare.ts +++ b/packages/compiler/src/executable/prepare.ts @@ -156,18 +156,20 @@ export function prepareExecutableModule( ); } // A failed program whose compiled packages need the runtime or a - // mutable object model (NI-2's report): try them as islands, and - // report this failure if that build fails too. + // mutable object model (NI-2's report): try them as islands. If that + // build fails too, its failure is the one reported. This build typed + // those packages from their JavaScript, where the types they only + // declare (interfaces, aliases such as `Static`) are `any`, so its + // diagnostics describe a program the island build does not compile: + // there the packages' declarations type the user code. const marked = retryFences ? fenceIslands(entryPath, opts, fe, lowered, lowerIslands, false) : null; if (marked !== null) { - const diagnostics = lowered.diagnostics; - const texts = fe.sourceTexts(); fe.dispose(); frontendDisposed(); disposed = true; - const islanded = prepareExecutableModule( + return prepareExecutableModule( entryPath, opts, ffi, @@ -177,8 +179,6 @@ export function prepareExecutableModule( marked, false, ); - if (islanded.ok) return islanded; - return { ok: false, diagnostics, sourceTexts: texts }; } return fail(lowered.diagnostics); } diff --git a/packages/compiler/src/frontend/lowering/lower-node-island.ts b/packages/compiler/src/frontend/lowering/lower-node-island.ts index f8ae8250a..f03f9c406 100644 --- a/packages/compiler/src/frontend/lowering/lower-node-island.ts +++ b/packages/compiler/src/frontend/lowering/lower-node-island.ts @@ -871,11 +871,20 @@ function toIsland(lowerer: Lowerer, node: ts.Expression): IrExpr { // NI-5: the runtime exports dyn objects by reference and copies only // primitives, strings and (for now) arrays. return lowerer.coerceToExpected(lowerer.lowerExpr(node), DYN); - case "refused": + case "refused": { + const type = lowerer.typeOf(node); + // A type parameter the instance binds to dyn (a generic function + // over island types, ``): its values are dyn + // values, and they cross the way `any` values do. + if ( + (type.flags & ts.TypeFlags.TypeParameter) !== 0 && + lowerer.typeParamResolver(type)?.kind === "dyn" + ) + return lowerer.coerceToExpected(lowerer.lowerExpr(node), DYN); // NI-5: closures, promises, records and class instances cross by // reference (scr_node_island_export.c): their dyn boxes keep the // native referent, and the closure's thunk checks its arguments. - if (exportedByReference(lowerer, lowerer.typeOf(node))) { + if (exportedByReference(lowerer, type)) { const lowered = lowerer.lowerExpr(node); const value = lowerer.coerceToExpected(lowered, DYN); return lowered.type.kind === "object" @@ -884,9 +893,10 @@ function toIsland(lowerer: Lowerer, node: ts.Expression): IrExpr { } return boundaryFail( lowerer, - `a '${lowerer.checker.typeToString(lowerer.typeOf(node))}' value (a native object would cross as a copy and lose its identity)`, + `a '${lowerer.checker.typeToString(type)}' value (a native object would cross as a copy and lose its identity)`, node, ); + } } } diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6829-node-island-declared-types/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6829-node-island-declared-types/main.ts.json new file mode 100644 index 000000000..8a467a09c --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6829-node-island-declared-types/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6829-node-island-declared-types/main.ts" + ], + "diags": [] +} diff --git a/tests/corpus/6829-node-island-declared-types/main.ts b/tests/corpus/6829-node-island-declared-types/main.ts new file mode 100644 index 000000000..52c2e5c40 --- /dev/null +++ b/tests/corpus/6829-node-island-declared-types/main.ts @@ -0,0 +1,39 @@ +// @requires-libnode +// Typed code names an island package's declared interfaces and generic +// aliases: the package's declarations type it, while its values cross the +// boundary as handles. +import { Arr, Num, Str, check, type Static, type TArray, type TNumber, type TSchema, type TString } from "schemakit"; + +interface Tool { + name: string; + parameters: T; + execute(id: string, params: Static): string; +} + +const sum: Tool> = { + name: "sum", + parameters: Arr(Num()), + execute: (id, params) => `${id}: ${params.reduce((a, b) => a + b, 0)}`, +}; +const shout: Tool = { + name: "shout", + parameters: Str(), + execute: (id, params) => `${id}: ${params.toUpperCase()}`, +}; + +function run(tool: Tool, input: unknown): string { + if (!check(tool.parameters, input)) return `${tool.name}: rejected ${JSON.stringify(input)}`; + return tool.execute(tool.name, input); +} + +function describeAll(schemas: TSchema[]): string[] { + return schemas.map((s) => `${s.kind} ${s.describe()}`); +} + +console.log(run(sum, [1, 2, 3])); +console.log(run(sum, [1, "2"])); +console.log(run(shout, "quiet")); +console.log(run(shout, 7)); +console.log(describeAll([sum.parameters, shout.parameters, Arr(Arr(Str()))]).join(" | ")); +const tools: Tool[] = [sum, shout]; +console.log(tools.map((t) => t.name).join(",")); diff --git a/tests/corpus/6829-node-island-declared-types/node_modules/schemakit/index.d.ts b/tests/corpus/6829-node-island-declared-types/node_modules/schemakit/index.d.ts new file mode 100644 index 000000000..d64602f56 --- /dev/null +++ b/tests/corpus/6829-node-island-declared-types/node_modules/schemakit/index.d.ts @@ -0,0 +1,27 @@ +// Types the package only declares: its JavaScript has no interfaces and no +// conditional aliases, so code typed from the JavaScript sees `any` here. +export interface TSchema { + readonly kind: string; + describe(): string; +} +export interface TNumber extends TSchema { + readonly kind: "number"; +} +export interface TString extends TSchema { + readonly kind: "string"; +} +export interface TArray extends TSchema { + readonly kind: "array"; + readonly items: T; +} +export type Static = T extends TNumber + ? number + : T extends TString + ? string + : T extends TArray + ? Static[] + : unknown; +export declare function Num(): TNumber; +export declare function Str(): TString; +export declare function Arr(items: T): TArray; +export declare function check(schema: T, value: unknown): value is Static; diff --git a/tests/corpus/6829-node-island-declared-types/node_modules/schemakit/index.js b/tests/corpus/6829-node-island-declared-types/node_modules/schemakit/index.js new file mode 100644 index 000000000..9fb64f5c3 --- /dev/null +++ b/tests/corpus/6829-node-island-declared-types/node_modules/schemakit/index.js @@ -0,0 +1,37 @@ +"use strict"; +// The esbuild CommonJS bundle prologue: getters over live `let` bindings +// and a constructor function with prototype methods. scriptc compiles it +// natively only with runtime fences, so it runs in a Node island. +var __defProp = Object.defineProperty; +var __export = (target, all) => { + for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); +}; +var src_exports = {}; +__export(src_exports, { + Num: () => Num, + Str: () => Str, + Arr: () => Arr, + check: () => check, +}); +module.exports = src_exports; +function Schema(kind, items) { + this.kind = kind; + if (items !== undefined) this.items = items; +} +Schema.prototype.describe = function () { + return this.items === undefined ? this.kind : this.kind + "<" + this.items.describe() + ">"; +}; +function Num() { + return new Schema("number"); +} +function Str() { + return new Schema("string"); +} +function Arr(items) { + return new Schema("array", items); +} +function check(schema, value) { + if (schema.kind === "array") return Array.isArray(value) && value.every((v) => check(schema.items, v)); + return typeof value === schema.kind; +} +0 && (module.exports = { Num, Str, Arr, check }); diff --git a/tests/corpus/6829-node-island-declared-types/node_modules/schemakit/package.json b/tests/corpus/6829-node-island-declared-types/node_modules/schemakit/package.json new file mode 100644 index 000000000..2ccb6f961 --- /dev/null +++ b/tests/corpus/6829-node-island-declared-types/node_modules/schemakit/package.json @@ -0,0 +1 @@ +{ "name": "schemakit", "version": "1.0.0", "main": "index.js", "types": "index.d.ts" } diff --git a/tests/fixtures/node-island/declared-types.ts b/tests/fixtures/node-island/declared-types.ts new file mode 100644 index 000000000..05835c751 --- /dev/null +++ b/tests/fixtures/node-island/declared-types.ts @@ -0,0 +1,27 @@ +// A failed build whose package runs in a Node island reports the island +// build's errors: there the package's declarations type the program. The +// test builds it beside corpus 6829's schemakit package. +import { Num, check, type Static, type TNumber, type TSchema } from "schemakit"; + +interface Tool { + name: string; + parameters: T; + execute(id: string, params: Static): string; +} + +const double: Tool = { + name: "double", + parameters: Num(), + execute: (id, params) => `${id}: ${params * 2}`, +}; +const input: unknown = 4; +if (check(double.parameters, input)) console.log(double.execute(double.name, input)); +// The build's own error: member decorators have no native lowering. +function logged(method: () => number, context: ClassMethodDecoratorContext): void {} +class Counter { + @logged + next(): number { + return 1; + } +} +console.log(new Counter().next()); diff --git a/tests/harness/corpus-costs/6829-node-island-declared-types/main.ts.json b/tests/harness/corpus-costs/6829-node-island-declared-types/main.ts.json new file mode 100644 index 000000000..c2f7906fb --- /dev/null +++ b/tests/harness/corpus-costs/6829-node-island-declared-types/main.ts.json @@ -0,0 +1 @@ +{ "plain": 3400, "san": 62000 } diff --git a/tests/harness/node-island.test.ts b/tests/harness/node-island.test.ts index cbb9daf66..85774285e 100644 --- a/tests/harness/node-island.test.ts +++ b/tests/harness/node-island.test.ts @@ -354,3 +354,28 @@ test("recursive JSON types cross into Node", async () => { const ir = JSON.stringify(deserializeModule(readFileSync(outPath, "utf8"))); expect(ir).toContain("nodeIsland.callSync"); }, 300_000); + +// When a build fails and the packages it compiled with runtime fences then +// fail as islands too, the island build's errors are reported. That build +// types the user code from the packages' declarations; the native one +// typed it from their JavaScript, where declared-only types (`Static`) +// are `any`. Needs no libnode pack: both builds fail before linking. +test("a failed build reports the island build's errors", async () => { + const dir = join(outDir, "declared-types"); + rmSync(dir, { recursive: true, force: true }); + cpSync( + join(repoRoot, "tests/corpus/6829-node-island-declared-types/node_modules"), + join(dir, "node_modules"), + { recursive: true }, + ); + copyFileSync( + join(repoRoot, "tests/fixtures/node-island/declared-types.ts"), + join(dir, "main.ts"), + ); + const result = await compile(join(dir, "main.ts"), { outPath: join(dir, "main"), outDir: dir }); + expect(result.ok).toBe(false); + if (result.ok) return; + const messages = result.diagnostics.map((d) => `${d.code}: ${d.message}`); + expect(messages.some((m) => m.startsWith("SC1090: method decorators"))).toBe(true); + expect(messages.filter((m) => /Tool|Static|'check'/.test(m))).toEqual([]); +}, 300_000); From d90577d5eb9337bc0285c4f90355cbd8ea175f68 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Mon, 26 Oct 2026 02:05:01 -0500 Subject: [PATCH 6/8] Expect the word-segments pilot to segment in its Node island --- tests/harness/npm-static.test.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/tests/harness/npm-static.test.ts b/tests/harness/npm-static.test.ts index 076733739..fb469ec22 100644 --- a/tests/harness/npm-static.test.ts +++ b/tests/harness/npm-static.test.ts @@ -15,6 +15,7 @@ import { promisify } from "node:util"; import { beforeEach, describe, expect, test } from "vitest"; import { analyze, compile } from "@scriptc/compiler"; import { skipUnownedTest } from "./shard.js"; +import { libnodePack } from "./libnode.js"; const execFileAsync = promisify(execFile); const repoRoot = join(import.meta.dirname, "../.."); @@ -586,7 +587,10 @@ describe(`npm-static pilots${sanitize ? " (sanitized)" : ""}`, () => { } }); - test("a word segmenter in a package constructs, and its segment() throws SC2020", async () => { + // The package builds its segmenter at module load, a run-time fence that + // surely fires, so the package runs in its Node island and segments as + // Node does. + test("a word segmenter a package builds at load runs in its Node island", async () => { const entry = join(pilotRoot, "word-segments-cli.ts"); const { coverage } = analyze(entry, { npmStatic: "auto" }); expect(coverage.npmStatic).toEqual([{ package: "word-segments", status: "static" }]); @@ -599,12 +603,15 @@ describe(`npm-static pilots${sanitize ? " (sanitized)" : ""}`, () => { outDir, outPath: join(outDir, "program"), }); + if (libnodePack() === null) { + expect(result.ok, "no libnode pack: the compile stops").toBe(false); + if (!result.ok) expect(result.diagnostics.map((d) => d.code)).toContain("SC8005"); + return; + } if (!result.ok) throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")); const nativeRes = await runBinary(result.binaryPath, []); - expect(nativeRes.stdout.toString()).toMatch( - /^SC2020: Intl\.Segmenter segmentation with granularity 'word' is not supported yet \[SC2020 at .*index\.js:1\]\n$/, - ); + expect(nativeRes.stdout.toString()).toBe("two| |words\n"); expect(nativeRes.exitCode).toBe(0); }); From b425eeca6ec1e0609c2360ffc3c1a4ea5cc0a38f Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Mon, 26 Oct 2026 16:07:11 -0700 Subject: [PATCH 7/8] Move SC8021 and SC8023 from the reserved to the active codes --- packages/compiler/src/diagnostics/codes.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/compiler/src/diagnostics/codes.ts b/packages/compiler/src/diagnostics/codes.ts index a40635849..5d05f64ee 100644 --- a/packages/compiler/src/diagnostics/codes.ts +++ b/packages/compiler/src/diagnostics/codes.ts @@ -116,7 +116,9 @@ export const DIAGNOSTIC_CODES = { SC8010: "value crossing a Node island boundary in a form without an exact copy", SC8011: "native class extending a Node island class outside the supported forms", SC8020: "stateful module duplicated across the Node island boundary", + SC8021: "Node island evaluation notes (deferred or at startup)", SC8022: "island code extends a class of a compiled module", + SC8023: "Node island build failed; built natively with runtime fences", SC8030: "type patch: no range covers the installed copy", SC8031: "type patch: selector matches nothing", SC8032: "type patch: declaration already has the type", @@ -155,8 +157,6 @@ export const RETIRED_DIAGNOSTIC_CODES = { * abandoned, delete them here. */ export const RESERVED_DIAGNOSTIC_CODES = { SC8012: "Node islands (ni/* branches)", - SC8021: "Node islands (ni/* branches)", - SC8023: "Node islands (ni/* branches)", } as const satisfies Record<`SC${number}`, string>; export type DiagnosticCode = keyof typeof DIAGNOSTIC_CODES; From af19aa1439e13142740c2a968ed58c186e00e518 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Mon, 26 Oct 2026 16:07:12 -0700 Subject: [PATCH 8/8] Do not count a synthesized fence as module code Main's lookup for builtin globals read through the global object lowers every global's value, and its fences have no source position, so they were marked as module-init fences and made packages like jose islands. The lookup runs when the program calls it. --- packages/compiler/src/frontend/lowering/lowerer.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index da9cb9108..974306187 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -1168,6 +1168,10 @@ export function lowerToIr( function markModuleInitFences(program: ts.Program, fences: readonly ScrDiagnostic[]): void { for (const d of fences) { if (!d.loc.file.includes("/node_modules/")) continue; + // A fence without a source position belongs to synthesized code, such + // as the global object's lookup (global-values.ts), which runs when the + // program calls it, not when the module evaluates. + if (d.loc.start < 0) continue; const sf = program.getSourceFile(d.loc.file); if (sf === undefined) continue; let inFunction = false;