Ctrl K
ComunidadGuías…

scripts/check-i18n-locale-parity.js

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

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env node
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========

/* global console, process */

/**
 * Validates the complete frontend locale contract against `en-us`:
 * - exact locale and resource-file coverage;
 * - valid JSON without duplicate object keys;
 * - identical leaf keys, non-empty string values, placeholders, rich-text tags,
 *   and boundary whitespace;
 * - every literal `t()`, `i18n.t()`, and `<Trans i18nKey="...">` key in `src` exists.
 *
 * Run from repo root: `node scripts/check-i18n-locale-parity.js`
 */

import fs from 'fs';
import path from 'path';
import ts from 'typescript';
import { fileURLToPath } from 'url';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const projectRoot = path.resolve(__dirname, '..');
const localesDir = path.join(projectRoot, 'src', 'i18n', 'locales');
const sourceDir = path.join(projectRoot, 'src');
const referenceLocale = 'en-us';

const expectedLocales = [
  'ar',
  'de',
  'en-us',
  'es',
  'fr',
  'it',
  'ja',
  'ko',
  'ru',
  'zh-Hans',
  'zh-Hant',
].sort();

const expectedResourceFiles = [
  'agents.json',
  'chat.json',
  'common.json',
  'connectors.json',
  'dashboard.json',
  'folder.json',
  'layout.json',
  'markdown.json',
  'setting.json',
  'triggers.json',
  'update.json',
  'workforce.json',
].sort();

const pluralCategorySuffixes = ['zero', 'one', 'two', 'few', 'many', 'other'];

const localeLanguageTags = {
  ar: 'ar',
  de: 'de',
  'en-us': 'en-US',
  es: 'es',
  fr: 'fr',
  it: 'it',
  ja: 'ja',
  ko: 'ko',
  ru: 'ru',
  'zh-Hans': 'zh-Hans',
  'zh-Hant': 'zh-Hant',
};

let failed = false;

function report(message) {
  console.error(message);
  failed = true;
}

function difference(left, right) {
  const rightSet = new Set(right);
  return left.filter((value) => !rightSet.has(value));
}

function arraysEqual(left, right) {
  return (
    left.length === right.length &&
    left.every((value, index) => value === right[index])
  );
}

function pluralCategoriesFor(locale) {
  return [
    ...new Intl.PluralRules(localeLanguageTags[locale]).resolvedOptions()
      .pluralCategories,
  ].sort();
}

function parsePluralLeaf(key) {
  const match = key.match(
    new RegExp(`^(.+)_(${pluralCategorySuffixes.join('|')})$`)
  );
  if (!match) return null;
  return { base: match[1], category: match[2] };
}

function collectPluralFamilies(leaves) {
  const families = new Map();
  for (const [key, value] of leaves) {
    const plural = parsePluralLeaf(key);
    if (!plural) continue;
    const family = families.get(plural.base) ?? new Map();
    family.set(plural.category, value);
    families.set(plural.base, family);
  }
  return families;
}

function pluralLeafKeys(families) {
  const keys = new Set();
  for (const [base, family] of families) {
    for (const category of family.keys()) {
      keys.add(`${base}_${category}`);
    }
  }
  return keys;
}

function flattenLeaves(value, prefix = '', out = new Map()) {
  if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
    for (const [key, child] of Object.entries(value)) {
      flattenLeaves(child, prefix ? `${prefix}.${key}` : key, out);
    }
    return out;
  }

  out.set(prefix, value);
  return out;
}

function extractPlaceholders(value) {
  return [...value.matchAll(/{{\s*([^},\s]+)(?:\s*,[^}]*)?}}/g)]
    .map((match) => match[1])
    .sort();
}

function extractRichTextTags(value) {
  return [...value.matchAll(/<\/?([A-Za-z0-9][\w-]*)\b[^>]*>/g)]
    .filter((match) => !match[0].endsWith('/>'))
    .map((match) => `${match[0].startsWith('</') ? '/' : ''}${match[1]}`)
    .sort();
}

function extractBoundaryWhitespace(value) {
  return {
    leading: value.match(/^\s*/)?.[0] ?? '',
    trailing: value.match(/\s*$/)?.[0] ?? '',
  };
}

function startsWithBoundaryPunctuation(value) {
  const first = value.trimStart().match(/^./u)?.[0] ?? '';
  return /^[\p{P}\p{S}]$/u.test(first);
}

function propertyName(property, sourceFile) {
  if (
    ts.isStringLiteral(property.name) ||
    ts.isNumericLiteral(property.name) ||
    ts.isIdentifier(property.name)
  ) {
    return property.name.text;
  }
  return property.name.getText(sourceFile);
}

function findDuplicateKeys(
  node,
  sourceFile,
  locale,
  relativeFile,
  prefix = ''
) {
  if (ts.isObjectLiteralExpression(node)) {
    const seen = new Map();
    for (const property of node.properties) {
      if (!ts.isPropertyAssignment(property)) continue;

      const name = propertyName(property, sourceFile);
      const dotted = prefix ? `${prefix}.${name}` : name;
      const first = seen.get(name);
      if (first) {
        const firstLine =
          sourceFile.getLineAndCharacterOfPosition(first.getStart(sourceFile))
            .line + 1;
        const duplicateLine =
          sourceFile.getLineAndCharacterOfPosition(
            property.getStart(sourceFile)
          ).line + 1;
        report(
          `Duplicate key [${locale}] ${relativeFile}: ${dotted} (lines ${firstLine}, ${duplicateLine})`
        );
      } else {
        seen.set(name, property);
      }

      findDuplicateKeys(
        property.initializer,
        sourceFile,
        locale,
        relativeFile,
        dotted
      );
    }
    return;
  }

  if (ts.isArrayLiteralExpression(node)) {
    node.elements.forEach((element, index) =>
      findDuplicateKeys(
        element,
        sourceFile,
        locale,
        relativeFile,
        `${prefix}[${index}]`
      )
    );
  }
}

function readResource(locale, relativeFile) {
  const filePath = path.join(localesDir, locale, relativeFile);
  let text;
  try {
    text = fs.readFileSync(filePath, 'utf8');
  } catch (error) {
    report(`Could not read [${locale}] ${relativeFile}: ${error.message}`);
    return null;
  }

  const sourceFile = ts.parseJsonText(filePath, text);
  for (const diagnostic of sourceFile.parseDiagnostics) {
    const location =
      diagnostic.start !== undefined
        ? sourceFile.getLineAndCharacterOfPosition(diagnostic.start)
        : null;
    const suffix = location
      ? `:${location.line + 1}:${location.character + 1}`
      : '';
    report(
      `Invalid JSON [${locale}] ${relativeFile}${suffix}: ${ts.flattenDiagnosticMessageText(
        diagnostic.messageText,
        '\n'
      )}`
    );
  }

  for (const statement of sourceFile.statements) {
    if (ts.isExpressionStatement(statement)) {
      findDuplicateKeys(statement.expression, sourceFile, locale, relativeFile);
    }
  }

  try {
    return JSON.parse(text);
  } catch (error) {
    if (sourceFile.parseDiagnostics.length === 0) {
      report(`Invalid JSON [${locale}] ${relativeFile}: ${error.message}`);
    }
    return null;
  }
}

function validateLeafValues(locale, relativeFile, leaves) {
  for (const [key, value] of leaves) {
    if (typeof value !== 'string') {
      report(
        `Non-string value [${locale}] ${relativeFile}: ${key} (${value === null ? 'null' : typeof value})`
      );
    } else if (!value.trim()) {
      report(`Empty value [${locale}] ${relativeFile}: ${key}`);
    }
  }
}

function compareValueContract(
  locale,
  relativeFile,
  key,
  referenceValue,
  targetValue
) {
  if (typeof referenceValue !== typeof targetValue) {
    report(
      `Value type mismatch [${locale}] ${relativeFile}: ${key} (${typeof referenceValue} != ${typeof targetValue})`
    );
    return;
  }
  if (typeof referenceValue !== 'string' || typeof targetValue !== 'string') {
    return;
  }

  const referencePlaceholders = extractPlaceholders(referenceValue);
  const targetPlaceholders = extractPlaceholders(targetValue);
  if (!arraysEqual(referencePlaceholders, targetPlaceholders)) {
    report(
      `Placeholder mismatch [${locale}] ${relativeFile}: ${key} (en-us=[${referencePlaceholders.join(
        ', '
      )}], locale=[${targetPlaceholders.join(', ')}])`
    );
  }

  const referenceTags = extractRichTextTags(referenceValue);
  const targetTags = extractRichTextTags(targetValue);
  if (!arraysEqual(referenceTags, targetTags)) {
    report(
      `Rich-text tag mismatch [${locale}] ${relativeFile}: ${key} (en-us=[${referenceTags.join(
        ', '
      )}], locale=[${targetTags.join(', ')}])`
    );
  }

  const referenceWhitespace = extractBoundaryWhitespace(referenceValue);
  const targetWhitespace = extractBoundaryWhitespace(targetValue);
  const leadingWhitespaceMatches =
    referenceWhitespace.leading === targetWhitespace.leading ||
    (referenceWhitespace.leading.length > 0 &&
      targetWhitespace.leading.length === 0 &&
      startsWithBoundaryPunctuation(targetValue));
  if (
    !leadingWhitespaceMatches ||
    referenceWhitespace.trailing !== targetWhitespace.trailing
  ) {
    report(
      `Boundary whitespace mismatch [${locale}] ${relativeFile}: ${key} (en-us=${JSON.stringify(
        referenceWhitespace
      )}, locale=${JSON.stringify(targetWhitespace)})`
    );
  }
}

function validatePluralFamilyCoverage(locale, relativeFile, families) {
  const expectedCategories = pluralCategoriesFor(locale);
  for (const [base, family] of families) {
    const actualCategories = [...family.keys()].sort();
    for (const category of difference(expectedCategories, actualCategories)) {
      report(
        `Missing plural category [${locale}] ${relativeFile}: ${base}_${category}`
      );
    }
    for (const category of difference(actualCategories, expectedCategories)) {
      report(
        `Unexpected plural category [${locale}] ${relativeFile}: ${base}_${category}`
      );
    }
  }
}

function compareResources(locale, relativeFile, referenceLeaves, targetLeaves) {
  const referenceFamilies = collectPluralFamilies(referenceLeaves);
  const targetFamilies = collectPluralFamilies(targetLeaves);
  const referencePluralLeaves = pluralLeafKeys(referenceFamilies);
  const targetPluralLeaves = pluralLeafKeys(targetFamilies);

  for (const [key, referenceValue] of referenceLeaves) {
    if (referencePluralLeaves.has(key)) continue;
    if (!targetLeaves.has(key)) {
      report(`Missing key [${locale}] ${relativeFile}: ${key}`);
      continue;
    }

    compareValueContract(
      locale,
      relativeFile,
      key,
      referenceValue,
      targetLeaves.get(key)
    );
  }

  for (const key of targetLeaves.keys()) {
    if (targetPluralLeaves.has(key)) {
      const plural = parsePluralLeaf(key);
      if (plural && !referenceFamilies.has(plural.base)) {
        report(`Extra key [${locale}] ${relativeFile}: ${key}`);
      }
    } else if (!referenceLeaves.has(key)) {
      report(`Extra key [${locale}] ${relativeFile}: ${key}`);
    }
  }

  const requiredTargetCategories = pluralCategoriesFor(locale);
  for (const [base, referenceFamily] of referenceFamilies) {
    const targetFamily = targetFamilies.get(base) ?? new Map();
    for (const category of requiredTargetCategories) {
      if (!targetFamily.has(category)) {
        report(
          `Missing plural category [${locale}] ${relativeFile}: ${base}_${category}`
        );
        continue;
      }

      const referenceValue =
        referenceFamily.get(category) ?? referenceFamily.get('other');
      if (referenceValue === undefined) continue;
      compareValueContract(
        locale,
        relativeFile,
        `${base}_${category}`,
        referenceValue,
        targetFamily.get(category)
      );
    }

    for (const category of targetFamily.keys()) {
      if (!requiredTargetCategories.includes(category)) {
        report(
          `Unexpected plural category [${locale}] ${relativeFile}: ${base}_${category}`
        );
      }
    }
  }
}

function collectSourceFiles(directory) {
  const files = [];
  for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
    const fullPath = path.join(directory, entry.name);
    if (entry.isDirectory()) {
      files.push(...collectSourceFiles(fullPath));
    } else if (entry.isFile() && /\.(?:[jt]sx?)$/.test(entry.name)) {
      files.push(fullPath);
    }
  }
  return files;
}

function scriptKindFor(filePath) {
  if (filePath.endsWith('.tsx')) return ts.ScriptKind.TSX;
  if (filePath.endsWith('.jsx')) return ts.ScriptKind.JSX;
  if (filePath.endsWith('.js')) return ts.ScriptKind.JS;
  return ts.ScriptKind.TS;
}

function isLiteral(node) {
  return ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node);
}

function isTranslationCall(expression) {
  if (ts.isIdentifier(expression)) return expression.text === 't';
  return (
    ts.isPropertyAccessExpression(expression) &&
    ts.isIdentifier(expression.expression) &&
    (expression.expression.text === 'i18n' ||
      expression.expression.text === 'i18next') &&
    expression.name.text === 't'
  );
}

function recordSourceKey(keys, key, sourceFile, node, filePath) {
  const line =
    sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line +
    1;
  const relativePath = path.relative(projectRoot, filePath);
  const references = keys.get(key) ?? new Set();
  references.add(`${relativePath}:${line}`);
  keys.set(key, references);
}

function collectLiteralSourceKeys() {
  const keys = new Map();

  for (const filePath of collectSourceFiles(sourceDir)) {
    const text = fs.readFileSync(filePath, 'utf8');
    const sourceFile = ts.createSourceFile(
      filePath,
      text,
      ts.ScriptTarget.Latest,
      true,
      scriptKindFor(filePath)
    );

    const visit = (node) => {
      if (
        ts.isCallExpression(node) &&
        isTranslationCall(node.expression) &&
        node.arguments[0] &&
        isLiteral(node.arguments[0])
      ) {
        recordSourceKey(
          keys,
          node.arguments[0].text,
          sourceFile,
          node,
          filePath
        );
      }

      if (ts.isJsxAttribute(node) && node.name.text === 'i18nKey') {
        const tag = node.parent?.parent?.tagName;
        const tagName = tag?.getText(sourceFile);
        if (
          tagName === 'Trans' &&
          node.initializer &&
          ts.isStringLiteral(node.initializer)
        ) {
          recordSourceKey(
            keys,
            node.initializer.text,
            sourceFile,
            node,
            filePath
          );
        }
      }

      ts.forEachChild(node, visit);
    };

    visit(sourceFile);
  }

  return keys;
}

function validateSourceKeys(referenceKeys) {
  const missing = [...collectLiteralSourceKeys()]
    .filter(([key]) => !referenceKeys.has(key))
    .sort(([left], [right]) => left.localeCompare(right));
  if (missing.length === 0) return;

  report(`\nMissing literal source translation keys (${missing.length}):`);
  const grouped = new Map();
  for (const [key, references] of missing) {
    const prefix = key.split('.')[0];
    const entries = grouped.get(prefix) ?? [];
    entries.push([key, references]);
    grouped.set(prefix, entries);
  }

  for (const [prefix, entries] of [...grouped].sort(([a], [b]) =>
    a.localeCompare(b)
  )) {
    console.error(`  [${prefix}] (${entries.length})`);
    for (const [key, references] of entries) {
      console.error(`    ${key} <- ${[...references].sort().join(', ')}`);
    }
  }
}

function main() {
  const actualLocales = fs
    .readdirSync(localesDir, { withFileTypes: true })
    .filter((entry) => entry.isDirectory())
    .map((entry) => entry.name)
    .sort();

  for (const locale of difference(expectedLocales, actualLocales)) {
    report(`Missing locale directory: ${locale}`);
  }
  for (const locale of difference(actualLocales, expectedLocales)) {
    report(`Unexpected locale directory: ${locale}`);
  }

  const availableLocales = expectedLocales.filter((locale) =>
    actualLocales.includes(locale)
  );
  const resourcesByLocale = new Map();

  for (const locale of availableLocales) {
    const localePath = path.join(localesDir, locale);
    const actualFiles = fs
      .readdirSync(localePath, { withFileTypes: true })
      .filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
      .map((entry) => entry.name)
      .sort();

    for (const file of difference(expectedResourceFiles, actualFiles)) {
      report(`Missing resource file [${locale}]: ${file}`);
    }
    for (const file of difference(actualFiles, expectedResourceFiles)) {
      report(`Unexpected resource file [${locale}]: ${file}`);
    }

    const localeResources = new Map();
    for (const relativeFile of expectedResourceFiles) {
      if (!actualFiles.includes(relativeFile)) continue;
      const resource = readResource(locale, relativeFile);
      if (resource === null) continue;
      const leaves = flattenLeaves(resource);
      validateLeafValues(locale, relativeFile, leaves);
      localeResources.set(relativeFile, leaves);
    }
    resourcesByLocale.set(locale, localeResources);
  }

  const referenceResources = resourcesByLocale.get(referenceLocale);
  if (!referenceResources) {
    report(`Reference locale could not be loaded: ${referenceLocale}`);
  } else {
    for (const [relativeFile, leaves] of referenceResources) {
      validatePluralFamilyCoverage(
        referenceLocale,
        relativeFile,
        collectPluralFamilies(leaves)
      );
    }

    for (const locale of availableLocales) {
      if (locale === referenceLocale) continue;
      const localeResources = resourcesByLocale.get(locale);
      for (const relativeFile of expectedResourceFiles) {
        const referenceLeaves = referenceResources.get(relativeFile);
        const targetLeaves = localeResources?.get(relativeFile);
        if (!referenceLeaves || !targetLeaves) continue;
        compareResources(locale, relativeFile, referenceLeaves, targetLeaves);
      }
    }

    const referenceKeys = new Set();
    for (const [relativeFile, leaves] of referenceResources) {
      const prefix = path.basename(relativeFile, '.json');
      for (const key of leaves.keys()) {
        referenceKeys.add(`${prefix}.${key}`);
      }
      for (const [base, family] of collectPluralFamilies(leaves)) {
        const expectedCategories = pluralCategoriesFor(referenceLocale);
        if (expectedCategories.every((category) => family.has(category))) {
          referenceKeys.add(`${prefix}.${base}`);
        }
      }
    }
    validateSourceKeys(referenceKeys);
  }

  if (failed) {
    console.error('\ncheck-i18n-locale-parity: FAILED');
    process.exit(1);
  }
  console.log('check-i18n-locale-parity: OK');
}

main();