fix(native): preserve implementation file locations (#8667)

fix(native): keep UI handler offsets distinct from method records
This commit is contained in:
ShuaixinHuang authored and GitHub committed 2026-11-03 09:38:12 +08:00
1 parent b7911b2d30
commit 22c493ad5c
4 files changed
+192 -23

No files matched your search

+5
View File
@@ -388,6 +388,11 @@ sites remain distinct. A static route does not establish runtime reachability.
Swift/closure dispatch requires resolved static references; unavailable ABI
forms remain unknown.
Encoded Objective-C method records retain their own locations in dispatch
Evidence. A handler node uses the implementation address with an unknown file
offset unless that function already has an independently located graph node;
the method-record offset does not locate the handler's code.
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,
@@ -1,5 +1,8 @@
import { describe, expect, it } from "vitest";
import { objcSwiftMetadataSchema } from "./objcSwiftMetadata.js";
import {
inspectNativeDispatchMetadata,
objcSwiftMetadataSchema,
} from "./objcSwiftMetadata.js";
import {
nativeInvestigationGraphSchema,
@@ -191,9 +194,18 @@ describe("native investigation graph traces", () => {
});
describe("Interface Builder dispatch joins", () => {
it.each(["BuildViewController", "BuildView", "BuildButton", "BuildHandler"])(
"joins an action to its declared %s receiver class",
(className) => {
it.each([
{
className: "BuildViewController",
recordOffset: 1944,
existingOffset: null,
},
{ className: "BuildView", recordOffset: 6040, existingOffset: null },
{ className: "BuildButton", recordOffset: null, existingOffset: null },
{ className: "BuildHandler", recordOffset: 1944, existingOffset: 1916 },
])(
"joins the declared $className without mixing record and implementation offsets",
({ className, recordOffset, existingOffset }) => {
const ui = buildInterfaceBuilderAnalysis({
targetSha256: "b".repeat(64),
toolVersion: "test",
@@ -221,38 +233,80 @@ describe("Interface Builder dispatch joins", () => {
],
limits: interfaceBuilderLimitsSchema.parse({}),
});
const joined = joinInterfaceBuilderDispatch(ui.graph, {
objc_classes: [],
objc_protocols: [],
objc_categories: [],
swift_decls: [],
objc_ivars: [],
objc_protocol_records: [],
const recordLocation = {
address: recordOffset === null ? "0x1000" : "0x2000",
file_offset: recordOffset,
};
const recordEvidence = [
{
kind: recordOffset === null ? "symbol" : "binary_metadata",
description: "Explicit synthetic method-record location",
location: recordLocation,
artifact_path: "/synthetic/Fixture",
artifact_sha256: "b".repeat(64),
},
];
const mappedEvidence = [
{
...recordEvidence[0],
kind: "reference",
description: "Explicit independently mapped handler",
location: { address: "0x1000", file_offset: existingOffset },
},
];
const inputGraph =
existingOffset === null
? ui.graph
: nativeInvestigationGraphSchema.parse({
...ui.graph,
nodes: [
...ui.graph.nodes,
{
id: "native:function:0x1000",
kind: "function",
name: "Already mapped handler",
location: { address: "0x1000", file_offset: existingOffset },
attributes: {},
evidence: mappedEvidence,
},
],
});
const metadata = objcSwiftMetadataSchema.parse({
...inspectNativeDispatchMetadata([], 1),
objc_dispatch_implementations: [
{
class_name: className,
selector: "buildTapped:",
method_type: "instance",
implementation_address: "0x1000",
location: { address: "0x1000", file_offset: 0 },
location: recordLocation,
decode: { status: "decoded", reason: null },
evidence,
evidence: recordEvidence,
},
],
swift_conformances: [],
swift_dispatch_slots: [],
swift_symbols: [],
relative_pointers: [],
coverage: [],
db_save_result: null,
});
const joined = joinInterfaceBuilderDispatch(inputGraph, metadata);
expect(joined.edges).toContainEqual(
expect.objectContaining({
relation: "objc_dispatch",
resolution: "resolved",
to: "native:function:0x1000",
evidence: expect.arrayContaining(recordEvidence),
}),
);
const handler = joined.nodes.find(
({ id }) => id === "native:function:0x1000",
);
expect(handler?.location).toEqual({
address: "0x1000",
file_offset: existingOffset,
});
expect(handler?.evidence).toEqual(
existingOffset === null ? recordEvidence : mappedEvidence,
);
expect(metadata.objc_dispatch_implementations[0]?.location).toEqual(
recordLocation,
);
},
);
});
@@ -391,7 +391,8 @@ export const joinInterfaceBuilderDispatch = (
name: `${implementation.class_name} ${selector}`,
location: {
address: implementation.implementation_address,
file_offset: implementation.location.file_offset,
// The implementation location identifies the method record, not its code.
file_offset: null,
},
attributes: {
class_name: implementation.class_name,
@@ -14,6 +14,11 @@ import {
} from "../../../src/domain/analysisErrorCore.js";
import { inspectNativeDispatchMetadata } from "../../../src/domain/native/objcSwiftMetadata.js";
import { nativeInvestigationTraceSchema } from "../../../src/domain/native/nativeInvestigationGraph.js";
import {
buildInterfaceBuilderAnalysis,
interfaceBuilderLimitsSchema,
} from "../../../src/domain/apple/interfaceBuilderGraph.js";
import { jsonValueSchema } from "../../../src/domain/jsonValue.js";
import { err, ok, type Result } from "../../../src/domain/result.js";
import { createServer } from "../../../src/server/createServer.js";
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
@@ -111,6 +116,12 @@ const noClassifiedReferences = {
};
type Plan = {
readonly ui?: ReturnType<typeof buildInterfaceBuilderAnalysis>;
readonly implementations?: ReturnType<
typeof inspectNativeDispatchMetadata
>["objc_dispatch_implementations"];
readonly action?: string;
readonly maxDepth?: number;
readonly references?: unknown;
readonly referencesFailure?: AnalysisError;
readonly uiFailure?: AnalysisError;
@@ -142,6 +153,12 @@ function publicTrace(plan: Plan, maxNodes = 12, maxEdges = 500) {
plan.referencesFailure !== undefined
)
return err(plan.referencesFailure);
if (operation === "decode_interface_builder" && plan.ui !== undefined)
return ok(
createAnalysisExecution(jsonValueSchema.parse(plan.ui), identity, {
subject,
}),
);
if (operation === "decode_interface_builder")
return err(
new AnalysisCapabilityUnavailableError(
@@ -157,7 +174,12 @@ function publicTrace(plan: Plan, maxNodes = 12, maxEdges = 500) {
target_sha256: sha256,
provider: identity,
analysis_profile_digest: null,
result: inspectNativeDispatchMetadata([], 1),
result: {
...inspectNativeDispatchMetadata([], 1),
...(plan.implementations === undefined
? {}
: { objc_dispatch_implementations: plan.implementations }),
},
},
identity,
{ subject },
@@ -220,10 +242,10 @@ function publicTrace(plan: Plan, maxNodes = 12, maxEdges = 500) {
const result = await client.callTool({
name: "trace_native_ui_action",
arguments: {
action: "0x1000",
action: plan.action ?? "0x1000",
max_nodes: maxNodes,
max_edges: maxEdges,
max_depth: 1,
max_depth: plan.maxDepth ?? 1,
},
});
return result;
@@ -237,6 +259,93 @@ const traceFrom = (result: { structuredContent?: unknown }) =>
})
.parse(result.structuredContent).normalized_result;
it.each([
{ label: "thin record", recordOffset: 1944 },
{ label: "selected-slice container record", recordOffset: 6040 },
{ label: "symbol-only record", recordOffset: null },
])(
"keeps handler offsets distinct from $label Evidence through public MCP",
async ({ recordOffset }) => {
// Explicit synthetic decoded facts; no AppKit, Ghidra or Kuna runtime claim.
const ui = buildInterfaceBuilderAnalysis({
targetSha256: sha256,
toolVersion: "synthetic",
documents: [
{
relativePath: "Main.storyboardc/scene.nib/objects.nib",
archiveSha256: "b".repeat(64),
documentKind: "storyboard_scene",
raw: {
"com.apple.ibtool.document.objects": {
receiver: { customClass: "AuditController" },
button: { class: "UIButton" },
},
"com.apple.ibtool.document.connections": {
button: [
{
type: "action",
label: "runAction:",
destinationId: "receiver",
},
],
},
},
},
],
limits: interfaceBuilderLimitsSchema.parse({}),
});
const recordLocation = {
address: recordOffset === null ? "0x10000077c" : "0x100000798",
file_offset: recordOffset,
};
const recordEvidence = [
{
kind:
recordOffset === null
? ("symbol" as const)
: ("binary_metadata" as const),
description: "Explicit synthetic method entry",
location: recordLocation,
artifact_path: subject.path,
artifact_sha256: sha256,
},
];
const response = await publicTrace({
ui,
action: "runAction:",
maxDepth: 4,
references: completeReferences([]),
callees: [],
implementations: [
{
class_name: "AuditController",
selector: "runAction:",
method_type: "instance",
implementation_address: "0x10000077c",
location: recordLocation,
decode: { status: "decoded", reason: null },
evidence: recordEvidence,
},
],
});
expect(response.isError, JSON.stringify(response)).not.toBe(true);
const trace = traceFrom(response);
const handler = trace.nodes.find(
({ id }) => id === "native:function:0x10000077c",
);
expect(handler?.location).toEqual({
address: "0x10000077c",
file_offset: null,
});
expect(handler?.evidence).toEqual(recordEvidence);
expect(
trace.edges.find(({ relation }) => relation === "objc_dispatch")
?.evidence,
).toEqual(expect.arrayContaining(recordEvidence));
expect(recordEvidence[0]?.location).toEqual(recordLocation);
},
);
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);