mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
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:
@@ -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",
|
||||
);
|
||||
},
|
||||
);
|
||||
Reference in New Issue
Block a user