diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 37a9af0..7cf5ecb 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -561,6 +561,30 @@ pathMatch('/about', '/blog/*') → false | page.goto() | タイムアウト, ERR_NAME_NOT_RESOLVED | `@retryable` でリトライ後 `type='error'` で返却 | | DOM 解析 | evaluate 失敗 | catch でフォールバック値 | +### CLI 終了コード + +`crawl` コマンドと `pipeline` コマンドはエラーの種類に応じて異なる終了コードを返す: + +| コード | 定数 (`exit-code.ts`) | 意味 | +| ------ | --------------------- | ---------------------------------------------------------------- | +| `0` | `ExitCode.Success` | 成功 | +| `1` | `ExitCode.Fatal` | 致命的エラー(引数不足、内部エラー、スコープ内ページのエラー等) | +| `2` | `ExitCode.Warning` | 警告 — 外部リンクエラーのみ発生(クロール自体は成功) | + +### エラー分類フロー + +``` +CrawlerError.isExternal + ├── true → 外部エラー(DNS 失敗、証明書エラー等) + └── false → 内部エラー(スコープ内ページの失敗) + +CrawlAggregateError + ├── hasOnlyExternalErrors = true → exit 2(--strict 時は exit 1) + └── hasOnlyExternalErrors = false → exit 1 +``` + +`--strict` フラグを指定すると、外部リンクエラーのみの場合でも exit 1(致命的)として扱う。CI/CD パイプラインで外部リンクの一時的な障害を許容したい場合は `--strict` を省略する。 + --- ## 12. E2E テスト構成 diff --git a/README.md b/README.md index 37e001b..77da86c 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,7 @@ $ npx @nitpicker/cli crawl https://example.com | `--user-agent` | 文字列 | `Nitpicker/` | 不可 | HTTP リクエストのカスタム User-Agent 文字列 | | `--ignore-robots` | なし | なし | 不可 | robots.txt の制限を無視する | | `--output` `-o` | ファイルパス | 自動生成 | 不可 | アーカイブファイルの出力先パス | +| `--strict` | なし | なし | 不可 | 外部リンクエラーも致命的エラーとして扱う | | `--verbose` | なし | なし | 不可 | 実行中に詳細ログを標準出力に表示 | | `--silent` | なし | なし | 不可 | 実行中のログ出力を抑制 | | `--diff` | なし | なし | 不可 | 差分モード | @@ -83,6 +84,24 @@ $ npx @nitpicker/cli crawl https://example.com --output ./reports/site.nitpicker $ npx @nitpicker/cli crawl https://example.com -o custom-name ``` +#### 終了コード + +| コード | 意味 | 説明 | +| ------ | ---------------------- | -------------------------------------------------------------- | +| `0` | 成功 | エラーなしで完了 | +| `1` | 致命的エラー | 引数不足、内部エラー、スコープ内ページのスクレイプ失敗など | +| `2` | 警告(外部エラーのみ) | 外部リンク(DNS 失敗、証明書エラー等)のみでクロール自体は成功 | + +CI/CD パイプラインでは、外部リンクの一時的な障害でビルドが失敗しないよう exit code `2` を利用できる。`--strict` を指定すると外部リンクエラーも exit code `1`(致命的)として扱う。 + +```sh +# CI: 外部リンクエラーを無視(exit 2 を許容) +npx @nitpicker/cli crawl https://example.com || [ $? -eq 2 ] + +# CI: 外部リンクエラーも失敗にする +npx @nitpicker/cli crawl https://example.com --strict +``` + ##### Tips: 認証付き URL ```sh @@ -186,12 +205,12 @@ $ npx @nitpicker/cli pipeline crawl / analyze / report のオプションをすべて指定可能。各ステップに対応するフラグが自動的にルーティングされる。 -| カテゴリ | 主要オプション | 説明 | -| -------- | -------------------------------------------------------------------------------- | ---------------------------------------------- | -| crawl | `--interval`, `--parallels`, `--no-image`, `--scope`, `--exclude`, `--output` 等 | クロール動作の制御(crawl セクション参照) | -| analyze | `--all`, `--plugin`, `--search-keywords`, `--axe-lang` 等 | 分析プラグインの制御(analyze セクション参照) | -| report | `--sheet`, `--credentials`, `--config`, `--limit` | レポート出力の制御(report セクション参照) | -| 共通 | `--verbose`, `--silent` | ログ出力の制御 | +| カテゴリ | 主要オプション | 説明 | +| -------- | -------------------------------------------------------------------------------------------- | ---------------------------------------------- | +| crawl | `--interval`, `--parallels`, `--no-image`, `--scope`, `--exclude`, `--output`, `--strict` 等 | クロール動作の制御(crawl セクション参照) | +| analyze | `--all`, `--plugin`, `--search-keywords`, `--axe-lang` 等 | 分析プラグインの制御(analyze セクション参照) | +| report | `--sheet`, `--credentials`, `--config`, `--limit` | レポート出力の制御(report セクション参照) | +| 共通 | `--verbose`, `--silent` | ログ出力の制御 | > **注意**: `--resume`, `--diff` は crawl 専用モードのため pipeline では使用不可。 @@ -212,4 +231,14 @@ $ npx @nitpicker/cli pipeline https://example.com --all --silent --sheet "https: # 出力パス指定 $ npx @nitpicker/cli pipeline https://example.com --all --output ./reports/site + +# CI: 外部リンクエラーを無視(exit 2 を許容) +$ npx @nitpicker/cli pipeline https://example.com --all --silent || [ $? -eq 2 ] + +# CI: 外部リンクエラーも失敗にする +$ npx @nitpicker/cli pipeline https://example.com --all --silent --strict ``` + +#### 終了コード + +crawl コマンドと同じ終了コード体系に従う。詳細は [crawl の終了コード](#終了コード) を参照。 diff --git a/packages/@nitpicker/cli/src/commands/crawl.spec.ts b/packages/@nitpicker/cli/src/commands/crawl.spec.ts index f1fe2b4..f668080 100644 --- a/packages/@nitpicker/cli/src/commands/crawl.spec.ts +++ b/packages/@nitpicker/cli/src/commands/crawl.spec.ts @@ -1,9 +1,14 @@ -import type { CrawlerOrchestrator as OrchestratorType } from '@nitpicker/crawler'; +import type { + CrawlerOrchestrator as OrchestratorType, + CrawlerError, +} from '@nitpicker/crawler'; import path from 'node:path'; import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest'; +import { ExitCode } from '../exit-code.js'; + const mockCrawling = vi.fn(); const mockResume = vi.fn(); @@ -69,6 +74,7 @@ function createFlags(overrides: Partial = {}): CrawlFlags { userAgent: undefined, ignoreRobots: undefined, output: undefined, + strict: undefined, verbose: undefined, silent: undefined, diff: undefined, @@ -354,3 +360,185 @@ describe('crawl', () => { expect(mockLog).toHaveBeenCalledWith('Options: %O', flags); }); }); + +/** Sentinel error thrown by the process.exit mock to halt execution. */ +class ExitError extends Error { + /** The exit code passed to process.exit(). */ + readonly code: number; + constructor(code: number) { + super(`process.exit(${code})`); + this.code = code; + } +} + +/** + * Creates a fake CrawlerError for testing. + * @param isExternal - Whether the error is from an external URL. + */ +function createCrawlerError(isExternal: boolean): CrawlerError { + return { + pid: 1, + isMainProcess: true, + url: isExternal ? 'https://external.example.com' : 'https://example.com/page', + isExternal, + error: new Error('test error'), + }; +} + +describe('CrawlAggregateError', () => { + it('外部エラーのみの場合、hasOnlyExternalErrors が true', async () => { + const { CrawlAggregateError } = await import('./crawl.js'); + const error = new CrawlAggregateError([ + createCrawlerError(true), + createCrawlerError(true), + ]); + expect(error.hasOnlyExternalErrors).toBe(true); + }); + + it('内部エラーを含む場合、hasOnlyExternalErrors が false', async () => { + const { CrawlAggregateError } = await import('./crawl.js'); + const error = new CrawlAggregateError([ + createCrawlerError(true), + createCrawlerError(false), + ]); + expect(error.hasOnlyExternalErrors).toBe(false); + }); + + it('内部エラーのみの場合、hasOnlyExternalErrors が false', async () => { + const { CrawlAggregateError } = await import('./crawl.js'); + const error = new CrawlAggregateError([createCrawlerError(false)]); + expect(error.hasOnlyExternalErrors).toBe(false); + }); + + it('plain Error は内部エラーとして扱う', async () => { + const { CrawlAggregateError } = await import('./crawl.js'); + const error = new CrawlAggregateError([new Error('plain error')]); + expect(error.hasOnlyExternalErrors).toBe(false); + }); + + it('空の配列に対して hasOnlyExternalErrors が false', async () => { + const { CrawlAggregateError } = await import('./crawl.js'); + const error = new CrawlAggregateError([]); + expect(error.hasOnlyExternalErrors).toBe(false); + expect(error.errors).toHaveLength(0); + }); + + it('外部エラーのみの場合、message に "external" の内訳を含む', async () => { + const { CrawlAggregateError } = await import('./crawl.js'); + const error = new CrawlAggregateError([ + createCrawlerError(true), + createCrawlerError(true), + ]); + expect(error.message).toBe('Crawl completed with 2 error(s) (2 external).'); + }); + + it('混合エラーの場合、message に内部と外部の内訳を含む', async () => { + const { CrawlAggregateError } = await import('./crawl.js'); + const error = new CrawlAggregateError([ + createCrawlerError(false), + createCrawlerError(true), + createCrawlerError(false), + ]); + expect(error.message).toBe( + 'Crawl completed with 3 error(s) (2 internal, 1 external).', + ); + }); + + it('内部エラーのみの場合、message に "internal" の内訳を含む', async () => { + const { CrawlAggregateError } = await import('./crawl.js'); + const error = new CrawlAggregateError([createCrawlerError(false)]); + expect(error.message).toBe('Crawl completed with 1 error(s) (1 internal).'); + }); +}); + +describe('crawl exit codes', () => { + let exitSpy: ReturnType; + beforeEach(() => { + vi.clearAllMocks(); + setupFakeOrchestrator(); + exitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => { + throw new ExitError(code as number); + }); + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('外部エラーのみの場合、サマリーに "external" を含む', async () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + mockEventAssignments.mockRejectedValueOnce(createCrawlerError(true)); + + const { crawl } = await import('./crawl.js'); + + try { + await crawl(['https://example.com'], createFlags()); + } catch { + // exit mock throws + } + expect(consoleErrorSpy).toHaveBeenCalledWith( + '\nCompleted with 1 error(s) (1 external).', + ); + }); + + it('内部エラーの場合、サマリーに "internal" を含む', async () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + mockEventAssignments.mockRejectedValueOnce(createCrawlerError(false)); + + const { crawl } = await import('./crawl.js'); + + try { + await crawl(['https://example.com'], createFlags()); + } catch { + // exit mock throws + } + expect(consoleErrorSpy).toHaveBeenCalledWith( + '\nCompleted with 1 error(s) (1 internal).', + ); + }); + + it('--resume 経由の外部エラーでも exit code 2 で終了する', async () => { + mockEventAssignments.mockRejectedValueOnce(createCrawlerError(true)); + + const { crawl } = await import('./crawl.js'); + + await expect(crawl([], createFlags({ resume: '/tmp/stub' }))).rejects.toThrow( + ExitError, + ); + expect(exitSpy).toHaveBeenCalledWith(ExitCode.Warning); + }); + + it('外部エラーのみの場合、exit code 2 で終了する', async () => { + mockEventAssignments.mockRejectedValueOnce(createCrawlerError(true)); + + const { crawl } = await import('./crawl.js'); + + await expect(crawl(['https://example.com'], createFlags())).rejects.toThrow( + ExitError, + ); + expect(exitSpy).toHaveBeenCalledWith(ExitCode.Warning); + }); + + it('内部エラーを含む場合、exit code 1 で終了する', async () => { + mockEventAssignments.mockRejectedValueOnce(createCrawlerError(false)); + + const { crawl } = await import('./crawl.js'); + + await expect(crawl(['https://example.com'], createFlags())).rejects.toThrow( + ExitError, + ); + expect(exitSpy).toHaveBeenCalledWith(ExitCode.Fatal); + }); + + it('--strict 指定時、外部エラーのみでも exit code 1 で終了する', async () => { + mockEventAssignments.mockRejectedValueOnce(createCrawlerError(true)); + + const { crawl } = await import('./crawl.js'); + + await expect( + crawl(['https://example.com'], createFlags({ strict: true })), + ).rejects.toThrow(ExitError); + expect(exitSpy).toHaveBeenCalledWith(ExitCode.Fatal); + }); +}); diff --git a/packages/@nitpicker/cli/src/commands/crawl.ts b/packages/@nitpicker/cli/src/commands/crawl.ts index 1796c45..e8f82a5 100644 --- a/packages/@nitpicker/cli/src/commands/crawl.ts +++ b/packages/@nitpicker/cli/src/commands/crawl.ts @@ -10,6 +10,7 @@ import { log, verbosely } from '../crawl/debug.js'; import { diff } from '../crawl/diff.js'; import { eventAssignments } from '../crawl/event-assignments.js'; import { mapFlagsToCrawlConfig } from '../crawl/map-flags-to-crawl-config.js'; +import { ExitCode } from '../exit-code.js'; /** * Command definition for the `crawl` sub-command. @@ -112,6 +113,10 @@ export const commandDef = { shortFlag: 'o', desc: 'Output file path for the .nitpicker archive', }, + strict: { + type: 'boolean', + desc: 'Treat external link errors as fatal (exit code 1 instead of 2)', + }, verbose: { type: 'boolean', desc: 'Output verbose log to standard out', @@ -319,12 +324,28 @@ export async function crawl(args: string[], flags: CrawlFlags) { } } catch (error) { if (error instanceof CrawlAggregateError) { - process.exit(1); + const exitCode = + error.hasOnlyExternalErrors && !flags.strict ? ExitCode.Warning : ExitCode.Fatal; + process.exit(exitCode); } throw error; } } +/** + * Type guard that checks whether a collected error is a {@link CrawlerError} + * originating from an external URL. + * + * `CrawlerError` objects carry both `pid` and `isExternal` fields set by the crawler. + * Plain `Error` objects (e.g. from event handler rejections) are treated + * as internal errors. + * @param error - The error to check + * @returns `true` if the error is a `CrawlerError` with `isExternal` set to `true`. + */ +function isCrawlerExternalError(error: CrawlerError | Error): boolean { + return 'pid' in error && 'isExternal' in error && error.isExternal === true; +} + /** * Error thrown when one or more errors occurred during crawling. * Wraps the collected errors so callers can inspect them. @@ -332,12 +353,30 @@ export async function crawl(args: string[], flags: CrawlFlags) { export class CrawlAggregateError extends Error { /** The individual errors that occurred during crawling. */ readonly errors: readonly (CrawlerError | Error)[]; + + /** Whether all errors are from external (out-of-scope) URLs only. */ + readonly hasOnlyExternalErrors: boolean; + /** * @param errors - The individual errors collected during the crawl session. */ constructor(errors: (CrawlerError | Error)[]) { - super(`Crawl completed with ${errors.length} error(s).`); + const externalCount = errors.filter(isCrawlerExternalError).length; + const internalCount = errors.length - externalCount; + const hasOnlyExternal = errors.length > 0 && internalCount === 0; + + const parts: string[] = []; + if (internalCount > 0) { + parts.push(`${internalCount} internal`); + } + if (externalCount > 0) { + parts.push(`${externalCount} external`); + } + const breakdown = parts.length > 0 ? ` (${parts.join(', ')})` : ''; + super(`Crawl completed with ${errors.length} error(s)${breakdown}.`); + this.errors = errors; + this.hasOnlyExternalErrors = hasOnlyExternal; } } @@ -346,6 +385,17 @@ export class CrawlAggregateError extends Error { * @param errStack - Array of errors collected during the crawl session */ function formatCrawlErrors(errStack: (CrawlerError | Error)[]) { + const externalCount = errStack.filter(isCrawlerExternalError).length; + const internalCount = errStack.length - externalCount; + + const parts: string[] = []; + if (internalCount > 0) { + parts.push(`${internalCount} internal`); + } + if (externalCount > 0) { + parts.push(`${externalCount} external`); + } + // eslint-disable-next-line no-console - console.error(`\nCompleted with ${errStack.length} error(s).`); + console.error(`\nCompleted with ${errStack.length} error(s) (${parts.join(', ')}).`); } diff --git a/packages/@nitpicker/cli/src/commands/pipeline.spec.ts b/packages/@nitpicker/cli/src/commands/pipeline.spec.ts index 9648673..651bf64 100644 --- a/packages/@nitpicker/cli/src/commands/pipeline.spec.ts +++ b/packages/@nitpicker/cli/src/commands/pipeline.spec.ts @@ -1,13 +1,22 @@ +import type { CrawlerError } from '@nitpicker/crawler'; + import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest'; +import { ExitCode } from '../exit-code.js'; + import { analyze as analyzeFn } from './analyze.js'; -import { startCrawl as startCrawlFn } from './crawl.js'; +import { CrawlAggregateError, startCrawl as startCrawlFn } from './crawl.js'; import { pipeline } from './pipeline.js'; import { report as reportFn } from './report.js'; -vi.mock('./crawl.js', () => ({ - startCrawl: vi.fn(), -})); +vi.mock('./crawl.js', async () => { + // eslint-disable-next-line @typescript-eslint/consistent-type-imports + const actual = await vi.importActual('./crawl.js'); + return { + startCrawl: vi.fn(), + CrawlAggregateError: actual.CrawlAggregateError, + }; +}); vi.mock('./analyze.js', () => ({ analyze: vi.fn(), @@ -53,6 +62,7 @@ describe('pipeline command', () => { userAgent: undefined, ignoreRobots: undefined, output: undefined, + strict: undefined, all: undefined, plugin: undefined, searchKeywords: undefined, @@ -87,7 +97,7 @@ describe('pipeline command', () => { expect(consoleErrorSpy).toHaveBeenCalledWith( 'Usage: nitpicker pipeline [options]', ); - expect(exitSpy).toHaveBeenCalledWith(1); + expect(exitSpy).toHaveBeenCalledWith(ExitCode.Fatal); }); it('runs crawl then analyze without report when --sheet is not provided', async () => { @@ -323,4 +333,65 @@ describe('pipeline command', () => { expect(consoleLogSpy).toHaveBeenCalledWith('\n✅ [pipeline] All steps completed.'); }); + + it('exits with warning (code 2) when crawl has only external errors', async () => { + const externalError: CrawlerError = { + pid: 1, + isMainProcess: true, + url: 'https://external.example.com', + isExternal: true, + error: new Error('DNS lookup failed'), + }; + vi.mocked(startCrawlFn).mockRejectedValue(new CrawlAggregateError([externalError])); + + await expect(pipeline(['https://example.com'], defaultFlags)).rejects.toThrow( + ExitError, + ); + expect(exitSpy).toHaveBeenCalledWith(ExitCode.Warning); + }); + + it('propagates CrawlAggregateError with internal errors', async () => { + const internalError: CrawlerError = { + pid: 1, + isMainProcess: true, + url: 'https://example.com/page', + isExternal: false, + error: new Error('Internal failure'), + }; + vi.mocked(startCrawlFn).mockRejectedValue(new CrawlAggregateError([internalError])); + + await expect(pipeline(['https://example.com'], defaultFlags)).rejects.toThrow( + CrawlAggregateError, + ); + }); + + it('exits with fatal (code 1) when --strict and external-only errors', async () => { + const externalError: CrawlerError = { + pid: 1, + isMainProcess: true, + url: 'https://external.example.com', + isExternal: true, + error: new Error('DNS lookup failed'), + }; + vi.mocked(startCrawlFn).mockRejectedValue(new CrawlAggregateError([externalError])); + + await expect( + pipeline(['https://example.com'], { ...defaultFlags, strict: true }), + ).rejects.toThrow(CrawlAggregateError); + }); + + it('passes --strict flag to startCrawl', async () => { + vi.mocked(startCrawlFn).mockResolvedValue('/tmp/site.nitpicker'); + vi.mocked(analyzeFn).mockResolvedValue(); + + await pipeline(['https://example.com'], { + ...defaultFlags, + strict: true, + }); + + expect(startCrawlFn).toHaveBeenCalledWith( + ['https://example.com'], + expect.objectContaining({ strict: true }), + ); + }); }); diff --git a/packages/@nitpicker/cli/src/commands/pipeline.ts b/packages/@nitpicker/cli/src/commands/pipeline.ts index 5e8da29..0f48648 100644 --- a/packages/@nitpicker/cli/src/commands/pipeline.ts +++ b/packages/@nitpicker/cli/src/commands/pipeline.ts @@ -1,7 +1,10 @@ import type { CommandDef, InferFlags } from '@d-zero/roar'; +import { ExitCode } from '../exit-code.js'; +import { formatCliError } from '../format-cli-error.js'; + import { analyze } from './analyze.js'; -import { startCrawl } from './crawl.js'; +import { CrawlAggregateError, startCrawl } from './crawl.js'; import { report } from './report.js'; /** @@ -105,6 +108,10 @@ export const commandDef = { shortFlag: 'o', desc: 'Output file path for the .nitpicker archive', }, + strict: { + type: 'boolean', + desc: 'Treat external link errors as fatal (exit code 1 instead of 2)', + }, // analyze flags all: { type: 'boolean', @@ -178,7 +185,8 @@ type PipelineFlags = InferFlags; * to the analyze step. If `--sheet` is provided, the report step runs * last to publish results to Google Sheets. * - * Errors from any step propagate to the caller as exceptions. + * When the crawl step encounters only external link errors and `--strict` + * is not set, the pipeline exits with code 2 (warning). * @param args - Positional arguments; first argument is the root URL to crawl. * @param flags - Parsed CLI flags from the `pipeline` command. * @returns Resolves when all pipeline steps complete. @@ -191,41 +199,57 @@ export async function pipeline(args: string[], flags: PipelineFlags) { console.error('Error: No URL specified.'); // eslint-disable-next-line no-console console.error('Usage: nitpicker pipeline [options]'); - process.exit(1); + process.exit(ExitCode.Fatal); } const silent = !!flags.silent; + const verbose = !!flags.verbose; // Step 1: Crawl if (!silent) { // eslint-disable-next-line no-console console.log('\n📡 [pipeline] Step 1/3: Crawling...'); } - const archivePath = await startCrawl([siteUrl], { - interval: flags.interval, - image: flags.image, - fetchExternal: flags.fetchExternal, - parallels: flags.parallels, - recursive: flags.recursive, - scope: flags.scope, - exclude: flags.exclude, - excludeKeyword: flags.excludeKeyword, - excludeUrl: flags.excludeUrl, - disableQueries: flags.disableQueries, - imageFileSizeThreshold: flags.imageFileSizeThreshold, - single: flags.single, - maxExcludedDepth: flags.maxExcludedDepth, - retry: flags.retry, - list: flags.list, - listFile: flags.listFile, - userAgent: flags.userAgent, - ignoreRobots: flags.ignoreRobots, - output: flags.output, - verbose: flags.verbose, - silent: flags.silent, - resume: undefined, - diff: undefined, - }); + + let archivePath: string; + try { + archivePath = await startCrawl([siteUrl], { + interval: flags.interval, + image: flags.image, + fetchExternal: flags.fetchExternal, + parallels: flags.parallels, + recursive: flags.recursive, + scope: flags.scope, + exclude: flags.exclude, + excludeKeyword: flags.excludeKeyword, + excludeUrl: flags.excludeUrl, + disableQueries: flags.disableQueries, + imageFileSizeThreshold: flags.imageFileSizeThreshold, + single: flags.single, + maxExcludedDepth: flags.maxExcludedDepth, + retry: flags.retry, + list: flags.list, + listFile: flags.listFile, + userAgent: flags.userAgent, + ignoreRobots: flags.ignoreRobots, + output: flags.output, + strict: flags.strict, + verbose: flags.verbose, + silent: flags.silent, + resume: undefined, + diff: undefined, + }); + } catch (error) { + if ( + error instanceof CrawlAggregateError && + error.hasOnlyExternalErrors && + !flags.strict + ) { + formatCliError(error, verbose); + process.exit(ExitCode.Warning); + } + throw error; + } // Step 2: Analyze if (!silent) { diff --git a/packages/@nitpicker/cli/src/exit-code.spec.ts b/packages/@nitpicker/cli/src/exit-code.spec.ts new file mode 100644 index 0000000..4874a53 --- /dev/null +++ b/packages/@nitpicker/cli/src/exit-code.spec.ts @@ -0,0 +1,17 @@ +import { describe, it, expect } from 'vitest'; + +import { ExitCode } from './exit-code.js'; + +describe('ExitCode', () => { + it('Success is 0', () => { + expect(ExitCode.Success).toBe(0); + }); + + it('Fatal is 1', () => { + expect(ExitCode.Fatal).toBe(1); + }); + + it('Warning is 2', () => { + expect(ExitCode.Warning).toBe(2); + }); +}); diff --git a/packages/@nitpicker/cli/src/exit-code.ts b/packages/@nitpicker/cli/src/exit-code.ts new file mode 100644 index 0000000..d0544ed --- /dev/null +++ b/packages/@nitpicker/cli/src/exit-code.ts @@ -0,0 +1,11 @@ +/** + * CLI exit codes for distinguishing between success, fatal errors, and partial success. + */ +export const ExitCode = { + /** All operations completed successfully with no errors. */ + Success: 0, + /** A fatal error occurred (e.g. missing arguments, internal errors, crawl failure). */ + Fatal: 1, + /** Operations completed but with non-fatal warnings (e.g. external link errors only). */ + Warning: 2, +} as const; diff --git a/packages/@nitpicker/cli/src/index.ts b/packages/@nitpicker/cli/src/index.ts index b7216f6..9e7585c 100644 --- a/packages/@nitpicker/cli/src/index.ts +++ b/packages/@nitpicker/cli/src/index.ts @@ -4,6 +4,8 @@ import { analyze, commandDef as analyzeDef } from './commands/analyze.js'; import { crawl, commandDef as crawlDef } from './commands/crawl.js'; import { pipeline, commandDef as pipelineDef } from './commands/pipeline.js'; import { report, commandDef as reportDef } from './commands/report.js'; +import { ExitCode } from './exit-code.js'; +import { formatCliError } from './format-cli-error.js'; process.title = 'Nitpicker CLI'; @@ -18,21 +20,26 @@ const cli = parseCli({ onError: () => true, }); -switch (cli.command) { - case 'crawl': { - await crawl(cli.args, cli.flags); - break; - } - case 'analyze': { - await analyze(cli.args, cli.flags); - break; - } - case 'report': { - await report(cli.args, cli.flags); - break; - } - case 'pipeline': { - await pipeline(cli.args, cli.flags); - break; +try { + switch (cli.command) { + case 'crawl': { + await crawl(cli.args, cli.flags); + break; + } + case 'analyze': { + await analyze(cli.args, cli.flags); + break; + } + case 'report': { + await report(cli.args, cli.flags); + break; + } + case 'pipeline': { + await pipeline(cli.args, cli.flags); + break; + } } +} catch (error) { + formatCliError(error, true); + process.exit(ExitCode.Fatal); } diff --git a/packages/@nitpicker/crawler/src/crawler-orchestrator.ts b/packages/@nitpicker/crawler/src/crawler-orchestrator.ts index e767192..66c8fb3 100644 --- a/packages/@nitpicker/crawler/src/crawler-orchestrator.ts +++ b/packages/@nitpicker/crawler/src/crawler-orchestrator.ts @@ -131,6 +131,7 @@ export class CrawlerOrchestrator extends EventEmitter { pid: process.pid, isMainProcess: true, url: null, + isExternal: false, error: e instanceof Error ? e : new Error(String(e)), }); }); diff --git a/packages/@nitpicker/crawler/src/crawler/crawler.ts b/packages/@nitpicker/crawler/src/crawler/crawler.ts index ef19a36..c25f487 100644 --- a/packages/@nitpicker/crawler/src/crawler/crawler.ts +++ b/packages/@nitpicker/crawler/src/crawler/crawler.ts @@ -179,6 +179,7 @@ export default class Crawler extends EventEmitter { pid: process.pid, isMainProcess: true, url: url.href, + isExternal: false, error: error instanceof Error ? error : new Error(String(error)), }); void this.emit('crawlEnd', {}); @@ -220,6 +221,7 @@ export default class Crawler extends EventEmitter { pid: process.pid, isMainProcess: true, url: pageList[0]!.href, + isExternal: false, error: error instanceof Error ? error : new Error(String(error)), }); void this.emit('crawlEnd', {}); @@ -362,6 +364,7 @@ export default class Crawler extends EventEmitter { this.#scope, this.#options, ); + const isExternal = isExternalUrl(url, this.#scope); if (pageResult) { if (pageResult.isExternal) { void this.emit('externalPage', { result: pageResult }); @@ -373,6 +376,7 @@ export default class Crawler extends EventEmitter { pid: process.pid, isMainProcess: true, url: url.href, + isExternal, error, }); break; diff --git a/packages/@nitpicker/crawler/src/utils/types/types.ts b/packages/@nitpicker/crawler/src/utils/types/types.ts index 65a7aaa..b2842f1 100644 --- a/packages/@nitpicker/crawler/src/utils/types/types.ts +++ b/packages/@nitpicker/crawler/src/utils/types/types.ts @@ -60,6 +60,9 @@ export interface CrawlerError { /** The URL being processed when the error occurred, or `null` if not applicable. */ url: string | null; + /** Whether the error occurred while processing an external (out-of-scope) URL. */ + isExternal: boolean; + /** The error object. */ error: Error; }