From 2a836f858c392a769667394456c953017e709304 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Tue, 20 Oct 2026 03:22:42 -0700 Subject: [PATCH 1/4] Compile TypeScript enums in npm packages as the objects they build A compiled package spells each enum as a bare `var` filled by an immediately invoked function (`(function (Kind) { Kind["A"] = "A"; })(Kind || (exports.Kind = Kind = {}))`). Inference types the variable from the empty object the argument creates, so every member read landed on `{}` and failed at run time. When every member value is a literal, the declaration now becomes the object literal the function builds, keys in Node's own-property order, and the call becomes the export assignment it performs. The TypeScript 5 spelling, the earlier `Kind = exports.Kind || (exports.Kind = {})` spelling and unexported enums are recognized; merged, computed or otherwise written enums keep their text. Co-Authored-By: Claude Opus 5.5 --- .../src/frontend/npm-static-enum-iife.test.ts | 114 +++++++ .../src/frontend/npm-static-enum-iife.ts | 277 ++++++++++++++++++ packages/compiler/src/frontend/npm-static.ts | 10 +- packages/compiler/src/frontend/services.ts | 8 + 4 files changed, 408 insertions(+), 1 deletion(-) create mode 100644 packages/compiler/src/frontend/npm-static-enum-iife.test.ts create mode 100644 packages/compiler/src/frontend/npm-static-enum-iife.ts 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..2dd170a0b5 --- /dev/null +++ b/packages/compiler/src/frontend/npm-static-enum-iife.test.ts @@ -0,0 +1,114 @@ +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 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..2ba12b325b --- /dev/null +++ b/packages/compiler/src/frontend/npm-static-enum-iife.ts @@ -0,0 +1,277 @@ +/* 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; + edits.push({ + start: stmts[i - 1]!.getStart(sf), + end: stmts[i - 1]!.getEnd(), + text: `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/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 { From 18034065d6f8b522d3d8aa9852eda0d64e9c7d93 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Tue, 20 Oct 2026 03:22:43 -0700 Subject: [PATCH 2/4] Compile the packages a module loaded through import() requires A workspace package loaded on demand had its npm dependency selected, but the selection never scanned that dependency's own JavaScript: it is outside the startup module order, so a compiled CommonJS dist requiring a sibling package was refused with "the module is not supported yet". The scan of the import() graph now follows opted-in package JavaScript through its literal require() and import edges. The lowering appended those modules by their import declarations only, so a CommonJS file's requires never joined the compiled graph and their bindings stayed fenced. Preflight's resolved module edges, require edges included, now reach the walks that append import(), fork() and Worker modules, with lazy requires ordered after their module and inline require cycles answered as at startup. Co-Authored-By: Claude Opus 5.5 --- .../src/frontend/lowering/lower-modules.ts | 85 +++++++++++++++---- .../compiler/src/frontend/lowering/lowerer.ts | 21 +++-- packages/compiler/src/frontend/pipeline.ts | 44 ++++++---- packages/compiler/src/frontend/program.ts | 11 ++- .../main.ts.json | 6 ++ .../workspace-dynamic-import-deps/main.ts | 12 +++ .../node_modules/wsdynamicdeps | 1 + .../npm/workspace/wsdynamicdeps/index.ts | 6 ++ .../@gtdyn/estree/lib/classify.d.ts | 2 + .../@gtdyn/estree/lib/classify.js | 8 ++ .../node_modules/@gtdyn/estree/lib/index.d.ts | 2 + .../node_modules/@gtdyn/estree/lib/index.js | 7 ++ .../node_modules/@gtdyn/estree/package.json | 10 +++ .../node_modules/@gtdyn/types/lib/index.d.ts | 4 + .../node_modules/@gtdyn/types/lib/index.js | 8 ++ .../node_modules/@gtdyn/types/package.json | 7 ++ .../npm/workspace/wsdynamicdeps/package.json | 10 +++ 17 files changed, 205 insertions(+), 39 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/workspace-dynamic-import-deps/main.ts.json create mode 100644 tests/fixtures/npm/cases/workspace-dynamic-import-deps/main.ts create mode 120000 tests/fixtures/npm/cases/workspace-dynamic-import-deps/node_modules/wsdynamicdeps create mode 100644 tests/fixtures/npm/workspace/wsdynamicdeps/index.ts create mode 100644 tests/fixtures/npm/workspace/wsdynamicdeps/node_modules/@gtdyn/estree/lib/classify.d.ts create mode 100644 tests/fixtures/npm/workspace/wsdynamicdeps/node_modules/@gtdyn/estree/lib/classify.js create mode 100644 tests/fixtures/npm/workspace/wsdynamicdeps/node_modules/@gtdyn/estree/lib/index.d.ts create mode 100644 tests/fixtures/npm/workspace/wsdynamicdeps/node_modules/@gtdyn/estree/lib/index.js create mode 100644 tests/fixtures/npm/workspace/wsdynamicdeps/node_modules/@gtdyn/estree/package.json create mode 100644 tests/fixtures/npm/workspace/wsdynamicdeps/node_modules/@gtdyn/types/lib/index.d.ts create mode 100644 tests/fixtures/npm/workspace/wsdynamicdeps/node_modules/@gtdyn/types/lib/index.js create mode 100644 tests/fixtures/npm/workspace/wsdynamicdeps/node_modules/@gtdyn/types/package.json create mode 100644 tests/fixtures/npm/workspace/wsdynamicdeps/package.json 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/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/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" + } +} From 891a42fe2d3014b658bb137d872c16fd5010d159 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Tue, 20 Oct 2026 04:29:56 -0700 Subject: [PATCH 3/4] Split a chained Set call the self-hosted compiler cannot lower `deadDeclarators.add(d).add(memoDecl)` stopped the self-hosting build with "chained Set method calls ... has no scriptc lowering yet". Co-Authored-By: Claude Opus 5.5 --- packages/compiler/src/frontend/npm-static-bundled-esm.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/compiler/src/frontend/npm-static-bundled-esm.ts b/packages/compiler/src/frontend/npm-static-bundled-esm.ts index 53564d7871..d5635bc2e3 100644 --- a/packages/compiler/src/frontend/npm-static-bundled-esm.ts +++ b/packages/compiler/src/frontend/npm-static-bundled-esm.ts @@ -664,7 +664,8 @@ export function lowerBundledEsmInits( text: `(${init}${getterText(value)})`, }); } - deadDeclarators.add(d).add(memoDecl); + deadDeclarators.add(d); + deadDeclarators.add(memoDecl); loaderSpans.push({ start: d.getStart(sf), end: d.getEnd() }); lowered.add(target); } From 66c6bfc38491ef5828e4dc7738d7999b574dea39 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Tue, 20 Oct 2026 08:29:06 -0500 Subject: [PATCH 4/4] Keep export on ES module enums the enum rewrite respells TypeScript's ES module emit for an exported enum is `export var Kind;` followed by the IIFE. The rewrite replaced the declaration with a plain `var Kind = { ... };`, so importers of the module lost the export. The replacement now keeps the modifiers before `var`. --- .../src/frontend/npm-static-enum-iife.test.ts | 21 +++++++++++++++++++ .../src/frontend/npm-static-enum-iife.ts | 13 +++++++++--- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/packages/compiler/src/frontend/npm-static-enum-iife.test.ts b/packages/compiler/src/frontend/npm-static-enum-iife.test.ts index 2dd170a0b5..b27c53345c 100644 --- a/packages/compiler/src/frontend/npm-static-enum-iife.test.ts +++ b/packages/compiler/src/frontend/npm-static-enum-iife.test.ts @@ -73,6 +73,27 @@ var Level; ); }); +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; diff --git a/packages/compiler/src/frontend/npm-static-enum-iife.ts b/packages/compiler/src/frontend/npm-static-enum-iife.ts index 2ba12b325b..288a172e27 100644 --- a/packages/compiler/src/frontend/npm-static-enum-iife.ts +++ b/packages/compiler/src/frontend/npm-static-enum-iife.ts @@ -38,10 +38,17 @@ export function rewriteEnumIifes(sf: ts.SourceFile): string | null { 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: stmts[i - 1]!.getStart(sf), - end: stmts[i - 1]!.getEnd(), - text: `var ${decl} = ${objectLiteral(iife.members)};`, + start: declaration.getStart(sf), + end: declaration.getEnd(), + text: `${modifiers}var ${decl} = ${objectLiteral(iife.members)};`, }); edits.push({ start: stmts[i]!.getStart(sf),