When your static analyzer starts imagining the browser, stop proving
To prove that one CSS custom property was font-only, a locale gate nearly rebuilt the cascade inside itself. The robust boundary was not another selector case; it was admitting where static evidence ends and making the safe authoring path local, explicit, and testable.
The gate that lied in both directions
We wanted a blocking locale check with a simple rule: production UI copy must come from the bilingual dictionary, so hard-coded Han characters in the web client fail the build.
A line grep could not enforce that rule. It treated Chinese comments and language-neutral CJK punctuation as UI copy, producing roughly 900 false hits. A hand-written comment stripper was worse: // inside displayed URL text could erase a real string from the scan, while comment-like syntax inside a template expression could invent a violation. One tool could now lie in both directions — false red and false green.
The first structural fix was straightforward: parse the languages we actually ship. TypeScript and JSX go through the TypeScript AST; Vue files are split with the SFC compiler; templates use the Vue template AST; CSS declarations use PostCSS plus a value AST. Comments stop being “text we hope to remove” and become syntax nodes the parser already understands.
Then CSS custom properties moved the proof boundary
Consider a token that currently feeds a font stack:
:root { --brand-font: "苹方"; }
.title { font-family: var(--brand-font), sans-serif; }
The obvious exemption is: if every visible consumer is font-family, the Han text names a typeface rather than user-facing copy. That works only if the scanner can prove the word “every.”
It cannot, from source text alone. A same-named variable can be redefined in another file, inherited through an ancestor, selected by a runtime class set, reordered through the cascade, placed behind an at-rule, or affected by Vue’s scoped-style transform. The same token can later reach generated content:
.card::after { content: var(--brand-font); }
At that point the checker is no longer classifying one CSS value. It is approximating the browser’s cascade without the final DOM, active media conditions, or runtime classes.
The warning sign was the size of the proof
One implementation tried to retain the custom-property exemption by proving cascade ownership. It grew selector-shape analysis, specificity comparisons, source-order rules, inheritance handling, conditional-scope handling, !important logic, Vue style-block identities, and whole-tree consumer tracking. The merge carried 277 added scanner lines and 217 added regression lines.
Those tests were useful. They found real ambiguity around sibling combinators, functional pseudos, escaped identifiers, selector lists, overlapping classes, root-prefixed descendants, and same-line Vue style blocks. But each fix expanded a second, partial CSS engine inside a locale checker.
That is the reusable smell: when an exemption requires you to reproduce another system’s runtime semantics, the exemption has crossed your evidence boundary. More cases may improve the approximation while never turning it into proof.
The rule we shipped
We kept the syntax-aware scanner and narrowed the policy:
- A Han value written directly in
font,font-family, orsrcis exempt. The declaration itself proves its purpose. - A Han-bearing custom property is always audited. Its future consumers cannot be proven from the scanner’s inputs.
- CSS comments remain ignored as AST nodes; escape-encoded Han, continued strings, unquoted counter symbols, and value data inside case-insensitive
url()forms remain visible to the gate. - Unsupported script/style languages and an empty scan fail instead of producing a vacuous green result.
The genuine font-token case has a deliberately boring repair:
.title { font-family: "苹方", sans-serif; }
We traded a convenient abstraction for a locally explicit declaration. That is acceptable here because the rewrite is cheap and a silent locale leak is more expensive than a narrow false positive.
A decision rule for static gates
Before adding a “smart” exemption to any blocking analyzer, write down three things:
- What evidence does the tool actually possess? An AST proves syntax. A source tree may prove references. Neither automatically proves runtime ownership.
- What is the ambiguous case? Do not let “probably font-only,” “currently unreachable,” or “usually overridden” silently become PASS.
- Which correction is cheaper? If authors can express intent locally, prefer that explicit form. If the valid cases are common and costly to rewrite, move the check to a layer with runtime evidence instead of teaching the static gate to guess.
Fail-closed is not a universal virtue. A gate with endless false positives will be bypassed. The useful pattern is narrower: fail closed exactly where the tool lacks the evidence required for an exemption, then keep the approved escape hatch small, explicit, and testable.
Recap
- Use parsers for syntax; do not keep patching regexes into a shadow parser.
- Define the analyzer’s evidence boundary before granting exemptions.
- If proving an exemption means rebuilding a browser, compiler, or policy engine, stop and narrow the rule.
- Make the safe authoring path obvious: here, a direct font declaration is allowed; a custom property carrying Han is not.
- Keep both sides in regression tests — values that must fail and legitimate syntax that must stay green.