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:
lijian19 2026-09-09 14:44:23 +08:00
parent 97bbe3c9f8
commit 4d344c23de
3 changed files with 424 additions and 0 deletions

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

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

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