From cb8614e8d75fbbd037afecda52be8da7d7ae8e91 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Thu, 2 Jul 2026 22:06:05 +0900 Subject: [PATCH 1/2] feat(errors)!: rebuild errors module on @vercel/error Adopt @vercel/error as the structured-error foundation. CliError now extends VercelError, gaining reason/hint/fix/link/metadata/cause context, environment-aware terminal formatting, and createErrors() factory compatibility via ErrorClass. BREAKING CHANGE: CliError's constructor is now (message, options) instead of (message, code, suggestions); suggestions is replaced by hint/fix. The ErrorOutput payload drops help in favor of reason/hint/fix/link (legacy suggestions from older toolkit copies still map onto hint). errorOutput() takes a details object instead of a suggestions array, and validateFormat's message moved its supported-formats list into fix. --- README.md | 38 +++++++++++++---- bun.lock | 4 ++ package.json | 1 + src/errors/error.ts | 67 ++++++++++++++++++++---------- src/errors/index.ts | 34 ++++++++++++++-- src/errors/output.ts | 83 +++++++++++++++++++++++++++++--------- src/index.ts | 24 ++++++++++- src/output/json.ts | 8 ++-- src/validation/numeric.ts | 8 ++-- src/validation/text.ts | 6 +-- test/errors/error.test.ts | 42 ++++++++++++++----- test/errors/output.test.ts | 71 +++++++++++++++++++++++++------- 12 files changed, 297 insertions(+), 89 deletions(-) diff --git a/README.md b/README.md index dfab394..7571ceb 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,9 @@ const username = validatePattern('user123', /^[a-z0-9]+$/, 'Username', 'alphanum ### Errors Module Throw structured errors with machine-readable codes and render them consistently -for agents to parse: +for agents to parse. Built on [`@vercel/error`](https://github.com/vercel-labs/error), +so every error can answer *what* happened (`message`), *why* (`reason`), *what +could help* (`hint`), *how to fix it* (`fix`), and *where to learn more* (`link`): ```typescript import { @@ -132,17 +134,38 @@ import { import { outputData } from '@pleaseai/cli-toolkit/output' try { - if (!issue) - throw new CliError('Issue #999 not found', 'NOT_FOUND', ['Run `gh-please issue list`']) + if (!issue) { + throw new CliError('Issue #999 not found', { + code: 'NOT_FOUND', + fix: 'Run `gh-please issue list` to see open issues', + }) + } } catch (error) { - // Render a stable { error, code, help } payload (JSON or TOON) + // Render a stable { error, code, reason?, hint?, fix?, link? } payload (JSON or TOON) outputData(toErrorOutput(error), 'toon') // Map to a process exit code (2 for validation errors, otherwise 1) process.exitCode = exitCodeForError(error) } ``` +For tools with many error sites, `createErrors` (re-exported from +`@vercel/error`) builds a factory that injects a shared scope and derives docs +links from error codes: + +```typescript +import { CliError, createErrors } from '@pleaseai/cli-toolkit/errors' + +const errors = createErrors({ + scope: 'gh-please', + ErrorClass: CliError, + docsBaseUrl: 'https://example.com/docs/errors', +}) + +errors.raise('Issue #999 not found', { code: 'NOT_FOUND' }) +// throws a CliError with link https://example.com/docs/errors/NOT_FOUND +``` + The built-in validators already throw `CliError` tagged with `VALIDATION_ERROR` (exit code `2`), so wrapping a command in the `try/catch` above gives you consistent structured errors and exit codes for free. @@ -158,7 +181,7 @@ consistent structured errors and exit codes for free. import { isCliError } from '@pleaseai/cli-toolkit/errors' if (isCliError(error)) { - // error.code / error.suggestions are safe to read here + // error.code / error.reason / error.hint / error.fix are safe to read here } ``` @@ -223,12 +246,13 @@ collapseHomeDirectory('/Users/alice/project/bin', '/Users/alice') // '~/project/ ### Errors Module -- `CliError` - Structured error with `code` and `suggestions` +- `CliError` - Structured error extending `VercelError`; carries `code` (always set) plus `reason`, `hint`, `fix`, `link`, `metadata`, `cause`, ... - `isCliError(error)` - Type guard for `CliError`, robust across subpath/version boundaries (prefer over `instanceof`) - `exitCodeForError(error)` - Map an error to a process exit code (`2` for validation, else `1`) -- `errorOutput(message, code, suggestions?)` - Build a `{ error, code, help? }` payload +- `errorOutput(message, code, details?)` - Build a `{ error, code, reason?, hint?, fix?, link? }` payload - `toErrorOutput(error)` - Convert any thrown value into a structured payload - `VALIDATION_ERROR` / `UNKNOWN_ERROR` - Built-in error code constants +- Re-exported from `@vercel/error`: `VercelError`, `createErrors`, `isVercelError`, `isError`, `isErrorLike`, `hasCode`, `getMessage`, `getRootCause` ## TypeScript Support diff --git a/bun.lock b/bun.lock index cbba238..4cbfec4 100644 --- a/bun.lock +++ b/bun.lock @@ -1,10 +1,12 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "cli-toolkit", "dependencies": { "@byjohann/toon": "^0.3.0", + "@vercel/error": "^0.0.2", "commander": "^12.1.0", "es-toolkit": "^1.31.0", }, @@ -133,6 +135,8 @@ "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.46.2", "", { "dependencies": { "@typescript-eslint/types": "8.46.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-tUFMXI4gxzzMXt4xpGJEsBsTox0XbNQ1y94EwlD/CuZwFcQP79xfQqMhau9HsRc/J0cAPA/HZt1dZPtGn9V/7w=="], + "@vercel/error": ["@vercel/error@0.0.2", "", {}, "sha512-eIwXoHYM+6TLAHW5MfzXjeJRUh7Yui8UdKqwtcOMMYiB25lLREBjeZbve9hG3FZlcRrcPlopMp+/KtBqIJUfCw=="], + "@vitest/eslint-plugin": ["@vitest/eslint-plugin@1.3.26", "", { "dependencies": { "@typescript-eslint/scope-manager": "^8.46.1", "@typescript-eslint/utils": "^8.46.1" }, "peerDependencies": { "eslint": ">=8.57.0", "typescript": ">=5.0.0", "vitest": "*" }, "optionalPeers": ["typescript", "vitest"] }, "sha512-oP+Vyqgp+kLuMagG0tRkcT7e2tUoE+XWgti1OFxqdTpmMlSZJ6BWSC3rv8vzhtDXReXNyAJI1eojuc7N0QqbNQ=="], "@vue/compiler-core": ["@vue/compiler-core@3.5.22", "", { "dependencies": { "@babel/parser": "^7.28.4", "@vue/shared": "3.5.22", "entities": "^4.5.0", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-jQ0pFPmZwTEiRNSb+i9Ow/I/cHv2tXYqsnHKKyCQ08irI2kdF5qmYedmF8si8mA7zepUFmJ2hqzS8CQmNOWOkQ=="], diff --git a/package.json b/package.json index 38d8a06..7bbd162 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ }, "dependencies": { "@byjohann/toon": "^0.3.0", + "@vercel/error": "^0.0.2", "commander": "^12.1.0", "es-toolkit": "^1.31.0" }, diff --git a/src/errors/error.ts b/src/errors/error.ts index 49406a6..a311e57 100644 --- a/src/errors/error.ts +++ b/src/errors/error.ts @@ -1,3 +1,6 @@ +import type { VercelErrorOptions } from '@vercel/error' +import { hasCode, VercelError } from '@vercel/error' + /** * Error code for validation failures (bad/invalid user input). * @@ -13,37 +16,54 @@ export const VALIDATION_ERROR = 'VALIDATION_ERROR' */ export const UNKNOWN_ERROR = 'UNKNOWN_ERROR' +/** + * Options accepted by {@link CliError}. + * + * Identical to `VercelErrorOptions` from `@vercel/error`, so a `CliError` can + * carry the full structured context (`reason`, `hint`, `fix`, `link`, + * `metadata`, `cause`, ...) and stays compatible with `createErrors()` via + * the `ErrorClass` option. + */ +export type CliErrorOptions = VercelErrorOptions + /** * Structured error for CLI / agent-facing tools. * - * Carries a machine-readable `code` and optional `suggestions` alongside the - * human message, so callers can render a consistent structured payload - * (`{ error, code, help }`) and map the error to a stable process exit code. + * Built on {@link VercelError}, so beyond the machine-readable `code` it can + * answer the full set of questions a human or agent needs: *what* happened + * (`message`), *why* (`reason`), *what could help* (`hint`), *how to fix it* + * (`fix`), and *where to learn more* (`link`). + * + * Unlike the base class, `code` is always set (defaulting to + * {@link UNKNOWN_ERROR}) so callers can rely on it when rendering the + * structured payload and mapping to a process exit code. * * Because it extends the built-in `Error`, existing `try/catch` and - * `expect(...).toThrow(message)` checks keep working unchanged. + * `expect(...).toThrow(message)` checks keep working unchanged. Its + * `toString()` is environment-aware: colored tree output on a TTY, plain + * indented text when piped (inherited from `VercelError`). * * @example * ```typescript - * throw new CliError( - * 'Issue #999 not found', - * 'NOT_FOUND', - * ['Run `gh-please issue list` to see open issues'], - * ) + * throw new CliError('Issue #999 not found', { + * code: 'NOT_FOUND', + * hint: 'The issue may have been closed or transferred.', + * fix: 'Run `gh-please issue list` to see open issues', + * }) * ``` */ -export class CliError extends Error { +export class CliError extends VercelError { + /** Always present; defaults to {@link UNKNOWN_ERROR}. */ + declare readonly code: string + /** * @param message - Human-readable error message - * @param code - Machine-readable error code (default: `'UNKNOWN_ERROR'`) - * @param suggestions - Optional actionable hints surfaced to the user/agent + * @param options - Structured context; `code` defaults to `'UNKNOWN_ERROR'` */ - constructor( - message: string, - public readonly code: string = UNKNOWN_ERROR, - public readonly suggestions: string[] = [], - ) { - super(message) + constructor(message: string, options: CliErrorOptions = {}) { + super(message, { ...options, code: options.code ?? UNKNOWN_ERROR }) + // Hardcoded (not derived from `this.constructor.name`) so minified + // bundles and the structural check in `isCliError` stay reliable. this.name = 'CliError' } } @@ -56,8 +76,9 @@ export class CliError extends Error { * (each bundled with its own copy), or two versions of the toolkit coexisting in * a dependency tree. Each copy defines a structurally identical but * reference-distinct class, so `instanceof` silently misclassifies the error and - * its `code`/`suggestions` are lost. Falling back to a structural `name`/`code` - * check keeps detection reliable across those boundaries. + * its structured fields are lost. Falling back to a structural `name`/`code` + * check keeps detection reliable across those boundaries (including errors + * from pre-`@vercel/error` versions of this toolkit). * * @param error - Any thrown value * @returns `true` if the value is a `CliError` (or structurally equivalent) @@ -80,6 +101,10 @@ export function isCliError(error: unknown): error is CliError { * Validation errors return `2` (usage error), everything else returns `1`. * Mirrors the common Unix convention where `2` signals misuse/invalid input. * + * Uses `hasCode` from `@vercel/error`, so any error-like value tagged with + * {@link VALIDATION_ERROR} maps to `2` — `CliError`, a plain `VercelError`, + * or a structurally equivalent error from another module copy. + * * @param error - Any thrown value * @returns Exit code (`2` for validation errors, otherwise `1`) * @@ -93,7 +118,7 @@ export function isCliError(error: unknown): error is CliError { * ``` */ export function exitCodeForError(error: unknown): number { - if (isCliError(error) && error.code === VALIDATION_ERROR) { + if (hasCode(error, VALIDATION_ERROR)) { return 2 } return 1 diff --git a/src/errors/index.ts b/src/errors/index.ts index cf8a904..3d20861 100644 --- a/src/errors/index.ts +++ b/src/errors/index.ts @@ -1,9 +1,13 @@ /** * Structured error utilities for CLI / agent-facing tools. * - * Provides a structured error class with machine-readable codes and - * suggestions, exit-code mapping, and helpers to render errors as a stable - * `{ error, code, help }` payload. + * Built on `@vercel/error`: provides a structured error class with + * machine-readable codes and rich context (`reason`, `hint`, `fix`, `link`), + * exit-code mapping, and helpers to render errors as a stable flat payload. + * + * Core primitives from `@vercel/error` (`VercelError`, `createErrors`, + * guards, and cause-chain helpers) are re-exported so consumers don't need a + * direct dependency. * * @module errors */ @@ -17,6 +21,28 @@ export { VALIDATION_ERROR, } from './error.ts' +export type { CliErrorOptions } from './error.ts' + // Structured error output export { errorOutput, toErrorOutput } from './output.ts' -export type { ErrorOutput } from './output.ts' +export type { ErrorOutput, ErrorOutputDetails } from './output.ts' + +// Core primitives from @vercel/error +export { + createErrors, + getMessage, + getRootCause, + hasCode, + isError, + isErrorLike, + isVercelError, + VercelError, +} from '@vercel/error' +export type { + CreateErrorsOptions, + ErrorAttributes, + ErrorFactory, + ErrorLike, + ErrorMetadata, + VercelErrorOptions, +} from '@vercel/error' diff --git a/src/errors/output.ts b/src/errors/output.ts index 574e5ac..83c6d81 100644 --- a/src/errors/output.ts +++ b/src/errors/output.ts @@ -1,48 +1,75 @@ +import { getMessage, isVercelError } from '@vercel/error' import { isCliError, UNKNOWN_ERROR } from './error.ts' /** * Structured, machine-readable error payload. * - * Designed to be rendered directly with the output module - * (`outputData(payload, 'toon')` / `outputData(payload, 'json')`), giving - * agents a stable shape to parse instead of free-form stderr text. + * Mirrors the context fields of `VercelError` in a flat shape, designed to be + * rendered directly with the output module (`outputData(payload, 'toon')` / + * `outputData(payload, 'json')`), giving agents a stable shape to parse + * instead of free-form stderr text. + * + * Optional keys are omitted entirely when absent, keeping the output minimal. */ export interface ErrorOutput { - /** Human-readable error message */ + /** Human-readable error message — *what* happened */ error: string /** Machine-readable error code */ code: string - /** Optional actionable hints (omitted when empty) */ - help?: string[] + /** Root-cause explanation — *why* it happened */ + reason?: string + /** Advisory tip for the developer/agent */ + hint?: string + /** Actionable step that resolves the error */ + fix?: string + /** URL to documentation for this error */ + link?: string +} + +/** Optional context fields accepted by {@link errorOutput}. */ +export interface ErrorOutputDetails { + reason?: string + hint?: string + fix?: string + link?: string } /** * Build a structured error payload from explicit fields. * - * The `help` key is omitted entirely when there are no suggestions, keeping - * the output minimal. + * Detail keys (`reason`, `hint`, `fix`, `link`) are included only when + * present, keeping the output minimal. * * @param message - Human-readable error message * @param code - Machine-readable error code - * @param suggestions - Optional actionable hints + * @param details - Optional context fields * @returns Structured error payload * * @example * ```typescript - * errorOutput('Invalid output format: xml', 'VALIDATION_ERROR', [ - * 'Supported formats: json, toon', - * ]) - * // { error: 'Invalid output format: xml', code: 'VALIDATION_ERROR', help: ['Supported formats: json, toon'] } + * errorOutput('Invalid output format: xml', 'VALIDATION_ERROR', { + * fix: 'Use one of the supported formats: json, toon', + * }) + * // { error: 'Invalid output format: xml', code: 'VALIDATION_ERROR', fix: 'Use one of...' } * ``` */ export function errorOutput( message: string, code: string, - suggestions: string[] = [], + details: ErrorOutputDetails = {}, ): ErrorOutput { const output: ErrorOutput = { error: message, code } - if (suggestions.length > 0) { - output.help = suggestions + if (details.reason !== undefined) { + output.reason = details.reason + } + if (details.hint !== undefined) { + output.hint = details.hint + } + if (details.fix !== undefined) { + output.fix = details.fix + } + if (details.link !== undefined) { + output.link = details.link } return output } @@ -50,7 +77,10 @@ export function errorOutput( /** * Convert any thrown value into a structured error payload. * - * - {@link CliError} preserves its `code` and `suggestions`. + * - {@link CliError} (and any `VercelError`) preserves its `code` plus the + * structured context fields (`reason`, `hint`, `fix`, `link`). + * - A `CliError` from a pre-`@vercel/error` copy of this toolkit maps its + * legacy `suggestions` array onto `hint`. * - A generic `Error` keeps its message with code `'UNKNOWN_ERROR'`. * - Any other value is stringified with code `'UNKNOWN_ERROR'`. * @@ -68,9 +98,22 @@ export function errorOutput( * ``` */ export function toErrorOutput(error: unknown): ErrorOutput { + if (isVercelError(error)) { + return errorOutput(error.message, error.code ?? UNKNOWN_ERROR, { + reason: error.reason, + hint: error.hint, + fix: error.fix, + link: error.link, + }) + } if (isCliError(error)) { - return errorOutput(error.message, error.code, error.suggestions ?? []) + // Structurally-detected copy from another bundle or an older toolkit + // version; legacy `suggestions` (if any) collapse into `hint`. + const suggestions = (error as { suggestions?: unknown }).suggestions + const hint = Array.isArray(suggestions) && suggestions.length > 0 + ? suggestions.join('\n') + : undefined + return errorOutput(error.message, error.code, { hint }) } - const message = error instanceof Error ? error.message : String(error) - return errorOutput(message, UNKNOWN_ERROR) + return errorOutput(getMessage(error) ?? String(error), UNKNOWN_ERROR) } diff --git a/src/index.ts b/src/index.ts index 4dd9a4b..cba66b4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,16 +15,38 @@ export { // Structured error class, codes, and exit-code mapping CliError, + // Error factory (from @vercel/error) + createErrors, // Structured error output errorOutput, exitCodeForError, + // Message / cause-chain helpers (from @vercel/error) + getMessage, + getRootCause, + // Guards (from @vercel/error) + hasCode, isCliError, + isError, + isErrorLike, + isVercelError, toErrorOutput, UNKNOWN_ERROR, VALIDATION_ERROR, + // Base error class (from @vercel/error) + VercelError, } from './errors/index.ts' -export type { ErrorOutput } from './errors/index.ts' +export type { + CliErrorOptions, + CreateErrorsOptions, + ErrorAttributes, + ErrorFactory, + ErrorLike, + ErrorMetadata, + ErrorOutput, + ErrorOutputDetails, + VercelErrorOptions, +} from './errors/index.ts' // ============================================================================ // i18n Module diff --git a/src/output/json.ts b/src/output/json.ts index a132887..6dbbe93 100644 --- a/src/output/json.ts +++ b/src/output/json.ts @@ -76,10 +76,10 @@ export function validateFormat(format: unknown): OutputFormat { const formatStr = format === undefined || format === null ? String(format) : `${format}` - throw new CliError( - `Invalid output format: ${formatStr}. Supported formats: ${VALID_FORMATS.join(', ')}`, - VALIDATION_ERROR, - ) + throw new CliError(`Invalid output format: ${formatStr}`, { + code: VALIDATION_ERROR, + fix: `Use one of the supported formats: ${VALID_FORMATS.join(', ')}`, + }) } return format } diff --git a/src/validation/numeric.ts b/src/validation/numeric.ts index d2d12fa..491f275 100644 --- a/src/validation/numeric.ts +++ b/src/validation/numeric.ts @@ -20,12 +20,12 @@ import { CliError, VALIDATION_ERROR } from '../errors/error.ts' export function validatePositiveInteger(value: string, fieldName = 'Value'): number { // Check if the string contains only digits if (!/^\d+$/.test(value)) { - throw new CliError(`${fieldName} must be a positive integer`, VALIDATION_ERROR) + throw new CliError(`${fieldName} must be a positive integer`, { code: VALIDATION_ERROR }) } const parsed = Number.parseInt(value, 10) if (Number.isNaN(parsed) || parsed <= 0) { - throw new CliError(`${fieldName} must be a positive integer`, VALIDATION_ERROR) + throw new CliError(`${fieldName} must be a positive integer`, { code: VALIDATION_ERROR }) } return parsed } @@ -50,7 +50,7 @@ export function validateNumericId(value: string | number, fieldName = 'ID'): num const num = typeof value === 'string' ? Number.parseInt(value, 10) : value if (Number.isNaN(num) || num <= 0 || !Number.isInteger(num)) { - throw new CliError(`${fieldName} must be a positive integer`, VALIDATION_ERROR) + throw new CliError(`${fieldName} must be a positive integer`, { code: VALIDATION_ERROR }) } return num @@ -81,7 +81,7 @@ export function validateRange( fieldName = 'Value', ): number { if (value < min || value > max) { - throw new CliError(`${fieldName} must be between ${min} and ${max}`, VALIDATION_ERROR) + throw new CliError(`${fieldName} must be between ${min} and ${max}`, { code: VALIDATION_ERROR }) } return value } diff --git a/src/validation/text.ts b/src/validation/text.ts index 24d4d19..f44ecf2 100644 --- a/src/validation/text.ts +++ b/src/validation/text.ts @@ -23,7 +23,7 @@ export function validateNonEmptyString( fieldName = 'Value', ): string { if (!value || value.trim().length === 0) { - throw new CliError(`${fieldName} cannot be empty`, VALIDATION_ERROR) + throw new CliError(`${fieldName} cannot be empty`, { code: VALIDATION_ERROR }) } return value.trim() } @@ -52,7 +52,7 @@ export function validateMaxLength( if (value.length > maxLength) { throw new CliError( `${fieldName} cannot exceed ${maxLength} characters (got ${value.length})`, - VALIDATION_ERROR, + { code: VALIDATION_ERROR }, ) } return value @@ -116,7 +116,7 @@ export function validatePattern( const description = patternDescription ? `be ${patternDescription}` : `match pattern ${pattern.toString()}` - throw new CliError(`${fieldName} must ${description}`, VALIDATION_ERROR) + throw new CliError(`${fieldName} must ${description}`, { code: VALIDATION_ERROR }) } return value } diff --git a/test/errors/error.test.ts b/test/errors/error.test.ts index ecd1f9b..76b4253 100644 --- a/test/errors/error.test.ts +++ b/test/errors/error.test.ts @@ -1,9 +1,12 @@ +import { VercelError } from '@vercel/error' import { describe, expect, test } from 'bun:test' import { CliError, exitCodeForError, isCliError, UNKNOWN_ERROR, VALIDATION_ERROR } from '../../src/errors/error' /** * A `CliError` from a second copy of the module: same shape, distinct class. * Reproduces the multi-instance scenario where `instanceof CliError` fails. + * Uses the legacy (pre-`@vercel/error`) shape with a `suggestions` array to + * also cover the cross-version boundary. */ class ForeignCliError extends Error { constructor( @@ -17,9 +20,10 @@ class ForeignCliError extends Error { } describe('CliError', () => { - test('should be an instance of Error', () => { + test('should be an instance of Error and VercelError', () => { const error = new CliError('boom') expect(error).toBeInstanceOf(Error) + expect(error).toBeInstanceOf(VercelError) expect(error).toBeInstanceOf(CliError) }) @@ -33,16 +37,26 @@ describe('CliError', () => { }) test('should carry a custom code', () => { - expect(new CliError('boom', 'NOT_FOUND').code).toBe('NOT_FOUND') + expect(new CliError('boom', { code: 'NOT_FOUND' }).code).toBe('NOT_FOUND') }) - test('should default suggestions to an empty array', () => { - expect(new CliError('boom').suggestions).toEqual([]) + test('should carry structured context fields', () => { + const error = new CliError('boom', { + code: 'NOT_FOUND', + reason: 'The issue was deleted.', + hint: 'It may have been transferred.', + fix: 'Run `list` to see open issues', + link: 'https://example.com/docs/errors/NOT_FOUND', + }) + expect(error.reason).toBe('The issue was deleted.') + expect(error.hint).toBe('It may have been transferred.') + expect(error.fix).toBe('Run `list` to see open issues') + expect(error.link).toBe('https://example.com/docs/errors/NOT_FOUND') }) - test('should carry suggestions', () => { - const error = new CliError('boom', 'NOT_FOUND', ['try this']) - expect(error.suggestions).toEqual(['try this']) + test('should chain a cause', () => { + const cause = new Error('root') + expect(new CliError('boom', { cause }).cause).toBe(cause) }) test('should set the error name', () => { @@ -51,7 +65,7 @@ describe('CliError', () => { test('should be catchable as a thrown error', () => { expect(() => { - throw new CliError('cannot be empty', VALIDATION_ERROR) + throw new CliError('cannot be empty', { code: VALIDATION_ERROR }) }).toThrow('cannot be empty') }) }) @@ -69,6 +83,10 @@ describe('isCliError', () => { expect(isCliError(new Error('boom'))).toBe(false) }) + test('should reject a plain VercelError', () => { + expect(isCliError(new VercelError('boom', { code: 'X' }))).toBe(false) + }) + test('should reject non-error values', () => { expect(isCliError('boom')).toBe(false) expect(isCliError({ name: 'CliError', code: 'X' })).toBe(false) @@ -78,15 +96,19 @@ describe('isCliError', () => { describe('exitCodeForError', () => { test('should return 2 for validation errors', () => { - expect(exitCodeForError(new CliError('bad', VALIDATION_ERROR))).toBe(2) + expect(exitCodeForError(new CliError('bad', { code: VALIDATION_ERROR }))).toBe(2) }) test('should return 2 for a validation CliError from another module copy', () => { expect(exitCodeForError(new ForeignCliError('bad', VALIDATION_ERROR))).toBe(2) }) + test('should return 2 for a plain VercelError tagged VALIDATION_ERROR', () => { + expect(exitCodeForError(new VercelError('bad', { code: VALIDATION_ERROR }))).toBe(2) + }) + test('should return 1 for CliError with a non-validation code', () => { - expect(exitCodeForError(new CliError('bad', 'NOT_FOUND'))).toBe(1) + expect(exitCodeForError(new CliError('bad', { code: 'NOT_FOUND' }))).toBe(1) }) test('should return 1 for a generic Error', () => { diff --git a/test/errors/output.test.ts b/test/errors/output.test.ts index e2dfb1d..7542268 100644 --- a/test/errors/output.test.ts +++ b/test/errors/output.test.ts @@ -1,8 +1,12 @@ +import { VercelError } from '@vercel/error' import { describe, expect, test } from 'bun:test' import { CliError, UNKNOWN_ERROR, VALIDATION_ERROR } from '../../src/errors/error' import { errorOutput, toErrorOutput } from '../../src/errors/output' -/** A `CliError` from a second module copy: same shape, distinct class. */ +/** + * A `CliError` from a second module copy: same shape, distinct class. + * Uses the legacy (pre-`@vercel/error`) shape with a `suggestions` array. + */ class ForeignCliError extends Error { constructor( message: string, @@ -15,48 +19,85 @@ class ForeignCliError extends Error { } describe('errorOutput', () => { - test('should build a minimal payload without suggestions', () => { + test('should build a minimal payload without details', () => { expect(errorOutput('boom', UNKNOWN_ERROR)).toEqual({ error: 'boom', code: UNKNOWN_ERROR, }) }) - test('should omit the help key when suggestions are empty', () => { - const output = errorOutput('boom', UNKNOWN_ERROR, []) - expect(output).not.toHaveProperty('help') + test('should omit detail keys that are undefined', () => { + const output = errorOutput('boom', UNKNOWN_ERROR, { hint: undefined }) + expect(output).not.toHaveProperty('hint') + expect(output).not.toHaveProperty('reason') + expect(output).not.toHaveProperty('fix') + expect(output).not.toHaveProperty('link') }) - test('should include the help key when suggestions are present', () => { - expect(errorOutput('boom', 'VALIDATION_ERROR', ['fix it'])).toEqual({ + test('should include detail keys when present', () => { + expect(errorOutput('boom', 'VALIDATION_ERROR', { + reason: 'input was empty', + hint: 'check the flag value', + fix: 'pass a non-empty string', + link: 'https://example.com/docs', + })).toEqual({ error: 'boom', code: 'VALIDATION_ERROR', - help: ['fix it'], + reason: 'input was empty', + hint: 'check the flag value', + fix: 'pass a non-empty string', + link: 'https://example.com/docs', }) }) }) describe('toErrorOutput', () => { - test('should preserve code and suggestions from a CliError', () => { - const error = new CliError('Issue not found', 'NOT_FOUND', ['run list']) + test('should preserve code and context fields from a CliError', () => { + const error = new CliError('Issue not found', { + code: 'NOT_FOUND', + hint: 'run list', + fix: 'pick an open issue id', + }) expect(toErrorOutput(error)).toEqual({ error: 'Issue not found', code: 'NOT_FOUND', - help: ['run list'], + hint: 'run list', + fix: 'pick an open issue id', + }) + }) + + test('should map a plain VercelError through', () => { + const error = new VercelError('Pool exhausted', { + code: 'pool_exhausted', + reason: 'all connections in use', + link: 'https://example.com/docs/pool', + }) + expect(toErrorOutput(error)).toEqual({ + error: 'Pool exhausted', + code: 'pool_exhausted', + reason: 'all connections in use', + link: 'https://example.com/docs/pool', + }) + }) + + test('should default a code-less VercelError to UNKNOWN_ERROR', () => { + expect(toErrorOutput(new VercelError('boom'))).toEqual({ + error: 'boom', + code: UNKNOWN_ERROR, }) }) - test('should preserve code and suggestions from a CliError of another module copy', () => { - const error = new ForeignCliError('Issue not found', 'NOT_FOUND', ['run list']) + test('should map legacy suggestions from a foreign CliError onto hint', () => { + const error = new ForeignCliError('Issue not found', 'NOT_FOUND', ['run list', 'check the id']) expect(toErrorOutput(error)).toEqual({ error: 'Issue not found', code: 'NOT_FOUND', - help: ['run list'], + hint: 'run list\ncheck the id', }) }) test('should map a validation CliError code through', () => { - const error = new CliError('Value cannot be empty', VALIDATION_ERROR) + const error = new CliError('Value cannot be empty', { code: VALIDATION_ERROR }) expect(toErrorOutput(error)).toEqual({ error: 'Value cannot be empty', code: 'VALIDATION_ERROR', From 9f03e73f5c9e8f97fdf6a765c51d9ab5c1a85684 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Thu, 2 Jul 2026 22:55:11 +0900 Subject: [PATCH 2/2] chore(errors): apply AI code review suggestions - carry reason/hint/fix/link through the structural isCliError fallback so a VercelError-derived copy that slips past isVercelError doesn't silently lose context fields (suggestions still wins for hint) - assert all four context fields explicitly on the CliError path in toErrorOutput tests, plus the foreign new-style copy fallback - ignore .impeccable local tool cache in eslint and git --- .gitignore | 3 +++ README.md | 4 ++-- eslint.config.js | 1 + src/errors/output.ts | 14 +++++++++++--- test/errors/output.test.ts | 36 ++++++++++++++++++++++++++++++++++++ 5 files changed, 53 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index a14702c..d6fd591 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,6 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json # Finder (MacOS) folder config .DS_Store + +# local tool caches +.impeccable diff --git a/README.md b/README.md index 7571ceb..82d1273 100644 --- a/README.md +++ b/README.md @@ -121,8 +121,8 @@ const username = validatePattern('user123', /^[a-z0-9]+$/, 'Username', 'alphanum Throw structured errors with machine-readable codes and render them consistently for agents to parse. Built on [`@vercel/error`](https://github.com/vercel-labs/error), -so every error can answer *what* happened (`message`), *why* (`reason`), *what -could help* (`hint`), *how to fix it* (`fix`), and *where to learn more* (`link`): +so every error can answer _what_ happened (`message`), _why_ (`reason`), _what +could help_ (`hint`), _how to fix it_ (`fix`), and _where to learn more_ (`link`): ```typescript import { diff --git a/eslint.config.js b/eslint.config.js index c66ed7a..826f5db 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -2,6 +2,7 @@ import antfu from '@antfu/eslint-config' export default antfu({ type: 'lib', + ignores: ['.impeccable'], typescript: true, formatters: true, stylistic: { diff --git a/src/errors/output.ts b/src/errors/output.ts index 83c6d81..99f1bac 100644 --- a/src/errors/output.ts +++ b/src/errors/output.ts @@ -108,12 +108,20 @@ export function toErrorOutput(error: unknown): ErrorOutput { } if (isCliError(error)) { // Structurally-detected copy from another bundle or an older toolkit - // version; legacy `suggestions` (if any) collapse into `hint`. + // version. Legacy `suggestions` (if any) collapse into `hint`; any + // new-style context fields present on the object are carried through so + // a VercelError-derived copy that slips past `isVercelError` (e.g. a + // future bundle with a different Symbol tag) doesn't silently lose them. const suggestions = (error as { suggestions?: unknown }).suggestions - const hint = Array.isArray(suggestions) && suggestions.length > 0 + const legacyHint = Array.isArray(suggestions) && suggestions.length > 0 ? suggestions.join('\n') : undefined - return errorOutput(error.message, error.code, { hint }) + return errorOutput(error.message, error.code, { + reason: error.reason, + hint: legacyHint ?? error.hint, + fix: error.fix, + link: error.link, + }) } return errorOutput(getMessage(error) ?? String(error), UNKNOWN_ERROR) } diff --git a/test/errors/output.test.ts b/test/errors/output.test.ts index 7542268..d24339f 100644 --- a/test/errors/output.test.ts +++ b/test/errors/output.test.ts @@ -66,6 +66,24 @@ describe('toErrorOutput', () => { }) }) + test('should preserve all four context fields from a CliError', () => { + const error = new CliError('Issue not found', { + code: 'NOT_FOUND', + reason: 'the issue was deleted', + hint: 'run list', + fix: 'pick an open issue id', + link: 'https://example.com/docs/errors/NOT_FOUND', + }) + expect(toErrorOutput(error)).toEqual({ + error: 'Issue not found', + code: 'NOT_FOUND', + reason: 'the issue was deleted', + hint: 'run list', + fix: 'pick an open issue id', + link: 'https://example.com/docs/errors/NOT_FOUND', + }) + }) + test('should map a plain VercelError through', () => { const error = new VercelError('Pool exhausted', { code: 'pool_exhausted', @@ -96,6 +114,24 @@ describe('toErrorOutput', () => { }) }) + test('should carry context fields from a foreign new-style CliError, with suggestions taking priority for hint', () => { + const error = new ForeignCliError('Issue not found', 'NOT_FOUND', ['run list']) + Object.assign(error, { + reason: 'the issue was deleted', + hint: 'native hint (overridden by suggestions)', + fix: 'pick an open issue id', + link: 'https://example.com/docs/errors/NOT_FOUND', + }) + expect(toErrorOutput(error)).toEqual({ + error: 'Issue not found', + code: 'NOT_FOUND', + reason: 'the issue was deleted', + hint: 'run list', + fix: 'pick an open issue id', + link: 'https://example.com/docs/errors/NOT_FOUND', + }) + }) + test('should map a validation CliError code through', () => { const error = new CliError('Value cannot be empty', { code: VALIDATION_ERROR }) expect(toErrorOutput(error)).toEqual({