From a47dcca010c24ff5020361ba7148f9862151394a Mon Sep 17 00:00:00 2001 From: Kaoru0822 Date: Fri, 30 Oct 2026 02:26:01 -0700 Subject: [PATCH] perf(javascript): keep one member-effect record per stored fact (#8103) Member-mutation indexing appended a binding write or escape record for every traversal state that reached it. States that differ only in coverage fields the record never stores (origin, owner initializer, getter widening) produced identical records, and each later receiver query rescanned all of them. Record each stored fact once, alongside the existing required-module effect deduplication. Propagation is unchanged. Effect ordering also re-walked the same top-level statement ancestry for every candidate record. Cache it with the other initializer positions, and test the selected path before the costlier receiver comparison. On the #7774 reproducer the semantic IR and the complete analyze-javascript-application Evidence are byte-identical, while the CLI run drops from 146 s to 40 s. Refs #7774 Co-authored-by: Claude Opus 5.5 --- .../javascriptMemberMutationCoverage.test.ts | 74 +++++++++++++++++++ .../javascriptSemanticMemberMutations.ts | 52 ++++++++++++- .../javascriptSemanticMutationInitializers.ts | 72 +++++++++++------- .../javascriptSemanticProjectedSources.ts | 24 +++--- 4 files changed, 184 insertions(+), 38 deletions(-) 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);