Generated documentation remains useful only when models draft descriptive text and humans own every contract sentence. Models can summarize public names, parameter labels, and return types that an AST already proved. They should not invent exception lists, filesystem effects, thread-safety claims, or silent compatibility windows. This article compiles an atlas from Python modules, drafts overviews from that atlas, and freezes unsigned contract language in CI.
Separate descriptive lanes from contract lanes
Most documentation failures are not missing adjectives but invented obligations that no reviewer actually verified. A generated paragraph that restates a function name is cheap to review and cheap to regenerate after a rename. A generated paragraph that claims never-raises behavior or atomic writes becomes a support ticket when production disagrees. Treat those two classes of sentences as different artifacts that require different owners and different merge rules.
Descriptive lanes may be drafted from a machine-readable atlas after the compiler has already listed public symbols. Contract lanes must be typed by a reviewer who can cite tests, tickets, or recorded runtime evidence. Mixing both lanes in one README invites silent drift because regenerating the file overwrites human promises without a diff cue. Keep the atlas, the overview draft, and the signed contract in three files with a merge step that refuses unsigned claims.
Ownership matrix for each sentence class
The table below is the control surface for the rest of the pipeline. Compile rows are mechanical. Signature rows are not optional commentary.
| Sentence class | Source of truth | May a model draft it? | Merge rule |
|---|---|---|---|
| Public symbol list | AST extract | No draft needed; compile only | Fail if docs mention extra names |
| Parameter labels and defaults | AST extract | Optional one-line restatement | Fail if a default disagrees with the atlas |
| Module overview | Atlas JSON only | Yes, labeled as draft | Human may edit tone, not add facts |
| Exception paths | Tests or explicit contract file | No | Require a signed_by field |
| I/O, network, and process effects | Reviewer evidence | No | Require a signed_by field |
| Compatibility and deprecation windows | Release process | No | Require signed_by plus an ISO date |
| Thread safety and reentrancy | Reviewer evidence | No | Require a signed_by field |
| Security and secret handling | Reviewer evidence | No | Block merge while the row is UNSIGNED
|
Teams that skip the last four rows are generating brochure copy rather than documentation a support engineer can defend. The matrix also explains why dumping a repository into a chat window is the wrong first step. Chat output cannot distinguish a name that the AST proved from a compatibility window that only a release owner can promise.
Step 1: Compile a public-symbol atlas from AST
Do not start by asking a model for a README of the package. Compile a narrow facts file first, then decide which sentences are even eligible for drafting. The script below walks one package, keeps public functions and classes, and writes JSON that later stages may read without importing runtime code.
# tools/compile_atlas.py — proposal example, not a published package.
from __future__ import annotations
import ast
import json
import sys
from pathlib import Path
from typing import Any
def _ann(node: ast.AST | None) -> str | None:
if node is None:
return None
return ast.unparse(node)
def _args(fn: ast.FunctionDef | ast.AsyncFunctionDef) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
positional = list(fn.args.args) + list(fn.args.kwonlyargs)
for arg in positional:
if arg.arg in {"self", "cls"}:
continue
out.append(
{
"name": arg.arg,
"annotation": _ann(arg.annotation),
"kind": "param",
}
)
if fn.args.vararg:
out.append({"name": fn.args.vararg.arg, "annotation": _ann(fn.args.vararg.annotation), "kind": "vararg"})
if fn.args.kwarg:
out.append({"name": fn.args.kwarg.arg, "annotation": _ann(fn.args.kwarg.annotation), "kind": "kwarg"})
return out
def _is_public(name: str, dunder_all: set[str] | None) -> bool:
if dunder_all is not None:
return name in dunder_all
return not name.startswith("_")
def compile_module(path: Path, module_name: str) -> dict[str, Any]:
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
dunder_all = None
for node in tree.body:
if isinstance(node, ast.Assign):
for target in node.targets:
if isinstance(target, ast.Name) and target.id == "__all__" and isinstance(node.value, ast.List):
dunder_all = {
elt.value for elt in node.value.elts if isinstance(elt, ast.Constant) and isinstance(elt.value, str)
}
symbols: list[dict[str, Any]] = []
for node in tree.body:
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and _is_public(node.name, dunder_all):
symbols.append(
{
"qualname": f"{module_name}.{node.name}",
"kind": "function",
"args": _args(node),
"returns": _ann(node.returns),
"is_async": isinstance(node, ast.AsyncFunctionDef),
}
)
elif isinstance(node, ast.ClassDef) and _is_public(node.name, dunder_all):
methods = []
for child in node.body:
if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)) and _is_public(child.name, None):
methods.append(
{
"qualname": f"{module_name}.{node.name}.{child.name}",
"kind": "method",
"args": _args(child),
"returns": _ann(child.returns),
"is_async": isinstance(child, ast.AsyncFunctionDef),
}
)
symbols.append({"qualname": f"{module_name}.{node.name}", "kind": "class", "methods": methods})
return {"module": module_name, "path": str(path).replace("\\", "/"), "symbols": symbols}
def compile_package(root: Path) -> dict[str, Any]:
packages: list[dict[str, Any]] = []
for py in sorted(root.rglob("*.py")):
if py.name.startswith("_"):
continue
rel = py.relative_to(root.parent)
mod = ".".join(rel.with_suffix("").parts)
packages.append(compile_module(py, mod))
if not packages:
raise SystemExit("atlas compiler refused an empty package path")
return {"root": str(root).replace("\\", "/"), "modules": packages}
if __name__ == "__main__":
package = Path(sys.argv[1])
json.dump(compile_package(package), sys.stdout, indent=2)
sys.stdout.write("\n")
Run the compiler as a facts step, not as an authoring step. The command writes docs/atlas.json and belongs in CI so the atlas cannot drift from the tree under review.
python tools/compile_atlas.py src/billing > docs/atlas.json
The atlas contains names, parameter labels, and return annotations only. It does not contain exception lists, because a Python AST does not prove which exceptions a function body raises. That absence is the design: later draft jobs cannot copy obligations that were never extracted from source.
- Point the compiler at one package path that the project already treats as public API.
- Reject underscore-prefixed names unless those names appear explicitly inside
__all__. - Record annotation strings without evaluating modules, so atlas compilation stays free of import side effects.
- Fail the job when the package path is empty, because an empty atlas looks like a successful no-op.
- Keep
docs/atlas.jsonbeside source so reviewers can diff public names across pull requests.
Step 2: Draft overviews from the atlas file only
Overview paragraphs may be drafted only after the atlas exists on disk. The draft job should receive JSON, not source files, not git history, and not a prior README. A constrained prompt is a proposal for keeping the model inside the descriptive lane.
Proposal prompt (unexecuted example, not a captured production trace):
You receive docs/atlas.json only. Write one short overview paragraph per module.
Restate public names, parameter labels, and return annotations already present.
Do not mention exceptions, files, sockets, processes, threads, versions, deprecations,
guarantees, security, or secrets. If a fact is absent from the JSON, write NOTHING
about it. Prefix the file with: DRAFT atlas_sha256=<hash>
Disclosure: This article was prepared as part of MonkeyCode's product outreach. A drafting pass can use MonkeyCode's free model access when the only input is docs/atlas.json, and the extract plus lint jobs can use the free server option so the atlas compiler does not need a separate always-on worker. Those availability options do not move exception lists, I/O claims, or compatibility windows into the model. Unsigned contract sentences still fail the merge gate in Step 4.
Save model output to docs/overview.draft.md and keep the DRAFT header until a reviewer accepts tone. Never merge that draft over docs/contracts.yaml. Hash the atlas and copy the digest into the draft header so a regenerated overview cannot pretend it matches a newer tree.
- Compute a SHA-256 digest of
docs/atlas.jsonand copy that digest into the draft header. - Reject any draft that mentions files, sockets, exceptions, versions, or guaranteed behavior.
- Store the draft beside the atlas rather than inside the human-owned contract file.
- Allow a reviewer to edit wording, then copy accepted overviews into
docs/overview.md.
A cheap vocabulary scan catches the obvious leaks before a human reads tone. It is not a substitute for the contract file; it only keeps descriptive drafts from smuggling obligations.
# tools/scan_overview_draft.py — proposal example.
import re
import sys
from pathlib import Path
BANNED = re.compile(
r"\b(raises?|thrown?|exception|guaranteed|atomically|thread[- ]safe|"
r"compatible|deprecat(?:ed|ion)|socket|subprocess|secret|password|token|"
r"never fails|writes to)\b",
re.I,
)
text = Path(sys.argv[1]).read_text(encoding="utf-8")
if not text.startswith("DRAFT atlas_sha256="):
raise SystemExit("overview draft missing atlas hash header")
hits = [f"L{i}: {line.strip()}" for i, line in enumerate(text.splitlines(), 1) if BANNED.search(line)]
if hits:
raise SystemExit("contract vocabulary in overview draft:\n" + "\n".join(hits))
print("overview draft stayed in the descriptive lane")
Step 3: Hand-write the contract file
Humans own exception paths, I/O effects, compatibility windows, and concurrency claims. Store those claims in YAML so CI can parse signatures without scraping prose. Each record must point at a fully qualified name that already exists in the atlas, which stops reviewers from signing ghosts.
# docs/contracts.yaml — human-owned; models must not write this file.
version: 1
records:
- qualname: billing.ledger.open_batch
raises:
- BillingConflict
- FrozenPeriodError
io_effects:
- "appends rows to the ledger table; does not write object storage"
thread_safety: "not safe to share one Batch across threads"
compat:
since: "2026-04-01"
until: null
notes: "open_batch remains the supported entry point through 2026"
signed_by: "alex.r"
signed_on: "2026-09-18"
evidence: "tests/test_ledger_conflicts.py::test_frozen_period"
- qualname: billing.ledger.purge_expired
raises: []
io_effects: []
thread_safety: ""
compat:
since: ""
until: ""
notes: ""
signed_by: "UNSIGNED"
signed_on: ""
evidence: ""
The second record is what a stubber should emit. It is not a completed document. An UNSIGNED row is a visible hole, which is more honest than a fluent paragraph that invented a purge behavior.
- Emit a stub contract with
UNSIGNEDfor every public name that still lacks a record. - Require a named reviewer to replace
UNSIGNEDafter citing a test module or a ticket. - Forbid draft tools from writing
signed_by,io_effects,raises, orcompatkeys. - Delete contract rows when the atlas no longer lists the symbol, so dead promises cannot linger.
A stub generator can add holes without drafting claims. That is the only automated write allowed against the contract file.
# tools/stub_contracts.py — proposal example.
from __future__ import annotations
import json
import sys
from pathlib import Path
import yaml
EMPTY = {
"raises": [],
"io_effects": [],
"thread_safety": "",
"compat": {"since": "", "until": "", "notes": ""},
"signed_by": "UNSIGNED",
"signed_on": "",
"evidence": "",
}
atlas = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
path = Path(sys.argv[2])
data = yaml.safe_load(path.read_text(encoding="utf-8")) if path.exists() else {"version": 1, "records": []}
known = {row["qualname"]: row for row in data.get("records", [])}
wanted: list[str] = []
for module in atlas["modules"]:
for symbol in module["symbols"]:
wanted.append(symbol["qualname"])
for method in symbol.get("methods", []):
wanted.append(method["qualname"])
records = []
for name in wanted:
row = known.get(name, {"qualname": name, **EMPTY})
records.append(row)
stale = sorted(set(known) - set(wanted))
data["records"] = records
path.write_text(yaml.safe_dump(data, sort_keys=False), encoding="utf-8")
if stale:
print("removed stale contract rows:", ", ".join(stale))
Step 4: Freeze unsigned contract language in CI
The merge gate needs three independent checks rather than one subjective docs review. First, every public atlas name has a contract row. Second, no contract row still says UNSIGNED. Third, overview drafts still lack contract vocabulary after regeneration.
# tools/lint_docs_ownership.py — proposal example.
from __future__ import annotations
import json
import sys
from pathlib import Path
import yaml
atlas = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
contracts = yaml.safe_load(Path(sys.argv[2]).read_text(encoding="utf-8"))
wanted: set[str] = set()
for module in atlas["modules"]:
for symbol in module["symbols"]:
wanted.add(symbol["qualname"])
wanted.update(m["qualname"] for m in symbol.get("methods", []))
rows = {row["qualname"]: row for row in contracts.get("records", [])}
missing = sorted(wanted - set(rows))
extra = sorted(set(rows) - wanted)
unsigned = sorted(name for name, row in rows.items() if row.get("signed_by") == "UNSIGNED")
incomplete = []
for name, row in rows.items():
if row.get("signed_by") == "UNSIGNED":
continue
if not row.get("evidence") or not row.get("signed_on"):
incomplete.append(name)
if not row.get("compat", {}).get("since"):
incomplete.append(name)
errors = []
if missing:
errors.append("atlas names without contracts: " + ", ".join(missing))
if extra:
errors.append("contracts for names absent from atlas: " + ", ".join(extra))
if unsigned:
errors.append("UNSIGNED contracts: " + ", ".join(unsigned))
if incomplete:
errors.append("signed rows missing evidence or compat.since: " + ", ".join(sorted(set(incomplete))))
if errors:
raise SystemExit("\n".join(errors))
print("docs ownership gate passed")
Wire the four commands in the same CI job so a regenerated overview cannot skip the atlas that produced it.
python tools/compile_atlas.py src/billing > docs/atlas.json
python tools/stub_contracts.py docs/atlas.json docs/contracts.yaml
python tools/scan_overview_draft.py docs/overview.draft.md
python tools/lint_docs_ownership.py docs/atlas.json docs/contracts.yaml
- Compile the atlas from the pull-request tree, not from a cached file left on the runner.
- Run the stubber so missing rows appear as
UNSIGNEDholes instead of silent omissions. - Scan the overview draft for banned contract tokens before anyone debates writing style.
- Block merge when any public name remains unsigned, even if the overview prose already sounds finished.
What this pipeline does not prove
The atlas proves names and annotations present in source, not the behavior those functions exhibit at runtime. Signed contracts prove a reviewer accepted a claim on a date, not that production still matches the claim after the next deploy. Overview drafts prove only that a model produced prose from JSON under a vocabulary constraint. Exception tests, integration checks for I/O, and a release calendar for compatibility dates remain mandatory outside this pipeline.
Do not use this workflow when the public surface changes faster than reviewers can sign rows with evidence. Do not use it for exploratory notebooks where no symbol carries a support obligation. Do not use it as a substitute for type checkers or schema files, which already own machine-readable shapes more strictly than Markdown. Skip it for one-off internal scripts that will never be quoted by customers or by on-call engineers.
If a library currently documents only happy-path overviews, the linter will look noisy until contract rows exist. That noise is cheaper than a generated never-raises sentence that a customer later quotes in a severity ticket. Keep descriptive drafting attached to the atlas, and keep the signature lane dated, cited, and slower than the model.
Top comments (0)