diff --git a/docs/api-reference/veryfront/errors.md b/docs/api-reference/veryfront/errors.md index 86a7d64b05..f54590fa09 100644 --- a/docs/api-reference/veryfront/errors.md +++ b/docs/api-reference/veryfront/errors.md @@ -145,6 +145,7 @@ throw INVALID_WIDGET.create({ detail: "The widget id is malformed." }); | `SECURITY_VIOLATION` | Path traversal / secure-fs violations (replaces SecurityError) | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/general.ts#L77) | | `SEMAPHORE_TIMEOUT` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/server.ts#L60) | | `SERVER_ERROR_CATALOG` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/catalog/server-errors.ts#L4) | +| `SERVER_EXPORT_STRIP_FAILED` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/build.ts#L75) | | `SERVER_ONLY_IN_CLIENT` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/boundary.ts#L11) | | `SERVER_START_ERROR` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/server.ts#L12) | | `SERVICE_OVERLOADED` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/errors/error-registry/server.ts#L44) | diff --git a/docs/guides/data-fetching.md b/docs/guides/data-fetching.md index 078c084462..024ee2413a 100644 --- a/docs/guides/data-fetching.md +++ b/docs/guides/data-fetching.md @@ -45,6 +45,93 @@ entirely, including their top-level side effects. Put client initialization in a separate client-referenced module or a bare side-effect import that is not only used by a server data hook. +### Declare server data hooks directly + +Veryfront must find a local declaration for each server data export so it can +empty it before the module reaches the browser. Declare the hook in the route +module as a function declaration or as an initializer on a `const`, `let`, or +`var`: + +```tsx +// Supported +export async function getServerData(ctx: DataContext) { + return { props: { query: ctx.query.toString() } }; +} + +// Also supported +export const getStaticData = async () => ({ props: { generated: true } }); +``` + +These forms have no declaration to empty and fail the build with +`server-export-strip-failed`: + +```tsx +// Not supported: the hook is a re-exported import +import { loadIt } from "./loader.ts"; +export { loadIt as getServerData }; + +// Not supported: the hook is a class +export class getServerData {} +``` + +Move the import inside a directly declared hook to migrate: + +```tsx +export async function getServerData(ctx: DataContext) { + const { loadIt } = await import("./loader.ts"); + return loadIt(ctx); +} +``` + +The same build error reports a value that only a stripped hook reads when that +value is declared in a position Veryfront cannot remove, such as a loop head: + +```tsx +// Not supported: the binding is declared by the loop, not at module scope +for (var KEY of getEnv("SECRET_KEY")) {} + +// Supported +const KEY = getEnv("SECRET_KEY"); +``` + +### Modules that rewrite the Object intrinsic + +Compiled input carries name registrations such as `__name(loadUser, "loadUser")`. +Veryfront reads them as build metadata, which is what lets it see that +`loadUser` is read only by a stripped hook and remove it along with the server +import and the secret behind it. + +A module that rewrites `Object.defineProperty`, or reaches it through +`.constructor`, `__proto__`, `eval`, or `Function`, makes that reading +unprovable. Veryfront must not delete a call the module can observe, and it must +not emit a module that still holds a server-only binding, so the build fails +with `server-export-strip-failed`. + +```tsx +// Not supported: the module rebinds Object, so the name registration +// cannot be proven to be compiler metadata +const Object = globalThis.Object; + +export async function getServerData() { + return { props: { user: await loadUser() } }; +} +``` + +Move the code that reaches or rewrites the intrinsic into a module that exports +no server data hook, then import what you need from it: + +```tsx +import { isPlainObject } from "../lib/is-plain-object.ts"; + +export async function getServerData() { + return { props: { user: await loadUser() } }; +} +``` + +Ordinary client code that reads `.constructor` or `__proto__` on a value, such +as an `isPlainObject` helper or `error.constructor.name` logging, does not +trigger this failure. + The `props` you return are passed to the page component. To read the same props data from a layout or nested component without prop-drilling, use `usePageContext().data` (see diff --git a/docs/guides/errors.md b/docs/guides/errors.md index 07c6fbfe1c..5cab6fe2e0 100644 --- a/docs/guides/errors.md +++ b/docs/guides/errors.md @@ -170,6 +170,13 @@ Compilation failed. - **HTTP status:** 500 - **What to do:** Review compiler output for specific errors +### server-export-strip-failed + +Server-only export cannot be removed from the client build. + +- **HTTP status:** 500 +- **What to do:** Declare the hook directly in the route module and keep its values module scope + ## Runtime Raised while executing project code. diff --git a/src/errors/catalog/build-errors.test.ts b/src/errors/catalog/build-errors.test.ts index 9e7f5e2fbb..caf8ef2efd 100644 --- a/src/errors/catalog/build-errors.test.ts +++ b/src/errors/catalog/build-errors.test.ts @@ -16,6 +16,7 @@ describe("errors/catalog/build-errors", () => { "ssg-generation-error", "sourcemap-error", "compilation-error", + "server-export-strip-failed", ]; for (const slug of expectedSlugs) { @@ -38,8 +39,8 @@ describe("errors/catalog/build-errors", () => { } }); - it("should have 9 entries", () => { - assertEquals(Object.keys(BUILD_ERROR_CATALOG).length, 9); + it("should have 10 entries", () => { + assertEquals(Object.keys(BUILD_ERROR_CATALOG).length, 10); }); it("build-failed should have tips", () => { @@ -52,5 +53,28 @@ describe("errors/catalog/build-errors", () => { const solution = BUILD_ERROR_CATALOG["mdx-compile-error"]!; assertEquals(typeof solution.example, "string"); }); + + it("documents server export stripping remediation", () => { + const solution = BUILD_ERROR_CATALOG["server-export-strip-failed"]!; + assertEquals( + solution.title, + "Server-only export cannot be removed from the client build", + ); + assertEquals(solution.message.includes("getServerData"), true); + assertEquals( + solution.steps?.includes( + "Declare the hook directly as a function declaration or a const, let, or var declaration", + ), + true, + ); + assertEquals( + solution.steps?.includes( + "Keep a browser-needed value in a client-referenced module before importing it into the hook", + ), + true, + ); + assertEquals(solution.example?.includes("export { loadIt as getServerData };"), true); + assertEquals(solution.example?.includes("export async function getServerData(ctx)"), true); + }); }); }); diff --git a/src/errors/catalog/build-errors.ts b/src/errors/catalog/build-errors.ts index b841839063..1ddb14922b 100644 --- a/src/errors/catalog/build-errors.ts +++ b/src/errors/catalog/build-errors.ts @@ -110,4 +110,36 @@ title: My Post "Verify TypeScript configuration", ], ), + + "server-export-strip-failed": createErrorSolution("server-export-strip-failed", { + title: "Server-only export cannot be removed from the client build", + message: + "A route module exports getServerData, getStaticData, or getStaticPaths in a form the " + + "client build cannot empty. Emitting the module would send the loader, its imports, and " + + "the values it reads to the browser, so the build stops instead.", + steps: [ + "Declare the hook directly as a function declaration or a const, let, or var declaration", + "Replace a re-export such as `export { loadIt as getServerData }` with a direct declaration", + "Replace a class or an alias export of the hook with an exported async function", + "Declare any value the hook reads once, at module scope, not inside a loop head", + "Keep a browser-needed value in a client-referenced module before importing it into the hook", + "Move code that rewrites or reaches the `Object` intrinsic into a module with no server data hook", + ], + tips: [ + "The error message names the export and the declaration form that blocked the removal", + "A hook declared directly is stripped from the client bundle with everything only it read", + "A module that rewrites `Object.defineProperty`, or reaches it through `.constructor`, " + + "`__proto__`, `eval` or `Function`, stops the build from proving which name registrations " + + "the compiler emitted, so a server-only binding one of them names cannot be removed", + ], + example: `// Not supported: no local declaration to empty +import { loadIt } from "./loader.ts"; +export { loadIt as getServerData }; + +// Supported +export async function getServerData(ctx) { + const { loadIt } = await import("./loader.ts"); + return loadIt(ctx); +}`, + }), }); diff --git a/src/errors/error-registry.test.ts b/src/errors/error-registry.test.ts index 44b6540274..a28da95f45 100644 --- a/src/errors/error-registry.test.ts +++ b/src/errors/error-registry.test.ts @@ -29,9 +29,9 @@ describe("error-registry", () => { assertEquals(slugs.length, uniqueSlugs.size, "Duplicate slugs detected"); }); - it("should have 113 registered errors", () => { + it("should have 114 registered errors", () => { const slugs = getAllSlugs(); - assertEquals(slugs.length, 113); + assertEquals(slugs.length, 114); }); }); @@ -180,7 +180,7 @@ describe("error-registry", () => { it("should return BUILD errors", () => { const errors = getErrorsByCategory("BUILD"); - assertEquals(errors.length, 9); + assertEquals(errors.length, 10); for (const error of errors) { assertEquals(error.category, "BUILD"); } @@ -322,7 +322,7 @@ describe("error-registry", () => { describe("error categories coverage", () => { const expectedCategoryCounts: Record = { CONFIG: 12, - BUILD: 9, + BUILD: 10, RUNTIME: 11, ROUTE: 6, MODULE: 8, diff --git a/src/errors/error-registry/build.ts b/src/errors/error-registry/build.ts index c41a68e747..2ec75c896f 100644 --- a/src/errors/error-registry/build.ts +++ b/src/errors/error-registry/build.ts @@ -72,6 +72,14 @@ export const COMPILATION_ERROR = defineError({ suggestion: "Review compiler output for specific errors", }); +export const SERVER_EXPORT_STRIP_FAILED = defineError({ + slug: "server-export-strip-failed", + category: "BUILD", + status: 500, + title: "Server-only export cannot be removed from the client build", + suggestion: "Declare the hook directly in the route module and keep its values module scope", +}); + /** Registry fragment for BUILD errors (slug → definition). */ export const BUILD_REGISTRY = { "build-failed": BUILD_FAILED, @@ -83,4 +91,5 @@ export const BUILD_REGISTRY = { "ssg-generation-error": SSG_GENERATION_ERROR, "sourcemap-error": SOURCEMAP_ERROR, "compilation-error": COMPILATION_ERROR, + "server-export-strip-failed": SERVER_EXPORT_STRIP_FAILED, } as const; diff --git a/src/errors/index.ts b/src/errors/index.ts index 4bdafcbefd..d58cf07db5 100644 --- a/src/errors/index.ts +++ b/src/errors/index.ts @@ -137,6 +137,7 @@ export { SCHEDULE_CONFIG_INVALID, SECURITY_VIOLATION, SEMAPHORE_TIMEOUT, + SERVER_EXPORT_STRIP_FAILED, SERVER_ONLY_IN_CLIENT, SERVER_START_ERROR, SERVICE_OVERLOADED, diff --git a/src/server/handlers/dev/dashboard/api.test.ts b/src/server/handlers/dev/dashboard/api.test.ts index 4d4f4c0cb6..6079c6053d 100644 --- a/src/server/handlers/dev/dashboard/api.test.ts +++ b/src/server/handlers/dev/dashboard/api.test.ts @@ -214,10 +214,10 @@ describe("Dashboard API - GET endpoints", () => { assertEquals("errors" in body, true); assertEquals("categories" in body, true); assertEquals("count" in body, true); - assertEquals(body.count, 66); + assertEquals(body.count, 67); assertEquals(body.categories, { config: 7, - build: 9, + build: 10, runtime: 7, route: 6, server: 8, 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 f8f6ee9e30..381a442298 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts @@ -1,7 +1,13 @@ import "#veryfront/schemas/_test-setup.ts"; import "../../plugins/__tests__/code-parser-setup.ts"; +import { VeryfrontError } from "#veryfront/errors"; import { stop as stopEsbuild } from "#veryfront/platform/compat/esbuild.ts"; -import { assertEquals, assertRejects, assertStringIncludes } from "#veryfront/testing/assert.ts"; +import { + assert, + 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"; @@ -18,6 +24,23 @@ function assertNotIncludes(haystack: string, needle: string): void { assertEquals(haystack.includes(needle), false, `expected not to find ${needle} in:\n${haystack}`); } +/** + * A module that defeats the intrinsic proof, so the `setName(loadSecret, …)` + * registration cannot be classified as compiler metadata. Deleting it could + * delete a call the module observes, and emitting the module ships the secret + * and the server import behind it to the browser. Stopping the build is the + * only outcome that is neither, so that is what the pass must do. + */ +async function assertUnprovableRegistrationRejected(code: string): Promise { + const error = await assertRejects(() => stripServerOnlyExports(code, "pages/x.tsx")); + + assertEquals((error as VeryfrontError).slug, "server-export-strip-failed"); + assertStringIncludes( + (error as Error).message, + "compiler name registration this pass cannot verify", + ); +} + /** Identifier occurrences, so "kept the import" and "kept the binding" differ. */ function occurrences(haystack: string, name: string): number { return haystack.match(new RegExp(`\\b${name}\\b`, "g"))?.length ?? 0; @@ -226,6 +249,8 @@ describe("browser-server-exports-strip", () => { const error = await assertRejects(() => stripServerOnlyExports(code, "pages/x.tsx")); + assertEquals(error instanceof VeryfrontError, true); + assertEquals((error as VeryfrontError).slug, "server-export-strip-failed"); assertStringIncludes((error as Error).message, "pages/x.tsx"); }); @@ -246,6 +271,200 @@ describe("browser-server-exports-strip", () => { await assertRejects(() => stripServerOnlyExports(code, "pages/x.tsx")); }); + // An imported binding re-exported under a hook name has no local + // declaration to stub. Emitting the module unchanged would keep the import + // (and the loader module behind it) in the browser graph, so the build + // stops instead. (This form used to pass through silently.) + it("fails the build when a hook is an imported binding re-exported locally", async () => { + const code = [ + `import { loadIt } from "./loader.ts";`, + `export { loadIt as getServerData };`, + ].join("\n"); + + const error = await assertRejects(() => stripServerOnlyExports(code, "pages/x.tsx")); + + assertStringIncludes((error as Error).message, "pages/x.tsx"); + }); + + // ES2022 lets an export clause publish an arbitrary string as the exported + // name, and the runtime looks `mod.getServerData` up under it just the + // same. The name matcher only ever read the identifier form, so the module + // was reported as exporting no hook and passed through byte for byte, + // loader body, imports and closed-over secrets included. + it("fails the build when a hook is exported under a string-literal name", async () => { + const code = [ + `import { getEnv } from "veryfront";`, + `const API_KEY = getEnv("SECRET_KEY");`, + `async function loadIt() { return { props: { k: API_KEY } }; }`, + `export { loadIt as "getServerData" };`, + `export default function Page() { return null; }`, + ].join("\n"); + + const error = await assertRejects(() => stripServerOnlyExports(code, "pages/x.tsx")); + + assertStringIncludes((error as Error).message, "getServerData"); + }); + + // `export * as getServerData from "./loader"` names a hook without binding + // anything locally, so there is nothing to stub and the loader module stays + // in the browser graph. + it("fails the build when a hook is a namespace re-export", async () => { + const code = `export * as getServerData from "./loader.ts";`; + + const error = await assertRejects(() => stripServerOnlyExports(code, "pages/x.tsx")); + + assertStringIncludes((error as Error).message, "getServerData"); + }); + + // A class declaration exported under a hook name is a form the stubber + // does not handle. Fail closed rather than shipping the class body and + // everything it closes over. + it("fails the build when a hook is exported as a class declaration", async () => { + const code = `export class getServerData { load() { return readSecret(); } }`; + + const error = await assertRejects(() => stripServerOnlyExports(code, "pages/x.tsx")); + + assertStringIncludes((error as Error).message, "getServerData"); + }); + + // A module-scope reassignment defeats stubbing: the pass empties the + // declarator, but the assignment puts the real loader back at + // module-evaluation time, so the loader body and its imports would ship to + // the browser and overwrite the stub. This form used to be reported as + // successfully emptied while the real loader shipped silently; it now + // fails closed. + it("fails the build when an exported hook binding is reassigned at module scope", async () => { + const code = [ + `import { getEnv } from "veryfront";`, + `export let getServerData = async () => null;`, + `getServerData = async () => ({ props: { secret: getEnv("SECRET_A") } });`, + ].join("\n"); + + const error = await assertRejects(() => stripServerOnlyExports(code, "pages/x.tsx")); + + assertStringIncludes((error as Error).message, "getServerData"); + assertStringIncludes((error as Error).message, "reassigned"); + }); + + it("fails the build when a hook is reassigned to an imported server loader", async () => { + const code = [ + `import { realLoader } from "./server/db.ts";`, + `export let getServerData;`, + `getServerData = realLoader;`, + ].join("\n"); + + const error = await assertRejects(() => stripServerOnlyExports(code, "pages/x.tsx")); + + assertStringIncludes((error as Error).message, "getServerData"); + }); + + it("fails the build when a separately exported hook binding is reassigned", async () => { + const code = [ + `let getServerData;`, + `getServerData = async () => ({ props: { s: readSecret() } });`, + `export { getServerData };`, + ].join("\n"); + + await assertRejects(() => stripServerOnlyExports(code, "pages/x.tsx")); + }); + + it("fails the build when a hook binding is written by a destructuring assignment", async () => { + const code = [ + `import { loaders } from "./loaders.ts";`, + `export let getServerData = async () => null;`, + `({ getServerData } = loaders);`, + ].join("\n"); + + await assertRejects(() => stripServerOnlyExports(code, "pages/x.tsx")); + }); + + // A `var` below the top level binds the same module-scope name as the + // exported hook, but the stubber only rewrites top-level declarations and + // the assignment scan only sees assignment and update expressions. Both + // used to miss it, so the artifact carried the stub *and* the real loader, + // and the hoisted initialiser overwrote the stub at module evaluation. + const hoistedVarForms: Array<[string, string]> = [ + ["a bare block", `{ var getServerData = realLoader; }`], + ["an if branch", `if (globalThis.cond) { var getServerData = realLoader; }`], + ["a for-of head", `for (var getServerData of [realLoader]) {}`], + ["a for-in head", `for (var getServerData in { a: realLoader }) {}`], + ["a for init", `for (var getServerData = realLoader; false;) {}`], + ["a switch case", `switch (globalThis.k) { case 1: var getServerData = realLoader; }`], + ["a try block", `try { var getServerData = realLoader; } catch { }`], + ["a catch block", `try { } catch (e) { var getServerData = realLoader; }`], + ["a finally block", `try { } finally { var getServerData = realLoader; }`], + ["a labelled block", `outer: { var getServerData = realLoader; }`], + ["a while body", `while (globalThis.cond) { var getServerData = realLoader; }`], + ["a nested loop", `if (a) { for (;;) { var getServerData = realLoader; } }`], + ["a destructuring pattern", `{ var { getServerData } = { getServerData: realLoader }; }`], + ]; + + for (const [description, redeclaration] of hoistedVarForms) { + it(`fails the build when a hook binding is redeclared by a hoisted var in ${description}`, async () => { + const code = [ + `import { realLoader } from "./server/db.ts";`, + `export var getServerData = async () => null;`, + redeclaration, + ].join("\n"); + + const error = await assertRejects(() => stripServerOnlyExports(code, "pages/x.tsx")); + + assertStringIncludes((error as Error).message, "getServerData"); + assertStringIncludes((error as Error).message, "redeclared"); + }); + } + + // The mirror image: a `var` inside a function is function-scoped and never + // reaches the module binding, so it must not stop the build. Failing closed + // on these would reject ordinary client code that happens to reuse a name. + it("strips normally when a var with a hook name is local to a nested function", async () => { + const code = [ + `import { getEnv } from "veryfront";`, + `export var getServerData = async () => ({ props: { s: getEnv("SECRET_A") } });`, + `export default function Page() {`, + ` if (globalThis.cond) { var getServerData = 1; }`, + ` return getServerData;`, + `}`, + ].join("\n"); + + const result = await stripServerOnlyExports(code, "pages/x.tsx"); + + assertStringIncludes(result, `throw new Error("server-only")`); + assertEquals(result.includes("SECRET_A"), false); + assertEquals(result.includes("veryfront"), false); + }); + + // A class static block is its own `var` scope, so it does not hoist either. + it("strips normally when a var with a hook name is local to a class static block", async () => { + const code = [ + `import { getEnv } from "veryfront";`, + `export async function getServerData() { return getEnv("SECRET_A"); }`, + `class Registry { static { var getServerData = 1; globalThis.x = getServerData; } }`, + `export default function Page() { return Registry; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code, "pages/x.tsx"); + + assertStringIncludes(result, `throw new Error("server-only")`); + assertEquals(result.includes("SECRET_A"), false); + }); + + // `let`/`const` in a block are block-scoped: a same-named binding there is + // a different variable and leaves the exported stub alone. + it("strips normally when a block-scoped let shadows a hook name", async () => { + const code = [ + `import { getEnv } from "veryfront";`, + `export async function getServerData() { return getEnv("SECRET_A"); }`, + `{ let getServerData = 1; globalThis.x = getServerData; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code, "pages/x.tsx"); + + assertStringIncludes(result, `throw new Error("server-only")`); + assertEquals(result.includes("SECRET_A"), false); + }); + // The pre-check runs before anything else, so a module with no hook at all // is never parsed and can never fail the build. it("leaves a module that does not parse alone when it names no hook", async () => { @@ -459,6 +678,52 @@ describe("browser-server-exports-strip", () => { assertEquals(occurrences(result, "loadJob"), 0); }); + it("pre-binds lexical declarations across switch cases", async () => { + const code = [ + `import { loadJob } from "../server/load-job.ts";`, + `export async function getServerData() {`, + ` return { props: { job: loadJob("server") } };`, + `}`, + `export default function Page(value) {`, + ` switch (value) {`, + ` case "read":`, + ` return loadJob("shadowed");`, + ` case "declare":`, + ` const loadJob = () => "local";`, + ` return loadJob();`, + ` }`, + `}`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertNotIncludes(result, "../server/load-job.ts"); + assertEquals(occurrences(result, "loadJob"), 3); + }); + + it("pre-binds lexical declarations before switch case tests", async () => { + const code = [ + `import { loadJob } from "../server/load-job.ts";`, + `export async function getServerData() {`, + ` return { props: { job: loadJob("server") } };`, + `}`, + `export default function Page(value) {`, + ` switch (value) {`, + ` case loadJob("shadowed"):`, + ` return null;`, + ` case "declare":`, + ` let loadJob = () => "local";`, + ` return loadJob();`, + ` }`, + `}`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertNotIncludes(result, "../server/load-job.ts"); + assertEquals(occurrences(result, "loadJob"), 3); + }); + it("keeps an unrelated import when a hook parameter default shadows its name", async () => { const code = [ `import { ctx } from "./client-init.ts";`, @@ -624,6 +889,36 @@ describe("browser-server-exports-strip", () => { assertEquals(occurrences(result, "getEnv"), 0); }); + it("keeps unrelated top-level side effects that share a global with the hook", async () => { + const code = [ + `const clientInit = console.log("client");`, + `export async function getServerData() { console.log("server"); return { props: {} }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertStringIncludes(result, "clientInit"); + assertStringIncludes(result, 'console.log("client")'); + assertNotIncludes(result, 'console.log("server")'); + }); + + it("keeps unrelated top-level side effects that share an import with the hook", async () => { + const code = [ + `import { report } from "./analytics.ts";`, + `const clientInit = report("client");`, + `export async function getServerData() { report("server"); return { props: {} }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertStringIncludes(result, "clientInit"); + assertStringIncludes(result, 'report("client")'); + assertNotIncludes(result, 'report("server")'); + assertStringIncludes(result, 'from "./analytics.ts"'); + }); + it("keeps unrelated co-declared client initializers while dropping hook-only bindings", async () => { const code = [ `const secret = serverOnly(), boot = bootClient();`, @@ -642,159 +937,4130 @@ describe("browser-server-exports-strip", () => { assertStringIncludes(result, "function bootClient()"); }); - // A chain fully feeds the hook: dropping one dead binding frees the next. - it("drops a chain of module-scope bindings that only fed a stripped hook", async () => { + // Silent-leak fix. Liveness used to ask what the module reads once the + // hook's own closure is elided, which made every *other* declaration + // unconditionally live, including ones nothing calls. A private helper the + // module never reaches then counted as a browser reader of `createHash` and + // kept the `node:crypto` import, which is the hydration failure this stage + // exists to prevent. A declaration that runs nothing and that nothing + // reaches is not a reason to keep anything alive. + it("drops a dead private helper that was pinning a node builtin import", async () => { const code = [ - `import { getEnv } from "veryfront";`, - `const RAW = getEnv("TOKEN");`, - `const TOKEN = RAW.trim();`, - `export async function getServerData() { return { props: { t: TOKEN } }; }`, + `import { createHash } from "node:crypto";`, + `function deadHelper() { return createHash("sha1"); }`, + `export async function getServerData() { return { props: { h: createHash("sha256") } }; }`, `export default function Page() { return null; }`, ].join("\n"); const result = await stripServerOnlyExports(code); - assertEquals(occurrences(result, "RAW"), 0); - assertEquals(occurrences(result, "TOKEN"), 0); - assertEquals(occurrences(result, "getEnv"), 0); + assertNotIncludes(result, "node:crypto"); + assertEquals(occurrences(result, "createHash"), 0); + assertEquals(occurrences(result, "deadHelper"), 0); }); - // The hook can be an arrow assigned to `const` — its closure must be - // captured the same way as a `function` declaration before it is emptied. - it("prunes the closure of a const-arrow hook form", async () => { + it("drops a dead helper that was sharing the hook's secret", async () => { const code = [ `import { getEnv } from "veryfront";`, - `const API_KEY = getEnv("SECRET_KEY");`, - `export const getServerData = async () => ({ props: { ok: Boolean(API_KEY) } });`, + `const KEY = getEnv("SECRET_KEY");`, + `const deadHelper = () => KEY;`, + `export async function getServerData() { return { props: { k: KEY } }; }`, `export default function Page() { return null; }`, ].join("\n"); const result = await stripServerOnlyExports(code); - assertEquals(occurrences(result, "API_KEY"), 0); + assertEquals(occurrences(result, "KEY"), 0); + assertNotIncludes(result, "SECRET_KEY"); + assertEquals(occurrences(result, "deadHelper"), 0); assertEquals(occurrences(result, "getEnv"), 0); }); - // A module-scope helper *function* reached only from the hook is part of its - // closure and goes; the same helper is kept the moment client code uses it. - it("prunes a helper function only the hook used, keeps it when the client uses it", async () => { - const onlyHook = [ + it("drops a dead class that was holding the hook's secret", async () => { + const code = [ `import { getEnv } from "veryfront";`, - `function computeKey() { return getEnv("SECRET_KEY"); }`, - `export async function getServerData() { return { props: { k: computeKey() } }; }`, + `const KEY = getEnv("SECRET_KEY");`, + `class DeadLoader { run() { return KEY; } }`, + `export async function getServerData() { return { props: { k: KEY } }; }`, `export default function Page() { return null; }`, ].join("\n"); - const strippedOnlyHook = await stripServerOnlyExports(onlyHook); - assertEquals(occurrences(strippedOnlyHook, "computeKey"), 0); - assertEquals(occurrences(strippedOnlyHook, "getEnv"), 0); - const shared = [ - `function fmt(x) { return String(x); }`, - `export async function getServerData() { return { props: { k: fmt(1) } }; }`, - `export default function Page() { return fmt(2); }`, - ].join("\n"); - const strippedShared = await stripServerOnlyExports(shared); - assertStringIncludes(strippedShared, "function fmt"); + const result = await stripServerOnlyExports(code); + + assertEquals(occurrences(result, "KEY"), 0); + assertNotIncludes(result, "SECRET_KEY"); + assertEquals(occurrences(result, "DeadLoader"), 0); + assertEquals(occurrences(result, "getEnv"), 0); }); - // Production release modules have already passed through esbuild with - // `keepNames`, which emits a top-level name-registration call for every - // function. That compiler metadata must not turn a hook-only helper into a - // browser reference and keep its server import graph alive. - it("prunes hook-only helpers from compiled keepNames output", async () => { + // Two dead helpers that call each other are each the other's last consumer, + // so no per-declaration rule can ever free the secret they share. + it("drops a dead helper cycle that was holding the hook's secret", async () => { const code = [ - `var defineName = Object.defineProperty;`, - `var setName = (target, value) => defineName(target, "name", { value, configurable: true });`, - `import { getActivity } from "../lib/api.js";`, - `async function loadReview() { return getActivity(); }`, - `setName(loadReview, "getReviewProps");`, - `function loadStatic() { return loadReview(); }`, - `setName(loadStatic, "getStaticData");`, - `function loadServer() { return loadReview(); }`, - `setName(loadServer, "getServerData");`, - `function Page() { return null; }`, - `setName(Page, "Page");`, - `export { Page as default, loadServer as getServerData, loadStatic as getStaticData };`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `function first() { return second() + KEY; }`, + `function second() { return first(); }`, + `export async function getServerData() { return { props: { k: KEY } }; }`, + `export default function Page() { return null; }`, ].join("\n"); const result = await stripServerOnlyExports(code); - assertNotIncludes(result, "../lib/api.js"); - assertEquals(occurrences(result, "getActivity"), 0); - assertEquals(occurrences(result, "loadReview"), 0); - assertStringIncludes(result, `setName(Page, "Page")`); + assertEquals(occurrences(result, "KEY"), 0); + assertNotIncludes(result, "SECRET_KEY"); + assertEquals(occurrences(result, "first"), 0); + assertEquals(occurrences(result, "second"), 0); }); - it("keeps helpers consumed by ordinary top-level registration", async () => { + // The same gap in the shape that survives esbuild's production tree-shaker: + // a `var` inside an `if`, `switch`, loop or `try` is not provably pure, so + // it reaches this stage and used to root whatever it reads. + it("drops a hook-only secret read only by a hoisted var in an impure guard", async () => { const code = [ - `import { getActivity } from "../lib/api.js";`, - `async function loadReview() { return getActivity(); }`, - `registerClientHandler(loadReview, "review-loader");`, - `function loadServer() { return loadReview(); }`, - `export { loadServer as getServerData };`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `if (globalThis.debug) { var dead = KEY; }`, + `export async function getServerData() { return { props: { k: KEY } }; }`, + `export default function Page() { return null; }`, ].join("\n"); const result = await stripServerOnlyExports(code); - assertStringIncludes(result, "../lib/api.js"); - assertStringIncludes(result, "registerClientHandler(loadReview"); + assertEquals(occurrences(result, "KEY"), 0); + assertNotIncludes(result, "SECRET_KEY"); + assertEquals(occurrences(result, "dead"), 0); + assertEquals(occurrences(result, "getEnv"), 0); }); - // A chain member the client also reads is kept even though a later link in - // the chain (used only by the hook) is dropped. - it("keeps a chain member the client reads while dropping the hook-only tail", async () => { + it("drops a helper reached only from a hoisted var in an impure guard", async () => { const code = [ + `import { createHash } from "node:crypto";`, `import { getEnv } from "veryfront";`, - `const RAW = getEnv("X");`, - `const TOKEN = RAW + "!";`, - `export async function getServerData() { return { props: { t: TOKEN } }; }`, - `export default function Page() { return RAW; }`, + `const KEY = getEnv("SECRET_KEY");`, + `function deadHelper() { return createHash("sha1") + KEY; }`, + `if (globalThis.debug) { var dead = deadHelper; }`, + `export async function getServerData() { return { props: { k: KEY } }; }`, + `export default function Page() { return null; }`, ].join("\n"); const result = await stripServerOnlyExports(code); - assertStringIncludes(result, "RAW"); // client reads it → kept (with its import) - assertStringIncludes(result, "getEnv"); - assertEquals(occurrences(result, "TOKEN"), 0); // hook-only tail → dropped + assertNotIncludes(result, "node:crypto"); + assertNotIncludes(result, "SECRET_KEY"); + assertEquals(occurrences(result, "deadHelper"), 0); + assertEquals(occurrences(result, "dead"), 0); }); - // Known limitation (pinned): a *destructured* server value is NOT pruned — - // `moduleScopeDeclarations` handles only simple identifiers, to avoid - // mishandling default-value references inside patterns. Conservative (never - // over-prunes) but it means a destructured server value still ships. If this - // ever needs closing, extend the declaration collector to safe patterns. - it("conservatively keeps a destructured server value (documented limitation)", async () => { + // A declaration that *does* run at module load is still elided when every + // binding it evaluates is already the hooks': the only thing it can pin is + // one this pass owns. + it("drops a hoisted var whose initialiser only calls a hook-only import", async () => { const code = [ - `import { getEnv } from "veryfront";`, - `const { a } = getEnv("X");`, - `export async function getServerData() { return { props: { a } }; }`, + `import { createHash } from "node:crypto";`, + `switch (globalThis.mode) { case 1: var dead = createHash("md5"); }`, + `export async function getServerData() { return { props: { h: createHash("sha256") } }; }`, `export default function Page() { return null; }`, ].join("\n"); const result = await stripServerOnlyExports(code); - // Pinned as-is: the destructured binding and its import survive. - assertStringIncludes(result, "getEnv"); + assertNotIncludes(result, "node:crypto"); + assertEquals(occurrences(result, "createHash"), 0); + assertEquals(occurrences(result, "dead"), 0); }); - it("keeps an import that the client still references", async () => { + // Over-pruning guard for the wider reachability: removal stays scoped to + // the hooks' closure, so a helper nothing calls that holds nothing + // server-only is left exactly where it is. This stage is not a general + // dead-code eliminator. + it("keeps a dead helper that holds nothing from the hook's closure", async () => { const code = [ - `import { formatDate } from "../lib/dates.js";`, - `export async function getServerData() { return { props: {} }; }`, - `export default function Page(props) { return formatDate(props.at); }`, + `import { fmt } from "./util.ts";`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `function unusedClientHelper() { return fmt("x"); }`, + `export async function getServerData() { return { props: { k: KEY } }; }`, + `export default function Page() { return null; }`, ].join("\n"); const result = await stripServerOnlyExports(code); - assertStringIncludes(result, "formatDate"); - assertStringIncludes(result, "../lib/dates.js"); + assertEquals(occurrences(result, "KEY"), 0); + assertEquals(occurrences(result, "getEnv"), 0); + assertStringIncludes(result, "function unusedClientHelper"); + assertStringIncludes(result, "./util.ts"); }); - it("keeps an import when only one of its bindings is used", async () => { + // A dev build wraps every initialiser in esbuild's `keepNames` helper and + // compiles a class's registration into a static block. Neither is a call + // the module makes, so neither may turn a dead declaration into live code; + // but the helper performing them stays for as long as one still runs. + it("drops dead declarations wrapped in compiler name registrations", async () => { const code = [ - `import { a, b } from "./x.js";`, - `export async function getServerData() { return b(); }`, - `export default function Page() { return a(); }`, + `var defineName = Object.defineProperty;`, + `var setName = (target, value) => defineName(target, "name", { value, configurable: true });`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `const deadHelper = setName(() => KEY, "deadHelper");`, + `class DeadLoader { static { setName(this, "DeadLoader"); } run() { return KEY; } }`, + `function loadServer() { return KEY; }`, + `setName(loadServer, "getServerData");`, + `function Page() { return null; }`, + `setName(Page, "Page");`, + `export { Page as default, loadServer as getServerData };`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertEquals(occurrences(result, "KEY"), 0); + assertNotIncludes(result, "SECRET_KEY"); + assertEquals(occurrences(result, "deadHelper"), 0); + assertEquals(occurrences(result, "DeadLoader"), 0); + assertEquals(occurrences(result, "getEnv"), 0); + assertStringIncludes(result, `setName(Page, "Page")`); + }); + + it("does not treat a module-local Object.defineProperty call as compiler metadata", async () => { + const code = [ + `const Object = {`, + ` defineProperty(target, key, descriptor) {`, + ` globalThis.nameRegistrations = (globalThis.nameRegistrations ?? 0) + 1;`, + ` return target;`, + ` },`, + `};`, + `var defineName = Object.defineProperty;`, + `var setName = (target, value) => defineName(target, "name", { value, configurable: true });`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `function loadSecret() { return KEY; }`, + `setName(loadSecret, "loadSecret");`, + `export async function getServerData() { return { props: { k: loadSecret() } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + await assertUnprovableRegistrationRejected(code); + }); + + it("does not treat a dynamic Object property as compiler metadata", async () => { + const code = [ + `const defineProperty = "seal";`, + `var setName = (target, value) => Object[defineProperty](`, + ` target, "name", { value, configurable: true },`, + `);`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `function loadSecret() { return KEY; }`, + `setName(loadSecret, "loadSecret");`, + `export async function getServerData() { return { props: { k: loadSecret() } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertStringIncludes(result, `defineProperty = "seal"`); + assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); + assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + }); + + it("does not treat an effectful name descriptor as compiler metadata", async () => { + const code = [ + `function recordRegistration() {`, + ` globalThis.nameRegistrations = (globalThis.nameRegistrations ?? 0) + 1;`, + ` return {};`, + `}`, + `var setName = (target, value) => Object.defineProperty(`, + ` target, "name", { value, configurable: true, ...recordRegistration() },`, + `);`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `function loadSecret() { return KEY; }`, + `setName(loadSecret, "loadSecret");`, + `export async function getServerData() { return { props: { k: loadSecret() } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertStringIncludes(result, "recordRegistration()"); + assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); + assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + }); + + it("does not treat a reassigned defineProperty alias as compiler metadata", async () => { + const code = [ + `var defineName = Object.defineProperty;`, + `function recordAndReturn(target) {`, + ` globalThis.nameRegistrations = (globalThis.nameRegistrations ?? 0) + 1;`, + ` return target;`, + `}`, + `defineName = recordAndReturn;`, + `var setName = (target, value) => defineName(`, + ` target, "name", { value, configurable: true },`, + `);`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `function loadSecret() { return KEY; }`, + `setName(loadSecret, "loadSecret");`, + `export async function getServerData() { return { props: { k: loadSecret() } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertStringIncludes(result, "defineName = recordAndReturn"); + assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); + assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + }); + + it("does not treat a reassigned name helper as compiler metadata", async () => { + const code = [ + `var setName = (target, value) => Object.defineProperty(`, + ` target, "name", { value, configurable: true },`, + `);`, + `function recordAndReturn(target) {`, + ` globalThis.nameRegistrations = (globalThis.nameRegistrations ?? 0) + 1;`, + ` return target;`, + `}`, + `setName = recordAndReturn;`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `function loadSecret() { return KEY; }`, + `setName(loadSecret, "loadSecret");`, + `export async function getServerData() { return { props: { k: loadSecret() } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertStringIncludes(result, "setName = recordAndReturn"); + assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); + assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + }); + + it("ignores assignments to a lexically shadowed name helper", async () => { + const code = [ + `var setName = (target, value) => Object.defineProperty(`, + ` target, "name", { value, configurable: true },`, + `);`, + `function configure(setName) { setName = (target) => target; }`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `function loadSecret() { return KEY; }`, + `setName(loadSecret, "loadSecret");`, + `export async function getServerData() { return { props: { k: loadSecret() } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertStringIncludes(result, "function configure(setName)"); + assertNotIncludes(result, `setName(loadSecret, "loadSecret")`); + assertEquals(occurrences(result, "KEY"), 0); + assertEquals(occurrences(result, "getEnv"), 0); + }); + + it("keeps metadata when a nested assignment reaches the module helper", async () => { + const code = [ + `var setName = (target, value) => Object.defineProperty(`, + ` target, "name", { value, configurable: true },`, + `);`, + `function recordAndReturn(target) { return target; }`, + `function configure() { setName = recordAndReturn; }`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `function loadSecret() { return KEY; }`, + `setName(loadSecret, "loadSecret");`, + `export async function getServerData() { return { props: { k: loadSecret() } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertStringIncludes(result, "setName = recordAndReturn"); + assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); + assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + }); + + it("does not treat a mutated Object.defineProperty as compiler metadata", async () => { + const code = [ + `function recordAndReturn(target) {`, + ` globalThis.nameRegistrations = (globalThis.nameRegistrations ?? 0) + 1;`, + ` return target;`, + `}`, + `Object.defineProperty = recordAndReturn;`, + `var setName = (target, value) => Object.defineProperty(`, + ` target, "name", { value, configurable: true },`, + `);`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `function loadSecret() { return KEY; }`, + `setName(loadSecret, "loadSecret");`, + `export async function getServerData() { return { props: { k: loadSecret() } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + await assertUnprovableRegistrationRejected(code); + }); + + it("does not treat a redefined Object.defineProperty as compiler metadata", async () => { + const code = [ + `function recordAndReturn(target) {`, + ` globalThis.nameRegistrations = (globalThis.nameRegistrations ?? 0) + 1;`, + ` return target;`, + `}`, + `Object.defineProperty(Object, "defineProperty", { value: recordAndReturn });`, + `var setName = (target, value) => Object.defineProperty(`, + ` target, "name", { value, configurable: true },`, + `);`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `function loadSecret() { return KEY; }`, + `setName(loadSecret, "loadSecret");`, + `export async function getServerData() { return { props: { k: loadSecret() } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + await assertUnprovableRegistrationRejected(code); + }); + + it("does not treat a multiply initialized name helper as compiler metadata", async () => { + const code = [ + `function recordAndReturn(target) {`, + ` globalThis.nameRegistrations = (globalThis.nameRegistrations ?? 0) + 1;`, + ` return target;`, + `}`, + `var setName = recordAndReturn;`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `function loadSecret() { return KEY; }`, + `setName(loadSecret, "loadSecret");`, + `var setName = (target, value) => Object.defineProperty(`, + ` target, "name", { value, configurable: true },`, + `);`, + `export async function getServerData() { return { props: { k: loadSecret() } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertStringIncludes(result, "globalThis.nameRegistrations"); + assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); + assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + }); + + it("does not treat a multiply initialized intrinsic alias as compiler metadata", async () => { + const code = [ + `function recordAndReturn(target) {`, + ` globalThis.nameRegistrations = (globalThis.nameRegistrations ?? 0) + 1;`, + ` return target;`, + `}`, + `var defineName = recordAndReturn;`, + `var setName = (target, value) => defineName(`, + ` target, "name", { value, configurable: true },`, + `);`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `function loadSecret() { return KEY; }`, + `setName(loadSecret, "loadSecret");`, + `var defineName = Object.defineProperty;`, + `export async function getServerData() { return { props: { k: loadSecret() } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertStringIncludes(result, "globalThis.nameRegistrations"); + assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); + assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + }); + + it("does not treat a reassigned global Object as compiler metadata", async () => { + const code = [ + `function recordAndReturn(target) {`, + ` globalThis.nameRegistrations = (globalThis.nameRegistrations ?? 0) + 1;`, + ` return target;`, + `}`, + `Object = { defineProperty: recordAndReturn };`, + `var setName = (target, value) => Object.defineProperty(`, + ` target, "name", { value, configurable: true },`, + `);`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `function loadSecret() { return KEY; }`, + `setName(loadSecret, "loadSecret");`, + `export async function getServerData() { return { props: { k: loadSecret() } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + await assertUnprovableRegistrationRejected(code); + }); + + it("does not treat a helper with a local Object name as compiler metadata", async () => { + const code = [ + `var setName = function Object(target, value) {`, + ` return Object.defineProperty(target, "name", { value, configurable: true });`, + `};`, + `function recordAndReturn(target) {`, + ` globalThis.nameRegistrations = (globalThis.nameRegistrations ?? 0) + 1;`, + ` return target;`, + `}`, + `setName.defineProperty = recordAndReturn;`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `function loadSecret() { return KEY; }`, + `setName(loadSecret, "loadSecret");`, + `export async function getServerData() { return { props: { k: loadSecret() } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertStringIncludes(result, "setName.defineProperty = recordAndReturn"); + assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); + assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + }); + + it("does not treat an aliased intrinsic as compiler metadata", async () => { + const code = [ + `function recordAndReturn(target) {`, + ` globalThis.nameRegistrations = (globalThis.nameRegistrations ?? 0) + 1;`, + ` return target;`, + `}`, + `const intrinsic = Object;`, + `intrinsic.defineProperty = recordAndReturn;`, + `var setName = (target, value) => Object.defineProperty(`, + ` target, "name", { value, configurable: true },`, + `);`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `function loadSecret() { return KEY; }`, + `setName(loadSecret, "loadSecret");`, + `export async function getServerData() { return { props: { k: loadSecret() } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + await assertUnprovableRegistrationRejected(code); + }); + + for ( + const [label, mutation] of [ + [ + "a transitive intrinsic alias", + [ + `const intrinsic = Object;`, + `const alias = intrinsic;`, + `alias.defineProperty = recordAndReturn;`, + ].join("\n"), + ], + [ + "a nonliteral merge source", + [ + `const patch = { Object: { defineProperty: recordAndReturn } };`, + `Object.assign(globalThis, patch);`, + ].join("\n"), + ], + [ + "an optional intrinsic mutation call", + `Object.defineProperty?.(Object, "defineProperty", { value: recordAndReturn });`, + ], + [ + "a call-invoked intrinsic mutation", + `(function (intrinsic) { intrinsic.defineProperty = recordAndReturn; }).call(null, Object);`, + ], + [ + "an apply-invoked intrinsic mutation", + `(function (intrinsic) { intrinsic.defineProperty = recordAndReturn; }).apply(null, [Object]);`, + ], + [ + "a named-function intrinsic mutation", + [ + `function mutateIntrinsic(intrinsic) {`, + ` intrinsic.defineProperty = recordAndReturn;`, + `}`, + `mutateIntrinsic(Object);`, + ].join("\n"), + ], + [ + "an invoked factory-returned function mutation", + [ + `(function () {`, + ` return function (intrinsic) {`, + ` intrinsic.defineProperty = recordAndReturn;`, + ` };`, + `})()(Object);`, + ].join("\n"), + ], + [ + "an invoked method-factory-returned function mutation", + [ + `const factory = {`, + ` make() {`, + ` return function (intrinsic) {`, + ` intrinsic.defineProperty = recordAndReturn;`, + ` };`, + ` },`, + `};`, + `factory.make()(Object);`, + ].join("\n"), + ], + [ + "an assigned method-factory-returned function mutation", + [ + `const factory = {};`, + `factory.make = () => function (intrinsic) {`, + ` intrinsic.defineProperty = recordAndReturn;`, + `};`, + `factory.make()(Object);`, + ].join("\n"), + ], + [ + "an alias-assigned method-factory-returned function mutation", + [ + `const factory = {};`, + `const alias = factory;`, + `alias.make = () => function (intrinsic) {`, + ` intrinsic.defineProperty = recordAndReturn;`, + `};`, + `factory.make()(Object);`, + ].join("\n"), + ], + [ + "a destructured-alias-assigned method-factory-returned function mutation", + [ + `const factory = {};`, + `const [alias] = [factory];`, + `alias.make = () => function (intrinsic) {`, + ` intrinsic.defineProperty = recordAndReturn;`, + `};`, + `factory.make()(Object);`, + ].join("\n"), + ], + [ + "a conditionally rebound alias-assigned method-factory-returned function mutation", + [ + `const factory = {};`, + `let alias = factory;`, + `if (globalThis.useOtherFactory) alias = {};`, + `alias.make = () => function (intrinsic) {`, + ` intrinsic.defineProperty = recordAndReturn;`, + `};`, + `factory.make()(Object);`, + ].join("\n"), + ], + [ + "an intrinsic mutation invoked through call", + `Object.defineProperty.call(null, Object, "defineProperty", { value: recordAndReturn });`, + ], + [ + "an intrinsic mutation invoked through apply", + `Object.defineProperty.apply(null, [Object, "defineProperty", { value: recordAndReturn }]);`, + ], + [ + "a global merge invoked through call", + [ + `const patch = { Object: { defineProperty: recordAndReturn } };`, + `Object.assign.call(null, globalThis, patch);`, + ].join("\n"), + ], + [ + "a global merge invoked through apply", + [ + `const patch = { Object: { defineProperty: recordAndReturn } };`, + `Object.assign.apply(null, [globalThis, patch]);`, + ].join("\n"), + ], + [ + "an intrinsic alias produced by a value expression", + [ + `const intrinsic = (0, Object);`, + `intrinsic.defineProperty = recordAndReturn;`, + ].join("\n"), + ], + ] + ) { + it(`does not treat ${label} as compiler metadata`, async () => { + const code = [ + `function recordAndReturn(target) {`, + ` globalThis.nameRegistrations = (globalThis.nameRegistrations ?? 0) + 1;`, + ` return target;`, + `}`, + mutation, + `var setName = (target, value) => Object.defineProperty(`, + ` target, "name", { value, configurable: true },`, + `);`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `function loadSecret() { return KEY; }`, + `setName(loadSecret, "loadSecret");`, + `export async function getServerData() { return { props: { k: loadSecret() } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + await assertUnprovableRegistrationRejected(code); + }); + } + + it("still strips metadata through the effective last object method", async () => { + const code = [ + `const factory = {`, + ` make() {`, + ` return function (intrinsic) {`, + ` intrinsic.defineProperty = (target) => target;`, + ` };`, + ` },`, + ` make() { return function (_intrinsic) {}; },`, + `};`, + `factory.make()(Object);`, + `var setName = (target, value) => Object.defineProperty(`, + ` target, "name", { value, configurable: true },`, + `);`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `function loadSecret() { return KEY; }`, + `setName(loadSecret, "loadSecret");`, + `export async function getServerData() { return { props: { k: loadSecret() } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertNotIncludes(result, `setName(loadSecret, "loadSecret")`); + assertEquals(occurrences(result, "KEY"), 0); + assertEquals(occurrences(result, "getEnv"), 0); + }); + + it("still strips metadata after an owner alias is rebound", async () => { + const code = [ + `const factory = { make() { return function (_intrinsic) {}; } };`, + `let alias = factory;`, + `alias = {};`, + `alias.make = () => function (intrinsic) {`, + ` intrinsic.defineProperty = (target) => target;`, + `};`, + `factory.make()(Object);`, + `var setName = (target, value) => Object.defineProperty(`, + ` target, "name", { value, configurable: true },`, + `);`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `function loadSecret() { return KEY; }`, + `setName(loadSecret, "loadSecret");`, + `export async function getServerData() { return { props: { k: loadSecret() } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertNotIncludes(result, `setName(loadSecret, "loadSecret")`); + assertEquals(occurrences(result, "KEY"), 0); + assertEquals(occurrences(result, "getEnv"), 0); + }); + + it("keeps metadata after a write to a hoisted function owner", async () => { + const code = [ + `owner.make = () => function (intrinsic) {`, + ` intrinsic.defineProperty = (target) => target;`, + `};`, + `function owner() {}`, + `owner.make()(Object);`, + `var setName = (target, value) => Object.defineProperty(`, + ` target, "name", { value, configurable: true },`, + `);`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `function loadSecret() { return KEY; }`, + `setName(loadSecret, "loadSecret");`, + `export async function getServerData() { return { props: { k: loadSecret() } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + await assertUnprovableRegistrationRejected(code); + }); + + it("still strips metadata after a hoisted function owner is rebound", async () => { + const code = [ + `owner.make = () => function (intrinsic) {`, + ` intrinsic.defineProperty = (target) => target;`, + `};`, + `owner = { make: () => function (_intrinsic) {} };`, + `function owner() {}`, + `owner.make()(Object);`, + `var setName = (target, value) => Object.defineProperty(`, + ` target, "name", { value, configurable: true },`, + `);`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `function loadSecret() { return KEY; }`, + `setName(loadSecret, "loadSecret");`, + `export async function getServerData() { return { props: { k: loadSecret() } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertNotIncludes(result, `setName(loadSecret, "loadSecret")`); + assertEquals(occurrences(result, "KEY"), 0); + assertEquals(occurrences(result, "getEnv"), 0); + }); + + it("still strips metadata after an invoked function rebinds its hoisted owner", async () => { + const code = [ + `const mutatingFactory = () => function (intrinsic) {`, + ` intrinsic.defineProperty = (target) => target;`, + `};`, + `const safeFactory = () => function (_intrinsic) {};`, + `function configure() {`, + ` owner.make = mutatingFactory;`, + ` owner = { make: safeFactory };`, + ` function owner() {}`, + ` owner.make()(Object);`, + `}`, + `configure();`, + `var setName = (target, value) => Object.defineProperty(`, + ` target, "name", { value, configurable: true },`, + `);`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `function loadSecret() { return KEY; }`, + `setName(loadSecret, "loadSecret");`, + `export async function getServerData() { return { props: { k: loadSecret() } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertNotIncludes(result, `setName(loadSecret, "loadSecret")`); + assertEquals(occurrences(result, "KEY"), 0); + assertEquals(occurrences(result, "getEnv"), 0); + }); + + it("uses the effective final duplicate function declaration", async () => { + const code = [ + `function configure() {`, + ` function factory() {`, + ` return function (intrinsic) {`, + ` intrinsic.defineProperty = (target) => target;`, + ` };`, + ` }`, + ` function factory() { return function (_intrinsic) {}; }`, + ` factory()(Object);`, + `}`, + `configure();`, + `var setName = (target, value) => Object.defineProperty(`, + ` target, "name", { value, configurable: true },`, + `);`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `function loadSecret() { return KEY; }`, + `setName(loadSecret, "loadSecret");`, + `export async function getServerData() { return { props: { k: loadSecret() } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertNotIncludes(result, `setName(loadSecret, "loadSecret")`); + assertEquals(occurrences(result, "KEY"), 0); + assertEquals(occurrences(result, "getEnv"), 0); + }); + + it("keeps metadata when a deferred function may rebind an owner", async () => { + const code = [ + `const mutatingFactory = () => function (intrinsic) {`, + ` intrinsic.defineProperty = (target) => target;`, + `};`, + `const safeFactory = () => function (_intrinsic) {};`, + `let owner = { make: mutatingFactory };`, + `function rebindLater() { owner = { make: safeFactory }; }`, + `owner.make()(Object);`, + `var setName = (target, value) => Object.defineProperty(`, + ` target, "name", { value, configurable: true },`, + `);`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `function loadSecret() { return KEY; }`, + `setName(loadSecret, "loadSecret");`, + `export async function getServerData() { return { props: { k: loadSecret() } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + await assertUnprovableRegistrationRejected(code); + }); + + it("keeps metadata when a conditional rebind may leave a mutating owner", async () => { + const code = [ + `const mutatingFactory = () => function (intrinsic) {`, + ` intrinsic.defineProperty = (target) => target;`, + `};`, + `const safeFactory = () => function (_intrinsic) {};`, + `function configure(useSafeFactory) {`, + ` let owner = { make: mutatingFactory };`, + ` if (useSafeFactory) owner = { make: safeFactory };`, + ` owner.make()(Object);`, + `}`, + `configure(globalThis.useSafeFactory);`, + `var setName = (target, value) => Object.defineProperty(`, + ` target, "name", { value, configurable: true },`, + `);`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `function loadSecret() { return KEY; }`, + `setName(loadSecret, "loadSecret");`, + `export async function getServerData() { return { props: { k: loadSecret() } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + await assertUnprovableRegistrationRejected(code); + }); + + for ( + const [label, invocation] of [ + [ + "a short-circuiting owner assignment", + [ + `owner ||= { make: safeFactory };`, + `owner.make()(Object);`, + ].join("\n"), + ], + [ + "a short-circuiting owner assignment expression", + `(owner ||= { make: safeFactory }).make()(Object);`, + ], + ] as const + ) { + it(`keeps metadata after ${label}`, async () => { + const code = [ + `const mutatingFactory = () => function (intrinsic) {`, + ` intrinsic.defineProperty = (target) => target;`, + `};`, + `const safeFactory = () => function (_intrinsic) {};`, + `let owner = { make: mutatingFactory };`, + invocation, + `var setName = (target, value) => Object.defineProperty(`, + ` target, "name", { value, configurable: true },`, + `);`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `function loadSecret() { return KEY; }`, + `setName(loadSecret, "loadSecret");`, + `export async function getServerData() { return { props: { k: loadSecret() } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + await assertUnprovableRegistrationRejected(code); + }); + } + + it("still strips metadata after a direct owner rebind", async () => { + const code = [ + `const mutatingFactory = () => function (intrinsic) {`, + ` intrinsic.defineProperty = (target) => target;`, + `};`, + `const safeFactory = () => function (_intrinsic) {};`, + `let owner = { make: mutatingFactory };`, + `owner = { make: safeFactory };`, + `owner.make()(Object);`, + `var setName = (target, value) => Object.defineProperty(`, + ` target, "name", { value, configurable: true },`, + `);`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `function loadSecret() { return KEY; }`, + `setName(loadSecret, "loadSecret");`, + `export async function getServerData() { return { props: { k: loadSecret() } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertNotIncludes(result, `setName(loadSecret, "loadSecret")`); + assertEquals(occurrences(result, "KEY"), 0); + assertEquals(occurrences(result, "getEnv"), 0); + }); + + it("still strips metadata after a direct member overwrite", async () => { + const code = [ + `const mutatingFactory = () => function (intrinsic) {`, + ` intrinsic.defineProperty = (target) => target;`, + `};`, + `const safeFactory = () => function (_intrinsic) {};`, + `const owner = {};`, + `owner.make = mutatingFactory;`, + `owner.make = safeFactory;`, + `owner.make()(Object);`, + `var setName = (target, value) => Object.defineProperty(`, + ` target, "name", { value, configurable: true },`, + `);`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `function loadSecret() { return KEY; }`, + `setName(loadSecret, "loadSecret");`, + `export async function getServerData() { return { props: { k: loadSecret() } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertNotIncludes(result, `setName(loadSecret, "loadSecret")`); + assertEquals(occurrences(result, "KEY"), 0); + assertEquals(occurrences(result, "getEnv"), 0); + }); + + for ( + const [label, ownerFlow] of [ + [ + "a conditional member overwrite", + [ + `const owner = { make: mutatingFactory };`, + `if (globalThis.useSafeFactory) owner.make = safeFactory;`, + ].join("\n"), + ], + [ + "a deferred member overwrite", + [ + `const owner = { make: mutatingFactory };`, + `function rebindLater() { owner.make = safeFactory; }`, + ].join("\n"), + ], + [ + "a member overwrite through an ambiguous alias", + [ + `const owner = { make: mutatingFactory };`, + `const other = {};`, + `const alias = globalThis.useSafeFactory ? owner : other;`, + `alias.make = safeFactory;`, + ].join("\n"), + ], + [ + "a short-circuiting member assignment", + [ + `const owner = { make: mutatingFactory };`, + `owner.make ||= safeFactory;`, + ].join("\n"), + ], + ] as const + ) { + it(`keeps metadata after ${label}`, async () => { + const code = [ + `const mutatingFactory = () => function (intrinsic) {`, + ` intrinsic.defineProperty = (target) => target;`, + `};`, + `const safeFactory = () => function (_intrinsic) {};`, + ownerFlow, + `owner.make()(Object);`, + `var setName = (target, value) => Object.defineProperty(`, + ` target, "name", { value, configurable: true },`, + `);`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `function loadSecret() { return KEY; }`, + `setName(loadSecret, "loadSecret");`, + `export async function getServerData() { return { props: { k: loadSecret() } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + await assertUnprovableRegistrationRejected(code); + }); + } + + for ( + const [label, ownerFlow] of [ + [ + "a nested object member factory", + `const namespace = { factory: { make: mutatingFactory } };`, + ], + [ + "a write to a nested object member factory", + [ + `const namespace = { factory: { make: safeFactory } };`, + `namespace.factory.make = mutatingFactory;`, + ].join("\n"), + ], + ] as const + ) { + it(`keeps metadata through ${label}`, async () => { + const code = [ + `const mutatingFactory = () => function (intrinsic) {`, + ` intrinsic.defineProperty = (target) => target;`, + `};`, + `const safeFactory = () => function (_intrinsic) {};`, + ownerFlow, + `namespace.factory.make()(Object);`, + `var setName = (target, value) => Object.defineProperty(`, + ` target, "name", { value, configurable: true },`, + `);`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `function loadSecret() { return KEY; }`, + `setName(loadSecret, "loadSecret");`, + `export async function getServerData() { return { props: { k: loadSecret() } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + await assertUnprovableRegistrationRejected(code); + }); + } + + it("still strips metadata after a nested member overwrite", async () => { + const code = [ + `const mutatingFactory = () => function (intrinsic) {`, + ` intrinsic.defineProperty = (target) => target;`, + `};`, + `const safeFactory = () => function (_intrinsic) {};`, + `const namespace = { factory: { make: mutatingFactory } };`, + `namespace.factory.make = safeFactory;`, + `namespace.factory.make()(Object);`, + `var setName = (target, value) => Object.defineProperty(`, + ` target, "name", { value, configurable: true },`, + `);`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `function loadSecret() { return KEY; }`, + `setName(loadSecret, "loadSecret");`, + `export async function getServerData() { return { props: { k: loadSecret() } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertNotIncludes(result, `setName(loadSecret, "loadSecret")`); + assertEquals(occurrences(result, "KEY"), 0); + assertEquals(occurrences(result, "getEnv"), 0); + }); + + it("keeps metadata after a write through an object-destructured owner", async () => { + const code = [ + `const mutatingFactory = () => function (intrinsic) {`, + ` intrinsic.defineProperty = (target) => target;`, + `};`, + `const safeFactory = () => function (_intrinsic) {};`, + `const namespace = { factory: { make: safeFactory } };`, + `const { factory: alias } = namespace;`, + `alias.make = mutatingFactory;`, + `namespace.factory.make()(Object);`, + `var setName = (target, value) => Object.defineProperty(`, + ` target, "name", { value, configurable: true },`, + `);`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `function loadSecret() { return KEY; }`, + `setName(loadSecret, "loadSecret");`, + `export async function getServerData() { return { props: { k: loadSecret() } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + await assertUnprovableRegistrationRejected(code); + }); + + for ( + const [label, ownerFlow] of [ + [ + "an object-destructured factory", + [ + `const owner = { make: mutatingFactory };`, + `const { make } = owner;`, + ].join("\n"), + ], + [ + "an array-destructured factory", + `const [make] = [mutatingFactory];`, + ], + ] as const + ) { + it(`keeps metadata through ${label}`, async () => { + const code = [ + `const mutatingFactory = () => function (intrinsic) {`, + ` intrinsic.defineProperty = (target) => target;`, + `};`, + ownerFlow, + `make()(Object);`, + `var setName = (target, value) => Object.defineProperty(`, + ` target, "name", { value, configurable: true },`, + `);`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `function loadSecret() { return KEY; }`, + `setName(loadSecret, "loadSecret");`, + `export async function getServerData() { return { props: { k: loadSecret() } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + await assertUnprovableRegistrationRejected(code); + }); + } + + it("keeps metadata through a statically resolved computed factory call", async () => { + const code = [ + `const mutatingFactory = () => function (intrinsic) {`, + ` intrinsic.defineProperty = (target) => target;`, + `};`, + `const owner = { make: mutatingFactory };`, + `const key = "make";`, + `owner[key]()(Object);`, + `var setName = (target, value) => Object.defineProperty(`, + ` target, "name", { value, configurable: true },`, + `);`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `function loadSecret() { return KEY; }`, + `setName(loadSecret, "loadSecret");`, + `export async function getServerData() { return { props: { k: loadSecret() } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + await assertUnprovableRegistrationRejected(code); + }); + + it("keeps metadata through a runtime-selected local factory call", async () => { + const code = [ + `const mutatingFactory = () => function (intrinsic) {`, + ` intrinsic.defineProperty = (target) => target;`, + `};`, + `const owner = { make: mutatingFactory };`, + `owner[globalThis.factoryKey]()(Object);`, + `var setName = (target, value) => Object.defineProperty(`, + ` target, "name", { value, configurable: true },`, + `);`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `function loadSecret() { return KEY; }`, + `setName(loadSecret, "loadSecret");`, + `export async function getServerData() { return { props: { k: loadSecret() } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + await assertUnprovableRegistrationRejected(code); + }); + + it("keeps metadata when a computed key has known and runtime-selected flows", async () => { + const code = [ + `const mutatingFactory = () => function (intrinsic) {`, + ` intrinsic.defineProperty = (target) => target;`, + `};`, + `const safeFactory = () => function (_intrinsic) {};`, + `const owner = { safe: safeFactory, make: mutatingFactory };`, + `const key = globalThis.useSafe ? "safe" : globalThis.factoryKey;`, + `owner[key]()(Object);`, + `var setName = (target, value) => Object.defineProperty(`, + ` target, "name", { value, configurable: true },`, + `);`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `function loadSecret() { return KEY; }`, + `setName(loadSecret, "loadSecret");`, + `export async function getServerData() { return { props: { k: loadSecret() } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + await assertUnprovableRegistrationRejected(code); + }); + + it("keeps metadata when a computed-key factory has an unresolved return flow", async () => { + const code = [ + `const mutatingFactory = () => function (intrinsic) {`, + ` intrinsic.defineProperty = (target) => target;`, + `};`, + `const safeFactory = () => function (_intrinsic) {};`, + `const owner = { safe: safeFactory, make: mutatingFactory };`, + `const key = (() => globalThis.useSafe ? "safe" : globalThis.factoryKey)();`, + `owner[key]()(Object);`, + `var setName = (target, value) => Object.defineProperty(`, + ` target, "name", { value, configurable: true },`, + `);`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `function loadSecret() { return KEY; }`, + `setName(loadSecret, "loadSecret");`, + `export async function getServerData() { return { props: { k: loadSecret() } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + await assertUnprovableRegistrationRejected(code); + }); + + it("keeps metadata through an unresolved computed object member", async () => { + const code = [ + `const mutatingFactory = () => function (intrinsic) {`, + ` intrinsic.defineProperty = (target) => target;`, + `};`, + `const key = globalThis.factoryKey;`, + `const owner = { [key]: mutatingFactory };`, + `owner[key]()(Object);`, + `var setName = (target, value) => Object.defineProperty(`, + ` target, "name", { value, configurable: true },`, + `);`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `function loadSecret() { return KEY; }`, + `setName(loadSecret, "loadSecret");`, + `export async function getServerData() { return { props: { k: loadSecret() } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + await assertUnprovableRegistrationRejected(code); + }); + + it("keeps metadata when a member value flow refers to itself", async () => { + const code = [ + `const mutatingFactory = () => function (intrinsic) {`, + ` intrinsic.defineProperty = (target) => target;`, + `};`, + `const owner = { make: mutatingFactory };`, + `owner.make = owner.make;`, + `owner.make()(Object);`, + `var setName = (target, value) => Object.defineProperty(`, + ` target, "name", { value, configurable: true },`, + `);`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `function loadSecret() { return KEY; }`, + `setName(loadSecret, "loadSecret");`, + `export async function getServerData() { return { props: { k: loadSecret() } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + await assertUnprovableRegistrationRejected(code); + }); + + for ( + const [label, ownerDeclaration, invocation] of [ + [ + "object member", + `const owner = { [key]: mutatingFactory };`, + `owner.make()(Object);`, + ], + [ + "static class member", + `class Owner { static [key] = mutatingFactory; }`, + `Owner.make()(Object);`, + ], + ] as const + ) { + it(`keeps metadata through an aliased computed ${label}`, async () => { + const code = [ + `const mutatingFactory = () => function (intrinsic) {`, + ` intrinsic.defineProperty = (target) => target;`, + `};`, + `const key = "make";`, + ownerDeclaration, + invocation, + `var setName = (target, value) => Object.defineProperty(`, + ` target, "name", { value, configurable: true },`, + `);`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `function loadSecret() { return KEY; }`, + `setName(loadSecret, "loadSecret");`, + `export async function getServerData() { return { props: { k: loadSecret() } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + await assertUnprovableRegistrationRejected(code); + }); + } + + for ( + const [label, key] of [ + ["statically resolved", `const key = "make";`], + ["runtime-selected", `const key = globalThis.factoryKey;`], + ] as const + ) { + it(`keeps metadata after a ${label} computed member write`, async () => { + const code = [ + `const mutatingFactory = () => function (intrinsic) {`, + ` intrinsic.defineProperty = (target) => target;`, + `};`, + key, + `const owner = {};`, + `owner[key] = mutatingFactory;`, + `owner[key]()(Object);`, + `var setName = (target, value) => Object.defineProperty(`, + ` target, "name", { value, configurable: true },`, + `);`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `function loadSecret() { return KEY; }`, + `setName(loadSecret, "loadSecret");`, + `export async function getServerData() { return { props: { k: loadSecret() } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + await assertUnprovableRegistrationRejected(code); + }); + } + + for ( + const [label, setup, invocation] of [ + [ + "targets another key", + [ + `const owner = { make: safeFactory };`, + `const key = "other";`, + `owner[key] = mutatingFactory;`, + ].join("\n"), + `owner.make()(Object);`, + ], + [ + "happens after the call", + [ + `const owner = { make: safeFactory };`, + `const key = globalThis.factoryKey;`, + ].join("\n"), + [ + `owner.make()(Object);`, + `owner[key] = mutatingFactory;`, + ].join("\n"), + ], + [ + "targets another owner", + [ + `const owner = { make: safeFactory };`, + `const other = {};`, + `const key = globalThis.factoryKey;`, + `other[key] = mutatingFactory;`, + ].join("\n"), + `owner[key]()(Object);`, + ], + ] as const + ) { + it(`still strips metadata when a computed member write ${label}`, async () => { + const code = [ + `const mutatingFactory = () => function (intrinsic) {`, + ` intrinsic.defineProperty = (target) => target;`, + `};`, + `const safeFactory = () => function (_intrinsic) {};`, + setup, + invocation, + `var setName = (target, value) => Object.defineProperty(`, + ` target, "name", { value, configurable: true },`, + `);`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `function loadSecret() { return KEY; }`, + `setName(loadSecret, "loadSecret");`, + `export async function getServerData() { return { props: { k: loadSecret() } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertNotIncludes(result, `setName(loadSecret, "loadSecret")`); + assertNotIncludes(result, `getEnv("SECRET_KEY")`); + }); + } + + it("keeps metadata when a computed write key reads the same owner", async () => { + const code = [ + `const mutatingFactory = () => function (intrinsic) {`, + ` intrinsic.defineProperty = (target) => target;`, + `};`, + `const safeFactory = () => function (_intrinsic) {};`, + `const owner = { key: "make", make: safeFactory };`, + `owner[owner.key] = mutatingFactory;`, + `owner.make()(Object);`, + `var setName = (target, value) => Object.defineProperty(`, + ` target, "name", { value, configurable: true },`, + `);`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `function loadSecret() { return KEY; }`, + `setName(loadSecret, "loadSecret");`, + `export async function getServerData() { return { props: { k: loadSecret() } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + await assertUnprovableRegistrationRejected(code); + }); + + it("keeps metadata when a nested computed write resolves its owner", async () => { + const code = [ + `const mutatingFactory = () => function (intrinsic) {`, + ` intrinsic.defineProperty = (target) => target;`, + `};`, + `const safeFactory = () => function (_intrinsic) {};`, + `const owner = { slot: { make: safeFactory } };`, + `const outerKey = globalThis.outerKey;`, + `const memberName = globalThis.memberName;`, + `owner[outerKey][memberName] = mutatingFactory;`, + `owner.slot.make()(Object);`, + `var setName = (target, value) => Object.defineProperty(`, + ` target, "name", { value, configurable: true },`, + `);`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `function loadSecret() { return KEY; }`, + `setName(loadSecret, "loadSecret");`, + `export async function getServerData() { return { props: { k: loadSecret() } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + await assertUnprovableRegistrationRejected(code); + }); + + it("bounds unresolved computed write key traversal", async () => { + const writes = Array.from( + { length: 9 }, + (_, index) => `owner[globalThis.key${index}] = safeFactory;`, + ); + const code = [ + `const mutatingFactory = () => function (intrinsic) {`, + ` intrinsic.defineProperty = (target) => target;`, + `};`, + `const safeFactory = () => function (_intrinsic) {};`, + `const owner = { make: mutatingFactory };`, + ...writes, + `owner.make()(Object);`, + `var setName = (target, value) => Object.defineProperty(`, + ` target, "name", { value, configurable: true },`, + `);`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `function loadSecret() { return KEY; }`, + `setName(loadSecret, "loadSecret");`, + `export async function getServerData() { return { props: { k: loadSecret() } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + const started = performance.now(); + await assertUnprovableRegistrationRejected(code); + const elapsed = performance.now() - started; + + assert(elapsed < 1_500, `expected bounded traversal, got ${elapsed.toFixed(1)} ms`); + }); + + for ( + const [label, ownerFlow] of [ + [ + "computed declaration", + [ + `const key = globalThis.factoryKey;`, + `const owner = { make: mutatingFactory, [key]: safeFactory };`, + ].join("\n"), + ], + [ + "computed write", + [ + `const owner = { make: mutatingFactory };`, + `const key = globalThis.useSafe ? "make" : "other";`, + `owner[key] = safeFactory;`, + ].join("\n"), + ], + ] as const + ) { + it(`keeps an earlier mutator behind a possible ${label}`, async () => { + const code = [ + `const mutatingFactory = () => function (intrinsic) {`, + ` intrinsic.defineProperty = (target) => target;`, + `};`, + `const safeFactory = () => function (_intrinsic) {};`, + ownerFlow, + `owner.make()(Object);`, + `var setName = (target, value) => Object.defineProperty(`, + ` target, "name", { value, configurable: true },`, + `);`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `function loadSecret() { return KEY; }`, + `setName(loadSecret, "loadSecret");`, + `export async function getServerData() { return { props: { k: loadSecret() } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + await assertUnprovableRegistrationRejected(code); + }); + } + + for ( + const [label, ownerDeclaration, invocation] of [ + [ + "an object getter", + `const owner = { get make() { return mutator; } };`, + `owner.make(Object);`, + ], + [ + "a static class getter", + `class Owner { static get make() { return mutator; } }`, + `Owner.make(Object);`, + ], + [ + "an inherited object getter", + [ + `const base = { get make() { return mutator; } };`, + `const owner = { __proto__: base };`, + ].join("\n"), + `owner.make(Object);`, + ], + [ + "an inherited static class getter", + [ + `class Base { static get make() { return mutator; } }`, + `class Owner extends Base {}`, + ].join("\n"), + `Owner.make(Object);`, + ], + ] as const + ) { + it(`keeps metadata through a callable returned by ${label}`, async () => { + const code = [ + `const mutator = (intrinsic) => {`, + ` intrinsic.defineProperty = (target) => target;`, + `};`, + ownerDeclaration, + invocation, + `var setName = (target, value) => Object.defineProperty(`, + ` target, "name", { value, configurable: true },`, + `);`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `function loadSecret() { return KEY; }`, + `setName(loadSecret, "loadSecret");`, + `export async function getServerData() { return { props: { k: loadSecret() } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + await assertUnprovableRegistrationRejected(code); + }); + } + + it("does not recurse indefinitely through a self-referential getter", async () => { + const code = [ + `const owner = { get make() { return owner.make; } };`, + `owner.make(Object);`, + `var setName = (target, value) => Object.defineProperty(`, + ` target, "name", { value, configurable: true },`, + `);`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `function loadSecret() { return KEY; }`, + `setName(loadSecret, "loadSecret");`, + `export async function getServerData() { return { props: { k: loadSecret() } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertNotIncludes(result, `setName(loadSecret, "loadSecret")`); + assertNotIncludes(result, `getEnv("SECRET_KEY")`); + }); + + it("keeps metadata after a write through a computed object-destructured owner", async () => { + const code = [ + `const mutatingFactory = () => function (intrinsic) {`, + ` intrinsic.defineProperty = (target) => target;`, + `};`, + `const safeFactory = () => function (_intrinsic) {};`, + `const namespace = { factory: { make: safeFactory } };`, + `const key = "factory";`, + `const { [key]: alias } = namespace;`, + `alias.make = mutatingFactory;`, + `namespace.factory.make()(Object);`, + `var setName = (target, value) => Object.defineProperty(`, + ` target, "name", { value, configurable: true },`, + `);`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `function loadSecret() { return KEY; }`, + `setName(loadSecret, "loadSecret");`, + `export async function getServerData() { return { props: { k: loadSecret() } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + await assertUnprovableRegistrationRejected(code); + }); + + it("keeps metadata after a write through a runtime-selected destructured owner", async () => { + const code = [ + `const mutatingFactory = () => function (intrinsic) {`, + ` intrinsic.defineProperty = (target) => target;`, + `};`, + `const safeFactory = () => function (_intrinsic) {};`, + `const namespace = { factory: { make: safeFactory } };`, + `const { [globalThis.factoryKey]: alias } = namespace;`, + `alias.make = mutatingFactory;`, + `namespace.factory.make()(Object);`, + `var setName = (target, value) => Object.defineProperty(`, + ` target, "name", { value, configurable: true },`, + `);`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `function loadSecret() { return KEY; }`, + `setName(loadSecret, "loadSecret");`, + `export async function getServerData() { return { props: { k: loadSecret() } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + await assertUnprovableRegistrationRejected(code); + }); + + it("keeps metadata after a nested write through an object-rest owner", async () => { + const code = [ + `const mutatingFactory = () => function (intrinsic) {`, + ` intrinsic.defineProperty = (target) => target;`, + `};`, + `const safeFactory = () => function (_intrinsic) {};`, + `const namespace = { factory: { make: safeFactory } };`, + `const { ...copy } = namespace;`, + `copy.factory.make = mutatingFactory;`, + `namespace.factory.make()(Object);`, + `var setName = (target, value) => Object.defineProperty(`, + ` target, "name", { value, configurable: true },`, + `);`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `function loadSecret() { return KEY; }`, + `setName(loadSecret, "loadSecret");`, + `export async function getServerData() { return { props: { k: loadSecret() } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + await assertUnprovableRegistrationRejected(code); + }); + + it("still strips metadata after a safe write through an object-destructured owner", async () => { + const code = [ + `const mutatingFactory = () => function (intrinsic) {`, + ` intrinsic.defineProperty = (target) => target;`, + `};`, + `const safeFactory = () => function (_intrinsic) {};`, + `const namespace = { factory: { make: mutatingFactory } };`, + `const { factory: alias } = namespace;`, + `alias.make = safeFactory;`, + `namespace.factory.make()(Object);`, + `var setName = (target, value) => Object.defineProperty(`, + ` target, "name", { value, configurable: true },`, + `);`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `function loadSecret() { return KEY; }`, + `setName(loadSecret, "loadSecret");`, + `export async function getServerData() { return { props: { k: loadSecret() } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertNotIncludes(result, `setName(loadSecret, "loadSecret")`); + assertEquals(occurrences(result, "KEY"), 0); + assertEquals(occurrences(result, "getEnv"), 0); + }); + + it("still strips metadata after a dominating overwrite before a conditional read", async () => { + const code = [ + `const mutatingFactory = () => function (intrinsic) {`, + ` intrinsic.defineProperty = (target) => target;`, + `};`, + `const safeFactory = () => function (_intrinsic) {};`, + `const owner = {};`, + `owner.make = mutatingFactory;`, + `owner.make = safeFactory;`, + `if (globalThis.runFactory) owner.make()(Object);`, + `var setName = (target, value) => Object.defineProperty(`, + ` target, "name", { value, configurable: true },`, + `);`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `function loadSecret() { return KEY; }`, + `setName(loadSecret, "loadSecret");`, + `export async function getServerData() { return { props: { k: loadSecret() } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertNotIncludes(result, `setName(loadSecret, "loadSecret")`); + assertEquals(occurrences(result, "KEY"), 0); + assertEquals(occurrences(result, "getEnv"), 0); + }); + + it("keeps metadata when a later loop write can reach the next iteration", async () => { + const code = [ + `const mutatingFactory = () => function (intrinsic) {`, + ` intrinsic.defineProperty = (target) => target;`, + `};`, + `const safeFactory = () => function (_intrinsic) {};`, + `const owner = { make: safeFactory };`, + `for (let index = 0; index < 2; index++) {`, + ` owner.make()(Object);`, + ` owner.make = mutatingFactory;`, + `}`, + `var setName = (target, value) => Object.defineProperty(`, + ` target, "name", { value, configurable: true },`, + `);`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `function loadSecret() { return KEY; }`, + `setName(loadSecret, "loadSecret");`, + `export async function getServerData() { return { props: { k: loadSecret() } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + await assertUnprovableRegistrationRejected(code); + }); + + it("still strips metadata past a write through a shadowing alias parameter", async () => { + const code = [ + `const intrinsic = Object;`, + `const alias = intrinsic;`, + `function configure(alias) { alias.other = 1; }`, + `var setName = (target, value) => Object.defineProperty(`, + ` target, "name", { value, configurable: true },`, + `);`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `function loadSecret() { return KEY; }`, + `setName(loadSecret, "loadSecret");`, + `export async function getServerData() { return { props: { k: loadSecret() } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertNotIncludes(result, `setName(loadSecret, "loadSecret")`); + assertEquals(occurrences(result, "KEY"), 0); + assertEquals(occurrences(result, "getEnv"), 0); + }); + + for (const invocation of ["call(null, Object)", "apply(null, [Object])"]) { + it(`keeps a generator ${invocation} body deferred during mutation analysis`, async () => { + const code = [ + `function recordAndReturn(target) { return target; }`, + `(function* (intrinsic) { intrinsic.defineProperty = recordAndReturn; }).${invocation};`, + `var setName = (target, value) => Object.defineProperty(`, + ` target, "name", { value, configurable: true },`, + `);`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `function loadSecret() { return KEY; }`, + `setName(loadSecret, "loadSecret");`, + `export async function getServerData() { return { props: { k: loadSecret() } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertNotIncludes(result, `setName(loadSecret, "loadSecret")`); + assertEquals(occurrences(result, "KEY"), 0); + assertEquals(occurrences(result, "getEnv"), 0); + }); + } + + for ( + const globalObject of [ + "window.Object", + "self.Object", + "frames.Object", + 'window["Object"]', + "(window as any).Object", + ] + ) { + it(`does not treat an aliased ${globalObject} intrinsic as compiler metadata`, async () => { + const code = [ + `function recordAndReturn(target) {`, + ` globalThis.nameRegistrations = (globalThis.nameRegistrations ?? 0) + 1;`, + ` return target;`, + `}`, + `const intrinsic = ${globalObject};`, + `intrinsic.defineProperty = recordAndReturn;`, + `var setName = (target, value) => Object.defineProperty(`, + ` target, "name", { value, configurable: true },`, + `);`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `function loadSecret() { return KEY; }`, + `setName(loadSecret, "loadSecret");`, + `export async function getServerData() { return { props: { k: loadSecret() } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + await assertUnprovableRegistrationRejected(code); + }); + } + + for ( + const globalObject of [ + "window.window.Object", + "window.self.Object", + "window.frames.Object", + ] + ) { + it(`does not treat nested alias ${globalObject} as compiler metadata`, async () => { + const code = [ + `function recordAndReturn(target) {`, + ` globalThis.nameRegistrations = (globalThis.nameRegistrations ?? 0) + 1;`, + ` return target;`, + `}`, + `const intrinsic = ${globalObject};`, + `intrinsic.defineProperty = recordAndReturn;`, + `var setName = (target, value) => Object.defineProperty(`, + ` target, "name", { value, configurable: true },`, + `);`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `function loadSecret() { return KEY; }`, + `setName(loadSecret, "loadSecret");`, + `export async function getServerData() { return { props: { k: loadSecret() } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + await assertUnprovableRegistrationRejected(code); + }); + } + + it("does not treat an aliased browser global as compiler metadata", async () => { + const code = [ + `function recordAndReturn(target) {`, + ` globalThis.nameRegistrations = (globalThis.nameRegistrations ?? 0) + 1;`, + ` return target;`, + `}`, + `const scope = window;`, + `scope.Object = { defineProperty: recordAndReturn };`, + `var setName = (target, value) => Object.defineProperty(`, + ` target, "name", { value, configurable: true },`, + `);`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `function loadSecret() { return KEY; }`, + `setName(loadSecret, "loadSecret");`, + `export async function getServerData() { return { props: { k: loadSecret() } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + await assertUnprovableRegistrationRejected(code); + }); + + // `typeof window` yields a string, never a reference the module can use to + // reach the intrinsic, so the ubiquitous SSR guard must not stop compiler + // metadata from being pruned. + it("still strips compiler metadata after a typeof window guard", async () => { + const code = [ + `const isBrowser = typeof window !== "undefined" && typeof self !== "undefined";`, + `var setName = (target, value) => Object.defineProperty(`, + ` target, "name", { value, configurable: true },`, + `);`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `function loadSecret() { return KEY; }`, + `setName(loadSecret, "loadSecret");`, + `export async function getServerData() { return { props: { k: loadSecret() } }; }`, + `export default function Page() { return isBrowser ? null : null; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertNotIncludes(result, `setName(loadSecret, "loadSecret")`); + assertEquals(occurrences(result, "KEY"), 0); + assertEquals(occurrences(result, "getEnv"), 0); + assertStringIncludes(result, "typeof window"); + }); + + it("still strips compiler metadata after TypeScript-wrapped typeof guards", async () => { + const code = [ + `const isBrowser = typeof (window as unknown) !== "undefined" && typeof self! !== "undefined";`, + `var setName = (target, value) => Object.defineProperty(`, + ` target, "name", { value, configurable: true },`, + `);`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `function loadSecret() { return KEY; }`, + `setName(loadSecret, "loadSecret");`, + `export async function getServerData() { return { props: { k: loadSecret() } }; }`, + `export default function Page() { return isBrowser ? null : null; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertNotIncludes(result, `setName(loadSecret, "loadSecret")`); + assertEquals(occurrences(result, "KEY"), 0); + assertEquals(occurrences(result, "getEnv"), 0); + assertStringIncludes(result, "typeof (window as unknown)"); + assertStringIncludes(result, "typeof self!"); + }); + + it("preserves member-base context through TypeScript wrappers", async () => { + const code = [ + `const hasDocument = (window as unknown as { document?: unknown }).document !== undefined;`, + `var setName = (target, value) => Object.defineProperty(`, + ` target, "name", { value, configurable: true },`, + `);`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `function loadSecret() { return KEY; }`, + `setName(loadSecret, "loadSecret");`, + `export async function getServerData() { return { props: { k: loadSecret() } }; }`, + `export default function Page() { return hasDocument ? null : null; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertStringIncludes(result, ").document"); + assertNotIncludes(result, `setName(loadSecret, "loadSecret")`); + assertEquals(occurrences(result, "KEY"), 0); + assertEquals(occurrences(result, "getEnv"), 0); + }); + + for (const globalObject of ["globalThis.Object", 'globalThis["Object"]']) { + it(`does not treat an aliased ${globalObject} intrinsic as compiler metadata`, async () => { + const code = [ + `function recordAndReturn(target) {`, + ` globalThis.nameRegistrations = (globalThis.nameRegistrations ?? 0) + 1;`, + ` return target;`, + `}`, + `const intrinsic = ${globalObject};`, + `intrinsic.defineProperty = recordAndReturn;`, + `var setName = (target, value) => Object.defineProperty(`, + ` target, "name", { value, configurable: true },`, + `);`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `function loadSecret() { return KEY; }`, + `setName(loadSecret, "loadSecret");`, + `export async function getServerData() { return { props: { k: loadSecret() } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + await assertUnprovableRegistrationRejected(code); + }); + } + + it("does not treat a hoisted name helper redeclaration as compiler metadata", async () => { + const code = [ + `var setName = (target, value) => Object.defineProperty(`, + ` target, "name", { value, configurable: true },`, + `);`, + `function recordAndReturn(target) {`, + ` globalThis.nameRegistrations = (globalThis.nameRegistrations ?? 0) + 1;`, + ` return target;`, + `}`, + `if (globalThis.patchNames) { var setName = recordAndReturn; }`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `function loadSecret() { return KEY; }`, + `setName(loadSecret, "loadSecret");`, + `export async function getServerData() { return { props: { k: loadSecret() } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertStringIncludes(result, "setName = recordAndReturn"); + assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); + assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + }); + + it("does not treat a Reflect-replaced intrinsic as compiler metadata", async () => { + const code = [ + `function recordAndReturn(target) {`, + ` globalThis.nameRegistrations = (globalThis.nameRegistrations ?? 0) + 1;`, + ` return target;`, + `}`, + `Reflect.defineProperty(Object, "defineProperty", { value: recordAndReturn });`, + `var setName = (target, value) => Object.defineProperty(`, + ` target, "name", { value, configurable: true },`, + `);`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `function loadSecret() { return KEY; }`, + `setName(loadSecret, "loadSecret");`, + `export async function getServerData() { return { props: { k: loadSecret() } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + await assertUnprovableRegistrationRejected(code); + }); + + it("does not treat a globalThis-rooted intrinsic write as compiler metadata", async () => { + const code = [ + `function recordAndReturn(target) {`, + ` globalThis.nameRegistrations = (globalThis.nameRegistrations ?? 0) + 1;`, + ` return target;`, + `}`, + `globalThis.Object.defineProperty = recordAndReturn;`, + `var setName = (target, value) => Object.defineProperty(`, + ` target, "name", { value, configurable: true },`, + `);`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `function loadSecret() { return KEY; }`, + `setName(loadSecret, "loadSecret");`, + `export async function getServerData() { return { props: { k: loadSecret() } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + await assertUnprovableRegistrationRejected(code); + }); + + it("does not treat a replaced globalThis.Object as compiler metadata", async () => { + const code = [ + `function recordAndReturn(target) {`, + ` globalThis.nameRegistrations = (globalThis.nameRegistrations ?? 0) + 1;`, + ` return target;`, + `}`, + `globalThis.Object = { defineProperty: recordAndReturn };`, + `var setName = (target, value) => Object.defineProperty(`, + ` target, "name", { value, configurable: true },`, + `);`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `function loadSecret() { return KEY; }`, + `setName(loadSecret, "loadSecret");`, + `export async function getServerData() { return { props: { k: loadSecret() } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + await assertUnprovableRegistrationRejected(code); + }); + + it("does not treat a defineProperty replacement of globalThis.Object as compiler metadata", async () => { + const code = [ + `function recordAndReturn(target) {`, + ` globalThis.nameRegistrations = (globalThis.nameRegistrations ?? 0) + 1;`, + ` return target;`, + `}`, + `Object.defineProperty(globalThis, "Object", {`, + ` value: { defineProperty: recordAndReturn },`, + ` configurable: true,`, + `});`, + `var setName = (target, value) => Object.defineProperty(`, + ` target, "name", { value, configurable: true },`, + `);`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `function loadSecret() { return KEY; }`, + `setName(loadSecret, "loadSecret");`, + `export async function getServerData() { return { props: { k: loadSecret() } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + await assertUnprovableRegistrationRejected(code); + }); + + it("does not treat a Reflect replacement of globalThis.Object as compiler metadata", async () => { + const code = [ + `function recordAndReturn(target) {`, + ` globalThis.nameRegistrations = (globalThis.nameRegistrations ?? 0) + 1;`, + ` return target;`, + `}`, + `Reflect.defineProperty(globalThis, "Object", {`, + ` value: { defineProperty: recordAndReturn },`, + ` configurable: true,`, + `});`, + `var setName = (target, value) => Object.defineProperty(`, + ` target, "name", { value, configurable: true },`, + `);`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `function loadSecret() { return KEY; }`, + `setName(loadSecret, "loadSecret");`, + `export async function getServerData() { return { props: { k: loadSecret() } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + await assertUnprovableRegistrationRejected(code); + }); + + it("does not treat an aliased global object as compiler metadata", async () => { + const code = [ + `function recordAndReturn(target) {`, + ` globalThis.nameRegistrations = (globalThis.nameRegistrations ?? 0) + 1;`, + ` return target;`, + `}`, + `const scope = globalThis;`, + `scope.Object = { defineProperty: recordAndReturn };`, + `var setName = (target, value) => Object.defineProperty(`, + ` target, "name", { value, configurable: true },`, + `);`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `function loadSecret() { return KEY; }`, + `setName(loadSecret, "loadSecret");`, + `export async function getServerData() { return { props: { k: loadSecret() } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + await assertUnprovableRegistrationRejected(code); + }); + + it("does not treat a computed intrinsic write as compiler metadata", async () => { + const code = [ + `function recordAndReturn(target) {`, + ` globalThis.nameRegistrations = (globalThis.nameRegistrations ?? 0) + 1;`, + ` return target;`, + `}`, + `Object[globalThis.patchedName] = recordAndReturn;`, + `var setName = (target, value) => Object.defineProperty(`, + ` target, "name", { value, configurable: true },`, + `);`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `function loadSecret() { return KEY; }`, + `setName(loadSecret, "loadSecret");`, + `export async function getServerData() { return { props: { k: loadSecret() } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + await assertUnprovableRegistrationRejected(code); + }); + + it("still strips compiler metadata after an unrelated defineProperty write", async () => { + const code = [ + `const registry = {};`, + `registry.defineProperty = (target) => target;`, + `var setName = (target, value) => Object.defineProperty(`, + ` target, "name", { value, configurable: true },`, + `);`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `function loadSecret() { return KEY; }`, + `setName(loadSecret, "loadSecret");`, + `export async function getServerData() { return { props: { k: loadSecret() } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertStringIncludes(result, "registry.defineProperty"); + assertNotIncludes(result, `setName(loadSecret, "loadSecret")`); + assertEquals(occurrences(result, "KEY"), 0); + assertEquals(occurrences(result, "getEnv"), 0); + }); + + it("still strips compiler metadata after a shadowed globalThis write", async () => { + const code = [ + `function configure(globalThis) {`, + ` globalThis.Object = { defineProperty: (target) => target };`, + `}`, + `var setName = (target, value) => Object.defineProperty(`, + ` target, "name", { value, configurable: true },`, + `);`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `function loadSecret() { return KEY; }`, + `setName(loadSecret, "loadSecret");`, + `export async function getServerData() { return { props: { k: loadSecret() } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertStringIncludes(result, "globalThis.Object = {"); + assertNotIncludes(result, `setName(loadSecret, "loadSecret")`); + assertEquals(occurrences(result, "KEY"), 0); + assertEquals(occurrences(result, "getEnv"), 0); + }); + + it("still strips compiler metadata after a shadowed Object write", async () => { + const code = [ + `function configure(Object) {`, + ` Object.defineProperty = (target) => target;`, + `}`, + `var setName = (target, value) => Object.defineProperty(`, + ` target, "name", { value, configurable: true },`, + `);`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `function loadSecret() { return KEY; }`, + `setName(loadSecret, "loadSecret");`, + `export async function getServerData() { return { props: { k: loadSecret() } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertStringIncludes(result, "Object.defineProperty ="); + assertNotIncludes(result, `setName(loadSecret, "loadSecret")`); + assertEquals(occurrences(result, "KEY"), 0); + assertEquals(occurrences(result, "getEnv"), 0); + }); + + it("still strips compiler metadata after a shadowed Object assignment", async () => { + const code = [ + `function configure(Object) {`, + ` Object = { defineProperty: (target) => target };`, + `}`, + `var setName = (target, value) => Object.defineProperty(`, + ` target, "name", { value, configurable: true },`, + `);`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `function loadSecret() { return KEY; }`, + `setName(loadSecret, "loadSecret");`, + `export async function getServerData() { return { props: { k: loadSecret() } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertStringIncludes(result, "Object = {"); + assertNotIncludes(result, `setName(loadSecret, "loadSecret")`); + assertEquals(occurrences(result, "KEY"), 0); + assertEquals(occurrences(result, "getEnv"), 0); + }); + + // A TypeScript type wrapper around the operand of `typeof` still yields a + // string, so the guard must read the same as the untyped form. + for (const guard of ["(window as unknown)", "window!", "( window)"]) { + it(`still strips compiler metadata after a typeof ${guard} guard`, async () => { + const code = [ + `const isBrowser = typeof ${guard} !== "undefined";`, + `var setName = (target, value) => Object.defineProperty(`, + ` target, "name", { value, configurable: true },`, + `);`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `function loadSecret() { return KEY; }`, + `setName(loadSecret, "loadSecret");`, + `export async function getServerData() { return { props: { k: loadSecret() } }; }`, + `export default function Page() { return isBrowser ? null : null; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code, "pages/guard.ts"); + + assertNotIncludes(result, `setName(loadSecret, "loadSecret")`); + assertEquals(occurrences(result, "KEY"), 0); + assertEquals(occurrences(result, "getEnv"), 0); + assertStringIncludes(result, "typeof"); + }); + } + + // `frames`, `parent`, `top`, and `document.defaultView` reach the same + // window as `window` does in a main browsing context. + for ( + const globalObject of [ + "frames.Object", + "parent.Object", + "top.Object", + "document.defaultView.Object", + ] + ) { + it(`does not treat an aliased ${globalObject} intrinsic as compiler metadata`, async () => { + const code = [ + `function recordAndReturn(target) {`, + ` globalThis.nameRegistrations = (globalThis.nameRegistrations ?? 0) + 1;`, + ` return target;`, + `}`, + `const intrinsic = ${globalObject};`, + `intrinsic.defineProperty = recordAndReturn;`, + `var setName = (target, value) => Object.defineProperty(`, + ` target, "name", { value, configurable: true },`, + `);`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `function loadSecret() { return KEY; }`, + `setName(loadSecret, "loadSecret");`, + `export async function getServerData() { return { props: { k: loadSecret() } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + await assertUnprovableRegistrationRejected(code); + }); + } + + // A TypeScript namespace emits runtime code, so its body can hold the + // intrinsic in a slot the module writes through later. + for (const keyword of ["namespace", "module"]) { + it(`does not treat a ${keyword} that holds the intrinsic as compiler metadata`, async () => { + const code = [ + `function recordAndReturn(target) {`, + ` globalThis.nameRegistrations = (globalThis.nameRegistrations ?? 0) + 1;`, + ` return target;`, + `}`, + `${keyword} Patch { export const intrinsic = globalThis.Object; }`, + `Patch.intrinsic.defineProperty = recordAndReturn;`, + `var setName = (target, value) => Object.defineProperty(`, + ` target, "name", { value, configurable: true },`, + `);`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `function loadSecret() { return KEY; }`, + `setName(loadSecret, "loadSecret");`, + `export async function getServerData() { return { props: { k: loadSecret() } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + await assertUnprovableRegistrationRejected(code); + }); + } + + it("does not treat a namespace-held intrinsic alias as compiler metadata", async () => { + const code = [ + `function recordAndReturn(target) {`, + ` globalThis.nameRegistrations = (globalThis.nameRegistrations ?? 0) + 1;`, + ` return target;`, + `}`, + `namespace Patch { export const intrinsic = Object; }`, + `Patch.intrinsic.defineProperty = recordAndReturn;`, + `var setName = (target, value) => Object.defineProperty(`, + ` target, "name", { value, configurable: true },`, + `);`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `function loadSecret() { return KEY; }`, + `setName(loadSecret, "loadSecret");`, + `export async function getServerData() { return { props: { k: loadSecret() } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + await assertUnprovableRegistrationRejected(code); + }); + + // A read that only hands the intrinsic to a callee, spreads it, or binds a + // name nothing writes through cannot replace `Object.defineProperty`, and + // these shapes are everywhere in ordinary client code. Treating them as + // escapes retained the helper, its hook-only initialiser, and the server + // import that fed it. + for ( + const [label, read] of [ + ["a plain global alias", `const scope = window;`], + [ + "a global alias inside a client callback", + `function useBrowser() { useEffect(() => { const scope = window; return scope.name; }); }`, + ], + ["an Object.assign onto the global", `Object.assign(globalThis, {});`], + ["a spread of the global", `const snapshot = { ...window };`], + ["the global passed to a callee", `report(globalThis);`], + ["the intrinsic passed as a callback", `const kinds = [].map(Object);`], + ["an ordinary constructor comparison", `const plain = value?.constructor === Object;`], + ["constructor-name logging", `const errorName = error.constructor.name;`], + ["an ordinary __proto__ read", `const prototype = value.__proto__;`], + ["an instanceof Function check", `const callable = value instanceof Function;`], + ["a typeof eval check", `const evalType = typeof eval;`], + ] + ) { + it(`still strips compiler metadata past ${label}`, async () => { + const code = [ + `import { useEffect } from "react";`, + `import { report } from "./report.ts";`, + read, + `var setName = (target, value) => Object.defineProperty(`, + ` target, "name", { value, configurable: true },`, + `);`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `function loadSecret() { return KEY; }`, + `setName(loadSecret, "loadSecret");`, + `export async function getServerData() { return { props: { k: loadSecret() } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertNotIncludes(result, `setName(loadSecret, "loadSecret")`); + assertEquals(occurrences(result, "KEY"), 0); + assertEquals(occurrences(result, "getEnv"), 0); + assertNotIncludes(result, "SECRET_KEY"); + }); + } + + // The prototype chain and the `Function` constructor reach the intrinsic + // without ever naming `Object` as a value. The recognised set does not + // admit a module that carries either route. + for ( + const [label, route] of [ + [ + "an object literal's constructor", + `({}).constructor.defineProperty = recordAndReturn;`, + ], + [ + "a prototype's constructor", + `Object.getPrototypeOf({}).constructor.defineProperty = recordAndReturn;`, + ], + [ + "the Function constructor", + `"".constructor.constructor(` + + `"globalThis.Object.defineProperty = arguments[0]"` + + `)(recordAndReturn);`, + ], + [ + "an aliased Function constructor", + [ + `const compile = Function;`, + `compile("globalThis.Object.defineProperty = arguments[0]")(`, + ` recordAndReturn,`, + `);`, + ].join("\n"), + ], + [ + "aliased eval", + [ + `const run = eval;`, + `run("globalThis.Object.defineProperty = (target) => target");`, + ].join("\n"), + ], + [ + "global-object eval", + `globalThis.eval("Object.defineProperty = (target) => target");`, + ], + [ + "global-object Function constructor", + `window.Function("Object.defineProperty = (target) => target")();`, + ], + [ + "destructured global-object eval", + [ + `const { eval: run } = globalThis;`, + `run("Object.defineProperty = (target) => target");`, + ].join("\n"), + ], + [ + "array-destructured global-object eval", + [ + `const [run] = [globalThis.eval];`, + `run("Object.defineProperty = (target) => target");`, + ].join("\n"), + ], + [ + "defaulted array-destructured global-object eval", + [ + `const [run = globalThis.eval] = [];`, + `run("Object.defineProperty = (target) => target");`, + ].join("\n"), + ], + [ + "defaulted array destructuring from an initially undefined binding", + [ + `let value;`, + `const [run = globalThis.eval] = [value];`, + `value = () => {};`, + `run("Object.defineProperty = (target) => target");`, + ].join("\n"), + ], + ] + ) { + it(`does not treat a module reaching the intrinsic through ${label} as compiler metadata`, async () => { + const code = [ + `function recordAndReturn(target) {`, + ` globalThis.nameRegistrations = (globalThis.nameRegistrations ?? 0) + 1;`, + ` return target;`, + `}`, + route, + `var setName = (target, value) => Object.defineProperty(`, + ` target, "name", { value, configurable: true },`, + `);`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `function loadSecret() { return KEY; }`, + `setName(loadSecret, "loadSecret");`, + `export async function getServerData() { return { props: { k: loadSecret() } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + await assertUnprovableRegistrationRejected(code); + }); + } + + // The recognised set has to admit what real compiled modules contain. A + // CommonJS interop prologue calls the intrinsic on its own exports object, + // and ordinary code writes computed keys on locals all the time; neither + // can reach `Object.defineProperty`, so neither may cost the module its + // compiler metadata. + for ( + const [label, line] of [ + [ + "a CommonJS interop marker", + `Object.defineProperty(exports, "__esModule", { value: true });`, + ], + ["a computed write on a local", `const bag = {};\nfor (const k of ["a"]) { bag[k] = 1; }`], + ["an index write on a local array", `const arr = [];\narr[0] = 1;`], + ["a member write on an instance", `class Box { fill() { this.items = []; } }`], + [ + "an intrinsic held only by a shadowed nested binding", + [ + `var intrinsic = {};`, + `function getIntrinsic() { const intrinsic = Object; return intrinsic; }`, + `intrinsic.defineProperty = () => {};`, + ].join("\n"), + ], + ] + ) { + it(`still strips compiler metadata past ${label}`, async () => { + const code = [ + line, + `var setName = (target, value) => Object.defineProperty(`, + ` target, "name", { value, configurable: true },`, + `);`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `function loadSecret() { return KEY; }`, + `setName(loadSecret, "loadSecret");`, + `export async function getServerData() { return { props: { k: loadSecret() } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertNotIncludes(result, `setName(loadSecret, "loadSecret")`); + assertEquals(occurrences(result, "KEY"), 0); + assertEquals(occurrences(result, "getEnv"), 0); + }); + } + + for ( + const [label, mutation] of [ + [ + "a dynamic intrinsic property name", + [ + `const propertyName = "defineProperty";`, + `Object.defineProperty(`, + ` Object, propertyName, { value: recordAndReturn },`, + `);`, + ].join("\n"), + ], + [ + "a Reflect.apply intrinsic mutation", + `Reflect.apply(` + + `Object.defineProperty, null, ` + + `[Object, "defineProperty", { value: recordAndReturn }])`, + ], + [ + "a Reflect.apply function-literal mutation", + [ + `Reflect.apply(function (intrinsic) {`, + ` intrinsic.defineProperty = recordAndReturn;`, + `}, null, [Object]);`, + ].join("\n"), + ], + [ + "a Reflect.apply sequence-wrapped function-literal mutation", + [ + `Reflect.apply((0, function (intrinsic) {`, + ` intrinsic.defineProperty = recordAndReturn;`, + `}), null, [Object]);`, + ].join("\n"), + ], + [ + "an immediately advanced generator mutation", + [ + `(function* (intrinsic) {`, + ` intrinsic.defineProperty = recordAndReturn;`, + `})(Object).next();`, + ].join("\n"), + ], + [ + "a stored and advanced generator mutation", + [ + `const iterator = (function* (intrinsic) {`, + ` intrinsic.defineProperty = recordAndReturn;`, + `})(Object);`, + `iterator.next();`, + ].join("\n"), + ], + [ + "a call-wrapped stored generator advancement", + [ + `const iterator = (function* (intrinsic) {`, + ` intrinsic.defineProperty = recordAndReturn;`, + `})(Object);`, + `iterator.next.call(iterator);`, + ].join("\n"), + ], + [ + "a Reflect.apply-wrapped stored generator advancement", + [ + `const iterator = (function* (intrinsic) {`, + ` intrinsic.defineProperty = recordAndReturn;`, + `})(Object);`, + `Reflect.apply(iterator.next, iterator, []);`, + ].join("\n"), + ], + [ + "a transitively stored and advanced generator mutation", + [ + `const iterator = (function* (intrinsic) {`, + ` intrinsic.defineProperty = recordAndReturn;`, + `})(Object);`, + `const iteratorAlias = iterator;`, + `iteratorAlias.next();`, + ].join("\n"), + ], + [ + "an assigned and advanced generator mutation", + [ + `let iterator;`, + `iterator = (function* (intrinsic) {`, + ` intrinsic.defineProperty = recordAndReturn;`, + `})(Object);`, + `iterator.next();`, + ].join("\n"), + ], + [ + "a named and advanced generator mutation", + [ + `function* mutateIntrinsic(intrinsic) {`, + ` intrinsic.defineProperty = recordAndReturn;`, + `}`, + `const iterator = mutateIntrinsic(Object);`, + `iterator.next();`, + ].join("\n"), + ], + [ + "a delegated generator mutation", + [ + `(function* (outer) {`, + ` yield* (function* (intrinsic) {`, + ` intrinsic.defineProperty = recordAndReturn;`, + ` })(outer);`, + `})(Object).next();`, + ].join("\n"), + ], + [ + "a direct class-constructor mutation", + [ + `new (class {`, + ` constructor(intrinsic) {`, + ` intrinsic.defineProperty = recordAndReturn;`, + ` }`, + `})(Object);`, + ].join("\n"), + ], + [ + "an aliased class-constructor mutation", + [ + `const Mutator = class {`, + ` constructor(intrinsic) {`, + ` intrinsic.defineProperty = recordAndReturn;`, + ` }`, + `};`, + `new Mutator(Object);`, + ].join("\n"), + ], + [ + "a named class-declaration constructor mutation", + [ + `class Mutator {`, + ` constructor(intrinsic) {`, + ` intrinsic.defineProperty = recordAndReturn;`, + ` }`, + `}`, + `new Mutator(Object);`, + ].join("\n"), + ], + [ + "an inherited implicit-constructor mutation", + [ + `class Base {`, + ` constructor(intrinsic) {`, + ` intrinsic.defineProperty = recordAndReturn;`, + ` }`, + `}`, + `class Mutator extends Base {}`, + `new Mutator(Object);`, + ].join("\n"), + ], + [ + "a spread-consumed generator mutation", + [ + `[...(function* (intrinsic) {`, + ` intrinsic.defineProperty = recordAndReturn;`, + `})(Object)];`, + ].join("\n"), + ], + [ + "a for-of-consumed generator mutation", + [ + `for (const unused of (function* (intrinsic) {`, + ` intrinsic.defineProperty = recordAndReturn;`, + `})(Object)) { void unused; }`, + ].join("\n"), + ], + [ + "a destructuring-consumed generator mutation", + [ + `const [unused] = (function* (intrinsic) {`, + ` intrinsic.defineProperty = recordAndReturn;`, + ` yield 1;`, + `})(Object);`, + `void unused;`, + ].join("\n"), + ], + [ + "an Array.from-consumed generator mutation", + [ + `Array.from((function* (intrinsic) {`, + ` intrinsic.defineProperty = recordAndReturn;`, + `})(Object));`, + ].join("\n"), + ], + [ + "a Set-consumed generator mutation", + [ + `new Set((function* (intrinsic) {`, + ` intrinsic.defineProperty = recordAndReturn;`, + `})(Object));`, + ].join("\n"), + ], + [ + "an assignment-destructuring-consumed generator mutation", + [ + `let unused;`, + `[unused] = (function* (intrinsic) {`, + ` intrinsic.defineProperty = recordAndReturn;`, + ` yield 1;`, + `})(Object);`, + `void unused;`, + ].join("\n"), + ], + [ + "an aliased intrinsic mutator", + [ + `const mutate = Object.defineProperty;`, + `mutate(Object, "defineProperty", { value: recordAndReturn });`, + ].join("\n"), + ], + [ + "a transitive intrinsic mutator alias", + [ + `const mutate = Object.defineProperty;`, + `const transitiveMutate = mutate;`, + `transitiveMutate(Object, "defineProperty", { value: recordAndReturn });`, + ].join("\n"), + ], + [ + "a bound intrinsic mutator alias", + [ + `const mutate = Object.defineProperty.bind(Object);`, + `mutate(Object, "defineProperty", { value: recordAndReturn });`, + ].join("\n"), + ], + [ + "a destructured intrinsic mutator alias", + [ + `const { defineProperty: mutate } = Object;`, + `mutate(Object, "defineProperty", { value: recordAndReturn });`, + ].join("\n"), + ], + [ + "a Reflect-destructured intrinsic mutator alias", + [ + `const { defineProperty: mutate } = Reflect;`, + `mutate(Object, "defineProperty", { value: recordAndReturn });`, + ].join("\n"), + ], + [ + "an Object-alias-destructured intrinsic mutator", + [ + `const intrinsic = Object;`, + `const { defineProperty: mutate } = intrinsic;`, + `mutate(Object, "defineProperty", { value: recordAndReturn });`, + ].join("\n"), + ], + [ + "an awaited Object-alias-destructured intrinsic mutator", + [ + `const intrinsic = await Object;`, + `const { defineProperty: mutate } = intrinsic;`, + `mutate(Object, "defineProperty", { value: recordAndReturn });`, + ].join("\n"), + ], + [ + "a global-destructured intrinsic container mutator", + [ + `const { Object: intrinsic } = globalThis;`, + `const { defineProperty: mutate } = intrinsic;`, + `mutate(Object, "defineProperty", { value: recordAndReturn });`, + ].join("\n"), + ], + [ + "a reflected constructor alias written through", + [ + `const intrinsic = ({}).constructor;`, + `intrinsic.defineProperty = recordAndReturn;`, + ].join("\n"), + ], + ] + ) { + it(`does not treat ${label} as compiler metadata`, async () => { + const code = [ + `function recordAndReturn(target) {`, + ` globalThis.nameRegistrations = (globalThis.nameRegistrations ?? 0) + 1;`, + ` return target;`, + `}`, + mutation, + `var setName = (target, value) => Object.defineProperty(`, + ` target, "name", { value, configurable: true },`, + `);`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `function loadSecret() { return KEY; }`, + `setName(loadSecret, "loadSecret");`, + `export async function getServerData() { return { props: { k: loadSecret() } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + await assertUnprovableRegistrationRejected(code); + }); + } + + it("still strips compiler metadata when one next call stops before a delegated mutation", async () => { + const code = [ + `function recordAndReturn(target) {`, + ` globalThis.nameRegistrations = (globalThis.nameRegistrations ?? 0) + 1;`, + ` return target;`, + `}`, + `const iterator = (function* (outer) {`, + ` yield 1;`, + ` yield* (function* (intrinsic) {`, + ` intrinsic.defineProperty = recordAndReturn;`, + ` })(outer);`, + `})(Object);`, + `iterator.next();`, + `var setName = (target, value) => Object.defineProperty(`, + ` target, "name", { value, configurable: true },`, + `);`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `function loadSecret() { return KEY; }`, + `setName(loadSecret, "loadSecret");`, + `export async function getServerData() { return { props: { k: loadSecret() } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertNotIncludes(result, `setName(loadSecret, "loadSecret")`); + assertEquals(occurrences(result, "KEY"), 0); + assertEquals(occurrences(result, "getEnv"), 0); + assertNotIncludes(result, "SECRET_KEY"); + }); + + it("still strips compiler metadata when one next call stops at a nested yield", async () => { + const code = [ + `function recordAndReturn(target) {`, + ` globalThis.nameRegistrations = (globalThis.nameRegistrations ?? 0) + 1;`, + ` return target;`, + `}`, + `const iterator = (function* (outer) {`, + ` if (true) yield 1;`, + ` yield* (function* (intrinsic) {`, + ` intrinsic.defineProperty = recordAndReturn;`, + ` })(outer);`, + `})(Object);`, + `iterator.next();`, + `var setName = (target, value) => Object.defineProperty(`, + ` target, "name", { value, configurable: true },`, + `);`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `function loadSecret() { return KEY; }`, + `setName(loadSecret, "loadSecret");`, + `export async function getServerData() { return { props: { k: loadSecret() } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertNotIncludes(result, `setName(loadSecret, "loadSecret")`); + assertEquals(occurrences(result, "KEY"), 0); + assertEquals(occurrences(result, "getEnv"), 0); + assertNotIncludes(result, "SECRET_KEY"); + }); + + // `defineProperties` installs a descriptor map's keys on its target just as + // `assign` copies a source's own keys, so a map this stage cannot read key + // by key leaves the replacement invisible. + // A module-scope `var` is bound where the enclosing body prebinds its + // direct declarations and again in the var scope, so a write through it + // used to resolve to a different binding than its own declaration. + for ( + const [label, lines] of [ + [ + "a var intrinsic alias", + [`var intrinsic = Object;`, `intrinsic.defineProperty = recordAndReturn;`], + ], + [ + "a var alias declared inside a block", + [ + `if (globalThis.patch) { var hoistedAlias = Object; }`, + `hoistedAlias.defineProperty = recordAndReturn;`, + ], + ], + [ + "a var alias written through from a function", + [ + `var deferredAlias = Object;`, + `function applyPatch() { deferredAlias.defineProperty = recordAndReturn; }`, + `applyPatch();`, + ], + ], + ] as const + ) { + it(`does not treat ${label} as compiler metadata`, async () => { + const code = [ + `function recordAndReturn(target) {`, + ` globalThis.nameRegistrations = (globalThis.nameRegistrations ?? 0) + 1;`, + ` return target;`, + `}`, + ...lines, + `var setName = (target, value) => Object.defineProperty(`, + ` target, "name", { value, configurable: true },`, + `);`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `function loadSecret() { return KEY; }`, + `setName(loadSecret, "loadSecret");`, + `export async function getServerData() { return { props: { k: loadSecret() } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + await assertUnprovableRegistrationRejected(code); + }); + } + + // `f.call.call(f, …)` invokes `f` with one more receiver peeled off, so + // unwrapping a single wrapper leaves `f.call` as the apparent callee. + for ( + const [label, invocation] of [ + [ + "a nested call wrapper", + `Object.defineProperty.call.call(` + + `Object.defineProperty, null, Object, "defineProperty", { value: recordAndReturn })`, + ], + [ + "a nested apply wrapper", + `Object.defineProperty.call.apply(` + + `Object.defineProperty, [null, Object, "defineProperty", { value: recordAndReturn }])`, + ], + ] + ) { + it(`does not treat ${label} on the intrinsic as compiler metadata`, async () => { + const code = [ + `function recordAndReturn(target) {`, + ` globalThis.nameRegistrations = (globalThis.nameRegistrations ?? 0) + 1;`, + ` return target;`, + `}`, + `${invocation};`, + `var setName = (target, value) => Object.defineProperty(`, + ` target, "name", { value, configurable: true },`, + `);`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `function loadSecret() { return KEY; }`, + `setName(loadSecret, "loadSecret");`, + `export async function getServerData() { return { props: { k: loadSecret() } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + await assertUnprovableRegistrationRejected(code); + }); + } + + // A spread hides how many sources a merge takes, not what its target is. + // A merge onto a fresh local cannot reach the intrinsic however many + // unreadable sources follow, so it must not cost the module its metadata. + it("still strips compiler metadata past a spread merge onto a fresh target", async () => { + const code = [ + `Object.assign.call(null, {}, ...[]);`, + `var setName = (target, value) => Object.defineProperty(`, + ` target, "name", { value, configurable: true },`, + `);`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `function loadSecret() { return KEY; }`, + `setName(loadSecret, "loadSecret");`, + `export async function getServerData() { return { props: { k: loadSecret() } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertNotIncludes(result, `setName(loadSecret, "loadSecret")`); + assertEquals(occurrences(result, "KEY"), 0); + assertEquals(occurrences(result, "getEnv"), 0); + }); + + it("does not treat a spread merge onto the global object as compiler metadata", async () => { + const code = [ + `function recordAndReturn(target) {`, + ` globalThis.nameRegistrations = (globalThis.nameRegistrations ?? 0) + 1;`, + ` return target;`, + `}`, + `Object.assign(globalThis, ...[{ Object: recordAndReturn }]);`, + `var setName = (target, value) => Object.defineProperty(`, + ` target, "name", { value, configurable: true },`, + `);`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `function loadSecret() { return KEY; }`, + `setName(loadSecret, "loadSecret");`, + `export async function getServerData() { return { props: { k: loadSecret() } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + await assertUnprovableRegistrationRejected(code); + }); + + it("does not treat named descriptors installed on the intrinsic as compiler metadata", async () => { + const code = [ + `function recordAndReturn(target) {`, + ` globalThis.nameRegistrations = (globalThis.nameRegistrations ?? 0) + 1;`, + ` return target;`, + `}`, + `const descriptors = { defineProperty: { value: recordAndReturn } };`, + `Object.defineProperties(Object, descriptors);`, + `var setName = (target, value) => Object.defineProperty(`, + ` target, "name", { value, configurable: true },`, + `);`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `function loadSecret() { return KEY; }`, + `setName(loadSecret, "loadSecret");`, + `export async function getServerData() { return { props: { k: loadSecret() } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + await assertUnprovableRegistrationRejected(code); + }); + + it("does not treat an eval of a replacement as compiler metadata", async () => { + const code = [ + `eval("globalThis.Object.defineProperty = (target) => target");`, + `var setName = (target, value) => Object.defineProperty(`, + ` target, "name", { value, configurable: true },`, + `);`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `function loadSecret() { return KEY; }`, + `setName(loadSecret, "loadSecret");`, + `export async function getServerData() { return { props: { k: loadSecret() } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + await assertUnprovableRegistrationRejected(code); + }); + + // A `function` declaration and a `var` cannot share a name in a module: + // the redeclaration is a SyntaxError, so a hoisted user function can never + // be the live binding when a later `var` initialiser classifies it. The + // build stops on the parse failure rather than analysing the module. + it("fails the build when a name helper redeclares a function declaration", async () => { + const code = [ + `function setName(target, value) {`, + ` globalThis.nameRegistrations = (globalThis.nameRegistrations ?? 0) + 1;`, + ` return target;`, + `}`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `function loadSecret() { return KEY; }`, + `setName(loadSecret, "loadSecret");`, + `var setName = (target, value) => Object.defineProperty(`, + ` target, "name", { value, configurable: true },`, + `);`, + `export async function getServerData() { return { props: { k: loadSecret() } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + const error = await assertRejects(() => stripServerOnlyExports(code, "pages/x.tsx")); + + assertStringIncludes((error as Error).message, "setName"); + }); + + it("keeps a helper call made before an exported var redeclaration", async () => { + const code = [ + `function recordAndReturn(target) {`, + ` globalThis.nameRegistrations = (globalThis.nameRegistrations ?? 0) + 1;`, + ` return target;`, + `}`, + `export var setName = recordAndReturn;`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `function loadSecret() { return KEY; }`, + `setName(loadSecret, "loadSecret");`, + `var setName = (target, value) => Object.defineProperty(`, + ` target, "name", { value, configurable: true },`, + `);`, + `export async function getServerData() { return { props: { k: loadSecret() } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertStringIncludes(result, "export var setName = recordAndReturn"); + assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); + assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + }); + + it("fails the build when an intrinsic alias redeclares a function declaration", async () => { + const code = [ + `function defineName(target, key, descriptor) {`, + ` globalThis.nameRegistrations = (globalThis.nameRegistrations ?? 0) + 1;`, + ` return target;`, + `}`, + `var setName = (target, value) => defineName(`, + ` target, "name", { value, configurable: true },`, + `);`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `function loadSecret() { return KEY; }`, + `setName(loadSecret, "loadSecret");`, + `var defineName = Object.defineProperty;`, + `export async function getServerData() { return { props: { k: loadSecret() } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + const error = await assertRejects(() => stripServerOnlyExports(code, "pages/x.tsx")); + + assertStringIncludes((error as Error).message, "defineName"); + }); + + // A chain fully feeds the hook: dropping one dead binding frees the next. + it("drops a chain of module-scope bindings that only fed a stripped hook", async () => { + const code = [ + `import { getEnv } from "veryfront";`, + `const RAW = getEnv("TOKEN");`, + `const TOKEN = RAW.trim();`, + `export async function getServerData() { return { props: { t: TOKEN } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertEquals(occurrences(result, "RAW"), 0); + assertEquals(occurrences(result, "TOKEN"), 0); + assertEquals(occurrences(result, "getEnv"), 0); + }); + + // The hook can be an arrow assigned to `const` — its closure must be + // captured the same way as a `function` declaration before it is emptied. + it("prunes the closure of a const-arrow hook form", async () => { + const code = [ + `import { getEnv } from "veryfront";`, + `const API_KEY = getEnv("SECRET_KEY");`, + `export const getServerData = async () => ({ props: { ok: Boolean(API_KEY) } });`, + `export default function Page() { return null; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertEquals(occurrences(result, "API_KEY"), 0); + assertEquals(occurrences(result, "getEnv"), 0); + }); + + // A module-scope helper *function* reached only from the hook is part of its + // closure and goes; the same helper is kept the moment client code uses it. + it("prunes a helper function only the hook used, keeps it when the client uses it", async () => { + const onlyHook = [ + `import { getEnv } from "veryfront";`, + `function computeKey() { return getEnv("SECRET_KEY"); }`, + `export async function getServerData() { return { props: { k: computeKey() } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + const strippedOnlyHook = await stripServerOnlyExports(onlyHook); + assertEquals(occurrences(strippedOnlyHook, "computeKey"), 0); + assertEquals(occurrences(strippedOnlyHook, "getEnv"), 0); + + const shared = [ + `function fmt(x) { return String(x); }`, + `export async function getServerData() { return { props: { k: fmt(1) } }; }`, + `export default function Page() { return fmt(2); }`, + ].join("\n"); + const strippedShared = await stripServerOnlyExports(shared); + assertStringIncludes(strippedShared, "function fmt"); + }); + + // Production release modules have already passed through esbuild with + // `keepNames`, which emits a top-level name-registration call for every + // function. That compiler metadata must not turn a hook-only helper into a + // browser reference and keep its server import graph alive. + it("prunes hook-only helpers from compiled keepNames output", async () => { + const code = [ + `var defineName = Object.defineProperty;`, + `var setName = (target, value) => defineName(target, "name", { value, configurable: true });`, + `import { getActivity } from "../lib/api.js";`, + `async function loadReview() { return getActivity(); }`, + `setName(loadReview, "getReviewProps");`, + `function loadStatic() { return loadReview(); }`, + `setName(loadStatic, "getStaticData");`, + `function loadServer() { return loadReview(); }`, + `setName(loadServer, "getServerData");`, + `function Page() { return null; }`, + `setName(Page, "Page");`, + `export { Page as default, loadServer as getServerData, loadStatic as getStaticData };`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertNotIncludes(result, "../lib/api.js"); + assertEquals(occurrences(result, "getActivity"), 0); + assertEquals(occurrences(result, "loadReview"), 0); + assertStringIncludes(result, `setName(Page, "Page")`); + }); + + it("keeps helpers consumed by ordinary top-level registration", async () => { + const code = [ + `import { getActivity } from "../lib/api.js";`, + `async function loadReview() { return getActivity(); }`, + `registerClientHandler(loadReview, "review-loader");`, + `function loadServer() { return loadReview(); }`, + `export { loadServer as getServerData };`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertStringIncludes(result, "../lib/api.js"); + assertStringIncludes(result, "registerClientHandler(loadReview"); + }); + + // A chain member the client also reads is kept even though a later link in + // the chain (used only by the hook) is dropped. + it("keeps a chain member the client reads while dropping the hook-only tail", async () => { + const code = [ + `import { getEnv } from "veryfront";`, + `const RAW = getEnv("X");`, + `const TOKEN = RAW + "!";`, + `export async function getServerData() { return { props: { t: TOKEN } }; }`, + `export default function Page() { return RAW; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertStringIncludes(result, "RAW"); // client reads it → kept (with its import) + assertStringIncludes(result, "getEnv"); + assertEquals(occurrences(result, "TOKEN"), 0); // hook-only tail → dropped + }); + + // Regression (closed leak): a *destructured* module-scope server value used + // only by a stripped hook used to survive into the browser output, because + // the declaration collector handled only simple identifiers. The pattern is + // now a removal candidate as a whole, so the binding, the initialiser call + // and the import it was the last user of all go. This is also the case + // esbuild's tree-shaker can never close: a destructuring of a call (even a + // `@__PURE__`-annotated one) is kept in both transform and bundle mode + // because the pattern may trigger getters or throw. + it("drops a destructured module-scope server value used only by a stripped hook", async () => { + const code = [ + `import { getEnv } from "veryfront";`, + `const { a } = getEnv("X");`, + `export async function getServerData() { return { props: { a } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertEquals(occurrences(result, "a"), 0); + assertEquals(occurrences(result, "getEnv"), 0); + assertNotIncludes(result, `"veryfront"`); + assertNotIncludes(result, `"X"`); + }); + + it("drops a destructured server secret and the import it was the last user of", async () => { + const code = [ + `import { getEnv } from "veryfront";`, + `const { apiKey, region } = getEnv("SECRET_CONFIG");`, + `export async function getServerData() { return { props: { ok: Boolean(apiKey), region } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertEquals(occurrences(result, "apiKey"), 0); + assertEquals(occurrences(result, "region"), 0); + assertNotIncludes(result, "SECRET_CONFIG"); + assertEquals(occurrences(result, "getEnv"), 0); + }); + + it("drops an array-pattern server value used only by a stripped hook", async () => { + const code = [ + `import { loadKeys } from "../server/keys.ts";`, + `const [primaryKey] = loadKeys();`, + `export async function getServerData() { return { props: { primaryKey } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertEquals(occurrences(result, "primaryKey"), 0); + assertEquals(occurrences(result, "loadKeys"), 0); + assertNotIncludes(result, "../server/keys.ts"); + }); + + it("drops a rest-pattern server value used only by a stripped hook", async () => { + const code = [ + `import { getEnv } from "veryfront";`, + `const { token, ...serverConfig } = getEnv("CFG");`, + `export async function getServerData() { return { props: { token, serverConfig } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertEquals(occurrences(result, "token"), 0); + assertEquals(occurrences(result, "serverConfig"), 0); + assertEquals(occurrences(result, "getEnv"), 0); + }); + + // Contrast pin: a pattern is removed only as a whole. When the client still + // reads one of its bindings, the whole declarator (and its import) stay. + it("keeps a destructured value the client component also reads", async () => { + const code = [ + `import { getEnv } from "veryfront";`, + `const { apiKey, region } = getEnv("CFG");`, + `export async function getServerData() { return { props: { ok: Boolean(apiKey) } }; }`, + `export default function Page() { return region; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertStringIncludes(result, "region"); + assertStringIncludes(result, "apiKey"); + assertStringIncludes(result, "getEnv"); + }); + + it("drops a destructured server value when client code only shadows its binding", async () => { + const code = [ + `import { getEnv } from "veryfront";`, + `const { apiKey } = getEnv("SERVER_SECRET_CFG");`, + `export async function getServerData() { return { props: { apiKey } }; }`, + `export default function Page() { const apiKey = "public"; return apiKey; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertStringIncludes(result, 'apiKey = "public"'); + assertNotIncludes(result, "SERVER_SECRET_CFG"); + assertNotIncludes(result, "getEnv"); + assertNotIncludes(result, '"veryfront"'); + }); + + it("drops a hook-only chain when client code shadows an intermediate helper", async () => { + const code = [ + `import { getEnv } from "veryfront";`, + `const { raw } = getEnv("SERVER_SECRET_CFG");`, + `function formatSecret() { return raw.trim(); }`, + `export async function getServerData() { return { props: { value: formatSecret() } }; }`, + `export default function Page() { const formatSecret = () => "public"; return formatSecret(); }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertStringIncludes(result, 'formatSecret = () => "public"'); + assertNotIncludes(result, "SERVER_SECRET_CFG"); + assertNotIncludes(result, "raw.trim"); + assertNotIncludes(result, "getEnv"); + assertNotIncludes(result, '"veryfront"'); + }); + + it("drops a hook-only import when client code shadows the imported binding", async () => { + const code = [ + `import { loadSecret } from "../server/secrets.ts";`, + `const secret = loadSecret();`, + `export async function getServerData() { return { props: { secret } }; }`, + `export default function Page() { const loadSecret = () => "public"; return loadSecret(); }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertStringIncludes(result, 'loadSecret = () => "public"'); + assertNotIncludes(result, "../server/secrets.ts"); + assertNotIncludes(result, "const secret ="); + }); + + it("drops a hook-only import shadowed by a named client class expression", async () => { + const code = [ + `import { loadSecret } from "../server/secrets.ts";`, + `const secret = loadSecret();`, + `export async function getServerData() { return { props: { secret } }; }`, + `export default function Page() {`, + ` const ClientValue = class loadSecret { static self = loadSecret; };`, + ` return ClientValue.self;`, + `}`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertStringIncludes(result, "class loadSecret"); + assertStringIncludes(result, "static self = loadSecret"); + assertNotIncludes(result, "../server/secrets.ts"); + assertNotIncludes(result, "const secret ="); + }); + + it("keeps an import read by a TypeScript parameter property default", async () => { + const code = [ + `import { loadSecret } from "../server/secrets.ts";`, + `export async function getServerData() { return loadSecret("server"); }`, + `export default class Page {`, + ` constructor(private value = loadSecret("client")) {}`, + `}`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertStringIncludes(result, "../server/secrets.ts"); + assertStringIncludes(result, 'loadSecret("client")'); + assertNotIncludes(result, 'loadSecret("server")'); + }); + + it("keeps an import read by a TypeScript parameter property decorator", async () => { + const code = [ + `import { loadSecret } from "../server/secrets.ts";`, + `export async function getServerData() { return loadSecret("server"); }`, + `export default class Page {`, + ` constructor(@inject(loadSecret) private value = "client") {}`, + `}`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertStringIncludes(result, 'import { loadSecret } from "../server/secrets.ts"'); + assertStringIncludes(result, "@inject(loadSecret)"); + assertNotIncludes(result, 'loadSecret("server")'); + }); + + // Only a `TSParameterProperty` used to have its decorators traversed, but + // Babel hangs them off an ordinary parameter too. The reads were invisible, + // so the import went and the surviving decorator was left unresolved. + it("keeps an import read by a decorator on an ordinary parameter", async () => { + const code = [ + `import { inject, loadSecret } from "../server/secrets.ts";`, + `export async function getServerData() { return loadSecret("server"); }`, + `export default class Page {`, + ` constructor(@inject(loadSecret) value) { this.value = value; }`, + `}`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertStringIncludes(result, 'import { inject, loadSecret } from "../server/secrets.ts"'); + assertStringIncludes(result, "@inject(loadSecret)"); + assertNotIncludes(result, 'loadSecret("server")'); + }); + + it("keeps an import read by a parameter-property decorator shadowed by the parameter", async () => { + const code = [ + `import { secret } from "../server/secrets.ts";`, + `export async function getServerData() { return secret("server"); }`, + `export default class Page {`, + ` constructor(@inject(secret) private secret: string) {}`, + `}`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertStringIncludes(result, 'import { secret } from "../server/secrets.ts"'); + assertStringIncludes(result, "@inject(secret)"); + assertNotIncludes(result, 'secret("server")'); + }); + + for ( + const [description, parameter] of [ + ["identifier", "@inject(loadSecret) value: string"], + ["defaulted parameter", '@inject(loadSecret) value = "client"'], + ["destructured parameter", "@inject(loadSecret) { value }: { value: string }"], + ] as const + ) { + it(`keeps an import read by an ordinary decorated ${description}`, async () => { + const code = [ + `import { loadSecret } from "../server/secrets.ts";`, + `export async function getServerData() { return loadSecret("server"); }`, + `export default class Page {`, + ` constructor(${parameter}) {}`, + `}`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertStringIncludes(result, 'import { loadSecret } from "../server/secrets.ts"'); + assertStringIncludes(result, "@inject(loadSecret)"); + assertNotIncludes(result, 'loadSecret("server")'); + }); + } + + it("does not treat a private property name as an import read", async () => { + const code = [ + `import { loadSecret } from "../server/secrets.ts";`, + `export async function getServerData() { return loadSecret("server"); }`, + `export default class Page {`, + ` #loadSecret = "client";`, + ` render() { return this.#loadSecret; }`, + `}`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertNotIncludes(result, "../server/secrets.ts"); + assertStringIncludes(result, '#loadSecret = "client"'); + assertStringIncludes(result, "this.#loadSecret"); + }); + + it("does not treat an auto-accessor name as an import read", async () => { + const code = [ + `import { loadSecret } from "../server/secrets.ts";`, + `export async function getServerData() { return loadSecret("server"); }`, + `export default class Page {`, + ` accessor loadSecret = "client";`, + `}`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertNotIncludes(result, "../server/secrets.ts"); + assertStringIncludes(result, 'accessor loadSecret = "client"'); + }); + + it("scopes private method parameters before pruning imports", async () => { + const code = [ + `import { loadSecret } from "../server/secrets.ts";`, + `export async function getServerData() { return loadSecret("server"); }`, + `export default class Page {`, + ` #format(loadSecret: string) { return loadSecret; }`, + ` render() { return this.#format("client"); }`, + `}`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertNotIncludes(result, "../server/secrets.ts"); + assertStringIncludes(result, "#format(loadSecret: string)"); + assertStringIncludes(result, "return loadSecret"); + }); + + it("keeps an unreferenced class whose parameter decorator runs at definition time", async () => { + const code = [ + `import { inject, secret } from "../server/secrets.ts";`, + `class Registration {`, + ` constructor(@inject(secret) value: string) {}`, + `}`, + `export async function getServerData() { return secret("server"); }`, + `export default function Page() { return null; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertStringIncludes(result, 'import { inject, secret } from "../server/secrets.ts"'); + assertStringIncludes(result, "class Registration"); + assertStringIncludes(result, "@inject(secret)"); + assertNotIncludes(result, 'secret("server")'); + }); + + it("drops a runtime TypeScript enum used only by a stripped hook", async () => { + const code = [ + `import { randomUUID } from "node:crypto";`, + `enum ServerStatus { Ready = randomUUID() }`, + `export async function getServerData() { return ServerStatus.Ready; }`, + `export default function Page() { return "client"; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertNotIncludes(result, "node:crypto"); + assertNotIncludes(result, "ServerStatus"); + assertNotIncludes(result, "randomUUID"); + }); + + it("keeps an import read by a runtime TypeScript enum used by the client", async () => { + const code = [ + `import { randomUUID } from "node:crypto";`, + `enum ClientStatus { Ready = randomUUID() }`, + `export async function getServerData() { return randomUUID(); }`, + `export default function Page() { return ClientStatus.Ready; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertStringIncludes(result, 'import { randomUUID } from "node:crypto"'); + assertStringIncludes(result, "enum ClientStatus"); + assertStringIncludes(result, "randomUUID()"); + }); + + it("drops a runtime TypeScript namespace used only by a stripped hook", async () => { + const code = [ + `import { randomUUID } from "node:crypto";`, + `namespace ServerStatus { export const Ready = randomUUID(); }`, + `export async function getServerData() { return ServerStatus.Ready; }`, + `export default function Page() { return "client"; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertNotIncludes(result, "node:crypto"); + assertNotIncludes(result, "ServerStatus"); + assertNotIncludes(result, "randomUUID"); + }); + + it("keeps an import read by a runtime TypeScript namespace used by the client", async () => { + const code = [ + `import { randomUUID } from "node:crypto";`, + `namespace ClientStatus { export const Ready = randomUUID(); }`, + `export async function getServerData() { return randomUUID(); }`, + `export default function Page() { return ClientStatus.Ready; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertStringIncludes(result, 'import { randomUUID } from "node:crypto"'); + assertStringIncludes(result, "namespace ClientStatus"); + assertStringIncludes(result, "randomUUID()"); + }); + + it("binds a hoisted var nested inside a runtime TypeScript namespace", async () => { + const code = [ + `import { loadSecret } from "../server/secrets.ts";`, + `const publicValue = "client";`, + `namespace Client {`, + ` export const value = loadSecret;`, + ` if (globalThis.cond) { var loadSecret = publicValue; }`, + `}`, + `export async function getServerData() { return loadSecret("server"); }`, + `export default function Page() { return Client.value; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertNotIncludes(result, "../server/secrets.ts"); + assertStringIncludes(result, "namespace Client"); + assertStringIncludes(result, "var loadSecret = publicValue"); + assertStringIncludes(result, "export const value = loadSecret"); + }); + + it("does not hoist a namespace var into the enclosing module", async () => { + const code = [ + `import { loadSecret } from "../server/secrets.ts";`, + `namespace Client {`, + ` if (globalThis.cond) { var loadSecret = "client"; }`, + ` export const value = loadSecret;`, + `}`, + `export async function getServerData() { return "server"; }`, + `export default function Page() { return loadSecret(); }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertStringIncludes(result, 'import { loadSecret } from "../server/secrets.ts"'); + assertStringIncludes(result, "return loadSecret()"); + }); + + it("drops a TypeScript import-equals binding used only by a stripped hook", async () => { + const code = [ + `import crypto = require("node:crypto");`, + `export async function getServerData() { return crypto.randomUUID(); }`, + `export default function Page() { return "client"; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertNotIncludes(result, "node:crypto"); + assertNotIncludes(result, "import crypto"); + assertNotIncludes(result, "crypto.randomUUID"); + }); + + it("keeps a TypeScript import-equals binding used by the client", async () => { + const code = [ + `import crypto = require("node:crypto");`, + `export async function getServerData() { return crypto.randomUUID(); }`, + `export default function Page() { return crypto.randomUUID(); }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertStringIncludes(result, 'import crypto = require("node:crypto")'); + assertStringIncludes(result, "return crypto.randomUUID()"); + }); + + it("binds the name introduced by a TypeScript parameter property", async () => { + const code = [ + `import { value } from "../server/secrets.ts";`, + `export async function getServerData() { return value; }`, + `export default class Page {`, + ` constructor(private value = "client") { console.log(value); }`, + `}`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertNotIncludes(result, "../server/secrets.ts"); + assertStringIncludes(result, 'value = "client"'); + assertStringIncludes(result, "console.log(value)"); + }); + + it("does not hoist a static-block var into the enclosing function scope", async () => { + const code = [ + `import { loadSecret } from "../server/secrets.ts";`, + `export async function getServerData() { return loadSecret("server"); }`, + `export default function Page() {`, + ` class ClientValue { static { var loadSecret = "local"; } }`, + ` return loadSecret("client") + ClientValue;`, + `}`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertStringIncludes(result, "../server/secrets.ts"); + assertStringIncludes(result, 'loadSecret("client")'); + assertNotIncludes(result, 'loadSecret("server")'); + }); + + it("keeps static-block var declarations scoped to that block", async () => { + const code = [ + `import { loadSecret } from "../server/secrets.ts";`, + `export async function getServerData() { return loadSecret("server"); }`, + `export default class Page {`, + ` static { console.log(loadSecret); var loadSecret = "local"; }`, + `}`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertNotIncludes(result, "../server/secrets.ts"); + assertStringIncludes(result, 'var loadSecret = "local"'); + assertNotIncludes(result, 'loadSecret("server")'); + }); + + // A pattern default is runtime code: a helper it references is part of the + // dropped declarator's closure and is pruned with it once nothing else + // reads it. + it("prunes a helper referenced only from a dropped pattern default", async () => { + const code = [ + `import { getEnv } from "veryfront";`, + `function fallbackKey() { return getEnv("FALLBACK"); }`, + `const { key = fallbackKey() } = getEnv("CFG");`, + `export async function getServerData() { return { props: { key } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertEquals(occurrences(result, "key"), 0); + assertEquals(occurrences(result, "fallbackKey"), 0); + assertEquals(occurrences(result, "getEnv"), 0); + }); + + it("ignores reads between bindings in the same dropped pattern", async () => { + const code = [ + `import { getEnv } from "veryfront";`, + `const { token, auth = token } = getEnv("CFG");`, + `export async function getServerData() { return { props: { auth } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertEquals(occurrences(result, "token"), 0); + assertEquals(occurrences(result, "auth"), 0); + assertEquals(occurrences(result, "getEnv"), 0); + assertNotIncludes(result, "CFG"); + }); + + // Regression (review probe): a pattern default that reads a *sibling* + // binding of the same pattern used to keep the declarator alive forever: + // the self-referential read counted as an external consumer, so the + // secret-bearing initialiser call and its import shipped silently even + // though only the stripped hook read the bindings. + it("drops a pattern whose default multiplies a sibling binding of the same pattern", async () => { + const code = [ + `import { getEnv } from "veryfront";`, + `const { retries, delay = retries * 2 } = getEnv("SERVER_SECRET_CFG");`, + `export async function getServerData() { return { props: { retries, delay } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertEquals(occurrences(result, "retries"), 0); + assertEquals(occurrences(result, "delay"), 0); + assertEquals(occurrences(result, "getEnv"), 0); + assertNotIncludes(result, "SERVER_SECRET_CFG"); + assertNotIncludes(result, `"veryfront"`); + }); + + it("drops a chain that flows through a destructured server value", async () => { + const code = [ + `import { getEnv } from "veryfront";`, + `const { raw } = getEnv("TOKEN");`, + `const cleaned = raw.trim();`, + `export async function getServerData() { return { props: { cleaned } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertEquals(occurrences(result, "raw"), 0); + assertEquals(occurrences(result, "cleaned"), 0); + assertEquals(occurrences(result, "getEnv"), 0); + }); + + // Regression (closed leak): liveness used to be decided one declaration at + // a time ("is this name mentioned anywhere else?"), so two hook-only + // helpers that call each other each counted as the other's consumer and + // neither could ever be removed. The secret they closed over, and the + // node-builtin import behind it, shipped to the browser. Liveness is now + // reachability from the code that survives, and an unreachable cycle goes + // whole however long it is. + it("drops a cycle of hook-only helpers and the node builtin they shared", async () => { + const code = [ + `import { createHash } from "node:crypto";`, + `function normalize(row) { return row.id ? sign(row) : null; }`, + `function sign(row) { return createHash("sha256").update(normalize(row) ?? "").digest("hex"); }`, + `export async function getServerData() { return { props: { rows: [normalize({ id: 1 })] } }; }`, + `export default function Page({ rows }) { return rows.length; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertEquals(occurrences(result, "normalize"), 0); + assertEquals(occurrences(result, "sign"), 0); + assertEquals(occurrences(result, "createHash"), 0); + assertNotIncludes(result, "node:crypto"); + }); + + it("drops a cycle of hook-only arrow bindings holding a secret", async () => { + const code = [ + `const API_KEY = "sk-live-example";`, + `const ping = (n) => n <= 0 ? API_KEY : pong(n - 1);`, + `const pong = (n) => ping(n - 1);`, + `export async function getServerData() { return { props: { k: ping(3) } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertEquals(occurrences(result, "API_KEY"), 0); + assertEquals(occurrences(result, "ping"), 0); + assertEquals(occurrences(result, "pong"), 0); + assertNotIncludes(result, "sk-live-example"); + }); + + it("drops a three-helper cycle reached only through the hook", async () => { + const code = [ + `const API_KEY = "sk-live-example";`, + `function first(n) { return n <= 0 ? API_KEY : second(n); }`, + `function second(n) { return third(n - 1); }`, + `function third(n) { return first(n - 1); }`, + `export async function getServerData() { return { props: { k: first(3) } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertNotIncludes(result, "sk-live-example"); + assertEquals(occurrences(result, "first"), 0); + assertEquals(occurrences(result, "second"), 0); + assertEquals(occurrences(result, "third"), 0); + }); + + // Contrast pin: the same cycle survives whole the moment the client reaches + // into any part of it. + it("keeps a helper cycle the client still reaches", async () => { + const code = [ + `function ping(n) { return n <= 0 ? 0 : pong(n - 1); }`, + `function pong(n) { return ping(n - 1); }`, + `export async function getServerData() { return { props: { k: ping(3) } }; }`, + `export default function Page() { return pong(2); }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertStringIncludes(result, "function ping"); + assertStringIncludes(result, "function pong"); + }); + + // Regression (closed leak): a `var` hoists into module scope out of any + // block, `if`, `try`, `switch`, loop or label it is written in, but the + // declaration collector only ever looked at direct top-level declarations. + // A secret declared that way was never a removal candidate at all, so it + // shipped whenever the statement around it was impure enough to survive on + // its own. + const hoistedVarSecrets: Array<[string, string]> = [ + ["a bare block", `{ var API_KEY = getEnv("SECRET_KEY"); }`], + ["an if branch", `if (globalThis.cond) { var API_KEY = getEnv("SECRET_KEY"); }`], + ["a labelled declaration", `setup: var API_KEY = getEnv("SECRET_KEY");`], + [ + "a try/catch pair", + `try { var API_KEY = getEnv("SECRET_KEY"); } catch (e) { var API_KEY = null; }`, + ], + [ + "a switch case", + `switch (globalThis.mode) { case 1: var API_KEY = getEnv("SECRET_KEY"); }`, + ], + ["a for initialiser", `for (var API_KEY = getEnv("SECRET_KEY"); false;) {}`], + [ + "a destructuring pattern", + `if (globalThis.cond) { var { token: API_KEY } = getEnv("SECRET_KEY"); }`, + ], + ]; + + for (const [description, declaration] of hoistedVarSecrets) { + it(`drops a hook-only module-scope var declared in ${description}`, async () => { + const code = [ + `import { getEnv } from "veryfront";`, + declaration, + `export async function getServerData() { return { props: { k: API_KEY } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code, "pages/x.tsx"); + + assertEquals(occurrences(result, "API_KEY"), 0); + assertNotIncludes(result, "SECRET_KEY"); + assertEquals(occurrences(result, "getEnv"), 0); + }); + } + + // Contrast pin: the same nested declaration stays the moment client code + // reads it, and so does the statement it lives in. + it("keeps a nested-block module-scope var the client reads", async () => { + const code = [ + `import { getEnv } from "veryfront";`, + `if (globalThis.cond) { var REGION = getEnv("REGION"); }`, + `export async function getServerData() { return { props: { r: REGION } }; }`, + `export default function Page() { return REGION; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertStringIncludes(result, "REGION"); + assertStringIncludes(result, "getEnv"); + }); + + it("keeps a nested var destructuring that reads a block-local shadow", async () => { + const code = [ + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `{`, + ` const KEY = {`, + ` get value() { globalThis.reads = (globalThis.reads ?? 0) + 1; return "client"; },`, + ` };`, + ` var { value } = KEY;`, + `}`, + `export async function getServerData() { return { props: { k: KEY } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertStringIncludes(result, "globalThis.reads"); + assertStringIncludes(result, "var {"); + assertStringIncludes(result, "} = KEY;"); + assertNotIncludes(result, "SECRET_KEY"); + assertNotIncludes(result, `from "veryfront"`); + }); + + // A `for…of` head declares the binding the loop assigns to, so there is no + // declaration to cut out and the value the loop iterates would stay either + // way. The build stops rather than shipping it. + it("fails the build when a dead server-only var is declared by a for-of head", async () => { + const code = [ + `import { getEnv } from "veryfront";`, + `for (var API_KEY of [getEnv("SECRET_KEY")]) { globalThis.seen = true; }`, + `export async function getServerData() { return { props: { k: API_KEY } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + const error = await assertRejects(() => stripServerOnlyExports(code, "pages/x.tsx")); + + assertStringIncludes((error as Error).message, "API_KEY"); + }); + + // A `var` can be written down twice for the same module binding. Dropping + // only the dead declaration would leave the name bound by the other one, so + // the pass refuses to take out half a binding and stops the build instead. + it("fails the build when only one declaration of a repeated var is dead", async () => { + const code = [ + `import { getEnv } from "veryfront";`, + `var API_KEY = getEnv("SECRET_KEY");`, + `if (globalThis.cond) { var [API_KEY, shown] = getEnv("PAIR"); }`, + `export async function getServerData() { return { props: { k: API_KEY } }; }`, + `export default function Page() { return shown; }`, + ].join("\n"); + + const error = await assertRejects(() => stripServerOnlyExports(code, "pages/x.tsx")); + + assertStringIncludes((error as Error).message, "API_KEY"); + assertStringIncludes((error as Error).message, "declared more than once"); + }); + + // Regression (closed leak): a statement label lives in its own namespace, + // but the scan read `break API_KEY` as a reference to the module's + // `API_KEY` and kept the secret alive forever. The label itself is client + // code and stays; the declaration it merely shares a spelling with does not. + it("does not count a statement label as a reference", async () => { + const code = [ + `import { getEnv } from "veryfront";`, + `const API_KEY = getEnv("SECRET_KEY");`, + `export async function getServerData() { return { props: { k: API_KEY } }; }`, + `export default function Page() {`, + ` API_KEY: for (let i = 0; i < 1; i++) { break API_KEY; }`, + ` return null;`, + `}`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertNotIncludes(result, "SECRET_KEY"); + assertEquals(occurrences(result, "getEnv"), 0); + assertStringIncludes(result, "break API_KEY"); + }); + + // The *exported* half of an export specifier is a name this module + // publishes, not a read of anything it declares. + it("does not count an export alias's exported name as a reference", async () => { + const code = [ + `import { getEnv } from "veryfront";`, + `const API_KEY = getEnv("SECRET_KEY");`, + `export async function getServerData() { return { props: { k: API_KEY } }; }`, + `const other = 1;`, + `export { other as API_KEY };`, + `export default function Page() { return null; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertNotIncludes(result, "SECRET_KEY"); + assertEquals(occurrences(result, "getEnv"), 0); + assertStringIncludes(result, "other as API_KEY"); + }); + + // A decorator is ordinary code in a position the scan skipped entirely, so + // a value only the hook's decorator read stayed behind with its import. + it("tracks a decorator read inside a stripped hook", async () => { + const code = [ + `import { getEnv } from "veryfront";`, + `const API_KEY = getEnv("SECRET_KEY");`, + `export async function getServerData() {`, + ` @API_KEY class Local {}`, + ` return { props: { n: Local.name } };`, + `}`, + `export default function Page() { return null; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code, "page.tsx"); + + assertEquals(occurrences(result, "API_KEY"), 0); + assertNotIncludes(result, "SECRET_KEY"); + }); + + it("keeps a value a decorator on client code reads", async () => { + const code = [ + `import { getEnv } from "veryfront";`, + `const REGION = getEnv("REGION");`, + `function withRegion(value) { return (target) => target; }`, + `@withRegion(REGION) class Widget {}`, + `export async function getServerData() { return { props: { r: REGION } }; }`, + `export default function Page() { return Widget; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code, "page.tsx"); + + assertStringIncludes(result, "REGION"); + assertStringIncludes(result, "getEnv"); + }); + + it("keeps an import that the client still references", async () => { + const code = [ + `import { formatDate } from "../lib/dates.js";`, + `export async function getServerData() { return { props: {} }; }`, + `export default function Page(props) { return formatDate(props.at); }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertStringIncludes(result, "formatDate"); + assertStringIncludes(result, "../lib/dates.js"); + }); + + it("keeps an import when only one of its bindings is used", async () => { + const code = [ + `import { a, b } from "./x.js";`, + `export async function getServerData() { return b(); }`, + `export default function Page() { return a(); }`, ].join("\n"); const result = await stripServerOnlyExports(code); @@ -802,231 +5068,678 @@ describe("browser-server-exports-strip", () => { assertStringIncludes(result, "a, b"); }); - it("keeps side effects for ordinary imports with mixed hook-only bindings", async () => { + it("keeps side effects for ordinary imports with mixed hook-only bindings", async () => { + const code = [ + `import { initClient, loadSecret } from "./client-setup.ts";`, + `export async function getServerData() { return { props: { token: loadSecret() } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertStringIncludes(result, `import "./client-setup.ts"`); + assertEquals(occurrences(result, "initClient"), 0); + assertEquals(occurrences(result, "loadSecret"), 0); + }); + + it("keeps a bare side-effect import untouched", async () => { + const code = [ + `import "../lib/polyfill.js";`, + `export async function getServerData() { return { props: {} }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + assertStringIncludes(result, "polyfill.js"); + }); + + it("keeps a default import the client renders with", async () => { + const code = [ + `import React from "react";`, + `export async function getServerData() { return { props: {} }; }`, + `export default function Page() { return React.createElement("p"); }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + assertStringIncludes(result, `from "react"`); + }); + + it("removes a namespace import the client no longer uses", async () => { const code = [ - `import { initClient, loadSecret } from "./client-setup.ts";`, - `export async function getServerData() { return { props: { token: loadSecret() } }; }`, + `import * as helpers from "../lib/util-bag.js";`, + `export async function getServerData() { return helpers.load(); }`, `export default function Page() { return null; }`, ].join("\n"); const result = await stripServerOnlyExports(code); - assertStringIncludes(result, `import "./client-setup.ts"`); - assertEquals(occurrences(result, "initClient"), 0); - assertEquals(occurrences(result, "loadSecret"), 0); + assertNotIncludes(result, "../lib/util-bag.js"); + assertEquals(occurrences(result, "helpers"), 0); }); - it("keeps a bare side-effect import untouched", async () => { + it("does not count a matching property name as a reference", async () => { const code = [ - `import "../lib/polyfill.js";`, + `import { hashOf } from "../lib/uses-crypto.js";`, + `export async function getServerData() { return hashOf("x"); }`, + `export default function Page(props) { return props.hashOf; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + // `props.hashOf` is a property name, not a reference to the import. + assertStringIncludes(result, "props.hashOf"); + assertNotIncludes(result, "../lib/uses-crypto.js"); + assertEquals(occurrences(result, "hashOf"), 1); + }); + + it("counts a computed property access as a reference", async () => { + const code = [ + `import { key } from "../lib/keys.js";`, + `export async function getServerData() { return { props: {} }; }`, + `export default function Page(props) { return props[key]; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + assertStringIncludes(result, "{ key }"); + }); + + it("counts a JSX component as a reference", async () => { + const code = [ + `import Badge from "../components/Badge.tsx";`, + `export async function getServerData() { return { props: {} }; }`, + `export default function Page() { return ; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code, "page.tsx"); + assertStringIncludes(result, "Badge from"); + }); + + it("does not count a lowercase JSX tag as a binding reference", async () => { + const code = [ + `import { secret } from "../server/secrets.ts";`, + `export async function getServerData() { return secret(); }`, + `export default function Page() { return ; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code, "page.tsx"); + + assertStringIncludes(result, ""); + assertNotIncludes(result, "../server/secrets.ts"); + assertEquals(occurrences(result, "secret"), 1); + }); + + it("counts a lowercase JSX member root as a binding reference", async () => { + const code = [ + `import client from "../components/client.tsx";`, `export async function getServerData() { return { props: {} }; }`, + `export default function Page() { return ; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code, "page.tsx"); + + assertStringIncludes(result, 'client from "../components/client.tsx"'); + assertStringIncludes(result, ""); + }); + + it("does not count a JSX namespace name as a binding reference", async () => { + const code = [ + `import { svg } from "../server/icons.ts";`, + `export async function getServerData() { return svg; }`, + `export default function Page() { return ; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code, "page.tsx"); + + assertStringIncludes(result, ""); + assertNotIncludes(result, "../server/icons.ts"); + assertEquals(occurrences(result, "svg"), 1); + }); + + it("does not count a JSX attribute name as a reference", async () => { + const code = [ + `import { secret } from "../server/secrets.ts";`, + `export async function getServerData() { return secret(); }`, + `export default function Page() { return
; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code, "page.tsx"); + + assertStringIncludes(result, 'secret="public"'); + assertNotIncludes(result, "../server/secrets.ts"); + assertEquals(occurrences(result, "secret"), 1); + }); + + it("reads the object but not the property of a JSX member expression", async () => { + const code = [ + `import Client from "../components/Client.tsx";`, + `import { Icon } from "../server/icons.ts";`, + `export async function getServerData() { return Icon; }`, + `export default function Page() { return ; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code, "page.tsx"); + + assertStringIncludes(result, 'Client from "../components/Client.tsx"'); + assertStringIncludes(result, ""); + assertNotIncludes(result, "../server/icons.ts"); + }); + + it("does not count import.meta names as binding references", async () => { + const code = [ + `import { meta } from "../server/meta.ts";`, + `export async function getServerData() { return meta; }`, + `export default function Page() { return import.meta.url; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertStringIncludes(result, "import.meta.url"); + assertNotIncludes(result, "../server/meta.ts"); + assertEquals(occurrences(result, "meta"), 1); + }); + }); + + // Regression: the scan used to count identifiers by matching text, so a name + // that survived only in inert text kept a server-only import alive. + describe("inert text is not a reference", () => { + it("does not count a line comment mention", async () => { + const code = [ + `import { createHash } from "node:crypto";`, + `// createHash only ever runs in getServerData`, + `export async function getServerData() { return createHash("sha256"); }`, `export default function Page() { return null; }`, ].join("\n"); const result = await stripServerOnlyExports(code); - assertStringIncludes(result, "polyfill.js"); + assertNotIncludes(result, "node:crypto"); + }); + + it("does not count a block comment mention", async () => { + const code = [ + `import { createHash } from "node:crypto";`, + `/* createHash hashes the slug on the server */`, + `export async function getServerData() { return createHash("sha256"); }`, + `export default function Page() { return null; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + assertNotIncludes(result, "node:crypto"); + }); + + it("does not count a string literal mention", async () => { + const code = [ + `import { hashOf } from "../lib/uses-crypto.js";`, + `export async function getServerData() { return hashOf("x"); }`, + `export default function Page() { return "hashOf"; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + // Only the string survives, so the import is removed. + assertEquals(occurrences(result, "hashOf"), 1); + assertStringIncludes(result, `"hashOf"`); + assertNotIncludes(result, "../lib/uses-crypto.js"); + }); + + it("does not count a template literal mention", async () => { + const code = [ + 'import { hashOf } from "../lib/uses-crypto.js";', + 'export async function getServerData() { return hashOf("x"); }', + "export default function Page() { return `hashOf`; }", + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertEquals(occurrences(result, "hashOf"), 1); + assertNotIncludes(result, "../lib/uses-crypto.js"); + }); + + it("counts a template literal interpolation, which is real code", async () => { + const code = [ + 'import { formatLabel } from "../lib/labels.js";', + "export async function getServerData() { return { props: {} }; }", + "export default function Page() { return `x ${formatLabel()} y`; }", + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertStringIncludes(result, "{ formatLabel }"); + assertStringIncludes(result, "../lib/labels.js"); + }); + + it("does not count a JSX text node mention", async () => { + const code = [ + `import { hashOf } from "../lib/uses-crypto.js";`, + `export async function getServerData() { return hashOf("x"); }`, + `export default function Page() { return

hashOf

; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code, "page.tsx"); + + assertEquals(occurrences(result, "hashOf"), 1); + assertNotIncludes(result, "../lib/uses-crypto.js"); + }); + }); + + describe("declaration forms", () => { + // Regression: a private helper that shares a hook's name is client code, + // even when the module really does export a hook elsewhere. + it("leaves a private same-named declaration alone beside a real hook", async () => { + const code = [ + `function getServerData() { return computeOnClient(); }`, + `export function getStaticData() { return readSecret(); }`, + `export default function Page() { return getServerData(); }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertNotIncludes(result, "readSecret"); + assertStringIncludes(result, "computeOnClient"); + }); + + it("empties both an aliased hook and a directly declared one", async () => { + const code = [ + `function loadIt() { return readAliasedSecret(); }`, + `export { loadIt as getServerData };`, + `export function getStaticData() { return readSecret(); }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertNotIncludes(result, "readSecret()"); + assertNotIncludes(result, "readAliasedSecret"); + }); + + // A local that merely shares a hook's name is ordinary client code: it is + // the exported name that makes something server-only. + it("leaves a local named like a hook but exported as something else alone", async () => { + const code = [ + `function getServerData() { return computeOnClient(); }`, + `export { getServerData as loadData };`, + ].join("\n"); + + assertEquals(await stripServerOnlyExports(code), code); + }); + + it("empties a hook declared as an exported function expression", async () => { + const code = `export const getServerData = async function () { return readSecret(); };`; + + const result = await stripServerOnlyExports(code); + + assertNotIncludes(result, "readSecret"); + assertStringIncludes(result, "getServerData"); + }); + + it("empties a hook declared as a directly exported async function", async () => { + const code = `export async function getServerData() { return readSecret(); }`; + + const result = await stripServerOnlyExports(code); + + assertNotIncludes(result, "readSecret"); + assertStringIncludes(result, "getServerData"); + }); + }); + + // A dead declaration must not be able to vouch for a secret. These are the + // shapes where it still could: an initialiser that only *looks* impure, and + // one that runs but reads the secret somewhere that never runs with it. + describe("what a dead declaration can pin", () => { + // Choosing between two values, or comparing them without coercion, calls + // nothing. Each of these used to be "not proven inert", so the dead + // declaration counted as a top-level side effect and rooted the secret. + const inertOperators: Array<[string, string]> = [ + ["a conditional", `const dead = MARK ? KEY : MARK;`], + ["a logical or", `const dead = KEY || MARK;`], + ["a nullish coalesce", `const dead = KEY ?? MARK;`], + ["a logical and", `const dead = KEY && MARK;`], + ["a strict comparison", `const dead = KEY === MARK;`], + ["a strict inequality", `const dead = KEY !== MARK;`], + ["a sequence", `const dead = (MARK, KEY);`], + ]; + + for (const [description, declaration] of inertOperators) { + it(`drops a hook-only secret ${description} reads in a dead declaration`, async () => { + const code = [ + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `const MARK = "client-mark";`, + declaration, + `export async function getServerData() { return { props: { k: KEY } }; }`, + `export default function Page() { return MARK; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertNotIncludes(result, "SECRET_KEY"); + assertEquals(occurrences(result, "KEY"), 0); + assertEquals(occurrences(result, "dead"), 0); + assertStringIncludes(result, "client-mark"); + }); + } + + // Coercion is the line: `==`, `<` and arithmetic all reach `valueOf`, so + // the comparison is a real read of the secret and the declaration stays. + it("keeps a hook-only secret a coercing comparison reads in a dead declaration", async () => { + const code = [ + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `const dead = KEY > 1;`, + `export async function getServerData() { return { props: { k: KEY } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertStringIncludes(result, "SECRET_KEY"); }); - it("keeps a default import the client renders with", async () => { + // Naming a superclass reads its `prototype`, which can invoke a Proxy trap + // even when the heritage expression is a plain binding. The pass cannot + // delete that evaluation or retain the secret in the deferred method. + it("fails closed for a dead class that extends a local client class", async () => { const code = [ - `import React from "react";`, - `export async function getServerData() { return { props: {} }; }`, - `export default function Page() { return React.createElement("p"); }`, + `import { getEnv } from "veryfront";`, + `class Base { b() { return "client-mark"; } }`, + `const KEY = getEnv("SECRET_KEY");`, + `class Dead extends Base { m() { return KEY; } }`, + `export async function getServerData() { return { props: { k: KEY } }; }`, + `export default function Page() { return new Base().b(); }`, ].join("\n"); - const result = await stripServerOnlyExports(code); - assertStringIncludes(result, `from "react"`); + const error = await assertRejects(() => stripServerOnlyExports(code, "pages/leak.tsx")); + + assertStringIncludes((error as Error).message, "KEY"); + assertStringIncludes((error as Error).message, "pages/leak.tsx"); }); - it("removes a namespace import the client no longer uses", async () => { + it("fails closed instead of deleting a dead subclass's heritage evaluation", async () => { const code = [ - `import * as helpers from "../lib/util-bag.js";`, - `export async function getServerData() { return helpers.load(); }`, + `import { getEnv } from "veryfront";`, + `import Base from "./client-base.ts";`, + `const KEY = getEnv("SECRET_KEY");`, + `class Dead extends Base { m() { return KEY; } }`, + `export async function getServerData() { return { props: { k: KEY } }; }`, `export default function Page() { return null; }`, ].join("\n"); - const result = await stripServerOnlyExports(code); + const error = await assertRejects(() => stripServerOnlyExports(code, "pages/leak.tsx")); - assertNotIncludes(result, "../lib/util-bag.js"); - assertEquals(occurrences(result, "helpers"), 0); + assertStringIncludes((error as Error).message, "KEY"); + assertStringIncludes((error as Error).message, "pages/leak.tsx"); }); - it("does not count a matching property name as a reference", async () => { + // A body that never runs is not a read. `memo(…)` is a genuine top-level + // side effect, so the declaration stays, but the arrow it is handed only + // reads the secret if something calls it, and nothing reaches `handler`. + // The pass can neither drop the surviving call nor honestly claim the + // secret is gone, so it stops the build. + it("fails the build when a secret is read only from an unreachable declaration's body", async () => { const code = [ - `import { hashOf } from "../lib/uses-crypto.js";`, - `export async function getServerData() { return hashOf("x"); }`, - `export default function Page(props) { return props.hashOf; }`, + `import { getEnv } from "veryfront";`, + `import { memo } from "./memo.ts";`, + `const KEY = getEnv("SECRET_KEY");`, + `const handler = memo(() => KEY);`, + `export async function getServerData() { return { props: { k: KEY } }; }`, + `export default function Page() { return null; }`, ].join("\n"); - const result = await stripServerOnlyExports(code); + const error = await assertRejects(() => stripServerOnlyExports(code, "pages/leak.tsx")); - // `props.hashOf` is a property name, not a reference to the import. - assertStringIncludes(result, "props.hashOf"); - assertNotIncludes(result, "../lib/uses-crypto.js"); - assertEquals(occurrences(result, "hashOf"), 1); + assertStringIncludes((error as Error).message, "KEY"); + assertStringIncludes((error as Error).message, "pages/leak.tsx"); }); - it("counts a computed property access as a reference", async () => { + it("fails the build when a deferred parameter default is the last secret reader", async () => { const code = [ - `import { key } from "../lib/keys.js";`, - `export async function getServerData() { return { props: {} }; }`, - `export default function Page(props) { return props[key]; }`, + `import { getEnv } from "veryfront";`, + `import { memo } from "./memo.ts";`, + `const KEY = getEnv("SECRET_KEY");`, + `const handler = memo((value = KEY) => value);`, + `export async function getServerData() { return { props: { k: KEY } }; }`, + `export default function Page() { return null; }`, ].join("\n"); - const result = await stripServerOnlyExports(code); - assertStringIncludes(result, "{ key }"); + const error = await assertRejects(() => stripServerOnlyExports(code, "pages/leak.tsx")); + + assertStringIncludes((error as Error).message, "KEY"); + assertStringIncludes((error as Error).message, "pages/leak.tsx"); }); - it("counts a JSX component as a reference", async () => { + it("fails the build when a called generator defers the last secret read", async () => { const code = [ - `import Badge from "../components/Badge.tsx";`, - `export async function getServerData() { return { props: {} }; }`, - `export default function Page() { return ; }`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `const dead = (function* () { yield KEY; })();`, + `export async function getServerData() { return { props: { k: KEY } }; }`, + `export default function Page() { return null; }`, ].join("\n"); - const result = await stripServerOnlyExports(code, "page.tsx"); - assertStringIncludes(result, "Badge from"); + const error = await assertRejects(() => stripServerOnlyExports(code, "pages/leak.tsx")); + + assertStringIncludes((error as Error).message, "KEY"); + assertStringIncludes((error as Error).message, "pages/leak.tsx"); }); - }); - // Regression: the scan used to count identifiers by matching text, so a name - // that survived only in inert text kept a server-only import alive. - describe("inert text is not a reference", () => { - it("does not count a line comment mention", async () => { + // Contrast pin: the same shape is ordinary client code the moment the + // browser can reach the declaration, and then the secret it closes over is + // shared state this pass must leave alone. + it("keeps a secret read from the body of a declaration the client reaches", async () => { const code = [ - `import { createHash } from "node:crypto";`, - `// createHash only ever runs in getServerData`, - `export async function getServerData() { return createHash("sha256"); }`, - `export default function Page() { return null; }`, + `import { getEnv } from "veryfront";`, + `import { memo } from "./memo.ts";`, + `const KEY = getEnv("SECRET_KEY");`, + `const handler = memo(() => KEY);`, + `export async function getServerData() { return { props: { k: KEY } }; }`, + `export default function Page() { return handler(); }`, ].join("\n"); const result = await stripServerOnlyExports(code); - assertNotIncludes(result, "node:crypto"); + + assertStringIncludes(result, "SECRET_KEY"); + assertStringIncludes(result, "handler"); }); - it("does not count a block comment mention", async () => { + it("keeps a hook-owned binding read by surviving module code", async () => { const code = [ - `import { createHash } from "node:crypto";`, - `/* createHash hashes the slug on the server */`, - `export async function getServerData() { return createHash("sha256"); }`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `function register(value) { globalThis.registered = value; }`, + `register(KEY);`, + `export async function getServerData() { return { props: { k: KEY } }; }`, `export default function Page() { return null; }`, ].join("\n"); const result = await stripServerOnlyExports(code); - assertNotIncludes(result, "node:crypto"); + + assertStringIncludes(result, "SECRET_KEY"); + assertStringIncludes(result, "register(KEY)"); }); - it("does not count a string literal mention", async () => { + it("keeps a hook-owned binding reached through a separate default export", async () => { const code = [ - `import { hashOf } from "../lib/uses-crypto.js";`, - `export async function getServerData() { return hashOf("x"); }`, - `export default function Page() { return "hashOf"; }`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `export async function getServerData() { return { props: { k: KEY } }; }`, + `function Page() { return KEY; }`, + `export { Page as default };`, ].join("\n"); const result = await stripServerOnlyExports(code); - // Only the string survives, so the import is removed. - assertEquals(occurrences(result, "hashOf"), 1); - assertStringIncludes(result, `"hashOf"`); - assertNotIncludes(result, "../lib/uses-crypto.js"); + assertStringIncludes(result, "SECRET_KEY"); + assertStringIncludes(result, "Page as default"); }); - it("does not count a template literal mention", async () => { + it("keeps a hook-owned binding reached through a direct default export", async () => { const code = [ - 'import { hashOf } from "../lib/uses-crypto.js";', - 'export async function getServerData() { return hashOf("x"); }', - "export default function Page() { return `hashOf`; }", + `import { forwardRef } from "react";`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `const Page = forwardRef(() => KEY);`, + `export async function getServerData() { return { props: { k: KEY } }; }`, + `export default Page;`, ].join("\n"); const result = await stripServerOnlyExports(code); - assertEquals(occurrences(result, "hashOf"), 1); - assertNotIncludes(result, "../lib/uses-crypto.js"); + assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + assertStringIncludes(result, "forwardRef(() => KEY)"); + assertStringIncludes(result, "export default Page"); }); - it("counts a template literal interpolation, which is real code", async () => { + it("keeps a hook-owned binding reached through a default export expression", async () => { const code = [ - 'import { formatLabel } from "../lib/labels.js";', - "export async function getServerData() { return { props: {} }; }", - "export default function Page() { return `x ${formatLabel()} y`; }", + `import { memo } from "react";`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `export async function getServerData() { return { props: { k: KEY } }; }`, + `export default memo(() => KEY);`, ].join("\n"); const result = await stripServerOnlyExports(code); - assertStringIncludes(result, "{ formatLabel }"); - assertStringIncludes(result, "../lib/labels.js"); + assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + assertStringIncludes(result, "export default memo(() => KEY)"); }); - it("does not count a JSX text node mention", async () => { + it("keeps bindings selected by a conditional default export", async () => { const code = [ - `import { hashOf } from "../lib/uses-crypto.js";`, - `export async function getServerData() { return hashOf("x"); }`, - `export default function Page() { return

hashOf

; }`, + `import { forwardRef } from "react";`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `const Page = forwardRef(() => KEY);`, + `const Fallback = () => null;`, + `const flag = globalThis.usePage;`, + `export async function getServerData() { return { props: { k: KEY } }; }`, + `export default (flag ? Page : Fallback);`, ].join("\n"); - const result = await stripServerOnlyExports(code, "page.tsx"); + const result = await stripServerOnlyExports(code); - assertEquals(occurrences(result, "hashOf"), 1); - assertNotIncludes(result, "../lib/uses-crypto.js"); + assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + assertStringIncludes(result, "forwardRef(() => KEY)"); + assertStringIncludes(result, "flag ? Page : Fallback"); }); - }); - describe("declaration forms", () => { - // Regression: a private helper that shares a hook's name is client code, - // even when the module really does export a hook elsewhere. - it("leaves a private same-named declaration alone beside a real hook", async () => { + it("keeps a hook-owned binding read by an anonymous default function", async () => { const code = [ - `function getServerData() { return computeOnClient(); }`, - `export function getStaticData() { return readSecret(); }`, - `export default function Page() { return getServerData(); }`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `export async function getServerData() { return { props: { k: KEY } }; }`, + `export default function () { return KEY; }`, ].join("\n"); const result = await stripServerOnlyExports(code); - assertNotIncludes(result, "readSecret"); - assertStringIncludes(result, "computeOnClient"); + assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + assertStringIncludes(result, "export default function"); + assertStringIncludes(result, "return KEY"); }); - it("empties both an aliased hook and a directly declared one", async () => { + // An immediately invoked function is not deferred: its body runs where it + // is written, so the secret it reads is genuinely read at module load. + it("keeps a secret an immediately invoked initialiser reads", async () => { const code = [ - `function loadIt() { return readAliasedSecret(); }`, - `export { loadIt as getServerData };`, - `export function getStaticData() { return readSecret(); }`, + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `const dead = (function () { globalThis.x = KEY; return 1; })();`, + `export async function getServerData() { return { props: { k: KEY } }; }`, + `export default function Page() { return null; }`, ].join("\n"); const result = await stripServerOnlyExports(code); - assertNotIncludes(result, "readSecret()"); - assertNotIncludes(result, "readAliasedSecret"); + assertStringIncludes(result, "SECRET_KEY"); }); - // A local that merely shares a hook's name is ordinary client code: it is - // the exported name that makes something server-only. - it("leaves a local named like a hook but exported as something else alone", async () => { - const code = [ - `function getServerData() { return computeOnClient(); }`, - `export { getServerData as loadData };`, - ].join("\n"); + for ( + const [label, invocation] of [ + [ + "satisfies expression", + `(function () { globalThis.registered = KEY; return true; } satisfies () => boolean)()`, + ], + [ + "type assertion", + `(<() => boolean> function () { globalThis.registered = KEY; return true; })()`, + ], + ] as const + ) { + it(`keeps a module-evaluation read from an IIFE wrapped in a ${label}`, async () => { + const code = [ + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `const ran = ${invocation};`, + `export async function getServerData() { return { props: { k: KEY } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); - assertEquals(await stripServerOnlyExports(code), code); - }); + const result = await stripServerOnlyExports(code, "pages/iife.ts"); - it("empties a hook declared as an exported function expression", async () => { - const code = `export const getServerData = async function () { return readSecret(); };`; + assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + assertStringIncludes(result, "globalThis.registered = KEY"); + }); + } + + for ( + const [method, args] of [ + ["call", "null"], + ["apply", "null, []"], + ] as const + ) { + it(`keeps a module-evaluation read from a bracketed ${method} IIFE`, async () => { + const code = [ + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SECRET_KEY");`, + `const ran = (function () { globalThis.registered = KEY; return true; })["${method}"](${args});`, + `export async function getServerData() { return { props: { k: KEY } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + assertStringIncludes(result, `["${method}"](${args})`); + assertStringIncludes(result, `throw new Error("server-only")`); + assertNotIncludes(result, "props:"); + }); + } + + // Over-pruning guard for the hoisted-`var` exception: eliding the site from + // the roots stops it pinning a hook-only import, but the call is still the + // module's own side effect. When the binding it calls survives (because + // browser code calls it too) removing the statement would silently delete + // working client code. + it("keeps a hoisted var whose initialiser calls an import the client also uses", async () => { + const code = [ + `import { boot } from "./boot.ts";`, + `if (globalThis.debug) { var dead = boot("dev-only-mark"); }`, + `export async function getServerData() { return { props: { b: boot("server") } }; }`, + `export default function Page() { return boot("client"); }`, + ].join("\n"); const result = await stripServerOnlyExports(code); - assertNotIncludes(result, "readSecret"); - assertStringIncludes(result, "getServerData"); + assertStringIncludes(result, "dev-only-mark"); + assertStringIncludes(result, "./boot.ts"); + assertStringIncludes(result, `boot("client")`); }); - it("empties a hook declared as a directly exported async function", async () => { - const code = `export async function getServerData() { return readSecret(); }`; + it("keeps a hoisted var that mixes live and hook-only calls", async () => { + const code = [ + `import { boot, loadSecret } from "./boot.ts";`, + `if (globalThis.debug) { var dead = boot(loadSecret("dev-only-mark")); }`, + `export async function getServerData() {`, + ` return { props: { b: boot("server"), k: loadSecret("server") } };`, + `}`, + `export default function Page() { return boot("client"); }`, + ].join("\n"); - const result = await stripServerOnlyExports(code); + const result = await stripServerOnlyExports(code, "pages/leak.tsx"); - assertNotIncludes(result, "readSecret"); - assertStringIncludes(result, "getServerData"); + assertStringIncludes(result, "dev-only-mark"); + assertStringIncludes(result, "loadSecret"); + assertStringIncludes(result, `boot("client")`); + assertStringIncludes(result, "./boot.ts"); }); }); @@ -1242,6 +5955,324 @@ describe("browser-server-exports-strip", () => { }); }); + // Everything above hands this stage source as the author wrote it. In the real + // browser pipeline esbuild runs first, and it rewrites the module's export + // shape: every named export is hoisted into one trailing `export { … }` clause + // and the declarations are left bare. That difference is not cosmetic; it is + // the only form in which the export contract reaches this stage, and a rule + // keyed on `export`-wrapped declarations silently does nothing here. These + // cases compile first, so a regression that only shows up after esbuild + // cannot pass unnoticed. + describe("compiled input", () => { + function ctx(code: string, filePath: string): TransformContext { + return { + code, + originalSource: code, + filePath, + projectDir: "/project", + projectId: "project", + target: "browser", + dev: true, + contentHash: "hash", + jsxImportSource: "react", + timing: new Map(), + debug: false, + metadata: new Map(), + reactVersion: "19.1.1", + } as TransformContext; + } + + /** The real browser pipeline: esbuild, then this stage. */ + async function compileThenStrip(source: string, filePath: string): Promise { + const compiled = await compilePlugin.transform!(ctx(source, filePath)); + return await stripServerOnlyExports(compiled, filePath); + } + + it("keeps an exported client value that shares a binding with the hook", async () => { + const source = [ + `import { getEnv } from "veryfront";`, + `import { makeClient } from "@/lib/client";`, + `const API_KEY = getEnv("API_KEY");`, + `export const client = makeClient({ get: () => API_KEY });`, + `export async function getServerData() { return { props: { k: API_KEY } }; }`, + ].join("\n"); + + const result = await compileThenStrip(source, "/project/app/page.tsx"); + + // `client` is exported, so the browser reaches `API_KEY` through it. The + // honest outcome is to keep both, not to fail the build over a value the + // module deliberately publishes. + assertStringIncludes(result, "const client = makeClient"); + assertStringIncludes(result, `const API_KEY = getEnv("API_KEY")`); + assertStringIncludes(result, `throw new Error("server-only")`); + assertNotIncludes(result, "props:"); + }); + + it("keeps a forwardRef component that defers a read of the hook's binding", async () => { + const source = [ + `import { forwardRef } from "react";`, + `import { getEnv } from "veryfront";`, + `const TOKEN = getEnv("INPUT_BOX_TOKEN");`, + `export const InputBox = forwardRef(function InputBox(props, ref) {`, + ` return ;`, + `});`, + `export async function getServerData() { return { props: { t: TOKEN } }; }`, + ].join("\n"); + + const result = await compileThenStrip(source, "/project/react/primitives/input-box.tsx"); + + // Nothing in the module calls `InputBox`; its only consumer is the export + // clause esbuild emitted, which is exactly the edge that used to be missed. + assertStringIncludes(result, "forwardRef("); + assertStringIncludes(result, `const TOKEN = getEnv("INPUT_BOX_TOKEN")`); + assertStringIncludes(result, "InputBox"); + assertStringIncludes(result, `throw new Error("server-only")`); + }); + + it("still drops a hook-only secret and its import from compiled output", async () => { + const source = [ + `import { getEnv } from "veryfront";`, + `const API_KEY = getEnv("API_KEY");`, + `function readKey() { return API_KEY; }`, + `export async function getServerData() { return { props: { k: readKey() } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + const result = await compileThenStrip(source, "/project/app/page.tsx"); + + // Rooting the export clause must not turn this stage into a no-op: nothing + // exported reaches `API_KEY`, so it and its import still go. + assertEquals(occurrences(result, "API_KEY"), 0); + assertEquals(occurrences(result, "readKey"), 0); + assertNotIncludes(result, `from "veryfront"`); + assertStringIncludes(result, "Page as default"); + }); + + it("does not root a re-exported name as a local binding", async () => { + const source = [ + `import { getEnv } from "veryfront";`, + `export { helper } from "@/lib/helper";`, + `const API_KEY = getEnv("API_KEY");`, + `function helper2() { return API_KEY; }`, + `export async function getServerData() { return { props: { k: helper2() } }; }`, + ].join("\n"); + + const result = await compileThenStrip(source, "/project/app/page.tsx"); + + // `export { helper } from "…"` names no binding this module declares, so it + // must not keep a same-named local alive. + assertEquals(occurrences(result, "API_KEY"), 0); + assertEquals(occurrences(result, "helper2"), 0); + assertStringIncludes(result, "@/lib/helper"); + }); + + for ( + const [method, args] of [ + ["call", "null"], + ["apply", "null, []"], + ] as const + ) { + it(`keeps a module-evaluation read from a function-expression .${method} IIFE`, async () => { + const source = [ + `import { getEnv } from "veryfront";`, + `const KEY = getEnv("SERVER_VALUE");`, + `const ran = (function () { globalThis.registered = KEY; return true; }).${method}(${args});`, + `export async function getServerData() { return { props: { k: KEY } }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + const result = await compileThenStrip(source, "/project/app/page.tsx"); + + assertStringIncludes(result, `const KEY = getEnv("SERVER_VALUE")`); + assertStringIncludes(result, `.${method}(${args})`); + assertStringIncludes(result, `throw new Error("server-only")`); + assertNotIncludes(result, "props:"); + }); + } + }); + + describe("remediation advice", () => { + it("tells the author to separate the value, not to re-declare the hook", async () => { + const code = [ + `import { getEnv } from "veryfront";`, + `import { makeClient } from "@/lib/client";`, + `const API_KEY = getEnv("API_KEY");`, + `const client = makeClient({ get: () => API_KEY });`, + `export async function getServerData() { return { props: { k: API_KEY } }; }`, + ].join("\n"); + + const error = await assertRejects(() => + stripServerOnlyExports(code, "/project/app/page.tsx") + ); + const { message } = error as Error; + + // The hook here *is* declared directly, so repeating that advice was noise + // covering up the only thing the author can actually act on. + assertStringIncludes(message, "still reads it from a body that runs only when"); + assertStringIncludes(message, "Move the shared value into a module the hook imports"); + assertNotIncludes(message, "Declare the hook directly"); + }); + + it("still tells the author to declare a re-exported hook directly", async () => { + const code = `export { loadIt as getServerData } from "./loader.ts";`; + + const error = await assertRejects(() => + stripServerOnlyExports(code, "/project/app/page.tsx") + ); + + assertStringIncludes((error as Error).message, "Declare the hook directly"); + }); + }); + // A `__name(fn, "fn")` registration esbuild emits is build metadata, not a + // browser read of `fn`. Recognising it is what lets the pass see a hook-only + // declaration as dead. When module code makes that proof impossible, the + // registration counts as a live browser read instead, and the hook's + // declaration, its server import and its secret all stay in the artifact. + // Silent retention is the one outcome a security stage must never produce, so + // the build stops instead. + describe("unprovable compiler name registrations", () => { + /** The esbuild `keepNames` shape, with one varying line of client code. */ + function keepNamesModule(clientLine: string): string { + return [ + `import { getEnv } from "veryfront";`, + `import { db } from "../lib/server/db.ts";`, + `var __defProp = Object.defineProperty;`, + `var __name = (target, value) => __defProp(target, "name", { value, configurable: true });`, + `const API_KEY = getEnv("ORDERS_SECRET");`, + `async function loadUser(id) { return db.query(id, API_KEY); }`, + `__name(loadUser, "loadUser");`, + `export async function getServerData(ctx) {`, + ` return { props: { user: await loadUser(ctx.id) } };`, + `}`, + clientLine, + `export default function Page() { return null; }`, + ].join("\n"); + } + + /** + * The security property, stated so neither outcome can be mistaken for the + * other: the server chain is gone, or the build failed. Never retained. + */ + async function assertStrippedOrRejected(clientLine: string): Promise { + let output: string; + try { + output = await stripServerOnlyExports(keepNamesModule(clientLine), "pages/orders.tsx"); + } catch (error) { + assertEquals((error as VeryfrontError).slug, "server-export-strip-failed"); + return; + } + assertNotIncludes(output, "../lib/server/db.ts"); + assertNotIncludes(output, `getEnv("ORDERS_SECRET")`); + } + + // Ordinary client code that reads `.constructor`, `__proto__`, `eval` or + // `Function`. Each of these once put the server import and the secret in the + // browser artifact. + const ordinaryClientLines: Array<[string, string]> = [ + ["typeof", `const isPlain = (v) => typeof v === "object";`], + ["optional constructor compare", `const isPlain = (v) => v?.constructor === Object;`], + ["constructor name read", `const label = (e) => e.constructor.name;`], + ["proto read", `const proto = (v) => v.__proto__;`], + ["instanceof Function", `const isFn = (v) => v instanceof Function;`], + ["typeof eval", `const hasEval = typeof eval;`], + ]; + + for (const [label, clientLine] of ordinaryClientLines) { + it(`never retains the server chain for ${label}`, async () => { + await assertStrippedOrRejected(clientLine); + }); + } + + // Shapes that do defeat the proof. The registration stays unrecognised, so + // the pass cannot see `loadUser` as dead and must not emit the module. + const unprovableClientLines: Array<[string, string]> = [ + ["a module-scope binding named `Object`", `const Object = globalThis.Object;`], + ["an assignment to the global `Object`", `globalThis.Object = Object;`], + ]; + + for (const [label, clientLine] of unprovableClientLines) { + it(`fails the build for ${label}`, async () => { + const error = await assertRejects(() => + stripServerOnlyExports(keepNamesModule(clientLine), "pages/orders.tsx") + ); + + assertEquals((error as VeryfrontError).slug, "server-export-strip-failed"); + const { message } = error as Error; + assertStringIncludes(message, "pages/orders.tsx"); + // The server-only binding that would have been removed, so the author + // knows which chain the unprovable registration is holding on to. + assertStringIncludes(message, "API_KEY"); + }); + } + + // The error has to be actionable: which construct blocked the proof, and + // what to do about it. + it("names the blocking construct and how to avoid it", async () => { + const error = await assertRejects(() => + stripServerOnlyExports( + keepNamesModule(`const Object = globalThis.Object;`), + "pages/orders.tsx", + ) + ); + const { message } = error as Error; + + assertStringIncludes(message, "declares a module-scope binding named `Object`"); + assertStringIncludes( + message, + "Move the code that reaches or rewrites the `Object` intrinsic", + ); + assertStringIncludes(message, "does not export a server data hook"); + // The hook is declared directly here, so the generic advice would be noise. + assertNotIncludes(message, "Declare the hook directly"); + }); + + // The failure is scoped to the registration that would have been removed. + // Defeating the proof is not by itself an error: without a hook-only + // registration there is nothing being retained, so the module builds + // exactly as it did before. + it("builds a module that defeats the proof with no hook-only registration", async () => { + const code = [ + `import { getEnv } from "veryfront";`, + `var __defProp = Object.defineProperty;`, + `var __name = (target, value) => __defProp(target, "name", { value, configurable: true });`, + `function Widget() { return null; }`, + `__name(Widget, "Widget");`, + `const Object2 = globalThis.Object;`, + `const Object = Object2;`, + `export async function getServerData() { return { props: { k: getEnv("K") } }; }`, + `export default Widget;`, + ].join("\n"); + + const result = await stripServerOnlyExports(code, "pages/orders.tsx"); + + // The client declaration and its registration are untouched. + assertStringIncludes(result, "function Widget()"); + assertStringIncludes(result, `__name(Widget, "Widget")`); + // The hook is still emptied, which is the pass's actual job. + assertNotIncludes(result, `getEnv("K")`); + }); + + // A registration whose target the browser still reads is not hook-only, so + // nothing is being retained and the build must not fail. + it("builds when an unprovable registration targets a browser-read binding", async () => { + const code = [ + `import { getEnv } from "veryfront";`, + `var __defProp = Object.defineProperty;`, + `var __name = (target, value) => __defProp(target, "name", { value, configurable: true });`, + `function format(v) { return String(v); }`, + `__name(format, "format");`, + `const Object = globalThis.Object;`, + `export async function getServerData() { return { props: { k: getEnv("K") } }; }`, + `export default function Page() { return format(1); }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code, "pages/orders.tsx"); + + assertStringIncludes(result, "function format(v)"); + assertNotIncludes(result, `getEnv("K")`); + }); + }); + describe("TypeScript reference classification", () => { /** * Both walkers' answers restricted to `names`, so a fixture asserts which @@ -1580,11 +6611,13 @@ export const c = 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. + // Enum, namespace and member IDs are fixed names, not reads. The + // walker must report none of the local names, because its answer also + // grows the hook dependency closure and can delete an unrelated + // declaration. Every liveness question now flows through the single + // scope-aware walker, so the flat over-approximation this fixture once + // held to a looser standard (`["Level", "Runtime"]`) answers precisely + // too. const { referenced, free } = await referencesAmong( `import { Level, Low, Runtime, Alias } from "./server.ts"; export function hook() { @@ -1596,7 +6629,7 @@ export function hook() { ); assertEquals(free, []); - assertEquals(referenced, ["Level", "Runtime"]); + assertEquals(referenced, []); }); }); diff --git a/src/transforms/pipeline/stages/browser-server-exports-strip.ts b/src/transforms/pipeline/stages/browser-server-exports-strip.ts index d68030f085..5fefad3867 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.ts @@ -13,7 +13,18 @@ * * esbuild cannot solve this for us: in transform mode (as opposed to bundle * mode) it never drops an import, because it cannot see that the binding was - * used only by a server-only hook that this pass just emptied. + * used only by a server-only hook that this pass just emptied. Nor can its + * tree-shaker own the rest of the job (verified against esbuild 0.28.1, both + * modes): a destructured module-scope value (`const { a } = getEnv(…)`) is + * never shaken (even `@__PURE__`-annotated) because destructuring may + * trigger getters or throw; an impure hook-only initialiser is + * indistinguishable from client init (`getEnv(…)` vs `bootClientAnalytics()`) + * without exactly the closure analysis below; keepNames registration calls + * pin hook-only helpers alive; and no esbuild mode reduces an unrelated + * unused import to a bare side-effect import while deleting a hook-owned one. + * The distinction that drives every one of those decisions, membership in + * the stripped hook's dependency closure, is not expressible in a bundler's + * side-effect model, so this stage computes it itself. * * The pass runs on the AST from the `CodeParser` contract, for the same reason * `rendering/rsc/export-extractor.ts` does: a module is not text. Matching @@ -21,6 +32,42 @@ * emptied, a `}` inside a regular expression literal ends a body early, and a * minified statement parses differently from the one a developer wrote. * + * Liveness is computed as *reachability over the module's binding graph*, not + * as "is this name mentioned somewhere else". The nodes are every module-scope + * binding, including a `var` that hoists out of a block, `if`, `try`, + * `switch`, loop or label, which binds module scope exactly as a top-level + * declaration does. The roots are what the module still *runs*: its surviving + * exports, the client component, and any side-effectful top-level statement, + * which keeps whatever it references. A declaration that merely introduces a + * name (a function, a `var dead = helper`, a class with no decorator, computed + * key or static initialiser) runs nothing, so it is elided from the roots and + * cannot vouch for anything: a private helper the module never calls used to be + * treated as unconditionally live and kept `const KEY = getEnv(…)` and its + * `node:crypto` import in the browser artifact. + * + * Roots and edges are drawn from different parts of a declaration, because + * "what runs at module load" and "what this binding reads" are different + * questions. A declaration roots only what it *evaluates*: `const handler = + * memo(() => KEY)` calls `memo` when the module loads, and reads `KEY` only if + * something calls the arrow, which needs `handler`. So the arrow's body is an + * edge out of `handler`, not a root, and a dead declaration can no longer + * vouch for a secret buried in a callback it never runs. An immediately + * invoked function is not deferred; nor is a class static block, a static + * field initialiser, a computed member key, a decorator or a heritage clause, + * all of which run where the class is defined. + * + * The edges are genuine reads, which is narrower than "identifier occurrences": + * a statement label, the *exported* half of an export specifier + * (`export { other as KEY }`), a non-computed property or JSX attribute name, + * and a declarator's reads of its own pattern's siblings all spell a name + * without reading the binding behind it. + * + * Deciding this per declaration instead (asking each one whether its name is + * mentioned elsewhere) cannot see a cycle. Two hook-only helpers that call + * each other are each the other's last consumer, so neither is ever removable + * and the secret they close over ships with them. Reachability drops the whole + * unreachable component however long it is. + * * Two rules keep it conservative: * * - Only an exported declaration is emptied. A private helper called @@ -43,20 +90,68 @@ * A module that names a server-only export and cannot be analysed fails the * build. This is a server/client boundary: emitting the module unchanged would * put the loader, its imports and any credential it closes over into the - * browser bundle, and a silent leak is worse than a stopped build. - * - * What this pass does: it empties hook bodies, drops the module-scope - * declarations the hooks were the last reader of (so `const API_KEY = - * getEnv(...)` used only by `getServerData` does not reach the browser), and - * removes the hook-only imports that leaves unused. What it does NOT do: reason - * about a value that is *also* read by browser code, or one reached only through - * an existing bare side-effect import — those are kept. It is not a general - * guarantee that every secret stays on the server, but a value used solely by a - * server-only hook no longer leaks. + * browser bundle, and a silent leak is worse than a stopped build. The same + * rule covers a hook this pass can *see* but cannot *stub*: a class, an + * imported binding re-exported under a hook name, a hook exported under an + * ES2022 string name (`export { loadIt as "getServerData" }`) or as a namespace + * re-export (`export * as getServerData from …`), a hook binding the module + * *reassigns* (`export let getServerData = stub; getServerData = realLoader`), + * and one it *redeclares* through a hoisted `var` below the top level + * (`export var getServerData = stub; if (cond) { var getServerData = + * realLoader }`), stubbing the declarator would leave the later write to put + * the real loader back at module-evaluation time, so the build stops rather + * than shipping the declaration. It covers two more cases on the other side of + * the analysis: a binding the graph proves dead but that sits in a position + * with no declaration to cut out, such as the `for (var KEY of …)` head, whose + * binding is what the loop assigns to; and a dead binding read only from a + * deferred body of a declaration that does run (`const handler = memo(() => + * KEY)` with nothing reading `handler`), where keeping the binding ships the + * secret and cutting it leaves the surviving call referring to nothing. + * + * As a final check the pass re-parses the artifact it is about to emit and + * verifies that no binding it *chose to remove* is still imported or referenced + * there, so a removal that the tree edits or the generator did not actually + * carry out fails the build instead of leaking. That check is scoped to those + * names and no further: it does not second-guess which bindings were chosen, + * so it neither catches a secret this pass decided to keep nor vetoes a removal + * that should not have happened. The elision, taint and reachability rules + * below are what decide that, and the checks above are what stop the build when + * they cannot. + * + * What this pass does: it empties hook bodies, drops every module-scope binding + * in the hooks' dependency closure that nothing surviving can reach, including + * destructured ones and ones a nested `var` hoists up, so neither + * `const API_KEY = getEnv(...)` nor `const { apiKey } = getEnv(...)` nor + * `if (cond) { var API_KEY = getEnv(...) }` used only by `getServerData` + * reaches the browser, and removes the hook-only imports that leaves unused. + * Unreachable code holding those bindings goes with them, however far it sits + * from the hook: a private helper nothing calls, a dead class, a dead helper + * cycle, a `if (…) { var debug = … }` dev aid. + * + * What it does NOT do: rewrite or delete code the module *runs*. This pass + * removes bindings, never side effects, so a value that surviving + * module-evaluation code reads is kept however server-only it looks. That + * covers a value browser code also reads, one a bare top-level statement + * references, and (the case that surprises) a declaration nothing reaches + * whose own initialiser still runs and reads the value while running: + * `const boot = initAnalytics(KEY)`, `Object.defineProperty(box, "run", …)`, + * `const dead = new Wrapper(KEY)`, `` tag`…${KEY}` ``, `const { a } = KEY`, + * `KEY?.[k]`, `await KEY`, `[KEY, ...list]`, `{ [k]: KEY }`, a class static + * block, a `for (var x of read(KEY)) …` loop, and the esbuild lowerings that + * are calls by the time this pass sees them: `using`/`await using` become + * `__using(stack, KEY)`, a TypeScript `enum` or `namespace` becomes an + * immediately invoked function, and a decorator becomes a call evaluated where + * the class is defined. Each of those reads the binding at module load, so + * dropping it would change what the module does. It is also not a dead-code + * eliminator: an unreachable declaration that holds nothing server-only stays + * where it is. Nor does it model `eval`. It is not a general guarantee that + * every secret stays on the server, but a value used solely by a server-only + * hook no longer leaks. */ import { tryResolve } from "#veryfront/extensions/contracts.ts"; import type { ASTNode, CodeParser } from "#veryfront/extensions/parser/index.ts"; +import { SERVER_EXPORT_STRIP_FAILED } from "#veryfront/errors"; import type { TransformContext, TransformPlugin } from "../types.ts"; import { TransformStage } from "../types.ts"; import { @@ -85,7 +180,8 @@ function appendSourceMapDirective(code: string, directive: string): string { /** Source the stub nodes are lifted from, so no node shape is hand-built. */ const STUB_SOURCE = `function __vfStub() { throw new Error("server-only"); } -const __vfStubInit = function () { throw new Error("server-only"); };`; +const __vfStubInit = function () { throw new Error("server-only"); }; +function __vfStubEmpty() {}`; type Node = Record & { type: string }; @@ -120,79 +216,27 @@ function walk(node: Node, visit: (node: Node) => boolean | void): void { } /** - * 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. + * Grouping and type-only nodes that wrap a runtime expression unchanged. + * `typeof (window as unknown)` and `typeof window!` read exactly as `typeof + * window` does once the wrapper is off, so every check that asks what an + * expression is has to look past them first. */ -const RUNTIME_TS_NODE_TYPES = new Set([ - // Value expressions wrapping a value expression plus an erased type operand. +const TRANSPARENT_EXPRESSION_TYPES = new Set([ + "ParenthesizedExpression", "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", + "TSTypeAssertion", ]); -/** - * 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); +/** The runtime expression a chain of transparent wrappers stands for. */ +function unwrapTransparent(node: Node): Node { + let current = node; + while (TRANSPARENT_EXPRESSION_TYPES.has(current.type) && isNode(current.expression)) { + current = current.expression; + } + return current; } function nodeName(value: unknown): string | null { @@ -201,6 +245,18 @@ function nodeName(value: unknown): string | null { return typeof name === "string" ? name : null; } +/** + * The name an export clause publishes. Usually an identifier, but ES2022 also + * allows a string literal (`export { loadIt as "getServerData" }`), which the + * runtime looks the hook up under just the same. + */ +function exportedName(value: unknown): string | null { + const identifier = nodeName(value); + if (identifier !== null) return identifier; + if (!isNode(value)) return null; + return typeof value.value === "string" ? value.value : null; +} + function bodyOf(ast: ASTNode): Node[] { const program = (ast as { program?: unknown }).program; const source: Node = isNode(program) ? program : ast; @@ -208,29 +264,57 @@ function bodyOf(ast: ASTNode): Node[] { return Array.isArray(body) ? body.filter(isNode) : []; } -/** The stub body and stub initialiser, parsed rather than constructed. */ -async function parseStubs(parser: CodeParser): Promise<{ body: Node; init: Node } | null> { +function isRuntimeTsModuleDeclaration(node: Node): boolean { + return node.type === "TSModuleDeclaration" && node.declare !== true && + node.global !== true && nodeName(node.id) !== null; +} + +function isRuntimeTsImportEqualsDeclaration(node: Node): boolean { + return node.type === "TSImportEqualsDeclaration" && node.importKind !== "type"; +} + +/** The stub nodes this pass splices in, parsed rather than constructed. */ +interface Stubs { + /** Hook function body: `{ throw new Error("server-only") }`. */ + body: Node; + /** Hook initialiser: `function () { throw new Error("server-only") }`. */ + init: Node; + /** Empty block, for a statement slot a dropped `var` declaration leaves bare. */ + empty: Node; +} + +async function parseStubs(parser: CodeParser): Promise { const ast = await parser.parse({ code: STUB_SOURCE, filePath: "vf-stub.ts" }); - const [fn, variable] = bodyOf(ast); + const [fn, variable, emptyFn] = bodyOf(ast); const body = fn?.body; + const empty = emptyFn?.body; const declarations = variable?.declarations; const init = Array.isArray(declarations) && isNode(declarations[0]) ? (declarations[0] as Node).init : undefined; - if (!isNode(body) || !isNode(init)) return null; - return { body, init }; + if (!isNode(body) || !isNode(init) || !isNode(empty)) return null; + return { body, init, empty }; } -/** Every binding name a destructuring pattern introduces. */ -function patternBoundNames(pattern: Node): string[] { - const names: string[] = []; +/** The declarators of a variable declaration, as nodes. */ +function declaratorsOf(declaration: Node): Node[] { + return Array.isArray(declaration.declarations) ? declaration.declarations.filter(isNode) : []; +} + +/** Every identifier node a destructuring pattern binds (binding positions only). */ +function patternBindingIdentifiers(pattern: Node): Node[] { + const ids: Node[] = []; const collect = (node: Node): void => { + if (node.type === "TSParameterProperty") { + if (isNode(node.parameter)) collect(node.parameter); + return; + } + if (node.type === "Identifier") { - const name = nodeName(node); - if (name) names.push(name); + ids.push(node); return; } @@ -244,13 +328,6 @@ 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); @@ -274,6 +351,16 @@ function patternBoundNames(pattern: Node): string[] { collect(pattern); + return ids; +} + +/** Every binding name a destructuring pattern introduces. */ +function patternBoundNames(pattern: Node): string[] { + const names: string[] = []; + for (const id of patternBindingIdentifiers(pattern)) { + const name = nodeName(id); + if (name) names.push(name); + } return names; } @@ -295,19 +382,42 @@ function exportedHookBindings(body: Node[]): { locals: Set; unhandled: s name != null && SERVER_ONLY_EXPORTS.includes(name); for (const statement of body) { - if (statement.type !== "ExportNamedDeclaration") continue; if (statement.exportKind === "type") continue; + // `export * as getServerData from "./loader"` names a hook without binding + // anything locally, so there is no declaration to stub and the loader + // module stays in the browser graph. + if (statement.type === "ExportAllDeclaration") { + const exported = exportedName(statement.exported); + if (isHook(exported)) unhandled.push(`export * as ${exported} from …`); + continue; + } + + if (statement.type !== "ExportNamedDeclaration") continue; + for (const specifier of Array.isArray(statement.specifiers) ? statement.specifiers : []) { if (!isNode(specifier)) continue; if (specifier.exportKind === "type") continue; - if (!isHook(nodeName(specifier.exported))) continue; + const exported = exportedName(specifier.exported); + if (!isHook(exported)) continue; // `export { x as getServerData } from "./loader"` never binds `x` here, // so there is no body to empty and the module it points at is still // pulled into the graph. if (isNode(statement.source)) { - unhandled.push(`export { … as ${nodeName(specifier.exported)} } from …`); + unhandled.push(`export { … as ${exported} } from …`); + continue; + } + + // ES2022 arbitrary module namespace name: `export { loadIt as + // "getServerData" }`. The runtime still looks the hook up under that + // string, but the export clause is a form this pass does not rewrite, so + // it stops the build rather than passing the module through untouched. + // In the browser pipeline esbuild has already normalised this to a plain + // identifier export by the time the stage runs, so this branch guards + // direct callers of `stripServerOnlyExports` rather than that path. + if (nodeName(specifier.exported) === null) { + unhandled.push(`export { … as "${exported}" }`); continue; } @@ -348,15 +458,20 @@ function exportedHookBindings(body: Node[]): { locals: Set; unhandled: s /** * Empty the body of every exported server-only hook. Emptying rather than * deleting keeps the binding, so an export clause or re-export stays valid. + * + * Returns the set of hook names that were actually emptied. The caller + * compares it against the full target set: a hook this pass identified but + * could not stub (a class declaration, an imported binding re-exported under + * a hook name) must fail the build, because emitting it unchanged would ship + * the server declaration to the browser. */ function emptyServerOnlyHooks( body: Node[], targets: Set, - stubs: { body: Node; init: Node }, -): boolean { - if (targets.size === 0) return false; - - let changed = false; + stubs: Stubs, +): Set { + const emptied = new Set(); + if (targets.size === 0) return emptied; const declarationsIn = (statement: Node): Node[] => { const declaration = statement.type === "ExportNamedDeclaration" @@ -372,7 +487,7 @@ function emptyServerOnlyHooks( if (!name || !targets.has(name)) continue; declaration.params = []; declaration.body = structuredClone(stubs.body); - changed = true; + emptied.add(name); continue; } @@ -385,67 +500,128 @@ function emptyServerOnlyHooks( const name = nodeName(declarator.id); if (!name || !targets.has(name)) continue; declarator.init = structuredClone(stubs.init); - changed = true; + emptied.add(name); } } } - return changed; + return emptied; } /** - * 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 }`). 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 - * counted as a use of itself when deciding whether it is dead. + * One place a module-scope binding is written down: a node of the binding + * graph, together with the way to take it back out of the tree. + * + * A destructuring declarator (`const { apiKey } = getEnv(...)`) is a single + * site carrying every name its pattern binds: it is removed only when *all* of + * them are dead, so a pattern the client still partly reads survives whole. + * This is what stops a destructured server value from shipping: esbuild's + * tree-shaker never removes a destructuring of a call (even a + * `@__PURE__`-annotated one) because the pattern itself may trigger getters or + * throw. */ -function referencedIdentifiers(body: Node[], excluded?: WeakSet): Set { - const referenced = new Set(); - // Filled in as each parent is visited, which always happens before its - // children. - const fixedNames = new WeakSet(); - - const markFixedName = (node: Node): void => { - const property = node.type === "MemberExpression" || node.type === "OptionalMemberExpression" - ? node.property - : node.type === "ObjectProperty" || node.type === "ObjectMethod" || - node.type === "ClassMethod" || node.type === "ClassProperty" || - node.type === "ClassAccessorProperty" - ? node.key - : node.type === "TSEnumDeclaration" || node.type === "TSEnumMember" - ? node.id - : node.type === "TSQualifiedName" - ? node.right - : undefined; - - if (node.computed === true) return; - if (isNode(property)) fixedNames.add(property); - }; - - const markEnumLocalReferences = (node: Node): void => { - 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); +interface BindingSite { + /** Every name this site binds. */ + names: string[]; + /** What the site's own code reads, its outgoing edges in the graph. */ + references: Set; + /** The node to elide when asking what the rest of the module still reads. */ + node: Node; + /** Exported sites are part of the module's contract and are never removed. */ + exported: boolean; + /** Whether a `var` site was hoisted out of nested control flow. */ + nested: boolean; + /** Takes the site out of the tree, or `null` when the form has no safe cut. */ + remove: (() => void) | null; +} + +/** The names a declarator binds, or `null` when the pattern is unanalysable. */ +function declaratorBoundNames(declarator: Node): string[] | null { + const id = declarator.id; + if (!isNode(id)) return null; + + const bindingIds = id.type === "Identifier" ? [id] : patternBindingIdentifiers(id); + const names: string[] = []; + for (const bindingId of bindingIds) { + const name = nodeName(bindingId); + if (name) names.push(name); + } + // A pattern with an unnameable binding cannot be reasoned about; a pattern + // binding nothing (`const {} = …`) has no dead name to chase. Either way the + // declarator simply stays. + if (names.length === 0 || names.length !== bindingIds.length) return null; + return names; +} + +/** + * What a single declarator reads. Asking `freeReferencedIdentifiers` about a + * one-declarator declaration rather than the declarator node keeps the pattern + * in binding position: a default that reads a *sibling* of the same pattern + * (`const { token, auth = token } = …`) is bound, not free, so it never counts + * as an outside consumer of the declaration it lives in. A nested `var` also + * receives the lexical bindings visible where it was written, so a block-local + * shadow cannot be mistaken for a module binding with the same name. + */ +function declaratorReferences( + declaration: Node, + declarator: Node, + enclosingBindings: ReadonlySet = NO_BOUND_NAMES, +): Set { + return freeReferencedIdentifiers( + { + type: "VariableDeclaration", + kind: declaration.kind, + declarations: [declarator], + }, + NOTHING_ELIDED, + NOTHING_ELIDED, + enclosingBindings, + ); +} + +/** + * Every module-scope binding, as graph nodes. + * + * Top-level declarations are the obvious ones, but a `var` hoists out of any + * block, `if`, `try`, `switch`, loop or label it is written in, so those bind + * module scope too and belong in the graph: the pass used to miss them + * entirely, which made a secret declared as `if (cond) { var KEY = getEnv(…) }` + * permanently unremovable. Function bodies and class static blocks are separate + * `var` scopes and are not entered. + * + * `removeStatement` collects top-level statements the caller should filter out; + * deeper sites carry a closure that edits the tree in place. + */ +function moduleScopeBindingSites( + body: Node[], + stubs: Stubs, + removeStatement: (statement: Node) => void, +): BindingSite[] { + const sites: BindingSite[] = []; + + const addDeclarators = ( + declaration: Node, + exported: boolean, + detach: (() => void) | null, + nested = false, + enclosingBindings: ReadonlySet = NO_BOUND_NAMES, + ): void => { + for (const declarator of declaratorsOf(declaration)) { + const names = declaratorBoundNames(declarator); + if (!names) continue; + + sites.push({ + names, + references: declaratorReferences(declaration, declarator, enclosingBindings), + node: declarator, + exported, + nested, + remove: detach === null ? null : () => { + declaration.declarations = declaratorsOf(declaration).filter((candidate) => + candidate !== declarator + ); + if (declaratorsOf(declaration).length === 0) detach(); + }, }); } }; @@ -453,148 +629,362 @@ function referencedIdentifiers(body: Node[], excluded?: WeakSet): Set { - 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); + const exported = statement.type === "ExportNamedDeclaration" || + statement.type === "ExportDefaultDeclaration" || + (isRuntimeTsImportEqualsDeclaration(statement) && statement.isExport === true); + const declaration = statement.type === "ExportNamedDeclaration" || + statement.type === "ExportDefaultDeclaration" + ? statement.declaration + : statement; + if (!isNode(declaration)) continue; - if (node.type === "Identifier" || node.type === "JSXIdentifier") { - if (fixedNames.has(node)) return true; - if (excluded?.has(node)) return true; - const name = nodeName(node); - if (name) referenced.add(name); + if (declaration.type === "FunctionDeclaration" || declaration.type === "ClassDeclaration") { + const name = nodeName(declaration.id); + if (name) { + sites.push({ + names: [name], + references: freeReferencedIdentifiers(declaration), + node: statement, + exported, + nested: false, + remove: exported ? null : () => removeStatement(statement), + }); + } + } else if ( + (declaration.type === "TSEnumDeclaration" && declaration.declare !== true) || + isRuntimeTsModuleDeclaration(declaration) || + isRuntimeTsImportEqualsDeclaration(declaration) + ) { + const name = nodeName(declaration.id); + if (name) { + sites.push({ + names: [name], + references: freeReferencedIdentifiers(declaration), + node: statement, + exported, + nested: false, + remove: exported ? null : () => removeStatement(statement), + }); } + } else if (declaration.type === "VariableDeclaration") { + addDeclarators(declaration, exported, exported ? null : () => removeStatement(statement)); + } - return true; - }); + collectHoistedVarSites( + declaration, + stubs, + (nestedDeclaration, nestedExported, detach, enclosingBindings) => + addDeclarators(nestedDeclaration, nestedExported, detach, true, enclosingBindings), + ); } - return referenced; + return sites; } -/** A top-level declaration and the binding names / binding-id nodes it owns. */ -interface ModuleScopeDecl { - statement: Node; - declarator?: Node; - names: string[]; - bindingIds: Node[]; +/** Constructs that open a fresh `var` scope, so a `var` inside stops here. */ +function startsVarScope(node: Node): boolean { + return node.type === "FunctionDeclaration" || node.type === "FunctionExpression" || + node.type === "ArrowFunctionExpression" || node.type === "ObjectMethod" || + node.type === "ClassMethod" || node.type === "ClassDeclaration" || + node.type === "ClassExpression" || node.type === "StaticBlock" || + node.type.startsWith("TS"); } -/** - * Non-exported top-level `const`/`let`/`var`/`function`/`class` declarations - * whose bindings we could safely drop if nothing references them. Exported - * declarations are part of the module's contract and are never candidates. - * Destructuring declarations are skipped — a pattern can carry default-value - * references, and a partial removal is not worth the risk. - */ -function moduleScopeDeclarations(body: Node[]): ModuleScopeDecl[] { - const decls: ModuleScopeDecl[] = []; +/** Lexical bindings introduced by the control-flow scope `node` opens. */ +function directLexicalBindingNames(node: Node): Set { + const names = new Set(); - for (const statement of body) { - if (statement.type === "FunctionDeclaration" || statement.type === "ClassDeclaration") { - const id = statement.id; - const name = nodeName(id); - if (name && isNode(id)) decls.push({ statement, names: [name], bindingIds: [id] }); - continue; + const bindDeclaration = (statement: Node): void => { + const declaration = statement.type === "ExportNamedDeclaration" || + statement.type === "ExportDefaultDeclaration" + ? statement.declaration + : statement; + if (!isNode(declaration)) return; + + if (declaration.type === "VariableDeclaration") { + if (declaration.kind === "var") return; + for (const declarator of declaratorsOf(declaration)) { + if (!isNode(declarator.id)) continue; + for (const name of patternBoundNames(declarator.id)) names.add(name); + } + return; } - if (statement.type === "VariableDeclaration") { - const variableDecls: ModuleScopeDecl[] = []; + if ( + declaration.type === "FunctionDeclaration" || + declaration.type === "ClassDeclaration" || + declaration.type === "TSEnumDeclaration" || + isRuntimeTsModuleDeclaration(declaration) || + isRuntimeTsImportEqualsDeclaration(declaration) + ) { + const name = nodeName(declaration.id); + if (name) names.add(name); + } + }; - for ( - const declarator of Array.isArray(statement.declarations) ? statement.declarations : [] - ) { - if (!isNode(declarator)) continue; - const id = declarator.id; - if (isNode(id) && id.type === "Identifier") { - const name = nodeName(id); - if (name) variableDecls.push({ statement, declarator, names: [name], bindingIds: [id] }); - } else { - variableDecls.length = 0; - break; + if (node.type === "BlockStatement") { + for (const statement of Array.isArray(node.body) ? node.body : []) { + if (isNode(statement)) bindDeclaration(statement); + } + } else if (node.type === "SwitchStatement") { + for (const caseNode of Array.isArray(node.cases) ? node.cases : []) { + if (!isNode(caseNode)) continue; + for (const statement of Array.isArray(caseNode.consequent) ? caseNode.consequent : []) { + if (isNode(statement)) bindDeclaration(statement); + } + } + } else if (node.type === "CatchClause" && isNode(node.param)) { + for (const name of patternBoundNames(node.param)) names.add(name); + } else if ( + node.type === "ForStatement" || node.type === "ForInStatement" || + node.type === "ForOfStatement" + ) { + const declaration = node.init ?? node.left; + if (isNode(declaration) && declaration.type === "VariableDeclaration") { + bindDeclaration(declaration); + } + } + + return names; +} + +/** + * `var` declarations *below* a top-level statement, which hoist into module + * scope all the same. Each is registered with the edit that removes it: an + * element of a statement list is filtered out, a statement slot + * (`label: var KEY = …`, `if (c) var KEY = …`) becomes an empty block, and a + * `for` initialiser is cleared. + * + * A `for…in`/`for…of` head has no such edit: the binding is what the loop + * assigns to, so those sites are registered as unremovable and the caller + * fails the build rather than shipping the value they hold. The callback also + * receives the lexical bindings surrounding each site, so reference analysis + * resolves block-local shadows instead of similarly named module bindings. + */ +function collectHoistedVarSites( + root: Node, + stubs: Stubs, + add: ( + declaration: Node, + exported: boolean, + detach: (() => void) | null, + enclosingBindings: ReadonlySet, + ) => void, +): void { + if (startsVarScope(root)) return; + + const slotDetach = (owner: Node, key: string): (() => void) | null => { + if (key === "body" || key === "consequent" || key === "alternate") { + return () => { + owner[key] = structuredClone(stubs.empty); + }; + } + if (key === "init" && owner.type === "ForStatement") { + return () => { + owner[key] = null; + }; + } + return null; + }; + + const descend = (node: Node, enclosingBindings: ReadonlySet): void => { + const directBindings = directLexicalBindingNames(node); + const scopedBindings = directBindings.size === 0 + ? enclosingBindings + : new Set([...enclosingBindings, ...directBindings]); + + for (const [key, value] of Object.entries(node)) { + if (key === "loc" || key === "leadingComments" || key === "trailingComments") continue; + + if (Array.isArray(value)) { + for (const entry of value) { + if (!isNode(entry) || startsVarScope(entry)) continue; + visitChild(entry, () => { + node[key] = (node[key] as unknown[]).filter((candidate) => candidate !== entry); + }, scopedBindings); } + continue; } - decls.push(...variableDecls); + if (!isNode(value) || startsVarScope(value)) continue; + visitChild(value, slotDetach(node, key), scopedBindings); + } + }; + + const visitChild = ( + child: Node, + detach: (() => void) | null, + enclosingBindings: ReadonlySet, + ): void => { + if (child.type === "VariableDeclaration" && child.kind === "var") { + add(child, false, detach, enclosingBindings); + } + descend(child, enclosingBindings); + }; + + descend(root, NO_BOUND_NAMES); +} + +/** Every binding declared directly by the module, including exported declarations. */ +function moduleScopeBindingNames(body: Node[]): Set { + const names = new Set(); + + for (const statement of body) { + const declaration = statement.type === "ExportNamedDeclaration" || + statement.type === "ExportDefaultDeclaration" + ? statement.declaration + : statement; + if (!isNode(declaration)) continue; + + if ( + declaration.type === "FunctionDeclaration" || declaration.type === "ClassDeclaration" || + (declaration.type === "TSEnumDeclaration" && declaration.declare !== true) || + isRuntimeTsModuleDeclaration(declaration) || + isRuntimeTsImportEqualsDeclaration(declaration) + ) { + const name = nodeName(declaration.id); + if (name) names.add(name); + continue; + } + + if (declaration.type !== "VariableDeclaration") continue; + for ( + const declarator of Array.isArray(declaration.declarations) ? declaration.declarations : [] + ) { + if (!isNode(declarator) || !isNode(declarator.id)) continue; + for (const name of patternBoundNames(declarator.id)) names.add(name); } } - return decls; + return names; } /** Whether a name is bound in the current lexical stack. */ interface LexicalScope { - kind: "function" | "block"; + kind: "var" | "block"; names: Set; + module?: true; } function isLexicallyBound(name: string, scopes: LexicalScope[]): boolean { return scopes.some((scope) => scope.names.has(name)); } +const NOTHING_ELIDED: ReadonlySet = new Set(); +const NO_BOUND_NAMES: ReadonlySet = new Set(); + /** - * Free identifiers read by a hook body or by a declaration in the stripped - * hook's dependency closure. Unlike `referencedIdentifiers`, this is - * scope-aware: a nested declaration that shadows `loadJob` must not hide a - * real outer hook read of the imported `loadJob`, and a nested local inside a - * pruned helper must not add an unrelated import to the hook closure. + * Free identifiers genuinely *read* by a subtree: the edges of the + * module-scope binding graph. + * + * Scope-aware: a nested declaration that shadows `loadJob` must not hide a real + * outer hook read of the imported `loadJob`, and a nested local inside a pruned + * helper must not add an unrelated import to the hook closure. + * + * Position-aware too, because several identifier positions are not reads and + * counting them keeps server state alive forever: a statement label + * (`KEY: for (…) { break KEY }`), the *exported* half of an export specifier + * (`export { other as KEY }`), a non-computed property or JSX attribute name, + * and the `import.meta` meta-property all spell a name without reading the + * binding it happens to match. + * + * `elided` names declaration nodes to treat as already deleted: their bindings + * are not introduced and their own reads are not collected, so the result is + * exactly what the *rest* of the module still reads. That is how a candidate + * for removal stops masking the reads of the code around it. + * + * `deferred` names functions, methods and instance fields whose bodies do not + * run where they are written. Their reads are still reads; they are just not + * reads the *module evaluation* performs, which is the difference between the + * roots of the liveness walk and the edges of it. + * + * `initiallyBound` supplies the lexical context around a subtree analyzed on + * its own. Nested hoisted `var` sites use it to preserve their enclosing block, + * catch and loop scopes. `onFreeIdentifier` exposes the concrete unbound node + * when callers must distinguish a real global from a lexically shadowed name. */ -function freeReferencedIdentifiers(root: Node): Set { +function freeReferencedIdentifiers( + root: Node, + elided: ReadonlySet = NOTHING_ELIDED, + deferred: ReadonlySet = NOTHING_ELIDED, + initiallyBound: ReadonlySet = NO_BOUND_NAMES, + onFreeIdentifier?: (node: Node) => void, + onIdentifier?: (node: Node, binding: LexicalScope | undefined) => void, + onBindingIdentifier?: (node: Node, binding: LexicalScope) => void, +): Set { const free = new Set(); - const rootScope: LexicalScope = { kind: "function", names: new Set() }; + const rootScope: LexicalScope = { + kind: "var", + names: new Set(initiallyBound), + module: root.type === "Program" ? true : undefined, + }; - const currentFunctionScope = (scopes: LexicalScope[]): LexicalScope => - scopes.find((scope) => scope.kind === "function") ?? scopes[0] ?? rootScope; + const currentVarScope = (scopes: LexicalScope[]): LexicalScope => + scopes.find((scope) => scope.kind === "var") ?? scopes[0] ?? rootScope; const bindPatternNames = (scope: LexicalScope, value: unknown): void => { if (!isNode(value)) return; - for (const name of patternBoundNames(value)) scope.names.add(name); + for (const identifier of patternBindingIdentifiers(value)) { + const name = nodeName(identifier); + if (!name) continue; + scope.names.add(name); + onBindingIdentifier?.(identifier, scope); + } }; - 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 addFreeName = ( + name: string | null, + scopes: LexicalScope[], + identifier?: Node, + ): void => { + if (name && !isLexicallyBound(name, scopes)) { + free.add(name); + if (identifier?.type === "Identifier") onFreeIdentifier?.(identifier); + } }; - const bindDirectDeclarations = (scope: LexicalScope, node: Node): void => { - const body = node.body; - if (!Array.isArray(body)) return; + const isIntrinsicJsxTagName = (name: string): boolean => { + const first = name.charCodeAt(0); + return (first >= 97 && first <= 122) || name.includes("-"); + }; - for (const statement of body) { - if (!isNode(statement)) continue; - if (statement.type === "FunctionDeclaration" || statement.type === "ClassDeclaration") { - bindPatternNames(scope, statement.id); + const bindDirectStatements = (scope: LexicalScope, statements: unknown[]): void => { + for (const statement of statements) { + if (!isNode(statement) || elided.has(statement)) continue; + const declaration = statement.type === "ExportNamedDeclaration" || + statement.type === "ExportDefaultDeclaration" + ? statement.declaration + : statement; + if (!isNode(declaration)) continue; + if ( + declaration.type === "FunctionDeclaration" || + declaration.type === "ClassDeclaration" || + declaration.type === "TSEnumDeclaration" || + isRuntimeTsModuleDeclaration(declaration) || + isRuntimeTsImportEqualsDeclaration(declaration) + ) { + bindPatternNames(scope, declaration.id); continue; } - if (bindHoistedRuntimeTsDeclaration(scope, statement)) continue; - if (statement.type !== "VariableDeclaration") continue; - for ( - const declarator of Array.isArray(statement.declarations) ? statement.declarations : [] - ) { - if (isNode(declarator)) bindPatternNames(scope, declarator.id); + if (declaration.type !== "VariableDeclaration") continue; + // `var` belongs to the nearest var scope, not to this lexical block. + // `bindNestedVarDeclarations` pre-binds it in that scope before the + // block is visited, so binding it here would give declarations and + // references two different identities. + if (declaration.kind === "var" && scope.kind === "block") continue; + for (const declarator of declaratorsOf(declaration)) { + if (!elided.has(declarator)) bindPatternNames(scope, declarator.id); } } }; + const bindDirectDeclarations = (scope: LexicalScope, node: Node): void => { + const body = node.body; + if (Array.isArray(body)) bindDirectStatements(scope, body); + }; + 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" || @@ -606,12 +996,11 @@ function freeReferencedIdentifiers(root: Node): Set { } if (child.type === "VariableDeclaration" && child.kind === "var") { - for ( - const declarator of Array.isArray(child.declarations) ? child.declarations : [] - ) { - if (isNode(declarator)) bindPatternNames(scope, declarator.id); + for (const declarator of declaratorsOf(child)) { + if (!elided.has(declarator)) bindPatternNames(scope, declarator.id); } } + bindNestedVarDeclarations(scope, child); } }; @@ -620,38 +1009,47 @@ 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") { - visitDecorators(pattern, scopes); - return; - } + const visitPatternRuntime = ( + pattern: Node, + scopes: LexicalScope[], + decoratorScopes: LexicalScope[] = scopes, + ): void => { + // Babel hangs a parameter decorator off the pattern itself (a plain + // `Identifier`, an `AssignmentPattern` or a destructuring pattern) and not + // only off a `TSParameterProperty`. A decorator is ordinary runtime code + // whose reads count, so `constructor(@inject(loadSecret) value: string)` + // keeps the import it needs; missing it dropped that import out from under + // the surviving client declaration. esbuild either rejects a parameter + // decorator or lowers it away before the browser pipeline reaches this + // stage, so this is defence in depth for direct callers and for any parser + // that hands over an untransformed tree, not a path the pipeline walks. + visitDecorators(pattern, decoratorScopes); if (pattern.type === "TSParameterProperty") { - visitDecorators(pattern, scopes); - if (isNode(pattern.parameter)) visitPatternRuntime(pattern.parameter, scopes); + if (isNode(pattern.parameter)) { + visitPatternRuntime(pattern.parameter, scopes, decoratorScopes); + } return; } + if (pattern.type === "Identifier") return; + if (pattern.type === "AssignmentPattern") { - if (isNode(pattern.left)) visitPatternRuntime(pattern.left, scopes); + if (isNode(pattern.left)) visitPatternRuntime(pattern.left, scopes, decoratorScopes); if (isNode(pattern.right)) visit(pattern.right, scopes); return; } if (pattern.type === "RestElement") { - if (isNode(pattern.argument)) visitPatternRuntime(pattern.argument, scopes); + if (isNode(pattern.argument)) { + visitPatternRuntime(pattern.argument, scopes, decoratorScopes); + } return; } if (pattern.type === "ArrayPattern") { for (const element of Array.isArray(pattern.elements) ? pattern.elements : []) { - if (isNode(element)) visitPatternRuntime(element, scopes); + if (isNode(element)) visitPatternRuntime(element, scopes, decoratorScopes); } return; } @@ -660,7 +1058,9 @@ function freeReferencedIdentifiers(root: Node): Set { for (const property of Array.isArray(pattern.properties) ? pattern.properties : []) { if (!isNode(property)) continue; if (property.type === "RestElement") { - if (isNode(property.argument)) visitPatternRuntime(property.argument, scopes); + if (isNode(property.argument)) { + visitPatternRuntime(property.argument, scopes, decoratorScopes); + } continue; } if (property.type !== "ObjectProperty") { @@ -668,7 +1068,9 @@ function freeReferencedIdentifiers(root: Node): Set { continue; } if (property.computed === true && isNode(property.key)) visit(property.key, scopes); - if (isNode(property.value)) visitPatternRuntime(property.value, scopes); + if (isNode(property.value)) { + visitPatternRuntime(property.value, scopes, decoratorScopes); + } } return; } @@ -676,38 +1078,81 @@ function freeReferencedIdentifiers(root: Node): Set { visit(pattern, scopes); }; + const visitPatternDecorators = ( + pattern: Node, + decoratorScopes: LexicalScope[], + ): void => { + visitDecorators(pattern, decoratorScopes); + + if (pattern.type === "TSParameterProperty" && isNode(pattern.parameter)) { + visitPatternDecorators(pattern.parameter, decoratorScopes); + return; + } + if (pattern.type === "AssignmentPattern" && isNode(pattern.left)) { + visitPatternDecorators(pattern.left, decoratorScopes); + return; + } + if (pattern.type === "RestElement" && isNode(pattern.argument)) { + visitPatternDecorators(pattern.argument, decoratorScopes); + return; + } + if (pattern.type === "ArrayPattern") { + for (const element of Array.isArray(pattern.elements) ? pattern.elements : []) { + if (isNode(element)) visitPatternDecorators(element, decoratorScopes); + } + return; + } + if (pattern.type === "ObjectPattern") { + for (const property of Array.isArray(pattern.properties) ? pattern.properties : []) { + if (!isNode(property)) continue; + if (property.type === "RestElement" && isNode(property.argument)) { + visitPatternDecorators(property.argument, decoratorScopes); + } else if (property.type === "ObjectProperty" && isNode(property.value)) { + visitPatternDecorators(property.value, decoratorScopes); + } + } + } + }; + const bindVariableDeclaration = (node: Node, scopes: LexicalScope[]): void => { - const targetScope = node.kind === "var" ? currentFunctionScope(scopes) : scopes[0] ?? rootScope; - for ( - const declarator of Array.isArray(node.declarations) ? node.declarations : [] - ) { - if (isNode(declarator)) bindPatternNames(targetScope, declarator.id); + const targetScope = node.kind === "var" ? currentVarScope(scopes) : scopes[0] ?? rootScope; + for (const declarator of declaratorsOf(node)) { + if (!elided.has(declarator)) bindPatternNames(targetScope, declarator.id); } }; const visitVariableDeclaration = (node: Node, scopes: LexicalScope[]): void => { bindVariableDeclaration(node, scopes); - for ( - const declarator of Array.isArray(node.declarations) ? node.declarations : [] - ) { - if (!isNode(declarator)) continue; + for (const declarator of declaratorsOf(node)) { + if (elided.has(declarator)) continue; if (isNode(declarator.id)) visitPatternRuntime(declarator.id, scopes); if (isNode(declarator.init)) visit(declarator.init, scopes); } }; const visitFunction = (node: Node, scopes: LexicalScope[]): void => { - const functionScope: LexicalScope = { kind: "function", names: new Set() }; - if (node.type === "FunctionDeclaration") bindPatternNames(scopes[0] ?? rootScope, node.id); - bindPatternNames(functionScope, node.id); + const functionScope: LexicalScope = { kind: "var", names: new Set() }; + const isDeferred = deferred.has(node); + if (node.type === "FunctionDeclaration") { + bindPatternNames(scopes[0] ?? rootScope, node.id); + } else { + // Only a named function expression creates a binding inside its own + // body. A function declaration's name belongs to the enclosing scope. + bindPatternNames(functionScope, node.id); + } for (const param of Array.isArray(node.params) ? node.params : []) { if (isNode(param)) bindPatternNames(functionScope, param); } for (const param of Array.isArray(node.params) ? node.params : []) { - if (isNode(param)) visitPatternRuntime(param, [functionScope, ...scopes]); + if (isNode(param)) { + if (isDeferred) visitPatternDecorators(param, scopes); + else visitPatternRuntime(param, [functionScope, ...scopes], scopes); + } } + if (isDeferred) return; + bindDirectDeclarations(functionScope, isNode(node.body) ? node.body : node); if (isNode(node.body)) bindNestedVarDeclarations(functionScope, node.body); @@ -723,9 +1168,19 @@ function freeReferencedIdentifiers(root: Node): Set { } }; + // A decorator is ordinary code in an easily missed position: `@withKey(KEY)` + // reads `KEY` just as a call in an initialiser would. Classes, their members + // and TypeScript parameter properties can all carry one. + const visitDecorators = (node: Node, scopes: LexicalScope[]): void => { + for (const decorator of Array.isArray(node.decorators) ? node.decorators : []) { + if (isNode(decorator)) visit(decorator, scopes); + } + }; + const visitObjectMember = (node: Node, scopes: LexicalScope[]): void => { visitDecorators(node, scopes); if (node.computed === true && isNode(node.key)) visit(node.key, scopes); + if (deferred.has(node)) return; if (isNode(node.value)) visit(node.value, scopes); }; @@ -750,128 +1205,223 @@ function freeReferencedIdentifiers(root: Node): Set { const switchScope: LexicalScope = { kind: "block", names: new Set() }; const scoped = [switchScope, ...scopes]; + for (const caseNode of Array.isArray(node.cases) ? node.cases : []) { + if (isNode(caseNode) && Array.isArray(caseNode.consequent)) { + bindDirectStatements(switchScope, caseNode.consequent); + } + } + for (const caseNode of Array.isArray(node.cases) ? node.cases : []) { if (!isNode(caseNode)) continue; - if (isNode(caseNode.test)) visit(caseNode.test, scopes); + if (isNode(caseNode.test)) visit(caseNode.test, scoped); for (const statement of Array.isArray(caseNode.consequent) ? caseNode.consequent : []) { if (isNode(statement)) visit(statement, scoped); } } }; - const visit = (node: Node, scopes: LexicalScope[]): void => { - if (node.type === "ImportDeclaration") 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); - if (name && !isLexicallyBound(name, scopes)) free.add(name); - return; - } - + const visitTsExpression = (node: Node, scopes: LexicalScope[]): boolean => { if ( - node.type === "Program" || node.type === "BlockStatement" || - node.type === "TSModuleBlock" + node.type === "TSAsExpression" || node.type === "TSTypeAssertion" || + node.type === "TSNonNullExpression" || node.type === "TSInstantiationExpression" || + node.type === "TSSatisfiesExpression" ) { - const scope: LexicalScope = { kind: "block", names: new Set() }; - bindDirectDeclarations(scope, node); - for (const statement of Array.isArray(node.body) ? node.body : []) { - if (isNode(statement)) visit(statement, [scope, ...scopes]); + if (isNode(node.expression)) visit(node.expression, scopes); + return true; + } + + if (node.type.startsWith("TS")) return true; + return false; + }; + + const visitTsEnum = (node: Node, scopes: LexicalScope[]): void => { + bindPatternNames(scopes[0] ?? rootScope, node.id); + + const enumScope: LexicalScope = { kind: "block", names: new Set() }; + bindPatternNames(enumScope, node.id); + for (const member of Array.isArray(node.members) ? node.members : []) { + if (isNode(member) && isNode(member.id) && member.id.type === "Identifier") { + bindPatternNames(enumScope, member.id); } - 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]); + const enumScopes = [enumScope, ...scopes]; + for (const member of Array.isArray(node.members) ? node.members : []) { + if (isNode(member) && isNode(member.initializer)) { + visit(member.initializer, enumScopes); } - return; } + }; - if (node.type === "VariableDeclaration") { - visitVariableDeclaration(node, scopes); + const visitTsModule = (node: Node, scopes: LexicalScope[]): void => { + if (!isRuntimeTsModuleDeclaration(node)) return; + + bindPatternNames(scopes[0] ?? rootScope, node.id); + const moduleScope: LexicalScope = { kind: "var", names: new Set() }; + bindPatternNames(moduleScope, node.id); + const moduleScopes = [moduleScope, ...scopes]; + + const body = node.body; + if (!isNode(body)) return; + if (body.type === "TSModuleBlock") { + bindDirectDeclarations(moduleScope, body); + bindNestedVarDeclarations(moduleScope, body); + for (const statement of Array.isArray(body.body) ? body.body : []) { + if (isNode(statement)) visit(statement, moduleScopes); + } return; } + if (body.type === "TSModuleDeclaration") visitTsModule(body, moduleScopes); + }; - if ( - node.type === "FunctionDeclaration" || node.type === "FunctionExpression" || - node.type === "ArrowFunctionExpression" - ) { - visitFunction(node, scopes); + const visitTsEntityName = (node: Node, scopes: LexicalScope[]): void => { + if (node.type === "TSQualifiedName" && isNode(node.left)) { + visitTsEntityName(node.left, scopes); return; } + if (node.type === "Identifier") visit(node, scopes); + }; + + const visitTsImportEquals = (node: Node, scopes: LexicalScope[]): void => { + if (!isRuntimeTsImportEqualsDeclaration(node)) return; + bindPatternNames(scopes[0] ?? rootScope, node.id); + if (isNode(node.moduleReference)) visitTsEntityName(node.moduleReference, scopes); + }; - // 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. + const visit = (node: Node, scopes: LexicalScope[]): void => { + if (node.type === "ImportDeclaration" || elided.has(node)) return; + // `declare const`, `declare function`, `declare class`, `declare enum` and + // `declare namespace` are ambient: they emit nothing, so a name inside one + // (a type annotation, a heritage clause) is not a runtime read. Decorators + // are the exception: tsc and esbuild both emit a runtime `__decorate` call + // for `@audit declare id: string`, so a decorated declared member falls + // through and its erased type positions are skipped individually below. + if (node.declare === true && !hasDecorators(node)) return; 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]); - } + visitTsEnum(node, scopes); + return; + } + if (node.type === "TSModuleDeclaration") { + visitTsModule(node, scopes); + return; + } + if (node.type === "TSImportEqualsDeclaration") { + visitTsImportEquals(node, scopes); + return; + } + // `export = handler` emits an assignment to the module export, so its + // operand is a real runtime read. + if (node.type === "TSExportAssignment") { + if (isNode(node.expression)) visit(node.expression, scopes); + return; + } + if (visitTsExpression(node, scopes)) return; + + if (node.type === "Identifier") { + const name = nodeName(node); + onIdentifier?.(node, name ? scopes.find((scope) => scope.names.has(name)) : undefined); + addFreeName(name, scopes, node); + return; + } + + if (node.type === "JSXIdentifier") { + const name = nodeName(node); + if (name && !isIntrinsicJsxTagName(name)) addFreeName(name, scopes); + return; + } + + // A statement label lives in its own namespace: `break KEY` does not read + // the module's `KEY`. + if (node.type === "LabeledStatement") { + if (isNode(node.body)) visit(node.body, scopes); + return; + } + if (node.type === "BreakStatement" || node.type === "ContinueStatement") return; + + // `export { other as KEY }` reads `other` and publishes the *name* `KEY`. + // A re-export (`export … from "./x"`) reads nothing declared here at all. + // `export type { Cfg }` and `export { type Only }` are erased whole, so + // neither the clause nor the type-only specifier reads its local binding. + if (node.type === "ExportNamedDeclaration" || node.type === "ExportAllDeclaration") { + if (isNode(node.source) || node.exportKind === "type") return; + visitChildren(node, scopes); + return; + } + if (node.type === "ExportSpecifier") { + if (node.exportKind !== "type" && isNode(node.local)) visit(node.local, scopes); + return; + } + if (node.type === "ExportDefaultSpecifier" || node.type === "ExportNamespaceSpecifier") return; + + // `import.meta` spells `import` and `meta`, and reads neither. + if (node.type === "MetaProperty") return; + if (node.type === "PrivateName") return; + + if (node.type === "JSXAttribute") { + if (isNode(node.value)) visit(node.value, scopes); + return; + } + if (node.type === "JSXMemberExpression") { + let object = node.object; + while (isNode(object) && object.type === "JSXMemberExpression") object = object.object; + if (isNode(object)) { + if (object.type === "JSXIdentifier") addFreeName(nodeName(object), scopes); + else visit(object, scopes); + } + return; + } + if (node.type === "JSXNamespacedName") return; + + if (node.type === "Program" || node.type === "BlockStatement") { + const scope: LexicalScope = { + kind: "block", + names: new Set(), + module: node.type === "Program" ? true : undefined, + }; + bindDirectDeclarations(scope, node); + for (const statement of Array.isArray(node.body) ? node.body : []) { + if (isNode(statement)) visit(statement, [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]); + if (node.type === "StaticBlock") { + const scope: LexicalScope = { kind: "var", names: new Set() }; + bindDirectDeclarations(scope, node); + bindNestedVarDeclarations(scope, node); + for (const statement of Array.isArray(node.body) ? node.body : []) { + if (isNode(statement)) visit(statement, [scope, ...scopes]); } return; } - if (node.type === "TSImportEqualsDeclaration") { - bindPatternNames(scopes[0] ?? rootScope, node.id); - if (isNode(node.moduleReference)) visit(node.moduleReference, scopes); + if (node.type === "VariableDeclaration") { + visitVariableDeclaration(node, 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); + if ( + node.type === "FunctionDeclaration" || node.type === "FunctionExpression" || + node.type === "ArrowFunctionExpression" + ) { + visitFunction(node, scopes); return; } if (node.type === "ClassDeclaration" || node.type === "ClassExpression") { if (node.type === "ClassDeclaration") bindPatternNames(scopes[0] ?? rootScope, node.id); - visitDecorators(node, scopes); + const classScope: LexicalScope = { kind: "block", names: new Set() }; + // A named class expression owns a private name binding. A class + // declaration uses its enclosing lexical binding both outside and + // inside the class body. + if (node.type === "ClassExpression") bindPatternNames(classScope, node.id); + const classScopes = [classScope, ...scopes]; const body = node.body; - if (isNode(body)) visitChildren(body, scopes); - if (isNode(node.superClass)) visit(node.superClass, scopes); + // A class decorator is evaluated outside the class, so it does not see + // the class binding. + visitDecorators(node, scopes); + if (isNode(node.superClass)) visit(node.superClass, classScopes); + if (isNode(body)) visitChildren(body, classScopes); return; } @@ -906,13 +1456,16 @@ function freeReferencedIdentifiers(root: Node): Set { if ( node.type === "ObjectProperty" || node.type === "ClassProperty" || - node.type === "ClassAccessorProperty" + node.type === "ClassPrivateProperty" || node.type === "ClassAccessorProperty" ) { visitObjectMember(node, scopes); return; } - if (node.type === "ObjectMethod" || node.type === "ClassMethod") { + if ( + node.type === "ObjectMethod" || node.type === "ClassMethod" || + node.type === "ClassPrivateMethod" + ) { visitDecorators(node, scopes); if (node.computed === true && isNode(node.key)) visit(node.key, scopes); visitFunction(node, scopes); @@ -923,10 +1476,68 @@ function freeReferencedIdentifiers(root: Node): Set { }; bindDirectDeclarations(rootScope, root); + bindNestedVarDeclarations(rootScope, root); visit(root, [rootScope]); return free; } +/** One concrete lexical binding, distinguished from same-spelled shadows. */ +interface LexicalBindingIdentity { + readonly scope: LexicalScope; + readonly name: string; +} + +interface LexicalBindingIndex { + declaration(node: Node): LexicalBindingIdentity | null; + reference(node: Node): LexicalBindingIdentity | null; +} + +/** Resolves declaration and reference identifiers to their concrete binding. */ +function indexLexicalBindings(body: Node[]): LexicalBindingIndex { + const declarations = new Map(); + const references = new Map(); + const identities = new WeakMap>(); + const identityFor = ( + scope: LexicalScope | undefined, + node: Node, + ): LexicalBindingIdentity | null => { + const name = nodeName(node); + if (!scope || !name) return null; + let byName = identities.get(scope); + if (!byName) { + byName = new Map(); + identities.set(scope, byName); + } + let identity = byName.get(name); + if (!identity) { + identity = { scope, name }; + byName.set(name, identity); + } + return identity; + }; + + freeReferencedIdentifiers( + { type: "Program", body } as Node, + NOTHING_ELIDED, + NOTHING_ELIDED, + NO_BOUND_NAMES, + undefined, + (node, scope) => { + const identity = identityFor(scope, node); + if (identity) references.set(node, identity); + }, + (node, scope) => { + const identity = identityFor(scope, node); + if (identity) declarations.set(node, identity); + }, + ); + + return { + declaration: (node) => declarations.get(node) ?? null, + reference: (node) => references.get(node) ?? null, + }; +} + /** * Identifiers referenced inside the server-only hooks that are about to be * emptied — the seed of the hook's dependency closure. Must be collected before @@ -961,52 +1572,3086 @@ function hookReferencedIdentifiers(body: Node[], targets: Set): Set; + free: Set; +} { + const program = (ast as { program?: unknown }).program; + const root: Node = isNode(program) ? program : ast; + const free = freeReferencedIdentifiers(root); + return { referenced: new Set(free), free }; +} + +/** + * Names written by assignment-like expressions anywhere in the module: + * `getServerData = realLoader`, `({ getServerData } = loaders)`, + * `getServerData++`, `for (getServerData of loaders) …`. Member writes + * (`obj.getServerData = …`) assign a property, not a binding, and are not + * collected. Import statements never contain assignments and are skipped. + * + * Used to fail closed on a module that reassigns a hook binding: the pass can + * stub only the declarator, and the assignment would put the real loader back + * at module-evaluation time. Collection is deliberately scope-blind: a nested + * local that shadows a hook name and is assigned also stops the build, because + * on this boundary a stopped build is recoverable and a shipped loader is not. + */ +function assignedIdentifierNodes(body: Node[]): Set { + const assigned = new Set(); + + const collectTargets = (target: Node): void => { + if (target.type === "Identifier") { + assigned.add(target); + return; + } + + if (target.type === "AssignmentPattern") { + if (isNode(target.left)) collectTargets(target.left); + return; + } + + if (target.type === "RestElement" || target.type === "SpreadElement") { + if (isNode(target.argument)) collectTargets(target.argument); + return; + } + + // A destructuring assignment target parses as a pattern or, depending on + // the parser, as the expression form of the same shape. + if (target.type === "ArrayPattern" || target.type === "ArrayExpression") { + for (const element of Array.isArray(target.elements) ? target.elements : []) { + if (isNode(element)) collectTargets(element); + } + return; + } + + if (target.type === "ObjectPattern" || target.type === "ObjectExpression") { + for (const property of Array.isArray(target.properties) ? target.properties : []) { + if (!isNode(property)) continue; + if (isNode(property.argument)) { + collectTargets(property.argument); + continue; + } + if (isNode(property.value)) collectTargets(property.value); + } + return; + } + + if (isNode(target.expression)) collectTargets(target.expression); + }; + + for (const statement of body) { + if (statement.type === "ImportDeclaration") continue; + + walk(statement, (node) => { + if (node.type === "ImportDeclaration") return false; + + if (node.type === "AssignmentExpression" && isNode(node.left)) collectTargets(node.left); + if (node.type === "UpdateExpression" && isNode(node.argument)) collectTargets(node.argument); + if ( + (node.type === "ForInStatement" || node.type === "ForOfStatement") && + isNode(node.left) && node.left.type !== "VariableDeclaration" + ) { + collectTargets(node.left); + } + + return true; + }); + } + + return assigned; +} + +function assignedNames(body: Node[]): Set { + return new Set( + [...assignedIdentifierNodes(body)].map(nodeName).filter((name): name is string => !!name), + ); +} + +/** Assignment targets that resolve to a binding declared by this module. */ +function assignedModuleBindingNames(body: Node[]): Set { + const targets = assignedIdentifierNodes(body); + const assigned = new Set(); + + freeReferencedIdentifiers( + { type: "Program", body }, + NOTHING_ELIDED, + NOTHING_ELIDED, + NO_BOUND_NAMES, + undefined, + (identifier, binding) => { + const name = nodeName(identifier); + if (name && targets.has(identifier) && binding?.module === true) assigned.add(name); + }, + ); + + return assigned; +} + +/** + * Names a `var` hoists into module scope from somewhere below the top level: + * `{ var getServerData = realLoader }`, `if (cond) { var getServerData = … }`, + * `for (var getServerData of realLoaders) {}`, and the same inside `switch`, + * `try`, `while` and labelled statements. + * + * `emptyServerOnlyHooks` only rewrites top-level declarations, and + * `assignedNames` only sees assignment and update expressions, so a hoisted + * redeclaration slipped past both: the stub was emitted *and* the real loader + * survived below it, overwriting the stub the moment the module evaluated. + * Treating these as binding writes fails the build instead, exactly as a + * plain reassignment does. + * + * Traversal stops at every construct that starts a new `var` scope (function + * bodies, class bodies, class static blocks and TypeScript-only nodes) so a + * nested `function Page() { var getServerData = 1 }` is a local of `Page` and + * is not reported. + */ +function hoistedVarNames(body: Node[]): Set { + const hoisted = new Set(); + + const collect = (node: Node): void => { + for (const child of children(node)) { + if (startsVarScope(child)) continue; + + if (child.type === "VariableDeclaration" && child.kind === "var") { + for (const declarator of Array.isArray(child.declarations) ? child.declarations : []) { + if (!isNode(declarator) || !isNode(declarator.id)) continue; + for (const name of patternBoundNames(declarator.id)) hoisted.add(name); + } + } + + collect(child); + } + }; + + // Only statements *below* the top level hoist past the stubber: a top-level + // `var` declaration is a declaration `emptyServerOnlyHooks` already rewrites, + // so entering the tree at the unwrapped declaration keeps it out of the set + // while still reaching anything nested inside its initialisers. + for (const statement of body) { + if (statement.type === "ImportDeclaration") continue; + + const declaration = statement.type === "ExportNamedDeclaration" || + statement.type === "ExportDefaultDeclaration" + ? statement.declaration + : statement; + const root = isNode(declaration) ? declaration : statement; + if (startsVarScope(root)) continue; + + collect(root); + } + + return hoisted; +} + +function literalText(node: Node | undefined): string | null { + if (!node) return null; + return typeof node.value === "string" ? node.value : nodeName(node); +} + +function stringLiteralText(node: Node | undefined): string | null { + return node && typeof node.value === "string" ? node.value : null; +} + +/** Identifier nodes that resolve past every module and nested lexical binding. */ +function unshadowedGlobalIdentifierNodes(body: Node[]): Set { + const moduleBindings = moduleScopeBindingNames(body); + for (const name of hoistedVarNames(body)) moduleBindings.add(name); + for (const statement of body) { + if (statement.type !== "ImportDeclaration" || statement.importKind === "type") continue; + for (const specifier of Array.isArray(statement.specifiers) ? statement.specifiers : []) { + if (!isNode(specifier) || specifier.importKind === "type") continue; + const name = nodeName(specifier.local); + if (name) moduleBindings.add(name); + } + } + + const globals = new Set(); + freeReferencedIdentifiers( + { type: "Program", body }, + NOTHING_ELIDED, + NOTHING_ELIDED, + NO_BOUND_NAMES, + (identifier) => { + const name = nodeName(identifier); + if (name && !moduleBindings.has(name)) globals.add(identifier); + }, + ); + return globals; +} + +function isUnshadowedGlobalIdentifier( + node: Node | undefined, + name: string, + globals: ReadonlySet, +): boolean { + return node?.type === "Identifier" && nodeName(node) === name && globals.has(node); +} + +function isObjectDefineProperty(node: Node | undefined): boolean { + if (!node || node.type !== "MemberExpression") return false; + const property = isNode(node.property) ? node.property : undefined; + const propertyName = node.computed === true ? stringLiteralText(property) : nodeName(property); + return nodeName(node.object) === "Object" && propertyName === "defineProperty"; +} + +/** + * Names that reach the global object. A browser module written before + * `globalThis` was universal uses `window` or `self`, and a module compiled for + * Node uses `global`. A main browsing context also publishes itself as + * `frames`, `parent`, and `top`, so every name here reaches the same `Object` + * slot. + */ +const GLOBAL_OBJECT_NAMES = [ + "globalThis", + "window", + "self", + "global", + "frames", + "parent", + "top", +]; + +/** + * `document.defaultView` is the same window object under a member access, so a + * module reaches the global through it without naming any of the identifiers + * above. + */ +function isDocumentDefaultView(node: Node | undefined, globals: ReadonlySet): boolean { + if (node?.type !== "MemberExpression" && node?.type !== "OptionalMemberExpression") return false; + const property = isNode(node.property) ? node.property : undefined; + const key = node.computed === true ? stringLiteralText(property) : nodeName(property); + if (key !== "defaultView") return false; + return isUnshadowedGlobalIdentifier( + isNode(node.object) ? node.object : undefined, + "document", + globals, + ); +} + +function isUnshadowedGlobalObject(node: Node | undefined, globals: ReadonlySet): boolean { + if (!node) return false; + const value = unwrapTransparent(node); + if (isDocumentDefaultView(value, globals)) return true; + if ( + GLOBAL_OBJECT_NAMES.some((name) => isUnshadowedGlobalIdentifier(value, name, globals)) + ) { + return true; + } + if (value.type !== "MemberExpression" && value.type !== "OptionalMemberExpression") { + return false; + } + + const object = isNode(value.object) ? value.object : undefined; + if (!isUnshadowedGlobalObject(object, globals)) return false; + const alias = memberKey(value); + // A dynamic member may resolve to any of the standard self aliases. Failing + // closed here prevents an indirect intrinsic mutation from being mistaken + // for removable compiler metadata. + return alias === null || GLOBAL_OBJECT_NAMES.includes(alias); +} + +function isGlobalObjectSlot(node: Node | undefined, globals: ReadonlySet): boolean { + if (node?.type !== "MemberExpression" && node?.type !== "OptionalMemberExpression") { + return false; + } + + const object = isNode(node.object) ? node.object : undefined; + if (!isUnshadowedGlobalObject(object, globals)) return false; + const property = isNode(node.property) ? node.property : undefined; + if (node.computed !== true) return nodeName(property) === "Object"; + const key = stringLiteralText(property); + return key === null || key === "Object"; +} + +/** + * Whether writing to `target` could replace `Object.defineProperty`. + * + * `isObjectDefineProperty` proves the base is the bare `Object` identifier, + * which a write target does not have to be: `globalThis.Object.defineProperty + * = record` reaches the same slot through the global object, and + * `Object[key] = record` reaches it through a key this stage cannot evaluate. + * Only those known intrinsic bases fail closed here. A base this stage cannot + * bound at all is rejected by `writesGuardedKeyThroughUnprovenBase` instead. + */ +function writesDefinePropertyMember(target: Node, globals: ReadonlySet): boolean { + if (target.type !== "MemberExpression" && target.type !== "OptionalMemberExpression") { + return false; + } + + const object = isNode(target.object) ? target.object : undefined; + const objectIsIntrinsic = isUnshadowedGlobalIdentifier(object, "Object", globals) || + isGlobalObjectSlot(object, globals); + if (!objectIsIntrinsic) return false; + + const property = isNode(target.property) ? target.property : undefined; + if (target.computed !== true) return nodeName(property) === "defineProperty"; + + const key = stringLiteralText(property); + return key === null || key === "defineProperty"; +} + +function isIntrinsicDefinePropertyCall( + node: Node | undefined, + globals: ReadonlySet, +): boolean { + if (node?.type !== "MemberExpression" && node?.type !== "OptionalMemberExpression") { + return false; + } + + const property = isNode(node.property) ? node.property : undefined; + const propertyName = node.computed === true ? stringLiteralText(property) : nodeName(property); + if (propertyName !== "defineProperty") return false; + + const object = isNode(node.object) ? node.object : undefined; + return isUnshadowedGlobalIdentifier(object, "Object", globals) || + isUnshadowedGlobalIdentifier(object, "Reflect", globals) || + isGlobalObjectSlot(object, globals); +} + +interface NormalizedCall { + callee: Node; + args: Node[]; + unknownArgs: boolean; +} + +/** + * The function and arguments a direct call invokes, including `.call` and + * `.apply` wrappers. Unknown spreads and apply lists stay explicitly unknown + * so a mutation check can fail closed instead of guessing argument positions. + */ +function normalizeCall(node: Node, globals: ReadonlySet): NormalizedCall | null { + if (node.type !== "CallExpression" && node.type !== "OptionalCallExpression") return null; + if (!isNode(node.callee)) return null; + + let callee = unwrapTransparent(node.callee); + let args = Array.isArray(node.arguments) ? node.arguments.filter(isNode) : []; + let unknownArgs = args.some((argument) => argument.type === "SpreadElement"); + + // A wrapper can be wrapped again. `f.call.call(f, null, …)` invokes `f` with + // one more receiver peeled off, and `f.call.apply(f, [null, …])` does the + // same through a list, so unwrapping once leaves the real callee hidden + // behind `f.call`. Peel until what is left is not another `call` or `apply`. + while (callee.type === "MemberExpression" || callee.type === "OptionalMemberExpression") { + const wrapper = memberKey(callee); + if ((wrapper !== "call" && wrapper !== "apply") || !isNode(callee.object)) break; + + const invoked = unwrapTransparent(callee.object); + // `Reflect.apply(fn, thisArg, args)` is a standard invocation primitive, + // not `Reflect` being invoked through Function.prototype.apply. Preserve + // the actual function and argument list so intrinsic mutation checks see + // the same call JavaScript evaluates. + if (wrapper === "apply" && isUnshadowedGlobalIdentifier(invoked, "Reflect", globals)) { + const target = args[0] ? unwrapTransparent(args[0]) : undefined; + if (!target) return { callee: invoked, args: [], unknownArgs: true }; + if (unknownArgs) return { callee: target, args: [], unknownArgs: true }; + + const list = args[2] ? unwrapTransparent(args[2]) : undefined; + if (list?.type !== "ArrayExpression" || !Array.isArray(list.elements)) { + return { callee: target, args: [], unknownArgs: true }; + } + const reflected: Node[] = []; + for (const element of list.elements) { + if (!isNode(element) || element.type === "SpreadElement") { + return { callee: target, args: [], unknownArgs: true }; + } + reflected.push(element); + } + args = reflected; + unknownArgs = false; + callee = target; + continue; + } + + if (wrapper === "call") { + args = args.slice(1); + callee = invoked; + continue; + } + + const list = args[1] ? unwrapTransparent(args[1]) : undefined; + if (list?.type !== "ArrayExpression" || !Array.isArray(list.elements)) { + return { callee: invoked, args: [], unknownArgs: true }; + } + const spread: Node[] = []; + for (const element of list.elements) { + if (!isNode(element) || element.type === "SpreadElement") { + return { callee: invoked, args: [], unknownArgs: true }; + } + spread.push(element); + } + args = spread; + unknownArgs = false; + callee = invoked; + } + + return { callee, args, unknownArgs }; +} + +/** Bindings that can hold the intrinsic defineProperty function. */ +function intrinsicDefinePropertyAliases( + body: Node[], + globals: ReadonlySet, + bindings: LexicalBindingIndex, +): Set { + const flows: Array<{ target: LexicalBindingIdentity; value: Node }> = []; + const destructured: Array<{ pattern: Node; value: Node; declaration: boolean }> = []; + const aliases = new Set(); + const collectDestructured = (pattern: Node, declaration: boolean): void => { + if (pattern.type !== "ObjectPattern") return; + + for (const property of Array.isArray(pattern.properties) ? pattern.properties : []) { + if (!isNode(property) || property.type !== "ObjectProperty" || !isNode(property.value)) { + continue; + } + const key = isNode(property.key) ? property.key : undefined; + const name = property.computed === true ? stringLiteralText(key) : literalText(key); + if (name !== null && name !== "defineProperty") continue; + for (const identifier of patternBindingIdentifiers(property.value)) { + const target = declaration + ? bindings.declaration(identifier) + : bindings.reference(identifier); + if (target) aliases.add(target); + } + } + }; + + for (const statement of body) { + if (statement.type === "ImportDeclaration") continue; + walk(statement, (node) => { + let targetNode: Node | undefined; + let value: Node | undefined; + let declaration = false; + if (node.type === "VariableDeclarator") { + targetNode = isNode(node.id) ? unwrapTransparent(node.id) : undefined; + value = isNode(node.init) ? node.init : undefined; + declaration = true; + } else if (node.type === "AssignmentExpression") { + targetNode = isNode(node.left) ? unwrapTransparent(node.left) : undefined; + value = isNode(node.right) ? node.right : undefined; + } + if (targetNode && value && targetNode.type === "ObjectPattern") { + destructured.push({ pattern: targetNode, value, declaration }); + } + if (targetNode?.type !== "Identifier" || !value) return; + const target = declaration + ? bindings.declaration(targetNode) + : bindings.reference(targetNode); + if (target) flows.push({ target, value }); + }); + } + + const addPatternPropertyBindings = ( + pattern: Node, + propertyNames: ReadonlySet, + declaration: boolean, + targets: Set, + ): void => { + if (pattern.type !== "ObjectPattern") return; + for (const property of Array.isArray(pattern.properties) ? pattern.properties : []) { + if (!isNode(property) || property.type !== "ObjectProperty" || !isNode(property.value)) { + continue; + } + const key = isNode(property.key) ? property.key : undefined; + const name = property.computed === true ? stringLiteralText(key) : literalText(key); + if (name !== null && !propertyNames.has(name)) continue; + for (const identifier of patternBindingIdentifiers(property.value)) { + const target = declaration + ? bindings.declaration(identifier) + : bindings.reference(identifier); + if (target) targets.add(target); + } + } + }; + + const globalContainers = new Set(); + const carriesGlobalContainer = (node: Node): boolean => { + const value = unwrapTransparent(node); + if (isUnshadowedGlobalObject(value, globals)) return true; + if (value.type === "Identifier") { + const source = bindings.reference(value); + return source !== null && globalContainers.has(source); + } + if (value.type === "SequenceExpression") { + const expressions = Array.isArray(value.expressions) ? value.expressions.filter(isNode) : []; + const last = expressions.at(-1); + return !!last && carriesGlobalContainer(last); + } + if (value.type === "ConditionalExpression") { + return (isNode(value.consequent) && carriesGlobalContainer(value.consequent)) || + (isNode(value.alternate) && carriesGlobalContainer(value.alternate)); + } + if (value.type === "LogicalExpression") { + return (isNode(value.left) && carriesGlobalContainer(value.left)) || + (isNode(value.right) && carriesGlobalContainer(value.right)); + } + if (value.type === "AssignmentExpression" && isNode(value.right)) { + return carriesGlobalContainer(value.right); + } + if (value.type === "AwaitExpression" && isNode(value.argument)) { + return carriesGlobalContainer(value.argument); + } + return false; + }; + + let globalsChanged = true; + while (globalsChanged) { + globalsChanged = false; + for (const { target, value } of flows) { + if (globalContainers.has(target) || !carriesGlobalContainer(value)) continue; + globalContainers.add(target); + globalsChanged = true; + } + } + + const intrinsicContainers = new Set(); + for (const { pattern, value, declaration } of destructured) { + if (carriesGlobalContainer(value)) { + addPatternPropertyBindings( + pattern, + new Set(["Object", "Reflect"]), + declaration, + intrinsicContainers, + ); + } + } + const isGlobalReflectSlot = (node: Node): boolean => { + const value = unwrapTransparent(node); + if (value.type !== "MemberExpression" && value.type !== "OptionalMemberExpression") { + return false; + } + const object = isNode(value.object) ? value.object : undefined; + if (!isUnshadowedGlobalObject(object, globals)) return false; + const key = memberKey(value); + return key === null || key === "Reflect"; + }; + const carriesIntrinsicContainer = (node: Node): boolean => { + const value = unwrapTransparent(node); + if ( + isUnshadowedGlobalIdentifier(value, "Object", globals) || + isUnshadowedGlobalIdentifier(value, "Reflect", globals) || + isGlobalObjectSlot(value, globals) || isGlobalReflectSlot(value) + ) { + return true; + } + if (value.type === "Identifier") { + const source = bindings.reference(value); + return source !== null && intrinsicContainers.has(source); + } + if (value.type === "SequenceExpression") { + const expressions = Array.isArray(value.expressions) ? value.expressions.filter(isNode) : []; + const last = expressions.at(-1); + return !!last && carriesIntrinsicContainer(last); + } + if (value.type === "ConditionalExpression") { + return (isNode(value.consequent) && carriesIntrinsicContainer(value.consequent)) || + (isNode(value.alternate) && carriesIntrinsicContainer(value.alternate)); + } + if (value.type === "LogicalExpression") { + const rightCarries = isNode(value.right) && carriesIntrinsicContainer(value.right); + if (value.operator === "&&") return rightCarries; + return rightCarries || (isNode(value.left) && carriesIntrinsicContainer(value.left)); + } + if (value.type === "AssignmentExpression" && isNode(value.right)) { + return carriesIntrinsicContainer(value.right); + } + if (value.type === "AwaitExpression" && isNode(value.argument)) { + return carriesIntrinsicContainer(value.argument); + } + return false; + }; + + let containersChanged = true; + while (containersChanged) { + containersChanged = false; + for (const { target, value } of flows) { + if (intrinsicContainers.has(target) || !carriesIntrinsicContainer(value)) continue; + intrinsicContainers.add(target); + containersChanged = true; + } + } + for (const { pattern, value, declaration } of destructured) { + if (carriesIntrinsicContainer(value)) collectDestructured(pattern, declaration); + } + + const carriesIntrinsic = (node: Node): boolean => { + const value = unwrapTransparent(node); + if (isIntrinsicDefinePropertyCall(value, globals)) return true; + if (value.type === "Identifier") { + const source = bindings.reference(value); + return source !== null && aliases.has(source); + } + if (value.type === "SequenceExpression") { + const expressions = Array.isArray(value.expressions) ? value.expressions.filter(isNode) : []; + const last = expressions.at(-1); + return !!last && carriesIntrinsic(last); + } + if (value.type === "ConditionalExpression") { + return (isNode(value.consequent) && carriesIntrinsic(value.consequent)) || + (isNode(value.alternate) && carriesIntrinsic(value.alternate)); + } + if (value.type === "LogicalExpression") { + const rightCarries = isNode(value.right) && carriesIntrinsic(value.right); + if (value.operator === "&&") return rightCarries; + return rightCarries || (isNode(value.left) && carriesIntrinsic(value.left)); + } + if (value.type === "AssignmentExpression" && isNode(value.right)) { + return carriesIntrinsic(value.right); + } + if (value.type === "AwaitExpression" && isNode(value.argument)) { + return carriesIntrinsic(value.argument); + } + if ( + (value.type === "CallExpression" || value.type === "OptionalCallExpression") && + isNode(value.callee) + ) { + const callee = unwrapTransparent(value.callee); + if ( + (callee.type === "MemberExpression" || callee.type === "OptionalMemberExpression") && + memberKey(callee) === "bind" && isNode(callee.object) + ) { + return carriesIntrinsic(callee.object); + } + } + return false; + }; + + let changed = true; + while (changed) { + changed = false; + for (const { target, value } of flows) { + if (aliases.has(target) || !carriesIntrinsic(value)) continue; + aliases.add(target); + changed = true; + } + } + return aliases; +} + +function assignsUnshadowedGlobal( + body: Node[], + name: string, + globals: ReadonlySet, +): boolean { + const targetAssignsGlobal = (target: Node): boolean => { + if (isUnshadowedGlobalIdentifier(target, name, globals)) return true; + if (target.type === "AssignmentPattern") { + return isNode(target.left) && targetAssignsGlobal(target.left); + } + if (target.type === "RestElement" || target.type === "SpreadElement") { + return isNode(target.argument) && targetAssignsGlobal(target.argument); + } + if (target.type === "ArrayPattern" || target.type === "ArrayExpression") { + return (Array.isArray(target.elements) ? target.elements : []).some((element) => + isNode(element) && targetAssignsGlobal(element) + ); + } + if (target.type === "ObjectPattern" || target.type === "ObjectExpression") { + return (Array.isArray(target.properties) ? target.properties : []).some((property) => { + if (!isNode(property)) return false; + if (isNode(property.argument)) return targetAssignsGlobal(property.argument); + return isNode(property.value) && targetAssignsGlobal(property.value); + }); + } + return isNode(target.expression) && targetAssignsGlobal(target.expression); + }; + + let assigns = false; + for (const statement of body) { + if (statement.type === "ImportDeclaration") continue; + walk(statement, (node) => { + if (assigns) return false; + + let target: Node | undefined; + if (node.type === "AssignmentExpression" && isNode(node.left)) target = node.left; + if (node.type === "UpdateExpression" && isNode(node.argument)) target = node.argument; + if ( + (node.type === "ForInStatement" || node.type === "ForOfStatement") && + isNode(node.left) && node.left.type !== "VariableDeclaration" + ) { + target = node.left; + } + + if (target && targetAssignsGlobal(target)) assigns = true; + return !assigns; + }); + if (assigns) break; + } + return assigns; +} + +function writesObjectDefineProperty( + body: Node[], + globals: ReadonlySet, + bindings: LexicalBindingIndex, +): boolean { + const aliases = intrinsicDefinePropertyAliases(body, globals, bindings); + const targetWritesDefineProperty = (target: Node): boolean => { + if ( + isGlobalObjectSlot(target, globals) || writesDefinePropertyMember(target, globals) + ) return true; + if (target.type === "AssignmentPattern") { + return isNode(target.left) && targetWritesDefineProperty(target.left); + } + if (target.type === "RestElement" || target.type === "SpreadElement") { + return isNode(target.argument) && targetWritesDefineProperty(target.argument); + } + if (target.type === "ArrayPattern" || target.type === "ArrayExpression") { + return (Array.isArray(target.elements) ? target.elements : []).some((element) => + isNode(element) && targetWritesDefineProperty(element) + ); + } + if (target.type === "ObjectPattern" || target.type === "ObjectExpression") { + return (Array.isArray(target.properties) ? target.properties : []).some((property) => { + if (!isNode(property)) return false; + if (isNode(property.argument)) return targetWritesDefineProperty(property.argument); + return isNode(property.value) && targetWritesDefineProperty(property.value); + }); + } + return isNode(target.expression) && targetWritesDefineProperty(target.expression); + }; + + let writes = false; + for (const statement of body) { + walk(statement, (node) => { + if (writes) return false; + + const invocation = normalizeCall(node, globals); + const aliasBinding = invocation?.callee.type === "Identifier" + ? bindings.reference(invocation.callee) + : null; + if ( + invocation && + (isIntrinsicDefinePropertyCall(invocation.callee, globals) || + (aliasBinding !== null && aliases.has(aliasBinding))) + ) { + if (invocation.unknownArgs) { + writes = true; + return false; + } + const args = invocation.args; + const key = stringLiteralText(args[1]); + const targetIsObject = isUnshadowedGlobalIdentifier(args[0], "Object", globals) || + isGlobalObjectSlot(args[0], globals); + const targetIsGlobal = isUnshadowedGlobalObject(args[0], globals); + if ( + (targetIsObject && (key === null || key === "defineProperty")) || + (targetIsGlobal && (key === null || key === "Object")) + ) { + writes = true; + return false; + } + } + + let target: Node | undefined; + if (node.type === "AssignmentExpression" && isNode(node.left)) target = node.left; + if (node.type === "UpdateExpression" && isNode(node.argument)) target = node.argument; + if (node.type === "UnaryExpression" && node.operator === "delete" && isNode(node.argument)) { + target = node.argument; + } + if ( + (node.type === "ForInStatement" || node.type === "ForOfStatement") && + isNode(node.left) && node.left.type !== "VariableDeclaration" + ) { + target = node.left; + } + + if (target && targetWritesDefineProperty(target)) writes = true; + return !writes; + }); + if (writes) break; + } + return writes; +} + +/** + * TypeScript nodes the escape scan must descend into because they still emit + * runtime code. A namespace body and an enum body both execute where they sit, + * so an intrinsic held inside one is exactly as reachable as one held at the + * top level. Skipping every `TS`-prefixed node hid `namespace Patch { export + * const intrinsic = Object }` from the scan entirely. + */ +const TS_RUNTIME_TYPES = new Set([ + ...TRANSPARENT_EXPRESSION_TYPES, + "TSModuleDeclaration", + "TSModuleBlock", + "TSEnumDeclaration", + "TSEnumMember", + "TSExportAssignment", + "TSParameterProperty", +]); + +/** + * Whether `key` holds a name rather than a read of the value behind it: the + * base of a member access (`Object.defineProperty`), a static member or object + * key, and every binding position. + */ +function isNamePosition(parent: Node, key: string): boolean { + if (key === "object") return true; + if (key === "property" || key === "key") return parent.computed !== true; + // `typeof window` yields a string, never a reference the module can reach the + // intrinsic through, and it is how every module guards for the browser. + if (key === "argument") { + return parent.type === "UnaryExpression" && parent.operator === "typeof"; + } + return key === "id" || key === "local" || key === "imported" || key === "exported" || + key === "label" || key === "params"; +} + +/** Every property-write target in the module, whatever its base. */ +function propertyWriteTargets(body: Node[]): Node[] { + const targets: Node[] = []; + for (const statement of body) { + if (statement.type === "ImportDeclaration") continue; + walk(statement, (node) => { + if (node.type === "AssignmentExpression" && isNode(node.left)) targets.push(node.left); + if (node.type === "UpdateExpression" && isNode(node.argument)) targets.push(node.argument); + if (node.type === "UnaryExpression" && node.operator === "delete" && isNode(node.argument)) { + targets.push(node.argument); + } + if ( + (node.type === "ForInStatement" || node.type === "ForOfStatement") && + isNode(node.left) && node.left.type !== "VariableDeclaration" + ) { + targets.push(node.left); + } + }); + } + return targets; +} + +/** The identifier a member path is rooted at, or null when it is not one. */ +function memberPathRoot(node: Node): Node | null { + let current = unwrapTransparent(node); + while (current.type === "MemberExpression" || current.type === "OptionalMemberExpression") { + if (!isNode(current.object)) return null; + current = unwrapTransparent(current.object); + } + return current.type === "Identifier" ? current : null; +} + +/** Bindings the module writes a property through, at any depth of member path. */ +function bindingsWrittenThrough( + body: Node[], + bindings: LexicalBindingIndex, +): Set { + const written = new Set(); + for (const target of propertyWriteTargets(body)) { + const member = unwrapTransparent(target); + if (member.type !== "MemberExpression" && member.type !== "OptionalMemberExpression") continue; + const root = memberPathRoot(member); + const binding = root ? bindings.reference(root) : null; + if (binding) written.add(binding); + } + return written; +} + +/** + * Bindings whose value can flow through local aliases to a property-write base. + * For `const intrinsic = Object; const alias = intrinsic; alias.key = value`, + * both `alias` and `intrinsic` are writable routes to the same object. + */ +function bindingsAliasedToWrittenThrough( + body: Node[], + bindings: LexicalBindingIndex, + writtenThrough: ReadonlySet, +): Set { + const aliases: Array<{ + source: LexicalBindingIdentity; + target: LexicalBindingIdentity; + }> = []; + for (const statement of body) { + if (statement.type === "ImportDeclaration") continue; + walk(statement, (node) => { + let left: Node | undefined; + let right: Node | undefined; + let declaration = false; + if (node.type === "VariableDeclarator") { + left = isNode(node.id) ? node.id : undefined; + right = isNode(node.init) ? node.init : undefined; + declaration = true; + } else if (node.type === "AssignmentExpression") { + left = isNode(node.left) ? node.left : undefined; + right = isNode(node.right) ? node.right : undefined; + } + const targetNode = left ? unwrapTransparent(left) : undefined; + const sourceNode = right ? unwrapTransparent(right) : undefined; + const target = targetNode?.type === "Identifier" + ? declaration ? bindings.declaration(targetNode) : bindings.reference(targetNode) + : null; + const source = sourceNode?.type === "Identifier" ? bindings.reference(sourceNode) : null; + if (source && target) aliases.push({ source, target }); + }); + } + + const reachesWrite = new Set(writtenThrough); + let changed = true; + while (changed) { + changed = false; + for (const { source, target } of aliases) { + if (reachesWrite.has(target) && !reachesWrite.has(source)) { + reachesWrite.add(source); + changed = true; + } + } + } + return reachesWrite; +} + +/** + * Whether the intrinsic reaches a slot the module can still write through. + * + * The earlier form of this check failed closed on any value read of `Object` + * or of a global object anywhere in the module. That is far too coarse: + * `const w = window`, `Object.assign(globalThis, {})`, `{ ...window }`, + * `report(globalThis)`, and `[].map(Object)` are ordinary client code, none of + * them can put a new function in the `defineProperty` slot, and treating them + * as escapes retained every compiler name helper together with its hook-only + * initialiser and the server import feeding it. + * + * What matters is not that the module read the intrinsic but that it kept the + * read somewhere it can write back into: + * + * - a property slot (`holder.intrinsic = Object`, `{ intrinsic: Object }`, a + * namespace's exported binding) is reachable again through that property, so + * it fails closed unconditionally; + * - a binding (`const alias = Object`) fails closed only when the module also + * writes a property through that name somewhere, which is the shape that can + * actually reach `alias.defineProperty = record`; + * - anything else is consumed by the expression that reads it and cannot be + * written back through, so it is not an escape. + */ +function intrinsicEscapesToWritableSlot( + body: Node[], + name: "Object" | "global", + globals: ReadonlySet, + bindings: LexicalBindingIndex, +): boolean { + const isIntrinsic = (entry: Node): boolean => + name === "Object" + ? isUnshadowedGlobalIdentifier(entry, "Object", globals) || + isGlobalObjectSlot(entry, globals) + : isUnshadowedGlobalObject(entry, globals); + + const expressionCanYieldIntrinsic = (entry: Node): boolean => { + const value = unwrapTransparent(entry); + if (isIntrinsic(value)) return true; + if (value.type === "SequenceExpression") { + const expressions = Array.isArray(value.expressions) ? value.expressions.filter(isNode) : []; + const last = expressions.at(-1); + return !!last && expressionCanYieldIntrinsic(last); + } + if (value.type === "ConditionalExpression") { + return (isNode(value.consequent) && expressionCanYieldIntrinsic(value.consequent)) || + (isNode(value.alternate) && expressionCanYieldIntrinsic(value.alternate)); + } + if (value.type === "LogicalExpression") { + const rightCanYield = isNode(value.right) && expressionCanYieldIntrinsic(value.right); + if (value.operator === "&&") return rightCanYield; + return rightCanYield || + (isNode(value.left) && expressionCanYieldIntrinsic(value.left)); + } + if (value.type === "AssignmentExpression" && isNode(value.right)) { + return expressionCanYieldIntrinsic(value.right); + } + if (value.type === "AwaitExpression" && isNode(value.argument)) { + return expressionCanYieldIntrinsic(value.argument); + } + return false; + }; + + const writtenThrough = bindingsAliasedToWrittenThrough( + body, + bindings, + bindingsWrittenThrough(body, bindings), + ); + + /** Whether storing the read at `parent[key]` puts it in a property slot. */ + const storesInPropertySlot = (parent: Node, key: string, inNamespace: boolean): boolean => { + if (key === "value") { + return parent.type === "ObjectProperty" || parent.type === "ClassProperty" || + parent.type === "ClassPrivateProperty" || parent.type === "ClassAccessorProperty"; + } + if (key !== "right" && key !== "init") return false; + // A namespace's bindings become properties of the emitted namespace object, + // so `namespace P { export const i = Object }` is reachable as `P.i`. + if (inNamespace) return true; + if (parent.type !== "AssignmentExpression" || !isNode(parent.left)) return false; + const left = unwrapTransparent(parent.left); + return left.type === "MemberExpression" || left.type === "OptionalMemberExpression"; + }; + + /** The concrete binding a read initializes or assigns. */ + const boundBinding = (parent: Node, key: string): LexicalBindingIdentity | null => { + if (parent.type === "VariableDeclarator" && key === "init" && isNode(parent.id)) { + const target = unwrapTransparent(parent.id); + return target.type === "Identifier" ? bindings.declaration(target) : null; + } + if ( + (parent.type === "AssignmentExpression" || parent.type === "AssignmentPattern") && + key === "right" && isNode(parent.left) + ) { + const left = unwrapTransparent(parent.left); + return left.type === "Identifier" ? bindings.reference(left) : null; + } + return null; + }; + + const escapes = (node: Node, inNamespace: boolean): boolean => { + if (node.type.startsWith("TS") && !TS_RUNTIME_TYPES.has(node.type)) return false; + if (node.type === "TSModuleDeclaration" && !isRuntimeTsModuleDeclaration(node)) return false; + if (node.type === "TSEnumDeclaration" && node.declare === true) return false; + const nested = inNamespace || node.type === "TSModuleDeclaration"; + + for (const [key, value] of Object.entries(node)) { + if (key === "loc" || key === "leadingComments" || key === "trailingComments") continue; + + for (const entry of Array.isArray(value) ? value : [value]) { + if (!isNode(entry)) continue; + const read = unwrapTransparent(entry); + if (expressionCanYieldIntrinsic(read)) { + if (isNamePosition(node, key)) continue; + if (storesInPropertySlot(node, key, nested)) return true; + const bound = boundBinding(node, key); + if (bound !== null) { + if (writtenThrough.has(bound)) return true; + continue; + } + continue; + } + if (escapes(entry, nested)) return true; + } + } + + return false; + }; + + return body.some((statement) => + statement.type !== "ImportDeclaration" && escapes(statement, false) + ); +} + +/** + * Property keys that can put a different function behind the helper's call. + * `defineProperty` and `defineProperties` replace the intrinsic's own methods; + * `Object` replaces the constructor the helper reaches them through. + */ +const GUARDED_INTRINSIC_KEYS = new Set(["defineProperty", "defineProperties", "Object"]); + +/** Expression forms that manifestly produce a value the module just made. */ +const FRESH_VALUE_TYPES = new Set([ + "ObjectExpression", + "ArrayExpression", + "FunctionExpression", + "ArrowFunctionExpression", + "ClassExpression", + "NewExpression", + "TemplateLiteral", + "StringLiteral", + "NumericLiteral", + "BooleanLiteral", + "RegExpLiteral", + "JSXElement", + "JSXFragment", +]); + +/** The static property name a member access reads, or null when it is dynamic. */ +function memberKey(node: Node): string | null { + const property = isNode(node.property) ? node.property : undefined; + return node.computed === true ? stringLiteralText(property) : nodeName(property); +} + +/** Parameter bindings whose function bodies this module immediately executes. */ +function invokedFunctionParameterBindings( + body: Node[], + globals: ReadonlySet, + bindings: LexicalBindingIndex, +): Set { + const invoked = new Set(); + // Keep concrete value flows so a call through a local function name and an + // iterator advanced through a later alias resolve to the same body. + const valueFlows = new Map(); + interface OwnerValueFlow { + value: Node; + order: number; + controlUncertain: boolean; + scope: Node | null; + } + interface MemberValueFlow { + owner: Node; + value: Node; + order: number; + controlUncertain: boolean; + scope: Node | null; + } + interface ComputedMemberValueFlow extends MemberValueFlow { + key: Node; + } + const ownerValueFlows = new Map(); + const memberValueFlows = new Map(); + const computedMemberValueFlows: ComputedMemberValueFlow[] = []; + const nodeOrders = new Map(); + const repeatedControlNodes = new Set(); + const ownerExecutionScopes = new Map(); + const addValueFlow = (target: LexicalBindingIdentity | null, value: Node): void => { + if (!target) return; + // Function declarations initialize their binding at scope entry, where + // only the final duplicate declaration is effective. Assignments remain + // separate possible values because this pass does not model call order. + const values = value.type === "FunctionDeclaration" + ? (valueFlows.get(target) ?? []).filter((entry) => entry.type !== "FunctionDeclaration") + : valueFlows.get(target) ?? []; + if (!values.includes(value)) values.push(value); + valueFlows.set(target, values); + }; + const addOwnerValueFlow = ( + target: LexicalBindingIdentity | null, + value: Node, + order: number, + controlUncertain: boolean, + scope: Node | null, + ): void => { + if (!target) return; + const values = ownerValueFlows.get(target) ?? []; + values.push({ value, order, controlUncertain, scope }); + ownerValueFlows.set(target, values); + }; + + for (const statement of body) { + if (statement.type === "ImportDeclaration") continue; + walk(statement, (node) => { + if ( + (node.type === "FunctionDeclaration" || node.type === "ClassDeclaration") && + isNode(node.id) + ) { + addValueFlow(bindings.declaration(node.id), node); + } else if ( + node.type === "VariableDeclarator" && isNode(node.id) && + node.id.type === "Identifier" && isNode(node.init) + ) { + addValueFlow(bindings.declaration(node.id), node.init); + } else if ( + node.type === "AssignmentExpression" && isNode(node.left) && isNode(node.right) + ) { + if (node.left.type === "Identifier") { + addValueFlow(bindings.reference(node.left), node.right); + } + } + }); + } + + const ownerExecutionScopeTypes = new Set([ + "FunctionDeclaration", + "FunctionExpression", + "ArrowFunctionExpression", + "ObjectMethod", + "ClassMethod", + "ClassPrivateMethod", + ]); + const controlUncertainFlowParentTypes = new Set([ + "ClassDeclaration", + "ClassExpression", + "ConditionalExpression", + "LogicalExpression", + "IfStatement", + "SwitchStatement", + "SwitchCase", + "WhileStatement", + "DoWhileStatement", + "ForStatement", + "ForInStatement", + "ForOfStatement", + "TryStatement", + "CatchClause", + ]); + const repeatedControlFlowParentTypes = new Set([ + "WhileStatement", + "DoWhileStatement", + "ForStatement", + "ForInStatement", + "ForOfStatement", + ]); + const addPatternOwnerFlows = ( + pattern: Node, + source: Node, + declaration: boolean, + order: number, + controlUncertain: boolean, + scope: Node | null, + repeatedControl: boolean, + ): void => { + const target = unwrapTransparent(pattern); + const value = unwrapTransparent(source); + if (target.type === "Identifier") { + const binding = declaration ? bindings.declaration(target) : bindings.reference(target); + addOwnerValueFlow(binding, value, order, controlUncertain, scope); + addValueFlow(binding, value); + return; + } + if (target.type === "AssignmentPattern" && isNode(target.left)) { + addPatternOwnerFlows( + target.left, + value, + declaration, + order, + controlUncertain, + scope, + repeatedControl, + ); + if (isNode(target.right)) { + addPatternOwnerFlows( + target.left, + target.right, + declaration, + order, + true, + scope, + repeatedControl, + ); + } + return; + } + if (value.type === "SequenceExpression") { + const expressions = Array.isArray(value.expressions) ? value.expressions.filter(isNode) : []; + const last = expressions.at(-1); + if (last) { + addPatternOwnerFlows( + target, + last, + declaration, + order, + controlUncertain, + scope, + repeatedControl, + ); + } + return; + } + if (value.type === "ConditionalExpression") { + if (isNode(value.consequent)) { + addPatternOwnerFlows( + target, + value.consequent, + declaration, + order, + true, + scope, + repeatedControl, + ); + } + if (isNode(value.alternate)) { + addPatternOwnerFlows( + target, + value.alternate, + declaration, + order, + true, + scope, + repeatedControl, + ); + } + return; + } + if (value.type === "LogicalExpression") { + if (isNode(value.left)) { + addPatternOwnerFlows( + target, + value.left, + declaration, + order, + true, + scope, + repeatedControl, + ); + } + if (isNode(value.right)) { + addPatternOwnerFlows( + target, + value.right, + declaration, + order, + true, + scope, + repeatedControl, + ); + } + return; + } + if (value.type === "AssignmentExpression" && isNode(value.right)) { + const nonDirectAssignment = value.operator !== "="; + if (nonDirectAssignment && isNode(value.left)) { + addPatternOwnerFlows( + target, + value.left, + declaration, + order, + true, + scope, + repeatedControl, + ); + } + addPatternOwnerFlows( + target, + value.right, + declaration, + order, + controlUncertain || nonDirectAssignment, + scope, + repeatedControl, + ); + return; + } + if (value.type === "AwaitExpression" && isNode(value.argument)) { + addPatternOwnerFlows( + target, + value.argument, + declaration, + order, + controlUncertain, + scope, + repeatedControl, + ); + return; + } + if (target.type === "ObjectPattern") { + const properties = Array.isArray(target.properties) ? target.properties : []; + for (const property of properties) { + if (!isNode(property)) continue; + if (property.type === "RestElement" && isNode(property.argument)) { + const freshRest: Node = { type: "ObjectExpression", properties: [] }; + nodeOrders.set(freshRest, order); + ownerExecutionScopes.set(freshRest, scope); + if (repeatedControl) repeatedControlNodes.add(freshRest); + // Rest creates a new container whose nested property values still + // alias the source. Keep both identities so a direct rest write is + // not mistaken for a certain write to the source object. + addPatternOwnerFlows( + property.argument, + value, + declaration, + order, + controlUncertain, + scope, + repeatedControl, + ); + addPatternOwnerFlows( + property.argument, + freshRest, + declaration, + order, + true, + scope, + repeatedControl, + ); + continue; + } + if (property.type !== "ObjectProperty" || !isNode(property.value)) { + continue; + } + const key = isNode(property.key) ? property.key : undefined; + const name = property.computed === true ? stringLiteralText(key) : literalText(key); + if (!key || (property.computed !== true && name === null)) continue; + // Resolve the source member after collection, when occurrence-aware + // owner flows are complete. Stamp the synthetic read with the + // destructuring occurrence so a later source rebind cannot leak in. + const projection: Node = { + type: "MemberExpression", + object: value, + property: key, + computed: property.computed === true, + optional: false, + }; + nodeOrders.set(projection, order); + ownerExecutionScopes.set(projection, scope); + if (repeatedControl) repeatedControlNodes.add(projection); + addPatternOwnerFlows( + property.value, + projection, + declaration, + order, + controlUncertain, + scope, + repeatedControl, + ); + } + return; + } + if ( + (target.type !== "ArrayPattern" && target.type !== "ArrayExpression") || + value.type !== "ArrayExpression" + ) return; + const targets = Array.isArray(target.elements) ? target.elements : []; + const sources = Array.isArray(value.elements) ? value.elements : []; + for (const [index, element] of targets.entries()) { + const elementValue = sources[index]; + if (isNode(element) && isNode(elementValue)) { + addPatternOwnerFlows( + element, + elementValue, + declaration, + order, + controlUncertain, + scope, + repeatedControl, + ); + } + } + }; + + let visitOrder = 0; + const collectOwnerFlows = ( + node: Node, + controlUncertain: boolean, + repeatedControl: boolean, + scope: Node | null, + ): void => { + const order = visitOrder++; + const nodeControlUncertain = controlUncertain || + (node.type === "AssignmentExpression" && node.operator !== "="); + nodeOrders.set(node, order); + ownerExecutionScopes.set(node, scope); + if (repeatedControl) repeatedControlNodes.add(node); + + if ( + (node.type === "FunctionDeclaration" || node.type === "ClassDeclaration") && + isNode(node.id) + ) { + // Function declarations are initialized when their scope is entered, so + // property writes before the declaration refer to the hoisted function. + // Classes retain their lexical occurrence because they have a TDZ. + const flowOrder = node.type === "FunctionDeclaration" ? Number.NEGATIVE_INFINITY : order; + addOwnerValueFlow( + bindings.declaration(node.id), + node, + flowOrder, + nodeControlUncertain, + scope, + ); + } else if (node.type === "VariableDeclarator" && isNode(node.id) && isNode(node.init)) { + if (node.id.type === "Identifier") { + addOwnerValueFlow( + bindings.declaration(node.id), + node.init, + order, + nodeControlUncertain, + scope, + ); + } else if (node.id.type === "ArrayPattern") { + addPatternOwnerFlows( + node.id, + node.init, + true, + order, + nodeControlUncertain, + scope, + repeatedControl, + ); + } else if (node.id.type === "ObjectPattern") { + addPatternOwnerFlows( + node.id, + node.init, + true, + order, + nodeControlUncertain, + scope, + repeatedControl, + ); + } + } else if ( + node.type === "AssignmentExpression" && isNode(node.left) && isNode(node.right) + ) { + if (node.left.type === "Identifier") { + addOwnerValueFlow( + bindings.reference(node.left), + node.right, + order, + nodeControlUncertain, + scope, + ); + } else if ( + node.left.type === "ArrayPattern" || node.left.type === "ArrayExpression" || + node.left.type === "ObjectPattern" + ) { + addPatternOwnerFlows( + node.left, + node.right, + false, + order, + nodeControlUncertain, + scope, + repeatedControl, + ); + } else if ( + (node.left.type === "MemberExpression" || + node.left.type === "OptionalMemberExpression") && isNode(node.left.object) + ) { + const key = memberKey(node.left); + if (key !== null) { + const flows = memberValueFlows.get(key) ?? []; + flows.push({ + owner: node.left.object, + value: node.right, + order, + controlUncertain: nodeControlUncertain, + scope, + }); + memberValueFlows.set(key, flows); + } else { + const property = isNode(node.left.property) ? node.left.property : undefined; + if (property) { + computedMemberValueFlows.push({ + owner: node.left.object, + key: property, + value: node.right, + order, + controlUncertain: nodeControlUncertain, + scope, + }); + } + } + } + } + + // Branches make their writes optional, but a straight-line write before a + // branch still dominates reads in that branch. Loops are different: a + // syntactically later write can feed a read on the next iteration. + const childControlUncertain = nodeControlUncertain || + controlUncertainFlowParentTypes.has(node.type); + const childRepeatedControl = repeatedControl || + repeatedControlFlowParentTypes.has(node.type); + const childScope = ownerExecutionScopeTypes.has(node.type) ? node : scope; + for (const child of children(node)) { + collectOwnerFlows(child, childControlUncertain, childRepeatedControl, childScope); + } + }; + for (const statement of body) { + if (statement.type !== "ImportDeclaration") { + collectOwnerFlows(statement, false, false, null); + } + } + + type OwnerIdentity = Node | LexicalBindingIdentity; + const activeOwnerFlows = ( + binding: LexicalBindingIdentity, + atOrder: number, + allPossible: boolean, + scope: Node | null, + ): OwnerValueFlow[] => { + const applicable = (ownerValueFlows.get(binding) ?? []).filter((flow) => + allPossible || flow.order <= atOrder + ).sort((left, right) => left.order - right.order); + if (allPossible) return applicable; + // A certain write supersedes every earlier owner. Branch and deferred + // writes after it remain possible until another certain write occurs. + let lastCertain = -1; + for (let index = applicable.length - 1; index >= 0; index--) { + const flow = applicable[index]; + if (flow && !flow.controlUncertain && flow.scope === scope) { + lastCertain = index; + break; + } + } + if (lastCertain < 0) return applicable; + return applicable.slice(lastCertain).filter((flow, index) => + index === 0 || flow.controlUncertain || flow.scope !== scope + ); + }; + const ownerIdentities = ( + entry: Node, + atOrder: number, + allPossible: boolean, + scope: Node | null, + seenBindings = new Set(), + ): OwnerIdentity[] => { + const value = unwrapTransparent(entry); + if (value.type === "Identifier") { + const binding = bindings.reference(value); + if (!binding || seenBindings.has(binding)) return binding ? [binding] : [value]; + const flows = activeOwnerFlows(binding, atOrder, allPossible, scope); + if (flows.length === 0) return [binding]; + const nextSeen = new Set(seenBindings); + nextSeen.add(binding); + return flows.flatMap((flow) => + ownerIdentities( + flow.value, + flow.order, + allPossible || flow.controlUncertain || flow.scope !== scope, + flow.scope, + new Set(nextSeen), + ) + ); + } + if (value.type === "SequenceExpression") { + const expressions = Array.isArray(value.expressions) ? value.expressions.filter(isNode) : []; + const last = expressions.at(-1); + return last ? ownerIdentities(last, atOrder, allPossible, scope, seenBindings) : []; + } + if (value.type === "ConditionalExpression") { + return [value.consequent, value.alternate].filter(isNode).flatMap((branch) => + ownerIdentities(branch, atOrder, true, scope, new Set(seenBindings)) + ); + } + if (value.type === "LogicalExpression") { + return [value.left, value.right].filter(isNode).flatMap((branch) => + ownerIdentities(branch, atOrder, true, scope, new Set(seenBindings)) + ); + } + if (value.type === "AssignmentExpression" && isNode(value.right)) { + if (value.operator === "=") { + return ownerIdentities(value.right, atOrder, allPossible, scope, seenBindings); + } + return [value.left, value.right].filter(isNode).flatMap((candidate) => + ownerIdentities(candidate, atOrder, true, scope, new Set(seenBindings)) + ); + } + if (value.type === "AwaitExpression" && isNode(value.argument)) { + return ownerIdentities(value.argument, atOrder, allPossible, scope, seenBindings); + } + return [value]; + }; + + function resolveStringValues( + entry: Node, + seenBindings = new Set(), + seenMemberFlows = new Set(), + ): { values: string[]; complete: boolean } { + const candidate = unwrapTransparent(entry); + const merge = (entries: Node[]): { values: string[]; complete: boolean } => { + if (entries.length === 0) return { values: [], complete: false }; + const resolutions = entries.map((next) => + resolveStringValues( + next, + new Set(seenBindings), + new Set(seenMemberFlows), + ) + ); + return { + values: resolutions.flatMap((resolution) => resolution.values), + complete: resolutions.every((resolution) => resolution.complete), + }; + }; + + if (candidate.type === "Identifier") { + const binding = bindings.reference(candidate); + if (!binding || seenBindings.has(binding)) return { values: [], complete: false }; + const sources = valueFlows.get(binding) ?? []; + const nextSeen = new Set(seenBindings); + nextSeen.add(binding); + if (sources.length === 0) return { values: [], complete: false }; + const resolutions = sources.map((source) => + resolveStringValues(source, new Set(nextSeen), new Set(seenMemberFlows)) + ); + return { + values: resolutions.flatMap((resolution) => resolution.values), + complete: resolutions.every((resolution) => resolution.complete), + }; + } + if (candidate.type === "SequenceExpression") { + const expressions = Array.isArray(candidate.expressions) + ? candidate.expressions.filter(isNode) + : []; + const last = expressions.at(-1); + return last + ? resolveStringValues(last, seenBindings, seenMemberFlows) + : { values: [], complete: false }; + } + if (candidate.type === "ConditionalExpression") { + return merge([candidate.consequent, candidate.alternate].filter(isNode)); + } + if (candidate.type === "LogicalExpression") { + return merge([candidate.left, candidate.right].filter(isNode)); + } + if (candidate.type === "AssignmentExpression" && isNode(candidate.right)) { + return candidate.operator === "=" + ? resolveStringValues(candidate.right, seenBindings, seenMemberFlows) + : merge([candidate.left, candidate.right].filter(isNode)); + } + if (candidate.type === "AwaitExpression" && isNode(candidate.argument)) { + return resolveStringValues(candidate.argument, seenBindings, seenMemberFlows); + } + + if ( + (candidate.type === "CallExpression" || + candidate.type === "OptionalCallExpression") && + isNode(candidate.callee) + ) { + const invocation = normalizeCall(candidate, globals); + const callee = invocation ? unwrapTransparent(invocation.callee) : null; + if ( + callee?.type === "ArrowFunctionExpression" && isNode(callee.body) && + callee.body.type !== "BlockStatement" + ) { + return resolveStringValues(callee.body, seenBindings, seenMemberFlows); + } + const concrete = concreteValues( + candidate, + new Set(seenBindings), + new Set(seenMemberFlows), + ); + return { + values: concrete + .map((resolved) => stringLiteralText(resolved)) + .filter((resolved): resolved is string => resolved !== null), + // Calls with block bodies, aliased callees, or unresolved callee flows + // can return along a path concreteValues cannot enumerate. + complete: false, + }; + } + if ( + candidate.type === "MemberExpression" || + candidate.type === "OptionalMemberExpression" + ) { + // Resolving a key through the same member-flow graph that is asking for + // that key explores every permutation of unresolved computed writes. + // A member read is never complete here, so keep it unresolved and let + // the caller conservatively retain every member it could select. + return { values: [], complete: false }; + } + + const concrete = concreteValues( + candidate, + new Set(seenBindings), + new Set(seenMemberFlows), + ); + const values = concrete + .map((resolved) => stringLiteralText(resolved)) + .filter((resolved): resolved is string => resolved !== null); + return { + values, + complete: concrete.length > 0 && values.length === concrete.length, + }; + } + + function synchronousReturnValues( + callable: Node, + seenBindings: Set, + seenMemberFlows: Set, + ): Node[] { + if ( + seenMemberFlows.has(callable) || + callable.async === true || callable.generator === true || + !isNode(callable.body) + ) return []; + const nextSeenMemberFlows = new Set(seenMemberFlows); + nextSeenMemberFlows.add(callable); + if (callable.body.type !== "BlockStatement") { + return concreteValues( + callable.body, + new Set(seenBindings), + new Set(nextSeenMemberFlows), + ); + } + const returned: Node[] = []; + walk(callable.body, (node) => { + if (node !== callable.body && startsVarScope(node)) return false; + if (node.type === "ReturnStatement" && isNode(node.argument)) { + returned.push(...concreteValues( + node.argument, + new Set(seenBindings), + new Set(nextSeenMemberFlows), + )); + } + return true; + }); + return returned; + } + + const resolvedMemberKeyMatch = ( + property: Node, + key: string, + seenBindings: Set, + seenMemberFlows: Set, + ): "none" | "possible" | "certain" => { + const resolution = resolveStringValues( + property, + new Set(seenBindings), + new Set(seenMemberFlows), + ); + if (!resolution.complete) return "possible"; + if (!resolution.values.includes(key)) return "none"; + return resolution.values.every((name) => name === key) ? "certain" : "possible"; + }; + + const concreteValues = ( + entry: Node, + seenBindings = new Set(), + seenMemberFlows = new Set(), + ): Node[] => { + const value = unwrapTransparent(entry); + if (value.type === "Identifier") { + const binding = bindings.reference(value); + if (!binding || seenBindings.has(binding)) return []; + const nextSeen = new Set(seenBindings); + nextSeen.add(binding); + return (valueFlows.get(binding) ?? []).flatMap((source) => + concreteValues(source, new Set(nextSeen), new Set(seenMemberFlows)) + ); + } + if (value.type === "SequenceExpression") { + const expressions = Array.isArray(value.expressions) ? value.expressions.filter(isNode) : []; + const last = expressions.at(-1); + return last ? concreteValues(last, seenBindings, seenMemberFlows) : []; + } + if (value.type === "ConditionalExpression") { + return [value.consequent, value.alternate].filter(isNode).flatMap((branch) => + concreteValues(branch, new Set(seenBindings), new Set(seenMemberFlows)) + ); + } + if (value.type === "LogicalExpression") { + return [value.left, value.right].filter(isNode).flatMap((branch) => + concreteValues(branch, new Set(seenBindings), new Set(seenMemberFlows)) + ); + } + if (value.type === "AssignmentExpression" && isNode(value.right)) { + if (value.operator === "=") { + return concreteValues(value.right, seenBindings, seenMemberFlows); + } + return [value.left, value.right].filter(isNode).flatMap((candidate) => + concreteValues(candidate, new Set(seenBindings), new Set(seenMemberFlows)) + ); + } + if (value.type === "AwaitExpression" && isNode(value.argument)) { + return concreteValues(value.argument, seenBindings, seenMemberFlows); + } + if ( + (value.type === "MemberExpression" || value.type === "OptionalMemberExpression") && + isNode(value.object) + ) { + const key = memberKey(value); + if (key === null) { + const property = isNode(value.property) ? value.property : undefined; + if (!property) return []; + const keyResolution = resolveStringValues( + property, + new Set(seenBindings), + new Set(seenMemberFlows), + ); + const resolvedKeys = new Set(keyResolution.values); + const members: Node[] = []; + if (!keyResolution.complete) { + const readOrder = nodeOrders.get(value) ?? Number.POSITIVE_INFINITY; + const readScope = ownerExecutionScopes.get(value) ?? null; + const readAllPossible = repeatedControlNodes.has(value); + for (const flow of computedMemberValueFlows) { + if (seenMemberFlows.has(flow)) continue; + const nextSeenMemberFlows = new Set(seenMemberFlows); + nextSeenMemberFlows.add(flow); + const flowKeyResolution = resolveStringValues( + flow.key, + new Set(seenBindings), + new Set(nextSeenMemberFlows), + ); + for (const name of flowKeyResolution.values) resolvedKeys.add(name); + if ( + flowKeyResolution.complete || + (!readAllPossible && flow.order > readOrder && + !flow.controlUncertain && flow.scope === readScope) + ) continue; + const resolveOwnerIdentities = ( + owners: OwnerIdentity[], + traversal = seenMemberFlows, + ): OwnerIdentity[] => + owners.flatMap((owner): OwnerIdentity[] => { + if (!isNode(owner)) return [owner]; + const candidate = unwrapTransparent(owner); + if ( + candidate.type !== "MemberExpression" && + candidate.type !== "OptionalMemberExpression" + ) return [owner]; + const resolved = concreteValues( + candidate, + new Set(seenBindings), + new Set(traversal), + ); + return resolved.length > 0 ? resolved : [owner]; + }); + const readOwners = new Set(resolveOwnerIdentities(ownerIdentities( + value.object, + readOrder, + readAllPossible, + readScope, + ))); + const flowOwners = new Set( + resolveOwnerIdentities( + ownerIdentities( + flow.owner, + flow.order, + flow.controlUncertain, + flow.scope, + ), + nextSeenMemberFlows, + ), + ); + if (![...readOwners].some((owner) => flowOwners.has(owner))) continue; + members.push(...concreteValues( + flow.value, + new Set(seenBindings), + nextSeenMemberFlows, + )); + } + for (const knownKey of memberValueFlows.keys()) resolvedKeys.add(knownKey); + const seenKeyOwners = new Set(); + const collectKnownKeys = (entry: Node): void => { + for ( + const owner of concreteValues( + entry, + new Set(seenBindings), + new Set(seenMemberFlows), + ) + ) { + if (seenKeyOwners.has(owner)) continue; + seenKeyOwners.add(owner); + if (owner.type === "ObjectExpression") { + const properties = Array.isArray(owner.properties) ? owner.properties : []; + for (const candidate of properties) { + if (!isNode(candidate)) continue; + if (candidate.type === "SpreadElement" && isNode(candidate.argument)) { + collectKnownKeys(candidate.argument); + continue; + } + const candidateKey = isNode(candidate.key) ? candidate.key : undefined; + if (candidate.computed === true && candidateKey) { + const candidateResolution = resolveStringValues( + candidateKey, + new Set(seenBindings), + ); + for (const name of candidateResolution.values) resolvedKeys.add(name); + if (!candidateResolution.complete) { + if (candidate.type === "ObjectMethod") { + if (candidate.kind === "get") { + members.push(...synchronousReturnValues( + candidate, + new Set(seenBindings), + new Set(seenMemberFlows), + )); + } else { + members.push(candidate); + } + } else if (candidate.type === "ObjectProperty" && isNode(candidate.value)) { + members.push(...concreteValues( + candidate.value, + new Set(seenBindings), + new Set(seenMemberFlows), + )); + } + } + continue; + } + const name = literalText(candidateKey); + if (name !== null) resolvedKeys.add(name); + } + continue; + } + if (owner.type !== "ClassDeclaration" && owner.type !== "ClassExpression") continue; + const classMembers = isNode(owner.body) && Array.isArray(owner.body.body) + ? owner.body.body.filter(isNode) + : []; + for (const candidate of classMembers) { + if (candidate.static !== true || !isNode(candidate.key)) continue; + if (candidate.computed === true) { + const candidateResolution = resolveStringValues( + candidate.key, + new Set(seenBindings), + ); + for (const name of candidateResolution.values) resolvedKeys.add(name); + if (!candidateResolution.complete) { + if (candidate.type === "ClassMethod") { + if (candidate.kind === "get") { + members.push(...synchronousReturnValues( + candidate, + new Set(seenBindings), + new Set(seenMemberFlows), + )); + } else { + members.push(candidate); + } + } else if (isNode(candidate.value)) { + members.push(...concreteValues( + candidate.value, + new Set(seenBindings), + new Set(seenMemberFlows), + )); + } + } + continue; + } + const name = literalText(candidate.key); + if (name !== null) resolvedKeys.add(name); + } + } + }; + collectKnownKeys(value.object); + } + for (const resolvedKey of resolvedKeys) { + const resolvedMember: Node = { + ...value, + property: { type: "StringLiteral", value: resolvedKey }, + computed: true, + }; + const order = nodeOrders.get(value); + if (order !== undefined) nodeOrders.set(resolvedMember, order); + ownerExecutionScopes.set( + resolvedMember, + ownerExecutionScopes.get(value) ?? null, + ); + if (repeatedControlNodes.has(value)) repeatedControlNodes.add(resolvedMember); + members.push(...concreteValues( + resolvedMember, + new Set(seenBindings), + new Set(seenMemberFlows), + )); + } + return members; + } + const members: Node[] = []; + const readOrder = nodeOrders.get(value) ?? Number.POSITIVE_INFINITY; + const readScope = ownerExecutionScopes.get(value) ?? null; + const readAllPossible = repeatedControlNodes.has(value); + const resolveOwnerIdentities = ( + owners: OwnerIdentity[], + traversal = seenMemberFlows, + ): OwnerIdentity[] => + owners.flatMap((owner): OwnerIdentity[] => { + if (!isNode(owner)) return [owner]; + const candidate = unwrapTransparent(owner); + if ( + candidate.type !== "MemberExpression" && + candidate.type !== "OptionalMemberExpression" + ) return [owner]; + const resolved = concreteValues( + candidate, + new Set(seenBindings), + new Set(traversal), + ); + // Keep an unresolved syntax identity so an analysis gap cannot make + // distinct writes look like certain writes to the same owner. + return resolved.length > 0 ? resolved : [owner]; + }); + const readOwners = new Set( + resolveOwnerIdentities( + ownerIdentities( + value.object, + readOrder, + readAllPossible, + readScope, + ), + ), + ); + + const resolvedMemberFlows = [ + ...(memberValueFlows.get(key) ?? []).map((flow) => ({ + flow, + keyUncertain: false, + })), + ...computedMemberValueFlows.flatMap((flow) => { + if (seenMemberFlows.has(flow)) return []; + const nextSeenMemberFlows = new Set(seenMemberFlows); + nextSeenMemberFlows.add(flow); + const match = resolvedMemberKeyMatch( + flow.key, + key, + new Set(seenBindings), + nextSeenMemberFlows, + ); + return match === "none" ? [] : [{ flow, keyUncertain: match !== "certain" }]; + }), + ] + .filter(({ flow }) => + readAllPossible || flow.order <= readOrder || flow.controlUncertain || + flow.scope !== readScope + ) + .map(({ flow, keyUncertain }) => { + const nextSeenMemberFlows = new Set(seenMemberFlows); + nextSeenMemberFlows.add(flow); + return { + flow, + keyUncertain, + owners: new Set( + resolveOwnerIdentities( + ownerIdentities( + flow.owner, + flow.order, + flow.controlUncertain, + flow.scope, + ), + nextSeenMemberFlows, + ), + ), + }; + }) + .sort((left, right) => left.flow.order - right.flow.order) + .filter(({ flow }) => !seenMemberFlows.has(flow)); + const activeMemberFlows = new Set(); + const overriddenOwners = new Set(); + for (const readOwner of readOwners) { + const applicable = resolvedMemberFlows.filter(({ owners }) => owners.has(readOwner)); + let lastCertain = -1; + if (!readAllPossible) { + for (let index = applicable.length - 1; index >= 0; index--) { + const candidate = applicable[index]; + if ( + candidate && !candidate.flow.controlUncertain && !candidate.keyUncertain && + candidate.flow.scope === readScope && candidate.owners.size === 1 + ) { + lastCertain = index; + break; + } + } + } + const active = lastCertain < 0 + ? applicable + : applicable.slice(lastCertain).filter(({ flow, keyUncertain, owners }, index) => + index === 0 || flow.controlUncertain || keyUncertain || flow.scope !== readScope || + owners.size !== 1 + ); + for (const { flow } of active) activeMemberFlows.add(flow); + if (lastCertain >= 0) overriddenOwners.add(readOwner); + } + for (const flow of activeMemberFlows) { + const nextSeenMemberFlows = new Set(seenMemberFlows); + nextSeenMemberFlows.add(flow); + members.push(...concreteValues( + flow.value, + new Set(seenBindings), + nextSeenMemberFlows, + )); + } + + const seenOwners = new Set(); + const collectObjectMember = (owner: Node): Node[] => { + if (seenOwners.has(owner)) return []; + seenOwners.add(owner); + const candidates: Node[] = []; + let prototypeValue: Node | null = null; + const properties = Array.isArray(owner.properties) ? owner.properties : []; + // Object literal definitions are applied from left to right. Search + // backwards so a final explicit property replaces earlier duplicates, + // while a later spread keeps both its known value and the earlier + // fallback as possible runtime values. + for (let index = properties.length - 1; index >= 0; index--) { + const property = properties[index]; + if (!isNode(property)) continue; + if (property.type === "SpreadElement") { + if (!isNode(property.argument)) continue; + for ( + const spread of concreteValues( + property.argument, + new Set(seenBindings), + new Set(seenMemberFlows), + ) + ) { + if (spread.type === "ObjectExpression") { + candidates.push(...collectObjectMember(spread)); + } + } + continue; + } + const propertyKey = isNode(property.key) ? property.key : undefined; + if ( + property.type === "ObjectProperty" && property.computed !== true && + property.shorthand !== true && literalText(propertyKey) === "__proto__" && + isNode(property.value) + ) { + prototypeValue = property.value; + continue; + } + const match = property.computed === true && propertyKey + ? resolvedMemberKeyMatch( + propertyKey, + key, + new Set(seenBindings), + new Set(seenMemberFlows), + ) + : literalText(propertyKey) === key + ? "certain" + : "none"; + if (match === "none") continue; + if (property.type === "ObjectMethod") { + if (property.kind === "get") { + candidates.push(...synchronousReturnValues( + property, + new Set(seenBindings), + new Set(seenMemberFlows), + )); + } else { + candidates.push(property); + } + } else if (property.type === "ObjectProperty" && isNode(property.value)) { + candidates.push(...concreteValues( + property.value, + new Set(seenBindings), + new Set(seenMemberFlows), + )); + } + if (match === "certain") return candidates; + } + if (prototypeValue) { + for ( + const prototype of concreteValues( + prototypeValue, + new Set(seenBindings), + new Set(seenMemberFlows), + ) + ) { + if (prototype.type === "ObjectExpression") { + candidates.push(...collectObjectMember(prototype)); + } + } + } + return candidates; + }; + + const collectClassMember = (owner: Node): void => { + if (seenOwners.has(owner)) return; + seenOwners.add(owner); + const classMembers = isNode(owner.body) && Array.isArray(owner.body.body) + ? owner.body.body.filter(isNode) + : []; + for (let index = classMembers.length - 1; index >= 0; index--) { + const property = classMembers[index]; + if (!property) continue; + if (property.static !== true || !isNode(property.key)) continue; + const match = property.computed === true + ? resolvedMemberKeyMatch( + property.key, + key, + new Set(seenBindings), + new Set(seenMemberFlows), + ) + : literalText(property.key) === key + ? "certain" + : "none"; + if (match === "none") continue; + if (property.type === "ClassMethod") { + if (property.kind === "get") { + members.push(...synchronousReturnValues( + property, + new Set(seenBindings), + new Set(seenMemberFlows), + )); + } else { + members.push(property); + } + } else if (isNode(property.value)) { + members.push(...concreteValues( + property.value, + new Set(seenBindings), + new Set(seenMemberFlows), + )); + } + if (match === "certain") return; + } + if (!isNode(owner.superClass)) return; + for ( + const base of concreteValues( + owner.superClass, + new Set(seenBindings), + new Set(seenMemberFlows), + ) + ) { + if (base.type === "ClassDeclaration" || base.type === "ClassExpression") { + collectClassMember(base); + } + } + }; + + for (const owner of readOwners) { + if (!isNode(owner) || overriddenOwners.has(owner)) continue; + if (owner.type === "ObjectExpression") { + members.push(...collectObjectMember(owner)); + continue; + } + if (owner.type !== "ClassDeclaration" && owner.type !== "ClassExpression") continue; + collectClassMember(owner); + } + return members; + } + if ( + (value.type === "CallExpression" || value.type === "OptionalCallExpression") && + isNode(value.callee) + ) { + const binder = unwrapTransparent(value.callee); + if ( + (binder.type === "MemberExpression" || binder.type === "OptionalMemberExpression") && + memberKey(binder) === "bind" && isNode(binder.object) + ) { + return concreteValues(binder.object, seenBindings, seenMemberFlows); + } + + const invocation = normalizeCall(value, globals); + if (!invocation) return []; + const returned: Node[] = []; + for ( + const callee of concreteValues( + invocation.callee, + new Set(seenBindings), + new Set(seenMemberFlows), + ) + ) { + if ( + callee.type !== "FunctionDeclaration" && callee.type !== "FunctionExpression" && + callee.type !== "ArrowFunctionExpression" && callee.type !== "ObjectMethod" && + callee.type !== "ClassMethod" + ) continue; + // Async factories return a promise and generator factories return an + // iterator, neither synchronously hands the caller a callable value. + returned.push(...synchronousReturnValues( + callee, + new Set(seenBindings), + new Set(seenMemberFlows), + )); + } + return returned; + } + return [value]; + }; + + const collect = ( + callee: unknown, + runGenerator: false | "once" | "all", + seenBindings = new Set(), + ): void => { + if (!isNode(callee)) return; + let target = unwrapTransparent(callee); + while ( + (target.type === "MemberExpression" || target.type === "OptionalMemberExpression") && + (memberKey(target) === "call" || memberKey(target) === "apply") && + isNode(target.object) + ) { + target = unwrapTransparent(target.object); + } + if (target.type === "Identifier") { + const binding = bindings.reference(target); + if (!binding || seenBindings.has(binding)) return; + seenBindings.add(binding); + for (const source of valueFlows.get(binding) ?? []) { + collect(source, runGenerator, seenBindings); + } + return; + } + if (target.type === "SequenceExpression") { + const expressions = Array.isArray(target.expressions) + ? target.expressions.filter(isNode) + : []; + const last = expressions.at(-1); + if (last) collect(last, runGenerator, seenBindings); + return; + } + if (target.type === "ConditionalExpression") { + if (isNode(target.consequent)) collect(target.consequent, runGenerator, seenBindings); + if (isNode(target.alternate)) collect(target.alternate, runGenerator, seenBindings); + return; + } + if (target.type === "LogicalExpression") { + if (target.operator !== "&&" && isNode(target.left)) { + collect(target.left, runGenerator, seenBindings); + } + if (isNode(target.right)) collect(target.right, runGenerator, seenBindings); + return; + } + if (target.type === "AssignmentExpression" && isNode(target.right)) { + collect(target.right, runGenerator, seenBindings); + return; + } + if (target.type === "MemberExpression" || target.type === "OptionalMemberExpression") { + for (const member of concreteValues(target, new Set(seenBindings))) { + collect(member, runGenerator, new Set(seenBindings)); + } + return; + } + if ( + (target.type === "CallExpression" || target.type === "OptionalCallExpression") && + isNode(target.callee) + ) { + const binder = unwrapTransparent(target.callee); + if ( + (binder.type === "MemberExpression" || binder.type === "OptionalMemberExpression") && + memberKey(binder) === "bind" && isNode(binder.object) + ) { + collect(binder.object, runGenerator, seenBindings); + return; + } + for (const returned of concreteValues(target, new Set(seenBindings))) { + collect(returned, runGenerator, new Set(seenBindings)); + } + return; + } + if (target.type === "ClassDeclaration" || target.type === "ClassExpression") { + const members = isNode(target.body) && Array.isArray(target.body.body) + ? target.body.body.filter(isNode) + : []; + const constructor = members.find((member) => + member.type === "ClassMethod" && member.kind === "constructor" + ); + for (const param of Array.isArray(constructor?.params) ? constructor.params : []) { + if (!isNode(param)) continue; + for (const identifier of patternBindingIdentifiers(param)) { + const binding = bindings.declaration(identifier); + if (binding) invoked.add(binding); + } + } + // A derived constructor invokes its superclass constructor. Following + // the heritage value also covers the implicit constructor that forwards + // every argument to `super`, which has no local parameter AST to mark. + if (isNode(target.superClass)) { + collect(target.superClass, false, new Set(seenBindings)); + } + return; + } + if ( + target.type !== "FunctionDeclaration" && target.type !== "FunctionExpression" && + target.type !== "ArrowFunctionExpression" && target.type !== "ObjectMethod" && + target.type !== "ClassMethod" + ) return; + // Invoking a generator only creates its iterator. Its body remains deferred + // until `next()` advances that exact call result. + if (target.generator === true && runGenerator === false) return; + for (const param of Array.isArray(target.params) ? target.params : []) { + if (!isNode(param)) continue; + for (const identifier of patternBindingIdentifiers(param)) { + const binding = bindings.declaration(identifier); + if (binding) invoked.add(binding); + } + } + if (target.generator === true && runGenerator !== false && isNode(target.body)) { + const collectDelegatedYield = (node: Node): boolean => { + if ( + node.type === "FunctionDeclaration" || node.type === "FunctionExpression" || + node.type === "ArrowFunctionExpression" + ) return false; + if ( + node.type === "YieldExpression" && node.delegate === true && + isNode(node.argument) + ) { + collectAdvanced( + node.argument, + new Set(), + runGenerator, + ); + } + return true; + }; + if (runGenerator === "all" || target.body.type !== "BlockStatement") { + walk(target.body, collectDelegatedYield); + } else { + const staticTruthiness = (value: Node | undefined): boolean | null => { + if (!value) return null; + const expression = unwrapTransparent(value); + if (expression.type === "BooleanLiteral" && typeof expression.value === "boolean") { + return expression.value; + } + if (expression.type === "NullLiteral") return false; + if (expression.type === "NumericLiteral" && typeof expression.value === "number") { + return expression.value !== 0 && !Number.isNaN(expression.value); + } + if (expression.type === "StringLiteral" && typeof expression.value === "string") { + return expression.value.length > 0; + } + return null; + }; + const collectBeforeSuspension = (statement: Node): boolean => { + if (statement.type === "BlockStatement") { + for (const child of Array.isArray(statement.body) ? statement.body : []) { + if (isNode(child) && collectBeforeSuspension(child)) return true; + } + return false; + } + if (statement.type === "ExpressionStatement" && isNode(statement.expression)) { + const expression = unwrapTransparent(statement.expression); + if (expression.type === "YieldExpression") { + if (expression.delegate === true && isNode(expression.argument)) { + collectAdvanced( + expression.argument, + new Set(), + "once", + ); + } + return expression.delegate !== true; + } + } + if (statement.type === "IfStatement") { + const test = isNode(statement.test) ? statement.test : undefined; + if (test) walk(test, collectDelegatedYield); + const truthiness = staticTruthiness(test); + const consequent = isNode(statement.consequent) ? statement.consequent : undefined; + const alternate = isNode(statement.alternate) ? statement.alternate : undefined; + if (truthiness === true) { + return consequent ? collectBeforeSuspension(consequent) : false; + } + if (truthiness === false) { + return alternate ? collectBeforeSuspension(alternate) : false; + } + const consequentSuspends = consequent ? collectBeforeSuspension(consequent) : false; + const alternateSuspends = alternate ? collectBeforeSuspension(alternate) : false; + return consequentSuspends && alternateSuspends; + } + if (statement.type === "LabeledStatement" && isNode(statement.body)) { + return collectBeforeSuspension(statement.body); + } + walk(statement, collectDelegatedYield); + return false; + }; + + // One `next()` stops at the first suspension that every reachable path + // takes. Do not advance a later delegated iterator that this call + // cannot reach, including a yield nested in a statically selected arm. + for (const statement of Array.isArray(target.body.body) ? target.body.body : []) { + if (!isNode(statement)) continue; + if (collectBeforeSuspension(statement)) break; + } + } + } + }; + + const collectInvocation = ( + value: Node, + runGenerator: false | "once" | "all", + ): void => { + const call = unwrapTransparent(value); + const invocation = normalizeCall(call, globals); + if (invocation) collect(invocation.callee, runGenerator); + }; + + function collectAdvanced( + value: Node, + seenBindings = new Set(), + runGenerator: "once" | "all" = "all", + ): void { + const advanced = unwrapTransparent(value); + if (advanced.type === "Identifier") { + const binding = bindings.reference(advanced); + if (!binding || seenBindings.has(binding)) return; + seenBindings.add(binding); + for (const source of valueFlows.get(binding) ?? []) { + collectAdvanced(source, seenBindings, runGenerator); + } + return; + } + if (advanced.type === "SequenceExpression") { + const expressions = Array.isArray(advanced.expressions) + ? advanced.expressions.filter(isNode) + : []; + const last = expressions.at(-1); + if (last) collectAdvanced(last, seenBindings, runGenerator); + return; + } + if (advanced.type === "ConditionalExpression") { + if (isNode(advanced.consequent)) { + collectAdvanced(advanced.consequent, seenBindings, runGenerator); + } + if (isNode(advanced.alternate)) { + collectAdvanced(advanced.alternate, seenBindings, runGenerator); + } + return; + } + if (advanced.type === "LogicalExpression") { + if (isNode(advanced.left)) collectAdvanced(advanced.left, seenBindings, runGenerator); + if (isNode(advanced.right)) collectAdvanced(advanced.right, seenBindings, runGenerator); + return; + } + if (advanced.type === "AssignmentExpression" && isNode(advanced.right)) { + collectAdvanced(advanced.right, seenBindings, runGenerator); + return; + } + collectInvocation(advanced, runGenerator); + } + + for (const statement of body) { + if (statement.type === "ImportDeclaration") continue; + walk(statement, (node) => { + if ( + node.type === "CallExpression" || node.type === "OptionalCallExpression" + ) { + collectInvocation(node, false); + const normalized = normalizeCall(node, globals); + // Any callee can synchronously consume an iterator argument. This also + // covers standard consumers such as Array.from and iterable + // constructors without pretending unknown callees leave it untouched. + for (const argument of normalized?.args ?? []) collectAdvanced(argument); + const callee = normalized?.callee; + if ( + callee && + (callee.type === "MemberExpression" || callee.type === "OptionalMemberExpression") && + memberKey(callee) === "next" && isNode(callee.object) + ) { + collectAdvanced(callee.object, new Set(), "once"); + } + } + if (node.type === "NewExpression") { + collect(node.callee, false); + for (const argument of Array.isArray(node.arguments) ? node.arguments : []) { + if (isNode(argument)) collectAdvanced(argument); + } + } + if (node.type === "SpreadElement" && isNode(node.argument)) { + collectAdvanced(node.argument); + } + if (node.type === "ForOfStatement" && isNode(node.right)) { + collectAdvanced(node.right); + } + if ( + node.type === "VariableDeclarator" && isNode(node.id) && + node.id.type === "ArrayPattern" && isNode(node.init) + ) { + collectAdvanced(node.init); + } + if ( + node.type === "AssignmentExpression" && isNode(node.left) && + (node.left.type === "ArrayPattern" || node.left.type === "ArrayExpression") && + isNode(node.right) + ) { + collectAdvanced(node.right); + } + }); + } + return invoked; +} + +/** + * Whether the module writes a guarded key through a base this stage cannot + * bound. + * + * `writesDefinePropertyMember` only recognises a base it can name: the bare + * `Object` identifier or a global object's `Object` slot. A base reached by + * any other route (`({}).constructor`, `Object.getPrototypeOf({}).constructor`, + * a namespace's property, a call's result) is not provably a different object, + * so a write of `defineProperty` through it fails closed. The base is accepted + * only when it is manifestly a value this module made, or a name bound in this + * module that no invoked function receives. + */ +function writesGuardedKeyThroughUnprovenBase( + body: Node[], + globals: ReadonlySet, + bindings: LexicalBindingIndex, +): boolean { + const invokedParams = invokedFunctionParameterBindings(body, globals, bindings); + + const baseIsProvenLocal = (base: Node): boolean => { + const target = unwrapTransparent(base); + if (FRESH_VALUE_TYPES.has(target.type)) return true; + if (target.type !== "Identifier") return false; + // An unshadowed global identifier may be `Object` itself, or a host object + // that exposes it; a shadowed one is a binding this module controls. + if (globals.has(target)) return false; + const binding = bindings.reference(target); + return binding !== null && !invokedParams.has(binding); + }; + + for (const target of propertyWriteTargets(body)) { + const member = unwrapTransparent(target); + if (member.type !== "MemberExpression" && member.type !== "OptionalMemberExpression") continue; + const key = memberKey(member); + if (key === null || !GUARDED_INTRINSIC_KEYS.has(key)) continue; + const base = isNode(member.object) ? member.object : undefined; + if (!base || !baseIsProvenLocal(base)) return true; + } + + return false; +} + +/** Member names that can hand a module a constructor or a prototype it did not name. */ +const REFLECTION_KEYS = new Set(["constructor", "__proto__"]); + +/** Global functions that turn a string into code running in this realm. */ +const CODE_FROM_STRING_NAMES = new Set(["eval", "Function"]); + +/** + * Whether the module invokes a route to the intrinsic that never names it. + * + * `({}).constructor` is `Object`, `Object.getPrototypeOf({}).constructor` is + * `Object`, and `"".constructor.constructor` is `Function`, which compiles a + * string into code that can reach anything at all. An ordinary read such as + * `error.constructor.name`, `value instanceof Function`, or `typeof eval` does + * none of those things and must not pin a hook-only server chain. Concrete + * binding flow distinguishes a constructor invoked later from same-spelled + * local shadows and ordinary inspection reads. + */ +function hasReflectionRoute( + body: Node[], + globals: ReadonlySet, + bindings: LexicalBindingIndex, +): boolean { + const flows = new Map(); + const uninitialized = new Set(); + const destructured: Array<{ pattern: Node; value: Node; declaration: boolean }> = []; + const addFlow = (target: LexicalBindingIdentity | null, value: Node): void => { + if (!target) return; + const values = flows.get(target) ?? []; + values.push(value); + flows.set(target, values); + }; + + for (const statement of body) { + if (statement.type === "ImportDeclaration") continue; + walk(statement, (node) => { + if (node.type === "VariableDeclarator" && isNode(node.id)) { + if (node.id.type === "Identifier") { + const binding = bindings.declaration(node.id); + if (isNode(node.init)) addFlow(binding, node.init); + else if (binding) uninitialized.add(binding); + } else if ( + isNode(node.init) && + (node.id.type === "ObjectPattern" || node.id.type === "ArrayPattern") + ) { + destructured.push({ pattern: node.id, value: node.init, declaration: true }); + } + } else if ( + node.type === "AssignmentExpression" && isNode(node.left) && isNode(node.right) + ) { + if (node.left.type === "Identifier") { + addFlow(bindings.reference(node.left), node.right); + } else if (node.left.type === "ObjectPattern" || node.left.type === "ArrayPattern") { + destructured.push({ pattern: node.left, value: node.right, declaration: false }); + } + } + }); + } + + const carriesGlobalObject = ( + entry: Node, + seen = new Set(), + ): boolean => { + const value = unwrapTransparent(entry); + if (isUnshadowedGlobalObject(value, globals)) return true; + if (value.type === "Identifier") { + const binding = bindings.reference(value); + if (!binding || seen.has(binding)) return false; + const nextSeen = new Set(seen); + nextSeen.add(binding); + return (flows.get(binding) ?? []).some((source) => + carriesGlobalObject(source, new Set(nextSeen)) + ); + } + if (value.type === "SequenceExpression") { + const expressions = Array.isArray(value.expressions) ? value.expressions.filter(isNode) : []; + const last = expressions.at(-1); + return !!last && carriesGlobalObject(last, seen); + } + if (value.type === "ConditionalExpression") { + return (isNode(value.consequent) && carriesGlobalObject(value.consequent, new Set(seen))) || + (isNode(value.alternate) && carriesGlobalObject(value.alternate, new Set(seen))); + } + if (value.type === "LogicalExpression") { + return (isNode(value.left) && carriesGlobalObject(value.left, new Set(seen))) || + (isNode(value.right) && carriesGlobalObject(value.right, new Set(seen))); + } + if (value.type === "AssignmentExpression" && isNode(value.right)) { + return carriesGlobalObject(value.right, seen); + } + if (value.type === "AwaitExpression" && isNode(value.argument)) { + return carriesGlobalObject(value.argument, seen); + } + return false; + }; + + const destructuredCodeRoutes = new Set(); + const isRoute = ( + entry: Node | undefined, + seen = new Set(), + ): boolean => { + if (!entry) return false; + const value = unwrapTransparent(entry); + if ( + value.type === "Identifier" && globals.has(value) && + CODE_FROM_STRING_NAMES.has(nodeName(value) ?? "") + ) { + return true; + } + if (value.type === "Identifier") { + const binding = bindings.reference(value); + if (!binding || seen.has(binding)) return false; + if (destructuredCodeRoutes.has(binding)) return true; + seen.add(binding); + return (flows.get(binding) ?? []).some((source) => isRoute(source, seen)); + } + if (value.type === "MemberExpression" || value.type === "OptionalMemberExpression") { + const key = memberKey(value); + if (key !== null && REFLECTION_KEYS.has(key)) return true; + const object = isNode(value.object) ? value.object : undefined; + return (key === null || CODE_FROM_STRING_NAMES.has(key)) && + isUnshadowedGlobalObject(object, globals); + } + if (value.type === "SequenceExpression") { + const expressions = Array.isArray(value.expressions) ? value.expressions.filter(isNode) : []; + return isRoute(expressions.at(-1), seen); + } + if (value.type === "ConditionalExpression") { + return isRoute(isNode(value.consequent) ? value.consequent : undefined, seen) || + isRoute(isNode(value.alternate) ? value.alternate : undefined, seen); + } + if (value.type === "LogicalExpression") { + return isRoute(isNode(value.left) ? value.left : undefined, seen) || + isRoute(isNode(value.right) ? value.right : undefined, seen); + } + if (value.type === "AssignmentExpression" && isNode(value.right)) { + return isRoute(value.right, seen); + } + if (value.type === "AwaitExpression" && isNode(value.argument)) { + return isRoute(value.argument, seen); + } + if ( + (value.type === "CallExpression" || value.type === "OptionalCallExpression") && + isNode(value.callee) + ) { + const callee = unwrapTransparent(value.callee); + if ( + (callee.type === "MemberExpression" || callee.type === "OptionalMemberExpression") && + memberKey(callee) === "bind" && isNode(callee.object) + ) { + return isRoute(callee.object, seen); + } + } + return false; + }; + + const arrayValues = ( + entry: Node, + seen = new Set(), + ): Array> => { + const value = unwrapTransparent(entry); + if (value.type === "ArrayExpression") { + return [ + Array.isArray(value.elements) + ? value.elements.map((element) => isNode(element) ? element : undefined) + : [], + ]; + } + if (value.type === "Identifier") { + const binding = bindings.reference(value); + if (!binding || seen.has(binding)) return []; + const nextSeen = new Set(seen); + nextSeen.add(binding); + return (flows.get(binding) ?? []).flatMap((source) => arrayValues(source, new Set(nextSeen))); + } + if (value.type === "SequenceExpression") { + const expressions = Array.isArray(value.expressions) ? value.expressions.filter(isNode) : []; + const last = expressions.at(-1); + return last ? arrayValues(last, seen) : []; + } + if (value.type === "ConditionalExpression") { + return [value.consequent, value.alternate].filter(isNode).flatMap((branch) => + arrayValues(branch, new Set(seen)) + ); + } + if (value.type === "LogicalExpression") { + return [value.left, value.right].filter(isNode).flatMap((branch) => + arrayValues(branch, new Set(seen)) + ); + } + if (value.type === "AssignmentExpression" && isNode(value.right)) { + return arrayValues(value.right, seen); + } + if (value.type === "AwaitExpression" && isNode(value.argument)) { + return arrayValues(value.argument, seen); + } + return []; + }; + + const cannotBeUndefined = ( + entry: Node, + seen = new Set(), + ): boolean => { + const value = unwrapTransparent(entry); + if (value.type === "Identifier") { + const binding = bindings.reference(value); + if (!binding || seen.has(binding)) return false; + if (uninitialized.has(binding)) return false; + const sources = flows.get(binding) ?? []; + if (sources.length === 0) return false; + const nextSeen = new Set(seen); + nextSeen.add(binding); + return sources.every((source) => cannotBeUndefined(source, new Set(nextSeen))); + } + if (value.type === "SequenceExpression") { + const expressions = Array.isArray(value.expressions) ? value.expressions.filter(isNode) : []; + const last = expressions.at(-1); + return !!last && cannotBeUndefined(last, seen); + } + if (value.type === "ConditionalExpression") { + return isNode(value.consequent) && isNode(value.alternate) && + cannotBeUndefined(value.consequent, new Set(seen)) && + cannotBeUndefined(value.alternate, new Set(seen)); + } + if (value.type === "LogicalExpression") { + return isNode(value.left) && isNode(value.right) && + cannotBeUndefined(value.left, new Set(seen)) && + cannotBeUndefined(value.right, new Set(seen)); + } + if (value.type === "AssignmentExpression" && isNode(value.right)) { + return cannotBeUndefined(value.right, seen); + } + if (value.type === "UnaryExpression") return value.operator !== "void"; + return value.type === "ObjectExpression" || value.type === "ArrayExpression" || + value.type === "FunctionExpression" || value.type === "ArrowFunctionExpression" || + value.type === "ClassExpression" || value.type === "NewExpression" || + value.type === "TemplateLiteral" || value.type === "StringLiteral" || + value.type === "NumericLiteral" || value.type === "BooleanLiteral" || + value.type === "RegExpLiteral" || value.type === "NullLiteral" || + value.type === "BigIntLiteral" || value.type === "DecimalLiteral" || + value.type === "MetaProperty" || + value.type === "BinaryExpression" || value.type === "UpdateExpression"; + }; + + for (const { pattern, value, declaration } of destructured) { + if (pattern.type === "ObjectPattern") { + if (!carriesGlobalObject(value)) continue; + for (const property of Array.isArray(pattern.properties) ? pattern.properties : []) { + if (!isNode(property) || property.type !== "ObjectProperty" || !isNode(property.value)) { + continue; + } + const key = isNode(property.key) ? property.key : undefined; + const name = property.computed === true ? stringLiteralText(key) : literalText(key); + if (name !== null && !CODE_FROM_STRING_NAMES.has(name)) continue; + for (const identifier of patternBindingIdentifiers(property.value)) { + const binding = declaration + ? bindings.declaration(identifier) + : bindings.reference(identifier); + if (binding) destructuredCodeRoutes.add(binding); + } + } + continue; + } + + const elements = Array.isArray(pattern.elements) ? pattern.elements : []; + for (const values of arrayValues(value)) { + for (const [index, element] of elements.entries()) { + if (!isNode(element)) continue; + const source = values[index]; + const defaultRoute = element.type === "AssignmentPattern" && isNode(element.right) && + (!source || !cannotBeUndefined(source)) && isRoute(element.right); + if (!isRoute(source) && !defaultRoute) continue; + for (const identifier of patternBindingIdentifiers(element)) { + const binding = declaration + ? bindings.declaration(identifier) + : bindings.reference(identifier); + if (binding) destructuredCodeRoutes.add(binding); + } + } + } + } + + for (const target of propertyWriteTargets(body)) { + const member = unwrapTransparent(target); + if (member.type !== "MemberExpression" && member.type !== "OptionalMemberExpression") continue; + const key = memberKey(member); + if (key !== null && !GUARDED_INTRINSIC_KEYS.has(key)) continue; + const base = isNode(member.object) ? member.object : undefined; + if (isRoute(base)) return true; + } + + let found = false; + for (const statement of body) { + if (statement.type === "ImportDeclaration") continue; + walk(statement, (node) => { + if (found) return false; + if (node.type === "CallExpression" || node.type === "OptionalCallExpression") { + const invocation = normalizeCall(node, globals); + found = isRoute(invocation?.callee) || + (invocation?.args ?? []).some((argument) => isRoute(argument)); + } + if (node.type === "NewExpression") { + found = isRoute(isNode(node.callee) ? node.callee : undefined) || + (Array.isArray(node.arguments) ? node.arguments : []).some((argument) => + isNode(argument) && isRoute(argument) + ); + } + if (node.type === "TaggedTemplateExpression") { + found = isRoute(isNode(node.tag) ? node.tag : undefined); + } + return !found; + }); + if (found) break; } - - return referenced; + return found; } /** - * Both reference walkers' answers for one parsed module. - * - * `referenced` is the flat over-approximation that decides whether a - * declaration or an import binding is still live. `free` is the scope-aware - * walk that seeds and grows the stripped hooks' dependency closure. The two - * must classify TypeScript syntax identically: if one counts a type-position - * read as a runtime reference and the other does not, a hook-only import stays - * in the browser artifact, and if one skips a value-emitting TypeScript node - * the pass deletes live code. - * - * Exported so that agreement can be tested directly. It is not observable - * through `stripServerOnlyExports` on compiled input, because the compile stage - * erases every TypeScript node before this stage runs today. + * Whether the module merges a guarded key onto the intrinsic or a global + * object. `Object.assign(globalThis, { Object: replacement })` installs a new + * constructor without ever writing a member, so the object literal's own keys + * decide, not the assignment target. */ -export function moduleReferenceWalkers(ast: ASTNode): { - referenced: 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 mergesGuardedKeyOntoIntrinsic(body: Node[], globals: ReadonlySet): boolean { + const literalCarriesGuardedKey = (node: Node): boolean => { + if (node.type !== "ObjectExpression") return false; + return (Array.isArray(node.properties) ? node.properties : []).some((property) => { + if (!isNode(property)) return false; + const key = isNode(property.key) ? property.key : undefined; + const name = property.computed === true ? stringLiteralText(key) : nodeName(key); + return name === null || GUARDED_INTRINSIC_KEYS.has(name); + }); }; -} -function literalText(node: Node | undefined): string | null { - if (!node) return null; - return typeof node.value === "string" ? node.value : nodeName(node); -} + let merges = false; + for (const statement of body) { + if (statement.type === "ImportDeclaration") continue; + walk(statement, (node) => { + if (merges) return false; + const invocation = normalizeCall(node, globals); + if (!invocation) return true; + const callee = invocation.callee; + if ( + callee?.type !== "MemberExpression" && callee?.type !== "OptionalMemberExpression" + ) return true; + const method = memberKey(callee); + if (method !== "assign" && method !== "defineProperty" && method !== "defineProperties") { + return true; + } -function stringLiteralText(node: Node | undefined): string | null { - return node && typeof node.value === "string" ? node.value : null; -} + if (invocation.unknownArgs) { + const owner = isNode(callee.object) ? unwrapTransparent(callee.object) : undefined; + if (!isUnshadowedGlobalIdentifier(owner, "Object", globals)) return true; + // A spread hides how many sources follow, but not what the target is + // when the target itself is written out. A merge onto a value this + // module manifestly just made cannot land on the intrinsic however + // many unreadable sources come after it. + const first = invocation.args[0] ? unwrapTransparent(invocation.args[0]) : undefined; + if (first && first.type !== "SpreadElement" && FRESH_VALUE_TYPES.has(first.type)) { + return true; + } + merges = true; + return false; + } -function isObjectDefineProperty(node: Node | undefined): boolean { - if (!node || node.type !== "MemberExpression") return false; - return nodeName(node.object) === "Object" && - literalText(isNode(node.property) ? node.property : undefined) === "defineProperty"; + const args = invocation.args; + const target = args[0] ? unwrapTransparent(args[0]) : undefined; + const targetsIntrinsic = isUnshadowedGlobalIdentifier(target, "Object", globals) || + isGlobalObjectSlot(target, globals) || isUnshadowedGlobalObject(target, globals); + const sources = args.slice(1); + // `assign` copies a source's own keys onto the target and + // `defineProperties` installs a descriptor map's keys the same way, so + // both land whatever the source holds. A source this stage cannot read + // key by key (a name, a call's result) is not a proven one. + const unprovenSource = (method === "assign" || method === "defineProperties") && + sources.some((source) => unwrapTransparent(source).type !== "ObjectExpression"); + if ( + targetsIntrinsic && + (unprovenSource || sources.some((source) => literalCarriesGuardedKey(source))) + ) { + merges = true; + return false; + } + return true; + }); + if (merges) break; + } + return merges; } function returnedCall(node: Node): Node | null { @@ -1034,19 +4679,115 @@ function isTrueExpression(node: Node | undefined): boolean { function isNameDescriptor(node: Node | undefined, valueParam: string): boolean { if (!node || node.type !== "ObjectExpression") return false; + const properties = Array.isArray(node.properties) ? node.properties : []; + if (properties.length !== 2) return false; + let hasValue = false; let configurable = false; - for (const property of Array.isArray(node.properties) ? node.properties : []) { - if (!isNode(property) || property.type !== "ObjectProperty") continue; + for (const property of properties) { + if ( + !isNode(property) || property.type !== "ObjectProperty" || property.computed === true || + property.method === true + ) { + return false; + } const key = literalText(isNode(property.key) ? property.key : undefined); const value = isNode(property.value) ? property.value : undefined; - if (key === "value" && nodeName(value) === valueParam) hasValue = true; - if (key === "configurable" && isTrueExpression(value)) configurable = true; + if (key === "value" && !hasValue && nodeName(value) === valueParam) { + hasValue = true; + continue; + } + if (key === "configurable" && !configurable && isTrueExpression(value)) { + configurable = true; + continue; + } + return false; } return hasValue && configurable; } +/** + * The analysis boundary for compiler name metadata. + * + * ## What the stage proves + * + * A call like `__name(loadPage, "loadPage")` is compiler metadata, not code + * the module wrote, so it must not keep a hook-only declaration alive. The + * stage removes such a call only when it can first prove that the helper it + * calls does nothing but set a name: the helper's body must be exactly + * `Object.defineProperty(target, "name", { value, configurable: true })` on + * the genuine `Object` intrinsic, reached either directly or through a + * single unreassigned alias, with no shadowing of `Object` in scope. + * + * ## Why the recognised set is an allowlist + * + * Deleting the call is safe only if `Object.defineProperty` still is the + * intrinsic when the call runs. Asking instead "has anything in this module + * replaced it?" cannot be answered: `({}).constructor`, + * `Object.getPrototypeOf({}).constructor`, and `"".constructor.constructor` + * all reach `Object` without naming it, and a string compiled by `Function` + * reaches anything at all. That list has no end, so the stage does not keep + * one. A module is recognised only when every one of these holds: + * + * 1. `Object` resolves to the global intrinsic: no module binding, import, + * hoisted `var`, or assignment to the global claims the name. + * 2. The module invokes no reflection route to a constructor or prototype and + * no route from a string to code (`.constructor`, `__proto__`, `eval`, + * `Function`). Ordinary inspection reads do not count as invocation. + * 3. No property write reaches `defineProperty`, `defineProperties`, or + * `Object` through a base this stage cannot bound to a value the module + * itself made. A parameter of a non-generator function the module + * immediately invokes, through `.call` and `.apply` included, is not such a + * value. Calling a generator only creates its still-deferred iterator. + * 4. No `defineProperty`-shaped call targets the intrinsic or a global object, + * and no `assign` or `defineProperties` onto either takes a source whose own + * keys this stage cannot read one by one. + * 5. The intrinsic never reaches a slot the module can write back through: a + * property, a namespace binding, or a name it writes a member of. Names are + * closed over their aliases first, so a chain of bindings counts as one. + * + * Everything outside that set keeps its helpers, their registrations, and + * whatever those pin. A route nobody has thought of yet is outside it by + * construction, so the analysis terminates instead of growing a new rejection + * for each one found. + * + * ## What the stage does not attempt + * + * It does not model tampering performed anywhere but this module: another + * module in the graph, a dynamically imported one, or injected script can + * replace the intrinsic, and no in-module analysis sees that. It does not + * track values across parameter passing beyond the functions this module + * immediately invokes. It resolves a computed key only when the key is a + * static string, so a fully dynamic member path on a base it can bound is read + * as ordinary user code. + * + * ## Which direction it errs in + * + * Toward keeping code. Failing to recognise compiler metadata retains a helper + * and can retain the hook-only server chain it names. The removed-name verifier + * does not backstop that direction because no name was selected for removal. + * Wrongly recognising metadata can instead delete a call the module observes, + * so the accepted reflection and mutation routes remain deliberately narrow. + */ +/** + * What `compilerNameHelperBindings` could prove about a module. + * + * `helpers` drives the pass. `candidates` is the same analysis with the + * intrinsic-tampering proof set aside, and exists only so a blocked recognition + * can report which registration it could not classify: without it the caller + * cannot tell "this module emits no name registrations" from "this module emits + * one that cannot be proven", and those two need opposite outcomes. + */ +interface NameHelperRecognition { + /** Bindings proven to register a name the way the compiler does. */ + helpers: Set; + /** The same bindings before the intrinsic proof, for reporting only. */ + candidates: Set; + /** The construct that made the proof impossible, or null when there is none. */ + blockedBy: string | null; +} + /** * Bindings for esbuild's `keepNames` helper. Release modules are compiled * before the browser transform, so their declarations are followed by calls @@ -1054,24 +4795,96 @@ function isNameDescriptor(node: Node | undefined, valueParam: string): boolean { * `Object.defineProperty(target, "name", …)` semantics rather than by its * minified binding name. */ -function compilerNameHelperBindings(body: Node[]): Set { +function compilerNameHelperBindings(body: Node[]): NameHelperRecognition { + // The helper esbuild emits calls the global intrinsic. A runtime module + // binding named `Object` changes those semantics completely, so fail closed + // and treat every apparent registration as ordinary user code. + const importsRuntimeObject = body.some((statement) => + statement.type === "ImportDeclaration" && statement.importKind !== "type" && + (Array.isArray(statement.specifiers) ? statement.specifiers : []).some((specifier) => + isNode(specifier) && specifier.importKind !== "type" && nodeName(specifier.local) === "Object" + ) + ); + const reassigned = assignedModuleBindingNames(body); + const hoisted = hoistedVarNames(body); + const globals = unshadowedGlobalIdentifierNodes(body); + const bindings = indexLexicalBindings(body); + // Each route is paired with the phrase that names it to the author. The tests + // run lazily, so this keeps the short-circuit the disjunction had. + const intrinsicRoutes: Array<[string, () => boolean]> = [ + [ + "declares a module-scope binding named `Object`", + () => moduleScopeBindingNames(body).has("Object"), + ], + ["hoists a `var` named `Object`", () => hoisted.has("Object")], + ["imports a binding named `Object`", () => importsRuntimeObject], + ["assigns to the global `Object`", () => assignsUnshadowedGlobal(body, "Object", globals)], + [ + "writes to `Object.defineProperty`", + () => writesObjectDefineProperty(body, globals, bindings), + ], + [ + "writes a guarded key through a base this pass cannot resolve", + () => writesGuardedKeyThroughUnprovenBase(body, globals, bindings), + ], + [ + "merges a guarded key onto the `Object` intrinsic", + () => mergesGuardedKeyOntoIntrinsic(body, globals), + ], + [ + "reaches an intrinsic through `.constructor`, `__proto__`, `eval` or `Function`", + () => hasReflectionRoute(body, globals, bindings), + ], + [ + "lets the `Object` intrinsic escape into a writable slot", + () => intrinsicEscapesToWritableSlot(body, "Object", globals, bindings), + ], + [ + "lets the `global` intrinsic escape into a writable slot", + () => intrinsicEscapesToWritableSlot(body, "global", globals, bindings), + ], + ]; + const blockedBy = intrinsicRoutes.find(([, reaches]) => reaches())?.[0] ?? null; + + // A `var` may be declared more than once, and only the initialiser that ran + // last is visible here. Classifying a binding from it would apply that shape + // to calls made earlier, when a different function was live: in + // `var setName = recordAndReturn; setName(secret, "secret"); var setName = + // (target, value) => Object.defineProperty(…)` the observable first call + // would be deleted as metadata. A hoisted redeclaration below the top level + // rebinds the same way without appearing here at all, so both shapes are + // rejected and stay ordinary user code. A `function` declaration sharing a + // `var`'s name needs no entry here: that is a redeclaration a module cannot + // have, and the parse failure already stops the build. const initializers = new Map(); + const rebound = new Set(hoisted); for (const statement of body) { - if (statement.type !== "VariableDeclaration") continue; - for (const declarator of Array.isArray(statement.declarations) ? statement.declarations : []) { + const declaration = statement.type === "ExportNamedDeclaration" && + isNode(statement.declaration) + ? statement.declaration + : statement; + if (declaration.type !== "VariableDeclaration") continue; + for ( + const declarator of Array.isArray(declaration.declarations) ? declaration.declarations : [] + ) { if (!isNode(declarator) || !isNode(declarator.init)) continue; const name = nodeName(declarator.id); - if (name) initializers.set(name, declarator.init); + if (!name) continue; + if (initializers.has(name)) rebound.add(name); + initializers.set(name, declarator.init); } } const definePropertyBindings = new Set(); for (const [name, init] of initializers) { - if (isObjectDefineProperty(init)) definePropertyBindings.add(name); + if (!rebound.has(name) && !reassigned.has(name) && isObjectDefineProperty(init)) { + definePropertyBindings.add(name); + } } const helpers = new Set(); for (const [name, init] of initializers) { + if (rebound.has(name) || reassigned.has(name)) continue; if (init.type !== "ArrowFunctionExpression" && init.type !== "FunctionExpression") continue; const params = Array.isArray(init.params) ? init.params.filter(isNode) : []; if (params.length !== 2) continue; @@ -1079,11 +4892,22 @@ function compilerNameHelperBindings(body: Node[]): Set { const valueParam = nodeName(params[1]); if (!targetParam || !valueParam) continue; + const helperLocalNames = new Set(params.flatMap(patternBoundNames)); + if (init.type === "FunctionExpression") { + const functionName = nodeName(init.id); + if (functionName) helperLocalNames.add(functionName); + } + const call = returnedCall(init); if (!call) continue; const callee = isNode(call.callee) ? call.callee : undefined; + const calleeName = nodeName(callee); + const calleeIsShadowed = isObjectDefineProperty(callee) + ? helperLocalNames.has("Object") + : callee?.type === "Identifier" && calleeName !== null && helperLocalNames.has(calleeName); + if (calleeIsShadowed) continue; const callsDefineProperty = isObjectDefineProperty(callee) || - (callee?.type === "Identifier" && definePropertyBindings.has(nodeName(callee) ?? "")); + (callee?.type === "Identifier" && definePropertyBindings.has(calleeName ?? "")); if (!callsDefineProperty) continue; const args = Array.isArray(call.arguments) ? call.arguments.filter(isNode) : []; @@ -1095,7 +4919,14 @@ function compilerNameHelperBindings(body: Node[]): Set { } } - return helpers; + // A blocked proof yields no usable helpers, so the pass keeps treating every + // apparent registration as ordinary user code. What changes is that the + // caller can now see that a registration was there to classify. + return { + helpers: blockedBy === null ? helpers : new Set(), + candidates: helpers, + blockedBy, + }; } interface CompilerNameRegistration { @@ -1104,8 +4935,10 @@ interface CompilerNameRegistration { targetName: string; } -function compilerNameRegistrations(body: Node[]): CompilerNameRegistration[] { - const helpers = compilerNameHelperBindings(body); +function compilerNameRegistrations( + body: Node[], + helpers: ReadonlySet, +): CompilerNameRegistration[] { if (helpers.size === 0) return []; const registrations: CompilerNameRegistration[] = []; @@ -1128,109 +4961,665 @@ function compilerNameRegistrations(body: Node[]): CompilerNameRegistration[] { } /** - * Drop the top-level declarations the emptied server-only hooks closed over. - * - * Scope is the *dependency closure of the stripped hooks*, not "everything - * unreferenced". A declaration is removed only when (a) it is reached from the - * hook's own reference graph — seeded from `hookClosure` and grown through the - * initialisers of declarations already removed — and (b) nothing surviving in - * the module still references it. So `const API_KEY = getEnv(...)` read only by - * `getServerData` goes (letting `dropUnusedImportBindings` drop the import - * next), while an unrelated `const _ = bootClientAnalytics()` — never part of - * the hook graph — is left intact along with its side effect. Iterates to a - * fixpoint: removing one binding can leave a helper it was the last user of - * newly dead *within the closure*. + * Every name reachable from `roots` by following the binding graph's edges. + * + * A name is live when surviving code reads it, or when a live binding's own + * code reads it. Everything else is dead, cycles included, which is exactly + * what asking each declaration in turn "is this name mentioned anywhere else?" + * can never see: two hook-only helpers that call each other keep each other + * alive forever, and whatever they close over ships with them. */ -function dropUnusedModuleScopeBindings(body: Node[], hookClosure: Set): Node[] { - let current = body; - - for (;;) { - const decls = moduleScopeDeclarations(current); - if (decls.length === 0) return current; - - const excluded = new WeakSet(); - for (const decl of decls) for (const id of decl.bindingIds) excluded.add(id); - - // Esbuild's generated name-registration call is metadata for a declaration, - // not an independent browser consumer of it. Ignore that target reference - // when deciding liveness, and remove the call together with a declaration - // that proves hook-only. - const nameRegistrations = compilerNameRegistrations(current); - for (const registration of nameRegistrations) excluded.add(registration.target); - - const referenced = referencedIdentifiers(current, excluded); - - const removableStatements = new Set(); - const removableDeclarators = new Map>(); - const removedDecls: ModuleScopeDecl[] = []; - for (const decl of decls) { - const inClosure = decl.names.some((name) => hookClosure.has(name)); - const unused = decl.names.every((name) => !referenced.has(name)); - if (!inClosure || !unused) continue; - - removedDecls.push(decl); - for (const registration of nameRegistrations) { - if (decl.names.includes(registration.targetName)) { - removableStatements.add(registration.statement); - } - } - if (!decl.declarator) { - removableStatements.add(decl.statement); - continue; +function reachableNames(roots: Iterable, sites: BindingSite[]): Set { + const byName = new Map(); + for (const site of sites) { + for (const name of site.names) { + const bound = byName.get(name); + if (bound) bound.push(site); + else byName.set(name, [site]); + } + } + + const reachable = new Set(roots); + const pending = [...reachable]; + while (pending.length > 0) { + const name = pending.pop() as string; + for (const site of byName.get(name) ?? []) { + for (const reference of site.references) { + if (reachable.has(reference)) continue; + reachable.add(reference); + pending.push(reference); } + } + } - const statementDeclarators = Array.isArray(decl.statement.declarations) - ? decl.statement.declarations.filter(isNode) - : []; - let statementRemoval = removableDeclarators.get(decl.statement); - if (!statementRemoval) { - statementRemoval = new Set(); - removableDeclarators.set(decl.statement, statementRemoval); + return reachable; +} + +/** Whether a node carries at least one decorator, which runs where it sits. */ +function hasDecorators(node: Node): boolean { + return Array.isArray(node.decorators) && node.decorators.length > 0; +} + +function patternHasDecorators(pattern: Node): boolean { + if (hasDecorators(pattern)) return true; + if (pattern.type === "TSParameterProperty") { + return isNode(pattern.parameter) && patternHasDecorators(pattern.parameter); + } + if (pattern.type === "AssignmentPattern") { + return isNode(pattern.left) && patternHasDecorators(pattern.left); + } + if (pattern.type === "RestElement") { + return isNode(pattern.argument) && patternHasDecorators(pattern.argument); + } + if (pattern.type === "ArrayPattern") { + return (Array.isArray(pattern.elements) ? pattern.elements : []).some((element) => + isNode(element) && patternHasDecorators(element) + ); + } + if (pattern.type === "ObjectPattern") { + return (Array.isArray(pattern.properties) ? pattern.properties : []).some((property) => { + if (!isNode(property)) return false; + if (property.type === "RestElement") { + return isNode(property.argument) && patternHasDecorators(property.argument); } - statementRemoval.add(decl.declarator); + return property.type === "ObjectProperty" && isNode(property.value) && + patternHasDecorators(property.value); + }); + } + return false; +} + +function hasParameterDecorators(node: Node): boolean { + return (Array.isArray(node.params) ? node.params : []).some((param) => + isNode(param) && patternHasDecorators(param) + ); +} + +/** + * `__name(, "name")`: esbuild's `keepNames` helper applied inline, the + * shape a dev build wraps every initialiser in. It defines a `name` property on + * the value it is handed and returns it, so it is compiler metadata rather than + * a call the module makes, and it is exactly as inert as its first argument. + */ +function isNameRegistrationCall(node: Node, helpers: ReadonlySet): boolean { + if (node.type !== "CallExpression" || !isNode(node.callee)) return false; + if (!helpers.has(nodeName(node.callee) ?? "")) return false; + + const args = Array.isArray(node.arguments) ? node.arguments.filter(isNode) : []; + return args.length === 2 && stringLiteralText(args[1]) !== null; +} + +/** `static { __name(this, "Loader") }`: the class form of that same metadata. */ +function isNameRegistrationBlock(node: Node, helpers: ReadonlySet): boolean { + const statements = Array.isArray(node.body) ? node.body.filter(isNode) : []; + return statements.every((statement) => { + if (statement.type !== "ExpressionStatement" || !isNode(statement.expression)) return false; + const call = statement.expression; + if (!isNameRegistrationCall(call, helpers)) return false; + const [target] = Array.isArray(call.arguments) ? call.arguments.filter(isNode) : []; + return target?.type === "ThisExpression"; + }); +} + +/** + * A class whose *definition* runs nothing: no decorator, no superclass, no + * computed member key and no static initialiser. Method bodies and instance + * field initialisers run at construction time, not at module load. Even + * `extends Base` reads `Base.prototype`, which can invoke a Proxy trap, so a + * heritage clause is never treated as inert here. + */ +function isInertClass(node: Node, helpers: ReadonlySet): boolean { + if (hasDecorators(node) || isNode(node.superClass)) return false; + + const members = isNode(node.body) && Array.isArray(node.body.body) ? node.body.body : []; + return members.every((member) => { + if (!isNode(member)) return false; + if (hasDecorators(member) || hasParameterDecorators(member) || member.computed === true) { + return false; + } + if (member.type === "StaticBlock") return isNameRegistrationBlock(member, helpers); + if (member.static !== true) return true; + return isInertExpression(isNode(member.value) ? member.value : undefined, helpers); + }); +} + +/** Expressions whose evaluation cannot run user code. A whitelist, by design. */ +function isInertExpression(node: Node | undefined, helpers: ReadonlySet): boolean { + if (!node) return true; + + const inner = (value: unknown): Node | undefined => isNode(value) ? value : undefined; + + switch (node.type) { + case "Identifier": + case "ThisExpression": + case "StringLiteral": + case "NumericLiteral": + case "BooleanLiteral": + case "NullLiteral": + case "BigIntLiteral": + case "DecimalLiteral": + case "RegExpLiteral": + case "FunctionExpression": + case "ArrowFunctionExpression": + return true; + case "ClassExpression": + return isInertClass(node, helpers); + case "CallExpression": + return isNameRegistrationCall(node, helpers) && + isInertExpression(inner((node.arguments as unknown[])[0]), helpers); + // Interpolation coerces its values to strings, which calls `toString`. + case "TemplateLiteral": + return !Array.isArray(node.expressions) || node.expressions.length === 0; + // `typeof`, `void` and `!` are the operators that never reach `valueOf`; + // `-x` and `+x` do, and `delete` mutates. + case "UnaryExpression": + return (node.operator === "typeof" || node.operator === "void" || + node.operator === "!") && isInertExpression(inner(node.argument), helpers); + // Testing a value for truthiness and yielding one of two operands calls + // nothing, however the choice is spelled. + case "ConditionalExpression": + return isInertExpression(inner(node.test), helpers) && + isInertExpression(inner(node.consequent), helpers) && + isInertExpression(inner(node.alternate), helpers); + case "LogicalExpression": + return isInertExpression(inner(node.left), helpers) && + isInertExpression(inner(node.right), helpers); + // Only the two comparisons that never coerce. `==` and the relational and + // arithmetic operators all reach `valueOf`/`toString`, `instanceof` calls + // `Symbol.hasInstance` and `in` traps on a proxy. + case "BinaryExpression": + return (node.operator === "===" || node.operator === "!==") && + isInertExpression(inner(node.left), helpers) && + isInertExpression(inner(node.right), helpers); + // `(a, b)` evaluates each operand in turn and yields the last. + case "SequenceExpression": + return (Array.isArray(node.expressions) ? node.expressions : []).every((expression) => + isNode(expression) && isInertExpression(expression, helpers) + ); + case "ArrayExpression": + return (Array.isArray(node.elements) ? node.elements : []).every((element) => + element === null || element === undefined || + (isNode(element) && element.type !== "SpreadElement" && + isInertExpression(element, helpers)) + ); + case "ObjectExpression": + return (Array.isArray(node.properties) ? node.properties : []).every((property) => { + // A spread iterates its source and a computed key is coerced to a + // property key; both run user code. Defining a method does not. + if (!isNode(property) || property.computed === true) return false; + if (property.type === "ObjectMethod") return true; + return property.type === "ObjectProperty" && + isInertExpression(inner(property.value), helpers); + }); + case "TSAsExpression": + case "TSSatisfiesExpression": + case "TSNonNullExpression": + case "TSTypeAssertion": + case "TSInstantiationExpression": + case "ParenthesizedExpression": + return isInertExpression(inner(node.expression), helpers); + default: + return false; + } +} + +/** + * Whether a declaration *runs* when the module is evaluated. + * + * This is the line between the two halves of an unused declaration. One that + * only introduces a name (a function, a `var dead = helper`, a class with no + * decorator, superclass or static initialiser) does nothing at module-load + * time, so an unreachable one is not surviving code and has no business being + * asked what the module still reads. One whose initialiser runs + * (`const clientInit = bootClientAnalytics()`) is a top-level side effect + * wearing a binding: it survives, and it keeps whatever it references exactly + * as the bare `registerClientHandler(…)` statement beside it would. + * + * Anything not proven inert counts as a side effect, which keeps its reads. + */ +function evaluationIsInert(node: Node, helpers: ReadonlySet): boolean { + if (node.type === "FunctionDeclaration") return true; + if (node.type === "ClassDeclaration") return isInertClass(node, helpers); + // A runtime enum, namespace or import-equals evaluates a body at module load. + if (node.type !== "VariableDeclarator") return false; + + // A destructuring pattern reads properties off the initialiser, which runs + // getters and throws on `null`, so only a plain identifier binding is inert. + if (!isNode(node.id) || node.id.type !== "Identifier") return false; + return isInertExpression(isNode(node.init) ? node.init : undefined, helpers); +} +/** + * The parts of a declaration that do not run where they are written: function, + * arrow and method bodies, and instance field initialisers, which run when + * something calls or constructs them. + * + * This is what separates a declaration's *roots* from its *edges*. `const + * handler = memo(() => KEY)` performs one read at module load (`memo`) and + * the arrow body's read of `KEY` happens only if something calls the arrow, + * which needs `handler`. Counting the whole subtree as module-evaluation reads + * let any dead declaration with an impure initialiser vouch for every name + * mentioned anywhere beneath it, secrets in never-run callbacks included. + * + * An immediately invoked function is not deferred: `(function () { … })()` runs + * its body exactly where it sits, as does esbuild's lowering of a TypeScript + * enum or namespace. + */ +function deferredExecutionNodes(root: Node): Set { + const deferred = new Set(); + const invokedFunctions = new Set(); + + const invokedChild = (node: Node): Node | null => { + if (node.type === "CallExpression" && isNode(node.callee)) { + const callee = unwrapTransparent(node.callee); + // A direct function literal invoked through its standard `.call` or + // `.apply` entry point runs here just as a plain IIFE does. Keep this + // narrow: an arbitrary receiver's method says nothing about whether a + // callback argument or another function body executes. if ( - statementDeclarators.length > 0 && - statementDeclarators.every((declarator) => statementRemoval?.has(declarator)) + callee.type === "MemberExpression" && isNode(callee.object) && + isNode(callee.property) ) { - removableStatements.add(decl.statement); - removableDeclarators.delete(decl.statement); + const method = callee.computed === true && callee.property.type === "StringLiteral" && + typeof callee.property.value === "string" + ? callee.property.value + : callee.computed !== true + ? nodeName(callee.property) + : null; + const target = unwrapTransparent(callee.object); + if ( + (method === "call" || method === "apply") && + (target.type === "FunctionExpression" || target.type === "ArrowFunctionExpression") + ) { + return target; + } } + return callee; + } + if (node.type === "OptionalCallExpression" || node.type === "NewExpression") { + return isNode(node.callee) ? unwrapTransparent(node.callee) : null; + } + if (node.type === "TaggedTemplateExpression") { + return isNode(node.tag) ? unwrapTransparent(node.tag) : null; + } + return null; + }; + + const walk = (node: Node, invoked: Node | null): void => { + const isFunction = node.type === "FunctionDeclaration" || + node.type === "FunctionExpression" || node.type === "ArrowFunctionExpression" || + node.type === "ObjectMethod" || node.type === "ClassMethod" || + node.type === "ClassPrivateMethod"; + const isInstanceField = (node.type === "ClassProperty" || + node.type === "ClassPrivateProperty" || node.type === "ClassAccessorProperty") && + node.static !== true; + + if ( + (isFunction && + (node.generator === true || (node !== invoked && !invokedFunctions.has(node)))) || + isInstanceField + ) { + deferred.add(node); + } + + const nextInvoked = invokedChild(node); + if (nextInvoked) invokedFunctions.add(nextInvoked); + for (const child of children(node)) walk(child, nextInvoked); + }; + + walk(root, null); + return deferred; +} + +/** + * Whether a declaration can be left out of the root computation, whether the + * module reading a name *there* is a reason to keep that name alive. + * + * Three shapes say it is not: + * + * - The declaration is already in the hooks' dependency closure by name. This + * is what the pass exists to drop: `const API_KEY = getEnv(…)` goes, impure + * initialiser and all. + * - Its declaration does not run at all, so it is not surviving code. + * - A `var` hoisted out of nested control flow evaluates only that closure. + * `switch (…) { case 1: var dead = createHash("md5") }` can otherwise pin a + * server-only import even though nothing reads `dead`. This exception does + * not apply to a direct top-level initialiser, whose side effect is part of + * the module even when it happens to call the same import as the hook, and + * eliding it from the roots is not on its own a licence to delete it, see + * `dropUnreachableModuleScopeBindings`, which still keeps the statement when + * any binding it evaluates survives. + * + * Anything else roots what it evaluates like any other side-effectful top-level + * statement. That is what keeps `const clientInit = bootClientAnalytics()`, + * and the helper it calls, in the browser artifact, including when the hook + * calls the same helper or import for a different purpose. + */ +type ElisionReason = + /** The site binds a name the hooks' closure already owns. */ + | "closure-member" + /** A hoisted `var` whose initialiser evaluates only that closure. */ + | "closure-only-evaluation" + /** The declaration runs nothing at module load. */ + | "does-not-run"; + +function elisionReason( + site: BindingSite, + hookClosure: ReadonlySet, + helpers: ReadonlySet, +): ElisionReason | null { + if (site.names.some((name) => hookClosure.has(name))) return "closure-member"; + if (evaluationIsInert(site.node, helpers)) return "does-not-run"; + if (site.nested && [...site.references].every((name) => hookClosure.has(name))) { + return "closure-only-evaluation"; + } + return null; +} + +/** + * The dead declarations this pass is entitled to remove: the ones still holding + * on to the hooks' dependency closure. + * + * Reachability finds every dead declaration, but removing all of them would + * make this stage a general dead-code eliminator and take unrelated client code + * with it. What it must remove is narrower and forced: a dead declaration that + * reads a hook-closure binding is precisely what keeps a secret and its import + * in the browser artifact, and once it goes, every dead declaration that read + * *it* has to go too or the output references a binding that is no longer + * there. So the set grows outwards from the closure until it stops. + */ +function serverTaintedSites( + dead: BindingSite[], + hookClosure: ReadonlySet, +): Set { + const tainted = new Set(); + const taintedNames = new Set(); + const touched = (name: string): boolean => hookClosure.has(name) || taintedNames.has(name); + + for (let grew = true; grew;) { + grew = false; + for (const site of dead) { + if (tainted.has(site)) continue; + if (!site.names.some(touched) && ![...site.references].some(touched)) continue; + + tainted.add(site); + for (const name of site.names) taintedNames.add(name); + grew = true; } - if (removedDecls.length === 0) return current; + } + + return tainted; +} + +/** + * The local names a surviving separate export declaration publishes. + * + * A separate export is a real browser consumer of the binding it names, + * whatever imports the module reads it, but `freeReferencedIdentifiers` + * cannot see that. An `ExportSpecifier` resolves `local` against the synthetic + * root scope, while `export default Page` also names an already-bound local. + * + * `BindingSite.exported` only compensates when the `export` keyword wraps the + * declaration itself. Esbuild hoists every named export into one trailing + * clause and leaves the declarations as plain `const`/`function` statements, + * so no site is `exported` and nothing roots them. That is what made a surviving + * `export const client = makeClient({ get: () => API_KEY })` look dead beside an + * emptied hook, and fail the build over a secret the browser can plainly reach. + * + * A re-export (`export { x } from "./m"`) binds nothing here, so its specifiers + * name no module binding and are skipped. + */ +function separateExportLocalNames(body: Node[]): Set { + const names = new Set(); - // Grow the closure through the removed declarations' initialisers, so a - // chain that only fed the hook (`const RAW = getEnv(); const TOKEN = RAW…`) - // is pruned end to end while unrelated declarations stay outside it. - for (const decl of removedDecls) { - for (const name of freeReferencedIdentifiers(decl.declarator ?? decl.statement)) { - hookClosure.add(name); + for (const statement of body) { + if (statement.type === "ExportDefaultDeclaration") { + if (isNode(statement.declaration)) { + for (const name of freeReferencedIdentifiers(statement.declaration)) names.add(name); } + continue; + } + if (statement.type !== "ExportNamedDeclaration") continue; + if (statement.exportKind === "type") continue; + if (isNode(statement.source)) continue; + + for (const specifier of Array.isArray(statement.specifiers) ? statement.specifiers : []) { + if (!isNode(specifier)) continue; + if (specifier.exportKind === "type") continue; + const local = nodeName(specifier.local); + if (local) names.add(local); + } + } + + return names; +} + +/** + * Drop the module-scope bindings the emptied server-only hooks closed over. + * + * Liveness is reachability from the code that survives, not "is this name + * mentioned elsewhere". The roots are what the module still *evaluates* once + * every declaration that merely introduces a name is elided, surviving + * exports, the client component and any side-effectful top-level statement, + * minus the bodies that run only when something calls them. The edges are + * genuine reads, deferred ones included, so a binding the browser can still + * reach keeps everything its callbacks read. Anything the roots cannot reach + * is dead. + * + * Elision and removal are scoped differently on purpose. Declarations that do + * not run, plus nested hoisted `var` sites that evaluate only the hooks' + * dependency closure, are elided from the roots because a dead declaration + * must not be able to pin a secret: a private helper nothing calls used to be + * treated as unconditionally live and kept `const KEY = getEnv(…)` and its + * `node:crypto` import in the browser artifact. Removal stays scoped to the + * closure, so an unrelated direct `const clientInit = bootClientAnalytics()` + * keeps its side effect even if the hook calls the same binding, and a + * hoisted `var` elided by that second rule is only cut when every binding it + * evaluates is going away too, because `if (dev) { var d = boot(secret()) }` + * is still client code when `boot` survives. Inside the closure the pass is exhaustive: + * `const API_KEY = getEnv(...)` read only by `getServerData` goes, which is + * what lets `dropUnusedImportBindings` drop the import next. + * + * Every binding name a removal takes out is added to `removedNames`, so the + * caller can verify (failing closed) that none of them survives in the final + * output. Two situations are reported back instead, and the caller stops the + * build rather than shipping the value: a dead binding this pass cannot cut + * out of the tree, and one that only a deferred body of a surviving + * declaration reads, where there is nothing to cut and nothing safe to keep. + */ +/** + * The elidable sites nothing in the browser reaches once `registrations` are + * treated as compiler metadata, narrowed to the ones still holding the hooks' + * closure. + * + * Which registrations count is the caller's decision, and it is asked twice: + * once with the registrations the pass proved, to decide what to remove, and + * once with the ones it only recognised by shape, to find out what an + * unprovable registration is keeping alive. + */ +function removableClosureSites( + body: Node[], + sites: BindingSite[], + elidable: BindingSite[], + reasons: ReadonlyMap, + hookClosure: ReadonlySet, + registrations: CompilerNameRegistration[], +): BindingSite[] { + // Esbuild's generated name-registration call is metadata for the declaration + // it names, not an independent browser consumer of it, so its *target* is + // elided from the roots and the call is removed together with the + // declaration. The call itself still reads the helper that performs it, which + // stays alive for as long as any registration survives. + const elidableNames = new Set(elidable.flatMap((site) => site.names)); + const elided = new Set(elidable.map((site) => site.node)); + for (const registration of registrations) { + if (elidableNames.has(registration.targetName)) elided.add(registration.target); + } + + // A declaration roots what it *evaluates*, not everything written inside it. + // The reads in a body that only runs when something calls it are edges of the + // declaration's own binding, so they keep the secret alive exactly as long as + // the browser can still reach that binding. + const deferred = new Set(); + for (const site of sites) { + for (const node of deferredExecutionNodes(site.node)) deferred.add(node); + } + + const roots = freeReferencedIdentifiers({ type: "Program", body }, elided, deferred); + for (const site of sites) { + if (site.exported) { for (const name of site.names) roots.add(name); } + } + // The same contract written through a separate export declaration, including + // the trailing clause emitted by esbuild and raw `export default Page`. + for (const name of separateExportLocalNames(body)) roots.add(name); + + // Every site carries edges, so an elided declaration the roots do reach still + // keeps what it reads: `const shared = KEY.trim()` read by the client roots + // `shared`, and `shared` roots `KEY` in turn. + const reachable = reachableNames(roots, sites); + const dead = elidable.filter((site) => site.names.every((name) => !reachable.has(name))); + const tainted = serverTaintedSites(dead, hookClosure); + return dead.filter((site) => { + if (!tainted.has(site)) return false; + if (reasons.get(site) !== "closure-only-evaluation") return true; + // This site's initialiser still runs, eliding it from the roots only + // stopped it vouching for what it calls. Cutting it out is justified only + // when everything it evaluates is going away. If even one called binding + // survives for browser code, deleting the whole initializer can delete an + // observable client-side call; the caller's blocked-path check then fails + // closed for any dead binding the surviving initializer still reads. + return site.references.size > 0 && + [...site.references].every((name) => !reachable.has(name)); + }); +} + +function dropUnreachableModuleScopeBindings( + body: Node[], + sites: BindingSite[], + hookClosure: ReadonlySet, + removeStatement: (statement: Node) => void, + removedNames: Set, +): Blocker[] { + const recognition = compilerNameHelperBindings(body); + const reasons = new Map(); + for (const site of sites) { + if (site.exported) continue; + const reason = elisionReason(site, hookClosure, recognition.helpers); + if (reason !== null) reasons.set(site, reason); + } + const elidable = sites.filter((site) => reasons.has(site)); + if (elidable.length === 0) return []; + + const registrations = compilerNameRegistrations(body, recognition.helpers); + const removable = removableClosureSites( + body, + sites, + elidable, + reasons, + hookClosure, + registrations, + ); + + // A registration the pass could not classify keeps its target alive: the call + // is a module-scope read of the binding it names, so the declaration, the + // server import behind it and the secret it initialises all stay in the + // browser artifact. `removedNames` cannot catch that, because nothing was + // selected for removal. So ask what the same module would drop if the + // registration were metadata: anything that appears only there is a + // server-only binding this pass is retaining, and the build has to stop. + if (recognition.blockedBy !== null) { + const retained = removableClosureSites( + body, + sites, + elidable, + reasons, + hookClosure, + compilerNameRegistrations(body, recognition.candidates), + ).filter((site) => !removable.includes(site)); + const [held] = retained.flatMap((site) => site.names); + if (held) { + return [{ + reason: `\`${held}\` is a server-only binding kept alive by a compiler name ` + + `registration this pass cannot verify, because the module ${recognition.blockedBy}`, + remedy: REMEDY.separateTheIntrinsicUse, + }]; + } + } + + if (removable.length === 0) return []; + + // A name written down in more than one place is only safe to drop when every + // one of its declarations goes, and only when each of them can be cut out + // at all, a `for (var KEY of …)` head declares the binding the loop assigns + // to and has no removable declaration. + const removableSites = new Set(removable); + const survivingNames = new Set( + sites.filter((site) => !removableSites.has(site)).flatMap((site) => site.names), + ); + + const blocked: Blocker[] = []; + for (const site of removable) { + const shared = site.names.find((name) => survivingNames.has(name)); + if (shared) { + blocked.push({ + reason: `\`${shared}\` is declared more than once and only one declaration is dead`, + remedy: REMEDY.rewriteTheDeclaration, + }); + continue; + } + if (site.remove === null) { + blocked.push({ + reason: `\`${site.names[0]}\` is a dead server-only binding declared in a position ` + + `this pass cannot remove`, + remedy: REMEDY.rewriteTheDeclaration, + }); } + } - for (const [statement, declarators] of removableDeclarators) { - const declarations = statement.declarations; - if (!Array.isArray(declarations)) continue; - statement.declarations = declarations.filter((declarator) => { - return !isNode(declarator) || !declarators.has(declarator); + // A declaration the browser keeps, holding a read of a binding the browser + // must not keep. The read is real but deferred, a callback body, a method, + // an instance field, so it never rooted the binding, while the declaration + // around it runs at module load and cannot be cut. Neither shipping the + // secret nor emitting a reference to a binding that is gone is acceptable, + // and choosing between them is the module author's call, not this pass's. + const goingAway = new Set(removable.flatMap((site) => site.names)); + for (const site of sites) { + if (removableSites.has(site)) continue; + const held = [...site.references].find((name) => goingAway.has(name)); + if (held) { + blocked.push({ + reason: `\`${held}\` is a server-only binding that nothing in the browser reaches, ` + + `but \`${site.names[0]}\` still reads it from a body that runs only when ` + + `it is called, and that declaration runs at module load`, + remedy: REMEDY.separateTheValue, }); } + } + if (blocked.length > 0) return blocked; - current = current.filter((statement) => !removableStatements.has(statement)); + for (const site of removable) { + for (const name of site.names) removedNames.add(name); + site.remove?.(); } + for (const registration of registrations) { + if (removedNames.has(registration.targetName)) removeStatement(registration.statement); + } + + return []; } -/** Local binding names an import statement introduces. */ +/** + * Local *runtime* binding names an import statement introduces. A type-only + * specifier (`import { hashOf, type Cfg }`) is erased before the module runs, + * so it is not a runtime binding: counting it would make a mixed import whose + * only value binding was hook-owned look partly alive, reducing it to a bare + * side-effect import instead of deleting it. + */ function importedBindings(statement: Node): string[] { const bindings: 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; + if (!isNode(specifier) || specifier.importKind === "type") continue; const name = nodeName(specifier.local); if (name) bindings.push(name); } @@ -1245,11 +5634,18 @@ function importedBindings(statement: Node): string[] { * bare side-effect import would keep its transitive graph in the browser * artifact, which is exactly what this stage strips. Other unused imports keep * the legacy conservative side-effect rewrite. + * + * Every binding a deletion or reduction removes is added to `removedNames` for + * the caller's fail-closed output verification. */ -function dropUnusedImportBindings(body: Node[], hookClosure: Set): Node[] { - // 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. +function dropUnusedImportBindings( + body: Node[], + hookClosure: Set, + removedNames: Set, +): Node[] { + // Imports are not lexical declarations inside this synthetic program, so a + // real read of an imported binding is free. A nested client binding with the + // same spelling is bound in its own scope and does not keep the import alive. const referenced = freeReferencedIdentifiers({ type: "Program", body }); return body.filter((statement) => { @@ -1261,6 +5657,8 @@ function dropUnusedImportBindings(body: Node[], hookClosure: Set): Node[ if (bindings.length === 0) return true; if (bindings.some((binding) => referenced.has(binding))) return true; + for (const binding of bindings) removedNames.add(binding); + const source = isNode(statement.source) ? statement.source.value : undefined; const isKnownDroppableSource = typeof source === "string" && (source.startsWith("node:") || source === "veryfront" || source.startsWith("veryfront/")); @@ -1287,21 +5685,54 @@ function setBody(ast: ASTNode, body: Node[]): void { target.body = body; } +/** + * What the author can do about a failure, chosen per failure class. + * + * The advice used to be one sentence appended to every message, telling the + * author to declare the hook directly. That is the fix for an export form this + * pass cannot follow, and nonsense for everything else: a module blocked over a + * binding its client code still reads has already declared the hook directly, + * and a missing parser extension is not the author's doing at all. + */ +const REMEDY = { + /** The hook is exported in a form with no local declaration to empty. */ + declareDirectly: "Declare the hook directly (`export async function getServerData() {…}`) " + + "so the framework can strip it from the client build.", + /** The hook is fine; a value it shares with client code is the problem. */ + separateTheValue: "Move the shared value into a module the hook imports, or read it from code " + + "the browser reaches so it is intentionally part of the client bundle.", + /** Module code stops the pass proving a name registration is compiler metadata. */ + separateTheIntrinsicUse: + "Move the code that reaches or rewrites the `Object` intrinsic into a module that " + + "does not export a server data hook, so the client build can prove the name " + + "registration is compiler metadata and remove the server-only binding.", + /** The declaration form itself is what blocks the removal. */ + rewriteTheDeclaration: + "Declare the value once, at the top level, so the stripped hook's state can " + + "be removed from the client build.", + /** Nothing about the module is wrong. */ + none: "", +} as const; + +/** A removal this pass refused to make, with the advice that fits it. */ +interface Blocker { + reason: string; + remedy: string; +} + /** * Raised when a module names a server-only export that this pass cannot remove. * Emitting the module anyway would put the loader, its imports and anything it * closes over into the browser bundle, so the build stops instead. */ -class ServerExportStripError extends Error { - constructor(filePath: string | undefined, reason: string) { - super( - `Cannot remove the server-only export from ${filePath ?? "this module"} ` + - `before it is sent to the browser: ${reason}. ` + - `Declare the hook directly (\`export async function getServerData() {…}\`) ` + - `so the framework can strip it from the client build.`, - ); - this.name = "ServerExportStripError"; - } +function createServerExportStripError( + filePath: string | undefined, + reason: string, + remedy: string = REMEDY.declareDirectly, +) { + const message = `Cannot remove the server-only export from ${filePath ?? "this module"} ` + + `before it is sent to the browser: ${reason}.` + (remedy ? ` ${remedy}` : ""); + return SERVER_EXPORT_STRIP_FAILED.create({ message, detail: message }); } /** @@ -1322,12 +5753,16 @@ export async function stripServerOnlyExports( const parser = tryResolve("CodeParser"); if (!parser) { - throw new ServerExportStripError(filePath, "no CodeParser extension is registered"); + throw createServerExportStripError( + filePath, + "no CodeParser extension is registered", + REMEDY.none, + ); } let body: Node[]; let ast: ASTNode; - let stubs: { body: Node; init: Node }; + let stubs: Stubs; try { const parsedStubs = await parseStubs(parser); @@ -1337,31 +5772,155 @@ export async function stripServerOnlyExports( ast = await parser.parse({ code, filePath: filePath ?? "module.tsx" }); body = bodyOf(ast); } catch (error) { - throw new ServerExportStripError( + throw createServerExportStripError( filePath, error instanceof Error ? error.message : String(error), + REMEDY.none, ); } const { locals, unhandled } = exportedHookBindings(body); if (unhandled.length > 0) { - throw new ServerExportStripError(filePath, `it is exported as \`${unhandled[0]}\``); + throw createServerExportStripError(filePath, `it is exported as \`${unhandled[0]}\``); + } + if (locals.size === 0) return code; + + // Fail closed on a reassigned hook binding: `export let getServerData = + // stub; getServerData = realLoader` leaves nothing this pass can neutralise. + // Stubbing the declarator would report the hook as emptied while the + // module-scope assignment puts the real loader back at evaluation time, so + // the loader body and everything it references would ship to the browser + // silently. The build stops instead. + const assigned = assignedNames(body); + const reassigned = [...locals].filter((name) => assigned.has(name)); + if (reassigned.length > 0) { + throw createServerExportStripError( + filePath, + `\`${reassigned[0]}\` is reassigned after its declaration, so the assigned ` + + `server loader would ship to the browser and overwrite the stripped stub`, + ); + } + + // Same failure, reached by hoisting rather than by assignment: a `var` + // redeclaration below the top level (`{ var getServerData = realLoader }`, + // `if (…) { var … }`, `for (var … of …)`) binds the same module-scope name, + // and its initialiser runs after the stubbed declaration. The stubber only + // rewrites top-level declarations, so the emitted artifact would carry both + // the stub and the real loader. + const hoisted = hoistedVarNames(body); + const redeclared = [...locals].filter((name) => hoisted.has(name)); + if (redeclared.length > 0) { + throw createServerExportStripError( + filePath, + `\`${redeclared[0]}\` is redeclared by a hoisted \`var\` below the module's ` + + `top level, so the hoisted server loader would ship to the browser and ` + + `overwrite the stripped stub`, + ); } // Capture what the hooks reference *before* emptying them, so pruning is // scoped to the hooks' dependency closure and never touches unrelated // top-level declarations (which may run browser side effects). - const hookClosure = hookReferencedIdentifiers(body, locals); - - if (!emptyServerOnlyHooks(body, locals, stubs)) return code; + const hookSeed = hookReferencedIdentifiers(body, locals); + + // Fail closed on a hook this pass identified but could not stub, a class + // declaration, an imported binding re-exported under a hook name, or any + // other form outside `emptyServerOnlyHooks`'s reach. Emitting the module + // with the declaration intact would ship the loader to the browser. + const emptied = emptyServerOnlyHooks(body, locals, stubs); + const missed = [...locals].filter((name) => !emptied.has(name)); + if (missed.length > 0) { + throw createServerExportStripError( + filePath, + `\`${missed[0]}\` is exported but its declaration is not a function or ` + + `variable this pass can stub`, + ); + } // Drop the module-scope state the emptied hooks were the last user of, then // the imports that leaves unused. Order matters: pruning `const API_KEY = // getEnv(...)` is what makes the `veryfront` import droppable. - const pruned = dropUnusedModuleScopeBindings(body, hookClosure); - setBody(ast, dropUnusedImportBindings(pruned, hookClosure)); + // + // The hooks' dependency closure is itself a reachability question, a helper + // the hook reaches only through another helper belongs to it just as much, + // so it is grown over the same binding graph the pruning walks. + const removedNames = new Set(); + const removableStatements = new Set(); + const sites = moduleScopeBindingSites(body, stubs, (statement) => { + removableStatements.add(statement); + }); + const moduleBindings = new Set(sites.flatMap((site) => site.names)); + for (const statement of body) { + if (statement.type !== "ImportDeclaration") continue; + for (const binding of importedBindings(statement)) moduleBindings.add(binding); + } + // Free globals are not part of the hook's removable closure. If both the + // hook and an unrelated client initializer call `console`, for example, + // their shared global name must not make the client side effect server-tainted. + const hookClosure = new Set( + [...reachableNames(hookSeed, sites)].filter((name) => moduleBindings.has(name)), + ); + const [firstBlocked] = dropUnreachableModuleScopeBindings( + body, + sites, + hookClosure, + (statement) => removableStatements.add(statement), + removedNames, + ); + if (firstBlocked) { + throw createServerExportStripError(filePath, firstBlocked.reason, firstBlocked.remedy); + } + + const pruned = body.filter((statement) => !removableStatements.has(statement)); + const finalBody = dropUnusedImportBindings(pruned, hookClosure, removedNames); + + setBody(ast, finalBody); const generated = await parser.generate(ast); + + // Fail-closed output verification, run against the artifact itself: the + // emitted code is re-parsed and scanned for every binding this pass decided + // to drop, as an import or as a reference. Checking the freshly parsed + // output (not the tree the nodes were structurally deleted from) means a + // regression anywhere between the removal decision and the emitted text, + // the generator included, stops the build instead of leaking. + if (removedNames.size > 0) { + let emittedBody: Node[]; + try { + const emitted = await parser.parse({ + code: generated.code, + filePath: filePath ?? "module.tsx", + }); + emittedBody = bodyOf(emitted); + } catch (error) { + throw createServerExportStripError( + filePath, + `the stripped output no longer parses: ${ + error instanceof Error ? error.message : String(error) + }`, + REMEDY.none, + ); + } + + const residual = freeReferencedIdentifiers({ type: "Program", body: emittedBody }); + for (const binding of moduleScopeBindingNames(emittedBody)) residual.add(binding); + // A `var` below the top level binds module scope too, so a declaration that + // survived inside a block must count as a leak just like a top-level one. + for (const binding of hoistedVarNames(emittedBody)) residual.add(binding); + for (const statement of emittedBody) { + if (statement.type !== "ImportDeclaration") continue; + for (const binding of importedBindings(statement)) residual.add(binding); + } + const leaked = [...removedNames].filter((name) => residual.has(name)); + if (leaked.length > 0) { + throw createServerExportStripError( + filePath, + `the server-only binding \`${leaked[0]}\` still appears in the stripped output`, + REMEDY.none, + ); + } + } + return dropSourceMapSuffix(generated.code); }