Generated References: One Source of Truth
When a source of truth changes, every doc projection should update automatically — generators with ownership markers make hand-editing a detected offense.
One Source, Many Projections
A codebase is full of structural facts that exist in at least two places: the code itself, and the documentation describing it. An API endpoint exists in a route definition and in the API reference. A config option exists in the TypeScript type and in the configuration guide. A CLI subcommand exists in the command definition and in the usage docs.
When the same fact lives in two places maintained independently, one of them will eventually be wrong. The question is only which one and when.
The generator pattern answers this by eliminating the duplicate. There is one source of truth; documentation is a projection of that source, produced automatically by a generator script. When the source changes, every projection regenerates. Nobody has to remember to update the docs — the CI run does it.
The pipeline diagram shows the four most common generator patterns: schema to types and reference docs, repo tree to README key-files table, CLI definitions to usage docs, and conventional commits to changelog. In each case, the same structural principle applies: the source changes, the generator runs, the output is overwritten. The doc cannot drift from the source because the doc is the source's output.
This is the A3 rung on the automation ladder from the docs-rot lesson. Drift tests (A2) verify that documented claims remain true. Generators (A3) ensure they are always true by definition — they cannot diverge because they are produced from the same data.
Ownership Markers: The Generator's Claim
A generator that produces an entire file is easy to reason about. But many docs have a structure where only some sections are computable: a README with a key-files table in the middle, surrounded by hand-written context and architecture notes. The generator needs to own its section without touching the prose around it.
This is where BEGIN/END ownership markers come in:
## Architecture Notes
This service handles all inbound webhook events from the upstream platform.
The retry logic is described in detail in the ADR for webhook reliability.
<!-- BEGIN-GENERATED: key-files-table -->
| Path | Purpose |
|------|---------|
| src/lib/tracks.ts | lesson registry |
| scripts/gen-key-files-table.ts | key-files table generator |
<!-- END-GENERATED: key-files-table -->
## Deployment
See docs/runbooks/deploy.md for the step-by-step deployment procedure.
The generator script replaces everything between its markers on each run. The narrative sections above and below the markers are never touched. The CI run regenerates the table, diffs the output against what is committed, and fails if there is a difference — meaning any hand-edit inside the markers is caught before merge.
The hand-edit trap is insidious because it feels like a fix. The developer sees a wrong row in the generated table, edits the markdown file directly, commits, opens a PR. The PR looks like a one-line doc fix. But on the next CI run, the generator overwrites their change because the source of the wrong data is still wrong. The fix was never in the doc — it was in whatever input the generator reads (the config file, the schema, the directory scan).
The diff gate catches this before merge: the CI run regenerates the section and checks git diff on the output. If the committed file differs from the freshly generated file, the gate fails with a message identifying which section has uncommitted drift. The developer is told to fix the source, not the doc.
What to Generate and What Not to Generate
The generator pattern is powerful but has a boundary. Knowing where that boundary is prevents over-application.
Generate: anything that is computable from a structured source without judgment. Type lists from schemas. Key-files tables from directory scans plus a config file of descriptions. Changelog sections from conventional commits. API endpoint lists from route definitions. Version strings from package.json. Counts of any kind.
Do not generate: anything that requires reasoning about why, not just what. ADRs record decisions — the alternatives considered, the tradeoffs, the specific constraints of the situation. That reasoning cannot be extracted from a schema. Runbook prose explains what to do and why at each step; while the step structure can be templated, the explanations are judgment. Context files (CLAUDE.md) contain rules and hard constraints accumulated from experience — they must be written and updated by someone who understands the system.
The rule is: generate the data, write the prose. A key-files table is data — paths and one-line descriptions derived from a config file. An architecture overview is prose — it requires a human to have formed an opinion about the architecture. Both can coexist in the same document, separated by ownership markers.
The Generator Script Pattern
A generator script follows a consistent structure across all applications:
// scripts/gen-api-reference.ts
import * as fs from 'fs'
const BEGIN = '<!-- BEGIN-GENERATED: api-reference -->'
const END = '<!-- END-GENERATED: api-reference -->'
function generateFromSchema(schemaPath: string): string {
const schema = JSON.parse(fs.readFileSync(schemaPath, 'utf8'))
// Build the table from schema.paths
const rows = Object.entries(schema.paths).map(([path, def]) => {
const methods = Object.keys(def as Record<string, unknown>).join(', ')
return '| ' + path + ' | ' + methods + ' |'
})
return '| Endpoint | Methods |\n|----------|---------|' + '\n' + rows.join('\n')
}
function injectSection(content: string, generated: string): string {
const start = content.indexOf(BEGIN)
const endIdx = content.indexOf(END)
if (start === -1 || endIdx === -1) throw new Error('markers missing')
return content.slice(0, start) + BEGIN + '\n' + generated + '\n' + END + content.slice(endIdx + END.length)
}
const docPath = 'docs/api-reference.md'
const content = fs.readFileSync(docPath, 'utf8')
const generated = generateFromSchema('schema.json')
fs.writeFileSync(docPath, injectSection(content, generated), 'utf8')
The CI step that catches hand-edits:
# .github/workflows/ci.yml
- name: Regenerate docs
run: npx ts-node scripts/gen-api-reference.ts
- name: Fail on uncommitted generator drift
run: |
if ! git diff --exit-code docs/api-reference.md; then
echo "ERROR: api-reference.md has uncommitted generator drift."
echo "Run scripts/gen-api-reference.ts locally and commit the result."
exit 1
fi
This pattern scales to as many generators as the project needs. Each generator owns exactly one set of markers. CI regenerates all of them in sequence and fails if any output differs from what is committed.
The BuildChallenge for this lesson implements a complete key-files table generator: directory scan, description lookup, marker injection, and a test that verifies the generator is idempotent (running it twice produces the same output).