feat(javascript): add local semantic call flow
This commit is contained in:
@@ -35,6 +35,7 @@ import {
|
||||
collectSemanticReturns,
|
||||
resolveSemanticModuleCallables,
|
||||
} from "./javascriptSemanticReturns.js";
|
||||
import { collectJavaScriptSemanticCalls } from "./javascriptSemanticCalls.js";
|
||||
import {
|
||||
compareCodePoints,
|
||||
propertyName,
|
||||
@@ -86,14 +87,16 @@ export const analyzeJavaScriptSemantics = (
|
||||
const bindings = immutableSemanticBindings(state);
|
||||
const callables = collectSemanticReturns(file.program, state, parserPartial);
|
||||
const moduleLinks = resolveSemanticModuleCallables(state, callables);
|
||||
const calls = collectJavaScriptSemanticCalls(file.program, state, callables);
|
||||
return {
|
||||
schema: "JavaScriptSemanticIR",
|
||||
schemaVersion: 2,
|
||||
schemaVersion: 3,
|
||||
scopes: immutableSemanticScopes(state),
|
||||
bindings,
|
||||
callables,
|
||||
references,
|
||||
moduleLinks,
|
||||
...calls,
|
||||
coverage: {
|
||||
status:
|
||||
state.limitsReached.size > 0
|
||||
@@ -115,6 +118,7 @@ export const analyzeJavaScriptSemantics = (
|
||||
: ["Semantic recovery stopped retaining facts at explicit limits."]),
|
||||
"Values and aliases were recovered from inert syntax only; no JavaScript was executed.",
|
||||
"Return sites include only direct callable returns; nested callable returns remain separate.",
|
||||
"Local call, argument, return, and closure relations are static candidates and do not prove runtime invocation.",
|
||||
"Cross-function mutation and dynamic property resolution remain unknown.",
|
||||
],
|
||||
};
|
||||
@@ -139,6 +143,7 @@ const createState = (
|
||||
scopeByNode: new WeakMap([[program, root]]),
|
||||
bindingsById: new Map(),
|
||||
callables: [],
|
||||
callableNodesById: new Map(),
|
||||
moduleLinks: [],
|
||||
limitsReached: new Set(),
|
||||
omittedCount: 0,
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
import * as t from "@babel/types";
|
||||
|
||||
import type {
|
||||
JavaScriptSemanticArgumentFlow,
|
||||
JavaScriptSemanticCallable,
|
||||
} from "./javascriptSemanticIr.js";
|
||||
import {
|
||||
reachSemanticLimit,
|
||||
semanticCallableIdForNode,
|
||||
} from "./javascriptSemanticProjection.js";
|
||||
import {
|
||||
resolveSemanticBindingState,
|
||||
type JavaScriptSemanticAnalysisState,
|
||||
type JavaScriptSemanticBindingState,
|
||||
} from "./javascriptSemanticState.js";
|
||||
import { compareCodePoints, range } from "./javascriptStaticAnalysisHelpers.js";
|
||||
|
||||
/** Resolution of one call target within the currently analyzed source. */
|
||||
export interface LocalCallableResolution {
|
||||
readonly callableIds: readonly string[];
|
||||
readonly complete: boolean;
|
||||
readonly reason: string;
|
||||
}
|
||||
|
||||
interface CallResolutionContext {
|
||||
readonly state: JavaScriptSemanticAnalysisState;
|
||||
readonly callableById: ReadonlyMap<string, JavaScriptSemanticCallable>;
|
||||
readonly seenBindings: ReadonlySet<string>;
|
||||
readonly depth: number;
|
||||
readonly bindingCache: Map<string, LocalCallableResolution>;
|
||||
}
|
||||
|
||||
/** Inputs needed to resolve one local call target. */
|
||||
export interface ResolveLocalCallablesInput {
|
||||
readonly node: t.Node;
|
||||
readonly state: JavaScriptSemanticAnalysisState;
|
||||
readonly callableById: ReadonlyMap<string, JavaScriptSemanticCallable>;
|
||||
readonly seenBindings: ReadonlySet<string>;
|
||||
readonly depth: number;
|
||||
readonly bindingCache: Map<string, LocalCallableResolution>;
|
||||
}
|
||||
|
||||
/** Resolve bounded local callable candidates without following dynamic properties. */
|
||||
export const resolveLocalCallables = (
|
||||
input: ResolveLocalCallablesInput,
|
||||
): LocalCallableResolution =>
|
||||
resolveCallables(input.node, {
|
||||
state: input.state,
|
||||
callableById: input.callableById,
|
||||
seenBindings: input.seenBindings,
|
||||
depth: input.depth,
|
||||
bindingCache: input.bindingCache,
|
||||
});
|
||||
|
||||
const resolveCallables = (
|
||||
node: t.Node,
|
||||
context: CallResolutionContext,
|
||||
): LocalCallableResolution => {
|
||||
if (context.depth >= context.state.limits.maxValueDepth) {
|
||||
reachSemanticLimit(context.state, "maxValueDepth");
|
||||
return {
|
||||
callableIds: [],
|
||||
complete: false,
|
||||
reason: "Call target depth limit reached.",
|
||||
};
|
||||
}
|
||||
const direct = semanticCallableIdForNode(node);
|
||||
if (direct !== null && context.callableById.has(direct))
|
||||
return {
|
||||
callableIds: [direct],
|
||||
complete: true,
|
||||
reason: "Direct local callable.",
|
||||
};
|
||||
if (t.isIdentifier(node)) return resolveIdentifier(node, context);
|
||||
if (
|
||||
t.isTSAsExpression(node) ||
|
||||
t.isTSTypeAssertion(node) ||
|
||||
t.isTSNonNullExpression(node)
|
||||
)
|
||||
return resolveCallables(node.expression, nestedContext(context));
|
||||
if (t.isConditionalExpression(node) || t.isLogicalExpression(node))
|
||||
return resolveAlternatives(
|
||||
t.isConditionalExpression(node) ? node.consequent : node.left,
|
||||
t.isConditionalExpression(node) ? node.alternate : node.right,
|
||||
context,
|
||||
);
|
||||
return {
|
||||
callableIds: [],
|
||||
complete: false,
|
||||
reason: `Unsupported ${node.type} call target.`,
|
||||
};
|
||||
};
|
||||
|
||||
const resolveIdentifier = (
|
||||
node: t.Identifier,
|
||||
context: CallResolutionContext,
|
||||
): LocalCallableResolution => {
|
||||
const binding = resolveSemanticBindingState(context.state, node, node.name);
|
||||
return binding === undefined
|
||||
? {
|
||||
callableIds: [],
|
||||
complete: false,
|
||||
reason: `Unresolved call target ${node.name}.`,
|
||||
}
|
||||
: resolveBindingCallables(binding, nestedContext(context));
|
||||
};
|
||||
|
||||
const resolveAlternatives = (
|
||||
left: t.Node,
|
||||
right: t.Node,
|
||||
context: CallResolutionContext,
|
||||
): LocalCallableResolution => {
|
||||
const candidates = [left, right].map((candidate) =>
|
||||
resolveCallables(candidate, nestedContext(context)),
|
||||
);
|
||||
return {
|
||||
callableIds: uniqueCallableIds(
|
||||
candidates.flatMap(({ callableIds }) => callableIds),
|
||||
),
|
||||
complete: candidates.every(({ complete }) => complete),
|
||||
reason: "Conditional or logical call target has multiple candidates.",
|
||||
};
|
||||
};
|
||||
|
||||
const resolveBindingCallables = (
|
||||
binding: JavaScriptSemanticBindingState,
|
||||
context: CallResolutionContext,
|
||||
): LocalCallableResolution => {
|
||||
const cached = context.bindingCache.get(binding.bindingId);
|
||||
if (cached !== undefined) return cached;
|
||||
if (context.seenBindings.has(binding.bindingId))
|
||||
return {
|
||||
callableIds: [],
|
||||
complete: false,
|
||||
reason: `Call target alias cycle at ${binding.name}.`,
|
||||
};
|
||||
if (binding.initializers.length === 0)
|
||||
return missingBindingInitializer(binding);
|
||||
const candidateContext = {
|
||||
...nestedContext(context),
|
||||
seenBindings: new Set([...context.seenBindings, binding.bindingId]),
|
||||
};
|
||||
const candidates = binding.initializers.map(({ node, projection }) =>
|
||||
projection.length === 0
|
||||
? resolveCallables(node, candidateContext)
|
||||
: {
|
||||
callableIds: [],
|
||||
complete: false,
|
||||
reason: `Projected call target ${binding.name} is unresolved.`,
|
||||
},
|
||||
);
|
||||
const callableIds = uniqueCallableIds(
|
||||
candidates.flatMap((candidate) => candidate.callableIds),
|
||||
);
|
||||
const resolution = {
|
||||
callableIds,
|
||||
complete: candidates.every(({ complete }) => complete),
|
||||
reason:
|
||||
callableIds.length === 1 && candidates.every(({ complete }) => complete)
|
||||
? "Unique local callable target."
|
||||
: `Call target ${binding.name} has ambiguous or unsupported assignments.`,
|
||||
};
|
||||
if (resolution.complete)
|
||||
context.bindingCache.set(binding.bindingId, resolution);
|
||||
return resolution;
|
||||
};
|
||||
|
||||
const missingBindingInitializer = (
|
||||
binding: JavaScriptSemanticBindingState,
|
||||
): LocalCallableResolution => ({
|
||||
callableIds: [],
|
||||
complete: false,
|
||||
reason:
|
||||
binding.kind === "import"
|
||||
? `Call target ${binding.name} is imported from outside this source.`
|
||||
: `Call target ${binding.name} has no local initializer.`,
|
||||
});
|
||||
|
||||
/** Find all bindings introduced by one positional parameter pattern. */
|
||||
export const parameterBindings = (
|
||||
callable: JavaScriptSemanticCallable,
|
||||
index: number,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
): JavaScriptSemanticBindingState[] => {
|
||||
if (callable.bodyScopeId === null) return [];
|
||||
const scope = state.scopesById.get(callable.bodyScopeId);
|
||||
if (scope === undefined) return [];
|
||||
return [...scope.bindings.values()]
|
||||
.filter(
|
||||
(binding) =>
|
||||
binding.kind === "parameter" &&
|
||||
binding.definitions.some(
|
||||
({ kind, location }) =>
|
||||
kind === "parameter" &&
|
||||
parameterIndex(callable, location, state) === index,
|
||||
),
|
||||
)
|
||||
.sort((left, right) => compareCodePoints(left.bindingId, right.bindingId));
|
||||
};
|
||||
|
||||
const parameterIndex = (
|
||||
callable: JavaScriptSemanticCallable,
|
||||
location: JavaScriptSemanticArgumentFlow["parameterLocation"],
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
): number => {
|
||||
const node = state.callableNodesById.get(callable.callableId);
|
||||
if (!t.isFunction(node)) return -1;
|
||||
return node.params.findIndex((parameter) =>
|
||||
rangeContains(range(parameter), location),
|
||||
);
|
||||
};
|
||||
|
||||
const rangeContains = (
|
||||
outer: JavaScriptSemanticArgumentFlow["parameterLocation"],
|
||||
inner: JavaScriptSemanticArgumentFlow["parameterLocation"],
|
||||
): boolean =>
|
||||
(outer.start.line < inner.start.line ||
|
||||
(outer.start.line === inner.start.line &&
|
||||
outer.start.column <= inner.start.column)) &&
|
||||
(outer.end.line > inner.end.line ||
|
||||
(outer.end.line === inner.end.line &&
|
||||
outer.end.column >= inner.end.column));
|
||||
|
||||
const nestedContext = (
|
||||
context: CallResolutionContext,
|
||||
): CallResolutionContext => ({ ...context, depth: context.depth + 1 });
|
||||
|
||||
const uniqueCallableIds = (values: readonly string[]): string[] =>
|
||||
[...new Set(values)].sort(compareCodePoints);
|
||||
@@ -0,0 +1,386 @@
|
||||
import * as t from "@babel/types";
|
||||
|
||||
import type {
|
||||
JavaScriptSemanticArgumentFlow,
|
||||
JavaScriptSemanticCallable,
|
||||
JavaScriptSemanticCallReturnFlow,
|
||||
JavaScriptSemanticCallSite,
|
||||
JavaScriptSemanticClosureCapture,
|
||||
JavaScriptSemanticFrontier,
|
||||
} from "./javascriptSemanticIr.js";
|
||||
import {
|
||||
reachSemanticLimit,
|
||||
semanticCallableIdForNode,
|
||||
semanticReferenceRole,
|
||||
semanticStaticPropertyName,
|
||||
} from "./javascriptSemanticProjection.js";
|
||||
import {
|
||||
resolveSemanticBindingState,
|
||||
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";
|
||||
|
||||
/** Local call, flow, capture, and unsupported-frontier facts. */
|
||||
export interface JavaScriptSemanticCallAnalysis {
|
||||
readonly callSites: readonly JavaScriptSemanticCallSite[];
|
||||
readonly argumentFlows: readonly JavaScriptSemanticArgumentFlow[];
|
||||
readonly callReturnFlows: readonly JavaScriptSemanticCallReturnFlow[];
|
||||
readonly closureCaptures: readonly JavaScriptSemanticClosureCapture[];
|
||||
readonly frontiers: readonly JavaScriptSemanticFrontier[];
|
||||
}
|
||||
|
||||
interface MutableCallAnalysis {
|
||||
readonly callSites: JavaScriptSemanticCallSite[];
|
||||
readonly argumentFlows: JavaScriptSemanticArgumentFlow[];
|
||||
readonly callReturnFlows: JavaScriptSemanticCallReturnFlow[];
|
||||
readonly closureCaptures: JavaScriptSemanticClosureCapture[];
|
||||
readonly frontiers: JavaScriptSemanticFrontier[];
|
||||
retainedArguments: number;
|
||||
}
|
||||
|
||||
interface CallCollectionContext {
|
||||
readonly state: JavaScriptSemanticAnalysisState;
|
||||
readonly callableById: ReadonlyMap<string, JavaScriptSemanticCallable>;
|
||||
readonly output: MutableCallAnalysis;
|
||||
readonly bindingResolutionCache: Map<string, LocalCallableResolution>;
|
||||
readonly parameterBindingCache: Map<
|
||||
string,
|
||||
readonly JavaScriptSemanticBindingState[]
|
||||
>;
|
||||
}
|
||||
|
||||
/** Recover bounded 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: [],
|
||||
closureCaptures: [],
|
||||
frontiers: [],
|
||||
retainedArguments: 0,
|
||||
};
|
||||
const callableById = new Map(
|
||||
callables.map((callable) => [callable.callableId, callable]),
|
||||
);
|
||||
const context: CallCollectionContext = {
|
||||
state,
|
||||
callableById,
|
||||
output,
|
||||
bindingResolutionCache: new Map(),
|
||||
parameterBindingCache: new Map(),
|
||||
};
|
||||
const callableStack: JavaScriptSemanticCallable[] = [];
|
||||
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);
|
||||
collectCapture(node, parent, owner, context);
|
||||
collectDynamicProperty(node, owner, state, output);
|
||||
if (
|
||||
t.isCallExpression(node) ||
|
||||
t.isOptionalCallExpression(node) ||
|
||||
t.isNewExpression(node)
|
||||
)
|
||||
collectCallSite(node, owner, context);
|
||||
},
|
||||
exit: (node) => {
|
||||
const callableId = semanticCallableIdForNode(node);
|
||||
if (
|
||||
callableId !== null &&
|
||||
callableStack.at(-1)?.callableId === callableId
|
||||
)
|
||||
callableStack.pop();
|
||||
},
|
||||
});
|
||||
return {
|
||||
callSites: output.callSites,
|
||||
argumentFlows: output.argumentFlows,
|
||||
callReturnFlows: output.callReturnFlows,
|
||||
closureCaptures: output.closureCaptures,
|
||||
frontiers: output.frontiers,
|
||||
};
|
||||
};
|
||||
|
||||
const collectCallSite = (
|
||||
node: t.CallExpression | t.OptionalCallExpression | t.NewExpression,
|
||||
owner: JavaScriptSemanticCallable | undefined,
|
||||
context: CallCollectionContext,
|
||||
): void => {
|
||||
const { state, callableById, output } = context;
|
||||
if (output.callSites.length >= state.limits.maxCallSites) {
|
||||
reachSemanticLimit(state, "maxCallSites");
|
||||
return;
|
||||
}
|
||||
const resolution = resolveLocalCallables({
|
||||
node: node.callee,
|
||||
state,
|
||||
callableById,
|
||||
seenBindings: new Set(),
|
||||
depth: 0,
|
||||
bindingCache: context.bindingResolutionCache,
|
||||
});
|
||||
const callSiteId = semanticCallSiteId(node);
|
||||
const argumentsValue = retainedArguments(node.arguments, state, output);
|
||||
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);
|
||||
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 retainedArguments = (
|
||||
nodes: readonly (
|
||||
| t.Expression
|
||||
| t.SpreadElement
|
||||
| t.JSXNamespacedName
|
||||
| t.ArgumentPlaceholder
|
||||
)[],
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
output: MutableCallAnalysis,
|
||||
): JavaScriptSemanticCallSite["arguments"] => {
|
||||
const retained: JavaScriptSemanticCallSite["arguments"][number][] = [];
|
||||
for (const [index, node] of nodes.entries()) {
|
||||
if (output.retainedArguments >= state.limits.maxCallArguments) {
|
||||
reachSemanticLimit(state, "maxCallArguments");
|
||||
break;
|
||||
}
|
||||
output.retainedArguments += 1;
|
||||
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 { state, 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,
|
||||
)) {
|
||||
if (output.argumentFlows.length >= state.limits.maxArgumentFlows) {
|
||||
reachSemanticLimit(state, "maxArgumentFlows");
|
||||
return;
|
||||
}
|
||||
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) {
|
||||
if (output.callReturnFlows.length >= state.limits.maxCallReturnFlows) {
|
||||
reachSemanticLimit(state, "maxCallReturnFlows");
|
||||
return;
|
||||
}
|
||||
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;
|
||||
if (output.closureCaptures.length >= state.limits.maxClosureCaptures) {
|
||||
reachSemanticLimit(state, "maxClosureCaptures");
|
||||
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)) &&
|
||||
semanticStaticPropertyName(node.property, node.computed) === ""
|
||||
)
|
||||
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 &&
|
||||
semanticStaticPropertyName(node.key, true) === ""
|
||||
)
|
||||
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,
|
||||
);
|
||||
};
|
||||
|
||||
const addFrontier = (
|
||||
frontier: JavaScriptSemanticFrontier,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
output: MutableCallAnalysis,
|
||||
): void => {
|
||||
if (output.frontiers.length >= state.limits.maxFrontiers) {
|
||||
reachSemanticLimit(state, "maxFrontiers");
|
||||
return;
|
||||
}
|
||||
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 =>
|
||||
`call:${t.isNewExpression(node) ? "construct" : "call"}:${String(node.start ?? -1)}:${String(node.end ?? -1)}`;
|
||||
@@ -8,6 +8,12 @@ export interface JavaScriptSemanticLimits {
|
||||
readonly maxReferences: number;
|
||||
readonly maxModuleLinks: number;
|
||||
readonly maxReturnSites: number;
|
||||
readonly maxCallSites: number;
|
||||
readonly maxCallArguments: number;
|
||||
readonly maxArgumentFlows: number;
|
||||
readonly maxCallReturnFlows: number;
|
||||
readonly maxClosureCaptures: number;
|
||||
readonly maxFrontiers: number;
|
||||
readonly maxValueDepth: number;
|
||||
readonly maxUnionValues: number;
|
||||
readonly maxObjectProperties: number;
|
||||
@@ -21,6 +27,12 @@ export const DEFAULT_JAVASCRIPT_SEMANTIC_LIMITS: JavaScriptSemanticLimits = {
|
||||
maxReferences: 100_000,
|
||||
maxModuleLinks: 20_000,
|
||||
maxReturnSites: 20_000,
|
||||
maxCallSites: 20_000,
|
||||
maxCallArguments: 100_000,
|
||||
maxArgumentFlows: 100_000,
|
||||
maxCallReturnFlows: 100_000,
|
||||
maxClosureCaptures: 100_000,
|
||||
maxFrontiers: 20_000,
|
||||
maxValueDepth: 16,
|
||||
maxUnionValues: 32,
|
||||
maxObjectProperties: 256,
|
||||
@@ -139,10 +151,63 @@ export interface JavaScriptSemanticCallable {
|
||||
|
||||
/** One direct return expression belonging to exactly one callable. */
|
||||
export interface JavaScriptSemanticReturnSite {
|
||||
readonly returnSiteId: string;
|
||||
readonly location: JavaScriptSourceRange;
|
||||
readonly value: JavaScriptSemanticValue;
|
||||
}
|
||||
|
||||
/** One statically visible call or construction expression. */
|
||||
export interface JavaScriptSemanticCallSite {
|
||||
readonly callSiteId: string;
|
||||
readonly kind: "call" | "construct";
|
||||
readonly callerCallableId: string | null;
|
||||
readonly location: JavaScriptSourceRange;
|
||||
readonly calleeLocation: JavaScriptSourceRange;
|
||||
readonly resolution: "exact" | "ambiguous" | "unresolved";
|
||||
readonly calleeCallableIds: readonly string[];
|
||||
readonly arguments: readonly JavaScriptSemanticCallArgument[];
|
||||
}
|
||||
|
||||
/** One argument occurrence retained without evaluating application code. */
|
||||
export interface JavaScriptSemanticCallArgument {
|
||||
readonly index: number;
|
||||
readonly location: JavaScriptSourceRange;
|
||||
readonly spread: boolean;
|
||||
}
|
||||
|
||||
/** One positional argument that may initialize one local parameter binding. */
|
||||
export interface JavaScriptSemanticArgumentFlow {
|
||||
readonly callSiteId: string;
|
||||
readonly argumentIndex: number;
|
||||
readonly argumentLocation: JavaScriptSourceRange;
|
||||
readonly callableId: string;
|
||||
readonly parameterBindingId: string;
|
||||
readonly parameterLocation: JavaScriptSourceRange;
|
||||
}
|
||||
|
||||
/** One direct local return that may supply a call expression's value. */
|
||||
export interface JavaScriptSemanticCallReturnFlow {
|
||||
readonly callSiteId: string;
|
||||
readonly callableId: string;
|
||||
readonly returnSiteId: string;
|
||||
readonly returnLocation: JavaScriptSourceRange;
|
||||
}
|
||||
|
||||
/** One resolved outer lexical binding referenced by a nested callable. */
|
||||
export interface JavaScriptSemanticClosureCapture {
|
||||
readonly callableId: string;
|
||||
readonly bindingId: string;
|
||||
readonly referenceLocation: JavaScriptSourceRange;
|
||||
}
|
||||
|
||||
/** One syntax location where exact local semantic continuation is unavailable. */
|
||||
export interface JavaScriptSemanticFrontier {
|
||||
readonly kind: "dynamic-call" | "dynamic-property";
|
||||
readonly callableId: string | null;
|
||||
readonly location: JavaScriptSourceRange;
|
||||
readonly reason: string;
|
||||
}
|
||||
|
||||
/** Exact retention state for one callable's direct returns. */
|
||||
interface JavaScriptSemanticReturnCoverage {
|
||||
readonly status: "complete" | "partial" | "truncated";
|
||||
@@ -174,15 +239,20 @@ interface JavaScriptSemanticCoverage {
|
||||
readonly limitsReached: readonly (keyof JavaScriptSemanticLimits)[];
|
||||
}
|
||||
|
||||
/** Provider-neutral JavaScript semantic IR v2. */
|
||||
/** Provider-neutral JavaScript semantic IR v3. */
|
||||
export interface JavaScriptSemanticIr {
|
||||
readonly schema: "JavaScriptSemanticIR";
|
||||
readonly schemaVersion: 2;
|
||||
readonly schemaVersion: 3;
|
||||
readonly scopes: readonly JavaScriptSemanticScope[];
|
||||
readonly bindings: readonly JavaScriptSemanticBinding[];
|
||||
readonly callables: readonly JavaScriptSemanticCallable[];
|
||||
readonly references: readonly JavaScriptSemanticReference[];
|
||||
readonly moduleLinks: readonly JavaScriptSemanticModuleLink[];
|
||||
readonly callSites: readonly JavaScriptSemanticCallSite[];
|
||||
readonly argumentFlows: readonly JavaScriptSemanticArgumentFlow[];
|
||||
readonly callReturnFlows: readonly JavaScriptSemanticCallReturnFlow[];
|
||||
readonly closureCaptures: readonly JavaScriptSemanticClosureCapture[];
|
||||
readonly frontiers: readonly JavaScriptSemanticFrontier[];
|
||||
readonly coverage: JavaScriptSemanticCoverage;
|
||||
readonly limitations: readonly string[];
|
||||
}
|
||||
@@ -208,12 +278,17 @@ export const semanticBinding = (
|
||||
/** Fail-closed result when Babel cannot produce an inert syntax tree. */
|
||||
export const failedJavaScriptSemanticIr = (): JavaScriptSemanticIr => ({
|
||||
schema: "JavaScriptSemanticIR",
|
||||
schemaVersion: 2,
|
||||
schemaVersion: 3,
|
||||
scopes: [],
|
||||
bindings: [],
|
||||
callables: [],
|
||||
references: [],
|
||||
moduleLinks: [],
|
||||
callSites: [],
|
||||
argumentFlows: [],
|
||||
callReturnFlows: [],
|
||||
closureCaptures: [],
|
||||
frontiers: [],
|
||||
coverage: { status: "failed", omittedCount: null, limitsReached: [] },
|
||||
limitations: [
|
||||
"JavaScript parsing failed; no semantic absence claim is available.",
|
||||
|
||||
@@ -42,8 +42,10 @@ export const collectSemanticCallable = (input: CollectCallableInput): void => {
|
||||
reachSemanticLimit(state, "maxCallables");
|
||||
return;
|
||||
}
|
||||
const callableId =
|
||||
semanticCallableIdForNode(node) ?? "callable:unknown:-1:-1";
|
||||
state.callables.push({
|
||||
callableId: semanticCallableIdForNode(node) ?? "callable:unknown:-1:-1",
|
||||
callableId,
|
||||
kind,
|
||||
name: callableName(node, parent),
|
||||
containerScopeId: containerScope.scopeId,
|
||||
@@ -60,6 +62,7 @@ export const collectSemanticCallable = (input: CollectCallableInput): void => {
|
||||
limitsReached: [],
|
||||
},
|
||||
});
|
||||
state.callableNodesById.set(callableId, node);
|
||||
};
|
||||
|
||||
/** Collect one static import/export relationship for later composition. */
|
||||
@@ -102,7 +105,7 @@ export const collectSemanticReferences = (
|
||||
traverseJavaScriptAst(program, {
|
||||
enter: (node, parent) => {
|
||||
if (!t.isIdentifier(node) || parent === null) return;
|
||||
const role = referenceRole(node, parent);
|
||||
const role = semanticReferenceRole(node, parent);
|
||||
if (role === null) return;
|
||||
const key = `${String(node.start)}:${role}:${node.name}`;
|
||||
if (seen.has(key)) return;
|
||||
@@ -403,7 +406,8 @@ const declarationCallableId = (
|
||||
: semanticCallableIdForNode(declarator.init);
|
||||
};
|
||||
|
||||
const referenceRole = (
|
||||
/** Classify one identifier occurrence consistently with semantic references. */
|
||||
export const semanticReferenceRole = (
|
||||
node: t.Identifier,
|
||||
parent: t.Node,
|
||||
): JavaScriptSemanticReference["role"] | null => {
|
||||
|
||||
@@ -55,6 +55,10 @@ export const collectSemanticReturns = (
|
||||
}
|
||||
retained += 1;
|
||||
sites.push({
|
||||
returnSiteId: semanticReturnSiteId(
|
||||
callable.callableId,
|
||||
expression.location,
|
||||
),
|
||||
location: expression.location,
|
||||
value:
|
||||
expression.node === null
|
||||
@@ -76,6 +80,12 @@ export const collectSemanticReturns = (
|
||||
});
|
||||
};
|
||||
|
||||
const semanticReturnSiteId = (
|
||||
callableId: string,
|
||||
location: JavaScriptSemanticReturnSite["location"],
|
||||
): string =>
|
||||
`${callableId}:return:${String(location.start.line)}:${String(location.start.column)}:${String(location.end.line)}:${String(location.end.column)}`;
|
||||
|
||||
/** Link exports to callables only when lexical resolution is unique and exact. */
|
||||
export const resolveSemanticModuleCallables = (
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
|
||||
@@ -45,6 +45,7 @@ export interface JavaScriptSemanticAnalysisState {
|
||||
readonly scopeByNode: WeakMap<t.Node, JavaScriptSemanticScopeState>;
|
||||
readonly bindingsById: Map<string, JavaScriptSemanticBindingState>;
|
||||
readonly callables: JavaScriptSemanticCallable[];
|
||||
readonly callableNodesById: Map<string, t.Node>;
|
||||
readonly moduleLinks: JavaScriptSemanticModuleLink[];
|
||||
readonly limitsReached: Set<keyof JavaScriptSemanticLimits>;
|
||||
omittedCount: number;
|
||||
|
||||
@@ -200,6 +200,153 @@ describe("JavaScript semantic analysis", () => {
|
||||
expect(bindingsNamed(ir, "method")).toEqual([]);
|
||||
});
|
||||
|
||||
it("links exact local calls to parameters, returns, and closure captures", () => {
|
||||
const ir = analyzeJavaScriptSemantics(`
|
||||
const prefix = "rea";
|
||||
function render(value, { mode }) {
|
||||
return prefix + value + mode;
|
||||
}
|
||||
const alias = render;
|
||||
const result = alias(input, { mode: "fast" });
|
||||
`);
|
||||
|
||||
expect(ir.schemaVersion).toBe(3);
|
||||
const render = onlyCallable(ir, "render");
|
||||
const call = ir.callSites.find(
|
||||
({ calleeCallableIds }) => calleeCallableIds[0] === render.callableId,
|
||||
);
|
||||
expect(call).toMatchObject({
|
||||
kind: "call",
|
||||
callerCallableId: null,
|
||||
resolution: "exact",
|
||||
calleeCallableIds: [render.callableId],
|
||||
arguments: [
|
||||
{ index: 0, spread: false },
|
||||
{ index: 1, spread: false },
|
||||
],
|
||||
});
|
||||
if (call === undefined) throw new Error("Missing render call");
|
||||
expect(
|
||||
ir.argumentFlows
|
||||
.filter(({ callSiteId }) => callSiteId === call.callSiteId)
|
||||
.map(({ argumentIndex, parameterBindingId }) => ({
|
||||
argumentIndex,
|
||||
parameter: semanticBinding(ir, parameterBindingId)?.name,
|
||||
})),
|
||||
).toEqual([
|
||||
{ argumentIndex: 0, parameter: "value" },
|
||||
{ argumentIndex: 1, parameter: "mode" },
|
||||
]);
|
||||
expect(ir.callReturnFlows).toEqual([
|
||||
expect.objectContaining({
|
||||
callSiteId: call.callSiteId,
|
||||
callableId: render.callableId,
|
||||
returnSiteId: render.returnSites[0]?.returnSiteId,
|
||||
}),
|
||||
]);
|
||||
expect(ir.closureCaptures).toEqual([
|
||||
expect.objectContaining({
|
||||
callableId: render.callableId,
|
||||
bindingId: topLevelBinding(ir, "prefix").bindingId,
|
||||
}),
|
||||
]);
|
||||
expect(ir.frontiers).toEqual([]);
|
||||
});
|
||||
|
||||
it("retains ambiguous local callees and explicit dynamic frontiers", () => {
|
||||
const ir = analyzeJavaScriptSemantics(`
|
||||
function left(value) { return value; }
|
||||
function right(value) { return value; }
|
||||
const selected = chooseLeft ? left : right;
|
||||
selected(input);
|
||||
receiver[key](input);
|
||||
external(input);
|
||||
`);
|
||||
|
||||
const selected = ir.callSites.find(
|
||||
({ resolution }) => resolution === "ambiguous",
|
||||
);
|
||||
expect(selected?.calleeCallableIds).toEqual(
|
||||
[
|
||||
onlyCallable(ir, "left").callableId,
|
||||
onlyCallable(ir, "right").callableId,
|
||||
].sort(),
|
||||
);
|
||||
expect(
|
||||
ir.argumentFlows.filter(
|
||||
({ callSiteId }) => callSiteId === selected?.callSiteId,
|
||||
),
|
||||
).toHaveLength(2);
|
||||
expect(ir.frontiers).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
kind: "dynamic-call",
|
||||
reason: expect.stringMatching(/ambiguous/iu),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
kind: "dynamic-property",
|
||||
reason: expect.stringMatching(/computed member/iu),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
kind: "dynamic-call",
|
||||
reason: "Unresolved call target external.",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not project positional argument flow after a spread", () => {
|
||||
const ir = analyzeJavaScriptSemantics(`
|
||||
function target(first, second, third) { return third; }
|
||||
target(one, ...rest, three);
|
||||
`);
|
||||
|
||||
expect(ir.argumentFlows.map(({ argumentIndex }) => argumentIndex)).toEqual([
|
||||
0,
|
||||
]);
|
||||
});
|
||||
|
||||
it("applies independent hard bounds to every new semantic fact family", () => {
|
||||
const ir = analyzeJavaScriptSemantics(
|
||||
`
|
||||
const captured = 1;
|
||||
function target(value, other) { return value + other + captured; }
|
||||
target(1, 2);
|
||||
target(3, 4);
|
||||
object[first];
|
||||
object[second];
|
||||
`,
|
||||
{
|
||||
maxCallSites: 1,
|
||||
maxCallArguments: 1,
|
||||
maxArgumentFlows: 0,
|
||||
maxCallReturnFlows: 0,
|
||||
maxClosureCaptures: 0,
|
||||
maxFrontiers: 1,
|
||||
},
|
||||
);
|
||||
|
||||
expect(ir.callSites).toHaveLength(1);
|
||||
expect(ir.callSites[0]?.arguments).toEqual([
|
||||
expect.objectContaining({ index: 0, spread: false }),
|
||||
]);
|
||||
expect(ir.argumentFlows).toEqual([]);
|
||||
expect(ir.callReturnFlows).toEqual([]);
|
||||
expect(ir.closureCaptures).toEqual([]);
|
||||
expect(ir.frontiers).toHaveLength(1);
|
||||
expect(ir.coverage).toMatchObject({
|
||||
status: "truncated",
|
||||
limitsReached: expect.arrayContaining([
|
||||
"maxCallSites",
|
||||
"maxCallArguments",
|
||||
"maxArgumentFlows",
|
||||
"maxCallReturnFlows",
|
||||
"maxClosureCaptures",
|
||||
"maxFrontiers",
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
it("recovers only direct return sites with literal and unknown object fields", () => {
|
||||
const ir = analyzeJavaScriptSemantics(`
|
||||
export default function parseMarkdown(value) {
|
||||
|
||||
Reference in New Issue
Block a user