From 1d12785aa43c3fbab085574e37a8016dcc0585e4 Mon Sep 17 00:00:00 2001 From: Carlos Scheidegger Date: Sat, 27 Jun 2026 17:18:50 -0500 Subject: [PATCH] build: consume published quarto-error-reporting 0.1.0 from crates.io (bd-egcyeym9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 cutover: quarto-error-reporting has been extracted to https://github.com/posit-dev/quarto-error-reporting and published to crates.io as 0.1.0 (it depends only on the already-published quarto-source-map 0.1.0). Cut q2 over to the published crate: - [workspace.dependencies.quarto-error-reporting]: path -> version = "0.1.0" - 7 plain path-deps -> { workspace = true } - wasm-quarto-hub-client (excluded standalone workspace): direct quarto-error-reporting = { version = "0.1.0", features = ["json"] } - the json consumers (quarto, quarto-core, quarto-preview) keep features = ["json"] (now resolving to the external crate; json on workspace-wide via unification) - delete in-tree crates/quarto-error-reporting/ - quarto-error-catalog (the q2-side Q-* policy) now depends on the external crate. Cargo.lock resolves quarto-error-reporting 0.1.0 + transitively quarto-source-map 0.1.0 from the crates.io registry (matching checksums). CLAUDE.md crate layout updated: both foundation crates moved to an "Externalized foundation crates" section. Verified: cargo nextest run --workspace (10177 passed); full cargo xtask verify — all 14 steps incl. the WASM build + hub-client tests (precache under limit). Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 7 +- Cargo.lock | 4 +- Cargo.toml | 2 +- ...6-26-extract-error-reporting-foundation.md | 67 +- crates/pampa/Cargo.toml | 2 +- crates/quarto-citeproc/Cargo.toml | 2 +- crates/quarto-config/Cargo.toml | 2 +- crates/quarto-csl/Cargo.toml | 2 +- crates/quarto-doctemplate/Cargo.toml | 2 +- .../CONTRIBUTING-ERRORS.md | 466 ------- crates/quarto-error-reporting/Cargo.toml | 46 - crates/quarto-error-reporting/README.md | 404 ------ .../examples/basic_error.rs | 23 - .../examples/builder_api.rs | 65 - .../examples/custom_rendering.rs | 91 -- .../examples/diagnostic_collector.rs | 138 -- .../examples/migration_helpers.rs | 70 - .../examples/with_location.rs | 64 - .../schemas/json-diagnostic.json | 145 -- .../schemas/json-pass1-failure.json | 173 --- crates/quarto-error-reporting/src/builder.rs | 595 -------- crates/quarto-error-reporting/src/catalog.rs | 197 --- crates/quarto-error-reporting/src/coalesce.rs | 461 ------- .../quarto-error-reporting/src/diagnostic.rs | 1191 ----------------- crates/quarto-error-reporting/src/json.rs | 480 ------- crates/quarto-error-reporting/src/lib.rs | 94 -- crates/quarto-error-reporting/src/macros.rs | 98 -- .../tests/schema_drift.rs | 138 -- crates/quarto-parse-errors/Cargo.toml | 2 +- crates/quarto-xml/Cargo.toml | 2 +- crates/wasm-quarto-hub-client/Cargo.lock | 4 +- crates/wasm-quarto-hub-client/Cargo.toml | 2 +- 32 files changed, 68 insertions(+), 4971 deletions(-) delete mode 100644 crates/quarto-error-reporting/CONTRIBUTING-ERRORS.md delete mode 100644 crates/quarto-error-reporting/Cargo.toml delete mode 100644 crates/quarto-error-reporting/README.md delete mode 100644 crates/quarto-error-reporting/examples/basic_error.rs delete mode 100644 crates/quarto-error-reporting/examples/builder_api.rs delete mode 100644 crates/quarto-error-reporting/examples/custom_rendering.rs delete mode 100644 crates/quarto-error-reporting/examples/diagnostic_collector.rs delete mode 100644 crates/quarto-error-reporting/examples/migration_helpers.rs delete mode 100644 crates/quarto-error-reporting/examples/with_location.rs delete mode 100644 crates/quarto-error-reporting/schemas/json-diagnostic.json delete mode 100644 crates/quarto-error-reporting/schemas/json-pass1-failure.json delete mode 100644 crates/quarto-error-reporting/src/builder.rs delete mode 100644 crates/quarto-error-reporting/src/catalog.rs delete mode 100644 crates/quarto-error-reporting/src/coalesce.rs delete mode 100644 crates/quarto-error-reporting/src/diagnostic.rs delete mode 100644 crates/quarto-error-reporting/src/json.rs delete mode 100644 crates/quarto-error-reporting/src/lib.rs delete mode 100644 crates/quarto-error-reporting/src/macros.rs delete mode 100644 crates/quarto-error-reporting/tests/schema_drift.rs diff --git a/CLAUDE.md b/CLAUDE.md index 712968331..0b942dce4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -296,8 +296,11 @@ When fixing ANY bug: **Core libraries:** - `quarto-core`: core rendering infrastructure for Quarto - `quarto-util`: shared utilities for Quarto crates -- `quarto-error-reporting`: uniform, helpful, beautiful error messages -- `quarto-source-map`: maintain source location information for data structures +- `quarto-error-catalog`: Quarto's `Q-*` error-code catalog data + the `CatalogProvider` it installs into `quarto-error-reporting` + +**Externalized foundation crates** (published to crates.io from their own `posit-dev/` repos; consumed here as version deps, no longer in `crates/`): +- `quarto-error-reporting`: uniform, helpful, beautiful error messages — now **catalog-agnostic** (the `Q-*` data lives in the in-tree `quarto-error-catalog`). Repo: `posit-dev/quarto-error-reporting`. The `json` wire shape is behind a default-off `json` feature; q2's wire-shape consumers enable it. +- `quarto-source-map`: maintain source location information for data structures. Repo: `posit-dev/quarto-source-map`. (See `claude-notes/plans/2026-06-26-extract-error-reporting-foundation.md` for the extraction.) **Parsing libraries:** - `quarto-yaml`: YAML parser with accurate fine-grained source locations diff --git a/Cargo.lock b/Cargo.lock index 9100bfd1b..998bc4cc2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3618,7 +3618,9 @@ dependencies = [ [[package]] name = "quarto-error-reporting" -version = "0.7.0" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2aa95153b93fea9754e121137d5a6e38e30dd1184c73d2266958f698b8cb9503" dependencies = [ "ariadne", "quarto-source-map", diff --git a/Cargo.toml b/Cargo.toml index 402df1d58..7e86786de 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -113,7 +113,7 @@ path = "./crates/quarto-yaml" path = "./crates/quarto-yaml-validation" [workspace.dependencies.quarto-error-reporting] -path = "./crates/quarto-error-reporting" +version = "0.1.0" [workspace.dependencies.quarto-error-catalog] path = "./crates/quarto-error-catalog" diff --git a/claude-notes/plans/2026-06-26-extract-error-reporting-foundation.md b/claude-notes/plans/2026-06-26-extract-error-reporting-foundation.md index 439bd2af0..5d3eca20b 100644 --- a/claude-notes/plans/2026-06-26-extract-error-reporting-foundation.md +++ b/claude-notes/plans/2026-06-26-extract-error-reporting-foundation.md @@ -320,25 +320,54 @@ would otherwise come from an uninitialised global. Requires Phase 1 (source-map published) **and** Phase 2 (carve-out done). -- [ ] **3a.** Create `posit-dev/`; **copy** the - `quarto-error-reporting` sources (fresh `git init`, no history). Own - `[workspace.package]`. Crucially, its `quarto-source-map` dependency is now - the **published version** dep (Phase 1c), *not* a path dep. (`json.rs` + - `coalesce.rs` travel with it, `json` behind its feature; the catalog data - already left in Phase 2c.) -- [ ] **3b.** Standalone CI: `cargo build` + `cargo nextest run` with the - **`EmptyCatalog`** default — proving catalog-agnostic operation with zero - Quarto policy present. -- [ ] **3c.** External-consumer smoke test: a throwaway crate builds a - `DiagnosticMessage`, installs a trivial `CatalogProvider`, renders — proving - the published API is usable with no Quarto context. -- [ ] **3d.** Publish `quarto-error-reporting` to crates.io. -- [ ] **3e.** q2 cutover: replace the in-tree `quarto-error-reporting` path dep with - the published version dep (enable `features = ["json"]`); delete the in-tree - copy. `quarto-error-catalog` stays in q2 and now depends on the external - `quarto-error-reporting`. **Full `cargo xtask verify` incl. hub-build** (WASM - risk surface again, now de-risked by Phase 1d). -- [ ] **3f.** Update `CLAUDE.md`'s crate-layout section + the workspace member list. +- [x] **3a.** Created **`posit-dev/quarto-error-reporting`** (public, + https://github.com/posit-dev/quarto-error-reporting): copied src/ + tests/ + + examples/ + `schemas/` + LICENSE; standalone single-crate manifest + (`version = "0.1.0"`, edition 2024, explicit dep versions, + **`quarto-source-map = "0.1.0"`** published dep, `schemars` optional + + `[features] json`). Dropped `CONTRIBUTING-ERRORS.md` (Quarto catalog policy — + belongs with `quarto-error-catalog`) and rewrote `README.md` for the + catalog-agnostic library. One source fix needed for stable-clippy + `-D warnings`: `macros.rs` had `items_after_test_module` (q2's pinned-nightly + clippy tolerated it) — moved the test module below the `#[macro_export]` + macros + dropped the now-redundant macro imports. (q2 deletes its copy at + 3e, so the standalone becomes the single source; no divergence.) +- [x] **3b.** Standalone CI green locally: builds **default (json off, no + schemars)** and `--all-features`; `cargo test` 51 / `--all-features` 61 + + doctests + schema_drift; fmt + clippy (both feature sets) clean. + `.github/workflows/ci.yml` added (Linux/macOS/Windows, both feature sets). + CI running on the new repo. +- [x] **3c.** External-consumer smoke test (scratchpad `er-smoke`): a separate + crate with **default features** builds+renders a `DiagnosticMessage`, gets + `EmptyCatalog` (no docs URL), then installs a custom `CatalogProvider` and + resolves a URL — `cargo tree` confirms **no schemars** in its tree. Passes. + `cargo publish --dry-run` clean (24 files, verify-built). +- [ ] **3d.** Publish `quarto-error-reporting` to crates.io. **(User step — needs + crates.io credentials; depends on the published `quarto-source-map 0.1.0`, + which is already live.)** +- [x] **3e.** q2 cutover (branch `braid/bd-egcyeym9-error-reporting-cutover`): + flipped `[workspace.dependencies.quarto-error-reporting]` `path` → + `version = "0.1.0"`; consolidated the 7 plain path-deps onto `{ workspace = + true }`; the WASM crate (excluded standalone workspace) → `{ version = + "0.1.0", features = ["json"] }`; the json consumers (`quarto`, `quarto-core`, + `quarto-preview`) kept their `features = ["json"]` (now resolving to the + external crate); deleted in-tree `crates/quarto-error-reporting`. + `quarto-error-catalog` now depends on the external crate. Cargo.lock resolves + `quarto-error-reporting 0.1.0` + transitively `quarto-source-map 0.1.0` from + the registry. `cargo nextest run --workspace` **10177 passed**; **full + `cargo xtask verify` GREEN** (all 14 steps incl. WASM — precache succeeded, + bundle under limit). + +**Phase 3 is COMPLETE.** Both foundation crates (`quarto-source-map`, +`quarto-error-reporting`) are now published to crates.io from their own +`posit-dev/` repos and consumed by q2 as version deps. The q2 tree keeps +`quarto-error-catalog` (the `Q-*` policy) + the 4 json consumers. Next up: the +YAML stack (`quarto-yaml` + `quarto-yaml-validation`) per the sibling plan. +- [x] **3f.** Updated `CLAUDE.md` crate layout: `quarto-error-reporting` + + `quarto-source-map` moved to an "Externalized foundation crates" section + (published from `posit-dev/`); added `quarto-error-catalog`. (Workspace member + list needs no edit — the in-tree crate was pulled in via the `crates/*` glob, + so deleting the directory removes it.) ## Decisions diff --git a/crates/pampa/Cargo.toml b/crates/pampa/Cargo.toml index f81c3a106..b5d07a12a 100644 --- a/crates/pampa/Cargo.toml +++ b/crates/pampa/Cargo.toml @@ -40,7 +40,7 @@ tree-sitter = { workspace = true } tree-sitter-qmd = { workspace = true } comrak = { version = "0.52.0", default-features = false } comrak-to-pandoc = { path = "../comrak-to-pandoc" } -quarto-error-reporting = { path = "../quarto-error-reporting" } +quarto-error-reporting = { workspace = true } quarto-source-map = { workspace = true } quarto-yaml = { path = "../quarto-yaml" } quarto-config = { path = "../quarto-config" } diff --git a/crates/quarto-citeproc/Cargo.toml b/crates/quarto-citeproc/Cargo.toml index 889f05e1a..a82d90dd0 100644 --- a/crates/quarto-citeproc/Cargo.toml +++ b/crates/quarto-citeproc/Cargo.toml @@ -8,7 +8,7 @@ description = "Citation processing engine using CSL styles" [dependencies] quarto-csl = { path = "../quarto-csl" } -quarto-error-reporting = { path = "../quarto-error-reporting" } +quarto-error-reporting = { workspace = true } quarto-pandoc-types = { path = "../quarto-pandoc-types" } quarto-source-map = { workspace = true } quarto-xml = { path = "../quarto-xml" } diff --git a/crates/quarto-config/Cargo.toml b/crates/quarto-config/Cargo.toml index 89b1f5afc..536e538a3 100644 --- a/crates/quarto-config/Cargo.toml +++ b/crates/quarto-config/Cargo.toml @@ -15,7 +15,7 @@ description = "Configuration merging with source tracking for Quarto" quarto-source-map = { workspace = true } quarto-yaml = { path = "../quarto-yaml" } quarto-pandoc-types = { path = "../quarto-pandoc-types" } -quarto-error-reporting = { path = "../quarto-error-reporting" } +quarto-error-reporting = { workspace = true } indexmap = "2.13" thiserror = { workspace = true } yaml-rust2 = { workspace = true } diff --git a/crates/quarto-csl/Cargo.toml b/crates/quarto-csl/Cargo.toml index a655997f8..a2a61e300 100644 --- a/crates/quarto-csl/Cargo.toml +++ b/crates/quarto-csl/Cargo.toml @@ -8,7 +8,7 @@ repository.workspace = true description = "CSL (Citation Style Language) parsing with source tracking for Quarto" [dependencies] -quarto-error-reporting = { path = "../quarto-error-reporting" } +quarto-error-reporting = { workspace = true } quarto-source-map = { workspace = true } quarto-xml = { path = "../quarto-xml" } thiserror = { workspace = true } diff --git a/crates/quarto-doctemplate/Cargo.toml b/crates/quarto-doctemplate/Cargo.toml index 36ba8cc1d..9c1106ef4 100644 --- a/crates/quarto-doctemplate/Cargo.toml +++ b/crates/quarto-doctemplate/Cargo.toml @@ -21,7 +21,7 @@ quarto-treesitter-ast = { workspace = true } # Error reporting infrastructure quarto-parse-errors = { path = "../quarto-parse-errors" } -quarto-error-reporting = { path = "../quarto-error-reporting" } +quarto-error-reporting = { workspace = true } quarto-source-map = { workspace = true } # Serialization (for TemplateValue conversion from JSON) diff --git a/crates/quarto-error-reporting/CONTRIBUTING-ERRORS.md b/crates/quarto-error-reporting/CONTRIBUTING-ERRORS.md deleted file mode 100644 index c73cd98f5..000000000 --- a/crates/quarto-error-reporting/CONTRIBUTING-ERRORS.md +++ /dev/null @@ -1,466 +0,0 @@ -# Contributing Error Messages to Quarto - - - -This guide helps Quarto contributors create consistent, high-quality error messages across the Rust port. - -## Table of Contents - -1. [When to Use This Crate](#when-to-use-this-crate) -2. [Quick Start](#quick-start) -3. [Error Code System](#error-code-system) -4. [Writing Good Error Messages](#writing-good-error-messages) -5. [Common Patterns](#common-patterns) -6. [Testing Your Errors](#testing-your-errors) -7. [Examples](#examples) - ---- - -## When to Use This Crate - -Use `quarto-error-reporting` when you need to: - -- **Report errors to users** in any Quarto subsystem (YAML, markdown, rendering, etc.) -- **Provide actionable feedback** about what went wrong and how to fix it -- **Maintain consistency** with other Quarto error messages -- **Enable searchability** with stable error codes - -**Don't use this for**: -- Internal assertions (use `assert!` or `panic!`) -- Debug logging (use `eprintln!` or a logging crate) -- Return values that aren't user-facing (use `Result`) - ---- - -## Quick Start - -### Step 1: Choose Your Pattern - -**For new code with a specific error:** -```rust -use quarto_error_reporting::DiagnosticMessageBuilder; - -let error = DiagnosticMessageBuilder::error("Invalid YAML Schema") - .with_code("Q-1-10") // Pick from catalog or add new - .problem("Value must be a string, not a number") - .add_detail("Property `title` has type `number`") - .add_hint("Did you forget quotes around the value?") - .build(); -``` - -**For migration from old error systems:** -```rust -use quarto_error_reporting::generic_error; - -let error = generic_error!("Something went wrong"); -``` - -### Step 2: Render the Error - -```rust -// For terminal output -eprintln!("{}", error.to_text(None)); - -// With source context (for ariadne integration) -eprintln!("{}", error.to_text(Some(&source_context))); - -// For JSON output -println!("{}", serde_json::to_string_pretty(&error.to_json())?); -``` - ---- - -## Error Code System - -### Format - -Quarto uses TypeScript-style error codes: **`Q--`** - -- `Q-1-15` ✅ YAML and Configuration error #15 -- `Q-2-301` ✅ Markdown and Parsing error #301 -- `Q-3-5` ✅ Engines and Execution error #5 - -### Subsystem Numbers - -| Number | Subsystem | Examples | -|--------|-----------|----------| -| **0** | Internal/System | Q-0-1 (Internal error), Q-0-99 (Migration) | -| **1** | YAML and Configuration | Q-1-1 (YAML syntax), Q-1-10 (Schema validation) | -| **2** | Markdown and Parsing | Q-2-10 (Unclosed quote), Q-2-301 (Code block) | -| **3** | Engines and Execution | Q-3-1 (Engine not found), Q-3-50+ (ANSI writer) | -| **4** | Rendering and Formats | Q-4-1 (Unknown format) | -| **5** | Projects and Structure | Q-5-1 (Invalid structure) | -| **6** | Extensions and Plugins | Q-6-1 (Extension not found) | -| **7** | CLI and Tools | Q-7-1 (Invalid command) | -| **8** | Publishing | Q-8-1 (Publish target not found) | -| **9** | XML/CSL | Q-9-1 (XML syntax error) | -| **10** | Templates | Q-10-1 (Template parse error) | -| **11** | Lua Filters | Q-11-1 (Lua filter diagnostic) | -| **12** | Listing (`listing`) | Q-12-1 (Listing diagnostics) | -| **13** | Navigation (`navigation`) | Q-13-1 (Sidebar references missing document) | -| **14** | Theme (`theme`) | Q-14-1 (Invalid theme config) | -| **15** | Crossref (`crossref`) | Q-15-1 (Duplicate crossref identifier) | -| **16+** | Reserved | For future subsystems | - -> New subsystems take the next free number with a descriptive -> `subsystem` string (as `listing`/`navigation`/`theme`/`crossref` -> did). The number is what appears in the code; the string is what -> appears in the catalog entry and the `docs/errors//` -> directory. - -### Finding the Next Error Code - -```bash -cd crates/quarto-error-reporting - -# Find last error in your subsystem (e.g., Q-1-*) -jq 'keys | map(select(startswith("Q-1-"))) | sort | last' error_catalog.json -``` - -Use the next sequential number. Don't skip numbers or try to organize by category. - -### Adding to the Catalog - -Edit `error_catalog.json`: - -```json -{ - "Q-1-42": { - "subsystem": "yaml", - "title": "Your Error Title", - "message_template": "Brief description of the error", - "docs_url": "https://quarto.org/docs/errors/yaml/Q-1-42", - "since_version": "99.9.9" - } -} -``` - -**Fields:** -- `subsystem`: One of: internal, yaml, markdown, engine, rendering, project, extension, cli, publish -- `title`: Brief title (3-5 words) -- `message_template`: One-sentence description -- `docs_url`: Future documentation URL -- `since_version`: Use "99.9.9" for unreleased errors - ---- - -## Writing Good Error Messages - -Follow the **tidyverse four-part structure**: - -### 1. Title (Required) - -**Brief, specific description of what went wrong.** - -```rust -.error("Invalid YAML Schema") // ✅ Specific -.error("Error") // ❌ Too vague -``` - -### 2. Problem Statement (Recommended) - -**Use "must" or "can't" to describe the requirement or impossibility.** - -```rust -.problem("Title must be a string, not a number") // ✅ Clear requirement -.problem("Cannot combine date and datetime types") // ✅ Clear impossibility -.problem("Invalid input") // ❌ Too vague -``` - -### 3. Details (As Needed, Max 5) - -**Specific information about what, where, or why.** - -```rust -.add_detail("Property `title` has type `number`") -.add_info("Schema defined in `_quarto.yml`") -.add_note("This is a recent change in v2.0") -``` - -Use: -- `.add_detail()` for problems (✖ bullet) -- `.add_info()` for additional context (ℹ bullet) -- `.add_note()` for related information (• bullet) - -**Limit to 5 details total** - don't overwhelm users. - -### 4. Hints (Optional) - -**Actionable guidance, ends with `?`** - -```rust -.add_hint("Did you forget quotes around the value?") // ✅ Actionable -.add_hint("Convert both to the same type first?") // ✅ Specific -.add_hint("Check the documentation") // ❌ Not actionable -``` - -### Complete Example - -```rust -let error = DiagnosticMessageBuilder::error("Incompatible types") - .with_code("Q-1-2") - .problem("Cannot combine date and datetime types") - .add_detail("`x` has type `date`") - .add_detail("`y` has type `datetime`") - .add_info("Both values come from the same data source") - .add_hint("Convert both to the same type first?") - .build(); -``` - -Output: -``` -Error [Q-1-2]: Incompatible types - -Cannot combine date and datetime types - -✖ `x` has type `date` -✖ `y` has type `datetime` -ℹ Both values come from the same data source - -ℹ Convert both to the same type first? -``` - ---- - -## Common Patterns - -### Pattern 1: Parse Errors with Source Location - -Used in markdown parsing, YAML parsing: - -```rust -use quarto_error_reporting::DiagnosticMessageBuilder; -use quarto_source_map::SourceInfo; - -let location = SourceInfo::original(file_id, start_offset, end_offset); - -let error = DiagnosticMessageBuilder::error("Unclosed code block") - .with_code("Q-2-301") - .with_location(location) - .problem("Code block started but never closed") - .add_detail("The opening ``` was found but no closing ``` before end of block") - .add_hint("Add a closing ``` on a new line") - .build(); - -// Render with ariadne for visual source context -eprintln!("{}", error.to_text(Some(&source_context))); -``` - -### Pattern 2: Validation Errors (Structured Details) - -Used in YAML validation, schema checking: - -```rust -let error = DiagnosticMessageBuilder::error("Schema Validation Failed") - .with_code("Q-1-10") - .problem("Value does not match expected schema") - .add_detail(format!("Property `{}` has type `{}`", prop, actual)) - .add_detail(format!("Expected type is `{}`", expected)) - .add_info(format!("Schema defined in `{}`", schema_file)) - .add_hint("Check the schema documentation for valid values") - .build(); -``` - -### Pattern 3: Accumulating Multiple Errors - -Used throughout the codebase (DiagnosticCollector pattern): - -```rust -use pampa::utils::diagnostic_collector::DiagnosticCollector; - -let mut collector = DiagnosticCollector::new(); - -// Collect errors as you find them -for item in items { - if let Err(e) = validate(item) { - collector.error(e); - } -} - -// Check if any errors occurred -if collector.has_errors() { - for diagnostic in collector.diagnostics() { - eprintln!("{}", diagnostic.to_text(Some(&ctx))); - } - return Err(/*...*/); -} -``` - -### Pattern 4: Migration from Old Code - -When migrating from old error systems: - -```rust -// OLD CODE: -eprintln!("Error: File not found: {}", path); - -// NEW CODE (migration): -use quarto_error_reporting::generic_error; -let error = generic_error!(format!("File not found: {}", path)); -eprintln!("{}", error.to_text(None)); - -// EVENTUAL CODE (after assigning proper error code): -let error = DiagnosticMessageBuilder::error("File not found") - .with_code("Q-X-Y") // Add to catalog first - .problem(format!("Could not open file: {}", path)) - .add_hint("Check that the file exists and you have permission") - .build(); -``` - ---- - -## Testing Your Errors - -### Unit Tests - -Test that your error is constructed correctly: - -```rust -#[test] -fn test_invalid_schema_error() { - let error = DiagnosticMessageBuilder::error("Invalid YAML Schema") - .with_code("Q-1-10") - .problem("Value must be a string") - .build(); - - assert_eq!(error.code, Some("Q-1-10".to_string())); - assert_eq!(error.kind, DiagnosticKind::Error); - assert!(error.problem.is_some()); -} -``` - -### Integration Tests - -Test that errors render correctly: - -```rust -#[test] -fn test_error_rendering() { - let error = DiagnosticMessageBuilder::error("Test") - .with_code("Q-1-1") - .problem("Something went wrong") - .build(); - - let text = error.to_text(None); - assert!(text.contains("[Q-1-1]")); - assert!(text.contains("Test")); - assert!(text.contains("Something went wrong")); -} -``` - -### Snapshot Tests - -For complex error messages, use insta for snapshot testing: - -```rust -#[test] -fn test_complex_error_output() { - let error = create_complex_error(); - let text = error.to_text(None); - insta::assert_snapshot!(text); -} -``` - ---- - -## Examples - -See the `examples/` directory for complete, runnable examples: - -```bash -# Basic usage -cargo run --example basic_error - -# Builder API -cargo run --example builder_api - -# With error codes -cargo run --example with_error_code - -# With source locations -cargo run --example with_location - -# Diagnostic collector pattern -cargo run --example diagnostic_collector - -# Custom rendering options -cargo run --example custom_rendering - -# Migration helpers -cargo run --example migration_helpers -``` - ---- - -## Validation - -Use `.build_with_validation()` to check tidyverse compliance: - -```rust -let (msg, warnings) = DiagnosticMessageBuilder::error("Test") - .add_detail("1") - .add_detail("2") - .add_detail("3") - .add_detail("4") - .add_detail("5") - .add_detail("6") // Too many! - .build_with_validation(); - -for warning in warnings { - eprintln!("⚠ {}", warning); -} -// Output: ⚠ Message has 6 details (max 5 recommended by tidyverse guidelines) -``` - -Validation checks: -- Has problem statement? -- Too many details (>5)? -- Hints end with `?`? - ---- - -## Best Practices - -### Do ✅ - -- **Use specific error codes** from the catalog -- **Write clear problem statements** with "must" or "can't" -- **Provide actionable hints** when the fix is obvious -- **Include source locations** when available -- **Test your error messages** -- **Keep details under 5 bullets** - -### Don't ❌ - -- **Don't skip error codes** - assign proper Q-X-Y codes -- **Don't write vague messages** like "Error" or "Invalid input" -- **Don't overwhelm users** with too many details -- **Don't provide unhelpful hints** like "Check the docs" -- **Don't use different terminology** - stay consistent with Quarto -- **Don't forget to update the catalog** when adding new codes - ---- - -## Checklist for Adding a New Error - -- [ ] Find the next available error code for your subsystem -- [ ] Add entry to `error_catalog.json` -- [ ] Implement error using `DiagnosticMessageBuilder` -- [ ] Follow tidyverse four-part structure -- [ ] Add source location if available -- [ ] Write unit tests for the error -- [ ] Test rendering (text and JSON) -- [ ] Use `.build_with_validation()` to check compliance -- [ ] Add example to documentation if it's a common pattern -- [ ] Run `cargo test -p quarto-error-reporting` - ---- - -## Questions? - -- **Examples**: See `crates/quarto-error-reporting/examples/` -- **Design docs**: `/claude-notes/error-reporting-design-research.md` -- **Error ID system**: `/claude-notes/error-id-system-design.md` -- **Tidyverse guide**: https://style.tidyverse.org/errors.html - -For questions about error reporting, check the README or ask in the development chat. diff --git a/crates/quarto-error-reporting/Cargo.toml b/crates/quarto-error-reporting/Cargo.toml deleted file mode 100644 index 90af34f0d..000000000 --- a/crates/quarto-error-reporting/Cargo.toml +++ /dev/null @@ -1,46 +0,0 @@ -[package] -name = "quarto-error-reporting" -version.workspace = true -edition.workspace = true -authors.workspace = true -license.workspace = true -repository.workspace = true -description = "Error reporting and diagnostic messages for Quarto" - -[dependencies] -# Source location tracking -quarto-source-map = { workspace = true } - -# Error reporting -ariadne = { workspace = true } -thiserror = { workspace = true } - -# Serialization -serde = { workspace = true } -serde_json = { workspace = true } - -# JSON Schema generation for the wire format (bd-iey8o). Scoped to -# this crate AND to the `json` feature: `schemars` documents the -# machine-to-machine JSON shapes in `json.rs`. Do NOT add it to other -# crates for the purpose of validating user-authored YAML config — -# that's the job of `quarto-yaml-validation`, which speaks Quarto's own -# dialect. See `claude-notes/plans/2026-05-22-q2-render-json-errors.md` -# § "Coexistence with quarto-yaml-validation". -schemars = { workspace = true, optional = true } - -# URL handling for file:// hyperlinks -url = "2.5" - -[features] -# The `json.rs` wire shape (JsonDiagnostic etc.) and its `schemars` -# dependency are opt-in: the q2 consumers (quarto, quarto-core, -# quarto-preview, wasm-quarto-hub-client) enable `json`, while a -# standalone non-Quarto consumer gets a leaner build with no schemars. -# Default-off so the published crate stays minimal. -json = ["dep:schemars"] - -[dev-dependencies] -# No dev dependencies yet - -[lints] -workspace = true diff --git a/crates/quarto-error-reporting/README.md b/crates/quarto-error-reporting/README.md deleted file mode 100644 index 04be664c1..000000000 --- a/crates/quarto-error-reporting/README.md +++ /dev/null @@ -1,404 +0,0 @@ -# quarto-error-reporting - - - -Error reporting and diagnostic messages for Quarto, providing structured, user-friendly error messages following tidyverse best practices. - -## For Quarto Contributors - -This crate is **internal infrastructure** for the Quarto Rust port. It provides consistent, high-quality error reporting across all Quarto subsystems (YAML validation, markdown parsing, rendering, etc.). - -**If you're working on Quarto and need to report errors**, this guide will help you: -- Understand how error reporting works -- Choose the right pattern for your subsystem -- Add new error codes to the catalog -- Write tidyverse-compliant error messages - -See `examples/` for runnable code showing common patterns. - -## Overview - -This crate provides a comprehensive error reporting system inspired by: - -- **[ariadne](https://docs.rs/ariadne/)**: Visual compiler-quality error messages with source code context -- **[R cli package](https://cli.r-lib.org/)**: Semantic, structured text output -- **[Tidyverse style guide](https://style.tidyverse.org/errors.html)**: Best practices for error message content - -### Architecture - -``` -┌─────────────────────────────────────┐ -│ quarto-error-reporting │ -│ │ -│ DiagnosticMessage │ -│ ├─ title, code, kind │ -│ ├─ problem (what went wrong) │ -│ ├─ details (specific info) │ -│ ├─ hints (how to fix) │ -│ └─ location (SourceInfo) │ -│ │ -│ Three output formats: │ -│ ├─ to_text() → ANSI terminal │ -│ ├─ to_json() → machine-readable │ -│ └─ (with ariadne) → visual reports │ -└─────────────────────────────────────┘ - │ │ - ▼ ▼ - ┌─────────┐ ┌──────────────┐ - │ Error │ │ quarto- │ - │ Catalog │ │ markdown- │ - │ │ │ pandoc │ - │ 70+ │ │ (ANSI writer)│ - │ codes │ └──────────────┘ - └─────────┘ -``` - -**Key relationships**: -- **quarto-source-map**: Provides `SourceInfo` for tracking locations -- **ariadne**: Renders visual error reports with source context -- **quarto-markdown-pandoc**: Contains ANSI writer for terminal output - -## Quick Start - -### Basic Error - -```rust -use quarto_error_reporting::DiagnosticMessage; - -let error = DiagnosticMessage::error("File not found"); -println!("{}", error.to_text(None)); -// Output: Error: File not found -``` - -### With Builder API - -```rust -use quarto_error_reporting::DiagnosticMessageBuilder; - -let error = DiagnosticMessageBuilder::error("Incompatible types") - .with_code("Q-1-2") - .problem("Cannot combine date and datetime types") - .add_detail("`x` has type `date`") - .add_detail("`y` has type `datetime`") - .add_hint("Convert both to the same type?") - .build(); - -println!("{}", error.to_text(None)); -``` - -### With Source Location - -```rust -use quarto_error_reporting::DiagnosticMessageBuilder; -use quarto_source_map::SourceInfo; - -let location = SourceInfo::original(file_id, start_offset, end_offset); - -let error = DiagnosticMessageBuilder::error("Unclosed code block") - .with_code("Q-2-301") - .with_location(location) - .problem("Code block started but never closed") - .add_hint("Did you forget the closing ``` ?") - .build(); - -// Render with source context -println!("{}", error.to_text(Some(&source_context))); -``` - -See `examples/` for complete, runnable examples. - -## Error Code System - -Quarto supports TypeScript-style error codes for better searchability and documentation. - -**Format**: `Q--` (e.g., `Q-1-1`, `Q-2-301`) - -**Subsystem Numbers**: -- **0**: Internal/System Errors -- **1**: YAML and Configuration -- **2**: Markdown and Parsing -- **3**: Engines and Execution -- **4**: Rendering and Formats -- **5**: Projects and Structure -- **6**: Extensions and Plugins -- **7**: CLI and Tools -- **8**: Publishing and Deployment -- **9**: XML parsing and processing -- **10**: Templates -- **11+**: Reserved for future use - -**Benefits**: -- Users can Google "Q-2-301" instead of error message text -- Error codes are stable across versions -- Each code maps to documentation at `https://quarto.org/docs/errors//Q-X-Y` -- Optional but encouraged - -**Catalog**: See `error_catalog.json` for the complete catalog of 70+ error codes. - -## Tidyverse Guidelines - -The builder API encodes tidyverse best practices for error messages: - -### Four-Part Structure - -1. **Title**: Brief error message (required) -2. **Problem**: What went wrong, using "must" or "can't" (recommended) -3. **Details**: Specific information, max 5 bullets (as needed) -4. **Hints**: Optional guidance, ends with `?` (when helpful) - -### Builder Methods - -```rust -DiagnosticMessageBuilder::error(title) - .with_code(code) // Q-X-Y error code - .problem(statement) // What went wrong ("must"/"can't") - .add_detail(info) // ✖ bullet - specific error info - .add_info(info) // ℹ bullet - additional context - .add_note(info) // • bullet - related information - .add_hint(suggestion) // Actionable fix (ends with ?) - .with_location(source_info) // Source location for ariadne - .build() // Create the message -``` - -### Example Following Guidelines - -```rust -let error = DiagnosticMessageBuilder::error("Invalid YAML Schema") - .with_code("Q-1-10") - .problem("Value must be a string, not a number") - .add_detail("Property `title` has type `number`") - .add_detail("Expected type is `string`") - .add_info("Schema defined in `_quarto.yml`") - .add_hint("Did you forget quotes around the value?") - .build(); -``` - -### Validation - -Use `.build_with_validation()` to get warnings about tidyverse compliance: - -```rust -let (msg, warnings) = DiagnosticMessageBuilder::error("Test") - .build_with_validation(); - -for warning in warnings { - eprintln!("Warning: {}", warning); -} -``` - -## Integration Patterns - -### Pattern 1: Parse Errors (with SourceInfo) - -Used in `quarto-markdown-pandoc/src/readers/qmd_error_messages.rs`: - -```rust -use quarto_error_reporting::DiagnosticMessageBuilder; - -fn error_from_parse_state(...) -> DiagnosticMessage { - DiagnosticMessageBuilder::error(title) - .with_code(code) - .with_location(source_info) - .problem("...") - .add_detail("...") - .build() -} -``` - -### Pattern 2: Validation Errors (structured details) - -Used in YAML validation: - -```rust -let error = DiagnosticMessageBuilder::error("Schema Validation Failed") - .with_code("Q-1-10") - .problem("Value does not match expected schema") - .add_detail(format!("Property `{}` has type `{}`", prop, actual)) - .add_detail(format!("Expected type is `{}`", expected)) - .add_info(format!("Schema defined in `{}`", schema_file)) - .build(); -``` - -### Pattern 3: DiagnosticCollector (accumulating multiple errors) - -Used throughout the codebase: - -```rust -use pampa::utils::diagnostic_collector::DiagnosticCollector; - -let mut collector = DiagnosticCollector::new(); - -// Add errors as you encounter them -collector.error("First problem"); -collector.error_at("Second problem", location); - -// Check if any errors occurred -if collector.has_errors() { - for diagnostic in collector.diagnostics() { - eprintln!("{}", diagnostic.to_text(Some(&ctx))); - } - return Err(/*...*/); -} -``` - -### Pattern 4: Writer Errors (accumulated in context) - -Used in `quarto-markdown-pandoc/src/writers/ansi.rs`: - -```rust -struct WriterContext { - errors: Vec, -} - -impl WriterContext { - fn report_unsupported(&mut self, feature: &str) { - self.errors.push( - DiagnosticMessageBuilder::error(format!("{} not supported", feature)) - .with_code("Q-3-50") - .problem(format!("{} cannot be rendered in this format", feature)) - .add_hint("Consider using a different output format") - .build() - ); - } -} - -// At end of writing -if !ctx.errors.is_empty() { - return Err(ctx.errors); -} -``` - -## Adding New Error Codes - -### Quick Steps - -1. **Find next error code**: - ```bash - cd crates/quarto-error-reporting - jq 'keys | map(select(startswith("Q-2-"))) | sort | last' error_catalog.json - ``` - -2. **Add to catalog** (`error_catalog.json`): - ```json - { - "Q-2-42": { - "subsystem": "markdown", - "title": "Your Error Title", - "message_template": "Your error message", - "docs_url": "https://quarto.org/docs/errors/markdown/Q-2-42", - "since_version": "99.9.9" - } - } - ``` - -3. **Use in code**: - ```rust - let error = DiagnosticMessageBuilder::error("Your Error Title") - .with_code("Q-2-42") - .problem("...") - .build(); - ``` - -### Guidelines - -- **Sequential numbering**: Use the next available number (don't skip) -- **Subsystem consistency**: Use the correct subsystem number for your domain -- **Stable codes**: Once assigned, error codes should not change -- **Documentation**: Each code should map to a docs page (eventually) - -## Implementation Status - -**All core features complete:** - -- ✅ **Phase 1**: Core types (DiagnosticMessage, MessageContent, DetailItem) -- ✅ **Phase 2**: ariadne integration and JSON serialization -- ✅ **Phase 3**: ANSI writer (in quarto-markdown-pandoc) -- ✅ **Phase 4**: Builder API with tidyverse validation - -**Current catalog**: 70+ error codes across all subsystems - -## Semantic Markup - -Use Pandoc span syntax for semantic inline markup in error messages: - -```markdown -Could not find file `config.yaml`{.file} in directory `/home/user/.config`{.path} -``` - -**Semantic classes** (to be standardized): -- `.file` - filenames and paths -- `.engine` - engine names (jupyter, knitr) -- `.format` - output formats (html, pdf) -- `.option` - YAML option names -- `.code` - generic code - -## Output Formats - -The same `DiagnosticMessage` can be rendered to multiple formats: - -### ANSI Terminal - -```rust -let text = error.to_text(None); -println!("{}", text); -``` - -With source context (via ariadne): - -```rust -let text = error.to_text(Some(&source_context)); -println!("{}", text); -``` - -### JSON - -```rust -let json = error.to_json(); -println!("{}", serde_json::to_string_pretty(&json)?); -``` - -### Custom Options - -```rust -use quarto_error_reporting::TextRenderOptions; - -let options = TextRenderOptions { - enable_hyperlinks: false, // Disable for snapshot testing -}; - -let text = error.to_text_with_options(None, &options); -``` - -## Development - -### Run Tests - -```bash -cargo test -p quarto-error-reporting -``` - -### Build Documentation - -```bash -cargo doc -p quarto-error-reporting --open -``` - -### Examples - -```bash -cargo run --example basic_error -cargo run --example builder_api -cargo run --example with_location -``` - -## Resources - -- **Design docs**: `/claude-notes/error-reporting-design-research.md` -- **Error ID system**: `/claude-notes/error-id-system-design.md` -- **Examples**: `examples/` directory -- **Tidyverse guide**: https://style.tidyverse.org/errors.html -- **ariadne docs**: https://docs.rs/ariadne/ - -## License - -MIT diff --git a/crates/quarto-error-reporting/examples/basic_error.rs b/crates/quarto-error-reporting/examples/basic_error.rs deleted file mode 100644 index 17a583ade..000000000 --- a/crates/quarto-error-reporting/examples/basic_error.rs +++ /dev/null @@ -1,23 +0,0 @@ -//! Basic error message example. -//! -//! This example shows the simplest way to create and display a diagnostic message. - -use quarto_error_reporting::DiagnosticMessage; - -fn main() { - // Create a simple error message - let error = DiagnosticMessage::error("File not found"); - - // Render to text - println!("{}", error.to_text(None)); - println!(); - - // Create a warning - let warning = DiagnosticMessage::warning("Deprecated feature used"); - println!("{}", warning.to_text(None)); - println!(); - - // Create an info message - let info = DiagnosticMessage::info("Processing 42 files"); - println!("{}", info.to_text(None)); -} diff --git a/crates/quarto-error-reporting/examples/builder_api.rs b/crates/quarto-error-reporting/examples/builder_api.rs deleted file mode 100644 index 1b6002740..000000000 --- a/crates/quarto-error-reporting/examples/builder_api.rs +++ /dev/null @@ -1,65 +0,0 @@ -//! Builder API example. -//! -//! This example demonstrates the builder API which encodes tidyverse guidelines -//! for error messages: title, problem, details, and hints. - -use quarto_error_reporting::DiagnosticMessageBuilder; - -fn main() { - println!("=== Example 1: Simple builder usage ===\n"); - - let error1 = DiagnosticMessageBuilder::error("Invalid input") - .problem("Value must be numeric") - .add_detail("Found text in column 3") - .add_hint("Check the input file format") - .build(); - - println!("{}", error1.to_text(None)); - - println!("\n=== Example 2: Tidyverse four-part structure ===\n"); - - let error2 = DiagnosticMessageBuilder::error("Incompatible types") - .problem("Cannot combine date and datetime types") - .add_detail("`x` has type `date`") - .add_detail("`y` has type `datetime`") - .add_info("Both values come from the same data source") - .add_hint("Convert both to the same type first?") - .build(); - - println!("{}", error2.to_text(None)); - - println!("\n=== Example 3: Multiple details and hints ===\n"); - - let error3 = DiagnosticMessageBuilder::error("Schema validation failed") - .problem("Configuration does not match expected schema") - .add_detail("Property `title` has type `number`") - .add_detail("Expected type is `string`") - .add_detail("Property `author` is missing") - .add_info("Schema is defined in `_quarto.yml`") - .add_hint("Did you forget quotes around the title?") - .add_hint("Add an `author` field to the configuration") - .build(); - - println!("{}", error3.to_text(None)); - - println!("\n=== Example 4: Builder validation ===\n"); - - // This will trigger validation warnings - let (msg, warnings) = DiagnosticMessageBuilder::error("Validation test") - .add_detail("Detail 1") - .add_detail("Detail 2") - .add_detail("Detail 3") - .add_detail("Detail 4") - .add_detail("Detail 5") - .add_detail("Detail 6") // Too many! - .build_with_validation(); - - println!("{}", msg.to_text(None)); - - if !warnings.is_empty() { - println!("\nValidation warnings:"); - for warning in warnings { - println!(" ⚠ {}", warning); - } - } -} diff --git a/crates/quarto-error-reporting/examples/custom_rendering.rs b/crates/quarto-error-reporting/examples/custom_rendering.rs deleted file mode 100644 index 9027299ad..000000000 --- a/crates/quarto-error-reporting/examples/custom_rendering.rs +++ /dev/null @@ -1,91 +0,0 @@ -//! Custom rendering options example. -//! -//! This example shows how to customize the output format using TextRenderOptions, -//! particularly useful for disabling terminal hyperlinks in snapshot tests. - -use quarto_error_reporting::{DiagnosticMessageBuilder, TextRenderOptions}; -use quarto_source_map::{SourceContext, SourceInfo}; - -fn main() { - println!("=== Example 1: Default rendering (with hyperlinks) ===\n"); - - let mut ctx = SourceContext::new(); - let file_id = ctx.add_file( - "document.qmd".to_string(), - Some("# My Document\n\nSome content here.\n".to_string()), - ); - - let location = SourceInfo::original(file_id, 15, 27); - - let error = DiagnosticMessageBuilder::error("Parse error") - .with_code("Q-2-100") - .with_location(location) - .problem("Invalid markdown syntax") - .add_hint("Check the markdown formatting") - .build(); - - // Default rendering includes OSC 8 hyperlinks for file paths - let default_text = error.to_text(Some(&ctx)); - println!("{}", default_text); - - println!("\n=== Example 2: Rendering without hyperlinks (for tests) ===\n"); - - // Disable hyperlinks - useful for snapshot testing where absolute paths - // would cause differences between machines - let options = TextRenderOptions { - enable_hyperlinks: false, - }; - - let no_hyperlink_text = error.to_text_with_options(Some(&ctx), &options); - println!("{}", no_hyperlink_text); - - println!("\n=== Example 3: Comparing outputs ===\n"); - - // Show the difference in output - println!("With hyperlinks enabled:"); - println!(" Length: {} bytes", default_text.len()); - println!( - " Contains OSC 8 codes: {}", - default_text.contains("\x1b]8;") - ); - - println!("\nWith hyperlinks disabled:"); - println!(" Length: {} bytes", no_hyperlink_text.len()); - println!( - " Contains OSC 8 codes: {}", - no_hyperlink_text.contains("\x1b]8;") - ); - - println!("\n=== Example 4: JSON output (no hyperlinks) ===\n"); - - let json = error.to_json(); - println!("{}", serde_json::to_string_pretty(&json).unwrap()); - - println!("\n=== Example 5: Multiple diagnostics with custom rendering ===\n"); - - let error2 = DiagnosticMessageBuilder::error("Type mismatch") - .with_code("Q-1-15") - .problem("Expected string, found number") - .add_detail("Value: 42") - .add_detail("Expected type: string") - .build(); - - let error3 = DiagnosticMessageBuilder::error("Missing field") - .with_code("Q-1-20") - .problem("Required field 'author' not found") - .add_hint("Add an 'author' field to your configuration") - .build(); - - let errors = [error, error2, error3]; - - // Render all with consistent options - let no_hyperlinks = TextRenderOptions { - enable_hyperlinks: false, - }; - - for (i, err) in errors.iter().enumerate() { - println!("Error {}:", i + 1); - println!("{}", err.to_text_with_options(Some(&ctx), &no_hyperlinks)); - println!(); - } -} diff --git a/crates/quarto-error-reporting/examples/diagnostic_collector.rs b/crates/quarto-error-reporting/examples/diagnostic_collector.rs deleted file mode 100644 index 6ba2e70f7..000000000 --- a/crates/quarto-error-reporting/examples/diagnostic_collector.rs +++ /dev/null @@ -1,138 +0,0 @@ -//! Diagnostic collector pattern example. -//! -//! This example shows how to use DiagnosticCollector to accumulate multiple errors -//! during processing - a common pattern in Quarto subsystems like YAML validation -//! and markdown parsing. -//! -//! Note: DiagnosticCollector is in pampa, so this example shows -//! the pattern manually. In real code, use the DiagnosticCollector utility. - -use quarto_error_reporting::{DiagnosticKind, DiagnosticMessage, DiagnosticMessageBuilder}; -use quarto_source_map::{SourceContext, SourceInfo}; - -/// Simple collector for accumulating diagnostic messages -struct SimpleCollector { - diagnostics: Vec, -} - -impl SimpleCollector { - fn new() -> Self { - Self { - diagnostics: Vec::new(), - } - } - - fn add(&mut self, diagnostic: DiagnosticMessage) { - self.diagnostics.push(diagnostic); - } - - fn error(&mut self, message: impl Into) { - self.add(DiagnosticMessage::error(message.into())); - } - - fn warn(&mut self, message: impl Into) { - self.add(DiagnosticMessage::warning(message.into())); - } - - fn error_at(&mut self, message: impl Into, location: SourceInfo) { - self.add( - DiagnosticMessageBuilder::error(message.into()) - .with_location(location) - .build(), - ); - } - - fn has_errors(&self) -> bool { - self.diagnostics - .iter() - .any(|d| d.kind == DiagnosticKind::Error) - } - - fn diagnostics(&self) -> &[DiagnosticMessage] { - &self.diagnostics - } - - fn to_text(&self, ctx: Option<&SourceContext>) -> Vec { - self.diagnostics.iter().map(|d| d.to_text(ctx)).collect() - } -} - -fn main() { - println!("=== Example 1: Accumulating multiple errors ===\n"); - - let mut collector = SimpleCollector::new(); - - // Simulate validating a YAML file - collector.error("Missing required field 'title'"); - collector.warn("Field 'description' is deprecated"); - collector.error("Invalid value for 'format': expected string, got number"); - - if collector.has_errors() { - println!( - "Validation failed with {} diagnostics:", - collector.diagnostics().len() - ); - for text in collector.to_text(None) { - println!("{}", text); - } - } - - println!("\n=== Example 2: Errors with source locations ===\n"); - - let mut ctx = SourceContext::new(); - let file_id = ctx.add_file( - "config.yml".to_string(), - Some("title: 123\nformat: html\nauthor: John\n".to_string()), - ); - - let mut collector2 = SimpleCollector::new(); - - // Error in "title: 123" (offsets 7-10) - let loc1 = SourceInfo::original(file_id, 7, 10); - collector2.error_at("Title must be a string", loc1); - - // Warning at "John" (offsets 33-37) - let loc2 = SourceInfo::original(file_id, 33, 37); - let warning = DiagnosticMessageBuilder::warning("Author field should include email") - .with_location(loc2) - .add_hint("Use format: 'Name '") - .build(); - collector2.add(warning); - - println!("Collected diagnostics:"); - for text in collector2.to_text(Some(&ctx)) { - println!("{}", text); - println!(); - } - - println!("=== Example 3: JSON output for all diagnostics ===\n"); - - let json_array: Vec<_> = collector2 - .diagnostics() - .iter() - .map(|d| d.to_json()) - .collect(); - - println!("{}", serde_json::to_string_pretty(&json_array).unwrap()); - - println!("\n=== Example 4: Continuing vs. failing fast ===\n"); - - let mut collector3 = SimpleCollector::new(); - - // In some subsystems, we collect all errors before failing - for i in 1..=3 { - collector3.error(format!("Error in item {}", i)); - } - - // Check at the end - if collector3.has_errors() { - eprintln!( - "Processing failed with {} errors", - collector3.diagnostics().len() - ); - eprintln!("\nErrors:"); - for diag in collector3.diagnostics() { - eprintln!(" - {}", diag.title); - } - } -} diff --git a/crates/quarto-error-reporting/examples/migration_helpers.rs b/crates/quarto-error-reporting/examples/migration_helpers.rs deleted file mode 100644 index d10c2c188..000000000 --- a/crates/quarto-error-reporting/examples/migration_helpers.rs +++ /dev/null @@ -1,70 +0,0 @@ -//! Migration helpers example. -//! -//! This example shows the generic_error! and generic_warning! macros used during -//! migration from old error systems. These macros create errors with code Q-0-99 -//! and include file/line tracking for easier debugging during the transition period. - -use quarto_error_reporting::{generic_error, generic_warning}; - -fn main() { - println!("=== Example 1: Using generic_error! macro ===\n"); - - // The generic_error! macro creates a DiagnosticMessage with: - // - Code: Q-0-99 (generic migration error) - // - File and line number where the macro was invoked - // - The provided message - let error = generic_error!("Something went wrong during migration"); - - println!("{}", error.to_text(None)); - println!(); - - // Check the error code - println!("Error code: {:?}", error.code); - println!(); - - println!("=== Example 2: Using generic_warning! macro ===\n"); - - let warning = generic_warning!("This feature is not yet fully migrated"); - - println!("{}", warning.to_text(None)); - println!(); - - println!("=== Example 3: Migration pattern in practice ===\n"); - - // During migration, you might replace old error handling like this: - // - // OLD CODE: - // eprintln!("Error: File not found: {}", path); - // return Err(...); - // - // NEW CODE (migration phase): - // let error = generic_error!(format!("File not found: {}", path)); - // eprintln!("{}", error.to_text(None)); - // return Err(...); - // - // FINAL CODE: - // let error = DiagnosticMessageBuilder::error("File not found") - // .with_code("Q-X-Y") // Proper error code - // .problem(format!("Could not open file: {}", path)) - // .add_hint("Check that the file exists and you have permission") - // .build(); - - let path = "/nonexistent/file.qmd"; - let migration_error = generic_error!(format!("File not found: {}", path)); - - println!("Migration-style error:"); - println!("{}", migration_error.to_text(None)); - println!(); - - println!("=== Example 4: JSON output shows file/line info ===\n"); - - let error_with_location = generic_error!("Error with source tracking"); - let json = error_with_location.to_json(); - - println!("{}", serde_json::to_string_pretty(&json).unwrap()); - println!(); - - println!("Note: The generic_error! and generic_warning! macros are intended"); - println!("for migration purposes only. New code should use DiagnosticMessageBuilder"); - println!("with proper error codes (Q-X-Y) instead of Q-0-99."); -} diff --git a/crates/quarto-error-reporting/examples/with_location.rs b/crates/quarto-error-reporting/examples/with_location.rs deleted file mode 100644 index 3bfc3d398..000000000 --- a/crates/quarto-error-reporting/examples/with_location.rs +++ /dev/null @@ -1,64 +0,0 @@ -//! Source location example. -//! -//! This example shows how to attach source location information to diagnostic messages -//! for integration with ariadne and source context rendering. - -use quarto_error_reporting::DiagnosticMessageBuilder; -use quarto_source_map::{SourceContext, SourceInfo}; - -fn main() { - println!("=== Example 1: Error with source location ===\n"); - - // Create a source context - let mut ctx = SourceContext::new(); - let file_id = ctx.add_file( - "example.qmd".to_string(), - Some("title: My Document\nauthor: John Doe\ndate: 2024-01-01\n".to_string()), - ); - - // Create a location (let's say there's an error in "My Document" - offsets 7 to 18) - let location = SourceInfo::original(file_id, 7, 18); - - let error = DiagnosticMessageBuilder::error("Invalid title format") - .with_code("Q-1-10") - .with_location(location) - .problem("Title must be a string, not a complex object") - .add_detail("Title value starts at this location") - .add_hint("Ensure the title is a simple quoted string") - .build(); - - // Render WITHOUT context - shows offset - println!("Without context:"); - println!("{}", error.to_text(None)); - - println!("\n---\n"); - - // Render WITH context - shows file path and line:column - println!("With context:"); - println!("{}", error.to_text(Some(&ctx))); - - println!("\n=== Example 2: Multiple locations ===\n"); - - let another_ctx = SourceContext::new(); - - // Note: This example shows the API, but without actual file content, - // the rendering will still show offsets. In real usage with proper - // SourceContext, this would show rich source snippets via ariadne. - - let location2 = SourceInfo::original(quarto_source_map::FileId(0), 100, 110); - - let error2 = DiagnosticMessageBuilder::error("Unclosed code block") - .with_code("Q-2-301") - .with_location(location2) - .problem("Code block started but never closed") - .add_detail("The opening ``` was found but no closing ``` before end of block") - .add_hint("Add a closing ``` on a new line") - .build(); - - println!("{}", error2.to_text(Some(&another_ctx))); - - println!("\n=== Example 3: JSON output with location ===\n"); - - let json = error.to_json(); - println!("{}", serde_json::to_string_pretty(&json).unwrap()); -} diff --git a/crates/quarto-error-reporting/schemas/json-diagnostic.json b/crates/quarto-error-reporting/schemas/json-diagnostic.json deleted file mode 100644 index 85282e301..000000000 --- a/crates/quarto-error-reporting/schemas/json-diagnostic.json +++ /dev/null @@ -1,145 +0,0 @@ -{ - "$defs": { - "JsonDiagnosticDetail": { - "description": "One detail item in a [`JsonDiagnostic`].", - "properties": { - "content": { - "type": "string" - }, - "end_column": { - "format": "uint32", - "minimum": 0, - "type": [ - "integer", - "null" - ] - }, - "end_line": { - "format": "uint32", - "minimum": 0, - "type": [ - "integer", - "null" - ] - }, - "kind": { - "type": "string" - }, - "start_column": { - "format": "uint32", - "minimum": 0, - "type": [ - "integer", - "null" - ] - }, - "start_line": { - "format": "uint32", - "minimum": 0, - "type": [ - "integer", - "null" - ] - } - }, - "required": [ - "kind", - "content" - ], - "type": "object" - } - }, - "$schema": "https://json-schema.org/draft/2020-12/schema", - "description": "A diagnostic message in transport-friendly JSON form.\n\nLine and column numbers are 1-based to match Monaco.\n\n## `$schema` field (bd-iey8o)\n\nEach instance carries a `$schema` field pointing at\n[`JsonDiagnostic::SCHEMA_URL`] so that consumers reading the\ndiagnostic over the wire (CLI stderr, WASM bridge, preview API)\ncan discover the JSON Schema describing this shape without prior\nknowledge. The field is a static-string field with a default\nmatching the const URL — the only place `JsonDiagnostic` is\nconstructed (`diagnostic_to_json`) sets it, and downstream\ntransforms like `with_source_file` preserve it.", - "properties": { - "$schema": { - "description": "JSON Schema URI describing this object's shape. Const value\nis [`JsonDiagnostic::SCHEMA_URL`]; included on the wire so\nconsumers can self-discover the contract.", - "type": "string" - }, - "code": { - "type": [ - "string", - "null" - ] - }, - "details": { - "items": { - "$ref": "#/$defs/JsonDiagnosticDetail" - }, - "type": "array" - }, - "end_column": { - "format": "uint32", - "minimum": 0, - "type": [ - "integer", - "null" - ] - }, - "end_line": { - "format": "uint32", - "minimum": 0, - "type": [ - "integer", - "null" - ] - }, - "hints": { - "items": { - "type": "string" - }, - "type": "array" - }, - "kind": { - "type": "string" - }, - "problem": { - "type": [ - "string", - "null" - ] - }, - "rendered": { - "description": "Pre-rendered ariadne source-context snippet (bd-352bh).\nPopulated when the diagnostic carries a `location` and the\nconverting site has a [`SourceContext`] to draw from\n(i.e. always, in [`diagnostic_to_json`]). Same text the\n`q2 render` CLI prints to stdout — ANSI-coded; strip on the\nJS side for browser display. Consumers can render this\nverbatim in a `
` block for the rich source-context\nview, or ignore it and fall back to the structured fields\nfor a compact summary. `None` for unlocated diagnostics\n(rare but possible for project-level errors with no span).",
-      "type": [
-        "string",
-        "null"
-      ]
-    },
-    "source_file": {
-      "description": "Source-file attribution for project-scoped diagnostics\n(bd-rqba). When the project pipeline emits a warning that\noriginates in *another* file (e.g., a sidebar entry that\nreferences a sibling page), this field carries that\nsibling's path so the in-app overlay can label the warning\nwith its source instead of free-floating text. `None` for\npage-local diagnostics whose location already pins them\nto the active page's source.",
-      "type": [
-        "string",
-        "null"
-      ]
-    },
-    "start_column": {
-      "format": "uint32",
-      "minimum": 0,
-      "type": [
-        "integer",
-        "null"
-      ]
-    },
-    "start_line": {
-      "format": "uint32",
-      "minimum": 0,
-      "type": [
-        "integer",
-        "null"
-      ]
-    },
-    "title": {
-      "type": "string"
-    }
-  },
-  "required": [
-    "$schema",
-    "kind",
-    "title",
-    "hints",
-    "details"
-  ],
-  "title": "JsonDiagnostic",
-  "type": "object"
-}
diff --git a/crates/quarto-error-reporting/schemas/json-pass1-failure.json b/crates/quarto-error-reporting/schemas/json-pass1-failure.json
deleted file mode 100644
index f83f01e6b..000000000
--- a/crates/quarto-error-reporting/schemas/json-pass1-failure.json
+++ /dev/null
@@ -1,173 +0,0 @@
-{
-  "$defs": {
-    "JsonDiagnostic": {
-      "description": "A diagnostic message in transport-friendly JSON form.\n\nLine and column numbers are 1-based to match Monaco.\n\n## `$schema` field (bd-iey8o)\n\nEach instance carries a `$schema` field pointing at\n[`JsonDiagnostic::SCHEMA_URL`] so that consumers reading the\ndiagnostic over the wire (CLI stderr, WASM bridge, preview API)\ncan discover the JSON Schema describing this shape without prior\nknowledge. The field is a static-string field with a default\nmatching the const URL — the only place `JsonDiagnostic` is\nconstructed (`diagnostic_to_json`) sets it, and downstream\ntransforms like `with_source_file` preserve it.",
-      "properties": {
-        "$schema": {
-          "description": "JSON Schema URI describing this object's shape. Const value\nis [`JsonDiagnostic::SCHEMA_URL`]; included on the wire so\nconsumers can self-discover the contract.",
-          "type": "string"
-        },
-        "code": {
-          "type": [
-            "string",
-            "null"
-          ]
-        },
-        "details": {
-          "items": {
-            "$ref": "#/$defs/JsonDiagnosticDetail"
-          },
-          "type": "array"
-        },
-        "end_column": {
-          "format": "uint32",
-          "minimum": 0,
-          "type": [
-            "integer",
-            "null"
-          ]
-        },
-        "end_line": {
-          "format": "uint32",
-          "minimum": 0,
-          "type": [
-            "integer",
-            "null"
-          ]
-        },
-        "hints": {
-          "items": {
-            "type": "string"
-          },
-          "type": "array"
-        },
-        "kind": {
-          "type": "string"
-        },
-        "problem": {
-          "type": [
-            "string",
-            "null"
-          ]
-        },
-        "rendered": {
-          "description": "Pre-rendered ariadne source-context snippet (bd-352bh).\nPopulated when the diagnostic carries a `location` and the\nconverting site has a [`SourceContext`] to draw from\n(i.e. always, in [`diagnostic_to_json`]). Same text the\n`q2 render` CLI prints to stdout — ANSI-coded; strip on the\nJS side for browser display. Consumers can render this\nverbatim in a `
` block for the rich source-context\nview, or ignore it and fall back to the structured fields\nfor a compact summary. `None` for unlocated diagnostics\n(rare but possible for project-level errors with no span).",
-          "type": [
-            "string",
-            "null"
-          ]
-        },
-        "source_file": {
-          "description": "Source-file attribution for project-scoped diagnostics\n(bd-rqba). When the project pipeline emits a warning that\noriginates in *another* file (e.g., a sidebar entry that\nreferences a sibling page), this field carries that\nsibling's path so the in-app overlay can label the warning\nwith its source instead of free-floating text. `None` for\npage-local diagnostics whose location already pins them\nto the active page's source.",
-          "type": [
-            "string",
-            "null"
-          ]
-        },
-        "start_column": {
-          "format": "uint32",
-          "minimum": 0,
-          "type": [
-            "integer",
-            "null"
-          ]
-        },
-        "start_line": {
-          "format": "uint32",
-          "minimum": 0,
-          "type": [
-            "integer",
-            "null"
-          ]
-        },
-        "title": {
-          "type": "string"
-        }
-      },
-      "required": [
-        "$schema",
-        "kind",
-        "title",
-        "hints",
-        "details"
-      ],
-      "type": "object"
-    },
-    "JsonDiagnosticDetail": {
-      "description": "One detail item in a [`JsonDiagnostic`].",
-      "properties": {
-        "content": {
-          "type": "string"
-        },
-        "end_column": {
-          "format": "uint32",
-          "minimum": 0,
-          "type": [
-            "integer",
-            "null"
-          ]
-        },
-        "end_line": {
-          "format": "uint32",
-          "minimum": 0,
-          "type": [
-            "integer",
-            "null"
-          ]
-        },
-        "kind": {
-          "type": "string"
-        },
-        "start_column": {
-          "format": "uint32",
-          "minimum": 0,
-          "type": [
-            "integer",
-            "null"
-          ]
-        },
-        "start_line": {
-          "format": "uint32",
-          "minimum": 0,
-          "type": [
-            "integer",
-            "null"
-          ]
-        }
-      },
-      "required": [
-        "kind",
-        "content"
-      ],
-      "type": "object"
-    }
-  },
-  "$schema": "https://json-schema.org/draft/2020-12/schema",
-  "description": "A Pass-1 failure (parse error or metadata error) in a project\nfile *other than* the active page (bd-rqba). Active-page\nfailures take the page-render error path; siblings flow through\nhere so the overlay can render them with source attribution\nwithout forcing the lenient preview to abort.\n\nStrict-vs-lenient policy lives at the consumer (Decision D1):\n`quarto preview` / hub-client surfaces these as warnings and\nkeeps rendering; `quarto render` (CLI) treats any non-empty\n`pass1_failures` as a non-zero exit (`bd-creo`).\n\n`$schema` carries [`JsonPass1Failure::SCHEMA_URL`] so consumers\ncan distinguish this shape from a plain `JsonDiagnostic` line on\na mixed stderr stream and self-discover its contract.",
-  "properties": {
-    "$schema": {
-      "description": "JSON Schema URI describing this object's shape. Const value\nis [`JsonPass1Failure::SCHEMA_URL`].",
-      "type": "string"
-    },
-    "diagnostics": {
-      "items": {
-        "$ref": "#/$defs/JsonDiagnostic"
-      },
-      "type": "array"
-    },
-    "error": {
-      "type": "string"
-    },
-    "source_file": {
-      "type": "string"
-    }
-  },
-  "required": [
-    "$schema",
-    "source_file",
-    "error",
-    "diagnostics"
-  ],
-  "title": "JsonPass1Failure",
-  "type": "object"
-}
diff --git a/crates/quarto-error-reporting/src/builder.rs b/crates/quarto-error-reporting/src/builder.rs
deleted file mode 100644
index b276ea40f..000000000
--- a/crates/quarto-error-reporting/src/builder.rs
+++ /dev/null
@@ -1,595 +0,0 @@
-//! Builder API for diagnostic messages.
-//!
-//! This module provides a builder pattern that encodes tidyverse-style error message
-//! guidelines directly in the API, making it easy to construct well-structured error messages.
-
-use crate::diagnostic::{
-    DetailItem, DetailKind, DiagnosticKind, DiagnosticMessage, MessageContent,
-};
-
-/// Builder for creating diagnostic messages following tidyverse guidelines.
-///
-/// The builder API naturally encourages the tidyverse four-part error structure:
-/// 1. **Title**: Brief error message (via `.error()`, `.warning()`, etc.)
-/// 2. **Problem**: What went wrong - the "must" or "can't" statement (via `.problem()`)
-/// 3. **Details**: Specific information - max 5 bulleted items (via `.add_detail()`, `.add_info()`)
-/// 4. **Hints**: Optional guidance (via `.add_hint()`)
-///
-/// # Example
-///
-/// ```
-/// use quarto_error_reporting::DiagnosticMessageBuilder;
-///
-/// let error = DiagnosticMessageBuilder::error("Incompatible types")
-///     .with_code("Q-1-2") // quarto-error-code-audit-ignore
-///     .problem("Cannot combine date and datetime types")
-///     .add_detail("`x`{.arg} has type `date`{.type}")
-///     .add_detail("`y`{.arg} has type `datetime`{.type}")
-///     .add_hint("Convert both to the same type?")
-///     .build();
-///
-/// assert_eq!(error.title, "Incompatible types");
-/// assert_eq!(error.code, Some("Q-1-2".to_string())); // quarto-error-code-audit-ignore
-/// assert!(error.problem.is_some());
-/// assert_eq!(error.details.len(), 2);
-/// assert_eq!(error.hints.len(), 1);
-/// ```
-#[derive(Debug, Clone)]
-pub struct DiagnosticMessageBuilder {
-    /// The kind of diagnostic (Error, Warning, Info)
-    kind: DiagnosticKind,
-
-    /// Brief title for the error
-    title: String,
-
-    /// Optional error code (e.g., "Q-1-1") (quarto-error-code-audit-ignore)
-    code: Option,
-
-    /// The problem statement (the "what")
-    problem: Option,
-
-    /// Specific error details (the "where/why")
-    details: Vec,
-
-    /// Optional hints for fixing
-    hints: Vec,
-
-    /// Source location for this diagnostic
-    location: Option,
-}
-
-impl DiagnosticMessageBuilder {
-    /// Create a new builder with the specified kind and title.
-    ///
-    /// Most code should use the convenience methods `.error()`, `.warning()`, or `.info()`
-    /// instead of calling this directly.
-    pub fn new(kind: DiagnosticKind, title: impl Into) -> Self {
-        Self {
-            kind,
-            title: title.into(),
-            code: None,
-            problem: None,
-            details: Vec::new(),
-            hints: Vec::new(),
-            location: None,
-        }
-    }
-
-    /// Create an error diagnostic builder.
-    ///
-    /// # Example
-    ///
-    /// ```
-    /// use quarto_error_reporting::DiagnosticMessageBuilder;
-    ///
-    /// let error = DiagnosticMessageBuilder::error("YAML Syntax Error")
-    ///     .build();
-    /// ```
-    pub fn error(title: impl Into) -> Self {
-        Self::new(DiagnosticKind::Error, title)
-    }
-
-    /// Create a generic error for migration purposes.
-    ///
-    /// This is a convenience method for the migration from ErrorCollector to DiagnosticMessage.
-    /// It creates an error with code Q-0-99 (quarto-error-code-audit-ignore) and includes file/line information for tracking
-    /// where the error originated in the code.
-    ///
-    /// # Example
-    ///
-    /// ```
-    /// use quarto_error_reporting::DiagnosticMessageBuilder;
-    ///
-    /// let error = DiagnosticMessageBuilder::generic_error(
-    ///     "Found unexpected attribute",
-    ///     file!(),
-    ///     line!()
-    /// );
-    /// assert_eq!(error.code, Some("Q-0-99".to_string())); // quarto-error-code-audit-ignore
-    /// assert!(error.title.contains("Found unexpected attribute"));
-    /// ```
-    pub fn generic_error(message: impl Into, file: &str, line: u32) -> DiagnosticMessage {
-        let title = format!("{} (at {}:{})", message.into(), file, line);
-        Self::error(title).with_code("Q-0-99").build() // quarto-error-code-audit-ignore
-    }
-
-    /// Create a generic warning for migration purposes.
-    ///
-    /// Similar to `generic_error()` but for warnings.
-    ///
-    /// # Example
-    ///
-    /// ```
-    /// use quarto_error_reporting::DiagnosticMessageBuilder;
-    ///
-    /// let warning = DiagnosticMessageBuilder::generic_warning(
-    ///     "Caption found without table",
-    ///     file!(),
-    ///     line!()
-    /// );
-    /// assert_eq!(warning.code, Some("Q-0-99".to_string()));
-    /// ```
-    pub fn generic_warning(message: impl Into, file: &str, line: u32) -> DiagnosticMessage {
-        let title = format!("{} (at {}:{})", message.into(), file, line);
-        Self::warning(title).with_code("Q-0-99").build() // quarto-error-code-audit-ignore
-    }
-
-    /// Create a warning diagnostic builder.
-    ///
-    /// # Example
-    ///
-    /// ```
-    /// use quarto_error_reporting::DiagnosticMessageBuilder;
-    ///
-    /// let warning = DiagnosticMessageBuilder::warning("Deprecated feature")
-    ///     .build();
-    /// ```
-    pub fn warning(title: impl Into) -> Self {
-        Self::new(DiagnosticKind::Warning, title)
-    }
-
-    /// Create an info diagnostic builder.
-    ///
-    /// # Example
-    ///
-    /// ```
-    /// use quarto_error_reporting::DiagnosticMessageBuilder;
-    ///
-    /// let info = DiagnosticMessageBuilder::info("Processing complete")
-    ///     .build();
-    /// ```
-    pub fn info(title: impl Into) -> Self {
-        Self::new(DiagnosticKind::Info, title)
-    }
-
-    /// Set the error code.
-    ///
-    /// Error codes follow the format `Q--` (e.g., "Q-1-1"). (quarto-error-code-audit-ignore)
-    ///
-    /// # Example
-    ///
-    /// ```
-    /// use quarto_error_reporting::DiagnosticMessageBuilder;
-    ///
-    /// let error = DiagnosticMessageBuilder::error("YAML Syntax Error")
-    ///     .with_code("Q-1-1") // quarto-error-code-audit-ignore
-    ///     .build();
-    ///
-    /// assert_eq!(error.code, Some("Q-1-1".to_string())); // quarto-error-code-audit-ignore
-    /// ```
-    pub fn with_code(mut self, code: impl Into) -> Self {
-        self.code = Some(code.into());
-        self
-    }
-
-    /// Attach a source location to this diagnostic.
-    ///
-    /// The location identifies where in the source code the issue occurred.
-    /// The location may track transformation history, allowing the error to be
-    /// mapped back through multiple processing steps to the original source file.
-    ///
-    /// # Example
-    ///
-    /// ```ignore
-    /// use quarto_error_reporting::DiagnosticMessageBuilder;
-    /// use quarto_source_map::{SourceInfo, SourceContext, FileId, Range, Location};
-    ///
-    /// let mut ctx = SourceContext::new();
-    /// let file_id = ctx.add_file("test.qmd".into(), Some("content".into()));
-    /// let range = Range {
-    ///     start: Location { offset: 0, row: 0, column: 0 },
-    ///     end: Location { offset: 7, row: 0, column: 7 },
-    /// };
-    /// let source_info = SourceInfo::original(file_id, range);
-    ///
-    /// let error = DiagnosticMessageBuilder::error("Parse error")
-    ///     .with_location(source_info)
-    ///     .build();
-    /// ```
-    pub fn with_location(mut self, location: quarto_source_map::SourceInfo) -> Self {
-        self.location = Some(location);
-        self
-    }
-
-    /// Set the problem statement.
-    ///
-    /// Following tidyverse guidelines, the problem statement should:
-    /// - Start with a general, concise statement
-    /// - Use "must" for requirements or "can't" for impossibilities
-    /// - Be specific about types/expectations
-    ///
-    /// # Example
-    ///
-    /// ```
-    /// use quarto_error_reporting::DiagnosticMessageBuilder;
-    ///
-    /// let error = DiagnosticMessageBuilder::error("Invalid input")
-    ///     .problem("`n` must be a numeric vector, not a character vector")
-    ///     .build();
-    /// ```
-    pub fn problem(mut self, stmt: impl Into) -> Self {
-        self.problem = Some(stmt.into());
-        self
-    }
-
-    /// Add an error detail (displayed with error/cross bullet).
-    ///
-    /// Error details provide specific information about what went wrong.
-    /// Following tidyverse guidelines:
-    /// - Keep sentences short and specific
-    /// - Reveal location, name, or content of problematic input
-    /// - Limit to 5 total details (error + info) to avoid overwhelming users
-    ///
-    /// # Example
-    ///
-    /// ```
-    /// use quarto_error_reporting::DiagnosticMessageBuilder;
-    ///
-    /// let error = DiagnosticMessageBuilder::error("Incompatible lengths")
-    ///     .add_detail("`x` has length 3")
-    ///     .add_detail("`y` has length 5")
-    ///     .build();
-    ///
-    /// assert_eq!(error.details.len(), 2);
-    /// ```
-    pub fn add_detail(mut self, detail: impl Into) -> Self {
-        self.details.push(DetailItem {
-            kind: DetailKind::Error,
-            content: detail.into(),
-            location: None,
-        });
-        self
-    }
-
-    /// Add an error detail with a source location.
-    ///
-    /// This allows adding contextual information that points to specific locations
-    /// in the source code, creating rich multi-location error messages.
-    ///
-    /// # Example
-    ///
-    /// ```ignore
-    /// use quarto_error_reporting::DiagnosticMessageBuilder;
-    ///
-    /// let error = DiagnosticMessageBuilder::error("Mismatched brackets")
-    ///     .add_detail_at("Opening bracket here", opening_location)
-    ///     .add_detail_at("But no closing bracket found", end_location)
-    ///     .build();
-    /// ```
-    pub fn add_detail_at(
-        mut self,
-        detail: impl Into,
-        location: quarto_source_map::SourceInfo,
-    ) -> Self {
-        self.details.push(DetailItem {
-            kind: DetailKind::Error,
-            content: detail.into(),
-            location: Some(location),
-        });
-        self
-    }
-
-    /// Add an info detail (displayed with info bullet).
-    ///
-    /// Info details provide additional context or explanatory information.
-    ///
-    /// # Example
-    ///
-    /// ```
-    /// use quarto_error_reporting::DiagnosticMessageBuilder;
-    ///
-    /// let error = DiagnosticMessageBuilder::error("Missing file")
-    ///     .add_detail("Could not find `config.yaml`")
-    ///     .add_info("Default configuration will be used")
-    ///     .build();
-    /// ```
-    pub fn add_info(mut self, info: impl Into) -> Self {
-        self.details.push(DetailItem {
-            kind: DetailKind::Info,
-            content: info.into(),
-            location: None,
-        });
-        self
-    }
-
-    /// Add an info detail with a source location.
-    pub fn add_info_at(
-        mut self,
-        info: impl Into,
-        location: quarto_source_map::SourceInfo,
-    ) -> Self {
-        self.details.push(DetailItem {
-            kind: DetailKind::Info,
-            content: info.into(),
-            location: Some(location),
-        });
-        self
-    }
-
-    /// Add a note detail (displayed with plain bullet).
-    ///
-    /// # Example
-    ///
-    /// ```
-    /// use quarto_error_reporting::DiagnosticMessageBuilder;
-    ///
-    /// let error = DiagnosticMessageBuilder::error("Parse error")
-    ///     .add_note("This is an experimental feature")
-    ///     .build();
-    /// ```
-    pub fn add_note(mut self, note: impl Into) -> Self {
-        self.details.push(DetailItem {
-            kind: DetailKind::Note,
-            content: note.into(),
-            location: None,
-        });
-        self
-    }
-
-    /// Add a note detail with a source location.
-    pub fn add_note_at(
-        mut self,
-        note: impl Into,
-        location: quarto_source_map::SourceInfo,
-    ) -> Self {
-        self.details.push(DetailItem {
-            kind: DetailKind::Note,
-            content: note.into(),
-            location: Some(location),
-        });
-        self
-    }
-
-    /// Add a faded detail with a source location.
-    ///
-    /// Rendered with the same dim grey colour Ariadne uses for unlabelled
-    /// source characters, so it visually "punches a hole" in any wider
-    /// label that also covers the same column range. Useful for excluding
-    /// block-quote prefixes or other prefix decorations from the highlight
-    /// of a multi-line span.
-    pub fn add_faded_at(
-        mut self,
-        content: impl Into,
-        location: quarto_source_map::SourceInfo,
-    ) -> Self {
-        self.details.push(DetailItem {
-            kind: DetailKind::Faded,
-            content: content.into(),
-            location: Some(location),
-        });
-        self
-    }
-
-    /// Add a hint for fixing the error.
-    ///
-    /// Following tidyverse guidelines, hints should:
-    /// - Only be included when the problem source is clear and common
-    /// - Provide straightforward fix suggestions
-    /// - End with a question mark if suggesting action
-    ///
-    /// # Example
-    ///
-    /// ```
-    /// use quarto_error_reporting::DiagnosticMessageBuilder;
-    ///
-    /// let error = DiagnosticMessageBuilder::error("Function not found")
-    ///     .problem("Could not find function `summarise()`")
-    ///     .add_hint("Did you mean `summarize()`?")
-    ///     .build();
-    ///
-    /// assert_eq!(error.hints.len(), 1);
-    /// ```
-    pub fn add_hint(mut self, hint: impl Into) -> Self {
-        self.hints.push(hint.into());
-        self
-    }
-
-    /// Build the diagnostic message.
-    ///
-    /// This consumes the builder and returns the constructed `DiagnosticMessage`.
-    ///
-    /// # Example
-    ///
-    /// ```
-    /// use quarto_error_reporting::DiagnosticMessageBuilder;
-    ///
-    /// let error = DiagnosticMessageBuilder::error("Parse error")
-    ///     .problem("Invalid syntax")
-    ///     .build();
-    ///
-    /// assert_eq!(error.title, "Parse error");
-    /// ```
-    pub fn build(self) -> DiagnosticMessage {
-        DiagnosticMessage {
-            code: self.code,
-            title: self.title,
-            kind: self.kind,
-            problem: self.problem,
-            details: self.details,
-            hints: self.hints,
-            location: self.location,
-        }
-    }
-
-    /// Build with validation.
-    ///
-    /// This validates the message structure according to tidyverse guidelines:
-    /// - Warns if there's no problem statement (recommended but not required)
-    /// - Warns if there are more than 5 details (overwhelming for users)
-    /// - Future: Could check that hints end with '?'
-    ///
-    /// Returns warnings as a Vec of strings. An empty Vec means validation passed.
-    ///
-    /// # Example
-    ///
-    /// ```
-    /// use quarto_error_reporting::DiagnosticMessageBuilder;
-    ///
-    /// let (error, warnings) = DiagnosticMessageBuilder::error("Test error")
-    ///     .build_with_validation();
-    ///
-    /// // Warns because there's no problem statement
-    /// assert!(!warnings.is_empty());
-    /// ```
-    pub fn build_with_validation(self) -> (DiagnosticMessage, Vec) {
-        let mut warnings = Vec::new();
-
-        // Check for problem statement
-        if self.problem.is_none() {
-            warnings.push(
-                "Error message missing problem statement. \
-                Consider adding .problem() to explain what went wrong."
-                    .to_string(),
-            );
-        }
-
-        // Check detail count (tidyverse recommends max 5)
-        if self.details.len() > 5 {
-            warnings.push(format!(
-                "Error message has {} details. Tidyverse guidelines recommend max 5 to avoid \
-                overwhelming users.",
-                self.details.len()
-            ));
-        }
-
-        (self.build(), warnings)
-    }
-}
-
-#[cfg(test)]
-mod tests {
-    use super::*;
-
-    #[test]
-    fn test_builder_error() {
-        let msg = DiagnosticMessageBuilder::error("Test error").build();
-        assert_eq!(msg.title, "Test error");
-        assert_eq!(msg.kind, DiagnosticKind::Error);
-    }
-
-    #[test]
-    fn test_builder_warning() {
-        let msg = DiagnosticMessageBuilder::warning("Test warning").build();
-        assert_eq!(msg.kind, DiagnosticKind::Warning);
-    }
-
-    #[test]
-    fn test_builder_info() {
-        let msg = DiagnosticMessageBuilder::info("Test info").build();
-        assert_eq!(msg.kind, DiagnosticKind::Info);
-    }
-
-    #[test]
-    fn test_builder_with_code() {
-        let msg = DiagnosticMessageBuilder::error("Test")
-            .with_code("Q-1-1")
-            .build();
-        assert_eq!(msg.code, Some("Q-1-1".to_string()));
-    }
-
-    #[test]
-    fn test_builder_problem() {
-        let msg = DiagnosticMessageBuilder::error("Test")
-            .problem("Something went wrong")
-            .build();
-        assert!(msg.problem.is_some());
-        assert_eq!(msg.problem.unwrap().as_str(), "Something went wrong");
-    }
-
-    #[test]
-    fn test_builder_details() {
-        let msg = DiagnosticMessageBuilder::error("Test")
-            .add_detail("Detail 1")
-            .add_info("Info 1")
-            .add_note("Note 1")
-            .build();
-
-        assert_eq!(msg.details.len(), 3);
-        assert_eq!(msg.details[0].kind, DetailKind::Error);
-        assert_eq!(msg.details[1].kind, DetailKind::Info);
-        assert_eq!(msg.details[2].kind, DetailKind::Note);
-    }
-
-    #[test]
-    fn test_builder_hints() {
-        let msg = DiagnosticMessageBuilder::error("Test")
-            .add_hint("Did you mean X?")
-            .add_hint("Try Y instead")
-            .build();
-
-        assert_eq!(msg.hints.len(), 2);
-    }
-
-    #[test]
-    fn test_builder_complete_message() {
-        let msg = DiagnosticMessageBuilder::error("Incompatible types")
-            .with_code("Q-1-2") // quarto-error-code-audit-ignore
-            .problem("Cannot combine date and datetime types")
-            .add_detail("`x` has type `date`")
-            .add_detail("`y` has type `datetime`")
-            .add_hint("Convert both to the same type?")
-            .build();
-
-        assert_eq!(msg.title, "Incompatible types");
-        assert_eq!(msg.code, Some("Q-1-2".to_string())); // quarto-error-code-audit-ignore
-        assert!(msg.problem.is_some());
-        assert_eq!(msg.details.len(), 2);
-        assert_eq!(msg.hints.len(), 1);
-    }
-
-    #[test]
-    fn test_builder_validation_no_problem() {
-        let (msg, warnings) = DiagnosticMessageBuilder::error("Test").build_with_validation();
-
-        assert_eq!(msg.title, "Test");
-        assert!(!warnings.is_empty());
-        assert!(warnings[0].contains("missing problem statement"));
-    }
-
-    #[test]
-    fn test_builder_validation_too_many_details() {
-        let (_msg, warnings) = DiagnosticMessageBuilder::error("Test")
-            .problem("Something wrong")
-            .add_detail("1")
-            .add_detail("2")
-            .add_detail("3")
-            .add_detail("4")
-            .add_detail("5")
-            .add_detail("6")
-            .build_with_validation();
-
-        assert!(!warnings.is_empty());
-        assert!(warnings[0].contains("6 details"));
-        assert!(warnings[0].contains("max 5"));
-    }
-
-    #[test]
-    fn test_builder_validation_passes() {
-        let (_msg, warnings) = DiagnosticMessageBuilder::error("Test")
-            .problem("Something wrong")
-            .add_detail("Detail")
-            .build_with_validation();
-
-        assert!(warnings.is_empty());
-    }
-}
diff --git a/crates/quarto-error-reporting/src/catalog.rs b/crates/quarto-error-reporting/src/catalog.rs
deleted file mode 100644
index 6256839cc..000000000
--- a/crates/quarto-error-reporting/src/catalog.rs
+++ /dev/null
@@ -1,197 +0,0 @@
-//! Pluggable error-code catalog.
-//!
-//! `quarto-error-reporting` is **catalog-agnostic**: it defines the catalog
-//! *shape* ([`ErrorCodeInfo`]) and a [`CatalogProvider`] seam, but ships no
-//! catalog *data*. An embedding product installs its own catalog once, early,
-//! via [`install_catalog`]; in Quarto this is done by the `quarto-error-catalog`
-//! crate (`quarto_error_catalog::install()`), which carries the `Q-*`
-//! `error_catalog.json`. With nothing installed, every lookup returns `None`
-//! (see [`EmptyCatalog`]).
-//!
-//! This is the host side of the cross-package error-code discipline; see
-//! `claude-notes/designs/cross-package-error-codes.md`.
-
-use serde::{Deserialize, Serialize};
-use std::sync::OnceLock;
-
-/// Metadata for an error code.
-///
-/// Each catalog entry describes a specific error code, including its
-/// subsystem, title, default message, and documentation URL.
-#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
-pub struct ErrorCodeInfo {
-    /// Subsystem name (e.g., "yaml", "markdown", "engine")
-    pub subsystem: String,
-
-    /// Short title for the error
-    pub title: String,
-
-    /// Default message template (may include placeholders)
-    pub message_template: String,
-
-    /// URL to documentation (optional)
-    #[serde(skip_serializing_if = "Option::is_none")]
-    pub docs_url: Option,
-
-    /// When this error was introduced (version)
-    pub since_version: String,
-}
-
-/// A source of error-code metadata, supplied by the embedding product.
-///
-/// Implementors return metadata for a given code, or `None` if the code is not
-/// in their catalog. The returned reference is tied to `&self`, which lets the
-/// installed-global path (see [`install_catalog`]) hand out `&'static`
-/// references — the installed provider lives for the rest of the process.
-///
-/// `Send + Sync` is required so the provider can live in a process-wide
-/// [`OnceLock`]; it costs nothing for the data-only providers in practice.
-pub trait CatalogProvider: Send + Sync {
-    /// Look up an error code's metadata, or `None` if it is not in this catalog.
-    fn lookup(&self, code: &str) -> Option<&ErrorCodeInfo>;
-}
-
-/// The default provider used when none has been installed: every lookup is
-/// `None`. This is what makes the crate usable standalone with zero config — a
-/// non-Quarto consumer that installs nothing simply gets code-less, URL-less
-/// diagnostics (tier-2 "passthrough" in the discipline's terms).
-pub struct EmptyCatalog;
-
-impl CatalogProvider for EmptyCatalog {
-    fn lookup(&self, _code: &str) -> Option<&ErrorCodeInfo> {
-        None
-    }
-}
-
-/// The process-wide installed catalog. Written at most once, by the embedder.
-static CATALOG: OnceLock> = OnceLock::new();
-
-/// Install the process-wide catalog provider.
-///
-/// The **first** call wins; later calls are no-ops (so a double install — e.g.
-/// a binary's `main` plus a test helper — is harmless). Embedders should call
-/// this once, as early as possible, at binary / WASM startup, *before* any
-/// diagnostic's docs URL is resolved.
-pub fn install_catalog(provider: Box) {
-    let _ = CATALOG.set(provider);
-}
-
-/// The installed provider, or a shared [`EmptyCatalog`] if none was installed.
-fn catalog() -> &'static dyn CatalogProvider {
-    static EMPTY: EmptyCatalog = EmptyCatalog;
-    match CATALOG.get() {
-        Some(provider) => &**provider,
-        None => &EMPTY,
-    }
-}
-
-/// Look up full metadata for an error code via the installed catalog.
-///
-/// Returns `None` if no catalog is installed, or the code is not in it.
-///
-/// # Example
-///
-/// ```
-/// use quarto_error_reporting::catalog::get_error_info;
-///
-/// // With a `CatalogProvider` installed (e.g. via `quarto-error-catalog`),
-/// // this resolves to the code's metadata; with none installed it is `None`.
-/// let _ = get_error_info("Q-0-1");
-/// ```
-pub fn get_error_info(code: &str) -> Option<&'static ErrorCodeInfo> {
-    catalog().lookup(code)
-}
-
-/// Get the documentation URL for an error code, if the installed catalog has one.
-///
-/// Returns `None` if no catalog is installed, the code is unknown, or the entry
-/// has no documentation URL.
-///
-/// # Example
-///
-/// ```
-/// use quarto_error_reporting::catalog::get_docs_url;
-///
-/// // `Some(url)` iff a catalog mapping this code (with a docs URL) is installed.
-/// let _ = get_docs_url("Q-0-1");
-/// ```
-pub fn get_docs_url(code: &str) -> Option<&'static str> {
-    catalog()
-        .lookup(code)
-        .and_then(|info| info.docs_url.as_deref())
-}
-
-/// Get the subsystem name for an error code, if the installed catalog knows it.
-///
-/// Returns `None` if no catalog is installed or the code is unknown.
-///
-/// # Example
-///
-/// ```
-/// use quarto_error_reporting::catalog::get_subsystem;
-///
-/// // With a catalog installed this returns e.g. `Some("internal")` for "Q-0-1".
-/// let _ = get_subsystem("Q-0-1");
-/// ```
-pub fn get_subsystem(code: &str) -> Option<&'static str> {
-    catalog().lookup(code).map(|info| info.subsystem.as_str())
-}
-
-#[cfg(test)]
-mod tests {
-    use super::*;
-
-    fn sample_info(subsystem: &str, docs_url: Option<&str>) -> ErrorCodeInfo {
-        ErrorCodeInfo {
-            subsystem: subsystem.to_string(),
-            title: "Sample".to_string(),
-            message_template: "sample".to_string(),
-            docs_url: docs_url.map(str::to_string),
-            since_version: "0.0.0".to_string(),
-        }
-    }
-
-    /// The default provider returns `None` for everything. Tested *directly*
-    /// (no global state) so it is robust regardless of test runner — this is
-    /// the canonical "no catalog installed → None" behaviour.
-    #[test]
-    fn empty_catalog_returns_none() {
-        let empty = EmptyCatalog;
-        assert!(empty.lookup("Q-0-1").is_none());
-        assert!(empty.lookup("anything").is_none());
-    }
-
-    /// A trivial mock provider implements the trait and is found by lookup.
-    struct MockCatalog {
-        entry: ErrorCodeInfo,
-    }
-    impl CatalogProvider for MockCatalog {
-        fn lookup(&self, code: &str) -> Option<&ErrorCodeInfo> {
-            (code == "Q-0-1").then_some(&self.entry)
-        }
-    }
-
-    /// The **single** test in this crate that mutates the process-global
-    /// catalog: install a mock and assert the free functions delegate to it for
-    /// a known code, and return `None` for an unknown one. Keeping this the only
-    /// global-mutating test means there is no intra-process install conflict,
-    /// even under `cargo test` (threads) rather than nextest (process-per-test).
-    #[test]
-    fn installed_catalog_is_used_by_lookups() {
-        install_catalog(Box::new(MockCatalog {
-            entry: sample_info("internal", Some("https://example.test/docs/Q-0-1")),
-        }));
-
-        assert_eq!(get_subsystem("Q-0-1"), Some("internal"));
-        assert_eq!(
-            get_docs_url("Q-0-1"),
-            Some("https://example.test/docs/Q-0-1")
-        );
-        assert!(get_error_info("Q-0-1").is_some());
-
-        // Unknown code, even with a catalog installed, is `None`.
-        assert!(get_subsystem("Q-9-9").is_none());
-        assert!(get_docs_url("Q-9-9").is_none());
-        assert!(get_error_info("Q-9-9").is_none());
-    }
-}
diff --git a/crates/quarto-error-reporting/src/coalesce.rs b/crates/quarto-error-reporting/src/coalesce.rs
deleted file mode 100644
index c0bb2506f..000000000
--- a/crates/quarto-error-reporting/src/coalesce.rs
+++ /dev/null
@@ -1,461 +0,0 @@
-//! Cross-source diagnostic coalescing.
-//!
-//! When a single underlying problem produces a diagnostic on many
-//! pages — for example, one bad `theme:` key in `_quarto.yml`
-//! triggering [`Q-14-1`](../../error_catalog.json) once per rendered
-//! page — the renderer should collapse them into a single emission
-//! that lists the affected pages, rather than printing the same
-//! ariadne block hundreds of times.
-//!
-//! # The primary key is the source location
-//!
-//! Two diagnostics whose `location` resolves to the same source
-//! span in the same file are presumed to be the same error and are
-//! grouped together. We deliberately do **not** include the code or
-//! title in the grouping key — the source location alone is the
-//! relation's primary key (decision recorded in
-//! `claude-notes/plans/2026-05-22-theme-diagnostic-epic.md`).
-//!
-//! If two unrelated checks ever land at the same span this is a
-//! design risk; the v1 cost (one merged emission with a possibly
-//! mixed-content representative) is low. We will widen the key to
-//! `(location, code)` if it turns out to bite.
-//!
-//! # What does not coalesce
-//!
-//! Diagnostics whose `location` is one of:
-//!
-//! - `None`,
-//! - [`SourceInfo::Concat`], or
-//! - [`SourceInfo::FilterProvenance`],
-//!
-//! pass through as singleton groups (one entry each). These shapes
-//! don't reduce to a single contiguous byte range, so we can't form
-//! a stable group key for them. This is the same conservative
-//! contract as [`SourceInfo::resolve_byte_range`].
-//!
-//! [`SourceInfo::Concat`]: quarto_source_map::SourceInfo::Concat
-//! [`SourceInfo::FilterProvenance`]: quarto_source_map::SourceInfo::FilterProvenance
-//! [`SourceInfo::resolve_byte_range`]: quarto_source_map::SourceInfo::resolve_byte_range
-
-use std::collections::HashMap;
-use std::path::PathBuf;
-
-use quarto_source_map::{SourceContext, SourceInfo};
-
-use crate::diagnostic::{DiagnosticMessage, TextRenderOptions};
-
-/// One entry from a coalesced render summary.
-///
-/// `affected_files` is in encounter order — the order in which the
-/// caller's iterator produced each (path, diagnostic) pair that
-/// contributed to this group. Singleton groups (size 1) carry one
-/// path; rendered output for them omits the "Affected files:" tail
-/// to match the legacy per-page render.
-#[derive(Debug, Clone)]
-pub struct CoalescedDiagnostic {
-    pub representative: DiagnosticMessage,
-    pub source_context: Option,
-    pub affected_files: Vec,
-}
-
-/// Maximum number of file names rendered inline in the "Affected
-/// files:" tail before switching to "… (and N others)".
-///
-/// Tunable; v1 sets it small so the typical "hundreds of pages"
-/// case stays one line.
-pub const AFFECTED_FILES_CAP: usize = 3;
-
-impl CoalescedDiagnostic {
-    /// Render the underlying ariadne diagnostic, followed by an
-    /// `Affected files:` tail listing up to [`AFFECTED_FILES_CAP`]
-    /// of the affected paths and a `(and N others)` count for the
-    /// rest. Single-element groups omit the tail.
-    pub fn to_text(&self) -> String {
-        self.to_text_with_options(&TextRenderOptions::default())
-    }
-
-    /// Like [`Self::to_text`] but with explicit render options
-    /// (mostly useful in tests, where hyperlinks are disabled for
-    /// path-independent assertions).
-    pub fn to_text_with_options(&self, opts: &TextRenderOptions) -> String {
-        let body = self
-            .representative
-            .to_text_with_options(self.source_context.as_ref(), opts);
-        if self.affected_files.len() <= 1 {
-            return body;
-        }
-        let tail = render_affected_files_tail(&self.affected_files);
-        format!("{}\n{}", body, tail)
-    }
-}
-
-fn render_affected_files_tail(paths: &[PathBuf]) -> String {
-    let shown = paths
-        .iter()
-        .take(AFFECTED_FILES_CAP)
-        .map(|p| p.display().to_string())
-        .collect::>()
-        .join(", ");
-    let remaining = paths.len().saturating_sub(AFFECTED_FILES_CAP);
-    if remaining == 0 {
-        format!("Affected files: {}", shown)
-    } else {
-        format!(
-            "Affected files: {} (and {} other{})",
-            shown,
-            remaining,
-            if remaining == 1 { "" } else { "s" },
-        )
-    }
-}
-
-/// Canonical, hashable form of a [`SourceInfo`] for grouping.
-///
-/// Resolves to the root `Original`'s `(file_id, start_offset,
-/// end_offset)` tuple. Returns `None` for shapes that don't reduce
-/// cleanly (mirrors [`SourceInfo::resolve_byte_range`]).
-#[derive(Debug, Clone, PartialEq, Eq, Hash)]
-struct LocationKey {
-    file_id: usize,
-    start: usize,
-    end: usize,
-}
-
-impl LocationKey {
-    fn from(info: &SourceInfo) -> Option {
-        let (file_id, start, end) = info.resolve_byte_range()?;
-        Some(LocationKey {
-            file_id,
-            start,
-            end,
-        })
-    }
-}
-
-/// Group the input by source location and return one
-/// [`CoalescedDiagnostic`] per group, in encounter order.
-///
-/// Inputs without a coalescable location (no `location`, or `Concat`
-/// / `FilterProvenance`) pass through as singleton groups in their
-/// original order — they always print exactly once.
-///
-/// The first `(path, diagnostic, source_context)` triple to introduce
-/// a given key becomes the group's representative. Later triples
-/// only contribute to `affected_files`. This matches the principle
-/// that the user sees the first diagnostic they would have seen
-/// before, with extra context appended.
-pub fn coalesce_by_source(input: I) -> Vec
-where
-    I: IntoIterator)>,
-{
-    let mut groups: Vec = Vec::new();
-    let mut index: HashMap = HashMap::new();
-
-    for (path, diagnostic, source_context) in input {
-        let key = diagnostic.location.as_ref().and_then(LocationKey::from);
-        match key {
-            Some(k) => match index.get(&k).copied() {
-                Some(idx) => {
-                    groups[idx].affected_files.push(path);
-                }
-                None => {
-                    let idx = groups.len();
-                    index.insert(k, idx);
-                    groups.push(CoalescedDiagnostic {
-                        representative: diagnostic,
-                        source_context,
-                        affected_files: vec![path],
-                    });
-                }
-            },
-            None => {
-                // Non-coalescable: emit as a singleton group at the
-                // tail. Do not register in the index, so subsequent
-                // identical-but-uncoalescable entries also emit as
-                // singletons.
-                groups.push(CoalescedDiagnostic {
-                    representative: diagnostic,
-                    source_context,
-                    affected_files: vec![path],
-                });
-            }
-        }
-    }
-
-    groups
-}
-
-#[cfg(test)]
-mod tests {
-    use super::*;
-    use crate::builder::DiagnosticMessageBuilder;
-    use quarto_source_map::{FileId, SourcePiece};
-    use std::sync::Arc;
-
-    fn original(file_id: usize, start: usize, end: usize) -> SourceInfo {
-        SourceInfo::Original {
-            file_id: FileId(file_id),
-            start_offset: start,
-            end_offset: end,
-        }
-    }
-
-    fn diag_at(loc: SourceInfo, title: &str) -> DiagnosticMessage {
-        DiagnosticMessageBuilder::error(title)
-            .with_code("Q-14-1")
-            .with_location(loc)
-            .problem("…")
-            .build()
-    }
-
-    #[test]
-    fn two_diagnostics_at_the_same_location_collapse() {
-        let loc = original(1, 100, 110);
-        let input = vec![
-            (PathBuf::from("a.qmd"), diag_at(loc.clone(), "T"), None),
-            (PathBuf::from("b.qmd"), diag_at(loc.clone(), "T"), None),
-        ];
-        let groups = coalesce_by_source(input);
-        assert_eq!(groups.len(), 1);
-        assert_eq!(
-            groups[0].affected_files,
-            vec![PathBuf::from("a.qmd"), PathBuf::from("b.qmd"),]
-        );
-    }
-
-    #[test]
-    fn different_locations_do_not_collapse() {
-        let input = vec![
-            (
-                PathBuf::from("a.qmd"),
-                diag_at(original(1, 100, 110), "T"),
-                None,
-            ),
-            (
-                PathBuf::from("b.qmd"),
-                diag_at(original(1, 200, 210), "T"),
-                None,
-            ),
-        ];
-        let groups = coalesce_by_source(input);
-        assert_eq!(groups.len(), 2);
-    }
-
-    #[test]
-    fn different_file_ids_do_not_collapse() {
-        let input = vec![
-            (
-                PathBuf::from("a.qmd"),
-                diag_at(original(1, 100, 110), "T"),
-                None,
-            ),
-            (
-                PathBuf::from("b.qmd"),
-                diag_at(original(2, 100, 110), "T"),
-                None,
-            ),
-        ];
-        let groups = coalesce_by_source(input);
-        assert_eq!(groups.len(), 2);
-    }
-
-    #[test]
-    fn substring_resolves_to_root_original_and_groups_with_it() {
-        // A Substring whose root Original matches another Original
-        // must coalesce into the same group — the canonical key is
-        // the resolved root.
-        let root = original(1, 100, 200);
-        let sub = SourceInfo::Substring {
-            parent: Arc::new(root.clone()),
-            // Offsets relative to parent's text; resolve_byte_range
-            // composes them: (fid, parent_start + sub_start,
-            // parent_start + sub_end) = (1, 100, 110).
-            start_offset: 0,
-            end_offset: 10,
-        };
-        let input = vec![
-            (PathBuf::from("a.qmd"), diag_at(root.clone(), "T"), None),
-            (PathBuf::from("b.qmd"), diag_at(sub, "T"), None),
-        ];
-        let groups = coalesce_by_source(input);
-        // root resolves to (1, 100, 200); sub resolves to (1, 100,
-        // 110). Different end offsets ⇒ different keys ⇒ separate
-        // groups. This documents the v1 contract: Substring uses
-        // the *composed* offsets, not the parent's offsets.
-        assert_eq!(groups.len(), 2);
-    }
-
-    #[test]
-    fn concat_location_passes_through_as_singleton() {
-        let concat = SourceInfo::Concat {
-            pieces: vec![SourcePiece {
-                source_info: original(1, 0, 10),
-                offset_in_concat: 0,
-                length: 10,
-            }],
-        };
-        let input = vec![
-            (PathBuf::from("a.qmd"), diag_at(concat.clone(), "T"), None),
-            (PathBuf::from("b.qmd"), diag_at(concat, "T"), None),
-        ];
-        let groups = coalesce_by_source(input);
-        // Both emitted as singletons because Concat has no
-        // coalescable key. Order preserved.
-        assert_eq!(groups.len(), 2);
-        assert_eq!(groups[0].affected_files, vec![PathBuf::from("a.qmd")]);
-        assert_eq!(groups[1].affected_files, vec![PathBuf::from("b.qmd")]);
-    }
-
-    #[test]
-    fn diagnostics_without_location_pass_through_as_singletons() {
-        let d = DiagnosticMessageBuilder::error("no location")
-            .problem("…")
-            .build();
-        let input = vec![
-            (PathBuf::from("a.qmd"), d.clone(), None),
-            (PathBuf::from("b.qmd"), d, None),
-        ];
-        let groups = coalesce_by_source(input);
-        assert_eq!(groups.len(), 2);
-    }
-
-    #[test]
-    fn encounter_order_preserved_across_groups() {
-        let loc1 = original(1, 100, 110);
-        let loc2 = original(1, 200, 210);
-        let input = vec![
-            (PathBuf::from("a.qmd"), diag_at(loc1.clone(), "T1"), None),
-            (PathBuf::from("b.qmd"), diag_at(loc2.clone(), "T2"), None),
-            (PathBuf::from("c.qmd"), diag_at(loc1.clone(), "T1"), None),
-        ];
-        let groups = coalesce_by_source(input);
-        assert_eq!(groups.len(), 2);
-        // Group order = order of first occurrence.
-        assert_eq!(groups[0].representative.title, "T1");
-        assert_eq!(
-            groups[0].affected_files,
-            vec![PathBuf::from("a.qmd"), PathBuf::from("c.qmd"),]
-        );
-        assert_eq!(groups[1].representative.title, "T2");
-        assert_eq!(groups[1].affected_files, vec![PathBuf::from("b.qmd")]);
-    }
-
-    #[test]
-    fn first_encounter_supplies_representative_and_context() {
-        // The representative is the *first* (path, diagnostic) seen
-        // for a given key. Later contributions only add to
-        // `affected_files`. The same goes for the SourceContext.
-        let loc = original(1, 100, 110);
-        let mut ctx_first = SourceContext::new();
-        ctx_first.add_file_with_id(FileId(1), "first.yml".into(), Some("first".into()));
-        let mut ctx_second = SourceContext::new();
-        ctx_second.add_file_with_id(FileId(1), "second.yml".into(), Some("second".into()));
-
-        let input = vec![
-            (
-                PathBuf::from("a.qmd"),
-                diag_at(loc.clone(), "first"),
-                Some(ctx_first),
-            ),
-            (
-                PathBuf::from("b.qmd"),
-                diag_at(loc.clone(), "second"),
-                Some(ctx_second),
-            ),
-        ];
-        let groups = coalesce_by_source(input);
-        assert_eq!(groups.len(), 1);
-        assert_eq!(groups[0].representative.title, "first");
-        assert!(groups[0].source_context.is_some());
-    }
-
-    #[test]
-    fn singleton_group_omits_affected_files_tail() {
-        let loc = original(1, 100, 110);
-        let input = vec![(PathBuf::from("a.qmd"), diag_at(loc, "T"), None)];
-        let groups = coalesce_by_source(input);
-        let opts = TextRenderOptions {
-            enable_hyperlinks: false,
-        };
-        let text = groups[0].to_text_with_options(&opts);
-        assert!(
-            !text.contains("Affected files:"),
-            "singleton groups must not emit the affected-files tail:\n{}",
-            text
-        );
-    }
-
-    #[test]
-    fn multi_group_below_cap_lists_all_files() {
-        let loc = original(1, 100, 110);
-        let input = vec![
-            (PathBuf::from("a.qmd"), diag_at(loc.clone(), "T"), None),
-            (PathBuf::from("b.qmd"), diag_at(loc.clone(), "T"), None),
-        ];
-        let groups = coalesce_by_source(input);
-        let opts = TextRenderOptions {
-            enable_hyperlinks: false,
-        };
-        let text = groups[0].to_text_with_options(&opts);
-        assert!(text.contains("Affected files: a.qmd, b.qmd"), "{}", text);
-        assert!(
-            !text.contains("other"),
-            "no '(and N others)' tail expected for ≤ cap:\n{}",
-            text
-        );
-    }
-
-    #[test]
-    fn multi_group_above_cap_truncates_and_counts() {
-        // AFFECTED_FILES_CAP=3, so 5 files should produce
-        // "a.qmd, b.qmd, c.qmd (and 2 others)".
-        let loc = original(1, 100, 110);
-        let input: Vec<_> = ["a", "b", "c", "d", "e"]
-            .iter()
-            .map(|n| {
-                (
-                    PathBuf::from(format!("{n}.qmd")),
-                    diag_at(loc.clone(), "T"),
-                    None,
-                )
-            })
-            .collect();
-        let groups = coalesce_by_source(input);
-        let opts = TextRenderOptions {
-            enable_hyperlinks: false,
-        };
-        let text = groups[0].to_text_with_options(&opts);
-        assert!(
-            text.contains("Affected files: a.qmd, b.qmd, c.qmd (and 2 others)"),
-            "{}",
-            text,
-        );
-    }
-
-    #[test]
-    fn multi_group_just_above_cap_uses_singular_other() {
-        // 4 files at cap=3 ⇒ 1 other (singular).
-        let loc = original(1, 100, 110);
-        let input: Vec<_> = ["a", "b", "c", "d"]
-            .iter()
-            .map(|n| {
-                (
-                    PathBuf::from(format!("{n}.qmd")),
-                    diag_at(loc.clone(), "T"),
-                    None,
-                )
-            })
-            .collect();
-        let groups = coalesce_by_source(input);
-        let opts = TextRenderOptions {
-            enable_hyperlinks: false,
-        };
-        let text = groups[0].to_text_with_options(&opts);
-        assert!(
-            text.contains("(and 1 other)"),
-            "expected singular 'other' for exactly 1 over cap:\n{}",
-            text,
-        );
-    }
-}
diff --git a/crates/quarto-error-reporting/src/diagnostic.rs b/crates/quarto-error-reporting/src/diagnostic.rs
deleted file mode 100644
index a402084d3..000000000
--- a/crates/quarto-error-reporting/src/diagnostic.rs
+++ /dev/null
@@ -1,1191 +0,0 @@
-//! Core diagnostic message types.
-//!
-//! This module defines the fundamental structures for representing diagnostic messages
-//! (errors, warnings, info) following tidyverse-style guidelines.
-
-use serde::{Deserialize, Serialize};
-
-/// The kind of diagnostic message.
-#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
-pub enum DiagnosticKind {
-    /// An error that prevents completion
-    Error,
-    /// A warning that doesn't prevent completion but indicates a problem
-    Warning,
-    /// Informational message
-    Info,
-    /// A note providing additional context
-    Note,
-}
-
-/// How detail items should be presented (tidyverse x/i bullet style).
-#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
-pub enum DetailKind {
-    /// Error detail (✖ bullet in tidyverse style)
-    Error,
-    /// Info detail (i bullet in tidyverse style)
-    Info,
-    /// Note detail (plain bullet)
-    Note,
-    /// Faded detail — rendered in Ariadne with the same dim grey colour
-    /// Ariadne uses for source characters outside any label. Use it to
-    /// attach a high-priority label to a column range you want to
-    /// *exclude* from a wider label's highlighting (e.g. a block-quote
-    /// prefix inside a multi-line span). Treated the same as `Note` in
-    /// tidyverse-style text output.
-    Faded,
-}
-
-/// Options for rendering diagnostic messages to text.
-///
-/// This struct controls various aspects of text rendering, such as whether
-/// to include terminal hyperlinks for clickable file paths.
-#[derive(Debug, Clone)]
-pub struct TextRenderOptions {
-    /// Enable OSC 8 hyperlinks for clickable file paths in terminals.
-    ///
-    /// When enabled, file paths in error messages will include terminal
-    /// escape codes for clickable links (supported by iTerm2, VS Code, etc.).
-    /// Disable for snapshot testing to avoid absolute path differences.
-    pub enable_hyperlinks: bool,
-}
-
-impl Default for TextRenderOptions {
-    fn default() -> Self {
-        Self {
-            enable_hyperlinks: true,
-        }
-    }
-}
-
-/// The content of a message or detail item.
-///
-/// This will eventually support Pandoc AST for rich formatting, but starts
-/// with simpler string-based content.
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-pub enum MessageContent {
-    /// Plain text content
-    Plain(String),
-    /// Markdown content (will be parsed to Pandoc AST in later phases)
-    Markdown(String),
-    // Future: PandocAst(Box)
-}
-
-impl MessageContent {
-    /// Get the raw string content for display
-    pub fn as_str(&self) -> &str {
-        match self {
-            MessageContent::Plain(s) => s,
-            MessageContent::Markdown(s) => s,
-        }
-    }
-
-    /// Convert to JSON value with type information
-    pub fn to_json(&self) -> serde_json::Value {
-        use serde_json::json;
-        match self {
-            MessageContent::Plain(s) => json!({
-                "type": "plain",
-                "content": s
-            }),
-            MessageContent::Markdown(s) => json!({
-                "type": "markdown",
-                "content": s
-            }),
-        }
-    }
-}
-
-impl From for MessageContent {
-    fn from(s: String) -> Self {
-        MessageContent::Markdown(s)
-    }
-}
-
-impl From<&str> for MessageContent {
-    fn from(s: &str) -> Self {
-        MessageContent::Markdown(s.to_string())
-    }
-}
-
-/// A detail item in a diagnostic message.
-///
-/// Following tidyverse guidelines, details provide specific information about
-/// the error (what went wrong, where, with what values).
-#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
-pub struct DetailItem {
-    /// The kind of detail (error, info, note)
-    pub kind: DetailKind,
-    /// The content of the detail
-    pub content: MessageContent,
-    /// Optional source location for this detail
-    ///
-    /// When present, this identifies where in the source code this detail applies.
-    /// This allows error messages to highlight multiple related locations.
-    #[serde(skip_serializing_if = "Option::is_none")]
-    pub location: Option,
-}
-
-/// A diagnostic message following tidyverse-style structure.
-///
-/// Structure:
-/// 1. **Code**: Optional error code (e.g., "Q-1-1") for searchability
-/// 2. **Title**: Brief error message
-/// 3. **Kind**: Error, Warning, Info
-/// 4. **Problem**: What went wrong (the "must" or "can't" statement)
-/// 5. **Details**: Specific information (bulleted, max 5 per tidyverse)
-/// 6. **Hints**: Optional guidance for fixing (ends with ?)
-///
-/// # Example
-///
-/// ```ignore
-/// let msg = DiagnosticMessage {
-///     code: Some("Q-1-2".to_string()), // quarto-error-code-audit-ignore
-///     title: "Incompatible types".to_string(),
-///     kind: DiagnosticKind::Error,
-///     problem: Some("Cannot combine date and datetime types".into()),
-///     details: vec![
-///         DetailItem {
-///             kind: DetailKind::Error,
-///             content: "`x`{.arg} has type `date`{.type}".into(),
-///         },
-///         DetailItem {
-///             kind: DetailKind::Error,
-///             content: "`y`{.arg} has type `datetime`{.type}".into(),
-///         },
-///     ],
-///     hints: vec!["Convert both to the same type?".into()],
-///     source_spans: vec![],
-/// };
-/// ```
-#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
-pub struct DiagnosticMessage {
-    /// Optional error code (e.g., "Q-1-1")
-    ///
-    /// Error codes are optional but encouraged. They provide:
-    /// - Searchability (users can Google "Q-1-1")
-    /// - Stability (codes don't change even if message wording improves)
-    /// - Documentation (each code maps to a detailed explanation)
-    #[serde(skip_serializing_if = "Option::is_none")]
-    pub code: Option,
-
-    /// Brief title for the error
-    pub title: String,
-
-    /// The kind of diagnostic (Error, Warning, Info)
-    pub kind: DiagnosticKind,
-
-    /// The problem statement (the "what" - using "must" or "can't")
-    pub problem: Option,
-
-    /// Specific error details (the "where/why" - max 5 per tidyverse)
-    pub details: Vec,
-
-    /// Optional hints for fixing (ends with ?)
-    pub hints: Vec,
-
-    /// Source location for this diagnostic
-    ///
-    /// When present, this identifies where in the source code the issue occurred.
-    /// The location may track transformation history, allowing the error to be
-    /// mapped back through multiple processing steps to the original source file.
-    #[serde(skip_serializing_if = "Option::is_none")]
-    pub location: Option,
-}
-
-impl DiagnosticMessage {
-    /// Access the diagnostic message builder API.
-    ///
-    /// This is the recommended way to create diagnostic messages, as the builder API
-    /// encodes tidyverse-style guidelines and makes it easy to construct well-structured
-    /// error messages.
-    ///
-    /// # Example
-    ///
-    /// ```
-    /// use quarto_error_reporting::{DiagnosticMessage, DiagnosticMessageBuilder};
-    ///
-    /// let error = DiagnosticMessageBuilder::error("Incompatible types")
-    ///     .with_code("Q-1-2") // quarto-error-code-audit-ignore
-    ///     .problem("Cannot combine date and datetime types")
-    ///     .add_detail("`x` has type `date`")
-    ///     .add_detail("`y` has type `datetime`")
-    ///     .add_hint("Convert both to the same type?")
-    ///     .build();
-    /// ```
-    pub fn builder() -> crate::builder::DiagnosticMessageBuilder {
-        // This is just a convenience for accessing the builder type
-        // Users should call DiagnosticMessageBuilder::error() etc directly
-        crate::builder::DiagnosticMessageBuilder::error("")
-    }
-
-    /// Create a new diagnostic message with just a title and kind.
-    ///
-    /// Note: Consider using `DiagnosticMessage::builder()` instead for better structure.
-    pub fn new(kind: DiagnosticKind, title: impl Into) -> Self {
-        Self {
-            code: None,
-            title: title.into(),
-            kind,
-            problem: None,
-            details: Vec::new(),
-            hints: Vec::new(),
-            location: None,
-        }
-    }
-
-    /// Create an error diagnostic.
-    ///
-    /// Note: Consider using `DiagnosticMessage::builder().error()` instead for better structure.
-    pub fn error(title: impl Into) -> Self {
-        Self::new(DiagnosticKind::Error, title)
-    }
-
-    /// Create a warning diagnostic.
-    ///
-    /// Note: Consider using `DiagnosticMessage::builder().warning()` instead for better structure.
-    pub fn warning(title: impl Into) -> Self {
-        Self::new(DiagnosticKind::Warning, title)
-    }
-
-    /// Create an info diagnostic.
-    ///
-    /// Note: Consider using `DiagnosticMessage::builder().info()` instead for better structure.
-    pub fn info(title: impl Into) -> Self {
-        Self::new(DiagnosticKind::Info, title)
-    }
-
-    /// Set the error code.
-    ///
-    /// Error codes follow the format `Q--` (e.g., "Q-1-1").
-    ///
-    /// # Example
-    ///
-    /// ```
-    /// use quarto_error_reporting::DiagnosticMessage;
-    ///
-    /// let msg = DiagnosticMessage::error("YAML Syntax Error")
-    ///     .with_code("Q-1-1");
-    /// ```
-    pub fn with_code(mut self, code: impl Into) -> Self {
-        self.code = Some(code.into());
-        self
-    }
-
-    /// Get the documentation URL for this error, if it has an error code.
-    ///
-    /// # Example
-    ///
-    /// Resolves the code against the installed [`CatalogProvider`]
-    /// (`crate::catalog`); returns `None` when no catalog is installed, the
-    /// code is unknown, or the entry has no docs URL.
-    ///
-    /// ```
-    /// use quarto_error_reporting::DiagnosticMessage;
-    ///
-    /// let msg = DiagnosticMessage::error("Internal Error")
-    ///     .with_code("Q-0-1");
-    ///
-    /// // `Some(url)` iff a catalog mapping "Q-0-1" (with a docs URL) is installed.
-    /// let _ = msg.docs_url();
-    /// ```
-    pub fn docs_url(&self) -> Option<&str> {
-        self.code
-            .as_ref()
-            .and_then(|code| crate::catalog::get_docs_url(code))
-    }
-
-    /// Render this diagnostic message as text following tidyverse style.
-    ///
-    /// This is a convenience method that uses default rendering options.
-    /// For more control over rendering, use [`Self::to_text_with_options`].
-    ///
-    /// # Example
-    ///
-    /// ```
-    /// use quarto_error_reporting::DiagnosticMessageBuilder;
-    ///
-    /// let msg = DiagnosticMessageBuilder::error("Invalid input")
-    ///     .problem("Values must be numeric")
-    ///     .add_detail("Found text in column 3")
-    ///     .add_hint("Convert to numbers first?")
-    ///     .build();
-    /// let text = msg.to_text(None);
-    /// assert!(text.contains("Error: Invalid input"));
-    /// assert!(text.contains("Values must be numeric"));
-    /// ```
-    pub fn to_text(&self, ctx: Option<&quarto_source_map::SourceContext>) -> String {
-        self.to_text_with_options(ctx, &TextRenderOptions::default())
-    }
-
-    /// Render this diagnostic message as text following tidyverse style with custom options.
-    ///
-    /// Format:
-    /// ```text
-    /// Error: title
-    /// Problem statement here
-    /// ✖ Error detail 1
-    /// ✖ Error detail 2
-    /// ℹ Info detail
-    /// • Note detail
-    /// ? Hint 1
-    /// ? Hint 2
-    /// ```
-    ///
-    /// # Example
-    ///
-    /// ```
-    /// use quarto_error_reporting::{DiagnosticMessageBuilder, TextRenderOptions};
-    ///
-    /// let msg = DiagnosticMessageBuilder::error("Invalid input")
-    ///     .problem("Values must be numeric")
-    ///     .add_detail("Found text in column 3")
-    ///     .add_hint("Convert to numbers first?")
-    ///     .build();
-    ///
-    /// // Disable hyperlinks for snapshot testing
-    /// let options = TextRenderOptions { enable_hyperlinks: false };
-    /// let text = msg.to_text_with_options(None, &options);
-    /// assert!(text.contains("Error: Invalid input"));
-    /// ```
-    pub fn to_text_with_options(
-        &self,
-        ctx: Option<&quarto_source_map::SourceContext>,
-        options: &TextRenderOptions,
-    ) -> String {
-        use std::fmt::Write;
-
-        let mut result = String::new();
-
-        // Check if we have any location info that could be displayed with ariadne
-        // This includes the main diagnostic location OR any detail with a location
-        let has_any_location =
-            self.location.is_some() || self.details.iter().any(|d| d.location.is_some());
-
-        // If we have location info and source context, render ariadne source display
-        let has_ariadne = if let (true, Some(ctx_val)) = (has_any_location, ctx) {
-            // Use main location if available, otherwise use first detail location
-            let location = self
-                .location
-                .as_ref()
-                .or_else(|| self.details.iter().find_map(|d| d.location.as_ref()));
-
-            if let Some(loc) = location {
-                if let Some(ariadne_output) =
-                    self.render_ariadne_source_context(loc, ctx_val, options.enable_hyperlinks)
-                {
-                    result.push_str(&ariadne_output);
-                    true
-                } else {
-                    false
-                }
-            } else {
-                false
-            }
-        } else {
-            false
-        };
-
-        // If we don't have ariadne output, show full tidyverse-style content
-        // If we do have ariadne, only show details without locations and hints
-        // (ariadne already shows: title, code, problem, and details with locations)
-        if !has_ariadne {
-            // No ariadne - show everything in tidyverse style
-
-            // Title with kind prefix and error code (e.g., "Error [Q-1-1]: Invalid input")
-            let kind_str = match self.kind {
-                DiagnosticKind::Error => "Error",
-                DiagnosticKind::Warning => "Warning",
-                DiagnosticKind::Info => "Info",
-                DiagnosticKind::Note => "Note",
-            };
-            if let Some(code) = &self.code {
-                writeln!(result, "{} [{}]: {}", kind_str, code, self.title).unwrap();
-            } else {
-                writeln!(result, "{}: {}", kind_str, self.title).unwrap();
-            }
-
-            // Show location info if available (but no ariadne rendering)
-            if let Some(loc) = &self.location {
-                // Try to map with context if available
-                if let Some(ctx) = ctx {
-                    if let Some(mapped) = loc.map_offset(loc.start_offset(), ctx)
-                        && let Some(file) = ctx.get_file(mapped.file_id)
-                    {
-                        writeln!(
-                            result,
-                            "  at {}:{}:{}",
-                            file.path,
-                            mapped.location.row + 1,
-                            mapped.location.column + 1
-                        )
-                        .unwrap();
-                    }
-                } else {
-                    // No context: show immediate location (1-indexed for display)
-                    // Note: Without context, we can't get row/column from offsets
-                    // We could map_offset with ctx to get Location, but ctx is None here
-                    writeln!(result, "  at offset {}", loc.start_offset()).unwrap();
-                }
-            }
-
-            // Problem statement (optional additional context)
-            if let Some(problem) = &self.problem {
-                writeln!(result, "{}", problem.as_str()).unwrap();
-            }
-
-            // All details with appropriate bullets
-            for detail in &self.details {
-                let bullet = match detail.kind {
-                    DetailKind::Error => "✖",
-                    DetailKind::Info => "ℹ",
-                    DetailKind::Note | DetailKind::Faded => "•",
-                };
-                writeln!(result, "{} {}", bullet, detail.content.as_str()).unwrap();
-            }
-
-            // All hints
-            for hint in &self.hints {
-                writeln!(result, "ℹ {}", hint.as_str()).unwrap();
-            }
-        } else {
-            // Have ariadne - only show details without locations and hints
-            // (ariadne shows title, code, problem, and located details)
-
-            // Details without locations (ariadne can't show these)
-            for detail in &self.details {
-                if detail.location.is_none() {
-                    let bullet = match detail.kind {
-                        DetailKind::Error => "✖",
-                        DetailKind::Info => "ℹ",
-                        DetailKind::Note | DetailKind::Faded => "•",
-                    };
-                    writeln!(result, "{} {}", bullet, detail.content.as_str()).unwrap();
-                }
-            }
-
-            // All hints (ariadne doesn't show hints)
-            for hint in &self.hints {
-                writeln!(result, "ℹ {}", hint.as_str()).unwrap();
-            }
-        }
-
-        result
-    }
-
-    /// Render this diagnostic message as a JSON value.
-    ///
-    /// Returns a structured JSON object with all fields:
-    /// ```json
-    /// {
-    ///   "kind": "error",
-    ///   "title": "Invalid input",
-    ///   "code": "Q-1-2", // quarto-error-code-audit-ignore
-    ///   "problem": "Values must be numeric",
-    ///   "details": [{"kind": "error", "content": "Found text in column 3"}],
-    ///   "hints": ["Convert to numbers first?"]
-    /// }
-    /// ```
-    ///
-    /// # Example
-    ///
-    /// ```
-    /// use quarto_error_reporting::DiagnosticMessage;
-    ///
-    /// let msg = DiagnosticMessage::error("Something went wrong");
-    /// let json = msg.to_json();
-    /// assert_eq!(json["kind"], "error");
-    /// assert_eq!(json["title"], "Something went wrong");
-    /// ```
-    pub fn to_json(&self) -> serde_json::Value {
-        use serde_json::json;
-
-        let kind_str = match self.kind {
-            DiagnosticKind::Error => "error",
-            DiagnosticKind::Warning => "warning",
-            DiagnosticKind::Info => "info",
-            DiagnosticKind::Note => "note",
-        };
-
-        let mut obj = json!({
-            "kind": kind_str,
-            "title": self.title,
-        });
-
-        // Add optional fields
-        if let Some(code) = &self.code {
-            obj["code"] = json!(code);
-        }
-
-        if let Some(problem) = &self.problem {
-            obj["problem"] = problem.to_json();
-        }
-
-        if !self.details.is_empty() {
-            let details: Vec<_> = self
-                .details
-                .iter()
-                .map(|d| {
-                    let detail_kind = match d.kind {
-                        DetailKind::Error => "error",
-                        DetailKind::Info => "info",
-                        DetailKind::Note => "note",
-                        DetailKind::Faded => "faded",
-                    };
-                    let mut detail_obj = json!({
-                        "kind": detail_kind,
-                        "content": d.content.to_json()
-                    });
-                    if let Some(location) = &d.location {
-                        detail_obj["location"] = json!(location);
-                    }
-                    detail_obj
-                })
-                .collect();
-            obj["details"] = json!(details);
-        }
-
-        if !self.hints.is_empty() {
-            let hints: Vec<_> = self.hints.iter().map(|h| h.to_json()).collect();
-            obj["hints"] = json!(hints);
-        }
-
-        if let Some(location) = &self.location {
-            obj["location"] = json!(location); // quarto-source-map::SourceInfo is Serialize
-        }
-
-        obj
-    }
-
-    /// Wrap a file path with OSC 8 ANSI hyperlink codes for clickable terminal links.
-    ///
-    /// OSC 8 is a terminal escape sequence that creates clickable hyperlinks:
-    /// `\x1b]8;;URI\x1b\\TEXT\x1b\\`
-    ///
-    /// Only adds hyperlinks if:
-    /// - Hyperlinks are enabled via the `enable_hyperlinks` parameter
-    /// - The file exists on disk (not an ephemeral in-memory file)
-    /// - The path can be converted to an absolute path
-    ///
-    /// The `url` crate handles:
-    /// - Platform differences (Windows drive letters vs Unix paths)
-    /// - Percent-encoding of special characters
-    /// - Proper file:// URL construction
-    ///
-    /// Line and column numbers are added to the URL as a fragment identifier
-    /// (e.g., `file:///path#line:column`), which is supported by iTerm2 3.4+
-    /// and other terminal emulators for opening files at specific positions.
-    ///
-    /// Returns the wrapped path if conditions are met, otherwise returns the original path.
-    #[cfg(not(target_family = "wasm"))]
-    fn wrap_path_with_hyperlink(
-        path: &str,
-        has_disk_file: bool,
-        line: Option,
-        column: Option,
-        enable_hyperlinks: bool,
-    ) -> String {
-        // Don't add hyperlinks if disabled (e.g., for snapshot testing)
-        if !enable_hyperlinks {
-            return path.to_string();
-        }
-
-        // Only add hyperlinks for real files on disk (not ephemeral in-memory files)
-        if !has_disk_file {
-            return path.to_string();
-        }
-
-        // Canonicalize to absolute path
-        let abs_path = match std::fs::canonicalize(path) {
-            Ok(p) => p,
-            Err(_) => return path.to_string(), // Can't canonicalize, skip hyperlink
-        };
-
-        // Convert to file:// URL (handles Windows/Unix + percent-encoding)
-        let mut file_url = match url::Url::from_file_path(&abs_path) {
-            Ok(url) => url.as_str().to_string(),
-            Err(_) => return path.to_string(), // Conversion failed, skip hyperlink
-        };
-
-        // Add line and column as fragment identifier (e.g., #line:column)
-        // This format is supported by iTerm2 3.4+ semantic history
-        if let Some(line_num) = line {
-            if let Some(col_num) = column {
-                file_url.push_str(&format!("#{}:{}", line_num, col_num));
-            } else {
-                file_url.push_str(&format!("#{}", line_num));
-            }
-        }
-
-        // Wrap with OSC 8 codes: \x1b]8;;URI\x1b\\TEXT\x1b]8;;\x1b\\
-        format!("\x1b]8;;{}\x1b\\{}\x1b]8;;\x1b\\", file_url, path)
-    }
-
-    /// WASM version: hyperlinks don't make sense in WASM environments (no file system).
-    /// Just return the path unmodified.
-    #[cfg(target_family = "wasm")]
-    fn wrap_path_with_hyperlink(
-        path: &str,
-        _has_disk_file: bool,
-        _line: Option,
-        _column: Option,
-        _enable_hyperlinks: bool,
-    ) -> String {
-        path.to_string()
-    }
-
-    /// Render source context using ariadne (private helper for to_text).
-    ///
-    /// This produces the visual source code snippet with highlighting.
-    /// The tidyverse-style problem/details/hints are added separately by to_text().
-    fn render_ariadne_source_context(
-        &self,
-        main_location: &quarto_source_map::SourceInfo,
-        ctx: &quarto_source_map::SourceContext,
-        enable_hyperlinks: bool,
-    ) -> Option {
-        use ariadne::{Color, Config, IndexType, Label, Report, ReportKind, Source};
-
-        // Mirror of ariadne's private `Config::unimportant_color()` from
-        // ariadne 0.6.0 (`src/lib.rs:543`). We use this for `DetailKind::Faded`
-        // labels so they blend visually with characters that fall outside any
-        // label. Bump this constant if the ariadne dependency upgrades and
-        // changes the colour.
-        const ARIADNE_UNIMPORTANT_COLOR: Color = Color::Fixed(249);
-
-        // Extract file_id from the source mapping by traversing the chain
-        let file_id = main_location.root_file_id()?;
-
-        let file = ctx.get_file(file_id)?;
-
-        // Get file content: use stored content for ephemeral files, or read from disk.
-        // In WASM (and any host with no real filesystem) the disk read fails with
-        // "operation not supported on this platform"; the only graceful response is
-        // to drop the source-context snippet. The diagnostic's code, message, and
-        // hints still surface — only the Ariadne visual is unavailable.
-        let content = match &file.content {
-            Some(c) => c.clone(),
-            None => match std::fs::read_to_string(&file.path) {
-                Ok(s) => s,
-                Err(_) => return None,
-            },
-        };
-
-        // Map the location offsets back to original file positions
-        // map_offset expects relative offsets (0 = start of this SourceInfo's range)
-        let start_mapped = main_location.map_offset(0, ctx)?;
-        // For end offset, try the full length first. If that fails (e.g., when the span
-        // extends past EOF), clamp to the last valid position. This handles edge cases
-        // like errors pointing to EOF or diagnostics with off-by-one end offsets.
-        let end_mapped = main_location
-            .map_offset(main_location.length(), ctx)
-            .or_else(|| {
-                // Clamp: if length() fails, try length()-1, which should be the last valid byte
-                if main_location.length() > 0 {
-                    main_location.map_offset(main_location.length() - 1, ctx)
-                } else {
-                    None
-                }
-            })
-            .unwrap_or_else(|| start_mapped.clone());
-
-        // Create display path with OSC 8 hyperlink for clickable file paths
-        // Check if this path refers to a real file on disk (vs an ephemeral in-memory file)
-        let is_disk_file = std::path::Path::new(&file.path).exists();
-        // Line and column numbers are 1-indexed for display (start_mapped.location uses 0-indexed)
-        let line = Some(start_mapped.location.row + 1);
-        let column = Some(start_mapped.location.column + 1);
-        let display_path = Self::wrap_path_with_hyperlink(
-            &file.path,
-            is_disk_file,
-            line,
-            column,
-            enable_hyperlinks,
-        );
-
-        // Determine report kind and color
-        let (report_kind, main_color) = match self.kind {
-            DiagnosticKind::Error => (ReportKind::Error, Color::Red),
-            DiagnosticKind::Warning => (ReportKind::Warning, Color::Yellow),
-            DiagnosticKind::Info => (ReportKind::Advice, Color::Cyan),
-            DiagnosticKind::Note => (ReportKind::Advice, Color::Blue),
-        };
-
-        // Build the report using the mapped offset for proper line:column display
-        // IMPORTANT: Use IndexType::Byte because our offsets are byte offsets, not character offsets
-        let mut report = Report::build(
-            report_kind,
-            (
-                display_path.clone(),
-                start_mapped.location.offset..start_mapped.location.offset,
-            ),
-        )
-        .with_config(Config::default().with_index_type(IndexType::Byte));
-
-        // Add title with error code
-        if let Some(code) = &self.code {
-            report = report.with_message(format!("[{}] {}", code, self.title));
-        } else {
-            report = report.with_message(&self.title);
-        }
-
-        // Add main location label using mapped offsets
-        let main_span = start_mapped.location.offset..end_mapped.location.offset;
-        let main_message = if let Some(problem) = &self.problem {
-            problem.as_str()
-        } else {
-            &self.title
-        };
-
-        // Set `with_order` on every label using its end offset. Ariadne
-        // groups labels by source and starts a new group whenever a label's
-        // end line is *before* the previous label's end line. Without an
-        // explicit order, multi-line main labels and per-line "padding"
-        // detail labels (used to defeat Ariadne's middle-line elision) end
-        // up in separate groups, producing a duplicated snippet block.
-        // Sorting by end offset puts the smaller-line labels first so the
-        // grouping algorithm extends rather than splits.
-        report = report.with_label(
-            Label::new((display_path.clone(), main_span.clone()))
-                .with_message(main_message)
-                .with_color(main_color)
-                .with_order(main_span.end as i32),
-        );
-
-        // Add detail locations as additional labels (only those with locations)
-        for detail in &self.details {
-            if let Some(detail_loc) = &detail.location {
-                // Extract file_id from detail location
-                let detail_file_id = match detail_loc.root_file_id() {
-                    Some(fid) => fid,
-                    None => continue, // Skip if we can't extract file_id
-                };
-
-                if detail_file_id == file_id {
-                    // Map detail offsets to original file positions
-                    // map_offset expects relative offsets (0 = start of SourceInfo's range)
-                    if let (Some(detail_start), Some(detail_end)) = (
-                        detail_loc.map_offset(0, ctx),
-                        detail_loc.map_offset(detail_loc.length(), ctx),
-                    ) {
-                        let detail_span = detail_start.location.offset..detail_end.location.offset;
-                        let detail_color = match detail.kind {
-                            DetailKind::Error => Color::Red,
-                            DetailKind::Info => Color::Cyan,
-                            DetailKind::Note => Color::Blue,
-                            // Match Ariadne's unimportant colour so faded
-                            // labels visually disappear into the surrounding
-                            // unlabelled text.
-                            DetailKind::Faded => ARIADNE_UNIMPORTANT_COLOR,
-                        };
-
-                        // Empty-content details exist purely to force Ariadne
-                        // to display a line that would otherwise be elided
-                        // inside a multi-line span. Leaving the label's
-                        // message at None makes Ariadne skip drawing the
-                        // `╰── ...` arrow row underneath, so the source line
-                        // appears clean.
-                        let mut label = Label::new((display_path.clone(), detail_span.clone()))
-                            .with_color(detail_color)
-                            .with_order(detail_span.end as i32);
-                        if !detail.content.as_str().is_empty() {
-                            label = label.with_message(detail.content.as_str());
-                        }
-                        report = report.with_label(label);
-                    }
-                }
-            }
-        }
-
-        // Render to string
-        let report = report.finish();
-        let mut output = Vec::new();
-        report
-            .write(
-                (display_path.clone(), Source::from(content.as_str())),
-                &mut output,
-            )
-            .ok()?;
-
-        let output_str = String::from_utf8(output).ok()?;
-
-        // Post-process to extend hyperlinks to include line:column numbers
-        // Ariadne adds :line:column after our hyperlinked path, so we need to
-        // move the hyperlink end marker to include those numbers
-        if is_disk_file && enable_hyperlinks {
-            Some(Self::extend_hyperlink_to_include_line_column(
-                &output_str,
-                &file.path,
-            ))
-        } else {
-            Some(output_str)
-        }
-    }
-
-    /// Extend OSC 8 hyperlinks to include the :line:column suffix that ariadne adds.
-    ///
-    /// Ariadne formats file references as `path:line:column`, but since we wrap the path
-    /// with OSC 8 codes, the structure becomes: `[hyperlink:path]:line:column`
-    /// We want: `[hyperlink:path:line:column]`
-    ///
-    /// This function finds patterns like `path]8;;\:line:column` and moves the hyperlink
-    /// end marker to after the line:column part.
-    fn extend_hyperlink_to_include_line_column(output: &str, original_path: &str) -> String {
-        // Pattern: original_path followed by ]8;;\ then :numbers:numbers
-        // We want to move the ]8;;\ to after the :numbers:numbers part
-        let end_marker = "\x1b]8;;\x1b\\";
-        let search_pattern = format!("{}{}", original_path, end_marker);
-
-        let mut result = output.to_string();
-        while let Some(pos) = result.find(&search_pattern) {
-            let after_marker = pos + search_pattern.len();
-            // Check if what follows is :line:column pattern
-            if let Some(rest) = result.get(after_marker..) {
-                // Match :digits:digits pattern
-                if let Some(colon_end) = Self::find_line_column_end(rest) {
-                    // Move the end marker to after the :line:column
-                    let before = &result[..pos + original_path.len()];
-                    let line_col = &rest[..colon_end];
-                    let after = &rest[colon_end..];
-                    result = format!("{}{}{}{}", before, line_col, end_marker, after);
-                    continue;
-                }
-            }
-            break;
-        }
-        result
-    }
-
-    /// Find the end position of a :line:column pattern at the start of the string.
-    /// Returns None if the pattern doesn't match.
-    fn find_line_column_end(s: &str) -> Option {
-        let bytes = s.as_bytes();
-        if bytes.is_empty() || bytes[0] != b':' {
-            return None;
-        }
-
-        let mut pos = 1;
-        // Read digits for line number
-        while pos < bytes.len() && bytes[pos].is_ascii_digit() {
-            pos += 1;
-        }
-        if pos == 1 || pos >= bytes.len() || bytes[pos] != b':' {
-            return None; // No digits or no second colon
-        }
-
-        pos += 1; // Skip second colon
-        let col_start = pos;
-        // Read digits for column number
-        while pos < bytes.len() && bytes[pos].is_ascii_digit() {
-            pos += 1;
-        }
-        if pos == col_start {
-            return None; // No digits for column
-        }
-
-        Some(pos)
-    }
-}
-
-#[cfg(test)]
-mod tests {
-    use super::*;
-
-    #[test]
-    fn test_diagnostic_kind() {
-        assert_eq!(DiagnosticKind::Error, DiagnosticKind::Error);
-        assert_ne!(DiagnosticKind::Error, DiagnosticKind::Warning);
-    }
-
-    #[test]
-    fn test_message_content_from_str() {
-        let content: MessageContent = "test".into();
-        assert_eq!(content.as_str(), "test");
-    }
-
-    #[test]
-    fn test_diagnostic_message_new() {
-        let msg = DiagnosticMessage::new(DiagnosticKind::Error, "Test error");
-        assert_eq!(msg.title, "Test error");
-        assert_eq!(msg.kind, DiagnosticKind::Error);
-        assert!(msg.code.is_none());
-        assert!(msg.problem.is_none());
-        assert!(msg.details.is_empty());
-        assert!(msg.hints.is_empty());
-    }
-
-    #[test]
-    fn test_diagnostic_message_constructors() {
-        let error = DiagnosticMessage::error("Error");
-        assert_eq!(error.kind, DiagnosticKind::Error);
-        assert!(error.code.is_none());
-
-        let warning = DiagnosticMessage::warning("Warning");
-        assert_eq!(warning.kind, DiagnosticKind::Warning);
-
-        let info = DiagnosticMessage::info("Info");
-        assert_eq!(info.kind, DiagnosticKind::Info);
-    }
-
-    #[test]
-    fn test_with_code() {
-        let msg = DiagnosticMessage::error("Test error").with_code("Q-1-1");
-        assert_eq!(msg.code, Some("Q-1-1".to_string()));
-    }
-
-    // The positive case — `docs_url()` for a real code resolves to the
-    // quarto.org URL — moved to `quarto-error-catalog`'s integration tests,
-    // where the `Q-*` catalog is installed. Here we only cover the
-    // catalog-free cases (no code / unknown code → `None`), which hold
-    // regardless of whether a catalog is installed.
-
-    #[test]
-    fn test_docs_url_without_code() {
-        let msg = DiagnosticMessage::error("Test error");
-        assert!(msg.docs_url().is_none());
-    }
-
-    #[test]
-    fn test_docs_url_invalid_code() {
-        let msg = DiagnosticMessage::error("Test error").with_code("Q-999-999"); // quarto-error-code-audit-ignore
-        assert!(msg.docs_url().is_none());
-    }
-
-    #[test]
-    fn test_to_text_simple_error() {
-        let msg = DiagnosticMessage::error("Something went wrong");
-        assert_eq!(msg.to_text(None), "Error: Something went wrong\n");
-    }
-
-    #[test]
-    fn test_to_text_with_code() {
-        let msg = DiagnosticMessage::error("Something went wrong").with_code("Q-1-1");
-        assert_eq!(msg.to_text(None), "Error [Q-1-1]: Something went wrong\n");
-    }
-
-    #[test]
-    fn test_to_text_full_message() {
-        use crate::builder::DiagnosticMessageBuilder;
-
-        let msg = DiagnosticMessageBuilder::error("Invalid input")
-            .problem("Values must be numeric")
-            .add_detail("Found text in column 3")
-            .add_info("Columns should contain only numbers")
-            .add_hint("Convert to numbers first?")
-            .build();
-
-        let text = msg.to_text(None);
-        assert!(text.contains("Error: Invalid input"));
-        assert!(text.contains("Values must be numeric"));
-        assert!(text.contains("✖ Found text in column 3"));
-        assert!(text.contains("ℹ Columns should contain only numbers"));
-        assert!(text.contains("ℹ Convert to numbers first?"));
-    }
-
-    #[test]
-    fn test_to_json_simple() {
-        let msg = DiagnosticMessage::error("Something went wrong");
-        let json = msg.to_json();
-
-        assert_eq!(json["kind"], "error");
-        assert_eq!(json["title"], "Something went wrong");
-        assert!(json.get("code").is_none());
-        assert!(json.get("problem").is_none());
-    }
-
-    #[test]
-    fn test_to_json_with_code() {
-        let msg = DiagnosticMessage::error("Something went wrong").with_code("Q-1-1");
-        let json = msg.to_json();
-
-        assert_eq!(json["kind"], "error");
-        assert_eq!(json["title"], "Something went wrong");
-        assert_eq!(json["code"], "Q-1-1");
-    }
-
-    #[test]
-    fn test_to_json_full_message() {
-        use crate::builder::DiagnosticMessageBuilder;
-
-        let msg = DiagnosticMessageBuilder::error("Invalid input")
-            .with_code("Q-1-2") // quarto-error-code-audit-ignore
-            .problem("Values must be numeric")
-            .add_detail("Found text in column 3")
-            .add_info("Expected numbers")
-            .add_hint("Convert to numbers first?")
-            .build();
-
-        let json = msg.to_json();
-        assert_eq!(json["kind"], "error");
-        assert_eq!(json["title"], "Invalid input");
-        assert_eq!(json["code"], "Q-1-2"); // quarto-error-code-audit-ignore
-        assert_eq!(json["problem"]["type"], "markdown");
-        assert_eq!(json["problem"]["content"], "Values must be numeric");
-        assert_eq!(json["details"][0]["kind"], "error");
-        assert_eq!(json["details"][0]["content"]["type"], "markdown");
-        assert_eq!(
-            json["details"][0]["content"]["content"],
-            "Found text in column 3"
-        );
-        assert_eq!(json["details"][1]["kind"], "info");
-        assert_eq!(json["details"][1]["content"]["type"], "markdown");
-        assert_eq!(json["details"][1]["content"]["content"], "Expected numbers");
-        assert_eq!(json["hints"][0]["type"], "markdown");
-        assert_eq!(json["hints"][0]["content"], "Convert to numbers first?");
-    }
-
-    #[test]
-    fn test_to_json_warning() {
-        let msg = DiagnosticMessage::warning("Be careful");
-        let json = msg.to_json();
-
-        assert_eq!(json["kind"], "warning");
-        assert_eq!(json["title"], "Be careful");
-    }
-
-    #[test]
-    fn test_location_in_to_text_without_context() {
-        use crate::builder::DiagnosticMessageBuilder;
-
-        // Create a location at offsets 100-110
-        let location =
-            quarto_source_map::SourceInfo::original(quarto_source_map::FileId(0), 100, 110);
-
-        let msg = DiagnosticMessageBuilder::error("Invalid syntax")
-            .with_location(location)
-            .build();
-
-        let text = msg.to_text(None);
-
-        // Without context, should show offset (we can't get row/column without context)
-        assert!(text.contains("Invalid syntax"));
-        assert!(text.contains("at offset 100"));
-    }
-
-    #[test]
-    fn test_location_in_to_text_with_context() {
-        use crate::builder::DiagnosticMessageBuilder;
-
-        // Create a source context with a file
-        let mut ctx = quarto_source_map::SourceContext::new();
-        let file_id = ctx.add_file(
-            "test.qmd".to_string(),
-            Some("line 1\nline 2\nline 3\nline 4".to_string()),
-        );
-
-        // Create a location in that file (offset 7 is start of "line 2")
-        let location = quarto_source_map::SourceInfo::original(
-            file_id, 7,  // Start of "line 2"
-            13, // End of "line 2"
-        );
-
-        let msg = DiagnosticMessageBuilder::error("Invalid syntax")
-            .with_location(location)
-            .build();
-
-        let text = msg.to_text(Some(&ctx));
-
-        // With context, should show file path and 1-indexed location
-        assert!(text.contains("Invalid syntax"));
-        assert!(text.contains("test.qmd"));
-        assert!(text.contains("2:1")); // row 1 + 1, column 0 + 1
-    }
-
-    #[test]
-    fn test_location_in_to_json() {
-        use crate::builder::DiagnosticMessageBuilder;
-
-        let location =
-            quarto_source_map::SourceInfo::original(quarto_source_map::FileId(0), 100, 110);
-
-        let msg = DiagnosticMessageBuilder::error("Invalid syntax")
-            .with_location(location)
-            .build();
-
-        let json = msg.to_json();
-
-        // Should have location field with Original variant
-        assert!(json.get("location").is_some());
-        let loc = &json["location"];
-
-        // Verify the SourceInfo is serialized correctly (as Original enum variant)
-        assert!(loc.get("Original").is_some());
-        let original = &loc["Original"];
-        assert_eq!(original["file_id"], 0);
-        assert_eq!(original["start_offset"], 100);
-        assert_eq!(original["end_offset"], 110);
-    }
-
-    #[test]
-    fn test_location_optional_in_to_json() {
-        let msg = DiagnosticMessage::error("No location");
-        let json = msg.to_json();
-
-        // Should not have location field when not provided
-        assert!(json.get("location").is_none());
-    }
-
-    #[test]
-    fn test_text_render_options_disable_hyperlinks() {
-        use crate::builder::DiagnosticMessageBuilder;
-
-        let mut ctx = quarto_source_map::SourceContext::new();
-        let file_id = ctx.add_file("test.qmd".to_string(), Some("test content".to_string()));
-
-        let location = quarto_source_map::SourceInfo::original(file_id, 0, 4);
-
-        let msg = DiagnosticMessageBuilder::error("Test error")
-            .with_location(location)
-            .build();
-
-        // With hyperlinks enabled (default)
-        let with_hyperlinks = msg.to_text(Some(&ctx));
-
-        // With hyperlinks disabled
-        let options = TextRenderOptions {
-            enable_hyperlinks: false,
-        };
-        let without_hyperlinks = msg.to_text_with_options(Some(&ctx), &options);
-
-        // When hyperlinks are disabled, output should be different
-        // (specifically, no OSC 8 escape sequences)
-        if with_hyperlinks.contains("\x1b]8;") {
-            assert!(
-                !without_hyperlinks.contains("\x1b]8;"),
-                "Disabled hyperlinks should not contain OSC 8 codes"
-            );
-        }
-    }
-
-    #[test]
-    fn test_text_render_options_default() {
-        let options = TextRenderOptions::default();
-        assert!(
-            options.enable_hyperlinks,
-            "Default should enable hyperlinks"
-        );
-    }
-
-    #[test]
-    fn test_render_with_custom_options() {
-        use crate::builder::DiagnosticMessageBuilder;
-
-        let msg = DiagnosticMessageBuilder::error("Test")
-            .problem("Something went wrong")
-            .add_detail("Detail 1")
-            .add_hint("Try this")
-            .build();
-
-        let options = TextRenderOptions {
-            enable_hyperlinks: false,
-        };
-
-        let text = msg.to_text_with_options(None, &options);
-
-        // Should still render properly without hyperlinks
-        assert!(text.contains("Error: Test"));
-        assert!(text.contains("Something went wrong"));
-        assert!(text.contains("Detail 1"));
-        assert!(text.contains("Try this"));
-    }
-}
diff --git a/crates/quarto-error-reporting/src/json.rs b/crates/quarto-error-reporting/src/json.rs
deleted file mode 100644
index 9bce6ec42..000000000
--- a/crates/quarto-error-reporting/src/json.rs
+++ /dev/null
@@ -1,480 +0,0 @@
-//! JSON-transport shape for [`DiagnosticMessage`] (bd-b9kzg).
-//!
-//! Lifted from `wasm-quarto-hub-client` so two callers can share
-//! one wire format:
-//!
-//!   * the WASM render bridge (returns `RenderResponse.warnings`
-//!     to hub-client and the q2-preview SPA),
-//!   * the `q2 preview` server's
-//!     [`/api/preview/diagnostics`](https://quarto.org) endpoint
-//!     (surfaces server-side `capture_driver` / `deps` /
-//!     `re_execute` diagnostics to the SPA).
-//!
-//! Both sites emit the same JSON shape so the SPA can merge the
-//! two feeds without a translation layer. The shape matches
-//! Monaco's 1-based `IMarkerData`-style line/column convention.
-//!
-//! ## Public surface
-//!
-//! * [`JsonDiagnostic`] — top-level diagnostic.
-//! * [`JsonDiagnosticDetail`] — nested detail (1..N per diagnostic).
-//! * [`JsonPass1Failure`] — sibling-page parse failure (bd-rqba).
-//! * [`diagnostic_to_json`] — `DiagnosticMessage → JsonDiagnostic`,
-//!   resolving byte offsets to 1-based line/column via
-//!   [`SourceContext`].
-//! * [`with_source_file`] — tag a `JsonDiagnostic` with the file
-//!   it came from (used by sibling Pass-1 failures, see bd-rqba).
-
-use schemars::JsonSchema;
-use serde::Serialize;
-
-use crate::diagnostic::{DetailKind, DiagnosticKind, DiagnosticMessage};
-use quarto_source_map::SourceContext;
-
-/// One detail item in a [`JsonDiagnostic`].
-#[derive(Debug, Clone, Serialize, JsonSchema)]
-pub struct JsonDiagnosticDetail {
-    pub kind: String,
-    pub content: String,
-    #[serde(skip_serializing_if = "Option::is_none")]
-    pub start_line: Option,
-    #[serde(skip_serializing_if = "Option::is_none")]
-    pub start_column: Option,
-    #[serde(skip_serializing_if = "Option::is_none")]
-    pub end_line: Option,
-    #[serde(skip_serializing_if = "Option::is_none")]
-    pub end_column: Option,
-}
-
-/// A diagnostic message in transport-friendly JSON form.
-///
-/// Line and column numbers are 1-based to match Monaco.
-///
-/// ## `$schema` field (bd-iey8o)
-///
-/// Each instance carries a `$schema` field pointing at
-/// [`JsonDiagnostic::SCHEMA_URL`] so that consumers reading the
-/// diagnostic over the wire (CLI stderr, WASM bridge, preview API)
-/// can discover the JSON Schema describing this shape without prior
-/// knowledge. The field is a static-string field with a default
-/// matching the const URL — the only place `JsonDiagnostic` is
-/// constructed (`diagnostic_to_json`) sets it, and downstream
-/// transforms like `with_source_file` preserve it.
-#[derive(Debug, Clone, Serialize, JsonSchema)]
-pub struct JsonDiagnostic {
-    /// JSON Schema URI describing this object's shape. Const value
-    /// is [`JsonDiagnostic::SCHEMA_URL`]; included on the wire so
-    /// consumers can self-discover the contract.
-    #[serde(rename = "$schema")]
-    #[schemars(rename = "$schema")]
-    pub schema: &'static str,
-    pub kind: String,
-    pub title: String,
-    #[serde(skip_serializing_if = "Option::is_none")]
-    pub code: Option,
-    #[serde(skip_serializing_if = "Option::is_none")]
-    pub problem: Option,
-    pub hints: Vec,
-    #[serde(skip_serializing_if = "Option::is_none")]
-    pub start_line: Option,
-    #[serde(skip_serializing_if = "Option::is_none")]
-    pub start_column: Option,
-    #[serde(skip_serializing_if = "Option::is_none")]
-    pub end_line: Option,
-    #[serde(skip_serializing_if = "Option::is_none")]
-    pub end_column: Option,
-    /// Source-file attribution for project-scoped diagnostics
-    /// (bd-rqba). When the project pipeline emits a warning that
-    /// originates in *another* file (e.g., a sidebar entry that
-    /// references a sibling page), this field carries that
-    /// sibling's path so the in-app overlay can label the warning
-    /// with its source instead of free-floating text. `None` for
-    /// page-local diagnostics whose location already pins them
-    /// to the active page's source.
-    #[serde(skip_serializing_if = "Option::is_none")]
-    pub source_file: Option,
-    pub details: Vec,
-    /// Pre-rendered ariadne source-context snippet (bd-352bh).
-    /// Populated when the diagnostic carries a `location` and the
-    /// converting site has a [`SourceContext`] to draw from
-    /// (i.e. always, in [`diagnostic_to_json`]). Same text the
-    /// `q2 render` CLI prints to stdout — ANSI-coded; strip on the
-    /// JS side for browser display. Consumers can render this
-    /// verbatim in a `
` block for the rich source-context
-    /// view, or ignore it and fall back to the structured fields
-    /// for a compact summary. `None` for unlocated diagnostics
-    /// (rare but possible for project-level errors with no span).
-    #[serde(skip_serializing_if = "Option::is_none")]
-    pub rendered: Option,
-}
-
-/// A Pass-1 failure (parse error or metadata error) in a project
-/// file *other than* the active page (bd-rqba). Active-page
-/// failures take the page-render error path; siblings flow through
-/// here so the overlay can render them with source attribution
-/// without forcing the lenient preview to abort.
-///
-/// Strict-vs-lenient policy lives at the consumer (Decision D1):
-/// `quarto preview` / hub-client surfaces these as warnings and
-/// keeps rendering; `quarto render` (CLI) treats any non-empty
-/// `pass1_failures` as a non-zero exit (`bd-creo`).
-///
-/// `$schema` carries [`JsonPass1Failure::SCHEMA_URL`] so consumers
-/// can distinguish this shape from a plain `JsonDiagnostic` line on
-/// a mixed stderr stream and self-discover its contract.
-#[derive(Debug, Clone, Serialize, JsonSchema)]
-pub struct JsonPass1Failure {
-    /// JSON Schema URI describing this object's shape. Const value
-    /// is [`JsonPass1Failure::SCHEMA_URL`].
-    #[serde(rename = "$schema")]
-    #[schemars(rename = "$schema")]
-    pub schema: &'static str,
-    pub source_file: String,
-    pub error: String,
-    pub diagnostics: Vec,
-}
-
-impl JsonDiagnostic {
-    /// JSON Schema URI for the `JsonDiagnostic` wire shape.
-    /// Versioned under `/v1/` so future incompatible changes get a
-    /// new URL rather than silently breaking old consumers.
-    pub const SCHEMA_URL: &'static str = "https://quarto.org/schemas/v1/json-diagnostic.json";
-}
-
-impl JsonPass1Failure {
-    /// JSON Schema URI for the `JsonPass1Failure` wire shape.
-    pub const SCHEMA_URL: &'static str = "https://quarto.org/schemas/v1/json-pass1-failure.json";
-
-    /// Build a `JsonPass1Failure` for a sibling Pass-1 failure
-    /// (parse or metadata error) whose diagnostics have already been
-    /// converted to [`JsonDiagnostic`] form. The `$schema` field is
-    /// populated from the const.
-    pub fn new(source_file: String, error: String, diagnostics: Vec) -> Self {
-        Self {
-            schema: Self::SCHEMA_URL,
-            source_file,
-            error,
-            diagnostics,
-        }
-    }
-}
-
-/// Convert a [`DiagnosticMessage`] to a [`JsonDiagnostic`], using
-/// the [`SourceContext`] to map byte offsets to 1-based
-/// line/column numbers.
-pub fn diagnostic_to_json(diag: &DiagnosticMessage, ctx: &SourceContext) -> JsonDiagnostic {
-    // Map the main location
-    let (start_line, start_column, end_line, end_column) = if let Some(loc) = &diag.location {
-        // Map start position (offset 0 relative to this SourceInfo)
-        let start = loc.map_offset(0, ctx);
-        // Map end position (offset = length of span)
-        let end = loc
-            .map_offset(loc.length(), ctx)
-            .or_else(|| {
-                // Fallback: if end mapping fails, try length-1
-                if loc.length() > 0 {
-                    loc.map_offset(loc.length() - 1, ctx)
-                } else {
-                    None
-                }
-            })
-            .or_else(|| start.clone());
-
-        match (start, end) {
-            (Some(s), Some(e)) => (
-                Some((s.location.row + 1) as u32),    // 1-based line
-                Some((s.location.column + 1) as u32), // 1-based column
-                Some((e.location.row + 1) as u32),
-                Some((e.location.column + 1) as u32),
-            ),
-            (Some(s), None) => (
-                Some((s.location.row + 1) as u32),
-                Some((s.location.column + 1) as u32),
-                None,
-                None,
-            ),
-            _ => (None, None, None, None),
-        }
-    } else {
-        (None, None, None, None)
-    };
-
-    // Convert details
-    let details: Vec = diag
-        .details
-        .iter()
-        .map(|detail| {
-            let (d_start_line, d_start_col, d_end_line, d_end_col) =
-                if let Some(loc) = &detail.location {
-                    let start = loc.map_offset(0, ctx);
-                    let end = loc.map_offset(loc.length(), ctx).or_else(|| start.clone());
-
-                    match (start, end) {
-                        (Some(s), Some(e)) => (
-                            Some((s.location.row + 1) as u32),
-                            Some((s.location.column + 1) as u32),
-                            Some((e.location.row + 1) as u32),
-                            Some((e.location.column + 1) as u32),
-                        ),
-                        (Some(s), None) => (
-                            Some((s.location.row + 1) as u32),
-                            Some((s.location.column + 1) as u32),
-                            None,
-                            None,
-                        ),
-                        _ => (None, None, None, None),
-                    }
-                } else {
-                    (None, None, None, None)
-                };
-
-            let kind_str = match detail.kind {
-                DetailKind::Error => "error",
-                DetailKind::Info => "info",
-                DetailKind::Note | DetailKind::Faded => "note",
-            };
-
-            JsonDiagnosticDetail {
-                kind: kind_str.to_string(),
-                content: detail.content.as_str().to_string(),
-                start_line: d_start_line,
-                start_column: d_start_col,
-                end_line: d_end_line,
-                end_column: d_end_col,
-            }
-        })
-        .collect();
-
-    let kind_str = match diag.kind {
-        DiagnosticKind::Error => "error",
-        DiagnosticKind::Warning => "warning",
-        DiagnosticKind::Info => "info",
-        DiagnosticKind::Note => "note",
-    };
-
-    let hints: Vec = diag.hints.iter().map(|h| h.as_str().to_string()).collect();
-
-    // bd-352bh: pre-render the ariadne source-context snippet for
-    // diagnostics that have a location. `DiagnosticMessage::to_text`
-    // delegates to ariadne when both the diagnostic's location AND
-    // the supplied `SourceContext` are present (see
-    // `crates/quarto-error-reporting/src/diagnostic.rs`'s
-    // `to_text_with_options`); for locationless diagnostics the
-    // function would produce a tidyverse text block instead, which
-    // duplicates what the structured fields already carry. So we
-    // gate on `diag.location.is_some()` to avoid shipping that
-    // redundant text on the wire.
-    let rendered = if diag.location.is_some() {
-        Some(diag.to_text(Some(ctx)))
-    } else {
-        None
-    };
-
-    JsonDiagnostic {
-        schema: JsonDiagnostic::SCHEMA_URL,
-        kind: kind_str.to_string(),
-        title: diag.title.clone(),
-        code: diag.code.clone(),
-        problem: diag.problem.as_ref().map(|p| p.as_str().to_string()),
-        hints,
-        start_line,
-        start_column,
-        end_line,
-        end_column,
-        // Default unattributed; callers that know the source file
-        // (e.g., the Pass-1 failure path) tag it explicitly via
-        // [`with_source_file`].
-        source_file: None,
-        details,
-        rendered,
-    }
-}
-
-/// Tag a [`JsonDiagnostic`] with its source file (bd-rqba). Used
-/// when surfacing project-scoped warnings that originate in a
-/// file other than the active page.
-pub fn with_source_file(mut diag: JsonDiagnostic, source_file: String) -> JsonDiagnostic {
-    diag.source_file = Some(source_file);
-    diag
-}
-
-#[cfg(test)]
-mod tests {
-    use super::*;
-    use crate::DiagnosticMessage;
-
-    #[test]
-    fn warning_with_no_location_serializes_without_position_fields() {
-        let diag = DiagnosticMessage::warning("Test warning").with_code("Q-1-1");
-        let ctx = SourceContext::new();
-        let json = diagnostic_to_json(&diag, &ctx);
-        assert_eq!(json.kind, "warning");
-        assert_eq!(json.title, "Test warning");
-        assert_eq!(json.code.as_deref(), Some("Q-1-1"));
-        assert!(json.start_line.is_none());
-        assert!(json.start_column.is_none());
-    }
-
-    #[test]
-    fn error_kind_serializes_as_lowercase() {
-        let diag = DiagnosticMessage::error("Boom");
-        let ctx = SourceContext::new();
-        assert_eq!(diagnostic_to_json(&diag, &ctx).kind, "error");
-    }
-
-    #[test]
-    fn info_and_note_kinds_serialize() {
-        let ctx = SourceContext::new();
-        assert_eq!(
-            diagnostic_to_json(&DiagnosticMessage::info("i"), &ctx).kind,
-            "info"
-        );
-        assert_eq!(
-            diagnostic_to_json(&DiagnosticMessage::new(DiagnosticKind::Note, "n"), &ctx).kind,
-            "note"
-        );
-    }
-
-    #[test]
-    fn with_source_file_tags_the_diagnostic() {
-        let json = diagnostic_to_json(
-            &DiagnosticMessage::warning("Bad sibling"),
-            &SourceContext::new(),
-        );
-        let tagged = with_source_file(json, "other.qmd".to_string());
-        assert_eq!(tagged.source_file.as_deref(), Some("other.qmd"));
-    }
-
-    /// bd-iey8o: every emitted `JsonDiagnostic` carries a `$schema`
-    /// field with the const URL, and it survives `with_source_file`.
-    #[test]
-    fn diagnostic_carries_schema_url() {
-        let json = diagnostic_to_json(
-            &DiagnosticMessage::warning("with schema"),
-            &SourceContext::new(),
-        );
-        assert_eq!(json.schema, JsonDiagnostic::SCHEMA_URL);
-
-        let tagged = with_source_file(json, "a.qmd".to_string());
-        assert_eq!(tagged.schema, JsonDiagnostic::SCHEMA_URL);
-    }
-
-    /// bd-iey8o: `JsonDiagnostic::SCHEMA_URL` is the value seen on
-    /// the wire under the `$schema` key (note the `$` prefix from
-    /// the serde rename).
-    #[test]
-    fn diagnostic_serializes_schema_field_as_dollar_schema() {
-        let json = diagnostic_to_json(
-            &DiagnosticMessage::warning("wire form"),
-            &SourceContext::new(),
-        );
-        let s = serde_json::to_value(&json).unwrap();
-        assert_eq!(
-            s.get("$schema").and_then(|v| v.as_str()),
-            Some(JsonDiagnostic::SCHEMA_URL)
-        );
-        assert!(
-            s.get("schema").is_none(),
-            "the serde rename should suppress the un-renamed `schema` key"
-        );
-    }
-
-    /// bd-iey8o: `JsonPass1Failure::new` populates `$schema` from
-    /// the const, and the wire form uses the `$` prefix.
-    #[test]
-    fn pass1_failure_carries_schema_url() {
-        let f = JsonPass1Failure::new("other.qmd".to_string(), "boom".to_string(), vec![]);
-        assert_eq!(f.schema, JsonPass1Failure::SCHEMA_URL);
-        let s = serde_json::to_value(&f).unwrap();
-        assert_eq!(
-            s.get("$schema").and_then(|v| v.as_str()),
-            Some(JsonPass1Failure::SCHEMA_URL)
-        );
-    }
-
-    // ─── bd-352bh: ariadne `rendered` field ──────────────────────
-
-    /// Build a `(DiagnosticMessage, SourceContext)` pair where the
-    /// diagnostic has a location pointing into a registered file
-    /// — enough for ariadne to draw a source-context box.
-    fn synth_located_diag() -> (DiagnosticMessage, SourceContext) {
-        use quarto_source_map::{
-            SourceInfo,
-            types::{Location, Range},
-        };
-        let mut ctx = SourceContext::new();
-        let file_id = ctx.add_file(
-            "fixture.qmd".to_string(),
-            Some("# Title\n\nA paragraph that has _unclosed emphasis.\n".to_string()),
-        );
-        // Point at the underscore on row 2. Exact span isn't
-        // important for the rendered-vs-not assertions.
-        let info = SourceInfo::from_range(
-            file_id,
-            Range {
-                start: Location {
-                    offset: 28,
-                    row: 2,
-                    column: 19,
-                },
-                end: Location {
-                    offset: 29,
-                    row: 2,
-                    column: 20,
-                },
-            },
-        );
-        let mut diag =
-            DiagnosticMessage::warning("Unclosed Underscore Emphasis").with_code("Q-2-5");
-        diag.location = Some(info);
-        (diag, ctx)
-    }
-
-    #[test]
-    fn rendered_is_some_when_location_present() {
-        let (diag, ctx) = synth_located_diag();
-        let json = diagnostic_to_json(&diag, &ctx);
-        let rendered = json
-            .rendered
-            .as_deref()
-            .expect("rendered should be populated when the diagnostic has a location");
-        // Ariadne's source-context box always opens with the
-        // U+256D "BOX DRAWINGS LIGHT ARC DOWN AND RIGHT" character.
-        // Pinning that single byte sequence is robust to font /
-        // padding tweaks while still proving "ariadne ran."
-        assert!(
-            rendered.contains('\u{256D}'),
-            "rendered text should contain ariadne's box-drawing chars; got: {rendered:?}",
-        );
-        // The diagnostic's title and code should also appear.
-        assert!(rendered.contains("Unclosed Underscore Emphasis"));
-        assert!(rendered.contains("Q-2-5"));
-    }
-
-    #[test]
-    fn rendered_is_none_when_location_absent() {
-        let diag = DiagnosticMessage::warning("Floating warning").with_code("Q-9-9");
-        let ctx = SourceContext::new();
-        let json = diagnostic_to_json(&diag, &ctx);
-        assert!(
-            json.rendered.is_none(),
-            "rendered should be None for a diagnostic without a location; got: {:?}",
-            json.rendered,
-        );
-    }
-
-    #[test]
-    fn rendered_skipped_in_json_when_none() {
-        // The serde attribute `skip_serializing_if = "Option::is_none"`
-        // keeps the wire shape clean for diagnostics that don't have
-        // a location — the field shouldn't appear in the JSON at all.
-        let diag = DiagnosticMessage::warning("Floating warning");
-        let ctx = SourceContext::new();
-        let json = diagnostic_to_json(&diag, &ctx);
-        let serialized = serde_json::to_string(&json).unwrap();
-        assert!(
-            !serialized.contains("\"rendered\""),
-            "JSON should omit `rendered` when None; got: {serialized}",
-        );
-    }
-}
diff --git a/crates/quarto-error-reporting/src/lib.rs b/crates/quarto-error-reporting/src/lib.rs
deleted file mode 100644
index 66bd92630..000000000
--- a/crates/quarto-error-reporting/src/lib.rs
+++ /dev/null
@@ -1,94 +0,0 @@
-//! Error reporting and diagnostic messages for Quarto.
-//!
-//! This crate provides a structured approach to error reporting, inspired by:
-//! - **ariadne**: Visual compiler-quality error messages with source context
-//! - **R cli package**: Semantic, structured text output
-//! - **Tidyverse style guide**: Best practices for error message content
-//!
-//! # Architecture
-//!
-//! The crate is organized into several phases:
-//!
-//! ## Phase 1: Core Types (Current)
-//! - [`DiagnosticMessage`]: The main error message structure
-//! - [`MessageContent`]: Content representation (Plain, Markdown, or Pandoc AST)
-//! - [`DetailItem`]: Individual detail bullets with error/info/note kinds
-//! - [`DiagnosticKind`]: Error, Warning, Info, etc.
-//!
-//! ## Phase 2: Rendering (Planned)
-//! - Integration with ariadne for visual terminal output
-//! - JSON serialization for machine-readable output
-//!
-//! ## Phase 3: Console Helpers (Planned)
-//! - High-level console output primitives
-//! - ANSI writer for Pandoc AST (requires discussion)
-//!
-//! ## Phase 4: Builder API (Planned)
-//! - Tidyverse-style builder methods (`.problem()`, `.add_detail()`, `.add_hint()`)
-//!
-//! # Design Decisions
-//!
-//! - **Markdown-first**: Messages use Markdown strings, converted to Pandoc AST internally
-//! - **Semantic markup**: Use Pandoc span syntax for semantic classes: `` `text`{.class} ``
-//! - **Multiple outputs**: ANSI terminal, HTML, and JSON formats
-//! - **Rust-idiomatic**: Designed for Rust ergonomics (WASM for cross-language if needed)
-//!
-//! # Example Usage (Future)
-//!
-//! ```ignore
-//! use quarto_error_reporting::DiagnosticMessage;
-//!
-//! let error = DiagnosticMessage::builder()
-//!     .error("Unclosed code block")
-//!     .problem("Code block started but never closed")
-//!     .add_detail("The code block starting with `` ```{python} `` was never closed")
-//!     .at_location(opening_span)
-//!     .add_hint("Did you forget the closing `` ``` ``?")
-//!     .build()?;
-//!
-//! console.error(&error);
-//! ```
-
-// Phase 1: Core error types
-pub mod diagnostic;
-
-// Error code catalog
-pub mod catalog;
-
-// Phase 4: Builder API
-pub mod builder;
-
-// JSON wire shape for diagnostics, shared by wasm-quarto-hub-client
-// (WASM render bridge) and quarto-preview (server-side diagnostics
-// endpoint). Lifted from wasm-quarto-hub-client under bd-b9kzg so
-// the q2-preview SPA can consume both feeds without a translation
-// layer. Behind the default-off `json` feature (carries `schemars`
-// and Quarto's `quarto.org` schema URLs) so the published crate stays
-// minimal for non-Quarto consumers.
-#[cfg(feature = "json")]
-pub mod json;
-
-// Macros for convenient error creation
-pub mod macros;
-
-// Cross-source diagnostic coalescing (bd-9hlja).
-//
-// When per-page diagnostics share a source location across many
-// pages, this module groups them into a single emission listing the
-// affected pages — used by the render summary printer in the CLI.
-pub mod coalesce;
-
-// Re-export main types for convenience
-pub use builder::DiagnosticMessageBuilder;
-pub use catalog::{
-    CatalogProvider, EmptyCatalog, ErrorCodeInfo, get_docs_url, get_error_info, get_subsystem,
-    install_catalog,
-};
-pub use coalesce::{CoalescedDiagnostic, coalesce_by_source};
-pub use diagnostic::{
-    DetailItem, DetailKind, DiagnosticKind, DiagnosticMessage, MessageContent, TextRenderOptions,
-};
-#[cfg(feature = "json")]
-pub use json::{
-    JsonDiagnostic, JsonDiagnosticDetail, JsonPass1Failure, diagnostic_to_json, with_source_file,
-};
diff --git a/crates/quarto-error-reporting/src/macros.rs b/crates/quarto-error-reporting/src/macros.rs
deleted file mode 100644
index b2a98e49f..000000000
--- a/crates/quarto-error-reporting/src/macros.rs
+++ /dev/null
@@ -1,98 +0,0 @@
-//! Macros for creating diagnostic messages.
-
-#[cfg(test)]
-mod tests {
-    use crate::{DiagnosticKind, generic_error, generic_warning};
-
-    #[test]
-    fn test_generic_error_macro() {
-        let error = generic_error!("Test error message");
-
-        assert_eq!(error.kind, DiagnosticKind::Error);
-        assert_eq!(error.code, Some("Q-0-99".to_string())); // quarto-error-code-audit-ignore
-        assert!(error.title.contains("Test error message"));
-        assert!(error.title.contains(file!()));
-        // Line number is included but varies depending on where macro is called
-        assert!(error.title.contains(':'));
-    }
-
-    #[test]
-    fn test_generic_warning_macro() {
-        let warning = generic_warning!("Test warning message");
-
-        assert_eq!(warning.kind, DiagnosticKind::Warning);
-        assert_eq!(warning.code, Some("Q-0-99".to_string())); // quarto-error-code-audit-ignore
-        assert!(warning.title.contains("Test warning message"));
-        assert!(warning.title.contains(file!()));
-    }
-
-    #[test]
-    fn test_macro_with_format() {
-        let value = 42;
-        let error = generic_error!(format!("Invalid value: {}", value));
-
-        assert!(error.title.contains("Invalid value: 42"));
-    }
-
-    #[test]
-    fn test_macro_error_can_be_rendered() {
-        let error = generic_error!("Render test");
-        let text = error.to_text(None);
-
-        assert!(text.contains("[Q-0-99]")); // quarto-error-code-audit-ignore
-        assert!(text.contains("Render test"));
-    }
-
-    #[test]
-    fn test_macro_warning_can_be_rendered() {
-        let warning = generic_warning!("Warning test");
-        let text = warning.to_text(None);
-
-        assert!(text.contains("[Q-0-99]")); // quarto-error-code-audit-ignore
-        assert!(text.contains("Warning test"));
-    }
-}
-
-/// Create a generic error with automatic file and line information.
-///
-/// This macro is for migration purposes - it creates an error with code Q-0-99 (quarto-error-code-audit-ignore)
-/// and automatically includes the file and line number where the error was created.
-///
-/// # Example
-///
-/// ```
-/// use quarto_error_reporting::generic_error;
-///
-/// let error = generic_error!("Found unexpected attribute");
-/// assert_eq!(error.code, Some("Q-0-99".to_string())); // quarto-error-code-audit-ignore
-/// assert!(error.title.contains("Found unexpected attribute"));
-/// assert!(error.title.contains(file!()));
-/// ```
-#[macro_export]
-macro_rules! generic_error {
-    ($message:expr) => {
-        $crate::DiagnosticMessageBuilder::generic_error($message, file!(), line!())
-    };
-}
-
-/// Create a generic warning with automatic file and line information.
-///
-/// This macro is for migration purposes - it creates a warning with code Q-0-99 (quarto-error-code-audit-ignore)
-/// and automatically includes the file and line number where the warning was created.
-///
-/// # Example
-///
-/// ```
-/// use quarto_error_reporting::generic_warning;
-///
-/// let warning = generic_warning!("Caption found without table");
-/// assert_eq!(warning.code, Some("Q-0-99".to_string())); // quarto-error-code-audit-ignore
-/// assert!(warning.title.contains("Caption found without table"));
-/// assert!(warning.title.contains(file!()));
-/// ```
-#[macro_export]
-macro_rules! generic_warning {
-    ($message:expr) => {
-        $crate::DiagnosticMessageBuilder::generic_warning($message, file!(), line!())
-    };
-}
diff --git a/crates/quarto-error-reporting/tests/schema_drift.rs b/crates/quarto-error-reporting/tests/schema_drift.rs
deleted file mode 100644
index 6eab0b955..000000000
--- a/crates/quarto-error-reporting/tests/schema_drift.rs
+++ /dev/null
@@ -1,138 +0,0 @@
-//! JSON Schema drift detection for the diagnostic wire shapes
-//! (bd-iey8o).
-//!
-//! The schema files at `crates/quarto-error-reporting/schemas/` are
-//! the source-of-truth contract published to the docs site (and
-//! referenced by every emitted diagnostic's `$schema` field). These
-//! files MUST be in sync with the Rust types in `src/json.rs`.
-//!
-//! This test detects drift by re-generating each schema in-memory
-//! from the current Rust type definitions and comparing to the
-//! checked-in JSON. On mismatch:
-//!
-//! - With `QUARTO_REGEN_SCHEMAS=1` set, the test overwrites the
-//!   checked-in file and passes — that's how you regenerate after a
-//!   wire-shape change.
-//! - Without it, the test fails with a clear message pointing at
-//!   the regenerate command.
-//!
-//! Idiomatic invocation:
-//!
-//! ```text
-//! # Verify (default — what CI does):
-//! cargo nextest run -p quarto-error-reporting --test schema_drift
-//!
-//! # Regenerate after editing JsonDiagnostic / JsonPass1Failure:
-//! QUARTO_REGEN_SCHEMAS=1 cargo nextest run -p quarto-error-reporting --test schema_drift
-//! ```
-//!
-//! Gated on the `json` feature (the wire shapes live behind it); compiles to an
-//! empty test binary when `json` is off. In the q2 workspace the feature is on
-//! (enabled by `quarto`/`quarto-core`/… via unification).
-#![cfg(feature = "json")]
-
-use std::path::PathBuf;
-
-use quarto_error_reporting::{JsonDiagnostic, JsonPass1Failure};
-use serde_json::Value;
-
-/// Recursively sort the keys of every JSON object in `value` so the
-/// resulting text representation is independent of upstream map
-/// iteration order.
-///
-/// Why: `serde_json`'s `preserve_order` feature is workspace-wide.
-/// When *any* crate in the workspace activates it (transitively),
-/// schemars' output ordering flips between sorted (BTreeMap) and
-/// insertion-order (IndexMap). The checked-in schema files MUST be
-/// stable under both. Canonical-sorting the keys here decouples the
-/// file content from the feature configuration.
-fn canonicalize_keys(value: Value) -> Value {
-    match value {
-        Value::Object(map) => {
-            let mut entries: Vec<(String, Value)> = map.into_iter().collect();
-            entries.sort_by(|a, b| a.0.cmp(&b.0));
-            let mut out = serde_json::Map::new();
-            for (k, v) in entries {
-                out.insert(k, canonicalize_keys(v));
-            }
-            Value::Object(out)
-        }
-        Value::Array(arr) => Value::Array(arr.into_iter().map(canonicalize_keys).collect()),
-        other => other,
-    }
-}
-
-/// Generate the schema for type `T` and return it as a
-/// pretty-printed JSON string with a trailing newline. Object keys
-/// are sorted lexicographically at every depth (see
-/// [`canonicalize_keys`]) so the output is independent of the
-/// `serde_json/preserve_order` feature.
-fn render_schema() -> String {
-    let schema = schemars::schema_for!(T);
-    let value = serde_json::to_value(&schema).expect("schema must serialize to Value");
-    let canonical = canonicalize_keys(value);
-    let mut s = serde_json::to_string_pretty(&canonical).expect("schema must serialize");
-    s.push('\n');
-    s
-}
-
-fn schemas_dir() -> PathBuf {
-    PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("schemas")
-}
-
-/// Check a single shape against its checked-in schema file. On
-/// mismatch, behavior depends on `QUARTO_REGEN_SCHEMAS`:
-/// `=1` writes and passes; otherwise this panics with a diff
-/// summary and the regenerate command.
-fn check_or_regenerate(file_name: &str, generated: &str) {
-    let path = schemas_dir().join(file_name);
-    let regen = std::env::var("QUARTO_REGEN_SCHEMAS").is_ok_and(|v| v == "1");
-
-    let existing = std::fs::read_to_string(&path).ok();
-
-    if existing.as_deref() == Some(generated) {
-        return;
-    }
-
-    if regen {
-        // Ensure the directory exists (first-time generation).
-        if let Some(parent) = path.parent() {
-            std::fs::create_dir_all(parent).expect("create schemas/ dir");
-        }
-        std::fs::write(&path, generated)
-            .unwrap_or_else(|e| panic!("failed to write schema to {}: {}", path.display(), e));
-        eprintln!("regenerated {}", path.display());
-        return;
-    }
-
-    // Build a useful failure message.
-    let existing_summary = match &existing {
-        None => "(no file on disk)".to_string(),
-        Some(s) => format!("({} bytes)", s.len()),
-    };
-    panic!(
-        "JSON Schema drift for {}.\n\
-         The checked-in file {} {} does not match the schema generated\n\
-         from the current Rust types in src/json.rs. To regenerate, run:\n\
-         \n\
-         \tQUARTO_REGEN_SCHEMAS=1 cargo nextest run -p quarto-error-reporting --test schema_drift\n\
-         \n\
-         Then review the diff before committing.",
-        file_name,
-        path.display(),
-        existing_summary,
-    );
-}
-
-#[test]
-fn json_diagnostic_schema_matches_committed() {
-    check_or_regenerate("json-diagnostic.json", &render_schema::());
-}
-
-#[test]
-fn json_pass1_failure_schema_matches_committed() {
-    check_or_regenerate(
-        "json-pass1-failure.json",
-        &render_schema::(),
-    );
-}
diff --git a/crates/quarto-parse-errors/Cargo.toml b/crates/quarto-parse-errors/Cargo.toml
index 8f76f4bb7..83bc4b682 100644
--- a/crates/quarto-parse-errors/Cargo.toml
+++ b/crates/quarto-parse-errors/Cargo.toml
@@ -11,7 +11,7 @@ license.workspace = true
 repository.workspace = true
 
 [dependencies]
-quarto-error-reporting = { path = "../quarto-error-reporting" }
+quarto-error-reporting = { workspace = true }
 quarto-source-map = { workspace = true }
 tree-sitter = { workspace = true }
 serde = { workspace = true, features = ["derive"] }
diff --git a/crates/quarto-xml/Cargo.toml b/crates/quarto-xml/Cargo.toml
index 47a1ab634..60ef7cc35 100644
--- a/crates/quarto-xml/Cargo.toml
+++ b/crates/quarto-xml/Cargo.toml
@@ -9,7 +9,7 @@ description = "Source-tracked XML parsing for Quarto"
 
 [dependencies]
 quick-xml = { workspace = true }
-quarto-error-reporting = { path = "../quarto-error-reporting" }
+quarto-error-reporting = { workspace = true }
 quarto-source-map = { workspace = true }
 thiserror = { workspace = true }
 
diff --git a/crates/wasm-quarto-hub-client/Cargo.lock b/crates/wasm-quarto-hub-client/Cargo.lock
index a5635cb17..cacc8db4f 100644
--- a/crates/wasm-quarto-hub-client/Cargo.lock
+++ b/crates/wasm-quarto-hub-client/Cargo.lock
@@ -2221,7 +2221,9 @@ dependencies = [
 
 [[package]]
 name = "quarto-error-reporting"
-version = "0.7.0"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2aa95153b93fea9754e121137d5a6e38e30dd1184c73d2266958f698b8cb9503"
 dependencies = [
  "ariadne",
  "quarto-source-map",
diff --git a/crates/wasm-quarto-hub-client/Cargo.toml b/crates/wasm-quarto-hub-client/Cargo.toml
index 53e7a7bab..1c072bd13 100644
--- a/crates/wasm-quarto-hub-client/Cargo.toml
+++ b/crates/wasm-quarto-hub-client/Cargo.toml
@@ -16,7 +16,7 @@ quarto-core = { path = "../quarto-core" }
 # NOTE: `quarto-error-catalog` is intentionally NOT a dependency here — the WASM
 # bridge never surfaces docs URLs, so installing the catalog would only bloat the
 # bundle past the PWA precache limit. See the comment in `init()` in src/lib.rs.
-quarto-error-reporting = { path = "../quarto-error-reporting", features = ["json"] }
+quarto-error-reporting = { version = "0.1.0", features = ["json"] }
 # Direct dep so the test-only `quarto_highlight_for_test` export can
 # call the Registry-backed highlight path. Same crate quarto-core uses
 # via CodeHighlightStage — ensuring WASM tests exercise the same code