fix(javascript): bind bundle exports and mutations to factory roles

This commit is contained in:
morluto
2026-10-24 02:28:03 +08:00
parent 1998640669
commit 5cbcd58550
7 changed files with 234 additions and 62 deletions
@@ -330,6 +330,16 @@ recorded path keeps those leading carrier segments; the procedure path is its
suffix. Utility calls such as `useUtils().invoice.list.fetch()`, `useQueries`,
and links whose callee was renamed by a minifier are not recognized.
Bundler adapters own callback roles: Webpack supplies module, exports, then
loader; esbuild CommonJS supplies exports, then module. Export extraction uses
those binding identities for assignments and declarations, so renaming either
parameter does not change the recovered names. Global property-definition
intrinsics require lexical and visible-write proof. Computed writes through a
standalone loader's exposed module/cache table use the shared value owner; an
unknown key invalidates the original-slot proof rather than implying a fresh
chunk installation. Export names describe recognized declarations, not a proof
of final runtime property lifetime or arbitrary helper behavior.
Each recovered bundle module retains the exact factory-source digest. A complete
bounded AST also receives a `babel-ast-v3` structural fingerprint. It ignores
ordinary identifier spellings while retaining literal and directive values,
@@ -31,6 +31,21 @@ const expectUnresolved = (source: string) => {
};
describe("Electron identity through Webpack externals", () => {
it.each([
["const key=45840;n.m[key]=local;", 0],
["const key='45840';delete n.m[key];", 0],
["n.m[runtimeKey]=local;", 0],
["const key=45841;n.m[key]=local;", 1],
] as const)(
"preserves only unmodified external slots after %s",
(write, expected) => {
expect(
electronOf(bootstrap(EXTERNAL, CONSUMER, `n.m=m;${write}n(100);`))
.browser_windows,
).toHaveLength(expected);
},
);
it("follows the default-export getter produced by webpack n.n", () => {
const source = bootstrap(
EXTERNAL,
@@ -70,6 +85,14 @@ describe("Electron identity through Webpack externals", () => {
["delete", "delete n.n;"],
["a destructuring write", "({g:n.n}={g:e=>()=>0});"],
["an unknown key", "n[key]=e=>()=>0;"],
[
"a stable loader alias",
"const copy=n;copy.n=e=>()=>({BrowserWindow:class{}});",
],
[
"a reflective write through a loader alias",
"const copy=n;Reflect.set(copy,'n',e=>()=>({BrowserWindow:class{}}));",
],
])(
"does not infer Electron after a getter write through %s",
(_name, write) => {
@@ -2,6 +2,8 @@ import { createHash } from "node:crypto";
import * as t from "@babel/types";
import { isUnmodifiedGlobalReference } from "./javascriptSemanticAmbientWrites.js";
import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js";
import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js";
@@ -29,32 +31,42 @@ export const fingerprintJavaScriptAst = (node: t.Node): string => {
return createHash("sha256").update(JSON.stringify(tokens)).digest("hex");
};
/** Calling-convention roles established by the bundler adapter. */
export interface JavaScriptFactoryExportBindings {
readonly exports: t.Identifier | undefined;
readonly module: t.Identifier | undefined;
readonly loader: t.Identifier | undefined;
}
/** Collect statically declared CommonJS/bundler export names from one factory. */
export const collectJavaScriptExports = (
node: t.Node,
bindings?: JavaScriptSemanticAnalysisState,
exportsParameter?: t.Identifier,
roles?: JavaScriptFactoryExportBindings,
): StaticExports => {
const exports = new Set<string>();
const factoryExports =
bindings !== undefined &&
t.isFunction(node) &&
exportsParameter !== undefined
? resolveSemanticBindingState(
bindings,
exportsParameter,
exportsParameter.name,
)?.bindingId
const bindingId = (
reference: t.Node | null | undefined,
): string | undefined =>
bindings !== undefined && t.isIdentifier(reference)
? resolveSemanticBindingState(bindings, reference, reference.name)
?.bindingId
: undefined;
const isExportsAlias = (reference: t.Node | null): boolean => {
if (!t.isIdentifier(reference)) return false;
if (factoryExports === undefined) return reference.name === "exports";
return (
bindings !== undefined &&
resolveSemanticBindingState(bindings, reference, reference.name)
?.bindingId === factoryExports
);
const identity = (
role: keyof JavaScriptFactoryExportBindings,
fallback: string,
) => {
const id = bindingId(roles?.[role]);
return (reference: t.Node | null): boolean => {
if (!t.isIdentifier(reference)) return false;
if (roles === undefined) return reference.name === fallback;
return id !== undefined && bindingId(reference) === id;
};
};
const isExportsAlias = identity("exports", "exports");
const isModuleAlias = identity("module", "module");
const isLoaderAlias = identity("loader", "__webpack_require__");
const receivers = { isExportsAlias, isModuleAlias };
traverseJavaScriptAst(node, {
enter: (current) => {
if (t.isAssignmentExpression(current))
@@ -62,18 +74,34 @@ export const collectJavaScriptExports = (
current.left,
current.right,
exports,
isExportsAlias,
receivers,
);
if (t.isCallExpression(current))
collectCallExports(current, exports, isExportsAlias);
if (t.isCallExpression(current)) {
const intrinsic =
bindings !== undefined &&
isUnmodifiedGlobalReference(current.callee, bindings, [
"Object",
"defineProperty",
]);
collectCallExports(
current,
exports,
receivers,
intrinsic,
isLoaderAlias,
);
}
return undefined;
},
});
return {
values: [...exports].sort(compareUnicodeCodePoints),
};
return { values: [...exports].sort(compareUnicodeCodePoints) };
};
interface ExportReceivers {
readonly isExportsAlias: (root: t.Node | null) => boolean;
readonly isModuleAlias: (root: t.Node | null) => boolean;
}
const semanticTokens = (node: t.Node): string[] => {
const tokens = [`node:${node.type}`];
if (t.isStringLiteral(node)) tokens.push(`string:${node.value}`);
@@ -140,7 +168,7 @@ const collectAssignmentExports = (
left: t.LVal | t.OptionalMemberExpression,
right: t.Expression,
output: Set<string>,
isExportsAlias: (root: t.Node | null) => boolean,
{ isExportsAlias, isModuleAlias }: ExportReceivers,
): void => {
const path = memberPath(left);
if (path === null) return;
@@ -150,7 +178,7 @@ const collectAssignmentExports = (
if (path.length === 1 && exportsAlias) return;
const exportOffset = exportsAlias
? 1
: path[0] === "module" && path[1] === "exports"
: isModuleAlias(root) && path[1] === "exports"
? 2
: null;
if (exportOffset === null) return;
@@ -168,15 +196,17 @@ const collectAssignmentExports = (
const collectCallExports = (
call: t.CallExpression,
output: Set<string>,
isExportsAlias: (root: t.Node | null) => boolean,
{ isExportsAlias, isModuleAlias }: ExportReceivers,
intrinsic: boolean,
isLoaderAlias: (root: t.Node | null) => boolean,
): void => {
const callee = memberPath(call.callee);
if (callee === null) return;
const definePropertyCall =
const definePropertyCall = intrinsic;
const declarationCall =
callee.length === 2 &&
callee[0] === "Object" &&
callee[1] === "defineProperty";
const declarationCall = callee.length >= 2 && callee.at(-1) === "d";
callee[1] === "d" &&
isLoaderAlias(memberRoot(call.callee));
if (!definePropertyCall && !declarationCall) return;
const targetNode = call.arguments[0];
const target = memberPath(targetNode);
@@ -184,7 +214,7 @@ const collectCallExports = (
isExportsAlias(targetNode ?? null) ||
(target !== null &&
target.length === 2 &&
target[0] === "module" &&
isModuleAlias(memberRoot(targetNode)) &&
target[1] === "exports");
if (!exportTarget) return;
if (definePropertyCall) {
@@ -8,7 +8,7 @@ import {
type JavaScriptSemanticAnalysisState,
} from "./javascriptSemanticState.js";
import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js";
import { argumentValue } from "./javascriptStaticAnalysisHelpers.js";
import { evaluateSemanticExpression } from "./javascriptSemanticValues.js";
type Bootstrap = NonNullable<ReturnType<typeof javascriptBootstrapAt>>;
@@ -452,7 +452,7 @@ const referencesAreContained = (
const use: Use = { node, parent, ancestors, shape, loaderIds, state };
if (role !== "loader") contained = containedUse(role, use);
else if (exposedTableUse(use) === "escaped") contained = false;
else exposedSlotWrite(use, replacedKeys);
else contained = exposedSlotWrite(use, replacedKeys);
},
});
return contained ? { replacedKeys } : undefined;
@@ -522,13 +522,9 @@ const exposedTableUse = (use: Use): "other" | "contained" | "escaped" => {
return entryWritten ? "escaped" : "contained";
};
/**
* `loader.m[key] = factory` or `delete loader.c[key]` replaces a literal slot.
* Writes with runtime keys are chunk installation, which adds modules under
* their own ids; they are not treated as replacing a declared slot.
*/
const exposedSlotWrite = (use: Use, replacedKeys: Set<string>): void => {
const { parent, ancestors } = use;
/** Unknown computed writes may replace any existing slot; fresh installation is unproven. */
const exposedSlotWrite = (use: Use, replacedKeys: Set<string>): boolean => {
const { parent, ancestors, state } = use;
const slot = ancestors.at(-2);
const write = ancestors.at(-3);
if (
@@ -545,11 +541,21 @@ const exposedSlotWrite = (use: Use, replacedKeys: Set<string>): void => {
t.isUpdateExpression(write)
)
)
return;
const key = slot.computed
? argumentValue(slot.property)
: semanticStaticPropertyKey(slot.property, false);
if (key !== undefined && key !== null) replacedKeys.add(key);
return true;
if (!slot.computed) {
const key = semanticStaticPropertyKey(slot.property, false);
if (key === null) return false;
replacedKeys.add(key);
return true;
}
const key = evaluateSemanticExpression(slot.property, state);
if (
key.status !== "literal" ||
(typeof key.value !== "string" && typeof key.value !== "number")
)
return false;
replacedKeys.add(String(key.value));
return true;
};
const bindingOf = (
@@ -8,6 +8,8 @@ import {
unwrapJavaScriptExpression,
} from "./javascriptAstValues.js";
import { collectJavaScriptIdentityWritesSteps } from "./javascriptElectronMemberWrites.js";
import { semanticBindingAtRead } from "./javascriptSemanticMutationInitializers.js";
import { SEMANTIC_ALIAS_SELECTION_LIMIT } from "./javascriptSemanticResourceLimits.js";
import { createSemanticModuleInterop } from "./javascriptModuleInterop.js";
import {
semanticRequireOrigin,
@@ -153,18 +155,41 @@ const canonicalWebpackInteropBindings = (
// The shared identity-write owner covers assignment, delete, destructuring,
// and Object/Reflect mutators; any write that may select `n` counts.
const writes = new Set<t.Node>();
const loaderMayOwnReference = (root: t.Node): boolean => {
const pending = [root];
const seen = new Set<t.Node>();
while (pending.length > 0) {
const reference = pending.pop();
if (reference === undefined || seen.has(reference)) continue;
seen.add(reference);
if (seen.size > SEMANTIC_ALIAS_SELECTION_LIMIT) {
state.valueResourceLimits.add("alias-selections");
return true;
}
const node = unwrapJavaScriptExpression(reference).node;
if (!t.isIdentifier(node)) continue;
const binding = resolveSemanticBindingState(state, node, node.name);
if (binding === undefined) continue;
if (loaderBindingIds.has(binding.bindingId)) return true;
const selected = semanticBindingAtRead(
binding,
node,
state.parentsByNode,
).binding;
for (const source of [
...selected.initializers,
...selected.referenceInitializers,
])
if (source.projection.length === 0) pending.push(source.node);
}
return false;
};
const interop = createSemanticModuleInterop(state);
completeSemanticSteps(
collectJavaScriptIdentityWritesSteps(
state.program,
(root, kind) => {
const node = unwrapJavaScriptExpression(root).node;
return kind === "member" &&
t.isIdentifier(node) &&
loaderBindingIds.has(
resolveSemanticBindingState(state, node, node.name)?.bindingId ??
"",
)
return kind === "member" && loaderMayOwnReference(root)
? [["loader"]]
: [];
},
@@ -0,0 +1,64 @@
import { runInNewContext } from "node:vm";
import { transformSync } from "esbuild";
import { expect, it } from "vitest";
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
it.each([false, true])(
"retains generated esbuild exports with module use %s",
(usesModule) => {
const { code } = transformSync(
`exports.parse = () => ${usesModule ? "module.id" : "42"};`,
{
format: "iife",
globalName: "fixture",
},
);
const runtime: { fixture?: Record<string, unknown> } = {};
runInNewContext(code, runtime);
const expected = Object.keys(runtime.fixture ?? {});
expect(expected).toEqual(["parse"]);
const analysis = analyzeJavaScriptStaticSource(code);
expect(analysis.bundler_registrations).toMatchObject([
{
bundler: "esbuild",
modules: [{ exports: expected, factory_require_name: null }],
},
]);
},
);
it.each(["module", "renamed"])(
"uses the %s factory binding for every export receiver",
(moduleName) => {
const analysis =
analyzeJavaScriptStaticSource(`globalThis.webpackChunkApp.push([[1], {
17(${moduleName}, api, load) {
${moduleName}.exports.direct = 1;
Object.defineProperty(${moduleName}.exports, "defined", {value: 2});
load.d(${moduleName}.exports, {declared: () => 3});
{ const module = {exports: {}}; module.exports.ghost = 4; }
{ const load = {d() {}}; load.d(api, {fake: () => 5}); }
}
}]);`);
expect(analysis.bundler_registrations[0]?.modules[0]?.exports).toEqual([
"declared",
"defined",
"direct",
]);
},
);
it.each([
"const Object = {defineProperty() {}};",
"globalThis.Object.defineProperty = () => {};",
])("requires the global property-definition intrinsic: %s", (prefix) => {
const analysis =
analyzeJavaScriptStaticSource(`globalThis.webpackChunkApp.push([[1], {
17(mod, api) { ${prefix} Object.defineProperty(api, "ghost", {value: 1}); api.real = 2; }
}]);`);
expect(analysis.bundler_registrations[0]?.modules[0]?.exports).toEqual([
"real",
]);
});
@@ -256,15 +256,29 @@ const recoverBundlerModule = (
if (key.startsWith("[computed@") || key.startsWith("[unknown@"))
accumulator.unknownFindings += 1;
const fingerprint = fingerprintJavaScriptAst(factory);
const exportsParameter =
runtimeFunctionParameters(factory)[runtime === "esbuild-commonjs" ? 0 : 1];
const exportsValue = collectJavaScriptExports(
factory,
bindings,
t.isIdentifier(exportsParameter) ? exportsParameter : undefined,
);
const requireName = factoryRequireName(factory);
const requireParameter = runtimeFunctionParameters(factory)[2];
const parameters = runtimeFunctionParameters(factory);
const esbuild = runtime.startsWith("esbuild-");
const identifierAt = (index: number): t.Identifier | undefined => {
const parameter = parameters[index];
return t.isIdentifier(parameter) ? parameter : undefined;
};
const exportsValue = collectJavaScriptExports(factory, bindings, {
exports:
runtime === "esbuild-commonjs"
? identifierAt(0)
: esbuild
? undefined
: identifierAt(1),
module:
runtime === "esbuild-commonjs"
? identifierAt(1)
: esbuild
? undefined
: identifierAt(0),
loader: esbuild ? undefined : identifierAt(2),
});
const requireName = esbuild ? null : factoryRequireName(factory);
const requireParameter = esbuild ? undefined : parameters[2];
const requireBindingId = t.isIdentifier(requireParameter)
? (resolveSemanticBindingState(
bindings,