diff --git a/src/application/ReconstructionCoverageService.ts b/src/application/ReconstructionCoverageService.ts index c19cef03a1..8bea27fb04 100644 --- a/src/application/ReconstructionCoverageService.ts +++ b/src/application/ReconstructionCoverageService.ts @@ -1,7 +1,6 @@ import { parseAnalysisInput } from "./AnalysisInput.js"; -import { z } from "zod"; -import { reconstructionCoverageEvaluationInputSchema } from "../domain/reconstructionCoverageInput.js"; +import { reconstructionCoverageRequestSchema } from "../domain/reconstructionCoverageInput.js"; import type { AnalysisError } from "../domain/analysisErrorBase.js"; import { evaluateParsedReconstructionClosure, @@ -11,17 +10,17 @@ import { import type { Result } from "../domain/result.js"; const OPERATION = "evaluate_reconstruction_coverage"; -const requestEnvelopeSchema = - reconstructionCoverageEvaluationInputSchema.extend({ - coverage: z.unknown(), - }); /** Evaluate inline reconstruction coverage against a named boundary. */ export const evaluateReconstructionCoverage = ( rawInput: unknown, nowEpochMs = Date.now(), ): Result => { - const parsed = parseAnalysisInput(OPERATION, rawInput, requestEnvelopeSchema); + const parsed = parseAnalysisInput( + OPERATION, + rawInput, + reconstructionCoverageRequestSchema, + ); if (!parsed.ok) return parsed; const coverage = readReconstructionCoverageData(parsed.value.coverage, [ "coverage", diff --git a/src/application/ReconstructionObligationLedgerRequest.ts b/src/application/ReconstructionObligationLedgerRequest.ts index 73f25656b8..ecdc5833a7 100644 --- a/src/application/ReconstructionObligationLedgerRequest.ts +++ b/src/application/ReconstructionObligationLedgerRequest.ts @@ -16,9 +16,11 @@ import { import { err, ok, type Result } from "../domain/result.js"; const OPERATION = "build_reconstruction_obligation_ledger"; -const requestSchema = reconstructionObligationLedgerInputSchema.extend({ - evidence_bundle: z.unknown(), -}); +/** The ledger parser admits the bundle itself after this envelope. */ +export const reconstructionObligationLedgerRequestSchema = + reconstructionObligationLedgerInputSchema.extend({ + evidence_bundle: z.unknown(), + }); /** Authenticated bundle and its single request-owned record index. */ export interface ReconstructionObligationSources { @@ -36,7 +38,11 @@ export interface ResolvedReconstructionObligationRequest { export const resolveReconstructionObligationLedgerRequest = ( input: unknown, ): Result => { - const frame = parseAnalysisInput(OPERATION, input, requestSchema); + const frame = parseAnalysisInput( + OPERATION, + input, + reconstructionObligationLedgerRequestSchema, + ); if (!frame.ok) return frame; const bundle = readEvidenceBundle(frame.value.evidence_bundle, { operation: OPERATION, diff --git a/src/cli/analysisViewCommands.ts b/src/cli/analysisViewCommands.ts index 90f8b86947..d28fd097a0 100644 --- a/src/cli/analysisViewCommands.ts +++ b/src/cli/analysisViewCommands.ts @@ -18,6 +18,13 @@ import type { CliInstance } from "./types.js"; import { localPathStringSchema } from "../domain/localPath.js"; import { withCommandCancellation } from "./commandCancellation.js"; import { finishAnalysisCommand } from "./finishAnalysisCommand.js"; +import { jsonInputArgument } from "./jsonInputArgument.js"; +import { inspectAnalysisViewInputSchema } from "../domain/analysisView/analysisViewRequest.js"; + +const inspectEvidenceBundleCliInputSchema = + getEvidenceBundleInputSchema.safeExtend({ + path: importEvidenceBundleInputSchema.shape.path, + }); /** Project selected views of completed analysis Evidence through the CLI. */ export const registerAnalysisViewCommands = ( @@ -28,11 +35,10 @@ export const registerAnalysisViewCommands = ( description: "Inspect a local Evidence bundle completely, as discovery metadata, as a page of selected complete records, or as direct-link and retained-closure membership.", args: z.object({ - inputJson: z - .string() - .describe( - "JSON or JSON file with absolute path, detail, optional record filters, records or links offset/limit, and links traversal/link_status", - ), + inputJson: jsonInputArgument( + "JSON or JSON file with absolute path, detail, optional record filters, records or links offset/limit, and links traversal/link_status", + inspectEvidenceBundleCliInputSchema, + ), }), run: ({ args }) => withCommandCancellation((signal) => @@ -43,10 +49,9 @@ export const registerAnalysisViewCommands = ( signal, ); if (!input.ok) return input.error; - const schema = getEvidenceBundleInputSchema.safeExtend({ - path: importEvidenceBundleInputSchema.shape.path, - }); - const parsed = schema.safeParse(input.value); + const parsed = inspectEvidenceBundleCliInputSchema.safeParse( + input.value, + ); if (!parsed.success) return { error: "Evidence bundle inspection failed", @@ -75,7 +80,10 @@ export const registerAnalysisViewCommands = ( description: "Project a selected view of completed Evidence JSON for a supported analysis operation. For inventory rows, use --format csv or --format tsv with --filter-output normalized_result.items and --columns logical_path,artifact_id (if those fields are included in the view).", args: z.object({ - inputJson: z.string().describe("Inline workflow JSON or JSON file path"), + inputJson: jsonInputArgument( + "Inline workflow JSON or JSON file path", + inspectAnalysisViewInputSchema, + ), }), options: z.object({ evidenceBundle: localPathStringSchema diff --git a/src/cli/applicationCommands.ts b/src/cli/applicationCommands.ts index 2ee2d05c5d..4bd145a2d5 100644 --- a/src/cli/applicationCommands.ts +++ b/src/cli/applicationCommands.ts @@ -13,6 +13,17 @@ import type { Logger } from "pino"; import { registerSourceToBundleCommand } from "./sourceToBundleCommand.js"; import { registerStaticObservedEndpointsCommand } from "./staticObservedEndpointsCommand.js"; import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js"; +import { getEvidenceInputContract } from "../domain/evidenceInputContract.js"; +import { applicationInventoryProjectionInputSchema } from "../domain/mobileApplicationGraphProjection.js"; +import { + compareApplicationVersionsRequestSchema, + compareJavaScriptExportShapesRequestSchema, + traceApplicationFeatureRequestSchema, + traceJavaScriptSemanticsRequestSchema, +} from "../contracts/javascript/applicationWorkflowInputContracts.js"; +import { reconstructionCoverageRequestSchema } from "../domain/reconstructionCoverageInput.js"; +import { reconstructionObligationLedgerRequestSchema } from "../application/ReconstructionObligationLedgerRequest.js"; +import { jsonInputArgument } from "./jsonInputArgument.js"; import type { EvidenceLookup } from "../application/EvidenceInputResolver.js"; import type { Evidence } from "../domain/evidence.js"; import type { Result } from "../domain/result.js"; @@ -24,6 +35,23 @@ import { withCommandCancellation } from "./commandCancellation.js"; type CliInstance = ReturnType; +const traceApplicationFeatureDocumentSchema = getEvidenceInputContract( + traceApplicationFeatureRequestSchema, +).frameSchema; +const traceJavaScriptSemanticsDocumentSchema = getEvidenceInputContract( + traceJavaScriptSemanticsRequestSchema, +).frameSchema; +const compareApplicationVersionsDocumentSchema = getEvidenceInputContract( + compareApplicationVersionsRequestSchema, +).frameSchema; +const compareJavaScriptExportShapesDocumentSchema = getEvidenceInputContract( + compareJavaScriptExportShapesRequestSchema, +).frameSchema; +const applicationInventoryDocumentSchema = getEvidenceInputContract( + applicationInventoryProjectionInputSchema, +).frameSchema; +const workflowJsonDescription = "Inline workflow JSON or JSON file path"; + /** Register CLI equivalents of provider-neutral application graph workflows. */ export const registerApplicationCommands = ( cli: ReturnType, @@ -34,6 +62,7 @@ export const registerApplicationCommands = ( logger, name: CLI_COMMANDS.traceApplicationFeature, operation: "trace_application_feature", + documentSchema: traceApplicationFeatureDocumentSchema, description: "Trace a typed seed through authenticated application Evidence JSON", workflow: async (input, lookup, signal) => { @@ -48,6 +77,7 @@ export const registerApplicationCommands = ( logger, name: CLI_COMMANDS.traceJavaScriptSemantics, operation: "trace_javascript_semantics", + documentSchema: traceJavaScriptSemanticsDocumentSchema, description: "Trace bounded static semantic relations through authenticated application Evidence", workflow: async (input, lookup, signal) => { @@ -64,6 +94,7 @@ export const registerApplicationCommands = ( logger, name: CLI_COMMANDS.compareApplicationVersions, operation: "compare_application_versions", + documentSchema: compareApplicationVersionsDocumentSchema, description: "Compare two authenticated JavaScript Application Graph versions", workflow: async (input, lookup, signal) => { @@ -85,6 +116,7 @@ export const registerApplicationCommands = ( logger, name: CLI_COMMANDS.compareJavaScriptExportShapes, operation: "compare_javascript_export_shapes", + documentSchema: compareJavaScriptExportShapesDocumentSchema, description: "Compare exact static JavaScript export return shapes without execution", workflow: async (input, lookup, signal) => { @@ -103,6 +135,7 @@ export const registerApplicationCommands = ( logger, name: CLI_COMMANDS.projectAndroidApplicationGraph, operation: "project_android_application_graph", + documentSchema: applicationInventoryDocumentSchema, description: "Project authenticated APK inventory Evidence into an Android application graph", workflow: async (input, lookup, signal) => { @@ -119,6 +152,7 @@ export const registerApplicationCommands = ( logger, name: CLI_COMMANDS.projectAppleApplicationGraph, operation: "project_apple_application_graph", + documentSchema: applicationInventoryDocumentSchema, description: "Project authenticated IPA or macOS app inventory Evidence into an Apple application graph", workflow: async (input, lookup, signal) => { @@ -133,6 +167,7 @@ export const registerApplicationCommands = ( logger, name: CLI_COMMANDS.projectHarmonyApplicationGraph, operation: "project_harmony_application_graph", + documentSchema: applicationInventoryDocumentSchema, description: "Project authenticated HarmonyOS HAP, HSP, or App Pack inventory Evidence into an application graph", workflow: async (input, lookup, signal) => { @@ -157,6 +192,7 @@ const registerObligationLedgerCommand = ( description: "Generate a deterministic Evidence-backed reconstruction obligation ledger page", inputSchema: z.unknown(), + documentSchema: reconstructionObligationLedgerRequestSchema, workflow: async (input) => { const { buildReconstructionObligationLedgerEvidence } = await import("../application/ReconstructionObligationLedgerService.js"); @@ -171,6 +207,7 @@ const registerCoverageCommand = (cli: CliInstance, logger: Logger): void => name: CLI_COMMANDS.evaluateReconstructionCoverage, description: "Evaluate inline fail-closed reconstruction coverage", inputSchema: z.unknown(), + documentSchema: reconstructionCoverageRequestSchema, workflow: async (input) => { const { evaluateReconstructionCoverage } = await import("../application/ReconstructionCoverageService.js"); @@ -184,6 +221,8 @@ interface JsonCommandOptions { readonly name: string; readonly description: string; readonly inputSchema: Schema; + /** Contract advertised by `--schema`. Runtime parsing stays `inputSchema`. */ + readonly documentSchema?: z.ZodType; readonly workflow: ( input: z.output, ) => Promise< @@ -198,12 +237,16 @@ const registerJsonCommand = ({ name, description, inputSchema, + documentSchema, workflow, }: JsonCommandOptions): void => { cli.command(name, { description, args: z.object({ - inputJson: z.string().describe("Inline workflow JSON or JSON file path"), + inputJson: jsonInputArgument( + workflowJsonDescription, + documentSchema ?? inputSchema, + ), }), run: ({ args }) => logCliCommand(logger, name, async () => { @@ -239,6 +282,7 @@ const registerEvidenceJsonCommand = ({ name, operation, description, + documentSchema, workflow, }: { readonly cli: CliInstance; @@ -246,6 +290,7 @@ const registerEvidenceJsonCommand = ({ readonly name: string; readonly operation: string; readonly description: string; + readonly documentSchema: z.ZodType; readonly workflow: ( input: unknown, lookup: EvidenceLookup | undefined, @@ -255,7 +300,7 @@ const registerEvidenceJsonCommand = ({ cli.command(name, { description, args: z.object({ - inputJson: z.string().describe("Inline workflow JSON or JSON file path"), + inputJson: jsonInputArgument(workflowJsonDescription, documentSchema), }), options: z.object({ evidenceBundle: evidenceBundleOption }), run: ({ args, options }) => diff --git a/src/cli/artifactComparisonCommand.ts b/src/cli/artifactComparisonCommand.ts index bdbb47b994..0149d2aa5a 100644 --- a/src/cli/artifactComparisonCommand.ts +++ b/src/cli/artifactComparisonCommand.ts @@ -6,12 +6,14 @@ import { resolveArtifactComparisonSources, } from "../application/artifacts/ArtifactComparisonEvidence.js"; import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; +import { artifactComparisonInputSchema } from "../contracts/artifactComparisonContracts.js"; import { CLI_COMMANDS } from "../cliCommandNames.js"; import { parseCliJsonInput, resolveCliJsonPaths } from "../cliJsonInput.js"; import { logCliCommand } from "../cliLogging.js"; import { ARTIFACT_COMPARISON_PROVIDER } from "../domain/comparisonEvidenceIdentity.js"; import { withCommandCancellation } from "./commandCancellation.js"; import { finishAnalysisCommand } from "./finishAnalysisCommand.js"; +import { jsonInputArgument } from "./jsonInputArgument.js"; import type { CliInstance } from "./types.js"; const OPERATION = "compare_artifacts"; @@ -26,7 +28,10 @@ export const registerArtifactComparisonCommand = ( description: 'Compare two artifact inventories, including FIT and legacy uImage firmware components, named as {left, right}: complete Evidence or {kind:"evidence-file",path,evidence_id?}. A one-shot command retains no session Evidence for retained-evidence references.', args: z.object({ - inputJson: z.string().describe("Inline workflow JSON or JSON file path"), + inputJson: jsonInputArgument( + "Inline workflow JSON or JSON file path", + artifactComparisonInputSchema, + ), }), run: ({ args }) => withCommandCancellation((signal) => diff --git a/src/cli/electronCommands.ts b/src/cli/electronCommands.ts index aaa177fef1..acd3505317 100644 --- a/src/cli/electronCommands.ts +++ b/src/cli/electronCommands.ts @@ -16,6 +16,8 @@ import { listElectronTargetsInputSchema, } from "../domain/javascript/electronObservation.js"; import { electronActiveObservationInputSchema } from "../domain/javascript/electronActiveObservation.js"; +import { reconcileJavaScriptRuntimeRequestSchema } from "../contracts/javascript/applicationWorkflowInputContracts.js"; +import { getEvidenceInputContract } from "../domain/evidenceInputContract.js"; import { AnalysisInputError } from "../domain/analysisErrorCore.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import { projectInputIssues } from "../domain/inputIssueProjection.js"; @@ -42,6 +44,7 @@ import { evidenceBundleOption, runEvidenceInputCommand, } from "./evidenceInputCommand.js"; +import { jsonInputArgument } from "./jsonInputArgument.js"; /** Register CLI equivalents of the Electron MCP tools. */ export const registerElectronCommands = ( @@ -116,11 +119,10 @@ const registerElectronActiveCommand = ( cli.command(CLI_COMMANDS.captureElectronScenario, { description: "Run one owned Electron scenario with cancellable actions", args: z.object({ - inputJson: z - .string() - .describe( - "Inline JSON or a JSON file matching capture_electron_scenario", - ), + inputJson: jsonInputArgument( + "Inline JSON or a JSON file matching capture_electron_scenario", + electronActiveObservationInputSchema, + ), }), run: ({ args }) => withCommandCancellation((signal) => @@ -184,11 +186,11 @@ const registerJavaScriptRuntimeReconciliationCommand = ( description: "Reconcile static application and passive runtime Evidence JSON", args: z.object({ - inputJson: z - .string() - .describe( - "Inline JSON or a JSON file path matching reconcile_javascript_runtime", - ), + inputJson: jsonInputArgument( + "Inline JSON or a JSON file path matching reconcile_javascript_runtime", + getEvidenceInputContract(reconcileJavaScriptRuntimeRequestSchema) + .frameSchema, + ), }), options: z.object({ evidenceBundle: evidenceBundleOption }), run: ({ args, options }) => diff --git a/src/cli/jsonInputArgument.ts b/src/cli/jsonInputArgument.ts new file mode 100644 index 0000000000..21c265e9a9 --- /dev/null +++ b/src/cli/jsonInputArgument.ts @@ -0,0 +1,73 @@ +import { z } from "incur"; + +const JSON_SCHEMA_DIALECT = "$schema"; +const wrapperTypes = new Set([ + "optional", + "nonoptional", + "nullable", + "default", + "prefault", + "readonly", + "catch", +]); + +const innerSchema = (schema: z.ZodType): z.ZodType | undefined => { + if (!("unwrap" in schema) || typeof schema.unwrap !== "function") + return undefined; + const inner: unknown = schema.unwrap(); + return inner instanceof z.ZodType ? inner : undefined; +}; + +/** Skip wrappers so `unknown` and `any` are recognized as the whole document. */ +const documentType = (schema: z.ZodType): string => { + let current = schema; + for (;;) { + const type = current._zod.def.type; + if (!wrapperTypes.has(type)) return type; + const inner = innerSchema(current); + if (inner === undefined) return type; + current = inner; + } +}; + +/** + * JSON Schema for one positional JSON document. + * + * `{}` is only returned for a document the handler parses as `unknown` or + * `any`. Any other empty conversion is omitted: a custom or unrepresentable + * document must not be advertised as accepting every value. Input mode keeps + * the type a transform accepts, so a string transform stays a string. + */ +export const jsonInputContentSchema = ( + schema: z.ZodType, +): Record | undefined => { + let converted: Record; + try { + converted = z.toJSONSchema(schema, { + io: "input", + unrepresentable: "any", + }) as Record; + } catch { + return undefined; + } + const content = { ...converted }; + delete content[JSON_SCHEMA_DIALECT]; + if (Object.keys(content).length > 0) return content; + const type = documentType(schema); + return type === "unknown" || type === "any" ? {} : undefined; +}; + +/** Keep the positional string and attach the document contract Incur can see. */ +export const jsonInputArgument = ( + description: string, + schema: z.ZodType, +): z.ZodString => { + const argument = z.string().describe(description); + const contentSchema = jsonInputContentSchema(schema); + return contentSchema === undefined + ? argument.meta({ contentMediaType: "application/json" }) + : argument.meta({ + contentMediaType: "application/json", + contentSchema, + }); +}; diff --git a/src/cli/managedCommands.ts b/src/cli/managedCommands.ts index a0835cfc4c..f6be42f30a 100644 --- a/src/cli/managedCommands.ts +++ b/src/cli/managedCommands.ts @@ -19,6 +19,10 @@ import type { Logger } from "pino"; import type { CliInstance } from "./types.js"; import { registerProjectManagedApplicationGraph } from "./managedProjectGraphCommand.js"; import { managedMembersInputSchema } from "../contracts/managed/managedToolContracts.js"; +import { managedReconstructionImportRequestSchema } from "../domain/managed/managedReconstruction.js"; +import { managedNativeVerificationRequestSchema } from "../domain/managed/managedNativeVerificationSchemas.js"; +import { getEvidenceInputContract } from "../domain/evidenceInputContract.js"; +import { jsonInputArgument } from "./jsonInputArgument.js"; import { localPathStringSchema } from "../domain/localPath.js"; import { withCommandCancellation } from "./commandCancellation.js"; import { finishAnalysisCommand } from "./finishAnalysisCommand.js"; @@ -240,9 +244,11 @@ const registerImportManagedReconstruction = ( description: "Import decompiler-produced managed reconstruction against exact static member evidence", args: z.object({ - inputJson: z - .string() - .describe("Inline managed reconstruction JSON or JSON file path"), + inputJson: jsonInputArgument( + "Inline managed reconstruction JSON or JSON file path", + getEvidenceInputContract(managedReconstructionImportRequestSchema) + .frameSchema, + ), }), options: z.object({ evidenceBundle: evidenceBundleOption }), run: ({ args, options }) => @@ -278,9 +284,11 @@ const registerVerifyManagedNativeBoundaries = ( description: "Verify managed P/Invoke declarations against authenticated native Evidence", args: z.object({ - inputJson: z - .string() - .describe("Inline managed/native verification JSON or JSON file path"), + inputJson: jsonInputArgument( + "Inline managed/native verification JSON or JSON file path", + getEvidenceInputContract(managedNativeVerificationRequestSchema) + .frameSchema, + ), }), options: z.object({ evidenceBundle: evidenceBundleOption }), run: ({ args, options }) => diff --git a/src/cli/managedProjectGraphCommand.ts b/src/cli/managedProjectGraphCommand.ts index 6865b9c3dd..8e1838f2e4 100644 --- a/src/cli/managedProjectGraphCommand.ts +++ b/src/cli/managedProjectGraphCommand.ts @@ -11,6 +11,9 @@ import { runEvidenceInputCommand, } from "./evidenceInputCommand.js"; import type { Logger } from "pino"; +import { managedApplicationGraphReferenceInputSchema } from "../domain/managed/managedApplicationGraph.js"; +import { getEvidenceInputContract } from "../domain/evidenceInputContract.js"; +import { jsonInputArgument } from "./jsonInputArgument.js"; import type { CliInstance } from "./types.js"; /** Register managed static-Evidence application graph projection. */ @@ -22,9 +25,11 @@ export const registerProjectManagedApplicationGraph = ( description: "Project authenticated managed static Evidence into an application graph", args: z.object({ - inputJson: z - .string() - .describe("Inline managed application graph JSON or JSON file path"), + inputJson: jsonInputArgument( + "Inline managed application graph JSON or JSON file path", + getEvidenceInputContract(managedApplicationGraphReferenceInputSchema) + .frameSchema, + ), }), options: z.object({ evidenceBundle: evidenceBundleOption }), run: ({ args, options }) => diff --git a/src/cli/nativeCallCommands.ts b/src/cli/nativeCallCommands.ts index 35916d8a78..b7055cc7eb 100644 --- a/src/cli/nativeCallCommands.ts +++ b/src/cli/nativeCallCommands.ts @@ -12,6 +12,7 @@ import { localPathStringSchema } from "../domain/localPath.js"; import { nativeCallObservationInputSchema } from "../domain/native/nativeCallObservation.js"; import type { Logger } from "pino"; import type { CliInstance } from "./types.js"; +import { jsonInputArgument } from "./jsonInputArgument.js"; const OPERATION = "observe_native_calls"; @@ -26,11 +27,10 @@ export const registerNativeCallCommands = ( "Launch a Mach-O under LLDB and record entries into selected functions or Objective-C methods", args: z.object({ path: localPathStringSchema.describe("Mach-O or app path"), - inputJson: z - .string() - .describe( - "Inline JSON or a JSON file path: breakpoints, arguments, environment, duration_ms, max_events, argument_registers, backtrace_frames, capture_limits", - ), + inputJson: jsonInputArgument( + "Inline JSON or a JSON file path: breakpoints, arguments, environment, duration_ms, max_events, argument_registers, backtrace_frames, capture_limits", + nativeCallObservationInputSchema, + ), }), run: ({ args }) => logCliCommand(logger, CLI_COMMANDS.observeNativeCalls, async () => { diff --git a/src/cli/sourceToBundleCommand.ts b/src/cli/sourceToBundleCommand.ts index 2d4d01a787..4a71c947be 100644 --- a/src/cli/sourceToBundleCommand.ts +++ b/src/cli/sourceToBundleCommand.ts @@ -5,7 +5,9 @@ import { CLI_COMMANDS } from "../cliCommandNames.js"; import { parseCliJsonInput } from "../cliJsonInput.js"; import { logCliCommand } from "../cliLogging.js"; import { compareSourceToBundleCliInputSchema } from "../contracts/javascript/applicationWorkflowInputContracts.js"; +import { getEvidenceInputContract } from "../domain/evidenceInputContract.js"; import { withCommandCancellation } from "./commandCancellation.js"; +import { jsonInputArgument } from "./jsonInputArgument.js"; import { evidenceBundleOption, runEvidenceInputCommand, @@ -21,7 +23,11 @@ export const registerSourceToBundleCommand = ( description: "Compare historical source (inline graph or reference {kind:file,path}) with authenticated application Evidence. Save the complete JSON to query with inspect-analysis-view.", args: z.object({ - inputJson: z.string().describe("Inline workflow JSON or JSON file path"), + inputJson: jsonInputArgument( + "Inline workflow JSON or JSON file path", + getEvidenceInputContract(compareSourceToBundleCliInputSchema) + .frameSchema, + ), }), options: z.object({ evidenceBundle: evidenceBundleOption }), run: ({ args, options }) => diff --git a/src/cli/staticObservedEndpointsCommand.ts b/src/cli/staticObservedEndpointsCommand.ts index 849d0bb501..02445ec7ff 100644 --- a/src/cli/staticObservedEndpointsCommand.ts +++ b/src/cli/staticObservedEndpointsCommand.ts @@ -6,6 +6,7 @@ import { CLI_COMMANDS } from "../cliCommandNames.js"; import { parseCliJsonInput } from "../cliJsonInput.js"; import { logCliCommand } from "../cliLogging.js"; import { compareStaticObservedEndpointsFactsSchema } from "../contracts/javascript/applicationWorkflowInputContracts.js"; +import { jsonInputArgument } from "./jsonInputArgument.js"; import { getEvidenceInputContract } from "../domain/evidenceInputContract.js"; import { endpointComparisonViewRequestSchema } from "../domain/analysisView/endpointComparisonView.js"; import { parseAnalysisInput } from "../application/AnalysisInput.js"; @@ -23,6 +24,9 @@ import { } from "./analysisEvidenceDelivery.js"; const operation = "compare_static_and_observed_endpoints"; +const comparisonDocumentSchema = getEvidenceInputContract( + compareStaticObservedEndpointsFactsSchema, +).frameSchema; const deliverySchema = z .object({ detail: analysisEvidenceDeliveryOptions.detail, @@ -73,8 +77,7 @@ const runComparison = async ( const input = parseAnalysisInput( operation, raw.value, - getEvidenceInputContract(compareStaticObservedEndpointsFactsSchema) - .frameSchema, + comparisonDocumentSchema, signal, ); if (!input.ok) return projectAnalysisError(input.error); @@ -112,11 +115,10 @@ export const registerStaticObservedEndpointsCommand = ( description: "Compare static JavaScript endpoints and HAR Evidence JSON offline; --detail summary with --evidence_output saves full facts for selected inspect-analysis-view pages", args: z.object({ - inputJson: z - .string() - .describe( - "Inline JSON or file containing application, capture and optional origin", - ), + inputJson: jsonInputArgument( + "Inline JSON or file containing application, capture and optional origin", + comparisonDocumentSchema, + ), }), options: commandOptions, run: ({ args, options }) => diff --git a/src/cliBrowserScenarioCommands.ts b/src/cliBrowserScenarioCommands.ts index 2f3ec96975..3f77b170dc 100644 --- a/src/cliBrowserScenarioCommands.ts +++ b/src/cliBrowserScenarioCommands.ts @@ -21,9 +21,12 @@ import { import { err, ok, type Result } from "./domain/result.js"; import type { BrowserScenarioCapturePort } from "./application/BrowserScenarioCapturePort.js"; import type { JsonValue } from "./domain/jsonValue.js"; +import { jsonInputArgument } from "./cli/jsonInputArgument.js"; import type { Logger } from "pino"; const OPERATION = "capture_browser_scenario"; +const browserScenarioJsonDescription = + "Inline browser scenario JSON, JSON file path, or '-' for stdin"; /** Register controlled browser execution and offline validation commands. */ export const registerBrowserScenarioCommands = ( @@ -40,11 +43,10 @@ export const registerBrowserScenarioCommands = ( description: "Run one bounded Playwright browser scenario and return Evidence", args: z.object({ - inputJson: z - .string() - .describe( - "Inline browser scenario JSON, JSON file path, or '-' for stdin", - ), + inputJson: jsonInputArgument( + browserScenarioJsonDescription, + browserScenarioSchema, + ), }), options: z.object(analysisEvidenceDeliveryOptions), run: ({ args, options: delivery }) => @@ -110,11 +112,10 @@ const registerBrowserScenarioValidationCommand = ( description: "Validate one browser scenario without launching or attaching to a browser", args: z.object({ - inputJson: z - .string() - .describe( - "Inline browser scenario JSON, JSON file path, or '-' for stdin", - ), + inputJson: jsonInputArgument( + browserScenarioJsonDescription, + browserScenarioSchema, + ), }), run: ({ args }) => withCommandCancellation((signal) => diff --git a/src/domain/reconstructionCoverageInput.ts b/src/domain/reconstructionCoverageInput.ts index f752f8530e..d7d04f20fb 100644 --- a/src/domain/reconstructionCoverageInput.ts +++ b/src/domain/reconstructionCoverageInput.ts @@ -9,3 +9,12 @@ export const reconstructionCoverageEvaluationInputSchema = z.strictObject({ coverage: reconstructionCoverageDataSchema, boundary_id: reconstructionBoundaryIdSchema, }); + +/** + * Envelope the coverage command parses before its reader authenticates + * `coverage`. The reader owns that document, so this field stays unknown. + */ +export const reconstructionCoverageRequestSchema = + reconstructionCoverageEvaluationInputSchema.extend({ + coverage: z.unknown(), + }); diff --git a/tests/boundary/cli/jsonInputSchema.test.ts b/tests/boundary/cli/jsonInputSchema.test.ts new file mode 100644 index 0000000000..d9697381fd --- /dev/null +++ b/tests/boundary/cli/jsonInputSchema.test.ts @@ -0,0 +1,390 @@ +import { beforeAll, expect, it } from "vitest"; +import { Ajv2020 } from "ajv/dist/2020.js"; +import type { AnySchema } from "ajv"; +import { z } from "zod"; + +import { createCli } from "../../../src/cli.js"; +import { CLI_COMMANDS } from "../../../src/cliCommandNames.js"; +import { artifactComparisonInputSchema } from "../../../src/contracts/artifactComparisonContracts.js"; +import { + compareApplicationVersionsRequestSchema, + compareJavaScriptExportShapesRequestSchema, + compareSourceToBundleCliInputSchema, + compareStaticObservedEndpointsFactsSchema, + reconcileJavaScriptRuntimeRequestSchema, + traceApplicationFeatureRequestSchema, + traceJavaScriptSemanticsRequestSchema, +} from "../../../src/contracts/javascript/applicationWorkflowInputContracts.js"; +import { + getEvidenceBundleInputSchema, + importEvidenceBundleInputSchema, +} from "../../../src/contracts/sessionToolSchemas.js"; +import { reconstructionCoverageRequestSchema } from "../../../src/domain/reconstructionCoverageInput.js"; +import { reconstructionObligationLedgerRequestSchema } from "../../../src/application/ReconstructionObligationLedgerRequest.js"; +import { inspectAnalysisViewInputSchema } from "../../../src/domain/analysisView/analysisViewRequest.js"; +import { browserScenarioSchema } from "../../../src/domain/browserScenario.js"; +import { getEvidenceInputContract } from "../../../src/domain/evidenceInputContract.js"; +import { electronActiveObservationInputSchema } from "../../../src/domain/javascript/electronActiveObservation.js"; +import { applicationInventoryProjectionInputSchema } from "../../../src/domain/mobileApplicationGraphProjection.js"; +import { managedApplicationGraphReferenceInputSchema } from "../../../src/domain/managed/managedApplicationGraph.js"; +import { managedNativeVerificationRequestSchema } from "../../../src/domain/managed/managedNativeVerificationSchemas.js"; +import { managedReconstructionImportRequestSchema } from "../../../src/domain/managed/managedReconstruction.js"; +import { nativeCallObservationInputSchema } from "../../../src/domain/native/nativeCallObservation.js"; +import { + jsonInputArgument, + jsonInputContentSchema, +} from "../../../src/cli/jsonInputArgument.js"; +import { visitRegisteredCommands } from "../../../src/cli/visitRegisteredCommands.js"; +import type { CliInstance } from "../../../src/cli/types.js"; + +const frame = (schema: z.ZodType): z.ZodType => + getEvidenceInputContract(schema).frameSchema; + +const inspectEvidenceBundleCliInputSchema = + getEvidenceBundleInputSchema.safeExtend({ + path: importEvidenceBundleInputSchema.shape.path, + }); +const inventoryDocumentSchema = frame( + applicationInventoryProjectionInputSchema, +); + +/** Every positional JSON document and the schema its handler parses. */ +const documents = [ + { + command: CLI_COMMANDS.traceApplicationFeature, + schema: frame(traceApplicationFeatureRequestSchema), + }, + { + command: CLI_COMMANDS.traceJavaScriptSemantics, + schema: frame(traceJavaScriptSemanticsRequestSchema), + }, + { + command: CLI_COMMANDS.compareApplicationVersions, + schema: frame(compareApplicationVersionsRequestSchema), + }, + { + command: CLI_COMMANDS.compareStaticAndObservedEndpoints, + schema: frame(compareStaticObservedEndpointsFactsSchema), + }, + { + command: CLI_COMMANDS.compareSourceToBundle, + schema: frame(compareSourceToBundleCliInputSchema), + }, + { + command: CLI_COMMANDS.compareJavaScriptExportShapes, + schema: frame(compareJavaScriptExportShapesRequestSchema), + }, + { + command: CLI_COMMANDS.buildReconstructionObligationLedger, + schema: reconstructionObligationLedgerRequestSchema, + }, + { + command: CLI_COMMANDS.evaluateReconstructionCoverage, + schema: reconstructionCoverageRequestSchema, + }, + { + command: CLI_COMMANDS.projectAndroidApplicationGraph, + schema: inventoryDocumentSchema, + }, + { + command: CLI_COMMANDS.projectAppleApplicationGraph, + schema: inventoryDocumentSchema, + }, + { + command: CLI_COMMANDS.projectHarmonyApplicationGraph, + schema: inventoryDocumentSchema, + }, + { + command: CLI_COMMANDS.inspectEvidenceBundle, + schema: inspectEvidenceBundleCliInputSchema, + }, + { + command: CLI_COMMANDS.inspectAnalysisView, + schema: inspectAnalysisViewInputSchema, + }, + { + command: CLI_COMMANDS.captureElectronScenario, + schema: electronActiveObservationInputSchema, + }, + { + command: CLI_COMMANDS.reconcileJavaScriptRuntime, + schema: frame(reconcileJavaScriptRuntimeRequestSchema), + }, + { + command: CLI_COMMANDS.observeNativeCalls, + schema: nativeCallObservationInputSchema, + }, + { + command: CLI_COMMANDS.importManagedReconstruction, + schema: frame(managedReconstructionImportRequestSchema), + }, + { + command: CLI_COMMANDS.verifyManagedNativeBoundaries, + schema: frame(managedNativeVerificationRequestSchema), + }, + { + command: CLI_COMMANDS.projectManagedApplicationGraph, + schema: frame(managedApplicationGraphReferenceInputSchema), + }, + { + command: CLI_COMMANDS.compareArtifacts, + schema: artifactComparisonInputSchema, + }, + { + command: CLI_COMMANDS.captureBrowserScenario, + schema: browserScenarioSchema, + }, + { + command: CLI_COMMANDS.validateBrowserScenario, + schema: browserScenarioSchema, + }, +] as const; + +interface InputJsonSchema { + readonly type?: unknown; + readonly description?: unknown; + readonly contentMediaType?: unknown; + readonly contentSchema?: { + readonly properties?: Readonly>; + readonly required?: readonly string[]; + }; +} + +const jsonInputCommands = (cli: CliInstance): string[] => { + const names: string[] = []; + visitRegisteredCommands(cli, (command, path) => { + if ( + command.args instanceof z.ZodObject && + Object.hasOwn(command.args.shape, "inputJson") + ) + names.push(path); + }); + return names; +}; + +let cli: ReturnType; + +beforeAll(() => { + cli = createCli({}); +}); + +const serve = async (argv: readonly string[]) => { + let stdout = ""; + let exitCode = 0; + await cli.serve([...argv], { + env: {}, + exit: (code) => { + exitCode = code; + }, + stdout: (text) => { + stdout += text; + }, + }); + return { stdout, exitCode }; +}; + +const advertisedInput = (stdout: string): InputJsonSchema => { + const document = JSON.parse(stdout) as { + args?: { + properties?: { inputJson?: InputJsonSchema }; + required?: readonly string[]; + }; + }; + expect(document.args?.required).toContain("inputJson"); + const inputJson = document.args?.properties?.inputJson; + expect(inputJson).toMatchObject({ + type: "string", + contentMediaType: "application/json", + }); + expect(typeof inputJson?.description).toBe("string"); + return inputJson as InputJsonSchema; +}; + +it("advertises the parsed document for every JSON-input command", async () => { + const ajv = new Ajv2020({ strict: false, validateFormats: false }); + expect(jsonInputCommands(cli).sort()).toEqual( + documents.map(({ command }) => command).sort(), + ); + for (const { command, schema } of documents) { + const served = await serve([command, "--schema", "--json"]); + expect(served.exitCode, served.stdout).toBe(0); + const inputJson = advertisedInput(served.stdout); + expect(inputJson.contentSchema, command).toBeDefined(); + expect(inputJson.contentSchema, command).toEqual( + jsonInputContentSchema(schema), + ); + expect(inputJson.contentSchema, command).not.toEqual({}); + expect( + () => ajv.compile(inputJson.contentSchema as AnySchema), + command, + ).not.toThrow(); + } +}); + +it("validates representative parsed documents with the advertised schema", async () => { + const input = advertisedInput( + ( + await serve([ + CLI_COMMANDS.evaluateReconstructionCoverage, + "--schema", + "--json", + ]) + ).stdout, + ); + const validate = new Ajv2020({ + strict: false, + validateFormats: false, + }).compile(input.contentSchema as AnySchema); + const accepted = { coverage: { arbitrary: true }, boundary_id: "main" }; + const rejected = [ + { coverage: {}, boundary_id: "" }, + { coverage: {} }, + { coverage: {}, boundary_id: "main", extra: true }, + ]; + expect(reconstructionCoverageRequestSchema.safeParse(accepted).success).toBe( + true, + ); + expect(validate(accepted)).toBe(true); + for (const document of rejected) { + expect( + reconstructionCoverageRequestSchema.safeParse(document).success, + ).toBe(false); + expect(validate(document)).toBe(false); + } +}); + +it("names the feature-trace, semantic-query, and browser-scenario fields", async () => { + const trace = advertisedInput( + (await serve([CLI_COMMANDS.traceApplicationFeature, "--schema", "--json"])) + .stdout, + ); + expect(trace.description).toBe("Inline workflow JSON or JSON file path"); + expect(Object.keys(trace.contentSchema?.properties ?? {})).toEqual([ + "native_observations", + "seed", + "semantic_page", + "direction", + "application", + ]); + expect(trace.contentSchema?.required).toEqual(["seed", "application"]); + + const semantic = advertisedInput( + (await serve([CLI_COMMANDS.traceJavaScriptSemantics, "--schema", "--json"])) + .stdout, + ); + expect(Object.keys(semantic.contentSchema?.properties ?? {})).toEqual([ + "application", + "query", + ]); + expect(semantic.contentSchema?.required).toEqual(["application", "query"]); + + const browser = advertisedInput( + (await serve([CLI_COMMANDS.captureBrowserScenario, "--schema", "--json"])) + .stdout, + ); + expect(browser.description).toBe( + "Inline browser scenario JSON, JSON file path, or '-' for stdin", + ); + expect(browser.contentSchema?.properties).toEqual( + expect.objectContaining({ + browser: expect.any(Object), + start_url: expect.any(Object), + actions: expect.any(Object), + }), + ); + expect(browser.contentSchema?.required).toEqual( + expect.arrayContaining(["browser", "start_url", "actions"]), + ); +}); + +it("keeps unknown envelope fields permissive and named fields constrained", async () => { + const coverage = advertisedInput( + ( + await serve([ + CLI_COMMANDS.evaluateReconstructionCoverage, + "--schema", + "--json", + ]) + ).stdout, + ); + expect(coverage.contentSchema?.properties?.coverage).toEqual({}); + // boundary_id is a stable identifier (#9919), so its pattern is advertised. + expect(coverage.contentSchema?.properties?.boundary_id).toMatchObject({ + type: "string", + pattern: expect.any(String), + }); + expect(coverage.contentSchema?.required).toEqual(["coverage", "boundary_id"]); + + const ledger = advertisedInput( + ( + await serve([ + CLI_COMMANDS.buildReconstructionObligationLedger, + "--schema", + "--json", + ]) + ).stdout, + ); + expect(ledger.contentSchema?.properties?.evidence_bundle).toEqual({}); + expect(ledger.contentSchema?.required).toEqual( + expect.arrayContaining([ + "evidence_bundle", + "reviewed_obligations", + "manifest", + ]), + ); +}); + +it("prints the nested document in the default schema format", async () => { + const served = await serve([ + CLI_COMMANDS.traceApplicationFeature, + "--schema", + ]); + expect(served.exitCode, served.stdout).toBe(0); + expect(served.stdout).toContain("contentMediaType: application/json"); + expect(served.stdout).toContain("contentSchema:"); + expect(served.stdout).toContain("seed:"); + expect(served.stdout).toContain("application:"); +}); + +it("uses an empty schema only for unknown or any documents", () => { + expect(jsonInputContentSchema(z.unknown())).toEqual({}); + expect(jsonInputContentSchema(z.any())).toEqual({}); + expect(jsonInputContentSchema(z.unknown().nonoptional())).toEqual({}); + + const custom = z.custom(); + expect(jsonInputContentSchema(custom)).toBeUndefined(); + const omitted = z.toJSONSchema(jsonInputArgument("payload", custom)); + expect(omitted).toMatchObject({ + type: "string", + description: "payload", + contentMediaType: "application/json", + }); + expect(omitted).not.toHaveProperty("contentSchema"); + + const exploding = z.object({ value: z.string() }); + exploding._zod.toJSONSchema = () => { + throw new Error("unconvertible"); + }; + expect(jsonInputContentSchema(exploding)).toBeUndefined(); + expect(() => + z.toJSONSchema(jsonInputArgument("payload", exploding)), + ).not.toThrow(); + + const query = z.toJSONSchema( + jsonInputArgument( + "Inline workflow JSON or JSON file path", + z.strictObject({ query: z.string() }), + ), + ); + expect(query).toMatchObject({ + type: "string", + description: "Inline workflow JSON or JSON file path", + contentMediaType: "application/json", + contentSchema: { + type: "object", + properties: { query: { type: "string" } }, + required: ["query"], + additionalProperties: false, + }, + }); +});