17 · Fixing a red build
When the build fails, does the compiler tell my agent how to fix it?
It tells you what is wrong as data, not as a sentence to parse: the two types, the
symbol at fault as a module::name, and the finite list of things that
could legally stand where the wrong thing stands. glyph check --agent
adds what the repairing edit must not break. Where the answer is fully determined,
glyph fix writes it.
Why it matters
An agent's loop around a compiler is read the error, guess the edit, rebuild. The
guessing step is where the damage happens, and it happens because the error is a
string. An agent that reads "non-exhaustive match on OrderStatus"
knows a match is short some arms. It does not know what the missing variants
carry, so it writes Paid("tx_1") and finds out from the next
diagnostic. Or it reads the word "exhaustive", adds an else arm, and
the build goes green having thrown away the guarantee that the diagnostic existed
to protect.
The compiler knew all of it. It knew the union's variants and their payloads, it
knew an else would forfeit the seal, and it dropped both one line
before the reply.
See it
A two-module project. orders declares the union;
main writes a match over it and handles one case.
glyph check --agent, trimmed to the keys that matter here:
{
"code": "E0200",
"severity": "error",
"stage": "typecheck",
"message": "non-exhaustive match on `OrderStatus`: missing variants
`Paid`, `Cancelled`",
"help": "Add an arm for each missing variant, or an `else` arm to
catch the rest.",
"file": "main.glyph",
"module": "main",
"entity": "main::describe",
"cause": "orders::OrderStatus",
"related": ["Pending", "Paid", "Cancelled"],
"missing_variants": ["Paid", "Cancelled"],
"expected": null, "actual": null, "alternatives": null,
"constraints": [
"preserve exhaustiveness: this match must name every case of
`OrderStatus`, so add one arm for each of `Paid`, `Cancelled`.",
"do not add an `else` arm: `OrderStatus` is declared in this project,
and a catch-all forfeits the guarantee that adding a variant to it
later forces this match to be updated (D9)."
],
"symbols": [
{ "entity": "orders::OrderStatus", "kind": "union", "pub": true,
"type": "Pending | Paid({ transaction_id: string }) | Cancelled",
"exhaustive_match": true,
"variants": [
{ "name": "Pending", "payload": null, "construct": "Pending" },
{ "name": "Paid", "payload": "{ transaction_id: string }",
"construct": "Paid({ transaction_id: string })" },
{ "name": "Cancelled", "payload": null, "construct": "Cancelled" }
] }
],
"symbols_absent": [],
"explain": { "command": "glyph --explain E0200",
"docs": "https://github.com/chadetov/glyph/blob/main/docs/error-codes.md#e0200" }
}
Three things are worth pulling out. cause is the symbol at fault, and
it is a different thing from entity, which is the declaration the
diagnostic sits in: here the edit happens in main::describe and the
shape it has to match comes from orders::OrderStatus. Both are
addresses the other tools take, so an answer chains without re-deriving anything.
constraints are not the repair. Each one is something the compiler
already enforces, written out so the edit that satisfies the diagnostic does not
break the guarantee behind it. The second constraint there is the whole point of
the key: help offers the else arm because it is legal, and
the constraint says what taking it costs.
symbols is the glyph_symbol answer for every symbol the
diagnostic names, so the agent's next edit needs no second call. A symbol the tool
refuses appears in symbols_absent with the reason, which keeps "we did
not look" apart from "we looked and it is not there".
Every key is always present. A fact the compiler does not hold arrives as an
explicit null, never as a missing key, so absence has one spelling on
every code and on both the CLI and the MCP surface.
Where the answer is determined, the compiler writes it
glyph fix applies only the repairs the compiler settles on its own:
unused imports, the missing arms of a non-exhaustive match, an arm head
that is not a variant when the checker computed exactly one suggestion, and a field
the record does not declare when exactly one declared field is a character away.
fixed src/main.glyph
glyph fix: removed 0 unused import(s) across 1 file(s).
glyph fix: applied E0200 in src/main.glyph: added 2 arm(s) to the match
on `OrderStatus`: `Paid`, `Cancelled`, and wrote
`import std/process` for the arm bodies and `Paid`,
`Cancelled` into `import orders`
import orders { OrderStatus, Pending, Paid, Cancelled }
...
return match s {
Pending => "waiting",
// TODO(glyph fix): `OrderStatus` variant `Paid` is unhandled; write this arm
Paid({ transaction_id }) => {
print("unhandled OrderStatus variant Paid (arm written by glyph fix)")
process.exit(1)
},
// TODO(glyph fix): `OrderStatus` variant `Cancelled` is unhandled; write this arm
Cancelled => {
print("unhandled OrderStatus variant Cancelled (arm written by glyph fix)")
process.exit(1)
},
}
// glyph check --no-tsc: 2 module(s) checked, no diagnostics.
OrderStatus is declared in orders and matched in
main, so the arms name variants this file did not have in scope. The
rule writes them into the import list, and where the module is imported as a
namespace it writes the patterns through the binding instead
(orders.Paid({ transaction_id })). Every byte it writes is named in
the report, the imports included.
The pattern comes from the declaration, so a record payload is destructured by
field name and the parts are in front of whoever writes the body. The body leaves
through process.exit for a reason that is not arbitrary: it returns
never, which contributes nothing to the arm join, so the same text is
legal whether the match owes a value or not and the rule needs no guess
about what the arm should produce. The alternative anyone would reach for first was
measured instead of argued about: assert(false, "unhandled") in a
value-position arm returning string fails tsc with
TS2322: Type 'void' is not assignable to type 'string'. A repair that
swaps one error for another is not a repair.
A match over the prelude Result or Option used to
be the one shape this rule declined, and it is the shape a real program writes most
often. Those declarations have an address now, std/result::Result, which is
the module the resolver registers them under and the module the emitter writes the
import from, so the rule can read the payload shapes it writes patterns from.
glyph fix: applied E0200 in src/main.glyph: added 1 arm(s) to the match
on `Result`: `Err`, and wrote `import std/process` for the
arm bodies
return match r {
Ok(v) => v,
// TODO(glyph fix): `Result` variant `Err` is unhandled; write this arm
Err(payload) => {
print("unhandled Result variant Err (arm written by glyph fix)")
process.exit(1)
},
}
// glyph check, with tsc: 1 module(s) checked, no diagnostics.
No import is written for the variants, because the prelude already binds Ok
and Err. The std/process import is the one every
E0200 repair adds for the TODO body.
These arms compile, and they are written to be replaced. Run glyph fix,
then fill in the bodies it marked. What it will not do is guess, so every rule that
cannot settle an answer prints the decline and the reason on the same output as what
it applied. Two fields a character away from a typo are two, not one, and it says so
rather than picking.
The code's own explanation, as data
glyph --explain <CODE> --json answers with the long-form
explanation, the one-line help, the docs URL, and a wrong program from
the compiler's negative corpus paired with the diagnostic this compiler draws for
it. The diagnostic is compiled when you ask, not stored, so it cannot drift from
what the compiler actually says.
{
"code": "E0200",
"title": "non-exhaustive match",
"explanation": "A `match` over a tagged union must handle every variant.
Unions are sealed (D9): adding a variant later forces every match to be
updated, so a missing variant cannot silently fall through at runtime ...",
"help": "Add an arm for each missing variant, or an `else` arm to catch the rest.",
"docs": "https://github.com/chadetov/glyph/blob/main/docs/error-codes.md#e0200",
"counter_example": {
"name": "alias_of_local_union_not_exhaustive",
"expected_error": "E0200",
"files": [ { "path": "alias_of_local_union_not_exhaustive.glyph",
"source": "module main\n\ntype Shape =\n | Circle\n ..." } ],
"diagnostic": { "code": "E0200",
"message": "non-exhaustive match on `Shape`: missing variants `Tri`",
"cause": "alias_of_local_union_not_exhaustive::Shape",
"related": ["Circle", "Square", "Tri"],
"missing_variants": ["Tri"] },
"corrected": null,
"corrected_absent": "`tests/negative/` pairs no repaired program with a case:
an entry is a wrong program and the code it draws. A fix invented here would
compile nowhere and be checked by nothing."
}
}
A code the corpus does not cover comes back with counter_example: null
and a sentence saying the corpus holds no case for it, rather than a made-up
example. The corpus claims what it covers and nothing about the rest.
glyph --explain hands over one case per code. The whole corpus is readable
too: glyph llms --negative E0200 prints every wrong program that draws the
code, each compiled when you ask, and where a second corpus pairs a program
tsc --strict accepts with the Glyph that refuses it, that comes back beside
it. With no code it lists 68 cases over 40 of the 61 codes and names the 21 that have
none, so coverage is stated rather than inferred from what came back. That and the rest
of what the compiler publishes about itself is
its own page.
Where it stands
Shipping today
Every diagnostic carries expected, actual, cause, alternatives, related, module, file and explain, populated from the error the compiler raised rather than re-derived, and each is an explicit null when the compiler holds no such fact. file is a path relative to the resolution root, and the report names that root in project_root, so it opens. glyph check --agent adds constraints and symbols per diagnostic. glyph fix applies four rules and states every decline with its reason. A match over the prelude Result or Option is one of them now: those declarations key to the stdlib module that declares them, std/result::Result, so the rule can read the payload shapes it writes patterns from, and it writes no import for the variants, because the prelude already binds Ok and Err. The arm bodies still bring in std/process, the way every E0200 repair does. glyph --explain <CODE> --json returns the explanation with a compiled counter-example, and glyph llms --negative <CODE> returns the whole corpus of wrong programs that draw it.
Where it stops
cause on E0211 is null: the symbol at fault at a call argument is a parameter, and module::fn.param is not something the identity vocabulary can spell, so the null is deliberate rather than an omission. corrected on a counter-example is always null, because the negative corpus pairs no repaired program with a case, and 21 of the 61 codes have no case at all. glyph fix still writes four rules and nothing else; everything it declines, it declines out loud.