Lesson 6 · Diagnostics
Errors that accumulate
You said you want all the errors, not the first one. That is not a logging change — it changes a return type, and it needs a second mechanism that has nothing to do with collecting.
One word first: diagnostic
A diagnostic is a reported problem as a value: severity, message,
and a location. Not an exception, not a print. Compilers use the word deliberately, because the moment
problems are values you can count them, sort them, de-duplicate them, snapshot-test them, and decide
later whether any of them is fatal. Your rejections.py is a set of exception
classes with excellent messages — the messages survive this lesson unchanged; the raise
does not.
The thing that holds them is a diagnostic sink (rustc calls it
DiagCtxt; GCC calls it a diagnostic context). One object, threaded through, that everything
reports into and nobody reads until the end.
Why the return type has to change
Here is the mechanism, and it is worth being able to state in one breath. Two ways to write a computation that can fail:
Either — bind: m >>= f f receives the value of m
Validation — ap: m <*> n n does not receive anything from m
Bind hands the value of the first step to the second. So if the first step
failed there is no value, f cannot be called, and its errors are unknowable. This
is not a design flaw in Either; it is what sequencing means. Short-circuiting is
forced by the type.
Ap (the applicative operator) combines two
computations that were built independently. Neither needs the other's value, so both can run, and both
sets of errors can be concatenated. The type that does this is conventionally called
Validation — the same shape as Either, differing only in that its failure case
holds a collection.
You can accumulate errors from exactly those steps that do not consume each other's output.
That sentence is the whole design rule, and it tells you where to look in your own code: not at the
error type, at the data dependencies between checks. Python has no
Validation, and you do not need one — list[Diagnostic] plus concatenation
is the applicative. What you need is the discipline of asking, per check, whether it consumes
anything a previous check produced.
raise is a short-circuit you cannot opt out of: the
rest of the enclosing function does not run. That is why “report all errors” cannot be done by
improving the exception classes. It is a control-flow property, not a message property.
Accumulating is only half of it
Collecting works within a phase, where checks are siblings. Across phases it does nothing: if
load() failed for a model, the transform pass has no model to complain about. To get
errors out of a later phase you need the second mechanism:
| Recovery | Continue past a failure by inventing a stand-in for the value that could not be produced. |
| Error node / poison | The stand-in itself. Rust's AST has
ExprKind::Err; TypeScript uses any; C compilers substitute
int. Later passes see something type-correct and keep going. |
| Synchronisation point | The granularity at which you resume — the unit
you are willing to throw away. Parsers sync on ; or a closing brace.
Yours is the model. One unrepresentable model must not hide the other nineteen. |
| Cascade suppression | Once a value is poisoned, errors caused by the poison are noise. Compilers mark them and stay quiet. Without this, error recovery makes output worse, not better. |
So: accumulate siblings, recover to reach the next phase, suppress what the recovery caused. Three separate decisions, and every real compiler makes all three. Lesson 10 is these four rows as code, and as the bugs they go wrong in.
Exercise A — Which errors can you even see?
A recipe over three models, with five seeded defects. Mark, for each policy, whether that defect is reported in one run. Answer a full row before moving on; feedback is immediate.
load("app.models:Invoice") # RootModel
load("app.models:User") # fine
load("app.models:Order") # has a @field_validator
pipe(user, omit("emial")) # typo
File("out/a.py", invoices) # both files
File("out/a.py", orders) # write to out/a.py
P1 fail-fast — today's behaviour: first raise wins.
P2 collect-per-phase — each phase gathers all its diagnostics, and a phase with any
error stops the run. P3 collect + recover — a model that cannot be loaded is dropped,
the run continues on the rest.
| defect | P1 fail-fast | P2 collect | P3 recover |
|---|
What changes in the repo
Three tiers, and they want three different treatments. That is the actual output of this lesson — not “return a list”.
1. gates.py — pure accumulation, no recovery needed
Every gate is a sibling: _reject_duplicate_models,
_reject_field_carrying_bases, _reject_import_collisions,
_reject_shadowed_imports. None consumes another's output; all four read the same
list[Model]. So all four can always run, and today all four are throttled to one
diagnostic each by _first_repeat and by shadowed[0].
def reject_unwritable(path, models) -> None # today
def unwritable(path, models) -> list[Diagnostic] # after
The word reject in the name is the tell: a verifier that rejects cannot report. Rename
to a noun and the collection falls out. _first_repeat becomes _repeats, and
Arr(...).filter(...) stops being followed by [0].
2. loader.py — the recovery boundary
This is the only place that needs an error node, and you get to pick a cheap one:
load() returns list[Model], so a rejected model can simply be
absent, with its diagnostic recorded. Absence is a legitimate poison value when nothing
downstream indexes by position. Check that: pipe, each and
rename_model all map over the list, and only rename_model with a string
argument cares about the count — it raises AmbiguousRenameError on more than one. Dropping
a model there could turn an ambiguous rename into a silently-accepted one. That is a cascade,
and it needs suppressing. The general shape: recovery must not make a later check
pass.
3. Diagnostics need provenance, and your IR has none
Once errors are no longer raised, you lose the traceback — which was doing real work, because it was
the only thing that said which recipe line caused the problem. Ten diagnostics with no
locations are worse than one exception with a stack. So a Diagnostic carries
severity, a code (rustc's E0308 — a stable identifier you can
document and test against), the message you already write well, and a location. Your
Model and Field nodes currently record no span —
no file, no line — even though module_source has the AST nodes in hand and could.
Lesson 2 named this: a source map. This lesson is what makes it necessary.
write() — the one public entry point — raises a single
aggregate. Python's ExceptionGroup (PEP 654) exists for exactly this, and
pytest.raises(ExceptionGroup) plus a check on the codes is a much better test than the
current match-on-message. Note what you already get right: generated() renders every file
before write() touches the disk, so a failure never leaves half the output written. Keep
that two-phase commit — with all-errors reporting it becomes the thing that makes a failed run safe to
retry.
Exercise B — Recall
From memory. Type the term; the field checks itself.
The counterweight
Not every error should be collected. Two cases where fail-fast is right and “all the errors” is actively worse:
Unrecoverable environment failures. FormatterNotFoundError — ruff is
not on PATH. There is no useful second diagnostic; every file will fail the same way.
Compilers call this a fatal diagnostic, distinct from an error: it aborts by
design.
Errors whose cause is a prior error. Collecting these is the failure mode of recovery, and it is why C++ users learn to read only the first message. Your rule of thumb: a diagnostic derived from a poisoned value is suppressed unless it names a defect the user could fix independently.
Report every independent defect once. Report no consequence of one.
Which is a testable property, and it belongs in Lesson 4's frame: a recipe with n independent seeded defects should produce exactly n diagnostics. That test is what stops the recovery from rotting.