mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
feat(cli): fase 8.3 — i18n completude e linter check-cli-i18n
- Adiciona health.description e health.noServer em en.json e pt-BR.json
- scripts/check/check-cli-i18n.mjs: valida que todas as 567 chaves t() dos
comandos existem em en.json e que pt-BR.json tem as mesmas seções top-level
- Adiciona check-cli-i18n ao hook pre-commit
- 7 testes unitários: completude do catálogo, detecção de locale (OMNIROUTE_LANG),
fallback en, interpolação {var}, t() pt-BR
This commit is contained in:
@@ -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."
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
103
scripts/check/check-cli-i18n.mjs
Normal file
103
scripts/check/check-cli-i18n.mjs
Normal file
@@ -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");
|
||||
}
|
||||
124
tests/unit/cli-i18n-catalog.test.ts
Normal file
124
tests/unit/cli-i18n-catalog.test.ts
Normal file
@@ -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<string, unknown>, prefix = ""): Set<string> {
|
||||
const keys = new Set<string>();
|
||||
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<string, unknown>, 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<string> {
|
||||
const used = new Set<string>();
|
||||
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<string, unknown>);
|
||||
|
||||
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();
|
||||
});
|
||||
Reference in New Issue
Block a user