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:
30
.claude/adr/0008-errors-as-values-at-cli-boundary.md
Normal file
30
.claude/adr/0008-errors-as-values-at-cli-boundary.md
Normal 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.
|
||||||
5
bun.lock
5
bun.lock
@@ -21,6 +21,7 @@
|
|||||||
"@kitchen-md/core": "workspace:*",
|
"@kitchen-md/core": "workspace:*",
|
||||||
"chalk": "^5.6.2",
|
"chalk": "^5.6.2",
|
||||||
"commander": "^15.0.0",
|
"commander": "^15.0.0",
|
||||||
|
"neverthrow": "^8.2.0",
|
||||||
"yaml": "^2.9.0",
|
"yaml": "^2.9.0",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -60,6 +61,8 @@
|
|||||||
|
|
||||||
"@nodable/entities": ["@nodable/entities@3.0.0", "", {}, "sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw=="],
|
"@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/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=="],
|
"@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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
||||||
|
|||||||
8
bun.nix
8
bun.nix
@@ -55,6 +55,10 @@
|
|||||||
url = "https://registry.npmjs.org/@nodable/entities/-/entities-3.0.0.tgz";
|
url = "https://registry.npmjs.org/@nodable/entities/-/entities-3.0.0.tgz";
|
||||||
hash = "sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==";
|
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 {
|
"@types/debug@4.1.13" = fetchurl {
|
||||||
url = "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz";
|
url = "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz";
|
||||||
hash = "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==";
|
hash = "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==";
|
||||||
@@ -271,6 +275,10 @@
|
|||||||
url = "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz";
|
url = "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz";
|
||||||
hash = "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==";
|
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 {
|
"path-expression-matcher@1.6.2" = fetchurl {
|
||||||
url = "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz";
|
url = "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz";
|
||||||
hash = "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==";
|
hash = "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==";
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"@kitchen-md/core": "workspace:*",
|
"@kitchen-md/core": "workspace:*",
|
||||||
"chalk": "^5.6.2",
|
"chalk": "^5.6.2",
|
||||||
"commander": "^15.0.0",
|
"commander": "^15.0.0",
|
||||||
|
"neverthrow": "^8.2.0",
|
||||||
"yaml": "^2.9.0"
|
"yaml": "^2.9.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
#!/usr/bin/env bun
|
#!/usr/bin/env bun
|
||||||
import { readFileSync } from "node:fs";
|
|
||||||
import { parse } from "@kitchen-md/core";
|
|
||||||
import { Command } from "commander";
|
import { Command } from "commander";
|
||||||
import { render } from "./render.ts";
|
import { formatViewError, viewFile } from "./view.ts";
|
||||||
|
|
||||||
const program = new Command();
|
const program = new Command();
|
||||||
|
|
||||||
@@ -13,15 +11,13 @@ program
|
|||||||
.description("Render a Recipe File to the terminal")
|
.description("Render a Recipe File to the terminal")
|
||||||
.argument("<file>", "path to a Recipe File")
|
.argument("<file>", "path to a Recipe File")
|
||||||
.action((file: string) => {
|
.action((file: string) => {
|
||||||
let content: string;
|
viewFile(file).match(
|
||||||
try {
|
(output) => process.stdout.write(output),
|
||||||
content = readFileSync(file, "utf8");
|
(error) => {
|
||||||
} catch (error) {
|
process.stderr.write(`kitchen: ${formatViewError(error)}\n`);
|
||||||
const reason = error instanceof Error ? error.message : String(error);
|
|
||||||
process.stderr.write(`kitchen: cannot read '${file}': ${reason}\n`);
|
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
},
|
||||||
process.stdout.write(render(parse(content)));
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
program.parse();
|
program.parse();
|
||||||
|
|||||||
27
packages/bin/src/view.ts
Normal file
27
packages/bin/src/view.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
41
packages/bin/src/view_test.ts
Normal file
41
packages/bin/src/view_test.ts
Normal 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",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user