A broken doc still renders.
That is the whole reason documentation rot is so dangerous.
The page looks fine right up to the moment someone clicks the link that went nowhere.
This week I shipped a small tool to catch that, and the tool taught me a lesson I keep relearning.
What the tool does
It scans a folder of markdown and flags three kinds of rot.
A link to a file that moved and now returns a 404.
A table of contents anchor that drifted away from its heading.
And the one most link checkers skip, a doc that nothing else references.
That last one deserves a name. It is dead code wearing a different hat.
Nobody deletes it because nobody is sure it is unused. Nobody reads it because nothing points to it. It sits there and goes stale, quietly lying to the next person who finds it by accident.
If you have ever run a dead code check on a codebase, you already understand the orphan doc. Same idea, applied to markdown.
The part where I got humbled
I wrote 15 tests. They ran on Ubuntu and macOS. All green.
Then I pointed the checker at my own setup, hundreds of real files, and it lit up a long table of contents as completely broken.
Every anchor reported wrong.
Except the anchors were fine. I checked by hand. The links matched the headings.
The bug was mine, and it was a good one.
The bash trap
Here is the heading lookup, simplified.
set -uo pipefail
# does the anchor match any heading slug in the file?
heading_slugs "$file" | grep -Fxq "$anchor" || report_broken "$anchor"
It reads as correct at a glance. Generate the heading slugs, search them for the anchor, and report only when the search fails.
The trap is the interaction between two things you turned on for safety.
grep -q exits the moment it finds a match. It does not read the rest of the input. When it exits, it closes the pipe.
heading_slugs, still writing, gets a broken pipe and dies with a non-zero status.
set -o pipefail then declares the whole pipeline failed, because one stage in it failed, even though the stage that mattered succeeded.
So a real match was reported as a miss. On a one heading doc the producer finishes before grep closes the pipe, so the bug hides. On a forty heading doc it fires every time.
The fix is to keep the producer out of grep's pipeline.
# process substitution. grep's exit status is its own, the producer's death
# is not part of the pipeline, so pipefail stays out of it.
grep -Fxq "$anchor" <(heading_slugs "$file") || report_broken "$anchor"
Two smaller bugs were hiding behind the loud one. The slug list was printed without newlines, so every slug ran together into a single line. And the slug collapsed repeated dashes while the platform that renders the anchors does not, so a heading with a separator mismatched its own valid anchor. The fix for the second one is to normalize both sides through the same function, so any difference cancels.
The two lessons
A passing test suite proves the cases you imagined. It says nothing about the cases you did not.
My tests had one heading because one heading was easy to write. Real docs have forty, and forty is where the bug lived.
The fastest way to find what your tool misses is to turn it on yourself first. The real corpus is the test the synthetic fixtures cannot be.
I shipped a checker for rot, and the checker found rot in the work of the checker's own author. That is the job working as intended.
One question for you
If you write bash, this trap is somewhere in your scripts right now.
A strict mode plus a command that exits early is a quiet false signal, and it only shows up under the inputs you did not test.
I write about the unglamorous parts of building tools that survive real use, the bugs that pass every test and fail on contact with reality.
So tell me. What is the bug your own tests were too polite to catch?
Top comments (0)