DEV Community

Avery Lin
Avery Lin

Posted on

Generate This Commit's API Surface; Hand-Write Every Support Window

Forward-looking documentation is a contract with readers, not a restatement of the current tree. Generated text should describe only what this commit actually implements, because that scope can be regenerated without legal or product judgment. Any sentence that names a future date, a support window, or a compatibility obligation should stay in a human-owned file. This article presents a two-lane layout, a decision table, and a linter that fails the build when generated markdown crosses that line.

Mixed files hide two different clocks

Teams often ask a model to refresh an entire README after a refactor, including migration notes and version support. The generated file then mixes two clocks: the repository at HEAD, and a product calendar that no compiler can verify. Reviewers treat the whole file as ordinary documentation, so a wrong deprecation month ships beside a restated function signature. The failure mode is not tone; it is a timestamp that nobody with authority actually chose.

When those two clocks share a single markdown path, later regeneration becomes unsafe for both authors and reviewers. A later inventory pass overwrites human calendar language, or a cautious author freezes the file and lets signatures drift. Splitting the clocks restores a simple rule that continuous integration can enforce without inferring product intent. The practical cost is not extra files; it is review time spent reconciling claims that should never have shared a path.

Two lanes, one merge rule

Lane A is commit-scoped observation, covering names, signatures, error codes, and flags present in this tree. Lane B is the compatibility calendar, covering supported versions, deprecation dates, removal targets, and post-commit bindings. A model may draft Lane A by extracting from source and tests, because those facts are already machine-checkable. A human must write Lane B, because those sentences are product decisions rather than extracts from HEAD.

The merge rule is mechanical and should live in continuous integration rather than in review folklore. Generated files under docs/observed/ may be replaced on every main build, while docs/calendar/ is never written by a model. Cross-links are allowed in one direction only: the calendar may cite observed identifiers, but observed files must not mention dates or support windows. If a reader needs both clocks, a thin human index can join them without letting either lane overwrite the other.

Decision table for sentence routing

The table below is the routing policy for this artifact, not a style guide for documentation prose. If a sentence cannot be placed without debate, it belongs in the calendar lane until a human shortens it. Do not resolve the debate by asking a model to be more careful, because careful tone does not create a support window.

Claim shape Example fragment Owner Allowed path
Current signature checkout(cart_id: str) -> Order extractor or model rewrite docs/observed/
Current error code ERR_CART_EMPTY is raised when the cart has no items extractor or model rewrite docs/observed/
Flag default at HEAD --strict defaults to false in this tree extractor or model rewrite docs/observed/
Version still supported 1.4 remains supported through March 2027 human docs/calendar/
Deprecation date legacy_checkout is removed in 2.0 human docs/calendar/
Availability target 99.9 percent monthly availability human docs/calendar/
Compatibility window clients on protocol v3 keep working across 1.x human docs/calendar/
Severity policy treat token leaks as Sev-1 and page the owner human docs/calendar/

Route by temporal scope first, then by whether HEAD can prove the sentence. A current signature is in scope for generation because the compiler or an AST walker can contradict it on the next commit. A support window is out of scope because no file in the tree contains next year's product decision unless a human already wrote it down.

Numbered workflow

  1. Freeze the path convention in a repository docs/README.md that names both lanes and states the merge rule in one page.
  2. Extract public symbols from source into docs/observed/ as markdown tables, using a deterministic script rather than a chat transcript.
  3. Optionally ask a model to turn those tables into short descriptive paragraphs, still writing only under docs/observed/.
  4. Keep every date, version window, deprecation, and support sentence in docs/calendar/ as human-authored markdown with named owners.
  5. Run a linter on docs/observed/ that fails when forward-looking language appears in generated files, then block the merge.
  6. In pull request review, reject calendar edits that lack a named human owner in CODEOWNERS or in the commit metadata.

The optional model step is a rewrite of extracted facts, not a second source of product policy. If the extractor and the model disagree, the extractor wins and the model draft is discarded. That ordering keeps regeneration cheap, because Lane A can be deleted and rebuilt from HEAD at any time. Chat logs are not an input to CI; only committed extractor output and calendar files are.

Artifact: extract the observed surface

The following proposed script walks a Python package and writes one markdown table of public callables. It is a starting point, not a complete documentation compiler, and it remains unexecuted until you run it against your tree. Extend the walker later for classes, HTTP routes, or generated stubs if those are your public surface.

#!/usr/bin/env python3
"""Write docs/observed/surface.md from public functions in a package."""
from __future__ import annotations

import ast
import sys
from pathlib import Path


def public_functions(py_file: Path) -> list[tuple[str, str]]:
    tree = ast.parse(py_file.read_text(encoding="utf-8"))
    rows: list[tuple[str, str]] = []
    for node in tree.body:
        if isinstance(node, ast.FunctionDef) and not node.name.startswith("_"):
            args = [a.arg for a in node.args.args if a.arg != "self"]
            rows.append((node.name, ", ".join(args)))
    return rows


def main(package_dir: str, out_path: str) -> None:
    root = Path(package_dir)
    lines = [
        "# Observed surface (generated from this commit)",
        "",
        "This file is commit-scoped. Do not edit dates or support windows here.",
        "",
        "| Symbol | Parameters | Source |",
        "| --- | --- | --- |",
    ]
    for py_file in sorted(root.rglob("*.py")):
        if py_file.name == "__init__.py":
            continue
        rel = py_file.relative_to(root)
        for name, params in public_functions(py_file):
            lines.append(f"| `{name}` | `{params}` | `{rel}` |")
    out = Path(out_path)
    out.parent.mkdir(parents=True, exist_ok=True)
    out.write_text("\n".join(lines) + "\n", encoding="utf-8")


if __name__ == "__main__":
    if len(sys.argv) != 3:
        raise SystemExit("usage: extract_observed.py PACKAGE_DIR OUT.md")
    main(sys.argv[1], sys.argv[2])
Enter fullscreen mode Exit fullscreen mode

Run the extractor from a local checkout or from CI with a fixed output path so reviewers can diff the table. The important property is determinism: the same tree should produce the same markdown bytes.

python tools/extract_observed.py src/payments docs/observed/surface.md
git diff --exit-code -- docs/observed/surface.md
Enter fullscreen mode Exit fullscreen mode

The git diff --exit-code step treats an uncommitted surface-table change as a failed build rather than a warning. Authors must either regenerate and commit the table, or explain why a public symbol disappeared. That check is about the current HEAD tree, not about next year's support matrix or removal dates.

Artifact: lint generated markdown for calendar language

The second proposed script scans markdown under docs/observed/ for tokens that usually mark forward-looking calendar claims. It is a conservative gate, and it will false-positive on the word will inside quoted error strings. That noise is acceptable, because those strings can be rephrased, escaped, or moved into a calendar note. Do not weaken the pattern on the first false positive; move the offending line out of the generated lane instead.

#!/usr/bin/env python3
"""Fail if generated docs contain forward-looking calendar language."""
from __future__ import annotations

import re
import sys
from pathlib import Path

FORBIDDEN = re.compile(
    r"""
    \b(will|won't|until|through|sla|guarantee|guaranteed|
       supported\s+through|deprecat(?:e|ed|ion)|
       breaking\s+change|backwards?\s+compatible|
       forever|always\s+supported|never\s+remove)\b
    |
    \b20\d{2}-\d{2}-\d{2}\b
    |
    \bv?\d+\.\d+\s+(through|until|to)\s+v?\d+\.\d+\b
    """,
    re.IGNORECASE | re.VERBOSE,
)


def lint(root: Path) -> list[str]:
    hits: list[str] = []
    for md in sorted(root.rglob("*.md")):
        text = md.read_text(encoding="utf-8")
        for i, line in enumerate(text.splitlines(), 1):
            if FORBIDDEN.search(line):
                hits.append(f"{md}:{i}: forward-looking language: {line.strip()}")
    return hits


def main(observed_dir: str) -> None:
    hits = lint(Path(observed_dir))
    if hits:
        sys.stderr.write("\n".join(hits) + "\n")
        raise SystemExit(f"lint failed: {len(hits)} forward-looking line(s)")
    print("observed docs are commit-scoped")


if __name__ == "__main__":
    if len(sys.argv) != 2:
        raise SystemExit("usage: lint_observed.py OBSERVED_DIR")
    main(sys.argv[1])
Enter fullscreen mode Exit fullscreen mode

A small proposed test file documents the intended gate before anyone argues about tone in review. Keep these tests next to the linter so a pattern change cannot silently reopen the calendar lane.

# tests/test_lint_observed.py
from pathlib import Path

from lint_observed import lint


def test_flags_deprecation_date(tmp_path: Path) -> None:
    sample = tmp_path / "checkout.md"
    sample.write_text("legacy_checkout is deprecated on 2027-01-01.\n", encoding="utf-8")
    assert lint(tmp_path), "expected a forward-looking hit"


def test_allows_current_signature(tmp_path: Path) -> None:
    sample = tmp_path / "checkout.md"
    sample.write_text("`checkout(cart_id)` returns an Order.\n", encoding="utf-8")
    assert lint(tmp_path) == []
Enter fullscreen mode Exit fullscreen mode

Wire both tools into one local target so Lane A cannot merge when it starts speaking about the future. A Makefile keeps the recipe independent of any one CI vendor and avoids pinning runner versions in this article.

.PHONY: docs-observed docs-lint docs-lanes

docs-observed:
    python tools/extract_observed.py src/payments docs/observed/surface.md

docs-lint:
    python tools/lint_observed.py docs/observed

docs-lanes: docs-observed
    git diff --exit-code -- docs/observed/surface.md
    python tools/lint_observed.py docs/observed
Enter fullscreen mode Exit fullscreen mode

A calendar file can still mention dates freely, because it is outside the linter root. CODEOWNERS should map docs/calendar/ to product, docs, or engineering managers rather than to whoever last touched the extractor. Reviewers then spend time on window accuracy, not on whether a model invented a removal month.

# .github/CODEOWNERS
/docs/calendar/  @your-org/docs-owners
/docs/observed/  @your-org/api-maintainers
Enter fullscreen mode Exit fullscreen mode

Optional model rewrite of Lane A

After the extractor writes tables, a model can draft short paragraphs that restate rows without adding calendar claims. Keep that pass on a disposable branch, then run the linter before anyone merges the prose. Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode's free model access can be used for that restatement pass over the extracted surface tables. The free server option can host the extractor and linter when a laptop is not the right runner.

The model is a formatter of already extracted rows, not a source of deprecation months or support windows. If the rewrite introduces a year, a version range, or an always-supported clause, the linter should fail the job. The draft should be dropped rather than edited in chat until the tone sounds more careful. Iterate by deleting the forbidden sentence, not by asking the model to hedge.

What this does not prove

The linter is a regular expression over markdown, not a legal review and not a substitute for counsel on customer contracts. It will miss clever paraphrases, and it will flag quoted logs that happen to contain calendar verbs. Teams that document multiple languages need additional extractors, because this Python ast walker will not see HTTP routes, protobuf stubs, or generated clients.

The workflow also assumes a repository that already has automated checks and CODEOWNERS. Without those, the two-lane layout is only a folder convention, and folder conventions rot under deadline pressure. It does not measure documentation quality, reader comprehension, or whether the human calendar itself is truthful. A green linter means generated files stayed commit-scoped; it does not mean the product will support what the calendar claims.

Who should not use this approach

Do not adopt the split if your documentation is a personal blog, a conference talk, or a one-file library README with no compatibility story. Do not use the linter as the only control on security advisories, pricing, or regulated product claims, because those need named human authors and often legal review. Do not point a model at docs/calendar/ and ask it to fill gaps, because gap-filling is how support windows get invented from tone.

If your API has no public compatibility story, generating a surface table may still help reviewers, but the calendar lane will be empty. In that case, skip the calendar folder until you actually have a window to publish. Empty policy files train people to ignore CODEOWNERS, which is worse than having no calendar lane at all.

Closing

Commit-scoped documentation can be regenerated because it is a view over HEAD, similar to a compiler listing. Forward-looking documentation cannot be regenerated from source, because source does not contain next year's support decision. Keep those clocks in different files, fail the build when generated prose names a date, and let humans own every window that extends past this commit. If this split already matches your review process, a free-model restatement of Lane A is an optional runner, not a calendar owner.

Top comments (1)

Collapse
 
alexshev profile image
Alex Shev

The two-lane rule is strong because it makes regeneration reversible without pretending that product policy is extractable. I would also require the CI report to name the commit SHA and extractor version; that gives reviewers a clean way to distinguish a source-derived change from a tooling change.