# 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 C** — `parse` 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` (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: , 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.