diff --git a/extensions/ext-parser-babel/src/parser-only.test.ts b/extensions/ext-parser-babel/src/parser-only.test.ts index 449f2f2e65..06a2983ee0 100644 --- a/extensions/ext-parser-babel/src/parser-only.test.ts +++ b/extensions/ext-parser-babel/src/parser-only.test.ts @@ -28,6 +28,30 @@ describe("BabelParseOnlyParser", () => { assertEquals(decorated.type, "File"); }); + it("parses compiled JSX under a Markdown or MDX path", async () => { + // Markdown and MDX reach the parser as compiled JSX. Choosing the Babel + // plugins from the authored extension would leave JSX off and the markup + // would parse as a regular expression. + const compiled = "export default function MDXContent() { return

Title

; }"; + + const parsed = await Promise.all( + ["page.mdx", "page.md", "page.MDX"].map((filePath) => + parser.parse({ code: compiled, filePath }) + ), + ); + + assertEquals(parsed.map((ast) => ast.type), ["File", "File", "File"]); + }); + + it("keeps `x` a type assertion for a `.ts` path", async () => { + const asserted = await parser.parse({ + code: "const value = input;", + filePath: "module.ts", + }); + + assertEquals(asserted.type, "File"); + }); + it("preserves Babel syntax-error identity and location metadata", async () => { let thrown: unknown; try { diff --git a/extensions/ext-parser-babel/src/parser-only.ts b/extensions/ext-parser-babel/src/parser-only.ts index 9ffad86046..8f8ae205c2 100644 --- a/extensions/ext-parser-babel/src/parser-only.ts +++ b/extensions/ext-parser-babel/src/parser-only.ts @@ -18,6 +18,21 @@ export interface BabelParseOnlyParserContract { parse(options: ParseOptions): Promise; } +/** + * The path the plugin choice reasons about. + * + * Markdown and MDX can reach this parser as compiled JSX, and the authored + * `.md` or `.mdx` extension would switch JSX off, so the emitted markup parses + * as a regular expression and throws "Unterminated regular expression". Map + * them onto a `.tsx` path for the plugin choice only. Nothing else reads this + * value, and `.ts` keeps `x` a type assertion because only Markdown paths + * are rewritten. + */ +function parseablePath(filePath?: string): string | undefined { + if (filePath === undefined) return undefined; + return filePath.replace(/\.mdx?$/i, ".tsx"); +} + function pickPlugins(filePath?: string): parser.ParserPlugin[] { const normalizedPath = filePath?.toLowerCase() ?? ""; const supportsJsx = !filePath || @@ -53,7 +68,7 @@ export class BabelParseOnlyParser implements BabelParseOnlyParserContract { sourceType: "unambiguous", allowReturnOutsideFunction: options.allowReturnOutsideFunction === true || /\.(?:cjs|js)$/.test(filePath), - plugins: pickPlugins(options.filePath), + plugins: pickPlugins(parseablePath(options.filePath)), }); const node: { type: string } = ast; return Promise.resolve(node as ASTNode); diff --git a/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts b/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts index 9ebe487085..f8f6ee9e30 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts @@ -3,8 +3,11 @@ import "../../plugins/__tests__/code-parser-setup.ts"; import { stop as stopEsbuild } from "#veryfront/platform/compat/esbuild.ts"; import { assertEquals, assertRejects, assertStringIncludes } from "#veryfront/testing/assert.ts"; import { afterAll, describe, it } from "#veryfront/testing/bdd.ts"; +import { tryResolve } from "#veryfront/extensions/contracts.ts"; +import type { CodeParser } from "#veryfront/extensions/parser/index.ts"; import { browserServerExportsStripPlugin, + moduleReferenceWalkers, stripServerOnlyExports, } from "./browser-server-exports-strip.ts"; import { COMPILE_SOURCE_MAP_DIRECTIVE_METADATA, compilePlugin } from "./compile.ts"; @@ -1238,4 +1241,584 @@ describe("browser-server-exports-strip", () => { assertEquals(browserServerExportsStripPlugin.condition?.(ctx("", "browser")), true); }); }); + + describe("TypeScript reference classification", () => { + /** + * Both walkers' answers restricted to `names`, so a fixture asserts which + * module-level bindings each one attributes to runtime code without + * listing every local it also sees. + */ + async function referencesAmong( + code: string, + names: string[], + filePath = "page.tsx", + ): Promise<{ referenced: string[]; free: string[] }> { + const parser = tryResolve("CodeParser"); + if (!parser) throw new Error("no CodeParser extension is registered"); + const ast = await parser.parse({ code, filePath }); + const { referenced, free } = moduleReferenceWalkers(ast); + const pick = (found: Set) => names.filter((name) => found.has(name)); + return { referenced: pick(referenced), free: pick(free) }; + } + + /** + * The defect this classification fixes is the two walkers disagreeing, so + * every fixture asserts the same expectation against both. + */ + async function assertWalkers( + code: string, + names: string[], + expected: string[], + ): Promise { + const { referenced, free } = await referencesAmong(code, names); + assertEquals(referenced, expected, "referencedIdentifiers"); + assertEquals(free, expected, "freeReferencedIdentifiers"); + } + + describe("erased type positions do not reference a binding", () => { + it("ignores `typeof` in a parameter type annotation", async () => { + await assertWalkers( + [ + `import { KEY, used } from "./server.ts";`, + `export default function Page(p: { k: typeof KEY }) { return used(p); }`, + ].join("\n"), + ["KEY", "used"], + ["used"], + ); + }); + + it("ignores a type alias over `ReturnType`", async () => { + await assertWalkers( + [ + `import { loadUser, render } from "./server.ts";`, + `type User = ReturnType;`, + `export default function Page(u: User) { return render(u); }`, + ].join("\n"), + ["loadUser", "render"], + ["render"], + ); + }); + + it("ignores an interface member type", async () => { + await assertWalkers( + `import { Loader, run } from "./server.ts"; +interface Shape { l: Loader; m(a: Loader): Loader } +export default function Page() { return run(); }`, + ["Loader", "run"], + ["run"], + ); + }); + + it("ignores the type operand of `as` and `satisfies` but keeps the value", async () => { + await assertWalkers( + `import { Cast, raw, SAT } from "./server.ts"; +export const a = raw as Cast; +export const b = raw satisfies typeof SAT;`, + ["Cast", "raw", "SAT"], + ["raw"], + ); + }); + + it("ignores heritage clauses in a type position", async () => { + await assertWalkers( + `import { Iface, Base, Mixin } from "./server.ts"; +interface Derived extends Base { x: number } +export class Page extends Mixin implements Iface {}`, + ["Iface", "Base", "Mixin"], + ["Mixin"], + ); + }); + + it("ignores type parameters and type arguments", async () => { + await assertWalkers( + `import { Bound, TArg, call } from "./server.ts"; +export function f(x: T) { return call(x); }`, + ["Bound", "TArg", "call"], + ["call"], + ); + }); + + it("ignores type-only import and export specifiers", async () => { + await assertWalkers( + `import { hashOf, type Cfg } from "./server.ts"; +import type { Only } from "./types.ts"; +export type { Cfg }; +export { type Only }; +export const h = hashOf("x");`, + ["hashOf", "Cfg", "Only"], + ["hashOf"], + ); + }); + + it("ignores `declare` forms and declared function signatures", async () => { + await assertWalkers( + `import { Amb, live } from "./server.ts"; +declare const ambient: Amb; +declare function ambientFn(a: Amb): Amb; +declare class Ambient extends Amb {} +export const v = live();`, + ["Amb", "live"], + ["live"], + ); + }); + + it("keeps a decorator on a declared property, which still emits a runtime call", async () => { + // `@audit declare id: string` is ambient in the type system, but tsc and + // esbuild both emit a `__decorate` call for it, so the decorator is a + // real read. Erasing it deleted the import the call needs and produced a + // ReferenceError at browser module evaluation. + const code = [ + 'import { audit } from "./audit.ts";', + 'import { getEnv } from "veryfront";', + 'const KEY = getEnv("SECRET");', + "export async function getServerData() { return { props: { k: KEY } }; }", + "class Model {", + " @audit declare id: string;", + "}", + "export default function Page() { return null; }", + ].join("\n"); + + const result = await stripServerOnlyExports(code, "/project/app/page.tsx"); + + // The NAMED binding must survive: the emitted `__decorate` call reads + // `audit` by name. Asserting only on the specifier would pass even when + // the import is demoted to a bare side-effect import, which is the bug. + assertStringIncludes(result, "{ audit }"); + assertNotIncludes(result, 'getEnv("SECRET")'); + }); + + it("still erases an undecorated declared property", async () => { + const code = [ + 'import { audit } from "./audit.ts";', + 'import { getEnv } from "veryfront";', + 'const KEY = getEnv("SECRET");', + "export async function getServerData() { return { props: { k: KEY } }; }", + "class Model {", + " declare id: string;", + "}", + "export default function Page() { return null; }", + ].join("\n"); + + const result = await stripServerOnlyExports(code, "/project/app/page.tsx"); + + // The binding goes; the module specifier is demoted to a side-effect + // import by dropUnusedImportBindings, which is pre-existing behaviour + // and not what this case is about. + assertNotIncludes(result, "{ audit }"); + assertNotIncludes(result, 'getEnv("SECRET")'); + }); + + it("ignores an ambient namespace but not its runtime sibling", async () => { + await assertWalkers( + `import { AMBIENT_ONLY, RUNTIME_ONLY } from "./server.ts"; +declare namespace Ambient { const a: typeof AMBIENT_ONLY; } +namespace Runtime { export const b = RUNTIME_ONLY; } +export const used = Runtime.b;`, + ["AMBIENT_ONLY", "RUNTIME_ONLY"], + ["RUNTIME_ONLY"], + ); + }); + }); + + describe("value-emitting TypeScript nodes do reference a binding", () => { + it("keeps an enum member initialiser", async () => { + await assertWalkers( + `import { compute, SEED } from "./server.ts"; +export enum Level { Low = compute(SEED) }`, + ["compute", "SEED"], + ["compute", "SEED"], + ); + }); + + it("binds enum member names while walking their initialisers", async () => { + // `Both = Read` names a preceding MEMBER, not module scope. Without an + // enum-member scope the pass reported `Read` as free, pulled the + // unrelated `const Read = boot()` into the hook closure, and deleted it + // together with its side-effectful import. + const code = [ + 'import { boot } from "./boot.ts";', + "const Read = boot();", + "export async function getServerData() {", + " enum Access { Read = 1, Both = Read }", + " return { props: { a: Access.Both } };", + "}", + "export default function Page() { return null; }", + ].join("\n"); + + const result = await stripServerOnlyExports(code, "/project/app/page.tsx"); + + assertStringIncludes(result, "const Read = boot()"); + assertStringIncludes(result, "./boot.ts"); + }); + + it("does not hoist a block-scoped enum into the enclosing function scope", async () => { + // An enum nested in a block is block scoped: TypeScript emits `let` there. + // Hoisting it into the function scope made the outer `consume(Alias)` read + // look shadowed, so import liveness reduced the named import to a bare + // side-effect import and left `client` with an unresolved binding. + const code = [ + 'import { Alias, secret } from "./lib.ts";', + "export async function getServerData() { return { props: { s: secret } }; }", + "export function client() {", + " consume(Alias);", + " if (false) { enum Alias { X } }", + "}", + "declare function consume(v: unknown): void;", + "export default function Page() { client(); return null; }", + ].join("\n"); + + const result = await stripServerOnlyExports(code, "/project/app/page.tsx"); + + assertStringIncludes(result, "import { Alias"); + }); + + it("still reduces an import a same-scope enum genuinely shadows", async () => { + // Paired with the case above: asserting only that the import survives + // would also pass if the pass stopped reducing imports altogether. + const code = [ + 'import { Alias, secret } from "./lib.ts";', + "export async function getServerData() { return { props: { s: secret } }; }", + "export function client() {", + " enum Alias { X }", + " consume(Alias);", + "}", + "declare function consume(v: unknown): void;", + "export default function Page() { client(); return null; }", + ].join("\n"); + + const result = await stripServerOnlyExports(code, "/project/app/page.tsx"); + + assertNotIncludes(result, "import { Alias"); + }); + + it("still strips a module binding an enum initialiser genuinely reads", async () => { + // No member is named `Read` here, so `Read` really is a module-scope + // read owned only by the hook and must still be removed. + const code = [ + 'import { boot } from "./boot.ts";', + "const Read = boot();", + "export async function getServerData() {", + " enum Access { X = 1 }", + " return { props: { a: Access.X, r: Read } };", + "}", + "export default function Page() { return null; }", + ].join("\n"); + + const result = await stripServerOnlyExports(code, "/project/app/page.tsx"); + + assertNotIncludes(result, "const Read = boot()"); + }); + + it("keeps a runtime namespace body", async () => { + await assertWalkers( + `import { NSREF } from "./server.ts"; +export namespace Runtime { export const value = NSREF; }`, + ["NSREF"], + ["NSREF"], + ); + }); + + it("keeps a parameter property default", async () => { + await assertWalkers( + `import { Dep, DEFAULT_DEP } from "./server.ts"; +export class Service { constructor(private readonly dep: Dep = DEFAULT_DEP) {} }`, + ["Dep", "DEFAULT_DEP"], + ["DEFAULT_DEP"], + ); + }); + + it("keeps an import-equals alias target", async () => { + await assertWalkers( + `import { NS } from "./server.ts"; +import Alias = NS.Sub; +export const v = Alias;`, + ["NS"], + ["NS"], + ); + }); + + it("keeps an export assignment operand", async () => { + await assertWalkers( + `import { handler } from "./server.ts"; +export = handler;`, + ["handler"], + ["handler"], + ); + }); + + it("keeps an `accessor` field initialiser", async () => { + await assertWalkers( + `import { ACCESSOR_INIT } from "./server.ts"; +export class Page { accessor field = ACCESSOR_INIT; }`, + ["ACCESSOR_INIT"], + ["ACCESSOR_INIT"], + ); + }); + + it("keeps decorator arguments on a class and on its members", async () => { + await assertWalkers( + `import { decorate, CLASS_TOKEN, inject, MEMBER_TOKEN, METHOD_TOKEN } from "./server.ts"; +@decorate(CLASS_TOKEN) +export class Page { + @inject(MEMBER_TOKEN) field = 1; + @inject(METHOD_TOKEN) method() { return 1; } +}`, + ["decorate", "CLASS_TOKEN", "inject", "MEMBER_TOKEN", "METHOD_TOKEN"], + ["decorate", "CLASS_TOKEN", "inject", "MEMBER_TOKEN", "METHOD_TOKEN"], + ); + }); + + it("keeps the value side of `as`, `satisfies`, `!` and instantiation", async () => { + await assertWalkers( + `import { raw, maybe, generic, TArg } from "./server.ts"; +export const a = raw as unknown; +export const b = maybe!; +export const c = generic;`, + ["raw", "maybe", "generic", "TArg"], + ["raw", "maybe", "generic"], + ); + }); + + it("treats runtime TypeScript declaration names as bindings, not reads", async () => { + // The flat walker remains conservative for module-declaration + // liveness, but enum declaration and member IDs are fixed names, not + // reads. Import liveness uses the scope-aware walker. That walker must + // report none of the local names, because its answer also grows the + // hook dependency closure and can delete an unrelated declaration. + const { referenced, free } = await referencesAmong( + `import { Level, Low, Runtime, Alias } from "./server.ts"; +export function hook() { + enum Level { Low = 1 } + namespace Runtime { export const v = 1; } + return Level.Low; +}`, + ["Level", "Low", "Runtime", "Alias"], + ); + + assertEquals(free, []); + assertEquals(referenced, ["Level", "Runtime"]); + }); + }); + + describe("stripping authored TypeScript source", () => { + it("drops a hook-only import referenced only from a type position", async () => { + const code = [ + `import { hashOf } from "../lib/server-only.ts";`, + `export async function getServerData() {`, + ` return { props: { h: hashOf("x") } };`, + `}`, + `export default function Page(p: { k: typeof hashOf }) { return null; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code, "page.tsx"); + + assertNotIncludes(result, "server-only.ts"); + }); + + it("deletes a mixed value and type import instead of reducing it", async () => { + const code = [ + `import { hashOf, type Cfg } from "../lib/server-only.ts";`, + `export function getServerData(): { props: { c: Cfg } } {`, + ` return { props: { c: hashOf() } };`, + `}`, + `export default function Page() { return null; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code, "page.tsx"); + + assertNotIncludes(result, "server-only.ts"); + }); + + it("keeps an import an enum member initialiser still reads", async () => { + const code = [ + `import { SEED } from "../lib/shared.ts";`, + `export function getServerData() { return { props: { s: SEED } }; }`, + `export enum Level { Low = SEED }`, + `export default function Page() { return Level.Low; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code, "page.tsx"); + + assertStringIncludes(result, "shared.ts"); + assertStringIncludes(result, "SEED"); + }); + + it("keeps a module-scope binding a runtime namespace still reads", async () => { + const code = [ + `import { makeToken } from "../lib/shared.ts";`, + `const TOKEN = makeToken();`, + `export function getServerData() { return { props: { t: TOKEN } }; }`, + `export namespace Config { export const value = TOKEN; }`, + `export default function Page() { return Config.value; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code, "page.tsx"); + + assertStringIncludes(result, "const TOKEN = makeToken()"); + }); + + it("keeps an import a parameter property default still reads", async () => { + const code = [ + `import { DEFAULT_DEP } from "../lib/shared.ts";`, + `export function getServerData() { return { props: { d: DEFAULT_DEP } }; }`, + `export class Service { constructor(public dep = DEFAULT_DEP) {} }`, + `export default function Page() { return new Service().dep; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code, "page.tsx"); + + assertStringIncludes(result, "shared.ts"); + assertStringIncludes(result, "DEFAULT_DEP"); + }); + + it("does not keep an import binding whose name matches an enum member", async () => { + const code = [ + `import { secretOnly, Low } from "../lib/server-only.ts";`, + `export function getServerData() { return { props: { s: secretOnly } }; }`, + `export enum Level { Low = 1 }`, + `export default function Page() { return Level.Low; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code, "page.tsx"); + + assertNotIncludes(result, "{ secretOnly, Low }"); + // A mixed project import keeps its pre-existing side-effect contract. + // The TypeScript fix removes the false live binding; it does not prove + // that evaluating the imported module is safe to delete. + assertStringIncludes(result, `import "../lib/server-only.ts"`); + assertStringIncludes(result, "Level.Low"); + }); + + it("keeps an import read after a static block shadows its name", async () => { + const code = [ + `import { token } from "../lib/client.ts";`, + `function local() { return "cache"; }`, + `export class Cache { static { const token = local(); } }`, + `export function getServerData() { return { props: { token } }; }`, + `export default function Page() { return token; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code, "page.tsx"); + + assertStringIncludes(result, `{ token }`); + assertStringIncludes(result, `return token`); + }); + + it("keeps an import that matches a nested namespace segment", async () => { + const code = [ + `import { B } from "../lib/client.ts";`, + `export namespace A.B { export const value = 1; }`, + `export function getServerData() { return { props: { b: B } }; }`, + `export default function Page() { return B; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code, "page.tsx"); + + assertStringIncludes(result, `{ B }`); + assertStringIncludes(result, `return B`); + }); + + it("drops a hook-only binding shadowed by a surviving enum member", async () => { + const code = [ + `import { getEnv } from "veryfront";`, + `const Read = getEnv("SECRET_KEY");`, + `export enum Access { Read = 1, Both = Read }`, + `export function getServerData() { return { props: { r: Read } }; }`, + `export default function Page() { return Access.Both; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code, "page.tsx"); + + assertNotIncludes(result, "SECRET_KEY"); + assertNotIncludes(result, "const Read ="); + assertStringIncludes(result, "Both = Read"); + }); + + it("keeps a module binding shadowed by a hoisted namespace var", async () => { + const code = [ + `import { boot } from "../lib/analytics.ts";`, + `const token = boot();`, + `export function getServerData() {`, + ` namespace N { consume(token); if (false) { var token = 1; } }`, + ` return { props: {} };`, + `}`, + `export default function Page() { return null; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code, "page.tsx"); + + assertStringIncludes(result, "const token = boot()"); + assertStringIncludes(result, "analytics.ts"); + }); + + it("does not keep an import shadowed by a hoisted namespace alias", async () => { + const code = [ + `import { secretOnly, Alias } from "../lib/server-only.ts";`, + `export namespace M {`, + ` queue(() => Alias.x);`, + ` import Alias = ClientNS;`, + `}`, + `export function getServerData() { return { props: { s: secretOnly } }; }`, + `export default function Page() { return M; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code, "page.tsx"); + + assertNotIncludes(result, "{ secretOnly, Alias }"); + assertStringIncludes(result, `import "../lib/server-only.ts"`); + assertStringIncludes(result, "import Alias = ClientNS"); + }); + + it("drops a hook-only binding that matches a qualified-name property", async () => { + const code = [ + `import { getEnv } from "veryfront";`, + `const Sub = getEnv("SECRET_KEY");`, + `import Alias = ClientNS.Sub;`, + `export function getServerData() { return { props: { s: Sub } }; }`, + `export default function Page() { return Alias; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code, "page.tsx"); + + assertNotIncludes(result, "SECRET_KEY"); + assertNotIncludes(result, "const Sub ="); + assertStringIncludes(result, "ClientNS.Sub"); + }); + + it("does not keep an import shadowed by a namespace-local enum", async () => { + const code = [ + `import { secretOnly, Alias } from "../lib/server-only.ts";`, + `export namespace M {`, + ` queue(() => Alias);`, + ` enum Alias { X }`, + `}`, + `export function getServerData() { return { props: { s: secretOnly } }; }`, + `export default function Page() { return M; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code, "page.tsx"); + + assertNotIncludes(result, "{ secretOnly, Alias }"); + assertStringIncludes(result, `import "../lib/server-only.ts"`); + assertStringIncludes(result, "enum Alias"); + }); + + it("keeps a declaration whose name matches a hook-local enum member", async () => { + const code = [ + `import { boot } from "../lib/analytics.ts";`, + `const Low = boot();`, + `export function getServerData() {`, + ` enum Level { Low = 1 }`, + ` return { props: { l: Level.Low } };`, + `}`, + `export default function Page() { return null; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code, "page.tsx"); + + assertStringIncludes(result, "const Low = boot()"); + assertStringIncludes(result, "analytics.ts"); + }); + }); + }); }); diff --git a/src/transforms/pipeline/stages/browser-server-exports-strip.ts b/src/transforms/pipeline/stages/browser-server-exports-strip.ts index 63d7d5bcf1..d68030f085 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.ts @@ -119,6 +119,82 @@ function walk(node: Node, visit: (node: Node) => boolean | void): void { for (const child of children(node)) walk(child, visit); } +/** + * TypeScript nodes that survive type erasure and emit runtime code. + * + * Everything else the TypeScript grammar adds is erased before the module + * runs, so an identifier read inside it is a type reference and must not keep a + * binding alive. Getting the split wrong is unsafe in both directions: treating + * a runtime node as erased deletes live code, and treating an erased node as + * runtime pins a server-only import into the browser artifact. + * + * The list is closed and enumerable, which is the point: it is a decidable + * question, unlike proving what a module does to an intrinsic. A TypeScript + * node type this pass does not know is erased by default. Any new TypeScript + * node type that emits runtime code must be added to this allowlist. + * + * The split is invisible while this stage runs after the compile stage, which + * erases every TypeScript node before this pass sees the module. It exists so + * the stage stays correct when it runs on authored source. + */ +const RUNTIME_TS_NODE_TYPES = new Set([ + // Value expressions wrapping a value expression plus an erased type operand. + "TSAsExpression", + "TSSatisfiesExpression", + "TSNonNullExpression", + "TSTypeAssertion", + "TSInstantiationExpression", + // `enum E { A = compute() }` emits an object and evaluates each initialiser. + "TSEnumDeclaration", + "TSEnumBody", + "TSEnumMember", + // `namespace N { … }` with a body emits an IIFE over a runtime object. + "TSModuleDeclaration", + "TSModuleBlock", + // `constructor(private dep = fallback())` emits an assignment in the body. + "TSParameterProperty", + // `import L = require("./l.ts")` and `import A = N.Sub` both emit a binding. + "TSImportEqualsDeclaration", + "TSExternalModuleReference", + "TSQualifiedName", + // `export = handler` emits an assignment to the module export. + "TSExportAssignment", +]); + +/** + * Whether the compiler erases `node` and everything under it, so no identifier + * inside it is a runtime read. + * + * Both reference walkers ask this, and they must ask the same question. A + * walker that counts a type-position read as a runtime reference keeps the + * server-only import that binding came from; a walker that skips a runtime + * TypeScript node reports live code as dead. + */ +/** Whether a node carries decorators, which emit a runtime call even when the + * declaration they annotate is ambient. */ +function nodeHasDecorators(node: Node): boolean { + const decorators = node.decorators; + return Array.isArray(decorators) && decorators.length > 0; +} + +function isErasedTypeNode(node: Node): boolean { + // `declare const`, `declare class`, `declare namespace`, `declare enum` and + // `declare prop: T` are all ambient: they emit nothing. + // + // Decorators are the exception. Both tsc and esbuild emit a runtime + // `__decorate` call for `@audit declare id: string`, so the decorator + // expression is a real read even though the property it annotates is not. + // Erasing it here deletes the import the decorator needs and the emitted + // call then throws a ReferenceError at module evaluation. + if (node.declare === true) return !nodeHasDecorators(node); + // `import { type Cfg }`, `export { type Cfg }`, `export type { Cfg }`. + if (node.importKind === "type" || node.exportKind === "type") return true; + if (!node.type.startsWith("TS")) return false; + if (!RUNTIME_TS_NODE_TYPES.has(node.type)) return true; + // An ambient `declare module "x";` has no body to run. + return node.type === "TSModuleDeclaration" && !isNode(node.body); +} + function nodeName(value: unknown): string | null { if (!isNode(value)) return null; const name = value.name; @@ -168,6 +244,13 @@ function patternBoundNames(pattern: Node): string[] { return; } + // `constructor(private dep: Dep)` binds `dep` as a parameter and assigns + // it to `this` at runtime. + if (node.type === "TSParameterProperty") { + if (isNode(node.parameter)) collect(node.parameter); + return; + } + if (node.type === "ArrayPattern") { for (const element of Array.isArray(node.elements) ? node.elements : []) { if (isNode(element)) collect(element); @@ -313,7 +396,8 @@ function emptyServerOnlyHooks( /** * Identifiers the module reads, ignoring import statements and the positions * where an identifier is a fixed name rather than a reference (`a.hashOf`, - * `{ hashOf: 1 }`). Over-counting only ever keeps an import. + * `{ hashOf: 1 }`). This flat walk is used for module-declaration liveness; + * import liveness uses the scope-aware walker below. * * `excluded` holds identifier nodes that are binding *positions* rather than * references (the `id` a declaration introduces), so a declaration is not @@ -329,20 +413,54 @@ function referencedIdentifiers(body: Node[], excluded?: WeakSet): Set { + if (node.type !== "TSEnumDeclaration") return; + const container = isNode(node.body) ? node.body : node; + const members = Array.isArray(container.members) ? container.members : []; + const localNames = new Set(); + const enumName = nodeName(node.id); + if (enumName) localNames.add(enumName); + for (const member of members) { + if (!isNode(member)) continue; + const memberId = isNode(member.id) ? member.id : undefined; + const memberName = nodeName(memberId) ?? stringLiteralText(memberId); + if (memberName) localNames.add(memberName); + } + for (const member of members) { + if (!isNode(member) || !isNode(member.initializer)) continue; + walk(member.initializer, (candidate) => { + if ( + candidate.type === "Identifier" && + localNames.has(nodeName(candidate) ?? "") + ) fixedNames.add(candidate); + }); + } + }; + for (const statement of body) { if (statement.type === "ImportDeclaration") continue; walk(statement, (node) => { if (node.type === "ImportDeclaration") return false; + // A type position is not a runtime read. Without this the walker counts + // `p: typeof KEY` as a use of `KEY` and keeps the server-only import it + // came from. + if (isErasedTypeNode(node)) return false; + markEnumLocalReferences(node); markFixedName(node); if (node.type === "Identifier" || node.type === "JSXIdentifier") { @@ -438,6 +556,15 @@ function freeReferencedIdentifiers(root: Node): Set { for (const name of patternBoundNames(value)) scope.names.add(name); }; + const bindHoistedRuntimeTsDeclaration = (scope: LexicalScope, node: Node): boolean => { + if ( + node.type !== "TSEnumDeclaration" && node.type !== "TSModuleDeclaration" && + node.type !== "TSImportEqualsDeclaration" + ) return false; + if (!isErasedTypeNode(node)) bindPatternNames(scope, node.id); + return true; + }; + const bindDirectDeclarations = (scope: LexicalScope, node: Node): void => { const body = node.body; if (!Array.isArray(body)) return; @@ -448,6 +575,7 @@ function freeReferencedIdentifiers(root: Node): Set { bindPatternNames(scope, statement.id); continue; } + if (bindHoistedRuntimeTsDeclaration(scope, statement)) continue; if (statement.type !== "VariableDeclaration") continue; for ( const declarator of Array.isArray(statement.declarations) ? statement.declarations : [] @@ -459,10 +587,20 @@ function freeReferencedIdentifiers(root: Node): Set { const bindNestedVarDeclarations = (scope: LexicalScope, node: Node): void => { for (const child of children(node)) { + // Only `var` hoists. An enum, a namespace or an import-equals nested in a + // block is block scoped (TypeScript emits `let` there), so binding it + // into the enclosing function scope makes an unrelated outer read look + // shadowed. `bindDirectDeclarations` already binds these at whichever + // scope actually contains them, so they need no hoisting pass. + if ( + child.type === "TSEnumDeclaration" || child.type === "TSImportEqualsDeclaration" + ) continue; if ( child.type === "FunctionDeclaration" || child.type === "FunctionExpression" || child.type === "ArrowFunctionExpression" || child.type === "ObjectMethod" || - child.type === "ClassMethod" + child.type === "ClassMethod" || child.type === "ClassDeclaration" || + child.type === "ClassExpression" || child.type === "StaticBlock" || + child.type === "TSModuleDeclaration" ) { continue; } @@ -474,7 +612,6 @@ function freeReferencedIdentifiers(root: Node): Set { if (isNode(declarator)) bindPatternNames(scope, declarator.id); } } - bindNestedVarDeclarations(scope, child); } }; @@ -483,8 +620,23 @@ function freeReferencedIdentifiers(root: Node): Set { for (const child of children(node)) visit(child, scopes); }; + const visitDecorators = (node: Node, scopes: LexicalScope[]): void => { + for (const decorator of Array.isArray(node.decorators) ? node.decorators : []) { + if (isNode(decorator)) visit(decorator, scopes); + } + }; + const visitPatternRuntime = (pattern: Node, scopes: LexicalScope[]): void => { - if (pattern.type === "Identifier") return; + if (pattern.type === "Identifier") { + visitDecorators(pattern, scopes); + return; + } + + if (pattern.type === "TSParameterProperty") { + visitDecorators(pattern, scopes); + if (isNode(pattern.parameter)) visitPatternRuntime(pattern.parameter, scopes); + return; + } if (pattern.type === "AssignmentPattern") { if (isNode(pattern.left)) visitPatternRuntime(pattern.left, scopes); @@ -572,6 +724,7 @@ function freeReferencedIdentifiers(root: Node): Set { }; const visitObjectMember = (node: Node, scopes: LexicalScope[]): void => { + visitDecorators(node, scopes); if (node.computed === true && isNode(node.key)) visit(node.key, scopes); if (isNode(node.value)) visit(node.value, scopes); }; @@ -606,23 +759,12 @@ function freeReferencedIdentifiers(root: Node): Set { } }; - const visitTsExpression = (node: Node, scopes: LexicalScope[]): boolean => { - if ( - node.type === "TSAsExpression" || node.type === "TSTypeAssertion" || - node.type === "TSNonNullExpression" || node.type === "TSInstantiationExpression" || - node.type === "TSSatisfiesExpression" - ) { - if (isNode(node.expression)) visit(node.expression, scopes); - return true; - } - - if (node.type.startsWith("TS")) return true; - return false; - }; - const visit = (node: Node, scopes: LexicalScope[]): void => { if (node.type === "ImportDeclaration") return; - if (visitTsExpression(node, scopes)) return; + // Same classification `referencedIdentifiers` uses. A value-emitting + // TypeScript node such as an enum or a namespace body falls through to the + // generic walk below, and its erased type operand is skipped there in turn. + if (isErasedTypeNode(node)) return; if (node.type === "Identifier" || node.type === "JSXIdentifier") { const name = nodeName(node); @@ -630,7 +772,10 @@ function freeReferencedIdentifiers(root: Node): Set { return; } - if (node.type === "Program" || node.type === "BlockStatement") { + if ( + node.type === "Program" || node.type === "BlockStatement" || + node.type === "TSModuleBlock" + ) { const scope: LexicalScope = { kind: "block", names: new Set() }; bindDirectDeclarations(scope, node); for (const statement of Array.isArray(node.body) ? node.body : []) { @@ -639,6 +784,19 @@ function freeReferencedIdentifiers(root: Node): Set { return; } + if (node.type === "StaticBlock") { + // A static block is its own var and lexical scope. Without this, a local + // declaration can bind the surrounding program scope and hide a later + // read of an imported binding with the same name. + const staticScope: LexicalScope = { kind: "function", names: new Set() }; + bindDirectDeclarations(staticScope, node); + bindNestedVarDeclarations(staticScope, node); + for (const statement of Array.isArray(node.body) ? node.body : []) { + if (isNode(statement)) visit(statement, [staticScope, ...scopes]); + } + return; + } + if (node.type === "VariableDeclaration") { visitVariableDeclaration(node, scopes); return; @@ -652,8 +810,65 @@ function freeReferencedIdentifiers(root: Node): Set { return; } + // A runtime TypeScript declaration binds its own name and, for an enum, + // names its members. Only the initialisers read anything, so descending + // blindly would report `enum Level { Low }` as a read of an unrelated + // module-scope `Low` and let the pass delete it. + if (node.type === "TSEnumDeclaration") { + bindPatternNames(scopes[0] ?? rootScope, node.id); + const container = isNode(node.body) ? node.body : node; + const members = Array.isArray(container.members) ? container.members : []; + // Member initialisers can name a preceding member without qualifying it, + // as in `enum Access { Read = 1, Both = Read }`. Those names resolve to + // the enum, not to module scope, so bind them in their own scope first: + // otherwise `Read` reads as free and the pass pulls an unrelated + // module-scope `Read` into the hook closure and deletes it. + const scope: LexicalScope = { kind: "block", names: new Set() }; + for (const member of members) { + if (!isNode(member)) continue; + const memberId = isNode(member.id) ? member.id : undefined; + const memberName = nodeName(memberId) ?? stringLiteralText(memberId); + if (memberName) scope.names.add(memberName); + } + for (const member of members) { + if (isNode(member) && isNode(member.initializer)) { + visit(member.initializer, [scope, ...scopes]); + } + } + return; + } + + if (node.type === "TSModuleDeclaration") { + bindPatternNames(scopes[0] ?? rootScope, node.id); + // Every emitted namespace IIFE introduces its own binding scope. For a + // dotted declaration such as `namespace A.B`, B belongs to A's scope, + // not to the surrounding module. + const namespaceScope: LexicalScope = { kind: "function", names: new Set() }; + bindPatternNames(namespaceScope, node.id); + if (isNode(node.body)) { + if (node.body.type === "TSModuleBlock") { + bindNestedVarDeclarations(namespaceScope, node.body); + } + visit(node.body, [namespaceScope, ...scopes]); + } + return; + } + + if (node.type === "TSImportEqualsDeclaration") { + bindPatternNames(scopes[0] ?? rootScope, node.id); + if (isNode(node.moduleReference)) visit(node.moduleReference, scopes); + return; + } + + // `import Alias = NS.Sub`: only `NS` is a read, `Sub` is a fixed name. + if (node.type === "TSQualifiedName") { + if (isNode(node.left)) visit(node.left, scopes); + return; + } + if (node.type === "ClassDeclaration" || node.type === "ClassExpression") { if (node.type === "ClassDeclaration") bindPatternNames(scopes[0] ?? rootScope, node.id); + visitDecorators(node, scopes); const body = node.body; if (isNode(body)) visitChildren(body, scopes); if (isNode(node.superClass)) visit(node.superClass, scopes); @@ -689,12 +904,16 @@ function freeReferencedIdentifiers(root: Node): Set { return; } - if (node.type === "ObjectProperty" || node.type === "ClassProperty") { + if ( + node.type === "ObjectProperty" || node.type === "ClassProperty" || + node.type === "ClassAccessorProperty" + ) { visitObjectMember(node, scopes); return; } if (node.type === "ObjectMethod" || node.type === "ClassMethod") { + visitDecorators(node, scopes); if (node.computed === true && isNode(node.key)) visit(node.key, scopes); visitFunction(node, scopes); return; @@ -748,6 +967,33 @@ function hookReferencedIdentifiers(body: Node[], targets: Set): Set; + free: Set; +} { + const program = (ast as { program?: unknown }).program; + const root: Node = isNode(program) ? program : ast; + return { + referenced: referencedIdentifiers(bodyOf(ast)), + free: freeReferencedIdentifiers(root), + }; +} + function literalText(node: Node | undefined): string | null { if (!node) return null; return typeof node.value === "string" ? node.value : nodeName(node); @@ -980,6 +1226,11 @@ function importedBindings(statement: Node): string[] { for (const specifier of Array.isArray(statement.specifiers) ? statement.specifiers : []) { if (!isNode(specifier)) continue; + // `import { hashOf, type Cfg }`: `Cfg` is erased before the module runs, so + // it is not a binding that has to be kept alive. Counting it would stop + // `hashOf` alone from proving the import hook-only, and the statement would + // be reduced to a side-effect import instead of deleted. + if (specifier.importKind === "type") continue; const name = nodeName(specifier.local); if (name) bindings.push(name); } @@ -996,7 +1247,10 @@ function importedBindings(statement: Node): string[] { * the legacy conservative side-effect rewrite. */ function dropUnusedImportBindings(body: Node[], hookClosure: Set): Node[] { - const referenced = referencedIdentifiers(body); + // Import liveness must be scope-aware. A local enum member, namespace, + // parameter property, or ordinary nested binding can share a spelling with + // an import without reading that imported binding. + const referenced = freeReferencedIdentifiers({ type: "Program", body }); return body.filter((statement) => { if (statement.type !== "ImportDeclaration") return true;