fix(semantics): isolate named function expression bindings (#776)

Squash-merge: reviewed, no issues. Clean semantics fix with thorough binding-matrix coverage.
This commit is contained in:
Rudy Mizrahi Celekli authored and GitHub committed 2026-10-07 02:36:06 +08:00
1 parent c945931961
commit ae81f64d2a
3 files changed
+149 -11

No files matched your search

@@ -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]);
});
+34 -11
View File
@@ -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)
@@ -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,
);