mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
fix(native): retain partial call routes and omitted sites
Separate enumeration outcome from retained facts in the native call-route owner. Keep every available typed call with its source provider and site, supplement incomplete kinds with inferred fallback targets, and retain known routes when fallback fails. Per-reference unknowns take precedence over aggregate availability; cancellation remains terminal. Project partial routes before enumeration failures, preserve each omitted route's relation, and include its call site in the omitted-edge identity so distinct observations to one target cannot overwrite each other. Remove the superseded success-only provider/result representation. Validation: 69 focused cases plus final 30-case rerun, including nine public SDK MCP regressions; full static/architecture, docs and discovery checks pass. Controlled-provider source REDs reproduce both route loss and omitted-site collisions. Real native providers remain unverified. Closes #5073 Closes #3922 Closes #5128
This commit is contained in:
@@ -216,6 +216,13 @@ 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
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
@@ -4,9 +4,40 @@ import { createAnalysisExecution } from "../AnalysisProvider.js";
|
||||
import { ghidraReferenceEdge } from "../../domain/ghidraValues.fixture.js";
|
||||
import { jsonValueSchema } from "../../domain/jsonValue.js";
|
||||
import { err, ok } from "../../domain/result.js";
|
||||
import { AnalysisCancelledError } from "../../domain/analysisErrorCore.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisCapabilityUnavailableError,
|
||||
} from "../../domain/analysisErrorCore.js";
|
||||
|
||||
const provider = { id: "fixture", name: "Fixture", version: "1" };
|
||||
const callReference = (source: string, target: string) => ({
|
||||
source_address: source,
|
||||
target_address: target,
|
||||
target_procedure: { address: target },
|
||||
kind: {
|
||||
available: true as const,
|
||||
provenance: "fixture-reference",
|
||||
type: "UNCONDITIONAL_CALL",
|
||||
flow: true,
|
||||
call: true,
|
||||
jump: false,
|
||||
data: false,
|
||||
read: false,
|
||||
write: false,
|
||||
indirect: false,
|
||||
computed: false,
|
||||
external: false,
|
||||
conditional: false,
|
||||
terminal: false,
|
||||
primary: true,
|
||||
operand_index: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const unavailableCallKind = {
|
||||
available: false as const,
|
||||
reason: "Reference kind is unavailable.",
|
||||
};
|
||||
describe("untyped native call route fallback", () => {
|
||||
it.each([{ callees: [] }, { callees: ["0x2000"] }])(
|
||||
"preserves targetless calls and their provider when fallback callees are $callees",
|
||||
@@ -50,35 +81,343 @@ describe("untyped native call route fallback", () => {
|
||||
);
|
||||
expect(calls).toEqual(["procedure_references", "procedure_callees"]);
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
routes: callees.map((address) => ({
|
||||
provider: fallbackProvider,
|
||||
routes: callees.map((address) => ({
|
||||
address,
|
||||
site: "0x1000",
|
||||
relation: "provider_call",
|
||||
resolution: "inferred",
|
||||
})),
|
||||
unknowns: [{ ...unknown, provider: referenceProvider }],
|
||||
limitations: [
|
||||
{
|
||||
provider: referenceProvider,
|
||||
operation: "procedure_references",
|
||||
limitation: "Reference-kind metadata is incomplete.",
|
||||
},
|
||||
{
|
||||
provider: fallbackProvider,
|
||||
operation: "procedure_callees",
|
||||
limitation: "Callee enumeration is limited to named functions.",
|
||||
},
|
||||
],
|
||||
},
|
||||
address,
|
||||
site: "0x1000",
|
||||
relation: "provider_call",
|
||||
resolution: "inferred",
|
||||
})),
|
||||
unknowns: [{ ...unknown, provider: referenceProvider }],
|
||||
limitations: [
|
||||
{
|
||||
provider: referenceProvider,
|
||||
operation: "procedure_references",
|
||||
limitation: "Reference-kind metadata is incomplete.",
|
||||
},
|
||||
{
|
||||
provider: fallbackProvider,
|
||||
operation: "procedure_callees",
|
||||
limitation: "Callee enumeration is limited to named functions.",
|
||||
},
|
||||
],
|
||||
outcome: { ok: true, value: undefined },
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("typed native call routes", () => {
|
||||
describe("partially typed native call routes", () => {
|
||||
it.each([false, true])(
|
||||
"keeps known typed calls when an individual kind is unavailable (aggregate=$0)",
|
||||
async (referenceKindsAvailable) => {
|
||||
const typedProvider = { ...provider, id: "typed" };
|
||||
const fallbackProvider = { ...provider, id: "fallback" };
|
||||
const result = await readNativeCallRoutes(
|
||||
{
|
||||
execute: async (operation) =>
|
||||
operation === "procedure_references"
|
||||
? ok(
|
||||
createAnalysisExecution(
|
||||
{
|
||||
reference_kinds_available: referenceKindsAvailable,
|
||||
references: [
|
||||
callReference("0x1004", "0x2000"),
|
||||
{
|
||||
...callReference("0x1008", "0x3000"),
|
||||
kind: unavailableCallKind,
|
||||
},
|
||||
],
|
||||
unresolved_calls: [],
|
||||
},
|
||||
typedProvider,
|
||||
{ limitations: ["Some reference kinds are unavailable."] },
|
||||
),
|
||||
)
|
||||
: ok(
|
||||
createAnalysisExecution(
|
||||
["0x2000", "0x3000", "0x4000"],
|
||||
fallbackProvider,
|
||||
{ limitations: ["Callee enumeration is incomplete."] },
|
||||
),
|
||||
),
|
||||
},
|
||||
"0x1000",
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
routes: [
|
||||
{
|
||||
provider: typedProvider,
|
||||
address: "0x2000",
|
||||
site: "0x1004",
|
||||
relation: "direct_call",
|
||||
resolution: "observed",
|
||||
},
|
||||
{
|
||||
provider: fallbackProvider,
|
||||
address: "0x3000",
|
||||
site: "0x1000",
|
||||
relation: "provider_call",
|
||||
resolution: "inferred",
|
||||
},
|
||||
{
|
||||
provider: fallbackProvider,
|
||||
address: "0x4000",
|
||||
site: "0x1000",
|
||||
relation: "provider_call",
|
||||
resolution: "inferred",
|
||||
},
|
||||
],
|
||||
unknowns: [],
|
||||
limitations: [
|
||||
{
|
||||
provider: typedProvider,
|
||||
operation: "procedure_references",
|
||||
limitation: "Some reference kinds are unavailable.",
|
||||
},
|
||||
{
|
||||
provider: fallbackProvider,
|
||||
operation: "procedure_callees",
|
||||
limitation: "Callee enumeration is incomplete.",
|
||||
},
|
||||
],
|
||||
outcome: { ok: true, value: undefined },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("preserves distinct typed sites when fallback overlaps their target", async () => {
|
||||
const typedProvider = { ...provider, id: "typed" };
|
||||
const fallbackProvider = { ...provider, id: "fallback" };
|
||||
const result = await readNativeCallRoutes(
|
||||
{
|
||||
execute: async (operation) =>
|
||||
operation === "procedure_references"
|
||||
? ok(
|
||||
createAnalysisExecution(
|
||||
{
|
||||
reference_kinds_available: false,
|
||||
references: [
|
||||
callReference("0x1004", "0x2000"),
|
||||
callReference("0x1008", "0x2000"),
|
||||
{
|
||||
...callReference("0x100c", "0x4000"),
|
||||
kind: unavailableCallKind,
|
||||
},
|
||||
],
|
||||
unresolved_calls: [],
|
||||
},
|
||||
typedProvider,
|
||||
),
|
||||
)
|
||||
: ok(
|
||||
createAnalysisExecution(["0x2000", "0x3000"], fallbackProvider),
|
||||
),
|
||||
},
|
||||
"0x1000",
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
routes: [
|
||||
{
|
||||
provider: typedProvider,
|
||||
address: "0x2000",
|
||||
site: "0x1004",
|
||||
relation: "direct_call",
|
||||
resolution: "observed",
|
||||
},
|
||||
{
|
||||
provider: typedProvider,
|
||||
address: "0x2000",
|
||||
site: "0x1008",
|
||||
relation: "direct_call",
|
||||
resolution: "observed",
|
||||
},
|
||||
{
|
||||
provider: fallbackProvider,
|
||||
address: "0x3000",
|
||||
site: "0x1000",
|
||||
relation: "provider_call",
|
||||
resolution: "inferred",
|
||||
},
|
||||
],
|
||||
unknowns: [],
|
||||
limitations: [],
|
||||
outcome: { ok: true, value: undefined },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("native call fallback output failures", () => {
|
||||
it("retains source limitations when fallback output is invalid", async () => {
|
||||
const typedProvider = { ...provider, id: "typed" };
|
||||
const fallbackProvider = { ...provider, id: "fallback" };
|
||||
const result = await readNativeCallRoutes(
|
||||
{
|
||||
execute: async (operation) =>
|
||||
operation === "procedure_references"
|
||||
? err(
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
typedProvider.id,
|
||||
"procedure_references",
|
||||
"Typed references are unavailable.",
|
||||
),
|
||||
)
|
||||
: ok(
|
||||
createAnalysisExecution(
|
||||
{ callees: "not-an-address-list" },
|
||||
fallbackProvider,
|
||||
{ limitations: ["Fallback output is truncated."] },
|
||||
),
|
||||
),
|
||||
},
|
||||
"0x1000",
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
routes: [],
|
||||
unknowns: [],
|
||||
limitations: [
|
||||
{
|
||||
provider: fallbackProvider,
|
||||
operation: "procedure_callees",
|
||||
limitation: "Fallback output is truncated.",
|
||||
},
|
||||
],
|
||||
outcome: {
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisOutputError" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("retains known typed routes when fallback output is invalid", async () => {
|
||||
const typedProvider = { ...provider, id: "typed" };
|
||||
const fallbackProvider = { ...provider, id: "fallback" };
|
||||
const result = await readNativeCallRoutes(
|
||||
{
|
||||
execute: async (operation) =>
|
||||
operation === "procedure_references"
|
||||
? ok(
|
||||
createAnalysisExecution(
|
||||
{
|
||||
reference_kinds_available: false,
|
||||
references: [
|
||||
callReference("0x1004", "0x2000"),
|
||||
{
|
||||
...callReference("0x1008", "0x3000"),
|
||||
kind: unavailableCallKind,
|
||||
},
|
||||
],
|
||||
unresolved_calls: [],
|
||||
},
|
||||
typedProvider,
|
||||
{ limitations: ["One call kind remains unavailable."] },
|
||||
),
|
||||
)
|
||||
: ok(
|
||||
createAnalysisExecution(
|
||||
{ callees: "not-an-address-list" },
|
||||
fallbackProvider,
|
||||
{ limitations: ["Fallback output is truncated."] },
|
||||
),
|
||||
),
|
||||
},
|
||||
"0x1000",
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
routes: [
|
||||
{
|
||||
provider: typedProvider,
|
||||
address: "0x2000",
|
||||
site: "0x1004",
|
||||
relation: "direct_call",
|
||||
resolution: "observed",
|
||||
},
|
||||
],
|
||||
unknowns: [],
|
||||
limitations: [
|
||||
{
|
||||
provider: typedProvider,
|
||||
operation: "procedure_references",
|
||||
limitation: "One call kind remains unavailable.",
|
||||
},
|
||||
{
|
||||
provider: fallbackProvider,
|
||||
operation: "procedure_callees",
|
||||
limitation: "Fallback output is truncated.",
|
||||
},
|
||||
],
|
||||
outcome: {
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisOutputError" },
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("native call fallback cancellation", () => {
|
||||
it("keeps known typed routes when fallback is cancelled", async () => {
|
||||
let calls = 0;
|
||||
const referenceProvider = { ...provider, id: "references" };
|
||||
const result = await readNativeCallRoutes(
|
||||
{
|
||||
execute: async (operation) => {
|
||||
calls += 1;
|
||||
return operation === "procedure_references"
|
||||
? ok(
|
||||
createAnalysisExecution(
|
||||
{
|
||||
reference_kinds_available: false,
|
||||
references: [
|
||||
callReference("0x1004", "0x2000"),
|
||||
{
|
||||
...callReference("0x1008", "0x3000"),
|
||||
kind: unavailableCallKind,
|
||||
},
|
||||
],
|
||||
unresolved_calls: [],
|
||||
},
|
||||
referenceProvider,
|
||||
{ limitations: ["One call kind remains unavailable."] },
|
||||
),
|
||||
)
|
||||
: err(new AnalysisCancelledError("procedure_callees"));
|
||||
},
|
||||
},
|
||||
"0x1000",
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
routes: [
|
||||
{
|
||||
provider: referenceProvider,
|
||||
address: "0x2000",
|
||||
site: "0x1004",
|
||||
relation: "direct_call",
|
||||
resolution: "observed",
|
||||
},
|
||||
],
|
||||
unknowns: [],
|
||||
limitations: [
|
||||
{
|
||||
provider: referenceProvider,
|
||||
operation: "procedure_references",
|
||||
limitation: "One call kind remains unavailable.",
|
||||
},
|
||||
],
|
||||
outcome: {
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisCancelledError" },
|
||||
},
|
||||
});
|
||||
expect(calls).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("complete typed native call routes", () => {
|
||||
it("preserves computed targets, ambiguity and targetless call sites", async () => {
|
||||
const reference = jsonValueSchema.parse(ghidraReferenceEdge());
|
||||
if (
|
||||
@@ -87,13 +426,15 @@ describe("typed native call routes", () => {
|
||||
Array.isArray(reference)
|
||||
)
|
||||
throw new TypeError("Invalid reference fixture");
|
||||
const calls: string[] = [];
|
||||
const result = await readNativeCallRoutes(
|
||||
{
|
||||
execute: async () =>
|
||||
ok(
|
||||
execute: async (operation) => {
|
||||
calls.push(operation);
|
||||
return ok(
|
||||
createAnalysisExecution(
|
||||
{
|
||||
reference_kinds_available: true,
|
||||
reference_kinds_available: false,
|
||||
references: ["0x2000", "0x3000"].map((address) => ({
|
||||
...reference,
|
||||
source_address: "0x1004",
|
||||
@@ -125,36 +466,41 @@ describe("typed native call routes", () => {
|
||||
provider,
|
||||
{ limitations: ["SEH handler targets may be outside the body."] },
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
},
|
||||
"0x1000",
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
routes: [
|
||||
{
|
||||
address: "0x2000",
|
||||
relation: "indirect_call",
|
||||
resolution: "ambiguous",
|
||||
},
|
||||
{
|
||||
address: "0x3000",
|
||||
relation: "indirect_call",
|
||||
resolution: "ambiguous",
|
||||
},
|
||||
],
|
||||
unknowns: [{ address: "0x1008" }],
|
||||
limitations: [
|
||||
{
|
||||
provider,
|
||||
operation: "procedure_references",
|
||||
limitation: "SEH handler targets may be outside the body.",
|
||||
},
|
||||
],
|
||||
},
|
||||
routes: [
|
||||
{
|
||||
provider,
|
||||
address: "0x2000",
|
||||
relation: "indirect_call",
|
||||
resolution: "ambiguous",
|
||||
},
|
||||
{
|
||||
provider,
|
||||
address: "0x3000",
|
||||
relation: "indirect_call",
|
||||
resolution: "ambiguous",
|
||||
},
|
||||
],
|
||||
unknowns: [{ address: "0x1008", provider }],
|
||||
limitations: [
|
||||
{
|
||||
provider,
|
||||
operation: "procedure_references",
|
||||
limitation: "SEH handler targets may be outside the body.",
|
||||
},
|
||||
],
|
||||
outcome: { ok: true, value: undefined },
|
||||
});
|
||||
expect(calls).toEqual(["procedure_references"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("malformed typed native call output", () => {
|
||||
it("does not fall back on cancellation or invalid typed output", async () => {
|
||||
let calls = 0;
|
||||
const cancelled = await readNativeCallRoutes(
|
||||
@@ -167,8 +513,10 @@ describe("typed native call routes", () => {
|
||||
"0x1000",
|
||||
);
|
||||
expect(cancelled).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisCancelledError" },
|
||||
routes: [],
|
||||
unknowns: [],
|
||||
limitations: [],
|
||||
outcome: { ok: false, error: { _tag: "AnalysisCancelledError" } },
|
||||
});
|
||||
expect(calls).toBe(1);
|
||||
const malformed = await readNativeCallRoutes(
|
||||
@@ -183,8 +531,8 @@ describe("typed native call routes", () => {
|
||||
"0x1000",
|
||||
);
|
||||
expect(malformed).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisOutputError" },
|
||||
routes: [],
|
||||
unknowns: [],
|
||||
limitations: [
|
||||
{
|
||||
provider,
|
||||
@@ -192,8 +540,12 @@ describe("typed native call routes", () => {
|
||||
limitation: "Malformed output still has source caveats.",
|
||||
},
|
||||
],
|
||||
outcome: { ok: false, error: { _tag: "AnalysisOutputError" } },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("missing unresolved-call observations", () => {
|
||||
it("rejects omitted unresolved-call observations instead of claiming none", async () => {
|
||||
let calls = 0;
|
||||
const result = await readNativeCallRoutes(
|
||||
@@ -215,8 +567,10 @@ describe("typed native call routes", () => {
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisOutputError" },
|
||||
routes: [],
|
||||
unknowns: [],
|
||||
limitations: [],
|
||||
outcome: { ok: false, error: { _tag: "AnalysisOutputError" } },
|
||||
});
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
referenceKindSchema,
|
||||
} from "../../domain/hopperValues.js";
|
||||
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";
|
||||
|
||||
/** Preferred typed observations and the untyped fallback share one owner. */
|
||||
@@ -39,9 +41,30 @@ const referencesSchema = z.object({
|
||||
),
|
||||
});
|
||||
|
||||
interface NativeCallRoute {
|
||||
readonly address: string;
|
||||
readonly site: string;
|
||||
readonly relation: "direct_call" | "indirect_call" | "provider_call";
|
||||
readonly resolution: "observed" | "resolved" | "ambiguous" | "inferred";
|
||||
readonly provider: ProviderIdentity;
|
||||
}
|
||||
|
||||
/** Enumeration failure does not invalidate facts already obtained. */
|
||||
interface NativeCallRouteRead {
|
||||
readonly routes: readonly NativeCallRoute[];
|
||||
readonly unknowns: readonly {
|
||||
address: string;
|
||||
reason: string;
|
||||
provider: ProviderIdentity;
|
||||
}[];
|
||||
readonly limitations: readonly SourceLimitation[];
|
||||
readonly outcome: Result<void, AnalysisError>;
|
||||
}
|
||||
|
||||
const typedCallRoutes = (
|
||||
references: z.infer<typeof referencesSchema>["references"],
|
||||
) => {
|
||||
provider: ProviderIdentity,
|
||||
): NativeCallRoute[] => {
|
||||
const calls = references.filter(({ kind }) => kind.available && kind.call);
|
||||
const countBySite = new Map<string, Set<string>>();
|
||||
for (const call of calls) {
|
||||
@@ -50,6 +73,7 @@ const typedCallRoutes = (
|
||||
countBySite.set(call.source_address, targets);
|
||||
}
|
||||
return calls.map((call) => ({
|
||||
provider,
|
||||
address: call.target_procedure?.address ?? call.target_address,
|
||||
site: call.source_address,
|
||||
relation:
|
||||
@@ -65,13 +89,13 @@ const typedCallRoutes = (
|
||||
}));
|
||||
};
|
||||
|
||||
/** Prefer typed static call references, preserving computed targets and targetless sites. */
|
||||
/** Retain typed observations while supplementing incomplete kinds with inferred callees. */
|
||||
export const readNativeCallRoutes = async (
|
||||
analysis: AnalysisOperationPort,
|
||||
procedure: string,
|
||||
signal?: AbortSignal,
|
||||
selectorOrigin?: ExecutionOptions["selectorOrigin"],
|
||||
) => {
|
||||
): Promise<NativeCallRouteRead> => {
|
||||
const options = {
|
||||
...(signal === undefined ? {} : { signal }),
|
||||
...(selectorOrigin === undefined ? {} : { selectorOrigin }),
|
||||
@@ -81,88 +105,85 @@ export const readNativeCallRoutes = async (
|
||||
{ procedure, direction: "outgoing" },
|
||||
options,
|
||||
);
|
||||
let unknowns: Array<{
|
||||
const routes: NativeCallRoute[] = [];
|
||||
const unknowns: Array<{
|
||||
address: string;
|
||||
reason: string;
|
||||
provider: ProviderIdentity;
|
||||
}> = [];
|
||||
let limitations: SourceLimitation[] = [];
|
||||
const limitations: SourceLimitation[] = [];
|
||||
const finish = (
|
||||
outcome: Result<void, AnalysisError>,
|
||||
): NativeCallRouteRead => ({
|
||||
routes,
|
||||
unknowns,
|
||||
limitations,
|
||||
outcome,
|
||||
});
|
||||
if (execution.ok) {
|
||||
limitations = limitationsFrom(
|
||||
NATIVE_CALL_ROUTE_SOURCES.references,
|
||||
execution.value.provider,
|
||||
execution.value.limitations,
|
||||
limitations.push(
|
||||
...limitationsFrom(
|
||||
NATIVE_CALL_ROUTE_SOURCES.references,
|
||||
execution.value.provider,
|
||||
execution.value.limitations,
|
||||
),
|
||||
);
|
||||
const parsed = referencesSchema.safeParse(execution.value.result);
|
||||
if (!parsed.success)
|
||||
return {
|
||||
ok: false as const,
|
||||
error: new AnalysisOutputError(
|
||||
NATIVE_CALL_ROUTE_SOURCES.references,
|
||||
"Invalid typed call references",
|
||||
return finish(
|
||||
err(
|
||||
new AnalysisOutputError(
|
||||
NATIVE_CALL_ROUTE_SOURCES.references,
|
||||
"Invalid typed call references",
|
||||
),
|
||||
),
|
||||
unknowns,
|
||||
limitations,
|
||||
};
|
||||
);
|
||||
// Missing detailed reference flags do not invalidate observed targetless
|
||||
// calls. Keep their producing provider even if another provider supplies
|
||||
// the fallback callee inventory.
|
||||
unknowns = parsed.data.unresolved_calls.map((call) => ({
|
||||
...call,
|
||||
provider: execution.value.provider,
|
||||
}));
|
||||
unknowns.push(
|
||||
...parsed.data.unresolved_calls.map((call) => ({
|
||||
...call,
|
||||
provider: execution.value.provider,
|
||||
})),
|
||||
);
|
||||
const references = parsed.data.references;
|
||||
routes.push(...typedCallRoutes(references, execution.value.provider));
|
||||
if (
|
||||
parsed.data.reference_kinds_available === true ||
|
||||
(references.length > 0 && references.every(({ kind }) => kind.available))
|
||||
) {
|
||||
return {
|
||||
ok: true as const,
|
||||
value: {
|
||||
provider: execution.value.provider,
|
||||
routes: typedCallRoutes(references),
|
||||
unknowns,
|
||||
limitations,
|
||||
},
|
||||
};
|
||||
}
|
||||
references.every(({ kind }) => kind.available) &&
|
||||
(parsed.data.reference_kinds_available || references.length > 0)
|
||||
)
|
||||
return finish(ok(undefined));
|
||||
} else if (execution.error._tag !== "AnalysisCapabilityUnavailableError")
|
||||
return {
|
||||
ok: false as const,
|
||||
error: execution.error,
|
||||
unknowns,
|
||||
limitations,
|
||||
};
|
||||
return finish(err(execution.error));
|
||||
const callees = await analysis.execute(
|
||||
NATIVE_CALL_ROUTE_SOURCES.callees,
|
||||
{ procedure },
|
||||
options,
|
||||
);
|
||||
if (!callees.ok)
|
||||
return { ok: false as const, error: callees.error, unknowns, limitations };
|
||||
limitations = [
|
||||
...limitations,
|
||||
if (!callees.ok) return finish(err(callees.error));
|
||||
limitations.push(
|
||||
...limitationsFrom(
|
||||
NATIVE_CALL_ROUTE_SOURCES.callees,
|
||||
callees.value.provider,
|
||||
callees.value.limitations,
|
||||
),
|
||||
];
|
||||
);
|
||||
const parsed = parseRelatedAddresses(callees.value.result, "callees");
|
||||
return parsed.ok
|
||||
? {
|
||||
ok: true as const,
|
||||
value: {
|
||||
provider: callees.value.provider,
|
||||
routes: parsed.value.map((address) => ({
|
||||
address,
|
||||
site: procedure,
|
||||
relation: "provider_call" as const,
|
||||
resolution: "inferred" as const,
|
||||
})),
|
||||
unknowns,
|
||||
limitations,
|
||||
},
|
||||
}
|
||||
: { ok: false as const, error: parsed.error, unknowns, limitations };
|
||||
if (!parsed.ok) return finish(err(parsed.error));
|
||||
// Keep every observed call site. A target-only fallback cannot replace one,
|
||||
// and repeated fallback targets add no new observation.
|
||||
const targets = new Set(routes.map(({ address }) => address));
|
||||
for (const address of parsed.value) {
|
||||
if (targets.has(address)) continue;
|
||||
targets.add(address);
|
||||
routes.push({
|
||||
address,
|
||||
site: procedure,
|
||||
relation: "provider_call",
|
||||
resolution: "inferred",
|
||||
provider: callees.value.provider,
|
||||
});
|
||||
}
|
||||
return finish(ok(undefined));
|
||||
};
|
||||
|
||||
@@ -202,7 +202,7 @@ export const traceNativeUiAction = async (
|
||||
max_edges: input.max_edges,
|
||||
},
|
||||
});
|
||||
const expanded = await addDirectCallees({
|
||||
const expanded = await expandNativeCalls({
|
||||
analysis,
|
||||
graph,
|
||||
initial,
|
||||
@@ -372,7 +372,7 @@ const resolveUiAction = (
|
||||
};
|
||||
};
|
||||
|
||||
const addDirectCallees = async (input: {
|
||||
const expandNativeCalls = async (input: {
|
||||
readonly analysis: AnalysisOperationPort;
|
||||
readonly graph: z.infer<typeof nativeInvestigationGraphSchema>;
|
||||
readonly initial: z.infer<typeof nativeInvestigationTraceSchema>;
|
||||
@@ -435,14 +435,13 @@ const addDirectCallees = async (input: {
|
||||
"provider",
|
||||
);
|
||||
examined += 1;
|
||||
sourceLimitations.push(
|
||||
...(execution.ok ? execution.value.limitations : execution.limitations),
|
||||
);
|
||||
if (!execution.ok && execution.error._tag === "AnalysisCancelledError")
|
||||
return { error: execution.error };
|
||||
const unknowns = execution.ok
|
||||
? execution.value.unknowns
|
||||
: execution.unknowns;
|
||||
sourceLimitations.push(...execution.limitations);
|
||||
if (
|
||||
!execution.outcome.ok &&
|
||||
execution.outcome.error._tag === "AnalysisCancelledError"
|
||||
)
|
||||
return { error: execution.outcome.error };
|
||||
const unknowns = execution.unknowns;
|
||||
for (const [index, unknown] of unknowns.entries()) {
|
||||
if (edges.size >= input.input.max_edges) {
|
||||
truncated = true;
|
||||
@@ -470,40 +469,19 @@ const addDirectCallees = async (input: {
|
||||
limitations: [],
|
||||
});
|
||||
}
|
||||
if (!execution.ok) {
|
||||
if (edges.size >= input.input.max_edges) {
|
||||
truncated = true;
|
||||
omitted += 1;
|
||||
continue;
|
||||
}
|
||||
const id = `unknown-callees:${current.address}`;
|
||||
edges.set(id, {
|
||||
id,
|
||||
from: current.node.id,
|
||||
to: null,
|
||||
relation: "direct_call",
|
||||
resolution: "unresolved",
|
||||
reason: projectAnalysisError(execution.error).message,
|
||||
evidence: [],
|
||||
limitations: [
|
||||
"The provider could not enumerate resolved direct callees.",
|
||||
],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
for (const [index, route] of execution.value.routes.entries()) {
|
||||
for (const [index, route] of execution.routes.entries()) {
|
||||
const address = route.address;
|
||||
if (edges.size >= input.input.max_edges) {
|
||||
truncated = true;
|
||||
omitted += execution.value.routes.length - index;
|
||||
omitted += execution.routes.length - index;
|
||||
break;
|
||||
}
|
||||
const targetId = `native:function:${address}`;
|
||||
const evidence = [
|
||||
{
|
||||
kind: "reference" as const,
|
||||
provider: execution.value.provider,
|
||||
description: `${execution.value.provider.name} reported ${route.relation} from ${current.address} to ${address}`,
|
||||
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,
|
||||
@@ -513,12 +491,12 @@ const addDirectCallees = async (input: {
|
||||
if (nodes.size >= input.input.max_nodes) {
|
||||
truncated = true;
|
||||
omitted += 1;
|
||||
const id = `unknown-node-bound:${current.address}:${address}`;
|
||||
const id = `unknown-node-bound:${current.address}:${route.site}:${address}`;
|
||||
edges.set(id, {
|
||||
id,
|
||||
from: current.node.id,
|
||||
to: null,
|
||||
relation: "direct_call",
|
||||
relation: route.relation,
|
||||
resolution: "unresolved",
|
||||
reason: "native_trace_node_bound_reached",
|
||||
evidence,
|
||||
@@ -532,7 +510,7 @@ const addDirectCallees = async (input: {
|
||||
kind: "function" as const,
|
||||
name,
|
||||
location: { address, file_offset: null },
|
||||
attributes: { resolved_by: execution.value.provider.id },
|
||||
attributes: { resolved_by: route.provider.id },
|
||||
evidence,
|
||||
};
|
||||
nodes.set(targetId, node);
|
||||
@@ -551,6 +529,24 @@ const addDirectCallees = async (input: {
|
||||
],
|
||||
});
|
||||
}
|
||||
if (!execution.outcome.ok) {
|
||||
if (edges.size >= input.input.max_edges) {
|
||||
truncated = true;
|
||||
omitted += 1;
|
||||
continue;
|
||||
}
|
||||
const id = `unknown-callees:${current.address}`;
|
||||
edges.set(id, {
|
||||
id,
|
||||
from: current.node.id,
|
||||
to: null,
|
||||
relation: "provider_call",
|
||||
resolution: "unresolved",
|
||||
reason: projectAnalysisError(execution.outcome.error).message,
|
||||
evidence: [],
|
||||
limitations: ["The provider could not complete callee enumeration."],
|
||||
});
|
||||
}
|
||||
}
|
||||
return {
|
||||
nodes: [...nodes.values()],
|
||||
|
||||
@@ -0,0 +1,427 @@
|
||||
import { afterEach, expect, it } from "vitest";
|
||||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
import { z } from "zod";
|
||||
import type {
|
||||
AnalysisExecution,
|
||||
AnalysisOperationPort,
|
||||
} from "../../../src/application/AnalysisProvider.js";
|
||||
import type { AnalysisError } from "../../../src/domain/analysisErrorBase.js";
|
||||
import { createAnalysisExecution } from "../../../src/application/AnalysisProvider.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisCapabilityUnavailableError,
|
||||
AnalysisOutputError,
|
||||
} from "../../../src/domain/analysisErrorCore.js";
|
||||
import { inspectNativeDispatchMetadata } from "../../../src/domain/native/objcSwiftMetadata.js";
|
||||
import { nativeInvestigationTraceSchema } from "../../../src/domain/native/nativeInvestigationGraph.js";
|
||||
import { err, ok, type Result } from "../../../src/domain/result.js";
|
||||
import { createServer } from "../../../src/server/createServer.js";
|
||||
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
|
||||
|
||||
const identity = { id: "typed", name: "Typed provider", version: "1" };
|
||||
const fallback = { id: "fallback", name: "Fallback provider", version: "1" };
|
||||
const sha256 = "a".repeat(64);
|
||||
const subject = {
|
||||
sha256,
|
||||
path: "/fixture",
|
||||
format: "mach-o",
|
||||
architecture: "arm64",
|
||||
} as const;
|
||||
const resources: Array<{ close(): Promise<unknown> }> = [];
|
||||
afterEach(async () =>
|
||||
Promise.all(resources.splice(0).map((resource) => resource.close())),
|
||||
);
|
||||
|
||||
function typedKind(indirect = false) {
|
||||
return {
|
||||
available: true,
|
||||
provenance: "fixture reference classification",
|
||||
type: "call",
|
||||
flow: true,
|
||||
call: true,
|
||||
jump: false,
|
||||
data: false,
|
||||
read: false,
|
||||
write: false,
|
||||
indirect,
|
||||
computed: indirect,
|
||||
conditional: false,
|
||||
terminal: false,
|
||||
primary: true,
|
||||
operand_index: 0,
|
||||
external: false,
|
||||
};
|
||||
}
|
||||
function ref(site: string, target: string, indirect = false) {
|
||||
return {
|
||||
source_address: site,
|
||||
target_address: target,
|
||||
target_procedure: { address: target },
|
||||
kind: typedKind(indirect),
|
||||
};
|
||||
}
|
||||
const mixedReferences = (refs = [ref("0x1004", "0x2000")]) => ({
|
||||
reference_kinds_available: false,
|
||||
references: [
|
||||
...refs,
|
||||
{
|
||||
source_address: "0x1008",
|
||||
target_address: "0x9000",
|
||||
target_procedure: { address: "0x9000" },
|
||||
kind: { available: false, reason: "classification unavailable" },
|
||||
},
|
||||
],
|
||||
unresolved_calls: [],
|
||||
});
|
||||
const completeReferences = (refs: ReturnType<typeof ref>[]) => ({
|
||||
reference_kinds_available: true,
|
||||
references: refs,
|
||||
unresolved_calls: [],
|
||||
});
|
||||
const noClassifiedReferences = {
|
||||
reference_kinds_available: false,
|
||||
references: [],
|
||||
unresolved_calls: [],
|
||||
};
|
||||
|
||||
type Plan = {
|
||||
readonly references?: ReturnType<typeof mixedReferences>;
|
||||
readonly callees?: readonly string[];
|
||||
readonly fallbackProvider?: typeof identity;
|
||||
readonly fallbackFailure?: "unavailable" | "output" | "cancelled";
|
||||
};
|
||||
function publicTrace(plan: Plan, maxNodes = 12) {
|
||||
const analysis: AnalysisOperationPort = {
|
||||
execute: async (
|
||||
operation,
|
||||
parameters,
|
||||
): Promise<Result<AnalysisExecution, AnalysisError>> => {
|
||||
if (operation === "decode_interface_builder")
|
||||
return err(
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
identity.id,
|
||||
operation,
|
||||
"No authored UI fixture",
|
||||
),
|
||||
);
|
||||
if (operation === "inspect_native_dispatch_metadata")
|
||||
return ok(
|
||||
createAnalysisExecution(
|
||||
{
|
||||
target_sha256: sha256,
|
||||
provider: identity,
|
||||
analysis_profile_digest: null,
|
||||
result: inspectNativeDispatchMetadata([]),
|
||||
},
|
||||
identity,
|
||||
{ subject },
|
||||
),
|
||||
);
|
||||
if (operation === "procedure_address")
|
||||
return ok(createAnalysisExecution("0x1000", identity, { subject }));
|
||||
if (operation === "procedure_references")
|
||||
return ok(
|
||||
createAnalysisExecution(
|
||||
plan.references ?? mixedReferences(),
|
||||
identity,
|
||||
{ subject },
|
||||
),
|
||||
);
|
||||
if (operation === "procedure_callees") {
|
||||
if (plan.fallbackFailure === "unavailable")
|
||||
return err(
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
identity.id,
|
||||
operation,
|
||||
"enumeration unavailable in fixture",
|
||||
),
|
||||
);
|
||||
if (plan.fallbackFailure === "output")
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
operation,
|
||||
"enumeration output failed in fixture",
|
||||
),
|
||||
);
|
||||
if (plan.fallbackFailure === "cancelled")
|
||||
return err(new AnalysisCancelledError(operation));
|
||||
return ok(
|
||||
createAnalysisExecution(
|
||||
plan.callees ?? ["0x3000"],
|
||||
plan.fallbackProvider ?? fallback,
|
||||
{ subject },
|
||||
),
|
||||
);
|
||||
}
|
||||
throw new Error(
|
||||
`Unexpected operation ${operation} ${JSON.stringify(parameters)}`,
|
||||
);
|
||||
},
|
||||
};
|
||||
const server = createServer({ kind: "fixed", analysis });
|
||||
const client = new Client({ name: "native-ui-partial-routes", version: "1" });
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
resources.push({
|
||||
close: async () => {
|
||||
await client.close();
|
||||
await server.close();
|
||||
},
|
||||
});
|
||||
return (async () => {
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
const result = await client.callTool({
|
||||
name: "trace_native_ui_action",
|
||||
arguments: { action: "0x1000", max_nodes: maxNodes, max_depth: 1 },
|
||||
});
|
||||
return result;
|
||||
})();
|
||||
}
|
||||
|
||||
const traceFrom = (result: { structuredContent?: unknown }) =>
|
||||
z
|
||||
.object({
|
||||
normalized_result: nativeInvestigationTraceSchema,
|
||||
})
|
||||
.parse(result.structuredContent).normalized_result;
|
||||
|
||||
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);
|
||||
const trace = traceFrom(response);
|
||||
expect(trace.target_sha256).toBe(sha256);
|
||||
expect(trace.edges).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
from: "native:function:0x1000",
|
||||
to: "native:function:0x2000",
|
||||
relation: "direct_call",
|
||||
resolution: "observed",
|
||||
evidence: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
provider: identity,
|
||||
location: expect.objectContaining({ address: "0x1004" }),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
from: "native:function:0x1000",
|
||||
to: "native:function:0x3000",
|
||||
relation: "provider_call",
|
||||
resolution: "inferred",
|
||||
evidence: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
provider: fallback,
|
||||
location: expect.objectContaining({ address: "0x1000" }),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it.each(["unavailable", "output"] as const)(
|
||||
"retains known observed routes when fallback enumeration has %s failure",
|
||||
async (failure) => {
|
||||
const response = await publicTrace({ fallbackFailure: failure });
|
||||
expect(response.isError).not.toBe(true);
|
||||
const trace = traceFrom(response);
|
||||
expect(trace.edges).toContainEqual(
|
||||
expect.objectContaining({
|
||||
from: "native:function:0x1000",
|
||||
to: "native:function:0x2000",
|
||||
relation: "direct_call",
|
||||
resolution: "observed",
|
||||
evidence: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
provider: identity,
|
||||
location: expect.objectContaining({ address: "0x1004" }),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
expect(trace.unresolved).toContainEqual(
|
||||
expect.objectContaining({
|
||||
from: "native:function:0x1000",
|
||||
to: null,
|
||||
relation: "provider_call",
|
||||
resolution: "unresolved",
|
||||
reason:
|
||||
failure === "unavailable"
|
||||
? "This analysis is unavailable for the current target. Choose another analysis or target."
|
||||
: expect.stringContaining("unreadable result"),
|
||||
limitations: expect.arrayContaining([
|
||||
"The provider could not complete callee enumeration.",
|
||||
]),
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("deduplicates fallback overlap without erasing multiple typed callsites", async () => {
|
||||
const response = await publicTrace({
|
||||
references: mixedReferences([
|
||||
ref("0x1004", "0x2000"),
|
||||
ref("0x100c", "0x2000"),
|
||||
]),
|
||||
callees: ["0x2000", "0x3000"],
|
||||
});
|
||||
expect(response.isError).not.toBe(true);
|
||||
const trace = traceFrom(response);
|
||||
const typedEdges = trace.edges.filter(
|
||||
(edge) => edge.to === "native:function:0x2000",
|
||||
);
|
||||
expect(typedEdges).toHaveLength(2);
|
||||
expect(
|
||||
typedEdges
|
||||
.map((edge) => edge.evidence.flatMap((item) => item.location?.address))
|
||||
.flat(),
|
||||
).toEqual(expect.arrayContaining(["0x1004", "0x100c"]));
|
||||
expect(
|
||||
trace.edges.filter(
|
||||
(edge) =>
|
||||
edge.to === "native:function:0x2000" &&
|
||||
edge.relation === "provider_call",
|
||||
),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
relation: "direct_call",
|
||||
references: completeReferences([
|
||||
ref("0x1004", "0x2000"),
|
||||
ref("0x1008", "0x3000"),
|
||||
]),
|
||||
callees: [],
|
||||
firstRelation: "direct_call",
|
||||
firstTarget: "native:function:0x2000",
|
||||
firstResolution: "observed",
|
||||
firstProvider: identity,
|
||||
firstSite: "0x1004",
|
||||
},
|
||||
{
|
||||
relation: "indirect_call",
|
||||
references: completeReferences([
|
||||
ref("0x1004", "0x2000"),
|
||||
ref("0x1008", "0x3000", true),
|
||||
]),
|
||||
callees: [],
|
||||
firstRelation: "direct_call",
|
||||
firstTarget: "native:function:0x2000",
|
||||
firstResolution: "observed",
|
||||
firstProvider: identity,
|
||||
firstSite: "0x1004",
|
||||
},
|
||||
{
|
||||
relation: "provider_call",
|
||||
references: noClassifiedReferences,
|
||||
callees: ["0x2000", "0x3000"],
|
||||
firstRelation: "provider_call",
|
||||
firstTarget: "native:function:0x2000",
|
||||
firstResolution: "inferred",
|
||||
firstProvider: fallback,
|
||||
firstSite: "0x1000",
|
||||
},
|
||||
] as const)(
|
||||
"preserves $relation when node bounds omit a route",
|
||||
async ({
|
||||
relation,
|
||||
callees,
|
||||
references,
|
||||
firstRelation,
|
||||
firstTarget,
|
||||
firstResolution,
|
||||
firstProvider,
|
||||
firstSite,
|
||||
}) => {
|
||||
const response = await publicTrace({ references, callees }, 2);
|
||||
expect(response.isError).not.toBe(true);
|
||||
const trace = traceFrom(response);
|
||||
expect(trace.unresolved).toContainEqual(
|
||||
expect.objectContaining({
|
||||
from: "native:function:0x1000",
|
||||
to: null,
|
||||
relation,
|
||||
resolution: "unresolved",
|
||||
reason: "native_trace_node_bound_reached",
|
||||
}),
|
||||
);
|
||||
expect(trace.edges).toContainEqual(
|
||||
expect.objectContaining({
|
||||
from: "native:function:0x1000",
|
||||
to: firstTarget,
|
||||
relation: firstRelation,
|
||||
resolution: firstResolution,
|
||||
evidence: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
provider: firstProvider,
|
||||
location: expect.objectContaining({ address: firstSite }),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
expect(trace.truncated).toBe(true);
|
||||
expect(trace.coverage).toContainEqual(
|
||||
expect.objectContaining({
|
||||
facet: "static_native_calls",
|
||||
status: "partial",
|
||||
}),
|
||||
);
|
||||
expect(trace.limitations).toContain(
|
||||
"Static analysis does not prove that an authored UI action is reachable or executed at runtime.",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("retains separate omitted callsites when routes share a target", async () => {
|
||||
const response = await publicTrace(
|
||||
{
|
||||
references: completeReferences([
|
||||
ref("0x1004", "0x2000"),
|
||||
ref("0x1008", "0x3000", true),
|
||||
ref("0x100c", "0x3000"),
|
||||
]),
|
||||
callees: [],
|
||||
},
|
||||
2,
|
||||
);
|
||||
expect(response.isError).not.toBe(true);
|
||||
const trace = traceFrom(response);
|
||||
expect(trace.unresolved).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
from: "native:function:0x1000",
|
||||
to: null,
|
||||
relation: "indirect_call",
|
||||
resolution: "unresolved",
|
||||
evidence: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
provider: identity,
|
||||
location: expect.objectContaining({ address: "0x1008" }),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
from: "native:function:0x1000",
|
||||
to: null,
|
||||
relation: "direct_call",
|
||||
resolution: "unresolved",
|
||||
evidence: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
provider: identity,
|
||||
location: expect.objectContaining({ address: "0x100c" }),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(trace.truncated).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps cancellation terminal when fallback enumeration is cancelled", async () => {
|
||||
const response = await publicTrace({ fallbackFailure: "cancelled" });
|
||||
expect(response.isError).toBe(true);
|
||||
expect(parseMcpToolError(response).error.code).toBe("cancelled");
|
||||
});
|
||||
Reference in New Issue
Block a user