过时的文档没有任何症状。文件能打开,格式合法,页头的日期看上去挺可信,链接还指向原来的地方。它唯一缺的,是关于当前代码的真相。一个月前有人把三个函数拆进了独立模块,重命名了入口,调换了参数顺序。AGENTS.md 对此毫无察觉,因为它根本没有察觉的手段。
对人来说这只是恼火:读完,磕绊一下,转头去看代码。对智能体来说则是另一回事。模型感受不到「这里有点对不上」——它把上下文文件当作事实,以同样的确信度去执行刚写下的指令,和那条描述半年前架构的指令。接下来智能体照着过时的描述写代码、提交、离场。在 ClewWiki——一个 self-hosted 知识库,页面由人和编码智能体共同撰写,AGPL-3.0,目前还是 pre-alpha、没有任何发布版本——code anchors 机制正是从这个问题里长出来的。
为什么「文件加行号」算不上锚点#
显而易见的方案是这样的:在页面旁边记上 src/anchors/resolve.ts:127-184,然后检查这几行有没有变。它不管用,而且坏得比想象中更快。
行号区间扛不住发生在它上方的改动。有人在文件开头加了一条 import,下面所有代码整体下移一行,区间现在指向的是隔壁的声明。跑一遍格式化工具,仓库里几乎每个区间会被一次性掀翻。一次完全不改变行为的函数重命名,同样被算作变更。结果是一串告警,其中真正有效的寥寥无几,两周之后团队就不再看它们了。
这个数字是我们量出来的,不是拍脑袋估的。在真实的重构历史上——26 次提交、479 个锚点——朴素的 line-range 锚点给出了大约 50% 的误报。在同一段历史上,符号锚点给出的是 零。
由此得到第一个决定:锚点的身份是 {kind, qualified_name} 这一对,而不是位置。函数 languageForPath 搬到另一个文件之后,依然是它自己。锚点里确实有 file_hint 字段,但它只用来决定从哪里开始找,而不用来决定找什么。
Line ranges 还是保留了下来——作为一种诚实的 fallback,留给那些没有可解析声明的块:配置、常量表、README 里的一段散文。对它们会归一化缩进和空行,并且每次 check 的响应里都带上 fallback_share——这类锚点在 space 中所占的比例。比例上升,意味着这个空间正在一点点滑向一条已知会坏掉的路径;这是噪声的早期指标,不是响应里的装饰。
哈希令牌,而不是哈希文本#
第二个决定是:到底该哈希什么。对声明文本做哈希毫无用处,原因和行号区间一样——它对一切都有反应。所以锚点保存的是解析器输出的令牌序列的哈希,并且把注释丢掉。
实际差别是:函数重新缩进、参数换行拆成几行、doc 注释重写、单引号改成双引号——这些都不会改变哈希。而代码做了什么一旦变化,按构造必然改变它。这正是你对「文档描述的这段已经过时」这个信号所期待的行为。
解析器用的是 tree-sitter,编译成 WebAssembly。这不是摆姿态:原生绑定会把 node-gyp 拖进运行时镜像,并且每次升级 Node 都要重新编译——只为一个纯读取的解析器。WASM 构建产物作为数据放进镜像,能安然度过运行时升级。声明表目前覆盖 Swift、TypeScript 和 TSX(.swift、.ts、.mts、.cts、.tsx);Kotlin 是下一个。
五级阶梯与四种状态#
锚点检查是一道有序的阶梯,而正是这个顺序,把重构和文档本身的问题区分开来:
| 阶段 | 找什么 | 结果 |
|---|---|---|
| 1 | 同一文件,同一身份 | fresh 或 stale |
| 2 | 不同文件,同一身份 | moved |
| 3 | 同一容器内,同样的函数体、不同的名字 | renamed |
| 4 | 任意位置的同样函数体 | moved and renamed |
| 5 | 什么都没有 | lost |
阶段 3 和 4 按函数体的哈希匹配,而不是完整声明的哈希。原因是机械性的:完整哈希覆盖了名字,而重命名按定义就会改变它——也就是说,用完整哈希在原理上根本不可能抓到重命名。
函数体哈希有它自己的陷阱:短的函数体会偶然撞车。{ return nil } 在一个文件里出现二十次,其中任何一个都能「证明」某个函数正是被重命名成了它。所以短于 MIN_BODY_TOKENS = 8 个令牌的函数体根本不参与匹配。一个自信的错误答案,比一个诚实的 lost 更糟。
锚点的状态有四种。fresh —— 声明还在原处,且没有改变。stale —— 同一个声明,函数体被改了。moved-renamed —— 找到了,响应会告诉你它现在在哪、现在叫什么。lost —— 什么都解析不出来;这不是「坏了」,而是在向人索取一个决定:删掉锚点、重新绑定,或者承认这个页面描述的是不存在的代码。
三个锚点,一次提交#
这套机制我在本地实例上手工验证过。三个 symbol 锚点指向包里真实的函数:resolveAnchor、languageForPath、lineRangeAnchor。第一次检查是 fresh、fresh、fresh。接着提交一次改动,在 languageForPath 的函数体里加一行,再检查一次:
{
"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 }
}恰好一个锚点改变了状态。同一文件里被这次改动按行推移的两个相邻声明仍然是 fresh——因为行号和身份没有任何关系。在界面上这是一张卡片:「function languageForPath — Stale — The declaration is still there, but its body changed」,外加一个「Confirm reviewed」按钮。
这个按钮是原则性的。标记只能由显式的 confirm(POST /api/v1/anchors/{id}/confirm)清除——一次由人或智能体做出的、可审计的自觉动作。一个会自己消失的徽标,会让其余所有徽标的沉默都变得一文不值:如果绿色有时意味着「没人看过,只是悄悄过时了」,那就没法再信任绿色状态。
锚点在创建时就会立刻解析:符号名里的拼写错误当场被拒,而不是一周后冒出来变成一个莫名其妙的 lost。
预算,以及系统刻意不做的事#
单次检查有硬性上限:2000 个文件、32 MB 源码、30 秒;大于 1 MB 的文件被跳过;repo-wide 阶段最多读取 4000 个文件。预算耗尽时,响应会直说——"complete": false、budget.limit、unchecked_anchor_ids 列表——并且未被检查的锚点保持原有状态。把它们标成 lost,等于对我们根本没读过的代码撒谎。
仓库会被读取,但永远不会被运行。space 上放的是 REPOS_DIR 下的 bare mirror,blob 通过 git show 取出,不创建工作树——也就是说,构建、install 脚本、hook 在物理上都不可能执行。私有仓库的凭据通过环境变量的名字来指定,而不是值:
CLEWWIKI_GIT_TOKEN_INTERNAL=ghp_... # 值只存在于环境变量中仓库设置里保存的是字符串 CLEWWIKI_GIT_TOKEN_INTERNAL——一个名字,而不是密钥;只允许 CLEWWIKI_GIT_TOKEN 和 CLEWWIKI_GIT_TOKEN_<NAME> 两种写法。
令牌只会发往自己仓库的 https origin,不会进入数据库、备份或 API 响应。
同一套 staleness 机制在没有代码的场景下也照样成立:一个页面可以有配对文档(technical ↔ human),这时锚点的目标就变成这对文档的 content hash——技术版本会知道人类版本已经跑到前面去了。
项目仍处于 pre-alpha:没有 tag,GHCR 镜像和 npm 包都还没发布,唯一的运行方式是从源码构建。但恰恰是这一部分,已经回答了当初写它的那个问题:页面再也无法对一个昨天刚被重写的函数,沉默地断言假话。



