diff --git a/src/cli/cutterCommands.ts b/src/cli/cutterCommands.ts index 695cf846ff..d521d7c394 100644 --- a/src/cli/cutterCommands.ts +++ b/src/cli/cutterCommands.ts @@ -8,6 +8,7 @@ import { logCliCommand } from "../cliLogging.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import { withCommandCancellation } from "./commandCancellation.js"; import type { CliInstance } from "./types.js"; +import { cliIntegerArgument } from "./options.js"; /** Register commands that control an already-running Cutter instance. */ export const registerCutterCommands = ( @@ -33,11 +34,9 @@ export const registerCutterCommands = ( .string() .uuid() .describe("Session ID copied from list-cutter-sessions."), - expectedGeneration: z.coerce - .number() - .int() - .nonnegative() - .describe("Session generation returned by list-cutter-sessions."), + expectedGeneration: cliIntegerArgument(0).describe( + "Session generation returned by list-cutter-sessions.", + ), command: z .string() .min(1) diff --git a/src/cli/options.ts b/src/cli/options.ts index e964147077..5a66c9c457 100644 --- a/src/cli/options.ts +++ b/src/cli/options.ts @@ -1,3 +1,5 @@ +import { z } from "incur"; + import { executableFormatHintSchema, type ExecutableFormatHint, @@ -11,6 +13,13 @@ import { analysisProviderSelectorSchema } from "../contracts/providerSelection.j import type { AnalysisProviderSelector } from "../contracts/providerSelection.js"; import type { Logger } from "pino"; +/** Parse decimal integer tokens before Number coercion can erase their syntax. */ +export const cliIntegerArgument = (min: number) => + z + .string() + .regex(/^[0-9]+$/u, "Expected a decimal integer") + .pipe(z.coerce.number().safe().min(min)); + export const providerSelectionOption = analysisProviderSelectorSchema .optional() .describe( diff --git a/src/cli/webModuleCommands.ts b/src/cli/webModuleCommands.ts index 0e58ea8041..837a4a7bdd 100644 --- a/src/cli/webModuleCommands.ts +++ b/src/cli/webModuleCommands.ts @@ -6,6 +6,7 @@ import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import { webModuleTraceInputSchema } from "../domain/webModuleTrace.js"; import type { Logger } from "pino"; import type { CliInstance } from "./types.js"; +import { cliIntegerArgument } from "./options.js"; import { withCommandCancellation } from "./commandCancellation.js"; const importMapInputSchema = @@ -23,11 +24,9 @@ export const registerWebModuleCommands = ( "Trace one captured script's native imports to URLs and captured source candidates", args: z.object({ manifestPath: webModuleTraceInputSchema.shape.manifest_path, - scriptIndex: z.coerce - .number() - .int() - .min(0) - .describe("Zero-based manifest script index"), + scriptIndex: cliIntegerArgument(0).describe( + "Zero-based manifest script index", + ), }), options: z.object({ importerUrl: webModuleTraceInputSchema.shape.importer_url, diff --git a/src/cli/webNetworkCaptureCommands.ts b/src/cli/webNetworkCaptureCommands.ts index d49e39b0ea..755992e125 100644 --- a/src/cli/webNetworkCaptureCommands.ts +++ b/src/cli/webNetworkCaptureCommands.ts @@ -5,6 +5,7 @@ import { CLI_COMMANDS } from "../cliCommandNames.js"; import { logCliCommand } from "../cliLogging.js"; import type { Logger } from "pino"; import type { CliInstance } from "./types.js"; +import { cliIntegerArgument } from "./options.js"; import { withCommandCancellation } from "./commandCancellation.js"; import { finishAnalysisCommand } from "./finishAnalysisCommand.js"; @@ -26,7 +27,7 @@ export const registerWebNetworkCaptureCommands = ( }), options: z.object({ record: z - .array(z.coerce.number().int().nonnegative()) + .array(cliIntegerArgument(0)) .optional() .describe("Zero-based producer ordinals; repeat to select several"), sensitiveValue: z diff --git a/src/cli/webSourceLocationCommands.ts b/src/cli/webSourceLocationCommands.ts index 1025733abb..49eb77f6b7 100644 --- a/src/cli/webSourceLocationCommands.ts +++ b/src/cli/webSourceLocationCommands.ts @@ -5,6 +5,7 @@ import { logCliCommand } from "../cliLogging.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import type { Logger } from "pino"; import type { CliInstance } from "./types.js"; +import { cliIntegerArgument } from "./options.js"; import { withCommandCancellation } from "./commandCancellation.js"; /** Expose the same explicit selected map/point contract through the CLI. */ @@ -21,21 +22,17 @@ export const registerWebSourceLocationCommands = ( manifestPath: z .string() .describe("Absolute export_web_scripts manifest path"), - scriptIndex: z.coerce - .number() - .int() - .min(0) - .describe("Zero-based manifest script index"), + scriptIndex: cliIntegerArgument(0).describe( + "Zero-based manifest script index", + ), sourceMapPath: z.string().describe("Absolute local source-map path"), sourceMapUrl: z .string() .describe("Absolute source-map URL context; no fetch"), - line: z.coerce.number().int().min(1).describe("One-based generated line"), - column: z.coerce - .number() - .int() - .min(0) - .describe("Zero-based UTF-16 generated column"), + line: cliIntegerArgument(1).describe("One-based generated line"), + column: cliIntegerArgument(0).describe( + "Zero-based UTF-16 generated column", + ), }), run: ({ args }) => withCommandCancellation(async (signal) => { diff --git a/tests/boundary/cli/cliIntegerArguments.test.ts b/tests/boundary/cli/cliIntegerArguments.test.ts new file mode 100644 index 0000000000..9f07e5777b --- /dev/null +++ b/tests/boundary/cli/cliIntegerArguments.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "vitest"; + +import { serveCli } from "./cliServeHarness.js"; + +const SESSION_ID = "27d3e3f1-f1e5-49ae-91ec-95af1f343a5a"; +const NON_INTEGER_TEXT = [ + "", + " ", + "\t", + "1\n", + "0x1", + "0b1", + "1e0", + "+1", + "1.0", + "9007199254740992", +]; +const NON_INTEGER_OPTION_TEXT = [...NON_INTEGER_TEXT, "-0"]; + +const expectCliValidationError = ( + result: Awaited>, + field: string, +): void => { + expect(result.exitCode).toBe(1); + expect(JSON.parse(result.stdout)).toMatchObject({ + code: "VALIDATION_ERROR", + fieldErrors: expect.arrayContaining([ + expect.objectContaining({ path: expect.stringContaining(field) }), + ]), + }); +}; + +describe("CLI integer arguments", () => { + it.each(NON_INTEGER_TEXT)( + "rejects %j as a Cutter expected generation", + async (value) => { + const result = await serveCli([ + "cutter-command", + SESSION_ID, + value, + "pdi", + "--json", + ]); + + expectCliValidationError(result, "expectedGeneration"); + }, + ); + + it.each(NON_INTEGER_TEXT)( + "rejects %j as a web module script index", + async (value) => { + const result = await serveCli([ + "trace-web-module-imports", + "/nonexistent/manifest.json", + value, + "--json", + ]); + + expectCliValidationError(result, "scriptIndex"); + }, + ); + + it.each(NON_INTEGER_OPTION_TEXT)( + "rejects %j in a repeated web capture record selector", + async (value) => { + const result = await serveCli([ + "inspect-web-network-capture", + "/nonexistent/capture.qlog", + "qlog", + "--record", + "0", + "--record", + value, + "--json", + ]); + + expectCliValidationError(result, "record"); + }, + ); + + it("keeps the one-based source line lower bound at the CLI boundary", async () => { + const result = await serveCli([ + "trace-web-source-location", + "/nonexistent/manifest.json", + "0", + "/nonexistent/app.js.map", + "https://example.test/app.js.map", + "0", + "0", + "--json", + ]); + + expectCliValidationError(result, "line"); + }); +}); diff --git a/tests/boundary/cli/cutterCommands.test.ts b/tests/boundary/cli/cutterCommands.test.ts index 6a588cb4b8..4acc851a63 100644 --- a/tests/boundary/cli/cutterCommands.test.ts +++ b/tests/boundary/cli/cutterCommands.test.ts @@ -169,7 +169,7 @@ describe("Cutter CLI command output flags", () => { ); const result = await serveCli( - ["cutter-command", SESSION_ID, "0", "pdi", "--json-output"], + ["cutter-command", SESSION_ID, "000", "pdi", "--json-output"], { REA_CUTTER_BRIDGE_DIR: directory }, );