From a80575c73652d8b43d7340654a48bd20a4eb966d Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Mon, 19 Oct 2026 07:12:22 +0800 Subject: [PATCH] fix(native): preserve partial call reference evidence Retain provider-reported call sites and raw classification independently of generic reference flags. Keep all endpoint evidence when containing-procedure routes merge, and include relation in edge identity to avoid order-dependent labels. Closes #5130 --- docs/native-investigation.md | 18 +- src/application/native/NativeCallRoutes.ts | 39 ++- src/application/native/NativeUiActionTrace.ts | 61 ++-- src/domain/hopperValues.ts | 9 +- src/domain/native/nativeCallReference.ts | 15 + src/domain/native/objcSwiftMetadata.ts | 2 + .../mcp/nativeUiPartialCallRoutesMcp.test.ts | 305 +++++++++++++++++- 7 files changed, 393 insertions(+), 56 deletions(-) create mode 100644 src/domain/native/nativeCallReference.ts diff --git a/docs/native-investigation.md b/docs/native-investigation.md index 8ff8282254..b07fb53e1d 100644 --- a/docs/native-investigation.md +++ b/docs/native-investigation.md @@ -216,12 +216,24 @@ 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 +Incomplete reference kinds do not discard known typed calls or partial +provider call references. A `provider_call` with `observed` resolution retains +the provider-reported call site and endpoint without claiming generic direct, +indirect or Objective-C dispatch semantics. Its `call_reference` evidence +preserves the original endpoints, classification, raw provider type and +provenance, even when the target node is a containing procedure. Hopper's +`none`, `unknown`, `direct` and `objective_c` labels stay provider-scoped; +unknown numeric types remain available in the raw type field. Target-only +callee fallback uses `provider_call` with `inferred` resolution. + +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. +Reports sharing a call site and containing target procedure retain all their +original endpoint evidence on the shared graph edge. Different reported +relations retain separate edges, independent of enumeration order. Session discovery describes two automatic source routes for this trace: `authored_ui_dispatch` needs Interface Builder decoding and dispatch metadata; diff --git a/src/application/native/NativeCallRoutes.ts b/src/application/native/NativeCallRoutes.ts index f4018b0a57..9c082c1966 100644 --- a/src/application/native/NativeCallRoutes.ts +++ b/src/application/native/NativeCallRoutes.ts @@ -12,8 +12,12 @@ 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"; +import { + nativeCallClassificationSchema, + type NativeCallReference, +} from "../../domain/native/nativeCallReference.js"; -/** Preferred typed observations and the untyped fallback share one owner. */ +/** Detailed or partial observations and the untyped fallback share one owner. */ export const NATIVE_CALL_ROUTE_SOURCES = { references: "procedure_references", callees: "procedure_callees", @@ -34,6 +38,7 @@ const referencesSchema = z.object({ target_address: z.string(), target_procedure: z.object({ address: z.string() }).nullable(), kind: referenceKindSchema, + call: nativeCallClassificationSchema.exactOptional(), }), ), unresolved_calls: z.array( @@ -47,6 +52,7 @@ interface NativeCallRoute { readonly relation: "direct_call" | "indirect_call" | "provider_call"; readonly resolution: "observed" | "resolved" | "ambiguous" | "inferred"; readonly provider: ProviderIdentity; + readonly callReference?: NativeCallReference; } /** Enumeration failure does not invalidate facts already obtained. */ @@ -61,11 +67,13 @@ interface NativeCallRouteRead { readonly outcome: Result; } -const typedCallRoutes = ( +const observedCallRoutes = ( references: z.infer["references"], provider: ProviderIdentity, ): NativeCallRoute[] => { - const calls = references.filter(({ kind }) => kind.available && kind.call); + const calls = references.filter(({ kind, call }) => + kind.available ? kind.call : call !== undefined, + ); const countBySite = new Map>(); for (const call of calls) { const targets = countBySite.get(call.source_address) ?? new Set(); @@ -76,20 +84,31 @@ const typedCallRoutes = ( provider, address: call.target_procedure?.address ?? call.target_address, site: call.source_address, - relation: - call.kind.available && (call.kind.computed || call.kind.indirect) + ...(call.call === undefined + ? {} + : { + callReference: { + ...call.call, + source_address: call.source_address, + target_address: call.target_address, + }, + }), + relation: !call.kind.available + ? ("provider_call" as const) + : call.kind.computed || call.kind.indirect ? ("indirect_call" as const) : ("direct_call" as const), - resolution: - (countBySite.get(call.source_address)?.size ?? 0) > 1 + resolution: !call.kind.available + ? ("observed" as const) + : (countBySite.get(call.source_address)?.size ?? 0) > 1 ? ("ambiguous" as const) - : call.kind.available && (call.kind.computed || call.kind.indirect) + : call.kind.computed || call.kind.indirect ? ("resolved" as const) : ("observed" as const), })); }; -/** Retain typed observations while supplementing incomplete kinds with inferred callees. */ +/** Retain available observations while supplementing incomplete kinds with inferred callees. */ export const readNativeCallRoutes = async ( analysis: AnalysisOperationPort, procedure: string, @@ -148,7 +167,7 @@ export const readNativeCallRoutes = async ( })), ); const references = parsed.data.references; - routes.push(...typedCallRoutes(references, execution.value.provider)); + routes.push(...observedCallRoutes(references, execution.value.provider)); if ( references.every(({ kind }) => kind.available) && (parsed.data.reference_kinds_available || references.length > 0) diff --git a/src/application/native/NativeUiActionTrace.ts b/src/application/native/NativeUiActionTrace.ts index bc67af6c45..d8e5b09651 100644 --- a/src/application/native/NativeUiActionTrace.ts +++ b/src/application/native/NativeUiActionTrace.ts @@ -471,39 +471,43 @@ const expandNativeCalls = async (input: { } for (const [index, route] of execution.routes.entries()) { const address = route.address; - if (edges.size >= input.input.max_edges) { + const targetId = `native:function:${address}`; + const targetOmitted = + !nodes.has(targetId) && nodes.size >= input.input.max_nodes; + const edgeId = `${targetOmitted ? "unknown-node-bound" : "call"}:${current.address}:${route.site}:${address}:${route.relation}`; + if (!edges.has(edgeId) && edges.size >= input.input.max_edges) { truncated = true; omitted += execution.routes.length - index; break; } - const targetId = `native:function:${address}`; - const evidence = [ - { - kind: "reference" as const, - 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, - }, - ]; + const evidence = edges.get(edgeId)?.evidence ?? []; + evidence.push({ + kind: "reference", + provider: route.provider, + description: `${route.provider.name} reported ${route.relation} from ${current.address} to ${address}`, + location: { address: route.site, file_offset: null }, + ...(route.callReference === undefined + ? {} + : { call_reference: route.callReference }), + artifact_path: null, + artifact_sha256: input.targetSha256, + }); + if (targetOmitted) { + truncated = true; + omitted += 1; + edges.set(edgeId, { + id: edgeId, + from: current.node.id, + to: null, + relation: route.relation, + resolution: "unresolved", + reason: "native_trace_node_bound_reached", + evidence, + limitations: [], + }); + continue; + } if (!nodes.has(targetId)) { - if (nodes.size >= input.input.max_nodes) { - truncated = true; - omitted += 1; - const id = `unknown-node-bound:${current.address}:${route.site}:${address}`; - edges.set(id, { - id, - from: current.node.id, - to: null, - relation: route.relation, - resolution: "unresolved", - reason: "native_trace_node_bound_reached", - evidence, - limitations: [], - }); - continue; - } const name = addressNames.get(address) ?? address; const node = { id: targetId, @@ -516,7 +520,6 @@ const expandNativeCalls = async (input: { nodes.set(targetId, node); queue.push({ node, address, depth: current.depth + 1 }); } - const edgeId = `call:${current.address}:${route.site}:${address}`; edges.set(edgeId, { id: edgeId, from: current.node.id, diff --git a/src/domain/hopperValues.ts b/src/domain/hopperValues.ts index 4fbdb72db2..e35338fd90 100644 --- a/src/domain/hopperValues.ts +++ b/src/domain/hopperValues.ts @@ -7,6 +7,7 @@ import type { JsonValue } from "./jsonValue.js"; import { err, ok, type Result } from "./result.js"; import { nativeApiBoundarySchema } from "./native/nativeApiBoundary.js"; import { nativeValueFlowSchema } from "./native/nativeValueFlow.js"; +import { nativeCallClassificationSchema } from "./native/nativeCallReference.js"; import { AnalysisOutputError } from "./analysisErrorCore.js"; export interface AddressedName { @@ -350,13 +351,7 @@ export const referenceEdgeSchema = z source_procedure: procedureIdentitySchema.nullable(), target_procedure: procedureIdentitySchema.nullable(), kind: referenceKindSchema, - call: z - .strictObject({ - classification: z.enum(["none", "unknown", "direct", "objective_c"]), - provider_type: z.number().int(), - provenance: z.string().min(1), - }) - .exactOptional(), + call: nativeCallClassificationSchema.exactOptional(), }) .strict(); const facetAvailabilitySchema = z.discriminatedUnion("available", [ diff --git a/src/domain/native/nativeCallReference.ts b/src/domain/native/nativeCallReference.ts new file mode 100644 index 0000000000..aa8a6e3b2b --- /dev/null +++ b/src/domain/native/nativeCallReference.ts @@ -0,0 +1,15 @@ +import { z } from "zod"; + +/** Partial provider classification; it does not establish generic reference flags. */ +export const nativeCallClassificationSchema = z.strictObject({ + classification: z.enum(["none", "unknown", "direct", "objective_c"]), + provider_type: z.number().int(), + provenance: z.string().min(1), +}); + +/** Original provider endpoints, before resolving a containing procedure. */ +export const nativeCallReferenceSchema = nativeCallClassificationSchema.extend({ + source_address: z.string(), + target_address: z.string(), +}); +export type NativeCallReference = z.infer; diff --git a/src/domain/native/objcSwiftMetadata.ts b/src/domain/native/objcSwiftMetadata.ts index 89afb599e3..85649ece29 100644 --- a/src/domain/native/objcSwiftMetadata.ts +++ b/src/domain/native/objcSwiftMetadata.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { isMachOFormat, type BinaryTarget } from "../binaryTargetTypes.js"; import { providerSchema } from "../evidence.js"; import { isSwiftMangledSymbolName } from "../swiftSymbolClassification.js"; +import { nativeCallReferenceSchema } from "./nativeCallReference.js"; import { compareUnicodeCodePoints, @@ -126,6 +127,7 @@ export const nativeMetadataEvidenceSchema = z.strictObject({ location: nativeMetadataLocationSchema, /** Preserve a provider's source value when it does not establish a location. */ reported_address: z.string().optional(), + call_reference: nativeCallReferenceSchema.exactOptional(), artifact_path: z.string().min(1).nullable().default(null), artifact_sha256: z .string() diff --git a/tests/boundary/mcp/nativeUiPartialCallRoutesMcp.test.ts b/tests/boundary/mcp/nativeUiPartialCallRoutesMcp.test.ts index 22440563a2..baa844025e 100644 --- a/tests/boundary/mcp/nativeUiPartialCallRoutesMcp.test.ts +++ b/tests/boundary/mcp/nativeUiPartialCallRoutesMcp.test.ts @@ -52,14 +52,40 @@ function typedKind(indirect = false) { external: false, }; } -function ref(site: string, target: string, indirect = false) { +type CallReference = { + readonly classification: "none" | "unknown" | "direct" | "objective_c"; + readonly provider_type: number; + readonly provenance: string; +}; +function ref( + site: string, + target: string, + indirect = false, + callReference?: CallReference, + targetProcedure = target, +) { return { source_address: site, target_address: target, - target_procedure: { address: target }, - kind: typedKind(indirect), + target_procedure: { address: targetProcedure }, + kind: + callReference === undefined + ? typedKind(indirect) + : { + available: false as const, + reason: "Detailed reference-kind flags unavailable", + }, + ...(callReference === undefined ? {} : { call: callReference }), }; } +const partialCall = ( + classification: CallReference["classification"], + provider_type: number, +): CallReference => ({ + classification, + provider_type, + provenance: "hopper-public-python-api:CallReference", +}); const mixedReferences = (refs = [ref("0x1004", "0x2000")]) => ({ reference_kinds_available: false, references: [ @@ -85,12 +111,12 @@ const noClassifiedReferences = { }; type Plan = { - readonly references?: ReturnType; + readonly references?: unknown; readonly callees?: readonly string[]; readonly fallbackProvider?: typeof identity; readonly fallbackFailure?: "unavailable" | "output" | "cancelled"; }; -function publicTrace(plan: Plan, maxNodes = 12) { +function publicTrace(plan: Plan, maxNodes = 12, maxEdges = 500) { const analysis: AnalysisOperationPort = { execute: async ( operation, @@ -173,7 +199,12 @@ function publicTrace(plan: Plan, maxNodes = 12) { await client.connect(clientTransport); const result = await client.callTool({ name: "trace_native_ui_action", - arguments: { action: "0x1000", max_nodes: maxNodes, max_depth: 1 }, + arguments: { + action: "0x1000", + max_nodes: maxNodes, + max_edges: maxEdges, + max_depth: 1, + }, }); return result; })(); @@ -421,7 +452,267 @@ it("retains separate omitted callsites when routes share a target", async () => }); it("keeps cancellation terminal when fallback enumeration is cancelled", async () => { - const response = await publicTrace({ fallbackFailure: "cancelled" }); + const response = await publicTrace({ + references: completeReferences([ + ref("0x1004", "0x2000", false, partialCall("direct", 2)), + ]), + fallbackFailure: "cancelled", + }); expect(response.isError).toBe(true); expect(parseMcpToolError(response).error.code).toBe("cancelled"); }); + +it.each([ + { classification: "direct", provider_type: 2 }, + { classification: "objective_c", provider_type: 3 }, + { classification: "none", provider_type: 0 }, + { classification: "unknown", provider_type: 1 }, + { classification: "unknown", provider_type: 99 }, +] as const)( + "retains raw $classification CallReference facts without inventing generic flags", + async ({ classification, provider_type }) => { + const call = partialCall(classification, provider_type); + const response = await publicTrace({ + references: completeReferences([ + ref("0x1004", "0x2000", false, call, "0x2fff"), + ]), + callees: [], + }); + expect(response.isError).not.toBe(true); + const trace = traceFrom(response); + expect(trace.edges).toContainEqual( + expect.objectContaining({ + from: "native:function:0x1000", + to: "native:function:0x2fff", + relation: "provider_call", + resolution: "observed", + evidence: expect.arrayContaining([ + expect.objectContaining({ + provider: identity, + location: expect.objectContaining({ address: "0x1004" }), + call_reference: { + ...call, + source_address: "0x1004", + target_address: "0x2000", + }, + }), + ]), + }), + ); + expect(trace.edges.some((edge) => edge.relation === "objc_dispatch")).toBe( + false, + ); + }, +); + +it("deduplicates fallback overlap while preserving CallReference site and provider", async () => { + const call = partialCall("direct", 2); + const response = await publicTrace({ + references: completeReferences([ref("0x1004", "0x2000", false, call)]), + callees: ["0x2000", "0x3000"], + }); + expect(response.isError).not.toBe(true); + const trace = traceFrom(response); + const targetEdges = trace.edges.filter( + (edge) => edge.to === "native:function:0x2000", + ); + expect(targetEdges).toHaveLength(1); + expect(targetEdges[0]).toMatchObject({ + relation: "provider_call", + resolution: "observed", + evidence: [ + expect.objectContaining({ + provider: identity, + location: expect.objectContaining({ address: "0x1004" }), + call_reference: expect.objectContaining({ + classification: "direct", + provider_type: 2, + provenance: "hopper-public-python-api:CallReference", + source_address: "0x1004", + target_address: "0x2000", + }), + }), + ], + }); +}); + +it.each(["unavailable", "output"] as const)( + "preserves partial CallReference evidence when fallback has %s failure", + async (fallbackFailure) => { + const call = partialCall("direct", 2); + const response = await publicTrace({ + references: completeReferences([ + ref("0x1004", "0x2000", false, call, "0x2fff"), + ]), + fallbackFailure, + }); + expect(response.isError).not.toBe(true); + const trace = traceFrom(response); + expect(trace.edges).toContainEqual( + expect.objectContaining({ + to: "native:function:0x2fff", + relation: "provider_call", + resolution: "observed", + evidence: expect.arrayContaining([ + expect.objectContaining({ + provider: identity, + location: expect.objectContaining({ address: "0x1004" }), + call_reference: expect.objectContaining({ + ...call, + source_address: "0x1004", + target_address: "0x2000", + }), + }), + ]), + }), + ); + expect(trace.unresolved).toContainEqual( + expect.objectContaining({ + to: null, + relation: "provider_call", + resolution: "unresolved", + limitations: expect.arrayContaining([ + "The provider could not complete callee enumeration.", + ]), + }), + ); + }, +); + +it("keeps partial CallReference evidence on a node-bound unresolved edge", async () => { + const response = await publicTrace( + { + references: completeReferences([ + ref("0x1004", "0x2000", false, partialCall("direct", 2)), + ref("0x1008", "0x3000", false, partialCall("unknown", 99)), + ]), + callees: [], + }, + 2, + ); + expect(response.isError).not.toBe(true); + const trace = traceFrom(response); + expect(trace.unresolved).toContainEqual( + expect.objectContaining({ + relation: "provider_call", + resolution: "unresolved", + evidence: expect.arrayContaining([ + expect.objectContaining({ + provider: identity, + location: expect.objectContaining({ address: "0x1008" }), + call_reference: expect.objectContaining({ + classification: "unknown", + provider_type: 99, + source_address: "0x1008", + target_address: "0x3000", + }), + }), + ]), + }), + ); +}); + +it("reports malformed partial CallReference as unreadable route evidence", async () => { + const response = await publicTrace({ + references: { + reference_kinds_available: false, + references: [ + { + ...ref("0x1004", "0x2000"), + kind: { available: false, reason: "Detailed flags unavailable" }, + call: { + classification: "objectivec", + provider_type: 3, + provenance: "hopper-public-python-api:CallReference", + }, + }, + ], + unresolved_calls: [], + }, + fallbackFailure: "unavailable", + }); + expect(response.isError).not.toBe(true); + const trace = traceFrom(response); + expect(trace.edges).toEqual([]); + expect(trace.unresolved).toContainEqual( + expect.objectContaining({ + relation: "provider_call", + resolution: "unresolved", + reason: expect.stringContaining("unreadable result"), + }), + ); +}); + +it.each([2, 12])( + "retains original endpoints on a shared edge at the edge bound (maxNodes=%s)", + async (maxNodes) => { + const response = await publicTrace( + { + references: completeReferences( + [ + ref("0x1004", "0x2004", false, partialCall("direct", 2), "0x2000"), + ref( + "0x1004", + "0x2008", + false, + partialCall("unknown", 99), + "0x2000", + ), + ref("0x1008", "0x3000", false, partialCall("direct", 2)), + ].reverse(), + ), + callees: [], + }, + maxNodes, + 2, + ); + expect(response.isError).not.toBe(true); + const trace = traceFrom(response); + const evidence = [...trace.edges, ...trace.unresolved].flatMap( + (edge) => edge.evidence, + ); + expect(evidence.map((item) => item.call_reference)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ target_address: "0x2004", provider_type: 2 }), + expect.objectContaining({ + target_address: "0x2008", + provider_type: 99, + }), + ]), + ); + }, +); + +it.each([ + { maxNodes: 2, reverse: false }, + { maxNodes: 2, reverse: true }, + { maxNodes: 12, reverse: false }, + { maxNodes: 12, reverse: true }, +])( + "preserves typed and partial relations sharing a site and target ($maxNodes/$reverse)", + async ({ maxNodes, reverse }) => { + const references = [ + ref("0x1004", "0x2000"), + ref("0x1004", "0x2008", false, partialCall("unknown", 99), "0x2000"), + ]; + const response = await publicTrace( + { + references: completeReferences([ + ref("0x1008", "0x3000"), + ...(reverse ? references.reverse() : references), + ]), + callees: [], + }, + maxNodes, + ); + expect(response.isError).not.toBe(true); + const trace = traceFrom(response); + const routes = [...trace.edges, ...trace.unresolved].filter((edge) => + edge.evidence.some((item) => item.location.address === "0x1004"), + ); + expect(routes.map((edge) => edge.relation).sort()).toEqual([ + "direct_call", + "provider_call", + ]); + }, +);