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
This commit is contained in:
gsxdsm
2026-06-20 06:46:35 -07:00
parent 254f31ebb6
commit 083ebf8134
7 changed files with 393 additions and 9 deletions

View File

@@ -26,11 +26,20 @@ All commands run from the repo root:
pnpm i18n:extract # pull t()/<Trans> 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

View File

@@ -52,11 +52,16 @@ const DEFERRED_I18N_LINT_FILES = [
*
* - `extract` pulls t()/<Trans> 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: {

View File

@@ -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",

View File

@@ -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();
}

View File

@@ -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", () => {

View File

@@ -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",
},
]);
});
});

126
packages/i18n/src/parity.ts Normal file
View File

@@ -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<string, CatalogObject | undefined>;
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<string>;
displayKeysByNormalizedKey: Map<string, Set<string>>;
}
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<string, Set<string>>, normalizedKey: string, keyPath: string) {
const keys = target.get(normalizedKey) ?? new Set<string>();
keys.add(keyPath);
target.set(normalizedKey, keys);
}
function flattenCatalog(catalog: CatalogObject | undefined, prefix = ""): FlattenedCatalog {
const keys = new Set<string>();
const displayKeysByNormalizedKey = new Map<string, Set<string>>();
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;
}