fix(cli): prefer native byte inspection for automatic dispatch metadata

Allow the composed one-shot dispatch workflow to open a covered Mach-O
without selecting a deep provider. The registry still owns selector
precedence; explicit request/environment choices remain binding, while
non-Mach-O and unsupported hosts retain deep symbol fallback. Requested
snapshots keep ordinary binding and profile semantics.

Share runtime composition and target admission with the native byte reader.
Keep target parsing, resource ownership and Evidence on the original session.

Validation: 13 focused files, 192 passed and 2 skipped; npm run check passed.
The six previously blocked native property CLI controls now pass. Ten measured
CLI workflows preserved exact metadata facts; warm raw reply token medians
1344/927, elapsed .475/.547s, CPU .691/.745s, sampled family RSS351.2/359.3MiB
baseline/candidate. Baseline used explicit Ghidra workaround; true cold and
full-agent costs unmeasured. No speed/memory improvement or Windows claim.

Refs #4555
This commit is contained in:
morluto committed 2026-10-17 03:11:32 +08:00
1 parent a70c42e03d
commit 060587b456
16 files changed
+234 -6

No files matched your search

+5 -1
View File
@@ -248,7 +248,11 @@ const runAnalysis = async (
} else {
const config = dependencies.readConfiguration();
if (!config.ok) return cliError(config.error);
session = await dependencies.createBinarySession(config.value, logger);
session = await (
tool === "inspect_native_dispatch_metadata" && snapshotPath === undefined
? dependencies.createNativeDispatchBinarySession
: dependencies.createBinarySession
)(config.value, logger);
}
return withSessionCleanup(
session,
@@ -11,6 +11,11 @@ export interface DirectAnalysisDependencies {
config: AppConfig,
logger: Logger,
) => BinarySession | Promise<BinarySession>;
/** Prefer byte-backed dispatch inspection while preserving explicit selection. */
readonly createNativeDispatchBinarySession: (
config: AppConfig,
logger: Logger,
) => BinarySession | Promise<BinarySession>;
/** Create an artifact-only session without selecting native analysis engines. */
readonly createArtifactBinarySession: () =>
| BinarySession
@@ -145,6 +145,88 @@ it("selects the sole available provider and honors explicit selection precedence
});
});
describe("inspection without deep binding", () => {
it("retains discovery facts without resolving a deep profile for covered automatic inspection", async () => {
const alpha = candidate("alpha", { profile: "error" });
const beta = candidate("beta", { profile: "error" });
const registry = new AnalysisProviderRegistry(
[alpha.provider, beta.provider],
"auto",
(target) => target === DATABASE_TARGET,
);
expect(await registry.select(DATABASE_TARGET)).toMatchObject({
ok: true,
value: {
requestedProviderId: "auto",
binding: null,
candidates: [
{
provider: { id: "alpha" },
selected: false,
availability: { status: "available" },
},
{
provider: { id: "beta" },
selected: false,
availability: { status: "available" },
},
],
},
});
expect(alpha.created).toEqual([]);
expect(beta.created).toEqual([]);
});
it("keeps explicit request and environment choices even when auxiliary inspection covers the target", async () => {
const alpha = candidate("alpha");
const beta = candidate("beta");
const registry = new AnalysisProviderRegistry(
[alpha.provider, beta.provider],
"beta",
() => true,
);
expect(await registry.select(DATABASE_TARGET)).toMatchObject({
ok: true,
value: {
binding: { identity: { id: "beta" }, selectionSource: "environment" },
},
});
expect(await registry.select(DATABASE_TARGET, "alpha")).toMatchObject({
ok: true,
value: {
binding: { identity: { id: "alpha" }, selectionSource: "request" },
},
});
expect(await registry.select(DATABASE_TARGET, "auto")).toMatchObject({
ok: true,
value: { binding: null },
});
expect(await registry.select(DATABASE_TARGET, "missing")).toMatchObject({
ok: false,
error: { reason: "unknown_provider" },
});
});
it("preserves ordinary ambiguity when auxiliary inspection does not cover the target", async () => {
const registry = new AnalysisProviderRegistry(
[candidate("alpha").provider, candidate("beta").provider],
"auto",
() => false,
);
expect(await registry.select(DATABASE_TARGET)).toMatchObject({
ok: false,
error: { reason: "ambiguous" },
});
const controller = new AbortController();
controller.abort();
expect(
await registry.select(DATABASE_TARGET, undefined, {
signal: controller.signal,
}),
).toMatchObject({ ok: false, error: { _tag: "AnalysisCancelledError" } });
});
});
describe("analysis provider registry: selection failures", () => {
it("returns actionable unknown, unavailable, and unsupported failures", async () => {
const unavailable = candidate("alpha", { available: false });
@@ -83,6 +83,9 @@ export class AnalysisProviderRegistry {
constructor(
providers: readonly AnalysisProviderCandidate[],
defaultSelector: AnalysisProviderSelector = "auto",
private readonly canInspectWithoutDeepProvider?: (
target: BinaryTarget,
) => boolean,
) {
this.#defaultSelector =
analysisProviderSelectorSchema.parse(defaultSelector);
@@ -209,6 +212,12 @@ export class AnalysisProviderRegistry {
const baseline = this.candidates(target);
if (signalIsAborted(options.signal))
return err(new AnalysisCancelledError("open_binary"));
if (selector === "auto" && this.canInspectWithoutDeepProvider?.(target))
return ok({
requestedProviderId: selector,
binding: null,
candidates: baseline,
});
if (
request === undefined &&
this.#byId.has(selector) &&
+33 -1
View File
@@ -1,3 +1,4 @@
import { isAppleDispatchMetadataTarget } from "../domain/native/objcSwiftMetadata.js";
import { snapshotEnvironment } from "../process/snapshotEnvironment.js";
import type { AppConfig } from "../config/types.js";
import type { BinarySession } from "../application/binary/BinarySession.js";
@@ -21,18 +22,49 @@ export const createBinarySession = (
config: AppConfig,
logger: Logger,
selectedEnvironment: Readonly<NodeJS.ProcessEnv>,
): BinarySession =>
composeDeepBinarySession(config, logger, selectedEnvironment);
/** Prefer byte-backed dispatch inspection while retaining deep symbol fallback. */
export const createNativeDispatchBinarySession = (
config: AppConfig,
logger: Logger,
selectedEnvironment: Readonly<NodeJS.ProcessEnv>,
): BinarySession =>
composeDeepBinarySession(
config,
logger,
selectedEnvironment,
"inspect_native_dispatch_metadata",
);
const composeDeepBinarySession = (
config: AppConfig,
logger: Logger,
selectedEnvironment: Readonly<NodeJS.ProcessEnv>,
inspection?: "inspect_native_dispatch_metadata",
): BinarySession => {
const environment = snapshotEnvironment(selectedEnvironment);
const hopper = new HopperProvider(config, logger, environment);
const ghidra = new GhidraProvider(config, logger, environment);
const ida = new IdaProvider(config, environment);
const kuna = new KunaProvider(environment);
const auxiliaryDeclarations =
auxiliaryAnalysisProviderDeclarations(environment);
const nativeInspectionAvailable =
inspection !== undefined &&
auxiliaryDeclarations.some(({ capabilities }) =>
capabilities.some(
({ operation, available }) => operation === inspection && available,
),
);
return composeBinarySession(
new AnalysisProviderRegistry(
[hopper, ghidra, ida, kuna],
config.analysisProvider,
nativeInspectionAvailable ? isAppleDispatchMetadataTarget : undefined,
),
auxiliaryAnalysisProviderDeclarations(environment).map(
auxiliaryDeclarations.map(
(declaration) => new LazyAnalysisProvider(declaration),
),
);
+4
View File
@@ -28,6 +28,10 @@ export const createDirectAnalysis = (
const { createManagedBinarySession } = await import("./binary.js");
return createManagedBinarySession();
},
createNativeDispatchBinarySession: async (config, logger) => {
const { createNativeDispatchBinarySession } = await import("./binary.js");
return createNativeDispatchBinarySession(config, logger, environment);
},
createNativeBinarySession: async () => {
const { createNativeBinarySession } = await import("./binary.js");
return createNativeBinarySession(environment);
+5
View File
@@ -1,4 +1,5 @@
import { z } from "zod";
import { isMachOFormat, type BinaryTarget } from "../binaryTargetTypes.js";
import { providerSchema } from "../evidence.js";
import { isSwiftMangledSymbolName } from "../swiftSymbolClassification.js";
@@ -7,6 +8,10 @@ import {
compositeKey,
} from "../unicodeCodePointOrder.js";
/** Targets admitted to the Apple byte reader; the reader validates slice support. */
export const isAppleDispatchMetadataTarget = (target: BinaryTarget): boolean =>
target.kind === "executable" && isMachOFormat(target.format);
/** Objective-C method type. */
export const objcMethodTypeSchema = z.enum([
"instance",
+2 -4
View File
@@ -28,6 +28,7 @@ import {
isMachOFormat,
type BinaryTarget,
} from "../domain/binaryTargetTypes.js";
import { isAppleDispatchMetadataTarget } from "../domain/native/objcSwiftMetadata.js";
import { toolContract } from "../contracts/toolContracts.js";
import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js";
import type { EvidenceLocation } from "../domain/evidence.js";
@@ -186,10 +187,7 @@ class NativeMacOSClient implements AnalysisClient {
},
),
);
if (
this.target.kind !== "executable" ||
!isMachOFormat(this.target.format)
)
if (!isAppleDispatchMetadataTarget(this.target))
return err(
new AnalysisCapabilityUnavailableError(
IDENTITY.id,
@@ -112,3 +112,53 @@ describe.skipIf(process.platform !== "darwin")(
);
},
);
describe.skipIf(process.platform !== "darwin")(
"native dispatch provider choice",
() => {
cliTest.for([
[[], "missing", 1, "unknown_provider"],
[["--provider", "missing"], "auto", 1, "unknown_provider"],
[["--provider", "auto"], "missing", 0, null],
] as const)(
"preserves explicit selection %j",
async ([options, configured, exitCode, reason], { cli }) => {
const directory = await createTestTempDirectory(
"rea-dispatch-provider-choice-",
);
const path = join(directory, "fixture.bin");
await writeFile(path, machoWithPropertyAttributes("Tq,N"));
const output = await cli.run({
arguments: [
"inspect-native-dispatch-metadata",
path,
...options,
"--json",
],
environment: {
REA_LOG_LEVEL: "silent",
REA_ANALYSIS_PROVIDER: configured,
},
});
expect(output.exitCode, `${output.stdout}\n${output.stderr}`).toBe(
exitCode,
);
if (reason !== null)
expect(output.json).toMatchObject({
code: "capability_unavailable",
details: {
selection_reason: reason,
requested_provider_id: "missing",
},
});
else
expect(output.json).toMatchObject({
normalized_result: {
provider: { id: "native-macos" },
result: { objc_classes: [{ name: "Fixture" }] },
},
});
},
);
},
);
@@ -246,6 +246,8 @@ const createSnapshotReplayFixture = async (
readConfiguration: () => parseConfig({}),
createBinarySession: () => createTestBinarySession(provider),
createArtifactBinarySession: () => createTestBinarySession(provider),
createNativeDispatchBinarySession: () =>
createTestBinarySession(provider),
createNativeBinarySession: () => createTestBinarySession(provider),
createManagedBinarySession: () => createTestBinarySession(provider),
};
@@ -1003,6 +1005,7 @@ describe("workflow snapshot profile and cancellation binding", () => {
readConfiguration: () => parseConfig({}),
createBinarySession: () => session,
createArtifactBinarySession: () => session,
createNativeDispatchBinarySession: () => session,
createNativeBinarySession: () => session,
createManagedBinarySession: () => session,
};
@@ -71,6 +71,7 @@ describe("official operation arguments", () => {
readConfiguration: () => parseConfig({}),
createBinarySession: createSession,
createArtifactBinarySession: createSession,
createNativeDispatchBinarySession: createSession,
createNativeBinarySession: createSession,
createManagedBinarySession: createSession,
};
@@ -331,6 +331,7 @@ describe("MCP composed workflow snapshot replay", () => {
readConfiguration: () => parseConfig({}),
createBinarySession: () => createWorkflowSession(provider),
createArtifactBinarySession: () => createWorkflowSession(provider),
createNativeDispatchBinarySession: () => createWorkflowSession(provider),
createNativeBinarySession: () => createWorkflowSession(provider),
createManagedBinarySession: () => createWorkflowSession(provider),
};
@@ -62,6 +62,7 @@ const factories = (
readConfiguration: () => parseConfig({}),
createBinarySession: () => session,
createArtifactBinarySession: () => session,
createNativeDispatchBinarySession: () => session,
createNativeBinarySession: () => session,
createManagedBinarySession: () => session,
};
@@ -37,6 +37,7 @@ const recordingFactories = (failure?: Error) => {
readConfiguration: () => parseConfig({}),
createBinarySession: createSession,
createArtifactBinarySession: createSession,
createNativeDispatchBinarySession: createSession,
createNativeBinarySession: createSession,
createManagedBinarySession: createSession,
};
@@ -104,6 +105,36 @@ describe("one-shot analysis factory boundary", () => {
expect(factories.sessions[0]?.activeTarget()).toBeUndefined();
});
it.each([undefined, "/fixture/committed.snapshot"])(
"selects the dispatch inspection factory only without a requested snapshot (%s)",
async (snapshotPath) => {
const factories = recordingFactories();
const expected = new Error("selected factory");
const unexpected = (): never => {
throw new Error("wrong session policy");
};
const selected = (): never => {
throw expected;
};
await expect(
runDirectAnalysis(
{
...factories.dependencies,
createBinarySession:
snapshotPath === undefined ? unexpected : selected,
createNativeDispatchBinarySession:
snapshotPath === undefined ? selected : unexpected,
},
"/fixture/target",
"inspect_native_dispatch_metadata",
{},
{ snapshotPath },
),
).rejects.toBe(expected);
expect(factories.sessions).toEqual([]);
},
);
it.each([
"inspect_macho",
"inspect_signature",
@@ -36,6 +36,7 @@ it("retains provider coverage caveats in a composed CLI result and closes the se
createBinarySession: createSession,
createManagedBinarySession: createSession,
createArtifactBinarySession: createSession,
createNativeDispatchBinarySession: createSession,
createNativeBinarySession: createSession,
},
path,
@@ -83,6 +83,7 @@ const fixture = (
readConfiguration: () => parseConfig({}),
createBinarySession: createSession,
createArtifactBinarySession: createSession,
createNativeDispatchBinarySession: createSession,
createNativeBinarySession: createSession,
createManagedBinarySession: createSession,
};