Re-export island package names from native modules

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 <noreply@anthropic.com>
This commit is contained in:
Malte Ubl
2026-10-29 19:14:36 -07:00
co-authored by Claude Opus 5.5
parent 678d66ed3e
commit 52ef883a48
14 changed files with 240 additions and 32 deletions
@@ -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
@@ -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<ts.Symbol>();
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;
@@ -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<ts.Symbol, boolean>();
/** 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<ts.Symbol, IslandBinding>();
/** `instanceof` expressions the island lowering already handles. */
readonly islandInstanceofHandled = new Set<ts.Node>();
/** 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.
@@ -0,0 +1,8 @@
{
"order": [
"<repo>/tests/corpus/6898-node-island-reexport/barrel.ts",
"<repo>/tests/corpus/6898-node-island-reexport/barrel2.ts",
"<repo>/tests/corpus/6898-node-island-reexport/main.ts"
],
"diags": []
}
@@ -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";
@@ -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";
@@ -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);
@@ -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<T extends TSchema = TSchema> extends TSchema {
readonly kind: "array";
readonly items: T;
}
export type Static<T extends TSchema> = T extends TNumber
? number
: T extends TString
? string
: T extends TArray<infer I>
? Static<I>[]
: unknown;
export declare function Num(): TNumber;
export declare function Str(): TString;
export declare function Arr<T extends TSchema>(items: T): TArray<T>;
export declare function check<T extends TSchema>(schema: T, value: unknown): value is Static<T>;
+37
View File
@@ -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 });
@@ -0,0 +1 @@
{ "name": "schemakit", "version": "1.0.0", "main": "index.js", "types": "index.d.ts" }
@@ -0,0 +1 @@
{ "name": "island-reexport", "private": true, "type": "module", "scriptc": { "packages": { "schemakit": "node" } } }
+6 -4
View File
@@ -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");
@@ -0,0 +1 @@
{ "plain": 3800, "san": 34000 }
+1 -1
View File
@@ -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");