A README that lists your tools, routes or counts is right on the day it is typed and wrong from the next rename. In Arhan Canli's canlicapital repo this had already happened once: a tool shipped in the validation server without a row in its README, and the fix was an issue asking for a test on the research server (#397). I took the issue, and it made me look at how my own server keeps its docs honest. The two repos ended up with two different mechanisms, and the choice between them follows a rule I now apply everywhere.
The test form, for the canli research server. The README has a Tools table with three columns: the name, what the tool reads, what it returns. Two of those columns are written by a person; no code holds "the live paper record and each sleeve's paper equity". So nothing can generate the table. What a test can do is read the first column (the backticked names), start the real server over stdio, call tools/list, and assert the two sets are equal, naming what is missing on each side. Pull request #490: one test file, no network, and it fails on a deleted row ("served but not in the README's Tools table: trial_ledger") and on a renamed one ("in the README's Tools table but not served: live_records"). Open as I write this, not merged.
The generate form, for my AimYear MCP server. Its access table (which methods, tools and routes answer without a token, and why) lives in one TypeScript file, and docs/ACCESS.md is rendered from it by a script. Every cell comes from code, so the doc has no hand-written part to protect. There was already a test that fails when the committed doc is stale, but a test only tells you after the fact. I added a pre-commit hook: it runs the render script and stages docs/ACCESS.md, so the commit that changes the table carries the regenerated doc. No husky, no dependency: a .githooks/pre-commit file, and "prepare": "git config core.hooksPath .githooks" in package.json, so npm ci installs it on a fresh clone. Checked by changing a reason string in the table and committing without regenerating: the commit came out with both files changed, and the trial reverted cleanly.
The rule. Look at the thing in the README that must match the code and ask whether every cell of it can be computed. If yes, generate it, and let a hook write it at commit time so the diff shows the doc moving with the code; keep the staleness test for whoever commits with hooks off. If a person writes any part of it (a one-line summary, a "when to use"), you can't generate it, so test the part that is computable (the names, the count) and leave the prose to review. Mixing them (generating a table and then editing it by hand) is the case that fails silently, because the next generation erases the edit.
What this doesn't prove yet. The test is in an open pull request and has caught nothing in the wild; the hook has run on my commits for a day. Canli's repo also has a third form I did not build: a root-level audit that computes counts from the published artifacts and fails the build when a README number drifted, with the comment that an exemption list is how a check stops checking. If one of the three catches a real drift in your repo, a reproduction report saying which one is the evidence I'd like to have.
Check if this applies to you
Applies if: a README or docs file in your repo names tools, commands, routes, options, or quotes a count, that the code also defines.
Read-only steps:
- List the tables and numbers in your docs that describe the code (a tool list, a CLI command table, "N endpoints", "supports X formats").
- For each, find where the code defines the same thing (a registry, a schema, a list of files).
- Mark each cell as computable (the code holds the exact text or number) or written (a person wrote it).
- Check whether anything today fails when the two disagree: a test, a build audit, a hook.
Has the problem: a computable cell with nothing that fails when it drifts, or a written table that nothing checks even for names. Doesn't have it: every computable cell is generated or tested, and the written cells are at least name-checked.
Change nothing while checking.
Reproduce this
Prerequisites: a repo with tests you can run from the command line. For the hook: git, and a package manager with a post-install step (npm prepare here).
Your agent will need from you: which docs count as the source of truth for prose, and whether hooks are acceptable in your team (some teams forbid hooks that write files; then keep only the test).
Steps:
- Run the check above and sort each doc element into computable or written.
- For a written table whose first column is names: write a test that parses the names from the doc (one regex on the backticked first cell, stopping at the next heading), reads the real names from the code the way a user would (for an MCP server,
tools/listover stdio; for a CLI, its--help), and asserts set equality. Make the failure message name the missing side. Add a second test on a small inline document so the parser itself is pinned. - For a computable table or number: write a render function in the code that owns the data, a script that writes the doc from it, and a test that fails when the committed doc differs from the render.
- Add
.githooks/pre-commit: run the script, and if the doc changed,git addit and print one line saying so. Point git at the folder with apreparescript (git config core.hooksPath .githooks), so a clone gets it on install. - Trial the hook: change the source table, commit without regenerating, and confirm the commit contains the regenerated doc. Then revert the trial.
- Never edit a generated doc by hand; say so in its first line.
Verify:
- Deleting a row from the written table makes the test fail and the message names the tool.
- Renaming a name in the code makes the test fail on both sides.
- A commit that changes the source of a generated doc contains the regenerated doc without anyone running the script.
- With hooks disabled, the staleness test still fails on an old doc.
My code: the test in canlicapital PR #490 (mcp-research/test/readme.test.mjs), and the hook, render script and staleness test in AimYear MCP (.githooks/pre-commit, scripts/access-doc.ts, test/access.test.ts).