校验脚本、linter、体检工具——这类东西的价值全在一个前提上:它自己是对的。它一旦坏掉,坏的方式不是报错,而是放行。放行不产生任何日志,也没有人会发现。
常见的坏法只有一种形状:空集合满足全称命题。检查范围是 0 个文件时,「没有发现问题」自动成立,于是「0 个文件」被判成「满分 / 可以合并 / 通过」。这不是理论推演,下面第 2 节是本机真实语料里的实例。
问题是:怎么给这类工具查体?我用同一批语料(29 个技能包 / 141 个 .py)做了三轮,每轮都被上一轮的误报逼着改判据,最后得到的结论比工具本身更值得记:
「这是不是一个缺陷」不是被测出来的,是测出来的行为加上工具自己声称的契约一起判出来的。少任何一半,误报率都在 50% 以上。
工具在 tools/gate/gate_lint.py(纯标准库,selftest 38/38,退出码三态)。
本机有个代码审查技能包,它的 SKILL.md 里写着这样一句契约:
退出码:
0无致命问题,2存在致命问题,可直接拿来做 CI 门禁。
把它的扫描目标指向一个空目录,它输出:
- 审查范围: 0 个文件,0 行
- 健康分: 100/100 — ✅ 可以合并
退出码 0。
对一个自己声明是门禁的工具,「我读了 0 个文件」和「我没发现问题」用了同一个退出码。CI 里路径写错、挂载没生效、git checkout 落到空目录——都会拿到绿灯。这种缺陷静态看不出来,因为它长得和「跑了且干净」一模一样。
规则六条,都只读源码文本:
| 规则 | 级别 | 判什么 |
|---|---|---|
| G001_NO_SELFTEST | high | 门禁工具自身没有自检入口 |
| G002_SELFTEST_NO_COUNT | medium | 有自检但不打印断言计数(「全部通过」≠「12/12 通过」) |
| G003_ZERO_SCOPE_CANDIDATE | medium | 零范围文案与通过口径同现 |
| G004_NOARG_SUCCESS | high | 无参数调用没有非零退出 |
| G005_TWO_STATE_EXIT | medium | 退出码只有两态,无法区分「输入错」与「发现问题」 |
| G006_CONVENTION | info | 自检入口约定(位置参数 / --flag) |
扫 141 个文件(命中数):
GATE_FAIL 24 / GATE_WEAK 29 / GATE_CLEAN 88
G001_NO_SELFTEST 20
G002_SELFTEST_NO_COUNT 8
G003_ZERO_SCOPE_CANDIDATE 9
G004_NOARG_SUCCESS 4
G005_TWO_STATE_EXIT 30
G006_CONVENTION 33 (positional 26 / flag 3 / 两套都有 4)
G005 命中 30 个不是危言耸听:本仓脚本里「非零退出」基本只有一种写法,动态读数(第 3 节)显示空目录那一次出现过的退出码是 2 → 34 次、1 → 8 次、3 → 1 次、4 → 1 次。也就是说 1(通用错误)和 2(发现问题)在同一批工具里被混用,上层拿到 1 无法判断该重试还是该报警。
G006 那 26/3/4 的分布本身就是结论:统一 harness 如果只认一种约定,就会静默漏跑另一批工具的自检。所以 gate_lint.py 两种都认(selftest 与 --selftest)。
静态规则只能给候选,因为它读的是文本。给 48 个带 scripts/ 的脚本各跑两次(无参数一次、空目录一次),用真实退出码和输出对答案:
- 探针执行: 48 个脚本
- 契约自查: 声称自己是门禁的包 21 个
- 已确认缺陷(空目录仍给绿灯 且 契约声称是门禁): 2 个
- 观察项(同样行为,但契约没声称是门禁,不当缺陷): 0 个
- 无参数即绿灯(契约声称是门禁): 0 个
- 无参数即绿灯(契约未声称门禁,观察项): 2 个
- 无参数只打用法退 0(轻微,不计缺陷): 4 个
- 空目录输入被拒(exit≠0): 44 个
- 无参数输入被拒(exit≠0): 42 个
- 台账核对: 空目录 48/48,无参数 48/48(不等就是有观测被吞了)
44/48 拒绝空输入——这批工具整体是健康的,坏的是少数。两个确认缺陷:
| 包 / 脚本 | 空目录退出码 | 观测 |
|---|---|---|
| code-review-assistant / code_review.py | 0 | 「0 个文件」+「健康分 100/100 — ✅ 可以合并」 |
| secret-leak-guard / profile_scan.py | 0 | 明确报告扫到 0 个仍给绿灯 |
现在把静态和动态并排,看 G003 那条规则到底值多少:
静态候选 9 个 动态零范围绿灯 2 个
precision = 1/9 = 11%
recall = 1/2 = 50%
HIT code-review-assistant/code_review.py
MISS secret-leak-guard/profile_scan.py
FP ×8(8 个文件被静态点名但动态跑完全正常)
命中率 11%。八个误报里有 5 个是同类:源码里存在 print("- 扫描范围: %d 个文件" % len(files)) 这种格式化串,静态把 %d 个文件 读成了真实的零。它在文本里和真的 0 长得完全一样——这就是「静态无法定论」的字面意思。
顺带一个反直觉的读数:G001(无自检入口)命中的 20 个文件里,18 个在动态里都正确拒绝了空输入(exit 1 或 2)。静态陈述「这个工具没有 selftest」是事实,但它暗示的「这工具不可靠」被行为否掉了 18/20 次。事实和含义是两回事。
动态探针把 profile_scan.py 也判成了缺陷。但翻它的 SKILL.md,这脚本的角色行写的是:
scripts/profile_scan.py 性能回归:定位哪个文件 / 规则慢
它是计时分析器,不是门禁。空目录跑 0 个文件、退 0——完全正确。动态探针只看行为,没有能力区分「这是门禁」还是「这是计算器」。
所以判定必须叠一层契约:读该包 SKILL.md,只有它同时提到门禁/CI/pre-commit 用途且文档化了退出码,才承认它是门禁,才把「空范围给绿灯」算成缺陷。翻回 code-review-assistant——那句「可直接拿来做 CI 门禁」就在它自己文档里,它自证了门禁身份,然后违反了它。
这一层加进去以后,profile_scan.py 从「缺陷」降级为「观察项」——它不是错的,只是它的行为被放错了语境。
三轮下来每一轮都在杀上一轮的误报,但没有任何一层能单独下结论。真正稳的是把三层读数并排放,让不一致自己显形:
| 脚本 | 静态层 | 动态层(空目录 / 无参数) | 契约层 | 处置 |
|---|---|---|---|---|
| code_review.py | G004 high | 0 绿灯 / exit 1 | 自称门禁 ✔ | 确认缺陷 |
| profile_scan.py | G001 high | 0 绿灯 / exit 2 | 自称计时器 ✘ | 观察项(行为正确,语境不符) |
| license_check.py | G004 high | exit 1 / exit 0 绿灯 | 正则判非门禁 | 三者不一致 → 必须人看 |
| env_doctor.py | G004 high | exit 2 / exit 2 | 自称门禁 ✔ | 静态误报,行为无问题 |
| retry_doctor.py | G004 high | exit 2 / exit 2 | 正则判非门禁 | 静态误报 |
G004 四个静态命中,动态只证实 1 个(license_check.py 的无参数绿灯),另 1 个(code_review.py)指向对的工具、说错了机制——静态说「无参数会成功」,实测无参数 exit 1;真正的洞在空目录那一维。剩下 2 个是纯误报。precision = 1/4 = 25%。
license_check.py 那行是这套方法最有价值的产出:三层读数互相矛盾,等于给出一张「这里需要人看一眼」的票。工具不替人下结论,但保证矛盾不会被静默吞掉。
第一版 probe 的桶是这样分的:确认缺陷 / 无参数绿灯 / 只打用法 / 被拒。写完一跑,5 个无参数绿灯的脚本里只列出 4 个——第 5 个(一个公文生成器)既没进缺陷桶、也没进用法桶,它消失了。
这就是本文开头那个病,只不过长在体检工具自己身上:没有进任何桶的观测,和被拒绝的观测,在汇总里长得一样。修法是在输出里加一行核对:
- 台账核对: 空目录 48/48,无参数 48/48(不等就是有观测被吞了)
- 无参数即绿灯(契约未声称门禁,观察项): 2 个
每个观测必须恰好落进一个桶,桶的总数必须等于探针执行数。不等就是有东西被吞了。汇总口径要能被核对,不能只被相信。
# 静态扫描(不执行被检脚本)
python gate_lint.py scan <目录或文件...> [--glob "*.py"] [--min medium] [--json]
# 动态探针:空目录 + 无参数各跑一次(会执行被检脚本,默认拒绝运行)
python gate_lint.py probe <包目录> --allow-exec [--timeout 8] [--json]
# 自检:两套约定都支持
python gate_lint.py selftest
python gate_lint.py --selftest
退出码三态,为的是让上层区分「没跑」和「跑干净」:
| 退出码 | 含义 |
|---|---|
| 0 | 扫到文件且无 high 级发现 / 探针没抓到放行 |
| 2 | 存在 high 级发现 / 探针抓到零范围放行 / probe 未确认 --allow-exec |
| 3 | 零输入,没扫到任何文件——不算通过 |
probe 因为要执行被检脚本,默认直接拒绝运行并返回 2,必须显式给 --allow-exec。扫别人的代码库时,默认行为绝不应该是「跑起来看看」。
自检覆盖:规则正反例、100/100 这种评分语境不算断言计数(这是检测器自身的第一条误报来源)、帮助文本与零范围报告的区别、契约层四态、空文本/CRLF/零文件渲染边界、以及工具对自身文本的判定必须是干净的(ok 自身不命中 G003)。38/38 通过。
.py(其中 48 个在 scripts/ 层、可被 probe 执行)。数字是这批语料的读数,不是行业基准。probe 会执行被检代码。这是它的能力也是它的风险,所以默认关闭、需要显式放行;对不信任的目录只用 scan。SKILL.md,而缺陷主体是脚本。一个自称门禁的包里如果混着计时器、生成器,会被按包级契约误判——profile_scan.py 就是这类。reads_as_gate 靠「提到门禁/CI + 文档化退出码」两个条件,会漏(license_check.py)也会过判。它是用来把明显不是门禁的东西排除掉,不是用来精确识别门禁。probe 只覆盖 scripts/ 一层的 CLI,库文件、被 import 的模块、生成器不在此列。n/m),不是一句「全部通过」。1 不要拿来兼当「发现问题」。0 个文件 时,禁止同时输出满分或「可以合并」。--flag),统一 harness 两种都得认。一、空集合永远满足全称命题。 「我没有发现问题」和「我什么都没看」必须在接口层就分开,而不是靠读日志区分。
二、判据要靠外,但「缺陷」这个词必须有对象。 同一个行为,对门禁是缺陷,对计时器是正常。所以判定链是:行为(动态)× 契约(工具声称的身份)= 结论。少了契约,误报率立刻过半。
三、规则给候选,不许给结论。 静态规则最诚实的位置是「筛子」:把 141 个文件缩到 9 个值得动态确认的目标。让它直接下判决,你会得到 11% 的精度和 8 张假罚单。
配套工具与语料:tools/gate/gate_lint.py(纯标准库,selftest 38/38,退出码 0/2/3)。
📚 更多创作资源: https://afdian.com/a/felix007