diff --git a/src/domain/javascript/javascriptMemberMutationCoverage.test.ts b/src/domain/javascript/javascriptMemberMutationCoverage.test.ts index 0f695da9ca..23399c7dce 100644 --- a/src/domain/javascript/javascriptMemberMutationCoverage.test.ts +++ b/src/domain/javascript/javascriptMemberMutationCoverage.test.ts @@ -271,3 +271,77 @@ it("preserves exact member writes through an externally supplied caught value", }), ]); }); + +const indexedMemberEffects = (source: string) => { + const parsed = parseJavaScriptSource(source); + if (parsed === null) throw new Error("Expected parsed source"); + const index = completeSemanticSteps( + indexJavaScriptBindingsWithReceiversSteps(parsed.program), + ); + completeSemanticSteps(index.indexMemberMutationFactsSteps()); + return [...index.bindings.bindingsById.values()]; +}; + +it("records a write reached through two alias routes once", () => { + // `c.y = 1` reaches `a.x` through `b` and through the later `c = a.x`. + const a = indexedMemberEffects(` + function f(a) { + var b = a.x; + var c = b; + c.y = 1; + c = a.x; + c.y = 2; + } + `).find(({ name }) => name === "a"); + expect( + a?.memberWrites.map(({ path, node, receiverIdentity }) => [ + path, + t.isAssignmentExpression(node) && t.isNumericLiteral(node.right) + ? node.right.value + : undefined, + receiverIdentity, + ]), + ).toEqual([ + [["x", "y"], 1, "possible"], + [["x", "y"], 2, "definite"], + ]); +}); + +it("keeps one record per stored fact for loop-carried register aliases", () => { + const identities = new WeakMap(); + let identityCount = 0; + const identity = (value: object | undefined) => { + if (value === undefined) return undefined; + if (!identities.has(value)) identities.set(value, identityCount++); + return identities.get(value); + }; + const bindings = indexedMemberEffects(` + function dispatch(a) { + var b, c; + for (b = a.target; b; ) { + c = b; + c.seen = 1; + if ((c = b.owner)) { b = c; continue; } + b = b.parentNode; + } + } + `); + const records = bindings.flatMap(({ memberWrites, escapedPaths }) => [ + memberWrites, + escapedPaths, + ]); + expect(records.some((effects) => effects.length > 1)).toBe(true); + for (const effects of records) { + const facts = effects.map((effect) => + JSON.stringify([ + effect.path, + identity(effect.node), + identity(effect.receiverAt), + effect.receiverIdentity, + effect.receiverInitializers?.map((source) => identity(source)), + effect.assignedSources?.map((source) => identity(source)), + ]), + ); + expect(new Set(facts).size).toBe(facts.length); + } +}); diff --git a/src/domain/javascript/javascriptSemanticMemberMutations.ts b/src/domain/javascript/javascriptSemanticMemberMutations.ts index aff9ca30d6..21737b1976 100644 --- a/src/domain/javascript/javascriptSemanticMemberMutations.ts +++ b/src/domain/javascript/javascriptSemanticMemberMutations.ts @@ -27,6 +27,7 @@ import { type JavaScriptBindingIndex, type JavaScriptSemanticBindingState, type JavaScriptSemanticAnalysisState, + type JavaScriptSemanticMemberEffect, } from "./javascriptSemanticState.js"; import { clearSemanticValueCaches, @@ -522,6 +523,53 @@ export function* collectSemanticMemberMutationsSteps( effects.push({ path: effectPath, node, kind: effect }); state.requiredModuleEffects.set(identity.specifier, effects); }; + // Traversal states that differ only in coverage fields a record never + // stores, such as the origin, owner initializer or getter widening, reach the + // same binding fact. Each later receiver query scans every record, so retain + // one record per fact. Nodes and initializers compare by identity. + const objectIdentities = new WeakMap(); + let objectIdentityCount = 0; + const objectIdentity = (value: object | undefined): number | undefined => { + if (value === undefined) return undefined; + let identity = objectIdentities.get(value); + if (identity === undefined) { + identity = objectIdentityCount++; + objectIdentities.set(value, identity); + } + return identity; + }; + const recordedBindingEffects = new WeakMap< + JavaScriptSemanticMemberEffect[], + Set + >(); + const recordBindingEffect = ( + records: JavaScriptSemanticMemberEffect[], + effect: JavaScriptSemanticMemberEffect, + ): void => { + const fields: { + readonly [Field in keyof JavaScriptSemanticMemberEffect]-?: unknown; + } = { + path: effect.path, + node: objectIdentity(effect.node), + assignedSources: effect.assignedSources?.map((source) => + objectIdentity(source), + ), + receiverInitializers: effect.receiverInitializers?.map((initializer) => + objectIdentity(initializer), + ), + receiverIdentity: effect.receiverIdentity, + receiverAt: objectIdentity(effect.receiverAt), + }; + const key = JSON.stringify(fields); + let recorded = recordedBindingEffects.get(records); + if (recorded === undefined) { + recorded = new Set(); + recordedBindingEffects.set(records, recorded); + } + if (recorded.has(key)) return; + recorded.add(key); + records.push(effect); + }; // Ambient indexing owns aliases of global objects. Consume its canonical // writes instead of reparsing qualified or aliased process assignments. for (const { path, node } of state.ambientWriteEffects) @@ -1140,7 +1188,7 @@ export function* collectSemanticMemberMutationsSteps( // Initializer origins retain the normalized mutation traversal below. retainsInitializers = receiver?.retainsOrigin !== false; if (effect === "escape") - binding.escapedPaths.push({ + recordBindingEffect(binding.escapedPaths, { path, node: mutation, receiverInitializers, @@ -1149,7 +1197,7 @@ export function* collectSemanticMemberMutationsSteps( : { receiverAt: current.memberReadAt ?? current.originAt }), }); else - binding.memberWrites.push({ + recordBindingEffect(binding.memberWrites, { receiverIdentity: definiteReceiver ? "definite" : "possible", path, node: mutation, diff --git a/src/domain/javascript/javascriptSemanticMutationInitializers.ts b/src/domain/javascript/javascriptSemanticMutationInitializers.ts index 2276d8a1ba..6a062f3681 100644 --- a/src/domain/javascript/javascriptSemanticMutationInitializers.ts +++ b/src/domain/javascript/javascriptSemanticMutationInitializers.ts @@ -51,6 +51,7 @@ interface ExpressionAncestry { */ interface InitializerPositionCache { readonly direct: WeakMap; + readonly topLevel: WeakMap; readonly mutation: WeakMap; readonly reference: WeakMap; readonly read: WeakMap; @@ -75,6 +76,7 @@ const initializerPositionCache = ( if (cached !== undefined) return cached; const created: InitializerPositionCache = { direct: new WeakMap(), + topLevel: new WeakMap(), mutation: new WeakMap(), reference: new WeakMap(), read: new WeakMap(), @@ -122,37 +124,57 @@ const positionPrecedes = ( left.declaratorIndex >= 0 && left.declaratorIndex < right.declaratorIndex)); +/** Statement position of a node evaluated once by the program, outside loops and functions. */ +const computeTopLevelStatementPosition = ( + node: t.Node | undefined, + parents: WeakMap, +): StatementPosition | null => { + let selected: StatementPosition | null = null; + let declarator: t.VariableDeclarator | undefined; + let child: t.Node | undefined; + while (node !== undefined) { + if ( + (t.isFunction(node) && + (child === undefined || !semanticComputedMethodKey(node, child))) || + t.isLoop(node) + ) + return null; + if (delayedInstanceFieldValue(node, child)) return null; + if (t.isVariableDeclarator(node)) declarator = node; + if (t.isStatement(node)) { + const enclosing = statementPosition(node, parents, declarator); + if (enclosing !== null) selected = enclosing; + } + if (t.isProgram(node)) return selected; + child = node; + node = parents.get(node); + } + return null; +}; + +// Effect ordering asks for the same capture and effect positions once per +// candidate record, so a growing record set would repeat each ancestry walk. +const topLevelStatementPosition = ( + node: t.Node | undefined, + parents: WeakMap, +): StatementPosition | null => { + if (node === undefined) return null; + const cache = initializerPositionCache(parents); + const cached = cache.topLevel.get(node); + if (cached !== undefined) return cached; + const computed = computeTopLevelStatementPosition(node, parents); + cache.topLevel.set(node, computed); + return computed; +}; + /** Order effects only in a non-repeating top-level activation. */ export const semanticTopLevelEffectFollowsCapture = ( capture: t.Node, effect: t.Node | undefined, parents: WeakMap, ): boolean => { - const position = (node: t.Node | undefined): StatementPosition | null => { - let selected: StatementPosition | null = null; - let declarator: t.VariableDeclarator | undefined; - let child: t.Node | undefined; - while (node !== undefined) { - if ( - (t.isFunction(node) && - (child === undefined || !semanticComputedMethodKey(node, child))) || - t.isLoop(node) - ) - return null; - if (delayedInstanceFieldValue(node, child)) return null; - if (t.isVariableDeclarator(node)) declarator = node; - if (t.isStatement(node)) { - const enclosing = statementPosition(node, parents, declarator); - if (enclosing !== null) selected = enclosing; - } - if (t.isProgram(node)) return selected; - child = node; - node = parents.get(node); - } - return null; - }; - const before = position(capture); - const after = position(effect); + const before = topLevelStatementPosition(capture, parents); + const after = topLevelStatementPosition(effect, parents); if (before === null || after === null) return false; if (positionPrecedes(before, after)) return true; if ( diff --git a/src/domain/javascript/javascriptSemanticProjectedSources.ts b/src/domain/javascript/javascriptSemanticProjectedSources.ts index f1cb5f9c73..79fb463a71 100644 --- a/src/domain/javascript/javascriptSemanticProjectedSources.ts +++ b/src/domain/javascript/javascriptSemanticProjectedSources.ts @@ -296,20 +296,11 @@ const projectedSourceFactsAtRead = ( ) ); }); + // Every condition is a pure query. Test the selected path first: it is the + // cheapest and rejects most records of a binding with many member paths. const matches = ( effect: JavaScriptSemanticBindingState["memberWrites"][number], ) => - (effect.receiverInitializers === undefined || - effect.receiverInitializers.length === 0 || - captured.length === 0 || - effect.receiverInitializers.some((receiver) => - captured.some( - (source) => - source.node === receiver.node && - JSON.stringify(source.projection) === - JSON.stringify(receiver.projection), - ), - )) && path.length > 0 && effect.path.length <= path.length && effect.path.every((key, index) => { @@ -324,6 +315,17 @@ const projectedSourceFactsAtRead = ( ) ); }) && + (effect.receiverInitializers === undefined || + effect.receiverInitializers.length === 0 || + captured.length === 0 || + effect.receiverInitializers.some((receiver) => + captured.some( + (source) => + source.node === receiver.node && + JSON.stringify(source.projection) === + JSON.stringify(receiver.projection), + ), + )) && semanticEffectTiming(selectedAt(effect.path.length), effect.node, state) !== "after" && !targetsReplacedPrefix(effect);