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.
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 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<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). parseis total: no input causes it to throw. Consumers never need to wrap it intry/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
sourceandposition. Diagnosticis a public API type with a stable, machine-readablecode. 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.