From d830cb79b305e6d4d255f8080de69ca668cbe0af Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Fri, 9 Oct 2026 02:42:33 +0800 Subject: [PATCH] fix(analysis): resolve procedure identities and preserve literal queries --- src/application/CallPathTracing.ts | 20 +++++- src/application/EnhancedLiteralTracing.ts | 61 +++++++++---------- src/application/EnhancedTools.ts | 11 +++- src/application/ProcedureAddressResolution.ts | 29 +++++++++ src/contracts/enhancedInputs.ts | 10 ++- src/contracts/enhancedToolContracts.ts | 6 +- ....01-lists-the-complete-target-open.test.ts | 19 +++++- ...3-returns-typed-graph-failures-and.test.ts | 42 ++----------- .../enhancedTools.callPathsAndBatches.test.ts | 35 ----------- 9 files changed, 117 insertions(+), 116 deletions(-) create mode 100644 src/application/ProcedureAddressResolution.ts diff --git a/src/application/CallPathTracing.ts b/src/application/CallPathTracing.ts index ce31885c6..7d24a3382 100644 --- a/src/application/CallPathTracing.ts +++ b/src/application/CallPathTracing.ts @@ -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 => ({ diff --git a/src/application/EnhancedLiteralTracing.ts b/src/application/EnhancedLiteralTracing.ts index ccede81ae..927838150 100644 --- a/src/application/EnhancedLiteralTracing.ts +++ b/src/application/EnhancedLiteralTracing.ts @@ -71,40 +71,37 @@ const literalMatches = async ( signal?: AbortSignal, ): Promise> => { const matches: TraceMatch[] = []; - const needles = [...new Set(input.query.trim().split(/\s+/u))]; const matched = new Set(); - 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 }); }; diff --git a/src/application/EnhancedTools.ts b/src/application/EnhancedTools.ts index 18f873514..d05a1cde5 100644 --- a/src/application/EnhancedTools.ts +++ b/src/application/EnhancedTools.ts @@ -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 = {}; diff --git a/src/application/ProcedureAddressResolution.ts b/src/application/ProcedureAddressResolution.ts new file mode 100644 index 000000000..7758c292f --- /dev/null +++ b/src/application/ProcedureAddressResolution.ts @@ -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>, + signal?: AbortSignal, +) => Promise>; + +/** Resolve a caller selector to an observed provider procedure identity. */ +export const resolveProcedureAddress = async ( + call: AnalysisCall, + procedure: string, + signal?: AbortSignal, +): Promise> => { + 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", + ), + ); +}; diff --git a/src/contracts/enhancedInputs.ts b/src/contracts/enhancedInputs.ts index 1cc8cf601..fdebbc28b 100644 --- a/src/contracts/enhancedInputs.ts +++ b/src/contracts/enhancedInputs.ts @@ -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"), }), diff --git a/src/contracts/enhancedToolContracts.ts b/src/contracts/enhancedToolContracts.ts index 4c71da877..1b407eda3 100644 --- a/src/contracts/enhancedToolContracts.ts +++ b/src/contracts/enhancedToolContracts.ts @@ -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( diff --git a/tests/boundary/mcp/enhancedTools.01-lists-the-complete-target-open.test.ts b/tests/boundary/mcp/enhancedTools.01-lists-the-complete-target-open.test.ts index f4a28a24c..4239ea554 100644 --- a/tests/boundary/mcp/enhancedTools.01-lists-the-complete-target-open.test.ts +++ b/tests/boundary/mcp/enhancedTools.01-lists-the-complete-target-open.test.ts @@ -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": diff --git a/tests/boundary/mcp/enhancedTools.03-returns-typed-graph-failures-and.test.ts b/tests/boundary/mcp/enhancedTools.03-returns-typed-graph-failures-and.test.ts index 9c13b8b70..989b994c4 100644 --- a/tests/boundary/mcp/enhancedTools.03-returns-typed-graph-failures-and.test.ts +++ b/tests/boundary/mcp/enhancedTools.03-returns-typed-graph-failures-and.test.ts @@ -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: () => { diff --git a/tests/boundary/mcp/enhancedTools.callPathsAndBatches.test.ts b/tests/boundary/mcp/enhancedTools.callPathsAndBatches.test.ts index 4ae1125d2..083ac1ee6 100644 --- a/tests/boundary/mcp/enhancedTools.callPathsAndBatches.test.ts +++ b/tests/boundary/mcp/enhancedTools.callPathsAndBatches.test.ts @@ -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 },