HookStack
Back to catalogue
ValidationStop

Full i18n validation (Stop)

Ship no untranslated string, no orphan key

Validates the consistency of translation files on every session stop: every key present in one locale file must exist in the others, every key referenced in the code (t(), gettext(), _(), formatMessage({id})…) must exist in the translations, and no orphan key lingers. Covers the standard i18n locations and formats: JSON under locales/messages/i18n/translations/lang/l10n, gettext (.po/.pot in po/ or LC_MESSAGES — msgctxt-aware keys), Apple (.strings in *.lproj), Android (strings.xml in values*), Flutter ARB, Java resource bundles (.properties) and Qt Linguist (.ts under translations/).

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.

As a Stop hook it runs after the action, reacting to what just happened rather than blocking it. Because it is a deterministic Node.js script, it executes on every matching event without relying on the model to remember — the guarantee that makes agentic workflows safe to automate.

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
// @hookstack stop-i18n-validation
// Valide la cohérence des fichiers de traduction (Stop)
import { readdirSync, readFileSync } from "node:fs";
import { join, relative, sep } from "node:path";
import { fileURLToPath } from "node:url";

// Répertoires exclus du parcours : lourds et sans traduction.
// `.claude` contient les worktrees (copies complètes du repo) — principal coupable du timeout.
const SKIP_DIRS = new Set([
	"node_modules",
	".git",
	".claude",
	".next",
	".turbo",
	".sveltekit",
	"dist",
	"build",
	".cache",
	"coverage",
	".worktrees",
]);

// Segment de chemin qui marque un dossier de traduction. Couvre les conventions
// web (locales/messages/i18n/translations/lang/l10n), GNU gettext (po,
// LC_MESSAGES) et Apple (*.lproj). Android `values*` et Qt `translations/*.ts`
// sont traités par nom de fichier (voir classifyFile).
const I18N_DIR_PATH =
	/(?:^|[/\\])(?:locales?|messages?|translations?|langs?|l10n|i18n|po|LC_MESSAGES|[^/\\]*\.lproj)[/\\]/i;

// Dossier Android comparable : `values` ou `values-<locale>` (BCP-47 simplifié).
// Les qualifiers non-locale (values-night, values-land, values-sw600dp,
// values-v21…) sont exclus — un override partiel de thème n'est pas une
// traduction manquante.
const ANDROID_VALUES_RE = /^values(?:-[a-z]{2,3}(?:-[A-Za-z]{2,8})*)?$/i;

// Dernier segment de chemin qui ressemble à une locale (fr, en-US, pt_BR…).
const LOCALE_SEG = /^[a-z]{2,3}(?:[-_][a-z0-9]{2,8})*$/i;

// Noms de dossiers de traduction connus — ne jamais les traiter comme une locale
// (ex. `po` matcherait LOCALE_SEG et casserait le groupement de po/fr.po).
const I18N_SEG =
	/^(?:locales?|messages?|translations?|langs?|l10n|i18n|po|LC_MESSAGES|[^/\\]*\.lproj)$/i;

// Noms de fichiers de traduction sans ambiguïté (Android, bundles Java).
const I18N_FILE = /^(?:strings\.xml|messages.*\.properties)$/i;

/**
 * Classe un chemin relatif (sans "./" initial) en fichier de traduction.
 * Retourne { rel, kind, group } ou null. `kind` pilote l'extraction des clés ;
 * `group` décide quels fichiers sont comparés entre eux.
 */
export function classifyFile(rel) {
	const clean = rel.replace(/^\.\//, "");
	const slash = clean.lastIndexOf("/");
	const dir = slash === -1 ? "" : clean.slice(0, slash);
	const base = slash === -1 ? clean : clean.slice(slash + 1);
	const dot = base.lastIndexOf(".");
	const ext = dot === -1 ? "" : base.slice(dot + 1).toLowerCase();

	let kind = null;
	if (ext === "json") {
		if (I18N_DIR_PATH.test(`/${dir}/`)) kind = "json";
	} else if (ext === "arb") {
		kind = "arb"; // Flutter : l'extension n'est utilisée que pour les ARB
	} else if (ext === "po" || ext === "pot") {
		kind = "po"; // GNU gettext
	} else if (ext === "ftl") {
		kind = "ftl"; // Project Fluent
	} else if (ext === "strings") {
		kind = "strings"; // Apple
	} else if (ext === "xml" && base === "strings.xml") {
		const last = dir.split("/").filter((s) => s && s !== ".").pop() ?? "";
		if (ANDROID_VALUES_RE.test(last)) kind = "android";
	} else if (ext === "properties") {
		// Bundles Java : sous un dossier i18n ou nommés messages*/MessagesBundle*
		if (I18N_DIR_PATH.test(`/${dir}/`) || I18N_FILE.test(base)) kind = "properties";
	} else if (ext === "ts" && I18N_DIR_PATH.test(`/${dir}/`)) {
		kind = "qt"; // Qt Linguist — jamais hors dossier i18n (collision avec .ts)
	}
	if (!kind) return null;

	return { rel, kind, group: groupOf(dir, base, kind) };
}

function groupOf(dir, base, kind) {
	const segs = dir.split("/").filter((s) => s && s !== ".");
	const last = segs[segs.length - 1] ?? "";
	if (kind === "android") {
		// res/values/strings.xml + res/values-fr/strings.xml → même groupe,
		// modules Android distincts → groupes distincts.
		const agnostic = dir.replace(/\/values(?:-[^/]*)?$/i, "");
		return `base:${agnostic}/${base}`;
	}
	if (/\.lproj$/i.test(last) || /^LC_MESSAGES$/i.test(last)) {
		// La locale est dans le dossier : *.lproj (Apple), <locale>/LC_MESSAGES (gettext)
		let idx = segs.length - 1;
		if (/^LC_MESSAGES$/i.test(segs[idx]) && idx > 0 && LOCALE_SEG.test(segs[idx - 1]))
			idx -= 1;
		return `base:${segs.slice(0, idx).join("/")}/${base}`;
	}
	if (LOCALE_SEG.test(last) && !I18N_SEG.test(last)) {
		// Dossiers par-locale : locales/fr/common.json vs locales/en/common.json
		return `dirbase:${segs.slice(0, -1).join("/")}/${base}`;
	}
	return `dir:${dir}`;
}

// ── Extraction des clés par format ──────────────────────────────────────────

function poKeys(content) {
	// GNU gettext : msgid (éventuellement msgid_plural), multiligne via "...".
	// L'en-tête du fichier (premier bloc déclaré `msgid ""`) est ignoré, même si
	// ses lignes de continuation l'alimentent (Content-Type, Plural-Forms…).
	// Un `msgctxt` préfixe la clé (contexte + EOT \x04, comme gettext) : deux
	// msgid identiques sous des contextes différents deviennent des clés distinctes
	// — pas de faux positif ni d'oubli masqué entre locales.
	const keys = new Set();
	let current = null; // msgid en cours (null = hors bloc)
	let plural = null; // msgid_plural du bloc (conserve le même contexte)
	let prefix = ""; // contexte capturé au début du bloc + "\u0004"
	let mode = null; // "context" | "msgid" | "plural" — lignes de continuation
	let first = true;
	let skipBlock = false; // seul le premier bloc `msgid ""` (en-tête) est ignoré
	const addId = (id) => {
		if (id !== null && id !== "" && !skipBlock) keys.add(`${prefix}${id}`);
	};
	const flush = () => {
		addId(current);
		addId(plural);
		current = null;
		plural = null;
		prefix = "";
		mode = null;
		skipBlock = false;
	};
	for (const raw of content.split("\n")) {
		const line = raw.trim();
		if (line.startsWith("msgctxt")) {
			flush(); // clôt tout bloc précédent (le msgctxt ouvre un nouveau contexte)
			const m = /^msgctxt\s*("(?:[^"\\]|\\.)*")?/.exec(line);
			const ctx = m?.[1] ? m[1].slice(1, -1) : "";
			prefix = ctx ? `${ctx}\u0004` : "";
			mode = "context";
			continue;
		}
		if (mode === "context" && line.startsWith('"')) {
			const m = /^"((?:[^"\\]|\\.)*)"/.exec(line);
			if (m) prefix = `${prefix ? prefix.slice(0, -1) : ""}${m[1]}\u0004`;
			continue;
		}
		if (/^msgid_plural/.test(line)) {
			// Même bloc que msgid : on ajoute le msgid capturé, le pluriel
			// garde le même préfixe de contexte.
			const m = /^msgid_plural\s*("(?:[^"\\]|\\.)*")?/.exec(line);
			addId(current);
			current = null;
			plural = m?.[1] ? m[1].slice(1, -1) : "";
			mode = "plural";
			continue;
		}
		if (/^msgid/.test(line)) {
			// Ne flushe que si un bloc est réellement ouvert : un msgctxt qui vient
			// d'être lu a déjà clos le précédent et ne doit pas effacer le préfixe.
			if (current !== null || plural !== null) flush();
			const m = /^msgid\s*("(?:[^"\\]|\\.)*")?/.exec(line);
			current = m?.[1] ? m[1].slice(1, -1) : "";
			mode = "msgid";
			if (first) {
				skipBlock = current === "";
				first = false;
			}
			continue;
		}
		if (mode !== null && line.startsWith('"')) {
			const m = /^"((?:[^"\\]|\\.)*)"/.exec(line);
			if (!m) continue;
			if (mode === "msgid") current += m[1];
			else if (mode === "plural") plural += m[1];
			continue;
		}
		if (/^msgstr/.test(line)) flush();
	}
	flush();
	return keys;
}

function ftlKeys(content) {
	// Project Fluent : identifiants de premier niveau `name = …` / `name { … }`.
	// Les attributs (`.attr`, indentés) et termes (`-name`) sont exclus.
	const keys = new Set();
	for (const line of content.split("\n")) {
		const m = /^([a-zA-Z][\w-]*)\s*(?:=|{)/.exec(line);
		if (m) keys.add(m[1]);
	}
	return keys;
}

function stringsKeys(content) {
	// Apple .strings : "clé" = "valeur";
	const keys = new Set();
	const re = /^\s*"((?:[^"\\]|\\.)*)"\s*=\s*"/gm;
	for (const m of content.matchAll(re)) keys.add(m[1]);
	return keys;
}

function xmlStringKeys(content) {
	// Android strings.xml : <string|string-array|plurals name="…">.
	const keys = new Set();
	const re = /<(?:string|string-array|plurals)\b[^>]*\bname="([^"]+)"/g;
	for (const m of content.matchAll(re)) keys.add(m[1]);
	return keys;
}

function propertiesKeys(content) {
	// Bundles Java .properties : key=value / key: value / key value,
	// continuations `\`, commentaires # et !.
	const keys = new Set();
	const flush = (logical) => {
		const sep = logical.search(/[=:]/);
		if (sep === -1) {
			const ws = logical.search(/\s/);
			keys.add((ws === -1 ? logical : logical.slice(0, ws)).trim());
		} else {
			keys.add(logical.slice(0, sep).trim());
		}
	};
	let pending = null;
	for (const raw of content.split("\n")) {
		const line = raw.replace(/\r$/, "");
		if (pending !== null) {
			pending += line;
			if (line.endsWith("\\")) {
				pending = pending.slice(0, -1);
				continue;
			}
			flush(pending);
			pending = null;
			continue;
		}
		const t = line.trim();
		if (!t || t.startsWith("#") || t.startsWith("!")) continue;
		if (line.endsWith("\\")) {
			pending = line.slice(0, -1);
			continue;
		}
		flush(line);
	}
	if (pending !== null) flush(pending);
	return keys;
}

function tsKeys(content) {
	// Qt Linguist .ts (XML) : les <source> sont les clés de traduction.
	const keys = new Set();
	const re = /<source>([^<]*)<\/source>/g;
	for (const m of content.matchAll(re)) {
		const k = m[1].trim();
		if (k) keys.add(k);
	}
	return keys;
}

// Aplatit un objet JSON en clés pointées : i18next/next-intl référencent
// `header.title` dans le code, pas l'objet imbriqué. Les tableaux restent
// des feuilles. `skipMeta` retire les méta-clés ARB (`@description`…).
function flattenKeys(obj, prefix, out, skipMeta) {
	for (const [k, v] of Object.entries(obj)) {
		if (skipMeta && k.startsWith("@")) continue;
		const path = prefix ? `${prefix}.${k}` : k;
		if (v !== null && typeof v === "object" && !Array.isArray(v)) {
			flattenKeys(v, path, out, skipMeta);
		} else {
			out.add(path);
		}
	}
	return out;
}

/** Extrait les clés de traduction d'un contenu selon le format détecté. */
export function extractKeys(content, kind) {
	switch (kind) {
		case "json":
			return flattenKeys(JSON.parse(content), "", new Set());
		case "arb":
			return flattenKeys(JSON.parse(content), "", new Set(), true);
		case "po":
			return poKeys(content);
		case "ftl":
			return ftlKeys(content);
		case "strings":
			return stringsKeys(content);
		case "android":
			return xmlStringKeys(content);
		case "properties":
			return propertiesKeys(content);
		case "qt":
			return tsKeys(content);
		default:
			return new Set();
	}
}

// ── Clés appelées dans le code source ───────────────────────────────────────

// Extensions de fichiers source parcourus (JS/TS, Vue, Svelte, Python, PHP).
const SOURCE_EXT = /\.(?:tsx?|jsx?|mjs|cjs|vue|svelte|py|php)$/i;
// Fichiers de test exclus : ils référencent volontairement des clés inexistantes.
const SOURCE_SKIP_NAME = /\.(?:test|spec)\.[a-z]+$/i;
const SOURCE_SKIP_DIR = new Set(["tests", "__tests__", "test"]);

// Appels i18n reconnus dans le code. Un argument littéral (chaîne simple) est
// la clé ; `pgettext('ctx', 'msg')` référence le msgctxt gettext (contexte + EOT).
const SRC_T = /\bt\(\s*['"]([^'"\n]+)['"]/g;
const SRC_I18N_T = /\bi18n\.t\(\s*['"]([^'"\n]+)['"]/g;
const SRC_GETTEXT = /\bgettext\(\s*['"]([^'"\n]+)['"]/g;
const SRC_NGETTEXT = /\bngettext\(\s*['"]([^'"\n]+)['"]\s*,\s*['"]([^'"\n]+)['"]/g;
const SRC_PGETTEXT = /\bpgettext\(\s*['"]([^'"\n]+)['"]\s*,\s*['"]([^'"\n]+)['"]/g;
const SRC_UNDERSCORE = /\b(?:_|__|N_)\(\s*['"]([^'"\n]+)['"]/g;
const SRC_FORMAT_MESSAGE = /\bformatMessage\(\s*\{\s*id\s*:\s*['"]([^'"\n]+)['"]/g;

/** Extrait les clés i18n référencées dans un fichier source. */
export function extractSourceKeys(content) {
	const keys = new Set();
	for (const m of content.matchAll(SRC_T)) keys.add(m[1]);
	for (const m of content.matchAll(SRC_I18N_T)) keys.add(m[1]);
	for (const m of content.matchAll(SRC_GETTEXT)) keys.add(m[1]);
	for (const m of content.matchAll(SRC_NGETTEXT)) {
		keys.add(m[1]);
		keys.add(m[2]);
	}
	for (const m of content.matchAll(SRC_PGETTEXT)) keys.add(`${m[1]}\u0004${m[2]}`);
	for (const m of content.matchAll(SRC_UNDERSCORE)) keys.add(m[1]);
	for (const m of content.matchAll(SRC_FORMAT_MESSAGE)) keys.add(m[1]);
	return keys;
}

/** True quand un chemin relatif pointe un fichier source analysable. */
export function isSourceFile(rel) {
	const clean = rel.replace(/^\.\//, "");
	const slash = clean.lastIndexOf("/");
	const base = slash === -1 ? clean : clean.slice(slash + 1);
	if (!SOURCE_EXT.test(base) || SOURCE_SKIP_NAME.test(base)) return false;
	const dirs = clean.split("/").slice(0, -1);
	return !dirs.some((d) => SOURCE_SKIP_DIR.has(d));
}

// Parcours natif des fichiers source (tests exclus, dossiers lourds sautés).
export function findSourceFiles(projectDir) {
	const out = [];
	const walk = (dir) => {
		let ents;
		try {
			ents = readdirSync(dir, { withFileTypes: true });
		} catch {
			return;
		}
		for (const ent of ents) {
			if (ent.isDirectory()) {
				if (SKIP_DIRS.has(ent.name) || SOURCE_SKIP_DIR.has(ent.name)) continue;
				walk(join(dir, ent.name));
			} else if (ent.isFile() && isSourceFile(ent.name)) {
				const rel = relative(projectDir, join(dir, ent.name))
					.split(sep)
					.join("/");
				out.push(`./${rel}`);
			}
		}
	};
	walk(projectDir);
	return out;
}

// Parcours natif (pas de spawn de shell) : rapide même sur un gros monorepo.
export function findTranslationFiles(projectDir) {
	const out = [];
	const walk = (dir) => {
		let ents;
		try {
			ents = readdirSync(dir, { withFileTypes: true });
		} catch {
			return;
		}
		for (const ent of ents) {
			if (ent.isDirectory()) {
				if (SKIP_DIRS.has(ent.name)) continue;
				walk(join(dir, ent.name));
			} else if (ent.isFile()) {
				const rel = relative(projectDir, join(dir, ent.name))
					.split(sep)
					.join("/");
				const classified = classifyFile(rel);
				if (classified) out.push({ ...classified, rel: `./${rel}` });
			}
		}
	};
	walk(projectDir);
	return out;
}

export function run({
	exec,
	readFile = readFileSync,
	projectDir = process.env.CLAUDE_PROJECT_DIR ?? process.cwd(),
} = {}) {
	// `exec` n'est utilisé que par les tests (mock) ; en production, parcours natif
	// (plus de spawn shell, donc plus de risque d'ETIMEDOUT sur un gros monorepo).
	// Ponytail: try/catch conservé — un mock qui throw ne doit pas crasher un Stop
	// hook non bloquant ; on rend la main silencieusement.
	let files;
	let sources;
	try {
		if (exec) {
			const lines = exec('find . -print')
				.split("\n")
				.map((f) => f.trim())
				.filter(Boolean);
			files = lines.map(classifyFile).filter(Boolean);
			sources = lines.filter(isSourceFile);
		} else {
			files = findTranslationFiles(projectDir);
			sources = findSourceFiles(projectDir);
		}
	} catch {
		return null;
	}

	if (files.length === 0) return null;
	if (files.length < 2 && sources.length === 0) return null;

	// Groupe par clé locale-agnostique et vérifie la cohérence des clés
	const byGroup = new Map();
	for (const f of files) {
		if (!byGroup.has(f.group)) byGroup.set(f.group, []);
		byGroup.get(f.group).push(f);
	}

	const issues = [];
	for (const group of byGroup.values()) {
		if (group.length < 2) continue;
		const parsed = group
			.map((f) => {
				try {
					return {
						rel: f.rel,
						keys: extractKeys(readFile(join(projectDir, f.rel), "utf8"), f.kind),
					};
				} catch {
					return null;
				}
			})
			.filter(Boolean);

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

	// Clés utilisées dans le code : elles doivent exister dans les traductions
	// (union de toutes les locales, tous formats). pgettext préfixe msgctxt+EOT.
	if (sources.length > 0) {
		const translationKeys = new Set();
		for (const f of files) {
			try {
				for (const k of extractKeys(
					readFile(join(projectDir, f.rel), "utf8"),
					f.kind,
				))
					translationKeys.add(k);
			} catch {
				// fichier illisible — ignoré
			}
		}
		if (translationKeys.size > 0) {
			const missing = new Set();
			for (const rel of sources) {
				try {
					for (const k of extractSourceKeys(
						readFile(join(projectDir, rel), "utf8"),
					)) {
						if (!translationKeys.has(k)) missing.add(k);
					}
				} catch {
					// fichier illisible — ignoré
				}
			}
			if (missing.size > 0) {
				const shown = [...missing].slice(0, 10).join(", ");
				issues.push(
					`Clé(s) du code absentes des fichiers de traduction (${missing.size}) : ${shown}${missing.size > 10 ? `… (+${missing.size - 10})` : ""}`,
				);
			}
		}
	}

	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);
}

Learn more

Related hooks