feat: zh-CN terminology glossary + consistency gate + normalization pass (#8038) (#8166)

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:
Diego Rodrigues de Sa e Souza
2026-07-22 15:54:26 -03:00
committed by GitHub
parent e392a39047
commit f23d7770ec
11 changed files with 1107 additions and 648 deletions

View File

@@ -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"

View File

@@ -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": "提供 IDgemini, copilot, cursor, ...",
"description": "为提供启动 OAuth 授权流程",
"provider": "提供 IDgemini, 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": "流式传输或导出请求日志",

View 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).

View File

@@ -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`)

View File

@@ -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",

View 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);
});
}

View 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"
]
}

View 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": []
}
}
}

View File

@@ -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

View 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, []);
});