From 65884350ff3373f2adc7337c1945eeb54334693f Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Mon, 19 Oct 2026 06:48:14 +0800 Subject: [PATCH] fix(native): retain partial call routes and omitted sites Separate enumeration outcome from retained facts in the native call-route owner. Keep every available typed call with its source provider and site, supplement incomplete kinds with inferred fallback targets, and retain known routes when fallback fails. Per-reference unknowns take precedence over aggregate availability; cancellation remains terminal. Project partial routes before enumeration failures, preserve each omitted route's relation, and include its call site in the omitted-edge identity so distinct observations to one target cannot overwrite each other. Remove the superseded success-only provider/result representation. Validation: 69 focused cases plus final 30-case rerun, including nine public SDK MCP regressions; full static/architecture, docs and discovery checks pass. Controlled-provider source REDs reproduce both route loss and omitted-site collisions. Real native providers remain unverified. Closes #5073 Closes #3922 Closes #5128 --- docs/native-investigation.md | 7 + .../native/NativeCallRoutes.test.ts | 468 +++++++++++++++--- src/application/native/NativeCallRoutes.ts | 143 +++--- src/application/native/NativeUiActionTrace.ts | 72 ++- .../mcp/nativeUiPartialCallRoutesMcp.test.ts | 427 ++++++++++++++++ 5 files changed, 961 insertions(+), 156 deletions(-) create mode 100644 tests/boundary/mcp/nativeUiPartialCallRoutesMcp.test.ts diff --git a/docs/native-investigation.md b/docs/native-investigation.md index e5abb3a457..8ff8282254 100644 --- a/docs/native-investigation.md +++ b/docs/native-investigation.md @@ -216,6 +216,13 @@ sites remain distinct. A static route does not establish runtime reachability. Swift/closure dispatch requires resolved static references; unavailable ABI forms remain unknown. +Incomplete reference kinds do not discard known typed calls. Callee fallback +adds inferred targets while each retained route keeps its original provider +and call site. If enumeration fails, known routes remain alongside an unresolved +failure; cancellation still terminates the request. A node-limit omission keeps +the route's known relation while marking its target unresolved. Distinct call +sites to the same omitted target retain separate evidence. + Session discovery describes two automatic source routes for this trace: `authored_ui_dispatch` needs Interface Builder decoding and dispatch metadata; `native_seed_call_trace` needs native address resolution, dispatch metadata and diff --git a/src/application/native/NativeCallRoutes.test.ts b/src/application/native/NativeCallRoutes.test.ts index fa8f12a151..0d6f10b1d6 100644 --- a/src/application/native/NativeCallRoutes.test.ts +++ b/src/application/native/NativeCallRoutes.test.ts @@ -4,9 +4,40 @@ import { createAnalysisExecution } from "../AnalysisProvider.js"; import { ghidraReferenceEdge } from "../../domain/ghidraValues.fixture.js"; import { jsonValueSchema } from "../../domain/jsonValue.js"; import { err, ok } from "../../domain/result.js"; -import { AnalysisCancelledError } from "../../domain/analysisErrorCore.js"; +import { + AnalysisCancelledError, + AnalysisCapabilityUnavailableError, +} from "../../domain/analysisErrorCore.js"; const provider = { id: "fixture", name: "Fixture", version: "1" }; +const callReference = (source: string, target: string) => ({ + source_address: source, + target_address: target, + target_procedure: { address: target }, + kind: { + available: true as const, + provenance: "fixture-reference", + type: "UNCONDITIONAL_CALL", + flow: true, + call: true, + jump: false, + data: false, + read: false, + write: false, + indirect: false, + computed: false, + external: false, + conditional: false, + terminal: false, + primary: true, + operand_index: 0, + }, +}); + +const unavailableCallKind = { + available: false as const, + reason: "Reference kind is unavailable.", +}; describe("untyped native call route fallback", () => { it.each([{ callees: [] }, { callees: ["0x2000"] }])( "preserves targetless calls and their provider when fallback callees are $callees", @@ -50,35 +81,343 @@ describe("untyped native call route fallback", () => { ); expect(calls).toEqual(["procedure_references", "procedure_callees"]); expect(result).toEqual({ - ok: true, - value: { + routes: callees.map((address) => ({ provider: fallbackProvider, - routes: callees.map((address) => ({ - address, - site: "0x1000", - relation: "provider_call", - resolution: "inferred", - })), - unknowns: [{ ...unknown, provider: referenceProvider }], - limitations: [ - { - provider: referenceProvider, - operation: "procedure_references", - limitation: "Reference-kind metadata is incomplete.", - }, - { - provider: fallbackProvider, - operation: "procedure_callees", - limitation: "Callee enumeration is limited to named functions.", - }, - ], - }, + address, + site: "0x1000", + relation: "provider_call", + resolution: "inferred", + })), + unknowns: [{ ...unknown, provider: referenceProvider }], + limitations: [ + { + provider: referenceProvider, + operation: "procedure_references", + limitation: "Reference-kind metadata is incomplete.", + }, + { + provider: fallbackProvider, + operation: "procedure_callees", + limitation: "Callee enumeration is limited to named functions.", + }, + ], + outcome: { ok: true, value: undefined }, }); }, ); }); -describe("typed native call routes", () => { +describe("partially typed native call routes", () => { + it.each([false, true])( + "keeps known typed calls when an individual kind is unavailable (aggregate=$0)", + async (referenceKindsAvailable) => { + const typedProvider = { ...provider, id: "typed" }; + const fallbackProvider = { ...provider, id: "fallback" }; + const result = await readNativeCallRoutes( + { + execute: async (operation) => + operation === "procedure_references" + ? ok( + createAnalysisExecution( + { + reference_kinds_available: referenceKindsAvailable, + references: [ + callReference("0x1004", "0x2000"), + { + ...callReference("0x1008", "0x3000"), + kind: unavailableCallKind, + }, + ], + unresolved_calls: [], + }, + typedProvider, + { limitations: ["Some reference kinds are unavailable."] }, + ), + ) + : ok( + createAnalysisExecution( + ["0x2000", "0x3000", "0x4000"], + fallbackProvider, + { limitations: ["Callee enumeration is incomplete."] }, + ), + ), + }, + "0x1000", + ); + + expect(result).toEqual({ + routes: [ + { + provider: typedProvider, + address: "0x2000", + site: "0x1004", + relation: "direct_call", + resolution: "observed", + }, + { + provider: fallbackProvider, + address: "0x3000", + site: "0x1000", + relation: "provider_call", + resolution: "inferred", + }, + { + provider: fallbackProvider, + address: "0x4000", + site: "0x1000", + relation: "provider_call", + resolution: "inferred", + }, + ], + unknowns: [], + limitations: [ + { + provider: typedProvider, + operation: "procedure_references", + limitation: "Some reference kinds are unavailable.", + }, + { + provider: fallbackProvider, + operation: "procedure_callees", + limitation: "Callee enumeration is incomplete.", + }, + ], + outcome: { ok: true, value: undefined }, + }); + }, + ); + + it("preserves distinct typed sites when fallback overlaps their target", async () => { + const typedProvider = { ...provider, id: "typed" }; + const fallbackProvider = { ...provider, id: "fallback" }; + const result = await readNativeCallRoutes( + { + execute: async (operation) => + operation === "procedure_references" + ? ok( + createAnalysisExecution( + { + reference_kinds_available: false, + references: [ + callReference("0x1004", "0x2000"), + callReference("0x1008", "0x2000"), + { + ...callReference("0x100c", "0x4000"), + kind: unavailableCallKind, + }, + ], + unresolved_calls: [], + }, + typedProvider, + ), + ) + : ok( + createAnalysisExecution(["0x2000", "0x3000"], fallbackProvider), + ), + }, + "0x1000", + ); + + expect(result).toEqual({ + routes: [ + { + provider: typedProvider, + address: "0x2000", + site: "0x1004", + relation: "direct_call", + resolution: "observed", + }, + { + provider: typedProvider, + address: "0x2000", + site: "0x1008", + relation: "direct_call", + resolution: "observed", + }, + { + provider: fallbackProvider, + address: "0x3000", + site: "0x1000", + relation: "provider_call", + resolution: "inferred", + }, + ], + unknowns: [], + limitations: [], + outcome: { ok: true, value: undefined }, + }); + }); +}); + +describe("native call fallback output failures", () => { + it("retains source limitations when fallback output is invalid", async () => { + const typedProvider = { ...provider, id: "typed" }; + const fallbackProvider = { ...provider, id: "fallback" }; + const result = await readNativeCallRoutes( + { + execute: async (operation) => + operation === "procedure_references" + ? err( + new AnalysisCapabilityUnavailableError( + typedProvider.id, + "procedure_references", + "Typed references are unavailable.", + ), + ) + : ok( + createAnalysisExecution( + { callees: "not-an-address-list" }, + fallbackProvider, + { limitations: ["Fallback output is truncated."] }, + ), + ), + }, + "0x1000", + ); + + expect(result).toMatchObject({ + routes: [], + unknowns: [], + limitations: [ + { + provider: fallbackProvider, + operation: "procedure_callees", + limitation: "Fallback output is truncated.", + }, + ], + outcome: { + ok: false, + error: { _tag: "AnalysisOutputError" }, + }, + }); + }); + + it("retains known typed routes when fallback output is invalid", async () => { + const typedProvider = { ...provider, id: "typed" }; + const fallbackProvider = { ...provider, id: "fallback" }; + const result = await readNativeCallRoutes( + { + execute: async (operation) => + operation === "procedure_references" + ? ok( + createAnalysisExecution( + { + reference_kinds_available: false, + references: [ + callReference("0x1004", "0x2000"), + { + ...callReference("0x1008", "0x3000"), + kind: unavailableCallKind, + }, + ], + unresolved_calls: [], + }, + typedProvider, + { limitations: ["One call kind remains unavailable."] }, + ), + ) + : ok( + createAnalysisExecution( + { callees: "not-an-address-list" }, + fallbackProvider, + { limitations: ["Fallback output is truncated."] }, + ), + ), + }, + "0x1000", + ); + + expect(result).toMatchObject({ + routes: [ + { + provider: typedProvider, + address: "0x2000", + site: "0x1004", + relation: "direct_call", + resolution: "observed", + }, + ], + unknowns: [], + limitations: [ + { + provider: typedProvider, + operation: "procedure_references", + limitation: "One call kind remains unavailable.", + }, + { + provider: fallbackProvider, + operation: "procedure_callees", + limitation: "Fallback output is truncated.", + }, + ], + outcome: { + ok: false, + error: { _tag: "AnalysisOutputError" }, + }, + }); + }); +}); + +describe("native call fallback cancellation", () => { + it("keeps known typed routes when fallback is cancelled", async () => { + let calls = 0; + const referenceProvider = { ...provider, id: "references" }; + const result = await readNativeCallRoutes( + { + execute: async (operation) => { + calls += 1; + return operation === "procedure_references" + ? ok( + createAnalysisExecution( + { + reference_kinds_available: false, + references: [ + callReference("0x1004", "0x2000"), + { + ...callReference("0x1008", "0x3000"), + kind: unavailableCallKind, + }, + ], + unresolved_calls: [], + }, + referenceProvider, + { limitations: ["One call kind remains unavailable."] }, + ), + ) + : err(new AnalysisCancelledError("procedure_callees")); + }, + }, + "0x1000", + ); + + expect(result).toMatchObject({ + routes: [ + { + provider: referenceProvider, + address: "0x2000", + site: "0x1004", + relation: "direct_call", + resolution: "observed", + }, + ], + unknowns: [], + limitations: [ + { + provider: referenceProvider, + operation: "procedure_references", + limitation: "One call kind remains unavailable.", + }, + ], + outcome: { + ok: false, + error: { _tag: "AnalysisCancelledError" }, + }, + }); + expect(calls).toBe(2); + }); +}); + +describe("complete typed native call routes", () => { it("preserves computed targets, ambiguity and targetless call sites", async () => { const reference = jsonValueSchema.parse(ghidraReferenceEdge()); if ( @@ -87,13 +426,15 @@ describe("typed native call routes", () => { Array.isArray(reference) ) throw new TypeError("Invalid reference fixture"); + const calls: string[] = []; const result = await readNativeCallRoutes( { - execute: async () => - ok( + execute: async (operation) => { + calls.push(operation); + return ok( createAnalysisExecution( { - reference_kinds_available: true, + reference_kinds_available: false, references: ["0x2000", "0x3000"].map((address) => ({ ...reference, source_address: "0x1004", @@ -125,36 +466,41 @@ describe("typed native call routes", () => { provider, { limitations: ["SEH handler targets may be outside the body."] }, ), - ), + ); + }, }, "0x1000", ); expect(result).toMatchObject({ - ok: true, - value: { - routes: [ - { - address: "0x2000", - relation: "indirect_call", - resolution: "ambiguous", - }, - { - address: "0x3000", - relation: "indirect_call", - resolution: "ambiguous", - }, - ], - unknowns: [{ address: "0x1008" }], - limitations: [ - { - provider, - operation: "procedure_references", - limitation: "SEH handler targets may be outside the body.", - }, - ], - }, + routes: [ + { + provider, + address: "0x2000", + relation: "indirect_call", + resolution: "ambiguous", + }, + { + provider, + address: "0x3000", + relation: "indirect_call", + resolution: "ambiguous", + }, + ], + unknowns: [{ address: "0x1008", provider }], + limitations: [ + { + provider, + operation: "procedure_references", + limitation: "SEH handler targets may be outside the body.", + }, + ], + outcome: { ok: true, value: undefined }, }); + expect(calls).toEqual(["procedure_references"]); }); +}); + +describe("malformed typed native call output", () => { it("does not fall back on cancellation or invalid typed output", async () => { let calls = 0; const cancelled = await readNativeCallRoutes( @@ -167,8 +513,10 @@ describe("typed native call routes", () => { "0x1000", ); expect(cancelled).toMatchObject({ - ok: false, - error: { _tag: "AnalysisCancelledError" }, + routes: [], + unknowns: [], + limitations: [], + outcome: { ok: false, error: { _tag: "AnalysisCancelledError" } }, }); expect(calls).toBe(1); const malformed = await readNativeCallRoutes( @@ -183,8 +531,8 @@ describe("typed native call routes", () => { "0x1000", ); expect(malformed).toMatchObject({ - ok: false, - error: { _tag: "AnalysisOutputError" }, + routes: [], + unknowns: [], limitations: [ { provider, @@ -192,8 +540,12 @@ describe("typed native call routes", () => { limitation: "Malformed output still has source caveats.", }, ], + outcome: { ok: false, error: { _tag: "AnalysisOutputError" } }, }); }); +}); + +describe("missing unresolved-call observations", () => { it("rejects omitted unresolved-call observations instead of claiming none", async () => { let calls = 0; const result = await readNativeCallRoutes( @@ -215,8 +567,10 @@ describe("typed native call routes", () => { ); expect(result).toMatchObject({ - ok: false, - error: { _tag: "AnalysisOutputError" }, + routes: [], + unknowns: [], + limitations: [], + outcome: { ok: false, error: { _tag: "AnalysisOutputError" } }, }); expect(calls).toBe(1); }); diff --git a/src/application/native/NativeCallRoutes.ts b/src/application/native/NativeCallRoutes.ts index 59b97cf175..f4018b0a57 100644 --- a/src/application/native/NativeCallRoutes.ts +++ b/src/application/native/NativeCallRoutes.ts @@ -9,6 +9,8 @@ import { referenceKindSchema, } from "../../domain/hopperValues.js"; import { AnalysisOutputError } from "../../domain/analysisErrorCore.js"; +import type { AnalysisError } from "../../domain/analysisErrorBase.js"; +import { err, ok, type Result } from "../../domain/result.js"; import type { SourceLimitation } from "../SourceLimitations.js"; /** Preferred typed observations and the untyped fallback share one owner. */ @@ -39,9 +41,30 @@ const referencesSchema = z.object({ ), }); +interface NativeCallRoute { + readonly address: string; + readonly site: string; + readonly relation: "direct_call" | "indirect_call" | "provider_call"; + readonly resolution: "observed" | "resolved" | "ambiguous" | "inferred"; + readonly provider: ProviderIdentity; +} + +/** Enumeration failure does not invalidate facts already obtained. */ +interface NativeCallRouteRead { + readonly routes: readonly NativeCallRoute[]; + readonly unknowns: readonly { + address: string; + reason: string; + provider: ProviderIdentity; + }[]; + readonly limitations: readonly SourceLimitation[]; + readonly outcome: Result; +} + const typedCallRoutes = ( references: z.infer["references"], -) => { + provider: ProviderIdentity, +): NativeCallRoute[] => { const calls = references.filter(({ kind }) => kind.available && kind.call); const countBySite = new Map>(); for (const call of calls) { @@ -50,6 +73,7 @@ const typedCallRoutes = ( countBySite.set(call.source_address, targets); } return calls.map((call) => ({ + provider, address: call.target_procedure?.address ?? call.target_address, site: call.source_address, relation: @@ -65,13 +89,13 @@ const typedCallRoutes = ( })); }; -/** Prefer typed static call references, preserving computed targets and targetless sites. */ +/** Retain typed observations while supplementing incomplete kinds with inferred callees. */ export const readNativeCallRoutes = async ( analysis: AnalysisOperationPort, procedure: string, signal?: AbortSignal, selectorOrigin?: ExecutionOptions["selectorOrigin"], -) => { +): Promise => { const options = { ...(signal === undefined ? {} : { signal }), ...(selectorOrigin === undefined ? {} : { selectorOrigin }), @@ -81,88 +105,85 @@ export const readNativeCallRoutes = async ( { procedure, direction: "outgoing" }, options, ); - let unknowns: Array<{ + const routes: NativeCallRoute[] = []; + const unknowns: Array<{ address: string; reason: string; provider: ProviderIdentity; }> = []; - let limitations: SourceLimitation[] = []; + const limitations: SourceLimitation[] = []; + const finish = ( + outcome: Result, + ): NativeCallRouteRead => ({ + routes, + unknowns, + limitations, + outcome, + }); if (execution.ok) { - limitations = limitationsFrom( - NATIVE_CALL_ROUTE_SOURCES.references, - execution.value.provider, - execution.value.limitations, + limitations.push( + ...limitationsFrom( + NATIVE_CALL_ROUTE_SOURCES.references, + execution.value.provider, + execution.value.limitations, + ), ); const parsed = referencesSchema.safeParse(execution.value.result); if (!parsed.success) - return { - ok: false as const, - error: new AnalysisOutputError( - NATIVE_CALL_ROUTE_SOURCES.references, - "Invalid typed call references", + return finish( + err( + new AnalysisOutputError( + NATIVE_CALL_ROUTE_SOURCES.references, + "Invalid typed call references", + ), ), - unknowns, - limitations, - }; + ); // Missing detailed reference flags do not invalidate observed targetless // calls. Keep their producing provider even if another provider supplies // the fallback callee inventory. - unknowns = parsed.data.unresolved_calls.map((call) => ({ - ...call, - provider: execution.value.provider, - })); + unknowns.push( + ...parsed.data.unresolved_calls.map((call) => ({ + ...call, + provider: execution.value.provider, + })), + ); const references = parsed.data.references; + routes.push(...typedCallRoutes(references, execution.value.provider)); if ( - parsed.data.reference_kinds_available === true || - (references.length > 0 && references.every(({ kind }) => kind.available)) - ) { - return { - ok: true as const, - value: { - provider: execution.value.provider, - routes: typedCallRoutes(references), - unknowns, - limitations, - }, - }; - } + references.every(({ kind }) => kind.available) && + (parsed.data.reference_kinds_available || references.length > 0) + ) + return finish(ok(undefined)); } else if (execution.error._tag !== "AnalysisCapabilityUnavailableError") - return { - ok: false as const, - error: execution.error, - unknowns, - limitations, - }; + return finish(err(execution.error)); const callees = await analysis.execute( NATIVE_CALL_ROUTE_SOURCES.callees, { procedure }, options, ); - if (!callees.ok) - return { ok: false as const, error: callees.error, unknowns, limitations }; - limitations = [ - ...limitations, + if (!callees.ok) return finish(err(callees.error)); + limitations.push( ...limitationsFrom( NATIVE_CALL_ROUTE_SOURCES.callees, callees.value.provider, callees.value.limitations, ), - ]; + ); const parsed = parseRelatedAddresses(callees.value.result, "callees"); - return parsed.ok - ? { - ok: true as const, - value: { - provider: callees.value.provider, - routes: parsed.value.map((address) => ({ - address, - site: procedure, - relation: "provider_call" as const, - resolution: "inferred" as const, - })), - unknowns, - limitations, - }, - } - : { ok: false as const, error: parsed.error, unknowns, limitations }; + if (!parsed.ok) return finish(err(parsed.error)); + // Keep every observed call site. A target-only fallback cannot replace one, + // and repeated fallback targets add no new observation. + const targets = new Set(routes.map(({ address }) => address)); + for (const address of parsed.value) { + if (targets.has(address)) continue; + targets.add(address); + routes.push({ + address, + site: procedure, + relation: "provider_call", + resolution: "inferred", + provider: callees.value.provider, + }); + } + return finish(ok(undefined)); }; diff --git a/src/application/native/NativeUiActionTrace.ts b/src/application/native/NativeUiActionTrace.ts index e413a07b3d..bc67af6c45 100644 --- a/src/application/native/NativeUiActionTrace.ts +++ b/src/application/native/NativeUiActionTrace.ts @@ -202,7 +202,7 @@ export const traceNativeUiAction = async ( max_edges: input.max_edges, }, }); - const expanded = await addDirectCallees({ + const expanded = await expandNativeCalls({ analysis, graph, initial, @@ -372,7 +372,7 @@ const resolveUiAction = ( }; }; -const addDirectCallees = async (input: { +const expandNativeCalls = async (input: { readonly analysis: AnalysisOperationPort; readonly graph: z.infer; readonly initial: z.infer; @@ -435,14 +435,13 @@ const addDirectCallees = async (input: { "provider", ); examined += 1; - sourceLimitations.push( - ...(execution.ok ? execution.value.limitations : execution.limitations), - ); - if (!execution.ok && execution.error._tag === "AnalysisCancelledError") - return { error: execution.error }; - const unknowns = execution.ok - ? execution.value.unknowns - : execution.unknowns; + sourceLimitations.push(...execution.limitations); + if ( + !execution.outcome.ok && + execution.outcome.error._tag === "AnalysisCancelledError" + ) + return { error: execution.outcome.error }; + const unknowns = execution.unknowns; for (const [index, unknown] of unknowns.entries()) { if (edges.size >= input.input.max_edges) { truncated = true; @@ -470,40 +469,19 @@ const addDirectCallees = async (input: { limitations: [], }); } - if (!execution.ok) { - if (edges.size >= input.input.max_edges) { - truncated = true; - omitted += 1; - continue; - } - const id = `unknown-callees:${current.address}`; - edges.set(id, { - id, - from: current.node.id, - to: null, - relation: "direct_call", - resolution: "unresolved", - reason: projectAnalysisError(execution.error).message, - evidence: [], - limitations: [ - "The provider could not enumerate resolved direct callees.", - ], - }); - continue; - } - for (const [index, route] of execution.value.routes.entries()) { + for (const [index, route] of execution.routes.entries()) { const address = route.address; if (edges.size >= input.input.max_edges) { truncated = true; - omitted += execution.value.routes.length - index; + omitted += execution.routes.length - index; break; } const targetId = `native:function:${address}`; const evidence = [ { kind: "reference" as const, - provider: execution.value.provider, - description: `${execution.value.provider.name} reported ${route.relation} from ${current.address} to ${address}`, + provider: route.provider, + description: `${route.provider.name} reported ${route.relation} from ${current.address} to ${address}`, location: { address: route.site, file_offset: null }, artifact_path: null, artifact_sha256: input.targetSha256, @@ -513,12 +491,12 @@ const addDirectCallees = async (input: { if (nodes.size >= input.input.max_nodes) { truncated = true; omitted += 1; - const id = `unknown-node-bound:${current.address}:${address}`; + const id = `unknown-node-bound:${current.address}:${route.site}:${address}`; edges.set(id, { id, from: current.node.id, to: null, - relation: "direct_call", + relation: route.relation, resolution: "unresolved", reason: "native_trace_node_bound_reached", evidence, @@ -532,7 +510,7 @@ const addDirectCallees = async (input: { kind: "function" as const, name, location: { address, file_offset: null }, - attributes: { resolved_by: execution.value.provider.id }, + attributes: { resolved_by: route.provider.id }, evidence, }; nodes.set(targetId, node); @@ -551,6 +529,24 @@ const addDirectCallees = async (input: { ], }); } + if (!execution.outcome.ok) { + if (edges.size >= input.input.max_edges) { + truncated = true; + omitted += 1; + continue; + } + const id = `unknown-callees:${current.address}`; + edges.set(id, { + id, + from: current.node.id, + to: null, + relation: "provider_call", + resolution: "unresolved", + reason: projectAnalysisError(execution.outcome.error).message, + evidence: [], + limitations: ["The provider could not complete callee enumeration."], + }); + } } return { nodes: [...nodes.values()], diff --git a/tests/boundary/mcp/nativeUiPartialCallRoutesMcp.test.ts b/tests/boundary/mcp/nativeUiPartialCallRoutesMcp.test.ts new file mode 100644 index 0000000000..22440563a2 --- /dev/null +++ b/tests/boundary/mcp/nativeUiPartialCallRoutesMcp.test.ts @@ -0,0 +1,427 @@ +import { afterEach, expect, it } from "vitest"; +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { z } from "zod"; +import type { + AnalysisExecution, + AnalysisOperationPort, +} from "../../../src/application/AnalysisProvider.js"; +import type { AnalysisError } from "../../../src/domain/analysisErrorBase.js"; +import { createAnalysisExecution } from "../../../src/application/AnalysisProvider.js"; +import { + AnalysisCancelledError, + AnalysisCapabilityUnavailableError, + AnalysisOutputError, +} from "../../../src/domain/analysisErrorCore.js"; +import { inspectNativeDispatchMetadata } from "../../../src/domain/native/objcSwiftMetadata.js"; +import { nativeInvestigationTraceSchema } from "../../../src/domain/native/nativeInvestigationGraph.js"; +import { err, ok, type Result } from "../../../src/domain/result.js"; +import { createServer } from "../../../src/server/createServer.js"; +import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; + +const identity = { id: "typed", name: "Typed provider", version: "1" }; +const fallback = { id: "fallback", name: "Fallback provider", version: "1" }; +const sha256 = "a".repeat(64); +const subject = { + sha256, + path: "/fixture", + format: "mach-o", + architecture: "arm64", +} as const; +const resources: Array<{ close(): Promise }> = []; +afterEach(async () => + Promise.all(resources.splice(0).map((resource) => resource.close())), +); + +function typedKind(indirect = false) { + return { + available: true, + provenance: "fixture reference classification", + type: "call", + flow: true, + call: true, + jump: false, + data: false, + read: false, + write: false, + indirect, + computed: indirect, + conditional: false, + terminal: false, + primary: true, + operand_index: 0, + external: false, + }; +} +function ref(site: string, target: string, indirect = false) { + return { + source_address: site, + target_address: target, + target_procedure: { address: target }, + kind: typedKind(indirect), + }; +} +const mixedReferences = (refs = [ref("0x1004", "0x2000")]) => ({ + reference_kinds_available: false, + references: [ + ...refs, + { + source_address: "0x1008", + target_address: "0x9000", + target_procedure: { address: "0x9000" }, + kind: { available: false, reason: "classification unavailable" }, + }, + ], + unresolved_calls: [], +}); +const completeReferences = (refs: ReturnType[]) => ({ + reference_kinds_available: true, + references: refs, + unresolved_calls: [], +}); +const noClassifiedReferences = { + reference_kinds_available: false, + references: [], + unresolved_calls: [], +}; + +type Plan = { + readonly references?: ReturnType; + readonly callees?: readonly string[]; + readonly fallbackProvider?: typeof identity; + readonly fallbackFailure?: "unavailable" | "output" | "cancelled"; +}; +function publicTrace(plan: Plan, maxNodes = 12) { + const analysis: AnalysisOperationPort = { + execute: async ( + operation, + parameters, + ): Promise> => { + if (operation === "decode_interface_builder") + return err( + new AnalysisCapabilityUnavailableError( + identity.id, + operation, + "No authored UI fixture", + ), + ); + if (operation === "inspect_native_dispatch_metadata") + return ok( + createAnalysisExecution( + { + target_sha256: sha256, + provider: identity, + analysis_profile_digest: null, + result: inspectNativeDispatchMetadata([]), + }, + identity, + { subject }, + ), + ); + if (operation === "procedure_address") + return ok(createAnalysisExecution("0x1000", identity, { subject })); + if (operation === "procedure_references") + return ok( + createAnalysisExecution( + plan.references ?? mixedReferences(), + identity, + { subject }, + ), + ); + if (operation === "procedure_callees") { + if (plan.fallbackFailure === "unavailable") + return err( + new AnalysisCapabilityUnavailableError( + identity.id, + operation, + "enumeration unavailable in fixture", + ), + ); + if (plan.fallbackFailure === "output") + return err( + new AnalysisOutputError( + operation, + "enumeration output failed in fixture", + ), + ); + if (plan.fallbackFailure === "cancelled") + return err(new AnalysisCancelledError(operation)); + return ok( + createAnalysisExecution( + plan.callees ?? ["0x3000"], + plan.fallbackProvider ?? fallback, + { subject }, + ), + ); + } + throw new Error( + `Unexpected operation ${operation} ${JSON.stringify(parameters)}`, + ); + }, + }; + const server = createServer({ kind: "fixed", analysis }); + const client = new Client({ name: "native-ui-partial-routes", version: "1" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + resources.push({ + close: async () => { + await client.close(); + await server.close(); + }, + }); + return (async () => { + await server.connect(serverTransport); + await client.connect(clientTransport); + const result = await client.callTool({ + name: "trace_native_ui_action", + arguments: { action: "0x1000", max_nodes: maxNodes, max_depth: 1 }, + }); + return result; + })(); +} + +const traceFrom = (result: { structuredContent?: unknown }) => + z + .object({ + normalized_result: nativeInvestigationTraceSchema, + }) + .parse(result.structuredContent).normalized_result; + +it("keeps typed callsite evidence and distinct fallback-provider routes for mixed reference kinds", async () => { + const response = await publicTrace({}); + expect(response.isError).not.toBe(true); + const trace = traceFrom(response); + expect(trace.target_sha256).toBe(sha256); + expect(trace.edges).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + from: "native:function:0x1000", + to: "native:function:0x2000", + relation: "direct_call", + resolution: "observed", + evidence: expect.arrayContaining([ + expect.objectContaining({ + provider: identity, + location: expect.objectContaining({ address: "0x1004" }), + }), + ]), + }), + expect.objectContaining({ + from: "native:function:0x1000", + to: "native:function:0x3000", + relation: "provider_call", + resolution: "inferred", + evidence: expect.arrayContaining([ + expect.objectContaining({ + provider: fallback, + location: expect.objectContaining({ address: "0x1000" }), + }), + ]), + }), + ]), + ); +}); + +it.each(["unavailable", "output"] as const)( + "retains known observed routes when fallback enumeration has %s failure", + async (failure) => { + const response = await publicTrace({ fallbackFailure: failure }); + expect(response.isError).not.toBe(true); + const trace = traceFrom(response); + expect(trace.edges).toContainEqual( + expect.objectContaining({ + from: "native:function:0x1000", + to: "native:function:0x2000", + relation: "direct_call", + resolution: "observed", + evidence: expect.arrayContaining([ + expect.objectContaining({ + provider: identity, + location: expect.objectContaining({ address: "0x1004" }), + }), + ]), + }), + ); + expect(trace.unresolved).toContainEqual( + expect.objectContaining({ + from: "native:function:0x1000", + to: null, + relation: "provider_call", + resolution: "unresolved", + reason: + failure === "unavailable" + ? "This analysis is unavailable for the current target. Choose another analysis or target." + : expect.stringContaining("unreadable result"), + limitations: expect.arrayContaining([ + "The provider could not complete callee enumeration.", + ]), + }), + ); + }, +); + +it("deduplicates fallback overlap without erasing multiple typed callsites", async () => { + const response = await publicTrace({ + references: mixedReferences([ + ref("0x1004", "0x2000"), + ref("0x100c", "0x2000"), + ]), + callees: ["0x2000", "0x3000"], + }); + expect(response.isError).not.toBe(true); + const trace = traceFrom(response); + const typedEdges = trace.edges.filter( + (edge) => edge.to === "native:function:0x2000", + ); + expect(typedEdges).toHaveLength(2); + expect( + typedEdges + .map((edge) => edge.evidence.flatMap((item) => item.location?.address)) + .flat(), + ).toEqual(expect.arrayContaining(["0x1004", "0x100c"])); + expect( + trace.edges.filter( + (edge) => + edge.to === "native:function:0x2000" && + edge.relation === "provider_call", + ), + ).toHaveLength(0); +}); + +it.each([ + { + relation: "direct_call", + references: completeReferences([ + ref("0x1004", "0x2000"), + ref("0x1008", "0x3000"), + ]), + callees: [], + firstRelation: "direct_call", + firstTarget: "native:function:0x2000", + firstResolution: "observed", + firstProvider: identity, + firstSite: "0x1004", + }, + { + relation: "indirect_call", + references: completeReferences([ + ref("0x1004", "0x2000"), + ref("0x1008", "0x3000", true), + ]), + callees: [], + firstRelation: "direct_call", + firstTarget: "native:function:0x2000", + firstResolution: "observed", + firstProvider: identity, + firstSite: "0x1004", + }, + { + relation: "provider_call", + references: noClassifiedReferences, + callees: ["0x2000", "0x3000"], + firstRelation: "provider_call", + firstTarget: "native:function:0x2000", + firstResolution: "inferred", + firstProvider: fallback, + firstSite: "0x1000", + }, +] as const)( + "preserves $relation when node bounds omit a route", + async ({ + relation, + callees, + references, + firstRelation, + firstTarget, + firstResolution, + firstProvider, + firstSite, + }) => { + const response = await publicTrace({ references, callees }, 2); + expect(response.isError).not.toBe(true); + const trace = traceFrom(response); + expect(trace.unresolved).toContainEqual( + expect.objectContaining({ + from: "native:function:0x1000", + to: null, + relation, + resolution: "unresolved", + reason: "native_trace_node_bound_reached", + }), + ); + expect(trace.edges).toContainEqual( + expect.objectContaining({ + from: "native:function:0x1000", + to: firstTarget, + relation: firstRelation, + resolution: firstResolution, + evidence: expect.arrayContaining([ + expect.objectContaining({ + provider: firstProvider, + location: expect.objectContaining({ address: firstSite }), + }), + ]), + }), + ); + expect(trace.truncated).toBe(true); + expect(trace.coverage).toContainEqual( + expect.objectContaining({ + facet: "static_native_calls", + status: "partial", + }), + ); + expect(trace.limitations).toContain( + "Static analysis does not prove that an authored UI action is reachable or executed at runtime.", + ); + }, +); + +it("retains separate omitted callsites when routes share a target", async () => { + const response = await publicTrace( + { + references: completeReferences([ + ref("0x1004", "0x2000"), + ref("0x1008", "0x3000", true), + ref("0x100c", "0x3000"), + ]), + callees: [], + }, + 2, + ); + expect(response.isError).not.toBe(true); + const trace = traceFrom(response); + expect(trace.unresolved).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + from: "native:function:0x1000", + to: null, + relation: "indirect_call", + resolution: "unresolved", + evidence: expect.arrayContaining([ + expect.objectContaining({ + provider: identity, + location: expect.objectContaining({ address: "0x1008" }), + }), + ]), + }), + expect.objectContaining({ + from: "native:function:0x1000", + to: null, + relation: "direct_call", + resolution: "unresolved", + evidence: expect.arrayContaining([ + expect.objectContaining({ + provider: identity, + location: expect.objectContaining({ address: "0x100c" }), + }), + ]), + }), + ]), + ); + expect(trace.truncated).toBe(true); +}); + +it("keeps cancellation terminal when fallback enumeration is cancelled", async () => { + const response = await publicTrace({ fallbackFailure: "cancelled" }); + expect(response.isError).toBe(true); + expect(parseMcpToolError(response).error.code).toBe("cancelled"); +});