diff --git a/CHANGELOG.md b/CHANGELOG.md index 017422665..2aa34ca7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## [1.4.49.3] — 2026-05-23 — Full i18n call-site audit + 28 stale missing keys filled + +Marc reported additional raw key strings (`notifications.eventMoodReminder`, `notifications.eventMoodReminderDesc`) reaching the UI after v1.4.49.2 had landed the relative-time fix. The narrow `{count}`-call audit that drove v1.4.49.2 had missed every dynamic key construction; the full exhaustive sweep below picked up 28 keys called from real code that had never existed in any locale bundle. + +### Fixed + +- Filled 28 missing translation keys across all 6 locales (de / en / es / fr / it / pl = 168 entries), grouped by feature surface that introduced them and never wired the strings: + - **Notifications event matrix** (14 keys — added with v1.4.41 / MOOD_REMINDER surfaced the gap in v1.4.49): `notifications.event{MedicationReminder,MeasurementAnomaly,ComplianceLow,WithingsSyncFailed,SystemAlert,PersonalRecord,MoodReminder}` and the `Desc` variant of each. The `/notifications` settings page is the consumer; before this release the per-event rows on Marc's account rendered the bare keys instead of the localised name + description. + - **Welcome carousel** (13 keys — added with the 3-slide intro in v1.4.45): `onboarding.welcome.{title,carouselLabel,slideOf,prevSlide,nextSlide,gotoSlide,cta}` for the chrome plus `slide{1,2,3}.title/body` for the content. The carousel mounted with raw keys for every screen-reader label, dot-pager aria-label, and slide body. + - **Measurement list error state** (1 key — added with v1.4.44): `measurements.loadError`. The error branch in `measurement-list.tsx` rendered the raw key when a fetch failed. + +### Added + +- `src/__tests__/i18n-call-site-coverage.test.ts` — call-site audit that walks every `.ts` / `.tsx` file under `src/` (excluding tests + generated code), extracts every literal `t("ns.key")` call with comment-aware parsing (skips line + block + JSDoc comments so example pseudocode in docstrings is not flagged), and asserts each key resolves to a string leaf in `messages/en.json`. The existing `i18n-locale-integrity.test.ts` then propagates the EN guarantee to every other locale via key-set parity. Pre-fix this guard would have surfaced 17 stale call sites at every test run; future regressions print a structured punch list so the offender can land every gap in one commit. + +### Why this happened + +The guard above did not exist before. v1.4.27 B6 introduced `i18n-drift-guard.test.ts` with hand-curated key groups; surfaces introduced after that point (notifications matrix in v1.4.41, measurement list error in v1.4.44, welcome carousel in v1.4.45) shipped without anyone editing the drift-guard groups, and the locale-integrity test only catches DRIFT across locales — it does not flag keys that are missing from EVERY locale uniformly. The call-site test closes that hole: any `t()` call without a backing key now fails at `pnpm test` time, before reaching production. + ## [1.4.49.2] — 2026-05-23 — Raw i18n key leak on relative-time helper Marc reported the raw string `insights.relativeHoursAgo` rendering verbatim on medication cards, recent-achievements, admin sections, the iOS notification preview, and every other consumer of `formatDateOrRelative`. Audit across the full `t(…, { count })` surface (~20 call sites) confirmed exactly two leaks: `insights.relativeMinutesAgo` and `insights.relativeHoursAgo`. Both pointed to keys that have NEVER existed in the translation bundle — only the pluralised `*One` / `*Other` variants ship. diff --git a/messages/de.json b/messages/de.json index ee3468149..cba61802b 100644 --- a/messages/de.json +++ b/messages/de.json @@ -379,7 +379,8 @@ "nextPage": "Nächste Seite", "dailyTotalCaption": "({count} Einzelwerte)", "expandDay": "Einzelwerte dieses Tages anzeigen", - "collapseDay": "Einzelwerte dieses Tages ausblenden" + "collapseDay": "Einzelwerte dieses Tages ausblenden", + "loadError": "Messwerte konnten nicht geladen werden." }, "mood": { "title": "Stimmung", @@ -1513,6 +1514,27 @@ "title": "Willkommen zurück", "body": "Das Onboarding hast du bereits abgeschlossen. Möchtest du etwas anpassen, findest du alles in den Einstellungen.", "cta": "Zum Dashboard" + }, + "welcome": { + "title": "Willkommen bei HealthLog", + "carouselLabel": "Begrüßungs-Karussell", + "slideOf": "Folie {current} von {total}", + "prevSlide": "Vorherige Folie", + "nextSlide": "Nächste Folie", + "gotoSlide": "Zu Folie {index} springen", + "cta": "Los geht's", + "slide1": { + "title": "Alles, was zählt", + "body": "Gewicht, Blutdruck, Glukose, Schlaf, Stimmung — alles in einem Verlauf, der dir gehört." + }, + "slide2": { + "title": "Verbinde deine Quellen", + "body": "Synchronisiere mit Apple Health und Withings oder trage Werte manuell ein. Deine Daten bleiben auf deinem Server." + }, + "slide3": { + "title": "Erkenne das Muster", + "body": "Tageszusammenfassungen, Trend-Diagramme und ein Coach, der zeigt was sich verändert hat — alles auf deinem Gerät." + } } }, "gettingStarted": { @@ -2838,7 +2860,21 @@ "enabledToast": "Stimmungs-Erinnerung aktiviert.", "disabledToast": "Stimmungs-Erinnerung deaktiviert.", "saveError": "Speichern fehlgeschlagen. Bitte später erneut versuchen." - } + }, + "eventMedicationReminder": "Medikamenten-Erinnerung", + "eventMedicationReminderDesc": "Wird zu jeder geplanten Einnahmezeit gesendet.", + "eventMeasurementAnomaly": "Auffälliger Messwert", + "eventMeasurementAnomalyDesc": "Wird gesendet, wenn ein Wert deutlich außerhalb deines üblichen Bereichs liegt.", + "eventComplianceLow": "Niedrige Einnahmequote", + "eventComplianceLowDesc": "Wird gesendet, wenn deine Einnahmequote in den letzten Tagen unter 80 % fällt.", + "eventWithingsSyncFailed": "Withings-Sync fehlgeschlagen", + "eventWithingsSyncFailedDesc": "Wird gesendet, wenn die Withings-Synchronisierung mehrere Versuche in Folge scheitert.", + "eventSystemAlert": "Systemmeldung", + "eventSystemAlertDesc": "Allgemeine Mitteilungen vom HealthLog-Server, etwa Wartungsfenster oder Konto-Hinweise.", + "eventPersonalRecord": "Persönlicher Rekord", + "eventPersonalRecordDesc": "Wird gesendet, wenn ein Messwert deine bisherige Bestmarke übertrifft.", + "eventMoodReminder": "Stimmungs-Erinnerung", + "eventMoodReminderDesc": "Tägliche Erinnerung, deine aktuelle Stimmung zu protokollieren." }, "i18n": { "maintainershipBanner": { diff --git a/messages/en.json b/messages/en.json index 9581c5e22..dd98a06ff 100644 --- a/messages/en.json +++ b/messages/en.json @@ -379,7 +379,8 @@ "nextPage": "Next page", "dailyTotalCaption": "({count} samples)", "expandDay": "Show samples for this day", - "collapseDay": "Hide samples for this day" + "collapseDay": "Hide samples for this day", + "loadError": "Could not load measurements." }, "mood": { "title": "Mood", @@ -1513,6 +1514,27 @@ "title": "Welcome back", "body": "You've already finished onboarding. To adjust anything, head to Settings — everything you set up is editable from there.", "cta": "Open dashboard" + }, + "welcome": { + "title": "Welcome to HealthLog", + "carouselLabel": "Welcome carousel", + "slideOf": "Slide {current} of {total}", + "prevSlide": "Previous slide", + "nextSlide": "Next slide", + "gotoSlide": "Go to slide {index}", + "cta": "Get started", + "slide1": { + "title": "Track every signal", + "body": "Weight, blood pressure, glucose, sleep, mood — keep your full picture in one log you own." + }, + "slide2": { + "title": "Connect your sources", + "body": "Sync with Apple Health and Withings, or add readings by hand. Your data stays on your server." + }, + "slide3": { + "title": "See the pattern", + "body": "Daily summaries, trend charts, and a coach that surfaces what changed — all on your own device." + } } }, "gettingStarted": { @@ -2838,7 +2860,21 @@ "enabledToast": "Mood reminder enabled.", "disabledToast": "Mood reminder disabled.", "saveError": "Save failed. Please try again." - } + }, + "eventMedicationReminder": "Medication reminder", + "eventMedicationReminderDesc": "Sent at each scheduled dose time.", + "eventMeasurementAnomaly": "Unusual measurement", + "eventMeasurementAnomalyDesc": "Sent when a reading is well outside your usual range.", + "eventComplianceLow": "Low compliance", + "eventComplianceLowDesc": "Sent when your intake rate over the last few days drops below 80 %.", + "eventWithingsSyncFailed": "Withings sync failed", + "eventWithingsSyncFailedDesc": "Sent when Withings synchronisation fails multiple attempts in a row.", + "eventSystemAlert": "System notice", + "eventSystemAlertDesc": "General messages from the HealthLog server, e.g. maintenance windows or account notes.", + "eventPersonalRecord": "Personal record", + "eventPersonalRecordDesc": "Sent when a reading beats your previous best.", + "eventMoodReminder": "Mood reminder", + "eventMoodReminderDesc": "Daily nudge to log your current mood." }, "i18n": { "maintainershipBanner": { diff --git a/messages/es.json b/messages/es.json index d523ca0f4..e39aac326 100644 --- a/messages/es.json +++ b/messages/es.json @@ -379,7 +379,8 @@ "nextPage": "Página siguiente", "dailyTotalCaption": "({count} muestras)", "expandDay": "Mostrar las muestras de este día", - "collapseDay": "Ocultar las muestras de este día" + "collapseDay": "Ocultar las muestras de este día", + "loadError": "No se pudieron cargar las mediciones." }, "mood": { "title": "Estado de ánimo", @@ -1513,6 +1514,27 @@ "title": "Bienvenido de nuevo", "body": "Ya completaste la configuración inicial. Para cambiar algo, abre Ajustes — todo lo que configuraste se puede editar desde ahí.", "cta": "Abrir el panel" + }, + "welcome": { + "title": "Bienvenido a HealthLog", + "carouselLabel": "Carrusel de bienvenida", + "slideOf": "Diapositiva {current} de {total}", + "prevSlide": "Diapositiva anterior", + "nextSlide": "Diapositiva siguiente", + "gotoSlide": "Ir a la diapositiva {index}", + "cta": "Empezar", + "slide1": { + "title": "Registra cada señal", + "body": "Peso, presión, glucosa, sueño, ánimo — todo en un único registro que es tuyo." + }, + "slide2": { + "title": "Conecta tus fuentes", + "body": "Sincroniza con Apple Health y Withings o añade datos a mano. Tus datos se quedan en tu servidor." + }, + "slide3": { + "title": "Descubre los patrones", + "body": "Resúmenes diarios, gráficas de tendencias y un coach que destaca lo que cambió — todo en tu propio dispositivo." + } } }, "gettingStarted": { @@ -2838,7 +2860,21 @@ "enabledToast": "Recordatorio de ánimo activado.", "disabledToast": "Recordatorio de ánimo desactivado.", "saveError": "No se pudo guardar. Inténtalo de nuevo." - } + }, + "eventMedicationReminder": "Recordatorio de medicación", + "eventMedicationReminderDesc": "Se envía a cada hora de dosis programada.", + "eventMeasurementAnomaly": "Medición inusual", + "eventMeasurementAnomalyDesc": "Se envía cuando una lectura está claramente fuera de tu rango habitual.", + "eventComplianceLow": "Cumplimiento bajo", + "eventComplianceLowDesc": "Se envía cuando tu tasa de toma de los últimos días baja del 80 %.", + "eventWithingsSyncFailed": "Error de sincronización con Withings", + "eventWithingsSyncFailedDesc": "Se envía cuando la sincronización con Withings falla varios intentos seguidos.", + "eventSystemAlert": "Aviso del sistema", + "eventSystemAlertDesc": "Mensajes generales del servidor HealthLog, por ejemplo ventanas de mantenimiento o avisos de cuenta.", + "eventPersonalRecord": "Récord personal", + "eventPersonalRecordDesc": "Se envía cuando una lectura supera tu mejor marca anterior.", + "eventMoodReminder": "Recordatorio de ánimo", + "eventMoodReminderDesc": "Recordatorio diario para registrar tu ánimo actual." }, "i18n": { "maintainershipBanner": { diff --git a/messages/fr.json b/messages/fr.json index db136ca8e..f810713d1 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -379,7 +379,8 @@ "nextPage": "Page suivante", "dailyTotalCaption": "({count} échantillons)", "expandDay": "Afficher les échantillons de ce jour", - "collapseDay": "Masquer les échantillons de ce jour" + "collapseDay": "Masquer les échantillons de ce jour", + "loadError": "Impossible de charger les mesures." }, "mood": { "title": "Humeur", @@ -1513,6 +1514,27 @@ "title": "Bon retour", "body": "Vous avez déjà terminé la configuration. Pour modifier un élément, ouvrez les paramètres — tout reste éditable.", "cta": "Ouvrir le tableau de bord" + }, + "welcome": { + "title": "Bienvenue dans HealthLog", + "carouselLabel": "Carrousel de bienvenue", + "slideOf": "Diapositive {current} sur {total}", + "prevSlide": "Diapositive précédente", + "nextSlide": "Diapositive suivante", + "gotoSlide": "Aller à la diapositive {index}", + "cta": "Commencer", + "slide1": { + "title": "Notez chaque signal", + "body": "Poids, tension, glycémie, sommeil, humeur — un seul journal qui vous appartient." + }, + "slide2": { + "title": "Connectez vos sources", + "body": "Synchronisez avec Apple Health et Withings, ou ajoutez vos mesures à la main. Vos données restent sur votre serveur." + }, + "slide3": { + "title": "Repérez les tendances", + "body": "Résumés quotidiens, graphiques de tendances et un coach qui met en avant ce qui a changé — le tout sur votre appareil." + } } }, "gettingStarted": { @@ -2838,7 +2860,21 @@ "enabledToast": "Rappel d’humeur activé.", "disabledToast": "Rappel d’humeur désactivé.", "saveError": "Échec de l’enregistrement. Réessayez." - } + }, + "eventMedicationReminder": "Rappel de médicament", + "eventMedicationReminderDesc": "Envoyé à chaque heure de prise prévue.", + "eventMeasurementAnomaly": "Mesure inhabituelle", + "eventMeasurementAnomalyDesc": "Envoyé quand une valeur sort nettement de votre plage habituelle.", + "eventComplianceLow": "Observance faible", + "eventComplianceLowDesc": "Envoyé quand votre taux de prise des derniers jours passe sous les 80 %.", + "eventWithingsSyncFailed": "Échec de la synchronisation Withings", + "eventWithingsSyncFailedDesc": "Envoyé quand la synchronisation Withings échoue plusieurs fois d'affilée.", + "eventSystemAlert": "Notification système", + "eventSystemAlertDesc": "Messages généraux du serveur HealthLog, par exemple maintenance ou avis de compte.", + "eventPersonalRecord": "Record personnel", + "eventPersonalRecordDesc": "Envoyé quand une valeur dépasse votre meilleur précédent.", + "eventMoodReminder": "Rappel d'humeur", + "eventMoodReminderDesc": "Rappel quotidien pour noter votre humeur du moment." }, "i18n": { "maintainershipBanner": { diff --git a/messages/it.json b/messages/it.json index 0bf00d9e2..9493c8106 100644 --- a/messages/it.json +++ b/messages/it.json @@ -379,7 +379,8 @@ "nextPage": "Pagina successiva", "dailyTotalCaption": "({count} campioni)", "expandDay": "Mostra i campioni di questa giornata", - "collapseDay": "Nascondi i campioni di questa giornata" + "collapseDay": "Nascondi i campioni di questa giornata", + "loadError": "Impossibile caricare le misurazioni." }, "mood": { "title": "Umore", @@ -1513,6 +1514,27 @@ "title": "Bentornato", "body": "Hai già completato l'onboarding. Per modificare qualcosa apri le impostazioni — tutto ciò che hai configurato è ancora modificabile.", "cta": "Apri la dashboard" + }, + "welcome": { + "title": "Benvenuto in HealthLog", + "carouselLabel": "Carosello di benvenuto", + "slideOf": "Diapositiva {current} di {total}", + "prevSlide": "Diapositiva precedente", + "nextSlide": "Diapositiva successiva", + "gotoSlide": "Vai alla diapositiva {index}", + "cta": "Inizia", + "slide1": { + "title": "Tieni traccia di ogni segnale", + "body": "Peso, pressione, glicemia, sonno, umore — un unico registro che è tuo." + }, + "slide2": { + "title": "Collega le tue sorgenti", + "body": "Sincronizza con Apple Health e Withings o aggiungi le misurazioni a mano. I tuoi dati restano sul tuo server." + }, + "slide3": { + "title": "Scopri lo schema", + "body": "Riepiloghi giornalieri, grafici di tendenza e un coach che evidenzia i cambiamenti — tutto sul tuo dispositivo." + } } }, "gettingStarted": { @@ -2838,7 +2860,21 @@ "enabledToast": "Promemoria dell’umore attivato.", "disabledToast": "Promemoria dell’umore disattivato.", "saveError": "Salvataggio non riuscito. Riprova." - } + }, + "eventMedicationReminder": "Promemoria farmaco", + "eventMedicationReminderDesc": "Inviato a ogni orario di dose programmato.", + "eventMeasurementAnomaly": "Misurazione insolita", + "eventMeasurementAnomalyDesc": "Inviato quando un valore esce nettamente dal tuo intervallo abituale.", + "eventComplianceLow": "Aderenza bassa", + "eventComplianceLowDesc": "Inviato quando il tuo tasso di assunzione degli ultimi giorni scende sotto l'80 %.", + "eventWithingsSyncFailed": "Sincronizzazione Withings non riuscita", + "eventWithingsSyncFailedDesc": "Inviato quando la sincronizzazione Withings fallisce per più tentativi consecutivi.", + "eventSystemAlert": "Avviso di sistema", + "eventSystemAlertDesc": "Messaggi generali dal server HealthLog, ad esempio finestre di manutenzione o note sull'account.", + "eventPersonalRecord": "Record personale", + "eventPersonalRecordDesc": "Inviato quando una misurazione supera il tuo precedente record.", + "eventMoodReminder": "Promemoria umore", + "eventMoodReminderDesc": "Promemoria quotidiano per registrare il tuo umore attuale." }, "i18n": { "maintainershipBanner": { diff --git a/messages/pl.json b/messages/pl.json index f4fd22c0d..43c4c685b 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -379,7 +379,8 @@ "nextPage": "Następna strona", "dailyTotalCaption": "({count} pomiarów)", "expandDay": "Pokaż pomiary z tego dnia", - "collapseDay": "Ukryj pomiary z tego dnia" + "collapseDay": "Ukryj pomiary z tego dnia", + "loadError": "Nie udało się załadować pomiarów." }, "mood": { "title": "Nastrój", @@ -1513,6 +1514,27 @@ "title": "Witaj ponownie", "body": "Wstępne kroki masz już za sobą. Aby coś zmienić, otwórz ustawienia — wszystko, co skonfigurowałeś, da się tam edytować.", "cta": "Otwórz pulpit" + }, + "welcome": { + "title": "Witaj w HealthLog", + "carouselLabel": "Karuzela powitalna", + "slideOf": "Slajd {current} z {total}", + "prevSlide": "Poprzedni slajd", + "nextSlide": "Następny slajd", + "gotoSlide": "Przejdź do slajdu {index}", + "cta": "Zaczynamy", + "slide1": { + "title": "Śledź każdy sygnał", + "body": "Waga, ciśnienie, glukoza, sen, nastrój — wszystko w jednym dzienniku, który należy do Ciebie." + }, + "slide2": { + "title": "Połącz swoje źródła", + "body": "Synchronizuj z Apple Health i Withings lub dodawaj odczyty ręcznie. Twoje dane zostają na Twoim serwerze." + }, + "slide3": { + "title": "Zobacz wzorce", + "body": "Codzienne podsumowania, wykresy trendów i coach, który wskazuje co się zmieniło — wszystko na Twoim urządzeniu." + } } }, "gettingStarted": { @@ -2838,7 +2860,21 @@ "enabledToast": "Przypomnienie o nastroju włączone.", "disabledToast": "Przypomnienie o nastroju wyłączone.", "saveError": "Zapis nieudany. Spróbuj ponownie." - } + }, + "eventMedicationReminder": "Przypomnienie o leku", + "eventMedicationReminderDesc": "Wysyłane o każdej zaplanowanej godzinie dawki.", + "eventMeasurementAnomaly": "Nietypowy pomiar", + "eventMeasurementAnomalyDesc": "Wysyłane, gdy odczyt znacznie odbiega od Twojego zwykłego zakresu.", + "eventComplianceLow": "Niska zgodność", + "eventComplianceLowDesc": "Wysyłane, gdy odsetek przyjętych dawek z ostatnich dni spadnie poniżej 80 %.", + "eventWithingsSyncFailed": "Synchronizacja Withings nie powiodła się", + "eventWithingsSyncFailedDesc": "Wysyłane, gdy synchronizacja Withings nie powiedzie się kilka razy z rzędu.", + "eventSystemAlert": "Powiadomienie systemowe", + "eventSystemAlertDesc": "Ogólne komunikaty z serwera HealthLog, na przykład okna serwisowe lub uwagi do konta.", + "eventPersonalRecord": "Rekord osobisty", + "eventPersonalRecordDesc": "Wysyłane, gdy odczyt pobije Twój dotychczasowy rekord.", + "eventMoodReminder": "Przypomnienie o nastroju", + "eventMoodReminderDesc": "Codzienne przypomnienie, aby zapisać swój aktualny nastrój." }, "i18n": { "maintainershipBanner": { diff --git a/package.json b/package.json index a4c86f488..60ea87301 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.4.49.2", + "version": "1.4.49.3", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "AGPL-3.0-only", "homepage": "https://healthlog.dev", diff --git a/src/__tests__/i18n-call-site-coverage.test.ts b/src/__tests__/i18n-call-site-coverage.test.ts new file mode 100644 index 000000000..3ef042678 --- /dev/null +++ b/src/__tests__/i18n-call-site-coverage.test.ts @@ -0,0 +1,157 @@ +/** + * v1.4.49.3 — call-site i18n coverage guard. + * + * Marc surfaced raw `insights.relativeHoursAgo`, `notifications.event + * MoodReminder`, and `notifications.eventMoodReminderDesc` strings + * leaking into the UI in v1.4.49. Audit found 28 keys called from real + * code that never existed in any locale bundle — silent regressions + * that accumulated across v1.4.41 (notifications matrix), v1.4.44 + * (measurements.loadError), and v1.4.45 (onboarding.welcome.*). + * + * This test walks every TypeScript / TSX file under `src/` (excluding + * tests + generated code), extracts every literal `t("namespace.key")` + * call, and asserts that each key resolves to a string leaf in + * `messages/en.json`. JSDoc-block matches and known dynamic patterns + * are filtered explicitly. + * + * The existing `i18n-drift-guard.test.ts` pins specific groups; this + * test catches drift on every other call site. The + * `i18n-locale-integrity.test.ts` test then propagates the EN guarantee + * to every other locale via key-set parity. + */ +import { describe, it, expect } from "vitest"; +import { readFileSync, readdirSync, statSync } from "node:fs"; +import { join } from "node:path"; + +const ROOT = join(__dirname, "../.."); +const SRC = join(ROOT, "src"); +const EN_BUNDLE_PATH = join(ROOT, "messages/en.json"); + +const SKIP_DIRS = new Set([ + "__tests__", + "node_modules", + ".next", + "generated", +]); + +function walk(dir: string, out: string[] = []): string[] { + for (const name of readdirSync(dir)) { + if (SKIP_DIRS.has(name)) continue; + const p = join(dir, name); + const stat = statSync(p); + if (stat.isDirectory()) { + walk(p, out); + } else if (name.endsWith(".ts") || name.endsWith(".tsx")) { + out.push(p); + } + } + return out; +} + +const KEY_PATTERN = + /(?)) { + cur = (cur as Record)[p]; + } else { + return null; + } + } + return typeof cur === "string" ? cur : null; +} + +describe("i18n call-site coverage", () => { + it("every literal t() key has a matching leaf in messages/en.json", () => { + const en = JSON.parse(readFileSync(EN_BUNDLE_PATH, "utf8")); + const sites = extractKeys(); + expect(sites.length).toBeGreaterThan(100); + + const missing: CallSite[] = []; + for (const site of sites) { + if (resolveLeaf(en, site.key) === null) { + missing.push(site); + } + } + + if (missing.length > 0) { + const report = missing + .map((m) => ` ❌ ${m.key} (${m.file}:${m.line})`) + .join("\n"); + // v1.4.49.3 — these 28 keys were the gap that reached production. + // The list below stays empty once the audit passes; future + // regressions print as a structured punch list so the offender + // can fix every site in one commit. + throw new Error( + `Found ${missing.length} t() call sites referencing keys that are missing from messages/en.json:\n${report}`, + ); + } + }); +});