feat: add kitchen view command and parser skeleton (task 0003)

Cut the first vertical slice through both layers: a runnable
`kitchen view <file>` that reads a Recipe File, parses it, and renders
it styled to the terminal.

@kitchen-md/core exposes a pure, total `parse(input): DocumentAST` built
on a minimal remark pipeline (parse + frontmatter) with a translation
layer to core's own AST types — frontmatter passthrough, HeadingBlock,
ParagraphBlock, and TextNode. remark types never leak into the public
API.

@kitchen-md/bin's `view` subcommand (commander) reads the file and hands
the DocumentAST to a pure `render(ast): string` (chalk, ANSI
auto-suppressed off a TTY). Fallible file I/O is modelled as a neverthrow
Result over a tagged-union CliError, matched at the boundary: stdout on
success, stderr and exit 1 on failure. The command functions sit beside
the import.meta.main-guarded CLI entry so they are testable in-process.

Tests follow ADR 0009's tiers — unit (parse, render), integration (the
view command's Result), and e2e (the binary as a subprocess) — with
coverage, a path-scoped test-report generator, and a prose fixture
rounding out the tooling. ADR 0008 records errors-as-values at the CLI
boundary; ADR 0009 records the testing tiers.
This commit was merged in pull request #2.
This commit is contained in:
2026-07-28 23:31:03 -04:00
parent fdede89e0c
commit ab309d3226
26 changed files with 1431 additions and 118 deletions

View File

@@ -0,0 +1,31 @@
# 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.
Because failure is a returned value rather than a side effect, error propagation can be asserted in-process by the integration tier, without spawning the binary (see ADR 0009).
## 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

@@ -0,0 +1,44 @@
# ADR 0009 — Testing Tiers and Boundaries
**Status**: Accepted
## Context
The test suite accumulated overlapping files — unit, integration, smoke, and subprocess — with no crisp definition of what each was responsible for.
The result was duplication and confusion: the same behaviour asserted in more than one tier, and no rule for where a given test belonged.
Both specs already referred to unit, integration, and smoke tests, but none of them pinned the boundaries.
## Decision
Recognise three test tiers, each defined by the seam it exercises.
**Unit** — one component in isolation, a pure function, asserted by its return value.
`parse` in core and `render` in bin are the unit seams.
**Integration** — several components composed in-process, asserted by the value the composed function returns.
The `view` command function — file read, then `parse`, then `render`, returning a `Result` — is the integration seam.
**E2E** — the binary as a black box.
It is spawned as a subprocess and asserted on its exit code and its stdout and stderr, with no knowledge of the internal structure.
The line between what is unit- or integration-testable and what is e2e-only is whether a function returns a value or performs a process-level side effect.
A function that returns a value can be asserted in-process.
A function that calls `process.exit` or `process.stdout.write` can only be observed by spawning the binary.
So all logic is pushed into value-returning functions, and the entry shell is kept as thin as possible, because it is the one part reachable only through a subprocess.
Core exposes a single public seam, `parse`, so it has unit tests only.
A whole-fixture parse test is still a unit test on that same seam with a broad input — a corpus test — not a separate tier.
## Rationale
Precise, non-overlapping definitions prevent the duplication that a vague unit-integration-smoke split produced.
Classifying by seam matches what is actually cheap or expensive to test.
Value-returning code runs fast and is visible to coverage in-process, while process-side-effecting code needs a subprocess and is invisible to coverage.
Concentrating the process-boundary surface in one thin shell keeps the amount of e2e-only code to a minimum.
## Consequences
Each behaviour is tested at exactly one tier: logic at unit or integration in-process, the process boundary at e2e.
The e2e tier is deliberately minimal — it verifies wiring such as exit codes and stream routing, not content already proven in-process.
Tests are co-located as `{module}_test.ts`, so the command-function tests live beside the entry module and are integration tests despite the file name.
When a command function shares a file with the top-level `program.parse()`, that call is guarded with `import.meta.main`, so importing the module for a test does not run the CLI.