静态检查器的误报治理:当「有问题」变成默认值

举报
yd_296806354 发表于 2026/11/09 22:09:27 2026/11/09
【摘要】 一个 Excel 公式检查器给出了 100% 命中率:6 个公式 6 个有问题。本文拆解误报是怎么被制造出来的,以及写检查器时该跑的五个自检。

我给一个 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

三个分支,实际上在说三件不同的事:

  1. 引用了一个不存在的单元格 → 真 bug,公式在 Excel 里会显示 #REF!
  2. 引用了一个空单元格 → 可能是 bug,也可能是「等用户填」,属于合理怀疑
  3. 引用了一个常量值单元格 → 这不是任何问题,这是电子表格的定义

第三个分支就是误报的源头。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 里的税率),而不是所有数字字面量。

写检查器时的五条自检

沉淀成可复用的规则,我现在的习惯是写完规则集先跑这五问:

  1. 这条规则在「完全正常的表」上会不会命中? 如果会,它要么不该存在,要么必须降级为 info。
  2. 这个名字在业界有没有约定含义? 有就别复用。dead_ref、overflow、race 这类词借来用,读者会按原意理解。
  3. 同一条件,另一个模块会怎么描述它? 两个模块结论不一致时,一定有一方错——这个检查几乎免费。
  4. 统计口径里,「风险」和「提示」分开了吗? 不分开,命中率这个指标就废了。
  5. 把命中结果直接喂给「自动修复」,会不会产出一堆「保持不变」? 会,说明规则集里有不该进问题池的东西。

第五条特别有用,因为它是个端到端的检验:修复模块的产出是误报的显影剂。分析阶段还能靠措辞含糊蒙过去,到了「要不要改」这一步就蒙不了了。

最后

误报这件事,本质上是把自己的不确定当成了用户的确定。

规则作者心里想的是「引用常量值单元格,虽然正常,但万一呢」——这个「万一」被写成了 dead_ref,然后以「有问题」的口吻交到用户手上。用户看到的是另一回事:这工具连 =A2*2 都要报。

工具该做的是把不确定留在自己这边,把确定的交出去。做不到的时候,至少要诚实地把它标成「提示」,而不是塞进「问题」的桶里。

毕竟检查器的信用是有限的,而每一次误报都在花它。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。