@cat-factory/app 0.217.1 → 0.218.0
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 +26 -3
- package/app/components/board/BoardCanvas.vue +15 -0
- package/app/components/board/nodes/TaskCard.vue +26 -11
- package/app/components/focus/BlockFocusView.vue +29 -1
- package/app/components/panels/InspectorPanel.vue +49 -4
- package/app/components/panels/inspector/TaskExecution.vue +24 -2
- package/app/components/riskPolicy/RiskPolicyPreview.vue +33 -2
- package/app/components/settings/MergeRolePolicyEditor.logic.spec.ts +146 -0
- package/app/components/settings/MergeRolePolicyEditor.logic.ts +133 -0
- package/app/components/settings/MergeRolePolicyEditor.vue +226 -0
- package/app/components/settings/RiskPolicyPanel.vue +35 -1
- package/app/composables/useRunStart.spec.ts +204 -0
- package/app/composables/useRunStart.ts +112 -0
- package/app/stores/execution/commands.ts +20 -3
- package/app/types/merge.ts +6 -0
- package/app/utils/riskPolicy.spec.ts +32 -1
- package/app/utils/riskPolicy.ts +29 -1
- package/i18n/i18n.config.ts +7 -21
- package/i18n/locales/de.json +33 -4
- package/i18n/locales/en.json +39 -4
- package/i18n/locales/es.json +33 -4
- package/i18n/locales/fr.json +33 -4
- package/i18n/locales/he.json +115 -86
- package/i18n/locales/it.json +33 -4
- package/i18n/locales/ja.json +33 -4
- package/i18n/locales/pl.json +36 -7
- package/i18n/locales/tr.json +33 -4
- package/i18n/locales/uk.json +36 -7
- package/i18n/plural-forms.spec.ts +76 -0
- package/i18n/plural-rules.spec.ts +98 -0
- package/i18n/plural-rules.ts +123 -0
- package/package.json +2 -2
|
@@ -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.
|
|
3
|
+
"version": "0.218.0",
|
|
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.
|
|
43
|
+
"@cat-factory/contracts": "0.231.0"
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|
|
46
46
|
"@toad-contracts/testing": "0.3.2",
|