Skip to content

Commit e06e8ea

Browse files
authored
Merge pull request #362 from quarto-dev/feature/bd-yjs54ptg-strict-mode-warnings-as-errors
feat(render): add --strict to promote warning diagnostics to errors
2 parents 4fbe7ae + c5d783c commit e06e8ea

7 files changed

Lines changed: 787 additions & 5 deletions

File tree

Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
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

Comments
 (0)