From 13497d88bd8318325e2a3484371952d60aefbb51 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 19:06:38 +0000 Subject: [PATCH 1/2] test(types): derive `NamedListView`'s four disposition buckets against the protocol MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The census pinned "declared, and read off a named view?". The disposition the card is waiting on turns on a second question it never pinned: does the PROTOCOL declare the member? That separates "the renderer sources it from the wrong place" from "objectui invented a spelling", and it is the axis the 2026-09-16 director-seat ruling is written on — as prose, with no assertion that could fail. Derived at test time off the installed `@objectstack/spec`, the declaration's own AST and the runtime fold; the literals are the pinned reading: - the protocol declares 50 keys for a named list view, 5 of them retirement tombstones refused by name — so objectui not declaring those 5 is agreement, not narrowness; - objectui declares all 45 LIVE protocol keys: the narrower-than-protocol direction is EMPTY today, with the opposite difference (19 objectui-only) as the control that the zero is a reading; - the 43 unread members partition 23 + 8 + 10 + 2, disjoint and exhaustive; - `.omit().extend().superRefine()` still refuses unknown keys under zod 4 — executed with an accept control, not read off `strictObject`. No declaration face moves: `objectql.ts`, every `*.zod.ts` and every renderer read set are untouched. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UanLVj6xvbS6puBCewLr8L --- .../object-view-unmirrored-keys-7779.test.ts | 294 ++++++++++++++++++ 1 file changed, 294 insertions(+) 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); + }); +}); From 4794f47869dec59f4241279b2efe408220008486 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 19:10:19 +0000 Subject: [PATCH 2/2] docs(changeset): declare the bucket derivation, and re-take the census entry's retired figures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The census changeset still carried `47 declared / 6 read / 41 unread`. Those figures were re-taken at objectui#8980 (PR #9534), which declared the seventeen members the protocol declares on this surface: the partition is 21 read and 43 unread of 64 today, and the other three sites that carry the pair together were updated with it. This was the fourth site and the only one left — an unreleased changeset, so the retired figures would have shipped into the release notes. Frontmatter untouched (empty before, empty after): no package's release is affected by the edit. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UanLVj6xvbS6puBCewLr8L --- ...924-named-list-view-disposition-buckets.md | 28 +++++++++++++++++++ .../7924-named-list-view-liveness-census.md | 13 +++++---- 2 files changed, 36 insertions(+), 5 deletions(-) create mode 100644 .changeset/7924-named-list-view-disposition-buckets.md 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.