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:
31
.claude/adr/0008-errors-as-values-at-cli-boundary.md
Normal file
31
.claude/adr/0008-errors-as-values-at-cli-boundary.md
Normal 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.
|
||||
44
.claude/adr/0009-testing-tiers-and-boundaries.md
Normal file
44
.claude/adr/0009-testing-tiers-and-boundaries.md
Normal 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.
|
||||
77
.claude/tasks/0003-view-skeleton.md
Normal file
77
.claude/tasks/0003-view-skeleton.md
Normal file
@@ -0,0 +1,77 @@
|
||||
---
|
||||
spec: cli-view
|
||||
---
|
||||
|
||||
## What to build
|
||||
|
||||
The walking skeleton: a runnable `kitchen view <file>` that reads a Recipe File, parses it, and prints it styled in the terminal.
|
||||
This slice cuts the first complete thread through both layers with the smallest set of node types.
|
||||
|
||||
In `@kitchen-md/core`, stand up the `parse` function and the types module, modelling only what this slice renders: frontmatter passthrough, `HeadingBlock`, `ParagraphBlock`, and `TextNode`.
|
||||
Set up the minimal internal remark pipeline needed for these (parse + frontmatter), with the translation layer from remark's output to core's own types.
|
||||
`parse` is a pure, total function returning a `DocumentAST` of shape `{ frontmatter, blocks, diagnostics }`.
|
||||
|
||||
In `@kitchen-md/bin`, implement the `view` subcommand with commander taking one required file-path argument.
|
||||
The entry point owns file I/O: it reads the file, calls `parse`, and passes the `DocumentAST` to a pure `render(ast)` function.
|
||||
`render` walks the AST and returns an ANSI-styled string using chalk, which auto-suppresses colour when stdout is not a TTY.
|
||||
Frontmatter prints as raw YAML followed by a visual separator, then the body: headings styled by level, paragraphs as prose with blank-line spacing.
|
||||
|
||||
The demoable outcome: `kitchen view <recipe.md>` shows metadata, headings, and prose; a missing file prints a human-readable error to stderr and exits non-zero.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] `parse` returns a `DocumentAST` `{ frontmatter, blocks, diagnostics }`; frontmatter is a plain object (empty when absent), diagnostics empty in the normal case
|
||||
- [x] Core types live in a dedicated types module and are re-exported from the package entry point alongside `parse`
|
||||
- [x] Headings (levels 1–6) and paragraphs are modelled as `HeadingBlock` and `ParagraphBlock`, with paragraph content as an inline array of `TextNode`
|
||||
- [x] Blocks are flat and in document order (a heading is a sibling of the following paragraph, not its parent)
|
||||
- [x] remark types do not appear in core's public API
|
||||
- [x] `kitchen view <file>` reads the file, calls `parse`, and prints the rendered output
|
||||
- [x] `render(ast)` is pure (no I/O, no side effects) and returns an ANSI-styled string
|
||||
- [x] Frontmatter renders as raw YAML before the body, followed by a visual separator
|
||||
- [x] Headings render bold and distinct by level; paragraphs render prose followed by a blank line
|
||||
- [x] chalk styling is suppressed automatically when stdout is not a TTY
|
||||
- [x] A missing file path prints commander usage to stderr and exits 1; an unreadable/nonexistent file prints a human-readable error to stderr and exits 1
|
||||
- [x] Renderer unit tests (ANSI stripped) cover frontmatter, the separator, headings, and paragraphs
|
||||
- [x] Core unit tests cover frontmatter passthrough (arbitrary fields, empty, absent), headings at every level, and paragraphs with `TextNode` content
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
All acceptance criteria are met. The following decisions and scope boundaries are worth recording.
|
||||
|
||||
### Scope boundaries carried by the slice
|
||||
|
||||
Only `HeadingBlock`, `ParagraphBlock`, and `TextNode` are modelled, as the slice specifies.
|
||||
The remark→core translation therefore skips any block that is not a heading or paragraph (lists, blockquotes, code, thematic breaks), and `translateInline` keeps only text nodes, dropping every other inline node type.
|
||||
The drop is by whole node, so emphasised or linked text is currently lost, not merely unstyled.
|
||||
This is within task 0003's stated scope; the lossless `RawInline`/`RawBlock` fallback and the typed `EmphasisNode`/`StrongNode`/`LinkNode` land in task 0004, which also makes the translation recurse into container children.
|
||||
|
||||
Malformed-frontmatter handling is out of scope here and owned by task 0007.
|
||||
This slice parses well-formed frontmatter and returns `{}` for the empty and absent cases; a genuinely malformed YAML block would currently throw from the YAML parser.
|
||||
The total-function guarantee for that case (returning `{}` plus an `invalid-frontmatter` diagnostic) arrives with 0007.
|
||||
|
||||
### Types defined ahead of full use
|
||||
|
||||
`Diagnostic`, `Point`, and `Position` are defined in the types module because `DocumentAST.diagnostics` is typed `Diagnostic[]`, even though only the empty case (`diagnostics: []`) is produced in this slice.
|
||||
This keeps the public shape stable; 0007 populates the channel.
|
||||
|
||||
### Rendering decisions
|
||||
|
||||
Headings render distinct-by-level via chalk, tapering from bold at level 1 toward dim at level 6; a heading is followed by a single newline and a paragraph by a blank line, which is what visually separates them once ANSI is stripped.
|
||||
Per the cli-view spec, the specific colour and weight choices are visual decisions verified by inspection, not asserted in tests — the renderer tests assert ANSI-stripped text, spacing, and the presence/absence of styling, not particular colours.
|
||||
The frontmatter separator is a dimmed 40-character box-drawing rule.
|
||||
|
||||
### Test suite structured on ADR 0009's tiers
|
||||
|
||||
The suite is organised by the three tiers ADR 0009 defines, each seam tested at exactly one tier.
|
||||
Because task 0003's code was already built, these are characterization tests — green on arrival — asserting observable behaviour against independent literals rather than restating the implementation.
|
||||
|
||||
Unit tests cover the two pure seams.
|
||||
`packages/core/src/parse_test.ts` asserts `parse` through the package barrel: frontmatter passthrough for arbitrary, empty, and absent blocks, headings at every level 1–6, paragraphs with `TextNode` content, flat document order, and empty diagnostics.
|
||||
`packages/bin/src/render_test.ts` asserts `render` on ANSI-stripped output: headings at every level, paragraph blank-line spacing, the frontmatter YAML with its separator, and their absence when frontmatter is empty.
|
||||
|
||||
The `view` command function and the CLI share `packages/bin/src/index.ts`: `viewFile`, `formatError`, and the `CliError` union are value-returning and tested in-process, while the thin `program.parse()` dispatch is guarded behind `import.meta.main` so importing the module for a test never runs the CLI.
|
||||
Integration tests (`packages/bin/src/index_test.ts`) assert that command function's returned `Result` in-process: `ok` with rendered output for a readable file, frontmatter passthrough, a `read-failed` error for a missing path, and `formatError`'s message.
|
||||
|
||||
End-to-end tests (`packages/bin/src/end_to_end_test.ts`) drive the `kitchen` binary as a subprocess, asserting exit codes, stream routing, document order, ANSI suppression on a pipe, the missing-file and missing-argument errors, and `--help`.
|
||||
`fixtures/prose.md` is a committed recipe using only the constructs this slice models — frontmatter, headings at levels 1–3, and plain-text paragraphs — so it renders losslessly today.
|
||||
The full `fixtures/basic.md` end-to-end remains task 0007's, once 0004's richer nodes make that fixture render losslessly.
|
||||
Reference in New Issue
Block a user