← 返回导航站首页

门禁工具自己怎么查体:一条静态规则命中率只有 11%,而它漏掉的那个才是真缺陷

校验脚本、linter、体检工具——这类东西的价值全在一个前提上:它自己是对的。它一旦坏掉,坏的方式不是报错,而是放行。放行不产生任何日志,也没有人会发现。

常见的坏法只有一种形状:空集合满足全称命题。检查范围是 0 个文件时,「没有发现问题」自动成立,于是「0 个文件」被判成「满分 / 可以合并 / 通过」。这不是理论推演,下面第 2 节是本机真实语料里的实例。

问题是:怎么给这类工具查体?我用同一批语料(29 个技能包 / 141 个 .py)做了三轮,每轮都被上一轮的误报逼着改判据,最后得到的结论比工具本身更值得记:

「这是不是一个缺陷」不是被测出来的,是测出来的行为加上工具自己声称的契约一起判出来的。少任何一半,误报率都在 50% 以上。

工具在 tools/gate/gate_lint.py(纯标准库,selftest 38/38,退出码三态)。


1. 先看一个能对上号的样本

本机有个代码审查技能包,它的 SKILL.md 里写着这样一句契约:

退出码:0 无致命问题,2 存在致命问题,可直接拿来做 CI 门禁。

把它的扫描目标指向一个空目录,它输出:

- 审查范围: 0 个文件,0 行
- 健康分: 100/100 — ✅ 可以合并

退出码 0。

对一个自己声明是门禁的工具,「我读了 0 个文件」和「我没发现问题」用了同一个退出码。CI 里路径写错、挂载没生效、git checkout 落到空目录——都会拿到绿灯。这种缺陷静态看不出来,因为它长得和「跑了且干净」一模一样。


2. 第一层:静态规则(只读文本,不执行任何东西)

规则六条,都只读源码文本:

规则 级别 判什么
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)。


3. 静态读数有多准?拿动态真跑一遍对答案

静态规则只能给候选,因为它读的是文本。给 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 次。事实和含义是两回事。


4. 第二层也会误报:同行为、不同契约

动态探针把 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 那行是这套方法最有价值的产出:三层读数互相矛盾,等于给出一张「这里需要人看一眼」的票。工具不替人下结论,但保证矛盾不会被静默吞掉。


5. 工具自己也会吞数据

第一版 probe 的桶是这样分的:确认缺陷 / 无参数绿灯 / 只打用法 / 被拒。写完一跑,5 个无参数绿灯的脚本里只列出 4 个——第 5 个(一个公文生成器)既没进缺陷桶、也没进用法桶,它消失了。

这就是本文开头那个病,只不过长在体检工具自己身上:没有进任何桶的观测,和被拒绝的观测,在汇总里长得一样。修法是在输出里加一行核对:

- 台账核对: 空目录 48/48,无参数 48/48(不等就是有观测被吞了)
- 无参数即绿灯(契约未声称门禁,观察项): 2 个

每个观测必须恰好落进一个桶,桶的总数必须等于探针执行数。不等就是有东西被吞了。汇总口径要能被核对,不能只被相信。


6. 用法

# 静态扫描(不执行被检脚本)
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 通过。


7. 边界声明


8. 上线检查清单

  1. 门禁工具必须有自检入口,且自检打印断言计数(n/m),不是一句「全部通过」。
  2. 零输入必须返回独立的非成功状态(与「发现了问题」用不同退出码)。
  3. 退出码至少三态:成功 / 发现问题 / 输入错误。1 不要拿来兼当「发现问题」。
  4. 报告里必须写出扫描范围(文件数/行数),不能只输出结论。
  5. 报告里出现 0 个文件 时,禁止同时输出满分或「可以合并」。
  6. 声明自己是门禁(写入文档、配进 CI)的工具,契约与退出码必须逐条对齐,并且被自己的文档约束。
  7. 自检入口的调用约定要写明(位置参数还是 --flag),统一 harness 两种都得认。
  8. 静态检查类工具要写清 precision/recall——它不是判据,是筛子。
  9. 动态探针类工具要提供「不执行」的路径,执行必须显式放行。
  10. 汇总口径要能核对:每个观测恰好落进一个桶,桶总和 == 输入总数。
  11. 判定「算不算缺陷」时把契约(工具声称自己是什么)作为输入,别只靠行为。
  12. 三层读数不一致时不要自动下结论,输出「需要人看」的票。

9. 可复用的三条

一、空集合永远满足全称命题。 「我没有发现问题」和「我什么都没看」必须在接口层就分开,而不是靠读日志区分。

二、判据要靠外,但「缺陷」这个词必须有对象。 同一个行为,对门禁是缺陷,对计时器是正常。所以判定链是:行为(动态)× 契约(工具声称的身份)= 结论。少了契约,误报率立刻过半。

三、规则给候选,不许给结论。 静态规则最诚实的位置是「筛子」:把 141 个文件缩到 9 个值得动态确认的目标。让它直接下判决,你会得到 11% 的精度和 8 张假罚单。

配套工具与语料:tools/gate/gate_lint.py(纯标准库,selftest 38/38,退出码 0/2/3)。


📚 更多创作资源: https://afdian.com/a/felix007