From cdf2475bb87c3d33eef3afd9bd5cf249203e488c Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Thu, 20 Aug 2026 14:53:35 -0700 Subject: [PATCH 1/2] fix: make error type guards work across duplicated class copies `isCommandNotFoundError` and `isArgsValidationError` use `instanceof`, which cannot match an error thrown by `gunshi` when the guard is imported from `@gunshi/plugin`: that package is built with `noExternal: ['gunshi/plugin']` and has no runtime dependency on `gunshi`, so it ships its own copy of the error classes. Both guards therefore always return `false` for every plugin outside the `gunshi` bundle, which silently disables `@gunshi/plugin-suggestion` entirely. Keeps `instanceof` as the fast path and adds a structural fallback on the `name` brand both constructors set. `isArgsValidationError` is now exported from `./error.ts` rather than re-exported straight from `args-tokens`, so consumers of `gunshi` and `gunshi/plugin` get the resilient version. --- packages/gunshi/src/error.test.ts | 96 +++++++++++++++++++++++++++++++ packages/gunshi/src/error.ts | 39 ++++++++++++- packages/gunshi/src/index.ts | 13 ++--- packages/gunshi/src/plugin.ts | 3 +- 4 files changed, 139 insertions(+), 12 deletions(-) create mode 100644 packages/gunshi/src/error.test.ts diff --git a/packages/gunshi/src/error.test.ts b/packages/gunshi/src/error.test.ts new file mode 100644 index 000000000..a1f364669 --- /dev/null +++ b/packages/gunshi/src/error.test.ts @@ -0,0 +1,96 @@ +import { ArgsValidationError, ArgsValidationErrorKeys } from 'args-tokens' +import { describe, expect, test } from 'vitest' +import { + CommandNotFoundError, + hasPriorityValidationError, + isArgsValidationError, + isCommandNotFoundError +} from './error.ts' + +/** + * Stand-ins for the duplicated class copies that `@gunshi/plugin` ships: it is bundled + * with `noExternal: ['gunshi/plugin']`, so a plugin importing these guards holds a + * different class object than the one `gunshi` throws with, and `instanceof` cannot match. + */ +class DuplicatedCommandNotFoundError extends Error { + readonly commandName: string + readonly candidates: readonly string[] + constructor(message: string, commandName: string, candidates: readonly string[]) { + super(message) + this.name = 'CommandNotFoundError' + this.commandName = commandName + this.candidates = candidates + } +} + +class DuplicatedArgsValidationError extends Error { + readonly code: string + readonly values: Record + constructor(message: string, code: string, values: Record) { + super(message) + this.name = 'ArgsValidationError' + this.code = code + this.values = values + } +} + +describe('isCommandNotFoundError', () => { + test('matches an instance of the class', () => { + const error = new CommandNotFoundError('not found', { commandName: 'lod' }) + expect(isCommandNotFoundError(error)).toBe(true) + }) + + test('matches an error from a duplicated copy of the class', () => { + const error = new DuplicatedCommandNotFoundError('not found', 'lod', ['load']) + expect(error instanceof CommandNotFoundError).toBe(false) + expect(isCommandNotFoundError(error)).toBe(true) + }) + + test('does not match unrelated errors or non-errors', () => { + expect(isCommandNotFoundError(new Error('boom'))).toBe(false) + expect(isCommandNotFoundError({ name: 'CommandNotFoundError' })).toBe(false) + expect(isCommandNotFoundError(undefined)).toBe(false) + }) +}) + +describe('isArgsValidationError', () => { + test('matches an instance of the class', () => { + const error = new ArgsValidationError('unknown option', { + code: ArgsValidationErrorKeys.unknownOption, + values: { name: 'alow-reload' } + }) + expect(isArgsValidationError(error)).toBe(true) + }) + + test('matches an error from a duplicated copy of the class', () => { + const error = new DuplicatedArgsValidationError( + 'unknown option', + ArgsValidationErrorKeys.unknownOption, + { name: 'alow-reload' } + ) + expect(error instanceof ArgsValidationError).toBe(false) + expect(isArgsValidationError(error)).toBe(true) + }) + + test('does not match unrelated errors or non-errors', () => { + expect(isArgsValidationError(new Error('boom'))).toBe(false) + expect(isArgsValidationError({ name: 'ArgsValidationError' })).toBe(false) + expect(isArgsValidationError(undefined)).toBe(false) + }) +}) + +describe('hasPriorityValidationError', () => { + test('detects a duplicated-copy unknown-option error', () => { + const error = new AggregateError([ + new DuplicatedArgsValidationError('unknown', ArgsValidationErrorKeys.unknownOption, {}) + ]) + expect(hasPriorityValidationError(error)).toBe(true) + }) + + test('detects a duplicated-copy command-not-found error', () => { + const error = new AggregateError([ + new DuplicatedCommandNotFoundError('not found', 'lod', ['load']) + ]) + expect(hasPriorityValidationError(error)).toBe(true) + }) +}) diff --git a/packages/gunshi/src/error.ts b/packages/gunshi/src/error.ts index 08d4ab599..84c2c2c4c 100644 --- a/packages/gunshi/src/error.ts +++ b/packages/gunshi/src/error.ts @@ -3,7 +3,12 @@ * @license MIT */ -import { ArgsValidationErrorKeys, isArgsValidationError } from 'args-tokens' +import { + ArgsValidationErrorKeys, + isArgsValidationError as isArgsValidationErrorInstance +} from 'args-tokens' + +import type { ArgsValidationError } from 'args-tokens' /** * Command not found error resource keys. @@ -82,7 +87,37 @@ export class CommandNotFoundError extends Error { * @returns `true` if the error is a {@link CommandNotFoundError} */ export function isCommandNotFoundError(error: unknown): error is CommandNotFoundError { - return error instanceof CommandNotFoundError + return ( + error instanceof CommandNotFoundError || + // `instanceof` alone is not enough: `@gunshi/plugin` is bundled with its own copy of + // this class (`noExternal: ['gunshi/plugin']`), so an error thrown by `gunshi` is never + // an instance of the class a plugin imports. Fall back to a structural check on the + // `name` brand the constructor sets, so the guard works across duplicated copies. + (error instanceof Error && + error.name === 'CommandNotFoundError' && + 'commandName' in error && + 'candidates' in error) + ) +} + +/** + * Check whether an error is an {@link ArgsValidationError}. + * + * Prefer this over the `args-tokens` guard of the same name: it additionally matches + * errors produced by a duplicated copy of the class, which is what plugins importing + * from `@gunshi/plugin` receive. + * + * @param error - An unknown error + * @returns `true` if the error is an {@link ArgsValidationError} + */ +export function isArgsValidationError(error: unknown): error is ArgsValidationError { + return ( + isArgsValidationErrorInstance(error) || + (error instanceof Error && + error.name === 'ArgsValidationError' && + 'code' in error && + 'values' in error) + ) } /** diff --git a/packages/gunshi/src/index.ts b/packages/gunshi/src/index.ts index f783da31f..62a56bdf3 100644 --- a/packages/gunshi/src/index.ts +++ b/packages/gunshi/src/index.ts @@ -9,8 +9,8 @@ * - `lazyWithTypes`: A function to lazily load a command with specific type parameters. * - `plugin`: A function to create a plugin. * - `createCommandContext`: A function to create a command context, mainly for testing purposes. - * - `args-tokens` utilities: `parseArgs`, `resolveArgs`, `ArgsValidationError`, `ArgsValidationErrorKeys`, `ArgsValidationErrorCode`, and `isArgsValidationError` for parsing and validating command line arguments. - * - Structured error utilities: `CommandNotFoundError`, `CommandNotFoundErrorKeys`, `CommandNotFoundErrorCode`, `CommandNotFoundErrorOptions`, `isCommandNotFoundError`, and `hasPriorityValidationError`. + * - `args-tokens` utilities: `parseArgs`, `resolveArgs`, `ArgsValidationError`, `ArgsValidationErrorKeys`, and `ArgsValidationErrorCode` for parsing and validating command line arguments. + * - Structured error utilities: `CommandNotFoundError`, `CommandNotFoundErrorKeys`, `CommandNotFoundErrorCode`, `CommandNotFoundErrorOptions`, `isCommandNotFoundError`, `isArgsValidationError`, and `hasPriorityValidationError`. * - Some basic type definitions, such as `CommandContext`, `Plugin`, `PluginContext`, etc. * * @example @@ -27,13 +27,7 @@ */ export { DefaultTranslation } from '@gunshi/plugin-i18n' // TODO(kazupon): remove this import after the next major release -export { - ArgsValidationError, - ArgsValidationErrorKeys, - isArgsValidationError, - parseArgs, - resolveArgs -} from 'args-tokens' +export { ArgsValidationError, ArgsValidationErrorKeys, parseArgs, resolveArgs } from 'args-tokens' export * from './cli.ts' export { ANONYMOUS_COMMAND_NAME } from './constants.ts' export { createCommandContext } from './context.ts' @@ -42,6 +36,7 @@ export { CommandNotFoundError, CommandNotFoundErrorKeys, hasPriorityValidationError, + isArgsValidationError, isCommandNotFoundError } from './error.ts' export { plugin } from './plugin/core.ts' diff --git a/packages/gunshi/src/plugin.ts b/packages/gunshi/src/plugin.ts index f5cc7197b..6710032b6 100644 --- a/packages/gunshi/src/plugin.ts +++ b/packages/gunshi/src/plugin.ts @@ -29,10 +29,11 @@ export { CommandNotFoundError, CommandNotFoundErrorKeys, hasPriorityValidationError, + isArgsValidationError, isCommandNotFoundError } from './error.ts' export { plugin } from './plugin/core.ts' -export { ArgsValidationError, ArgsValidationErrorKeys, isArgsValidationError } from 'args-tokens' +export { ArgsValidationError, ArgsValidationErrorKeys } from 'args-tokens' export type { CommandContextParams } from './context.ts' export type { CommandNotFoundErrorCode, CommandNotFoundErrorOptions } from './error.ts' From 69fbf0296de74ef1c1fa947b3191120461b39f3b Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Thu, 20 Aug 2026 20:16:13 -0700 Subject: [PATCH 2/2] chore: allow the intentional `lod` misspelling in command-not-found tests Matches the existing `alow` entry: `lod` is test data standing in for a mistyped command name, not a typo in prose. --- _typos.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/_typos.toml b/_typos.toml index ee91c5b19..48a4cc9c2 100644 --- a/_typos.toml +++ b/_typos.toml @@ -1,3 +1,5 @@ [default.extend-words] # Intentional misspelling used in strict unknown-option tests and docs. alow = "alow" +# Intentional misspelling used in command-not-found tests. +lod = "lod"