From 8a250b98b5de274e4a99fc1298c805c4127dacc7 Mon Sep 17 00:00:00 2001 From: le king fu Date: Sun, 18 Oct 2026 17:12:32 -0400 Subject: [PATCH 1/2] test: guard the key parity of fr.json and en.json (#453) No test compared the key trees of the two locale files: a key added to one language only passed the build and the suite, then showed raw to an English user (missing from en.json) or in English to a French user (missing from fr.json, English being the fallback). src/i18n/locales.keys.test.ts imports both files statically, flattens every node to a dotted path, array indices included (docs.dashboard.features.6), and fails with the keys missing from each language in a test of its own, plus a third test for a path held with another kind in each file (an array against an object, a leaf against an object or an array). Values are never compared. A canary checks both files were read whole, array items included, and four tests on small fixtures pin the rules of the comparison. Run on the base (6e8e157): 1712 leaves on each side, no gap, so no translation to add. Co-Authored-By: Claude Opus 5.5 --- src/i18n/locales.keys.test.ts | 125 ++++++++++++++++++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 src/i18n/locales.keys.test.ts diff --git a/src/i18n/locales.keys.test.ts b/src/i18n/locales.keys.test.ts new file mode 100644 index 0000000..8ae7796 --- /dev/null +++ b/src/i18n/locales.keys.test.ts @@ -0,0 +1,125 @@ +// Guard: fr.json and en.json declare the same keys (#453). +// +// i18next serves fr.json, the default language, and en.json, the fallback +// (`src/i18n/config.ts`). A key written in fr.json only shows raw to an +// English user; one written in en.json only shows in English to a French +// user. Neither breaks the build. These tests hold both files to one set of +// keys. +// +// A key is the dotted path of a leaf, array indices included +// (`docs.dashboard.features.6`) and plural suffixes too +// (`import.sources.fileCount_one`). Values are never compared: a text left +// untranslated in the other file passes. A path that is an array in one file +// and an object in the other, or a leaf in one and an object or an array in +// the other, is a gap as well: `t(key, { returnObjects: true })` would hand +// the component another shape. + +import { describe, it, expect } from "vitest"; +import fr from "./locales/fr.json"; +import en from "./locales/en.json"; + +type NodeKind = "object" | "array" | "leaf"; + +/** Every node under the root of a locale tree, by dotted path (array indices included), with its kind. */ +function nodesOf(tree: unknown): Map { + const nodes = new Map(); + const visit = (value: unknown, path: string[]): void => { + const kind: NodeKind = Array.isArray(value) + ? "array" + : typeof value === "object" && value !== null + ? "object" + : "leaf"; + if (path.length > 0) nodes.set(path.join("."), kind); + if (kind === "leaf") return; + // An array's entries are its indices, as strings: `features.6`. + for (const [key, item] of Object.entries(value as object)) visit(item, [...path, key]); + }; + visit(tree, []); + return nodes; +} + +/** + * The keys of `from` that `into` lacks, sorted: the leaves of `from` with + * nothing at the same path in `into`. A path `into` holds with another kind is + * left to `kindGaps`. + */ +function missingKeys(from: Map, into: Map): string[] { + return [...from] + .filter(([path, kind]) => kind === "leaf" && !into.has(path)) + .map(([path]) => path) + .sort(); +} + +/** The paths both trees hold with different kinds, sorted, as `path: in , in `. */ +function kindGaps( + a: Map, + b: Map, + nameA: string, + nameB: string, +): string[] { + return [...a] + .filter(([path, kind]) => b.has(path) && b.get(path) !== kind) + .map(([path, kind]) => `${path}: ${kind} in ${nameA}, ${b.get(path)} in ${nameB}`) + .sort(); +} + +const FR = nodesOf(fr); +const EN = nodesOf(en); + +describe("locale keys: fr.json and en.json (#453)", () => { + it("reads both files whole, array items included", () => { + // The canary: an empty or unread file would make the comparison pass on nothing. + for (const [name, nodes] of [["fr.json", FR], ["en.json", EN]] as const) { + expect(nodes.get("common.cancel"), name).toBe("leaf"); + expect(nodes.get("dashboard.help.tips"), name).toBe("array"); + expect(nodes.get("dashboard.help.tips.0"), name).toBe("leaf"); + } + }); + + it("en.json declares every key of fr.json", () => { + expect(missingKeys(FR, EN), "keys of fr.json missing from en.json").toEqual([]); + }); + + it("fr.json declares every key of en.json", () => { + expect(missingKeys(EN, FR), "keys of en.json missing from fr.json").toEqual([]); + }); + + it("gives every path both files hold the same kind: object, array or leaf", () => { + expect(kindGaps(FR, EN, "fr.json", "en.json"), "paths of another kind").toEqual([]); + }); +}); + +describe("locale keys: the comparison (#453)", () => { + it("names, on each side apart, the keys the other side lacks, array indices included", () => { + const a = nodesOf({ list: ["one", "two"], only: { a: "x" } }); + const b = nodesOf({ list: ["one"], only: { b: "y" } }); + expect(missingKeys(a, b)).toEqual(["list.1", "only.a"]); + expect(missingKeys(b, a)).toEqual(["only.b"]); + }); + + it("holds an array against an object with the same indices as a gap", () => { + const a = nodesOf({ list: ["one"] }); + const b = nodesOf({ list: { 0: "one" } }); + expect(missingKeys(a, b)).toEqual([]); + expect(missingKeys(b, a)).toEqual([]); + expect(kindGaps(a, b, "a", "b")).toEqual(["list: array in a, object in b"]); + }); + + it("holds a leaf against an object or an array as a gap", () => { + const leaf = nodesOf({ x: "text" }); + expect(kindGaps(leaf, nodesOf({ x: { y: "text" } }), "a", "b")).toEqual([ + "x: leaf in a, object in b", + ]); + expect(kindGaps(leaf, nodesOf({ x: ["text"] }), "a", "b")).toEqual(["x: leaf in a, array in b"]); + // The leaf is not reported missing as well: `x` exists on the other side, with another kind. + expect(missingKeys(leaf, nodesOf({ x: ["text"] }))).toEqual([]); + }); + + it("never compares values", () => { + const a = nodesOf({ x: "un", list: ["a", "b"] }); + const b = nodesOf({ x: "one", list: ["c", "d"] }); + expect(missingKeys(a, b)).toEqual([]); + expect(missingKeys(b, a)).toEqual([]); + expect(kindGaps(a, b, "a", "b")).toEqual([]); + }); +}); -- 2.45.2 From 25a1fdea84f35586bf800d743db4c66a1cd0f2de Mon Sep 17 00:00:00 2001 From: le king fu Date: Sun, 18 Oct 2026 17:16:13 -0400 Subject: [PATCH 2/2] docs: name the i18n key guard in REVIEW.md (#453) The "Faux t" review pitfall said that until #453 no guard caught a key present in one language only. It now names src/i18n/locales.keys.test.ts and what the guard does not see: a value left in the other language, and a key the code calls that neither file declares. The first rule of the "i18n et documentation" zone names the guard too, as the migration and release zones name theirs. The "Niveaux" section is untouched (scripts/review-levels.py --check still passes). Resolves #453 Co-Authored-By: Claude Opus 5.5 --- REVIEW.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/REVIEW.md b/REVIEW.md index 7e44b94..568d6c1 100644 --- a/REVIEW.md +++ b/REVIEW.md @@ -138,7 +138,7 @@ Chaque règle cite sa source. Elles valent quel que soit le niveau : une PR norm ### i18n et documentation -- Toute chaîne visible passe par i18next, dans `fr.json` et `en.json` à la fois ; aucun texte en dur dans un composant. — `CLAUDE.md` « Internationalisation (i18n) », `.claude/rules/i18n.md` +- Toute chaîne visible passe par i18next, dans `fr.json` et `en.json` à la fois (mêmes clés, vérifiées par `src/i18n/locales.keys.test.ts`) ; aucun texte en dur dans un composant. — `CLAUDE.md` « Internationalisation (i18n) », `.claude/rules/i18n.md`, #453 - Un changement visible ajoute une entrée sous `[Unreleased]` dans `CHANGELOG.md` et `CHANGELOG.fr.md`. — `CLAUDE.md` « Documentation technique » (Règle CHANGELOG) - Un changement d'usage met à jour `docs/guide-utilisateur.md` et les clés `docs.*` ; un service, un hook, une commande, une page ou une table de plus met à jour `docs/architecture.md` ; une décision structurante a son ADR. — `CLAUDE.md` « Documentation technique » @@ -183,7 +183,7 @@ Les trois premiers viennent du skill global `pr-review`, corrigés pour ce dép - **Un vert de `check-rust` ne dit rien de l'audit** : son étape `cargo audit` est `continue-on-error`. Lire le journal : `cargo-audit` écrit `1 vulnerability found` mais `N vulnerabilities found` (chercher `vulnerabilit(y|ies) found`), et le journal de `check-rust` contient des codes ANSI à retirer avant tout `grep`. Un `audit.yml` vert veut dire « zéro advisory hors de la liste acceptée », pas « zéro advisory ». — `CLAUDE.md` « CI/CD », [ADR 0018](docs/adr/0018-suppression-advisories-non-atteignables.md), #447 - **Windows n'est compilé qu'à la release.** La CI des PR ne compile que pour Linux : sans `cargo xwin check`, une casse Windows apparaît le jour du tag. — `docs/architecture.md` « `release.yml` — Build et publication », #455 - **Tests sans jsdom.** Un rendu par `renderToStaticMarkup` ne lance ni effet ni clic : ce qui en dépend n'est prouvé que par un test qui passe par `src/test/hookHarness.ts` (effets joués par `commit()`, gestionnaires appelés depuis l'arbre rendu). Le harnais ne rend pas de nouveau après un setter et laisse les enfants à l'état d'éléments : lire ce que le test exerce avant de le créditer. — `docs/architecture.md` « Tests (vitest sans jsdom) » -- **Faux `t`.** Un faux `t` qui rend la clé brute rend invisible un `t` oublié : les tests prennent `src/test/i18nMock.ts`, gardé par `src/__integration__/i18n-mock-guard.test.ts`. Jusqu'à #453, aucune garde ne détecte une clé présente dans une seule langue : comparer `fr.json` et `en.json`. — `docs/architecture.md` « Faux `t` marqué », #389 +- **Faux `t`.** Un faux `t` qui rend la clé brute rend invisible un `t` oublié : les tests prennent `src/test/i18nMock.ts`, gardé par `src/__integration__/i18n-mock-guard.test.ts`. Une clé présente dans une seule langue fait échouer la garde `src/i18n/locales.keys.test.ts`, qui ne compare que les clés des deux fichiers : un texte resté dans l'autre langue (du français dans `en.json`) la passe, et une clé que le code appelle sans qu'aucun des deux fichiers la déclare lui échappe aussi. — `docs/architecture.md` « Faux `t` marqué », #389, #453 - **Sous-chaîne du markup.** `not.toContain("disabled")` sur le HTML rendu bute sur une classe Tailwind (`disabled:opacity-50`) : tester l'attribut (`disabled=""`). — PR #452 - **Booléen lié en texte.** tauri-plugin-sql stocke un booléen JS en texte `'true'`, qu'une condition `= 1` ne trouve pas : défaut resté invisible de la v0.1.0 à #390. — [ADR 0021](docs/adr/0021-migrations-profils-secondaires.md) - **Transaction hors verrou.** Un `BEGIN` et son `COMMIT` envoyés par deux `db.execute` peuvent partir sur deux connexions du pool et laisser un verrou zombie (« database is locked », v0.10.0) : toujours `withTransaction` et son handle. — `docs/architecture.md` « Accès DB et transactions » -- 2.45.2