diff --git a/.changeset/9012-core-spec-floor.md b/.changeset/9012-core-spec-floor.md new file mode 100644 index 0000000000..63097dc114 --- /dev/null +++ b/.changeset/9012-core-spec-floor.md @@ -0,0 +1,42 @@ +--- +'@object-ui/core': patch +--- + +Raise `@object-ui/core`'s declared `@objectstack/spec` floor from `^17.2.0` to +`^17.3.0` (objectui#9012) — the old range admitted a spec that refuses this +package's own output. + +`normalizeListViewSchema` folds objectui's legacy toolbar flags onto the +`userActions` keys `group` / `hideFields` / `rowColor`, which the protocol +adopted in 17.3.0 (objectui#5435). `@objectstack/spec` was declared in +`dependencies` — consumer-facing — as `^17.2.0`, so any resolution landing on +17.0.0 / 17.1.0 / 17.2.0 satisfied the declared range and got a normalizer whose +output is refused **by name** at the view save gate. + +Measured against the published artifacts rather than the workspace copy: each +published 17.x was installed into its own isolated consumer project and the +fold's real output parsed against that install's own `./ui` entry. + + 17.0.0 / 17.1.0 / 17.2.0 REFUSED refused-keys=[group, hideFields, rowColor] + 17.3.0 / 17.4.0 ACCEPTED + +An undeclared firing-control key was refused by all five versions, so the +contrast is about those three keys and not about the harness. 17.3.0 is the +FIRST published version that accepts — verified across the entire published 17.x +stable line (17.0.0, 17.1.0, 17.2.0, 17.3.0, 17.4.0), not by taking the first +version that happened to work. + +A second, independent key family lands on the same floor: `ListViewSchema` gained +`pageName` in 17.3.0, and the `page` view fixture this package already pins is +refused by 17.0.0 / 17.1.0 / 17.2.0 (`refused-keys=[pageName]` plus an +`invalid_value` on `type`) and accepted from 17.3.0. + +No runtime behaviour changes: on the 17.4.0 every install resolves today, this is +the same normalizer. What changes is the declared contract — the range no longer +claims to work against specs that refuse its output. + +`scripts/check-spec-range-floors.mjs` was green before and after, and would be +green at any floor here: its criterion is symbol PRESENCE, and +`UserActionsConfigSchema` is exported by every version above. The floor is held +instead by `normalize-list-view.declaredSpecFloor-9012.test.ts`, which carries +firing controls proving its comparator can redden. diff --git a/packages/core/package.json b/packages/core/package.json index fb341c8ce4..54cca7cc9c 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -33,7 +33,7 @@ "dependencies": { "@object-ui/types": "workspace:*", "@objectstack/formula": "^17.0.0", - "@objectstack/spec": "^17.2.0" + "@objectstack/spec": "^17.3.0" }, "devDependencies": { "@object-ui/test-support": "workspace:*", diff --git a/packages/core/src/utils/__tests__/normalize-list-view.declaredSpecFloor-9012.test.ts b/packages/core/src/utils/__tests__/normalize-list-view.declaredSpecFloor-9012.test.ts new file mode 100644 index 0000000000..c01cb464aa --- /dev/null +++ b/packages/core/src/utils/__tests__/normalize-list-view.declaredSpecFloor-9012.test.ts @@ -0,0 +1,185 @@ +/** + * 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#9012 — `@object-ui/core`'s DECLARED `@objectstack/spec` floor must + * admit only specs that accept what this package's own fold produces. + * + * The sibling pin `normalize-list-view.foldOutputAuthorable-5435.test.ts` proves + * the fold's output is authorable against the RESOLVED spec. That is a fact + * about the lockfile, not about what a consumer installs: `dependencies` + * declared `^17.2.0`, and a consumer resolution landing on 17.2.x — a sibling + * pinning it exactly, an `overrides` entry, an offline mirror a minor behind — + * satisfied that range. This file pins the other half, the DECLARED floor. + * + * ## The measurement (objectui#9012, re-derived against published artifacts) + * + * Each published 17.x was installed into its own isolated consumer project + * (`npm install @objectstack/spec@VERSION`, no workspace resolution anywhere) + * and the fold's real output parsed against that install's own `./ui` entry: + * + * 17.0.0 UserActionsConfigSchema declares 8 keys REFUSED group/hideFields/rowColor + * 17.1.0 UserActionsConfigSchema declares 8 keys REFUSED group/hideFields/rowColor + * 17.2.0 UserActionsConfigSchema declares 8 keys REFUSED group/hideFields/rowColor + * 17.3.0 UserActionsConfigSchema declares 11 keys ACCEPTED + * 17.4.0 UserActionsConfigSchema declares 11 keys ACCEPTED + * + * A firing control (`zzUndeclared`) was refused by ALL FIVE, so "ACCEPTED" is + * not the reading of a schema that accepts everything, and "REFUSED" is not the + * reading of one that refuses everything. + * + * ⭐ SECOND, INDEPENDENT ROUTE to the same floor. `ListViewSchema` gained + * `pageName` in the same release, and `type: 'page'` with it. The fixture + * `normalize-list-view.pageResidual-8429.test.ts` asserts the spec ACCEPTS is + * refused by 17.0.0 / 17.1.0 / 17.2.0 (`refused-keys=['pageName']` plus an + * `invalid_value` on `type`) and accepted from 17.3.0. So the floor below is + * not propped up by one key family: two independent ones land on it. + * + * ⇒ 17.3.0 is the FIRST published version that accepts, verified across the + * whole published 17.x stable line rather than by taking the first version that + * happened to work. The line is exactly 17.0.0, 17.1.0, 17.2.0, 17.3.0, 17.4.0 + * — there is no unexamined gap between the last refusing and first accepting + * release. + * + * ## Why this is a test and not a gate extension + * + * `scripts/check-spec-range-floors.mjs` judges SYMBOL PRESENCE in the floor's + * published artifact, and `UserActionsConfigSchema` / `ListViewSchema` are + * exported by every version above — so that gate is green at `^17.2.0` and + * would stay green at any floor. The requirement here is BEHAVIOURAL (which + * KEYS the symbol declares), deliberately outside that gate's criterion; its + * header argues at length why a behaviour-based criterion there would over-name + * symbols. Teaching the gate this class is objectui#9012's fourth question and + * is filed separately rather than smuggled in here. + */ + +import { readFileSync } from 'node:fs'; + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { ListViewSchema as SpecListViewSchema, UserActionsConfigSchema } from '@objectstack/spec/ui'; + +import { normalizeListViewSchema } from '../normalize-list-view.js'; + +/** + * The first published `@objectstack/spec` that accepts this package's fold + * output. Measured, not assumed — see the docblock above. + */ +const REQUIRED_FLOOR = '17.3.0'; + +/** `[major, minor, patch]` of a plain `X.Y.Z`, or `null`. */ +const parseVersion = (v: string): [number, number, number] | null => { + const m = /^(\d+)\.(\d+)\.(\d+)$/.exec(v.trim()); + return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null; +}; + +/** The minimum version a `^X.Y.Z` range admits. */ +const floorOf = (range: string): string => range.trim().replace(/^[\^~>=\s]+/, ''); + +/** Whether every version `range` admits is at or above `required`. */ +const admitsOnlyAtOrAbove = (range: string, required: string): boolean => { + const low = parseVersion(floorOf(range)); + const need = parseVersion(required); + if (!low || !need) return false; + for (let i = 0; i < 3; i += 1) { + if (low[i] !== need[i]) return low[i] > need[i]; + } + return true; +}; + +const manifest = JSON.parse( + readFileSync(new URL('../../../package.json', import.meta.url), 'utf8'), +) as { name: string; dependencies?: Record }; + +/** The widest `userActions` block the fold can produce — all seven flags. */ +const LEGACY_VIEW = { + name: 'my_view', + label: 'My View', + type: 'grid', + columns: [{ field: 'name' }], + showSearch: true, + showSort: true, + showFilters: true, + showDensity: true, + showGroup: false, + showHideFields: true, + showColor: true, +} as const; + +/** The three keys adopted in 17.3.0 that made the old floor wrong. */ +const ADOPTED_KEYS = ['group', 'hideFields', 'rowColor'] as const; + +describe('the DECLARED spec floor admits only specs that accept the fold (objectui#9012)', () => { + beforeEach(() => { + // #8372's undrawable-kind warning is developer-facing noise here. + vi.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('the comparator, proven able to redden before it is trusted', () => { + it('refuses the floor this card replaced, and every floor below it', () => { + // ⭐ FIRING CONTROLS. Without these, `admitsOnlyAtOrAbove` returning true + // for the live manifest is equally consistent with a predicate that + // returns true for everything. + expect(admitsOnlyAtOrAbove('^17.2.0', REQUIRED_FLOOR)).toBe(false); + expect(admitsOnlyAtOrAbove('^17.1.0', REQUIRED_FLOOR)).toBe(false); + expect(admitsOnlyAtOrAbove('^17.0.0', REQUIRED_FLOOR)).toBe(false); + expect(admitsOnlyAtOrAbove('^16.99.99', REQUIRED_FLOOR)).toBe(false); + + // ... and accepts the floor that measured clean, and later ones. + expect(admitsOnlyAtOrAbove('^17.3.0', REQUIRED_FLOOR)).toBe(true); + expect(admitsOnlyAtOrAbove('^17.4.0', REQUIRED_FLOOR)).toBe(true); + + // A range it cannot reason about must not read as satisfied. + expect(admitsOnlyAtOrAbove('*', REQUIRED_FLOOR)).toBe(false); + expect(admitsOnlyAtOrAbove('workspace:*', REQUIRED_FLOOR)).toBe(false); + }); + }); + + describe('⭐ the declared range', () => { + it('declares the spec as a consumer-facing dependency at or above the measured floor', () => { + const declared = manifest.dependencies?.['@objectstack/spec']; + + // `dependencies`, not `devDependencies`: a dev range floors nothing for + // anybody, which is why this pin reads that field by name. + expect(declared, '@objectstack/spec must stay a runtime dependency').toBeTruthy(); + expect( + admitsOnlyAtOrAbove(declared as string, REQUIRED_FLOOR), + `declared "${declared}" admits a spec below ${REQUIRED_FLOOR}, which refuses this package's own fold output`, + ).toBe(true); + }); + }); + + describe('the requirement stays anchored to the spec, not to a number in this file', () => { + it('still emits exactly the keys the measurement was taken on', () => { + const out = normalizeListViewSchema(LEGACY_VIEW) as Record; + const ua = out.userActions as Record; + + // If the fold stops emitting these, REQUIRED_FLOOR is answering a + // question nobody is asking any more and this pin must be revisited. + for (const key of ADOPTED_KEYS) expect(Object.keys(ua)).toContain(key); + }); + + it('requires a spec that declares every key the floor was chosen for', () => { + const specKeys = Object.keys(UserActionsConfigSchema.shape); + for (const key of ADOPTED_KEYS) expect(specKeys).toContain(key); + + // The second, independent route to the same floor: `pageName` on a whole + // ListView document (objectui#8429's fixture, refused before 17.3.0). + const page = SpecListViewSchema.safeParse({ + name: 'account_page', + type: 'page', + pageName: 'crm_welcome', + columns: [], + }); + expect(page.success, '`pageName` on a `page` view must be authorable').toBe(true); + }); + }); +}); diff --git a/packages/core/src/utils/__tests__/normalize-list-view.foldOutputAuthorable-5435.test.ts b/packages/core/src/utils/__tests__/normalize-list-view.foldOutputAuthorable-5435.test.ts index 0f65d349db..98cb370b9a 100644 --- a/packages/core/src/utils/__tests__/normalize-list-view.foldOutputAuthorable-5435.test.ts +++ b/packages/core/src/utils/__tests__/normalize-list-view.foldOutputAuthorable-5435.test.ts @@ -48,9 +48,15 @@ * empties, this pin reddens and gets updated deliberately. * * ⚠️ Requires `@objectstack/spec >= 17.3.0` (the release that adopted the - * three). `@object-ui/core` still declares `^17.2.0`; if this file ever reddens - * on a resolved 17.2.x, the reading is that the declared floor is too low, not - * that the fold regressed. + * three). That is now also what `@object-ui/core` DECLARES: objectui#9012 + * measured the refusal against every published 17.x and raised the + * `dependencies` floor from `^17.2.0` to `^17.3.0`, so a resolution this + * package admits can no longer land on a spec that refuses the fold. The + * standing instruction that used to live here — "if this file ever reddens on a + * resolved 17.2.x, the reading is that the declared floor is too low, not that + * the fold regressed" — has been DISCHARGED, not deleted: it was read exactly + * that way. The declared floor itself is pinned by + * `normalize-list-view.declaredSpecFloor-9012.test.ts`. */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d06147cc97..1f9e1bfcc6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1184,7 +1184,7 @@ importers: specifier: ^17.0.0 version: 17.4.0(ai@7.0.65(zod@4.4.3)) '@objectstack/spec': - specifier: ^17.2.0 + specifier: ^17.3.0 version: 17.4.0(ai@7.0.65(zod@4.4.3)) devDependencies: '@object-ui/test-support':