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.
This commit is contained in:
2026-07-14 20:52:15 -04:00
parent 66bb497aca
commit 3ffd2b3f9f
4 changed files with 178 additions and 13 deletions

View File

@@ -0,0 +1,57 @@
# 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).
- `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.