Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 69 additions & 16 deletions packages/compiler/src/frontend/lowering/lower-modules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ts.SourceFile, readonly CycleEdge[]> | undefined;
inlineCycleCalls?: Set<ts.Node>;
}

/** 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<ts.SourceFile, CycleEdge[]>();
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<ts.SourceFile, "visiting" | "done">,
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
Expand All @@ -166,17 +209,19 @@ 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(
program: ts.Program,
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(
Expand All @@ -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(
Expand All @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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<ts.SourceFile, "visiting" | "done">();
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);
Expand All @@ -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<ts.SourceFile>();
for (;;) {
Expand Down
21 changes: 14 additions & 7 deletions packages/compiler/src/frontend/lowering/lowerer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,7 @@ import {
requireSpecOf,
resolveImport,
workspacePackageOfPath,
type CycleEdge,
} from "../program.js";
import {
containsRecord,
Expand Down Expand Up @@ -784,6 +785,8 @@ export interface LowerOptions {
startupCrash?: StartupCrash | null;
/** LoadResult.inlineRequireCycleCalls. */
inlineRequireCycleCalls?: ReadonlySet<ts.Node> | undefined;
/** LoadResult.moduleEdges. */
moduleEdges?: ReadonlyMap<ts.SourceFile, readonly CycleEdge[]> | 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
Expand Down Expand Up @@ -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<ts.Node>(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");
}
Expand All @@ -991,7 +998,7 @@ export function lowerToIr(
projectCheckJs: options.projectCheckJs ?? false,
targetPlatform,
startupCrash,
inlineRequireCycleCalls: options.inlineRequireCycleCalls,
inlineRequireCycleCalls,
ffiImports,
libraryCallbacks,
forkTargets,
Expand All @@ -1015,7 +1022,7 @@ export function lowerToIr(
projectCheckJs: options.projectCheckJs ?? false,
targetPlatform,
startupCrash,
inlineRequireCycleCalls: options.inlineRequireCycleCalls,
inlineRequireCycleCalls,
ffiImports,
libraryCallbacks,
forkTargets,
Expand Down Expand Up @@ -1046,7 +1053,7 @@ export function lowerToIr(
reachable,
targetPlatform,
startupCrash,
inlineRequireCycleCalls: options.inlineRequireCycleCalls,
inlineRequireCycleCalls,
ffiImports,
libraryCallbacks,
forkTargets,
Expand Down Expand Up @@ -1105,7 +1112,7 @@ export function lowerToIr(
reachable,
remainder: true,
alreadyFlushed: resultLowerer.flushedSymbols,
inlineRequireCycleCalls: options.inlineRequireCycleCalls,
inlineRequireCycleCalls,
targetPlatform,
ffiImports,
libraryCallbacks,
Expand Down
135 changes: 135 additions & 0 deletions packages/compiler/src/frontend/npm-static-enum-iife.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
Loading
Loading