Public READMEs fail when compiled inventory and user-facing promises share one unreviewed paragraph. Packaging metadata, console scripts, and collected tests already describe what the repository contains on this commit. Install instructions, supported platforms, and copy-paste examples create expectations that only a human should sign. The workflow below keeps those two classes of text in separate files until a documentation gate passes.
This article does not argue that models cannot write prose. It argues that README publication is a contract problem, not a drafting-speed problem. A model may assemble a facts ledger from files the repository already owns. A human must still own every sentence that tells a stranger what will work on their machine.
What belongs in each file
Treat the README as a render target, not as a source of truth. The compiler writes readme_facts.json from packaging metadata and test collection. Reviewers maintain promise_register.yaml for claims that would be false if a dependency, platform, or example drifted. The gate refuses to render when any signed field still contains a placeholder or when compiled names no longer match the register.
The split is mechanical rather than stylistic. If a statement can be checked against pyproject.toml, an entry point, or a collected node id, it is a compile field. If a statement would surprise a user when it is wrong, it is a signed promise, even when the wording looks obvious.
Decision table
| Claim in the README | Source of truth | Owner | Failure mode if unsigned |
|---|---|---|---|
| Package name and version |
pyproject.toml [project]
|
compiler | stale badge, wrong pip name |
| Console-script names | [project.scripts] |
compiler | documented CLI that does not exist |
| Collected test node ids | pytest --collect-only |
compiler | “covered behavior” that was renamed |
| Public module import paths | AST of src/ or package dir |
compiler | import examples that fail on install |
| Install command that users should run | human intent, extras, index | signer | broken onboarding in the first five minutes |
| Supported Python and OS window | release policy, not classifiers alone | signer | classifiers copied as a support promise |
| Copy-paste getting-started example | a command the signer actually ran | signer | hallucinated flags and fake output |
| “We will not break this until…” | compatibility policy | signer | silent contract change in a patch |
| Security, license, and support contacts | legal and ops owners | signer | model-invented addresses and SLAs |
Classifiers in pyproject.toml are still compile data. They record what the packaging file currently declares, not what the project commits to support next quarter. Copying classifiers into a support sentence without a signature is how READMEs become accidentally contractual.
1. Extract a facts ledger from files you already have
The extractor below is a local, deterministic script. It reads pyproject.toml with tomllib, walks Python files for importable module paths, and optionally records pytest node ids from a collected listing. It does not call a model and it does not invent install instructions.
#!/usr/bin/env python3
"""Compile README facts. Do not treat output as user promises."""
from __future__ import annotations
import ast
import json
import subprocess
import sys
import tomllib
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SRC_CANDIDATES = [ROOT / "src", ROOT]
def load_project() -> dict:
pyproject = ROOT / "pyproject.toml"
if not pyproject.is_file():
raise SystemExit("pyproject.toml is required for this compiler")
with pyproject.open("rb") as handle:
data = tomllib.load(handle)
project = data.get("project") or {}
if "name" not in project or "version" not in project:
raise SystemExit("project.name and project.version must exist")
return project
def iter_py_files() -> list[Path]:
files: list[Path] = []
for base in SRC_CANDIDATES:
if not base.is_dir():
continue
files.extend(path for path in base.rglob("*.py") if "__pycache__" not in path.parts)
if files:
break
return files
def public_modules(files: list[Path]) -> list[str]:
modules: set[str] = set()
for path in files:
try:
rel = path.relative_to(ROOT)
except ValueError:
continue
if any(part.startswith(".") for part in rel.parts):
continue
dotted = ".".join(rel.with_suffix("").parts)
if dotted.endswith(".__init__"):
dotted = dotted[: -len(".__init__")]
if dotted:
modules.add(dotted)
return sorted(modules)
def top_level_functions(files: list[Path]) -> list[str]:
names: set[str] = set()
for path in files:
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
for node in tree.body:
if isinstance(node, ast.FunctionDef) and not node.name.startswith("_"):
names.add(node.name)
return sorted(names)
def collect_pytest_nodes() -> list[str]:
try:
completed = subprocess.run(
[sys.executable, "-m", "pytest", "--collect-only", "-q"],
cwd=ROOT,
check=False,
capture_output=True,
text=True,
)
except OSError:
return []
if completed.returncode not in (0, 5):
return []
nodes = []
for line in completed.stdout.splitlines():
line = line.strip()
if "::" in line and not line.startswith("="):
nodes.append(line)
return nodes[:200]
def main() -> None:
project = load_project()
files = iter_py_files()
facts = {
"package_name": project["name"],
"version": str(project["version"]),
"description": project.get("description") or "",
"requires_python": project.get("requires-python") or "",
"scripts": sorted((project.get("scripts") or {}).keys()),
"optional_dependency_groups": sorted((project.get("optional-dependencies") or {}).keys()),
"public_modules": public_modules(files),
"top_level_functions": top_level_functions(files),
"pytest_node_ids": collect_pytest_nodes(),
}
out = ROOT / "docs" / "readme_facts.json"
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(json.dumps(facts, indent=2) + "\n", encoding="utf-8")
print(f"wrote {out} with {len(facts['pytest_node_ids'])} collected node ids")
if __name__ == "__main__":
main()
Run the compiler from the repository root after tests are collectable. Collection failures should empty the node-id list rather than invent coverage. The script labels nothing as supported, stable, or recommended.
python tools/extract_readme_facts.py
python -m pytest --collect-only -q
python -c "import json; print(json.load(open('docs/readme_facts.json'))['scripts'])"
If pytest is missing, the facts file still publishes packaging fields. Missing tests are a compile-lane gap, not a license to guess behavior in the README.
2. Keep signed promises in a register the compiler cannot fill
Create docs/promise_register.yaml by hand. The keys below are the minimum set for a library or CLI that strangers will install. Placeholders must remain visible until a reviewer replaces them with commands they have run.
# docs/promise_register.yaml
# Human-owned. The compiler must not write this file.
package_name: mypkg # must match readme_facts.json
install_command: SIGN_ME # exact command a reviewer ran
supported_python: SIGN_ME # policy sentence, not a copied classifier
supported_os: SIGN_ME
getting_started_example: |
SIGN_ME
example_exit_code: SIGN_ME
support_window: SIGN_ME # what stays compatible, and until when
breaking_change_policy: SIGN_ME
security_contact: SIGN_ME
A free drafting environment can propose wording for the compile lane after the facts file exists. Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode's free model access and free server option can draft README sections that only restate readme_facts.json, such as listing script names or linking to collected modules. They must not be used to fill SIGN_ME fields, because those fields are promises rather than inventory.
Label any model output as a proposal until the register is edited by a reviewer. If the draft introduces an install extra, a Docker tag, or a sample payload that is absent from the facts file, reject the draft. The rejection is the documentation gate working as designed.
3. Fail the build when promises are still placeholders
The checker compares the two files and exits nonzero when publication would lie. It does not grade prose quality. It only enforces ownership and referential match.
#!/usr/bin/env python3
from __future__ import annotations
import json
import sys
from pathlib import Path
try:
import yaml
except ImportError:
raise SystemExit("PyYAML is required for tools/check_readme_promises.py")
ROOT = Path(__file__).resolve().parents[1]
FACTS = ROOT / "docs" / "readme_facts.json"
REGISTER = ROOT / "docs" / "promise_register.yaml"
REQUIRED = (
"install_command",
"supported_python",
"supported_os",
"getting_started_example",
"example_exit_code",
"support_window",
"breaking_change_policy",
"security_contact",
)
def main() -> None:
facts = json.loads(FACTS.read_text(encoding="utf-8"))
register = yaml.safe_load(REGISTER.read_text(encoding="utf-8")) or {}
errors: list[str] = []
if register.get("package_name") != facts["package_name"]:
errors.append("package_name in promise register must match compiled facts")
for key in REQUIRED:
value = register.get(key)
text = str(value or "").strip()
if not text or "SIGN_ME" in text:
errors.append(f"unsigned promise field: {key}")
example = str(register.get("getting_started_example") or "")
for script in facts.get("scripts") or []:
if script in example:
break
else:
if facts.get("scripts"):
errors.append("getting_started_example does not mention a compiled script name")
if errors:
print("README documentation gate failed:")
for item in errors:
print(f" - {item}")
raise SystemExit(1)
print("README documentation gate passed")
if __name__ == "__main__":
main()
Wire the two commands into CI in that order. Rendering Markdown before the gate passes is how unsigned examples leak into Git tags.
python tools/extract_readme_facts.py
python tools/check_readme_promises.py
A minimal GitHub Actions step is enough. Keep the facts file as a build artifact if reviewers want to inspect drift without rerunning collection locally.
# proposal: CI fragment, not a full workflow
- name: README documentation gate
run: |
python tools/extract_readme_facts.py
python tools/check_readme_promises.py
4. Render only after both inputs are valid
The renderer should interpolate compiled fields and signed fields, then refuse leftover placeholders. The template below is deliberately plain so reviewers can diff the rendered README against the register.
#!/usr/bin/env python3
from __future__ import annotations
import json
from pathlib import Path
import yaml
ROOT = Path(__file__).resolve().parents[1]
TEMPLATE = """# {package_name}\n\n{description}\n\n## Install\n\n```
\n{install_command}\n
```\n\n## Getting started\n\n```
\n{getting_started_example}\n
```\n\nExpected exit code: {example_exit_code}\n\n## Compatibility\n\n- Python: {supported_python}\n- OS: {supported_os}\n- Support window: {support_window}\n- Breaking changes: {breaking_change_policy}\n\nCompiled scripts: {scripts}\nCompiled modules: {modules}\n"""
def main() -> None:
facts = json.loads((ROOT / "docs" / "readme_facts.json").read_text(encoding="utf-8"))
register = yaml.safe_load((ROOT / "docs" / "promise_register.yaml").read_text())
text = TEMPLATE.format(
package_name=facts["package_name"],
description=facts["description"] or register.get("short_description", ""),
install_command=register["install_command"].strip(),
getting_started_example=register["getting_started_example"].rstrip(),
example_exit_code=register["example_exit_code"],
supported_python=register["supported_python"],
supported_os=register["supported_os"],
support_window=register["support_window"],
breaking_change_policy=register["breaking_change_policy"],
scripts=", ".join(facts["scripts"]) or "(none)",
modules=", ".join(facts["public_modules"][:12]) or "(none)",
)
if "SIGN_ME" in text:
raise SystemExit("refusing to render unsigned README")
out = ROOT / "README.md"
out.write_text(text, encoding="utf-8")
print(f"wrote {out}")
if __name__ == "__main__":
main()
Do not let the renderer call a model. If compile-lane prose needs a clearer sentence, edit a small template in review, then rerun the renderer. Chat history is not an auditable source for install commands.
What the model may draft, and what a human must own
The model may restate compiled inventory: package name, version, script names, module paths, and a table of collected tests. It may also propose heading order or shorten a description that already exists in pyproject.toml. Those drafts remain discardable because the facts file can be regenerated on the next commit.
The human must own every command a new user will paste, every platform sentence, every support window, and every example output. The human must also own omission: if a public function exists in the AST but is not a supported interface, the register should not promote it. Silence in the README is a product decision, not a missing paragraph the model should complete.
A practical review sequence looks like the following numbered pass. First, regenerate readme_facts.json on the release commit, not on a dirty tree. Second, diff the facts file and update the register only where a promise actually changed. Third, run the example from a clean virtualenv and record the exit code. Fourth, run the gate, then render. Fifth, reject any draft that adds URLs, versions, or flags absent from both inputs.
Limitations
The compiler cannot see runtime extras that are imported only inside functions. AST module paths therefore over-approximate the public surface and must not be pasted as a supported API list. Pytest node ids describe collection, not contractual behavior, and names like test_happy_path do not document arguments.
requires-python is still not a support promise. A project can declare >=3.10 while only testing 3.12. CLI --help is omitted from the compiler on purpose, because plugins and lazy imports often make help text environment-dependent. If you later add help scraping, treat it as another compile field and still sign the user-facing examples separately.
This workflow also assumes a single pyproject.toml and a test suite that collects in CI. Monorepos with several packages need one facts file per publishable unit. Generated READMEs that must satisfy legal, medical, or financial disclosure rules need owners outside engineering, and this gate does not replace that review.
Who should not use this approach
Do not use this gate as a substitute for running examples. A signed field that nobody executed is still a false promise with better metadata. Do not use it for repositories without packaging metadata, because the compiler then has nothing stricter than directory names.
Teams that want a model to invent a getting-started story from the repository vibe should not adopt this split. The method is slower than pasting chat output into README.md, and that slowness is the point. If your audience is only internal and the README is not a support surface, a facts file may be overhead without a matching risk.
The durable output is not friendlier prose. It is a README whose inventory can be regenerated and whose promises have named owners. If you draft compile-lane restatements with MonkeyCode's free model access on the free server option, export readme_facts.json into review and leave every SIGN_ME field to a human.
Top comments (0)