← 返回博客2026-08-12

当静态检查器开始脑补浏览器,就别再证明了

为了证明一个 CSS custom property「只会用于字体」,locale 闸门差点在内部重造一套 cascade engine。真正稳的边界不是继续补 selector case,而是承认静态证据到哪里结束,再把合法写法收窄到局部、显式、可测试。

一条会同时撒两种谎的闸门

我们想做一条阻断式 locale 检查,规则很简单:生产 UI 文案必须来自中英文字典,web 客户端源码里一旦出现硬编码汉字,就让构建失败。

逐行 grep 根本守不住这条规则。中文注释、与语言无关的 CJK 标点都会被当成界面文案,粗略算下来接近 900 个误报。后来手写了 comment stripper,情况反而更危险:展示给用户的 URL 里有 //,真实字符串可能被整段抹掉;template expression 里的类注释语法,又可能凭空制造违规。同一把闸门开始同时撒两种谎——该红的不红,不该红的乱红。

第一步结构性修复并不神秘:按真正发布的语言来 parse。TypeScript 与 JSX 交给 TypeScript AST;Vue 文件先用 SFC compiler 拆块;template 走 Vue template AST;CSS declaration 交给 PostCSS 与 value AST。comment 不再是「我们猜着删掉的一段文字」,而是 parser 本来就认识的语法节点。

CSS custom property 把证明边界推远了

看一个目前只喂给字体栈的 token:

:root { --brand-font: "苹方"; }
.title { font-family: var(--brand-font), sans-serif; }

最顺手的豁免是:只要所有可见 consumer 都是 font-family,这里的汉字就是字体名,不是用户文案。问题藏在「所有」两个字里——scanner 得先证明它。

只靠源码文本,证明不了。另一个文件可以重定义同名变量;ancestor 可以把它继承下来;runtime class set、cascade 顺序、at-rule、Vue scoped-style transform 都会改变最终归属。同一个 token 以后还可能流进 generated content:

.card::after { content: var(--brand-font); }

走到这里,检查器已经不是在判断一段 CSS value,而是在没有最终 DOM、active media condition 和 runtime class 的情况下,模拟浏览器 cascade。

证明越写越大,本身就是警报

有一版实现试图保住 custom-property 豁免,办法是证明 cascade ownership。它逐渐长出了 selector shape、specificity、source order、inheritance、conditional scope、!important、Vue style-block identity 和 whole-tree consumer tracking。那次 merge 给 scanner 增加了 277 行,regression 又增加了 217 行。

这些测试并非白费。它们真的揪出了 sibling combinator、functional pseudo、escaped identifier、selector list、重叠 class、以 root 开头的 descendant,以及同一行多个 Vue style block 等歧义。但每修一个角落,locale checker 里就多长出一点不完整的 CSS engine。

这就是可以复用的警报:当一条例外要求你复刻另一个系统的 runtime semantics,这条例外已经越过了当前工具的证据边界。继续补 case,或许能把近似做得更好,却不会自动把近似变成证明。

最后落地的规则

我们保留 syntax-aware scanner,把 policy 收窄:

  • 汉字直接写在 fontfont-familysrc declaration 里,可以豁免——这条 declaration 自己就证明了用途。
  • 只要 custom property 的 value 含汉字,一律进入审计。scanner 手里的输入无法证明它未来只会被谁消费。
  • CSS comment 仍作为 AST node 被忽略;escape 编码的汉字、continued string、未加引号的 counter symbol,以及大小写不敏感的 url() value data,仍然逃不过检查。
  • 不支持的 script/style language 与空扫描都直接失败,不允许拿一张真空绿灯。

真正的字体 token,要修也很朴素:

.title { font-family: "苹方", sans-serif; }

我们用一个局部、显式的 declaration,换掉了一层方便的抽象。这里值得这么做:改写成本很低,而一次静默的 locale 泄漏,比这点局部误报贵得多。

给静态闸门的一条决策方法

任何 blocking analyzer 要新增「聪明豁免」之前,先把三件事写清楚:

  1. 工具手里到底有什么证据?AST 能证明 syntax,source tree 也许能证明 reference;两者都不会自动证明 runtime ownership。
  2. 歧义落在哪?别让「大概只用于字体」「现在走不到」「通常会被覆盖」悄悄变成 PASS。
  3. 哪种纠正更便宜?如果作者能在局部把意图写明,就优先采用显式写法;如果合法案例很多、改写又昂贵,就把检查搬到拥有 runtime evidence 的层,而不是教静态闸门继续猜。

fail-closed 并不是放之四海皆准。误报无穷的闸门,最后一定会被绕开。真正有用的模式更窄:只在工具缺少「批准例外所需证据」的位置 fail closed,同时把合法逃生口做得小、显式、可测试。

总结

  1. 语法问题交给 parser,别不断给 regex 打补丁、养出一个影子 parser。
  2. 给 analyzer 发豁免之前,先定义它的证据边界。
  3. 如果证明一条例外需要重造浏览器、compiler 或 policy engine,就停下来收窄规则。
  4. 把安全写法摆明:这里允许直接 font declaration,不允许 custom property 携带汉字。
  5. regression 两边都要钉住——必须失败的 value,以及必须保持绿色的合法 syntax。