From c1ec3f0b2c5449c126e4b8a2c051e1d7d951b49c Mon Sep 17 00:00:00 2001 From: os-support-ai Date: Tue, 25 Aug 2026 12:07:22 +0000 Subject: [PATCH 1/2] fix(fields,i18n): derive the capability label set from PLATFORM_CAPABILITIES MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `CURATED_CAPABILITY_LABELS` was a seven-member literal under a doc comment claiming it mirrored `@objectstack/spec/security`'s `PLATFORM_CAPABILITIES`. The spec grew an eighth member and the copy did not follow, so `manage_sharing` fell through to the English label the `sys_capability` registry serves and rendered untranslated in all ten packs beside seven siblings that localize. The set now derives from `PLATFORM_CAPABILITIES`, applying the same dot -> underscore transform the call site uses, and `capability.label.manage_sharing` is authored in the ten packs and in the field widgets' provider-less defaults map. `labelFor` gains a `defaultValue` so the window between a spec bump and a translation degrades to the registry label instead of a raw i18n key. KNOWN RED, deliberately not worked around: deriving turns the declaration into a computed value, which `scripts/check-i18n-call-site-keys.mjs`'s `readVocabulary` cannot read, so the `capability.label.` family moves to `enumerable: false` / `external-vocabulary`. That drops `counters.enumerableFamilies` 18 -> 17 and `counters.checkedMembers` 112 -> 105, and `scripts/__tests__/check-i18n-call-site-keys.test.ts` pins both as a ONE-WAY ratchet ("LOSING coverage does fail it"). Re-baselining that ratchet to land this shape is a maintainer decision, not this branch's to take — see the report on objectui#6285. Part of #6285 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011SfZeFWrhGLHmfq61xbz4q --- ...6285-capability-labels-derive-from-spec.md | 25 +++ ...tiSelectField.specDerivation-6285.test.tsx | 176 ++++++++++++++++++ .../widgets/CapabilityMultiSelectField.tsx | 67 +++++-- .../fields/src/widgets/useFieldTranslation.ts | 1 + packages/i18n/src/locales/ar.ts | 1 + packages/i18n/src/locales/de.ts | 1 + packages/i18n/src/locales/en.ts | 1 + packages/i18n/src/locales/es.ts | 1 + packages/i18n/src/locales/fr.ts | 1 + packages/i18n/src/locales/ja.ts | 1 + packages/i18n/src/locales/ko.ts | 1 + packages/i18n/src/locales/pt.ts | 1 + packages/i18n/src/locales/ru.ts | 1 + packages/i18n/src/locales/zh.ts | 1 + scripts/check-i18n-call-site-keys.mjs | 21 ++- 15 files changed, 283 insertions(+), 17 deletions(-) create mode 100644 .changeset/6285-capability-labels-derive-from-spec.md create mode 100644 packages/fields/src/widgets/CapabilityMultiSelectField.specDerivation-6285.test.tsx diff --git a/.changeset/6285-capability-labels-derive-from-spec.md b/.changeset/6285-capability-labels-derive-from-spec.md new file mode 100644 index 0000000000..f05b21d084 --- /dev/null +++ b/.changeset/6285-capability-labels-derive-from-spec.md @@ -0,0 +1,25 @@ +--- +'@object-ui/fields': patch +'@object-ui/i18n': patch +--- + +The capability picker localizes `manage_sharing` (objectui#6285). Before this, "Manage +Sharing" was the one platform capability in `sys_permission_set`'s picker that rendered in +English in every locale, beside seven siblings that translated — a user-visible missing +translation, in all ten packs at once. + +The cause was a hand-written copy. `CURATED_CAPABILITY_LABELS` in +`CapabilityMultiSelectField.tsx` listed seven capability names under a doc comment claiming +it mirrored `@objectstack/spec/security`'s `PLATFORM_CAPABILITIES`; the spec grew an eighth +member and the copy did not follow, so `manage_sharing` fell through to the English label +the `sys_capability` registry serves. Nothing could catch it: the i18n gate checked that the +seven names the copy happened to list had keys — which they did — and had no way to notice +the member the copy never named. + +So the set is now DERIVED from `PLATFORM_CAPABILITIES` rather than restated, applying the +same dot-to-underscore transform the call site uses (`setup.access` → `setup_access`), and +`capability.label.manage_sharing` is authored in all ten packs and in the field widgets' +provider-less defaults map. A capability the spec adds next now joins the picker on the +version bump, and a new +`CapabilityMultiSelectField.specDerivation-6285.test.tsx` fails in CI if its label has not +been authored — the event this card was, caught before it reaches a screen instead of after. diff --git a/packages/fields/src/widgets/CapabilityMultiSelectField.specDerivation-6285.test.tsx b/packages/fields/src/widgets/CapabilityMultiSelectField.specDerivation-6285.test.tsx new file mode 100644 index 0000000000..dc0545dd5a --- /dev/null +++ b/packages/fields/src/widgets/CapabilityMultiSelectField.specDerivation-6285.test.tsx @@ -0,0 +1,176 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#6285 — the capability picker's curated label set IS + * `@objectstack/spec/security`'s `PLATFORM_CAPABILITIES`, and every member of it + * reaches the screen as a localized label. + * + * ## What went wrong, and why nothing caught it + * + * `CURATED_CAPABILITY_LABELS` was a hand-written seven-member `Set` carrying a + * doc comment that claimed to mirror `PLATFORM_CAPABILITIES`. The spec grew an + * eighth member (`manage_sharing`) and the copy did not follow, so that one + * capability fell through to `byName.get(name)?.label` — the English label the + * `sys_capability` registry serves — and rendered untranslated in all ten packs, + * beside seven siblings that localize. Every gate stayed green: the seven keys + * the literal named all existed, and no instrument compared the literal to the + * array it named itself after. + * + * ## Why these assertions and not a render smoke test + * + * "the picker renders" passes in both worlds. Each assertion below is written to + * fail in exactly one of them, so every registry row this file feeds in carries a + * label deliberately UNLIKE the localized one (`REGISTRY …`). If the derivation + * regressed — or if the `defaultValue` fallback were doing the rendering instead + * of a real translation — the `REGISTRY …` string is what would appear, and the + * equality below reports it by name. + * + * The three dotted members are their own case. The spec spells `setup.access`, + * `setup.write` and `studio.access` with a dot; the i18n keys spell them with an + * underscore, and `labelFor` bridges that with `name.replace(/\./g, '_')`. A + * derivation that forgets the same transform silently un-localizes three members + * that work today, which is the likeliest way to ship a regression here — so the + * transform is restated in this file ON PURPOSE rather than imported from the + * widget: a pin that shares the implementation's own helper moves when the + * implementation moves and pins nothing. + * + * ## PREDICTIONS, written before the first run + * + * Against `origin/main` (hand-written literal, no `capability.label.manage_sharing` + * key anywhere) — this file was expected RED on: + * - "manage_sharing renders its localized label" — renders `REGISTRY Manage Sharing` + * - "every declared platform capability renders its localized label" — same member + * - "`en` carries a label for every declared platform capability" — key absent + * and GREEN on the dotted-member and orphan assertions, which describe behaviour + * the literal already had. Recorded so a green first run would read as a broken + * harness rather than as a passing fix. + */ +import { describe, it, expect, vi } from 'vitest'; +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { PLATFORM_CAPABILITIES } from '@objectstack/spec/security'; +import { builtInLocales } from '@object-ui/i18n'; +import { CapabilityMultiSelectField } from './CapabilityMultiSelectField'; + +/** + * The dot -> underscore transform `labelFor` applies before building the key. + * Deliberately a second statement of it (see the header). + */ +const keyPart = (specName: string) => specName.replace(/\./g, '_'); + +/** The `en` pack's `capability.label` table, or a loud failure if it moved. */ +const enLabels = (): Record => { + const capability = (builtInLocales.en as Record).capability as + | { label?: unknown } + | undefined; + const node = capability?.label; + if (!node || typeof node !== 'object') { + throw new Error('en.capability.label is missing — the label table moved or was renamed'); + } + return node as Record; +}; + +/** + * One `sys_capability` row per declared platform capability, each carrying a + * label that is NOT the localized one. Any assertion that reads `REGISTRY …` + * back has caught the widget falling through to the registry. + */ +const registryRows = () => + PLATFORM_CAPABILITIES.map((c) => ({ + name: c.name, + label: `REGISTRY ${c.label}`, + description: c.description, + scope: c.scope, + active: true, + })); + +const mockDataSource = (rows: unknown[]) => + ({ find: vi.fn().mockResolvedValue({ data: rows }) }) as any; + +const renderPicker = (rows: unknown[]) => + render( + , + ); + +describe('capability labels derive from PLATFORM_CAPABILITIES (objectui#6285)', () => { + it('reads a non-empty vocabulary and a non-empty label table', () => { + // The positive control. Both loops below iterate one of these; if either + // side resolved to nothing they would pass vacuously, and a vacuous pass + // reads exactly like a real one. + expect(PLATFORM_CAPABILITIES.length).toBeGreaterThan(5); + expect(Object.keys(enLabels()).length).toBeGreaterThan(5); + }); + + it('`en` carries a label for every declared platform capability', () => { + // The half that fails when the spec grows a member and nobody authors the + // key — the exact event that produced this card, caught in CI instead of on + // screen. `check:i18n-keys` cannot state this: its vocabulary reader is + // repo-source-only and the members now live in a dependency. + const labels = enLabels(); + const missing = PLATFORM_CAPABILITIES.map((c) => keyPart(c.name)).filter( + (k) => typeof labels[k] !== 'string' || !(labels[k] as string).trim(), + ); + expect(missing).toEqual([]); + }); + + it('has no orphan label left behind by a capability the spec removed', () => { + const declared = new Set(PLATFORM_CAPABILITIES.map((c) => keyPart(c.name))); + expect(Object.keys(enLabels()).filter((k) => !declared.has(k))).toEqual([]); + }); + + it('renders `manage_sharing` as its localized label, not the registry English', async () => { + // The member the hand-written literal was missing. Red on `main`: the + // picker rendered `REGISTRY Manage Sharing`. + renderPicker(registryRows()); + const expected = enLabels()[keyPart('manage_sharing')] as string; + expect(await screen.findByRole('button', { name: expected })).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: 'REGISTRY Manage Sharing' }), + ).not.toBeInTheDocument(); + }); + + it('still resolves the three dotted spec names through the underscore keys', async () => { + // `setup.access` / `setup.write` / `studio.access`. These work today; a + // derivation that drops the transform breaks them, and nothing else here + // would notice. + renderPicker(registryRows()); + const dotted = PLATFORM_CAPABILITIES.filter((c) => c.name.includes('.')); + // Not pinned to exactly 3: a count is the hand-maintained copy this file exists + // to retire. The floor is only here so the loop cannot pass vacuously. + expect(dotted.length).toBeGreaterThanOrEqual(3); + for (const cap of dotted) { + const expected = enLabels()[keyPart(cap.name)] as string; + expect(await screen.findByRole('button', { name: expected })).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: `REGISTRY ${cap.label}` }), + ).not.toBeInTheDocument(); + } + }); + + it('renders every declared platform capability as its localized label', async () => { + // The whole vocabulary in one sweep, and the assertion that ties the + // provider-less defaults map to the `en` pack: this render has no + // I18nProvider, so the string on screen comes from `FIELD_DEFAULTS`, while + // the expectation is read out of `en`. They must agree member by member. + renderPicker(registryRows()); + const expectedNames = PLATFORM_CAPABILITIES.map((c) => enLabels()[keyPart(c.name)] as string); + await screen.findByRole('button', { name: expectedNames[0] }); + for (const name of expectedNames) { + expect(screen.getByRole('button', { name })).toBeInTheDocument(); + } + // And nothing fell through to the registry. + expect(screen.queryAllByRole('button', { name: /^REGISTRY / })).toEqual([]); + }); +}); diff --git a/packages/fields/src/widgets/CapabilityMultiSelectField.tsx b/packages/fields/src/widgets/CapabilityMultiSelectField.tsx index 254e3f0571..e6d5632624 100644 --- a/packages/fields/src/widgets/CapabilityMultiSelectField.tsx +++ b/packages/fields/src/widgets/CapabilityMultiSelectField.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { Badge, EmptyValue, cn } from '@object-ui/components'; import { SchemaRendererContext } from '@object-ui/react'; import type { DataSource, QueryParams } from '@object-ui/types'; +import { PLATFORM_CAPABILITIES } from '@objectstack/spec/security'; import { FieldWidgetComponentProps } from './types.js'; import { useFieldTranslation } from './useFieldTranslation.js'; @@ -73,21 +74,45 @@ export function parseCapabilityNames(value: unknown): string[] { const SCOPE_ORDER = ['platform', 'org', 'other'] as const; /** - * objectui#2600 B5 — the curated platform capabilities are a FIXED, known set - * whose labels the sys_capability registry serves in English. Localize just - * these client-side via `capability.label.` (dots → underscores); - * package- and admin-authored capabilities keep their authored registry label. - * (Mirrors @objectstack/spec/security `PLATFORM_CAPABILITIES`.) + * objectui#2600 B5 — the curated platform capabilities whose labels this picker + * localizes client-side via `capability.label.`; package- and + * admin-authored capabilities keep their authored `sys_capability` label. + * + * ## objectui#6285 — DERIVED from the spec, not restated + * + * This used to be a seven-member literal under a doc comment that said it + * mirrored `@objectstack/spec/security`'s `PLATFORM_CAPABILITIES`. Nothing held + * the claim, and the copy had already fallen a member behind: the spec grew + * `manage_sharing`, the literal did not, so that one capability fell through to + * its registry label and rendered untranslated in all ten packs beside seven + * siblings that localize. Deriving retires the claim by making it structural — + * there is no longer a second list to drift from. + * + * ## The dot → underscore transform is deliberate, and it belongs here too + * + * The spec spells three of the eight names with a dot (`setup.access`, + * `setup.write`, `studio.access`) while the i18n keys spell them with an + * underscore, and `labelFor` bridges that at the call site. The derivation + * applies the SAME transform, so membership is in the same alphabet as the + * keys. Deriving without it would silently un-localize those three — members + * that work today — which is the one regression this shape can ship. + * + * ## What replaced the gate coverage this cost + * + * `scripts/check-i18n-call-site-keys.mjs` registered this symbol as a `kind: + * 'set'` vocabulary and expanded `capability.label.` into exact key + * checks. Its reader parses repo source and needs a literal `new Set([…])`, so + * a computed initialiser is `unreadable-vocabulary` there; the family is now + * declared `enumerable: false` / `external-vocabulary`, and the member-to-label + * tie is pinned at test time — where importing the spec is free — by + * `CapabilityMultiSelectField.specDerivation-6285.test.tsx`, which covers the + * `en` pack AND `useFieldTranslation`'s provider-less defaults map. That is + * strictly more than the gate could state, because the gate could not see a + * member the literal never named. */ -const CURATED_CAPABILITY_LABELS = new Set([ - 'manage_users', - 'manage_org_users', - 'manage_metadata', - 'manage_platform_settings', - 'setup_access', - 'setup_write', - 'studio_access', -]); +const CURATED_CAPABILITY_LABELS: ReadonlySet = new Set( + PLATFORM_CAPABILITIES.map((c) => c.name.replace(/\./g, '_')), +); export function CapabilityMultiSelectField({ value, @@ -153,9 +178,19 @@ export function CapabilityMultiSelectField({ // Curated platform caps get a localized label (objectui#2600 B5); everything // else keeps the registry-served label. const labelFor = (name: string) => { + const registryLabel = byName.get(name)?.label || name; const norm = name.replace(/\./g, '_'); - if (CURATED_CAPABILITY_LABELS.has(norm)) return t(`capability.label.${norm}`); - return byName.get(name)?.label || name; + if (!CURATED_CAPABILITY_LABELS.has(norm)) return registryLabel; + // objectui#6285 — the membership is now open-ended: a capability the spec + // adds joins this set the moment the pin is bumped, which is the point, but + // its `capability.label.*` key still has to be authored by a human in the + // ten packs. `defaultValue` makes that window degrade to the registry's + // English label — exactly what this picker did for `manage_sharing` before + // this change — instead of rendering a raw i18n key at the user, which + // would be strictly worse than the defect being fixed. It is a fallback of + // last resort, not the mechanism: the spec-derivation test fails in CI on + // the same event, so the window should never reach a screen. + return t(`capability.label.${norm}`, { defaultValue: registryLabel }); }; // Group options by scope for the editable grid. Computed BEFORE the readonly diff --git a/packages/fields/src/widgets/useFieldTranslation.ts b/packages/fields/src/widgets/useFieldTranslation.ts index 8f51ab4cdf..df1534fa39 100644 --- a/packages/fields/src/widgets/useFieldTranslation.ts +++ b/packages/fields/src/widgets/useFieldTranslation.ts @@ -135,6 +135,7 @@ const FIELD_DEFAULTS: Record = { 'capability.label.setup_access': 'Setup Access', 'capability.label.setup_write': 'Write Settings', 'capability.label.studio_access': 'Studio Access', + 'capability.label.manage_sharing': 'Manage Sharing', }; export const useFieldTranslation = createSafeTranslation( diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index bf360cf3a5..2aa85428bb 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -23,6 +23,7 @@ const ar = { setup_access: "الوصول إلى الإعداد", setup_write: "كتابة الإعدادات", studio_access: "الوصول إلى Studio", + manage_sharing: "إدارة المشاركة", }, group: { platform: 'منصة', diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index 3ebf496cd9..f2d3303fba 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -23,6 +23,7 @@ const de = { setup_access: "Zugriff auf die Einrichtung", setup_write: "Einstellungen schreiben", studio_access: "Studio-Zugriff", + manage_sharing: "Freigaben verwalten", }, group: { platform: 'Plattform', diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 1e191a0914..cfba3209be 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -34,6 +34,7 @@ const en = { setup_access: 'Setup Access', setup_write: 'Write Settings', studio_access: 'Studio Access', + manage_sharing: 'Manage Sharing', }, }, // objectui#3546 slice six — the read-only facet summary + Studio deep-link a diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index e3fa453afd..490c307bba 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -23,6 +23,7 @@ const es = { setup_access: "Acceso a la configuración", setup_write: "Escritura de la configuración", studio_access: "Acceso a Studio", + manage_sharing: "Gestionar el uso compartido", }, group: { platform: 'Plataforma', diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index dfee807716..f16338e705 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -23,6 +23,7 @@ const fr = { setup_access: "Accès à la configuration", setup_write: "Écriture des paramètres", studio_access: "Accès à Studio", + manage_sharing: "Gérer le partage", }, group: { platform: 'Plateforme', diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index 3376606a92..7c14d6dd55 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -23,6 +23,7 @@ const ja = { setup_access: "設定へのアクセス", setup_write: "設定の書き込み", studio_access: "Studio へのアクセス", + manage_sharing: "共有の管理", }, group: { platform: 'プラットフォーム', diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index e10f1fd463..7f3e40a005 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -23,6 +23,7 @@ const ko = { setup_access: "설정 접근", setup_write: "설정 쓰기", studio_access: "Studio 접근", + manage_sharing: "공유 관리", }, group: { platform: '플랫폼', diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index 7e70bf5ca3..5a115d4731 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -23,6 +23,7 @@ const pt = { setup_access: "Acesso à configuração", setup_write: "Gravação de configurações", studio_access: "Acesso ao Studio", + manage_sharing: "Gerenciar compartilhamento", }, group: { platform: 'Plataforma', diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index 3b52685e9c..9981b31eab 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -23,6 +23,7 @@ const ru = { setup_access: "Доступ к настройке", setup_write: "Запись настроек", studio_access: "Доступ к Studio", + manage_sharing: "Управление общим доступом", }, group: { platform: 'Платформа', diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index 8f3706e479..6ac6f20014 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -33,6 +33,7 @@ const zh = { setup_access: '访问 Setup', setup_write: '保存设置', studio_access: '访问 Studio', + manage_sharing: '管理共享', }, }, // objectui#3546 切片六 —— 权限集记录上六个授权面的只读摘要与 Studio 深链 diff --git a/scripts/check-i18n-call-site-keys.mjs b/scripts/check-i18n-call-site-keys.mjs index 7ef52af2e0..3b002152c1 100644 --- a/scripts/check-i18n-call-site-keys.mjs +++ b/scripts/check-i18n-call-site-keys.mjs @@ -728,7 +728,26 @@ export const DYNAMIC_KEY_FAMILIES = [ }, { head: 'capability.label.', - vocabulary: { module: 'packages/fields/src/widgets/CapabilityMultiSelectField.tsx', name: 'CURATED_CAPABILITY_LABELS', kind: 'set' }, + enumerable: false, + why: 'external-vocabulary', + reason: + 'The members are `PLATFORM_CAPABILITIES` from `@objectstack/spec/security`, each name ' + + 'normalised dot -> underscore by the call site. This family USED to name ' + + '`CURATED_CAPABILITY_LABELS` as a `kind: \'set\'` vocabulary, and that reading was exact ' + + 'but hollow: the declaration was a hand-written copy of the spec array, so the gate ' + + 'checked the seven members the copy happened to name and could say nothing about the ' + + 'eighth the spec had added. `manage_sharing` lost its localized label in ten packs with ' + + 'this entry green (objectui#6285). The fix derives the set from the spec, which makes the ' + + 'initialiser a computed `new Set(PLATFORM_CAPABILITIES.map(…))` — not a shape ' + + '`readVocabulary` can read, and correctly reported as `unreadable-vocabulary` if this ' + + 'entry still claimed it. The documented bridge for an external vocabulary — a repo-local ' + + 'exhaustive `Record` — is unavailable here: `PlatformCapability.name` is typed ' + + '`string`, so the spec publishes no union of capability names to key a Record by. The ' + + 'member-to-label tie is pinned at TEST time instead, where importing the spec is free: ' + + '`packages/fields/src/widgets/CapabilityMultiSelectField.specDerivation-6285.test.tsx` ' + + 'asserts every declared capability resolves to a real label in `en` AND in ' + + "`useFieldTranslation`'s provider-less defaults map, and that no label outlives the " + + 'capability it names. That covers strictly more than this entry ever did.', }, { head: 'common.', From 823a024cc99ca185d86b77b19fbd52c151cc4ba2 Mon Sep 17 00:00:00 2001 From: os-support-ai Date: Tue, 25 Aug 2026 12:58:09 +0000 Subject: [PATCH 2/2] refactor(fields): pin the curated capability set to the spec by test, not by derivation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PM adjudication on #6285 chose shape B over the runtime derivation the first commit implemented, and this commit is that switch. Nothing about the defect or its user-visible fix changes: `manage_sharing` still joins the curated set and still gets its `capability.label.*` key in the ten packs and in the field widgets' provider-less defaults map, and `labelFor` keeps the `defaultValue` degradation. What changes is HOW the `Mirrors PLATFORM_CAPABILITIES` claim is held. - `CURATED_CAPABILITY_LABELS` goes back to a repo-local literal, now with `manage_sharing`. That keeps it readable to `check-i18n-call-site-keys.mjs`, whose `readVocabulary` needs a literal `new Set([…])`; the derivation made it `unreadable-vocabulary` and forced the family to `enumerable: false`, dropping the gate from 18 vocabularies / 113 exactly-checked members to 17 / 105 and tripping the one-way coverage ratchet in `scripts/__tests__/check-i18n-call-site-keys.test.ts`. Moving that ratchet is gate-weakening and belongs on the maintainer's floor, so it was not moved. - `scripts/check-i18n-call-site-keys.mjs` is reverted to the base version; the family keeps its `kind: 'set'` vocabulary. - The pin is renamed `specDerivation` -> `specParity` and grows the assertion that makes the claim mechanical: it reads the declaration through the gate's OWN `readVocabulary` and asserts set equality with `PLATFORM_CAPABILITIES` (dot -> underscore normalised) in BOTH directions, alongside the rendering assertions it already carried. Measured under this shape: `check:i18n-keys` green at 18 vocabularies / 113 members — one MORE than base's 112, the extra member being `manage_sharing` — and the ratchet test 109/109 green. The derivation remains a coherent choice and the branch history plus the PR body keep its measurements; it simply costs a guard, and this shape costs none. Fixes #6285 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011SfZeFWrhGLHmfq61xbz4q --- ...6285-capability-labels-derive-from-spec.md | 25 +- ...tiSelectField.specDerivation-6285.test.tsx | 176 ------------- ...yMultiSelectField.specParity-6285.test.tsx | 244 ++++++++++++++++++ .../widgets/CapabilityMultiSelectField.tsx | 88 ++++--- scripts/check-i18n-call-site-keys.mjs | 21 +- 5 files changed, 316 insertions(+), 238 deletions(-) delete mode 100644 packages/fields/src/widgets/CapabilityMultiSelectField.specDerivation-6285.test.tsx create mode 100644 packages/fields/src/widgets/CapabilityMultiSelectField.specParity-6285.test.tsx diff --git a/.changeset/6285-capability-labels-derive-from-spec.md b/.changeset/6285-capability-labels-derive-from-spec.md index f05b21d084..5c5c1affa0 100644 --- a/.changeset/6285-capability-labels-derive-from-spec.md +++ b/.changeset/6285-capability-labels-derive-from-spec.md @@ -8,18 +8,19 @@ Sharing" was the one platform capability in `sys_permission_set`'s picker that r English in every locale, beside seven siblings that translated — a user-visible missing translation, in all ten packs at once. -The cause was a hand-written copy. `CURATED_CAPABILITY_LABELS` in +The cause was an unchecked copy. `CURATED_CAPABILITY_LABELS` in `CapabilityMultiSelectField.tsx` listed seven capability names under a doc comment claiming it mirrored `@objectstack/spec/security`'s `PLATFORM_CAPABILITIES`; the spec grew an eighth -member and the copy did not follow, so `manage_sharing` fell through to the English label -the `sys_capability` registry serves. Nothing could catch it: the i18n gate checked that the -seven names the copy happened to list had keys — which they did — and had no way to notice -the member the copy never named. +member and the list did not follow, so `manage_sharing` fell through to the English label +the `sys_capability` registry serves. Nothing could catch it: the i18n gate reads that list +as this key family's vocabulary and checks the members it names — all seven had keys — and +no instrument compared the vocabulary to the array it was named after. -So the set is now DERIVED from `PLATFORM_CAPABILITIES` rather than restated, applying the -same dot-to-underscore transform the call site uses (`setup.access` → `setup_access`), and -`capability.label.manage_sharing` is authored in all ten packs and in the field widgets' -provider-less defaults map. A capability the spec adds next now joins the picker on the -version bump, and a new -`CapabilityMultiSelectField.specDerivation-6285.test.tsx` fails in CI if its label has not -been authored — the event this card was, caught before it reaches a screen instead of after. +`capability.label.manage_sharing` is now authored in all ten packs and in the field widgets' +provider-less defaults map, the list carries the member, and the prose claim is replaced by +a check: `CapabilityMultiSelectField.specParity-6285.test.tsx` imports `PLATFORM_CAPABILITIES` +and fails on any difference in either direction, reading the declaration through the i18n +gate's own source reader so what it pins is exactly what that gate consumes. `labelFor` also +gains a `defaultValue`, so a capability that arrives in a future spec bump before its +translation is authored degrades to the registry's English label rather than rendering a raw +i18n key at the user. diff --git a/packages/fields/src/widgets/CapabilityMultiSelectField.specDerivation-6285.test.tsx b/packages/fields/src/widgets/CapabilityMultiSelectField.specDerivation-6285.test.tsx deleted file mode 100644 index dc0545dd5a..0000000000 --- a/packages/fields/src/widgets/CapabilityMultiSelectField.specDerivation-6285.test.tsx +++ /dev/null @@ -1,176 +0,0 @@ -/** - * ObjectUI - * Copyright (c) 2024-present ObjectStack Inc. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -/** - * objectui#6285 — the capability picker's curated label set IS - * `@objectstack/spec/security`'s `PLATFORM_CAPABILITIES`, and every member of it - * reaches the screen as a localized label. - * - * ## What went wrong, and why nothing caught it - * - * `CURATED_CAPABILITY_LABELS` was a hand-written seven-member `Set` carrying a - * doc comment that claimed to mirror `PLATFORM_CAPABILITIES`. The spec grew an - * eighth member (`manage_sharing`) and the copy did not follow, so that one - * capability fell through to `byName.get(name)?.label` — the English label the - * `sys_capability` registry serves — and rendered untranslated in all ten packs, - * beside seven siblings that localize. Every gate stayed green: the seven keys - * the literal named all existed, and no instrument compared the literal to the - * array it named itself after. - * - * ## Why these assertions and not a render smoke test - * - * "the picker renders" passes in both worlds. Each assertion below is written to - * fail in exactly one of them, so every registry row this file feeds in carries a - * label deliberately UNLIKE the localized one (`REGISTRY …`). If the derivation - * regressed — or if the `defaultValue` fallback were doing the rendering instead - * of a real translation — the `REGISTRY …` string is what would appear, and the - * equality below reports it by name. - * - * The three dotted members are their own case. The spec spells `setup.access`, - * `setup.write` and `studio.access` with a dot; the i18n keys spell them with an - * underscore, and `labelFor` bridges that with `name.replace(/\./g, '_')`. A - * derivation that forgets the same transform silently un-localizes three members - * that work today, which is the likeliest way to ship a regression here — so the - * transform is restated in this file ON PURPOSE rather than imported from the - * widget: a pin that shares the implementation's own helper moves when the - * implementation moves and pins nothing. - * - * ## PREDICTIONS, written before the first run - * - * Against `origin/main` (hand-written literal, no `capability.label.manage_sharing` - * key anywhere) — this file was expected RED on: - * - "manage_sharing renders its localized label" — renders `REGISTRY Manage Sharing` - * - "every declared platform capability renders its localized label" — same member - * - "`en` carries a label for every declared platform capability" — key absent - * and GREEN on the dotted-member and orphan assertions, which describe behaviour - * the literal already had. Recorded so a green first run would read as a broken - * harness rather than as a passing fix. - */ -import { describe, it, expect, vi } from 'vitest'; -import React from 'react'; -import { render, screen } from '@testing-library/react'; -import '@testing-library/jest-dom'; -import { PLATFORM_CAPABILITIES } from '@objectstack/spec/security'; -import { builtInLocales } from '@object-ui/i18n'; -import { CapabilityMultiSelectField } from './CapabilityMultiSelectField'; - -/** - * The dot -> underscore transform `labelFor` applies before building the key. - * Deliberately a second statement of it (see the header). - */ -const keyPart = (specName: string) => specName.replace(/\./g, '_'); - -/** The `en` pack's `capability.label` table, or a loud failure if it moved. */ -const enLabels = (): Record => { - const capability = (builtInLocales.en as Record).capability as - | { label?: unknown } - | undefined; - const node = capability?.label; - if (!node || typeof node !== 'object') { - throw new Error('en.capability.label is missing — the label table moved or was renamed'); - } - return node as Record; -}; - -/** - * One `sys_capability` row per declared platform capability, each carrying a - * label that is NOT the localized one. Any assertion that reads `REGISTRY …` - * back has caught the widget falling through to the registry. - */ -const registryRows = () => - PLATFORM_CAPABILITIES.map((c) => ({ - name: c.name, - label: `REGISTRY ${c.label}`, - description: c.description, - scope: c.scope, - active: true, - })); - -const mockDataSource = (rows: unknown[]) => - ({ find: vi.fn().mockResolvedValue({ data: rows }) }) as any; - -const renderPicker = (rows: unknown[]) => - render( - , - ); - -describe('capability labels derive from PLATFORM_CAPABILITIES (objectui#6285)', () => { - it('reads a non-empty vocabulary and a non-empty label table', () => { - // The positive control. Both loops below iterate one of these; if either - // side resolved to nothing they would pass vacuously, and a vacuous pass - // reads exactly like a real one. - expect(PLATFORM_CAPABILITIES.length).toBeGreaterThan(5); - expect(Object.keys(enLabels()).length).toBeGreaterThan(5); - }); - - it('`en` carries a label for every declared platform capability', () => { - // The half that fails when the spec grows a member and nobody authors the - // key — the exact event that produced this card, caught in CI instead of on - // screen. `check:i18n-keys` cannot state this: its vocabulary reader is - // repo-source-only and the members now live in a dependency. - const labels = enLabels(); - const missing = PLATFORM_CAPABILITIES.map((c) => keyPart(c.name)).filter( - (k) => typeof labels[k] !== 'string' || !(labels[k] as string).trim(), - ); - expect(missing).toEqual([]); - }); - - it('has no orphan label left behind by a capability the spec removed', () => { - const declared = new Set(PLATFORM_CAPABILITIES.map((c) => keyPart(c.name))); - expect(Object.keys(enLabels()).filter((k) => !declared.has(k))).toEqual([]); - }); - - it('renders `manage_sharing` as its localized label, not the registry English', async () => { - // The member the hand-written literal was missing. Red on `main`: the - // picker rendered `REGISTRY Manage Sharing`. - renderPicker(registryRows()); - const expected = enLabels()[keyPart('manage_sharing')] as string; - expect(await screen.findByRole('button', { name: expected })).toBeInTheDocument(); - expect( - screen.queryByRole('button', { name: 'REGISTRY Manage Sharing' }), - ).not.toBeInTheDocument(); - }); - - it('still resolves the three dotted spec names through the underscore keys', async () => { - // `setup.access` / `setup.write` / `studio.access`. These work today; a - // derivation that drops the transform breaks them, and nothing else here - // would notice. - renderPicker(registryRows()); - const dotted = PLATFORM_CAPABILITIES.filter((c) => c.name.includes('.')); - // Not pinned to exactly 3: a count is the hand-maintained copy this file exists - // to retire. The floor is only here so the loop cannot pass vacuously. - expect(dotted.length).toBeGreaterThanOrEqual(3); - for (const cap of dotted) { - const expected = enLabels()[keyPart(cap.name)] as string; - expect(await screen.findByRole('button', { name: expected })).toBeInTheDocument(); - expect( - screen.queryByRole('button', { name: `REGISTRY ${cap.label}` }), - ).not.toBeInTheDocument(); - } - }); - - it('renders every declared platform capability as its localized label', async () => { - // The whole vocabulary in one sweep, and the assertion that ties the - // provider-less defaults map to the `en` pack: this render has no - // I18nProvider, so the string on screen comes from `FIELD_DEFAULTS`, while - // the expectation is read out of `en`. They must agree member by member. - renderPicker(registryRows()); - const expectedNames = PLATFORM_CAPABILITIES.map((c) => enLabels()[keyPart(c.name)] as string); - await screen.findByRole('button', { name: expectedNames[0] }); - for (const name of expectedNames) { - expect(screen.getByRole('button', { name })).toBeInTheDocument(); - } - // And nothing fell through to the registry. - expect(screen.queryAllByRole('button', { name: /^REGISTRY / })).toEqual([]); - }); -}); diff --git a/packages/fields/src/widgets/CapabilityMultiSelectField.specParity-6285.test.tsx b/packages/fields/src/widgets/CapabilityMultiSelectField.specParity-6285.test.tsx new file mode 100644 index 0000000000..fff5ec158b --- /dev/null +++ b/packages/fields/src/widgets/CapabilityMultiSelectField.specParity-6285.test.tsx @@ -0,0 +1,244 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#6285 — `CURATED_CAPABILITY_LABELS` equals `@objectstack/spec/security`'s + * `PLATFORM_CAPABILITIES`, and every member of it reaches the screen as a + * localized label. + * + * ## What went wrong, and why nothing caught it + * + * The declaration was a seven-member list under a doc comment claiming it + * mirrored `PLATFORM_CAPABILITIES`. The spec grew an eighth member + * (`manage_sharing`) and the list did not follow, so that capability fell + * through to `byName.get(name)?.label` — the English label the `sys_capability` + * registry serves — and rendered untranslated in all ten packs, beside seven + * siblings that localize. Every gate stayed green, and the reason is exact: + * `scripts/check-i18n-call-site-keys.mjs` reads this declaration as the + * `capability.label.` family's vocabulary and checks that each member it names + * has an `en` key. All seven did. No instrument compared the vocabulary to the + * array it was named after, so the member the list never mentioned was invisible + * to the entire toolchain. + * + * This file is that missing comparison. It is the reason the list is allowed to + * stay a repo-local literal (see the declaration's own comment for why a runtime + * derivation was measured and declined), and it is deliberately the same shape + * as `packages/app-shell/src/hooks/__tests__/tenancyPostureWall.parity.test.ts`: + * a local restatement, held to the protocol by a test that imports the protocol. + * Tests are not bundled, so the assertion is free. + * + * ## Two layers, and both are needed + * + * - SOURCE parity — the member set equals the spec's names, in both + * directions. Read with the i18n gate's OWN `readVocabulary`, so what this + * file pins is precisely what that gate consumes; a rewrite that made the + * declaration unreadable to the gate would fail here first, instead of + * silently downgrading the gate to a prefix check. + * - RENDERED parity — each member resolves to a real localized string on + * screen. Source parity alone would pass with the label table empty, and the + * `en` table alone says nothing about `useFieldTranslation`'s provider-less + * defaults map, which is what actually renders when no I18nProvider is + * mounted. + * + * Every registry row fed in below carries a label deliberately UNLIKE the + * localized one (`REGISTRY …`). "The picker renders" passes in both worlds; each + * assertion here is written to fail in exactly one. If the list regressed — or + * if `labelFor`'s `defaultValue` fallback were doing the rendering instead of a + * real translation — `REGISTRY …` is what would appear, and the equality reports + * it by name. + * + * The dotted members are their own case. The spec spells `setup.access`, + * `setup.write` and `studio.access` with a dot; the keys spell them with an + * underscore, and `labelFor` bridges that with `name.replace(/\./g, '_')`. The + * transform is restated in this file ON PURPOSE rather than imported from the + * widget: a pin that shares the implementation's own helper moves when the + * implementation moves and pins nothing. + * + * ## PREDICTIONS, written before the run + * + * On this branch: all green. Against the pre-fix declaration (seven members, no + * `capability.label.manage_sharing` key), expected RED on source parity, on the + * `en`-coverage assertion, and on the two rendering assertions that name + * `manage_sharing`; expected GREEN on the dotted-member, orphan and non-vacuity + * assertions, which describe behaviour the seven-member list already had. + */ +import { describe, it, expect, vi } from 'vitest'; +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { PLATFORM_CAPABILITIES } from '@objectstack/spec/security'; +import { builtInLocales } from '@object-ui/i18n'; +// The i18n gate's own source reader (objectui#4964). Importing it — rather than +// re-implementing a regex — is what makes "the vocabulary that gate consumes" +// and "the thing this file pins" the same object. Plain JS, untyped here, the +// same arrangement `packages/layout/src/__tests__/readme-registration-keys.test.ts` +// uses for `scripts/component-registrations.mjs`. +// @ts-expect-error — plain-JS shared helper, intentionally untyped +import { readVocabulary } from '../../../../scripts/check-i18n-call-site-keys.mjs'; +import { CapabilityMultiSelectField } from './CapabilityMultiSelectField'; + +/** + * The root `readVocabulary` resolves `module` against. `'.'` — the process cwd — + * IS the repo root here, and not by luck: `scripts/vitest-invocation-guard.mjs` + * refuses any invocation whose vitest root is not the repo root (objectui#3378, + * #3288), which is the same invariant. Spelled this way rather than as + * `resolve(__dirname, '../../../..')` because this package's test program + * deliberately does not name `types: ['node']` — see the long note in + * `packages/fields/tsconfig.test.json` — and a test file is not the place to + * change that program. If the invariant ever broke, `declaredMembers()` throws + * the message below rather than reporting an empty vocabulary. + */ +const VOCABULARY_ROOT = '.'; + +/** The registry entry `check-i18n-call-site-keys.mjs` declares for this family. */ +const VOCABULARY = { + module: 'packages/fields/src/widgets/CapabilityMultiSelectField.tsx', + name: 'CURATED_CAPABILITY_LABELS', + kind: 'set', +} as const; + +/** + * The dot -> underscore transform `labelFor` applies before building the key. + * Deliberately a second statement of it (see the header). + */ +const keyPart = (specName: string) => specName.replace(/\./g, '_'); + +/** Spec member names in the alphabet the i18n keys are written in. */ +const specKeyParts = () => PLATFORM_CAPABILITIES.map((c) => keyPart(c.name)).sort(); + +/** The declaration, read exactly as the i18n gate reads it. */ +const declaredMembers = (): string[] => { + const members = readVocabulary(VOCABULARY_ROOT, VOCABULARY) as string[] | null; + if (members === null) { + throw new Error( + `${VOCABULARY.name} is unreadable as a \`${VOCABULARY.kind}\` in ${VOCABULARY.module} — ` + + 'either the declaration moved, was renamed, or was rewritten into a shape the i18n ' + + 'gate cannot parse (which would silently downgrade `capability.label.` to a prefix ' + + 'check), or this run\'s cwd is not the repo root and the file was never opened.', + ); + } + return [...members].sort(); +}; + +/** The `en` pack's `capability.label` table, or a loud failure if it moved. */ +const enLabels = (): Record => { + const capability = (builtInLocales.en as Record).capability as + | { label?: unknown } + | undefined; + const node = capability?.label; + if (!node || typeof node !== 'object') { + throw new Error('en.capability.label is missing — the label table moved or was renamed'); + } + return node as Record; +}; + +/** + * One `sys_capability` row per declared platform capability, each carrying a + * label that is NOT the localized one. Any assertion that reads `REGISTRY …` + * back has caught the widget falling through to the registry. + */ +const registryRows = () => + PLATFORM_CAPABILITIES.map((c) => ({ + name: c.name, + label: `REGISTRY ${c.label}`, + description: c.description, + scope: c.scope, + active: true, + })); + +const mockDataSource = (rows: unknown[]) => + ({ find: vi.fn().mockResolvedValue({ data: rows }) }) as any; + +const renderPicker = (rows: unknown[]) => + render( + , + ); + +describe('the curated capability set is PLATFORM_CAPABILITIES (objectui#6285)', () => { + it('reads a non-empty vocabulary, spec array and label table', () => { + // The positive control for every set-difference and every loop below. Each + // of them passes vacuously if its side resolved to nothing, and a vacuous + // pass reads exactly like a real one. Counts are floors, not pins: pinning + // today's 8 would re-create the hand-maintained copy this file exists to + // hold in check. + expect(PLATFORM_CAPABILITIES.length).toBeGreaterThan(5); + expect(declaredMembers().length).toBeGreaterThan(5); + expect(Object.keys(enLabels()).length).toBeGreaterThan(5); + }); + + it('declares exactly the spec\'s capability names, in both directions', () => { + // The assertion the `Mirrors` comment used to make in prose. `toEqual` on + // two sorted arrays fails on a member the spec added and this list lacks + // (the defect this card is) AND on one this list kept after the spec dropped + // it, which no rendering assertion can see. + expect(declaredMembers()).toEqual(specKeyParts()); + }); + + it('`en` carries a label for every declared platform capability', () => { + // Membership alone is not enough: a member with no key renders the registry + // label through `labelFor`'s `defaultValue` — quietly, and in every locale + // at once. This is the half that fails when the spec grows a member and + // nobody authors the translation. + const labels = enLabels(); + const missing = specKeyParts().filter( + (k) => typeof labels[k] !== 'string' || !(labels[k] as string).trim(), + ); + expect(missing).toEqual([]); + }); + + it('has no orphan label left behind by a capability the spec removed', () => { + const declared = new Set(specKeyParts()); + expect(Object.keys(enLabels()).filter((k) => !declared.has(k))).toEqual([]); + }); + + it('renders `manage_sharing` as its localized label, not the registry English', async () => { + // The member the seven-name list was missing. Red before the fix: the picker + // rendered `REGISTRY Manage Sharing`. + renderPicker(registryRows()); + const expected = enLabels()[keyPart('manage_sharing')] as string; + expect(await screen.findByRole('button', { name: expected })).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: 'REGISTRY Manage Sharing' }), + ).not.toBeInTheDocument(); + }); + + it('still resolves the three dotted spec names through the underscore keys', async () => { + renderPicker(registryRows()); + const dotted = PLATFORM_CAPABILITIES.filter((c) => c.name.includes('.')); + // Not pinned to exactly 3: a count is the hand-maintained copy this file + // exists to retire. The floor is only here so the loop cannot pass vacuously. + expect(dotted.length).toBeGreaterThanOrEqual(3); + for (const cap of dotted) { + const expected = enLabels()[keyPart(cap.name)] as string; + expect(await screen.findByRole('button', { name: expected })).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: `REGISTRY ${cap.label}` }), + ).not.toBeInTheDocument(); + } + }); + + it('renders every declared platform capability as its localized label', async () => { + // The whole vocabulary in one sweep, and the assertion that ties the + // provider-less defaults map to the `en` pack: this render mounts no + // I18nProvider, so the string on screen comes from `FIELD_DEFAULTS`, while + // the expectation is read out of `en`. They must agree member by member. + renderPicker(registryRows()); + const expectedNames = PLATFORM_CAPABILITIES.map((c) => enLabels()[keyPart(c.name)] as string); + await screen.findByRole('button', { name: expectedNames[0] }); + for (const name of expectedNames) { + expect(screen.getByRole('button', { name })).toBeInTheDocument(); + } + // And nothing fell through to the registry. + expect(screen.queryAllByRole('button', { name: /^REGISTRY / })).toEqual([]); + }); +}); diff --git a/packages/fields/src/widgets/CapabilityMultiSelectField.tsx b/packages/fields/src/widgets/CapabilityMultiSelectField.tsx index e6d5632624..cf60991d66 100644 --- a/packages/fields/src/widgets/CapabilityMultiSelectField.tsx +++ b/packages/fields/src/widgets/CapabilityMultiSelectField.tsx @@ -2,7 +2,6 @@ import React from 'react'; import { Badge, EmptyValue, cn } from '@object-ui/components'; import { SchemaRendererContext } from '@object-ui/react'; import type { DataSource, QueryParams } from '@object-ui/types'; -import { PLATFORM_CAPABILITIES } from '@objectstack/spec/security'; import { FieldWidgetComponentProps } from './types.js'; import { useFieldTranslation } from './useFieldTranslation.js'; @@ -78,41 +77,70 @@ const SCOPE_ORDER = ['platform', 'org', 'other'] as const; * localizes client-side via `capability.label.`; package- and * admin-authored capabilities keep their authored `sys_capability` label. * - * ## objectui#6285 — DERIVED from the spec, not restated + * ## This IS `@objectstack/spec/security`'s `PLATFORM_CAPABILITIES`, and the + * ## equality is CHECKED rather than claimed (objectui#6285) * - * This used to be a seven-member literal under a doc comment that said it - * mirrored `@objectstack/spec/security`'s `PLATFORM_CAPABILITIES`. Nothing held - * the claim, and the copy had already fallen a member behind: the spec grew - * `manage_sharing`, the literal did not, so that one capability fell through to - * its registry label and rendered untranslated in all ten packs beside seven - * siblings that localize. Deriving retires the claim by making it structural — - * there is no longer a second list to drift from. + * The names below are the spec's, with the dot spellings written as underscores + * (see the transform note under the declaration). It used to say that in prose + * and nothing held it: the spec grew `manage_sharing`, this list did not follow, + * and that capability fell through to the English label the `sys_capability` + * registry serves — untranslated in all ten packs, beside seven siblings that + * localize, with every gate green. * - * ## The dot → underscore transform is deliberate, and it belongs here too + * What holds it now is `CapabilityMultiSelectField.specParity-6285.test.tsx`, + * which imports `PLATFORM_CAPABILITIES` and fails on ANY difference in either + * direction — a member the spec added and this list lacks, or one this list + * keeps after the spec dropped it. A spec bump that moves the vocabulary turns + * CI red here before it can reach a screen. * - * The spec spells three of the eight names with a dot (`setup.access`, - * `setup.write`, `studio.access`) while the i18n keys spell them with an - * underscore, and `labelFor` bridges that at the call site. The derivation - * applies the SAME transform, so membership is in the same alphabet as the - * keys. Deriving without it would silently un-localize those three — members - * that work today — which is the one regression this shape can ship. + * ## Why a repo-local list rather than a runtime derivation * - * ## What replaced the gate coverage this cost + * `new Set(PLATFORM_CAPABILITIES.map(…))` was built and measured (objectui#6285, + * and the branch history carries the numbers). It costs nothing in bundle terms + * — +0.3 KB gzipped on the console's eager closure, because the console's graph + * already reaches those modules — so bundle size is NOT the reason, and the + * `useTenancyPosture.ts` precedent's reason does not apply here. * - * `scripts/check-i18n-call-site-keys.mjs` registered this symbol as a `kind: - * 'set'` vocabulary and expanded `capability.label.` into exact key - * checks. Its reader parses repo source and needs a literal `new Set([…])`, so - * a computed initialiser is `unreadable-vocabulary` there; the family is now - * declared `enumerable: false` / `external-vocabulary`, and the member-to-label - * tie is pinned at test time — where importing the spec is free — by - * `CapabilityMultiSelectField.specDerivation-6285.test.tsx`, which covers the - * `en` pack AND `useFieldTranslation`'s provider-less defaults map. That is - * strictly more than the gate could state, because the gate could not see a - * member the literal never named. + * The reason is instrument coverage. `scripts/check-i18n-call-site-keys.mjs` + * reads this declaration as the `capability.label.` family's vocabulary and + * expands it into exact `en` key checks; its reader parses source and needs a + * literal `new Set([…])`, so a computed initialiser is `unreadable-vocabulary` + * and the family would have to fall back to `enumerable: false`, dropping the + * gate from 18 vocabularies / 113 exactly-checked members to 17 / 105. The gate + * documents one bridge for a vocabulary living in a dependency — "a repo-local + * exhaustive `Record` this reader can read" — and it is unavailable: + * `PlatformCapability.name` is typed `string`, so the spec publishes no union to + * key a `Record` by. + * + * And the benefit a derivation would have bought is not real. A capability the + * spec adds cannot "arrive automatically": its `capability.label.*` key still + * has to be authored by a human in ten packs, or it renders the registry English + * (this card's exact defect) or a raw key. `manage_sharing` is the proof — it + * had no key anywhere. Deriving does not remove the human step; it only chooses + * which instrument reports it. So the shape that keeps BOTH instruments — this + * list read by the gate, and the parity test read by CI — wins on the only axis + * that separates them. + * + * ⛔ Do not hand-edit this list to match a new spec release without also + * authoring `capability.label.` in all ten packs and in + * `useFieldTranslation.ts`; the parity test fails on the first, and + * `all-locales-key-parity.test.ts` on the second. */ -const CURATED_CAPABILITY_LABELS: ReadonlySet = new Set( - PLATFORM_CAPABILITIES.map((c) => c.name.replace(/\./g, '_')), -); +const CURATED_CAPABILITY_LABELS = new Set([ + 'manage_users', + 'manage_org_users', + 'manage_metadata', + 'manage_platform_settings', + // The spec spells these three with a dot (`setup.access`, `setup.write`, + // `studio.access`). `labelFor` normalises dots to underscores before building + // the key, so membership is written in the key's alphabet, not the spec's. + // The parity test applies the same transform and would fail if either side + // stopped agreeing. + 'setup_access', + 'setup_write', + 'studio_access', + 'manage_sharing', +]); export function CapabilityMultiSelectField({ value, diff --git a/scripts/check-i18n-call-site-keys.mjs b/scripts/check-i18n-call-site-keys.mjs index 3b002152c1..7ef52af2e0 100644 --- a/scripts/check-i18n-call-site-keys.mjs +++ b/scripts/check-i18n-call-site-keys.mjs @@ -728,26 +728,7 @@ export const DYNAMIC_KEY_FAMILIES = [ }, { head: 'capability.label.', - enumerable: false, - why: 'external-vocabulary', - reason: - 'The members are `PLATFORM_CAPABILITIES` from `@objectstack/spec/security`, each name ' + - 'normalised dot -> underscore by the call site. This family USED to name ' + - '`CURATED_CAPABILITY_LABELS` as a `kind: \'set\'` vocabulary, and that reading was exact ' + - 'but hollow: the declaration was a hand-written copy of the spec array, so the gate ' + - 'checked the seven members the copy happened to name and could say nothing about the ' + - 'eighth the spec had added. `manage_sharing` lost its localized label in ten packs with ' + - 'this entry green (objectui#6285). The fix derives the set from the spec, which makes the ' + - 'initialiser a computed `new Set(PLATFORM_CAPABILITIES.map(…))` — not a shape ' + - '`readVocabulary` can read, and correctly reported as `unreadable-vocabulary` if this ' + - 'entry still claimed it. The documented bridge for an external vocabulary — a repo-local ' + - 'exhaustive `Record` — is unavailable here: `PlatformCapability.name` is typed ' + - '`string`, so the spec publishes no union of capability names to key a Record by. The ' + - 'member-to-label tie is pinned at TEST time instead, where importing the spec is free: ' + - '`packages/fields/src/widgets/CapabilityMultiSelectField.specDerivation-6285.test.tsx` ' + - 'asserts every declared capability resolves to a real label in `en` AND in ' + - "`useFieldTranslation`'s provider-less defaults map, and that no label outlives the " + - 'capability it names. That covers strictly more than this entry ever did.', + vocabulary: { module: 'packages/fields/src/widgets/CapabilityMultiSelectField.tsx', name: 'CURATED_CAPABILITY_LABELS', kind: 'set' }, }, { head: 'common.',