From 52ef883a48e3032709f905e96bf3c26a10495dc6 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 14:10:16 -0700 Subject: [PATCH] Re-export island package names from native modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A native module that re-exports an island package's names (`export { Type, Static } from "typebox"`, pi's ai/src/index.ts) was refused with SC8010: the re-export arm of the island import collection reported every export declaration from an island package as a boundary, so an island value in pi-ai's public API had no way to be written. The barrel is now a module like any other: it loads the island package at its own %init position (nodeIsland.defer, exactly as an import does), snapshots the CommonJS named exports it re-exports, and registers each re-exported name against the island's own export symbol. A name imported from the barrel reaches that symbol through the checker's alias chain (islandReexportOf), so islandBindingOf binds it as the island value it is — including through a second barrel (`export { Num as NumberSchema } from "./barrel.ts"`), since every re-exporting module registers the symbol its chain ends on. A namespace or star re-export has no name of its own to bind and stays refused — and now actually reaches that refusal: the npm-import collection skipped a star or namespace re-export before the island arm ran, so the statement was silently dropped instead of refused. The guard routes it to the island boundary refusal (a type-only star re-export stays erased like a type-only named one), and the path-mapped re-export case in node-island.test.ts pins that refusal with a star re-export rather than the named one this commit makes work. Test: tests/corpus/6898-node-island-reexport (a two-step barrel, differential to Node, plain and sanitized). Co-Authored-By: Claude Opus 5.5 --- .../src/frontend/lowering/lower-modules.ts | 15 +- .../frontend/lowering/lower-node-island.ts | 136 ++++++++++++++---- .../compiler/src/frontend/lowering/lowerer.ts | 8 +- .../6898-node-island-reexport/main.ts.json | 8 ++ .../6898-node-island-reexport/barrel.ts | 5 + .../6898-node-island-reexport/barrel2.ts | 3 + .../corpus/6898-node-island-reexport/main.ts | 18 +++ .../node_modules/schemakit/index.d.ts | 27 ++++ .../node_modules/schemakit/index.js | 37 +++++ .../node_modules/schemakit/package.json | 1 + .../6898-node-island-reexport/package.json | 1 + .../node-island/path-mapped-reexport.ts | 10 +- .../6898-node-island-reexport/main.ts.json | 1 + tests/harness/node-island.test.ts | 2 +- 14 files changed, 240 insertions(+), 32 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6898-node-island-reexport/main.ts.json create mode 100644 tests/corpus/6898-node-island-reexport/barrel.ts create mode 100644 tests/corpus/6898-node-island-reexport/barrel2.ts create mode 100644 tests/corpus/6898-node-island-reexport/main.ts create mode 100644 tests/corpus/6898-node-island-reexport/node_modules/schemakit/index.d.ts create mode 100644 tests/corpus/6898-node-island-reexport/node_modules/schemakit/index.js create mode 100644 tests/corpus/6898-node-island-reexport/node_modules/schemakit/package.json create mode 100644 tests/corpus/6898-node-island-reexport/package.json create mode 100644 tests/harness/corpus-costs/6898-node-island-reexport/main.ts.json diff --git a/packages/compiler/src/frontend/lowering/lower-modules.ts b/packages/compiler/src/frontend/lowering/lower-modules.ts index 173b44db4..82a1884bd 100644 --- a/packages/compiler/src/frontend/lowering/lower-modules.ts +++ b/packages/compiler/src/frontend/lowering/lower-modules.ts @@ -646,8 +646,21 @@ export function collectNpmImports(lowerer: Lowerer, parts: FileParts[]): void { if ( reexport === null && (!ts.isImportDeclaration(stmt) || !ts.isStringLiteral(stmt.moduleSpecifier)) - ) + ) { + // A star or namespace re-export (`export * from "pkg"`) names + // nothing the re-exporting module could bind: an island package's + // is refused at the boundary instead of silently dropped. Any + // other module keeps the previous skip, and a type-only one is + // erased like the type-only named re-export above. + if ( + ts.isExportDeclaration(stmt) && + !stmt.isTypeOnly && + stmt.moduleSpecifier !== undefined && + ts.isStringLiteral(stmt.moduleSpecifier) + ) + collectIslandImport(lowerer, fp.sf, stmt, stmt.moduleSpecifier.text); continue; + } const specNode = ts.isImportDeclaration(stmt) || ts.isExportDeclaration(stmt) ? stmt.moduleSpecifier diff --git a/packages/compiler/src/frontend/lowering/lower-node-island.ts b/packages/compiler/src/frontend/lowering/lower-node-island.ts index ffefc4e15..a54681b73 100644 --- a/packages/compiler/src/frontend/lowering/lower-node-island.ts +++ b/packages/compiler/src/frontend/lowering/lower-node-island.ts @@ -404,9 +404,43 @@ export function collectIslandImport( const path = islandModuleFile(islands, sf.fileName, spec, "import"); if (path === null) return false; if (ts.isExportDeclaration(stmt)) { - lowerer.pushDiag( - nodeIslandBoundaryDiag(`re-exporting the Node island module '${spec}'`, locOf(stmt)), - ); + // `export { Type } from "typebox"`: the re-exporting module loads the + // island module and every name it re-exports binds the island's own + // export, so importers of this module reach it through the alias chain + // (islandBindingOf). A namespace or star re-export has no name to bind. + const clause = stmt.exportClause; + if (stmt.isTypeOnly || clause === undefined || !ts.isNamedExports(clause)) { + lowerer.pushDiag( + nodeIslandBoundaryDiag(`re-exporting the Node island module '${spec}'`, locOf(stmt)), + ); + return true; + } + lowerer.usesNodeIsland = true; + const exportLoc = locOf(stmt); + const exported = islandModuleGlobal(lowerer, path); + const initActions = lowerer.jsonInitActions.get(sf) ?? []; + lowerer.jsonInitActions.set(sf, initActions); + initActions.push({ + kind: "assign", + localId: exported.global.id, + value: deferIslandModule(lowerer, path, exportLoc), + loc: exportLoc, + }); + for (const el of clause.elements.nodes) { + if (el.isTypeOnly) continue; + const name = el.propertyName?.text ?? el.name.text; + // CommonJS named exports are the namespace's snapshot (below). + if (!exported.esm && name !== "default") + initActions.push(islandSnapshotAssign(lowerer, exported, name, exportLoc)); + registerIslandReexport( + lowerer, + stmt, + el, + name === "default" + ? { kind: "default", module: exported } + : { kind: "named", module: exported, name }, + ); + } return true; } lowerer.usesNodeIsland = true; @@ -448,31 +482,36 @@ export function collectIslandImport( return undefined; }); } - { - for (const name of snapshotNames) { - if (name === "default") continue; - let g = binding.snapshots.get(name); - if (g === undefined) { - g = { - id: `%g.island.${lowerer.globalsList.length}`, - name, - type: DYN, - mutable: true, - }; - lowerer.globalsList.push(g); - binding.snapshots.set(name, g); - } - actions.push({ - kind: "assign", - localId: g.id, - value: islandGet(varRef(binding.global.id, DYN, loc), name, loc), - loc, - }); - } + for (const name of snapshotNames) { + if (name === "default") continue; + actions.push(islandSnapshotAssign(lowerer, binding, name, loc)); } return true; } +/** A CommonJS island name's snapshot global and the %init action that + * assigns it once the module evaluated (Node's namespace snapshots named + * exports). */ +function islandSnapshotAssign( + lowerer: Lowerer, + binding: IslandModuleBinding, + name: string, + loc: SrcLoc, +): IrStmt { + let g = binding.snapshots.get(name); + if (g === undefined) { + g = { id: `%g.island.${lowerer.globalsList.length}`, name, type: DYN, mutable: true }; + lowerer.globalsList.push(g); + binding.snapshots.set(name, g); + } + return { + kind: "assign", + localId: g.id, + value: islandGet(varRef(binding.global.id, DYN, loc), name, loc), + loc, + }; +} + function islandGet(target: IrExpr, key: string, loc: SrcLoc): IrExpr { return { kind: "libCall", @@ -483,12 +522,55 @@ function islandGet(target: IrExpr, key: string, loc: SrcLoc): IrExpr { }; } -type IslandBinding = +export type IslandBinding = | { kind: "required"; module: IslandModuleBinding } | { kind: "namespace"; module: IslandModuleBinding } | { kind: "default"; module: IslandModuleBinding } | { kind: "named"; module: IslandModuleBinding; name: string }; +/** Records that `specifier`'s exported name binds `binding`: importers of + * the re-exporting module reach the island's own export through the alias + * chain, whatever they spell it (`export { Num as NumberSchema }`). The + * island's export symbol is the key a chain ends on, so it is registered + * beside the specifier's own symbol. */ +function registerIslandReexport( + lowerer: Lowerer, + stmt: ts.ExportDeclaration, + specifier: ts.ExportSpecifier, + binding: IslandBinding, +): void { + const named = lowerer.checker.getSymbolAtLocation(specifier.name); + if (named !== undefined) lowerer.islandReexports.set(named, binding); + const moduleSpecifier = stmt.moduleSpecifier; + if (moduleSpecifier === undefined) return; + const moduleSymbol = lowerer.checker.getSymbolAtLocation(moduleSpecifier); + if (moduleSymbol === undefined) return; + const exported = lowerer.checker.getExportsOfModule(moduleSymbol); + const found = exported.find( + (s) => ts.symbolName(s) === (specifier.propertyName?.text ?? specifier.name.text), + ); + if (found === undefined) return; + lowerer.islandReexports.set(found, binding); + if ((found.flags & ts.SymbolFlags.Alias) !== 0) + lowerer.islandReexports.set(lowerer.checker.getAliasedSymbol(found), binding); +} + +/** The island binding an alias chain reaches, or null: a name re-exported + * by a native module resolves to the island's export symbol itself. */ +function islandReexportOf(lowerer: Lowerer, symbol: ts.Symbol | undefined): IslandBinding | null { + if (symbol === undefined || lowerer.islandReexports.size === 0) return null; + const seen = new Set(); + let s: ts.Symbol | undefined = symbol; + while (s !== undefined && !seen.has(s)) { + seen.add(s); + const bound = lowerer.islandReexports.get(s); + if (bound !== undefined) return bound; + if ((s.flags & ts.SymbolFlags.Alias) === 0) return null; + s = lowerer.checker.getAliasedSymbol(s); + } + return null; +} + function moduleSpecifierOf(decl: ts.Node | undefined): string | null { return ts.isImportDeclaration(decl) && ts.isStringLiteral(decl.moduleSpecifier) ? decl.moduleSpecifier.text @@ -506,6 +588,10 @@ function islandBindingOf(lowerer: Lowerer, ident: ts.Identifier): IslandBinding const islands = lowerer.nodeIslands; if (islands === null) return null; const symbol = lowerer.checker.getSymbolAtLocation(ident); + // A name a native module re-exports from an island package binds the + // island's own export: the import's alias chain reaches it. + const reexport = islandReexportOf(lowerer, symbol); + if (reexport !== null) return reexport; const decl = symbol ? symbol.declarations[0] : undefined; if (decl === undefined) return null; let importDecl: ts.Node | undefined; diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index ef9d5c31c..43822d695 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -58,7 +58,7 @@ import { setActiveNodeIslands, type NodeIslandSet, } from "../../node-islands/boundary.js"; -import type { IslandModuleBinding } from "./lower-node-island.js"; +import type { IslandBinding, IslandModuleBinding } from "./lower-node-island.js"; import { bindingSymbolOfRead, onlyForwarded, stripTypeWrappers } from "./typed-arguments.js"; import { needsGeneralNodeStreams, @@ -3758,6 +3758,9 @@ export class Lowerer { readonly nodeIslandLoads: { literal: IrExpr & { kind: "strLit" }; path: string }[] = []; /** Island handle bindings by symbol (lower-node-island.ts). */ readonly islandHandleBindings = new Map(); + /** Names a native module re-exports from an island package, by the + * symbol an alias chain reaches (lower-node-island.ts). */ + readonly islandReexports = new Map(); /** `instanceof` expressions the island lowering already handles. */ readonly islandInstanceofHandled = new Set(); /** NI-6: the modules shared with Node islands (lower-shared-modules.ts), @@ -4549,6 +4552,9 @@ export class Lowerer { // NI-5: whether a symbol names an island handle binding depends only on // its declaration, so the answer is a memo keyed by symbol. "islandHandleBindings", + // The island names native modules re-export: a pure function of the + // program's modules, collected before lowering begins. + "islandReexports", // The modules shared with the islands (NI-6): a pure function of the // program's modules, computed once, and the TDZ flags its plan gives a // statement. diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6898-node-island-reexport/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6898-node-island-reexport/main.ts.json new file mode 100644 index 000000000..783c22cfe --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6898-node-island-reexport/main.ts.json @@ -0,0 +1,8 @@ +{ + "order": [ + "/tests/corpus/6898-node-island-reexport/barrel.ts", + "/tests/corpus/6898-node-island-reexport/barrel2.ts", + "/tests/corpus/6898-node-island-reexport/main.ts" + ], + "diags": [] +} diff --git a/tests/corpus/6898-node-island-reexport/barrel.ts b/tests/corpus/6898-node-island-reexport/barrel.ts new file mode 100644 index 000000000..c967e1faa --- /dev/null +++ b/tests/corpus/6898-node-island-reexport/barrel.ts @@ -0,0 +1,5 @@ +// A native module re-exports an island package's values: the barrel loads +// the island module where it evaluates and imports of the barrel bind the +// island module's own exports, not a copy of them. +export { Arr, Num, Str, check } from "schemakit"; +export type { TArray, TNumber, TSchema } from "schemakit"; diff --git a/tests/corpus/6898-node-island-reexport/barrel2.ts b/tests/corpus/6898-node-island-reexport/barrel2.ts new file mode 100644 index 000000000..431d4244e --- /dev/null +++ b/tests/corpus/6898-node-island-reexport/barrel2.ts @@ -0,0 +1,3 @@ +// A second barrel renames one of the island's exports: the binding follows +// the alias chain back to the island's own export. +export { Num as NumberSchema } from "./barrel.ts"; diff --git a/tests/corpus/6898-node-island-reexport/main.ts b/tests/corpus/6898-node-island-reexport/main.ts new file mode 100644 index 000000000..f2533c8f7 --- /dev/null +++ b/tests/corpus/6898-node-island-reexport/main.ts @@ -0,0 +1,18 @@ +// @requires-libnode +// A native module re-exports an island package's names (`export … from +// "pkg"`): the barrel evaluates the island module at its own position and +// importers of the barrel call the island's exports in Node exactly as a +// direct import does. The names are the island's own values, so two paths +// to one export are the same function. +import { Arr, Num, Str, check } from "./barrel.ts"; +import { NumberSchema } from "./barrel2.ts"; + +const numbers = Arr(Num()); +const names = Arr(Str()); +console.log(numbers.kind, numbers.describe()); +console.log(names.kind, names.describe()); + +console.log(check(numbers, [1, 2, 3]), check(numbers, [1, "2"]), check(names, ["a"]), check(names, [0])); + +const alias = NumberSchema(); +console.log(alias.kind, alias.describe(), Num === NumberSchema); diff --git a/tests/corpus/6898-node-island-reexport/node_modules/schemakit/index.d.ts b/tests/corpus/6898-node-island-reexport/node_modules/schemakit/index.d.ts new file mode 100644 index 000000000..d64602f56 --- /dev/null +++ b/tests/corpus/6898-node-island-reexport/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/6898-node-island-reexport/node_modules/schemakit/index.js b/tests/corpus/6898-node-island-reexport/node_modules/schemakit/index.js new file mode 100644 index 000000000..9fb64f5c3 --- /dev/null +++ b/tests/corpus/6898-node-island-reexport/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/6898-node-island-reexport/node_modules/schemakit/package.json b/tests/corpus/6898-node-island-reexport/node_modules/schemakit/package.json new file mode 100644 index 000000000..2ccb6f961 --- /dev/null +++ b/tests/corpus/6898-node-island-reexport/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/corpus/6898-node-island-reexport/package.json b/tests/corpus/6898-node-island-reexport/package.json new file mode 100644 index 000000000..beaba8ad6 --- /dev/null +++ b/tests/corpus/6898-node-island-reexport/package.json @@ -0,0 +1 @@ +{ "name": "island-reexport", "private": true, "type": "module", "scriptc": { "packages": { "schemakit": "node" } } } diff --git a/tests/fixtures/node-island/path-mapped-reexport.ts b/tests/fixtures/node-island/path-mapped-reexport.ts index 7bb102298..6f80b98ff 100644 --- a/tests/fixtures/node-island/path-mapped-reexport.ts +++ b/tests/fixtures/node-island/path-mapped-reexport.ts @@ -1,7 +1,9 @@ // Re-exports from an island package that tsconfig `paths` maps to its -// installed directory: the type-only one is erased, the value one reports -// the island boundary, and the package's declarations never compile as a -// program module. The test builds it beside corpus 6839's pathkit package. +// installed directory: the type-only one is erased, the star one reports +// the island boundary (a star re-export names nothing the re-exporting +// module could bind; a named one is supported, corpus 6898), and the +// package's declarations never compile as a program module. The test +// builds it beside corpus 6839's pathkit package. export type { Route } from "pathkit"; -export { route } from "pathkit"; +export * from "pathkit"; console.log("loaded"); diff --git a/tests/harness/corpus-costs/6898-node-island-reexport/main.ts.json b/tests/harness/corpus-costs/6898-node-island-reexport/main.ts.json new file mode 100644 index 000000000..e75007ac8 --- /dev/null +++ b/tests/harness/corpus-costs/6898-node-island-reexport/main.ts.json @@ -0,0 +1 @@ +{ "plain": 3800, "san": 34000 } diff --git a/tests/harness/node-island.test.ts b/tests/harness/node-island.test.ts index 215d75384..3daac0f81 100644 --- a/tests/harness/node-island.test.ts +++ b/tests/harness/node-island.test.ts @@ -381,7 +381,7 @@ test("a failed build reports the island build's errors", async () => { }, 300_000); // Needs no libnode pack: the build fails before linking. -test("re-exporting a path-mapped island package reports the island boundary", async () => { +test("a star re-export of a path-mapped island package reports the island boundary", async () => { const dir = join(outDir, "path-mapped-reexport"); rmSync(dir, { recursive: true, force: true }); const corpus = join(repoRoot, "tests/corpus/6839-node-island-path-mapped");