Ctrl K
ComunidadGuías…

frontend/scripts/check-i18n.mjs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// 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.");
}