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:
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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",
|
||||
|
||||
110
packages/i18n/scripts/check-i18n-parity.mjs
Normal file
110
packages/i18n/scripts/check-i18n-parity.mjs
Normal 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();
|
||||
}
|
||||
@@ -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", () => {
|
||||
|
||||
130
packages/i18n/src/__tests__/parity.test.ts
Normal file
130
packages/i18n/src/__tests__/parity.test.ts
Normal 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
126
packages/i18n/src/parity.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user