From 9ee2fbef1fe04e4db1a9d33364845a9f0e8fd500 Mon Sep 17 00:00:00 2001 From: Alisue Date: Tue, 27 Jan 2026 23:22:11 +0900 Subject: [PATCH 1/2] feat(@probitas/probitas): add unknown CLI argument detection with hints Detect unknown options in `run` and `list` commands using parseArgs unknown callback. Provides contextual hints for common mistakes like --tag, --name, --filter, and suggests similar options for typos using Levenshtein distance. --- deno.json | 3 + deno.lock | 12 ++ src/cli/commands/list.ts | 82 ++++++--- src/cli/commands/run.ts | 112 +++++++----- src/cli/unknown_args.ts | 245 +++++++++++++++++++++++++++ src/cli/unknown_args_test.ts | 318 +++++++++++++++++++++++++++++++++++ 6 files changed, 709 insertions(+), 63 deletions(-) create mode 100644 src/cli/unknown_args.ts create mode 100644 src/cli/unknown_args_test.ts diff --git a/deno.json b/deno.json index eabbc36..104e2ea 100644 --- a/deno.json +++ b/deno.json @@ -106,6 +106,9 @@ "@std/testing/bdd": "jsr:@std/testing@^1.0.16/bdd", "@std/testing/mock": "jsr:@std/testing@^1.0.16/mock", "@std/testing/time": "jsr:@std/testing@^1.0.16/time", + "@std/text": "jsr:@std/text@^1.0.17", + "@std/text/closest-string": "jsr:@std/text@^1.0.17/closest-string", + "@std/text/levenshtein-distance": "jsr:@std/text@^1.0.17/levenshtein-distance", "jsr:@probitas/probitas@^0": "./src/mod.ts" } } diff --git a/deno.lock b/deno.lock index a7b51e3..b52c826 100644 --- a/deno.lock +++ b/deno.lock @@ -83,10 +83,12 @@ "jsr:@std/path@0.217": "0.217.0", "jsr:@std/path@1": "1.1.4", "jsr:@std/path@^1.1.4": "1.1.4", + "jsr:@std/regexp@^1.0.1": "1.0.1", "jsr:@std/streams@^1.0.14": "1.0.16", "jsr:@std/streams@^1.0.16": "1.0.16", "jsr:@std/streams@^1.0.9": "1.0.16", "jsr:@std/testing@^1.0.16": "1.0.16", + "jsr:@std/text@^1.0.17": "1.0.17", "npm:@aws-sdk/client-sqs@^3.700.0": "3.965.0", "npm:@bufbuild/protobuf@^2.7.0": "2.10.2", "npm:@connectrpc/connect-node@^2.1.1": "2.1.1_@bufbuild+protobuf@2.10.2_@connectrpc+connect@2.1.1__@bufbuild+protobuf@2.10.2", @@ -447,6 +449,9 @@ "jsr:@std/internal@^1.0.12" ] }, + "@std/regexp@1.0.1": { + "integrity": "5179d823465085c5480dafb44438466e83c424fadc61ba31f744050ecc0f596d" + }, "@std/streams@1.0.16": { "integrity": "85030627befb1767c60d4f65cb30fa2f94af1d6ee6e5b2515b76157a542e89c4", "dependencies": [ @@ -461,6 +466,12 @@ "jsr:@std/data-structures", "jsr:@std/internal@^1.0.12" ] + }, + "@std/text@1.0.17": { + "integrity": "4b2c4ef67ae5b6c1dfd447c81c83a43718f52e3c7e748d8b33f694aba9895f95", + "dependencies": [ + "jsr:@std/regexp" + ] } }, "npm": { @@ -1579,6 +1590,7 @@ "jsr:@std/path@^1.1.4", "jsr:@std/streams@^1.0.16", "jsr:@std/testing@^1.0.16", + "jsr:@std/text@^1.0.17", "npm:@faker-js/faker@^10.2.0" ] } diff --git a/src/cli/commands/list.ts b/src/cli/commands/list.ts index 76d14a9..7a258a0 100644 --- a/src/cli/commands/list.ts +++ b/src/cli/commands/list.ts @@ -12,6 +12,11 @@ import { fromErrorObject, isErrorObject } from "@core/errorutil/error-object"; import { unreachable } from "@core/errorutil/unreachable"; import { EXIT_CODE } from "../constants.ts"; import { findProbitasConfigFile, loadConfig } from "../config.ts"; +import { + createUnknownArgHandler, + extractKnownOptions, + formatUnknownArgError, +} from "../unknown_args.ts"; import { createDiscoveryProgress, writeStatus } from "../progress.ts"; import { configureLogging, @@ -29,6 +34,36 @@ import { const logger = getLogger(["probitas", "cli", "list"]); +/** + * parseArgs configuration for the list command + */ +const PARSE_ARGS_CONFIG = { + string: ["config", "include", "exclude", "selector", "env"], + boolean: [ + "help", + "json", + "no-env", + "reload", + "quiet", + "verbose", + "debug", + ], + collect: ["include", "exclude", "selector"], + alias: { + h: "help", + s: "selector", + r: "reload", + v: "verbose", + q: "quiet", + d: "debug", + }, + default: { + include: undefined, + exclude: undefined, + selector: undefined, + }, +} as const; + /** * Execute the list command * @@ -46,34 +81,33 @@ export async function listCommand( // Extract deno options first (before parseArgs) const { denoArgs, remainingArgs } = extractDenoOptions(args); + // Setup unknown argument handler + const knownOptions = extractKnownOptions(PARSE_ARGS_CONFIG); + const { handler: unknownHandler, result: unknownResult } = + createUnknownArgHandler({ + knownOptions, + commandName: "list", + }); + // Parse command-line arguments const parsed = parseArgs(remainingArgs, { - string: ["config", "include", "exclude", "selector", "env"], - boolean: [ - "help", - "json", - "no-env", - "reload", - "quiet", - "verbose", - "debug", - ], - collect: ["include", "exclude", "selector"], - alias: { - h: "help", - s: "selector", - r: "reload", - v: "verbose", - q: "quiet", - d: "debug", - }, - default: { - include: undefined, - exclude: undefined, - selector: undefined, - }, + ...PARSE_ARGS_CONFIG, + unknown: unknownHandler, }); + // Check for unknown arguments before showing help + if (unknownResult.hasErrors) { + for (const unknown of unknownResult.unknownArgs) { + console.error( + formatUnknownArgError(unknown, { + knownOptions, + commandName: "list", + }), + ); + } + return EXIT_CODE.USAGE_ERROR; + } + // Show help if requested if (parsed.help) { try { diff --git a/src/cli/commands/run.ts b/src/cli/commands/run.ts index 34797f2..18ca012 100644 --- a/src/cli/commands/run.ts +++ b/src/cli/commands/run.ts @@ -10,6 +10,11 @@ import { unreachable } from "@core/errorutil/unreachable"; import { getLogger, type LogLevel } from "@logtape/logtape"; import { DEFAULT_TIMEOUT, EXIT_CODE } from "../constants.ts"; import { findProbitasConfigFile, loadConfig } from "../config.ts"; +import { + createUnknownArgHandler, + extractKnownOptions, + formatUnknownArgError, +} from "../unknown_args.ts"; import { discoverScenarioFiles } from "@probitas/discover"; import type { StepOptions } from "@probitas/core"; import type { Reporter, RunResult } from "@probitas/runner"; @@ -36,6 +41,51 @@ import { const logger = getLogger(["probitas", "cli", "run"]); +/** + * parseArgs configuration for the run command + */ +const PARSE_ARGS_CONFIG = { + string: [ + "reporter", + "config", + "max-concurrency", + "max-failures", + "include", + "exclude", + "selector", + "timeout", + "env", + ], + boolean: [ + "help", + "no-color", + "no-timeout", + "no-env", + "reload", + "quiet", + "verbose", + "debug", + "sequential", + "fail-fast", + ], + collect: ["include", "exclude", "selector"], + alias: { + h: "help", + s: "selector", + S: "sequential", + f: "fail-fast", + v: "verbose", + q: "quiet", + d: "debug", + r: "reload", + }, + default: { + include: undefined, + exclude: undefined, + selector: undefined, + }, +} as const; + /** * Execute the run command * @@ -55,49 +105,33 @@ export async function runCommand( // Extract deno options first (before parseArgs) const { denoArgs, remainingArgs } = extractDenoOptions(args); + // Setup unknown argument handler + const knownOptions = extractKnownOptions(PARSE_ARGS_CONFIG); + const { handler: unknownHandler, result: unknownResult } = + createUnknownArgHandler({ + knownOptions, + commandName: "run", + }); + // Parse command-line arguments const parsed = parseArgs(remainingArgs, { - string: [ - "reporter", - "config", - "max-concurrency", - "max-failures", - "include", - "exclude", - "selector", - "timeout", - "env", - ], - boolean: [ - "help", - "no-color", - "no-timeout", - "no-env", - "reload", - "quiet", - "verbose", - "debug", - "sequential", - "fail-fast", - ], - collect: ["include", "exclude", "selector"], - alias: { - h: "help", - s: "selector", - S: "sequential", - f: "fail-fast", - v: "verbose", - q: "quiet", - d: "debug", - r: "reload", - }, - default: { - include: undefined, - exclude: undefined, - selector: undefined, - }, + ...PARSE_ARGS_CONFIG, + unknown: unknownHandler, }); + // Check for unknown arguments before showing help + if (unknownResult.hasErrors) { + for (const unknown of unknownResult.unknownArgs) { + console.error( + formatUnknownArgError(unknown, { + knownOptions, + commandName: "run", + }), + ); + } + return EXIT_CODE.USAGE_ERROR; + } + // Show help if requested if (parsed.help) { try { diff --git a/src/cli/unknown_args.ts b/src/cli/unknown_args.ts new file mode 100644 index 0000000..c8adc7e --- /dev/null +++ b/src/cli/unknown_args.ts @@ -0,0 +1,245 @@ +/** + * Unknown CLI arguments detection and hint generation + * + * @module + */ + +import { closestString } from "@std/text/closest-string"; +import { levenshteinDistance } from "@std/text/levenshtein-distance"; + +/** + * Maximum Levenshtein distance for suggesting similar options + */ +const MAX_SUGGESTION_DISTANCE = 3; + +/** + * Represents an unknown argument detected during parsing + */ +export interface UnknownArg { + /** The full argument string (e.g., "--tag" or "--tag=foo") */ + arg: string; + /** The option name without dashes (e.g., "tag") */ + key: string; + /** The value if provided with = (e.g., "foo" for "--tag=foo") */ + value: unknown; +} + +/** + * Configuration for the unknown argument handler + */ +export interface UnknownArgHandlerOptions { + /** List of known option names (without dashes) */ + knownOptions: readonly string[]; + /** The command name for help text (e.g., "run" or "list") */ + commandName: string; +} + +/** + * Result from unknown argument collection + */ +export interface UnknownArgResult { + /** List of unknown arguments detected */ + unknownArgs: UnknownArg[]; + /** Whether any unknown arguments were found */ + hasErrors: boolean; +} + +/** + * Creates a handler for detecting unknown arguments in parseArgs + * + * The returned object contains: + * - `handler`: The callback to pass to parseArgs `unknown` option + * - `result`: The collection of unknown arguments after parsing + * + * @example Usage with parseArgs + * ```ts + * import { parseArgs } from "@std/cli"; + * import { createUnknownArgHandler, formatUnknownArgError } from "./unknown_args.ts"; + * + * const knownOptions = ["help", "verbose", "selector"]; + * const { handler, result } = createUnknownArgHandler({ + * knownOptions, + * commandName: "run", + * }); + * + * const args = ["--unknown", "--help"]; + * const parsed = parseArgs(args, { + * boolean: ["help"], + * unknown: handler, + * }); + * + * if (result.hasErrors) { + * for (const unknown of result.unknownArgs) { + * console.error(formatUnknownArgError(unknown, { + * knownOptions, + * commandName: "run", + * })); + * } + * } + * ``` + */ +export function createUnknownArgHandler( + _options: UnknownArgHandlerOptions, +): { + handler: (arg: string, key?: string, value?: unknown) => boolean; + result: UnknownArgResult; +} { + const result: UnknownArgResult = { + unknownArgs: [], + hasErrors: false, + }; + + const handler = (arg: string, key?: string, value?: unknown): boolean => { + // Only handle option arguments (starting with -) + if (!arg.startsWith("-")) { + return true; // Allow positional arguments + } + + // key is undefined for malformed arguments, extract from arg + const actualKey = key ?? extractKeyFromArg(arg); + + result.unknownArgs.push({ + arg, + key: actualKey, + value, + }); + result.hasErrors = true; + + // Return false to exclude from parse result + return false; + }; + + return { handler, result }; +} + +/** + * Extracts the option key from an argument string + */ +function extractKeyFromArg(arg: string): string { + // Handle --option=value or --option + const match = arg.match(/^--?([^=]+)/); + return match?.[1] ?? arg; +} + +/** + * Generates a contextual hint for an unknown argument + * + * Provides helpful suggestions for common mistakes like: + * - --tag → suggests -s 'tag:' + * - --name → suggests -s 'name:' + * - --filter → suggests -s '' + * - Typos → suggests closest known option + */ +export function generateHint( + unknown: UnknownArg, + options: UnknownArgHandlerOptions, +): string { + const { key, value } = unknown; + const { knownOptions, commandName } = options; + + // Check for tag/tags pattern + if (key === "tag" || key === "tags") { + const tagValue = value ?? ""; + return `Did you mean '-s "tag:${tagValue}"'? Use the selector option to filter by tag.`; + } + + // Check for name/names pattern + if (key === "name" || key === "names") { + const nameValue = value ?? ""; + return `Did you mean '-s "name:${nameValue}"'? Use the selector option to filter by name.`; + } + + // Check for filter-like options + if (key === "filter" || key === "select" || key === "match") { + const filterValue = value ?? ""; + return `Did you mean '-s "${filterValue}"'? Use the selector option to filter scenarios.`; + } + + // Check for similar options using Levenshtein distance + const similar = findSimilarOption(key, knownOptions); + if (similar) { + return `Did you mean '--${similar}'?`; + } + + // Fallback to generic help message + return `Run 'probitas ${commandName} --help' for available options.`; +} + +/** + * Finds a similar known option using Levenshtein distance + * + * Returns the closest match if within MAX_SUGGESTION_DISTANCE, otherwise undefined. + * This is the same approach used by Cliffy for production-quality suggestions. + */ +export function findSimilarOption( + unknown: string, + knownOptions: readonly string[], +): string | undefined { + if (knownOptions.length === 0) { + return undefined; + } + + // Use closestString from @std/text (same as Cliffy) + const closest = closestString(unknown, knownOptions as string[]); + + // Only suggest if within threshold + const distance = levenshteinDistance(unknown, closest); + if (distance <= MAX_SUGGESTION_DISTANCE) { + return closest; + } + + return undefined; +} + +/** + * Formats an error message for an unknown argument + */ +export function formatUnknownArgError( + unknown: UnknownArg, + options: UnknownArgHandlerOptions, +): string { + const hint = generateHint(unknown, options); + return `Unknown option: ${unknown.arg}\n${hint}`; +} + +/** + * Extracts all known options from parseArgs configuration + * + * Combines string options, boolean options, and their aliases. + * Also handles --no-* variants for boolean options. + */ +export function extractKnownOptions(config: { + string?: readonly string[]; + boolean?: readonly string[]; + alias?: Record; +}): string[] { + const known = new Set(); + + // Add string options + for (const opt of config.string ?? []) { + known.add(opt); + } + + // Add boolean options and their --no-* variants + for (const opt of config.boolean ?? []) { + known.add(opt); + // Boolean options also accept --no-