core_sync-checker.js

'use strict';

/**
 * @fileoverview Cross-language file synchronization checker.
 * Detects missing keys, identical values, and ICU format mismatches.
 * @module sync-checker
 */

const path = require('path');
const fs = require('./fs-adapter');
const {
  readJsonFile,
  flattenKeys,
  getNestedValue,
  isICUMessage,
  normalizeData,
} = require('./json-utils');
const {
  logSyncIssues,
  logSyncSummary,
  logSyncHeader,
  handleInsufficientLangs,
} = require('./sync-checker-utils');

const isTranslationFile = entry =>
  entry.isFile() &&
  entry.name.endsWith('.json') &&
  !entry.name.includes('report') &&
  !entry.name.includes('extracted');

async function readI18nDirectory(i18nDir) {
  try {
    return await fs.readdir(i18nDir, { withFileTypes: true });
  } catch {
    throw new Error(`i18n directory not found: ${i18nDir}`);
  }
}

async function loadLangFilesForSync(i18nDir) {
  const dirEntries = await readI18nDirectory(i18nDir);
  return dirEntries
    .filter(isTranslationFile)
    .map(f => ({ name: f.name.replace('.json', ''), path: path.join(i18nDir, f.name) }));
}

async function normalizeLangData(langFiles, format) {
  const langData = {};
  const allKeys = new Set();
  const results = await Promise.all(
    langFiles.map(async ({ name, path: filePath }) => {
      const data = await readJsonFile(filePath);
      if (!data) {
        throw new Error(`Cannot parse ${name}.json`);
      }
      return { name, data };
    }),
  );
  for (const { name, data } of results) {
    const normalized = normalizeData(data, format);
    const keys = flattenKeys(normalized);
    langData[name] = { data: normalized, keys: new Set(keys) };
    keys.forEach(k => allKeys.add(k));
  }
  return { langData, allKeys };
}

function getKeyEntries(key, langFiles, langData) {
  return langFiles
    .filter(({ name }) => langData[name].keys.has(key))
    .map(({ name }) => [name, getNestedValue(langData[name].data, key.split('.'))]);
}

function detectIcuStatus(entries) {
  return {
    icuLangs: entries.filter(([, v]) => isICUMessage(v)).map(([l]) => l),
    nonIcuLangs: entries.filter(([, v]) => v && !isICUMessage(v)).map(([l]) => l),
  };
}

const hasIcuMismatch = (icu, nonIcu) => icu.length > 0 && nonIcu.length > 0;
const hasUniqueValue = entries => new Set(Object.values(Object.fromEntries(entries))).size === 1;

function classifyKeyResult(key, entries, icuStatus) {
  const { icuLangs, nonIcuLangs } = icuStatus;
  if (hasIcuMismatch(icuLangs, nonIcuLangs)) {
    return { type: 'icuMismatch', key, hasIcu: icuLangs, missingIcu: nonIcuLangs };
  }
  if (icuLangs.length > 0) {
    return { type: 'icuMessage', key, langs: icuLangs };
  }
  if (hasUniqueValue(entries)) {
    return { type: 'identical', key, value: entries[0]?.[1] };
  }
  return null;
}

function analyzeKeyForSync(key, langFiles, langData) {
  const entries = getKeyEntries(key, langFiles, langData);
  if (entries.length < 2) {
    return null;
  }
  return classifyKeyResult(key, entries, detectIcuStatus(entries));
}

const CATEGORY_MAP = {
  icuMismatch: 'icuMismatches',
  icuMessage: 'icuMessages',
  identical: 'identicalValues',
};
const addToCategory = (cat, res) => {
  const k = res?.type && CATEGORY_MAP[res.type];
  if (k) {
    cat[k].push(res);
  }
};

function categorizeKeysForSync(allKeys, langFiles, langData) {
  const categories = { identicalValues: [], icuMessages: [], icuMismatches: [] };
  for (const key of allKeys) {
    addToCategory(categories, analyzeKeyForSync(key, langFiles, langData));
  }
  return categories;
}

function buildSyncResult(langFiles, langData, allKeys) {
  const missingByLang = Object.fromEntries(
    langFiles.map(({ name }) => [name, [...allKeys.difference(langData[name].keys)]]),
  );
  const { identicalValues, icuMessages, icuMismatches } = categorizeKeysForSync(
    allKeys,
    langFiles,
    langData,
  );
  return { allKeys, langFiles, missingByLang, identicalValues, icuMessages, icuMismatches };
}

const hasCriticalSyncIssues = sr =>
  Object.values(sr.missingByLang).some(k => k.length > 0) || sr.icuMismatches.length > 0;

function buildCheckSyncResult(ctx) {
  const { syncResult, strict, exitCodes, log } = ctx;
  const failed = strict && hasCriticalSyncIssues(syncResult);
  if (failed) {
    log('\nSync check failed (--strict mode)');
  }
  return {
    success: !failed,
    exitCode: failed ? exitCodes.untranslated : exitCodes.success,
    result: syncResult,
  };
}

async function performSyncCheck(langFiles, format, log) {
  log(`Comparing: ${langFiles.map(f => f.name).join(', ')}\n`);
  const { langData, allKeys } = await normalizeLangData(langFiles, format);
  const syncResult = buildSyncResult(langFiles, langData, allKeys);
  logSyncIssues(syncResult, log);
  logSyncSummary(syncResult, log);
  return syncResult;
}

const getExitCodes = options => options.exitCodes || { success: 0, untranslated: 1 };
const parseSyncOptions = opts => ({
  i18nDir: opts.i18nDir,
  format: opts.format || 'nested',
  log: opts.log || console.log,
  strict: opts.strict || false,
  exitCodes: getExitCodes(opts),
});

/**
 * Compares language files for missing keys, ICU mismatches, and duplicates
 * @param {SyncOptions} [options]
 * @returns {Promise<SyncCheckResult>}
 * @example
 * const { success, result } = await checkSync({ i18nDir: './src/i18n', strict: true });
 * console.log(result.missingByLang);
 */
async function checkSync(options = {}) {
  const opts = parseSyncOptions(options);
  logSyncHeader(opts.log);
  const langFiles = await loadLangFilesForSync(opts.i18nDir);
  if (langFiles.length < 2) {
    return handleInsufficientLangs(langFiles, opts.log);
  }
  const syncResult = await performSyncCheck(langFiles, opts.format, opts.log);
  return buildCheckSyncResult({
    syncResult,
    strict: opts.strict,
    exitCodes: opts.exitCodes,
    log: opts.log,
  });
}

module.exports = {
  checkSync,
  isTranslationFile,
  readI18nDirectory,
};