From 42fc6c53ae188978deb3bedfe88287b6b316ba22 Mon Sep 17 00:00:00 2001 From: Ysopwnable <161784452+N0zoM1z0@users.noreply.github.com> Date: Fri, 16 Oct 2026 10:09:22 +0800 Subject: [PATCH] fix(javascript): preserve incoming callback target uncertainty (#4243) Preserve incoming callback uncertainty and resolve proven preceding writes. Share read-position and initializer identity facts with returned-reference analysis; cache completed projected work with actual cycle dependencies. Fixes #3880 --- docs/javascript-application-workflows.md | 11 + .../javascriptSemanticCallResolution.test.ts | 233 ++++++++++++++ .../javascriptSemanticCallResolution.ts | 101 ++++-- .../javascript/javascriptSemanticCalls.ts | 9 +- .../javascriptSemanticMutationInitializers.ts | 114 +++++-- .../javascriptSemanticReturnedReferences.ts | 177 +++++++---- .../javascript/javascriptSemanticState.ts | 2 +- .../javascriptCallbackTargets.test.ts | 293 ++++++++++++++++++ 8 files changed, 822 insertions(+), 118 deletions(-) create mode 100644 src/domain/javascript/javascriptSemanticCallResolution.test.ts create mode 100644 tests/acceptance/applications/javascriptCallbackTargets.test.ts diff --git a/docs/javascript-application-workflows.md b/docs/javascript-application-workflows.md index 5e9c0adab8..67dd871011 100644 --- a/docs/javascript-application-workflows.md +++ b/docs/javascript-application-workflows.md @@ -125,6 +125,17 @@ registrations/removals and dispatch, Node timers and cancellation handles, async defaults, request construction and response consumers, parse/coercion/ validation boundaries, and built-in resource acquisition/release. +Local callback candidates do not establish a complete target set when a +conditional write may leave a parameter, catch binding, or uninitialized +variable unchanged. These calls retain candidate edges and dynamic uncertainty; +default traces remain ambiguous with partial coverage. Opt into +`include_ambiguous_dynamic_edges` to traverse the candidates. Proven preceding +unconditional assignments, including writes dominating a read in the same +conditional block, can resolve a target; later writes do not replace an +earlier read or captured alias. Unproved ordering across branches or function +activations remains conservative. Returned-reference effects use the same +read-position facts as call resolution. + Mutation tracking covers explicit member assignments, updates, deletion, and loop assignment targets, including the supported local aliases and shared nested references. Calls conservatively invalidate exposed mutable references; diff --git a/src/domain/javascript/javascriptSemanticCallResolution.test.ts b/src/domain/javascript/javascriptSemanticCallResolution.test.ts new file mode 100644 index 0000000000..fa953ce49f --- /dev/null +++ b/src/domain/javascript/javascriptSemanticCallResolution.test.ts @@ -0,0 +1,233 @@ +import * as t from "@babel/types"; +import { expect, it, vi } from "vitest"; + +import { indexJavaScriptBindingsSteps } from "./javascriptSemanticAnalysis.js"; +import { + resolveLocalCallables, + type LocalCallableResolution, +} from "./javascriptSemanticCallResolution.js"; +import { parseJavaScriptSource } from "./javascriptSourceParser.js"; +import { createSemanticReturnedReferences } from "./javascriptSemanticReturnedReferences.js"; +import { semanticBindingAtRead } from "./javascriptSemanticMutationInitializers.js"; +import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js"; + +const indexed = (source: string) => { + const parsed = parseJavaScriptSource(source, "callback.js"); + if (parsed === null) throw new Error("Expected valid callback fixture"); + const steps = indexJavaScriptBindingsSteps(parsed.program); + let step = steps.next(); + while (step.done !== true) step = steps.next(); + const state = step.value; + const callableById = new Map( + state.callables.map((callable) => [callable.callableId, callable]), + ); + const bindingCache = new WeakMap(); + return { + state, + callableById, + bindingCache, + resolve: (node: t.Node) => + resolveLocalCallables({ + node, + state, + callableById, + bindingCache, + seenInitializers: new Set(), + }), + }; +}; + +it("bounds shared partial callback alias expansion by AST size", () => { + const measure = (depth: number) => { + const context = + indexed(`function fallback(){return {member:1};} function choose(cb,flag){ + if(flag)cb=fallback; + const a0=cb; + ${Array.from({ length: depth }, (_, i) => `const a${i + 1}=flag?a${i}:a${i};`).join("")} + return a${depth}(); + }`); + const choose = context.state.program.body[1]; + if (!t.isFunctionDeclaration(choose)) throw new Error("Missing choose"); + const last = choose.body.body.at(-1); + if (!t.isReturnStatement(last) || !t.isCallExpression(last.argument)) + throw new Error("Missing call"); + const reads = vi.spyOn(context.bindingCache, "get"); + const result = context.resolve(last.argument.callee); + expect(result).toMatchObject({ complete: false }); + expect(result.callableIds).toHaveLength(1); + const callReads = reads.mock.calls.length; + const lookups = context.state.scopes.map((scope) => + vi.spyOn(scope.bindings, "get"), + ); + try { + expect( + createSemanticReturnedReferences(context.state)(last.argument.callee), + ).toHaveLength(1); + return { + callReads, + referenceReads: lookups.reduce( + (total, lookup) => total + lookup.mock.calls.length, + 0, + ), + }; + } finally { + for (const lookup of lookups) lookup.mockRestore(); + } + }; + const small = measure(8); + const large = measure(12); + expect(small.callReads).toBeGreaterThan(0); + expect(large.callReads).toBeLessThan(3 * small.callReads); + expect(small.referenceReads).toBeGreaterThan(0); + expect(large.referenceReads).toBeLessThan(3 * small.referenceReads); +}); + +it.each([ + ["if(flag){cb=fallback;cb();}", true], + ["if(flag)cb=fallback;cb();", false], + ["cb ||= fallback;cb();", false], +] as const)( + "reports initializer coverage at the read: %s", + (body, expected) => { + const { state } = indexed(`function choose(cb,flag){${body}}`); + const binding = [...state.bindingsById.values()].find( + (binding) => binding.name === "cb", + ); + if (binding === undefined) throw new Error("Missing cb binding"); + let reads = 0; + traverseJavaScriptAst(state.program, { + enter(node) { + if ( + t.isCallExpression(node) && + t.isIdentifier(node.callee, { name: "cb" }) + ) { + reads++; + expect( + semanticBindingAtRead(binding, node.callee, state.parentsByNode) + .hasDefiniteInitializer, + ).toBe(expected); + } + }, + }); + expect(reads).toBe(1); + }, +); + +it("does not reuse a later resolved callback at an earlier read", () => { + const context = indexed( + `function fallback(){} function choose(cb){cb();cb=fallback;cb();}`, + ); + const choose = context.state.program.body[1]; + if (!t.isFunctionDeclaration(choose)) throw new Error("Missing choose"); + const calls = choose.body.body.flatMap((statement) => + t.isExpressionStatement(statement) && + t.isCallExpression(statement.expression) + ? [statement.expression.callee] + : [], + ); + const [before, after] = calls; + if (before === undefined || after === undefined) + throw new Error("Missing callback reads"); + expect(context.resolve(after)).toMatchObject({ complete: true }); + expect(context.resolve(before)).toMatchObject({ + complete: false, + callableIds: [], + }); + expect(context.resolve(after)).toMatchObject({ complete: true }); +}); + +it.each([false, true])( + "bounds separate branch assignments with intrinsic cycle=%s", + (cyclic) => { + const measure = (depth: number) => { + const context = indexed(`function fallback(){return {};} +function choose(flag){let a0=${cyclic ? "flag?a0:{make:fallback}" : "{make:fallback}"}; +${Array.from({ length: depth }, (_, i) => `let a${i + 1};if(flag)a${i + 1}=a${i};else a${i + 1}=a${i};`).join("")} +return a${depth}.make();}`); + const choose = context.state.program.body[1]; + if (!t.isFunctionDeclaration(choose)) throw new Error("Missing choose"); + const last = choose.body.body.at(-1); + if (!t.isReturnStatement(last) || !t.isCallExpression(last.argument)) + throw new Error("Missing call"); + const lookups = context.state.scopes.map((scope) => + vi.spyOn(scope.bindings, "get"), + ); + try { + expect( + createSemanticReturnedReferences(context.state)(last.argument.callee), + ).toHaveLength(1); + return lookups.reduce( + (total, lookup) => total + lookup.mock.calls.length, + 0, + ); + } finally { + for (const lookup of lookups) lookup.mockRestore(); + } + }; + const small = measure(8), + large = measure(12); + expect(small).toBeGreaterThan(0); + expect(large).toBeLessThan(3 * small); + }, +); + +it.each(["||=", "??="])( + "retains definite assignment to an empty local: %s", + (operator) => { + const { state } = indexed( + `function choose(){let cb;cb ${operator} fallback;cb();}`, + ); + const binding = [...state.bindingsById.values()].find( + (binding) => binding.name === "cb", + ); + if (binding === undefined) throw new Error("Missing cb binding"); + let reads = 0; + traverseJavaScriptAst(state.program, { + enter(node) { + if ( + t.isCallExpression(node) && + t.isIdentifier(node.callee, { name: "cb" }) + ) { + reads++; + expect( + semanticBindingAtRead(binding, node.callee, state.parentsByNode) + .hasDefiniteInitializer, + ).toBe(true); + } + }, + }); + expect(reads).toBe(1); + }, +); + +it("does not memoize alias cycle cuts across entry paths", () => { + const context = indexed( + `function fallback(){} let a,b; function setA(){a=flag?b:fallback;} function setB(){b=a;} a(); b();`, + ); + const callees = context.state.program.body.flatMap((statement) => + t.isExpressionStatement(statement) && + t.isCallExpression(statement.expression) + ? [statement.expression.callee] + : [], + ); + const fallback = context.state.callables.find( + (callable) => callable.name === "fallback", + ); + if (fallback === undefined) throw new Error("Missing fallback"); + for (const node of [...callees, ...callees].reverse()) { + const warm = context.resolve(node); + const cold = resolveLocalCallables({ + node, + state: context.state, + callableById: context.callableById, + bindingCache: new WeakMap(), + seenInitializers: new Set(), + }); + expect(warm).toEqual(cold); + expect(warm).toMatchObject({ + complete: false, + cycleDependent: true, + callableIds: [fallback.callableId], + }); + } +}); diff --git a/src/domain/javascript/javascriptSemanticCallResolution.ts b/src/domain/javascript/javascriptSemanticCallResolution.ts index 3ed09dca0c..c034751570 100644 --- a/src/domain/javascript/javascriptSemanticCallResolution.ts +++ b/src/domain/javascript/javascriptSemanticCallResolution.ts @@ -6,14 +6,17 @@ import type { } from "./javascriptSemanticIr.js"; import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js"; import { semanticCallableIdForNode } from "./javascriptSemanticProjection.js"; +import { semanticBindingAtRead } from "./javascriptSemanticMutationInitializers.js"; import { unwrapJavaScriptExpression, runtimeFunctionParameters, } from "./javascriptAstValues.js"; import { resolveSemanticBindingState, + semanticStableInitializer, type JavaScriptSemanticAnalysisState, type JavaScriptSemanticBindingState, + type JavaScriptSemanticInitializer, } from "./javascriptSemanticState.js"; import { range } from "./javascriptStaticAnalysisHelpers.js"; @@ -22,13 +25,15 @@ export interface LocalCallableResolution { readonly callableIds: readonly string[]; readonly complete: boolean; readonly reason: string; + /** A cycle cut depends on the active alias path and cannot be memoized. */ + readonly cycleDependent?: true; } interface CallResolutionContext { readonly state: JavaScriptSemanticAnalysisState; readonly callableById: ReadonlyMap; - readonly seenBindings: ReadonlySet; - readonly bindingCache: Map; + readonly seenInitializers: ReadonlySet; + readonly bindingCache: WeakMap; } /** Inputs needed to resolve one local call target. */ @@ -36,8 +41,8 @@ export interface ResolveLocalCallablesInput { readonly node: t.Node; readonly state: JavaScriptSemanticAnalysisState; readonly callableById: ReadonlyMap; - readonly seenBindings: ReadonlySet; - readonly bindingCache: Map; + readonly seenInitializers: ReadonlySet; + readonly bindingCache: WeakMap; } /** Resolve local callable candidates without following dynamic properties. */ @@ -47,7 +52,7 @@ export const resolveLocalCallables = ( resolveCallables(input.node, { state: input.state, callableById: input.callableById, - seenBindings: input.seenBindings, + seenInitializers: input.seenInitializers, bindingCache: input.bindingCache, }); @@ -84,14 +89,39 @@ const resolveIdentifier = ( node: t.Identifier, context: CallResolutionContext, ): LocalCallableResolution => { + const cached = context.bindingCache.get(node); + if (cached !== undefined) return cached; const binding = resolveSemanticBindingState(context.state, node, node.name); - return binding === undefined - ? { - callableIds: [], - complete: false, - reason: `Unresolved call target ${node.name}.`, - } - : resolveBindingCallables(binding, context); + if (binding === undefined) + return { + callableIds: [], + complete: false, + reason: `Unresolved call target ${node.name}.`, + }; + const read = semanticBindingAtRead( + binding, + node, + context.state.parentsByNode, + ); + const candidates = resolveBindingCallables(read.binding, context); + // Knowing every local candidate does not prove that any write replaced the + // incoming value, or that a write in another activation precedes this read. + const covered = + read.hasDefiniteInitializer || + semanticStableInitializer(binding, context.state) !== undefined; + const resolution = + candidates.complete && !covered + ? { + ...candidates, + complete: false, + reason: `Call target ${binding.name} may retain an incoming value or an unordered assignment.`, + } + : candidates; + // Key by the immutable read, not binding identity: before/after snapshots + // differ. Cache cycle-independent partial results too, keeping alias + // diamonds linear even when their shared leaf has an unknown incoming value. + if (!resolution.cycleDependent) context.bindingCache.set(node, resolution); + return resolution; }; const resolveAlternatives = ( @@ -108,6 +138,9 @@ const resolveAlternatives = ( ), complete: candidates.every(({ complete }) => complete), reason: "Conditional or logical call target has multiple candidates.", + ...(candidates.some((candidate) => candidate.cycleDependent) + ? { cycleDependent: true as const } + : {}), }; }; @@ -115,28 +148,31 @@ 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 = { - ...context, - seenBindings: new Set([...context.seenBindings, binding.bindingId]), - }; - const candidates = binding.initializers.map(({ node, projection }) => - projection.length === 0 - ? resolveCallables(node, candidateContext) - : { + const candidates = binding.initializers.map( + (initializer): LocalCallableResolution => { + if (context.seenInitializers.has(initializer)) + return { callableIds: [], complete: false, - reason: `Projected call target ${binding.name} is unresolved.`, - }, + cycleDependent: true, + reason: `Call target initializer cycle at ${binding.name}.`, + }; + return initializer.projection.length === 0 + ? resolveCallables(initializer.node, { + ...context, + seenInitializers: new Set([ + ...context.seenInitializers, + initializer, + ]), + }) + : { + callableIds: [], + complete: false, + reason: `Projected call target ${binding.name} is unresolved.`, + }; + }, ); const callableIds = uniqueCallableIds( candidates.flatMap((candidate) => candidate.callableIds), @@ -148,9 +184,10 @@ const resolveBindingCallables = ( callableIds.length === 1 && candidates.every(({ complete }) => complete) ? "Unique local callable target." : `Call target ${binding.name} has ambiguous or unsupported assignments.`, + ...(candidates.some((candidate) => candidate.cycleDependent) + ? { cycleDependent: true as const } + : {}), }; - if (resolution.complete) - context.bindingCache.set(binding.bindingId, resolution); return resolution; }; diff --git a/src/domain/javascript/javascriptSemanticCalls.ts b/src/domain/javascript/javascriptSemanticCalls.ts index fc0816635c..488e57eae4 100644 --- a/src/domain/javascript/javascriptSemanticCalls.ts +++ b/src/domain/javascript/javascriptSemanticCalls.ts @@ -50,7 +50,10 @@ interface CallCollectionContext { readonly state: JavaScriptSemanticAnalysisState; readonly callableById: ReadonlyMap; readonly output: MutableCallAnalysis; - readonly bindingResolutionCache: Map; + readonly bindingResolutionCache: WeakMap< + t.Identifier, + LocalCallableResolution + >; readonly parameterBindingCache: Map< string, readonly JavaScriptSemanticBindingState[] @@ -79,7 +82,7 @@ export const collectJavaScriptSemanticCalls = ( state, callableById, output, - bindingResolutionCache: new Map(), + bindingResolutionCache: new WeakMap(), parameterBindingCache: new Map(), ancestors: [], }; @@ -127,7 +130,7 @@ const collectCallSite = ( node: node.callee, state, callableById, - seenBindings: new Set(), + seenInitializers: new Set(), bindingCache: context.bindingResolutionCache, }); const callSiteId = semanticCallSiteId(node); diff --git a/src/domain/javascript/javascriptSemanticMutationInitializers.ts b/src/domain/javascript/javascriptSemanticMutationInitializers.ts index 7c25046bd6..7adc626f8e 100644 --- a/src/domain/javascript/javascriptSemanticMutationInitializers.ts +++ b/src/domain/javascript/javascriptSemanticMutationInitializers.ts @@ -558,6 +558,7 @@ export const semanticMutationInitializers = ( return binding; const { retained, replacesEntry } = retainedInitializerOrigins( origins, + binding, parents, ); return { @@ -629,6 +630,8 @@ const bindingReadPosition = ( /** Select a local alias at its read, before any later straight-line reassignment. */ type SemanticBindingRead = { readonly status: "ordered" | "unresolved"; + /** A retained initializer executes on paths reaching this particular read. */ + readonly hasDefiniteInitializer: boolean; /** Unresolved reads retain all possible origins for conservative alias effects. */ readonly binding: JavaScriptSemanticBindingState; }; @@ -642,9 +645,10 @@ export const semanticBindingAtRead = ( binding.initializers.length === 0 && binding.referenceInitializers.length === 0 ) - return { status: "ordered", binding }; + return { status: "ordered", binding, hasDefiniteInitializer: false }; const initializer = binding.initializers[0]; const reference = binding.referenceInitializers[0]; + const origin = initializer ?? reference; const sourcePosition = initializer === undefined ? reference === undefined @@ -652,28 +656,55 @@ export const semanticBindingAtRead = ( : initializerPosition(reference, parents, true) : initializerPosition(initializer, parents); const { position, repeating } = bindingReadPosition(read, parents); + // Coverage and latest-value selection are independent. A preceding write + // can cover entry even when another write's order remains unknown. + const hasDefiniteInitializer = + position !== null && + binding.initializers.some((initializer) => { + const source = initializerPosition(initializer, parents); + return ( + source !== null && + positionPrecedes(source, position) && + !initializerMayRetainEntry(initializer, binding, parents) + ); + }); if ( !repeating && + origin !== undefined && binding.initializers.length + binding.referenceInitializers.length === 1 && sourcePosition !== null && position !== null && positionPrecedes(sourcePosition, position) ) - return { status: "ordered", binding }; + return { + status: "ordered", + binding, + hasDefiniteInitializer, + }; const uncertainOrder = repeating || sourcePosition === null || position === null || sourcePosition.body !== position.body; - if (uncertainOrder) return { status: "unresolved", binding }; + if (uncertainOrder) + return { status: "unresolved", binding, hasDefiniteInitializer }; const selected = selectInitializers(binding, position, read, parents); - if (selected === undefined) return { status: "unresolved", binding }; - if (selected === binding) return { status: "ordered", binding }; + if (selected === undefined) + return { status: "unresolved", binding, hasDefiniteInitializer }; + if (selected.binding === binding) + return { + status: "ordered", + binding, + hasDefiniteInitializer: + hasDefiniteInitializer || selected.hasDefiniteInitializer, + }; return { status: "ordered", + hasDefiniteInitializer: + hasDefiniteInitializer || selected.hasDefiniteInitializer, binding: { ...binding, - ...selected, + ...selected.binding, // Later assignment definitions do not describe the value captured here. definitions: binding.definitions.filter( ({ kind }) => kind !== "assignment", @@ -688,10 +719,13 @@ const selectInitializers = ( read: t.Node, parents: WeakMap, ): - | Pick< - JavaScriptSemanticBindingState, - "initializers" | "referenceInitializers" - > + | { + readonly binding: Pick< + JavaScriptSemanticBindingState, + "initializers" | "referenceInitializers" + >; + readonly hasDefiniteInitializer: boolean; + } | undefined => { if (position === null) return undefined; const origins = [ @@ -725,7 +759,11 @@ const selectInitializers = ( if (order === "before") preceding.push(origin); } } - const { retained } = retainedInitializerOrigins(preceding, parents); + const { retained, replacesEntry } = retainedInitializerOrigins( + preceding, + binding, + parents, + ); // Preserve the owner's identity when selection removes no facts. Consumers // can then reuse the binding's dependency-aware provenance memo rather than // treating every equivalent read as a new temporal snapshot. @@ -735,32 +773,53 @@ const selectInitializers = ( retained.has(initializer), ) ) - return binding; + return { binding, hasDefiniteInitializer: replacesEntry }; return { - initializers: binding.initializers.filter((initializer) => - retained.has(initializer), - ), - referenceInitializers: binding.referenceInitializers.filter((initializer) => - retained.has(initializer), - ), + hasDefiniteInitializer: replacesEntry, + binding: { + initializers: binding.initializers.filter((initializer) => + retained.has(initializer), + ), + referenceInitializers: binding.referenceInitializers.filter( + (initializer) => retained.has(initializer), + ), + }, }; }; -/** Logical writes retain the preceding value as a possible alias. */ -const logicalInitializer = ( - node: t.Node, +/** Logical writes may keep prior aliases. An RHS fact alone does not prove an + * empty local: the complete binding must exclude incoming/default/other writes. + */ +const initializerMayRetainEntry = ( + initializer: JavaScriptSemanticBindingState["initializers"][number], + binding: JavaScriptSemanticBindingState, parents: WeakMap, ): boolean => { - const parent = parents.get(node); + const node = initializer.node, + parent = parents.get(node); const assignment = t.isAssignmentExpression(node) ? node : t.isAssignmentExpression(parent) && parent.right === node ? parent : undefined; - return ( - t.isAssignmentExpression(assignment) && - ["&&=", "||=", "??="].includes(assignment.operator) - ); + if ( + assignment === undefined || + !["&&=", "||=", "??="].includes(assignment.operator) + ) + return false; + const emptyLocal = + node === assignment.right && + binding.kind === "variable" && + !binding.runtimeImport && + binding.initializers.length === 1 && + binding.initializers[0] === initializer && + binding.referenceInitializers.length === 0 && + binding.directOrigins.length === 0 && + binding.definitions.some(({ kind }) => kind === "variable") && + binding.definitions.every( + ({ kind }) => kind === "variable" || kind === "assignment", + ); + return !emptyLocal; }; /** Certain writes replace prior origins; short-circuit writes retain both. */ @@ -769,12 +828,13 @@ const retainedInitializerOrigins = ( readonly initializer: JavaScriptSemanticBindingState["initializers"][number]; readonly position: StatementPosition | null; }[], + binding: JavaScriptSemanticBindingState, parents: WeakMap, ) => { const latest = origins.reduce( (latest, { initializer, position }) => position !== null && - !logicalInitializer(initializer.node, parents) && + !initializerMayRetainEntry(initializer, binding, parents) && (latest === null || positionPrecedes(latest, position)) ? position : latest, diff --git a/src/domain/javascript/javascriptSemanticReturnedReferences.ts b/src/domain/javascript/javascriptSemanticReturnedReferences.ts index bdaf2af589..fd3fa4fa40 100644 --- a/src/domain/javascript/javascriptSemanticReturnedReferences.ts +++ b/src/domain/javascript/javascriptSemanticReturnedReferences.ts @@ -4,11 +4,8 @@ import { semanticStaticPropertyKey, unwrapJavaScriptExpression, } from "./javascriptAstValues.js"; -import { - resolveLocalCallables, - type LocalCallableResolution, -} from "./javascriptSemanticCallResolution.js"; import { semanticCallableResultExpressions } from "./javascriptSemanticCallableResults.js"; +import { semanticBindingAtRead } from "./javascriptSemanticMutationInitializers.js"; import { semanticArrayIndex, type JavaScriptSemanticPropertyPath, @@ -16,6 +13,7 @@ import { import { resolveSemanticBindingState, type JavaScriptSemanticAnalysisState, + type JavaScriptSemanticInitializer, } from "./javascriptSemanticState.js"; /** A reference potentially handed to a caller or generator consumer. */ @@ -27,9 +25,73 @@ export interface SemanticReturnedReference { interface Candidate { readonly node: t.Node; readonly path: readonly (string | number | null)[]; - readonly bindings: ReadonlySet; + readonly initializers: ReadonlySet; } +type InitializerContext = ReadonlySet; +type CompletedExpansions = WeakMap< + t.Node, + Map +>; + +interface ExpansionFrame { + readonly item: Candidate; + readonly node: t.Node; + readonly pathKey: string; + readonly parent: ExpansionFrame | null; + readonly blockers: Set; +} + +type ExpansionTask = + | { + readonly kind: "visit"; + readonly item: Candidate; + readonly parent: ExpansionFrame | null; + } + | { readonly kind: "complete"; readonly frame: ExpansionFrame }; + +const contextContains = ( + context: InitializerContext, + required: InitializerContext, +): boolean => { + for (const initializer of required) + if (!context.has(initializer)) return false; + return true; +}; + +const propagateBlockers = ( + blockers: InitializerContext, + parent: ExpansionFrame | null, +): void => { + if (parent === null) return; + for (const initializer of blockers) + if (parent.item.initializers.has(initializer)) + parent.blockers.add(initializer); +}; + +const completeExpansion = ( + frame: ExpansionFrame, + expanded: CompletedExpansions, +): void => { + const paths = + expanded.get(frame.node) ?? + new Map(); + const contexts = paths.get(frame.pathKey) ?? []; + const covered = contexts.find((context) => + contextContains(frame.blockers, context), + ); + if (covered === undefined) { + paths.set(frame.pathKey, [ + ...contexts.filter( + (context) => !contextContains(context, frame.blockers), + ), + frame.blockers, + ]); + expanded.set(frame.node, paths); + } + propagateBlockers(covered ?? frame.blockers, frame.parent); +}; + interface ObjectTargetIndex { readonly propertyByName: ReadonlyMap< string, @@ -50,73 +112,73 @@ interface ObjectTargetUnknown { export const createSemanticReturnedReferences = ( state: JavaScriptSemanticAnalysisState, ): ((callee: t.Node) => readonly SemanticReturnedReference[]) => { - const callableById = new Map( - state.callables.map((callable) => [callable.callableId, callable]), - ); - const bindingCache = new Map(); const results = new WeakMap(); const objectTargets = new WeakMap(); return (callee) => { const existing = results.get(callee); if (existing !== undefined) return existing; const functions = new Set(); - const resolution = resolveLocalCallables({ - node: callee, - state, - callableById, - bindingCache, - seenBindings: new Set(), - }); - for (const id of resolution.callableIds) { - const node = state.callableNodesById.get(id); - if (node !== undefined) functions.add(node); - } - const pending: Candidate[] = [ - { node: callee, path: [], bindings: new Set() }, + const work: ExpansionTask[] = [ + { + kind: "visit", + item: { node: callee, path: [], initializers: new Set() }, + parent: null, + }, ]; - // A shared initializer can be reached through both arms of every alias - // diamond. Expand an expression/path again only when its cycle context - // permits a binding that an earlier traversal blocked. - const expanded = new WeakMap< - t.Node, - Map[]> - >(); - for (let cursor = 0; cursor < pending.length; cursor++) { - const item = pending[cursor]; - if (item === undefined) break; + // Finish descendants before memoizing an expansion. Only actual external + // cycle blockers affect reuse; branch history is not a dependency. An + // acyclic expression/path is consequently expanded once across a diamond. + const expanded: CompletedExpansions = new WeakMap(); + while (work.length > 0) { + const task = work.pop(); + if (task === undefined) break; + if (task.kind === "complete") { + completeExpansion(task.frame, expanded); + continue; + } + const { item, parent } = task; const node = unwrapJavaScriptExpression(item.node).node; const pathKey = JSON.stringify(item.path); - const paths = - expanded.get(node) ?? new Map[]>(); - const contexts = paths.get(pathKey) ?? []; - if ( - contexts.some((context) => - [...context].every((binding) => item.bindings.has(binding)), - ) - ) + const covered = expanded + .get(node) + ?.get(pathKey) + ?.find((context) => contextContains(item.initializers, context)); + if (covered !== undefined) { + // Cached cycle dependencies must reach the caller's proof too. + propagateBlockers(covered, parent); continue; - paths.set(pathKey, [ - ...contexts.filter( - (context) => - ![...item.bindings].every((binding) => context.has(binding)), - ), - item.bindings, - ]); - expanded.set(node, paths); + } + const frame: ExpansionFrame = { + item, + node, + pathKey, + parent, + blockers: new Set(), + }; + work.push({ kind: "complete", frame }); + const pending: Candidate[] = []; if (t.isIdentifier(node)) { const binding = resolveSemanticBindingState(state, node, node.name); - if (binding === undefined || item.bindings.has(binding.bindingId)) - continue; - const bindings = new Set([...item.bindings, binding.bindingId]); + if (binding === undefined) continue; + const selected = semanticBindingAtRead( + binding, + node, + state.parentsByNode, + ).binding; for (const initializer of [ - ...binding.initializers, - ...binding.referenceInitializers, - ]) + ...selected.initializers, + ...selected.referenceInitializers, + ]) { + if (item.initializers.has(initializer)) { + frame.blockers.add(initializer); + continue; + } pending.push({ node: initializer.node, path: [...initializer.projection, ...item.path], - bindings, + initializers: new Set([...item.initializers, initializer]), }); + } } else if ( t.isMemberExpression(node) || t.isOptionalMemberExpression(node) @@ -203,6 +265,11 @@ export const createSemanticReturnedReferences = ( if (node.superClass !== null && node.superClass !== undefined) pending.push({ ...item, node: node.superClass }); } + for (let index = pending.length - 1; index >= 0; index--) { + const child = pending[index]; + if (child !== undefined) + work.push({ kind: "visit", item: child, parent: frame }); + } } const output = [...functions].flatMap((node) => { const callables = t.isClass(node) diff --git a/src/domain/javascript/javascriptSemanticState.ts b/src/domain/javascript/javascriptSemanticState.ts index 585541e82b..38b80422f9 100644 --- a/src/domain/javascript/javascriptSemanticState.ts +++ b/src/domain/javascript/javascriptSemanticState.ts @@ -24,7 +24,7 @@ import type { } from "./javascriptSemanticValueTypes.js"; /** Internal initializer plus a destructuring/member projection. */ -interface JavaScriptSemanticInitializer { +export interface JavaScriptSemanticInitializer { readonly node: t.Node; readonly projection: readonly (string | number | null)[]; readonly entryBody?: t.BlockStatement; diff --git a/tests/acceptance/applications/javascriptCallbackTargets.test.ts b/tests/acceptance/applications/javascriptCallbackTargets.test.ts new file mode 100644 index 0000000000..3a0229ffbd --- /dev/null +++ b/tests/acceptance/applications/javascriptCallbackTargets.test.ts @@ -0,0 +1,293 @@ +import { execFile } from "node:child_process"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { expect } from "vitest"; + +import { toolContract } from "../../../src/contracts/toolContracts.js"; +import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js"; +import { javaScriptSemanticTraceResultSchema } from "../../../src/domain/javascript/javascriptSemanticTraceSchemas.js"; +import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js"; +import { verifyJavaScriptReturnShapes } from "../../fixtures/javascriptReturnShapes.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +const createCallbackFixture = async () => { + const root = await createTestTempDirectory("rea-callback-targets-"); + const file = join(root, "app.mjs"); + await writeFile( + file, + ` +function fallback() { return "local"; } +function original() { return "original"; } +export function choose(cb, flag) { if (flag) cb=fallback; return cb(); } +export function logical(cb, flag) { flag && (cb=fallback); return cb(); } +export function definite(cb) { cb=fallback; return cb(); } +export function beforeAfter(cb) { const before=cb(); cb=fallback; return [before,cb()]; } +export function captured(cb) { const saved=cb; cb=fallback; return saved(); } +export function delayed(cb) { function replace() { cb=fallback; } return cb(); } +export function caught(cb,flag) { try { throw cb; } catch (cb) { if(flag)cb=fallback; return cb(); } } +export function defaulted(cb=original,flag) { if(flag)cb=fallback; return cb(); } +export function local(flag) { let cb=original; if(flag)cb=fallback; return cb(); } +export function replaced() { let cb=original; cb=fallback; return cb(); } +export function guarded(cb,flag) { if(flag){cb=fallback;return cb();}return "skipped"; } +export function guardedVar(flag) { if(flag){var cb=fallback;return cb();}return "skipped"; } +export function logicalAssign(cb) { cb ||= fallback;return cb(); } +export function restored() { let cb=fallback;const saved=cb;cb=saved;return cb(); } +export function emptyOr() { let cb;cb ||= fallback;return cb(); } +export function emptyNullish() { let cb;cb ??= fallback;return cb(); } +export function knownLocal(flag) { let cb=fallback;if(flag)cb=fallback;return cb(); } +export function finallyDefinite(cb,flag) { if(flag)cb=fallback;cb=fallback;return cb(); } +`, + ); + const oracle = await promisify(execFile)(process.execPath, [ + "--input-type=module", + "-e", + ` +import {pathToFileURL} from "node:url"; +const m=await import(pathToFileURL(process.argv[1])); +const cb=()=>"incoming"; +console.log(JSON.stringify([ +m.choose(cb,false),m.choose(cb,true),m.logical(cb,false),m.definite(cb), +m.beforeAfter(cb),m.captured(cb),m.delayed(cb),m.caught(cb,false), +m.defaulted(cb,false),m.defaulted(undefined,false),m.local(false),m.local(true),m.replaced(), +m.guarded(cb,false),m.guarded(cb,true),m.guardedVar(false),m.guardedVar(true),m.logicalAssign(cb),m.restored(),m.emptyOr(),m.emptyNullish(),m.knownLocal(false),m.knownLocal(true),m.finallyDefinite(cb,false) +]));`, + file, + ]); + expect(JSON.parse(oracle.stdout)).toEqual([ + "incoming", + "local", + "incoming", + "local", + ["incoming", "local"], + "incoming", + "incoming", + "incoming", + "incoming", + "original", + "original", + "local", + "local", + "skipped", + "local", + "skipped", + "local", + "incoming", + "local", + "local", + "local", + "local", + "local", + "local", + ]); + return root; +}; + +const verifyCallbackGraph = ( + graph: ReturnType< + typeof javascriptApplicationAnalysisResultSchema.parse + >["semantic_graph"], +) => { + const nodes = new Map(graph.nodes.map((node) => [node.node_id, node])); + const calls = (name: string) => + graph.nodes + .filter( + (node) => + node.kind === "call-site" && + node.function_node_id !== null && + nodes.get(node.function_node_id)?.properties.name === name, + ) + .sort( + (a, b) => + (a.identity.source_range?.start.column ?? 0) - + (b.identity.source_range?.start.column ?? 0), + ); + const targets = (nodeId: string) => + graph.relations + .filter( + (relation) => + relation.relation === "calls" && relation.source_node_id === nodeId, + ) + .map((relation) => ({ + target: nodes.get(relation.target_node_id)?.label, + resolution: relation.resolution, + })) + .sort((a, b) => String(a.target).localeCompare(String(b.target))); + for (const name of ["choose", "logical", "delayed", "caught", "defaulted"]) { + const call = calls(name)[0]; + if (call === undefined) throw new Error(`Missing ${name} call`); + expect(targets(call.node_id), name).toContainEqual({ + target: "fallback", + resolution: "candidate", + }); + expect( + targets(call.node_id).every((target) => target.resolution !== "resolved"), + name, + ).toBe(true); + } + for (const name of [ + "definite", + "replaced", + "guarded", + "guardedVar", + "restored", + "emptyOr", + "emptyNullish", + "knownLocal", + "finallyDefinite", + ]) { + const call = calls(name)[0]; + if (call === undefined) throw new Error(`Missing ${name} call`); + expect(targets(call.node_id), name).toEqual([ + { target: "fallback", resolution: "resolved" }, + ]); + } + expect(targets(calls("logicalAssign")[0]!.node_id)).toEqual([]); + expect(calls("beforeAfter")).toHaveLength(2); + expect(targets(calls("beforeAfter")[0]!.node_id)).toEqual([]); + expect(targets(calls("beforeAfter")[1]!.node_id)).toEqual([ + { target: "fallback", resolution: "resolved" }, + ]); + expect(targets(calls("captured")[0]!.node_id)).toEqual([]); + expect(targets(calls("local")[0]!.node_id)).toEqual([ + { target: "fallback", resolution: "candidate" }, + { target: "original", resolution: "candidate" }, + ]); + + return calls; +}; + +cliTest( + "preserves incoming callback uncertainty through CLI and retained MCP traces", + async ({ cli }) => { + const root = await createCallbackFixture(); + const analyzed = await cli.run({ + arguments: [ + "analyze-javascript-application", + root, + "--artifact-format", + "directory", + "--json", + ], + }); + expect(analyzed.exitCode, analyzed.stderr).toBe(0); + const contract = toolContract("analyze_javascript_application"); + const evidence = contract.outputSchema.parse(analyzed.json); + const graph = javascriptApplicationAnalysisResultSchema.parse( + evidence.normalized_result, + ).semantic_graph; + const calls = verifyCallbackGraph(graph); + + const { client, call } = await connectLocalToolsMcp(); + const retainedResponse = await call("analyze_javascript_application", { + input_path: root, + format: "directory", + detail: "summary", + }); + expect(retainedResponse.isError).not.toBe(true); + expect(retainedResponse.structuredContent).toMatchObject({ + normalized_result: { parent_evidence_id: evidence.evidence_id }, + }); + // Summary replies keep the complete Evidence available for subsequent queries. + const retainedId = evidence.evidence_id; + const selected = calls("choose")[0]!; + const query = { + seed: { kind: "semantic-node" as const, node_id: selected.node_id }, + direction: "forward-influence" as const, + allowed_relations: ["calls" as const], + }; + const traced = await call("trace_javascript_semantics", { + application: { kind: "retained-evidence", evidence_id: retainedId }, + query, + }); + expect(traced.isError).not.toBe(true); + const traceEvidence = toolContract( + "trace_javascript_semantics", + ).outputSchema.parse(traced.structuredContent); + const trace = javaScriptSemanticTraceResultSchema.parse( + traceEvidence.normalized_result, + ); + expect(trace.status).toBe("ambiguous"); + expect(trace.relations).toEqual([]); + expect(trace.coverage.status).toBe("partial"); + expect(graph.unknowns.length).toBeGreaterThan(0); + expect(trace.source_graph_id).toBe(graph.graph_id); + expect(trace.evidence_contexts.length).toBeGreaterThan(0); + const request = join(root, "trace.json"); + await writeFile(request, JSON.stringify({ application: evidence, query })); + const cliTrace = await cli.run({ + arguments: ["trace-javascript-semantics", request, "--json"], + }); + expect(cliTrace.exitCode, cliTrace.stderr).toBe(0); + expect( + toolContract("trace_javascript_semantics").outputSchema.parse( + cliTrace.json, + ).evidence_id, + ).toBe(traceEvidence.evidence_id); + const candidates = await call("trace_javascript_semantics", { + application: { kind: "retained-evidence", evidence_id: retainedId }, + query: { ...query, include_ambiguous_dynamic_edges: true }, + }); + expect(candidates.isError).not.toBe(true); + const candidateTrace = javaScriptSemanticTraceResultSchema.parse( + toolContract("trace_javascript_semantics").outputSchema.parse( + candidates.structuredContent, + ).normalized_result, + ); + expect(candidateTrace.status).toBe("ambiguous"); + expect(candidateTrace.relations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ relation: "calls", resolution: "candidate" }), + ]), + ); + await client.ping(); + }, +); + +cliTest( + "applies returned-reference effects to the callback selected at the call", + async ({ cli }) => { + const source = ` +const original={value:1},replacement={value:2}; +function first(){return original;} +function second(){return replacement;} +export function inspect(){let cb=first;const result=cb();cb=second;result.value=3;return {original:original.value,replacement:replacement.value};} +const restoredObject={value:1}; +function create(){return restoredObject;} +export function restored(){let cb=create;const saved=cb;cb=saved;cb().value=3;return {value:restoredObject.value};} +`; + const oracle = await promisify(execFile)(process.execPath, [ + "--input-type=module", + "-e", + source + "console.log(JSON.stringify([inspect(),restored()]));", + ]); + expect(JSON.parse(oracle.stdout)).toEqual([ + { original: 3, replacement: 2 }, + { value: 3 }, + ]); + await verifyJavaScriptReturnShapes( + cli, + source, + (fields) => { + expect(fields("restored")).toContainEqual( + expect.objectContaining({ path: "/value", state: "unknown" }), + ); + expect(fields("inspect")).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + path: "/replacement", + state: "literal", + value: 2, + }), + expect.objectContaining({ + path: "/original", + state: "unknown", + }), + ]), + ); + }, + "app.mjs", + ); + }, +);