PSA/scripts/find-missing-i18n-keys.cjs
Hermes 284313f908
Some checks are pending
Bidi Control Character Guard / bidi-control-guard (push) Waiting to run
Circular Dependency Check / Check for new circular dependencies (push) Waiting to run
Citus Migration Smoke / Combined migrations on single-node Citus (push) Waiting to run
E2E Fresh Install Tests / fresh-install-e2e (push) Waiting to run
ext-v2 guardrails / Run ext-v2 guard and ESLint (push) Waiting to run
Integration Tests / Check for relevant changes (push) Waiting to run
Integration Tests / ${{ (github.event_name == 'schedule' || github.event.inputs.suite == 'full') && 'Full integration suite' || 'Tier-1 integration subset' }} (push) Blocked by required conditions
Mobile checks / Mobile lint + typecheck (push) Waiting to run
Mobile checks / Mobile unit tests (push) Waiting to run
Mobile checks / Mobile dependency audit (report) (push) Waiting to run
Mobile checks / Mobile reproducibility checks (push) Waiting to run
Secrets guard (env backups) / Ensure no tracked env backup files (push) Waiting to run
Temporal Readiness / fast-readiness (push) Waiting to run
Temporal Readiness / docker-parity (push) Waiting to run
TypeScript Type Check / Nx affected typecheck (push) Waiting to run
Unit Tests / Skipped-test budget (push) Waiting to run
Unit Tests / Nx affected unit tests (push) Waiting to run
Unit Tests / Server unit coverage (informational) (push) Waiting to run
Validate Tenant Management Schema / Check for relevant changes (push) Waiting to run
Validate Tenant Management Schema / Validate Tenant Management Schema (push) Blocked by required conditions
EE Workflows Build Guard / ee-workflows-build-guard (push) Waiting to run
Initial import of AlgaPSA codebase from PSA server
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz

Source: /opt/alga-psa on psa.joliet.tech
2026-06-22 16:12:17 -05:00

152 lines
5.0 KiB
JavaScript

#!/usr/bin/env node
/**
* Scan TS/TSX sources for t('key', ...) calls and report keys whose namespace
* cannot resolve them in the English locale tree.
*
* Heuristics:
* - For each .ts/.tsx file with a `useTranslation('<ns>')` call, capture the
* namespace and any subsequent `t('foo.bar')` / `t("foo.bar")` calls.
* - Resolve `<ns>` against server/public/locales/en/<ns>.json. If the dotted
* key is missing, print the file:line:key.
*
* Usage:
* node scripts/find-missing-i18n-keys.cjs [path ...]
* defaults to scanning the workspace.
*/
const fs = require('fs');
const path = require('path');
const REPO = path.resolve(__dirname, '..');
const LOCALES = path.join(REPO, 'server/public/locales/en');
const args = process.argv.slice(2);
const SCAN_ROOTS = args.length
? args.map((p) => path.resolve(p))
: [
path.join(REPO, 'server/src'),
path.join(REPO, 'packages'),
path.join(REPO, 'ee/server/src'),
];
const SKIP_DIRS = new Set([
'node_modules', '.next', 'dist', 'build', '.turbo', 'coverage',
]);
// Test files assert on key strings (contract tests grep source for t('…')
// literals) without being runtime translation calls — skip them.
const RE_TEST_FILE = /(\.test\.|\.spec\.|\/__tests__\/|\/src\/test\/)/;
function walk(target, out = []) {
if (!fs.existsSync(target)) return out;
const stat = fs.statSync(target);
if (stat.isFile()) {
if (/\.tsx?$/.test(target) && !/\.d\.ts$/.test(target)) out.push(target);
return out;
}
for (const entry of fs.readdirSync(target, { withFileTypes: true })) {
if (SKIP_DIRS.has(entry.name)) continue;
const full = path.join(target, entry.name);
if (entry.isDirectory()) walk(full, out);
else if (/\.tsx?$/.test(entry.name) && !/\.d\.ts$/.test(entry.name)) {
out.push(full);
}
}
return out;
}
const localeCache = new Map();
function loadLocale(ns) {
if (localeCache.has(ns)) return localeCache.get(ns);
const file = path.join(LOCALES, `${ns}.json`);
let data = null;
if (fs.existsSync(file)) {
try { data = JSON.parse(fs.readFileSync(file, 'utf8')); }
catch { data = null; }
}
localeCache.set(ns, data);
return data;
}
function hasKey(obj, dotted) {
if (!obj) return false;
let cur = obj;
for (const part of dotted.split('.')) {
if (cur && typeof cur === 'object' && part in cur) cur = cur[part];
else return false;
}
// Leaf must be a string (or array, occasionally) — not an unresolved branch.
return typeof cur === 'string' || Array.isArray(cur);
}
// i18next v4 stores count-based keys with CLDR plural suffixes; a t('key',
// { count }) call resolves via key_one/key_other/…, so accept those too.
const PLURAL_SUFFIXES = ['zero', 'one', 'two', 'few', 'many', 'other'];
function resolvesKey(obj, dotted) {
if (hasKey(obj, dotted)) return true;
return PLURAL_SUFFIXES.some((s) => hasKey(obj, `${dotted}_${s}`));
}
const RE_USE_TRANSLATION = /useTranslation\(\s*['"`]([^'"`]+)['"`]/g;
// t('key'…) or t("key"…) — skip template strings (dynamic keys).
const RE_T_CALL = /(?<![A-Za-z0-9_$])t\(\s*['"]([^'"]+)['"]/g;
const findings = [];
for (const root of SCAN_ROOTS) {
for (const file of walk(root)) {
if (RE_TEST_FILE.test(file)) continue;
const src = fs.readFileSync(file, 'utf8');
if (!src.includes('useTranslation') && !/(?<![A-Za-z0-9_$])t\(/.test(src)) continue;
// Collect namespaces declared in this file.
const nsList = [];
let m;
RE_USE_TRANSLATION.lastIndex = 0;
while ((m = RE_USE_TRANSLATION.exec(src))) nsList.push(m[1]);
if (!nsList.length) continue;
// Collect t() keys with line numbers.
RE_T_CALL.lastIndex = 0;
while ((m = RE_T_CALL.exec(src))) {
const key = m[1];
// Skip obvious non-i18n calls: keys with whitespace or no dots and short.
if (!/^[A-Za-z0-9_.:-]+$/.test(key)) continue;
// Skip trailing-dot prefixes (dynamic keys / contract-test assertions).
if (key.endsWith('.')) continue;
const before = src.slice(0, m.index);
const line = before.split('\n').length;
// Try every namespace declared in the file; pass if any resolves.
const resolved = nsList.some((ns) => resolvesKey(loadLocale(ns), key));
if (!resolved) {
findings.push({ file: path.relative(REPO, file), line, key, namespaces: nsList });
}
}
}
}
if (!findings.length) {
console.log('No missing English keys detected.');
process.exit(0);
}
// Group by (namespace,key) for a clean summary.
const byKey = new Map();
for (const f of findings) {
const id = `${f.namespaces.join('|')}::${f.key}`;
if (!byKey.has(id)) byKey.set(id, { ...f, refs: [] });
byKey.get(id).refs.push(`${f.file}:${f.line}`);
}
console.log(`Missing English locale keys: ${byKey.size}\n`);
const sorted = [...byKey.values()].sort((a, b) => a.key.localeCompare(b.key));
for (const f of sorted) {
console.log(` [${f.namespaces.join(', ')}] ${f.key}`);
for (const ref of f.refs.slice(0, 3)) console.log(` ${ref}`);
if (f.refs.length > 3) console.log(`${f.refs.length - 3} more`);
}
process.exit(1);