mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-28 01:32:17 +00:00
test(i18n): add dangling translation-key checker (Wave 2 QA)
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.
This commit is contained in:
parent
97bbe3c9f8
commit
4d344c23de
3 changed files with 424 additions and 0 deletions
200
ui/litellm-dashboard/scripts/i18n/check-keys-dangling-lib.mjs
Normal file
200
ui/litellm-dashboard/scripts/i18n/check-keys-dangling-lib.mjs
Normal file
|
|
@ -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();
|
||||
|
||||
// 记录 <Trans> 组件的 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;
|
||||
}
|
||||
91
ui/litellm-dashboard/scripts/i18n/check-keys-dangling.mjs
Normal file
91
ui/litellm-dashboard/scripts/i18n/check-keys-dangling.mjs
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
#!/usr/bin/env node
|
||||
// T-02b 悬空 key 校验 CLI —— 组件引用的 key ⊆ 已声明 key(只报告 + 可作为门禁退出码)。
|
||||
// 用量:node scripts/i18n/check-keys-dangling.mjs [dir|file ...]
|
||||
// --en <dir> 指定 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 <enDir>] [--fail-on-dynamic]
|
||||
|
||||
Scans .ts/.tsx sources for t("ns:key") / <Trans i18nKey> 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);
|
||||
133
ui/litellm-dashboard/tests/i18n/check-keys-dangling-lib.test.ts
Normal file
133
ui/litellm-dashboard/tests/i18n/check-keys-dangling-lib.test.ts
Normal file
|
|
@ -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 <Trans i18nKey> refs", () => {
|
||||
const src = `
|
||||
import { useTranslation } from "react-i18next";
|
||||
export function C(){
|
||||
const { t } = useTranslation();
|
||||
return (<>
|
||||
<span>{t("common:hello")}</span>
|
||||
<span>{t("dashboard")}</span>
|
||||
<Trans i18nKey="navigation:dashboard">fallback</Trans>
|
||||
</>);
|
||||
}
|
||||
`;
|
||||
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 <span>{t(k)}</span>; }
|
||||
`;
|
||||
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 <span>{t("common:hello")} {t("common:missing")}</span>; }`,
|
||||
);
|
||||
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 <span>{t("hello")}</span>; }`,
|
||||
);
|
||||
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 <span>{t("common:count", { count }) }</span>; }`,
|
||||
);
|
||||
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 <span>{t("doesNotExist:someth")}</span>; }`,
|
||||
);
|
||||
const d = findDanglingReferences(srcDir, { enDir });
|
||||
expect(d.some((x) => x.kind === "unknown-namespace")).toBe(true);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue