|
| 1 | +# Strict mode: promote warning diagnostics to errors (GH #220) |
| 2 | + |
| 3 | +- **Braid strand:** bd-yjs54ptg |
| 4 | +- **GitHub issue:** https://github.com/quarto-dev/q2/issues/220 |
| 5 | +- **Status:** feasibility study + implementation plan (no code yet) |
| 6 | + |
| 7 | +## Overview |
| 8 | + |
| 9 | +GH #220 asks for a strict mode where warnings become errors, primarily for |
| 10 | +CI. The design goal stated for this study: find the (hopefully few, central) |
| 11 | +locations where warnings flow, and upgrade warning diagnostics to errors |
| 12 | +there, so the command *naturally* fails with what are now errors — and so |
| 13 | +**future warning diagnostics automatically participate in strict mode with |
| 14 | +zero per-call-site work**. |
| 15 | + |
| 16 | +**Verdict: feasible, and cheap.** The architecture already has the seam we |
| 17 | +need. The recommended design touches ~4 places, all policy-side (CLI + |
| 18 | +summary type); the diagnostic engine stays policy-free, consistent with |
| 19 | +Decision D1 (bd-creo) and the config-error-handling decision of 2025-12-07 |
| 20 | +("the caller decides"). |
| 21 | + |
| 22 | +## How diagnostics flow today (verified 2026-07-02) |
| 23 | + |
| 24 | +1. **One diagnostic type.** `DiagnosticMessage` in `quarto-error-reporting` |
| 25 | + (source: `external-sources/quarto-error-reporting/src/diagnostic.rs:215`) |
| 26 | + with `kind: DiagnosticKind` (`Error | Warning | Info | Note`, |
| 27 | + `diagnostic.rs:9`). A warning differs from an error **only** by this |
| 28 | + field. Promotion is a one-field mutation. The crate has no built-in |
| 29 | + severity-promotion affordance — by design, policy is consumer-side. |
| 30 | + |
| 31 | +2. **Many emitters, no single emission chokepoint.** Warnings are pushed |
| 32 | + into `StageContext.diagnostics` (`crates/quarto-core/src/stage/context.rs:94`), |
| 33 | + `RenderContext.diagnostics` (`crates/quarto-core/src/render.rs:233`), |
| 34 | + pampa's `DiagnosticCollector` |
| 35 | + (`crates/pampa/src/utils/diagnostic_collector.rs`), and ~15 transforms |
| 36 | + that take a bare `&mut Vec<DiagnosticMessage>`. Lua `quarto.warn()` is |
| 37 | + harvested into real `DiagnosticMessage`s |
| 38 | + (`crates/pampa/src/lua/diagnostics.rs:354`), so filter warnings ride the |
| 39 | + same rails. Emission-point promotion would therefore **not** be "few, |
| 40 | + central" — but it doesn't need to be, because: |
| 41 | + |
| 42 | +3. **Everything converges on `ProjectRenderSummary`** |
| 43 | + (`crates/quarto-core/src/project/orchestrator.rs:481`), via exactly four |
| 44 | + fields: `pass1_failures[].diagnostics`, `pass2_failures[].diagnostics`, |
| 45 | + `project_diagnostics`, and per-output `outputs[].render_output.diagnostics` |
| 46 | + (exposed by the `OutputDiagnostics` trait, `orchestrator.rs:546`). |
| 47 | + Both the printing (`print_render_diagnostics`, text and `--json-errors` |
| 48 | + paths, `crates/quarto/src/commands/render.rs:840`) and the error/warning |
| 49 | + tally (`ProjectRenderSummary::diagnostic_counts`, `orchestrator.rs:583`) |
| 50 | + operate **post-run on the summary** — nothing structured is streamed to |
| 51 | + the user mid-render. This is the central seam. |
| 52 | + |
| 53 | +4. **The exit-code boundary is one function**: `should_exit_nonzero` |
| 54 | + (`crates/quarto/src/commands/render.rs:830`), called from both |
| 55 | + `execute_single_doc` (:728) and `execute_project` (:811). Today it |
| 56 | + returns true iff pass1/pass2 failures exist or a *project-level* |
| 57 | + diagnostic has `kind == Error`. |
| 58 | + |
| 59 | +5. **Precedent already in-tree:** `quarto-doctemplate`'s `EvalContext` has a |
| 60 | + working `strict_mode` flag with `warn_or_error_at` / |
| 61 | + `warn_or_error_with_code` (`crates/quarto-doctemplate/src/eval_context.rs:125-232`). |
| 62 | + That's the emission-side pattern; we deliberately do **not** generalize |
| 63 | + it (see "Alternatives rejected"). |
| 64 | + |
| 65 | +### Two latent facts that shaped the design |
| 66 | + |
| 67 | +- **Per-document diagnostics on *successful* outputs never affect the exit |
| 68 | + code today — even `Error`-kind ones.** `should_exit_nonzero` only looks |
| 69 | + at failures + `project_diagnostics`. So "promote kind and the command |
| 70 | + naturally fails" is *almost* true: promotion must be paired with a small |
| 71 | + exit-gate extension, otherwise a promoted warning on a successful render |
| 72 | + would print as `error` and still exit 0. (This is arguably a latent |
| 73 | + inconsistency even without strict mode — see Open Questions.) |
| 74 | +- **~552 `eprintln!` and ~63 `tracing::warn!` call sites bypass the |
| 75 | + structured system entirely.** Strict mode structurally cannot see these. |
| 76 | + That is acceptable (they are logging, not user-facing diagnostics), but |
| 77 | + it makes the convention "user-visible warnings must be |
| 78 | + `DiagnosticMessage`s" load-bearing. A follow-up xtask lint could police |
| 79 | + new `eprintln!("warning: ...")`-style output in quarto-core. |
| 80 | + |
| 81 | +## Recommended design: promote at the summary boundary |
| 82 | + |
| 83 | +Add a `--strict` flag to `quarto render`. When set, after `pipeline.run()` |
| 84 | +returns and **before** printing: |
| 85 | + |
| 86 | +```rust |
| 87 | +if args.strict { |
| 88 | + summary.promote_warnings_to_errors(); |
| 89 | +} |
| 90 | +``` |
| 91 | + |
| 92 | +where `ProjectRenderSummary::promote_warnings_to_errors()` (new, in |
| 93 | +`orchestrator.rs`) walks the four diagnostic sources and flips |
| 94 | +`DiagnosticKind::Warning` → `DiagnosticKind::Error`. `Info`/`Note` are left |
| 95 | +alone. Then extend the exit gate: |
| 96 | + |
| 97 | +```rust |
| 98 | +fn should_exit_nonzero(summary: &ProjectRenderSummary, strict: bool) -> bool { |
| 99 | + if !summary.pass1_failures.is_empty() || !summary.pass2_failures.is_empty() { |
| 100 | + return true; |
| 101 | + } |
| 102 | + if strict && summary.diagnostic_counts().errors > 0 { |
| 103 | + return true; // post-promotion, this is exactly "any former warning or real error anywhere" |
| 104 | + } |
| 105 | + summary.project_diagnostics.iter().any(|d| d.kind == DiagnosticKind::Error) |
| 106 | +} |
| 107 | +``` |
| 108 | + |
| 109 | +### Why this satisfies the sustainability requirement |
| 110 | + |
| 111 | +Every structured diagnostic — from pipeline stages, transforms, pampa, |
| 112 | +Lua filters, project post-render — already drains into the summary; both |
| 113 | +existing consumers of severity (printer, counter) read the summary |
| 114 | +post-promotion. A **new warning added anywhere in the codebase tomorrow |
| 115 | +automatically**: prints as `error` under `--strict` (text and |
| 116 | +`--json-errors` alike), counts as an error in the summary line, and fails |
| 117 | +the command. No per-call-site opt-in, no new emission API to remember. |
| 118 | + |
| 119 | +### What the user sees |
| 120 | + |
| 121 | +- Without `--strict`: unchanged. |
| 122 | +- With `--strict`: diagnostics render with error severity (ariadne styling, |
| 123 | + JSON `"kind": "error"`), the counts clause reports them as errors, and |
| 124 | + the process exits 1. Consistent labeling everywhere because promotion |
| 125 | + happens *before* any output is produced. |
| 126 | + |
| 127 | +### Touched locations (the "few, central" list) |
| 128 | + |
| 129 | +1. `crates/quarto/src/commands/render.rs` — `RenderArgs.strict` (clap flag, |
| 130 | + modeled on `fail_fast`, :89), the two `promote` call sites (or one, if |
| 131 | + the shared tail of `execute_single_doc`/`execute_project` is factored), |
| 132 | + and the `should_exit_nonzero` extension. |
| 133 | +2. `crates/quarto-core/src/project/orchestrator.rs` — |
| 134 | + `promote_warnings_to_errors()`; add `diagnostics_mut()` to the |
| 135 | + `OutputDiagnostics` trait (with the same `#[cfg(target_arch = "wasm32")]` |
| 136 | + empty-impl shape as the existing `diagnostics()`), or implement the |
| 137 | + method directly on `ProjectRenderSummary<RenderToFileResult>`. |
| 138 | + |
| 139 | +Nothing in `quarto-error-reporting` (external crate) changes. |
| 140 | + |
| 141 | +### Scope boundaries |
| 142 | + |
| 143 | +- **`q2 preview` / hub-client / WASM: intentionally out.** Decision D1 |
| 144 | + makes preview/hub deliberately lenient (partial progress over strictness); |
| 145 | + the WASM response even carries `warnings` as a separate field |
| 146 | + (`crates/wasm-quarto-hub-client/src/lib.rs:616-621`). Strict mode is a |
| 147 | + CLI-render policy. If browser strictness is ever wanted, the promotion |
| 148 | + point would be where the WASM response assembles its |
| 149 | + `warnings`/`diagnostics` fields. |
| 150 | +- **`quarto-doctemplate`'s internal `strict_mode`: untouched.** Its |
| 151 | + warnings surface upward as ordinary `DiagnosticMessage` warnings and get |
| 152 | + promoted at the boundary like everything else. |
| 153 | +- **`eprintln!`/`tracing` output: out of scope** (not structurally |
| 154 | + reachable; see above). |
| 155 | +- **Render control flow: unchanged.** Strict mode does not abort rendering |
| 156 | + mid-flight or change what gets rendered — it renders everything, then |
| 157 | + reports and fails. (`--fail-fast` keeps its current meaning: stop at |
| 158 | + first *failure*; it does not learn to stop at first warning in v1.) |
| 159 | + |
| 160 | +## Alternatives rejected |
| 161 | + |
| 162 | +- **Exit-gate-only** (`strict && warnings > 0` → exit 1, no kind |
| 163 | + promotion): smallest diff, but the command fails while the output still |
| 164 | + says "warning" — confusing in CI logs, and the issue/user intent is |
| 165 | + explicitly "what are now errors". |
| 166 | +- **Emission-point promotion** (generalize doctemplate's |
| 167 | + `warn_or_error_*` everywhere): no central emission chokepoint exists |
| 168 | + (~15 bare-`Vec` pushes, several sink types, plus Lua harvesting); every |
| 169 | + future warning author would need to remember the strict-aware API — |
| 170 | + exactly the unsustainable shape we're avoiding. It would also let strict |
| 171 | + mode alter mid-render control flow (`has_errors()` checks, fail-fast |
| 172 | + aborts), changing *what renders* rather than just the outcome. |
| 173 | +- **Promotion inside `quarto-error-reporting`**: violates the |
| 174 | + engine-stays-policy-free design (D1; 2025-12-07 config plan) and would |
| 175 | + require a release cycle of the external crate for no benefit. |
| 176 | + |
| 177 | +## Work items (TDD order) |
| 178 | + |
| 179 | +### Phase 1 — tests first |
| 180 | + |
| 181 | +- [x] Pick/build a fixture with a stable, successful-render warning: |
| 182 | + an unresolved crossref (`@fig-nonexistent`) — confirmed empirically |
| 183 | + to render successfully with one warning, exit 0. |
| 184 | +- [x] CLI integration tests: |
| 185 | + `crates/quarto/tests/integration/strict_mode.rs` (7 tests). |
| 186 | + Verified failing first: 6 failed with clap's |
| 187 | + `unexpected argument '--strict'`, baseline test passed. |
| 188 | +- [x] Unit tests for `promote_warnings_to_errors` covering all four |
| 189 | + summary sources; `Info`/`Note` untouched (orchestrator.rs tests). |
| 190 | +- [x] Unit tests for the `should_exit_nonzero` strict arm (render.rs |
| 191 | + tests: warning-only, promoted, clean, info-only). |
| 192 | +- [x] `--json-errors --strict` test: emitted JSON `kind` is `"error"`. |
| 193 | +- [x] Project-render variant (multi-file, warning in one file). |
| 194 | + |
| 195 | +### Phase 2 — implementation |
| 196 | + |
| 197 | +- [x] `RenderArgs.strict` + clap wiring (`--strict`, global to the render |
| 198 | + subcommand, modeled on `--fail-fast`) + `--help` text. |
| 199 | +- [x] `OutputDiagnostics::diagnostics_mut` (native + wasm impls) + |
| 200 | + `ProjectRenderSummary::promote_warnings_to_errors`. |
| 201 | +- [x] Promotion call + `should_exit_nonzero(summary, strict)` in both |
| 202 | + execute paths. The strict arm defensively checks `warnings > 0` too, |
| 203 | + so the gate stays correct even if a caller forgets to promote. |
| 204 | +- [x] `cargo build --workspace` clean; `cargo nextest run --workspace`: |
| 205 | + 9884 passed. Full `cargo xtask verify` (WASM leg) run as well. |
| 206 | + |
| 207 | +### Phase 3 — end-to-end + docs |
| 208 | + |
| 209 | +- [x] End-to-end verification (2026-07-02, real binary, output inspected): |
| 210 | + |
| 211 | + ``` |
| 212 | + $ q2 render warn.qmd --strict # warn.qmd contains @fig-nonexistent |
| 213 | + Error: unresolved crossref `@fig-nonexistent`: no target with this identifier was found. |
| 214 | + 1 error |
| 215 | + EXIT: 1 # warn.html still written (905 bytes) |
| 216 | + |
| 217 | + $ q2 render warn.qmd # without --strict: unchanged |
| 218 | + Warning: unresolved crossref `@fig-nonexistent`: ... |
| 219 | + 1 warning |
| 220 | + EXIT: 0 |
| 221 | + |
| 222 | + $ q2 render warn.qmd --strict --json-errors |
| 223 | + {"$schema":".../json-diagnostic.json","kind":"error","title":"unresolved crossref ..."} |
| 224 | + ``` |
| 225 | + |
| 226 | +- [x] User-facing docs: "Rendering in CI" section in |
| 227 | + `docs/guides/publishing/index.qmd`; page verified to render via |
| 228 | + `q2 render docs/guides/publishing/index.qmd`. |
| 229 | +- [x] Close the loop on GH #220: PR |
| 230 | + https://github.com/quarto-dev/q2/pull/362 closes it on merge and |
| 231 | + links this plan + strand bd-yjs54ptg. |
| 232 | + |
| 233 | +## Decisions (Carlos, 2026-07-02) |
| 234 | + |
| 235 | +1. **Latent inconsistency** (Error-kind diagnostic on a successful output |
| 236 | + exits 0): **separate strand**, likely handled right after this one. |
| 237 | + Filed as a discovered-from strand of bd-yjs54ptg. |
| 238 | +2. **Config surface**: **CLI-flag-only** for v1; no `_quarto.yml` key. |
| 239 | +3. **Flag name**: **`--strict`**, with help text spelling out the |
| 240 | + warnings-become-errors semantics. |
| 241 | +4. **`--strict --fail-fast` interplay**: **keep orthogonal**. Under |
| 242 | + `--strict --fail-fast`, a promoted warning does not stop the render |
| 243 | + (fail-fast still means "stop at first *failure*"). Accepted as |
| 244 | + not-ideal; not worth reconsidering the parallelism / diagnostic-merge |
| 245 | + design until there is strong evidence of shortcomings. |
0 commit comments