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
This commit is contained in:
Ysopwnable
2026-10-16 10:09:22 +08:00
committed by GitHub
parent 1bd5c5c87e
commit 42fc6c53ae
8 changed files with 822 additions and 118 deletions
+11
View File
@@ -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;
@@ -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<t.Identifier, LocalCallableResolution>();
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],
});
}
});
@@ -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<string, JavaScriptSemanticCallable>;
readonly seenBindings: ReadonlySet<string>;
readonly bindingCache: Map<string, LocalCallableResolution>;
readonly seenInitializers: ReadonlySet<JavaScriptSemanticInitializer>;
readonly bindingCache: WeakMap<t.Identifier, LocalCallableResolution>;
}
/** 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<string, JavaScriptSemanticCallable>;
readonly seenBindings: ReadonlySet<string>;
readonly bindingCache: Map<string, LocalCallableResolution>;
readonly seenInitializers: ReadonlySet<JavaScriptSemanticInitializer>;
readonly bindingCache: WeakMap<t.Identifier, LocalCallableResolution>;
}
/** 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;
};
@@ -50,7 +50,10 @@ interface CallCollectionContext {
readonly state: JavaScriptSemanticAnalysisState;
readonly callableById: ReadonlyMap<string, JavaScriptSemanticCallable>;
readonly output: MutableCallAnalysis;
readonly bindingResolutionCache: Map<string, LocalCallableResolution>;
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);
@@ -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<t.Node, t.Node>,
):
| 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<t.Node, t.Node>,
): 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<t.Node, t.Node>,
) => {
const latest = origins.reduce<StatementPosition | null>(
(latest, { initializer, position }) =>
position !== null &&
!logicalInitializer(initializer.node, parents) &&
!initializerMayRetainEntry(initializer, binding, parents) &&
(latest === null || positionPrecedes(latest, position))
? position
: latest,
@@ -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<string>;
readonly initializers: ReadonlySet<JavaScriptSemanticInitializer>;
}
type InitializerContext = ReadonlySet<JavaScriptSemanticInitializer>;
type CompletedExpansions = WeakMap<
t.Node,
Map<string, readonly InitializerContext[]>
>;
interface ExpansionFrame {
readonly item: Candidate;
readonly node: t.Node;
readonly pathKey: string;
readonly parent: ExpansionFrame | null;
readonly blockers: Set<JavaScriptSemanticInitializer>;
}
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<string, readonly InitializerContext[]>();
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<string, LocalCallableResolution>();
const results = new WeakMap<t.Node, readonly SemanticReturnedReference[]>();
const objectTargets = new WeakMap<t.ObjectExpression, ObjectTargetIndex>();
return (callee) => {
const existing = results.get(callee);
if (existing !== undefined) return existing;
const functions = new Set<t.Node>();
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<string, readonly ReadonlySet<string>[]>
>();
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<string, readonly ReadonlySet<string>[]>();
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)
@@ -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;
@@ -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",
);
},
);