Design. Ship. Scale.

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.

AI Agents make this worse before they make it better. They churn through refactors faster than any human team, so the half-life of a path-based link keeps shrinking. But they also read specs as context on every task, which means a stale spec isn’t just dusty documentation anymore, it’s actively feeding wrong assumptions into the loop that writes your code.

This post is the design I landed on: bind spec sections to code 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 since applied it over the past few weeks to my own work, 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; I was one of Kiro’s first users and still rate it. OpenSpec is just the one that fits how I work.

The gap is those living specs. Nothing keeps them up to date once a feature ships and quick fixes start arriving. That’s the job the anchors do.

The anchor convention #

The trick is to stop pointing at where the code is and match what it looks like instead. A path is an address, so it breaks the moment code moves; an ast-grep rule matches the code’s shape, which the move leaves intact.

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. Move the file, split the module, shuffle the methods around, and the anchor still resolves because it matches the shape of the code rather than its address. 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.

That’s the whole trade in one table, a path-and-line link against a structural anchor over the same edits:

                       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)

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 #

                 feature lands
                       |
          small/medium?     large?
             |                 |
        plan mode        openspec proposal
             |                 |
             +--------+--------+
                      |
       agent resolves anchors for touched files,
       reads only the matching spec sections
                      |
                 makes changes
                      |
       end of task: agent re-runs anchors,
       drafts a spec patch as a diff
                      |
              I review the diff
        approve -> spec + code commit together
        reject  -> code lands, spec untouched
                      |
              CI drift gate on the PR

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, not a failure.

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 inside or 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; the dangling check carries the rename nobody re-pointed. Here they are together, per rule on the PR:

rule still matches?
  |
  +-- no, but it used to ---> DANGLING (rename) ---> warn
  |
  +-- yes
       |
   match inside the PR's changed lines?
       |
       +-- no ---> PR didn't touch this code ---> quiet
       |
       +-- yes
            |
        diff also touched the section
        (its prose or its rule)?
            |
            +-- yes ---> handled ---> quiet
            |
            +-- no  ---> DRIFT (edit) ---> warn

The non-blocking part is load-bearing. The moment a doc check blocks a merge, people game it, and I include myself in that. A named warning on the PR is enough to catch the silent case this whole thing exists for: the hotfix that lands outside the agent loop and quietly makes the spec a liar.

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 left to error quietly. 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 core idea holds up in practice, at a higher and narrower cost than I drew going in. And it stays worth the bother for the reason I started with: the agent reads these specs on every task, so a spec that’s quietly wrong is wrong in the code it writes next.

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.

$ comments --load