1180 lines
42 KiB
TypeScript
1180 lines
42 KiB
TypeScript
import * as t from "@babel/types";
|
|
|
|
import {
|
|
resolveSemanticBindingState,
|
|
type JavaScriptSemanticBindingState,
|
|
type JavaScriptSemanticAnalysisState,
|
|
} from "./javascriptSemanticState.js";
|
|
import {
|
|
clearSemanticValueCaches,
|
|
evaluateSemanticBinding,
|
|
evaluateSemanticExpression,
|
|
} from "./javascriptSemanticValues.js";
|
|
import { semanticContainer } from "./javascriptSemanticSlots.js";
|
|
import {
|
|
semanticClassCandidates,
|
|
semanticClassPropertyReferences,
|
|
semanticObjectPropertyReferences,
|
|
type JavaScriptSemanticObjectReference,
|
|
} from "./javascriptSemanticObjectReferences.js";
|
|
import { semanticIterationSources } from "./javascriptSemanticIterationSources.js";
|
|
import { semanticCallableResultExpressions } from "./javascriptSemanticCallableResults.js";
|
|
import { createSemanticReturnedReferences } from "./javascriptSemanticReturnedReferences.js";
|
|
import { semanticMutationInitializers } from "./javascriptSemanticMutationInitializers.js";
|
|
import {
|
|
semanticReferenceSourcesInactive,
|
|
semanticSelectedReferenceSlot,
|
|
} from "./javascriptSemanticReferenceSources.js";
|
|
import {
|
|
semanticArrayIndex,
|
|
SemanticPropertyPathCoverage,
|
|
semanticPropertyPathKeyMatches,
|
|
type JavaScriptSemanticPropertyPath,
|
|
} from "./javascriptSemanticPropertyPaths.js";
|
|
import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js";
|
|
import { traverseJavaScriptAstSteps } from "./javascriptSemanticTraversal.js";
|
|
import {
|
|
semanticStaticPropertyKey,
|
|
semanticWriteTargets,
|
|
unwrapJavaScriptExpression,
|
|
} from "./javascriptAstValues.js";
|
|
|
|
type PropertyPath = JavaScriptSemanticPropertyPath;
|
|
type ValueEffect = "write" | "escape";
|
|
|
|
/** Preserve uncertainty from property writes and references exposed to calls, yielding during traversal and fixpoint iteration. */
|
|
export function* collectSemanticMemberMutationsSteps(
|
|
program: t.Program,
|
|
state: JavaScriptSemanticAnalysisState,
|
|
): Generator<void, void> {
|
|
const parents = state.parentsByNode;
|
|
const observablePaths = observableMutationPaths(state);
|
|
const returnedReferences = createSemanticReturnedReferences(state);
|
|
const recordedEffects = new Set<string>();
|
|
const selections = new Map<string, readonly string[]>();
|
|
const coveringPaths = new Map<string, SemanticPropertyPathCoverage>();
|
|
let arrayIterationUnknown = false;
|
|
// Definition collection finishes before this phase, so a scope's bindings no
|
|
// longer change and one node resolves to one binding. Repeated visits of the
|
|
// same node otherwise repeat the same scope walk.
|
|
const resolvedBindings = new WeakMap<
|
|
t.Node,
|
|
JavaScriptSemanticBindingState | null
|
|
>();
|
|
const resolveBinding = (
|
|
node: t.Node,
|
|
name: string,
|
|
): JavaScriptSemanticBindingState | undefined => {
|
|
const cached = resolvedBindings.get(node);
|
|
if (cached !== undefined) return cached ?? undefined;
|
|
const resolved = resolveSemanticBindingState(state, node, name);
|
|
resolvedBindings.set(node, resolved ?? null);
|
|
return resolved;
|
|
};
|
|
// One capture tuple recurs across every alias it reaches. Re-encoding it
|
|
// would rebuild the same long key and force a fresh string hash per lookup.
|
|
const captureKeys = new WeakMap<
|
|
JavaScriptSemanticBindingState,
|
|
Map<string, string>
|
|
>();
|
|
const captureKeyOf = (
|
|
binding: JavaScriptSemanticBindingState,
|
|
originAt: t.Node | undefined,
|
|
ownerInitializer: t.Node | undefined,
|
|
): string => {
|
|
let byPosition = captureKeys.get(binding);
|
|
if (byPosition === undefined) {
|
|
byPosition = new Map();
|
|
captureKeys.set(binding, byPosition);
|
|
}
|
|
const positionKey = `${originAt?.start ?? "u"}:${originAt?.end ?? "u"}:${
|
|
ownerInitializer?.start ?? "u"
|
|
}:${ownerInitializer?.end ?? "u"}`;
|
|
const cached = byPosition.get(positionKey);
|
|
if (cached !== undefined) return cached;
|
|
const created = JSON.stringify([
|
|
binding.bindingId,
|
|
originAt?.start,
|
|
originAt?.end,
|
|
ownerInitializer?.start,
|
|
ownerInitializer?.end,
|
|
]);
|
|
byPosition.set(positionKey, created);
|
|
return created;
|
|
};
|
|
// A capture key already ends the array; a path stays one nested element.
|
|
const lifetimeKey = (captureKey: string, path: PropertyPath): string =>
|
|
`${captureKey.slice(0, -1)},${JSON.stringify(path)}]`;
|
|
const pendingReferences: {
|
|
readonly initializer: JavaScriptSemanticBindingState["referenceInitializers"][number];
|
|
readonly path: PropertyPath;
|
|
readonly bindings: ReadonlySet<string>;
|
|
readonly effect: ValueEffect;
|
|
readonly mutation?: t.Node;
|
|
readonly originAt: t.Node | undefined;
|
|
}[] = [];
|
|
const iterableReferences: IterableReference[] = [];
|
|
const iterableReferenceKeys = new WeakMap<t.Node, Set<string>>();
|
|
const deferIterable = (reference: IterableReference): void => {
|
|
const keys = iterableReferenceKeys.get(reference.node) ?? new Set<string>();
|
|
const key = JSON.stringify([
|
|
reference.projection,
|
|
reference.path,
|
|
reference.fallbackPath,
|
|
reference.effect,
|
|
reference.mutation?.start,
|
|
reference.mutation?.end,
|
|
reference.originAt?.start,
|
|
reference.originAt?.end,
|
|
reference.iterationOnly,
|
|
reference.requiredSources?.map((source) => [
|
|
source.node.start,
|
|
source.node.end,
|
|
source.projection,
|
|
]),
|
|
reference.fallbackSources?.map((source) => [
|
|
source.node.start,
|
|
source.node.end,
|
|
source.projection,
|
|
]),
|
|
[...reference.bindings].sort(compareUnicodeCodePoints),
|
|
]);
|
|
if (keys.has(key)) return;
|
|
keys.add(key);
|
|
iterableReferenceKeys.set(reference.node, keys);
|
|
iterableReferences.push(reference);
|
|
};
|
|
const markValue = (
|
|
node: t.Node,
|
|
path: PropertyPath,
|
|
bindings: ReadonlySet<string>,
|
|
effect: ValueEffect = "write",
|
|
mutation?: t.Node,
|
|
originAt: t.Node | undefined = mutation,
|
|
ownerInitializer?: t.Node,
|
|
): void => {
|
|
const pending: (ReferencedValue & {
|
|
readonly bindings: ReadonlySet<string>;
|
|
readonly originAt: t.Node | undefined;
|
|
})[] = [
|
|
{
|
|
node,
|
|
path,
|
|
bindings,
|
|
originAt,
|
|
...(ownerInitializer === undefined ? {} : { ownerInitializer }),
|
|
},
|
|
];
|
|
// Visit shallower alias traversals first so their exact prefixes suppress
|
|
// longer loop paths before those paths enumerate every assignment order.
|
|
for (let cursor = 0; cursor < pending.length; cursor++) {
|
|
const current = pending[cursor];
|
|
if (current === undefined) break;
|
|
const expression = unwrapJavaScriptExpression(current.node).node;
|
|
if (t.isIdentifier(expression)) {
|
|
const binding = resolveBinding(expression, expression.name);
|
|
if (binding === undefined) {
|
|
if (
|
|
(expression.name === "Array" &&
|
|
(current.path[0] === "prototype" || current.path[0] === null)) ||
|
|
(["globalThis", "global", "window", "self"].includes(
|
|
expression.name,
|
|
) &&
|
|
(current.path[0] === "Array" || current.path[0] === null) &&
|
|
(current.path[1] === "prototype" || current.path[1] === null))
|
|
)
|
|
arrayIterationUnknown = true;
|
|
continue;
|
|
}
|
|
// An alias restored from a saved reference can revisit the same binding
|
|
// at an earlier capture or a shorter getter-receiver path. Only a
|
|
// repeated capture and path closes a cycle.
|
|
const captureKey = captureKeyOf(
|
|
binding,
|
|
current.originAt,
|
|
current.ownerInitializer,
|
|
);
|
|
const lifetime = lifetimeKey(captureKey, current.path);
|
|
if (current.bindings.has(lifetime)) continue;
|
|
// A self alias such as `node = node[key]` revisits its capture with the
|
|
// same prefix added on every cycle, so exact paths never repeat. When
|
|
// that prefix can never be pruned, escaping it once covers every longer
|
|
// effect, including writes below shared children.
|
|
// Only a capture already on this alias chain can repeat a prefix.
|
|
const revisited = current.bindings.has(captureKey);
|
|
let repeatedPrefix: PropertyPath | undefined;
|
|
for (
|
|
let start = 1;
|
|
revisited && start <= current.path.length;
|
|
start++
|
|
) {
|
|
const suffix = current.path.slice(start);
|
|
if (!current.bindings.has(lifetimeKey(captureKey, suffix))) continue;
|
|
repeatedPrefix = current.path.slice(0, start);
|
|
break;
|
|
}
|
|
if (
|
|
repeatedPrefix !== undefined &&
|
|
observablePaths.repeatsUnpruned(binding.bindingId, repeatedPrefix)
|
|
) {
|
|
markValue(
|
|
current.node,
|
|
repeatedPrefix,
|
|
new Set(),
|
|
"escape",
|
|
mutation,
|
|
current.originAt,
|
|
current.ownerInitializer,
|
|
);
|
|
continue;
|
|
}
|
|
// Union compatible copied-slot selections at each binding. Carrying every
|
|
// combination of exclusions through branch diamonds is exponential.
|
|
// Preserve paths that can change observed slots or Array prototype
|
|
// effects; canonicalize suffixes that cannot distinguish lattice facts.
|
|
let path = observablePaths.normalize(
|
|
binding.bindingId,
|
|
current.path,
|
|
effect,
|
|
);
|
|
if (path === null) continue;
|
|
const selectorIndex = path.findIndex(
|
|
(key) => key !== null && typeof key === "object",
|
|
);
|
|
const selector = path[selectorIndex];
|
|
if (selector !== null && typeof selector === "object") {
|
|
const selectionIdentity = JSON.stringify([
|
|
binding.bindingId,
|
|
effect,
|
|
path.map((key, index) =>
|
|
index === selectorIndex
|
|
? { startIndex: selector.startIndex }
|
|
: key,
|
|
),
|
|
mutation?.start,
|
|
mutation?.end,
|
|
current.originAt?.start,
|
|
current.originAt?.end,
|
|
current.ownerInitializer?.start,
|
|
current.ownerInitializer?.end,
|
|
]);
|
|
const previous = selections.get(selectionIdentity);
|
|
const excludedKeys =
|
|
previous === undefined
|
|
? selector.excludedKeys
|
|
: previous.filter((key) => selector.excludedKeys.includes(key));
|
|
if (previous !== undefined && excludedKeys.length === previous.length)
|
|
continue;
|
|
selections.set(selectionIdentity, excludedKeys);
|
|
path = path.map((key, index) =>
|
|
index === selectorIndex ? { ...selector, excludedKeys } : key,
|
|
);
|
|
}
|
|
// Keep an antichain of exact paths. Widening distinct member keys would
|
|
// also invalidate unrelated siblings that never reach this effect.
|
|
const pathState = JSON.stringify([
|
|
binding.bindingId,
|
|
effect,
|
|
// Short writes stop at owned literal slots, whereas longer writes
|
|
// reach shared children. Only equal-depth writes cover one another.
|
|
effect === "write" ? path.length : null,
|
|
mutation?.start,
|
|
mutation?.end,
|
|
current.originAt?.start,
|
|
current.originAt?.end,
|
|
current.ownerInitializer?.start,
|
|
current.ownerInitializer?.end,
|
|
]);
|
|
let covering = coveringPaths.get(pathState);
|
|
if (covering === undefined) {
|
|
covering = new SemanticPropertyPathCoverage();
|
|
coveringPaths.set(pathState, covering);
|
|
}
|
|
if (!covering.retain(path)) continue;
|
|
const identity = JSON.stringify([
|
|
binding.bindingId,
|
|
effect,
|
|
path,
|
|
mutation?.start,
|
|
mutation?.end,
|
|
current.originAt?.start,
|
|
current.originAt?.end,
|
|
current.ownerInitializer?.start,
|
|
current.ownerInitializer?.end,
|
|
]);
|
|
if (recordedEffects.has(identity)) continue;
|
|
// A recorded write invalidates the binding to a non-primitive value, so
|
|
// only its first write can target a primitive. Re-evaluating it would
|
|
// replay every earlier write and make repeated writes superlinear.
|
|
const value =
|
|
effect === "write" && binding.mutatedPaths.length === 0
|
|
? evaluateSemanticBinding(binding, state)
|
|
: undefined;
|
|
const primitiveWrite =
|
|
value?.status === "literal" || value?.status === "union";
|
|
recordedEffects.add(identity);
|
|
const nested = new Set([...current.bindings, lifetime, captureKey]);
|
|
// Returning a literal's receiver reaches that allocation, not the
|
|
// binding's older values read while evaluating its initializer.
|
|
const origins =
|
|
current.ownerInitializer !== undefined
|
|
? {
|
|
initializers: binding.initializers.filter(
|
|
({ node }) => node === current.ownerInitializer,
|
|
),
|
|
referenceInitializers: [],
|
|
}
|
|
: current.originAt === undefined
|
|
? binding
|
|
: semanticMutationInitializers(
|
|
binding,
|
|
current.originAt,
|
|
parents,
|
|
);
|
|
if (!primitiveWrite) {
|
|
if (effect === "escape")
|
|
binding.escapedPaths.push({ path, node: mutation });
|
|
else binding.mutatedPaths.push(path);
|
|
clearSemanticValueCaches(state);
|
|
for (const initializer of origins.initializers)
|
|
pending.push({
|
|
node: initializer.node,
|
|
path: [...initializer.projection, ...path],
|
|
bindings: nested,
|
|
originAt: initializer.node,
|
|
});
|
|
}
|
|
for (const initializer of origins.referenceInitializers) {
|
|
const copiedPath = copiedReferencePath(initializer, path, effect);
|
|
if (copiedPath === null) continue;
|
|
pendingReferences.push({
|
|
initializer,
|
|
path: copiedPath,
|
|
bindings: nested,
|
|
effect,
|
|
originAt: initializer.node,
|
|
...(mutation === undefined ? {} : { mutation }),
|
|
});
|
|
}
|
|
} else if (
|
|
t.isMemberExpression(expression) ||
|
|
t.isOptionalMemberExpression(expression)
|
|
) {
|
|
pending.push({
|
|
node: expression.object,
|
|
path: [
|
|
semanticStaticPropertyKey(expression.property, expression.computed),
|
|
...current.path,
|
|
],
|
|
bindings: current.bindings,
|
|
originAt: current.originAt,
|
|
});
|
|
} else {
|
|
for (const value of effect === "escape"
|
|
? escapedReferenceLeaves(expression, current.path)
|
|
: expandedValues(expression, current.path, effect)) {
|
|
if (value.escapes && effect !== "escape")
|
|
markValue(
|
|
value.node,
|
|
value.path,
|
|
new Set(),
|
|
"escape",
|
|
mutation,
|
|
current.originAt,
|
|
value.ownerInitializer,
|
|
);
|
|
else if (value.iterableFallbackPath !== undefined)
|
|
deferIterable({
|
|
node: value.node,
|
|
projection: [],
|
|
path: value.path,
|
|
fallbackPath: value.iterableFallbackPath,
|
|
bindings: current.bindings,
|
|
effect,
|
|
originAt: current.originAt,
|
|
...(mutation === undefined ? {} : { mutation }),
|
|
});
|
|
else
|
|
pending.push({
|
|
...value,
|
|
bindings: current.bindings,
|
|
originAt: current.originAt,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
};
|
|
const markEscaped = (
|
|
node: t.Node,
|
|
mutation: t.Node,
|
|
path: PropertyPath = [],
|
|
): void => markValue(node, path, new Set(), "escape", mutation);
|
|
function expandedValues(
|
|
expression: t.Node,
|
|
path: PropertyPath,
|
|
effect: ValueEffect,
|
|
): readonly ReferencedValue[] {
|
|
if (
|
|
t.isArrayExpression(expression) &&
|
|
((path[0] === "__proto__" && (effect === "escape" || path.length > 1)) ||
|
|
(path[0] === "constructor" && path[1] === "prototype"))
|
|
)
|
|
arrayIterationUnknown = true;
|
|
return referencedValues(expression, path, effect, state);
|
|
}
|
|
// Expanding a value below its references depends only on syntax, not on the
|
|
// escape site. Escaping an object reaches every method's results, so
|
|
// re-expanding it at each call through that object made N calls over N
|
|
// methods quadratic. Identifiers that resolve to the same binding and path
|
|
// are processed identically, so one stands for all of them.
|
|
const escapedLeaves = new WeakMap<
|
|
t.Node,
|
|
Map<string, readonly ReferencedValue[]>
|
|
>();
|
|
function escapedReferenceLeaves(
|
|
root: t.Node,
|
|
rootPath: PropertyPath,
|
|
): readonly ReferencedValue[] {
|
|
// Parser recovery can leave an argument hole, which expands to nothing.
|
|
if (!t.isNode(root)) return [];
|
|
const rootKey = JSON.stringify(rootPath);
|
|
const cached = escapedLeaves.get(root)?.get(rootKey);
|
|
if (cached !== undefined) return cached;
|
|
const leaves: ReferencedValue[] = [];
|
|
const leafIdentities = new Set<string>();
|
|
const expanded = new WeakMap<t.Node, Set<string>>();
|
|
const pending = [...expandedValues(root, rootPath, "escape")];
|
|
for (let cursor = 0; cursor < pending.length; cursor++) {
|
|
const value = pending[cursor];
|
|
if (value === undefined) break;
|
|
const node = unwrapJavaScriptExpression(value.node).node;
|
|
if (!t.isNode(node)) continue;
|
|
const pathKey = JSON.stringify(value.path);
|
|
if (t.isIdentifier(node) && value.iterableFallbackPath === undefined) {
|
|
const binding = resolveBinding(node, node.name);
|
|
const identity = JSON.stringify([
|
|
binding === undefined ? null : binding.bindingId,
|
|
binding === undefined ? node.name : null,
|
|
pathKey,
|
|
value.ownerInitializer?.start,
|
|
value.ownerInitializer?.end,
|
|
]);
|
|
if (leafIdentities.has(identity)) continue;
|
|
leafIdentities.add(identity);
|
|
leaves.push(value);
|
|
} else if (
|
|
value.iterableFallbackPath !== undefined ||
|
|
t.isIdentifier(node) ||
|
|
t.isMemberExpression(node) ||
|
|
t.isOptionalMemberExpression(node)
|
|
)
|
|
leaves.push(value);
|
|
else {
|
|
const paths = expanded.get(node) ?? new Set<string>();
|
|
if (paths.has(pathKey)) continue;
|
|
paths.add(pathKey);
|
|
expanded.set(node, paths);
|
|
for (const reference of expandedValues(node, value.path, "escape"))
|
|
pending.push(reference);
|
|
}
|
|
}
|
|
const byPath = escapedLeaves.get(root) ?? new Map();
|
|
byPath.set(rootKey, leaves);
|
|
escapedLeaves.set(root, byPath);
|
|
return leaves;
|
|
}
|
|
const markReceiver = (callee: t.Node, mutation: t.Node): void => {
|
|
const expression = unwrapJavaScriptExpression(callee).node;
|
|
if (
|
|
t.isMemberExpression(expression) ||
|
|
t.isOptionalMemberExpression(expression)
|
|
)
|
|
markEscaped(expression.object, mutation);
|
|
};
|
|
const markTarget = (node: t.Node, mutation: t.Node): void => {
|
|
for (const target of semanticWriteTargets(node))
|
|
if (!t.isIdentifier(target))
|
|
markValue(
|
|
target.object,
|
|
[semanticStaticPropertyKey(target.property, target.computed)],
|
|
new Set(),
|
|
"write",
|
|
mutation,
|
|
);
|
|
};
|
|
yield* traverseJavaScriptAstSteps(program, {
|
|
enter: (node, parent) => {
|
|
for (const source of semanticIterationSources(node, parent))
|
|
deferIterable({
|
|
...source,
|
|
path: source.projection,
|
|
fallbackPath: source.projection,
|
|
bindings: new Set(),
|
|
effect: "escape",
|
|
originAt: source.mutation,
|
|
iterationOnly: source.kind === "iteration",
|
|
});
|
|
if (t.isAssignmentExpression(node)) markTarget(node.left, node);
|
|
else if (t.isUpdateExpression(node)) markTarget(node.argument, node);
|
|
else if (t.isUnaryExpression(node, { operator: "delete" }))
|
|
markTarget(node.argument, node);
|
|
else if (t.isForOfStatement(node) || t.isForInStatement(node))
|
|
markTarget(node.left, node);
|
|
else if (
|
|
t.isCallExpression(node) ||
|
|
t.isOptionalCallExpression(node) ||
|
|
t.isNewExpression(node)
|
|
) {
|
|
for (const source of returnedReferences(node.callee))
|
|
markEscaped(source.node, node, source.projection);
|
|
for (const argument of node.arguments)
|
|
if (t.isSpreadElement(argument))
|
|
deferIterable({
|
|
node: argument.argument,
|
|
projection: [],
|
|
path: [null],
|
|
fallbackPath: [],
|
|
bindings: new Set(),
|
|
effect: "escape",
|
|
mutation: node,
|
|
originAt: node,
|
|
});
|
|
else markEscaped(argument, node);
|
|
// Constructing a member does not pass its container as `this`.
|
|
if (!t.isNewExpression(node)) markReceiver(node.callee, node);
|
|
} else if (t.isTaggedTemplateExpression(node)) {
|
|
markReceiver(node.tag, node);
|
|
for (const expression of node.quasi.expressions)
|
|
markEscaped(expression, node);
|
|
}
|
|
},
|
|
});
|
|
// A later effect can make a previously defined destructuring source uncertain.
|
|
// Reconsider defaults and iterable origins until their effects are stable.
|
|
while (pendingReferences.length > 0 || iterableReferences.length > 0) {
|
|
const effectsBefore = recordedEffects.size;
|
|
const iterablesBefore = iterableReferences.length;
|
|
const iterationBefore: boolean = arrayIterationUnknown;
|
|
for (const [index, reference] of pendingReferences.splice(0).entries()) {
|
|
if (index % 64 === 0) yield;
|
|
const { initializer } = reference;
|
|
if (semanticReferenceSourcesInactive(initializer, state))
|
|
pendingReferences.push(reference);
|
|
else if (t.isForOfStatement(initializer.node))
|
|
deferIterable({
|
|
node: initializer.node.right,
|
|
projection: [],
|
|
path: [null, ...reference.path],
|
|
fallbackPath: [],
|
|
bindings: reference.bindings,
|
|
effect: reference.effect,
|
|
originAt: initializer.node,
|
|
...(reference.mutation === undefined
|
|
? {}
|
|
: { mutation: reference.mutation }),
|
|
});
|
|
else if (
|
|
initializer.copyKind === "array-rest" &&
|
|
initializer.copyProjectionOffset !== undefined
|
|
) {
|
|
const offset = initializer.copyProjectionOffset;
|
|
const projection = initializer.projection.slice(0, offset);
|
|
deferIterable({
|
|
node: initializer.node,
|
|
projection,
|
|
path: reference.path,
|
|
fallbackPath: projection,
|
|
bindings: reference.bindings,
|
|
effect: reference.effect,
|
|
originAt: reference.originAt,
|
|
...(reference.mutation === undefined
|
|
? {}
|
|
: { mutation: reference.mutation }),
|
|
});
|
|
} else
|
|
markValue(
|
|
initializer.node,
|
|
reference.path,
|
|
reference.bindings,
|
|
reference.effect,
|
|
reference.mutation,
|
|
reference.originAt,
|
|
);
|
|
}
|
|
for (const [index, reference] of [...iterableReferences].entries()) {
|
|
if (index % 64 === 0) yield;
|
|
if (semanticReferenceSourcesInactive(reference, state)) continue;
|
|
if (reference.iterationOnly) {
|
|
const expanded = expandedIterationReferences(reference, state);
|
|
if (expanded !== null) {
|
|
for (const selected of expanded) deferIterable(selected);
|
|
continue;
|
|
}
|
|
}
|
|
const slot = semanticSelectedReferenceSlot(reference, state);
|
|
// Custom iteration can yield the iterable itself as well as any child.
|
|
// Treat its root as escaped, including writes through yielded references.
|
|
const knownArray =
|
|
!arrayIterationUnknown &&
|
|
slot?.value.status === "array" &&
|
|
slot.value.items.every(
|
|
(item) => semanticArrayIndex(item.name) !== null,
|
|
);
|
|
if (knownArray && reference.iterationOnly) continue;
|
|
markValue(
|
|
reference.node,
|
|
knownArray ? reference.path : reference.fallbackPath,
|
|
reference.bindings,
|
|
knownArray ? reference.effect : "escape",
|
|
reference.mutation,
|
|
reference.originAt,
|
|
);
|
|
}
|
|
if (
|
|
recordedEffects.size === effectsBefore &&
|
|
iterableReferences.length === iterablesBefore &&
|
|
arrayIterationUnknown === iterationBefore
|
|
)
|
|
break;
|
|
}
|
|
// Gathering effects can evaluate a primitive projection before its object
|
|
// escapes. Final values must use the completed mutation state.
|
|
clearSemanticValueCaches(state);
|
|
}
|
|
|
|
interface ReferencedValue extends JavaScriptSemanticObjectReference {
|
|
readonly iterableFallbackPath?: PropertyPath;
|
|
}
|
|
|
|
interface IterableReference extends ReferencedValue {
|
|
readonly projection: PropertyPath;
|
|
readonly fallbackPath: PropertyPath;
|
|
readonly bindings: ReadonlySet<string>;
|
|
readonly effect: ValueEffect;
|
|
readonly mutation?: t.Node;
|
|
readonly originAt: t.Node | undefined;
|
|
readonly iterationOnly?: boolean;
|
|
readonly requiredSources?: readonly {
|
|
readonly node: t.Node;
|
|
readonly projection: PropertyPath;
|
|
}[];
|
|
readonly fallbackSources?: readonly {
|
|
readonly node: t.Node;
|
|
readonly projection: PropertyPath;
|
|
}[];
|
|
}
|
|
|
|
const expandedIterationReferences = (
|
|
reference: IterableReference,
|
|
state: JavaScriptSemanticAnalysisState,
|
|
): readonly IterableReference[] | null => {
|
|
if (!t.isForOfStatement(reference.mutation)) return null;
|
|
const iterable = reference.mutation.right;
|
|
// Expand only the loop's yielded-item selector. Enumerating dynamic property
|
|
// selections would multiply paths through shared object aliases.
|
|
const source = [
|
|
reference,
|
|
...(reference.requiredSources ?? []),
|
|
...(reference.fallbackSources ?? []),
|
|
].find((source) => source.node === iterable && source.projection[0] === null);
|
|
if (source === undefined) return null;
|
|
const slot = semanticSelectedReferenceSlot(
|
|
{ node: iterable, projection: [] },
|
|
state,
|
|
);
|
|
const container = slot === undefined ? null : semanticContainer(slot.value);
|
|
if (container?.coverage.status !== "complete") return null;
|
|
const select = (
|
|
candidate: { readonly node: t.Node; readonly projection: PropertyPath },
|
|
name: string,
|
|
) => ({
|
|
...candidate,
|
|
projection:
|
|
candidate.node === iterable && candidate.projection[0] === null
|
|
? [name, ...candidate.projection.slice(1)]
|
|
: candidate.projection,
|
|
});
|
|
return container.slots.map((slot) => {
|
|
const { projection } = select(reference, slot.name);
|
|
return {
|
|
...reference,
|
|
projection,
|
|
path: projection,
|
|
fallbackPath: projection,
|
|
...(reference.requiredSources === undefined
|
|
? {}
|
|
: {
|
|
requiredSources: reference.requiredSources.map((source) =>
|
|
select(source, slot.name),
|
|
),
|
|
}),
|
|
...(reference.fallbackSources === undefined
|
|
? {}
|
|
: {
|
|
fallbackSources: reference.fallbackSources.map((source) =>
|
|
select(source, slot.name),
|
|
),
|
|
}),
|
|
};
|
|
});
|
|
};
|
|
|
|
const referencedValues = (
|
|
node: t.Node,
|
|
path: PropertyPath,
|
|
effect: ValueEffect,
|
|
state: JavaScriptSemanticAnalysisState,
|
|
): readonly ReferencedValue[] => {
|
|
if (effect === "escape" && t.isFunction(node))
|
|
return semanticCallableResultExpressions(node).flatMap(
|
|
({ node, delegated }) =>
|
|
node === null ? [] : [{ node, path: delegated ? [null] : [] }],
|
|
);
|
|
// An initializer owns its slots; only deeper writes can affect shared children.
|
|
if (t.isObjectExpression(node) || t.isArrayExpression(node)) {
|
|
if (effect === "write" && path.length < 2) return [];
|
|
return t.isObjectExpression(node)
|
|
? semanticObjectPropertyReferences(node, path, state)
|
|
: arrayReferencedValues(node, path);
|
|
}
|
|
if (t.isClass(node)) {
|
|
const [key, ...remaining] = path;
|
|
if (key === undefined) return [];
|
|
// Replacing or deleting the getter's property does not use the value it
|
|
// would return. Only a deeper path can mutate the returned object.
|
|
if (effect === "write" && remaining.length === 0) return [];
|
|
return semanticClassPropertyReferences(
|
|
{ owner: node, kind: "static" },
|
|
[key, ...remaining],
|
|
state,
|
|
);
|
|
}
|
|
if (t.isNewExpression(node)) {
|
|
const [key, ...remaining] = path;
|
|
if (key === undefined) return [];
|
|
if (effect === "write" && remaining.length === 0) return [];
|
|
return semanticClassCandidates(node.callee, state).flatMap((klass) =>
|
|
semanticClassPropertyReferences(
|
|
{ owner: klass, kind: "instance" },
|
|
[key, ...remaining],
|
|
state,
|
|
),
|
|
);
|
|
}
|
|
if (t.isAssignmentExpression(node))
|
|
return node.operator === "||=" || node.operator === "??="
|
|
? [
|
|
{ node: node.left, path },
|
|
{ node: node.right, path },
|
|
]
|
|
: [{ node: node.right, path }];
|
|
if (t.isAwaitExpression(node)) return [{ node: node.argument, path }];
|
|
if (t.isSequenceExpression(node)) {
|
|
const last = node.expressions.at(-1);
|
|
return last === undefined ? [] : [{ node: last, path }];
|
|
}
|
|
if (t.isConditionalExpression(node))
|
|
return [
|
|
{ node: node.consequent, path },
|
|
{ node: node.alternate, path },
|
|
];
|
|
if (t.isLogicalExpression(node))
|
|
return [
|
|
{ node: node.left, path },
|
|
{ node: node.right, path },
|
|
];
|
|
return [];
|
|
};
|
|
|
|
type MutationObservableOrigin =
|
|
| t.ObjectExpression
|
|
| t.ArrayExpression
|
|
| t.ClassDeclaration
|
|
| t.ClassExpression
|
|
| t.NewExpression
|
|
| "ambient"
|
|
| "array"
|
|
| "array-prototype"
|
|
| "opaque";
|
|
|
|
interface MutationObserverNode {
|
|
origins?: Set<MutationObservableOrigin>;
|
|
sources?: Set<MutationObserverNode>;
|
|
dependents?: Set<MutationObserverNode>;
|
|
projections?: Map<string, MutationObserverNode>;
|
|
readonly read?: {
|
|
readonly source: MutationObserverNode;
|
|
readonly key: PropertyPath[number];
|
|
};
|
|
}
|
|
|
|
const observableMutationPaths = (
|
|
state: JavaScriptSemanticAnalysisState,
|
|
): {
|
|
readonly normalize: (
|
|
bindingId: string,
|
|
path: PropertyPath,
|
|
effect: ValueEffect,
|
|
) => PropertyPath | null;
|
|
readonly repeatsUnpruned: (
|
|
bindingId: string,
|
|
prefix: PropertyPath,
|
|
) => boolean;
|
|
} => {
|
|
const bindingNodes = new Map<string, MutationObserverNode>();
|
|
const expressions = new WeakMap<t.Node, MutationObserverNode>();
|
|
const pendingExpressions: {
|
|
readonly expression: t.Node;
|
|
readonly observer: MutationObserverNode;
|
|
}[] = [];
|
|
const pending: MutationObserverNode[] = [];
|
|
const queued = new Set<MutationObserverNode>();
|
|
const schedule = (node: MutationObserverNode): void => {
|
|
if (queued.has(node)) return;
|
|
queued.add(node);
|
|
pending.push(node);
|
|
};
|
|
const create = (read?: MutationObserverNode["read"]): MutationObserverNode =>
|
|
// Most bindings have no object origin or property projection. Materialize
|
|
// each collection only when it contains a fact or relationship.
|
|
read === undefined ? {} : { read };
|
|
const link = (
|
|
source: MutationObserverNode,
|
|
target: MutationObserverNode,
|
|
): void => {
|
|
if (target.sources?.has(source) === true) return;
|
|
(target.sources ??= new Set()).add(source);
|
|
(source.dependents ??= new Set()).add(target);
|
|
schedule(target);
|
|
};
|
|
const expressionNode = (node: t.Node): MutationObserverNode => {
|
|
const expression = unwrapJavaScriptExpression(node).node;
|
|
const existing = expressions.get(expression);
|
|
if (existing !== undefined) return existing;
|
|
const observer = create();
|
|
expressions.set(expression, observer);
|
|
pendingExpressions.push({ expression, observer });
|
|
return observer;
|
|
};
|
|
const project = (
|
|
source: MutationObserverNode,
|
|
path: PropertyPath,
|
|
): MutationObserverNode => {
|
|
let observer = source;
|
|
for (const key of path) {
|
|
const identity = JSON.stringify(
|
|
key === null || typeof key === "object" ? key : String(key),
|
|
);
|
|
let selected = observer.projections?.get(identity);
|
|
if (selected === undefined) {
|
|
selected = create({ source: observer, key });
|
|
(observer.projections ??= new Map()).set(identity, selected);
|
|
(observer.dependents ??= new Set()).add(selected);
|
|
schedule(selected);
|
|
}
|
|
observer = selected;
|
|
}
|
|
return observer;
|
|
};
|
|
for (const binding of state.bindingsById.values())
|
|
bindingNodes.set(binding.bindingId, create());
|
|
for (const binding of state.bindingsById.values()) {
|
|
const observer = bindingNodes.get(binding.bindingId);
|
|
if (observer === undefined) continue;
|
|
for (const initializer of binding.initializers) {
|
|
link(
|
|
project(expressionNode(initializer.node), initializer.projection),
|
|
observer,
|
|
);
|
|
}
|
|
// Preserve exact paths where copies, defaults or iteration need their
|
|
// reference-specific projections; an opaque origin prevents pruning.
|
|
if (binding.referenceInitializers.length > 0)
|
|
(observer.origins ??= new Set()).add("opaque");
|
|
for (const initializer of binding.referenceInitializers) {
|
|
for (const source of [
|
|
initializer,
|
|
...(initializer.requiredSources ?? []),
|
|
...(initializer.fallbackSources ?? []),
|
|
])
|
|
link(expressionNode(source.node), observer);
|
|
}
|
|
}
|
|
const settle = (): void => {
|
|
while (pendingExpressions.length > 0 || pending.length > 0) {
|
|
while (pendingExpressions.length > 0) {
|
|
const selected = pendingExpressions.pop();
|
|
if (selected === undefined) break;
|
|
const { expression, observer } = selected;
|
|
// Keep class/getter projections distinct so cycle pruning does not
|
|
// discard observable escapes through a shared getter result.
|
|
if (
|
|
t.isObjectExpression(expression) ||
|
|
t.isArrayExpression(expression) ||
|
|
t.isClass(expression) ||
|
|
t.isNewExpression(expression)
|
|
)
|
|
(observer.origins ??= new Set()).add(expression);
|
|
else if (t.isIdentifier(expression)) {
|
|
const binding = resolveSemanticBindingState(
|
|
state,
|
|
expression,
|
|
expression.name,
|
|
);
|
|
if (binding !== undefined) {
|
|
const origin = bindingNodes.get(binding.bindingId);
|
|
if (origin !== undefined) link(origin, observer);
|
|
} else if (expression.name === "Array")
|
|
(observer.origins ??= new Set()).add("array");
|
|
else if (
|
|
["globalThis", "global", "window", "self"].includes(expression.name)
|
|
)
|
|
(observer.origins ??= new Set()).add("ambient");
|
|
} else if (
|
|
t.isMemberExpression(expression) ||
|
|
t.isOptionalMemberExpression(expression)
|
|
)
|
|
link(
|
|
project(expressionNode(expression.object), [
|
|
semanticStaticPropertyKey(
|
|
expression.property,
|
|
expression.computed,
|
|
),
|
|
]),
|
|
observer,
|
|
);
|
|
else
|
|
for (const reference of referencedValues(
|
|
expression,
|
|
[],
|
|
"escape",
|
|
state,
|
|
))
|
|
link(
|
|
project(expressionNode(reference.node), reference.path),
|
|
observer,
|
|
);
|
|
schedule(observer);
|
|
}
|
|
const observer = pending.pop();
|
|
if (observer === undefined) continue;
|
|
queued.delete(observer);
|
|
let changed = false;
|
|
const add = (origin: MutationObservableOrigin): void => {
|
|
if (observer.origins?.has(origin) === true) return;
|
|
(observer.origins ??= new Set()).add(origin);
|
|
changed = true;
|
|
};
|
|
for (const source of observer.sources ?? [])
|
|
for (const origin of source.origins ?? []) add(origin);
|
|
if (observer.read !== undefined) {
|
|
const { source, key } = observer.read;
|
|
if (key === null || typeof key === "object") {
|
|
// Dynamic keys and copied-slot masks retain their original paths.
|
|
// Enumerating their exclusion combinations would recreate the growth
|
|
// that the mutation collector's selection union already prevents.
|
|
if ((source.origins?.size ?? 0) > 0) add("opaque");
|
|
} else
|
|
for (const origin of source.origins ?? []) {
|
|
if (typeof origin === "string") {
|
|
if (origin === "array-prototype" || origin === "opaque")
|
|
add(origin);
|
|
else if (
|
|
origin === "ambient" &&
|
|
semanticPropertyPathKeyMatches(key, "Array")
|
|
)
|
|
add("array");
|
|
else if (
|
|
origin === "array" &&
|
|
semanticPropertyPathKeyMatches(key, "prototype")
|
|
)
|
|
add("array-prototype");
|
|
} else {
|
|
for (const reference of referencedValues(
|
|
origin,
|
|
[key],
|
|
"escape",
|
|
state,
|
|
)) {
|
|
link(
|
|
project(expressionNode(reference.node), reference.path),
|
|
observer,
|
|
);
|
|
if (reference.iterableFallbackPath !== undefined)
|
|
link(
|
|
project(
|
|
expressionNode(reference.node),
|
|
reference.iterableFallbackPath,
|
|
),
|
|
observer,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (changed)
|
|
for (const dependent of observer.dependents ?? []) schedule(dependent);
|
|
}
|
|
};
|
|
settle();
|
|
const normalize = (
|
|
bindingId: string,
|
|
path: PropertyPath,
|
|
effect: ValueEffect,
|
|
): PropertyPath | null => {
|
|
let observer = bindingNodes.get(bindingId);
|
|
if (observer === undefined) return path;
|
|
if (path.some((key) => key !== null && typeof key === "object"))
|
|
return path;
|
|
if (effect === "escape") {
|
|
observer = project(observer, path);
|
|
settle();
|
|
return (observer.origins?.size ?? 0) === 0 ? null : path;
|
|
}
|
|
if ((observer.origins?.size ?? 0) === 0)
|
|
return path.length === 0 ? path : [null];
|
|
for (let offset = 0; offset < path.length; offset++) {
|
|
const key = path[offset];
|
|
if (key === undefined) break;
|
|
observer = project(observer, [key]);
|
|
settle();
|
|
if ((observer.origins?.size ?? 0) === 0 && offset + 1 < path.length)
|
|
return [...path.slice(0, offset + 1), null];
|
|
}
|
|
return path;
|
|
};
|
|
// A projection's origins depend only on its source origins and key. Once
|
|
// repeating a prefix revisits an origin set, every longer repetition stays
|
|
// observable and normalization can never prune it. The answer depends only
|
|
// on the binding and prefix, so every revisit of a long chain shares it.
|
|
const repeated = new Map<string, boolean>();
|
|
const repeatsUnpruned = (
|
|
bindingId: string,
|
|
prefix: PropertyPath,
|
|
): boolean => {
|
|
const key = JSON.stringify([bindingId, prefix]);
|
|
const cached = repeated.get(key);
|
|
if (cached !== undefined) return cached;
|
|
let observer = bindingNodes.get(bindingId);
|
|
const signatures = new Set<string>();
|
|
let result = false;
|
|
while (observer !== undefined && prefix.length > 0) {
|
|
observer = project(observer, prefix);
|
|
settle();
|
|
if ((observer.origins?.size ?? 0) === 0) break;
|
|
const signature = JSON.stringify(
|
|
[...(observer.origins ?? [])]
|
|
.map((origin) =>
|
|
typeof origin === "string"
|
|
? origin
|
|
: `${String(origin.start)}:${String(origin.end)}`,
|
|
)
|
|
.sort(compareUnicodeCodePoints),
|
|
);
|
|
if (signatures.has(signature)) {
|
|
result = true;
|
|
break;
|
|
}
|
|
signatures.add(signature);
|
|
}
|
|
repeated.set(key, result);
|
|
return result;
|
|
};
|
|
return { normalize, repeatsUnpruned };
|
|
};
|
|
|
|
const arrayReferencedValues = (
|
|
node: t.ArrayExpression,
|
|
path: PropertyPath,
|
|
): readonly ReferencedValue[] => {
|
|
const [key, ...remaining] = path;
|
|
const selected = key ?? null;
|
|
const selectedIndex =
|
|
typeof selected === "object" ? null : semanticArrayIndex(selected);
|
|
if (typeof selected !== "object" && selectedIndex === null) return [];
|
|
const references: ReferencedValue[] = [];
|
|
let minimumIndex = 0;
|
|
let uncertainIndex = false;
|
|
for (const element of node.elements) {
|
|
if (t.isSpreadElement(element)) {
|
|
if (selectedIndex === null || selectedIndex >= minimumIndex) {
|
|
let sourceKey: PropertyPath[number] = null;
|
|
if (!uncertainIndex) {
|
|
if (selectedIndex !== null) sourceKey = selectedIndex - minimumIndex;
|
|
else if (selected !== null && typeof selected === "object")
|
|
sourceKey = {
|
|
excludedKeys: selected.excludedKeys.flatMap((name) => {
|
|
const index = semanticArrayIndex(name);
|
|
return index === null || index < minimumIndex
|
|
? []
|
|
: [String(index - minimumIndex)];
|
|
}),
|
|
startIndex: Math.max(
|
|
0,
|
|
(selected.startIndex ?? 0) - minimumIndex,
|
|
),
|
|
};
|
|
}
|
|
references.push({
|
|
node: element.argument,
|
|
path: [sourceKey, ...remaining],
|
|
iterableFallbackPath: [],
|
|
});
|
|
}
|
|
uncertainIndex = true;
|
|
continue;
|
|
}
|
|
if (
|
|
element !== null &&
|
|
(uncertainIndex
|
|
? selectedIndex === null || selectedIndex >= minimumIndex
|
|
: semanticPropertyPathKeyMatches(selected, String(minimumIndex)))
|
|
)
|
|
references.push({ node: element, path: remaining });
|
|
minimumIndex++;
|
|
}
|
|
return references;
|
|
};
|
|
|
|
const copiedReferencePath = (
|
|
initializer: JavaScriptSemanticBindingState["referenceInitializers"][number],
|
|
path: PropertyPath,
|
|
effect: ValueEffect,
|
|
): PropertyPath | null => {
|
|
const offset = initializer.copyProjectionOffset;
|
|
if (offset === undefined) return [...initializer.projection, ...path];
|
|
const copiedPath = [...initializer.projection.slice(offset + 1), ...path];
|
|
if (effect === "write" && copiedPath.length < 2) return null;
|
|
let [key, ...remaining] = copiedPath;
|
|
if (initializer.copyKind === "array-rest") {
|
|
const startIndex = initializer.copyStartIndex ?? 0;
|
|
if (key === undefined || key === null)
|
|
key = { excludedKeys: [], startIndex };
|
|
else if (typeof key === "object")
|
|
key = {
|
|
excludedKeys: key.excludedKeys.flatMap((name) => {
|
|
const index = semanticArrayIndex(name);
|
|
return index === null ? [] : [String(index + startIndex)];
|
|
}),
|
|
startIndex: (key.startIndex ?? 0) + startIndex,
|
|
};
|
|
else {
|
|
const index = semanticArrayIndex(key);
|
|
if (index === null) return null;
|
|
key = index + startIndex;
|
|
}
|
|
} else {
|
|
const excludedKeys = initializer.copyExcludedKeys ?? [];
|
|
if (key === undefined || key === null) key = { excludedKeys };
|
|
else if (typeof key === "object")
|
|
key = {
|
|
...key,
|
|
excludedKeys: [...new Set([...key.excludedKeys, ...excludedKeys])].sort(
|
|
compareUnicodeCodePoints,
|
|
),
|
|
};
|
|
else if (excludedKeys.includes(String(key))) return null;
|
|
}
|
|
return [...initializer.projection.slice(0, offset), key, ...remaining];
|
|
};
|