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:
57
.claude/adr/0004-total-function-parser-with-diagnostics.md
Normal file
57
.claude/adr/0004-total-function-parser-with-diagnostics.md
Normal 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.
|
||||
Reference in New Issue
Block a user