mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
One-shot 提供商->提供者 normalization across src/i18n/messages/zh-CN.json (679 substitutions) and bin/cli/locales/zh-CN.json (54), mirroring #8024's zh-TW pass. Adds a versioned terminology glossary (scripts/i18n/glossary/zh-CN.json), a protected-names list (scripts/i18n/glossary/protected-terms.json), and a pure-function consistency check (scripts/i18n/check-glossary-consistency.mjs, npm run i18n:check-glossary) wired into CI as the i18n-glossary-zhcn job. zh-CN added to the visual-QA harness default locales. Complements the existing parity (check-ui-keys-coverage.mjs) and ICU (validate_translation.py) gates without replacing them.
This commit is contained in:
committed by
GitHub
parent
e392a39047
commit
f23d7770ec
21
.github/workflows/ci.yml
vendored
21
.github/workflows/ci.yml
vendored
@@ -459,6 +459,25 @@ jobs:
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- run: node scripts/i18n/check-ui-keys-coverage.mjs --threshold=65
|
||||
|
||||
# #8038: cheap single-locale glossary/protected-terms consistency gate —
|
||||
# complements i18n-ui-coverage (key parity) and the ICU `i18n` job below
|
||||
# without needing app-boot/Playwright infra. Same gating as i18n-ui-coverage.
|
||||
i18n-glossary-zhcn:
|
||||
name: i18n Glossary (zh-CN)
|
||||
runs-on: ubuntu-latest
|
||||
needs: changes
|
||||
if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.draft == false && (needs.changes.outputs.i18n == 'true' || needs.changes.outputs.code == 'true')) }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- run: node scripts/i18n/check-glossary-consistency.mjs --locale=zh-CN
|
||||
|
||||
# D4 (plano mestre testes+CI): a matrix de ~40 jobs de <1min por idioma saturava sozinha
|
||||
# a concorrência de jobs da conta (Free = 20 slots, compartilhados entre TODOS os repos)
|
||||
# e pagava spin-up + arredondamento de billing por idioma. Um único job itera os idiomas
|
||||
@@ -1172,6 +1191,7 @@ jobs:
|
||||
- lint
|
||||
- docs-sync-strict
|
||||
- i18n-ui-coverage
|
||||
- i18n-glossary-zhcn
|
||||
- i18n
|
||||
- pr-test-policy
|
||||
- build
|
||||
@@ -1217,6 +1237,7 @@ jobs:
|
||||
echo "| Lint | $(status '${{ needs.lint.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Docs Sync (Strict) | $(status '${{ needs.docs-sync-strict.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| i18n UI Coverage | $(status '${{ needs.i18n-ui-coverage.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| i18n Glossary (zh-CN) | $(status '${{ needs.i18n-glossary-zhcn.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| PR Test Policy | $(status '${{ needs.pr-test-policy.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| SonarQube | $(status '${{ needs.sonarqube.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
|
||||
@@ -29,16 +29,16 @@
|
||||
"setup": {
|
||||
"title": "OmniRoute 设置",
|
||||
"passwordPrompt": "管理员密码",
|
||||
"providerPrompt": "默认提供商(留空跳过)",
|
||||
"providerPrompt": "默认提供者(留空跳过)",
|
||||
"done": "设置完成",
|
||||
"passwordSet": "管理员密码已配置",
|
||||
"providerSet": "提供商已配置:{name}",
|
||||
"testingProvider": "正在测试提供商连接:{name}",
|
||||
"testPassed": "提供商测试通过",
|
||||
"testFailed": "提供商测试失败:{error}",
|
||||
"providerSet": "提供者已配置:{name}",
|
||||
"testingProvider": "正在测试提供者连接:{name}",
|
||||
"testPassed": "提供者测试通过",
|
||||
"testFailed": "提供者测试失败:{error}",
|
||||
"loginEnabled": "登录:已启用(密码已更新)",
|
||||
"loginDisabled": "登录:已禁用",
|
||||
"providerInfo": "提供商:{info}"
|
||||
"providerInfo": "提供者:{info}"
|
||||
},
|
||||
"doctor": {
|
||||
"title": "OmniRoute 诊断",
|
||||
@@ -52,29 +52,29 @@
|
||||
"warnings": "{count} 个警告 — 见上文。"
|
||||
},
|
||||
"providers": {
|
||||
"title": "提供商",
|
||||
"noProviders": "未配置提供商。运行:omniroute setup",
|
||||
"title": "提供者",
|
||||
"noProviders": "未配置提供者。运行:omniroute setup",
|
||||
"testing": "正在测试 {name}...",
|
||||
"available": "{count} 个提供商可用",
|
||||
"available": "{count} 个提供者可用",
|
||||
"connected": "已连接",
|
||||
"disconnected": "未连接",
|
||||
"validationFailed": "验证失败:{error}",
|
||||
"metrics": {
|
||||
"description": "显示提供商性能指标(延迟、成功率、成本)",
|
||||
"provider": "按提供商 ID 筛选",
|
||||
"description": "显示提供者性能指标(延迟、成功率、成本)",
|
||||
"provider": "按提供者 ID 筛选",
|
||||
"connection_id": "按连接 ID 筛选",
|
||||
"period": "时间范围:1h|6h|24h|7d|30d(默认:24h)",
|
||||
"metric": "关注特定指标字段",
|
||||
"sort": "按字段排序(降序)",
|
||||
"limit": "最大行数(默认:50)",
|
||||
"watch": "每 5 秒刷新(实时模式)",
|
||||
"compare": "逗号分隔的提供商 ID,并排比较"
|
||||
"compare": "逗号分隔的提供者 ID,并排比较"
|
||||
},
|
||||
"metric_single": {
|
||||
"description": "获取特定连接的单个指标值"
|
||||
},
|
||||
"rotate": {
|
||||
"description": "轮换提供商连接的上游 API 密钥",
|
||||
"description": "轮换提供者连接的上游 API 密钥",
|
||||
"newKeyOpt": "新 API 密钥值(避免使用:优先使用 --from-env)",
|
||||
"fromEnvOpt": "从环境变量 VAR 读取新密钥",
|
||||
"oauthOpt": "改为触发 OAuth 重新认证流程",
|
||||
@@ -89,18 +89,18 @@
|
||||
"testFailed": "轮换后测试失败:{error}"
|
||||
},
|
||||
"status": {
|
||||
"description": "显示所有提供商连接的密钥健康状态(期限、过期、冷却)",
|
||||
"providerOpt": "按提供商名称筛选",
|
||||
"header": "ID 提供商 名称 过期状态 测试状态 冷却至",
|
||||
"noData": "没有可用的提供商连接数据。",
|
||||
"description": "显示所有提供者连接的密钥健康状态(期限、过期、冷却)",
|
||||
"providerOpt": "按提供者名称筛选",
|
||||
"header": "ID 提供者 名称 过期状态 测试状态 冷却至",
|
||||
"noData": "没有可用的提供者连接数据。",
|
||||
"requiresServer": "providers status 需要 OmniRoute 服务器正在运行。"
|
||||
}
|
||||
},
|
||||
"keys": {
|
||||
"title": "API 密钥",
|
||||
"addDescription": "为提供商添加或更新 API 密钥",
|
||||
"addDescription": "为提供者添加或更新 API 密钥",
|
||||
"listDescription": "列出所有已配置的 API 密钥",
|
||||
"removeDescription": "移除提供商的 API 密钥",
|
||||
"removeDescription": "移除提供者的 API 密钥",
|
||||
"regenerateDescription": "重新生成 OmniRoute API 密钥",
|
||||
"revokeDescription": "撤销 OmniRoute API 密钥",
|
||||
"revealDescription": "显示未掩码的 API 密钥值",
|
||||
@@ -122,10 +122,10 @@
|
||||
"revoked": "密钥 {id} 已撤销。",
|
||||
"rotated": "密钥 {id} 已轮换。新密钥 ID:{newId}",
|
||||
"revealWarning": "⚠ 这将显示完整的未掩码密钥。请确保您的屏幕不被人看到。",
|
||||
"providerRequired": "需要提供商。",
|
||||
"providerRequired": "需要提供者。",
|
||||
"keyRequired": "需要 API 密钥。",
|
||||
"stdinEmpty": "未通过标准输入提供 API 密钥。",
|
||||
"unknownProvider": "未知提供商:{provider}",
|
||||
"unknownProvider": "未知提供者:{provider}",
|
||||
"policy": {
|
||||
"title": "密钥策略",
|
||||
"showDescription": "显示密钥的速率/成本策略",
|
||||
@@ -165,7 +165,7 @@
|
||||
"analytics": {
|
||||
"description": "显示汇总使用分析",
|
||||
"period": "时间范围:1d|7d|30d|90d|ytd|all(默认:30d)",
|
||||
"provider": "按提供商 ID 筛选"
|
||||
"provider": "按提供者 ID 筛选"
|
||||
},
|
||||
"budget": {
|
||||
"description": "管理成本预算",
|
||||
@@ -175,8 +175,8 @@
|
||||
}
|
||||
},
|
||||
"quota": {
|
||||
"description": "显示提供商配额使用情况",
|
||||
"provider": "按提供商 ID 筛选",
|
||||
"description": "显示提供者配额使用情况",
|
||||
"provider": "按提供者 ID 筛选",
|
||||
"check": "显示新请求是否有可用配额"
|
||||
},
|
||||
"logs": {
|
||||
@@ -201,7 +201,7 @@
|
||||
}
|
||||
},
|
||||
"cost": {
|
||||
"description": "按提供商、模型、组合或 API 密钥显示成本报告",
|
||||
"description": "按提供者、模型、组合或 API 密钥显示成本报告",
|
||||
"period": "时间范围:1d|7d|30d|90d|ytd|all(默认:30d)",
|
||||
"since": "开始日期(ISO 格式,例如 2026-01-01)— 覆盖 --period",
|
||||
"until": "结束日期(ISO 格式)",
|
||||
@@ -210,7 +210,7 @@
|
||||
"limit": "显示的最大行数(默认:100)"
|
||||
},
|
||||
"simulate": {
|
||||
"description": "模拟路由(空运行)— 显示将选择哪些提供商而不调用上游",
|
||||
"description": "模拟路由(空运行)— 显示将选择哪些提供者而不调用上游",
|
||||
"file": "从 JSON 文件加载完整请求体",
|
||||
"model": "模型 ID(默认:auto)",
|
||||
"combo": "强制指定组合名称",
|
||||
@@ -301,7 +301,7 @@
|
||||
"cost": "成本(24h):"
|
||||
},
|
||||
"quota": {
|
||||
"description": "显示提供商配额使用情况",
|
||||
"description": "显示提供者配额使用情况",
|
||||
"noServer": "服务器未运行。启动:omniroute serve",
|
||||
"noData": "没有可用的配额信息。"
|
||||
},
|
||||
@@ -315,12 +315,12 @@
|
||||
"description": "一键启动本地 Redis 容器(Podman 或 Docker),用于 OmniRoute 缓存和配额跟踪"
|
||||
},
|
||||
"test": {
|
||||
"description": "测试提供商连接",
|
||||
"description": "测试提供者连接",
|
||||
"noServer": "服务器未运行。启动:omniroute serve",
|
||||
"testing": "正在测试 {provider} / {model}...",
|
||||
"passed": "连接成功!",
|
||||
"failed": "连接失败:{error}",
|
||||
"allProvidersOpt": "测试所有已配置的提供商",
|
||||
"allProvidersOpt": "测试所有已配置的提供者",
|
||||
"latencyOpt": "显示延迟测量值(平均/最小/最大 毫秒)",
|
||||
"repeatOpt": "重复测试 N 次并汇总结果",
|
||||
"compareOpt": "逗号分隔的要比较的模型(例如 gpt-4o,claude-3-5-sonnet)",
|
||||
@@ -328,7 +328,7 @@
|
||||
"saved": "结果已保存至 {path}",
|
||||
"compareTitle": "模型比较",
|
||||
"compareMinTwo": "--compare 需要至少两个模型(逗号分隔)",
|
||||
"noProviders": "未配置提供商。添加:omniroute keys add"
|
||||
"noProviders": "未配置提供者。添加:omniroute keys add"
|
||||
},
|
||||
"update": {
|
||||
"checking": "正在检查更新...",
|
||||
@@ -509,7 +509,7 @@
|
||||
},
|
||||
"models": {
|
||||
"description": "列出可用模型(需要服务器)",
|
||||
"search": "按 ID、名称、提供商或描述筛选模型",
|
||||
"search": "按 ID、名称、提供者或描述筛选模型",
|
||||
"noServer": "服务器未运行。启动:omniroute serve",
|
||||
"noModels": "未找到模型。"
|
||||
},
|
||||
@@ -656,25 +656,25 @@
|
||||
}
|
||||
},
|
||||
"oauth": {
|
||||
"description": "管理 OAuth 提供商连接",
|
||||
"description": "管理 OAuth 提供者连接",
|
||||
"providers": {
|
||||
"description": "列出支持 OAuth 的提供商及其流程类型"
|
||||
"description": "列出支持 OAuth 的提供者及其流程类型"
|
||||
},
|
||||
"start": {
|
||||
"description": "为提供商启动 OAuth 授权流程",
|
||||
"provider": "提供商 ID(gemini, copilot, cursor, ...)",
|
||||
"description": "为提供者启动 OAuth 授权流程",
|
||||
"provider": "提供者 ID(gemini, copilot, cursor, ...)",
|
||||
"no_browser": "仅打印 URL — 不打开浏览器",
|
||||
"import_system": "从本地系统配置自动导入凭据",
|
||||
"social": "社交登录提供商(google|github)— kiro 需要",
|
||||
"social": "社交登录提供者(google|github)— kiro 需要",
|
||||
"timeout": "等待授权超时时间(毫秒,默认:300000)"
|
||||
},
|
||||
"status": {
|
||||
"description": "列出活动的 OAuth 连接",
|
||||
"provider": "按提供商 ID 筛选"
|
||||
"provider": "按提供者 ID 筛选"
|
||||
},
|
||||
"revoke": {
|
||||
"description": "撤销 OAuth 连接",
|
||||
"provider": "要撤销的提供商 ID",
|
||||
"provider": "要撤销的提供者 ID",
|
||||
"connection_id": "按 ID 撤销特定连接",
|
||||
"yes": "跳过确认提示"
|
||||
}
|
||||
@@ -884,11 +884,11 @@
|
||||
"description": "管理模型定价数据",
|
||||
"sync": {
|
||||
"description": "从上游同步价格",
|
||||
"provider": "按提供商筛选",
|
||||
"provider": "按提供者筛选",
|
||||
"force": "强制重新同步"
|
||||
},
|
||||
"list": {
|
||||
"provider": "按提供商筛选",
|
||||
"provider": "按提供者筛选",
|
||||
"model": "按模型筛选",
|
||||
"limit": "最大结果数"
|
||||
},
|
||||
@@ -907,25 +907,25 @@
|
||||
"resilience": {
|
||||
"description": "检查和管理弹性机制",
|
||||
"status": {
|
||||
"provider": "按提供商筛选"
|
||||
"provider": "按提供者筛选"
|
||||
},
|
||||
"breakers": {
|
||||
"provider": "按提供商筛选"
|
||||
"provider": "按提供者筛选"
|
||||
},
|
||||
"cooldowns": {
|
||||
"provider": "按提供商筛选",
|
||||
"provider": "按提供者筛选",
|
||||
"connectionId": "按连接 ID 筛选"
|
||||
},
|
||||
"lockouts": {
|
||||
"provider": "按提供商筛选",
|
||||
"provider": "按提供者筛选",
|
||||
"model": "按模型筛选"
|
||||
},
|
||||
"reset": {
|
||||
"description": "重置断路器/冷却状态",
|
||||
"provider": "要重置的提供商",
|
||||
"provider": "要重置的提供者",
|
||||
"connectionId": "要重置的连接 ID",
|
||||
"model": "要重置锁定状态的模型",
|
||||
"allCooldowns": "重置提供商的所有冷却",
|
||||
"allCooldowns": "重置提供者的所有冷却",
|
||||
"yes": "跳过确认"
|
||||
},
|
||||
"profile": {
|
||||
@@ -940,13 +940,13 @@
|
||||
}
|
||||
},
|
||||
"nodes": {
|
||||
"description": "管理提供商节点(端点)",
|
||||
"description": "管理提供者节点(端点)",
|
||||
"list": {
|
||||
"provider": "按提供商筛选",
|
||||
"provider": "按提供者筛选",
|
||||
"enabled": "仅显示已启用的节点"
|
||||
},
|
||||
"add": {
|
||||
"provider": "提供商名称",
|
||||
"provider": "提供者名称",
|
||||
"baseUrl": "节点的基础 URL",
|
||||
"name": "节点名称",
|
||||
"weight": "负载均衡权重",
|
||||
@@ -965,7 +965,7 @@
|
||||
},
|
||||
"validate": {
|
||||
"baseUrl": "要验证的 URL",
|
||||
"provider": "要验证的提供商"
|
||||
"provider": "要验证的提供者"
|
||||
},
|
||||
"test": {
|
||||
"description": "向节点发送测试请求"
|
||||
@@ -1091,7 +1091,7 @@
|
||||
"oneproxy": {
|
||||
"description": "管理 OneProxy 上游代理池",
|
||||
"stats": {
|
||||
"provider": "按提供商筛选",
|
||||
"provider": "按提供者筛选",
|
||||
"period": "时间范围(默认:24h)"
|
||||
},
|
||||
"fetch": {
|
||||
@@ -1101,14 +1101,14 @@
|
||||
},
|
||||
"rotate": {
|
||||
"description": "强制轮换代理",
|
||||
"provider": "要轮换的提供商",
|
||||
"provider": "要轮换的提供者",
|
||||
"connectionId": "要轮换的特定连接 ID"
|
||||
},
|
||||
"config": {
|
||||
"description": "显示或更新 OneProxy 配置",
|
||||
"enabled": "启用代理池(true|false)",
|
||||
"poolSize": "池大小",
|
||||
"providerSource": "代理提供商的 URL",
|
||||
"providerSource": "代理提供者的 URL",
|
||||
"rotationPolicy": "轮换策略:sticky|per-request|periodic"
|
||||
},
|
||||
"pool": {
|
||||
@@ -1201,7 +1201,7 @@
|
||||
"bash": "打印 bash 补全脚本",
|
||||
"fish": "打印 fish 补全脚本",
|
||||
"install": "为检测到的 Shell 全局安装补全脚本",
|
||||
"refresh": "刷新组合/提供商/模型缓存"
|
||||
"refresh": "刷新组合/提供者/模型缓存"
|
||||
},
|
||||
"logs": {
|
||||
"description": "流式传输或导出请求日志",
|
||||
|
||||
1
changelog.d/features/8038-zhcn-terminology-visual-qa.md
Normal file
1
changelog.d/features/8038-zhcn-terminology-visual-qa.md
Normal file
@@ -0,0 +1 @@
|
||||
- **feat(i18n):** zh-CN terminology glossary + consistency gate + 提供商→提供者 normalization — one-shot pass across `src/i18n/messages/zh-CN.json` (679 substitutions) and `bin/cli/locales/zh-CN.json` (54) mirroring #8024's zh-TW pass, a new versioned glossary (`scripts/i18n/glossary/zh-CN.json`) and protected-terms list (`scripts/i18n/glossary/protected-terms.json`), a pure-function consistency check (`scripts/i18n/check-glossary-consistency.mjs`, `npm run i18n:check-glossary`) wired into CI as `i18n-glossary-zhcn`, and zh-CN added to the visual-QA harness default locales (#8038).
|
||||
@@ -397,7 +397,7 @@ node scripts/i18n/generate-qa-checklist.mjs
|
||||
**Visual QA via Playwright** — takes screenshots of all dashboard routes in multiple locales and viewports, then evaluates page health.
|
||||
|
||||
```bash
|
||||
# Default: es, fr, de, ja, ar on localhost:20128
|
||||
# Default: es, fr, de, ja, ar, zh-CN on localhost:20128
|
||||
node scripts/i18n/run-visual-qa.mjs
|
||||
|
||||
# Custom base URL and locales
|
||||
@@ -446,6 +446,59 @@ Allowlist of keys that should remain identical to English source. Used by `valid
|
||||
|
||||
**To add a key:** Edit the `keys` array in `scripts/i18n/untranslatable-keys.json` and re-run validation.
|
||||
|
||||
## Terminology Glossary
|
||||
|
||||
Beyond key parity (`check-ui-keys-coverage.mjs`) and ICU validity
|
||||
(`validate_translation.py`), a maintained per-locale terminology layer
|
||||
catches semantic drift that those two gates cannot see — e.g. the same
|
||||
English concept ("provider") rendered with two different, equally-valid
|
||||
Chinese words (提供商 vs 提供者) across thousands of strings. #8038 added
|
||||
this layer starting with zh-CN.
|
||||
|
||||
### scripts/i18n/glossary/\<locale\>.json
|
||||
|
||||
Versioned glossary of recurring concepts (provider, connection, routing,
|
||||
fallback, quota, context window, reasoning, tool call, cache, circuit
|
||||
breaker…) for that locale. Each concept has a `canonical` translation and an
|
||||
optional `synonyms` list — any synonym found in the catalog is flagged as
|
||||
drift. A concept with an empty `synonyms` array is documented but not yet
|
||||
enforced (the catalog still has legitimate mixed usage for it; normalizing
|
||||
it is a follow-up, not this gate's job).
|
||||
|
||||
`scripts/i18n/glossary/zh-CN.json` seeds the ten concepts above; `provider`
|
||||
is the only one currently enforced, following the one-shot
|
||||
提供商→提供者 normalization pass (mirroring #8024's zh-TW pass) across
|
||||
`src/i18n/messages/zh-CN.json` and `bin/cli/locales/zh-CN.json`.
|
||||
|
||||
### scripts/i18n/glossary/protected-terms.json
|
||||
|
||||
Flat list of product/provider/model/protocol/CLI/env/identifier names
|
||||
(`OmniRoute`, `OAuth`, `MCP`, `A2A`, `DATA_DIR`, …) that must appear verbatim
|
||||
inside any translated value. This is a different granularity than
|
||||
`untranslatable-keys.json` (which excludes whole KEYS from the parity/ICU
|
||||
checks) — protected terms are checked wherever they show up inside any
|
||||
value, keyed by concept rather than by key path.
|
||||
|
||||
### check-glossary-consistency.mjs
|
||||
|
||||
```bash
|
||||
# zh-CN by default
|
||||
npm run i18n:check-glossary
|
||||
|
||||
# explicit locale, JSON report, or non-failing report mode
|
||||
node scripts/i18n/check-glossary-consistency.mjs --locale=zh-CN
|
||||
node scripts/i18n/check-glossary-consistency.mjs --locale=zh-CN --json
|
||||
node scripts/i18n/check-glossary-consistency.mjs --locale=zh-CN --report
|
||||
```
|
||||
|
||||
Exports a pure `checkGlossaryConsistency(localeMessages, glossary,
|
||||
protectedTerms)` function (mirroring `evaluateFileSizes` in
|
||||
`check-file-size.mjs`) returning `{ violations: [...] }` — a
|
||||
`glossary-synonym` violation for a non-canonical term, or a
|
||||
`protected-term-altered` violation for a mistranslated protected name.
|
||||
Wired into CI as the `i18n-glossary-zhcn` job (same gating as
|
||||
`i18n-ui-coverage`: skips draft PRs, runs when i18n or code changes).
|
||||
|
||||
## CI Integration
|
||||
|
||||
### GitHub Actions (`.github/workflows/ci.yml`)
|
||||
|
||||
@@ -136,6 +136,7 @@
|
||||
"i18n:sync-ui": "node scripts/i18n/sync-ui-keys.mjs",
|
||||
"i18n:sync-ui:dry": "node scripts/i18n/sync-ui-keys.mjs --dry-run",
|
||||
"i18n:check-ui-coverage": "node scripts/i18n/check-ui-keys-coverage.mjs",
|
||||
"i18n:check-glossary": "node scripts/i18n/check-glossary-consistency.mjs",
|
||||
"check:node-runtime": "node --import tsx scripts/check/check-supported-node-runtime.ts",
|
||||
"check:pack-artifact": "node --import tsx scripts/build/validate-pack-artifact.ts",
|
||||
"check:pack-boot": "node scripts/check/check-pack-boot.mjs",
|
||||
|
||||
199
scripts/i18n/check-glossary-consistency.mjs
Normal file
199
scripts/i18n/check-glossary-consistency.mjs
Normal file
@@ -0,0 +1,199 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* OmniRoute — zh-CN terminology glossary consistency gate.
|
||||
*
|
||||
* Complements the existing parity (check-ui-keys-coverage.mjs) and ICU
|
||||
* (validate_translation.py) checks with a native-quality layer:
|
||||
* - glossary-synonym: a value uses a non-canonical synonym for a concept
|
||||
* that has an actively-normalized canonical rendering
|
||||
* (scripts/i18n/glossary/<locale>.json).
|
||||
* - protected-term-altered: a value renders a protected product/provider/
|
||||
* protocol/CLI/env identifier (scripts/i18n/glossary/protected-terms.json)
|
||||
* using a known incorrect translation instead of leaving it verbatim.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/i18n/check-glossary-consistency.mjs # zh-CN, exit 1 on drift
|
||||
* node scripts/i18n/check-glossary-consistency.mjs --locale=zh-CN
|
||||
* node scripts/i18n/check-glossary-consistency.mjs --json
|
||||
* node scripts/i18n/check-glossary-consistency.mjs --report # print, always exit 0
|
||||
*/
|
||||
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = path.resolve(SCRIPT_DIR, "..", "..");
|
||||
const MESSAGES_DIR = path.join(ROOT, "src", "i18n", "messages");
|
||||
const GLOSSARY_DIR = path.join(SCRIPT_DIR, "glossary");
|
||||
const LOG_PREFIX = "[i18n-glossary]";
|
||||
|
||||
// Small, maintained map of known incorrect renderings for protected terms —
|
||||
// identifiers that must survive translation verbatim. NOT exhaustive by
|
||||
// design (a full back-translation model is out of scope for a static gate),
|
||||
// and deliberately conservative: an entry is only added once we've verified
|
||||
// it doesn't collide with a legitimate, unrelated use of the same Chinese
|
||||
// phrase elsewhere in the real catalog (e.g. a generic word for
|
||||
// "authorization" is NOT a safe proxy for "OAuth was translated instead of
|
||||
// left verbatim" — it fires on every unrelated auth string). Extend as new,
|
||||
// non-colliding mistranslations are discovered in native review.
|
||||
const KNOWN_MISTRANSLATIONS = {
|
||||
DATA_DIR: ["数据目录"],
|
||||
};
|
||||
|
||||
function logInfo(...parts) {
|
||||
console.log(LOG_PREFIX, ...parts);
|
||||
}
|
||||
|
||||
function isPlainObject(value) {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function collectLeaves(obj, prefix = []) {
|
||||
const leaves = [];
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
const next = [...prefix, key];
|
||||
if (isPlainObject(value)) {
|
||||
leaves.push(...collectLeaves(value, next));
|
||||
} else if (typeof value === "string") {
|
||||
leaves.push({ path: next.join("."), value });
|
||||
}
|
||||
}
|
||||
return leaves;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure consistency check — no I/O. Mirrors evaluateFileSizes' split in
|
||||
* check-file-size.mjs (pure fn + separate CLI wrapper).
|
||||
*
|
||||
* @param {object} localeMessages - parsed src/i18n/messages/<locale>.json
|
||||
* @param {object} glossary - parsed scripts/i18n/glossary/<locale>.json
|
||||
* @param {string[]} protectedTerms - flat list from protected-terms.json
|
||||
* @returns {{violations: Array<object>}}
|
||||
*/
|
||||
export function checkGlossaryConsistency(localeMessages, glossary, protectedTerms) {
|
||||
const violations = [];
|
||||
const leaves = collectLeaves(localeMessages || {});
|
||||
|
||||
const terms = glossary && glossary.terms ? glossary.terms : {};
|
||||
for (const [concept, def] of Object.entries(terms)) {
|
||||
const synonyms = Array.isArray(def.synonyms) ? def.synonyms : [];
|
||||
for (const synonym of synonyms) {
|
||||
if (!synonym) continue;
|
||||
for (const leaf of leaves) {
|
||||
if (leaf.value.includes(synonym)) {
|
||||
violations.push({
|
||||
type: "glossary-synonym",
|
||||
concept,
|
||||
path: leaf.path,
|
||||
found: synonym,
|
||||
canonical: def.canonical,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const protectedList = Array.isArray(protectedTerms) ? protectedTerms : [];
|
||||
for (const term of protectedList) {
|
||||
const badRenderings = KNOWN_MISTRANSLATIONS[term];
|
||||
if (!badRenderings || badRenderings.length === 0) continue;
|
||||
for (const bad of badRenderings) {
|
||||
for (const leaf of leaves) {
|
||||
if (leaf.value.includes(bad)) {
|
||||
violations.push({
|
||||
type: "protected-term-altered",
|
||||
term,
|
||||
path: leaf.path,
|
||||
found: bad,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { violations };
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const opts = { locale: "zh-CN", json: false, report: false };
|
||||
for (const arg of argv.slice(2)) {
|
||||
if (arg.startsWith("--locale=")) {
|
||||
opts.locale = arg.slice(9);
|
||||
} else if (arg === "--json") {
|
||||
opts.json = true;
|
||||
} else if (arg === "--report") {
|
||||
opts.report = true;
|
||||
} else if (arg === "--help" || arg === "-h") {
|
||||
console.log(
|
||||
[
|
||||
"Usage: node scripts/i18n/check-glossary-consistency.mjs [options]",
|
||||
"",
|
||||
" --locale=<code> Locale to check (default zh-CN)",
|
||||
" --json Emit JSON report to stdout",
|
||||
" --report Print violations, exit 0 regardless",
|
||||
].join("\n")
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
return opts;
|
||||
}
|
||||
|
||||
async function loadJson(filePath) {
|
||||
const raw = await fs.readFile(filePath, "utf8");
|
||||
return JSON.parse(raw);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const opts = parseArgs(process.argv);
|
||||
|
||||
const messagesPath = path.join(MESSAGES_DIR, `${opts.locale}.json`);
|
||||
const glossaryPath = path.join(GLOSSARY_DIR, `${opts.locale}.json`);
|
||||
const protectedPath = path.join(GLOSSARY_DIR, "protected-terms.json");
|
||||
|
||||
const [messages, glossary, protectedData] = await Promise.all([
|
||||
loadJson(messagesPath),
|
||||
loadJson(glossaryPath),
|
||||
loadJson(protectedPath),
|
||||
]);
|
||||
const protectedTerms = Array.isArray(protectedData.terms) ? protectedData.terms : [];
|
||||
|
||||
const { violations } = checkGlossaryConsistency(messages, glossary, protectedTerms);
|
||||
|
||||
if (opts.json) {
|
||||
process.stdout.write(
|
||||
JSON.stringify({ locale: opts.locale, ok: violations.length === 0, violations }, null, 2) +
|
||||
"\n"
|
||||
);
|
||||
if (violations.length && !opts.report) process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (violations.length === 0) {
|
||||
logInfo(`PASS — ${opts.locale} has no glossary/protected-term drift.`);
|
||||
return;
|
||||
}
|
||||
|
||||
logInfo(`FAIL — ${violations.length} violation(s) in ${opts.locale}.`);
|
||||
for (const v of violations.slice(0, 50)) {
|
||||
if (v.type === "glossary-synonym") {
|
||||
console.log(` - [${v.concept}] ${v.path}: found "${v.found}", canonical is "${v.canonical}"`);
|
||||
} else {
|
||||
console.log(` - [protected-term] ${v.path}: "${v.term}" rendered as "${v.found}"`);
|
||||
}
|
||||
}
|
||||
if (violations.length > 50) {
|
||||
console.log(` ... and ${violations.length - 50} more`);
|
||||
}
|
||||
if (!opts.report) process.exit(1);
|
||||
}
|
||||
|
||||
const isDirectRun = import.meta.url === pathToFileURL(process.argv[1]).href;
|
||||
if (isDirectRun) {
|
||||
main().catch((err) => {
|
||||
console.error(LOG_PREFIX, "ERROR", err?.stack || err?.message || String(err));
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
25
scripts/i18n/glossary/protected-terms.json
Normal file
25
scripts/i18n/glossary/protected-terms.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"description": "Product/provider/model/protocol/header/CLI/env/identifier names that must appear verbatim (untranslated) inside any zh-CN localized string that mentions them. Distinct from untranslatable-keys.json, which excludes whole KEYS from drift checks at key-granularity; this list is consumed by scripts/i18n/check-glossary-consistency.mjs to flag a VALUE that mentions the concept but altered/translated the protected term itself.",
|
||||
"terms": [
|
||||
"OmniRoute",
|
||||
"OAuth",
|
||||
"MCP",
|
||||
"A2A",
|
||||
"JWT",
|
||||
"SSE",
|
||||
"API",
|
||||
"SQLite",
|
||||
"GitHub",
|
||||
"DATA_DIR",
|
||||
"cf_clearance",
|
||||
"JSON",
|
||||
"URL",
|
||||
"HTTP",
|
||||
"HTTPS",
|
||||
"WebSocket",
|
||||
"CLI",
|
||||
"Docker",
|
||||
"Electron",
|
||||
"Playwright"
|
||||
]
|
||||
}
|
||||
47
scripts/i18n/glossary/zh-CN.json
Normal file
47
scripts/i18n/glossary/zh-CN.json
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"version": 1,
|
||||
"locale": "zh-CN",
|
||||
"description": "Canonical zh-CN terminology for recurring OmniRoute concepts. Consumed by scripts/i18n/check-glossary-consistency.mjs. Each concept lists the canonical translation plus any non-canonical synonym that is actively normalized (drift enforced by the consistency gate). Concepts whose `synonyms` array is empty are seeded for documentation only — the catalog already uses more than one legitimate rendering for them today, so enforcement is deferred to a follow-up normalization pass rather than gating this PR.",
|
||||
"terms": {
|
||||
"provider": {
|
||||
"canonical": "提供者",
|
||||
"synonyms": ["提供商"]
|
||||
},
|
||||
"connection": {
|
||||
"canonical": "连接",
|
||||
"synonyms": []
|
||||
},
|
||||
"routing": {
|
||||
"canonical": "路由",
|
||||
"synonyms": []
|
||||
},
|
||||
"fallback": {
|
||||
"canonical": "回退",
|
||||
"synonyms": []
|
||||
},
|
||||
"quota": {
|
||||
"canonical": "配额",
|
||||
"synonyms": []
|
||||
},
|
||||
"context window": {
|
||||
"canonical": "上下文窗口",
|
||||
"synonyms": []
|
||||
},
|
||||
"reasoning": {
|
||||
"canonical": "推理",
|
||||
"synonyms": []
|
||||
},
|
||||
"tool call": {
|
||||
"canonical": "工具调用",
|
||||
"synonyms": []
|
||||
},
|
||||
"cache": {
|
||||
"canonical": "缓存",
|
||||
"synonyms": []
|
||||
},
|
||||
"circuit breaker": {
|
||||
"canonical": "断路器",
|
||||
"synonyms": []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ const DATE = new Date().toISOString().slice(0, 10);
|
||||
const BASE_URL = process.env.QA_BASE_URL || "http://localhost:20128";
|
||||
const REPORT_SUFFIX = process.env.QA_REPORT_SUFFIX ? `-${process.env.QA_REPORT_SUFFIX}` : "";
|
||||
|
||||
const DEFAULT_LOCALES = ["es", "fr", "de", "ja", "ar"];
|
||||
const DEFAULT_LOCALES = ["es", "fr", "de", "ja", "ar", "zh-CN"];
|
||||
const RTL_LOCALES = new Set(["ar", "he"]);
|
||||
|
||||
const ROUTES = [
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
112
tests/unit/i18n-glossary-consistency-check.test.ts
Normal file
112
tests/unit/i18n-glossary-consistency-check.test.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert";
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { checkGlossaryConsistency } from "../../scripts/i18n/check-glossary-consistency.mjs";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = path.resolve(__dirname, "..", "..");
|
||||
|
||||
const glossary = {
|
||||
version: 1,
|
||||
locale: "zh-CN",
|
||||
terms: {
|
||||
provider: { canonical: "提供者", synonyms: ["提供商"] },
|
||||
},
|
||||
};
|
||||
|
||||
const protectedTerms = ["DATA_DIR"];
|
||||
|
||||
test("catalog mixing canonical and synonym for a concept is flagged", () => {
|
||||
const messages = {
|
||||
common: {
|
||||
provider: "提供者",
|
||||
unknownProvider: "未知提供商",
|
||||
},
|
||||
};
|
||||
const { violations } = checkGlossaryConsistency(messages, glossary, protectedTerms);
|
||||
assert.equal(violations.length, 1);
|
||||
assert.equal(violations[0].type, "glossary-synonym");
|
||||
assert.equal(violations[0].concept, "provider");
|
||||
assert.equal(violations[0].found, "提供商");
|
||||
assert.equal(violations[0].canonical, "提供者");
|
||||
});
|
||||
|
||||
test("clean catalog using only the canonical rendering has no violations", () => {
|
||||
const messages = {
|
||||
common: {
|
||||
provider: "提供者",
|
||||
providerHealth: "提供者健康状态",
|
||||
},
|
||||
};
|
||||
const { violations } = checkGlossaryConsistency(messages, glossary, protectedTerms);
|
||||
assert.deepEqual(violations, []);
|
||||
});
|
||||
|
||||
test("protected term altered inside a value is flagged", () => {
|
||||
const messages = {
|
||||
settings: {
|
||||
dataDirHint: "存储在 数据目录 中",
|
||||
},
|
||||
};
|
||||
const { violations } = checkGlossaryConsistency(messages, glossary, protectedTerms);
|
||||
assert.equal(violations.length, 1);
|
||||
assert.equal(violations[0].type, "protected-term-altered");
|
||||
assert.equal(violations[0].term, "DATA_DIR");
|
||||
assert.equal(violations[0].found, "数据目录");
|
||||
});
|
||||
|
||||
test("protected term left verbatim is not flagged", () => {
|
||||
const messages = {
|
||||
settings: {
|
||||
dataDirHint: "存储在 DATA_DIR 中",
|
||||
},
|
||||
};
|
||||
const { violations } = checkGlossaryConsistency(messages, glossary, protectedTerms);
|
||||
assert.deepEqual(violations, []);
|
||||
});
|
||||
|
||||
test("empty synonyms list for a concept never produces violations", () => {
|
||||
const permissiveGlossary = {
|
||||
version: 1,
|
||||
locale: "zh-CN",
|
||||
terms: {
|
||||
cache: { canonical: "缓存", synonyms: [] },
|
||||
},
|
||||
};
|
||||
const messages = { common: { cache: "高速缓存" } };
|
||||
const { violations } = checkGlossaryConsistency(messages, permissiveGlossary, protectedTerms);
|
||||
assert.deepEqual(violations, []);
|
||||
});
|
||||
|
||||
// Regression guard: the one-shot 提供商→提供者 normalization pass (#8038) must
|
||||
// not silently regress. Load the REAL zh-CN catalogs and assert the retired
|
||||
// synonym is gone.
|
||||
test("regression: src/i18n/messages/zh-CN.json no longer contains 提供商", () => {
|
||||
const raw = readFileSync(path.join(ROOT, "src/i18n/messages/zh-CN.json"), "utf8");
|
||||
assert.equal(raw.includes("提供商"), false);
|
||||
});
|
||||
|
||||
test("regression: bin/cli/locales/zh-CN.json no longer contains 提供商", () => {
|
||||
const raw = readFileSync(path.join(ROOT, "bin/cli/locales/zh-CN.json"), "utf8");
|
||||
assert.equal(raw.includes("提供商"), false);
|
||||
});
|
||||
|
||||
test("real zh-CN.json + real glossary + real protected terms pass the gate", () => {
|
||||
const realMessages = JSON.parse(
|
||||
readFileSync(path.join(ROOT, "src/i18n/messages/zh-CN.json"), "utf8")
|
||||
);
|
||||
const realGlossary = JSON.parse(
|
||||
readFileSync(path.join(ROOT, "scripts/i18n/glossary/zh-CN.json"), "utf8")
|
||||
);
|
||||
const realProtected = JSON.parse(
|
||||
readFileSync(path.join(ROOT, "scripts/i18n/glossary/protected-terms.json"), "utf8")
|
||||
);
|
||||
const { violations } = checkGlossaryConsistency(
|
||||
realMessages,
|
||||
realGlossary,
|
||||
realProtected.terms
|
||||
);
|
||||
assert.deepEqual(violations, []);
|
||||
});
|
||||
Reference in New Issue
Block a user