mirror of
https://github.com/morluto/rea.git
synced 2026-10-12 02:19:24 +00:00
* fix(hopper): normalize string-backed addresses and update verification * feat(native): add inspection primitives, dispatch traces and approved UI observation * test: prioritize real artifact E2E and captured golden regressions * fix(process): queue observed output before reactive rendering actions * test: prune redundant cases and strengthen boundary assertions * fix(cli): route native value tracing through shared workflows * test: replace synthetic success with real workflows and captured data * fix(catalog): separate portable metadata from native host availability
547 lines
15 KiB
TypeScript
547 lines
15 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { z } from "zod";
|
|
|
|
import {
|
|
createAnalysisExecution,
|
|
type AnalysisExecution,
|
|
type AnalysisOperation,
|
|
} from "./AnalysisProvider.js";
|
|
import type { AnalysisError } from "../domain/errors.js";
|
|
import { EnhancedTools } from "./EnhancedTools.js";
|
|
import {
|
|
nativeInvestigationGraphSchema,
|
|
nativeInvestigationTraceSchema,
|
|
} from "../domain/nativeInvestigationGraph.js";
|
|
import { err, ok } from "../domain/result.js";
|
|
import type { Result } from "../domain/result.js";
|
|
import {
|
|
AnalysisCancelledError,
|
|
AnalysisCapabilityUnavailableError,
|
|
} from "../domain/errors.js";
|
|
|
|
const target = "a".repeat(64);
|
|
const provider = { id: "fixture", name: "Fixture", version: "1" };
|
|
const evidence = [
|
|
{
|
|
kind: "interface_builder_resource" as const,
|
|
description: "Fixture UI archive",
|
|
location: { address: null, file_offset: null },
|
|
artifact_path: null,
|
|
artifact_sha256: null,
|
|
},
|
|
];
|
|
const graph = {
|
|
target_sha256: target,
|
|
provider: {
|
|
id: provider.id,
|
|
version: provider.version,
|
|
tool_version: "fixture",
|
|
},
|
|
nodes: [
|
|
{
|
|
id: "button",
|
|
kind: "control" as const,
|
|
name: "Build",
|
|
location: null,
|
|
attributes: {
|
|
class_name: "UIButton",
|
|
interface_builder_object_id: "button",
|
|
},
|
|
evidence,
|
|
},
|
|
{
|
|
id: "action",
|
|
kind: "action" as const,
|
|
name: "buildTapped:",
|
|
location: null,
|
|
attributes: { selector: "buildTapped:" },
|
|
evidence,
|
|
},
|
|
{
|
|
id: "controller",
|
|
kind: "view_controller" as const,
|
|
name: "BuildViewController",
|
|
location: null,
|
|
attributes: { class_name: "BuildViewController" },
|
|
evidence,
|
|
},
|
|
{
|
|
id: "selector",
|
|
kind: "objc_selector" as const,
|
|
name: "buildTapped:",
|
|
location: null,
|
|
attributes: {},
|
|
evidence,
|
|
},
|
|
],
|
|
edges: [
|
|
{
|
|
id: "button-action",
|
|
from: "button",
|
|
to: "action",
|
|
relation: "target_action" as const,
|
|
resolution: "observed" as const,
|
|
evidence,
|
|
limitations: [],
|
|
},
|
|
{
|
|
id: "action-selector",
|
|
from: "action",
|
|
to: "selector",
|
|
relation: "target_action" as const,
|
|
resolution: "observed" as const,
|
|
evidence,
|
|
limitations: [],
|
|
},
|
|
{
|
|
id: "action-controller",
|
|
from: "action",
|
|
to: "controller",
|
|
relation: "target_action" as const,
|
|
resolution: "observed" as const,
|
|
evidence,
|
|
limitations: [],
|
|
},
|
|
],
|
|
coverage: [],
|
|
truncated: false,
|
|
};
|
|
|
|
const execution = (
|
|
operation: AnalysisOperation,
|
|
result: unknown,
|
|
sha256 = target,
|
|
) =>
|
|
ok(
|
|
createAnalysisExecution(result, provider, {
|
|
subject: {
|
|
sha256,
|
|
path: "/fixture.app",
|
|
format: "mach-o",
|
|
architecture: "arm64",
|
|
},
|
|
}),
|
|
);
|
|
|
|
const testAnalysis = (
|
|
overrides: Partial<
|
|
Record<
|
|
AnalysisOperation,
|
|
(
|
|
parameters: Readonly<Record<string, unknown>>,
|
|
) => Result<AnalysisExecution, AnalysisError>
|
|
>
|
|
> = {},
|
|
uiGraph: z.input<typeof nativeInvestigationGraphSchema> = graph,
|
|
) => ({
|
|
execute: async (
|
|
operation: AnalysisOperation,
|
|
parameters: Readonly<Record<string, unknown>>,
|
|
): Promise<Result<AnalysisExecution, AnalysisError>> => {
|
|
const override = overrides[operation];
|
|
if (override !== undefined) return override(parameters);
|
|
switch (operation) {
|
|
case "procedure_address":
|
|
return err(
|
|
new AnalysisCapabilityUnavailableError(
|
|
"fixture",
|
|
operation,
|
|
"Seed not available",
|
|
),
|
|
);
|
|
case "procedure_references":
|
|
return err(
|
|
new AnalysisCapabilityUnavailableError(
|
|
"fixture",
|
|
operation,
|
|
"No typed reference reader in fixture",
|
|
),
|
|
);
|
|
case "inspect_native_dispatch_metadata":
|
|
return err(
|
|
new AnalysisCapabilityUnavailableError(
|
|
"fixture",
|
|
operation,
|
|
"No binary metadata reader in fixture",
|
|
),
|
|
);
|
|
case "decode_interface_builder":
|
|
return execution(operation, {
|
|
target_sha256: target,
|
|
documents: [
|
|
{
|
|
relative_path: "Base.lproj/Main.nib",
|
|
archive_sha256: "b".repeat(64),
|
|
document_kind: "nib",
|
|
object_count: 4,
|
|
connection_count: 3,
|
|
hierarchy_complete: true,
|
|
},
|
|
],
|
|
graph: uiGraph,
|
|
limitations: [],
|
|
});
|
|
case "list_names":
|
|
return execution(operation, [
|
|
{ address: "0x1000", name: "-[BuildViewController buildTapped:]" },
|
|
]);
|
|
case "procedure_callees":
|
|
return execution(
|
|
operation,
|
|
parameters.procedure === "0x1000" ? ["0x2000"] : [],
|
|
);
|
|
default:
|
|
throw new Error(`Unexpected provider operation: ${operation}`);
|
|
}
|
|
},
|
|
});
|
|
|
|
describe("native UI action trace", () => {
|
|
it("accepts an explicit native seed without an authored UI connection", async () => {
|
|
const result = await new EnhancedTools(
|
|
testAnalysis({
|
|
procedure_address: () => execution("procedure_address", "0x1000"),
|
|
}),
|
|
).execute("trace_native_ui_action", { action: "0x1000" });
|
|
expect(result).toMatchObject({
|
|
ok: true,
|
|
value: {
|
|
start: "native:function:0x1000",
|
|
nodes: expect.arrayContaining([
|
|
expect.objectContaining({ id: "native:function:0x2000" }),
|
|
]),
|
|
},
|
|
});
|
|
});
|
|
it("builds the evidence path from the active app and native provider", async () => {
|
|
const tools = new EnhancedTools(testAnalysis());
|
|
const result = await tools.execute("trace_native_ui_action", {
|
|
action: "buildTapped:",
|
|
});
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) return;
|
|
const trace = nativeInvestigationTraceSchema.parse(result.value);
|
|
expect(trace.start).toBe("action");
|
|
expect(trace.nodes.map(({ id }) => id)).toContain("native:function:0x1000");
|
|
expect(trace.nodes.map(({ id }) => id)).toContain("native:function:0x2000");
|
|
expect(trace.edges).toContainEqual(
|
|
expect.objectContaining({
|
|
relation: "objc_dispatch",
|
|
resolution: "inferred",
|
|
}),
|
|
);
|
|
expect(trace.edges).toContainEqual(
|
|
expect.objectContaining({
|
|
relation: "provider_call",
|
|
to: "native:function:0x2000",
|
|
}),
|
|
);
|
|
expect(trace.coverage).toContainEqual(
|
|
expect.objectContaining({
|
|
facet: "cross_function_value_flow",
|
|
status: "unsupported",
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("bounded native UI tracing", () => {
|
|
it("continues handler traversal from its own depth when another UI branch is deeper", async () => {
|
|
const deepGraph = {
|
|
...graph,
|
|
nodes: [
|
|
...graph.nodes,
|
|
{
|
|
id: "view",
|
|
kind: "view" as const,
|
|
name: "Container",
|
|
location: null,
|
|
attributes: {},
|
|
evidence,
|
|
},
|
|
{
|
|
id: "nested-view",
|
|
kind: "view" as const,
|
|
name: "Nested container",
|
|
location: null,
|
|
attributes: {},
|
|
evidence,
|
|
},
|
|
],
|
|
edges: [
|
|
...graph.edges,
|
|
{
|
|
id: "controller-view",
|
|
from: "controller",
|
|
to: "view",
|
|
relation: "contains" as const,
|
|
resolution: "observed" as const,
|
|
evidence,
|
|
limitations: [],
|
|
},
|
|
{
|
|
id: "view-nested-view",
|
|
from: "view",
|
|
to: "nested-view",
|
|
relation: "contains" as const,
|
|
resolution: "observed" as const,
|
|
evidence,
|
|
limitations: [],
|
|
},
|
|
],
|
|
};
|
|
const result = await new EnhancedTools(testAnalysis({}, deepGraph)).execute(
|
|
"trace_native_ui_action",
|
|
{ action: "buildTapped:", max_depth: 3 },
|
|
);
|
|
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) return;
|
|
expect(
|
|
nativeInvestigationTraceSchema
|
|
.parse(result.value)
|
|
.nodes.map(({ id }) => id),
|
|
).toContain("native:function:0x2000");
|
|
});
|
|
|
|
it("returns a typed input error for malformed direct metadata requests", async () => {
|
|
const result = await new EnhancedTools(testAnalysis()).execute(
|
|
"inspect_native_dispatch_metadata",
|
|
{ max_records: 0 },
|
|
);
|
|
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
error: { _tag: "AnalysisInputError" },
|
|
});
|
|
});
|
|
|
|
it("binds dispatch metadata to the active target digest", async () => {
|
|
const result = await new EnhancedTools(testAnalysis()).execute(
|
|
"inspect_native_dispatch_metadata",
|
|
{ max_records: 10 },
|
|
);
|
|
|
|
expect(result).toMatchObject({
|
|
ok: true,
|
|
value: { target_sha256: target },
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("native UI query outcomes", () => {
|
|
it("resolves a control object ID through its authored action edge", async () => {
|
|
const tools = new EnhancedTools(testAnalysis());
|
|
const result = await tools.execute("trace_native_ui_action", {
|
|
action: "button",
|
|
});
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) return;
|
|
expect(result.value).toMatchObject({ start: "action" });
|
|
});
|
|
|
|
it("rejects UI and native observations with different target identities", async () => {
|
|
const tools = new EnhancedTools(
|
|
testAnalysis({
|
|
list_names: () => execution("list_names", [], "c".repeat(64)),
|
|
}),
|
|
);
|
|
const result = await tools.execute("trace_native_ui_action", {
|
|
action: "buildTapped:",
|
|
});
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
_tag: "AnalysisOutputError",
|
|
operation: "decode_interface_builder",
|
|
},
|
|
});
|
|
});
|
|
|
|
it("returns candidates and a reason for an ambiguous selector", async () => {
|
|
const duplicateGraph = {
|
|
...graph,
|
|
nodes: [
|
|
...graph.nodes,
|
|
{
|
|
...graph.nodes[1],
|
|
id: "second-action",
|
|
},
|
|
],
|
|
};
|
|
const tools = new EnhancedTools(
|
|
testAnalysis({
|
|
decode_interface_builder: () =>
|
|
execution("decode_interface_builder", {
|
|
target_sha256: target,
|
|
documents: [],
|
|
graph: duplicateGraph,
|
|
limitations: [],
|
|
}),
|
|
}),
|
|
);
|
|
const result = await tools.execute("trace_native_ui_action", {
|
|
action: "buildTapped:",
|
|
});
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) return;
|
|
expect(result.value).toMatchObject({
|
|
reason: "action_selector_matches_multiple_ui_connections",
|
|
nodes: [{ id: "action" }, { id: "second-action" }],
|
|
});
|
|
});
|
|
|
|
it("retains decoder truncation coverage when the action is absent", async () => {
|
|
const boundedGraph = {
|
|
...graph,
|
|
coverage: [
|
|
{
|
|
facet: "objects:Main.nib",
|
|
status: "partial" as const,
|
|
reason: "object_limit_reached",
|
|
examined: 2,
|
|
omitted: 1,
|
|
},
|
|
],
|
|
truncated: true,
|
|
};
|
|
const result = await new EnhancedTools(
|
|
testAnalysis({}, boundedGraph),
|
|
).execute("trace_native_ui_action", { action: "missing-selector" });
|
|
|
|
expect(result).toMatchObject({ ok: true, value: { truncated: true } });
|
|
if (!result.ok) return;
|
|
expect(result.value).toHaveProperty("coverage");
|
|
expect(result.value).toMatchObject({
|
|
coverage: expect.arrayContaining([
|
|
expect.objectContaining({
|
|
facet: "objects:Main.nib",
|
|
status: "partial",
|
|
reason: "object_limit_reached",
|
|
}),
|
|
]),
|
|
});
|
|
});
|
|
|
|
it("returns cancellation instead of a successful trace during callee expansion", async () => {
|
|
const result = await new EnhancedTools(
|
|
testAnalysis({
|
|
procedure_callees: () =>
|
|
err(new AnalysisCancelledError("procedure_callees")),
|
|
}),
|
|
).execute("trace_native_ui_action", { action: "buildTapped:" });
|
|
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
error: { _tag: "AnalysisCancelledError", operation: "procedure_callees" },
|
|
});
|
|
});
|
|
|
|
it("reports only callees left unprocessed by the edge limit", async () => {
|
|
const result = await new EnhancedTools(
|
|
testAnalysis({
|
|
procedure_callees: (parameters) =>
|
|
execution(
|
|
"procedure_callees",
|
|
parameters.procedure === "0x1000"
|
|
? ["0x2000", "0x2001", "0x2002"]
|
|
: [],
|
|
),
|
|
}),
|
|
).execute("trace_native_ui_action", {
|
|
action: "buildTapped:",
|
|
max_depth: 4,
|
|
max_edges: 5,
|
|
});
|
|
|
|
expect(result).toMatchObject({ ok: true });
|
|
if (!result.ok) return;
|
|
expect(result.value).toMatchObject({
|
|
coverage: expect.arrayContaining([
|
|
expect.objectContaining({
|
|
facet: "static_native_calls",
|
|
status: "partial",
|
|
omitted: 1,
|
|
}),
|
|
]),
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("native UI trace boundaries", () => {
|
|
it("retains unresolved dispatch evidence during callee expansion", async () => {
|
|
const result = await new EnhancedTools(
|
|
testAnalysis({ list_names: () => execution("list_names", []) }),
|
|
).execute("trace_native_ui_action", { action: "buildTapped:" });
|
|
|
|
expect(result).toMatchObject({ ok: true });
|
|
if (!result.ok) return;
|
|
expect(result.value).toMatchObject({
|
|
unresolved: [
|
|
expect.objectContaining({
|
|
relation: "objc_dispatch",
|
|
resolution: "unresolved",
|
|
}),
|
|
],
|
|
});
|
|
});
|
|
|
|
it("returns cancellation when archive decoding is cancelled", async () => {
|
|
const result = await new EnhancedTools(
|
|
testAnalysis({
|
|
decode_interface_builder: () =>
|
|
err(new AnalysisCancelledError("decode_interface_builder")),
|
|
}),
|
|
).execute("trace_native_ui_action", { action: "buildTapped:" });
|
|
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
_tag: "AnalysisCancelledError",
|
|
operation: "decode_interface_builder",
|
|
},
|
|
});
|
|
});
|
|
|
|
it("matches handlers after the dispatch inventory's former default limit", async () => {
|
|
const names = Array.from({ length: 5_000 }, (_, index) => ({
|
|
address: `0x${(index + 1).toString(16)}`,
|
|
name: `unrelated_symbol_${index}`,
|
|
}));
|
|
names.push({
|
|
address: "0x2000",
|
|
name: "-[BuildViewController buildTapped:]",
|
|
});
|
|
const result = await new EnhancedTools(
|
|
testAnalysis({ list_names: () => execution("list_names", names) }),
|
|
).execute("trace_native_ui_action", { action: "buildTapped:" });
|
|
|
|
expect(result).toMatchObject({ ok: true });
|
|
if (!result.ok) return;
|
|
expect(result.value).toMatchObject({
|
|
nodes: expect.arrayContaining([
|
|
expect.objectContaining({ id: "native:function:0x2000" }),
|
|
]),
|
|
});
|
|
});
|
|
|
|
it("counts edges removed with nodes in bounded graph coverage", async () => {
|
|
const result = await new EnhancedTools(testAnalysis()).execute(
|
|
"trace_native_ui_action",
|
|
{ action: "missing-selector", max_nodes: 1 },
|
|
);
|
|
|
|
expect(result).toMatchObject({ ok: true });
|
|
if (!result.ok) return;
|
|
expect(result.value).toMatchObject({
|
|
coverage: expect.arrayContaining([
|
|
expect.objectContaining({
|
|
facet: "interface_builder_graph",
|
|
status: "partial",
|
|
omitted: 8,
|
|
}),
|
|
]),
|
|
});
|
|
});
|
|
});
|