作者发现 24 条架构决策中约 2/3 无法被代码审查自动检查,遂构建工具验证,发现自己违反了自己定下的四条规则。
我为手头一直在做的平台维护着一份架构文档。不是图表,就是用大白话写的决策。"这个系统履行订单,从不创建订单。""外部集成只负责记录和继续,从不阻塞核心流程。"
我维护这份文档是因为我用编码 Agent 写代码,而 Agent 的失败模式不是写出烂代码。它会写出局部看起来很合理的代码,却违反了我三周前做出的决策。diff 看起来没问题,review 也通过了。没有任何东西能catch到它。
所以我想知道,这类工具到底能检查文档中的多少内容。我写了一个工具来验证这一点。然后我把它跑在真实的代码库上,它四次发现我对自己报出的数字在撒谎——这反而成了它做的最有价值的事。
在写任何代码之前,我逐条过了一遍文档。共 24 条。它们分成三组,每组需要完全不同的处理方式。
八条是关于流程的,不是代码。不要在没跑通的情况下声称它能工作。破坏性操作之前要暂停。无法验证物理事实时就提问而不是猜测。这些不会留下任何痕迹。你无法通过看一个 repo 来判断 Agent 在删表之前是否停下来问过。它做了或没做,证据在一个没人保存过的 session 里。没有任何东西能检查出来。
九条是关于代码库的不变式。从文件树就可以证明。这些是工具能做点什么的部分。
七条是声明性知识。order_number 是一个展示值,查询用的是内部 id。纸箱目录归上游,我们只做镜像从不写入。"Cart"在仓库里指物理拣货车,在商业端指交易前的容器,两者毫无关系。你没法用文件来违反这些。但你会遗忘它们,而遗忘它们正是 bug 发生的途径。
九条不变式中,六条能转化为确定性检查。三条不能,而这三条失败的比那六条成功的更有趣,因为它们各有各的失败方式。
第一条是"只有 ingest 边界可以创建订单记录"。无法检查。ingest 逻辑在一个巨大的服务类内部,所以没有文件路径可以用来限定排除范围。我的架构声称那里有一条边界。我的代码里没有。工具没有发现漂移,它发现的是这个不变量是不可执行的,因为它所描述的结构还不存在。
第二条是"前后端的权限字符串必须匹配"。这需要跨文件集合比较——从 A 拉取字符串,从 B 拉取字符串,做 diff。不是解析问题。只是我一个还没构建的原始工具。
第三条是"实体必须渲染为可点击链接"。这需要理解代码的含义。超出了基于模式的工具的能力范围,也许永久超出。
加上两条有可检查影子的知识条目,我最终从 24 条陈述中得到了八条确定性检查。
六种检查器类型。禁止的调用模式、禁止的依赖关系、配置值白名单、非空方法体、错误处理器存在性,以及标识符误用的邻近启发式。没有 AST 解析器,是故意的。每个检查都在配置中注明了如何规避它。
第一次运行:24 个违规,2 个建议。
其中十三个是真的。前端组件直接调用 HTTP 客户端而不是走服务层,积攒了几个月,没人注意到。
六个是标记了无 rollback 的迁移。我打开了这六个。每一个都是故意不可逆的,且都有解释原因的注释,而这六个注释都是我自己写的但每一个都忘了。这意味着规则错了,不是代码错了。我没有"每个迁移都可逆"。我有的是"可逆性是默认值,例外需要声明"。我修改了检查器,接受一个标记或 rollback,六个发现变成零,不需要动任何代码。
四个是两个文件中的合法排除。Blob 下载、一个与规则目标不同的 payload 类。
一条是被注释掉的代码行。词法检查分不清代码和注释。我保留了下来,在文档里注明了。
修复后的第二次运行:14 个违规。13 个真的加上那个误报。
那个正确触发但仍然错了的
一条建议会标记展示标识符出现在查询上下文附近。它命中了一个合作伙伴的 webhook 处理器,我把它归类为真正的危险用例——正是这条规则存在要防止的那类 bug。
然后我读了周围的代码。合作伙伴的文档化契约使用那个展示值作为共享键,因为他们没有我们内部 id 的访问权。文档块是这么说的。另一个方法用同样的匹配方式做同样的事,原因也相同。检查器在符号上正确触发了,而用法是正确的。
AST 也帮不上忙。解析只能告诉你一个符号在语法上位于哪里。它没法告诉你外部契约是否允许它。我需要的信息在一个注释里,在另一个方法的行为里。
这就是天花板,而且这不是解析器的问题。在 26 个发现中,没有任何东西推动我去写一个 AST 层。只有一个东西推动我去写一个新的原始工具。我本来以为到这一步已经在写解析器了。
不可执行的规则比可检查的规则捕获了更多东西
这是我没有预料到的部分。
Agent 告诉我工具的脚手架在工作了。两个文档化的接口不存在。一个入口点没有顶层调用,静默地什么都不做。JSON 输出 flag 根本没有实现,尽管这正是整个集成应该依附的东西。
这两个都被"不要在没用文档方式跑过的情况下声称它能工作"catch到了。没有任何检查器能找到其中任何一个,因为不运行的工具不产生发现,而没有发现看起来和干净完全一样。
同一类检查还阻止了它写入一条无法溯源的日志条目,阻止了它编造一个它没被展示过的配置文件,并让它主动承认了一次没人要求它做的 force-push。
这些事后都无法验证。全都只是因为那条规则在它面前,所以它遵守了。
写这篇文章时,我从记忆中陈述了四个数字,四个全错了。
我说 25 个发现。文件说是 24 个。更正这个后,我从记忆中的小计中推导出了 23 个,放在一段关于这样做的危险的段落里。我预测有 15 个真实的漂移点。结果是 13 个。我说四个排除分布在三个文件中。是两个文件——而且我说这话的时候正在解释把站点数和文件数混为一谈是讨论中的那个错误。
每一处更正都来自打开输出文件。没有一处来自更努力地思考。
所以失败模式在知道它存在之后仍然存在。对一个特定错误的主动警惕并不能防止那个错误。这是一个比我想要的更糟糕的结果,它是整个项目中最强有力的论据,证明应该把东西写在某个机器能检查的地方。对 prose 适用,对代码同样适用。
有些工具能从 repo 中推断你的架构,有些工具能拿代码和正式模型比较,有些工具能统计式地评分侵蚀。其中一些比我这个成熟得多。
我从另一个地方开始:有些架构决策之所以存在,仅仅是因为一个人类做出了它们,而 Agent 不应该从代码中重新发现它们。所以文档是人类编写的,检查器强制执行可机器化的那部分,剩下的作为上下文由 Agent 需要但无法证明的东西来携带。
检查器是三部分契约的执行那半。结果证明它是较小的那一半,而我仍然不确定这意味着我应该构建更多检查器还是写更好的规则。
工具和完整的漂移日志,包括我四个错误的数字:github.com/KaleemNoorani/arch-drift