From 311f7cf823c9ede3fa52e220f40171bee4c597b3 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 17 Aug 2026 15:22:19 +0200 Subject: [PATCH 01/81] fix(security): close the destructured server-value leak and fail closed on unstubable hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The browser server-exports strip left a destructured module-scope server value (`const { apiKey } = getEnv(...)`) in the client artifact, because the declaration collector handled only simple-identifier declarators. A pattern declarator is now a removal candidate as a single unit: it is dropped — with its initialiser call and the imports it was the last user of — only when every name it binds is exclusively part of the stripped hook's dependency closure, so a pattern the client still partly reads survives whole. Two fail-closed guards are added on the same server/client boundary: - A hook the pass identifies but cannot stub (a class declaration, an imported binding re-exported under a hook name) now stops the build instead of shipping the module unchanged. - After pruning, the pass verifies that no binding it decided to drop still appears in the output it is about to emit; a violated invariant raises ServerExportStripError rather than leaking. Investigated moving this DCE onto esbuild's tree-shaker (issue ask) and recorded in the header why it cannot own the job: verified against esbuild 0.28.1 in both transform and bundle mode, a destructuring of a call is never shaken (even @__PURE__-annotated), an impure hook-only initialiser is indistinguishable from client init without the closure analysis, keepNames registrations pin hook-only helpers alive, and no mode expresses the delete-hook-owned / reduce-unrelated import policy. Refs veryfront/veryfront-issue-inbox#112 --- .../browser-server-exports-strip.test.ts | 143 ++++++++++++++- .../stages/browser-server-exports-strip.ts | 165 ++++++++++++++---- 2 files changed, 263 insertions(+), 45 deletions(-) diff --git a/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts b/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts index 9ebe487085..75f63aac85 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts @@ -243,6 +243,32 @@ 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"); + }); + + // 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"); + }); + // 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 () => { @@ -755,12 +781,15 @@ describe("browser-server-exports-strip", () => { assertEquals(occurrences(result, "TOKEN"), 0); // hook-only tail → dropped }); - // 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 () => { + // 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");`, @@ -770,10 +799,110 @@ describe("browser-server-exports-strip", () => { const result = await stripServerOnlyExports(code); - // Pinned as-is: the destructured binding and its import survive. + 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"); }); + // 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("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); + }); + it("keeps an import that the client still references", async () => { const code = [ `import { formatDate } from "../lib/dates.js";`, diff --git a/src/transforms/pipeline/stages/browser-server-exports-strip.ts b/src/transforms/pipeline/stages/browser-server-exports-strip.ts index 63d7d5bcf1..ddf4af1691 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 @@ -43,16 +54,22 @@ * 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. + * 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): the build stops rather than + * shipping the declaration. As a final fail-closed check, the pass verifies + * that no binding it decided to drop still appears in the output it is about + * to emit — a violated invariant fails the build instead of leaking. * * 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. + * declarations the hooks were the last reader of — including destructured + * ones, so neither `const API_KEY = getEnv(...)` nor `const { apiKey } = + * getEnv(...)` used only by `getServerData` reaches 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. */ import { tryResolve } from "#veryfront/extensions/contracts.ts"; @@ -147,14 +164,13 @@ async function parseStubs(parser: CodeParser): Promise<{ body: Node; init: Node return { body, init }; } -/** Every binding name a destructuring pattern introduces. */ -function patternBoundNames(pattern: Node): string[] { - const names: string[] = []; +/** 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 === "Identifier") { - const name = nodeName(node); - if (name) names.push(name); + ids.push(node); return; } @@ -191,6 +207,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; } @@ -265,15 +291,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; +): Set { + const emptied = new Set(); + if (targets.size === 0) return emptied; const declarationsIn = (statement: Node): Node[] => { const declaration = statement.type === "ExportNamedDeclaration" @@ -289,7 +320,7 @@ function emptyServerOnlyHooks( if (!name || !targets.has(name)) continue; declaration.params = []; declaration.body = structuredClone(stubs.body); - changed = true; + emptied.add(name); continue; } @@ -302,12 +333,12 @@ 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; } /** @@ -371,8 +402,15 @@ interface ModuleScopeDecl { * 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. + * + * A destructuring declarator (`const { apiKey } = getEnv(...)`) is a candidate + * as a single unit carrying every name its pattern binds: it is removed only + * when *all* of them fall out of use, 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. Default-value and computed-key references inside the + * pattern are runtime reads and count against liveness like any other. */ function moduleScopeDeclarations(body: Node[]): ModuleScopeDecl[] { const decls: ModuleScopeDecl[] = []; @@ -386,23 +424,26 @@ function moduleScopeDeclarations(body: Node[]): ModuleScopeDecl[] { } if (statement.type === "VariableDeclaration") { - const variableDecls: ModuleScopeDecl[] = []; - 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 (!isNode(id)) continue; + + 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) continue; - decls.push(...variableDecls); + decls.push({ statement, declarator, names, bindingIds }); + } } } @@ -894,8 +935,16 @@ function compilerNameRegistrations(body: Node[]): CompilerNameRegistration[] { * 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 binding name a removal takes out is added to `removedNames`, so the + * caller can verify — fail-closed — that none of them survives in the final + * output. */ -function dropUnusedModuleScopeBindings(body: Node[], hookClosure: Set): Node[] { +function dropUnusedModuleScopeBindings( + body: Node[], + hookClosure: Set, + removedNames: Set, +): Node[] { let current = body; for (;;) { @@ -957,6 +1006,7 @@ function dropUnusedModuleScopeBindings(body: Node[], hookClosure: Set): // 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 decl.names) removedNames.add(name); for (const name of freeReferencedIdentifiers(decl.declarator ?? decl.statement)) { hookClosure.add(name); } @@ -994,8 +1044,15 @@ 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[] { +function dropUnusedImportBindings( + body: Node[], + hookClosure: Set, + removedNames: Set, +): Node[] { const referenced = referencedIdentifiers(body); return body.filter((statement) => { @@ -1007,6 +1064,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/")); @@ -1093,19 +1152,49 @@ export async function stripServerOnlyExports( if (unhandled.length > 0) { throw new ServerExportStripError(filePath, `it is exported as \`${unhandled[0]}\``); } + if (locals.size === 0) return code; // 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; + // 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 new ServerExportStripError( + 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)); + const removedNames = new Set(); + const pruned = dropUnusedModuleScopeBindings(body, hookClosure, removedNames); + const finalBody = dropUnusedImportBindings(pruned, hookClosure, removedNames); + + // Fail-closed output verification: every binding this pass decided to drop + // must be gone from the artifact about to be emitted. The prune passes only + // remove bindings they counted as unreferenced, so a hit here is a violated + // invariant — and the safe response to a violated invariant on a + // server/client boundary is a stopped build, not a silent leak. + const residual = referencedIdentifiers(finalBody); + const leaked = [...removedNames].filter((name) => residual.has(name)); + if (leaked.length > 0) { + throw new ServerExportStripError( + filePath, + `the server-only binding \`${leaked[0]}\` still appears in the stripped output`, + ); + } + + setBody(ast, finalBody); const generated = await parser.generate(ast); return dropSourceMapSuffix(generated.code); From 9a23d9332f6eaec44fa87c14639aa057239ce340 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 17 Aug 2026 16:22:32 +0200 Subject: [PATCH 02/81] fix(build): ignore intra-pattern liveness reads --- .../stages/browser-server-exports-strip.test.ts | 16 ++++++++++++++++ .../stages/browser-server-exports-strip.ts | 16 +++++++++++++--- 2 files changed, 29 insertions(+), 3 deletions(-) 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 75f63aac85..c76bf63eec 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts @@ -887,6 +887,22 @@ describe("browser-server-exports-strip", () => { 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"); + }); + it("drops a chain that flows through a destructured server value", async () => { const code = [ `import { getEnv } from "veryfront";`, diff --git a/src/transforms/pipeline/stages/browser-server-exports-strip.ts b/src/transforms/pipeline/stages/browser-server-exports-strip.ts index ddf4af1691..37b50ff867 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.ts @@ -350,7 +350,11 @@ function emptyServerOnlyHooks( * references (the `id` a declaration introduces), so a declaration is not * counted as a use of itself when deciding whether it is dead. */ -function referencedIdentifiers(body: Node[], excluded?: WeakSet): Set { +function referencedIdentifiers( + body: Node[], + excluded?: WeakSet, + ignoredSubtree?: Node, +): Set { const referenced = new Set(); // Filled in as each parent is visited, which always happens before its // children. @@ -372,6 +376,7 @@ function referencedIdentifiers(body: Node[], excluded?: WeakSet): Set { + if (node === ignoredSubtree) return false; if (node.type === "ImportDeclaration") return false; markFixedName(node); @@ -410,7 +415,8 @@ interface ModuleScopeDecl { * 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. Default-value and computed-key references inside the - * pattern are runtime reads and count against liveness like any other. + * pattern remain part of the declaration's dependency closure, but are not + * external browser consumers of sibling bindings from that same pattern. */ function moduleScopeDeclarations(body: Node[]): ModuleScopeDecl[] { const decls: ModuleScopeDecl[] = []; @@ -968,7 +974,11 @@ function dropUnusedModuleScopeBindings( 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)); + const id = decl.declarator?.id; + const externalReferences = isNode(id) && id.type !== "Identifier" + ? referencedIdentifiers(current, excluded, decl.declarator) + : referenced; + const unused = decl.names.every((name) => !externalReferences.has(name)); if (!inClosure || !unused) continue; removedDecls.push(decl); From 15208fcbd2b16a0b79f2daba600be4eba78e8994 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 17 Aug 2026 16:33:58 +0200 Subject: [PATCH 03/81] fix(security): fail closed on reassigned hook bindings and verify the emitted artifact A module-scope assignment to a server-hook binding (export let getServerData = stub; getServerData = realLoader) defeated stubbing: the pass reported the hook as emptied while the real loader shipped to the browser and overwrote the stub at evaluation time. Any assignment-like write to a hook binding now raises ServerExportStripError. The post-strip output verification now re-parses the artifact about to be emitted and scans it for every dropped binding, as an import or a reference, instead of scanning the same tree the nodes were structurally deleted from - so a regression anywhere up to and including the generator stops the build instead of leaking. Also pins the reviewed sibling-default probe (const { retries, delay = retries * 2 } = getEnv(...)) as a regression test. --- .../browser-server-exports-strip.test.ts | 73 ++++++++ .../stages/browser-server-exports-strip.ts | 162 ++++++++++++++++-- 2 files changed, 216 insertions(+), 19 deletions(-) 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 c76bf63eec..5877623d55 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts @@ -269,6 +269,57 @@ describe("browser-server-exports-strip", () => { 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")); + }); + // 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 () => { @@ -903,6 +954,28 @@ describe("browser-server-exports-strip", () => { 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";`, diff --git a/src/transforms/pipeline/stages/browser-server-exports-strip.ts b/src/transforms/pipeline/stages/browser-server-exports-strip.ts index 37b50ff867..18e9a955c8 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.ts @@ -55,11 +55,16 @@ * 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. The same - * rule covers a hook this pass can *see* but cannot *stub* (a class, an - * imported binding re-exported under a hook name): the build stops rather than - * shipping the declaration. As a final fail-closed check, the pass verifies - * that no binding it decided to drop still appears in the output it is about - * to emit — a violated invariant fails the build instead of leaking. + * rule covers a hook this pass can *see* but cannot *stub*: a class, an + * imported binding re-exported under a hook name, and a hook binding the + * module *reassigns* (`export let getServerData = stub; getServerData = + * realLoader`) — stubbing the declarator would leave the assignment to put + * the real loader back at module-evaluation time, so the build stops rather + * than shipping the declaration. As a final fail-closed check, the pass + * re-parses the output it is about to emit and verifies that no binding it + * decided to drop is still imported or referenced in that artifact — a + * violated invariant anywhere between the removal decision and the emitted + * text fails the build instead of leaking. * * What this pass does: it empties hook bodies, drops the module-scope * declarations the hooks were the last reader of — including destructured @@ -795,6 +800,85 @@ function hookReferencedIdentifiers(body: Node[], targets: Set): Set { + const assigned = new Set(); + + const collectTargets = (target: Node): void => { + if (target.type === "Identifier") { + const name = nodeName(target); + if (name) assigned.add(name); + 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 literalText(node: Node | undefined): string | null { if (!node) return null; return typeof node.value === "string" ? node.value : nodeName(node); @@ -1164,6 +1248,22 @@ export async function stripServerOnlyExports( } 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 new ServerExportStripError( + filePath, + `\`${reassigned[0]}\` is reassigned after its declaration, so the assigned ` + + `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). @@ -1190,23 +1290,47 @@ export async function stripServerOnlyExports( const pruned = dropUnusedModuleScopeBindings(body, hookClosure, removedNames); const finalBody = dropUnusedImportBindings(pruned, hookClosure, removedNames); - // Fail-closed output verification: every binding this pass decided to drop - // must be gone from the artifact about to be emitted. The prune passes only - // remove bindings they counted as unreferenced, so a hit here is a violated - // invariant — and the safe response to a violated invariant on a - // server/client boundary is a stopped build, not a silent leak. - const residual = referencedIdentifiers(finalBody); - const leaked = [...removedNames].filter((name) => residual.has(name)); - if (leaked.length > 0) { - throw new ServerExportStripError( - filePath, - `the server-only binding \`${leaked[0]}\` still appears in the stripped output`, - ); - } - 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 new ServerExportStripError( + filePath, + `the stripped output no longer parses: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + + const residual = referencedIdentifiers(emittedBody); + 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 new ServerExportStripError( + filePath, + `the server-only binding \`${leaked[0]}\` still appears in the stripped output`, + ); + } + } + return dropSourceMapSuffix(generated.code); } From 9a88a217eb080ae6096f51ca3bd23d618a9128e6 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 17 Aug 2026 16:55:51 +0200 Subject: [PATCH 04/81] fix(build): ignore shadowed client bindings in strip liveness --- .../browser-server-exports-strip.test.ts | 34 +++++++ .../stages/browser-server-exports-strip.ts | 96 ++++++++++++++++--- 2 files changed, 115 insertions(+), 15 deletions(-) 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 5877623d55..d7e3e5aee6 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts @@ -919,6 +919,40 @@ describe("browser-server-exports-strip", () => { 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"'); + }); + // 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. diff --git a/src/transforms/pipeline/stages/browser-server-exports-strip.ts b/src/transforms/pipeline/stages/browser-server-exports-strip.ts index 18e9a955c8..4f9a9f1f57 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.ts @@ -405,7 +405,6 @@ interface ModuleScopeDecl { statement: Node; declarator?: Node; names: string[]; - bindingIds: Node[]; } /** @@ -430,7 +429,7 @@ function moduleScopeDeclarations(body: Node[]): ModuleScopeDecl[] { 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] }); + if (name && isNode(id)) decls.push({ statement, names: [name] }); continue; } @@ -453,7 +452,7 @@ function moduleScopeDeclarations(body: Node[]): ModuleScopeDecl[] { // Either way the declarator simply stays. if (names.length === 0 || names.length !== bindingIds.length) continue; - decls.push({ statement, declarator, names, bindingIds }); + decls.push({ statement, declarator, names }); } } } @@ -461,6 +460,37 @@ function moduleScopeDeclarations(body: Node[]): ModuleScopeDecl[] { return decls; } +/** 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" + ) { + 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 names; +} + /** Whether a name is bound in the current lexical stack. */ interface LexicalScope { kind: "function" | "block"; @@ -1012,6 +1042,45 @@ function compilerNameRegistrations(body: Node[]): CompilerNameRegistration[] { return registrations; } +/** + * References to a module declaration after removing only that declaration and + * its compiler-generated name registration from the analysis tree. A real + * module read becomes free; a same-named binding inside client code remains + * lexically bound and does not keep server state alive. + */ +function referencesOutsideModuleScopeDeclaration( + body: Node[], + declaration: ModuleScopeDecl, + nameRegistrations: CompilerNameRegistration[], +): Set { + const ignoredStatements = new Set( + nameRegistrations.filter((registration) => declaration.names.includes(registration.targetName)) + .map((registration) => registration.statement), + ); + const remainingBody: Node[] = []; + + for (const statement of body) { + if (ignoredStatements.has(statement)) continue; + if (statement !== declaration.statement) { + remainingBody.push(statement); + continue; + } + if (!declaration.declarator) continue; + + const declarators = Array.isArray(statement.declarations) + ? statement.declarations.filter(isNode) + : []; + const remainingDeclarators = declarators.filter((candidate) => + candidate !== declaration.declarator + ); + if (remainingDeclarators.length > 0) { + remainingBody.push({ ...statement, declarations: remainingDeclarators }); + } + } + + return freeReferencedIdentifiers({ type: "Program", body: remainingBody }); +} + /** * Drop the top-level declarations the emptied server-only hooks closed over. * @@ -1041,29 +1110,25 @@ function dropUnusedModuleScopeBindings( 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 id = decl.declarator?.id; - const externalReferences = isNode(id) && id.type !== "Identifier" - ? referencedIdentifiers(current, excluded, decl.declarator) - : referenced; + if (!inClosure) continue; + const externalReferences = referencesOutsideModuleScopeDeclaration( + current, + decl, + nameRegistrations, + ); const unused = decl.names.every((name) => !externalReferences.has(name)); - if (!inClosure || !unused) continue; + if (!unused) continue; removedDecls.push(decl); for (const registration of nameRegistrations) { @@ -1317,7 +1382,8 @@ export async function stripServerOnlyExports( ); } - const residual = referencedIdentifiers(emittedBody); + const residual = freeReferencedIdentifiers({ type: "Program", body: emittedBody }); + for (const binding of moduleScopeBindingNames(emittedBody)) residual.add(binding); for (const statement of emittedBody) { if (statement.type !== "ImportDeclaration") continue; for (const binding of importedBindings(statement)) residual.add(binding); From b38fd7b62222f4840623cca27e3228e3c7e08453 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 17 Aug 2026 17:09:28 +0200 Subject: [PATCH 05/81] fix(build): make stripped import liveness scope-aware --- .../browser-server-exports-strip.test.ts | 15 +++++ .../stages/browser-server-exports-strip.ts | 59 ++----------------- 2 files changed, 19 insertions(+), 55 deletions(-) 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 d7e3e5aee6..035ffb84a3 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts @@ -953,6 +953,21 @@ describe("browser-server-exports-strip", () => { 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 ="); + }); + // 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. diff --git a/src/transforms/pipeline/stages/browser-server-exports-strip.ts b/src/transforms/pipeline/stages/browser-server-exports-strip.ts index 4f9a9f1f57..0dfdb04cf1 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.ts @@ -346,60 +346,6 @@ function emptyServerOnlyHooks( 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 }`). Over-counting only ever keeps an import. - * - * `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. - */ -function referencedIdentifiers( - body: Node[], - excluded?: WeakSet, - ignoredSubtree?: Node, -): 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.key - : undefined; - - if (node.computed === true) return; - if (isNode(property)) fixedNames.add(property); - }; - - for (const statement of body) { - if (statement.type === "ImportDeclaration") continue; - - walk(statement, (node) => { - if (node === ignoredSubtree) return false; - if (node.type === "ImportDeclaration") return false; - - markFixedName(node); - - 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); - } - - return true; - }); - } - - return referenced; -} - /** A top-level declaration and the binding names / binding-id nodes it owns. */ interface ModuleScopeDecl { statement: Node; @@ -1212,7 +1158,10 @@ function dropUnusedImportBindings( hookClosure: Set, removedNames: Set, ): Node[] { - const referenced = referencedIdentifiers(body); + // 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) => { if (statement.type !== "ImportDeclaration") return true; From 0a26945de21132cc9002cbc47cf1a3e47d22a813 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 17 Aug 2026 17:14:32 +0200 Subject: [PATCH 06/81] fix(build): bind class names during import liveness --- .../browser-server-exports-strip.test.ts | 19 +++++++++++++++++++ .../stages/browser-server-exports-strip.ts | 7 +++++-- 2 files changed, 24 insertions(+), 2 deletions(-) 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 035ffb84a3..11c46cbb90 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts @@ -968,6 +968,25 @@ describe("browser-server-exports-strip", () => { 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 ="); + }); + // 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. diff --git a/src/transforms/pipeline/stages/browser-server-exports-strip.ts b/src/transforms/pipeline/stages/browser-server-exports-strip.ts index 0dfdb04cf1..b41f8331d6 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.ts @@ -682,9 +682,12 @@ function freeReferencedIdentifiers(root: Node): Set { if (node.type === "ClassDeclaration" || node.type === "ClassExpression") { if (node.type === "ClassDeclaration") bindPatternNames(scopes[0] ?? rootScope, node.id); + const classScope: LexicalScope = { kind: "block", names: new Set() }; + 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); + if (isNode(node.superClass)) visit(node.superClass, classScopes); + if (isNode(body)) visitChildren(body, classScopes); return; } From 4de2aa47ced1b1cbd77c528dc2d3a6cc9f920ad9 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 17 Aug 2026 17:28:42 +0200 Subject: [PATCH 07/81] fix(security): fail closed on hoisted var redeclarations of hook bindings --- .../browser-server-exports-strip.test.ts | 87 ++++++++++++++++++ .../stages/browser-server-exports-strip.ts | 88 ++++++++++++++++++- 2 files changed, 172 insertions(+), 3 deletions(-) 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 11c46cbb90..7a3ee6aee6 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts @@ -320,6 +320,93 @@ describe("browser-server-exports-strip", () => { 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 () => { diff --git a/src/transforms/pipeline/stages/browser-server-exports-strip.ts b/src/transforms/pipeline/stages/browser-server-exports-strip.ts index b41f8331d6..f0958939af 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.ts @@ -56,9 +56,11 @@ * 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. The same * rule covers a hook this pass can *see* but cannot *stub*: a class, an - * imported binding re-exported under a hook name, and a hook binding the - * module *reassigns* (`export let getServerData = stub; getServerData = - * realLoader`) — stubbing the declarator would leave the assignment to put + * imported binding re-exported under a hook name, 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. As a final fail-closed check, the pass * re-parses the output it is about to emit and verifies that no binding it @@ -858,6 +860,69 @@ function assignedNames(body: Node[]): Set { 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 startsVarScope = (node: Node): boolean => + 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"); + + 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); @@ -1281,6 +1346,23 @@ export async function stripServerOnlyExports( ); } + // 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 new ServerExportStripError( + 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). From 04176039683d985895c56eb02180484b903e24e3 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 17 Aug 2026 17:29:14 +0200 Subject: [PATCH 08/81] fix(transforms): model TypeScript and static-block scopes --- .../browser-server-exports-strip.test.ts | 65 +++++++++++++++++++ .../stages/browser-server-exports-strip.ts | 35 ++++++++-- 2 files changed, 93 insertions(+), 7 deletions(-) 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 7a3ee6aee6..eadf4eecea 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts @@ -1074,6 +1074,71 @@ describe("browser-server-exports-strip", () => { 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("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. diff --git a/src/transforms/pipeline/stages/browser-server-exports-strip.ts b/src/transforms/pipeline/stages/browser-server-exports-strip.ts index f0958939af..c7282b0895 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.ts @@ -176,6 +176,11 @@ 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") { ids.push(node); return; @@ -441,7 +446,7 @@ function moduleScopeBindingNames(body: Node[]): Set { /** Whether a name is bound in the current lexical stack. */ interface LexicalScope { - kind: "function" | "block"; + kind: "var" | "block"; names: Set; } @@ -458,10 +463,10 @@ function isLexicallyBound(name: string, scopes: LexicalScope[]): boolean { */ function freeReferencedIdentifiers(root: Node): Set { const free = new Set(); - const rootScope: LexicalScope = { kind: "function", names: new Set() }; + const rootScope: LexicalScope = { kind: "var", names: new Set() }; - 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; @@ -492,7 +497,8 @@ function freeReferencedIdentifiers(root: Node): Set { if ( child.type === "FunctionDeclaration" || child.type === "FunctionExpression" || child.type === "ArrowFunctionExpression" || child.type === "ObjectMethod" || - child.type === "ClassMethod" + child.type === "ClassMethod" || child.type === "ClassDeclaration" || + child.type === "ClassExpression" || child.type === "StaticBlock" ) { continue; } @@ -514,6 +520,11 @@ function freeReferencedIdentifiers(root: Node): Set { }; const visitPatternRuntime = (pattern: Node, scopes: LexicalScope[]): void => { + if (pattern.type === "TSParameterProperty") { + if (isNode(pattern.parameter)) visitPatternRuntime(pattern.parameter, scopes); + return; + } + if (pattern.type === "Identifier") return; if (pattern.type === "AssignmentPattern") { @@ -555,7 +566,7 @@ function freeReferencedIdentifiers(root: Node): Set { }; const bindVariableDeclaration = (node: Node, scopes: LexicalScope[]): void => { - const targetScope = node.kind === "var" ? currentFunctionScope(scopes) : scopes[0] ?? rootScope; + const targetScope = node.kind === "var" ? currentVarScope(scopes) : scopes[0] ?? rootScope; for ( const declarator of Array.isArray(node.declarations) ? node.declarations : [] ) { @@ -575,7 +586,7 @@ function freeReferencedIdentifiers(root: Node): Set { }; const visitFunction = (node: Node, scopes: LexicalScope[]): void => { - const functionScope: LexicalScope = { kind: "function", names: new Set() }; + const functionScope: LexicalScope = { kind: "var", names: new Set() }; if (node.type === "FunctionDeclaration") bindPatternNames(scopes[0] ?? rootScope, node.id); bindPatternNames(functionScope, node.id); @@ -669,6 +680,16 @@ function freeReferencedIdentifiers(root: Node): Set { return; } + 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 === "VariableDeclaration") { visitVariableDeclaration(node, scopes); return; From 09ad67160c9fd00c938f5b4e9477ce0b0b96f1d1 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 17 Aug 2026 17:49:33 +0200 Subject: [PATCH 09/81] fix(transforms): retain decorator imports in parameter properties --- .../stages/browser-server-exports-strip.test.ts | 16 ++++++++++++++++ .../stages/browser-server-exports-strip.ts | 3 +++ 2 files changed, 19 insertions(+) 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 eadf4eecea..ed830435ae 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts @@ -1090,6 +1090,22 @@ describe("browser-server-exports-strip", () => { 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")'); + }); + it("binds the name introduced by a TypeScript parameter property", async () => { const code = [ `import { value } from "../server/secrets.ts";`, diff --git a/src/transforms/pipeline/stages/browser-server-exports-strip.ts b/src/transforms/pipeline/stages/browser-server-exports-strip.ts index c7282b0895..7084a375e0 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.ts @@ -521,6 +521,9 @@ function freeReferencedIdentifiers(root: Node): Set { const visitPatternRuntime = (pattern: Node, scopes: LexicalScope[]): void => { if (pattern.type === "TSParameterProperty") { + for (const decorator of Array.isArray(pattern.decorators) ? pattern.decorators : []) { + if (isNode(decorator)) visit(decorator, scopes); + } if (isNode(pattern.parameter)) visitPatternRuntime(pattern.parameter, scopes); return; } From 68cdb72474360b4757d31ed9900809ce3d8b9524 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 17 Aug 2026 18:05:44 +0200 Subject: [PATCH 10/81] fix(transforms): model switch lexical scope --- .../browser-server-exports-strip.test.ts | 46 +++++++++++++++++++ .../stages/browser-server-exports-strip.ts | 20 +++++--- 2 files changed, 60 insertions(+), 6 deletions(-) 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 ed830435ae..cad8191c63 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts @@ -620,6 +620,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";`, diff --git a/src/transforms/pipeline/stages/browser-server-exports-strip.ts b/src/transforms/pipeline/stages/browser-server-exports-strip.ts index 7084a375e0..e6ff690860 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.ts @@ -473,11 +473,8 @@ function freeReferencedIdentifiers(root: Node): Set { for (const name of patternBoundNames(value)) scope.names.add(name); }; - const bindDirectDeclarations = (scope: LexicalScope, node: Node): void => { - const body = node.body; - if (!Array.isArray(body)) return; - - for (const statement of body) { + const bindDirectStatements = (scope: LexicalScope, statements: unknown[]): void => { + for (const statement of statements) { if (!isNode(statement)) continue; if (statement.type === "FunctionDeclaration" || statement.type === "ClassDeclaration") { bindPatternNames(scope, statement.id); @@ -492,6 +489,11 @@ function freeReferencedIdentifiers(root: Node): Set { } }; + 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)) { if ( @@ -641,9 +643,15 @@ 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); } From 41823c1f08555621220c62b28a2d63d3a5ccf2c2 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 17 Aug 2026 18:05:58 +0200 Subject: [PATCH 11/81] fix(transforms): compute strip liveness as reachability from surviving roots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Liveness was decided one declaration at a time — "is this name mentioned anywhere else?" — over direct top-level declarations only. Four leaks followed from that single shape: - Two hook-only helpers that call each other are each the other's last consumer, so neither was ever removable and the secret they closed over shipped whole, node builtin imports included. - A `var` hoists into module scope out of any block, `if`, `try`, `switch`, loop or label, but those declarations were never collected, so a secret written that way was not even a removal candidate. - Statement labels and an export specifier's exported name counted as reads, pinning a secret alive on a name collision. - An ES2022 string export name (`export { loadIt as "getServerData" }`) did not match the hook matcher, so the module passed through byte for byte. Liveness is now reachability over the module's binding graph: nodes are every module-scope binding including hoisted `var`s, roots are what the module still reads once candidates are elided, and edges are genuine reads. An unreachable component is dropped whole, cycles included. Pruning stays scoped to the hooks' dependency closure, itself grown over the same graph, so unrelated side-effectful initialisation is untouched. Fails closed on the two forms with no safe rewrite: a hook exported under a string name or as a namespace re-export, and a dead binding declared by a `for…of` head, which has no declaration to cut out. Decorators are now read as edges, both inside a stripped hook and on surviving client code, and the post-strip output check counts hoisted `var`s as module bindings. --- .../browser-server-exports-strip.test.ts | 255 +++++++ .../stages/browser-server-exports-strip.ts | 704 ++++++++++++------ 2 files changed, 741 insertions(+), 218 deletions(-) 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 cad8191c63..c8d789bb25 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts @@ -258,6 +258,36 @@ describe("browser-server-exports-strip", () => { 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. @@ -1274,6 +1304,231 @@ describe("browser-server-exports-strip", () => { 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"); + }); + + // 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"); + }); + + // 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";`, diff --git a/src/transforms/pipeline/stages/browser-server-exports-strip.ts b/src/transforms/pipeline/stages/browser-server-exports-strip.ts index e6ff690860..163bce9d18 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.ts @@ -32,6 +32,25 @@ * 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 reads once every + * removal candidate is elided: its surviving exports, the client component, and + * any side-effectful top-level statement, which keeps whatever it references. + * 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 @@ -56,27 +75,36 @@ * 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. 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 binding the module + * 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. As a final fail-closed check, the pass + * than shipping the declaration. It covers one more case 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. As a final fail-closed check, the pass * re-parses the output it is about to emit and verifies that no binding it * decided to drop is still imported or referenced in that artifact — a * violated invariant anywhere between the removal decision and the emitted * text fails the build instead of leaking. * - * What this pass does: it empties hook bodies, drops the module-scope - * declarations the hooks were the last reader of — including destructured - * ones, so neither `const API_KEY = getEnv(...)` nor `const { apiKey } = - * getEnv(...)` used only by `getServerData` reaches 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. + * 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. + * What it does NOT do: reason about a value that is *also* read by browser + * code, one a surviving side-effectful top-level statement still references + * (`Object.defineProperty(box, "run", …)` reads what it is given), or one + * reached only through an existing bare side-effect import — those are kept. + * 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"; @@ -109,7 +137,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 }; @@ -149,6 +178,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; @@ -156,19 +197,34 @@ 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> { +/** 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 }; +} + +/** 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). */ @@ -250,19 +306,39 @@ 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. + if (nodeName(specifier.exported) === null) { + unhandled.push(`export { … as "${exported}" }`); continue; } @@ -313,7 +389,7 @@ function exportedHookBindings(body: Node[]): { locals: Set; unhandled: s function emptyServerOnlyHooks( body: Node[], targets: Set, - stubs: { body: Node; init: Node }, + stubs: Stubs, ): Set { const emptied = new Set(); if (targets.size === 0) return emptied; @@ -353,64 +429,205 @@ function emptyServerOnlyHooks( return emptied; } -/** A top-level declaration and the binding names / binding-id nodes it owns. */ -interface ModuleScopeDecl { - statement: Node; - declarator?: Node; +/** + * 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. + */ +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; + /** 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; } /** - * 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. + * 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. + */ +function declaratorReferences(declaration: Node, declarator: Node): Set { + return freeReferencedIdentifiers({ + type: "VariableDeclaration", + kind: declaration.kind, + declarations: [declarator], + }); +} + +/** + * 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. * - * A destructuring declarator (`const { apiKey } = getEnv(...)`) is a candidate - * as a single unit carrying every name its pattern binds: it is removed only - * when *all* of them fall out of use, 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. Default-value and computed-key references inside the - * pattern remain part of the declaration's dependency closure, but are not - * external browser consumers of sibling bindings from that same pattern. + * `removeStatement` collects top-level statements the caller should filter out; + * deeper sites carry a closure that edits the tree in place. */ -function moduleScopeDeclarations(body: Node[]): ModuleScopeDecl[] { - const decls: ModuleScopeDecl[] = []; +function moduleScopeBindingSites( + body: Node[], + stubs: Stubs, + removeStatement: (statement: Node) => void, +): BindingSite[] { + const sites: BindingSite[] = []; + + const addDeclarators = ( + declaration: Node, + exported: boolean, + detach: (() => void) | null, + ): void => { + for (const declarator of declaratorsOf(declaration)) { + const names = declaratorBoundNames(declarator); + if (!names) continue; + + sites.push({ + names, + references: declaratorReferences(declaration, declarator), + node: declarator, + exported, + remove: detach === null ? null : () => { + declaration.declarations = declaratorsOf(declaration).filter((candidate) => + candidate !== declarator + ); + if (declaratorsOf(declaration).length === 0) detach(); + }, + }); + } + }; 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] }); - continue; - } + if (statement.type === "ImportDeclaration") continue; - if (statement.type === "VariableDeclaration") { - for ( - const declarator of Array.isArray(statement.declarations) ? statement.declarations : [] - ) { - if (!isNode(declarator)) continue; - const id = declarator.id; - if (!isNode(id)) continue; - - 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) continue; + const exported = statement.type === "ExportNamedDeclaration" || + statement.type === "ExportDefaultDeclaration"; + const declaration = exported ? statement.declaration : statement; + if (!isNode(declaration)) continue; - decls.push({ statement, declarator, names }); + if (declaration.type === "FunctionDeclaration" || declaration.type === "ClassDeclaration") { + const name = nodeName(declaration.id); + if (name) { + sites.push({ + names: [name], + references: freeReferencedIdentifiers(declaration), + node: statement, + exported, + remove: exported ? null : () => removeStatement(statement), + }); } + } else if (declaration.type === "VariableDeclaration") { + addDeclarators(declaration, exported, exported ? null : () => removeStatement(statement)); } + + collectHoistedVarSites(declaration, stubs, addDeclarators); } - return decls; + return sites; +} + +/** 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"); +} + +/** + * `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. + */ +function collectHoistedVarSites( + root: Node, + stubs: Stubs, + add: (declaration: Node, exported: boolean, detach: (() => void) | null) => 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): void => { + 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); + }); + } + continue; + } + + if (!isNode(value) || startsVarScope(value)) continue; + visitChild(value, slotDetach(node, key)); + } + }; + + const visitChild = (child: Node, detach: (() => void) | null): void => { + if (child.type === "VariableDeclaration" && child.kind === "var") { + add(child, false, detach); + } + descend(child); + }; + + descend(root); } /** Every binding declared directly by the module, including exported declarations. */ @@ -454,14 +671,32 @@ function isLexicallyBound(name: string, scopes: LexicalScope[]): boolean { return scopes.some((scope) => scope.names.has(name)); } +const NOTHING_ELIDED: 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. */ -function freeReferencedIdentifiers(root: Node): Set { +function freeReferencedIdentifiers( + root: Node, + elided: ReadonlySet = NOTHING_ELIDED, +): Set { const free = new Set(); const rootScope: LexicalScope = { kind: "var", names: new Set() }; @@ -475,16 +710,14 @@ function freeReferencedIdentifiers(root: Node): Set { const bindDirectStatements = (scope: LexicalScope, statements: unknown[]): void => { for (const statement of statements) { - if (!isNode(statement)) continue; + if (!isNode(statement) || elided.has(statement)) continue; if (statement.type === "FunctionDeclaration" || statement.type === "ClassDeclaration") { bindPatternNames(scope, statement.id); continue; } if (statement.type !== "VariableDeclaration") continue; - for ( - const declarator of Array.isArray(statement.declarations) ? statement.declarations : [] - ) { - if (isNode(declarator)) bindPatternNames(scope, declarator.id); + for (const declarator of declaratorsOf(statement)) { + if (!elided.has(declarator)) bindPatternNames(scope, declarator.id); } } }; @@ -506,10 +739,8 @@ 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); } } @@ -523,9 +754,7 @@ function freeReferencedIdentifiers(root: Node): Set { const visitPatternRuntime = (pattern: Node, scopes: LexicalScope[]): void => { if (pattern.type === "TSParameterProperty") { - for (const decorator of Array.isArray(pattern.decorators) ? pattern.decorators : []) { - if (isNode(decorator)) visit(decorator, scopes); - } + visitDecorators(pattern, scopes); if (isNode(pattern.parameter)) visitPatternRuntime(pattern.parameter, scopes); return; } @@ -572,19 +801,15 @@ function freeReferencedIdentifiers(root: Node): Set { const bindVariableDeclaration = (node: Node, scopes: LexicalScope[]): void => { const targetScope = node.kind === "var" ? currentVarScope(scopes) : scopes[0] ?? rootScope; - for ( - const declarator of Array.isArray(node.declarations) ? node.declarations : [] - ) { - if (isNode(declarator)) bindPatternNames(targetScope, declarator.id); + 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); } @@ -617,7 +842,17 @@ 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 (isNode(node.value)) visit(node.value, scopes); }; @@ -673,7 +908,7 @@ function freeReferencedIdentifiers(root: Node): Set { }; const visit = (node: Node, scopes: LexicalScope[]): void => { - if (node.type === "ImportDeclaration") return; + if (node.type === "ImportDeclaration" || elided.has(node)) return; if (visitTsExpression(node, scopes)) return; if (node.type === "Identifier" || node.type === "JSXIdentifier") { @@ -682,6 +917,39 @@ function freeReferencedIdentifiers(root: Node): Set { 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. + if (node.type === "ExportNamedDeclaration" || node.type === "ExportAllDeclaration") { + if (isNode(node.source)) return; + visitChildren(node, scopes); + return; + } + if (node.type === "ExportSpecifier") { + if (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 === "JSXAttribute") { + if (isNode(node.value)) visit(node.value, scopes); + return; + } + if (node.type === "JSXMemberExpression") { + if (isNode(node.object)) visit(node.object, scopes); + return; + } + if (node.type === "Program" || node.type === "BlockStatement") { const scope: LexicalScope = { kind: "block", names: new Set() }; bindDirectDeclarations(scope, node); @@ -720,6 +988,9 @@ function freeReferencedIdentifiers(root: Node): Set { bindPatternNames(classScope, node.id); const classScopes = [classScope, ...scopes]; const body = node.body; + // 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; @@ -760,6 +1031,7 @@ function freeReferencedIdentifiers(root: Node): Set { } if (node.type === "ObjectMethod" || node.type === "ClassMethod") { + visitDecorators(node, scopes); if (node.computed === true && isNode(node.key)) visit(node.key, scopes); visitFunction(node, scopes); return; @@ -913,13 +1185,6 @@ function assignedNames(body: Node[]): Set { function hoistedVarNames(body: Node[]): Set { const hoisted = new Set(); - const startsVarScope = (node: Node): boolean => - 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"); - const collect = (node: Node): void => { for (const child of children(node)) { if (startsVarScope(child)) continue; @@ -1089,144 +1354,125 @@ function compilerNameRegistrations(body: Node[]): CompilerNameRegistration[] { } /** - * References to a module declaration after removing only that declaration and - * its compiler-generated name registration from the analysis tree. A real - * module read becomes free; a same-named binding inside client code remains - * lexically bound and does not keep server state alive. + * 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 referencesOutsideModuleScopeDeclaration( - body: Node[], - declaration: ModuleScopeDecl, - nameRegistrations: CompilerNameRegistration[], -): Set { - const ignoredStatements = new Set( - nameRegistrations.filter((registration) => declaration.names.includes(registration.targetName)) - .map((registration) => registration.statement), - ); - const remainingBody: Node[] = []; - - for (const statement of body) { - if (ignoredStatements.has(statement)) continue; - if (statement !== declaration.statement) { - remainingBody.push(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]); } - if (!declaration.declarator) continue; + } - const declarators = Array.isArray(statement.declarations) - ? statement.declarations.filter(isNode) - : []; - const remainingDeclarators = declarators.filter((candidate) => - candidate !== declaration.declarator - ); - if (remainingDeclarators.length > 0) { - remainingBody.push({ ...statement, declarations: remainingDeclarators }); + 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); + } } } - return freeReferencedIdentifiers({ type: "Program", body: remainingBody }); + return reachable; } /** - * Drop the top-level declarations the emptied server-only hooks closed over. + * 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 rest of the module still reads + * once every candidate is elided — surviving exports, the client component and + * any side-effectful top-level statement, which keeps whatever it references. + * The edges are genuine reads. Anything the roots cannot reach is dead. * - * 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*. + * Candidacy stays scoped to the stripped hooks' dependency closure, so an + * unrelated `const _ = bootClientAnalytics()` — unreachable, but never part of + * the hook graph — keeps its side effect. Inside that 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 — fail-closed — that none of them survives in the final - * output. + * caller can verify — fail closed — that none of them survives in the final + * output. A dead binding this pass cannot cut out of the tree is reported back + * instead, and the caller stops the build rather than shipping the value. */ -function dropUnusedModuleScopeBindings( +function dropUnreachableModuleScopeBindings( body: Node[], - hookClosure: Set, + sites: BindingSite[], + hookClosure: ReadonlySet, + removeStatement: (statement: Node) => void, removedNames: Set, -): Node[] { - let current = body; - - for (;;) { - const decls = moduleScopeDeclarations(current); - if (decls.length === 0) return current; - - // 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); - - 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)); - if (!inClosure) continue; - const externalReferences = referencesOutsideModuleScopeDeclaration( - current, - decl, - nameRegistrations, - ); - const unused = decl.names.every((name) => !externalReferences.has(name)); - if (!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; - } +): string[] { + const candidates = sites.filter((site) => + !site.exported && site.names.some((name) => hookClosure.has(name)) + ); + if (candidates.length === 0) return []; + + // Esbuild's generated name-registration call is metadata for a declaration, + // not an independent browser consumer of it: it is elided from the roots and + // removed together with the declaration it names. + const registrations = compilerNameRegistrations(body); + const candidateNames = new Set(candidates.flatMap((site) => site.names)); + const elided = new Set(candidates.map((site) => site.node)); + for (const registration of registrations) { + if (candidateNames.has(registration.targetName)) elided.add(registration.statement); + } - 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); - } - statementRemoval.add(decl.declarator); + const roots = freeReferencedIdentifiers({ type: "Program", body }, elided); + for (const site of sites) { + if (site.exported) { for (const name of site.names) roots.add(name); } + } - if ( - statementDeclarators.length > 0 && - statementDeclarators.every((declarator) => statementRemoval?.has(declarator)) - ) { - removableStatements.add(decl.statement); - removableDeclarators.delete(decl.statement); - } - } - if (removedDecls.length === 0) return current; + const reachable = reachableNames(roots, candidates); + const dead = candidates.filter((site) => site.names.every((name) => !reachable.has(name))); + if (dead.length === 0) return []; + + // A name written down in more than one place is only safe to drop when every + // one of its declarations is dead, 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 deadSites = new Set(dead); + const survivingNames = new Set( + sites.filter((site) => !deadSites.has(site)).flatMap((site) => site.names), + ); - // 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 decl.names) removedNames.add(name); - for (const name of freeReferencedIdentifiers(decl.declarator ?? decl.statement)) { - hookClosure.add(name); - } + const blocked: string[] = []; + for (const site of dead) { + const shared = site.names.find((name) => survivingNames.has(name)); + if (shared) { + blocked.push(`\`${shared}\` is declared more than once and only one declaration is dead`); + continue; } - - 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); - }); + if (site.remove === null) { + blocked.push( + `\`${site.names[0]}\` is a dead server-only binding declared in a position ` + + `this pass cannot remove`, + ); } + } + if (blocked.length > 0) return blocked; - current = current.filter((statement) => !removableStatements.has(statement)); + for (const site of dead) { + 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. */ @@ -1340,7 +1586,7 @@ export async function stripServerOnlyExports( let body: Node[]; let ast: ASTNode; - let stubs: { body: Node; init: Node }; + let stubs: Stubs; try { const parsedStubs = await parseStubs(parser); @@ -1398,7 +1644,7 @@ export async function stripServerOnlyExports( // 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); + 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 @@ -1417,8 +1663,27 @@ export async function stripServerOnlyExports( // 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. + // + // 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 pruned = dropUnusedModuleScopeBindings(body, hookClosure, removedNames); + const removableStatements = new Set(); + const sites = moduleScopeBindingSites(body, stubs, (statement) => { + removableStatements.add(statement); + }); + const hookClosure = reachableNames(hookSeed, sites); + const blocked = dropUnreachableModuleScopeBindings( + body, + sites, + hookClosure, + (statement) => removableStatements.add(statement), + removedNames, + ); + const [firstBlocked] = blocked; + if (firstBlocked) throw new ServerExportStripError(filePath, firstBlocked); + + const pruned = body.filter((statement) => !removableStatements.has(statement)); const finalBody = dropUnusedImportBindings(pruned, hookClosure, removedNames); setBody(ast, finalBody); @@ -1450,6 +1715,9 @@ export async function stripServerOnlyExports( 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); From 429784455fb45247b6dd09d6c9405424e017b9c3 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 17 Aug 2026 18:11:33 +0200 Subject: [PATCH 12/81] test(transforms): pin the half-dead repeated var fail-closed path A `var` can be written down twice for the same module binding. When only one of the declarations is dead, dropping it would leave the name bound by the other, so the pass refuses to take out half a binding. That guard had no regression covering it. --- .../browser-server-exports-strip.test.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) 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 c8d789bb25..4ed73f4301 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts @@ -1453,6 +1453,24 @@ describe("browser-server-exports-strip", () => { 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 From c991de89e2f7626d96711b5c7c887e8f6b67798f Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 17 Aug 2026 18:29:46 +0200 Subject: [PATCH 13/81] fix(transforms): track runtime TypeScript bindings --- .../browser-server-exports-strip.test.ts | 87 +++++++++++++ .../stages/browser-server-exports-strip.ts | 121 +++++++++++++++++- 2 files changed, 201 insertions(+), 7 deletions(-) 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 4ed73f4301..66317aeedd 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts @@ -1182,6 +1182,93 @@ describe("browser-server-exports-strip", () => { assertNotIncludes(result, 'loadSecret("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("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";`, diff --git a/src/transforms/pipeline/stages/browser-server-exports-strip.ts b/src/transforms/pipeline/stages/browser-server-exports-strip.ts index 163bce9d18..00eb800917 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.ts @@ -197,6 +197,15 @@ function bodyOf(ast: ASTNode): Node[] { return Array.isArray(body) ? body.filter(isNode) : []; } +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") }`. */ @@ -535,8 +544,12 @@ function moduleScopeBindingSites( if (statement.type === "ImportDeclaration") continue; const exported = statement.type === "ExportNamedDeclaration" || - statement.type === "ExportDefaultDeclaration"; - const declaration = exported ? statement.declaration : statement; + statement.type === "ExportDefaultDeclaration" || + (isRuntimeTsImportEqualsDeclaration(statement) && statement.isExport === true); + const declaration = statement.type === "ExportNamedDeclaration" || + statement.type === "ExportDefaultDeclaration" + ? statement.declaration + : statement; if (!isNode(declaration)) continue; if (declaration.type === "FunctionDeclaration" || declaration.type === "ClassDeclaration") { @@ -550,6 +563,21 @@ function moduleScopeBindingSites( 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, + remove: exported ? null : () => removeStatement(statement), + }); + } } else if (declaration.type === "VariableDeclaration") { addDeclarators(declaration, exported, exported ? null : () => removeStatement(statement)); } @@ -642,7 +670,10 @@ function moduleScopeBindingNames(body: Node[]): Set { if (!isNode(declaration)) continue; if ( - declaration.type === "FunctionDeclaration" || declaration.type === "ClassDeclaration" + 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); @@ -711,12 +742,23 @@ function freeReferencedIdentifiers( const bindDirectStatements = (scope: LexicalScope, statements: unknown[]): void => { for (const statement of statements) { if (!isNode(statement) || elided.has(statement)) continue; - if (statement.type === "FunctionDeclaration" || statement.type === "ClassDeclaration") { - bindPatternNames(scope, statement.id); + 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 (statement.type !== "VariableDeclaration") continue; - for (const declarator of declaratorsOf(statement)) { + if (declaration.type !== "VariableDeclaration") continue; + for (const declarator of declaratorsOf(declaration)) { if (!elided.has(declarator)) bindPatternNames(scope, declarator.id); } } @@ -907,8 +949,73 @@ function freeReferencedIdentifiers( 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); + } + } + + const enumScopes = [enumScope, ...scopes]; + for (const member of Array.isArray(node.members) ? node.members : []) { + if (isNode(member) && isNode(member.initializer)) { + visit(member.initializer, enumScopes); + } + } + }; + + const visitTsModule = (node: Node, scopes: LexicalScope[]): void => { + if (!isRuntimeTsModuleDeclaration(node)) return; + + bindPatternNames(scopes[0] ?? rootScope, node.id); + const moduleScope: LexicalScope = { kind: "block", 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); + for (const statement of Array.isArray(body.body) ? body.body : []) { + if (isNode(statement)) visit(statement, moduleScopes); + } + return; + } + if (body.type === "TSModuleDeclaration") visitTsModule(body, moduleScopes); + }; + + 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); + }; + const visit = (node: Node, scopes: LexicalScope[]): void => { if (node.type === "ImportDeclaration" || elided.has(node)) return; + if (node.type === "TSEnumDeclaration") { + visitTsEnum(node, scopes); + return; + } + if (node.type === "TSModuleDeclaration") { + visitTsModule(node, scopes); + return; + } + if (node.type === "TSImportEqualsDeclaration") { + visitTsImportEquals(node, scopes); + return; + } if (visitTsExpression(node, scopes)) return; if (node.type === "Identifier" || node.type === "JSXIdentifier") { From 2aa3fda6363ea8d8ae66d92e92d3bda04e76ed89 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 17 Aug 2026 18:31:32 +0200 Subject: [PATCH 14/81] test(transforms): cover reference-only syntax --- .../browser-server-exports-strip.test.ts | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) 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 66317aeedd..8d3795474f 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts @@ -1744,6 +1744,49 @@ describe("browser-server-exports-strip", () => { const result = await stripServerOnlyExports(code, "page.tsx"); assertStringIncludes(result, "Badge from"); }); + + 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 From dc571b88562cdda814f0570e174ea86fbf3b0ae5 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 17 Aug 2026 18:46:47 +0200 Subject: [PATCH 15/81] fix(transforms): model decorator and namespace scopes --- .../browser-server-exports-strip.test.ts | 53 +++++++++++++++++++ .../stages/browser-server-exports-strip.ts | 38 +++++++++---- 2 files changed, 80 insertions(+), 11 deletions(-) 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 8d3795474f..52e9964f59 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts @@ -1182,6 +1182,22 @@ describe("browser-server-exports-strip", () => { 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")'); + }); + it("drops a runtime TypeScript enum used only by a stripped hook", async () => { const code = [ `import { randomUUID } from "node:crypto";`, @@ -1242,6 +1258,43 @@ describe("browser-server-exports-strip", () => { 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");`, diff --git a/src/transforms/pipeline/stages/browser-server-exports-strip.ts b/src/transforms/pipeline/stages/browser-server-exports-strip.ts index 00eb800917..c58094408f 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.ts @@ -775,7 +775,8 @@ function freeReferencedIdentifiers( child.type === "FunctionDeclaration" || child.type === "FunctionExpression" || child.type === "ArrowFunctionExpression" || child.type === "ObjectMethod" || child.type === "ClassMethod" || child.type === "ClassDeclaration" || - child.type === "ClassExpression" || child.type === "StaticBlock" + child.type === "ClassExpression" || child.type === "StaticBlock" || + child.type === "TSModuleDeclaration" ) { continue; } @@ -794,29 +795,37 @@ function freeReferencedIdentifiers( for (const child of children(node)) visit(child, scopes); }; - const visitPatternRuntime = (pattern: Node, scopes: LexicalScope[]): void => { + const visitPatternRuntime = ( + pattern: Node, + scopes: LexicalScope[], + decoratorScopes: LexicalScope[] = scopes, + ): void => { if (pattern.type === "TSParameterProperty") { - visitDecorators(pattern, scopes); - if (isNode(pattern.parameter)) visitPatternRuntime(pattern.parameter, scopes); + visitDecorators(pattern, decoratorScopes); + 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; } @@ -825,7 +834,9 @@ function freeReferencedIdentifiers( 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") { @@ -833,7 +844,9 @@ function freeReferencedIdentifiers( 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; } @@ -866,7 +879,9 @@ function freeReferencedIdentifiers( 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)) { + visitPatternRuntime(param, [functionScope, ...scopes], scopes); + } } bindDirectDeclarations(functionScope, isNode(node.body) ? node.body : node); @@ -972,7 +987,7 @@ function freeReferencedIdentifiers( if (!isRuntimeTsModuleDeclaration(node)) return; bindPatternNames(scopes[0] ?? rootScope, node.id); - const moduleScope: LexicalScope = { kind: "block", names: new Set() }; + const moduleScope: LexicalScope = { kind: "var", names: new Set() }; bindPatternNames(moduleScope, node.id); const moduleScopes = [moduleScope, ...scopes]; @@ -980,6 +995,7 @@ function freeReferencedIdentifiers( 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); } From a7dfa3bd41755f8549cb31f2cff05824ce525b2f Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 17 Aug 2026 18:48:30 +0200 Subject: [PATCH 16/81] fix(transforms): stop dead code from pinning the hooks' closure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Roots were computed as everything the *non-elided* program reads, and only declarations already inside the hooks' dependency closure were elided. Every other module-scope declaration was therefore treated as unconditionally live, including ones nothing can reach — so an unreachable declaration that read a server-only binding rooted it, and the secret and its import shipped to the browser with no error raised: import { createHash } from "node:crypto"; import { getEnv } from "veryfront"; const KEY = getEnv("SEKRIT"); function deadHelper() { return createHash("sha1") + KEY; } if (globalThis.z) { var dead = deadHelper; } export async function getServerData() { … } esbuild's production tree-shaker hides the plainest shapes but not these: an impure guard (`if`/`switch`/`for`/`while`) around a hoisted `var` survives compilation, which is exactly what a dev-only debug helper compiles to — `if (process.env.NODE_ENV !== "production") { var debugDigest = (s) => createHash("sha1").update(SALT + s) … }` shipped the `node:crypto` shape and the salt read in a production build. Roots are now what the module still *runs*. A declaration that merely introduces a name — a function, a `var dead = helper`, a plain class — is elided from the roots, so it can no longer vouch for anything; so is one whose initialiser only evaluates bindings already in the hooks' closure, since the only thing it could pin is one this pass owns. A declaration whose initialiser runs something else (`const clientInit = bootClientAnalytics()`) is still a top-level side effect and still keeps what it reads. Removal stays scoped to the closure so this does not become a general dead-code eliminator: an unreachable declaration is taken out when it names or reads a hook-closure binding, and then whatever unreachable declaration read *it*, until the set stops growing. An unreachable helper holding nothing server-only is left where it is. esbuild's `keepNames` metadata is recognised in its two remaining forms — the inline `__name(, "x")` wrapper a dev build emits and the `static { __name(this, "C") }` block it compiles a class to — so neither turns a dead declaration into live code. Only a registration's *target* is elided from the roots now, not the whole call, so the helper performing it stays alive for as long as one still runs. --- .../browser-server-exports-strip.test.ts | 184 +++++++++++ .../stages/browser-server-exports-strip.ts | 296 +++++++++++++++--- 2 files changed, 445 insertions(+), 35 deletions(-) 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 52e9964f59..0d6769a7b2 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts @@ -879,6 +879,190 @@ describe("browser-server-exports-strip", () => { assertStringIncludes(result, "function bootClient()"); }); + // 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 { 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); + + assertNotIncludes(result, "node:crypto"); + assertEquals(occurrences(result, "createHash"), 0); + assertEquals(occurrences(result, "deadHelper"), 0); + }); + + it("drops a dead helper that was sharing the hook's secret", async () => { + const code = [ + `import { getEnv } from "veryfront";`, + `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, "KEY"), 0); + assertNotIncludes(result, "SECRET_KEY"); + assertEquals(occurrences(result, "deadHelper"), 0); + assertEquals(occurrences(result, "getEnv"), 0); + }); + + it("drops a dead class that was holding the hook's secret", async () => { + const code = [ + `import { getEnv } from "veryfront";`, + `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 result = await stripServerOnlyExports(code); + + assertEquals(occurrences(result, "KEY"), 0); + assertNotIncludes(result, "SECRET_KEY"); + assertEquals(occurrences(result, "DeadLoader"), 0); + assertEquals(occurrences(result, "getEnv"), 0); + }); + + // 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 = [ + `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); + + assertEquals(occurrences(result, "KEY"), 0); + assertNotIncludes(result, "SECRET_KEY"); + assertEquals(occurrences(result, "first"), 0); + assertEquals(occurrences(result, "second"), 0); + }); + + // 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 { 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); + + assertEquals(occurrences(result, "KEY"), 0); + assertNotIncludes(result, "SECRET_KEY"); + assertEquals(occurrences(result, "dead"), 0); + assertEquals(occurrences(result, "getEnv"), 0); + }); + + 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 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); + + assertNotIncludes(result, "node:crypto"); + assertNotIncludes(result, "SECRET_KEY"); + assertEquals(occurrences(result, "deadHelper"), 0); + assertEquals(occurrences(result, "dead"), 0); + }); + + // 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 { 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); + + assertNotIncludes(result, "node:crypto"); + assertEquals(occurrences(result, "createHash"), 0); + assertEquals(occurrences(result, "dead"), 0); + }); + + // 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 { 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); + + assertEquals(occurrences(result, "KEY"), 0); + assertEquals(occurrences(result, "getEnv"), 0); + assertStringIncludes(result, "function unusedClientHelper"); + assertStringIncludes(result, "./util.ts"); + }); + + // 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 = [ + `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")`); + }); + // 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 = [ diff --git a/src/transforms/pipeline/stages/browser-server-exports-strip.ts b/src/transforms/pipeline/stages/browser-server-exports-strip.ts index c58094408f..728977e08e 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.ts @@ -36,9 +36,13 @@ * 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 reads once every - * removal candidate is elided: its surviving exports, the client component, and - * any side-effectful top-level statement, which keeps whatever it references. + * 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 plain class — 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. * 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, @@ -98,10 +102,16 @@ * `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. - * What it does NOT do: reason about a value that is *also* read by browser - * code, one a surviving side-effectful top-level statement still references - * (`Object.defineProperty(box, "run", …)` reads what it is given), or one - * reached only through an existing bare side-effect import — those are kept. + * 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: reason + * about a value that is *also* read by browser code, or one a surviving + * side-effectful top-level statement still references — including a + * declaration whose own initialiser runs something outside the hooks' closure + * (`Object.defineProperty(box, "run", …)` and `const boot = initAnalytics(KEY)` + * both read what they are given), or one reached only through an existing bare + * side-effect import — those are kept. Nor is it 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. @@ -1453,8 +1463,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[] = []; @@ -1511,18 +1523,224 @@ function reachableNames(roots: Iterable, sites: BindingSite[]): Set 0; +} + +/** + * `__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 to + * validate, no computed member key and no static initialiser. Method bodies and + * instance field initialisers run at construction time, not at module load. + */ +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) || 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); + 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); +} + +/** + * 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. + * - Everything it evaluates is in that closure too, so it is server-only code + * by construction however it is written. `switch (…) { case 1: var dead = + * createHash("md5") }` does run at module load, but the only binding it can + * pin is one this pass already owns — and if client code reads that binding + * as well, the client read roots it anyway. + * - Its declaration does not run at all, so it is not surviving code. + * + * Anything else evaluates something outside the closure when the module loads, + * and roots what it reads like any other side-effectful top-level statement. + * That is what keeps `const clientInit = bootClientAnalytics()` — and the + * helper it calls — in the browser artifact. + */ +function isElidableFromRoots( + site: BindingSite, + hookClosure: ReadonlySet, + helpers: ReadonlySet, +): boolean { + if (site.names.some((name) => hookClosure.has(name))) return true; + if ([...site.references].every((name) => hookClosure.has(name))) return true; + return evaluationIsInert(site.node, helpers); +} + +/** + * 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; + } + } + + return tainted; +} + /** * 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 rest of the module still reads - * once every candidate is elided — surviving exports, the client component and - * any side-effectful top-level statement, which keeps whatever it references. - * The edges are genuine reads. Anything the roots cannot reach is dead. + * mentioned elsewhere". The roots are what the module still *runs* once every + * declaration that merely introduces a name is elided — surviving exports, the + * client component and any side-effectful top-level statement, which keeps + * whatever it references. The edges are genuine reads. Anything the roots + * cannot reach is dead. * - * Candidacy stays scoped to the stripped hooks' dependency closure, so an - * unrelated `const _ = bootClientAnalytics()` — unreachable, but never part of - * the hook graph — keeps its side effect. Inside that closure the pass is + * Elision and removal are scoped differently on purpose. Every declaration that + * does not run, or that runs only inside the hooks' dependency closure, is + * 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 + * `const clientInit = bootClientAnalytics()` — unreachable, but never part of + * the hook graph — keeps its side effect. 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. * @@ -1538,19 +1756,22 @@ function dropUnreachableModuleScopeBindings( removeStatement: (statement: Node) => void, removedNames: Set, ): string[] { - const candidates = sites.filter((site) => - !site.exported && site.names.some((name) => hookClosure.has(name)) + const nameHelpers = compilerNameHelperBindings(body); + const elidable = sites.filter((site) => + !site.exported && isElidableFromRoots(site, hookClosure, nameHelpers) ); - if (candidates.length === 0) return []; - - // Esbuild's generated name-registration call is metadata for a declaration, - // not an independent browser consumer of it: it is elided from the roots and - // removed together with the declaration it names. - const registrations = compilerNameRegistrations(body); - const candidateNames = new Set(candidates.flatMap((site) => site.names)); - const elided = new Set(candidates.map((site) => site.node)); + if (elidable.length === 0) return []; + + // 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 registrations = compilerNameRegistrations(body, nameHelpers); + const elidableNames = new Set(elidable.flatMap((site) => site.names)); + const elided = new Set(elidable.map((site) => site.node)); for (const registration of registrations) { - if (candidateNames.has(registration.targetName)) elided.add(registration.statement); + if (elidableNames.has(registration.targetName)) elided.add(registration.target); } const roots = freeReferencedIdentifiers({ type: "Program", body }, elided); @@ -1558,21 +1779,26 @@ function dropUnreachableModuleScopeBindings( if (site.exported) { for (const name of site.names) roots.add(name); } } - const reachable = reachableNames(roots, candidates); - const dead = candidates.filter((site) => site.names.every((name) => !reachable.has(name))); - if (dead.length === 0) return []; + // 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); + const removable = dead.filter((site) => tainted.has(site)); + 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 is dead, and only when each of them can be cut out + // 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 deadSites = new Set(dead); + const removableSites = new Set(removable); const survivingNames = new Set( - sites.filter((site) => !deadSites.has(site)).flatMap((site) => site.names), + sites.filter((site) => !removableSites.has(site)).flatMap((site) => site.names), ); const blocked: string[] = []; - for (const site of dead) { + for (const site of removable) { const shared = site.names.find((name) => survivingNames.has(name)); if (shared) { blocked.push(`\`${shared}\` is declared more than once and only one declaration is dead`); @@ -1587,7 +1813,7 @@ function dropUnreachableModuleScopeBindings( } if (blocked.length > 0) return blocked; - for (const site of dead) { + for (const site of removable) { for (const name of site.names) removedNames.add(name); site.remove?.(); } From a8b27701beec242135eb4c50603b5c0f1f021a26 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 17 Aug 2026 18:52:08 +0200 Subject: [PATCH 17/81] fix(transforms): read decorators on ordinary parameters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only a `TSParameterProperty` had its decorators traversed, but Babel hangs a parameter decorator off the pattern itself — a plain `Identifier`, an `AssignmentPattern` or a destructuring pattern — whenever the parameter is not also a property. `constructor(@inject(loadSecret) value)` on surviving client code therefore read nothing the graph could see, so a hook that shared the import took it down: the emitted artifact reduced `import { inject, loadSecret } from "./di.ts"` to a bare side-effect import and left the decorator unresolved. The fail-closed output check agreed the bindings were gone, because it scans with the same reference model. Decorators are read on every pattern the traversal reaches now. Reported in review on PR #3825. --- .../browser-server-exports-strip.test.ts | 19 +++++++++++++++++++ .../stages/browser-server-exports-strip.ts | 9 ++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) 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 0d6769a7b2..1d6a2d6119 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts @@ -1366,6 +1366,25 @@ describe("browser-server-exports-strip", () => { 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";`, diff --git a/src/transforms/pipeline/stages/browser-server-exports-strip.ts b/src/transforms/pipeline/stages/browser-server-exports-strip.ts index 728977e08e..8a94f730db 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.ts @@ -810,8 +810,15 @@ function freeReferencedIdentifiers( 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. + visitDecorators(pattern, decoratorScopes); + if (pattern.type === "TSParameterProperty") { - visitDecorators(pattern, decoratorScopes); if (isNode(pattern.parameter)) { visitPatternRuntime(pattern.parameter, scopes, decoratorScopes); } From b95f4392ea22036f5cbc597a089be951ee5b7099 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 17 Aug 2026 18:53:33 +0200 Subject: [PATCH 18/81] fix(transforms): retain ordinary parameter decorators --- .../browser-server-exports-strip.test.ts | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) 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 1d6a2d6119..b1df2d88f1 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts @@ -1401,6 +1401,30 @@ describe("browser-server-exports-strip", () => { 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("drops a runtime TypeScript enum used only by a stripped hook", async () => { const code = [ `import { randomUUID } from "node:crypto";`, From 3a567376cbd889495e058fe57782ae296f0b74fe Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 17 Aug 2026 19:07:41 +0200 Subject: [PATCH 19/81] fix(transforms): preserve shared client initializers --- .../browser-server-exports-strip.test.ts | 30 +++++++++ .../stages/browser-server-exports-strip.ts | 66 ++++++++++++------- 2 files changed, 74 insertions(+), 22 deletions(-) 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 b1df2d88f1..fa4cc15d70 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts @@ -861,6 +861,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();`, diff --git a/src/transforms/pipeline/stages/browser-server-exports-strip.ts b/src/transforms/pipeline/stages/browser-server-exports-strip.ts index 8a94f730db..82dde79f7c 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.ts @@ -469,6 +469,8 @@ interface BindingSite { 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; } @@ -530,6 +532,7 @@ function moduleScopeBindingSites( declaration: Node, exported: boolean, detach: (() => void) | null, + nested = false, ): void => { for (const declarator of declaratorsOf(declaration)) { const names = declaratorBoundNames(declarator); @@ -540,6 +543,7 @@ function moduleScopeBindingSites( references: declaratorReferences(declaration, declarator), node: declarator, exported, + nested, remove: detach === null ? null : () => { declaration.declarations = declaratorsOf(declaration).filter((candidate) => candidate !== declarator @@ -570,6 +574,7 @@ function moduleScopeBindingSites( references: freeReferencedIdentifiers(declaration), node: statement, exported, + nested: false, remove: exported ? null : () => removeStatement(statement), }); } @@ -585,6 +590,7 @@ function moduleScopeBindingSites( references: freeReferencedIdentifiers(declaration), node: statement, exported, + nested: false, remove: exported ? null : () => removeStatement(statement), }); } @@ -592,7 +598,12 @@ function moduleScopeBindingSites( addDeclarators(declaration, exported, exported ? null : () => removeStatement(statement)); } - collectHoistedVarSites(declaration, stubs, addDeclarators); + collectHoistedVarSites( + declaration, + stubs, + (nestedDeclaration, nestedExported, detach) => + addDeclarators(nestedDeclaration, nestedExported, detach, true), + ); } return sites; @@ -1673,17 +1684,17 @@ function evaluationIsInert(node: Node, helpers: ReadonlySet): boolean { * - 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. - * - Everything it evaluates is in that closure too, so it is server-only code - * by construction however it is written. `switch (…) { case 1: var dead = - * createHash("md5") }` does run at module load, but the only binding it can - * pin is one this pass already owns — and if client code reads that binding - * as well, the client read roots it anyway. + * - 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 initializer, whose side effect is part of + * the module even when it happens to call the same import as the hook. * - Its declaration does not run at all, so it is not surviving code. * - * Anything else evaluates something outside the closure when the module loads, - * and roots what it reads like any other side-effectful top-level statement. - * That is what keeps `const clientInit = bootClientAnalytics()` — and the - * helper it calls — in the browser artifact. + * Anything else roots what it reads 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. */ function isElidableFromRoots( site: BindingSite, @@ -1691,7 +1702,7 @@ function isElidableFromRoots( helpers: ReadonlySet, ): boolean { if (site.names.some((name) => hookClosure.has(name))) return true; - if ([...site.references].every((name) => hookClosure.has(name))) return true; + if (site.nested && [...site.references].every((name) => hookClosure.has(name))) return true; return evaluationIsInert(site.node, helpers); } @@ -1740,16 +1751,17 @@ function serverTaintedSites( * whatever it references. The edges are genuine reads. Anything the roots * cannot reach is dead. * - * Elision and removal are scoped differently on purpose. Every declaration that - * does not run, or that runs only inside the hooks' dependency closure, is - * 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 - * `const clientInit = bootClientAnalytics()` — unreachable, but never part of - * the hook graph — keeps its side effect. 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. + * 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. 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 — fail closed — that none of them survives in the final @@ -2028,7 +2040,17 @@ export async function stripServerOnlyExports( const sites = moduleScopeBindingSites(body, stubs, (statement) => { removableStatements.add(statement); }); - const hookClosure = reachableNames(hookSeed, sites); + 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 blocked = dropUnreachableModuleScopeBindings( body, sites, From 1f8b805ec3082452a0e7d5a7713c3f1c264f303c Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 17 Aug 2026 19:16:53 +0200 Subject: [PATCH 20/81] fix(transforms): model private elements and parameter decorators --- .../browser-server-exports-strip.test.ts | 52 +++++++++++++++++++ .../stages/browser-server-exports-strip.ts | 50 ++++++++++++++++-- 2 files changed, 99 insertions(+), 3 deletions(-) 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 fa4cc15d70..f110330efa 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts @@ -1455,6 +1455,58 @@ describe("browser-server-exports-strip", () => { }); } + 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("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";`, diff --git a/src/transforms/pipeline/stages/browser-server-exports-strip.ts b/src/transforms/pipeline/stages/browser-server-exports-strip.ts index 82dde79f7c..21b9f21e74 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.ts @@ -1091,6 +1091,7 @@ function freeReferencedIdentifiers( // `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); @@ -1176,12 +1177,18 @@ function freeReferencedIdentifiers( return; } - if (node.type === "ObjectProperty" || node.type === "ClassProperty") { + if ( + node.type === "ObjectProperty" || node.type === "ClassProperty" || + node.type === "ClassPrivateProperty" + ) { 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); @@ -1546,6 +1553,41 @@ 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); + } + 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 @@ -1583,7 +1625,9 @@ function isInertClass(node: Node, helpers: ReadonlySet): boolean { 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) || member.computed === true) 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); From 4fede0981887a6dacc15a58cfc2d2a1af1d8c5fa Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 17 Aug 2026 19:20:14 +0200 Subject: [PATCH 21/81] fix(transforms): model auto-accessor properties --- .../stages/browser-server-exports-strip.test.ts | 15 +++++++++++++++ .../stages/browser-server-exports-strip.ts | 2 +- 2 files changed, 16 insertions(+), 1 deletion(-) 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 f110330efa..b7e1a0df51 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts @@ -1472,6 +1472,21 @@ describe("browser-server-exports-strip", () => { 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";`, diff --git a/src/transforms/pipeline/stages/browser-server-exports-strip.ts b/src/transforms/pipeline/stages/browser-server-exports-strip.ts index 21b9f21e74..90255efd02 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.ts @@ -1179,7 +1179,7 @@ function freeReferencedIdentifiers( if ( node.type === "ObjectProperty" || node.type === "ClassProperty" || - node.type === "ClassPrivateProperty" + node.type === "ClassPrivateProperty" || node.type === "ClassAccessorProperty" ) { visitObjectMember(node, scopes); return; From 00791667b54286ad66862935e7abf7df73d44210 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 17 Aug 2026 19:22:48 +0200 Subject: [PATCH 22/81] fix(transforms): separate what a declaration evaluates from what it reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A dead declaration used to root every name written anywhere beneath it, so `const handler = memo(() => KEY)` that nothing reaches kept the secret its never-run callback reads. Roots now come from what the declaration evaluates at module load; bodies that run only when something calls them stay as edges out of the declaration's own binding. When such a body is the last reader of a binding nothing reaches, there is nothing safe to cut and nothing safe to keep, so the build stops instead of shipping the value. Widen the inertness whitelist to the operators that choose between operands without calling into them — `?:`, `||`, `&&`, `??`, `===`, `!==` and `,` — and to a class heritage clause that is itself inert, so a dead subclass of a client class goes instead of pinning what its methods mention. Also stop the hoisted-`var` elision from over-pruning: eliding the site from the roots keeps it from vouching for a hook-only import, but the call is still the module's own side effect, so `if (dev) { var d = boot() }` is only cut when something it calls is going away too. --- .../browser-server-exports-strip.test.ts | 151 +++++++++ .../stages/browser-server-exports-strip.ts | 301 +++++++++++++++--- 2 files changed, 401 insertions(+), 51 deletions(-) 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 b7e1a0df51..39a780b16c 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts @@ -2307,6 +2307,157 @@ describe("browser-server-exports-strip", () => { }); }); + // 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"); + }); + + // Naming a superclass evaluates the heritage expression; when that is a + // plain binding the class definition still runs nothing, so a dead class + // extending a client class is as elidable as a dead plain one. + it("drops a dead class that extends a client class", async () => { + const code = [ + `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); + + assertNotIncludes(result, "SECRET_KEY"); + assertEquals(occurrences(result, "Dead"), 0); + assertStringIncludes(result, "client-mark"); + assertStringIncludes(result, "class Base"); + }); + + // 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 { 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 error = await assertRejects(() => stripServerOnlyExports(code, "pages/leak.tsx")); + + assertStringIncludes((error as Error).message, "KEY"); + assertStringIncludes((error as Error).message, "pages/leak.tsx"); + }); + + // 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 { 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); + + assertStringIncludes(result, "SECRET_KEY"); + assertStringIncludes(result, "handler"); + }); + + // 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 = [ + `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); + + assertStringIncludes(result, "SECRET_KEY"); + }); + + // 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); + + assertStringIncludes(result, "dev-only-mark"); + assertStringIncludes(result, "./boot.ts"); + assertStringIncludes(result, `boot("client")`); + }); + }); + describe("plugin", () => { function ctx(code: string, target: "browser" | "ssr"): TransformContext { return { code, target, filePath: "pages/test.tsx", metadata: new Map() } as TransformContext; diff --git a/src/transforms/pipeline/stages/browser-server-exports-strip.ts b/src/transforms/pipeline/stages/browser-server-exports-strip.ts index 90255efd02..d9c22a7f34 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.ts @@ -39,10 +39,23 @@ * 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 plain class — 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. + * 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, @@ -87,14 +100,23 @@ * (`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 one more case on the other side of + * 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. As a final fail-closed check, the pass - * re-parses the output it is about to emit and verifies that no binding it - * decided to drop is still imported or referenced in that artifact — a - * violated invariant anywhere between the removal decision and the emitted - * text fails the build instead of leaking. + * 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 @@ -104,17 +126,27 @@ * 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: reason - * about a value that is *also* read by browser code, or one a surviving - * side-effectful top-level statement still references — including a - * declaration whose own initialiser runs something outside the hooks' closure - * (`Object.defineProperty(box, "run", …)` and `const boot = initAnalytics(KEY)` - * both read what they are given), or one reached only through an existing bare - * side-effect import — those are kept. Nor is it 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. + * 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"; @@ -356,6 +388,9 @@ function exportedHookBindings(body: Node[]): { locals: Set; unhandled: s // "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; @@ -744,10 +779,16 @@ const NOTHING_ELIDED: ReadonlySet = new Set(); * 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. */ function freeReferencedIdentifiers( root: Node, elided: ReadonlySet = NOTHING_ELIDED, + deferred: ReadonlySet = NOTHING_ELIDED, ): Set { const free = new Set(); const rootScope: LexicalScope = { kind: "var", names: new Set() }; @@ -826,7 +867,10 @@ function freeReferencedIdentifiers( // 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. + // 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") { @@ -912,6 +956,8 @@ function freeReferencedIdentifiers( } } + if (deferred.has(node)) return; + bindDirectDeclarations(functionScope, isNode(node.body) ? node.body : node); if (isNode(node.body)) bindNestedVarDeclarations(functionScope, node.body); @@ -939,6 +985,7 @@ function freeReferencedIdentifiers( 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); }; @@ -1615,12 +1662,19 @@ function isNameRegistrationBlock(node: Node, helpers: ReadonlySet): bool } /** - * A class whose *definition* runs nothing: no decorator, no superclass to - * validate, no computed member key and no static initialiser. Method bodies and - * instance field initialisers run at construction time, not at module load. + * A class whose *definition* runs nothing: no decorator, an inert superclass + * expression if any, no computed member key and no static initialiser. Method + * bodies and instance field initialisers run at construction time, not at + * module load. + * + * `extends Base` evaluates `Base` and reads its `prototype`, so it is inert on + * the same terms as any other read of a plain binding — which is what lets a + * dead subclass of a client class go instead of pinning whatever its methods + * mention. `extends makeBase()` is a call and stays. */ function isInertClass(node: Node, helpers: ReadonlySet): boolean { - if (hasDecorators(node) || isNode(node.superClass)) return false; + if (hasDecorators(node)) return false; + if (isNode(node.superClass) && !isInertExpression(node.superClass, helpers)) return false; const members = isNode(node.body) && Array.isArray(node.body.body) ? node.body.body : []; return members.every((member) => { @@ -1666,6 +1720,27 @@ function isInertExpression(node: Node | undefined, helpers: ReadonlySet) 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 || @@ -1719,6 +1794,69 @@ function evaluationIsInert(node: Node, helpers: ReadonlySet): boolean { 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 unwrap = (node: Node): Node => { + let current = node; + while ( + (current.type === "ParenthesizedExpression" || current.type === "TSAsExpression" || + current.type === "TSNonNullExpression" || current.type === "TSInstantiationExpression") && + isNode(current.expression) + ) { + current = current.expression; + } + return current; + }; + + const invokedChild = (node: Node): Node | null => { + if ( + node.type === "CallExpression" || node.type === "OptionalCallExpression" || + node.type === "NewExpression" + ) { + return isNode(node.callee) ? unwrap(node.callee) : null; + } + if (node.type === "TaggedTemplateExpression") { + return isNode(node.tag) ? unwrap(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 !== invoked) || isInstanceField) deferred.add(node); + + const nextInvoked = invokedChild(node); + 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. @@ -1728,26 +1866,40 @@ function evaluationIsInert(node: Node, helpers: ReadonlySet): boolean { * - 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 initializer, whose side effect is part of - * the module even when it happens to call the same import as the hook. - * - Its declaration does not run at all, so it is not surviving code. + * 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 + * everything it calls survives. * - * Anything else roots what it reads like any other side-effectful top-level + * 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. */ -function isElidableFromRoots( +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, -): boolean { - if (site.names.some((name) => hookClosure.has(name))) return true; - if (site.nested && [...site.references].every((name) => hookClosure.has(name))) return true; - return evaluationIsInert(site.node, helpers); +): 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; } /** @@ -1789,11 +1941,13 @@ function serverTaintedSites( * 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 *runs* once every - * declaration that merely introduces a name is elided — surviving exports, the - * client component and any side-effectful top-level statement, which keeps - * whatever it references. The edges are genuine reads. Anything the roots - * cannot reach is dead. + * 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' @@ -1802,15 +1956,19 @@ function serverTaintedSites( * 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. 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. + * 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 something it calls + * is going away too, because `if (dev) { var d = boot() }` is client code the + * moment `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 — fail closed — that none of them survives in the final - * output. A dead binding this pass cannot cut out of the tree is reported back - * instead, and the caller stops the build rather than shipping the value. + * 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. */ function dropUnreachableModuleScopeBindings( body: Node[], @@ -1820,9 +1978,13 @@ function dropUnreachableModuleScopeBindings( removedNames: Set, ): string[] { const nameHelpers = compilerNameHelperBindings(body); - const elidable = sites.filter((site) => - !site.exported && isElidableFromRoots(site, hookClosure, nameHelpers) - ); + const reasons = new Map(); + for (const site of sites) { + if (site.exported) continue; + const reason = elisionReason(site, hookClosure, nameHelpers); + if (reason !== null) reasons.set(site, reason); + } + const elidable = sites.filter((site) => reasons.has(site)); if (elidable.length === 0) return []; // Esbuild's generated name-registration call is metadata for the declaration @@ -1837,7 +1999,16 @@ function dropUnreachableModuleScopeBindings( if (elidableNames.has(registration.targetName)) elided.add(registration.target); } - const roots = freeReferencedIdentifiers({ type: "Program", body }, elided); + // 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); } } @@ -1848,7 +2019,16 @@ function dropUnreachableModuleScopeBindings( const reachable = reachableNames(roots, sites); const dead = elidable.filter((site) => site.names.every((name) => !reachable.has(name))); const tainted = serverTaintedSites(dead, hookClosure); - const removable = dead.filter((site) => tainted.has(site)); + const removable = 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 when + // it would otherwise be left calling something this pass is taking away, + // and is plain over-pruning when everything it calls survives because + // browser code calls it too. + return [...site.references].some((name) => !reachable.has(name)); + }); if (removable.length === 0) return []; // A name written down in more than one place is only safe to drop when every @@ -1874,6 +2054,25 @@ function dropUnreachableModuleScopeBindings( ); } } + + // 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( + `\`${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`, + ); + } + } if (blocked.length > 0) return blocked; for (const site of removable) { From a34693481582c5131d7afdc76c1df33c3b059f14 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 17 Aug 2026 19:25:43 +0200 Subject: [PATCH 23/81] fix(transforms): classify JSX tag references --- .../browser-server-exports-strip.test.ts | 41 +++++++++++++++++++ .../stages/browser-server-exports-strip.ts | 26 ++++++++++-- 2 files changed, 64 insertions(+), 3 deletions(-) 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 39a780b16c..0e204cc51f 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts @@ -2122,6 +2122,47 @@ describe("browser-server-exports-strip", () => { 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";`, diff --git a/src/transforms/pipeline/stages/browser-server-exports-strip.ts b/src/transforms/pipeline/stages/browser-server-exports-strip.ts index d9c22a7f34..ffd862c577 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.ts @@ -801,6 +801,15 @@ function freeReferencedIdentifiers( for (const name of patternBoundNames(value)) scope.names.add(name); }; + const addFreeName = (name: string | null, scopes: LexicalScope[]): void => { + if (name && !isLexicallyBound(name, scopes)) free.add(name); + }; + + const isIntrinsicJsxTagName = (name: string): boolean => { + const first = name.charCodeAt(0); + return (first >= 97 && first <= 122) || name.includes("-"); + }; + const bindDirectStatements = (scope: LexicalScope, statements: unknown[]): void => { for (const statement of statements) { if (!isNode(statement) || elided.has(statement)) continue; @@ -1109,9 +1118,14 @@ function freeReferencedIdentifiers( } if (visitTsExpression(node, scopes)) return; - if (node.type === "Identifier" || node.type === "JSXIdentifier") { + if (node.type === "Identifier") { + addFreeName(nodeName(node), scopes); + return; + } + + if (node.type === "JSXIdentifier") { const name = nodeName(node); - if (name && !isLexicallyBound(name, scopes)) free.add(name); + if (name && !isIntrinsicJsxTagName(name)) addFreeName(name, scopes); return; } @@ -1145,9 +1159,15 @@ function freeReferencedIdentifiers( return; } if (node.type === "JSXMemberExpression") { - if (isNode(node.object)) visit(node.object, scopes); + 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() }; From b48049b9221f3dfeeb9dc503bc2a3656e9618065 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 17 Aug 2026 19:30:09 +0200 Subject: [PATCH 24/81] fix(transforms): preserve class heritage evaluation --- .../browser-server-exports-strip.test.ts | 32 +++++++++++++------ .../stages/browser-server-exports-strip.ts | 17 ++++------ 2 files changed, 29 insertions(+), 20 deletions(-) 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 0e204cc51f..e1fe8489d9 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts @@ -2401,10 +2401,10 @@ describe("browser-server-exports-strip", () => { assertStringIncludes(result, "SECRET_KEY"); }); - // Naming a superclass evaluates the heritage expression; when that is a - // plain binding the class definition still runs nothing, so a dead class - // extending a client class is as elidable as a dead plain one. - it("drops a dead class that extends a client class", 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 { getEnv } from "veryfront";`, `class Base { b() { return "client-mark"; } }`, @@ -2414,12 +2414,26 @@ describe("browser-server-exports-strip", () => { `export default function Page() { return new Base().b(); }`, ].join("\n"); - const result = await stripServerOnlyExports(code); + const error = await assertRejects(() => stripServerOnlyExports(code, "pages/leak.tsx")); - assertNotIncludes(result, "SECRET_KEY"); - assertEquals(occurrences(result, "Dead"), 0); - assertStringIncludes(result, "client-mark"); - assertStringIncludes(result, "class Base"); + assertStringIncludes((error as Error).message, "KEY"); + assertStringIncludes((error as Error).message, "pages/leak.tsx"); + }); + + it("fails closed instead of deleting a dead subclass's heritage evaluation", async () => { + const code = [ + `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 error = await assertRejects(() => stripServerOnlyExports(code, "pages/leak.tsx")); + + assertStringIncludes((error as Error).message, "KEY"); + assertStringIncludes((error as Error).message, "pages/leak.tsx"); }); // A body that never runs is not a read. `memo(…)` is a genuine top-level diff --git a/src/transforms/pipeline/stages/browser-server-exports-strip.ts b/src/transforms/pipeline/stages/browser-server-exports-strip.ts index ffd862c577..b847bc0497 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.ts @@ -1682,19 +1682,14 @@ function isNameRegistrationBlock(node: Node, helpers: ReadonlySet): bool } /** - * A class whose *definition* runs nothing: no decorator, an inert superclass - * expression if any, no computed member key and no static initialiser. Method - * bodies and instance field initialisers run at construction time, not at - * module load. - * - * `extends Base` evaluates `Base` and reads its `prototype`, so it is inert on - * the same terms as any other read of a plain binding — which is what lets a - * dead subclass of a client class go instead of pinning whatever its methods - * mention. `extends makeBase()` is a call and stays. + * 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)) return false; - if (isNode(node.superClass) && !isInertExpression(node.superClass, helpers)) return false; + 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) => { From dbd48fff2a289f6d55fada36da1c4283847278bf Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 17 Aug 2026 19:34:28 +0200 Subject: [PATCH 25/81] fix(transforms): preserve mixed hoisted initializers --- .../browser-server-exports-strip.test.ts | 18 +++++++++++++++++ .../stages/browser-server-exports-strip.ts | 20 ++++++++++--------- 2 files changed, 29 insertions(+), 9 deletions(-) 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 e1fe8489d9..efd07de251 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts @@ -2511,6 +2511,24 @@ describe("browser-server-exports-strip", () => { assertStringIncludes(result, "./boot.ts"); assertStringIncludes(result, `boot("client")`); }); + + 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, "pages/leak.tsx"); + + assertStringIncludes(result, "dev-only-mark"); + assertStringIncludes(result, "loadSecret"); + assertStringIncludes(result, `boot("client")`); + assertStringIncludes(result, "./boot.ts"); + }); }); describe("plugin", () => { diff --git a/src/transforms/pipeline/stages/browser-server-exports-strip.ts b/src/transforms/pipeline/stages/browser-server-exports-strip.ts index b847bc0497..2169561502 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.ts @@ -1889,7 +1889,7 @@ function deferredExecutionNodes(root: Node): Set { * 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 - * everything it calls survives. + * 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()` — @@ -1972,9 +1972,9 @@ function serverTaintedSites( * `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 something it calls - * is going away too, because `if (dev) { var d = boot() }` is client code the - * moment `boot` survives. Inside the closure the pass is exhaustive: + * 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. * @@ -2038,11 +2038,13 @@ function dropUnreachableModuleScopeBindings( 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 when - // it would otherwise be left calling something this pass is taking away, - // and is plain over-pruning when everything it calls survives because - // browser code calls it too. - return [...site.references].some((name) => !reachable.has(name)); + // 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 blocked-path check below then fails + // closed for any dead binding the surviving initializer still reads. + return site.references.size > 0 && + [...site.references].every((name) => !reachable.has(name)); }); if (removable.length === 0) return []; From 7b7161ab5a0eca277c3623e7d1a7a1aa9a6ed82c Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 17 Aug 2026 19:37:06 +0200 Subject: [PATCH 26/81] fix(transforms): defer callback initialization reads --- .../browser-server-exports-strip.test.ts | 31 +++++++++++++ .../stages/browser-server-exports-strip.ts | 46 +++++++++++++++++-- 2 files changed, 74 insertions(+), 3 deletions(-) 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 efd07de251..3a79d7c4a8 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts @@ -2457,6 +2457,37 @@ describe("browser-server-exports-strip", () => { assertStringIncludes((error as Error).message, "pages/leak.tsx"); }); + it("fails the build when a deferred parameter default is the last secret reader", async () => { + const code = [ + `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 error = await assertRejects(() => stripServerOnlyExports(code, "pages/leak.tsx")); + + assertStringIncludes((error as Error).message, "KEY"); + assertStringIncludes((error as Error).message, "pages/leak.tsx"); + }); + + it("fails the build when a called generator defers the last secret read", async () => { + const code = [ + `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 error = await assertRejects(() => stripServerOnlyExports(code, "pages/leak.tsx")); + + assertStringIncludes((error as Error).message, "KEY"); + assertStringIncludes((error as Error).message, "pages/leak.tsx"); + }); + // 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. diff --git a/src/transforms/pipeline/stages/browser-server-exports-strip.ts b/src/transforms/pipeline/stages/browser-server-exports-strip.ts index 2169561502..adf520e12d 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.ts @@ -935,6 +935,42 @@ function freeReferencedIdentifiers( 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" ? currentVarScope(scopes) : scopes[0] ?? rootScope; for (const declarator of declaratorsOf(node)) { @@ -953,6 +989,7 @@ function freeReferencedIdentifiers( const visitFunction = (node: Node, scopes: LexicalScope[]): void => { const functionScope: LexicalScope = { kind: "var", names: new Set() }; + const isDeferred = deferred.has(node); if (node.type === "FunctionDeclaration") bindPatternNames(scopes[0] ?? rootScope, node.id); bindPatternNames(functionScope, node.id); @@ -961,11 +998,12 @@ function freeReferencedIdentifiers( } for (const param of Array.isArray(node.params) ? node.params : []) { if (isNode(param)) { - visitPatternRuntime(param, [functionScope, ...scopes], scopes); + if (isDeferred) visitPatternDecorators(param, scopes); + else visitPatternRuntime(param, [functionScope, ...scopes], scopes); } } - if (deferred.has(node)) return; + if (isDeferred) return; bindDirectDeclarations(functionScope, isNode(node.body) ? node.body : node); if (isNode(node.body)) bindNestedVarDeclarations(functionScope, node.body); @@ -1862,7 +1900,9 @@ function deferredExecutionNodes(root: Node): Set { node.type === "ClassPrivateProperty" || node.type === "ClassAccessorProperty") && node.static !== true; - if ((isFunction && node !== invoked) || isInstanceField) deferred.add(node); + if ((isFunction && (node !== invoked || node.generator === true)) || isInstanceField) { + deferred.add(node); + } const nextInvoked = invokedChild(node); for (const child of children(node)) walk(child, nextInvoked); From 85a1efd6fa6e95c965527e4ed50f67bacba1a9c0 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 17 Aug 2026 19:52:27 +0200 Subject: [PATCH 27/81] test(transforms): pin module binding reachability --- .../browser-server-exports-strip.test.ts | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) 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 3a79d7c4a8..02a496044c 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts @@ -2507,6 +2507,37 @@ describe("browser-server-exports-strip", () => { assertStringIncludes(result, "handler"); }); + it("keeps a hook-owned binding read by surviving module code", async () => { + const code = [ + `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); + + assertStringIncludes(result, "SECRET_KEY"); + assertStringIncludes(result, "register(KEY)"); + }); + + it("keeps a hook-owned binding reached through a separate default export", async () => { + const code = [ + `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); + + assertStringIncludes(result, "SECRET_KEY"); + assertStringIncludes(result, "Page as default"); + }); + // 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 () => { From 4e234d3eeb6d24945d1ef6487e2ed9e19d4cfa16 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 17 Aug 2026 19:59:11 +0200 Subject: [PATCH 28/81] fix(transforms): root the names a surviving export clause publishes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An `export { … }` clause entry is a real browser consumer of the binding it names, but the reachability pass could not see it: `visit(ExportSpecifier)` resolves `local` against the synthetic root scope, which binds every declaration the module still has, so the read is bound rather than free. `BindingSite.exported` only compensated for that when the `export` keyword wraps the declaration itself. That is never the shape this stage actually receives. esbuild runs first and hoists every named export into one trailing clause, leaving the declarations bare, so no site was `exported` and nothing rooted them. A surviving `export const client = makeClient({ get: () => API_KEY })` beside an emptied hook therefore looked dead, and the deferred-body blocker failed the build over a value the module deliberately publishes. Real modules hit it: forwardRef and memo components, and template `tool({…})` modules, whenever they were hook-augmented. Rooting the clause's local names restores the semantics the uncompiled form already had. It does not weaken the strip: a binding nothing exported reaches is still dropped, along with its import. The remediation sentence is now chosen per failure class. It told every author to "declare the hook directly", including the ones whose hook already is declared directly and whose real problem is a value shared with client code, and including failures that are not the author's doing at all. Regression tests run the real compile-then-strip pipeline, because the raw form passed throughout and hid this. --- .../browser-server-exports-strip.test.ts | 149 ++++++++++++++++++ .../stages/browser-server-exports-strip.ts | 121 ++++++++++++-- 2 files changed, 253 insertions(+), 17 deletions(-) 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 02a496044c..d021d9c198 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts @@ -2804,4 +2804,153 @@ describe("browser-server-exports-strip", () => { assertEquals(browserServerExportsStripPlugin.condition?.(ctx("", "browser")), true); }); }); + + // 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", () => { + afterAll(async () => { + await stopEsbuild(); + }); + + 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"); + }); + }); + + 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"); + }); + }); }); diff --git a/src/transforms/pipeline/stages/browser-server-exports-strip.ts b/src/transforms/pipeline/stages/browser-server-exports-strip.ts index adf520e12d..62007da35b 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.ts @@ -1992,6 +1992,45 @@ function serverTaintedSites( return tainted; } +/** + * The local names a surviving `export { … }` clause publishes. + * + * A clause entry is a real browser consumer of the binding it names — whatever + * imports the module reads it — but `freeReferencedIdentifiers` cannot see that: + * `visit(ExportSpecifier)` resolves `local` against the synthetic root scope, + * which binds every declaration the module still has, so the read is bound and + * never free. + * + * `BindingSite.exported` only compensates for that when the `export` keyword + * wraps the declaration itself. In the compiled input this stage actually runs + * on it never does: 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 exportClauseLocalNames(body: Node[]): Set { + const names = new Set(); + + for (const statement of body) { + 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. * @@ -2031,7 +2070,7 @@ function dropUnreachableModuleScopeBindings( hookClosure: ReadonlySet, removeStatement: (statement: Node) => void, removedNames: Set, -): string[] { +): Blocker[] { const nameHelpers = compilerNameHelperBindings(body); const reasons = new Map(); for (const site of sites) { @@ -2067,6 +2106,9 @@ function dropUnreachableModuleScopeBindings( for (const site of sites) { if (site.exported) { for (const name of site.names) roots.add(name); } } + // The same contract written the other way round, which is the only way the + // compiled input writes it. + for (const name of exportClauseLocalNames(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 @@ -2097,18 +2139,22 @@ function dropUnreachableModuleScopeBindings( sites.filter((site) => !removableSites.has(site)).flatMap((site) => site.names), ); - const blocked: string[] = []; + const blocked: Blocker[] = []; for (const site of removable) { const shared = site.names.find((name) => survivingNames.has(name)); if (shared) { - blocked.push(`\`${shared}\` is declared more than once and only one declaration is dead`); + 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( - `\`${site.names[0]}\` is a dead server-only binding declared in a position ` + + blocked.push({ + reason: `\`${site.names[0]}\` is a dead server-only binding declared in a position ` + `this pass cannot remove`, - ); + remedy: REMEDY.rewriteTheDeclaration, + }); } } @@ -2123,11 +2169,12 @@ function dropUnreachableModuleScopeBindings( if (removableSites.has(site)) continue; const held = [...site.references].find((name) => goingAway.has(name)); if (held) { - blocked.push( - `\`${held}\` is a server-only binding that nothing in the browser reaches, ` + + 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; @@ -2214,18 +2261,50 @@ 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.", + /** 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) { + constructor( + filePath: string | undefined, + reason: string, + remedy: string = REMEDY.declareDirectly, + ) { 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.`, + `before it is sent to the browser: ${reason}.` + (remedy ? ` ${remedy}` : ""), ); this.name = "ServerExportStripError"; } @@ -2249,7 +2328,11 @@ export async function stripServerOnlyExports( const parser = tryResolve("CodeParser"); if (!parser) { - throw new ServerExportStripError(filePath, "no CodeParser extension is registered"); + throw new ServerExportStripError( + filePath, + "no CodeParser extension is registered", + REMEDY.none, + ); } let body: Node[]; @@ -2267,6 +2350,7 @@ export async function stripServerOnlyExports( throw new ServerExportStripError( filePath, error instanceof Error ? error.message : String(error), + REMEDY.none, ); } @@ -2351,15 +2435,16 @@ export async function stripServerOnlyExports( const hookClosure = new Set( [...reachableNames(hookSeed, sites)].filter((name) => moduleBindings.has(name)), ); - const blocked = dropUnreachableModuleScopeBindings( + const [firstBlocked] = dropUnreachableModuleScopeBindings( body, sites, hookClosure, (statement) => removableStatements.add(statement), removedNames, ); - const [firstBlocked] = blocked; - if (firstBlocked) throw new ServerExportStripError(filePath, firstBlocked); + if (firstBlocked) { + throw new ServerExportStripError(filePath, firstBlocked.reason, firstBlocked.remedy); + } const pruned = body.filter((statement) => !removableStatements.has(statement)); const finalBody = dropUnusedImportBindings(pruned, hookClosure, removedNames); @@ -2388,6 +2473,7 @@ export async function stripServerOnlyExports( `the stripped output no longer parses: ${ error instanceof Error ? error.message : String(error) }`, + REMEDY.none, ); } @@ -2405,6 +2491,7 @@ export async function stripServerOnlyExports( throw new ServerExportStripError( filePath, `the server-only binding \`${leaked[0]}\` still appears in the stripped output`, + REMEDY.none, ); } } From 6cdbd2dca86a63a32200dbe9e9ebf392f39136af Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 17 Aug 2026 20:14:44 +0200 Subject: [PATCH 29/81] fix(transforms): recognize call and apply IIFEs --- .../browser-server-exports-strip.test.ts | 24 ++++++++++++++ .../stages/browser-server-exports-strip.ts | 31 ++++++++++++++++--- 2 files changed, 50 insertions(+), 5 deletions(-) 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 d021d9c198..ee6d6c0e0c 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts @@ -2919,6 +2919,30 @@ describe("browser-server-exports-strip", () => { 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", () => { diff --git a/src/transforms/pipeline/stages/browser-server-exports-strip.ts b/src/transforms/pipeline/stages/browser-server-exports-strip.ts index 62007da35b..021e48115c 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.ts @@ -1865,6 +1865,7 @@ function evaluationIsInert(node: Node, helpers: ReadonlySet): boolean { */ function deferredExecutionNodes(root: Node): Set { const deferred = new Set(); + const invokedFunctions = new Set(); const unwrap = (node: Node): Node => { let current = node; @@ -1879,10 +1880,25 @@ function deferredExecutionNodes(root: Node): Set { }; const invokedChild = (node: Node): Node | null => { - if ( - node.type === "CallExpression" || node.type === "OptionalCallExpression" || - node.type === "NewExpression" - ) { + if (node.type === "CallExpression" && isNode(node.callee)) { + const callee = unwrap(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 ( + callee.type === "MemberExpression" && callee.computed !== true && + isNode(callee.object) && isNode(callee.property) && + (nodeName(callee.property) === "call" || nodeName(callee.property) === "apply") + ) { + const target = unwrap(callee.object); + if (target.type === "FunctionExpression" || target.type === "ArrowFunctionExpression") { + return target; + } + } + return callee; + } + if (node.type === "OptionalCallExpression" || node.type === "NewExpression") { return isNode(node.callee) ? unwrap(node.callee) : null; } if (node.type === "TaggedTemplateExpression") { @@ -1900,11 +1916,16 @@ function deferredExecutionNodes(root: Node): Set { node.type === "ClassPrivateProperty" || node.type === "ClassAccessorProperty") && node.static !== true; - if ((isFunction && (node !== invoked || node.generator === true)) || isInstanceField) { + 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); }; From 95f29b1d2a11faebbe5a24c6cdfcc1357cebb634 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 17 Aug 2026 20:50:18 +0200 Subject: [PATCH 30/81] fix(transforms): preserve raw client roots --- .../browser-server-exports-strip.test.ts | 41 ++++++++++++++++ .../stages/browser-server-exports-strip.ts | 49 ++++++++++++------- 2 files changed, 71 insertions(+), 19 deletions(-) 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 ee6d6c0e0c..314ce3d13f 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts @@ -2538,6 +2538,23 @@ describe("browser-server-exports-strip", () => { assertStringIncludes(result, "Page as default"); }); + it("keeps a hook-owned binding reached through a direct default export", async () => { + const code = [ + `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); + + assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + assertStringIncludes(result, "forwardRef(() => KEY)"); + assertStringIncludes(result, "export default Page"); + }); + // 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 () => { @@ -2554,6 +2571,30 @@ describe("browser-server-exports-strip", () => { assertStringIncludes(result, "SECRET_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 diff --git a/src/transforms/pipeline/stages/browser-server-exports-strip.ts b/src/transforms/pipeline/stages/browser-server-exports-strip.ts index 021e48115c..69aa9ea8eb 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.ts @@ -1887,12 +1887,20 @@ function deferredExecutionNodes(root: Node): Set { // narrow: an arbitrary receiver's method says nothing about whether a // callback argument or another function body executes. if ( - callee.type === "MemberExpression" && callee.computed !== true && - isNode(callee.object) && isNode(callee.property) && - (nodeName(callee.property) === "call" || nodeName(callee.property) === "apply") + callee.type === "MemberExpression" && isNode(callee.object) && + isNode(callee.property) ) { + 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 = unwrap(callee.object); - if (target.type === "FunctionExpression" || target.type === "ArrowFunctionExpression") { + if ( + (method === "call" || method === "apply") && + (target.type === "FunctionExpression" || target.type === "ArrowFunctionExpression") + ) { return target; } } @@ -2014,29 +2022,32 @@ function serverTaintedSites( } /** - * The local names a surviving `export { … }` clause publishes. + * The local names a surviving separate export declaration publishes. * - * A clause entry is a real browser consumer of the binding it names — whatever - * imports the module reads it — but `freeReferencedIdentifiers` cannot see that: - * `visit(ExportSpecifier)` resolves `local` against the synthetic root scope, - * which binds every declaration the module still has, so the read is bound and - * never free. + * 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 for that when the `export` keyword - * wraps the declaration itself. In the compiled input this stage actually runs - * on it never does: 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 + * `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 exportClauseLocalNames(body: Node[]): Set { +function separateExportLocalNames(body: Node[]): Set { const names = new Set(); for (const statement of body) { + if (statement.type === "ExportDefaultDeclaration") { + const local = nodeName(statement.declaration); + if (local) names.add(local); + continue; + } if (statement.type !== "ExportNamedDeclaration") continue; if (statement.exportKind === "type") continue; if (isNode(statement.source)) continue; @@ -2127,9 +2138,9 @@ function dropUnreachableModuleScopeBindings( for (const site of sites) { if (site.exported) { for (const name of site.names) roots.add(name); } } - // The same contract written the other way round, which is the only way the - // compiled input writes it. - for (const name of exportClauseLocalNames(body)) 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 From 865419589f678cebc5be29a2a2743c6621f6915b Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 17 Aug 2026 21:07:33 +0200 Subject: [PATCH 31/81] fix(transforms): root default export expressions --- .../browser-server-exports-strip.test.ts | 49 +++++++++++++++++++ .../stages/browser-server-exports-strip.ts | 5 +- 2 files changed, 52 insertions(+), 2 deletions(-) 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 314ce3d13f..f267ea23fa 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts @@ -2555,6 +2555,55 @@ describe("browser-server-exports-strip", () => { assertStringIncludes(result, "export default Page"); }); + it("keeps a hook-owned binding reached through a default export expression", async () => { + const code = [ + `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, `const KEY = getEnv("SECRET_KEY")`); + assertStringIncludes(result, "export default memo(() => KEY)"); + }); + + it("keeps bindings selected by a conditional default export", async () => { + const code = [ + `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); + + assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + assertStringIncludes(result, "forwardRef(() => KEY)"); + assertStringIncludes(result, "flag ? Page : Fallback"); + }); + + it("keeps a hook-owned binding read by an anonymous default function", async () => { + const code = [ + `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); + + assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + assertStringIncludes(result, "export default function"); + assertStringIncludes(result, "return KEY"); + }); + // 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 () => { diff --git a/src/transforms/pipeline/stages/browser-server-exports-strip.ts b/src/transforms/pipeline/stages/browser-server-exports-strip.ts index 69aa9ea8eb..7434364150 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.ts @@ -2044,8 +2044,9 @@ function separateExportLocalNames(body: Node[]): Set { for (const statement of body) { if (statement.type === "ExportDefaultDeclaration") { - const local = nodeName(statement.declaration); - if (local) names.add(local); + if (isNode(statement.declaration)) { + for (const name of freeReferencedIdentifiers(statement.declaration)) names.add(name); + } continue; } if (statement.type !== "ExportNamedDeclaration") continue; From 91621483c28f3e214821dfc6e60de583eb3f8545 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 17 Aug 2026 21:22:03 +0200 Subject: [PATCH 32/81] fix(build): preserve analyzer lexical context --- .../browser-server-exports-strip.test.ts | 48 ++++++ .../stages/browser-server-exports-strip.ts | 145 +++++++++++++++--- 2 files changed, 173 insertions(+), 20 deletions(-) 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 f267ea23fa..07989d6961 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts @@ -1093,6 +1093,31 @@ describe("browser-server-exports-strip", () => { 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"); + + const result = await stripServerOnlyExports(code); + + assertStringIncludes(result, "globalThis.nameRegistrations"); + assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); + assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + }); + // 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 = [ @@ -1901,6 +1926,29 @@ describe("browser-server-exports-strip", () => { 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. diff --git a/src/transforms/pipeline/stages/browser-server-exports-strip.ts b/src/transforms/pipeline/stages/browser-server-exports-strip.ts index 7434364150..d7a80be2e1 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.ts @@ -533,14 +533,25 @@ function declaratorBoundNames(declarator: Node): string[] | null { * 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. + * 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): Set { - return freeReferencedIdentifiers({ - type: "VariableDeclaration", - kind: declaration.kind, - declarations: [declarator], - }); +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, + ); } /** @@ -568,6 +579,7 @@ function moduleScopeBindingSites( exported: boolean, detach: (() => void) | null, nested = false, + enclosingBindings: ReadonlySet = NO_BOUND_NAMES, ): void => { for (const declarator of declaratorsOf(declaration)) { const names = declaratorBoundNames(declarator); @@ -575,7 +587,7 @@ function moduleScopeBindingSites( sites.push({ names, - references: declaratorReferences(declaration, declarator), + references: declaratorReferences(declaration, declarator, enclosingBindings), node: declarator, exported, nested, @@ -636,8 +648,8 @@ function moduleScopeBindingSites( collectHoistedVarSites( declaration, stubs, - (nestedDeclaration, nestedExported, detach) => - addDeclarators(nestedDeclaration, nestedExported, detach, true), + (nestedDeclaration, nestedExported, detach, enclosingBindings) => + addDeclarators(nestedDeclaration, nestedExported, detach, true, enclosingBindings), ); } @@ -653,6 +665,64 @@ function startsVarScope(node: Node): boolean { node.type.startsWith("TS"); } +/** Lexical bindings introduced by the control-flow scope `node` opens. */ +function directLexicalBindingNames(node: Node): Set { + const names = new Set(); + + 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 ( + declaration.type === "FunctionDeclaration" || + declaration.type === "ClassDeclaration" || + declaration.type === "TSEnumDeclaration" || + isRuntimeTsModuleDeclaration(declaration) || + isRuntimeTsImportEqualsDeclaration(declaration) + ) { + const name = nodeName(declaration.id); + if (name) names.add(name); + } + }; + + 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 @@ -662,12 +732,19 @@ function startsVarScope(node: Node): boolean { * * 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. + * 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) => void, + add: ( + declaration: Node, + exported: boolean, + detach: (() => void) | null, + enclosingBindings: ReadonlySet, + ) => void, ): void { if (startsVarScope(root)) return; @@ -685,7 +762,12 @@ function collectHoistedVarSites( return null; }; - const descend = (node: Node): void => { + 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; @@ -694,24 +776,28 @@ function collectHoistedVarSites( if (!isNode(entry) || startsVarScope(entry)) continue; visitChild(entry, () => { node[key] = (node[key] as unknown[]).filter((candidate) => candidate !== entry); - }); + }, scopedBindings); } continue; } if (!isNode(value) || startsVarScope(value)) continue; - visitChild(value, slotDetach(node, key)); + visitChild(value, slotDetach(node, key), scopedBindings); } }; - const visitChild = (child: Node, detach: (() => void) | null): void => { + const visitChild = ( + child: Node, + detach: (() => void) | null, + enclosingBindings: ReadonlySet, + ): void => { if (child.type === "VariableDeclaration" && child.kind === "var") { - add(child, false, detach); + add(child, false, detach, enclosingBindings); } - descend(child); + descend(child, enclosingBindings); }; - descend(root); + descend(root, NO_BOUND_NAMES); } /** Every binding declared directly by the module, including exported declarations. */ @@ -759,6 +845,7 @@ function isLexicallyBound(name: string, scopes: LexicalScope[]): boolean { } const NOTHING_ELIDED: ReadonlySet = new Set(); +const NO_BOUND_NAMES: ReadonlySet = new Set(); /** * Free identifiers genuinely *read* by a subtree — the edges of the @@ -784,14 +871,19 @@ const NOTHING_ELIDED: ReadonlySet = new Set(); * 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. */ function freeReferencedIdentifiers( root: Node, elided: ReadonlySet = NOTHING_ELIDED, deferred: ReadonlySet = NOTHING_ELIDED, + initiallyBound: ReadonlySet = NO_BOUND_NAMES, ): Set { const free = new Set(); - const rootScope: LexicalScope = { kind: "var", names: new Set() }; + const rootScope: LexicalScope = { kind: "var", names: new Set(initiallyBound) }; const currentVarScope = (scopes: LexicalScope[]): LexicalScope => scopes.find((scope) => scope.kind === "var") ?? scopes[0] ?? rootScope; @@ -1544,6 +1636,19 @@ function isNameDescriptor(node: Node | undefined, valueParam: string): boolean { * minified binding name. */ function compilerNameHelperBindings(body: Node[]): Set { + // 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 objectIsModuleLocal = moduleScopeBindingNames(body).has("Object") || + hoistedVarNames(body).has("Object") || importsRuntimeObject; + if (objectIsModuleLocal) return new Set(); + const initializers = new Map(); for (const statement of body) { if (statement.type !== "VariableDeclaration") continue; From 7fc14e1a298a673e4549b1aea6cf8816e60c4de5 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 17 Aug 2026 21:28:16 +0200 Subject: [PATCH 33/81] fix(build): reject dynamic name helper keys --- .../browser-server-exports-strip.test.ts | 21 +++++++++++++++++++ .../stages/browser-server-exports-strip.ts | 5 +++-- 2 files changed, 24 insertions(+), 2 deletions(-) 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 07989d6961..8a177e9a15 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts @@ -1118,6 +1118,27 @@ describe("browser-server-exports-strip", () => { assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); }); + 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")`); + }); + // 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 = [ diff --git a/src/transforms/pipeline/stages/browser-server-exports-strip.ts b/src/transforms/pipeline/stages/browser-server-exports-strip.ts index d7a80be2e1..da8b8304a0 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.ts @@ -1586,8 +1586,9 @@ function stringLiteralText(node: Node | undefined): string | null { 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 property = isNode(node.property) ? node.property : undefined; + const propertyName = node.computed === true ? stringLiteralText(property) : nodeName(property); + return nodeName(node.object) === "Object" && propertyName === "defineProperty"; } function returnedCall(node: Node): Node | null { From fd2341d7e4f8fa8ecc180bb0e2e750b5acfda0c6 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 17 Aug 2026 21:37:50 +0200 Subject: [PATCH 34/81] fix: tighten compiler name helper detection --- .../browser-server-exports-strip.test.ts | 75 +++++++++++++++++++ .../stages/browser-server-exports-strip.ts | 28 +++++-- 2 files changed, 98 insertions(+), 5 deletions(-) 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 8a177e9a15..ac8c00499a 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts @@ -1139,6 +1139,81 @@ describe("browser-server-exports-strip", () => { 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")`); + }); + // 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 = [ diff --git a/src/transforms/pipeline/stages/browser-server-exports-strip.ts b/src/transforms/pipeline/stages/browser-server-exports-strip.ts index da8b8304a0..86d1db4f75 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.ts @@ -1616,14 +1616,29 @@ 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; @@ -1660,13 +1675,16 @@ function compilerNameHelperBindings(body: Node[]): Set { } } + const reassigned = assignedNames(body); + const definePropertyBindings = new Set(); for (const [name, init] of initializers) { - if (isObjectDefineProperty(init)) definePropertyBindings.add(name); + if (!reassigned.has(name) && isObjectDefineProperty(init)) definePropertyBindings.add(name); } const helpers = new Set(); for (const [name, init] of initializers) { + if (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; From fb0c8c6a2ed5161bf52e90257fa0443b25851484 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 17 Aug 2026 22:06:05 +0200 Subject: [PATCH 35/81] fix: reject mutated name helper intrinsics --- .../browser-server-exports-strip.test.ts | 50 +++++++++++++++++ .../stages/browser-server-exports-strip.ts | 56 ++++++++++++++++++- 2 files changed, 103 insertions(+), 3 deletions(-) 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 ac8c00499a..e3fdb0103a 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts @@ -1214,6 +1214,56 @@ describe("browser-server-exports-strip", () => { 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"); + + const result = await stripServerOnlyExports(code); + + assertStringIncludes(result, "Object.defineProperty = recordAndReturn"); + 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"); + + const result = await stripServerOnlyExports(code); + + assertStringIncludes(result, "Object = {"); + assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); + assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + }); + // 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 = [ diff --git a/src/transforms/pipeline/stages/browser-server-exports-strip.ts b/src/transforms/pipeline/stages/browser-server-exports-strip.ts index 86d1db4f75..6eb7ec083e 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.ts @@ -1591,6 +1591,56 @@ function isObjectDefineProperty(node: Node | undefined): boolean { return nodeName(node.object) === "Object" && propertyName === "defineProperty"; } +function writesObjectDefineProperty(body: Node[]): boolean { + const targetWritesDefineProperty = (target: Node): boolean => { + if (isObjectDefineProperty(target)) 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; + + 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; +} + function returnedCall(node: Node): Node | null { const body = node.body; if (!isNode(body)) return null; @@ -1661,8 +1711,10 @@ function compilerNameHelperBindings(body: Node[]): Set { isNode(specifier) && specifier.importKind !== "type" && nodeName(specifier.local) === "Object" ) ); + const reassigned = assignedNames(body); const objectIsModuleLocal = moduleScopeBindingNames(body).has("Object") || - hoistedVarNames(body).has("Object") || importsRuntimeObject; + hoistedVarNames(body).has("Object") || importsRuntimeObject || reassigned.has("Object") || + writesObjectDefineProperty(body); if (objectIsModuleLocal) return new Set(); const initializers = new Map(); @@ -1675,8 +1727,6 @@ function compilerNameHelperBindings(body: Node[]): Set { } } - const reassigned = assignedNames(body); - const definePropertyBindings = new Set(); for (const [name, init] of initializers) { if (!reassigned.has(name) && isObjectDefineProperty(init)) definePropertyBindings.add(name); From 18319339f983d62c4632dba2816c5ee96d0372ac Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 17 Aug 2026 22:16:40 +0200 Subject: [PATCH 36/81] fix: reject shadowed name helper intrinsics --- .../browser-server-exports-strip.test.ts | 25 +++++++++++++++++++ .../stages/browser-server-exports-strip.ts | 13 +++++++++- 2 files changed, 37 insertions(+), 1 deletion(-) 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 e3fdb0103a..5cfd6d779d 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts @@ -1264,6 +1264,31 @@ describe("browser-server-exports-strip", () => { assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); }); + 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")`); + }); + // 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 = [ diff --git a/src/transforms/pipeline/stages/browser-server-exports-strip.ts b/src/transforms/pipeline/stages/browser-server-exports-strip.ts index 6eb7ec083e..e8e0136b5c 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.ts @@ -1742,11 +1742,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) : []; From a8fa844386692a0514d40387b070447e5892d57a Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 17 Aug 2026 22:25:44 +0200 Subject: [PATCH 37/81] fix(transforms): guard compiler name helper recognition --- .../browser-server-exports-strip.test.ts | 76 +++++++++++++++++++ .../stages/browser-server-exports-strip.ts | 23 +++++- 2 files changed, 96 insertions(+), 3 deletions(-) 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 5cfd6d779d..89b4de902a 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts @@ -1239,6 +1239,82 @@ describe("browser-server-exports-strip", () => { assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); }); + 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"); + + const result = await stripServerOnlyExports(code); + + assertStringIncludes(result, `Object.defineProperty(Object, "defineProperty"`); + assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); + assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + }); + + 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) {`, diff --git a/src/transforms/pipeline/stages/browser-server-exports-strip.ts b/src/transforms/pipeline/stages/browser-server-exports-strip.ts index e8e0136b5c..f64a9cda0b 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.ts @@ -1620,6 +1620,16 @@ function writesObjectDefineProperty(body: Node[]): boolean { walk(statement, (node) => { if (writes) return false; + if ( + node.type === "CallExpression" && isNode(node.callee) && isObjectDefineProperty(node.callee) + ) { + const args = Array.isArray(node.arguments) ? node.arguments.filter(isNode) : []; + if (nodeName(args[0]) === "Object" && stringLiteralText(args[1]) === "defineProperty") { + 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; @@ -1718,23 +1728,30 @@ function compilerNameHelperBindings(body: Node[]): Set { if (objectIsModuleLocal) return new Set(); const initializers = new Map(); + const multiplyInitialized = new Set(); for (const statement of body) { if (statement.type !== "VariableDeclaration") continue; for (const declarator of Array.isArray(statement.declarations) ? statement.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)) multiplyInitialized.add(name); + initializers.set(name, declarator.init); } } const definePropertyBindings = new Set(); for (const [name, init] of initializers) { - if (!reassigned.has(name) && isObjectDefineProperty(init)) definePropertyBindings.add(name); + if ( + !multiplyInitialized.has(name) && !reassigned.has(name) && isObjectDefineProperty(init) + ) { + definePropertyBindings.add(name); + } } const helpers = new Set(); for (const [name, init] of initializers) { - if (reassigned.has(name)) continue; + if (multiplyInitialized.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; From 90851f64a5057254ca5f34d47a827eec5bcf0300 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 17 Aug 2026 22:31:54 +0200 Subject: [PATCH 38/81] fix: reject escaped intrinsics and hoisted helper rebinds The compiler name helper scan treated a module as safe whenever it never assigned to an `Object.defineProperty` member expression, so a module that handed the intrinsic to something else kept its metadata classification: `const alias = Object; alias.defineProperty = record` replaces the helper's callee through a second binding, and passing `Object` to any callee does the same. Treat every read of `Object` outside a member access base as an intrinsic mutation. A `var` redeclared below the top level hoists into module scope without appearing in the top level initialiser map, so it rebinds a helper the same way a repeated top level initialiser does. Reject those names too. --- .../browser-server-exports-strip.test.ts | 51 ++++++++++++ .../stages/browser-server-exports-strip.ts | 80 +++++++++++++++++-- 2 files changed, 123 insertions(+), 8 deletions(-) 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 89b4de902a..f0b4d5f0b7 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts @@ -1365,6 +1365,57 @@ describe("browser-server-exports-strip", () => { 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"); + + const result = await stripServerOnlyExports(code); + + assertStringIncludes(result, "intrinsic.defineProperty = recordAndReturn"); + assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); + assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + }); + + 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")`); + }); + // 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 = [ diff --git a/src/transforms/pipeline/stages/browser-server-exports-strip.ts b/src/transforms/pipeline/stages/browser-server-exports-strip.ts index f64a9cda0b..4c79efb03e 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.ts @@ -1651,6 +1651,63 @@ function writesObjectDefineProperty(body: Node[]): boolean { return writes; } +/** TypeScript nodes that still carry a runtime expression underneath. */ +const TS_EXPRESSION_TYPES = new Set([ + "TSAsExpression", + "TSTypeAssertion", + "TSNonNullExpression", + "TSInstantiationExpression", + "TSSatisfiesExpression", +]); + +/** + * 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; + return key === "id" || key === "local" || key === "imported" || key === "exported" || + key === "label" || key === "params"; +} + +/** + * Whether the module reads the `Object` intrinsic as a value instead of only + * reaching through it with a member access. + * + * `writesObjectDefineProperty` only sees assignment-shaped writes, so a module + * that hands the intrinsic to a callee replaces the helper's callee without + * ever naming `Object.defineProperty` as a target: + * `Object.defineProperty(Object, "defineProperty", { value: recordAndReturn })` + * redefines it through a call, and `const alias = Object; alias.defineProperty + * = recordAndReturn` redefines it through a second binding. Anything holding + * the intrinsic can rewrite `defineProperty` on it, so every value read fails + * closed and the module's apparent registrations stay ordinary user code. + */ +function readsObjectAsValue(body: Node[]): boolean { + const reads = (node: Node): boolean => { + if (node.type.startsWith("TS") && !TS_EXPRESSION_TYPES.has(node.type)) return false; + + 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; + if (entry.type === "Identifier" && entry.name === "Object") { + if (!isNamePosition(node, key)) return true; + continue; + } + if (reads(entry)) return true; + } + } + + return false; + }; + + return body.some((statement) => statement.type !== "ImportDeclaration" && reads(statement)); +} + function returnedCall(node: Node): Node | null { const body = node.body; if (!isNode(body)) return null; @@ -1722,36 +1779,43 @@ function compilerNameHelperBindings(body: Node[]): Set { ) ); const reassigned = assignedNames(body); + const hoisted = hoistedVarNames(body); const objectIsModuleLocal = moduleScopeBindingNames(body).has("Object") || - hoistedVarNames(body).has("Object") || importsRuntimeObject || reassigned.has("Object") || - writesObjectDefineProperty(body); + hoisted.has("Object") || importsRuntimeObject || reassigned.has("Object") || + writesObjectDefineProperty(body) || readsObjectAsValue(body); if (objectIsModuleLocal) return new Set(); + // 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. const initializers = new Map(); - const multiplyInitialized = new Set(); + 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 : []) { if (!isNode(declarator) || !isNode(declarator.init)) continue; const name = nodeName(declarator.id); if (!name) continue; - if (initializers.has(name)) multiplyInitialized.add(name); + if (initializers.has(name)) rebound.add(name); initializers.set(name, declarator.init); } } const definePropertyBindings = new Set(); for (const [name, init] of initializers) { - if ( - !multiplyInitialized.has(name) && !reassigned.has(name) && isObjectDefineProperty(init) - ) { + if (!rebound.has(name) && !reassigned.has(name) && isObjectDefineProperty(init)) { definePropertyBindings.add(name); } } const helpers = new Set(); for (const [name, init] of initializers) { - if (multiplyInitialized.has(name) || reassigned.has(name)) continue; + 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; From 4e7a8863a9517fedfe5c3c4c236d0153d341d76a Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 17 Aug 2026 22:42:01 +0200 Subject: [PATCH 39/81] fix: reject indirect intrinsic writes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The write scan proved a target was the intrinsic only when its base was the bare `Object` identifier, so two shapes replaced `Object.defineProperty` without being seen: `globalThis.Object.defineProperty = record` reaches the slot through the global object, and `Object[key] = record` reaches it through a key this stage cannot evaluate. Neither base can be proven to be something else, so any write to a member named `defineProperty`, and any computed write on `Object`, now fails closed. Also covers the `Reflect.defineProperty(Object, "defineProperty", …)` path, already rejected by the intrinsic escape check, and the module shape where a `var` helper redeclares a `function` declaration, which is a SyntaxError in a module and stops the build at the parse step. --- .../browser-server-exports-strip.test.ts | 124 ++++++++++++++++++ .../stages/browser-server-exports-strip.ts | 28 +++- 2 files changed, 150 insertions(+), 2 deletions(-) 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 f0b4d5f0b7..a4936800b9 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts @@ -1416,6 +1416,130 @@ describe("browser-server-exports-strip", () => { 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"); + + const result = await stripServerOnlyExports(code); + + assertStringIncludes(result, `Reflect.defineProperty(Object, "defineProperty"`); + assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); + assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + }); + + 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"); + + const result = await stripServerOnlyExports(code); + + assertStringIncludes(result, "globalThis.Object.defineProperty = recordAndReturn"); + assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); + assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + }); + + 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"); + + const result = await stripServerOnlyExports(code); + + assertStringIncludes(result, "Object[globalThis.patchedName] = recordAndReturn"); + assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); + assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + }); + + // 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("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 = [ diff --git a/src/transforms/pipeline/stages/browser-server-exports-strip.ts b/src/transforms/pipeline/stages/browser-server-exports-strip.ts index 4c79efb03e..c35e4fafe2 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.ts @@ -1591,9 +1591,31 @@ function isObjectDefineProperty(node: Node | undefined): boolean { return nodeName(node.object) === "Object" && propertyName === "defineProperty"; } +/** + * 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. + * Neither base can be proven to be something else, so any write to a member + * named `defineProperty`, and any computed write on `Object`, fails closed. + */ +function writesDefinePropertyMember(target: Node): boolean { + if (target.type !== "MemberExpression" && target.type !== "OptionalMemberExpression") { + 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 ? nodeName(target.object) === "Object" : key === "defineProperty"; +} + function writesObjectDefineProperty(body: Node[]): boolean { const targetWritesDefineProperty = (target: Node): boolean => { - if (isObjectDefineProperty(target)) return true; + if (writesDefinePropertyMember(target)) return true; if (target.type === "AssignmentPattern") { return isNode(target.left) && targetWritesDefineProperty(target.left); } @@ -1792,7 +1814,9 @@ function compilerNameHelperBindings(body: Node[]): Set { // (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. + // 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) { From e1d6acc24e651307d952a5e506147045729e865a Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 17 Aug 2026 22:47:11 +0200 Subject: [PATCH 40/81] fix(transforms): preserve unrelated defineProperty writes --- .../browser-server-exports-strip.test.ts | 23 +++++++++++++++++++ .../stages/browser-server-exports-strip.ts | 14 ++++++++--- 2 files changed, 34 insertions(+), 3 deletions(-) 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 a4936800b9..06b52e2494 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts @@ -1491,6 +1491,29 @@ describe("browser-server-exports-strip", () => { assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); }); + 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); + }); + // 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 diff --git a/src/transforms/pipeline/stages/browser-server-exports-strip.ts b/src/transforms/pipeline/stages/browser-server-exports-strip.ts index c35e4fafe2..d11c4b8495 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.ts @@ -1598,19 +1598,27 @@ function isObjectDefineProperty(node: Node | undefined): boolean { * 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. - * Neither base can be proven to be something else, so any write to a member - * named `defineProperty`, and any computed write on `Object`, fails closed. + * Only those known intrinsic bases fail closed. An unrelated + * `registry.defineProperty` write cannot replace the intrinsic and must not + * stop compiler metadata from being removed with a hook-only binding. */ function writesDefinePropertyMember(target: Node): boolean { if (target.type !== "MemberExpression" && target.type !== "OptionalMemberExpression") { return false; } + const object = isNode(target.object) ? target.object : undefined; + const objectIsIntrinsic = nodeName(object) === "Object" || + (object?.type === "MemberExpression" || object?.type === "OptionalMemberExpression") && + nodeName(object.object) === "globalThis" && + literalText(isNode(object.property) ? object.property : undefined) === "Object"; + 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 ? nodeName(target.object) === "Object" : key === "defineProperty"; + return key === null || key === "defineProperty"; } function writesObjectDefineProperty(body: Node[]): boolean { From 24673f4fe696e02c6b98f2f6f5f12719b95d56a5 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 17 Aug 2026 22:55:28 +0200 Subject: [PATCH 41/81] fix(transforms): reject global Object replacement --- .../browser-server-exports-strip.test.ts | 25 +++++++++++++++++++ .../stages/browser-server-exports-strip.ts | 18 ++++++++++--- 2 files changed, 39 insertions(+), 4 deletions(-) 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 06b52e2494..05c655829e 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts @@ -1466,6 +1466,31 @@ describe("browser-server-exports-strip", () => { assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); }); + 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"); + + const result = await stripServerOnlyExports(code); + + assertStringIncludes(result, "globalThis.Object = {"); + assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); + assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + }); + it("does not treat a computed intrinsic write as compiler metadata", async () => { const code = [ `function recordAndReturn(target) {`, diff --git a/src/transforms/pipeline/stages/browser-server-exports-strip.ts b/src/transforms/pipeline/stages/browser-server-exports-strip.ts index d11c4b8495..2ba57fe7a5 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.ts @@ -1602,6 +1602,18 @@ function isObjectDefineProperty(node: Node | undefined): boolean { * `registry.defineProperty` write cannot replace the intrinsic and must not * stop compiler metadata from being removed with a hook-only binding. */ +function isGlobalObjectSlot(node: Node | undefined): boolean { + if (node?.type !== "MemberExpression" && node?.type !== "OptionalMemberExpression") { + return false; + } + + if (nodeName(node.object) !== "globalThis") 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"; +} + function writesDefinePropertyMember(target: Node): boolean { if (target.type !== "MemberExpression" && target.type !== "OptionalMemberExpression") { return false; @@ -1609,9 +1621,7 @@ function writesDefinePropertyMember(target: Node): boolean { const object = isNode(target.object) ? target.object : undefined; const objectIsIntrinsic = nodeName(object) === "Object" || - (object?.type === "MemberExpression" || object?.type === "OptionalMemberExpression") && - nodeName(object.object) === "globalThis" && - literalText(isNode(object.property) ? object.property : undefined) === "Object"; + isGlobalObjectSlot(object); if (!objectIsIntrinsic) return false; const property = isNode(target.property) ? target.property : undefined; @@ -1623,7 +1633,7 @@ function writesDefinePropertyMember(target: Node): boolean { function writesObjectDefineProperty(body: Node[]): boolean { const targetWritesDefineProperty = (target: Node): boolean => { - if (writesDefinePropertyMember(target)) return true; + if (isGlobalObjectSlot(target) || writesDefinePropertyMember(target)) return true; if (target.type === "AssignmentPattern") { return isNode(target.left) && targetWritesDefineProperty(target.left); } From 40167d53f78766b40befbaa4ea499d6ee61d2c24 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 17 Aug 2026 23:08:34 +0200 Subject: [PATCH 42/81] fix: reject global object replacement paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The escape check covered only `Object`, so replacing the whole constructor on the global object slipped past it: `Object.defineProperty(globalThis, "Object", { value: replacement })` defines it through a call, and `const scope = globalThis; scope.Object = replacement` writes it through a second binding. Both leave the helper calling `replacement.defineProperty` while the module still looks like compiler metadata. Reading either global as a value now fails closed, which covers those two shapes along with `Reflect.defineProperty(globalThis, …)` and `Object.assign(globalThis, …)` without enumerating callees. --- .../browser-server-exports-strip.test.ts | 75 +++++++++++++++++++ .../stages/browser-server-exports-strip.ts | 28 ++++--- 2 files changed, 92 insertions(+), 11 deletions(-) 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 05c655829e..e116152b00 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts @@ -1491,6 +1491,81 @@ describe("browser-server-exports-strip", () => { assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); }); + it("does not treat a defined global Object binding as compiler metadata", async () => { + const code = [ + `function recordAndReturn(target) {`, + ` globalThis.nameRegistrations = (globalThis.nameRegistrations ?? 0) + 1;`, + ` return target;`, + `}`, + `const replacement = { defineProperty: recordAndReturn };`, + `Object.defineProperty(globalThis, "Object", { value: replacement });`, + `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(globalThis, "Object"`); + assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); + assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + }); + + 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"); + + const result = await stripServerOnlyExports(code); + + assertStringIncludes(result, "scope.Object = "); + assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); + assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + }); + + // The intrinsic guards match on name, exactly as `assignedNames` and + // `moduleScopeBindingNames` do. A shadowed `globalThis` therefore fails + // closed: the module keeps code it really runs, which is the direction + // this stage errs in whenever it cannot prove a module shape safe. + it("fails closed when globalThis is shadowed by a parameter", async () => { + const code = [ + `function configure(globalThis) { globalThis.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); + + assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); + }); + it("does not treat a computed intrinsic write as compiler metadata", async () => { const code = [ `function recordAndReturn(target) {`, diff --git a/src/transforms/pipeline/stages/browser-server-exports-strip.ts b/src/transforms/pipeline/stages/browser-server-exports-strip.ts index 2ba57fe7a5..81cc6c080a 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.ts @@ -1712,20 +1712,26 @@ function isNamePosition(parent: Node, key: string): boolean { key === "label" || key === "params"; } +/** Globals the helper's semantics rest on, either of which can replace it. */ +const INTRINSIC_NAMES = new Set(["Object", "globalThis"]); + /** - * Whether the module reads the `Object` intrinsic as a value instead of only - * reaching through it with a member access. + * Whether the module reads `Object` or `globalThis` as a value instead of only + * reaching through one with a member access. * * `writesObjectDefineProperty` only sees assignment-shaped writes, so a module - * that hands the intrinsic to a callee replaces the helper's callee without - * ever naming `Object.defineProperty` as a target: + * that hands either global to a callee replaces the helper's callee without + * ever naming a target it can recognise: * `Object.defineProperty(Object, "defineProperty", { value: recordAndReturn })` - * redefines it through a call, and `const alias = Object; alias.defineProperty - * = recordAndReturn` redefines it through a second binding. Anything holding - * the intrinsic can rewrite `defineProperty` on it, so every value read fails - * closed and the module's apparent registrations stay ordinary user code. + * redefines the method through a call, `const alias = Object; + * alias.defineProperty = recordAndReturn` redefines it through a second + * binding, and `Object.defineProperty(globalThis, "Object", { value: + * replacement })` and `const scope = globalThis; scope.Object = replacement` + * replace the whole constructor the same two ways. Anything holding either + * global can rewrite what the helper calls, so every value read fails closed + * and the module's apparent registrations stay ordinary user code. */ -function readsObjectAsValue(body: Node[]): boolean { +function readsGlobalAsValue(body: Node[]): boolean { const reads = (node: Node): boolean => { if (node.type.startsWith("TS") && !TS_EXPRESSION_TYPES.has(node.type)) return false; @@ -1734,7 +1740,7 @@ function readsObjectAsValue(body: Node[]): boolean { for (const entry of Array.isArray(value) ? value : [value]) { if (!isNode(entry)) continue; - if (entry.type === "Identifier" && entry.name === "Object") { + if (entry.type === "Identifier" && INTRINSIC_NAMES.has(entry.name as string)) { if (!isNamePosition(node, key)) return true; continue; } @@ -1822,7 +1828,7 @@ function compilerNameHelperBindings(body: Node[]): Set { const hoisted = hoistedVarNames(body); const objectIsModuleLocal = moduleScopeBindingNames(body).has("Object") || hoisted.has("Object") || importsRuntimeObject || reassigned.has("Object") || - writesObjectDefineProperty(body) || readsObjectAsValue(body); + writesObjectDefineProperty(body) || readsGlobalAsValue(body); if (objectIsModuleLocal) return new Set(); // A `var` may be declared more than once, and only the initialiser that ran From f3294ccb18a245dde49ef96a3f710120667af29e Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 17 Aug 2026 23:09:16 +0200 Subject: [PATCH 43/81] fix(transforms): resolve intrinsic writes by scope --- .../browser-server-exports-strip.test.ts | 81 +++++++++-- .../stages/browser-server-exports-strip.ts | 133 ++++++++++++++---- 2 files changed, 173 insertions(+), 41 deletions(-) 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 e116152b00..395dc11d80 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts @@ -1491,14 +1491,16 @@ describe("browser-server-exports-strip", () => { assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); }); - it("does not treat a defined global Object binding as compiler metadata", async () => { + 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;`, `}`, - `const replacement = { defineProperty: recordAndReturn };`, - `Object.defineProperty(globalThis, "Object", { value: replacement });`, + `Object.defineProperty(globalThis, "Object", {`, + ` value: { defineProperty: recordAndReturn },`, + ` configurable: true,`, + `});`, `var setName = (target, value) => Object.defineProperty(`, ` target, "name", { value, configurable: true },`, `);`, @@ -1517,14 +1519,16 @@ describe("browser-server-exports-strip", () => { assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); }); - it("does not treat an aliased global object as compiler metadata", async () => { + 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;`, `}`, - `const scope = globalThis;`, - `scope.Object = { defineProperty: recordAndReturn };`, + `Reflect.defineProperty(globalThis, "Object", {`, + ` value: { defineProperty: recordAndReturn },`, + ` configurable: true,`, + `});`, `var setName = (target, value) => Object.defineProperty(`, ` target, "name", { value, configurable: true },`, `);`, @@ -1538,18 +1542,19 @@ describe("browser-server-exports-strip", () => { const result = await stripServerOnlyExports(code); - assertStringIncludes(result, "scope.Object = "); + assertStringIncludes(result, `Reflect.defineProperty(globalThis, "Object"`); assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); }); - // The intrinsic guards match on name, exactly as `assignedNames` and - // `moduleScopeBindingNames` do. A shadowed `globalThis` therefore fails - // closed: the module keeps code it really runs, which is the direction - // this stage errs in whenever it cannot prove a module shape safe. - it("fails closed when globalThis is shadowed by a parameter", async () => { + it("does not treat an aliased global object as compiler metadata", async () => { const code = [ - `function configure(globalThis) { globalThis.Object = {}; }`, + `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 },`, `);`, @@ -1563,7 +1568,9 @@ describe("browser-server-exports-strip", () => { const result = await stripServerOnlyExports(code); + assertStringIncludes(result, "scope.Object = "); assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); + assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); }); it("does not treat a computed intrinsic write as compiler metadata", async () => { @@ -1614,6 +1621,54 @@ describe("browser-server-exports-strip", () => { 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); + }); + // 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 diff --git a/src/transforms/pipeline/stages/browser-server-exports-strip.ts b/src/transforms/pipeline/stages/browser-server-exports-strip.ts index 81cc6c080a..548d264c8c 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.ts @@ -874,13 +874,15 @@ const NO_BOUND_NAMES: ReadonlySet = new Set(); * * `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. + * 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, elided: ReadonlySet = NOTHING_ELIDED, deferred: ReadonlySet = NOTHING_ELIDED, initiallyBound: ReadonlySet = NO_BOUND_NAMES, + onFreeIdentifier?: (node: Node) => void, ): Set { const free = new Set(); const rootScope: LexicalScope = { kind: "var", names: new Set(initiallyBound) }; @@ -893,8 +895,15 @@ function freeReferencedIdentifiers( for (const name of patternBoundNames(value)) scope.names.add(name); }; - const addFreeName = (name: string | null, scopes: LexicalScope[]): void => { - if (name && !isLexicallyBound(name, scopes)) free.add(name); + 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 isIntrinsicJsxTagName = (name: string): boolean => { @@ -1249,7 +1258,7 @@ function freeReferencedIdentifiers( if (visitTsExpression(node, scopes)) return; if (node.type === "Identifier") { - addFreeName(nodeName(node), scopes); + addFreeName(nodeName(node), scopes, node); return; } @@ -1584,6 +1593,41 @@ 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; @@ -1602,26 +1646,27 @@ function isObjectDefineProperty(node: Node | undefined): boolean { * `registry.defineProperty` write cannot replace the intrinsic and must not * stop compiler metadata from being removed with a hook-only binding. */ -function isGlobalObjectSlot(node: Node | undefined): boolean { +function isGlobalObjectSlot(node: Node | undefined, globals: ReadonlySet): boolean { if (node?.type !== "MemberExpression" && node?.type !== "OptionalMemberExpression") { return false; } - if (nodeName(node.object) !== "globalThis") return false; + const object = isNode(node.object) ? node.object : undefined; + if (!isUnshadowedGlobalIdentifier(object, "globalThis", 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"; } -function writesDefinePropertyMember(target: Node): boolean { +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 = nodeName(object) === "Object" || - isGlobalObjectSlot(object); + const objectIsIntrinsic = isUnshadowedGlobalIdentifier(object, "Object", globals) || + isGlobalObjectSlot(object, globals); if (!objectIsIntrinsic) return false; const property = isNode(target.property) ? target.property : undefined; @@ -1631,9 +1676,29 @@ function writesDefinePropertyMember(target: Node): boolean { return key === null || key === "defineProperty"; } -function writesObjectDefineProperty(body: Node[]): boolean { +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); +} + +function writesObjectDefineProperty(body: Node[], globals: ReadonlySet): boolean { const targetWritesDefineProperty = (target: Node): boolean => { - if (isGlobalObjectSlot(target) || writesDefinePropertyMember(target)) return true; + if ( + isGlobalObjectSlot(target, globals) || writesDefinePropertyMember(target, globals) + ) return true; if (target.type === "AssignmentPattern") { return isNode(target.left) && targetWritesDefineProperty(target.left); } @@ -1661,10 +1726,18 @@ function writesObjectDefineProperty(body: Node[]): boolean { if (writes) return false; if ( - node.type === "CallExpression" && isNode(node.callee) && isObjectDefineProperty(node.callee) + node.type === "CallExpression" && isNode(node.callee) && + isIntrinsicDefinePropertyCall(node.callee, globals) ) { const args = Array.isArray(node.arguments) ? node.arguments.filter(isNode) : []; - if (nodeName(args[0]) === "Object" && stringLiteralText(args[1]) === "defineProperty") { + const key = stringLiteralText(args[1]); + const targetIsObject = isUnshadowedGlobalIdentifier(args[0], "Object", globals) || + isGlobalObjectSlot(args[0], globals); + const targetIsGlobal = isUnshadowedGlobalIdentifier(args[0], "globalThis", globals); + if ( + (targetIsObject && key === "defineProperty") || + (targetIsGlobal && key === "Object") + ) { writes = true; return false; } @@ -1712,26 +1785,25 @@ function isNamePosition(parent: Node, key: string): boolean { key === "label" || key === "params"; } -/** Globals the helper's semantics rest on, either of which can replace it. */ -const INTRINSIC_NAMES = new Set(["Object", "globalThis"]); - /** - * Whether the module reads `Object` or `globalThis` as a value instead of only - * reaching through one with a member access. + * Whether the module reads an unshadowed intrinsic as a value instead of only + * reaching through it with a member access. * * `writesObjectDefineProperty` only sees assignment-shaped writes, so a module * that hands either global to a callee replaces the helper's callee without * ever naming a target it can recognise: * `Object.defineProperty(Object, "defineProperty", { value: recordAndReturn })` - * redefines the method through a call, `const alias = Object; - * alias.defineProperty = recordAndReturn` redefines it through a second - * binding, and `Object.defineProperty(globalThis, "Object", { value: - * replacement })` and `const scope = globalThis; scope.Object = replacement` - * replace the whole constructor the same two ways. Anything holding either - * global can rewrite what the helper calls, so every value read fails closed - * and the module's apparent registrations stay ordinary user code. + * redefines it through a call, and `const alias = Object; alias.defineProperty + * = recordAndReturn` redefines it through a second binding. Anything holding + * `Object`, or anyone handed `globalThis`, can rewrite `defineProperty`, so + * every genuine global value read fails closed and the module's apparent + * registrations stay ordinary user code. Lexically shadowed names do not. */ -function readsGlobalAsValue(body: Node[]): boolean { +function readsIntrinsicAsValue( + body: Node[], + name: "Object" | "globalThis", + globals: ReadonlySet, +): boolean { const reads = (node: Node): boolean => { if (node.type.startsWith("TS") && !TS_EXPRESSION_TYPES.has(node.type)) return false; @@ -1740,7 +1812,9 @@ function readsGlobalAsValue(body: Node[]): boolean { for (const entry of Array.isArray(value) ? value : [value]) { if (!isNode(entry)) continue; - if (entry.type === "Identifier" && INTRINSIC_NAMES.has(entry.name as string)) { + if ( + entry.type === "Identifier" && entry.name === name && globals.has(entry) + ) { if (!isNamePosition(node, key)) return true; continue; } @@ -1826,9 +1900,12 @@ function compilerNameHelperBindings(body: Node[]): Set { ); const reassigned = assignedNames(body); const hoisted = hoistedVarNames(body); + const globals = unshadowedGlobalIdentifierNodes(body); const objectIsModuleLocal = moduleScopeBindingNames(body).has("Object") || hoisted.has("Object") || importsRuntimeObject || reassigned.has("Object") || - writesObjectDefineProperty(body) || readsGlobalAsValue(body); + writesObjectDefineProperty(body, globals) || + readsIntrinsicAsValue(body, "Object", globals) || + readsIntrinsicAsValue(body, "globalThis", globals); if (objectIsModuleLocal) return new Set(); // A `var` may be declared more than once, and only the initialiser that ran From 9c8771ae48b0779532547a32f0737bafbf3dc8fc Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 17 Aug 2026 23:21:17 +0200 Subject: [PATCH 44/81] fix(transforms): resolve global intrinsic aliases by scope --- .../browser-server-exports-strip.test.ts | 52 ++++++++++++++++ .../stages/browser-server-exports-strip.ts | 59 ++++++++++++++++++- 2 files changed, 110 insertions(+), 1 deletion(-) 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 395dc11d80..022f8116d7 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts @@ -1391,6 +1391,34 @@ describe("browser-server-exports-strip", () => { assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); }); + 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"); + + const result = await stripServerOnlyExports(code); + + assertStringIncludes(result, `const intrinsic = ${globalObject}`); + assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); + assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + }); + } + it("does not treat a hoisted name helper redeclaration as compiler metadata", async () => { const code = [ `var setName = (target, value) => Object.defineProperty(`, @@ -1669,6 +1697,30 @@ describe("browser-server-exports-strip", () => { 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 `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 diff --git a/src/transforms/pipeline/stages/browser-server-exports-strip.ts b/src/transforms/pipeline/stages/browser-server-exports-strip.ts index 548d264c8c..e1aa664508 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.ts @@ -1694,6 +1694,58 @@ function isIntrinsicDefinePropertyCall( isGlobalObjectSlot(object, globals); } +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): boolean { const targetWritesDefineProperty = (target: Node): boolean => { if ( @@ -1812,6 +1864,10 @@ function readsIntrinsicAsValue( for (const entry of Array.isArray(value) ? value : [value]) { if (!isNode(entry)) continue; + if (name === "Object" && isGlobalObjectSlot(entry, globals)) { + if (!isNamePosition(node, key)) return true; + continue; + } if ( entry.type === "Identifier" && entry.name === name && globals.has(entry) ) { @@ -1902,7 +1958,8 @@ function compilerNameHelperBindings(body: Node[]): Set { const hoisted = hoistedVarNames(body); const globals = unshadowedGlobalIdentifierNodes(body); const objectIsModuleLocal = moduleScopeBindingNames(body).has("Object") || - hoisted.has("Object") || importsRuntimeObject || reassigned.has("Object") || + hoisted.has("Object") || importsRuntimeObject || + assignsUnshadowedGlobal(body, "Object", globals) || writesObjectDefineProperty(body, globals) || readsIntrinsicAsValue(body, "Object", globals) || readsIntrinsicAsValue(body, "globalThis", globals); From fdf5bec2915d6ddc6e508b7d5186668eb2fe20a8 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 17 Aug 2026 23:35:50 +0200 Subject: [PATCH 45/81] fix: resolve browser global object aliases The global object slot check only accepted a `globalThis` base, so a module that reaches the same slot through `window`, `self` or `global` bypassed it: `const intrinsic = window.Object; intrinsic.defineProperty = record` obtains the constructor without either identifier scan seeing a value read. All four names now resolve to the global object, scope-aware as before, which also covers `Object.defineProperty(window, "Object", ...)` and `const scope = window; scope.Object = replacement`. Reading one of those names as a value is an escape, but `typeof window` yields a string rather than a reference, and it is how every module guards for the browser, so a `typeof` operand stays a plain read and keeps compiler metadata prunable. --- .../browser-server-exports-strip.test.ts | 81 +++++++++++++++++++ .../stages/browser-server-exports-strip.ts | 38 ++++++--- 2 files changed, 108 insertions(+), 11 deletions(-) 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 022f8116d7..2101117904 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts @@ -1391,6 +1391,87 @@ describe("browser-server-exports-strip", () => { assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); }); + for ( + const globalObject of ["window.Object", "self.Object", 'window["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"); + + const result = await stripServerOnlyExports(code); + + assertStringIncludes(result, `const intrinsic = ${globalObject}`); + assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); + assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + }); + } + + 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"); + + const result = await stripServerOnlyExports(code); + + assertStringIncludes(result, "const scope = window"); + assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); + assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + }); + + // `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"); + }); + for (const globalObject of ["globalThis.Object", 'globalThis["Object"]']) { it(`does not treat an aliased ${globalObject} intrinsic as compiler metadata`, async () => { const code = [ diff --git a/src/transforms/pipeline/stages/browser-server-exports-strip.ts b/src/transforms/pipeline/stages/browser-server-exports-strip.ts index e1aa664508..7aa3dbb4b1 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.ts @@ -1646,13 +1646,24 @@ function isObjectDefineProperty(node: Node | undefined): boolean { * `registry.defineProperty` write cannot replace the intrinsic and must not * stop compiler metadata from being removed with a hook-only binding. */ +/** + * 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`, so all four reach the same `Object` slot. + */ +const GLOBAL_OBJECT_NAMES = ["globalThis", "window", "self", "global"]; + +function isUnshadowedGlobalObject(node: Node | undefined, globals: ReadonlySet): boolean { + return GLOBAL_OBJECT_NAMES.some((name) => isUnshadowedGlobalIdentifier(node, name, globals)); +} + 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 (!isUnshadowedGlobalIdentifier(object, "globalThis", globals)) return false; + 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); @@ -1785,7 +1796,7 @@ function writesObjectDefineProperty(body: Node[], globals: ReadonlySet): b const key = stringLiteralText(args[1]); const targetIsObject = isUnshadowedGlobalIdentifier(args[0], "Object", globals) || isGlobalObjectSlot(args[0], globals); - const targetIsGlobal = isUnshadowedGlobalIdentifier(args[0], "globalThis", globals); + const targetIsGlobal = isUnshadowedGlobalObject(args[0], globals); if ( (targetIsObject && key === "defineProperty") || (targetIsGlobal && key === "Object") @@ -1833,6 +1844,11 @@ const TS_EXPRESSION_TYPES = new Set([ 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"; } @@ -1853,9 +1869,15 @@ function isNamePosition(parent: Node, key: string): boolean { */ function readsIntrinsicAsValue( body: Node[], - name: "Object" | "globalThis", + name: "Object" | "global", globals: ReadonlySet, ): boolean { + const isIntrinsic = (entry: Node): boolean => + name === "Object" + ? isUnshadowedGlobalIdentifier(entry, "Object", globals) || + isGlobalObjectSlot(entry, globals) + : isUnshadowedGlobalObject(entry, globals); + const reads = (node: Node): boolean => { if (node.type.startsWith("TS") && !TS_EXPRESSION_TYPES.has(node.type)) return false; @@ -1864,13 +1886,7 @@ function readsIntrinsicAsValue( for (const entry of Array.isArray(value) ? value : [value]) { if (!isNode(entry)) continue; - if (name === "Object" && isGlobalObjectSlot(entry, globals)) { - if (!isNamePosition(node, key)) return true; - continue; - } - if ( - entry.type === "Identifier" && entry.name === name && globals.has(entry) - ) { + if (isIntrinsic(entry)) { if (!isNamePosition(node, key)) return true; continue; } @@ -1962,7 +1978,7 @@ function compilerNameHelperBindings(body: Node[]): Set { assignsUnshadowedGlobal(body, "Object", globals) || writesObjectDefineProperty(body, globals) || readsIntrinsicAsValue(body, "Object", globals) || - readsIntrinsicAsValue(body, "globalThis", globals); + readsIntrinsicAsValue(body, "global", globals); if (objectIsModuleLocal) return new Set(); // A `var` may be declared more than once, and only the initialiser that ran From 63fe5f1d83226b8356e9315a5d087cdbc702cb36 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 18 Aug 2026 00:01:54 +0200 Subject: [PATCH 46/81] fix(transforms): cover transparent browser globals --- .../browser-server-exports-strip.test.ts | 30 ++++++++++++++++++- .../stages/browser-server-exports-strip.ts | 14 +++++---- 2 files changed, 38 insertions(+), 6 deletions(-) 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 2101117904..71d3ee5db0 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts @@ -1392,7 +1392,12 @@ describe("browser-server-exports-strip", () => { }); for ( - const globalObject of ["window.Object", "self.Object", 'window["Object"]'] + const globalObject of [ + "window.Object", + "self.Object", + "frames.Object", + 'window["Object"]', + ] ) { it(`does not treat an aliased ${globalObject} intrinsic as compiler metadata`, async () => { const code = [ @@ -1472,6 +1477,29 @@ describe("browser-server-exports-strip", () => { 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!"); + }); + for (const globalObject of ["globalThis.Object", 'globalThis["Object"]']) { it(`does not treat an aliased ${globalObject} intrinsic as compiler metadata`, async () => { const code = [ diff --git a/src/transforms/pipeline/stages/browser-server-exports-strip.ts b/src/transforms/pipeline/stages/browser-server-exports-strip.ts index 7aa3dbb4b1..b9ed73e96a 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.ts @@ -1649,9 +1649,10 @@ function isObjectDefineProperty(node: Node | undefined): boolean { /** * 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`, so all four reach the same `Object` slot. + * Node uses `global`, and browsers expose `frames` as a Window alias, so all + * five reach the same `Object` slot. */ -const GLOBAL_OBJECT_NAMES = ["globalThis", "window", "self", "global"]; +const GLOBAL_OBJECT_NAMES = ["globalThis", "window", "self", "frames", "global"]; function isUnshadowedGlobalObject(node: Node | undefined, globals: ReadonlySet): boolean { return GLOBAL_OBJECT_NAMES.some((name) => isUnshadowedGlobalIdentifier(node, name, globals)); @@ -1878,7 +1879,7 @@ function readsIntrinsicAsValue( isGlobalObjectSlot(entry, globals) : isUnshadowedGlobalObject(entry, globals); - const reads = (node: Node): boolean => { + const reads = (node: Node, transparentTypeof = false): boolean => { if (node.type.startsWith("TS") && !TS_EXPRESSION_TYPES.has(node.type)) return false; for (const [key, value] of Object.entries(node)) { @@ -1886,11 +1887,14 @@ function readsIntrinsicAsValue( for (const entry of Array.isArray(value) ? value : [value]) { if (!isNode(entry)) continue; + const entryIsTransparentTypeof = + (node.type === "UnaryExpression" && node.operator === "typeof" && key === "argument") || + (transparentTypeof && TS_EXPRESSION_TYPES.has(node.type) && key === "expression"); if (isIntrinsic(entry)) { - if (!isNamePosition(node, key)) return true; + if (!entryIsTransparentTypeof && !isNamePosition(node, key)) return true; continue; } - if (reads(entry)) return true; + if (reads(entry, entryIsTransparentTypeof)) return true; } } From cc3ec402981becfb99d22d47fe6f25cb82b43cac Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 18 Aug 2026 00:21:53 +0200 Subject: [PATCH 47/81] fix(transforms): recognise compiler name metadata by allowlist Enumerating the ways a module can replace `Object.defineProperty` does not terminate. Fifteen review rounds found a new route each time, and three more were still open at this head: `({}).constructor.defineProperty = record`, `Object.getPrototypeOf({}).constructor.defineProperty = record`, and `"".constructor.constructor("globalThis.Object.defineProperty = arguments[0]")` all reach the intrinsic without ever naming `Object` as a value, so each one still had `setName` classified as compiler metadata and deleted the observable registration together with `const KEY = getEnv("SECRET_KEY")`. Invert the analysis. A module is recognised as carrying compiler name metadata only when it is inside a narrow allowlist: `Object` resolves to the intrinsic, the module carries no reflection route to a constructor or a prototype and no route from a string to code, no property write reaches `defineProperty`, `defineProperties` or `Object` through a base that cannot be bound to a value the module itself made, no `defineProperty`-shaped call or object merge targets the intrinsic, and the intrinsic never reaches a slot the module can write back through. A route nobody has thought of yet is outside that set by construction, so the analysis terminates instead of growing a rejection per route found. One doc block on `compilerNameHelperBindings` states the boundary: what the stage proves, what it does not attempt, and that it errs toward keeping code. Also fixes three defects two verifiers found on top of that: - The escape scan returned false for every `TS`-prefixed node outside the expression set, so a runtime namespace body was never traversed. `namespace Patch { export const intrinsic = Object }` followed by `Patch.intrinsic.defineProperty = record` deleted the registration and the secret initialiser. Namespace and enum bodies are traversed now, which closes the contradiction with this branch's own hoisted-vars-in-namespaces fix. - The escape scan failed closed on any value read of a global anywhere in the module, including inside nested client callbacks. `const w = window`, `Object.assign(globalThis, {})`, `{ ...window }`, `fn(globalThis)` and `[].map(Object)` are ordinary client code and none can replace the intrinsic, yet each disabled name-helper recognition for the whole module, so on keepNames-compiled release modules the helper, its hook-only initialiser and its server import were all retained where main strips them. The scan now only fails closed when the intrinsic reaches a slot the module can write back through: a property, a namespace binding, or a name it writes a member of. - `typeof (window as unknown)` and `typeof window!` wrap the operand in a transparent node, so the immediate-parent name-position check missed it. The local unwrap helper in `deferredExecutionNodes` is hoisted to module scope, extended with `TSTypeAssertion` and `TSSatisfiesExpression`, and reused. `parent`, `top` and `document.defaultView` join the global object aliases; each one is the same window in a main browsing context. Documents the build failures this branch introduced for shapes that used to build: a re-exported or class-declared hook, and a hook-only binding declared by a loop head. Adds the `server-export-strip-failed` catalog entry with the supported forms, sets the slug on the thrown error so the entry resolves, and notes the migration in the data fetching guide. Replaces every em dash this branch introduced in the stage and its tests. Refs veryfront/veryfront-issue-inbox#112 --- docs/guides/data-fetching.md | 49 ++ docs/guides/errors.md | 7 + src/errors/catalog/build-errors.test.ts | 5 +- src/errors/catalog/build-errors.ts | 28 + src/errors/error-registry.test.ts | 8 +- src/errors/error-registry/build.ts | 9 + .../browser-server-exports-strip.test.ts | 256 +++++++- .../stages/browser-server-exports-strip.ts | 606 ++++++++++++++---- 8 files changed, 838 insertions(+), 130 deletions(-) diff --git a/docs/guides/data-fetching.md b/docs/guides/data-fetching.md index 078c084462..1f81dad731 100644 --- a/docs/guides/data-fetching.md +++ b/docs/guides/data-fetching.md @@ -45,6 +45,55 @@ 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: await load(ctx) }; +} + +// Also supported +export const getStaticData = async () => ({ props: await load() }); +``` + +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"); +``` + 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..fd9ca6cb1a 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", () => { diff --git a/src/errors/catalog/build-errors.ts b/src/errors/catalog/build-errors.ts index b841839063..143d3285a9 100644 --- a/src/errors/catalog/build-errors.ts +++ b/src/errors/catalog/build-errors.ts @@ -110,4 +110,32 @@ 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 in the route module as a function or an arrow initializer", + "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", + "Move a value the browser also needs into a module the hook imports", + ], + 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", + ], + 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/transforms/pipeline/stages/browser-server-exports-strip.test.ts b/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts index 71d3ee5db0..74fe27a0de 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts @@ -245,7 +245,7 @@ describe("browser-server-exports-strip", () => { // 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 + // (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 = [ @@ -261,7 +261,7 @@ describe("browser-server-exports-strip", () => { // 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 — + // 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 = [ @@ -911,7 +911,7 @@ describe("browser-server-exports-strip", () => { // 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 + // 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 @@ -1066,7 +1066,7 @@ describe("browser-server-exports-strip", () => { // 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 — + // 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 = [ @@ -1830,6 +1830,234 @@ describe("browser-server-exports-strip", () => { 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"); + + const result = await stripServerOnlyExports(code); + + assertStringIncludes(result, `const intrinsic = ${globalObject}`); + assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); + assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + }); + } + + // 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"); + + const result = await stripServerOnlyExports(code, "pages/ns.ts"); + + assertStringIncludes(result, "Patch.intrinsic.defineProperty = recordAndReturn"); + assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); + assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + }); + } + + 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"); + + const result = await stripServerOnlyExports(code, "pages/ns.ts"); + + assertStringIncludes(result, "Patch.intrinsic.defineProperty = recordAndReturn"); + assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); + assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + }); + + // 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);`], + ] + ) { + 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);`, + ], + ] + ) { + 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"); + + const result = await stripServerOnlyExports(code); + + assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); + assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + }); + } + + 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"); + + const result = await stripServerOnlyExports(code); + + assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); + assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + }); + // 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 @@ -2000,8 +2228,8 @@ describe("browser-server-exports-strip", () => { // 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 + // 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 = [ @@ -2066,7 +2294,7 @@ describe("browser-server-exports-strip", () => { }); // 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. + // 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";`, @@ -2517,7 +2745,7 @@ describe("browser-server-exports-strip", () => { }); // Regression (review probe): a pattern default that reads a *sibling* - // binding of the same pattern used to keep the declarator alive forever — + // 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. @@ -2555,7 +2783,7 @@ describe("browser-server-exports-strip", () => { }); // Regression (closed leak): liveness used to be decided one declaration at - // a time — "is this name mentioned anywhere else?" — so two hook-only + // 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 @@ -3247,7 +3475,7 @@ describe("browser-server-exports-strip", () => { // 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`. + // 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 () => { @@ -3455,8 +3683,8 @@ describe("browser-server-exports-strip", () => { // 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 + // 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 = [ @@ -3707,7 +3935,7 @@ 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 + // 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 @@ -3774,7 +4002,7 @@ describe("browser-server-exports-strip", () => { const result = await compileThenStrip(source, "/project/react/primitives/input-box.tsx"); - // Nothing in the module calls `InputBox` — its only consumer is the export + // 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")`); diff --git a/src/transforms/pipeline/stages/browser-server-exports-strip.ts b/src/transforms/pipeline/stages/browser-server-exports-strip.ts index b9ed73e96a..20492eb0b7 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.ts @@ -16,14 +16,14 @@ * 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 + * 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 + * 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 @@ -34,13 +34,13 @@ * * 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`, + * 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 + * 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. @@ -49,7 +49,7 @@ * "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 + * 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 @@ -62,8 +62,8 @@ * 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 + * 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. @@ -98,7 +98,7 @@ * *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 + * 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 @@ -119,11 +119,11 @@ * 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 + * 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. + * 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. @@ -132,7 +132,7 @@ * 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 + * 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`, @@ -214,6 +214,30 @@ function walk(node: Node, visit: (node: Node) => boolean | void): void { for (const child of children(node)) walk(child, visit); } +/** + * 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 TRANSPARENT_EXPRESSION_TYPES = new Set([ + "ParenthesizedExpression", + "TSAsExpression", + "TSSatisfiesExpression", + "TSNonNullExpression", + "TSInstantiationExpression", + "TSTypeAssertion", +]); + +/** 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 { if (!isNode(value)) return null; const name = value.name; @@ -490,15 +514,15 @@ function emptyServerOnlyHooks( * 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 + * 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. */ interface BindingSite { /** Every name this site binds. */ names: string[]; - /** What the site's own code reads — its outgoing edges in the graph. */ + /** 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; @@ -559,7 +583,7 @@ function declaratorReferences( * * 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 + * 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. @@ -730,8 +754,8 @@ function directLexicalBindingNames(node: Node): Set { * (`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 + * 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. @@ -848,7 +872,7 @@ const NOTHING_ELIDED: ReadonlySet = new Set(); const NO_BOUND_NAMES: ReadonlySet = new Set(); /** - * Free identifiers genuinely *read* by a subtree — the edges of the + * 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 @@ -868,7 +892,7 @@ const NO_BOUND_NAMES: ReadonlySet = new Set(); * 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 + * 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. * @@ -972,8 +996,8 @@ function freeReferencedIdentifiers( 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 + // 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 @@ -1458,7 +1482,7 @@ function hookReferencedIdentifiers(body: Node[], targets: Set): Set { * 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 + * 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. */ @@ -1635,26 +1659,42 @@ function isObjectDefineProperty(node: Node | undefined): boolean { return nodeName(node.object) === "Object" && propertyName === "defineProperty"; } -/** - * 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. An unrelated - * `registry.defineProperty` write cannot replace the intrinsic and must not - * stop compiler metadata from being removed with a hook-only binding. - */ /** * 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`, and browsers expose `frames` as a Window alias, so all - * five reach the same `Object` slot. + * 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", "frames", "global"]; +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 (isDocumentDefaultView(node, globals)) return true; return GLOBAL_OBJECT_NAMES.some((name) => isUnshadowedGlobalIdentifier(node, name, globals)); } @@ -1671,6 +1711,16 @@ function isGlobalObjectSlot(node: Node | undefined, globals: ReadonlySet): 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; @@ -1828,13 +1878,21 @@ function writesObjectDefineProperty(body: Node[], globals: ReadonlySet): b return writes; } -/** TypeScript nodes that still carry a runtime expression underneath. */ -const TS_EXPRESSION_TYPES = new Set([ - "TSAsExpression", - "TSTypeAssertion", - "TSNonNullExpression", - "TSInstantiationExpression", - "TSSatisfiesExpression", +/** + * 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", ]); /** @@ -1854,21 +1912,75 @@ function isNamePosition(parent: Node, key: string): boolean { 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; +} + +/** Names the module writes a property through, at any depth of member path. */ +function namesWrittenThrough(body: Node[]): Set { + const names = 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 name = root ? nodeName(root) : null; + if (name) names.add(name); + } + return names; +} + /** - * Whether the module reads an unshadowed intrinsic as a value instead of only - * reaching through it with a member access. + * 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: * - * `writesObjectDefineProperty` only sees assignment-shaped writes, so a module - * that hands either global to a callee replaces the helper's callee without - * ever naming a target it can recognise: - * `Object.defineProperty(Object, "defineProperty", { value: recordAndReturn })` - * redefines it through a call, and `const alias = Object; alias.defineProperty - * = recordAndReturn` redefines it through a second binding. Anything holding - * `Object`, or anyone handed `globalThis`, can rewrite `defineProperty`, so - * every genuine global value read fails closed and the module's apparent - * registrations stay ordinary user code. Lexically shadowed names do not. + * - 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 readsIntrinsicAsValue( +function intrinsicEscapesToWritableSlot( body: Node[], name: "Object" | "global", globals: ReadonlySet, @@ -1879,29 +1991,250 @@ function readsIntrinsicAsValue( isGlobalObjectSlot(entry, globals) : isUnshadowedGlobalObject(entry, globals); - const reads = (node: Node, transparentTypeof = false): boolean => { - if (node.type.startsWith("TS") && !TS_EXPRESSION_TYPES.has(node.type)) return false; + const writtenThrough = namesWrittenThrough(body); + + /** 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 name a read is bound to, when the module can track it by name. */ + const boundName = (parent: Node, key: string): string | null => { + if (parent.type === "VariableDeclarator" && key === "init") return nodeName(parent.id); + if ( + (parent.type === "AssignmentExpression" || parent.type === "AssignmentPattern") && + key === "right" && isNode(parent.left) + ) { + const left = unwrapTransparent(parent.left); + return left.type === "Identifier" ? nodeName(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 entryIsTransparentTypeof = - (node.type === "UnaryExpression" && node.operator === "typeof" && key === "argument") || - (transparentTypeof && TS_EXPRESSION_TYPES.has(node.type) && key === "expression"); - if (isIntrinsic(entry)) { - if (!entryIsTransparentTypeof && !isNamePosition(node, key)) return true; + const read = unwrapTransparent(entry); + if (isIntrinsic(read)) { + if (isNamePosition(node, key)) continue; + if (storesInPropertySlot(node, key, nested)) return true; + const bound = boundName(node, key); + if (bound !== null) { + if (writtenThrough.has(bound)) return true; + continue; + } continue; } - if (reads(entry, entryIsTransparentTypeof)) return true; + if (escapes(entry, nested)) return true; } } return false; }; - return body.some((statement) => statement.type !== "ImportDeclaration" && reads(statement)); + 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 names of every function this module immediately invokes. */ +function invokedFunctionParameterNames(body: Node[]): Set { + const names = new Set(); + const collect = (callee: unknown): void => { + if (!isNode(callee)) return; + const target = unwrapTransparent(callee); + if (target.type !== "FunctionExpression" && target.type !== "ArrowFunctionExpression") return; + for (const param of Array.isArray(target.params) ? target.params : []) { + if (isNode(param)) { for (const name of patternBoundNames(param)) names.add(name); } + } + }; + + for (const statement of body) { + if (statement.type === "ImportDeclaration") continue; + walk(statement, (node) => { + if ( + node.type === "CallExpression" || node.type === "OptionalCallExpression" || + node.type === "NewExpression" + ) { + collect(node.callee); + } + }); + } + return names; +} + +/** + * 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): boolean { + const invokedParams = invokedFunctionParameterNames(body); + + 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 name = nodeName(target); + return name !== null && !invokedParams.has(name); + }; + + 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 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 carries 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. None of these read `Object` + * as a value, so no amount of tracking reads finds them, and enumerating the + * expressions that produce a constructor has no end. The recognised set simply + * does not admit a module carrying one. + */ +function hasReflectionRoute(body: Node[], globals: ReadonlySet): boolean { + let found = false; + for (const statement of body) { + if (statement.type === "ImportDeclaration") continue; + walk(statement, (node) => { + if (found) return false; + if (node.type === "MemberExpression" || node.type === "OptionalMemberExpression") { + const key = memberKey(node); + if (key !== null && REFLECTION_KEYS.has(key)) found = true; + } + if ( + node.type === "Identifier" && globals.has(node) && + CODE_FROM_STRING_NAMES.has(nodeName(node) ?? "") + ) { + found = true; + } + return !found; + }); + if (found) break; + } + return found; +} + +/** + * 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. + */ +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); + }); + }; + + let merges = false; + for (const statement of body) { + if (statement.type === "ImportDeclaration") continue; + walk(statement, (node) => { + if (merges) return false; + if (node.type !== "CallExpression" && node.type !== "OptionalCallExpression") return true; + const callee = isNode(node.callee) ? unwrapTransparent(node.callee) : undefined; + if ( + callee?.type !== "MemberExpression" && callee?.type !== "OptionalMemberExpression" + ) return true; + const method = memberKey(callee); + if (method !== "assign" && method !== "defineProperty" && method !== "defineProperties") { + return true; + } + + const args = Array.isArray(node.arguments) ? node.arguments.filter(isNode) : []; + const targetsIntrinsic = args.some((argument) => { + const value = unwrapTransparent(argument); + return isUnshadowedGlobalIdentifier(value, "Object", globals) || + isGlobalObjectSlot(value, globals) || isUnshadowedGlobalObject(value, globals); + }); + if (targetsIntrinsic && args.some((argument) => literalCarriesGuardedKey(argument))) { + merges = true; + return false; + } + return true; + }); + if (merges) break; + } + return merges; } function returnedCall(node: Node): Node | null { @@ -1957,6 +2290,65 @@ function isNameDescriptor(node: Node | undefined, valueParam: string): boolean { 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 carries no reflection route to a constructor or a prototype, + * and no route from a string to code (`.constructor`, `__proto__`, `eval`, + * `Function`). + * 3. No property write reaches `defineProperty`, `defineProperties`, or + * `Object` through a base this stage cannot bound to a value the module + * itself made. + * 4. No `defineProperty`-shaped call and no merge of an object carrying one of + * those keys targets the intrinsic or a global object. + * 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. + * + * 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 its chain, which costs bundle size. Wrongly recognising it would delete + * a call the module observes. Neither direction can leak a server value: the + * removal of server exports and their dependency chains does not depend on + * this recognition, and the pass verifies the removed names separately. + */ /** * Bindings for esbuild's `keepNames` helper. Release modules are compiled * before the browser transform, so their declarations are followed by calls @@ -1981,8 +2373,11 @@ function compilerNameHelperBindings(body: Node[]): Set { hoisted.has("Object") || importsRuntimeObject || assignsUnshadowedGlobal(body, "Object", globals) || writesObjectDefineProperty(body, globals) || - readsIntrinsicAsValue(body, "Object", globals) || - readsIntrinsicAsValue(body, "global", globals); + writesGuardedKeyThroughUnprovenBase(body, globals) || + mergesGuardedKeyOntoIntrinsic(body, globals) || + hasReflectionRoute(body, globals) || + intrinsicEscapesToWritableSlot(body, "Object", globals) || + intrinsicEscapesToWritableSlot(body, "global", globals); if (objectIsModuleLocal) return new Set(); // A `var` may be declared more than once, and only the initialiser that ran @@ -2090,7 +2485,7 @@ function compilerNameRegistrations( * 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 + * 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. @@ -2162,7 +2557,7 @@ function hasParameterDecorators(node: Node): boolean { } /** - * `__name(, "name")` — esbuild's `keepNames` helper applied inline, the + * `__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. @@ -2175,7 +2570,7 @@ function isNameRegistrationCall(node: Node, helpers: ReadonlySet): boole return args.length === 2 && stringLiteralText(args[1]) !== null; } -/** `static { __name(this, "Loader") }` — the class form of that same metadata. */ +/** `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) => { @@ -2293,8 +2688,8 @@ function isInertExpression(node: Node | undefined, helpers: ReadonlySet) * 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 + * 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 @@ -2321,7 +2716,7 @@ function evaluationIsInert(node: Node, helpers: ReadonlySet): boolean { * 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 + * 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 @@ -2335,21 +2730,9 @@ function deferredExecutionNodes(root: Node): Set { const deferred = new Set(); const invokedFunctions = new Set(); - const unwrap = (node: Node): Node => { - let current = node; - while ( - (current.type === "ParenthesizedExpression" || current.type === "TSAsExpression" || - current.type === "TSNonNullExpression" || current.type === "TSInstantiationExpression") && - isNode(current.expression) - ) { - current = current.expression; - } - return current; - }; - const invokedChild = (node: Node): Node | null => { if (node.type === "CallExpression" && isNode(node.callee)) { - const callee = unwrap(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 @@ -2364,7 +2747,7 @@ function deferredExecutionNodes(root: Node): Set { : callee.computed !== true ? nodeName(callee.property) : null; - const target = unwrap(callee.object); + const target = unwrapTransparent(callee.object); if ( (method === "call" || method === "apply") && (target.type === "FunctionExpression" || target.type === "ArrowFunctionExpression") @@ -2375,10 +2758,10 @@ function deferredExecutionNodes(root: Node): Set { return callee; } if (node.type === "OptionalCallExpression" || node.type === "NewExpression") { - return isNode(node.callee) ? unwrap(node.callee) : null; + return isNode(node.callee) ? unwrapTransparent(node.callee) : null; } if (node.type === "TaggedTemplateExpression") { - return isNode(node.tag) ? unwrap(node.tag) : null; + return isNode(node.tag) ? unwrapTransparent(node.tag) : null; } return null; }; @@ -2410,7 +2793,7 @@ function deferredExecutionNodes(root: Node): Set { } /** - * Whether a declaration can be left out of the root computation — whether the + * 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: @@ -2424,13 +2807,13 @@ function deferredExecutionNodes(root: Node): Set { * 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 + * 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 + * 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 = @@ -2492,8 +2875,8 @@ function serverTaintedSites( /** * 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` + * 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. * @@ -2537,7 +2920,7 @@ function separateExportLocalNames(body: Node[]): Set { * * 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 + * 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 @@ -2551,7 +2934,7 @@ function separateExportLocalNames(body: Node[]): Set { * 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 + * 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: @@ -2559,7 +2942,7 @@ function separateExportLocalNames(body: Node[]): Set { * what lets `dropUnusedImportBindings` drop the import next. * * Every binding name a removal takes out is added to `removedNames`, so the - * caller can verify — fail closed — that none of them survives in the final + * 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 @@ -2620,7 +3003,7 @@ function dropUnreachableModuleScopeBindings( const removable = 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 + // 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 @@ -2633,7 +3016,7 @@ function dropUnreachableModuleScopeBindings( // 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 + // 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( @@ -2660,8 +3043,8 @@ function dropUnreachableModuleScopeBindings( } // 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 + // 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. @@ -2798,6 +3181,9 @@ interface Blocker { * closes over into the browser bundle, so the build stops instead. */ class ServerExportStripError extends Error { + /** Catalog slug, so the failure resolves to its entry and its docs page. */ + readonly slug = "server-export-strip-failed"; + constructor( filePath: string | undefined, reason: string, @@ -2899,7 +3285,7 @@ export async function stripServerOnlyExports( // top-level declarations (which may run browser side effects). const hookSeed = hookReferencedIdentifiers(body, locals); - // Fail closed on a hook this pass identified but could not stub — a class + // 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. @@ -2917,8 +3303,8 @@ export async function stripServerOnlyExports( // the imports that leaves unused. Order matters: pruning `const API_KEY = // getEnv(...)` is what makes the `veryfront` import droppable. // - // The hooks' dependency closure is itself a reachability question — a helper - // the hook reaches only through another helper belongs to it just as much — + // 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(); @@ -2957,7 +3343,7 @@ export async function stripServerOnlyExports( // 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 + // 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) { From d23da54e41faaf4ea213309af61048a3bae0d92b Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 18 Aug 2026 00:26:00 +0200 Subject: [PATCH 48/81] test(transforms): pin the shapes the allowlist must still admit A CommonJS interop marker calls `Object.defineProperty` on its own exports object and ordinary code writes computed keys on locals constantly. Neither can reach the intrinsic, so neither may put the module outside the recognised set and retain a hook-only helper with its server import. --- .../browser-server-exports-strip.test.ts | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) 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 74fe27a0de..07476816c9 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts @@ -2038,6 +2038,44 @@ describe("browser-server-exports-strip", () => { }); } + // 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 = []; } }`], + ] + ) { + 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); + }); + } + it("does not treat an eval of a replacement as compiler metadata", async () => { const code = [ `eval("globalThis.Object.defineProperty = (target) => target");`, From 10c6f8bf4c182fcfdebb88179c56f1bef0c0917a Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 18 Aug 2026 00:26:49 +0200 Subject: [PATCH 49/81] test(server): update dev dashboard error catalog counts The dashboard errors endpoint reports the catalog size and its per-category breakdown, so adding `server-export-strip-failed` moves both. --- src/server/handlers/dev/dashboard/api.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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, From 1be3d6bc7ea6eef7b4d4084d54ccc8094330895f Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 18 Aug 2026 00:32:32 +0200 Subject: [PATCH 50/81] fix(transforms): resolve helper mutations lexically --- .../browser-server-exports-strip.test.ts | 101 ++++++++++++++++++ .../stages/browser-server-exports-strip.ts | 74 +++++++++++-- 2 files changed, 165 insertions(+), 10 deletions(-) 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 07476816c9..cc0665f6dc 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts @@ -1214,6 +1214,50 @@ describe("browser-server-exports-strip", () => { 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) {`, @@ -1397,6 +1441,7 @@ describe("browser-server-exports-strip", () => { "self.Object", "frames.Object", 'window["Object"]', + "(window as any).Object", ] ) { it(`does not treat an aliased ${globalObject} intrinsic as compiler metadata`, async () => { @@ -1426,6 +1471,40 @@ describe("browser-server-exports-strip", () => { }); } + 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"); + + const result = await stripServerOnlyExports(code); + + assertStringIncludes(result, `const intrinsic = ${globalObject}`); + assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); + assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + }); + } + it("does not treat an aliased browser global as compiler metadata", async () => { const code = [ `function recordAndReturn(target) {`, @@ -1500,6 +1579,28 @@ describe("browser-server-exports-strip", () => { 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 = [ diff --git a/src/transforms/pipeline/stages/browser-server-exports-strip.ts b/src/transforms/pipeline/stages/browser-server-exports-strip.ts index 20492eb0b7..de8167426b 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.ts @@ -862,6 +862,7 @@ function moduleScopeBindingNames(body: Node[]): Set { interface LexicalScope { kind: "var" | "block"; names: Set; + module?: true; } function isLexicallyBound(name: string, scopes: LexicalScope[]): boolean { @@ -907,9 +908,14 @@ function freeReferencedIdentifiers( deferred: ReadonlySet = NOTHING_ELIDED, initiallyBound: ReadonlySet = NO_BOUND_NAMES, onFreeIdentifier?: (node: Node) => void, + onIdentifier?: (node: Node, binding: LexicalScope | undefined) => void, ): Set { const free = new Set(); - const rootScope: LexicalScope = { kind: "var", names: new Set(initiallyBound) }; + const rootScope: LexicalScope = { + kind: "var", + names: new Set(initiallyBound), + module: root.type === "Program" ? true : undefined, + }; const currentVarScope = (scopes: LexicalScope[]): LexicalScope => scopes.find((scope) => scope.kind === "var") ?? scopes[0] ?? rootScope; @@ -1282,7 +1288,9 @@ function freeReferencedIdentifiers( if (visitTsExpression(node, scopes)) return; if (node.type === "Identifier") { - addFreeName(nodeName(node), scopes, node); + const name = nodeName(node); + onIdentifier?.(node, name ? scopes.find((scope) => scope.names.has(name)) : undefined); + addFreeName(name, scopes, node); return; } @@ -1333,7 +1341,11 @@ function freeReferencedIdentifiers( if (node.type === "JSXNamespacedName") return; if (node.type === "Program" || node.type === "BlockStatement") { - const scope: LexicalScope = { kind: "block", names: new Set() }; + 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]); @@ -1486,13 +1498,12 @@ function hookReferencedIdentifiers(body: Node[], targets: Set): Set { - const assigned = new Set(); +function assignedIdentifierNodes(body: Node[]): Set { + const assigned = new Set(); const collectTargets = (target: Node): void => { if (target.type === "Identifier") { - const name = nodeName(target); - if (name) assigned.add(name); + assigned.add(target); return; } @@ -1552,6 +1563,32 @@ function assignedNames(body: Node[]): Set { 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 = … }`, @@ -1694,8 +1731,25 @@ function isDocumentDefaultView(node: Node | undefined, globals: ReadonlySet): boolean { - if (isDocumentDefaultView(node, globals)) return true; - return GLOBAL_OBJECT_NAMES.some((name) => isUnshadowedGlobalIdentifier(node, name, globals)); + 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 { @@ -2366,7 +2420,7 @@ function compilerNameHelperBindings(body: Node[]): Set { isNode(specifier) && specifier.importKind !== "type" && nodeName(specifier.local) === "Object" ) ); - const reassigned = assignedNames(body); + const reassigned = assignedModuleBindingNames(body); const hoisted = hoistedVarNames(body); const globals = unshadowedGlobalIdentifierNodes(body); const objectIsModuleLocal = moduleScopeBindingNames(body).has("Object") || From c98006e58dac8ab06f1bb770a3569fc15cab5a7e Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 18 Aug 2026 00:45:14 +0200 Subject: [PATCH 51/81] fix(transforms): preserve exported helper rebindings --- .../browser-server-exports-strip.test.ts | 53 +++++++++++++++++++ .../stages/browser-server-exports-strip.ts | 10 +++- 2 files changed, 61 insertions(+), 2 deletions(-) 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 cc0665f6dc..c1edb17a82 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts @@ -2223,6 +2223,31 @@ describe("browser-server-exports-strip", () => { 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) {`, @@ -3796,6 +3821,34 @@ describe("browser-server-exports-strip", () => { assertStringIncludes(result, "SECRET_KEY"); }); + 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"); + + const result = await stripServerOnlyExports(code, "pages/iife.ts"); + + assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + assertStringIncludes(result, "globalThis.registered = KEY"); + }); + } + for ( const [method, args] of [ ["call", "null"], diff --git a/src/transforms/pipeline/stages/browser-server-exports-strip.ts b/src/transforms/pipeline/stages/browser-server-exports-strip.ts index de8167426b..6d6a77d40c 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.ts @@ -2447,8 +2447,14 @@ function compilerNameHelperBindings(body: Node[]): Set { 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) continue; From 1cbcae9cc7f77130dece9682cd27c9458805ec05 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 18 Aug 2026 00:55:25 +0200 Subject: [PATCH 52/81] fix(transforms): close intrinsic mutation aliases --- .../browser-server-exports-strip.test.ts | 56 ++++++++++++++ .../stages/browser-server-exports-strip.ts | 73 ++++++++++++++++--- 2 files changed, 119 insertions(+), 10 deletions(-) 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 c1edb17a82..6d07d97165 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts @@ -1435,6 +1435,62 @@ describe("browser-server-exports-strip", () => { assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); }); + 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]);`, + ], + ] + ) { + 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"); + + const result = await stripServerOnlyExports(code); + + assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); + assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + }); + } + for ( const globalObject of [ "window.Object", diff --git a/src/transforms/pipeline/stages/browser-server-exports-strip.ts b/src/transforms/pipeline/stages/browser-server-exports-strip.ts index 6d6a77d40c..408602f103 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.ts @@ -1894,8 +1894,8 @@ function writesObjectDefineProperty(body: Node[], globals: ReadonlySet): b if (writes) return false; if ( - node.type === "CallExpression" && isNode(node.callee) && - isIntrinsicDefinePropertyCall(node.callee, globals) + (node.type === "CallExpression" || node.type === "OptionalCallExpression") && + isNode(node.callee) && isIntrinsicDefinePropertyCall(node.callee, globals) ) { const args = Array.isArray(node.arguments) ? node.arguments.filter(isNode) : []; const key = stringLiteralText(args[1]); @@ -2011,6 +2011,48 @@ function namesWrittenThrough(body: Node[]): Set { return names; } +/** + * Names 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 namesAliasedToWrittenThrough( + body: Node[], + writtenThrough: ReadonlySet, +): Set { + const aliases: Array<{ source: string; target: string }> = []; + for (const statement of body) { + if (statement.type === "ImportDeclaration") continue; + walk(statement, (node) => { + let left: Node | undefined; + let right: Node | undefined; + if (node.type === "VariableDeclarator") { + left = isNode(node.id) ? node.id : undefined; + right = isNode(node.init) ? node.init : undefined; + } else if (node.type === "AssignmentExpression") { + left = isNode(node.left) ? node.left : undefined; + right = isNode(node.right) ? node.right : undefined; + } + const target = left ? nodeName(unwrapTransparent(left)) : null; + const source = right ? nodeName(unwrapTransparent(right)) : 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. * @@ -2045,7 +2087,7 @@ function intrinsicEscapesToWritableSlot( isGlobalObjectSlot(entry, globals) : isUnshadowedGlobalObject(entry, globals); - const writtenThrough = namesWrittenThrough(body); + const writtenThrough = namesAliasedToWrittenThrough(body, namesWrittenThrough(body)); /** Whether storing the read at `parent[key]` puts it in a property slot. */ const storesInPropertySlot = (parent: Node, key: string, inNamespace: boolean): boolean => { @@ -2144,7 +2186,14 @@ function invokedFunctionParameterNames(body: Node[]): Set { const names = new Set(); const collect = (callee: unknown): void => { if (!isNode(callee)) return; - const target = unwrapTransparent(callee); + let target = unwrapTransparent(callee); + if ( + (target.type === "MemberExpression" || target.type === "OptionalMemberExpression") && + (memberKey(target) === "call" || memberKey(target) === "apply") && + isNode(target.object) + ) { + target = unwrapTransparent(target.object); + } if (target.type !== "FunctionExpression" && target.type !== "ArrowFunctionExpression") return; for (const param of Array.isArray(target.params) ? target.params : []) { if (isNode(param)) { for (const name of patternBoundNames(param)) names.add(name); } @@ -2275,12 +2324,16 @@ function mergesGuardedKeyOntoIntrinsic(body: Node[], globals: ReadonlySet) } const args = Array.isArray(node.arguments) ? node.arguments.filter(isNode) : []; - const targetsIntrinsic = args.some((argument) => { - const value = unwrapTransparent(argument); - return isUnshadowedGlobalIdentifier(value, "Object", globals) || - isGlobalObjectSlot(value, globals) || isUnshadowedGlobalObject(value, globals); - }); - if (targetsIntrinsic && args.some((argument) => literalCarriesGuardedKey(argument))) { + 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); + const unprovenAssignSource = method === "assign" && + sources.some((source) => unwrapTransparent(source).type !== "ObjectExpression"); + if ( + targetsIntrinsic && + (unprovenAssignSource || sources.some((source) => literalCarriesGuardedKey(source))) + ) { merges = true; return false; } From 36430c6b3b23889928f52b617c8b05cf70c05df4 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 18 Aug 2026 00:58:25 +0200 Subject: [PATCH 53/81] fix(transforms): reject unreadable descriptor maps on the intrinsic `Object.assign` was required to take a source this stage can read key by key, but `Object.defineProperties` was not, and it installs a descriptor map's keys on its target the same way. `const descriptors = { defineProperty: { value: recordAndReturn } }; Object.defineProperties(Object, descriptors)` therefore replaced the intrinsic through a name, left the module recognised as carrying compiler metadata, and deleted the later `setName(loadSecret, "loadSecret")` along with `const KEY = getEnv("SECRET_KEY")` even though the registration now calls `recordAndReturn` and is observable. Both methods take the same source rule now. The boundary doc block is brought back in line with the checks it describes: the alias closure, the `.call` and `.apply` forms of an immediately invoked function, and the merge source rule. --- .../browser-server-exports-strip.test.ts | 29 +++++++++++++++++++ .../stages/browser-server-exports-strip.ts | 19 ++++++++---- 2 files changed, 42 insertions(+), 6 deletions(-) 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 6d07d97165..482d8ec928 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts @@ -2233,6 +2233,35 @@ describe("browser-server-exports-strip", () => { }); } + // `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. + 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"); + + const result = await stripServerOnlyExports(code); + + assertStringIncludes(result, "Object.defineProperties(Object, descriptors)"); + assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); + assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + }); + it("does not treat an eval of a replacement as compiler metadata", async () => { const code = [ `eval("globalThis.Object.defineProperty = (target) => target");`, diff --git a/src/transforms/pipeline/stages/browser-server-exports-strip.ts b/src/transforms/pipeline/stages/browser-server-exports-strip.ts index 408602f103..3408357390 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.ts @@ -2328,11 +2328,15 @@ function mergesGuardedKeyOntoIntrinsic(body: Node[], globals: ReadonlySet) const targetsIntrinsic = isUnshadowedGlobalIdentifier(target, "Object", globals) || isGlobalObjectSlot(target, globals) || isUnshadowedGlobalObject(target, globals); const sources = args.slice(1); - const unprovenAssignSource = method === "assign" && + // `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 && - (unprovenAssignSource || sources.some((source) => literalCarriesGuardedKey(source))) + (unprovenSource || sources.some((source) => literalCarriesGuardedKey(source))) ) { merges = true; return false; @@ -2427,11 +2431,14 @@ function isNameDescriptor(node: Node | undefined, valueParam: string): boolean { * `Function`). * 3. No property write reaches `defineProperty`, `defineProperties`, or * `Object` through a base this stage cannot bound to a value the module - * itself made. - * 4. No `defineProperty`-shaped call and no merge of an object carrying one of - * those keys targets the intrinsic or a global object. + * itself made. A parameter of a function the module immediately invokes, + * through `.call` and `.apply` included, is not such a value. + * 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. + * 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 From e63feba83799de6d4e0e2b3aca4bba3c0eaf61cc Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 18 Aug 2026 01:06:53 +0200 Subject: [PATCH 54/81] fix(transforms): track intrinsic routes lexically --- .../browser-server-exports-strip.test.ts | 76 +++++ .../stages/browser-server-exports-strip.ts | 282 +++++++++++++++--- 2 files changed, 315 insertions(+), 43 deletions(-) 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 482d8ec928..035c7ae542 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts @@ -1464,6 +1464,35 @@ describe("browser-server-exports-strip", () => { "an apply-invoked intrinsic mutation", `(function (intrinsic) { intrinsic.defineProperty = recordAndReturn; }).apply(null, [Object]);`, ], + [ + "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 () => { @@ -1491,6 +1520,53 @@ describe("browser-server-exports-strip", () => { }); } + 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", diff --git a/src/transforms/pipeline/stages/browser-server-exports-strip.ts b/src/transforms/pipeline/stages/browser-server-exports-strip.ts index 3408357390..0dc653ac0e 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.ts @@ -909,6 +909,7 @@ function freeReferencedIdentifiers( 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 = { @@ -922,7 +923,12 @@ function freeReferencedIdentifiers( 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 addFreeName = ( @@ -1445,6 +1451,63 @@ function freeReferencedIdentifiers( 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 @@ -1810,6 +1873,63 @@ function isIntrinsicDefinePropertyCall( 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): NormalizedCall | null { + if (node.type !== "CallExpression" && node.type !== "OptionalCallExpression") return null; + if (!isNode(node.callee)) return null; + const callee = unwrapTransparent(node.callee); + const rawArgs = Array.isArray(node.arguments) ? node.arguments.filter(isNode) : []; + if (callee.type !== "MemberExpression" && callee.type !== "OptionalMemberExpression") { + return { + callee, + args: rawArgs, + unknownArgs: rawArgs.some((argument) => argument.type === "SpreadElement"), + }; + } + + const wrapper = memberKey(callee); + if ((wrapper !== "call" && wrapper !== "apply") || !isNode(callee.object)) { + return { + callee, + args: rawArgs, + unknownArgs: rawArgs.some((argument) => argument.type === "SpreadElement"), + }; + } + + const invoked = unwrapTransparent(callee.object); + if (wrapper === "call") { + const args = rawArgs.slice(1); + return { + callee: invoked, + args, + unknownArgs: rawArgs.some((argument) => argument.type === "SpreadElement"), + }; + } + + const list = rawArgs[1] ? unwrapTransparent(rawArgs[1]) : undefined; + if (list?.type !== "ArrayExpression" || !Array.isArray(list.elements)) { + return { callee: invoked, args: [], unknownArgs: true }; + } + const args: Node[] = []; + for (const element of list.elements) { + if (!isNode(element) || element.type === "SpreadElement") { + return { callee: invoked, args: [], unknownArgs: true }; + } + args.push(element); + } + return { callee: invoked, args, unknownArgs: false }; +} + function assignsUnshadowedGlobal( body: Node[], name: string, @@ -1893,11 +2013,13 @@ function writesObjectDefineProperty(body: Node[], globals: ReadonlySet): b walk(statement, (node) => { if (writes) return false; - if ( - (node.type === "CallExpression" || node.type === "OptionalCallExpression") && - isNode(node.callee) && isIntrinsicDefinePropertyCall(node.callee, globals) - ) { - const args = Array.isArray(node.arguments) ? node.arguments.filter(isNode) : []; + const invocation = normalizeCall(node); + if (invocation && isIntrinsicDefinePropertyCall(invocation.callee, globals)) { + 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); @@ -1998,43 +2120,56 @@ function memberPathRoot(node: Node): Node | null { return current.type === "Identifier" ? current : null; } -/** Names the module writes a property through, at any depth of member path. */ -function namesWrittenThrough(body: Node[]): Set { - const names = new Set(); +/** 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 name = root ? nodeName(root) : null; - if (name) names.add(name); + const binding = root ? bindings.reference(root) : null; + if (binding) written.add(binding); } - return names; + return written; } /** - * Names whose value can flow through local aliases to a property-write base. + * 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 namesAliasedToWrittenThrough( +function bindingsAliasedToWrittenThrough( body: Node[], - writtenThrough: ReadonlySet, -): Set { - const aliases: Array<{ source: string; target: string }> = []; + 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 target = left ? nodeName(unwrapTransparent(left)) : null; - const source = right ? nodeName(unwrapTransparent(right)) : null; + 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 }); }); } @@ -2080,6 +2215,7 @@ function intrinsicEscapesToWritableSlot( body: Node[], name: "Object" | "global", globals: ReadonlySet, + bindings: LexicalBindingIndex, ): boolean { const isIntrinsic = (entry: Node): boolean => name === "Object" @@ -2087,7 +2223,38 @@ function intrinsicEscapesToWritableSlot( isGlobalObjectSlot(entry, globals) : isUnshadowedGlobalObject(entry, globals); - const writtenThrough = namesAliasedToWrittenThrough(body, namesWrittenThrough(body)); + 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 => { @@ -2104,15 +2271,18 @@ function intrinsicEscapesToWritableSlot( return left.type === "MemberExpression" || left.type === "OptionalMemberExpression"; }; - /** The name a read is bound to, when the module can track it by name. */ - const boundName = (parent: Node, key: string): string | null => { - if (parent.type === "VariableDeclarator" && key === "init") return nodeName(parent.id); + /** 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" ? nodeName(left) : null; + return left.type === "Identifier" ? bindings.reference(left) : null; } return null; }; @@ -2129,10 +2299,10 @@ function intrinsicEscapesToWritableSlot( for (const entry of Array.isArray(value) ? value : [value]) { if (!isNode(entry)) continue; const read = unwrapTransparent(entry); - if (isIntrinsic(read)) { + if (expressionCanYieldIntrinsic(read)) { if (isNamePosition(node, key)) continue; if (storesInPropertySlot(node, key, nested)) return true; - const bound = boundName(node, key); + const bound = boundBinding(node, key); if (bound !== null) { if (writtenThrough.has(bound)) return true; continue; @@ -2181,9 +2351,12 @@ function memberKey(node: Node): string | null { return node.computed === true ? stringLiteralText(property) : nodeName(property); } -/** Parameter names of every function this module immediately invokes. */ -function invokedFunctionParameterNames(body: Node[]): Set { - const names = new Set(); +/** Parameter bindings of every non-generator function this module immediately invokes. */ +function invokedFunctionParameterBindings( + body: Node[], + bindings: LexicalBindingIndex, +): Set { + const invoked = new Set(); const collect = (callee: unknown): void => { if (!isNode(callee)) return; let target = unwrapTransparent(callee); @@ -2195,8 +2368,15 @@ function invokedFunctionParameterNames(body: Node[]): Set { target = unwrapTransparent(target.object); } if (target.type !== "FunctionExpression" && target.type !== "ArrowFunctionExpression") return; + // Invoking a generator only creates its iterator. Its body remains deferred + // until a later `next()`, so its parameters do not receive values here. + if (target.generator === true) return; for (const param of Array.isArray(target.params) ? target.params : []) { - if (isNode(param)) { for (const name of patternBoundNames(param)) names.add(name); } + if (!isNode(param)) continue; + for (const identifier of patternBindingIdentifiers(param)) { + const binding = bindings.declaration(identifier); + if (binding) invoked.add(binding); + } } }; @@ -2211,7 +2391,7 @@ function invokedFunctionParameterNames(body: Node[]): Set { } }); } - return names; + return invoked; } /** @@ -2226,8 +2406,12 @@ function invokedFunctionParameterNames(body: Node[]): Set { * 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): boolean { - const invokedParams = invokedFunctionParameterNames(body); +function writesGuardedKeyThroughUnprovenBase( + body: Node[], + globals: ReadonlySet, + bindings: LexicalBindingIndex, +): boolean { + const invokedParams = invokedFunctionParameterBindings(body, bindings); const baseIsProvenLocal = (base: Node): boolean => { const target = unwrapTransparent(base); @@ -2236,8 +2420,8 @@ function writesGuardedKeyThroughUnprovenBase(body: Node[], globals: ReadonlySet< // 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 name = nodeName(target); - return name !== null && !invokedParams.has(name); + const binding = bindings.reference(target); + return binding !== null && !invokedParams.has(binding); }; for (const target of propertyWriteTargets(body)) { @@ -2313,8 +2497,9 @@ function mergesGuardedKeyOntoIntrinsic(body: Node[], globals: ReadonlySet) if (statement.type === "ImportDeclaration") continue; walk(statement, (node) => { if (merges) return false; - if (node.type !== "CallExpression" && node.type !== "OptionalCallExpression") return true; - const callee = isNode(node.callee) ? unwrapTransparent(node.callee) : undefined; + const invocation = normalizeCall(node); + if (!invocation) return true; + const callee = invocation.callee; if ( callee?.type !== "MemberExpression" && callee?.type !== "OptionalMemberExpression" ) return true; @@ -2323,7 +2508,16 @@ function mergesGuardedKeyOntoIntrinsic(body: Node[], globals: ReadonlySet) return true; } - const args = Array.isArray(node.arguments) ? node.arguments.filter(isNode) : []; + if (invocation.unknownArgs) { + const owner = isNode(callee.object) ? unwrapTransparent(callee.object) : undefined; + if (isUnshadowedGlobalIdentifier(owner, "Object", globals)) { + merges = true; + return false; + } + return true; + } + + const args = invocation.args; const target = args[0] ? unwrapTransparent(args[0]) : undefined; const targetsIntrinsic = isUnshadowedGlobalIdentifier(target, "Object", globals) || isGlobalObjectSlot(target, globals) || isUnshadowedGlobalObject(target, globals); @@ -2431,8 +2625,9 @@ function isNameDescriptor(node: Node | undefined, valueParam: string): boolean { * `Function`). * 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 function the module immediately invokes, - * through `.call` and `.apply` included, is not such a value. + * 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. @@ -2483,15 +2678,16 @@ function compilerNameHelperBindings(body: Node[]): Set { const reassigned = assignedModuleBindingNames(body); const hoisted = hoistedVarNames(body); const globals = unshadowedGlobalIdentifierNodes(body); + const bindings = indexLexicalBindings(body); const objectIsModuleLocal = moduleScopeBindingNames(body).has("Object") || hoisted.has("Object") || importsRuntimeObject || assignsUnshadowedGlobal(body, "Object", globals) || writesObjectDefineProperty(body, globals) || - writesGuardedKeyThroughUnprovenBase(body, globals) || + writesGuardedKeyThroughUnprovenBase(body, globals, bindings) || mergesGuardedKeyOntoIntrinsic(body, globals) || hasReflectionRoute(body, globals) || - intrinsicEscapesToWritableSlot(body, "Object", globals) || - intrinsicEscapesToWritableSlot(body, "global", globals); + intrinsicEscapesToWritableSlot(body, "Object", globals, bindings) || + intrinsicEscapesToWritableSlot(body, "global", globals, bindings); if (objectIsModuleLocal) return new Set(); // A `var` may be declared more than once, and only the initialiser that ran From b4221815820b83080ca75a407c54703594452bce Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 18 Aug 2026 01:37:22 +0200 Subject: [PATCH 55/81] fix(transforms): resolve var aliases and nested invocation wrappers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three more findings on the recognised set, two of them routes that reach the intrinsic and one a false positive that retained a helper for nothing. - A module-scope `var` is bound twice: once where the enclosing body prebinds its direct declarations and again in the var scope when the declaration is visited. The lexical index keeps the second, while a reference resolves to the first scope up the chain that holds the name, so `var intrinsic = Object; intrinsic.defineProperty = recordAndReturn` had a write that never met its own declaration and the module stayed inside the recognised set. `intrinsicAliasWrittenThrough` is the backstop: names the module itself binds, closed over their aliases, against writes whose root resolves to the module scope. Matching by name is coarser than by binding, so it is fenced to module-scope names and module-scope references, which keeps a shadowing parameter (`function configure(alias) { alias.other = 1 }`) out of it. It can only add rejections, so its imprecision costs a helper kept, never a call deleted. Also covers the `var` declared inside a block and the alias written through from a function body. - `Object.defineProperty.call.call(Object.defineProperty, null, Object, "defineProperty", …)` invokes the intrinsic with one more receiver peeled off, but `normalizeCall` removed a single wrapper and returned `Object.defineProperty.call` as the callee, so the mutation was invisible. Wrappers are peeled until what is left is not another `call` or `apply`. - A spread made the merge check discard a statically known target and reject on the callee owner alone, so `Object.assign.call(null, {}, ...[])` retained the helper, its hook-only initialiser, and its server import even though the merge lands on a fresh local. A spread hides how many sources follow, not what the target is when the target is written out. Verified that the shapes real modules contain are unaffected: a read-only `window` alias at module scope and inside a `useEffect`, a CommonJS interop marker, `Object.assign(globalThis, { __DEV__: true })`, a local `ref.current` write, and `var box = {}; box.defineProperty = …` on a plain local all still strip. --- .../browser-server-exports-strip.test.ts | 140 ++++++++++++++ .../stages/browser-server-exports-strip.ts | 175 +++++++++++++----- 2 files changed, 272 insertions(+), 43 deletions(-) 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 035c7ae542..0fa383aad6 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts @@ -2312,6 +2312,146 @@ describe("browser-server-exports-strip", () => { // `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();`, + ], + ], + ] + ) { + 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"); + + const result = await stripServerOnlyExports(code); + + assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); + assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + }); + } + + // `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"); + + const result = await stripServerOnlyExports(code); + + assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); + assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + }); + } + + // 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"); + + const result = await stripServerOnlyExports(code); + + assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); + assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + }); + it("does not treat named descriptors installed on the intrinsic as compiler metadata", async () => { const code = [ `function recordAndReturn(target) {`, diff --git a/src/transforms/pipeline/stages/browser-server-exports-strip.ts b/src/transforms/pipeline/stages/browser-server-exports-strip.ts index 0dc653ac0e..3012118549 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.ts @@ -1887,47 +1887,43 @@ interface NormalizedCall { function normalizeCall(node: Node): NormalizedCall | null { if (node.type !== "CallExpression" && node.type !== "OptionalCallExpression") return null; if (!isNode(node.callee)) return null; - const callee = unwrapTransparent(node.callee); - const rawArgs = Array.isArray(node.arguments) ? node.arguments.filter(isNode) : []; - if (callee.type !== "MemberExpression" && callee.type !== "OptionalMemberExpression") { - return { - callee, - args: rawArgs, - unknownArgs: rawArgs.some((argument) => argument.type === "SpreadElement"), - }; - } - - const wrapper = memberKey(callee); - if ((wrapper !== "call" && wrapper !== "apply") || !isNode(callee.object)) { - return { - callee, - args: rawArgs, - unknownArgs: rawArgs.some((argument) => argument.type === "SpreadElement"), - }; - } - - const invoked = unwrapTransparent(callee.object); - if (wrapper === "call") { - const args = rawArgs.slice(1); - return { - callee: invoked, - args, - unknownArgs: rawArgs.some((argument) => argument.type === "SpreadElement"), - }; - } - - const list = rawArgs[1] ? unwrapTransparent(rawArgs[1]) : undefined; - if (list?.type !== "ArrayExpression" || !Array.isArray(list.elements)) { - return { callee: invoked, args: [], unknownArgs: true }; - } - const args: Node[] = []; - for (const element of list.elements) { - if (!isNode(element) || element.type === "SpreadElement") { + + 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); + 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 }; } - args.push(element); + 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: invoked, args, unknownArgs: false }; + + return { callee, args, unknownArgs }; } function assignsUnshadowedGlobal( @@ -2120,6 +2116,92 @@ function memberPathRoot(node: Node): Node | null { return current.type === "Identifier" ? current : null; } +/** + * Whether a name the module binds the intrinsic to is one it writes a property + * through, matched by name rather than by lexical binding. + * + * `intrinsicEscapesToWritableSlot` resolves both halves to a concrete binding, + * which is the precise answer and the one that keeps a shadowed name from + * counting. It is also the answer that disappears when the two halves resolve + * to different scopes: a module-scope `var` is bound once where the enclosing + * body prebinds its direct declarations and again in the var scope, so + * `var intrinsic = Object; intrinsic.defineProperty = record` had a write that + * never met its declaration and the module stayed inside the recognised set. + * + * This is the coarse backstop for that: a name bound to the intrinsic anywhere, + * closed over its own aliases, and written through anywhere. It can only add + * rejections, so the cost of its imprecision is a helper kept, never a call + * deleted. A name that is never bound to an unshadowed intrinsic read is not in + * the set at all, which is what keeps `function configure(Object) { Object + * .defineProperty = … }` out of it. + */ +function intrinsicAliasWrittenThrough( + body: Node[], + globals: ReadonlySet, + bindings: LexicalBindingIndex, +): boolean { + const isIntrinsic = (node: Node): boolean => + isUnshadowedGlobalIdentifier(node, "Object", globals) || + isGlobalObjectSlot(node, globals) || isUnshadowedGlobalObject(node, globals); + + // Only names the module itself binds are in play, so a nested local that + // happens to share one of them cannot put its own value into the set. + const moduleNames = moduleScopeBindingNames(body); + for (const name of hoistedVarNames(body)) moduleNames.add(name); + + const writtenThrough = 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 name = root ? nodeName(root) : null; + if (!name || !moduleNames.has(name)) continue; + // The write has to reach the module's own binding. A parameter or a local + // that shadows the name reaches something else entirely, and the lexical + // index is what tells the two apart. + if (bindings.reference(root as Node)?.scope.module !== true) continue; + writtenThrough.add(name); + } + if (writtenThrough.size === 0) return false; + + const aliasBindings: Array<{ name: string; value: Node }> = []; + for (const statement of body) { + if (statement.type === "ImportDeclaration") continue; + walk(statement, (node) => { + if (node.type === "VariableDeclarator" && isNode(node.init) && isNode(node.id)) { + const name = nodeName(unwrapTransparent(node.id)); + if (name && moduleNames.has(name)) aliasBindings.push({ name, value: node.init }); + } + if ( + (node.type === "AssignmentExpression" || node.type === "AssignmentPattern") && + isNode(node.left) && isNode(node.right) + ) { + const left = unwrapTransparent(node.left); + const name = left.type === "Identifier" ? nodeName(left) : null; + if (name && moduleNames.has(name)) aliasBindings.push({ name, value: node.right }); + } + }); + } + + const derived = new Set(); + let grew = true; + while (grew) { + grew = false; + for (const binding of aliasBindings) { + if (derived.has(binding.name)) continue; + const value = unwrapTransparent(binding.value); + const carries = isIntrinsic(value) || + (value.type === "Identifier" && derived.has(nodeName(value) ?? "")); + if (!carries) continue; + derived.add(binding.name); + grew = true; + } + } + + for (const name of derived) if (writtenThrough.has(name)) return true; + return false; +} + /** Bindings the module writes a property through, at any depth of member path. */ function bindingsWrittenThrough( body: Node[], @@ -2510,11 +2592,17 @@ function mergesGuardedKeyOntoIntrinsic(body: Node[], globals: ReadonlySet) if (invocation.unknownArgs) { const owner = isNode(callee.object) ? unwrapTransparent(callee.object) : undefined; - if (isUnshadowedGlobalIdentifier(owner, "Object", globals)) { - merges = true; - return false; + 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; } - return true; + merges = true; + return false; } const args = invocation.args; @@ -2687,7 +2775,8 @@ function compilerNameHelperBindings(body: Node[]): Set { mergesGuardedKeyOntoIntrinsic(body, globals) || hasReflectionRoute(body, globals) || intrinsicEscapesToWritableSlot(body, "Object", globals, bindings) || - intrinsicEscapesToWritableSlot(body, "global", globals, bindings); + intrinsicEscapesToWritableSlot(body, "global", globals, bindings) || + intrinsicAliasWrittenThrough(body, globals, bindings); if (objectIsModuleLocal) return new Set(); // A `var` may be declared more than once, and only the initialiser that ran From 6049e3c2094f7f0cc788e4cca1cb8275e8b83497 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 18 Aug 2026 01:47:16 +0200 Subject: [PATCH 56/81] fix(transforms): close intrinsic invocation gaps --- .../browser-server-exports-strip.test.ts | 66 +++++++- .../stages/browser-server-exports-strip.ts | 158 +++++++----------- 2 files changed, 120 insertions(+), 104 deletions(-) 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 0fa383aad6..24b6511ca3 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts @@ -2285,6 +2285,14 @@ describe("browser-server-exports-strip", () => { ["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 () => { @@ -2309,6 +2317,58 @@ describe("browser-server-exports-strip", () => { }); } + 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 }])`, + ], + [ + "an immediately advanced generator mutation", + [ + `(function* (intrinsic) {`, + ` intrinsic.defineProperty = recordAndReturn;`, + `})(Object).next();`, + ].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"); + + const result = await stripServerOnlyExports(code); + + assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); + assertStringIncludes(result, `const KEY = getEnv("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. @@ -2336,7 +2396,7 @@ describe("browser-server-exports-strip", () => { `applyPatch();`, ], ], - ] + ] as const ) { it(`does not treat ${label} as compiler metadata`, async () => { const code = [ @@ -4434,10 +4494,6 @@ describe("browser-server-exports-strip", () => { // cases compile first, so a regression that only shows up after esbuild // cannot pass unnoticed. describe("compiled input", () => { - afterAll(async () => { - await stopEsbuild(); - }); - function ctx(code: string, filePath: string): TransformContext { return { code, diff --git a/src/transforms/pipeline/stages/browser-server-exports-strip.ts b/src/transforms/pipeline/stages/browser-server-exports-strip.ts index 3012118549..266e0f8e0b 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.ts @@ -966,6 +966,11 @@ function freeReferencedIdentifiers( continue; } 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); } @@ -1447,6 +1452,7 @@ function freeReferencedIdentifiers( }; bindDirectDeclarations(rootScope, root); + bindNestedVarDeclarations(rootScope, root); visit(root, [rootScope]); return free; } @@ -1884,7 +1890,7 @@ interface NormalizedCall { * `.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): NormalizedCall | null { +function normalizeCall(node: Node, globals: ReadonlySet): NormalizedCall | null { if (node.type !== "CallExpression" && node.type !== "OptionalCallExpression") return null; if (!isNode(node.callee)) return null; @@ -1901,6 +1907,32 @@ function normalizeCall(node: Node): NormalizedCall | null { 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; @@ -2009,7 +2041,7 @@ function writesObjectDefineProperty(body: Node[], globals: ReadonlySet): b walk(statement, (node) => { if (writes) return false; - const invocation = normalizeCall(node); + const invocation = normalizeCall(node, globals); if (invocation && isIntrinsicDefinePropertyCall(invocation.callee, globals)) { if (invocation.unknownArgs) { writes = true; @@ -2021,8 +2053,8 @@ function writesObjectDefineProperty(body: Node[], globals: ReadonlySet): b isGlobalObjectSlot(args[0], globals); const targetIsGlobal = isUnshadowedGlobalObject(args[0], globals); if ( - (targetIsObject && key === "defineProperty") || - (targetIsGlobal && key === "Object") + (targetIsObject && (key === null || key === "defineProperty")) || + (targetIsGlobal && (key === null || key === "Object")) ) { writes = true; return false; @@ -2116,92 +2148,6 @@ function memberPathRoot(node: Node): Node | null { return current.type === "Identifier" ? current : null; } -/** - * Whether a name the module binds the intrinsic to is one it writes a property - * through, matched by name rather than by lexical binding. - * - * `intrinsicEscapesToWritableSlot` resolves both halves to a concrete binding, - * which is the precise answer and the one that keeps a shadowed name from - * counting. It is also the answer that disappears when the two halves resolve - * to different scopes: a module-scope `var` is bound once where the enclosing - * body prebinds its direct declarations and again in the var scope, so - * `var intrinsic = Object; intrinsic.defineProperty = record` had a write that - * never met its declaration and the module stayed inside the recognised set. - * - * This is the coarse backstop for that: a name bound to the intrinsic anywhere, - * closed over its own aliases, and written through anywhere. It can only add - * rejections, so the cost of its imprecision is a helper kept, never a call - * deleted. A name that is never bound to an unshadowed intrinsic read is not in - * the set at all, which is what keeps `function configure(Object) { Object - * .defineProperty = … }` out of it. - */ -function intrinsicAliasWrittenThrough( - body: Node[], - globals: ReadonlySet, - bindings: LexicalBindingIndex, -): boolean { - const isIntrinsic = (node: Node): boolean => - isUnshadowedGlobalIdentifier(node, "Object", globals) || - isGlobalObjectSlot(node, globals) || isUnshadowedGlobalObject(node, globals); - - // Only names the module itself binds are in play, so a nested local that - // happens to share one of them cannot put its own value into the set. - const moduleNames = moduleScopeBindingNames(body); - for (const name of hoistedVarNames(body)) moduleNames.add(name); - - const writtenThrough = 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 name = root ? nodeName(root) : null; - if (!name || !moduleNames.has(name)) continue; - // The write has to reach the module's own binding. A parameter or a local - // that shadows the name reaches something else entirely, and the lexical - // index is what tells the two apart. - if (bindings.reference(root as Node)?.scope.module !== true) continue; - writtenThrough.add(name); - } - if (writtenThrough.size === 0) return false; - - const aliasBindings: Array<{ name: string; value: Node }> = []; - for (const statement of body) { - if (statement.type === "ImportDeclaration") continue; - walk(statement, (node) => { - if (node.type === "VariableDeclarator" && isNode(node.init) && isNode(node.id)) { - const name = nodeName(unwrapTransparent(node.id)); - if (name && moduleNames.has(name)) aliasBindings.push({ name, value: node.init }); - } - if ( - (node.type === "AssignmentExpression" || node.type === "AssignmentPattern") && - isNode(node.left) && isNode(node.right) - ) { - const left = unwrapTransparent(node.left); - const name = left.type === "Identifier" ? nodeName(left) : null; - if (name && moduleNames.has(name)) aliasBindings.push({ name, value: node.right }); - } - }); - } - - const derived = new Set(); - let grew = true; - while (grew) { - grew = false; - for (const binding of aliasBindings) { - if (derived.has(binding.name)) continue; - const value = unwrapTransparent(binding.value); - const carries = isIntrinsic(value) || - (value.type === "Identifier" && derived.has(nodeName(value) ?? "")); - if (!carries) continue; - derived.add(binding.name); - grew = true; - } - } - - for (const name of derived) if (writtenThrough.has(name)) return true; - return false; -} - /** Bindings the module writes a property through, at any depth of member path. */ function bindingsWrittenThrough( body: Node[], @@ -2433,16 +2379,16 @@ function memberKey(node: Node): string | null { return node.computed === true ? stringLiteralText(property) : nodeName(property); } -/** Parameter bindings of every non-generator function this module immediately invokes. */ +/** Parameter bindings whose function bodies this module immediately executes. */ function invokedFunctionParameterBindings( body: Node[], bindings: LexicalBindingIndex, ): Set { const invoked = new Set(); - const collect = (callee: unknown): void => { + const collect = (callee: unknown, runGenerator: boolean): void => { if (!isNode(callee)) return; let target = unwrapTransparent(callee); - if ( + while ( (target.type === "MemberExpression" || target.type === "OptionalMemberExpression") && (memberKey(target) === "call" || memberKey(target) === "apply") && isNode(target.object) @@ -2451,8 +2397,8 @@ function invokedFunctionParameterBindings( } if (target.type !== "FunctionExpression" && target.type !== "ArrowFunctionExpression") return; // Invoking a generator only creates its iterator. Its body remains deferred - // until a later `next()`, so its parameters do not receive values here. - if (target.generator === true) return; + // until `next()` advances that exact call result. + if (target.generator === true && !runGenerator) return; for (const param of Array.isArray(target.params) ? target.params : []) { if (!isNode(param)) continue; for (const identifier of patternBindingIdentifiers(param)) { @@ -2469,7 +2415,22 @@ function invokedFunctionParameterBindings( node.type === "CallExpression" || node.type === "OptionalCallExpression" || node.type === "NewExpression" ) { - collect(node.callee); + const callee = isNode(node.callee) ? unwrapTransparent(node.callee) : undefined; + if ( + callee && + (callee.type === "MemberExpression" || callee.type === "OptionalMemberExpression") && + memberKey(callee) === "next" && isNode(callee.object) + ) { + const iterator = unwrapTransparent(callee.object); + if ( + (iterator.type === "CallExpression" || + iterator.type === "OptionalCallExpression") && + isNode(iterator.callee) + ) { + collect(iterator.callee, true); + } + } + collect(node.callee, false); } }); } @@ -2579,7 +2540,7 @@ function mergesGuardedKeyOntoIntrinsic(body: Node[], globals: ReadonlySet) if (statement.type === "ImportDeclaration") continue; walk(statement, (node) => { if (merges) return false; - const invocation = normalizeCall(node); + const invocation = normalizeCall(node, globals); if (!invocation) return true; const callee = invocation.callee; if ( @@ -2775,8 +2736,7 @@ function compilerNameHelperBindings(body: Node[]): Set { mergesGuardedKeyOntoIntrinsic(body, globals) || hasReflectionRoute(body, globals) || intrinsicEscapesToWritableSlot(body, "Object", globals, bindings) || - intrinsicEscapesToWritableSlot(body, "global", globals, bindings) || - intrinsicAliasWrittenThrough(body, globals, bindings); + intrinsicEscapesToWritableSlot(body, "global", globals, bindings); if (objectIsModuleLocal) return new Set(); // A `var` may be declared more than once, and only the initialiser that ran From 1d6ef35a30ce792901c86c58c3961af727d6d64f Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 18 Aug 2026 01:58:48 +0200 Subject: [PATCH 57/81] fix(transforms): follow invoked intrinsic routes --- .../browser-server-exports-strip.test.ts | 46 +++++++ .../stages/browser-server-exports-strip.ts | 122 ++++++++++++++++-- 2 files changed, 160 insertions(+), 8 deletions(-) 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 24b6511ca3..508544a822 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts @@ -2334,6 +2334,14 @@ describe("browser-server-exports-strip", () => { `Object.defineProperty, null, ` + `[Object, "defineProperty", { value: recordAndReturn }])`, ], + [ + "a Reflect.apply function-literal mutation", + [ + `Reflect.apply(function (intrinsic) {`, + ` intrinsic.defineProperty = recordAndReturn;`, + `}, null, [Object]);`, + ].join("\n"), + ], [ "an immediately advanced generator mutation", [ @@ -2342,6 +2350,44 @@ describe("browser-server-exports-strip", () => { `})(Object).next();`, ].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"), + ], + [ + "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"), + ], ] ) { it(`does not treat ${label} as compiler metadata`, async () => { diff --git a/src/transforms/pipeline/stages/browser-server-exports-strip.ts b/src/transforms/pipeline/stages/browser-server-exports-strip.ts index 266e0f8e0b..94c36157f8 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.ts @@ -1958,6 +1958,87 @@ function normalizeCall(node: Node, globals: ReadonlySet): NormalizedCall | 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 }> = []; + 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?.type !== "Identifier" || !value) return; + const target = declaration + ? bindings.declaration(targetNode) + : bindings.reference(targetNode); + if (target) flows.push({ target, value }); + }); + } + + const aliases = new Set(); + 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 === "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, @@ -2010,7 +2091,12 @@ function assignsUnshadowedGlobal( return assigns; } -function writesObjectDefineProperty(body: Node[], globals: ReadonlySet): boolean { +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) @@ -2042,7 +2128,14 @@ function writesObjectDefineProperty(body: Node[], globals: ReadonlySet): b if (writes) return false; const invocation = normalizeCall(node, globals); - if (invocation && isIntrinsicDefinePropertyCall(invocation.callee, 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; @@ -2382,6 +2475,7 @@ function memberKey(node: Node): string | null { /** Parameter bindings whose function bodies this module immediately executes. */ function invokedFunctionParameterBindings( body: Node[], + globals: ReadonlySet, bindings: LexicalBindingIndex, ): Set { const invoked = new Set(); @@ -2408,13 +2502,19 @@ function invokedFunctionParameterBindings( } }; + const collectInvocation = (value: Node, runGenerator: boolean): void => { + const call = unwrapTransparent(value); + const invocation = normalizeCall(call, globals); + if (invocation) collect(invocation.callee, runGenerator); + }; + for (const statement of body) { if (statement.type === "ImportDeclaration") continue; walk(statement, (node) => { if ( - node.type === "CallExpression" || node.type === "OptionalCallExpression" || - node.type === "NewExpression" + node.type === "CallExpression" || node.type === "OptionalCallExpression" ) { + collectInvocation(node, false); const callee = isNode(node.callee) ? unwrapTransparent(node.callee) : undefined; if ( callee && @@ -2427,10 +2527,16 @@ function invokedFunctionParameterBindings( iterator.type === "OptionalCallExpression") && isNode(iterator.callee) ) { - collect(iterator.callee, true); + collectInvocation(iterator, true); } } - collect(node.callee, false); + } + if (node.type === "NewExpression") collect(node.callee, false); + if (node.type === "SpreadElement" && isNode(node.argument)) { + collectInvocation(node.argument, true); + } + if (node.type === "ForOfStatement" && isNode(node.right)) { + collectInvocation(node.right, true); } }); } @@ -2454,7 +2560,7 @@ function writesGuardedKeyThroughUnprovenBase( globals: ReadonlySet, bindings: LexicalBindingIndex, ): boolean { - const invokedParams = invokedFunctionParameterBindings(body, bindings); + const invokedParams = invokedFunctionParameterBindings(body, globals, bindings); const baseIsProvenLocal = (base: Node): boolean => { const target = unwrapTransparent(base); @@ -2731,7 +2837,7 @@ function compilerNameHelperBindings(body: Node[]): Set { const objectIsModuleLocal = moduleScopeBindingNames(body).has("Object") || hoisted.has("Object") || importsRuntimeObject || assignsUnshadowedGlobal(body, "Object", globals) || - writesObjectDefineProperty(body, globals) || + writesObjectDefineProperty(body, globals, bindings) || writesGuardedKeyThroughUnprovenBase(body, globals, bindings) || mergesGuardedKeyOntoIntrinsic(body, globals) || hasReflectionRoute(body, globals) || From 60ab605a61201289b38453483db8a0f67499dfba Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 18 Aug 2026 02:09:04 +0200 Subject: [PATCH 58/81] fix(transforms): cover destructured intrinsic routes --- .../browser-server-exports-strip.test.ts | 36 +++++++++ .../stages/browser-server-exports-strip.ts | 79 ++++++++++++++++++- 2 files changed, 114 insertions(+), 1 deletion(-) 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 508544a822..2f8fe5e78f 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts @@ -2342,6 +2342,14 @@ describe("browser-server-exports-strip", () => { `}, 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", [ @@ -2366,6 +2374,27 @@ describe("browser-server-exports-strip", () => { `})(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 assignment-destructuring-consumed generator mutation", + [ + `let unused;`, + `[unused] = (function* (intrinsic) {`, + ` intrinsic.defineProperty = recordAndReturn;`, + ` yield 1;`, + `})(Object);`, + `void unused;`, + ].join("\n"), + ], [ "an aliased intrinsic mutator", [ @@ -2388,6 +2417,13 @@ describe("browser-server-exports-strip", () => { `mutate(Object, "defineProperty", { value: recordAndReturn });`, ].join("\n"), ], + [ + "a destructured intrinsic mutator alias", + [ + `const { defineProperty: mutate } = Object;`, + `mutate(Object, "defineProperty", { value: recordAndReturn });`, + ].join("\n"), + ], ] ) { it(`does not treat ${label} as compiler metadata`, async () => { diff --git a/src/transforms/pipeline/stages/browser-server-exports-strip.ts b/src/transforms/pipeline/stages/browser-server-exports-strip.ts index 94c36157f8..408d3de4c8 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.ts @@ -1965,6 +1965,35 @@ function intrinsicDefinePropertyAliases( bindings: LexicalBindingIndex, ): Set { const flows: Array<{ target: LexicalBindingIdentity; value: Node }> = []; + const aliases = new Set(); + const collectDestructured = ( + pattern: Node, + valueNode: Node, + declaration: boolean, + ): void => { + if (pattern.type !== "ObjectPattern") return; + const value = unwrapTransparent(valueNode); + if ( + !isUnshadowedGlobalIdentifier(value, "Object", globals) && + !isGlobalObjectSlot(value, globals) + ) 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) => { @@ -1979,6 +2008,7 @@ function intrinsicDefinePropertyAliases( targetNode = isNode(node.left) ? unwrapTransparent(node.left) : undefined; value = isNode(node.right) ? node.right : undefined; } + if (targetNode && value) collectDestructured(targetNode, value, declaration); if (targetNode?.type !== "Identifier" || !value) return; const target = declaration ? bindings.declaration(targetNode) @@ -1987,7 +2017,6 @@ function intrinsicDefinePropertyAliases( }); } - const aliases = new Set(); const carriesIntrinsic = (node: Node): boolean => { const value = unwrapTransparent(node); if (isIntrinsicDefinePropertyCall(value, globals)) return true; @@ -2489,6 +2518,41 @@ function invokedFunctionParameterBindings( ) { target = unwrapTransparent(target.object); } + if (target.type === "SequenceExpression") { + const expressions = Array.isArray(target.expressions) + ? target.expressions.filter(isNode) + : []; + const last = expressions.at(-1); + if (last) collect(last, runGenerator); + return; + } + if (target.type === "ConditionalExpression") { + if (isNode(target.consequent)) collect(target.consequent, runGenerator); + if (isNode(target.alternate)) collect(target.alternate, runGenerator); + return; + } + if (target.type === "LogicalExpression") { + if (target.operator !== "&&" && isNode(target.left)) collect(target.left, runGenerator); + if (isNode(target.right)) collect(target.right, runGenerator); + return; + } + if (target.type === "AssignmentExpression" && isNode(target.right)) { + collect(target.right, runGenerator); + 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); + return; + } + } if (target.type !== "FunctionExpression" && target.type !== "ArrowFunctionExpression") return; // Invoking a generator only creates its iterator. Its body remains deferred // until `next()` advances that exact call result. @@ -2538,6 +2602,19 @@ function invokedFunctionParameterBindings( if (node.type === "ForOfStatement" && isNode(node.right)) { collectInvocation(node.right, true); } + if ( + node.type === "VariableDeclarator" && isNode(node.id) && + node.id.type === "ArrayPattern" && isNode(node.init) + ) { + collectInvocation(node.init, true); + } + if ( + node.type === "AssignmentExpression" && isNode(node.left) && + (node.left.type === "ArrayPattern" || node.left.type === "ArrayExpression") && + isNode(node.right) + ) { + collectInvocation(node.right, true); + } }); } return invoked; From 8a966bd43a9c9a21b6427cd98b48553f24b9dc01 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 18 Aug 2026 02:10:34 +0200 Subject: [PATCH 59/81] docs(data): keep hook guidance self-contained --- docs/guides/data-fetching.md | 4 ++-- src/errors/catalog/build-errors.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/guides/data-fetching.md b/docs/guides/data-fetching.md index 1f81dad731..df6df14af3 100644 --- a/docs/guides/data-fetching.md +++ b/docs/guides/data-fetching.md @@ -55,11 +55,11 @@ module as a function declaration or as an initializer on a `const`, `let`, or ```tsx // Supported export async function getServerData(ctx: DataContext) { - return { props: await load(ctx) }; + return { props: { query: ctx.query.toString() } }; } // Also supported -export const getStaticData = async () => ({ props: await load() }); +export const getStaticData = async () => ({ props: { generated: true } }); ``` These forms have no declaration to empty and fail the build with diff --git a/src/errors/catalog/build-errors.ts b/src/errors/catalog/build-errors.ts index 143d3285a9..06c170c767 100644 --- a/src/errors/catalog/build-errors.ts +++ b/src/errors/catalog/build-errors.ts @@ -118,11 +118,11 @@ title: My Post "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 in the route module as a function or an arrow initializer", + "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", - "Move a value the browser also needs into a module the hook imports", + "Keep a browser-needed value in a client-referenced module before importing it into the hook", ], tips: [ "The error message names the export and the declaration form that blocked the removal", From 4f88f0b9134f8e3ab5207dd98e4d4a0fc5eaf5e1 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 18 Aug 2026 02:12:37 +0200 Subject: [PATCH 60/81] test(errors): cover server export remediation --- src/errors/catalog/build-errors.test.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/errors/catalog/build-errors.test.ts b/src/errors/catalog/build-errors.test.ts index fd9ca6cb1a..caf8ef2efd 100644 --- a/src/errors/catalog/build-errors.test.ts +++ b/src/errors/catalog/build-errors.test.ts @@ -53,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); + }); }); }); From dfac8e77def9f43d1d1ed716d6030b197484419a Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 18 Aug 2026 02:25:16 +0200 Subject: [PATCH 61/81] fix(transforms): follow intrinsic iterator flows --- .../browser-server-exports-strip.test.ts | 87 +++++++ .../stages/browser-server-exports-strip.ts | 223 +++++++++++++++--- 2 files changed, 275 insertions(+), 35 deletions(-) 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 2f8fe5e78f..e3267862c9 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts @@ -1464,6 +1464,15 @@ describe("browser-server-exports-strip", () => { "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 intrinsic mutation invoked through call", `Object.defineProperty.call(null, Object, "defineProperty", { value: recordAndReturn });`, @@ -2358,6 +2367,45 @@ describe("browser-server-exports-strip", () => { `})(Object).next();`, ].join("\n"), ], + [ + "a stored and advanced generator mutation", + [ + `const iterator = (function* (intrinsic) {`, + ` intrinsic.defineProperty = recordAndReturn;`, + `})(Object);`, + `iterator.next();`, + ].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 spread-consumed generator mutation", [ @@ -2384,6 +2432,22 @@ describe("browser-server-exports-strip", () => { `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", [ @@ -2424,6 +2488,29 @@ describe("browser-server-exports-strip", () => { `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"), + ], ] ) { it(`does not treat ${label} as compiler metadata`, async () => { diff --git a/src/transforms/pipeline/stages/browser-server-exports-strip.ts b/src/transforms/pipeline/stages/browser-server-exports-strip.ts index 408d3de4c8..e21c7048e2 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.ts @@ -1132,8 +1132,13 @@ function freeReferencedIdentifiers( const visitFunction = (node: Node, scopes: LexicalScope[]): void => { const functionScope: LexicalScope = { kind: "var", names: new Set() }; const isDeferred = deferred.has(node); - if (node.type === "FunctionDeclaration") bindPatternNames(scopes[0] ?? rootScope, node.id); - bindPatternNames(functionScope, node.id); + 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); @@ -1965,18 +1970,10 @@ function intrinsicDefinePropertyAliases( 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, - valueNode: Node, - declaration: boolean, - ): void => { + const collectDestructured = (pattern: Node, declaration: boolean): void => { if (pattern.type !== "ObjectPattern") return; - const value = unwrapTransparent(valueNode); - if ( - !isUnshadowedGlobalIdentifier(value, "Object", globals) && - !isGlobalObjectSlot(value, globals) - ) return; for (const property of Array.isArray(pattern.properties) ? pattern.properties : []) { if (!isNode(property) || property.type !== "ObjectProperty" || !isNode(property.value)) { @@ -2008,7 +2005,9 @@ function intrinsicDefinePropertyAliases( targetNode = isNode(node.left) ? unwrapTransparent(node.left) : undefined; value = isNode(node.right) ? node.right : undefined; } - if (targetNode && value) collectDestructured(targetNode, value, declaration); + if (targetNode && value && targetNode.type === "ObjectPattern") { + destructured.push({ pattern: targetNode, value, declaration }); + } if (targetNode?.type !== "Identifier" || !value) return; const target = declaration ? bindings.declaration(targetNode) @@ -2017,6 +2016,66 @@ function intrinsicDefinePropertyAliases( }); } + const intrinsicContainers = new Set(); + 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; @@ -2041,6 +2100,9 @@ function intrinsicDefinePropertyAliases( 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) @@ -2508,7 +2570,42 @@ function invokedFunctionParameterBindings( bindings: LexicalBindingIndex, ): Set { const invoked = new Set(); - const collect = (callee: unknown, runGenerator: boolean): void => { + // 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(); + const addValueFlow = (target: LexicalBindingIdentity | null, value: Node): void => { + if (!target) return; + const values = valueFlows.get(target) ?? []; + values.push(value); + valueFlows.set(target, values); + }; + + for (const statement of body) { + if (statement.type === "ImportDeclaration") continue; + walk(statement, (node) => { + if ( + node.type === "FunctionDeclaration" && 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) && + node.left.type === "Identifier" && isNode(node.right) + ) { + addValueFlow(bindings.reference(node.left), node.right); + } + }); + } + + const collect = ( + callee: unknown, + runGenerator: boolean, + seenBindings = new Set(), + ): void => { if (!isNode(callee)) return; let target = unwrapTransparent(callee); while ( @@ -2518,26 +2615,37 @@ function invokedFunctionParameterBindings( ) { 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); + if (last) collect(last, runGenerator, seenBindings); return; } if (target.type === "ConditionalExpression") { - if (isNode(target.consequent)) collect(target.consequent, runGenerator); - if (isNode(target.alternate)) collect(target.alternate, runGenerator); + 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); - if (isNode(target.right)) collect(target.right, runGenerator); + 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); + collect(target.right, runGenerator, seenBindings); return; } if ( @@ -2549,11 +2657,14 @@ function invokedFunctionParameterBindings( (binder.type === "MemberExpression" || binder.type === "OptionalMemberExpression") && memberKey(binder) === "bind" && isNode(binder.object) ) { - collect(binder.object, runGenerator); + collect(binder.object, runGenerator, seenBindings); return; } } - if (target.type !== "FunctionExpression" && target.type !== "ArrowFunctionExpression") return; + if ( + target.type !== "FunctionDeclaration" && target.type !== "FunctionExpression" && + target.type !== "ArrowFunctionExpression" + ) return; // Invoking a generator only creates its iterator. Its body remains deferred // until `next()` advances that exact call result. if (target.generator === true && !runGenerator) return; @@ -2572,6 +2683,45 @@ function invokedFunctionParameterBindings( if (invocation) collect(invocation.callee, runGenerator); }; + const collectAdvanced = ( + value: Node, + seenBindings = new Set(), + ): 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); + } + 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); + return; + } + if (advanced.type === "ConditionalExpression") { + if (isNode(advanced.consequent)) collectAdvanced(advanced.consequent, seenBindings); + if (isNode(advanced.alternate)) collectAdvanced(advanced.alternate, seenBindings); + return; + } + if (advanced.type === "LogicalExpression") { + if (isNode(advanced.left)) collectAdvanced(advanced.left, seenBindings); + if (isNode(advanced.right)) collectAdvanced(advanced.right, seenBindings); + return; + } + if (advanced.type === "AssignmentExpression" && isNode(advanced.right)) { + collectAdvanced(advanced.right, seenBindings); + return; + } + collectInvocation(advanced, true); + }; + for (const statement of body) { if (statement.type === "ImportDeclaration") continue; walk(statement, (node) => { @@ -2579,41 +2729,44 @@ function invokedFunctionParameterBindings( 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 = isNode(node.callee) ? unwrapTransparent(node.callee) : undefined; if ( callee && (callee.type === "MemberExpression" || callee.type === "OptionalMemberExpression") && memberKey(callee) === "next" && isNode(callee.object) ) { - const iterator = unwrapTransparent(callee.object); - if ( - (iterator.type === "CallExpression" || - iterator.type === "OptionalCallExpression") && - isNode(iterator.callee) - ) { - collectInvocation(iterator, true); - } + collectAdvanced(callee.object); + } + } + 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 === "NewExpression") collect(node.callee, false); if (node.type === "SpreadElement" && isNode(node.argument)) { - collectInvocation(node.argument, true); + collectAdvanced(node.argument); } if (node.type === "ForOfStatement" && isNode(node.right)) { - collectInvocation(node.right, true); + collectAdvanced(node.right); } if ( node.type === "VariableDeclarator" && isNode(node.id) && node.id.type === "ArrayPattern" && isNode(node.init) ) { - collectInvocation(node.init, true); + collectAdvanced(node.init); } if ( node.type === "AssignmentExpression" && isNode(node.left) && (node.left.type === "ArrayPattern" || node.left.type === "ArrayExpression") && isNode(node.right) ) { - collectInvocation(node.right, true); + collectAdvanced(node.right); } }); } From f8fe5bee6cde9ef60bfb61fb798a61ab83928903 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 18 Aug 2026 02:36:00 +0200 Subject: [PATCH 62/81] fix(transforms): narrow intrinsic reflection routes --- .../browser-server-exports-strip.test.ts | 78 ++++++ .../stages/browser-server-exports-strip.ts | 247 ++++++++++++++++-- 2 files changed, 298 insertions(+), 27 deletions(-) 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 e3267862c9..f16412041a 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts @@ -2207,6 +2207,11 @@ describe("browser-server-exports-strip", () => { ["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 () => { @@ -2253,6 +2258,22 @@ describe("browser-server-exports-strip", () => { `"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"), + ], ] ) { it(`does not treat a module reaching the intrinsic through ${label} as compiler metadata`, async () => { @@ -2376,6 +2397,24 @@ describe("browser-server-exports-strip", () => { `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", [ @@ -2406,6 +2445,37 @@ describe("browser-server-exports-strip", () => { `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 spread-consumed generator mutation", [ @@ -2511,6 +2581,14 @@ describe("browser-server-exports-strip", () => { `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"), + ], ] ) { it(`does not treat ${label} as compiler metadata`, async () => { diff --git a/src/transforms/pipeline/stages/browser-server-exports-strip.ts b/src/transforms/pipeline/stages/browser-server-exports-strip.ts index e21c7048e2..9a9b568fd9 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.ts @@ -2016,7 +2016,80 @@ function intrinsicDefinePropertyAliases( }); } + 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") { @@ -2661,6 +2734,22 @@ function invokedFunctionParameterBindings( 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); + } + } + return; + } if ( target.type !== "FunctionDeclaration" && target.type !== "FunctionExpression" && target.type !== "ArrowFunctionExpression" @@ -2675,6 +2764,21 @@ function invokedFunctionParameterBindings( if (binding) invoked.add(binding); } } + if (target.generator === true && runGenerator && isNode(target.body)) { + walk(target.body, (node) => { + 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); + } + return true; + }); + } }; const collectInvocation = (value: Node, runGenerator: boolean): void => { @@ -2683,10 +2787,10 @@ function invokedFunctionParameterBindings( if (invocation) collect(invocation.callee, runGenerator); }; - const collectAdvanced = ( + function collectAdvanced( value: Node, seenBindings = new Set(), - ): void => { + ): void { const advanced = unwrapTransparent(value); if (advanced.type === "Identifier") { const binding = bindings.reference(advanced); @@ -2720,7 +2824,7 @@ function invokedFunctionParameterBindings( return; } collectInvocation(advanced, true); - }; + } for (const statement of body) { if (statement.type === "ImportDeclaration") continue; @@ -2734,7 +2838,7 @@ function invokedFunctionParameterBindings( // 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 = isNode(node.callee) ? unwrapTransparent(node.callee) : undefined; + const callee = normalized?.callee; if ( callee && (callee.type === "MemberExpression" || callee.type === "OptionalMemberExpression") && @@ -2815,37 +2919,126 @@ function writesGuardedKeyThroughUnprovenBase( return false; } -/** Member names that hand a module a constructor or a prototype it did not name. */ +/** 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 carries a route to the intrinsic that never names it. + * 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. None of these read `Object` - * as a value, so no amount of tracking reads finds them, and enumerating the - * expressions that produce a constructor has no end. The recognised set simply - * does not admit a module carrying one. + * 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): boolean { - let found = false; +function hasReflectionRoute( + body: Node[], + globals: ReadonlySet, + bindings: LexicalBindingIndex, +): boolean { + const flows = new Map(); + 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 (found) return false; - if (node.type === "MemberExpression" || node.type === "OptionalMemberExpression") { - const key = memberKey(node); - if (key !== null && REFLECTION_KEYS.has(key)) found = true; + if ( + node.type === "VariableDeclarator" && isNode(node.id) && + node.id.type === "Identifier" && isNode(node.init) + ) { + addFlow(bindings.declaration(node.id), node.init); + } else if ( + node.type === "AssignmentExpression" && isNode(node.left) && + node.left.type === "Identifier" && isNode(node.right) + ) { + addFlow(bindings.reference(node.left), node.right); } + }); + } + + 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; + seen.add(binding); + return (flows.get(binding) ?? []).some((source) => isRoute(source, seen)); + } + if (value.type === "MemberExpression" || value.type === "OptionalMemberExpression") { + const key = memberKey(value); + return key !== null && REFLECTION_KEYS.has(key); + } + 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 ( - node.type === "Identifier" && globals.has(node) && - CODE_FROM_STRING_NAMES.has(nodeName(node) ?? "") + (callee.type === "MemberExpression" || callee.type === "OptionalMemberExpression") && + memberKey(callee) === "bind" && isNode(callee.object) ) { - found = true; + return isRoute(callee.object, seen); + } + } + return false; + }; + + 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; }); @@ -3005,9 +3198,9 @@ function isNameDescriptor(node: Node | undefined, valueParam: string): boolean { * * 1. `Object` resolves to the global intrinsic: no module binding, import, * hoisted `var`, or assignment to the global claims the name. - * 2. The module carries no reflection route to a constructor or a prototype, - * and no route from a string to code (`.constructor`, `__proto__`, `eval`, - * `Function`). + * 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 @@ -3038,10 +3231,10 @@ function isNameDescriptor(node: Node | undefined, valueParam: string): boolean { * ## Which direction it errs in * * Toward keeping code. Failing to recognise compiler metadata retains a helper - * and its chain, which costs bundle size. Wrongly recognising it would delete - * a call the module observes. Neither direction can leak a server value: the - * removal of server exports and their dependency chains does not depend on - * this recognition, and the pass verifies the removed names separately. + * 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. */ /** * Bindings for esbuild's `keepNames` helper. Release modules are compiled @@ -3070,7 +3263,7 @@ function compilerNameHelperBindings(body: Node[]): Set { writesObjectDefineProperty(body, globals, bindings) || writesGuardedKeyThroughUnprovenBase(body, globals, bindings) || mergesGuardedKeyOntoIntrinsic(body, globals) || - hasReflectionRoute(body, globals) || + hasReflectionRoute(body, globals, bindings) || intrinsicEscapesToWritableSlot(body, "Object", globals, bindings) || intrinsicEscapesToWritableSlot(body, "global", globals, bindings); if (objectIsModuleLocal) return new Set(); From 86590201ffed315352fcf65fa7d048e6d550beb5 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 18 Aug 2026 02:47:57 +0200 Subject: [PATCH 63/81] fix(transforms): close reflection iterator review gaps --- .../browser-server-exports-strip.test.ts | 47 ++++++++++++ .../stages/browser-server-exports-strip.ts | 73 ++++++++++++++----- 2 files changed, 103 insertions(+), 17 deletions(-) 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 f16412041a..85dacbedfe 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts @@ -2274,6 +2274,14 @@ describe("browser-server-exports-strip", () => { `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")();`, + ], ] ) { it(`does not treat a module reaching the intrinsic through ${label} as compiler metadata`, async () => { @@ -2589,6 +2597,13 @@ describe("browser-server-exports-strip", () => { `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 () => { @@ -2616,6 +2631,38 @@ describe("browser-server-exports-strip", () => { }); } + 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"); + }); + // `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. diff --git a/src/transforms/pipeline/stages/browser-server-exports-strip.ts b/src/transforms/pipeline/stages/browser-server-exports-strip.ts index 9a9b568fd9..84b05c24d5 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.ts @@ -2676,7 +2676,7 @@ function invokedFunctionParameterBindings( const collect = ( callee: unknown, - runGenerator: boolean, + runGenerator: false | "once" | "all", seenBindings = new Set(), ): void => { if (!isNode(callee)) return; @@ -2756,7 +2756,7 @@ function invokedFunctionParameterBindings( ) return; // Invoking a generator only creates its iterator. Its body remains deferred // until `next()` advances that exact call result. - if (target.generator === true && !runGenerator) return; + 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)) { @@ -2764,8 +2764,8 @@ function invokedFunctionParameterBindings( if (binding) invoked.add(binding); } } - if (target.generator === true && runGenerator && isNode(target.body)) { - walk(target.body, (node) => { + if (target.generator === true && runGenerator !== false && isNode(target.body)) { + const collectDelegatedYield = (node: Node): boolean => { if ( node.type === "FunctionDeclaration" || node.type === "FunctionExpression" || node.type === "ArrowFunctionExpression" @@ -2774,14 +2774,36 @@ function invokedFunctionParameterBindings( node.type === "YieldExpression" && node.delegate === true && isNode(node.argument) ) { - collectAdvanced(node.argument); + collectAdvanced( + node.argument, + new Set(), + runGenerator, + ); } return true; - }); + }; + if (runGenerator === "all" || target.body.type !== "BlockStatement") { + walk(target.body, collectDelegatedYield); + } else { + // One `next()` stops at the first direct, non-delegated yield. Do not + // advance a later delegated iterator that this call cannot reach. + for (const statement of Array.isArray(target.body.body) ? target.body.body : []) { + if (!isNode(statement)) continue; + const expression = statement.type === "ExpressionStatement" && + isNode(statement.expression) + ? unwrapTransparent(statement.expression) + : undefined; + if (expression?.type === "YieldExpression" && expression.delegate !== true) break; + walk(statement, collectDelegatedYield); + } + } } }; - const collectInvocation = (value: Node, runGenerator: boolean): void => { + const collectInvocation = ( + value: Node, + runGenerator: false | "once" | "all", + ): void => { const call = unwrapTransparent(value); const invocation = normalizeCall(call, globals); if (invocation) collect(invocation.callee, runGenerator); @@ -2790,6 +2812,7 @@ function invokedFunctionParameterBindings( function collectAdvanced( value: Node, seenBindings = new Set(), + runGenerator: "once" | "all" = "all", ): void { const advanced = unwrapTransparent(value); if (advanced.type === "Identifier") { @@ -2797,7 +2820,7 @@ function invokedFunctionParameterBindings( if (!binding || seenBindings.has(binding)) return; seenBindings.add(binding); for (const source of valueFlows.get(binding) ?? []) { - collectAdvanced(source, seenBindings); + collectAdvanced(source, seenBindings, runGenerator); } return; } @@ -2806,24 +2829,28 @@ function invokedFunctionParameterBindings( ? advanced.expressions.filter(isNode) : []; const last = expressions.at(-1); - if (last) collectAdvanced(last, seenBindings); + if (last) collectAdvanced(last, seenBindings, runGenerator); return; } if (advanced.type === "ConditionalExpression") { - if (isNode(advanced.consequent)) collectAdvanced(advanced.consequent, seenBindings); - if (isNode(advanced.alternate)) collectAdvanced(advanced.alternate, seenBindings); + 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); - if (isNode(advanced.right)) collectAdvanced(advanced.right, seenBindings); + 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); + collectAdvanced(advanced.right, seenBindings, runGenerator); return; } - collectInvocation(advanced, true); + collectInvocation(advanced, runGenerator); } for (const statement of body) { @@ -2844,7 +2871,7 @@ function invokedFunctionParameterBindings( (callee.type === "MemberExpression" || callee.type === "OptionalMemberExpression") && memberKey(callee) === "next" && isNode(callee.object) ) { - collectAdvanced(callee.object); + collectAdvanced(callee.object, new Set(), "once"); } } if (node.type === "NewExpression") { @@ -2986,7 +3013,10 @@ function hasReflectionRoute( } if (value.type === "MemberExpression" || value.type === "OptionalMemberExpression") { const key = memberKey(value); - return key !== null && REFLECTION_KEYS.has(key); + 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) : []; @@ -3021,6 +3051,15 @@ function hasReflectionRoute( return false; }; + 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; From 2b56419da2756f97843662c03e70fc0adaec676a Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 18 Aug 2026 03:00:34 +0200 Subject: [PATCH 64/81] fix(transforms): follow class and destructured routes --- .../browser-server-exports-strip.test.ts | 50 ++++++ .../stages/browser-server-exports-strip.ts | 156 ++++++++++++++++-- 2 files changed, 188 insertions(+), 18 deletions(-) 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 85dacbedfe..24da7dc0ba 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts @@ -2282,6 +2282,13 @@ describe("browser-server-exports-strip", () => { "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"), + ], ] ) { it(`does not treat a module reaching the intrinsic through ${label} as compiler metadata`, async () => { @@ -2484,6 +2491,17 @@ describe("browser-server-exports-strip", () => { `new Mutator(Object);`, ].join("\n"), ], + [ + "a named class-declaration constructor mutation", + [ + `class Mutator {`, + ` constructor(intrinsic) {`, + ` intrinsic.defineProperty = recordAndReturn;`, + ` }`, + `}`, + `new Mutator(Object);`, + ].join("\n"), + ], [ "a spread-consumed generator mutation", [ @@ -2663,6 +2681,38 @@ describe("browser-server-exports-strip", () => { 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. diff --git a/src/transforms/pipeline/stages/browser-server-exports-strip.ts b/src/transforms/pipeline/stages/browser-server-exports-strip.ts index 84b05c24d5..e54167b328 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.ts @@ -1395,7 +1395,10 @@ function freeReferencedIdentifiers( if (node.type === "ClassDeclaration" || node.type === "ClassExpression") { if (node.type === "ClassDeclaration") bindPatternNames(scopes[0] ?? rootScope, node.id); const classScope: LexicalScope = { kind: "block", names: new Set() }; - bindPatternNames(classScope, node.id); + // 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; // A class decorator is evaluated outside the class, so it does not see @@ -2657,7 +2660,8 @@ function invokedFunctionParameterBindings( if (statement.type === "ImportDeclaration") continue; walk(statement, (node) => { if ( - node.type === "FunctionDeclaration" && isNode(node.id) + (node.type === "FunctionDeclaration" || node.type === "ClassDeclaration") && + isNode(node.id) ) { addValueFlow(bindings.declaration(node.id), node); } else if ( @@ -2785,16 +2789,70 @@ function invokedFunctionParameterBindings( if (runGenerator === "all" || target.body.type !== "BlockStatement") { walk(target.body, collectDelegatedYield); } else { - // One `next()` stops at the first direct, non-delegated yield. Do not - // advance a later delegated iterator that this call cannot reach. + 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; - const expression = statement.type === "ExpressionStatement" && - isNode(statement.expression) - ? unwrapTransparent(statement.expression) - : undefined; - if (expression?.type === "YieldExpression" && expression.delegate !== true) break; - walk(statement, collectDelegatedYield); + if (collectBeforeSuspension(statement)) break; } } } @@ -2969,6 +3027,7 @@ function hasReflectionRoute( bindings: LexicalBindingIndex, ): boolean { const flows = new Map(); + 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) ?? []; @@ -2979,20 +3038,80 @@ function hasReflectionRoute( for (const statement of body) { if (statement.type === "ImportDeclaration") continue; walk(statement, (node) => { - if ( - node.type === "VariableDeclarator" && isNode(node.id) && - node.id.type === "Identifier" && isNode(node.init) - ) { - addFlow(bindings.declaration(node.id), node.init); + if (node.type === "VariableDeclarator" && isNode(node.id) && isNode(node.init)) { + if (node.id.type === "Identifier") { + addFlow(bindings.declaration(node.id), node.init); + } else if (node.id.type === "ObjectPattern") { + destructured.push({ pattern: node.id, value: node.init, declaration: true }); + } } else if ( - node.type === "AssignmentExpression" && isNode(node.left) && - node.left.type === "Identifier" && isNode(node.right) + node.type === "AssignmentExpression" && isNode(node.left) && isNode(node.right) ) { - addFlow(bindings.reference(node.left), node.right); + if (node.left.type === "Identifier") { + addFlow(bindings.reference(node.left), node.right); + } else if (node.left.type === "ObjectPattern") { + 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(); + for (const { pattern, value, declaration } of destructured) { + 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); + } + } + } + const isRoute = ( entry: Node | undefined, seen = new Set(), @@ -3008,6 +3127,7 @@ function hasReflectionRoute( 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)); } From ac2155ba027e2bcfca2ffbb86c7cbc8fc4f0ac78 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 18 Aug 2026 03:15:08 +0200 Subject: [PATCH 65/81] fix(transforms): follow remaining dynamic routes --- src/errors/index.ts | 1 + .../browser-server-exports-strip.test.ts | 32 +++ .../stages/browser-server-exports-strip.ts | 226 ++++++++++++++---- 3 files changed, 215 insertions(+), 44 deletions(-) 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/transforms/pipeline/stages/browser-server-exports-strip.test.ts b/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts index 24da7dc0ba..c54ec08a18 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts @@ -1,5 +1,6 @@ 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 { afterAll, describe, it } from "#veryfront/testing/bdd.ts"; @@ -223,6 +224,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"); }); @@ -1473,6 +1476,16 @@ describe("browser-server-exports-strip", () => { `mutateIntrinsic(Object);`, ].join("\n"), ], + [ + "an invoked factory-returned function mutation", + [ + `(function () {`, + ` return function (intrinsic) {`, + ` intrinsic.defineProperty = recordAndReturn;`, + ` };`, + `})()(Object);`, + ].join("\n"), + ], [ "an intrinsic mutation invoked through call", `Object.defineProperty.call(null, Object, "defineProperty", { value: recordAndReturn });`, @@ -2289,6 +2302,13 @@ describe("browser-server-exports-strip", () => { `run("Object.defineProperty = (target) => target");`, ].join("\n"), ], + [ + "array-destructured global-object eval", + [ + `const [run] = [globalThis.eval];`, + `run("Object.defineProperty = (target) => target");`, + ].join("\n"), + ], ] ) { it(`does not treat a module reaching the intrinsic through ${label} as compiler metadata`, async () => { @@ -2502,6 +2522,18 @@ describe("browser-server-exports-strip", () => { `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", [ diff --git a/src/transforms/pipeline/stages/browser-server-exports-strip.ts b/src/transforms/pipeline/stages/browser-server-exports-strip.ts index e54167b328..81af5c0ca4 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.ts @@ -151,6 +151,7 @@ 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 { @@ -2678,6 +2679,81 @@ function invokedFunctionParameterBindings( }); } + const concreteValues = ( + entry: Node, + seenBindings = 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)) + ); + } + if (value.type === "SequenceExpression") { + const expressions = Array.isArray(value.expressions) ? value.expressions.filter(isNode) : []; + const last = expressions.at(-1); + return last ? concreteValues(last, seenBindings) : []; + } + if (value.type === "ConditionalExpression") { + return [value.consequent, value.alternate].filter(isNode).flatMap((branch) => + concreteValues(branch, new Set(seenBindings)) + ); + } + if (value.type === "LogicalExpression") { + return [value.left, value.right].filter(isNode).flatMap((branch) => + concreteValues(branch, new Set(seenBindings)) + ); + } + if (value.type === "AssignmentExpression" && isNode(value.right)) { + return concreteValues(value.right, seenBindings); + } + if (value.type === "AwaitExpression" && isNode(value.argument)) { + return concreteValues(value.argument, seenBindings); + } + 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); + } + + const invocation = normalizeCall(value, globals); + if (!invocation) return []; + const returned: Node[] = []; + for (const callee of concreteValues(invocation.callee, new Set(seenBindings))) { + if ( + callee.type !== "FunctionDeclaration" && callee.type !== "FunctionExpression" && + callee.type !== "ArrowFunctionExpression" + ) continue; + // Async factories return a promise and generator factories return an + // iterator, neither synchronously hands the caller a callable value. + if (callee.async === true || callee.generator === true || !isNode(callee.body)) continue; + if (callee.body.type !== "BlockStatement") { + returned.push(...concreteValues(callee.body, new Set(seenBindings))); + continue; + } + walk(callee.body, (node) => { + if (node !== callee.body && startsVarScope(node)) return false; + if (node.type === "ReturnStatement" && isNode(node.argument)) { + returned.push(...concreteValues(node.argument, new Set(seenBindings))); + } + return true; + }); + } + return returned; + } + return [value]; + }; + const collect = ( callee: unknown, runGenerator: false | "once" | "all", @@ -2737,6 +2813,10 @@ function invokedFunctionParameterBindings( 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) @@ -2752,6 +2832,12 @@ function invokedFunctionParameterBindings( 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 ( @@ -3041,7 +3127,7 @@ function hasReflectionRoute( if (node.type === "VariableDeclarator" && isNode(node.id) && isNode(node.init)) { if (node.id.type === "Identifier") { addFlow(bindings.declaration(node.id), node.init); - } else if (node.id.type === "ObjectPattern") { + } else if (node.id.type === "ObjectPattern" || node.id.type === "ArrayPattern") { destructured.push({ pattern: node.id, value: node.init, declaration: true }); } } else if ( @@ -3049,7 +3135,7 @@ function hasReflectionRoute( ) { if (node.left.type === "Identifier") { addFlow(bindings.reference(node.left), node.right); - } else if (node.left.type === "ObjectPattern") { + } else if (node.left.type === "ObjectPattern" || node.left.type === "ArrayPattern") { destructured.push({ pattern: node.left, value: node.right, declaration: false }); } } @@ -3094,24 +3180,6 @@ function hasReflectionRoute( }; const destructuredCodeRoutes = new Set(); - for (const { pattern, value, declaration } of destructured) { - 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); - } - } - } - const isRoute = ( entry: Node | undefined, seen = new Set(), @@ -3171,6 +3239,83 @@ function hasReflectionRoute( 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 []; + }; + + 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) || !isRoute(values[index])) 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; @@ -4233,21 +4378,14 @@ interface Blocker { * 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 { - /** Catalog slug, so the failure resolves to its entry and its docs page. */ - readonly slug = "server-export-strip-failed"; - - constructor( - filePath: string | undefined, - reason: string, - remedy: string = REMEDY.declareDirectly, - ) { - super( - `Cannot remove the server-only export from ${filePath ?? "this module"} ` + - `before it is sent to the browser: ${reason}.` + (remedy ? ` ${remedy}` : ""), - ); - 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 }); } /** @@ -4268,7 +4406,7 @@ export async function stripServerOnlyExports( const parser = tryResolve("CodeParser"); if (!parser) { - throw new ServerExportStripError( + throw createServerExportStripError( filePath, "no CodeParser extension is registered", REMEDY.none, @@ -4287,7 +4425,7 @@ 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, @@ -4296,7 +4434,7 @@ export async function stripServerOnlyExports( 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; @@ -4309,7 +4447,7 @@ export async function stripServerOnlyExports( const assigned = assignedNames(body); const reassigned = [...locals].filter((name) => assigned.has(name)); if (reassigned.length > 0) { - throw new ServerExportStripError( + 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`, @@ -4325,7 +4463,7 @@ export async function stripServerOnlyExports( const hoisted = hoistedVarNames(body); const redeclared = [...locals].filter((name) => hoisted.has(name)); if (redeclared.length > 0) { - throw new ServerExportStripError( + 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 ` + @@ -4345,7 +4483,7 @@ export async function stripServerOnlyExports( const emptied = emptyServerOnlyHooks(body, locals, stubs); const missed = [...locals].filter((name) => !emptied.has(name)); if (missed.length > 0) { - throw new ServerExportStripError( + throw createServerExportStripError( filePath, `\`${missed[0]}\` is exported but its declaration is not a function or ` + `variable this pass can stub`, @@ -4383,7 +4521,7 @@ export async function stripServerOnlyExports( removedNames, ); if (firstBlocked) { - throw new ServerExportStripError(filePath, firstBlocked.reason, firstBlocked.remedy); + throw createServerExportStripError(filePath, firstBlocked.reason, firstBlocked.remedy); } const pruned = body.filter((statement) => !removableStatements.has(statement)); @@ -4408,7 +4546,7 @@ export async function stripServerOnlyExports( }); emittedBody = bodyOf(emitted); } catch (error) { - throw new ServerExportStripError( + throw createServerExportStripError( filePath, `the stripped output no longer parses: ${ error instanceof Error ? error.message : String(error) @@ -4428,7 +4566,7 @@ export async function stripServerOnlyExports( } const leaked = [...removedNames].filter((name) => residual.has(name)); if (leaked.length > 0) { - throw new ServerExportStripError( + throw createServerExportStripError( filePath, `the server-only binding \`${leaked[0]}\` still appears in the stripped output`, REMEDY.none, From cc089a14d86f4f9d899531b4664b5d076ec9b0dc Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 18 Aug 2026 03:26:05 +0200 Subject: [PATCH 66/81] fix(transforms): follow method factories and pattern defaults --- docs/api-reference/veryfront/errors.md | 1 + .../browser-server-exports-strip.test.ts | 20 ++++ .../stages/browser-server-exports-strip.ts | 105 +++++++++++++++++- 3 files changed, 123 insertions(+), 3 deletions(-) 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/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts b/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts index c54ec08a18..422b0d96d7 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts @@ -1486,6 +1486,19 @@ describe("browser-server-exports-strip", () => { `})()(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 intrinsic mutation invoked through call", `Object.defineProperty.call(null, Object, "defineProperty", { value: recordAndReturn });`, @@ -2309,6 +2322,13 @@ describe("browser-server-exports-strip", () => { `run("Object.defineProperty = (target) => target");`, ].join("\n"), ], + [ + "defaulted array-destructured global-object eval", + [ + `const [run = globalThis.eval] = [];`, + `run("Object.defineProperty = (target) => target");`, + ].join("\n"), + ], ] ) { it(`does not treat a module reaching the intrinsic through ${label} as compiler metadata`, async () => { diff --git a/src/transforms/pipeline/stages/browser-server-exports-strip.ts b/src/transforms/pipeline/stages/browser-server-exports-strip.ts index 81af5c0ca4..7508c236f5 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.ts @@ -2714,6 +2714,49 @@ function invokedFunctionParameterBindings( if (value.type === "AwaitExpression" && isNode(value.argument)) { return concreteValues(value.argument, seenBindings); } + if ( + (value.type === "MemberExpression" || value.type === "OptionalMemberExpression") && + isNode(value.object) + ) { + const key = memberKey(value); + if (key === null) return []; + const members: Node[] = []; + for (const owner of concreteValues(value.object, new Set(seenBindings))) { + if (owner.type === "ObjectExpression") { + for (const property of Array.isArray(owner.properties) ? owner.properties : []) { + if (!isNode(property) || property.type === "SpreadElement") continue; + const propertyKey = isNode(property.key) ? property.key : undefined; + const name = property.computed === true + ? stringLiteralText(propertyKey) + : literalText(propertyKey); + if (name !== key) continue; + if (property.type === "ObjectMethod") { + members.push(property); + } else if (property.type === "ObjectProperty" && isNode(property.value)) { + members.push(...concreteValues(property.value, new Set(seenBindings))); + } + } + 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 property of classMembers) { + if (property.static !== true || !isNode(property.key)) continue; + const name = property.computed === true + ? stringLiteralText(property.key) + : literalText(property.key); + if (name !== key) continue; + if (property.type === "ClassMethod") { + members.push(property); + } else if (isNode(property.value)) { + members.push(...concreteValues(property.value, new Set(seenBindings))); + } + } + } + return members; + } if ( (value.type === "CallExpression" || value.type === "OptionalCallExpression") && isNode(value.callee) @@ -2732,7 +2775,8 @@ function invokedFunctionParameterBindings( for (const callee of concreteValues(invocation.callee, new Set(seenBindings))) { if ( callee.type !== "FunctionDeclaration" && callee.type !== "FunctionExpression" && - callee.type !== "ArrowFunctionExpression" + 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. @@ -2801,6 +2845,12 @@ function invokedFunctionParameterBindings( 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) @@ -2842,7 +2892,8 @@ function invokedFunctionParameterBindings( } if ( target.type !== "FunctionDeclaration" && target.type !== "FunctionExpression" && - target.type !== "ArrowFunctionExpression" + 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. @@ -3282,6 +3333,50 @@ function hasReflectionRoute( 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; + 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; @@ -3305,7 +3400,11 @@ function hasReflectionRoute( const elements = Array.isArray(pattern.elements) ? pattern.elements : []; for (const values of arrayValues(value)) { for (const [index, element] of elements.entries()) { - if (!isNode(element) || !isRoute(values[index])) continue; + 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) From bfdfb4e2938e444f2f57adacb5acbb1a2a087658 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 18 Aug 2026 03:40:04 +0200 Subject: [PATCH 67/81] fix(transforms): tighten callable value flow --- .../browser-server-exports-strip.test.ts | 48 ++++++++ .../stages/browser-server-exports-strip.ts | 107 ++++++++++++++---- 2 files changed, 135 insertions(+), 20 deletions(-) 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 422b0d96d7..3b0ea93c29 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts @@ -1499,6 +1499,16 @@ describe("browser-server-exports-strip", () => { `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 intrinsic mutation invoked through call", `Object.defineProperty.call(null, Object, "defineProperty", { value: recordAndReturn });`, @@ -1555,6 +1565,35 @@ describe("browser-server-exports-strip", () => { }); } + 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 past a write through a shadowing alias parameter", async () => { const code = [ `const intrinsic = Object;`, @@ -2329,6 +2368,15 @@ describe("browser-server-exports-strip", () => { `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 () => { diff --git a/src/transforms/pipeline/stages/browser-server-exports-strip.ts b/src/transforms/pipeline/stages/browser-server-exports-strip.ts index 7508c236f5..bbcf718e02 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.ts @@ -2650,12 +2650,25 @@ function invokedFunctionParameterBindings( // 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(); + const memberValueFlows = new Map>(); const addValueFlow = (target: LexicalBindingIdentity | null, value: Node): void => { if (!target) return; const values = valueFlows.get(target) ?? []; values.push(value); valueFlows.set(target, values); }; + const addMemberValueFlow = ( + target: LexicalBindingIdentity | null, + key: string | null, + value: Node, + ): void => { + if (!target || key === null) return; + const byKey = memberValueFlows.get(target) ?? new Map(); + const values = byKey.get(key) ?? []; + values.push(value); + byKey.set(key, values); + memberValueFlows.set(target, byKey); + }; for (const statement of body) { if (statement.type === "ImportDeclaration") continue; @@ -2671,10 +2684,19 @@ function invokedFunctionParameterBindings( ) { addValueFlow(bindings.declaration(node.id), node.init); } else if ( - node.type === "AssignmentExpression" && isNode(node.left) && - node.left.type === "Identifier" && isNode(node.right) + node.type === "AssignmentExpression" && isNode(node.left) && isNode(node.right) ) { - addValueFlow(bindings.reference(node.left), node.right); + if (node.left.type === "Identifier") { + addValueFlow(bindings.reference(node.left), node.right); + } else if ( + (node.left.type === "MemberExpression" || + node.left.type === "OptionalMemberExpression") && isNode(node.left.object) + ) { + const owner = unwrapTransparent(node.left.object); + if (owner.type === "Identifier") { + addMemberValueFlow(bindings.reference(owner), memberKey(node.left), node.right); + } + } } }); } @@ -2721,28 +2743,65 @@ function invokedFunctionParameterBindings( const key = memberKey(value); if (key === null) return []; const members: Node[] = []; - for (const owner of concreteValues(value.object, new Set(seenBindings))) { - if (owner.type === "ObjectExpression") { - for (const property of Array.isArray(owner.properties) ? owner.properties : []) { - if (!isNode(property) || property.type === "SpreadElement") continue; - const propertyKey = isNode(property.key) ? property.key : undefined; - const name = property.computed === true - ? stringLiteralText(propertyKey) - : literalText(propertyKey); - if (name !== key) continue; - if (property.type === "ObjectMethod") { - members.push(property); - } else if (property.type === "ObjectProperty" && isNode(property.value)) { - members.push(...concreteValues(property.value, new Set(seenBindings))); + const ownerReference = unwrapTransparent(value.object); + if (ownerReference.type === "Identifier") { + const ownerBinding = bindings.reference(ownerReference); + for ( + const source of ownerBinding ? memberValueFlows.get(ownerBinding)?.get(key) ?? [] : [] + ) { + members.push(...concreteValues(source, new Set(seenBindings))); + } + } + + const seenOwners = new Set(); + const collectObjectMember = (owner: Node): Node[] => { + if (seenOwners.has(owner)) return []; + seenOwners.add(owner); + const candidates: Node[] = []; + 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))) { + if (spread.type === "ObjectExpression") { + candidates.push(...collectObjectMember(spread)); + } } + continue; + } + const propertyKey = isNode(property.key) ? property.key : undefined; + const name = property.computed === true + ? stringLiteralText(propertyKey) + : literalText(propertyKey); + if (name !== key) continue; + if (property.type === "ObjectMethod") { + candidates.push(property); + } else if (property.type === "ObjectProperty" && isNode(property.value)) { + candidates.push(...concreteValues(property.value, new Set(seenBindings))); } + return candidates; + } + return candidates; + }; + + for (const owner of concreteValues(value.object, new Set(seenBindings))) { + if (owner.type === "ObjectExpression") { + members.push(...collectObjectMember(owner)); 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 property of classMembers) { + 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 name = property.computed === true ? stringLiteralText(property.key) @@ -2753,6 +2812,7 @@ function invokedFunctionParameterBindings( } else if (isNode(property.value)) { members.push(...concreteValues(property.value, new Set(seenBindings))); } + break; } } return members; @@ -3164,6 +3224,7 @@ function hasReflectionRoute( 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; @@ -3175,10 +3236,15 @@ function hasReflectionRoute( for (const statement of body) { if (statement.type === "ImportDeclaration") continue; walk(statement, (node) => { - if (node.type === "VariableDeclarator" && isNode(node.id) && isNode(node.init)) { + if (node.type === "VariableDeclarator" && isNode(node.id)) { if (node.id.type === "Identifier") { - addFlow(bindings.declaration(node.id), node.init); - } else if (node.id.type === "ObjectPattern" || node.id.type === "ArrayPattern") { + 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 ( @@ -3341,6 +3407,7 @@ function hasReflectionRoute( 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); From 9651e9dfa496f1dabf744a9fcdbe161807e41080 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 18 Aug 2026 03:47:31 +0200 Subject: [PATCH 68/81] fix(transforms): follow callable owner aliases --- .../browser-server-exports-strip.test.ts | 11 +++ .../stages/browser-server-exports-strip.ts | 68 +++++++++++++++++-- 2 files changed, 75 insertions(+), 4 deletions(-) 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 3b0ea93c29..3d86d0d6bb 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts @@ -1509,6 +1509,17 @@ describe("browser-server-exports-strip", () => { `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"), + ], [ "an intrinsic mutation invoked through call", `Object.defineProperty.call(null, Object, "defineProperty", { value: recordAndReturn });`, diff --git a/src/transforms/pipeline/stages/browser-server-exports-strip.ts b/src/transforms/pipeline/stages/browser-server-exports-strip.ts index bbcf718e02..4efe056c26 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.ts @@ -2651,11 +2651,63 @@ function invokedFunctionParameterBindings( // iterator advanced through a later alias resolve to the same body. const valueFlows = new Map(); const memberValueFlows = new Map>(); + const memberOwnerAliases = new Map>(); + const aliasSourceBindings = (entry: Node): Set => { + const aliases = new Set(); + const collectAlias = (source: Node): void => { + const value = unwrapTransparent(source); + if (value.type === "Identifier") { + const binding = bindings.reference(value); + if (binding) aliases.add(binding); + return; + } + if (value.type === "SequenceExpression") { + const expressions = Array.isArray(value.expressions) + ? value.expressions.filter(isNode) + : []; + const last = expressions.at(-1); + if (last) collectAlias(last); + return; + } + if (value.type === "ConditionalExpression") { + if (isNode(value.consequent)) collectAlias(value.consequent); + if (isNode(value.alternate)) collectAlias(value.alternate); + return; + } + if (value.type === "LogicalExpression") { + if (isNode(value.left)) collectAlias(value.left); + if (isNode(value.right)) collectAlias(value.right); + return; + } + if (value.type === "AssignmentExpression" && isNode(value.right)) { + collectAlias(value.right); + return; + } + if (value.type === "AwaitExpression" && isNode(value.argument)) { + collectAlias(value.argument); + } + }; + collectAlias(entry); + return aliases; + }; + const linkMemberOwnerAliases = ( + left: LexicalBindingIdentity, + right: LexicalBindingIdentity, + ): void => { + if (left === right) return; + const leftAliases = memberOwnerAliases.get(left) ?? new Set(); + const rightAliases = memberOwnerAliases.get(right) ?? new Set(); + leftAliases.add(right); + rightAliases.add(left); + memberOwnerAliases.set(left, leftAliases); + memberOwnerAliases.set(right, rightAliases); + }; const addValueFlow = (target: LexicalBindingIdentity | null, value: Node): void => { if (!target) return; const values = valueFlows.get(target) ?? []; values.push(value); valueFlows.set(target, values); + for (const alias of aliasSourceBindings(value)) linkMemberOwnerAliases(target, alias); }; const addMemberValueFlow = ( target: LexicalBindingIdentity | null, @@ -2746,10 +2798,18 @@ function invokedFunctionParameterBindings( const ownerReference = unwrapTransparent(value.object); if (ownerReference.type === "Identifier") { const ownerBinding = bindings.reference(ownerReference); - for ( - const source of ownerBinding ? memberValueFlows.get(ownerBinding)?.get(key) ?? [] : [] - ) { - members.push(...concreteValues(source, new Set(seenBindings))); + if (ownerBinding) { + const pending = [ownerBinding]; + const visited = new Set(); + while (pending.length > 0) { + const candidate = pending.pop(); + if (!candidate || visited.has(candidate)) continue; + visited.add(candidate); + for (const source of memberValueFlows.get(candidate)?.get(key) ?? []) { + members.push(...concreteValues(source, new Set(seenBindings))); + } + pending.push(...memberOwnerAliases.get(candidate) ?? []); + } } } From 3da6e9ecd4e8166f6fea6ea8c69d848eca84ca0f Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 18 Aug 2026 04:01:31 +0200 Subject: [PATCH 69/81] fix(transforms): track member owner flow by occurrence --- .../browser-server-exports-strip.test.ts | 50 +++ .../stages/browser-server-exports-strip.ts | 325 +++++++++++++----- 2 files changed, 291 insertions(+), 84 deletions(-) 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 3d86d0d6bb..2338373d08 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts @@ -1520,6 +1520,29 @@ describe("browser-server-exports-strip", () => { `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 });`, @@ -1605,6 +1628,33 @@ describe("browser-server-exports-strip", () => { 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("still strips metadata past a write through a shadowing alias parameter", async () => { const code = [ `const intrinsic = Object;`, diff --git a/src/transforms/pipeline/stages/browser-server-exports-strip.ts b/src/transforms/pipeline/stages/browser-server-exports-strip.ts index 4efe056c26..786fcd8d59 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.ts @@ -2650,76 +2650,37 @@ function invokedFunctionParameterBindings( // 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(); - const memberValueFlows = new Map>(); - const memberOwnerAliases = new Map>(); - const aliasSourceBindings = (entry: Node): Set => { - const aliases = new Set(); - const collectAlias = (source: Node): void => { - const value = unwrapTransparent(source); - if (value.type === "Identifier") { - const binding = bindings.reference(value); - if (binding) aliases.add(binding); - return; - } - if (value.type === "SequenceExpression") { - const expressions = Array.isArray(value.expressions) - ? value.expressions.filter(isNode) - : []; - const last = expressions.at(-1); - if (last) collectAlias(last); - return; - } - if (value.type === "ConditionalExpression") { - if (isNode(value.consequent)) collectAlias(value.consequent); - if (isNode(value.alternate)) collectAlias(value.alternate); - return; - } - if (value.type === "LogicalExpression") { - if (isNode(value.left)) collectAlias(value.left); - if (isNode(value.right)) collectAlias(value.right); - return; - } - if (value.type === "AssignmentExpression" && isNode(value.right)) { - collectAlias(value.right); - return; - } - if (value.type === "AwaitExpression" && isNode(value.argument)) { - collectAlias(value.argument); - } - }; - collectAlias(entry); - return aliases; - }; - const linkMemberOwnerAliases = ( - left: LexicalBindingIdentity, - right: LexicalBindingIdentity, - ): void => { - if (left === right) return; - const leftAliases = memberOwnerAliases.get(left) ?? new Set(); - const rightAliases = memberOwnerAliases.get(right) ?? new Set(); - leftAliases.add(right); - rightAliases.add(left); - memberOwnerAliases.set(left, leftAliases); - memberOwnerAliases.set(right, rightAliases); - }; + interface OwnerValueFlow { + value: Node; + order: number; + uncertain: boolean; + } + interface MemberValueFlow { + owner: Node; + value: Node; + order: number; + uncertain: boolean; + } + const ownerValueFlows = new Map(); + const memberValueFlows = new Map(); + const nodeOrders = new Map(); + const uncertainNodes = new Set(); const addValueFlow = (target: LexicalBindingIdentity | null, value: Node): void => { if (!target) return; const values = valueFlows.get(target) ?? []; values.push(value); valueFlows.set(target, values); - for (const alias of aliasSourceBindings(value)) linkMemberOwnerAliases(target, alias); }; - const addMemberValueFlow = ( + const addOwnerValueFlow = ( target: LexicalBindingIdentity | null, - key: string | null, value: Node, + order: number, + uncertain: boolean, ): void => { - if (!target || key === null) return; - const byKey = memberValueFlows.get(target) ?? new Map(); - const values = byKey.get(key) ?? []; - values.push(value); - byKey.set(key, values); - memberValueFlows.set(target, byKey); + if (!target) return; + const values = ownerValueFlows.get(target) ?? []; + values.push({ value, order, uncertain }); + ownerValueFlows.set(target, values); }; for (const statement of body) { @@ -2740,19 +2701,218 @@ function invokedFunctionParameterBindings( ) { if (node.left.type === "Identifier") { addValueFlow(bindings.reference(node.left), node.right); - } else if ( - (node.left.type === "MemberExpression" || - node.left.type === "OptionalMemberExpression") && isNode(node.left.object) - ) { - const owner = unwrapTransparent(node.left.object); - if (owner.type === "Identifier") { - addMemberValueFlow(bindings.reference(owner), memberKey(node.left), node.right); - } } } }); } + const uncertainFlowParentTypes = new Set([ + "FunctionDeclaration", + "FunctionExpression", + "ArrowFunctionExpression", + "ObjectMethod", + "ClassMethod", + "ClassPrivateMethod", + "ClassDeclaration", + "ClassExpression", + "ConditionalExpression", + "LogicalExpression", + "IfStatement", + "SwitchStatement", + "SwitchCase", + "WhileStatement", + "DoWhileStatement", + "ForStatement", + "ForInStatement", + "ForOfStatement", + "TryStatement", + "CatchClause", + ]); + const addPatternOwnerFlows = ( + pattern: Node, + source: Node, + declaration: boolean, + order: number, + uncertain: boolean, + ): void => { + const target = unwrapTransparent(pattern); + const value = unwrapTransparent(source); + if (target.type === "Identifier") { + addOwnerValueFlow( + declaration ? bindings.declaration(target) : bindings.reference(target), + value, + order, + uncertain, + ); + return; + } + if (target.type === "AssignmentPattern" && isNode(target.left)) { + addPatternOwnerFlows(target.left, value, declaration, order, uncertain); + if (isNode(target.right)) { + addPatternOwnerFlows(target.left, target.right, declaration, order, true); + } + 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, uncertain); + return; + } + if (value.type === "ConditionalExpression") { + if (isNode(value.consequent)) { + addPatternOwnerFlows(target, value.consequent, declaration, order, true); + } + if (isNode(value.alternate)) { + addPatternOwnerFlows(target, value.alternate, declaration, order, true); + } + return; + } + if (value.type === "LogicalExpression") { + if (isNode(value.left)) addPatternOwnerFlows(target, value.left, declaration, order, true); + if (isNode(value.right)) addPatternOwnerFlows(target, value.right, declaration, order, true); + return; + } + if (value.type === "AssignmentExpression" && isNode(value.right)) { + addPatternOwnerFlows(target, value.right, declaration, order, uncertain); + return; + } + if (value.type === "AwaitExpression" && isNode(value.argument)) { + addPatternOwnerFlows(target, value.argument, declaration, order, uncertain); + 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, uncertain); + } + } + }; + + let visitOrder = 0; + const collectOwnerFlows = (node: Node, uncertain: boolean): void => { + const order = visitOrder++; + nodeOrders.set(node, order); + if (uncertain) uncertainNodes.add(node); + + if ( + (node.type === "FunctionDeclaration" || node.type === "ClassDeclaration") && + isNode(node.id) + ) { + addOwnerValueFlow(bindings.declaration(node.id), node, order, uncertain); + } else if (node.type === "VariableDeclarator" && isNode(node.id) && isNode(node.init)) { + if (node.id.type === "Identifier") { + addOwnerValueFlow(bindings.declaration(node.id), node.init, order, uncertain); + } else if (node.id.type === "ArrayPattern") { + addPatternOwnerFlows(node.id, node.init, true, order, uncertain); + } + } else if ( + node.type === "AssignmentExpression" && isNode(node.left) && isNode(node.right) + ) { + if (node.left.type === "Identifier") { + addOwnerValueFlow(bindings.reference(node.left), node.right, order, uncertain); + } else if (node.left.type === "ArrayPattern" || node.left.type === "ArrayExpression") { + addPatternOwnerFlows(node.left, node.right, false, order, uncertain); + } 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, + uncertain, + }); + memberValueFlows.set(key, flows); + } + } + } + + const childUncertain = uncertain || uncertainFlowParentTypes.has(node.type); + for (const child of children(node)) collectOwnerFlows(child, childUncertain); + }; + for (const statement of body) { + if (statement.type !== "ImportDeclaration") collectOwnerFlows(statement, false); + } + + type OwnerIdentity = Node | LexicalBindingIdentity; + const activeOwnerFlows = ( + binding: LexicalBindingIdentity, + atOrder: number, + allPossible: boolean, + ): OwnerValueFlow[] => { + const applicable = (ownerValueFlows.get(binding) ?? []).filter((flow) => + allPossible || flow.order <= atOrder + ); + 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--) { + if (!applicable[index]?.uncertain) { + lastCertain = index; + break; + } + } + if (lastCertain < 0) return applicable; + return applicable.slice(lastCertain).filter((flow, index) => index === 0 || flow.uncertain); + }; + const ownerIdentities = ( + entry: Node, + atOrder: number, + allPossible: boolean, + 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); + 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.uncertain, + 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, seenBindings) : []; + } + if (value.type === "ConditionalExpression") { + return [value.consequent, value.alternate].filter(isNode).flatMap((branch) => + ownerIdentities(branch, atOrder, true, new Set(seenBindings)) + ); + } + if (value.type === "LogicalExpression") { + return [value.left, value.right].filter(isNode).flatMap((branch) => + ownerIdentities(branch, atOrder, true, new Set(seenBindings)) + ); + } + if (value.type === "AssignmentExpression" && isNode(value.right)) { + return ownerIdentities(value.right, atOrder, allPossible, seenBindings); + } + if (value.type === "AwaitExpression" && isNode(value.argument)) { + return ownerIdentities(value.argument, atOrder, allPossible, seenBindings); + } + return [value]; + }; + const concreteValues = ( entry: Node, seenBindings = new Set(), @@ -2795,21 +2955,18 @@ function invokedFunctionParameterBindings( const key = memberKey(value); if (key === null) return []; const members: Node[] = []; - const ownerReference = unwrapTransparent(value.object); - if (ownerReference.type === "Identifier") { - const ownerBinding = bindings.reference(ownerReference); - if (ownerBinding) { - const pending = [ownerBinding]; - const visited = new Set(); - while (pending.length > 0) { - const candidate = pending.pop(); - if (!candidate || visited.has(candidate)) continue; - visited.add(candidate); - for (const source of memberValueFlows.get(candidate)?.get(key) ?? []) { - members.push(...concreteValues(source, new Set(seenBindings))); - } - pending.push(...memberOwnerAliases.get(candidate) ?? []); - } + const readOrder = nodeOrders.get(value) ?? Number.POSITIVE_INFINITY; + const readOwners = new Set( + ownerIdentities(value.object, readOrder, uncertainNodes.has(value)), + ); + for (const flow of memberValueFlows.get(key) ?? []) { + const writeOwners = ownerIdentities( + flow.owner, + flow.order, + flow.uncertain, + ); + if (writeOwners.some((owner) => readOwners.has(owner))) { + members.push(...concreteValues(flow.value, new Set(seenBindings))); } } From e14bc69c9cb17b043a027b02507204169b8779e7 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 18 Aug 2026 04:16:25 +0200 Subject: [PATCH 70/81] fix(transforms): account for hoisted function owners --- .../browser-server-exports-strip.test.ts | 50 +++++++++++++++++++ .../stages/browser-server-exports-strip.ts | 8 ++- 2 files changed, 56 insertions(+), 2 deletions(-) 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 2338373d08..eab250f691 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts @@ -1655,6 +1655,56 @@ describe("browser-server-exports-strip", () => { 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"); + + const result = await stripServerOnlyExports(code); + + assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); + assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + }); + + 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 past a write through a shadowing alias parameter", async () => { const code = [ `const intrinsic = Object;`, diff --git a/src/transforms/pipeline/stages/browser-server-exports-strip.ts b/src/transforms/pipeline/stages/browser-server-exports-strip.ts index 786fcd8d59..08bc58a51d 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.ts @@ -2805,7 +2805,11 @@ function invokedFunctionParameterBindings( (node.type === "FunctionDeclaration" || node.type === "ClassDeclaration") && isNode(node.id) ) { - addOwnerValueFlow(bindings.declaration(node.id), node, order, uncertain); + // 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, uncertain); } else if (node.type === "VariableDeclarator" && isNode(node.id) && isNode(node.init)) { if (node.id.type === "Identifier") { addOwnerValueFlow(bindings.declaration(node.id), node.init, order, uncertain); @@ -2852,7 +2856,7 @@ function invokedFunctionParameterBindings( ): 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. From 9dd4cdc92156a52345b674d32cc70f7880663e6f Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 18 Aug 2026 04:32:48 +0200 Subject: [PATCH 71/81] fix(transforms): preserve execution-scope flow order --- .../browser-server-exports-strip.test.ts | 116 ++++++++++++++ .../stages/browser-server-exports-strip.ts | 149 +++++++++++++----- 2 files changed, 224 insertions(+), 41 deletions(-) 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 eab250f691..a83d56c84e 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts @@ -1705,6 +1705,122 @@ describe("browser-server-exports-strip", () => { 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"); + + const result = await stripServerOnlyExports(code); + + assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); + assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + }); + + 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"); + + const result = await stripServerOnlyExports(code); + + assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); + assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + }); + it("still strips metadata past a write through a shadowing alias parameter", async () => { const code = [ `const intrinsic = Object;`, diff --git a/src/transforms/pipeline/stages/browser-server-exports-strip.ts b/src/transforms/pipeline/stages/browser-server-exports-strip.ts index 08bc58a51d..384e14470a 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.ts @@ -2653,21 +2653,29 @@ function invokedFunctionParameterBindings( interface OwnerValueFlow { value: Node; order: number; - uncertain: boolean; + controlUncertain: boolean; + scope: Node | null; } interface MemberValueFlow { owner: Node; value: Node; order: number; - uncertain: boolean; + controlUncertain: boolean; + scope: Node | null; } const ownerValueFlows = new Map(); const memberValueFlows = new Map(); const nodeOrders = new Map(); - const uncertainNodes = new Set(); + const controlUncertainNodes = new Set(); + const ownerExecutionScopes = new Map(); const addValueFlow = (target: LexicalBindingIdentity | null, value: Node): void => { if (!target) return; - const values = valueFlows.get(target) ?? []; + // 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) ?? []; values.push(value); valueFlows.set(target, values); }; @@ -2675,11 +2683,12 @@ function invokedFunctionParameterBindings( target: LexicalBindingIdentity | null, value: Node, order: number, - uncertain: boolean, + controlUncertain: boolean, + scope: Node | null, ): void => { if (!target) return; const values = ownerValueFlows.get(target) ?? []; - values.push({ value, order, uncertain }); + values.push({ value, order, controlUncertain, scope }); ownerValueFlows.set(target, values); }; @@ -2706,13 +2715,15 @@ function invokedFunctionParameterBindings( }); } - const uncertainFlowParentTypes = new Set([ + const ownerExecutionScopeTypes = new Set([ "FunctionDeclaration", "FunctionExpression", "ArrowFunctionExpression", "ObjectMethod", "ClassMethod", "ClassPrivateMethod", + ]); + const controlUncertainFlowParentTypes = new Set([ "ClassDeclaration", "ClassExpression", "ConditionalExpression", @@ -2733,7 +2744,8 @@ function invokedFunctionParameterBindings( source: Node, declaration: boolean, order: number, - uncertain: boolean, + controlUncertain: boolean, + scope: Node | null, ): void => { const target = unwrapTransparent(pattern); const value = unwrapTransparent(source); @@ -2742,43 +2754,50 @@ function invokedFunctionParameterBindings( declaration ? bindings.declaration(target) : bindings.reference(target), value, order, - uncertain, + controlUncertain, + scope, ); return; } if (target.type === "AssignmentPattern" && isNode(target.left)) { - addPatternOwnerFlows(target.left, value, declaration, order, uncertain); + addPatternOwnerFlows(target.left, value, declaration, order, controlUncertain, scope); if (isNode(target.right)) { - addPatternOwnerFlows(target.left, target.right, declaration, order, true); + addPatternOwnerFlows(target.left, target.right, declaration, order, true, scope); } 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, uncertain); + if (last) { + addPatternOwnerFlows(target, last, declaration, order, controlUncertain, scope); + } return; } if (value.type === "ConditionalExpression") { if (isNode(value.consequent)) { - addPatternOwnerFlows(target, value.consequent, declaration, order, true); + addPatternOwnerFlows(target, value.consequent, declaration, order, true, scope); } if (isNode(value.alternate)) { - addPatternOwnerFlows(target, value.alternate, declaration, order, true); + addPatternOwnerFlows(target, value.alternate, declaration, order, true, scope); } return; } if (value.type === "LogicalExpression") { - if (isNode(value.left)) addPatternOwnerFlows(target, value.left, declaration, order, true); - if (isNode(value.right)) addPatternOwnerFlows(target, value.right, declaration, order, true); + if (isNode(value.left)) { + addPatternOwnerFlows(target, value.left, declaration, order, true, scope); + } + if (isNode(value.right)) { + addPatternOwnerFlows(target, value.right, declaration, order, true, scope); + } return; } if (value.type === "AssignmentExpression" && isNode(value.right)) { - addPatternOwnerFlows(target, value.right, declaration, order, uncertain); + addPatternOwnerFlows(target, value.right, declaration, order, controlUncertain, scope); return; } if (value.type === "AwaitExpression" && isNode(value.argument)) { - addPatternOwnerFlows(target, value.argument, declaration, order, uncertain); + addPatternOwnerFlows(target, value.argument, declaration, order, controlUncertain, scope); return; } if ( @@ -2790,16 +2809,28 @@ function invokedFunctionParameterBindings( for (const [index, element] of targets.entries()) { const elementValue = sources[index]; if (isNode(element) && isNode(elementValue)) { - addPatternOwnerFlows(element, elementValue, declaration, order, uncertain); + addPatternOwnerFlows( + element, + elementValue, + declaration, + order, + controlUncertain, + scope, + ); } } }; let visitOrder = 0; - const collectOwnerFlows = (node: Node, uncertain: boolean): void => { + const collectOwnerFlows = ( + node: Node, + controlUncertain: boolean, + scope: Node | null, + ): void => { const order = visitOrder++; nodeOrders.set(node, order); - if (uncertain) uncertainNodes.add(node); + ownerExecutionScopes.set(node, scope); + if (controlUncertain) controlUncertainNodes.add(node); if ( (node.type === "FunctionDeclaration" || node.type === "ClassDeclaration") && @@ -2809,20 +2840,38 @@ function invokedFunctionParameterBindings( // 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, uncertain); + addOwnerValueFlow( + bindings.declaration(node.id), + node, + flowOrder, + controlUncertain, + 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, uncertain); + addOwnerValueFlow( + bindings.declaration(node.id), + node.init, + order, + controlUncertain, + scope, + ); } else if (node.id.type === "ArrayPattern") { - addPatternOwnerFlows(node.id, node.init, true, order, uncertain); + addPatternOwnerFlows(node.id, node.init, true, order, controlUncertain, scope); } } else if ( node.type === "AssignmentExpression" && isNode(node.left) && isNode(node.right) ) { if (node.left.type === "Identifier") { - addOwnerValueFlow(bindings.reference(node.left), node.right, order, uncertain); + addOwnerValueFlow( + bindings.reference(node.left), + node.right, + order, + controlUncertain, + scope, + ); } else if (node.left.type === "ArrayPattern" || node.left.type === "ArrayExpression") { - addPatternOwnerFlows(node.left, node.right, false, order, uncertain); + addPatternOwnerFlows(node.left, node.right, false, order, controlUncertain, scope); } else if ( (node.left.type === "MemberExpression" || node.left.type === "OptionalMemberExpression") && isNode(node.left.object) @@ -2834,18 +2883,23 @@ function invokedFunctionParameterBindings( owner: node.left.object, value: node.right, order, - uncertain, + controlUncertain, + scope, }); memberValueFlows.set(key, flows); } } } - const childUncertain = uncertain || uncertainFlowParentTypes.has(node.type); - for (const child of children(node)) collectOwnerFlows(child, childUncertain); + const childControlUncertain = controlUncertain || + controlUncertainFlowParentTypes.has(node.type); + const childScope = ownerExecutionScopeTypes.has(node.type) ? node : scope; + for (const child of children(node)) { + collectOwnerFlows(child, childControlUncertain, childScope); + } }; for (const statement of body) { - if (statement.type !== "ImportDeclaration") collectOwnerFlows(statement, false); + if (statement.type !== "ImportDeclaration") collectOwnerFlows(statement, false, null); } type OwnerIdentity = Node | LexicalBindingIdentity; @@ -2853,6 +2907,7 @@ function invokedFunctionParameterBindings( binding: LexicalBindingIdentity, atOrder: number, allPossible: boolean, + scope: Node | null, ): OwnerValueFlow[] => { const applicable = (ownerValueFlows.get(binding) ?? []).filter((flow) => allPossible || flow.order <= atOrder @@ -2862,25 +2917,29 @@ function invokedFunctionParameterBindings( // writes after it remain possible until another certain write occurs. let lastCertain = -1; for (let index = applicable.length - 1; index >= 0; index--) { - if (!applicable[index]?.uncertain) { + 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.uncertain); + 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); + const flows = activeOwnerFlows(binding, atOrder, allPossible, scope); if (flows.length === 0) return [binding]; const nextSeen = new Set(seenBindings); nextSeen.add(binding); @@ -2888,7 +2947,8 @@ function invokedFunctionParameterBindings( ownerIdentities( flow.value, flow.order, - allPossible || flow.uncertain, + allPossible || flow.controlUncertain || flow.scope !== scope, + flow.scope, new Set(nextSeen), ) ); @@ -2896,23 +2956,23 @@ function invokedFunctionParameterBindings( 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, seenBindings) : []; + 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, new Set(seenBindings)) + 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, new Set(seenBindings)) + ownerIdentities(branch, atOrder, true, scope, new Set(seenBindings)) ); } if (value.type === "AssignmentExpression" && isNode(value.right)) { - return ownerIdentities(value.right, atOrder, allPossible, seenBindings); + return ownerIdentities(value.right, atOrder, allPossible, scope, seenBindings); } if (value.type === "AwaitExpression" && isNode(value.argument)) { - return ownerIdentities(value.argument, atOrder, allPossible, seenBindings); + return ownerIdentities(value.argument, atOrder, allPossible, scope, seenBindings); } return [value]; }; @@ -2960,14 +3020,21 @@ function invokedFunctionParameterBindings( if (key === null) return []; const members: Node[] = []; const readOrder = nodeOrders.get(value) ?? Number.POSITIVE_INFINITY; + const readScope = ownerExecutionScopes.get(value) ?? null; const readOwners = new Set( - ownerIdentities(value.object, readOrder, uncertainNodes.has(value)), + ownerIdentities( + value.object, + readOrder, + controlUncertainNodes.has(value), + readScope, + ), ); for (const flow of memberValueFlows.get(key) ?? []) { const writeOwners = ownerIdentities( flow.owner, flow.order, - flow.uncertain, + flow.controlUncertain, + flow.scope, ); if (writeOwners.some((owner) => readOwners.has(owner))) { members.push(...concreteValues(flow.value, new Set(seenBindings))); From 578208ccea5879c39391db6d5f5dce2c799b1848 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 18 Aug 2026 04:47:50 +0200 Subject: [PATCH 72/81] fix(transforms): resolve active member values --- .../browser-server-exports-strip.test.ts | 176 ++++++++++++++++++ .../stages/browser-server-exports-strip.ts | 84 +++++++-- 2 files changed, 245 insertions(+), 15 deletions(-) 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 a83d56c84e..47fa18369e 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts @@ -1821,6 +1821,182 @@ describe("browser-server-exports-strip", () => { assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); }); + 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"), + ], + ] 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"); + + const result = await stripServerOnlyExports(code); + + assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); + assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + }); + } + + 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"); + + const result = await stripServerOnlyExports(code); + + assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); + assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + }); + } + + 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("still strips metadata past a write through a shadowing alias parameter", async () => { const code = [ `const intrinsic = Object;`, diff --git a/src/transforms/pipeline/stages/browser-server-exports-strip.ts b/src/transforms/pipeline/stages/browser-server-exports-strip.ts index 384e14470a..74232ed8f5 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.ts @@ -3021,24 +3021,77 @@ function invokedFunctionParameterBindings( const members: Node[] = []; const readOrder = nodeOrders.get(value) ?? Number.POSITIVE_INFINITY; const readScope = ownerExecutionScopes.get(value) ?? null; + const readAllPossible = controlUncertainNodes.has(value); + const resolveOwnerIdentities = (owners: OwnerIdentity[]): 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)); + // 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( - ownerIdentities( - value.object, - readOrder, - controlUncertainNodes.has(value), - readScope, + resolveOwnerIdentities( + ownerIdentities( + value.object, + readOrder, + readAllPossible, + readScope, + ), ), ); - for (const flow of memberValueFlows.get(key) ?? []) { - const writeOwners = ownerIdentities( - flow.owner, - flow.order, - flow.controlUncertain, - flow.scope, - ); - if (writeOwners.some((owner) => readOwners.has(owner))) { - members.push(...concreteValues(flow.value, new Set(seenBindings))); + + const resolvedMemberFlows = (memberValueFlows.get(key) ?? []) + .filter((flow) => + readAllPossible || flow.order <= readOrder || flow.controlUncertain || + flow.scope !== readScope + ) + .map((flow) => ({ + flow, + owners: new Set( + resolveOwnerIdentities( + ownerIdentities( + flow.owner, + flow.order, + flow.controlUncertain, + flow.scope, + ), + ), + ), + })) + .sort((left, right) => left.flow.order - right.flow.order); + 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.flow.scope === readScope && candidate.owners.size === 1 + ) { + lastCertain = index; + break; + } + } } + const active = lastCertain < 0 + ? applicable + : applicable.slice(lastCertain).filter(({ flow, owners }, index) => + index === 0 || flow.controlUncertain || 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) { + members.push(...concreteValues(flow.value, new Set(seenBindings))); } const seenOwners = new Set(); @@ -3078,7 +3131,8 @@ function invokedFunctionParameterBindings( return candidates; }; - for (const owner of concreteValues(value.object, new Set(seenBindings))) { + for (const owner of readOwners) { + if (!isNode(owner) || overriddenOwners.has(owner)) continue; if (owner.type === "ObjectExpression") { members.push(...collectObjectMember(owner)); continue; From d085250d42553bc624ca7132bd273515aaf58731 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 18 Aug 2026 04:55:12 +0200 Subject: [PATCH 73/81] fix(transforms): retain non-direct assignment flows --- .../browser-server-exports-strip.test.ts | 48 +++++++++++++++++++ .../stages/browser-server-exports-strip.ts | 43 ++++++++++++----- 2 files changed, 80 insertions(+), 11 deletions(-) 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 47fa18369e..c92d8777db 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts @@ -1821,6 +1821,47 @@ describe("browser-server-exports-strip", () => { assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); }); + 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"); + + const result = await stripServerOnlyExports(code); + + assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); + assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + }); + } + it("still strips metadata after a direct owner rebind", async () => { const code = [ `const mutatingFactory = () => function (intrinsic) {`, @@ -1901,6 +1942,13 @@ describe("browser-server-exports-strip", () => { `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 () => { diff --git a/src/transforms/pipeline/stages/browser-server-exports-strip.ts b/src/transforms/pipeline/stages/browser-server-exports-strip.ts index 74232ed8f5..1553dd6ddd 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.ts @@ -2793,7 +2793,18 @@ function invokedFunctionParameterBindings( return; } if (value.type === "AssignmentExpression" && isNode(value.right)) { - addPatternOwnerFlows(target, value.right, declaration, order, controlUncertain, scope); + const nonDirectAssignment = value.operator !== "="; + if (nonDirectAssignment && isNode(value.left)) { + addPatternOwnerFlows(target, value.left, declaration, order, true, scope); + } + addPatternOwnerFlows( + target, + value.right, + declaration, + order, + controlUncertain || nonDirectAssignment, + scope, + ); return; } if (value.type === "AwaitExpression" && isNode(value.argument)) { @@ -2828,9 +2839,11 @@ function invokedFunctionParameterBindings( scope: Node | null, ): void => { const order = visitOrder++; + const nodeControlUncertain = controlUncertain || + (node.type === "AssignmentExpression" && node.operator !== "="); nodeOrders.set(node, order); ownerExecutionScopes.set(node, scope); - if (controlUncertain) controlUncertainNodes.add(node); + if (nodeControlUncertain) controlUncertainNodes.add(node); if ( (node.type === "FunctionDeclaration" || node.type === "ClassDeclaration") && @@ -2844,7 +2857,7 @@ function invokedFunctionParameterBindings( bindings.declaration(node.id), node, flowOrder, - controlUncertain, + nodeControlUncertain, scope, ); } else if (node.type === "VariableDeclarator" && isNode(node.id) && isNode(node.init)) { @@ -2853,11 +2866,11 @@ function invokedFunctionParameterBindings( bindings.declaration(node.id), node.init, order, - controlUncertain, + nodeControlUncertain, scope, ); } else if (node.id.type === "ArrayPattern") { - addPatternOwnerFlows(node.id, node.init, true, order, controlUncertain, scope); + addPatternOwnerFlows(node.id, node.init, true, order, nodeControlUncertain, scope); } } else if ( node.type === "AssignmentExpression" && isNode(node.left) && isNode(node.right) @@ -2867,11 +2880,11 @@ function invokedFunctionParameterBindings( bindings.reference(node.left), node.right, order, - controlUncertain, + nodeControlUncertain, scope, ); } else if (node.left.type === "ArrayPattern" || node.left.type === "ArrayExpression") { - addPatternOwnerFlows(node.left, node.right, false, order, controlUncertain, scope); + addPatternOwnerFlows(node.left, node.right, false, order, nodeControlUncertain, scope); } else if ( (node.left.type === "MemberExpression" || node.left.type === "OptionalMemberExpression") && isNode(node.left.object) @@ -2883,7 +2896,7 @@ function invokedFunctionParameterBindings( owner: node.left.object, value: node.right, order, - controlUncertain, + controlUncertain: nodeControlUncertain, scope, }); memberValueFlows.set(key, flows); @@ -2891,7 +2904,7 @@ function invokedFunctionParameterBindings( } } - const childControlUncertain = controlUncertain || + const childControlUncertain = nodeControlUncertain || controlUncertainFlowParentTypes.has(node.type); const childScope = ownerExecutionScopeTypes.has(node.type) ? node : scope; for (const child of children(node)) { @@ -2969,7 +2982,12 @@ function invokedFunctionParameterBindings( ); } if (value.type === "AssignmentExpression" && isNode(value.right)) { - return ownerIdentities(value.right, atOrder, allPossible, scope, seenBindings); + 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); @@ -3007,7 +3025,10 @@ function invokedFunctionParameterBindings( ); } if (value.type === "AssignmentExpression" && isNode(value.right)) { - return concreteValues(value.right, seenBindings); + if (value.operator === "=") return concreteValues(value.right, seenBindings); + return [value.left, value.right].filter(isNode).flatMap((candidate) => + concreteValues(candidate, new Set(seenBindings)) + ); } if (value.type === "AwaitExpression" && isNode(value.argument)) { return concreteValues(value.argument, seenBindings); From a398a80b407dfa5537014a25fa4a91459d76d23f Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 18 Aug 2026 04:59:48 +0200 Subject: [PATCH 74/81] fix(transforms): preserve structured owner flows --- .../browser-server-exports-strip.test.ts | 282 +++++++++++++++++ .../stages/browser-server-exports-strip.ts | 289 ++++++++++++++++-- 2 files changed, 547 insertions(+), 24 deletions(-) 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 c92d8777db..679721f27c 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts @@ -2045,6 +2045,288 @@ describe("browser-server-exports-strip", () => { 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"); + + const result = await stripServerOnlyExports(code); + + assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); + assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + }); + + 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"); + + const result = await stripServerOnlyExports(code); + + assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); + assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + }); + } + + 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"); + + const result = await stripServerOnlyExports(code); + + assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); + assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + }); + + 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"); + + const result = await stripServerOnlyExports(code); + + assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); + assertStringIncludes(result, `const KEY = 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"); + + const result = await stripServerOnlyExports(code); + + assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); + assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + }); + + 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"); + + const result = await stripServerOnlyExports(code); + + assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); + assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + }); + + 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"); + + const result = await stripServerOnlyExports(code); + + assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); + assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + }); + + 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"); + + const result = await stripServerOnlyExports(code); + + assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); + assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + }); + it("still strips metadata past a write through a shadowing alias parameter", async () => { const code = [ `const intrinsic = Object;`, diff --git a/src/transforms/pipeline/stages/browser-server-exports-strip.ts b/src/transforms/pipeline/stages/browser-server-exports-strip.ts index 1553dd6ddd..ee73eb9c0a 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.ts @@ -2666,7 +2666,7 @@ function invokedFunctionParameterBindings( const ownerValueFlows = new Map(); const memberValueFlows = new Map(); const nodeOrders = new Map(); - const controlUncertainNodes = new Set(); + const repeatedControlNodes = new Set(); const ownerExecutionScopes = new Map(); const addValueFlow = (target: LexicalBindingIdentity | null, value: Node): void => { if (!target) return; @@ -2676,7 +2676,7 @@ function invokedFunctionParameterBindings( const values = value.type === "FunctionDeclaration" ? (valueFlows.get(target) ?? []).filter((entry) => entry.type !== "FunctionDeclaration") : valueFlows.get(target) ?? []; - values.push(value); + if (!values.includes(value)) values.push(value); valueFlows.set(target, values); }; const addOwnerValueFlow = ( @@ -2739,6 +2739,13 @@ function invokedFunctionParameterBindings( "TryStatement", "CatchClause", ]); + const repeatedControlFlowParentTypes = new Set([ + "WhileStatement", + "DoWhileStatement", + "ForStatement", + "ForInStatement", + "ForOfStatement", + ]); const addPatternOwnerFlows = ( pattern: Node, source: Node, @@ -2746,23 +2753,36 @@ function invokedFunctionParameterBindings( order: number, controlUncertain: boolean, scope: Node | null, + repeatedControl: boolean, ): void => { const target = unwrapTransparent(pattern); const value = unwrapTransparent(source); if (target.type === "Identifier") { - addOwnerValueFlow( - declaration ? bindings.declaration(target) : bindings.reference(target), + 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, ); - return; - } - if (target.type === "AssignmentPattern" && isNode(target.left)) { - addPatternOwnerFlows(target.left, value, declaration, order, controlUncertain, scope); if (isNode(target.right)) { - addPatternOwnerFlows(target.left, target.right, declaration, order, true, scope); + addPatternOwnerFlows( + target.left, + target.right, + declaration, + order, + true, + scope, + repeatedControl, + ); } return; } @@ -2770,32 +2790,80 @@ function invokedFunctionParameterBindings( const expressions = Array.isArray(value.expressions) ? value.expressions.filter(isNode) : []; const last = expressions.at(-1); if (last) { - addPatternOwnerFlows(target, last, declaration, order, controlUncertain, scope); + 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); + addPatternOwnerFlows( + target, + value.consequent, + declaration, + order, + true, + scope, + repeatedControl, + ); } if (isNode(value.alternate)) { - addPatternOwnerFlows(target, value.alternate, declaration, order, true, scope); + 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); + addPatternOwnerFlows( + target, + value.left, + declaration, + order, + true, + scope, + repeatedControl, + ); } if (isNode(value.right)) { - addPatternOwnerFlows(target, value.right, declaration, order, true, scope); + 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); + addPatternOwnerFlows( + target, + value.left, + declaration, + order, + true, + scope, + repeatedControl, + ); } addPatternOwnerFlows( target, @@ -2804,11 +2872,83 @@ function invokedFunctionParameterBindings( order, controlUncertain || nonDirectAssignment, scope, + repeatedControl, ); return; } if (value.type === "AwaitExpression" && isNode(value.argument)) { - addPatternOwnerFlows(target, value.argument, declaration, order, controlUncertain, scope); + 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 ( @@ -2827,6 +2967,7 @@ function invokedFunctionParameterBindings( order, controlUncertain, scope, + repeatedControl, ); } } @@ -2836,6 +2977,7 @@ function invokedFunctionParameterBindings( const collectOwnerFlows = ( node: Node, controlUncertain: boolean, + repeatedControl: boolean, scope: Node | null, ): void => { const order = visitOrder++; @@ -2843,7 +2985,7 @@ function invokedFunctionParameterBindings( (node.type === "AssignmentExpression" && node.operator !== "="); nodeOrders.set(node, order); ownerExecutionScopes.set(node, scope); - if (nodeControlUncertain) controlUncertainNodes.add(node); + if (repeatedControl) repeatedControlNodes.add(node); if ( (node.type === "FunctionDeclaration" || node.type === "ClassDeclaration") && @@ -2870,7 +3012,25 @@ function invokedFunctionParameterBindings( scope, ); } else if (node.id.type === "ArrayPattern") { - addPatternOwnerFlows(node.id, node.init, true, order, nodeControlUncertain, scope); + 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) @@ -2883,8 +3043,19 @@ function invokedFunctionParameterBindings( nodeControlUncertain, scope, ); - } else if (node.left.type === "ArrayPattern" || node.left.type === "ArrayExpression") { - addPatternOwnerFlows(node.left, node.right, false, 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) @@ -2904,15 +3075,22 @@ function invokedFunctionParameterBindings( } } + // 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, childScope); + collectOwnerFlows(child, childControlUncertain, childRepeatedControl, childScope); } }; for (const statement of body) { - if (statement.type !== "ImportDeclaration") collectOwnerFlows(statement, false, null); + if (statement.type !== "ImportDeclaration") { + collectOwnerFlows(statement, false, false, null); + } } type OwnerIdentity = Node | LexicalBindingIdentity; @@ -3038,11 +3216,74 @@ function invokedFunctionParameterBindings( isNode(value.object) ) { const key = memberKey(value); - if (key === null) return []; + if (key === null) { + const property = isNode(value.property) ? value.property : undefined; + if (!property) return []; + const resolvedKeys = new Set( + concreteValues(property, new Set(seenBindings)) + .map((candidate) => stringLiteralText(candidate)) + .filter((candidate): candidate is string => candidate !== null), + ); + if (resolvedKeys.size === 0) { + 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))) { + 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; + const name = candidate.computed === true + ? stringLiteralText(candidateKey) + : 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; + const name = candidate.computed === true + ? stringLiteralText(candidate.key) + : literalText(candidate.key); + if (name !== null) resolvedKeys.add(name); + } + } + }; + collectKnownKeys(value.object); + } + const members: Node[] = []; + 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))); + } + return members; + } const members: Node[] = []; const readOrder = nodeOrders.get(value) ?? Number.POSITIVE_INFINITY; const readScope = ownerExecutionScopes.get(value) ?? null; - const readAllPossible = controlUncertainNodes.has(value); + const readAllPossible = repeatedControlNodes.has(value); const resolveOwnerIdentities = (owners: OwnerIdentity[]): OwnerIdentity[] => owners.flatMap((owner): OwnerIdentity[] => { if (!isNode(owner)) return [owner]; From e597cf78781899b96bbf8324729f55d9c5900d0c Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 18 Aug 2026 05:15:34 +0200 Subject: [PATCH 75/81] fix(transforms): preserve mixed computed key flows --- .../browser-server-exports-strip.test.ts | 26 +++++++ .../stages/browser-server-exports-strip.ts | 69 +++++++++++++++++-- 2 files changed, 89 insertions(+), 6 deletions(-) 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 679721f27c..5a27b81334 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts @@ -2161,6 +2161,32 @@ describe("browser-server-exports-strip", () => { assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); }); + 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"); + + const result = await stripServerOnlyExports(code); + + assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); + assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + }); + it("keeps metadata after a write through a computed object-destructured owner", async () => { const code = [ `const mutatingFactory = () => function (intrinsic) {`, diff --git a/src/transforms/pipeline/stages/browser-server-exports-strip.ts b/src/transforms/pipeline/stages/browser-server-exports-strip.ts index ee73eb9c0a..394b8b158d 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.ts @@ -3219,12 +3219,69 @@ function invokedFunctionParameterBindings( if (key === null) { const property = isNode(value.property) ? value.property : undefined; if (!property) return []; - const resolvedKeys = new Set( - concreteValues(property, new Set(seenBindings)) - .map((candidate) => stringLiteralText(candidate)) - .filter((candidate): candidate is string => candidate !== null), - ); - if (resolvedKeys.size === 0) { + const resolveStringValues = ( + entry: Node, + seen = 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(seen))); + return { + values: resolutions.flatMap((resolution) => resolution.values), + complete: resolutions.every((resolution) => resolution.complete), + }; + }; + + if (candidate.type === "Identifier") { + const binding = bindings.reference(candidate); + if (!binding || seen.has(binding)) return { values: [], complete: false }; + const sources = valueFlows.get(binding) ?? []; + const nextSeen = new Set(seen); + nextSeen.add(binding); + if (sources.length === 0) return { values: [], complete: false }; + const resolutions = sources.map((source) => + resolveStringValues(source, new Set(nextSeen)) + ); + 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, seen) : { 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, seen) + : merge([candidate.left, candidate.right].filter(isNode)); + } + if (candidate.type === "AwaitExpression" && isNode(candidate.argument)) { + return resolveStringValues(candidate.argument, seen); + } + + const concrete = concreteValues(candidate, new Set(seen)); + const values = concrete + .map((resolved) => stringLiteralText(resolved)) + .filter((resolved): resolved is string => resolved !== null); + return { + values, + complete: concrete.length > 0 && values.length === concrete.length, + }; + }; + const keyResolution = resolveStringValues(property, new Set(seenBindings)); + const resolvedKeys = new Set(keyResolution.values); + if (!keyResolution.complete) { for (const knownKey of memberValueFlows.keys()) resolvedKeys.add(knownKey); const seenKeyOwners = new Set(); const collectKnownKeys = (entry: Node): void => { From 2a9e3d72bd4497b9d80ab3136c8a4b1e55bb53da Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 18 Aug 2026 05:24:59 +0200 Subject: [PATCH 76/81] fix(transforms): bound incomplete member flows --- .../browser-server-exports-strip.test.ts | 76 +++++++ .../stages/browser-server-exports-strip.ts | 190 +++++++++++++++--- 2 files changed, 239 insertions(+), 27 deletions(-) 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 5a27b81334..68507f292b 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts @@ -2187,6 +2187,82 @@ describe("browser-server-exports-strip", () => { assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); }); + 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"); + + const result = await stripServerOnlyExports(code); + + assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); + assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + }); + + 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"); + + const result = await stripServerOnlyExports(code); + + assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); + assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + }); + + 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"); + + const result = await stripServerOnlyExports(code); + + assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); + assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + }); + it("keeps metadata after a write through a computed object-destructured owner", async () => { const code = [ `const mutatingFactory = () => function (intrinsic) {`, diff --git a/src/transforms/pipeline/stages/browser-server-exports-strip.ts b/src/transforms/pipeline/stages/browser-server-exports-strip.ts index 394b8b158d..c1a28702eb 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.ts @@ -3176,6 +3176,7 @@ function invokedFunctionParameterBindings( const concreteValues = ( entry: Node, seenBindings = new Set(), + seenMemberFlows = new Set(), ): Node[] => { const value = unwrapTransparent(entry); if (value.type === "Identifier") { @@ -3184,32 +3185,34 @@ function invokedFunctionParameterBindings( const nextSeen = new Set(seenBindings); nextSeen.add(binding); return (valueFlows.get(binding) ?? []).flatMap((source) => - concreteValues(source, new Set(nextSeen)) + 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) : []; + return last ? concreteValues(last, seenBindings, seenMemberFlows) : []; } if (value.type === "ConditionalExpression") { return [value.consequent, value.alternate].filter(isNode).flatMap((branch) => - concreteValues(branch, new Set(seenBindings)) + 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)) + concreteValues(branch, new Set(seenBindings), new Set(seenMemberFlows)) ); } if (value.type === "AssignmentExpression" && isNode(value.right)) { - if (value.operator === "=") return concreteValues(value.right, seenBindings); + if (value.operator === "=") { + return concreteValues(value.right, seenBindings, seenMemberFlows); + } return [value.left, value.right].filter(isNode).flatMap((candidate) => - concreteValues(candidate, new Set(seenBindings)) + concreteValues(candidate, new Set(seenBindings), new Set(seenMemberFlows)) ); } if (value.type === "AwaitExpression" && isNode(value.argument)) { - return concreteValues(value.argument, seenBindings); + return concreteValues(value.argument, seenBindings, seenMemberFlows); } if ( (value.type === "MemberExpression" || value.type === "OptionalMemberExpression") && @@ -3270,7 +3273,57 @@ function invokedFunctionParameterBindings( return resolveStringValues(candidate.argument, seen); } - const concrete = concreteValues(candidate, new Set(seen)); + 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, seen); + } + const concrete = concreteValues( + candidate, + new Set(seen), + 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" + ) { + const concrete = concreteValues( + candidate, + new Set(seen), + new Set(seenMemberFlows), + ); + return { + values: concrete + .map((resolved) => stringLiteralText(resolved)) + .filter((resolved): resolved is string => resolved !== null), + // A member read can retain concrete values while an unresolved + // owner, key, or write flow contributes another runtime value. + complete: false, + }; + } + + const concrete = concreteValues( + candidate, + new Set(seen), + new Set(seenMemberFlows), + ); const values = concrete .map((resolved) => stringLiteralText(resolved)) .filter((resolved): resolved is string => resolved !== null); @@ -3281,11 +3334,18 @@ function invokedFunctionParameterBindings( }; const keyResolution = resolveStringValues(property, new Set(seenBindings)); const resolvedKeys = new Set(keyResolution.values); + const members: Node[] = []; if (!keyResolution.complete) { 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))) { + for ( + const owner of concreteValues( + entry, + new Set(seenBindings), + new Set(seenMemberFlows), + ) + ) { if (seenKeyOwners.has(owner)) continue; seenKeyOwners.add(owner); if (owner.type === "ObjectExpression") { @@ -3297,9 +3357,26 @@ function invokedFunctionParameterBindings( continue; } const candidateKey = isNode(candidate.key) ? candidate.key : undefined; - const name = candidate.computed === true - ? stringLiteralText(candidateKey) - : literalText(candidateKey); + 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") { + 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; @@ -3310,16 +3387,32 @@ function invokedFunctionParameterBindings( : []; for (const candidate of classMembers) { if (candidate.static !== true || !isNode(candidate.key)) continue; - const name = candidate.computed === true - ? stringLiteralText(candidate.key) - : literalText(candidate.key); + 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") { + 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); } - const members: Node[] = []; for (const resolvedKey of resolvedKeys) { const resolvedMember: Node = { ...value, @@ -3333,7 +3426,11 @@ function invokedFunctionParameterBindings( ownerExecutionScopes.get(value) ?? null, ); if (repeatedControlNodes.has(value)) repeatedControlNodes.add(resolvedMember); - members.push(...concreteValues(resolvedMember, new Set(seenBindings))); + members.push(...concreteValues( + resolvedMember, + new Set(seenBindings), + new Set(seenMemberFlows), + )); } return members; } @@ -3349,7 +3446,11 @@ function invokedFunctionParameterBindings( candidate.type !== "MemberExpression" && candidate.type !== "OptionalMemberExpression" ) return [owner]; - const resolved = concreteValues(candidate, new Set(seenBindings)); + const resolved = concreteValues( + candidate, + new Set(seenBindings), + new Set(seenMemberFlows), + ); // 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]; @@ -3383,7 +3484,8 @@ function invokedFunctionParameterBindings( ), ), })) - .sort((left, right) => left.flow.order - right.flow.order); + .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) { @@ -3410,7 +3512,13 @@ function invokedFunctionParameterBindings( if (lastCertain >= 0) overriddenOwners.add(readOwner); } for (const flow of activeMemberFlows) { - members.push(...concreteValues(flow.value, new Set(seenBindings))); + const nextSeenMemberFlows = new Set(seenMemberFlows); + nextSeenMemberFlows.add(flow); + members.push(...concreteValues( + flow.value, + new Set(seenBindings), + nextSeenMemberFlows, + )); } const seenOwners = new Set(); @@ -3428,7 +3536,13 @@ function invokedFunctionParameterBindings( if (!isNode(property)) continue; if (property.type === "SpreadElement") { if (!isNode(property.argument)) continue; - for (const spread of concreteValues(property.argument, new Set(seenBindings))) { + for ( + const spread of concreteValues( + property.argument, + new Set(seenBindings), + new Set(seenMemberFlows), + ) + ) { if (spread.type === "ObjectExpression") { candidates.push(...collectObjectMember(spread)); } @@ -3443,7 +3557,11 @@ function invokedFunctionParameterBindings( if (property.type === "ObjectMethod") { candidates.push(property); } else if (property.type === "ObjectProperty" && isNode(property.value)) { - candidates.push(...concreteValues(property.value, new Set(seenBindings))); + candidates.push(...concreteValues( + property.value, + new Set(seenBindings), + new Set(seenMemberFlows), + )); } return candidates; } @@ -3471,7 +3589,11 @@ function invokedFunctionParameterBindings( if (property.type === "ClassMethod") { members.push(property); } else if (isNode(property.value)) { - members.push(...concreteValues(property.value, new Set(seenBindings))); + members.push(...concreteValues( + property.value, + new Set(seenBindings), + new Set(seenMemberFlows), + )); } break; } @@ -3487,13 +3609,19 @@ function invokedFunctionParameterBindings( (binder.type === "MemberExpression" || binder.type === "OptionalMemberExpression") && memberKey(binder) === "bind" && isNode(binder.object) ) { - return concreteValues(binder.object, seenBindings); + 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))) { + 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" && @@ -3503,13 +3631,21 @@ function invokedFunctionParameterBindings( // iterator, neither synchronously hands the caller a callable value. if (callee.async === true || callee.generator === true || !isNode(callee.body)) continue; if (callee.body.type !== "BlockStatement") { - returned.push(...concreteValues(callee.body, new Set(seenBindings))); + returned.push(...concreteValues( + callee.body, + new Set(seenBindings), + new Set(seenMemberFlows), + )); continue; } walk(callee.body, (node) => { if (node !== callee.body && startsVarScope(node)) return false; if (node.type === "ReturnStatement" && isNode(node.argument)) { - returned.push(...concreteValues(node.argument, new Set(seenBindings))); + returned.push(...concreteValues( + node.argument, + new Set(seenBindings), + new Set(seenMemberFlows), + )); } return true; }); From ca026f383e34958513273c2175bcbcdea1e5c29b Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 18 Aug 2026 05:36:25 +0200 Subject: [PATCH 77/81] fix(transforms): model computed member flows --- .../browser-server-exports-strip.test.ts | 172 +++++++ .../stages/browser-server-exports-strip.ts | 442 ++++++++++++------ 2 files changed, 470 insertions(+), 144 deletions(-) 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 68507f292b..5d0c152b8f 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts @@ -2263,6 +2263,178 @@ describe("browser-server-exports-strip", () => { assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); }); + 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"); + + const result = await stripServerOnlyExports(code); + + assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); + assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + }); + } + + 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"); + + const result = await stripServerOnlyExports(code); + + assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); + assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + }); + } + + 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")`); + }); + } + + for ( + const [label, ownerDeclaration, invocation] of [ + [ + "object getter", + `const owner = { get make() { return mutator; } };`, + `owner.make(Object);`, + ], + [ + "static class getter", + `class Owner { static get make() { return mutator; } }`, + `Owner.make(Object);`, + ], + ] as const + ) { + it(`keeps metadata through a callable returned by a ${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"); + + const result = await stripServerOnlyExports(code); + + assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); + assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + }); + } + it("keeps metadata after a write through a computed object-destructured owner", async () => { const code = [ `const mutatingFactory = () => function (intrinsic) {`, diff --git a/src/transforms/pipeline/stages/browser-server-exports-strip.ts b/src/transforms/pipeline/stages/browser-server-exports-strip.ts index c1a28702eb..237bd3f293 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.ts @@ -2663,8 +2663,12 @@ function invokedFunctionParameterBindings( 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(); @@ -3071,6 +3075,18 @@ function invokedFunctionParameterBindings( 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, + }); + } } } } @@ -3173,6 +3189,157 @@ function invokedFunctionParameterBindings( 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" + ) { + const concrete = concreteValues( + candidate, + new Set(seenBindings), + new Set(seenMemberFlows), + ); + return { + values: concrete + .map((resolved) => stringLiteralText(resolved)) + .filter((resolved): resolved is string => resolved !== null), + // A member read can retain concrete values while an unresolved owner, + // key, or write flow contributes another runtime value. + 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 ( + callable.async === true || callable.generator === true || + !isNode(callable.body) + ) return []; + if (callable.body.type !== "BlockStatement") { + return concreteValues( + callable.body, + new Set(seenBindings), + new Set(seenMemberFlows), + ); + } + 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(seenMemberFlows), + )); + } + return true; + }); + return returned; + } + const concreteValues = ( entry: Node, seenBindings = new Set(), @@ -3222,120 +3389,66 @@ function invokedFunctionParameterBindings( if (key === null) { const property = isNode(value.property) ? value.property : undefined; if (!property) return []; - const resolveStringValues = ( - entry: Node, - seen = 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(seen))); - return { - values: resolutions.flatMap((resolution) => resolution.values), - complete: resolutions.every((resolution) => resolution.complete), - }; - }; - - if (candidate.type === "Identifier") { - const binding = bindings.reference(candidate); - if (!binding || seen.has(binding)) return { values: [], complete: false }; - const sources = valueFlows.get(binding) ?? []; - const nextSeen = new Set(seen); - nextSeen.add(binding); - if (sources.length === 0) return { values: [], complete: false }; - const resolutions = sources.map((source) => - resolveStringValues(source, new Set(nextSeen)) - ); - 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, seen) : { 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, seen) - : merge([candidate.left, candidate.right].filter(isNode)); - } - if (candidate.type === "AwaitExpression" && isNode(candidate.argument)) { - return resolveStringValues(candidate.argument, seen); - } - - 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, seen); - } - const concrete = concreteValues( - candidate, - new Set(seen), - 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" - ) { - const concrete = concreteValues( - candidate, - new Set(seen), - new Set(seenMemberFlows), - ); - return { - values: concrete - .map((resolved) => stringLiteralText(resolved)) - .filter((resolved): resolved is string => resolved !== null), - // A member read can retain concrete values while an unresolved - // owner, key, or write flow contributes another runtime value. - complete: false, - }; - } - - const concrete = concreteValues( - candidate, - new Set(seen), - 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, - }; - }; - const keyResolution = resolveStringValues(property, new Set(seenBindings)); + 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) { + const flowKeyResolution = resolveStringValues( + flow.key, + new Set(seenBindings), + new Set(seenMemberFlows), + ); + for (const name of flowKeyResolution.values) resolvedKeys.add(name); + if ( + flowKeyResolution.complete || + seenMemberFlows.has(flow) || + (!readAllPossible && flow.order > readOrder && + !flow.controlUncertain && flow.scope === readScope) + ) continue; + const resolveOwnerIdentities = (owners: OwnerIdentity[]): 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(seenMemberFlows), + ); + 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, + ))); + if (![...readOwners].some((owner) => flowOwners.has(owner))) continue; + const nextSeenMemberFlows = new Set(seenMemberFlows); + nextSeenMemberFlows.add(flow); + 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 => { @@ -3365,7 +3478,15 @@ function invokedFunctionParameterBindings( for (const name of candidateResolution.values) resolvedKeys.add(name); if (!candidateResolution.complete) { if (candidate.type === "ObjectMethod") { - members.push(candidate); + 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, @@ -3395,7 +3516,15 @@ function invokedFunctionParameterBindings( for (const name of candidateResolution.values) resolvedKeys.add(name); if (!candidateResolution.complete) { if (candidate.type === "ClassMethod") { - members.push(candidate); + 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, @@ -3466,7 +3595,17 @@ function invokedFunctionParameterBindings( ), ); - const resolvedMemberFlows = (memberValueFlows.get(key) ?? []) + const resolvedMemberFlows = [ + ...(memberValueFlows.get(key) ?? []), + ...computedMemberValueFlows.filter((flow) => { + const resolution = resolveStringValues( + flow.key, + new Set(seenBindings), + new Set(seenMemberFlows), + ); + return !resolution.complete || resolution.values.includes(key); + }), + ] .filter((flow) => readAllPossible || flow.order <= readOrder || flow.controlUncertain || flow.scope !== readScope @@ -3550,12 +3689,27 @@ function invokedFunctionParameterBindings( continue; } const propertyKey = isNode(property.key) ? property.key : undefined; - const name = property.computed === true - ? stringLiteralText(propertyKey) - : literalText(propertyKey); - if (name !== key) continue; + const matches = property.computed === true && propertyKey + ? (() => { + const resolution = resolveStringValues( + propertyKey, + new Set(seenBindings), + new Set(seenMemberFlows), + ); + return !resolution.complete || resolution.values.includes(key); + })() + : literalText(propertyKey) === key; + if (!matches) continue; if (property.type === "ObjectMethod") { - candidates.push(property); + 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, @@ -3582,12 +3736,27 @@ function invokedFunctionParameterBindings( const property = classMembers[index]; if (!property) continue; if (property.static !== true || !isNode(property.key)) continue; - const name = property.computed === true - ? stringLiteralText(property.key) - : literalText(property.key); - if (name !== key) continue; + const matches = property.computed === true + ? (() => { + const resolution = resolveStringValues( + property.key, + new Set(seenBindings), + new Set(seenMemberFlows), + ); + return !resolution.complete || resolution.values.includes(key); + })() + : literalText(property.key) === key; + if (!matches) continue; if (property.type === "ClassMethod") { - members.push(property); + 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, @@ -3629,26 +3798,11 @@ function invokedFunctionParameterBindings( ) continue; // Async factories return a promise and generator factories return an // iterator, neither synchronously hands the caller a callable value. - if (callee.async === true || callee.generator === true || !isNode(callee.body)) continue; - if (callee.body.type !== "BlockStatement") { - returned.push(...concreteValues( - callee.body, - new Set(seenBindings), - new Set(seenMemberFlows), - )); - continue; - } - walk(callee.body, (node) => { - if (node !== callee.body && startsVarScope(node)) return false; - if (node.type === "ReturnStatement" && isNode(node.argument)) { - returned.push(...concreteValues( - node.argument, - new Set(seenBindings), - new Set(seenMemberFlows), - )); - } - return true; - }); + returned.push(...synchronousReturnValues( + callee, + new Set(seenBindings), + new Set(seenMemberFlows), + )); } return returned; } From ff6fb95dfdb1b70c4e38139c5180927d4c3cce92 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 18 Aug 2026 05:39:26 +0200 Subject: [PATCH 78/81] fix(transforms): guard recursive value traversal --- .../browser-server-exports-strip.test.ts | 47 +++++++++++++++++++ .../stages/browser-server-exports-strip.ts | 26 ++++++---- 2 files changed, 63 insertions(+), 10 deletions(-) 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 5d0c152b8f..28a091bcb6 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts @@ -2396,6 +2396,32 @@ describe("browser-server-exports-strip", () => { }); } + 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"); + + const result = await stripServerOnlyExports(code); + + assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); + assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + }); + for ( const [label, ownerDeclaration, invocation] of [ [ @@ -2435,6 +2461,27 @@ describe("browser-server-exports-strip", () => { }); } + 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) {`, diff --git a/src/transforms/pipeline/stages/browser-server-exports-strip.ts b/src/transforms/pipeline/stages/browser-server-exports-strip.ts index 237bd3f293..b8d49fe73e 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.ts @@ -3192,7 +3192,7 @@ function invokedFunctionParameterBindings( function resolveStringValues( entry: Node, seenBindings = new Set(), - seenMemberFlows = new Set(), + seenMemberFlows = new Set(), ): { values: string[]; complete: boolean } { const candidate = unwrapTransparent(entry); const merge = (entries: Node[]): { values: string[]; complete: boolean } => { @@ -3312,17 +3312,20 @@ function invokedFunctionParameterBindings( function synchronousReturnValues( callable: Node, seenBindings: Set, - seenMemberFlows: 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(seenMemberFlows), + new Set(nextSeenMemberFlows), ); } const returned: Node[] = []; @@ -3332,7 +3335,7 @@ function invokedFunctionParameterBindings( returned.push(...concreteValues( node.argument, new Set(seenBindings), - new Set(seenMemberFlows), + new Set(nextSeenMemberFlows), )); } return true; @@ -3343,7 +3346,7 @@ function invokedFunctionParameterBindings( const concreteValues = ( entry: Node, seenBindings = new Set(), - seenMemberFlows = new Set(), + seenMemberFlows = new Set(), ): Node[] => { const value = unwrapTransparent(entry); if (value.type === "Identifier") { @@ -3401,15 +3404,17 @@ function invokedFunctionParameterBindings( 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(seenMemberFlows), + new Set(nextSeenMemberFlows), ); for (const name of flowKeyResolution.values) resolvedKeys.add(name); if ( flowKeyResolution.complete || - seenMemberFlows.has(flow) || (!readAllPossible && flow.order > readOrder && !flow.controlUncertain && flow.scope === readScope) ) continue; @@ -3441,8 +3446,6 @@ function invokedFunctionParameterBindings( flow.scope, ))); if (![...readOwners].some((owner) => flowOwners.has(owner))) continue; - const nextSeenMemberFlows = new Set(seenMemberFlows); - nextSeenMemberFlows.add(flow); members.push(...concreteValues( flow.value, new Set(seenBindings), @@ -3598,10 +3601,13 @@ function invokedFunctionParameterBindings( const resolvedMemberFlows = [ ...(memberValueFlows.get(key) ?? []), ...computedMemberValueFlows.filter((flow) => { + if (seenMemberFlows.has(flow)) return false; + const nextSeenMemberFlows = new Set(seenMemberFlows); + nextSeenMemberFlows.add(flow); const resolution = resolveStringValues( flow.key, new Set(seenBindings), - new Set(seenMemberFlows), + nextSeenMemberFlows, ); return !resolution.complete || resolution.values.includes(key); }), From 919f16fe4dfbefe1f74a1fadb9db6e550bd30932 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 18 Aug 2026 05:50:31 +0200 Subject: [PATCH 79/81] fix(transforms): preserve uncertain member lookup --- .../browser-server-exports-strip.test.ts | 95 ++++++++- .../stages/browser-server-exports-strip.ts | 200 ++++++++++++------ 2 files changed, 231 insertions(+), 64 deletions(-) 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 28a091bcb6..92e481196d 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts @@ -2422,21 +2422,110 @@ describe("browser-server-exports-strip", () => { assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); }); + 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"); + + const result = await stripServerOnlyExports(code); + + assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); + assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + }); + + 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"); + + const result = await stripServerOnlyExports(code); + + assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); + assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + }); + } + for ( const [label, ownerDeclaration, invocation] of [ [ - "object getter", + "an object getter", `const owner = { get make() { return mutator; } };`, `owner.make(Object);`, ], [ - "static class getter", + "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 a ${label}`, async () => { + it(`keeps metadata through a callable returned by ${label}`, async () => { const code = [ `const mutator = (intrinsic) => {`, ` intrinsic.defineProperty = (target) => target;`, diff --git a/src/transforms/pipeline/stages/browser-server-exports-strip.ts b/src/transforms/pipeline/stages/browser-server-exports-strip.ts index b8d49fe73e..8259181f66 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.ts @@ -3343,6 +3343,22 @@ function invokedFunctionParameterBindings( 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(), @@ -3418,7 +3434,10 @@ function invokedFunctionParameterBindings( (!readAllPossible && flow.order > readOrder && !flow.controlUncertain && flow.scope === readScope) ) continue; - const resolveOwnerIdentities = (owners: OwnerIdentity[]): OwnerIdentity[] => + const resolveOwnerIdentities = ( + owners: OwnerIdentity[], + traversal = seenMemberFlows, + ): OwnerIdentity[] => owners.flatMap((owner): OwnerIdentity[] => { if (!isNode(owner)) return [owner]; const candidate = unwrapTransparent(owner); @@ -3429,7 +3448,7 @@ function invokedFunctionParameterBindings( const resolved = concreteValues( candidate, new Set(seenBindings), - new Set(seenMemberFlows), + new Set(traversal), ); return resolved.length > 0 ? resolved : [owner]; }); @@ -3439,12 +3458,17 @@ function invokedFunctionParameterBindings( readAllPossible, readScope, ))); - const flowOwners = new Set(resolveOwnerIdentities(ownerIdentities( - flow.owner, - flow.order, - flow.controlUncertain, - flow.scope, - ))); + 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, @@ -3570,7 +3594,10 @@ function invokedFunctionParameterBindings( const readOrder = nodeOrders.get(value) ?? Number.POSITIVE_INFINITY; const readScope = ownerExecutionScopes.get(value) ?? null; const readAllPossible = repeatedControlNodes.has(value); - const resolveOwnerIdentities = (owners: OwnerIdentity[]): OwnerIdentity[] => + const resolveOwnerIdentities = ( + owners: OwnerIdentity[], + traversal = seenMemberFlows, + ): OwnerIdentity[] => owners.flatMap((owner): OwnerIdentity[] => { if (!isNode(owner)) return [owner]; const candidate = unwrapTransparent(owner); @@ -3581,7 +3608,7 @@ function invokedFunctionParameterBindings( const resolved = concreteValues( candidate, new Set(seenBindings), - new Set(seenMemberFlows), + new Set(traversal), ); // Keep an unresolved syntax identity so an analysis gap cannot make // distinct writes look like certain writes to the same owner. @@ -3599,36 +3626,46 @@ function invokedFunctionParameterBindings( ); const resolvedMemberFlows = [ - ...(memberValueFlows.get(key) ?? []), - ...computedMemberValueFlows.filter((flow) => { - if (seenMemberFlows.has(flow)) return false; + ...(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 resolution = resolveStringValues( + const match = resolvedMemberKeyMatch( flow.key, + key, new Set(seenBindings), nextSeenMemberFlows, ); - return !resolution.complete || resolution.values.includes(key); + return match === "none" ? [] : [{ flow, keyUncertain: match !== "certain" }]; }), ] - .filter((flow) => + .filter(({ flow }) => readAllPossible || flow.order <= readOrder || flow.controlUncertain || flow.scope !== readScope ) - .map((flow) => ({ - flow, - owners: new Set( - resolveOwnerIdentities( - ownerIdentities( - flow.owner, - flow.order, - flow.controlUncertain, - flow.scope, + .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(); @@ -3640,7 +3677,7 @@ function invokedFunctionParameterBindings( for (let index = applicable.length - 1; index >= 0; index--) { const candidate = applicable[index]; if ( - candidate && !candidate.flow.controlUncertain && + candidate && !candidate.flow.controlUncertain && !candidate.keyUncertain && candidate.flow.scope === readScope && candidate.owners.size === 1 ) { lastCertain = index; @@ -3650,8 +3687,9 @@ function invokedFunctionParameterBindings( } const active = lastCertain < 0 ? applicable - : applicable.slice(lastCertain).filter(({ flow, owners }, index) => - index === 0 || flow.controlUncertain || flow.scope !== readScope || owners.size !== 1 + : 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); @@ -3671,6 +3709,7 @@ function invokedFunctionParameterBindings( 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, @@ -3695,17 +3734,25 @@ function invokedFunctionParameterBindings( continue; } const propertyKey = isNode(property.key) ? property.key : undefined; - const matches = property.computed === true && propertyKey - ? (() => { - const resolution = resolveStringValues( - propertyKey, - new Set(seenBindings), - new Set(seenMemberFlows), - ); - return !resolution.complete || resolution.values.includes(key); - })() - : literalText(propertyKey) === key; - if (!matches) continue; + 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( @@ -3723,18 +3770,27 @@ function invokedFunctionParameterBindings( new Set(seenMemberFlows), )); } - return candidates; + 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; }; - 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; + 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) : []; @@ -3742,17 +3798,17 @@ function invokedFunctionParameterBindings( const property = classMembers[index]; if (!property) continue; if (property.static !== true || !isNode(property.key)) continue; - const matches = property.computed === true - ? (() => { - const resolution = resolveStringValues( - property.key, - new Set(seenBindings), - new Set(seenMemberFlows), - ); - return !resolution.complete || resolution.values.includes(key); - })() - : literalText(property.key) === key; - if (!matches) 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( @@ -3770,8 +3826,30 @@ function invokedFunctionParameterBindings( new Set(seenMemberFlows), )); } - break; + 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; } From d4efcce5e724efe72f31115b97aa5afe7494afbf Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 18 Aug 2026 05:56:06 +0200 Subject: [PATCH 80/81] fix(transforms): bound computed key resolution --- .../browser-server-exports-strip.test.ts | 40 ++++++++++++++++++- .../stages/browser-server-exports-strip.ts | 18 +++------ 2 files changed, 44 insertions(+), 14 deletions(-) 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 92e481196d..ea2b3437ec 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts @@ -2,7 +2,12 @@ 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 { browserServerExportsStripPlugin, @@ -2450,6 +2455,39 @@ describe("browser-server-exports-strip", () => { assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); }); + 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(); + const result = await stripServerOnlyExports(code); + const elapsed = performance.now() - started; + + assert(elapsed < 1_500, `expected bounded traversal, got ${elapsed.toFixed(1)} ms`); + assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); + assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + }); + for ( const [label, ownerFlow] of [ [ diff --git a/src/transforms/pipeline/stages/browser-server-exports-strip.ts b/src/transforms/pipeline/stages/browser-server-exports-strip.ts index 8259181f66..3b8440ecdd 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.ts @@ -3280,19 +3280,11 @@ function invokedFunctionParameterBindings( candidate.type === "MemberExpression" || candidate.type === "OptionalMemberExpression" ) { - const concrete = concreteValues( - candidate, - new Set(seenBindings), - new Set(seenMemberFlows), - ); - return { - values: concrete - .map((resolved) => stringLiteralText(resolved)) - .filter((resolved): resolved is string => resolved !== null), - // A member read can retain concrete values while an unresolved owner, - // key, or write flow contributes another runtime value. - complete: false, - }; + // 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( From 991e3096b9cf853e4424ee93a6e1c246d4475e5e Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Tue, 18 Aug 2026 07:45:03 +0200 Subject: [PATCH 81/81] fix(security): fail closed on unprovable compiler name registrations A `__name(loadUser, "loadUser")` registration that the pass cannot prove is compiler metadata stayed a live browser read of its target. The hook-only declaration behind it was never removed, and `dropUnusedImportBindings` kept its import, so the module's server import chain and its secret initialiser survived into the browser artifact. `removedNames` cannot backstop this: nothing was selected for removal, so the fail-closed scan stayed silent. Recognition failure is now decoupled from retention. When the intrinsic proof is blocked, the pass asks what the same module would drop if the registration were metadata. Anything that appears only there is a server-only binding it would be retaining, and the build stops with the construct that blocked the proof and the fix for it. Both conditions are required. A module that defeats the proof without a hook-only registration builds exactly as before. --- docs/guides/data-fetching.md | 38 ++ src/errors/catalog/build-errors.ts | 4 + .../browser-server-exports-strip.test.ts | 445 +++++++++--------- .../stages/browser-server-exports-strip.ts | 169 +++++-- 4 files changed, 400 insertions(+), 256 deletions(-) diff --git a/docs/guides/data-fetching.md b/docs/guides/data-fetching.md index df6df14af3..024ee2413a 100644 --- a/docs/guides/data-fetching.md +++ b/docs/guides/data-fetching.md @@ -94,6 +94,44 @@ for (var KEY of getEnv("SECRET_KEY")) {} 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/src/errors/catalog/build-errors.ts b/src/errors/catalog/build-errors.ts index 06c170c767..1ddb14922b 100644 --- a/src/errors/catalog/build-errors.ts +++ b/src/errors/catalog/build-errors.ts @@ -123,10 +123,14 @@ title: My Post "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"; 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 ea2b3437ec..29d586cf1c 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts @@ -21,6 +21,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; @@ -1119,11 +1136,7 @@ describe("browser-server-exports-strip", () => { `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")`); + await assertUnprovableRegistrationRejected(code); }); it("does not treat a dynamic Object property as compiler metadata", async () => { @@ -1284,11 +1297,7 @@ describe("browser-server-exports-strip", () => { `export default function Page() { return null; }`, ].join("\n"); - const result = await stripServerOnlyExports(code); - - assertStringIncludes(result, "Object.defineProperty = recordAndReturn"); - assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); - assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + await assertUnprovableRegistrationRejected(code); }); it("does not treat a redefined Object.defineProperty as compiler metadata", async () => { @@ -1309,11 +1318,7 @@ describe("browser-server-exports-strip", () => { `export default function Page() { return null; }`, ].join("\n"); - const result = await stripServerOnlyExports(code); - - assertStringIncludes(result, `Object.defineProperty(Object, "defineProperty"`); - assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); - assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + await assertUnprovableRegistrationRejected(code); }); it("does not treat a multiply initialized name helper as compiler metadata", async () => { @@ -1385,11 +1390,7 @@ describe("browser-server-exports-strip", () => { `export default function Page() { return null; }`, ].join("\n"); - const result = await stripServerOnlyExports(code); - - assertStringIncludes(result, "Object = {"); - assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); - assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + await assertUnprovableRegistrationRejected(code); }); it("does not treat a helper with a local Object name as compiler metadata", async () => { @@ -1436,11 +1437,7 @@ describe("browser-server-exports-strip", () => { `export default function Page() { return null; }`, ].join("\n"); - const result = await stripServerOnlyExports(code); - - assertStringIncludes(result, "intrinsic.defineProperty = recordAndReturn"); - assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); - assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + await assertUnprovableRegistrationRejected(code); }); for ( @@ -1597,10 +1594,7 @@ describe("browser-server-exports-strip", () => { `export default function Page() { return null; }`, ].join("\n"); - const result = await stripServerOnlyExports(code); - - assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); - assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + await assertUnprovableRegistrationRejected(code); }); } @@ -1678,10 +1672,7 @@ describe("browser-server-exports-strip", () => { `export default function Page() { return null; }`, ].join("\n"); - const result = await stripServerOnlyExports(code); - - assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); - assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + await assertUnprovableRegistrationRejected(code); }); it("still strips metadata after a hoisted function owner is rebound", async () => { @@ -1791,10 +1782,7 @@ describe("browser-server-exports-strip", () => { `export default function Page() { return null; }`, ].join("\n"); - const result = await stripServerOnlyExports(code); - - assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); - assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + await assertUnprovableRegistrationRejected(code); }); it("keeps metadata when a conditional rebind may leave a mutating owner", async () => { @@ -1820,10 +1808,7 @@ describe("browser-server-exports-strip", () => { `export default function Page() { return null; }`, ].join("\n"); - const result = await stripServerOnlyExports(code); - - assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); - assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + await assertUnprovableRegistrationRejected(code); }); for ( @@ -1860,10 +1845,7 @@ describe("browser-server-exports-strip", () => { `export default function Page() { return null; }`, ].join("\n"); - const result = await stripServerOnlyExports(code); - - assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); - assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + await assertUnprovableRegistrationRejected(code); }); } @@ -1975,10 +1957,7 @@ describe("browser-server-exports-strip", () => { `export default function Page() { return null; }`, ].join("\n"); - const result = await stripServerOnlyExports(code); - - assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); - assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + await assertUnprovableRegistrationRejected(code); }); } @@ -2016,10 +1995,7 @@ describe("browser-server-exports-strip", () => { `export default function Page() { return null; }`, ].join("\n"); - const result = await stripServerOnlyExports(code); - - assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); - assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + await assertUnprovableRegistrationRejected(code); }); } @@ -2071,10 +2047,7 @@ describe("browser-server-exports-strip", () => { `export default function Page() { return null; }`, ].join("\n"); - const result = await stripServerOnlyExports(code); - - assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); - assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + await assertUnprovableRegistrationRejected(code); }); for ( @@ -2110,10 +2083,7 @@ describe("browser-server-exports-strip", () => { `export default function Page() { return null; }`, ].join("\n"); - const result = await stripServerOnlyExports(code); - - assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); - assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + await assertUnprovableRegistrationRejected(code); }); } @@ -2136,10 +2106,7 @@ describe("browser-server-exports-strip", () => { `export default function Page() { return null; }`, ].join("\n"); - const result = await stripServerOnlyExports(code); - - assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); - assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + await assertUnprovableRegistrationRejected(code); }); it("keeps metadata through a runtime-selected local factory call", async () => { @@ -2160,10 +2127,7 @@ describe("browser-server-exports-strip", () => { `export default function Page() { return null; }`, ].join("\n"); - const result = await stripServerOnlyExports(code); - - assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); - assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + await assertUnprovableRegistrationRejected(code); }); it("keeps metadata when a computed key has known and runtime-selected flows", async () => { @@ -2186,10 +2150,7 @@ describe("browser-server-exports-strip", () => { `export default function Page() { return null; }`, ].join("\n"); - const result = await stripServerOnlyExports(code); - - assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); - assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + await assertUnprovableRegistrationRejected(code); }); it("keeps metadata when a computed-key factory has an unresolved return flow", async () => { @@ -2212,10 +2173,7 @@ describe("browser-server-exports-strip", () => { `export default function Page() { return null; }`, ].join("\n"); - const result = await stripServerOnlyExports(code); - - assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); - assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + await assertUnprovableRegistrationRejected(code); }); it("keeps metadata through an unresolved computed object member", async () => { @@ -2237,10 +2195,7 @@ describe("browser-server-exports-strip", () => { `export default function Page() { return null; }`, ].join("\n"); - const result = await stripServerOnlyExports(code); - - assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); - assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + await assertUnprovableRegistrationRejected(code); }); it("keeps metadata when a member value flow refers to itself", async () => { @@ -2262,10 +2217,7 @@ describe("browser-server-exports-strip", () => { `export default function Page() { return null; }`, ].join("\n"); - const result = await stripServerOnlyExports(code); - - assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); - assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + await assertUnprovableRegistrationRejected(code); }); for ( @@ -2301,10 +2253,7 @@ describe("browser-server-exports-strip", () => { `export default function Page() { return null; }`, ].join("\n"); - const result = await stripServerOnlyExports(code); - - assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); - assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + await assertUnprovableRegistrationRejected(code); }); } @@ -2334,10 +2283,7 @@ describe("browser-server-exports-strip", () => { `export default function Page() { return null; }`, ].join("\n"); - const result = await stripServerOnlyExports(code); - - assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); - assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + await assertUnprovableRegistrationRejected(code); }); } @@ -2421,10 +2367,7 @@ describe("browser-server-exports-strip", () => { `export default function Page() { return null; }`, ].join("\n"); - const result = await stripServerOnlyExports(code); - - assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); - assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + await assertUnprovableRegistrationRejected(code); }); it("keeps metadata when a nested computed write resolves its owner", async () => { @@ -2449,10 +2392,7 @@ describe("browser-server-exports-strip", () => { `export default function Page() { return null; }`, ].join("\n"); - const result = await stripServerOnlyExports(code); - - assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); - assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + await assertUnprovableRegistrationRejected(code); }); it("bounds unresolved computed write key traversal", async () => { @@ -2480,12 +2420,10 @@ describe("browser-server-exports-strip", () => { ].join("\n"); const started = performance.now(); - const result = await stripServerOnlyExports(code); + await assertUnprovableRegistrationRejected(code); const elapsed = performance.now() - started; assert(elapsed < 1_500, `expected bounded traversal, got ${elapsed.toFixed(1)} ms`); - assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); - assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); }); for ( @@ -2526,10 +2464,7 @@ describe("browser-server-exports-strip", () => { `export default function Page() { return null; }`, ].join("\n"); - const result = await stripServerOnlyExports(code); - - assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); - assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + await assertUnprovableRegistrationRejected(code); }); } @@ -2581,10 +2516,7 @@ describe("browser-server-exports-strip", () => { `export default function Page() { return null; }`, ].join("\n"); - const result = await stripServerOnlyExports(code); - - assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); - assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + await assertUnprovableRegistrationRejected(code); }); } @@ -2631,10 +2563,7 @@ describe("browser-server-exports-strip", () => { `export default function Page() { return null; }`, ].join("\n"); - const result = await stripServerOnlyExports(code); - - assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); - assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + await assertUnprovableRegistrationRejected(code); }); it("keeps metadata after a write through a runtime-selected destructured owner", async () => { @@ -2658,10 +2587,7 @@ describe("browser-server-exports-strip", () => { `export default function Page() { return null; }`, ].join("\n"); - const result = await stripServerOnlyExports(code); - - assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); - assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + await assertUnprovableRegistrationRejected(code); }); it("keeps metadata after a nested write through an object-rest owner", async () => { @@ -2685,10 +2611,7 @@ describe("browser-server-exports-strip", () => { `export default function Page() { return null; }`, ].join("\n"); - const result = await stripServerOnlyExports(code); - - assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); - assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + await assertUnprovableRegistrationRejected(code); }); it("still strips metadata after a safe write through an object-destructured owner", async () => { @@ -2769,10 +2692,7 @@ describe("browser-server-exports-strip", () => { `export default function Page() { return null; }`, ].join("\n"); - const result = await stripServerOnlyExports(code); - - assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); - assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + await assertUnprovableRegistrationRejected(code); }); it("still strips metadata past a write through a shadowing alias parameter", async () => { @@ -2850,11 +2770,7 @@ describe("browser-server-exports-strip", () => { `export default function Page() { return null; }`, ].join("\n"); - const result = await stripServerOnlyExports(code); - - assertStringIncludes(result, `const intrinsic = ${globalObject}`); - assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); - assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + await assertUnprovableRegistrationRejected(code); }); } @@ -2884,11 +2800,7 @@ describe("browser-server-exports-strip", () => { `export default function Page() { return null; }`, ].join("\n"); - const result = await stripServerOnlyExports(code); - - assertStringIncludes(result, `const intrinsic = ${globalObject}`); - assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); - assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + await assertUnprovableRegistrationRejected(code); }); } @@ -2911,11 +2823,7 @@ describe("browser-server-exports-strip", () => { `export default function Page() { return null; }`, ].join("\n"); - const result = await stripServerOnlyExports(code); - - assertStringIncludes(result, "const scope = window"); - assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); - assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + await assertUnprovableRegistrationRejected(code); }); // `typeof window` yields a string, never a reference the module can use to @@ -3008,11 +2916,7 @@ describe("browser-server-exports-strip", () => { `export default function Page() { return null; }`, ].join("\n"); - const result = await stripServerOnlyExports(code); - - assertStringIncludes(result, `const intrinsic = ${globalObject}`); - assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); - assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + await assertUnprovableRegistrationRejected(code); }); } @@ -3059,11 +2963,7 @@ describe("browser-server-exports-strip", () => { `export default function Page() { return null; }`, ].join("\n"); - const result = await stripServerOnlyExports(code); - - assertStringIncludes(result, `Reflect.defineProperty(Object, "defineProperty"`); - assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); - assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + await assertUnprovableRegistrationRejected(code); }); it("does not treat a globalThis-rooted intrinsic write as compiler metadata", async () => { @@ -3084,11 +2984,7 @@ describe("browser-server-exports-strip", () => { `export default function Page() { return null; }`, ].join("\n"); - const result = await stripServerOnlyExports(code); - - assertStringIncludes(result, "globalThis.Object.defineProperty = recordAndReturn"); - assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); - assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + await assertUnprovableRegistrationRejected(code); }); it("does not treat a replaced globalThis.Object as compiler metadata", async () => { @@ -3109,11 +3005,7 @@ describe("browser-server-exports-strip", () => { `export default function Page() { return null; }`, ].join("\n"); - const result = await stripServerOnlyExports(code); - - assertStringIncludes(result, "globalThis.Object = {"); - assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); - assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + await assertUnprovableRegistrationRejected(code); }); it("does not treat a defineProperty replacement of globalThis.Object as compiler metadata", async () => { @@ -3137,11 +3029,7 @@ describe("browser-server-exports-strip", () => { `export default function Page() { return null; }`, ].join("\n"); - const result = await stripServerOnlyExports(code); - - assertStringIncludes(result, `Object.defineProperty(globalThis, "Object"`); - assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); - assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + await assertUnprovableRegistrationRejected(code); }); it("does not treat a Reflect replacement of globalThis.Object as compiler metadata", async () => { @@ -3165,11 +3053,7 @@ describe("browser-server-exports-strip", () => { `export default function Page() { return null; }`, ].join("\n"); - const result = await stripServerOnlyExports(code); - - assertStringIncludes(result, `Reflect.defineProperty(globalThis, "Object"`); - assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); - assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + await assertUnprovableRegistrationRejected(code); }); it("does not treat an aliased global object as compiler metadata", async () => { @@ -3191,11 +3075,7 @@ describe("browser-server-exports-strip", () => { `export default function Page() { return null; }`, ].join("\n"); - const result = await stripServerOnlyExports(code); - - assertStringIncludes(result, "scope.Object = "); - assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); - assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + await assertUnprovableRegistrationRejected(code); }); it("does not treat a computed intrinsic write as compiler metadata", async () => { @@ -3216,11 +3096,7 @@ describe("browser-server-exports-strip", () => { `export default function Page() { return null; }`, ].join("\n"); - const result = await stripServerOnlyExports(code); - - assertStringIncludes(result, "Object[globalThis.patchedName] = recordAndReturn"); - assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); - assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + await assertUnprovableRegistrationRejected(code); }); it("still strips compiler metadata after an unrelated defineProperty write", async () => { @@ -3373,11 +3249,7 @@ describe("browser-server-exports-strip", () => { `export default function Page() { return null; }`, ].join("\n"); - const result = await stripServerOnlyExports(code); - - assertStringIncludes(result, `const intrinsic = ${globalObject}`); - assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); - assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + await assertUnprovableRegistrationRejected(code); }); } @@ -3403,11 +3275,7 @@ describe("browser-server-exports-strip", () => { `export default function Page() { return null; }`, ].join("\n"); - const result = await stripServerOnlyExports(code, "pages/ns.ts"); - - assertStringIncludes(result, "Patch.intrinsic.defineProperty = recordAndReturn"); - assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); - assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + await assertUnprovableRegistrationRejected(code); }); } @@ -3430,11 +3298,7 @@ describe("browser-server-exports-strip", () => { `export default function Page() { return null; }`, ].join("\n"); - const result = await stripServerOnlyExports(code, "pages/ns.ts"); - - assertStringIncludes(result, "Patch.intrinsic.defineProperty = recordAndReturn"); - assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); - assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + await assertUnprovableRegistrationRejected(code); }); // A read that only hands the intrinsic to a callee, spreads it, or binds a @@ -3578,10 +3442,7 @@ describe("browser-server-exports-strip", () => { `export default function Page() { return null; }`, ].join("\n"); - const result = await stripServerOnlyExports(code); - - assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); - assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + await assertUnprovableRegistrationRejected(code); }); } @@ -3923,10 +3784,7 @@ describe("browser-server-exports-strip", () => { `export default function Page() { return null; }`, ].join("\n"); - const result = await stripServerOnlyExports(code); - - assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); - assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + await assertUnprovableRegistrationRejected(code); }); } @@ -4041,10 +3899,7 @@ describe("browser-server-exports-strip", () => { `export default function Page() { return null; }`, ].join("\n"); - const result = await stripServerOnlyExports(code); - - assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); - assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + await assertUnprovableRegistrationRejected(code); }); } @@ -4082,10 +3937,7 @@ describe("browser-server-exports-strip", () => { `export default function Page() { return null; }`, ].join("\n"); - const result = await stripServerOnlyExports(code); - - assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); - assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + await assertUnprovableRegistrationRejected(code); }); } @@ -4131,10 +3983,7 @@ describe("browser-server-exports-strip", () => { `export default function Page() { return null; }`, ].join("\n"); - const result = await stripServerOnlyExports(code); - - assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); - assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + await assertUnprovableRegistrationRejected(code); }); it("does not treat named descriptors installed on the intrinsic as compiler metadata", async () => { @@ -4156,11 +4005,7 @@ describe("browser-server-exports-strip", () => { `export default function Page() { return null; }`, ].join("\n"); - const result = await stripServerOnlyExports(code); - - assertStringIncludes(result, "Object.defineProperties(Object, descriptors)"); - assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); - assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + await assertUnprovableRegistrationRejected(code); }); it("does not treat an eval of a replacement as compiler metadata", async () => { @@ -4177,10 +4022,7 @@ describe("browser-server-exports-strip", () => { `export default function Page() { return null; }`, ].join("\n"); - const result = await stripServerOnlyExports(code); - - assertStringIncludes(result, `setName(loadSecret, "loadSecret")`); - assertStringIncludes(result, `const KEY = getEnv("SECRET_KEY")`); + await assertUnprovableRegistrationRejected(code); }); // A `function` declaration and a `var` cannot share a name in a module: @@ -6278,4 +6120,153 @@ describe("browser-server-exports-strip", () => { 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")`); + }); + }); }); diff --git a/src/transforms/pipeline/stages/browser-server-exports-strip.ts b/src/transforms/pipeline/stages/browser-server-exports-strip.ts index 3b8440ecdd..e4401a1f22 100644 --- a/src/transforms/pipeline/stages/browser-server-exports-strip.ts +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.ts @@ -4733,6 +4733,24 @@ function isNameDescriptor(node: Node | undefined, valueParam: string): boolean { * 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 @@ -4740,7 +4758,7 @@ 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. @@ -4754,16 +4772,42 @@ function compilerNameHelperBindings(body: Node[]): Set { const hoisted = hoistedVarNames(body); const globals = unshadowedGlobalIdentifierNodes(body); const bindings = indexLexicalBindings(body); - const objectIsModuleLocal = moduleScopeBindingNames(body).has("Object") || - hoisted.has("Object") || importsRuntimeObject || - assignsUnshadowedGlobal(body, "Object", globals) || - writesObjectDefineProperty(body, globals, bindings) || - writesGuardedKeyThroughUnprovenBase(body, globals, bindings) || - mergesGuardedKeyOntoIntrinsic(body, globals) || - hasReflectionRoute(body, globals, bindings) || - intrinsicEscapesToWritableSlot(body, "Object", globals, bindings) || - intrinsicEscapesToWritableSlot(body, "global", globals, bindings); - if (objectIsModuleLocal) return new Set(); + // 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 @@ -4838,7 +4882,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 { @@ -5339,29 +5390,29 @@ function separateExportLocalNames(body: Node[]): Set { * 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. */ -function dropUnreachableModuleScopeBindings( +/** + * 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, - removeStatement: (statement: Node) => void, - removedNames: Set, -): Blocker[] { - const nameHelpers = compilerNameHelperBindings(body); - const reasons = new Map(); - for (const site of sites) { - if (site.exported) continue; - const reason = elisionReason(site, hookClosure, nameHelpers); - if (reason !== null) reasons.set(site, reason); - } - const elidable = sites.filter((site) => reasons.has(site)); - if (elidable.length === 0) return []; - + 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 registrations = compilerNameRegistrations(body, nameHelpers); const elidableNames = new Set(elidable.flatMap((site) => site.names)); const elided = new Set(elidable.map((site) => site.node)); for (const registration of registrations) { @@ -5391,18 +5442,73 @@ function dropUnreachableModuleScopeBindings( const reachable = reachableNames(roots, sites); const dead = elidable.filter((site) => site.names.every((name) => !reachable.has(name))); const tainted = serverTaintedSites(dead, hookClosure); - const removable = dead.filter((site) => { + 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 blocked-path check below then fails + // 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 @@ -5552,6 +5658,11 @@ const REMEDY = { /** 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 " +