diff --git a/src/domain/javascript/javascriptSemanticAnalysis.calls.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.calls.test.ts index 217480175f..536281b30d 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.calls.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.calls.test.ts @@ -158,6 +158,121 @@ describe("JavaScript semantic analysis: calls 1", () => { }); }); +describe("JavaScript semantic analysis: computed method keys", () => { + it("evaluates computed method keys in the enclosing callable", () => { + const ir = analyzeJavaScriptSemantics(` + function outer() { + const name = "m"; + const pick = () => "n"; + const obj = { [name]() { return 1; }, [pick()]() { return name; } }; + class C { [name]() { return 2; } } + } + `); + + const outer = onlyCallable(ir, "outer"); + const pickCall = ir.callSites.find( + ({ calleeCallableIds }) => calleeCallableIds.length === 1, + ); + expect(pickCall).toMatchObject({ + callerCallableId: outer.callableId, + resolution: "exact", + }); + // Only the method body that reads `name` captures it; the computed keys + // are evaluated outside every method environment. + expect( + ir.closureCaptures.map(({ callableId, bindingId }) => ({ + owner: ir.callables.find( + (callable) => callable.callableId === callableId, + )?.kind, + binding: bindingById(ir, bindingId)?.name, + })), + ).toEqual([{ owner: "method", binding: "name" }]); + }); + + it("keeps callables nested in a computed method key in their own frames", () => { + const ir = analyzeJavaScriptSemantics(` + function outer() { + const name = "m"; + const pick = () => "n"; + return { [(() => pick())()]() { return name; } }; + } + `); + + const outer = onlyCallable(ir, "outer"); + const pick = onlyCallable(ir, "pick"); + const keyArrow = ir.callables.find( + ({ kind, name }) => kind === "function" && name === null, + ); + const method = ir.callables.find(({ kind }) => kind === "method"); + expect(keyArrow).toBeDefined(); + expect(method).toBeDefined(); + const callerOf = (calleeCallableId: string | undefined) => + ir.callSites.find(({ calleeCallableIds }) => + calleeCallableIds.includes(calleeCallableId ?? ""), + )?.callerCallableId; + expect(callerOf(pick.callableId)).toBe(keyArrow?.callableId); + expect(callerOf(keyArrow?.callableId)).toBe(outer.callableId); + expect( + ir.closureCaptures.map(({ callableId, bindingId }) => ({ + callableId, + binding: bindingById(ir, bindingId)?.name, + })), + ).toEqual( + expect.arrayContaining([ + { callableId: keyArrow?.callableId, binding: "pick" }, + { callableId: method?.callableId, binding: "name" }, + ]), + ); + }); + + it("attributes effects in computed method keys to the enclosing callable", () => { + const ir = analyzeJavaScriptSemantics(` + import { spawn } from "node:child_process"; + function outer(config) { + return { + [fetch("/x")]() {}, + [setTimeout(tick, 1)]() {}, + [Promise.resolve(1)]() {}, + [spawn("ls")]() {}, + [config.key]() {}, + }; + } + class C { [fetch("/y")]() {} } + `); + + const outer = onlyCallable(ir, "outer"); + const owners = ( + operations: readonly { readonly ownerCallableId: string | null }[], + ) => operations.map(({ ownerCallableId }) => ownerCallableId); + const methodIds = new Set( + ir.callables + .filter(({ kind }) => kind === "method") + .map(({ callableId }) => callableId), + ); + expect(methodIds.size).toBe(6); + const classId = onlyCallable(ir, "C").callableId; + expect(owners(ir.requestOperations).sort()).toEqual( + [outer.callableId, classId].sort(), + ); + expect(owners(ir.timerOperations)).toEqual([outer.callableId]); + expect(owners(ir.promiseOperations)).toEqual([outer.callableId]); + expect(owners(ir.childProcessSpawns)).toEqual([outer.callableId]); + expect( + ir.objectOperations.filter(({ propertyPath }) => + propertyPath?.includes("key"), + ), + ).toEqual([expect.objectContaining({ ownerCallableId: outer.callableId })]); + for (const owner of [ + ...owners(ir.requestOperations), + ...owners(ir.timerOperations), + ...owners(ir.promiseOperations), + ...owners(ir.childProcessSpawns), + ...owners(ir.objectOperations), + ]) + expect(methodIds.has(owner ?? "")).toBe(false); + }); +}); + describe("JavaScript semantic analysis: calls 2", () => { it("does not treat a shadowed Promise binding as the intrinsic", () => { const ir = analyzeJavaScriptSemantics(` diff --git a/src/domain/javascript/javascriptSemanticAsyncEffects.ts b/src/domain/javascript/javascriptSemanticAsyncEffects.ts index 6ab62bd598..a34bfb7500 100644 --- a/src/domain/javascript/javascriptSemanticAsyncEffects.ts +++ b/src/domain/javascript/javascriptSemanticAsyncEffects.ts @@ -5,7 +5,7 @@ import type { JavaScriptSemanticEventOperation, JavaScriptSemanticTimerOperation, } from "./javascriptSemanticIr.js"; -import { semanticCallableIdForNode } from "./javascriptSemanticProjection.js"; +import { createSemanticCallableIdStack } from "./javascriptSemanticCallableStack.js"; import { semanticStaticPropertyName } from "./javascriptAstValues.js"; import { resolveSemanticBindingState, @@ -94,15 +94,13 @@ export const collectJavaScriptSemanticAsyncEffects = ( const events: JavaScriptSemanticEventOperation[] = []; const timers: TimerCandidate[] = []; const callableIds = new Set(callables.map(({ callableId }) => callableId)); - const callableStack: string[] = []; + const callableStack = createSemanticCallableIdStack(callableIds); const ancestors: t.Node[] = []; traverseJavaScriptAst(program, { - enter: (node) => { - const callableId = semanticCallableIdForNode(node); - if (callableId !== null && callableIds.has(callableId)) - callableStack.push(callableId); + enter: (node, parent) => { + callableStack.enter(node, parent); if (t.isCallExpression(node) || t.isOptionalCallExpression(node)) { - const ownerCallableId = callableStack.at(-1) ?? null; + const ownerCallableId = callableStack.callables.at(-1) ?? null; collectEvent(node, ownerCallableId, state, events); collectTimer({ node, @@ -115,10 +113,8 @@ export const collectJavaScriptSemanticAsyncEffects = ( ancestors.push(node); }, exit: (node) => { - const callableId = semanticCallableIdForNode(node); ancestors.pop(); - if (callableId !== null && callableStack.at(-1) === callableId) - callableStack.pop(); + callableStack.exit(node); }, }); return { diff --git a/src/domain/javascript/javascriptSemanticCallableStack.ts b/src/domain/javascript/javascriptSemanticCallableStack.ts new file mode 100644 index 0000000000..0718d4aadd --- /dev/null +++ b/src/domain/javascript/javascriptSemanticCallableStack.ts @@ -0,0 +1,85 @@ +import * as t from "@babel/types"; + +import { semanticCallableIdForNode } from "./javascriptSemanticProjection.js"; + +/** + * Innermost-last stack of admitted callables during one enter/exit traversal. + * + * Single owner for semantic callable ownership. A computed object/class method + * key is evaluated before the method's parameter and body environments, so the + * method is set aside while its key is traversed and the enclosing callable + * owns every fact recovered from the key. Collectors share this tracker so + * calls, captures, effects, promises, and object operations agree. + */ +export interface SemanticCallableStack { + /** Admitted callables, outermost first; the innermost owner is last. */ + readonly callables: readonly Callable[]; + readonly enter: (node: t.Node, parent: t.Node | null) => void; + readonly exit: (node: t.Node) => void; +} + +interface CallableFrame { + readonly callableId: string; + readonly callable: Callable; +} + +/** + * Track callables admitted by `admit`, which maps a callable ID to the value + * kept on the stack or `undefined` to ignore it. A caller-owned `callables` + * array is updated in place, so shared contexts can keep reading it. + */ +export const createSemanticCallableStack = ( + admit: (callableId: string) => Callable | undefined, + callables: Callable[] = [], +): SemanticCallableStack => { + const frames: CallableFrame[] = []; + const suspendedMethods = new Map>(); + callables.length = 0; + const push = (frame: CallableFrame): void => { + frames.push(frame); + callables.push(frame.callable); + }; + const pop = (): CallableFrame | undefined => { + callables.pop(); + return frames.pop(); + }; + return { + callables, + enter: (node, parent) => { + if ( + parent !== null && + (t.isObjectMethod(parent) || t.isClassMethod(parent)) && + parent.computed && + parent.key === node && + frames.at(-1)?.callableId === semanticCallableIdForNode(parent) + ) { + const method = pop(); + if (method !== undefined) suspendedMethods.set(node, method); + } + const callableId = semanticCallableIdForNode(node); + if (callableId === null) return; + const callable = admit(callableId); + if (callable !== undefined) push({ callableId, callable }); + }, + exit: (node) => { + const callableId = semanticCallableIdForNode(node); + if (callableId !== null && frames.at(-1)?.callableId === callableId) + pop(); + const method = suspendedMethods.get(node); + if (method !== undefined) { + suspendedMethods.delete(node); + push(method); + } + }, + }; +}; + +/** Track admitted callable IDs, keeping each ID as its stack value. */ +export const createSemanticCallableIdStack = ( + admitted: ReadonlySet, + callables: string[] = [], +): SemanticCallableStack => + createSemanticCallableStack( + (callableId) => (admitted.has(callableId) ? callableId : undefined), + callables, + ); diff --git a/src/domain/javascript/javascriptSemanticCalls.ts b/src/domain/javascript/javascriptSemanticCalls.ts index b07715af89..fc0816635c 100644 --- a/src/domain/javascript/javascriptSemanticCalls.ts +++ b/src/domain/javascript/javascriptSemanticCalls.ts @@ -9,10 +9,8 @@ import type { JavaScriptSemanticClosureCapture, JavaScriptSemanticFrontier, } from "./javascriptSemanticIr.js"; -import { - semanticCallableIdForNode, - semanticReferenceRole, -} from "./javascriptSemanticProjection.js"; +import { semanticReferenceRole } from "./javascriptSemanticProjection.js"; +import { createSemanticCallableStack } from "./javascriptSemanticCallableStack.js"; import { semanticStaticPropertyKey } from "./javascriptAstValues.js"; import { resolveSemanticBindingState, @@ -58,7 +56,6 @@ interface CallCollectionContext { readonly JavaScriptSemanticBindingState[] >; readonly ancestors: t.Node[]; - readonly callableStack: JavaScriptSemanticCallable[]; } /** Recover direct-call and lexical-capture candidates from inert syntax. */ @@ -78,7 +75,6 @@ export const collectJavaScriptSemanticCalls = ( const callableById = new Map( callables.map((callable) => [callable.callableId, callable]), ); - const callableStack: JavaScriptSemanticCallable[] = []; const context: CallCollectionContext = { state, callableById, @@ -86,15 +82,14 @@ export const collectJavaScriptSemanticCalls = ( bindingResolutionCache: new Map(), parameterBindingCache: new Map(), ancestors: [], - callableStack, }; + const callableStack = createSemanticCallableStack((callableId) => + callableById.get(callableId), + ); traverseJavaScriptAst(program, { enter: (node, parent) => { - const callableId = semanticCallableIdForNode(node); - const callable = - callableId === null ? undefined : callableById.get(callableId); - if (callable !== undefined) callableStack.push(callable); - const owner = enclosingFunction(callableStack); + callableStack.enter(node, parent); + const owner = enclosingFunction(callableStack.callables); collectCapture(node, parent, owner, context); collectDynamicProperty(node, owner, state, output); collectDynamicScope(node, owner, state, output); @@ -108,12 +103,7 @@ export const collectJavaScriptSemanticCalls = ( }, exit: (node) => { context.ancestors.pop(); - const callableId = semanticCallableIdForNode(node); - if ( - callableId !== null && - callableStack.at(-1)?.callableId === callableId - ) - callableStack.pop(); + callableStack.exit(node); }, }); return { @@ -318,49 +308,19 @@ const collectCapture = ( ) return; if (semanticReferenceRole(node, parent) === null) return; - const captureOwner = computedMethodKeyOwner(node, owner, context); - if (captureOwner === undefined || captureOwner.bodyScopeId === null) return; const binding = resolveSemanticBindingState(state, node, node.name); if ( binding === undefined || - bindingIsWithinCallable(binding, captureOwner.bodyScopeId, state) + bindingIsWithinCallable(binding, owner.bodyScopeId, state) ) return; output.closureCaptures.push({ - callableId: captureOwner.callableId, + callableId: owner.callableId, bindingId: binding.bindingId, referenceLocation: range(node), }); }; -const computedMethodKeyOwner = ( - node: t.Node, - owner: JavaScriptSemanticCallable, - context: CallCollectionContext, -): JavaScriptSemanticCallable | undefined => { - const ownerNode = context.ancestors.findLast( - (candidate) => semanticCallableIdForNode(candidate) === owner.callableId, - ); - if ( - ownerNode === undefined || - (!t.isObjectMethod(ownerNode) && !t.isClassMethod(ownerNode)) || - !ownerNode.computed - ) - return owner; - let child = node; - let parent = context.state.parentsByNode.get(child); - while (parent !== undefined && parent !== ownerNode) { - child = parent; - parent = context.state.parentsByNode.get(parent); - } - if (parent !== ownerNode || ownerNode.key !== child) return owner; - return enclosingFunction( - context.callableStack.filter( - ({ callableId }) => callableId !== owner.callableId, - ), - ); -}; - const collectDynamicProperty = ( node: t.Node, owner: JavaScriptSemanticCallable | undefined, diff --git a/src/domain/javascript/javascriptSemanticChildProcesses.ts b/src/domain/javascript/javascriptSemanticChildProcesses.ts index c9aec78e79..45d15f5719 100644 --- a/src/domain/javascript/javascriptSemanticChildProcesses.ts +++ b/src/domain/javascript/javascriptSemanticChildProcesses.ts @@ -5,7 +5,7 @@ import type { JavaScriptSemanticChildProcessInteraction, JavaScriptSemanticChildProcessSpawn, } from "./javascriptSemanticIr.js"; -import { semanticCallableIdForNode } from "./javascriptSemanticProjection.js"; +import { createSemanticCallableIdStack } from "./javascriptSemanticCallableStack.js"; import { semanticStaticPropertyName, unwrapJavaScriptExpression, @@ -84,13 +84,11 @@ const collectSpawns = ( const admittedCallables = new Set( callables.map(({ callableId }) => callableId), ); - const callableStack: string[] = []; + const callableStack = createSemanticCallableIdStack(admittedCallables); const ancestors: t.Node[] = []; traverseJavaScriptAst(program, { - enter: (node) => { - const callableId = semanticCallableIdForNode(node); - if (callableId !== null && admittedCallables.has(callableId)) - callableStack.push(callableId); + enter: (node, parent) => { + callableStack.enter(node, parent); if (t.isCallExpression(node) || t.isOptionalCallExpression(node)) { const method = childProcessMethod(node.callee, state); if (method !== null) { @@ -102,7 +100,7 @@ const collectSpawns = ( output.push({ node, method, - ownerCallableId: callableStack.at(-1) ?? null, + ownerCallableId: callableStack.callables.at(-1) ?? null, resultBindingId: scalarAssignedResultBinding( projectedResultBindings, ), @@ -118,7 +116,7 @@ const collectSpawns = ( }, exit: (node) => { ancestors.pop(); - popCallable(node, callableStack); + callableStack.exit(node); }, }); return output; @@ -152,7 +150,7 @@ const collectInteractions = ( ): JavaScriptSemanticChildProcessInteraction[] => { const output: JavaScriptSemanticChildProcessInteraction[] = []; const callableIds = new Set(callables.map(({ callableId }) => callableId)); - const callableStack: string[] = []; + const callableStack = createSemanticCallableIdStack(callableIds); const spawnByNode = new WeakMap(); const spawnsByBinding = new Map(); for (const spawn of spawns) { @@ -173,21 +171,19 @@ const collectInteractions = ( spawnsByBinding, }; traverseJavaScriptAst(program, { - enter: (node) => { - const callableId = semanticCallableIdForNode(node); - if (callableId !== null && callableIds.has(callableId)) - callableStack.push(callableId); + enter: (node, parent) => { + callableStack.enter(node, parent); if (!t.isCallExpression(node) && !t.isOptionalCallExpression(node)) return; const interaction = childInteraction( node, - callableStack.at(-1) ?? null, + callableStack.callables.at(-1) ?? null, context, ); if (interaction === null) return; output.push(interaction); }, - exit: (node) => popCallable(node, callableStack), + exit: (node) => callableStack.exit(node), }); return output; }; @@ -359,10 +355,5 @@ const objectBinding = ( ? resolveSemanticBindingState(state, node, node.name) : undefined; -const popCallable = (node: t.Node, stack: string[]): void => { - const callableId = semanticCallableIdForNode(node); - if (callableId !== null && stack.at(-1) === callableId) stack.pop(); -}; - const spawnId = (candidate: SpawnCandidate): string => `child:spawn:${String(candidate.node.start ?? -1)}:${String(candidate.node.end ?? -1)}`; diff --git a/src/domain/javascript/javascriptSemanticDataEffectHelpers.ts b/src/domain/javascript/javascriptSemanticDataEffectHelpers.ts index 99ae170975..9112eacd16 100644 --- a/src/domain/javascript/javascriptSemanticDataEffectHelpers.ts +++ b/src/domain/javascript/javascriptSemanticDataEffectHelpers.ts @@ -20,6 +20,7 @@ import { import { semanticCallableIdForNode } from "./javascriptSemanticProjection.js"; import { semanticTopLevelEffectFollowsCapture } from "./javascriptSemanticMutationInitializers.js"; +import { createSemanticCallableIdStack } from "./javascriptSemanticCallableStack.js"; import { resolveSemanticBindingState, type JavaScriptSemanticAnalysisState, @@ -882,21 +883,20 @@ export const traverseDataEffects = ( context: DataEffectTraversalContext, visit: (node: t.Node, parent: t.Node | null) => void, ): void => { - context.callableStack.length = 0; + const callableStack = createSemanticCallableIdStack( + context.callableIds, + context.callableStack, + ); context.ancestors.length = 0; traverseJavaScriptAst(program, { enter: (node, parent) => { - const callableId = semanticCallableIdForNode(node); - if (callableId !== null && context.callableIds.has(callableId)) - context.callableStack.push(callableId); + callableStack.enter(node, parent); visit(node, parent); context.ancestors.push(node); }, exit: (node) => { context.ancestors.pop(); - const callableId = semanticCallableIdForNode(node); - if (callableId !== null && context.callableStack.at(-1) === callableId) - context.callableStack.pop(); + callableStack.exit(node); }, }); }; diff --git a/src/domain/javascript/javascriptSemanticObjects.ts b/src/domain/javascript/javascriptSemanticObjects.ts index 8c0b7af8b7..6e4dbb0a1e 100644 --- a/src/domain/javascript/javascriptSemanticObjects.ts +++ b/src/domain/javascript/javascriptSemanticObjects.ts @@ -4,7 +4,7 @@ import type { JavaScriptSemanticCallable, JavaScriptSemanticObjectOperation, } from "./javascriptSemanticIr.js"; -import { semanticCallableIdForNode } from "./javascriptSemanticProjection.js"; +import { createSemanticCallableIdStack } from "./javascriptSemanticCallableStack.js"; import { semanticStaticPropertyKey, semanticWriteSiteReadsFirst, @@ -27,7 +27,7 @@ export const collectJavaScriptSemanticObjects = ( ): JavaScriptSemanticObjectOperation[] => { const output: JavaScriptSemanticObjectOperation[] = []; const callableIds = new Set(callables.map(({ callableId }) => callableId)); - const callableStack: string[] = []; + const callableStack = createSemanticCallableIdStack(callableIds); const context: ObjectCollectionContext = { state, output, @@ -35,10 +35,8 @@ export const collectJavaScriptSemanticObjects = ( }; traverseJavaScriptAst(program, { enter: (node, parent) => { - const callableId = semanticCallableIdForNode(node); - if (callableId !== null && callableIds.has(callableId)) - callableStack.push(callableId); - const ownerCallableId = callableStack.at(-1) ?? null; + callableStack.enter(node, parent); + const ownerCallableId = callableStack.callables.at(-1) ?? null; const writeTarget = semanticWriteSiteTarget(node); if (writeTarget !== null) for (const leaf of semanticWriteTargets(writeTarget)) @@ -69,11 +67,7 @@ export const collectJavaScriptSemanticObjects = ( ) collectDestructuring(node, ownerCallableId, state, output); }, - exit: (node) => { - const callableId = semanticCallableIdForNode(node); - if (callableId !== null && callableStack.at(-1) === callableId) - callableStack.pop(); - }, + exit: (node) => callableStack.exit(node), }); return output; }; diff --git a/src/domain/javascript/javascriptSemanticPromises.ts b/src/domain/javascript/javascriptSemanticPromises.ts index 524d3d0ff3..062e593560 100644 --- a/src/domain/javascript/javascriptSemanticPromises.ts +++ b/src/domain/javascript/javascriptSemanticPromises.ts @@ -5,6 +5,7 @@ import type { JavaScriptSemanticPromiseOperation, } from "./javascriptSemanticIr.js"; import { semanticCallableIdForNode } from "./javascriptSemanticProjection.js"; +import { createSemanticCallableIdStack } from "./javascriptSemanticCallableStack.js"; import { semanticStaticPropertyName, unwrapJavaScriptExpression, @@ -106,20 +107,18 @@ const collectCandidates = ( const admittedCallables = new Set( callables.map(({ callableId }) => callableId), ); - const callableStack: string[] = []; + const callableStack = createSemanticCallableIdStack(admittedCallables); const ancestors: t.Node[] = []; traverseJavaScriptAst(program, { - enter: (node) => { - const callableId = semanticCallableIdForNode(node); - if (callableId !== null && admittedCallables.has(callableId)) - callableStack.push(callableId); + enter: (node, parent) => { + callableStack.enter(node, parent); const details = promiseCandidateDetails(node, state); if (details !== null) { output.push({ node, ...details, promiseId: semanticPromiseId(details.kind, node), - ownerCallableId: callableStack.at(-1) ?? null, + ownerCallableId: callableStack.callables.at(-1) ?? null, ancestors: [...ancestors], }); } @@ -127,9 +126,7 @@ const collectCandidates = ( }, exit: (node) => { ancestors.pop(); - const callableId = semanticCallableIdForNode(node); - if (callableId !== null && callableStack.at(-1) === callableId) - callableStack.pop(); + callableStack.exit(node); }, }); return output;