静态检查器的误报治理:当「有问题」变成默认值
我给一个 Excel 公式分析工具跑了个最小用例:一张表、6 个公式,全是 =A2*2、=SUM(A2:A3)、=IF(A2>50,"高","低") 这种幼儿园级别的写法。
工具输出:
共检测 6 个公式,6 个有问题,2 个高风险
6 个公式,6 个有问题。
这就是本文想聊的事:一个检查器出现 100% 的命中率时,它的输出已经不再是信息,而是噪音。
误报的成本比漏报高
在安全领域有个说法叫「告警疲劳」:当告警量超过人能处理的速度,人会开始整体忽略告警。静态检查器同理,而且更严重——它没有「升级到人」这一步,误报直接污染了唯一的一份报告。
对检查器来说,两类错误的价格是不一样的:
| 错误类型 | 后果 |
|---|---|
| 漏报(false negative) | 工具没发现这个 bug,用户靠自己。工具价值打折,但不添乱 |
| 误报(false positive) | 用户要花时间确认这是不是真的,确认后发现不是。每次都要花时间 |
漏报是「工具没帮上忙」,误报是「工具帮了倒忙」。而这个差别在一个数字上体现得最清楚:用户还会不会打开第二次。
一个 20% 误报率的工具,用户会边用边骂,但继续用。一个 100% 误报率的工具,用户用一次就不再打开了——不管它的规则写得多专业。
案例解剖:一个被滥用的语义
上面这个工具里,把正常公式判为问题的规则长这样(去掉无关行):
def find_dead_refs(self):
issues = []
for cell_key, refs in self.graph.items():
for ref in refs:
info = self._resolve(ref)
if info is None:
issues.append(("dead_ref", "high", f"引用了不存在的单元格 {ref}"))
elif info.value is None and info.formula is None:
issues.append(("dead_ref", "medium", f"引用了空单元格 {ref}"))
elif info.formula is None and info.value is not None:
issues.append(("dead_ref", "low", f"引用了常量值单元格 {ref}"))
return issues
三个分支,实际上在说三件不同的事:
- 引用了一个不存在的单元格 → 真 bug,公式在 Excel 里会显示
#REF! - 引用了一个空单元格 → 可能是 bug,也可能是「等用户填」,属于合理怀疑
- 引用了一个常量值单元格 → 这不是任何问题,这是电子表格的定义
第三个分支就是误报的源头。A2 是数据源,=A2*2 引用它天经地义;如果这也算 "dead reference"(死引用),那世界上没有活引用。
更值得说的一点在这里:同一个仓库的另一个模块,对完全相同的条件给出了相反的结论。
修复模块(fix_excel.py)对 =A2*2 的处理是:
[需人工确认] 销售!B2
原因: 引用了常量值单元格 A2(值:100),属正常用法
新值: (保持不变)
「属正常用法」——五个字,把分析模块的判定直接推翻了。
一个代码库内部对同一事实给出两个相反结论,这是最便宜的缺陷信号:你不需要判断哪边对,只需要知道其中一定有错。而且修复模块的措辞更像是人写的、审过的;分析模块的实现更像是「顺手多写了一个 elif」。
这类 bug 的共同形状
把上面这个例子抽象一下,能归纳出一个很常见的模式:
> 一个语义明确的标识符,被塞进了语义不同的分支。
dead_ref 在业界含义是清楚的:引用到已删除对象(Excel 里的 #REF!、编程语言里的悬空指针)。当它同时承担「引用空单元格」和「引用常量」时,名字还在,含义已经散了。
后果是连锁的:
- 报告里的
dead_ref计数不再可用——你没法用它判断表里有没有真 bug - 严重度失去区分度——low/medium/high 全打在同一个 id 上
- 下游全部被污染——修复计划里出现大量「新值: (保持不变)」的条目,真正需要动的 1 条被埋在第 7 条
上面这个用例最终产出 10 条修复项、0 条可自动执行。一个需要人工逐条确认、而其中 9 条确认结果是「不用改」的工具,等于让用户替它做减法。
怎么修:三个层次,从便宜到正确
第一层:删掉那个分支。 最省事,也立刻能不能 100% 误报。代价是彻底放弃「引用常量」这个观察。
第二层:换成独立维度。 如果确实想提示「公式里引用了硬编码数据」,那就给它一个自己的名字(比如 const_ref),并且把严重度降到 info。名字对了,用户才知道自己在看什么。
第三层:统计口径分离。 这是最关键的,也是很多工具漏掉的一步:
共检测 6 个公式,4 个存在风险,2 个仅供参考
现在是 6 个有问题,把「提示」和「风险」混在一个数字里。拆开之后,即使误报率不变,用户也能立刻看出「这 4 条里只有 0 条是 high」——信息密度完全不同。
顺带说一句,这个用例里 hard_coded 也是同一类问题:=A2*2 里的 2 被标为「硬编码数值」。系数是业务定义的一部分,*2 的意思是「翻倍」,不是「忘记抽常量」。真要提示,应该优先提示那些魔法数字(比如 =A1*1.17 里的税率),而不是所有数字字面量。
写检查器时的五条自检
沉淀成可复用的规则,我现在的习惯是写完规则集先跑这五问:
- 这条规则在「完全正常的表」上会不会命中? 如果会,它要么不该存在,要么必须降级为 info。
- 这个名字在业界有没有约定含义? 有就别复用。
dead_ref、overflow、race这类词借来用,读者会按原意理解。 - 同一条件,另一个模块会怎么描述它? 两个模块结论不一致时,一定有一方错——这个检查几乎免费。
- 统计口径里,「风险」和「提示」分开了吗? 不分开,命中率这个指标就废了。
- 把命中结果直接喂给「自动修复」,会不会产出一堆「保持不变」? 会,说明规则集里有不该进问题池的东西。
第五条特别有用,因为它是个端到端的检验:修复模块的产出是误报的显影剂。分析阶段还能靠措辞含糊蒙过去,到了「要不要改」这一步就蒙不了了。
最后
误报这件事,本质上是把自己的不确定当成了用户的确定。
规则作者心里想的是「引用常量值单元格,虽然正常,但万一呢」——这个「万一」被写成了 dead_ref,然后以「有问题」的口吻交到用户手上。用户看到的是另一回事:这工具连 =A2*2 都要报。
工具该做的是把不确定留在自己这边,把确定的交出去。做不到的时候,至少要诚实地把它标成「提示」,而不是塞进「问题」的桶里。
毕竟检查器的信用是有限的,而每一次误报都在花它。
- 点赞
- 收藏
- 关注作者
评论(0)