From a0edd145d31ffeb9f022c25ec9e9131083f563d5 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:45:48 +0800 Subject: [PATCH] fix(analysis): preserve input diagnostics and reuse JEB contracts --- .../ProviderOperationExecution.test.ts | 89 ++++++++++++++ src/application/ProviderOperationExecution.ts | 14 ++- .../jeb/JebAnalysisService.test.ts | 7 ++ src/cli/jebCommands.ts | 47 ++------ .../boundary/cli/jebInputDiagnostics.test.ts | 111 ++++++++++++++++++ 5 files changed, 229 insertions(+), 39 deletions(-) create mode 100644 src/application/ProviderOperationExecution.test.ts create mode 100644 tests/boundary/cli/jebInputDiagnostics.test.ts diff --git a/src/application/ProviderOperationExecution.test.ts b/src/application/ProviderOperationExecution.test.ts new file mode 100644 index 0000000000..7729cb42b7 --- /dev/null +++ b/src/application/ProviderOperationExecution.test.ts @@ -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(); +}); diff --git a/src/application/ProviderOperationExecution.ts b/src/application/ProviderOperationExecution.ts index 3cb46ee354..7a48e9ae88 100644 --- a/src/application/ProviderOperationExecution.ts +++ b/src/application/ProviderOperationExecution.ts @@ -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( diff --git a/src/application/jeb/JebAnalysisService.test.ts b/src/application/jeb/JebAnalysisService.test.ts index 9f1e975e16..f4b3f0f01d 100644 --- a/src/application/jeb/JebAnalysisService.test.ts +++ b/src/application/jeb/JebAnalysisService.test.ts @@ -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); }); diff --git a/src/cli/jebCommands.ts b/src/cli/jebCommands.ts index 18009c2f71..0aab2617a7 100644 --- a/src/cli/jebCommands.ts +++ b/src/cli/jebCommands.ts @@ -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", { diff --git a/tests/boundary/cli/jebInputDiagnostics.test.ts b/tests/boundary/cli/jebInputDiagnostics.test.ts new file mode 100644 index 0000000000..7e459873cf --- /dev/null +++ b/tests/boundary/cli/jebInputDiagnostics.test.ts @@ -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, + 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[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[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); +});