使用 ast-grep 将规范锚定到代码

Reddit r/ArtificialInteligence 工具

摘要

一篇博客文章,描述了一种使用 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 秒,因此更多的规则只是增加了已经很快的东西的量。随着仓库增长,需要纪律的是规则的精确性,因为匹配到十几个调用点的规则是噪音,而在更大的代码库中这种情况更容易发生,这正是“一规则一位置”约束发挥作用的地方。上下文节约方面则相反,并且随着规模扩大而变得更好:你拥有的领域规范越多,通过解析锚点而不是全部加载它们所获得的收益就越大。

相似文章

🚀 今天我要介绍 specra-lang。

Reddit r/ArtificialInteligence

Specra-lang 是一种紧凑的规范语言,用结构化合约替代非结构化的 Markdown,供 AI 编程代理使用,支持意图定义、代理实现和自动验证。