Ctrl K
ComunidadGuías…

scripts/fix-untranslated.js

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

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env node
// Version: 0.1.15
const { readdir, readFile, writeFile } = require('node:fs/promises');
const { existsSync, writeFileSync } = require('node:fs');
const path = require('node:path');

// Import shared utilities
const { detectSrcRoot } = require('./lib/projectConfig');
const { getIgnorePatterns, isPlaceholderOnlyText } = require('./lib/ignorePatterns');
const { listLocales } = require('./lib/localeUtils');

const projectRoot = path.resolve(__dirname, '..');
const autoDir = path.resolve(projectRoot, 'resources', 'js', 'i18n', 'auto');
const srcRoot = detectSrcRoot(projectRoot);
const ignorePatternsPath = path.resolve(projectRoot, 'scripts', 'i18n-ignore-patterns.json');

// Initialize ignore patterns from shared utility
getIgnorePatterns(projectRoot);

function shouldPersistIgnorePattern(normalized) {
  const value = String(normalized || '').trim();
  if (!value) return false;

  const isLower = value === value.toLowerCase();
  const isKeyLike = isLower && /^[a-z0-9_]+(\.[a-z0-9_]+)+$/.test(value);
  if (!isKeyLike) return true;

  const lastSegment = value.split('.').pop();
  const tlds = new Set(['com', 'net', 'org', 'io', 'dev', 'app', 'co', 'edu', 'gov', 'info']);
  if (lastSegment && tlds.has(lastSegment)) {
    return true;
  }

  return false;
}

function addExactIgnorePattern(value) {
  if (!value) return;
  const normalized = String(value).replace(/\s+/g, ' ').trim();
  if (!normalized) return;
  if (!shouldPersistIgnorePattern(normalized)) return;
  const patterns = getIgnorePatterns(projectRoot);
  if (!patterns.exact) {
    patterns.exact = [];
  }
  if (patterns.exact.includes(normalized)) {
    return;
  }
  patterns.exact.push(normalized);
  try {
    writeFileSync(ignorePatternsPath, JSON.stringify(patterns, null, 2) + '\n', 'utf8');
  } catch {
    // ignore write errors to avoid breaking the main flow
  }
}

async function collectJsonFiles(dir, out) {
  const entries = await readdir(dir, { withFileTypes: true });
  for (const entry of entries) {
    const entryPath = path.join(dir, entry.name);
    if (entry.isDirectory()) {
      await collectJsonFiles(entryPath, out);
    } else if (entry.isFile() && entry.name.endsWith('.json')) {
      out.push(entryPath);
    }
  }
}

async function readJsonSafe(filePath) {
  try {
    const raw = await readFile(filePath, 'utf8');
    return JSON.parse(raw);
  } catch (err) {
    console.error(`[fix-untranslated] Failed to read/parse JSON: ${filePath}`);
    console.error(err?.message || err);
    return null;
  }
}

async function collectSourceFiles(dir, out) {
  const entries = await readdir(dir, { withFileTypes: true });
  for (const entry of entries) {
    const entryPath = path.join(dir, entry.name);
    if (entry.isDirectory()) {
      if (['node_modules', 'vendor', '.git', 'storage', 'bootstrap', 'public'].includes(entry.name)) {
        continue;
      }
      await collectSourceFiles(entryPath, out);
    } else if (entry.isFile()) {
      if (/\.(tsx|ts|jsx|js)$/i.test(entry.name)) {
        out.push(entryPath);
      }
    }
  }
}
let cachedUsageIndex = null;

async function buildUsageIndex() {
  const index = Object.create(null);
  if (!existsSync(srcRoot)) return index;

  const files = [];
  await collectSourceFiles(srcRoot, files);

  console.log(`[fix-untranslated] Scanning ${files.length} source files for key usage...`);

  for (const file of files) {
    const rel = path.relative(projectRoot, file);
    const code = await readFile(file, 'utf8');
    const lines = code.split(/\r?\n/);
    const regex = /(?:^|[^a-zA-Z0-9_$])\$?t\s*\(\s*(['"`])([A-Za-z0-9_\.\-]+)\1\s*(?:,|\))/g;

    for (let i = 0; i < lines.length; i += 1) {
      const lineText = lines[i];
      let match;
      while ((match = regex.exec(lineText)) !== null) {
        const key = match[2];
        if (!index[key]) {
          index[key] = [];
        }
        index[key].push({ file: rel, line: i + 1 });
      }
    }
  }

  console.log('[fix-untranslated] Finished building source usage index.');
  return index;
}

async function indexKeyUsage(fullKey) {
  if (!fullKey) return [];
  if (!cachedUsageIndex) {
    cachedUsageIndex = await buildUsageIndex();
  }
  return cachedUsageIndex[fullKey] || [];
}

function isNonTranslatableExample(text) {
  const trimmed = String(text || '').trim();
  if (!trimmed) return false;
  const normalized = trimmed.replace(/\s+/g, ' ');
  const ignore = getIgnorePatterns();
  if (ignore && Array.isArray(ignore.exact) && ignore.exact.includes(normalized)) {
    return true;
  }
  if (ignore && Array.isArray(ignore.exactInsensitive)) {
    const lowerNorm = normalized.toLowerCase();
    for (const v of ignore.exactInsensitive) {
      if (String(v).toLowerCase() === lowerNorm) {
        return true;
      }
    }
  }
  if (ignore && Array.isArray(ignore.contains)) {
    for (const part of ignore.contains) {
      if (part && normalized.includes(String(part))) {
        return true;
      }
    }
  }
  if (isPlaceholderOnlyText(normalized)) {
    addExactIgnorePattern(normalized);
    return true;
  }
  if (!/\s/.test(normalized) && normalized.includes('.') && /^[A-Za-z0-9.-]+$/.test(normalized)) {
    addExactIgnorePattern(normalized);
    return true;
  }
  if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(normalized)) {
    addExactIgnorePattern(normalized);
    return true;
  }
  // Query-string or URL-fragment-like segments such as "?duration=", "?lang=en"
  if (!/\s/.test(normalized) && /[?&]/.test(normalized) && /=/.test(normalized)) {
    addExactIgnorePattern(normalized);
    return true;
  }
  if (!/\s/.test(normalized) && /[:\[\]]/.test(normalized) && /^[A-Za-z0-9:._\-\[\]]+$/.test(normalized)) {
    addExactIgnorePattern(normalized);
    return true;
  }
  if (/[{};]/.test(normalized) && /\b(const|let|var|function|return|if|else|for|while|class|async|await)\b/.test(normalized)) {
    addExactIgnorePattern(normalized);
    return true;
  }
  // Domain + optional port and qualifier, e.g. "imap.gmail.com:993 (SSL)"
  if (/^[A-Za-z0-9.-]+\.[A-Za-z]{2,}(:\d+)?(\s*\([A-Za-z0-9\s]+\))?$/.test(normalized)) {
    addExactIgnorePattern(normalized);
    return true;
  }
  if (/\{\{\s*[^}]+\s*\}\}/.test(normalized) && (/[?:]/.test(normalized) || /\|\|/.test(normalized) || /&&/.test(normalized) || /\.length\b/.test(normalized))) {
    addExactIgnorePattern(normalized);
    return true;
  }
  // Strings that are overwhelmingly CSS/utility classes plus placeholders
  {
    const words = normalized.split(/\s+/);
    const nonPlaceholderWords = words.filter((w) => !/^\{[^}]+\}$/.test(w));
    if (nonPlaceholderWords.length > 0) {
      const cssishWords = nonPlaceholderWords.filter(
        (w) => /[-:]/.test(w) && /^[A-Za-z0-9:._\-\[\]]+$/.test(w),
      );

      if (
        cssishWords.length >= 2 &&
        cssishWords.length >= nonPlaceholderWords.length - 1
      ) {
        addExactIgnorePattern(normalized);
        return true;
      }

      if (
        nonPlaceholderWords.length === 1 &&
        cssishWords.length === 1 &&
        nonPlaceholderWords[0].includes('-')
      ) {
        addExactIgnorePattern(normalized);
        return true;
      }
    }
  }
  // Analytics / object-literal style code snippets (e.g. gtag(... { 'send_to': ... }); )
  if (
    normalized.includes('gtag(') ||
    (/[{}]/.test(normalized) && /['"][^'"]+['"]\s*:/.test(normalized))
  ) {
    addExactIgnorePattern(normalized);
    return true;
  }
  // Strings that clearly look like examples (example.com, example-bucket, etc.)
  if (/\bexample\b/i.test(normalized)) {
    addExactIgnorePattern(normalized);
    return true;
  }
  // Obvious filesystem paths (Unix/Windows) that are not URLs
  if (
    (/^[\\/][^\s]+$/.test(normalized)) || // /var/mail, \\server\\share
    (/^[A-Za-z]:[\\/][^\s]*$/.test(normalized)) // C:\path\to\file
  ) {
    addExactIgnorePattern(normalized);
    return true;
  }
  // Single-character strings are almost never meaningful UI copy
  if (normalized.length === 1) {
    addExactIgnorePattern(normalized);
    return true;
  }
  // Obvious URLs
  if (/^https?:\/\//i.test(normalized) || /^www\./i.test(normalized)) {
    addExactIgnorePattern(normalized);
    return true;
  }
  // Sample email addresses on example/acme domains
  const emailMatch = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalized);
  if (emailMatch) {
    const lower = normalized.toLowerCase();
    if (lower.includes('example.') || lower.includes('acme.')) {
      addExactIgnorePattern(normalized);
      return true;
    }
  }
  // Placeholder IDs / tokens like abcd1234 (no spaces, mixed letters+digits)
  const noSpace = !/\s/.test(normalized);
  const hasLetter = /[A-Za-z]/.test(normalized);
  const hasDigit = /\d/.test(normalized);
  if (noSpace && hasLetter && hasDigit && normalized.length >= 6 && normalized.length <= 64) {
    addExactIgnorePattern(normalized);
    return true;
  }
  // Well-known dummy names / brands and protocol-like tokens
  const lowerNorm = normalized.toLowerCase();
  if (
    lowerNorm === 'jane doe' ||
    lowerNorm === 'john doe' ||
    lowerNorm === 'acme corporation' ||
    lowerNorm === 'acme inc' ||
    lowerNorm === 'acme corp' ||
    lowerNorm === 'acme company' ||
    lowerNorm === 'venmail' ||
    /\bacme\b/.test(lowerNorm) ||
    /\bvenmail\b/.test(lowerNorm) ||
    /\bamazon s3\b/.test(lowerNorm) ||
    ['webdav', 'imap', 'smtp', 'pop3', 'ftp', 'sftp'].includes(lowerNorm)
  ) {
    addExactIgnorePattern(normalized);
    return true;
  }
  const alphaNum = normalized.replace(/[^A-Za-z0-9]/g, '');
  if (alphaNum && alphaNum.length <= 4 && alphaNum.toUpperCase() === alphaNum) {
    addExactIgnorePattern(normalized);
    return true;
  }
  if (/\d{2,}/.test(normalized)) {
    const lower = normalized.toLowerCase();
    if (lower.includes('main street') && lower.includes('city') && lower.includes('state')) {
      addExactIgnorePattern(normalized);
      return true;
    }
  }
  // Heuristic: single "word" strings that look like random codes/abbreviations
  // rather than natural language. This helps auto-ignore things like internal
  // identifiers that happen to be present in locale files.
  const wordMatch = normalized.match(/^[A-Za-z]{3,8}$/);
  if (wordMatch) {
    const word = wordMatch[0];
    const lower = word.toLowerCase();
    const vowels = lower.replace(/[^aeiouy]/g, '').length;
    const consonants = lower.replace(/[^bcdfghjklmnpqrstvwxyz]/g, '').length;

    // Ignore words that have no vowels at all (e.g. "crm", "cfg"), since
    // these are overwhelmingly likely to be codes or abbreviations.
    if (vowels === 0 && consonants >= 2) {
      addExactIgnorePattern(normalized);
      return true;
    }

    // Ignore short "dense" consonant clusters like cccv/cccv etc (3 or more
    // consonants in a row) for words up to length 6; these are very unlikely
    // to be meaningful UI copy in English and are often internal codes.
    if (word.length <= 6 && /[bcdfghjklmnpqrstvwxyz]{3,}/i.test(word)) {
      addExactIgnorePattern(normalized);
      return true;
    }
  }
  return false;
}

function walkCompare(enObj, locObj, pathSegments, issues, locale, options) {
  if (!enObj || typeof enObj !== 'object' || Array.isArray(enObj)) return;
  if (!locObj || typeof locObj !== 'object' || Array.isArray(locObj)) return;

  for (const [key, enVal] of Object.entries(enObj)) {
    const nextPath = pathSegments.concat(key);
    const locVal = Object.prototype.hasOwnProperty.call(locObj, key) ? locObj[key] : undefined;

    if (typeof enVal === 'string' && typeof locVal === 'string') {
      const enTrimmed = String(enVal || '').trim();
      if (!enTrimmed || !/[A-Za-z]/.test(enTrimmed) || isNonTranslatableExample(enTrimmed)) {
        continue;
      }
      if (locVal === enVal) {
        const keyPath = nextPath.join('.');
        // Generic untranslated issue (locale string identical to English)
        issues.push({
          locale,
          keyPath,
          english: enVal,
          current: locVal,
          issueType: 'untranslated',
        });
      }

      // Optional: style hints for French button labels
      if (
        locale === 'fr' &&
        options &&
        options.conciseMap &&
        pathSegments.includes('button') &&
        options.conciseMap[locVal]
      ) {
        const keyPath = nextPath.join('.');
        issues.push({
          locale,
          keyPath,
          english: enVal,
          current: locVal,
          suggested: options.conciseMap[locVal],
          issueType: 'style',
        });
      }
    } else if (
      enVal && typeof enVal === 'object' && !Array.isArray(enVal) &&
      locVal && typeof locVal === 'object' && !Array.isArray(locVal)
    ) {
      walkCompare(enVal, locVal, nextPath, issues, locale, options);
    }
  }
}

async function main() {
  if (!existsSync(autoDir)) {
    console.error('[fix-untranslated] i18n auto directory not found:', autoDir);
    process.exit(1);
  }

  let hadLocaleReadErrors = false;

  const locales = await listLocales(autoDir, { includeJsonFiles: false });
  if (!locales.includes('en')) {
    console.error('[fix-untranslated] No "en" locale found under', autoDir);
    process.exit(1);
  }

  const issues = [];

  const conciseFrMap = {
    "Mettre à jour l'utilisateur": "Modifier",
    "Mettre à jour l'invitation": "Modifier",
    "Mettre à jour le mot de passe": "Modifier",
    "Mettre à jour l'abonnement": "Modifier",
    "Mettre à jour": "Modifier",
    "Supprimer l'utilisateur": "Supprimer",
    "Supprimer le compte": "Supprimer",
    "Supprimer le jeton": "Supprimer",
    "Supprimer l'arrière-plan": "Supprimer",
    "Enregistrer l'abonnement": "Enregistrer",
    "Enregistrer le fournisseur": "Enregistrer",
    "Enregistrer les paramètres API": "Enregistrer",
    "Enregistrer les modifications": "Enregistrer",
    "Ajouter un libellé": "Ajouter",
    "Ajouter un fournisseur": "Ajouter",
    "Ajouter un client": "Ajouter",
    "Ajouter des fonds": "Ajouter",
    "Ajouter une note": "Ajouter",
    "Créer un nouveau libellé": "Créer",
    "Créer une prospection": "Créer",
    "Créer un profil": "Créer",
    "Créer une tâche": "Créer",
    "Créer une équipe": "Créer",
    "Copier/Partager l'URL d'invitation": "Copier le lien",
    "Personnaliser le message d'invitation": "Personnaliser",
    "Envoyer le mot de passe à l'utilisateur": "Envoyer",
    "Envoyer le mot de passe": "Envoyer",
    "Importer des utilisateurs existants": "Importer",
    "Démarrer l'import": "Démarrer",
    "Voir les détails": "Détails",
    "Retour aux contacts": "Retour",
    "Alimenter le portefeuille": "Alimenter",
    "Gérer les valeurs par défaut partenaires": "Gérer",
    "Voir tous les clients": "Voir tout",
    "Recommander un partenaire": "Recommander",
    "Recommander votre premier partenaire": "Recommander",
    "Obtenir le jeton FCM": "Obtenir",
    "Rafraîchir le jeton": "Rafraîchir",
    "Rafraîchir la page": "Rafraîchir",
    "Revenir aux réunions": "Retour",
    "Rejoindre la réunion": "Rejoindre",
    "Mettre fin à l'abonnement": "Résilier",
    "Annuler l'abonnement": "Annuler",
    "Correction automatique": "Auto-corriger",
    "Reprogrammer": "Modifier",
    "Planifier une réunion": "Planifier",
    "Ajouter un e‑mail de relance": "Ajouter",
    "Copier l'invitation": "Copier",
    "Copier l'e‑mail": "Copier",
    "Copier le lien": "Copier",
  };

  for (const locale of locales) {
    if (locale === 'en') continue;

    const locRoot = path.resolve(autoDir, locale);
    const enRoot = path.resolve(autoDir, 'en');

    const locFiles = [];
    await collectJsonFiles(locRoot, locFiles);

    for (const locFile of locFiles) {
      const rel = path.relative(locRoot, locFile);
      const enFile = path.resolve(enRoot, rel);
      if (!existsSync(enFile)) continue;

      const locJson = await readJsonSafe(locFile);
      const enJson = await readJsonSafe(enFile);
      if (!locJson || !enJson) {
        hadLocaleReadErrors = true;
        continue;
      }

      const fileIssues = [];
      walkCompare(
        enJson,
        locJson,
        [],
        fileIssues,
        locale,
        locale === 'fr' ? { conciseMap: conciseFrMap } : {},
      );

      for (const issue of fileIssues) {
        issue.localeFile = path.relative(autoDir, locFile);
        issues.push(issue);
      }
    }
  }

  if (issues.length === 0) {
    if (hadLocaleReadErrors) {
      console.error('[fix-untranslated] Aborting: one or more locale JSON files could not be parsed.');
      process.exit(1);
    }
    console.log('[fix-untranslated] No untranslated or style issues detected.');
    process.exit(0);
  }

  if (hadLocaleReadErrors) {
    console.error('[fix-untranslated] Aborting: one or more locale JSON files could not be parsed.');
    process.exit(1);
  }

  function groupIssuesByFile(issuesSubset) {
    const byFile = new Map();
    for (const issue of issuesSubset) {
      const key = `${issue.locale}::${issue.localeFile || ''}`;
      let entry = byFile.get(key);
      if (!entry) {
        entry = {
          locale: issue.locale,
          localeFile: issue.localeFile,
          issues: [],
        };
        byFile.set(key, entry);
      }
      const item = {
        keyPath: issue.keyPath,
        english: issue.english,
        current: issue.current,
      };
      if (issue.issueType === 'style' && issue.suggested) {
        item.suggested = issue.suggested;
      }
      entry.issues.push(item);
    }
    return Array.from(byFile.values()).sort((a, b) => {
      if (a.locale === b.locale) {
        return String(a.localeFile || '').localeCompare(String(b.localeFile || ''));
      }
      return String(a.locale).localeCompare(String(b.locale));
    });
  }

  const untranslatedIssues = issues.filter((i) => i.issueType === 'untranslated');
  const styleIssues = issues.filter((i) => i.issueType === 'style');
  const generatedAt = new Date().toISOString();

  const combinedReportPath = path.resolve(projectRoot, 'scripts', '.i18n-untranslated-report.json');
  const combinedReport = {
    generatedAt,
    issues,
  };
  await writeFile(combinedReportPath, JSON.stringify(combinedReport, null, 2) + '\n', 'utf8');
  console.log('[fix-untranslated] Combined report written to', path.relative(projectRoot, combinedReportPath));
  console.log(`[fix-untranslated] Total issues: ${issues.length}`);

  if (untranslatedIssues.length > 0) {
    const files = groupIssuesByFile(untranslatedIssues);
    const untranslatedPath = path.resolve(projectRoot, 'scripts', '.i18n-untranslated-untranslated.json');
    const report = {
      issueType: 'untranslated',
      generatedAt,
      files,
    };
    await writeFile(untranslatedPath, JSON.stringify(report, null, 2) + '\n', 'utf8');
    console.log('[fix-untranslated] Untranslated report written to', path.relative(projectRoot, untranslatedPath));
    console.log(`  Files: ${files.length}, Issues: ${untranslatedIssues.length}`);

    // Also write a compact untranslated report that only contains, for each
    // locale/localeFile pair, the source files and line ranges where
    // untranslated keys are used. This is optimized for GPT-style tools that
    // only need to know *where* to look in the source, not per-key details.
    const compactByFile = new Map();

    // Pre-seed entries for each locale/localeFile pair.
    for (const issue of untranslatedIssues) {
      const key = `${issue.locale}::${issue.localeFile || ''}`;
      if (!compactByFile.has(key)) {
        compactByFile.set(key, {
          locale: issue.locale,
          localeFile: issue.localeFile,
          ranges: new Map(), // sourceFile -> { file, lineStart, lineEnd }
        });
      }
    }

    // For each untranslated key, look up where it is used in source and merge
    // all usages into line ranges per source file.
    for (const issue of untranslatedIssues) {
      const key = `${issue.locale}::${issue.localeFile || ''}`;
      const entry = compactByFile.get(key);
      if (!entry) continue;

      const usage = await indexKeyUsage(issue.keyPath);
      if (!usage || !usage.length) continue;

      for (const u of usage) {
        const fileKey = u.file;
        let range = entry.ranges.get(fileKey);
        if (!range) {
          range = { file: fileKey, lineStart: u.line, lineEnd: u.line };
        } else {
          if (u.line < range.lineStart) range.lineStart = u.line;
          if (u.line > range.lineEnd) range.lineEnd = u.line;
        }
        entry.ranges.set(fileKey, range);
      }
    }

    const compactFiles = Array.from(compactByFile.values())
      .map((entry) => ({
        locale: entry.locale,
        localeFile: entry.localeFile,
        issues: Array.from(entry.ranges.values()),
      }))
      .filter((entry) => entry.issues.length > 0)
      .sort((a, b) => {
        if (a.locale === b.locale) {
          return String(a.localeFile || '').localeCompare(String(b.localeFile || ''));
        }
        return String(a.locale).localeCompare(String(b.locale));
      });

    const compactPath = path.resolve(projectRoot, 'scripts', '.i18n-untranslated-compact.json');
    const compactReport = {
      issueType: 'untranslated',
      generatedAt,
      files: compactFiles,
    };
    await writeFile(compactPath, JSON.stringify(compactReport, null, 2) + '\n', 'utf8');
    console.log('[fix-untranslated] Compact untranslated report written to', path.relative(projectRoot, compactPath));
  }

  if (styleIssues.length > 0) {
    const files = groupIssuesByFile(styleIssues);
    const stylePath = path.resolve(projectRoot, 'scripts', '.i18n-untranslated-style.json');
    const report = {
      issueType: 'style',
      generatedAt,
      files,
    };
    await writeFile(stylePath, JSON.stringify(report, null, 2) + '\n', 'utf8');
    console.log('[fix-untranslated] Style report written to', path.relative(projectRoot, stylePath));
    console.log(`  Files: ${files.length}, Issues: ${styleIssues.length}`);
  }

  // Write a small standalone AI instructions text file instead of embedding
  // bulky prompts inside the JSON reports. This keeps the JSON lean while
  // still giving consumers a ready-to-use prompt.
  const instructionsPath = path.resolve(projectRoot, 'scripts', '.i18n-untranslated-ai-instructions.txt');
  const instructions = [
    'You are given one JSON report at a time describing i18n translation issues and to apply fixes not suggestions',
    '',
    'Input JSON structure:',
    '- issueType: "untranslated" or "style".',
    '- files: array of { locale, localeFile, issues }.',
    '- each issues[] item has: keyPath, english, current, and for style issues an optional suggested value.',
    '',
    'Processing rules:',
    '- Treat each element in files[] as a separate batch that corresponds to a single locale JSON file (localeFile).',
    '- For every issue in issues[], you must propose a final translation for that locale.',
    '- For issueType="untranslated": translate english into the target locale language.',
    '- For issueType="style": refine current (or suggested, if present) into a concise, UI-appropriate label in the same locale.',
    '- Always preserve placeholders exactly (e.g. {name}, {value1}, {{ variable }}).',
    '- Do not change brand names (e.g. Venmail) or protocol names (e.g. SMTP, IMAP, WebDAV).',
    '',
    'Your goal is to help improve translations in-place, in small batches per locale file, based on this report applying the suggestions to relevant files as necessary.',
  ].join('\n');

  await writeFile(instructionsPath, instructions + '\n', 'utf8');
  console.log('[fix-untranslated] AI instructions written to', path.relative(projectRoot, instructionsPath));
}

main().catch((err) => {
  console.error('[fix-untranslated] Failed:', err && err.message ? err.message : err);
  process.exit(1);
});