Config documentation should be compiled from call sites, then split so a model never owns secret classification, production defaults, or rollout language. Identifier inventories are mechanical artifacts that a parser can emit on every commit, while policy cells remain human work. This article presents a Python AST extractor, a two-lane atlas format, and a CI gate that refuses unsigned policy cells. Nothing below depends on a vendor remaining available, and the extractor runs with the Python standard library alone.
The failure mode this pipeline targets
Most README config tables start as copies of getenv calls, then drift the moment a key is renamed in one module. Authors later paste the stale table into a chat model and ask for clearer descriptions, which leaks production defaults into generated prose. A compiler can prove which string keys exist at call sites, but it cannot prove whether a key is a credential. Mixing those jobs produces documentation that looks complete while remaining wrong in the cells that cause incidents.
Literal keys also hide inside environ subscripts, getenv helpers, and framework wrappers that still receive a constant string. Dynamic keys built from f-strings or concatenation will not appear, and that incompleteness must stay visible in the report. The atlas is an inventory of proven literals, not a claim that the process environment contains nothing else. Treat missing dynamic keys as a documented limitation rather than a reason to skip extraction entirely.
Lane split: draftable cells versus owned cells
Keep every config key in one row, then assign each column to a compiler, a human, or an optional draft pass. The table below is the contract for this workflow; it is not a style preference. If a cell can change incident response, a model does not write it. If a cell is a restatement of an identifier already proven by AST, a model may draft wording after the human lane is locked.
| Cell | Owner | Allowed source | Forbidden source |
|---|---|---|---|
key, path, lineno, kind
|
Compiler | Python AST literals | Chat completion |
default_in_code |
Compiler | Literal default argument | Guessed production value |
nearby_comment |
Compiler | Preceding comment on the same block | Rewritten marketing copy |
description_draft |
Optional model | Non-secret rows after secret_class is set |
Any row still marked unsigned
|
secret_class |
Human | Reviewer decision: public, sensitive, secret
|
Model classification |
prod_default |
Human | Runbook or ops owner | Code default copied blindly |
rollout_notes |
Human | Change window, dual-read, rollback | Generated urgency |
signed_by, signed_at
|
Human | Reviewer identity and timestamp | Empty string in CI |
Three ownership rules follow from that matrix and should be enforced in code, not in review folklore. First, secret classification happens before any draft prose is requested, because descriptions of secret keys tend to invite example values. Second, production defaults are never copied from development fallbacks, because local defaults are often empty, insecure, or single-node. Third, rollout notes stay outside the model because they encode freeze windows and dual-write steps that a parser cannot see.
Artifact: extract proven literals into config_atlas.generated.json
The extractor below walks a directory of .py files and records only string literals passed to os.getenv, os.environ.get, and os.environ[...]. It also captures a literal default when present, plus a comment on the previous line when that comment exists. Dynamic names are counted, not invented, so the report can fail loudly when coverage is incomplete. Label this script as a local, reproducible artifact; run it on your tree rather than trusting the sample numbers in comments.
#!/usr/bin/env python3
"""Extract literal config keys from os.environ / os.getenv call sites."""
from __future__ import annotations
import ast
import json
import sys
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import List, Optional
@dataclass
class ConfigHit:
key: str
path: str
lineno: int
kind: str
default_in_code: Optional[str]
nearby_comment: str
dynamic_name: bool
class EnvironVisitor(ast.NodeVisitor):
def __init__(self, path: Path, source: str) -> None:
self.path = path
self.lines = source.splitlines()
self.hits: List[ConfigHit] = []
self.dynamic_count = 0
def _comment_above(self, lineno: int) -> str:
idx = lineno - 2
if idx < 0 or idx >= len(self.lines):
return ""
line = self.lines[idx].strip()
return line[1:].strip() if line.startswith("#") else ""
def _literal(self, node: ast.AST) -> Optional[str]:
if isinstance(node, ast.Constant) and isinstance(node.value, str):
return node.value
return None
def _record(
self,
key_node: ast.AST,
kind: str,
default_node: Optional[ast.AST],
lineno: int,
) -> None:
key = self._literal(key_node)
if key is None:
self.dynamic_count += 1
self.hits.append(
ConfigHit(
key="<dynamic>",
path=str(self.path),
lineno=lineno,
kind=kind,
default_in_code=None,
nearby_comment=self._comment_above(lineno),
dynamic_name=True,
)
)
return
default = self._literal(default_node) if default_node is not None else None
self.hits.append(
ConfigHit(
key=key,
path=str(self.path),
lineno=lineno,
kind=kind,
default_in_code=default,
nearby_comment=self._comment_above(lineno),
dynamic_name=False,
)
)
def visit_Subscript(self, node: ast.Subscript) -> None:
if (
isinstance(node.value, ast.Attribute)
and node.value.attr == "environ"
and isinstance(node.value.value, ast.Name)
and node.value.value.id == "os"
):
sl = node.slice
self._record(sl, "environ_subscript", None, node.lineno)
self.generic_visit(node)
def visit_Call(self, node: ast.Call) -> None:
func = node.func
if isinstance(func, ast.Attribute) and func.attr == "getenv":
if isinstance(func.value, ast.Name) and func.value.id == "os" and node.args:
default = node.args[1] if len(node.args) > 1 else None
self._record(node.args[0], "os_getenv", default, node.lineno)
if (
isinstance(func, ast.Attribute)
and func.attr == "get"
and isinstance(func.value, ast.Attribute)
and func.value.attr == "environ"
and isinstance(func.value.value, ast.Name)
and func.value.value.id == "os"
and node.args
):
default = node.args[1] if len(node.args) > 1 else None
self._record(node.args[0], "environ_get", default, node.lineno)
self.generic_visit(node)
def extract(root: Path) -> dict:
hits: List[ConfigHit] = []
dynamic_total = 0
files = 0
for path in sorted(root.rglob("*.py")):
if any(part.startswith(".") or part == "venv" for part in path.parts):
continue
source = path.read_text(encoding="utf-8")
tree = ast.parse(source, filename=str(path))
visitor = EnvironVisitor(path, source)
visitor.visit(tree)
hits.extend(visitor.hits)
dynamic_total += visitor.dynamic_count
files += 1
return {
"root": str(root.resolve()),
"python_files_scanned": files,
"dynamic_name_count": dynamic_total,
"hits": [asdict(h) for h in hits],
}
def main() -> int:
root = Path(sys.argv[1]) if len(sys.argv) > 1 else Path(".")
payload = extract(root)
Path("config_atlas.generated.json").write_text(
json.dumps(payload, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
print(
f"wrote config_atlas.generated.json with {len(payload['hits'])} hits "
f"and {payload['dynamic_name_count']} dynamic names"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
A generated file is not documentation yet, because it still lacks secret class, production default, and rollout notes. Store it beside a human file named config_atlas.owned.json so reviewers can diff policy without rereading syntax trees. The owned file is keyed by key plus path plus lineno, which keeps two call sites of the same name from sharing one secret class by accident. When a site disappears from the generated file, CI should fail until the owned row is deleted or marked retired.
{
"rows": {
"API_TIMEOUT_MS::src/client.py::41": {
"secret_class": "public",
"prod_default": "2500",
"rollout_notes": "Raise with a 10-minute dual-read of p95 latency; no restart storm.",
"signed_by": "ops-oncall",
"signed_at": "2026-09-18T14:00:00Z",
"description_final": "Client-side HTTP timeout in milliseconds."
},
"BILLING_WEBHOOK_SECRET::src/webhooks.py::17": {
"secret_class": "secret",
"prod_default": "unset-in-docs",
"rollout_notes": "Rotate in the secret store first; never paste a value into README.",
"signed_by": "security-review",
"signed_at": "2026-09-18T14:05:00Z",
"description_final": "Shared webhook authenticator. Value lives outside git."
}
}
}
Numbered workflow
Scan on every documentation-affecting commit. Run
python extract_config_keys.py srcin CI and commitconfig_atlas.generated.jsononly if your review culture wants the inventory visible. Prefer generating it in CI and uploading it as an artifact when the tree is large, because generated noise can bury policy diffs. Keep the owned file in git either way, because signatures are the review surface. Fail the job whendynamic_name_countexceeds a budget you chose, such as zero for new packages.Join generated hits to owned rows by stable identity. A small join script should iterate generated hits and require an owned row for every non-dynamic key. Missing rows print as unsigned work, not as suggested secret classes. Extra owned rows for deleted call sites print as stale policy that a human must retire. Do not auto-create owned rows with empty
secret_class, because empty rows become a habit that CI then rubber-stamps.Classify secrets before any prose draft exists. A reviewer sets
secret_classtopublic,sensitive, orsecretusing call-site context and the nearby comment.sensitivecovers tenant identifiers and non-credential tokens you still refuse to put in public screenshots.secretcovers passwords, private keys, webhook authenticators, and session material. If the reviewer is unsure, the class issecretuntil a security owner demotes it.Write production defaults and rollout notes by hand. Production defaults come from the runbook, Helm values, or secret store documentation, not from the AST default. An AST default of
"debug"or"localhost"is evidence of local convenience, not evidence of operations intent. Rollout notes name the freeze window, the dual-read, and the rollback, in sentences an on-call engineer can execute. Leaveprod_defaultasunset-in-docsfor secret rows so the README cannot grow sample credentials.Optionally draft descriptions for public rows only. After
secret_classis signed, a model may rewritenearby_commentplus the identifier into a one-sentencedescription_draftforpublicrows. Sensitive and secret rows keep humandescription_finaltext that must not include values, rotation commands with real paths to production, or pasteable examples. Merge happens in a script that copies drafts into a holding field, never intoprod_defaultorrollout_notes. A human still promotesdescription_drafttodescription_final.Render the README table from the join, not from chat. A renderer emits Markdown for public and sensitive keys, and emits only the key name plus
unset-in-docsfor secret keys. The renderer must refuse to printdefault_in_codefor secret rows even when the AST found a literal, because developers sometimes hard-code a non-empty development secret. Put the rendered fragment behind a<!-- generated: config-atlas -->marker so the rest of the README can stay hand-written. The tutorial sections, warnings, and destructive command examples remain human text.
#!/usr/bin/env python3
"""Fail CI when generated config hits lack signed policy cells."""
from __future__ import annotations
import json
import sys
from pathlib import Path
REQUIRED = ("secret_class", "prod_default", "rollout_notes", "signed_by", "signed_at")
ALLOWED_CLASS = {"public", "sensitive", "secret"}
def identity(hit: dict) -> str:
return f"{hit['key']}::{hit['path']}::{hit['lineno']}"
def main() -> int:
generated = json.loads(Path("config_atlas.generated.json").read_text(encoding="utf-8"))
owned = json.loads(Path("config_atlas.owned.json").read_text(encoding="utf-8"))
rows = owned["rows"]
errors = []
if generated["dynamic_name_count"] > 0:
errors.append(f"dynamic names: {generated['dynamic_name_count']} (budget is 0)")
gen_ids = set()
for hit in generated["hits"]:
if hit["dynamic_name"]:
continue
ident = identity(hit)
gen_ids.add(ident)
row = rows.get(ident)
if row is None:
errors.append(f"unsigned hit {ident}")
continue
for field in REQUIRED:
if not str(row.get(field, "")).strip():
errors.append(f"empty {field} on {ident}")
if row.get("secret_class") not in ALLOWED_CLASS:
errors.append(f"bad secret_class on {ident}")
if row.get("secret_class") == "secret" and row.get("prod_default") != "unset-in-docs":
errors.append(f"secret row must use unset-in-docs: {ident}")
for ident in sorted(set(rows) - gen_ids):
errors.append(f"stale owned row {ident}")
if errors:
print("config atlas gate failed:")
for item in errors:
print(f"- {item}")
return 1
print(f"config atlas gate passed for {len(gen_ids)} keys")
return 0
if __name__ == "__main__":
raise SystemExit(main())
A minimal local check looks like the commands below, using a fixture tree rather than production secrets. Create two Python files with known getenv calls, run the extractor, then run the gate before and after filling config_atlas.owned.json. The first gate run should fail on unsigned hits; the second should pass only when policy cells are complete. That failure-then-pass pair is the test plan for this pipeline.
python extract_config_keys.py ./src
python check_config_atlas.py; echo "exit $?"
# fill config_atlas.owned.json using the identities printed by the gate
python check_config_atlas.py; echo "exit $?"
Optional draft pass after policy cells are locked
Disclosure: This article was prepared as part of MonkeyCode's product outreach. The draft pass is optional and sits after the gate, never before it. MonkeyCode's free model access can turn a public identifier plus a nearby comment into a one-sentence description_draft, and the free server option is only relevant if you want that draft job off a laptop. Do not send secret rows, prod_default values, or rollout notes to any model, including one running on a machine you control, if those notes contain customer identifiers or rotation material.
A safe prompt is a structured bundle of key, kind, nearby_comment, and secret_class=public, with an instruction to return JSON {key, description_draft} and nothing else. Reject any draft that contains =, bearer tokens, PEM fences, or the substring example value. The join script should drop drafts that mention keys classified secret even if the model emitted them unsolicited. Teams that already host models internally can keep those hosts; the atlas and gate do not require a vendor.
Limitations
The extractor does not understand os.environ.copy(), dotenv parsers, YAML defaults, Kubernetes envFrom, or keys assembled from prefixes plus loops. Framework helpers such as Pydantic BaseSettings and Click envvar= need a second visitor, and shipping only the os visitor will under-count those codebases. Comment harvest is a heuristic that reads one previous line, so block comments and docstring notes are ignored. Dynamic-name budget of zero is realistic for new modules and unrealistic for mature metaprogramming, so choose the budget per package rather than globally.
Rendered README tables also cannot replace install prerequisites, threat notes, or destructive operator examples. Those sections stay in the human lane even when every key is public, because they encode judgment about who may run a command. Timestamped signatures do not prove competence; they only prove that CI saw non-empty cells. If your review process rubber-stamps signed_by: ci-bot, the gate becomes theater and you should stop publishing the table.
Who should not use this approach
Skip this pipeline if the repository is not Python, or if config is entirely declared in infrastructure files the AST visitor cannot see. Skip it if no human will classify secrets, because a model-assigned secret_class is how credentials get described with example values. Skip it if the product requires pasting live production defaults into public docs, because that requirement already violates the owned-cell contract. Skip it if you need a narrative architecture guide rather than a config inventory; this workflow will not write that guide, and stretching it there recreates the original failure mode.
The core conclusion does not change when a draft model is added or removed. Compile the keys you can prove, sign the cells that change incidents, and keep generated prose away from secrets, production defaults, and rollout language. If those three owned cells are honest, a shorter README table is safer than a fluent paragraph that nobody can verify. If they are not honest, no amount of generated description will make the documentation an engineering artifact.
Top comments (0)