diff --git a/.husky/pre-commit b/.husky/pre-commit index 1bfbc29d10..f23acbf847 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -12,6 +12,9 @@ npm run check:any-budget:t11 # Strict env-doc sync (FASE 2) node scripts/check/check-env-doc-sync.mjs +# CLI i18n consistency check — all t() keys must exist in en.json (FASE 8.3) +node scripts/check/check-cli-i18n.mjs + # i18n docs drift advisory (FASE 5) — warn-only on pre-commit; CI enforces strict. node scripts/i18n/check-translation-drift.mjs --warn || \ echo "⚠️ i18n drift detected. Run 'npm run i18n:run' to update locale mirrors." diff --git a/bin/cli/locales/en.json b/bin/cli/locales/en.json index c572218e2a..41791e8ce6 100644 --- a/bin/cli/locales/en.json +++ b/bin/cli/locales/en.json @@ -192,6 +192,8 @@ "confirmRestore": "Overwrite current data with backup from {ts}?" }, "health": { + "description": "Check server health and component status", + "noServer": "Server not running. Start with: omniroute serve", "title": "Health", "status": "Status: {status}", "uptime": "Uptime: {uptime}", diff --git a/bin/cli/locales/pt-BR.json b/bin/cli/locales/pt-BR.json index 7a9adc8fa4..352b1bb20b 100644 --- a/bin/cli/locales/pt-BR.json +++ b/bin/cli/locales/pt-BR.json @@ -192,6 +192,8 @@ "confirmRestore": "Substituir dados atuais pelo backup de {ts}?" }, "health": { + "description": "Verificar saúde do servidor e status dos componentes", + "noServer": "Servidor não está em execução. Inicie com: omniroute serve", "title": "Saúde", "status": "Status: {status}", "uptime": "Uptime: {uptime}", diff --git a/scripts/check/check-cli-i18n.mjs b/scripts/check/check-cli-i18n.mjs new file mode 100644 index 0000000000..3503adb197 --- /dev/null +++ b/scripts/check/check-cli-i18n.mjs @@ -0,0 +1,103 @@ +#!/usr/bin/env node +/** + * Validates that: + * 1. All t("key") calls in bin/cli/commands/ resolve to existing keys in en.json. + * 2. pt-BR.json has the same top-level shape as en.json (no missing top-level sections). + * 3. No raw string literals are passed to .description() in commands without going + * through t() — only warns, does not fail hard (many descriptions use || fallback). + */ +import { readFileSync, readdirSync, statSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(__dirname, "..", ".."); +const COMMANDS_DIR = join(ROOT, "bin", "cli", "commands"); +const LOCALES_DIR = join(ROOT, "bin", "cli", "locales"); + +// Paths that look like t() keys but are actually import paths — skip them. +const IGNORE_AS_KEY = new Set([".", ".."]); +const IMPORT_PATH_RE = /^(\.\.?\/|node:|\/)/; + +function walk(dir) { + const results = []; + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + if (statSync(full).isDirectory()) { + results.push(...walk(full)); + } else if (entry.endsWith(".mjs") || entry.endsWith(".js")) { + results.push(full); + } + } + return results; +} + +function flattenKeys(obj, prefix = "") { + const keys = new Set(); + for (const [k, v] of Object.entries(obj)) { + const full = prefix ? `${prefix}.${k}` : k; + if (v !== null && typeof v === "object" && !Array.isArray(v)) { + for (const sub of flattenKeys(v, full)) keys.add(sub); + } else { + keys.add(full); + } + } + return keys; +} + +function collectTKeys(files) { + const used = new Set(); + const re = /\bt\(\s*["']([^"']+)["']/g; + for (const file of files) { + const src = readFileSync(file, "utf8"); + let m; + re.lastIndex = 0; + while ((m = re.exec(src)) !== null) { + const key = m[1]; + if (IGNORE_AS_KEY.has(key) || IMPORT_PATH_RE.test(key)) continue; + used.add(key); + } + } + return used; +} + +function loadJson(file) { + return JSON.parse(readFileSync(file, "utf8")); +} + +const files = walk(COMMANDS_DIR); +const usedKeys = collectTKeys(files); +const en = loadJson(join(LOCALES_DIR, "en.json")); +const ptBR = loadJson(join(LOCALES_DIR, "pt-BR.json")); +const enKeys = flattenKeys(en); + +let errors = 0; + +// Check 1: all used keys exist in en.json +const missingInEn = [...usedKeys].filter((k) => !enKeys.has(k)); +if (missingInEn.length > 0) { + console.error("[cli-i18n] Keys used in commands but missing in en.json:"); + for (const k of missingInEn) console.error(` ✗ ${k}`); + errors += missingInEn.length; +} else { + console.log(`[cli-i18n] ✓ All ${usedKeys.size} t() keys found in en.json`); +} + +// Check 2: pt-BR.json has the same top-level sections as en.json +const enTopLevel = Object.keys(en); +const ptTopLevel = new Set(Object.keys(ptBR)); +const missingTopLevel = enTopLevel.filter((k) => !ptTopLevel.has(k)); +if (missingTopLevel.length > 0) { + console.error("[cli-i18n] Top-level sections in en.json missing from pt-BR.json:"); + for (const k of missingTopLevel) console.error(` ✗ ${k}`); + errors += missingTopLevel.length; +} else { + console.log(`[cli-i18n] ✓ pt-BR.json has all ${enTopLevel.length} top-level sections`); +} + +if (errors > 0) { + console.error(`[cli-i18n] FAIL — ${errors} error(s) found`); + process.exit(1); +} else { + console.log("[cli-i18n] PASS — CLI i18n is consistent"); +} diff --git a/tests/unit/cli-i18n-catalog.test.ts b/tests/unit/cli-i18n-catalog.test.ts new file mode 100644 index 0000000000..2bd987dbe1 --- /dev/null +++ b/tests/unit/cli-i18n-catalog.test.ts @@ -0,0 +1,124 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; +import { readFileSync, readdirSync, statSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(__dirname, "..", ".."); +const require = createRequire(import.meta.url); +const en = require("../../bin/cli/locales/en.json"); +const ptBR = require("../../bin/cli/locales/pt-BR.json"); + +function flattenKeys(obj: Record, prefix = ""): Set { + const keys = new Set(); + for (const [k, v] of Object.entries(obj)) { + const full = prefix ? `${prefix}.${k}` : k; + if (v !== null && typeof v === "object" && !Array.isArray(v)) { + for (const sub of flattenKeys(v as Record, full)) keys.add(sub); + } else { + keys.add(full); + } + } + return keys; +} + +function walkMjs(dir: string): string[] { + const results: string[] = []; + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + if (statSync(full).isDirectory()) { + results.push(...walkMjs(full)); + } else if (entry.endsWith(".mjs") || entry.endsWith(".js")) { + results.push(full); + } + } + return results; +} + +const IMPORT_PATH_RE = /^(\.\.?\/|node:|\/)/; +const IGNORE_AS_KEY = new Set([".", ".."]); + +function collectTKeys(files: string[]): Set { + const used = new Set(); + const re = /\bt\(\s*["']([^"']+)["']/g; + for (const file of files) { + const src = readFileSync(file, "utf8"); + re.lastIndex = 0; + let m; + while ((m = re.exec(src)) !== null) { + const key = m[1]; + if (IGNORE_AS_KEY.has(key) || IMPORT_PATH_RE.test(key)) continue; + used.add(key); + } + } + return used; +} + +const commandFiles = walkMjs(join(ROOT, "bin", "cli", "commands")); +const usedKeys = collectTKeys(commandFiles); +const enKeys = flattenKeys(en as Record); + +test("en.json contém todas as chaves usadas via t() nos comandos", () => { + const missing = [...usedKeys].filter((k) => !enKeys.has(k)); + assert.deepEqual(missing, [], `Chaves faltando em en.json: ${missing.join(", ")}`); +}); + +test("pt-BR.json tem todas as seções top-level de en.json", () => { + const enTop = Object.keys(en as object); + const ptTop = new Set(Object.keys(ptBR as object)); + const missing = enTop.filter((k) => !ptTop.has(k)); + assert.deepEqual(missing, [], `Seções top-level faltando em pt-BR.json: ${missing.join(", ")}`); +}); + +test("i18n.mjs detecta locale por OMNIROUTE_LANG", async () => { + const { resetForTests, detectLocale } = await import("../../bin/cli/i18n.mjs"); + const orig = process.env.OMNIROUTE_LANG; + process.env.OMNIROUTE_LANG = "pt-BR"; + resetForTests(); + const locale = detectLocale(); + assert.equal(locale, "pt-BR"); + if (orig === undefined) delete process.env.OMNIROUTE_LANG; + else process.env.OMNIROUTE_LANG = orig; + resetForTests(); +}); + +test("i18n.mjs usa fallback en quando locale não existe", async () => { + const { resetForTests, detectLocale } = await import("../../bin/cli/i18n.mjs"); + const orig = process.env.OMNIROUTE_LANG; + process.env.OMNIROUTE_LANG = "xx-FAKE"; + resetForTests(); + const locale = detectLocale(); + assert.equal(locale, "en"); + if (orig === undefined) delete process.env.OMNIROUTE_LANG; + else process.env.OMNIROUTE_LANG = orig; + resetForTests(); +}); + +test("t() interpola variáveis {var}", async () => { + const { resetForTests, t, setLocale } = await import("../../bin/cli/i18n.mjs"); + resetForTests(); + setLocale("en"); + const result = t("health.status", { status: "OK" }); + assert.equal(result, "Status: OK"); + resetForTests(); +}); + +test("t() retorna a chave quando não existe no catálogo", async () => { + const { resetForTests, t, setLocale } = await import("../../bin/cli/i18n.mjs"); + resetForTests(); + setLocale("en"); + const result = t("does.not.exist.at.all"); + assert.equal(result, "does.not.exist.at.all"); + resetForTests(); +}); + +test("t() usa pt-BR quando disponível", async () => { + const { resetForTests, t, setLocale } = await import("../../bin/cli/i18n.mjs"); + resetForTests(); + setLocale("pt-BR"); + const result = t("health.noServer"); + assert.ok(result.includes("omniroute serve"), `Esperava mensagem pt-BR, obteve: ${result}`); + resetForTests(); +});