From fa53f7070ae2fb89cfdebfd7c4332e6dab39231c Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sun, 15 Nov 2026 15:00:24 +0800 Subject: [PATCH] refactor(cli): project view-delivery inspectors from their contracts (#10600) * refactor(cli): project view-delivery inspectors from their contracts * refactor(cli): project APK signature, desktop application and EVM inspectors from contracts (#10603) --- docs/development/tool-design.md | 7 +- src/cli/apkSignatureCommands.ts | 110 +++------ src/cli/binaryDiagnosticsCommands.ts | 7 +- src/cli/contractCliDescription.ts | 215 ++++++++++++++++-- src/cli/contractCommand.ts | 26 ++- src/cli/desktopApplicationCommands.ts | 96 ++++---- src/cli/erlangCommands.ts | 103 +++------ src/cli/evmCommands.ts | 85 +++---- src/cli/modelCommands.ts | 134 ++++------- src/cli/producerLimitsOption.ts | 8 +- src/cli/pythonPycCommands.ts | 110 +++------ src/cli/rustCommands.ts | 167 ++++---------- src/domain/analysisView/erlangBeamView.ts | 4 - src/domain/analysisView/pythonPycView.ts | 4 - .../cli/cliContractCommandParity.test.ts | 151 +++++++++++- 15 files changed, 622 insertions(+), 605 deletions(-) diff --git a/docs/development/tool-design.md b/docs/development/tool-design.md index 569143d82e..3d20773b8d 100644 --- a/docs/development/tool-design.md +++ b/docs/development/tool-design.md @@ -608,8 +608,11 @@ and rendered for the CLI framework by `registerContractCommand` in The projection names only what the CLI spells differently: positionals, caller-relative path fields, option renames and short flags, CLI help wording, omitted optional fields with a reason, Evidence delivery options, and whether the contract's cross-field -rules refuse at argv parsing or at the service's contract parse. Field schemas -and decimal argv tokens are derived, and +rules refuse at argv parsing or at the service's contract parse. A delivery +`view` is JSON text decoded when the command runs; the contract input then +admits the view with its `detail` before any target is opened, including a +`detail`-discriminated union input. Field schemas and decimal argv tokens are +derived, and `tests/boundary/cli/cliContractCommandParity.test.ts` checks that each projected command admits what its contract admits. diff --git a/src/cli/apkSignatureCommands.ts b/src/cli/apkSignatureCommands.ts index 57d62c192b..f4761c8d05 100644 --- a/src/cli/apkSignatureCommands.ts +++ b/src/cli/apkSignatureCommands.ts @@ -1,20 +1,12 @@ -import { parseCliJsonOption } from "./cliJsonOption.js"; -import { z } from "incur"; import type { Logger } from "pino"; + import { APK_SIGNATURE_PROVIDER_IDENTITY } from "../apkSignature/ApkSignatureRelease.js"; import { apkSignatureModule } from "../composition/apkSignature.js"; -import { inspectApkSignatureInputSchema } from "../domain/apkSignature/apkSignatureQuery.js"; -import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; -import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js"; -import { logCliCommand } from "../cliLogging.js"; -import { localPathStringSchema } from "../domain/localPath.js"; -import { absoluteCallerPath } from "../filesystem/callerPath.js"; -import type { CliInstance } from "./types.js"; -import { withCommandCancellation } from "./commandCancellation.js"; import { - analysisEvidenceDeliveryOptions, - deliverCliAnalysisEvidence, -} from "./analysisEvidenceDelivery.js"; + deliverWithService, + registerContractCommand, +} from "./contractCommand.js"; +import type { CliInstance } from "./types.js"; /** CLI shares selected delivery and complete-Evidence publication with other artifact tools. */ export const registerApkSignatureCommands = ( @@ -22,65 +14,35 @@ export const registerApkSignatureCommands = ( logger: Logger, environment: Readonly, ): void => { - cli.command(apkSignatureModule.cliCommand, { - description: - "Inspect declared APK signing schemes, signers and content digests", - args: z.object({ - path: localPathStringSchema.describe("Selected local APK/ZIP/AAB"), - }), - options: z.object({ - ...analysisEvidenceDeliveryOptions, - view: z - .string() - .optional() - .describe( - "JSON signer/digest/lineage selection; requires --detail summary and --evidence_output", - ), - }), - run: ({ args, options }) => - withCommandCancellation((signal) => - logCliCommand(logger, apkSignatureModule.cliCommand, async () => { - const decoded = parseCliJsonOption( - "inspect_apk_signature", - "view", - options.view, - "view must be a JSON APK signature selection", - ); - if (!decoded.ok) return projectAnalysisError(decoded.error); - const input = inspectApkSignatureInputSchema.safeParse({ - path: absoluteCallerPath(args.path), - detail: options.detail, - ...(decoded.value === undefined ? {} : { view: decoded.value }), - }); - if (!input.success) - return projectAnalysisError( - analysisInputErrorFromIssues( - "inspect_apk_signature", - input.error.issues, - { view: decoded.value }, - ), - ); - const service = apkSignatureModule.create(environment); - return deliverCliAnalysisEvidence({ - callerInputPath: args.path, - run: () => - service.inspect( - { path: input.data.path, detail: "complete" }, - { signal }, - ), - service, - providerId: APK_SIGNATURE_PROVIDER_IDENTITY.id, - operation: apkSignatureModule.contract.name, - signal, - detail: options.detail, - evidenceOutput: options.evidence_output, - ...(input.data.view === undefined - ? {} - : { - view: { kind: "apk-signature" as const, ...input.data.view }, - }), - }); - }), - ), - }); + const deliver = deliverWithService( + () => apkSignatureModule.create(environment), + APK_SIGNATURE_PROVIDER_IDENTITY.id, + ); + registerContractCommand( + cli, + logger, + apkSignatureModule.contract, + { + command: apkSignatureModule.cliCommand, + description: + "Inspect declared APK signing schemes, signers and content digests", + positionals: ["path"], + callerPaths: ["path"], + evidenceDelivery: true, + viewDelivery: { + invalidJson: "view must be a JSON APK signature selection", + kind: "apk-signature", + }, + fields: { + path: { description: "Selected local APK/ZIP/AAB" }, + view: { + description: + "JSON signer/digest/lineage selection; requires --detail summary and --evidence_output", + }, + }, + }, + // The CLI delivers the summary itself, so the service returns complete + // Evidence; the contract's own default would retain a summary instead. + (input, context) => deliver({ ...input, detail: "complete" }, context), + ); }; diff --git a/src/cli/binaryDiagnosticsCommands.ts b/src/cli/binaryDiagnosticsCommands.ts index 755144cf3c..bc26bbf8f6 100644 --- a/src/cli/binaryDiagnosticsCommands.ts +++ b/src/cli/binaryDiagnosticsCommands.ts @@ -30,6 +30,7 @@ import { inspectWithService, registerContractCommand, } from "./contractCommand.js"; +import { PRODUCER_LIMITS_HELP } from "./producerLimitsOption.js"; type BinaryDiagnosticsOperation = (typeof BINARY_DIAGNOSTICS_TOOL_CONTRACTS)[number]["name"]; @@ -309,8 +310,6 @@ export const registerBinaryDiagnosticsCommands = ( }, deliverWithService(createNativeExportsService, "rea-native-exports"), ); - const producerLimitsHelp = - "JSON producer budget overrides. Keys and units follow this operation's producer_limits contract; changes apply on this call without restart and preserve tighter inherited OS limits."; const layout = createBinaryLayoutService(environment); registerContractCommand( cli, @@ -325,7 +324,7 @@ export const registerBinaryDiagnosticsCommands = ( jsonText: ["producer_limits"], fields: { path: { description: "Local ELF binary or relocatable object" }, - producer_limits: { description: producerLimitsHelp }, + producer_limits: { description: PRODUCER_LIMITS_HELP }, }, omit: { detail: @@ -355,7 +354,7 @@ export const registerBinaryDiagnosticsCommands = ( path: { description: "Local Linux ELF64 amd64 or AArch64 little-endian core", }, - producer_limits: { description: producerLimitsHelp }, + producer_limits: { description: PRODUCER_LIMITS_HELP }, include_debugger_context: { name: "debuggerContext", description: "Add BYO core-only GDB/pwndbg mapping candidates", diff --git a/src/cli/contractCliDescription.ts b/src/cli/contractCliDescription.ts index 48b6606e7d..f5fb948678 100644 --- a/src/cli/contractCliDescription.ts +++ b/src/cli/contractCliDescription.ts @@ -1,8 +1,16 @@ import { z } from "zod"; +import type { AnalysisInputError } from "../domain/analysisErrorCore.js"; +import type { AnalysisViewRequest } from "../domain/analysisView/analysisViewRequest.js"; +import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js"; import { localPathStringSchema } from "../domain/localPath.js"; +import { err, ok, type Result } from "../domain/result.js"; import { absoluteCallerPath } from "../filesystem/callerPath.js"; -import { analysisEvidenceDeliveryOptions } from "./analysisEvidenceDelivery.js"; +import { + analysisEvidenceDeliveryOptions, + cliEvidenceDeliveryInputError, +} from "./analysisEvidenceDelivery.js"; +import { parseCliJsonOption } from "./cliJsonOption.js"; import { safeParseJson } from "../domain/safeJson.js"; import { cliDecimalShape } from "./options.js"; @@ -13,10 +21,16 @@ import { cliDecimalShape } from "./options.js"; * framework adapter (today `contractCommand.ts` for Incur) only renders it. */ -/** The parts of a tool contract that a CLI projection reads. */ +/** + * The parts of a tool contract that a CLI projection reads. A union input + * shares every field except those the CLI delivery owns, such as a + * `detail`-discriminated delivery. + */ export interface ProjectedContract { readonly name: Name; - readonly inputSchema: z.ZodObject; + readonly inputSchema: + | z.ZodObject + | z.ZodDiscriminatedUnion; } /** A contract input field name. */ @@ -74,6 +88,26 @@ export interface CliContractProjection { * field selects that delivery and is not passed to the analysis. */ readonly evidenceDelivery?: true; + /** + * With `evidenceDelivery`, the contract `view` field selects the delivered + * page. `--view` takes JSON text that is decoded when the command runs, and + * the delivery is then checked against the contract input before any + * target is opened. + */ + readonly viewDelivery?: CliViewDelivery; +} + +/** How a contract command takes its `view` selection. */ +export interface CliViewDelivery { + /** The refusal when `--view` is not JSON. */ + readonly invalidJson: string; + /** Added to the selection when the contract's view does not name its kind. */ + readonly kind?: AnalysisViewRequest["kind"]; + /** + * `--detail` has no default: a view selects summary delivery, and complete + * applies otherwise, as in the contract. + */ + readonly viewSelectsSummary?: true; } /** A CLI option with no contract field, such as `--evidence_output`. */ @@ -87,6 +121,8 @@ export interface CliOnlyOption { export interface CliEvidenceDeliveryChoice { readonly detail: "complete" | "summary"; readonly evidenceOutput: string | undefined; + /** The admitted view, for a projection with `viewDelivery`. */ + readonly view?: AnalysisViewRequest; } /** One positional argument or option of a contract command. */ @@ -115,6 +151,11 @@ export interface ContractCliParameter { readonly jsonText: boolean; /** Selects CLI Evidence delivery and is not analysis input. */ readonly delivery: boolean; + /** + * A delivery view as JSON text, decoded when the command runs and admitted + * with the delivery by the contract input. + */ + readonly viewSelection: boolean; } /** One cross-field refusal, naming CLI keys. */ @@ -129,6 +170,11 @@ export interface ContractCliDescription { readonly command: string; readonly description: string; readonly contract: ProjectedContract; + /** + * Each contract input field's own schema. In a union input, a field the + * delivery owns admits the value of any branch. + */ + readonly fields: Readonly>; readonly parameters: readonly ContractCliParameter[]; /** Options after the contract parameters that no contract field owns. */ readonly cliOnlyOptions: readonly CliOnlyOption[]; @@ -153,10 +199,15 @@ export interface ContractCliDescription { readonly callerInputs: ( values: Readonly>, ) => Readonly>; - /** The selected delivery, for a projection with `evidenceDelivery`. */ - readonly delivery: ( - values: Readonly>, - ) => CliEvidenceDeliveryChoice; + /** + * The selected delivery, for a projection with `evidenceDelivery`, refused + * before any target is opened when it is not admitted. + */ + readonly delivery: + | (( + values: Readonly>, + ) => Result) + | undefined; } /** One contract from a contract group, keeping its exact schema types. */ @@ -310,16 +361,24 @@ const renameConstraint = ( ); }; +/** The union keyword naming a root discriminator, checked with the delivery. */ +const ROOT_DISCRIMINATOR = "x-rea-root-discriminator"; + const projectObjectConstraints = ( contract: ProjectedContract, rename: (field: string) => string, + deliveryFields: ReadonlySet, ): Record | undefined => { const metadata = contract.inputSchema.meta(); if (metadata === undefined) return undefined; for (const keyword of Object.keys(metadata)) if ( !ROOT_ANNOTATIONS.has(keyword) && - !(OBJECT_CONSTRAINTS as readonly string[]).includes(keyword) + !(OBJECT_CONSTRAINTS as readonly string[]).includes(keyword) && + !( + keyword === ROOT_DISCRIMINATOR && + deliveryFields.has(String(metadata[keyword])) + ) ) throw new Error( `${contract.name} input keyword ${keyword} has no CLI projection`, @@ -340,12 +399,59 @@ const projectObjectConstraints = ( return Object.keys(projected).length === 0 ? undefined : projected; }; +/** + * Each input field's schema. Branches of a union input must share each field + * the CLI takes as an argument; only delivery-owned fields may differ, and + * they admit any branch's value here. The delivery is then checked against + * the whole union. + */ +const inputFields = ( + contract: ProjectedContract, + deliveryFields: ReadonlySet, +): Record => { + const schema = contract.inputSchema; + if (schema instanceof z.ZodObject) + return schema.shape as Record; + const shapes = schema.options.map( + (option) => option.shape as Record, + ); + const names = new Set(shapes.flatMap((shape) => Object.keys(shape))); + return Object.fromEntries( + [...names].map((field) => { + const branches = shapes.map((shape) => shape[field]); + const [first] = branches; + if (branches.every((branch) => branch === first)) return [field, first!]; + if ( + !deliveryFields.has(field) || + branches.some((branch) => branch === undefined) + ) + throw new Error( + `${contract.name}.${field} differs between input branches and has no argv projection`, + ); + return [field, z.union(branches as z.ZodType[])]; + }), + ); +}; + /** Derive the CLI parameters of one contract from its input schema and projection. */ export const describeContractCommand = ( contract: Contract, projection: CliContractProjection>, ): ContractCliDescription => { - const shape = contract.inputSchema.shape as Record; + const evidenceDelivery = projection.evidenceDelivery === true; + const { viewDelivery } = projection; + if (viewDelivery !== undefined && !evidenceDelivery) + throw new Error(`${contract.name} view delivery requires evidenceDelivery`); + const deliveryFields: ReadonlySet = new Set( + evidenceDelivery + ? viewDelivery === undefined + ? ["detail"] + : ["detail", "view"] + : [], + ); + const shape = inputFields(contract, deliveryFields); + if (viewDelivery !== undefined && !admitsOmission(shape.view ?? z.never())) + throw new Error(`${contract.name} has no optional input field view`); const positionals: readonly string[] = projection.positionals ?? []; const callerPaths: ReadonlySet = new Set( projection.callerPaths ?? [], @@ -374,16 +480,21 @@ export const describeContractCommand = ( if (!admitsOmission(shape[field]!)) throw new Error(`${contract.name}.${field} is required on the CLI`); - const evidenceDelivery = projection.evidenceDelivery === true; const parameter = (field: string): ContractCliParameter => { const declared = shape[field]!; const projected = fieldProjections[field]; const callerPath = callerPaths.has(field); - const delivery = evidenceDelivery && field === "detail"; + const delivery = deliveryFields.has(field); + const viewSelection = delivery && field === "view"; const jsonText = jsonTextFields.has(field); const key = projected?.name ?? camelCase(field); let schema: z.ZodType; - if (delivery) schema = analysisEvidenceDeliveryOptions.detail; + if (viewSelection) schema = z.string().optional(); + else if (delivery) + schema = + viewDelivery?.viewSelectsSummary === true + ? analysisEvidenceDeliveryOptions.detail.unwrap().optional() + : analysisEvidenceDeliveryOptions.detail; else if (jsonText) schema = jsonTextField(declared, kebabCase(key)); else if (callerPath) { schema = admitsOmission(declared) @@ -412,6 +523,7 @@ export const describeContractCommand = ( callerPath, delivery, jsonText, + viewSelection, }; }; const parameters = [ @@ -494,7 +606,12 @@ export const describeContractCommand = ( parameters, cliOnlyOptions, omitted, - optionConstraints: projectObjectConstraints(contract, rename), + fields: shape, + optionConstraints: projectObjectConstraints( + contract, + rename, + deliveryFields, + ), checkOptions, toContractInput, callerInputs: (values) => @@ -506,12 +623,70 @@ export const describeContractCommand = ( : []; }), ), - delivery: (values) => ({ - detail: values.detail === "summary" ? "summary" : "complete", - evidenceOutput: - typeof values.evidence_output === "string" - ? values.evidence_output - : undefined, - }), + delivery: evidenceDelivery + ? (values) => { + const selected = selectDelivery( + contract, + viewDelivery, + values, + toContractInput, + rename, + ); + if (!selected.ok) return selected; + const refused = cliEvidenceDeliveryInputError({ + ...selected.value, + operation: contract.name, + }); + return refused === undefined ? selected : err(refused); + } + : undefined, }; }; + +/** + * The delivery the parsed values select. A view is decoded from its JSON + * text and admitted with the delivery and the other inputs by the contract + * input schema, so the contract's own delivery rules and messages apply. + */ +const selectDelivery = ( + contract: ProjectedContract, + viewDelivery: CliViewDelivery | undefined, + values: Readonly>, + toContractInput: ContractCliDescription["toContractInput"], + rename: (field: string) => string, +): Result => { + const detail = values[rename("detail")] as "complete" | "summary" | undefined; + const evidenceOutput = values.evidence_output as string | undefined; + if (viewDelivery === undefined) + return ok({ detail: detail ?? "complete", evidenceOutput }); + const viewKey = rename("view"); + const decoded = parseCliJsonOption( + contract.name, + viewKey, + values[viewKey] as string | undefined, + viewDelivery.invalidJson, + ); + if (!decoded.ok) return decoded; + const input = { + ...toContractInput(values), + ...(detail === undefined ? {} : { detail }), + ...(decoded.value === undefined ? {} : { view: decoded.value }), + }; + const admitted = contract.inputSchema.safeParse(input); + if (!admitted.success) + return err( + analysisInputErrorFromIssues(contract.name, admitted.error.issues, input), + ); + const { view } = admitted.data as { readonly view?: object }; + return ok({ + detail: detail ?? (view === undefined ? "complete" : "summary"), + evidenceOutput, + ...(view === undefined + ? {} + : { + view: (viewDelivery.kind === undefined + ? view + : { kind: viewDelivery.kind, ...view }) as AnalysisViewRequest, + }), + }); +}; diff --git a/src/cli/contractCommand.ts b/src/cli/contractCommand.ts index 644beab995..b4065ee556 100644 --- a/src/cli/contractCommand.ts +++ b/src/cli/contractCommand.ts @@ -3,6 +3,7 @@ import type { Logger } from "pino"; import { logCliCommand } from "../cliLogging.js"; import type { AnalysisError } from "../domain/analysisErrorBase.js"; +import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import type { Evidence } from "../domain/evidence.js"; import type { Result } from "../domain/result.js"; import { deliverCliAnalysisEvidence } from "./analysisEvidenceDelivery.js"; @@ -32,8 +33,8 @@ export interface ContractCommandContext { readonly operation: string; /** Each caller-path field as the caller spelled it, before resolution. */ readonly callerInputs: Readonly>; - /** The selected delivery, for a projection with `evidenceDelivery`. */ - readonly delivery: CliEvidenceDeliveryChoice; + /** The admitted delivery, for a projection with `evidenceDelivery`. */ + readonly delivery: CliEvidenceDeliveryChoice | undefined; } /** A closeable one-shot service that inspects one contract input. */ @@ -82,6 +83,9 @@ export const deliverWithService = (service: () => EvidenceInspectionService, providerId: string) => (input: unknown, context: ContractCommandContext) => { const callerInputPath = primaryCallerPath(context); + const { delivery } = context; + if (delivery === undefined) + throw new Error(`${context.operation} does not declare evidenceDelivery`); const owned = service(); return deliverCliAnalysisEvidence({ callerInputPath, @@ -90,8 +94,9 @@ export const deliverWithService = providerId, operation: context.operation, signal: context.signal, - detail: context.delivery.detail, - evidenceOutput: context.delivery.evidenceOutput, + detail: delivery.detail, + evidenceOutput: delivery.evidenceOutput, + ...(delivery.view === undefined ? {} : { view: delivery.view }), }); }; @@ -174,8 +179,11 @@ export const registerContractCommand = < }) => { const values = { ...options, ...args }; return withCommandCancellation((signal) => - logCliCommand(logger, description.command, () => - run( + logCliCommand(logger, description.command, async () => { + const delivery = description.delivery?.(values); + if (delivery?.ok === false) + return projectAnalysisError(delivery.error); + return run( description.toContractInput(values) as z.input< Contract["inputSchema"] >, @@ -183,10 +191,10 @@ export const registerContractCommand = < signal, operation: contract.name, callerInputs: description.callerInputs(values), - delivery: description.delivery(values), + delivery: delivery?.value, }, - ), - ), + ); + }), ); }, } as never); diff --git a/src/cli/desktopApplicationCommands.ts b/src/cli/desktopApplicationCommands.ts index 82ac5e6dd2..1dc1d85903 100644 --- a/src/cli/desktopApplicationCommands.ts +++ b/src/cli/desktopApplicationCommands.ts @@ -1,67 +1,47 @@ -import { localPathStringSchema } from "../domain/localPath.js"; -import { inspectDesktopApplicationInputSchema } from "../domain/desktopApplication.js"; -import { - analysisEvidenceDeliveryOptions, - deliverCliAnalysisEvidence, -} from "./analysisEvidenceDelivery.js"; -import { z } from "incur"; import type { Logger } from "pino"; -import type { CliInstance } from "./types.js"; -import { createDesktopApplicationService } from "../composition/desktop.js"; -import { absoluteCallerPath } from "../filesystem/callerPath.js"; + import { CLI_COMMANDS } from "../cliCommandNames.js"; -import { logCliCommand } from "../cliLogging.js"; -import { withCommandCancellation } from "./commandCancellation.js"; -import { cliDecimalField } from "./options.js"; +import { createDesktopApplicationService } from "../composition/desktop.js"; +import { DESKTOP_APPLICATION_TOOL_CONTRACTS } from "../contracts/desktopApplicationToolContracts.js"; +import { contractNamed } from "./contractCliDescription.js"; +import { + deliverWithService, + registerContractCommand, +} from "./contractCommand.js"; +import type { CliInstance } from "./types.js"; /** One-shot complete Evidence can be saved and queried with inspect-analysis-view. */ export const registerDesktopApplicationCommands = ( cli: CliInstance, logger: Logger, ): void => { - cli.command(CLI_COMMANDS.inspectDesktopApplication, { - description: - "Identify desktop runtime candidates from bounded directory and header observations", - args: z.object({ - path: localPathStringSchema.describe("Local installation directory"), - }), - options: z.object({ - // Decimal tokens only: numeric coercion would read "0x1" as 1 and - // "1e1" as 10. The shared fields keep their bounds and defaults. - max_entries: cliDecimalField( - inspectDesktopApplicationInputSchema.shape.max_entries, - ).describe( - "Maximum directory entries to examine; exhaustion leaves partial coverage", - ), - ...analysisEvidenceDeliveryOptions, - max_probe_bytes: cliDecimalField( - inspectDesktopApplicationInputSchema.shape.max_probe_bytes, - ).describe("Maximum total bytes read from selected file ranges"), - }), - run: ({ args, options }) => - withCommandCancellation((signal) => - logCliCommand( - logger, - CLI_COMMANDS.inspectDesktopApplication, - async () => { - const { detail, evidence_output, ...limits } = options; - const service = createDesktopApplicationService(); - return deliverCliAnalysisEvidence({ - callerInputPath: args.path, - run: () => - service.inspect( - { path: absoluteCallerPath(args.path), ...limits }, - { signal }, - ), - service, - providerId: "rea-artifact-graph", - operation: "inspect_desktop_application", - signal, - detail, - evidenceOutput: evidence_output, - }); - }, - ), - ), - }); + registerContractCommand( + cli, + logger, + contractNamed( + DESKTOP_APPLICATION_TOOL_CONTRACTS, + "inspect_desktop_application", + ), + { + command: CLI_COMMANDS.inspectDesktopApplication, + description: + "Identify desktop runtime candidates from bounded directory and header observations", + positionals: ["path"], + callerPaths: ["path"], + evidenceDelivery: true, + fields: { + path: { description: "Local installation directory" }, + max_entries: { + name: "max_entries", + description: + "Maximum directory entries to examine; exhaustion leaves partial coverage", + }, + max_probe_bytes: { + name: "max_probe_bytes", + description: "Maximum total bytes read from selected file ranges", + }, + }, + }, + deliverWithService(createDesktopApplicationService, "rea-artifact-graph"), + ); }; diff --git a/src/cli/erlangCommands.ts b/src/cli/erlangCommands.ts index 096dc9eac4..304a134268 100644 --- a/src/cli/erlangCommands.ts +++ b/src/cli/erlangCommands.ts @@ -1,81 +1,40 @@ -import { z } from "incur"; -import { localPathStringSchema } from "../domain/localPath.js"; -import { absoluteCallerPath } from "../filesystem/callerPath.js"; -import { createBeamService } from "../composition/erlang.js"; -import { CLI_COMMANDS } from "../cliCommandNames.js"; -import { logCliCommand } from "../cliLogging.js"; import type { Logger } from "pino"; -import type { CliInstance } from "./types.js"; -import { withCommandCancellation } from "./commandCancellation.js"; -import { inspectErlangBeamInputSchema } from "../domain/erlang/beam.js"; + +import { CLI_COMMANDS } from "../cliCommandNames.js"; +import { createBeamService } from "../composition/erlang.js"; +import { ERLANG_BEAM_TOOL_CONTRACTS } from "../contracts/erlang/beamToolContracts.js"; +import { contractNamed } from "./contractCliDescription.js"; import { - analysisEvidenceDeliveryOptions, - deliverCliAnalysisEvidence, - parseCliAnalysisEvidenceDelivery, -} from "./analysisEvidenceDelivery.js"; -import { erlangBeamDeliverySchema } from "../domain/analysisView/erlangBeamView.js"; -import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; -import { cliDecimalField } from "./options.js"; + deliverWithService, + registerContractCommand, +} from "./contractCommand.js"; +import type { CliInstance } from "./types.js"; /** Inspect operator-selected BEAM files through the same shared workflow as MCP. */ export const registerErlangCommands = ( cli: CliInstance, logger: Logger, ): void => { - cli.command(CLI_COMMANDS.inspectErlangBeam, { - description: - "Inspect Erlang BEAM atoms, exports, imports and Code header without executing bytecode", - args: z.object({ - path: localPathStringSchema.describe("Local BEAM file"), - }), - options: z.object({ - // Decimal tokens only, as for inspect-python-pyc. - maxFileBytes: cliDecimalField( - inspectErlangBeamInputSchema.shape.max_file_bytes, - ), - maxChunks: cliDecimalField(inspectErlangBeamInputSchema.shape.max_chunks), - maxAtoms: cliDecimalField(inspectErlangBeamInputSchema.shape.max_atoms), - ...analysisEvidenceDeliveryOptions, - view: z - .string() - .optional() - .describe( - "JSON beam view; requires --detail summary and --evidence_output", - ), - }), - run: ({ args, options }) => - withCommandCancellation((signal) => - logCliCommand(logger, CLI_COMMANDS.inspectErlangBeam, async () => { - const delivery = parseCliAnalysisEvidenceDelivery( - "inspect_erlang_beam", - options, - erlangBeamDeliverySchema, - ); - if (!delivery.ok) return projectAnalysisError(delivery.error); - const service = createBeamService(); - return deliverCliAnalysisEvidence({ - callerInputPath: args.path, - run: () => - service.inspect( - { - path: absoluteCallerPath(args.path), - max_file_bytes: options.maxFileBytes, - max_chunks: options.maxChunks, - max_atoms: options.maxAtoms, - }, - { signal }, - ), - service, - providerId: "rea-erlang-beam", - operation: "inspect_erlang_beam", - signal, - detail: delivery.value.detail, - evidenceOutput: delivery.value.evidenceOutput, - ...(delivery.value.view === undefined - ? {} - : { view: delivery.value.view }), - }); - }), - ), - }); + registerContractCommand( + cli, + logger, + contractNamed(ERLANG_BEAM_TOOL_CONTRACTS, "inspect_erlang_beam"), + { + command: CLI_COMMANDS.inspectErlangBeam, + description: + "Inspect Erlang BEAM atoms, exports, imports and Code header without executing bytecode", + positionals: ["path"], + callerPaths: ["path"], + evidenceDelivery: true, + viewDelivery: { invalidJson: "view must be valid JSON" }, + fields: { + path: { description: "Local BEAM file" }, + view: { + description: + "JSON beam view; requires --detail summary and --evidence_output", + }, + }, + }, + deliverWithService(createBeamService, "rea-erlang-beam"), + ); }; diff --git a/src/cli/evmCommands.ts b/src/cli/evmCommands.ts index 21e32b04e3..2aee2358d6 100644 --- a/src/cli/evmCommands.ts +++ b/src/cli/evmCommands.ts @@ -1,19 +1,12 @@ -import { projectCliAnalysisResult } from "./projectCliAnalysisResult.js"; -import { z } from "incur"; -import { evmInterfaceModule } from "../composition/evm.js"; -import { logCliCommand } from "../cliLogging.js"; import type { Logger } from "pino"; -import type { CliInstance } from "./types.js"; -import { withCommandCancellation } from "./commandCancellation.js"; -import { localPathStringSchema } from "../domain/localPath.js"; -import { absoluteCallerPath } from "../filesystem/callerPath.js"; -import { finishCliEvidenceCommand } from "./cliEvidenceCallerPath.js"; -import { cliDecimalField } from "./options.js"; + +import { evmInterfaceModule } from "../composition/evm.js"; import { - inspectEvmInterfaceInputSchema, - evmProducerLimitsSchema, -} from "../domain/evm/evmInterface.js"; -import { producerLimitsOption } from "./producerLimitsOption.js"; + inspectWithService, + registerContractCommand, +} from "./contractCommand.js"; +import { PRODUCER_LIMITS_HELP } from "./producerLimitsOption.js"; +import type { CliInstance } from "./types.js"; /** Resolve operator paths and preserve explicit encoding through the shared workflow. */ export const registerEvmCommands = ( @@ -22,45 +15,27 @@ export const registerEvmCommands = ( environment: Readonly, ): void => { const service = evmInterfaceModule.create(environment); - cli.command(evmInterfaceModule.cliCommand, { - description: "Infer interface candidates from explicit local EVM bytecode", - args: z.object({ - path: localPathStringSchema.describe("Local bytecode carrier"), - encoding: z - .enum(["raw", "hex"]) - .describe("Selected carrier representation"), - }), - options: z.object({ - producerLimits: producerLimitsOption(evmProducerLimitsSchema), - timeoutMs: cliDecimalField( - inspectEvmInterfaceInputSchema.shape.timeout_ms, - ), - }), - run: ({ args, options }) => - withCommandCancellation((signal) => - logCliCommand(logger, evmInterfaceModule.cliCommand, () => - finishCliEvidenceCommand( - args.path, - () => - service.inspect( - { - path: absoluteCallerPath(args.path), - encoding: args.encoding, - ...(options.producerLimits === undefined - ? {} - : { producer_limits: options.producerLimits }), - ...(options.timeoutMs === undefined - ? {} - : { timeout_ms: options.timeoutMs }), - }, - { signal }, - ), - service, - "evmole-interface", - "inspect_evm_interface", - signal, - ).then(projectCliAnalysisResult), - ), - ), - }); + registerContractCommand( + cli, + logger, + evmInterfaceModule.contract, + { + command: evmInterfaceModule.cliCommand, + description: + "Infer interface candidates from explicit local EVM bytecode", + positionals: ["path", "encoding"], + callerPaths: ["path"], + jsonText: ["producer_limits"], + fields: { + path: { description: "Local bytecode carrier" }, + encoding: { description: "Selected carrier representation" }, + producer_limits: { description: PRODUCER_LIMITS_HELP }, + }, + omit: { + detail: + "One-shot output is complete Evidence; this command has no summary delivery", + }, + }, + inspectWithService(() => service, "evmole-interface"), + ); }; diff --git a/src/cli/modelCommands.ts b/src/cli/modelCommands.ts index 7546357b09..2cd953377d 100644 --- a/src/cli/modelCommands.ts +++ b/src/cli/modelCommands.ts @@ -1,102 +1,52 @@ -import { parseCliJsonOption } from "./cliJsonOption.js"; -import { inspectModelArtifactInputSchema } from "../domain/model/modelArtifactQuery.js"; -import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js"; -import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; -import { z } from "incur"; import type { Logger } from "pino"; -import type { CliInstance } from "./types.js"; -import { localPathStringSchema } from "../domain/localPath.js"; -import { absoluteCallerPath } from "../filesystem/callerPath.js"; -import { createModelArtifactService } from "../composition/model.js"; + import { CLI_COMMANDS } from "../cliCommandNames.js"; -import { logCliCommand } from "../cliLogging.js"; -import { withCommandCancellation } from "./commandCancellation.js"; +import { createModelArtifactService } from "../composition/model.js"; +import { INSPECT_MODEL_ARTIFACT_CONTRACT } from "../contracts/model/modelToolContracts.js"; import { - analysisEvidenceDeliveryOptions, - deliverCliAnalysisEvidence, -} from "./analysisEvidenceDelivery.js"; + deliverWithService, + registerContractCommand, +} from "./contractCommand.js"; +import type { CliInstance } from "./types.js"; /** One-shot summaries export the same complete observations used by MCP retained pages. */ export const registerModelCommands = ( cli: CliInstance, logger: Logger, ): void => { - cli.command(CLI_COMMANDS.inspectModelArtifact, { - description: - "Inspect GGUF or safetensors headers without loading model weights", - args: z.object({ - path: localPathStringSchema.describe( - "Local model file, detected by content", - ), - }), - options: z.object({ - ...analysisEvidenceDeliveryOptions, - view: z - .string() - .optional() - .describe( - "JSON model page selection; requires --detail summary and --evidence_output", - ), - hash: z - .enum(["header", "full"]) - .default("header") - .describe( - "header reads declarations only; full additionally streams all bytes for SHA-256", - ), - }), - run: ({ args, options }) => - withCommandCancellation((signal) => - logCliCommand(logger, CLI_COMMANDS.inspectModelArtifact, async () => { - const decoded = parseCliJsonOption( - "inspect_model_artifact", - "view", - options.view, - "view must be a JSON model page selection", - ); - if (!decoded.ok) return projectAnalysisError(decoded.error); - const view = decoded.value; - const selected = inspectModelArtifactInputSchema.safeParse({ - path: absoluteCallerPath(args.path), - hash: options.hash, - detail: options.detail, - ...(view === undefined ? {} : { view }), - }); - if (!selected.success) - return projectAnalysisError( - analysisInputErrorFromIssues( - "inspect_model_artifact", - selected.error.issues, - { view }, - ), - ); - const service = createModelArtifactService(); - return deliverCliAnalysisEvidence({ - callerInputPath: args.path, - run: () => - service.inspect( - { - path: selected.data.path, - hash: selected.data.hash, - detail: "complete", - }, - { signal }, - ), - service, - providerId: "model-artifact", - operation: "inspect_model_artifact", - signal, - detail: options.detail, - evidenceOutput: options.evidence_output, - ...(selected.data.view === undefined - ? {} - : { - view: { - kind: "model-artifact" as const, - ...selected.data.view, - }, - }), - }); - }), - ), - }); + const deliver = deliverWithService( + createModelArtifactService, + "model-artifact", + ); + registerContractCommand( + cli, + logger, + INSPECT_MODEL_ARTIFACT_CONTRACT, + { + command: CLI_COMMANDS.inspectModelArtifact, + description: + "Inspect GGUF or safetensors headers without loading model weights", + positionals: ["path"], + callerPaths: ["path"], + evidenceDelivery: true, + viewDelivery: { + invalidJson: "view must be a JSON model page selection", + kind: "model-artifact", + }, + fields: { + path: { description: "Local model file, detected by content" }, + hash: { + description: + "header reads declarations only; full additionally streams all bytes for SHA-256", + }, + view: { + description: + "JSON model page selection; requires --detail summary and --evidence_output", + }, + }, + }, + // The CLI delivers the summary itself, so the service returns complete + // Evidence; the contract's own default would retain a summary instead. + (input, context) => deliver({ ...input, detail: "complete" }, context), + ); }; diff --git a/src/cli/producerLimitsOption.ts b/src/cli/producerLimitsOption.ts index 6fb3a3b47b..638c3fac8f 100644 --- a/src/cli/producerLimitsOption.ts +++ b/src/cli/producerLimitsOption.ts @@ -1,6 +1,10 @@ import { z } from "incur"; import { safeParseJson } from "../domain/safeJson.js"; +/** CLI help for a producer's `producer_limits` JSON text. */ +export const PRODUCER_LIMITS_HELP = + "JSON producer budget overrides. Keys and units follow this operation's producer_limits contract; changes apply on this call without restart and preserve tighter inherited OS limits."; + /** Incur passes object flags as text; validate the selected producer's exact policy. */ export const producerLimitsOption = ( schema: Schema, @@ -21,6 +25,4 @@ export const producerLimitsOption = ( }) .pipe(schema) .optional() - .describe( - "JSON producer budget overrides. Keys and units follow this operation's producer_limits contract; changes apply on this call without restart and preserve tighter inherited OS limits.", - ); + .describe(PRODUCER_LIMITS_HELP); diff --git a/src/cli/pythonPycCommands.ts b/src/cli/pythonPycCommands.ts index 0b7dbf23e1..67b54e236e 100644 --- a/src/cli/pythonPycCommands.ts +++ b/src/cli/pythonPycCommands.ts @@ -1,88 +1,40 @@ -import { z } from "incur"; -import { localPathStringSchema } from "../domain/localPath.js"; -import { absoluteCallerPath } from "../filesystem/callerPath.js"; -import { createPythonPycService } from "../composition/pythonPyc.js"; -import { CLI_COMMANDS } from "../cliCommandNames.js"; -import { logCliCommand } from "../cliLogging.js"; import type { Logger } from "pino"; -import type { CliInstance } from "./types.js"; -import { withCommandCancellation } from "./commandCancellation.js"; -import { inspectPythonPycInputSchema } from "../domain/python/pythonPyc.js"; + +import { CLI_COMMANDS } from "../cliCommandNames.js"; +import { createPythonPycService } from "../composition/pythonPyc.js"; +import { PYTHON_PYC_TOOL_CONTRACTS } from "../contracts/python/pythonPycToolContracts.js"; +import { contractNamed } from "./contractCliDescription.js"; import { - analysisEvidenceDeliveryOptions, - deliverCliAnalysisEvidence, - parseCliAnalysisEvidenceDelivery, -} from "./analysisEvidenceDelivery.js"; -import { pythonPycDeliverySchema } from "../domain/analysisView/pythonPycView.js"; -import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; -import { cliDecimalField } from "./options.js"; + deliverWithService, + registerContractCommand, +} from "./contractCommand.js"; +import type { CliInstance } from "./types.js"; /** Inspect operator-selected pyc files through the same shared workflow as MCP. */ export const registerPythonPycCommands = ( cli: CliInstance, logger: Logger, ): void => { - cli.command(CLI_COMMANDS.inspectPythonPyc, { - description: - "Inspect a supported CPython 3.7–3.15 pyc header and marshal code objects without executing bytecode", - args: z.object({ - path: localPathStringSchema.describe("Local CPython .pyc file"), - }), - options: z.object({ - // Decimal tokens only: numeric coercion would read "0x10" as 16 and - // "1e2" as 100. The shared fields keep their bounds and defaults. - maxFileBytes: cliDecimalField( - inspectPythonPycInputSchema.shape.max_file_bytes, - ), - maxObjects: cliDecimalField( - inspectPythonPycInputSchema.shape.max_objects, - ), - maxDepth: cliDecimalField(inspectPythonPycInputSchema.shape.max_depth), - maxStringBytes: cliDecimalField( - inspectPythonPycInputSchema.shape.max_string_bytes, - ), - ...analysisEvidenceDeliveryOptions, - view: z - .string() - .optional() - .describe( - "JSON python-pyc view; requires --detail summary and --evidence_output", - ), - }), - run: ({ args, options }) => - withCommandCancellation((signal) => - logCliCommand(logger, CLI_COMMANDS.inspectPythonPyc, async () => { - const delivery = parseCliAnalysisEvidenceDelivery( - "inspect_python_pyc", - options, - pythonPycDeliverySchema, - ); - if (!delivery.ok) return projectAnalysisError(delivery.error); - const service = createPythonPycService(); - return deliverCliAnalysisEvidence({ - callerInputPath: args.path, - run: () => - service.inspect( - { - path: absoluteCallerPath(args.path), - max_file_bytes: options.maxFileBytes, - max_objects: options.maxObjects, - max_depth: options.maxDepth, - max_string_bytes: options.maxStringBytes, - }, - { signal }, - ), - service, - providerId: "rea-python-pyc", - operation: "inspect_python_pyc", - signal, - detail: delivery.value.detail, - evidenceOutput: delivery.value.evidenceOutput, - ...(delivery.value.view === undefined - ? {} - : { view: delivery.value.view }), - }); - }), - ), - }); + registerContractCommand( + cli, + logger, + contractNamed(PYTHON_PYC_TOOL_CONTRACTS, "inspect_python_pyc"), + { + command: CLI_COMMANDS.inspectPythonPyc, + description: + "Inspect a supported CPython 3.7–3.15 pyc header and marshal code objects without executing bytecode", + positionals: ["path"], + callerPaths: ["path"], + evidenceDelivery: true, + viewDelivery: { invalidJson: "view must be valid JSON" }, + fields: { + path: { description: "Local CPython .pyc file" }, + view: { + description: + "JSON python-pyc view; requires --detail summary and --evidence_output", + }, + }, + }, + deliverWithService(createPythonPycService, "rea-python-pyc"), + ); }; diff --git a/src/cli/rustCommands.ts b/src/cli/rustCommands.ts index a43616ce42..8e78f166b8 100644 --- a/src/cli/rustCommands.ts +++ b/src/cli/rustCommands.ts @@ -1,128 +1,57 @@ -import { parseCliJsonOption } from "./cliJsonOption.js"; -import { z } from "incur"; -import { producerLimitsOption } from "./producerLimitsOption.js"; -import { rustProducerLimitsSchema } from "../domain/rust/rustResourceLimits.js"; -import { inspectRustBinaryInputSchema } from "../domain/rust/rustBinary.js"; -import { cliDecimalField } from "./options.js"; -import { createRustBinaryService } from "../composition/rust.js"; -import { CLI_COMMANDS } from "../cliCommandNames.js"; -import { logCliCommand } from "../cliLogging.js"; -import { - analysisEvidenceDeliveryOptions, - deliverCliAnalysisEvidence, -} from "./analysisEvidenceDelivery.js"; -import { inspectRustBinaryRequestSchema } from "../domain/rust/rustBinaryQuery.js"; -import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js"; -import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import type { Logger } from "pino"; + +import { CLI_COMMANDS } from "../cliCommandNames.js"; +import { createRustBinaryService } from "../composition/rust.js"; +import { RUST_TOOL_CONTRACTS } from "../contracts/rust/rustToolContracts.js"; +import { contractNamed } from "./contractCliDescription.js"; +import { + deliverWithService, + registerContractCommand, +} from "./contractCommand.js"; +import { PRODUCER_LIMITS_HELP } from "./producerLimitsOption.js"; import type { CliInstance } from "./types.js"; -import { withCommandCancellation } from "./commandCancellation.js"; -import { localPathStringSchema } from "../domain/localPath.js"; -import { absoluteCallerPath } from "../filesystem/callerPath.js"; -import { machoSliceArchitectureSchema } from "../domain/apple/machoTargetSelection.js"; /** Resolve caller paths and inspect embedded Rust dependencies through the shared workflow. */ export const registerRustCommands = ( cli: CliInstance, logger: Logger, ): void => { - cli.command(CLI_COMMANDS.inspectRustBinary, { - description: - "Inspect an embedded cargo-auditable Rust dependency list without target execution", - args: z.object({ - path: localPathStringSchema.describe( - "Local ELF, PE, thin Mach-O or universal Mach-O image", - ), - }), - options: z.object({ - ...analysisEvidenceDeliveryOptions, - producerLimits: producerLimitsOption(rustProducerLimitsSchema), - maxRustcHashes: cliDecimalField( - inspectRustBinaryInputSchema.shape.max_rustc_hashes, - ), - architecture: machoSliceArchitectureSchema - .optional() - .describe( - "Slice to read from a universal Mach-O, by CPU family; when several slices share a family, arm64 (arm64 or arm64v8), arm64e (arm64e or arm64e.x1) or x86_64 names the subtype, and a value matching several slices is refused; required for universal files", - ), - detail: z - .enum(["complete", "summary"]) - .optional() - .describe( - "Default complete; a view selects compact delivery with --evidence_output", - ), - view: z - .string() - .optional() - .describe( - "JSON package or edge selection; retains complete Evidence with --evidence_output", - ), - }), - run: ({ args, options }) => - withCommandCancellation((signal) => - logCliCommand(logger, CLI_COMMANDS.inspectRustBinary, async () => { - const decoded = parseCliJsonOption( - "inspect_rust_binary", - "view", - options.view, - "view must be a JSON package or edge selection", - ); - if (!decoded.ok) return projectAnalysisError(decoded.error); - const view = decoded.value; - const input = inspectRustBinaryRequestSchema.safeParse({ - path: absoluteCallerPath(args.path), - ...(options.producerLimits === undefined - ? {} - : { producer_limits: options.producerLimits }), - ...(options.maxRustcHashes === undefined - ? {} - : { max_rustc_hashes: options.maxRustcHashes }), - ...(options.architecture === undefined - ? {} - : { architecture: options.architecture }), - ...(options.detail === undefined ? {} : { detail: options.detail }), - ...(view === undefined ? {} : { view }), - }); - if (!input.success) - return projectAnalysisError( - analysisInputErrorFromIssues( - "inspect_rust_binary", - input.error.issues, - { view }, - ), - ); - const service = createRustBinaryService(); - return deliverCliAnalysisEvidence({ - callerInputPath: args.path, - run: () => - service.inspect( - { - path: input.data.path, - ...(input.data.producer_limits === undefined - ? {} - : { producer_limits: input.data.producer_limits }), - ...(input.data.max_rustc_hashes === undefined - ? {} - : { max_rustc_hashes: input.data.max_rustc_hashes }), - ...(input.data.architecture === undefined - ? {} - : { architecture: input.data.architecture }), - }, - { signal }, - ), - service, - providerId: "rust-auditable", - operation: "inspect_rust_binary", - signal, - detail: - input.data.detail ?? - (input.data.view === undefined ? "complete" : "summary"), - evidenceOutput: options.evidence_output, - ...(input.data.view === undefined - ? {} - : { view: { kind: "rust" as const, ...input.data.view } }), - }); - }), - ), - }); + registerContractCommand( + cli, + logger, + contractNamed(RUST_TOOL_CONTRACTS, "inspect_rust_binary"), + { + command: CLI_COMMANDS.inspectRustBinary, + description: + "Inspect an embedded cargo-auditable Rust dependency list without target execution", + positionals: ["path"], + callerPaths: ["path"], + jsonText: ["producer_limits"], + evidenceDelivery: true, + viewDelivery: { + invalidJson: "view must be a JSON package or edge selection", + kind: "rust", + viewSelectsSummary: true, + }, + fields: { + path: { + description: "Local ELF, PE, thin Mach-O or universal Mach-O image", + }, + producer_limits: { description: PRODUCER_LIMITS_HELP }, + architecture: { + description: + "Slice to read from a universal Mach-O, by CPU family; when several slices share a family, arm64 (arm64 or arm64v8), arm64e (arm64e or arm64e.x1) or x86_64 names the subtype, and a value matching several slices is refused; required for universal files", + }, + detail: { + description: + "Default complete; a view selects compact delivery with --evidence_output", + }, + view: { + description: + "JSON package or edge selection; retains complete Evidence with --evidence_output", + }, + }, + }, + deliverWithService(createRustBinaryService, "rust-auditable"), + ); }; diff --git a/src/domain/analysisView/erlangBeamView.ts b/src/domain/analysisView/erlangBeamView.ts index d52f608e53..fe634f1bf0 100644 --- a/src/domain/analysisView/erlangBeamView.ts +++ b/src/domain/analysisView/erlangBeamView.ts @@ -111,10 +111,6 @@ export const erlangBeamSummaryDeliveryShape = { detail: z.literal("summary"), view: erlangBeamViewRequestSchema.exactOptional(), }; -export const erlangBeamDeliverySchema = z.discriminatedUnion("detail", [ - z.strictObject(erlangBeamCompleteDeliveryShape), - z.strictObject(erlangBeamSummaryDeliveryShape), -]); const readReport = createAnalysisViewResultReader(erlangBeamSchema); type Table = "atoms" | "exports" | "imports" | "lambdas" | "chunks"; type Row = NonNullable[number]; diff --git a/src/domain/analysisView/pythonPycView.ts b/src/domain/analysisView/pythonPycView.ts index 8676db842e..5b21fc73b1 100644 --- a/src/domain/analysisView/pythonPycView.ts +++ b/src/domain/analysisView/pythonPycView.ts @@ -40,10 +40,6 @@ export const pythonPycSummaryDeliveryShape = { detail: z.literal("summary"), view: pythonPycViewRequestSchema.exactOptional(), }; -export const pythonPycDeliverySchema = z.discriminatedUnion("detail", [ - z.strictObject(pythonPycCompleteDeliveryShape), - z.strictObject(pythonPycSummaryDeliveryShape), -]); export const pythonPycViewItemsSchema = z.array( code.partial().extend({ source_index: z.int().nonnegative() }), diff --git a/tests/boundary/cli/cliContractCommandParity.test.ts b/tests/boundary/cli/cliContractCommandParity.test.ts index 46be93a359..c03b72fe1f 100644 --- a/tests/boundary/cli/cliContractCommandParity.test.ts +++ b/tests/boundary/cli/cliContractCommandParity.test.ts @@ -134,7 +134,38 @@ const registeredField = ( }; const contractShape = (description: ContractCliDescription) => - description.contract.inputSchema.shape as Record; + description.fields; + +/** A caller-relative target, or the first probe the positional admits. */ +const positionalValue = ( + description: ContractCliDescription, + parameter: ContractCliParameter, +): unknown => + parameter.callerPath + ? "rel/target" + : probes(contractShape(description)[parameter.field]!).find( + (value) => + value !== undefined && + admits(registeredField(description, parameter), value), + ); + +/** Parsed option values plus admitted positionals. */ +const argvValues = ( + description: ContractCliDescription, + options: Readonly>, +): Record => ({ + ...(registered.get(description.command)!.options as z.ZodObject).parse( + options, + ), + ...Object.fromEntries( + description.parameters + .filter(({ positional }) => positional) + .map((parameter) => [ + parameter.key, + positionalValue(description, parameter), + ]), + ), +}); const each = descriptions.map( (description) => [description.command, description] as const, @@ -150,6 +181,13 @@ describe("CLI commands projected from tool contracts", () => { "inspect-devicetree", "inspect-go-binary", "inspect-v8-code-cache", + "inspect-rust-binary", + "inspect-python-pyc", + "inspect-erlang-beam", + "inspect-model-artifact", + "inspect-apk-signature", + "inspect-desktop-application", + "inspect-evm-interface", ]), ); }); @@ -202,6 +240,12 @@ describe("CLI commands projected from tool contracts", () => { expect(admits(contractField, "rel/x"), parameter.field).toBe(false); expect(admits(contractField, absoluteCallerPath("rel/x"))).toBe(true); } + if (parameter.viewSelection) { + // The view is JSON text decoded when the command runs; see below. + for (const value of [undefined, "", "{", "{}"]) + expect(admits(cliField, value), parameter.key).toBe(true); + continue; + } for (const value of probes(contractField)) { const expected = expectedAdmission(parameter, contractField, value); if (admits(cliField, value) !== expected) @@ -233,15 +277,7 @@ describe("CLI commands projected from tool contracts", () => { it.each(each)( "%s maps parsed argv to an admitted contract input", (_command, description) => { - const command = registered.get(description.command)!; - const values = { - ...(command.options as z.ZodObject).parse({}), - ...Object.fromEntries( - description.parameters - .filter(({ positional }) => positional) - .map(({ key }) => [key, "rel/target"]), - ), - }; + const values = argvValues(description, {}); const input = description.toContractInput(values); expect(description.contract.inputSchema.safeParse(input).error).toBe( undefined, @@ -287,3 +323,98 @@ describe("CLI commands projected from tool contracts", () => { ).toThrow("probe input keyword not has no CLI projection"); }); }); + +const delivering = each.filter( + ([, description]) => description.delivery !== undefined, +); + +describe("Evidence delivery of contract commands", () => { + it.each(delivering)( + "%s refuses summary delivery without --evidence_output before analysis", + (_command, description) => { + const refused = description.delivery!( + argvValues(description, { detail: "summary" }), + ); + expect(refused.ok).toBe(false); + if (!refused.ok) + expect(refused.error.issues.map(({ path }) => path)).toEqual([ + ["evidence_output"], + ]); + expect( + description.delivery!( + argvValues(description, { + detail: "summary", + evidence_output: "out.json", + }), + ).ok, + ).toBe(true); + }, + ); + + it.each( + delivering.filter(([, description]) => + description.parameters.some(({ viewSelection }) => viewSelection), + ), + )( + "%s decodes --view when it runs and admits what its contract admits", + (_command, description) => { + const view = description.parameters.find( + ({ viewSelection }) => viewSelection, + )!; + const malformed = description.delivery!( + argvValues(description, { + detail: "summary", + evidence_output: "out.json", + [view.key]: "{", + }), + ); + expect(malformed.ok).toBe(false); + if (!malformed.ok) + expect(malformed.error.issues).toEqual([ + expect.objectContaining({ path: ["view"], reason: "invalid_format" }), + ]); + const mismatches: string[] = []; + for (const detail of ["complete", "summary"] as const) + for (const text of ["{}", "[]", "null", '{"unknown":1}']) { + const values = argvValues(description, { + detail, + evidence_output: "out.json", + [view.key]: text, + }); + const expected = description.contract.inputSchema.safeParse({ + ...description.toContractInput(values), + detail, + view: JSON.parse(text) as unknown, + }).success; + if (description.delivery!(values).ok !== expected) + mismatches.push(`${detail} ${text} expected ${String(expected)}`); + } + expect(mismatches).toEqual([]); + }, + ); + + it("refuses a union input whose branches differ outside the delivery", () => { + const inputSchema = z.discriminatedUnion("detail", [ + z.object({ + detail: z.literal("complete").optional(), + limit: z.number().optional(), + }), + z.object({ + detail: z.literal("summary"), + limit: z.number().max(1).optional(), + }), + ]); + expect(() => + describeContractCommand( + { name: "probe", inputSchema }, + { command: "probe", description: "Probe", evidenceDelivery: true }, + ), + ).toThrow("probe.limit differs between input branches"); + expect(() => + describeContractCommand( + { name: "probe", inputSchema }, + { command: "probe", description: "Probe" }, + ), + ).toThrow("probe.detail differs between input branches"); + }); +});