diff --git a/src/domain/javascriptSemanticAnalysis.function_names.test.ts b/src/domain/javascriptSemanticAnalysis.function_names.test.ts new file mode 100644 index 0000000000..d3fd481f02 --- /dev/null +++ b/src/domain/javascriptSemanticAnalysis.function_names.test.ts @@ -0,0 +1,54 @@ +import { expect, it } from "vitest"; + +import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js"; + +it.each(["execute", "{execute}", "...execute", "execute = () => 'DEFAULT'"])( + "lets %s parameters shadow a named function expression", + (parameter) => { + const ir = analyzeJavaScriptSemantics( + `const task = function execute(${parameter}) { return execute(); };`, + ); + const call = ir.callSites[0]; + expect(call?.calleeCallableIds).toEqual([]); + expect(call?.resolution).toBe("unresolved"); + const bindings = ir.bindings.filter(({ name }) => name === "execute"); + expect(bindings).toHaveLength(2); + expect(bindings.map(({ kind }) => kind).sort()).toEqual([ + "function", + "parameter", + ]); + }, +); + +it.each(["var", "let", "const"])( + "lets body %s declarations shadow a named function expression", + (kind) => { + const ir = analyzeJavaScriptSemantics(`const task = function execute() { + ${kind} execute = () => 'LOCAL'; + return execute(); + };`); + const local = ir.callables.find( + ({ location }) => location.start.line === 2, + ); + expect(ir.callSites[0]?.calleeCallableIds).toEqual([local?.callableId]); + expect(ir.callSites[0]?.resolution).toBe("exact"); + }, +); + +it("preserves recursive function-name resolution without leaking the name", () => { + const ir = analyzeJavaScriptSemantics(`const task = function execute(depth) { + return depth === 0 ? 'DONE' : execute(depth - 1); + }; + execute();`); + const own = ir.callables.find(({ name }) => name === "execute"); + expect(ir.callSites[0]?.calleeCallableIds).toEqual([own?.callableId]); + expect(ir.callSites[1]?.calleeCallableIds).toEqual([]); +}); + +it("retains an unshadowed function name in parameter defaults", () => { + const ir = analyzeJavaScriptSemantics( + `const task = function execute(value = execute()) { return value; };`, + ); + const own = ir.callables.find(({ name }) => name === "execute"); + expect(ir.callSites[0]?.calleeCallableIds).toEqual([own?.callableId]); +}); diff --git a/src/domain/javascriptSemanticAnalysis.ts b/src/domain/javascriptSemanticAnalysis.ts index af9cd803dd..b79d2e6ca3 100644 --- a/src/domain/javascriptSemanticAnalysis.ts +++ b/src/domain/javascriptSemanticAnalysis.ts @@ -310,7 +310,9 @@ const nestedScope = ( if (kind === undefined) return undefined; const scope: JavaScriptSemanticScopeState = { scopeId: semanticScopeId(kind, switchOwner ?? node), - parentScopeId: parentScope.scopeId, + parentScopeId: + functionNameScope(node, parentScope, state)?.scopeId ?? + parentScope.scopeId, kind, location: range(switchOwner ?? node), bindingsComplete: true, @@ -321,6 +323,37 @@ const nestedScope = ( return scope; }; +const functionNameScope = ( + node: t.Node, + parentScope: JavaScriptSemanticScopeState, + state: JavaScriptSemanticAnalysisState, +): JavaScriptSemanticScopeState | undefined => { + if (!t.isFunctionExpression(node) || !t.isIdentifier(node.id)) + return undefined; + // A named expression has a private name environment outside its parameters + // and body. Parameters and local declarations may shadow that name. + const scope: JavaScriptSemanticScopeState = { + scopeId: `${semanticScopeId("block", node)}:function-name`, + parentScopeId: parentScope.scopeId, + kind: "block", + location: range(node), + bindingsComplete: true, + bindings: new Map(), + }; + state.scopes.push(scope); + state.scopesById.set(scope.scopeId, scope); + addBinding({ + state, + scope, + name: node.id.name, + kind: "function", + mutable: false, + definitionNode: node.id, + initializer: node, + }); + return scope; +}; + const scopeKind = ( node: t.Node, parent: t.Node | null, @@ -377,16 +410,6 @@ const bindFunctionLocals = ( scope: JavaScriptSemanticScopeState, state: JavaScriptSemanticAnalysisState, ): void => { - if (t.isFunctionExpression(node) && t.isIdentifier(node.id)) - addBinding({ - state, - scope, - name: node.id.name, - kind: "function", - mutable: false, - definitionNode: node.id, - initializer: node, - }); for (const parameter of node.params) bindPattern({ pattern: t.isTSParameterProperty(parameter) diff --git a/tests/acceptance/applications/javascriptFunctionNameCli.test.ts b/tests/acceptance/applications/javascriptFunctionNameCli.test.ts new file mode 100644 index 0000000000..5f688f0ede --- /dev/null +++ b/tests/acceptance/applications/javascriptFunctionNameCli.test.ts @@ -0,0 +1,61 @@ +import { execFile } from "node:child_process"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { promisify } from "node:util"; + +import { expect, it } from "vitest"; + +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascriptApplicationAnalysis.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +const execute = promisify(execFile); + +it.each([ + [ + "callback", + `const task = function execute(execute) { + return execute(); + }; task(() => 'CALLBACK');`, + 2, + null, + ], + [ + "body-local", + `const task = function execute() { + var execute = () => 'LOCAL'; + return execute(); + }; task();`, + 3, + 2, + ], +])( + "keeps named expression %s calls distinct in the public CLI", + async (name, source, callLine, targetLine) => { + const root = await createTestTempDirectory("rea-function-name-cli-"); + await writeFile(join(root, "app.js"), source); + const { stdout } = await execute( + process.execPath, + ["scripts/rea.mjs", "analyze-javascript-application", root, "--json"], + { cwd: process.cwd(), maxBuffer: 16 * 1024 * 1024 }, + ); + const result = javascriptApplicationAnalysisResultSchema.parse( + parseEvidence(JSON.parse(stdout)).normalized_result, + ); + const graph = result.semantic_graph; + const targets = graph.relations + .filter( + ({ relation, source_node_id }) => + relation === "calls" && + graph.nodes.find(({ node_id }) => node_id === source_node_id) + ?.identity.source_range?.start.line === callLine, + ) + .map( + ({ target_node_id }) => + graph.nodes.find(({ node_id }) => node_id === target_node_id) + ?.identity.source_range?.start.line, + ); + expect(targets).toEqual(targetLine === null ? [] : [targetLine]); + }, + 20_000, +);