Skip to content

🌐 新增翻译完整性机械检查 (check:i18n)#1606

Merged
CodFrm merged 7 commits into
scriptscat:mainfrom
cyfung1031:claude/missing-translation-checker-6b27d1
Jul 20, 2026
Merged

🌐 新增翻译完整性机械检查 (check:i18n)#1606
CodFrm merged 7 commits into
scriptscat:mainfrom
cyfung1031:claude/missing-translation-checker-6b27d1

Conversation

@cyfung1031

@cyfung1031 cyfung1031 commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator

Checklist / 检查清单

  • Fixes mentioned issues / 修复已提及的问题
  • Code reviewed by human / 代码通过人工检查
  • Changes tested / 已完成测试

背景

#1587(pt-BR 翻译)合并后,评审中发现该 PR 遗漏了 messages.json(chrome.i18n 商店信息)与 Monaco 编辑器语言(editorLangs),当时没有机制能自动拦截这类遗漏("we don't have a solid mechanism to prevent this")。本 PR 为此补上一个机械检查脚本,并在本地提交与 CI 两处强制执行。

本次改动

新增 scripts/check-i18n.mjs,并通过新增的 pnpm run check:i18n 接入 pnpm lint / pnpm lint:ci。针对 src/locales/ 下的每个 locale,以 en-US 为基准做以下校验:

  1. src/locales/<locale>/*.json:每个命名空间文件的 key 是否与 en-US 一一对应,缺失或多余都会报错。
  2. src/locales/<locale>/index.ts:是否导出了 en-US 拥有的全部命名空间。
  3. src/assets/_locales/<chrome-locale>/messages.json:与 en/messages.json 的 key 是否一致(即 Brazilian Portuguese/pt-BR Translation #1587 遗漏的部分)。chrome 目录名通过实际扫描 _locales/ 目录解析(如 ko-KRkozh-CNzh_CN),而非写死的映射表,避免映射表本身过期失效。
  4. docs/references/terminology-<locale>.md:每个 locale 必须有对应术语规范文件,缺失即报错。
  5. src/pkg/utils/monaco-editor/langs.ts(或未来拆分后的 langs/<locale>.ts + langs/index.ts):editorLangs(悬浮提示、脚本头字段提示,即 Brazilian Portuguese/pt-BR Translation #1587 遗漏的另一部分)用 TypeScript 编译器 API 解析。它是手写的 const 对象而非 JSON,部分属性引用了同文件内其他顶层常量(如 grantValuePrompts: grantValuePromptsEnUS)或跨文件 import 绑定(拆分后每个 locale 一个文件,index.ts"pt-BR": ptBR 指向 ./pt-BR.tsexport default),解析时都会展开后再与 en-US 比对 key。

同时:

  • docs/translation.md 新增"机械检查:遗漏翻译"一节并更新翻译检查清单。
  • .husky/pre-commit 中新增一段:只要暂存区包含 src/locales/**/*.jsonsrc/assets/_locales/**/*.json,本地提交前就会先跑一遍 pnpm run check:i18n,不通过直接拒绝提交——不用等推送后才在 CI 里看到红叉。范围刻意收窄为 json 文件本身(不含 terminology-*.mdlangs.ts/langs/),这样改动检查脚本本身或调整 terminology/langs 这类周边文件时不会被连带拦下;这些周边文件的完整性仍由 CI 侧的 lint:ci(覆盖全部 5 类检查)兜底。

已知限制

  • 对于第 3、5 项,如果某个 locale 尚未创建对应目录/条目(当前如 pt-BR_locales 目录、pt-BR/tr-TReditorLangs 条目),本次检查仅给出提示(warning),不会导致失败——这两处目前是可选/尽力而为的内容(参见 docs/translation.md),与项目现状一致。但只要该 locale 已经创建了对应条目,其 key 就必须与 en-US 保持一致,否则报错。这个折衷是为了不让本 PR 自身因为与本次改动无关的历史遗留缺口(tr-TR/pt-BReditorLangs)而在合并后立即变红。
  • 该脚本无法判断翻译措辞是否准确、是否符合术语规范——那部分仍需人工审阅并遵循 docs/translation.md 与对应的 terminology-<locale>.md
  • 本 PR 无法做到"检查不通过就不允许合并"pnpm run check:i18n 已经接入 pnpm lint:ci.github/workflows/test.ymlLint job),失败时 PR 上会有红叉,本地 pre-commit 也会提前拦截;但真正能拦住合并按钮的,是仓库管理员在 main 分支的 branch protection 里把 Lint 设为 required status check——这一步需要 scriptscat/scriptcat 的仓库管理员在 GitHub 仓库设置里配置,贡献者/本 PR 都无法代为完成,只能建议。

建议审查重点

  • src/pkg/utils/monaco-editor/langs.ts 的 TypeScript AST 解析逻辑(parseModule/flattenNode):是否正确处理了对象嵌套、同文件顶层常量引用、跨文件 import 绑定与 shorthand 属性(grantValuePrompts,)。
  • src/assets/_locales 目录名解析逻辑(findChromeDirName):是否能正确覆盖未来新增的 locale,而不需要手动维护映射表。
  • 第 3、5 项"缺失整个目录/条目仅警告,但已存在则必须对齐"的严格程度是否符合预期。
  • .husky/pre-commit 只在 json 文件改动时触发是否合适:好处是不会拦住本 PR 自身、或调整 terminology/langs 文件时的提交;代价是本地提交阶段不会拦截"新增 locale 但漏配 terminology 文件/langs 条目"这类问题,这类问题仍要等 CI 才会报出来。

验证

  • pnpm run check:i18n:在当前 main 上通过,仅有两条提示性 warning(pt-BR 尚无 _locales 目录、pt-BR/tr-TR 尚无 editorLangs 条目),均为历史遗留、非阻塞。
  • pnpm run lint:ci:全部通过(prettier、tsc、check:i18n、eslint)。
  • 用 scriptscat/scriptcat 官方仓库的三个真实 PR 做了验证:
  • 反向验证(人为在 JSON 命名空间、拆分前后的 langs.ts/langs/<locale>.ts 中删除/新增 key,删除 messages.json、删除 terminology-<locale>.md)均能正确以 exit code 1 报出具体缺失/多余的 key 路径,还原后重新通过;pre-commit 钩子在暂存区包含 json 翻译文件的坏改动时也会正确拒绝提交,同时确认了它不会拦截本 PR 自身对 .husky/pre-commitscripts/check-i18n.mjs 等非 json 文件的提交。

关联

PR scriptscat#1587 (pt-BR) 合并后发现 messages.json 与 monaco 编辑器语言遗漏,且当时无机制拦截;
本次新增 scripts/check-i18n.mjs 并接入 pnpm lint / lint:ci,机械校验:

- src/locales/<locale>/*.json 各命名空间 key 是否与 en-US 一一对应(缺失/多余均报错)
- src/locales/<locale>/index.ts 是否导出全部命名空间
- src/assets/_locales/<chrome-locale>/messages.json 与 en/messages.json 的 key 是否一致
  (尚未创建该目录时仅提示,不阻塞)
- docs/references/terminology-<locale>.md 是否存在(每个 locale 必须有,缺失即报错)
- src/pkg/utils/monaco-editor/langs.ts 中 editorLangs 各 locale 的 key 是否与 en-US 一致
  (尚未创建该 locale 条目时仅提示;已创建则 key 必须对齐)

已用 scriptscat/scriptcat 官方仓库的 PR scriptscat#1568(韩语)与 scriptscat#1587(pt-BR)实际验证:
正确通过完整、正确的翻译提交,也能在人为剔除 key / 文件时正确报错。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@cyfung1031 cyfung1031 changed the title Add mechanical i18n completeness check (check:i18n) 🌐 新增翻译完整性机械检查 (check:i18n) Jul 17, 2026
用 PR scriptscat#1605 实测时发现:check-i18n.mjs 硬编码了单文件 src/pkg/utils/monaco-editor/langs.ts
路径,一旦该文件被拆分为 langs/<locale>.ts + langs/index.ts(如 scriptscat#1605 所做),检查会误报
"文件缺失"。改为先探测 langs/index.ts 是否存在,再退回单文件路径;并将 key 展开逻辑改为
按模块解析(parseModule/flattenNode),支持跨文件解析 import 绑定(如 index.ts 里
"pt-BR": ptBR 指向 ./pt-BR.ts 的 default export)与同文件 shorthand 属性
(如 pt-BR.ts 内的 grantValuePrompts,)。

已用 scriptscat#1605 的实际分支验证:新结构下 0 误报,人为在拆分后的 pt-BR.ts 中删除顶层 key 与
grantValuePrompts 子 key 均能正确报出具体路径。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
cyfung1031 and others added 2 commits July 18, 2026 03:50
CI 的 lint:ci 已经跑 check:i18n,但那只在 push/PR 之后才会看到红叉。
本次让本地 pre-commit 钩子在暂存区涉及翻译相关路径
(src/locales/、src/assets/_locales/、docs/references/terminology-*.md、
src/pkg/utils/monaco-editor/langs.ts 或 langs/ 拆分文件)时,
提前跑一遍 pnpm run check:i18n,不通过则直接拒绝提交,
不需要等到推送后在 CI 里才发现。

真正能拦住"不通过就不许合并"的还有一层:仓库 main 分支的
branch protection 需要把 Lint 设为 required status check,
这一步需要 scriptscat/scriptcat 的仓库管理员在 GitHub 设置里配置,
不是贡献者这边能做的。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
之前的触发范围包含 terminology-*.md、langs.ts/langs/* 等非 json 文件,
导致本 PR 自身改动 .husky/pre-commit、scripts/check-i18n.mjs 这类非
翻译内容的提交也可能被牵连(虽然本次未命中,但范围过宽)。收窄为只在
暂存区包含 src/locales/**/*.json 或 src/assets/_locales/**/*.json 时才
触发 pnpm run check:i18n,其余改动(含本 PR 自身)不受影响,仍可正常
提交推送。CI 侧的 lint:ci 不受影响,仍覆盖全部 5 类检查。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@cyfung1031

Copy link
Copy Markdown
Collaborator Author

manually tested

@cyfung1031 cyfung1031 added the P0 🚑 需要紧急处理的内容 label Jul 18, 2026
@cyfung1031 cyfung1031 added this to the 2026七月 Milestone milestone Jul 18, 2026
@CodFrm

CodFrm commented Jul 18, 2026

Copy link
Copy Markdown
Member

复核了当前 head 450cc0b 的完整 diff,并针对 checker 构造了临时文件树和 Git 暂存区场景。结论:目前不建议直接合并。CI 接入方式本身没问题,但核心检查存在多条可复现的 fail-open 路径:

  1. pre-commit 检查的是工作区,不是待提交的暂存快照.husky/pre-commit:36-41
    git diff --cached 只用于决定是否触发,随后 pnpm run check:i18n 读取普通工作区文件。因此“暂存坏版本、工作区恢复为好版本”会放行坏提交;反过来也会误拦。--diff-filter=ACMR 还漏掉删除操作。建议增加 --staged/--root 模式,用 git checkout-index 的临时快照或 git show :path 校验 index,并覆盖 D

  2. locale 完整性没有对照运行时注册源scripts/check-i18n.mjs:218-258
    当前仅扫描目录,从未核对 src/locales/locales.tsresources/NS。实测以下错误均可 exit 0:存在完整 locale 目录但未注册、删除 en-US/index.ts 的真实 export、用注释中的 "./agent.json" 骗过 includes、增加 stale namespace、删除整个已注册 locale 目录。建议以 locales.ts 的注册集合与 NS 为唯一基准,解析真实 ExportDeclaration,并双向检查目录、JSON 文件和 export。

  3. Chrome 与 Monaco 检查会静默放过整个覆盖面的缺失
    _locales 根目录不存在时整段跳过;已有 locale 的 Chrome 目录被删除时只 warning。Monaco 自定义 AST evaluator 对 spread、named/namespace import、re-export、identifier alias、satisfies、computed property、parse error 等无法解析的结构会折叠成相同的空/叶子 key 集,导致不同运行时结构仍通过;循环 alias 还会栈溢出。建议改为 TypeScript Program/TypeChecker 的 fail-closed 解析,任何未解析节点或诊断都应明确失败。

  4. 策略已落后于当前 main
    当前 main 的 🌐 Cover chrome.i18n messages.json and Monaco editor langs for pt-BR / tr-TR #1605 已补齐 9 个 locale 的 Chrome/Monaco 覆盖并增加对应测试,因此 PR 中“整个目录/条目缺失只 warning”的兼容策略已不再必要;docs/translation.md 仍主要描述旧的单文件 langs.ts

  5. 403 行 checker 与 hook 没有自身回归测试
    建议至少增加:runtime 注册与目录双向一致、真实 export、缺失/多余 namespace、Chrome 映射/重复目录、Monaco 各类 AST 结构及 parse/cycle、Git partial staging 与 A/M/R/D 的临时仓库测试。

建议先修复上述 false negative 和 staged-content 问题,再基于最新 main 重跑 check:i18nlint:ci 及新增定向测试。常规 CI 当前为绿;FOSSA License Compliance 的外部错误应与本 PR 的实现问题分开处理。

@cyfung1031 cyfung1031 added P1 🔥 重要但是不紧急的内容 and removed P0 🚑 需要紧急处理的内容 labels Jul 19, 2026
CodFrm 在 PR scriptscat#1606 review 中指出: pre-commit 的 check:i18n 校验的是工作区而非
Git 暂存快照(暂存坏版本、工作区改回好版本可绕过检查,删除操作也不触发);
check-i18n.mjs 从未与 src/locales/locales.ts 的实际注册(NS/resources/import)
交叉核对,index.ts 导出检查只是子串匹配可被注释字符串骗过;Monaco 自定义 AST
evaluator 对 spread、计算属性、循环别名等无法解析的结构会静默折叠为空/叶子键集
而非报错;Chrome _locales 与 Monaco 覆盖面缺失只是 warning。

本次改动:
- scripts/git-staged-snapshot.mjs: 用 `git checkout-index` 直接从索引物化暂存
  快照,.husky/pre-commit 改为对该快照跑 check:i18n(--diff-filter 补上 D)。
- scripts/check-i18n.mjs: 新增 locales.ts 双向一致性检查(NS 数组 vs 命名空间
  文件、import/resources vs 磁盘目录);index.ts 导出改用真实 AST 解析;Monaco
  AST evaluator 对 spread/计算属性/不支持的表达式/循环引用/语法错误一律报错
  (fail-closed);Chrome _locales 与 Monaco 覆盖面缺失由 warning 升级为 error。
- 新增 scripts/check-i18n.test.mjs、scripts/git-staged-snapshot.test.mjs 覆盖
  上述所有可复现场景。
- docs/translation.md 同步更新为当前 fail-closed 行为。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@cyfung1031

Copy link
Copy Markdown
Collaborator Author

@CodFrm 复核意见属实,已在 ffae9f1 修复,逐条对应如下:

  1. pre-commit 校验工作区而非暂存快照 — 新增 scripts/git-staged-snapshot.mjs,用 git checkout-index -a --prefix=<tmp>/ 直接从索引物化暂存快照(不碰工作区/索引本身),.husky/pre-commit 改为对该快照跑 check:i18n -- --root=<tmp>--diff-filter 补上 D,删除翻译文件也会触发检查。用一个临时 Git 仓库复现了「暂存坏版本、工作区改回好版本」的绕过场景,修复前旧逻辑确实放行(exit 0),修复后新逻辑正确拦截(exit 1),见 scripts/git-staged-snapshot.test.mjs

  2. locale 完整性未对照 locales.ts 注册源check-i18n.mjs 新增一个检查段:解析 locales.tsimport * as X from "./<dir>"resources 里的 spread 以及顶层 NS 数组,与磁盘上的 locale 目录、en-US 下的命名空间文件双向核对。未注册目录、resources 遗漏、NS 多余/缺失命名空间现在都会报错。index.ts 的导出检查也从子串匹配换成了真实的 ts.isExportDeclaration 解析,注释掉的假 export 字符串不再能骗过检查。

  3. Chrome/Monaco 检查 fail-open — Monaco 的自定义 AST evaluator 现在遇到 spread、非字符串字面量的计算属性键、以及其它无法静态解析的表达式都会直接报错,而不是静默当作空/叶子键处理;标识符解析加了 visiting 集合做循环检测,循环 alias 会报 "Circular import" 而不是栈溢出;TS 文件解析前先用 ts.transpileModule 做语法诊断,语法错误会被拦下而不是被 ts.createSourceFile 静默恢复后误判。_locales 目录缺失、editorLangs 条目缺失,都从 warning 升级为 error。

  4. 策略落后于 main — 当前分支已合并 main(含 🌐 Cover chrome.i18n messages.json and Monaco editor langs for pt-BR / tr-TR #1605),Chrome/Monaco 覆盖面已对全部 9 个 locale 补齐,升级为 error 不会破坏现有通过状态(已验证)。docs/translation.md 同步更新,不再以单文件 langs.ts 为主要描述,并写明了新的 fail-closed 行为与「pre-commit 校验的是暂存区」这一点。

  5. 无回归测试 — 新增 scripts/check-i18n.test.mjs(12 个用例,覆盖上述每一种可复现的 fail-open 路径:未注册 locale、stale/缺失 NS 命名空间、注释掉的假 export、语法错误、Chrome 目录缺失、Monaco 条目缺失、spread、计算属性键、循环 alias)和 scripts/git-staged-snapshot.test.mjs(2 个用例,用真实临时 Git 仓库验证暂存 vs 工作区语义)。

验证:pnpm exec vitest run scripts/check-i18n.test.mjs scripts/git-staged-snapshot.test.mjs 全绿;node ./scripts/check-i18n.mjs 对当前仓库仍然通过;pnpm run typecheck / prettier --check / eslint 均通过(pre-commit hook 本身跑过一遍)。

麻烦再帮忙复核一下,如果还有遗漏的场景我再补。

check-i18n.mjs 与 git-staged-snapshot.mjs 都用
`import.meta.url === \`file://${process.argv[1]}\`` 判断是否被直接执行。
这个比较在两种情况下永不成立,main() / CLI 分支不执行,进程零输出 exit 0:

- `import.meta.url` 是 percent-encoded 的,而 argv[1] 是原始路径:仓库路径含
  空格或非 ASCII 字符(如 ~/我的项目/scriptcat)时两者不等;
- `import.meta.url` 会解析软链而 argv[1] 不会:macOS 的 /tmp、/var 即是软链。

后果是本 PR 建立的机制整体失效且毫无迹象——`pnpm lint` / `lint:ci` 里这一格
永远绿,pre-commit 中两个脚本以 `&&` 串联、一起放行坏提交。实测在
~/…/中文目录/ 下删掉 zh-CN/common.json 的一个 key 后 `git commit` 直接落库,
而同样的破坏在 ASCII 路径下会被正确拦截。这恰好是脚本自身
"fail-closed by design" 承诺要杜绝的失败模式。

改为两边都归一化成真实文件路径再比对,同时覆盖编码与软链两种情形。

补充 CLI 入口回归测试:原有用例都直接调 runCheck(),绕过了 CLI 入口,
覆盖不到"脚本到底有没有被执行"这一层。新用例把脚本复制到含空格 /
非 ASCII 字符的目录下真实 spawn,断言干净树输出通过信息、缺 key 时
exit 1。因需拉起 node 子进程(实测 300ms+),显式放宽这些用例的超时,
不套用 vitest.config.ts 给单元测试定的 340ms 预算。
@CodFrm

CodFrm commented Jul 20, 2026

Copy link
Copy Markdown
Member

评审时发现一个 fail-open,已直接推到本分支(e34fd967),说明如下。

问题

两个脚本都用这个判断是否被直接执行:

if (import.meta.url === `file://${process.argv[1]}`) {

这个比较在两种情况下永不成立,main() / CLI 分支不执行,进程零输出 exit 0

  1. percent-encodingimport.meta.url 是编码过的,而 argv[1] 是原始路径。仓库路径含空格或非 ASCII 字符时两者不等 —— 对中文项目来说 ~/我的项目/scriptcat~/文档/… 相当常见。
  2. 软链解析import.meta.url 会解析软链,argv[1] 不会。macOS 的 /tmp/var 都是软链(这条是写回归测试时才暴露出来的,第一版只修了编码仍然挂)。

后果是本 PR 建立的机制整体失效且毫无迹象lint / lint:ci 里这一格永远绿,pre-commit 里两个脚本以 && 串联、一起放行坏提交。

复现(修复前)

两个真实 clone,同一个破坏(删掉 zh-CN/common.json 一个 key),走真实 husky hook:

ASCII 路径 → ❌ missing 1 key(s): user_guide,提交被拦截 ✅
CJK  路径 → 提交直接落库 ❌,检查脚本零输出

# CI 那一半同样中招
$ pnpm run check:i18n
> node ./scripts/check-i18n.mjs
CHECK_EXIT=0        ← 连 ✅ 那行都没打印

修复

两边都归一化成真实文件路径再比对,同时覆盖编码与软链:

if (process.argv[1] && fileURLToPath(import.meta.url) === realpathSync(process.argv[1])) {

修复后同一个 CJK 仓库:坏改动被拦截(HEAD 不动),合法改动正常放行(✅ i18n check passed)。

补充的测试

原有 23 个用例都直接调 runCheck(),绕过 CLI 入口,覆盖不到「脚本到底有没有被执行」这一层 —— 这正是漏掉这个 bug 的原因。新增 6 个用例把脚本复制到含空格 / 非 ASCII 字符的目录下真实 spawn,断言干净树输出通过信息、缺 key 时 exit 1。已确认这 6 个用例在修复前全部失败、修复后全部通过。

因为要拉起 node 子进程(实测 300ms+,而 vitest.config.ts 给单元测试的预算是 340ms),给这些用例显式放宽了超时,否则 CI 满载下必然偶发超时。

其余部分

跑下来质量很高,没有别的阻塞项:check:i18n 在当前分支零 warning 通过(pt-BR/tr-TR 的历史缺口已被 #1605 补齐),全量 3390 个测试通过,prettier / tsc / eslint 均干净。用 TS 编译器 API 而非字符串匹配、parseTsFile 先跑 diagnostics、findChromeDirName 扫描真实目录而非硬编码映射表 —— 这几处考虑得都很细。

另:正文「已知限制」一节还停留在早期版本(说第 3、5 项只给 warning,实际现在是硬失败),也没提到 check 0(locales.ts 注册一致性)和 git-staged-snapshot.mjs,方便的话同步一下。

@CodFrm
CodFrm merged commit 46ebef8 into scriptscat:main Jul 20, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

P1 🔥 重要但是不紧急的内容

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants