Stale documentation has no symptoms. The file opens, the markup is valid, the date in the header looks plausible, the links still go where they always went. The only thing missing is the truth about the code as it stands today. A month ago someone pulled three functions out into a separate module, renamed the entry point, reordered the arguments. AGENTS.md never noticed, because it had nothing to notice with.
For a human that's an annoyance: you read it, you trip, you go read the code instead. For an agent it's something else. A model doesn't get that nagging sense that something doesn't add up — it takes a context file as fact and executes a fresh instruction and a description of six-month-old architecture with exactly the same confidence. Then it writes code against the outdated description, commits, and moves on. In ClewWiki — a self-hosted knowledge base where pages are written jointly by humans and coding agents, AGPL-3.0, still pre-alpha with no releases — the code anchors mechanism grew out of this problem.
Why "file and line numbers" isn't an anchor#
The obvious solution looks like this: store src/anchors/resolve.ts:127-184 next to the page and check whether those lines changed. It doesn't work, and it breaks faster than you'd expect.
A line range doesn't survive an edit above itself. Someone adds an import at the top of the file, everything shifts down a line, and the range now points at the neighbouring declaration. A formatter run flips nearly every range in the repository at once. Renaming a function, which changes nothing about behaviour at all, counts as a change too. The result is a stream of flags with only a handful of real ones in it, and two weeks later the team stops reading them.
That number is measured, not eyeballed. Across a real refactoring history — 26 commits, 479 anchors — naive line-range anchors produced roughly 50% false positives. Symbol anchors on the same history produced zero.
Hence the first decision: an anchor's identity is the pair {kind, qualified_name}, not a location. The languageForPath function is still itself after moving to another file. Anchors do carry a file_hint field, but it only exists to start the search in the right place — not to define what we're looking for.
Line ranges did survive, as an honest fallback for blocks that have no resolvable declaration: a config, a table of constants, a chunk of prose in a README. For those, indentation and blank lines are normalized, and every check response carries fallback_share — the proportion of such anchors in the space. A rising share means the space is drifting onto the path we know breaks; it's an early noise indicator, not decoration on the response.
Hash the tokens, not the text#
The second decision is what exactly to hash. Hashing the declaration's text is useless for the same reason a line range is useless: it reacts to everything. So an anchor stores a hash of the token sequence the parser emits, with comments dropped.
The practical difference: re-indenting a function, spreading arguments across several lines, rewriting a doc comment, swapping single quotes for double — none of that changes the hash. Changing what the code does changes it by construction. That's precisely the behaviour you want from a signal that says "the documentation about this is out of date."
The parsers are tree-sitter compiled to WebAssembly. This isn't posturing: native bindings would drag node-gyp into the runtime image and demand a rebuild on every Node upgrade — for a parser that only reads. A WASM build ships into the image as data and survives runtime upgrades. The declaration tables today cover Swift, TypeScript and TSX (.swift, .ts, .mts, .cts, .tsx); Kotlin is next.
A five-stage ladder and four states#
Checking an anchor is an ordered ladder, and it's the order that separates a refactor from an actual documentation problem:
| Stage | What we look for | Result |
|---|---|---|
| 1 | same file, same identity | fresh or stale |
| 2 | different file, same identity | moved |
| 3 | same body under a different name in the same container | renamed |
| 4 | same body anywhere | moved and renamed |
| 5 | nothing | lost |
Stages 3 and 4 match on the hash of the body, not of the full declaration. The reason is mechanical: the full hash covers the name, and a rename changes it by definition — so a rename is impossible to catch with the full hash, in principle.
The body hash has a trap of its own: short bodies collide by accident. { return nil } appears twenty times in a file, and any one of them will "prove" the function was renamed into it. So a body shorter than MIN_BODY_TOKENS = 8 tokens isn't matched at all. A confidently wrong answer is worse than an honest lost.
An anchor has four states. fresh — the declaration is in place and unchanged. stale — same declaration, changed body. moved-renamed — found it, and the response says where it lives now and what it's called now. lost — nothing resolves; this isn't "it broke," it's a request for a human decision: delete the anchor, re-point it, or accept that the page describes code that no longer exists.
Three anchors, one commit#
I verified the mechanics by hand on a local instance. Three symbol anchors on real functions in the package: resolveAnchor, languageForPath, lineRangeAnchor. First check — fresh, fresh, fresh. Then a commit adding one line to the body of languageForPath, and a re-check:
{
"complete": true,
"budget": { "files_read": 3, "bytes_read": 10524, "elapsed_ms": 35, "limit": null },
"anchors": [
{ "qualified_name": "resolveAnchor", "state": "fresh",
"detail": { "reason": "identity_matched" } },
{ "qualified_name": "languageForPath", "state": "stale",
"detail": { "reason": "body_changed" } },
{ "qualified_name": "lineRangeAnchor", "state": "fresh",
"detail": { "reason": "identity_matched" } }
],
"fallback_share": { "total": 3, "fallback": 0, "share": 0 }
}Exactly one anchor changed state. The two neighbouring declarations in the same file, shifted line-wise by that edit, stayed fresh — because lines have nothing to do with identity. In the UI it's a card: "function languageForPath — Stale — The declaration is still there, but its body changed," with a "Confirm reviewed" button.
That button matters. The flag is cleared only by an explicit confirm (POST /api/v1/anchors/{id}/confirm) — a deliberate, auditable act by a human or an agent. A badge that vanished on its own would devalue the silence of every other badge: you can't trust a green status if green sometimes means "nobody looked, it just went stale quietly."
An anchor resolves the moment it's created: a typo in a symbol name is rejected on the spot, instead of surfacing as a mysterious lost a week later.
Budgets, and what the system doesn't do#
A single check has hard limits: 2000 files, 32 MB of sources, 30 seconds; files over 1 MB are skipped; repo-wide stages read up to 4000 files. When the budget runs out the response says so outright — "complete": false, budget.limit, a list of unchecked_anchor_ids — and unchecked anchors keep their previous state. Marking them lost would mean lying about code we never read.
The repository is read, but never run. A bare mirror is placed on the space under REPOS_DIR, blobs are pulled via git show, no working tree is created — which means no build, no install script and no hook can physically execute. The credential for a private repository is configured by the environment variable's name, not its value:
CLEWWIKI_GIT_TOKEN_INTERNAL=ghp_... # the value lives only in the environmentWhat's stored in the repository settings is the string CLEWWIKI_GIT_TOKEN_INTERNAL — a name, not a secret; only CLEWWIKI_GIT_TOKEN and CLEWWIKI_GIT_TOKEN_<NAME> are accepted.
The token goes out only to its own repository's https origin, and never reaches the database, a backup, or an API response.
The same staleness machinery works without code, too: a page can have a paired document (technical ↔ human), in which case the anchor targets the pair's content hash — the technical version finds out that the human one has moved ahead.
The project is pre-alpha: no tags, no image in GHCR, no published npm package, and the only way to run it is to build from source. But this particular piece already answers the question it was written for: a page can no longer quietly assert something untrue about a function that was rewritten yesterday.



