diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts index b74a92cc..9cfc65b7 100644 --- a/packages/core/src/errors.ts +++ b/packages/core/src/errors.ts @@ -1,5 +1,14 @@ import { log } from './logger.js'; +/** + * Brand recognised across copies of this module. `@releasekit/release` bundles version, notes and + * publish from their own builds, and each of those inlines its own copy of core — so one process can + * hold several distinct `ReleaseKitError` classes, and `instanceof` against any one of them misses an + * error thrown through another (its code then degrades to GENERAL_ERROR). `Symbol.for` keys a + * process-wide registry, so every copy stamps and recognises the same brand. + */ +const RELEASEKIT_ERROR_BRAND = Symbol.for('@releasekit/core:ReleaseKitError'); + /** * Base error class that all releasekit errors should extend. * Provides consistent error handling with codes and suggestions. @@ -11,6 +20,8 @@ export abstract class ReleaseKitError extends Error { constructor(message: string) { super(message); this.name = this.constructor.name; + // Non-enumerable, so the brand never shows up in a spread, JSON output, or a test's toEqual. + Object.defineProperty(this, RELEASEKIT_ERROR_BRAND, { value: true }); } logError(): void { @@ -24,8 +35,16 @@ export abstract class ReleaseKitError extends Error { } } + /** True for an error from any copy of core (see {@link RELEASEKIT_ERROR_BRAND}), not just this one. */ static isReleaseKitError(error: unknown): error is ReleaseKitError { - return error instanceof ReleaseKitError; + if (error instanceof ReleaseKitError) { + return true; + } + return ( + typeof error === 'object' && + error !== null && + (error as { [RELEASEKIT_ERROR_BRAND]?: unknown })[RELEASEKIT_ERROR_BRAND] === true + ); } } diff --git a/packages/core/test/unit/errors.spec.ts b/packages/core/test/unit/errors.spec.ts index c2e89a23..c41d5074 100644 --- a/packages/core/test/unit/errors.spec.ts +++ b/packages/core/test/unit/errors.spec.ts @@ -93,6 +93,25 @@ describe('ReleaseKitError', () => { expect(ReleaseKitError.isReleaseKitError('error')).toBe(false); expect(ReleaseKitError.isReleaseKitError({})).toBe(false); }); + + it('should recognise an error from another copy of this module', async () => { + vi.resetModules(); + const other = await import('../../src/errors.js'); + class OtherCopyError extends other.ReleaseKitError { + readonly code = 'CONFIG_ERROR'; + readonly suggestions: string[] = []; + } + const error = new OtherCopyError('from the other copy'); + + expect(other.ReleaseKitError).not.toBe(ReleaseKitError); + expect(error instanceof ReleaseKitError).toBe(false); + expect(ReleaseKitError.isReleaseKitError(error)).toBe(true); + }); + + it('should keep the brand out of enumerable properties', () => { + const error = new TestError('Test'); + expect(Object.getOwnPropertySymbols({ ...error })).toEqual([]); + }); }); }); diff --git a/packages/release/test/unit/commands/cross-bundle-errors.spec.ts b/packages/release/test/unit/commands/cross-bundle-errors.spec.ts new file mode 100644 index 00000000..cff03f3f --- /dev/null +++ b/packages/release/test/unit/commands/cross-bundle-errors.spec.ts @@ -0,0 +1,61 @@ +import { EXIT_CODES, ReleaseKitError } from '@releasekit/core'; +import { LLMError } from '@releasekit/notes'; +import { BasePublishError, PublishErrorCode } from '@releasekit/publish'; +import { BaseVersionError } from '@releasekit/version'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../src/release.js'); + +import { createReleaseCommand } from '../../../src/commands/release-command.js'; +import { runRelease } from '../../../src/release.js'; + +describe('release --json when a stage fails', () => { + let exitSpy: ReturnType; + let logSpy: ReturnType; + + beforeEach(() => { + exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + vi.spyOn(console, 'error').mockImplementation(() => undefined); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it.each([ + { + stage: 'version', + error: new BaseVersionError('git tag failed', 'GIT_ERROR'), + code: 'GIT_ERROR', + retryable: false, + exit: EXIT_CODES.GIT_ERROR, + }, + { + stage: 'notes', + error: new LLMError('provider timed out', { retryable: true }), + code: 'LLM_ERROR', + retryable: true, + exit: EXIT_CODES.LLM_ERROR, + }, + { + stage: 'publish', + error: new BasePublishError('npm auth is not configured', PublishErrorCode.CONFIG_ERROR), + code: 'CONFIG_ERROR', + retryable: false, + exit: EXIT_CODES.CONFIG_ERROR, + }, + ])('should report $code from the $stage stage and exit $exit', async ({ stage, error, code, retryable, exit }) => { + expect( + error, + `the ${stage} build shares release's ReleaseKitError, so this case no longer tests a separate copy of core`, + ).not.toBeInstanceOf(ReleaseKitError); + vi.mocked(runRelease).mockRejectedValue(error); + + await createReleaseCommand().parseAsync(['node', 'release', '--json']); + + const envelope = JSON.parse(logSpy.mock.calls[0]?.[0] as string); + expect(envelope.errors).toMatchObject([{ code, retryable, message: error.message }]); + expect(exitSpy).toHaveBeenCalledWith(exit); + }); +});