diff --git a/.changeset/7924-named-list-view-disposition-buckets.md b/.changeset/7924-named-list-view-disposition-buckets.md new file mode 100644 index 0000000000..b49df85687 --- /dev/null +++ b/.changeset/7924-named-list-view-disposition-buckets.md @@ -0,0 +1,28 @@ +--- +--- + +Derive `NamedListView`'s four disposition buckets against the protocol and pin +them (objectui#7924). + +The liveness census answered "declared, and read off a named view?". The +disposition the card is waiting on turns on a second question the census never +pinned: does `@objectstack/spec` declare the member? That is what separates +"the renderer sources it from the wrong place" from "objectui invented a +spelling", and the 2026-09-16 ruling is written on that axis — in prose, with +no assertion that could fail. + +Re-derived at test time, off the installed protocol schema, the declaration's +own AST and the runtime fold: the protocol declares **50** keys for a named +list view, **5** of them retirement tombstones refused by name, so objectui not +declaring those five is agreement rather than narrowness; objectui declares all +**45** live protocol keys, so the narrower-than-protocol direction is empty +today (it was seventeen before objectui#8980); and the **43** unread members +partition **23 + 8 + 10 + 2**, disjoint and exhaustive, so a member changing +bucket fails by name instead of staling a prose list. The strict protocol +value's unknown-key refusal through `.omit().extend().superRefine()` is now +executed under zod 4 with an accept control, rather than read off +`strictObject`. + +⛔ Measurement only: no declaration face moved, and the disposition for the 43 +unread members stays where the ruling put it. Test only; no package is released +by this change. diff --git a/.changeset/7924-named-list-view-liveness-census.md b/.changeset/7924-named-list-view-liveness-census.md index cad1fa58a0..c5c7051d64 100644 --- a/.changeset/7924-named-list-view-liveness-census.md +++ b/.changeset/7924-named-list-view-liveness-census.md @@ -6,10 +6,13 @@ Measure `NamedListView`'s per-member liveness and pin it (objectui#7924). The declared set is now walked with the TypeScript parser rather than a regex, the named-view read set is derived through every route from `schema.listViews` to a named view (with every occurrence of that record classified, so a new -route fails instead of shrinking the reading), and each of the 47 declared -members is pinned by name on its side of the partition — 6 read, 41 unread — -so a member moving between the two sets fails in either direction. +route fails instead of shrinking the reading), and each declared member is +pinned by name on its side of the partition — 6 read, 41 unread of 47 when this +entry was written; ⚠️ re-taken at objectui#8980, which declared the seventeen +members the protocol declares on this surface, so the partition is 21 read and +43 unread of 64 today — so a member moving between the two sets fails in either +direction. ⛔ Measurement only: neither declaration face moved, and the disposition for the -41 unread members (`?: never` tombstones vs. making the renderer read them) -stays with objectui#7928. Test only; no package is released by this change. +unread members (`?: never` tombstones vs. making the renderer read them) stays +with objectui#7928. Test only; no package is released by this change. diff --git a/packages/types/src/__tests__/object-view-unmirrored-keys-7779.test.ts b/packages/types/src/__tests__/object-view-unmirrored-keys-7779.test.ts index c31eb60ffb..af89d8bcd2 100644 --- a/packages/types/src/__tests__/object-view-unmirrored-keys-7779.test.ts +++ b/packages/types/src/__tests__/object-view-unmirrored-keys-7779.test.ts @@ -271,6 +271,107 @@ const NAMED_VIEW_UNREAD_CONTROL = 'rowHeight'; /** A plausible list-view spelling that is neither declared nor read. */ const NAMED_VIEW_ABSENT_CONTROL = 'stickyHeader'; +/* ── objectui#7924 — the DISPOSITION buckets, derived against the protocol ─── + * The census above answers "declared, and read off a named view?". It does not + * answer the question the disposition turns on: does the PROTOCOL declare the + * member? That is what separates "the renderer sources it from the wrong place" + * from "objectui invented a spelling". The 2026-09-16 director-seat ruling on + * objectui#7924 is written in those terms, and its bucket lists were prose with + * no assertion that could fail — the same shape that let "about 52 members" + * survive two corrections. Everything below is DERIVED at test time from the + * installed `@objectstack/spec`, the declaration and the runtime fold; the + * literals are the pinned reading. + */ + +/** + * The protocol's own member set for a named list view — `ObjectListViewSchema`'s + * shape, off `@objectstack/spec` as installed (17.4.0). ⛔ Not a copy of the + * objectui face and ⛔ not a hand list: it is read off the schema object, so a + * spec bump that moves a key moves this. + */ +const PROTOCOL_LIST_VIEW_KEYS = [ + 'addRecord', 'allowPrinting', 'appearance', 'aria', 'bordered', 'bulkActionDefs', 'bulkActions', + 'calendar', 'chart', 'columns', 'compactToolbar', 'conditionalFormatting', 'data', 'description', + 'emptyState', 'exportOptions', 'fieldOrder', 'filter', 'filterableFields', 'gallery', 'gantt', + 'grouping', 'hiddenFields', 'inlineEdit', 'kanban', 'label', 'map', 'name', 'navigation', + 'pageName', 'pagination', 'performance', 'resizable', 'responsive', 'rowActions', 'rowColor', + 'rowHeight', 'searchableFields', 'selection', 'sharing', 'showRecordCount', 'sort', 'striped', + 'tabs', 'timeline', 'tree', 'type', 'userActions', 'userFilters', 'virtualScroll', +] as const; + +/** + * Five of those 50 are RETIREMENT TOMBSTONES, refused by name with the + * protocol's own `[REMOVED]` text. ⚠️ They are why a raw key-count diff reads + * objectui as "narrower" and is wrong to: not declaring a key the protocol + * refuses is agreement, not a gap. Derived by the tombstone's own marker, not + * listed by hand. + */ +const PROTOCOL_RETIRED_KEYS = ['bordered', 'performance', 'responsive', 'striped', 'virtualScroll'] as const; + +/** The marker every protocol tombstone's description opens with. */ +const PROTOCOL_TOMBSTONE_MARKER = '[REMOVED]'; + +/** + * BUCKET ① — declared by BOTH faces and read off no named view: 23 members. + * The behaviour is the protocol's; only the SOURCE the renderer reads it from + * is wrong. ⛔ A `?: never` tombstone here would put objectui narrower than the + * protocol, which is the direction the maintainer principle forbids — what is + * left for these is implementation, ⛔ not declaration. + */ +const BUCKET_PROTOCOL_DECLARED_UNREAD = [ + 'addRecord', 'allowPrinting', 'aria', 'bulkActionDefs', 'bulkActions', 'compactToolbar', + 'conditionalFormatting', 'description', 'emptyState', 'exportOptions', 'filterableFields', + 'hiddenFields', 'inlineEdit', 'navigation', 'pagination', 'resizable', 'rowActions', 'rowHeight', + 'searchableFields', 'selection', 'sharing', 'showRecordCount', 'userFilters', +] as const; + +/** + * BUCKET ② — the eight legacy `show*` SPELLINGS: declared by objectui alone, + * each with a canonical protocol twin the runtime already folds onto. The + * mapping is objectui's own (`SHOW_FLAG_TO_USER_ACTION` + the `showDescription` + * fold), and it is re-derived off disk below rather than trusted from here. + */ +const LEGACY_SHOW_SPELLING_TWINS: Readonly> = { + showSearch: ['userActions', 'search'], + showSort: ['userActions', 'sort'], + showFilters: ['userActions', 'filter'], + showDensity: ['userActions', 'rowHeight'], + showGroup: ['userActions', 'group'], + showHideFields: ['userActions', 'hideFields'], + showColor: ['userActions', 'rowColor'], + showDescription: ['appearance', 'showDescription'], +}; +const BUCKET_LEGACY_SHOW_SPELLINGS = [ + 'showColor', 'showDensity', 'showDescription', 'showFilters', 'showGroup', 'showHideFields', + 'showSearch', 'showSort', +] as const; + +/** + * BUCKET ③ — ten members the protocol declares NOWHERE, no runtime fold, and no + * reader on any path. The only bucket where "declared, unenforced, unread" has + * nothing at all to weigh against it. + */ +const BUCKET_LOCAL_ONLY_UNREAD = [ + 'addDeleteRecordsInline', 'addRecordViaForm', 'allowExport', 'clickIntoRecordDetails', + 'collapseAllByDefault', 'color', 'densityMode', 'fieldTextColor', 'prefixField', 'wrapHeaders', +] as const; + +/** + * The two members objectui#8980 declared KNOWING nothing reads them, with the + * measurement reported — ⛔ not an oversight and ⛔ not a retirement candidate. + */ +const BUCKET_DECLARED_INERT = ['pageName', 'tabs'] as const; + +/** + * The one objectui-only member that IS read off a named view. It is the whole + * cost of a by-reference mirror in one name: the strict protocol value refuses + * it, and the renderer reads it. + */ +const LOCAL_ONLY_READ = 'options'; + +/** The `SHOW_FLAG_TO_USER_ACTION` fold lives here, and is read off disk. */ +const FOLD = 'packages/core/src/utils/normalize-list-view.ts'; + /** The syntactic roles every occurrence of the named-views RECORD may take. */ const RECORD_ROLES = [ 'COMPARISON', 'CONDITION', 'DECLARATION', 'ELEMENT_ACCESS', 'HOOK_DEPENDENCY', @@ -1176,3 +1277,196 @@ describe('objectui#7779 — the docs no longer teach `viewTabBar` as an `object- expect(configRow).toContain('`config` prop of `ViewTabBar`'); }); }); + +/* ── objectui#7924 — the disposition buckets, against the PROTOCOL ────────── */ + +/** The protocol's named-list-view member set, off the installed schema object. */ +function protocolListViewKeys(): string[] { + return shapeKeys(SpecObjectListViewSchema).sort(); +} + +/** The protocol keys whose slot carries the retirement marker in its description. */ +function protocolRetiredKeys(): string[] { + return protocolListViewKeys() + .filter((key) => ((shapeMember(SpecObjectListViewSchema, key) as { description?: string } | undefined)?.description ?? '') + .startsWith(PROTOCOL_TOMBSTONE_MARKER)) + .sort(); +} + +/** `SHOW_FLAG_TO_USER_ACTION`, read off the runtime fold rather than restated. */ +function foldFlagMap(): Record { + const src = readRepo(FOLD); + const start = src.indexOf('const SHOW_FLAG_TO_USER_ACTION: Record = {'); + expect(start, `\`SHOW_FLAG_TO_USER_ACTION\` is no longer declared in ${FOLD}`).toBeGreaterThan(-1); + const end = src.indexOf('\n};', start); + const body = src.slice(start, end); + const out: Record = {}; + for (const m of body.matchAll(/^ {2}([A-Za-z_$][\w$]*):[ ]'([^']+)',$/gm)) out[m[1]] = m[2]; + return out; +} + +describe('objectui#7924 — the 43 unread members are FOUR populations, derived against the installed protocol', () => { + it('the protocol declares 50 keys for a named list view, and the instrument that reads them fires', () => { + const keys = protocolListViewKeys(); + expect(keys).toEqual([...PROTOCOL_LIST_VIEW_KEYS]); + expect(keys).toHaveLength(50); + // Firing control + its negative twin, on the same query that produces every + // "the protocol declares this nowhere" zero below. + expect(keys).toContain(NAMED_VIEW_READ_CONTROL); + expect(keys).not.toContain(NAMED_VIEW_ABSENT_CONTROL); + }); + + it('five of the 50 are RETIREMENT TOMBSTONES, refused BY NAME — so objectui not declaring them is agreement, not narrowness', () => { + expect(protocolRetiredKeys()).toEqual([...PROTOCOL_RETIRED_KEYS]); + const base = { label: 'Directory', columns: ['name', 'email'] }; + // The accept leg first: without it every refusal below is a schema that + // refuses everything rather than a reading of these five keys. + expect(SpecObjectListViewSchema.safeParse(base).success).toBe(true); + for (const key of PROTOCOL_RETIRED_KEYS) { + const r = SpecObjectListViewSchema.safeParse({ ...base, [key]: true }); + expect(r.success, `the protocol no longer refuses the retired \`${key}\``).toBe(false); + if (!r.success) { + const issues = r.error.issues as readonly Issue[]; + // Refused AT the key with the protocol's own prescription — ⛔ not + // swallowed as an unknown key, which is what a deleted member would do. + expect(refusedAt(issues, key)).toBe(true); + expect(issues.some((i) => (i.message ?? '').includes(PROTOCOL_TOMBSTONE_MARKER) + || (i.message ?? '').includes('was removed in @objectstack/spec'))).toBe(true); + } + } + // …and none of the five is declared on the objectui face. + const declared = namedListViewMembers().names; + expect(PROTOCOL_RETIRED_KEYS.filter((k) => declared.includes(k))).toEqual([]); + }); + + it('objectui declares every LIVE protocol key on this surface — the narrower-than-protocol direction is EMPTY today', () => { + const declared = new Set(namedListViewMembers().names); + const live = protocolListViewKeys().filter((k) => !protocolRetiredKeys().includes(k)); + expect(live).toHaveLength(45); + const missing = live.filter((k) => !declared.has(k)); + // ⭐ objectui#8979 measured SEVENTEEN live protocol keys missing here; the + // objectui#8980 ruling declared them (PR #9534) and this is the zero that + // records it. ⛔ A name appearing here is objectui going narrower than the + // protocol — the direction the maintainer principle forbids. + expect(missing, 'objectui is narrower than the protocol on these live keys').toEqual([]); + // The control for that zero: the SAME set difference over the SAME two key + // sets is non-empty in the other direction, so the emptiness is a reading. + const localOnly = [...declared].filter((k) => !protocolListViewKeys().includes(k)).sort(); + expect(localOnly).toHaveLength(19); + expect(localOnly).toEqual([ + ...BUCKET_LEGACY_SHOW_SPELLINGS, ...BUCKET_LOCAL_ONLY_UNREAD, LOCAL_ONLY_READ, + ].sort()); + }); + + it('exactly one objectui-only member is READ off a named view — the whole cost of a by-reference mirror, in one name', () => { + const reads = deriveNamedViewReads().reads; + const protocolKeys = protocolListViewKeys(); + const localOnlyRead = reads.filter((r) => !protocolKeys.includes(r)); + expect(localOnlyRead).toEqual([LOCAL_ONLY_READ]); + // And the strict protocol value refuses it BY NAME, which is why the mirror + // is still not a drop-in — the same fact objectui#7928 weighs. + const r = SpecObjectListViewSchema.safeParse({ label: 'x', columns: ['a'], [LOCAL_ONLY_READ]: {} }); + expect(r.success).toBe(false); + if (!r.success) { + expect((r.error.issues as readonly Issue[]).some((i) => i.code === 'unrecognized_keys')).toBe(true); + } + }); + + it('`.omit().extend().superRefine()` STILL refuses unknown keys under zod 4 — measured, not read off `strictObject`', () => { + // ⚠️ Carried as NOT MEASURED by the 2026-09-10 protocol sweep and by the + // triage that answered it; executed here. If this ever flips, bucket ③'s + // "the protocol refuses these" weakens to "does not declare these". + const base = { label: 'Directory', columns: ['name', 'email'] }; + expect(SpecObjectListViewSchema.safeParse(base).success).toBe(true); + for (const key of [LOCAL_ONLY_READ, 'showSearch', 'densityMode', NAMED_VIEW_ABSENT_CONTROL]) { + const r = SpecObjectListViewSchema.safeParse({ ...base, [key]: key === LOCAL_ONLY_READ ? {} : true }); + expect(r.success, `the strict protocol value now ADMITS \`${key}\``).toBe(false); + if (!r.success) { + const issues = r.error.issues as readonly Issue[]; + expect(issues.some((i) => i.code === 'unrecognized_keys')).toBe(true); + } + } + }); + + it('the 43 unread members partition FOUR ways — 23 + 8 + 10 + 2 — disjoint and exhaustive', () => { + const unread = new Set(NAMED_LIST_VIEW_UNREAD as readonly string[]); + const protocolKeys = new Set(protocolListViewKeys()); + const ruledInert = new Set(BUCKET_DECLARED_INERT); + const spellings = new Set(BUCKET_LEGACY_SHOW_SPELLINGS); + + const protocolDeclared = [...unread].filter((m) => protocolKeys.has(m) && !ruledInert.has(m)).sort(); + const localOnly = [...unread].filter((m) => !protocolKeys.has(m)).sort(); + const legacy = localOnly.filter((m) => spellings.has(m)).sort(); + const inventedOnly = localOnly.filter((m) => !spellings.has(m)).sort(); + + expect(protocolDeclared).toEqual([...BUCKET_PROTOCOL_DECLARED_UNREAD]); + expect(legacy).toEqual([...BUCKET_LEGACY_SHOW_SPELLINGS]); + expect(inventedOnly).toEqual([...BUCKET_LOCAL_ONLY_UNREAD]); + expect([...ruledInert].sort()).toEqual([...BUCKET_DECLARED_INERT]); + expect(protocolDeclared).toHaveLength(23); + expect(legacy).toHaveLength(8); + expect(inventedOnly).toHaveLength(10); + expect(BUCKET_DECLARED_INERT).toHaveLength(2); + // Exhaustive and disjoint against the census's own unread set: a member that + // lands in no bucket, or in two, fails here — which is the event that makes + // the 2026-09-16 disposition ruling stale. + const union = [...protocolDeclared, ...legacy, ...inventedOnly, ...BUCKET_DECLARED_INERT]; + expect(union).toHaveLength(43); + expect(new Set(union).size).toBe(43); + expect([...union].sort()).toEqual([...unread].sort()); + }); + + it('the runtime fold maps seven of the eight legacy spellings, and folds the eighth — read off the fold, not restated', () => { + const map = foldFlagMap(); + expect(Object.keys(map).sort()).toEqual( + BUCKET_LEGACY_SHOW_SPELLINGS.filter((s) => s !== 'showDescription').slice().sort(), + ); + for (const [flag, action] of Object.entries(map)) { + expect(LEGACY_SHOW_SPELLING_TWINS[flag]).toEqual(['userActions', action]); + } + // The eighth is folded by its own branch, not by the table. + const src = readRepo(FOLD); + expect(src).toContain('appearance.showDescription = s.showDescription;'); + expect(src).toContain('delete next.showDescription;'); + // Control: a flag the fold does NOT know, measured by the same query. + expect(map).not.toHaveProperty('showRecordCount'); + }); + + it.each(BUCKET_PROTOCOL_DECLARED_UNREAD)('BUCKET ① — `%s` is declared by BOTH faces and read off no named view (the SOURCE is wrong, not the member)', (member) => { + expect(namedListViewMembers().names).toContain(member); + expect(protocolListViewKeys()).toContain(member); + expect(deriveNamedViewReads().reads).not.toContain(member); + }); + + it.each(BUCKET_LEGACY_SHOW_SPELLINGS)('BUCKET ② — `%s` is a legacy SPELLING: the protocol declares it nowhere, and the runtime already folds it onto its canonical twin', (member) => { + const [block, canonical] = LEGACY_SHOW_SPELLING_TWINS[member]; + expect(namedListViewMembers().names).toContain(member); + expect(protocolListViewKeys()).not.toContain(member); + expect(deriveNamedViewReads().reads).not.toContain(member); + // The canonical twin is a real protocol key on this very surface, and the + // block that carries it is declared on the objectui face too — which is the + // condition the 2026-09-16 ruling attached to retiring the spelling. + expect(protocolListViewKeys()).toContain(block); + expect(namedListViewMembers().names).toContain(block); + const inner = shapeMember(SpecObjectListViewSchema, block) as { unwrap(): unknown }; + expect(shapeKeys(inner.unwrap())).toContain(canonical); + }); + + it.each(BUCKET_LOCAL_ONLY_UNREAD)('BUCKET ③ — `%s` is objectui-only, folded nowhere and read nowhere', (member) => { + expect(namedListViewMembers().names).toContain(member); + expect(protocolListViewKeys()).not.toContain(member); + expect(foldFlagMap()).not.toHaveProperty(member); + expect(deriveNamedViewReads().reads).not.toContain(member); + // ⛔ "The protocol declares it nowhere" is the reading; the strict value + // refusing it is the measurement that backs it. + const r = SpecObjectListViewSchema.safeParse({ label: 'x', columns: ['a'], [member]: true }); + expect(r.success).toBe(false); + }); + + it.each(BUCKET_DECLARED_INERT)('INERT BY RULING — `%s` is protocol-declared, declared here and read nowhere, reported rather than dropped', (member) => { + expect(namedListViewMembers().names).toContain(member); + expect(protocolListViewKeys()).toContain(member); + expect(deriveNamedViewReads().reads).not.toContain(member); + expect(NAMED_LIST_VIEW_UNREAD as readonly string[]).toContain(member); + }); +});