From a26a1c73df019e060a5ca50208e71dad1eb55f1e Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Wed, 22 Jul 2026 21:40:23 +0000 Subject: [PATCH] feat(javascript): add local semantic call flow --- src/domain/javascriptSemanticAnalysis.ts | 7 +- .../javascriptSemanticCallResolution.ts | 229 +++++++++++ src/domain/javascriptSemanticCalls.ts | 386 ++++++++++++++++++ src/domain/javascriptSemanticIr.ts | 81 +++- src/domain/javascriptSemanticProjection.ts | 10 +- src/domain/javascriptSemanticReturns.ts | 10 + src/domain/javascriptSemanticState.ts | 1 + tests/javascriptSemanticAnalysis.test.ts | 147 +++++++ 8 files changed, 864 insertions(+), 7 deletions(-) create mode 100644 src/domain/javascriptSemanticCallResolution.ts create mode 100644 src/domain/javascriptSemanticCalls.ts diff --git a/src/domain/javascriptSemanticAnalysis.ts b/src/domain/javascriptSemanticAnalysis.ts index 15818eba2..6e39f758d 100644 --- a/src/domain/javascriptSemanticAnalysis.ts +++ b/src/domain/javascriptSemanticAnalysis.ts @@ -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, diff --git a/src/domain/javascriptSemanticCallResolution.ts b/src/domain/javascriptSemanticCallResolution.ts new file mode 100644 index 000000000..62873dc22 --- /dev/null +++ b/src/domain/javascriptSemanticCallResolution.ts @@ -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; + readonly seenBindings: ReadonlySet; + readonly depth: number; + readonly bindingCache: Map; +} + +/** Inputs needed to resolve one local call target. */ +export interface ResolveLocalCallablesInput { + readonly node: t.Node; + readonly state: JavaScriptSemanticAnalysisState; + readonly callableById: ReadonlyMap; + readonly seenBindings: ReadonlySet; + readonly depth: number; + readonly bindingCache: Map; +} + +/** 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); diff --git a/src/domain/javascriptSemanticCalls.ts b/src/domain/javascriptSemanticCalls.ts new file mode 100644 index 000000000..31c2c8aa2 --- /dev/null +++ b/src/domain/javascriptSemanticCalls.ts @@ -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; + readonly output: MutableCallAnalysis; + readonly bindingResolutionCache: Map; + 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, + 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)}`; diff --git a/src/domain/javascriptSemanticIr.ts b/src/domain/javascriptSemanticIr.ts index ed89b2cf1..7cb47e217 100644 --- a/src/domain/javascriptSemanticIr.ts +++ b/src/domain/javascriptSemanticIr.ts @@ -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.", diff --git a/src/domain/javascriptSemanticProjection.ts b/src/domain/javascriptSemanticProjection.ts index e8ed83cec..c13bfe954 100644 --- a/src/domain/javascriptSemanticProjection.ts +++ b/src/domain/javascriptSemanticProjection.ts @@ -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 => { diff --git a/src/domain/javascriptSemanticReturns.ts b/src/domain/javascriptSemanticReturns.ts index c078f4a02..017a032cd 100644 --- a/src/domain/javascriptSemanticReturns.ts +++ b/src/domain/javascriptSemanticReturns.ts @@ -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, diff --git a/src/domain/javascriptSemanticState.ts b/src/domain/javascriptSemanticState.ts index 69ad149cf..ee0a73e55 100644 --- a/src/domain/javascriptSemanticState.ts +++ b/src/domain/javascriptSemanticState.ts @@ -45,6 +45,7 @@ export interface JavaScriptSemanticAnalysisState { readonly scopeByNode: WeakMap; readonly bindingsById: Map; readonly callables: JavaScriptSemanticCallable[]; + readonly callableNodesById: Map; readonly moduleLinks: JavaScriptSemanticModuleLink[]; readonly limitsReached: Set; omittedCount: number; diff --git a/tests/javascriptSemanticAnalysis.test.ts b/tests/javascriptSemanticAnalysis.test.ts index d846b480c..e182e4d86 100644 --- a/tests/javascriptSemanticAnalysis.test.ts +++ b/tests/javascriptSemanticAnalysis.test.ts @@ -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) {