fix(javascript): share accessor selection and invocation effects

This commit is contained in:
morluto
2026-10-24 01:39:25 +08:00
parent 48f700ee35
commit 6e324b2244
7 changed files with 623 additions and 96 deletions
@@ -33,15 +33,21 @@ import {
semanticClassCandidates,
semanticClassPropertyReferences,
semanticObjectPropertyReferences,
semanticObjectAccessorReferences,
type JavaScriptSemanticObjectReference,
} from "./javascriptSemanticObjectReferences.js";
import {
semanticIterationSources,
type SemanticIterationSource,
} from "./javascriptSemanticIterationSources.js";
import { semanticGetterReceiverPaths } from "./javascriptSemanticGetterResults.js";
import { semanticCallableResultExpressions } from "./javascriptSemanticCallableResults.js";
import { createSemanticReturnedReferences } from "./javascriptSemanticReturnedReferences.js";
import { semanticMutationInitializers } from "./javascriptSemanticMutationInitializers.js";
import {
semanticMutationInitializers,
semanticBindingAtRead,
} from "./javascriptSemanticMutationInitializers.js";
import { SEMANTIC_ALIAS_SELECTION_LIMIT } from "./javascriptSemanticResourceLimits.js";
import {
semanticCopiedReferencePath,
semanticKnownArrayIteration,
@@ -55,10 +61,14 @@ import {
type JavaScriptSemanticPropertyPath,
} from "./javascriptSemanticPropertyPaths.js";
import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js";
import { traverseJavaScriptAstSteps } from "./javascriptSemanticTraversal.js";
import {
traverseJavaScriptAstSteps,
childNodes,
} from "./javascriptSemanticTraversal.js";
import {
semanticStaticPropertyKey,
semanticWriteSiteTarget,
semanticWriteSiteReadsFirst,
semanticWriteTargets,
unwrapJavaScriptExpression,
} from "./javascriptAstValues.js";
@@ -85,79 +95,169 @@ const arrayPrototypeIterationMayChange = (path: PropertyPath): boolean => {
/** Preserve uncertainty from property writes and references exposed to calls, yielding during traversal and fixpoint iteration. */
/** Setter keys of one object literal; a computed setter key may match any key. */
interface SetterKeys {
readonly names: ReadonlySet<string>;
readonly computed: boolean;
}
// Writes to one literal repeat; scan its properties once, including the
// common result that it defines no setter.
const setterKeysByObject = new WeakMap<t.ObjectExpression, SetterKeys | null>();
const setterKeys = (object: t.ObjectExpression): SetterKeys | null => {
const cached = setterKeysByObject.get(object);
if (cached !== undefined) return cached;
const names = new Set<string>();
let computed = false;
// Follow definition order: a later data property or method with the same
// static name replaces the accessor, so its setter no longer runs. Spreads
// and unknown computed keys may or may not replace it and keep it.
for (const property of object.properties) {
if (t.isSpreadElement(property)) continue;
const name = semanticStaticPropertyKey(property.key, property.computed);
if (t.isObjectMethod(property) && property.kind === "set") {
if (name === null) computed = true;
else names.add(name);
/** Accessor selection uses the same slots, prototype shadowing and array indices as aliases. */
const localAccessorMethods = (
root: t.Node,
path: PropertyPath,
accessor: "get" | "set",
state: JavaScriptSemanticAnalysisState,
keyMayExist: (
key: PropertyPath[number] | undefined,
kind: "get" | "set",
) => boolean,
): readonly t.ObjectMethod[] | undefined => {
if (!keyMayExist(path.at(-1), accessor)) return [];
const methods = new Set<t.ObjectMethod>();
const pending = [{ node: root, path }];
const examined = new WeakMap<t.Node, Set<string>>();
let selections = 0;
for (let cursor = 0; cursor < pending.length; cursor++) {
const current = pending[cursor]!;
const node = unwrapJavaScriptExpression(current.node).node;
const key = JSON.stringify(current.path);
const paths = examined.get(node) ?? new Set<string>();
if (paths.has(key)) continue;
paths.add(key);
examined.set(node, paths);
if (++selections > SEMANTIC_ALIAS_SELECTION_LIMIT) {
state.valueResourceLimits.add("alias-selections");
return undefined;
}
if (t.isObjectMethod(node) && current.path.length === 0) {
methods.add(node);
continue;
}
if (t.isIdentifier(node)) {
const binding = resolveSemanticBindingState(state, node, node.name);
if (binding === undefined) continue;
const selected = semanticBindingAtRead(
binding,
node,
state.parentsByNode,
).binding;
for (const source of [
...selected.initializers,
...selected.referenceInitializers,
])
pending.push({
node: source.node,
path: [...source.projection, ...current.path],
});
} else if (
name !== null &&
!(t.isObjectMethod(property) && property.kind === "get")
)
names.delete(name);
t.isMemberExpression(node) ||
t.isOptionalMemberExpression(node)
) {
pending.push({
node: node.object,
path: [
semanticStaticPropertyKey(node.property, node.computed),
...current.path,
],
});
} else if (
current.path.length > 0 &&
(t.isObjectExpression(node) || t.isArrayExpression(node))
) {
const [selected, ...remaining] = current.path;
const references = t.isArrayExpression(node)
? semanticArrayPropertyReferences(node, [selected!])
: remaining.length === 0
? semanticObjectAccessorReferences(node, selected!, state, accessor)
: semanticObjectPropertyReferences(node, [selected!], state);
for (const source of references)
pending.push({
node: source.node,
path: [...source.path, ...remaining],
});
} else {
for (const source of branchReferenceValues(node, current.path))
pending.push(source);
}
}
const keys = names.size === 0 && !computed ? null : { names, computed };
setterKeysByObject.set(object, keys);
return keys;
return [...methods];
};
/**
* The receiver path whose own object-literal setter an assignment to `path`
* runs, following nested literal members from the binding's initializer.
*/
const localSetterReceiver = (
initializers: readonly {
readonly node: t.Node;
readonly projection: readonly unknown[];
}[],
path: readonly unknown[],
): readonly string[] | undefined => {
const key = path.at(-1);
if (typeof key !== "string") return undefined;
const receiver = path.slice(0, -1);
if (!receiver.every((segment) => typeof segment === "string"))
return undefined;
for (const { node, projection } of initializers) {
if (projection.length !== 0) continue;
let object: t.Node | undefined = unwrapJavaScriptExpression(node).node;
for (const segment of receiver) {
if (!t.isObjectExpression(object)) break;
const member: t.ObjectExpression["properties"][number] | undefined =
object.properties.findLast(
(property) =>
t.isObjectProperty(property) &&
semanticStaticPropertyKey(property.key, property.computed) ===
segment,
);
object = t.isObjectProperty(member)
? unwrapJavaScriptExpression(member.value).node
: undefined;
type AccessorEffectOperand =
| { readonly kind: "escape"; readonly node: t.Node }
| {
readonly kind: "read";
readonly node: t.Node;
readonly key: PropertyPath[number];
}
if (!t.isObjectExpression(object)) continue;
const keys = setterKeys(object);
if (keys !== null && (keys.computed || keys.names.has(key)))
return receiver as readonly string[];
| {
readonly kind: "invoke";
readonly node: t.Node;
readonly invocation?: t.CallExpression | t.OptionalCallExpression;
};
const accessorOperands = new WeakMap<
t.Function,
readonly AccessorEffectOperand[]
>();
/** Collect invocation operands once; receiver interpretation belongs to the getter owner. */
const accessorEffectOperands = (
method: t.Function,
): readonly AccessorEffectOperand[] => {
const cached = accessorOperands.get(method);
if (cached !== undefined) return cached;
const operands: AccessorEffectOperand[] = [];
const pending: t.Node[] = [method.body];
const writeOnly = new WeakSet<t.Node>();
while (pending.length > 0) {
const node = pending.pop()!;
// Defining a nested callable does not invoke it.
if (t.isFunction(node) || t.isClass(node)) continue;
const write = semanticWriteSiteTarget(node);
if (write !== null) {
for (const target of semanticWriteTargets(write)) {
if (!semanticWriteSiteReadsFirst(node)) writeOnly.add(target);
if (!t.isIdentifier(target))
operands.push({ kind: "escape", node: target.object });
}
}
if (
(t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) &&
!writeOnly.has(node)
)
operands.push({
kind: "read",
node: node.object,
key: semanticStaticPropertyKey(node.property, node.computed),
});
if (
t.isCallExpression(node) ||
t.isOptionalCallExpression(node) ||
t.isNewExpression(node)
) {
const callee = unwrapJavaScriptExpression(node.callee).node;
operands.push({
kind: "invoke",
node: node.callee,
...(t.isNewExpression(node) ? {} : { invocation: node }),
});
if (
!t.isNewExpression(node) &&
(t.isMemberExpression(callee) || t.isOptionalMemberExpression(callee))
)
operands.push({ kind: "escape", node: callee.object });
for (const argument of node.arguments)
operands.push({
kind: "escape",
node: t.isSpreadElement(argument) ? argument.argument : argument,
});
} else if (t.isTaggedTemplateExpression(node)) {
operands.push({ kind: "invoke", node: node.tag });
const tag = unwrapJavaScriptExpression(node.tag).node;
if (t.isMemberExpression(tag) || t.isOptionalMemberExpression(tag))
operands.push({ kind: "escape", node: tag.object });
for (const expression of node.quasi.expressions)
operands.push({ kind: "escape", node: expression });
}
pending.push(...childNodes(node).reverse());
}
return undefined;
accessorOperands.set(method, operands);
return operands;
};
export function* collectSemanticMemberMutationsSteps(
@@ -168,9 +268,97 @@ export function* collectSemanticMemberMutationsSteps(
const deferredMapReceivers = new Set<
t.CallExpression | t.OptionalCallExpression
>();
// The binding pass already retained callable nodes. Ordinary data-only code
// must not pay an alias walk for every member read or assignment.
const accessorKeys = {
get: new Set<string | null>(),
set: new Set<string | null>(),
};
for (const node of state.callableNodesById.values())
if (t.isObjectMethod(node) && (node.kind === "get" || node.kind === "set"))
accessorKeys[node.kind].add(
semanticStaticPropertyKey(node.key, node.computed),
);
const accessorKeyMayExist = (
key: PropertyPath[number] | undefined,
kind: "get" | "set",
): boolean => {
const keys = accessorKeys[kind];
return (
keys.has(null) ||
(key === null || typeof key === "object"
? keys.size > 0
: keys.has(String(key)))
);
};
const returnedReferences = createSemanticReturnedReferences(state);
const getterEffectPaths = (
root: t.Node,
path: PropertyPath,
): readonly PropertyPath[] => {
if (!accessorKeyMayExist(path.at(-1), "get")) return [];
const pending = [path];
const examined = new Set<string>();
const effects: PropertyPath[] = [];
for (let cursor = 0; cursor < pending.length; cursor++) {
const selected = pending[cursor]!;
const identity = JSON.stringify(selected);
if (examined.has(identity)) continue;
examined.add(identity);
if (examined.size > SEMANTIC_ALIAS_SELECTION_LIMIT) {
state.valueResourceLimits.add("alias-selections");
return [[]];
}
const methods = localAccessorMethods(
root,
selected,
"get",
state,
accessorKeyMayExist,
);
const receiver = selected.slice(0, -1);
if (methods === undefined) {
effects.push(receiver);
continue;
}
for (const method of methods) {
const callables: t.Function[] = [method];
const visitedCallables = new Set<t.Function>();
for (let index = 0; index < callables.length; index++) {
const callable = callables[index]!;
if (visitedCallables.has(callable)) continue;
visitedCallables.add(callable);
for (const operand of accessorEffectOperands(callable)) {
if (operand.kind === "invoke") {
for (const target of returnedReferences(
operand.node,
operand.invocation,
).callables)
if (!target.generator) callables.push(target);
continue;
}
if (
operand.kind === "read" &&
!accessorKeyMayExist(operand.key, "get")
)
continue;
for (const suffix of semanticGetterReceiverPaths(
method,
operand.node,
state,
)) {
const target = [...receiver, ...suffix];
if (operand.kind === "escape") effects.push(target);
else pending.push([...target, operand.key]);
}
}
}
}
}
return effects;
};
const parents = state.parentsByNode;
const observablePaths = observableMutationPaths(state);
const returnedReferences = createSemanticReturnedReferences(state);
const recordedRequiredEffects = new Set<string>();
let recordedEffectCount = 0;
const recordRequiredEffect = (
@@ -513,15 +701,27 @@ export function* collectSemanticMemberMutationsSteps(
current.originAt,
parents,
);
// A setter on the binding's own literal runs on assignment and may
// A selected setter runs on assignment and may
// replace any member of its receiver, so the written key is unknown.
// Deleting the accessor never calls it.
if (
effect === "write" &&
!t.isUnaryExpression(mutation, { operator: "delete" })
) {
const receiver = localSetterReceiver(origins.initializers, path);
if (receiver !== undefined) path = [...receiver, null];
const selectedPath = path;
if (
origins.initializers.some(
(source) =>
(localAccessorMethods(
source.node,
[...source.projection, ...selectedPath],
"set",
state,
accessorKeyMayExist,
)?.length ?? 1) > 0,
)
)
path = [...selectedPath.slice(0, -1), null];
}
// Keep an antichain of exact paths. Widening distinct member keys would
// also invalidate unrelated siblings that never reach this effect.
@@ -888,8 +1088,13 @@ export function* collectSemanticMemberMutationsSteps(
sourcesByTarget.get(target) ?? [],
);
};
const writeOnlyMembers = new WeakSet<t.Node>();
yield* traverseJavaScriptAstSteps(program, {
enter: (node, parent) => {
const writeTarget = semanticWriteSiteTarget(node);
if (writeTarget !== null && !semanticWriteSiteReadsFirst(node))
for (const target of semanticWriteTargets(writeTarget))
writeOnlyMembers.add(target);
for (const source of semanticIterationSources(node, parent))
deferIterable({
...source,
@@ -900,7 +1105,14 @@ export function* collectSemanticMemberMutationsSteps(
originAt: source.mutation,
iterationOnly: source.kind === "iteration",
});
const writeTarget = semanticWriteSiteTarget(node);
if (t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) {
// The write owner classifies direct and destructuring stores once.
if (!writeOnlyMembers.has(node))
for (const path of getterEffectPaths(node.object, [
semanticStaticPropertyKey(node.property, node.computed),
]))
markEscaped(node.object, node, path);
}
if (writeTarget !== null) markTarget(writeTarget, node);
else if (
t.isCallExpression(node) ||
@@ -929,6 +1141,8 @@ export function* collectSemanticMemberMutationsSteps(
else if (!mapLookup) markReceiver(node.callee, node);
}
} else if (t.isTaggedTemplateExpression(node)) {
for (const source of returnedReferences(node.tag).references)
markEscaped(source.node, node, source.projection);
markReceiver(node.tag, node);
for (const expression of node.quasi.expressions)
markEscaped(expression, node);