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<string, ViewError>
(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.
This commit is contained in:
2026-07-28 21:21:23 -04:00
parent 313a5b60b7
commit ae542893da
7 changed files with 120 additions and 12 deletions

View File

@@ -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<T, E>`, 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.

View File

@@ -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=="],

View File

@@ -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==";

View File

@@ -14,6 +14,7 @@
"@kitchen-md/core": "workspace:*",
"chalk": "^5.6.2",
"commander": "^15.0.0",
"neverthrow": "^8.2.0",
"yaml": "^2.9.0"
}
}

View File

@@ -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("<file>", "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`);
viewFile(file).match(
(output) => process.stdout.write(output),
(error) => {
process.stderr.write(`kitchen: ${formatViewError(error)}\n`);
process.exit(1);
}
process.stdout.write(render(parse(content)));
},
);
});
program.parse();

27
packages/bin/src/view.ts Normal file
View File

@@ -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<string, ViewError> {
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<string, ViewError> {
try {
return ok(readFileSync(path, "utf8"));
} catch (error) {
const cause = error instanceof Error ? error.message : String(error);
return err({ tag: "read-failed", path, cause });
}
}

View File

@@ -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",
);
});
});