DEV Community

Avery Lin
Avery Lin

Posted on

Compile Exception Catalogs From AST; Humans Sign Retry, Loss, and Dates

Exception reference pages go stale when authors rewrite class names by hand after every refactor. A more durable pipeline compiles the catalog from AST, then treats prose as a separate lane. Models may draft unsigned explanations for those symbols, but they must not invent retry policy, data-loss outcomes, or calendar promises. Those three claims need a signed sidecar in version control, or the documentation merge gate should fail.

The failure mode this workflow targets

Public APIs accumulate exception classes faster than documentation reviews can rename them. Tutorial writers then paste those class names into Markdown, and the next rename leaves broken references behind. Generated chat copy makes the drift worse when it infers retryability from the English word "timeout" without reading tests. The published page looks complete while encoding promises the service never made.

This article does not classify arbitrary documentation paragraphs, and it does not compile API example tables from fixtures. It extracts exception symbols, drafts one clearly unsigned explanation field, and blocks merge when forbidden promise language appears without a human sidecar. The reusable artifact is a Python extractor, a lexicon gate, a signed YAML join, and a pytest plan that can run locally.

The compiler should read source through the standard ast module rather than from chat output. Reviewers then argue about retry and loss, not about whether InvoiceLockedError still exists.

Ownership matrix

Treat every field as compiled, drafted, or signed before anyone opens a pull request. Compiled fields come only from parseable source and must drift when code drifts. Drafted fields may come from a model and must keep an explicit unsigned status. Signed fields require a human-edited sidecar that CI can join back to compiled symbols.

Field Lane Allowed source Merge rule
qualname compiled AST class def fail if missing from source
module compiled AST module path fail on drift
bases compiled AST bases informational
http_status compiled or signed decorator or mapping file fail if inferred in prose only
explanation drafted model or human must carry draft_status
retryable signed sidecar YAML fail if present only in draft
data_loss signed sidecar YAML fail if model-authored
supported_until signed sidecar YAML fail on date-like strings in draft
customer_sla signed or omit sidecar YAML forbidden in generated files

Keep this matrix beside the extractor so reviewers do not renegotiate ownership inside pull-request comments. If a field has no owner, omit it from the published page instead of asking a model to complete the row.

Pipeline

The pipeline has five mechanical steps that write artifacts to disk. None of those steps should publish Markdown until the gate returns zero.

1. Extract exception classes from public packages

Walk only the packages you mark public, and skip tests, examples, and vendor trees. Otherwise the catalog absorbs fixture exceptions and becomes a junk drawer. The script below is a local, reproducible compiler; it is not a production service.

# extract_exceptions.py
from __future__ import annotations

import ast
import json
from pathlib import Path

PUBLIC_ROOTS = ("src/payments",)


class ExceptionCollector(ast.NodeVisitor):
    def __init__(self, module: str) -> None:
        self.module = module
        self.rows: list[dict] = []

    def visit_ClassDef(self, node: ast.ClassDef) -> None:
        bases = [ast.unparse(b) for b in node.bases]
        looks_like_error = node.name.endswith(("Error", "Exception"))
        inherits_error = any("Exception" in b or "Error" in b for b in bases)
        if looks_like_error or inherits_error:
            self.rows.append(
                {
                    "qualname": node.name,
                    "module": self.module,
                    "bases": bases,
                    "lineno": node.lineno,
                }
            )
        self.generic_visit(node)


def collect(root: Path) -> list[dict]:
    rows: list[dict] = []
    for path in root.rglob("*.py"):
        rel = path.relative_to(root)
        if any(part in {"tests", "examples", "vendor"} for part in rel.parts):
            continue
        module = ".".join((root.name, *rel.with_suffix("").parts))
        tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
        visitor = ExceptionCollector(module)
        visitor.visit(tree)
        rows.extend(visitor.rows)
    return sorted(rows, key=lambda r: (r["module"], r["qualname"]))


if __name__ == "__main__":
    catalog: list[dict] = []
    for public in PUBLIC_ROOTS:
        catalog.extend(collect(Path(public)))
    Path("generated/exceptions.json").parent.mkdir(parents=True, exist_ok=True)
    Path("generated/exceptions.json").write_text(
        json.dumps(catalog, indent=2) + "\n", encoding="utf-8"
    )
Enter fullscreen mode Exit fullscreen mode

This script must not call a model, and it must not emit customer-facing sentences. If a class disappears from AST output, generated docs should drop it in the same commit.

2. Reserve a drafted explanation file with explicit status

Copy compiled symbols into a second document that holds only unsigned prose. Every row starts as draft_status: unsigned so later gates can distinguish compilation from narration. Empty explanations are valid; smuggled signed keys are not.

# generated/exception_explanations.draft.yaml
- qualname: InvoiceLockedError
  module: payments.ledger
  draft_status: unsigned
  explanation: ""
Enter fullscreen mode Exit fullscreen mode

A model may fill explanation and nothing else. If the model adds retryable, data_loss, or a calendar date, the file is already invalid.

3. Draft explanations as unsigned narration

The drafting step is optional for teams that already maintain one-line exception summaries by hand. Those teams can skip generation entirely and still run the compiler plus the merge gate. Teams that want a first pass can fill empty explanation fields from qualname, module, and bases only.

MonkeyCode's free model access can perform that narrow draft step from the compiled JSON, without becoming the catalog's source of truth. Disclosure: This article was prepared as part of MonkeyCode's product outreach. The same product's free server option can run the extractor and the merge gate as a scheduled job, away from production request paths. Do not paste secrets, customer payloads, or incident timelines into the draft prompt. The prompt below is a labeled template rather than an executed evaluation or benchmark.

# prompt.template.txt (proposal; not a production prompt)
You receive JSON exception symbols compiled from AST.
Write one explanation sentence per symbol.
Use only the qualname, module, and bases fields.
Do not state retryability, data loss, uptime, support end dates, or refunds.
Do not add keys other than explanation.
Leave draft_status as unsigned.
Enter fullscreen mode Exit fullscreen mode

Reviewers should treat causal language in this file as speculative until a human edits it. Completeness is not a virtue here; a short unsigned sentence beats a fluent invented policy.

4. Keep a human sidecar for promises

Create docs/exceptions.signed.yaml and restrict edits to people who own the public API. The signed file is the only place retry, loss, and dates may appear. Absence of a signed row is allowed and simply means the published page shows the compiled name plus any unsigned explanation.

# docs/exceptions.signed.yaml
- qualname: InvoiceLockedError
  module: payments.ledger
  retryable: false
  data_loss: none
  supported_until: null
  http_status: 409
  signed_by: api-owners
  notes: "Caller must wait for the existing invoice workflow; do not retry with a new idempotency key."
Enter fullscreen mode Exit fullscreen mode

What remains invalid is a date or SLA phrase that exists solely inside generated Markdown. A null supported_until is clearer than a model guessing next quarter.

5. Gate forbidden promise language before merge

Scan generated files for a small lexicon of guarantee and calendar language. The list is conservative on purpose, because models insert those phrases when they try to sound finished. Pair the scan with a join test so renamed exceptions cannot keep stale signed promises.

# gate_exception_docs.py
from __future__ import annotations

import re
import sys
from pathlib import Path

FORBIDDEN = re.compile(
    r"(?i)\b("
    r"sla|we guarantee|guaranteed|99\.\d+|supported until|"
    r"will always|will never|never lose|no data loss|"
    r"refund|uptime|forever"
    r")\b"
)

GENERATED_GLOBS = (
    "generated/exception_explanations.draft.yaml",
    "generated/exceptions.md",
)


def main() -> int:
    failed = False
    for glob in GENERATED_GLOBS:
        path = Path(glob)
        if not path.exists():
            continue
        for i, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
            if FORBIDDEN.search(line):
                print(f"{path}:{i}: unsigned promise language: {line.strip()}")
                failed = True
    return 1 if failed else 0


if __name__ == "__main__":
    sys.exit(main())
Enter fullscreen mode Exit fullscreen mode
# tests/test_exception_catalog.py
import json
from pathlib import Path

import yaml  # PyYAML in the docs CI image


def test_compiled_symbols_are_unique():
    rows = json.loads(Path("generated/exceptions.json").read_text(encoding="utf-8"))
    keys = [(r["module"], r["qualname"]) for r in rows]
    assert keys == sorted(set(keys)), "duplicate exception symbols"


def test_signed_rows_reference_existing_symbols():
    compiled = {
        (r["module"], r["qualname"])
        for r in json.loads(Path("generated/exceptions.json").read_text(encoding="utf-8"))
    }
    signed = yaml.safe_load(Path("docs/exceptions.signed.yaml").read_text(encoding="utf-8")) or []
    missing = [
        (r["module"], r["qualname"])
        for r in signed
        if (r["module"], r["qualname"]) not in compiled
    ]
    assert missing == [], f"signed sidecar references missing symbols: {missing}"


def test_draft_explanations_do_not_add_signed_keys():
    draft = yaml.safe_load(
        Path("generated/exception_explanations.draft.yaml").read_text(encoding="utf-8")
    ) or []
    banned = {"retryable", "data_loss", "supported_until", "customer_sla", "http_status"}
    for row in draft:
        overlap = banned.intersection(row)
        assert not overlap, f"draft row smuggled signed keys: {overlap}"
Enter fullscreen mode Exit fullscreen mode

A local reproduction sequence is extractor, lexicon gate, then the join tests, in that order:

python extract_exceptions.py
python gate_exception_docs.py
pytest tests/test_exception_catalog.py
Enter fullscreen mode Exit fullscreen mode

What the model may draft versus what a human must own

The model may restate the symbol in plain language, mention the base class, and remind the reader that the explanation is unsigned. It may also raise a reviewer question, such as whether middleware maps the class to HTTP 409. It may not answer that question inside generated files, because inferred status codes become contract text once they render.

A human must own retry classification, because retry changes idempotency keys and charge behavior. A human must own data-loss language, because "safe to retry" is not the same as "the write never reached storage." A human must own any calendar, including deprecation and support-end dates, because those sentences become commitments once they ship in product docs. If the team cannot staff that review, omit the fields rather than asking a model to guess.

Limitations

AST collection misses exceptions built through type() factories, __getattr__ shims, and RPC stubs generated at install time. Name heuristics that match *Error will over-include internal helpers when public roots are drawn too broadly. HTTP status codes often live in framework middleware rather than on the exception class, so compiling them needs a second explicit mapping file. The lexicon gate is a tripwire rather than a legal review, and it will both false-positive on quoted error strings and false-negative on novel promise phrasing. Draft explanations can still be wrong about causes even when they avoid every forbidden token.

This workflow also assumes exception classes are part of the public contract. Services that return only opaque string codes on the wire need a different compiler, because AST class names will not match payloads.

Who should not use this approach

Do not adopt this pipeline for incident postmortems, status-page copy, or pricing pages, because those documents are not exception catalogs. Do not use it as a substitute for security-advisory process when an exception indicates a vulnerability. Skip it if the library exposes only a handful of public errors and the gate costs more than a manually signed page. Skip it if reviewers will rubber-stamp the sidecar, because a date with a human name attached is still false when nobody checked the calendar.

Closing

Compile names from source, draft explanations as unsigned narration, and keep retry, loss, and dates in a sidecar that tests can join. The useful output is not a longer Markdown file. The useful output is a merge gate that refuses promises nobody signed.

Top comments (0)