fix(analysis): preserve input diagnostics and reuse JEB contracts

This commit is contained in:
morluto committed 2026-10-19 18:45:48 +08:00
1 parent 64abd4aefb
commit a0edd145d3
5 files changed
+229 -39

No files matched your search

@@ -0,0 +1,89 @@
import { expect, it, vi } from "vitest";
import { z } from "zod";
import { AnalysisInputError } from "../domain/analysisErrorCore.js";
import { executeProviderOperation } from "./ProviderOperationExecution.js";
it("projects request-schema issues without exposing the request envelope", async () => {
const execute = vi.fn();
const result = await executeProviderOperation(
"list_jeb_units",
{ parent_unit_path: "" },
undefined,
z.strictObject({
operation: z.literal("list_jeb_units"),
input: z.strictObject({ parent_unit_path: z.string().min(1) }),
}),
{ execute },
"observed",
);
expect(result.ok).toBe(false);
if (result.ok || !(result.error instanceof AnalysisInputError))
throw new Error("Expected input error");
expect(result.error.issues).toEqual([
{
path: ["parent_unit_path"],
reason: "out_of_range",
minimum: 1,
},
]);
expect(result.error.cause).toBeInstanceOf(z.ZodError);
expect(execute).not.toHaveBeenCalled();
});
it("removes only the owned envelope prefix while preserving nested caller paths", async () => {
const execute = vi.fn();
const result = await executeProviderOperation(
"inspect",
{ input: "", rows: [{}], mode: "foreign", extra: "caller-only-marker" },
undefined,
z.strictObject({
operation: z.literal("inspect"),
input: z.strictObject({
input: z.string().min(1),
rows: z.array(z.strictObject({ name: z.string() })),
mode: z.enum(["first", "second"]),
}),
}),
{ execute },
"observed",
);
if (result.ok || !(result.error instanceof AnalysisInputError))
throw new Error("Expected input error");
expect(result.error.issues).toEqual([
{ path: ["input"], reason: "out_of_range", minimum: 1 },
{
path: ["rows", 0, "name"],
reason: "missing_argument",
expected: "string",
},
{ path: ["mode"], reason: "invalid_value", expected: ["first", "second"] },
{ path: ["extra"], reason: "unknown_argument" },
]);
expect(JSON.stringify(result.error.issues)).not.toContain(
"caller-only-marker",
);
expect(execute).not.toHaveBeenCalled();
});
it("preserves operation correction outside the input envelope", async () => {
const execute = vi.fn();
const result = await executeProviderOperation(
"unknown",
{},
undefined,
z.strictObject({
operation: z.literal("known"),
input: z.strictObject({}),
}),
{ execute },
"observed",
);
if (result.ok || !(result.error instanceof AnalysisInputError))
throw new Error("Expected input error");
expect(result.error.issues).toEqual([
{ path: ["operation"], reason: "invalid_value", expected: ["known"] },
]);
expect(execute).not.toHaveBeenCalled();
});
+13 -1
View File
@@ -8,6 +8,7 @@ import {
AnalysisInputError,
} from "../domain/analysisErrorCore.js";
import type { Evidence } from "../domain/evidence.js";
import { projectInputIssues } from "../domain/inputIssueProjection.js";
import { err, ok, type Result } from "../domain/result.js";
import {
createAnalysisExecutionEvidence,
@@ -52,7 +53,18 @@ export const executeProviderOperation = async <
);
const request = schema.safeParse({ operation, input });
if (!request.success)
return err(new AnalysisInputError(operation, { cause: request.error }));
return err(
new AnalysisInputError(
operation,
{ cause: request.error },
projectInputIssues(request.error.issues, { operation, input }).map(
(issue) =>
issue.path[0] === "input"
? { ...issue, path: issue.path.slice(1) }
: issue,
),
),
);
const executed = await provider.execute(request.data, options);
if (!executed.ok) return executed;
const evidence = createAnalysisExecutionEvidence(
@@ -52,10 +52,17 @@ describe("JebAnalysisService admission", () => {
const result = await service.execute("list_jeb_units", {
count: 101,
index: -1,
parent_unit_path: "",
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error).toBeInstanceOf(AnalysisInputError);
if (!(result.error instanceof AnalysisInputError)) throw result.error;
expect(result.error.issues).toContainEqual({
path: ["parent_unit_path"],
reason: "out_of_range",
minimum: 1,
});
expect(requests).toHaveLength(0);
});
+9 -38
View File
@@ -9,6 +9,7 @@ import type { Logger } from "pino";
import type { CliInstance } from "./types.js";
import { withCommandCancellation } from "./commandCancellation.js";
import { finishAnalysisCommand } from "./finishAnalysisCommand.js";
import { jebInputSchemas } from "../domain/jeb/jebAnalysis.js";
/** Expose the same explicit JEB requests through one-shot CLI commands. */
export const registerJebCommands = (
@@ -40,9 +41,7 @@ export const registerJebCommands = (
description:
"Open or create a JEB project from an artifact or .jdb2 database",
args: z.object({
path: z
.string()
.describe("Artifact or .jdb2 path resolved by the JEB engine host"),
path: jebInputSchemas.open_jeb_project.shape.path,
}),
run: ({ args }) =>
execute(CLI_COMMANDS.openJebProject, "open_jeb_project", args),
@@ -51,27 +50,10 @@ export const registerJebCommands = (
description: "List JEB project units by path and type with optional filter",
args: z.object({}),
options: z.object({
filter: z
.string()
.optional()
.describe("Wildcard filter matched against unit paths"),
"parent-unit-path": z
.string()
.optional()
.describe("Restrict results to descendants of this unit path"),
index: z
.number()
.int()
.nonnegative()
.default(0)
.describe("Zero-based index of the first result to return"),
count: z
.number()
.int()
.min(1)
.max(100)
.default(100)
.describe("Maximum results; the engine caps unit pages at 100"),
filter: jebInputSchemas.list_jeb_units.shape.filter,
"parent-unit-path": jebInputSchemas.list_jeb_units.shape.parent_unit_path,
index: jebInputSchemas.list_jeb_units.shape.index,
count: jebInputSchemas.list_jeb_units.shape.count,
}),
run: ({ options }) =>
execute(CLI_COMMANDS.listJebUnits, "list_jeb_units", {
@@ -86,22 +68,11 @@ export const registerJebCommands = (
cli.command(CLI_COMMANDS.decompileJebItem, {
description: "Decompile one JEB type or method to engine pseudo-code",
args: z.object({
"item-address": z
.string()
.min(1)
.describe("Type or method address within the unit"),
"item-kind": z
.enum(["type", "method"])
.describe("Kind of item to decompile"),
"item-address": jebInputSchemas.decompile_jeb_item.shape.item_address,
"item-kind": jebInputSchemas.decompile_jeb_item.shape.item_kind,
}),
options: z.object({
"unit-path": z
.string()
.min(1)
.optional()
.describe(
"Target code unit; the engine's first code unit when omitted",
),
"unit-path": jebInputSchemas.decompile_jeb_item.shape.unit_path,
}),
run: ({ args, options }) =>
execute(CLI_COMMANDS.decompileJebItem, "decompile_jeb_item", {
@@ -0,0 +1,111 @@
import { Cli } from "incur";
import { Ajv2020 } from "ajv/dist/2020.js";
import { expect, it } from "vitest";
import type { JebAnalysisPort } from "../../../src/application/jeb/JebAnalysisPort.js";
import { createAnalysisExecution } from "../../../src/application/AnalysisProvider.js";
import { registerJebCommands } from "../../../src/cli/jebCommands.js";
import { silentLogger } from "../../../src/logger.js";
import { ok } from "../../../src/domain/result.js";
import type { JebRequest } from "../../../src/domain/jeb/jebAnalysis.js";
import { jebResultSchemas } from "../../../src/domain/jeb/jebAnalysis.js";
const invoke = async (argv: readonly string[]) => {
const requests: JebRequest[] = [];
const provider: JebAnalysisPort = {
async inspectAvailability() {
throw new Error("unexpected provider probe");
},
async close() {},
async execute(request) {
requests.push(request);
return ok(
createAnalysisExecution(
jebResultSchemas.list_jeb_units.parse({
engine: {
name: "JEB test provider",
version: null,
endpoint: "http://127.0.0.1:1/mcp",
gui_client: null,
},
filter: null,
parent_unit_path:
request.operation === "list_jeb_units"
? (request.input.parent_unit_path ?? null)
: null,
index: 0,
count: 100,
units: [],
coverage: "complete",
}),
{ id: "jeb-test", name: "JEB test provider", version: null },
),
);
},
};
const cli = Cli.create("rea-jeb-input-test", { sync: false });
registerJebCommands(cli, silentLogger, {}, provider);
let stdout = "";
let exitCode = 0;
await cli.serve([...argv], {
env: {},
exit: (code) => {
exitCode = code;
},
stdout: (text) => {
stdout += text;
},
});
return {
exitCode,
output: JSON.parse(stdout) as Record<string, unknown>,
requests,
};
};
it("keeps JEB CLI correction details aligned with canonical list-unit validation", async () => {
const invalid = await invoke([
"list-jeb-units",
"--parent-unit-path",
"",
"--json",
]);
expect(invalid.output).toMatchObject({
code: "VALIDATION_ERROR",
fieldErrors: [{ path: "parent-unit-path", code: "too_small" }],
});
expect(invalid.requests).toEqual([]);
const valid = await invoke([
"list-jeb-units",
"--parent-unit-path",
"sample.dex",
"--json",
]);
expect(valid.output).not.toHaveProperty("code");
expect(valid.requests).toMatchObject([
{ input: { parent_unit_path: "sample.dex" } },
]);
});
it("advertises the canonical minimum for a JEB parent unit path", async () => {
const { output } = await invoke(["list-jeb-units", "--schema", "--json"]);
const schema = output.options as Parameters<Ajv2020["compile"]>[0];
const validate = new Ajv2020({ strict: false }).compile(schema);
expect(validate({ index: 0, count: 100 })).toBe(true);
expect(validate({ index: 0, count: 100, "parent-unit-path": "" })).toBe(
false,
);
expect(
validate({ index: 0, count: 100, "parent-unit-path": "sample.dex" }),
).toBe(true);
});
it("advertises the canonical nonempty, NUL-free JEB project path", async () => {
const { output } = await invoke(["open-jeb-project", "--schema", "--json"]);
const schema = output.args as Parameters<Ajv2020["compile"]>[0];
const validate = new Ajv2020({ strict: false }).compile(schema);
expect(validate({ path: "sample.apk" })).toBe(true);
expect(validate({ path: "" })).toBe(false);
expect(validate({ path: "sample\u0000.apk" })).toBe(false);
});