refactor(i18n): harden key checker per review

- Validate static t() keys even under a dynamic prefix (dynamic prefixes now
  only affect the unused calc, not missing) — Copilot
- Strip JS/TS/JSX comments before scanning so keys in comments aren't counted
  as usage — CodeRabbit
- Run i18n:check as part of \un run test\ so local mirrors CI — CodeRabbit
- Fix stale 'all locale files' comment (fixer is source-only) — Copilot
This commit is contained in:
Gauvino
2026-06-01 12:15:22 +02:00
parent 5d3648e875
commit ac2bb104e4
2 changed files with 22 additions and 15 deletions

View File

@@ -24,7 +24,7 @@
"doctor": "expo-doctor", "doctor": "expo-doctor",
"i18n:check": "bun scripts/check-i18n-keys.mjs", "i18n:check": "bun scripts/check-i18n-keys.mjs",
"i18n:fix-unused": "bun scripts/check-i18n-keys.mjs --fix-unused", "i18n:fix-unused": "bun scripts/check-i18n-keys.mjs --fix-unused",
"test": "bun run typecheck && bun run lint && bun run format && bun run doctor", "test": "bun run typecheck && bun run lint && bun run format && bun run i18n:check && bun run doctor",
"postinstall": "patch-package" "postinstall": "patch-package"
}, },
"dependencies": { "dependencies": {

View File

@@ -132,22 +132,32 @@ const KEY_SHAPE = /^[A-Za-z0-9_]+(\.[A-Za-z0-9_]+)+$/; // dotted key, e.g. home.
const usedStatic = new Set(); // keys passed to t(...) / i18nKey — used for MISSING detection const usedStatic = new Set(); // keys passed to t(...) / i18nKey — used for MISSING detection
const dynamicPrefixes = new Set(); const dynamicPrefixes = new Set();
const fullyDynamic = []; // { file, line } const fullyDynamic = []; // { file, line }
let codeBlob = ""; // all source text — searched for delimited key literals let codeBlob = ""; // all (comment-stripped) source text — searched for delimited key literals
// Strip comments so keys mentioned in comments (e.g. `// t("old.key")`) are not counted as
// usage. Block comments and JSX {/* */} are blanked (preserving newlines for line numbers);
// line comments are only stripped when `//` follows start/whitespace/punctuation, which keeps
// `://` inside string URLs intact.
const stripComments = (src) =>
src
.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " "))
.replace(/(^|[\s;{}()[\],=>])\/\/[^\n]*/g, (_m, p) => p);
const files = config.srcDirs.flatMap((d) => const files = config.srcDirs.flatMap((d) =>
walk(join(ROOT, d === "." ? "" : d) || ROOT), walk(join(ROOT, d === "." ? "" : d) || ROOT),
); );
for (const file of files) { for (const file of files) {
const text = readFileSync(file, "utf8"); const text = readFileSync(file, "utf8");
codeBlob += `\n${text}`; const clean = stripComments(text);
for (const m of text.matchAll(STATIC_RE)) usedStatic.add(m[2]); codeBlob += `\n${clean}`;
for (const m of text.matchAll(TPL_STATIC_RE)) usedStatic.add(m[1]); for (const m of clean.matchAll(STATIC_RE)) usedStatic.add(m[2]);
for (const m of text.matchAll(I18NKEY_RE)) usedStatic.add(m[2]); for (const m of clean.matchAll(TPL_STATIC_RE)) usedStatic.add(m[1]);
for (const m of text.matchAll(TPL_DYN_RE)) { for (const m of clean.matchAll(I18NKEY_RE)) usedStatic.add(m[2]);
for (const m of clean.matchAll(TPL_DYN_RE)) {
const prefix = m[1]; const prefix = m[1];
if (prefix?.includes(".")) dynamicPrefixes.add(prefix); if (prefix?.includes(".")) dynamicPrefixes.add(prefix);
else { else {
const idx = text.slice(0, m.index).split("\n").length; const idx = clean.slice(0, m.index).split("\n").length;
fullyDynamic.push({ file: relative(ROOT, file), line: idx }); fullyDynamic.push({ file: relative(ROOT, file), line: idx });
} }
} }
@@ -169,16 +179,13 @@ const isUsed = (key) =>
// ---- compute ---- // ---- compute ----
const unused = sourceKeys.filter((k) => !isUsed(k)).sort(); const unused = sourceKeys.filter((k) => !isUsed(k)).sort();
// Static references are always validated, even under a dynamic prefix: a dynamic prefix only
// affects the UNUSED calculation, never MISSING.
const missing = [...usedStatic] const missing = [...usedStatic]
.filter( .filter((k) => KEY_SHAPE.test(k) && !sourceKeySet.has(k))
(k) =>
KEY_SHAPE.test(k) &&
!sourceKeySet.has(k) &&
!prefixList.some((p) => k.startsWith(p)),
)
.sort(); .sort();
// ---- optional fix: strip dead keys from all locale files ---- // ---- optional fix: strip dead keys from the source locale (en.json) ----
const removeKey = (obj, parts) => { const removeKey = (obj, parts) => {
const [head, ...rest] = parts; const [head, ...rest] = parts;
if (!(head in obj)) return; if (!(head in obj)) return;