mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-11-01 07:33:12 +08:00
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:
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
+10
@@ -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";
|
||||
+1
@@ -0,0 +1 @@
|
||||
export declare function tag(text: string): string;
|
||||
+4
@@ -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 + ">";
|
||||
+1
@@ -0,0 +1 @@
|
||||
{ "name": "laterkit", "version": "1.0.0", "main": "index.js", "types": "index.d.ts" }
|
||||
+1
@@ -0,0 +1 @@
|
||||
export declare function tag(text: string): string;
|
||||
+4
@@ -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 + "]";
|
||||
+1
@@ -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));
|
||||
Reference in New Issue
Block a user