Anchoring specs to code with ast-grep
Specs rot for a reason: we link them to code by file path and line number, and code moves. The spec says the token refresh logic lives in src/auth/service.py, someone refactors it into TokenService, and now the spec describes a file that no longer exists. Nobody notices until the spec is wrong enough to mislead someone, usually an agent, usually mine.
Instead of pointing at where the code lives, match what it looks like. Anchor the spec section to an ast-grep rule for a function called refresh_token inside TokenService, and moving the file no longer breaks the link. A path is an address; the rule recognises the code’s shape. Here’s how each survives the same edits:
| edit | path + line | structural anchor |
|---|---|---|
| move the file | breaks | survives |
| split into modules | breaks | survives |
| reorder the methods | breaks | survives |
| rename the symbol | breaks | breaks (by design) |
Agents make this urgent rather than academic. They churn through refactors faster than any human team, so a path-based link’s half-life keeps shrinking, and they read specs as context on every task, so a spec that has drifted feeds bad assumptions straight into the code they write next. The design is three parts: anchor spec sections with ast-grep rules instead of paths, have the agent resolve those anchors before it plans a change, and gate spec updates behind a diff review at the end of each task. I’ve applied it to my own work over the past few weeks, and it corrected me on three things I’d drawn too cleanly: how you actually write the rules, what the drift gate can catch, and which languages it works on at all. Those are folded in below.
Where this sits in my workflow #
The word ‘spec’ gets stretched over two different things, and the difference matters here: a plan or a change proposal is scoped to one task and thrown away after, while a living spec is durable and describes how a part of the system works, so it gets read again and again. The anchors are only about the durable kind.
For most features I just use plan mode in the coding CLI and keep AGENTS.md current (it’s the standing context the agent reads on every task). For large features I use OpenSpec, which treats specs as disposable change proposals, and I like that: the proposal gets archived on merge and the durable knowledge folds back into a small set of living domain specs in the repo. GitHub’s Spec Kit and Kiro are both great in this space too, and I was one of Kiro’s first users and still rate it. OpenSpec is just the one that fits how I work.
Living specs are the awkward part. Once a feature ships and quick fixes start arriving, nothing keeps the prose current. I use the anchors to catch that drift.
The anchor convention #
Each spec section gets an id (I use an invisible HTML comment under the heading, so the docs still render unchanged), and each id gets one or more ast-grep rules in a sidecar file that structurally locate the code the section describes:
# specs/anchors/auth.yml
auth.token-refresh:
rule:
kind: function_definition
has:
field: name
regex: '^refresh_token$'
inside:
kind: class_definition
has: { field: name, regex: '^TokenService$' }
stopBy: end
files: [src/auth/**/*.py]
That rule matches by node kind and name: a function_definition called refresh_token inside a class_definition called TokenService.
The rule is the link, and it rides out any edit that keeps the identifiers. What it won’t survive is a rename: call the method something other than refresh_token and the rule stops matching, same as a stale line number would. I’ve made my peace with that, because a rename is usually a change to the thing the spec is describing, so a dead anchor there is exactly the signal I want.
ast-grep is fast enough to run across a large codebase in CI without anyone noticing, and the rules are readable enough that fixing one is a thirty second job.
Writing the rules did have one wrinkle. The tidier way to write that anchor, an ast-grep pattern: like async def refresh_token($$$ARGS), silently matched nothing for async Python defs on the version I was running, so I match by node kind and name instead. Less pretty, but it resolves. Call it correction number one.
ast-grep wasn’t the only option I looked at. Language-server symbol references are more precise, but they need an LSP per language and a resolver running in CI, which is a lot of machinery for a doc check. Comby and plain structural grep can match code too, but neither gives me a rule file I can read in a diff. ast-grep sits in the middle: declarative yaml, tree-sitter underneath so it covers the languages I care about, and fast enough not to notice in CI.
Keeping the rules in a sidecar yaml rather than inline in the markdown was a deliberate call. The spec stays readable for humans, and the CI script only has to parse yaml.
The loop #
flowchart TD
land["feature lands"] --> size{"small/medium or large?"}
size -->|"small/medium"| plan["plan mode"]
size -->|large| prop["openspec proposal"]
plan --> resolve["agent resolves anchors for touched files,<br/>reads only the matching spec sections"]
prop --> resolve
resolve --> changes["makes changes"]
changes --> rerun["end of task: agent re-runs anchors,<br/>drafts a spec patch as a diff"]
rerun --> review{"I review the diff"}
review -->|approve| commit["spec + code commit together"]
review -->|reject| untouched["code lands, spec untouched"]
commit --> gate["CI drift gate on the PR"]
untouched --> gate
The front half is about context efficiency. Instead of stuffing entire spec documents into the agent’s context, it resolves the anchors against the files it expects to touch and pulls only the sections that match. It won’t know the full file set until it has planned the change, so this is a best-effort pass over the obvious candidates, and the end-of-task re-run over the files it actually touched is what closes the gap. Even rough, it pays off: in practice, resolving anchors pulled 5 to 37 times less context than loading the specs whole, depending on the change.
The back half is about maintenance without slop. The agent never writes to specs/ directly. It proposes a patch, I apply it or bin it. Most tasks should produce no spec change at all, and that’s the correct outcome.
Maintenance rules #
Three constraints, enforced through AGENTS.md and the drift gate:
- Diff-only proposals. The agent outputs a patch, a human commits it
- Size budget. Spec sections get a soft cap around 40 lines, and a patch that blows the cap has to propose a split instead of appending
- Anchor hygiene. A rule should resolve to one place. Match nothing and the anchor is dangling, so the agent fixes it in the same PR. Match three call sites and the rule is too loose, so it gets an
insideor a tighter pattern until it points at one thing. Both are drift
The size budget matters more than it looks. Agents are eager, and without a cap every spec section slowly becomes a changelog. The cap forces the summarise-or-split decision at write time, while the context is fresh, rather than during some future cleanup that never happens.
The drift gate #
Cheap and dumb on purpose. On every PR, CI runs each rule with ast-grep scan, keeps only the matches inside the PR’s changed lines, and for each one checks whether the diff also touched the spec section the rule belongs to, prose or rule. Anchored code that changed while its section sat still gets a warning comment naming the id. That’s the whole thing: parse the yaml, shell out to ast-grep, diff against the merge-base, intersect the ranges. That minimal version came to 57 lines.
That version is also where correction number two came in. I’d assumed it would catch a rename that slipped the agent loop, since a rename changes anchored code without touching the spec, exactly the drift I want flagged. It doesn’t: a rename stops the rule matching, so there’s no match left to intersect, and the gate goes quiet at the one moment I want it loud. Catching renames needs a separate check that flags any rule which used to resolve and now matches nothing, a dangling anchor. That’s a different mechanism rather than a tweak, so it took the gate from 57 lines to 220.
The intersection half carries the common case, code edited without the spec following, and the dangling check carries the rename nobody re-pointed. Here they are together, per rule on the PR:
flowchart TD
matches{"rule still matches?"} -->|"no, but it used to"| dangling["DANGLING (rename): warn"]
matches -->|yes| inpr{"match inside the PR's changed lines?"}
inpr -->|no| quiet1["PR didn't touch this code: quiet"]
inpr -->|yes| touched{"diff also touched the section,<br/>its prose or its rule?"}
touched -->|yes| quiet2["handled: quiet"]
touched -->|no| drift["DRIFT (edit): warn"]
The warning must stay non-blocking. The moment a docs check blocks a merge, people game it, myself included. A named warning on the PR is enough to catch the case I care about: a hotfix lands outside the agent loop and leaves the spec behind.
What I left out #
I left all of these out for the same reason. Each is another artifact that rots on its own, and the convention only works by fighting spec rot cheaply, so piling on more things to maintain would defeat the point.
No ADRs, the specs and AGENTS.md carry the memory. No spec-per-feature sprawl, OpenSpec proposals stay disposable. No agent free-writing into context files: AGENTS.md gets edited by me on a monthly pass, using the accumulated drift warnings as the input for what actually needs updating.
And no framework. The whole thing is a naming convention, a sidecar yaml, and a CI script that ran to 57 lines for the minimal gate, 220 once it also catches renames. That’s the escape hatch too: if the anchors ever cost more to maintain than the drift they prevent, I delete the yaml and the workflow degrades gracefully back to plan mode plus OpenSpec, which is where I started.
The brittleness I worried about showed up along language lines, correction number three. ast-grep only anchors what it has a working tree-sitter grammar for, and in my own codebase Dart was 41% of the source with none of it anchorable: three escalating attempts at a custom grammar all failed against the version I was running. Worse, a rule ast-grep can’t load aborts the whole scan rather than just failing itself, so the Dart rules had to be quarantined out of the rule directory instead of just left in place. The convention covers the languages ast-grep ships cleanly and skips the rest, which is a real limit if your repo leans on one of the gaps.
Running the gate back over 40 commits of history settled the noise question: it warned on 12% of them, with 3 of the 5 warnings near-certain true positives, so the signal is real without being constant. Whether the end-of-task patches stay cheap to review is the part history can’t tell me, so that one waits on more time running the loop on live work.
The idea holds up in practice, though it costs a little more and works for fewer languages than I expected. I still think it’s worth doing because the agent reads these specs on every task. Stale prose doesn’t just mislead the next person; it changes the next batch of code the agent writes.
FAQ #
What is an anchor in this convention?
An anchor is an id on a spec section plus one or more ast-grep rules in a sidecar yaml that structurally locate the code that section describes. The rule is the link: the agent runs it to pull the right spec sections before a change, and CI runs it to catch drift after.
How is a drift warning different from a failing test?
A test checks that the code does what it should. A drift warning checks that the code and the spec still agree about what it does, which is a different question. The gate never runs anything or looks at behaviour, it just notices when anchored code changed and its spec section didn’t move with it. That’s also why it warns instead of failing: a spec going stale isn’t a broken build, and treating it like one is how doc checks end up gamed.
Do the anchors survive a rename?
No, and that’s on purpose. A structural anchor rides out moves and refactors that keep the identifiers, but rename the method or class and the rule stops matching, same as a stale line number. A rename is usually a change to the thing the spec describes, so a broken anchor there is the signal I want rather than noise. The drift gate only surfaces it if you give it a dangling-anchor check, though: plain line-intersection goes blind the moment the match disappears, which I learned the hard way.
Isn't this just ADRs, or a docs linter?
No. An ADR is a separate decision log, another document that rots on its own and needs tending, whereas anchors bind the living specs I already keep to the code they describe, so there’s nothing new to maintain. A docs linter is closer, but it still checks a document against itself, its links and its formatting. The drift gate checks the document against the code, whether the section still matches the thing it claims to describe, which is the part that actually goes wrong.
Does this scale to a monorepo with lots of specs?
The scanning does, easily. A whole-repo ast-grep run was 0.08 seconds on my repo, so more rules is just more of something that’s already fast. What needs discipline as the repo grows is rule tightness, since a rule that matches a dozen call sites is noise and there are more chances for that in a bigger codebase, which is where the one-rule-one-place constraint earns its keep. The context saving works the other way and gets better with scale: the more domain specs you have, the more you gain by resolving anchors instead of loading all of them.
Discussion