fix(javascript): preserve factory export helper identity

This commit is contained in:
morluto committed 2026-10-24 16:08:26 +08:00
1 parent 781760a195
commit 0f3a15de0d
12 files changed
+277 -29

No files matched your search

@@ -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,
});