#!/usr/bin/env node /** * OmniRoute — UI i18n key coverage gate. * * Compares every `src/i18n/messages/.json` against `en.json` and * reports: * - total_en: total leaves in en.json * - present: leaves present in the locale (any shape match) * - missing: leaves absent in the locale * - placeholder: leaves whose value starts with __MISSING__: * - coverage: (present - placeholder) / total_en * 100 * * Usage: * npm run i18n:check-ui-coverage # threshold 80, fail on drop * npm run i18n:check-ui-coverage -- --threshold=75 * npm run i18n:check-ui-coverage -- --report # informational tabular output * npm run i18n:check-ui-coverage -- --json # machine-readable report * * Exits 1 when any locale falls below `--threshold` (default 80), unless * `--report` is set, in which case the table is printed and exit code is 0. */ import { promises as fs, existsSync } from "node:fs"; import path from "node:path"; import process from "node:process"; import { fileURLToPath, pathToFileURL } from "node:url"; const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.resolve(SCRIPT_DIR, "..", ".."); const MESSAGES_DIR = path.join(ROOT, "src", "i18n", "messages"); const CONFIG_PATH = path.join(ROOT, "config", "i18n.json"); const SOURCE_LOCALE = "en"; const PLACEHOLDER_PREFIX = "__MISSING__:"; function logInfo(...parts) { console.log("[i18n-ui-coverage]", ...parts); } function logWarn(...parts) { console.warn("[i18n-ui-coverage] WARN", ...parts); } function parseArgs(argv) { const opts = { threshold: 80, report: false, json: false }; for (const arg of argv.slice(2)) { if (arg.startsWith("--threshold=")) { opts.threshold = Number(arg.slice(12)); } else if (arg === "--report") { opts.report = true; } else if (arg === "--json") { opts.json = true; } else if (arg === "--help" || arg === "-h") { console.log( [ "Usage: node scripts/i18n/check-ui-keys-coverage.mjs [options]", "", " --threshold= Minimum coverage % for every locale (default 80)", " --report Print full coverage table, exit 0 regardless", " --json Emit JSON report to stdout", ].join("\n") ); process.exit(0); } } if (!Number.isFinite(opts.threshold) || opts.threshold < 0 || opts.threshold > 100) { throw new Error(`Invalid --threshold value: ${opts.threshold}`); } return opts; } async function loadJson(filePath) { const raw = await fs.readFile(filePath, "utf8"); return JSON.parse(raw); } function isPlainObject(value) { return value !== null && typeof value === "object" && !Array.isArray(value); } function collectLeafPaths(obj, prefix = []) { const paths = []; for (const [key, value] of Object.entries(obj)) { const next = [...prefix, key]; if (isPlainObject(value)) { paths.push(...collectLeafPaths(value, next)); } else { paths.push(next); } } return paths; } // Reject any segment that could traverse into the object prototype chain. This // is defensive — our inputs are JSON files we authored, but the static // scanner correctly flags any dynamic indexing with untrusted-looking keys. const FORBIDDEN_KEY_SEGMENTS = new Set(["__proto__", "prototype", "constructor"]); function lookupPath(obj, parts) { let cur = obj; for (const part of parts) { if (!isPlainObject(cur)) return undefined; if (FORBIDDEN_KEY_SEGMENTS.has(part)) return undefined; if (!Object.prototype.hasOwnProperty.call(cur, part)) return undefined; // Use Map-like get via Object.entries to avoid dynamic bracket access // patterns the static analyzer warns about. We already validated the key // exists as an own property above. const entry = Object.entries(cur).find(([k]) => k === part); cur = entry ? entry[1] : undefined; } return cur; } function pad(str, width) { const s = String(str); return s.length >= width ? s : s + " ".repeat(width - s.length); } function padLeft(str, width) { const s = String(str); return s.length >= width ? s : " ".repeat(width - s.length) + s; } async function main() { const opts = parseArgs(process.argv); const sourcePath = path.join(MESSAGES_DIR, `${SOURCE_LOCALE}.json`); if (!existsSync(sourcePath)) { throw new Error(`Source messages file not found: ${sourcePath}`); } const source = await loadJson(sourcePath); const enPaths = collectLeafPaths(source); const totalEn = enPaths.length; // Locale set: every .json on disk except en, intersected with config. let configCodes = null; if (existsSync(CONFIG_PATH)) { try { const cfg = JSON.parse(await fs.readFile(CONFIG_PATH, "utf8")); if (Array.isArray(cfg.locales)) { configCodes = new Set(cfg.locales.map((l) => l.code)); } } catch { /* ignore — fall back to disk listing only */ } } const onDisk = (await fs.readdir(MESSAGES_DIR)) .filter((f) => f.endsWith(".json") && f !== `${SOURCE_LOCALE}.json`) .map((f) => f.slice(0, -5)) .filter((code) => (configCodes ? configCodes.has(code) : true)) .sort(); const results = []; for (const locale of onDisk) { const localePath = path.join(MESSAGES_DIR, `${locale}.json`); let target; try { target = await loadJson(localePath); } catch (err) { logWarn(`${locale}: failed to parse JSON (${err.message})`); results.push({ locale, total_en: totalEn, present: 0, missing: totalEn, placeholder: 0, coverage: 0, }); continue; } let present = 0; let missing = 0; let placeholder = 0; for (const pathParts of enPaths) { const value = lookupPath(target, pathParts); if (value === undefined) { missing++; continue; } // Treat plain objects at scalar positions as missing — shape mismatch. if (isPlainObject(value)) { missing++; continue; } present++; if (typeof value === "string" && value.startsWith(PLACEHOLDER_PREFIX)) { placeholder++; } } const real = present - placeholder; const coverage = totalEn === 0 ? 100 : (real / totalEn) * 100; results.push({ locale, total_en: totalEn, present, missing, placeholder, coverage }); } const failures = results.filter((r) => r.coverage < opts.threshold); if (opts.json) { process.stdout.write( JSON.stringify( { source: SOURCE_LOCALE, totalKeys: totalEn, threshold: opts.threshold, ok: failures.length === 0, results, }, null, 2 ) + "\n" ); if (failures.length && !opts.report) process.exit(1); return; } // Human-readable output (table). const localeW = Math.max(8, ...results.map((r) => r.locale.length)); const header = pad("locale", localeW) + " " + padLeft("coverage", 10) + " " + padLeft("real", 6) + " " + padLeft("present", 7) + " " + padLeft("missing", 7) + " " + padLeft("placeholder", 11) + " " + padLeft("total_en", 8); console.log(header); console.log("-".repeat(header.length)); for (const r of results) { const real = r.present - r.placeholder; const pct = `${r.coverage.toFixed(1)}%`; const marker = r.coverage < opts.threshold ? " ✗" : ""; console.log( pad(r.locale, localeW) + " " + padLeft(pct, 10) + " " + padLeft(real, 6) + " " + padLeft(r.present, 7) + " " + padLeft(r.missing, 7) + " " + padLeft(r.placeholder, 11) + " " + padLeft(r.total_en, 8) + marker ); } if (failures.length) { if (opts.report) { logInfo( `${failures.length} locale(s) below threshold ${opts.threshold}% — report mode, exiting 0.` ); } else { logInfo(`FAIL — ${failures.length} locale(s) below threshold ${opts.threshold}%.`); for (const f of failures) { console.log(` - ${f.locale}: ${f.coverage.toFixed(1)}%`); } process.exit(1); } } else { logInfo(`PASS — all ${results.length} locale(s) at or above ${opts.threshold}% coverage.`); } } const isDirectRun = import.meta.url === pathToFileURL(process.argv[1]).href; if (isDirectRun) { main().catch((err) => { console.error("[i18n-ui-coverage] ERROR", err?.stack || err?.message || String(err)); process.exit(1); }); }