@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 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
@@ -20,6 +20,9 @@ const execution = useExecutionStore()
20
20
  const ui = useUiStore()
21
21
  const github = useGitHubStore()
22
22
  const toast = useToast()
23
+ // The drop resolves its target task at the moment of the drop, so the sandbox reading is a
24
+ // function of that id rather than a computed over a bound block.
25
+ const { forcedFor } = useDryRunPolicy()
23
26
  const access = useWorkspaceAccess()
24
27
  const { t } = useI18n()
25
28
 
@@ -184,6 +187,18 @@ async function onDrop(event: DragEvent) {
184
187
  })
185
188
  return
186
189
  }
190
+ // A dropped pipeline starts live and has nothing to ask for, but a preset that sandboxes the
191
+ // dropper's role means this run merges nothing. Said HERE, because the drop is the only
192
+ // moment this surface has: the ordinary start stays silent (it is already visible on the
193
+ // card it landed on), and only the sandbox, which is not, earns a toast.
194
+ if (forcedFor(target.id)) {
195
+ toast.add({
196
+ title: t('board.dryRunToast.title'),
197
+ description: t('board.dryRunToast.body', { name: pipeline.name }),
198
+ color: 'warning',
199
+ icon: 'i-lucide-shield',
200
+ })
201
+ }
187
202
  execution.start(target.id, pipeline)
188
203
  ui.select(target.id)
189
204
  }
@@ -36,6 +36,13 @@ const typeBadge = computed(() => {
36
36
  })
37
37
  const selected = computed(() => ui.selectedBlockId === props.taskId)
38
38
 
39
+ // This card's Start is a ONE-TAP live start, so it has nothing to ask for and offers no dry-run
40
+ // request. What it does owe the reader is the half that is not a choice: a preset that sandboxes
41
+ // their role means this button opens a pull request and merges nothing, and a card that said so
42
+ // only after the fact would leave that to be discovered from a run that stops at the merge.
43
+ const { forcedFor } = useDryRunPolicy()
44
+ const sandboxed = computed(() => forcedFor(props.taskId))
45
+
39
46
  // Drag-to-connect: dragging from this card's handle onto another task makes THAT task
40
47
  // depend on this one (this is the prerequisite). The composable tracks the gesture.
41
48
  const { start: startConnect } = useDependencyConnect()
@@ -106,12 +113,16 @@ async function run() {
106
113
  const started = await execution.start(props.taskId, pipeline)
107
114
  if (started) {
108
115
  // Confirm the (optimistic) start landed — the button unmounts once the stream pushes
109
- // in_progress, so without this the successful action gives no feedback.
116
+ // in_progress, so without this the successful action gives no feedback. A sandboxed start
117
+ // says so here rather than borrowing the live wording: this is the moment the reader learns
118
+ // what they just started, and the two runs differ in what they will end up doing.
110
119
  toast.add({
111
- title: t('board.task.startedToast.title'),
112
- description: t('board.task.startedToast.body', { name: pipeline.name }),
113
- color: 'success',
114
- icon: 'i-lucide-play',
120
+ title: sandboxed.value ? t('board.dryRunToast.title') : t('board.task.startedToast.title'),
121
+ description: sandboxed.value
122
+ ? t('board.dryRunToast.body', { name: pipeline.name })
123
+ : t('board.task.startedToast.body', { name: pipeline.name }),
124
+ color: sandboxed.value ? 'warning' : 'success',
125
+ icon: sandboxed.value ? 'i-lucide-shield' : 'i-lucide-play',
115
126
  })
116
127
  } else {
117
128
  starting.value = false
@@ -357,15 +368,19 @@ function selectTask() {
357
368
  :color="runnable ? 'primary' : 'neutral'"
358
369
  variant="soft"
359
370
  size="xs"
360
- :icon="runnable ? 'i-lucide-play' : 'i-lucide-lock'"
371
+ :icon="!runnable ? 'i-lucide-lock' : sandboxed ? 'i-lucide-shield' : 'i-lucide-play'"
361
372
  :loading="starting"
362
373
  :disabled="!runnable || starting"
363
374
  :title="
364
- runnable
365
- ? t('board.task.startPipeline', {
366
- name: defaultPipeline?.name ?? t('board.task.pipelineFallback'),
367
- })
368
- : t('board.task.waitingOn', { deps: unmet.map((d) => d.title).join(', ') })
375
+ !runnable
376
+ ? t('board.task.waitingOn', { deps: unmet.map((d) => d.title).join(', ') })
377
+ : sandboxed
378
+ ? t('board.task.startPipelineDryRun', {
379
+ name: defaultPipeline?.name ?? t('board.task.pipelineFallback'),
380
+ })
381
+ : t('board.task.startPipeline', {
382
+ name: defaultPipeline?.name ?? t('board.task.pipelineFallback'),
383
+ })
369
384
  "
370
385
  @click.stop="run"
371
386
  >
@@ -47,10 +47,16 @@ const runOptions = computed(() => {
47
47
  )
48
48
  })
49
49
 
50
+ // The run MODE, shared with the inspector's Run menu so the two surfaces offer (and force) the
51
+ // same thing. The toggle sits beside the picker rather than inside it because the picker's rows
52
+ // START a run on click: a modifier reachable only by opening the list would be one the user has
53
+ // to arm and re-open the menu to use.
54
+ const runStart = useRunStart(() => block.value?.id)
55
+
50
56
  /** Start the picked pipeline immediately — the Run menu chooses an ACTION, it stores no default. */
51
57
  function runPipeline(id: string) {
52
58
  const pipeline = pipelines.getPipeline(id)
53
- if (pipeline && block.value) void execution.start(block.value.id, pipeline)
59
+ if (pipeline && block.value) void runStart.start(pipeline)
54
60
  }
55
61
 
56
62
  /**
@@ -136,6 +142,28 @@ function openApprovalFor(approvalId: string) {
136
142
  >
137
143
  {{ t('initiative.inspector.runPlanning') }}
138
144
  </UButton>
145
+ <!-- A sandboxed role cannot ask its way out, so the badge REPLACES the toggle rather
146
+ than sitting beside it as a switch that does nothing. -->
147
+ <UBadge
148
+ v-if="!isInitiative && runStart.forced.value"
149
+ color="warning"
150
+ variant="subtle"
151
+ size="sm"
152
+ icon="i-lucide-shield"
153
+ :title="t('focus.dryRunForcedHint')"
154
+ data-testid="focus-dry-run-forced"
155
+ >
156
+ {{ t('focus.dryRun') }}
157
+ </UBadge>
158
+ <USwitch
159
+ v-else-if="!isInitiative && runStart.canRequest.value"
160
+ :model-value="runStart.requested.value"
161
+ size="sm"
162
+ :label="t('focus.dryRun')"
163
+ :title="t('focus.dryRunHint')"
164
+ data-testid="focus-dry-run"
165
+ @update:model-value="runStart.setRequested($event)"
166
+ />
139
167
  <!-- The rich picker rather than a list of names: the run starts the moment a row is
140
168
  clicked, so the preview is the only chance to see which agents it will run. -->
141
169
  <PipelinePicker
@@ -178,6 +178,10 @@ const taskBranchUrl = computed(() => {
178
178
  return base ? `${base}/tree/${pr.branch}` : null
179
179
  })
180
180
 
181
+ // The run MODE, shared with the focus view's Run picker so the two surfaces offer (and force)
182
+ // the same thing.
183
+ const runStart = useRunStart(() => block.value?.id)
184
+
181
185
  // Hide UI-testing pipelines when this block's frame has no UI to exercise, `'recurring'`-only
182
186
  // pipelines (a manual run of one is refused server-side), and every pipeline whose purpose doesn't
183
187
  // match this block's task type or LEVEL — they'd be refused at run start (see utils/pipeline + the
@@ -186,7 +190,7 @@ const taskBranchUrl = computed(() => {
186
190
  // frames and modules too, which is why it reads `block.level` rather than assuming a task.
187
191
  const runMenu = computed(() => {
188
192
  const frame = block.value ? board.serviceOf(block.value) : undefined
189
- return pipelines.pipelines
193
+ const runnable = pipelines.pipelines
190
194
  .filter((p) =>
191
195
  pipelineAllowedForManualStart(
192
196
  p,
@@ -199,8 +203,40 @@ const runMenu = computed(() => {
199
203
  .map((p) => ({
200
204
  label: p.name,
201
205
  icon: 'i-lucide-play',
202
- onSelect: () => block.value && execution.start(block.value.id, p),
206
+ onSelect: () => void runStart.start(p),
203
207
  }))
208
+ // The run MODE leads the menu, because it changes what every row below it does. A policy
209
+ // sandbox is stated in both interface tiers as a disabled row: there is nothing to choose, and
210
+ // a menu that said nothing would leave the user to discover it from a run that never merges.
211
+ if (runStart.forced.value) {
212
+ return [
213
+ [
214
+ {
215
+ label: t('panels.inspector.dryRunForced'),
216
+ icon: 'i-lucide-shield',
217
+ disabled: true,
218
+ type: 'label' as const,
219
+ },
220
+ ],
221
+ runnable,
222
+ ]
223
+ }
224
+ if (!runStart.canRequest.value) return runnable
225
+ return [
226
+ [
227
+ {
228
+ label: t('panels.inspector.dryRun'),
229
+ icon: 'i-lucide-shield',
230
+ type: 'checkbox' as const,
231
+ checked: runStart.requested.value,
232
+ // Keep the menu open: the choice is a modifier on the pipeline row the user is about to
233
+ // pick, so closing here would make them reopen the menu to act on it.
234
+ onSelect: (e: Event) => e.preventDefault(),
235
+ onUpdateChecked: (checked: boolean) => runStart.setRequested(checked),
236
+ },
237
+ ],
238
+ runnable,
239
+ ]
204
240
  })
205
241
 
206
242
  // Delegate to the shared confirm-gated deletion so the button and the keyboard shortcut
@@ -515,10 +551,19 @@ const showOriginalDescription = ref(false)
515
551
  :color="canRun ? 'primary' : 'neutral'"
516
552
  variant="soft"
517
553
  size="sm"
518
- :icon="canRun ? 'i-lucide-play' : 'i-lucide-lock'"
554
+ :icon="
555
+ !canRun
556
+ ? 'i-lucide-lock'
557
+ : runStart.dryRun.value
558
+ ? 'i-lucide-shield'
559
+ : 'i-lucide-play'
560
+ "
519
561
  trailing-icon="i-lucide-chevron-down"
520
562
  :disabled="!canRun"
521
- :title="runBlockedReason ?? undefined"
563
+ :title="
564
+ runBlockedReason ??
565
+ (runStart.dryRun.value ? t('panels.inspector.dryRunHint') : undefined)
566
+ "
522
567
  data-testid="run-start"
523
568
  >
524
569
  {{ instance ? t('panels.inspector.reRun') : t('panels.inspector.run') }}
@@ -1,4 +1,5 @@
1
1
  <script setup lang="ts">
2
+ import { isDryRun } from '@cat-factory/contracts'
2
3
  import type { Block } from '~/types/domain'
3
4
  import { agentKindMeta } from '~/utils/catalog'
4
5
  import {
@@ -48,6 +49,12 @@ const reviewStageLabel = computed(() =>
48
49
 
49
50
  const instance = computed(() => execution.getInstance(props.block.executionId))
50
51
 
52
+ // Whether this run may land its work at all. Read through the contracts helper rather than an
53
+ // equality here, so a run persisted before the mode existed (absent ⇒ live) is never badged as a
54
+ // sandbox. Both routes into the mode look identical from here on purpose: what the reader needs
55
+ // is that nothing will merge, and WHY is answered by the run's own notes and the merge decision.
56
+ const sandboxed = computed(() => isDryRun(instance.value?.mode))
57
+
51
58
  // Nothing to show yet: no run, no failed run, no PR, and not awaiting a merge — render an
52
59
  // empty state instead of a blank gap so the section reads as "no runs yet" rather than broken.
53
60
  const isEmpty = computed(
@@ -266,8 +273,23 @@ async function mergePr() {
266
273
  <!-- running pipeline -->
267
274
  <div v-if="instance">
268
275
  <div class="mb-1 flex items-center justify-between">
269
- <span class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
270
- {{ instance.pipelineName }}
276
+ <span class="flex min-w-0 items-center gap-1.5">
277
+ <span class="truncate text-[11px] font-semibold uppercase tracking-wide text-slate-400">
278
+ {{ instance.pipelineName }}
279
+ </span>
280
+ <!-- A sandboxed run looks exactly like one that simply has not reached the merge yet,
281
+ right up until it stops there, so it says what it is from the start. -->
282
+ <UBadge
283
+ v-if="sandboxed"
284
+ color="warning"
285
+ variant="subtle"
286
+ size="sm"
287
+ icon="i-lucide-shield"
288
+ :title="t('inspector.execution.dryRunHint')"
289
+ data-testid="run-dry-run"
290
+ >
291
+ {{ t('inspector.execution.dryRun') }}
292
+ </UBadge>
271
293
  </span>
272
294
  <div class="flex items-center gap-1">
273
295
  <!-- Stop without deleting: halts the run but keeps it readable + retryable. -->
@@ -8,12 +8,29 @@
8
8
  // A policy with auto-merge off has no thresholds to explain (the master switch wins before
9
9
  // any score is compared), so it says so instead of listing ceilings that never apply.
10
10
  import { computed } from 'vue'
11
- import type { RiskPolicy } from '~/types/merge'
12
- import { riskPolicyCeilings, type RiskPolicyAxis } from '~/utils/riskPolicy'
11
+ import type { RiskPolicy, WorkspaceRole } from '~/types/merge'
12
+ import { riskPolicyCeilings, rolePolicySummary, type RiskPolicyAxis } from '~/utils/riskPolicy'
13
13
 
14
14
  const props = defineProps<{ policy: RiskPolicy }>()
15
15
  const { t, n } = useI18n()
16
16
 
17
+ // The role layer, when the policy has one. Worth a line here rather than only in settings: this
18
+ // preview is what a task's merge-policy picker shows, and "runs started by a member never merge"
19
+ // changes what picking this policy means for whoever is reading it.
20
+ const ROLE_LABEL: Record<WorkspaceRole, () => string> = {
21
+ admin: () => t('merge.role.admin'),
22
+ member: () => t('merge.role.member'),
23
+ viewer: () => t('merge.role.viewer'),
24
+ }
25
+ const roleLayer = computed(() => {
26
+ const { sandboxed, narrowed } = rolePolicySummary(props.policy)
27
+ return {
28
+ sandboxed: sandboxed.map((r) => ROLE_LABEL[r]()).join(', '),
29
+ narrowed: narrowed.map((r) => ROLE_LABEL[r]()).join(', '),
30
+ any: sandboxed.length > 0 || narrowed.length > 0,
31
+ }
32
+ })
33
+
17
34
  // Exhaustive over the axis union with LITERAL keys, so both i18n drift guards apply: the
18
35
  // typed-key check catches a renamed key, and the Record catches a new axis.
19
36
  const AXIS_LABEL: Record<RiskPolicyAxis, () => string> = {
@@ -68,6 +85,20 @@ const ceilings = computed(() =>
68
85
  </p>
69
86
  </div>
70
87
 
88
+ <!-- Who started the run changes what may land, on a policy that says so. -->
89
+ <div v-if="roleLayer.any" data-testid="risk-policy-preview-roles">
90
+ <div class="mb-1 flex items-center gap-1 text-[10px] uppercase tracking-wide text-slate-500">
91
+ <UIcon name="i-lucide-users" class="h-3 w-3" />
92
+ {{ t('riskPolicy.preview.roleHeading') }}
93
+ </div>
94
+ <p v-if="roleLayer.sandboxed" class="text-[12px] leading-snug text-slate-400">
95
+ {{ t('riskPolicy.preview.roleSandboxed', { roles: roleLayer.sandboxed }) }}
96
+ </p>
97
+ <p v-if="roleLayer.narrowed" class="text-[12px] leading-snug text-slate-400">
98
+ {{ t('riskPolicy.preview.roleNarrowed', { roles: roleLayer.narrowed }) }}
99
+ </p>
100
+ </div>
101
+
71
102
  <div>
72
103
  <div class="mb-1 flex items-center gap-1 text-[10px] uppercase tracking-wide text-slate-500">
73
104
  <UIcon name="i-lucide-wrench" class="h-3 w-3" />
@@ -0,0 +1,146 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { MERGE_CLASS_RULES } from '@cat-factory/contracts'
3
+ import type { ClassRulesByRole, MergeClassRules } from '~/types/merge'
4
+ import {
5
+ INHERIT_RULE,
6
+ narrowingOptionsFor,
7
+ roleClassRuleRows,
8
+ roleNarrowedCount,
9
+ setRoleClassRule,
10
+ toggleDryRunRole,
11
+ } from '~/components/settings/MergeRolePolicyEditor.logic'
12
+
13
+ describe('narrowingOptionsFor', () => {
14
+ it('offers only the rules that would actually narrow the base', () => {
15
+ expect(narrowingOptionsFor('always')).toEqual(['thresholds', 'never'])
16
+ expect(narrowingOptionsFor('thresholds')).toEqual(['never'])
17
+ })
18
+
19
+ // Nothing is stricter than "always require review", so the editor has nothing to offer a role
20
+ // on that class rather than an option that silently does nothing.
21
+ it('offers nothing on a class the policy already routes to a human', () => {
22
+ expect(narrowingOptionsFor('never')).toEqual([])
23
+ })
24
+ })
25
+
26
+ // The sentinel is a VALUE the select renders as an item, and Reka UI's `SelectItem` throws on an
27
+ // empty-string value (it reserves `''` for "cleared, show the placeholder"). Nothing here can see
28
+ // that widget, so the invariant it imposes is asserted where it is authored: the failure it
29
+ // prevents is not a wrong label, it is the settings panel throwing when a role group is expanded.
30
+ describe('INHERIT_RULE', () => {
31
+ it('is a non-empty value the select can carry as an item', () => {
32
+ expect(INHERIT_RULE).not.toBe('')
33
+ expect(INHERIT_RULE.length).toBeGreaterThan(0)
34
+ })
35
+
36
+ // It is also not one of the rules, or clearing a row would be indistinguishable from setting it.
37
+ it('cannot collide with a real rule', () => {
38
+ expect(MERGE_CLASS_RULES).not.toContain(INHERIT_RULE as string)
39
+ })
40
+ })
41
+
42
+ describe('roleClassRuleRows', () => {
43
+ const base: MergeClassRules = { docs: 'always', schema: 'never' }
44
+
45
+ // The sentinel is truthy, so anything asking "did this role author a rule" by testing the
46
+ // SELECTION rather than the stored entry reads inheritance as a stored rule: it would offer
47
+ // "same as this policy" twice on every untouched row, and flag every one of them redundant.
48
+ it('never treats inheritance as a stored rule', () => {
49
+ for (const row of roleClassRuleRows(base, undefined)) {
50
+ expect(row.options).not.toContain(INHERIT_RULE as string)
51
+ expect(row.redundant).toBe(false)
52
+ }
53
+ })
54
+
55
+ it('reads an absent entry as inheritance, never as thresholds', () => {
56
+ const rows = roleClassRuleRows(base, undefined)
57
+ expect(rows.map((r) => r.selected)).toEqual([
58
+ INHERIT_RULE,
59
+ INHERIT_RULE,
60
+ INHERIT_RULE,
61
+ INHERIT_RULE,
62
+ INHERIT_RULE,
63
+ INHERIT_RULE,
64
+ ])
65
+ expect(rows.find((r) => r.changeClass === 'docs')?.base).toBe('always')
66
+ expect(rows.find((r) => r.changeClass === 'source')?.base).toBe('thresholds')
67
+ })
68
+
69
+ it('surfaces the role rule and marks it as governing when it narrows', () => {
70
+ const rows = roleClassRuleRows(base, { docs: 'never' })
71
+ const docs = rows.find((r) => r.changeClass === 'docs')
72
+ expect(docs?.selected).toBe('never')
73
+ expect(docs?.redundant).toBe(false)
74
+ })
75
+
76
+ // A base edit can turn a stored role rule into a no-op. The row keeps it (so it can be read and
77
+ // cleared) and says it no longer does anything, rather than dropping it and reading as clean.
78
+ it('keeps a rule the base rule has overtaken, flagged as having no effect', () => {
79
+ const rows = roleClassRuleRows({ docs: 'never' }, { docs: 'thresholds' })
80
+ const docs = rows.find((r) => r.changeClass === 'docs')
81
+ expect(docs?.selected).toBe('thresholds')
82
+ expect(docs?.redundant).toBe(true)
83
+ expect(docs?.options).toContain('thresholds')
84
+ })
85
+ })
86
+
87
+ describe('setRoleClassRule', () => {
88
+ it('writes a rule under the role', () => {
89
+ expect(setRoleClassRule({}, 'member', 'source', 'never')).toEqual({
90
+ member: { source: 'never' },
91
+ })
92
+ })
93
+
94
+ it('clears back to an omission, so absent stays absent', () => {
95
+ const before: ClassRulesByRole = { member: { source: 'never', docs: 'never' } }
96
+ expect(setRoleClassRule(before, 'member', 'docs', INHERIT_RULE)).toEqual({
97
+ member: { source: 'never' },
98
+ })
99
+ })
100
+
101
+ // `{}` is the identity for the whole feature, so emptying a role's last rule must reach it:
102
+ // a `{ member: {} }` left behind would read as a role that had been given a policy.
103
+ it('drops the role entirely once its last rule is cleared', () => {
104
+ const before: ClassRulesByRole = { admin: { docs: 'never' }, member: { source: 'never' } }
105
+ expect(setRoleClassRule(before, 'member', 'source', INHERIT_RULE)).toEqual({
106
+ admin: { docs: 'never' },
107
+ })
108
+ })
109
+
110
+ it('leaves the other roles untouched', () => {
111
+ const before: ClassRulesByRole = { admin: { docs: 'never' } }
112
+ expect(setRoleClassRule(before, 'member', 'source', 'thresholds')).toEqual({
113
+ admin: { docs: 'never' },
114
+ member: { source: 'thresholds' },
115
+ })
116
+ })
117
+ })
118
+
119
+ describe('toggleDryRunRole', () => {
120
+ it('adds and removes a role', () => {
121
+ expect(toggleDryRunRole([], 'member', true)).toEqual(['member'])
122
+ expect(toggleDryRunRole(['member'], 'member', false)).toEqual([])
123
+ })
124
+
125
+ it('keeps the shared role order whatever order the toggles arrived in', () => {
126
+ expect(toggleDryRunRole(['viewer'], 'admin', true)).toEqual(['admin', 'viewer'])
127
+ expect(toggleDryRunRole(['viewer', 'admin'], 'member', true)).toEqual([
128
+ 'admin',
129
+ 'member',
130
+ 'viewer',
131
+ ])
132
+ })
133
+
134
+ it('is idempotent on a role already in the state asked for', () => {
135
+ expect(toggleDryRunRole(['member'], 'member', true)).toEqual(['member'])
136
+ expect(toggleDryRunRole([], 'member', false)).toEqual([])
137
+ })
138
+ })
139
+
140
+ describe('roleNarrowedCount', () => {
141
+ it('counts the classes a role authored, and reads absence as zero', () => {
142
+ expect(roleNarrowedCount(undefined)).toBe(0)
143
+ expect(roleNarrowedCount({})).toBe(0)
144
+ expect(roleNarrowedCount({ docs: 'never', source: 'never' })).toBe(2)
145
+ })
146
+ })
@@ -0,0 +1,133 @@
1
+ // Pure state math behind <MergeRolePolicyEditor>: what a preset's ROLE layer currently says, and
2
+ // what each edit turns it into.
3
+ //
4
+ // It lives outside the SFC because the interesting parts are not the happy path. Two of them:
5
+ //
6
+ // - **Absent is not a rule.** A role that authored nothing on a class is governed by the base
7
+ // rule, and that silence must survive a round trip through this editor: an edit clears back to
8
+ // an OMISSION rather than writing `thresholds`, and a role left with nothing drops out of the
9
+ // map entirely, so `{}` stays the identity the wire contract says it is.
10
+ // - **Narrow-only.** `narrowMergeClassRule` (contracts, the same implementation the engine
11
+ // applies) takes the stricter of the base and the role rule, so a role rule that is looser than
12
+ // the base does nothing at all. Offering one would tell an operator they had written a policy
13
+ // when they had written a no-op, so the options a row offers are exactly the rules that would
14
+ // change its outcome, plus whatever is already stored.
15
+ import {
16
+ MERGE_CLASS_RULES,
17
+ narrowMergeClassRule,
18
+ RULEABLE_CHANGE_CLASSES,
19
+ WORKSPACE_ROLES,
20
+ } from '@cat-factory/contracts'
21
+ import type {
22
+ ClassRulesByRole,
23
+ MergeClassRule,
24
+ MergeClassRules,
25
+ RuleableChangeClass,
26
+ WorkspaceRole,
27
+ } from '~/types/merge'
28
+
29
+ /**
30
+ * The "this role adds nothing here" selection, stored as an OMISSION.
31
+ *
32
+ * It must be a non-empty string, and not because of a style preference: the select this feeds is
33
+ * Nuxt UI's `USelect` over Reka UI, whose `SelectItem` THROWS on an empty-string value (the empty
34
+ * string is how that widget spells "cleared, show the placeholder", so an item may not claim it).
35
+ * A `''` sentinel therefore does not degrade, it takes down the settings panel the moment a role
36
+ * group is expanded. It is a member of the selection union rather than `undefined` for the same
37
+ * reason: the row has to be able to OFFER inheriting as a choice, which is a value.
38
+ */
39
+ export const INHERIT_RULE = 'inherit' as const
40
+ export type RoleRuleSelection = MergeClassRule | typeof INHERIT_RULE
41
+
42
+ /** One class's row inside one role's group. */
43
+ export interface RoleClassRuleRow {
44
+ changeClass: RuleableChangeClass
45
+ /** What the preset's base `classRules` say for this class (what the row inherits). */
46
+ base: MergeClassRule
47
+ /** The role's own entry, or {@link INHERIT_RULE} when it authored none. */
48
+ selected: RoleRuleSelection
49
+ /**
50
+ * The rules this row may be set to: the ones strictly stricter than `base` (a looser one is
51
+ * discarded by the engine), plus the stored value whatever it is, so a rule that a later base
52
+ * edit turned into a no-op stays visible and clearable instead of vanishing from its own row.
53
+ */
54
+ options: MergeClassRule[]
55
+ /**
56
+ * The stored rule no longer changes anything, because the base rule is already at least as
57
+ * strict. Not an error and not silently dropped: the operator wrote it down, and the honest
58
+ * report is that the base map now covers it.
59
+ */
60
+ redundant: boolean
61
+ }
62
+
63
+ /**
64
+ * The rules that would actually narrow `base`. Empty for `never`, which is already the strictest
65
+ * rule there is, so a class the preset always routes to a human offers a role nothing to add.
66
+ */
67
+ export function narrowingOptionsFor(base: MergeClassRule): MergeClassRule[] {
68
+ return MERGE_CLASS_RULES.filter((rule) => narrowMergeClassRule(base, rule) !== base)
69
+ }
70
+
71
+ /** One role's rows, in the shared class order, against the preset's base rules. */
72
+ export function roleClassRuleRows(
73
+ base: MergeClassRules,
74
+ entry: MergeClassRules | undefined,
75
+ ): RoleClassRuleRow[] {
76
+ return RULEABLE_CHANGE_CLASSES.map((changeClass) => {
77
+ const baseRule = base[changeClass] ?? 'thresholds'
78
+ // The role's own rule, or nothing at all. Held as `undefined` rather than folded into
79
+ // `selected` up front because "did this role author a rule here" is the question the two
80
+ // lines below both ask, and an absent entry is the one answer that is not one of the three
81
+ // rules. Testing the SENTINEL for that would tie them to whatever string it happens to be.
82
+ const stored = entry?.[changeClass]
83
+ const narrowing = narrowingOptionsFor(baseRule)
84
+ return {
85
+ changeClass,
86
+ base: baseRule,
87
+ selected: stored ?? INHERIT_RULE,
88
+ options: stored && !narrowing.includes(stored) ? [...narrowing, stored] : narrowing,
89
+ redundant: !!stored && narrowMergeClassRule(baseRule, stored) === baseRule,
90
+ }
91
+ })
92
+ }
93
+
94
+ /**
95
+ * Set (or clear) one role's rule for one class, pruning back to the identity: clearing the last
96
+ * rule a role carried removes the role's entry, so a preset an operator has emptied is byte-for-byte
97
+ * the `{}` a preset that never had a role rule carries.
98
+ */
99
+ export function setRoleClassRule(
100
+ byRole: ClassRulesByRole,
101
+ role: WorkspaceRole,
102
+ changeClass: RuleableChangeClass,
103
+ rule: RoleRuleSelection,
104
+ ): ClassRulesByRole {
105
+ const entry: MergeClassRules = { ...byRole[role] }
106
+ if (rule === INHERIT_RULE) delete entry[changeClass]
107
+ else entry[changeClass] = rule
108
+ const next: ClassRulesByRole = { ...byRole }
109
+ if (Object.keys(entry).length === 0) delete next[role]
110
+ else next[role] = entry
111
+ return next
112
+ }
113
+
114
+ /**
115
+ * Add or remove a role from the sandboxed list, keeping it in the shared role order so two presets
116
+ * carrying the same policy carry the same array (and a diff of a preset reads as an edit rather
117
+ * than a reshuffle).
118
+ */
119
+ export function toggleDryRunRole(
120
+ roles: readonly WorkspaceRole[],
121
+ role: WorkspaceRole,
122
+ sandboxed: boolean,
123
+ ): WorkspaceRole[] {
124
+ const next = new Set(roles)
125
+ if (sandboxed) next.add(role)
126
+ else next.delete(role)
127
+ return WORKSPACE_ROLES.filter((r) => next.has(r))
128
+ }
129
+
130
+ /** How many classes a role has authored a rule for (the collapsed group's summary). */
131
+ export function roleNarrowedCount(entry: MergeClassRules | undefined): number {
132
+ return entry ? Object.keys(entry).length : 0
133
+ }