fix(analysis): resolve procedure identities and preserve literal queries

This commit is contained in:
morluto 2026-10-09 02:42:33 +08:00
parent f2e22e13a6
commit d830cb79b3
9 changed files with 117 additions and 116 deletions

View File

@ -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 => ({

View File

@ -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 });
};

View File

@ -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[]> = {};

View File

@ -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",
),
);
};

View File

@ -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"),
}),

View File

@ -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(

View File

@ -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":

View File

@ -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: () => {

View File

@ -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 },