昨天,一个叫 deadgate 的工具被发布到了 PyPI。它的功能很单一:读取 GitHub Actions 的工作流文件,找出那些"永远不会失败"的检查。

什么叫永远不会失败?一个被跳过的任务却满足了必需的门禁,一个被管道吞掉的退出状态,一个从不读取上游是否通过的汇总任务。这些检查看起来在把关,实际上什么都没拦。

打开网易新闻 查看精彩图片

发布之后,作者把它指向了 Arize-ai/openinference——一个 1.2k 星、22 个工作流的仓库。工具报告了 13 个 HIGH 级别的问题。

其中 12 个是误报。

文档推荐的那种写法,恰好是工具看不见的那种

GitHub 官方文档里,判断"上游有没有失败"的推荐写法是用通配符:contains(needs.*.result, 'failure')。

而检测器的正则写的是 needs\.[A-Za-z0-9_-]+\.(result|outputs)——它匹配不了那个星号。

于是出现了一个尴尬的场面:文档唯一推荐的写法,正好是工具盲区里的写法。每一个写得正确的汇总门禁,都被判定成了"永远不会失败的门禁"。

更讽刺的是报错信息本身。那条发现写着"从不读取 needs.*.result",而它自己就没能匹配上这个字符串。

修复、发布 0.1.1,然后把修好的版本指向 astral-sh/ruff。这次报告了 21 个 HIGH,其中 18 个是误报。

Ruff 用了第三种写法:把整个 needs 对象转成 JSON,再用 jq 一次性筛选出所有非 success、非 skipped 的上游。这种写法里,"result"这个词根本不出现在任何工作流表达式中,正则自然无从匹配。

第四类问题跟拼写无关。scikit-learn 有一个任务调用了可复用工作流,所以它只有 uses: 而没有 steps:,它通过任务级的 with: 传递 needs.check-sdist.result。这个表达式本身完全看得懂,只是它待在一个没被扫描的位置——工具只扫了步骤级的 with,没扫任务级的 with。

40% 是平均值,而错误分布得极不均匀

四类问题,前两类已经发到了 PyPI。后两类是在持续把"修好的"版本指向新仓库的过程中才发现的,而不是宣布胜利之后就收工。

真正关键的数字来自一个 275 个仓库、4543 个文件的语料库,两个版本读取完全相同的字节:40% 的 HIGH 级别发现是误报。

这个数字听起来已经很糟,但它还低估了问题的严重性,因为 40% 是平均值,而错误并不是均匀分布的。

在这 275 个仓库里,只有 15 个用到了通配符写法。误报集中堆在那些把门禁写得很仔细的仓库上:openinference 的误报率是 92%,ruff 是 86%。而在一个压根没有汇总门禁的仓库上,误报率是零——因为没有任何东西可以被误读。

这个工具在最优秀的代码上准确率最低。

一个工具如果在烂代码上很吵,噪音和信号是一起到达的,你会两个都读。但如果它偏偏在好代码上很吵,被惩罚的就是那些把活干对的人,而他们最自然的反应就是不再运行它。

语料库的平均值无法暴露这一点,因为语料库里大部分是普通仓库,工具碰巧在那里是对的。

一个活下来的变异体,可能意味着代码什么都没做

为了不再报告那些已经被门禁覆盖的失败任务,作者沿着 needs 图做了传递性遍历:如果某个门禁覆盖了 ci,而 ci 依赖 changes,那么 changes 也算被覆盖了。

接着对修复做了变异测试——也就是把每个测试声称能抓到的缺陷人为植入,看是否有东西变红。有一个变异体活了下来:删掉传递性遍历的那个。

第一反应是缺测试。但并不是。变异体活下来是因为那段遍历本身就是错的:needs.*.result 只报告直接依赖,而一个失败的祖父节点会让父节点被跳过,跳过不等于失败,所以门禁根本看不到它。传递性遍历会压制掉真实的发现。

一个存活的变异体有两种含义,而且很容易混淆:要么缺一个测试,要么这段代码什么都没做。如果当时写个测试把它杀掉,就等于把一个 bug 钉在原地,还管它叫覆盖率。

我公开更正了一个本来正确的数字

修这些问题的过程中,作者注意到自己有五个仓库的 README 里写着过期的测试数量。手动更正,并加了一个 CI 检查防止再次漂移。

这个检查立刻变红了。在一个刚刚"更正"过的数字上。

agent-graph 的 README 从 42 改成了 58,公开的提交信息里还写着上一个提交把数量夸大成 67。而 CI 收集到的是 67。本地虚拟环境缺了一个可选依赖,tests/test_rag.py 开头是 pytest.importorskip,于是九个测试在收集阶段悄无声息地消失了——没有报错,没有警告,连文件名都不提。pytest 说"收集到 58 个测试",它说的是实话。

原来的数字是对的。作者用一个来自非真实环境的测量值,公开更正了一个正确的说法。

一小时前刚发布的那个检查有同样的盲区,它同意了 58。现在它会在磁盘上任何测试文件贡献零个被收集测试时失败,因为那是环境不完整的唯一证据。

语料库给你体量,体量给你平均值

上面这些扫描由作者自己的测试框架驱动,而背后的智能体脚手架、门禁实现和提示词都不在仓库里,也不打算放进去。deadgate 本身很小,MIT 协议,全部代码可读。把它指向 275 个仓库并分类存活者的那部分,是保留的。

作者说,不知道怎么在没有优质代码样本的情况下测试一个 linter 的这类错误,而"优质代码"不是一个能自动检测的属性。语料库给你体量,体量给你平均值,而平均值恰恰是掩盖这个问题的那个统计量。

唯一有效的东西是品味:挑两个你本来就相信工程做得好的仓库。