This repository has been archived on 2026-07-29. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
kitchen-md/.claude/adr/0004-total-function-parser-with-diagnostics.md
alexion 3ffd2b3f9f docs: resolve core parser design decisions
Capture the design decisions reached while grilling the core-parser spec:

- Parser is a total function; invalid frontmatter YAML surfaces through a
  new Document AST `diagnostics` channel instead of throwing (ADR 0004).
- Parse all of OFM losslessly via `RawBlock`/`RawInline` fallbacks with
  position-sliced verbatim source and a minimal plugin set (ADR 0005).
- Grammar-driven (not last-space) quantity/unit split, fixing the
  mixed-number collision, with trimming and multi-word unit rules.
- Container blocks nest `Block[]` so annotations surface inside
  blockquotes and list items; unified wikilink/transclusion shape.

Update SPEC.md and CONTEXT.md accordingly; no parser code yet.
2026-07-14 20:52:15 -04:00

4.0 KiB

ADR 0004 — Total-Function Parser with a Diagnostics Channel

Status: Accepted

Context

@kitchen-md/core's parse is a pure function from a Recipe File string to a Document AST. Almost every input is forgiving by construction: CommonMark has no syntax errors, so any Markdown body is valid; unknown OFM constructs fall through to a raw fallback (see ADR 0005); and a malformed annotation such as bare @foo simply stays as plain text.

The one input class that can be genuinely invalid is frontmatter YAML. remark-frontmatter only extracts the --- block as a raw string; core still has to parse that string into the plain object the spec requires, and YAML can be syntactically broken (bad indentation, an unclosed quote, a tab where spaces are required).

Three approaches were considered for what parse does when the frontmatter YAML is invalid:

Option A — Throw parse raises a typed error on invalid YAML. The body never throws; only broken frontmatter does.

Option B — Total function, silently empty Invalid YAML yields frontmatter: {} and parsing continues. parse never throws.

Option C — Total function with a diagnostics channel Invalid YAML yields frontmatter: {}, parsing continues, and a non-fatal warning is surfaced through a new top-level diagnostics array on the Document AST. The raw invalid YAML and the parse error are preserved in the diagnostic. parse never throws.

Decision

Option Cparse is a total function that never throws, and reports genuinely invalid input (currently only malformed frontmatter YAML) through a diagnostics array on the Document AST.

The Document AST top-level shape becomes { frontmatter, blocks, diagnostics }. frontmatter stays a plain Record<string, unknown> (empty when absent or invalid). diagnostics is a list of { severity, code, message, source?, position? }, empty in the normal case. The invalid-frontmatter case emits { severity: "warning", code: "invalid-frontmatter", message, source: <raw YAML>, position }.

Rationale

Throwing (Option A) makes parse partial and pushes a try/catch onto every consumer for a condition that is not fatal to the rest of the document — the body is still perfectly parseable. It also makes the CLI and the future Obsidian plugin each responsible for reconstructing a graceful-degradation story that the parser is better placed to provide once.

Silently emptying (Option B) is total but lossy: a consumer cannot distinguish "this file has no frontmatter" from "this file has broken frontmatter," so real authoring mistakes vanish without a trace.

Option C keeps the forgiving behaviour everywhere it makes sense while refusing to swallow the one class of input that is unambiguously the author's mistake. It mirrors how Obsidian itself behaves: it still renders a note whose frontmatter is broken, and shows a warning rather than refusing the file.

A separate diagnostics array — rather than widening frontmatter into a discriminated union such as { valid: true, data } | { valid: false, raw, error } — keeps the common path clean. User story 2 wants frontmatter as "a plain object so I can read whatever fields I need"; a union would force every consumer to narrow on validity before reading a single field. A diagnostics array leaves the happy path untouched and gives the parser a natural, extensible home for any future non-fatal warning.

Consequences

  • The Document AST gains a third top-level key, diagnostics, always present (empty when there is nothing to report).
  • parse is total: no input causes it to throw. Consumers never need to wrap it in try/catch.
  • Consumers decide how to surface diagnostics — the CLI can print a warning to stderr, the Obsidian plugin can show a banner — using the preserved source and position.
  • Diagnostic is a public API type with a stable, machine-readable code. Adding new diagnostic codes later is additive and non-breaking.
  • The only diagnostic emitted today is invalid-frontmatter; the channel exists to absorb future non-fatal parse issues without further shape changes.