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:

glyph check --agent --no-tsc src
{
  "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.

glyph fix src, on the match above
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 src, on `match r { Ok(v) => v, }`
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.

glyph --explain E0200 --json
{
  "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.