mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
fix(session): describe native UI trace source routes
Share source operations with execution owners and derive availability and missing prerequisites from one evaluation. Distinguish authored UI dispatch from native-seed call tracing without inventing a default request mode. Preserve target gates, typed failures and partial results. Fixes #5071
This commit is contained in:
1 parent
29910d16b9
commit
32f0e51209
5 files changed
+815
-81
No files matched your search
@@ -216,6 +216,17 @@ sites remain distinct. A static route does not establish runtime reachability.
|
||||
Swift/closure dispatch requires resolved static references; unavailable ABI
|
||||
forms remain unknown.
|
||||
|
||||
Session discovery describes two automatic source routes for this trace:
|
||||
`authored_ui_dispatch` needs Interface Builder decoding and dispatch metadata;
|
||||
`native_seed_call_trace` needs native address resolution, dispatch metadata and
|
||||
a call reader. Metadata can come from direct inspection or `list_names`;
|
||||
the call reader can supply typed `procedure_references` or inferred
|
||||
`procedure_callees`. An authored UI-to-handler edge remains useful without
|
||||
call expansion, and native-seed tracing can proceed without UI decoding.
|
||||
These routes add no request option or default mode, so discovery omits
|
||||
`default_mode_available`. Availability describes source prerequisites;
|
||||
the result reports the query's actual matches, coverage and failures.
|
||||
|
||||
`trace_native_values` / `rea trace-native-values <binary> <procedure> --provider
|
||||
ghidra` composes high-p-code def-use graphs across statically resolved calls.
|
||||
Logical CALL arguments bind to recovered parameter ordinals; recovered RETURN
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { DEFAULT_CALL_RELATIONSHIP_DIRECTION } from "../contracts/enhancedInputs.js";
|
||||
import { CALL_RELATIONSHIP_TRAVERSALS } from "./CallRelationshipTraversal.js";
|
||||
import { NATIVE_DISPATCH_METADATA_SOURCES } from "./native/NativeDispatchMetadataInspection.js";
|
||||
import { NATIVE_UI_ACTION_OPERATIONS } from "./native/NativeUiActionTrace.js";
|
||||
import { NATIVE_CALL_ROUTE_SOURCES } from "./native/NativeCallRoutes.js";
|
||||
import { z } from "zod";
|
||||
import { sqliteDatabaseCapability } from "./sqlite/SqliteDatabaseCapability.js";
|
||||
import type {
|
||||
@@ -117,10 +119,10 @@ type CompositionRequirements = readonly (
|
||||
)[];
|
||||
type AvailabilityMode = {
|
||||
readonly name: string;
|
||||
readonly requirements: readonly AnalysisOperation[];
|
||||
readonly requirements: CompositionRequirements;
|
||||
};
|
||||
type ModeComposition = {
|
||||
readonly defaultMode: string;
|
||||
readonly defaultMode?: string;
|
||||
readonly modes: readonly AvailabilityMode[];
|
||||
};
|
||||
type ProviderComposition = CompositionRequirements | ModeComposition;
|
||||
@@ -135,16 +137,32 @@ const CALL_RELATIONSHIP_COMPOSITION: ModeComposition = {
|
||||
),
|
||||
};
|
||||
|
||||
const NATIVE_UI_ACTION_COMPOSITION: ModeComposition = {
|
||||
modes: [
|
||||
{
|
||||
name: "authored_ui_dispatch",
|
||||
requirements: [
|
||||
NATIVE_UI_ACTION_OPERATIONS.interfaceBuilder,
|
||||
Object.values(NATIVE_DISPATCH_METADATA_SOURCES),
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "native_seed_call_trace",
|
||||
requirements: [
|
||||
Object.values(NATIVE_DISPATCH_METADATA_SOURCES),
|
||||
NATIVE_UI_ACTION_OPERATIONS.nativeSeed,
|
||||
Object.values(NATIVE_CALL_ROUTE_SOURCES),
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Complete coverage is checked against canonical enhanced contracts, rather than
|
||||
// allowing a newly registered workflow to silently look like a missing provider.
|
||||
const ENHANCED_REQUIREMENTS: ReadonlyMap<string, ProviderComposition> = new Map(
|
||||
Object.entries({
|
||||
trace_native_values: ["analyze_function", "resolve_native_call_targets"],
|
||||
trace_native_ui_action: [
|
||||
"decode_interface_builder",
|
||||
"list_names",
|
||||
"procedure_callees",
|
||||
],
|
||||
trace_native_ui_action: NATIVE_UI_ACTION_COMPOSITION,
|
||||
inspect_native_dispatch_metadata: [
|
||||
Object.values(NATIVE_DISPATCH_METADATA_SOURCES),
|
||||
],
|
||||
@@ -739,76 +757,79 @@ const composedAvailability = (
|
||||
requirements.defaultMode,
|
||||
descriptors,
|
||||
)
|
||||
: composedAvailabilityFor(requirements, descriptors);
|
||||
: composedAvailabilityFor(requirements, descriptors).availability;
|
||||
};
|
||||
|
||||
/** Evaluate source groups once so discovery and missing-operation facts agree. */
|
||||
const composedAvailabilityFor = (
|
||||
requirements: readonly (string | readonly string[])[],
|
||||
requirements: CompositionRequirements,
|
||||
descriptors: ReadonlyMap<string, ProviderDescriptor>,
|
||||
): Availability => {
|
||||
for (const requirement of requirements) {
|
||||
if (typeof requirement === "string") {
|
||||
if (!descriptors.has(requirement))
|
||||
return {
|
||||
reason: "provider_missing",
|
||||
remediation: `Configure a provider for required operation ${requirement}.`,
|
||||
};
|
||||
} else {
|
||||
const declared = requirement.some((operation) =>
|
||||
descriptors.has(operation),
|
||||
): {
|
||||
readonly availability: Availability;
|
||||
readonly missingOperations: readonly AnalysisOperation[];
|
||||
} => {
|
||||
const unmet = requirements
|
||||
.map((requirement) => {
|
||||
const operations =
|
||||
typeof requirement === "string" ? [requirement] : requirement;
|
||||
const observations = operations.map((operation) =>
|
||||
descriptors.get(operation),
|
||||
);
|
||||
if (!declared)
|
||||
return {
|
||||
reason: "provider_missing",
|
||||
remediation: `Configure a provider for one of: ${requirement.join(", ")}.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
for (const requirement of requirements) {
|
||||
if (typeof requirement === "string") {
|
||||
const descriptor = descriptors.get(requirement);
|
||||
if (descriptor?.available === false)
|
||||
return {
|
||||
reason:
|
||||
descriptor.availability_code === "unsupported_host"
|
||||
? "unsupported_host"
|
||||
: "provider_unavailable",
|
||||
remediation:
|
||||
descriptor.reason ??
|
||||
"Choose another target or configured provider.",
|
||||
};
|
||||
} else {
|
||||
const anyAvailable = requirement.some(
|
||||
(operation) => descriptors.get(operation)?.available === true,
|
||||
);
|
||||
if (!anyAvailable) {
|
||||
const unavailable = requirement
|
||||
.map((operation) => descriptors.get(operation))
|
||||
.find((descriptor) => descriptor?.available === false);
|
||||
if (unavailable !== undefined)
|
||||
return {
|
||||
reason:
|
||||
unavailable.availability_code === "unsupported_host"
|
||||
? "unsupported_host"
|
||||
: "provider_unavailable",
|
||||
remediation:
|
||||
unavailable.reason ??
|
||||
"Choose another target or configured provider.",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
return { reason: "available", remediation: null };
|
||||
return {
|
||||
requirement,
|
||||
operations,
|
||||
available: observations.some(
|
||||
(descriptor) => descriptor?.available === true,
|
||||
),
|
||||
unavailable: observations.find(
|
||||
(descriptor) => descriptor?.available === false,
|
||||
),
|
||||
};
|
||||
})
|
||||
.filter((group) => !group.available);
|
||||
const missingOperations = [
|
||||
...new Set(unmet.flatMap((group) => group.operations)),
|
||||
];
|
||||
// Preserve the existing admission rule: report an undeclared prerequisite
|
||||
// before an unhealthy declaration, in requirement order within each class.
|
||||
const undeclared = unmet.find((group) => group.unavailable === undefined);
|
||||
if (undeclared !== undefined)
|
||||
return {
|
||||
missingOperations,
|
||||
availability: {
|
||||
reason: "provider_missing",
|
||||
remediation:
|
||||
typeof undeclared.requirement === "string"
|
||||
? `Configure a provider for required operation ${undeclared.requirement}.`
|
||||
: `Configure a provider for one of: ${undeclared.operations.join(", ")}.`,
|
||||
},
|
||||
};
|
||||
const unavailable = unmet[0]?.unavailable;
|
||||
if (unavailable !== undefined)
|
||||
return {
|
||||
missingOperations,
|
||||
availability: {
|
||||
reason:
|
||||
unavailable.availability_code === "unsupported_host"
|
||||
? "unsupported_host"
|
||||
: "provider_unavailable",
|
||||
remediation:
|
||||
unavailable.reason ?? "Choose another target or configured provider.",
|
||||
},
|
||||
};
|
||||
return {
|
||||
availability: { reason: "available", remediation: null },
|
||||
missingOperations,
|
||||
};
|
||||
};
|
||||
|
||||
const availabilityForModes = (
|
||||
declarations: readonly AvailabilityMode[],
|
||||
defaultMode: string,
|
||||
defaultMode: string | undefined,
|
||||
descriptors: ReadonlyMap<string, ProviderDescriptor>,
|
||||
): Availability => {
|
||||
const modeResults: AvailabilityModeResult[] = declarations.map((mode) => {
|
||||
const availability = composedAvailabilityFor(
|
||||
const { availability, missingOperations } = composedAvailabilityFor(
|
||||
mode.requirements,
|
||||
descriptors,
|
||||
);
|
||||
@@ -824,10 +845,7 @@ const availabilityForModes = (
|
||||
name: mode.name,
|
||||
available: false,
|
||||
reason: availability.reason,
|
||||
missing_operations: mode.requirements.filter((operation) => {
|
||||
const descriptor = descriptors.get(operation);
|
||||
return descriptor === undefined || descriptor.available === false;
|
||||
}),
|
||||
missing_operations: [...missingOperations],
|
||||
remediation: availability.remediation,
|
||||
};
|
||||
});
|
||||
@@ -858,8 +876,12 @@ const availabilityForModes = (
|
||||
const specificFailureRemediation =
|
||||
failure?.reason === "provider_missing" ? undefined : failure?.remediation;
|
||||
const facts = {
|
||||
defaultModeAvailable:
|
||||
modes.find((mode) => mode.name === defaultMode)?.available ?? false,
|
||||
...(defaultMode === undefined
|
||||
? {}
|
||||
: {
|
||||
defaultModeAvailable:
|
||||
modes.find((mode) => mode.name === defaultMode)?.available ?? false,
|
||||
}),
|
||||
modes,
|
||||
};
|
||||
if (availableMode !== undefined)
|
||||
|
||||
@@ -11,6 +11,12 @@ import {
|
||||
import { AnalysisOutputError } from "../../domain/analysisErrorCore.js";
|
||||
import type { SourceLimitation } from "../SourceLimitations.js";
|
||||
|
||||
/** Preferred typed observations and the untyped fallback share one owner. */
|
||||
export const NATIVE_CALL_ROUTE_SOURCES = {
|
||||
references: "procedure_references",
|
||||
callees: "procedure_callees",
|
||||
} as const;
|
||||
|
||||
const limitationsFrom = (
|
||||
operation: SourceLimitation["operation"],
|
||||
provider: ProviderIdentity,
|
||||
@@ -71,7 +77,7 @@ export const readNativeCallRoutes = async (
|
||||
...(selectorOrigin === undefined ? {} : { selectorOrigin }),
|
||||
};
|
||||
const execution = await analysis.execute(
|
||||
"procedure_references",
|
||||
NATIVE_CALL_ROUTE_SOURCES.references,
|
||||
{ procedure, direction: "outgoing" },
|
||||
options,
|
||||
);
|
||||
@@ -83,7 +89,7 @@ export const readNativeCallRoutes = async (
|
||||
let limitations: SourceLimitation[] = [];
|
||||
if (execution.ok) {
|
||||
limitations = limitationsFrom(
|
||||
"procedure_references",
|
||||
NATIVE_CALL_ROUTE_SOURCES.references,
|
||||
execution.value.provider,
|
||||
execution.value.limitations,
|
||||
);
|
||||
@@ -92,7 +98,7 @@ export const readNativeCallRoutes = async (
|
||||
return {
|
||||
ok: false as const,
|
||||
error: new AnalysisOutputError(
|
||||
"procedure_references",
|
||||
NATIVE_CALL_ROUTE_SOURCES.references,
|
||||
"Invalid typed call references",
|
||||
),
|
||||
unknowns,
|
||||
@@ -128,7 +134,7 @@ export const readNativeCallRoutes = async (
|
||||
limitations,
|
||||
};
|
||||
const callees = await analysis.execute(
|
||||
"procedure_callees",
|
||||
NATIVE_CALL_ROUTE_SOURCES.callees,
|
||||
{ procedure },
|
||||
options,
|
||||
);
|
||||
@@ -137,7 +143,7 @@ export const readNativeCallRoutes = async (
|
||||
limitations = [
|
||||
...limitations,
|
||||
...limitationsFrom(
|
||||
"procedure_callees",
|
||||
NATIVE_CALL_ROUTE_SOURCES.callees,
|
||||
callees.value.provider,
|
||||
callees.value.limitations,
|
||||
),
|
||||
|
||||
@@ -23,6 +23,13 @@ import { AnalysisOutputError } from "../../domain/analysisErrorCore.js";
|
||||
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
|
||||
|
||||
type Input = z.output<typeof enhancedInputSchemas.trace_native_ui_action>;
|
||||
|
||||
/** Source roles used by execution and route availability. */
|
||||
export const NATIVE_UI_ACTION_OPERATIONS = {
|
||||
interfaceBuilder: "decode_interface_builder",
|
||||
nativeSeed: "procedure_address",
|
||||
} as const;
|
||||
|
||||
/** Build a UI-to-code trace from the active app and selected native provider. */
|
||||
export const traceNativeUiAction = async (
|
||||
analysis: AnalysisOperationPort,
|
||||
@@ -31,7 +38,7 @@ export const traceNativeUiAction = async (
|
||||
): EnhancedResult => {
|
||||
const [uiExecution, metadataExecution] = await Promise.all([
|
||||
analysis.execute(
|
||||
"decode_interface_builder",
|
||||
NATIVE_UI_ACTION_OPERATIONS.interfaceBuilder,
|
||||
{
|
||||
max_documents: 64,
|
||||
max_objects: input.max_nodes,
|
||||
@@ -65,14 +72,14 @@ export const traceNativeUiAction = async (
|
||||
if (!decoded.success)
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
"decode_interface_builder",
|
||||
NATIVE_UI_ACTION_OPERATIONS.interfaceBuilder,
|
||||
"artifact provider returned an invalid interface graph",
|
||||
),
|
||||
);
|
||||
if (decoded.data.target_sha256 !== targetSha256)
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
"decode_interface_builder",
|
||||
NATIVE_UI_ACTION_OPERATIONS.interfaceBuilder,
|
||||
"UI and native provider results refer to different target SHA-256 values",
|
||||
),
|
||||
);
|
||||
@@ -123,7 +130,7 @@ export const traceNativeUiAction = async (
|
||||
let selected = resolveUiAction(graph, input.action);
|
||||
if (selected.nodeId === null && selected.candidates.length === 0) {
|
||||
const native = await analysis.execute(
|
||||
"procedure_address",
|
||||
NATIVE_UI_ACTION_OPERATIONS.nativeSeed,
|
||||
{ procedure: input.action },
|
||||
signal === undefined ? {} : { signal },
|
||||
);
|
||||
@@ -139,7 +146,7 @@ export const traceNativeUiAction = async (
|
||||
if (!address.success || native.value.subject?.sha256 !== targetSha256)
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
"procedure_address",
|
||||
NATIVE_UI_ACTION_OPERATIONS.nativeSeed,
|
||||
"Native seed lacks a validated address or matching target identity",
|
||||
),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,688 @@
|
||||
import { rm } from "node:fs/promises";
|
||||
|
||||
import type { Client } from "@modelcontextprotocol/client";
|
||||
import type { CallToolResult } from "@modelcontextprotocol/server";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import type {
|
||||
AnalysisExecution,
|
||||
AnalysisClient,
|
||||
AnalysisProvider,
|
||||
CapabilityDescriptor,
|
||||
} from "../../../src/application/AnalysisProvider.js";
|
||||
import { createAnalysisExecution } from "../../../src/application/AnalysisProvider.js";
|
||||
import type { AnalysisError } from "../../../src/domain/analysisErrorBase.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisOutputError,
|
||||
} from "../../../src/domain/analysisErrorCore.js";
|
||||
import {
|
||||
buildInterfaceBuilderAnalysis,
|
||||
interfaceBuilderLimitsSchema,
|
||||
} from "../../../src/domain/apple/interfaceBuilderGraph.js";
|
||||
import { inspectNativeDispatchMetadata } from "../../../src/domain/native/objcSwiftMetadata.js";
|
||||
import { nativeInvestigationTraceSchema } from "../../../src/domain/native/nativeInvestigationGraph.js";
|
||||
import type { JsonValue } from "../../../src/domain/jsonValue.js";
|
||||
import { err, ok, type Result } from "../../../src/domain/result.js";
|
||||
import type { AnalysisOperation } from "../../../src/application/AnalysisProvider.js";
|
||||
import { toolContract } from "../../../src/contracts/toolContracts.js";
|
||||
import { expandToolAvailability } from "../../../src/contracts/toolOutputSchemaPrimitives.js";
|
||||
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { createSessionMcpHarness } from "./sessionMcpHarness.js";
|
||||
|
||||
const resources: Array<{ close(): Promise<unknown> }> = [];
|
||||
let directory: string | undefined;
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(resources.splice(0).map((resource) => resource.close()));
|
||||
if (directory !== undefined)
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
directory = undefined;
|
||||
});
|
||||
|
||||
const identity = {
|
||||
id: "fixture",
|
||||
name: "Fixture analysis provider",
|
||||
version: "1",
|
||||
};
|
||||
const controllerName = "BuildViewController";
|
||||
const selector = "buildTapped:";
|
||||
const handlerAddress = "0x1000";
|
||||
const calleeAddress = "0x2000";
|
||||
const dispatchMetadata = inspectNativeDispatchMetadata([
|
||||
{ address: handlerAddress, name: `-[${controllerName} ${selector}]` },
|
||||
]);
|
||||
const symbolInventory = [
|
||||
{ address: handlerAddress, name: `-[${controllerName} ${selector}]` },
|
||||
];
|
||||
const interfaceBuilderFor = (targetSha256: string) =>
|
||||
buildInterfaceBuilderAnalysis({
|
||||
targetSha256,
|
||||
toolVersion: "fixture",
|
||||
documents: [
|
||||
{
|
||||
relativePath: "Main.storyboardc/scene.nib/objects.nib",
|
||||
archiveSha256: "b".repeat(64),
|
||||
documentKind: "storyboard_scene",
|
||||
raw: {
|
||||
"com.apple.ibtool.document.objects": {
|
||||
controller: { customClass: controllerName },
|
||||
button: { class: "UIButton", title: "Build" },
|
||||
},
|
||||
"com.apple.ibtool.document.connections": {
|
||||
button: [
|
||||
{ type: "action", label: selector, destinationId: "controller" },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
limits: interfaceBuilderLimitsSchema.parse({}),
|
||||
});
|
||||
|
||||
type ScenarioName =
|
||||
| "authored-ui-direct"
|
||||
| "authored-ui-symbols"
|
||||
| "native-seed-typed"
|
||||
| "native-seed-callees"
|
||||
| "metadata-only"
|
||||
| "missing-metadata"
|
||||
| "native-route-unavailable"
|
||||
| "metadata-runtime-failure"
|
||||
| "decode-cancelled";
|
||||
type ProviderOperation = Extract<
|
||||
AnalysisOperation,
|
||||
| "decode_interface_builder"
|
||||
| "list_names"
|
||||
| "inspect_native_dispatch_metadata"
|
||||
| "procedure_address"
|
||||
| "procedure_references"
|
||||
| "procedure_callees"
|
||||
>;
|
||||
type Scenario = {
|
||||
readonly name: ScenarioName;
|
||||
readonly capabilities: readonly {
|
||||
readonly operation: ProviderOperation;
|
||||
readonly available: true;
|
||||
}[];
|
||||
readonly unavailable?: readonly {
|
||||
readonly operation: ProviderOperation;
|
||||
readonly reason: string;
|
||||
}[];
|
||||
readonly failure?: "metadata-runtime" | "decode-cancelled";
|
||||
};
|
||||
type RouteScenario = Scenario & {
|
||||
readonly route: "authored_ui_dispatch" | "native_seed_call_trace";
|
||||
};
|
||||
|
||||
const authoredUiDirectScenario: RouteScenario = {
|
||||
name: "authored-ui-direct",
|
||||
route: "authored_ui_dispatch",
|
||||
capabilities: [
|
||||
{ operation: "decode_interface_builder", available: true },
|
||||
{ operation: "inspect_native_dispatch_metadata", available: true },
|
||||
],
|
||||
};
|
||||
const authoredUiSymbolsScenario: RouteScenario = {
|
||||
name: "authored-ui-symbols",
|
||||
route: "authored_ui_dispatch",
|
||||
capabilities: [
|
||||
{ operation: "decode_interface_builder", available: true },
|
||||
{ operation: "list_names", available: true },
|
||||
],
|
||||
};
|
||||
const nativeSeedTypedScenario: RouteScenario = {
|
||||
name: "native-seed-typed",
|
||||
route: "native_seed_call_trace",
|
||||
capabilities: [
|
||||
{ operation: "inspect_native_dispatch_metadata", available: true },
|
||||
{ operation: "procedure_address", available: true },
|
||||
{ operation: "procedure_references", available: true },
|
||||
],
|
||||
};
|
||||
const nativeSeedCalleesScenario: RouteScenario = {
|
||||
name: "native-seed-callees",
|
||||
route: "native_seed_call_trace",
|
||||
capabilities: [
|
||||
{ operation: "inspect_native_dispatch_metadata", available: true },
|
||||
{ operation: "procedure_address", available: true },
|
||||
{ operation: "procedure_callees", available: true },
|
||||
],
|
||||
};
|
||||
|
||||
const routeScenarios = [
|
||||
authoredUiDirectScenario,
|
||||
authoredUiSymbolsScenario,
|
||||
nativeSeedTypedScenario,
|
||||
nativeSeedCalleesScenario,
|
||||
] as const;
|
||||
|
||||
const metadataOnlyScenario: Scenario = {
|
||||
name: "metadata-only",
|
||||
capabilities: [
|
||||
{ operation: "inspect_native_dispatch_metadata", available: true },
|
||||
],
|
||||
};
|
||||
const missingMetadataScenario: Scenario = {
|
||||
name: "missing-metadata",
|
||||
capabilities: [{ operation: "decode_interface_builder", available: true }],
|
||||
};
|
||||
const unavailableNativeRouteScenario: Scenario = {
|
||||
name: "native-route-unavailable",
|
||||
capabilities: [
|
||||
{ operation: "inspect_native_dispatch_metadata", available: true },
|
||||
{ operation: "procedure_address", available: true },
|
||||
],
|
||||
unavailable: [
|
||||
{
|
||||
operation: "procedure_references",
|
||||
reason: "Typed references are unavailable in this fixture.",
|
||||
},
|
||||
],
|
||||
};
|
||||
const metadataRuntimeFailureScenario: Scenario = {
|
||||
name: "metadata-runtime-failure",
|
||||
capabilities: [
|
||||
{ operation: "decode_interface_builder", available: true },
|
||||
{ operation: "inspect_native_dispatch_metadata", available: true },
|
||||
{ operation: "list_names", available: true },
|
||||
],
|
||||
failure: "metadata-runtime",
|
||||
};
|
||||
const decodeCancelledScenario: Scenario = {
|
||||
name: "decode-cancelled",
|
||||
capabilities: [
|
||||
{ operation: "decode_interface_builder", available: true },
|
||||
{ operation: "inspect_native_dispatch_metadata", available: true },
|
||||
{ operation: "list_names", available: true },
|
||||
],
|
||||
failure: "decode-cancelled",
|
||||
};
|
||||
|
||||
describe("native UI action availability through available routes", () => {
|
||||
it.each(routeScenarios)(
|
||||
"reports the $name route available when its observed path succeeds",
|
||||
async (scenario) => {
|
||||
directory = await createTestTempDirectory("rea-native-ui-availability-");
|
||||
const calls: ProviderOperation[] = [];
|
||||
const { mcp, first } = await createSessionMcpHarness(
|
||||
directory,
|
||||
() => provider(scenario, calls),
|
||||
resources,
|
||||
);
|
||||
const opened = await mcp.callTool({
|
||||
name: "open_binary",
|
||||
arguments: { path: first },
|
||||
});
|
||||
expect(opened.isError).not.toBe(true);
|
||||
|
||||
try {
|
||||
const inventory = await readInventory(mcp);
|
||||
const result = await mcp.callTool({
|
||||
name: "trace_native_ui_action",
|
||||
arguments: {
|
||||
action:
|
||||
scenario.route === "authored_ui_dispatch"
|
||||
? selector
|
||||
: `-[${controllerName} ${selector}]`,
|
||||
},
|
||||
});
|
||||
assertSuccessfulRoute(scenario, result, calls);
|
||||
|
||||
expect(dispatchAvailability(inventory)).toMatchObject({
|
||||
available: true,
|
||||
reason: "available",
|
||||
});
|
||||
const entry = dispatchAvailability(inventory);
|
||||
expect(entry?.modes).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: scenario.route,
|
||||
available: true,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(entry).not.toHaveProperty("default_mode_available");
|
||||
} finally {
|
||||
const closed = await mcp.callTool({
|
||||
name: "close_binary",
|
||||
arguments: {},
|
||||
});
|
||||
expect(closed.isError).not.toBe(true);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("native UI action availability for unmet routes", () => {
|
||||
it.each([
|
||||
metadataOnlyScenario,
|
||||
missingMetadataScenario,
|
||||
unavailableNativeRouteScenario,
|
||||
])(
|
||||
"reports unmet route sources for $name without hiding partial results",
|
||||
async (scenario) => {
|
||||
directory = await createTestTempDirectory("rea-native-ui-no-metadata-");
|
||||
const calls: ProviderOperation[] = [];
|
||||
const { mcp, first } = await createSessionMcpHarness(
|
||||
directory,
|
||||
() => provider(scenario, calls),
|
||||
resources,
|
||||
);
|
||||
const opened = await mcp.callTool({
|
||||
name: "open_binary",
|
||||
arguments: { path: first },
|
||||
});
|
||||
expect(opened.isError).not.toBe(true);
|
||||
|
||||
try {
|
||||
const inventory = await readInventory(mcp);
|
||||
const result = await mcp.callTool({
|
||||
name: "trace_native_ui_action",
|
||||
arguments: {
|
||||
action:
|
||||
scenario.name === "metadata-only"
|
||||
? "missingAction:"
|
||||
: scenario.name === "native-route-unavailable"
|
||||
? `-[${controllerName} ${selector}]`
|
||||
: selector,
|
||||
},
|
||||
});
|
||||
if (scenario.name === "metadata-only") {
|
||||
expect(result.isError).not.toBe(true);
|
||||
const trace = z
|
||||
.object({ normalized_result: nativeInvestigationTraceSchema })
|
||||
.parse(result.structuredContent).normalized_result;
|
||||
expect(trace.edges).toEqual([]);
|
||||
expect(trace.nodes).toEqual([]);
|
||||
expect(trace.reason).toBe("ui_action_or_object_not_found");
|
||||
expect(trace.coverage).toContainEqual(
|
||||
expect.objectContaining({
|
||||
facet: "interface_builder_archives",
|
||||
status: "unsupported",
|
||||
}),
|
||||
);
|
||||
expect(calls).toEqual(["inspect_native_dispatch_metadata"]);
|
||||
} else if (scenario.name === "missing-metadata") {
|
||||
expect(result.isError).toBe(true);
|
||||
expect(parseMcpToolError(result)).toMatchObject({
|
||||
error: { code: "capability_unavailable" },
|
||||
});
|
||||
expect(calls).toContain("decode_interface_builder");
|
||||
expect(calls).not.toContain("inspect_native_dispatch_metadata");
|
||||
} else {
|
||||
expect(result.isError).not.toBe(true);
|
||||
expect(calls).toContain("procedure_address");
|
||||
expect(calls).not.toContain("procedure_references");
|
||||
}
|
||||
const entry = dispatchAvailability(inventory);
|
||||
expect(entry).toMatchObject({
|
||||
available: false,
|
||||
reason:
|
||||
scenario.name === "native-route-unavailable"
|
||||
? "unsupported_host"
|
||||
: "provider_missing",
|
||||
});
|
||||
expect(entry?.modes).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: "authored_ui_dispatch",
|
||||
available: false,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
name: "native_seed_call_trace",
|
||||
available: false,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
if (scenario.name === "metadata-only") {
|
||||
const authored = entry?.modes?.find(
|
||||
(mode) => mode.name === "authored_ui_dispatch",
|
||||
);
|
||||
expect(authored?.missing_operations).toEqual([
|
||||
"decode_interface_builder",
|
||||
]);
|
||||
}
|
||||
if (scenario.name === "missing-metadata") {
|
||||
const authored = entry?.modes?.find(
|
||||
(mode) => mode.name === "authored_ui_dispatch",
|
||||
);
|
||||
expect(authored?.missing_operations).toEqual([
|
||||
"inspect_native_dispatch_metadata",
|
||||
"list_names",
|
||||
]);
|
||||
}
|
||||
if (scenario.name === "native-route-unavailable") {
|
||||
const native = entry?.modes?.find(
|
||||
(mode) => mode.name === "native_seed_call_trace",
|
||||
);
|
||||
expect(native?.missing_operations).toEqual([
|
||||
"procedure_references",
|
||||
"procedure_callees",
|
||||
]);
|
||||
}
|
||||
} finally {
|
||||
const closed = await mcp.callTool({
|
||||
name: "close_binary",
|
||||
arguments: {},
|
||||
});
|
||||
expect(closed.isError).not.toBe(true);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("native UI action availability for failures and target gates", () => {
|
||||
it.each([metadataRuntimeFailureScenario, decodeCancelledScenario])(
|
||||
"preserves runtime failures for $name without trying another declared source",
|
||||
async (scenario) => {
|
||||
directory = await createTestTempDirectory(
|
||||
"rea-native-ui-source-failure-",
|
||||
);
|
||||
const calls: ProviderOperation[] = [];
|
||||
const { mcp, first } = await createSessionMcpHarness(
|
||||
directory,
|
||||
() => provider(scenario, calls),
|
||||
resources,
|
||||
);
|
||||
expect(
|
||||
(
|
||||
await mcp.callTool({
|
||||
name: "open_binary",
|
||||
arguments: { path: first },
|
||||
})
|
||||
).isError,
|
||||
).not.toBe(true);
|
||||
try {
|
||||
const inventory = await readInventory(mcp);
|
||||
const result = await mcp.callTool({
|
||||
name: "trace_native_ui_action",
|
||||
arguments: { action: selector },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
expect(parseMcpToolError(result).error.code).toBe(
|
||||
scenario.name === "decode-cancelled"
|
||||
? "cancelled"
|
||||
: "unreadable_output",
|
||||
);
|
||||
if (scenario.name === "metadata-runtime-failure") {
|
||||
expect(calls).toContain("inspect_native_dispatch_metadata");
|
||||
expect(calls).not.toContain("list_names");
|
||||
} else {
|
||||
expect(calls).toContain("decode_interface_builder");
|
||||
expect(calls).not.toContain("list_names");
|
||||
}
|
||||
expect(dispatchAvailability(inventory)).toMatchObject({
|
||||
available: true,
|
||||
});
|
||||
} finally {
|
||||
expect(
|
||||
(await mcp.callTool({ name: "close_binary", arguments: {} })).isError,
|
||||
).not.toBe(true);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("reports target_required before querying provider operations", async () => {
|
||||
directory = await createTestTempDirectory("rea-native-ui-no-target-");
|
||||
const calls: ProviderOperation[] = [];
|
||||
const { mcp } = await createSessionMcpHarness(
|
||||
directory,
|
||||
() => provider(authoredUiDirectScenario, calls),
|
||||
resources,
|
||||
);
|
||||
const inventory = await readInventory(mcp);
|
||||
expect(inventory.result.open).toBe(false);
|
||||
expect(dispatchAvailability(inventory)).toMatchObject({
|
||||
available: false,
|
||||
reason: "target_required",
|
||||
});
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
const readInventory = async (mcp: Client) => {
|
||||
const result = await mcp.callTool({ name: "binary_session", arguments: {} });
|
||||
return toolContract("binary_session").outputSchema.parse(
|
||||
result.structuredContent,
|
||||
);
|
||||
};
|
||||
|
||||
const dispatchAvailability = (
|
||||
inventory: Awaited<ReturnType<typeof readInventory>>,
|
||||
) =>
|
||||
expandToolAvailability(inventory.result.tool_availability).find(
|
||||
(entry) => entry.name === "trace_native_ui_action",
|
||||
);
|
||||
|
||||
const assertSuccessfulRoute = (
|
||||
scenario: RouteScenario,
|
||||
result: CallToolResult,
|
||||
calls: readonly ProviderOperation[],
|
||||
): void => {
|
||||
expect(result.isError).not.toBe(true);
|
||||
const trace = z
|
||||
.object({ normalized_result: nativeInvestigationTraceSchema })
|
||||
.parse(result.structuredContent).normalized_result;
|
||||
expect(trace.target_sha256).toMatch(/^[a-f0-9]{64}$/u);
|
||||
if (scenario.route === "authored_ui_dispatch") {
|
||||
expect(trace.edges).toContainEqual(
|
||||
expect.objectContaining({
|
||||
relation: "objc_dispatch",
|
||||
to: `native:function:${handlerAddress}`,
|
||||
}),
|
||||
);
|
||||
expect(calls).toContain("decode_interface_builder");
|
||||
expect(calls).toContain(
|
||||
scenario.name === "authored-ui-direct"
|
||||
? "inspect_native_dispatch_metadata"
|
||||
: "list_names",
|
||||
);
|
||||
expect(calls).not.toContain(
|
||||
scenario.name === "authored-ui-direct"
|
||||
? "list_names"
|
||||
: "inspect_native_dispatch_metadata",
|
||||
);
|
||||
expect(calls).not.toContain("procedure_address");
|
||||
return;
|
||||
}
|
||||
expect(trace.start).toBe(`native:function:${handlerAddress}`);
|
||||
expect(trace.edges).toContainEqual(
|
||||
expect.objectContaining({
|
||||
relation:
|
||||
scenario.name === "native-seed-typed" ? "direct_call" : "provider_call",
|
||||
from: `native:function:${handlerAddress}`,
|
||||
to: `native:function:${calleeAddress}`,
|
||||
}),
|
||||
);
|
||||
expect(calls).not.toContain("decode_interface_builder");
|
||||
expect(calls).toContain("inspect_native_dispatch_metadata");
|
||||
expect(calls).toContain("procedure_address");
|
||||
expect(calls).toContain(
|
||||
scenario.name === "native-seed-typed"
|
||||
? "procedure_references"
|
||||
: "procedure_callees",
|
||||
);
|
||||
};
|
||||
|
||||
const capabilityEffects = {
|
||||
mutatesArtifact: false,
|
||||
launchesProcess: false,
|
||||
mayShowUi: false,
|
||||
mayAccessNetwork: false,
|
||||
mayWriteFilesystem: false,
|
||||
changesPermissions: false,
|
||||
requiresRoot: false,
|
||||
};
|
||||
|
||||
const providerOperations = new Set<AnalysisOperation>([
|
||||
"decode_interface_builder",
|
||||
"list_names",
|
||||
"inspect_native_dispatch_metadata",
|
||||
"procedure_address",
|
||||
"procedure_references",
|
||||
"procedure_callees",
|
||||
]);
|
||||
|
||||
const isProviderOperation = (
|
||||
operation: AnalysisOperation,
|
||||
): operation is ProviderOperation => providerOperations.has(operation);
|
||||
|
||||
type ProviderFixtureContext = {
|
||||
readonly scenario: Scenario;
|
||||
readonly calls: ProviderOperation[];
|
||||
readonly target: Parameters<AnalysisProvider["createClient"]>[0];
|
||||
readonly profile: Parameters<AnalysisProvider["createClient"]>[1];
|
||||
};
|
||||
|
||||
const provider = (
|
||||
scenario: Scenario,
|
||||
calls: ProviderOperation[],
|
||||
): AnalysisProvider => {
|
||||
const capabilities: CapabilityDescriptor[] = [
|
||||
...scenario.capabilities.map((capability) => {
|
||||
return {
|
||||
provider: identity,
|
||||
operation: capability.operation,
|
||||
effects: capabilityEffects,
|
||||
limitations: [],
|
||||
available: true as const,
|
||||
reason: null,
|
||||
};
|
||||
}),
|
||||
...(scenario.unavailable ?? []).map((capability) => {
|
||||
return {
|
||||
provider: identity,
|
||||
operation: capability.operation,
|
||||
effects: capabilityEffects,
|
||||
limitations: [],
|
||||
available: false as const,
|
||||
availabilityCode: "unsupported_host" as const,
|
||||
reason: capability.reason,
|
||||
};
|
||||
}),
|
||||
];
|
||||
return {
|
||||
identity: () => identity,
|
||||
capabilities: () => capabilities,
|
||||
createClient: (target, profile): AnalysisClient => ({
|
||||
execute: (operation, parameters) =>
|
||||
executeProviderFixture(
|
||||
{ scenario, calls, target, profile },
|
||||
operation,
|
||||
parameters,
|
||||
),
|
||||
close: async () => ok(null),
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
const executeProviderFixture = async (
|
||||
context: ProviderFixtureContext,
|
||||
operation: AnalysisOperation,
|
||||
parameters: Readonly<Record<string, JsonValue>>,
|
||||
): Promise<Result<AnalysisExecution, AnalysisError>> => {
|
||||
if (operation === "health")
|
||||
return ok(createAnalysisExecution(null, identity));
|
||||
if (!isProviderOperation(operation))
|
||||
throw new Error(`Unexpected provider operation: ${operation}`);
|
||||
context.calls.push(operation);
|
||||
const failure = failureFor(context.scenario, operation);
|
||||
if (failure !== undefined) return err(failure);
|
||||
return ok(
|
||||
createAnalysisExecution(
|
||||
resultFor(operation, parameters, context.target.sha256, context.profile),
|
||||
identity,
|
||||
{
|
||||
subject: context.target,
|
||||
...(context.profile === undefined
|
||||
? {}
|
||||
: { analysisProfile: context.profile }),
|
||||
},
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const failureFor = (
|
||||
scenario: Scenario,
|
||||
operation: ProviderOperation,
|
||||
): AnalysisError | undefined => {
|
||||
if (
|
||||
operation === "inspect_native_dispatch_metadata" &&
|
||||
scenario.failure === "metadata-runtime"
|
||||
)
|
||||
return new AnalysisOutputError(
|
||||
"inspect_native_dispatch_metadata",
|
||||
"fixture failure",
|
||||
);
|
||||
if (
|
||||
operation === "decode_interface_builder" &&
|
||||
scenario.failure === "decode-cancelled"
|
||||
)
|
||||
return new AnalysisCancelledError("decode_interface_builder");
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const resultFor = (
|
||||
operation: ProviderOperation,
|
||||
parameters: Readonly<Record<string, JsonValue>>,
|
||||
targetSha256: string,
|
||||
profile: Parameters<AnalysisProvider["createClient"]>[1],
|
||||
): unknown => {
|
||||
switch (operation) {
|
||||
case "decode_interface_builder":
|
||||
return interfaceBuilderFor(targetSha256);
|
||||
case "inspect_native_dispatch_metadata":
|
||||
return {
|
||||
target_sha256: targetSha256,
|
||||
provider: identity,
|
||||
analysis_profile_digest: profile?.digest ?? null,
|
||||
result: dispatchMetadata,
|
||||
};
|
||||
case "procedure_address":
|
||||
return handlerAddress;
|
||||
case "list_names":
|
||||
return symbolInventory;
|
||||
case "procedure_callees":
|
||||
return parameters.procedure === handlerAddress ? [calleeAddress] : [];
|
||||
case "procedure_references":
|
||||
return typedCallReferences(
|
||||
typeof parameters.procedure === "string" ? parameters.procedure : "",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const typedCallReferences = (procedure: string) => ({
|
||||
reference_kinds_available: true,
|
||||
references:
|
||||
procedure === handlerAddress
|
||||
? [
|
||||
{
|
||||
source_address: "0x1008",
|
||||
target_address: calleeAddress,
|
||||
target_procedure: { address: calleeAddress },
|
||||
kind: {
|
||||
available: true,
|
||||
provenance: "fixture typed reference",
|
||||
type: "call",
|
||||
flow: true,
|
||||
call: true,
|
||||
jump: false,
|
||||
data: false,
|
||||
read: false,
|
||||
write: false,
|
||||
indirect: false,
|
||||
computed: false,
|
||||
conditional: false,
|
||||
terminal: false,
|
||||
primary: true,
|
||||
operand_index: 0,
|
||||
external: false,
|
||||
},
|
||||
},
|
||||
]
|
||||
: [],
|
||||
unresolved_calls: [],
|
||||
});
|
||||
Reference in new issue
Block a user