A pile of notes rots from the edges. The note is still there, still readable, still confidently telling you to see the setup doc — and the setup doc was renamed eight months ago.
Markdown does not notice. The link renders. The anchor renders. The code block you pasted out of a source file renders exactly as it did the day you pasted it, including the two lines that source file no longer contains.
stale reads a folder of notes and reports three kinds of rot — links whose target file is gone, anchors whose heading is gone, and fenced blocks that claim to be copied from a source whose lines have changed — with the evidence that produced every verdict.
👉 Live, with every table computed in your browser by a port of the same engine: https://dev48.in/agentlab/vol5-03-stale.html
🔴 The corpus is invented
There is no public archive of one person's notes, and borrowing somebody else's is both a privacy problem and a licensing one. So the 30 notes and 6 source files were generated from the fixed seed 20260083, and re-running the generator reproduces the tree byte for byte.
What that costs: every point estimate below is a property of this tree and would move on a real one. What transfers is the shape — that the three option flags disagree with each other at all, and roughly by how much, follows from how markdown and filesystems work rather than from the draw. The defects were not placed to hit a target: the generator draws each reference from a distribution, and every number here was measured afterwards by running the tool.
30 notes, 136 references checked, 45 stale.
The link that passes on your laptop and 404s in CI
NTFS and APFS match filenames without regard to case. ext4 does not.
A link written as setup.md against a file called Setup.md works on the machine it was written on and breaks the moment the notes are published from Linux. A checker built on os.path.exists reports PASS on the first machine and FAIL on the second, and never mentions that it changed its mind.
9 of the 95 link references — 9.5% — resolve only case-insensitively, across 9 notes and 7 target files.
notes/cell-formatting.md:8 setup.md#step-1-install
no notes/setup.md; notes/SETUP.md differs only in case
So stale resolves against an index of the tree rather than against the filesystem, and the case rule is a flag with a printed value. The answer is a property of the notes.
It is also not pure subtraction. A link that fails on case never had its #fragment read, so switching to --case insensitive exposes 5 anchor references that were hiding behind a link failure. Fixing one check uncovers another.
Two anchor conventions, disagreeing about four in ten
GitHub lowercases a heading, deletes punctuation rather than replacing it, hyphenates the spaces, and suffixes a repeat with -1. The hand-rolled version everybody writes lowercases, hyphenates, and stops.
| heading | GitHub slug | naive slug |
|---|---|---|
What's next? |
whats-next |
what's-next? |
Step 1: install |
step-1-install |
step-1:-install |
Rollout / rollback |
rollout--rollback |
rollout-/-rollback |
Notes (second one) |
notes-1 |
notes — collides |
rollout--rollback has two hyphens, because the slash is deleted and both spaces around it survive. Nobody writes that anchor by hand, which is exactly why the naive checker calls the correct one broken.
Over the corpus's 27 anchor references the two conventions disagree on 11 — 40.7% — and in both directions: 6 that only the GitHub rule accepts, 5 that only the naive rule accepts. Neither is a superset of the other, so neither is the safe default.
The verdict on four in ten anchors is a property of the slugger you picked, not of the notes.
One line of whitespace handling moves a third of the quote verdicts
A fenced block can declare where it came from, and that declaration is the thing being checked:
<!-- from: ../src/parse.py#L12-L20 -->
```python
def retry(fn, attempts=3, backoff=0.5):
...
```
Of 14 quoted blocks, 7 differ from their source byte for byte. 2 of those 7 — 28.6% — are character-identical once whitespace is normalised: re-indented when they were pasted, or carrying a trailing space. Same notes, same sources, one flag.
stale defaults to exact, because indentation is semantic in the language most of these blocks are written in. But it prints resolved-after-normalising rather than resolved, so you can see which of the two answers you are reading.
The number that decides whether anyone reads the report
45 findings sounds like a working detector right up until you look at where they land.
They land in 22 of 30 notes — 73%. And across all eight combinations of the three flags, the base rate never drops below 60%:
| case / anchors / quote | stale | notes flagged | base rate |
|---|---|---|---|
| strict / slug / exact | 45 | 22 | 73% |
| strict / raw / exact | 46 | 22 | 73% |
| insensitive / slug / exact | 36 | 20 | 67% |
| insensitive / raw / normalised | 38 | 18 | 60% |
| insensitive / slug / normalised | 34 | 18 | 60% |
Loosening every flag at once removes 11 findings and still leaves 60% of the pile flagged. A tool that flags three quarters of your notes has not triaged anything — it has handed you the folder back.
The flags are not the lever. The findings are not independent.
One file that got renamed shows up once per note that linked to it. Collapse the findings onto the thing you would actually repair and 45 findings become 25 fixes, with the top four clearing 18 of them:
6 target-missing: notes/setup-guide.md
5 target-missing: notes/index.md
4 target-missing: notes/old-glossary.md
3 case-only-match: notes/2026/Handoff-2025.md
Nothing about the detector changed. Only what it counted. That is the difference between a list of symptoms and a list of jobs, and it is the only honest answer a sensitive detector has to its own base rate.
How it is checked
The page ships a JS port of the Python package. The day's verifier lifts that port straight out of the HTML — the bytes the browser runs — evaluates it in Node, runs the Python package as a subprocess, and compares them on 76 trees: the real corpus under all eight flag settings, 60 generated ones, and the edge cases.
1,933 references compared — verdict, reason, and evidence string, 9,162 assertions, 0 failures. Plus 84 pytest cases and a 36-check panel in the page itself.
A port that is only checked against itself is not checked. The failure worth guarding against is the one that does not look like a failure: both halves run, both produce a report, and the reports differ.
Offline, standard library, no model
No key, no network, no LLM. Python 3.8+, dependencies = [].
MIT. Part of Agent Lab Vol 5 — reading the pile you already have.
Top comments (0)