昨天我把一个叫 deadgate 的工具发布到了 PyPI。它读取 GitHub Actions 工作流,找出那些"无法失败"的检查:一个被跳过的任务满足的必需门禁、一个被管道吞掉的退出状态、一个从不读取上游是否通过的汇聚任务。

然后我把它指向了 Arize-ai/openinference,1.2k 星标,22 个工作流。它报告了 13 个高危发现。

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

其中 12 个是误报。

不是模棱两可的误报,也不是"技术上成立但影响很小"的那种。那个仓库的 CI 配置完全正确,是我的工具看不见它。而做报告的那个版本,已经在 PyPI 上挂了大约一个小时。

它看不见官方推荐的写法

GitHub 官方文档里,询问"上游是否有任何东西失败了"的标准写法是通配符形式:用 contains(needs.*.result, 'failure') 来判断。

我的检测器用正则 needs\.[A-Za-z0-9_-]+\.(result|outputs) 去匹配,这个表达式根本匹配不到星号。于是文档推荐的那一种写法,恰好是工具完全看不见的那一种。每一个写得正确的汇聚门禁,都被报告成了"无法失败的门禁"。

更讽刺的是,那条误报自己的提示信息里写着"从未读取 needs.*.result"——而它恰恰没能匹配上这个字符串。

我修复了它,发布了 0.1.1 版本,然后把修复后的版本指向了 astral-sh/ruff。21 个高危发现。18 个是误报。Ruff 用了第三种写法:通过环境变量把整个 needs 对象转成 JSON,再用 jq 去筛选所有 result 不等于 success 也不等于 skipped 的条目。这种写法一次性读取所有上游,而"result"这个词根本不出现在任何工作流表达式里。我的模式匹配无从下手。

第四类问题不在拼写上

第四类根本不是拼写问题。scikit-learn 有一个任务调用了可复用工作流,所以它只有 uses 没有 steps,它通过任务级别的 with 传递 needs.check-sdist.result。这个表达式我完全理解,它只是待在一个我没在看的地方——我扫描的是步骤级别的 with,不是任务级别的 with。

四类问题。前两类已经发布到了 PyPI。第三类和第四类,是我不断把"已修复"的版本指向新仓库、而不是宣布胜利之后才发现的。

下面这个数字才是关键。在一个包含 275 个仓库、4543 个文件的语料库上,用两个版本读取完全相同的字节:

40% 的高危发现是误报。

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

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

我的检查工具,在最好的代码上准确率最低。

语料库平均值掩盖了一切

如果一个工具在烂代码上很吵,噪音和信号会一起到达,你会两个都读。但如果它偏偏在好代码上很吵,那些把工作做对的人反而成了被惩罚的对象,而最自然的反应就是不再运行它。语料库平均值无法暴露这一点,因为语料库里大部分是普通仓库,工具碰巧在那里是对的。

我手里有那个语料库。我跑了它。它告诉我 40%,我本来会把这个数字当作头条发布出去。真正发现问题的,是把工具指向两个我本来就尊重的 CI 配置。

为了停止报告那些失败已经被门禁捕获的任务,我沿着 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。它现在会在磁盘上任何测试文件贡献零个被收集测试时失败,因为那是环境不完整的唯一证据。

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

我不知道如何在没有一批好代码样本的情况下,测试一个检查工具的这一类错误,而"好代码"并不是我能自动检测的属性。语料库给你体量,体量给你平均值,而平均值恰恰是掩盖了这件事的那个统计量。

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