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 { const parents = state.parentsByNode; const observablePaths = observableMutationPaths(state); const returnedReferences = createSemanticReturnedReferences(state); const recordedEffects = new Set(); const selections = new Map(); const coveringPaths = new Map(); 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 >(); 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; readonly effect: ValueEffect; readonly mutation?: t.Node; readonly originAt: t.Node | undefined; }[] = []; const iterableReferences: IterableReference[] = []; const iterableReferenceKeys = new WeakMap>(); const deferIterable = (reference: IterableReference): void => { const keys = iterableReferenceKeys.get(reference.node) ?? new Set(); 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, effect: ValueEffect = "write", mutation?: t.Node, originAt: t.Node | undefined = mutation, ownerInitializer?: t.Node, ): void => { const pending: (ReferencedValue & { readonly bindings: ReadonlySet; 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 >(); 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(); const expanded = new WeakMap>(); 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(); 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; 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; sources?: Set; dependents?: Set; projections?: Map; 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(); const expressions = new WeakMap(); const pendingExpressions: { readonly expression: t.Node; readonly observer: MutationObserverNode; }[] = []; const pending: MutationObserverNode[] = []; const queued = new Set(); 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(); 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(); 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]; };