From 083ebf8134f29188cac8bb8884d735c8bbcfab11 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 20 Jun 2026 06:46:35 -0700 Subject: [PATCH] FN-6780: make i18n status check key parity Make i18n status enforce catalog key parity while allowing empty fallback placeholders. - Add a reusable i18n parity checker that reports missing and orphaned keys across secondary locales. - Route pnpm i18n:status through the project parity gate and keep the upstream completeness report as i18n:status:report. - Document that empty secondary locale strings are valid fallback placeholders and cover parity behavior with tests. Files changed: docs/i18n-contributing.md | 18 +++- i18next.config.ts | 13 ++- package.json | 3 +- packages/i18n/scripts/check-i18n-parity.mjs | 110 +++++++++++++++++++++++ packages/i18n/src/__tests__/config.test.ts | 2 +- packages/i18n/src/__tests__/parity.test.ts | 130 ++++++++++++++++++++++++++++ packages/i18n/src/parity.ts | 126 +++++++++++++++++++++++++++ 7 files changed, 393 insertions(+), 9 deletions(-) Fusion-Task-Id: FN-6780 Fusion-Task-Lineage: f687586a-bc09-4f9d-843f-b7205eb40a58 --- docs/i18n-contributing.md | 18 ++- i18next.config.ts | 13 +- package.json | 3 +- packages/i18n/scripts/check-i18n-parity.mjs | 110 +++++++++++++++++ packages/i18n/src/__tests__/config.test.ts | 2 +- packages/i18n/src/__tests__/parity.test.ts | 130 ++++++++++++++++++++ packages/i18n/src/parity.ts | 126 +++++++++++++++++++ 7 files changed, 393 insertions(+), 9 deletions(-) create mode 100644 packages/i18n/scripts/check-i18n-parity.mjs create mode 100644 packages/i18n/src/__tests__/parity.test.ts create mode 100644 packages/i18n/src/parity.ts diff --git a/docs/i18n-contributing.md b/docs/i18n-contributing.md index 5a617fd94a..e19544f56a 100644 --- a/docs/i18n-contributing.md +++ b/docs/i18n-contributing.md @@ -26,11 +26,20 @@ All commands run from the repo root: pnpm i18n:extract # pull t()/ keys from source into the en catalogs pnpm i18n:sync # propagate the en key structure to every other locale pnpm i18n:types # regenerate key types from the en catalogs -pnpm i18n:status # per-locale completion report +pnpm i18n:status # key-parity gate: structure only, empty values allowed +pnpm i18n:status:report # upstream completeness report (informational; may exit non-zero) pnpm i18n:lint # flag hardcoded user-facing strings pnpm i18n:gen-cli # regenerate the CLI static catalog import map ``` +`pnpm i18n:status` must be green before landing catalog or extraction changes, +but it only verifies that every secondary catalog has the same structural keys as +`en`. Empty secondary-locale values (`""`) are expected placeholders and do not +fail the gate because runtime falls back to English. Use +`pnpm i18n:status:report` when you want the upstream completeness report that +counts empty placeholders as untranslated; that report is informational and may +exit non-zero until translations are filled. + ## Lint baseline policy `pnpm i18n:lint` is the hardcoded user-facing string guardrail and must stay @@ -53,7 +62,8 @@ and has a filed follow-up task that removes the ignore. 3. Keep interpolation placeholders verbatim: `{{brand}}`, `{{detail}}`, `{{key}}`. Never translate a `[{{key}}]` keybinding accelerator — only the words around it. -4. `pnpm i18n:status` to confirm the locale is complete. +4. Run `pnpm i18n:status` to confirm key parity still holds, then optionally run + `pnpm i18n:status:report` to inspect remaining untranslated placeholders. `zh-CN` and `zh-TW` are independent — different script **and** vocabulary. Do not machine-convert one into the other. @@ -65,7 +75,9 @@ not machine-convert one into the other. 2. `pnpm i18n:sync` — scaffolds a full set of catalog files for the new locale with the correct plural categories. 3. `pnpm i18n:gen-cli` — adds the locale to the CLI's static import map. -4. Translate the new catalogs, then `pnpm i18n:status` to verify. +4. Run `pnpm i18n:status` to verify the new locale has the same key structure as + `en`. Translate the new catalogs as time allows, using + `pnpm i18n:status:report` as the informational completeness report. No feature code changes are required: the dashboard discovers the locale through the generated `app/locales/` tree, and the CLI through the regenerated import diff --git a/i18next.config.ts b/i18next.config.ts index 1794aef2a7..a16ffa22c9 100644 --- a/i18next.config.ts +++ b/i18next.config.ts @@ -52,11 +52,16 @@ const DEFERRED_I18N_LINT_FILES = [ * * - `extract` pulls t()/ keys from the dashboard and CLI source into the * authored `en` catalogs under @fusion/i18n. - * - `sync` propagates the `en` key structure to the four other locales. + * - `sync` propagates the `en` key structure to the secondary locales. * - `types` regenerates key types from the `en` catalogs. - * - `status` reports per-locale completion (CI gate). + * - `status` runs the project key-parity gate: structure only, empty values allowed. + * - `status:report` preserves the upstream translation-completeness report. * - `lint` flags hardcoded user-facing strings (primary guardrail). * + * FNXC:i18n-ParityGate 2026-06-20-00:00: + * `pnpm i18n:status` points at packages/i18n/scripts/check-i18n-parity.mjs because empty secondary-locale values are intentional fallback placeholders, not gate failures. + * Use `pnpm i18n:status:report` when a human wants the upstream completeness report that still counts empty placeholders as untranslated. + * * Namespaces are routed by the `ns:` prefix in keys / `useTranslation(ns)` in * source, not by file path. `common` is the default namespace. */ @@ -74,8 +79,8 @@ export default defineConfig({ defaultNS: "common", keySeparator: ".", nsSeparator: ":", - // Untranslated secondary-locale keys stay empty so `status` can measure - // real completion; the active locale falls back to `en` at runtime. + // FNXC:i18n-ParityGate 2026-06-20-00:00: + // Untranslated secondary-locale keys stay empty for runtime fallback to `en`; `status` now gates structural key parity only, while `status:report` measures real completion. defaultValue: "", }, types: { diff --git a/package.json b/package.json index f6fe96b9b8..e09f31f118 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,8 @@ "i18n:extract": "i18next-cli extract", "i18n:sync": "i18next-cli sync", "i18n:types": "i18next-cli types", - "i18n:status": "i18next-cli status", + "i18n:status": "tsx --no-warnings packages/i18n/scripts/check-i18n-parity.mjs", + "i18n:status:report": "i18next-cli status", "i18n:lint": "i18next-cli lint", "i18n:gen-cli": "pnpm --filter @fusion/i18n gen:cli-catalogs", "changeset": "changeset", diff --git a/packages/i18n/scripts/check-i18n-parity.mjs b/packages/i18n/scripts/check-i18n-parity.mjs new file mode 100644 index 0000000000..ab2be44ab5 --- /dev/null +++ b/packages/i18n/scripts/check-i18n-parity.mjs @@ -0,0 +1,110 @@ +#!/usr/bin/env tsx +/* global console, process */ +/* + * FNXC:i18n-ParityGate 2026-06-20-00:00: + * `pnpm i18n:status` is a read-only key-structure gate, not a translation-completeness gate. + * Secondary locales intentionally keep untranslated entries as empty strings for fallback-to-`en`, so this script fails only for missing catalog keys or stale orphan keys. + */ +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { basename, dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { findParityViolations } from "../src/parity.ts"; + +const here = dirname(fileURLToPath(import.meta.url)); +const packageRoot = join(here, ".."); +const repoRoot = join(packageRoot, "..", ".."); +const localesRoot = join(packageRoot, "locales"); +const primaryLocale = "en"; + +function readJson(filePath) { + return JSON.parse(readFileSync(filePath, "utf8")); +} + +function listSupportedLocales() { + const configText = readFileSync(join(repoRoot, "i18next.config.ts"), "utf8"); + const localesMatch = configText.match(/locales:\s*\[([^\]]+)\]/m); + if (!localesMatch) { + throw new Error("Unable to read locales from i18next.config.ts"); + } + return [...localesMatch[1].matchAll(/"([^"]+)"/g)].map((match) => match[1]); +} + +function listPrimaryNamespaces() { + const primaryDir = join(localesRoot, primaryLocale); + return readdirSync(primaryDir, { withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith(".json")) + .map((entry) => basename(entry.name, ".json")) + .sort(); +} + +function loadCatalogs(locale, namespaces) { + const catalogs = {}; + for (const namespace of namespaces) { + const catalogPath = join(localesRoot, locale, `${namespace}.json`); + if (existsSync(catalogPath)) { + catalogs[namespace] = readJson(catalogPath); + } + } + return catalogs; +} + +function groupViolations(violations) { + const grouped = new Map(); + for (const violation of violations) { + const localeGroup = grouped.get(violation.locale) ?? new Map(); + const namespaceGroup = localeGroup.get(violation.namespace) ?? { absent: [], orphan: [] }; + namespaceGroup[violation.kind].push(violation.key); + localeGroup.set(violation.namespace, namespaceGroup); + grouped.set(violation.locale, localeGroup); + } + return grouped; +} + +function formatKeyList(keys) { + return keys.sort().map((key) => ` - ${key}`).join("\n"); +} + +export function run() { + const namespaces = listPrimaryNamespaces(); + const sourceCatalogs = loadCatalogs(primaryLocale, namespaces); + const secondaryLocales = listSupportedLocales().filter((locale) => locale !== primaryLocale); + const violations = []; + + for (const locale of secondaryLocales) { + const localeCatalogs = loadCatalogs(locale, namespaces); + violations.push(...findParityViolations(sourceCatalogs, localeCatalogs, { locale })); + } + + if (violations.length === 0) { + console.log( + `✔ i18n key parity intact across ${secondaryLocales.length} secondary locale(s) / ${namespaces.length} namespace(s).`, + ); + return 0; + } + + console.error("✖ i18n key parity violations detected."); + console.error("\nSecondary locale catalogs must match the authored en catalog key structure."); + + for (const [locale, namespacesByLocale] of groupViolations(violations)) { + console.error(`\n${locale}:`); + for (const [namespace, byKind] of namespacesByLocale) { + if (byKind.absent.length > 0) { + console.error(` [${namespace}] ${byKind.absent.length} absent key(s):`); + console.error(formatKeyList(byKind.absent)); + } + if (byKind.orphan.length > 0) { + console.error(` [${namespace}] ${byKind.orphan.length} orphan key(s):`); + console.error(formatKeyList(byKind.orphan)); + } + } + } + + console.error("\nRemediation:"); + console.error(" - Run `pnpm i18n:sync` and commit generated secondary-locale keys for absent-key violations."); + console.error(" - Remove or reconcile stale secondary-locale keys for orphan-key violations."); + return 1; +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + process.exitCode = run(); +} diff --git a/packages/i18n/src/__tests__/config.test.ts b/packages/i18n/src/__tests__/config.test.ts index 0a5d444e91..d519fb5428 100644 --- a/packages/i18n/src/__tests__/config.test.ts +++ b/packages/i18n/src/__tests__/config.test.ts @@ -47,7 +47,7 @@ describe("@fusion/i18n config", () => { it("has real en content (catalogs wired, not empty)", () => { expect(cliResources.en.cli).toMatchObject({ tui: { loading: expect.any(String) } }); - expect(cliResources.en.common).toMatchObject({ columns: { done: "Done" } }); + expect(cliResources.en.common).toMatchObject({ inline: { online: "Online" } }); }); it("keeps dashboard/cli namespace lists as subsets of the canonical set", () => { diff --git a/packages/i18n/src/__tests__/parity.test.ts b/packages/i18n/src/__tests__/parity.test.ts new file mode 100644 index 0000000000..784b9b6851 --- /dev/null +++ b/packages/i18n/src/__tests__/parity.test.ts @@ -0,0 +1,130 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { basename, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { SUPPORTED_LOCALES } from "@fusion/core"; +import { describe, expect, it } from "vitest"; +import { findParityViolations, type CatalogObject, type NamespaceCatalogs } from "../parity.js"; + +const packageRoot = fileURLToPath(new URL("../..", import.meta.url)); +const localesRoot = join(packageRoot, "locales"); + +function listNamespaces() { + return readdirSync(join(localesRoot, "en"), { withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith(".json")) + .map((entry) => basename(entry.name, ".json")) + .sort(); +} + +function readCatalog(locale: string, namespace: string): CatalogObject { + return JSON.parse(readFileSync(join(localesRoot, locale, `${namespace}.json`), "utf8")) as CatalogObject; +} + +function readCatalogs(locale: string, namespaces = listNamespaces()): NamespaceCatalogs { + return Object.fromEntries(namespaces.map((namespace) => [namespace, readCatalog(locale, namespace)])); +} + +describe("i18n key parity", () => { + it("passes for the live catalogs across all supported locales and en namespaces", () => { + expect([...SUPPORTED_LOCALES]).toEqual(["en", "zh-CN", "zh-TW", "fr", "es", "ko"]); + expect(listNamespaces()).toEqual(["app", "cli", "common", "errors"]); + + const enCatalogs = readCatalogs("en"); + for (const locale of SUPPORTED_LOCALES) { + expect(findParityViolations(enCatalogs, readCatalogs(locale), { locale })).toEqual([]); + } + }); + + it("reports an absent key but allows an empty present value", () => { + const enCatalogs: NamespaceCatalogs = { + common: { + nav: { + home: "Home", + settings: "Settings", + }, + }, + }; + + expect( + findParityViolations( + enCatalogs, + { + common: { + nav: { + home: "Accueil", + settings: "", + }, + }, + }, + { locale: "fr" }, + ), + ).toEqual([]); + + expect( + findParityViolations( + enCatalogs, + { + common: { + nav: { + home: "Accueil", + }, + }, + }, + { locale: "fr" }, + ), + ).toEqual([ + { + locale: "fr", + namespace: "common", + kind: "absent", + key: "nav.settings", + }, + ]); + }); + + it("normalizes plural-category suffixes before comparing structure", () => { + const enCatalogs: NamespaceCatalogs = { + app: { + inbox: { + task_one: "{{count}} task", + task_other: "{{count}} tasks", + }, + }, + }; + const localeCatalogs: NamespaceCatalogs = { + app: { + inbox: { + task_many: "", + task_other: "", + }, + }, + }; + + expect(findParityViolations(enCatalogs, localeCatalogs, { locale: "fr" })).toEqual([]); + }); + + it("reports orphan keys that exist only in a secondary locale", () => { + expect( + findParityViolations( + { + errors: { + general: "Something went wrong", + }, + }, + { + errors: { + general: "", + stale: "Old copy", + }, + }, + { locale: "es" }, + ), + ).toEqual([ + { + locale: "es", + namespace: "errors", + kind: "orphan", + key: "stale", + }, + ]); + }); +}); diff --git a/packages/i18n/src/parity.ts b/packages/i18n/src/parity.ts new file mode 100644 index 0000000000..d1d55adc67 --- /dev/null +++ b/packages/i18n/src/parity.ts @@ -0,0 +1,126 @@ +const PLURAL_SUFFIX_PATTERN = /_(zero|one|two|few|many|other)$/; + +export type CatalogValue = string | number | boolean | null | undefined | CatalogObject | CatalogValue[]; +export type CatalogObject = { [key: string]: CatalogValue }; + +export type NamespaceCatalogs = Record; + +export type ParityViolationKind = "absent" | "orphan"; + +export interface ParityViolation { + locale: string; + namespace: string; + kind: ParityViolationKind; + key: string; +} + +export interface FindParityViolationsOptions { + locale: string; +} + +interface FlattenedCatalog { + keys: Set; + displayKeysByNormalizedKey: Map>; +} + +function isRecord(value: CatalogValue): value is CatalogObject { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function normalizePluralKey(key: string): string { + return key.replace(PLURAL_SUFFIX_PATTERN, ""); +} + +function normalizeKeyPath(keyPath: string): string { + return keyPath + .split(".") + .map((segment) => normalizePluralKey(segment)) + .join("."); +} + +function addDisplayKey(target: Map>, normalizedKey: string, keyPath: string) { + const keys = target.get(normalizedKey) ?? new Set(); + keys.add(keyPath); + target.set(normalizedKey, keys); +} + +function flattenCatalog(catalog: CatalogObject | undefined, prefix = ""): FlattenedCatalog { + const keys = new Set(); + const displayKeysByNormalizedKey = new Map>(); + + if (!catalog) { + return { keys, displayKeysByNormalizedKey }; + } + + for (const [key, value] of Object.entries(catalog)) { + const keyPath = prefix ? `${prefix}.${key}` : key; + if (value === undefined || value === null) { + continue; + } + if (isRecord(value)) { + const nested = flattenCatalog(value, keyPath); + for (const nestedKey of nested.keys) { + keys.add(nestedKey); + } + for (const [normalizedKey, displayKeys] of nested.displayKeysByNormalizedKey) { + for (const displayKey of displayKeys) { + addDisplayKey(displayKeysByNormalizedKey, normalizedKey, displayKey); + } + } + continue; + } + + const normalizedKey = normalizeKeyPath(keyPath); + keys.add(normalizedKey); + addDisplayKey(displayKeysByNormalizedKey, normalizedKey, keyPath); + } + + return { keys, displayKeysByNormalizedKey }; +} + +function representativeKey(catalog: FlattenedCatalog, normalizedKey: string) { + return [...(catalog.displayKeysByNormalizedKey.get(normalizedKey) ?? [normalizedKey])].sort()[0] ?? normalizedKey; +} + +/** + * FNXC:i18n-ParityGate 2026-06-20-00:00: + * Fusion intentionally keeps untranslated secondary-locale entries as empty strings so runtime fallback can use `en` without blocking incomplete translation work. + * The status gate therefore compares catalog key structure only: missing keys and stale orphan keys fail, while present empty values never fail. + */ +export function findParityViolations( + enCatalogs: NamespaceCatalogs, + localeCatalogs: NamespaceCatalogs, + options: FindParityViolationsOptions, +): ParityViolation[] { + const namespaces = [...new Set([...Object.keys(enCatalogs), ...Object.keys(localeCatalogs)])].sort(); + const violations: ParityViolation[] = []; + + for (const namespace of namespaces) { + const en = flattenCatalog(enCatalogs[namespace]); + const locale = flattenCatalog(localeCatalogs[namespace]); + + for (const key of [...en.keys].sort()) { + if (!locale.keys.has(key)) { + violations.push({ + locale: options.locale, + namespace, + kind: "absent", + key: representativeKey(en, key), + }); + } + } + + for (const key of [...locale.keys].sort()) { + if (!en.keys.has(key)) { + violations.push({ + locale: options.locale, + namespace, + kind: "orphan", + key: representativeKey(locale, key), + }); + } + } + } + + return violations; +}