问题
硬编码字符串指绕过本地化系统、直接到达屏幕的字符串。仓库约定的写法是 Text(L10n.Onboarding.Welcome.titleLine1);一处 leak 则是被原样发布的 Text("看懂世界")。leak 不会让编译失败,只会对每个 locale 不是该字面量所写语言的用户静默失败。
数量并不小:MenuLens 第一遍全量扫描就在 onboarding、paywall、result 等页面暴露 247 处 leak,YoloShell 有 24 处。目标是用机器枚举 leak、把计数压到 0,再让一次回归的代价落在作者的红色流水线上,而非用户的坏页面上。
为什么单一检查不够
本地化在三个相互独立的位置坏掉,一个检测器覆盖不了:开发者直接写 Text("Start") 没走 L10n(源码可见);走了 L10n 但 CSV 在某 locale 没译文、静默回退基准语言(源码与运行时都不可见,除非切到那个 locale);走了 L10n 也译了,却被某布局、formatter 或条件分支挡住没渲染出本地化值(像素画出来前不可见)。
工具链是 DevOps/Rust/i18n-audit/ 下的 Rust crate,通过 dev i18n <子命令> CLI 暴露,用三条独立路径分别回答。
路径一:静态源码扫描
dev i18n check-source 遍历模块 Swift 文件,标记看起来像用户可见文案的字面量,分类刻意保守:含 CJK 表意文字或假名的是 ChineseLiteral;两词以上、仅 ASCII 字母标点、无格式占位符、非 camelCase 标识符的英文文案是 EnglishLiteral;其余(标识符、资源名、URL、正则、key 参数)忽略。
误报靠两层抑制:整文件跳过(Tests/、Mocks/、Logger.swift、生成的 L10n.swift)与行级标记(logger.、NSLog(、systemName:、forKey:),各产品降噪旋钮(extra_skip_markers、文件前后缀排除)与各自 App 一起入库。
有一类情况扫描器不能报:故意硬编码的串。品牌行 Snap · Translate · Recommend 每种语言都一样,修法不是加 key,而是 Text(verbatim: "Snap · Translate · Recommend")——verbatim: 是作者声明"此字面量有意",MenuLens 这轮用了 9 次,意图变成代码而非一行让扫描器去猜的注释。
路径二:静态 CSV 覆盖率
dev i18n check-csv 不启动任何东西,直接校验 CSV,报告三类问题:每 locale 填充率(默认门槛 95%,最差的排前)、重复 key、格式占位符一致性(每 locale 占位符必须与 en 基准一致,德语丢掉英文声明的 {pct} 会报 mismatch)。占位符检查风格感知:printf(%@、%d)与花括号({name})按两个独立子集比较,译文里 % 后接字母不算 spec。
覆盖率是真实数据:MenuLens 42 locale × 381 key 全 100%;SnapSweep 20 个按页 CSV、285 条 key-row、42 locale 全 100%;YoloShell 11 locale × 164 key,最低 99.4%,全部高于门槛。
路径三:伪本地化 OCR 审计
前两条是静态的,无法证明接线正确的字符串真以本地化形态显示了出来。机制是伪 locale:csv2strings-rs --emit-pseudo 生成 xa-XA.lproj,每个本地化值被替换成带角括号的 key 路径 《Onboarding.Welcome.titleLine1》。dev i18n audit 把模拟器切到该 locale、装上构建、逐页导航截图,再过 macOS Vision OCR 给每个 token 分类:点分标识符是 Sentinel(证明系统到达了那个 label);时钟、百分比、SI 单位、emoji、Apple chrome(Back、Cancel、星期月份名)与 OCR 噪声是 AllowedSystem;其余可读内容是 SuspectedLeak——渲染成真实词语而非哨兵,即绕过了 L10n。leak 之所以显眼,是它在一片 《...》 中唯一可读的英文或中文短语。
两个细节关键:xa-XA 只在 debug 生成,在 localization.bzl 按 compilation_mode 门控,release 与 App Store 构建必须不含——它是 UN M.49 区域码而非 BCP 47,发布会触发 ITMS-90176 拒收;访问页面集合是各产品一份声明式 AuditPages.json,取代了一段 148 行 bash,加页面现在是改 JSON 而非改脚本。
让它保持在零
压到 0 一次是容易的一半,难的一半是工具链可信到能拿来 gate——动不动误报的检测器一周内就被关掉。分类器带 107 个单测(仅 sentinel 文法 39 个、源码扫描器 21 个),覆盖 OCR 残渣、专有名词豁免(加密算法名、协议标识、终端主题、修饰键组合)与格式占位符边界;豁免清单精简且每条有文档,因为过宽的 allow-list 会静默吞掉真实 leak。
退出码就是契约。check-csv、check-source 与聚合的 dev i18n status 在 MR 流水线运行,填充率低于门槛或出现新源码候选,job 即失败。leak 计数是作者合并前必须看到的数字,不是用户发布后才发现的数字。
可迁移的结论
- 本地化在三个相互独立的位置坏掉——未翻译源码、缺失 CSV 条目、永不渲染本地化的字符串,一个检测器盖不全,三条一起跑。
- 把意图编码进代码而非注释:
Text(verbatim:)让门槛能严而不误报;伪 locale 把"它本地化了吗"变成视觉上的是/否,但要按构建模式门控让xa-XA永不发布。 - 要 gate CI 的 linter 必须用测试挣来信任,退出码才能当契约。