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"
)
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: ""
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.
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."
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())
# 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}"
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
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)