From 12ae5d7b448cd0298c81646417d5818f76c921c8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 18 Sep 2026 18:43:23 +0000 Subject: [PATCH] fix(core): derive the undrawable list-view kinds instead of hard-coding them (objectui#9880) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `normalize-list-view`'s two view-kind tables could not be total against both the `@objectstack/spec` this repository resolves and one built from objectstack `main`, which retired the list-view kind `page`. The undrawable table was annotated `Record, string | null>`, and an annotated object literal is exact in both directions: the retired kind's row became an excess property (TS2353) whose only repair under that annotation is deleting the row — which the resolved published spec forbids, because an author can still write a `page` view against it. The table now carries `satisfies Record` and the undrawable union is `Extract`, so a row for a retired kind goes inert at type level while staying live at runtime. `ListViewVisualization` subtracts that derived union instead of the literal `'list' | 'detail' | 'page'`; its members are unchanged under either spec. The add-direction guard is unchanged and still mechanical: a kind neither table classes stays inside `ListViewVisualization`, so the total `Record` on `LIST_VIEW_KINDS` fails the build until it is classed as drawable there or explained here. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018HrVaotisyhgmot9o2MLRq --- .../9880-undrawable-view-kinds-both-legs.md | 15 ++ ...-list-view.undrawableBothLegs-9880.test.ts | 225 ++++++++++++++++++ .../core/src/utils/normalize-list-view.ts | 63 ++++- 3 files changed, 296 insertions(+), 7 deletions(-) create mode 100644 .changeset/9880-undrawable-view-kinds-both-legs.md create mode 100644 packages/core/src/utils/__tests__/normalize-list-view.undrawableBothLegs-9880.test.ts diff --git a/.changeset/9880-undrawable-view-kinds-both-legs.md b/.changeset/9880-undrawable-view-kinds-both-legs.md new file mode 100644 index 0000000000..01dd0eebf8 --- /dev/null +++ b/.changeset/9880-undrawable-view-kinds-both-legs.md @@ -0,0 +1,15 @@ +--- +'@object-ui/core': patch +--- + +`normalize-list-view`'s two view-kind tables now derive their partition from the undrawable table's own keys instead of a hard-coded list of kinds, so one spelling is total against both the `@objectstack/spec` this repository resolves today and one built from objectstack `main`. + +`UNDRAWABLE_VIEW_KINDS` was annotated `Record, string | null>`, and `ListViewVisualization` subtracted the literal `'list' | 'detail' | 'page'`. An annotated object literal is exact in BOTH directions, so when objectstack retired the list-view kind `page` from the spec (objectstack#17063, ADR-0049 enforce-or-remove) the table's `page:` row became an excess property against the narrower union — a TS2353 whose only repair under that annotation is DELETING the row, which this repository cannot do while it resolves a published spec that still accepts `type: 'page'` views. + +The table now carries `satisfies Record` — the value constraint without the exactness — and the undrawable union is `Extract`, so a row for a kind the spec has retired goes inert at type level while staying live at runtime, which is what an author who can still write a `page` view against the resolved spec needs. `ListViewVisualization` subtracts that derived union instead of a literal list; its members are unchanged under either spec, and so is every runtime answer this module gives. + +The guarantee the tables carry is unchanged and still mechanical, it just lands in one place instead of two: a kind the spec ADDS is classed by neither table, so it stays inside `ListViewVisualization`, and the total `Record` on `LIST_VIEW_KINDS` fails the build until the new kind is classed as drawable there or explained in the undrawable table. A MISSPELLED key in the undrawable table fails the same way, because the kind it was meant to class is not extracted either. + +Compiled both ways rather than argued. With the published pin `@objectstack/spec@17.4.0` installed, `turbo run type-check --filter=@object-ui/core` exits 0 before and after this change. With a spec built from objectstack `main` injected in its place, the same command reported the TS2353 in `normalize-list-view.ts` before and does not report it after; the diagnostics that remain on that leg come from other files, are reproduced against the pristine source, and are not this change's. + +`normalize-list-view.undrawableBothLegs-9880.test.ts` pins all of it over a SIMULATED vocabulary, because any single run installs only one spec: the retirement direction, the addition direction, and the old spelling's TS2353 kept as a firing control. diff --git a/packages/core/src/utils/__tests__/normalize-list-view.undrawableBothLegs-9880.test.ts b/packages/core/src/utils/__tests__/normalize-list-view.undrawableBothLegs-9880.test.ts new file mode 100644 index 0000000000..f6189d5c96 --- /dev/null +++ b/packages/core/src/utils/__tests__/normalize-list-view.undrawableBothLegs-9880.test.ts @@ -0,0 +1,225 @@ +/** + * 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#9880 — the two view-kind tables in `normalize-list-view.ts` have to + * be total against TWO different `ViewType` unions at once: + * + * · the `@objectstack/spec` this repository RESOLVES today, which still + * publishes the list-view kind `page`; + * · a spec built from objectstack `main`, which RETIRED it (objectstack#17063, + * ADR-0049 enforce-or-remove) and which objectui#9860's shape gate compiles + * this repository against on every pull request. + * + * The spelling that failed was the ANNOTATION + * `Record, string | null>` on the + * undrawable table. An annotated object literal is exact in BOTH directions, so + * the day the kind left the union its row became an excess property — TS2353, + * the one diagnostic that gate reported on its first run — and the only repair + * the annotation admits is DELETING the row, which the resolved published spec + * forbids because an author can still write a `page` view against it. + * + * ⭐ The card's first deliverable was the judgement "is a both-legs spelling + * possible at all", with a re-grade switch if it was not. It is: keep the + * value constraint with `satisfies`, drop the exactness, and derive the + * undrawable union from the table's keys INTERSECTED with the live vocabulary + * (`Extract`), so a retired row is inert rather than illegal. This file pins + * that judgement in both directions, with the failing spelling kept as a firing + * control so the green below can never be the green of a check that stopped + * checking. + * + * ⚠️ Only ONE spec is installed in any single run, so the two legs cannot both + * be observed at runtime here. The vocabulary is therefore SIMULATED for the + * type-level half — the same two operators in the same composition, applied to + * a published-shaped union, a main-shaped one, and a grown one. The real + * module's own cross-leg invariants are asserted separately below, and they are + * written to hold identically under either spec; the two-spec compile itself + * lives in this card's pull request, where both legs were run. + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; + +import { isListViewVisualization } from '../normalize-list-view.js'; +import type { ListViewVisualization } from '../normalize-list-view.js'; + +type Equal = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 + ? true + : false; +type Expect = T; + +/* ------------------------------------------------------------------ * + * The mechanism, lifted off the live vocabulary so both legs are * + * reachable from one compile. * + * ------------------------------------------------------------------ */ + +/** The source's `UndrawableViewKind`, over an arbitrary vocabulary and table. */ +type Undrawable = Extract; + +/** The source's `ListViewVisualization`, over the same two parameters. */ +type Drawable = Exclude>; + +/** A stand-in for `UNDRAWABLE_VIEW_KINDS`, spelled the way the source spells it. */ +const SIMULATED_UNDRAWABLE = { + list: null, + detail: null, + page: 'the retired kind, still classed while the resolved spec accepts one', +} satisfies Record; +type SimulatedTable = typeof SIMULATED_UNDRAWABLE; + +/** The resolved spec's shape: the kind is still in the vocabulary. */ +type PublishedVocabulary = 'grid' | 'kanban' | 'page' | 'list' | 'detail'; +/** objectstack `main`'s shape: the kind is gone. */ +type MainVocabulary = 'grid' | 'kanban' | 'list' | 'detail'; +/** The direction the guard was written FOR: the spec grows a member. */ +type GrownVocabulary = 'grid' | 'kanban' | 'page' | 'pivot' | 'list' | 'detail'; + +// The derived drawable set is the SAME under both legs — the retired row does +// not leak into it, and its absence does not take a drawable kind with it. +export type _DrawableUnderPublished = Expect< + Equal, 'grid' | 'kanban'> +>; +export type _DrawableUnderMain = Expect, 'grid' | 'kanban'>>; + +// …and a table total over it compiles under both, which is the whole claim. +export const drawableUnderPublished: Record, true> = { + grid: true, + kanban: true, +}; +export const drawableUnderMain: Record, true> = { + grid: true, + kanban: true, +}; + +// FIRING CONTROL — the spelling this card replaced. Annotated with the exact +// `Record>`, the retired row is an excess property the moment the +// vocabulary drops it. If this assignment ever compiles, the green above has +// stopped meaning "tolerant of a retirement" and this file must be re-read. +export const retiredRowUnderTheOldSpelling: Record< + Exclude>, + string | null +> = { + list: null, + detail: null, + // @ts-expect-error — TS2353: 'page' does not exist in type 'Record<"list" | "detail", string | null>' + page: 'the retired kind', +}; + +// The ADD direction objectui#8127 installed the guard for, unchanged by this +// card: a kind NEITHER table classes stays in the drawable union, so the +// drawable table is missing a key (TS2739) until it is classed. +// @ts-expect-error — 'pivot' is classed by neither table, so it is a required key here +export const drawableUnderGrown: Record, true> = { + grid: true, + kanban: true, +}; + +/** The other half of "drawable OR explained": explaining it clears the red. */ +const SIMULATED_UNDRAWABLE_WITH_PIVOT = { + ...SIMULATED_UNDRAWABLE, + pivot: 'a hypothetical non-visualization list type', +} satisfies Record; + +export const drawableUnderGrownOnceExplained: Record< + Drawable, + true +> = { + grid: true, + kanban: true, +}; + +/* ------------------------------------------------------------------ * + * The real module: invariants that read the same under either spec. * + * ------------------------------------------------------------------ */ + +/** + * The exported visualization union is the nine kinds `ListView` has a `case` + * for — under the resolved spec AND under `main`'s. This is what makes the + * change a re-spelling rather than a re-definition: the published type does not + * move, only the way it is derived. + */ +export type _VisualizationUnionIsTheNineDrawnKinds = Expect< + Equal< + ListViewVisualization, + 'grid' | 'kanban' | 'gallery' | 'calendar' | 'timeline' | 'gantt' | 'map' | 'chart' | 'tree' + > +>; + +const DRAWN_KINDS = [ + 'grid', + 'kanban', + 'gallery', + 'calendar', + 'timeline', + 'gantt', + 'map', + 'chart', + 'tree', +] as const; + +describe('the undrawable table survives an upstream retirement (objectui#9880)', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('keeps the simulated legs honest: every table above is a real, non-empty reading', () => { + // A census over an empty set passes, so the simulated tables are sized + // before the type-level assertions above are trusted. + expect(Object.keys(SIMULATED_UNDRAWABLE)).toEqual(['list', 'detail', 'page']); + expect(Object.keys(SIMULATED_UNDRAWABLE_WITH_PIVOT)).toEqual(['list', 'detail', 'page', 'pivot']); + expect(Object.keys(drawableUnderPublished)).toEqual(Object.keys(drawableUnderMain)); + expect(Object.keys(drawableUnderGrownOnceExplained)).toEqual(['grid', 'kanban']); + expect(Object.keys(retiredRowUnderTheOldSpelling)).toContain('page'); + expect(Object.keys(drawableUnderGrown)).toEqual(['grid', 'kanban']); + }); + + it('answers the drawable question identically whichever spec is resolved', () => { + for (const kind of DRAWN_KINDS) { + expect(isListViewVisualization(kind)).toBe(true); + } + // Controls: the predicate is a membership test, not a `true` generator. + // All three are undrawable under either spec — `list` and `detail` because + // they are objectui CATEGORIES with no spec counterpart, `page` because the + // renderer has no branch for it whether or not the spec still offers it. + expect(isListViewVisualization('page')).toBe(false); + expect(isListViewVisualization('list')).toBe(false); + expect(isListViewVisualization('detail')).toBe(false); + }); + + it('still explains a `page` view out loud, because the resolved spec still accepts one', async () => { + // The retired row is kept at RUNTIME on purpose: `Extract` removes it from + // the type when the vocabulary drops it, and nothing removes it from the + // table. While the resolved spec accepts a `page` view, an author can still + // write one, and the degrade to a grid still has to say so. + // + // ⚠️ The warning is once-per-kind-per-MODULE-INSTANCE, and the `unit` + // project runs with `isolate: false`, so a sibling suite that already + // normalised a `page` view in this worker has spent it — asserting on the + // statically imported module measured whichever file ran first. A fresh + // instance is what makes the count below a reading instead of a race. + vi.resetModules(); + const fresh = await import('../normalize-list-view.js'); + + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const out = fresh.normalizeListViewSchema({ + type: 'list-view', + objectName: 'account', + specType: 'page', + }); + + expect((out as { viewType?: string }).viewType).toBe('grid'); + expect(warn).toHaveBeenCalledTimes(1); + expect(String(warn.mock.calls[0]?.[0])).toContain('"page"'); + + // Control: the same fresh instance says nothing for a kind it DRAWS, so the + // line above is the retired row answering and not a warning for every input. + warn.mockClear(); + fresh.normalizeListViewSchema({ type: 'list-view', objectName: 'account', specType: 'grid' }); + expect(warn).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/utils/normalize-list-view.ts b/packages/core/src/utils/normalize-list-view.ts index 59dc77b681..2cb7935053 100644 --- a/packages/core/src/utils/normalize-list-view.ts +++ b/packages/core/src/utils/normalize-list-view.ts @@ -150,10 +150,21 @@ const ARIA_KEY_ALIASES: Record = { * a future non-visualization list type (another `page`) pass this site in * silence, which is the exact failure being fixed. * + * ⭐ What it subtracts is {@link UndrawableViewKind} — the keys of + * {@link UNDRAWABLE_VIEW_KINDS} the current `ViewType` still carries — and + * never a literal list of kinds (objectui#9880). A literal list is a second + * hand-written copy of the vocabulary, the failure class objectui#8127 removed + * from `ViewType` itself, and it rots in the direction the spec actually moved: + * `Exclude` kept subtracting `'page'` + * after objectstack RETIRED it, where `Exclude` of a non-member is a silent + * no-op no compiler reads. The sibling record's excess `page:` key is what went + * red; this line stayed green while saying something that had stopped being + * true. + * * It happens to equal `@objectstack/spec/ui`'s `VisualizationType` today. That - * is a fact about 17.3.0, not the definition. + * is a fact about the resolved spec, not the definition. */ -export type ListViewVisualization = Exclude; +export type ListViewVisualization = Exclude; /** * The view kinds `ListView` actually draws. @@ -190,20 +201,58 @@ const LIST_VIEW_KINDS: Record = { * with the reason — and, where the answer is "the author authored something * valid and will get a grid", the sentence to say out loud. * - * Total over `Exclude` so the two records - * together partition the derived union: a member the spec adds must be classed - * as drawable or explained HERE, and until it is, neither record compiles. + * The two tables together still partition the derived union — a member the spec + * ADDS must be classed as drawable or explained HERE, and until it is the build + * is red — but the red lands on {@link LIST_VIEW_KINDS} alone, because a kind + * neither table classes is not extracted into {@link UndrawableViewKind} and so + * stays inside {@link ListViewVisualization}, whose record is total. A key + * MISSPELLED here fails the same way, for the same reason: the kind it was + * meant to class is not extracted either. Both directions are pinned in + * `__tests__/normalize-list-view.undrawableBothLegs-9880.test.ts` rather than + * asserted in this sentence. + * + * ⛔ NOT annotated `Record, …>`, and + * the `satisfies` is not a style choice (objectui#9880). An annotation makes + * the literal EXACT in BOTH directions: a kind the spec adds is a missing key + * (wanted) and a kind the spec RETIRES is an EXCESS PROPERTY — a TS2353 that + * demands a deletion this repository cannot make while it resolves a published + * spec which still carries the kind. `satisfies` keeps the value constraint + * (every reason is a `string` or `null`) and drops the exactness, which is what + * lets one spelling compile against both the pinned published spec and one + * built from objectstack `main`. * * `null` means silence is correct — these are objectui view CATEGORIES, never * authored as a list-view kind: * - `list` is the view CATEGORY, not a kind — it already folds to `grid`. * - `detail` is a different renderer (`plugin-detail`), never a ListView case. + * + * ⚠️ `page` is a RETIRED spec kind and is kept on purpose. objectstack#17063 + * removed `type: 'page'` from the list-view enum, and until this repository's + * `@objectstack/spec` resolution moves onto a release carrying that removal an + * author can still write one, so this table still has to answer for it. Once + * the resolution moves, `Extract` stops extracting the row and it becomes inert + * of its own accord — deletable, on the day the residual pins that hold it + * (`normalize-list-view.pageResidual-8429.test.ts`) are converted, and not + * before. */ -const UNDRAWABLE_VIEW_KINDS: Record, string | null> = { +const UNDRAWABLE_VIEW_KINDS = { list: null, detail: null, page: 'it mounts a published page (bound through `pageName`) in place of rows, which this renderer has no branch for', -}; +} satisfies Record; + +/** + * The members of the current {@link ViewType} that {@link UNDRAWABLE_VIEW_KINDS} + * classes — the other half of the partition, derived rather than written out. + * + * `Extract` over the table's keys, not the table's raw `keyof`, is the load- + * bearing operator (objectui#9880): the table may carry a row for a kind the + * spec has since RETIRED, and `Extract` drops that row from the TYPE without + * the table having to lose it at runtime. That is what makes one spelling + * correct against both the pinned published spec (which still publishes `page`) + * and a spec built from objectstack `main` (which retired it). + */ +type UndrawableViewKind = Extract; /** One warning per undrawable kind per process — see {@link warnUndrawableViewKind}. */ const warnedUndrawableKinds = new Set();