From ae542893da11f63636f29d5cb6da41485b45c013 Mon Sep 17 00:00:00 2001 From: alexion Date: Tue, 28 Jul 2026 21:21:23 -0400 Subject: [PATCH] refactor: model bin errors as neverthrow Result values (task 0003) Split the view command out of the entry point and make its failure a value rather than an exception. index.ts is now scaffolding plus dispatch; view.ts owns the behaviour, returning Result (a plain-data tagged union) via neverthrow, with try/catch confined to a readFile adapter. The entry point matches the Result at the boundary. Because viewFile is a pure in-process function it is now unit-tested directly (and counted by coverage), while the subprocess tests stay as the end-to-end check. ADR 0008 records the errors-as-values convention. --- .../0008-errors-as-values-at-cli-boundary.md | 30 ++++++++++++++ bun.lock | 5 +++ bun.nix | 8 ++++ packages/bin/package.json | 1 + packages/bin/src/index.ts | 20 ++++----- packages/bin/src/view.ts | 27 ++++++++++++ packages/bin/src/view_test.ts | 41 +++++++++++++++++++ 7 files changed, 120 insertions(+), 12 deletions(-) create mode 100644 .claude/adr/0008-errors-as-values-at-cli-boundary.md create mode 100644 packages/bin/src/view.ts create mode 100644 packages/bin/src/view_test.ts diff --git a/.claude/adr/0008-errors-as-values-at-cli-boundary.md b/.claude/adr/0008-errors-as-values-at-cli-boundary.md new file mode 100644 index 0000000..c66a84f --- /dev/null +++ b/.claude/adr/0008-errors-as-values-at-cli-boundary.md @@ -0,0 +1,30 @@ +# ADR 0008 — Errors as Values at the CLI Boundary + +**Status**: Accepted + +## Context + +`@kitchen-md/bin`'s `view` command performs a fallible operation: reading a file from disk. +Node's `readFileSync` signals failure by throwing. +Two idioms are available: let exceptions propagate and catch them at the entry point, or represent failure as a value the type system tracks. +`@kitchen-md/core`'s parser already takes the second path — it is a total function that never throws and reports problems through the Document AST's diagnostics (see ADR 0004). + +## Decision + +Model fallible operations in `@kitchen-md/bin` as a neverthrow `Result`, with error types expressed as plain-data tagged unions (e.g. `ViewError = { tag: "read-failed"; … }`). +A throwing API is wrapped in a small adapter that converts the exception into an `err`, so nothing above the adapter leaks exceptions. +The CLI entry point matches the `Result` at the boundary: stdout on success, stderr plus a non-zero exit on failure. + +## Rationale + +It keeps the bin layer consistent with core's total-function stance, so the whole codebase treats recoverable failure as data rather than control flow. +The possibility of failure becomes visible in a function's signature instead of hidden behind a `throw`. +The success value cannot be read without first handling the error case, which removes a class of mistakes at compile time. +A tagged-union error type gives exhaustive handling: a new failure mode is a new tag that every match must account for. + +## Consequences + +`@kitchen-md/bin` takes a dependency on neverthrow. +`try`/`catch` is confined to the thin adapters that wrap throwing APIs, and the rest of the layer is exception-free. +Must-use enforcement — that a `Result` is never silently dropped — is a convention (always terminate a `Result` at a `.match` at the boundary), not a lint rule, because the project uses Biome alone and does not add ESLint's `eslint-plugin-neverthrow` for now. +New failure modes stay additive: a new tag on the error union, handled at the boundary. diff --git a/bun.lock b/bun.lock index edaebe9..c80262e 100644 --- a/bun.lock +++ b/bun.lock @@ -21,6 +21,7 @@ "@kitchen-md/core": "workspace:*", "chalk": "^5.6.2", "commander": "^15.0.0", + "neverthrow": "^8.2.0", "yaml": "^2.9.0", }, }, @@ -60,6 +61,8 @@ "@nodable/entities": ["@nodable/entities@3.0.0", "", {}, "sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw=="], + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.62.3", "", { "os": "linux", "cpu": "x64" }, "sha512-V4KtWtQfAFMU7+9/A/VDps/VI8CHd3cYz0L8sgJzz8qK7eY7wI4ruFD82UYIYvW9Z4DtlTfhQcsl4XyPHW5uSg=="], + "@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="], "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="], @@ -168,6 +171,8 @@ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "neverthrow": ["neverthrow@8.2.0", "", { "optionalDependencies": { "@rollup/rollup-linux-x64-gnu": "^4.24.0" } }, "sha512-kOCT/1MCPAxY5iUV3wytNFUMUolzuwd/VF/1KCx7kf6CutrOsTie+84zTGTpgQycjvfLdBBdvBvFLqFD2c0wkQ=="], + "path-expression-matcher": ["path-expression-matcher@1.6.2", "", {}, "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ=="], "remark-frontmatter": ["remark-frontmatter@5.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-frontmatter": "^2.0.0", "micromark-extension-frontmatter": "^2.0.0", "unified": "^11.0.0" } }, "sha512-XTFYvNASMe5iPN0719nPrdItC9aU0ssC4v14mH1BCi1u0n1gAocqcujWUrByftZTbLhRtiKRyjYTSIOcr69UVQ=="], diff --git a/bun.nix b/bun.nix index ac06a6a..7cb5de8 100644 --- a/bun.nix +++ b/bun.nix @@ -55,6 +55,10 @@ url = "https://registry.npmjs.org/@nodable/entities/-/entities-3.0.0.tgz"; hash = "sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw=="; }; + "@rollup/rollup-linux-x64-gnu@4.62.3" = fetchurl { + url = "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.3.tgz"; + hash = "sha512-V4KtWtQfAFMU7+9/A/VDps/VI8CHd3cYz0L8sgJzz8qK7eY7wI4ruFD82UYIYvW9Z4DtlTfhQcsl4XyPHW5uSg=="; + }; "@types/debug@4.1.13" = fetchurl { url = "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz"; hash = "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="; @@ -271,6 +275,10 @@ url = "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz"; hash = "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="; }; + "neverthrow@8.2.0" = fetchurl { + url = "https://registry.npmjs.org/neverthrow/-/neverthrow-8.2.0.tgz"; + hash = "sha512-kOCT/1MCPAxY5iUV3wytNFUMUolzuwd/VF/1KCx7kf6CutrOsTie+84zTGTpgQycjvfLdBBdvBvFLqFD2c0wkQ=="; + }; "path-expression-matcher@1.6.2" = fetchurl { url = "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz"; hash = "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ=="; diff --git a/packages/bin/package.json b/packages/bin/package.json index fc9261c..9423872 100644 --- a/packages/bin/package.json +++ b/packages/bin/package.json @@ -14,6 +14,7 @@ "@kitchen-md/core": "workspace:*", "chalk": "^5.6.2", "commander": "^15.0.0", + "neverthrow": "^8.2.0", "yaml": "^2.9.0" } } diff --git a/packages/bin/src/index.ts b/packages/bin/src/index.ts index 62b9343..4f29454 100644 --- a/packages/bin/src/index.ts +++ b/packages/bin/src/index.ts @@ -1,8 +1,6 @@ #!/usr/bin/env bun -import { readFileSync } from "node:fs"; -import { parse } from "@kitchen-md/core"; import { Command } from "commander"; -import { render } from "./render.ts"; +import { formatViewError, viewFile } from "./view.ts"; const program = new Command(); @@ -13,15 +11,13 @@ program .description("Render a Recipe File to the terminal") .argument("", "path to a Recipe File") .action((file: string) => { - let content: string; - try { - content = readFileSync(file, "utf8"); - } catch (error) { - const reason = error instanceof Error ? error.message : String(error); - process.stderr.write(`kitchen: cannot read '${file}': ${reason}\n`); - process.exit(1); - } - process.stdout.write(render(parse(content))); + viewFile(file).match( + (output) => process.stdout.write(output), + (error) => { + process.stderr.write(`kitchen: ${formatViewError(error)}\n`); + process.exit(1); + }, + ); }); program.parse(); diff --git a/packages/bin/src/view.ts b/packages/bin/src/view.ts new file mode 100644 index 0000000..4db9cfa --- /dev/null +++ b/packages/bin/src/view.ts @@ -0,0 +1,27 @@ +import { readFileSync } from "node:fs"; +import { parse } from "@kitchen-md/core"; +import { err, ok, type Result } from "neverthrow"; +import { render } from "./render.ts"; + +export type ViewError = { tag: "read-failed"; path: string; cause: string }; + +export function viewFile(path: string): Result { + return readFile(path).map((content) => render(parse(content))); +} + +export function formatViewError(error: ViewError): string { + switch (error.tag) { + case "read-failed": + return `cannot read '${error.path}': ${error.cause}`; + } +} + +// The one place a throwing API is turned into a Result; nothing above this leaks exceptions. +function readFile(path: string): Result { + try { + return ok(readFileSync(path, "utf8")); + } catch (error) { + const cause = error instanceof Error ? error.message : String(error); + return err({ tag: "read-failed", path, cause }); + } +} diff --git a/packages/bin/src/view_test.ts b/packages/bin/src/view_test.ts new file mode 100644 index 0000000..d8eebcb --- /dev/null +++ b/packages/bin/src/view_test.ts @@ -0,0 +1,41 @@ +import { describe, expect, test } from "bun:test"; +import { join } from "node:path"; + +import { stripAnsi } from "./test-support.ts"; +import { formatViewError, viewFile } from "./view.ts"; + +const FIXTURE = join(import.meta.dir, "..", "..", "..", "fixtures", "prose.md"); + +describe("view", () => { + test("returns Ok with rendered output for a real recipe", () => { + const result = viewFile(FIXTURE); + + expect(result.isOk()).toBe(true); + + const output = stripAnsi(result._unsafeUnwrap()); + expect(output).toContain("title: Buttered Toast"); + expect(output).toContain("servings: 2"); + expect(output).toMatch(/─+/); + expect(output).toContain("Buttered Toast"); + expect(output).toContain("Method"); + expect(output).toContain("Toast the bread until golden on both sides."); + expect(output).toContain("Cut into triangles and serve at once."); + }); + + test("returns Err with a read-failed error for a missing file", () => { + const result = viewFile("/no/such/kitchen-view-missing.md"); + + expect(result.isErr()).toBe(true); + + const error = result._unsafeUnwrapErr(); + expect(error.tag).toBe("read-failed"); + expect(error.path).toBe("/no/such/kitchen-view-missing.md"); + expect(error.cause).toMatch(/ENOENT|no such file/i); + }); + + test("formats a read-failed error as a human-readable message", () => { + expect(formatViewError({ tag: "read-failed", path: "/tmp/x.md", cause: "boom" })).toBe( + "cannot read '/tmp/x.md': boom", + ); + }); +});