diff --git a/src/application/native/EnhancedNativeInvestigation.test.ts b/src/application/native/EnhancedNativeInvestigation.test.ts index e81dcb8b19..1e1b517b62 100644 --- a/src/application/native/EnhancedNativeInvestigation.test.ts +++ b/src/application/native/EnhancedNativeInvestigation.test.ts @@ -23,6 +23,20 @@ import type { Result } from "../../domain/result.js"; const target = "a".repeat(64); const provider = { id: "fixture", name: "Fixture", version: "1" }; +const methodName = "-[BuildViewController buildTapped:]"; +const methodSymbol = { + primary: true, + dynamic: false, + external: false, + type: "function", + source: "user_defined", +}; +const methodInventoryEntry = { + address: "0x1000", + value: methodName, + symbol: methodSymbol, +}; + const evidence = [ { kind: "interface_builder_resource" as const, @@ -258,8 +272,12 @@ describe("native UI action trace", () => { }, }); }); - it("builds the evidence path from the active app and native provider", async () => { - const tools = new EnhancedTools(testAnalysis()); + it("builds the evidence path from provider names with declared symbol facts", async () => { + const tools = new EnhancedTools( + testAnalysis({ + list_names: () => execution("list_names", [methodInventoryEntry]), + }), + ); const result = await tools.execute("trace_native_ui_action", { action: "buildTapped:", }); @@ -392,18 +410,117 @@ describe("bounded native UI tracing", () => { error: { _tag: "AnalysisInputError" }, }); }); +}); - it("binds dispatch metadata to the active target digest", async () => { - const result = await new EnhancedTools(testAnalysis()).execute( - "inspect_native_dispatch_metadata", - { max_records: 10 }, - ); +describe("native dispatch inventory", () => { + it.each([ + { label: "plain value", entry: { address: "0x1000", value: methodName } }, + { label: "value with symbol facts", entry: methodInventoryEntry }, + { + label: "legacy name with symbol facts", + entry: { address: "0x1000", name: methodName, symbol: methodSymbol }, + }, + ])( + "binds $label inventory to the target without overstating dispatch coverage", + async ({ entry }) => { + const result = await new EnhancedTools( + testAnalysis({ + list_names: () => execution("list_names", [entry]), + }), + ).execute("inspect_native_dispatch_metadata", { max_records: 10 }); - expect(result).toMatchObject({ - ok: true, - value: { target_sha256: target }, - }); - }); + expect(result).toMatchObject({ + ok: true, + value: { + target_sha256: target, + provider, + result: { + objc_dispatch_implementations: [ + { + class_name: "BuildViewController", + selector: "buildTapped:", + implementation_address: "0x1000", + decode: { status: "partial", reason: "symbol_name_only" }, + }, + ], + coverage: expect.arrayContaining([ + expect.objectContaining({ + facet: "objc_dispatch_implementations", + status: "partial", + }), + expect.objectContaining({ + facet: "swift_dispatch_tables", + status: "unsupported", + }), + expect.objectContaining({ + facet: "relative_pointers", + status: "unsupported", + }), + ]), + }, + }, + }); + }, + ); + + it.each([ + { + label: "malformed symbol flag", + entry: { + ...methodInventoryEntry, + symbol: { ...methodSymbol, primary: "true" }, + }, + }, + { + label: "unknown symbol source", + entry: { + ...methodInventoryEntry, + symbol: { ...methodSymbol, source: "unknown" }, + }, + }, + { + label: "incomplete symbol", + entry: { ...methodInventoryEntry, symbol: { primary: true } }, + }, + { + label: "malformed legacy symbol", + entry: { address: "0x1000", name: methodName, symbol: null }, + }, + { + label: "malformed address", + entry: { ...methodInventoryEntry, address: 4096 }, + }, + { + label: "malformed value with a legacy name", + entry: { ...methodInventoryEntry, value: 42, name: methodName }, + }, + { + label: "undeclared field", + entry: { address: "0x1000", value: methodName, unexpected: true }, + }, + ])( + "rejects $label inventory in both dispatch consumers", + async ({ entry }) => { + const tools = new EnhancedTools( + testAnalysis({ + list_names: () => execution("list_names", [entry]), + }), + ); + for (const [operation, parameters] of [ + ["inspect_native_dispatch_metadata", { max_records: 10 }], + ["trace_native_ui_action", { action: "buildTapped:" }], + ] as const) { + expect(await tools.execute(operation, parameters)).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisOutputError", + operation: "list_names", + reason: "provider returned an invalid inventory", + }, + }); + } + }, + ); }); describe("native UI query outcomes", () => { diff --git a/src/application/native/NativeDispatchMetadataInspection.ts b/src/application/native/NativeDispatchMetadataInspection.ts index 85c1a69c30..e72d17dbff 100644 --- a/src/application/native/NativeDispatchMetadataInspection.ts +++ b/src/application/native/NativeDispatchMetadataInspection.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { addressedValue } from "../../contracts/toolOutputSchemaPrimitives.js"; import { nativeDispatchMetadataResultSchema, inspectNativeDispatchMetadata, @@ -36,8 +37,11 @@ export const inspectNativeDispatch = async ( const names = z .array( z.union([ - z.strictObject({ address: z.string(), value: z.string() }), - z.strictObject({ address: z.string(), name: z.string() }), + addressedValue.strict(), + addressedValue + .omit({ value: true }) + .extend({ name: z.string() }) + .strict(), ]), ) .safeParse(execution.value.result); 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 62246a85c7..add15a6b29 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 @@ -104,7 +104,16 @@ const fixturePort = (): AnalysisOperationPort => ({ "0x11": "_OBJC_CLASS_$_Fixture", "0x12": "_OBJC_PROTOCOL_$_FixtureDelegate", "0x13": "entry", - }), + }).map((entry) => ({ + ...entry, + symbol: { + primary: true, + dynamic: false, + external: false, + type: "label", + source: "analysis", + }, + })), ), ); case "decode_interface_builder":