@cat-factory/app 0.216.0 → 0.217.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.md +50 -4
  2. package/app/components/board/AddTaskModal.vue +54 -78
  3. package/app/components/board/CreateInitiativeModal.vue +8 -6
  4. package/app/components/{board/InitiativePresetFields.vue → common/DescriptorFields.vue} +57 -63
  5. package/app/components/inputGate/InputGateNotice.vue +1 -1
  6. package/app/components/panels/AgentStepDetail.vue +2 -2
  7. package/app/components/panels/inspector/ServiceTestConfig.vue +1 -1
  8. package/app/components/panels/inspector/TaskExecution.vue +2 -2
  9. package/app/components/settings/WorkspaceSettingsPanel.vue +1 -1
  10. package/app/composables/api/inputGate.ts +1 -1
  11. package/app/modular/nav-contributions.spec.ts +1 -1
  12. package/app/modular/nav-contributions.ts +1 -1
  13. package/app/stores/inputGate.ts +1 -1
  14. package/app/stores/ui/resultViews.ts +1 -1
  15. package/app/types/domain.ts +6 -0
  16. package/app/utils/descriptorFields.spec.ts +126 -0
  17. package/app/utils/descriptorFields.ts +104 -0
  18. package/app/utils/initiative.ts +0 -30
  19. package/app/utils/inputGate.ts +1 -1
  20. package/app/utils/pipelineRender.spec.ts +1 -1
  21. package/app/utils/pipelineRender.ts +1 -1
  22. package/i18n/i18n.config.ts +7 -21
  23. package/i18n/locales/de.json +3 -2
  24. package/i18n/locales/en.json +6 -2
  25. package/i18n/locales/es.json +18 -5
  26. package/i18n/locales/fr.json +18 -5
  27. package/i18n/locales/he.json +88 -86
  28. package/i18n/locales/it.json +3 -2
  29. package/i18n/locales/ja.json +3 -2
  30. package/i18n/locales/pl.json +21 -8
  31. package/i18n/locales/tr.json +3 -2
  32. package/i18n/locales/uk.json +21 -8
  33. package/i18n/plural-forms.spec.ts +76 -0
  34. package/i18n/plural-rules.spec.ts +98 -0
  35. package/i18n/plural-rules.ts +123 -0
  36. package/package.json +2 -2
@@ -2671,6 +2671,7 @@
2671
2671
  "continue": "Continua",
2672
2672
  "submit": "Aggiungi attività",
2673
2673
  "addFailedTitle": "Impossibile aggiungere l'attività",
2674
+ "customFieldsInvalid": "Il modulo di questo tipo di attività è cambiato da quando lo hai aperto, quindi le risposte non corrispondono più a quanto richiesto. Chiudi e riapri la finestra per compilare i campi attuali.",
2674
2675
  "linkFailed": "Attività aggiunta, ma {count} allegato non è stato collegato | Attività aggiunta, ma {count} allegati non sono stati collegati",
2675
2676
  "review": {
2676
2677
  "prUrl": "Pull request",
@@ -4814,7 +4815,6 @@
4814
4815
  "titlePlaceholder": "es. Migrare l'API al nuovo modello di autenticazione",
4815
4816
  "goalField": "Obiettivo",
4816
4817
  "goalPlaceholder": "Descrivi l'obiettivo, i vincoli e l'ambito approssimativo. Il pianificatore lo affina in un piano multi-fase.",
4817
- "pathInvalid": "Inserisci un percorso all'interno del repository (senza \"..\", percorsi assoluti o barre rovesciate).",
4818
4818
  "hint": "Non viene eseguito ancora nulla: dopo la creazione, esegui la pipeline di Pianificazione dell'iniziativa sul blocco. Analizza il codebase e redige il piano multi-fase per la tua approvazione.",
4819
4819
  "submit": "Crea iniziativa",
4820
4820
  "failedTitle": "Impossibile creare l'iniziativa",
@@ -5027,7 +5027,8 @@
5027
5027
  "confirm": "Ignora",
5028
5028
  "keep": "Continua a modificare"
5029
5029
  },
5030
- "edit": "Modifica"
5030
+ "edit": "Modifica",
5031
+ "pathInvalid": "Inserisci un percorso all'interno del repository (senza \"..\", percorsi assoluti o barre rovesciate)."
5031
5032
  },
5032
5033
  "access": {
5033
5034
  "noBoardWrite": "Accesso in sola lettura: puoi visualizzare questa board ma non modificarla.",
@@ -83,7 +83,8 @@
83
83
  "confirm": "破棄",
84
84
  "keep": "編集を続ける"
85
85
  },
86
- "edit": "編集"
86
+ "edit": "編集",
87
+ "pathInvalid": "リポジトリ内のパスを入力してください(\"..\"、絶対パス、バックスラッシュは使用できません)。"
87
88
  },
88
89
  "access": {
89
90
  "noBoardWrite": "読み取り専用アクセス: このボードは閲覧できますが編集はできません。",
@@ -298,6 +299,7 @@
298
299
  "continue": "続行",
299
300
  "submit": "タスクを追加",
300
301
  "addFailedTitle": "タスクを追加できませんでした",
302
+ "customFieldsInvalid": "このタスクタイプのフォームは開いたあとに変更されたため、入力内容が求められているものと一致しません。ダイアログを閉じて開き直し、現在の項目に入力してください。",
301
303
  "linkFailed": "タスクを追加しましたが、{count} 件の添付をリンクできませんでした | タスクを追加しましたが、{count} 件の添付をリンクできませんでした",
302
304
  "review": {
303
305
  "prUrl": "プルリクエスト",
@@ -5942,7 +5944,6 @@
5942
5944
  "titlePlaceholder": "例: API を新しい認証モデルへ移行する",
5943
5945
  "goalField": "ゴール",
5944
5946
  "goalPlaceholder": "ゴール、制約、おおまかなスコープを記述してください。プランナーが複数フェーズの計画に練り上げます。",
5945
- "pathInvalid": "リポジトリ内のパスを入力してください(\"..\"、絶対パス、バックスラッシュは使用できません)。",
5946
5947
  "hint": "この時点では何も実行されません。作成後、このブロックでイニシアチブ計画パイプラインを実行してください。コードベースを分析し、承認用の複数フェーズ計画を起草します。",
5947
5948
  "submit": "イニシアチブを作成",
5948
5949
  "failedTitle": "イニシアチブを作成できませんでした",
@@ -83,7 +83,8 @@
83
83
  "confirm": "Odrzuć",
84
84
  "keep": "Kontynuuj edycję"
85
85
  },
86
- "edit": "Edytuj"
86
+ "edit": "Edytuj",
87
+ "pathInvalid": "Podaj sciezke wewnatrz repozytorium (bez \"..\", sciezek bezwzglednych ani ukosnikow wstecznych)."
87
88
  },
88
89
  "access": {
89
90
  "noBoardWrite": "Dostęp tylko do odczytu: możesz przeglądać tę tablicę, ale nie możesz jej edytować.",
@@ -298,6 +299,7 @@
298
299
  "continue": "Dalej",
299
300
  "submit": "Dodaj zadanie",
300
301
  "addFailedTitle": "Nie udało się dodać zadania",
302
+ "customFieldsInvalid": "Formularz tego typu zadania zmienił się od chwili otwarcia, więc odpowiedzi nie odpowiadają już temu, o co pyta. Zamknij i otwórz okno ponownie, aby wypełnić aktualne pola.",
301
303
  "linkFailed": "Zadanie dodane, ale nie udało się powiązać {count} załącznika | Zadanie dodane, ale nie udało się powiązać {count} załączników | Zadanie dodane, ale nie udało się powiązać {count} załączników",
302
304
  "review": {
303
305
  "prUrl": "Pull request",
@@ -3224,13 +3226,25 @@
3224
3226
  "executionBackend": {
3225
3227
  "label": "Gdzie działają agenci",
3226
3228
  "local-docker": "Lokalny Docker (host)",
3229
+ "local-dockerDesc": "Uruchamia każdego agenta w kontenerze na demonie Dockera tej maszyny.",
3227
3230
  "cloudflare-containers": "Cloudflare Containers (wbudowane)",
3228
- "runner-pool": "Samodzielnie hostowana pula runnerów"
3231
+ "cloudflare-containersDesc": "Uruchamia agentów na wbudowanej platformie kontenerów Cloudflare, po jednym na uruchomienie.",
3232
+ "kubernetes": "Klaster Kubernetes",
3233
+ "kubernetesDesc": "Uruchamia każdego agenta jako pod w klastrze Kubernetes, którym sam zarządzasz.",
3234
+ "runner-pool": "Własna pula runnerów (HTTP)",
3235
+ "runner-poolDesc": "Kieruje agentów do własnego harmonogramu za API manifestów HTTP.",
3236
+ "k3s": "Lokalny Kubernetes (k3s)",
3237
+ "k3sDesc": "Wypełnione wstępnie dla lokalnego klastra k3s/k3d na tej maszynie: wystarczy wkleić token."
3229
3238
  },
3230
3239
  "testEnvBackend": {
3231
3240
  "label": "Gdzie działają środowiska testowe",
3232
- "local-compose": "docker-compose w kontenerze",
3233
- "environment-provider": "Dostawca środowisk",
3241
+ "local-compose": "docker-compose w kontenerze (zależności Testera)",
3242
+ "local-composeDesc": "Uruchamia zależności Testera przez docker-compose wewnątrz kontenera uruchomienia. Pełne środowisko podglądu aplikacji znajdziesz w opcji środowiska podglądu Docker Compose poniżej.",
3243
+ "kubernetes": "Klaster Kubernetes",
3244
+ "kubernetesDesc": "Tworzy przestrzeń nazw dla każdego PR w klastrze Kubernetes, którym sam zarządzasz. Najlepsze dla aplikacji natywnych dla Kubernetes lub gdy już masz klaster.",
3245
+ "environment-provider": "Własny dostawca HTTP",
3246
+ "environment-providerDesc": "Tworzy efemeryczne środowiska przez własne API zarządzania HTTP. Najlepsze, gdy masz już własne narzędzia do środowisk podglądu.",
3247
+ "compose": "Środowisko podglądu Docker Compose",
3234
3248
  "composeDesc": "Uruchamia własny plik docker-compose.yml repozytorium na lokalnym demonie Dockera jako adres URL Testera. Najlepsze dla lokalnych aplikacji opartych na Compose. Obsługuje gotowe obrazy lub budowanie ze źródeł; wymaga demona Dockera, więc tylko wdrożenia lokalne."
3235
3249
  },
3236
3250
  "dockerComposeInfo": "Usługi Docker Compose uruchamiają się na lokalnym Dockerze środowiska uruchomieniowego: połączenie nie jest potrzebne (kontener uruchomienia musi obsługiwać Docker-in-Docker). Skonfiguruj usługę, port i źródło obrazu (pobranie gotowego lub budowanie ze źródeł) w ustawieniach środowiska usługi.",
@@ -5930,7 +5944,6 @@
5930
5944
  "titlePlaceholder": "np. Migracja API do nowego modelu uwierzytelniania",
5931
5945
  "goalField": "Cel",
5932
5946
  "goalPlaceholder": "Opisz cel, ograniczenia i przyblizony zakres. Planista doprecyzuje to w wielofazowy plan.",
5933
- "pathInvalid": "Podaj sciezke wewnatrz repozytorium (bez \"..\", sciezek bezwzglednych ani ukosnikow wstecznych).",
5934
5947
  "hint": "Nic jeszcze nie jest uruchamiane: po utworzeniu uruchom na bloku pipeline planowania inicjatywy. Analizuje on kod i przygotowuje wielofazowy plan do zatwierdzenia.",
5935
5948
  "submit": "Utworz inicjatywe",
5936
5949
  "failedTitle": "Nie udalo sie utworzyc inicjatywy",
@@ -6286,7 +6299,7 @@
6286
6299
  "viewPr": "Zobacz pull request",
6287
6300
  "stalledNote": "Pętla zatrzymała się przedwcześnie: kolejne iteracje nie zmieniły gałęzi, a polecenie walidacji nadal kończy się niepowodzeniem, więc nie zużyła reszty budżetu. Zanim ponowna próba cokolwiek da, prawdopodobnie trzeba zmienić zadanie lub polecenie walidacji.",
6288
6301
  "iterationsHeading": "Iteracje",
6289
- "iterationsTruncated": "Nie pokazano {count} wcześniejszej iteracji (historia jest ograniczona). | Nie pokazano {count} wcześniejszych iteracji (historia jest ograniczona).",
6302
+ "iterationsTruncated": "Nie pokazano {count} wcześniejszej iteracji (historia jest ograniczona). | Nie pokazano {count} wcześniejszych iteracji (historia jest ograniczona). | Nie pokazano {count} wcześniejszych iteracji (historia jest ograniczona).",
6290
6303
  "iteration": "Iteracja {number}",
6291
6304
  "iterationPassed": "walidacja zaliczona",
6292
6305
  "iterationFailed": "kod {exit}",
@@ -6423,9 +6436,9 @@
6423
6436
  "body": "Uruchomienia agentów wypychają zmiany poświadczeniem, które może zapisywać w każdym objętym repozytorium. Nic tutaj nie powstrzyma skompromitowanego uruchomienia przed wypchnięciem zmian wprost na gałąź domyślną ani przed scaleniem własnego pull requesta przez API hosta — oba przypadki pokrywa wyłącznie ochrona gałęzi po stronie hosta, którą konfigurujesz sam. To sprawdzenie pokazuje, czy jest włączona.",
6424
6437
  "check": "Sprawdź ochronę",
6425
6438
  "allProtected": "Gałąź domyślna każdego powiązanego repozytorium jest chroniona.",
6426
- "exposed": "{count} repozytorium ma niechronioną gałąź domyślną. | {count} repozytoria mają niechronioną gałąź domyślną.",
6439
+ "exposed": "{count} repozytorium ma niechronioną gałąź domyślną. | {count} repozytoria mają niechronioną gałąź domyślną. | {count} repozytoriów ma niechronioną gałąź domyślną.",
6427
6440
  "unavailable": "Podłączony dostawca nie potrafi zgłosić ochrony gałęzi, więc to sprawdzenie nic nie mówi — to nie jest wynik pozytywny.",
6428
- "omitted": "Nie sprawdzono jeszcze {count} powiązanego repozytorium. | Nie sprawdzono jeszcze {count} powiązanych repozytoriów.",
6441
+ "omitted": "Nie sprawdzono jeszcze {count} powiązanego repozytorium. | Nie sprawdzono jeszcze {count} powiązanych repozytoriów. | Nie sprawdzono jeszcze {count} powiązanych repozytoriów.",
6429
6442
  "state": {
6430
6443
  "protected": "Chroniona",
6431
6444
  "unprotected": "Niechroniona",
@@ -83,7 +83,8 @@
83
83
  "confirm": "At",
84
84
  "keep": "Düzenlemeye devam et"
85
85
  },
86
- "edit": "Düzenle"
86
+ "edit": "Düzenle",
87
+ "pathInvalid": "Depo icinde bir yol girin (\"..\", mutlak yollar veya ters egik cizgi olmadan)."
87
88
  },
88
89
  "access": {
89
90
  "noBoardWrite": "Salt okunur erişim: bu panoyu görüntüleyebilirsiniz ancak düzenleyemezsiniz.",
@@ -298,6 +299,7 @@
298
299
  "continue": "Devam",
299
300
  "submit": "Görev ekle",
300
301
  "addFailedTitle": "Görev eklenemedi",
302
+ "customFieldsInvalid": "Bu görev türünün formu siz açtıktan sonra değişti, bu nedenle yanıtlar artık istenenle uyuşmuyor. İletişim kutusunu kapatıp yeniden açarak güncel alanları doldurun.",
301
303
  "linkFailed": "Görev eklendi, ancak {count} ek bağlanamadı | Görev eklendi, ancak {count} ek bağlanamadı",
302
304
  "review": {
303
305
  "prUrl": "Pull request",
@@ -5942,7 +5944,6 @@
5942
5944
  "titlePlaceholder": "orn. API'yi yeni kimlik dogrulama modeline tasi",
5943
5945
  "goalField": "Hedef",
5944
5946
  "goalPlaceholder": "Hedefi, kisitlari ve yaklasik kapsami aciklayin. Planlayici bunu cok asamali bir plana donusturur.",
5945
- "pathInvalid": "Depo icinde bir yol girin (\"..\", mutlak yollar veya ters egik cizgi olmadan).",
5946
5947
  "hint": "Henuz hicbir sey calismiyor: olusturduktan sonra blok uzerinde Girisim Planlama hattini calistirin. Kod tabanini analiz eder ve onayiniz icin cok asamali plani taslak olarak hazirlar.",
5947
5948
  "submit": "Girisim olustur",
5948
5949
  "failedTitle": "Girisim olusturulamadi",
@@ -83,7 +83,8 @@
83
83
  "confirm": "Відхилити",
84
84
  "keep": "Продовжити редагування"
85
85
  },
86
- "edit": "Редагувати"
86
+ "edit": "Редагувати",
87
+ "pathInvalid": "Введіть шлях усередині репозиторію (без \"..\", абсолютних шляхів або зворотних скісних рисок)."
87
88
  },
88
89
  "access": {
89
90
  "noBoardWrite": "Доступ лише для перегляду: ви можете переглядати цю дошку, але не можете її редагувати.",
@@ -298,6 +299,7 @@
298
299
  "continue": "Продовжити",
299
300
  "submit": "Додати завдання",
300
301
  "addFailedTitle": "Не вдалося додати завдання",
302
+ "customFieldsInvalid": "Форма цього типу завдання змінилася з моменту, коли ви її відкрили, тому відповіді більше не відповідають запитаному. Закрийте та відкрийте вікно знову, щоб заповнити поточні поля.",
301
303
  "linkFailed": "Завдання додано, але не вдалося прив’язати {count} вкладення | Завдання додано, але не вдалося прив’язати {count} вкладення | Завдання додано, але не вдалося прив’язати {count} вкладень",
302
304
  "review": {
303
305
  "prUrl": "Pull request",
@@ -3224,13 +3226,25 @@
3224
3226
  "executionBackend": {
3225
3227
  "label": "Де виконуються агенти",
3226
3228
  "local-docker": "Локальний Docker (хост)",
3229
+ "local-dockerDesc": "Запускає кожного агента в контейнері на демоні Docker цієї машини.",
3227
3230
  "cloudflare-containers": "Cloudflare Containers (вбудовано)",
3228
- "runner-pool": "Самостійно розміщений пул раннерів"
3231
+ "cloudflare-containersDesc": "Запускає агентів на вбудованій платформі контейнерів Cloudflare, по одному на запуск.",
3232
+ "kubernetes": "Кластер Kubernetes",
3233
+ "kubernetesDesc": "Запускає кожного агента як pod у кластері Kubernetes, яким ви керуєте.",
3234
+ "runner-pool": "Власний пул раннерів (HTTP)",
3235
+ "runner-poolDesc": "Надсилає агентів до вашого власного планувальника за API маніфестів HTTP.",
3236
+ "k3s": "Локальний Kubernetes (k3s)",
3237
+ "k3sDesc": "Попередньо заповнено для локального кластера k3s/k3d на цій машині: достатньо вставити токен."
3229
3238
  },
3230
3239
  "testEnvBackend": {
3231
3240
  "label": "Де виконуються тестові середовища",
3232
- "local-compose": "docker-compose у контейнері",
3233
- "environment-provider": "Провайдер середовищ",
3241
+ "local-compose": "docker-compose у контейнері (залежності Тестувальника)",
3242
+ "local-composeDesc": "Піднімає залежності Тестувальника через docker-compose всередині контейнера запуску. Для повного середовища попереднього перегляду застосунку скористайтеся опцією середовища Docker Compose нижче.",
3243
+ "kubernetes": "Кластер Kubernetes",
3244
+ "kubernetesDesc": "Створює простір імен для кожного PR у кластері Kubernetes, яким ви керуєте. Найкраще підходить для застосунків, орієнтованих на Kubernetes, або якщо ви вже маєте кластер.",
3245
+ "environment-provider": "Власний провайдер HTTP",
3246
+ "environment-providerDesc": "Створює ефемерні середовища через ваш власний API керування HTTP. Найкраще підходить, якщо у вас уже є власні інструменти для середовищ попереднього перегляду.",
3247
+ "compose": "Середовище попереднього перегляду Docker Compose",
3234
3248
  "composeDesc": "Піднімає власний docker-compose.yml репозиторію на локальному демоні Docker як URL Тестувальника. Найкраще підходить для локальних застосунків на основі Compose. Підтримує готові образи або збирання з вихідного коду; потребує демона Docker, тому лише для локальних розгортань."
3235
3249
  },
3236
3250
  "dockerComposeInfo": "Служби Docker Compose піднімаються на локальному Docker середовища виконання: підключення не потрібне (контейнер запуску має підтримувати Docker-in-Docker). Налаштуйте службу, порт і джерело образу (завантаження готового чи збирання з вихідного коду) у налаштуваннях середовища служби.",
@@ -5930,7 +5944,6 @@
5930
5944
  "titlePlaceholder": "напр. Перенести API на нову модель автентифікації",
5931
5945
  "goalField": "Мета",
5932
5946
  "goalPlaceholder": "Опишіть мету, обмеження та приблизний обсяг. Планувальник перетворить це на багатофазний план.",
5933
- "pathInvalid": "Введіть шлях усередині репозиторію (без \"..\", абсолютних шляхів або зворотних скісних рисок).",
5934
5947
  "hint": "Поки що нічого не запускається: після створення запустіть на блоці пайплайн планування ініціативи. Він аналізує кодову базу та готує багатофазний план на ваше затвердження.",
5935
5948
  "submit": "Створити ініціативу",
5936
5949
  "failedTitle": "Не вдалося створити ініціативу",
@@ -6286,7 +6299,7 @@
6286
6299
  "viewPr": "Переглянути pull request",
6287
6300
  "stalledNote": "Цикл зупинився достроково: послідовні ітерації не змінили гілку, а команда перевірки досі не проходить, тож решту бюджету не витрачено. Імовірно, перш ніж повторний запуск допоможе, потрібно змінити завдання або команду перевірки.",
6288
6301
  "iterationsHeading": "Ітерації",
6289
- "iterationsTruncated": "Не показано {count} попередню ітерацію (історія обмежена). | Не показано {count} попередніх ітерацій (історія обмежена).",
6302
+ "iterationsTruncated": "Не показано {count} попередню ітерацію (історія обмежена). | Не показано {count} попередні ітерації (історія обмежена). | Не показано {count} попередніх ітерацій (історія обмежена).",
6290
6303
  "iteration": "Ітерація {number}",
6291
6304
  "iterationPassed": "перевірку пройдено",
6292
6305
  "iterationFailed": "код {exit}",
@@ -6423,9 +6436,9 @@
6423
6436
  "body": "Запуски агентів надсилають зміни обліковими даними, які можуть писати в кожен охоплений репозиторій. Ніщо тут не завадить скомпрометованому запуску надіслати зміни просто до типової гілки або злити власний запит на злиття через API хоста — обидва випадки покриває лише захист гілок на боці хоста, і його налаштовуєте ви. Ця перевірка показує, чи він увімкнений.",
6424
6437
  "check": "Перевірити захист",
6425
6438
  "allProtected": "Типова гілка кожного приєднаного репозиторію захищена.",
6426
- "exposed": "{count} репозиторій має незахищену типову гілку. | {count} репозиторії мають незахищену типову гілку.",
6439
+ "exposed": "{count} репозиторій має незахищену типову гілку. | {count} репозиторії мають незахищену типову гілку. | {count} репозиторіїв мають незахищену типову гілку.",
6427
6440
  "unavailable": "Приєднаний постачальник не вміє повідомляти про захист гілок, тож цій перевірці нема чого сказати — це не чистий результат.",
6428
- "omitted": "Ще {count} приєднаний репозиторій не перевірено. | Ще {count} приєднаних репозиторіїв не перевірено.",
6441
+ "omitted": "Ще {count} приєднаний репозиторій не перевірено. | Ще {count} приєднані репозиторії не перевірено. | Ще {count} приєднаних репозиторіїв не перевірено.",
6429
6442
  "state": {
6430
6443
  "protected": "Захищена",
6431
6444
  "unprotected": "Незахищена",
@@ -0,0 +1,76 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { describe, expect, it } from 'vitest'
4
+ import { allowedFormCounts, type OverriddenLocale } from './plural-rules'
5
+
6
+ // Catalog invariant for the locales whose plural selector is overridden (`plural-rules.ts`).
7
+ //
8
+ // Neither existing i18n gate can see this class of breakage. `i18n:check` only asks whether a
9
+ // key EXISTS, and the locale-parity guard only asks whether a key MOVED with `en`; both pass on
10
+ // a `he` entry that carries the wrong NUMBER of pipe-separated forms. The damage is silent and
11
+ // total: the form count is what tells the selector whether the entry leads with a zero form, so
12
+ // one form too few does not drop a case, it re-points every remaining slot onto a different
13
+ // count. Too few forms outright is worse still, since the clamp then renders one form for
14
+ // several distinct counts.
15
+ //
16
+ // (This lives as a test rather than a `scripts/*.mjs` guard like its two neighbours so it can
17
+ // import the slot contract from `plural-rules.ts` instead of restating the allowed counts,
18
+ // which is precisely the coupling that would rot.)
19
+
20
+ const LOCALES: OverriddenLocale[] = ['pl', 'uk', 'he']
21
+ const SOURCE_LOCALE = 'en'
22
+
23
+ // Resolved off the vitest root (the package dir) rather than `import.meta.url`, which the
24
+ // happy-dom environment rewrites to a server-root-relative path.
25
+ function loadCatalog(locale: string): Map<string, string> {
26
+ const path = join(process.cwd(), 'i18n', 'locales', `${locale}.json`)
27
+ const out = new Map<string, string>()
28
+ const walk = (node: unknown, prefix: string) => {
29
+ for (const [key, value] of Object.entries(node as Record<string, unknown>)) {
30
+ // `@<key>` siblings are translator notes, live only in `en`, and are never rendered —
31
+ // their prose may contain a pipe without being a plural.
32
+ if (key.startsWith('@')) continue
33
+ const path = prefix ? `${prefix}.${key}` : key
34
+ if (typeof value === 'string') out.set(path, value)
35
+ else if (value && typeof value === 'object') walk(value, path)
36
+ }
37
+ }
38
+ walk(JSON.parse(readFileSync(path, 'utf8')), '')
39
+ return out
40
+ }
41
+
42
+ const catalogs = new Map(
43
+ [SOURCE_LOCALE, ...LOCALES].map((locale) => [locale, loadCatalog(locale)] as const),
44
+ )
45
+
46
+ /** Pipe-separated plural entries only; a message with no pipe never reaches a selector. */
47
+ function pluralEntries(locale: string): [string, string[]][] {
48
+ return [...catalogs.get(locale)!]
49
+ .filter(([, value]) => value.includes('|'))
50
+ .map(([key, value]) => [key, value.split('|')])
51
+ }
52
+
53
+ describe.each(LOCALES)('%s plural entries', (locale) => {
54
+ const allowed = allowedFormCounts(locale)
55
+
56
+ it(`carry ${allowed.join(' or ')} forms`, () => {
57
+ const offenders = pluralEntries(locale)
58
+ .filter(([, forms]) => !allowed.includes(forms.length))
59
+ .map(([key, forms]) => `${key}: ${forms.length} forms (expected ${allowed.join(' or ')})`)
60
+ expect(offenders).toEqual([])
61
+ })
62
+
63
+ // The mirror image: a key `en` pluralizes but this locale renders as one flat string never
64
+ // reaches the selector at all, so it reads as a singular at every count. Nothing else notices,
65
+ // because the key is present and moved with `en` exactly as both other gates require.
66
+ it('pluralize every key `en` pluralizes', () => {
67
+ const catalog = catalogs.get(locale)!
68
+ const missing = pluralEntries(SOURCE_LOCALE)
69
+ .map(([key]) => key)
70
+ .filter((key) => {
71
+ const translated = catalog.get(key)
72
+ return translated !== undefined && !translated.includes('|')
73
+ })
74
+ expect(missing).toEqual([])
75
+ })
76
+ })
@@ -0,0 +1,98 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { allowedFormCounts, pluralRules, type OverriddenLocale } from './plural-rules'
3
+
4
+ // The slot order each overridden locale's CLDR categories fill, matching `plural-rules.ts`.
5
+ // Naming them as CLDR category strings is what lets the tests below compare against
6
+ // `Intl.PluralRules`, the platform's own copy of the same CLDR data: a hand-written rule that
7
+ // drifts from ICU (because CLDR revised the locale, or because the rule was wrong to begin
8
+ // with) fails here instead of quietly rendering the wrong form.
9
+ const CATEGORY_ORDER: Record<OverriddenLocale, readonly Intl.LDMLPluralRule[]> = {
10
+ pl: ['one', 'few', 'many'],
11
+ uk: ['one', 'few', 'many'],
12
+ he: ['one', 'two', 'other'],
13
+ }
14
+
15
+ const LOCALES = Object.keys(CATEGORY_ORDER) as OverriddenLocale[]
16
+
17
+ /** The CLDR category a selector resolves `n` to, for an entry carrying only the CLDR forms. */
18
+ function categoryFor(locale: OverriddenLocale, n: number): Intl.LDMLPluralRule {
19
+ const order = CATEGORY_ORDER[locale]
20
+ return order[pluralRules[locale](n, order.length)]!
21
+ }
22
+
23
+ describe('plural selectors agree with Intl.PluralRules', () => {
24
+ // Every whole count a UI badge can plausibly show. The Slavic rules key off n % 100, and
25
+ // Hebrew's off n itself, so 0..1000 exercises both far past their period.
26
+ it.each(LOCALES)('%s over whole counts 0..1000', (locale) => {
27
+ const icu = new Intl.PluralRules(locale)
28
+ const disagreements = Array.from({ length: 1001 }, (_, n) => n)
29
+ .map((n) => ({ n, ours: categoryFor(locale, n), icu: icu.select(n) }))
30
+ .filter(({ ours, icu: theirs }) => ours !== theirs)
31
+ expect(disagreements).toEqual([])
32
+ })
33
+
34
+ // Hebrew is exact over fractions too. The Slavic locales are deliberately not: CLDR routes
35
+ // their fractions to an `other` category the 3-form catalogs carry no slot for, so the rule
36
+ // folds those onto `many` (documented in `plural-rules.ts`) and cannot be compared here.
37
+ it('he over fractional counts', () => {
38
+ const icu = new Intl.PluralRules('he')
39
+ const fractions = [0.1, 0.5, 0.9, 1.1, 1.5, 2.5, 3.5, 10.5, 20.5, 100.5]
40
+ for (const n of fractions) expect(categoryFor('he', n)).toBe(icu.select(n))
41
+ })
42
+
43
+ it('treats a negative count as its magnitude', () => {
44
+ for (const locale of LOCALES) {
45
+ for (const n of [1, 2, 3, 5, 22]) {
46
+ expect(pluralRules[locale](-n, 3)).toBe(pluralRules[locale](n, 3))
47
+ }
48
+ }
49
+ })
50
+ })
51
+
52
+ describe('the leading zero form', () => {
53
+ // One optional slot may precede the CLDR forms: a copy nicety ("no participants"), NOT a
54
+ // CLDR category. An entry that carries it shifts every other slot by one, which is why the
55
+ // form count is part of the contract rather than an authoring detail.
56
+ it('is selected only for 0, and only when the entry carries it', () => {
57
+ for (const locale of LOCALES) {
58
+ const [cldrOnly, withZero] = allowedFormCounts(locale) as [number, number]
59
+ expect(pluralRules[locale](0, withZero)).toBe(0)
60
+ // Without a zero slot, 0 falls to whatever category the locale puts it in, never to
61
+ // the `one` form.
62
+ expect(pluralRules[locale](0, cldrOnly)).not.toBe(0)
63
+ for (const n of [1, 2, 3, 5, 11, 22]) {
64
+ expect(pluralRules[locale](n, withZero)).toBe(pluralRules[locale](n, cldrOnly) + 1)
65
+ }
66
+ }
67
+ })
68
+ })
69
+
70
+ describe('an entry with too few forms', () => {
71
+ // A short entry is a CI failure (`scripts/i18n-plural-forms.mjs`). At RUNTIME the selector
72
+ // still has to answer with an in-range index: vue-i18n indexes the form array raw and throws
73
+ // out of `t()` on an out-of-range answer, which blanks the whole surface rendering it rather
74
+ // than degrading to an approximate form.
75
+ it('clamps to the last form instead of running off the end', () => {
76
+ for (const locale of LOCALES) {
77
+ for (const forms of [1, 2]) {
78
+ for (let n = 0; n <= 200; n++) {
79
+ const index = pluralRules[locale](n, forms)
80
+ expect(index).toBeGreaterThanOrEqual(0)
81
+ expect(index).toBeLessThan(forms)
82
+ }
83
+ }
84
+ }
85
+ })
86
+ })
87
+
88
+ describe('hebrew', () => {
89
+ // The behaviour this module exists to add: 2 is its own form, where the default 2-form
90
+ // selector lumped it in with the plural.
91
+ it('gives 2 a form of its own', () => {
92
+ const forms = ['one', 'two', 'other']
93
+ expect(forms[pluralRules.he(1, 3)]).toBe('one')
94
+ expect(forms[pluralRules.he(2, 3)]).toBe('two')
95
+ expect(forms[pluralRules.he(3, 3)]).toBe('other')
96
+ expect(forms[pluralRules.he(20, 3)]).toBe('other')
97
+ })
98
+ })
@@ -0,0 +1,123 @@
1
+ // Per-locale plural SELECTORS for vue-i18n: given a count and how many forms a catalog entry
2
+ // carries, return the index of the form to render. Wired onto `pluralRules` in `i18n.config.ts`;
3
+ // this module is deliberately free of Nuxt/vue-i18n imports so it unit-tests as pure logic
4
+ // (`plural-rules.spec.ts`).
5
+ //
6
+ // vue-i18n's BUILT-IN selector implements neither Slavic nor Semitic agreement: for a 2-form
7
+ // entry it picks index 0 when n === 1 and index 1 otherwise, and for a 3-form entry it picks
8
+ // 0/1/2 for n === 0 / n === 1 / n > 1. That is right for `en`/`es`/`fr`/`de`/`it`/`ja`/`tr`
9
+ // (which are therefore NOT listed here) and wrong everywhere below.
10
+ //
11
+ // ## The slot contract a catalog entry declares by its FORM COUNT
12
+ //
13
+ // A locale's CLDR categories fill the trailing slots, in the order named by `CLDR_CATEGORIES`
14
+ // below. One optional slot may precede them: a ZERO form, which is a COPY nicety rather than a
15
+ // CLDR category ("no participants" reads better than "0 participants") and which `en` already
16
+ // uses. So for a locale with 3 CLDR categories:
17
+ //
18
+ // 3 forms -> <cat0> | <cat1> | <cat2>
19
+ // 4 forms -> zero | <cat0> | <cat1> | <cat2>
20
+ //
21
+ // The count is therefore load-bearing: dropping a form does not degrade the message, it
22
+ // RE-POINTS every remaining slot onto a different count. `scripts/i18n-plural-forms.mjs` fails
23
+ // CI on an entry whose form count is not one of the two shapes, because nothing else can catch
24
+ // it (a short entry renders confidently and wrongly, and vue-i18n throws outright when a
25
+ // selector returns an index past the end).
26
+
27
+ /** A vue-i18n plural selector: `(count, formsInThisEntry) => index of the form to render`. */
28
+ export type PluralSelector = (choice: number, choicesLength: number) => number
29
+
30
+ // Polish and Ukrainian share the `few` bucket but NOT the `one` bucket, and one rule serving
31
+ // both is what this pair of functions replaced: Polish reserves `one` for exactly 1, while
32
+ // Ukrainian gives it to every count ending in 1 except the teens, so 21/31/…/101 were rendering
33
+ // the `many` form ("21 репозиторіїв" for "21 репозиторій") on 89 of the first 1000 counts.
34
+ const slavicFew = (mod10: number, mod100: number): boolean =>
35
+ mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)
36
+
37
+ /**
38
+ * Polish one/few/many, e.g. "decyzja | decyzje | decyzji".
39
+ *
40
+ * CLDR also gives Polish an `other` category, reached only by fractional counts. The catalogs
41
+ * carry no slot for it and a count here is always a whole number of things, so a fraction
42
+ * collapses onto `many`, which is the form Polish uses for a decimal anyway ("2,5 decyzji").
43
+ * The same holds for Ukrainian below.
44
+ */
45
+ const polishCategory = (n: number): number => {
46
+ if (n === 1) return 0 // one
47
+ if (slavicFew(n % 10, n % 100)) return 1 // few
48
+ return 2 // many (incl. 0, 5-21, ...)
49
+ }
50
+
51
+ /** Ukrainian one/few/many, e.g. "рішення | рішення | рішень". */
52
+ const ukrainianCategory = (n: number): number => {
53
+ const mod10 = n % 10
54
+ const mod100 = n % 100
55
+ if (mod10 === 1 && mod100 !== 11) return 0 // one (1, 21, 31, ..., 101, ...)
56
+ if (slavicFew(mod10, mod100)) return 1 // few
57
+ return 2 // many (incl. 0, 5-20, ...)
58
+ }
59
+
60
+ /**
61
+ * Hebrew one/two/other (CLDR `he`): a distinct DUAL, which is why running `he` on the default
62
+ * 2-form selector made every count message an approximation. n === 2 takes its own form, both
63
+ * for the lexical duals ("יומיים" for two days, "פעמיים" for twice) and for the spelled-out
64
+ * numeral ordinary prose wants ("שתי משימות" rather than "2 משימות").
65
+ *
66
+ * Fractions follow CLDR too: a count below 1 is `one` (0.5 -> "one"), a fractional count at or
67
+ * above 1 is `other`. `plural-rules.spec.ts` asserts the whole domain against `Intl.PluralRules`,
68
+ * so an ICU/CLDR revision to Hebrew fails a test rather than silently disagreeing with the
69
+ * platform's own formatter.
70
+ *
71
+ * Note the CLDR rule has THREE categories, not the four (one/two/many/other) it carried before
72
+ * the `many` bucket for round tens was retired: modern Hebrew does not inflect for it, so asking
73
+ * a translator to author that form would only produce a duplicate of `other`.
74
+ */
75
+ const hebrewCategory = (n: number): number => {
76
+ const integerPart = Math.floor(n)
77
+ if (n !== integerPart) return integerPart === 0 ? 0 : 2 // one below 1, otherwise other
78
+ if (integerPart === 1) return 0 // one
79
+ if (integerPart === 2) return 1 // two
80
+ return 2 // other (incl. 0)
81
+ }
82
+
83
+ /** How many CLDR categories each overridden locale's rule resolves, in slot order. */
84
+ const CLDR_CATEGORIES = {
85
+ pl: { count: 3, category: polishCategory },
86
+ uk: { count: 3, category: ukrainianCategory },
87
+ he: { count: 3, category: hebrewCategory },
88
+ } as const
89
+
90
+ /** The locales whose plural selector is overridden, i.e. the ones the form-count guard covers. */
91
+ export type OverriddenLocale = keyof typeof CLDR_CATEGORIES
92
+
93
+ /**
94
+ * The form counts a catalog entry may carry in `locale`: the CLDR categories alone, or those
95
+ * preceded by the optional zero form. Exported so the CI guard and this module agree on the
96
+ * contract by construction instead of by two copies of the same numbers.
97
+ */
98
+ export function allowedFormCounts(locale: OverriddenLocale): readonly number[] {
99
+ const { count } = CLDR_CATEGORIES[locale]
100
+ return [count, count + 1]
101
+ }
102
+
103
+ function selectorFor(locale: OverriddenLocale): PluralSelector {
104
+ const { count: cldrForms, category } = CLDR_CATEGORIES[locale]
105
+ return (choice, choicesLength) => {
106
+ const n = Math.abs(choice)
107
+ // More forms than the locale has categories means the entry leads with a zero form.
108
+ const hasZeroForm = choicesLength > cldrForms
109
+ if (hasZeroForm && n === 0) return 0
110
+ const index = (hasZeroForm ? 1 : 0) + category(n)
111
+ // An entry with too FEW forms is a CI failure, not a runtime one: clamping renders the
112
+ // nearest form instead of handing vue-i18n an out-of-range index, which it rejects by
113
+ // throwing out of `t()` and blanking whatever was rendering the message.
114
+ return Math.min(index, choicesLength - 1)
115
+ }
116
+ }
117
+
118
+ /** vue-i18n's `pluralRules` map: only the locales the built-in selector gets wrong. */
119
+ export const pluralRules: Record<OverriddenLocale, PluralSelector> = {
120
+ pl: selectorFor('pl'),
121
+ uk: selectorFor('uk'),
122
+ he: selectorFor('he'),
123
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.216.0",
3
+ "version": "0.217.2",
4
4
  "description": "Reusable Nuxt layer for the Agent Architecture Board SPA (components, stores, composables, pages). Consume it from a thin deployment app via `extends: ['@cat-factory/app']` and point it at your backend with NUXT_PUBLIC_API_BASE. See deploy/frontend for an example.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -40,7 +40,7 @@
40
40
  "valibot": "^1.4.2",
41
41
  "vue": "3.5.40",
42
42
  "wretch": "^3.0.9",
43
- "@cat-factory/contracts": "0.229.0"
43
+ "@cat-factory/contracts": "0.230.1"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@toad-contracts/testing": "0.3.2",