DEV Community

Avery Lin
Avery Lin

Posted on

Separate Inventory, Narrative, and Signed Examples When Generating Library Docs

Library documentation stays honest when generation is split into three lanes: a machine inventory, optional narrative glue, and human-signed examples. Models can restate names, arguments, and test titles that already exist in the repository. They should not invent runnable snippets, support windows, or security caveats, which become contracts once they land in a README. Treat the scripts below as a proposed local method, and run them on your own tree before you trust any generated sentence.

This workflow compiles a doc ownership ledger from one Python module and one pytest-style test file. The ledger is JSON plus Markdown, and every cell is marked draftable or human-owned before any prose is requested. The compile step is offline, deterministic, and cheap enough to run in CI on every docs pull request.

Why mixed-lane docs fail review

Generated API pages usually mix three kinds of text that do not share a source of truth. Inventory text can be compiled from function definitions, annotations, and collected test identifiers. Narrative text can summarize that inventory for a reader who does not want to open the module. Contract text tells a caller what remains true across versions, and only a maintainer can own that promise.

When those lanes collapse into one chat transcript, examples drift away from tests, and deprecation notes appear without a tag. Reviewers then argue with fluent paragraphs instead of with a table of unsigned cells. A ledger makes the unsigned cells visible before merge, which is the actual engineering control.

Adjacent industry talk about treating casual generation as engineering is a topic signal, not a method. The method here is the split: inventory first, narrative second, signed examples last. Extra model access does not repair a missing inventory, and it cannot witness that a fenced example executed.

Decision table: what may be drafted

Use this table as the contract for any later language-model step. Cells marked No must stay empty in model output; empty is safer than a fluent guess.

Ledger cell Evidence on disk Model may draft? Human must own
Public symbol list AST FunctionDef / AsyncFunctionDef without a leading _ Restate names only Confirm the public surface
Parameter names and annotations AST arguments Restate; never invent types Fill or reject missing annotations
Existing docstring first line ast.get_docstring Echo or lightly rephrase Keep or reject the stated intent
Related tests pytest test_* identifiers List substring matches Confirm the test still covers the symbol
Overview / audience Product intent, not AST Hypothesis only, from inventory rows Audience, non-goals, support status
Runnable example Tests or a recorded session Skeleton comments only Must execute; prefer copying assertions
Compatibility / deprecation Tags, changelog, issue ids No Yes
Secrets, tokens, hostnames Team policy No Yes, placeholders only
When not to use Product intent No Yes

Substring test matching is a heuristic. False positives are expected on short names, and a human still has to accept or drop each related-test row.

What you need on disk

You need Python 3.11 or newer for ast.unparse, plus a small public module and at least one test file. The compiler parses tests with AST instead of importing them, so collection stays offline and does not execute fixtures. Replace the sample paths with a real package when you adopt the workflow.

1. Illustrative public module

# sample_ops.py
from __future__ import annotations

def normalize_slug(value: str, *, max_len: int = 64) -> str:
    """Collapse whitespace and lowercase a slug candidate."""
    parts = "_".join(value.strip().split())
    return parts.lower()[:max_len]

def bounded_retry(attempts: int) -> int:
    """Return a clamped retry count for callers that cannot loop forever."""
    if attempts < 1:
        return 1
    if attempts > 8:
        return 8
    return attempts
Enter fullscreen mode Exit fullscreen mode

2. Tests that mention the symbols

# tests/test_sample_ops.py
from sample_ops import bounded_retry, normalize_slug

def test_normalize_slug_collapses_spaces():
    assert normalize_slug("Hello World") == "hello_world"

def test_bounded_retry_clamps_high_values():
    assert bounded_retry(99) == 8
Enter fullscreen mode Exit fullscreen mode

Those test names are evidence that an example could be derived later. They are not documentation examples until a human copies the assertions into a fenced block and runs that block.

Compile the ownership ledger

The compiler walks one module and one test file, then writes ledger.json and ledger.md. Treat the script as a proposed tool, not as a published package, and read the output before any narrative draft.

3. Proposed compiler (doc_ownership_ledger.py)

#!/usr/bin/env python3
"""Compile a doc ownership ledger from one module and pytest-style tests."""
from __future__ import annotations

import argparse
import ast
import json
from pathlib import Path
from typing import Any


def is_public(name: str) -> bool:
    return not name.startswith("_")


def func_records(tree: ast.AST) -> list[dict[str, Any]]:
    records: list[dict[str, Any]] = []
    for node in tree.body:
        if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
            continue
        if not is_public(node.name):
            continue
        args = []
        arg_nodes = node.args.posonlyargs + node.args.args + node.args.kwonlyargs
        for arg in arg_nodes:
            ann = ast.unparse(arg.annotation) if arg.annotation else None
            args.append({"name": arg.arg, "annotation": ann})
        doc = ast.get_docstring(node)
        first = (doc or "").splitlines()[0] if doc else None
        records.append(
            {
                "name": node.name,
                "lineno": node.lineno,
                "async": isinstance(node, ast.AsyncFunctionDef),
                "args": args,
                "returns": ast.unparse(node.returns) if node.returns else None,
                "docstring_first_line": first,
            }
        )
    return records


def test_index(path: Path) -> list[dict[str, Any]]:
    tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
    found: list[dict[str, Any]] = []
    for node in ast.walk(tree):
        if isinstance(node, ast.FunctionDef) and node.name.startswith("test_"):
            found.append({"file": str(path), "id": node.name, "lineno": node.lineno})
    return found


def match_tests(symbol: str, tests: list[dict[str, Any]]) -> list[dict[str, Any]]:
    key = symbol.lower()
    return [t for t in tests if key in t["id"].lower()]


def ledger_row(record: dict[str, Any], tests: list[dict[str, Any]]) -> dict[str, Any]:
    related = match_tests(record["name"], tests)
    return {
        "symbol": record["name"],
        "inventory": record,
        "related_tests": related,
        "cells": {
            "symbol_restatement": {"model_may_draft": True, "value": None},
            "param_restatement": {"model_may_draft": True, "value": None},
            "docstring_echo": {
                "model_may_draft": True,
                "value": record["docstring_first_line"],
            },
            "related_tests": {
                "model_may_draft": True,
                "value": [t["id"] for t in related],
            },
            "overview_audience": {"model_may_draft": False, "value": None},
            "runnable_example": {"model_may_draft": False, "value": None},
            "compatibility": {"model_may_draft": False, "value": None},
            "secrets_policy": {"model_may_draft": False, "value": None},
            "when_not_to_use": {"model_may_draft": False, "value": None},
        },
    }


def render_markdown(rows: list[dict[str, Any]]) -> str:
    lines = [
        "# Doc ownership ledger",
        "",
        "Signed cells must remain blank until a human fills them.",
        "",
    ]
    for row in rows:
        inv = row["inventory"]
        args = ", ".join(
            f"{a['name']}: {a['annotation'] or 'UNTYPED'}" for a in inv["args"]
        )
        tests = row["cells"]["related_tests"]["value"] or []
        lines.extend(
            [
                f"## `{row['symbol']}`",
                "",
                f"- Inventory args: `{args}`",
                f"- Returns: `{inv['returns'] or 'UNTYPED'}`",
                f"- Docstring first line: {inv['docstring_first_line'] or '(none)'}",
                f"- Related tests: {', '.join(tests) if tests else '(none matched)'}",
                "",
                "### Model may draft",
                "",
                "- symbol_restatement:",
                "- param_restatement:",
                "",
                "### Human must own (leave blank in model output)",
                "",
                "- overview_audience:",
                "- runnable_example:",
                "- compatibility:",
                "- secrets_policy:",
                "- when_not_to_use:",
                "",
            ]
        )
    return "\n".join(lines)


def main() -> None:
    parser = argparse.ArgumentParser(description="Compile a doc ownership ledger")
    parser.add_argument("--module", required=True, type=Path)
    parser.add_argument("--tests", required=True, type=Path)
    parser.add_argument("--out", required=True, type=Path)
    args = parser.parse_args()
    source = args.module.read_text(encoding="utf-8")
    tree = ast.parse(source, filename=str(args.module))
    tests = test_index(args.tests)
    rows = [ledger_row(rec, tests) for rec in func_records(tree)]
    args.out.mkdir(parents=True, exist_ok=True)
    (args.out / "ledger.json").write_text(
        json.dumps(rows, indent=2) + "\n", encoding="utf-8"
    )
    (args.out / "ledger.md").write_text(render_markdown(rows), encoding="utf-8")
    print(f"wrote {args.out / 'ledger.json'} and {args.out / 'ledger.md'}")


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

4. Commands to compile, then fail closed on unsigned examples

python doc_ownership_ledger.py \
  --module sample_ops.py \
  --tests tests/test_sample_ops.py \
  --out ledger

python - <<'PY'
import json
from pathlib import Path
rows = json.loads(Path("ledger/ledger.json").read_text())
unsigned = []
for row in rows:
    example = row["cells"]["runnable_example"]["value"]
    if not example:
        unsigned.append(row["symbol"])
if unsigned:
    raise SystemExit("unsigned examples: " + ", ".join(unsigned))
print("all runnable_example cells are filled")
PY
Enter fullscreen mode Exit fullscreen mode

The second command is a proposed CI gate, not a claim about any hosted runner. Keep it failing until a reviewer pastes real examples. Do not let a model fill runnable_example to silence the gate.

Fill narrative cells without mixing lanes

After ledger.json exists, a model may draft only cells with "model_may_draft": true. Paste that JSON into a prompt that forbids filling signed cells, and keep the model step optional. If the inventory is wrong, fix the compiler inputs instead of asking for a longer overview.

Disclosure: This article was prepared as part of MonkeyCode's product outreach.

MonkeyCode's free model access and free server option can sit in that optional narrative step, after the ledger file is already committed. The compile step does not need a remote model, and the signed cells should remain blank in any model file you store beside the ledger. If you use that free-model path at all, keep it downstream of doc_ownership_ledger.py, never as a substitute for AST inventory or pytest evidence.

A tight prompt shape looks like the following unlabeled template. It is a proposal, not a recorded session.

You receive ledger.json.
Write Markdown only for cells where model_may_draft is true.
Leave overview_audience, runnable_example, compatibility,
secrets_policy, and when_not_to_use completely empty.
Do not invent types, default values, hostnames, or version guarantees.
If related_tests is empty, say unmatched rather than guessing coverage.
Enter fullscreen mode Exit fullscreen mode

Store model output as ledger/narrative.draft.md, never as the published README. Diff that draft against ledger.md and reject any heading that appeared under Human must own.

Promote tests into signed examples

Human ownership of examples is a copy-and-run procedure, not a rewrite contest. The goal is a fenced block whose assertions already exist in pytest, plus a recorded command that still passes.

  1. Open the related test id from the ledger row and copy the assertion lines, not a paraphrased story about them.
  2. Wrap those lines in a module-level example or a README fence that imports the public symbol only.
  3. Run the example with the same interpreter the tests use, and paste the command plus exit status under runnable_example.
  4. Fill when_not_to_use with one concrete misuse, such as passing untrusted HTML to normalize_slug and expecting escaping.
  5. Leave compatibility empty unless a changelog entry or tag already states the window; do not date a promise from memory.
python -c "from sample_ops import normalize_slug; assert normalize_slug('Hello World') == 'hello_world'"
python -m pytest tests/test_sample_ops.py -q
Enter fullscreen mode Exit fullscreen mode

If either command fails, the ledger row stays unsigned. Documentation that cannot replay those two commands is still a draft, even when the overview paragraph reads smoothly.

Proposed checks for the compiler itself

Label these as unexecuted checks in this article; run them locally if you adopt the script. They protect the lane split more than they protect slug behavior.

# test_doc_ownership_ledger.py  (proposed)
import json
from pathlib import Path

import doc_ownership_ledger as dol

def test_private_functions_are_omitted(tmp_path: Path):
    mod = tmp_path / "m.py"
    mod.write_text("def _hidden():\n    return 1\n\ndef visible(x: int) -> int:\n    return x\n")
    tree = __import__("ast").parse(mod.read_text())
    names = [r["name"] for r in dol.func_records(tree)]
    assert names == ["visible"]

def test_signed_cells_start_empty(tmp_path: Path):
    # After a real compile, runnable_example must be JSON null.
    sample = json.loads('{"cells": {"runnable_example": {"model_may_draft": false, "value": null}}}')
    assert sample["cells"]["runnable_example"]["value"] is None
    assert sample["cells"]["runnable_example"]["model_may_draft"] is False
Enter fullscreen mode Exit fullscreen mode

Add a check that bounded_retry matches test_bounded_retry_clamps_high_values and that a symbol with no test is listed as unmatched. Unmatched is a valid inventory state; it is not a license to invent coverage narrative.

Limitations

The compiler sees only top-level functions in one module and test_* functions in one file. It does not expand pytest.mark.parametrize ids, class-based tests, doctest blocks, stub files, or C extensions. Re-exports, __all__ mismatches, overloaded @typing.overload stacks, and runtime-built functions will be missing or duplicated, and reviewers must correct those rows by hand.

Narrative drafts can still smuggle contracts into restatements, especially default values and raised exceptions that were never in the AST snapshot. Ban exception behavior and version numbers from draft cells even if a docstring mentions them. The CI gate above only proves that runnable_example is non-empty; it does not prove the example still matches HEAD after a later refactor.

This method does not generate Sphinx inventories, OpenAPI examples, or man pages. Those formats need their own evidence sources, and copying this Markdown skeleton into them would hide the lane split.

Who should not use this approach

Do not use this split as a substitute for legal, safety, or incident-response documentation, where an invented sentence has operational cost. Do not use it to document credential handling, production hostnames, or customer data flows, because model restatement does not make those examples safe. Teams without pytest (or an equivalent named test suite) should stop at the AST inventory and skip related-test matching entirely.

Skip the model step when the public surface is smaller than a single screen. Hand-written examples from tests will be faster, and the ledger still helps as a review checklist. If your docs are generated solely from a type checker or an OpenAPI source of truth, keep that pipeline; this article is for libraries whose README still mixes inventory with promises.

The durable output is not a generated overview. It is a file that shows which sentences were compiled, which were drafted, and which still need a human signature before a caller should believe them.

Top comments (0)