From 179c313f54611ad1666c902e7d6c961a3faaf075 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=B2=20Boschi?= Date: Tue, 16 Jun 2026 14:32:49 +0200 Subject: [PATCH] test(control-plane): validate t() keys resolve against the catalog Add a vitest guard that walks every src .ts/.tsx file, resolves each useTranslations("ns") binding, and asserts every static t("key") / t.rich("key") reference maps to a leaf key in en.json. This closes the gap between the two existing i18n checks: messages.test.ts only compares locale catalogs against each other (a key missing from *every* catalog, en included, passes parity), and find-untranslated.ts does the inverse (flags strings *not* wrapped in t()). Neither walked from a t() call site back to the catalog, so a missing key only surfaced as a runtime next-intl error in the browser. Runs under the existing `npm test` step in the build-control-plane CI job, so no workflow change is needed. The guard immediately surfaced 14 keys referenced by the curation feature (#1976) but missing from all 10 catalogs (filterActive, filterInvalidated, invalidatedHint, invalidatedFactsTitle, and the memoryDetailPanel curation*/editField* set). Add translations for all locales so the suite is green. Supersedes #2226, which patched only filterActive. --- hindsight-control-plane/src/messages/de.json | 20 +- hindsight-control-plane/src/messages/en.json | 20 +- hindsight-control-plane/src/messages/es.json | 20 +- hindsight-control-plane/src/messages/fr.json | 20 +- hindsight-control-plane/src/messages/ja.json | 20 +- hindsight-control-plane/src/messages/ko.json | 20 +- hindsight-control-plane/src/messages/pt.json | 20 +- .../src/messages/yue-Hant.json | 20 +- .../src/messages/zh-CN.json | 20 +- .../src/messages/zh-TW.json | 20 +- .../tests/messages/used-keys.test.ts | 214 ++++++++++++++++++ 11 files changed, 384 insertions(+), 30 deletions(-) create mode 100644 hindsight-control-plane/tests/messages/used-keys.test.ts diff --git a/hindsight-control-plane/src/messages/de.json b/hindsight-control-plane/src/messages/de.json index fceff474b7..476b569df9 100644 --- a/hindsight-control-plane/src/messages/de.json +++ b/hindsight-control-plane/src/messages/de.json @@ -594,7 +594,10 @@ "scopeGlobal": "Global (keine Tags)", "groupByScope": "Nach Bereich gruppieren", "scopeSearch": "Bereiche suchen…", - "scopeNoResults": "Keine Bereiche gefunden" + "scopeNoResults": "Keine Bereiche gefunden", + "filterActive": "Aktiv", + "filterInvalidated": "Ungültig", + "invalidatedHint": "Ungültig gemachte Erinnerungen werden archiviert – aus Recall und Reflect ausgeblendet, aber zu Prüfzwecken aufbewahrt." }, "documentsView": { "loadingDocuments": "Dokumente werden geladen...", @@ -683,7 +686,8 @@ "importConflictReplace": "Ersetzen — durch das importierte überschreiben", "importConflictNewId": "Beide behalten — unter neuer id importieren", "textNotStoredWarning": "Der Quelltext wird auf diesem Server nicht gespeichert – nur die extrahierten Erinnerungen werden behalten.", - "labelObservationScopes": "Beobachtungsbereiche" + "labelObservationScopes": "Beobachtungsbereiche", + "invalidatedFactsTitle": "Ungültige Fakten" }, "entitiesView": { "viewRelations": "Beziehungen", @@ -1290,7 +1294,17 @@ "curationInvalidateExplain": "Dadurch wird die Erinnerung in das Archiv für ungültige Einträge verschoben. Sie erscheint nicht mehr in Recall oder Reflect, bleibt aber zur Prüfung erhalten und kann später wiederhergestellt werden.", "curationReasonPlaceholder": "Grund (optional)", "curationCancel": "Abbrechen", - "curationInvalidate": "Ungültig machen" + "curationInvalidate": "Ungültig machen", + "curationActions": "Kuratierung", + "curationEdit": "Bearbeiten", + "curationReasonLabel": "Grund", + "curationRevert": "Wiederherstellen", + "curationSave": "Speichern", + "curationStateInvalidated": "Ungültig", + "editFieldContext": "Kontext", + "editFieldOccurredEnd": "Enddatum", + "editFieldOccurredStart": "Startdatum", + "editFieldType": "Typ" }, "documentChunkModal": { "documentTitle": "Dokumentdetails", diff --git a/hindsight-control-plane/src/messages/en.json b/hindsight-control-plane/src/messages/en.json index 8b458a970f..fcbd259abd 100644 --- a/hindsight-control-plane/src/messages/en.json +++ b/hindsight-control-plane/src/messages/en.json @@ -594,7 +594,10 @@ "scopeGlobal": "Global (no tags)", "groupByScope": "Group by scope", "scopeSearch": "Search scopes…", - "scopeNoResults": "No scopes found" + "scopeNoResults": "No scopes found", + "filterActive": "Active", + "filterInvalidated": "Invalidated", + "invalidatedHint": "Invalidated memories are archived — hidden from recall and reflect, but kept for audit." }, "documentsView": { "loadingDocuments": "Loading documents...", @@ -683,7 +686,8 @@ "importConflictReplace": "Replace — overwrite with the imported one", "importConflictNewId": "Keep both — import under a new id", "textNotStoredWarning": "Source text isn't stored on this server — only the extracted memories are kept.", - "labelObservationScopes": "Observation scopes" + "labelObservationScopes": "Observation scopes", + "invalidatedFactsTitle": "Invalidated facts" }, "entitiesView": { "viewRelations": "Relations", @@ -1290,7 +1294,17 @@ "curationInvalidateExplain": "This moves the memory to the invalidated archive. It will no longer surface in recall or reflect, but is kept for audit and can be restored later.", "curationReasonPlaceholder": "Reason (optional)", "curationCancel": "Cancel", - "curationInvalidate": "Invalidate" + "curationInvalidate": "Invalidate", + "curationActions": "Curation", + "curationEdit": "Edit", + "curationReasonLabel": "Reason", + "curationRevert": "Restore", + "curationSave": "Save", + "curationStateInvalidated": "Invalidated", + "editFieldContext": "Context", + "editFieldOccurredEnd": "End date", + "editFieldOccurredStart": "Start date", + "editFieldType": "Type" }, "documentChunkModal": { "documentTitle": "Document Details", diff --git a/hindsight-control-plane/src/messages/es.json b/hindsight-control-plane/src/messages/es.json index d2c4698c6b..5293998d2d 100644 --- a/hindsight-control-plane/src/messages/es.json +++ b/hindsight-control-plane/src/messages/es.json @@ -594,7 +594,10 @@ "scopeGlobal": "Global (sin etiquetas)", "groupByScope": "Agrupar por ámbito", "scopeSearch": "Buscar ámbitos…", - "scopeNoResults": "No se encontraron ámbitos" + "scopeNoResults": "No se encontraron ámbitos", + "filterActive": "Activas", + "filterInvalidated": "Invalidadas", + "invalidatedHint": "Las memorias invalidadas se archivan: se ocultan de recall y reflect, pero se conservan para auditoría." }, "documentsView": { "loadingDocuments": "Cargando documentos...", @@ -683,7 +686,8 @@ "importConflictReplace": "Reemplazar — sobrescribir con el importado", "importConflictNewId": "Conservar ambos — importar con un nuevo id", "textNotStoredWarning": "El texto original no se almacena en este servidor: solo se conservan las memorias extraídas.", - "labelObservationScopes": "Ámbitos de observación" + "labelObservationScopes": "Ámbitos de observación", + "invalidatedFactsTitle": "Hechos invalidados" }, "entitiesView": { "viewRelations": "Relaciones", @@ -1290,7 +1294,17 @@ "curationInvalidateExplain": "Esto mueve la memoria al archivo de elementos invalidados. Ya no aparecerá en Recall ni Reflect, pero se conserva para auditoría y puede restaurarse más tarde.", "curationReasonPlaceholder": "Motivo (opcional)", "curationCancel": "Cancelar", - "curationInvalidate": "Invalidar" + "curationInvalidate": "Invalidar", + "curationActions": "Curación", + "curationEdit": "Editar", + "curationReasonLabel": "Motivo", + "curationRevert": "Restaurar", + "curationSave": "Guardar", + "curationStateInvalidated": "Invalidada", + "editFieldContext": "Contexto", + "editFieldOccurredEnd": "Fecha de fin", + "editFieldOccurredStart": "Fecha de inicio", + "editFieldType": "Tipo" }, "documentChunkModal": { "documentTitle": "Detalles del documento", diff --git a/hindsight-control-plane/src/messages/fr.json b/hindsight-control-plane/src/messages/fr.json index 2be72200cb..d7c2e288e5 100644 --- a/hindsight-control-plane/src/messages/fr.json +++ b/hindsight-control-plane/src/messages/fr.json @@ -594,7 +594,10 @@ "scopeGlobal": "Global (sans tags)", "groupByScope": "Grouper par portée", "scopeSearch": "Rechercher des portées…", - "scopeNoResults": "Aucune portée trouvée" + "scopeNoResults": "Aucune portée trouvée", + "filterActive": "Actives", + "filterInvalidated": "Invalidées", + "invalidatedHint": "Les mémoires invalidées sont archivées : masquées de recall et reflect, mais conservées pour audit." }, "documentsView": { "loadingDocuments": "Chargement des documents...", @@ -683,7 +686,8 @@ "importConflictReplace": "Remplacer — écraser par celui importé", "importConflictNewId": "Conserver les deux — importer sous un nouvel id", "textNotStoredWarning": "Le texte source n'est pas conservé sur ce serveur — seules les mémoires extraites sont conservées.", - "labelObservationScopes": "Portées d'observation" + "labelObservationScopes": "Portées d'observation", + "invalidatedFactsTitle": "Faits invalidés" }, "entitiesView": { "viewRelations": "Relations", @@ -1290,7 +1294,17 @@ "curationInvalidateExplain": "Cela déplace la mémoire vers l'archive des éléments invalidés. Elle n'apparaîtra plus dans Recall ni Reflect, mais est conservée à des fins d'audit et peut être restaurée ultérieurement.", "curationReasonPlaceholder": "Raison (facultatif)", "curationCancel": "Annuler", - "curationInvalidate": "Invalider" + "curationInvalidate": "Invalider", + "curationActions": "Curation", + "curationEdit": "Modifier", + "curationReasonLabel": "Motif", + "curationRevert": "Restaurer", + "curationSave": "Enregistrer", + "curationStateInvalidated": "Invalidée", + "editFieldContext": "Contexte", + "editFieldOccurredEnd": "Date de fin", + "editFieldOccurredStart": "Date de début", + "editFieldType": "Type" }, "documentChunkModal": { "documentTitle": "Détails du document", diff --git a/hindsight-control-plane/src/messages/ja.json b/hindsight-control-plane/src/messages/ja.json index bc974d9032..3e3f8b50df 100644 --- a/hindsight-control-plane/src/messages/ja.json +++ b/hindsight-control-plane/src/messages/ja.json @@ -594,7 +594,10 @@ "scopeGlobal": "グローバル(タグなし)", "groupByScope": "スコープでグループ化", "scopeSearch": "スコープを検索…", - "scopeNoResults": "スコープが見つかりません" + "scopeNoResults": "スコープが見つかりません", + "filterActive": "有効", + "filterInvalidated": "無効化済み", + "invalidatedHint": "無効化されたメモリはアーカイブされ、recall と reflect には表示されませんが、監査のために保持されます。" }, "documentsView": { "loadingDocuments": "ドキュメントを読み込み中...", @@ -683,7 +686,8 @@ "importConflictReplace": "置換 — インポート版で上書き", "importConflictNewId": "両方保持 — 新しい id でインポート", "textNotStoredWarning": "このサーバーでは元のテキストは保存されません。抽出されたメモリのみが保持されます。", - "labelObservationScopes": "観測スコープ" + "labelObservationScopes": "観測スコープ", + "invalidatedFactsTitle": "無効化されたファクト" }, "entitiesView": { "viewRelations": "関係", @@ -1290,7 +1294,17 @@ "curationInvalidateExplain": "メモリを無効化アーカイブに移動します。recall や reflect には表示されなくなりますが、監査用に保持され、後で復元できます。", "curationReasonPlaceholder": "理由(任意)", "curationCancel": "キャンセル", - "curationInvalidate": "無効化" + "curationInvalidate": "無効化", + "curationActions": "キュレーション", + "curationEdit": "編集", + "curationReasonLabel": "理由", + "curationRevert": "復元", + "curationSave": "保存", + "curationStateInvalidated": "無効化済み", + "editFieldContext": "コンテキスト", + "editFieldOccurredEnd": "終了日", + "editFieldOccurredStart": "開始日", + "editFieldType": "タイプ" }, "documentChunkModal": { "documentTitle": "ドキュメント詳細", diff --git a/hindsight-control-plane/src/messages/ko.json b/hindsight-control-plane/src/messages/ko.json index 04d02d4516..e075b75b2e 100644 --- a/hindsight-control-plane/src/messages/ko.json +++ b/hindsight-control-plane/src/messages/ko.json @@ -594,7 +594,10 @@ "scopeGlobal": "전역(태그 없음)", "groupByScope": "범위별 그룹화", "scopeSearch": "범위 검색…", - "scopeNoResults": "범위를 찾을 수 없음" + "scopeNoResults": "범위를 찾을 수 없음", + "filterActive": "활성", + "filterInvalidated": "무효화됨", + "invalidatedHint": "무효화된 메모리는 보관되어 recall 및 reflect에 표시되지 않지만 감사를 위해 유지됩니다." }, "documentsView": { "loadingDocuments": "문서 불러오는 중...", @@ -683,7 +686,8 @@ "importConflictReplace": "교체 — 가져온 것으로 덮어쓰기", "importConflictNewId": "둘 다 유지 — 새 id로 가져오기", "textNotStoredWarning": "이 서버에는 원본 텍스트가 저장되지 않습니다. 추출된 메모리만 보관됩니다.", - "labelObservationScopes": "관찰 범위" + "labelObservationScopes": "관찰 범위", + "invalidatedFactsTitle": "무효화된 사실" }, "entitiesView": { "viewRelations": "관계", @@ -1290,7 +1294,17 @@ "curationInvalidateExplain": "메모리를 무효화 보관소로 이동합니다. recall이나 reflect에 더 이상 표시되지 않지만 감사를 위해 보관되며 나중에 복원할 수 있습니다.", "curationReasonPlaceholder": "사유 (선택)", "curationCancel": "취소", - "curationInvalidate": "무효화" + "curationInvalidate": "무효화", + "curationActions": "큐레이션", + "curationEdit": "편집", + "curationReasonLabel": "사유", + "curationRevert": "복원", + "curationSave": "저장", + "curationStateInvalidated": "무효화됨", + "editFieldContext": "컨텍스트", + "editFieldOccurredEnd": "종료일", + "editFieldOccurredStart": "시작일", + "editFieldType": "유형" }, "documentChunkModal": { "documentTitle": "문서 상세 정보", diff --git a/hindsight-control-plane/src/messages/pt.json b/hindsight-control-plane/src/messages/pt.json index f5da7fa477..3789751c41 100644 --- a/hindsight-control-plane/src/messages/pt.json +++ b/hindsight-control-plane/src/messages/pt.json @@ -594,7 +594,10 @@ "scopeGlobal": "Global (sem tags)", "groupByScope": "Agrupar por escopo", "scopeSearch": "Pesquisar escopos…", - "scopeNoResults": "Nenhum escopo encontrado" + "scopeNoResults": "Nenhum escopo encontrado", + "filterActive": "Ativas", + "filterInvalidated": "Invalidadas", + "invalidatedHint": "As memórias invalidadas são arquivadas — ocultas de recall e reflect, mas mantidas para auditoria." }, "documentsView": { "loadingDocuments": "Carregando documentos...", @@ -683,7 +686,8 @@ "importConflictReplace": "Substituir — sobrescrever pelo importado", "importConflictNewId": "Manter ambos — importar com novo id", "textNotStoredWarning": "O texto de origem não é armazenado neste servidor — apenas as memórias extraídas são mantidas.", - "labelObservationScopes": "Escopos de observação" + "labelObservationScopes": "Escopos de observação", + "invalidatedFactsTitle": "Fatos invalidados" }, "entitiesView": { "viewRelations": "Relações", @@ -1290,7 +1294,17 @@ "curationInvalidateExplain": "Isto move a memória para o arquivo de itens invalidados. Ela não aparecerá mais em Recall ou Reflect, mas é mantida para auditoria e pode ser restaurada depois.", "curationReasonPlaceholder": "Motivo (opcional)", "curationCancel": "Cancelar", - "curationInvalidate": "Invalidar" + "curationInvalidate": "Invalidar", + "curationActions": "Curadoria", + "curationEdit": "Editar", + "curationReasonLabel": "Motivo", + "curationRevert": "Restaurar", + "curationSave": "Salvar", + "curationStateInvalidated": "Invalidada", + "editFieldContext": "Contexto", + "editFieldOccurredEnd": "Data de término", + "editFieldOccurredStart": "Data de início", + "editFieldType": "Tipo" }, "documentChunkModal": { "documentTitle": "Detalhes do Documento", diff --git a/hindsight-control-plane/src/messages/yue-Hant.json b/hindsight-control-plane/src/messages/yue-Hant.json index bc63eea707..db0444b9c7 100644 --- a/hindsight-control-plane/src/messages/yue-Hant.json +++ b/hindsight-control-plane/src/messages/yue-Hant.json @@ -594,7 +594,10 @@ "scopeGlobal": "全域(冇標籤)", "groupByScope": "按範圍分組", "scopeSearch": "搜尋範圍…", - "scopeNoResults": "搵唔到範圍" + "scopeNoResults": "搵唔到範圍", + "filterActive": "有效", + "filterInvalidated": "已失效", + "invalidatedHint": "失效咗嘅記憶會封存——唔會喺 recall 同 reflect 度出現,但會保留以供稽核。" }, "documentsView": { "loadingDocuments": "載入文件中...", @@ -683,7 +686,8 @@ "importConflictReplace": "取代 — 以匯入嘅覆寫", "importConflictNewId": "兩者都保留 — 以新 id 匯入", "textNotStoredWarning": "呢個伺服器唔會儲存原始文字,淨係保留擷取到嘅記憶。", - "labelObservationScopes": "觀測範圍" + "labelObservationScopes": "觀測範圍", + "invalidatedFactsTitle": "已失效嘅事實" }, "entitiesView": { "viewRelations": "關係", @@ -1290,7 +1294,17 @@ "curationInvalidateExplain": "呢個動作會將記憶移去已失效封存。佢唔會再喺 Recall 或 Reflect 出現,但會保留以供稽核,可以遲啲復原。", "curationReasonPlaceholder": "原因(可選)", "curationCancel": "取消", - "curationInvalidate": "使失效" + "curationInvalidate": "使失效", + "curationActions": "整理", + "curationEdit": "編輯", + "curationReasonLabel": "原因", + "curationRevert": "還原", + "curationSave": "儲存", + "curationStateInvalidated": "已失效", + "editFieldContext": "情境", + "editFieldOccurredEnd": "結束日期", + "editFieldOccurredStart": "開始日期", + "editFieldType": "類型" }, "documentChunkModal": { "documentTitle": "文件詳情", diff --git a/hindsight-control-plane/src/messages/zh-CN.json b/hindsight-control-plane/src/messages/zh-CN.json index 82d375abfd..7bb844c6ec 100644 --- a/hindsight-control-plane/src/messages/zh-CN.json +++ b/hindsight-control-plane/src/messages/zh-CN.json @@ -594,7 +594,10 @@ "scopeGlobal": "全局(无标签)", "groupByScope": "按范围分组", "scopeSearch": "搜索范围…", - "scopeNoResults": "未找到范围" + "scopeNoResults": "未找到范围", + "filterActive": "有效", + "filterInvalidated": "已失效", + "invalidatedHint": "失效的记忆会被归档——不会出现在 recall 和 reflect 中,但会保留以供审计。" }, "documentsView": { "loadingDocuments": "正在加载文档...", @@ -683,7 +686,8 @@ "importConflictReplace": "替换 — 用导入的覆盖", "importConflictNewId": "两者都保留 — 以新 id 导入", "textNotStoredWarning": "此服务器不存储原始文本,仅保留提取的记忆。", - "labelObservationScopes": "观测范围" + "labelObservationScopes": "观测范围", + "invalidatedFactsTitle": "已失效的事实" }, "entitiesView": { "viewRelations": "关系", @@ -1290,7 +1294,17 @@ "curationInvalidateExplain": "这会将该记忆移至已失效归档。它将不再出现在 Recall 或 Reflect 中,但会保留以供审计,并可在以后恢复。", "curationReasonPlaceholder": "原因(可选)", "curationCancel": "取消", - "curationInvalidate": "失效" + "curationInvalidate": "失效", + "curationActions": "整理", + "curationEdit": "编辑", + "curationReasonLabel": "原因", + "curationRevert": "恢复", + "curationSave": "保存", + "curationStateInvalidated": "已失效", + "editFieldContext": "上下文", + "editFieldOccurredEnd": "结束日期", + "editFieldOccurredStart": "开始日期", + "editFieldType": "类型" }, "documentChunkModal": { "documentTitle": "文档详情", diff --git a/hindsight-control-plane/src/messages/zh-TW.json b/hindsight-control-plane/src/messages/zh-TW.json index 253ca999ec..5f71b022f3 100644 --- a/hindsight-control-plane/src/messages/zh-TW.json +++ b/hindsight-control-plane/src/messages/zh-TW.json @@ -594,7 +594,10 @@ "scopeGlobal": "全域(無標籤)", "groupByScope": "按範圍分組", "scopeSearch": "搜尋範圍…", - "scopeNoResults": "找不到範圍" + "scopeNoResults": "找不到範圍", + "filterActive": "有效", + "filterInvalidated": "已失效", + "invalidatedHint": "失效的記憶會被封存——不會出現在 recall 和 reflect 中,但會保留以供稽核。" }, "documentsView": { "loadingDocuments": "正在載入文件…", @@ -683,7 +686,8 @@ "importConflictReplace": "取代 — 以匯入的覆寫", "importConflictNewId": "兩者都保留 — 以新 id 匯入", "textNotStoredWarning": "此伺服器不會儲存原始文字,僅保留擷取的記憶。", - "labelObservationScopes": "觀測範圍" + "labelObservationScopes": "觀測範圍", + "invalidatedFactsTitle": "已失效的事實" }, "entitiesView": { "viewRelations": "關係", @@ -1290,7 +1294,17 @@ "curationInvalidateExplain": "這會將該記憶移至已失效封存。它將不再出現在 Recall 或 Reflect 中,但會保留以供稽核,並可在日後復原。", "curationReasonPlaceholder": "原因(可選)", "curationCancel": "取消", - "curationInvalidate": "失效" + "curationInvalidate": "失效", + "curationActions": "整理", + "curationEdit": "編輯", + "curationReasonLabel": "原因", + "curationRevert": "還原", + "curationSave": "儲存", + "curationStateInvalidated": "已失效", + "editFieldContext": "情境", + "editFieldOccurredEnd": "結束日期", + "editFieldOccurredStart": "開始日期", + "editFieldType": "類型" }, "documentChunkModal": { "documentTitle": "文件詳情", diff --git a/hindsight-control-plane/tests/messages/used-keys.test.ts b/hindsight-control-plane/tests/messages/used-keys.test.ts new file mode 100644 index 0000000000..09bd70dc23 --- /dev/null +++ b/hindsight-control-plane/tests/messages/used-keys.test.ts @@ -0,0 +1,214 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync, readdirSync, statSync } from "node:fs"; +import { join, relative, resolve } from "node:path"; +import ts from "typescript"; + +import { defaultLocale } from "@/i18n/config"; + +/** + * Static guard for translation keys *referenced in code*. + * + * The sibling parity test (`messages.test.ts`) only compares locale catalogs + * against each other, so a key that is missing from *every* catalog — including + * the default locale — passes parity unnoticed. And `find-untranslated.ts` does + * the inverse, flagging hardcoded strings that are NOT wrapped in `t(...)`. + * Neither one ever walks from a `t("key")` call site back to the catalog, so a + * `t("dataView.filterActive")` with no entry anywhere only surfaces as a + * runtime next-intl error in the browser. + * + * This test closes that gap. It walks every `.ts`/`.tsx` file under `src`, + * resolves each `useTranslations("ns")` binding to its namespace, and asserts + * that every statically-resolvable `t("key")` / `t.rich("key")` reference maps + * to a leaf key present in the default-locale catalog. + * + * Limitations (intentional, to avoid false positives that would block + * unrelated PRs): + * - Dynamic keys (`t(`a_${x}`)`, `t(someVar)`) can't be resolved statically + * and are skipped. + * - `t.has("key")` is skipped — it exists precisely to probe for keys that + * may be absent. + * - If a variable name is bound to more than one namespace in a single file + * (rare), a key is accepted when it resolves under *any* of them. + */ + +const ROOT = resolve(__dirname, "..", ".."); +const SRC_DIRS = ["src/components", "src/app", "src/lib", "src/hooks"]; +const MESSAGES_DIR = join(ROOT, "src", "messages"); + +// next-intl accessor methods that take a message key as their first argument. +// `has` is deliberately excluded: it is the sanctioned way to test for a key's +// presence, so an absent key there is expected, not a bug. +const KEY_METHODS = new Set(["rich", "markup", "raw"]); + +type Catalog = Record; + +function collectLeafKeys(obj: unknown, prefix = "", out: Set = new Set()): Set { + if (obj && typeof obj === "object" && !Array.isArray(obj)) { + for (const [k, v] of Object.entries(obj as Catalog)) { + const path = prefix ? `${prefix}.${k}` : k; + if (v && typeof v === "object" && !Array.isArray(v)) { + collectLeafKeys(v, path, out); + } else { + out.add(path); + } + } + } + return out; +} + +function walkDir(dir: string, files: string[] = []): string[] { + let entries: string[]; + try { + entries = readdirSync(dir); + } catch { + return files; // directory may not exist (e.g. no src/hooks) — that's fine + } + for (const name of entries) { + if (name.startsWith(".") || name === "node_modules") continue; + const full = join(dir, name); + if (statSync(full).isDirectory()) { + walkDir(full, files); + } else if (name.endsWith(".tsx") || name.endsWith(".ts")) { + files.push(full); + } + } + return files; +} + +interface MissingKey { + file: string; + line: number; + attempted: string[]; +} + +// Unwrap `("key" as const)` / parenthesized expressions down to the inner node. +function unwrap(node: ts.Expression): ts.Expression { + let cur = node; + while (ts.isAsExpression(cur) || ts.isParenthesizedExpression(cur)) { + cur = cur.expression; + } + return cur; +} + +function staticString(node: ts.Expression): string | undefined { + const inner = unwrap(node); + if (ts.isStringLiteral(inner) || ts.isNoSubstitutionTemplateLiteral(inner)) { + return inner.text; + } + return undefined; +} + +interface VarBinding { + namespaces: Set; // resolvable namespaces this var name was bound to + dynamic: boolean; // bound at least once to a non-static namespace +} + +function scanFile(filePath: string, leafKeys: Set): MissingKey[] { + const src = readFileSync(filePath, "utf-8"); + const sf = ts.createSourceFile(filePath, src, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); + + // Pass 1: map each variable bound to `useTranslations(...)` to its namespace. + const bindings = new Map(); + const record = (name: string, ns: string | undefined) => { + const b = bindings.get(name) ?? { namespaces: new Set(), dynamic: false }; + if (ns === undefined) b.dynamic = true; + else b.namespaces.add(ns); + bindings.set(name, b); + }; + + const collectBindings = (node: ts.Node) => { + if ( + ts.isVariableDeclaration(node) && + node.initializer && + ts.isCallExpression(node.initializer) && + ts.isIdentifier(node.initializer.expression) && + node.initializer.expression.text === "useTranslations" && + ts.isIdentifier(node.name) + ) { + const arg = node.initializer.arguments[0]; + const ns = arg === undefined ? "" : staticString(arg); + record(node.name.text, ns); + } + ts.forEachChild(node, collectBindings); + }; + collectBindings(sf); + + if (bindings.size === 0) return []; + + // Pass 2: find every `t("key")` / `t.rich("key")` call on a tracked binding. + const missing: MissingKey[] = []; + const resolves = (key: string, b: VarBinding): boolean => { + if (b.dynamic) return true; // can't prove a violation + for (const ns of b.namespaces) { + if (leafKeys.has(ns === "" ? key : `${ns}.${key}`)) return true; + } + return false; + }; + const attemptedKeys = (key: string, b: VarBinding): string[] => + [...b.namespaces].map((ns) => (ns === "" ? key : `${ns}.${key}`)); + + const checkCalls = (node: ts.Node) => { + if (ts.isCallExpression(node)) { + const callee = node.expression; + let varName: string | undefined; + if (ts.isIdentifier(callee)) { + varName = callee.text; // t("key") + } else if ( + ts.isPropertyAccessExpression(callee) && + ts.isIdentifier(callee.expression) && + KEY_METHODS.has(callee.name.text) + ) { + varName = callee.expression.text; // t.rich("key") + } + + if (varName) { + const binding = bindings.get(varName); + const firstArg = node.arguments[0]; + if (binding && firstArg) { + const key = staticString(firstArg); + // Static key that doesn't resolve under any bound namespace → missing. + if (key !== undefined && !resolves(key, binding)) { + const { line } = sf.getLineAndCharacterOfPosition(node.getStart(sf)); + missing.push({ + file: relative(ROOT, filePath), + line: line + 1, + attempted: attemptedKeys(key, binding), + }); + } + } + } + } + ts.forEachChild(node, checkCalls); + }; + checkCalls(sf); + + return missing; +} + +describe("translation key references resolve against the default catalog", () => { + const baseline = JSON.parse(readFileSync(join(MESSAGES_DIR, `${defaultLocale}.json`), "utf-8")); + const leafKeys = collectLeafKeys(baseline); + + const files: string[] = []; + for (const dir of SRC_DIRS) walkDir(join(ROOT, dir), files); + files.sort(); + + it("scans a non-trivial number of source files", () => { + // Guards against a refactor silently pointing the walker at an empty tree. + expect(files.length).toBeGreaterThan(10); + }); + + it("every static t(...) key exists in the default locale", () => { + const missing: MissingKey[] = []; + for (const f of files) missing.push(...scanFile(f, leafKeys)); + + const report = missing.map( + (m) => + `${m.file}:${m.line} t("…") → none of [${m.attempted.join(", ")}] exist in ${defaultLocale}.json` + ); + expect( + report, + `translation keys referenced in code but missing from ${defaultLocale}.json` + ).toEqual([]); + }); +});