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
This commit is contained in:
morluto
2026-10-19 07:12:22 +08:00
parent fdaffe21cc
commit a80575c736
7 changed files with 393 additions and 56 deletions
+15 -3
View File
@@ -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;
+29 -10
View File
@@ -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<void, AnalysisError>;
}
const typedCallRoutes = (
const observedCallRoutes = (
references: z.infer<typeof referencesSchema>["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<string, Set<string>>();
for (const call of calls) {
const targets = countBySite.get(call.source_address) ?? new Set<string>();
@@ -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)
+32 -29
View File
@@ -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,
+2 -7
View File
@@ -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", [
+15
View File
@@ -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<typeof nativeCallReferenceSchema>;
+2
View File
@@ -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()
@@ -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<typeof mixedReferences>;
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",
]);
},
);