diff --git a/packages/compiler/src/frontend/lowering/lower-modules.ts b/packages/compiler/src/frontend/lowering/lower-modules.ts index ba33811f96..16dc0a2ca2 100644 --- a/packages/compiler/src/frontend/lowering/lower-modules.ts +++ b/packages/compiler/src/frontend/lowering/lower-modules.ts @@ -156,6 +156,49 @@ export interface FileParts { topStmts: ts.Statement[]; } +/** Preflight's module graph (LoadResult.moduleEdges), for the walks that + * append modules outside the startup order, and the set collecting the + * inline require() calls that close a cycle there. */ +export interface AppendGraph { + edges?: ReadonlyMap | undefined; + inlineCycleCalls?: Set; +} + +/** An appended module's edges: its import and export declarations, then + * the require() edges preflight resolved — a CommonJS file's only edges + * (the compiled CommonJS package a workspace module loaded on demand + * imports, and the sibling packages and files IT requires). */ +function appendedEdgesOf( + program: ts.Program, + graph: AppendGraph | undefined, +): (sf: ts.SourceFile) => CycleEdge[] { + const cache = new Map(); + return (sf) => { + let out = cache.get(sf); + if (out !== undefined) return out; + out = orderedImportsOf(program, sf).flatMap(({ stmt, dep }) => + dep !== null && dep !== sf + ? [{ dep, stmt: stmt as ts.ImportDeclaration | ts.ExportDeclaration }] + : [], + ); + for (const e of graph?.edges?.get(sf) ?? []) + if (e.stmt === undefined && e.dep !== sf) out.push(e); + cache.set(sf, out); + return out; + }; +} + +/** Lazy inline requires run after their module's top level: their modules + * join the order afterwards, as in preflight's walk. */ +function visitLazyEdges( + sf: ts.SourceFile, + edgesOf: (sf: ts.SourceFile) => CycleEdge[], + state: ReadonlyMap, + visit: (sf: ts.SourceFile) => void, +): void { + for (const e of edgesOf(sf)) if (e.lazy === true && state.get(e.dep) === undefined) visit(e.dep); +} + /** Program modules selected by statically resolved child_process.fork() * calls join the compiled graph without becoming startup dependencies of * the parent entry. Their guarded init functions are selected by %main only @@ -166,8 +209,9 @@ export function appendForkModules( order: ts.SourceFile[], targets: ts.SourceFile[], onCycle: (cycle: string, reason: string) => void, + graph?: AppendGraph, ): void { - appendEntryModules(program, order, targets, forkTargetPaths(program, order), onCycle); + appendEntryModules(program, order, targets, forkTargetPaths(program, order), onCycle, graph); } export function appendWorkerModules( @@ -175,8 +219,9 @@ export function appendWorkerModules( order: ts.SourceFile[], targets: ts.SourceFile[], onCycle: (cycle: string, reason: string) => void, + graph?: AppendGraph, ): void { - appendEntryModules(program, order, targets, workerTargetPaths(program, order), onCycle); + appendEntryModules(program, order, targets, workerTargetPaths(program, order), onCycle, graph); } function appendEntryModules( @@ -185,6 +230,7 @@ function appendEntryModules( targets: ts.SourceFile[], paths: readonly string[], onCycle: (cycle: string, reason: string) => void, + graph?: AppendGraph, ): void { if (order.length === 0) return; const byPath = new Map( @@ -194,22 +240,24 @@ function appendEntryModules( for (const sf of order) state.set(sf, "done"); const added: ts.SourceFile[] = []; const stack: string[] = []; - const staticEdgesOf = (sf: ts.SourceFile): CycleEdge[] => - orderedImportsOf(program, sf).flatMap(({ stmt, dep }) => - dep !== null && dep !== sf - ? [{ dep, stmt: stmt as ts.ImportDeclaration | ts.ExportDeclaration }] - : [], - ); - const cycleAdmissionReason = makeCycleAdmission(program, staticEdgesOf); + const staticEdgesOf = appendedEdgesOf(program, graph); + const cycleAdmissionReason = makeCycleAdmission(program, (sf) => + staticEdgesOf(sf).filter((e) => e.call === undefined), + ); const visit = (sf: ts.SourceFile): void => { const prior = state.get(sf); if (prior === "done") return; state.set(sf, "visiting"); stack.push(sf.fileName); for (const edge of staticEdgesOf(sf)) { + if (edge.lazy === true) continue; // visited after this module completes const edgeState = state.get(edge.dep); if (edgeState === "done") continue; if (edgeState === "visiting") { + if (edge.call !== undefined) { + graph?.inlineCycleCalls?.add(edge.call); + continue; + } const reason = cycleAdmissionReason(sf, edge); if (reason !== null) { const cycleStart = stack.indexOf(edge.dep.fileName); @@ -222,6 +270,7 @@ function appendEntryModules( stack.pop(); state.set(sf, "done"); added.push(sf); + visitLazyEdges(sf, staticEdgesOf, state, visit); }; const knownTargets = new Set(targets); @@ -313,27 +362,30 @@ export function appendDynamicImportModules( program: ts.Program, order: ts.SourceFile[], onCycle: (cycle: string, reason: string) => void, + graph?: AppendGraph, ): void { if (order.length === 0) return; // no preflight order — sites keep their fences const state = new Map(); for (const sf of order) state.set(sf, "done"); const added: ts.SourceFile[] = []; const stack: string[] = []; - const staticEdgesOf = (sf: ts.SourceFile): CycleEdge[] => - orderedImportsOf(program, sf).flatMap(({ stmt, dep }) => - dep !== null && dep !== sf - ? [{ dep, stmt: stmt as ts.ImportDeclaration | ts.ExportDeclaration }] - : [], - ); - const cycleAdmissionReason = makeCycleAdmission(program, staticEdgesOf); + const staticEdgesOf = appendedEdgesOf(program, graph); + const cycleAdmissionReason = makeCycleAdmission(program, (sf) => + staticEdgesOf(sf).filter((e) => e.call === undefined), + ); const visit = (sf: ts.SourceFile): void => { if (state.get(sf) !== undefined) return; state.set(sf, "visiting"); stack.push(sf.fileName); for (const e of staticEdgesOf(sf)) { + if (e.lazy === true) continue; // visited after this module completes const s = state.get(e.dep); if (s === "done") continue; if (s === "visiting") { + if (e.call !== undefined) { + graph?.inlineCycleCalls?.add(e.call); + continue; + } // A back edge: Node answers it from the cache. Same admission // question as preflight's static walk, same fence when refused. const reason = cycleAdmissionReason(sf, e); @@ -348,6 +400,7 @@ export function appendDynamicImportModules( stack.pop(); state.set(sf, "done"); added.push(sf); + visitLazyEdges(sf, staticEdgesOf, state, visit); }; const scanned = new Set(); for (;;) { diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 42d07305d4..8ae3771f7b 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -233,6 +233,7 @@ import { requireSpecOf, resolveImport, workspacePackageOfPath, + type CycleEdge, } from "../program.js"; import { containsRecord, @@ -784,6 +785,8 @@ export interface LowerOptions { startupCrash?: StartupCrash | null; /** LoadResult.inlineRequireCycleCalls. */ inlineRequireCycleCalls?: ReadonlySet | undefined; + /** LoadResult.moduleEdges. */ + moduleEdges?: ReadonlyMap | undefined; /** LIBRARY mode's reachability roots: the profile-mapped exports of the * entry module. Executable builds root at the entry's top level alone * (an unreferenced export dead-strips); a library's exports are called from @@ -972,11 +975,15 @@ export function lowerToIr( ), ); }; + // Inline require() calls closing a cycle inside the appended subgraphs + // join preflight's set (the same run-time refusal by name). + const inlineRequireCycleCalls = new Set(options.inlineRequireCycleCalls ?? []); + const graph = { edges: options.moduleEdges, inlineCycleCalls: inlineRequireCycleCalls }; for (let pass = 0; pass < 32; pass++) { const before = moduleOrder.length; - appendDynamicImportModules(program, moduleOrder, onExtraCycle); - appendForkModules(program, moduleOrder, forkTargets, onExtraCycle); - appendWorkerModules(program, moduleOrder, workerTargets, onExtraCycle); + appendDynamicImportModules(program, moduleOrder, onExtraCycle, graph); + appendForkModules(program, moduleOrder, forkTargets, onExtraCycle, graph); + appendWorkerModules(program, moduleOrder, workerTargets, onExtraCycle, graph); if (moduleOrder.length === before) break; if (pass === 31) throw new Error("dynamic import and fork module discovery did not converge"); } @@ -991,7 +998,7 @@ export function lowerToIr( projectCheckJs: options.projectCheckJs ?? false, targetPlatform, startupCrash, - inlineRequireCycleCalls: options.inlineRequireCycleCalls, + inlineRequireCycleCalls, ffiImports, libraryCallbacks, forkTargets, @@ -1015,7 +1022,7 @@ export function lowerToIr( projectCheckJs: options.projectCheckJs ?? false, targetPlatform, startupCrash, - inlineRequireCycleCalls: options.inlineRequireCycleCalls, + inlineRequireCycleCalls, ffiImports, libraryCallbacks, forkTargets, @@ -1046,7 +1053,7 @@ export function lowerToIr( reachable, targetPlatform, startupCrash, - inlineRequireCycleCalls: options.inlineRequireCycleCalls, + inlineRequireCycleCalls, ffiImports, libraryCallbacks, forkTargets, @@ -1105,7 +1112,7 @@ export function lowerToIr( reachable, remainder: true, alreadyFlushed: resultLowerer.flushedSymbols, - inlineRequireCycleCalls: options.inlineRequireCycleCalls, + inlineRequireCycleCalls, targetPlatform, ffiImports, libraryCallbacks, diff --git a/packages/compiler/src/frontend/npm-static-enum-iife.test.ts b/packages/compiler/src/frontend/npm-static-enum-iife.test.ts new file mode 100644 index 0000000000..b27c53345c --- /dev/null +++ b/packages/compiler/src/frontend/npm-static-enum-iife.test.ts @@ -0,0 +1,135 @@ +import { spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, expect, test } from "vitest"; +import { nodeFrontendServices } from "./services-node.js"; + +const dirs: string[] = []; +afterAll(() => { + for (const dir of dirs) rmSync(dir, { recursive: true, force: true }); +}); + +function rewrite(source: string): string | null { + return nodeFrontendServices().enumIifes(source, "/pkg/index.js"); +} + +function run(file: string): string { + const result = spawnSync(process.execPath, [file], { encoding: "utf8" }); + expect(result.status).toBe(0); + return result.stdout; +} + +const probe = ` +for (const name of Object.keys(module.exports)) { + const v = module.exports[name]; + console.log(name, JSON.stringify(v), Object.keys(v ?? {}).join(",")); +} +`; + +/** The rewritten module exports what the original exports, keys in the + * same order. */ +function expectSameExports(source: string): string { + const result = rewrite(source); + expect(typeof result).toBe("string"); + const dir = mkdtempSync(join(tmpdir(), "scriptc-enum-iife-")); + dirs.push(dir); + const original = join(dir, "original.cjs"); + const rewritten = join(dir, "rewritten.cjs"); + writeFileSync(original, source + probe); + writeFileSync(rewritten, result! + probe); + expect(run(rewritten)).toBe(run(original)); + // Line positions survive the respelling. + expect(result!.split("\n").length).toBe(source.split("\n").length); + return result!; +} + +test("a TypeScript 5 exported string enum becomes its object literal", () => { + const out = expectSameExports(`"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.Kind = void 0; +var Kind; +(function (Kind) { + Kind["Identifier"] = "Identifier"; + Kind["Literal"] = "Literal"; +})(Kind || (exports.Kind = Kind = {})); +`); + expect(out).toContain(`var Kind = { "Identifier": "Identifier", "Literal": "Literal" };`); + expect(out).toContain("exports.Kind = Kind;"); +}); + +test("numeric enums keep their reverse mapping and Node's key order", () => { + const out = expectSameExports(`"use strict"; +var Level; +(function (Level) { + Level[Level["High"] = 2] = "High"; + Level[Level["Low"] = 0] = "Low"; + Level[Level["Below"] = -1] = "Below"; + Level[Level["Also"] = 2] = "Also"; +})(Level = exports.Level || (exports.Level = {})); +`); + expect(out).toContain( + `var Level = { 0: "Low", 2: "Also", "High": 2, "Low": 0, "Below": -1, "-1": "Below", "Also": 2 };`, + ); +}); + +test("an ES module's exported enum keeps its export", () => { + const source = `export var Kind; +(function (Kind) { + Kind["Identifier"] = "Identifier"; + Kind[Kind["Zero"] = 0] = "Zero"; +})(Kind || (Kind = {})); +`; + const out = rewrite(source); + expect(out).toContain(`export var Kind = { 0: "Zero", "Identifier": "Identifier", "Zero": 0 };`); + const dir = mkdtempSync(join(tmpdir(), "scriptc-enum-iife-")); + dirs.push(dir); + writeFileSync(join(dir, "original.mjs"), source); + writeFileSync(join(dir, "rewritten.mjs"), out!); + const reader = (file: string) => + `import { Kind } from "./${file}"; console.log(JSON.stringify(Kind), Object.keys(Kind).join(","));`; + writeFileSync(join(dir, "read-original.mjs"), reader("original.mjs")); + writeFileSync(join(dir, "read-rewritten.mjs"), reader("rewritten.mjs")); + expect(run(join(dir, "read-rewritten.mjs"))).toBe(run(join(dir, "read-original.mjs"))); + expect(out!.split("\n").length).toBe(source.split("\n").length); +}); + +test("an enum the module does not export becomes a local object", () => { + const out = expectSameExports(`"use strict"; +var Mode; +(function (Mode) { + Mode.Fast = "fast"; +})(Mode || (Mode = {})); +exports.mode = Mode.Fast; +`); + expect(out).toContain(`var Mode = { "Fast": "fast" };`); +}); + +test("merged, computed and otherwise written enums keep their text", () => { + // A second declaration merges into the first. + expect( + rewrite(`var E; +(function (E) { E["A"] = "a"; })(E || (E = {})); +(function (E) { E["B"] = "b"; })(E || (E = {})); +`), + ).toBeNull(); + // A member referencing another member. + expect( + rewrite(`var F; +(function (F) { F[F["A"] = 1] = "A"; F[F["B"] = F.A << 1] = "B"; })(F || (exports.F = F = {})); +`), + ).toBeNull(); + // A statement other than a member write. + expect( + rewrite(`var G; +(function (G) { G["A"] = "a"; console.log("side effect"); })(G || (exports.G = G = {})); +`), + ).toBeNull(); + // The variable is reassigned later. + expect( + rewrite(`var H; +(function (H) { H["A"] = "a"; })(H || (exports.H = H = {})); +H = null; +`), + ).toBeNull(); +}); diff --git a/packages/compiler/src/frontend/npm-static-enum-iife.ts b/packages/compiler/src/frontend/npm-static-enum-iife.ts new file mode 100644 index 0000000000..288a172e27 --- /dev/null +++ b/packages/compiler/src/frontend/npm-static-enum-iife.ts @@ -0,0 +1,284 @@ +/* TypeScript's enum emit in a compiled package: a bare `var` filled by an + * immediately invoked function that writes each member onto its argument. + * + * var Kind; + * (function (Kind) { + * Kind["Identifier"] = "Identifier"; + * Kind[Kind["Zero"] = 0] = "Zero"; + * })(Kind || (exports.Kind = Kind = {})); + * + * Inference types the variable from the empty object the argument creates, + * so every member read lands on `{}`. When every member value is a literal, + * the enum is that object literal: the declaration becomes + * `var Kind = { ... };` and the call becomes the export assignment it + * performs. Keys keep Node's own-property order (integer keys ascending, + * then the rest in insertion order, a later write replacing an earlier + * value in place), so enumeration observes the same object. + * + * Accepted argument spellings: `Kind || (exports.Kind = Kind = {})` + * (TypeScript 5), `Kind = exports.Kind || (exports.Kind = {})` (earlier + * releases) and `Kind || (Kind = {})` (an enum the module does not + * export). Merged declarations, computed or referencing members, and any + * other write to the variable keep the original text. */ +import * as ts from "./ts7/syntax.js"; +import { applyEdits, unparen, type Edit } from "./npm-static-bundled-chunks.js"; + +/** True when the text may hold an enum IIFE (a cheap probe). */ +export function mayHoldEnumIife(source: string): boolean { + return source.includes("(function (") && source.includes("|| ("); +} + +/** The module text with each literal-valued enum IIFE respelled as an + * object literal, or null when there is none. */ +export function rewriteEnumIifes(sf: ts.SourceFile): string | null { + const stmts = sf.statements.nodes; + const edits: Edit[] = []; + for (let i = 1; i < stmts.length; i++) { + const decl = bareVarName(stmts[i - 1]!); + if (decl === null) continue; + const iife = enumIife(stmts[i]!, decl); + if (iife === null || writtenElsewhere(sf, decl, stmts[i]!)) continue; + // Modifiers before `var` stay: `export var Kind;` (an ES module's + // enum) keeps exporting the binding. + const declaration = stmts[i - 1] as ts.VariableStatement; + const modifiers = sf.text.slice( + declaration.getStart(sf), + declaration.declarationList.getStart(sf), + ); + edits.push({ + start: declaration.getStart(sf), + end: declaration.getEnd(), + text: `${modifiers}var ${decl} = ${objectLiteral(iife.members)};`, + }); + edits.push({ + start: stmts[i]!.getStart(sf), + end: stmts[i]!.getEnd(), + text: iife.exportName === null ? "" : `exports.${iife.exportName} = ${decl};`, + }); + } + if (edits.length === 0) return null; + return applyEdits(sf.text, 0, sf.text.length, edits); +} + +/** `var Name;`: one declarator, no initializer. */ +function bareVarName(stmt: ts.Statement): string | null { + if (!ts.isVariableStatement(stmt)) return null; + const list = stmt.declarationList; + if ((list.flags & (ts.NodeFlags.Let | ts.NodeFlags.Const)) !== 0) return null; + const decls = list.declarations.nodes; + if (decls.length !== 1) return null; + const d = decls[0]!; + if (d.initializer !== undefined || !ts.isIdentifier(d.name)) return null; + return d.name.text; +} + +type Members = Map; + +function enumIife( + stmt: ts.Statement, + name: string, +): { members: Members; exportName: string | null } | null { + if (!ts.isExpressionStatement(stmt)) return null; + const call = unparen(stmt.expression); + if (!ts.isCallExpression(call) || call.arguments.nodes.length !== 1) return null; + const fn = unparen(call.expression); + if (!ts.isFunctionExpression(fn) || fn.name !== undefined || fn.asteriskToken !== undefined) + return null; + if (fn.modifiers !== undefined && fn.modifiers.nodes.length > 0) return null; + const params = fn.parameters.nodes; + if (params.length !== 1) return null; + const param = params[0]!; + if (!ts.isIdentifier(param.name) || param.initializer !== undefined) return null; + if (param.dotDotDotToken !== undefined) return null; + const exportName = enumArgument(call.arguments.nodes[0]!, name); + if (exportName === undefined) return null; + const members = enumMembers(fn.body.statements.nodes, param.name.text); + return members === null ? null : { members, exportName }; +} + +/** The export name the argument assigns (null: not exported), or + * undefined when it is not one of the enum spellings. */ +function enumArgument(arg: ts.Expression, name: string): string | null | undefined { + const e = unparen(arg); + if (!ts.isBinaryExpression(e)) return undefined; + // `Kind = exports.Kind || (exports.Kind = {})` + if (e.operatorToken.kind === ts.SyntaxKind.EqualsToken) { + if (!isName(e.left, name)) return undefined; + const or = unparen(e.right); + if (!ts.isBinaryExpression(or) || or.operatorToken.kind !== ts.SyntaxKind.BarBarToken) + return undefined; + const exported = exportsMember(or.left); + if (exported === null) return undefined; + const init = unparen(or.right); + if ( + !ts.isBinaryExpression(init) || + init.operatorToken.kind !== ts.SyntaxKind.EqualsToken || + exportsMember(init.left) !== exported || + !isEmptyObject(init.right) + ) + return undefined; + return exported; + } + if (e.operatorToken.kind !== ts.SyntaxKind.BarBarToken || !isName(e.left, name)) return undefined; + const init = unparen(e.right); + if (!ts.isBinaryExpression(init) || init.operatorToken.kind !== ts.SyntaxKind.EqualsToken) + return undefined; + // `Kind || (Kind = {})` + if (isName(init.left, name)) return isEmptyObject(init.right) ? null : undefined; + // `Kind || (exports.Kind = Kind = {})` + const exported = exportsMember(init.left); + const inner = unparen(init.right); + if ( + exported === null || + !ts.isBinaryExpression(inner) || + inner.operatorToken.kind !== ts.SyntaxKind.EqualsToken || + !isName(inner.left, name) || + !isEmptyObject(inner.right) + ) + return undefined; + return exported; +} + +/** The members the body writes, or null when a statement is anything but + * a literal member write (`E["K"] = v`, `E.K = v`, or the reverse-mapped + * `E[E["K"] = v] = "K"`). */ +function enumMembers(body: readonly ts.Statement[], param: string): Members | null { + const members: Members = new Map(); + for (const stmt of body) { + if (!ts.isExpressionStatement(stmt)) return null; + const e = stmt.expression; + if (!ts.isBinaryExpression(e) || e.operatorToken.kind !== ts.SyntaxKind.EqualsToken) + return null; + const value = literalValue(e.right); + if (value === null) return null; + const key = memberKey(e.left, param); + if (key !== null) { + members.set(key, value); + continue; + } + // E[E["K"] = v] = "K" + const target = e.left; + if (!ts.isElementAccessExpression(target) || !isName(target.expression, param)) return null; + const inner = unparen(target.argumentExpression); + if (!ts.isBinaryExpression(inner) || inner.operatorToken.kind !== ts.SyntaxKind.EqualsToken) + return null; + const innerKey = memberKey(inner.left, param); + const innerValue = literalValue(inner.right); + if (innerKey === null || innerValue === null) return null; + members.set(innerKey, innerValue); + members.set(propertyKey(innerValue), value); + } + return members.size > 0 ? members : null; +} + +function memberKey(target: ts.Expression, param: string): string | null { + if (ts.isElementAccessExpression(target) && isName(target.expression, param)) { + const k = unparen(target.argumentExpression); + return ts.isStringLiteral(k) || ts.isNoSubstitutionTemplateLiteral(k) ? k.text : null; + } + if ( + ts.isPropertyAccessExpression(target) && + isName(target.expression, param) && + ts.isIdentifier(target.name) + ) + return target.name.text; + return null; +} + +function literalValue(expr: ts.Expression): string | number | null { + const e = unparen(expr); + if (ts.isStringLiteral(e) || ts.isNoSubstitutionTemplateLiteral(e)) return e.text; + if (ts.isNumericLiteral(e)) return numberOf(e.text); + if ( + ts.isPrefixUnaryExpression(e) && + e.operator === ts.SyntaxKind.MinusToken && + ts.isNumericLiteral(e.operand) + ) { + const n = numberOf(e.operand.text); + return n === null ? null : -n; + } + return null; +} + +function numberOf(text: string): number | null { + if (!/^(?:\d+(?:\.\d+)?(?:[eE][+-]?\d+)?|0[xX][0-9a-fA-F]+)$/.test(text)) return null; + const n = Number(text); + return Number.isFinite(n) ? n : null; +} + +/** ToPropertyKey of a member value. */ +function propertyKey(value: string | number): string { + return typeof value === "string" ? value : String(value); +} + +/** The canonical array-index keys JavaScript enumerates first. */ +function isIndexKey(key: string): boolean { + return /^(?:0|[1-9]\d*)$/.test(key) && Number(key) < 4294967295; +} + +function objectLiteral(members: Members): string { + const keys = [...members.keys()]; + const ordered = [ + ...keys.filter(isIndexKey).sort((a, b) => Number(a) - Number(b)), + ...keys.filter((k) => !isIndexKey(k)), + ]; + const parts = ordered.map((k) => { + const v = members.get(k)!; + const value = typeof v === "string" ? JSON.stringify(v) : Object.is(v, -0) ? "-0" : String(v); + return `${isIndexKey(k) ? k : JSON.stringify(k)}: ${value}`; + }); + return `{ ${parts.join(", ")} }`; +} + +/** True when the module writes `name` anywhere outside the enum call. */ +function writtenElsewhere(sf: ts.SourceFile, name: string, iife: ts.Statement): boolean { + const start = iife.getStart(sf); + const end = iife.getEnd(); + let written = false; + ts.walkPreorder(sf, (n) => { + if (written) return "stop"; + if (n.pos >= start && n.end <= end) return "skip"; + if (ts.isBinaryExpression(n) && isAssignmentOperator(n.operatorToken.kind)) { + if (isName(n.left, name)) written = true; + } else if ( + (ts.isPrefixUnaryExpression(n) || ts.isPostfixUnaryExpression(n)) && + (n.operator === ts.SyntaxKind.PlusPlusToken || + n.operator === ts.SyntaxKind.MinusMinusToken) && + isName(n.operand, name) + ) { + written = true; + } else if ( + (ts.isFunctionDeclaration(n) || ts.isClassDeclaration(n)) && + n.name !== undefined && + n.name.text === name + ) { + written = true; + } + return undefined; + }); + return written; +} + +function isAssignmentOperator(kind: ts.SyntaxKind): boolean { + return kind >= ts.SyntaxKind.FirstAssignment && kind <= ts.SyntaxKind.LastAssignment; +} + +function isName(e: ts.Expression, name: string): boolean { + const u = unparen(e); + return ts.isIdentifier(u) && u.text === name; +} + +function exportsMember(e: ts.Expression): string | null { + const u = unparen(e); + return ts.isPropertyAccessExpression(u) && + ts.isIdentifier(u.expression) && + u.expression.text === "exports" && + ts.isIdentifier(u.name) + ? u.name.text + : null; +} + +function isEmptyObject(e: ts.Expression): boolean { + const u = unparen(e); + return ts.isObjectLiteralExpression(u) && u.properties.nodes.length === 0; +} diff --git a/packages/compiler/src/frontend/npm-static.ts b/packages/compiler/src/frontend/npm-static.ts index 43a1439961..bcff89633d 100644 --- a/packages/compiler/src/frontend/npm-static.ts +++ b/packages/compiler/src/frontend/npm-static.ts @@ -455,8 +455,16 @@ export function npmStaticFsShadow(services: FrontendServices): NpmStaticFsShadow // through that function. const fnExports = original === null ? null : services.cjsFunctionExports(original, path); if (fnExports !== null) original = fnExports; + // TypeScript's enum IIFEs with literal members become the object + // literals they build. + const enums = original === null ? null : services.enumIifes(original, path); + if (enums !== null) original = enums; const respelled = - split !== null || esm !== null || aliased !== null || fnExports !== null; + split !== null || + esm !== null || + aliased !== null || + fnExports !== null || + enums !== null; if (original !== null) { const namepaths = services.jsDocNamepaths(path, original); const source = namepaths ?? original; diff --git a/packages/compiler/src/frontend/pipeline.ts b/packages/compiler/src/frontend/pipeline.ts index 1bb34bc3bc..82f3d2d902 100644 --- a/packages/compiler/src/frontend/pipeline.ts +++ b/packages/compiler/src/frontend/pipeline.ts @@ -10,6 +10,7 @@ import { checkPreflight, isNodeTypesPath, locOf, + npmStaticDepSf7, npmStaticLoadableFiles, requiresOf, resolveImport, @@ -119,26 +120,32 @@ export type FrontendFactory = ( * with their own static import graphs: they load at the call, outside the * startup module order, and their npm imports are dependencies all the * same (a workspace package loaded on demand, the vercel.ts config parser - * shape). Package files are the package machinery's business. */ + * shape). An opted-in package's JavaScript reached that way joins too, + * with every file its literal require() and import edges load: those + * files are outside the module order as well, and the packages THEY + * require (a compiled CommonJS dist requiring a sibling package) are + * dependencies the selection must see. */ function addDynamicImportGraph(load: LoadResult, scanned: Set): void { const queue = [...scanned]; const added = new Set(); while (queue.length > 0) { const sf = queue.pop()!; - if (sf.fileName.includes("/node_modules/")) continue; - const specs: string[] = []; + const packageJs = sf.fileName.includes("/node_modules/"); + if (packageJs && !added.has(sf)) continue; + const specs: { spec: string; kind: "import" | "require" }[] = []; // Every literal import() in the file, read from the syntax: a comment // can stand between the keyword and its parenthesis. A file without - // the keyword has none. - const calls = sf.text.includes("import") ? moduleSourceCandidates(load.program, sf).calls : []; + // the keyword has none. Package JavaScript loads its literal require() + // calls the same way, nested ones included. + const calls = + packageJs || sf.text.includes("import") ? moduleSourceCandidates(load.program, sf).calls : []; for (const call of calls) { const arg = call.arguments.nodes[0]; - if ( - call.expression.kind === SyntaxKind.ImportKeyword && - arg !== undefined && - isStringLiteralLike(arg) - ) - specs.push(arg.text); + if (arg === undefined || !isStringLiteralLike(arg)) continue; + if (call.expression.kind === SyntaxKind.ImportKeyword) + specs.push({ spec: arg.text, kind: "import" }); + else if (packageJs && isIdentifier(call.expression) && call.expression.text === "require") + specs.push({ spec: arg.text, kind: "require" }); } // The startup graph's static edges are already in the module order. if (added.has(sf)) { @@ -149,12 +156,18 @@ function addDynamicImportGraph(load: LoadResult, scanned: Set): void isStringLiteral(stmt.moduleSpecifier) && !(isImportDeclaration(stmt) ? erasedTypeOnlyImport(stmt) : stmt.isTypeOnly) ) - specs.push(stmt.moduleSpecifier.text); + specs.push({ spec: stmt.moduleSpecifier.text, kind: "import" }); } } - for (const spec of specs) { - const dep = resolveImport(load.program, sf, spec); - if (dep === null || scanned.has(dep) || dep.fileName.includes("/node_modules/")) continue; + for (const { spec, kind } of specs) { + if (canonicalBuiltinModule(spec) !== null) continue; + // Only the JavaScript of a package the selection already opted in: + // a package's declarations load nothing. + const dep = + resolveImport(load.program, sf, spec, kind) ?? + npmStaticDepSf7(load.program, sf, spec, kind); + if (dep === null || scanned.has(dep)) continue; + if (dep.fileName.includes("/node_modules/") && !isJsSourceFileName(dep.fileName)) continue; scanned.add(dep); added.add(dep); queue.push(dep); @@ -674,6 +687,7 @@ function loadFrontend( frontendServices: finalLoad.services, startupCrash: finalLoad.startupCrash ?? null, inlineRequireCycleCalls: finalLoad.inlineRequireCycleCalls, + moduleEdges: finalLoad.moduleEdges, projectCheckJs: finalLoad.typeWorld?.projectCheckJs ?? false, externalTypes: finalLoad.externalTypes, externalTypeSpecifiersByFile: finalLoad.externalTypeSpecifiersByFile, diff --git a/packages/compiler/src/frontend/program.ts b/packages/compiler/src/frontend/program.ts index 5ce2ef20b7..a78c68477c 100644 --- a/packages/compiler/src/frontend/program.ts +++ b/packages/compiler/src/frontend/program.ts @@ -354,6 +354,11 @@ export interface LoadResult { * at run time instead of refusing the whole program. Filled by * checkPreflight. */ inlineRequireCycleCalls?: ReadonlySet; + /** Every program file's module edges as preflight resolved them, + * require() edges included: the lowering orders the modules only a + * literal import(), fork() or Worker reaches with them. Filled by + * checkPreflight. */ + moduleEdges?: ReadonlyMap | undefined; /** Node's startup refusal, when the graph carries an import edge Node * rejects before ANY module evaluates — a RESOLUTION refusal (a bare * specifier nothing installed resolves, an exports/imports map that @@ -1052,10 +1057,12 @@ export function loadProgram( * preflight diagnostics. Preserve the caller's complete load shape when * mutating it, including its owned services and disposal callback. */ export function checkPreflight(load: T): ScrDiagnostic[] { - const { diags, moduleOrder, startupCrash, inlineRequireCycleCalls } = preflight7(load); + const { diags, moduleOrder, startupCrash, inlineRequireCycleCalls, moduleEdges } = + preflight7(load); load.moduleOrder = moduleOrder; load.startupCrash = startupCrash; load.inlineRequireCycleCalls = inlineRequireCycleCalls; + load.moduleEdges = moduleEdges; return diags; } @@ -4393,6 +4400,7 @@ function preflight7(load: LoadResult): { moduleOrder: ts.SourceFile[]; startupCrash: StartupCrash | null; inlineRequireCycleCalls: ReadonlySet; + moduleEdges?: ReadonlyMap; } { const { program, entry } = load; const diags: ScrDiagnostic[] = [...load.configDiags]; @@ -5501,6 +5509,7 @@ function preflight7(load: LoadResult): { moduleOrder: order, startupCrash: resolveCrash ?? linkCrash, inlineRequireCycleCalls, + moduleEdges: edges, }; } diff --git a/packages/compiler/src/frontend/services.ts b/packages/compiler/src/frontend/services.ts index 308024b384..f349a2ad91 100644 --- a/packages/compiler/src/frontend/services.ts +++ b/packages/compiler/src/frontend/services.ts @@ -12,6 +12,7 @@ import { mayRequireBuiltinAlias, rewriteBuiltinAliasRequires, } from "./npm-static-builtin-aliases.js"; +import { mayHoldEnumIife, rewriteEnumIifes } from "./npm-static-enum-iife.js"; import { builtinPackageAliasFor } from "./package-shims.js"; import * as syntax from "./npm-static-declaration-syntax.js"; import type { SourceFile } from "./ts7/ast-types.js"; @@ -104,6 +105,13 @@ export class FrontendServices { if (!source.includes("module.exports")) return null; return normalizeCjsFunctionExports(this.parse(path, source, "js")); } + /** A package module with its literal-valued TypeScript enum IIFEs + * respelled as object literals (null: none). */ + enumIifes(source: string, path: string): string | null { + this.ensureOpen(); + if (!mayHoldEnumIife(source)) return null; + return rewriteEnumIifes(this.parse(path, source, "js")); + } /** A package module with its requires of builtin-aliased polyfill * packages respelled as the builtins (null: none). */ builtinAliasRequires(source: string, path: string): string | null { diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/workspace-dynamic-import-deps/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/workspace-dynamic-import-deps/main.ts.json new file mode 100644 index 0000000000..9206e9bfc6 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/workspace-dynamic-import-deps/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/fixtures/npm/cases/workspace-dynamic-import-deps/main.ts" + ], + "diags": [] +} diff --git a/tests/fixtures/npm/cases/workspace-dynamic-import-deps/main.ts b/tests/fixtures/npm/cases/workspace-dynamic-import-deps/main.ts new file mode 100644 index 0000000000..4e50ea733b --- /dev/null +++ b/tests/fixtures/npm/cases/workspace-dynamic-import-deps/main.ts @@ -0,0 +1,12 @@ +// A workspace package the program loads on demand with a dynamic import. +// Its npm dependency is compiled CommonJS that requires a sibling scoped +// package (shipping JavaScript and declarations) from a nested file. Both +// packages compile with the workspace package, although none of them is +// part of the startup module graph. +async function main(): Promise { + console.log("before"); + const m = await import("wsdynamicdeps"); + console.log(m.describe("42")); + console.log(m.describe("name")); +} +main(); diff --git a/tests/fixtures/npm/cases/workspace-dynamic-import-deps/node_modules/wsdynamicdeps b/tests/fixtures/npm/cases/workspace-dynamic-import-deps/node_modules/wsdynamicdeps new file mode 120000 index 0000000000..25faae497e --- /dev/null +++ b/tests/fixtures/npm/cases/workspace-dynamic-import-deps/node_modules/wsdynamicdeps @@ -0,0 +1 @@ +../../../workspace/wsdynamicdeps \ No newline at end of file diff --git a/tests/fixtures/npm/workspace/wsdynamicdeps/index.ts b/tests/fixtures/npm/workspace/wsdynamicdeps/index.ts new file mode 100644 index 0000000000..20609be17f --- /dev/null +++ b/tests/fixtures/npm/workspace/wsdynamicdeps/index.ts @@ -0,0 +1,6 @@ +import { classify, NodeKind } from "@gtdyn/estree"; + +export function describe(text: string): string { + const kind = classify(text); + return kind === NodeKind.Literal ? `literal ${text}` : `${kind} ${text}`; +} diff --git a/tests/fixtures/npm/workspace/wsdynamicdeps/node_modules/@gtdyn/estree/lib/classify.d.ts b/tests/fixtures/npm/workspace/wsdynamicdeps/node_modules/@gtdyn/estree/lib/classify.d.ts new file mode 100644 index 0000000000..ac901bdb90 --- /dev/null +++ b/tests/fixtures/npm/workspace/wsdynamicdeps/node_modules/@gtdyn/estree/lib/classify.d.ts @@ -0,0 +1,2 @@ +import { NodeKind } from "@gtdyn/types"; +export declare function classify(text: string): NodeKind; diff --git a/tests/fixtures/npm/workspace/wsdynamicdeps/node_modules/@gtdyn/estree/lib/classify.js b/tests/fixtures/npm/workspace/wsdynamicdeps/node_modules/@gtdyn/estree/lib/classify.js new file mode 100644 index 0000000000..d08ea80064 --- /dev/null +++ b/tests/fixtures/npm/workspace/wsdynamicdeps/node_modules/@gtdyn/estree/lib/classify.js @@ -0,0 +1,8 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.classify = void 0; +const types_1 = require("@gtdyn/types"); +function classify(text) { + return /^[0-9]+$/.test(text) ? types_1.NodeKind.Literal : types_1.NodeKind.Identifier; +} +exports.classify = classify; diff --git a/tests/fixtures/npm/workspace/wsdynamicdeps/node_modules/@gtdyn/estree/lib/index.d.ts b/tests/fixtures/npm/workspace/wsdynamicdeps/node_modules/@gtdyn/estree/lib/index.d.ts new file mode 100644 index 0000000000..44b541dfc5 --- /dev/null +++ b/tests/fixtures/npm/workspace/wsdynamicdeps/node_modules/@gtdyn/estree/lib/index.d.ts @@ -0,0 +1,2 @@ +export { classify } from "./classify"; +export { NodeKind } from "@gtdyn/types"; diff --git a/tests/fixtures/npm/workspace/wsdynamicdeps/node_modules/@gtdyn/estree/lib/index.js b/tests/fixtures/npm/workspace/wsdynamicdeps/node_modules/@gtdyn/estree/lib/index.js new file mode 100644 index 0000000000..ffcecce4a2 --- /dev/null +++ b/tests/fixtures/npm/workspace/wsdynamicdeps/node_modules/@gtdyn/estree/lib/index.js @@ -0,0 +1,7 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.NodeKind = exports.classify = void 0; +var classify_1 = require("./classify"); +Object.defineProperty(exports, "classify", { enumerable: true, get: function () { return classify_1.classify; } }); +var types_1 = require("@gtdyn/types"); +Object.defineProperty(exports, "NodeKind", { enumerable: true, get: function () { return types_1.NodeKind; } }); diff --git a/tests/fixtures/npm/workspace/wsdynamicdeps/node_modules/@gtdyn/estree/package.json b/tests/fixtures/npm/workspace/wsdynamicdeps/node_modules/@gtdyn/estree/package.json new file mode 100644 index 0000000000..922e315e6f --- /dev/null +++ b/tests/fixtures/npm/workspace/wsdynamicdeps/node_modules/@gtdyn/estree/package.json @@ -0,0 +1,10 @@ +{ + "name": "@gtdyn/estree", + "version": "1.0.0", + "description": "a compiled CommonJS package that requires its sibling scoped package from a nested file", + "main": "lib/index.js", + "types": "lib/index.d.ts", + "dependencies": { + "@gtdyn/types": "1.0.0" + } +} diff --git a/tests/fixtures/npm/workspace/wsdynamicdeps/node_modules/@gtdyn/types/lib/index.d.ts b/tests/fixtures/npm/workspace/wsdynamicdeps/node_modules/@gtdyn/types/lib/index.d.ts new file mode 100644 index 0000000000..2e6a9a968d --- /dev/null +++ b/tests/fixtures/npm/workspace/wsdynamicdeps/node_modules/@gtdyn/types/lib/index.d.ts @@ -0,0 +1,4 @@ +export declare enum NodeKind { + Identifier = "Identifier", + Literal = "Literal" +} diff --git a/tests/fixtures/npm/workspace/wsdynamicdeps/node_modules/@gtdyn/types/lib/index.js b/tests/fixtures/npm/workspace/wsdynamicdeps/node_modules/@gtdyn/types/lib/index.js new file mode 100644 index 0000000000..0a480b4b95 --- /dev/null +++ b/tests/fixtures/npm/workspace/wsdynamicdeps/node_modules/@gtdyn/types/lib/index.js @@ -0,0 +1,8 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.NodeKind = void 0; +var NodeKind; +(function (NodeKind) { + NodeKind["Identifier"] = "Identifier"; + NodeKind["Literal"] = "Literal"; +})(NodeKind || (exports.NodeKind = NodeKind = {})); diff --git a/tests/fixtures/npm/workspace/wsdynamicdeps/node_modules/@gtdyn/types/package.json b/tests/fixtures/npm/workspace/wsdynamicdeps/node_modules/@gtdyn/types/package.json new file mode 100644 index 0000000000..42f546c0d0 --- /dev/null +++ b/tests/fixtures/npm/workspace/wsdynamicdeps/node_modules/@gtdyn/types/package.json @@ -0,0 +1,7 @@ +{ + "name": "@gtdyn/types", + "version": "1.0.0", + "description": "a scoped package shipping compiled JavaScript and declarations", + "main": "lib/index.js", + "types": "lib/index.d.ts" +} diff --git a/tests/fixtures/npm/workspace/wsdynamicdeps/package.json b/tests/fixtures/npm/workspace/wsdynamicdeps/package.json new file mode 100644 index 0000000000..2135d92d66 --- /dev/null +++ b/tests/fixtures/npm/workspace/wsdynamicdeps/package.json @@ -0,0 +1,10 @@ +{ + "name": "wsdynamicdeps", + "version": "1.0.0", + "type": "module", + "main": "./index.ts", + "types": "./index.ts", + "dependencies": { + "@gtdyn/estree": "1.0.0" + } +}