From 4d344c23def33f28cf3673232167d20b34edc4f1 Mon Sep 17 00:00:00 2001 From: lijian19 Date: Wed, 9 Sep 2026 14:44:23 +0800 Subject: [PATCH] test(i18n): add dangling translation-key checker (Wave 2 QA) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend QA tooling (I18N_TEST_PLAN TEST_TOOLS §2): check-keys-dangling checks that every t()/useTranslation key reference in source is declared in the en/zh-CN dictionaries (no annotated wait dangling refs at runtime). - scripts/i18n/check-keys-dangling.{mjs,lib.mjs}: static scan of t('ns:key') references vs declared keys; report only (no rewrite). - tests/i18n/check-keys-dangling-lib.test.ts: 10 unit tests. Note: tests/e2e/ui/i18n/ E2E Playwright assertions still pending (Wave 2 gap). Verified: 29 QA tool tests pass; dangling check PASS on current platform scope. --- .../scripts/i18n/check-keys-dangling-lib.mjs | 200 ++++++++++++++++++ .../scripts/i18n/check-keys-dangling.mjs | 91 ++++++++ .../i18n/check-keys-dangling-lib.test.ts | 133 ++++++++++++ 3 files changed, 424 insertions(+) create mode 100644 ui/litellm-dashboard/scripts/i18n/check-keys-dangling-lib.mjs create mode 100644 ui/litellm-dashboard/scripts/i18n/check-keys-dangling.mjs create mode 100644 ui/litellm-dashboard/tests/i18n/check-keys-dangling-lib.test.ts diff --git a/ui/litellm-dashboard/scripts/i18n/check-keys-dangling-lib.mjs b/ui/litellm-dashboard/scripts/i18n/check-keys-dangling-lib.mjs new file mode 100644 index 00000000000..9a04c876757 --- /dev/null +++ b/ui/litellm-dashboard/scripts/i18n/check-keys-dangling-lib.mjs @@ -0,0 +1,200 @@ +// T-02b 悬空 key 校验库 —— 组件引用的 key ⊆ 已声明 key(TEST_TOOLS §2 组件引用校验)。 +// 使用 TypeScript 编译器 AST(typescript 属既有依赖,不新增 package.json 项), +// 只读扫描 `.ts/.tsx` 中的 `t("ns:key")` 引用,对照 en locale 资源检查是否存在。 +// 只报告,不改写,不自动生成 key。 +// +// 保守策略(降低误报): +// - 仅当文件“i18n-active”(import 了 react-i18next 的 useTranslation,或 @/i18n 的 getI18n) +// 才执行引用提取,避免把无关的本地 `t()` 函数当成翻译调用。 +// - 仅匹配首参为字符串字面量的 `t(...)` 与 `xxx.t(...)` 调用;非字面量(模板/变量)跳过并 +// 标注为“动态 key”,供人工复核。 +// - 无命名空间前缀的 key 视为 default namespace(defaultNS,来自 registry)。 +// - 复数分支:引用 `key` 时,声明侧可能出现 `key_one`/`key_other`/`key_0`,视为合法。 + +import { readFileSync, readdirSync, statSync } from "node:fs"; +import { extname, join } from "node:path"; +import ts from "typescript"; +import { collectNamespaces } from "./check-keys-lib.mjs"; + +const defaultNS = "common"; +const PLURAL_SUFFIXES = ["one", "other", "zero", "few", "many", "two"]; +const IGNORED_DIRS = new Set(["node_modules", ".next", "out", "dist", ".git", "coverage"]); + +// ---------- 文件收集 ---------- +export function collectTsFiles(root) { + const out = []; + const walk = (dir) => { + if (!statSync(dir, { throwIfNoEntry: false })?.isDirectory()) return; + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + const st = statSync(full); + if (st.isDirectory()) { + if (!IGNORED_DIRS.has(entry)) walk(full); + } else if (st.isFile() && (extname(full) === ".ts" || extname(full) === ".tsx")) { + // 排除类型声明文件 *.d.ts(extname 也是 .ts) + if (!full.endsWith(".d.ts")) out.push(full); + } + } + }; + const st = statSync(root, { throwIfNoEntry: false }); + if (!st) return out; + if (st.isFile()) { + if (extname(root) === ".ts" || extname(root) === ".tsx") out.push(root); + } else { + walk(root); + } + return out; +} + +function normalizeImportSpecifier(spec) { + return spec.replace(/^["']|["']$/g, ""); +} + +// 判断文件是否“i18n-active”:import useTranslation(@react-i18next) 或 import { getI18n } @/i18n。 +export function isI18nActive(source) { + const s = ts.createSourceFile("_", source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); + let active = false; + ts.forEachChild(s, (node) => { + if (active) return; + if (ts.isImportDeclaration(node) && node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) { + const mod = node.moduleSpecifier.text; + const isReactI18next = mod === "react-i18next"; + const isLocalI18n = mod === "@/i18n" || mod.endsWith("/i18n") || mod.endsWith("/i18n/index"); + if (!isReactI18next && !isLocalI18n) return; + const named = node.importClause?.namedBindings; + if (named && ts.isNamedImports(named)) { + for (const el of named.elements) { + const name = el.name.text; + if (name === "useTranslation" || name === "getI18n" || name === "I18nProvider") { + active = true; + return; + } + } + } + } + }); + return active; +} + +// 提取一个字符串字面量 k 的路径(支持点分隔),同时从声明字典查是否有 key 或复数后缀。 +function keyExists(declFlat, path) { + if (declFlat.has(path)) return true; + // 复数/序数后缀:key_one / key_other / key_0 …(key 可能带子路径,仅最后一个段加后缀) + for (const p of declFlat.keys()) { + if (p.startsWith(path + "_")) { + const suf = p.slice(path.length + 1); + if (PLURAL_SUFFIXES.includes(suf) || /^\d+$/.test(suf)) return true; + } + } + return false; +} + +// 提取文件中所有翻译 key 引用,返回 { qualified, unqualified, dynamic } 的数组。 +export function extractTranslationKeys(source, filePath) { + const s = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); + const refs = []; + let seenTIdentifier = false; + const inTrans = new Set(); + + // 记录 组件的 i18nKey 属性与 children 静态文本(策略性最小支持:仅 i18nKey)。 + function visit(node) { + // Trans i18nKey="ns:key" + if (ts.isJsxAttribute(node) && node.name.getText(s) === "i18nKey") { + const init = node.initializer; + if (init && ts.isStringLiteral(init)) { + refs.push({ kind: "trans", key: init.text, line: s.getLineAndCharacterOfPosition(init.getStart(s)).line + 1 }); + } + } + // t("...") 或 obj.t("...") + if (ts.isCallExpression(node)) { + const expr = node.expression; + let callee = null; + if (ts.isPropertyAccessExpression(expr)) callee = expr.name.text; + else if (ts.isIdentifier(expr)) callee = expr.text; + if (callee === "t" && node.arguments.length > 0 && ts.isStringLiteral(node.arguments[0])) { + seenTIdentifier = true; + const key = node.arguments[0].text; + const line = s.getLineAndCharacterOfPosition(node.getStart(s)).line + 1; + if (key.includes(":")) refs.push({ kind: "qualified", key, line }); + else refs.push({ kind: "unqualified", key, line }); + } else if ( + callee === "t" && + node.arguments.length > 0 && + !ts.isStringLiteral(node.arguments[0]) && + !ts.isTemplateLiteral(node.arguments[0]) + ) { + const line = s.getLineAndCharacterOfPosition(node.getStart(s)).line + 1; + refs.push({ kind: "dynamic", key: node.arguments[0].getText(s), line }); + } + } + } + + function walkNode(node) { + if (ts.isJsxSelfClosingElement(node) || ts.isJsxOpeningElement(node)) { + const tag = node.tagName.getText(s); + if (tag === "Trans") { + inTrans.add(node); + } + } + visit(node); + ts.forEachChild(node, walkNode); + } + walkNode(s); + + // 过滤:callee 为 t 的调用,需文件 i18n-active 且(存在 useTranslation t 或 getI18n/Trans)。 + // seenTIdentifier 标记存在 t(...) 调用;i18n-active 在外层已判断。 + return { refs, active: seenTIdentifier }; +} + +/** + * 主入口:扫描 root(目录/文件),对比 en locale 资源,返回悬空引用列表。 + * @param {string} root 目标目录或文件 + * @param {object} opts { enDir, localesDir } + */ +export function findDanglingReferences(root, { enDir, locale = "en" } = {}) { + const enNamespaceFiles = collectNamespaces(enDir); + const declFlatByNs = new Map(); + for (const [ns, obj] of enNamespaceFiles) { + const flat = new Set(); + // 复用 flattenDict,取所有叶子路径 + // (手动扁平化,保留分支节点,但悬空校验只看叶子是否可达) + (function walk(o, p) { + for (const [k, v] of Object.entries(o)) { + const path = p ? `${p}.${k}` : k; + if (v !== null && typeof v === "object" && !Array.isArray(v)) walk(v, path); + else flat.add(path); + } + })(obj, ""); + declFlatByNs.set(ns, flat); + } + const enNames = new Set(enNamespaceFiles.keys()); + + const files = collectTsFiles(root); + const dangling = []; + + for (const file of files) { + const source = readFileSync(file, "utf8"); + if (!isI18nActive(source)) continue; + const { refs } = extractTranslationKeys(source, file); + const nsOf = (key) => { + const idx = key.indexOf(":"); + if (idx === -1) return { ns: defaultNS, path: key }; + return { ns: key.slice(0, idx), path: key.slice(idx + 1) }; + }; + for (const ref of refs) { + if (ref.kind === "dynamic") { + dangling.push({ file, line: ref.line, kind: "dynamic", key: ref.key, ns: undefined }); + continue; + } + const { ns, path } = nsOf(ref.key); + if (!enNames.has(ns)) { + dangling.push({ file, line: ref.line, kind: "unknown-namespace", key: ref.key, ns }); + continue; + } + if (!keyExists(declFlatByNs.get(ns), path)) { + dangling.push({ file, line: ref.line, kind: "missing-key", key: ref.key, ns }); + } + } + } + return dangling; +} diff --git a/ui/litellm-dashboard/scripts/i18n/check-keys-dangling.mjs b/ui/litellm-dashboard/scripts/i18n/check-keys-dangling.mjs new file mode 100644 index 00000000000..b06ff6256e8 --- /dev/null +++ b/ui/litellm-dashboard/scripts/i18n/check-keys-dangling.mjs @@ -0,0 +1,91 @@ +#!/usr/bin/env node +// T-02b 悬空 key 校验 CLI —— 组件引用的 key ⊆ 已声明 key(只报告 + 可作为门禁退出码)。 +// 用量:node scripts/i18n/check-keys-dangling.mjs [dir|file ...] +// --en 指定 en locale 资源目录(默认 src/locales/en) +// 退出码:0 = 无缺 key;1 = 存在缺 key/未知 namespace/动态 key;2 = 用法/IO 错误。 +// +// 说明:动态 key(首参非字面量)无法静态判定,作为人工复核提示列出,不计入失败(除非 --fail-on-dynamic)。 + +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { dirname } from "node:path"; +import { findDanglingReferences } from "./check-keys-dangling-lib.mjs"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); +const DEFAULT_EN = resolve(root, "src/locales/en"); + +function parseArgs(argv) { + const args = { targets: [], en: DEFAULT_EN, failOnDynamic: false, help: false }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === "--en" && argv[i + 1]) args.en = resolve(argv[++i]); + else if (a === "--fail-on-dynamic") args.failOnDynamic = true; + else if (a === "--help" || a === "-h") args.help = true; + else if (!a.startsWith("-")) args.targets.push(a); + } + return args; +} + +if (process.argv.includes("--help") || process.argv.includes("-h")) { + console.log(`T-02b dangling translation-key checker + +Usage: + node scripts/i18n/check-keys-dangling.mjs [dir|file ...] [--en ] [--fail-on-dynamic] + +Scans .ts/.tsx sources for t("ns:key") / references and verifies each +referenced key exists in the en locale resource. Files must import useTranslation / +getI18n to be considered i18n-active. + +Exit code 0 when no missing keys; 1 when missing keys / unknown namespaces found +(or dynamic keys with --fail-on-dynamic); 2 on IO error.`); + process.exit(0); +} + +const { targets, en, failOnDynamic } = parseArgs(process.argv.slice(2)); +if (targets.length === 0) { + console.error("check-keys-dangling: no target dir/file given. Pass a path or --help."); + process.exit(2); +} + +let dangling; +try { + dangling = findDanglingReferences(targets[0], { enDir: en }); + // 支持多个 target:合并结果(简单起见,逐 target 运行追加) + for (const t of targets.slice(1)) { + dangling = dangling.concat(findDanglingReferences(t, { enDir: en })); + } +} catch (e) { + console.error(`check-keys-dangling: failed (${e.message})`); + process.exit(2); +} + +const byKind = (k) => dangling.filter((d) => d.kind === k); +const missingKey = byKind("missing-key"); +const unknownNs = byKind("unknown-namespace"); +const dynamics = byKind("dynamic"); + +if (dangling.length === 0) { + console.log(`check-keys-dangling: PASS — no dangling translation keys in ${targets.join(", ")}.`); + process.exit(0); +} + +if (missingKey.length > 0) { + console.log(`[MISSING-KEY] ${missingKey.length} 引用的 key 未在 en locale 声明:`); + for (const d of missingKey) console.log(` ${d.file}:${d.line} [${d.ns}] ${d.key}`); +} +if (unknownNs.length > 0) { + console.log(`[UNKNOWN-NS] ${unknownNs.length} 引用了未注册的 namespace:`); + for (const d of unknownNs) console.log(` ${d.file}:${d.line} ${d.key}`); +} +if (dynamics.length > 0) { + console.log(`[DYNAMIC] ${dynamics.length} 引用为动态 key(无法静态校验,需人工复核):`); + for (const d of dynamics) console.log(` ${d.file}:${d.line} ${d.key}`); +} + +const failed = missingKey.length > 0 || unknownNs.length > 0 || (failOnDynamic && dynamics.length > 0); +if (failed) { + console.error("check-keys-dangling: FAILED — dangling/key issues found."); + process.exit(1); +} +console.log("check-keys-dangling: WARN — 存在需人工复核的动态 key(未因动态 key 失败)。"); +process.exit(0); diff --git a/ui/litellm-dashboard/tests/i18n/check-keys-dangling-lib.test.ts b/ui/litellm-dashboard/tests/i18n/check-keys-dangling-lib.test.ts new file mode 100644 index 00000000000..e1654eb1620 --- /dev/null +++ b/ui/litellm-dashboard/tests/i18n/check-keys-dangling-lib.test.ts @@ -0,0 +1,133 @@ +import { describe, it, expect, afterAll } from "vitest"; +import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + collectTsFiles, + extractTranslationKeys, + findDanglingReferences, + isI18nActive, +} from "../../scripts/i18n/check-keys-dangling-lib.mjs"; + +function writeLocale(dir, name, obj) { + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, `${name}.json`), JSON.stringify(obj)); +} + +describe("collectTsFiles", () => { + it("collects only .ts / .tsx and skips ignored dirs", () => { + const tmp = mkdtempSync(join(tmpdir(), "dangling-collect-")); + mkdirSync(join(tmp, "node_modules"), { recursive: true }); + mkdirSync(join(tmp, "src"), { recursive: true }); + writeFileSync(join(tmp, "src", "a.tsx"), "x"); + writeFileSync(join(tmp, "src", "b.ts"), "y"); + writeFileSync(join(tmp, "src", "c.d.ts"), "z"); + writeFileSync(join(tmp, "node_modules", "ignored.tsx"), "i"); + const files = collectTsFiles(tmp) + .map((f) => f.split("/").slice(-2).join("/")) + .sort(); + expect(files).toEqual(["src/a.tsx", "src/b.ts"]); // 排除 .d.ts 与 node_modules,且 a 在 b 前按序 + rmSync(tmp, { recursive: true, force: true }); + }); +}); + +describe("isI18nActive", () => { + it("true when importing useTranslation from react-i18next", () => { + expect(isI18nActive(`import { useTranslation } from "react-i18next";`)).toBe(true); + }); + it("true when importing getI18n from @/i18n", () => { + expect(isI18nActive(`import { getI18n } from "@/i18n";`)).toBe(true); + }); + it("false when no i18n import", () => { + expect(isI18nActive(`import { useState } from "react"; function f(){ const t=(x)=>x; return t("hi"); }`)).toBe( + false, + ); + }); +}); + +describe("extractTranslationKeys", () => { + it("extracts qualified, unqualified, and refs", () => { + const src = ` + import { useTranslation } from "react-i18next"; + export function C(){ + const { t } = useTranslation(); + return (<> + {t("common:hello")} + {t("dashboard")} + fallback + ); + } + `; + const { refs } = extractTranslationKeys(src, "/f/C.tsx"); + const kinds = refs.map((r) => r.kind).sort(); + expect(kinds).toContain("qualified"); + expect(kinds).toContain("unqualified"); + expect(refs.filter((r) => r.kind === "trans").map((r) => r.key)).toEqual(["navigation:dashboard"]); + }); + + it("marks non-string first args as dynamic", () => { + const src = ` + import { useTranslation } from "react-i18next"; + export function C({k}){ const { t } = useTranslation(); return {t(k)}; } + `; + const { refs } = extractTranslationKeys(src, "/f/C.tsx"); + expect(refs.filter((r) => r.kind === "dynamic").length).toBe(1); + }); +}); + +describe("findDanglingReferences", () => { + const tmp = mkdtempSync(join(tmpdir(), "dangling-")); + const enDir = join(tmp, "en"); + const srcDir = join(tmp, "src"); + mkdirSync(srcDir, { recursive: true }); + + writeLocale(enDir, "common", { hello: "Hello", action: { save: "Save" }, count: "{{count}} items" }); + writeLocale(enDir, "navigation", { dashboard: "Dashboard" }); + + const writeSrc = (name, src) => writeFileSync(join(srcDir, name), src); + + afterAll(() => rmSync(tmp, { recursive: true, force: true })); + + it("flags a referenced key missing from the declared resource", () => { + writeSrc( + "Missing.tsx", + `import { useTranslation } from "react-i18next"; + export function C(){ const { t } = useTranslation(); return {t("common:hello")} {t("common:missing")}; }`, + ); + const d = findDanglingReferences(srcDir, { enDir }); + expect(d.some((x) => x.kind === "missing-key" && x.key === "common:missing")).toBe(true); + // 已有 key 不应误报 + expect(d.some((x) => x.key === "common:hello")).toBe(false); + }); + + it("resolves unqualified keys against the default namespace", () => { + writeSrc( + "Default.tsx", + `import { useTranslation } from "react-i18next"; + export function C(){ const { t } = useTranslation(); return {t("hello")}; }`, + ); + const d = findDanglingReferences(srcDir, { enDir }); + expect(d.some((x) => x.key === "hello")).toBe(false); + }); + + it("accepts plural suffixes (count keys)", () => { + writeSrc( + "Plural.tsx", + `import { useTranslation } from "react-i18next"; + export function C(){ const { t } = useTranslation(); return {t("common:count", { count }) }; }`, + ); + const d = findDanglingReferences(srcDir, { enDir }); + // common.count 声明为 "{{count}} items",无后缀也匹配(字面量 key 已存在) + expect(d.some((x) => x.key === "common:count")).toBe(false); + }); + + it("flags unknown namespaces", () => { + writeSrc( + "UnknownNs.tsx", + `import { useTranslation } from "react-i18next"; + export function C(){ const { t } = useTranslation(); return {t("doesNotExist:someth")}; }`, + ); + const d = findDanglingReferences(srcDir, { enDir }); + expect(d.some((x) => x.kind === "unknown-namespace")).toBe(true); + }); +});