@cat-factory/app 0.217.1 → 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.
package/README.md CHANGED
@@ -577,10 +577,12 @@ layer ships the base `en` locale, and a downstream deployment overrides by dropp
577
577
  [`docs/localization.md`](../../docs/localization.md).
578
578
 
579
579
  - `i18n/locales/<locale>.json`: the catalogs (the v9+ `i18n/` convention, NOT `app/locales/`).
580
- - `i18n/i18n.config.ts`: runtime vue-i18n behaviour only (fallback locale, the named
581
- `numberFormats`/`datetimeFormats`). Messages are deliberately NOT here so the module can
582
- deep-merge across the `extends` chain. Referenced as the BARE filename
580
+ - `i18n/i18n.config.ts`: runtime vue-i18n behaviour only (fallback locale, the plural
581
+ selectors, the named `numberFormats`/`datetimeFormats`). Messages are deliberately NOT here
582
+ so the module can deep-merge across the `extends` chain. Referenced as the BARE filename
583
583
  `vueI18n: 'i18n.config.ts'`, never `layerDir`-anchored.
584
+ - `i18n/plural-rules.ts`: the per-locale plural selectors, kept beside the config as pure
585
+ logic so they unit-test standalone. See **Plural forms** below.
584
586
  - `package.json` `files` MUST include `"i18n"`. Release-blocking.
585
587
 
586
588
  **Adding a string**: add the key to `en.json` under the feature namespace, resolve with
@@ -605,6 +607,25 @@ so a dynamic lookup is total; **no cross-key concatenation** (a full sentence is
605
607
  until runtime.
606
608
  - Straight quotes, no em-dashes in new entries.
607
609
 
610
+ **Plural forms: how MANY forms an entry carries is part of its contract.** Most locales run on
611
+ vue-i18n's built-in selector, where a 2-form entry is `one | other` and a 3-form entry is
612
+ `zero | one | other` (the leading zero form is a copy nicety, not a CLDR category: "no
613
+ participants" beats "0 participants"). `pl`, `uk` and `he` override that selector in
614
+ `i18n/plural-rules.ts` because the built-in one cannot express their agreement, so their entries
615
+ carry the locale's CLDR categories instead, optionally behind the same zero form:
616
+
617
+ | Locale | CLDR forms | With a zero form |
618
+ | ----------- | --------------------- | ----------------------------- |
619
+ | `pl` / `uk` | `one \| few \| many` | `zero \| one \| few \| many` |
620
+ | `he` | `one \| two \| other` | `zero \| one \| two \| other` |
621
+
622
+ Dropping a form does not drop a case, it RE-POINTS every remaining slot onto a different count,
623
+ so `i18n/plural-forms.spec.ts` fails the build on an entry whose form count is neither shape (and
624
+ on a key `en` pluralizes that one of those three renders flat). Neither other i18n gate can see
625
+ this: the key exists and it moved with `en`, which is all they check. `plural-rules.spec.ts`
626
+ separately pins each selector against `Intl.PluralRules`, so a hand-written rule that disagrees
627
+ with the platform's own CLDR data fails a test rather than shipping.
628
+
608
629
  **Translator descriptions (`@<key>` siblings): default to NONE.** They live only in `en.json` and
609
630
  are notes to a translator, never runtime data. Add one ONLY when a competent translator seeing
610
631
  the English and the key path could plausibly get it wrong: homograph / part-of-speech ambiguity
@@ -633,6 +654,8 @@ rather than being dropped. A new failure-presenting surface copies that split (t
633
654
  4. **Locale parity**: `i18n-locale-parity.mjs --since origin/<base>` requires a PR that adds,
634
655
  changes, or removes an `en.json` key to make the SAME change in every other locale. It is
635
656
  change-coupling against the merge-base, NOT full key parity.
657
+ 5. **Plural shape**: `i18n/plural-forms.spec.ts` fails on a `pl`/`uk`/`he` entry carrying the
658
+ wrong number of forms (see **Plural forms** above), which the other three gates all pass.
636
659
 
637
660
  **Translate for real: NEVER ship an English string as a non-`en` value.** The parity gate checks
638
661
  only that the key exists, so it will pass a verbatim English copy, and that copy is a bug. The
@@ -4,32 +4,18 @@
4
4
  //
5
5
  // Locale MESSAGES are NOT defined here — they live in `i18n/locales/*.json` so the
6
6
  // module can deep-merge them across the `extends` layer chain. This file carries only
7
- // the runtime vue-i18n behaviour (fallback, number/date formats) shared by every locale.
8
- // Slavic one/few/many plural selector (CLDR rule for Polish & Ukrainian), returning the
9
- // 0|1|2 index into a 3-form `"one | few | many"` message. vue-i18n's BUILT-IN pluralizer
10
- // only ever picks index 0 (n===1) or 1/2 by a non-Slavic rule, so without this the pl/uk
11
- // 3-form catalog entries (e.g. board.toolbar.decisionWord "decyzja | decyzje | decyzji")
12
- // render the WRONG form for counts like 2-4 and 22-24. `choicesLength` is unused — the
13
- // three forms are assumed; en/es/fr keep the default 2-form behaviour (not listed here).
14
- const slavicPluralRule = (choice: number): number => {
15
- const n = Math.abs(choice)
16
- const mod10 = n % 10
17
- const mod100 = n % 100
18
- if (n === 1) return 0 // one
19
- if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return 1 // few
20
- return 2 // many (incl. 0, 5-21, …)
21
- }
7
+ // the runtime vue-i18n behaviour (fallback, plural selectors, number/date formats)
8
+ // shared by every locale.
9
+ import { pluralRules } from './plural-rules'
22
10
 
23
11
  export default defineI18nConfig(() => ({
24
12
  legacy: false,
25
13
  fallbackLocale: 'en',
26
14
 
27
- // Per-locale plural selectors. Only the Slavic locales need overriding; the others use
28
- // vue-i18n's default (correct for their 2-form catalogs).
29
- pluralRules: {
30
- pl: slavicPluralRule,
31
- uk: slavicPluralRule,
32
- },
15
+ // Per-locale plural selectors for the locales vue-i18n's built-in pluralizer gets wrong
16
+ // (Slavic one/few/many, Hebrew one/two/other). Everything else keeps the default. The
17
+ // slot contract each catalog entry declares by its form count lives in `plural-rules.ts`.
18
+ pluralRules,
33
19
 
34
20
  // Locale-aware number/currency formatting. Use `$n(value, 'currency')` etc. at call
35
21
  // sites instead of a raw `Intl.NumberFormat`; `$n`/`$d` are thin `Intl` wrappers so
@@ -157,7 +157,7 @@
157
157
  },
158
158
  "toolbar": {
159
159
  "addService": "הוסף שירות",
160
- "decisionWord": "החלטה | החלטות",
160
+ "decisionWord": "החלטה | החלטות | החלטות",
161
161
  "lod": {
162
162
  "far": "סקירה כללית",
163
163
  "mid": "תקציר",
@@ -300,7 +300,7 @@
300
300
  "submit": "הוסף משימה",
301
301
  "addFailedTitle": "לא ניתן היה להוסיף משימה",
302
302
  "customFieldsInvalid": "הטופס של סוג המשימה הזה השתנה מאז שפתחתם אותו, ולכן התשובות אינן תואמות עוד את מה שהוא מבקש. סגרו ופתחו מחדש את החלון כדי למלא את השדות הנוכחיים.",
303
- "linkFailed": "המשימה נוספה, אך {count} צרופה לא ניתנה לקישור | המשימה נוספה, אך {count} צרופות לא ניתנו לקישור",
303
+ "linkFailed": "המשימה נוספה, אך {count} צרופה לא ניתנה לקישור | המשימה נוספה, אך שתי צרופות לא ניתנו לקישור | המשימה נוספה, אך {count} צרופות לא ניתנו לקישור",
304
304
  "review": {
305
305
  "prUrl": "בקשת משיכה",
306
306
  "prUrlHint": "כתובת או מספר של בקשת המשיכה לסקירה",
@@ -368,7 +368,7 @@
368
368
  "showDetail": "הצג פרטים",
369
369
  "retrying": "מנסה שוב…",
370
370
  "history": {
371
- "previousErrors": "שגיאה קודמת {count} | {count} שגיאות קודמות"
371
+ "previousErrors": "שגיאה קודמת אחת | שתי שגיאות קודמות | {count} שגיאות קודמות"
372
372
  }
373
373
  },
374
374
  "stop": {
@@ -410,8 +410,8 @@
410
410
  "noTasksYet": "אין עדיין משימות",
411
411
  "prCount": "{count} PR",
412
412
  "prReadyCount": "{count} PR מוכנים",
413
- "taskCount": "{count} משימה | {count} משימות",
414
- "moduleCount": "{count} מודול | {count} מודולים",
413
+ "taskCount": "{count} משימה | שתי משימות | {count} משימות",
414
+ "moduleCount": "{count} מודול | שני מודולים | {count} מודולים",
415
415
  "addTaskTitle": "הוסף משימה",
416
416
  "createTaskFromIssueTitle": "צור משימה מאישיו",
417
417
  "addRecurringTitle": "הוסף צינור מחזורי",
@@ -665,7 +665,7 @@
665
665
  "noExpiry": "לא הוגדרה תפוגה",
666
666
  "renewal": {
667
667
  "expired": "מנוי ה-{vendor} שלך פג. חדש אותו והתחבר מחדש כדי להמשיך להריץ את המודלים שלו.",
668
- "soon": "מנוי ה-{vendor} שלך מתחדש בעוד יום {count}. עדכן אותו כאן לאחר החידוש. | מנוי ה-{vendor} שלך מתחדש בעוד {count} ימים. עדכן אותו כאן לאחר החידוש."
668
+ "soon": "מנוי ה-{vendor} שלך מתחדש בעוד יום. עדכן אותו כאן לאחר החידוש. | מנוי ה-{vendor} שלך מתחדש בעוד יומיים. עדכן אותו כאן לאחר החידוש. | מנוי ה-{vendor} שלך מתחדש בעוד {count} ימים. עדכן אותו כאן לאחר החידוש."
669
669
  },
670
670
  "toast": {
671
671
  "connected": "מנוי {vendor} מחובר",
@@ -707,7 +707,7 @@
707
707
  "container": {
708
708
  "modulesTitle": "מודולים",
709
709
  "modulesHint": "המודולים בתוך שירות זה. לחץ על מודול כדי לבחור אותו בלוח.",
710
- "taskCount": "משימה {count} | {count} משימות",
710
+ "taskCount": "משימה אחת | שתי משימות | {count} משימות",
711
711
  "tasksTitle": "משימות",
712
712
  "allTasksTitle": "כל המשימות",
713
713
  "tasksHint": "המשימות בתוך בלוק זה. לחץ על משימה כדי לבחור אותה בלוח.",
@@ -1065,7 +1065,7 @@
1065
1065
  "review": "סקור",
1066
1066
  "approve": "אשר",
1067
1067
  "fragmentsTooltip": "קטעי מומלצות עבודה ששולבו בשלב זה: {fragments}",
1068
- "standardsApplied": "תקן {count} הוחל | {count} תקנים הוחלו",
1068
+ "standardsApplied": "תקן אחד הוחל | שני תקנים הוחלו | {count} תקנים הוחלו",
1069
1069
  "companionOf": "{label} (מלווה)",
1070
1070
  "merged": "מוזג",
1071
1071
  "open": "פתוח",
@@ -1119,7 +1119,7 @@
1119
1119
  "modelPreset": "תבנית מודל",
1120
1120
  "modelPresetHint": "קובע על איזה מודל AI רץ כל שלב סוכן.",
1121
1121
  "modelPresetBase": "בסיס {model}",
1122
- "modelPresetOverrides": ", עקיפה {count} | , {count} עקיפות",
1122
+ "modelPresetOverrides": ", עקיפה אחת | , שתי עקיפות | , {count} עקיפות",
1123
1123
  "modelPresetEmpty": "לא הוגדר תבנית. הסוכנים רצים לפי ניתוב ברירת המחדל של הפריסה.",
1124
1124
  "unavailableModels": "אינם זמינים תחת התצורה הנוכחית: {models}. משימה זו תיכשל בשלבים אלה.",
1125
1125
  "editPresets": "ערוך תבניות",
@@ -1188,7 +1188,7 @@
1188
1188
  "detect": {
1189
1189
  "action": "זהה",
1190
1190
  "hint": "הצע בדיקות מתוך המאגר של שירות זה",
1191
- "added": "נוספה בדיקה {count} | נוספו {count} בדיקות",
1191
+ "added": "נוספה בדיקה אחת | נוספו שתי בדיקות | נוספו {count} בדיקות",
1192
1192
  "installOnly": "התקנת התלויות מולאה",
1193
1193
  "found": "זוהה: {ecosystems}.",
1194
1194
  "capped": "חלק מההצעות הושמטו — שירות מקבל לכל היותר {max} בדיקות.",
@@ -1323,7 +1323,7 @@
1323
1323
  "awaitingChoice": "ממתין לבחירה אנושית",
1324
1324
  "approvalGate": "שער אישור",
1325
1325
  "companionReview": "סקירת מלווה",
1326
- "correctionIterations": "{count} מחזור תיקון. | {count} מחזורי תיקון.",
1326
+ "correctionIterations": "מחזור תיקון אחד. | שני מחזורי תיקון. | {count} מחזורי תיקון.",
1327
1327
  "state": {
1328
1328
  "pending": "ממתין",
1329
1329
  "working": "פועל",
@@ -1438,7 +1438,7 @@
1438
1438
  "setupFailed": "ההכנה נכשלה",
1439
1439
  "notRun": "לא הורץ",
1440
1440
  "attempts": "ניסיון {attempts} מתוך {maxAttempts}",
1441
- "omittedTestPaths": "{count} קובץ בדיקה שהוצהר הושמט לפני הרצת ההוכחה, ולכן העץ שלפני התיקון נבנה משחזור חלקי. | {count} קובצי בדיקה שהוצהרו הושמטו לפני הרצת ההוכחה, ולכן העץ שלפני התיקון נבנה משחזור חלקי."
1441
+ "omittedTestPaths": "קובץ בדיקה אחד שהוצהר הושמט לפני הרצת ההוכחה, ולכן העץ שלפני התיקון נבנה משחזור חלקי. | שני קובצי בדיקה שהוצהרו הושמטו לפני הרצת ההוכחה, ולכן העץ שלפני התיקון נבנה משחזור חלקי. | {count} קובצי בדיקה שהוצהרו הושמטו לפני הרצת ההוכחה, ולכן העץ שלפני התיקון נבנה משחזור חלקי."
1442
1442
  }
1443
1443
  },
1444
1444
  "inspector": {
@@ -1491,14 +1491,14 @@
1491
1491
  "title": "למחוק את ה־pipeline החוזר הזה?",
1492
1492
  "body": "\"{name}\", התזמון שלו והיסטוריית ההרצות יימחקו. לא ניתן לבטל פעולה זו."
1493
1493
  },
1494
- "containerBodyWithCount": "\"{name}\" ו-{count} פריט שבתוכו יימחקו. לא ניתן לבטל פעולה זו. | \"{name}\" ו-{count} פריטים שבתוכו יימחקו. לא ניתן לבטל פעולה זו."
1494
+ "containerBodyWithCount": "\"{name}\" והפריט שבתוכו יימחקו. לא ניתן לבטל פעולה זו. | \"{name}\" ושני הפריטים שבתוכו יימחקו. לא ניתן לבטל פעולה זו. | \"{name}\" ו-{count} פריטים שבתוכו יימחקו. לא ניתן לבטל פעולה זו."
1495
1495
  },
1496
1496
  "archiveService": "העברת שירות לארכיון",
1497
1497
  "confirmArchive": {
1498
1498
  "title": "להעביר שירות זה לארכיון?",
1499
1499
  "body": "\"{name}\" והמשימות שלו יוסתרו מהלוח. אפשר לשחזר אותו בכל עת."
1500
1500
  },
1501
- "runBlocked": "חסום על ידי תלות אחת שלא הושלמה: {names} | חסום על ידי {count} תלויות שלא הושלמו: {names}"
1501
+ "runBlocked": "חסום על ידי תלות אחת שלא הושלמה: {names} | חסום על ידי שתי תלויות שלא הושלמו: {names} | חסום על ידי {count} תלויות שלא הושלמו: {names}"
1502
1502
  }
1503
1503
  },
1504
1504
  "observability": {
@@ -1521,7 +1521,7 @@
1521
1521
  "tokensInOut": "טוקנים (נכנס / יוצא)",
1522
1522
  "transportOverhead": "תקורת תעבורה",
1523
1523
  "modelExecution": "הרצת מודל",
1524
- "truncated": "{count} נקטע | {count} נקטעו",
1524
+ "truncated": "{count} נקטע | {count} נקטעו | {count} נקטעו",
1525
1525
  "inputTokensHint": "סך טוקני הקלט / הפלט. הקלט סופר גם טוקנים מהמטמון: הם עדיין תופסים את חלון ההקשר, בדיוק כפי שמד ההקשר של Claude Code סופר אותם.",
1526
1526
  "fresh": "{tokens} חדשים",
1527
1527
  "freshHint": "קלט שעובד מאפס, ללא שום חלק שהוגש מהמטמון",
@@ -1546,7 +1546,7 @@
1546
1546
  "unattributedHint": "נרשם על ידי ערוץ שאינו מדווח על שלב"
1547
1547
  },
1548
1548
  "metricsBar": {
1549
- "calls": "{count} קריאה | {count} קריאות",
1549
+ "calls": "{count} קריאה | שתי קריאות | {count} קריאות",
1550
1550
  "inputCompletionTokens": "סך טוקני הקלט / הפלט. הקלט סופר גם טוקנים מהמטמון: הם עדיין תופסים את חלון ההקשר, בדיוק כפי שמד ההקשר של Claude Code סופר אותם.",
1551
1551
  "costHint": "עלות משוערת של הטוקנים בצעד זה לפי מחירון, כל סוג קלט בתעריף שלו. הרצה במסגרת מנוי אינה משלמת לפי טוקן, ולכן זהו הסכום שאותם טוקנים היו עולים בתשלום לפי שימוש.",
1552
1552
  "fresh": "{tokens} חדשים",
@@ -1555,10 +1555,10 @@
1555
1555
  "cacheReadHint": "טוקני קלט שהוגשו ממטמון הספק (כ-0.1 ממחיר קלט חדש)",
1556
1556
  "cacheWrite": "{tokens} נכתבו למטמון",
1557
1557
  "cacheWriteHint": "טוקני קלט שנכתבו למטמון הספק (פי 1.25 עד 2 ממחיר קלט חדש)",
1558
- "errors": "{count} שגיאה | {count} שגיאות",
1559
- "warnings": "{count} אזהרה | {count} אזהרות",
1558
+ "errors": "{count} שגיאה | שתי שגיאות | {count} שגיאות",
1559
+ "warnings": "{count} אזהרה | שתי אזהרות | {count} אזהרות",
1560
1560
  "outputLimit": "מגבלת פלט",
1561
- "truncatedCalls": "{count} קריאה נקטעה במגבלה | {count} קריאות נקטעו במגבלה",
1561
+ "truncatedCalls": "{count} קריאה נקטעה במגבלה | שתי קריאות נקטעו במגבלה | {count} קריאות נקטעו במגבלה",
1562
1562
  "transportVsExecution": "תעבורה לעומת הרצה",
1563
1563
  "transportOverhead": "תקורת תעבורה / פרוקסי",
1564
1564
  "modelExecution": "הרצת מודל"
@@ -1599,7 +1599,7 @@
1599
1599
  "available": "זמין",
1600
1600
  "unavailable": "לא זמין",
1601
1601
  "provider": "ספק",
1602
- "resultsCount": "תוצאה {count} | {count} תוצאות",
1602
+ "resultsCount": "תוצאה אחת | שתי תוצאות | {count} תוצאות",
1603
1603
  "queriesTitle": "חיפושים שבוצעו"
1604
1604
  }
1605
1605
  },
@@ -1731,14 +1731,14 @@
1731
1731
  "byAgentKind": "עלות לפי סוג סוכן",
1732
1732
  "heading": "עלות",
1733
1733
  "empty": "לא נרשם שימוש בטווח הזה.",
1734
- "calls": "קריאה {count} | {count} קריאות",
1734
+ "calls": "קריאה אחת | שתי קריאות | {count} קריאות",
1735
1735
  "tokens": "{input} נכנס / {output} יוצא",
1736
1736
  "subscriptionAside": "+{value} מנוי"
1737
1737
  },
1738
1738
  "activity": {
1739
1739
  "heading": "ריצות",
1740
1740
  "empty": "לא היו ריצות בטווח הזה.",
1741
- "runs": "ריצה {count} | {count} ריצות",
1741
+ "runs": "ריצה אחת | שתי ריצות | {count} ריצות",
1742
1742
  "avg": "ממוצע {value}"
1743
1743
  },
1744
1744
  "status": {
@@ -2108,7 +2108,7 @@
2108
2108
  "intro": "החיבורים האישיים שלך, המשמשים להרצות שאתה מתחיל וגלויים רק לך.",
2109
2109
  "connected": "מחובר",
2110
2110
  "notConnected": "לא מחובר",
2111
- "connectedCount": "לא מחוברים | {count} מחוברים | {count} מחוברים",
2111
+ "connectedCount": "לא מחוברים | {count} מחובר | {count} מחוברים | {count} מחוברים",
2112
2112
  "sourceControl": {
2113
2113
  "title": "בקרת מקור"
2114
2114
  },
@@ -2278,7 +2278,7 @@
2278
2278
  "keyConnected": "מפתח מחובר",
2279
2279
  "notConnected": "לא מחובר",
2280
2280
  "recommended": "מומלץ",
2281
- "connectedCount": "לא מחוברים | {count} מחוברים | {count} מחוברים"
2281
+ "connectedCount": "לא מחוברים | {count} מחובר | {count} מחוברים | {count} מחוברים"
2282
2282
  },
2283
2283
  "groups": {
2284
2284
  "workspace": "ספקים של סביבת העבודה",
@@ -2374,7 +2374,7 @@
2374
2374
  "deleteDisabledTitle": "לא ניתן למחוק את תצורת ברירת המחדל",
2375
2375
  "deleteTitle": "מחיקת תצורה",
2376
2376
  "basePrefix": "בסיס:",
2377
- "overrideCount": "עקיפה אחת | {count} עקיפות",
2377
+ "overrideCount": "עקיפה אחת | שתי עקיפות | {count} עקיפות",
2378
2378
  "empty": "אין עדיין תצורות. צור אחת כדי למפות מודלים לסוכנים שלך.",
2379
2379
  "customRouteOrder": "סדר מסלולים מותאם"
2380
2380
  },
@@ -2671,7 +2671,7 @@
2671
2671
  "form": {
2672
2672
  "updateConfiguration": "עדכן הגדרות",
2673
2673
  "connect": "התחבר",
2674
- "reenterSecrets": "הזן מחדש את שדה הסוד כדי לשמור שינויים — סודות מאוחסנים הם לכתיבה בלבד ואינם מוצגים. | הזן מחדש את שדות הסוד כדי לשמור שינויים — סודות מאוחסנים הם לכתיבה בלבד ואינם מוצגים.",
2674
+ "reenterSecrets": "הזן מחדש את שדה הסוד כדי לשמור שינויים — סודות מאוחסנים הם לכתיבה בלבד ואינם מוצגים. | הזן מחדש את שני שדות הסוד כדי לשמור שינויים — סודות מאוחסנים הם לכתיבה בלבד ואינם מוצגים. | הזן מחדש את שדות הסוד כדי לשמור שינויים — סודות מאוחסנים הם לכתיבה בלבד ואינם מוצגים.",
2675
2675
  "optionalLabel": "{label} (אופציונלי)",
2676
2676
  "missingFields": "מלא את השדות הנדרשים כדי להתחבר: {fields}."
2677
2677
  },
@@ -3170,7 +3170,7 @@
3170
3170
  "enableRecommended": "אפשר מומלצים",
3171
3171
  "filterPlaceholder": "סינון לפי שם או slug…",
3172
3172
  "empty": "אין עדיין מודלים. לחץ {action} כדי לטעון את הרשימה החיה של OpenRouter.",
3173
- "enabledCount": "אין מודלים מאופשרים | {count} מאופשר | {count} מאופשרים",
3173
+ "enabledCount": "אין מודלים מאופשרים | {count} מאופשר | {count} מאופשרים | {count} מאופשרים",
3174
3174
  "context": "{value} ctx",
3175
3175
  "contextThousands": "{value}K ctx",
3176
3176
  "price": "{input}/{output} לכל Mtok",
@@ -3323,7 +3323,7 @@
3323
3323
  "intro": "כוון סוכנים ל-LLM שרץ על {ownMachine} (Ollama, LM Studio, llama.cpp, vLLM, או כל שרת תואם-OpenAI). מריץ נשמר {justForYou} (מריץ חי על המכונה שלך), והמודלים שאתה מאפשר מופיעים אוטומטית בבוחר המודלים. מפתח ה-API (רוב המריצים מתעלמים ממנו) הוא לכתיבה בלבד ולא יוצג שוב לעולם.",
3324
3324
  "introOwnMachine": "המכונה שלך",
3325
3325
  "introJustForYou": "רק עבורך",
3326
- "modelCount": "אין מודלים | {count} מודל | {count} מודלים",
3326
+ "modelCount": "אין מודלים | {count} מודל | {count} מודלים | {count} מודלים",
3327
3327
  "keySet": "מפתח הוגדר",
3328
3328
  "edit": "ערוך",
3329
3329
  "editRunner": "ערוך מריץ",
@@ -3337,7 +3337,7 @@
3337
3337
  "apiKeyIgnorePlaceholder": "רוב המריצים מתעלמים מזה",
3338
3338
  "testConnection": "בדוק חיבור",
3339
3339
  "unreachable": "לא ניתן היה להגיע למריץ.",
3340
- "reachable": "נגיש · אין מודלים | נגיש · {count} מודל | נגיש · {count} מודלים",
3340
+ "reachable": "נגיש · אין מודלים | נגיש · {count} מודל | נגיש · {count} מודלים | נגיש · {count} מודלים",
3341
3341
  "noModels": "לא דווחו מודלים.",
3342
3342
  "enableModels": "אפשר מודלים",
3343
3343
  "toast": {
@@ -3438,7 +3438,7 @@
3438
3438
  "bar": "מ-{bar}",
3439
3439
  "editTitle": "עריכת קבוצה",
3440
3440
  "deleteTitle": "מחיקת קבוצה",
3441
- "participantCount": "אין משתתפים | משתתף אחד | {count} משתתפים"
3441
+ "participantCount": "אין משתתפים | משתתף אחד | שני משתתפים | {count} משתתפים"
3442
3442
  },
3443
3443
  "editor": {
3444
3444
  "nameLabel": "שם",
@@ -3605,7 +3605,7 @@
3605
3605
  "keyPlaceholder": "הדבק את מפתח ה-API",
3606
3606
  "connect": "חבר",
3607
3607
  "connected": "מחובר ({count})",
3608
- "usage": "{tokens} טוקנים בחלון זה · {count} קריאה | {tokens} טוקנים בחלון זה · {count} קריאות",
3608
+ "usage": "{tokens} טוקנים בחלון זה · קריאה אחת | {tokens} טוקנים בחלון זה · שתי קריאות | {tokens} טוקנים בחלון זה · {count} קריאות",
3609
3609
  "toast": {
3610
3610
  "connected": "מפתח API חובר",
3611
3611
  "connectFailed": "לא ניתן לחבר את המפתח",
@@ -3676,7 +3676,7 @@
3676
3676
  "tokenPlaceholder": "מפתח ה-API של תוכנית הקוד שלך",
3677
3677
  "connect": "חבר",
3678
3678
  "connected": "מחובר ({count})",
3679
- "usage": "{tokens} טוקנים בחלון זה · {count} הרצה | {tokens} טוקנים בחלון זה · {count} הרצות",
3679
+ "usage": "{tokens} טוקנים בחלון זה · הרצה אחת | {tokens} טוקנים בחלון זה · שתי הרצות | {tokens} טוקנים בחלון זה · {count} הרצות",
3680
3680
  "toast": {
3681
3681
  "connected": "הטוקן חובר",
3682
3682
  "connectFailed": "לא ניתן לחבר את הטוקן",
@@ -3804,7 +3804,7 @@
3804
3804
  "title": "הוסף שירות ממאגר",
3805
3805
  "repository": "מאגר",
3806
3806
  "searchPlaceholder": "חפש מאגרים לפי בעלים או שם…",
3807
- "searchMinChars": "הקלד לפחות {min} תו לחיפוש. | הקלד לפחות {min} תווים לחיפוש.",
3807
+ "searchMinChars": "הקלד לפחות תו אחד לחיפוש. | הקלד לפחות שני תווים לחיפוש. | הקלד לפחות {min} תווים לחיפוש.",
3808
3808
  "noMatches": "לא נמצאו מאגרים עבור {query}.",
3809
3809
  "clearSelection": "נקה בחירה",
3810
3810
  "repoLabel": {
@@ -3818,7 +3818,7 @@
3818
3818
  "monorepoBrowseHint": "עיין במאגר ובחר את הספריות של השירותים שברצונך להוסיף — מכל תיקייה. סוכנים העובדים על שירות ירוצו בתוך תת-הספרייה שלו.",
3819
3819
  "selectedServices": "שירותים נבחרים",
3820
3820
  "noServicesSelected": "עדיין לא נבחרו שירותים. בחר ספריות למעלה.",
3821
- "addServices": "הוסף שירות {count} | הוסף {count} שירותים",
3821
+ "addServices": "הוסף שירות אחד | הוסף שני שירותים | הוסף {count} שירותים",
3822
3822
  "removeService": "הסר {directory}",
3823
3823
  "addedConfigure": "{title} נוסף, הגדר אותו",
3824
3824
  "grantAccess": "הענק לאפליקציה גישה למאגר",
@@ -3831,7 +3831,7 @@
3831
3831
  "addedDescription": "{title} על הלוח, הגדר אותו למטה.",
3832
3832
  "addFailedTitle": "לא ניתן היה להוסיף שירות",
3833
3833
  "servicesAddedTitle": "השירותים נוספו",
3834
- "servicesAddedDescription": "שירות {count} נוסף ללוח. | {count} שירותים נוספו ללוח."
3834
+ "servicesAddedDescription": "שירות אחד נוסף ללוח. | שני שירותים נוספו ללוח. | {count} שירותים נוספו ללוח."
3835
3835
  },
3836
3836
  "repoType": "סוג המאגר",
3837
3837
  "repoTypeHint": "מה המאגר הזה: שירות בק-אנד, אפליקציית פרונט-אנד, ספרייה משותפת או מאגר תיעוד (מסמכים/ספייקים בלבד)."
@@ -3848,7 +3848,7 @@
3848
3848
  "errors": {
3849
3849
  "listDirectory": "לא ניתן היה לרשום את הספרייה"
3850
3850
  },
3851
- "selectAllFiles": "בחר {count} קובץ | בחר את כל {count} הקבצים"
3851
+ "selectAllFiles": "בחר את הקובץ | בחר את שני הקבצים | בחר את כל {count} הקבצים"
3852
3852
  }
3853
3853
  },
3854
3854
  "slack": {
@@ -4002,9 +4002,9 @@
4002
4002
  "spawned": "המבנה נוצר",
4003
4003
  "spawnFailed": "היצירה נכשלה",
4004
4004
  "summary": "{frames} · {modules} · {tasks}",
4005
- "frameCount": "מסגרת {count} | {count} מסגרות",
4006
- "moduleCount": "מודול {count} | {count} מודולים",
4007
- "taskCount": "משימה {count} | {count} משימות"
4005
+ "frameCount": "מסגרת אחת | שתי מסגרות | {count} מסגרות",
4006
+ "moduleCount": "מודול אחד | שני מודולים | {count} מודולים",
4007
+ "taskCount": "משימה אחת | שתי משימות | {count} משימות"
4008
4008
  },
4009
4009
  "taskDocs": {
4010
4010
  "heading": "מסמכי הקשר",
@@ -4092,7 +4092,7 @@
4092
4092
  "imported": "יובא \"{title}\"",
4093
4093
  "importFailed": "הייבוא נכשל",
4094
4094
  "epicSpawned": "נוצר אפיק \"{title}\"",
4095
- "epicChildren": "{count} משימת צאצא נוצרה | {count} משימות צאצא נוצרו",
4095
+ "epicChildren": "משימת צאצא אחת נוצרה | שתי משימות צאצא נוצרו | {count} משימות צאצא נוצרו",
4096
4096
  "epicFailed": "לא ניתן היה ליצור אפיק"
4097
4097
  },
4098
4098
  "connect": {
@@ -4147,7 +4147,7 @@
4147
4147
  "ratings": "השפעה {impact}/5, מורכבות {complexity}/5, רמת ודאות {confidence}",
4148
4148
  "viaModel": "הוערך על ידי {model}.",
4149
4149
  "truncated": "נסרקו רק {count} הבאגים התואמים הראשונים; בלוח הזה יש עוד.",
4150
- "comments": "תגובה {count} | {count} תגובות",
4150
+ "comments": "תגובה אחת | שתי תגובות | {count} תגובות",
4151
4151
  "confidence": {
4152
4152
  "high": "גבוהה",
4153
4153
  "medium": "בינונית",
@@ -4168,8 +4168,8 @@
4168
4168
  "stopReset": "עצור ואפס את המשימה"
4169
4169
  },
4170
4170
  "preview": {
4171
- "stepCount": "שלב {count} | {count} שלבים",
4172
- "gateCount": "אישור {count} | {count} אישורים",
4171
+ "stepCount": "שלב אחד | שני שלבים | {count} שלבים",
4172
+ "gateCount": "אישור אחד | שני אישורים | {count} אישורים",
4173
4173
  "gated": "אישור אנושי אחרי שלב זה"
4174
4174
  },
4175
4175
  "picker": {
@@ -4244,7 +4244,7 @@
4244
4244
  "archivedCount": "מאורכבים ({count})",
4245
4245
  "allLabels": "הכל",
4246
4246
  "defaultBadge": "ברירת מחדל",
4247
- "stepCount": "שלב {count} | {count} שלבים",
4247
+ "stepCount": "שלב אחד | שני שלבים | {count} שלבים",
4248
4248
  "unarchive": "בטל ארכוב",
4249
4249
  "archive": "אַרכֵב (הסתר מתצוגת ברירת המחדל)",
4250
4250
  "cloneDefault": "שכפל ברירת מחדל זו לעותק הניתן לעריכה",
@@ -4458,7 +4458,7 @@
4458
4458
  "intermediateHint": "מוסיף את הסוכנים שאתם משתמשים בהם באופן קבוע.",
4459
4459
  "advancedHint": "מציג את כל הסוכנים, כולל המתמחים.",
4460
4460
  "tooltip": "בחרו כמה מקטלוג הסוכנים להציג",
4461
- "hidden": "סוכן אחד מוסתר ברמה הזו. | {count} סוכנים מוסתרים ברמה הזו."
4461
+ "hidden": "סוכן אחד מוסתר ברמה הזו. | שני סוכנים מוסתרים ברמה הזו. | {count} סוכנים מוסתרים ברמה הזו."
4462
4462
  },
4463
4463
  "palette": {
4464
4464
  "hint": "לחץ על סוכן כדי להוסיף אותו לצינור.",
@@ -4490,7 +4490,7 @@
4490
4490
  "passedCi": "ה-CI ירוק.",
4491
4491
  "passedConflicts": "ה-PR מתמזג בצורה נקייה עם הבסיס שלו.",
4492
4492
  "humanReview": {
4493
- "approvals": "{approved} / {required} אישור | {approved} / {required} אישורים",
4493
+ "approvals": "{approved} / {required} אישור | {approved} / {required} אישורים | {approved} / {required} אישורים",
4494
4494
  "suffixFixing": "· המתקן מטפל בהערות…",
4495
4495
  "suffixFailing": "· הערות סקירה לטיפול",
4496
4496
  "suffixAwaiting": "· ממתין לסקירה",
@@ -4525,8 +4525,8 @@
4525
4525
  },
4526
4526
  "sidebar": {
4527
4527
  "state": "מצב",
4528
- "fixRounds": "{count} סבב תיקון | {count} סבבי תיקון",
4529
- "attempts": "{attempts}/{max} ניסיון | {attempts}/{max} ניסיונות",
4528
+ "fixRounds": "סבב תיקון אחד | שני סבבי תיקון | {count} סבבי תיקון",
4529
+ "attempts": "{attempts}/{max} ניסיון | {attempts}/{max} ניסיונות | {attempts}/{max} ניסיונות",
4530
4530
  "suffixRunning": "· פועל…",
4531
4531
  "suffixNotNeeded": "· עדיין לא נדרש",
4532
4532
  "gatedCommit": "קומיט מגודר",
@@ -4706,7 +4706,7 @@
4706
4706
  "actions": {
4707
4707
  "proceedNothing": "המשך (אין מה לשלב)",
4708
4708
  "incorporateAnswers": "שלב תשובות",
4709
- "requestRecommendations": "בקש המלצה {count} | בקש המלצה {count} | בקש {count} המלצות",
4709
+ "requestRecommendations": "בקש המלצות | בקש המלצה אחת | בקש שתי המלצות | בקש {count} המלצות",
4710
4710
  "reReview": "נראה טוב — סקור מחדש",
4711
4711
  "reReviewing": "סוקר מחדש…",
4712
4712
  "redoIncorporation": "בצע שילוב מחדש",
@@ -4721,14 +4721,14 @@
4721
4721
  "redoPlaceholder": "מה המיזוג צריך לעשות אחרת?",
4722
4722
  "settledFooter": "הדרישות נקבעו — הפייפליין ממשיך עם המסמך מימין.",
4723
4723
  "toast": {
4724
- "preparingRecommendations": "מכין המלצה {count} ברקע | מכין המלצה {count} ברקע | מכין {count} המלצות ברקע",
4724
+ "preparingRecommendations": "מכין המלצות ברקע | מכין המלצה אחת ברקע | מכין שתי המלצות ברקע | מכין {count} המלצות ברקע",
4725
4725
  "preparingRecommendationsDescription": "התשובות שלך נשמרו — סגור את זה אם תרצה; נודיע לך כשהן יהיו מוכנות.",
4726
- "recommendationsReady": "המלצה {count} מוכנה | המלצה {count} מוכנה | {count} המלצות מוכנות",
4726
+ "recommendationsReady": "ההמלצות מוכנות | המלצה אחת מוכנה | שתי המלצות מוכנות | {count} המלצות מוכנות",
4727
4727
  "incorporating": "משלב את התשובות שלך ברקע",
4728
4728
  "incorporatingDescription": "חזרת ללוח — נודיע לך רק אם נדרש קלט נוסף.",
4729
4729
  "reviewerSatisfied": "הסוקר מרוצה — ממשיך את הפייפליין",
4730
4730
  "iterationLimitReached": "הגעת למגבלת האיטרציות — בחר כיצד להמשיך",
4731
- "newFindings": "אין ממצאים חדשים להגיב עליהם | ממצא חדש {count} להגיב עליו | {count} ממצאים חדשים להגיב עליהם",
4731
+ "newFindings": "אין ממצאים חדשים להגיב עליהם | ממצא חדש אחד להגיב עליו | שני ממצאים חדשים להגיב עליהם | {count} ממצאים חדשים להגיב עליהם",
4732
4732
  "proceeding": "ממשיך לשלב הבא",
4733
4733
  "taskReset": "המשימה אופסה — ערוך את הדרישות והגש מחדש",
4734
4734
  "extraRoundGranted": "ניתן סבב סקירה נוסף אחד"
@@ -4811,7 +4811,7 @@
4811
4811
  "clarifyingDescription": "חזרת ללוח, נודיע לך רק אם נדרש קלט נוסף.",
4812
4812
  "reReviewSatisfied": "הסוקר מרוצה — ממשיך את הצינור",
4813
4813
  "reReviewExceeded": "הגעת למגבלת האיטרציות — בחר כיצד להמשיך",
4814
- "reReviewNewFindings": "אין ממצאים חדשים להתייחס אליהם | ממצא חדש אחד להתייחס אליו | {count} ממצאים חדשים להתייחס אליהם",
4814
+ "reReviewNewFindings": "אין ממצאים חדשים להתייחס אליהם | ממצא חדש אחד להתייחס אליו | שני ממצאים חדשים להתייחס אליהם | {count} ממצאים חדשים להתייחס אליהם",
4815
4815
  "proceeding": "ממשיך לשלב הבא",
4816
4816
  "taskReset": "המשימה אופסה — ערוך את דוח הבאג והגש שוב",
4817
4817
  "extraRoundGranted": "ניתן סבב סקירה נוסף אחד"
@@ -4827,7 +4827,7 @@
4827
4827
  },
4828
4828
  "consensus": {
4829
4829
  "titlePrefix": "קונצנזוס",
4830
- "participantCount": "אין משתתפים | משתתף אחד | {count} משתתפים",
4830
+ "participantCount": "אין משתתפים | משתתף אחד | שני משתתפים | {count} משתתפים",
4831
4831
  "loading": "טוען מושב קונצנזוס…",
4832
4832
  "empty": "טרם רץ מושב קונצנזוס עבור שלב זה.",
4833
4833
  "failed": "הקונצנזוס נכשל: {error}",
@@ -4943,7 +4943,7 @@
4943
4943
  "draftingDescription": "חזרת ללוח — נודיע לך רק אם נדרש קלט נוסף.",
4944
4944
  "reReviewSettled": "הכיוון נקבע — ממשיך את הצינור",
4945
4945
  "reReviewExceeded": "הוגעה למגבלת האיטרציות — בחר כיצד להמשיך",
4946
- "reReviewNewOptions": "אין אפשרויות חדשות להגיב עליהן | אפשרות חדשה אחת להגיב עליה | {count} אפשרויות חדשות להגיב עליהן",
4946
+ "reReviewNewOptions": "אין אפשרויות חדשות להגיב עליהן | אפשרות חדשה אחת להגיב עליה | שתי אפשרויות חדשות להגיב עליהן | {count} אפשרויות חדשות להגיב עליהן",
4947
4947
  "reReviewError": "לא ניתן היה להריץ מחדש את סיעור המוחות",
4948
4948
  "proceeding": "ממשיך לשלב הבא",
4949
4949
  "proceedError": "לא ניתן היה להמשיך",
@@ -4963,8 +4963,8 @@
4963
4963
  "noRequirements": "אין דרישות בקבוצה זו.",
4964
4964
  "domainRules": "כללי דומיין",
4965
4965
  "ruleRationale": " — {rationale}",
4966
- "moduleHint": "אין מודולים. בחר קבוצת תכונות משמאל כדי להציג את הדרישות שלה. | מודול אחד. בחר קבוצת תכונות משמאל כדי להציג את הדרישות שלה. | {count} מודולים. בחר קבוצת תכונות משמאל כדי להציג את הדרישות שלה.",
4967
- "moduleHintGherkin": "אין מודולים. בחר קבוצת תכונות משמאל כדי להציג את הדרישות שלה או עבור לתרחישי Gherkin. | מודול אחד. בחר קבוצת תכונות משמאל כדי להציג את הדרישות שלה או עבור לתרחישי Gherkin. | {count} מודולים. בחר קבוצת תכונות משמאל כדי להציג את הדרישות שלה או עבור לתרחישי Gherkin.",
4966
+ "moduleHint": "אין מודולים. בחר קבוצת תכונות משמאל כדי להציג את הדרישות שלה. | מודול אחד. בחר קבוצת תכונות משמאל כדי להציג את הדרישות שלה. | שני מודולים. בחר קבוצת תכונות משמאל כדי להציג את הדרישות שלה. | {count} מודולים. בחר קבוצת תכונות משמאל כדי להציג את הדרישות שלה.",
4967
+ "moduleHintGherkin": "אין מודולים. בחר קבוצת תכונות משמאל כדי להציג את הדרישות שלה או עבור לתרחישי Gherkin. | מודול אחד. בחר קבוצת תכונות משמאל כדי להציג את הדרישות שלה או עבור לתרחישי Gherkin. | שני מודולים. בחר קבוצת תכונות משמאל כדי להציג את הדרישות שלה או עבור לתרחישי Gherkin. | {count} מודולים. בחר קבוצת תכונות משמאל כדי להציג את הדרישות שלה או עבור לתרחישי Gherkin.",
4968
4968
  "mode": {
4969
4969
  "structured": "מובנה",
4970
4970
  "gherkin": "Gherkin",
@@ -5009,7 +5009,7 @@
5009
5009
  "titleWithBlock": "המשכים — {title}",
5010
5010
  "subtitle": "המשכים ושאלות צופי-עתיד שה-Coder העלה. הצינור ממשיך ברגע שכל פריט הוכרע.",
5011
5011
  "badge": {
5012
- "toDecide": "{count} להכרעה | {count} להכרעה | {count} להכרעה",
5012
+ "toDecide": "{count} להכרעה | {count} להכרעה | {count} להכרעה | {count} להכרעה",
5013
5013
  "allDecided": "הכל הוכרע"
5014
5014
  },
5015
5015
  "empty": {
@@ -5034,7 +5034,7 @@
5034
5034
  "sendToCoder": "שלח ל-Coder"
5035
5035
  },
5036
5036
  "footer": {
5037
- "summary": "{count} פריט · {undecided} לא הוכרעו | {count} פריטים · {undecided} לא הוכרעו",
5037
+ "summary": "פריט אחד · {undecided} לא הוכרעו | שני פריטים · {undecided} לא הוכרעו | {count} פריטים · {undecided} לא הוכרעו",
5038
5038
  "loops": "לולאות Coder בשימוש: {loops} / {max}"
5039
5039
  }
5040
5040
  },
@@ -5078,7 +5078,7 @@
5078
5078
  "send": "שלח למתקן"
5079
5079
  },
5080
5080
  "history": {
5081
- "heading": "היסטוריה (אין סבבים) | היסטוריה (סבב אחד) | היסטוריה ({count} סבבים)",
5081
+ "heading": "היסטוריה (אין סבבים) | היסטוריה (סבב אחד) | היסטוריה (שני סבבים) | היסטוריה ({count} סבבים)",
5082
5082
  "fixRequested": "תיקון התבקש",
5083
5083
  "pulledMain": "נמשך main"
5084
5084
  },
@@ -5122,9 +5122,9 @@
5122
5122
  "scenariosOutcomes": "תרחישים ותוצאות",
5123
5123
  "otherChecks": "בדיקות אחרות",
5124
5124
  "checks": "בדיקות",
5125
- "screenshotCount": "{count} צילום מסך | {count} צילומי מסך",
5126
- "checkCount": "{count} בדיקה | {count} בדיקות",
5127
- "concernCount": "{count} חשש | {count} חששות",
5125
+ "screenshotCount": "צילום מסך אחד | שני צילומי מסך | {count} צילומי מסך",
5126
+ "checkCount": "בדיקה אחת | שתי בדיקות | {count} בדיקות",
5127
+ "concernCount": "חשש אחד | שני חששות | {count} חששות",
5128
5128
  "noDiscreteCheck": "לא נרשמה בדיקה נפרדת עבור תרחיש זה.",
5129
5129
  "severity": {
5130
5130
  "critical": "קריטי",
@@ -5155,7 +5155,7 @@
5155
5155
  "failed": "נכשל",
5156
5156
  "skipped": "דולג",
5157
5157
  "concerns": "חששות",
5158
- "blocking": "({count} חוסם) | ({count} חוסמים)"
5158
+ "blocking": "({count} חוסם) | ({count} חוסמים) | ({count} חוסמים)"
5159
5159
  },
5160
5160
  "environment": "סביבה",
5161
5161
  "infrastructure": "תשתית",
@@ -5210,7 +5210,7 @@
5210
5210
  "send": "שלח למתקן"
5211
5211
  },
5212
5212
  "history": {
5213
- "heading": "היסטוריה (אין סבבים) | היסטוריה ({count} סבב) | היסטוריה ({count} סבבים)",
5213
+ "heading": "היסטוריה (אין סבבים) | היסטוריה (סבב אחד) | היסטוריה (שני סבבים) | היסטוריה ({count} סבבים)",
5214
5214
  "fixRequested": "התבקש תיקון"
5215
5215
  }
5216
5216
  },
@@ -5389,7 +5389,7 @@
5389
5389
  "sources": "מקורות מאגר"
5390
5390
  },
5391
5391
  "catalog": {
5392
- "summary": "מקטע אחד יושב · {builtin} מובנים. | {count} מקטעים יושבו · {builtin} מובנים.",
5392
+ "summary": "מקטע אחד יושב · {builtin} מובנים. | שני מקטעים יושבו · {builtin} מובנים. | {count} מקטעים יושבו · {builtin} מובנים.",
5393
5393
  "live": "חי · {source}"
5394
5394
  },
5395
5395
  "authored": {
@@ -5470,7 +5470,7 @@
5470
5470
  "checkSourceFailed": "לא ניתן היה לבדוק את המקור",
5471
5471
  "sourceUnlinked": "קישור המקור בוטל",
5472
5472
  "unlinkSourceFailed": "לא ניתן היה לבטל את קישור המקור",
5473
- "documentsLinked": "{count} מסמך קושר כמקטע חי | {count} מסמכים קושרו כמקטעים חיים",
5473
+ "documentsLinked": "מסמך אחד קושר כמקטע חי | שני מסמכים קושרו כמקטעים חיים | {count} מסמכים קושרו כמקטעים חיים",
5474
5474
  "updated": "הקטע עודכן",
5475
5475
  "updateFailed": "לא ניתן היה לעדכן את הקטע"
5476
5476
  },
@@ -5621,7 +5621,7 @@
5621
5621
  "sourceLinked": "המאגר קושר וסונכרן",
5622
5622
  "linkSourceFailed": "לא ניתן היה לקשר את המאגר",
5623
5623
  "synced": "סונכרן: {updated} עודכנו, {removed} הוסרו",
5624
- "syncSkipped": "קובץ אחד נראה כמו חוזה אך לא ניתן היה להשתמש בו. | {count} קבצים נראו כמו חוזים אך לא ניתן היה להשתמש בהם.",
5624
+ "syncSkipped": "קובץ אחד נראה כמו חוזה אך לא ניתן היה להשתמש בו. | שני קבצים נראו כמו חוזים אך לא ניתן היה להשתמש בהם. | {count} קבצים נראו כמו חוזים אך לא ניתן היה להשתמש בהם.",
5625
5625
  "syncTruncated": "התיקייה מכילה יותר ממה שסנכרון אחד יכול לקלוט, ולכן נקרא רק חלק ממנה.",
5626
5626
  "syncFolderMissing": "התיקייה המקושרת אינה נמצאת במאגר. בדקו את הנתיב, או קשרו מחדש את המקור אם הוא הועבר.",
5627
5627
  "syncFailed": "לא ניתן היה לסנכרן את המאגר",
@@ -5640,7 +5640,7 @@
5640
5640
  "mediaTypes": "פורמטים נדרשים",
5641
5641
  "misdirectedBadge": "שירות אחר",
5642
5642
  "unknownBadge": "לא בקטלוג",
5643
- "storedCount": "{outcome}, פריט אחד | {outcome}, {count} פריטים",
5643
+ "storedCount": "{outcome}, פריט אחד | {outcome}, שני פריטים | {outcome}, {count} פריטים",
5644
5644
  "state": {
5645
5645
  "notStarted": {
5646
5646
  "summary": "טרם התחיל",
@@ -5667,13 +5667,13 @@
5667
5667
  }
5668
5668
  },
5669
5669
  "warning": {
5670
- "unknownServices": "צוין שירות שאינו בקטלוג: {ids}. הרשומה נשמרת כפי שהוצהרה; בדוק את המזהה מול קטלוג הלוח. | צוינו שירותים שאינם בקטלוג: {ids}. הרשומות נשמרות כפי שהוצהרו; בדוק את המזהים מול קטלוג הלוח.",
5670
+ "unknownServices": "צוין שירות שאינו בקטלוג: {ids}. הרשומה נשמרת כפי שהוצהרה; בדוק את המזהה מול קטלוג הלוח. | צוינו שני שירותים שאינם בקטלוג: {ids}. הרשומות נשמרות כפי שהוצהרו; בדוק את המזהים מול קטלוג הלוח. | צוינו שירותים שאינם בקטלוג: {ids}. הרשומות נשמרות כפי שהוצהרו; בדוק את המזהים מול קטלוג הלוח.",
5671
5671
  "targetUnknown": "הקטלוג כבר אינו מכיל את שירות האחסון של השלב הזה ({id}), ולכן לא ניתן היה להשוות מולו דבר ממה שלהלן. רשום אותו מחדש, או הפנה את השלב לשירות אחר.",
5672
- "undeliveredMediaTypes": "השלב הזה אמור היה לספק {formats}, ואף פריט למטה אינו מדווח על הפורמט הזה. | השלב הזה אמור היה לספק {formats}, ואף פריט למטה אינו מדווח על הפורמטים האלה.",
5673
- "misdirected": "פריט אחד הגיע לשירות אחר מ- {target}. | {count} פריטים הגיעו לשירות אחר מ- {target}.",
5674
- "invalidEntries": "רשומה מוצהרת אחת נדחתה: לא צוינו בה שירות ומיקום. | {count} רשומות מוצהרות נדחו: לא צוינו בהן שירות ומיקום.",
5675
- "omitted": "פריט נוסף אחד הוצהר מעבר למגבלת הדוח ואינו מופיע ברשימה. | {count} פריטים נוספים הוצהרו מעבר למגבלת הדוח ואינם מופיעים ברשימה.",
5676
- "unknownGenerators": "צוינה אינטגרציה גנרטיבית שההתקנה הזו אינה רושמת: {ids}. הרשומה נשמרת כפי שהוצהרה; האינטגרציה נרשמת בקוד ההתקנה, לא במרחב העבודה הזה. | צוינו אינטגרציות גנרטיביות שההתקנה הזו אינה רושמת: {ids}. הרשומות נשמרות כפי שהוצהרו; אינטגרציות נרשמות בקוד ההתקנה, לא במרחב העבודה הזה.",
5672
+ "undeliveredMediaTypes": "השלב הזה אמור היה לספק {formats}, ואף פריט למטה אינו מדווח על הפורמט הזה. | השלב הזה אמור היה לספק {formats}, ואף פריט למטה אינו מדווח על שני הפורמטים האלה. | השלב הזה אמור היה לספק {formats}, ואף פריט למטה אינו מדווח על הפורמטים האלה.",
5673
+ "misdirected": "פריט אחד הגיע לשירות אחר מ- {target}. | שני פריטים הגיעו לשירות אחר מ- {target}. | {count} פריטים הגיעו לשירות אחר מ- {target}.",
5674
+ "invalidEntries": "רשומה מוצהרת אחת נדחתה: לא צוינו בה שירות ומיקום. | שתי רשומות מוצהרות נדחו: לא צוינו בהן שירות ומיקום. | {count} רשומות מוצהרות נדחו: לא צוינו בהן שירות ומיקום.",
5675
+ "omitted": "פריט נוסף אחד הוצהר מעבר למגבלת הדוח ואינו מופיע ברשימה. | שני פריטים נוספים הוצהרו מעבר למגבלת הדוח ואינם מופיעים ברשימה. | {count} פריטים נוספים הוצהרו מעבר למגבלת הדוח ואינם מופיעים ברשימה.",
5676
+ "unknownGenerators": "צוינה אינטגרציה גנרטיבית שההתקנה הזו אינה רושמת: {ids}. הרשומה נשמרת כפי שהוצהרה; האינטגרציה נרשמת בקוד ההתקנה, לא במרחב העבודה הזה. | צוינו שתי אינטגרציות גנרטיביות שההתקנה הזו אינה רושמת: {ids}. הרשומות נשמרות כפי שהוצהרו; אינטגרציות נרשמות בקוד ההתקנה, לא במרחב העבודה הזה. | צוינו אינטגרציות גנרטיביות שההתקנה הזו אינה רושמת: {ids}. הרשומות נשמרות כפי שהוצהרו; אינטגרציות נרשמות בקוד ההתקנה, לא במרחב העבודה הזה.",
5677
5677
  "generatorsUnverified": "לא ניתן היה לקרוא את האינטגרציות הגנרטיביות של הפריסה הזו בעת סיום השלב, ולכן האינטגרציות המצוינות למטה לא נבדקו מולן. הרשומות נשמרות כפי שהוצהרו."
5678
5678
  },
5679
5679
  "unknownGeneratorBadge": "לא רשומה"
@@ -5708,7 +5708,7 @@
5708
5708
  "judgeModel": "מודל שופט",
5709
5709
  "judgeModelHint": "מנקד כל תא",
5710
5710
  "nameLabel": "שם (אופציונלי)",
5711
- "cellCount": "תא אחד | {count} תאים",
5711
+ "cellCount": "תא אחד | שני תאים | {count} תאים",
5712
5712
  "maxCells": "(מקסימום {max})",
5713
5713
  "run": "הרץ"
5714
5714
  },
@@ -5756,7 +5756,7 @@
5756
5756
  "custom": "מותאם אישית"
5757
5757
  },
5758
5758
  "fixtures": {
5759
- "expectations": "ציפייה מנוקדת אחת | {count} ציפיות מנוקדות",
5759
+ "expectations": "ציפייה מנוקדת אחת | שתי ציפיות מנוקדות | {count} ציפיות מנוקדות",
5760
5760
  "empty": "אין fixtures."
5761
5761
  },
5762
5762
  "toast": {
@@ -5913,7 +5913,7 @@
5913
5913
  "manualHeading": "סקירה ידנית בלבד",
5914
5914
  "manualExplainer": "מדיניות זו לעולם אינה ממזגת מעצמה, ולכן כל בקשת משיכה ממתינה לסקירה אנושית ללא קשר לניקוד.",
5915
5915
  "ciHeading": "תיקוני CI",
5916
- "ciAttempts": "ההרצה רשאית לנסות פעם {count} להחזיר CI אדום למצב ירוק לפני שהיא מוותרת. | ההרצה רשאית לנסות {count} פעמים להחזיר CI אדום למצב ירוק לפני שהיא מוותרת."
5916
+ "ciAttempts": "ההרצה רשאית לנסות פעם אחת להחזיר CI אדום למצב ירוק לפני שהיא מוותרת. | ההרצה רשאית לנסות פעמיים להחזיר CI אדום למצב ירוק לפני שהיא מוותרת. | ההרצה רשאית לנסות {count} פעמים להחזיר CI אדום למצב ירוק לפני שהיא מוותרת."
5917
5917
  }
5918
5918
  },
5919
5919
  "modelPreset": {
@@ -5949,7 +5949,7 @@
5949
5949
  "failedTitle": "לא ניתן היה ליצור את היוזמה",
5950
5950
  "contextDocsHint": "צרף דרישה, RFC או PRD כדי שסוכני התכנון יקראו אותם בעת תיחום וגיבוש התוכנית.",
5951
5951
  "contextIssuesHint": "צרף אישיו כדי שסוכני התכנון יראו את התיאור והתגובות שלו בעת גיבוש התוכנית.",
5952
- "linkFailed": "היוזמה נוצרה, אך {count} צרופה לא ניתנה לקישור | היוזמה נוצרה, אך {count} צרופות לא ניתנו לקישור"
5952
+ "linkFailed": "היוזמה נוצרה, אך {count} צרופה לא ניתנה לקישור | היוזמה נוצרה, אך שתי צרופות לא ניתנו לקישור | היוזמה נוצרה, אך {count} צרופות לא ניתנו לקישור"
5953
5953
  },
5954
5954
  "status": {
5955
5955
  "planning": "בתכנון",
@@ -6299,7 +6299,7 @@
6299
6299
  "viewPr": "הצג בקשת משיכה",
6300
6300
  "stalledNote": "הלולאה נעצרה מוקדם: איטרציות רצופות לא שינו את הענף ופקודת האימות עדיין נכשלת, ולכן היא לא ניצלה את שארית התקציב. כנראה שצריך לשנות את המשימה או את פקודת האימות לפני שניסיון חוזר יועיל.",
6301
6301
  "iterationsHeading": "איטרציות",
6302
- "iterationsTruncated": "איטרציה קודמת אחת אינה מוצגת (ההיסטוריה מוגבלת). | {count} איטרציות קודמות אינן מוצגות (ההיסטוריה מוגבלת).",
6302
+ "iterationsTruncated": "איטרציה קודמת אחת אינה מוצגת (ההיסטוריה מוגבלת). | שתי איטרציות קודמות אינן מוצגות (ההיסטוריה מוגבלת). | {count} איטרציות קודמות אינן מוצגות (ההיסטוריה מוגבלת).",
6303
6303
  "iteration": "איטרציה {number}",
6304
6304
  "iterationPassed": "האימות עבר",
6305
6305
  "iterationFailed": "יציאה {exit}",
@@ -6436,9 +6436,9 @@
6436
6436
  "body": "הרצות סוכן דוחפות באמצעות אישור שיכול לכתוב לכל מאגר שהוא מכסה. שום דבר כאן לא ימנע מהרצה שנפרצה לדחוף ישירות לענף הראשי או למזג בקשת משיכה משלה דרך ה-API של השרת המארח — רק הגנת ענפים בצד המארח מכסה את שני המקרים, והיא באחריותכם להגדיר. בדיקה זו מראה אם היא קיימת.",
6437
6437
  "check": "בדיקת הגנה",
6438
6438
  "allProtected": "הענף הראשי של כל מאגר מקושר מוגן.",
6439
- "exposed": "למאגר {count} יש ענף ראשי לא מוגן. | ל-{count} מאגרים יש ענף ראשי לא מוגן.",
6439
+ "exposed": "למאגר אחד יש ענף ראשי לא מוגן. | לשני מאגרים יש ענף ראשי לא מוגן. | ל-{count} מאגרים יש ענף ראשי לא מוגן.",
6440
6440
  "unavailable": "הספק המחובר אינו יכול לדווח על הגנת ענפים, ולכן לבדיקה הזו אין מה לומר — זו אינה תוצאה תקינה.",
6441
- "omitted": "מאגר מקושר נוסף אחד לא נבדק. | {count} מאגרים מקושרים נוספים לא נבדקו.",
6441
+ "omitted": "מאגר מקושר נוסף אחד לא נבדק. | שני מאגרים מקושרים נוספים לא נבדקו. | {count} מאגרים מקושרים נוספים לא נבדקו.",
6442
6442
  "state": {
6443
6443
  "protected": "מוגן",
6444
6444
  "unprotected": "לא מוגן",
@@ -6468,7 +6468,7 @@
6468
6468
  "title": "מדריכים",
6469
6469
  "intro": "סיורים מודרכים שמדגישים את הפקדים האמיתיים תוך כדי התקדמות. אפשר להתחיל כל אחד מהם בכל רגע ולחזור עליו כמה פעמים שתרצה.",
6470
6470
  "progress": "{completed} מתוך {total} הושלמו",
6471
- "steps": "שלב אחד | {count} שלבים",
6471
+ "steps": "שלב אחד | שני שלבים | {count} שלבים",
6472
6472
  "blocked": "יהיה זמין כאשר יהיו לך:",
6473
6473
  "notApplicable": "אין כרגע בלוח הזה דבר שהסיור הזה יכול להראות.",
6474
6474
  "empty": "בהתקנה הזו אין מדריכים.",
@@ -6509,7 +6509,7 @@
6509
6509
  "progress": "שלב {current} מתוך {total}",
6510
6510
  "ariaLabel": "שלב במדריך",
6511
6511
  "announcement": "שלב {current} מתוך {total}. {title}. {body}",
6512
- "abridged": "דילגנו על שלב אחד: הפקד הזה אינו חלק מהלוח הזה. | דילגנו על {count} שלבים: הפקדים האלה אינם חלק מהלוח הזה.",
6512
+ "abridged": "דילגנו על שלב אחד: הפקד הזה אינו חלק מהלוח הזה. | דילגנו על שני שלבים: הפקדים האלה אינם חלק מהלוח הזה. | דילגנו על {count} שלבים: הפקדים האלה אינם חלק מהלוח הזה.",
6513
6513
  "searching": "מחפש את הפקד המודגש...",
6514
6514
  "clickHint": "לחץ על הפקד המודגש כדי להמשיך",
6515
6515
  "nextUp": "הבא בתור",
@@ -6299,7 +6299,7 @@
6299
6299
  "viewPr": "Zobacz pull request",
6300
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.",
6301
6301
  "iterationsHeading": "Iteracje",
6302
- "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).",
6303
6303
  "iteration": "Iteracja {number}",
6304
6304
  "iterationPassed": "walidacja zaliczona",
6305
6305
  "iterationFailed": "kod {exit}",
@@ -6436,9 +6436,9 @@
6436
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.",
6437
6437
  "check": "Sprawdź ochronę",
6438
6438
  "allProtected": "Gałąź domyślna każdego powiązanego repozytorium jest chroniona.",
6439
- "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ą.",
6440
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.",
6441
- "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.",
6442
6442
  "state": {
6443
6443
  "protected": "Chroniona",
6444
6444
  "unprotected": "Niechroniona",
@@ -6299,7 +6299,7 @@
6299
6299
  "viewPr": "Переглянути pull request",
6300
6300
  "stalledNote": "Цикл зупинився достроково: послідовні ітерації не змінили гілку, а команда перевірки досі не проходить, тож решту бюджету не витрачено. Імовірно, перш ніж повторний запуск допоможе, потрібно змінити завдання або команду перевірки.",
6301
6301
  "iterationsHeading": "Ітерації",
6302
- "iterationsTruncated": "Не показано {count} попередню ітерацію (історія обмежена). | Не показано {count} попередніх ітерацій (історія обмежена).",
6302
+ "iterationsTruncated": "Не показано {count} попередню ітерацію (історія обмежена). | Не показано {count} попередні ітерації (історія обмежена). | Не показано {count} попередніх ітерацій (історія обмежена).",
6303
6303
  "iteration": "Ітерація {number}",
6304
6304
  "iterationPassed": "перевірку пройдено",
6305
6305
  "iterationFailed": "код {exit}",
@@ -6436,9 +6436,9 @@
6436
6436
  "body": "Запуски агентів надсилають зміни обліковими даними, які можуть писати в кожен охоплений репозиторій. Ніщо тут не завадить скомпрометованому запуску надіслати зміни просто до типової гілки або злити власний запит на злиття через API хоста — обидва випадки покриває лише захист гілок на боці хоста, і його налаштовуєте ви. Ця перевірка показує, чи він увімкнений.",
6437
6437
  "check": "Перевірити захист",
6438
6438
  "allProtected": "Типова гілка кожного приєднаного репозиторію захищена.",
6439
- "exposed": "{count} репозиторій має незахищену типову гілку. | {count} репозиторії мають незахищену типову гілку.",
6439
+ "exposed": "{count} репозиторій має незахищену типову гілку. | {count} репозиторії мають незахищену типову гілку. | {count} репозиторіїв мають незахищену типову гілку.",
6440
6440
  "unavailable": "Приєднаний постачальник не вміє повідомляти про захист гілок, тож цій перевірці нема чого сказати — це не чистий результат.",
6441
- "omitted": "Ще {count} приєднаний репозиторій не перевірено. | Ще {count} приєднаних репозиторіїв не перевірено.",
6441
+ "omitted": "Ще {count} приєднаний репозиторій не перевірено. | Ще {count} приєднані репозиторії не перевірено. | Ще {count} приєднаних репозиторіїв не перевірено.",
6442
6442
  "state": {
6443
6443
  "protected": "Захищена",
6444
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.217.1",
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",