Skip to content
Chapter 102Lesson 3

Docs ship in the PR, or they're already wrong

The pull-request discipline that keeps documentation accurate, shipping every doc update alongside the code change that affects it.

A pull request splits the test suite into unit and end-to-end tests, and renames the command the README tells people to run from pnpm test to pnpm test:unit. The code is correct, CI is green, the PR merges.

The README still says pnpm test.

Three weeks later someone new clones the repo, follows the README, and types pnpm test. The shell answers command not found. They check their typing, their Node version, the setup steps, then ask in the team channel, where someone answers in ten seconds: it’s test:unit now. They lost an afternoon, and they learned to check the code before the README.

No line of code was wrong. A PR changed something the README makes a claim about, and the stale README shipped in the same repo on the same day, because nobody asked the question this lesson is about: what did this PR claim, and which docs make a claim about that?

This chapter moved documentation inside the source file, with TSDoc on the public surface and // why comments, joining the README, AGENTS.md, and ADRs from the previous chapter. Every one is one merge away from being silently wrong, and this lesson is the discipline that keeps them true: a rule for when a doc ships, a map of which artifact moves with which change, a reviewer’s checklist, and the line where automation stops and a human has to read.

Start with the obvious case: a missing doc, a function with no doc comment or a command the README never mentions. It costs the reader real time, since they have to read the code to learn what the doc would have told them. But that cost is bounded: paid once, up front, with nothing there to mislead them.

Now the case that looks the same but isn’t: a wrong doc, one that asserts something the code no longer does. This is drift: a doc and the code it describes have fallen out of sync, so the doc still claims something that’s no longer true. It costs the next reader far more. Trace what the README cost the newcomer in the opening:

  • They read it, the same time a missing doc would have cost them.
  • They believed it and acted on it: they ran the command and watched it fail.
  • They spent an afternoon discovering it was wrong, suspecting their own setup first, because a doc is supposed to be true.
  • They stopped trusting it, not just that line but the whole README and every doc beside it, so now they verify all of them against the code, the exact work the docs were supposed to save.

That last cost is the one that compounds. A missing doc is a hole; a wrong doc is a trap, and once a reader steps in one, they distrust the whole floor.

Missing doccost ledger
1Reader goes and reads the code instead.
Total:one cost, paid once, up front. The reader is never misled.
Bounded and one-time: the reader knows to go straight to the code.

The asymmetry is what makes paying up front a reflex. Updating the doc inside the PR, while the change is fresh and the diff is in front of you, costs about fifteen minutes. Discovering a wrong doc in production costs days: someone hits the wrong claim, traces it, fixes it, and rebuilds their trust in the docs. “I’ll do it later” only feels cheaper because the fifteen minutes lands on you now and the days land on someone else later.

A code change that breaks a doc claim updates that doc claim in the same PR. Not the next PR, not a follow-up ticket, not “before release.” The same one.

The reason is structural. A change to production passes exactly one checkpoint where someone looks at it closely: code review. The next checkpoint after it is production. So a doc the change should have updated gets a single shot at being noticed, that one review, before it is wrong in front of real users with nobody looking. Review is the only moment when the change and the docs that describe it sit in front of a person together; once it merges, the docs are wrong and no one is reading them.

This is why “I’ll fix the docs in a quick follow-up PR” doesn’t escape the problem, it just narrows it. The code PR merges Monday, the doc PR merges Wednesday, and in between main contradicts itself. On a team that deploys main continuously, that is a wrong doc live in production for two days. The same-PR rule is the only option where that window has zero width, because the code and its doc land in one merge or neither does.

The doc-change map: which artifact moves with which change

Section titled “The doc-change map: which artifact moves with which change”

When you open a PR, run one question down the same seven doc surfaces: did this diff invalidate a claim that surface makes? You answer yes or no on each, every time, which is what makes it automatic. Knowing which surface to skip is as much the skill as knowing which to update; most PRs touch one or two and leave the rest, so half the map is the “usually doesn’t move” case. Here are the seven, each with its trigger and its quiet case.

  • README moves when the local-dev sequence changes, a common-task command changes, or the stack swaps. Most feature PRs don’t touch the thin README; it’s deliberately small, so its few claims are the ones worth watching.
  • AGENTS.md moves when a convention shifts: a new “don’t” rule, a new module in the repo layout, a renamed build or test command, a tool added to the stack. Feature PRs rarely move it; refactor and infrastructure PRs often do.
  • ADRs. A PR that ships an architectural decision adds a new ADR ; a PR that overturns one flips an existing ADR’s status to “superseded.” Both happen in the deciding PR, never a follow-up. The three-test bar still applies: not every change is a decision worth an ADR.
  • TSDoc moves when an exported function’s signature, contract, side effects, or failure modes change: a new @throws, an updated @param, a @deprecated mark, or a refreshed summary sentence. Which functions earn a block hasn’t changed; this is about keeping the blocks you wrote honest.
  • Inline // why comments move when the why changes. The constraint got fixed upstream, so the comment is now a fossil comment and the workaround it guards goes with it; or the constraint got promoted into enforcement, so a type, test, or transaction replaces both. The comment travels with the lines it explains or dies with them, never outliving its reason.
  • Schema header comments. The one-paragraph header on a pgTable declaration moves when the table’s purpose, scope, or invariants change. A new column usually doesn’t trigger it; a new invariant does, like a uniqueness rule, a tenancy constraint, or a new lifecycle the table now enforces.
  • .env.example moves whenever env.ts adds, removes, or renames a key. env.ts is the validated source of truth and .env.example is the human-readable hint a new developer copies; an env.ts change without a matching .env.example change is an incomplete PR.

That last pair shows why the human checklist exists. env.ts is enforced: a missing required variable fails pnpm build. But .env.example is plain text that nothing validates. Add RESEND_API_KEY to env.ts and forget the example, and the build stays green while the next developer to clone the repo never learns the variable exists until something fails at runtime. The build catches a missing var; nothing catches a stale example, and only a person reading the diff closes that gap.

The diagram puts each kind of code change on the left and the doc surface it moves on the right, so opening a PR fires the right edge automatically.

flowchart LR
  cmd["Renamed a command"]
  env["Added / renamed<br/>an env var"]
  sig["Changed an<br/>exported signature"]
  adr["Made an<br/>architectural decision"]
  why["Fixed the bug a<br/><code>// why</code> guarded"]
  inv["Changed a<br/>table's invariant"]
  conv["Added a convention<br/>/ new module"]

  readme["README"]
  agents["AGENTS.md"]
  envfiles["<code>.env.example</code> + <code>env.ts</code>"]
  tsdoc["TSDoc"]
  adrdoc["ADR<br/>(new / superseded)"]
  whydoc["<code>// why</code> comment<br/>(moves / dies)"]
  schema["Schema header"]

  cmd --> readme
  cmd --> agents
  env --> envfiles
  sig --> tsdoc
  adr --> adrdoc
  why --> whydoc
  inv --> schema
  conv --> agents

  class cmd,env,sig,adr,why,inv,conv change
  class readme,agents,envfiles,tsdoc,adrdoc,whydoc,schema artifact
  classDef change fill:#dbeafe,stroke:#1d4ed8,color:#111,stroke-width:2px
  classDef artifact fill:#bbf7d0,stroke:#15803d,color:#111,stroke-width:2px
Run down the left column and follow the edges. Most PRs light up one or two surfaces and leave the rest dark.

One edge separates following the map from understanding it: a plain new column does not move the schema header. A nullable feature_flag_enabled column is just more shape, and the table’s purpose, scope, and invariants are unchanged. The header moves only when the new column carries an invariant, like a uniqueness rule or a tenancy constraint. The cut is purpose and invariants, never field count.

So far you’ve been the author asking “which docs did my change touch?” Now flip to the reviewer’s chair, where the question is “did they move the docs they should have?” Same map, read from the other side of the PR. This is the review that keeps docs accurate, the enforcement TSDoc and comments both pointed to. Run these five checks in order on the diff:

  1. Signatures. Did any exported function’s signature, contract, or set of thrown errors change? If so, did its TSDoc update to match? A new parameter with no @param, a new error path with no @throws, a summary sentence that now describes the old behavior: all drift.

  2. Env vars. Did any environment variable get added or renamed? If so, did env.ts and .env.example both update? The build enforces one side; you’re the only thing enforcing the other.

  3. Conventions and layout. Did any convention or repo-layout fact change, such as a renamed command, a new module, or a new rule? If so, did AGENTS.md update? This is the surface that rots most quietly, because no single small change feels like it touches it.

  4. Decisions. Did this PR make an architectural decision, or overturn one? If so, is there a new ADR, or a status flip on the old one? A cross-cutting pattern introduced with no ADR is a decision nobody recorded.

  5. Stripped comments. Was a // why comment removed in a refactor? If so, was the constraint it protected either preserved in a moved comment or upgraded to enforcement? If the comment is just gone and the constraint is gone with it, that’s a bug walking back in.

Check five is the subtle one. The first four ask you to spot something in the diff; check five asks you to spot something missing from it, a comment that used to be there and isn’t anymore. That is the hardest thing a reviewer does, because nothing on the screen draws your eye to a deleted line: you have to read the minus lines as carefully as the plus ones. Remember the deleted setTimeout from the last lesson, the load-bearing sleep tidied away with no comment that surfaced as a flaky production bug. A // why line beside it would have turned that silent deletion into a question, and this is the checkpoint where it gets asked.

Now review a real one: four files, four defects, one per surface from the checklist. Find where a doc no longer matches the code it describes, and click the line to name the drift.

Find where a doc no longer matches the code it describes, then click the line and name the drift. Four defects, one per surface. Click any line to leave a review comment, then press Submit review.

src/lib/billing/charge.ts
/**
* Charges a finalized invoice through Stripe and records the result.
*
* @param invoiceId - the invoice to charge
* @throws when the invoice is not in the `finalized` state
*/
export const chargeInvoice = async (invoiceId: string): Promise<Result<Charge>> => {
const invoice = await getInvoice(invoiceId);
if (invoice.status !== 'finalized') return err('not_finalized', 'Invoice must be finalized before charging.');
if (invoice.amountCents > org.chargeLimitCents) {
throw new ChargeLimitError(invoice.amountCents);
}
return ok(await stripe.charge(invoice));
};

That fourth plant raises a question the checklist doesn’t answer: what does a reviewer do with an incomplete PR? The mechanism is a block. The reviewer declines to approve until the missing doc ships, because a PR that changes a contract without updating its doc isn’t done. How to block well, the language that makes it land as help, is the subject of the next chapter; for now, just hold the shape: an incomplete PR gets blocked, and the doc is part of what makes it complete.

Some drift a machine can compare, and some it can’t, and getting that line wrong is expensive: assume “tooling will catch it” and you stop reading diffs, so the half of drift no tool can see ships unguarded.

Mechanical drift is automatable. A machine detects it by comparing two things for a structural match, with no interpretation:

  • A .env.example-vs-env.ts key-parity check. The two files must declare the same keys, so a script computes both key sets and fails if they differ. Pure mechanics, zero judgment.
  • A TSDoc linter (such as eslint-plugin-tsdoc) flagging a @param that names an argument the function no longer has, or a malformed tag. It matches tag names against the signature, not meaning.
  • A test that imports a Server Action and exercises its success and failure paths. Change the contract in a way the test pins, and the test goes red.

Semantic drift is review-only. Being right requires reading intent against behavior, which no linter can do:

  • Does the TSDoc summary sentence still describe what the function does? Every tag can be present and well-formed while the sentence quietly describes last quarter’s behavior. A machine sees a valid summary; only a reader sees it’s wrong.
  • Does the README’s “getting started” prose still match the actual steps? The commands might all exist, so a parity check passes, while the order or the explanation is now wrong.
  • Is a // why comment still true, or a fossil for a bug since fixed? It’s syntactically fine, just describing a world that no longer exists.
  • Did this PR’s behavior warrant an ADR nobody wrote? No tool can decide a change was architecturally significant.

None of these have a structural fact to compare. They need someone who knows what the code is supposed to do, reading the doc against that. That someone is the reviewer.

So here is the threshold to carry out of this section: lint catches the drift a machine can compare; the reviewer catches the drift only a human can read. The env-parity check anchors the mechanical side, set equality and nothing else. The further a check gets from “two sets must be equal,” the more it belongs to a person.

A reflex evaporates the first busy week unless something holds it in place. Two lightweight rails do that: a PR template and a quarterly review.

A pull-request template is a markdown file that GitHub uses to pre-fill the description box whenever someone opens a PR. Put two checkboxes in it and the author has to look at the doc surfaces at PR-open time, before they request review.

.github/pull_request_template.md
## Docs
- [ ] I updated the docs affected by this change (TSDoc, `// why`, README, AGENTS.md, ADR).
- [ ] If I added or renamed a dependency or env var, `env.ts` and `.env.example` match.

Keep it to two lines. A template that runs to a page of checkboxes gets approved unread, which trains everyone to tick boxes without looking, worse than no template at all.

The template is a prompt, not enforcement: it can’t make anyone actually update a doc, and that was never its job. The reviewer’s five-check pass from two sections ago catches a box ticked falsely. The box makes the author look; the checklist verifies they did; neither works alone.

Some docs rot without any single PR touching them. The README’s “getting started” sequence and the AGENTS.md conventions drift slowly: a dozen small changes each leave them a little more out of date, but no one change is wrong enough to trip the per-PR check. Every PR is individually clean while the doc is collectively stale, and per-PR review can’t see it because there’s no single PR to point at.

The counter is a cadence, not a better review. Every quarter, someone follows the README from a genuinely clean clone and writes down every place it deviates from reality, then does the same for the AGENTS.md conventions against what the codebase actually does now.

Defend that cadence on the calendar, or it slips to “everyone’s too busy this quarter,” then the quarter after, and the meta-docs rot in exactly the slow, invisible way the cadence existed to prevent. Schedule it like dependency upgrades or on-call rotation: not urgent any given week, but the only thing that catches rot nothing else can.

One small connection: if your team uses conventional commits , a line in the commit body can flag that the PR touched docs, and changelog tooling picks it up. It’s useful but not load-bearing; the protection is the PR review, not the commit prefix.

The map, the checklist, the boundary, the template, the cadence: all of it is scaffolding around a single question to carry into every PR.

Before you request review, ask: what did this PR claim, and which docs make a claim about that?

This also closes the documentation half of the unit. Four ideas, each a lesson’s work, stack into one posture:

  • Docs live next to the truth, in the repo beside the code, not in a wiki nobody opens.
  • You link instead of duplicating, so a doc can’t drift from a source it never copied.
  • Volume tracks value: the public-surface cut for TSDoc and the why-not-what cut for comments leave only the docs worth reading.
  • And the lock on all of it: the doc ships with the change that affects it, or it’s already wrong.

The first three keep docs worth reading; the fourth keeps them true, and it is load-bearing, because without it the others decay: the doc next to the truth drifts on the next merge, the link goes stale, the cut TSDoc block describes a function that no longer behaves that way. With the fourth in place, the three compound instead.

This matters more in 2026, because a repo’s docs are read by whoever, or whatever, edits the code next: a stale AGENTS.md or a wrong TSDoc steers the next change wrong before a human ever looks.

The next chapter picks up where the reviewer’s pass left off and covers the whole craft of reviewing a pull request: what to look for beyond docs, when to suggest versus block, and the language that makes a review land as help.

Worth bookmarking: the philosophy this unit rests on, the reviewer’s pass from another angle, and the file that wires the reflex into a real repo.