diff --git a/src/domain/javascript/javascriptSemanticAnalysis.calls.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.calls.test.ts index 536281b30d..794a53ffbd 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.calls.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.calls.test.ts @@ -165,7 +165,7 @@ describe("JavaScript semantic analysis: computed method keys", () => { const name = "m"; const pick = () => "n"; const obj = { [name]() { return 1; }, [pick()]() { return name; } }; - class C { [name]() { return 2; } } + class C { [name]() { return 2; } [pick()]() { return 3; } } } `); @@ -177,6 +177,14 @@ describe("JavaScript semantic analysis: computed method keys", () => { callerCallableId: outer.callableId, resolution: "exact", }); + expect( + ir.callSites.filter( + ({ callerCallableId, calleeCallableIds, resolution }) => + callerCallableId === outer.callableId && + calleeCallableIds.length === 1 && + resolution === "exact", + ), + ).toHaveLength(2); // Only the method body that reads `name` captures it; the computed keys // are evaluated outside every method environment. expect( @@ -189,6 +197,23 @@ describe("JavaScript semantic analysis: computed method keys", () => { ).toEqual([{ owner: "method", binding: "name" }]); }); + it("keeps ordinary nested method-body reads uncertain", () => { + const ir = analyzeJavaScriptSemantics(` + function outer() { + const pick = () => "n"; + return { method() { return pick(); } }; + } + `); + const pick = onlyCallable(ir, "pick"); + const pickCall = ir.callSites.find(({ calleeCallableIds }) => + calleeCallableIds.includes(pick.callableId), + ); + expect(pickCall).toMatchObject({ + resolution: "ambiguous", + calleeCallableIds: [pick.callableId], + }); + }); + it("keeps callables nested in a computed method key in their own frames", () => { const ir = analyzeJavaScriptSemantics(` function outer() { diff --git a/src/domain/javascript/javascriptSemanticAnalysis.ts b/src/domain/javascript/javascriptSemanticAnalysis.ts index 239c0a79a6..e569948e43 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.ts @@ -7,6 +7,7 @@ import { import * as t from "@babel/types"; import { + semanticComputedMethodKey, semanticBranchMayBeSkipped, semanticIterationCardinality, } from "./javascriptSemanticEvaluationOrder.js"; @@ -1029,10 +1030,7 @@ function* collectDefinitionsSteps( needsPrototypeFacts = true; let parentScope = currentSemanticScope(stack); if ( - parent !== null && - (t.isObjectMethod(parent) || t.isClassMethod(parent)) && - parent.computed && - parent.key === node && + semanticComputedMethodKey(parent ?? undefined, node) && parentScope.parentScopeId !== null ) { // A computed method key is evaluated before entering the method's diff --git a/src/domain/javascript/javascriptSemanticCallResolution.test.ts b/src/domain/javascript/javascriptSemanticCallResolution.test.ts index 54e1dc0ef7..f24fc89c1d 100644 --- a/src/domain/javascript/javascriptSemanticCallResolution.test.ts +++ b/src/domain/javascript/javascriptSemanticCallResolution.test.ts @@ -137,6 +137,123 @@ it("does not reuse a later resolved callback at an earlier read", () => { expect(context.resolve(after)).toMatchObject({ complete: true }); }); +it("keeps a nested callback read uncertain across a later var initializer", () => { + const context = indexed( + `function target(){} function read(){cb();} read(); var cb=target;`, + ); + const target = context.state.callables.find( + (callable) => callable.name === "target", + ); + let read: t.Identifier | undefined; + traverseJavaScriptAst(context.state.program, { + enter(node) { + if ( + t.isCallExpression(node) && + t.isIdentifier(node.callee, { name: "cb" }) + ) + read = node.callee; + }, + }); + if (target === undefined || read === undefined) + throw new Error("Missing target or callback read"); + expect(context.resolve(read)).toMatchObject({ + callableIds: [target.callableId], + complete: false, + }); +}); + +it("keeps same-activation calls after a var initializer resolved", () => { + const context = indexed(`function target(){} var cb=target; cb();`); + let read: t.Identifier | undefined; + traverseJavaScriptAst(context.state.program, { + enter(node) { + if ( + t.isCallExpression(node) && + t.isIdentifier(node.callee, { name: "cb" }) + ) + read = node.callee; + }, + }); + if (read === undefined) throw new Error("Missing callback read"); + expect(context.resolve(read)).toMatchObject({ + callableIds: [context.state.callables[0]?.callableId], + complete: true, + }); +}); + +it("keeps a hoisted function declaration resolved from a nested read", () => { + const context = indexed( + `function read(){target();} read(); function target(){}`, + ); + const target = context.state.callables.find( + (callable) => callable.name === "target", + ); + let read: t.Identifier | undefined; + traverseJavaScriptAst(context.state.program, { + enter(node) { + if ( + t.isCallExpression(node) && + t.isIdentifier(node.callee, { name: "target" }) + ) + read = node.callee; + }, + }); + if (target === undefined || read === undefined) + throw new Error("Missing target or nested call"); + expect(context.resolve(read)).toMatchObject({ + callableIds: [target.callableId], + complete: true, + }); +}); + +it("does not infer hoisting for a conditional function declaration", () => { + const context = indexed( + `if (flag) { function target(){} } function read(){target();} read();`, + ); + let read: t.Identifier | undefined; + traverseJavaScriptAst(context.state.program, { + enter(node) { + if ( + t.isCallExpression(node) && + t.isIdentifier(node.callee, { name: "target" }) + ) + read = node.callee; + }, + }); + if (read === undefined) throw new Error("Missing conditional function read"); + expect(context.resolve(read)).toMatchObject({ complete: false }); +}); + +it("uses class declaration order instead of a stable initializer shortcut", () => { + const before = indexed("class Target {} new Target();"); + const after = indexed("new Target(); class Target {}"); + const exported = indexed("export class Target {} new Target();"); + const callTarget = (context: ReturnType) => { + let read: t.Identifier | undefined; + traverseJavaScriptAst(context.state.program, { + enter(node) { + if ( + t.isNewExpression(node) && + t.isIdentifier(node.callee, { name: "Target" }) + ) + read = node.callee; + }, + }); + if (read === undefined) throw new Error("Missing class constructor read"); + return context.resolve(read); + }; + const beforeTarget = before.state.callables.find( + (callable) => callable.name === "Target", + ); + if (beforeTarget === undefined) throw new Error("Missing class target"); + expect(callTarget(before)).toMatchObject({ + callableIds: [beforeTarget.callableId], + complete: true, + }); + expect(callTarget(after)).toMatchObject({ complete: false }); + expect(callTarget(exported)).toMatchObject({ complete: true }); +}); + it.each([false, true])( "bounds separate branch assignments with intrinsic cycle=%s", (cyclic) => { diff --git a/src/domain/javascript/javascriptSemanticCallResolution.ts b/src/domain/javascript/javascriptSemanticCallResolution.ts index 4ca40cc9f6..473cd60fd7 100644 --- a/src/domain/javascript/javascriptSemanticCallResolution.ts +++ b/src/domain/javascript/javascriptSemanticCallResolution.ts @@ -13,7 +13,6 @@ import { } from "./javascriptAstValues.js"; import { resolveSemanticBindingState, - semanticStableInitializer, type JavaScriptSemanticAnalysisState, type JavaScriptSemanticBindingState, type JavaScriptSemanticInitializer, @@ -128,9 +127,7 @@ const resolveIdentifier = ( ); // Knowing every local candidate does not prove that any write replaced the // incoming value, or that a write in another activation precedes this read. - const covered = - read.hasDefiniteInitializer || - semanticStableInitializer(binding, context.state) !== undefined; + const covered = read.hasDefiniteInitializer; const resolution = candidates.complete && !covered ? { diff --git a/src/domain/javascript/javascriptSemanticCallableStack.ts b/src/domain/javascript/javascriptSemanticCallableStack.ts index 0718d4aadd..f7c84d2040 100644 --- a/src/domain/javascript/javascriptSemanticCallableStack.ts +++ b/src/domain/javascript/javascriptSemanticCallableStack.ts @@ -1,5 +1,6 @@ import * as t from "@babel/types"; +import { semanticComputedMethodKey } from "./javascriptSemanticEvaluationOrder.js"; import { semanticCallableIdForNode } from "./javascriptSemanticProjection.js"; /** @@ -48,9 +49,7 @@ export const createSemanticCallableStack = ( enter: (node, parent) => { if ( parent !== null && - (t.isObjectMethod(parent) || t.isClassMethod(parent)) && - parent.computed && - parent.key === node && + semanticComputedMethodKey(parent, node) && frames.at(-1)?.callableId === semanticCallableIdForNode(parent) ) { const method = pop(); diff --git a/src/domain/javascript/javascriptSemanticEvaluationOrder.ts b/src/domain/javascript/javascriptSemanticEvaluationOrder.ts index 62013fc95b..d5cdc38c5a 100644 --- a/src/domain/javascript/javascriptSemanticEvaluationOrder.ts +++ b/src/domain/javascript/javascriptSemanticEvaluationOrder.ts @@ -13,6 +13,15 @@ export type JavaScriptSemanticIterationCardinality = | "nonempty" | "unknown"; +/** Whether a method's computed key executes in the enclosing environment. */ +export const semanticComputedMethodKey = ( + parent: t.Node | undefined, + child: t.Node, +): boolean => + (t.isObjectMethod(parent) || t.isClassMethod(parent)) && + parent.computed && + parent.key === child; + /** Exact source-only cardinality for literal loop heads. */ export const semanticIterationCardinality = ( loop: t.ForInStatement | t.ForOfStatement, diff --git a/src/domain/javascript/javascriptSemanticMutationInitializers.ts b/src/domain/javascript/javascriptSemanticMutationInitializers.ts index c297caa13d..a29fed4481 100644 --- a/src/domain/javascript/javascriptSemanticMutationInitializers.ts +++ b/src/domain/javascript/javascriptSemanticMutationInitializers.ts @@ -2,6 +2,7 @@ import * as t from "@babel/types"; import { delayedInstanceFieldValue, + semanticComputedMethodKey, semanticBranchMayBeSkipped, semanticIterationCardinality, } from "./javascriptSemanticEvaluationOrder.js"; @@ -377,6 +378,16 @@ const computeDirectInitializerPosition = ( node: t.Node, parents: WeakMap, ): StatementPosition | null => { + if (t.isClassDeclaration(node) && node.id !== null) { + const parent = parents.get(node); + const statement = + (t.isExportNamedDeclaration(parent) || + t.isExportDefaultDeclaration(parent)) && + parent.declaration === node + ? parent + : node; + return statementPosition(statement, parents); + } if ( (t.isAssignmentExpression(node) && t.isIdentifier(unwrapJavaScriptExpression(node.left).node)) || @@ -597,7 +608,7 @@ const bindingReadPosition = ( } if ( parent === undefined || - t.isFunction(parent) || + (t.isFunction(parent) && !semanticComputedMethodKey(parent, current)) || delayedInstanceFieldValue(parent, current) ) { result = { position: null, repeating: false }; @@ -646,6 +657,17 @@ export const semanticBindingAtRead = ( binding.referenceInitializers.length === 0 ) return { status: "ordered", binding, hasDefiniteInitializer: false }; + const [soleInitializer] = binding.initializers; + if ( + binding.initializers.length === 1 && + binding.referenceInitializers.length === 0 && + binding.declarationKinds.size === 0 && + binding.definitions.length === 1 && + binding.definitions[0]?.kind === "function" && + soleInitializer !== undefined && + t.isFunctionDeclaration(soleInitializer.node) + ) + return { status: "ordered", binding, hasDefiniteInitializer: true }; const initializer = binding.initializers[0]; const reference = binding.referenceInitializers[0]; const origin = initializer ?? reference;