From 5f999d5728603c71e8398d5de0894b52dedd7f55 Mon Sep 17 00:00:00 2001 From: Emp1500 Date: Sun, 28 Jun 2026 04:14:53 +0000 Subject: [PATCH 1/2] fix: replace ENOENT stack trace with FileReadError and human-readable stderr (#132) Introduce a typed `FileReadError` class in `readInput` so the function throws instead of calling `process.exit` directly. Each command handler catches it and writes a plain-text error to stderr: Error: "DESIGN.md" not found. Create a DESIGN.md file or pass "-" to read from stdin. This replaces the unhandled Node.js stack trace dump reported in #132. `readInput` is now unit-testable without mocking `process.exit`, and `FileReadError.filePath` identifies the specific missing file (important for `diff`, which reads two files). --- packages/cli/src/commands/diff.ts | 16 +++++++++++++--- packages/cli/src/commands/export.ts | 14 ++++++++++++-- packages/cli/src/commands/lint.ts | 14 ++++++++++++-- packages/cli/src/utils.test.ts | 19 ++++++++++++++++++- packages/cli/src/utils.ts | 19 ++++++++++--------- 5 files changed, 65 insertions(+), 17 deletions(-) diff --git a/packages/cli/src/commands/diff.ts b/packages/cli/src/commands/diff.ts index c3531f07..32f9476a 100644 --- a/packages/cli/src/commands/diff.ts +++ b/packages/cli/src/commands/diff.ts @@ -14,7 +14,7 @@ import { defineCommand } from 'citty'; import { lint } from '../linter/index.js'; -import { readInput, formatOutput, diffMaps } from '../utils.js'; +import { readInput, formatOutput, diffMaps, FileReadError } from '../utils.js'; import type { ComponentDef } from '../linter/model/spec.js'; export default defineCommand({ @@ -40,8 +40,18 @@ export default defineCommand({ }, }, async run({ args }) { - const beforeContent = await readInput(args.before); - const afterContent = await readInput(args.after); + let beforeContent: string, afterContent: string; + try { + beforeContent = await readInput(args.before); + afterContent = await readInput(args.after); + } catch (error) { + if (error instanceof FileReadError) { + process.stderr.write(`Error: "${error.filePath}" not found.\nCreate a DESIGN.md file or pass "-" to read from stdin.\n`); + process.exitCode = 2; + return; + } + throw error; + } const beforeReport = lint(beforeContent); const afterReport = lint(afterContent); diff --git a/packages/cli/src/commands/export.ts b/packages/cli/src/commands/export.ts index 1bb50d5b..bf517478 100644 --- a/packages/cli/src/commands/export.ts +++ b/packages/cli/src/commands/export.ts @@ -15,7 +15,7 @@ import { defineCommand } from 'citty'; import { lint, TailwindEmitterHandler, TailwindV4EmitterHandler, serializeTailwindV4 } from '../linter/index.js'; import { DtcgEmitterHandler } from '../linter/dtcg/handler.js'; -import { readInput } from '../utils.js'; +import { readInput, FileReadError } from '../utils.js'; const FORMATS = ['css-tailwind', 'json-tailwind', 'tailwind', 'dtcg'] as const; type ExportFormat = typeof FORMATS[number]; @@ -49,7 +49,17 @@ export default defineCommand({ return; } - const content = await readInput(args.file); + let content: string; + try { + content = await readInput(args.file); + } catch (error) { + if (error instanceof FileReadError) { + process.stderr.write(`Error: "${error.filePath}" not found.\nCreate a DESIGN.md file or pass "-" to read from stdin.\n`); + process.exitCode = 2; + return; + } + throw error; + } const report = lint(content); if (format === 'css-tailwind') { diff --git a/packages/cli/src/commands/lint.ts b/packages/cli/src/commands/lint.ts index 990901d9..5b87e9c2 100644 --- a/packages/cli/src/commands/lint.ts +++ b/packages/cli/src/commands/lint.ts @@ -14,7 +14,7 @@ import { defineCommand } from 'citty'; import { lint } from '../linter/index.js'; -import { readInput, formatOutput } from '../utils.js'; +import { readInput, formatOutput, FileReadError } from '../utils.js'; export default defineCommand({ meta: { @@ -34,7 +34,17 @@ export default defineCommand({ }, }, async run({ args }) { - const content = await readInput(args.file); + let content: string; + try { + content = await readInput(args.file); + } catch (error) { + if (error instanceof FileReadError) { + process.stderr.write(`Error: "${error.filePath}" not found.\nCreate a DESIGN.md file or pass "-" to read from stdin.\n`); + process.exitCode = 2; + return; + } + throw error; + } const report = lint(content); const output = { diff --git a/packages/cli/src/utils.test.ts b/packages/cli/src/utils.test.ts index f254ba31..afc9b287 100644 --- a/packages/cli/src/utils.test.ts +++ b/packages/cli/src/utils.test.ts @@ -13,7 +13,24 @@ // limitations under the License. import { describe, it, expect } from 'bun:test'; -import { formatOutput } from './utils.js'; +import { readInput, FileReadError, formatOutput } from './utils.js'; + +describe('readInput', () => { + it('throws FileReadError when file does not exist', async () => { + const err = await readInput('/nonexistent-path/DESIGN.md').catch(e => e); + expect(err).toBeInstanceOf(FileReadError); + }); + + it('FileReadError carries the missing file path', async () => { + const err = await readInput('/nonexistent-path/DESIGN.md').catch(e => e); + expect((err as FileReadError).filePath).toBe('/nonexistent-path/DESIGN.md'); + }); + + it('FileReadError carries the underlying OS error message', async () => { + const err = await readInput('/nonexistent-path/DESIGN.md').catch(e => e); + expect((err as FileReadError).message).toContain('ENOENT'); + }); +}); describe('formatOutput', () => { describe('--format markdown', () => { diff --git a/packages/cli/src/utils.ts b/packages/cli/src/utils.ts index 22756e4d..12c62d80 100644 --- a/packages/cli/src/utils.ts +++ b/packages/cli/src/utils.ts @@ -14,13 +14,20 @@ import { readFileSync } from 'node:fs'; +export class FileReadError extends Error { + readonly code = 'FILE_READ_ERROR' as const; + constructor(public readonly filePath: string, cause: unknown) { + super(cause instanceof Error ? cause.message : String(cause), { cause }); + this.name = 'FileReadError'; + } +} + /** * Read input from a file path or stdin ("-"). - * Never throws — returns the content string or exits with error JSON. + * Throws FileReadError if the file cannot be read. */ export async function readInput(filePath: string): Promise { if (filePath === '-') { - // Read from stdin const chunks: Buffer[] = []; for await (const chunk of process.stdin) { chunks.push(chunk as Buffer); @@ -31,13 +38,7 @@ export async function readInput(filePath: string): Promise { try { return readFileSync(filePath, 'utf-8'); } catch (error) { - console.error(JSON.stringify({ - error: 'FILE_READ_ERROR', - message: error instanceof Error ? error.message : String(error), - path: filePath, - })); - process.exitCode = 2; - throw error; // bubbles up, but process will exit with code 2 if uncaught + throw new FileReadError(filePath, error); } } From 454b6cda23295f2889b99cdfb9ef3ece2c698869 Mon Sep 17 00:00:00 2001 From: Emp1500 Date: Sun, 28 Jun 2026 04:52:41 +0000 Subject: [PATCH 2/2] fix: use error code to generate accurate FileReadError message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the hardcoded "not found" string in all command handlers with a friendlyMessage getter on FileReadError that checks the OS error code: - ENOENT → "not found. Create a DESIGN.md file or pass '-' for stdin." - EACCES → "could not be read: permission denied." - other → "could not be read: " This prevents a misleading "not found" message when the file exists but cannot be read due to permissions or other I/O errors. --- packages/cli/src/commands/diff.ts | 2 +- packages/cli/src/commands/export.ts | 2 +- packages/cli/src/commands/lint.ts | 2 +- packages/cli/src/utils.test.ts | 18 ++++++++++++++++++ packages/cli/src/utils.ts | 11 +++++++++++ 5 files changed, 32 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/commands/diff.ts b/packages/cli/src/commands/diff.ts index 32f9476a..e7a12b2d 100644 --- a/packages/cli/src/commands/diff.ts +++ b/packages/cli/src/commands/diff.ts @@ -46,7 +46,7 @@ export default defineCommand({ afterContent = await readInput(args.after); } catch (error) { if (error instanceof FileReadError) { - process.stderr.write(`Error: "${error.filePath}" not found.\nCreate a DESIGN.md file or pass "-" to read from stdin.\n`); + process.stderr.write(`Error: ${error.friendlyMessage}\n`); process.exitCode = 2; return; } diff --git a/packages/cli/src/commands/export.ts b/packages/cli/src/commands/export.ts index bf517478..90822bf7 100644 --- a/packages/cli/src/commands/export.ts +++ b/packages/cli/src/commands/export.ts @@ -54,7 +54,7 @@ export default defineCommand({ content = await readInput(args.file); } catch (error) { if (error instanceof FileReadError) { - process.stderr.write(`Error: "${error.filePath}" not found.\nCreate a DESIGN.md file or pass "-" to read from stdin.\n`); + process.stderr.write(`Error: ${error.friendlyMessage}\n`); process.exitCode = 2; return; } diff --git a/packages/cli/src/commands/lint.ts b/packages/cli/src/commands/lint.ts index 5b87e9c2..64071d4f 100644 --- a/packages/cli/src/commands/lint.ts +++ b/packages/cli/src/commands/lint.ts @@ -39,7 +39,7 @@ export default defineCommand({ content = await readInput(args.file); } catch (error) { if (error instanceof FileReadError) { - process.stderr.write(`Error: "${error.filePath}" not found.\nCreate a DESIGN.md file or pass "-" to read from stdin.\n`); + process.stderr.write(`Error: ${error.friendlyMessage}\n`); process.exitCode = 2; return; } diff --git a/packages/cli/src/utils.test.ts b/packages/cli/src/utils.test.ts index afc9b287..94fb5638 100644 --- a/packages/cli/src/utils.test.ts +++ b/packages/cli/src/utils.test.ts @@ -30,6 +30,24 @@ describe('readInput', () => { const err = await readInput('/nonexistent-path/DESIGN.md').catch(e => e); expect((err as FileReadError).message).toContain('ENOENT'); }); + + it('friendlyMessage says "not found" for ENOENT', async () => { + const err = await readInput('/nonexistent-path/DESIGN.md').catch(e => e); + expect((err as FileReadError).friendlyMessage).toContain('not found'); + }); + + it('friendlyMessage says "permission denied" for EACCES', () => { + const cause = Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES' }); + const err = new FileReadError('/some/file.md', cause); + expect(err.friendlyMessage).toContain('permission denied'); + expect(err.friendlyMessage).not.toContain('not found'); + }); + + it('friendlyMessage falls back to the raw message for unknown errors', () => { + const cause = Object.assign(new Error('ENOMEM: out of memory'), { code: 'ENOMEM' }); + const err = new FileReadError('/some/file.md', cause); + expect(err.friendlyMessage).toContain('ENOMEM'); + }); }); describe('formatOutput', () => { diff --git a/packages/cli/src/utils.ts b/packages/cli/src/utils.ts index 12c62d80..fac6e648 100644 --- a/packages/cli/src/utils.ts +++ b/packages/cli/src/utils.ts @@ -20,6 +20,17 @@ export class FileReadError extends Error { super(cause instanceof Error ? cause.message : String(cause), { cause }); this.name = 'FileReadError'; } + + get friendlyMessage(): string { + const errCode = (this.cause as { code?: string })?.code; + if (errCode === 'ENOENT') { + return `"${this.filePath}" not found. Create a DESIGN.md file or pass "-" to read from stdin.`; + } + if (errCode === 'EACCES') { + return `"${this.filePath}" could not be read: permission denied.`; + } + return `"${this.filePath}" could not be read: ${this.message}`; + } } /**