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.

Exceptions are bind. 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:

RecoveryContinue past a failure by inventing a stand-in for the value that could not be produced.
Error node / poisonThe 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 pointThe 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 suppressionOnce 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.

defectP1 fail-fastP2 collectP3 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.

Raise once, at the edge. Values inside, exception at the boundary: the library functions return diagnostics, and 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.


status · resources · mission