fix(cli): accept exact native names through explicit selectors

This commit is contained in:
morluto 2026-10-09 05:45:40 +08:00
parent 2a4979b7cd
commit c3136064c7
7 changed files with 317 additions and 12 deletions

View File

@ -83,6 +83,18 @@ complete observation.
partial coverage. Other providers retain the
existing symbol-based inventory with its narrower coverage.
## Exact CLI selectors
`function`, `instructions` and `decompile` accept `--procedure=<name-or-address>`;
`xrefs` accepts `--address=<name-or-address>`. `search` accepts `--pattern=<text>`
and `trace` accepts `--query=<text>`. These alternatives preserve names and text
that begin with a dash, including Objective-C names such as
`rea function ./app '--procedure=-[REAWidget delegate]' --provider hopper --format json`.
Use the equals form when the value resembles a CLI flag, for example
`rea trace ./app --query=--help --provider hopper --format json`.
The existing positional forms remain available. Missing selectors and conflicting
positional/named values fail before analysis starts; identical selections agree.
## Native instruction, call and type primitives
Ghidra supplies three exact-object operations:

View File

@ -214,6 +214,9 @@ Objective-C fixture is present, the lane also verifies native UTF-16 string obje
and their inferred encodings against their actual bytes. Native terminal calls
are checked across reference, instruction, assembly, block-range and procedure-length
projections; block endpoints are normalized using actual native membership.
Exact Objective-C names also exercise named CLI selectors for function, instruction,
decompilation, reference and search operations. A literal `--help` trace query proves
that selector data is preserved independently of global CLI flags.
Unmapped annotation destinations and
oversized later batch names fail before any earlier edit is applied.
The Linux demo lane remains a separate `verify:hopper:linux` command.

View File

@ -7,6 +7,7 @@ import { promisify } from "node:util";
import Ajv from "ajv/dist/2020.js";
import addFormats from "ajv-formats";
import { mcpTextValue, requireMcpResult } from "./mcp-verifier-results.mjs";
import { verifyHopperCliSelectors } from "./real-hopper-cli-selectors.mjs";
import { verifyHopperNavigationAndText } from "./real-hopper-navigation.mjs";
import { verifyHopperWorkflows } from "./real-hopper-workflows.mjs";
import {
@ -395,6 +396,7 @@ export async function verifyHopperLifecycleAndCli(client, options, targets) {
expectedFunction,
);
let cliTerminalFunctionParity = null;
let cliNamedSelectorParity = null;
if (targets.unicode !== undefined) {
const objc = join(directory, `objc-${suffix}`);
await copyFile(targets.unicode, objc);
@ -420,6 +422,13 @@ export async function verifyHopperLifecycleAndCli(client, options, targets) {
]);
assert.deepEqual(JSON.parse(analyzed.stdout).normalized_result, expected);
cliTerminalFunctionParity = true;
await call("open_binary", { path: objc });
cliNamedSelectorParity = await verifyHopperCliSelectors(
call,
runCli,
dispatcher,
objc,
);
}
let failure;
try {
@ -452,6 +461,7 @@ export async function verifyHopperLifecycleAndCli(client, options, targets) {
cliLiteralTraceParity: true,
cliFunctionDossierParity: true,
cliTerminalFunctionParity,
cliNamedSelectorParity,
callerCancellationRecovered: true,
closedDocumentAbsent: true,
};

View File

@ -0,0 +1,88 @@
import assert from "node:assert/strict";
/** Compare exact native symbol and literal selectors through MCP and the real CLI. */
export async function verifyHopperCliSelectors(call, runCli, dispatcher, path) {
const procedures = await call("list_procedures");
const delegate = procedures.find(
(item) => item.value === "-[REAWidget delegate]",
);
assert.ok(delegate, "Objective-C fixture omitted its tail-call method");
const cases = [
{
command: "function",
option: "procedure",
operation: "analyze_function",
parameters: { procedure: delegate.value },
},
{
command: "instructions",
option: "procedure",
operation: "read_function_instructions",
parameters: { procedure: delegate.value },
},
{
command: "decompile",
option: "procedure",
operation: "procedure_pseudo_code",
parameters: { procedure: delegate.value },
},
{
command: "xrefs",
option: "address",
operation: "xrefs",
parameters: { address: delegate.value },
},
{
command: "search",
option: "pattern",
operation: "search_procedures",
parameters: {
pattern: delegate.value,
mode: "literal",
case_sensitive: true,
},
flags: ["--kind", "procedures", "--case-sensitive"],
},
{
command: "trace",
option: "query",
operation: "trace_feature",
parameters: { query: "--help" },
},
];
const expected = [];
for (const item of cases)
expected.push(await call(item.operation, item.parameters));
await call("close_binary");
for (let i = 0; i < cases.length; i++) {
const item = cases[i];
const selector = item.parameters[item.option];
const { stdout } = await runCli([
dispatcher,
item.command,
path,
`--${item.option}=${selector}`,
...(item.flags ?? []),
"--provider",
"hopper",
"--format",
"json",
]);
const evidence = JSON.parse(stdout);
assert.deepEqual(
evidence.normalized_result,
expected[i],
`${item.command} named selector differs from MCP`,
);
assert.equal(
evidence.parameters[item.option],
selector,
`${item.command} altered its selector`,
);
}
return {
commands: cases.map(({ command }) => command),
exactObjectiveCNames: true,
literalGlobalFlagPreserved: true,
};
}

View File

@ -0,0 +1,29 @@
import { AnalysisInputError } from "../domain/analysisErrorCore.js";
/** Preserve a CLI selector supplied positionally or through its named option. */
export const resolveCliAnalysisSelector = (
positional: string | undefined,
named: string | undefined,
option: string,
operation: string,
): string => {
if (positional !== undefined && named !== undefined && positional !== named)
throw new AnalysisInputError(operation, undefined, [
{
path: [option],
reason: "invalid_value",
message: `The positional selector and --${option} select different objects; supply one selector.`,
},
]);
const selected = named ?? positional;
if (selected === undefined)
throw new AnalysisInputError(operation, undefined, [
{
path: [option],
reason: "missing_argument",
message: `Supply a positional selector or --${option}=<value>. Use the named option for values beginning with a dash.`,
expected: "string",
},
]);
return selected;
};

View File

@ -2,6 +2,7 @@ import { z } from "incur";
import { stat } from "node:fs/promises";
import { resolve } from "node:path";
import { resolveCliAnalysisSelector } from "./analysisSelector.js";
import { runDirectAnalysis } from "../composition/directAnalysis.js";
import { CLI_COMMANDS } from "../cliCommandNames.js";
import { logCliCommand } from "../cliLogging.js";
@ -25,6 +26,7 @@ export const registerCoreBinaryCommands = (
resultOutput?: CliResultOutput,
): void => {
registerOverviewCommands(cli, logger, resultOutput);
registerDecompileCommand(cli, logger);
registerFunctionCommand(cli, logger);
registerInstructionsCommand(cli, logger);
registerSearchCommand(cli, logger);
@ -85,13 +87,27 @@ const registerOverviewCommands = (
),
),
});
};
const registerDecompileCommand = (cli: CliInstance, logger: Logger): void => {
cli.command(CLI_COMMANDS.decompile, {
description: "Read one part of an app as code",
args: z.object({
path: z.string().describe("App or program path"),
address: z.string().describe("Procedure address"),
address: z
.string()
.min(1)
.optional()
.describe("Procedure name or address"),
}),
options: z.object({
procedure: z
.string()
.min(1)
.optional()
.describe(
"Procedure name or address; use --procedure=<value> for leading dashes",
),
snapshot: z
.string()
.min(1)
@ -105,7 +121,14 @@ const registerOverviewCommands = (
runDirectAnalysis(
args.path,
"procedure_pseudo_code",
{ procedure: args.address },
{
procedure: resolveCliAnalysisSelector(
args.address,
options.procedure,
"procedure",
"procedure_pseudo_code",
),
},
directAnalysisOptions(
logger,
options.snapshot,
@ -169,9 +192,16 @@ const registerXrefsCommand = (cli: CliInstance, logger: Logger): void => {
description: "List bounded references to an analyzed address",
args: z.object({
path: z.string().describe("App or program path"),
address: z.string().describe("Hexadecimal address"),
address: z.string().min(1).optional().describe("Address or symbol name"),
}),
options: z.object({
address: z
.string()
.min(1)
.optional()
.describe(
"Address or symbol name; use --address=<value> for leading dashes",
),
snapshot: z
.string()
.min(1)
@ -185,7 +215,14 @@ const registerXrefsCommand = (cli: CliInstance, logger: Logger): void => {
runDirectAnalysis(
args.path,
"xrefs",
{ address: args.address },
{
address: resolveCliAnalysisSelector(
args.address,
options.address,
"address",
"xrefs",
),
},
directAnalysisOptions(
logger,
options.snapshot,
@ -202,9 +239,16 @@ const registerTraceCommand = (cli: CliInstance, logger: Logger): void => {
description: "Trace a literal feature through analyzed references",
args: z.object({
path: z.string().describe("App or program path"),
query: z.string().min(1).describe("Literal feature query"),
query: z.string().min(1).optional().describe("Literal feature query"),
}),
options: z.object({
query: z
.string()
.min(1)
.optional()
.describe(
"Complete literal feature query; use --query=<value> for leading dashes",
),
caseSensitive: z
.boolean()
.default(false)
@ -226,7 +270,12 @@ const registerTraceCommand = (cli: CliInstance, logger: Logger): void => {
args.path,
"trace_feature",
{
query: args.query,
query: resolveCliAnalysisSelector(
args.query,
options.query,
"query",
"trace_feature",
),
case_sensitive: options.caseSensitive,
},
directAnalysisOptions(
@ -245,9 +294,20 @@ const registerFunctionCommand = (cli: CliInstance, logger: Logger): void => {
description: "Analyze one complete function with evidence",
args: z.object({
path: z.string().describe("App or program path"),
address: z.string().describe("Procedure name or address"),
address: z
.string()
.min(1)
.optional()
.describe("Procedure name or address"),
}),
options: z.object({
procedure: z
.string()
.min(1)
.optional()
.describe(
"Procedure name or address; use --procedure=<value> for leading dashes",
),
snapshot: z
.string()
.min(1)
@ -261,7 +321,14 @@ const registerFunctionCommand = (cli: CliInstance, logger: Logger): void => {
runDirectAnalysis(
args.path,
"analyze_function",
{ procedure: args.address },
{
procedure: resolveCliAnalysisSelector(
args.address,
options.procedure,
"procedure",
"analyze_function",
),
},
directAnalysisOptions(
logger,
options.snapshot,
@ -281,9 +348,20 @@ const registerInstructionsCommand = (
description: "Read every raw instruction without decompiling",
args: z.object({
path: z.string().describe("App or program path"),
address: z.string().describe("Procedure name or address"),
address: z
.string()
.min(1)
.optional()
.describe("Procedure name or address"),
}),
options: z.object({
procedure: z
.string()
.min(1)
.optional()
.describe(
"Procedure name or address; use --procedure=<value> for leading dashes",
),
snapshot: z
.string()
.min(1)
@ -298,7 +376,12 @@ const registerInstructionsCommand = (
args.path,
"read_function_instructions",
{
procedure: args.address,
procedure: resolveCliAnalysisSelector(
args.address,
options.procedure,
"procedure",
"read_function_instructions",
),
},
directAnalysisOptions(
logger,
@ -316,9 +399,20 @@ const registerSearchCommand = (cli: CliInstance, logger: Logger): void => {
description: "Search every analyzed string or procedure name",
args: z.object({
path: z.string().describe("App or program path"),
pattern: z.string().min(1).describe("Literal text or regex pattern"),
pattern: z
.string()
.min(1)
.optional()
.describe("Literal text or regex pattern"),
}),
options: z.object({
pattern: z
.string()
.min(1)
.optional()
.describe(
"Complete literal text or regex pattern; use --pattern=<value> for leading dashes",
),
kind: z
.enum(["strings", "procedures"])
.default("strings")
@ -346,7 +440,14 @@ const registerSearchCommand = (cli: CliInstance, logger: Logger): void => {
args.path,
options.kind === "strings" ? "search_strings" : "search_procedures",
{
pattern: args.pattern,
pattern: resolveCliAnalysisSelector(
args.pattern,
options.pattern,
"pattern",
options.kind === "strings"
? "search_strings"
: "search_procedures",
),
mode: options.mode,
case_sensitive: options.caseSensitive,
},

View File

@ -0,0 +1,62 @@
import { describe, expect } from "vitest";
import { cliTest } from "../../support/cli/cliFixture.js";
const selectors = [
["function", "procedure", "analyze_function"],
["instructions", "procedure", "read_function_instructions"],
["decompile", "procedure", "procedure_pseudo_code"],
["xrefs", "address", "xrefs"],
["search", "pattern", "search_strings"],
["trace", "query", "trace_feature"],
] as const;
describe("CLI analysis selector boundaries", () => {
cliTest(
"rejects missing and conflicting selectors before opening a target",
async ({ cli }) => {
for (const [command, option, operation] of selectors) {
for (const [selection, reason] of [
[[], "missing_argument"],
[["first", `--${option}=-second`], "invalid_value"],
] as const) {
const result = await cli.run({
arguments: [
command,
"does-not-exist",
...selection,
"--format",
"json",
],
environment: { REA_LOG_LEVEL: "silent" },
});
expect(result.exitCode).not.toBe(0);
expect(result.json).toMatchObject({
code: "invalid_request",
details: { operation, issues: [{ path: [option], reason }] },
});
}
}
},
);
cliTest(
"keeps named dash values out of global-option parsing",
async ({ cli }) => {
for (const [command, option] of selectors) {
const result = await cli.run({
arguments: [
command,
"does-not-exist",
`--${option}=--help`,
"--format",
"json",
],
environment: { REA_LOG_LEVEL: "silent" },
});
expect(result.exitCode).not.toBe(0);
expect(result.json).toMatchObject({ code: "target_unavailable" });
}
},
);
});