DEV Community

Avery Lin
Avery Lin

Posted on

Extract Module Reference Pages From AST, Then Fail Builds on Unsigned Claim Verbs

Generated reference pages can list every public callable without ever being allowed to promise behavior the repository cannot prove. That split is the entire workflow: compile signatures, names, and fixture-backed shapes from source, then reject operational language that no human signed. Teams that skip the second gate publish docs that read complete while remaining legally and operationally unsigned. The rest of this article is a proposed, runnable pipeline for that gate, not a report of production outcomes.

Why structural generation and claim ownership diverge

Model-written documentation usually fails in a predictable place rather than inside the generated function table itself. Signatures, parameter names, and raised exception classes are already in the abstract syntax tree, so they can be regenerated on every commit. Compatibility windows, retention rules, threat models, and rate limits are not AST facts, and treating them as draftable prose creates unsigned operational claims. A useful pipeline therefore forbids mixed paragraphs: generated files may contain tables, while owned files may contain guarantees.

Public callables change when code changes, which makes them a stable compile input rather than a writing task. Operational claims change when policy, contracts, or incident review change, which makes them a review task rather than a generation task. Mixing those cadences in one Markdown file is the usual failure, because a later model draft will happily rewrite a sentence the legal or SRE owner never re-approved. The artifact below keeps those cadences in different paths and fails the build when they leak.

What a model may draft versus what a human must own

The following decision table is a proposed control, not an observed production metric or a vendor benchmark. Use it to decide which bytes may be overwritten by a generator and which bytes require a named human owner on the pull request.

Lane Allowed inputs Allowed outputs Human owner must supply
Structural draft Public AST, type hints, exception names, pytest node ids Module index, parameter tables, example headings Nothing beyond code review of the extractor
Owned claims Policy docs, incident notes, contract text Compatibility, auth, retention, rate limits, deprecation Named reviewer plus change ticket
Forbidden mix Chat output merged into either lane SLA sentences, legal adjectives, uptime verbs Reject the file; do not edit in place

A drafting model may rephrase column two of a generated table only when the cells still match the AST extract on the next lint run. It may not invent endpoints, status codes, encryption properties, or customer-facing severity. Humans own every sentence that would still be false if the function were deleted tomorrow. That rule is stricter than “looks accurate,” and it is the point of the pipeline.

Artifact: extractor, ownership manifest, and claim linter

The proposed layout keeps generated pages out of the owned tree so reviewers can ignore churn in docs/generated/ while treating docs/owned/ as source. Place the extractor beside tests so the same checkout can prove both the tables and the denylist. Label the commands unexecuted until you run them against your own tree.

docs/
  generated/          # overwritten by extract_module_docs.py
  owned/
    claims.md         # humans only
    ownership.yaml    # humans only
  forbidden_verbs.txt
scripts/
  extract_module_docs.py
  lint_doc_claims.py
tests/
  test_billing.py
Enter fullscreen mode Exit fullscreen mode

docs/owned/ownership.yaml records sections that generators must not emit. Keep values boring and local; do not paste vendor quotas, hardware, or unpublished product claims into this file.

# docs/owned/ownership.yaml
version: 1
generated_roots:
  - docs/generated/
owned_roots:
  - docs/owned/
required_human_sections:
  - compatibility
  - authentication
  - data_retention
  - rate_limits
  - deprecation
model_may_draft:
  - module_index
  - parameter_tables
  - exception_name_lists
  - pytest_node_id_headings
Enter fullscreen mode Exit fullscreen mode

docs/forbidden_verbs.txt is the build-time denylist for generated files. Expand it from incident language you actually use; the list below is a starter, not a legal dictionary.

guarantee
SLA
99.9
always delivered
never lose
zero downtime
GDPR compliant
end-to-end encrypted
production-ready
indefinitely
PII is never
we promise
Enter fullscreen mode Exit fullscreen mode

The extractor walks one package, skips private names, and writes one Markdown file per module. Example headings come from pytest node ids so the page can point at tests that exist, not at stories a model invented.

# scripts/extract_module_docs.py
from __future__ import annotations

import ast
import sys
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]
SRC = ROOT / "src"
OUT = ROOT / "docs" / "generated"


def public_functions(tree: ast.AST) -> list[ast.FunctionDef]:
    fns: list[ast.FunctionDef] = []
    for node in tree.body:
        if isinstance(node, ast.FunctionDef) and not node.name.startswith("_"):
            fns.append(node)
    return fns


def format_args(fn: ast.FunctionDef) -> str:
    parts = []
    for arg in fn.args.args:
        if arg.arg == "self":
            continue
        hint = ast.unparse(arg.annotation) if arg.annotation else "untyped"
        parts.append(f"| `{arg.arg}` | `{hint}` |")
    return "\n".join(parts) or "| _(none)_ | |


def raised_names(fn: ast.FunctionDef) -> str:
    names = []
    for node in ast.walk(fn):
        if isinstance(node, ast.Raise) and node.exc is not None:
            names.append(ast.unparse(node.exc).split("(")[0])
    return ", ".join(sorted(set(names))) or "_(none found in AST)_"


def pytest_headings(mod_stem: str) -> list[str]:
    headings = []
    for path in (ROOT / "tests").glob("test_*.py"):
        text = path.read_text(encoding="utf-8")
        tree = ast.parse(text)
        for node in tree.body:
            if isinstance(node, ast.FunctionDef) and node.name.startswith("test_"):
                if mod_stem.replace("-", "_") in node.name or mod_stem in text:
                    headings.append(f"`{path.name}::{node.name}`")
    return headings[:12]


def render(mod: Path, tree: ast.Module) -> str:
    lines = [
        f"<!-- generated from {mod.relative_to(ROOT)} ; do not edit -->",
        f"# `{mod.stem}` reference",
        "",
        "This page lists public callables and test node ids only.",
        "Operational guarantees live in `docs/owned/` and are unsigned here.",
        "",
    ]
    for fn in public_functions(tree):
        lines += [
            f"## `{fn.name}`",
            "",
            "| Parameter | Annotation |",
            "| --- | --- |",
            format_args(fn),
            "",
            f"- Return annotation: `{ast.unparse(fn.returns) if fn.returns else 'untyped'}`",
            f"- Raise names found in AST: {raised_names(fn)}",
            "",
        ]
    heads = pytest_headings(mod.stem)
    lines += ["## Fixture-backed example headings", ""]
    if heads:
        lines += [f"- {h}" for h in heads]
    else:
        lines.append("- _(no matching pytest node ids; do not invent examples)_")
    lines.append("")
    return "\n".join(lines)


def main() -> int:
    OUT.mkdir(parents=True, exist_ok=True)
    written = 0
    for mod in sorted(SRC.rglob("*.py")):
        if mod.name.startswith("_"):
            continue
        tree = ast.parse(mod.read_text(encoding="utf-8"))
        target = OUT / f"{mod.stem}.md"
        target.write_text(render(mod, tree), encoding="utf-8")
        written += 1
    print(f"wrote {written} generated pages under {OUT}")
    return 0


if __name__ == "__main__":
    sys.exit(main())
Enter fullscreen mode Exit fullscreen mode

The linter has two jobs: generated files must not contain denylisted verbs, and owned files must still contain the required human sections. Run it after any prose rewrite so a drafting pass cannot smuggle a guarantee back into docs/generated/.

# scripts/lint_doc_claims.py
from __future__ import annotations

import sys
from pathlib import Path

import yaml

ROOT = Path(__file__).resolve().parents[1]
OWN = ROOT / "docs" / "owned" / "ownership.yaml"
VERBS = ROOT / "docs" / "forbidden_verbs.txt"


def main() -> int:
    cfg = yaml.safe_load(OWN.read_text(encoding="utf-8"))
    verbs = [
        line.strip().lower()
        for line in VERBS.read_text(encoding="utf-8").splitlines()
        if line.strip() and not line.startswith("#")
    ]
    errors: list[str] = []

    for rel in cfg["generated_roots"]:
        root = ROOT / rel
        if not root.exists():
            errors.append(f"missing generated root: {rel}")
            continue
        for path in root.rglob("*.md"):
            text = path.read_text(encoding="utf-8").lower()
            for verb in verbs:
                if verb in text:
                    errors.append(f"{path}: unsigned claim verb {verb!r}")

    owned_blob = "\n".join(
        p.read_text(encoding="utf-8").lower()
        for rel in cfg["owned_roots"]
        for p in (ROOT / rel).rglob("*")
        if p.is_file()
    )
    for section in cfg["required_human_sections"]:
        needle = f"## {section}".replace("_", " ")
        if section.replace("_", " ") not in owned_blob and needle not in owned_blob:
            errors.append(f"owned docs missing required section {section!r}")

    for err in errors:
        print(err, file=sys.stderr)
    print(f"claim lint {'failed' if errors else 'passed'} ({len(errors)} issues)")
    return 1 if errors else 0


if __name__ == "__main__":
    sys.exit(main())
Enter fullscreen mode Exit fullscreen mode

Proposed local commands, labeled unexecuted until you run them in your checkout:

python scripts/extract_module_docs.py
python scripts/lint_doc_claims.py
pytest -q --collect-only
Enter fullscreen mode Exit fullscreen mode

Wire both scripts as required checks. A green extract with a red claim lint is the desired failure mode when someone pastes chat prose into a generated page.

Numbered workflow

  1. Freeze the ownership manifest first, including required human sections and the generated-root path the extractor may overwrite. Do not ask a model to invent those section names, because the names are the control surface.
  2. Run the extractor on a clean tree and commit docs/generated/ as build output, or gitignore it and rebuild in CI; pick one rule and keep it. Reviewers should not hand-edit those files, because the next extract will delete the edits.
  3. Fill docs/owned/claims.md with compatibility, authentication, retention, rate limits, and deprecation only from reviewed sources. If a sentence cannot cite a ticket, contract, or incident, leave the section as unsigned rather than guessing.
  4. Optionally draft explanatory prose around the generated tables in a separate scratch file, then copy nothing into docs/generated/ until lint_doc_claims.py is green. A free model session on a free server option can produce that scratch prose when the tables already exist as AST output. Disclosure: This article was prepared as part of MonkeyCode's product outreach.
  5. Fail the pull request if generated Markdown contains a denylisted verb or if owned Markdown dropped a required heading. Treat either miss as a documentation defect, not as a style comment.
  6. When a public function is deleted, regenerate pages and delete owned sentences that named that function. Owned claims that mention missing callables are still unsigned claims, even when the verbs look careful.

What the pytest headings are for

Example sections are where generated docs usually start lying, because models complete a happy path that no test covers. Restricting example headings to collected node ids makes the page point at executable names without copying fixture bodies into Markdown. If pytest --collect-only does not print a node, the generated page must not describe that scenario. Humans may still write a narrative example in docs/owned/, but that narrative is then a signed claim with an owner, not a generated convenience.

This also keeps timing, credentials, and environment out of the compiled lane. Tests may use secret names as fixture parameters; the extractor only prints the node id, never the fixture source. That is a deliberate loss of detail, and it is cheaper than leaking a token into a reference page that will be copied into chat later.

Limitations and who should not use this

The pipeline does not prove that a function is safe, fast, or legally fit for a customer contract. It only proves that generated pages stayed inside AST-derived structure and that owned headings still exist as text. Absence of a forbidden verb is not evidence of a true claim; a human can still write a wrong retention sentence under the correct heading. Teams that need attested legal language still require counsel review outside this linter.

Do not use this approach for hardware manuals, medical device labeling, or incident comms that must be quoted verbatim from an approved corpus. Do not use it as a substitute for OpenAPI contract tests when HTTP status codes are the product. Do not point a drafting model at docs/owned/ and ask it to “complete” unsigned sections, because completion is how guarantee verbs re-enter the tree. Operators without CI should not adopt the generator alone, because an unenforced denylist is just another Markdown file.

The extractor also misses behavior that lives only in decorators, dynamic getattr exports, or C extensions. Those surfaces need an explicit owned note or a different compile step; they should not be implied by a partial function table. If your package is mostly generated protobuf stubs, prefer compiling from the schema, and keep this AST walk for the thin Python wrappers only.

Closing control, not a completeness score

Reference documentation is complete when every public callable has a table and every operational sentence has an owner, not when the Markdown looks fluent. Keep the fluent sentences in scratch space until the claim linter agrees they belong nowhere in docs/generated/. If you already fail CI on unsigned verbs, a later prose draft is optional and must lose to the linter when the two disagree.

Top comments (0)