Capture island locals in closures, load islands in import order, and narrow SC1010

Three fixes.

A function-local island handle read inside a closure, such as an island
array spread in an arrow (`() => [...local]`), resolved to the enclosing
function's binding without registering a capture, so the closure referred to
a local it does not have (SC9001). The handle is now read through
resolveLocal after its binding is retyped checked-dynamic, which captures
it like any other local.

A static island import or re-export loaded the island module in the
importer's %init prelude, after every native dependency the file imports.
A native module imported after the island package therefore evaluated
first, which reverses Node's dependency order. The load now runs in its
statement's place among the dependencies' %init calls. An island statement
the import order leaves out keeps the prelude's place.

Tests: 6897-node-island-declared-arrays gains closures that read, measure
and spread a function-local island array. node-island-import-order checks
evaluation order when an island re-export precedes a native import, and
when an island import sits between two native imports.

The SC1010 refusal of an import or re-export that resolves to type
declarations now applies only when the declaration has no runtime file
beside it (x.js for x.d.ts, x.mjs for x.d.mts, x.cjs for x.d.cts). A
relative import of `./node_modules/dependency/index.js` names a module
Node loads, and keeps its previous SC2013. The noUnusedLocals test reaches
its declaration file through a type-only re-export, since a runtime import
of a declaration-only file is now refused, as Node fails to load it.
This commit is contained in:
Chris Tate
2026-10-30 00:46:57 -05:00
parent d2e3690bc3
commit 015cc47ff2
19 changed files with 128 additions and 11 deletions
@@ -3225,7 +3225,14 @@ export function lowerFileInit(
});
ctx.hoistedFnDecls.add(stmt);
}
const islandActions = lowerer.islandImportActions.get(sf);
const islandPlaced = new Set<ts.Statement>();
for (const { stmt, dep } of orderedImportsOf(lowerer.program, sf)) {
const island = islandActions?.get(stmt);
if (island !== undefined) {
header.push(...island);
islandPlaced.add(stmt);
}
const npm = lowerer.npmInitActions.get(sf)?.get(stmt);
if (npm !== undefined && npm.length > 0) {
header.push(...npm);
@@ -3294,6 +3301,11 @@ export function lowerFileInit(
}
}
}
// An island load whose statement the import order leaves out keeps the
// prelude's place.
const islandUnplaced: IrStmt[] = [];
for (const [stmt, actions] of islandActions ?? [])
if (!islandPlaced.has(stmt)) islandUnplaced.push(...actions);
if (asyncDeps.length === 1) {
const dep = asyncDeps[0]!;
const promiseT: IrType = { kind: "promise", inner: VOID };
@@ -3432,7 +3444,7 @@ export function lowerFileInit(
if (ms && ms.sf === sf) statics.push({ pos: ms.pos, info: c });
}
statics.sort((a, b) => a.pos - b.pos);
const body = [...header, ...prelude];
const body = [...header, ...islandUnplaced, ...prelude];
let at = 0;
for (const stmt of stmts) {
// `<=`: a mixin instantiation's position IS its statement's start
@@ -31,6 +31,7 @@ import {
VOID,
type IrExpr,
type IrGlobal,
type IrLocal,
type IrStmt,
type IrType,
type SrcLoc,
@@ -390,9 +391,26 @@ function runsAtModuleEvaluation(node: ts.Node): boolean {
return false;
}
/** The %init statements that run in `stmt`'s place in its file's %init
* header (Lowerer.islandImportActions). */
function islandStatementActions(lowerer: Lowerer, sf: ts.SourceFile, stmt: ts.Statement): IrStmt[] {
let byStatement = lowerer.islandImportActions.get(sf);
if (byStatement === undefined) {
byStatement = new Map();
lowerer.islandImportActions.set(sf, byStatement);
}
let actions = byStatement.get(stmt);
if (actions === undefined) {
actions = [];
byStatement.set(stmt, actions);
}
return actions;
}
/** A static import (or re-export) of an island package: loads the module
* in the importer's %init prelude. Returns false when `spec` is not an
* island module. */
* in the importer's %init header, in the statement's place among the
* dependencies' %init calls, as Node evaluates it in source order. Returns
* false when `spec` is not an island module. */
export function collectIslandImport(
lowerer: Lowerer,
sf: ts.SourceFile,
@@ -418,8 +436,7 @@ export function collectIslandImport(
lowerer.usesNodeIsland = true;
const exportLoc = locOf(stmt);
const exported = islandModuleGlobal(lowerer, path);
const initActions = lowerer.jsonInitActions.get(sf) ?? [];
lowerer.jsonInitActions.set(sf, initActions);
const initActions = islandStatementActions(lowerer, sf, stmt);
initActions.push({
kind: "assign",
localId: exported.global.id,
@@ -446,8 +463,7 @@ export function collectIslandImport(
lowerer.usesNodeIsland = true;
const loc = locOf(stmt);
const binding = islandModuleGlobal(lowerer, path);
const actions = lowerer.jsonInitActions.get(sf) ?? [];
lowerer.jsonInitActions.set(sf, actions);
const actions = islandStatementActions(lowerer, sf, stmt);
actions.push({
kind: "assign",
localId: binding.global.id,
@@ -1499,6 +1515,16 @@ function lowerIslandChain(
return stmts.length > 0 ? { kind: "seqExpr", stmts, result, type: result.type, loc } : result;
}
/** The local an island handle binding reads here: its own binding, retyped
* checked-dynamic, or, inside a closure, the capture of an enclosing
* function's binding, which resolveLocal registers. */
function islandHandleLocal(lowerer: Lowerer, ident: ts.Identifier): IrLocal | null {
const origin = lowerer.peekLocal(ident);
if (origin === null) return null;
origin.type = DYN;
return lowerer.resolveLocal(ident);
}
/** Expressions on island values (the lowerExpr hook; null: not an island
* expression, the ordinary lowering applies). */
export function lowerIslandExpr(lowerer: Lowerer, expr: ts.Expression): IrExpr | null {
@@ -1527,7 +1553,7 @@ export function lowerIslandExpr(lowerer: Lowerer, expr: ts.Expression): IrExpr |
expr,
);
lowerer.usesNodeIsland = true;
const slot = lowerer.globalOf(expr) ?? lowerer.peekLocal(expr);
const slot = lowerer.globalOf(expr) ?? islandHandleLocal(lowerer, expr);
if (slot === null) return null; // the local is declared dyn
slot.type = DYN;
const ref = varRef(slot.id, DYN, loc);
@@ -3662,6 +3662,12 @@ export class Lowerer {
readonly globalsList: IrGlobal[] = [];
readonly npmInitActions = new Map<ts.SourceFile, Map<ts.Statement, IrStmt[]>>();
/** Per-file %init statements of static island imports and re-exports,
* by the import or export statement that loads the island module: the
* importer's %init header runs them in that statement's place among its
* dependencies' %init calls, so the island module evaluates in source
* order with the native dependencies around it. */
readonly islandImportActions = new Map<ts.SourceFile, Map<ts.Statement, IrStmt[]>>();
/** Per-file %init PRELUDE statements for JSON imports: bakeable DATA
* assignments with no observable evaluation order of their own —
* prepended by lowerFileInit so the bindings are live before any
@@ -4380,6 +4386,7 @@ export class Lowerer {
checkedTable("globalsByDeclNode", () => this.globalsByDeclNode.size),
checkedTable("globalsList", () => this.globalsList.length),
checkedTable("npmInitActions", () => this.npmInitActions.size),
checkedTable("islandImportActions", () => this.islandImportActions.size),
checkedTable("jsonInitActions", () => this.jsonInitActions.size),
checkedTable("jsonRequireModules", () => this.jsonRequireModules.size),
checkedTable("jsonRequireCalls", () => this.jsonRequireCalls.size),
+19 -2
View File
@@ -5532,6 +5532,23 @@ export function checkPreflightTs7(
}
}
/** Whether a declaration file has the runtime file TypeScript pairs it
* with (`x.d.ts` with `x.js`, `x.d.mts` with `x.mjs`, `x.d.cts` with
* `x.cjs`): a specifier that resolves to the declaration then still names a
* module Node can load. */
function declarationHasRuntimeFile(declaration: ts.SourceFile): boolean {
const name = declaration.fileName;
const pairs: [string, string][] = [
[".d.ts", ".js"],
[".d.mts", ".mjs"],
[".d.cts", ".cjs"],
];
for (const [declared, runtime] of pairs)
if (name.endsWith(declared))
return trackedFileExists(name.slice(0, -declared.length) + runtime);
return false;
}
function preflight7(load: LoadResult): {
diags: ScrDiagnostic[];
moduleOrder: ts.SourceFile[];
@@ -6065,7 +6082,7 @@ function preflight7(load: LoadResult): {
// ERR_MODULE_NOT_FOUND), and a declaration file is not a program
// module: an edge to it would lower the declarations' own imports
// as code.
if (reDep !== null && reDep.isDeclarationFile) {
if (reDep !== null && reDep.isDeclarationFile && !declarationHasRuntimeFile(reDep)) {
diags.push(
unsupportedDiag(
"SC1010",
@@ -6271,7 +6288,7 @@ function preflight7(load: LoadResult): {
// edge would lower its imports as if it were code (SC2013 for every
// specifier inside a package's `.d.ts`). Type-only imports were
// already erased above; this is the value form.
if (dep !== null && dep.isDeclarationFile && !isJson) {
if (dep !== null && dep.isDeclarationFile && !isJson && !declarationHasRuntimeFile(dep)) {
diags.push(
unsupportedDiag(
"SC1010",
@@ -88,7 +88,7 @@ test("noUnusedLocals and noUnusedParameters report nothing in declaration files"
const codes = (extension: string, text: string): string[] =>
checkPreflightTs7(
project(options, {
"main.ts": 'import "./extra.js";\nexport {};\n',
"main.ts": 'export type {} from "./extra.js";\n',
[`extra.${extension}`]: text,
}),
).diags.map((d) => d.message);
@@ -0,0 +1,10 @@
{
"order": [
"<repo>/tests/corpus/node-island-import-order/native-a.ts",
"<repo>/tests/corpus/node-island-import-order/barrel.ts",
"<repo>/tests/corpus/node-island-import-order/native-b.ts",
"<repo>/tests/corpus/node-island-import-order/user.ts",
"<repo>/tests/corpus/node-island-import-order/main.ts"
],
"diags": []
}
@@ -38,3 +38,13 @@ const second = list();
const sortedNative: string[] = [...first];
sortedNative.sort();
console.log(sortedNative.join(","), second.join(","), describe(first));
// A function-local island array captured by an arrow crosses the same way.
function captured(): string {
const local = list();
const show = (): string => describe(local);
const size = (): number => local.length;
const spread = (): string => [...local].join(",");
return `${show()} ${size()} ${spread()}`;
}
console.log(captured());
@@ -0,0 +1,5 @@
// The island re-export comes first, so the island module evaluates before
// the native module this barrel imports next.
export { tag } from "sidekit";
import { a } from "./native-a.ts";
console.log("barrel evaluates", a);
@@ -0,0 +1,8 @@
// @requires-libnode
// Static imports and re-exports of a Node island package evaluate the
// island module in source order among the native modules a file imports,
// as Node evaluates dependencies: an island package's top-level code runs
// before a native module imported after it, and after one imported before.
import { tag } from "./barrel.ts";
import "./user.ts";
console.log("main", tag("main"));
@@ -0,0 +1,2 @@
console.log("native module a evaluates");
export const a = "a";
@@ -0,0 +1,2 @@
console.log("native module b evaluates");
export const b = "b";
@@ -0,0 +1 @@
export declare function tag(text: string): string;
+4
View File
@@ -0,0 +1,4 @@
"use strict";
// Top-level code with an observable effect: when it runs is the test.
console.log("second island module evaluates");
exports.tag = (text) => "<" + text + ">";
@@ -0,0 +1 @@
{ "name": "laterkit", "version": "1.0.0", "main": "index.js", "types": "index.d.ts" }
@@ -0,0 +1 @@
export declare function tag(text: string): string;
+4
View File
@@ -0,0 +1,4 @@
"use strict";
// Top-level code with an observable effect: when it runs is the test.
console.log("island module evaluates");
exports.tag = (text) => "[" + text + "]";
@@ -0,0 +1 @@
{ "name": "sidekit", "version": "1.0.0", "main": "index.js", "types": "index.d.ts" }
@@ -0,0 +1 @@
{ "name": "node-island-import-order", "private": true, "type": "module", "scriptc": { "packages": { "sidekit": "node", "laterkit": "node" } } }
@@ -0,0 +1,5 @@
// A native import, then an island import, then another native import.
import { b } from "./native-b.ts";
import { tag } from "laterkit";
import { a } from "./native-a.ts";
console.log("user evaluates", tag(a + b));