feat(javascript): add local semantic call flow

This commit is contained in:
morluto
2026-07-22 23:22:55 +00:00
parent 74d1c7b274
commit a26a1c73df
8 changed files with 864 additions and 7 deletions
+6 -1
View File
@@ -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);
+386
View File
@@ -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)}`;
+78 -3
View File
@@ -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.",
+7 -3
View File
@@ -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 => {
+10
View File
@@ -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,
+1
View File
@@ -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;
+147
View File
@@ -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) {