使用 ast-grep 将规范锚定到代码
摘要
一篇博客文章,描述了一种使用 ast-grep 规则将活跃规范锚定到代码的技术,这些规则匹配代码结构而非文件路径,从而防止重构时代码移动导致的规范腐化。该方法专为每次任务都会阅读规范的 AI 代理设计,锚点可随移动而存活,但不支持重命名。
暂无内容
查看缓存全文
缓存时间: 2026/07/06 16:16
# 使用 ast-grep 将规范锚定到代码
来源:https://coles.codes/posts/anchoring-specs-to-code-with-ast-grep
规范腐烂是有原因的:我们通过文件路径和行号将它们链接到代码,而代码会移动。规范说令牌刷新逻辑位于 `src/auth/service.py` 中,有人将它重构到 `TokenService` 里,现在规范描述的是一个不再存在的文件。没人注意到,直到规范错误到足以误导某人——通常是某个 AI 智能体,而且通常是我的。
AI 智能体在改进之前会让情况变得更糟。它们比任何人类团队更快地处理重构,因此基于路径的链接的半衰期不断缩短。但它们在每个任务中都会读取规范作为上下文,这意味着过时的规范不仅仅是尘封的文档,它还会主动向编写代码的循环提供错误的假设。
这篇文章是我最终采用的设计:通过 ast-grep 规则(而非路径)将规范章节绑定到代码,让智能体在计划变更之前解析这些锚点,并在每个任务结束时通过差异审查来控制规范更新。在过去的几周里,我将其应用到了自己的工作中,它纠正了我三处画得太完美的地方:你实际如何编写这些规则、漂移门能捕捉到什么、以及它到底支持哪些语言。这些都在下面一并讨论。
## 这在我的工作流中的位置
(https://coles.codes/posts/anchoring-specs-to-code-with-ast-grep#where-this-sits-in-my-workflow)
“规范”一词被用于两种不同的事物,而这里的区别很重要:计划或变更提案的范围限定在一个任务内,用后即焚;而活文档则是持久的,描述了系统某一部分的工作方式,因此会被反复阅读。锚点仅针对持久的那一种。
对于大多数功能,我只使用编码 CLI 中的计划模式,并保持 AGENTS.md 为最新(这是智能体在每个任务中读取的常驻上下文)。对于大型功能,我使用 OpenSpec(https://github.com/Fission-AI/OpenSpec),它将规范视为可丢弃的变更提案,我很喜欢这一点:提案在合并时归档,持久的知识则回收到仓库中一小部分活领域规范中。GitHub 的 Spec Kit(https://github.com/github/spec-kit)和 Kiro(https://kiro.dev/)在这个领域也很出色;我是 Kiro 最早的用户之一,至今仍很欣赏它。OpenSpec 只是最符合我工作方式的那一个。
差距在于那些活文档。一旦功能发布并开始快速修复,没有任何东西能让它们保持最新。这就是锚点要做的工作。
## 锚定约定
(https://coles.codes/posts/anchoring-specs-to-code-with-ast-grep#the-anchor-convention)
诀窍是停止指向代码所在的位置,而是匹配代码的外观。路径是一个地址,因此代码移动时它就会失效;而 ast-grep 规则匹配代码的形状,移动不会改变它。
每个规范章节都有一个 id(我在标题下使用一个不可见的 HTML 注释,这样文档渲染时不变),每个 id 在侧边 yaml 文件中对应一个或多个 ast-grep 规则,这些规则在结构上定位该章节描述的代码:
```yaml
# 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]
```
该规则通过节点类型和名称进行匹配:一个名为 `refresh_token` 的 `function_definition`,位于一个名为 `TokenService` 的 `class_definition` 内部。
规则就是链接。移动文件、拆分模块、重排方法,锚点仍然能解析,因为它匹配代码的形状而不是地址。它无法应对的是重命名:如果你将方法命名为 `refresh_token` 以外的东西,规则就不再匹配,就像过期的行号一样。我对此已经释然,因为重命名通常意味着规范所描述的内容发生了变化,所以那里出现死锚正是我想要的信号。
这就是整个权衡,用一个表格来对比路径+行号链接与结构锚点在相同编辑下的表现:
| | 路径 + 行号 | 结构锚点 |
|----------------|-------------|----------|
| 移动文件 | 失效 | 幸存 |
| 拆分为模块 | 失效 | 幸存 |
| 重排方法 | 失效 | 幸存 |
| 重命名符号 | 失效 | 失效(有意为之)|
ast-grep 足够快,可以在 CI 中跨大型代码库运行而几乎不被察觉,而且规则可读性足够好,修复一个只需要三十秒。
编写规则确实有一个小问题。有一种更整洁的方式来写那个锚点,即像 `async def refresh_token($$$ARGS)` 这样的 ast-grep `pattern:` 模式,但在当时我运行的版本上,它对异步 Python def 会静默地不匹配任何内容,所以我改为通过节点类型和名称来匹配。不那么美观,但能解析。姑且称之为第一处修正。
ast-grep 并不是我考虑的唯一选择。语言服务器符号引用更精确,但它们需要每种语言一个 LSP,并且 CI 中需要一个解析器,这对于一个文档检查来说过于复杂。Comby 和简单的结构化 grep 也能匹配代码,但两者都不能提供一个我可以在 diff 中阅读的规则文件。ast-grep 处于中间位置:声明式 yaml,底层是 tree-sitter,因此覆盖了我关心的语言,速度快到在 CI 中几乎不被注意。
将规则放在侧边 yaml 中而不是内嵌在 markdown 中是有意为之。规范对人类保持可读,而 CI 脚本只需要解析 yaml。
## 循环
(https://coles.codes/posts/anchoring-specs-to-code-with-ast-grep#the-loop)
```
功能发布
|
小型/中型? 大型?
| |
计划模式 openspec 提案
| |
+----+------+
|
智能体解析受影响文件的锚点,
只读取匹配的规范章节
|
进行更改
|
任务结束:智能体重新运行锚点,
将规范补丁生成为 diff
|
我审查 diff
批准 -> 规范和代码一起提交
拒绝 -> 代码提交,规范不变
|
PR 上的 CI 漂移门
```
前半部分是关于上下文效率。与其将整个规范文档塞进智能体的上下文,不如针对它期望触碰的文件解析锚点,只拉取匹配的章节。在计划好变更之前,它不会知道完整的文件集,因此这是对明显候选文件的一次尽力而为的遍历,而任务结束时的重新运行(针对它实际触碰的文件)则弥补了差距。即使粗略,这也是值得的:在实践中,解析锚点提取的上下文比加载完整规范少 5 到 37 倍,具体取决于变更。
后半部分是关于维护而不产生冗余。智能体从不直接写入 `specs/`。它提出一个补丁,由我决定是应用还是丢弃。大多数任务应该根本不会产生规范变更,这是正确的结果,而不是失败。
## 维护规则
(https://coles.codes/posts/anchoring-specs-to-code-with-ast-grep#maintenance-rules)
三个约束,通过 AGENTS.md 和漂移门强制执行:
- **仅 diff 提案**。智能体输出一个补丁,由人类提交
- **大小预算**。规范章节有约 40 行的软上限,超出上限的补丁必须提出拆分而不是追加
- **锚点卫生**。一条规则应解析到一个位置。什么也没匹配到意味着锚点悬挂,智能体应在同一个 PR 中修复它。匹配到三个调用点则规则太宽松,应添加 `inside` 或更精确的模式,直到指向一个东西。两者都是漂移
大小预算比看起来更重要。智能体很积极,没有上限的话,每个规范章节都会慢慢变成变更日志。上限会在编写时(上下文还新鲜)强制做出“总结或拆分”的决定,而不是留到未来永远不会发生的清理工作。
## 漂移门
(https://coles.codes/posts/anchoring-specs-to-code-with-ast-grep#the-drift-gate)
故意设计得廉价且简单。在每个 PR 上,CI 使用 `ast-grep scan` 运行每条规则,只保留 PR 更改行内的匹配项,然后检查 diff 是否也触及了该规则所属的规范章节(无论是散文内容还是规则本身)。已更改的锚定代码如果其章节保持不变,就会收到一条包含 id 的警告评论。仅此而已:解析 yaml,调用 ast-grep,与合并基准进行 diff,取范围交集。这个最小版本只有 57 行。
这个版本也是第二处修正的由来。我曾假设它能捕捉到溜过智能体循环的重命名,因为重命名会改变锚定代码而不触及规范,这正是我想要标记的漂移。但事实并非如此:重命名会使规则停止匹配,因此没有匹配项可以取交集,门在我最希望它发出警报的时刻变得沉默。捕捉重命名需要一个单独的检查,来标记那些曾经能解析而现在什么也匹配不到的规则(悬挂锚点)。这是一个不同的机制,而不是简单的调整,因此门从 57 行增加到了 220 行。
交集部分处理常见情况:代码被编辑但规范未更新;悬挂检查处理了未被重定向的重命名。在 PR 上,每条规则的处理如下:
```
规则仍然匹配?
|
+-- 否,但之前匹配过 ---> 悬挂(重命名) ---> 警告
|
+-- 是
|
匹配位于 PR 更改行内?
|
+-- 否 ---> PR 未触及此代码 ---> 静默
|
+-- 是
|
diff 也触及了该章节
(其散文或规则)?
|
+-- 是 ---> 已处理 ---> 静默
|
+-- 否 ---> 漂移(编辑) ---> 警告
```
非阻塞这一点至关重要。一旦文档检查阻塞了合并,人们就会钻空子,我自己也不例外。在 PR 上命名的警告足以捕捉到这项方案存在的静默情况:那些发生在智能体循环之外且悄悄地使规范变成谎言的 hotfix。
## 我遗漏了什么
(https://coles.codes/posts/anchoring-specs-to-code-with-ast-grep#what-i-left-out)
我遗漏了所有这些,原因相同。每一个都是另一个会自行腐烂的产物,而该约定只有通过廉价地对抗规范腐烂才能工作,因此增加更多需要维护的东西会适得其反。
没有 ADR,规范和 AGENTS.md 承载着记忆。没有每个功能的规范蔓延,OpenSpec 提案保持可丢弃。没有智能体自由写入上下文文件:AGENTS.md 由我每月编辑一次,使用累积的漂移警告作为实际需要更新的依据。
没有框架。整套方案只是一个命名约定、一个侧边 yaml 和一个 CI 脚本(最小门为 57 行,增加重命名检测后为 220 行)。这也是逃生舱门:如果锚点的维护成本超过了它们所防止的漂移,我就删除 yaml,工作流会优雅地降级回计划模式加 OpenSpec,这正是我最初的起点。
我所担心的脆弱性沿着语言线出现了,这是第三处修正。ast-grep 只能锚定它有正常工作的 tree-sitter 语法的内容,而在我的代码库中,Dart 占了 41% 的源码,但没有任何部分可以锚定:三次针对自定义语法的升级尝试都在我运行的版本上失败了。更糟的是,ast-grep 无法加载的规则会中止整个扫描,而不是仅自身失败,因此 Dart 规则必须被隔离出规则目录,而不是静默报错。该约定覆盖了 ast-grep 原生支持的语言,跳过了其余部分,如果你的仓库偏向某个缺口语言,这就是一个真正的限制。
将门在 40 次提交历史中运行后,噪音问题得以确定:它在 12% 的提交上发出警告,其中 5 次警告中有 3 次接近真正的阳性,因此信号是真实的,但并不持续。任务结束时的补丁是否保持易于审查,这是历史无法告诉我的,因此这需要更多时间在实际工作中运行这个循环。
核心思想在实践中是成立的,但成本和范围比我最初设想的更高、更窄。而且它仍然值得费心,原因和我开始时一样:智能体在每个任务中都会读取这些规范,因此一个悄悄出错的规范在它接下来编写的代码中也是错误的。
## 常见问题
(https://coles.codes/posts/anchoring-specs-to-code-with-ast-grep#faq)
**这个约定中的锚点是什么?**
锚点是规范章节上的一个 id,加上侧边 yaml 中的一个或多个 ast-grep 规则,这些规则从结构上定位该章节描述的代码。规则就是链接:智能体在变更前运行它以拉取正确的规范章节,CI 在变更后运行它以捕捉漂移。
**漂移警告与失败的测试有何不同?**
测试检查代码是否按预期工作。漂移警告检查代码和规范是否仍然就其所做的事情保持一致,这是一个不同的问题。门从不运行任何东西或检查行为,它只是注意到锚定代码发生了更改而对应的规范章节没有随之更新。这也是它发出警告而不是失败的原因:规范过时并不是构建失败,将其视为构建失败是文档检查最终被钻空子的方式。
**锚点能经受住重命名吗?**
不能,这是有意为之。结构锚点可以经受住移动和重构(只要保持标识符不变),但重命名方法或类会使规则停止匹配,就像过时的行号一样。重命名通常意味着规范所描述的内容发生了变化,因此那里出现断开的锚点是我想要的信号,而不是噪音。不过,漂移门只有在你为其添加悬挂锚点检查时才会暴露它:纯行交集一旦匹配消失就会失明,这是我亲身经历的教训。
**这不就是 ADR 或文档 linter 吗?**
不是。ADR 是一个单独的决策日志,是另一个会自行腐烂且需要维护的文档;而锚点将我已经维护的活文档绑定到它们所描述的代码上,因此没有新的东西需要维护。文档 linter 更接近一些,但它仍然检查文档自身(其链接和格式)。漂移门检查文档与代码的一致性,即章节是否仍然匹配它声称描述的内容,这才是真正出错的部分。
**这在包含大量规范的单仓库中能扩展吗?**
扫描部分很容易扩展。在我的仓库中,对整个仓库运行 ast-grep 只需 0.08 秒,因此更多的规则只是增加了已经很快的东西的量。随着仓库增长,需要纪律的是规则的精确性,因为匹配到十几个调用点的规则是噪音,而在更大的代码库中这种情况更容易发生,这正是“一规则一位置”约束发挥作用的地方。上下文节约方面则相反,并且随着规模扩大而变得更好:你拥有的领域规范越多,通过解析锚点而不是全部加载它们所获得的收益就越大。
相似文章
为什么用文本给AI编码代理提供架构上下文从根本上说是有问题的
AI编码代理在处理文本描述时难以应对隐式的架构决策。作者构建了specrabbit,一个可视化画布,通过类型化节点和流程定义架构,并导出机器可读的规范。
@RoundtableSpace: GitHub 刚刚开源了一个系统,强制 AI 代理在编码前编写完整规范,数天内获得 95K 星标
GitHub 开源了一个系统,强制 AI 代理在编码前编写完整规范,迅速获得 9.5万星标。
@trq212: 好吧,这有点火了,说实话我原来的文本有点乱,所以借助Cla…做了第二次润色。
一位用户分享了借助AI实现规格的优化方法,使用Claude维护一个implementation-notes.html文件,记录设计决策、偏差、权衡和未解决的问题。
🚀 今天我要介绍 specra-lang。
Specra-lang 是一种紧凑的规范语言,用结构化合约替代非结构化的 Markdown,供 AI 编程代理使用,支持意图定义、代理实现和自动验证。
我构建了一个开源Claude Code插件,强制实施规范驱动的工作流(访谈→规范→计划→任务→代码)
介绍Specsmith,一个用于Claude Code的开源插件,强制实施规范驱动的工作流(访谈、规范、计划、任务、代码),以减少歧义并提高代码质量,目前处于早期v0.1版本。