From 32f0e5120966be59d17a1e092353816c76a9c5e2 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Mon, 19 Oct 2026 03:39:02 +0800 Subject: [PATCH] fix(session): describe native UI trace source routes Share source operations with execution owners and derive availability and missing prerequisites from one evaluation. Distinguish authored UI dispatch from native-seed call tracing without inventing a default request mode. Preserve target gates, typed failures and partial results. Fixes #5071 --- docs/native-investigation.md | 11 + src/application/CapabilityInventory.ts | 164 +++-- src/application/native/NativeCallRoutes.ts | 16 +- src/application/native/NativeUiActionTrace.ts | 17 +- .../boundary/mcp/nativeUiAvailability.test.ts | 688 ++++++++++++++++++ 5 files changed, 815 insertions(+), 81 deletions(-) create mode 100644 tests/boundary/mcp/nativeUiAvailability.test.ts diff --git a/docs/native-investigation.md b/docs/native-investigation.md index 1a19d6fd84..e5abb3a457 100644 --- a/docs/native-investigation.md +++ b/docs/native-investigation.md @@ -216,6 +216,17 @@ sites remain distinct. A static route does not establish runtime reachability. Swift/closure dispatch requires resolved static references; unavailable ABI forms remain unknown. +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 +a call reader. Metadata can come from direct inspection or `list_names`; +the call reader can supply typed `procedure_references` or inferred +`procedure_callees`. An authored UI-to-handler edge remains useful without +call expansion, and native-seed tracing can proceed without UI decoding. +These routes add no request option or default mode, so discovery omits +`default_mode_available`. Availability describes source prerequisites; +the result reports the query's actual matches, coverage and failures. + `trace_native_values` / `rea trace-native-values --provider ghidra` composes high-p-code def-use graphs across statically resolved calls. Logical CALL arguments bind to recovered parameter ordinals; recovered RETURN diff --git a/src/application/CapabilityInventory.ts b/src/application/CapabilityInventory.ts index 4d7c6890bb..5fa568254e 100644 --- a/src/application/CapabilityInventory.ts +++ b/src/application/CapabilityInventory.ts @@ -1,6 +1,8 @@ import { DEFAULT_CALL_RELATIONSHIP_DIRECTION } from "../contracts/enhancedInputs.js"; import { CALL_RELATIONSHIP_TRAVERSALS } from "./CallRelationshipTraversal.js"; import { NATIVE_DISPATCH_METADATA_SOURCES } from "./native/NativeDispatchMetadataInspection.js"; +import { NATIVE_UI_ACTION_OPERATIONS } from "./native/NativeUiActionTrace.js"; +import { NATIVE_CALL_ROUTE_SOURCES } from "./native/NativeCallRoutes.js"; import { z } from "zod"; import { sqliteDatabaseCapability } from "./sqlite/SqliteDatabaseCapability.js"; import type { @@ -117,10 +119,10 @@ type CompositionRequirements = readonly ( )[]; type AvailabilityMode = { readonly name: string; - readonly requirements: readonly AnalysisOperation[]; + readonly requirements: CompositionRequirements; }; type ModeComposition = { - readonly defaultMode: string; + readonly defaultMode?: string; readonly modes: readonly AvailabilityMode[]; }; type ProviderComposition = CompositionRequirements | ModeComposition; @@ -135,16 +137,32 @@ const CALL_RELATIONSHIP_COMPOSITION: ModeComposition = { ), }; +const NATIVE_UI_ACTION_COMPOSITION: ModeComposition = { + modes: [ + { + name: "authored_ui_dispatch", + requirements: [ + NATIVE_UI_ACTION_OPERATIONS.interfaceBuilder, + Object.values(NATIVE_DISPATCH_METADATA_SOURCES), + ], + }, + { + name: "native_seed_call_trace", + requirements: [ + Object.values(NATIVE_DISPATCH_METADATA_SOURCES), + NATIVE_UI_ACTION_OPERATIONS.nativeSeed, + Object.values(NATIVE_CALL_ROUTE_SOURCES), + ], + }, + ], +}; + // Complete coverage is checked against canonical enhanced contracts, rather than // allowing a newly registered workflow to silently look like a missing provider. const ENHANCED_REQUIREMENTS: ReadonlyMap = new Map( Object.entries({ trace_native_values: ["analyze_function", "resolve_native_call_targets"], - trace_native_ui_action: [ - "decode_interface_builder", - "list_names", - "procedure_callees", - ], + trace_native_ui_action: NATIVE_UI_ACTION_COMPOSITION, inspect_native_dispatch_metadata: [ Object.values(NATIVE_DISPATCH_METADATA_SOURCES), ], @@ -739,76 +757,79 @@ const composedAvailability = ( requirements.defaultMode, descriptors, ) - : composedAvailabilityFor(requirements, descriptors); + : composedAvailabilityFor(requirements, descriptors).availability; }; +/** Evaluate source groups once so discovery and missing-operation facts agree. */ const composedAvailabilityFor = ( - requirements: readonly (string | readonly string[])[], + requirements: CompositionRequirements, descriptors: ReadonlyMap, -): Availability => { - for (const requirement of requirements) { - if (typeof requirement === "string") { - if (!descriptors.has(requirement)) - return { - reason: "provider_missing", - remediation: `Configure a provider for required operation ${requirement}.`, - }; - } else { - const declared = requirement.some((operation) => - descriptors.has(operation), +): { + readonly availability: Availability; + readonly missingOperations: readonly AnalysisOperation[]; +} => { + const unmet = requirements + .map((requirement) => { + const operations = + typeof requirement === "string" ? [requirement] : requirement; + const observations = operations.map((operation) => + descriptors.get(operation), ); - if (!declared) - return { - reason: "provider_missing", - remediation: `Configure a provider for one of: ${requirement.join(", ")}.`, - }; - } - } - - for (const requirement of requirements) { - if (typeof requirement === "string") { - const descriptor = descriptors.get(requirement); - if (descriptor?.available === false) - return { - reason: - descriptor.availability_code === "unsupported_host" - ? "unsupported_host" - : "provider_unavailable", - remediation: - descriptor.reason ?? - "Choose another target or configured provider.", - }; - } else { - const anyAvailable = requirement.some( - (operation) => descriptors.get(operation)?.available === true, - ); - if (!anyAvailable) { - const unavailable = requirement - .map((operation) => descriptors.get(operation)) - .find((descriptor) => descriptor?.available === false); - if (unavailable !== undefined) - return { - reason: - unavailable.availability_code === "unsupported_host" - ? "unsupported_host" - : "provider_unavailable", - remediation: - unavailable.reason ?? - "Choose another target or configured provider.", - }; - } - } - } - return { reason: "available", remediation: null }; + return { + requirement, + operations, + available: observations.some( + (descriptor) => descriptor?.available === true, + ), + unavailable: observations.find( + (descriptor) => descriptor?.available === false, + ), + }; + }) + .filter((group) => !group.available); + const missingOperations = [ + ...new Set(unmet.flatMap((group) => group.operations)), + ]; + // Preserve the existing admission rule: report an undeclared prerequisite + // before an unhealthy declaration, in requirement order within each class. + const undeclared = unmet.find((group) => group.unavailable === undefined); + if (undeclared !== undefined) + return { + missingOperations, + availability: { + reason: "provider_missing", + remediation: + typeof undeclared.requirement === "string" + ? `Configure a provider for required operation ${undeclared.requirement}.` + : `Configure a provider for one of: ${undeclared.operations.join(", ")}.`, + }, + }; + const unavailable = unmet[0]?.unavailable; + if (unavailable !== undefined) + return { + missingOperations, + availability: { + reason: + unavailable.availability_code === "unsupported_host" + ? "unsupported_host" + : "provider_unavailable", + remediation: + unavailable.reason ?? "Choose another target or configured provider.", + }, + }; + return { + availability: { reason: "available", remediation: null }, + missingOperations, + }; }; const availabilityForModes = ( declarations: readonly AvailabilityMode[], - defaultMode: string, + defaultMode: string | undefined, descriptors: ReadonlyMap, ): Availability => { const modeResults: AvailabilityModeResult[] = declarations.map((mode) => { - const availability = composedAvailabilityFor( + const { availability, missingOperations } = composedAvailabilityFor( mode.requirements, descriptors, ); @@ -824,10 +845,7 @@ const availabilityForModes = ( name: mode.name, available: false, reason: availability.reason, - missing_operations: mode.requirements.filter((operation) => { - const descriptor = descriptors.get(operation); - return descriptor === undefined || descriptor.available === false; - }), + missing_operations: [...missingOperations], remediation: availability.remediation, }; }); @@ -858,8 +876,12 @@ const availabilityForModes = ( const specificFailureRemediation = failure?.reason === "provider_missing" ? undefined : failure?.remediation; const facts = { - defaultModeAvailable: - modes.find((mode) => mode.name === defaultMode)?.available ?? false, + ...(defaultMode === undefined + ? {} + : { + defaultModeAvailable: + modes.find((mode) => mode.name === defaultMode)?.available ?? false, + }), modes, }; if (availableMode !== undefined) diff --git a/src/application/native/NativeCallRoutes.ts b/src/application/native/NativeCallRoutes.ts index a4ba354e3d..59b97cf175 100644 --- a/src/application/native/NativeCallRoutes.ts +++ b/src/application/native/NativeCallRoutes.ts @@ -11,6 +11,12 @@ import { import { AnalysisOutputError } from "../../domain/analysisErrorCore.js"; import type { SourceLimitation } from "../SourceLimitations.js"; +/** Preferred typed observations and the untyped fallback share one owner. */ +export const NATIVE_CALL_ROUTE_SOURCES = { + references: "procedure_references", + callees: "procedure_callees", +} as const; + const limitationsFrom = ( operation: SourceLimitation["operation"], provider: ProviderIdentity, @@ -71,7 +77,7 @@ export const readNativeCallRoutes = async ( ...(selectorOrigin === undefined ? {} : { selectorOrigin }), }; const execution = await analysis.execute( - "procedure_references", + NATIVE_CALL_ROUTE_SOURCES.references, { procedure, direction: "outgoing" }, options, ); @@ -83,7 +89,7 @@ export const readNativeCallRoutes = async ( let limitations: SourceLimitation[] = []; if (execution.ok) { limitations = limitationsFrom( - "procedure_references", + NATIVE_CALL_ROUTE_SOURCES.references, execution.value.provider, execution.value.limitations, ); @@ -92,7 +98,7 @@ export const readNativeCallRoutes = async ( return { ok: false as const, error: new AnalysisOutputError( - "procedure_references", + NATIVE_CALL_ROUTE_SOURCES.references, "Invalid typed call references", ), unknowns, @@ -128,7 +134,7 @@ export const readNativeCallRoutes = async ( limitations, }; const callees = await analysis.execute( - "procedure_callees", + NATIVE_CALL_ROUTE_SOURCES.callees, { procedure }, options, ); @@ -137,7 +143,7 @@ export const readNativeCallRoutes = async ( limitations = [ ...limitations, ...limitationsFrom( - "procedure_callees", + NATIVE_CALL_ROUTE_SOURCES.callees, callees.value.provider, callees.value.limitations, ), diff --git a/src/application/native/NativeUiActionTrace.ts b/src/application/native/NativeUiActionTrace.ts index c039f85cbd..e413a07b3d 100644 --- a/src/application/native/NativeUiActionTrace.ts +++ b/src/application/native/NativeUiActionTrace.ts @@ -23,6 +23,13 @@ import { AnalysisOutputError } from "../../domain/analysisErrorCore.js"; import { projectAnalysisError } from "../../domain/analysisErrorProjection.js"; type Input = z.output; + +/** Source roles used by execution and route availability. */ +export const NATIVE_UI_ACTION_OPERATIONS = { + interfaceBuilder: "decode_interface_builder", + nativeSeed: "procedure_address", +} as const; + /** Build a UI-to-code trace from the active app and selected native provider. */ export const traceNativeUiAction = async ( analysis: AnalysisOperationPort, @@ -31,7 +38,7 @@ export const traceNativeUiAction = async ( ): EnhancedResult => { const [uiExecution, metadataExecution] = await Promise.all([ analysis.execute( - "decode_interface_builder", + NATIVE_UI_ACTION_OPERATIONS.interfaceBuilder, { max_documents: 64, max_objects: input.max_nodes, @@ -65,14 +72,14 @@ export const traceNativeUiAction = async ( if (!decoded.success) return err( new AnalysisOutputError( - "decode_interface_builder", + NATIVE_UI_ACTION_OPERATIONS.interfaceBuilder, "artifact provider returned an invalid interface graph", ), ); if (decoded.data.target_sha256 !== targetSha256) return err( new AnalysisOutputError( - "decode_interface_builder", + NATIVE_UI_ACTION_OPERATIONS.interfaceBuilder, "UI and native provider results refer to different target SHA-256 values", ), ); @@ -123,7 +130,7 @@ export const traceNativeUiAction = async ( let selected = resolveUiAction(graph, input.action); if (selected.nodeId === null && selected.candidates.length === 0) { const native = await analysis.execute( - "procedure_address", + NATIVE_UI_ACTION_OPERATIONS.nativeSeed, { procedure: input.action }, signal === undefined ? {} : { signal }, ); @@ -139,7 +146,7 @@ export const traceNativeUiAction = async ( if (!address.success || native.value.subject?.sha256 !== targetSha256) return err( new AnalysisOutputError( - "procedure_address", + NATIVE_UI_ACTION_OPERATIONS.nativeSeed, "Native seed lacks a validated address or matching target identity", ), ); diff --git a/tests/boundary/mcp/nativeUiAvailability.test.ts b/tests/boundary/mcp/nativeUiAvailability.test.ts new file mode 100644 index 0000000000..8dcc1f6895 --- /dev/null +++ b/tests/boundary/mcp/nativeUiAvailability.test.ts @@ -0,0 +1,688 @@ +import { rm } from "node:fs/promises"; + +import type { Client } from "@modelcontextprotocol/client"; +import type { CallToolResult } from "@modelcontextprotocol/server"; +import { afterEach, describe, expect, it } from "vitest"; +import { z } from "zod"; + +import type { + AnalysisExecution, + AnalysisClient, + AnalysisProvider, + CapabilityDescriptor, +} from "../../../src/application/AnalysisProvider.js"; +import { createAnalysisExecution } from "../../../src/application/AnalysisProvider.js"; +import type { AnalysisError } from "../../../src/domain/analysisErrorBase.js"; +import { + AnalysisCancelledError, + AnalysisOutputError, +} from "../../../src/domain/analysisErrorCore.js"; +import { + buildInterfaceBuilderAnalysis, + interfaceBuilderLimitsSchema, +} from "../../../src/domain/apple/interfaceBuilderGraph.js"; +import { inspectNativeDispatchMetadata } from "../../../src/domain/native/objcSwiftMetadata.js"; +import { nativeInvestigationTraceSchema } from "../../../src/domain/native/nativeInvestigationGraph.js"; +import type { JsonValue } from "../../../src/domain/jsonValue.js"; +import { err, ok, type Result } from "../../../src/domain/result.js"; +import type { AnalysisOperation } from "../../../src/application/AnalysisProvider.js"; +import { toolContract } from "../../../src/contracts/toolContracts.js"; +import { expandToolAvailability } from "../../../src/contracts/toolOutputSchemaPrimitives.js"; +import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { createSessionMcpHarness } from "./sessionMcpHarness.js"; + +const resources: Array<{ close(): Promise }> = []; +let directory: string | undefined; + +afterEach(async () => { + await Promise.all(resources.splice(0).map((resource) => resource.close())); + if (directory !== undefined) + await rm(directory, { recursive: true, force: true }); + directory = undefined; +}); + +const identity = { + id: "fixture", + name: "Fixture analysis provider", + version: "1", +}; +const controllerName = "BuildViewController"; +const selector = "buildTapped:"; +const handlerAddress = "0x1000"; +const calleeAddress = "0x2000"; +const dispatchMetadata = inspectNativeDispatchMetadata([ + { address: handlerAddress, name: `-[${controllerName} ${selector}]` }, +]); +const symbolInventory = [ + { address: handlerAddress, name: `-[${controllerName} ${selector}]` }, +]; +const interfaceBuilderFor = (targetSha256: string) => + buildInterfaceBuilderAnalysis({ + targetSha256, + toolVersion: "fixture", + documents: [ + { + relativePath: "Main.storyboardc/scene.nib/objects.nib", + archiveSha256: "b".repeat(64), + documentKind: "storyboard_scene", + raw: { + "com.apple.ibtool.document.objects": { + controller: { customClass: controllerName }, + button: { class: "UIButton", title: "Build" }, + }, + "com.apple.ibtool.document.connections": { + button: [ + { type: "action", label: selector, destinationId: "controller" }, + ], + }, + }, + }, + ], + limits: interfaceBuilderLimitsSchema.parse({}), + }); + +type ScenarioName = + | "authored-ui-direct" + | "authored-ui-symbols" + | "native-seed-typed" + | "native-seed-callees" + | "metadata-only" + | "missing-metadata" + | "native-route-unavailable" + | "metadata-runtime-failure" + | "decode-cancelled"; +type ProviderOperation = Extract< + AnalysisOperation, + | "decode_interface_builder" + | "list_names" + | "inspect_native_dispatch_metadata" + | "procedure_address" + | "procedure_references" + | "procedure_callees" +>; +type Scenario = { + readonly name: ScenarioName; + readonly capabilities: readonly { + readonly operation: ProviderOperation; + readonly available: true; + }[]; + readonly unavailable?: readonly { + readonly operation: ProviderOperation; + readonly reason: string; + }[]; + readonly failure?: "metadata-runtime" | "decode-cancelled"; +}; +type RouteScenario = Scenario & { + readonly route: "authored_ui_dispatch" | "native_seed_call_trace"; +}; + +const authoredUiDirectScenario: RouteScenario = { + name: "authored-ui-direct", + route: "authored_ui_dispatch", + capabilities: [ + { operation: "decode_interface_builder", available: true }, + { operation: "inspect_native_dispatch_metadata", available: true }, + ], +}; +const authoredUiSymbolsScenario: RouteScenario = { + name: "authored-ui-symbols", + route: "authored_ui_dispatch", + capabilities: [ + { operation: "decode_interface_builder", available: true }, + { operation: "list_names", available: true }, + ], +}; +const nativeSeedTypedScenario: RouteScenario = { + name: "native-seed-typed", + route: "native_seed_call_trace", + capabilities: [ + { operation: "inspect_native_dispatch_metadata", available: true }, + { operation: "procedure_address", available: true }, + { operation: "procedure_references", available: true }, + ], +}; +const nativeSeedCalleesScenario: RouteScenario = { + name: "native-seed-callees", + route: "native_seed_call_trace", + capabilities: [ + { operation: "inspect_native_dispatch_metadata", available: true }, + { operation: "procedure_address", available: true }, + { operation: "procedure_callees", available: true }, + ], +}; + +const routeScenarios = [ + authoredUiDirectScenario, + authoredUiSymbolsScenario, + nativeSeedTypedScenario, + nativeSeedCalleesScenario, +] as const; + +const metadataOnlyScenario: Scenario = { + name: "metadata-only", + capabilities: [ + { operation: "inspect_native_dispatch_metadata", available: true }, + ], +}; +const missingMetadataScenario: Scenario = { + name: "missing-metadata", + capabilities: [{ operation: "decode_interface_builder", available: true }], +}; +const unavailableNativeRouteScenario: Scenario = { + name: "native-route-unavailable", + capabilities: [ + { operation: "inspect_native_dispatch_metadata", available: true }, + { operation: "procedure_address", available: true }, + ], + unavailable: [ + { + operation: "procedure_references", + reason: "Typed references are unavailable in this fixture.", + }, + ], +}; +const metadataRuntimeFailureScenario: Scenario = { + name: "metadata-runtime-failure", + capabilities: [ + { operation: "decode_interface_builder", available: true }, + { operation: "inspect_native_dispatch_metadata", available: true }, + { operation: "list_names", available: true }, + ], + failure: "metadata-runtime", +}; +const decodeCancelledScenario: Scenario = { + name: "decode-cancelled", + capabilities: [ + { operation: "decode_interface_builder", available: true }, + { operation: "inspect_native_dispatch_metadata", available: true }, + { operation: "list_names", available: true }, + ], + failure: "decode-cancelled", +}; + +describe("native UI action availability through available routes", () => { + it.each(routeScenarios)( + "reports the $name route available when its observed path succeeds", + async (scenario) => { + directory = await createTestTempDirectory("rea-native-ui-availability-"); + const calls: ProviderOperation[] = []; + const { mcp, first } = await createSessionMcpHarness( + directory, + () => provider(scenario, calls), + resources, + ); + const opened = await mcp.callTool({ + name: "open_binary", + arguments: { path: first }, + }); + expect(opened.isError).not.toBe(true); + + try { + const inventory = await readInventory(mcp); + const result = await mcp.callTool({ + name: "trace_native_ui_action", + arguments: { + action: + scenario.route === "authored_ui_dispatch" + ? selector + : `-[${controllerName} ${selector}]`, + }, + }); + assertSuccessfulRoute(scenario, result, calls); + + expect(dispatchAvailability(inventory)).toMatchObject({ + available: true, + reason: "available", + }); + const entry = dispatchAvailability(inventory); + expect(entry?.modes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: scenario.route, + available: true, + }), + ]), + ); + expect(entry).not.toHaveProperty("default_mode_available"); + } finally { + const closed = await mcp.callTool({ + name: "close_binary", + arguments: {}, + }); + expect(closed.isError).not.toBe(true); + } + }, + ); +}); + +describe("native UI action availability for unmet routes", () => { + it.each([ + metadataOnlyScenario, + missingMetadataScenario, + unavailableNativeRouteScenario, + ])( + "reports unmet route sources for $name without hiding partial results", + async (scenario) => { + directory = await createTestTempDirectory("rea-native-ui-no-metadata-"); + const calls: ProviderOperation[] = []; + const { mcp, first } = await createSessionMcpHarness( + directory, + () => provider(scenario, calls), + resources, + ); + const opened = await mcp.callTool({ + name: "open_binary", + arguments: { path: first }, + }); + expect(opened.isError).not.toBe(true); + + try { + const inventory = await readInventory(mcp); + const result = await mcp.callTool({ + name: "trace_native_ui_action", + arguments: { + action: + scenario.name === "metadata-only" + ? "missingAction:" + : scenario.name === "native-route-unavailable" + ? `-[${controllerName} ${selector}]` + : selector, + }, + }); + if (scenario.name === "metadata-only") { + expect(result.isError).not.toBe(true); + const trace = z + .object({ normalized_result: nativeInvestigationTraceSchema }) + .parse(result.structuredContent).normalized_result; + expect(trace.edges).toEqual([]); + expect(trace.nodes).toEqual([]); + expect(trace.reason).toBe("ui_action_or_object_not_found"); + expect(trace.coverage).toContainEqual( + expect.objectContaining({ + facet: "interface_builder_archives", + status: "unsupported", + }), + ); + expect(calls).toEqual(["inspect_native_dispatch_metadata"]); + } else if (scenario.name === "missing-metadata") { + expect(result.isError).toBe(true); + expect(parseMcpToolError(result)).toMatchObject({ + error: { code: "capability_unavailable" }, + }); + expect(calls).toContain("decode_interface_builder"); + expect(calls).not.toContain("inspect_native_dispatch_metadata"); + } else { + expect(result.isError).not.toBe(true); + expect(calls).toContain("procedure_address"); + expect(calls).not.toContain("procedure_references"); + } + const entry = dispatchAvailability(inventory); + expect(entry).toMatchObject({ + available: false, + reason: + scenario.name === "native-route-unavailable" + ? "unsupported_host" + : "provider_missing", + }); + expect(entry?.modes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "authored_ui_dispatch", + available: false, + }), + expect.objectContaining({ + name: "native_seed_call_trace", + available: false, + }), + ]), + ); + if (scenario.name === "metadata-only") { + const authored = entry?.modes?.find( + (mode) => mode.name === "authored_ui_dispatch", + ); + expect(authored?.missing_operations).toEqual([ + "decode_interface_builder", + ]); + } + if (scenario.name === "missing-metadata") { + const authored = entry?.modes?.find( + (mode) => mode.name === "authored_ui_dispatch", + ); + expect(authored?.missing_operations).toEqual([ + "inspect_native_dispatch_metadata", + "list_names", + ]); + } + if (scenario.name === "native-route-unavailable") { + const native = entry?.modes?.find( + (mode) => mode.name === "native_seed_call_trace", + ); + expect(native?.missing_operations).toEqual([ + "procedure_references", + "procedure_callees", + ]); + } + } finally { + const closed = await mcp.callTool({ + name: "close_binary", + arguments: {}, + }); + expect(closed.isError).not.toBe(true); + } + }, + ); +}); + +describe("native UI action availability for failures and target gates", () => { + it.each([metadataRuntimeFailureScenario, decodeCancelledScenario])( + "preserves runtime failures for $name without trying another declared source", + async (scenario) => { + directory = await createTestTempDirectory( + "rea-native-ui-source-failure-", + ); + const calls: ProviderOperation[] = []; + const { mcp, first } = await createSessionMcpHarness( + directory, + () => provider(scenario, calls), + resources, + ); + expect( + ( + await mcp.callTool({ + name: "open_binary", + arguments: { path: first }, + }) + ).isError, + ).not.toBe(true); + try { + const inventory = await readInventory(mcp); + const result = await mcp.callTool({ + name: "trace_native_ui_action", + arguments: { action: selector }, + }); + expect(result.isError).toBe(true); + expect(parseMcpToolError(result).error.code).toBe( + scenario.name === "decode-cancelled" + ? "cancelled" + : "unreadable_output", + ); + if (scenario.name === "metadata-runtime-failure") { + expect(calls).toContain("inspect_native_dispatch_metadata"); + expect(calls).not.toContain("list_names"); + } else { + expect(calls).toContain("decode_interface_builder"); + expect(calls).not.toContain("list_names"); + } + expect(dispatchAvailability(inventory)).toMatchObject({ + available: true, + }); + } finally { + expect( + (await mcp.callTool({ name: "close_binary", arguments: {} })).isError, + ).not.toBe(true); + } + }, + ); + + it("reports target_required before querying provider operations", async () => { + directory = await createTestTempDirectory("rea-native-ui-no-target-"); + const calls: ProviderOperation[] = []; + const { mcp } = await createSessionMcpHarness( + directory, + () => provider(authoredUiDirectScenario, calls), + resources, + ); + const inventory = await readInventory(mcp); + expect(inventory.result.open).toBe(false); + expect(dispatchAvailability(inventory)).toMatchObject({ + available: false, + reason: "target_required", + }); + expect(calls).toEqual([]); + }); +}); + +const readInventory = async (mcp: Client) => { + const result = await mcp.callTool({ name: "binary_session", arguments: {} }); + return toolContract("binary_session").outputSchema.parse( + result.structuredContent, + ); +}; + +const dispatchAvailability = ( + inventory: Awaited>, +) => + expandToolAvailability(inventory.result.tool_availability).find( + (entry) => entry.name === "trace_native_ui_action", + ); + +const assertSuccessfulRoute = ( + scenario: RouteScenario, + result: CallToolResult, + calls: readonly ProviderOperation[], +): void => { + expect(result.isError).not.toBe(true); + const trace = z + .object({ normalized_result: nativeInvestigationTraceSchema }) + .parse(result.structuredContent).normalized_result; + expect(trace.target_sha256).toMatch(/^[a-f0-9]{64}$/u); + if (scenario.route === "authored_ui_dispatch") { + expect(trace.edges).toContainEqual( + expect.objectContaining({ + relation: "objc_dispatch", + to: `native:function:${handlerAddress}`, + }), + ); + expect(calls).toContain("decode_interface_builder"); + expect(calls).toContain( + scenario.name === "authored-ui-direct" + ? "inspect_native_dispatch_metadata" + : "list_names", + ); + expect(calls).not.toContain( + scenario.name === "authored-ui-direct" + ? "list_names" + : "inspect_native_dispatch_metadata", + ); + expect(calls).not.toContain("procedure_address"); + return; + } + expect(trace.start).toBe(`native:function:${handlerAddress}`); + expect(trace.edges).toContainEqual( + expect.objectContaining({ + relation: + scenario.name === "native-seed-typed" ? "direct_call" : "provider_call", + from: `native:function:${handlerAddress}`, + to: `native:function:${calleeAddress}`, + }), + ); + expect(calls).not.toContain("decode_interface_builder"); + expect(calls).toContain("inspect_native_dispatch_metadata"); + expect(calls).toContain("procedure_address"); + expect(calls).toContain( + scenario.name === "native-seed-typed" + ? "procedure_references" + : "procedure_callees", + ); +}; + +const capabilityEffects = { + mutatesArtifact: false, + launchesProcess: false, + mayShowUi: false, + mayAccessNetwork: false, + mayWriteFilesystem: false, + changesPermissions: false, + requiresRoot: false, +}; + +const providerOperations = new Set([ + "decode_interface_builder", + "list_names", + "inspect_native_dispatch_metadata", + "procedure_address", + "procedure_references", + "procedure_callees", +]); + +const isProviderOperation = ( + operation: AnalysisOperation, +): operation is ProviderOperation => providerOperations.has(operation); + +type ProviderFixtureContext = { + readonly scenario: Scenario; + readonly calls: ProviderOperation[]; + readonly target: Parameters[0]; + readonly profile: Parameters[1]; +}; + +const provider = ( + scenario: Scenario, + calls: ProviderOperation[], +): AnalysisProvider => { + const capabilities: CapabilityDescriptor[] = [ + ...scenario.capabilities.map((capability) => { + return { + provider: identity, + operation: capability.operation, + effects: capabilityEffects, + limitations: [], + available: true as const, + reason: null, + }; + }), + ...(scenario.unavailable ?? []).map((capability) => { + return { + provider: identity, + operation: capability.operation, + effects: capabilityEffects, + limitations: [], + available: false as const, + availabilityCode: "unsupported_host" as const, + reason: capability.reason, + }; + }), + ]; + return { + identity: () => identity, + capabilities: () => capabilities, + createClient: (target, profile): AnalysisClient => ({ + execute: (operation, parameters) => + executeProviderFixture( + { scenario, calls, target, profile }, + operation, + parameters, + ), + close: async () => ok(null), + }), + }; +}; + +const executeProviderFixture = async ( + context: ProviderFixtureContext, + operation: AnalysisOperation, + parameters: Readonly>, +): Promise> => { + if (operation === "health") + return ok(createAnalysisExecution(null, identity)); + if (!isProviderOperation(operation)) + throw new Error(`Unexpected provider operation: ${operation}`); + context.calls.push(operation); + const failure = failureFor(context.scenario, operation); + if (failure !== undefined) return err(failure); + return ok( + createAnalysisExecution( + resultFor(operation, parameters, context.target.sha256, context.profile), + identity, + { + subject: context.target, + ...(context.profile === undefined + ? {} + : { analysisProfile: context.profile }), + }, + ), + ); +}; + +const failureFor = ( + scenario: Scenario, + operation: ProviderOperation, +): AnalysisError | undefined => { + if ( + operation === "inspect_native_dispatch_metadata" && + scenario.failure === "metadata-runtime" + ) + return new AnalysisOutputError( + "inspect_native_dispatch_metadata", + "fixture failure", + ); + if ( + operation === "decode_interface_builder" && + scenario.failure === "decode-cancelled" + ) + return new AnalysisCancelledError("decode_interface_builder"); + return undefined; +}; + +const resultFor = ( + operation: ProviderOperation, + parameters: Readonly>, + targetSha256: string, + profile: Parameters[1], +): unknown => { + switch (operation) { + case "decode_interface_builder": + return interfaceBuilderFor(targetSha256); + case "inspect_native_dispatch_metadata": + return { + target_sha256: targetSha256, + provider: identity, + analysis_profile_digest: profile?.digest ?? null, + result: dispatchMetadata, + }; + case "procedure_address": + return handlerAddress; + case "list_names": + return symbolInventory; + case "procedure_callees": + return parameters.procedure === handlerAddress ? [calleeAddress] : []; + case "procedure_references": + return typedCallReferences( + typeof parameters.procedure === "string" ? parameters.procedure : "", + ); + } +}; + +const typedCallReferences = (procedure: string) => ({ + reference_kinds_available: true, + references: + procedure === handlerAddress + ? [ + { + source_address: "0x1008", + target_address: calleeAddress, + target_procedure: { address: calleeAddress }, + kind: { + available: true, + provenance: "fixture typed reference", + type: "call", + flow: true, + call: true, + jump: false, + data: false, + read: false, + write: false, + indirect: false, + computed: false, + conditional: false, + terminal: false, + primary: true, + operand_index: 0, + external: false, + }, + }, + ] + : [], + unresolved_calls: [], +});