Files
rea/src/domain/javascript/javascriptSemanticCalls.ts
T
morluto 9ac76d3d94 fix(javascript): invalidate stale facts across direct eval
Index unshadowed direct-eval sites once at the semantic owner. Distinguish
lexical rebinding, non-strict eval-introduced names, and possible mutation
of reachable objects. Reuse existing effect timing and capture proofs;
retain original definitions and module origins without inventing assignments
or parsing or executing eval payloads. Query object uncertainty conservatively
across the owning activation so aliases remain covered when names are shadowed
or leave block scope. Retain no binding-by-eval table.

Use the shared facts for values, read provenance, reference effects and child
argument shapes. Preserve indirect/optional/shadowed eval controls, unrelated
activations, immutable shapes, strict-mode bindings and prior primitive
captures. Remove obsolete reference plumbing and the source-wide eval blocker.

Validation: 306 focused checks across 16 files pass, including compiled CLI
and stdio MCP return projections, constructor uncertainty and child-process
consumers. Against the preceding SQLite commit, 9 of 23 selected checks fail
and 14 controls pass. Static/docs/test discovery (1559 files) pass. Two source
reviews found no remaining scoped blocker after alias and sloppy-name fixes.
No analyzed JavaScript, SQLite engine, heavy test or profiler was executed.
Conservative object reachability can intentionally lose exactness; large-case
opaque-query CPU and memory remain unmeasured.

Fixes #4875
2026-10-18 05:08:37 +08:00

433 lines
14 KiB
TypeScript

import * as t from "@babel/types";
import type {
JavaScriptSemanticArgumentFlow,
JavaScriptSemanticCallable,
JavaScriptSemanticCallResultFlow,
JavaScriptSemanticCallReturnFlow,
JavaScriptSemanticCallSite,
JavaScriptSemanticClosureCapture,
JavaScriptSemanticFrontier,
} from "./javascriptSemanticIr.js";
import { semanticReferenceRole } from "./javascriptSemanticProjection.js";
import { createSemanticCallableStack } from "./javascriptSemanticCallableStack.js";
import { semanticStaticPropertyKey } from "./javascriptAstValues.js";
import {
resolveSemanticBindingState,
isUnshadowedGlobal,
type JavaScriptSemanticAnalysisState,
type JavaScriptSemanticBindingState,
} from "./javascriptSemanticState.js";
import {
type LocalCallableResolution,
parameterBindings,
resolveLocalCallables,
} from "./javascriptSemanticCallResolution.js";
import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js";
import { range } from "./javascriptStaticAnalysisHelpers.js";
import { assignedSemanticResultBindings } from "./javascriptSemanticDataEffectHelpers.js";
/** Local call, flow, capture, and unsupported-frontier facts. */
export interface JavaScriptSemanticCallAnalysis {
readonly callSites: readonly JavaScriptSemanticCallSite[];
readonly argumentFlows: readonly JavaScriptSemanticArgumentFlow[];
readonly callReturnFlows: readonly JavaScriptSemanticCallReturnFlow[];
readonly callResultFlows: readonly JavaScriptSemanticCallResultFlow[];
readonly closureCaptures: readonly JavaScriptSemanticClosureCapture[];
readonly frontiers: readonly JavaScriptSemanticFrontier[];
}
interface MutableCallAnalysis {
readonly callSites: JavaScriptSemanticCallSite[];
readonly argumentFlows: JavaScriptSemanticArgumentFlow[];
readonly callReturnFlows: JavaScriptSemanticCallReturnFlow[];
readonly callResultFlows: JavaScriptSemanticCallResultFlow[];
readonly closureCaptures: JavaScriptSemanticClosureCapture[];
readonly frontiers: JavaScriptSemanticFrontier[];
}
interface CallCollectionContext {
readonly state: JavaScriptSemanticAnalysisState;
readonly callableById: ReadonlyMap<string, JavaScriptSemanticCallable>;
readonly output: MutableCallAnalysis;
readonly bindingResolutionCache: WeakMap<
t.Identifier,
LocalCallableResolution
>;
readonly parameterBindingCache: Map<
string,
readonly JavaScriptSemanticBindingState[]
>;
readonly ancestors: t.Node[];
}
/** Recover direct-call and lexical-capture candidates from inert syntax. */
export const collectJavaScriptSemanticCalls = (
program: t.Program,
state: JavaScriptSemanticAnalysisState,
callables: readonly JavaScriptSemanticCallable[],
): JavaScriptSemanticCallAnalysis => {
const output: MutableCallAnalysis = {
callSites: [],
argumentFlows: [],
callReturnFlows: [],
callResultFlows: [],
closureCaptures: [],
frontiers: [],
};
const callableById = new Map(
callables.map((callable) => [callable.callableId, callable]),
);
const context: CallCollectionContext = {
state,
callableById,
output,
bindingResolutionCache: new WeakMap(),
parameterBindingCache: new Map(),
ancestors: [],
};
const callableStack = createSemanticCallableStack((callableId) =>
callableById.get(callableId),
);
traverseJavaScriptAst(program, {
enter: (node, parent) => {
callableStack.enter(node, parent);
const owner = enclosingFunction(callableStack.callables);
collectCapture(node, parent, owner, context);
collectDynamicProperty(node, owner, state, output);
collectDynamicScope(node, owner, state, output);
if (
t.isCallExpression(node) ||
t.isOptionalCallExpression(node) ||
t.isNewExpression(node)
)
collectCallSite(node, parent, owner, context);
context.ancestors.push(node);
},
exit: (node) => {
context.ancestors.pop();
callableStack.exit(node);
},
});
return {
callSites: output.callSites,
argumentFlows: output.argumentFlows,
callReturnFlows: output.callReturnFlows,
callResultFlows: output.callResultFlows,
closureCaptures: output.closureCaptures,
frontiers: output.frontiers,
};
};
const collectCallSite = (
node: t.CallExpression | t.OptionalCallExpression | t.NewExpression,
parent: t.Node | null,
owner: JavaScriptSemanticCallable | undefined,
context: CallCollectionContext,
): void => {
const { state, callableById, output } = context;
const resolution = resolveLocalCallables({
node: node.callee,
state,
callableById,
seenInitializers: new Set(),
bindingCache: context.bindingResolutionCache,
});
const callSiteId = semanticCallSiteId(node);
const argumentsValue = retainedArguments(node.arguments);
const site: JavaScriptSemanticCallSite = {
callSiteId,
kind: t.isNewExpression(node) ? "construct" : "call",
callerCallableId: owner?.callableId ?? null,
location: range(node),
calleeLocation: range(node.callee),
resolution:
resolution.callableIds.length === 1 && resolution.complete
? "exact"
: resolution.callableIds.length > 0
? "ambiguous"
: "unresolved",
calleeCallableIds: resolution.callableIds,
arguments: argumentsValue,
};
output.callSites.push(site);
collectCallResultFlow(site, node, context);
if (site.resolution !== "exact")
addFrontier(
{
kind: "dynamic-call",
callableId: owner?.callableId ?? null,
location: range(node.callee),
reason: resolution.reason,
},
state,
output,
);
collectArgumentFlows(site, node, context);
if (site.kind === "call")
collectReturnFlows(site, callableById, state, output);
};
const collectCallResultFlow = (
site: JavaScriptSemanticCallSite,
node: t.CallExpression | t.OptionalCallExpression | t.NewExpression,
context: CallCollectionContext,
): void => {
for (const assigned of assignedSemanticResultBindings(
node,
context.ancestors,
context.state,
).filter(({ projectionPath }) => projectionPath.length === 0)) {
const binding = context.state.bindingsById.get(assigned.bindingId);
if (binding === undefined) continue;
const identifierRange = range(assigned.identifier);
const definition = binding.definitions.find(
({ location }) =>
location.start.line === identifierRange.start.line &&
location.start.column === identifierRange.start.column &&
location.end.line === identifierRange.end.line &&
location.end.column === identifierRange.end.column,
);
if (definition === undefined) continue;
context.output.callResultFlows.push({
callSiteId: site.callSiteId,
bindingId: binding.bindingId,
definitionLocation: definition.location,
});
}
};
const retainedArguments = (
nodes: readonly (
| t.Expression
| t.SpreadElement
| t.JSXNamespacedName
| t.ArgumentPlaceholder
| null
)[],
): JavaScriptSemanticCallSite["arguments"] => {
const retained: JavaScriptSemanticCallSite["arguments"][number][] = [];
for (const [index, node] of nodes.entries()) {
// Babel recovery can leave null slots; retain the other argument positions.
if (node === null) continue;
retained.push({
index,
location: range(node),
spread: t.isSpreadElement(node),
});
}
return retained;
};
const collectArgumentFlows = (
site: JavaScriptSemanticCallSite,
node: t.CallExpression | t.OptionalCallExpression | t.NewExpression,
context: CallCollectionContext,
): void => {
const { callableById, output } = context;
const retainedIndexes = new Set(site.arguments.map(({ index }) => index));
let positionIsExact = true;
for (const [index, argument] of node.arguments.entries()) {
if (t.isSpreadElement(argument)) {
positionIsExact = false;
continue;
}
if (
!positionIsExact ||
!retainedIndexes.has(index) ||
!t.isExpression(argument)
)
continue;
for (const callableId of site.calleeCallableIds) {
const callable = callableById.get(callableId);
if (callable === undefined) continue;
for (const parameter of cachedParameterBindings(
callable,
index,
context,
)) {
const definition = parameter.definitions.find(
({ kind }) => kind === "parameter",
);
if (definition === undefined) continue;
output.argumentFlows.push({
callSiteId: site.callSiteId,
argumentIndex: index,
argumentLocation: range(argument),
callableId,
parameterBindingId: parameter.bindingId,
parameterLocation: definition.location,
});
}
}
}
};
const cachedParameterBindings = (
callable: JavaScriptSemanticCallable,
index: number,
context: CallCollectionContext,
): readonly JavaScriptSemanticBindingState[] => {
const key = `${callable.callableId}:${String(index)}`;
const cached = context.parameterBindingCache.get(key);
if (cached !== undefined) return cached;
const bindings = parameterBindings(callable, index, context.state);
context.parameterBindingCache.set(key, bindings);
return bindings;
};
const collectReturnFlows = (
site: JavaScriptSemanticCallSite,
callableById: ReadonlyMap<string, JavaScriptSemanticCallable>,
state: JavaScriptSemanticAnalysisState,
output: MutableCallAnalysis,
): void => {
for (const callableId of site.calleeCallableIds) {
const callable = callableById.get(callableId);
if (callable === undefined) continue;
for (const returnSite of callable.returnSites) {
output.callReturnFlows.push({
callSiteId: site.callSiteId,
callableId,
returnSiteId: returnSite.returnSiteId,
returnLocation: returnSite.location,
});
}
}
};
const collectCapture = (
node: t.Node,
parent: t.Node | null,
owner: JavaScriptSemanticCallable | undefined,
context: CallCollectionContext,
): void => {
const { state, output } = context;
if (
!t.isIdentifier(node) ||
parent === null ||
owner === undefined ||
owner.bodyScopeId === null
)
return;
if (semanticReferenceRole(node, parent) === null) return;
const binding = resolveSemanticBindingState(state, node, node.name);
if (
binding === undefined ||
bindingIsWithinCallable(binding, owner.bodyScopeId, state)
)
return;
output.closureCaptures.push({
callableId: owner.callableId,
bindingId: binding.bindingId,
referenceLocation: range(node),
});
};
const collectDynamicProperty = (
node: t.Node,
owner: JavaScriptSemanticCallable | undefined,
state: JavaScriptSemanticAnalysisState,
output: MutableCallAnalysis,
): void => {
if (
(t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) &&
semanticStaticPropertyKey(node.property, node.computed) === null
)
addFrontier(
{
kind: "dynamic-property",
callableId: owner?.callableId ?? null,
location: range(node.property),
reason: "Computed member property is not a static string or number.",
},
state,
output,
);
else if (
(t.isObjectProperty(node) || t.isObjectMethod(node)) &&
node.computed &&
semanticStaticPropertyKey(node.key, true) === null
)
addFrontier(
{
kind: "dynamic-property",
callableId: owner?.callableId ?? null,
location: range(node.key),
reason: "Computed object property is not a static string or number.",
},
state,
output,
);
};
/**
* `with (o) { … }` and `eval("…")` decide at runtime which bindings exist, so
* every name inside them is unresolvable statically. Recording the frontier
* is what keeps the analyzer from presenting an empty or partial resolution as
* an observed fact — silence here would read as "nothing to resolve".
*/
const collectDynamicScope = (
node: t.Node,
owner: JavaScriptSemanticCallable | undefined,
state: JavaScriptSemanticAnalysisState,
output: MutableCallAnalysis,
): void => {
const reason = t.isWithStatement(node)
? "`with` introduces a runtime binding environment; names inside are not statically resolvable."
: state.opaqueEffects.sites.has(node)
? "`eval` can change bindings and reachable objects at runtime; its effects are not statically resolvable."
: null;
if (reason === null) return;
addFrontier(
{
kind: "dynamic-scope",
callableId: owner?.callableId ?? null,
location: range(node),
reason,
},
state,
output,
);
};
const addFrontier = (
frontier: JavaScriptSemanticFrontier,
state: JavaScriptSemanticAnalysisState,
output: MutableCallAnalysis,
): void => {
output.frontiers.push(frontier);
};
const bindingIsWithinCallable = (
binding: JavaScriptSemanticBindingState,
bodyScopeId: string,
state: JavaScriptSemanticAnalysisState,
): boolean => {
let scope = state.scopesById.get(binding.scopeId);
while (scope !== undefined) {
if (scope.scopeId === bodyScopeId) return true;
scope =
scope.parentScopeId === null
? undefined
: state.scopesById.get(scope.parentScopeId);
}
return false;
};
const enclosingFunction = (
stack: readonly JavaScriptSemanticCallable[],
): JavaScriptSemanticCallable | undefined =>
stack.findLast(({ kind }) => kind !== "class");
const semanticCallSiteId = (
node: t.CallExpression | t.OptionalCallExpression | t.NewExpression,
): string =>
// Join once so retained IDs do not keep a chain of intermediate strings.
[
"call",
t.isNewExpression(node) ? "construct" : "call",
String(node.start ?? -1),
String(node.end ?? -1),
].join(":");