|
| 1 | +# Extracting `quarto-yaml-validation` into a standalone repository |
| 2 | + |
| 3 | +**Strand:** bd-egcyeym9 |
| 4 | +**Date:** 2026-06-17 |
| 5 | +**Status:** Information gathering / current-state architecture (no code changes this session) |
| 6 | + |
| 7 | +## Goal |
| 8 | + |
| 9 | +Move the YAML-schema-validation infrastructure (`quarto-yaml-validation`) out of |
| 10 | +`quarto-dev/q2` so that non-Quarto developers can depend on it as a general-purpose |
| 11 | +crate. This session maps where the crate lives in the dependency graph, its inbound |
| 12 | +and outbound dependencies, and the design problems an extraction must solve — chiefly |
| 13 | +the Quarto-specific error-code catalog. |
| 14 | + |
| 15 | +## TL;DR findings |
| 16 | + |
| 17 | +1. **The outbound closure is small and clean.** To use `quarto-yaml-validation` you |
| 18 | + need exactly three other workspace crates: `quarto-yaml`, `quarto-source-map`, |
| 19 | + and `quarto-error-reporting`. `quarto-source-map` is a true leaf (only `serde`, |
| 20 | + `serde_json`, `smallvec`). None of the four pulls in the heavy Quarto engine |
| 21 | + crates (`pampa`, `quarto-core`, etc.). |
| 22 | + |
| 23 | +2. **Inside q2, `quarto-yaml-validation` has essentially no inbound coupling.** The |
| 24 | + only in-repo consumer is the `validate-yaml` binary (a thin CLI/test harness). It |
| 25 | + is **not** wired into the render pipeline, `quarto-config`, or the WASM client. |
| 26 | + This makes extraction low-risk: nothing in the engine breaks if the crate moves. |
| 27 | + |
| 28 | +3. **The three *foundation* crates are heavily used inside q2.** `quarto-source-map` |
| 29 | + (~26 dependents), `quarto-error-reporting` (~19), and `quarto-yaml` (8) are core |
| 30 | + infrastructure. They **cannot simply be relocated** — q2 still needs them. The |
| 31 | + split therefore has to be a *publish-and-consume* arrangement (q2 depends on the |
| 32 | + externalized crates), not a move. |
| 33 | + |
| 34 | +4. **The hard design problem is error-code identity.** `quarto-yaml-validation` |
| 35 | + hard-codes Quarto error codes (`Q-1-10`, `Q-1-11`, …) and leans on |
| 36 | + `quarto-error-reporting`'s centralized `error_catalog.json` (titles, docs URLs at |
| 37 | + `https://quarto.org/docs/errors/...`). Outside q2 those codes and URLs are |
| 38 | + meaningless. The standalone library needs a *pluggable error-code / catalog |
| 39 | + provider*; the q2-embedded build keeps the existing `Q-1-x` codes. This is both a |
| 40 | + technical change (abstraction boundary) and an org change (two catalogs, two |
| 41 | + "since_version" lineages, a remapping layer). |
| 42 | + |
| 43 | +## Crate dependency graph (relevant slice) |
| 44 | + |
| 45 | +``` |
| 46 | +quarto-source-map leaf: serde, serde_json, smallvec (NO quarto deps) |
| 47 | + ▲ |
| 48 | + │ |
| 49 | +quarto-yaml → quarto-source-map, yaml-rust2, serde, thiserror |
| 50 | + ▲ |
| 51 | + │ |
| 52 | +quarto-error-reporting → quarto-source-map, ariadne, schemars, url, once_cell, |
| 53 | + ▲ serde, serde_json, thiserror |
| 54 | + │ |
| 55 | +quarto-yaml-validation → quarto-yaml, quarto-source-map, quarto-error-reporting, |
| 56 | + ▲ yaml-rust2, regex, serde, serde_json, thiserror |
| 57 | + │ |
| 58 | +validate-yaml (bin) → the only in-repo consumer of quarto-yaml-validation |
| 59 | +``` |
| 60 | + |
| 61 | +Note: `quarto-error-reporting` does **not** depend on `quarto-yaml-validation`. (A |
| 62 | +grep match is a false positive — it appears only in a code comment that warns against |
| 63 | +adding `schemars` for user-YAML validation. There is no dependency cycle.) |
| 64 | + |
| 65 | +### Outbound dependencies (what extraction must carry along) |
| 66 | + |
| 67 | +`quarto-yaml-validation` → (workspace) `quarto-yaml`, `quarto-source-map`, |
| 68 | +`quarto-error-reporting`; (crates.io) `yaml-rust2`, `regex`, `serde`, `serde_json`, |
| 69 | +`thiserror`, `anyhow`. |
| 70 | + |
| 71 | +The **transitive workspace closure** that must be externalized to make |
| 72 | +`quarto-yaml-validation` buildable outside q2 is: |
| 73 | + |
| 74 | +| Crate | Outbound (workspace) deps | crates.io deps | Notes | |
| 75 | +|---|---|---|---| |
| 76 | +| `quarto-source-map` | *(none)* | serde, serde_json, smallvec | Clean leaf — easiest to externalize | |
| 77 | +| `quarto-yaml` | source-map | yaml-rust2, serde, thiserror | Annotated YAML parse; re-exports `SourceInfo` | |
| 78 | +| `quarto-error-reporting` | source-map | ariadne, schemars, url, once_cell, serde(_json), thiserror | Holds the **error catalog** — the contentious piece | |
| 79 | +| `quarto-yaml-validation` | the above three | yaml-rust2, regex, serde(_json), thiserror, anyhow | The crate we want to ship | |
| 80 | + |
| 81 | +All four share the workspace `[workspace.package]` metadata |
| 82 | +(`repository = posit-dev/quarto-markdown-syntax`, `license = MIT`, |
| 83 | +`edition = 2024`, version `0.3.0`). |
| 84 | + |
| 85 | +### Inbound dependencies (who would be affected by a move) |
| 86 | + |
| 87 | +| Crate | Direct in-repo dependents (Cargo) | Implication for split | |
| 88 | +|---|---|---| |
| 89 | +| `quarto-yaml-validation` | `validate-yaml` only | **Trivial** — move freely; only the CLI follows | |
| 90 | +| `quarto-yaml` | pampa, quarto-core, quarto-config, quarto-error-reporting, quarto-lsp-core, validate-yaml (8) | q2 still needs it → must depend on externalized crate | |
| 91 | +| `quarto-error-reporting` | ~19 crates (pampa, quarto-core, quarto-config, quarto-csl, quarto-citeproc, quarto-doctemplate, quarto-lsp-core, quarto-preview, quarto-publish, quarto, wasm-quarto-hub-client, …) | Deeply embedded → cannot move; must be a shared dependency | |
| 92 | +| `quarto-source-map` | ~26 crates (most of the workspace, incl. WASM client) | Core infra → cannot move; must be a shared dependency | |
| 93 | + |
| 94 | +**Consequence:** the only crate that *leaves* q2 cleanly is `quarto-yaml-validation` |
| 95 | +itself. The three foundation crates have to become shared/published dependencies that |
| 96 | +*both* the new external repo and q2 consume. This is the central structural decision |
| 97 | +(see "Extraction strategies" below). |
| 98 | + |
| 99 | +## How `quarto-yaml-validation` couples to source-location infra |
| 100 | + |
| 101 | +`quarto-source-map` provides the annotated-parse machinery the user mentioned. The |
| 102 | +validation crate uses it pervasively: |
| 103 | + |
| 104 | +- `quarto-yaml` parses YAML into `YamlWithSourceInfo`, carrying `SourceInfo` (byte |
| 105 | + ranges + provenance) for every node. `SourceInfo` is re-exported from |
| 106 | + `quarto-source-map`. |
| 107 | +- `ValidationError` (in `error.rs`) stores `yaml_node: Option<YamlWithSourceInfo>` |
| 108 | + and a resolved `SourceLocation { file, line, column }` obtained via |
| 109 | + `SourceInfo::map_offset(0, ctx)` against a `SourceContext`. |
| 110 | +- `ValidationDiagnostic` (in `diagnostic.rs`) calls `source_info.map_offset(...)` and |
| 111 | + `source_ctx.get_file(...)` to produce a `SourceRange` (filename + offset + |
| 112 | + line/col) for machine-readable JSON, and hands the `SourceInfo` to |
| 113 | + `quarto-error-reporting`'s ariadne renderer for human text. |
| 114 | + |
| 115 | +The public `quarto-source-map` surface in play: |
| 116 | +`SourceContext`, `SourceFile`, `SourceInfo`, `FileId`, `Location`, `MappedLocation`, |
| 117 | +`map_offset`, `get_file`, `start_offset`/`end_offset`. This surface is general (it is |
| 118 | +not Quarto-specific in any way) — externalizing `quarto-source-map` is conceptually |
| 119 | +clean; the only cost is that ~26 q2 crates must now consume it as an external crate. |
| 120 | + |
| 121 | +## How `quarto-yaml-validation` couples to error reporting / the catalog |
| 122 | + |
| 123 | +Two distinct couplings, both via `quarto-error-reporting`: |
| 124 | + |
| 125 | +### (a) Rendering coupling — `DiagnosticMessage` / `DiagnosticMessageBuilder` |
| 126 | + |
| 127 | +`ValidationDiagnostic::build_diagnostic_message` builds a |
| 128 | +`DiagnosticMessageBuilder::error("YAML Validation Failed")` and calls `.with_code(...)`, |
| 129 | +`.problem(...)`, `.with_location(SourceInfo)`, `.add_detail/.add_info/.add_hint`, |
| 130 | +`.build()`. The resulting `DiagnosticMessage` is what renders to ariadne text |
| 131 | +(`to_text`) and JSON. This coupling is purely about *presentation* and is |
| 132 | +non-Quarto-specific in shape — it could move to the external library as-is. |
| 133 | + |
| 134 | +### (b) Identity coupling — the centralized error-code catalog (the hard part) |
| 135 | + |
| 136 | +- `ValidationErrorKind::error_code()` (in `error.rs`) hard-codes |
| 137 | + `&'static str` Quarto codes: |
| 138 | + `Q-1-10` (missing required property), `Q-1-11` (type mismatch), |
| 139 | + `Q-1-12` (invalid enum), `Q-1-13`, `Q-1-14`, `Q-1-15`, `Q-1-16`, `Q-1-17`, |
| 140 | + `Q-1-18`, `Q-1-19`, `Q-1-20`, `Q-1-29`, `Q-1-99` (other). |
| 141 | +- These strings are looked up at render time in `quarto-error-reporting`'s |
| 142 | + **catalog**: `catalog.rs` loads `error_catalog.json` via `include_str!` into a |
| 143 | + `HashMap<String, ErrorCodeInfo>` where `ErrorCodeInfo { subsystem, title, |
| 144 | + message_template, docs_url, since_version }`. `DiagnosticMessage::docs_url()` does |
| 145 | + `catalog::get_docs_url(code)`. |
| 146 | +- The catalog is **global and centralized**: 145 entries spanning many subsystems |
| 147 | + (yaml=`Q-1-*`, internal=`Q-0-*`, listing=`Q-12-*`, navigation=`Q-13-*`, …). Codes |
| 148 | + follow `Q-<subsystem>-<number>`. The yaml subsystem occupies `Q-1-1..Q-1-29` + |
| 149 | + `Q-1-99`. `docs_url`s all point at `https://quarto.org/docs/errors/<subsystem>/<code>`. |
| 150 | +- A repo-wide **audit** (`scripts/audit-error-codes.py`, `scripts/quick-error-audit.sh`) |
| 151 | + enforces consistency between codes referenced in source and entries in |
| 152 | + `error_catalog.json`. Deliberate exceptions use the marker |
| 153 | + `// quarto-error-code-audit-ignore` (line) or `...-ignore-file` (file). Any |
| 154 | + extraction must keep this audit green for the q2-embedded build. |
| 155 | + |
| 156 | +**Why this blocks a naive extraction:** the codes `Q-1-x`, the central JSON catalog, |
| 157 | +the `quarto.org` docs URLs, and the cross-subsystem numbering are all *Quarto policy*. |
| 158 | +A standalone library shipped to non-Quarto users must not bake in `Q-1-x`/`quarto.org`, |
| 159 | +yet the q2-embedded build must keep them (codes are a stability contract — see |
| 160 | +`error.rs`/`builder.rs` doc comments: "codes don't change even if message wording |
| 161 | +improves"). |
| 162 | + |
| 163 | +## The error-code remapping design problem (statement, not solution) |
| 164 | + |
| 165 | +We need an abstraction where: |
| 166 | + |
| 167 | +- `quarto-yaml-validation` emits *semantic* error kinds (it already has a clean |
| 168 | + `ValidationErrorKind` enum — machine-readable, no strings). The mapping from kind → |
| 169 | + catalog code becomes a **policy supplied by the embedder**, not hard-coded in the |
| 170 | + library. |
| 171 | +- `quarto-error-reporting` becomes a reusable error-reporting library whose catalog is |
| 172 | + **pluggable / remappable**: a host can register its own code namespace, titles, and |
| 173 | + docs-URL scheme. The library ships with no Quarto-specific catalog; q2 supplies the |
| 174 | + `Q-*` catalog + `quarto.org` URLs; an external user supplies their own (or none). |
| 175 | +- The same `ValidationErrorKind` therefore renders as `Q-1-11` + a `quarto.org` URL |
| 176 | + inside q2, and as `<their-scheme>` (or code-less) outside q2. |
| 177 | + |
| 178 | +Candidate shapes to evaluate in a follow-up design session (NOT decided here): |
| 179 | + |
| 180 | +- A trait, e.g. `ErrorCodeProvider`/`Catalog`, injected into the diagnostic builder; |
| 181 | + q2 implements it over `error_catalog.json`, the standalone lib provides a no-op or |
| 182 | + caller-supplied default. |
| 183 | +- Keep `error_code()` returning a *library-local* stable id (namespaced to the |
| 184 | + validation crate, e.g. `YV-…` or a typed enum discriminant), and let the embedder |
| 185 | + *remap* library ids → its own catalog codes at the reporting boundary. This is the |
| 186 | + "remap error codes when errors are defined in different packages" idea the user |
| 187 | + raised, and it generalizes beyond yaml-validation (any externalized crate that |
| 188 | + defines its own errors). |
| 189 | +- Split `quarto-error-reporting` into `error-reporting-core` (catalog-agnostic |
| 190 | + builder/render/JSON) + `quarto-error-catalog` (the q2 `Q-*` policy + audit). q2 |
| 191 | + depends on both; external users depend only on the core. |
| 192 | + |
| 193 | +## Extraction strategies (to weigh in design phase) |
| 194 | + |
| 195 | +Because the three foundation crates must serve *both* repos, the split is really a |
| 196 | +question of *who owns the source of truth*: |
| 197 | + |
| 198 | +1. **New repo owns the foundation crates; q2 consumes them as published crates.** |
| 199 | + Move `quarto-source-map`, `quarto-yaml`, `quarto-error-reporting`(-core), |
| 200 | + `quarto-yaml-validation` to a new repo; publish to crates.io (or git deps); q2 |
| 201 | + replaces its in-tree copies with version deps. Cleanest long-term, highest |
| 202 | + coordination cost (every q2 change to source-map now crosses a repo boundary). |
| 203 | + |
| 204 | +2. **q2 stays the source of truth; new repo vendors via subtree/`git subtree` or a |
| 205 | + read-only mirror + crates.io publish from q2.** Lowest disruption to q2 dev flow; |
| 206 | + external repo is a packaging view. Risk: the external "non-Quarto" identity is |
| 207 | + thin if it is just a mirror. |
| 208 | + |
| 209 | +3. **Cargo workspace inheritance / path-or-version deps.** Publish the foundation |
| 210 | + crates from q2 but keep developing them in q2; the external repo pins versions. |
| 211 | + Requires decoupling the shared `[workspace.package]` metadata |
| 212 | + (`posit-dev/quarto-markdown-syntax` repository field, version `0.3.0`) per-crate. |
| 213 | + |
| 214 | +Each option interacts with the error-code design: option 1/3 force the |
| 215 | +catalog-pluggability work (external users get no `Q-*`); option 2 could defer it. |
| 216 | + |
| 217 | +## Open questions for the next session |
| 218 | + |
| 219 | +- Which extraction strategy (own / mirror / publish-from-q2)? |
| 220 | +- Does `quarto-error-reporting` split into core + catalog, or gain a provider trait? |
| 221 | +- Where do the **JSON wire types** (`json.rs`: `JsonDiagnostic`, shared with |
| 222 | + `wasm-quarto-hub-client` and `quarto-preview`) live after the split? They are |
| 223 | + catalog-adjacent and have their own q2 consumers. |
| 224 | +- `schemars` is scoped into `quarto-error-reporting` deliberately (machine-to-machine |
| 225 | + JSON shapes); does the external core keep it? |
| 226 | +- Naming/identity of the external project (the YAML validator is "Quarto's dialect" |
| 227 | + per the code comments — is the externalized lib still "Quarto-dialect YAML schema" |
| 228 | + or rebranded general-purpose?). |
| 229 | +- How does the `error-code audit` (`scripts/audit-error-codes.py`) adapt — does it |
| 230 | + only police the q2 catalog, with the library carrying its own check? |
| 231 | + |
| 232 | +## Source references |
| 233 | + |
| 234 | +- `crates/quarto-yaml-validation/src/error.rs` — `ValidationErrorKind::error_code()` |
| 235 | + (hard-coded `Q-1-x`), `ValidationError`, `SchemaError`, source-loc usage. |
| 236 | +- `crates/quarto-yaml-validation/src/diagnostic.rs` — `ValidationDiagnostic`, |
| 237 | + builder/source-map/catalog coupling, JSON output. |
| 238 | +- `crates/quarto-error-reporting/src/catalog.rs` + `error_catalog.json` — centralized |
| 239 | + catalog (145 entries), `ErrorCodeInfo`, `get_docs_url`. |
| 240 | +- `crates/quarto-error-reporting/src/builder.rs` / `diagnostic.rs` — `with_code`, |
| 241 | + `docs_url()`, ariadne/JSON rendering. |
| 242 | +- `crates/quarto-error-reporting/src/lib.rs` — re-exports incl. `json` module. |
| 243 | +- `crates/quarto-source-map/src/lib.rs` — public surface (`SourceContext`, |
| 244 | + `SourceInfo`, `map_offset`, …). |
| 245 | +- `crates/quarto-yaml/src/lib.rs` — `YamlWithSourceInfo`, re-export of `SourceInfo`. |
| 246 | +- `crates/validate-yaml/Cargo.toml` — the sole in-repo consumer. |
| 247 | +- `scripts/audit-error-codes.py`, `scripts/quick-error-audit.sh` — catalog audit. |
| 248 | +- Prior related notes: `claude-notes/error-id-system-design.md`, |
| 249 | + `claude-notes/error-reporting-design-research.md`, |
| 250 | + `claude-notes/yaml-validation-rust-design.md`, |
| 251 | + `claude-notes/validate-yaml-error-reporting-integration.md`. |
0 commit comments