NEE's Blog

不是每个长得像路径的字符串都是引用——拆解 BMAD 的文件引用校验器

October 10, 2026

BMAD 的技能是几百个 markdown/YAML 指令文件,这些指令里到处是对其他文件的引用:references/help.md、scripts/tickets.py、{project-root}/_bmad/...。当一条指令引用的文件已经不存在时,加载这条指令的代理会跟着它一头撞进 404,然后开始即兴发挥。这就是指令腐烂。

最显而易见的修法是写个 linter:grep 出所有反引号里的路径,检查它是否存在。但这个显而易见的修法会把你淹死——因为文档里大多数长得像路径的字符串是提及(mention),不是使用(use)。它们是示例(”技能会写出 output/report.md“)、运行时模板({output_folder}/{active_initiative})、或者纯示意。朴素的校验器会在所有这些东西上报错,于是所有人学会无视警告,真正的断链反而藏进了噪音里。

BMAD-METHOD 仓库里的 tools/validate_file_refs.py 是我见过的对这个问题最诚实的处理,因为它明确承认:引用检测本质上是一个只能从表层证据做出的『使用—提及』判断。一个字符串要先过三道门才会被考虑:

  1. 必须被反引号包裹、含有斜杠、以已知扩展名结尾(references/help.md 合格;裸文件名 prd.md 被搁置——文档字符串坦白承认裸文件名”与运行时输出的提及无法区分”)。
  2. 先剥掉代码块和裸 JSON 示例块——这里有个值得偷学的细节:剥离时把匹配到的文本抹成空白但保留换行符,这样后面输出告警注释时行号依然是对的。
  3. 跳过 `` 变量和一张明确的 18 条无法解析的运行时变量清单({output_folder}、{epic_num}、{timestamp}……)。

然后是皇冠上的宝石——首目录规则:一个候选路径只有在它的第一级目录确实存在于解析根目录下时才会被标记。你写了 references/help.md,但任何地方都不存在 references/ 目录,校验器就把这个 token 当成散文。容器的存在被当作”曾经有人想要引用它”的证据。是的,这用漏报(指向尚未创建目录的真实引用会保持沉默)换了接近零的误报——而对 linter 来说这是正确的交易方向,因为 linter 死于误报,不是死于漏报。

还有两个值得偷走的分类学:

  • BROKEN 与 UNRESOLVED:带文件扩展名却解析不到的路径是 BROKEN;不带扩展名又解析不到的是 UNRESOLVED(可能是目录引用)。不同的词,不同的严重级别。
  • 先警告后严格的退出码:默认模式即使发现断链也退出码 0,只打印警告;--strict 才退出码 1,留给 CI。文档字符串说得很直白:”默认模式仅警告(退出码 0),这样采用时不会造成破坏。”第一天就让 CI 挂掉的校验器会被删掉;警告一个月的校验器会被自愿升级。

它还跨越了安装期的鸿沟:{project-root}/_bmad/... 引用描述的是安装后的目录结构而非源码树,所以校验器把安装路径映射回源码(scripts/... → skills/bmad/scripts/...),并跳过仅安装期存在的目录(_config/、custom/、method/scripts/)和生成文件(config.yaml)。此外还有可移植性泄漏扫描(/Users/、/home/、C:\)和一条结构不变量:任何文件不得直接放在 skills/ 根下。

我为什么在意:这个仓库重构得非常频繁——我断断续续读了大约两个月,技能树已经搬了三次家(根目录 skills/ → src/bmm-skills/ → 又搬回 skills/)。一棵这么能搬的树需要机器可检查的引用。这里的架构教训可以推广:把你的代理指令当作一个会悄悄坏掉的 API 表面对待,给文档配上和客户端—服务端 schema 同一套漂移防火墙。

如果你在维护代理指令,偷学清单:

  • 斜杠 + 扩展名决定资格,容器存在才决定标记
  • 扫描前先剥示例,剥的时候保住行号
  • 用两个词的严重级别分类,而不是一个
  • 先退出码 0,挣到资格再退出码 1
comments powered by Disqus