frontend/scripts/check-i18n.mjs
Rama main · réplica de lectura del repositorio original.
// frontend/scripts/check-i18n.mjs
//
// Enforces strict 1:1 mapping between source code and canonical locale files:
// 1. Missing keys — t("key") in code but key not in locale files
// 2. Unused keys — key in locale files but not referenced in code
// 3. Consistency — all locale files must have the exact same key set
// 4. Placeholders — i18next uses {{name}}, and every locale uses the
// same placeholders as en-US
// 5. Banned wording — retired user-facing terms must not reappear in
// locale values (keys and code identifiers are
// exempt — only rendered strings are checked)
// 6. Section layout — SQL Review messages live only in sql-review/
// 7. Linked wording — strings that must quote, or must not reuse, another
//
// Usage: node frontend/scripts/check-i18n.mjs
import { readFileSync, readdirSync } from "fs";
import { resolve, dirname } from "path";
import { fileURLToPath } from "url";
// Keys whose usage the checker cannot trace statically — exempt from the
// unused-key check. Matched with String.startsWith, so an entry ending in
// "." is a prefix family (matches any key under it) and an entry without a
// trailing "." matches itself (and, technically, anything that starts with
// it — see `instance.selected-n-instances` for that convention).
const DYNAMIC_PREFIXES = [
"dynamic.subscription.features.",
"dynamic.subscription.purchase.features.",
"dynamic.settings.sensitive-data.semantic-types.template.",
"sql-review.category.",
"sql-review.engine.",
// Finding code titles, rendered via t(findingCodeTitleKey(code)) over the
// codes in components/sql-review/standardRuleCatalog.json.
"sql-review.finding-code.",
"sql-review.level.",
"sql-review.rule.",
"sql-review.template.",
"sql-editor.query-plan-viewer.",
"subscription.plan.",
"subscription.purchase.cancel-dialog.reason.",
"settings.sensitive-data.algorithms.",
// SQL Editor theme anchor labels, rendered via t(`…anchor.${key}`) over a
// key list in ThemeAnchorEditor.tsx, not as literal t("…") calls.
"settings.general.workspace.sql-editor-theme.anchor.",
"instance.selected-n-instances",
"settings.sidebar.",
// Stored as messageKey string literals and translated via t(messageKey) at
// render, not as literal t("…") calls. See
// frontend/src/routes/auth/OAuthCallbackPage.tsx.
"auth.oauth-callback.",
// SSO redirect failures, thrown as SsoConfigError i18nKey string literals in
// utils/sso.ts and translated via t(error.i18nKey) by whichever page renders
// the notification (SigninPage, IdpTestConnection).
"auth.sso.",
// Returned from getReviewBadge as labelKey string literals, not invoked
// via t("…") in source. See frontend/src/routes/project/utils/reviewBadge.ts.
"common.bypassed",
"common.closed",
"common.rejected",
"common.skipped",
"common.under-review",
"issue.table.approved",
// Role-grant expiration presets, rendered via t(preset.labelKey) in
// MembersPage.tsx (EXPIRATION_PRESETS), not as literal t("…") calls.
"project.members.expiration-presets.",
// Workspace setup guide step definitions store label and description keys;
// the presentation translates them via t(step.definition.<key>).
"workspace-setup-guide.descriptions.",
"workspace-setup-guide.generic.",
"workspace-setup-guide.scenarios.",
"workspace-setup-guide.steps.",
// Landing starter IDs are ranked in a pure helper and translated by the
// shared selector using the selected ID.
"agent.landing-starters.",
// Handoff labels are selected by the validated intent kind.
"agent.handoff.",
// Attention result labels use the Issue relation returned by a bounded
// read-only lookup to choose the corresponding translation key.
"agent.project-attention.issue-",
"agent.workspace-attention.issue-",
// MCP mode cards and the broken-ceiling banner, rendered via
// t(`settings.mcp.policy.mode.${key}.title`) and a ternary over two key
// strings in MCPAccessPolicySection.tsx, not as literal t("…") calls.
"settings.mcp.policy.mode.",
"settings.mcp.policy.unreadable.description",
"settings.mcp.policy.unreadable.title",
"settings.mcp.policy.unserved.description",
"settings.mcp.policy.unserved.title",
// The capability ladder's template-driven families only: row titles and
// detail lines keyed by row id, the per-mode summary, and the tier tag and
// divider keyed by tier. Listed per family rather than as the
// `settings.mcp.ladder.` subtree, which would also exempt every sibling key
// the ladder names literally — the floor line among them.
"settings.mcp.ladder.row.",
"settings.mcp.ladder.stops.",
"settings.mcp.ladder.summary.",
"settings.mcp.ladder.tier.",
// Built as `…undisclosed.${reason}.<suffix>` over a reason the consent page
// derives: title, description and line for both, plus retry for the one that
// offers it. Listed per reason rather than as one subtree, which would also
// exempt the two sibling keys named literally.
"oauth2.consent.mcp.undisclosed.undisclosable.",
"oauth2.consent.mcp.undisclosed.unknown.",
// Passed through the `translate` parameter of stringifyTaskStatus /
// stringifyTaskRunStatus (src/utils/v1/issue/rollout.ts), which the t("…")
// scan does not match; failed/skipped also have literal callers.
"task.status.available",
"task.status.canceled",
"task.status.done",
"task.status.not-started",
"task.status.pending",
"task.status.running",
];
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(__dirname, "..");
const SOURCE_DIR = resolve(ROOT, "src");
const LOCALES_DIR = resolve(ROOT, "src/locales");
const SOURCE_SCAN_DIRS = [SOURCE_DIR];
const LOCALES = ["en-US", "zh-CN", "es-ES", "ja-JP", "vi-VN"];
let errors = 0;
function error(msg) {
console.error(msg);
errors++;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function findFiles(dir, ext) {
const results = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = resolve(dir, entry.name);
if (entry.isDirectory() && !["locales", "proto-es"].includes(entry.name)) {
results.push(...findFiles(full, ext));
} else if (entry.isFile() && entry.name.endsWith(ext)) {
results.push(full);
}
}
return results;
}
function flatten(obj, prefix = "") {
const result = {};
for (const [k, v] of Object.entries(obj)) {
const key = prefix ? `${prefix}.${k}` : k;
if (typeof v === "object" && v !== null && !Array.isArray(v)) {
Object.assign(result, flatten(v, key));
} else {
result[key] = v;
}
}
return result;
}
function mergeMessages(base, override) {
const result = { ...base };
for (const [key, value] of Object.entries(override)) {
const existing = result[key];
if (
existing &&
typeof existing === "object" &&
!Array.isArray(existing) &&
value &&
typeof value === "object" &&
!Array.isArray(value)
) {
result[key] = mergeMessages(existing, value);
} else {
result[key] = value;
}
}
return result;
}
// i18next pluralization: _one/_other suffixes map to the base key in code
function baseKey(key) {
return key.replace(/_(one|other|zero|few|many)$/, "");
}
function isDynamic(key) {
return DYNAMIC_PREFIXES.some((p) => key.startsWith(p));
}
// ---------------------------------------------------------------------------
// Collect translation keys from source files
// ---------------------------------------------------------------------------
function collectSourceKeys() {
const files = SOURCE_SCAN_DIRS.flatMap((dir) => [
...findFiles(dir, ".tsx"),
...findFiles(dir, ".ts"),
]);
const keys = new Set();
// Only match single/double quoted strings — template literals with ${} are dynamic keys.
const re = /\bt\(\s*["']([^"']+)["']/g;
// <Trans i18nKey="..."> — react-i18next component-interpolation pattern
const transRe = /\bi18nKey\s*=\s*["']([^"']+)["']/g;
for (const file of files) {
const src = readFileSync(file, "utf-8");
re.lastIndex = 0;
let m;
while ((m = re.exec(src))) {
keys.add(m[1]);
}
transRe.lastIndex = 0;
while ((m = transRe.exec(src))) {
keys.add(m[1]);
}
}
return keys;
}
// ---------------------------------------------------------------------------
// Collect keys from a locale (main + generated/dynamic sections)
// ---------------------------------------------------------------------------
function loadLocaleKeys(locale) {
const main = JSON.parse(
readFileSync(resolve(LOCALES_DIR, `${locale}.json`), "utf-8")
);
const dynamic = JSON.parse(
readFileSync(resolve(LOCALES_DIR, `dynamic/${locale}.json`), "utf-8")
);
const sqlReview = JSON.parse(
readFileSync(resolve(LOCALES_DIR, `sql-review/${locale}.json`), "utf-8")
);
const subscription = JSON.parse(
readFileSync(resolve(LOCALES_DIR, `subscription/${locale}.json`), "utf-8")
);
const sections = mergeMessages(
{
"sql-review": sqlReview,
subscription,
},
main
);
return new Set(
Object.keys(
flatten({
...sections,
dynamic,
})
)
);
}
// ---------------------------------------------------------------------------
// Check 1: Missing keys (in code but not in locale)
// ---------------------------------------------------------------------------
const sourceKeys = collectSourceKeys();
const enUSKeys = loadLocaleKeys("en-US");
// Build a set of "effective" locale keys: includes base keys for pluralized entries
const effectiveLocaleKeys = new Set(enUSKeys);
for (const key of enUSKeys) {
effectiveLocaleKeys.add(baseKey(key));
}
const missing = [];
for (const key of sourceKeys) {
if (effectiveLocaleKeys.has(key)) continue;
if (isDynamic(key)) continue;
missing.push(key);
}
if (missing.length > 0) {
console.error(`Missing keys (${missing.length}) — used in code but not in locale files:\n`);
for (const key of missing.sort()) {
error(` - ${key}`);
}
console.error();
}
// ---------------------------------------------------------------------------
// Check 2: Unused keys (in locale but not in code)
// ---------------------------------------------------------------------------
const unused = [];
for (const key of enUSKeys) {
const base = baseKey(key);
if (sourceKeys.has(key) || sourceKeys.has(base)) continue;
if (isDynamic(key)) continue;
unused.push(key);
}
if (unused.length > 0) {
console.error(`Unused keys (${unused.length}) — in locale files but not in code:\n`);
for (const key of unused.sort()) {
error(` - ${key}`);
}
console.error(
"\nRemove from frontend/src/locales/ or add to DYNAMIC_PREFIXES if referenced indirectly (helper return, template literal).\n"
);
}
// ---------------------------------------------------------------------------
// Check 3: Cross-locale consistency (all locales must match en-US key set)
// ---------------------------------------------------------------------------
for (const locale of LOCALES) {
if (locale === "en-US") continue;
const localeKeys = loadLocaleKeys(locale);
const missingInLocale = [...enUSKeys].filter((k) => !localeKeys.has(k));
const extraInLocale = [...localeKeys].filter((k) => !enUSKeys.has(k));
if (missingInLocale.length > 0) {
console.error(`${locale}: missing ${missingInLocale.length} key(s) (present in en-US):\n`);
for (const key of missingInLocale.sort()) {
error(` - ${key}`);
}
console.error();
}
if (extraInLocale.length > 0) {
console.error(`${locale}: extra ${extraInLocale.length} key(s) (not in en-US):\n`);
for (const key of extraInLocale.sort()) {
error(` + ${key}`);
}
console.error();
}
}
// ---------------------------------------------------------------------------
// Check 4: placeholders
// ---------------------------------------------------------------------------
// i18next interpolates {{name}}; a bare {name} renders literally. A locale
// whose placeholders differ from en-US renders a raw placeholder or drops the
// interpolated value.
const SINGLE_BRACE_RE = /(?<!\{)\{([a-zA-Z_][a-zA-Z0-9_]*)\}(?!\})/g;
const PLACEHOLDER_RE = /\{\{\s*([\w.-]+)(?:\s*,[^}]*)?\s*\}\}/g;
function findSingleBracePlaceholders(obj, path = "") {
const issues = [];
if (obj && typeof obj === "object" && !Array.isArray(obj)) {
for (const [k, v] of Object.entries(obj)) {
issues.push(...findSingleBracePlaceholders(v, path ? `${path}.${k}` : k));
}
} else if (typeof obj === "string") {
SINGLE_BRACE_RE.lastIndex = 0;
const names = [];
let m;
while ((m = SINGLE_BRACE_RE.exec(obj))) names.push(m[1]);
if (names.length > 0) issues.push({ key: path, value: obj, names });
}
return issues;
}
function placeholders(value) {
return [...value.matchAll(PLACEHOLDER_RE)]
.map((m) => m[1])
.sort()
.join(", ");
}
// Parse a locale's four section files as [pathPrefix, parsedJson] pairs.
// Single source of truth for the locale file layout — used by checks 4–7.
function localeSections(locale) {
return [
["", `${locale}.json`],
["dynamic", `dynamic/${locale}.json`],
["sql-review", `sql-review/${locale}.json`],
["subscription", `subscription/${locale}.json`],
].map(([prefix, rel]) => [
prefix,
JSON.parse(readFileSync(resolve(LOCALES_DIR, rel), "utf-8")),
]);
}
for (const locale of LOCALES) {
const issues = localeSections(locale).flatMap(([prefix, data]) =>
findSingleBracePlaceholders(data, prefix)
);
if (issues.length > 0) {
console.error(
`${locale}: ${issues.length} string(s) with {name} placeholders — i18next needs {{name}}:\n`
);
for (const { key, value, names } of issues) {
error(` - ${key} → ${JSON.stringify(value)} (placeholders: ${names.join(", ")})`);
}
console.error();
}
}
// Main-file values win over section values, matching src/lib/i18n/index.ts.
function localeValues(locale) {
const [[, main], ...sections] = localeSections(locale);
return Object.assign(
{},
...sections.map(([prefix, data]) => flatten(data, prefix)),
flatten(main)
);
}
const enUSValues = localeValues("en-US");
for (const locale of LOCALES) {
if (locale === "en-US") continue;
const values = localeValues(locale);
const mismatched = Object.entries(enUSValues).filter(
([key, value]) =>
typeof values[key] === "string" &&
placeholders(values[key]) !== placeholders(String(value))
);
if (mismatched.length > 0) {
console.error(
`${locale}: ${mismatched.length} string(s) whose placeholders differ from en-US:\n`
);
for (const [key, value] of mismatched) {
error(` - ${key}: en-US {${placeholders(String(value))}}, ${locale} {${placeholders(values[key])}}`);
}
console.error();
}
}
// ---------------------------------------------------------------------------
// Check 5: banned wording in locale values
//
// BYT-9840 retired "just-in-time"/"JIT" as a product-UI term — the object
// users see is an "access grant"; the workflow term lives in the docs only.
// Code identifiers (FEATURE_JIT, allowJustInTimeAccess) intentionally keep
// the name, so this check walks locale VALUES only, never keys.
// ---------------------------------------------------------------------------
const BANNED_WORDING = [
{ re: /just[- ]?in[- ]?time/i, label: "just-in-time" },
{ re: /\bJIT\b/i, label: "JIT" },
// Each locale's retired native-script rendering of the term. Do not widen
// 即时 beyond the full retired phrase — 即时通讯 (IM) is a legitimate use.
{ re: /ジャストインタイム/, label: "ジャストインタイム" },
{ re: /即时访问/, label: "即时访问" },
{ re: /truy cập tức thời/i, label: "truy cập tức thời" },
];
function findBannedWording(obj, path = "") {
const hits = [];
if (obj && typeof obj === "object") {
for (const [k, v] of Object.entries(obj)) {
hits.push(...findBannedWording(v, path ? `${path}.${k}` : k));
}
} else if (typeof obj === "string") {
for (const { re, label } of BANNED_WORDING) {
if (re.test(obj)) hits.push({ key: path, value: obj, label });
}
}
return hits;
}
for (const locale of LOCALES) {
const hits = localeSections(locale).flatMap(([prefix, data]) =>
findBannedWording(data, prefix)
);
if (hits.length > 0) {
console.error(
`${locale}: ${hits.length} string(s) contain retired wording (use "access grant" terms instead — see BYT-9840):\n`
);
for (const { key, value, label } of hits) {
error(` - ${key} → ${JSON.stringify(value)} (banned: ${label})`);
}
console.error();
}
}
// ---------------------------------------------------------------------------
// Check 6: SQL Review messages live only in sql-review/
// ---------------------------------------------------------------------------
// The i18n module merges each main locale file over its sections, so a
// "sql-review" key in a main file would shadow the SQL Review section.
for (const locale of LOCALES) {
const [, main] = localeSections(locale)[0];
if (main["sql-review"] !== undefined) {
error(`${locale}.json: move "sql-review" messages to sql-review/${locale}.json`);
}
}
// ---------------------------------------------------------------------------
// Check 7: linked wording
// ---------------------------------------------------------------------------
// The unmask capability is a different mechanism from the Masking Exemptions
// feature, so its strings must not reuse the feature's name (zh once shipped
// 脱敏豁免 on the unmask button).
for (const locale of LOCALES) {
const values = localeValues(locale);
const featureName = values["project.masking-exemption.self"] ?? "";
for (const key of ["sql-editor.request-unmask", "sql-editor.grant-type-unmask"]) {
const value = values[key] ?? "";
if (featureName && value.toLowerCase().includes(featureName.toLowerCase())) {
error(
`${locale}: ${key} (${JSON.stringify(value)}) must not reuse the Masking Exemptions feature name (${JSON.stringify(featureName)})`
);
}
}
}
// ---------------------------------------------------------------------------
// Result
// ---------------------------------------------------------------------------
if (errors > 0) {
process.exit(1);
} else {
console.log("i18n: all checks passed.");
}