mirror of
https://github.com/morluto/rea.git
synced 2026-11-07 05:56:19 -10:00
fix(native): preserve implementation file locations (#8667)
fix(native): keep UI handler offsets distinct from method records
This commit is contained in:
1 parent
b7911b2d30
commit
22c493ad5c
4 files changed
+192
-23
No files matched your search
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user