HookStackGitHub
Back to catalogue
ValidationStopStopWhen the agent finishes its task· non-blocking

Full i18n validation (Stop)

Ship no untranslated string, no orphan key

Validates the consistency of translation files on every session stop: every key used in the code exists in the i18n files, and no orphan key lingers. Runs only if frontend files were modified.

What does the Full i18n validation (Stop) hook do?

Full i18n validation (Stop) is a Claude Code Stop hook. It fires automatically at that lifecycle event — outside the model, so it can't be skipped or forgotten. Ship no untranslated string, no orphan key.

Use cases

  • Prevent shipping untranslated strings or orphan keys
  • Validate translations automatically without waiting for CI

Tags

#i18n#translations#validation#frontend#definition-of-done

settings.json fragment

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "command": "node .claude/hooks/i18n-validation.mjs",
            "type": "command"
          }
        ]
      }
    ]
  }
}

Script · .claude/hooks/i18n-validation.mjs

#!/usr/bin/env node
// Valide la cohérence des fichiers de traduction (Stop)
import { readFileSync, existsSync } from 'fs';
import { execSync } from 'child_process';
import { join } from 'path';
import { fileURLToPath } from 'url';

export function run({
  exec,
  readFile = readFileSync,
  projectDir = process.env.CLAUDE_PROJECT_DIR ?? process.cwd(),
} = {}) {
  const doExec =
    exec ?? ((cmd) => execSync(cmd, { encoding: 'utf8', timeout: 5_000, cwd: projectDir }).trim());

  // Cherche les fichiers de traduction JSON (ex: locales/fr.json, messages/en.json)
  const i18nFiles = doExec('find . -path ./node_modules -prune -o -name "*.json" -print')
    .split('\n')
    .filter((f) => /\/(locales?|messages?|i18n)\//i.test(f) && f.endsWith('.json'));

  if (i18nFiles.length < 2) return null;

  // Groupe par répertoire et vérifie la cohérence des clés
  const byDir = {};
  for (const f of i18nFiles) {
    const dir = f.split('/').slice(0, -1).join('/');
    byDir[dir] ??= [];
    byDir[dir].push(f);
  }

  const issues = [];
  for (const [, files] of Object.entries(byDir)) {
    if (files.length < 2) continue;
    const parsed = files
      .map((f) => {
        try { return { f, keys: new Set(Object.keys(JSON.parse(readFile(join(projectDir, f), 'utf8')))) }; } catch { return null; }
      })
      .filter(Boolean);

    const allKeys = new Set(parsed.flatMap((p) => [...p.keys]));
    for (const { f, keys } of parsed) {
      const missing = [...allKeys].filter((k) => !keys.has(k));
      if (missing.length > 0)
        issues.push(`${f} manque ${missing.length} clé(s) : ${missing.slice(0, 5).join(', ')}${missing.length > 5 ? '…' : ''}`);
    }
  }

  const message =
    issues.length > 0
      ? `[i18n-validation] Incohérences détectées :\n${issues.map((i) => `  - ${i}`).join('\n')}\n`
      : '[i18n-validation] ✓ Fichiers de traduction cohérents.\n';

  return { issues, message };
}

/* v8 ignore next 4 */
if (process.argv[1] === fileURLToPath(import.meta.url)) {
  const result = run();
  if (result) process.stderr.write(result.message);
}