fix(javascript): preserve factory export helper identity

This commit is contained in:
morluto
2026-10-24 14:42:32 +08:00
parent 781760a195
commit 0f3a15de0d
12 changed files with 277 additions and 29 deletions
+13 -7
View File
@@ -399,13 +399,19 @@ 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. A function stored on a standalone loader member does not
write the table merely by being defined: the bootstrap owner excludes its
deferred writes only when that member is unread and the loader namespace stays
confined. Calls, member aliases, dynamic member reads, namespace escapes, or
intrinsics require lexical and visible-write proof. Factory export declarations
also require the loader's `d` member to have no prior or unordered visible
replacement, including writes through lexical aliases. Unrelated loader members
do not invalidate declarations. Ordering describes declarations within one factory
activation; helper integrity on entry, effects of earlier invocations, and
arbitrary call side effects remain unproven.
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. A function stored on a
standalone loader member does not write the table merely by being defined:
the bootstrap owner excludes its deferred writes only when that member is
unread and the loader namespace stays confined. Calls, member aliases, dynamic
member reads, namespace escapes, or
receiver-based access retain uncertainty about those writes. Export names
describe recognized declarations, not a proof
of final runtime property lifetime or arbitrary helper behavior.
@@ -12,7 +12,7 @@ import { semanticStaticPropertyKey } from "./javascriptAstValues.js";
import { stringValue } from "./javascriptStaticAnalysisHelpers.js";
import {
resolveSemanticBindingState,
type JavaScriptSemanticAnalysisState,
type JavaScriptBindingIndex,
} from "./javascriptSemanticState.js";
/** Static CommonJS export names. */
@@ -42,9 +42,17 @@ export interface JavaScriptFactoryExportBindings {
/** Collect statically declared CommonJS/bundler export names from one factory. */
export const collectJavaScriptExports = (
node: t.Node,
bindings?: JavaScriptSemanticAnalysisState,
roles?: JavaScriptFactoryExportBindings,
factory?: {
readonly roles: JavaScriptFactoryExportBindings;
readonly index: Pick<
JavaScriptBindingIndex,
"bindings" | "effectsForReferenceWithinActivationAt"
>;
},
): StaticExports => {
const roles = factory?.roles;
const index = factory?.index;
const bindings = index?.bindings;
const exports = new Set<string>();
const bindingId = (
reference: t.Node | null | undefined,
@@ -102,13 +110,22 @@ export const collectJavaScriptExports = (
"Object",
"defineProperty",
]);
collectCallExports(
current,
exports,
receivers,
intrinsic,
isLoaderAlias,
);
collectCallExports(current, exports, receivers, intrinsic, (root) => {
if (root === null || !isLoaderAlias(root)) return false;
if (index === undefined) return true;
const facts = index.effectsForReferenceWithinActivationAt(
current,
root,
["d"],
);
return (
facts.memberEffectsIndexed &&
!facts.effects.some(
(effect) =>
effect.kind === "member-write" && effect.timing !== "after",
)
);
});
}
return undefined;
},
@@ -1,4 +1,5 @@
import { expect, it } from "vitest";
import * as t from "@babel/types";
import {
analyzeJavaScriptSemantics,
indexJavaScriptBindingsWithReceiversSteps,
@@ -7,7 +8,10 @@ import { onlyCallable } from "./javascriptSemanticAnalysis.fixture.js";
import { semanticResourceLimitReason } from "./javascriptSemanticResourceLimits.js";
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
import { parseJavaScriptSource } from "./javascriptSourceParser.js";
import { completeSemanticSteps } from "./javascriptSemanticTraversal.js";
import {
completeSemanticSteps,
traverseJavaScriptAst,
} from "./javascriptSemanticTraversal.js";
import {
joinSemanticPropertyPaths,
@@ -171,3 +175,99 @@ it("keeps a preceding global self-property replacement unresolved", () => {
facts.references.filter(({ kind }) => kind === "worker"),
).toMatchObject([{ specifier: null }]);
});
it.each([
[
"load",
"inspect(load); load.d = replacement; inspect(load);",
["after", "before"],
],
[
"load",
"inspect(load); function later() { load.d = replacement; }",
["unknown"],
],
["load", "inspect(load); if (flag) { load.d = replacement; }", ["unknown"]],
[
"load",
"inspect(load); while (flag) { load.d = replacement; }",
["unknown"],
],
[
"load",
"while (flag) { inspect(load); load.d = replacement; }",
["unknown"],
],
["load, value = (load.d = replacement)", "inspect(load);", ["unknown"]],
] as const)(
"preserves parameter member provenance and activation ordering: (%s) %s",
(parameters, body, timing) => {
const parsed = parseJavaScriptSource(
`function factory(${parameters}) { ${body} }`,
);
if (parsed === null) throw new Error("Expected parsed source");
const index = completeSemanticSteps(
indexJavaScriptBindingsWithReceiversSteps(parsed.program),
);
completeSemanticSteps(index.indexMemberMutationFactsSteps());
const sites: t.CallExpression[] = [];
traverseJavaScriptAst(parsed.program, {
enter: (node) => {
if (
t.isCallExpression(node) &&
t.isIdentifier(node.callee, { name: "inspect" })
)
sites.push(node);
},
});
const originalTiming: string[] = [];
const activationTiming: string[] = [];
for (const site of sites) {
const reference = site.arguments[0];
if (!t.isIdentifier(reference))
throw new Error("Expected parameter reference");
const binding = index.bindingForReferenceAt(reference);
expect(binding?.memberWrites).toEqual([
expect.objectContaining({
path: ["d"],
node: expect.objectContaining({ type: "AssignmentExpression" }),
}),
]);
for (const [facts, output] of [
[index.effectsForReferenceAt(site, reference, ["d"]), originalTiming],
[
index.effectsForReferenceWithinActivationAt(site, reference, ["d"]),
activationTiming,
],
] as const) {
const writes = facts.effects.filter(
({ kind }) => kind === "member-write",
);
expect(writes).toHaveLength(1);
output.push(writes[0]!.timing);
}
}
expect(originalTiming).toEqual(timing.map(() => "unknown"));
expect(activationTiming).toEqual(timing);
},
);
it("preserves exact member writes through an externally supplied caught value", () => {
const parsed = parseJavaScriptSource(`
try { throw external; } catch (caught) { caught.d = replacement; }
`);
if (parsed === null) throw new Error("Expected parsed source");
const index = completeSemanticSteps(
indexJavaScriptBindingsWithReceiversSteps(parsed.program),
);
completeSemanticSteps(index.indexMemberMutationFactsSteps());
const caught = [...index.bindings.bindingsById.values()].find(
({ name }) => name === "caught",
);
expect(caught?.memberWrites).toEqual([
expect.objectContaining({
path: ["d"],
node: expect.objectContaining({ type: "AssignmentExpression" }),
}),
]);
});
@@ -67,7 +67,10 @@ import {
} from "./javascriptSemanticRequireOrigin.js";
import { semanticBundledRequireOrigin } from "./javascriptBundledRequireOrigin.js";
import { collectSemanticMemberMutationsSteps } from "./javascriptSemanticMemberMutations.js";
import { semanticEffectTiming } from "./javascriptSemanticMutationInitializers.js";
import {
semanticEffectTiming,
semanticEffectTimingWithinActivation,
} from "./javascriptSemanticMutationInitializers.js";
import {
createAmbientPathResolver,
indexJavaScriptAmbientWritesSteps,
@@ -229,6 +232,7 @@ const visitSemanticReferenceEffectsAtSite = (
effect: JavaScriptSemanticReferenceEffectsAtSite["effects"][number],
) => boolean,
requestedPath?: JavaScriptSemanticPropertyPath,
effectTiming: typeof semanticEffectTiming = semanticEffectTiming,
): Omit<JavaScriptSemanticReferenceEffectsAtSite, "effects"> => {
const { root, path: inferredPath } = semanticReferenceRoot(referenceNode);
const memberEffectsIndexed = indexedMemberMutationStates.has(state);
@@ -259,7 +263,7 @@ const visitSemanticReferenceEffectsAtSite = (
return visit({
kind,
...(effectPath === undefined ? {} : { path: effectPath }),
timing: semanticEffectTiming(site, node, state),
timing: effectTiming(site, node, state),
});
};
if (binding !== undefined) {
@@ -328,6 +332,7 @@ const semanticReferenceEffectsAtSite = (
site: t.Node,
referenceNode: t.Node,
requestedPath?: JavaScriptSemanticPropertyPath,
effectTiming: typeof semanticEffectTiming = semanticEffectTiming,
): JavaScriptSemanticReferenceEffectsAtSite => {
const effects: JavaScriptSemanticReferenceEffectsAtSite["effects"][number][] =
[];
@@ -340,6 +345,7 @@ const semanticReferenceEffectsAtSite = (
return true;
},
requestedPath,
effectTiming,
);
return { ...metadata, effects };
};
@@ -554,6 +560,15 @@ export function* indexJavaScriptBindingsWithReceiversSteps(
propertyPath,
);
},
effectsForReferenceWithinActivationAt(site, referenceNode, propertyPath) {
return semanticReferenceEffectsAtSite(
state,
site,
referenceNode,
propertyPath,
semanticEffectTimingWithinActivation,
);
},
referenceMayHaveChangedAt(site, referenceNode, propertyPath) {
if (!indexedMemberMutationStates.has(state)) return true;
return referenceEffectsMayHaveChanged(
@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
import * as t from "@babel/types";
import {
analyzeJavaScriptSemantics,
@@ -270,8 +271,9 @@ describe("typed-array reference dependencies", () => {
});
it("does not retain the cross-product of scalar assignments and store sites", () => {
const storeCount = 48;
const stores = Array.from(
{ length: 48 },
{ length: storeCount },
(_, i) =>
`value = heap[index + ${i} >> 2]; heap[index + ${i + 1} >> 2] = value;`,
).join("\n");
@@ -279,9 +281,21 @@ describe("typed-array reference dependencies", () => {
const heap = new Int32Array(buffer);
function copy(index) { let value = 0; ${stores} }
`);
for (const name of ["heap", "value"])
expect(
bindings.find((binding) => binding.name === name)!.escapedPaths,
).toEqual([]);
// An unknown parameter need not be numeric: '+' may invoke conversion methods.
const indexEscapes = bindings.find(
(binding) => binding.name === "index",
)!.escapedPaths;
expect(indexEscapes.length).toBeGreaterThan(0);
expect(indexEscapes.length).toBeLessThanOrEqual(2 * storeCount);
expect(
bindings.reduce((n, binding) => n + binding.escapedPaths.length, 0),
).toBe(0);
indexEscapes.every(({ node }) =>
t.isBinaryExpression(node, { operator: "+" }),
),
).toBe(true);
expect(
bindings.find((binding) => binding.name === "heap")!.memberWrites.length,
).toBeGreaterThan(0);
@@ -2008,8 +2008,8 @@ const observableMutationPaths = (
for (const binding of state.bindingsById.values()) {
const observer = bindingNodes.get(binding.bindingId);
if (observer === undefined) continue;
// Imported values are external objects with observable member slots even
// when their syntax has no local object-allocation node (for example a
// Parameters, caught values and imports can have observable member slots
// without a local object-allocation node (for example a
// CommonJS namespace from `require("node:fs")`). Keep those writes at their
// exact member paths instead of widening the first path to a wildcard.
const hasParsedRequireOrigin = binding.initializers.some(
@@ -2018,6 +2018,8 @@ const observableMutationPaths = (
hasSemanticCreateRequireFactoryOrigin(node, state),
);
if (
binding.kind === "parameter" ||
binding.kind === "catch" ||
binding.directOrigins.length > 0 ||
hasParsedRequireOrigin ||
binding.hostOrigin !== undefined
@@ -556,6 +556,25 @@ export const semanticDefiniteEffectPrecedesReference = (
);
};
/** Order straight-line source effects in one activation, preserving deferred/repeating unknowns. */
export const semanticEffectTimingWithinActivation = (
site: t.Node,
effect: t.Node | undefined,
state: JavaScriptSemanticAnalysisState,
): JavaScriptSemanticEffectTiming => {
if (effect !== undefined) {
if (
semanticDefiniteEffectPrecedesReference(site, effect, state.parentsByNode)
)
return "after";
if (
semanticDefiniteEffectPrecedesReference(effect, site, state.parentsByNode)
)
return "before";
}
return semanticEffectTiming(site, effect, state);
};
const computeReferenceInitializerPosition = (
node: t.Node,
parents: WeakMap<t.Node, t.Node>,
@@ -207,6 +207,12 @@ export interface JavaScriptBindingIndex {
referenceNode: t.Node,
propertyPath?: JavaScriptSemanticPropertyPath,
): JavaScriptSemanticReferenceEffectsAtSite;
/** Order declarations within one activation; deferred and repeating effects stay unknown. */
effectsForReferenceWithinActivationAt(
site: t.Node,
referenceNode: t.Node,
propertyPath?: JavaScriptSemanticPropertyPath,
): JavaScriptSemanticReferenceEffectsAtSite;
/** Stop at the first relevant prior/uncertain effect without retaining a report. */
referenceMayHaveChangedAt(
site: t.Node,
@@ -17,7 +17,10 @@ import {
import { isElectronRendererLoad } from "./electronStaticRenderer.js";
import { finishElectronNativeBindingsSteps } from "./electronStaticAnalysisNative.js";
import { inspectElectronStaticNode } from "./electronStaticAnalysis.js";
import { hasPossibleCommonJsDelegateWrite } from "./javascriptSemanticRequireOrigin.js";
import {
hasPossibleCommonJsDelegateWrite,
semanticFactoryLoaderMemberAt,
} from "./javascriptSemanticRequireOrigin.js";
import { semanticResourceLimitReason } from "./javascriptSemanticResourceLimits.js";
import { createStaticPathIntrinsicQuery } from "./javascriptStaticPathIntrinsics.js";
import { createSemanticExpressionShapeResolver } from "./javascriptSemanticExpressionShapes.js";
@@ -41,6 +44,7 @@ import {
indexVitePreloadDependencyTable,
} from "./javascriptStaticAnalysisCalls.js";
import {
finishBundlerExports,
inspectBundlerRegistration,
inspectBundlerBootstrap,
inspectEsbuildWrapper,
@@ -125,6 +129,9 @@ export function* analyzeParsedJavaScriptStaticSourceSteps(
);
yield* traverseStaticSourceSteps(file, findings, bindings, routeScan);
const routes = yield* routeScan.finishSteps();
if (accumulator.bundlerExportHelpersObserved)
yield* index.indexMemberMutationFactsSteps();
finishBundlerExports(accumulator, index);
yield* finishElectronNativeBindingsSteps(findings, index);
yield* finishElectronBrowserWindowsSteps(findings, index);
if (accumulator.pendingModuleRequireCalls.length > 0) {
@@ -299,6 +306,15 @@ const inspectNode = (
inspectBundlerBootstrap(source, node, accumulator, bindings);
inspectBundlerRegistration(source, node, accumulator, bindings);
inspectEsbuildWrapper(source, node, accumulator, bindings);
if (
semanticFactoryLoaderMemberAt(
node,
bindings,
accumulator.factoryRequireBindingIds,
"d",
) === "factory-loader"
)
accumulator.bundlerExportHelpersObserved = true;
inspectCall(source, node, findings, bindings);
} else if (t.isOptionalCallExpression(node)) {
inspectCall(source, node, findings, bindings);
@@ -4,6 +4,7 @@ import { javascriptBootstrapAt } from "./javascriptBootstrap.js";
import {
resolveSemanticBindingState,
type JavaScriptSemanticAnalysisState,
type JavaScriptBindingIndex,
} from "./javascriptSemanticState.js";
import { semanticFactoryLoaderMemberAt } from "./javascriptSemanticRequireOrigin.js";
@@ -262,7 +263,7 @@ const recoverBundlerModule = (
const parameter = parameters[index];
return t.isIdentifier(parameter) ? parameter : undefined;
};
const exportsValue = collectJavaScriptExports(factory, bindings, {
const roles = {
exports:
runtime === "esbuild-commonjs"
? identifierAt(0)
@@ -276,7 +277,9 @@ const recoverBundlerModule = (
? undefined
: identifierAt(0),
loader: esbuild ? undefined : identifierAt(2),
});
};
const exports: string[] = [];
accumulator.pendingBundlerExports.push({ factory, roles, exports });
const requireName = esbuild ? null : factoryRequireName(factory);
const requireParameter = esbuild ? undefined : parameters[2];
const requireBindingId = t.isIdentifier(requireParameter)
@@ -310,7 +313,7 @@ const recoverBundlerModule = (
source_sha256: sha256Text(sourceSlice(source, factory)),
structural_fingerprint_sha256: fingerprint,
structural_fingerprint_algorithm: JAVASCRIPT_AST_FINGERPRINT_ALGORITHM,
exports: exportsValue.values,
exports,
location: range(factory),
},
asyncChunkKeys: asyncChunks.values,
@@ -448,3 +451,17 @@ const uniqueValues = (values: readonly string[], unknown = 0): StaticValues => {
unknown,
};
};
/** Complete factory exports once the shared alias-aware write index is ready. */
export const finishBundlerExports = (
accumulator: AnalysisAccumulator,
index: JavaScriptBindingIndex,
): void => {
for (const pending of accumulator.pendingBundlerExports) {
const collected = collectJavaScriptExports(pending.factory, {
roles: pending.roles,
index,
});
for (const value of collected.values) pending.exports.push(value);
}
};
@@ -1,4 +1,5 @@
import type * as t from "@babel/types";
import type { JavaScriptFactoryExportBindings } from "./javascriptAstFingerprint.js";
import type { JavaScriptOpenReceiverFact } from "./javascriptSemanticAnalysis.js";
import type { JavaScriptRpcScan } from "./javascriptRpcEndpoints.js";
import type { JavaScriptStaticPathIntrinsicQuery } from "./javascriptStaticAnalysisHelpers.js";
@@ -41,6 +42,11 @@ export interface LocatedJavaScriptFinding<Value> {
/** Mutable collections used during one inert AST traversal. */
export interface JavaScriptAnalysisAccumulator {
readonly pendingBundlerExports: {
readonly factory: t.Node;
readonly roles: JavaScriptFactoryExportBindings;
readonly exports: string[];
}[];
readonly pendingModuleRequireCalls: (
| t.CallExpression
| t.OptionalCallExpression
@@ -85,6 +91,7 @@ export interface JavaScriptAnalysisAccumulator {
/** Binding identities proven as third-parameter loaders of recovered factories. */
readonly factoryRequireBindingIds: Set<string>;
readonly seen: Set<string>;
bundlerExportHelpersObserved: boolean;
visitedNodes: number;
unknownFindings: number;
}
@@ -145,6 +152,7 @@ export interface LocatedJavaScriptFindingInput<
export const createJavaScriptAnalysisAccumulator = (
sourceLength: number,
): JavaScriptAnalysisAccumulator => ({
pendingBundlerExports: [],
pendingModuleRequireCalls: [],
appPathChanges: [],
references: [],
@@ -166,6 +174,7 @@ export const createJavaScriptAnalysisAccumulator = (
moduleRangeIndex: new JavaScriptModuleRangeIndex(sourceLength),
factoryRequireBindingIds: new Set(),
seen: new Set(),
bundlerExportHelpersObserved: false,
visitedNodes: 0,
unknownFindings: 0,
});
@@ -34,6 +34,21 @@ cliTest.for(["CLI", "stdio MCP"] as const)(
r = {d() {}};
r.d(e, { loaderGhost: () => 2 });
e.afterLoaderReplacement = 3;
},
31(mod, api, load) {
load.d(api, { beforeMethodReplacement: () => 1 });
load.other = () => {};
load.d(api, { afterUnrelatedWrite: () => 2 });
load.d = () => {};
load.d(api, { methodGhost: () => 3 });
api.afterMethodReplacement = 4;
},
37(mod, api, load) {
load.d(api, { beforeAliasedReplacement: () => 1 });
const alias = load;
alias.d = () => {};
load.d(api, { aliasedMethodGhost: () => 2 });
api.afterAliasedReplacement = 3;
}
}]);`,
);
@@ -51,6 +66,18 @@ cliTest.for(["CLI", "stdio MCP"] as const)(
module_key: "23",
exports: ["afterLoaderReplacement", "alpha"],
}),
expect.objectContaining({
module_key: "31",
exports: [
"afterMethodReplacement",
"afterUnrelatedWrite",
"beforeMethodReplacement",
],
}),
expect.objectContaining({
module_key: "37",
exports: ["afterAliasedReplacement", "beforeAliasedReplacement"],
}),
]),
);
expect(result.graph.coverage.status).toBe("complete");