fix(analysis): resolve procedure identities and preserve literal queries
This commit is contained in:
parent
f2e22e13a6
commit
d830cb79b3
|
|
@ -5,6 +5,7 @@ import { parseRelatedAddresses } from "../domain/hopperValues.js";
|
|||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import { ok, type Result } from "../domain/result.js";
|
||||
import type { EnhancedResult } from "./EnhancedToolTypes.js";
|
||||
import { resolveProcedureAddress } from "./ProcedureAddressResolution.js";
|
||||
|
||||
type AnalysisCall = (
|
||||
name: AnalysisOperation,
|
||||
|
|
@ -61,7 +62,20 @@ export const traceCallPath = async (
|
|||
input: CallPathTraceInput,
|
||||
signal?: AbortSignal,
|
||||
): EnhancedResult => {
|
||||
const state = createTraceState(input);
|
||||
const start = await resolveProcedureAddress(call, input.start, signal);
|
||||
if (!start.ok) return start;
|
||||
let goal: string | undefined;
|
||||
if (input.goal !== undefined) {
|
||||
const resolved = await resolveProcedureAddress(call, input.goal, signal);
|
||||
if (!resolved.ok) return resolved;
|
||||
goal = resolved.value;
|
||||
}
|
||||
const resolvedInput = {
|
||||
start: start.value,
|
||||
...(goal === undefined ? {} : { goal }),
|
||||
direction: input.direction,
|
||||
};
|
||||
const state = createTraceState(resolvedInput);
|
||||
while (
|
||||
state.queueIndex < state.queue.length &&
|
||||
state.traversalPath.length === 0
|
||||
|
|
@ -71,7 +85,7 @@ export const traceCallPath = async (
|
|||
const current = state.visited.get(address);
|
||||
if (current === undefined) continue;
|
||||
const found = await expandAddress(call, {
|
||||
input,
|
||||
input: resolvedInput,
|
||||
state,
|
||||
address,
|
||||
current,
|
||||
|
|
@ -79,7 +93,7 @@ export const traceCallPath = async (
|
|||
});
|
||||
if (found) break;
|
||||
}
|
||||
return ok(projectTrace(input, state));
|
||||
return ok(projectTrace(resolvedInput, state));
|
||||
};
|
||||
|
||||
const createTraceState = (input: CallPathTraceInput): TraceState => ({
|
||||
|
|
|
|||
|
|
@ -71,40 +71,37 @@ const literalMatches = async (
|
|||
signal?: AbortSignal,
|
||||
): Promise<Result<{ matches: TraceMatch[] }, AnalysisError>> => {
|
||||
const matches: TraceMatch[] = [];
|
||||
const needles = [...new Set(input.query.trim().split(/\s+/u))];
|
||||
const matched = new Set<string>();
|
||||
for (const needle of needles) {
|
||||
for (const [tool, type] of sources) {
|
||||
const result = await call(
|
||||
tool,
|
||||
{
|
||||
pattern: needle,
|
||||
mode: "literal",
|
||||
case_sensitive: input.case_sensitive,
|
||||
},
|
||||
signal,
|
||||
for (const [tool, type] of sources) {
|
||||
const result = await call(
|
||||
tool,
|
||||
{
|
||||
pattern: input.query,
|
||||
mode: "literal",
|
||||
case_sensitive: input.case_sensitive,
|
||||
},
|
||||
signal,
|
||||
);
|
||||
if (!result.ok) return result;
|
||||
const items = z
|
||||
.array(z.object({ address: z.string(), value: z.string() }))
|
||||
.safeParse(result.value);
|
||||
if (!items.success)
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
tool,
|
||||
"provider returned an invalid search result",
|
||||
),
|
||||
);
|
||||
if (!result.ok) return result;
|
||||
const items = z
|
||||
.array(z.object({ address: z.string(), value: z.string() }))
|
||||
.safeParse(result.value);
|
||||
if (!items.success)
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
tool,
|
||||
"provider returned an invalid search result",
|
||||
),
|
||||
);
|
||||
addPageMatches({
|
||||
items: items.data.map(({ address, value: name }) => ({
|
||||
address,
|
||||
name,
|
||||
})),
|
||||
type,
|
||||
matches,
|
||||
matched,
|
||||
});
|
||||
}
|
||||
addPageMatches({
|
||||
items: items.data.map(({ address, value: name }) => ({
|
||||
address,
|
||||
name,
|
||||
})),
|
||||
type,
|
||||
matches,
|
||||
matched,
|
||||
});
|
||||
}
|
||||
return ok({ matches });
|
||||
};
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ import {
|
|||
} from "./EnhancedToolTypes.js";
|
||||
import { resolveAnalysisDocument } from "./AnalysisDocument.js";
|
||||
import { traceCallPath } from "./CallPathTracing.js";
|
||||
import { resolveProcedureAddress } from "./ProcedureAddressResolution.js";
|
||||
import { traceLiteralFeature } from "./EnhancedLiteralTracing.js";
|
||||
import { projectNativeApiInspection } from "./native/NativeApiInspection.js";
|
||||
export type { ValidatedEnhancedCall } from "./EnhancedToolTypes.js";
|
||||
|
|
@ -296,9 +297,15 @@ export class EnhancedTools {
|
|||
const relation = input.direction === "forward" ? "callees" : "callers";
|
||||
const tool =
|
||||
input.direction === "forward" ? "procedure_callees" : "procedure_callers";
|
||||
const discovered = new Set([input.address]);
|
||||
const resolved = await resolveProcedureAddress(
|
||||
this.#call.bind(this),
|
||||
input.address,
|
||||
signal,
|
||||
);
|
||||
if (!resolved.ok) return resolved;
|
||||
const discovered = new Set([resolved.value]);
|
||||
const queue: Array<{ address: string; depth: number }> = [
|
||||
{ address: input.address, depth: 0 },
|
||||
{ address: resolved.value, depth: 0 },
|
||||
];
|
||||
let queueIndex = 0;
|
||||
const graph: Record<string, JsonValue[]> = {};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,29 @@
|
|||
import type { AnalysisOperation } from "./AnalysisProvider.js";
|
||||
import { AnalysisOutputError } from "../domain/analysisErrorCore.js";
|
||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
|
||||
type AnalysisCall = (
|
||||
operation: AnalysisOperation,
|
||||
parameters: Readonly<Record<string, JsonValue>>,
|
||||
signal?: AbortSignal,
|
||||
) => Promise<Result<JsonValue, AnalysisError>>;
|
||||
|
||||
/** Resolve a caller selector to an observed provider procedure identity. */
|
||||
export const resolveProcedureAddress = async (
|
||||
call: AnalysisCall,
|
||||
procedure: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Result<string, AnalysisError>> => {
|
||||
const result = await call("procedure_address", { procedure }, signal);
|
||||
if (!result.ok) return result;
|
||||
return typeof result.value === "string" && result.value.length > 0
|
||||
? ok(result.value)
|
||||
: err(
|
||||
new AnalysisOutputError(
|
||||
"procedure_address",
|
||||
"provider returned no canonical procedure address",
|
||||
),
|
||||
);
|
||||
};
|
||||
|
|
@ -19,7 +19,11 @@ export const enhancedInputSchemas = {
|
|||
.default([]),
|
||||
}),
|
||||
get_call_graph: z.strictObject({
|
||||
address: z.string().describe("A provider-normalized procedure address"),
|
||||
address: z
|
||||
.string()
|
||||
.describe(
|
||||
"A procedure symbol or address to resolve to its canonical entry",
|
||||
),
|
||||
direction: z.enum(["forward", "backward"]).default("forward"),
|
||||
}),
|
||||
analyze_swift_types: z.strictObject({
|
||||
|
|
@ -44,10 +48,10 @@ export const enhancedInputSchemas = {
|
|||
}),
|
||||
trace_feature: traceLiteralInputSchema,
|
||||
trace_call_path: z.strictObject({
|
||||
start: z.string().describe("A provider-normalized procedure address"),
|
||||
start: z.string().describe("A starting procedure symbol or address"),
|
||||
goal: z
|
||||
.string()
|
||||
.describe("An optional provider-normalized destination address")
|
||||
.describe("An optional destination procedure symbol or address")
|
||||
.optional(),
|
||||
direction: z.enum(["forward", "backward"]).default("forward"),
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ export const ENHANCED_TOOL_CONTRACTS = [
|
|||
),
|
||||
enhanced(
|
||||
"get_call_graph",
|
||||
"Traverse the bound provider's caller or callee relationships from one symbol or address until the reachable graph is exhausted. Every node has an ok/error status and failures use safe typed projections; unresolved indirect calls may be missing and results are not a whole-program CFG.",
|
||||
"Resolve one symbol or address to its canonical procedure entry, then traverse the bound provider's caller or callee relationships until the reachable graph is exhausted. Every node uses a canonical address and has an ok/error status; failures use safe typed projections. Unresolved indirect calls may be missing and results are not a whole-program CFG.",
|
||||
enhancedInputSchemas.get_call_graph,
|
||||
),
|
||||
enhanced(
|
||||
|
|
@ -70,12 +70,12 @@ export const ENHANCED_TOOL_CONTRACTS = [
|
|||
...FUNCTION_WORKFLOW_TOOL_CONTRACTS,
|
||||
enhanced(
|
||||
"trace_feature",
|
||||
"Trace a literal feature query through every matching string and procedure, their xrefs, and truthful containing-procedure resolution. Returns observations, operation count, and residual unknowns without inferring reference kinds.",
|
||||
"Trace the complete literal query, preserving spaces, through every matching string and procedure, their xrefs, and truthful containing-procedure resolution. Returns observations, operation count, and residual unknowns without inferring reference kinds.",
|
||||
enhancedInputSchemas.trace_feature,
|
||||
),
|
||||
enhanced(
|
||||
"trace_call_path",
|
||||
"Trace direct callers or callees from one exact procedure address until the graph is exhausted or the optional goal is reached. Returns visited nodes, direct-call edges, a shortest traversal path, provider failures, and residual unknowns; unresolved indirect calls remain unknown.",
|
||||
"Resolve start and optional goal symbols or addresses to canonical procedure entries, then trace direct callers or callees until the graph is exhausted or the goal is reached. Results use canonical addresses; Evidence preserves supplied selectors. Returns visited nodes, direct-call edges, a shortest traversal path, provider failures, and residual unknowns; unresolved indirect calls remain unknown.",
|
||||
enhancedInputSchemas.trace_call_path,
|
||||
),
|
||||
enhanced(
|
||||
|
|
|
|||
|
|
@ -40,7 +40,6 @@ const fixturePort = (): AnalysisOperationPort => ({
|
|||
execute: (name, arguments_) => {
|
||||
switch (name) {
|
||||
case "inspect_native_dispatch_metadata":
|
||||
case "procedure_address":
|
||||
return Promise.resolve(
|
||||
err(
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
|
|
@ -50,6 +49,24 @@ const fixturePort = (): AnalysisOperationPort => ({
|
|||
),
|
||||
),
|
||||
);
|
||||
case "procedure_address": {
|
||||
const procedure = inventory(PROCEDURES).find(
|
||||
(item) =>
|
||||
item.address === arguments_.procedure ||
|
||||
item.value === arguments_.procedure,
|
||||
);
|
||||
return Promise.resolve(
|
||||
procedure === undefined
|
||||
? err(
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
"fixture",
|
||||
name,
|
||||
"No matching procedure in this symbol-only fixture",
|
||||
),
|
||||
)
|
||||
: ok(procedure.address),
|
||||
);
|
||||
}
|
||||
case "list_procedures":
|
||||
return Promise.resolve(ok(inventory(PROCEDURES)));
|
||||
case "list_names":
|
||||
|
|
|
|||
|
|
@ -19,9 +19,11 @@ describe("enhanced MCP tools", () => {
|
|||
it("returns typed graph failures and stable unresolved-name results", async () => {
|
||||
const tools = new EnhancedTools({
|
||||
execute: (name) =>
|
||||
name === "procedure_callees"
|
||||
? Promise.resolve(err(new AnalysisOutputError(name, "failed")))
|
||||
: Promise.resolve(ok(inventory({}))),
|
||||
name === "procedure_address"
|
||||
? Promise.resolve(ok("0x1"))
|
||||
: name === "procedure_callees"
|
||||
? Promise.resolve(err(new AnalysisOutputError(name, "failed")))
|
||||
: Promise.resolve(ok(inventory({}))),
|
||||
});
|
||||
|
||||
const graph = await tools.execute("get_call_graph", {
|
||||
|
|
@ -58,40 +60,6 @@ describe("enhanced MCP tools", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("traverses the complete reachable call graph and stops at cycles", async () => {
|
||||
const tools = new EnhancedTools({
|
||||
execute: (name, arguments_) => {
|
||||
if (name !== "procedure_callees") return Promise.resolve(ok([]));
|
||||
const address = String(arguments_.procedure);
|
||||
const next = Number.parseInt(address.slice(2), 16) + 1;
|
||||
return Promise.resolve(
|
||||
ok(next <= 8 ? [`0x${next.toString(16)}`] : ["0x2"]),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const result = await tools.execute("get_call_graph", {
|
||||
address: "0x1",
|
||||
direction: "forward",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
value: Object.fromEntries(
|
||||
Array.from({ length: 8 }, (_, index) => [
|
||||
String(index),
|
||||
[
|
||||
{
|
||||
address: `0x${index + 1}`,
|
||||
calls: [index === 7 ? "0x2" : `0x${index + 2}`],
|
||||
status: "ok",
|
||||
},
|
||||
],
|
||||
]),
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
it("uses every procedure in one complete inventory", async () => {
|
||||
const client = await connect({
|
||||
execute: () => {
|
||||
|
|
|
|||
|
|
@ -11,41 +11,6 @@ import { observed as ok } from "../../fixtures/analysisExecution.js";
|
|||
afterEach(closeEnhancedToolResources);
|
||||
|
||||
describe("enhanced MCP tools", () => {
|
||||
it("returns Evidence IDs and a complete call path", async () => {
|
||||
const client = await connect({
|
||||
execute: async (_name, arguments_) => {
|
||||
const procedure = arguments_.procedure;
|
||||
return ok(
|
||||
procedure === "0x1"
|
||||
? ["0x2", "0x3"]
|
||||
: procedure === "0x2"
|
||||
? ["0x1"]
|
||||
: [],
|
||||
);
|
||||
},
|
||||
});
|
||||
const result = await client.callTool({
|
||||
name: "trace_call_path",
|
||||
arguments: {
|
||||
start: "0x1",
|
||||
goal: "0x3",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.isError).not.toBe(true);
|
||||
expect(result.structuredContent).toMatchObject({
|
||||
evidence_id: expect.stringMatching(/^ev_[a-f0-9]{64}$/u),
|
||||
result: {
|
||||
goal_status: "reached",
|
||||
nodes: expect.arrayContaining([
|
||||
expect.objectContaining({ address: "0x1" }),
|
||||
expect.objectContaining({ address: "0x3" }),
|
||||
]),
|
||||
truncated: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("returns every batch item in caller order with its own result", async () => {
|
||||
const addresses = Array.from(
|
||||
{ length: 37 },
|
||||
|
|
|
|||
Loading…
Reference in New Issue