diff --git a/.changeset/10071-number-scale-ceiling.md b/.changeset/10071-number-scale-ceiling.md index 69e8641097..15ce082308 100644 --- a/.changeset/10071-number-scale-ceiling.md +++ b/.changeset/10071-number-scale-ceiling.md @@ -13,3 +13,6 @@ edit. Both now take the ruling the percent faces already took (objectui#9808): the width is clamped to 100 and a one-time `console.warn` names the declared width, the rendered width and the card. In-range, negative and non-integer widths are unchanged. + +⚠️ **Dated note, 2026-09-29 — the clamp above was retired in this same release — objectui#11073.** +Later in this release this repository began resolving `@objectstack/spec` 17.5.0, which refuses a field `scale` above 100 at the declaration (`too_big` at `scale`, a computed number field included). The objectui#9808 ruling this change followed retires at exactly that point, so the clamp and its one-time warning are gone from `NumberCellRenderer` and from a grid's computed column: the declared width is passed through again, and a width above 100 is no longer something an author can declare. diff --git a/.changeset/10355-grid-currency-minor-unit.md b/.changeset/10355-grid-currency-minor-unit.md index 9affe1422b..e09905ba6e 100644 --- a/.changeset/10355-grid-currency-minor-unit.md +++ b/.changeset/10355-grid-currency-minor-unit.md @@ -43,3 +43,6 @@ line with the ruling on objectstack-ai/objectstack#19910 that a currency's decimal places are the currency's. A USD tenant's grid used to read `¥1,234.57` and now reads `$1,234.57`. Columns with an authored `scale`, and `number` columns, round as before. + +⚠️ **Dated note, 2026-09-29 — one sentence above was made false in this same release — objectui#11073.** +The sentence saying an authored currency `scale` above 100 "is clamped and reported on the display too" no longer holds. Later in this release this repository began resolving `@objectstack/spec` 17.5.0, which refuses a `scale` above 100 at the declaration, and the objectui#10071 clamp that sentence relied on was retired with it. The display now uses the authored width as it is, as the stored value does. Everything else above is unchanged. diff --git a/.changeset/11073-objectstack-17-5-bump.md b/.changeset/11073-objectstack-17-5-bump.md new file mode 100644 index 0000000000..a4fa2af32a --- /dev/null +++ b/.changeset/11073-objectstack-17-5-bump.md @@ -0,0 +1,24 @@ +--- +'@object-ui/types': minor +'@object-ui/core': minor +'@object-ui/data-objectstack': patch +'@object-ui/fields': patch +'@object-ui/components': patch +'@object-ui/plugin-list': patch +'@object-ui/app-shell': minor +'@object-ui/console': patch +--- + +objectui now resolves `@objectstack/*` 17.5.0 and `zod` 4.6.5, and follows every contract move that release makes (objectui#11073). `@objectstack/spec` 17.5.0 and `@objectstack/core` 17.5.0 require `zod ^4.6.1`, so objectui's own `zod` resolves to the same 4.6.5: two zod minors do not type-check against each other. + +⚠️ Breaking in places, marked `minor` under this repo's version-alignment rule (a `major` in the fixed group would move all of it off the `@objectstack` major). Every narrowing below is the spec's own, and already reaches any consumer that resolves `@objectstack/spec ^17.5`. + +- `@object-ui/types`: declares `@objectstack/spec ^17.5.0` and `zod ^4.6.1`. A named list view's `pageName` and `tabs` are retired, as the spec retired them: typed `never` and refused by name at parse. `DashboardWidgetSchema` attaches the spec's two new checks (`checkDashboardWidgetStageOrder`, `checkDashboardWidgetMetricMeasureArity`), so a widget the spec refuses is refused here too. Where a strict object is an arm of a plain union, its unknown-key refusal is terminal again (the spec's own mechanism, needed under zod 4.6): the union answers with every arm instead of the one that failed only on unknown keys. That changes the error shape, never the verdict (measured on every objectui union). Two by-name named-view checks that can no longer run are retired; the spec's refusal answers those documents. +- `@object-ui/core`: declares `@objectstack/spec ^17.5.0`. `page` leaves the list-view kinds the normalizer knows, as the spec removed `type: 'page'`. `isRefusedTextComparand` and `textComparandRefusalReason` are re-exported from `@objectstack/spec/data`, whose copies are byte-identical. `SPEC_ACTION_KEYS` lists `execution`, which `ActionSchema` now declares. +- `@object-ui/data-objectstack`: declares `@objectstack/spec ^17.5.0`, up from `^17.4.0`. Its bundle carries `@object-ui/core`'s filter converter, which now takes `isRefusedTextComparand` and `textComparandRefusalReason` from `@objectstack/spec/data`. `@objectstack/spec` 17.4.0 exports neither, so the old range admitted a spec under which the shipped module names exports that are not there (objectui#5793's floor gate). Nothing moves at runtime in this repository, because the lockfile already resolves 17.5.0. `patch`, as the floor raise of objectui#10864 was. +- `@object-ui/fields`: the percent and number `scale` clamp is gone, at the sunset its own test named. The spec now refuses a `scale` above 100 at the declaration, so the width a formatter cannot render no longer arrives, and every face passes the declared width through as it did before the clamp. +- `@object-ui/components`: two module-local prop interfaces of the action renderers are renamed (`ActionButtonRendererProps`, `ActionIconRendererProps`), because the spec now exports the old names for its authored props. Neither is reachable through the package's exports map; only the shipped per-file declarations change. +- `@object-ui/plugin-list`: the README's view-type example no longer lists the retired `page` kind. +- `@object-ui/app-shell`: the flow designer follows 17.5.0 (maintainer ruling on objectui#11088, decision 2). A new `wait` node is seeded `waitEventConfig: { eventType: 'timer', timerDuration: 'PT1H' }`, because the spec now refuses a timer wait with no duration; the inspector shows the Duration at once and it can be changed. The decision form offers the spec's new `mode` (exclusive or inclusive), with no default declared because the spec applies none. The screen form's `mode` states `create` again, because the spec now applies that default. `minor` because the decision form gains a control. +- `@object-ui/app-shell`: declares `@objectstack/spec ^17.5.0`, up from `^17.4.0`, and `zod ^4.6.1`, up from `^4.4.3`. The designer now states 17.5.0 behaviour. The screen form's declared `create` default equals a default the spec applies only since 17.5.0, and objectui#9109 requires the two to be equal, so a range that admitted 17.4.0 claimed more than the designer holds to. The resolved versions do not move. +- `@object-ui/console`: the bundle inlines the 17.5.0 packages, so its client-side validation answers as the published 17.5.0 contract does. The flow preview sample's script nodes use the 17.5.0 script contract. The eager-closure budget is re-baselined for the release's growth, under the maintainer ruling on objectui#11088 (decision 1). diff --git a/.changeset/7715-mirrors-carry-spec-object-checks.md b/.changeset/7715-mirrors-carry-spec-object-checks.md index 8226a93608..f288a5a539 100644 --- a/.changeset/7715-mirrors-carry-spec-object-checks.md +++ b/.changeset/7715-mirrors-carry-spec-object-checks.md @@ -14,3 +14,6 @@ A mirror built from the spec's `.shape` (`specFieldsExcept(...)`) takes the spec The spec's other `ListViewSchema` check, `checkListViewPageMount`, is **not** attached: it reads `type`, which on a `list-view` node is the component discriminator rather than the spec's view kind (that rides as `viewType`), so attaching it would refuse every `pageName`, a valid page mount included. The navigation-area, app, dashboard-widget and dashboard mirrors attach nothing because their spec objects carry no object-level check at the resolved spec release. `GlobalFilterSchema` already ran the spec's date-default check through its delegating re-parse and is unchanged. `packages/types/src/__tests__/spec-object-refinements-7715.test.ts` accounts for every object-level check each of the six spec objects carries, so a check the spec adds in a later release fails there by site name instead of being dropped. + +⚠️ **Dated note, 2026-09-29 — `@objectstack/spec` 17.5.0 moved two sentences above — objectui#11073.** +Later in this same release this repository began resolving `@objectstack/spec` 17.5.0, which retired `type: 'page'` and `pageName` on list views (ADR-0049 enforce-or-remove) and no longer exports `checkListViewPageMount`. So the paragraph on that check describes a spec that is no longer installed: there is no such check to attach, and a `pageName` is refused by the spec itself as a retired key, not a "valid page mount". The same release adds two object-level checks to the spec's `DashboardWidgetSchema`, `checkDashboardWidgetStageOrder` and `checkDashboardWidgetMetricMeasureArity`, and objectui's `DashboardWidgetSchema` now attaches both, so "the dashboard-widget mirror attaches nothing" no longer holds either: a non-funnel widget carrying `options.stageOrder`, and a metric-family widget declaring more than one measure in `values`, are refused with the spec's own message. diff --git a/.changeset/8201-quickadd-ruled-carve-out.md b/.changeset/8201-quickadd-ruled-carve-out.md index d1d9147a99..57d059ae8d 100644 --- a/.changeset/8201-quickadd-ruled-carve-out.md +++ b/.changeset/8201-quickadd-ruled-carve-out.md @@ -11,3 +11,6 @@ bookkeeping (the `objectui#8176` backlog ceiling reaches 0, with the ruling reco data rather than prose) and the docblock in `packages/plugin-kanban/src/index.tsx`, which said the disposition was still with the maintainer. It is not — it was ruled on 2026-09-07, and objectui#8285 owns the fix. + +⚠️ **Dated note, 2026-09-29 — the carve-out recorded above was harvested in this same release — objectui#11073.** +Later in this release this repository began resolving `@objectstack/spec` 17.5.0, which carries objectui#8285's ruled retirement: `object-kanban.quickAdd` is now a spec tombstone, refused by name. The exit the ruling named therefore landed. The carve-out's exemption entry was deleted, the ruled carve-out list is empty, and the key is pinned among the gate's tombstones instead. It is no longer a RULED CARVE-OUT awaiting a fix, and `OBJECT_KANBAN_INPUTS` still does not declare it. diff --git a/.changeset/8285-kanban-quickadd-html-diagnostic.md b/.changeset/8285-kanban-quickadd-html-diagnostic.md index 43676bd4c5..cb4044dd66 100644 --- a/.changeset/8285-kanban-quickadd-html-diagnostic.md +++ b/.changeset/8285-kanban-quickadd-html-diagnostic.md @@ -23,3 +23,6 @@ Interim by ruling (objectui#8285, decision batch #91): the contract-side refusal retiring `object-kanban.quickAdd` from the spec's `ComponentPropsMap`, and this diagnostic is removed by the change that lands it. `kanban-ui` keeps the `quickAdd` / `onQuickAdd` pair untouched. + +⚠️ **Dated note, 2026-09-29 — the contract moved in this same release — objectui#11073.** +The reason given above, that "has no prop quickAdd" was false because `@objectstack/spec` publishes the key, no longer holds. Later in this release this repository began resolving `@objectstack/spec` 17.5.0, which carries the retirement this diagnostic was declared interim for: `object-kanban.quickAdd` is now a spec tombstone, refused by name at the authoring door. The diagnostic's own removal belongs to the change that ruled it and is not made here. Until then, the key it names is one the contract itself refuses. diff --git a/.changeset/9808-percent-scale-out-of-range.md b/.changeset/9808-percent-scale-out-of-range.md index 48b120c6ea..c35793d8d2 100644 --- a/.changeset/9808-percent-scale-out-of-range.md +++ b/.changeset/9808-percent-scale-out-of-range.md @@ -50,3 +50,6 @@ nothing here, because the bound is merged and unreleased at the installed renderer-side fallback #0.1 bans, and the module is to be deleted rather than kept beside the enforced contract. The percent pin file asks the installed `FieldSchema` that question on every run and fails with those instructions. + +⚠️ **Dated note, 2026-09-29 — the SUNSET above fired in this same release — objectui#11073.** +Later in this release this repository began resolving `@objectstack/spec` 17.5.0, which bounds `scale` at 100: a percent field declaring `scale: 101` is now refused at the declaration (`too_big` at `scale`). That is the condition the SUNSET paragraph names, and it fired as written. The clamp, its console warning and the module that held them are deleted, and `formatPercent` and both percent faces pass the declared width through again. So what this release ships is the spec's refusal at the declaration, not the clamp-and-report described above, and `formatPercent` no longer returns a string for a width the engine refuses. diff --git a/.changeset/named-list-view-protocol-members-8980.md b/.changeset/named-list-view-protocol-members-8980.md index 49d57a6520..a787d17436 100644 --- a/.changeset/named-list-view-protocol-members-8980.md +++ b/.changeset/named-list-view-protocol-members-8980.md @@ -53,3 +53,6 @@ rung whose source could not be authored before this change — so on existing documents the renderer produces the same nodes it produced before, proven by an absence-control case beside each fix. The 19 legacy spellings `NamedListView` declares beyond the protocol are objectui#7924's remedy and are untouched. + +⚠️ **Dated note, 2026-09-29 — two members named above were retired in this same release — objectui#11073.** +Later in this release this repository began resolving `@objectstack/spec` 17.5.0, which retired `pageName` and the list view's `tabs` (and the `type: 'page'` branch `pageName` configured) under ADR-0049 enforce-or-remove. Following the same standing principle this change cites, `NamedListView` no longer declares either as a member. Both are tombstones, typed `never` and refused by name at parse. So the table of seventeen above is fifteen live members plus those two, and "declared and NOT read" no longer describes `tabs` or `pageName`: neither is accepted. diff --git a/.changeset/shaggy-doors-repeat.md b/.changeset/shaggy-doors-repeat.md index 0604853fcb..af59a5a72e 100644 --- a/.changeset/shaggy-doors-repeat.md +++ b/.changeset/shaggy-doors-repeat.md @@ -33,3 +33,6 @@ which the renderer has drawn for releases. ⚠️ Consumers holding an exhaustive `switch` or a total `Record` will now fail to compile until they account for `page`. That failure is the point of the change — it is the guarantee that was silently lost. + +⚠️ **Dated note, 2026-09-29 — `page` left the vocabulary again in this same release — objectui#11073.** +Later in this release this repository began resolving `@objectstack/spec` 17.5.0, which removed `type: 'page'` from the list-view vocabulary (ADR-0049 enforce-or-remove). Because `ViewType` and `ViewTypeSchema` are derived from the spec, as this change made them, they no longer carry `page`. A `type: 'page'` view is refused by name at parse rather than being "spec-valid", and the one-time "cannot draw" warning has no kind left to explain. The rest of this change stands: the vocabulary still follows the spec, now in the narrowing direction. diff --git a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts index 92595cc200..e329400152 100644 --- a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts +++ b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts @@ -587,11 +587,36 @@ const LAZY_REGISTERED_BLOCKS = [ 'object-kanban', ]; +/** + * The six blocks `@objectstack/spec` 17.5.0 newly carries that this file JUDGES + * (objectui#11073): the map went from 45 entries to 55. The other four new + * entries — `object-gantt`, `object-map`, `object-timeline`, `object-tree` — are + * registered lazily and not loaded here, and are booked in + * `UNJUDGED_SPEC_BLOCKS` to objectui#11168 instead (see `OWED_TO` below). + * + * Same shape as `GA_ONLY_BLOCKS` / `MINOR_17_1_BLOCKS`: this repo registered all + * six with `inputs` before the spec described them, so what moved at the bump + * is the SPEC's side. They enter `covered` the moment the installed spec + * carries them. + */ +const MINOR_17_5_BLOCKS = [ + 'action:button', + 'action:group', + 'action:icon', + 'action:menu', + 'element:definition-list', + 'element:repeater', +]; + +/** Does the installed `@objectstack/spec` carry the 17.5.0 set? `every`, for the reason `specCarriesGaBlocks` gives. */ +const specCarries175Blocks = MINOR_17_5_BLOCKS.every((type) => type in ComponentPropsMap); + const EXPECTED_COVERED = [ ...PINNED_EXPECTED_COVERED, ...LAZY_REGISTERED_BLOCKS, ...(specCarriesGaBlocks ? GA_ONLY_BLOCKS : []), ...(specCarries171Blocks ? MINOR_17_1_BLOCKS : []), + ...(specCarries175Blocks ? MINOR_17_5_BLOCKS : []), ].sort(); /** @@ -648,6 +673,97 @@ function isRetiredUpstream(type: string): boolean { return specTombstonedKeys(type).length > 0 && specTopLevelKeys(type).length === 0; } +/** + * ⚠️ WHY FIVE OF THIS FILE'S LEDGERS ARE NOT EMPTY: objectui#11111 decision 3 = B. + * + * The maintainer ruled on objectui#11111 (「11111 同意」, record 5902351047) + * that the 17.5.0 bump re-pins this gate's population and DECLARES NOTHING + * (Clause ② no). Every difference 17.5.0 newly surfaces is booked here, one + * entry per difference, each naming the card that owns it and an expiry: + * + * - objectui#11168 — the ten newly carried blocks (the four lazily + * registered ones unjudged, the six judged ones' unpublished keys, their + * refused input, refused arms and owed member pins), and + * `object-form.layout`'s refused arms; + * - objectui#8652 — `navigation` on `object-kanban` and `object-calendar`; + * - objectui#8649 — `enforceFieldSecurity`, `redactFields` and + * `requiredPermissions` on the three `record:*` blocks. + * + * Each owner card decides every key by its own measurement: declare what the + * renderer honours, refuse or retire what it does not. ⛔ Batch-declaring an + * input a renderer does not honour is the failure decision 3 = B exists to + * avoid, so an entry here is never resolved that way. + * + * The ledgers are CAPPED at exactly the entries listed — never unbounded — + * by `the objectui#11111 ledger is capped at exactly the entries it lists`, + * which also routes every entry to its owner card and requires its expiry. A + * new difference beyond the listed entries is still red: every direction below + * subtracts exact ids, never a block or a prefix. Each owner card's landing + * strikes its entries and lowers the matching cap in the same change; the last + * of the three to land restores the empty ledgers and the zero caps this file + * held before the bump. + * + * EXPIRY. None of these ledgers has a date field, and this repo's SUNSET + * convention is a condition re-derived from the installed spec, not a date, so + * nothing here can enforce a calendar deadline. The date is stated in every + * entry's reason and asserted PRESENT; it is read by the owner cards, not by a + * clock. + */ +const OBJECTUI_11111_EXPIRES = '2026-10-30'; + +/** The three owner cards the ruling names, and nothing else may own an entry. */ +const OBJECTUI_11111_OWNERS = ['objectui#11168', 'objectui#8652', 'objectui#8649'] as const; +type Objectui11111Owner = (typeof OBJECTUI_11111_OWNERS)[number]; + +/** The security triple 17.5.0 declares on exactly the three record blocks (objectui#8649's). */ +const FIELD_SECURITY_TRIPLE = ['enforceFieldSecurity', 'redactFields', 'requiredPermissions']; + +/** Every entry the ruling books starts with this, so the caps can count them. */ +const OWED_PREFIX = 'OWED TO '; + +/** One ruled entry's reason: owner first, then what is owed, then the ruling and the expiry. */ +const OWED_TO = (owner: Objectui11111Owner, what: string): string => + `${OWED_PREFIX}${owner}. ${what} Booked by objectui#11111 decision 3 = B (record 5902351047): ` + + `the 17.5.0 bump re-pins and declares nothing; ${owner} decides it by its own measurement. ` + + `Expires ${OBJECTUI_11111_EXPIRES}, or when ${owner} lands, whichever is first.`; + +/** `BLOCK.KEY` entries for every listed key of one block, all with the same owner and reason. */ +const owedEntries = ( + type: string, + keys: readonly string[], + owner: Objectui11111Owner, + what: string, +): Record => + Object.fromEntries(keys.map((key) => [`${type}.${key}`, OWED_TO(owner, what)])); + +/** Which owner the ruling routes an entry id to — asserted against every entry's reason. */ +function objectui11111OwnerOf(id: string): Objectui11111Owner { + const key = id.slice(id.indexOf('.') + 1).split(':')[0]; + if (FIELD_SECURITY_TRIPLE.includes(key)) return 'objectui#8649'; + if (key === 'navigation') return 'objectui#8652'; + return 'objectui#11168'; +} + +/** The ruled entries of one ledger. */ +const owedIdsOf = (ledger: Record): string[] => + Object.entries(ledger) + .filter(([, reason]) => reason.startsWith(OWED_PREFIX)) + .map(([id]) => id) + .sort(); + +/** + * THE CAPS, one per ledger, each EQUAL to the entries listed below. Lower one in + * the same change as the owner card that strikes its entries; the last owner + * card to land sets all five to 0. + */ +const OBJECTUI_11111_LEDGER_CAPS = { + unjudgedBlocks: 4, // objectui#11168: object-gantt, object-map, object-timeline, object-tree + offSpecInputs: 1, // objectui#11168: action:group.name + unpublishedKeys: 57, // objectui#11168: 46 (22 + 20 + 2 + 2); objectui#8652: 2; objectui#8649: 9 + refusedArms: 5, // objectui#11168: action:group ×2, action:menu, element:definition-list, object-form + memberPins: 6, // objectui#11168: action:group, action:menu, element:definition-list, element:repeater ×3 +} as const; + /** * The third and last place a `ComponentPropsMap` entry may land: blocks this * gate CANNOT judge, each with the reason it cannot (objectui#8176). @@ -688,6 +804,13 @@ function isRetiredUpstream(type: string): boolean { * from `ComponentRegistry.getKnownTypes()` (lazy stubs INCLUDED, so the * assertion cannot be satisfied by the very stub-blindness objectui#8176 is * about), so registering one of them forces this entry out. + * - OWED (objectui#11111 decision 3 = B, record 5902351047) — a block that IS + * registered, lazily, and that this file does not load, booked to its + * owner card with an expiry (see `OWED_TO`). Asserted as still registered + * lazily and still unloaded here, so loading it — the owner card's work — + * forces the entry out; and CAPPED at the four listed, so a fifth lazily + * registered block is still red in `no spec-carried block is registered + * but unloaded`. The last objectui#11111 owner card to land empties it. * * The first class is EXCLUSIVE, and that is what keeps a hand-written reason * from outliving a retirement (objectui#10033): an entry under either of the @@ -730,6 +853,28 @@ const UNJUDGED_SPEC_BLOCKS: Record = { 'EMPTY SPEC SHAPE. New in `@objectstack/spec` 17.3.0, which declares it with no top-level key at all. Registered propless by `@object-ui/app-shell` (`src/console/marketplace/InstalledListWidget.tsx`), outside this file\'s import graph; the empty shape is the load-bearing half either way. objectui#7122, ledger objectui#8176.', 'mcp:connect-agent': 'EMPTY SPEC SHAPE. New in `@objectstack/spec` 17.3.0, which declares it with no top-level key at all. Registered propless by `@object-ui/app-shell` (`src/console/connect/ConnectAgentWidget.tsx`), outside this file\'s import graph; the empty shape is the load-bearing half either way. objectui#7122, ledger objectui#8176.', + // The fourth class, and the only one that is not mechanically "unjudgeable": + // OWED. These four are new in `@objectstack/spec` 17.5.0, registered with + // `registerLazy` by the console (`register-plugins.ts`) and not loaded by this + // file, so their declarations are real and unread. The remedy the + // mechanism test names — an eager import — would judge them, and judging them + // is objectui#11168's work, not the bump's (objectui#11111 decision 3 = B). + 'object-gantt': OWED_TO( + 'objectui#11168', + 'REGISTERED LAZILY, NOT LOADED HERE: `object-gantt` is new in `@objectstack/spec` 17.5.0, and loading `@object-ui/plugin-gantt` in this file is what judges it.', + ), + 'object-map': OWED_TO( + 'objectui#11168', + 'REGISTERED LAZILY, NOT LOADED HERE: `object-map` is new in `@objectstack/spec` 17.5.0, and loading `@object-ui/plugin-map` in this file is what judges it.', + ), + 'object-timeline': OWED_TO( + 'objectui#11168', + 'REGISTERED LAZILY, NOT LOADED HERE: `object-timeline` is new in `@objectstack/spec` 17.5.0, and loading `@object-ui/plugin-timeline` in this file is what judges it.', + ), + 'object-tree': OWED_TO( + 'objectui#11168', + 'REGISTERED LAZILY, NOT LOADED HERE: `object-tree` is new in `@objectstack/spec` 17.5.0, and loading `@object-ui/plugin-tree` in this file is what judges it.', + ), 'user:profile': 'RETIRED UPSTREAM. `@objectstack/spec` 17.3.0 retired it by name (objectstack#14159, landed by objectstack#15112): out of `PageComponentType`, named in `RETIRED_PAGE_COMPONENT_TYPES`, and its row `retiredComponentProps` — a `never` that refuses every props bag, `{}` included — so there is no authoring surface for either direction to judge. The same release\'s reconciliation here (objectui#7122) took it out of `PROTOCOL_COMPONENTS` in `@object-ui/components` `renderers/placeholders.tsx`, so not even the placeholder scaffold stands in for it. objectui#10033, ledger objectui#8176.', }; @@ -795,8 +940,19 @@ const UNJUDGED_SPEC_BLOCKS: Record = { * somewhere to be registered, and `exemptedFor` below reads it. Empty is a * state, not a deletion — every forward-direction assertion now runs with no * cover of any kind, which is the strongest reading this gate has ever had. + * + * ⚠️ ONE ENTRY SINCE THE 17.5.0 BUMP, and it is ruled, not owed-by-default: + * `action:group` entered `covered` with 17.5.0 and declares a `name` input its + * spec row does not accept. objectui#11111 decision 3 = B booked it to + * objectui#11168 (see `OWED_TO`), capped at this one entry; that card's landing + * empties the map again. */ -const OFF_SPEC_EXEMPTIONS: Record = {}; +const OFF_SPEC_EXEMPTIONS: Record = { + 'action:group.name': OWED_TO( + 'objectui#11168', + 'A DECLARED INPUT THE SPEC REFUSES: `action:group` publishes `name`, and `ComponentPropsMap[action:group]` does not accept it.', + ), +}; /** * Spec-declared top-level keys deliberately NOT published, each with the reason. @@ -1167,8 +1323,14 @@ const UNPUBLISHED_EXEMPTIONS: Record = { // member instead (`field`, `order`, an omitted `order` meaning ascending, // and a member with no usable `field` dropped rather than invented). That // is a SHARPER member claim than a pass-through, not a weaker one. - 'object-kanban.quickAdd': - 'RULED CARVE-OUT — PREMATURE, not "wrong" (PM ruling on objectui#8201, Q1 = A, 2026-09-07). The renderer does not honour this key; objectui#8285 owns the fix. Measured, and re-measured at @objectstack/spec 17.4.0 rather than inherited: `KanbanImpl` gates the Quick Add control on `quickAdd && onQuickAdd`, `onQuickAdd` is an objectui#6124 RUNTIME SLOT the zod twin refuses BY NAME, and nothing on the `ObjectKanban` path supplies one — so an authored `quickAdd: true` reaches the board and changes nothing. The key is still a live `z.boolean().optional()` on the installed pin, with a control key drawing `unrecognized_keys` on the same safeParse call, so this cover is describing something. WHAT THIS ENTRY DOES NOT SAY, deliberately: it does not claim the object-bound board cannot grow quick-add. That is a strictly stronger claim than anything measured, and the sibling `KanbanRenderer` host contradicts it by honouring the same `quickAdd` + `onQuickAdd` pair by identity. Declaring the input is the one resolution FORBIDDEN here — it would publish a key the renderer cannot honour, which is the failure mode this whole gate exists to catch. THE EXIT IS NOT A DECLARATION: objectui#8285 was ruled (director seat 2026-09-08, decision batch #91) to retire `object-kanban.quickAdd` from the spec ComponentPropsMap, so the day that lands the key leaves the accepted set, this entry goes dangling AND stale, and it is harvested exactly as the eight ADR-0087 tombstones above were. objectui#8176.', + // `object-kanban.quickAdd` — the RULED CARVE-OUT (PREMATURE; PM ruling on + // objectui#8201, Q1 = A) whose entry stood here — was HARVESTED at the + // `@objectstack/spec` 17.5.0 bump (objectui#11073). Its named exit landed: + // objectui#8285's ruled retirement took the key out of the spec's accepted + // set (typed `never`, refused by name), the entry went dangling AND stale in + // the same run, and deleting it was the only way back to green, exactly as + // the eight ADR-0087 tombstones before it. The tombstone pin below now names + // it, so it cannot come back as an exemption or as a published input. // ── record:reference_rail.entries — a nested collection, newly JUDGED ────── // (1 key) @@ -1210,6 +1372,80 @@ const UNPUBLISHED_EXEMPTIONS: Record = { // entire justification for this exemption — it always was sufficient alone. 'record:reference_rail.entries': 'An array of {objectName, relationshipField, title, limit, displayField} objects; `inputs` is a flat scalar carrier and cannot express it. Newly judged rather than newly missing — @objectstack/spec 17.1.0 added record:reference_rail to ComponentPropsMap, and the registration (plugin-detail/src/index.tsx:675) has always published only `hideEmpty` — the 17.1.0 pin, objectui#5328. The flat-carrier shape limit is the entire reason: the `icon` divergence that once also blocked an entries editor was settled by Option B (maintainer 2026-08-22, objectui#5494).', + + /* + * ⚠️ THE 17.5.0 BOOKINGS — objectui#11111 decision 3 = B (record 5902351047). + * + * 57 spec keys 17.5.0 newly declares on judged blocks and `inputs` does not + * publish. The bar at the top of this map says a key the renderer honours + * "gets declared"; the ruling decided WHO declares, and it is not the bump: + * each owner card measures each key against its renderer and declares what + * is honoured, refuses or retires the rest. Booked here, not declared, so + * the bump adds nothing to the public surface (Clause ② no). Capped at 57 by + * `the objectui#11111 ledger is capped at exactly the entries it lists`. + */ + ...owedEntries( + 'action:button', + [ + 'visible', 'disabled', 'params', 'description', 'openIn', 'endpoint', 'method', 'bodyExtra', + 'bodyShape', 'operation', 'patch', 'confirmText', 'successMessage', 'errorMessage', 'refreshAfter', + 'undoable', 'recordIdField', 'locations', 'toast', 'resultDialog', 'onSuccess', 'objectName', + ], + 'objectui#11168', + 'A SPEC KEY NOT PUBLISHED: `action:button` entered `covered` with 17.5.0 and its `inputs` omit this key its spec row declares.', + ), + ...owedEntries( + 'action:icon', + [ + 'visible', 'disabled', 'params', 'description', 'openIn', 'endpoint', 'method', 'bodyExtra', + 'bodyShape', 'operation', 'patch', 'confirmText', 'successMessage', 'errorMessage', 'refreshAfter', + 'locations', 'toast', 'resultDialog', 'onSuccess', 'objectName', + ], + 'objectui#11168', + 'A SPEC KEY NOT PUBLISHED: `action:icon` entered `covered` with 17.5.0 and its `inputs` omit this key its spec row declares.', + ), + ...owedEntries( + 'action:group', + ['location', 'visible'], + 'objectui#11168', + 'A SPEC KEY NOT PUBLISHED: `action:group` entered `covered` with 17.5.0 and its `inputs` omit this key its spec row declares.', + ), + ...owedEntries( + 'action:menu', + ['size', 'visible'], + 'objectui#11168', + 'A SPEC KEY NOT PUBLISHED: `action:menu` entered `covered` with 17.5.0 and its `inputs` omit this key its spec row declares.', + ), + ...owedEntries( + 'object-kanban', + ['navigation'], + 'objectui#8652', + 'A SPEC KEY NOT PUBLISHED: 17.5.0 declares `navigation` on this block; objectui#8652 met its unlock with that release and mirrors it as its ruled second step.', + ), + ...owedEntries( + 'object-calendar', + ['navigation'], + 'objectui#8652', + 'A SPEC KEY NOT PUBLISHED: 17.5.0 declares `navigation` on this block; objectui#8652 met its unlock with that release and mirrors it as its ruled second step.', + ), + ...owedEntries( + 'record:details', + FIELD_SECURITY_TRIPLE, + 'objectui#8649', + 'A SPEC KEY NOT PUBLISHED: 17.5.0 declares the field-security triple on exactly the three record blocks; objectui#8649 aligns the mirror and un-casts the reads.', + ), + ...owedEntries( + 'record:highlights', + FIELD_SECURITY_TRIPLE, + 'objectui#8649', + 'A SPEC KEY NOT PUBLISHED: 17.5.0 declares the field-security triple on exactly the three record blocks; objectui#8649 aligns the mirror and un-casts the reads.', + ), + ...owedEntries( + 'record:related_list', + FIELD_SECURITY_TRIPLE, + 'objectui#8649', + 'A SPEC KEY NOT PUBLISHED: 17.5.0 declares the field-security triple on exactly the three record blocks; objectui#8649 aligns the mirror and un-casts the reads.', + ), }; /** @@ -1276,8 +1512,14 @@ const UNPUBLISHED_EXEMPTIONS: Record = { * the exemption entry goes dangling AND stale, and deleting it is the only way * back to green — at which point assertion 2 above fails until this list is * emptied in the same change. The two halves cannot drift apart. + * + * ⭐ EMPTIED at the `@objectstack/spec` 17.5.0 bump (objectui#11073), by exactly + * that route: 17.5.0 carries objectui#8285's retirement, `quickAdd` is a + * tombstone, its exemption entry was deleted, and this list emptied in the same + * change. The three assertions above now read an empty list; the tombstone pin + * names the key instead. */ -const LAZY_BLOCK_RULED_CARVE_OUTS = ['object-kanban.quickAdd']; +const LAZY_BLOCK_RULED_CARVE_OUTS: string[] = []; /** * Exemption entries whose KEY the installed spec is allowed not to declare yet. @@ -1755,6 +1997,34 @@ const OFF_SPEC_ARM_EXEMPTIONS: Record = { * file's exemption discipline working end to end. objectui#6206 and #6207 can * be closed as resolved-upstream; reported on objectui#7122. */ + + /* + * ⚠️ FIVE 17.5.0 BOOKINGS — objectui#11111 decision 3 = B (record 5902351047). + * Arms the contract refuses on blocks 17.5.0 newly judges, plus + * `object-form.layout`, whose spec row narrowed to two members. Booked to + * objectui#11168, which narrows each declaration to what the spec accepts; + * capped at these five, and that card's landing empties the map again. + */ + 'action:group.actions:object': OWED_TO( + 'objectui#11168', + 'A REFUSED ARM: `action:group.actions` declares the `object` kind, and the contract refuses the KIND itself.', + ), + 'action:group.size:enum': OWED_TO( + 'objectui#11168', + 'A REFUSED ARM: `action:group.size` declares the member `md`, and the contract refuses it.', + ), + 'action:menu.actions:object': OWED_TO( + 'objectui#11168', + 'A REFUSED ARM: `action:menu.actions` declares the `object` kind, and the contract refuses the KIND itself.', + ), + 'element:definition-list.columns:enum': OWED_TO( + 'objectui#11168', + 'A REFUSED ARM: `element:definition-list.columns` declares the members `1` and `2`, and the contract refuses them.', + ), + 'object-form.layout:enum': OWED_TO( + 'objectui#11168', + 'A REFUSED ARM: `object-form.layout` declares the members `inline` and `grid`, which the 17.5.0 contract refuses.', + ), }; // ── the MEMBER direction (objectui#8067) ───────────────────────────────────── @@ -2888,6 +3158,35 @@ const MEMBER_PIN_EXEMPTIONS: Record = { // objectui#8071 slice 16 pinned both, so the block is now fully pinned, the // objectui#8176 correction is spent to the last key, and this header stays // only as a note for the next reader who greps for it. + + // ⚠️ SIX 17.5.0 BOOKINGS — objectui#11111 decision 3 = B (record 5902351047). + // The array/object-armed inputs of the blocks 17.5.0 newly judges, owed a + // member pin by objectui#11168. Counted into `MEMBER_PIN_EXEMPTION_CEILING` + // (1 -> 7) and capped at these six; that card's pins strike them. + ...owedEntries( + 'action:group', + ['actions'], + 'objectui#11168', + 'A MEMBER PIN OWED: `action:group.actions` is array/object-armed on a block 17.5.0 newly judges, and no pin states its members.', + ), + ...owedEntries( + 'action:menu', + ['actions'], + 'objectui#11168', + 'A MEMBER PIN OWED: `action:menu.actions` is array/object-armed on a block 17.5.0 newly judges, and no pin states its members.', + ), + ...owedEntries( + 'element:definition-list', + ['items'], + 'objectui#11168', + 'A MEMBER PIN OWED: `element:definition-list.items` is array/object-armed on a block 17.5.0 newly judges, and no pin states its members.', + ), + ...owedEntries( + 'element:repeater', + ['fields', 'filter', 'sort'], + 'objectui#11168', + 'A MEMBER PIN OWED: this `element:repeater` input is array/object-armed on a block 17.5.0 newly judges, and no pin states its members.', + ), }; /** @@ -3663,8 +3962,18 @@ const NEWLY_JUDGED_UNPINNED_MEMBERS: string[] = []; * ⇒ The rule for every future slice of objectui#8071: delete the entry, register * the pin, and set this constant to the new count. Not to the new count plus * room. + * + * ## 1 -> 7 (objectui#11073, the @objectstack/spec 17.5.0 bump) — the one rise + * since the objectui#8176 correction, and it is a RULING, not room + * + * The six blocks 17.5.0 newly judges bring six array/object-armed inputs with + * no pin. objectui#11111 decision 3 = B (record 5902351047) booked all six + * OWED to objectui#11168 rather than pinning them in the bump. 1 (the + * `NO_READ_SITE_TO_PIN` unit above) + 6 booked = 7, the exact count, and + * `the objectui#11111 ledger is capped at exactly the entries it lists` pins + * the six by name. objectui#11168's pins take this back to 1. */ -const MEMBER_PIN_EXEMPTION_CEILING = 1; +const MEMBER_PIN_EXEMPTION_CEILING = 7; /** * Every test file a member pin can live in, as LAZY `?raw` loaders. @@ -3817,10 +4126,16 @@ describe('registry `inputs` vs `@objectstack/spec` ComponentPropsMap (repo-wide) // blocks counted on the other side of the partition. `judged` and // `registeredPropless` do not move, which is the check that the pin // added blocks rather than moving any across the partition (objectui#7122). - specCarried: 45, - judged: 29, + // + // 45 -> 55 on the `@objectstack/spec` 17.5.0 pin (objectui#11073): ten new + // blocks. Six are judged (`MINOR_17_5_BLOCKS`, 29 -> 35); four are + // registered lazily and unloaded here, booked OWED to objectui#11168 under + // objectui#11111 decision 3 = B (9 -> 13). `registeredPropless` does not + // move. 35 + 7 + 13 = 55. + specCarried: 55, + judged: 35, registeredPropless: 7, - ledgeredUnjudgeable: 9, + ledgeredUnjudgeable: 13, }); // Non-vacuity, stated rather than implied by the numbers above. expect(covered.length).toBeGreaterThan(0); @@ -3842,6 +4157,12 @@ describe('registry `inputs` vs `@objectstack/spec` ComponentPropsMap (repo-wide) // exists and is reachable, so `UNJUDGED_SPEC_BLOCKS` would be a false // statement about it. `assertFullyLoaded` in `sdui-parser` makes the same // demand of manifest generation for the same reason. + // + // ⚠️ ONE RULED EXCEPTION, CAPPED: objectui#11111 decision 3 = B (record + // 5902351047) booked the four lazily registered blocks 17.5.0 newly carries + // OWED to objectui#11168, which loads and judges them. The expectation is + // EXACTLY those booked entries, so a fifth unloaded block is still red, and + // the owner card's landing takes this back to `[]`. const registeredButUnloaded = Object.keys(ComponentPropsMap) .filter((type) => ComponentRegistry.hasLazy(type)) .filter((type) => ComponentRegistry.getConfig(type) === undefined) @@ -3851,7 +4172,7 @@ describe('registry `inputs` vs `@objectstack/spec` ComponentPropsMap (repo-wide) 'these blocks are registered lazily and this file has not loaded them, so ' + 'their `inputs` are invisible to both directions — add an eager import ' + 'of the owning plugin package alongside the two at the top of this file', - ).toEqual([]); + ).toEqual(owedIdsOf(UNJUDGED_SPEC_BLOCKS)); }); it('every unjudged-block ledger entry still describes a block this gate cannot judge', () => { @@ -3893,6 +4214,15 @@ describe('registry `inputs` vs `@objectstack/spec` ComponentPropsMap (repo-wide) `${type} is ledgered as having an empty spec shape, and it does not — ` + 'it now has an authoring surface, so it must be judged', ).toEqual([]); + } else if (reason.startsWith(OWED_PREFIX)) { + // OWED (objectui#11111 decision 3 = B): still registered lazily and still + // unloaded here. Loading it is the owner card's work, and the day that + // lands this entry goes red and the block gets judged. + expect( + ComponentRegistry.hasLazy(type) && ComponentRegistry.getConfig(type) === undefined, + `${type} is booked as registered lazily and unloaded here, and it no longer is — ` + + 'judge it and strike the entry', + ).toBe(true); } else { // NOT REGISTERED, DELIBERATELY. `getKnownTypes` includes pending lazy // stubs on purpose: a stub is a registration, and satisfying this @@ -4261,7 +4591,9 @@ describe('registry `inputs` vs `@objectstack/spec` ComponentPropsMap (repo-wide) // direction — greening a fresh divergence on these two blocks by writing a // nineteenth entry instead of declaring the input. // - // ZERO is the MEASURED backlog today, not a budget. `object-calendar` + // TWO is the BOOKED backlog today — objectui#8652's `navigation` pair, + // ruled at the 17.5.0 bump (the last row of the table below) — and ZERO + // is what it measured before that bump, not a budget. `object-calendar` // publishes all nine keys its spec row declares; `object-kanban` publishes // thirteen of fourteen, and the fourteenth is RULED rather than owed — // objectui#8313 emptied the board, objectui#8314 emptied the calendar, and @@ -4288,6 +4620,15 @@ describe('registry `inputs` vs `@objectstack/spec` ComponentPropsMap (repo-wide) // 0 objectui#8201 slice 2 lands the PM's Q1 = A ruling on the last // entry — `object-kanban.quickAdd` becomes a RULED CARVE-OUT and // moves into `LAZY_BLOCK_RULED_CARVE_OUTS` + // 0 the `@objectstack/spec` 17.5.0 bump carries objectui#8285's ruled + // retirement: the carve-out's entry is harvested and the list + // empties (objectui#11073) — the MAP is now empty on these blocks too + // 2 the same bump: 17.5.0 declares `navigation` on both blocks, and + // objectui#11111 decision 3 = B (record 5902351047) BOOKED the two + // keys OWED to objectui#8652 instead of declaring them in the bump. + // The only step UP this ceiling has taken, and it is a ruling, not + // headroom: the cap is exactly the two booked entries, and + // objectui#8652's landing takes it back to 0. // // ⚠️ THE LAST STEP IS THE ONLY ONE THAT IS NOT A DECLARATION, and reading // it as one would be the wrong lesson. Every step above was this ceiling's @@ -4341,14 +4682,20 @@ describe('registry `inputs` vs `@objectstack/spec` ComponentPropsMap (repo-wide) 'a new unpublished-key exemption was added on a block objectui#8176 newly ' + 'judged — declare the input at its registration site instead; the ' + 'backlog list is shrink-only', - ).toBeLessThanOrEqual(0); + ).toBeLessThanOrEqual(2); // Lower it here when the owning cards land, so the ceiling keeps ratcheting // rather than banking the headroom their fixes free up. expect( backlog.length, 'the objectui#8176 backlog shrank — lower the ceiling above to match, in ' + 'the same change that declared the input', - ).toBe(0); + ).toBe(2); + // …and the two it holds are exactly objectui#8652's booked `navigation` + // pair, so the ruling's room cannot be spent on a different key. + expect(backlog.sort(), 'the backlog is not the two booked navigation keys').toEqual([ + 'object-calendar.navigation', + 'object-kanban.navigation', + ]); }); it('the one ruled carve-out on a newly judged block is real, and names its exit', () => { @@ -4361,11 +4708,13 @@ describe('registry `inputs` vs `@objectstack/spec` ComponentPropsMap (repo-wide) // // 1. PINNED BY NAME. Growing the list has to be a visible edit to a // literal, never a side effect of writing one more exemption. + // EMPTY since the 17.5.0 bump harvested `object-kanban.quickAdd` + // (objectui#11073) — the exit this list named, landed upstream. expect( [...LAZY_BLOCK_RULED_CARVE_OUTS].sort(), 'a ruled carve-out was added on a newly judged block — a ruling is a ' + 'maintainer or PM decision on the record, so name it here and cite it', - ).toEqual(['object-kanban.quickAdd']); + ).toEqual([]); for (const id of LAZY_BLOCK_RULED_CARVE_OUTS) { const [type] = splitExemptionKey(id); @@ -4397,7 +4746,7 @@ describe('registry `inputs` vs `@objectstack/spec` ComponentPropsMap (repo-wide) } }); - it('the eight tombstoned keys are recognised, not exempted — and not published either', () => { + it('the nine tombstoned keys are recognised, not exempted — and not published either', () => { // The pin the harvest leaves behind (objectui#3809). Deleting eight // exemptions is only half the change: the derived assertions above would go // green just as readily if a future edit RE-EXEMPTED one of these keys, or @@ -4407,7 +4756,8 @@ describe('registry `inputs` vs `@objectstack/spec` ComponentPropsMap (repo-wide) // the same reason the `#3808 / #3830` and `rc.6 record_picker` pins next door // are written by name. // - // Five upstream retirements, eight keys, several facts each. The list is + // Six upstream retirements, nine keys, several facts each (eight through + // 17.4.0; `object-kanban.quickAdd` joined at 17.5.0). The list is // pin-dependent by construction and that is the point: it is the measurement // (`@objectstack/spec@17.0.0`, and the same eight on the rc.6 that preceded // it — this change was verified on both), so a pin that un-retires one of @@ -4422,6 +4772,10 @@ describe('registry `inputs` vs `@objectstack/spec` ComponentPropsMap (repo-wide) ['page:header', 'icon'], ['page:tabs', 'type'], ['record:details', 'layout'], + // The ninth, harvested at the `@objectstack/spec` 17.5.0 bump + // (objectui#11073): objectui#8285's ruled retirement, which emptied + // `LAZY_BLOCK_RULED_CARVE_OUTS` in the same change. + ['object-kanban', 'quickAdd'], ]; for (const [type, key] of HARVESTED) { @@ -4940,7 +5294,10 @@ describe('registry `inputs` vs `@objectstack/spec` ComponentPropsMap (repo-wide) expect(declaredInputs(type) ?? [], `${type} stopped publishing ${key}`).toContain(key); expect(Object.keys(OFF_SPEC_EXEMPTIONS)).not.toContain(`${type}.${key}`); } - expect(Object.keys(OFF_SPEC_EXEMPTIONS)).toEqual([]); + // Empty since rc.6 but for the one entry objectui#11111 decision 3 = B + // booked to objectui#11168 at the 17.5.0 bump — pinned by name, so nothing + // else rides in; objectui#11168's landing takes this back to `[]`. + expect(Object.keys(OFF_SPEC_EXEMPTIONS)).toEqual(['action:group.name']); }); // ── the MEMBER-PIN direction (objectui#8068) ─────────────────────────────── @@ -5146,4 +5503,54 @@ describe('registry `inputs` vs `@objectstack/spec` ComponentPropsMap (repo-wide) 'that is not part of the measured correction — it needs a pin, not room', ).toEqual([...NEWLY_JUDGED_UNPINNED_MEMBERS].sort()); }); + + it('the objectui#11111 ledger is capped at exactly the entries it lists', () => { + // objectui#11111 decision 3 = B (record 5902351047): the 17.5.0 bump + // re-pins and declares nothing, and every difference it surfaces is booked + // to an owner card with an expiry — see `OBJECTUI_11111_LEDGER_CAPS`. This + // is what keeps "booked" from becoming "unbounded": each ledger's booked + // entries must number EXACTLY its cap (so one more is red, and a struck one + // demands the cap come down with it), each must be routed to the card the + // ruling names for it, and each must carry the ruling and its expiry. + const ledgers: Array<[keyof typeof OBJECTUI_11111_LEDGER_CAPS, Record]> = [ + ['unjudgedBlocks', UNJUDGED_SPEC_BLOCKS], + ['offSpecInputs', OFF_SPEC_EXEMPTIONS], + ['unpublishedKeys', UNPUBLISHED_EXEMPTIONS], + ['refusedArms', OFF_SPEC_ARM_EXEMPTIONS], + ['memberPins', MEMBER_PIN_EXEMPTIONS], + ]; + const reasons: string[] = []; + for (const [name, ledger] of ledgers) { + const owed = owedIdsOf(ledger); + expect( + owed.length, + `${name}: the booked entries are not exactly the cap — strike an entry and ` + + 'lower the cap in the same change; the cap never rises', + ).toBe(OBJECTUI_11111_LEDGER_CAPS[name]); + for (const id of owed) { + const reason = ledger[id]; + const owner = objectui11111OwnerOf(id); + expect( + reason.startsWith(`${OWED_PREFIX}${owner}.`), + `${id} is not booked to ${owner}, the card the ruling routes it to`, + ).toBe(true); + expect(reason, `${id} does not cite the ruling`).toContain( + 'objectui#11111 decision 3 = B (record 5902351047)', + ); + expect(reason, `${id} carries no expiry`).toContain( + `Expires ${OBJECTUI_11111_EXPIRES}, or when ${owner} lands`, + ); + reasons.push(reason); + } + } + // Per-owner totals, so an entry cannot move between owners without a diff here. + expect( + Object.fromEntries( + OBJECTUI_11111_OWNERS.map((owner) => [ + owner, + reasons.filter((reason) => reason.startsWith(`${OWED_PREFIX}${owner}.`)).length, + ]), + ), + ).toEqual({ 'objectui#11168': 62, 'objectui#8652': 2, 'objectui#8649': 9 }); + }); }); diff --git a/apps/console/src/preview-samples.ts b/apps/console/src/preview-samples.ts index eb06a5d371..34fa826c66 100644 --- a/apps/console/src/preview-samples.ts +++ b/apps/console/src/preview-samples.ts @@ -238,7 +238,7 @@ export const SAMPLES: Record> = { }, }, }, - { id: 'fan_out', type: 'parallel', label: 'Notify in parallel', config: { branches: [ { name: 'Email the owner', nodes: [ { id: 'email_owner', type: 'script', label: 'Email Owner', config: { actionType: 'email', template: 'renewal_reminder', recipients: ['owner.email'] } } ], edges: [] }, { name: 'Post to Slack', nodes: [ { id: 'slack_post', type: 'script', label: 'Slack Notify', config: { actionType: 'slack' } } ], edges: [] } ] } }, + { id: 'fan_out', type: 'parallel', label: 'Notify in parallel', config: { branches: [ { name: 'Email the owner', nodes: [ { id: 'email_owner', type: 'script', label: 'Email Owner', config: { function: 'compose_owner_email', inputs: { template: 'renewal_reminder', to: 'owner.email' }, outputVariable: 'ownerEmail' } } ], edges: [] }, { name: 'Post to Slack', nodes: [ { id: 'slack_post', type: 'script', label: 'Slack Notify', config: { function: 'compose_slack_message', outputVariable: 'slackMessage' } } ], edges: [] } ] } }, { id: 'guard', type: 'try_catch', label: 'Push with retry', config: { errorVariable: '$error', try: { nodes: [ { id: 'push', type: 'http_request', label: 'Push to CRM', config: { method: 'POST', url: 'https://api.example.com/v1/tasks' } } ], edges: [] }, catch: { nodes: [ { id: 'record_failure', type: 'update_record', label: 'Flag Sync Failure', config: { objectName: 'contract', fields: { reminded: false } } } ], edges: [] } } }, { id: 'check', type: 'decision', label: 'Within reminder window?', config: { conditions: [ { label: 'Within window', expression: 'daysToExpiry <= daysBefore' }, { label: 'Else', expression: 'true' } ] } }, { id: 'email', type: 'connector_action', label: 'Send renewal email', connectorConfig: { connectorId: 'email', actionId: 'send', input: { to: 'owner.email', template: 'renewal_reminder' } } }, @@ -249,8 +249,15 @@ export const SAMPLES: Record> = { { id: 'task', type: 'create_record', label: 'Create CSM follow-up', config: { objectName: 'task', fields: { subject: 'Renewal follow-up', priority: 'high' } } }, { id: 'skip', type: 'update_record', label: 'Mark as not due', config: { objectName: 'contract', filter: { id: '{contractId}' }, fields: { reminded: false } } }, { id: 'review', type: 'screen', label: 'CSM review', config: { fields: [ { name: 'discount', label: 'Discount %', type: 'number', required: false }, { name: 'note', label: 'Note', type: 'text', required: true, visibleWhen: 'discount > 0' } ] } }, - { id: 'notify', type: 'script', label: 'Email the owner', config: { actionType: 'email', template: 'renewal_reminder', recipients: ['owner.email', 'csm@example.com'], variables: { contractId: '{contractId}' } } }, - { id: 'enrich', type: 'script', label: 'Score (code)', config: { script: "variables.score = 42;\nreturn variables;", outputVariables: ['score'] } }, + // The four `script` nodes use the script contract `@objectstack/spec` 17.5.0 + // enforces (objectui#11073): a REQUIRED `function` naming a registered + // function, optional `inputs` and `outputVariable`. The keys they carried + // before — `actionType`, `template`, `recipients`, `variables`, `script`, + // `outputVariables` — are spec 17 tombstones the executor refuses by name, + // and a script is contractually pure, so these compose a value a later + // declarative node sends rather than sending it themselves. + { id: 'notify', type: 'script', label: 'Email the owner', config: { function: 'compose_renewal_reminder', inputs: { template: 'renewal_reminder', contractId: '{contractId}' }, outputVariable: 'reminder' } }, + { id: 'enrich', type: 'script', label: 'Score (code)', config: { function: 'score_contract', inputs: { contractId: '{contractId}' }, outputVariable: 'score' } }, // `completed`, not `success`: @objectstack/spec 17.4.0 narrowed the end // node's outcome enum to `completed | refused` (objectui#8785). `refused` // is not interchangeable — the spec additionally REQUIRES a `message` diff --git a/content/docs/guide/ci-cd-pipeline.md b/content/docs/guide/ci-cd-pipeline.md index 52db84b35a..604df2347c 100644 --- a/content/docs/guide/ci-cd-pipeline.md +++ b/content/docs/guide/ci-cd-pipeline.md @@ -657,20 +657,15 @@ a second key, `OBJECTSTACK_REF`, moved by hand under a MUST that nothing could c shape as the version drift above, and retired for the same reason (objectui#7964). Deriving it means the app source and the published packages it runs on come from one release by construction. -That file carries a **second** pin, `BETTER_AUTH_VERSION`, and it is a different kind of thing: -not a matched-pair pin but a workaround for a break inside the published packages themselves. -`@objectstack/plugin-auth` imports `createLocalAccountIssuer` from `@better-auth/core/db` and -declares `@better-auth/core` with a caret; `@better-auth/core@1.7.3` removed that export in a -**patch** release and is also `latest`, so a fresh install floats onto it, AuthPlugin fails to -load, no `sys_*` table is ever created, the seeded sign-in never answers, and the lane dies on -its 300-second readiness timeout having run zero specs (objectstack#16186, objectui#8084). -`start-backend.sh` therefore writes an npm `overrides` block pinning that family, and — because -pinning a dependency to turn a lane green is a gate weakening — `e2e/live/ci/better-auth-pin.mjs` -runs on **every** start, cache hits included, and fails by name if the override was not declared, -did not resolve, or resolved and still lacks the export. ⛔ It is not, and must not become, a -repair of the version pin above: objectui#7689's triage forbids repairing this lane by moving it. -Retire the pin and its guard together in the PR that bumps `OBJECTSTACK_VERSION` past the -upstream fix. +No transitive dependency is overridden in the backend's install: the published manifests decide +what installs, which is the pair the lane exists to test. The file used to carry a **second** +pin, `BETTER_AUTH_VERSION`, with `e2e/live/ci/better-auth-pin.mjs` as its guard. It was a +workaround for a break inside the published packages: `@objectstack/plugin-auth` imported an +export `@better-auth/core@1.7.3` removed in a patch release, so the lane held the family at 1.7.2 +(objectstack#16186, objectui#8084). Both were retired together in the 17.5.0 bump, as their own +text prescribed (objectui#11073, the maintainer's ruling on objectui#11111): `plugin-auth` 17.5.0 +declares the whole family at exactly 1.7.3 and no longer imports that export, so holding 1.7.2 +on it made the lane test a combination nobody ships, and the seeded sign-in never answered. ## Internal Docs Links (`docs-links.yml`) diff --git a/content/docs/plugins/plugin-calendar.mdx b/content/docs/plugins/plugin-calendar.mdx index 9aa0708b86..b713796bec 100644 --- a/content/docs/plugins/plugin-calendar.mdx +++ b/content/docs/plugins/plugin-calendar.mdx @@ -274,34 +274,34 @@ not `===` the authored object. startDateField: string, // Field containing event start date endDateField?: string, // Field containing event end date titleField: string, // Field to use as event title - colorField?: string // Field for color coding + colorField?: string, // Field for color coding + allDayField?: string // Field carrying the all-day flag } ``` `CalendarConfig` is a re-export of `@objectstack/spec`'s `CalendarConfigSchema`, -and that schema is **strict**: the four keys above are the whole of *the spec -type*, and it refuses a fifth by name with an `unrecognized_keys` diagnostic. -That is a statement about the type — not about what this renderer reads, and not -about what reaches it. +and that schema is **strict**: the five keys above are the whole of *the spec +type*, and it refuses any other key by name with an `unrecognized_keys` +diagnostic. That is a statement about the type — not about what this renderer +reads, and not about what reaches it. `ObjectCalendar` (`ObjectCalendar.tsx`) reads **five** keys off the `calendar` -block: the four above plus objectui's own `allDayField`, honoured since -objectui#8026. +block — the same five; it has honoured `allDayField` since objectui#8026. `{ startDateField, endDateField, titleField, colorField, allDayField }` -`allDayField` names the record field carrying the all-day flag. It is -objectui-local by design — the spec refuses the name — and this renderer gives it -**no** default field name, so a calendar that never declares it draws exactly as -before, with the all-day flag inferred from the absence of an end date. +`allDayField` names the record field carrying the all-day flag. objectui read it +before the spec declared it; the spec declares it since `@objectstack/spec` +17.5.0. This renderer gives it **no** default field name, so a calendar that +never declares it draws exactly as before, with the all-day flag inferred from +the absence of an end date. -Nor is a fifth key rejected on the way in. Neither published face of +Nor is a sixth key rejected on the way in. Neither published face of `ObjectCalendarSchema` declares the `calendar` container, so whatever you write inside it reaches the renderer unexamined; on the list-view path the container *is* declared — as objectui's own mirror of the spec schema, which keeps `.passthrough()` for exactly this. Annotating the block `CalendarConfig` is what -narrows it to the spec's four: under that annotation `allDayField` does not -compile. +narrows it to the spec's five. ## Configuration diff --git a/e2e/live/ci/backend.env b/e2e/live/ci/backend.env index 0ebb0bced9..95c134de73 100644 --- a/e2e/live/ci/backend.env +++ b/e2e/live/ci/backend.env @@ -14,7 +14,7 @@ # # This file declares exactly ONE @objectstack pin, and that is the whole # design: the commit is derived from it, see below. -OBJECTSTACK_VERSION=17.4.0 +OBJECTSTACK_VERSION=17.5.0 # There is deliberately no OBJECTSTACK_REF key here. The objectstack-ai/objectstack # commit the showcase app metadata is sparse-checked-out from is DERIVED at @@ -45,33 +45,12 @@ OBJECTSTACK_VERSION=17.4.0 # the Actions cache is the one path it is not caught on. Published release tags # are not expected to move, and moving one is the anomaly, not this lane. -# BETTER_AUTH_VERSION — the version the whole floating `better-auth` family is -# pinned to in the backend's install path, via an npm `overrides` block that -# start-backend.sh writes into the showcase app's manifest. -# -# Why: @objectstack/plugin-auth imports `createLocalAccountIssuer` from -# `@better-auth/core/db` and declares `@better-auth/core` with a CARET. -# @better-auth/core@1.7.3 removed that export in a PATCH release, and 1.7.3 is -# `latest`, so a fresh install of the pinned @objectstack/* floats onto it, -# AuthPlugin fails to load, no sys_* table is ever created, the seeded sign-in -# never answers and this lane times out at 300s having run zero specs. -# Root cause objectstack#16186; this pin decided on objectui#8084. -# -# ⛔ This is NOT a repair of the two pins above and must never become one: -# objectui#7689's triage forbids repairing this lane by moving OBJECTSTACK_*, -# and those two values are unchanged. This pins a TRANSITIVE dependency of the -# published artifact to the version that artifact's own manifest was authored -# against. Retire it — this key and e2e/live/ci/better-auth-pin.mjs, together -# — in the PR that bumps OBJECTSTACK_VERSION past the upstream fix. -# -# Enforced, not merely stated: e2e/live/ci/better-auth-pin.mjs runs on EVERY -# start (not just after a fresh install) and fails by name if the override was -# not declared, did not resolve, or resolved and still lacks the export. It -# also owns the list of package names — this file carries only the version. -# -# ⚠️ It lives in THIS file rather than in start-backend.sh on purpose: the -# workflow caches the prepared fixture under -# `live-backend--`, so a pin that lives -# here changes the cache key and a pin change cannot be served a fixture built -# before it. start-backend.sh's own stamp carries it for the same reason. -BETTER_AUTH_VERSION=1.7.2 +# There is no `better-auth` pin here any more. BETTER_AUTH_VERSION and +# e2e/live/ci/better-auth-pin.mjs held the family at 1.7.2 while the published +# @objectstack/plugin-auth imported an export 1.7.3 removed (objectstack#16186, +# objectui#8084). They were retired together in the 17.5.0 bump, as their own +# text prescribed (objectui#11073, maintainer ruling on objectui#11111): +# plugin-auth 17.5.0 declares the whole family at exactly 1.7.3 and no longer +# imports that export, so the published artifact's own manifest is the pin. +# Holding 1.7.2 on it made the lane test a pair nobody ships, and the seeded +# sign-in never answered. diff --git a/e2e/live/ci/better-auth-pin.mjs b/e2e/live/ci/better-auth-pin.mjs deleted file mode 100755 index 4bcecfd63e..0000000000 --- a/e2e/live/ci/better-auth-pin.mjs +++ /dev/null @@ -1,300 +0,0 @@ -#!/usr/bin/env node -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * The live-e2e backend's `better-auth` pin: it writes the override, and it - * refuses to let the override fail quietly. - * - * node e2e/live/ci/better-auth-pin.mjs overrides VERSION - * node e2e/live/ci/better-auth-pin.mjs verify APP_DIR VERSION - * - * Exit: 0 = the pinned world matches reality - * 1 = it does not, named by which of the three ways it stopped matching - * - * ## Why the pin exists (objectstack#16186) - * - * `@objectstack/plugin-auth` imports `createLocalAccountIssuer` from - * `@better-auth/core/db` and declares `@better-auth/core` with a CARET range - * (`^1.7.1` at 17.2.0, `^1.7.2` at 17.3.0). `@better-auth/core@1.7.3` REMOVED - * that public export — in a patch release, upstream better-auth#10909 — and - * 1.7.3 is both the top of the published `1.7.x` set and `latest`, so every - * caret in the family floats onto it and no range in the published manifest can - * protect against it. Measured in this lane's own install path: the import dies - * with `SyntaxError: The requested module '@better-auth/core/db' does not - * provide an export named 'createLocalAccountIssuer'`. - * - * What that costs is not one plugin. AuthPlugin fails to load, `auth` is - * reported as a missing core service, no `sys_*` table is ever created, the - * seeded sign-in never answers, and `start-backend.sh` times out at 300s — so - * the console is never built, Playwright never runs, and the lane's whole - * product (a console x published-backend integration reading) is lost on every - * PR. The backend does not refuse to start, which is the confusing part: it - * binds the port, loads 42 plugins and prints `Server is ready` while auth is - * absent (objectui#8084). - * - * ⛔ The pin is NOT a repair of `OBJECTSTACK_VERSION` (nor of the showcase-app - * commit start-backend.sh derives from it — objectui#7964 retired the second, - * hand-moved `OBJECTSTACK_REF` key that used to sit beside it). - * objectui#7689's triage forbids repairing this lane by moving that, and this - * does not move it. It pins a TRANSITIVE dependency of the published artifact - * to the version that artifact's own manifest was authored against, restoring - * the resolution the publisher intended. When objectstack#16186 lands upstream - * — plugin-auth pinning `@better-auth/core` itself, or moving off the removed - * export — this file and the `BETTER_AUTH_VERSION` key in `backend.env` should - * be deleted in the same PR that bumps `OBJECTSTACK_VERSION` past the fix. - * - * ## Why `verify` exists, and why it runs on EVERY start - * - * Pinning a dependency to make a red lane green is a gate weakening, and it was - * taken deliberately (objectui#8084) on ONE condition: quieting the red must not - * quiet the information. A pin that stops applying must be LOUD, because the - * thing it decays into is precisely the outcome the lane exists to detect, minus - * the explanation — a 300-second timeout with no stated cause. - * - * Three ways the pinned world can stop matching reality, and this file names - * which one it is rather than reporting a single undifferentiated failure: - * - * PIN-NOT-DECLARED the `overrides` block never reached the manifest npm - * installed from. The classic silent failure: `overrides` - * is npm's spelling, `pnpm.overrides` is pnpm's, and each - * is INERT under the other package manager — no warning, - * no error, exit 0, a fully resolved tree that ignored the - * pin. This lane installs with `npm install`, so the npm - * spelling is the load-bearing one. - * PIN-NOT-RESOLVED the override was declared and the installed tree does - * not match it anyway (a stale cached fixture reused - * across a pin change, a nested copy npm declined to - * dedupe, a hand-edited manifest). - * EXPORT-MISSING the pinned version installed cleanly and the export the - * auth plugin needs is still not there. This is the check - * that survives the other two being satisfiable by a - * version number alone: it asks the question the plugin - * asks, with the resolver the plugin uses. - * - * ⚠️ EXPORT-MISSING is a CANARY, not a contract. It probes the one named import - * that broke. If `@objectstack/plugin-auth` grows a second import that a future - * `@better-auth/*` drops, this will not see it — the 300s readiness timeout - * will, in the way it always did. Widen `REQUIRED_IMPORTS` when that happens. - */ - -import { spawnSync } from 'node:child_process'; -import fs from 'node:fs'; -import path from 'node:path'; - -/** - * The packages the override pins, and the ONE list both halves of this file - * read — `overrides` writes exactly these names, `verify` checks exactly these - * names. Two lists would be a drift the lane could not see: pinning a name - * nothing checks, or checking a name nothing pins, both read as green. - * - * MEASURED against this lane's own install path at `@objectstack/*@17.2.0` - * rather than copied from the upstream workaround — every name below really is - * in the installed tree, all ten of them resolving to 1.7.3 before the pin: - * - * node_modules/@better-auth/{core,drizzle-adapter,kysely-adapter, - * memory-adapter,mongo-adapter,prisma-adapter,telemetry} - * node_modules/@objectstack/plugin-auth/node_modules/{better-auth, - * @better-auth/oauth-provider,@better-auth/sso} - * - * ⛔ TWO deliberate exclusions, each of which would be a bug to "fix" by adding: - * - * `@better-auth/scim` — present, but `@objectstack/plugin-auth@17.2.0` pins it - * EXACTLY at `1.7.0-rc.1`, not with a caret. It is therefore not floating, - * it is not what broke, and overriding it to 1.7.2 would OVERRIDE THE - * PUBLISHER rather than restore their intent — the opposite of this pin's - * whole justification. The upstream workaround lists it; this lane measured - * its own tree and left it alone. - * - * `@better-auth/utils` — present at 0.4.2 and 0.5.0. A separate version line - * that never had a 1.7.x, so pinning it to 1.7.2 would fail the install. - */ -const FAMILY = [ - 'better-auth', - '@better-auth/core', - '@better-auth/drizzle-adapter', - '@better-auth/kysely-adapter', - '@better-auth/memory-adapter', - '@better-auth/mongo-adapter', - '@better-auth/oauth-provider', - '@better-auth/prisma-adapter', - '@better-auth/sso', - '@better-auth/telemetry', -]; - -/** - * The named imports EXPORT-MISSING probes, as `[specifier, name]`. Exactly the - * one that broke; see the canary note in the header before adding to it. - */ -const REQUIRED_IMPORTS = [['@better-auth/core/db', 'createLocalAccountIssuer']]; - -/** The package whose absence makes the whole pin pointless. */ -const KEYSTONE = '@better-auth/core'; - -const ISSUE = 'objectstack#16186 (downstream: objectui#8084)'; - -/** `overrides` object for the app manifest, in npm's top-level spelling. */ -function overridesFor(version) { - const out = {}; - for (const name of FAMILY) out[name] = version; - return out; -} - -function die(tag, lines) { - console.error(`[better-auth-pin] FAIL ${tag}`); - for (const line of lines) console.error(`[better-auth-pin] ${line}`); - console.error(`[better-auth-pin] root cause: ${ISSUE}`); - process.exit(1); -} - -/** - * Every installed copy of a FAMILY package under `dir`, nested ones included. - * - * Walks `node_modules` by hand rather than shelling out to `npm ls`: the answer - * must be about what is ON DISK, which is the thing a restored cache can make - * disagree with any manifest. - */ -function installedCopies(appDir) { - const found = []; - const walk = (nodeModules) => { - let entries; - try { - entries = fs.readdirSync(nodeModules, { withFileTypes: true }); - } catch { - return; - } - for (const entry of entries) { - if (!entry.isDirectory() || entry.name.startsWith('.')) continue; - const dirs = entry.name.startsWith('@') - ? fs - .readdirSync(path.join(nodeModules, entry.name), { withFileTypes: true }) - .filter((e) => e.isDirectory()) - .map((e) => path.join(nodeModules, entry.name, e.name)) - : [path.join(nodeModules, entry.name)]; - for (const pkgDir of dirs) { - const manifest = path.join(pkgDir, 'package.json'); - if (fs.existsSync(manifest)) { - try { - const pkg = JSON.parse(fs.readFileSync(manifest, 'utf8')); - if (FAMILY.includes(pkg.name)) { - found.push({ name: pkg.name, version: pkg.version, dir: path.relative(appDir, pkgDir) }); - } - } catch { - /* an unreadable manifest is npm's business, not this gate's */ - } - } - walk(path.join(pkgDir, 'node_modules')); - } - } - }; - walk(path.join(appDir, 'node_modules')); - return found; -} - -function verify(appDir, version) { - // ── PIN-NOT-DECLARED ──────────────────────────────────────────────────── - const manifestPath = path.join(appDir, 'package.json'); - let manifest; - try { - manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); - } catch (error) { - die('(PIN-NOT-DECLARED)', [ - `could not read ${manifestPath}: ${error.message}`, - 'start-backend.sh rewrites this manifest before installing; if it is not there,', - 'the prepare step did not run or did not finish.', - ]); - } - const declared = manifest.overrides || {}; - const wrong = FAMILY.filter((name) => declared[name] !== version); - if (wrong.length > 0) { - die('(PIN-NOT-DECLARED)', [ - `${path.relative(appDir, manifestPath) || 'package.json'} does not declare the whole family at ${version}.`, - ...wrong.map((name) => ` ${name}: declared ${JSON.stringify(declared[name] ?? null)}, want ${JSON.stringify(version)}`), - manifest.pnpm && manifest.pnpm.overrides - ? 'NOTE: a `pnpm.overrides` block IS present. This app is installed with `npm install`,' - : 'The npm spelling is the top-level `overrides` key. `pnpm.overrides` is inert here:', - 'npm reads `overrides`, pnpm reads `pnpm.overrides`, and each ignores the other silently.', - ]); - } - - // ── PIN-NOT-RESOLVED ──────────────────────────────────────────────────── - const copies = installedCopies(appDir); - // A zero-hit "everything matches" is not a reading. If the keystone is not - // installed at all, the loop below would pass over an empty set and this - // whole gate would be decorative. - if (!copies.some((c) => c.name === KEYSTONE)) { - die('(PIN-NOT-RESOLVED)', [ - `no copy of ${KEYSTONE} is installed under ${appDir}/node_modules.`, - `That is either a broken install, or ${KEYSTONE} is no longer a dependency of`, - '@objectstack/plugin-auth at this OBJECTSTACK_VERSION. If it is the latter, the pin and', - 'this guard have outlived their purpose: delete BETTER_AUTH_VERSION from backend.env and', - 'delete this file, deliberately and in one PR. Do not leave a pin that pins nothing.', - ]); - } - const drifted = copies.filter((c) => c.version !== version); - if (drifted.length > 0) { - die('(PIN-NOT-RESOLVED)', [ - `the override is declared at ${version} but the installed tree does not match it:`, - ...drifted.map((c) => ` ${c.name}@${c.version} at ${c.dir}`), - 'A restored fixture cache is the likeliest cause — the cache key is the hash of', - 'backend.env, so a pin change must change that file, and start-backend.sh must not', - 'reuse a stamped fixture across it.', - ]); - } - - // ── EXPORT-MISSING ────────────────────────────────────────────────────── - // Asked with the resolver the plugin itself uses: a real ESM named import, - // resolved from the app directory. `--input-type=module -e` takes its - // resolution base from the cwd, so bare specifiers hit this app's tree. - for (const [specifier, name] of REQUIRED_IMPORTS) { - const probe = `import { ${name} } from '${specifier}'; if (typeof ${name} === 'undefined') { console.error('bound but undefined'); process.exit(3); }`; - const result = spawnSync(process.execPath, ['--input-type=module', '-e', probe], { - cwd: appDir, - encoding: 'utf8', - }); - if (result.status !== 0) { - die('(EXPORT-MISSING)', [ - `${specifier} does not provide a usable export named '${name}',`, - `even though the family installed cleanly at ${version}.`, - 'This is the exact import @objectstack/plugin-auth makes; when it fails, AuthPlugin does', - 'not load, no sys_* table is created, the seeded sign-in never answers, and this lane', - 'times out after 300s having produced no Playwright run at all.', - `node exited ${result.status}:`, - ...String(result.stderr || '') - .trim() - .split('\n') - .slice(0, 6) - .map((l) => ` ${l}`), - `A NEW version pin will not necessarily fix this — check whether ${ISSUE} has landed`, - 'upstream, and if it has, retire this pin instead of moving it.', - ]); - } - } - - const shown = copies.map((c) => `${c.name}@${c.version}`).sort(); - console.log( - `[better-auth-pin] ok: ${copies.length} installed copies pinned at ${version}, ` + - `${REQUIRED_IMPORTS.length} required import(s) resolved`, - ); - console.log(`[better-auth-pin] ${shown.join(', ')}`); -} - -const [command, ...rest] = process.argv.slice(2); - -if (command === 'overrides') { - const version = rest[0]; - if (!version) { - console.error('usage: better-auth-pin.mjs overrides VERSION'); - process.exit(2); - } - process.stdout.write(JSON.stringify(overridesFor(version))); -} else if (command === 'verify') { - const [appDir, version] = rest; - if (!appDir || !version) { - console.error('usage: better-auth-pin.mjs verify APP_DIR VERSION'); - process.exit(2); - } - verify(path.resolve(appDir), version); -} else { - console.error('usage: better-auth-pin.mjs overrides VERSION | verify APP_DIR VERSION'); - process.exit(2); -} diff --git a/e2e/live/ci/start-backend.sh b/e2e/live/ci/start-backend.sh index c1661ebdce..d8eac54152 100755 --- a/e2e/live/ci/start-backend.sh +++ b/e2e/live/ci/start-backend.sh @@ -10,12 +10,11 @@ # 2. Rewrite its package.json: every `@objectstack/*` workspace dep -> the # pinned published OBJECTSTACK_VERSION; dev-only tooling dropped. # 3. `npm install` (published tarballs only — nothing is built from source, -# so the lane tests the console against the released artifacts), with the -# floating `better-auth` family pinned to BETTER_AUTH_VERSION through an -# npm `overrides` block — see backend.env and better-auth-pin.mjs. -# 4. Verify that pin actually took, ON EVERY RUN including cache hits, and -# fail by name if it did not (objectstack#16186 / objectui#8084). -# 5. `objectstack dev --seed-admin --fresh` on $LIVE_BACKEND_PORT with a +# so the lane tests the console against the released artifacts). No +# transitive dependency is overridden: the published manifests decide +# what installs, which is the pair this lane exists to test (the +# `better-auth` pin retired with the 17.5.0 bump — see backend.env). +# 4. `objectstack dev --seed-admin --fresh` on $LIVE_BACKEND_PORT with a # throwaway sqlite db, then poll the seeded sign-in until it answers. # # Env overrides: @@ -35,10 +34,6 @@ PORT="${LIVE_BACKEND_PORT:-4010}" REPO_URL="${OBJECTSTACK_REPO_URL:-https://github.com/objectstack-ai/objectstack.git}" APP_DIR="$BACKEND_DIR/app" STAMP="$BACKEND_DIR/.prepared" -# An absent pin is a hard stop, not a soft default: silently installing the -# floating family is the exact outcome this pin exists to prevent, and it costs -# 300 seconds to discover downstream (objectstack#16186). -: "${BETTER_AUTH_VERSION:?backend.env must declare BETTER_AUTH_VERSION — see its header and objectstack#16186}" # The showcase-app commit is DERIVED here, and is declared nowhere: it is # whatever the `@objectstack/cli@$OBJECTSTACK_VERSION` release tag points at, so @@ -85,8 +80,10 @@ echo "[live-backend] resolved $OBJECTSTACK_TAG -> $OBJECTSTACK_REF" # installed — and a re-pointed tag is exactly such a change with no key of its # own to hash. The workflow's fixture cache key is the hash of backend.env, # which carries the version for the same reason; see the ⚠️ note there for the -# one path that key can no longer see. -WANT_STAMP="$OBJECTSTACK_REF $OBJECTSTACK_VERSION better-auth@$BETTER_AUTH_VERSION" +# one path that key can no longer see. The stamp used to carry the retired +# `better-auth` pin as a third component, so a fixture prepared under that pin +# no longer matches and is rebuilt without the override. +WANT_STAMP="$OBJECTSTACK_REF $OBJECTSTACK_VERSION" mkdir -p "$BACKEND_DIR" @@ -111,25 +108,17 @@ prepare() { # workspace:* -> the pinned published version; drop dev tooling the server # doesn't need (playwright/vitest/typescript — the CLI loads the TS config # itself). Keep non-@objectstack runtime deps (e.g. @modelcontextprotocol/sdk). - # - # The third argument is the `better-auth` pin, as npm's TOP-LEVEL `overrides` - # object. Spelling matters and is not interchangeable: this app is installed - # with `npm install` below, npm reads `overrides`, pnpm reads - # `pnpm.overrides`, and each ignores the other with no warning and exit 0. The - # names come from better-auth-pin.mjs so the list that is pinned and the list - # that is checked cannot drift apart. + # No `overrides` block is written: the published @objectstack/* manifests + # decide their own transitive versions. node -e ' const fs = require("fs"); const file = process.argv[1], pin = process.argv[2]; - const overrides = JSON.parse(process.argv[3]); const pkg = JSON.parse(fs.readFileSync(file, "utf8")); for (const k of Object.keys(pkg.dependencies || {})) if (k.startsWith("@objectstack/")) pkg.dependencies[k] = pin; pkg.devDependencies = { "@objectstack/cli": pin }; - pkg.overrides = Object.assign({}, pkg.overrides, overrides); fs.writeFileSync(file, JSON.stringify(pkg, null, 2) + "\n"); - ' "$APP_DIR/package.json" "$OBJECTSTACK_VERSION" \ - "$(node "$SCRIPT_DIR/better-auth-pin.mjs" overrides "$BETTER_AUTH_VERSION")" + ' "$APP_DIR/package.json" "$OBJECTSTACK_VERSION" (cd "$APP_DIR" && npm install --no-audit --no-fund --loglevel=error) echo "$WANT_STAMP" > "$STAMP" @@ -177,8 +166,4 @@ start() { } prepare -# Deliberately OUTSIDE prepare(): prepare is skipped on a stamp hit, and a -# reused fixture is one of the ways the pin can stop being true. A check that -# only runs when the thing it checks was just built is not a check. -node "$SCRIPT_DIR/better-auth-pin.mjs" verify "$APP_DIR" "$BETTER_AUTH_VERSION" start diff --git a/packages/app-shell/package.json b/packages/app-shell/package.json index 8ab08ef694..c1a1e8f276 100644 --- a/packages/app-shell/package.json +++ b/packages/app-shell/package.json @@ -85,13 +85,13 @@ "@object-ui/types": "workspace:*", "@objectstack/formula": "^17.0.0", "@objectstack/lint": "^17.0.0", - "@objectstack/spec": "^17.4.0", + "@objectstack/spec": "^17.5.0", "@sentry/react": "^10.70.0", "jsonc-parser": "^3.3.1", "lucide-react": "^1.43.0", "qrcode": "^1.5.4", "sonner": "^2.0.8", - "zod": "^4.4.3" + "zod": "^4.6.1" }, "peerDependencies": { "@object-ui/plugin-calendar": "workspace:^", diff --git a/packages/app-shell/src/services/MetadataService.fieldKeyCarryOver.test.ts b/packages/app-shell/src/services/MetadataService.fieldKeyCarryOver.test.ts index 904283f8ef..e99d95eaa5 100644 --- a/packages/app-shell/src/services/MetadataService.fieldKeyCarryOver.test.ts +++ b/packages/app-shell/src/services/MetadataService.fieldKeyCarryOver.test.ts @@ -263,11 +263,14 @@ describe('objectui#6488 · a server key the designer does not model SURVIVES a f }); it('does not read `Object.prototype` as a previous entry for a field named `__proto__`', async () => { - // `__proto__` is a SPEC-LEGAL field name (the record's key rule is - // `/^[a-z_][a-z0-9_]*$/`, measured green), and a plain `previous['__proto__']` - // lookup returns the inherited `Object.prototype` — an object that is not a - // previous field entry at all. The other end of this same map already - // documents the hazard in `toFieldsMap`. + // `__proto__` matches the record's key rule (`/^[a-z_][a-z0-9_]*$/`), and a + // plain `previous['__proto__']` lookup returns the inherited + // `Object.prototype` — an object that is not a previous field entry at all. + // The other end of this same map already documents the hazard in + // `toFieldsMap`. Through `@objectstack/spec` 17.4.0 the name was also + // spec-legal; 17.5.0 refuses it BY NAME (`z.record()` would silently drop + // it), and that refusal can only name the field if the body still carries + // it — so keeping the key is now what makes the server's answer legible. const { adapter, puts } = makeCapturingAdapter({ name: 'account', label: 'Account', fields: {} }); await new MetadataService(adapter).saveFields('account', [designerField('__proto__', { label: 'P' })]); @@ -283,7 +286,8 @@ describe('objectui#6488 · a server key the designer does not model SURVIVES a f // non-enumerable, so a spread of it is empty — the guard is what keeps this // a statement about the designer's field rather than a lucky no-op. expect(Object.keys(entry).sort()).toEqual(['label', 'name', 'type']); - expect(issuesOf(puts[0])).toEqual([]); + // The spec's by-name refusal, and nothing else (objectui#11073 re-pin). + expect(issuesOf(puts[0])).toEqual(['custom @ fields.__proto__']); }); }); diff --git a/packages/app-shell/src/services/MetadataService.objectPayloadFieldsMap.test.ts b/packages/app-shell/src/services/MetadataService.objectPayloadFieldsMap.test.ts index 2dc2f59a16..17fed3aa1b 100644 --- a/packages/app-shell/src/services/MetadataService.objectPayloadFieldsMap.test.ts +++ b/packages/app-shell/src/services/MetadataService.objectPayloadFieldsMap.test.ts @@ -149,9 +149,13 @@ describe('the instrument', () => { expect(ObjectSchema.safeParse({ name: 'account', label: 'Account', fields: { undefined: { type: 'text', label: 'N' } } }).success).toBe(true); }); - it('keys the record with a snake_case rule — `__proto__` is a LEGAL field name', () => { + it('keys the record with a snake_case rule — `__proto__` matches it, and the spec refuses it BY NAME', () => { // Which is why `toFieldsMap` builds through `Object.fromEntries`: plain // assignment would invoke the prototype setter and drop the field silently. + // Through `@objectstack/spec` 17.4.0 the name was LEGAL; 17.5.0 refuses it + // by name, because `z.record()` drops the key from its output while + // reporting success. That refusal names the field only if the body still + // carries it, so `Object.fromEntries` still earns its place (objectui#11073). // // The fixture below spells the key `['__proto__']` DELIBERATELY, and the // two controls are why (objectui#6524). Per Annex B.3.1 a PLAIN @@ -162,7 +166,9 @@ describe('the instrument', () => { // test claims to pin. expect(Object.keys({ __proto__: { type: 'text', label: 'P' } })).toEqual([]); expect(Object.keys({ ['__proto__']: { type: 'text', label: 'P' } })).toEqual(['__proto__']); - expect(ObjectSchema.safeParse({ name: 'account', label: 'Account', fields: { ['__proto__']: { type: 'text', label: 'P' } } }).success).toBe(true); + expect(issuesOf(ObjectSchema.safeParse({ name: 'account', label: 'Account', fields: { ['__proto__']: { type: 'text', label: 'P' } } }))).toEqual([ + 'custom @ fields.__proto__', + ]); expect(issuesOf(ObjectSchema.safeParse({ name: 'account', label: 'Account', fields: { firstName: { type: 'text', label: 'F' } } }))).toEqual([ 'invalid_key @ fields.firstName', ]); @@ -395,9 +401,10 @@ describe('objectui#6240 · a field with no name FAILS LOUDLY, and nothing is sen }); it('keys a field literally named `__proto__` instead of silently dropping it', async () => { - // `__proto__` matches the record's key rule, so it is authorable. Built by - // assignment it would set the prototype and vanish from the serialised - // body; built by `Object.fromEntries` it is an own property. + // `__proto__` matches the record's key rule, so a designer can type it. + // Built by assignment it would set the prototype and vanish from the + // serialised body; built by `Object.fromEntries` it is an own property — + // which is what lets the spec's by-name refusal (17.5.0) reach the author. const { adapter, puts } = makeCapturingAdapter(); await new MetadataService(adapter).saveObject(ACCOUNT, [ { name: '__proto__', type: 'text', label: 'Proto' }, diff --git a/packages/app-shell/src/views/ObjectView.kanbanGroupByRetired-8213.test.tsx b/packages/app-shell/src/views/ObjectView.kanbanGroupByRetired-8213.test.tsx index ec2176290b..f7877bcd23 100644 --- a/packages/app-shell/src/views/ObjectView.kanbanGroupByRetired-8213.test.tsx +++ b/packages/app-shell/src/views/ObjectView.kanbanGroupByRetired-8213.test.tsx @@ -28,20 +28,22 @@ * * ⚠️ THE CLAIM IS NARROWER THAN "THE EMITTED BAG VALIDATES", and saying so is * the point of the ratchet arm. The producer also emits `titleField` and - * `cardFields`, and `KanbanConfigSchema` — which declares exactly - * `groupByField` / `summarizeField` / `columns` — refuses BOTH of those by name - * as well. They are a different question and stay: + * `cardFields`, and `KanbanConfigSchema` — which declared exactly + * `groupByField` / `summarizeField` / `columns` through `@objectstack/spec` + * 17.4.0 — refused BOTH of those by name as well. 17.5.0 declares `titleField` + * (objectui#11073), so the ratchet went DOWN to `cardFields` alone. They were a + * different question and stayed: * - `cardFields` is a DECLARED deprecated alias of the spec's `columns` in * this repo's own `KanbanConfig` mirror (`@object-ui/types`), i.e. drift * this repo has taken a documented position on; - * - `titleField` is live and undeclared anywhere — `ListView` destructures it - * out of the merged config and forwards it onto the generated - * `object-kanban` node. + * - `titleField` is live — `ListView` destructures it out of the merged + * config and forwards it onto the generated `object-kanban` node — and was + * undeclared anywhere until 17.5.0 declared it on `KanbanConfigSchema`. * Neither is a second spelling of a key this expression already writes * correctly, which is all objectui#8213 was. So the honest assertion is not - * "nothing is refused" but "the refused set is EXACTLY the two known ones" — - * which reddens for a fourth undeclared key, and would have reddened for - * `groupBy`. + * "nothing is refused" but "the refused set is EXACTLY the known ones" (two + * through 17.4.0, one since) — which reddens for a new undeclared key, and + * would have reddened for `groupBy`. * * ⚠️ NO ARM HERE ASSERTS THE `...restKanban` OVERRIDE, deliberately. `ListView` * spread the rest of the merged kanban config AFTER its own `groupBy: @@ -98,8 +100,12 @@ const refusedKeys = (bag: Record): string[] => { const laneKeysOf = (bag: Record) => LANE_SPELLINGS.filter((k) => k in bag).sort(); -/** The two keys the producer emits that the spec refuses and this card keeps. */ -const KNOWN_REFUSED_RESIDUAL = ['cardFields', 'titleField']; +/** + * The key the producer emits that the spec refuses and this card keeps. Two + * through `@objectstack/spec` 17.4.0; `titleField` left the set when 17.5.0 + * declared it (objectui#11073), which is the ratchet moving the way it may. + */ +const KNOWN_REFUSED_RESIDUAL = ['cardFields']; describe('the instrument can answer both ways on this exact call (objectui#8213)', () => { it('CONTROL: the strict schema refuses an unknown key BY NAME on the producer output', () => { @@ -174,7 +180,7 @@ describe('the RATCHET on what the spec still refuses here (objectui#8213)', () = // ⭐ THE DURABLE ARM. Not "the emitted bag validates" — it does not, and this // file's header says why. This is the exact refused SET, so `groupBy` cannot // come back and a FOURTH undeclared key cannot join quietly. - it('refuses exactly the two known residual keys, and nothing else', () => { + it('refuses exactly the known residual key, and nothing else', () => { const emitted = kanbanViewOptions({ kanban: { groupByField: 'stage' } }, OBJECT_WITH_STAGE); expect(refusedKeys(emitted)).toEqual(KNOWN_REFUSED_RESIDUAL); }); @@ -190,6 +196,6 @@ describe('the RATCHET on what the spec still refuses here (objectui#8213)', () = // Guards the ratchet against being satisfied by a residual list that // quietly grew to include the very key this card retired. expect(KNOWN_REFUSED_RESIDUAL).not.toContain('groupBy'); - expect(KNOWN_REFUSED_RESIDUAL.length).toBe(2); + expect(KNOWN_REFUSED_RESIDUAL.length).toBe(1); }); }); diff --git a/packages/app-shell/src/views/ObjectView.kanbanLane-8193.test.tsx b/packages/app-shell/src/views/ObjectView.kanbanLane-8193.test.tsx index f30e116b0d..25ae355089 100644 --- a/packages/app-shell/src/views/ObjectView.kanbanLane-8193.test.tsx +++ b/packages/app-shell/src/views/ObjectView.kanbanLane-8193.test.tsx @@ -213,15 +213,19 @@ describe('the emitted lane key is the one `@objectstack/spec` declares (objectui expect(unrecognized(complete({ groupBy: 'stage' }))).toContain('groupBy'); }); - it('declares exactly the three keys this file reasons about', () => { + it('declares exactly the four keys this file reasons about', () => { // A tripwire on the premise itself: if a future spec bump declares // `groupBy` (or drops `groupByField`), the arms above stop meaning what - // they say and this reddens first. objectui#7685 is in flight to move the - // resolved spec version. + // they say and this reddens first. It fired at the 17.5.0 bump + // (objectui#11073) for a key that is NOT a lane spelling: 17.5.0 declares + // `titleField` (the card-title binding, falling back to the record display + // name). Re-read: every arm above is about the lane key and still holds, so + // the pin moves from three keys to four and nothing else does. expect(Object.keys((KanbanConfigSchema as any).shape).sort()).toEqual([ 'columns', 'groupByField', 'summarizeField', + 'titleField', ]); }); }); diff --git a/packages/app-shell/src/views/metadata-admin/celAuthoring.ts b/packages/app-shell/src/views/metadata-admin/celAuthoring.ts index ef307a65b0..761ee036ae 100644 --- a/packages/app-shell/src/views/metadata-admin/celAuthoring.ts +++ b/packages/app-shell/src/views/metadata-admin/celAuthoring.ts @@ -428,7 +428,7 @@ function loadRowCanon(): Promise { * not re-derived — and the engine's own message ships with it. * * ⚠️ The reason is NOT that the engine's message refuses a `record.` - * rewrite. Measured against the installed 17.4.0, it mostly PRESCRIBES one: the + * rewrite. Measured against the installed 17.5.0, it mostly PRESCRIBES one: the * `current_user` text CONTAINS "To gate on record state, rewrite the predicate * against `record`." — one of the three remedies it offers, and NOT where it * ends (measured: `includes` true, `endsWith` false; every covered slot's text diff --git a/packages/app-shell/src/views/metadata-admin/clientValidation.flowNodeTypes.test.ts b/packages/app-shell/src/views/metadata-admin/clientValidation.flowNodeTypes.test.ts index 5d99536688..3d402c1872 100644 --- a/packages/app-shell/src/views/metadata-admin/clientValidation.flowNodeTypes.test.ts +++ b/packages/app-shell/src/views/metadata-admin/clientValidation.flowNodeTypes.test.ts @@ -32,12 +32,24 @@ import { describe, it, expect } from 'vitest'; import { validateMetadataDraft } from './clientValidation'; -/** A flow that differs from a clean one only in its single node's `type`. */ -const flowWithNodeType = (type: unknown) => ({ +/** + * A flow that differs from a clean one only in its single node's `type` — plus, + * for a type whose own contract requires a sibling block, that block. + * + * `@objectstack/spec` 17.5.0 refuses a `connector_action` node without a + * `connectorConfig` naming the connector and the action (objectui#11073). That + * is a refusal about the NODE's config, addressed to `nodes.N.connectorConfig`, + * ⛔ not a re-closed `type` enum — the next case pins exactly that — so the + * type-only fixture carries the block its type requires. + */ +const REQUIRED_SIBLING: Record> = { + connector_action: { connectorConfig: { connectorId: 'slack', actionId: 'post_message' } }, +}; +const flowWithNodeType = (type: unknown, extra: Record = REQUIRED_SIBLING[type as string] ?? {}) => ({ name: 'f1', label: 'F1', type: 'autolaunched', - nodes: [{ id: 'n1', type, label: 'N1' }], + nodes: [{ id: 'n1', type, label: 'N1', ...extra }], edges: [], }); @@ -55,6 +67,16 @@ describe('flow node `type` is an open string — the shim premise, re-measured', expect(res.ok).toBe(true); }); + it('refuses a `connector_action` with no `connectorConfig` at that block — never at `type` (17.5.0)', async () => { + // The new refusal, pinned where it lands. If it were addressed to + // `nodes.0.type` the open-string premise above would be back in doubt. + const res = await validateMetadataDraft('flow', flowWithNodeType('connector_action', {})); + expect(res.ok).toBe(false); + const paths = res.issues.map((i) => i.path); + expect(paths).toEqual(['nodes.0.connectorConfig']); + expect(paths).not.toContain('nodes.0.type'); + }); + it('still rejects an EMPTY node type, and that issue was never suppressed', async () => { const res = await validateMetadataDraft('flow', flowWithNodeType('')); expect(res.ok).toBe(false); diff --git a/packages/app-shell/src/views/metadata-admin/clientValidation.viewDiagnostics.test.ts b/packages/app-shell/src/views/metadata-admin/clientValidation.viewDiagnostics.test.ts index 5c8a75c78c..b62e493259 100644 --- a/packages/app-shell/src/views/metadata-admin/clientValidation.viewDiagnostics.test.ts +++ b/packages/app-shell/src/views/metadata-admin/clientValidation.viewDiagnostics.test.ts @@ -632,6 +632,14 @@ describe('view nested union — the sole-candidate rule (objectui#3678)', () => }; // ── CANARY ─────────────────────────────────────────────────────────────── + // + // ⚠️ Since `@objectstack/spec` 17.5.0, `view.sort` is NOT a union any more: + // the bare-string arm retired (objectui#11073 re-measured it — a plain + // `z.array` whose own `error` map answers a string with the retirement + // prescription). The three sort canaries below still pin what an author SEES, + // which did not move; they no longer exercise the sole-candidate rule, whose + // evidence is now the census's union rows (`columns`, `summary`, + // `filter[].value`). it('CANARY: a bad `order` on a sort row reports the key and the allowed options', async () => { // THE case #3678 was filed on. Before: `config.sort` / `Invalid input`. @@ -712,10 +720,12 @@ describe('view nested union — the sole-candidate rule (objectui#3678)', () => // ── MAINTAINED: the cells this rule must stay out of ───────────────────── it('k === 0 — every member rejected the type, so the collapse is kept', async () => { - // `sort: 42` is neither a string nor an array. Nothing distinguishes the - // members, and #3626 already ruled that inventing a preference is not ours. - expect(await bothGates(withConfig({ sort: 42 }))).toEqual([ - { path: 'config.sort', message: 'Invalid input' }, + // `columns: 'nope'` is an array under neither member. Nothing distinguishes + // the members, and #3626 already ruled that inventing a preference is not + // ours. (This row used `sort: 42` until `view.sort` stopped being a union + // at `@objectstack/spec` 17.5.0; objectui#11073.) + expect(await bothGates(withConfig({ columns: 'nope' }))).toEqual([ + { path: 'config.columns', message: 'Invalid input' }, ]); }); @@ -742,7 +752,8 @@ describe('view nested union — the sole-candidate rule (objectui#3678)', () => // that spoke shows up as a nearer path OR as a named message, and each can // happen without the other: // - // selected member's issue is DEEPER → path moves (`sort` → `…sort.0.order`) + // selected member's issue is DEEPER → path moves (`sort` → `…sort.0.order` + // through 17.4.0; `filter[].value` now) // selected member's issue is AT the → path stays, message stops being // union node (a scalar member) `Invalid input` (`summary: 'bogus'`) // descent ends in a `none` cell → path moves, message stays @@ -767,10 +778,10 @@ describe('view nested union — the sole-candidate rule (objectui#3678)', () => cell: 'sole' | 'content' | 'none'; collapsedMessage: boolean; }> = [ - { label: 'sort: bad order', body: withConfig({ sort: [{ field: 'n', order: 'bogus' }] }), unionAt: 'config.sort', reportedAt: 'config.sort.0.order', cell: 'sole', collapsedMessage: false }, - { label: 'sort: of field names', body: withConfig({ sort: ['name'] }), unionAt: 'config.sort', reportedAt: 'config.sort.0', cell: 'sole', collapsedMessage: false }, - { label: 'sort: of numbers', body: withConfig({ sort: [42] }), unionAt: 'config.sort', reportedAt: 'config.sort.0', cell: 'sole', collapsedMessage: false }, - { label: 'sort: a bare number', body: withConfig({ sort: 42 }), unionAt: 'config.sort', reportedAt: 'config.sort', cell: 'none', collapsedMessage: true }, + // The four `sort` rows that opened this census left it at `@objectstack/spec` + // 17.5.0, when `view.sort` stopped being a union (objectui#11073): a plain + // array reports at its natural path with no rule speaking, so keeping them + // would read "a rule spoke" off a path no rule chose. { label: 'columns: bad key type', body: withConfig({ columns: [{ field: 123 }] }), unionAt: 'config.columns', reportedAt: 'config.columns.0.field', cell: 'content', collapsedMessage: false }, { label: 'columns: stray element', body: withConfig({ columns: ['a', 42] }), unionAt: 'config.columns', reportedAt: 'config.columns.1', cell: 'content', collapsedMessage: false }, { label: 'columns: elected by nothing', body: withConfig({ columns: [42] }), unionAt: 'config.columns', reportedAt: 'config.columns', cell: 'none', collapsedMessage: true }, @@ -830,7 +841,9 @@ describe('view verdict parity — sole-candidate bodies (objectui#3678)', () => }); const CASES: Array<{ label: string; body: unknown; ok: boolean }> = [ - { label: 'sort as a field name', body: withConfig({ sort: 'name' }), ok: true }, + // `ok: true` through `@objectstack/spec` 17.4.0; 17.5.0 retired the bare + // string clause and refuses it with its prescription (objectui#11073). + { label: 'sort as a field name', body: withConfig({ sort: 'name' }), ok: false }, { label: 'sort as an empty array', body: withConfig({ sort: [] }), ok: true }, { label: 'sort as column sorts', body: withConfig({ sort: [{ field: 'n', order: 'asc' }] }), ok: true }, { label: 'sort with a bad order', body: withConfig({ sort: [{ field: 'n', order: 'bogus' }] }), ok: false }, diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/FlowKeyValueField.valueEnvelope.test.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/FlowKeyValueField.valueEnvelope.test.tsx index 298740a58b..74a89852bb 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/FlowKeyValueField.valueEnvelope.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/inspectors/FlowKeyValueField.valueEnvelope.test.tsx @@ -48,7 +48,7 @@ function renderField(field: FlowConfigField, nodeType: string, value: unknown) { } describe('assignment value cell — text or CEL expression (objectui#7588)', () => { - it('offers the toggle on the assignment map and on no other key/value map', () => { + it('offers the toggle on the assignment map, on the record field maps since 17.5.0, and on no other key/value map', () => { renderField(ASSIGNMENTS, 'assignment', { label: '{record.name}', digest: { dialect: 'cel', source: 'joinNonEmpty(names, ", ")' }, @@ -60,7 +60,25 @@ describe('assignment value cell — text or CEL expression (objectui#7588)', () expect(screen.queryByRole('alert')).toBeNull(); cleanup(); - renderField(FIELD_VALUES, 'create_record', { name: '{record.name}' }); + // `@objectstack/spec` 17.5.0 declares `create_record` / `update_record` + // `fields.*` as `value`-role slots in its expression ledger, so the same + // choice reaches those maps (objectui#11073). Through 17.4.0 this row + // asserted NO toggle on `create_record`'s field values. + for (const nodeType of ['create_record', 'update_record']) { + renderField(FIELD_VALUES, nodeType, { name: '{record.name}', total: { dialect: 'cel', source: 'a + b' } }); + expect( + screen.getAllByRole('button', { name: TOGGLE }).map((b) => b.getAttribute('aria-pressed')), + `${nodeType}: one toggle per value, the envelope pressed`, + ).toEqual(['false', 'true']); + expect(screen.getByDisplayValue('a + b')).toBeTruthy(); + cleanup(); + } + + // Control: a key/value map the ledger gives no `value` role keeps plain + // text cells — an object naming a `dialect` there is just data. + renderField({ id: 'input', path: ['config', 'input'], label: 'Input mapping', kind: 'keyValue' }, 'subflow', { + name: '{record.name}', + }); expect(screen.queryAllByRole('button', { name: TOGGLE })).toHaveLength(0); }); diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeInspector.declaredDefault.test.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeInspector.declaredDefault.test.tsx index cf302e9e00..02da178e8f 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeInspector.declaredDefault.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeInspector.declaredDefault.test.tsx @@ -62,7 +62,7 @@ */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { render, screen, cleanup } from '@testing-library/react'; +import { render, screen, cleanup, fireEvent } from '@testing-library/react'; import { z } from 'zod'; // Mutable so a case can publish a server `configSchema` for one node type and @@ -94,7 +94,9 @@ import { ApprovalNodeConfigSchema, EndConfigSchema, FlowNodeSchema, + ScreenConfigSchema, } from '@objectstack/spec/automation'; +import { defaultNodeExtras } from '../previews/flow-canvas-parts'; /* ── The `meta/*` double (objectui#7307) ─────────────────────────────── * `FlowNodeInspector` renders `FlowReferenceField` for every reference-kind key @@ -379,7 +381,7 @@ describe('boolean: a declared defaultValue seeds the control (objectui#8451, arm // ⇒ `screen.waitForInput` is the offline undeclared boolean that sentence // says does not exist — and it is undeclared CORRECTLY, so ⛔ do not read // this as an objectui#9277-class omission waiting to be declared. Measured - // on the installed `@objectstack/spec` (17.4.0): `waitForInput` is typed + // on the installed `@objectstack/spec` (17.5.0): `waitForInput` is typed // `z.boolean().optional()` with no `.default(...)`, so an omitted key // materialises nothing and there is no spec answer for a declaration to // mirror. Declaring one here would invent a default the runtime does not @@ -806,9 +808,12 @@ describe('objectui#9109: the form states no default the installed spec does not * on the trigger. ⚠️ `was` is HISTORY — nothing re-derives it, and it is here * only so a failure says what was removed. The live claim is the em-dash. */ + // `screen` · `Form mode` was the second row. `@objectstack/spec` 17.5.0 gave + // `ScreenConfigSchema.mode` a `.default('create')`, so the declaration came + // back, backed this time, and its trigger states the default again — pinned + // positively below (objectui#11073). const GONE: readonly GoneRow[] = [ { type: 'http_request', node: { config: {} }, label: 'Method', was: 'GET' }, - { type: 'screen', node: { config: {} }, label: 'Form mode', was: 'Create' }, { type: 'wait', node: { waitEventConfig: {} }, label: 'Wait for', was: 'Timer' }, { type: 'boundary_event', @@ -857,6 +862,42 @@ describe('objectui#9109: the form states no default the installed spec does not }, ); + it('screen: Form mode states the default the spec applies since 17.5.0, as a placeholder (objectui#11073)', () => { + // Derived from the installed spec, as the end.outcome rows derive theirs: + // `ScreenConfigSchema` materialises a mode for an omitted key. + const specMode = (ScreenConfigSchema.parse({}) as { mode?: string }).mode; + expect(specMode, 'ScreenConfigSchema applies a default to an omitted mode').toBeTypeOf('string'); + const field = fieldsForNodeType('screen').find((f) => f.id === 'mode'); + expect(field?.defaultValue, 'screen.mode declares the default the spec applies').toBe(specMode); + const expected = field?.options?.find((o) => o.value === specMode)?.label; + expect(expected, 'and the declared default names an offered option').toBeTypeOf('string'); + + const { onPatch } = renderInspector(draftWith('screen', { config: {} })); + expect(triggerText('Form mode'), 'the trigger states the default the runtime applies').toBe(expected); + expect(triggerIsPlaceholder('Form mode'), 'as a placeholder, never as a selection').toBe(true); + // Shown, never written (objectui#6263): rendering patches nothing. + expect(onPatch.mock.calls).toEqual([]); + }); + + it('wait: a freshly SEEDED node shows its timer Duration, filled and editable (objectui#11088 decision 2 = A)', () => { + // The seed the designer now writes: `@objectstack/spec` 17.5.0 refuses a + // timer wait with no duration, so the fresh node carries the spec's own + // example value, and the author sees it and can change it at once. + const seed = defaultNodeExtras('wait'); + expect(seed).toEqual({ waitEventConfig: { eventType: 'timer', timerDuration: 'PT1H' } }); + const { onPatch } = renderInspector(draftWith('wait', seed)); + expect(labelled('Duration'), 'the Duration control is on screen for a timer wait').not.toBeNull(); + const duration = screen.getByDisplayValue('PT1H') as HTMLInputElement; + expect(duration.disabled || duration.readOnly, 'and it is an editable control').toBe(false); + fireEvent.change(duration, { target: { value: 'P3D' } }); + fireEvent.blur(duration); + const written = onPatch.mock.calls.at(-1)?.[0] as { nodes?: Array> } | undefined; + expect( + (written?.nodes?.[0]?.waitEventConfig as Record | undefined)?.timerDuration, + 'an edit writes the spec key, not a copy beside it', + ).toBe('P3D'); + }); + it('wait: the timer-only sibling now waits for a real eventType, and a STORED one still shows', () => { // The on-screen half of the false claim. `controllerAdmits` resolved the // unset controller through the declared `timer`, so `timerDuration` was @@ -919,7 +960,7 @@ describe('objectui#9109: the form states no default the installed spec does not describe('the declaration surface this card names', () => { /** - * The nine declaring fields, as `.`. Triage named + * The ten declaring fields, as `.`. Triage named * this list the acceptance surface, so it is pinned: a PR that retires the * property, or that adds a tenth declaration, moves this line. * @@ -928,8 +969,10 @@ describe('the declaration surface this card names', () => { * `wait.waitEventConfig.eventType`, `boundary_event.boundaryConfig.eventType`) * — triage's direction B, on the ground that the `defaultValue` doc comment * defines the property AS the spec default for its key, so a declaration with - * no spec counterpart is false by that definition. The nine that remain are - * the nine the installed spec actually applies, reconciled key by key in + * no spec counterpart is false by that definition. The nine that remained were + * the nine the installed spec applied; `@objectstack/spec` 17.5.0 began + * applying `screen.mode`'s, so the declaration returned and there are ten + * (objectui#11073), each reconciled key by key in * `flow-node-config.spec-reconciliation.test.ts`. * * ⚠️ The number is NOT a constant to copy, and it is not one card's @@ -953,7 +996,7 @@ describe('the declaration surface this card names', () => { */ const OFF_PICKER_TYPES = ['boundary_event', 'parallel_gateway', 'join_gateway', 'legacy_action', 'notify']; - it('exactly nine fields declare a defaultValue, and these are they', () => { + it('exactly ten fields declare a defaultValue, and these are they', () => { const swept = [...FLOW_NODE_TYPE_OPTIONS, ...OFF_PICKER_TYPES]; expect( FLOW_NODE_TYPE_OPTIONS.every((t) => swept.includes(t)), @@ -976,6 +1019,10 @@ describe('the declaration surface this card names', () => { 'approval.onEmptyApprovers', 'boundary_event.boundaryConfig.interrupting', 'end.outcome', + // The tenth since `@objectstack/spec` 17.5.0 applies `'create'` to an + // omitted mode (objectui#11073) — the declaration objectui#9109 deleted + // while the spec applied none, back now that it does. + 'screen.mode', ].sort()); }); @@ -1013,12 +1060,13 @@ describe('the declaration surface this card names', () => { expect( cases.map((c) => c.id).sort(), - 'the select-kind half of the declaration surface — four of the nine', + 'the select-kind half of the declaration surface — five of the ten', ).toEqual([ 'approval.behavior', 'approval.escalation.action', 'approval.onEmptyApprovers', 'end.outcome', + 'screen.mode', ]); for (const c of cases) { diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/ReportDefaultInspector.joinedClearsBinding-10746.test.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/ReportDefaultInspector.joinedClearsBinding-10746.test.tsx index 06ed49a3a7..cf5656c59f 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/ReportDefaultInspector.joinedClearsBinding-10746.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/inspectors/ReportDefaultInspector.joinedClearsBinding-10746.test.tsx @@ -21,12 +21,15 @@ * second kept every one of those keys INVISIBLY; its save was then refused at a * path no control on the Properties tab could reach. * - * What the installed `@objectstack/spec` can and cannot measure here: 17.4.0 at - * the time of writing predates PR #20160, so it already refuses a container - * `order` and still ACCEPTS the four selection keys. The parse leg below shows - * the `order` half of the refusal going away; for the four keys the pins assert - * ABSENCE, against the rule quoted above. ⛔ Never weaken those to "parses under - * the installed spec" — that read green on the defect too. + * What the installed `@objectstack/spec` can measure here: when this was + * written the spec in the lockfile predated PR #20160 — it refused a container + * `order` and still ACCEPTED the four selection keys — so the four were pinned + * as ABSENCE, against the rule quoted above. 17.5.0 (objectui#11073) carries + * #20160: re-measured, a joined container with `dataset` is refused at + * `dataset` with that rule's own sentence. So the final parse leg below now + * covers all five keys, and the absence pins stay as they are. ⛔ Never weaken + * them to "parses under the installed spec" — against a spec without #20160 + * that read green on the defect too. * * ⚠️ Assertion spelling. `toHaveBeenCalledWith` and `toEqual` treat an * `undefined`-valued key as absent, so `{ type: 'joined', dataset: undefined }` diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/datasetFilterCondition.readHalfHolds-10257.test.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/datasetFilterCondition.readHalfHolds-10257.test.tsx index 206e64a411..b79cd9425a 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/datasetFilterCondition.readHalfHolds-10257.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/inspectors/datasetFilterCondition.readHalfHolds-10257.test.tsx @@ -137,9 +137,23 @@ describe('class 1 — an incomplete stored value is not opened as a row the next { region: [] }, ]; - it('reachable: the spec accepts every one of them, so a Source-tab or AI-authored filter can carry it', () => { + it('reachable: the spec accepts all but one of them, so a Source-tab or AI-authored filter can carry it', () => { + // Through `@objectstack/spec` 17.4.0 the spec accepted EVERY row. 17.5.0 + // refuses the bare-array implicit-equality comparand (`{ region: [] }`) at + // the door, by name, with its `$in` prescription (objectui#11073) — so that + // one row is reachable now only as a document stored before the bump, and + // the read-half cases below keep it for exactly that reason. + const refusedSince1750 = JSON.stringify({ region: [] }); for (const c of INCOMPLETE) { - expect(FilterConditionSchema.safeParse(beside(c)).success, JSON.stringify(c)).toBe(true); + const parsed = FilterConditionSchema.safeParse(beside(c)); + if (JSON.stringify(c) === refusedSince1750) { + expect(parsed.success, JSON.stringify(c)).toBe(false); + expect(parsed.success ? [] : parsed.error.issues.map((i) => `${i.code} @ ${i.path.join('.')}`)).toEqual([ + 'custom @ $and.1.region', + ]); + continue; + } + expect(parsed.success, JSON.stringify(c)).toBe(true); } expect(FieldOperatorsSchema.safeParse({ $eq: '' }).success).toBe(true); expect(FieldOperatorsSchema.safeParse({ $in: [] }).success).toBe(true); diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/expression-envelope.test.ts b/packages/app-shell/src/views/metadata-admin/inspectors/expression-envelope.test.ts index 09946bc591..ae1ff2364e 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/expression-envelope.test.ts +++ b/packages/app-shell/src/views/metadata-admin/inspectors/expression-envelope.test.ts @@ -10,7 +10,9 @@ * everything else in the envelope must survive that edit. * * Fixtures are authored input fed through `HookSchema.parse` — never - * hand-written envelopes — so they cannot drift from the spec. + * hand-written envelopes — so they cannot drift from the spec. The one + * exception is the ast-only envelope, which `@objectstack/spec` 17.5.0 refuses + * to produce; its row says why (objectui#11073). */ import { describe, it, expect } from 'vitest'; @@ -47,9 +49,24 @@ describe('expressionSource — read (#3218)', () => { // `disabled` is `boolean | ExpressionInput` on an action — a boolean is // not an expression and must not be stringified into the editor. expect(expressionSource(true)).toBe(''); - // An AST-only envelope (spec phase M9.2) has no readable source YET; - // `conditionText` says so rather than inventing text. - expect(expressionSource(storedCondition({ dialect: 'cel', ast: { op: 'gt' } }))).toBe(''); + // An AST-only envelope (spec phase M9.2) has no readable source; + // `conditionText` says so rather than inventing text. Through + // `@objectstack/spec` 17.4.0 `HookSchema.parse` produced one; 17.5.0 + // REFUSES it at `condition` (an evaluated slot needs a non-blank `source`), + // so the spec can no longer mint this fixture (objectui#11073). The read + // still owes an answer for the one that is already STORED — the exact + // object 17.4.0 parsed to, which a hand-written literal now reproduces. + const astOnly = { dialect: 'cel', ast: { op: 'gt' } }; + const refused = HookSchema.safeParse({ + name: 'guard_hook', + object: 'lead', + events: ['beforeInsert'], + handler: 'guard_fn', + condition: astOnly, + }); + expect(refused.success, 'the spec refuses an ast-only envelope since 17.5.0').toBe(false); + expect(refused.success ? [] : refused.error.issues.map((i) => i.path.join('.'))).toContain('condition'); + expect(expressionSource(astOnly)).toBe(''); // Never `[object Object]` — the generic SchemaForm condition widget used // to `String(value)` an envelope straight into the editor. expect(expressionSource({ dialect: 'cel', source: 'a > 1' })).not.toContain('object Object'); diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.spec-reconciliation.test.ts b/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.spec-reconciliation.test.ts index c7b0010bc9..9130a01b5a 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.spec-reconciliation.test.ts +++ b/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.spec-reconciliation.test.ts @@ -416,7 +416,11 @@ describe('declared defaults ↔ per-node-type spec schemas (#6794, #6620, object type: 'wait', prefix: ['waitEventConfig'], schema: () => unwrapped(flowNodeShape?.waitEventConfig), - supplied: { eventType: 'timer' }, + // `timerDuration` joined the minimal region at `@objectstack/spec` 17.5.0, + // which REFUSES a timer wait with no duration (it would park the run + // forever while reporting success). `'PT1H'` is the value the designer now + // seeds (objectui#11088 decision 2 = A; objectui#11073). + supplied: { eventType: 'timer', timerDuration: 'PT1H' }, }, { type: 'boundary_event', @@ -717,7 +721,9 @@ describe('declared defaults ↔ per-node-type spec schemas (#6794, #6620, object * ⭐ **objectui#9109's deletion, pinned from the spec side.** * * The four declarations this card removed, each named with the spec state - * that made removal the right end to move. This is the register's + * that made removal the right end to move. Three remain rows: the spec side + * of the fourth, `screen:config.mode`, moved at 17.5.0 and the declaration + * came back as the first bullet below prescribes (objectui#11073). This is the register's * re-measurement applied to keys that now carry NO row, and it is what keeps * the two ends connected after the register emptied: * @@ -747,7 +753,10 @@ describe('declared defaults ↔ per-node-type spec schemas (#6794, #6620, object { region: 'wait:waitEventConfig', key: 'eventType', was: 'timer', state: 'required-no-default' }, { region: 'boundary_event:boundaryConfig', key: 'eventType', was: 'error', state: 'required-no-default' }, { region: 'http_request:config', key: 'method', was: 'GET', state: 'optional-no-default' }, - { region: 'screen:config', key: 'mode', was: 'create', state: 'optional-no-default' }, + // `screen:config.mode` was the fourth row. It left at `@objectstack/spec` + // 17.5.0, exactly the way this block says a row leaves: the spec began + // materialising `'create'` for an omitted key, so the form declares it + // again — pinned from the spec side in the next case (objectui#11073). ]; it.each(DELETED_BY_9109)( @@ -767,6 +776,23 @@ describe('declared defaults ↔ per-node-type spec schemas (#6794, #6620, object }, ); + it('objectui#11073: screen:config · mode — the spec materialises its default since 17.5.0, and the form declares the same value again', () => { + const scope = SCOPES.find((s) => scopeId(s.type, s.prefix) === 'screen:config')!; + const field = fieldsInScope(scope).get('mode'); + const materialised = specDefaults(scope).mode; + // The spec side, measured: an omitted `mode` now parses to a value. + expect(materialised, 'ScreenConfigSchema must materialise a mode for an omitted key').toBeTypeOf('string'); + // The form side: the same select, now DECLARING that value — the end + // objectui#9109's pin named for the day the spec started applying one. + expect(field, 'screen:config.mode: the editor still exists').toBeDefined(); + expect(field!.kind).toBe('select'); + expect(field!.defaultValue, 'screen:config.mode declares the default the spec applies').toBe(materialised); + expect( + (field!.options ?? []).map((o) => o.value), + 'and the declared default is one of the offered options', + ).toContain(materialised); + }); + // ⛔ THE REGISTER'S RE-MEASUREMENT, and it walks `SCOPES` rather than // `UNDECLARED_REGISTER` on purpose. Written as `it.each(UNDECLARED_REGISTER)` // it contributed one case per row — so a register that empties contributes NO diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.ts b/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.ts index ee31b4e020..a7d3f12a60 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.ts +++ b/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.ts @@ -651,6 +651,19 @@ const FLOW_NODE_CONFIG: Record = { help: 'Inert — nothing reads this. The engine only honours `condition` on a Start node (the trigger gate); on a Decision it gates nothing. Shown so a stored value is not invisible. Move the predicate to a branch above, or to the outgoing edge’s own condition, then clear this.', showWhen: { field: '__legacy__', equals: [] }, }), + // `@objectstack/spec` 17.5.0 added `DecisionConfigSchema.mode`, and the + // decision executor reads it (objectui#11073). An OPTIONAL enum with NO + // `.default()`, so ⛔ no `defaultValue` here, the objectui#9109 rule: the + // spec's own text says an omitted mode means `exclusive`, but it + // materialises nothing, and a declaration would state a default the spec + // does not apply. The spec refuses it beside a non-empty `conditions` list. + cfg('mode', 'Branch mode', 'select', { + options: [ + { value: 'exclusive', label: 'Exclusive' }, + { value: 'inclusive', label: 'Inclusive' }, + ], + help: 'For a decision that branches on its out-edge conditions: Exclusive takes only the first edge that holds, in edge order (what leaving this unset means); Inclusive takes every edge that holds. The default edge runs when none holds. Not allowed together with Branches above.', + }), ], assignment: [ cfg('assignments', 'Assignments', 'keyValue', { @@ -806,12 +819,14 @@ const FLOW_NODE_CONFIG: Record = { placeholder: 'account_id', help: 'Object form only: variable bound to the saved record\u2019s id, for later steps.', }), - // ⛔ No `defaultValue` (objectui#9109): `ScreenConfigSchema.mode` is an - // OPTIONAL enum with no `.default()`, so an omitted key materialises - // nothing. Same shape as `http_request.method` above — whether the executor - // applies create-mode is NOT MEASURED here, and a declaration would state a - // spec default that does not exist. + // objectui#9109 deleted this field's `defaultValue` while + // `ScreenConfigSchema.mode` was an OPTIONAL enum with no `.default()`. + // `@objectstack/spec` 17.5.0 gave it one — an omitted key now materialises + // `'create'` — so the declaration returns, as that card's pin said it would + // the day the spec started applying one, with the spec's own value + // (objectui#11073). Shown, never written (objectui#6263). cfg('mode', 'Form mode', 'select', { + defaultValue: 'create', options: [ { value: 'create', label: 'Create' }, { value: 'edit', label: 'Edit' }, diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/flow-value-envelope.test.ts b/packages/app-shell/src/views/metadata-admin/inspectors/flow-value-envelope.test.ts index 9e048436c7..6a5275fa90 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/flow-value-envelope.test.ts +++ b/packages/app-shell/src/views/metadata-admin/inspectors/flow-value-envelope.test.ts @@ -8,7 +8,12 @@ */ import { describe, it, expect } from 'vitest'; -import { ASSIGNMENT_VALUE_ENVELOPE_REFUSAL, FLOW_NODE_EXPRESSION_PATHS } from '@objectstack/spec/automation'; +import { + ASSIGNMENT_VALUE_ENVELOPE_REFUSAL, + CreateRecordConfigSchema, + FLOW_NODE_EXPRESSION_PATHS, + UpdateRecordConfigSchema, +} from '@objectstack/spec/automation'; import { isEditableValueEnvelope, isValueEnvelopeSlot, @@ -29,26 +34,58 @@ describe('isValueEnvelopeSlot — read off the spec expression ledger (objectui# expect(isValueEnvelopeSlot(undefined, ['config', 'assignments'])).toBe(false); }); - it('offers the envelope on exactly one hand-table key/value map: assignment.assignments', () => { + it('offers the envelope on exactly the three hand-table key/value maps the ledger names', () => { // The key/value editor serves many maps (params, headers, filters, inputs) // where an object naming a `dialect` is plain data. Every keyValue field in // the designer table is enumerated here. If the ledger declares another // `value`-role map, this goes red, and whoever widens it has to confirm // that `AssignmentValueSchema` is the right refusal for the new slot. + // + // It went red at `@objectstack/spec` 17.5.0, which declares + // `create_record` / `update_record` `fields.*` as `value`-role slots, and the + // confirmation it asks for is the next case (objectui#11073). const keyValueFields = FLOW_NODE_TYPE_OPTIONS.flatMap((type) => fieldsForNodeType(type) .filter((f) => f.kind === 'keyValue') .map((f) => ({ type, path: f.path })), ); - expect(keyValueFields.length).toBeGreaterThan(1); + expect(keyValueFields.length).toBeGreaterThan(3); const offered = keyValueFields .filter(({ type, path }) => isValueEnvelopeSlot(type, path)) .map(({ type, path }) => `${type}:${path.join('.')}`); - expect(offered).toEqual(['assignment:config.assignments']); - // The same answer the ledger gives: one value-role entry, on that map. - expect(FLOW_NODE_EXPRESSION_PATHS.filter((e) => e.role === 'value').map((e) => `${e.nodeType}:${e.path}`)).toEqual([ - 'assignment:assignments.*', + expect(offered.sort()).toEqual([ + 'assignment:config.assignments', + 'create_record:config.fields', + 'update_record:config.fields', ]); + // The same answer the ledger gives: three value-role entries, on those maps. + expect( + FLOW_NODE_EXPRESSION_PATHS.filter((e) => e.role === 'value').map((e) => `${e.nodeType}:${e.path}`).sort(), + ).toEqual(['assignment:assignments.*', 'create_record:fields.*', 'update_record:fields.*']); + }); + + it('confirms `AssignmentValueSchema` is the right refusal for the two record field maps', () => { + // The confirmation the case above demands, measured: each record node's own + // config schema refuses a malformed envelope in `fields` with exactly the + // messages the editor shows (`valueEnvelopeRefusal`), and accepts the + // well-formed envelope and a plain `{token}` string it accepts. + const schemas = { + create_record: CreateRecordConfigSchema, + update_record: UpdateRecordConfigSchema, + } as const; + const MALFORMED = [{ dialect: 'cel' }, { dialect: 'js', source: 'a' }]; + for (const [nodeType, schema] of Object.entries(schemas)) { + for (const bad of MALFORMED) { + const parsed = schema.safeParse({ objectName: 'account', fields: { name: bad } }); + expect(parsed.success, `${nodeType}: ${JSON.stringify(bad)} is refused`).toBe(false); + const messages = parsed.success ? [] : parsed.error.issues.map((i) => i.message); + expect(messages, `${nodeType}: with the same sentences the editor shows`).toEqual(valueEnvelopeRefusal(bad)); + } + for (const good of [{ dialect: 'cel', source: 'a + 1' }, '{record.name}']) { + expect(schema.safeParse({ objectName: 'account', fields: { name: good } }).success).toBe(true); + expect(valueEnvelopeRefusal(good)).toBeNull(); + } + } }); }); diff --git a/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config-schema-parity-8216.test.ts b/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config-schema-parity-8216.test.ts index c8cb98fa2a..d51e74d6a5 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config-schema-parity-8216.test.ts +++ b/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config-schema-parity-8216.test.ts @@ -180,16 +180,10 @@ function oraclesFor(blockType: string): Array<{ face: OracleFace; schema: unknow * "every exemption names a real block" below would have failed on it. */ const EXEMPT: Readonly> = { - 'element:definition-list': { - reason: - 'objectui-native element block: absent from PageComponentType and ComponentPropsMap, and no @object-ui/types/zod arm declares it. Its declared face is the registry `inputs` list in components/renderers/basic/data-list.tsx, judged by a different gate (objectui#8067/objectui#8068) that reads top-level inputs only. A live item-key mismatch this gate structurally cannot see is objectui#8279.', - card: 'objectui#8281', - }, - 'element:repeater': { - reason: - 'objectui-native element block: absent from PageComponentType and ComponentPropsMap, and no @object-ui/types/zod arm declares it. Same registry-`inputs`-only face as element:definition-list.', - card: 'objectui#8281', - }, + // `element:definition-list` and `element:repeater` were the two rows here + // until `@objectstack/spec` 17.5.0 gave both a `ComponentPropsMap` entry + // (objectui#11073). The self-deleting row below turned red, as written, and + // both rows went: the two blocks are judged on the SPEC face now. }; /* ── ledger ───────────────────────────────────────────────────────────────── */ diff --git a/packages/app-shell/src/views/metadata-admin/previews/flow-canvas-parts.tsx b/packages/app-shell/src/views/metadata-admin/previews/flow-canvas-parts.tsx index 1354d85241..1c5882864f 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/flow-canvas-parts.tsx +++ b/packages/app-shell/src/views/metadata-admin/previews/flow-canvas-parts.tsx @@ -390,9 +390,16 @@ export function defaultNodeExtras(type: string): Record { // No `onTimeout`: retired in spec 17 (framework#4158) as a `retiredKey()` // tombstone, so writing it is a hard `FlowNodeSchema` error, not a // silently-stripped extra. A wait node has no timeout — it resumes when - // its timer elapses or its signal arrives. The author fills in - // `timerDuration`. - return { waitEventConfig: { eventType: 'timer' } }; + // its timer elapses or its signal arrives. + // + // `timerDuration: 'PT1H'` is seeded because `@objectstack/spec` 17.5.0 + // REFUSES a timer wait without one: a timer with no duration schedules no + // wake-up and parks the run forever while reporting success. The value is + // the spec's own example, ruled as this seed by the maintainer on + // objectui#11088 (decision 2 = A) — a product default they may change — + // and it is visible and editable at once, because the inspector shows + // `Duration` whenever `eventType` is `timer`. + return { waitEventConfig: { eventType: 'timer', timerDuration: 'PT1H' } }; case 'connector_action': return { connectorConfig: { connectorId: '', actionId: '', input: {} } }; case 'boundary_event': diff --git a/packages/app-shell/src/views/metadata-admin/previews/flow-canvas-seeds.spec-parse.test.tsx b/packages/app-shell/src/views/metadata-admin/previews/flow-canvas-seeds.spec-parse.test.tsx index f497ac1c91..33f36c92e8 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/flow-canvas-seeds.spec-parse.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/previews/flow-canvas-seeds.spec-parse.test.tsx @@ -215,11 +215,24 @@ describe('designer node seeds ↔ spec FlowNodeSchema (#3316)', () => { expect(offenders, 'designer seeds writing non-authorable keys').toEqual([]); }); - it('`wait` seeds only the timer flavor — no retired timeout keys', () => { + it('`wait` seeds the timer flavor WITH a duration — no retired timeout keys', () => { // Pinned by name because this is the shape #3316 found in the field: the // wait node has NO timeout (framework#4158), it resumes when its timer - // elapses or its signal arrives. `timerDuration` is the author's to fill. - expect(defaultNodeExtras('wait')).toEqual({ waitEventConfig: { eventType: 'timer' } }); + // elapses or its signal arrives. Through `@objectstack/spec` 17.4.0 + // `timerDuration` was the author's to fill; 17.5.0 refuses a timer wait + // without one (it would park the run forever), so the seed carries the + // spec's own example value, ruled by the maintainer on objectui#11088 + // (decision 2 = A) and changeable there (objectui#11073). + expect(defaultNodeExtras('wait')).toEqual({ waitEventConfig: { eventType: 'timer', timerDuration: 'PT1H' } }); + // …and the refusal it answers is real, on the same instrument: the old seed + // is refused at `waitEventConfig.timerDuration` by name. + const oldSeed = Automation.FlowNodeSchema.safeParse({ + id: 'w', type: 'wait', label: 'Wait', waitEventConfig: { eventType: 'timer' }, + }); + expect(oldSeed.success).toBe(false); + expect(oldSeed.success ? [] : oldSeed.error.issues.map((i) => i.path.join('.'))).toContain( + 'waitEventConfig.timerDuration', + ); }); it('the config-rooted seeds write only live keys of the spec node-config Zods', () => { diff --git a/packages/app-shell/src/views/metadata-admin/previews/object-fields-io.prototypeKey-9237.test.ts b/packages/app-shell/src/views/metadata-admin/previews/object-fields-io.prototypeKey-9237.test.ts index 06a10901a3..1da5e8b5ca 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/object-fields-io.prototypeKey-9237.test.ts +++ b/packages/app-shell/src/views/metadata-admin/previews/object-fields-io.prototypeKey-9237.test.ts @@ -144,23 +144,32 @@ describe('the full round trip — `readFields` then `writeFields`', () => { expect(Object.keys(sent)).toEqual(['title', '__proto__', 'owner_ref']); }); - it('the emitted document is spec-legal — which is exactly why the loss was silent', () => { - // This half is the severity, not the repair. `ObjectSchema` ACCEPTS the - // body with the field and ACCEPTS it without: the validator can never be - // the thing that notices, so the pin above is the only instrument there - // is. `fields`' key grammar is /^[a-z_][a-z0-9_]*$/, which admits - // `__proto__`. - expect(ObjectSchema.safeParse(emittedBody(storedFields())).success).toBe(true); + it('the spec still ACCEPTS the mutilated body — which is exactly why the loss was silent', () => { + // This half is the severity, not the repair. `fields`' key grammar is + // /^[a-z_][a-z0-9_]*$/, which admits `__proto__`, and through + // `@objectstack/spec` 17.4.0 `ObjectSchema` ACCEPTED the body with the field + // AND without it. 17.5.0 refuses the KEPT key by name (`z.record()` would + // drop it from its output while reporting success) — but it still accepts + // the body that already lost it, so the validator can still never notice + // the loss, and the pins above remain the only instrument there is. What + // the repair buys since 17.5.0 is that the refusal reaches the author at + // all (objectui#11073 re-pin). + const kept = ObjectSchema.safeParse(emittedBody(storedFields())); + expect(kept.success ? [] : kept.error.issues.map((i) => `${i.code} @ ${i.path.join('.')}`)).toEqual([ + 'custom @ fields.__proto__', + ]); const mutilated = { name: 'account', label: 'Account', fields: { title: { type: 'text', label: 'Title' } } }; expect(ObjectSchema.safeParse(mutilated).success).toBe(true); }); it('control: nothing about ordinary names changed', () => { - const body = roundTrip( - JSON.parse('{"title":{"type":"text","label":"Title"},"owner_ref":{"type":"text","label":"Owner"}}'), - ) as Record; + const ordinary = JSON.parse('{"title":{"type":"text","label":"Title"},"owner_ref":{"type":"text","label":"Owner"}}'); + const body = roundTrip(ordinary) as Record; expect(Object.keys(body)).toEqual(['title', 'owner_ref']); - expect(ObjectSchema.safeParse(emittedBody(storedFields())).success).toBe(true); + // Parsed on the ORDINARY body: this row used to parse the `__proto__` + // fixture, which answered the same as this one only while the spec still + // accepted that name (through 17.4.0; objectui#11073). + expect(ObjectSchema.safeParse(emittedBody(ordinary)).success).toBe(true); }); it('control: the array shape carries the name as a VALUE and was never affected', () => { diff --git a/packages/app-shell/src/views/metadata-admin/previews/simulator/__tests__/flow-simulator.test.ts b/packages/app-shell/src/views/metadata-admin/previews/simulator/__tests__/flow-simulator.test.ts index 9029c46c82..e9175fb06d 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/simulator/__tests__/flow-simulator.test.ts +++ b/packages/app-shell/src/views/metadata-admin/previews/simulator/__tests__/flow-simulator.test.ts @@ -1,8 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { describe, it, expect } from 'vitest'; -import { FlowEdgeSchema } from '@objectstack/spec/automation'; -import { EVALUATED_EXPRESSION_SOURCE_REQUIRED } from '@objectstack/spec/shared'; +import { FlowEdgeSchema, STRUCTURAL_CONDITION_SHAPE_REFUSAL } from '@objectstack/spec/automation'; import { FlowSimulator } from '../flow-simulator'; import { validateFlowDraft, findCycle } from '../flow-sim-validate'; import type { SimEdge, SimNode } from '../flow-sim-types'; @@ -565,17 +564,24 @@ describe('decision guards in the spec expression envelope (#3216)', () => { // There is no CEL source to run, and the simulator's rule is to say so // rather than fake a result — the fix widened the reader, it did not make // every object a condition. It used to say "Branch has no condition." and - // take the default. The evaluated-slot rule objectstack main's edge schema - // applies (the installed 17.4.0 `FlowEdgeSchema`, which `specEdge` above - // uses, still admits this shape) (`EvaluatedExpressionSchema`, a non-blank - // `source`) refuses an `ast`-only envelope, so the runtime never registers - // the flow, and since + // take the default. The evaluated-slot rule the spec's edge schema applies + // (`EvaluatedExpressionSchema`, a non-blank `source`) refuses an `ast`-only + // envelope — `FlowEdgeSchema`, which `specEdge` above uses, admitted it + // through 17.4.0 and refuses it at `condition` since 17.5.0, the version + // this tree installs (objectui#11073) — so the runtime never registers the + // flow, and since // objectui#10615 the Debug run stops on it too. const astOnly: SimEdge = { id: 'e_hi', source: 'd', target: 'hi', condition: { dialect: 'cel', ast: { op: 'gt' } } }; const sim = run(NODES, [START, astOnly, { id: 'e_lo', source: 'd', target: 'lo', isDefault: true }], { amount: 20 }); const hiEval = edgeEval(sim, 'hi'); - expect(hiEval.error).toContain(EVALUATED_EXPRESSION_SOURCE_REQUIRED); + // The shape rule answers first since `@objectstack/spec` 17.5.0: its + // `structuralConditionRefusal` refuses an object carrying an `ast` but no + // string `source`, before the evaluated-slot rule is reached. Through + // 17.4.0 this row read `EVALUATED_EXPRESSION_SOURCE_REQUIRED`, the second + // rule's sentence (objectui#11073). + expect(hiEval.error).toContain(STRUCTURAL_CONDITION_SHAPE_REFUSAL); + expect(hiEval.error).toContain('`ast`'); expect(sim.state.status).toBe('error'); expect(sim.state.visitedNodeIds).not.toContain('lo'); }); diff --git a/packages/app-shell/src/views/metadata-admin/previews/simulator/flow-sim-validate.ts b/packages/app-shell/src/views/metadata-admin/previews/simulator/flow-sim-validate.ts index 21d0e8145d..dc1b40668e 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/simulator/flow-sim-validate.ts +++ b/packages/app-shell/src/views/metadata-admin/previews/simulator/flow-sim-validate.ts @@ -139,16 +139,17 @@ export type GuardEvaluation = * other value is read as "no condition". * 2. Shape: the spec's `structuralConditionRefusal`, the first thing * `registerFlow`'s edge pass and `evaluateCondition` do. A boolean, number, - * array or source-less object is refused. + * array or source-less object is refused — an `ast`-only envelope included, + * which `@objectstack/spec` 17.5.0 moved to this step (objectui#11073). * 3. Evaluated slot: the rule `FlowEdgeSchema.condition` applies on main * (`EvaluatedExpressionInputSchema`), from its two installed parts. A string * must be non-blank (main's `NON_BLANK_STRING`), so `''` and `' '` are * refused with the spec's `EVALUATED_EXPRESSION_SOURCE_REQUIRED`. Anything * else must satisfy `EvaluatedExpressionSchema`: an envelope with a * `dialect` (`ExpressionSchema` requires one, so `{ source: 'n == 2' }` is - * refused) and a non-blank `source` (so `{ dialect: 'cel', source: '' }` - * and an `ast`-only envelope are refused). `null` is not an envelope and is - * refused here too. + * refused) and a non-blank `source` (so `{ dialect: 'cel', source: '' }` is + * refused; an `ast`-only envelope was refused here through 17.4.0 and is + * caught by step 2 since). `null` is not an envelope and is refused here too. * 4. CEL: `validateExpression('predicate', …)`, the parse `registerFlow` * refuses a flow with. A dialect other than `cel` is refused, and so is a * `{var}` or `${…}` template, which is not CEL. @@ -234,9 +235,11 @@ function evalCelPredicate( * {@link evalCelPredicate} (objectui#10692). * * - A blank `expression` is read as `false`, as `evaluateCondition` reads an - * empty source; objectstack main refuses it at `registerFlow` - * (`predicateSlotRefusal`, objectstack#17493) and the installed 17.4.0 admits it — a - * declared divergence. + * empty source. The platform refuses it at `registerFlow` + * (`predicateSlotRefusal`, objectstack#17493), and so does the installed + * 17.5.0's `FlowSchema` at parse, at `config.conditions[i].expression` (it + * admitted it through 17.4.0; objectui#11073) — so only a draft that has + * not passed that door reaches this read. * - A non-string `expression` is refused with the spec's * `predicateSlotRefusal`, the refusal `registerFlow` applies to this slot. */ diff --git a/packages/components/src/__tests__/spec-symbol-action-renderer-props-11073.test.ts b/packages/components/src/__tests__/spec-symbol-action-renderer-props-11073.test.ts new file mode 100644 index 0000000000..2c261e0e7d --- /dev/null +++ b/packages/components/src/__tests__/spec-symbol-action-renderer-props-11073.test.ts @@ -0,0 +1,45 @@ +/** + * 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#11073 — why `action:button` / `action:icon` name their React props + * `…RendererProps`. + * + * `@objectstack/spec` 17.5.0 began exporting `ActionButtonProps` and + * `ActionIconProps` from `@objectstack/spec/ui`: the blocks' AUTHORED property + * bags. The renderers here had declared their React props envelope under those + * same names, which `pnpm check:spec-symbols` refuses (a local declaration under + * a spec export's name reads as the spec's own definition). The envelope is a + * different layer — it carries the node as `schema`, the host's `context` and + * its evaluated `disabled`, with an open tail — so it was renamed rather than + * derived, as objectui#7265 renamed `RecordAlertRendererProps`. Neither type is + * on `@object-ui/components`' published entry, so no public name moved. + * + * These rows pin the REASON, read off the spec's live schemas: the day the + * spec's bag becomes the envelope, they go red and the rename's premise has + * expired. That the spec does not own the NEW names is held by + * `pnpm check:spec-symbols`, which judges every declaration against every spec + * export name on each run. + */ +import { describe, expect, it } from 'vitest'; +import { ActionButtonPropsSchema, ActionIconPropsSchema } from '@objectstack/spec/ui'; + +const keysOf = (schema: unknown): string[] => + Object.keys((schema as { shape: Record }).shape); + +describe('objectui#11073 — the spec owns the AUTHORED action props bags, not the renderer envelope', () => { + it.each([ + ['ActionButtonProps', ActionButtonPropsSchema], + ['ActionIconProps', ActionIconPropsSchema], + ] as const)('the spec`s `%s` is the authored bag: it declares `actionType`, and no envelope member', (_name, schema) => { + const keys = keysOf(schema); + expect(keys).toContain('actionType'); + expect(keys).toContain('label'); + for (const envelope of ['schema', 'context', 'className']) expect(keys).not.toContain(envelope); + }); +}); diff --git a/packages/components/src/renderers/action/action-button.tsx b/packages/components/src/renderers/action/action-button.tsx index f13fc71458..d493846288 100644 --- a/packages/components/src/renderers/action/action-button.tsx +++ b/packages/components/src/renderers/action/action-button.tsx @@ -45,7 +45,11 @@ import { readStaticParamValues } from './static-params'; * as the SDUI envelope's component discriminator — `'action:button'` on every * authored path — and is no longer read as an action type by anything here. */ -export interface ActionButtonProps { +// `…RendererProps`, not `ActionButtonProps` (objectui#11073): `@objectstack/spec/ui` 17.5.0 exports +// `ActionButtonProps` as the block's AUTHORED property bag (`actionType`, `label`, `variant`, …). +// This is the React envelope that carries a node (`schema`, `context`, `disabled`, an open +// tail) — a different layer, named as objectui#7265 named `RecordAlertRendererProps`. +export interface ActionButtonRendererProps { schema: UIActionSchema & { type: string; className?: string; actionType?: string }; className?: string; /** Override context for this specific action */ @@ -61,21 +65,21 @@ export interface ActionButtonProps { [key: string]: any; } -// `PropsWithoutRef` would collapse `ActionButtonProps` to its bare index +// `PropsWithoutRef` would collapse `ActionButtonRendererProps` to its bare index // signature, so the type argument carries the declared props WITHOUT it (each -// derived off `ActionButtonProps`, so the two cannot drift) and the index +// derived off `ActionButtonRendererProps`, so the two cannot drift) and the index // signature stays on the parameter annotation — mechanism note on `action:bar` // (objectui#4422), pinned by // `__tests__/forwardref-props-annotation.guard.test.ts`. const ActionButtonRenderer = forwardRef< HTMLButtonElement, { - schema: ActionButtonProps['schema']; - className?: ActionButtonProps['className']; - context?: ActionButtonProps['context']; + schema: ActionButtonRendererProps['schema']; + className?: ActionButtonRendererProps['className']; + context?: ActionButtonRendererProps['context']; } >( - ({ schema, className, context: localContext, ...props }: ActionButtonProps, ref) => { + ({ schema, className, context: localContext, ...props }: ActionButtonRendererProps, ref) => { const { 'data-obj-id': dataObjId, 'data-obj-type': dataObjType, @@ -185,7 +189,7 @@ const ActionButtonRenderer = forwardRef< // where TypeScript actually RUNS the excess-property (freshness) check. // It does not run it on a literal that spreads a value of type `any`, // and `localContext` is exactly that: `PropsWithoutRef` collapses - // `ActionButtonProps` (which carries an `[key: string]: any` index + // `ActionButtonRendererProps` (which carries an `[key: string]: any` index // signature) to a pure index-signature type, so every destructured prop // arrives as `any`. Spreading it into the payload made this site absorb // unknown keys in silence while `action:group` / `action:menu` — same diff --git a/packages/components/src/renderers/action/action-icon.tsx b/packages/components/src/renderers/action/action-icon.tsx index 847e70e184..4fe557d05e 100644 --- a/packages/components/src/renderers/action/action-icon.tsx +++ b/packages/components/src/renderers/action/action-icon.tsx @@ -39,12 +39,16 @@ import { readStaticParamValues } from './static-params'; * on `action:button`; `type` stays on the intersection as the SDUI envelope's * component discriminator and is not read as an action type here. */ -export interface ActionIconProps { +// `…RendererProps`, not `ActionIconProps` (objectui#11073): `@objectstack/spec/ui` 17.5.0 exports +// `ActionIconProps` as the block's AUTHORED property bag (`actionType`, `label`, `variant`, …). +// This is the React envelope that carries a node (`schema`, `context`, `disabled`, an open +// tail) — a different layer, named as objectui#7265 named `RecordAlertRendererProps`. +export interface ActionIconRendererProps { schema: UIActionSchema & { type: string; className?: string; actionType?: string }; className?: string; context?: Record; /** - * The host-EVALUATED enablement verdict — see `ActionButtonProps` for the + * The host-EVALUATED enablement verdict — see `ActionButtonRendererProps` for the * mechanism (objectui#9131). Declared rather than left to the index * signature, consumed by name below, never re-spread onto the DOM. */ @@ -58,12 +62,12 @@ export interface ActionIconProps { const ActionIconRenderer = forwardRef< HTMLButtonElement, { - schema: ActionIconProps['schema']; - className?: ActionIconProps['className']; - context?: ActionIconProps['context']; + schema: ActionIconRendererProps['schema']; + className?: ActionIconRendererProps['className']; + context?: ActionIconRendererProps['context']; } >( - ({ schema, className, context: localContext, ...props }: ActionIconProps, ref) => { + ({ schema, className, context: localContext, ...props }: ActionIconRendererProps, ref) => { const { 'data-obj-id': dataObjId, 'data-obj-type': dataObjType, diff --git a/packages/core/package.json b/packages/core/package.json index 54cca7cc9c..32b5d8f92c 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.3.0" + "@objectstack/spec": "^17.5.0" }, "devDependencies": { "@object-ui/test-support": "workspace:*", diff --git a/packages/core/src/actions/actionKeys.ts b/packages/core/src/actions/actionKeys.ts index fd7cab57ce..e8128bb67d 100644 --- a/packages/core/src/actions/actionKeys.ts +++ b/packages/core/src/actions/actionKeys.ts @@ -242,6 +242,14 @@ export const SPEC_ACTION_KEYS = [ 'disabled', 'errorMessage', 'execute', + // Added to `ActionSchema` in @objectstack/spec 17.5.0 (objectui#11073): the + // bulk dispatch contract the action's body is written for, in + // `bulkActionDefs`' own `'perRecord' | 'aggregate'` vocabulary. Listed for the + // reason `description` is — this array restates the spec's declared keys, so + // an action carrying it is not reported as having an unknown key. `ActionDef` + // does NOT declare it and the runner does not read it: the spec's own + // enforcement is `@objectstack/lint`'s authoring-time check. + 'execution', 'icon', 'label', 'locations', diff --git a/packages/core/src/adapters/__tests__/ValueDataSource.filterLogicConformance-8513.test.ts b/packages/core/src/adapters/__tests__/ValueDataSource.filterLogicConformance-8513.test.ts index e9de590f1e..927b394a07 100644 --- a/packages/core/src/adapters/__tests__/ValueDataSource.filterLogicConformance-8513.test.ts +++ b/packages/core/src/adapters/__tests__/ValueDataSource.filterLogicConformance-8513.test.ts @@ -45,6 +45,12 @@ * - `$not` regressing to its pre-objectui#8447 fail-OPEN behaviour, where it * matched EVERY row (the refusal assertions go red). * + * `@objectstack/spec` 17.5.0 added a second partition of the same kind: the + * STAGED `$empty` flag (objectstack#20444), enrolled in the table while the spec + * keeps it out of `FILTER_OPERATORS` and says the executors refuse it until each + * face has its arm. It is pinned the same way — by name, by direction, and with + * an expiry row that reddens when the staging ends (objectui#11073). + * * That last one is why this file asserts the refusal instead of `it.skip`: * `$not` failed in the OPPOSITE direction from `$and` / `$or` before #8447, and * a fix that flattened the three combinators into one arm is the specific @@ -70,15 +76,27 @@ */ import { describe, it, expect, vi, afterEach } from 'vitest'; -import { FILTER_LOGIC_CASES, FILTER_LOGIC_ROWS } from '@objectstack/spec/data'; +import { FILTER_LOGIC_CASES, FILTER_LOGIC_ROWS, FILTER_OPERATORS } from '@objectstack/spec/data'; import { ValueDataSource } from '../ValueDataSource'; import { toFilterNode } from '../../utils/filter-converter'; /** Cases whose filter mentions `$not` anywhere — the out-of-scope partition. */ const carriesNot = (filter: unknown): boolean => JSON.stringify(filter).includes('"$not"'); +/** + * Cases whose filter mentions the STAGED `$empty` flag (objectstack#20444), + * which `@objectstack/spec` 17.5.0 enrolled in this table while keeping it OUT + * of `FILTER_OPERATORS`: "Until each face has its arm, the query executors + * refuse it". This matcher has no arm, so it refuses — the staged behaviour — + * and that partition is pinned below by name and direction, exactly as `$not`'s + * is (objectui#11073). A `$not` over `$empty` stays in the `$not` partition: + * the outer refusal is the one this matcher gives. + */ +const carriesEmpty = (filter: unknown): boolean => JSON.stringify(filter).includes('"$empty"'); + const NOT_CASES = FILTER_LOGIC_CASES.filter((c) => carriesNot(c.filter)); -const EXECUTED_CASES = FILTER_LOGIC_CASES.filter((c) => !carriesNot(c.filter)); +const STAGED_EMPTY_CASES = FILTER_LOGIC_CASES.filter((c) => !carriesNot(c.filter) && carriesEmpty(c.filter)); +const EXECUTED_CASES = FILTER_LOGIC_CASES.filter((c) => !carriesNot(c.filter) && !carriesEmpty(c.filter)); async function selectedIds(filter: unknown): Promise { const ds = new ValueDataSource({ items: FILTER_LOGIC_ROWS as any[] }); @@ -99,13 +117,16 @@ afterEach(() => { // --------------------------------------------------------------------------- describe('objectui#8513 — the conformance harness discriminates', () => { - it('the table is present, non-trivial, and split into two non-empty parts', () => { + it('the table is present, non-trivial, and split into three non-empty parts', () => { // A table that failed to import would make every `it.each` below vacuous. expect(FILTER_LOGIC_CASES.length).toBeGreaterThan(20); expect(FILTER_LOGIC_ROWS.length).toBe(4); expect(EXECUTED_CASES.length).toBeGreaterThan(0); expect(NOT_CASES.length).toBeGreaterThan(0); - expect(EXECUTED_CASES.length + NOT_CASES.length).toBe(FILTER_LOGIC_CASES.length); + expect(STAGED_EMPTY_CASES.length).toBeGreaterThan(0); + expect(EXECUTED_CASES.length + NOT_CASES.length + STAGED_EMPTY_CASES.length).toBe( + FILTER_LOGIC_CASES.length, + ); }); it('the fixture discriminates: no expectation is the whole table AND none is empty', () => { @@ -145,11 +166,16 @@ describe('objectui#8513 — `$not` is out of scope, and stays refused', () => { it('the excluded partition is exactly these cases', () => { // By NAME, so a new `$not` case landing upstream is a decision rather than // a silent widening of the exclusion list. + // The two `$empty` rows arrived with `@objectstack/spec` 17.5.0 and were + // added here by decision (objectui#11073): the outer `$not` is what this + // matcher refuses, whatever it negates. expect(NOT_CASES.map((c) => c.name).sort()).toEqual( [ '$not ANDs with its sibling keys inside a branch', '$not of {} is FALSE — NOT TRUE', '$not returns the rows with no value', + '$not over $empty true returns the valued rows', + '$not over $empty false returns the no-value rows', ].sort(), ); }); @@ -168,14 +194,54 @@ describe('objectui#8513 — `$not` is out of scope, and stays refused', () => { ); it('one of the excluded cases would be WRONG if `$not` were silently ignored', () => { - // The direction that makes the partition honest: two of the three name a - // non-empty row set, so "refused" is a visibly different answer from - // "executed", not a coincidence. + // The direction that makes the partition honest: four of the five name a + // non-empty row set (two of three before the 17.5.0 `$empty` rows), so + // "refused" is a visibly different answer from "executed", not a coincidence. const nonEmpty = NOT_CASES.filter((c) => c.expected.length > 0); - expect(nonEmpty.length).toBe(2); + expect(nonEmpty.length).toBe(4); }); }); +// --------------------------------------------------------------------------- +// 2b. The STAGED `$empty` partition — refused, as the spec stages it +// --------------------------------------------------------------------------- + +describe('objectui#11073 — the staged `$empty` flag is refused by this matcher, as the spec stages it', () => { + it('the staged partition is exactly these cases', () => { + // By NAME, so another staged case landing upstream is a decision. + expect(STAGED_EMPTY_CASES.map((c) => c.name).sort()).toEqual( + [ + '$empty true selects exactly the no-value rows', + '$empty false selects exactly the valued rows', + '$empty inside a $or branch OR-s with its sibling branch', + '$empty inside a $and ANDs with its sibling', + '$empty ANDs with a sibling operator on the same field', + ].sort(), + ); + }); + + it('the staging still holds upstream — `$empty` is not in FILTER_OPERATORS', () => { + // The expiry of this partition. The day the flip card adds `$empty` to + // `FILTER_OPERATORS`, the query executors stop refusing it and this face + // owes its arm: this row reddens, and the partition above moves back into + // the executed table. Lit control: a flag operator that IS in the list. + expect(FILTER_OPERATORS as readonly string[]).not.toContain('$empty'); + expect(FILTER_OPERATORS as readonly string[]).toContain('$null'); + }); + + it.each(STAGED_EMPTY_CASES.map((c) => [c.name, c] as const))( + 'refused, not answered: %s', + async (_name, testCase) => { + const warn = spyWarn(); + // A visibly different answer from the table's, AND a logged reason that + // names the operator — never a silent partial answer. + expect(await selectedIds(testCase.filter)).not.toEqual(testCase.expected); + expect(warn).toHaveBeenCalledTimes(1); + expect(String(warn.mock.calls[0]?.[0])).toContain('$empty'); + }, + ); +}); + // --------------------------------------------------------------------------- // 3. The path that actually reaches this matcher // --------------------------------------------------------------------------- diff --git a/packages/core/src/evaluator/optionRules.ts b/packages/core/src/evaluator/optionRules.ts index 7e186fbc38..9713ef7661 100644 --- a/packages/core/src/evaluator/optionRules.ts +++ b/packages/core/src/evaluator/optionRules.ts @@ -118,7 +118,7 @@ export function resolveVisibleOptions( * This is deliberately NOT the lenient renderer fallback AGENTS.md #0.1 * forbids. That rule governs metadata the contract REFUSES ("if the metadata * is off-spec, fix it at the producer"); an empty label is metadata the - * contract ACCEPTS. Measured on the installed `@objectstack/spec` 17.4.0, + * contract ACCEPTS. Measured on the installed `@objectstack/spec` 17.5.0, * `SelectOptionSchema`: * * { value: 'low', label: 'Low' } -> ACCEPT (lit control) diff --git a/packages/core/src/utils/__tests__/filter-array-comparand-8530.test.ts b/packages/core/src/utils/__tests__/filter-array-comparand-8530.test.ts index c9245944f8..9b5bc87514 100644 --- a/packages/core/src/utils/__tests__/filter-array-comparand-8530.test.ts +++ b/packages/core/src/utils/__tests__/filter-array-comparand-8530.test.ts @@ -23,6 +23,9 @@ * `$between`. The refusal therefore arrived two layers away — `driver-sql`'s * `400 INVALID_FILTER` from the wire, or an empty list from every in-memory * matcher — and the author learned nothing at lowering time. + * (That was `@objectstack/spec` through 17.4.0. Since 17.5.0 `parseFilterAST` + * refuses the node itself, naming the field — section 1 was re-pinned to that + * sibling refusal by objectui#11073, and the producer arm stays.) * * ## The ruling (objectui#8530, PM comment 5583351371) * @@ -84,14 +87,26 @@ function captureRefusal(run: () => unknown): FilterOperatorError { // --------------------------------------------------------------------------- describe('objectui#8530 — a bare array in comparand position is refused', () => { - it('records why the producer must refuse: the spec doors pass the old node unjudged', () => { - // The pre-fix emission. Both spec doors accept it, so nothing between this - // file and the driver would have said a word. If the spec ever starts - // refusing it, this pin reddens and the reader learns the refusal now has - // a sibling — not that the producer arm can go. + it('records that the spec door now refuses the old node too — a SIBLING refusal, not a licence to drop this one', () => { + // The pre-fix emission. Through `@objectstack/spec` 17.4.0 both spec doors + // accepted it, so nothing between this file and the driver would have said + // a word. This pin was written to redden the day the spec started refusing + // it, and it did at 17.5.0 (objectui#11073): the shape check still passes, + // and the lowering now throws `INVALID_FILTER` naming the field. The + // reader's lesson is the one this comment always gave — the refusal gained + // a sibling; the producer arm below still refuses FIRST, at authoring. const preFixNode = ['tags', '=', ['a', 'b']]; expect(isFilterAST(preFixNode)).toBe(true); - expect(parseFilterAST(preFixNode)).toEqual({ tags: ['a', 'b'] }); + let thrown: unknown; + try { + parseFilterAST(preFixNode); + } catch (e) { + thrown = e; + } + expect((thrown as { code?: string } | undefined)?.code).toBe('INVALID_FILTER'); + expect((thrown as Error).message).toMatch( + /^The implicit-equality comparand on field "tags" requires a single comparable value, but received an array/, + ); }); it('refuses { tags: [...] } with the INVALID_FILTER / 400 envelope', () => { diff --git a/packages/core/src/utils/__tests__/filter-tokens.spec-derived-7265.test.ts b/packages/core/src/utils/__tests__/filter-tokens.spec-derived-7265.test.ts index c9c68dbcd1..a345ee674f 100644 --- a/packages/core/src/utils/__tests__/filter-tokens.spec-derived-7265.test.ts +++ b/packages/core/src/utils/__tests__/filter-tokens.spec-derived-7265.test.ts @@ -48,6 +48,22 @@ const NEAR_MISSES_AT_567f370 = [ ['current_tenant_id', 'current_org_id'], ] as const; +/** + * The near-miss spellings `@objectstack/spec` 17.5.0 ADDED to the imported map, + * transcribed from the installed map when the bump turned the exact-set + * assertion red (objectui#11073) — the pool widening doing its job. Kept apart + * from the 567f370 table so that table stays what it says it is. + * + * Four were added; only these two WARN, because `{record.id}` and `{record-id}` + * never reach the lookup: the resolver's whole-token pattern is + * `[a-zA-Z0-9_]+`, so a `.` or `-` spelling is not a placeholder to it and + * passes through in silence, exactly as it did before the bump. + */ +const NEAR_MISSES_ADDED_AT_17_5_0 = [ + ['current_record_id', 'record_id'], + ['recordid', 'record_id'], +] as const; + /** Every token the deleted local predicate answered `true` for. */ const CONTEXT_TOKENS_AT_567f370 = ['current_user_id', 'current_org_id'] as const; @@ -110,7 +126,7 @@ describe('objectui#7265 — the derived map and predicate behave as the local co }); it('warns with the same suggestion target for every near-miss spelling', () => { - for (const [spelling, target] of NEAR_MISSES_AT_567f370) { + for (const [spelling, target] of [...NEAR_MISSES_AT_567f370, ...NEAR_MISSES_ADDED_AT_17_5_0]) { const { value, warnings } = resolveOne(spelling); expect(value).toBe(`{${spelling}}`); // never substituted — it is not a token expect(warnings).toHaveLength(1); @@ -130,7 +146,7 @@ describe('objectui#7265 — the derived map and predicate behave as the local co const warners = [...new Set(pool)].filter((s) => resolveOne(s).warnings.length > 0); expect(warners.sort()).toEqual( - [...NEAR_MISSES_AT_567f370.map(([spelling]) => spelling)].sort(), + [...NEAR_MISSES_AT_567f370, ...NEAR_MISSES_ADDED_AT_17_5_0].map(([spelling]) => spelling).sort(), ); }); diff --git a/packages/core/src/utils/__tests__/filter-view-rule-arity-8557.test.ts b/packages/core/src/utils/__tests__/filter-view-rule-arity-8557.test.ts index 176d6d7cfe..3b3d6345bd 100644 --- a/packages/core/src/utils/__tests__/filter-view-rule-arity-8557.test.ts +++ b/packages/core/src/utils/__tests__/filter-view-rule-arity-8557.test.ts @@ -18,10 +18,11 @@ * → `[['tags', 'equals', ['a']]]` * → `parseFilterAST` → `{ tags: ['a'] }` * - * Section 1 pins that the spec's doors accept that node unjudged (measured + * Section 1 pinned that the spec's doors accept that node unjudged (measured * against `@objectstack/spec` 17.3.0), which is why the refusal used to arrive * two layers away as a `400 INVALID_FILTER` from `driver-sql`, or as an empty - * list from an in-memory matcher. + * list from an in-memory matcher. Since 17.5.0 `parseFilterAST` refuses it + * itself; section 1 now pins that sibling refusal (objectui#11073). * * It is the SAME array-in-a-scalar-slot shape objectui#8530 / PR #8551 refused * in `convertFiltersToAST`'s object arm. That fix deliberately did not reach @@ -103,16 +104,27 @@ const rule = (operator: string, value?: unknown) => value === undefined ? { field: 'tags', operator } : { field: 'tags', operator, value }; // --------------------------------------------------------------------------- -// 1. Why the producer must refuse: the spec doors pass the old node unjudged +// 1. Why the producer refuses first: the spec door judged nothing through 17.4.0 // --------------------------------------------------------------------------- -describe('objectui#8557 — the pre-fix node reaches the wire unjudged', () => { - it('is accepted by isFilterAST and lowered to an array comparand', () => { - // If the spec ever starts refusing this, the pin reddens and the reader - // learns the refusal gained a sibling — not that this arm can go. +describe('objectui#8557 — the pre-fix node is judged by the spec door since 17.5.0', () => { + it('passes isFilterAST, and the lowering now REFUSES its array comparand', () => { + // Through `@objectstack/spec` 17.4.0 the lowering produced `{ tags: ['a'] }` + // unjudged. This pin was written to redden the day the spec refused it, and + // it did at 17.5.0 (objectui#11073): the reader learns the refusal gained a + // sibling — not that this arm can go. const preFixNode = ['tags', 'equals', ['a']]; expect(isFilterAST(preFixNode)).toBe(true); - expect(parseFilterAST(preFixNode)).toEqual({ tags: ['a'] }); + let thrown: unknown; + try { + parseFilterAST(preFixNode); + } catch (e) { + thrown = e; + } + expect((thrown as { code?: string } | undefined)?.code).toBe('INVALID_FILTER'); + expect((thrown as Error).message).toMatch( + /^The implicit-equality comparand on field "tags" requires a single comparable value, but received an array/, + ); }); }); 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 index c01cb464aa..e5e0a2f7f5 100644 --- 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 @@ -33,12 +33,13 @@ * 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. + * A SECOND, INDEPENDENT ROUTE landed on the same floor until objectui#11073: + * `ListViewSchema` gained `pageName` in the same release, and `type: 'page'` + * with it, refused by 17.0.0 / 17.1.0 / 17.2.0 and accepted from 17.3.0. + * `@objectstack/spec` 17.5.0 RETIRED both (ADR-0049 enforce-or-remove), so that + * route no longer props the floor: it rests on the `UserActionsConfigSchema` + * key family alone, which is what the rows below measure. The retirement itself + * is pinned in `normalize-list-view.pageResidual-8429.test.ts`. * * ⇒ 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 @@ -170,16 +171,9 @@ describe('the DECLARED spec floor admits only specs that accept the fold (object 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); + // The second route (`pageName` on a `page` view) was retired by the spec at + // 17.5.0 and no longer props this floor (objectui#11073). + expect(SpecListViewSchema.safeParse({ name: 'account_page', type: 'page', pageName: 'crm_welcome', columns: [] }).success).toBe(false); }); }); }); diff --git a/packages/core/src/utils/__tests__/normalize-list-view.pageResidual-8429.test.ts b/packages/core/src/utils/__tests__/normalize-list-view.pageResidual-8429.test.ts index d86cab7ef2..1efb254990 100644 --- a/packages/core/src/utils/__tests__/normalize-list-view.pageResidual-8429.test.ts +++ b/packages/core/src/utils/__tests__/normalize-list-view.pageResidual-8429.test.ts @@ -7,48 +7,27 @@ */ /** - * objectui#8429 — the RESIDUAL left standing after objectui#8127 / PR #8372. + * objectui#8429 — the RESIDUAL left standing after objectui#8127 / PR #8372, + * and how it CLOSED (objectui#11073). * * #8127 was the DECLARATION gap: `ViewType` was a hand-written copy of - * `@objectstack/spec`'s list-view vocabulary, so every structure keyed on it - * was total over the COPY and compiled green while the spec's `page` had - * nowhere to land. PR #8372 closed exactly that — both published - * `@object-ui/types` faces are DERIVED from the spec now. + * `@objectstack/spec`'s list-view vocabulary. PR #8372 derived both published + * `@object-ui/types` faces from the spec, and that made a renderer gap visible: + * `page` was a list-view type the spec ACCEPTED and both objectui faces + * published, which `ListView` cannot draw, so `normalizeListViewSchema` handed + * the renderer a grid with the `pageName` mount target unread. * - * What it did NOT close, and what this file states mechanically, is the - * renderer gap the derivation made visible rather than removed: - * - * `page` is simultaneously - * (a) a list-view type `@objectstack/spec`'s own `ListViewSchema` ACCEPTS, - * (b) a member of `@object-ui/types`' published `ViewType` / `ViewTypeSchema`, - * (c) NOT a member of the set `ListView` can draw, and therefore - * (d) normalised to `viewType: 'grid'` — a DIFFERENT view kind — by - * `normalizeListViewSchema`, with the `pageName` mount target left - * sitting in the metadata, unread. - * - * ⛔ This file is DISPOSITION-FREE and must stay that way. It does not propose - * that `ListView` grow a page renderer, and it does not propose narrowing - * `ViewType` back — both are rulings the card deliberately leaves open, and the - * second would break the derivation #8372 just established. The pin asserts - * TODAY'S behaviour so that neither disposition can land silently: - * - * · grow a renderer ⇒ `page` becomes drawable ⇒ the residual set empties and - * the normalised output stops being `'grid'` ⇒ RED. - * · narrow `ViewType` ⇒ the published faces stop accepting `page` ⇒ RED. - * ⚠️ That leg is RUNTIME now, judged against the spec this repository - * RESOLVES. Its compile-time half could not keep naming the live union — - * see `_LivePageFaceSkew` below and objectui#9978. - * - * ⚠️ The `console.warn` #8372 added is a signal to the DEVELOPER, not to the end - * user — the author still gets a grid. It is silenced here, not treated as a - * mitigation, and its own behaviour stays pinned in `normalize-list-view.test.ts`. - * - * ⚠️ Every set below is asserted at its EXACT size before it is used, because a - * census over an empty set passes. `SPEC_LIST_VIEW_TYPES` in particular is read - * from the installed `@objectstack/spec` at runtime, so an upstream bump that - * empties or reshapes the vocabulary must fail here rather than vacuously pass. + * This file stayed DISPOSITION-FREE: it pinned that behaviour so that neither + * "grow a renderer" nor "narrow `ViewType`" could land silently. The answer + * came from the protocol instead: objectstack#17063 retired `type: 'page'` and + * `pageName` (ADR-0049 enforce-or-remove), and `@objectstack/spec` 17.5.0 + * publishes the retirement. The derived faces followed the spec, as the + * derivation promised, so the residual set is EMPTY: every list-view type the + * spec accepts is one `ListView` draws. The pins below state the closed state, + * each population sized before it is used, because a census over an empty set + * passes. The seat's Q4 ruling on objectui#11073 retired the matching members + * on objectui's own faces. */ - import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { ListViewSchema as SpecListViewSchema } from '@objectstack/spec/ui'; import { ViewTypeSchema } from '@object-ui/types/zod'; @@ -62,14 +41,11 @@ const SPEC_LIST_VIEW_TYPES: readonly string[] = SpecListViewSchema.shape.type.re const OBJECTUI_VIEW_TYPES: readonly string[] = ViewTypeSchema.options; /** - * A document `@objectstack/spec`'s `ListViewSchema` ACCEPTS — proven below, - * against three controls, before it is used as the normalizer's input. - * - * `pageName` is required on a `page` view and `columns` must be empty beside it - * (`checkListViewPageMount`, `@objectstack/spec@17.3.0`), so this is not a - * hand-waved "spec-valid": it is the only shape a page view can legally take. + * The page-mount document objectui#8429 held spec-valid through 17.4.0: + * `type: 'page'` with its `pageName` and empty `columns`. The spec REFUSES it + * since 17.5.0, by name, with the removal prescription. */ -const SPEC_VALID_PAGE_VIEW = { +const RETIRED_PAGE_VIEW = { name: 'account_page', type: 'page', pageName: 'crm_welcome', @@ -103,10 +79,10 @@ const readViewType = (out: unknown): unknown => (out as { viewType?: unknown }). * than uncompilable. * * ⚠️ What no COMPILE-time spelling can state on both legs is `page`'s bare - * presence in the live union: that sentence is FALSE once the spec retires the - * kind. It is stated at RUNTIME instead, against the spec this repository - * actually resolves — the sized vocabularies, the `safeParse`, the residual set - * and the census below all name `page` literally and all redden if it leaves. + * presence in the live union. It is stated at RUNTIME instead, against the spec + * this repository actually resolves — since objectui#11073 that is 17.5.0, + * which retired the kind, so the sized vocabularies, the `safeParse` and the + * census below state its ABSENCE, and redden if it comes back. */ /** The VALUE face's vocabulary, read as a type off the enum's own options. */ @@ -127,14 +103,14 @@ export type _LivePageFaceSkew = AssertNever>; -/** LEG 2 — objectstack `main`. Both faces dropped it together: the card's green. */ +/** LEG 2 — a spec that retired it, the resolved one today. Both faces dropped it together. */ export type _SkewUnderMain = AssertNever>; /** FIRING CONTROL A — the TYPE face narrowed alone. The skew is `'page'`. */ @@ -154,10 +130,8 @@ export type _SkewValueFaceNarrowedAlone = AssertNever { +describe('the `page` residual (objectui#8429), closed by the protocol at @objectstack/spec 17.5.0', () => { beforeEach(() => { - // Developer-facing noise from #8372's undrawable-kind warning. Silenced, not - // asserted: this file is about what the AUTHOR gets, and the author gets a grid. vi.spyOn(console, 'warn').mockImplementation(() => {}); }); @@ -167,137 +141,88 @@ describe('the `page` residual (objectui#8429)', () => { describe('the populations, sized before they are used', () => { it('reads a non-empty, exactly-sized vocabulary from each face', () => { - // The spec's list-view types, as published in @objectstack/spec 17.3.0. - expect(SPEC_LIST_VIEW_TYPES.length).toBe(10); + // The spec's list-view types, as published in @objectstack/spec 17.5.0: + // the nine visualizations, `page` retired. + expect(SPEC_LIST_VIEW_TYPES.length).toBe(9); expect([...SPEC_LIST_VIEW_TYPES].sort()).toEqual([ - 'calendar', 'chart', 'gallery', 'gantt', 'grid', 'kanban', 'map', 'page', 'timeline', 'tree', + 'calendar', 'chart', 'gallery', 'gantt', 'grid', 'kanban', 'map', 'timeline', 'tree', ]); - // objectui's derived face: the spec's ten, plus the two local categories. - expect(OBJECTUI_VIEW_TYPES.length).toBe(12); + // objectui's derived face: the spec's nine, plus the two local categories. + expect(OBJECTUI_VIEW_TYPES.length).toBe(11); expect([...OBJECTUI_VIEW_TYPES].sort()).toEqual([ 'calendar', 'chart', 'detail', 'gallery', 'gantt', 'grid', - 'kanban', 'list', 'map', 'page', 'timeline', 'tree', + 'kanban', 'list', 'map', 'timeline', 'tree', ]); // The derivation itself: objectui declares a superset, never a subset. - // This is the leg that reddens if `ViewType` is ever narrowed back. for (const kind of SPEC_LIST_VIEW_TYPES) { expect(OBJECTUI_VIEW_TYPES).toContain(kind); } }); - it('partitions objectui`s vocabulary into 9 drawable and 3 undrawable kinds', () => { + it('partitions objectui`s vocabulary into 9 drawable kinds and the 2 local categories', () => { const drawable = OBJECTUI_VIEW_TYPES.filter((k) => isListViewVisualization(k)); const undrawable = OBJECTUI_VIEW_TYPES.filter((k) => !isListViewVisualization(k)); expect(drawable.length).toBe(9); - expect(undrawable.length).toBe(3); + expect(undrawable.length).toBe(2); expect(drawable.length + undrawable.length).toBe(OBJECTUI_VIEW_TYPES.length); - expect([...undrawable].sort()).toEqual(['detail', 'list', 'page']); + expect([...undrawable].sort()).toEqual(['detail', 'list']); }); }); - describe('⭐ the contradiction, as a set with exactly one member', () => { - it('leaves `page` as the ONLY spec-authorable list-view type ListView cannot draw', () => { - // `list` and `detail` are objectui view CATEGORIES with no spec counterpart, - // so they are undrawable WITHOUT being a contract violation. Subtracting the - // drawable set from the SPEC's vocabulary — not from objectui's — is what - // isolates the residual this card registers. + describe('⭐ the contradiction is gone: the residual set is EMPTY', () => { + it('leaves no spec-authorable list-view type ListView cannot draw', () => { const residual = SPEC_LIST_VIEW_TYPES.filter((k) => !isListViewVisualization(k)); - - expect(residual).toEqual(['page']); - - // Control: the subtraction is a real one, not an empty-in / empty-out - // identity. Nine of the spec's ten types ARE drawable. + expect(residual).toEqual([]); + // Control: the subtraction is a real one — every one of the spec's nine IS drawable. expect(SPEC_LIST_VIEW_TYPES.filter((k) => isListViewVisualization(k)).length).toBe(9); }); - it('keeps `page` on BOTH published `@object-ui/types` faces', () => { - // The value face. Reddens the day `ViewTypeSchema` is narrowed. - expect(ViewTypeSchema.safeParse('page').success).toBe(true); - // Control: the vocabulary is still CLOSED, so the `true` above is a - // reading of an enum and not an enum that accepts anything. - expect(ViewTypeSchema.safeParse('nonsense-control').success).toBe(false); - - // The type face, at runtime. Its COMPILE-time half is `_LivePageFaceSkew` - // at the top of this file: an annotation here would judge the spelling - // against whichever `ViewType` is resolved, which is what made this pin - // uncompilable against a spec that retired the kind (objectui#9978). - expect(OBJECTUI_VIEW_TYPES).toContain('page'); + it('refuses `page` on BOTH published `@object-ui/types` faces, as the spec does', () => { + expect(ViewTypeSchema.safeParse('page').success).toBe(false); + expect(OBJECTUI_VIEW_TYPES).not.toContain('page'); + // Control: the enum is a reading, not a schema that refuses everything. + expect(ViewTypeSchema.safeParse('kanban').success).toBe(true); }); - }); - - describe('⭐ the OUTPUT — a document the spec accepts comes back as a different view kind', () => { - it('confirms the input is spec-valid, against three refusal controls', () => { - expect(SpecListViewSchema.safeParse(SPEC_VALID_PAGE_VIEW).success).toBe(true); - // The spec's validator is not a rubber stamp — `checkListViewPageMount` - // refuses a page view with no mount target, refuses `pageName` on any - // other kind, and the enum still refuses a typo. - expect(SpecListViewSchema.safeParse({ name: 'account_page', type: 'page', columns: [] }).success).toBe(false); - expect( - SpecListViewSchema.safeParse({ name: 'account_board', type: 'kanban', pageName: 'crm_welcome', columns: [] }) - .success, - ).toBe(false); + it('the spec refuses the page-mount document by name, with its removal prescription', () => { + const r = SpecListViewSchema.safeParse(RETIRED_PAGE_VIEW); + expect(r.success).toBe(false); + const messages = r.success ? [] : r.error.issues.map((i) => ({ path: i.path.join('.'), message: i.message })); + expect(messages.find((m) => m.path === 'type')?.message).toContain('removed'); + expect(messages.find((m) => m.path === 'pageName')?.message).toContain('removed'); + // Controls: the same shape as a drawable kind parses, and a typo is still refused. + expect(SpecListViewSchema.safeParse({ name: 'account_grid', type: 'grid', columns: ['name'] }).success).toBe(true); expect(SpecListViewSchema.safeParse({ name: 'account_typo', type: 'nonsense', columns: [] }).success).toBe(false); }); + }); - it('normalises that spec-valid `page` view to `viewType: "grid"`', () => { - const out = normalizeListViewSchema({ ...SPEC_VALID_PAGE_VIEW }); - - // ⭐ The sharpest statement of the bug: the author declared a page and the - // renderer was handed a grid. - expect(readViewType(out)).toBe('grid'); - - // The mount target survives in the metadata — nothing consumed it. The - // degrade is not "the page could not be found"; it is "the page was never - // looked for". - expect((out as { pageName?: unknown }).pageName).toBe('crm_welcome'); - expect((out as { type?: unknown }).type).toBe('page'); - }); - - it('degrades the same way through the `specType` slot a `list-view` node uses', () => { - // `components/renderers/layout/react-page.tsx` parks the SDUI-tier `type` - // in `specType` (ADR-0078), so this is the OTHER door the same author - // reaches the normalizer through. - const out = normalizeListViewSchema({ - type: 'list-view', - objectName: 'account', - specType: 'page', - pageName: 'crm_welcome', - }); - expect(readViewType(out)).toBe('grid'); + describe('the OUTPUT — a stored `page` view degrades like any kind outside the vocabulary', () => { + it('normalises a stored `page` view to `viewType: "grid"`, exactly as it normalises a typo', () => { + expect(readViewType(normalizeListViewSchema({ ...RETIRED_PAGE_VIEW }))).toBe('grid'); + expect(readViewType(normalizeListViewSchema({ type: 'list-view', objectName: 'account', specType: 'page' }))).toBe('grid'); + expect(readViewType(normalizeListViewSchema({ type: 'list-view', objectName: 'account', specType: 'nonsense' }))).toBe('grid'); }); it('discriminates: a drawable kind keeps its own identity', () => { - // Without this control, the `'grid'` above would also be produced by a - // normalizer that answered `'grid'` for everything — an implementation - // strictly worse than the bug, passing a pin about the bug. - expect(readViewType(normalizeListViewSchema({ type: 'list-view', objectName: 'a', specType: 'kanban' }))).toBe( - 'kanban', - ); - expect(readViewType(normalizeListViewSchema({ type: 'list-view', objectName: 'a', specType: 'gantt' }))).toBe( - 'gantt', - ); + expect(readViewType(normalizeListViewSchema({ type: 'list-view', objectName: 'a', specType: 'kanban' }))).toBe('kanban'); + expect(readViewType(normalizeListViewSchema({ type: 'list-view', objectName: 'a', specType: 'gantt' }))).toBe('gantt'); expect(readViewType(normalizeListViewSchema({ viewType: 'kanban', objectName: 'a' }))).toBe('kanban'); }); }); describe('the whole vocabulary, as a census', () => { - it('maps every one of the 12 declared kinds to what the renderer actually gets', () => { + it('maps every one of the 11 declared kinds to what the renderer actually gets', () => { const census = new Map( OBJECTUI_VIEW_TYPES.map((kind) => [ kind, readViewType(normalizeListViewSchema({ type: 'list-view', objectName: 'account', specType: kind })), ]), ); - - // Population first: a census over an empty set passes. - expect(census.size).toBe(12); - + expect(census.size).toBe(11); expect(Object.fromEntries(census)).toEqual({ - // The nine ListView draws — each keeps its own identity. grid: 'grid', kanban: 'kanban', gallery: 'gallery', @@ -310,16 +235,9 @@ describe('the `page` residual (objectui#8429)', () => { // The two objectui CATEGORIES — folding to grid is correct and intended. list: 'grid', detail: 'grid', - // ⭐ THE RESIDUAL. Spec-authorable, published on both faces, and handed - // to the renderer as something else entirely. Not a category: a kind. - page: 'grid', }); - - // Stated once more as the property rather than the table, so the - // contradiction survives a future reshuffle of the rows above: every - // SPEC-declared kind resolves to itself, except exactly one. - const misrouted = SPEC_LIST_VIEW_TYPES.filter((kind) => census.get(kind) !== kind); - expect(misrouted).toEqual(['page']); + // Every SPEC-declared kind resolves to itself: the residual is closed. + expect(SPEC_LIST_VIEW_TYPES.filter((kind) => census.get(kind) !== kind)).toEqual([]); }); }); }); diff --git a/packages/core/src/utils/__tests__/normalize-list-view.test.ts b/packages/core/src/utils/__tests__/normalize-list-view.test.ts index 6040d34de8..8de52a50db 100644 --- a/packages/core/src/utils/__tests__/normalize-list-view.test.ts +++ b/packages/core/src/utils/__tests__/normalize-list-view.test.ts @@ -649,18 +649,22 @@ describe('rowHeightToDensityMode (#4440)', () => { * grid, because `ViewType` was a hand-written copy of the spec's list and every * structure keyed on it was total over the COPY. * - * These pin the two halves of the fix: the derived vocabulary ACCEPTS `page`, - * and the renderer's drawable set still refuses it — loudly, not silently. + * These pinned the two halves of the fix: the derived vocabulary ACCEPTED + * `page`, and the renderer's drawable set refused it — loudly. Since + * `@objectstack/spec` 17.5.0 retired `page` (objectui#11073), the derived + * vocabulary follows the spec and refuses it too, and a stored `page` view + * degrades like any kind outside the vocabulary. */ describe('the derived view-type vocabulary (objectui#8127)', () => { - it('accepts the spec`s `page` list-view type, and refuses a typo', () => { - // The published validator's answer. Before the derivation, `ViewTypeSchema` - // was an eleven-arm hand copy that refused `page` while `ViewKindEnum` - // (already derived) accepted it — two faces of one vocabulary disagreeing. - expect(ViewTypeSchema.safeParse('page').success).toBe(true); - // Control: the vocabulary is still closed, so the `true` above is a reading - // and not an enum that accepts anything. + it('follows the spec: refuses the retired `page` list-view type, and a typo, and accepts a drawable kind', () => { + // The published validator's answer is DERIVED from the spec. It accepted + // `page` while the resolved spec published it; `@objectstack/spec` 17.5.0 + // retired the kind (objectui#11073), and the derivation dropped it with it. + expect(ViewTypeSchema.safeParse('page').success).toBe(false); expect(ViewTypeSchema.safeParse('nonsense-control').success).toBe(false); + // Control: the `false`s above are readings of a closed enum, not an enum + // that refuses everything. + expect(ViewTypeSchema.safeParse('kanban').success).toBe(true); }); it('keeps the two objectui view CATEGORIES, which have no spec counterpart', () => { @@ -690,22 +694,16 @@ describe('the undrawable-kind fallback is loud (objectui#8127)', () => { vi.restoreAllMocks(); }); - it('warns once for `page` and still falls back to the caller`s grid default', () => { + it('treats the retired `page` as a kind outside the vocabulary: a grid, and no undrawable-kind warning', () => { + // Until objectui#11073 `page` was a kind the published validator ACCEPTED + // and ListView could not draw, so the fallback said so once. The spec + // retired it at 17.5.0 and both published faces refuse it now, so a stored + // `page` view is a document outside the vocabulary, exactly like a typo: + // the fallback is unchanged and the warning that it was a VALID kind is gone. const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); const out = normalizeListViewSchema({ type: 'list-view', objectName: 'account', specType: 'page' }); - - // The fallback itself is UNCHANGED — this card does not add a page renderer. expect((out as { viewType?: string }).viewType).toBe('grid'); - - // What changed is that it says so. Before this, `page` and a typo produced - // byte-identical output and silence. - expect(warn).toHaveBeenCalledTimes(1); - expect(String(warn.mock.calls[0]?.[0])).toContain('page'); - - // Once per process, not once per render: this runs inside a normalizer a - // render can call on every keystroke. - normalizeListViewSchema({ type: 'list-view', objectName: 'account', specType: 'page' }); - expect(warn).toHaveBeenCalledTimes(1); + expect(warn).not.toHaveBeenCalled(); }); it('stays silent for the objectui view categories and for a plain typo', () => { 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 index f6189d5c96..7ea6c79ce1 100644 --- 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 @@ -33,6 +33,13 @@ * control so the green below can never be the green of a check that stopped * checking. * + * ⚠️ UPDATE (objectui#11073): the resolved `@objectstack/spec` is 17.5.0, which + * carries the retirement too, so both legs now read `main`'s shape. The real + * table's `page` row was deleted — the `Extract` spelling is what let that be a + * plain deletion — and the runtime row below was re-pinned to the silence that + * follows. The simulated legs are kept: they are the proof that the next + * retirement lands the same way. + * * ⚠️ 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 @@ -190,11 +197,13 @@ describe('the undrawable table survives an upstream retirement (objectui#9880)', 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. + it('no longer explains a `page` view: the resolved spec refuses one, and the row retired with it', async () => { + // Through `@objectstack/spec` 17.4.0 the retired row was kept at RUNTIME on + // purpose: while the resolved spec accepted a `page` view an author could + // still write one, and the degrade to a grid had to say so. 17.5.0 refuses + // `type: 'page'` at the door, both published `@object-ui/types` faces refuse + // it, and the row was deleted as it said it would be (objectui#11073). A + // stored `page` view now degrades like any kind outside the vocabulary. // // ⚠️ The warning is once-per-kind-per-MODULE-INSTANCE, and the `unit` // project runs with `isolate: false`, so a sibling suite that already @@ -213,13 +222,13 @@ describe('the undrawable table survives an upstream retirement (objectui#9880)', }); expect((out as { viewType?: string }).viewType).toBe('grid'); - expect(warn).toHaveBeenCalledTimes(1); - expect(String(warn.mock.calls[0]?.[0])).toContain('"page"'); + expect(warn).not.toHaveBeenCalled(); - // 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' }); + // Control: a kind outside the vocabulary answers exactly the same way, so + // the silence above is `page` being an ordinary unknown now — not a + // normalizer that stopped degrading. + const typo = fresh.normalizeListViewSchema({ type: 'list-view', objectName: 'account', specType: 'nonsense' }); + expect((typo as { viewType?: string }).viewType).toBe('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 cfe989eec2..7cb80439b2 100644 --- a/packages/core/src/utils/normalize-list-view.ts +++ b/packages/core/src/utils/normalize-list-view.ts @@ -216,7 +216,8 @@ const LIST_VIEW_KINDS: Record = { * 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 + * spec which still carries the kind (as it did with `page` through 17.4.0). + * `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`. @@ -226,19 +227,18 @@ const LIST_VIEW_KINDS: Record = { * - `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. + * `page` had a row here until objectui#11073. objectstack#17063 removed + * `type: 'page'` from the list-view enum, and the row answered for it while this + * repository still resolved a spec that published the kind. `@objectstack/spec` + * 17.5.0 carries the removal: both published `@object-ui/types` faces refuse + * `page`, `Extract` no longer extracted the row, and the residual pins that held + * it (`normalize-list-view.pageResidual-8429.test.ts`) were converted, so it was + * deleted as it said it would be. A stored `page` view now degrades like any + * kind outside the vocabulary. */ 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; /** @@ -248,9 +248,10 @@ const UNDRAWABLE_VIEW_KINDS = { * `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). + * the table having to lose it at runtime. That is what made one spelling + * correct against both the published spec (which published `page` through + * 17.4.0) and a spec built from objectstack `main` (which retired it), and what + * made the row's deletion at 17.5.0 a plain deletion (objectui#11073). */ type UndrawableViewKind = Extract; diff --git a/packages/core/src/utils/reference-keys.ts b/packages/core/src/utils/reference-keys.ts index 5f2c8d6206..9878aaabf7 100644 --- a/packages/core/src/utils/reference-keys.ts +++ b/packages/core/src/utils/reference-keys.ts @@ -161,16 +161,20 @@ import { FieldSchema } from '@objectstack/spec/data'; * typed`, which drives every snake twin the live spec implies through this * choke point. * - * ⛔ The `id_field` slice is BLOCKED, and not on a card. `@objectstack/spec`'s + * ⛔ The `id_field` slice is NOT TAKEN, and not on a card. `@objectstack/spec`'s * `FIELD_KEY_GUIDANCE` grew an `id_field` row explaining why the key has no - * successor, but that row is in NO PUBLISHED version — measured against 17.3.0 - * (installed) and 17.4.0 (newest on npm), both zero occurrences, with - * `startingNumber` as the lit control in the same read. What is missing is a - * RELEASE, not a decision. Quoting that sentence from a local copy here was - * refused (a second copy of contract prose is the drift AGENTS.md #0.1 exists - * to stop) and so was reading it optionally with a local fallback (an invisible - * fallback that silently degrades on an older spec is this card's own defect - * class). Until the cut lands, `id_field` is simply left alone. + * successor. When this was written the row was in no published version — + * measured then against 17.3.0 and 17.4.0, both zero occurrences, with + * `startingNumber` as the lit control in the same read — so what was missing + * was a RELEASE, not a decision. The release has shipped: the installed 17.5.0 + * carries the row (objectui#11073 re-measured it: one `id_field:` guidance entry + * in `dist/data`). Quoting that sentence from a local copy here was refused (a + * second copy of contract prose is the drift AGENTS.md #0.1 exists to stop) and + * so was reading it optionally with a local fallback (an invisible fallback + * that silently degrades on an older spec is this card's own defect class); + * neither refusal moves. Reading the published row is now possible and is its + * own change — the bump that made it possible did not take it, so `id_field` + * is still left alone here. * * ## What this arm deliberately does NOT do * diff --git a/packages/core/src/utils/text-comparand.ts b/packages/core/src/utils/text-comparand.ts index c297821394..c1fbac8906 100644 --- a/packages/core/src/utils/text-comparand.ts +++ b/packages/core/src/utils/text-comparand.ts @@ -83,61 +83,19 @@ export function describeComparand(target: unknown): string { } /** - * Is this comparand one of the two shapes `FILTER_TEXT_CASES` declares REFUSED - * for the case-insensitive contains operator? + * THE CONTRACT HALF, now the spec's own (objectui#11073). * - * The discrimination, not a re-reading of it: `typeof target !== 'string'` - * answers the "a non-string $icontains comparand is REFUSED" row and - * `target === ''` answers the "an empty $icontains comparand is REFUSED" row. - * It is written once so a face cannot drift into judging a slightly different - * set — the failure mode that produced this module's card, where the same - * authored filter was refused in one dialect and lowered onto the wire in - * another. + * `isRefusedTextComparand` (the discrimination: a non-string or empty + * comparand) and `textComparandRefusalReason` (the half of the message the + * contract owns, no face's envelope on it) were written here once, for both + * faces. `@objectstack/spec` 17.5.0 publishes the SAME two functions from + * `@objectstack/spec/data`, byte for byte the implementation this module carried + * — measured against the published `dist` when the bump landed, message text + * included. So they are re-exported rather than kept as a copy under the spec's + * own names, which `pnpm check:spec-symbols` refuses (a hand-written copy under + * a spec name is one release away from drifting). Both faces still import them + * from here, so the ONE-place property this module exists for is unchanged; the + * one place is now the spec. `describeComparand` above stays local: the spec + * does not export it. */ -export function isRefusedTextComparand(target: unknown): boolean { - return typeof target !== 'string' || target === ''; -} - -/** - * The REASON a case-insensitive-contains comparand is refused — the half of the - * message the CONTRACT owns, with no face's envelope on it. - * - * Returned without a leading capital and without a trailing period so each face - * can seat it in its own sentence: the matcher logs `…ValueDataSource: . - * Rows are excluded…`, the converter throws `[ObjectUI] The . `. - * Those two seatings are exactly what objectui#8748 and objectui#9001 already - * shipped, byte for byte — this function is where the shared bytes moved to, not - * a rewording of them. `mustMention` is what makes that load-bearing rather than - * stylistic: a differently-worded refusal is a different failure to honour the - * same row. - * - * `operator` is the spelling that ACTUALLY ARRIVED, never a canonical one - * substituted for it. That is `ValueDataSource`'s own answer to the question and - * it is measurable on today's tree: its `$` arm names `$icontains` and its AST - * arm names `icontains`, each from the node in hand. Telling an author about a - * spelling their dialect does not have is the same class of misdirection the - * `describeComparand` guard above exists to avoid. - */ -export function textComparandRefusalReason( - field: string, - operator: string, - target: unknown, -): string { - const declared = - `@objectstack/spec's FILTER_TEXT_CASES declares this shape refused ` - + `(INVALID_FILTER); the declared comparand for '${operator}' is a NON-EMPTY STRING`; - if (target === '') { - return ( - `filter comparand for field '${field}' on operator '${operator}' is the EMPTY ` - + `STRING. Every value contains the empty substring, so evaluating it is a ` - + `predicate that constrains nothing. ${declared}. Drop the condition instead ` - + `of sending an empty comparand` - ); - } - return ( - `filter comparand for field '${field}' on operator '${operator}' is ` - + `${target === null ? 'null' : typeof target} (${describeComparand(target)}), ` - + `not a string. Coercing it would answer a query nobody wrote. ${declared}. ` - + `Write the comparand as a string` - ); -} +export { isRefusedTextComparand, textComparandRefusalReason } from '@objectstack/spec/data'; diff --git a/packages/data-objectstack/package.json b/packages/data-objectstack/package.json index a8c9152584..731bcc2f49 100644 --- a/packages/data-objectstack/package.json +++ b/packages/data-objectstack/package.json @@ -32,7 +32,7 @@ "@object-ui/core": "workspace:*", "@object-ui/types": "workspace:*", "@objectstack/client": "^17.3.0", - "@objectstack/spec": "^17.4.0" + "@objectstack/spec": "^17.5.0" }, "devDependencies": { "@object-ui/test-support": "workspace:*", diff --git a/packages/fields/src/__tests__/NumberScaleOutOfRange-10071.test.tsx b/packages/fields/src/__tests__/NumberScaleOutOfRange-10071.test.tsx deleted file mode 100644 index 3e895a1e4c..0000000000 --- a/packages/fields/src/__tests__/NumberScaleOutOfRange-10071.test.tsx +++ /dev/null @@ -1,193 +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#10071 — the two NON-percent readers of a declared `scale` hit the - * same engine ceiling objectui#9808 guarded on the percent faces. - * - * ── What fails before the repair ──────────────────────────────────────── - * Measured on this container's node (v22): - * - * ``` - * NumberCellRenderer, scale 101 -> RangeError: minimumFractionDigits value is out of range. - * computeRow, column scale 101 -> RangeError: toFixed() digits argument must be between 0 and 100 - * ``` - * - * The cell face reaches `Intl` through `formatDisplayNumber`, whose `catch` - * retries WITHOUT the locale but WITH the same digits, so the retry refuses - * the same width and the throw escapes the render. `computeRow` runs on every - * edit of a master-detail line grid, so there the throw lands in an event - * handler instead of a render — the edit is lost, not just the cell. - * - * ── The ruling ────────────────────────────────────────────────────────── - * The one objectui#9808 already made, reused rather than restated: a width the - * installed spec ACCEPTS and the engine REFUSES is clamped to the ceiling and - * reported once on the console. Everything else keeps its pre-existing path. - * The reasoning and the SUNSET condition live on `../widgets/percent-scale`; - * the last block below re-asks the installed spec door whether the premise - * still holds for THESE two readers' declarations. - */ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import React from 'react'; -import { render, cleanup } from '@testing-library/react'; -import '@testing-library/jest-dom'; - -import { NumberCellRenderer } from '../index'; -import { computeRow, type GridColumn } from '../widgets/GridField'; -// Module-private by design (see its header), so imported by module path. -import { PERCENT_SCALE_CEILING as CEILING } from '../widgets/percent-scale'; -import { FieldSchema } from '@objectstack/spec/data'; - -let warn: ReturnType; - -beforeEach(() => { - warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); -}); - -afterEach(() => { - warn.mockRestore(); - cleanup(); -}); - -/** The ruling's own diagnostic marker — never a `console.warn` call count, - * for the shared-channel reason `PercentScaleOutOfRange-9808.test.tsx`'s - * `MARKER` records. */ -const MARKER = 'objectui#10071'; - -const warnings = () => warn.mock.calls.map((c: unknown[]) => String(c[0])).join('\n'); - -/** Digits after the decimal mark in the first number of a rendered string. */ -const fractionDigits = (text: string): number => { - const m = text.match(/\d+[.,](\d+)/); - return m ? m[1].length : 0; -}; - -const renderNumberCell = (value: unknown, field: Record) => - render(); - -const cellText = () => document.body.textContent ?? ''; - -/** A master-detail line: `amount = quantity * unit_price`, as `deriveColumns` - * builds it from a child field carrying `expression` + `scale`. */ -const amountColumn = (extra: Partial): GridColumn => ({ - name: 'amount', - type: 'number', - computed: true, - expr: 'record.quantity * record.unit_price', - ...extra, -}); - -describe('an out-of-range `scale` on the NUMBER cell is clamped and reported (objectui#10071)', () => { - it('renders instead of taking out the subtree', () => { - expect(() => renderNumberCell(1.5, { scale: 101 })).not.toThrow(); - expect(fractionDigits(cellText())).toBe(CEILING); - }); - - it('names the declared width, the rendered width and the card', () => { - renderNumberCell(1.5, { scale: 102 }); - const said = warnings(); - expect(said).toContain('102'); - expect(said).toContain(String(CEILING)); - expect(said).toContain(MARKER); - }); -}); - -describe('an out-of-range computed-column `scale` in the GRID is clamped and reported (objectui#10071)', () => { - it('computes the row instead of throwing out of the edit', () => { - const row = { quantity: 3, unit_price: 1.25 }; - let next: Record = {}; - expect(() => { - next = computeRow([amountColumn({ scale: 103 })], row); - }).not.toThrow(); - // A 100-digit round of an exact binary value is the value itself. - expect(next.amount).toBe(3.75); - }); - - it('names the declared width, the rendered width and the card', () => { - computeRow([amountColumn({ scale: 104 })], { quantity: 1, unit_price: 2 }); - const said = warnings(); - expect(said).toContain('104'); - expect(said).toContain(String(CEILING)); - expect(said).toContain(MARKER); - }); -}); - -describe('an IN-RANGE `scale` is untouched on both readers (objectui#10071 control)', () => { - it('number cell: an ordinary scale pads exactly as before', () => { - renderNumberCell(16, { scale: 2 }); - expect(cellText()).toContain('16.00'); - expect(warnings()).not.toContain(MARKER); - }); - - it('number cell: the ceiling itself is inclusive', () => { - expect(() => renderNumberCell(1.5, { scale: CEILING })).not.toThrow(); - expect(fractionDigits(cellText())).toBe(CEILING); - expect(warnings()).not.toContain(MARKER); - }); - - it('grid: an ordinary scale still rounds', () => { - const next = computeRow([amountColumn({ scale: 2 })], { quantity: 3, unit_price: 0.3333 }); - expect(next.amount).toBe(1); - expect(warnings()).not.toContain(MARKER); - }); - - it('grid: the ceiling itself is inclusive', () => { - expect(() => computeRow([amountColumn({ scale: CEILING })], { quantity: 1, unit_price: 2 })).not.toThrow(); - expect(warnings()).not.toContain(MARKER); - }); - - it('grid: a currency column takes its currency minor unit, in range and unreported', () => { - // With no authored `scale`, the width is the resolved currency's ISO 4217 - // minor unit now, never a literal 2 (objectui#10355) — pinned there; this - // is the ceiling's control that the currency path stays quiet. - const next = computeRow([amountColumn({ type: 'currency' })], { quantity: 3, unit_price: 0.3333 }, 'USD'); - expect(next.amount).toBe(1); - expect(warnings()).not.toContain(MARKER); - }); - - it('a NEGATIVE width keeps its pre-existing path on both readers', () => { - // Refused by the installed spec (`int().min(0)`, asked below), so not this - // renderer's to rescue — AGENTS.md #0.1, the same inversion as objectui#9808. - expect(() => renderNumberCell(1.5, { scale: -1 })).toThrow(RangeError); - expect(() => computeRow([amountColumn({ scale: -1 })], { quantity: 1, unit_price: 2 })).toThrow(RangeError); - expect(warnings()).not.toContain(MARKER); - }); -}); - -describe('SUNSET — the premise, re-asked of the installed spec for these readers (objectui#10071)', () => { - // `deriveColumns` copies a child field's `scale` onto a computed column only - // when the field carries an `expression`, so that is the document asked here. - const computedNumber = { - name: 'amount', - type: 'number', - label: 'Amount', - expression: 'quantity * unit_price', - }; - - it('CONTROL: the door is strict — an unknown key is refused by name', () => { - const res = FieldSchema.safeParse({ ...computedNumber, scale: 2, bogusKey10071: true }); - expect(res.success).toBe(false); - if (res.success) return; - expect( - res.error.issues.find( - (i) => i.code === 'unrecognized_keys' && - ((i as { keys?: string[] }).keys ?? []).includes('bogusKey10071'), - ), - ).toBeDefined(); - }); - - it('the installed spec still ACCEPTS a number `scale` above the ceiling', () => { - expect( - FieldSchema.safeParse({ ...computedNumber, scale: CEILING + 1 }).success, - 'SUNSET REACHED — the installed `@objectstack/spec` now REFUSES a `scale` above ' + - `${CEILING}. The clamp in \`packages/fields/src/widgets/percent-scale.ts\` is from this ` + - 'moment the lenient fallback AGENTS.md #0.1 bans: delete it and every call site that ' + - 'imports it, with this file — do NOT relax this assertion.', - ).toBe(true); - }); -}); diff --git a/packages/fields/src/__tests__/PercentScaleOutOfRange-9808.test.tsx b/packages/fields/src/__tests__/PercentScaleOutOfRange-9808.test.tsx deleted file mode 100644 index 1e054a31cb..0000000000 --- a/packages/fields/src/__tests__/PercentScaleOutOfRange-9808.test.tsx +++ /dev/null @@ -1,361 +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#9808 — a `scale` above the engine's fraction-width ceiling crashed - * BOTH percent faces, and `@objectstack/spec` accepts the declaration. - * - * ── What fails before the repair ──────────────────────────────────────── - * Every row below for a width the spec accepts and the engine refuses, with an - * UNCAUGHT `RangeError` — measured on - * this container's node (v22.22.2), all three sites report the same message: - * - * ``` - * PercentField readonly, scale 101 -> RangeError: toFixed() digits argument must be between 0 and 100 - * PercentField editable, scale 101 -> RangeError: toFixed() digits argument must be between 0 and 100 - * PercentCellRenderer, scale 101 -> RangeError: toFixed() digits argument must be between 0 and 100 - * ``` - * - * ⚠️ The cell face reports `toFixed` even though its formatter is `Intl`: - * `formatPercentBody` catches the `Intl` `RangeError` and its fallback - * `displayValue.toFixed(precision)` refuses the same width from inside the - * `catch`. The recovery arm is what crashed the render. - * - * For a React render an uncaught throw takes out the subtree, so the assertion - * that matters is that the component RENDERS — every row here drives the - * component and reads the DOM it produced. - * - * ── The ruling these rows pin, and its EXACT reach ────────────────────── - * ONE ruling, both faces: a width the installed spec ACCEPTS and the engine - * REFUSES — a non-negative integer above the ceiling — is CLAMPED to the - * ceiling and REPORTED on the console. ⛔ And nothing else is touched. A - * negative, non-finite or non-integer `scale` is a declaration the installed - * spec already REFUSES, so rescuing it in a renderer would be the - * lenient-fallback-around-bad-input AGENTS.md #0.1 lists by name; those widths - * keep the behaviour they had before this ruling existed, which the two - * `pre-existing path` rows below pin by asserting that they still THROW. - * - * The reasoning — why a clamp and not a refusal, and why the bounds are the - * formatters' own rather than this package's — is on - * `../widgets/percent-scale`. Its SUNSET condition is not prose here: the last - * block asks the installed `FieldSchema` whether the premise still holds, on - * every run. - * ⛔ The declaration-side upper bound is NOT this card's: it lives in - * `@objectstack/spec` and was filed as objectstack#18972. - * - * ── The in-range control is the point, not decoration ─────────────────── - * A change that moved ordinary percent fields would be a different card, so - * the second block pins that an in-range declaration renders exactly as it did - * and draws NO diagnostic of this ruling's. `scale: 100` is in that block on - * purpose: the ceiling is INCLUSIVE, and a clamp that fired there would be a - * silent off-by-one nobody would see. - * - * ⚠️ Those controls assert on the ruling's own MARKER and ⛔ never on a - * `console.warn` call count — see `MARKER` below for the measurement that - * forced the distinction. Each control must hold when it is the ONLY row that - * runs. - * - * ⚠️ The diagnostic is deduplicated by DECLARED VALUE (a percent column - * re-renders per row), so every row below that expects a warning declares a - * DIFFERENT out-of-range width. Reusing one across two rows would leave the - * second asserting on a warning the first consumed. - */ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import React from 'react'; -import { render, screen, cleanup } from '@testing-library/react'; -import '@testing-library/jest-dom'; - -import { PercentField } from '../widgets/PercentField'; -// The ruling's own module — module-private by design, so the pin imports that -// module path exactly as it imports the widget, and NOT the package barrel. -import { PERCENT_SCALE_CEILING } from '../widgets/percent-scale'; -// The installed spec's own door — the instrument the SUNSET block below asks -// on every run, so the ruling's premise is re-derived rather than remembered. -import { FieldSchema } from '@objectstack/spec/data'; -import { PercentCellRenderer } from '../index'; - -let warn: ReturnType; - -beforeEach(() => { - warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); -}); - -afterEach(() => { - warn.mockRestore(); - cleanup(); -}); - -const percentField = (field: Record) => - ({ type: 'percent', name: 'rate', ...field }) as any; - -const renderReadonly = (value: number, field: Record) => - render( {}} field={percentField(field)} readonly />); - -const renderEditable = (value: number, field: Record) => - render( {}} field={percentField(field)} />); - -const renderCell = (value: unknown, field: Record) => - render(); - -/** The text the readonly face drew, percent sign included. */ -const readonlyText = () => document.body.textContent ?? ''; - -/** The granularity the browser's spinner and arrow keys obey. */ -const inputStep = () => screen.getByRole('spinbutton').getAttribute('step'); - -/** Digits after the decimal mark in the first number of a rendered string. */ -const fractionDigits = (text: string): number => { - const m = text.match(/\d+[.,](\d+)/); - return m ? m[1].length : 0; -}; - -/** Every console line this render produced, joined. */ -const warnings = () => warn.mock.calls.map((c: unknown[]) => String(c[0])).join('\n'); - -/** - * What the ruling's own diagnostic is recognised BY. - * - * ⚠️ Every MUST-NOT-CHANGE control below asserts on THIS, and ⛔ never on - * `expect(warn).not.toHaveBeenCalled()`. `console.warn` is a SHARED channel: - * the first render in a module emits react-i18next's `useTranslation` warning - * through it, so a call-count control passes only while some earlier row in - * this file has already consumed that first warning — measured, each such - * control read `1 failed` when run alone with `-t`. A control that holds only - * in file order cannot tell "the clamp stayed silent" from "another warning - * got there first", which is ⛔ not a control at all. - */ -const MARKER = 'objectui#9808'; - -describe('an out-of-range percent `scale` is clamped and reported, not thrown (objectui#9808)', () => { - it('renders the readonly edit face instead of taking out the subtree', () => { - // The card's headline reading. Before the repair this call threw - // `RangeError` out of `render` and no DOM existed to assert on. - expect(() => renderReadonly(0.25, { scale: 101 })).not.toThrow(); - - expect(readonlyText()).toContain('%'); - // Clamped to the ceiling, not to this widget's own absent-`scale` 2 — the - // author asked for the widest width available and got it. - expect(fractionDigits(readonlyText())).toBe(PERCENT_SCALE_CEILING); - }); - - it('says so on the console rather than guessing quietly', () => { - renderReadonly(0.25, { scale: 102 }); - - const said = warnings(); - // The declared width, the width actually rendered, and the card — an - // author reading this line can act on it without opening the renderer. - expect(said).toContain('102'); - expect(said).toContain(String(PERCENT_SCALE_CEILING)); - expect(said).toContain('objectui#9808'); - }); - - it('keeps the editable face and its spinner step renderable', () => { - expect(() => renderEditable(0.25, { scale: 103 })).not.toThrow(); - - // `step` is `Math.pow(10, -scale).toFixed(scale)`, the second `toFixed` - // site on this face; before the repair it refused the same width. - const step = inputStep(); - expect(step).not.toBeNull(); - expect(fractionDigits(step!)).toBe(PERCENT_SCALE_CEILING); - }); - - it('renders the read-only CELL face, which fails through `Intl` and lands in `toFixed`', () => { - expect(() => renderCell(0.25, { scale: 104 })).not.toThrow(); - - // The cell draws the bar and the number; the bar contributes no text. - const cell = screen.getByRole('progressbar').parentElement!.textContent ?? ''; - expect(cell).toContain('%'); - expect(fractionDigits(cell)).toBe(PERCENT_SCALE_CEILING); - }); - - it('takes the SAME ruling on both faces for the same declaration', () => { - // The card's own bound: ⛔ not one face clamping while the other refuses. - // Both are driven here with one declaration and compared to each other. - renderReadonly(0.25, { scale: 105 }); - const fromWidget = fractionDigits(readonlyText()); - cleanup(); - - renderCell(0.25, { scale: 105 }); - const fromCell = fractionDigits(screen.getByRole('progressbar').parentElement!.textContent ?? ''); - - expect(fromWidget).toBe(fromCell); - expect(fromWidget).toBe(PERCENT_SCALE_CEILING); - }); - - it('leaves a NEGATIVE declared width on its pre-existing path, deliberately', () => { - // ⛔ This row is INVERTED on purpose, and the inversion is the ruling. - // - // `(25).toFixed(-1)` refuses exactly as `(25).toFixed(101)` does, so a - // clamp over the whole domain would have rescued this too — and that is - // precisely what it must NOT do. A negative `scale` is a declaration the - // INSTALLED spec already REFUSES (`z.number().int().min(0)`, measured - // below in the sunset probe's own instrument), so making it render would - // be a renderer-side default around bad input: AGENTS.md #0.1's own listed - // example, inside the module that cites #0.1 as its reason for existing. - // - // ⇒ it keeps the behaviour it had before this ruling existed. The place to - // repair a negative `scale` is the producer that wrote it. - expect(() => renderReadonly(0.25, { scale: -1 })).toThrow(RangeError); - expect(warnings()).not.toContain(MARKER); - }); - - it('leaves a NON-FINITE declared width on its pre-existing path, for the same reason', () => { - // Same inversion, same ground: `Infinity` is refused by the installed - // spec's `int()` and is not this renderer's to reinterpret. - expect(() => renderReadonly(0.25, { scale: Number.POSITIVE_INFINITY })).toThrow(RangeError); - expect(warnings()).not.toContain(MARKER); - - cleanup(); - // And the cell face agrees — one ruling means one answer on both faces for - // what the ruling does NOT cover, too. - expect(() => renderCell(0.25, { scale: Number.POSITIVE_INFINITY })).toThrow(RangeError); - }); -}); - -describe('an IN-RANGE percent `scale` is untouched by the ruling (objectui#9808 control)', () => { - it('renders an ordinary two-decimal percent field exactly as before', () => { - renderReadonly(0.25, { precision: 10, scale: 2 }); - - // The row objectui#9568 pinned, re-read here: the repair must be invisible - // to it. - expect(readonlyText()).toContain('25.00%'); - expect(warnings()).not.toContain(MARKER); - }); - - it("leaves the editable face's step alone", () => { - renderEditable(0.25, { scale: 2 }); - - expect(inputStep()).toBe('0.01'); - expect(warnings()).not.toContain(MARKER); - }); - - it('leaves the cell face alone', () => { - renderCell(0.25, { scale: 2 }); - - expect(screen.getByRole('progressbar').parentElement!.textContent).toContain('25.00'); - expect(warnings()).not.toContain(MARKER); - }); - - it('does not fire AT the ceiling — the bound is inclusive', () => { - // An off-by-one here would clamp a legal declaration and warn about it, - // which is the quiet failure this control exists to catch. - expect(() => renderReadonly(0.25, { scale: PERCENT_SCALE_CEILING })).not.toThrow(); - - expect(fractionDigits(readonlyText())).toBe(PERCENT_SCALE_CEILING); - expect(warnings()).not.toContain(MARKER); - }); - - it('leaves a width the ENGINE already coerces exactly where it was', () => { - // ⚠️ MEASURED, and the reason the ruling tests "could the engine render - // this" rather than "did the value change spelling": both formatters - // coerce for themselves — `(25).toFixed('2')` and - // `maximumFractionDigits: '2'` render two decimals, and so does `2.9`. A - // guard that refused a non-number would MOVE a cell that renders today, - // which is a silent regression on off-spec-but-working metadata and not - // this card's business. - renderCell(0.25, { scale: '2' }); - expect(screen.getByRole('progressbar').parentElement!.textContent).toContain('25.00'); - cleanup(); - - renderCell(0.25, { scale: 2.9 }); - expect(screen.getByRole('progressbar').parentElement!.textContent).toContain('25.00'); - - expect(warnings()).not.toContain(MARKER); - }); - - it('leaves a percent field declaring NO `scale` on its own face default', () => { - // ⛔ This card does not touch what an absent `scale` means, and the two - // faces still spell it differently (widget 2, cell 0) — the disagreement - // objectui#9568 declined to make a premise. - renderReadonly(0.25, {}); - expect(readonlyText()).toContain('25.00%'); - cleanup(); - - renderCell(0.25, {}); - expect(screen.getByRole('progressbar').parentElement!.textContent).toContain('25%'); - - expect(warnings()).not.toContain(MARKER); - }); -}); - -describe('SUNSET — the premise this ruling rests on, re-derived every run (objectui#9808)', () => { - /** - * ⭐ This block exists because a sentence is not an instrument. - * - * `percent-scale.ts` clamps rather than refuses for ONE reason: the - * `@objectstack/spec` this repository INSTALLS accepts a percent field - * declaring a `scale` above the ceiling, so refusing it in a renderer would - * invent a contract stricter than the one the renderer is built against - * (AGENTS.md #0.1 in mirror image). - * - * ⚠️ That premise is perishable, and it has already been observed to perish: - * the upstream bound (objectstack#18972, landed as objectstack#19083) is on - * the spec repository's `main` and is NOT in the version resolved here, and - * it landed minutes after the ruling's docblock was written. Nothing in a - * tree notices prose going stale — so the premise is asked of the installed - * door here instead, and the day a spec bump answers differently this row - * goes red and says what to do about it. - */ - const percentDocument = { - name: 'discount_rate', - type: 'percent', - label: 'Discount Rate', - }; - - it('CONTROL: the installed spec door is live and strict', () => { - // Without this, an "accepted" verdict below could mean the door admits - // everything — or that the import resolved to something that parses - // nothing. A refusal BY NAME proves the instrument is reading the document. - const res = FieldSchema.safeParse({ ...percentDocument, scale: 2, bogusKey9808: true }); - expect(res.success).toBe(false); - if (res.success) return; - expect( - res.error.issues.find( - (i) => i.code === 'unrecognized_keys' && - ((i as { keys?: string[] }).keys ?? []).includes('bogusKey9808'), - ), - 'the spec door admitted an unknown key — every verdict in this block proves nothing', - ).toBeDefined(); - }); - - it('CONTROL: an ordinary `scale` is accepted, so acceptance is a reading', () => { - expect(FieldSchema.safeParse({ ...percentDocument, scale: 2 }).success).toBe(true); - }); - - it('the installed spec still ACCEPTS a `scale` above the renderable ceiling', () => { - const verdict = FieldSchema.safeParse({ - ...percentDocument, - scale: PERCENT_SCALE_CEILING + 1, - }); - - expect( - verdict.success, - 'SUNSET REACHED — the installed `@objectstack/spec` now REFUSES a percent `scale` above ' + - `${PERCENT_SCALE_CEILING}, so the premise objectui#9808 rested on has expired. ` + - 'The clamp in `packages/fields/src/widgets/percent-scale.ts` is from this moment the ' + - 'lenient renderer-side fallback AGENTS.md #0.1 bans, because the declaration it rescues ' + - 'can no longer be authored. ⇒ DELETE that module, every call site that imports it ' + - '(the percent faces and, since objectui#10071, the number faces), this whole test file ' + - 'and the changeset note — do NOT relax this assertion to make the suite green.', - ).toBe(true); - }); - - it('and it REFUSES the widths this ruling deliberately does not rescue', () => { - // The other half of the #0.1 reading, asked of the same door: these are - // declarations the contract already rejects, which is exactly why - // `renderablePercentScale` leaves them on their pre-existing path instead - // of inventing a width for them. - for (const scale of [-1, 2.5, Number.POSITIVE_INFINITY, Number.NaN]) { - expect( - FieldSchema.safeParse({ ...percentDocument, scale }).success, - `the installed spec ACCEPTED scale ${String(scale)} — if it is now a legal declaration, ` + - 'the renderer owes it a rendering and this ruling needs re-deciding, not patching', - ).toBe(false); - } - }); -}); diff --git a/packages/fields/src/index.tsx b/packages/fields/src/index.tsx index 5da7cc7f8a..5d7a549f18 100644 --- a/packages/fields/src/index.tsx +++ b/packages/fields/src/index.tsx @@ -24,14 +24,6 @@ import { withFieldCarrier } from './withFieldCarrier.js'; // Pure formatting rule shared with `AddressField`'s readonly branch — no React, // so this does not pull the widget out of its lazy chunk (objectui#4037). import { formatAddress, type AddressValue } from './widgets/address-format.js'; -// The ONE out-of-range `scale` ruling both percent faces take (objectui#9808). -// Shared with `PercentField` rather than restated here — a second spelling of -// the same domain is exactly the drift `address-format` above exists to -// prevent — and, like `address-format` and `file-affordance` below, -// deliberately NOT re-exported from the `export *` block at the end of this -// file, so this package's published surface is unchanged. Pure, no React, so -// it pulls no widget out of its lazy chunk (objectui#4037). -import { renderablePercentScale, renderableFractionScale } from './widgets/percent-scale.js'; // Module-level cache so multiple renderers fetching the same lookup ID // only trigger one network call. Keyed by `${objectName}:${id}`. It holds the @@ -677,17 +669,10 @@ export function formatPercent(value: number, precision: number = 0, locale?: str // Scale a fraction-stored percent (0.8 → 80%) via the shared core helper, so // the list cell and the dashboard measure formatter (`formatMeasure`) agree. const displayValue = percentDisplayValue(value); - // objectui#9808 — the out-of-range ruling lands HERE rather than at - // `PercentCellRenderer`'s call site, because this is the door the cell face - // actually goes through and the one a future caller cannot step around. - // - // ⚠️ Without it the throw arrives from `toFixed`, not from `Intl`, which is - // worth knowing when reading a stack: `formatPercentBody` CATCHES the - // `Intl` `RangeError` for a width above the engine's ceiling, and its - // fallback `displayValue.toFixed(precision)` refuses the same width from - // inside the `catch`. So the recovery arm was the one that crashed the - // render, and neither arm could have rescued the other. - return formatPercentBody(displayValue, renderablePercentScale(precision), locale); + // The objectui#9808 clamp that stood here was retired at its own SUNSET + // (objectui#11073): `@objectstack/spec` 17.5.0 refuses a `scale` above 100 at + // the declaration, so the width the engine cannot render no longer arrives. + return formatPercentBody(displayValue, precision, locale); } /** @@ -795,12 +780,9 @@ export function NumberCellRenderer({ value, field }: CellRendererProps): React.R // grouping: absent means "decimals unknown", not "integer". The policy and // its interim status live in `formatDisplayNumber`, not here. // - // A declared width above the engine's fraction ceiling is clamped and - // reported, never carried into `Intl` (objectui#10071 — the objectui#9808 - // ruling; see `./widgets/percent-scale.js`). - const scale = typeof numField.scale === 'number' - ? renderableFractionScale(numField.scale, 'number field', 'objectui#10071') - : undefined; + // No clamp: the objectui#10071 one retired at the objectui#9808 SUNSET + // (objectui#11073) — `@objectstack/spec` 17.5.0 refuses a `scale` above 100. + const scale = typeof numField.scale === 'number' ? numField.scale : undefined; // The author's digit-grouping hint (objectui#11026): `FieldSchema.useGrouping`, // declared on `NumberFieldMetadata`, so it is read off the typed `field` and // not through `numField`. An authored boolean overrides the `scale` heuristic diff --git a/packages/fields/src/widgets/GridField.currencyMinorUnit-10355.test.tsx b/packages/fields/src/widgets/GridField.currencyMinorUnit-10355.test.tsx index 91d5c0f085..bcd8df62ca 100644 --- a/packages/fields/src/widgets/GridField.currencyMinorUnit-10355.test.tsx +++ b/packages/fields/src/widgets/GridField.currencyMinorUnit-10355.test.tsx @@ -160,27 +160,9 @@ describe('GridField shows a currency cell in its resolved currency (objectui#103 expect(flat(document.querySelector('[data-computed="amount"]')?.textContent)).toBe('¥1,234.57'); }); - it('an authored currency `scale` above the engine ceiling is clamped on the display too (objectui#10071)', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - try { - expect(() => - render( - - {}} - field={{ columns: [lineColumns[0], lineColumns[1], amountColumn({ scale: 105 })] } as never} - /> - , - ), - ).not.toThrow(); - const said = warn.mock.calls.map((c: unknown[]) => String(c[0])).join('\n'); - expect(said).toContain('105'); - expect(said).toContain('objectui#10071'); - } finally { - warn.mockRestore(); - } - }); + // The objectui#10071 row that stood here pinned the out-of-range clamp on + // this display. The clamp retired at the objectui#9808 SUNSET (objectui#11073): + // `@objectstack/spec` 17.5.0 refuses a `scale` above 100 at the declaration. it('an authored `prefix` replaces the symbol but not the width', () => { const onChange = vi.fn(); diff --git a/packages/fields/src/widgets/GridField.tsx b/packages/fields/src/widgets/GridField.tsx index f31b6eae25..8ea13f8488 100644 --- a/packages/fields/src/widgets/GridField.tsx +++ b/packages/fields/src/widgets/GridField.tsx @@ -25,7 +25,6 @@ import { toDateInputValue, toDateTimeInputValue, fromDateTimeInputValue, isImpos import { useFieldTranslation } from './useFieldTranslation.js'; import { toDomProps } from './toDomProps.js'; import { toHostGroupProps } from './toHostGroupProps.js'; -import { renderableFractionScale } from './percent-scale.js'; /** * GridField / LineItemsField — editable child-grid ("line items") widget. @@ -383,8 +382,9 @@ function currencyWidth(c: GridColumn, currency: string | undefined): number | un * - every other type — the column's declared `scale`, unrounded when absent * (unchanged). * - * Either way the caller clamps the width to the engine's `toFixed` ceiling - * (`renderableFractionScale`, objectui#10071). + * No clamp to the engine's `toFixed` ceiling: the objectui#10071 one retired at + * the objectui#9808 SUNSET (objectui#11073), since `@objectstack/spec` 17.5.0 + * refuses a `scale` above 100 at the declaration. */ function storedFractionScale(c: GridColumn, tenantCurrency: string | undefined): number | undefined { if (c.type !== 'currency') return c.scale; @@ -404,11 +404,7 @@ export function computeRow(columns: GridColumn[], row: Row, tenantCurrency?: str const v = evalArith(c.expr!, next); if (v === null) { next[c.name] = null; continue; } const scale = storedFractionScale(c, tenantCurrency); - // A width above the engine's `toFixed` ceiling is clamped and reported, - // never thrown out of the edit (objectui#10071, the objectui#9808 ruling). - next[c.name] = scale != null - ? Number(v.toFixed(renderableFractionScale(scale, 'grid computed column', 'objectui#10071'))) - : v; + next[c.name] = scale != null ? Number(v.toFixed(scale)) : v; } return next; } @@ -525,17 +521,14 @@ function currencyAdornment(c: GridColumn, currency: string | undefined, locale: * stored value — so an authored `scale` shows that many places, and without * one a yen amount shows no decimals and a dinar amount three. With neither * there is no width to take, and the amount keeps the plain locale format - * this branch always had. An authored width above the engine's ceiling is - * clamped and reported like the stored one (objectui#10071), since `Intl` - * refuses it the same way `toFixed` does. + * this branch always had. * * With no authored `prefix`, the amount is `Intl`'s own currency format, so * the symbol sits where the locale puts it (`¥3,704`, `3.704 ¥` in de-DE). An * authored `prefix` replaces the symbol, not the width. */ function currencyText(c: GridColumn, n: number, currency: string | undefined, locale: string): string { - const declared = currencyWidth(c, currency); - const digits = declared === undefined ? undefined : renderableFractionScale(declared, 'grid currency cell', 'objectui#10071'); + const digits = currencyWidth(c, currency); const width = digits === undefined ? {} : { minimumFractionDigits: digits, maximumFractionDigits: digits }; if (c.prefix || !currency) { return `${currencyAdornment(c, currency, locale)}${formatDisplayNumber(n, { locale, ...width })}`; diff --git a/packages/fields/src/widgets/PercentField.tsx b/packages/fields/src/widgets/PercentField.tsx index fadfb2bcdb..a1a24bf7e2 100644 --- a/packages/fields/src/widgets/PercentField.tsx +++ b/packages/fields/src/widgets/PercentField.tsx @@ -3,10 +3,6 @@ import { Input, Slider, EmptyValue, cn } from '@object-ui/components'; import { FieldWidgetComponentProps } from './types.js'; import { toDomProps } from './toDomProps.js'; import { useBadInputRefusal, BadInputMessage, BAD_INPUT_BORDER } from './numberBadInput.js'; -// The ONE out-of-range `scale` ruling both percent faces take (objectui#9808), -// in its own module so the barrel can share the same spelling without -// publishing it — see that module's header for the ruling and its sunset. -import { renderablePercentScale } from './percent-scale.js'; /** * The stored fraction a typed percentage-point value becomes — computed by @@ -106,12 +102,10 @@ export function PercentField({ value, onChange, field, readonly, error, classNam * should agree at all needs its own ruling, not a side effect of this one. */ const declaredScale = percentField?.scale; - // The width this face resolves, then the ONE out-of-range ruling both faces - // take (objectui#9808) — see `./percent-scale.js` for why a width the engine - // cannot render is clamped and reported rather than refused, why it leaves a - // width the engine already accepts byte-identical, and the SUNSET condition - // that retires the whole clamp. - const scale = renderablePercentScale(typeof declaredScale === 'number' ? declaredScale : 2); + // The width this face resolves. The objectui#9808 out-of-range clamp retired + // at its own SUNSET (objectui#11073): `@objectstack/spec` 17.5.0 refuses a + // `scale` above 100, so a width the engine cannot render no longer arrives. + const scale = typeof declaredScale === 'number' ? declaredScale : 2; // Before the readonly return below: hooks are unconditional (objectui#6780). const { refusal, readBadInput } = useBadInputRefusal('12.5'); diff --git a/packages/fields/src/widgets/__tests__/FilterConditionField.operators.test.ts b/packages/fields/src/widgets/__tests__/FilterConditionField.operators.test.ts index 91bd82dbe3..b650e9c74f 100644 --- a/packages/fields/src/widgets/__tests__/FilterConditionField.operators.test.ts +++ b/packages/fields/src/widgets/__tests__/FilterConditionField.operators.test.ts @@ -21,7 +21,7 @@ * still carry. */ import { describe, it, expect } from 'vitest'; -import { FieldOperatorsSchema } from '@objectstack/spec/data'; +import { FieldOperatorsSchema, FILTER_OPERATORS } from '@objectstack/spec/data'; import { FILTER_BUILDER_OPERATORS, operatorsForFieldType } from '@object-ui/components'; import { condToMongo, @@ -89,7 +89,16 @@ const noTypes = () => undefined; * this comment block warns about one level up, and a reader is what catches it, * not a run. */ -const KNOWN_UNREACHABLE = new Set(['$eq', '$between', '$like', '$ilike']); +/* + * `$empty` arrived with `@objectstack/spec` 17.5.0 (objectui#11073) and is a + * different kind of entry: a STAGED operator, not a builder decision. The spec + * declares it "ahead of its backends and absent from FILTER_OPERATORS. Until + * each face has its arm, the query executors refuse it … the view operators + * is_empty / is_not_empty still lower to $null." A dropdown row emitting it + * would therefore author a filter every executor refuses. It leaves this set + * the day `FILTER_OPERATORS` admits it — the row below the ratchet reddens then. + */ +const KNOWN_UNREACHABLE = new Set(['$eq', '$between', '$like', '$ilike', '$empty']); /** * Pull the operator keys out of a `{ field: { $op: v } }` fragment — descending @@ -170,6 +179,12 @@ describe('every spec field operator is reachable from the builder (#2942)', () = ).toEqual([]); }); + it('the staged `$empty` exclusion expires when FILTER_OPERATORS admits it', () => { + // Lit control: a flag operator that IS in the list. + expect(FILTER_OPERATORS as readonly string[]).not.toContain('$empty'); + expect(FILTER_OPERATORS as readonly string[]).toContain('$null'); + }); + it('every KNOWN_UNREACHABLE token is still a spec operator (the exclusion ratchet)', () => { // A stale exclusion is how a parity test rots into a tautology: if the spec // ever drops one of these, the entry must go too rather than sit there diff --git a/packages/fields/src/widgets/percent-scale.ts b/packages/fields/src/widgets/percent-scale.ts deleted file mode 100644 index c0c17a7be9..0000000000 --- a/packages/fields/src/widgets/percent-scale.ts +++ /dev/null @@ -1,217 +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. - */ - -/** - * ONE ruling over BOTH percent faces (objectui#9808): a declared `scale` - * outside the width the engine can render is CLAMPED into it and REPORTED — - * ⛔ never carried into a formatter that throws. - * - * ── Why this is its OWN module and NOT exported from the barrel ────────── - * `PercentField` and the `formatPercent` door in `index.tsx` both need the one - * spelling, and this package's standing shape for exactly that is a module the - * barrel IMPORTS and deliberately leaves out of its `export *` block — - * `address-format.ts`, `numberBadInput.tsx` and `file-affordance.ts` are all - * shared that way, the last with the comment "deliberately NOT re-exported - * below, so this package's published surface is unchanged". Sharing one - * spelling needs one MODULE, ⛔ not an entry export. Publishing these two - * symbols would also publish this module's side effects — the warn-once `Set` - * and the `console.warn` — as contract, and would make the retirement in the - * SUNSET note below a breaking removal rather than a deletion. - * - * ── The defect ────────────────────────────────────────────────────────── - * `scale: 101` is a declaration `@objectstack/spec` accepts today (its - * `FieldSchema` states "Decimal places (non-negative integer)" and carries no - * upper bound), and it crashed BOTH percent faces with a `RangeError`: - * `PercentField`'s readonly `toFixed` and its `step` attribute, and - * `PercentCellRenderer` through `formatPercent`. ⚠️ On the cell face the throw - * arrives from `toFixed` even though the path is `Intl`: `formatPercentBody` - * CATCHES the `Intl` `RangeError` and its fallback is `toFixed(precision)`, - * which refuses the same width. For a React render either one takes out the - * subtree, and the author had no signal — the platform accepted the - * declaration and failed at render time. - * - * ── Why CLAMP and not REFUSE ──────────────────────────────────────────── - * AGENTS.md #0.1 bans a renderer-side fallback that makes NON-COMPLIANT - * metadata work. This declaration is compliant — and the premise is stated as - * what THIS REPOSITORY INSTALLS, ⛔ never as what some other repository's - * `main` says today. The lockfile resolves `@objectstack/spec` 17.4.0, and - * that door ACCEPTS a percent field declaring a `scale` above the ceiling; a - * refusal here would therefore be this renderer inventing a contract STRICTER - * than the contract it is built against, which is #0.1 in mirror image. - * - * ⚠️ The distinction is not pedantry — it is the whole reason the SUNSET below - * has an instrument. The upstream bound (objectstack#18972, landed as - * objectstack#19083) is MERGED and UNRELEASED: it is on that repository's - * `main` and it is not in 17.4.0, so the premise held here and had already - * stopped holding at source while this very change was open. ⇒ ⛔ never - * re-derive this premise by reading the other repository; the only reading - * that governs this module is what the installed spec's door answers, and - * `PercentScaleOutOfRange-9808.test.tsx` asks it on every run. - * - * What is left for a renderer is the medium's own limit: the value is not in - * doubt, only the width. Refusing to draw the number would blank a grid column - * over a display width, so the faces render what the engine CAN render and say - * that they did. - * - * ── ⚠️ SUNSET — this ruling is CONDITIONAL, and the condition is upstream ── - * The whole #0.1 argument above rests on one fact: the `@objectstack/spec` - * THIS REPOSITORY INSTALLS does not bound `scale`. The trigger is therefore a - * SPEC RELEASE this repository takes, ⛔ not the upstream card landing — that - * already happened (objectstack#18972, landed as objectstack#19083) and - * changed nothing here, because the bound is unreleased at 17.4.0. - * - * On the day this repository installs a spec whose `scale` refuses a width - * above the ceiling, `scale: 101` stops being a compliant declaration — and - * from that moment this clamp IS the lenient renderer-side fallback #0.1 - * bans, kept alive by nothing but its own inertia. ⇒ whoever bumps - * `@objectstack/spec` past that bound owns this module: the clamp becomes - * redundant (the declaration cannot arrive), and the correct move is to DELETE - * this file, ⛔ not to keep a second de-facto contract next to the enforced - * one. - * - * ⭐ And that is a TEST, ⛔ not a sentence. `PercentScaleOutOfRange-9808.test. - * tsx` parses a percent field declaring `PERCENT_SCALE_CEILING + 1` through - * the installed `FieldSchema` and requires it to be ACCEPTED; the bump turns - * that row red and its failure message says this file is to be deleted. An - * argument whose premise has quietly expired reads exactly like a live one — - * which is not a hypothetical here: this module's premise expired at source - * ten minutes after it was written, and nothing in the tree noticed. - * - * ── The reporting half ────────────────────────────────────────────────── - * The clamp alone would be the silent guess objectui#9808 refuses, so a moved - * width always emits a `console.warn` naming the declared value, the width - * actually rendered and the card. Deduplicated by declared value, because a - * percent COLUMN re-renders per row and an undeduplicated warning would bury - * the one line that matters. ⚠️ The dedup is per MODULE LIFETIME and not per - * face: measured, the widget warning for a given width fires once and a cell - * rendering the SAME width afterwards is silent. So the honest claim is "the - * first face to render this width says so", ⛔ not "both faces say so". - * - * ── What reaches this function ────────────────────────────────────────── - * ⚠️ ⛔ NOT only the two percent faces. It sits behind `formatPercent`, whose - * non-test callers reach well past them — a dashboard metric among them, whose - * width comes from a numeral-format PATTERN and ⛔ not from a field face. Every - * one of them takes this ruling for a width above the ceiling, which is a wider - * blast radius than "both percent faces". - * ⚠️ ⛔ How many, and which, is deliberately NOT written here (AGENTS.md #9): - * the live answer is whatever a search for `formatPercent` outside tests - * returns today, and a figure that is still correct is the dangerous case, not - * the safe one — a reader who spot-checks it confirms it and is wrong the - * moment a caller is added. - * - * ── Also the NUMBER faces (objectui#10071) ───────────────────────────── - * The ceiling is the engine's, ⛔ not the percent format's, so the three - * non-percent readers of a declared `scale` hit it identically: the number - * cell (`NumberCellRenderer`, through `formatDisplayNumber`'s `Intl`), a - * grid's computed column (`computeRow`'s `toFixed`), and the grid currency - * cell display (`currencyText` in `GridField`, face label - * `grid currency cell`, through `formatDisplayNumber`'s `Intl` — - * objectui#10355). They take this SAME - * ruling through `renderableFractionScale`, each under its own face label and - * card, so the percent diagnostic above stays byte-identical. The SUNSET - * applies to them unchanged: `NumberScaleOutOfRange-10071.test.tsx` asks the - * installed spec the same question for a computed number field, and on the - * day it answers differently every importer of this module goes with it. - * - * ⛔ This says nothing about which MEMBER a face reads, nor about what an - * ABSENT `scale` means — the two faces still spell that absence differently - * (`PercentField` 2, `PercentCellRenderer` 0) and objectui#9810 holds the - * question of what `scale` means to the write path versus the display path. - * Neither is touched here. - */ - -/** - * The widest fraction the percent formatters can actually render. - * - * ⛔ NOT a number this renderer picked. It is the bound both formatting APIs - * this package reaches for define for themselves, and on the INTEGERS they - * define the same one — which is what makes a single ruling over both faces - * possible at all. Re-measured on node v22.22.2, the two APIs and the two - * ends: - * - * ``` - * (25).toFixed(100) -> "25.000…" - * (25).toFixed(101) -> RangeError - * (25).toFixed(-1) -> RangeError - * new Intl.NumberFormat('en', { maximumFractionDigits: 100 }) -> ok - * new Intl.NumberFormat('en', { maximumFractionDigits: 101 }) -> RangeError - * new Intl.NumberFormat('en', { maximumFractionDigits: -1 }) -> RangeError - * ``` - * - * ⚠️ The two agree on the INTEGERS ONLY, and the difference is measurable off - * them: `(25).toFixed(100.5)` and `(25).toFixed(NaN)` both render, while - * `Intl` refuses either. It costs the cell face nothing, because - * `formatPercentBody` falls back to `toFixed` when `Intl` throws — so a - * non-integer or unreadable width rendered through that fallback before this - * module existed and renders the same text now, with a diagnostic added. - * - * ⚠️ Re-measure rather than trust this comment if it ever matters again: the - * bound is a RUNTIME property of the engine (ECMA-262 `Number.prototype. - * toFixed` and ECMA-402 `SetNumberFormatDigitOptions` each state it), ⛔ not a - * constant this repository owns. That disclaimer is one of the reasons this - * symbol stays module-private: no consumer of a published constant could see - * it. - */ -export const PERCENT_SCALE_CEILING = 100; - -/** Declared widths already reported, keyed by face and width, so a 1,000-row - * grid warns once per face. */ -const warnedScales = new Set(); - -/** - * Clamp a declared fraction width into the renderable domain for the percent - * faces (objectui#9808). See this module's header for the ruling, the sunset - * condition and what actually reaches here. - */ -export function renderablePercentScale(declared: number): number { - return renderableFractionScale(declared, 'percent field', 'objectui#9808'); -} - -/** - * The one ruling, for any face that turns a declared `scale` into fraction - * digits: a width the installed spec accepts and the engine refuses is clamped - * to the ceiling and reported once per `face` and width, naming `card`. - */ -export function renderableFractionScale(declared: number, face: string, card: string): number { - // ⭐ The predicate is the WHOLE ruling, so read it as one sentence: fire on a - // width the installed spec ACCEPTS and the engine REFUSES, and on nothing - // else. `Number.isInteger` is the spec's own `int()` shape and `> ceiling` - // is the engine's own bound, so the set this function acts on is exactly the - // gap between the two — which is the defect objectui#9808 reports and, when - // the SUNSET above closes the gap upstream, the empty set. - // - // ⛔ Deliberately NOT a clamp over the whole domain. `-1`, `Infinity`, `NaN`, - // `2.5` and a string `'101'` are widths the installed `FieldSchema` already - // REFUSES (measured: `int()` rejects the non-integers and the non-finite, - // `min(0)` rejects the negative), so rescuing them here would be a - // renderer-side default around bad input — AGENTS.md #0.1's own listed - // example, and it would be doing it inside the module that cites #0.1 as its - // reason for existing. They keep their pre-existing path untouched: `-1` and - // `Infinity` still reach `toFixed` and still throw, exactly as before this - // module existed, and the producer is where that is fixed. - // - // ⚠️ `declared` is returned UNCOERCED on that path, on purpose. The engines - // coerce for themselves — measured, `(25).toFixed('2')` and - // `maximumFractionDigits: '2'` render two decimals and so does `2.9` — so - // handing the value straight back is what makes "everything else is - // byte-identical" a fact rather than a hope. The parameter is typed `number` - // because callers declare it so; the value arrives from untyped JSON - // metadata. - const refused = Number.isInteger(declared) && declared > PERCENT_SCALE_CEILING; - if (!refused) return declared; - const key = `${face}|${declared}`; - if (!warnedScales.has(key)) { - warnedScales.add(key); - console.warn( - `[ObjectUI] ${face}: a declared \`scale\` of ${declared} is above the ` + - `${PERCENT_SCALE_CEILING}-digit fraction width this platform can render; ` + - `rendering at ${PERCENT_SCALE_CEILING} instead (${card}).`, - ); - } - return PERCENT_SCALE_CEILING; -} diff --git a/packages/plugin-calendar/src/ObjectCalendar.tsx b/packages/plugin-calendar/src/ObjectCalendar.tsx index 482233a8f6..674c2b9f9f 100644 --- a/packages/plugin-calendar/src/ObjectCalendar.tsx +++ b/packages/plugin-calendar/src/ObjectCalendar.tsx @@ -1007,8 +1007,11 @@ export const ObjectCalendar: React.FC = ({ // with visual-regression evidence across all four surfaces in one stroke. // ⛔ The ONE cast objectui#8651 left standing, deliberately. `navigation` is // objectui#8652's key: the maintainer ruled B there — declare it on the - // platform element schemas first, then mirror — and that card waits on - // objectstack `e233db9db`. Its declaredness verdict at this read site is + // platform element schemas first, then mirror — and that card waited on + // objectstack `e233db9db`, which `@objectstack/spec` 17.5.0 ships: the + // `object-calendar` element entry declares `navigation` since that release + // (objectui#11073 measured it), so the unlock criterion is met and the + // mirroring is that card's next step. Its declaredness verdict at this read site is // UNCHANGED by this card: through the retired union it was undeclared too, // and it is undeclared on `ObjectCalendarSchema`. The rule that makes that // come out right is NOT "declared on every arm". In the checker reading @@ -1252,7 +1255,7 @@ export const ObjectCalendar: React.FC = ({ * * `@objectstack/spec`'s `CalendarConfigSchema` is a `strictObject` whose ONE * required key is `startDateField`; `titleField` is optional. Re-measured on - * the installed 17.4.0, three legs: `{}` and `{ titleField: 't' }` both fail + * the installed 17.5.0, three legs: `{}` and `{ titleField: 't' }` both fail * `invalid_type` at `startDateField`, and `{ startDateField: 'd' }` parses * CLEAN. The spec's own note on that schema names THIS renderer as the * reason — `resolveTitle` above takes an explicit `titleField` when present diff --git a/packages/plugin-calendar/src/__tests__/calendarUnionReads-8651.test.tsx b/packages/plugin-calendar/src/__tests__/calendarUnionReads-8651.test.tsx index 485fd2f03b..04a3d3ffed 100644 --- a/packages/plugin-calendar/src/__tests__/calendarUnionReads-8651.test.tsx +++ b/packages/plugin-calendar/src/__tests__/calendarUnionReads-8651.test.tsx @@ -90,7 +90,10 @@ * objectui#8652 carries the `navigation` family; the maintainer ruled B * (declare on the platform element schemas first, then mirror), the spec half * is objectstack#17987, and #8652 is `pm:blocked` on it. This card must not - * rule, declare, retire or touch it. + * rule, declare, retire or touch it. (`@objectstack/spec` 17.5.0 ships that + * spec half — the `object-calendar` element entry declares `navigation` — + * which meets #8652's unlock criterion; the mirroring is still #8652's, and + * the rows below still hold: objectui#11073.) * * ⭐ It does not, and that is measurable rather than asserted: through the * UNION the key was already undeclared, and on `ObjectCalendarSchema` it is @@ -164,9 +167,10 @@ const LEDGERED_OTHER_CARD_READS = ['navigation'] as const; * `ObjectCalendarSchema` and must stay so. * * ⚠️ The ground is NOT that the spec singles these two out. MEASURED on - * installed `@objectstack/spec` 17.4.0: `ComponentPropsMap['object-calendar']` - * is STRICT and declares exactly nine flat members — `calendar` `data` - * `defaultView` `filter` `loading` `locale` `objectName` `sort` `staticData` — + * installed `@objectstack/spec` 17.5.0: `ComponentPropsMap['object-calendar']` + * is STRICT and declares exactly ten flat members — `calendar` `data` + * `defaultView` `filter` `loading` `locale` `navigation` `objectName` `sort` + * `staticData` (nine through 17.4.0; 17.5.0 added `navigation`) — * so it refuses every undeclared flat key with the same `unrecognized_keys` * diagnostic: these two aliases, a nonsense key, AND the five canonical field * keys `ObjectCalendarSchema` already declares and this renderer reads (`startDateField` diff --git a/packages/plugin-dashboard/src/DatasetWidget.tsx b/packages/plugin-dashboard/src/DatasetWidget.tsx index 083152f15a..2b9a6c0732 100644 --- a/packages/plugin-dashboard/src/DatasetWidget.tsx +++ b/packages/plugin-dashboard/src/DatasetWidget.tsx @@ -91,8 +91,11 @@ import { metricAccentTextClass } from './colorVariants'; import { DrillDownDrawer } from './DrillDownDrawer'; type Row = Record; -interface DatasetTotals { dimensions: string[]; rows: Row[] } -interface DatasetResult { rows: Row[]; fields?: DatasetResultField[]; object?: string; dimensionFields?: Record; drillRawRows?: Row[]; drillRanges?: Array>; totals?: DatasetTotals[] } +// One RESULT-side totals grouping (the response's `totals[]`), named apart from +// `@objectstack/spec/api`'s `DatasetTotals`, which since 17.5.0 is the REQUEST side +// (`{ groupings }`, what to compute) — objectui#11073. +interface DatasetResultTotals { dimensions: string[]; rows: Row[] } +interface DatasetResult { rows: Row[]; fields?: DatasetResultField[]; object?: string; dimensionFields?: Record; drillRawRows?: Row[]; drillRanges?: Array>; totals?: DatasetResultTotals[] } interface DatasetCapableSource { queryDataset?: (dataset: string, selection: unknown) => Promise; } @@ -588,7 +591,7 @@ export function DatasetWidget({ widget, dataSource, subCaption }: { widget: any; // `signature` is the query an `ok` answer was read for, and `refreshing` marks // a re-read of that same query in flight (objectui#10815, see the effect). - const [state, setState] = useState<{ status: 'idle' | 'loading' | 'ok' | 'error'; rows: Row[]; fields?: DatasetResultField[]; object?: string; dimensionFields?: Record; drillRawRows?: Array>; drillRanges?: Array>; totals?: DatasetTotals[]; error?: string; forbidden?: boolean; signature?: string; refreshing?: boolean }>({ status: 'idle', rows: [] }); + const [state, setState] = useState<{ status: 'idle' | 'loading' | 'ok' | 'error'; rows: Row[]; fields?: DatasetResultField[]; object?: string; dimensionFields?: Record; drillRawRows?: Array>; drillRanges?: Array>; totals?: DatasetResultTotals[]; error?: string; forbidden?: boolean; signature?: string; refreshing?: boolean }>({ status: 'idle', rows: [] }); // Drill-through (ADR-0021 D2): the clicked bucket's record-list filter + title. const [drill, setDrill] = useState<{ filter: Record; title: string } | null>(null); // ── The flat table's client-side sort (objectui#5827) ──────────────────── diff --git a/packages/plugin-dashboard/src/__tests__/spec-symbol-dataset-totals-11073.test.ts b/packages/plugin-dashboard/src/__tests__/spec-symbol-dataset-totals-11073.test.ts new file mode 100644 index 0000000000..841f56bcd3 --- /dev/null +++ b/packages/plugin-dashboard/src/__tests__/spec-symbol-dataset-totals-11073.test.ts @@ -0,0 +1,35 @@ +/** + * 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#11073 — why `DatasetWidget.tsx` names its totals grouping + * `DatasetResultTotals`, not `DatasetTotals`. + * + * `@objectstack/spec` 17.5.0 began exporting `DatasetTotals` from + * `@objectstack/spec/api`: the REQUEST side of ADR-0021's marginal aggregates + * (`{ groupings }`, which totals to compute). The file-local interface is the + * RESULT side, one grouping of the response's `totals[]` (`{ dimensions, rows }`), + * so the two measured unequal and the local one was renamed + * (`pnpm check:spec-symbols` refuses a local declaration under a spec export's + * name). It was never exported, so no public name moved. + * + * This row pins the REASON off the spec's live schema: if the spec's + * `DatasetTotals` ever becomes the result grouping, it goes red and the local + * type should be derived instead. That the spec does not own the NEW name is + * held by `pnpm check:spec-symbols` on every run. + */ +import { describe, expect, it } from 'vitest'; +import { DatasetTotalsSchema } from '@objectstack/spec/api'; + +describe('objectui#11073 — the spec`s DatasetTotals is the request, not a result grouping', () => { + it('declares `groupings` and none of the result grouping`s members', () => { + const keys = Object.keys((DatasetTotalsSchema as unknown as { shape: Record }).shape); + expect(keys).toEqual(['groupings']); + for (const resultMember of ['dimensions', 'rows']) expect(keys).not.toContain(resultMember); + }); +}); diff --git a/packages/plugin-designer/src/MetadataFieldsPage.fieldsMapKeying.test.tsx b/packages/plugin-designer/src/MetadataFieldsPage.fieldsMapKeying.test.tsx index e48bc966ff..52ed5f84e5 100644 --- a/packages/plugin-designer/src/MetadataFieldsPage.fieldsMapKeying.test.tsx +++ b/packages/plugin-designer/src/MetadataFieldsPage.fieldsMapKeying.test.tsx @@ -35,6 +35,10 @@ * invokes the prototype setter. The field vanished from the serialised * body while the spec stood ready to accept it. That is what makes * `Object.fromEntries` load-bearing here rather than stylistic. + * ⚠️ `@objectstack/spec` 17.5.0 refuses this name BY NAME (objectui#11073 + * re-pinned `the instrument`), which keeps the load-bearing part: the + * refusal can name the field only if the serialised body still carries + * it, and a document stored before 17.5.0 still arrives with it. * * The third refusal, duplicates, is the conversion's OWN hazard: a designer * list can carry two fields called `amount` and a map cannot, so the later one @@ -223,8 +227,16 @@ describe('the instrument', () => { expect(parseWithFields({ undefined: { type: 'text', label: 'N' } }).success).toBe(true); }); - it('treats `__proto__` as a LEGAL field name — the spec would have accepted what assignment threw away', () => { - expect(parseWithFields({ ['__proto__']: { type: 'text', label: 'P' } }).success).toBe(true); + it('refuses `__proto__` BY NAME since 17.5.0 — a refusal that reaches the author only if the key survives', () => { + // Through `@objectstack/spec` 17.4.0 this row read `success === true`: the + // spec would have accepted what assignment threw away. 17.5.0 refuses the + // name at the key, by name (`z.record()` drops it from its output while + // reporting success), so keeping it in the body is now what lets that + // refusal name the field instead of the save silently losing it + // (objectui#11073 re-pin). + expect(issuesOf(parseWithFields({ ['__proto__']: { type: 'text', label: 'P' } }))).toEqual([ + 'custom @ fields.__proto__', + ]); // Control, so the line above is a verdict about this key rather than a // schema that accepts anything: a camelCase name is refused AT THE KEY. expect(issuesOf(parseWithFields({ firstName: { type: 'text', label: 'F' } }))).toEqual([ diff --git a/packages/plugin-designer/src/MetadataFieldsPage.tsx b/packages/plugin-designer/src/MetadataFieldsPage.tsx index 72d54cb9e5..a09aed6df1 100644 --- a/packages/plugin-designer/src/MetadataFieldsPage.tsx +++ b/packages/plugin-designer/src/MetadataFieldsPage.tsx @@ -182,7 +182,7 @@ function isDesignerAuthorableType(raw: unknown): raw is DesignerFieldType { * ## Why reading the retired spelling is a RENAME here and not a laundering * * `@objectstack/spec` treats the two as one key under two spellings and says - * so in the refusal itself. Measured on the installed 17.4.0: + * so in the refusal itself. Measured on the installed 17.5.0: * * FieldSchema.safeParse({ type: 'lookup', label: 'L', referenceTo: 'account' }) * => success = false diff --git a/packages/plugin-detail/src/index.tsx b/packages/plugin-detail/src/index.tsx index cce29f47b1..48023ebf88 100644 --- a/packages/plugin-detail/src/index.tsx +++ b/packages/plugin-detail/src/index.tsx @@ -882,7 +882,7 @@ ComponentRegistry.register('alert', RecordAlertRenderer, { // through `pickLocalized`, which is exactly what these descriptions teach. // Declaring the map arm therefore adds no shape the block does not already // honour; it stops the manifest gate warning `type-mismatch` on the - // recommended write. (The row DOES exist as of the installed 17.4.0 — read + // recommended write. (The row DOES exist as of the installed 17.5.0 — read // for `visible` below, objectui#9100 — so the "no entry" reading is stale; // these two arms are unaffected either way.) { name: 'title', type: ['string', 'object'], description: 'Accepts an inline translation map ({ en, "zh-CN", … })' }, @@ -890,7 +890,7 @@ ComponentRegistry.register('alert', RecordAlertRenderer, { // objectui#9100 — the spec accepts three arms here and the renderer now // resolves all three, so a single `'string'` was the declaration-narrower- // than-the-contract family of objectui#4581, one layer up. Measured on the - // INSTALLED `@objectstack/spec` 17.4.0 (`dist/ui/index.d.ts`, the + // INSTALLED `@objectstack/spec` 17.5.0 (`dist/ui/index.d.ts`, the // `ComponentPropsMap['record:alert']` row): `visible` is // `boolean | string | { dialect: 'cel'|'cron'|'template', source?, … }`, // and `renderers/record-alert.tsx` hands whichever arrives to diff --git a/packages/plugin-detail/src/renderers/__tests__/detailRendererUndeclaredKeys-8649.test.ts b/packages/plugin-detail/src/renderers/__tests__/detailRendererUndeclaredKeys-8649.test.ts index e28534735f..2bb72c36c1 100644 --- a/packages/plugin-detail/src/renderers/__tests__/detailRendererUndeclaredKeys-8649.test.ts +++ b/packages/plugin-detail/src/renderers/__tests__/detailRendererUndeclaredKeys-8649.test.ts @@ -64,6 +64,11 @@ * but NOT on the three this card covers. A word-frequency screen over the contract reads "present" and is * wrong about exactly this; the per-schema census below is what separates * them. ⇒ ROUTED TO THE PRODUCER, same floor. + * - ⭐ UPDATE, `@objectstack/spec` 17.5.0 (objectui#11073): the routed card + * LANDED upstream. All three keys are now declared on exactly these three + * blocks, so the two routings above have expired into align-the-mirror. + * The census rows fired and were flipped; the alignment itself is this + * card's burn-down and was ⛔ not taken by the bump. * * ## What each leg can and cannot prove * @@ -311,27 +316,27 @@ describe('objectui#8649 — the census the routing rests on (PREMISE, re-derived expect(declaringBlocksOf('fields').length).toBeGreaterThan(0); }); - it('`enforceFieldSecurity` and `redactFields` are declared by NO block, and not on the node', () => { - // The reading that takes "declare" off the table for these two: they are no - // part of any block's authoring surface, nor of the envelope every block - // shares. It goes RED the day the platform declares either — which is the - // signal that the routed producer-side card landed. - expect(declaringBlocksOf('enforceFieldSecurity')).toEqual([]); - expect(declaringBlocksOf('redactFields')).toEqual([]); + // ⭐ FIRED at the `@objectstack/spec` 17.5.0 bump (objectui#11073), as these two + // rows were written to. Through 17.4.0 they read: `enforceFieldSecurity` and + // `redactFields` declared by NO block, and `requiredPermissions` declared, but + // never on these three. 17.5.0 declares all three ON exactly these three + // blocks — the routed producer-side card LANDED. Flipped to the new reading; + // what it signals — this repo owes the mirror an update (declare on the + // three interfaces, then drop the casts) — is objectui#8649's own burn-down, + // ⛔ not the bump's, the same split objectui#10940's tripwire took. + it('`enforceFieldSecurity` and `redactFields` are declared on exactly the three card blocks since 17.5.0, and not on the node', () => { + expect(declaringBlocksOf('enforceFieldSecurity')).toEqual([...CARD_BLOCKS]); + expect(declaringBlocksOf('redactFields')).toEqual([...CARD_BLOCKS]); expect(nodeLevelKeys()).not.toContain('enforceFieldSecurity'); expect(nodeLevelKeys()).not.toContain('redactFields'); }); - it('`requiredPermissions` IS declared by the contract — just never on these three blocks', () => { - // Why a word-frequency screen gets this key wrong, stated as an assertion - // rather than as prose: the token is present on the authoring surface AND - // absent from the three blocks that read it. + it('`requiredPermissions` is declared on the three card blocks too since 17.5.0 — beside `record:quick_actions`', () => { + // Why a word-frequency screen got this key wrong through 17.4.0 is kept as + // the sibling precedent: `record:quick_actions` carried it first. const declaring = declaringBlocksOf('requiredPermissions'); - expect(declaring.length).toBeGreaterThan(0); - // The sibling block that DOES carry it — the precedent the producer-side - // card would cite. expect(declaring).toContain('record:quick_actions'); - for (const block of CARD_BLOCKS) expect(declaring).not.toContain(block); + for (const block of CARD_BLOCKS) expect(declaring).toContain(block); expect(nodeLevelKeys()).not.toContain('requiredPermissions'); }); diff --git a/packages/plugin-detail/src/renderers/__tests__/record-details.hideFieldsUncast-9965.test.ts b/packages/plugin-detail/src/renderers/__tests__/record-details.hideFieldsUncast-9965.test.ts index 3c6b457936..d151f47680 100644 --- a/packages/plugin-detail/src/renderers/__tests__/record-details.hideFieldsUncast-9965.test.ts +++ b/packages/plugin-detail/src/renderers/__tests__/record-details.hideFieldsUncast-9965.test.ts @@ -144,16 +144,32 @@ type _MirrorRefusesTheMisspelling = RecordDetailsComponentProps['hideFeilds']; * ⛔ Not an opinion, and ⛔ not a list of keys to leave alone forever: every * entry is asserted to be ABSENT FROM THE MIRROR below, so the exemption * expires the moment its premise does. + * + * ⚠️ SINCE THE 17.5.0 BUMP THIS IS ALSO THE SOURCE-TEXT GUARD'S LEDGER, which + * is why that guard is no longer carve-out-free. `@objectstack/spec` 17.5.0 + * declares all three keys on `record:details`, so they entered the CONTRACT's + * population while the MIRROR still lacks them and the renderer still casts. + * objectui#11111 decision 3 = B (record 5902351047) booked them to + * objectui#8649, which declares them on the mirror and un-casts the reads; + * the bump declares nothing. Capped at exactly these three by the test + * "no cast stands between schema and ANY contract-declared key", and + * objectui#8649's landing empties this map — the last booked entry leaving + * restores the guard to no ledger at all. Expiry: 2026-10-30, or when + * objectui#8649 lands, whichever is first (stated, not clock-enforced: this + * ledger has no date field). */ const HONEST_CASTS: Record = { requiredPermissions: - 'the mirror declares no such key — `RecordDetailsProps` has no permission gate, so the cast is the honest spelling until one is declared', + 'the mirror declares no such key — `RecordDetailsProps` has no permission gate, so the cast is the honest spelling until one is declared. Booked to objectui#8649 by objectui#11111 decision 3 = B (record 5902351047): the contract declares it since @objectstack/spec 17.5.0. Expires 2026-10-30, or when objectui#8649 lands.', enforceFieldSecurity: - 'the mirror declares no such key — field-level security is read off the object, not off the page block', + 'the mirror declares no such key — field-level security is read off the object, not off the page block. Booked to objectui#8649 by objectui#11111 decision 3 = B (record 5902351047): the contract declares it since @objectstack/spec 17.5.0. Expires 2026-10-30, or when objectui#8649 lands.', redactFields: - 'the mirror declares no such key — it travels with `enforceFieldSecurity` and shares its fate', + 'the mirror declares no such key — it travels with `enforceFieldSecurity` and shares its fate. Booked to objectui#8649 by objectui#11111 decision 3 = B (record 5902351047): the contract declares it since @objectstack/spec 17.5.0. Expires 2026-10-30, or when objectui#8649 lands.', }; +/** The cap: the booked casts, by name. objectui#8649's landing takes it to `[]`. */ +const OBJECTUI_11111_BOOKED_CASTS = ['enforceFieldSecurity', 'redactFields', 'requiredPermissions']; + interface SchemaRead { /** The property name read off `schema`. */ key: string; @@ -341,8 +357,10 @@ describe('objectui#9965 — the source-text guard is derived and discriminates', expect(keys.length).toBeGreaterThan(5); expect(keys).toContain('hideFields'); // …and it is not an everything-set, which would make the guard unfalsifiable. - expect(keys).not.toContain('enforceFieldSecurity'); - expect(keys).not.toContain('requiredPermissions'); + // (`enforceFieldSecurity` / `requiredPermissions` were the absent controls + // through `@objectstack/spec` 17.4.0; 17.5.0 declares both on this block — + // objectui#11073 — so the control is a key nothing declares.) + expect(keys).not.toContain('zzqxNoSuchRecordBlockKey'); // The premise of reading the named export: it IS this block's map entry. expect(ComponentPropsMap['record:details']).toBe(RecordDetailsProps); }); @@ -365,7 +383,14 @@ describe('objectui#9965 — the source-text guard is derived and discriminates', expect(source).toContain('RecordDetailsRendererProps'); // Liveness, in source text this time: the read and its guard are still here. expect(source).toMatch(/Array\.isArray\(schema\.hideFields\) \? schema\.hideFields/); - const offenders = declaredKeys().filter((key) => castBefore(key).test(source)); - expect(offenders).toEqual([]); + const cast = declaredKeys().filter((key) => castBefore(key).test(source)); + // Named, not counted: a failure has to say WHICH key regressed. Any cast + // over a contract-declared key outside the booked ledger is red. + expect(cast.filter((key) => !(key in HONEST_CASTS))).toEqual([]); + // THE CAP (objectui#11111 decision 3 = B): the ledger holds exactly the three + // booked to objectui#8649, each still contract-declared AND still cast — so + // it can neither grow nor keep an entry whose cast is gone. + expect(Object.keys(HONEST_CASTS).sort()).toEqual(OBJECTUI_11111_BOOKED_CASTS); + expect(cast.filter((key) => key in HONEST_CASTS).sort()).toEqual(OBJECTUI_11111_BOOKED_CASTS); }); }); diff --git a/packages/plugin-detail/src/renderers/__tests__/record-related-list.relationshipValueFieldUncast-9475.test.tsx b/packages/plugin-detail/src/renderers/__tests__/record-related-list.relationshipValueFieldUncast-9475.test.tsx index 739eee282f..2f5745fd70 100644 --- a/packages/plugin-detail/src/renderers/__tests__/record-related-list.relationshipValueFieldUncast-9475.test.tsx +++ b/packages/plugin-detail/src/renderers/__tests__/record-related-list.relationshipValueFieldUncast-9475.test.tsx @@ -162,8 +162,27 @@ const declaredKeys = (): string[] => Object.keys(RecordRelatedListProps.shape).s * array shape, the direction AGENTS.md #0.1 requires. ⛔ An entry added here * is a declaration that disagrees with itself somewhere: write the measurement * that makes it load-bearing, or move the declaration instead. + * + * ⚠️ THREE ENTRIES SINCE THE 17.5.0 BUMP, and they are exactly that + * disagreement, ruled rather than measured away. `@objectstack/spec` 17.5.0 + * declares the field-security triple on `record:related_list`, while the + * mirror (`RecordRelatedListComponentProps`) does not and the renderer reads + * the three through a cast. objectui#11111 decision 3 = B (record 5902351047) + * booked them to objectui#8649 — which "moves the declaration", as the + * sentence above demands — instead of declaring them in the bump. Capped at + * exactly these three by `the ledger holds exactly the objectui#11111 triple`; + * objectui#8649's landing strikes all three, and the last one leaving restores + * the empty ledger. Expiry: 2026-10-30, or when objectui#8649 lands, whichever + * is first (stated, not clock-enforced: this ledger has no date field). */ -const LOAD_BEARING_CASTS: Record = {}; +const LOAD_BEARING_CASTS: Record = { + enforceFieldSecurity: + 'contract-declared since @objectstack/spec 17.5.0, absent from the mirror, read through a cast. Booked to objectui#8649 by objectui#11111 decision 3 = B (record 5902351047). Expires 2026-10-30, or when objectui#8649 lands.', + redactFields: + 'contract-declared since @objectstack/spec 17.5.0, absent from the mirror, read through a cast; travels with `enforceFieldSecurity`. Booked to objectui#8649 by objectui#11111 decision 3 = B (record 5902351047). Expires 2026-10-30, or when objectui#8649 lands.', + requiredPermissions: + 'contract-declared since @objectstack/spec 17.5.0, absent from the mirror, read through a cast. Booked to objectui#8649 by objectui#11111 decision 3 = B (record 5902351047). Expires 2026-10-30, or when objectui#8649 lands.', +}; describe('objectui#9475 — the guard population is derived and the matcher discriminates', () => { it('reads the contract’s own props schema, non-empty and calibrated both ways', () => { @@ -173,8 +192,10 @@ describe('objectui#9475 — the guard population is derived and the matcher disc expect(keys).toContain('relationshipValueField'); expect(keys).toContain('add'); // …and it is not an everything-set, which would make them unfalsifiable. - expect(keys).not.toContain('enforceFieldSecurity'); - expect(keys).not.toContain('requiredPermissions'); + // (`enforceFieldSecurity` / `requiredPermissions` were the absent controls + // through `@objectstack/spec` 17.4.0; 17.5.0 declares both on this block — + // objectui#11073 — so the control is a key nothing declares.) + expect(keys).not.toContain('zzqxNoSuchRecordBlockKey'); // The premise of reading the named export: it IS this block's map entry. expect(ComponentPropsMap['record:related_list']).toBe(RecordRelatedListProps); }); @@ -223,11 +244,22 @@ describe('objectui#9475 — the declaration reaches the read', () => { expect(source).not.toMatch(castBefore('add')); }); - // Zero entries today, so the loop below would assert nothing in silence. - // Stated as its own reading instead: an empty ledger is what makes the - // derived guard above carve-out-free. - it('the ledger is empty, so no contract-declared key is exempt from the guard', () => { - expect(Object.keys(LOAD_BEARING_CASTS)).toEqual([]); + // THE CAP (objectui#11111 decision 3 = B, record 5902351047). The ledger was + // empty from objectui#9964 to the 17.5.0 bump; it now holds exactly the + // three keys booked to objectui#8649, pinned by name so a fourth is red, and + // every entry must cite its owner and expiry. objectui#8649's landing takes + // this back to `[]`, the carve-out-free state. + it('the ledger holds exactly the objectui#11111 triple, each booked to objectui#8649 with its expiry', () => { + expect(Object.keys(LOAD_BEARING_CASTS).sort()).toEqual([ + 'enforceFieldSecurity', + 'redactFields', + 'requiredPermissions', + ]); + for (const [key, why] of Object.entries(LOAD_BEARING_CASTS)) { + expect(declaredKeys(), `${key} is booked but the contract no longer declares it`).toContain(key); + expect(why, `${key} names no owner card`).toContain('objectui#8649'); + expect(why, `${key} carries no expiry`).toContain('Expires 2026-10-30'); + } }); for (const [key, why] of Object.entries(LOAD_BEARING_CASTS)) { diff --git a/packages/plugin-form/src/__tests__/formSectionGroupReference-7051.test.tsx b/packages/plugin-form/src/__tests__/formSectionGroupReference-7051.test.tsx index 8bc083db2c..f4cea796bd 100644 --- a/packages/plugin-form/src/__tests__/formSectionGroupReference-7051.test.tsx +++ b/packages/plugin-form/src/__tests__/formSectionGroupReference-7051.test.tsx @@ -448,7 +448,7 @@ describe('objectui#7051 — resolveSectionGroupReferences', () => { /** * ⭐ The second axis. The card asks that group-owned presentation keys beside * `group`, and `group` on a wizard step, not be SILENTLY accepted. Measured - * against the installed `@objectstack/spec` (17.4.0): both are refused at PARSE + * against the installed `@objectstack/spec` (17.5.0): both are refused at PARSE * — a stronger door than the lint that objectui#8497 concluded owns EXISTENCE * checking, and a different one. So authored metadata carrying either never * reaches this renderer at all; the renderer's own reports above exist for the diff --git a/packages/plugin-gantt/src/readme-navigation-example.test.ts b/packages/plugin-gantt/src/readme-navigation-example.test.ts index 0255f301b1..d753855312 100644 --- a/packages/plugin-gantt/src/readme-navigation-example.test.ts +++ b/packages/plugin-gantt/src/readme-navigation-example.test.ts @@ -68,14 +68,16 @@ * the block never carried the key. An instruction can live entirely in prose, * which is why the prose is measured here too. * - * WARNING: no schema this package installs can derive that key's retirement. - * The pinned `@objectstack/spec@17.4.0` still DECLARES `view`, so - * `declaredMembers()` reports it legal and every schema-derived statement about - * it is vacuous in this tree; the retirement lands it as a tombstone (typed - * `never`, raising a prescription at parse) only when the pin moves. It is - * therefore named by hand, exactly as `basePath` already is -- and the absence - * carries its own control: the same detector is run over the sentence that used - * to carry the instruction, in the same file, and must find it there. + * WARNING: `declaredMembers()` cannot derive that key's retirement. When this + * was written the spec in the lockfile (17.4.0) still DECLARED `view` as a live + * member. The installed `@objectstack/spec` 17.5.0 lands it as a tombstone + * (objectui#11073 re-measured: typed `never`, and `{ view: 'detail' }` is + * refused at `view` with "`view.list.navigation.view` was removed in + * @objectstack/spec 17.5.0") -- but a tombstone is still a KEY of the shape, + * so the key-set reading below lists it exactly as it listed the live member. + * It is therefore named by hand, exactly as `basePath` already is -- and the + * absence carries its own control: the same detector is run over the sentence + * that used to carry the instruction, in the same file, and must find it there. */ import { describe, it, expect } from 'vitest'; diff --git a/packages/plugin-grid/src/__tests__/bulkLookupDependsOnReach-8755.test.tsx b/packages/plugin-grid/src/__tests__/bulkLookupDependsOnReach-8755.test.tsx index a800a1d1c6..5c5ad029bb 100644 --- a/packages/plugin-grid/src/__tests__/bulkLookupDependsOnReach-8755.test.tsx +++ b/packages/plugin-grid/src/__tests__/bulkLookupDependsOnReach-8755.test.tsx @@ -44,6 +44,12 @@ * authorable — that inference is the trap this card was filed to close, and leg * B is here so it fails loudly rather than being re-derived. * + * ⚠️ That was the reading through `@objectstack/spec` 17.4.0. 17.5.0 CLOSED + * `BulkActionParamSchema` and declares `dependsOn` on it (objectui#11073), so + * leg B did fail loudly — and was re-pinned to the new reading: the accept is + * now a declaration, and the key IS authorable on a bulk param. The sibling + * `ActionParamSchema` still refuses it. + * * ## Every reading has a lit control beside it * * - leg A pairs the subject with a CONTROL lookup identical but for `dependsOn` @@ -331,8 +337,14 @@ describe('objectui#8755 leg A — a `dependsOn` lookup BULK param gates on an EM * this repo has installed, and the answers move if those schemas do. It is * pinned because the STRICTNESS DIFFERENCE between the two param schemas is * doing work that is invisible at every call site. + * + * ⚠️ THEY MOVED at 17.5.0 (objectui#11073). Through 17.4.0 the bulk schema was + * `.passthrough()` and refused nothing, so its accept of `dependsOn` was a NULL + * reading. 17.5.0 CLOSED it — a nonsense key is refused by name — and declares + * `dependsOn` as a member, so the same accept is now EVIDENCE: `dependsOn` is + * authorable on a bulk param. Re-pinned as that, rather than relaxed. */ -describe('objectui#8755 leg B — the bulk param schema refuses nothing, so its accept of `dependsOn` is a NULL reading', () => { +describe('objectui#8755 leg B — the bulk param schema is strict since 17.5.0, so its accept of `dependsOn` is a reading', () => { const NONSENSE = 'zzz_not_a_key_any_producer_emits'; const bulkParam = (over: Record) => ({ name: 'contact', label: 'Contact', type: 'lookup', object: 'contacts', ...over, @@ -347,21 +359,26 @@ describe('objectui#8755 leg B — the bulk param schema refuses nothing, so its expect(ActionParamSchema.safeParse(actionParam({})).success).toBe(true); }); - it('⭐ NEGATIVE CONTROL — a nonsense key is ACCEPTED by the bulk schema and REFUSED by the single-record one', () => { - // This one assertion is the whole card's warning. The two schemas disagree - // about whether an unknown key is an error at all, so an "accept" means - // something on one side and nothing on the other. - expect(BulkActionParamSchema.safeParse(bulkParam({ [NONSENSE]: 1 })).success).toBe(true); + it('⭐ NEGATIVE CONTROL — a nonsense key is REFUSED by BOTH schemas since 17.5.0', () => { + // Through 17.4.0 this one assertion was the whole card's warning: the bulk + // schema ACCEPTED the nonsense key, so an "accept" meant nothing there. + // 17.5.0 closed it (objectui#11073), and the two schemas now agree that an + // unknown key is an error — which is what makes the SUBJECT row a reading. + const bulk = BulkActionParamSchema.safeParse(bulkParam({ [NONSENSE]: 1 })); + expect(bulk.success).toBe(false); + expect(bulk.error?.issues.some((i) => i.code === 'unrecognized_keys')).toBe(true); const strict = ActionParamSchema.safeParse(actionParam({ [NONSENSE]: 1 })); expect(strict.success).toBe(false); expect(strict.error?.issues.some((i) => i.code === 'unrecognized_keys')).toBe(true); }); - it('SUBJECT — `dependsOn` is accepted by the bulk schema, and that accept carries NO information', () => { - // Read this ONLY together with the negative control above: the schema that - // accepted this also accepted `zzz_not_a_key_any_producer_emits`. ⛔ It is - // not evidence that `dependsOn` is authorable on a bulk param. + it('SUBJECT — `dependsOn` is accepted by the now-strict bulk schema, so it IS authorable on a bulk param', () => { + // Read this ONLY together with the negative control above. Through 17.4.0 + // the schema that accepted this also accepted + // `zzz_not_a_key_any_producer_emits`, so the accept carried no information; + // since 17.5.0 that schema refuses the nonsense key, and this accept is a + // declaration reading (objectui#11073). expect(BulkActionParamSchema.safeParse(bulkParam({ dependsOn: ['region'] })).success).toBe(true); // …while the strict sibling names it, which is what objectui#8672 left diff --git a/packages/plugin-grid/src/__tests__/serverGrouping-7189.test.tsx b/packages/plugin-grid/src/__tests__/serverGrouping-7189.test.tsx index 0bf9972027..b937bd0777 100644 --- a/packages/plugin-grid/src/__tests__/serverGrouping-7189.test.tsx +++ b/packages/plugin-grid/src/__tests__/serverGrouping-7189.test.tsx @@ -29,7 +29,7 @@ * the real door answers the compiled queries this way (five groups 86/61/31/7/1 * in both orders, the 86-row group paged 50 + 36) is the platform half's pin * (objectstack#15330, `list-view-grouping-query-door.test.ts`) and was - * re-measured for this card against the installed `@objectstack/spec` 17.4.0; + * re-measured for this card against `@objectstack/spec` 17.4.0, the version installed then; * what THIS file pins is the grid consuming those answers — and not the page. * * Every positive pin has a control that must come out differently: the SAME diff --git a/packages/plugin-kanban/src/index.tsx b/packages/plugin-kanban/src/index.tsx index dff6d6fcb6..71da03b610 100644 --- a/packages/plugin-kanban/src/index.tsx +++ b/packages/plugin-kanban/src/index.tsx @@ -548,11 +548,14 @@ export const ObjectKanbanRenderer: React.FC<{ schema: any; [key: string]: any }> * * ## Why these keys were added * - * `@objectstack/spec`'s `ComponentPropsMap['object-kanban']` declares FOURTEEN - * top-level keys on the installed 17.4.0 pin; this list published three until - * objectui#8186 added `filter`. ⚠️ It declared THIRTEEN when objectui#8201 was - * filed — 17.4.0 added `limit` (see below), and the count moved with it. Both - * numbers are correct about their own pin, which is why this one names its pin. + * `@objectstack/spec`'s `ComponentPropsMap['object-kanban']` carries FIFTEEN + * top-level keys on the installed 17.5.0 pin, fourteen of them live: 17.5.0 + * added `navigation` and turned `quickAdd` into a tombstone (typed `never`, + * refused by name — objectui#8285's ruled retirement, landed). This list + * published three until objectui#8186 added `filter`. ⚠️ The shape carried + * THIRTEEN when objectui#8201 was filed and FOURTEEN through 17.4.0, which + * added `limit` (see below). Each number is correct about its own spec + * version, which is why this one names its version. * The gap was STRUCTURAL rather than considered — the console registers this * block with `ComponentRegistry.registerLazy` and `getConfig` is loaded-only by * design, so the block sat outside the console's reverse-parity population diff --git a/packages/plugin-list/README.md b/packages/plugin-list/README.md index ce011ca56c..501201abcb 100644 --- a/packages/plugin-list/README.md +++ b/packages/plugin-list/README.md @@ -222,7 +222,6 @@ const viewTypes: Record, string> = { map: 'Records at their geographic coordinates', chart: 'Aggregated bar / line / pie / area chart', tree: 'Hierarchical parent-child rows', - page: 'A published page mounted in place of rows', }; export { view, richColumns, viewTypes }; diff --git a/packages/plugin-report/src/DatasetReportRenderer.tsx b/packages/plugin-report/src/DatasetReportRenderer.tsx index 5fe48ac949..7e53981d3e 100644 --- a/packages/plugin-report/src/DatasetReportRenderer.tsx +++ b/packages/plugin-report/src/DatasetReportRenderer.tsx @@ -104,8 +104,14 @@ import { useDatasetDimensionLabels, useDatasetDimensionMeta } from './useDataset type Row = Record; -/** One server-computed totals grouping: `dimensions: []` is the grand total. */ -interface DatasetTotals { +/** + * One server-computed totals grouping: `dimensions: []` is the grand total. + * + * The RESULT side (the response's `totals[]`), named apart from + * `@objectstack/spec/api`'s `DatasetTotals`, which since 17.5.0 is the REQUEST + * side (`{ groupings }`, what to compute) — objectui#11073. + */ +interface DatasetResultTotals { dimensions: string[]; rows: Row[]; } @@ -121,7 +127,7 @@ interface DatasetCapableSource { dimensionFields?: Record; drillRawRows?: Row[]; drillRanges?: Array>; - totals?: DatasetTotals[]; + totals?: DatasetResultTotals[]; }>; } @@ -377,7 +383,7 @@ function useDatasetRows( dimensionFields?: Record; drillRawRows?: Row[]; drillRanges?: Array>; - totals?: DatasetTotals[]; + totals?: DatasetResultTotals[]; error?: string; }>({ status: 'idle', diff --git a/packages/plugin-report/src/__tests__/spec-symbol-dataset-totals-11073.test.ts b/packages/plugin-report/src/__tests__/spec-symbol-dataset-totals-11073.test.ts new file mode 100644 index 0000000000..8e478b0c55 --- /dev/null +++ b/packages/plugin-report/src/__tests__/spec-symbol-dataset-totals-11073.test.ts @@ -0,0 +1,35 @@ +/** + * 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#11073 — why `DatasetReportRenderer.tsx` names its totals grouping + * `DatasetResultTotals`, not `DatasetTotals`. + * + * `@objectstack/spec` 17.5.0 began exporting `DatasetTotals` from + * `@objectstack/spec/api`: the REQUEST side of ADR-0021's marginal aggregates + * (`{ groupings }`, which totals to compute). The file-local interface is the + * RESULT side, one grouping of the response's `totals[]` (`{ dimensions, rows }`), + * so the two measured unequal and the local one was renamed + * (`pnpm check:spec-symbols` refuses a local declaration under a spec export's + * name). It was never exported, so no public name moved. + * + * This row pins the REASON off the spec's live schema: if the spec's + * `DatasetTotals` ever becomes the result grouping, it goes red and the local + * type should be derived instead. That the spec does not own the NEW name is + * held by `pnpm check:spec-symbols` on every run. + */ +import { describe, expect, it } from 'vitest'; +import { DatasetTotalsSchema } from '@objectstack/spec/api'; + +describe('objectui#11073 — the spec`s DatasetTotals is the request, not a result grouping', () => { + it('declares `groupings` and none of the result grouping`s members', () => { + const keys = Object.keys((DatasetTotalsSchema as unknown as { shape: Record }).shape); + expect(keys).toEqual(['groupings']); + for (const resultMember of ['dimensions', 'rows']) expect(keys).not.toContain(resultMember); + }); +}); diff --git a/packages/plugin-tree/src/ObjectTree.hostConfigExported-8253.test.ts b/packages/plugin-tree/src/ObjectTree.hostConfigExported-8253.test.ts index e54daaf345..b824f025f7 100644 --- a/packages/plugin-tree/src/ObjectTree.hostConfigExported-8253.test.ts +++ b/packages/plugin-tree/src/ObjectTree.hostConfigExported-8253.test.ts @@ -125,7 +125,7 @@ describe('objectui#8253/#8841 — TreeViewConfig is the protocol\'s block, reach // would carry `[key: string]: unknown` and parity would still hold while // every misspelling became assignable again. This line is what pins the // STRICTNESS the card depends on, and it is the line that fires if the - // installed `@objectstack/spec` ever drops below 17.4.0. + // resolved `@objectstack/spec` ever predates 17.4.0, the release that closed it. type _NoIndexSignature = Assert>; // Every key is optional: a host writes the subset it means. `Partial` diff --git a/packages/plugin-tree/src/ObjectTree.schemaTyped-8655.test.ts b/packages/plugin-tree/src/ObjectTree.schemaTyped-8655.test.ts index 86adf5cdda..5ddc9fa502 100644 --- a/packages/plugin-tree/src/ObjectTree.schemaTyped-8655.test.ts +++ b/packages/plugin-tree/src/ObjectTree.schemaTyped-8655.test.ts @@ -80,6 +80,28 @@ * key arriving through a spread (objectui#8651 measured exactly that * failure and had to withdraw a retirement). ⇒ routed, rung untouched. * + * ## ⚠️ UPDATE at `@objectstack/spec` 17.5.0 (objectui#11073) — the readings moved + * + * The bump turned the three spec-reading rows below red, which is what they + * are for. Re-measured on the installed 17.5.0: + * + * - `ComponentPropsMap['object-tree']` now EXISTS — a strict entry declaring + * `objectName` `data` `staticData` `filter` `tree` `navigation`. There is + * still no `tree` entry. + * - `navigation` is declared on SEVEN element entries, `object-tree` and + * `object-calendar` among them — objectui#8652's unlock criterion ("a + * released spec carrying the declaration installable here") is MET. The + * ruled second step, mirroring it, is that card's and is ⛔ not executed by + * the bump, so the key stays ledgered and undeclared here. + * - `tree` is declared on exactly ONE element entry, `object-tree`, as the + * nested author face (the same block `ListViewSchema.tree` declares). The + * "declaring it on the node would fork" premise above has EXPIRED: declaring + * is now align-the-mirror. That is a new DECLARE verdict — it widens the + * published node — and it is ⛔ not executed by the bump either. + * + * The rows below pin those readings; the "stays undeclared here" rows are + * unchanged and now mean "owed, not yet executed". + * * ## The ceiling, stated so nobody reads this file as claiming more * * `BaseSchema` ends in `[key: string]: any`. Typing the prop makes the question @@ -335,8 +357,16 @@ describe('objectui#8655 — every key read off the node is declared, or ledgered /* ── 3. ③ — the spec readings each verdict rests on ────────────────────────── */ describe('objectui#8655 — the spec has no element schema for this node', () => { - it('no `object-tree` entry, and no `tree` entry (controls: two that ARE there)', () => { - expect(specElementKeys('object-tree')).toBeNull(); + it('an `object-tree` entry since 17.5.0, and still no `tree` entry (controls: two that ARE there)', () => { + // `toBeNull()` through 17.4.0; the bump turned it red (objectui#11073). + expect(specElementKeys('object-tree')?.sort()).toEqual([ + 'data', + 'filter', + 'navigation', + 'objectName', + 'staticData', + 'tree', + ]); expect(specElementKeys('tree')).toBeNull(); // FIRING CONTROLS on those two nulls — without them a broken reader and a // real absence give the same answer. @@ -345,12 +375,20 @@ describe('objectui#8655 — the spec has no element schema for this node', () => }); }); -describe('objectui#8655 — `navigation` is objectui#8652\'s, and the unlock has not happened', () => { - it('the spec declares it on ONE element entry, and it is not this one', () => { - const declaring = specElementsDeclaring('navigation'); - expect(declaring).toContain('object-grid'); - expect(declaring).not.toContain('object-tree'); - expect(declaring).not.toContain('object-calendar'); +describe('objectui#8655 — `navigation` is objectui#8652\'s, and its unlock HAS happened', () => { + it('the spec declares it on seven element entries since 17.5.0, this one included', () => { + // Through 17.4.0: ONE entry, `object-grid`, and not this one. 17.5.0 met + // objectui#8652's unlock criterion (objectui#11073); mirroring is that + // card's ruled second step. + expect(specElementsDeclaring('navigation')).toEqual([ + 'object-calendar', + 'object-gantt', + 'object-grid', + 'object-kanban', + 'object-map', + 'object-timeline', + 'object-tree', + ]); }); it('⛔ and it stays UNDECLARED here — this card must not rule the family', () => { @@ -396,8 +434,10 @@ describe('objectui#8655 — `tree` is a VIEW-level block, so declaring it on the expect(blockKeys).toEqual(['defaultExpandedDepth', 'fields', 'labelField', 'parentField']); }); - it('…and NO element face declares a `tree` key (control: one key that IS on four)', () => { - expect(specElementsDeclaring('tree')).toEqual([]); + it('…and ONE element face declares a `tree` key since 17.5.0 — this node\'s (control: one key that IS on four)', () => { + // `[]` through 17.4.0. The premise this describe is named for expired at + // the bump (objectui#11073): see the UPDATE note in this file's header. + expect(specElementsDeclaring('tree')).toEqual(['object-tree']); // FIRING CONTROL on that zero, same instrument, same corpus. expect(specElementsDeclaring('filter').length).toBeGreaterThan(3); }); diff --git a/packages/plugin-view/src/ObjectView.tsx b/packages/plugin-view/src/ObjectView.tsx index ff22761fcb..9b0570cc1f 100644 --- a/packages/plugin-view/src/ObjectView.tsx +++ b/packages/plugin-view/src/ObjectView.tsx @@ -2081,10 +2081,11 @@ export const ObjectView: React.FC = ({ // The contract half is NOT here and must not be duplicated here. It // lives in `@object-ui/types` (`zod/objectql.zod.ts`), one string // (`KanbanStrayGroupByRefusal`) on two routes: the `list-view` route - // takes it through the view-level `KanbanConfig` mirror, and a named - // view's `listViews.KEY.kanban.groupBy` (what this branch serves) - // through `ObjectViewSchema`'s named-view door, - // `checkNamedViewKanbanStrayGroupBy`, in both nestings (objectui#10321). + // takes it through the view-level `KanbanConfig` mirror. A named view's + // `listViews.KEY.kanban.groupBy` (what this branch serves) is refused by + // the protocol's own strict record, naming the key; objectui#10321's + // pointer on that door was retired by objectui#11073, because the + // protocol's refusal is terminal since `@objectstack/spec` 17.5.0. // What this line closes is the BEHAVIOUR half: a document that reaches // this branch carrying the key, whether or not it passed a validator. // ⚠️ NODE-LOCAL vs VIEW-LEVEL, as everywhere in this branch: the diff --git a/packages/plugin-view/src/__tests__/ObjectView.calendarAliasRefused-8355.test.tsx b/packages/plugin-view/src/__tests__/ObjectView.calendarAliasRefused-8355.test.tsx index 542f5e475b..8474d9db2a 100644 --- a/packages/plugin-view/src/__tests__/ObjectView.calendarAliasRefused-8355.test.tsx +++ b/packages/plugin-view/src/__tests__/ObjectView.calendarAliasRefused-8355.test.tsx @@ -214,17 +214,19 @@ describe('objectui#8355 · second route, half 1 — the emitted node carries can }); describe('objectui#8355 · second route, half 2 — the document is REFUSED at the read door', () => { - it('a named view authoring `calendar.dateField` is refused BY NAME, pointing at `startDateField`', () => { - const issues = issuesEndingIn(objectViewDoc({ calendar: { dateField: 'kickoff' } }), 'calendar.dateField'); - expect(issues).toHaveLength(1); - expect(issues[0].code).toBe('custom'); - expect(issues[0].message).toContain('Did you mean `dateField` → `startDateField`?'); - }); - - it('…and `endField` in the same pass, pointing at `endDateField`', () => { - const issues = issuesEndingIn(objectViewDoc({ calendar: { endField: 'wrapup' } }), 'calendar.endField'); - expect(issues).toHaveLength(1); - expect(issues[0].message).toContain('Did you mean `endField` → `endDateField`?'); + // ⭐ RE-PINNED at objectui#11073. These two rows pinned objectui's pointer + // (`custom` at `calendar.ALIAS`, "Did you mean …"), which ran beside the + // protocol's refusal inside a named view. `@objectstack/spec` 17.5.0 makes that + // refusal terminal, zod skips even a `when`-guarded check after it, and the + // pointer was retired (seat ruling Q2 → A). The document is still refused, by + // the protocol, naming the key at the `calendar` block. + it.each([['dateField'], ['endField']])('a named view authoring `calendar.%s` is refused by the protocol, naming the key', (alias) => { + const doc = objectViewDoc({ calendar: { startDateField: 'kickoff', [alias]: 'kickoff' } }); + expect(issuesEndingIn(doc, `calendar.${alias}`)).toHaveLength(0); + const refusal = issuesEndingIn(doc, 'listViews.v1.calendar') as Array<{ code: string; keys?: string[] }>; + expect(refusal).toHaveLength(1); + expect(refusal[0].code).toBe('unrecognized_keys'); + expect(refusal[0].keys).toEqual([alias]); }); it('INVERTED: the LEGACY `options.calendar` nesting is refused WHOLE — `options` by name — and no longer judged inside (objectui#7928)', () => { diff --git a/packages/types/package.json b/packages/types/package.json index c9d4275d62..2b4c9ce0f7 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -93,8 +93,8 @@ "directory": "packages/types" }, "dependencies": { - "@objectstack/spec": "^17.4.0", - "zod": "^4.4.3" + "@objectstack/spec": "^17.5.0", + "zod": "^4.6.1" }, "devDependencies": { "@object-ui/test-support": "workspace:*", diff --git a/packages/types/src/__tests__/calendar-date-alias-refusal-8355.test.ts b/packages/types/src/__tests__/calendar-date-alias-refusal-8355.test.ts index ff67af410a..0964967fd8 100644 --- a/packages/types/src/__tests__/calendar-date-alias-refusal-8355.test.ts +++ b/packages/types/src/__tests__/calendar-date-alias-refusal-8355.test.ts @@ -317,18 +317,24 @@ describe('objectui#8355 · surface 5 — a NAMED VIEW, and the ledger it must no expect(ObjectQLComponentSchema.safeParse({ type: 'zzz-no-such-node' }).success).toBe(false); }); - it.each(RETIRED)('a named view authoring `calendar.%s` is refused through the union, naming `%s`', (alias, canonical) => { + // ⭐ RE-PINNED at objectui#11073. The named-view door added objectui's pointer + // (`custom` at `listViews.KEY.calendar.ALIAS`, "Did you mean …") beside the + // protocol's refusal. `@objectstack/spec` 17.5.0 makes that refusal terminal, + // zod skips even a `when`-guarded check after it, and the pointer was retired + // (seat ruling Q2 → A). What stands is the protocol's own refusal, naming the + // key at the block; the four DECLARED doors above keep objectui's pointer. + it.each(RETIRED)('a named view authoring `calendar.%s` is refused through the union by the protocol, naming the key', (alias) => { const r = ObjectQLComponentSchema.safeParse({ type: 'object-view', objectName: 'duly_task', listViews: { v1: { type: 'calendar', calendar: { [alias]: 'kickoff' } } }, }); expect(r.success, `listViews.v1.calendar.${alias} still parses green`).toBe(false); - const issue = r.success - ? undefined - : r.error.issues.find((i) => i.path.join('.') === `listViews.v1.calendar.${alias}`); - expect(issue?.code).toBe('custom'); - expect(issue?.message).toContain(`Did you mean \`${alias}\` → \`${canonical}\`?`); + const issues = r.success ? [] : r.error.issues; + const refusal = issues.find((i) => i.path.join('.') === 'listViews.v1.calendar') as { code?: string; keys?: string[] } | undefined; + expect(refusal?.code).toBe('unrecognized_keys'); + expect(refusal?.keys).toEqual([alias]); + expect(issues.filter((i) => i.code === 'custom')).toEqual([]); }); }); diff --git a/packages/types/src/__tests__/calendar-doc-key-set-8830.test.ts b/packages/types/src/__tests__/calendar-doc-key-set-8830.test.ts index 4314a8261f..2d2522c416 100644 --- a/packages/types/src/__tests__/calendar-doc-key-set-8830.test.ts +++ b/packages/types/src/__tests__/calendar-doc-key-set-8830.test.ts @@ -19,7 +19,7 @@ * "destructures exactly" those same four — "so the list above is also the whole * of what the renderer reads". * - * Measured on the dispatch base, `@objectstack/spec` 17.4.0 installed: + * Measured on the dispatch base, with `@objectstack/spec` 17.4.0 installed then: * * SPEC FACE `CalendarConfigSchema` is a strict object of exactly * `startDateField` / `endDateField` / `titleField` / @@ -175,14 +175,17 @@ describe('objectui#8830 — the page states the renderer\'s key set, derived on expect(listed).toEqual(Object.keys((CalendarConfigSchema as unknown as { shape: Record }).shape).sort()); }); - it('the spec schema really does refuse the fifth key BY NAME — the half the old sentence got right', () => { - const four = { startDateField: 's', endDateField: 'e', titleField: 't', colorField: 'c' }; - expect(CalendarConfigSchema.safeParse(four).success).toBe(true); - const refused = CalendarConfigSchema.safeParse({ ...four, allDayField: 'isAllDay' }); + it('the spec schema accepts `allDayField` since 17.5.0, and still refuses an undeclared key BY NAME', () => { + // Through `@objectstack/spec` 17.4.0 this row asserted `allDayField` as the + // REFUSED fifth key. 17.5.0 declares it (objectui#11073), so the fence above + // lists five keys and the refusal half moves to a key the spec never had. + const five = { startDateField: 's', endDateField: 'e', titleField: 't', colorField: 'c', allDayField: 'isAllDay' }; + expect(CalendarConfigSchema.safeParse(five).success).toBe(true); + const refused = CalendarConfigSchema.safeParse({ ...five, dateField: 'kickoff' }); expect(refused.success).toBe(false); if (!refused.success) { expect(refused.error.issues.map((i) => i.code)).toContain('unrecognized_keys'); - expect(JSON.stringify(refused.error.issues)).toContain('allDayField'); + expect(JSON.stringify(refused.error.issues)).toContain('dateField'); } }); }); diff --git a/packages/types/src/__tests__/calendar-flat-color-allday-8466.test.ts b/packages/types/src/__tests__/calendar-flat-color-allday-8466.test.ts index 6f19408649..b635ecfa69 100644 --- a/packages/types/src/__tests__/calendar-flat-color-allday-8466.test.ts +++ b/packages/types/src/__tests__/calendar-flat-color-allday-8466.test.ts @@ -409,23 +409,29 @@ describe('objectui#8466 — the spec refuses ALL FIVE flat keys, which is why de expect(oc.safeParse({ locale: 'en-GB' }).success).toBe(true); }); - it('`colorField` IS a spec key — but only in the NESTED block, which is a different position', () => { - // The distinction the whole decision turns on. `CalendarConfigSchema` is a - // strictObject of four keys: it HAS `colorField` and refuses `allDayField` - // by name. That asymmetry is real nested, and absent flat. + it('`colorField` and `allDayField` ARE spec keys — but only in the NESTED block, which is a different position', () => { + // The distinction the whole decision turns on: what the spec declares + // NESTED says nothing about the FLAT position, which the row above measures. + // Through `@objectstack/spec` 17.4.0 `CalendarConfigSchema` was a + // strictObject of four keys: it had `colorField` and refused `allDayField` + // by name, an asymmetry that was real nested and absent flat. 17.5.0 + // declares `allDayField` in the nested block too (objectui#11073), so the + // nested asymmetry is gone; the flat refusal of all five did not move. expect(Object.keys(CalendarConfigSchema.shape)).toEqual([ 'startDateField', 'endDateField', 'titleField', 'colorField', + 'allDayField', ]); - const nested = CalendarConfigSchema.safeParse({ startDateField: 's', allDayField: 'x' }); + expect(CalendarConfigSchema.safeParse({ startDateField: 's', allDayField: 'x' }).success).toBe(true); + expect(CalendarConfigSchema.safeParse({ startDateField: 's', colorField: 'c' }).success).toBe(true); + // Firing control: a key the nested block never declared is still refused by name. + const nested = CalendarConfigSchema.safeParse({ startDateField: 's', dateField: 'x' }); expect(nested.success).toBe(false); if (!nested.success) { expect(nested.error.issues.map((i) => i.code)).toContain('unrecognized_keys'); } - // Firing control: the same parse with a declared member is green. - expect(CalendarConfigSchema.safeParse({ startDateField: 's', colorField: 'c' }).success).toBe(true); }); }); diff --git a/packages/types/src/__tests__/detail-view-field-options-10296.test.ts b/packages/types/src/__tests__/detail-view-field-options-10296.test.ts index 43060e3945..c8b220ac33 100644 --- a/packages/types/src/__tests__/detail-view-field-options-10296.test.ts +++ b/packages/types/src/__tests__/detail-view-field-options-10296.test.ts @@ -117,36 +117,31 @@ describe('DetailViewFieldSchema.options is the spec authoring option schema (obj }); /** - * ⚠️ Pinned as it stands on the INSTALLED `@objectstack/spec` 17.4.0: a - * whitespace-only string is ACCEPTED and canonicalized. objectstack `main` - * already moved `visibleWhen` to `EvaluatedExpressionInputSchema`, whose - * string arm refuses blank strings, so the next spec bump TIGHTENS this and - * turns this test red on purpose. When it does, flip it to a refusal. + * Flipped at the pin bump, as this row's 17.4.0 docblock instructed + * (objectui#11073). On `@objectstack/spec` 17.4.0 a whitespace-only string + * was ACCEPTED and canonicalized; 17.5.0 moved `visibleWhen` to + * `EvaluatedExpressionInputSchema`, whose string arm refuses a blank string. + * The mirror reads the spec by reference, so it refuses it too. */ - it('accepts a whitespace-only `visibleWhen` on spec 17.4.0 (the next spec bump refuses it)', () => { - expect(optionOut(' ')).toEqual({ - label: 'Open', - value: 'open', - visibleWhen: { dialect: 'cel', source: ' ' }, - }); + it('refuses a whitespace-only `visibleWhen` (spec 17.5.0 and later)', () => { + const r = field({ label: 'Open', value: 'open', visibleWhen: ' ' }); + expect(r.success).toBe(false); + expect(issuesOf(r)).toContainEqual({ code: 'invalid_union', path: 'options.0.visibleWhen' }); }); /** - * TRIPWIRE for the spec-version gate in `zod-mirror-parity.test.ts` - * (`SpecEnvelopeAdmitsSourceless`). On the installed spec 17.4.0 the envelope's - * `source` is OPTIONAL in the static input type, and at runtime `ast` alone - * satisfies its refine (`{ dialect }` with neither is refused). So an envelope - * with no `source` parses, which the declaration's wire refuses. That is why - * `options` and its two containers are ledgered WIDER. objectstack `main` requires - * a non-blank `source`. When the pin bump turns this red, delete the gated rows, - * their `WIDER_ARMS` entries and the gate, then flip this to a refusal. + * The former TRIPWIRE for the spec-version gate `SpecEnvelopeAdmitsSourceless`, + * retired at the pin bump as it instructed (objectui#11073). On + * `@objectstack/spec` 17.4.0 the envelope's `source` was OPTIONAL and `ast` + * alone satisfied its refine, so a source-less envelope parsed and + * `options` and its two containers were ledgered WIDER. 17.5.0 requires a + * non-blank `source`, so the envelope is refused: the gated rows, their + * `WIDER_ARMS` entries and the gate are gone from `zod-mirror-parity.test.ts`. */ - it('accepts a source-less `{ dialect, ast }` envelope on spec 17.4.0 (the gated WIDER rows rest on this)', () => { - expect(optionOut({ dialect: 'cel', ast: { kind: 'ident' } })).toEqual({ - label: 'Open', - value: 'open', - visibleWhen: { dialect: 'cel', ast: { kind: 'ident' } }, - }); + it('refuses a source-less `{ dialect, ast }` envelope (spec 17.5.0 and later)', () => { + const r = field({ label: 'Open', value: 'open', visibleWhen: { dialect: 'cel', ast: { kind: 'ident' } } }); + expect(r.success).toBe(false); + expect(issuesOf(r)).toContainEqual({ code: 'invalid_union', path: 'options.0.visibleWhen' }); }); it('gives the same verdict as the spec schema itself, over a spread of options', () => { diff --git a/packages/types/src/__tests__/element-number-arm-10872.test.ts b/packages/types/src/__tests__/element-number-arm-10872.test.ts index 7408e5306b..76db1d8c76 100644 --- a/packages/types/src/__tests__/element-number-arm-10872.test.ts +++ b/packages/types/src/__tests__/element-number-arm-10872.test.ts @@ -291,30 +291,40 @@ describe('`element:number` reads the spec by reference (objectui#10872 batch 2)' const member = (ElementNumberBlockSchema.shape.dataSource as unknown as z.ZodOptional).unwrap() as unknown as z.ZodObject; const spec = ElementDataSourceSchema as unknown as z.ZodObject; expect(Object.keys(member.shape).sort()).toEqual(Object.keys(spec.shape).sort()); - // The boundary's `lazy` arm rebuilds this one clean schema (the recursive - // filter clause sits under `filter`), so it is an equal-answering clone — - // `imported-defaults-8317.test.ts` names it in `REBUILT_CLEAN`. - expect(stripImportedDefaults(spec)).not.toBe(spec); + // Through `@objectstack/spec` 17.4.0 the boundary's `lazy` arm rebuilt this + // one clean schema (the recursive filter clause sat under `filter`). 17.5.0 + // converged `filter` on the `ViewFilterRule` array, which reaches no + // `z.lazy`, so the schema is clean and the boundary hands back the spec's + // own object (objectui#11073; `REBUILT_CLEAN` in + // `imported-defaults-8317.test.ts` is empty for the same reason). + expect(stripImportedDefaults(spec)).toBe(spec); for (const probe of [{ object: 'order' }, { object: '' }, { object: 1 }, {}, { object: 'o', limit: 0 }, { object: 'o', objectName: 'o' }]) { expect(member.safeParse(probe).success, JSON.stringify(probe)).toBe(spec.safeParse(probe).success); } }); /** - * Measured WHERE the rebuild happens: `filter` is the member whose subtree - * reaches the recursive filter clause's `z.lazy`, the one node the boundary's - * walker rebuilds in this clean schema. Every probe above stops short of it. - * Each probe here goes through `filter`, the nested ones through the lazy's - * recursion, and each verdict is held to the spec's own schema — on the - * member, and on the node through both faces (the strict face rebuilds the - * same lazy a second time, as a fresh `z.lazy`). + * Measured WHERE the rebuild happened: through `@objectstack/spec` 17.4.0 + * `filter` was the member whose subtree reached the recursive filter clause's + * `z.lazy`, the one node the boundary's walker rebuilt in this clean schema. + * Every probe above stops short of it. Each probe here goes through `filter`, + * and each verdict is held to the spec's own schema — on the member, and on + * the node through both faces. + * + * ⚠️ 17.5.0 moved the ACCEPT SET under these rows (objectui#11073): `filter` + * converged on the `ViewFilterRule` array `[{ field, operator, value }]`, and + * the MongoDB-style record form, `$and` / `$or` included, is refused. The + * rows follow the spec's verdicts rather than restating the old ones, and the + * array row is the one that parses now. What the renderer does with each + * shape is objectui#8945's, not these rows'. */ it.each([ - ['record filter', { object: 'o', filter: { status: 'won' } }, true], - ['nested $and / $or filter', { object: 'o', filter: { $and: [{ status: 'won' }, { $or: [{ amount: { $gt: 100 } }, { stage: { $in: ['a', 'b'] } }] }] } }, true], + ['ViewFilterRule array filter', { object: 'o', filter: [{ field: 'status', operator: 'equals', value: 'won' }] }, true], + ['record filter', { object: 'o', filter: { status: 'won' } }, false], + ['nested $and / $or filter', { object: 'o', filter: { $and: [{ status: 'won' }, { $or: [{ amount: { $gt: 100 } }, { stage: { $in: ['a', 'b'] } }] }] } }, false], ['non-object filter', { object: 'o', filter: 5 }, false], ['non-object clause inside $and', { object: 'o', filter: { $and: [5] } }, false], - ] as const)('`dataSource.filter` through the rebuilt binding answers as the spec does: %s', (_name, probe, expected) => { + ] as const)('`dataSource.filter` through the binding answers as the spec does: %s', (_name, probe, expected) => { const member = (ElementNumberBlockSchema.shape.dataSource as unknown as z.ZodOptional).unwrap() as unknown as z.ZodObject; const spec = ElementDataSourceSchema as unknown as z.ZodObject; // Non-vacuity: the spec's own verdict is the one this row names. diff --git a/packages/types/src/__tests__/imported-defaults-8317.test.ts b/packages/types/src/__tests__/imported-defaults-8317.test.ts index 7f55e132bf..884180b00f 100644 --- a/packages/types/src/__tests__/imported-defaults-8317.test.ts +++ b/packages/types/src/__tests__/imported-defaults-8317.test.ts @@ -108,6 +108,8 @@ import { objectNavTargetExclusivity, checkListViewCalendarVisualization, checkPageSourceCompleteness, + checkDashboardWidgetStageOrder, + checkDashboardWidgetMetricMeasureArity, } from '@objectstack/spec/ui'; import { SelectOptionSchema as SpecSelectOptionSchema } from '@objectstack/spec/data'; import { stripImportedDefaults } from '../zod/imported-defaults.js'; @@ -281,10 +283,14 @@ const CARRIES_DEFAULT = IMPORTED.filter(([, s]) => defaultsIn(s) > 0); * inferred, so the set cannot grow in silence — the lazy test below holds it * equal to what the graph re-derives. * - * `ElementDataSourceSchema` (objectui#10872 batch 2, the `element:number` - * arm's `dataSource`) reaches the recursive filter clause through `filter`. + * EMPTY since `@objectstack/spec` 17.5.0 (objectui#11073). It named + * `ElementDataSourceSchema` (objectui#10872 batch 2, the `element:number` arm's + * `dataSource`), whose `filter` reached the recursive filter clause's `z.lazy`. + * 17.5.0 converged that `filter` on the `ViewFilterRule` array, which reaches + * no `z.lazy`, so the schema is clean and now comes back reference-equal like + * every other member of `CARRIES_NONE`. */ -const REBUILT_CLEAN: ReadonlySet = new Set(['ElementDataSourceSchema']); +const REBUILT_CLEAN: ReadonlySet = new Set(); /** …and the complement of both, where the strip must be the identity function. */ const CARRIES_NONE = IMPORTED.filter(([n, s]) => defaultsIn(s) === 0 && !REBUILT_CLEAN.has(n)); @@ -402,6 +408,14 @@ describe('the import boundary strips every imported default (objectui#8317)', () * probe list. `.default(v)` makes a member omissible; `.removeDefault()` * alone gives that omissibility back to a bare `ZodDefault(T)` — so every * member that was omissible before must still be omissible after. + * + * ⚠️ Omissibility is a RUNG, and zod 4.6 has two omissible rungs + * (objectui#11073): a `.default()` member answers `optin: 'defaulted'` + * ("absent, and something is substituted"), where zod 4.4 answered + * `'optional'` for it. Removing the substitution is this boundary's whole + * job, so the stripped member answers `'optional'`: a rung CHANGE that is + * not a narrowing. What must never happen is an omissible rung becoming + * none at all, and a `'defaulted'` rung surviving the strip. */ it.each(CARRIES_DEFAULT.map(([n]) => [n] as const))('%s: no member became REQUIRED', (name) => { const [, raw] = CARRIES_DEFAULT.find(([n]) => n === name)!; @@ -410,10 +424,16 @@ describe('the import boundary strips every imported default (objectui#8317)', () const strippedShape = defOf(stripImportedDefaults(raw))!.shape!; const optin = (n: unknown) => (n as { _zod?: { optin?: string } })._zod?.optin; for (const key of Object.keys(rawShape)) { + const before = optin(rawShape[key]); + const after = optin(strippedShape[key]); expect( - optin(strippedShape[key]), + after !== undefined, `${name}.${key} changed omissibility — removing a default must not narrow the accept set`, - ).toBe(optin(rawShape[key])); + ).toBe(before !== undefined); + expect( + after, + `${name}.${key}: the strip left a substituting rung behind, or moved a rung it had no default to remove`, + ).toBe(before === 'defaulted' ? 'optional' : before); } }); }); @@ -534,6 +554,10 @@ describe('the import boundary strips every imported default (objectui#8317)', () // rebuilt them without the spec object's own checks. ['checkListViewCalendarVisualization', checkListViewCalendarVisualization], ['checkPageSourceCompleteness', checkPageSourceCompleteness], + // objectui#11073: the two `@objectstack/spec` 17.5.0 added to `DashboardWidgetSchema`, + // re-attached by objectui's `DashboardWidgetSchema` under the same ruling. + ['checkDashboardWidgetStageOrder', checkDashboardWidgetStageOrder], + ['checkDashboardWidgetMetricMeasureArity', checkDashboardWidgetMetricMeasureArity], ]); const isSpecModule = (m: string): boolean => diff --git a/packages/types/src/__tests__/imported-defaults-describe-9034.test.ts b/packages/types/src/__tests__/imported-defaults-describe-9034.test.ts index c7d9b42238..88239aef07 100644 --- a/packages/types/src/__tests__/imported-defaults-describe-9034.test.ts +++ b/packages/types/src/__tests__/imported-defaults-describe-9034.test.ts @@ -204,7 +204,11 @@ const hasRestlessTuple = (root: z.ZodType): boolean => { * walker branched on and `optin` is invariant under the walk. * * - `'inner-optin'` asks the unwrapped SOURCE node the same question - * `isAlreadyOptional` asks. The live rule. + * `isAlreadyOptional` asks. The live rule. ⚠️ Since zod 4.6 + * (objectui#11073) "omissible" is two rungs: a `ZodDefault` answers + * `optin: 'defaulted'`, not `'optional'`. The walker only ever sees such a + * node AFTER walking it, which turns the rung into `'optional'`; the source + * side still carries `'defaulted'`, so this rule counts either rung. * - `'output-type'` is ⛔ the superseded rule, kept ONLY as the control that * fires and ⛔ never used for a published figure. It asked the OUTPUT's * `def.type === 'optional'`; on the `.optional().default()` spelling the @@ -227,7 +231,7 @@ interface Tally { * EVERY default arm whose unwrapped inner was already omissible, described or * not — objectui#9103's population. Overwhelmingly the `.optional().default()` * spelling; a default directly under a default lands here too, because a - * `ZodDefault` answers `optin: 'optional'` as well. + * `ZodDefault` is omissible as well (`optin: 'defaulted'` since zod 4.6). */ alreadyOptionalArms: number; /** `.describe().default()` — the description sits on the node UNDER the default. */ @@ -296,7 +300,7 @@ const pairRoots = (roots: readonly [string, z.ZodType][], rule: DefaultArmRule): // pins the two equal, so a zod that changed one and not the other reddens // there instead of silently re-pointing this walk. const removed = (before as unknown as { removeDefault: () => z.ZodType }).removeDefault(); - const innerWasOptional = optinOf(removed) === 'optional'; + const innerWasOptional = optinOf(removed) !== undefined; if (innerWasOptional) { t.alreadyOptionalArms++; if (bDesc !== undefined) t.alreadyOptionalInner++; diff --git a/packages/types/src/__tests__/mirror-partial-record-narrowing-8516.test.ts b/packages/types/src/__tests__/mirror-partial-record-narrowing-8516.test.ts index 3c703cae3f..b7ebd4e21e 100644 --- a/packages/types/src/__tests__/mirror-partial-record-narrowing-8516.test.ts +++ b/packages/types/src/__tests__/mirror-partial-record-narrowing-8516.test.ts @@ -170,17 +170,22 @@ describe('objectui#8516 — GridSchema.columns accept set', () => { }); it('names the offending key in the issue an author is shown', () => { - // Q1, measured rather than assumed. `columns` is a UNION, so zod reports one - // top-level `invalid_union` whose message is the bare "Invalid input"; the - // CLI's `explainUnionIssue` expands an undiscriminated union's arms and - // rebases their paths, which is where the key surfaces. `z.partialRecord` - // puts it in the PATH (`columns -> xxl`); the `.strict()` object spelling - // would put it in the MESSAGE instead. Both name it; this pins which. + // Q1, measured rather than assumed. `columns` is a UNION (a number, or the + // `z.partialRecord`). Under zod 4.4 zod reported one top-level + // `invalid_union` whose message was the bare "Invalid input", and the key + // surfaced only inside its arms, as an `invalid_key` whose PATH held it. + // Since zod 4.6 (objectui#11073, forced by `@objectstack/spec` 17.5.0's + // `zod ^4.6.1`) a record's out-of-vocabulary key is a NON-aborting + // `unrecognized_keys`, the number arm is the only aborted one, and the union + // returns the record arm's issue on its own: `unrecognized_keys` AT + // `columns`, naming the key in `keys` and in its message. Still named, and + // named at the top level now; this pins which. const r = GridZodMirror.safeParse({ type: 'grid', columns: { xxl: 6 } }); expect(r.success).toBe(false); - const arms = (r.error!.issues[0] as { errors?: { path: PropertyKey[]; code?: string }[][] }).errors ?? []; - const flat = arms.flat(); - expect(flat.some((i) => i.code === 'invalid_key' && i.path.includes('xxl'))).toBe(true); + expect(r.error!.issues).toEqual([ + expect.objectContaining({ code: 'unrecognized_keys', path: ['columns'], keys: ['xxl'] }), + ]); + expect(r.error!.issues[0].message).toContain('xxl'); }); }); diff --git a/packages/types/src/__tests__/named-view-kanban-stray-group-by-10321.test.ts b/packages/types/src/__tests__/named-view-kanban-stray-group-by-10321.test.ts index a49fe8478b..d11b25da0f 100644 --- a/packages/types/src/__tests__/named-view-kanban-stray-group-by-10321.test.ts +++ b/packages/types/src/__tests__/named-view-kanban-stray-group-by-10321.test.ts @@ -8,7 +8,19 @@ /** * objectui#10321 — a named view's stray `kanban.groupBy` is refused at the - * object-view read door, with the string the `list-view` route already gives. + * object-view read door. + * + * ## ⭐ RE-PINNED at objectui#11073: the protocol's refusal, and no pointer + * + * Until `@objectstack/spec` 17.5.0 this door added objectui's pointer + * (`custom` at `listViews.KEY.kanban.groupBy`, the `list-view` route's string) + * beside the protocol's `unrecognized_keys`. 17.5.0 makes that refusal TERMINAL, + * zod then skips even a `when`-guarded check, and the pointer + * (`checkNamedViewKanbanStrayGroupBy`) was retired (seat ruling Q2 → A on + * objectui#11073). The rows below pin what stands: the protocol refuses the + * key by name at `listViews.KEY.kanban`, and objectui adds no issue of its own. + * The `list-view` route keeps the pointer; `listViewRouteMessage` still reads it + * there, as the control that the pointer lives on and was not deleted. * * The objectui#8365 ruling (B, maintainer 「8365 同意」) refuses a stored view's * `kanban.groupBy` loudly 「at the read door of the view, visible to the author @@ -21,18 +33,17 @@ * * ## What is asserted * - * - REFUSED, both nestings, through the published door (`safeValidateSchema`, - * which `objectui validate` runs): the issue's `code` and `path`, and a - * message EQUAL to the one the `list-view` route gives. Equality, not a copy - * of the text: the ruling fixes the sentence shape, and the lead sentence is - * asserted only because the ruling names it. + * - REFUSED through the published door (`safeValidateSchema`, which + * `objectui validate` runs): the protocol's `unrecognized_keys` at the block, + * naming the key (since objectui#11073; until then also objectui's pointer, + * a message EQUAL to the one the `list-view` route gives). * - DARK CONTROL: the canonical named view parses green through the same door. * - SCOPE CONTROLS: the door is not a mirror of `listViews`. An undeclared * sibling in the kanban block is kept, a kanban block without the spec's * required `columns` is accepted, and so is the legacy `options.kanban` bag * without the stray key. - * - SIBLING CONTROL: the calendar check on the same door still fires, so an - * ablation of the kanban check reddens the kanban pins alone. + * - SIBLING CONTROL: the calendar block on the same door is refused the same + * way, by the protocol, naming its retired spelling. * * ## ⭐ Inverted at objectui#7928, ⛔ no assertion deleted * @@ -47,11 +58,8 @@ * of its own to any of them. Fixtures meant as canonical carry the protocol's * required `columns`. * - * REVERSE VERIFICATION, direction predicted before running: unwire - * `checkNamedViewKanbanStrayGroupBy` from `ObjectViewSchema` ⇒ every REFUSED arm - * goes red (the document parses green), while the dark, scope and sibling - * controls stay green. The run is recorded on the pull request, not kept as a - * test. + * The reverse verification objectui#10321 recorded (unwiring the pointer turned + * the REFUSED arms red) described the retired check; it is not re-runnable. */ import { describe, it, expect } from 'vitest'; @@ -93,22 +101,20 @@ const CANONICAL_KANBAN = { type: 'kanban', columns: ['name'], kanban: { groupByF const customIssues = (result: ReturnType) => issuesOf(result).filter((i) => i.code === 'custom'); describe('objectui#10321 · a named view authoring the stray `kanban.groupBy` is REFUSED', () => { - it('declared `kanban` block: refused by name at its own path, with the list-view route\'s string', () => { + it('declared `kanban` block: refused by the protocol, naming the key at the block (objectui#11073)', () => { const view = { ...CANONICAL_KANBAN, kanban: { ...CANONICAL_KANBAN.kanban, groupBy: 'stage' } }; - const path = 'listViews.v1.kanban.groupBy'; const result = safeValidateSchema(objectView({ v1: view })); - expect(result.success, `${path} still parses green`).toBe(false); - const issue = issueAt(result, path); - expect(issue?.code).toBe('custom'); - expect(issue?.message).toBe(listViewRouteMessage()); - expect(issue?.message).toContain('Did you mean `groupBy` → `groupByField`?'); - // Was "one key written, one issue". Since objectui#7928 the protocol's own - // refusal of the key sits beside this door's pointer: one key written, two - // issues, the refusal at the block and the pointer at the key. - expect(issuesOf(result), 'one key written: the protocol\'s refusal and this door\'s pointer').toHaveLength(2); + expect(result.success, 'listViews.v1.kanban.groupBy still parses green').toBe(false); + // One key written, one issue: the protocol's refusal at the block. The pointer + // that stood beside it at `listViews.v1.kanban.groupBy` was retired. + expect(issuesOf(result), 'one key written: the protocol\'s refusal alone').toHaveLength(1); const refusal = issueAt(result, 'listViews.v1.kanban') as (Issue & { keys?: string[] }) | undefined; expect(refusal?.code).toBe('unrecognized_keys'); expect(refusal?.keys).toEqual(['groupBy']); + expect(refusal?.message).toContain('`groupBy`'); + expect(issueAt(result, 'listViews.v1.kanban.groupBy')).toBeUndefined(); + // Control: the `list-view` route still answers the same key with objectui's pointer. + expect(listViewRouteMessage()).toContain('Did you mean `groupBy` → `groupByField`?'); }); it('INVERTED — legacy `options.kanban` bag: refused WHOLE, `options` by name, and no longer judged inside (objectui#7928)', () => { @@ -132,7 +138,8 @@ describe('objectui#10321 · a named view authoring the stray `kanban.groupBy` is clean: CANONICAL_KANBAN, stray: { ...CANONICAL_KANBAN, kanban: { ...CANONICAL_KANBAN.kanban, groupBy: 'owner' } }, })); - expect(customIssues(result).map((i) => i.path.join('.'))).toEqual(['listViews.stray.kanban.groupBy']); + expect(issuesOf(result).filter((i) => i.code === 'unrecognized_keys').map((i) => i.path.join('.'))).toEqual(['listViews.stray.kanban']); + expect(customIssues(result)).toEqual([]); // …and nothing at all is reported under the clean view. expect(issuesOf(result).filter((i) => i.path.join('.').startsWith('listViews.clean'))).toEqual([]); }); @@ -175,10 +182,12 @@ describe('objectui#10321 · the controls, each able to fire on its own', () => { expect(customIssues(legacyBag)).toEqual([]); }); - it('SIBLING CONTROL: the calendar arm on the same door still refuses its retired spelling', () => { + it('SIBLING CONTROL: the calendar block on the same door is refused by the protocol too, naming its retired spelling', () => { const result = safeValidateSchema(objectView({ v1: { type: 'calendar', calendar: { dateField: 'kickoff' } } })); - const issue = issueAt(result, 'listViews.v1.calendar.dateField'); - expect(issue?.code).toBe('custom'); - expect(issue?.message).toContain('Did you mean `dateField` → `startDateField`?'); + const refusal = issueAt(result, 'listViews.v1.calendar') as (Issue & { keys?: string[] }) | undefined; + expect(refusal?.code).toBe('unrecognized_keys'); + expect(refusal?.keys).toEqual(['dateField']); + expect(issueAt(result, 'listViews.v1.calendar.dateField')).toBeUndefined(); + expect(customIssues(result)).toEqual([]); }); }); 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 85bb8539fa..f758c77fb8 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 @@ -273,11 +273,13 @@ const NAMED_VIEW_READ_UNDECLARED = [] as const; * 44 of objectui#7928 minus bucket ①'s twenty-three, which objectui#10758 made * the host delegation read off the named view. What is left is bucket ② (the * legacy `show*` spellings), bucket ③ (objectui-only members) and the two - * members ruled inert below — no member the protocol declares is on it any - * more except those two. + * members `@objectstack/spec` 17.5.0 retired ({@link BUCKET_PROTOCOL_RETIRED_TOMBSTONED}) + * — no LIVE protocol member is on it. * - * ⭐ TWO OF THEM ARE objectui#8980's OWN, AND THAT IS THE RULED OUTCOME, not - * an oversight: `tabs` and `pageName`. The ruling's item 2 requires a member + * ⭐ TWO OF THEM WERE objectui#8980's OWN, the ruled outcome then: `tabs` and + * `pageName`. Since objectui#11073 both are `?: never` tombstones, following the + * protocol's retirement; the paragraph below is the record of why they were + * declared at all. The ruling's item 2 requires a member * with no renderer behaviour to attach to be DECLARED and REPORTED with its * measurement — ⛔ not silently declared inert and ⛔ not dropped from the type. * The measurements, reported on objectui#8980: objectui's tab bar for an object @@ -333,7 +335,7 @@ const NAMED_VIEW_ABSENT_CONTROL = 'stickyHeader'; /** * 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 + * shape, off `@objectstack/spec` as installed (17.5.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. */ @@ -348,13 +350,14 @@ const PROTOCOL_LIST_VIEW_KEYS = [ ] 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 + * Seven of those 50 are RETIREMENT TOMBSTONES, refused by name with the + * protocol's own `[REMOVED]` text (five until `@objectstack/spec` 17.5.0 retired + * `pageName` and `tabs`, objectui#11073). ⚠️ 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; +const PROTOCOL_RETIRED_KEYS = ['bordered', 'pageName', 'performance', 'responsive', 'striped', 'tabs', 'virtualScroll'] as const; /** The marker every protocol tombstone's description opens with. */ const PROTOCOL_TOMBSTONE_MARKER = '[REMOVED]'; @@ -466,10 +469,13 @@ const NAMED_LIST_VIEW_TOMBSTONES = [ ] as const; /** - * The two members objectui#8980 declared KNOWING nothing reads them, with the - * measurement reported — ⛔ not an oversight and ⛔ not a retirement candidate. + * The two members objectui#8980 declared KNOWING nothing reads them ("declared + * inert", the protocol declared them). `@objectstack/spec` 17.5.0 RETIRED both, + * and the seat's Q4 ruling on objectui#11073 retired them on objectui's face as + * `?: never` tombstones: the one bucket of protocol keys objectui still names, + * named only to refuse them. */ -const BUCKET_DECLARED_INERT = ['pageName', 'tabs'] as const; +const BUCKET_PROTOCOL_RETIRED_TOMBSTONED = ['pageName', 'tabs'] as const; /** * The one objectui-only member that WAS read off a named view, until @@ -1586,11 +1592,11 @@ describe('objectui#7924 — the unread members, in the FOUR buckets the ruling w 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', () => { + it('seven of the 50 are RETIREMENT TOMBSTONES, refused BY NAME — so objectui not declaring them LIVE 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. + // refuses everything rather than a reading of these seven keys. expect(SpecObjectListViewSchema.safeParse(base).success).toBe(true); for (const key of PROTOCOL_RETIRED_KEYS) { const r = SpecObjectListViewSchema.safeParse({ ...base, [key]: true }); @@ -1604,15 +1610,19 @@ describe('objectui#7924 — the unread members, in the FOUR buckets the ruling w || (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([]); + // …and none of the seven is declared LIVE on the objectui face. Five were + // never declared; the two objectui#8980 declared are `?: never` tombstones + // since objectui#11073, refused by name on this face as on the protocol's. + const { names, tombstones } = namedListViewMembers(); + const declaredLive = names.filter((n) => !tombstones.includes(n)); + expect(PROTOCOL_RETIRED_KEYS.filter((k) => declaredLive.includes(k))).toEqual([]); + expect(PROTOCOL_RETIRED_KEYS.filter((k) => tombstones.includes(k))).toEqual([...BUCKET_PROTOCOL_RETIRED_TOMBSTONED]); }); 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); + expect(live).toHaveLength(43); 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 @@ -1669,10 +1679,10 @@ describe('objectui#7924 — the unread members, in the FOUR buckets the ruling w it('the 21 unread members partition FOUR ways — 0 + 8 + 11 + 2 since objectui#10758 (23 + 8 + 11 + 2 before it) — 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 retiredTombstoned = new Set(BUCKET_PROTOCOL_RETIRED_TOMBSTONED); const spellings = new Set(BUCKET_LEGACY_SHOW_SPELLINGS); - const protocolDeclared = [...unread].filter((m) => protocolKeys.has(m) && !ruledInert.has(m)).sort(); + const protocolDeclared = [...unread].filter((m) => protocolKeys.has(m) && !retiredTombstoned.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(); @@ -1680,18 +1690,18 @@ describe('objectui#7924 — the unread members, in the FOUR buckets the ruling w 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([...retiredTombstoned].sort()).toEqual([...BUCKET_PROTOCOL_RETIRED_TOMBSTONED]); // Bucket ① is EMPTY since objectui#10758: every protocol member left on the - // unread side is one of the two ruled inert. Derived, not assumed — the - // same filter that produced the twenty-three produces nothing now. + // unread side is one of the two the protocol retired at 17.5.0 (objectui#8980's + // "declared inert" pair, tombstoned since objectui#11073). Derived, not assumed. expect(protocolDeclared).toHaveLength(0); expect(legacy).toHaveLength(8); expect(inventedOnly).toHaveLength(11); - expect(BUCKET_DECLARED_INERT).toHaveLength(2); + expect(BUCKET_PROTOCOL_RETIRED_TOMBSTONED).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]; + const union = [...protocolDeclared, ...legacy, ...inventedOnly, ...BUCKET_PROTOCOL_RETIRED_TOMBSTONED]; expect(union).toHaveLength(21); expect(new Set(union).size).toBe(21); expect([...union].sort()).toEqual([...unread].sort()); @@ -1713,13 +1723,15 @@ describe('objectui#7924 — the unread members, in the FOUR buckets the ruling w expect(map).not.toHaveProperty('showRecordCount'); }); - it('BUCKET ① is EMPTY since objectui#10758 — no protocol member but the two ruled inert is left unread off a named view', () => { + it('BUCKET ① is EMPTY since objectui#10758 — no LIVE protocol member is left unread off a named view', () => { expect([...BUCKET_PROTOCOL_DECLARED_UNREAD]).toEqual([]); const reads = deriveNamedViewReads().reads; const live = protocolListViewKeys().filter((k) => !protocolRetiredKeys().includes(k)); const declared = namedListViewMembers().names; const unreadProtocol = live.filter((k) => declared.includes(k) && !reads.includes(k)).sort(); - expect(unreadProtocol).toEqual([...BUCKET_DECLARED_INERT]); + // Was the objectui#8980 pair until `@objectstack/spec` 17.5.0 retired both; + // a retired key is not live, so the set is empty (objectui#11073). + expect(unreadProtocol).toEqual([]); // Control on the same derivation: it still finds the bucket-② / ③ members // unread, so the empty bucket is a reading and not a probe that reads all. expect(declared.filter((k) => !reads.includes(k))).toContain(NAMED_VIEW_UNREAD_CONTROL); @@ -1758,9 +1770,12 @@ describe('objectui#7924 — the unread members, in the FOUR buckets the ruling w 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); + it.each(BUCKET_PROTOCOL_RETIRED_TOMBSTONED)('RETIRED BY THE PROTOCOL — `%s` is a protocol tombstone at 17.5.0 and a `?: never` tombstone here, read nowhere (objectui#11073)', (member) => { + const { names, tombstones, docs } = namedListViewMembers(); + expect(names).toContain(member); + expect(tombstones).toContain(member); + expect(docs[member]).toContain('RETIRED (objectui#11073)'); + expect(protocolRetiredKeys()).toContain(member); expect(deriveNamedViewReads().reads).not.toContain(member); expect(NAMED_LIST_VIEW_UNREAD as readonly string[]).toContain(member); }); @@ -1862,7 +1877,11 @@ export const _acceptedDensityCanonical: NamedListView = { ...BASE_VIEW, rowHeigh describe('objectui#7924 — the retirement: seventeen `?: never` tombstones, one retained read, the canonical blocks', () => { it('the tombstone set is EXACTLY bucket ② plus bucket ③ minus the retained read and the one held member, derived off the AST', () => { - const { tombstones, names } = namedListViewMembers(); + const { tombstones: allTombstones, names } = namedListViewMembers(); + // The objectui#11073 pair are protocol-retirement tombstones, censused in + // their own bucket above; this row is about objectui#7924's seventeen. + const tombstones = allTombstones.filter((t) => !(BUCKET_PROTOCOL_RETIRED_TOMBSTONED as readonly string[]).includes(t)); + expect(allTombstones).toHaveLength(19); expect([...tombstones].sort()).toEqual([...NAMED_LIST_VIEW_TOMBSTONES]); expect(tombstones).toHaveLength(17); const expected = [ diff --git a/packages/types/src/__tests__/percent-field-metadata-scale-9784.test.ts b/packages/types/src/__tests__/percent-field-metadata-scale-9784.test.ts index 121c5e7757..1eaaa0a6e6 100644 --- a/packages/types/src/__tests__/percent-field-metadata-scale-9784.test.ts +++ b/packages/types/src/__tests__/percent-field-metadata-scale-9784.test.ts @@ -157,9 +157,9 @@ describe('objectui#9784 — the percent cell renderer reads `scale` off the fiel const src = readFileSync(join(REPO_ROOT, CELL), 'utf8'); it('the read still exists — the fact this declaration was catching up to', () => { - // Pinned as the READ, not as the whole expression: objectui#9808 is open - // against this same renderer over an unbounded `scale`, and a clamp there - // may reshape the expression around this member without ending the read. + // Pinned as the READ, not as the whole expression: objectui#9808's clamp + // reshaped the expression around this member without ending the read, and + // retiring it at its SUNSET (objectui#11073) did the same. expect(src, `${CELL} no longer reads \`scale\` off the percent field`).toMatch(/percentField\.scale\b/); }); diff --git a/packages/types/src/__tests__/record-highlights-layout-9187.test.ts b/packages/types/src/__tests__/record-highlights-layout-9187.test.ts index 2e28803e16..ea283d5482 100644 --- a/packages/types/src/__tests__/record-highlights-layout-9187.test.ts +++ b/packages/types/src/__tests__/record-highlights-layout-9187.test.ts @@ -139,10 +139,17 @@ describe('objectui#9187 — record:highlights `layout` against the installed spe const layout = RecordHighlightsProps.def.shape.layout; const entries = Object.keys(layout.def.innerType.def.entries).sort(); expect(entries).toEqual(['horizontal', 'vertical']); + // `@objectstack/spec` 17.5.0 added the record-block security members + // (`requiredPermissions`, `redactFields`, `enforceFieldSecurity`) beside + // `layout` (objectui#11073). `layout`'s closed set above did not move, so + // every pin in this file still describes the contract as it stands. expect(Object.keys(RecordHighlightsProps.def.shape).sort()).toEqual([ 'aria', + 'enforceFieldSecurity', 'fields', 'layout', + 'redactFields', + 'requiredPermissions', ]); }); diff --git a/packages/types/src/__tests__/report-chart-query-spec-parity.test.ts b/packages/types/src/__tests__/report-chart-query-spec-parity.test.ts index f54ec9bb68..496f153bc0 100644 --- a/packages/types/src/__tests__/report-chart-query-spec-parity.test.ts +++ b/packages/types/src/__tests__/report-chart-query-spec-parity.test.ts @@ -24,9 +24,11 @@ * wearing. The risk is picking a new name the spec ALREADY owns — the * `PageComponentSchema` mistake from objectui#3074 — so each new name is * asserted absent from the spec's export set, types and values alike. - * - **Not burnable yet** (`JoinedReportBlock`): erased in the installed spec, - * typed — as a different shape — on spec `main`; the burn-down is owed at the - * bump that installs the typed one. See the bottom of this file. + * - **Burnable now, on its own card** (`JoinedReportBlock`): erased through the + * published 17.4.0, typed — as a different shape — since 17.5.0, which this + * repository installs since objectui#11073. The tripwire has fired and been + * flipped; the burn-down (a published-type change) is objectui#10940's. See the + * bottom of this file. */ import { describe, it, expect } from 'vitest'; @@ -781,30 +783,29 @@ describe('renamed local dialects do not collide with a spec export (objectui#307 * the derived type would be `unknown`, the exact regression this section exists * to stop. * - * ## What guards it now: two halves, because two different runs read two different specs + * ## What guards it now: one state, on both runs (objectui#11073) * - * - COMPILE TIME, both runs. The state pin admits `erased` and `typed` and - * refuses `any`. On a typed spec, the divergence lines under it each name one - * difference and stop compiling the day that one moves — on the Spec Main - * Shape Gate first. On the erased spec they are not asked (`OnTypedSpec`): - * an erased type declares no member to compare. - * - TEST TIME, the installed spec only (the Spec Main Shape Gate type-checks and - * runs no test). The tripwire at the bottom reads the INSTALLED spec's built - * declarations and fails on the first bump past the published 17.4.0. If - * that release types the block, the failure is the burn-down's start signal: - * derive the published interface from the spec in its own slice, and delete - * the `erased` arm from the state pin so that an erasure stops compiling - * again. If that release still erases it, the spec erased it again — an - * upstream regression to report, and the `erased` arm loses its licence all - * the same. + * - COMPILE TIME, both runs. Until the bump that installed `@objectstack/spec` + * 17.5.0 the state pin admitted `erased` as well as `typed`, because the + * pull-request type-check read the erasing 17.4.0 while the Spec Main Shape + * Gate read `main`. 17.5.0 carries #20369, so both runs now read a typed + * block, and the pin admits `typed` ALONE: an erasure — on the installed + * spec or on `main` — stops compiling. `any` is refused as before. The + * divergence lines under it each name one difference and stop compiling the + * day that one moves, on the Spec Main Shape Gate first. + * - TEST TIME, the resolved spec only (the Spec Main Shape Gate type-checks and + * runs no test). The tripwire at the bottom reads the resolved spec's built + * declarations. It fired at that bump, as it was written to, and was flipped: + * it now expects `typed`, so an erasure is reported as the upstream + * regression it would be. The burn-down it announced — replacing the + * PUBLISHED `JoinedReportBlock` with the spec's type — is objectui#10940's + * own slice, not the bump's. * - * ⚠️ NOT guardable from here: an erasure on objectstack `main` BEFORE that - * bump. At the type level it is indistinguishable from the installed 17.4.0 — - * both are the same bare `z.ZodTypeAny` — so the state pin admits it on the - * gate. The producer's own pin covers that window - * (`packages/spec/src/ui/joined-report-block-type.test.ts` in objectstack, - * landed with #20369), and the tripwire's version row catches it at the bump - * at the latest. + * The window that was NOT guardable from here before that bump — an erasure on + * objectstack `main` looked, at the type level, exactly like the erasing + * 17.4.0 the pull-request run read — closed with the `erased` arm. The + * producer's own pin (`packages/spec/src/ui/joined-report-block-type.test.ts` + * in objectstack, landed with #20369) still covers it upstream. * * ⚠️ objectstack#4171 (CLOSED 2026-07-30) was never this symbol's release: it * typed the RECURSIVE schemas, and this erasure's cause was the bare @@ -816,17 +817,19 @@ type IsAny = 0 extends 1 & T ? true : false; /** `any` is asked first: it passes `IsUnknown` as well. */ type SpecTyping = IsAny extends true ? 'any' : IsUnknown extends true ? 'erased' : 'typed'; -// THE STATE PIN. `erased` = the installed, published 17.4.0; `typed` = spec -// `main` since objectstack#20369. `any` is refused on both (case 2). -const _specJoinedReportBlockTyping = null as unknown as SpecTyping satisfies - | 'erased' - | 'typed'; +// THE STATE PIN. `typed` — the installed `@objectstack/spec` 17.5.0 and spec +// `main` since objectstack#20369 both type the block. The `erased` arm (the +// published 17.4.0) was deleted at the bump that installed 17.5.0 (objectui#11073), +// as the tripwire below instructed, so an erasure stops compiling again on both +// runs. `any` is refused (case 2). +const _specJoinedReportBlockTyping = null as unknown as SpecTyping satisfies 'typed'; const _specJoinedReportBlockIsNotEvenAny = false satisfies IsAny; /** - * A divergence probe, asked of a TYPED spec only; on the erased spec the answer - * is `true` by construction. The licence for that is the `erased` arm above, - * which the test-time tripwire retires at the bump. + * A divergence probe, asked of a TYPED spec only; on an erased spec the answer + * would be `true` by construction. That licence was the `erased` arm of the + * state pin, deleted at the 17.5.0 bump (objectui#11073), so today every probe + * below is asked. */ type OnTypedSpec = SpecTyping extends 'typed' ? Probe : true; /** Keys a type DECLARES: the local `[k: string]: unknown` is not a member. */ @@ -924,23 +927,17 @@ describe('JoinedReportBlock burn-down tripwire, read off the INSTALLED spec (obj expect(installedSpecTyping('ReportSort').typing).toBe('typed'); }); - it('the installed spec still erases the block to `unknown`', () => { + // FLIPPED at the bump (objectui#11073). This row read "the installed spec still + // erases the block" and fired when `@objectstack/spec` 17.5.0 was installed, which + // types it; its companion version row ("still the published 17.4.0") retired with + // it, having done its one job. The state pin's `erased` arm is deleted above. The + // burn-down it announced — deriving the PUBLISHED `JoinedReportBlock` from the + // spec, a published-type change — is objectui#10940's own slice, not this bump's. + it('the installed spec types the block (the erasure is gone; an erasure is an upstream regression)', () => { expect( installed.typing, - `the installed @objectstack/spec ${installed.version} now types JoinedReportBlock — this is the ` + - 'bump the burn-down is owed at. Re-run the triage against the divergence pins above, derive ' + - "the published interface from the spec in its own slice (a published type changes), and delete " + - "the state pin's `erased` arm so that an erasure stops compiling again", - ).toBe('erased'); - }); - - it('the installed spec is still the published 17.4.0, the newest release before objectstack#20369', () => { - expect( - installed.version, - `a newer @objectstack/spec is installed (block typing: ${installed.typing}). Every release after ` + - '17.4.0 is cut from an objectstack `main` that carries #20369, so it should type the block: if ' + - 'the row above is red too, burn down as it says; if it is green, the spec erased the block again ' + - "— report that upstream. Either way the state pin's `erased` arm has lost its licence", - ).toBe('17.4.0'); + `the installed @objectstack/spec ${installed.version} erases JoinedReportBlock again — an ` + + 'upstream regression to report; the state pin above refuses it at compile time too', + ).toBe('typed'); }); }); diff --git a/packages/types/src/__tests__/spec-object-refinements-7715.test.ts b/packages/types/src/__tests__/spec-object-refinements-7715.test.ts index 9bcd142d94..be5bad7967 100644 --- a/packages/types/src/__tests__/spec-object-refinements-7715.test.ts +++ b/packages/types/src/__tests__/spec-object-refinements-7715.test.ts @@ -30,7 +30,6 @@ * rather than leaving a pin that compares two acceptances. */ import { describe, it, expect } from 'vitest'; -import { z } from 'zod'; // ⛔ Schemas only. No spec `check*` function is imported by name in this file; // they are read at RUN time by `specUiChecks()` below, and why is written there. import { @@ -43,7 +42,7 @@ import { PageSchema as SpecPageSchema, } from '@objectstack/spec/ui'; import { ListViewSchema, PageNodeSchema, safeValidateSchema } from '../zod/index.zod'; -import { GlobalFilterSchema } from '../zod/complex.zod'; +import { DashboardWidgetSchema, GlobalFilterSchema } from '../zod/complex.zod'; /** The spec-shaped view: the spec requires `columns`, objectui does not. */ const specView = (extra: Record) => ({ columns: ['name'], ...extra }); @@ -128,16 +127,26 @@ describe('objectui#7715 — the Page source-completeness refusal reaches objectu const SITES = [ { site: 'NavigationAreaSchema (app.zod.ts)', spec: SpecNavigationAreaSchema, attached: [], notAttachable: [] }, { site: 'SpecAppFields → AppComponentSchema (app.zod.ts)', spec: SpecAppSchema, attached: [], notAttachable: [] }, - { site: 'DashboardWidgetSchema (complex.zod.ts)', spec: SpecDashboardWidgetSchema, attached: [], notAttachable: [] }, + { + site: 'DashboardWidgetSchema (complex.zod.ts)', + spec: SpecDashboardWidgetSchema, + // Both added to the spec object at 17.5.0 and attached by objectui#11073: each reads only + // `type`, `options.stageOrder` / `values` and `id`, the spec's own fields on this node. + attached: ['checkDashboardWidgetStageOrder', 'checkDashboardWidgetMetricMeasureArity'], + notAttachable: [], + }, { site: 'SpecDashboardFields → DashboardComponentSchema (complex.zod.ts)', spec: SpecDashboardSchema, attached: [], notAttachable: [] }, { site: 'SpecPageFields → PageNodeSchema (layout.zod.ts)', spec: SpecPageSchema, attached: ['checkPageSourceCompleteness'], notAttachable: [] }, { site: 'ListViewSchema (objectql.zod.ts)', spec: SpecListViewSchema, attached: ['checkListViewCalendarVisualization'], - // Reads `type`, which on this node is the component discriminator — see the - // measurement below. - notAttachable: ['checkListViewPageMount'], + // `checkListViewPageMount` stood here (it read `type`, which on this node is + // the component discriminator, so it was measured not attachable). The spec + // stopped exporting it at 17.5.0, when it retired `pageName` and the `page` + // list-view type; the row and its measurement left together, as the + // measurement's own message instructed (objectui#11073). + notAttachable: [], }, ] as const; @@ -184,10 +193,6 @@ async function specUiChecks(): Promise> { const objectLevelChecks = (schema: unknown): number => ((schema as { _zod: { def: { checks?: unknown[] } } })._zod.def.checks ?? []).length; -/** Run one exported check directly, outside any schema. */ -const runCheck = (check: unknown, value: unknown) => - z.any().superRefine(check as (v: unknown, ctx: z.RefinementCtx) => void).safeParse(value); - describe('objectui#7715 — census: the spec\'s object-level checks at the six derivation sites', () => { it.each(SITES.map((s) => [s.site, s] as const))('%s accounts for every object-level check the spec object carries', (_site, s) => { expect({ @@ -206,22 +211,6 @@ describe('objectui#7715 — census: the spec\'s object-level checks at the six d for (const [name, binding] of checks) expect(typeof binding, name).toBe('function'); }); - it('`checkListViewPageMount` is not attachable: it reads `type`, which the list-view node spends on its discriminator', async () => { - const checkListViewPageMount = (await specUiChecks()).get('checkListViewPageMount'); - expect( - typeof checkListViewPageMount, - 'the spec no longer exports `checkListViewPageMount` — remove it from the ListView row\'s ' + - '`notAttachable` list, and this measurement with it', - ).toBe('function'); - // The same page mount, spelled once per face. The spec accepts its own. - expect(SpecListViewSchema.safeParse({ type: 'page', pageName: 'home_page', columns: [] }).success).toBe(true); - // Attached as-is, the check would refuse objectui's spelling of it, at - // `pageName` — the verdict a mirror must never add. - const asIs = runCheck(checkListViewPageMount, { type: 'list-view', viewType: 'page', pageName: 'home_page', columns: [] }); - expect(asIs.success).toBe(false); - expect(asIs.error!.issues.map((i) => i.path.join('.'))).toEqual(['pageName']); - }); - it('`checkGlobalFilterDateDefaultValue` already runs on objectui\'s GlobalFilterSchema, with the spec\'s own issue', () => { const bad = { field: 'created_at', type: 'date', defaultValue: 'last_7_dayz' }; const spec = SpecGlobalFilterSchema.safeParse(bad); @@ -237,3 +226,28 @@ describe('objectui#7715 — census: the spec\'s object-level checks at the six d expect(GlobalFilterSchema.safeParse(good).success).toBe(true); }); }); + +describe('objectui#11073 — the DashboardWidget checks reach objectui\'s door with the spec\'s own issue (objectui#9111\'s criterion)', () => { + // The spec requires `id` and `values`; without them its own parse aborts before the + // object-level check runs, and the comparison would have no spec issue to read. + const widget = (extra: Record) => ({ id: 'stages', dataset: 'pipeline', values: ['amount'], ...extra }); + + it('a non-funnel widget carrying `options.stageOrder` is refused at `options.stageOrder`, as the spec refuses it', () => { + const doc = widget({ type: 'horizontal-bar', options: { stageOrder: ['a', 'b'] } }); + const spec = SpecDashboardWidgetSchema.safeParse(doc); + expect(spec.success).toBe(false); + const specIssue = spec.error!.issues.find((i) => i.path.join('.') === 'options.stageOrder')!; + expect(specIssue.code).toBe('custom'); + const mirror = DashboardWidgetSchema.safeParse(doc); + expect(mirror.success).toBe(false); + expect(mirror.error!.issues.map((i) => ({ code: i.code, path: i.path.join('.'), message: i.message }))) + .toContainEqual({ code: 'custom', path: 'options.stageOrder', message: specIssue.message }); + }); + + it('LIT CONTROL: the same widget as a `funnel` parses on both', () => { + const doc = widget({ type: 'funnel', options: { stageOrder: ['a', 'b'] } }); + expect(SpecDashboardWidgetSchema.safeParse(doc).success).toBe(true); + expect(DashboardWidgetSchema.safeParse(doc).success).toBe(true); + }); +}); + diff --git a/packages/types/src/__tests__/terminal-unknown-key-refusal-11073.test.ts b/packages/types/src/__tests__/terminal-unknown-key-refusal-11073.test.ts new file mode 100644 index 0000000000..9b8ae2ebb4 --- /dev/null +++ b/packages/types/src/__tests__/terminal-unknown-key-refusal-11073.test.ts @@ -0,0 +1,218 @@ +/** + * 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#11073 — the terminal unknown-key refusal where a strict object meets + * a PLAIN union (the seat's Q3 ruling: restore the objectui#9256 / #11022 + * behaviour with the spec's own mechanism, and measure the blast radius FIRST). + * + * `zod/node-derivation.ts` (`closeStrictUnionArms`) carries the why. This file + * re-derives, on every run, the two facts that make it safe to ship: + * + * 1. THE CENSUS, on both faces — the node face (`@object-ui/types/zod`) and the + * strict authoring face (`StrictAnyComponentSchema`): every plain union + * objectui owns has NO strict object arm left open. A union added later + * with a strict arm and no `closeStrictUnionArms` turns this red by name. + * 2. THE VERDICT DIFFERENTIAL — each closed union against its OPEN twin (the + * same arms rebuilt as ordinary `ZodObject`s from the same def), over a + * corpus derived from the arms themselves. The ruling's stop condition was + * "if any accept set moves, stop and report the list": the list is pinned + * EMPTY here, while the error SHAPE is pinned to move at least once — the + * lit control that the mechanism is doing something at all. + * + * Measured when this landed (node face): 29 plain objectui unions with a strict + * arm, 1106 probes, 0 accept-set moves, 187 error-shape moves. The counts are + * floors below, not equalities: a union gained is not a regression, and the + * zero is the assertion that matters. + */ + +import { describe, it, expect } from 'vitest'; +import { z } from 'zod'; +// The strict authoring face is read through this same barrel +// (`NodeFace.StrictAnyComponentSchema`), never from `strict-authoring-face` +// itself: objectui#8345 pins the barrel as the only entry into that module's +// cycle, test files included. +import * as NodeFace from '../zod/index.zod'; + +type Z = z.ZodType & { _zod: { def: Record; constr?: { name?: string } } }; + +const isZ = (v: unknown): v is Z => + !!v && (typeof v === 'object' || typeof v === 'function') && !!(v as Z)._zod?.def; + +/** Every schema node directly under `s` — options, shape members, wrappers, lazy targets. */ +function children(s: Z): Z[] { + const def = s._zod.def; + const out: Z[] = []; + for (const [key, value] of Object.entries(def)) { + if (key === 'getter') continue; + if (isZ(value)) out.push(value); + else if (Array.isArray(value)) for (const x of value) { if (isZ(x)) out.push(x); } + else if (key === 'shape' && value && typeof value === 'object') for (const x of Object.values(value)) if (isZ(x)) out.push(x); + } + if (def.type === 'lazy') { + try { out.push(def.getter()); } catch { /* an unresolvable getter reaches nothing */ } + } + return out; +} + +function reach(roots: unknown[]): Set { + const seen = new Set(); + const stack = roots.filter(isZ); + while (stack.length) { + const s = stack.pop()!; + if (seen.has(s)) continue; + seen.add(s); + stack.push(...children(s)); + } + return seen; +} + +const WRAPPERS = ['optional', 'nullable', 'default', 'prefault', 'readonly', 'nonoptional']; +const unwrap = (s: Z): Z => { + let x = s; + for (let i = 0; i < 10; i++) { + const t = x._zod.def.type; + if (WRAPPERS.includes(t)) x = x._zod.def.innerType; + else if (t === 'lazy') { try { x = x._zod.def.getter(); } catch { break; } } + else break; + } + return x; +}; +const isStrict = (o: Z): boolean => o._zod.def.type === 'object' && o._zod.def.catchall?._zod?.def?.type === 'never'; +const isClosed = (o: Z): boolean => (o._zod.constr?.name ?? '') === 'ZodClosedObject'; + +/** Nodes the spec owns — its unions are the spec's to close, not objectui's. */ +const SPEC_MODULES = [ + '@objectstack/spec', '@objectstack/spec/ui', '@objectstack/spec/data', '@objectstack/spec/automation', + '@objectstack/spec/shared', '@objectstack/spec/api', '@objectstack/spec/system', '@objectstack/spec/ai', + '@objectstack/spec/security', '@objectstack/spec/identity', +]; +async function specOwnedNodes(): Promise> { + const roots: unknown[] = []; + for (const m of SPEC_MODULES) { + const mod = (await import(/* @vite-ignore */ m)) as Record; + roots.push(...Object.values(mod).filter(isZ)); + } + return reach(roots); +} + +/** A representative value for `s`, shallow — enough to steer a union to one arm. */ +function sample(s: Z, depth = 0): unknown { + if (!isZ(s) || depth > 3) return 'x'; + const d = s._zod.def; + switch (d.type) { + case 'string': return 'x'; + case 'number': case 'int': case 'bigint': return 1; + case 'boolean': return true; + case 'literal': return (d.values ?? [])[0]; + case 'enum': return Object.values(d.entries ?? {})[0]; + case 'array': return [sample(d.element, depth + 1)]; + case 'object': { + const o: Record = {}; + for (const [k, v] of Object.entries(d.shape ?? {})) { + const u = unwrap(v as Z); + if (u._zod.def.type === 'never') continue; + o[k] = sample(u, depth + 1); + } + return o; + } + case 'union': return sample(d.options[0], depth + 1); + case 'lazy': try { return sample(d.getter(), depth + 1); } catch { return {}; } + case 'record': return {}; + case 'pipe': return sample(d.in, depth); + default: + return WRAPPERS.includes(d.type) ? sample(d.innerType, depth) : 'x'; + } +} + +interface FaceReading { + plainUnionsWithStrictArm: number; + openStrictArms: string[]; + probes: number; + acceptMoves: string[]; + errorShapeMoves: number; +} + +/** The census and the differential, over every plain union `nodes` reaches that the spec does not own. */ +function readFace(nodes: Set, specOwned: Set): FaceReading { + const reading: FaceReading = { plainUnionsWithStrictArm: 0, openStrictArms: [], probes: 0, acceptMoves: [], errorShapeMoves: 0 }; + let n = 0; + for (const u of nodes) { + const d = u._zod.def; + if (d.type !== 'union' || d.discriminator !== undefined || specOwned.has(u)) continue; + const arms = (d.options as Z[]).map(unwrap); + if (!arms.some(isStrict)) continue; + n += 1; + reading.plainUnionsWithStrictArm += 1; + arms.forEach((arm, i) => { + if (isStrict(arm) && !isClosed(arm)) reading.openStrictArms.push(`union #${n} arm ${i}: ${Object.keys(arm._zod.def.shape).join(',')}`); + }); + + // The OPEN twin: every closed arm rebuilt as an ordinary ZodObject from the same def. + const twin = z.union((d.options as Z[]).map((o) => (isStrict(o) && isClosed(o) ? new (z.ZodObject as any)({ ...o._zod.def }) : o)) as any); + + const corpus: unknown[] = [undefined, null, '', 'x', 0, 1, true, [], {}, { zzUnknown: 1 }]; + const samples = arms.filter((o) => o._zod.def.type === 'object').map((o) => sample(o) as Record); + for (const s of samples) { + corpus.push(s, { ...s, zzUnknown: 1 }); + for (const k of Object.keys(s)) { + corpus.push({ [k]: s[k] }, { [k]: s[k], zzUnknown: 1 }); + const minus = { ...s }; + delete minus[k]; + corpus.push(minus); + } + } + for (let i = 0; i < samples.length; i++) for (let j = i + 1; j < samples.length; j++) corpus.push({ ...samples[i], ...samples[j] }); + for (const o of arms) if (o._zod.def.type !== 'object') corpus.push(sample(o)); + + for (const input of corpus) { + reading.probes += 1; + const closed = u.safeParse(input); + const open = twin.safeParse(input); + if (closed.success !== open.success) { + reading.acceptMoves.push(`union #${n}: ${JSON.stringify(input)?.slice(0, 120)} closed=${closed.success} open=${open.success}`); + } else if (!closed.success && !open.success) { + const shape = (r: typeof closed) => JSON.stringify((r as any).error.issues.map((i: any) => [i.code, i.path])); + if (shape(closed) !== shape(open)) reading.errorShapeMoves += 1; + } + } + } + return reading; +} + +describe('objectui#11073 — terminal unknown-key refusal on plain unions: census and blast radius', () => { + it('node face: no strict arm of a plain objectui union is left open, and no accept set moves', async () => { + const specOwned = await specOwnedNodes(); + const reading = readFace(reach(Object.values(NodeFace)), specOwned); + // Non-vacuity: the census found the population the ruling is about. + expect(reading.plainUnionsWithStrictArm).toBeGreaterThanOrEqual(20); + expect(reading.probes).toBeGreaterThan(500); + // 1. The census. + expect(reading.openStrictArms, 'a plain union with an OPEN strict arm — wrap its options in closeStrictUnionArms').toEqual([]); + // 2. The ruling's stop condition, pinned empty. + expect(reading.acceptMoves, 'the terminal refusal moved an ACCEPT set — the Q3 stop condition').toEqual([]); + // …and the lit control: the mechanism changes the error shape somewhere. + expect(reading.errorShapeMoves).toBeGreaterThan(0); + }); + + it('strict authoring face: the same census and the same differential', async () => { + const specOwned = await specOwnedNodes(); + const reading = readFace(reach([NodeFace.StrictAnyComponentSchema]), specOwned); + expect(reading.plainUnionsWithStrictArm).toBeGreaterThan(0); + expect(reading.openStrictArms, 'a plain union with an OPEN strict arm on the strict face').toEqual([]); + expect(reading.acceptMoves, 'the terminal refusal moved an ACCEPT set on the strict face').toEqual([]); + }); + + it('the instruments discriminate: an open twin IS reported open, and a closed arm is not', () => { + const strictArm = z.object({ a: z.string() }).strict() as unknown as Z; + expect(isStrict(strictArm)).toBe(true); + expect(isClosed(strictArm)).toBe(false); + const reading = readFace(reach([z.union([strictArm as any, z.string()])]), new Set()); + expect(reading.openStrictArms).toHaveLength(1); + }); +}); diff --git a/packages/types/src/__tests__/zod-mirror-parity.test.ts b/packages/types/src/__tests__/zod-mirror-parity.test.ts index 33c331cf75..746abdcd2f 100644 --- a/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -477,8 +477,14 @@ * spelled "six" rots exactly as fast as one spelled `6`, it is just harder to * point a regex at. ⛔ Do not spell a live figure out again, and ⛔ do not * restate one without checking that the pin's spelling still reaches it. - * - **6 entries** in `WiderThanDeclared`, **7 keys** across them, and **7 arms** - * under those keys — split **5** SCHEMA-NODE, **2** CONCRETE, **0** MIXED, **0** unions. + * - **3 entries** in `WiderThanDeclared`, **3 keys** across them, and **3 arms** + * under those keys — split **2** SCHEMA-NODE, **1** CONCRETE, **0** MIXED, **0** unions. + * It read 6 / 7 / 7 — 5 / 2 / 0 / 0 — until objectui#11073 bumped the installed + * `@objectstack/spec` to 17.5.0, whose `visibleWhen` envelope requires `source`: the + * spec-version-gated entries `views.zod.ts#DetailViewFieldSchema` (`options`, one CONCRETE + * arm), `views.zod.ts#DetailViewSchema` (`fields`, `sections`) and + * `views.zod.ts#DetailViewSectionSchema` (`fields`), three SCHEMA-NODE arms between them, + * LEFT together with their gate. * It read 7 / 9 / 9 — 5 / 4 / 0 / 0 — until objectui#7759 group A settled * `complex.zod.ts#DashboardComponentSchema`'s `header` and `globalFilters`: both keys * are spec-declared, so both faces now state the spec's member, and the entry, its @@ -3339,19 +3345,16 @@ interface WiderThanDeclared { // The renderer places `schema.content` RAW in a React child position, so the list arm parsed // green and then failed to render. objectui#10280 NARROWED the MIRROR to the declaration, and // the entry is GONE. - /** - * EXPECTED DIVERGENCE, read model (objectui#10296, ruling F1): the mirror is the - * spec's authoring `SelectOptionSchema`, whose `visibleWhen` envelope admits an - * object with no `source`, which the read model's wire refuses. Spec-version-gated - * by `SpecEnvelopeAdmitsSourceless`. See the `KnownDrift` - * entry for this pair. (The inline option shape that stood here, admitting a - * number or boolean `value`, is gone.) - */ - 'views.zod.ts#DetailViewFieldSchema': 'options'; - /** SCHEMA-NODE. (`tabs` left under objectui#7760; `fields` and `sections` did not.) */ - 'views.zod.ts#DetailViewSchema': 'fields' | 'sections'; - /** SCHEMA-NODE. */ - 'views.zod.ts#DetailViewSectionSchema': 'fields'; + // `views.zod.ts#DetailViewFieldSchema` recorded `options` here (CONCRETE; objectui#10296, + // ruling F1), and its two containers `views.zod.ts#DetailViewSchema` (`fields`, `sections`) + // and `views.zod.ts#DetailViewSectionSchema` (`fields`) SCHEMA-NODE: the mirror is the spec's + // authoring `SelectOptionSchema`, whose `visibleWhen` envelope admitted an object with no + // `source` through `@objectstack/spec` 17.4.0, which the read model's wire refuses. The + // spec-version gate `SpecEnvelopeAdmitsSourceless` held the three entries on 17.4.0 and + // withheld them on objectstack `main`. `@objectstack/spec` 17.5.0 requires a non-blank + // `source`, so the mirror is no longer wider, and the entries, their `WIDER_ARMS` rows and + // the gate LEFT together at that pin bump, as the gate's docblock instructed + // (objectui#11073). The `KnownDrift` entry for the pair (`dialect` required) holds on both. } /** @@ -3467,10 +3470,6 @@ const WIDER_ARMS: Readonly< Record< string, readonly WiderArmClass[] > > = { 'app.zod.ts#AppComponentSchema::areas': ['SCHEMA-NODE'], 'form.zod.ts#FormSchema::layout': ['CONCRETE'], 'layout.zod.ts#PageNodeSchema::slots': ['SCHEMA-NODE'], - 'views.zod.ts#DetailViewFieldSchema::options': ['CONCRETE'], - 'views.zod.ts#DetailViewSchema::fields': ['SCHEMA-NODE'], - 'views.zod.ts#DetailViewSchema::sections': ['SCHEMA-NODE'], - 'views.zod.ts#DetailViewSectionSchema::fields': ['SCHEMA-NODE'], }; /* ── The invariant ──────────────────────────────────────────────────────────── */ @@ -3607,57 +3606,18 @@ export const assertionUnmirroredMatchesLedger: never = 0 as unknown as { }[MirrorKey]; /** - * SPEC-VERSION GATE for the `options` row and its two containers (objectui#10296): does the installed spec's - * `visibleWhen` input envelope admit an object with NO `source`? - * - * `views.zod.ts#DetailViewFieldSchema::options` reads the spec's `SelectOptionSchema` - * by reference, so its WIDER verdict follows the spec that is installed. On the - * pinned `@objectstack/spec` 17.4.0 the envelope's `source` is optional in the static - * input type (at runtime `ast` alone satisfies its refine), so an envelope with no - * `source` is admitted and the declaration (whose wire requires `source`) refuses it: - * WIDER, recorded. On objectstack `main` the key is - * `EvaluatedExpressionInputSchema`, whose envelope REQUIRES `source`, so the mirror is - * no longer wider there (the `KnownDrift` half, `dialect` required, holds on both). - * The Spec Main Shape Gate compiles this file against `main`, so the ledger must - * hold on both. - * - * Read off the MIRROR's own input face, not an import of the spec, so it measures - * exactly the schema the row is about. The ledger entry stays a literal (the census - * parses it). Only the reconciliation consults this, and only for the rows in - * `SpecEnvelopeGatedWider` below: on 17.4.0 they must be measured WIDER exactly as - * before, and on `main` they must measure absent, so neither side is waived. ⛔ At - * the pin bump this reads `false` for good. Then delete those rows, their - * `WIDER_ARMS` entries and this gate together. - * The runtime tripwire in `detail-view-field-options-10296.test.ts` goes red at - * that bump to say so. + * What `WiderThanDeclared` records for a pair — `never` for a pair with no entry. + * + * It applied a SPEC-VERSION GATE until `@objectstack/spec` 17.5.0 + * (`SpecEnvelopeAdmitsSourceless`, objectui#10296): on 17.4.0 the `visibleWhen` + * envelope of `views.zod.ts#DetailViewFieldSchema::options` admitted an object with no + * `source`, so that row and its two containers had to measure WIDER there and absent on + * objectstack `main`. 17.5.0 requires `source`, the gate read `false` for good, and the + * rows and the gate were deleted together at that pin bump, as the gate's docblock + * instructed (objectui#11073). */ -type DetailViewOptionVisibleWhenInput = NonNullable< - NonNullable< MirrorInputOf< 'views.zod.ts#DetailViewFieldSchema', 'options' > >[number]['visibleWhen'] ->; -export type SpecEnvelopeAdmitsSourceless = - { dialect: 'cel' } extends DetailViewOptionVisibleWhenInput ? true : false; - -/** - * The rows that gate withholds on a spec whose envelope requires `source`. Measured - * against objectstack `main` `e8f163fc3a62`, not assumed: the two container pairs - * carry `DetailViewFieldSchema` elements, and there their WIDER reading is gone too. - * Their only wider member was `options` (on the base commit, the inline shape's - * number/boolean `value`; on 17.4.0, the source-less envelope). They are ledgered - * SCHEMA-NODE, but that class names the arm, not the cause, so they leave with it. - */ -type SpecEnvelopeGatedWider = { - 'views.zod.ts#DetailViewFieldSchema': 'options'; - 'views.zod.ts#DetailViewSchema': 'fields' | 'sections'; - 'views.zod.ts#DetailViewSectionSchema': 'fields'; -}; - -/** What `WiderThanDeclared` records for a pair, with the spec-version gate above applied. */ export type WiderRecorded< K extends MirrorKey > = - K extends keyof WiderThanDeclared - ? SpecEnvelopeAdmitsSourceless extends true - ? WiderThanDeclared[K] - : Exclude< WiderThanDeclared[K], K extends keyof SpecEnvelopeGatedWider ? SpecEnvelopeGatedWider[K] : never > - : never; + K extends keyof WiderThanDeclared ? WiderThanDeclared[K] : never; /** * The THIRD direction: every pair's WIDER key set equals what `WiderThanDeclared` diff --git a/packages/types/src/field-types.ts b/packages/types/src/field-types.ts index 889b28bcc7..0e11d65bea 100644 --- a/packages/types/src/field-types.ts +++ b/packages/types/src/field-types.ts @@ -25,7 +25,7 @@ import type { Field as SpecField } from '@objectstack/spec/data'; * sibling field names, or `{ field, param }` entries mapping a sibling onto the * remote query parameter a dependent lookup filters by. * - * Measured on the installed `@objectstack/spec` (17.4.0), `FieldSchema` declares + * Measured on the installed `@objectstack/spec` (17.5.0), `FieldSchema` declares * `dependsOn` as an OPTIONAL ARRAY of `string | { field, param? }` — never a * bare string. That is deliberately narrower than `DependsOnInput` (`form.ts`), * the shape the widget prop `FieldWidgetComponentProps.dependsOn` and @@ -259,7 +259,7 @@ export interface MarkdownFieldMetadata extends BaseFieldMetadata { * annotated literal rejected. Follows the `TextareaFieldMetadata` precedent. * * A DECLARED spec key as of `@objectstack/spec` 17.3.0, which implements that - * same ruling. Measured on the installed `@objectstack/spec` 17.4.0: + * same ruling. Measured on the installed `@objectstack/spec` 17.5.0: * `FieldSchema` ACCEPTS `rows` on all four of textarea/markdown/html/ * richtext, as an integer of at least 1 — a non-integer answers * `invalid_type` and 0 answers `too_small`, so "declared" does not mean "any @@ -549,7 +549,7 @@ export interface SelectOptionMetadata extends SelectOptionBase { * * A DECLARED `SelectOptionSchema` key as of `@objectstack/spec` 17.3.0, * which implements the objectui#6153 half of that ruling. Measured on the - * installed `@objectstack/spec` 17.4.0: an option carrying `description` + * installed `@objectstack/spec` 17.5.0: an option carrying `description` * is ACCEPTED, as a string (a non-string answers `invalid_type`; the empty * string is valid), and a field whose `options` carry it parses whole. ⇒ it * may now be written into authored object metadata, which is the point of diff --git a/packages/types/src/objectql.ts b/packages/types/src/objectql.ts index 3e8859b37d..94e89c9392 100644 --- a/packages/types/src/objectql.ts +++ b/packages/types/src/objectql.ts @@ -2839,35 +2839,31 @@ export interface NamedListView { appearance?: ListViewSchema['appearance']; /** - * Tab definitions for a multi-tab view interface (`ViewTabSchema[]`). + * @deprecated RETIRED (objectui#11073) — the protocol removed the list view's + * own `tabs` in `@objectstack/spec` 17.5.0 (ADR-0049 enforce-or-remove): no + * renderer ever mounted a tab bar for it. Move each tab to a named view under + * the object's `listViews` (the tab's `name` becomes the entry's key, its + * `label` the entry's `label`, its `filter` joins that entry's `filter`); + * every `listViews` entry renders as a tab in the saved-view switcher. * - * ⭐ RULING ITEM 3 — MEASURED BEFORE DECLARED, because the protocol spends the - * word `tabs` on two different keys. This is the TOP-LEVEL one on the list - * shape, and it survives `ObjectListViewSchema` untouched. The other is - * `userFilters.tabs`, which that schema OMITS as page-only - * (`ObjectUserFiltersSchema`: "an object view's tab bar is its saved-view - * switcher (ViewTabBar), and a second one would collide"). So the key declared - * here is the array of `ViewTabSchema`, ⛔ not the user-filter preset bar — - * objectui's own `userFilters` dialect keeps carrying that one separately. - * - * ⚠️ NO RENDERER BEHAVIOUR ON THIS SURFACE, measured and REPORTED on - * objectui#8980 rather than silently declared inert: objectui's tab bar for an - * object is the saved-view switcher the HOST owns (ADR-0053), and the only - * `tabs` read in `packages/plugin-list` is `UserFilters`' `config.tabs` — the - * page-only key, not this one. + * Declared until then as a protocol member objectui read nowhere (objectui#8980, + * "declared inert"); the seat's Q4 ruling on objectui#11073 retired it here + * once its premise, "the protocol declares it", was falsified. */ - tabs?: ListViewSchema['tabs']; + tabs?: never; /** - * Name of the published `page` a `type: 'page'` view mounts. + * @deprecated RETIRED (objectui#11073) — the protocol removed `pageName` with + * `type: 'page'` in `@objectstack/spec` 17.5.0 (ADR-0049 enforce-or-remove): + * the page mount was never built. To put a published page in front of users, + * give the app a navigation item with `type: 'page'` and its `pageName` — a + * different key on a different surface. * - * ⚠️ NO RENDERER BEHAVIOUR ON THIS SURFACE, measured and REPORTED on - * objectui#8980: `page` is not a member of this interface's own `type` union - * (seven values; the protocol's is ten), so no authored named view can select - * the branch this key configures. Declared because the protocol declares it - * and the ruling forbids dropping a member to avoid the report. + * Declared until then as a protocol member objectui read nowhere (objectui#8980, + * "declared inert"); the seat's Q4 ruling on objectui#11073 retired it here + * once its premise, "the protocol declares it", was falsified. */ - pageName?: ListViewSchema['pageName']; + pageName?: never; /* ── The eight view-KIND configuration blocks ────────────────────────────── * The protocol carries each at the TOP LEVEL of a list view. objectui read @@ -3735,7 +3731,7 @@ export interface ObjectCalendarSchema extends BaseSchema { * (the spec's own `ObjectCalendarPropsSchema`) declares * `z.array(z.unknown()).optional()`, described *"Pre-fetched records — skips * the internal fetch"*. - * MEASURED on the installed artifact at `@objectstack/spec` 17.4.0 — the + * MEASURED on the installed artifact at `@objectstack/spec` 17.5.0 — the * version this repository's `pnpm-lock.yaml` resolves — through the published * `@objectstack/spec/ui` entry point: the provider block returns * `success=false` with `expected: 'array'` at `path: ['data']`, the array diff --git a/packages/types/src/record-components.ts b/packages/types/src/record-components.ts index c5b8ccd4e7..d98c0dd3eb 100644 --- a/packages/types/src/record-components.ts +++ b/packages/types/src/record-components.ts @@ -107,7 +107,7 @@ export interface RecordDetailsComponentProps { * It was `number` here until objectui#8604, which is the wrong PRIMITIVE * TYPE, not merely a wider range: `{ columns: 2 }` compiled locally and the * contract refused it at publish with `invalid_value` at `columns` (measured - * on the installed pin, 17.4.0, against a control — `columns: '2'` — that + * on the installed pin, 17.5.0, against a control — `columns: '2'` — that * parses green on the same instrument). Contract-first (Commandment #0.1): * the code moves to the contract's spelling, and today's `columns: 2` * authors are the defect surfacing rather than collateral damage. @@ -282,7 +282,7 @@ export interface RecordHighlightsComponentProps { * `RecordHighlightsProps.fields[]`'s object arm declares exactly * `name`/`label`/`type`/`readonly` and carries a `never` catchall, i.e. it is * `$strict`: an unlisted key is REFUSED, not stripped, and the refusal takes - * the WHOLE document with it. Measured on the installed pin, 17.4.0, + * the WHOLE document with it. Measured on the installed pin, 17.5.0, * `RecordHighlightsProps.safeParse({ fields: [{ name: 'x', icon: 'star' }] })` * is RED with `invalid_union` at `fields.0`. So `{ name: 'amount', icon: * 'dollar-sign' }` type-checked here and was refused at the door — a green @@ -314,7 +314,7 @@ export interface RecordHighlightsComponentProps { * `z.enum(['horizontal','vertical'])` behind a `.default('horizontal')`). * * It offered a third value, `grid`, until objectui#9187, and the contract - * never accepted it: measured on the installed pin, 17.4.0, + * never accepted it: measured on the installed pin, 17.5.0, * `RecordHighlightsProps.safeParse({ fields: ['name'], layout: 'grid' })` is * RED with `invalid_value` at `layout`. So `{ layout: 'grid' }` type-checked * here and was refused at the door — a green local build and a rejection at diff --git a/packages/types/src/strict-authoring-face.ts b/packages/types/src/strict-authoring-face.ts index 0148925554..950a9eabe6 100644 --- a/packages/types/src/strict-authoring-face.ts +++ b/packages/types/src/strict-authoring-face.ts @@ -109,6 +109,7 @@ import { SchemaNodeSchema } from './zod/base.zod.js'; import { AnyComponentSchema } from './zod/index.zod.js'; import { carryRegistryMeta, + closeStrictUnionArms, cloneWithDef, internals, isZodType, @@ -250,11 +251,17 @@ function createStrictWalker(options: DeriveStrictAuthoringOptions = {}): walk(option, `${path}/options/${i}`)); + // objectui#11073: every object this face closes is a strict object, so on a PLAIN + // union its unknown-key refusal is made terminal, as on the node face, and the union + // answers with every arm rather than with the one arm that failed only on unknown + // keys. A discriminated union routes to one arm and is left as it is. out = cloneWithDef(schema, { - options: (def.options ?? []).map((option, i) => walk(option, `${path}/options/${i}`)), + options: (def as { discriminator?: unknown }).discriminator === undefined ? closeStrictUnionArms(options) : options, }); break; + } case 'array': out = cloneWithDef(schema, { element: walk(def.element!, `${path}/element`) }); break; diff --git a/packages/types/src/ui-action.ts b/packages/types/src/ui-action.ts index 7c70979462..fa930d886c 100644 --- a/packages/types/src/ui-action.ts +++ b/packages/types/src/ui-action.ts @@ -845,8 +845,8 @@ export interface UIActionSchema { * `actions/__tests__/actionKeys.types.test.ts`; this is the read side of that * pair, which had no declaration to land in. * - * ⚠️ Not to be confused with the `disabled` PROP on `ActionButtonProps` / - * `ActionIconProps` (objectui#9131): that one is the host's EVALUATED + * ⚠️ Not to be confused with the `disabled` PROP on `ActionButtonRendererProps` / + * `ActionIconRendererProps` (objectui#9131): that one is the host's EVALUATED * verdict, a `boolean`, and lives on the renderer's props bag. This one is * the author's predicate and lives on the action. */ diff --git a/packages/types/src/views.ts b/packages/types/src/views.ts index 4138bc5cba..26e966d012 100644 --- a/packages/types/src/views.ts +++ b/packages/types/src/views.ts @@ -88,7 +88,7 @@ export type ViewType = NonNullable | 'list' | 'detail'; * restated key list is exactly what let `titleField` through. The `Pick` * interim objectui#8841 offered was conditional on the 17.4.0 bump not having * happened yet — it landed on `main` before this change (`chore(deps): take the - * 17.4.0 @objectstack/* line`), so the pinned spec is already strict and the + * 17.4.0 @objectstack/* line`), so the resolved spec is already strict and the * plain alias is available. * * ## `titleField` is NOT declared here; the reads that survive are tolerance diff --git a/packages/types/src/zod/complex.zod.ts b/packages/types/src/zod/complex.zod.ts index b046f1238e..da1964cf89 100644 --- a/packages/types/src/zod/complex.zod.ts +++ b/packages/types/src/zod/complex.zod.ts @@ -20,6 +20,8 @@ import { z } from 'zod'; import { handlerKeyRefusal, retiredNodeType, retirementTombstone } from './tombstone.zod.js'; import { ChartTypeSchema as SpecChartTypeSchema, + checkDashboardWidgetMetricMeasureArity, + checkDashboardWidgetStageOrder, DashboardSchema as SpecDashboardSchema, DashboardWidgetSchema as SpecDashboardWidgetSchema, GlobalFilterSchema as SpecGlobalFilterSchema, @@ -33,7 +35,7 @@ import { type DashboardComponentWidgetType, } from '../complex.js'; import { stripImportedDefaults } from './imported-defaults.js'; -import { declareRegisteredInputs } from './node-derivation.js'; +import { closeStrictUnionArms, declareRegisteredInputs } from './node-derivation.js'; /** * ⭐ THE IMPORT BOUNDARY (objectui#8317, decision batch #90, 2026-09-08). @@ -1237,7 +1239,22 @@ export const DashboardWidgetSchema = specFieldsExcept(stripImportedDefaults(Spec // refused now. No corpus document, fixture or pin writes one, and the key is // declared "Widget Component (legacy format)" — a node, never a scalar. component: BaseSchema.optional().describe('Widget Component (legacy format)'), -}).strict(); +}).strict() + // ⭐ THE SPEC'S OBJECT-LEVEL CHECKS, re-attached (objectui#7715, ruling B1; objectui#11073). + // + // `specFieldsExcept` rebuilds a fresh object from the spec's `.shape`, so it drops + // every check the spec attached to the OBJECT. `@objectstack/spec` 17.5.0 attaches two + // to `DashboardWidgetSchema` and exports both by name, so the one the spec runs is + // attached here instead of restated. Both are ATTACHABLE: each reads only `type`, + // `options.stageOrder` / `values` and `id`, and on this node those are the spec's own + // fields (`type` adds objectui's closed `list` / `custom` extensions, which the spec's + // checks judge as the non-funnel, non-metric types they are; an absent `type` resolves + // to the spec's default inside the check, as on the spec). This is objectui#9111's + // criterion: a non-funnel widget carrying `options.stageOrder` is refused here as the + // server refuses it. `__tests__/spec-object-refinements-7715.test.ts` re-derives the + // split from the spec object's own check count. + .superRefine(checkDashboardWidgetStageOrder) + .superRefine(checkDashboardWidgetMetricMeasureArity); /** * objectui#9256 (public-block slice): ONE refusal string for both content channels of the @@ -1441,7 +1458,10 @@ export const DashboardComponentSchema = BaseSchema.extend(SpecDashboardFields.sh // the `.strict()` spec-derived schema. Component arm first — it matches // exclusively on the closed component-type enum, so a spec-family widget // can never be captured by it. - widgets: z.array(z.union([DashboardWidgetSlotComponentSchema, DashboardWidgetSchema])) + // objectui#11073: the strict spec-derived arm is closed where it meets this union, so a + // widget the component arm refuses by name is not answered by this arm's unknown keys alone + // (see `closeStrictUnionArms` in `./node-derivation.ts`). + widgets: z.array(z.union(closeStrictUnionArms([DashboardWidgetSlotComponentSchema, DashboardWidgetSchema] as const))) .describe('Dashboard widgets'), globalFilters: z.array(GlobalFilterSchema).optional().describe('Dashboard-level filters'), body: retirementTombstone( diff --git a/packages/types/src/zod/form.zod.ts b/packages/types/src/zod/form.zod.ts index 83258d2768..57e762ee4b 100644 --- a/packages/types/src/zod/form.zod.ts +++ b/packages/types/src/zod/form.zod.ts @@ -27,6 +27,7 @@ import { BaseSchema, SchemaNodeSchema } from './base.zod.js'; // rationale moved with it. import { ExpressionWireSchema } from './expression.zod.js'; import { stripImportedDefaults } from './imported-defaults.js'; +import { closeStrictUnionArms } from './node-derivation.js'; /** * ⭐ THE IMPORT BOUNDARY (objectui#8317, decision batch #90, 2026-09-08). @@ -577,11 +578,13 @@ const CalendarDayRangeSchema = z.strictObject({ // objectui#10304: every selection shape the date picker reads. WHICH one a node // may carry is decided by its `mode`, and `calendarSelectionFitsMode` below // holds that pairing; this union alone is the key-level set. -const CalendarSelectionSchema = z.union([ +// objectui#11073: the strict range arm is closed where it meets this union (see +// `closeStrictUnionArms` in `./node-derivation.ts`). +const CalendarSelectionSchema = z.union(closeStrictUnionArms([ CalendarDaySchema, z.array(CalendarDaySchema), CalendarDayRangeSchema, -]); +] as const)); /** * objectui#10304 — a selection must have the shape its `mode` reads. diff --git a/packages/types/src/zod/imported-defaults.ts b/packages/types/src/zod/imported-defaults.ts index 544dad0fe6..739eb34ae7 100644 --- a/packages/types/src/zod/imported-defaults.ts +++ b/packages/types/src/zod/imported-defaults.ts @@ -136,13 +136,14 @@ const walk = (schema: z.ZodType): z.ZodType => { // `ViewSchema.formViews[…].groups[].fields[]`, walked because the whole // container crosses the boundary, not only the slot. // Each sits inside a schema that carries a default anyway, so the exception - // costs no extra rebuild — with ONE named exception since objectui#10872 - // batch 2: `ElementDataSourceSchema` (the `element:number` arm's - // `dataSource`) reaches the recursive filter clause and carries no default, - // so it is the one clean import this arm rebuilds — an equal-answering - // clone, not the spec's own object. The pin file re-derives the count and - // that named set (`REBUILT_CLEAN`), and goes red if either moves, rather than - // trusting this sentence. + // costs no extra rebuild. objectui#10872 batch 2 had ONE named exception, + // `ElementDataSourceSchema` (the `element:number` arm's `dataSource`), whose + // `filter` reached the recursive filter clause with no default above it; + // `@objectstack/spec` 17.5.0 converged that `filter` on the `ViewFilterRule` + // array, which reaches no `z.lazy`, so it is clean and comes back as the + // spec's own object (objectui#11073). The pin file re-derives the count and + // the named set of clean rebuilt imports (`REBUILT_CLEAN`, empty today), and + // goes red if either moves, rather than trusting this sentence. // // ⛔ Rebuilt through `cloneWithDef`, not `z.lazy(…)`: a fresh `z.lazy` would // be a different class with none of this node's own `def.checks` or registry diff --git a/packages/types/src/zod/node-derivation.ts b/packages/types/src/zod/node-derivation.ts index b3ab08eee0..5724c7a448 100644 --- a/packages/types/src/zod/node-derivation.ts +++ b/packages/types/src/zod/node-derivation.ts @@ -334,3 +334,94 @@ export const declareRegisteredInputs = (schema: T, names: r * none are. Identity-keyed: a clone or a wrapper of the node carries none. */ export const registeredInputsOf = (schema: z.ZodType): readonly string[] | undefined => REGISTERED_INPUTS.get(schema); + +/** + * TERMINAL UNKNOWN-KEY REFUSAL — where a strict object meets a union + * (objectui#11073, the seat's Q3 ruling: 「恢复不变量」, never report a registered + * input as unrecognized). + * + * ## Why this exists + * + * zod 4.6 (which `@objectstack/spec` 17.5.0's `zod ^4.6.1` forces) made a strict + * object's `unrecognized_keys` issue NON-aborting (`continue: true`); zod 4.4 + * left it aborting. A plain `z.union` picks its answer from its arms' results, + * and when exactly one failing arm is non-aborted it returns THAT arm's issues + * alone instead of an `invalid_union` carrying every arm. So a document that + * the matching arm refuses by name, and a strict sibling arm refuses only for + * unknown keys, surfaced as the sibling's `unrecognized_keys` — the by-name + * refusal vanished and a key the matching arm declares was reported as + * unrecognized (the widget-slot `metric-card`, objectui#9256 / objectui#11022). + * + * `@objectstack/spec` 17.5.0 answers the same zod change on its own closed + * objects with `markUnknownKeyRefusalTerminal` (in its `ZodClosedObject` + * constructor, not exported). This is the same mechanism, spelled here because + * the spec does not export it: a `ZodObject` subclass whose parse marks every + * `unrecognized_keys` issue `continue: false`, so a union sees that arm as + * aborted and answers with `invalid_union`, as it did on zod 4.4. + * + * ## Why it cannot move an accept set + * + * It rewrites a flag on an issue that is already there; it adds and removes + * none. A union succeeds only through an arm with zero issues, which this + * cannot create or destroy. What the flag does change is whether a LATER, + * non-`when` refinement on an enclosing schema still runs on a document that + * is already refused — an error-shape effect, never a verdict. + * `../__tests__/terminal-unknown-key-refusal-11073.test.ts` re-derives both + * halves on every run: the census of plain unions with strict arms on both + * faces, and the verdict of each closed union against its open twin. + * + * ## Where it is applied + * + * Only to strict object ARMS of plain (non-discriminated) unions, through + * {@link closeStrictUnionArms}: a discriminated union routes to one arm by its + * discriminator and never chooses among failing arms, so the collapse above + * cannot happen there. The arm is a closed TWIN — the exported object it was + * made from is not touched, so every other use of that object is unchanged. + */ +const markUnknownKeyRefusalTerminal =

}>(payload: P): P => { + for (const issue of payload.issues) { + if (issue.code === 'unrecognized_keys') issue.continue = false; + } + return payload; +}; + +type ParseFn = (payload: { issues: Array<{ code?: string; continue?: boolean }> }, ctx: unknown) => unknown; + +let closedObjectCtor: (new (def: WalkableDef) => z.ZodType) | undefined; + +/** The constructor, built once: a `ZodObject` whose parse marks its unknown-key refusal terminal. */ +const closedObjectConstructor = (): new (def: WalkableDef) => z.ZodType => + (closedObjectCtor ??= z.core.$constructor('ZodClosedObject', (inst: unknown, def: unknown) => { + (z.ZodObject as unknown as { init: (inst: unknown, def: unknown) => void }).init(inst, def); + const zod = (inst as { _zod: { parse: ParseFn } })._zod; + const parse = zod.parse; + zod.parse = (payload, ctx) => { + const done = parse(payload, ctx); + return done instanceof Promise + ? done.then((settled) => markUnknownKeyRefusalTerminal(settled as Parameters[0])) + : markUnknownKeyRefusalTerminal(done as Parameters[0]); + }; + }) as unknown as new (def: WalkableDef) => z.ZodType); + +/** A plain strict object: a `ZodObject` closed with `catchall: z.never()` whose unknown-key refusal is not yet terminal. */ +export const isPlainStrictObject = (schema: z.ZodType): boolean => { + if (!isZodType(schema)) return false; + const def = internals(schema)._zod.def; + if (def.type !== 'object') return false; + if (internals(def.catchall as z.ZodType)?._zod?.def?.type !== 'never') return false; + return !(schema instanceof closedObjectConstructor()); +}; + +/** + * The closed twin of a strict object: the same def (shape, catchall, checks) + * and registry metadata, built by the terminal-refusal constructor above. A + * node that is not a plain strict object is returned unchanged. + */ +export const closedObject = (schema: T): T => + isPlainStrictObject(schema) + ? (carryRegistryMeta(schema, new (closedObjectConstructor())({ ...internals(schema)._zod.def })) as T) + : schema; + +/** Close the strict object arms of a plain union (see {@link closedObject}); every other arm is returned as is. */ +export const closeStrictUnionArms = (options: T): T => + options.map((option) => closedObject(option)) as unknown as T; diff --git a/packages/types/src/zod/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts index 0aee3d7646..8b04de1299 100644 --- a/packages/types/src/zod/objectql.zod.ts +++ b/packages/types/src/zod/objectql.zod.ts @@ -767,24 +767,10 @@ export const ObjectViewSchema = BaseSchema.extend({ // channel, so both are refused by name here as on the TypeScript twin, each kept a MEMBER. body: retirementTombstone(OBJECT_VIEW_NEITHER_CHANNEL), children: retirementTombstone(OBJECT_VIEW_NEITHER_CHANNEL), -}) - // ⭐ objectui#8355 / objectui#10321 — the by-name pointers for the named alias - // refusals on a named view: two sibling checks on this one door (the calendar - // spellings, and the kanban twin's stray `groupBy`). Declared and explained at - // `checkNamedViewCalendarAliases` and `checkNamedViewKanbanStrayGroupBy` below - // (hoisted function declarations, so the forward references resolve at module - // init and the bodies run at parse time, long after the refusal arms they read - // are built). - // - // ⚠️ `when` is load-bearing since objectui#7928. The named view is now the - // protocol's strict record, which refuses both kinds of key itself, and a - // refinement zod runs by default is SKIPPED once any earlier issue aborts the - // parse. So without `when` these checks never run on exactly the documents - // they exist for. Measured on the flip before this line was added. - .superRefine((_value, ctx) => { - checkNamedViewCalendarAliases(ctx); - checkNamedViewKanbanStrayGroupBy(ctx); - }, { when: () => true }); +}); +// objectui#8355 / objectui#10321's named-view pointers were retired here (objectui#11073): +// the protocol's refusal inside a named view is terminal since `@objectstack/spec` 17.5.0, +// so they could no longer run. See the RETIRED note below `CalendarNodeDateAliasRefusals`. /** * User Filters — field-level filter option @@ -1069,9 +1055,10 @@ const KanbanStrayGroupByRefusal = aliasKeyRefusal( * nothing `ListViewSchema` declares reaches it, and it has ONE nesting: the * record refuses a named view's `options` bag whole (`unrecognized_keys` naming * `options`), and `generateViewSchema` no longer reads that bag. The `kanban` - * block takes the SAME guidance through the named-view door (`custom` at - * `listViews.KEY.kanban.groupBy`, objectui#10321, beside the protocol's own - * `unrecognized_keys`) — see `checkNamedViewKanbanStrayGroupBy` below. + * block there is refused by the protocol alone (`unrecognized_keys` at + * `listViews.KEY.kanban`, naming `groupBy`): objectui#10321's pointer on that + * door was retired by objectui#11073, because the protocol's refusal is terminal + * since `@objectstack/spec` 17.5.0 and the pointer could no longer run. * * ⚠️ Covering the legacy nesting is not optional politeness: the retired * producer (`app-shell`'s `kanbanViewOptions`, objectui#8213) wrote into @@ -1118,8 +1105,10 @@ const KanbanConfig = stripImportedDefaults(SpecKanbanConfigSchema).partial().ext * ⚠️ THE CANONICAL TARGET IS objectui's, AND UPSTREAM ANSWERS DIFFERENTLY — but * ⛔ NOT BECAUSE IT HOLDS A CONTRARY ALIAS ENTRY. This paragraph said "upstream's * alias table points this spelling at the END of the event" and that was WRONG - * about the protocol; the corrected mechanism, re-derived by RUNNING the - * installed pin (`@objectstack/spec` 17.4.0) rather than reading it: + * about the protocol; the corrected mechanism, re-derived by RUNNING + * `@objectstack/spec` 17.4.0 (the version in the lockfile then) rather than + * reading it — and RE-RUN on the installed 17.5.0 (objectui#11073), whose + * answer moved, as the last bullet says: * * - `CalendarConfigSchema`'s `strictObject` options carry `surface` and * `history` and NOTHING ELSE. There is no `aliases` entry, so upstream holds @@ -1136,22 +1125,31 @@ const KanbanConfig = stripImportedDefaults(SpecKanbanConfigSchema).partial().ext * hint — and neither does a nonsense control key. ⭐ Positive control that * the suggester is alive and correct for what it is for: a genuine one-char * typo of the canonical key resolves to `startDateField`. - * - * ⇒ a generic typo-distance suggester picked the wrong sibling. It is not a - * declaration, it contradicts no declaration, and ⛔ no upstream text says - * `dateField` means the end of an event. - * - * The author-facing hazard is real all the same: an author who copies that hint - * writes `endDateField` and binds the END of an event to the date they meant as - * the START — accepted by every layer, wrong on screen. Every objectui read site + * - ON 17.5.0 THAT HINT IS GONE. The formatter now asks an opposite-pole + * question before the suggester: `dateField` sits between + * `startDateField` and `endDateField`, so the refusal names BOTH ("does + * not say which end of the range it binds … Write the one you mean") and + * prescribes neither. `endField` still draws no hint, and a one-char typo + * still resolves by distance (`titleFeld` → `titleField`) — all three + * measured on the installed 17.5.0. + * + * ⇒ through 17.4.0 a generic typo-distance suggester picked the wrong sibling. + * It was not a declaration, it contradicted no declaration, and ⛔ no upstream + * text says `dateField` means the end of an event. + * + * The author-facing hazard was real all the same: an author who copied that + * hint wrote `endDateField` and bound the END of an event to the date they + * meant as the START — accepted by every layer, wrong on screen. Every objectui read site * folds this spelling onto the START: the ladder this card retires did, * `normalizeListViewSchema`'s `timeline` fold does, `resolveTimelineDateBinding` * documents it as "the pre-#2231 alias for `startDateField`", and this package * has published "Deprecated alias for startDateField" on `TimelineConfig` for - * releases. So these arms name `startDateField`, and the remedy upstream needs - * is an explicit `aliases` (or `guidance`) entry for the two spellings so the - * suggester never answers for them. ⛔ Not fixed here — it is upstream's - * formatter, on upstream's card. + * releases. So these arms name `startDateField`. The remedy upstream needed was + * a formatter answer that keeps the suggester from speaking for `dateField`; + * 17.5.0's opposite-pole prescription is that answer (it declines to guess an + * end, where these arms name one because objectui's own alias history does). + * `endField` still draws nothing upstream. ⛔ Neither is fixed here — it is + * upstream's formatter. * * ONE detail STEM, four installed arms, plus ONE consequence clause PER KEY. * ⚠️ The per-key split is not tidiness: the two spellings fail DIFFERENTLY when @@ -1282,166 +1280,24 @@ const CalendarNodeDateAliasRefusals = { }; /** - * A plain JSON object — the only shape a view-kind block or a named view can - * take. Arrays and `null` are not blocks. - */ -function isPlainBlock(v: unknown): v is Record { - return !!v && typeof v === 'object' && !Array.isArray(v); -} - -/** - * The protocol's refusals of undeclared keys inside ONE view-kind block of a - * named view on an `object-view` document, with the path each was reported at - * (`listViews.KEY.KIND`). - * - * Since objectui#7928 `listViews` is the protocol's strict record by reference, - * so a key its `KIND` block does not declare is refused there as - * `unrecognized_keys`, and the parse output DROPS it. The named-view checks - * below therefore read those refusals rather than the parsed value, which no - * longer carries the key. The legacy `options.KIND` nesting is not walked: the - * protocol refuses a named view's `options` bag whole, and a stored body's bag - * is folded onto `KIND` at `@object-ui/app-shell`'s `ViewPreview` before it - * reaches this record (director ruling on objectui#7928, comment 5856694523). - * - * Shared by the named-view checks below so they read the same refusals; it - * judges nothing itself. + * RETIRED (objectui#11073): the named-view by-name pointers of objectui#8355 + * (`calendar.dateField` / `calendar.endField`) and objectui#10321 + * (`kanban.groupBy`) on `ObjectViewSchema`. + * + * They were `superRefine` checks with `when: () => true` that read the + * protocol's `unrecognized_keys` refusal inside a named view's `calendar` / + * `kanban` block and added objectui's pointer at the key. `@objectstack/spec` + * 17.5.0 makes that refusal TERMINAL (its closed objects mark `unrecognized_keys` + * `continue: false`), and zod skips even a `when`-guarded check once the parse is + * explicitly aborted, so the checks could no longer run on the documents they + * existed for. The document is still refused, by the protocol, at + * `listViews.KEY.calendar` / `listViews.KEY.kanban`, naming the key (for + * `dateField` the protocol's own text also says which end of the range it + * cannot tell apart). The `list-view` route keeps objectui's pointers + * (`CalendarBlockDateAliasRefusals`, `KanbanStrayGroupByRefusal`): nothing there + * aborts before them. Seat ruling Q2 → A on objectui#11073, under the + * maintainer's principle that the protocol governs (objectui#8934). */ -function namedViewKindRefusals( - issues: readonly unknown[], - kind: 'calendar' | 'kanban', -): Array<{ keys: readonly string[]; input: Record | undefined; path: PropertyKey[] }> { - const out: Array<{ keys: readonly string[]; input: Record | undefined; path: PropertyKey[] }> = []; - for (const raw of issues) { - const issue = raw as { code?: string; path?: PropertyKey[]; keys?: string[]; input?: unknown }; - if (issue.code !== 'unrecognized_keys' || issue.path?.length !== 3) continue; - if (issue.path[0] !== 'listViews' || issue.path[2] !== kind) continue; - out.push({ - keys: issue.keys ?? [], - input: isPlainBlock(issue.input) ? issue.input : undefined, - path: [...issue.path], - }); - } - return out; -} - -/** - * THE SECOND ROUTE'S READ DOOR — `ObjectViewSchema`'s named views (objectui#8355). - * - * `plugin-view`'s `generateViewSchema` is, in its own words, "the SECOND route - * to `ObjectCalendar`": it runs when no host supplied `renderListView`, so a - * named view never passes through `ListView` and nothing `ListViewSchema` - * declares reaches it. Its calendar branch used to end `...(viewOptions.calendar - * || {})`, flattening the authored block — including the two retired spellings — - * onto the `object-calendar` node. This card strips that spread; this check is - * the loud half, without which the strip would only make the failure CONSISTENT - * and still mute, which is the shape the ruling refuses. - * - * ## What it reads since `listViews` became a mirror (objectui#7928) - * - * `listViews` is the protocol's strict record by reference now (the reading is - * on `ObjectViewSchema` above). The protocol's calendar block refuses both - * spellings itself — `unrecognized_keys` at `listViews.KEY.calendar`, naming - * the key — and the parse output DROPS the refused key. So the value this check - * used to read never carries the alias any more, and the check reads the - * protocol's refusal instead: for each alias that refusal names, it adds the - * objectui pointer at the key itself. The protocol's own hint is a near-miss - * suggester that answers `dateField` → `endDateField` (see the reading above - * {@link CALENDAR_DATE_ALIAS_STEM}), so without this pointer an author would be - * taught to bind the END of an event to the date they meant as its START. - * - * ⚠️ It adds a message to a document that is already refused. It refuses - * nothing on its own and accepts nothing the protocol refuses. `superRefine`'s - * `when` on `ObjectViewSchema` is what lets it run after that refusal. - * - * ⛔ The legacy `options.calendar` nesting is NOT read any more. The protocol - * refuses `options` whole on a named view (`unrecognized_keys` naming - * `options`), `plugin-view` no longer merges it, and a stored body's - * `options.calendar` is folded onto `calendar` by `@object-ui/app-shell`'s - * `ViewPreview` before it reaches this record, where the arm below judges it - * (director ruling on objectui#7928, comment 5856694523). - * - * The in-module precedent is `ListViewSchema.options`: an untyped bag that - * declares no member and still carries a `.check()` refusing `kanban.groupBy` - * (objectui#8365) and this card's two calendar spellings by name. - * - * Its kanban sibling, `checkNamedViewKanbanStrayGroupBy` below (objectui#10321), - * reads the same kind of refusal for the stray `groupBy`. - * - * ⛔ Scoped to the TWO keys under `calendar`. `timeline.dateField` on a named - * view draws only the protocol's own refusal, and nothing else about a named - * view is judged here. - */ -function checkNamedViewCalendarAliases(ctx: { value: unknown; issues: unknown[] }): void { - // Collected before the loop, which pushes onto the same array. - for (const refusal of namedViewKindRefusals(ctx.issues, 'calendar')) { - for (const alias of ['dateField', 'endField'] as const) { - if (!refusal.keys.includes(alias)) continue; - // ⛔ One string, read off the arm's own `.description`, so this door and - // the four declared ones cannot answer an author differently. - ctx.issues.push({ - code: 'custom', - message: CalendarBlockDateAliasRefusals[alias].description as string, - input: refusal.input?.[alias], - path: [...refusal.path, alias], - }); - } - } -} - -/** - * THE SECOND ROUTE'S KANBAN TWIN — a named view's stray `kanban.groupBy` - * (objectui#10321, under the objectui#8365 ruling B, verbatim 「8365 同意」). - * - * `generateViewSchema`'s kanban branch had the calendar branch's shape: a stray - * `groupBy` in `viewOptions.kanban` rode `...restKanban` onto the node and - * overrode the lane the branch had resolved from `groupByField` (objectui#9242 - * now drops it). The ruling refuses that key loudly 「at the read door of the - * view, visible to the author of the view」, and a named view's - * `listViews.KEY.kanban.groupBy` is a stored view authoring the same key. So it - * takes the same refusal, as a sibling of the calendar check above on the same - * `ObjectViewSchema` door, with the ONE string `KanbanStrayGroupByRefusal` - * carries on the `list-view` route (`custom` here, at `listViews.KEY.kanban.groupBy`; - * the `options.kanban` nesting it also judged until objectui#7928 is refused whole). - * - * The protocol agrees on this route too: `@objectstack/spec`'s `ViewSchema` - * refuses `listViews.KEY.kanban.groupBy` as an unrecognized key exactly as its - * `ListViewSchema` refuses `kanban.groupBy`. Measured when this landed, on - * `@objectstack/spec` 17.4.0, with a dark control (`groupByField` and `columns` - * alone, accepted) on both routes. The spec refuses a named view's `options` bag - * outright, as it does a `list-view`'s, so refusing `options.kanban.groupBy` here - * is no stricter than the protocol. - * - * ⛔ A sibling, not a widening of the calendar check: two pending changesets - * describe that check as judging the two calendar spellings only, and it still - * does. ⛔ Not the mirror of `listViews` either: it judges ONE key and refuses - * nothing on its own. - * - * ⚠️ Since objectui#7928 `listViews` IS mirrored, by reference to the - * protocol's strict record, so the protocol refuses `kanban.groupBy` itself - * (`unrecognized_keys` at `listViews.KEY.kanban`) and drops it from the parse - * output. This check reads that refusal ({@link namedViewKindRefusals}) and adds - * this repository's pointer at `listViews.KEY.kanban.groupBy`; the protocol - * answers `groupBy` as a plain unrecognized key with no pointer. The legacy - * `options.kanban` nesting is refused WHOLE (`options` by name) and no longer - * read here, and an undeclared sibling in the block or a block without its - * required keys is refused by the record, not by this check. The SCOPE CONTROLS - * in `named-view-kanban-stray-group-by-10321.test.ts` were inverted to that - * tree. - */ -function checkNamedViewKanbanStrayGroupBy(ctx: { value: unknown; issues: unknown[] }): void { - // Collected before the loop, which pushes onto the same array. - for (const refusal of namedViewKindRefusals(ctx.issues, 'kanban')) { - if (!refusal.keys.includes('groupBy')) continue; - // ⛔ One string, read off the arm's own `.description`, so the named-view - // door and the `list-view` route cannot answer an author differently. - ctx.issues.push({ - code: 'custom', - message: KanbanStrayGroupByRefusal.description as string, - input: refusal.input?.groupBy, - path: [...refusal.path, 'groupBy'], - }); - } -} const CalendarConfig = stripImportedDefaults(SpecCalendarConfigSchema).partial().extend({ // objectui-only: the calendar renderer's initial view mode. No spec counterpart — @@ -1472,20 +1328,23 @@ const CalendarConfig = stripImportedDefaults(SpecCalendarConfigSchema).partial() * gate's own failure mode one layer in. * * ⚠️ THE MEMBER LIST IS objectui's OWN, and the spec does NOT supply it. - * MEASURED on the installed `@objectstack/spec` 17.4.0: + * MEASURED on the installed `@objectstack/spec` 17.5.0 (the same answer + * 17.4.0 gave): * `ComponentPropsMap['object-calendar'].calendar` is NOT `CalendarConfigSchema` * — it is `z.unknown().optional()` (wrapper chain `["optional","unknown"]`, and * not the same object reference), so at THIS position the protocol accepts * everything: a nonsense key, a wrong-typed member, even `calendar: 42` all - * parse. `CalendarConfigSchema` is the strict four-key object the spec uses for - * a LIST VIEW's calendar block, which is a different position. + * parse. `CalendarConfigSchema` is the strict object the spec uses for a LIST + * VIEW's calendar block, which is a different position — four keys through + * 17.4.0, five since 17.5.0 declared `allDayField` (objectui#11073). * * ⇒ what the protocol settles here is the KEY, not its SHAPE. The shape below * is objectui's, chosen as exactly the five members `ObjectCalendar.tsx`'s - * events pass destructures out of the resolved config — the spec's four plus - * objectui's own `allDayField`, the same objectui-local lane objectui#8466 took - * for the FLAT spelling of this vocabulary, on this same interface, for the same - * renderer. + * events pass destructures out of the resolved config. Through 17.4.0 that was + * the spec's four plus objectui's own `allDayField`, the same objectui-local + * lane objectui#8466 took for the FLAT spelling of this vocabulary, on this same + * interface, for the same renderer; since 17.5.0 it is the list view block's + * five exactly. * * That makes this mirror STRICTER than the protocol at this position, which is * the sanctioned direction and not the forbidden one: objectui#8327's triage diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b7f3e9d063..54fd0ec5b5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,7 +35,7 @@ importers: version: link:packages/test-support '@objectstack/spec': specifier: ^17.0.0 - version: 17.4.0(ai@7.0.65(zod@4.4.3)) + version: 17.5.0(ai@7.0.65(zod@4.6.5)) '@playwright/test': specifier: ^1.62.1 version: 1.62.1 @@ -273,13 +273,13 @@ importers: version: link:../../packages/types '@objectstack/client': specifier: ^17.3.0 - version: 17.4.0(ai@7.0.65(zod@4.4.3)) + version: 17.5.0(ai@7.0.65(zod@4.6.5)) '@objectstack/lint': specifier: ^17.0.0 - version: 17.4.0(ai@7.0.65(zod@4.4.3)) + version: 17.5.0(ai@7.0.65(zod@4.6.5)) '@objectstack/spec': specifier: ^17.0.0 - version: 17.4.0(ai@7.0.65(zod@4.4.3)) + version: 17.5.0(ai@7.0.65(zod@4.6.5)) '@tailwindcss/postcss': specifier: ^4.3.3 version: 4.3.3 @@ -417,16 +417,16 @@ importers: version: link:../../packages/types '@objectstack/spec': specifier: ^17.0.0 - version: 17.4.0(ai@7.0.65(zod@4.4.3)) + version: 17.5.0(ai@7.0.65(zod@4.6.5)) fumadocs-core: specifier: 16.15.4 - version: 16.15.4(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.8(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.43.0(react@19.2.8))(next@16.3.3(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react-router@7.18.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(zod@4.4.3) + version: 16.15.4(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.8(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.43.0(react@19.2.8))(next@16.3.3(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react-router@7.18.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(zod@4.6.5) fumadocs-mdx: specifier: 15.2.3 - version: 15.2.3(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.2.18)(fumadocs-core@16.15.4(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.8(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.43.0(react@19.2.8))(next@16.3.3(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react-router@7.18.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(zod@4.4.3))(next@16.3.3(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(rolldown@1.2.7)(vite@8.2.2(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) + version: 15.2.3(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.2.18)(fumadocs-core@16.15.4(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.8(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.43.0(react@19.2.8))(next@16.3.3(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react-router@7.18.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(zod@4.6.5))(next@16.3.3(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(rolldown@1.2.7)(vite@8.2.2(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) fumadocs-ui: specifier: 16.15.4 - version: 16.15.4(@types/mdx@2.0.14)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(fumadocs-core@16.15.4(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.8(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.43.0(react@19.2.8))(next@16.3.3(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react-router@7.18.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(zod@4.4.3))(next@16.3.3(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3) + version: 16.15.4(@types/mdx@2.0.14)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(fumadocs-core@16.15.4(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.8(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.43.0(react@19.2.8))(next@16.3.3(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react-router@7.18.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(zod@4.6.5))(next@16.3.3(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3) lucide-react: specifier: ^1.43.0 version: 1.43.0(react@19.2.8) @@ -775,13 +775,13 @@ importers: version: link:../types '@objectstack/formula': specifier: ^17.0.0 - version: 17.4.0(ai@7.0.65(zod@4.4.3)) + version: 17.5.0(ai@7.0.65(zod@4.6.5)) '@objectstack/lint': specifier: ^17.0.0 - version: 17.4.0(ai@7.0.65(zod@4.4.3)) + version: 17.5.0(ai@7.0.65(zod@4.6.5)) '@objectstack/spec': - specifier: ^17.4.0 - version: 17.4.0(ai@7.0.65(zod@4.4.3)) + specifier: ^17.5.0 + version: 17.5.0(ai@7.0.65(zod@4.6.5)) '@sentry/react': specifier: ^10.70.0 version: 10.70.0(react@19.2.8) @@ -798,8 +798,8 @@ importers: specifier: ^2.0.8 version: 2.0.8(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) zod: - specifier: ^4.4.3 - version: 4.4.3 + specifier: ^4.6.1 + version: 4.6.5 devDependencies: '@object-ui/plugin-calendar': specifier: workspace:* @@ -872,7 +872,7 @@ importers: dependencies: '@objectstack/spec': specifier: ^17.0.0 - version: 17.4.0(ai@7.0.65(zod@4.4.3)) + version: 17.5.0(ai@7.0.65(zod@4.6.5)) better-auth: specifier: ^1.7.2 version: 1.7.2(@opentelemetry/api@1.9.1)(better-sqlite3@12.9.0)(mongodb@7.2.0(socks@2.8.9))(next@16.3.3(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@4.1.10) @@ -968,7 +968,7 @@ importers: devDependencies: '@objectstack/spec': specifier: ^17.0.0 - version: 17.4.0(ai@7.0.65(zod@4.4.3)) + version: 17.5.0(ai@7.0.65(zod@4.6.5)) '@types/react': specifier: 19.2.18 version: 19.2.18 @@ -1004,7 +1004,7 @@ importers: version: link:../types '@objectstack/spec': specifier: ^17.0.0 - version: 17.4.0(ai@7.0.65(zod@4.4.3)) + version: 17.5.0(ai@7.0.65(zod@4.6.5)) '@radix-ui/react-accordion': specifier: ^1.2.20 version: 1.2.20(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -1179,7 +1179,7 @@ importers: version: 5.0.3(@microsoft/api-extractor@7.58.2(@types/node@26.2.0))(esbuild@0.28.2)(rolldown@1.2.7)(rollup@4.62.2)(typescript@6.0.3)(vite@8.2.2(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) zod: specifier: ^4.4.3 - version: 4.4.3 + version: 4.6.5 packages/core: dependencies: @@ -1188,10 +1188,10 @@ importers: version: link:../types '@objectstack/formula': specifier: ^17.0.0 - version: 17.4.0(ai@7.0.65(zod@4.4.3)) + version: 17.5.0(ai@7.0.65(zod@4.6.5)) '@objectstack/spec': - specifier: ^17.3.0 - version: 17.4.0(ai@7.0.65(zod@4.4.3)) + specifier: ^17.5.0 + version: 17.5.0(ai@7.0.65(zod@4.6.5)) devDependencies: '@object-ui/test-support': specifier: workspace:* @@ -1247,10 +1247,10 @@ importers: version: link:../types '@objectstack/client': specifier: ^17.3.0 - version: 17.4.0(ai@7.0.65(zod@4.4.3)) + version: 17.5.0(ai@7.0.65(zod@4.6.5)) '@objectstack/spec': - specifier: ^17.4.0 - version: 17.4.0(ai@7.0.65(zod@4.4.3)) + specifier: ^17.5.0 + version: 17.5.0(ai@7.0.65(zod@4.6.5)) devDependencies: '@object-ui/test-support': specifier: workspace:* @@ -1290,7 +1290,7 @@ importers: version: link:../types '@objectstack/spec': specifier: ^17.0.0 - version: 17.4.0(ai@7.0.65(zod@4.4.3)) + version: 17.5.0(ai@7.0.65(zod@4.6.5)) lucide-react: specifier: ^1.43.0 version: 1.43.0(react@19.2.8) @@ -1394,7 +1394,7 @@ importers: version: link:../types '@objectstack/spec': specifier: ^17.0.0 - version: 17.4.0(ai@7.0.65(zod@4.4.3)) + version: 17.5.0(ai@7.0.65(zod@4.6.5)) lucide-react: specifier: ^1.43.0 version: 1.43.0(react@19.2.8) @@ -1435,7 +1435,7 @@ importers: devDependencies: '@objectstack/spec': specifier: ^17.0.0 - version: 17.4.0(ai@7.0.65(zod@4.4.3)) + version: 17.5.0(ai@7.0.65(zod@4.6.5)) '@types/react': specifier: 19.2.18 version: 19.2.18 @@ -1616,7 +1616,7 @@ importers: version: link:../test-support '@objectstack/spec': specifier: ^17.0.0 - version: 17.4.0(ai@7.0.65(zod@4.4.3)) + version: 17.5.0(ai@7.0.65(zod@4.6.5)) '@types/react': specifier: 19.2.18 version: 19.2.18 @@ -1640,7 +1640,7 @@ importers: dependencies: '@ai-sdk/react': specifier: ^4.0.68 - version: 4.0.68(react@19.2.8)(zod@4.4.3) + version: 4.0.68(react@19.2.8)(zod@4.6.5) '@object-ui/components': specifier: workspace:* version: link:../components @@ -1658,7 +1658,7 @@ importers: version: link:../types '@objectstack/spec': specifier: ^17.0.0 - version: 17.4.0(ai@7.0.65(zod@4.4.3)) + version: 17.5.0(ai@7.0.65(zod@4.6.5)) '@radix-ui/react-slot': specifier: ^1.3.3 version: 1.3.3(@types/react@19.2.18)(react@19.2.8) @@ -1667,7 +1667,7 @@ importers: version: 1.2.6(@types/react@19.2.18)(react@19.2.8) ai: specifier: ^7.0.65 - version: 7.0.65(zod@4.4.3) + version: 7.0.65(zod@4.6.5) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -1765,7 +1765,7 @@ importers: version: link:../test-support '@objectstack/spec': specifier: ^17.0.0 - version: 17.4.0(ai@7.0.65(zod@4.4.3)) + version: 17.5.0(ai@7.0.65(zod@4.6.5)) '@types/react-grid-layout': specifier: ^2.1.0 version: 2.1.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -1816,7 +1816,7 @@ importers: version: link:../types '@objectstack/spec': specifier: ^17.4.0 - version: 17.4.0(ai@7.0.65(zod@4.4.3)) + version: 17.5.0(ai@7.0.65(zod@4.6.5)) clsx: specifier: ^2.1.1 version: 2.1.1 @@ -1868,7 +1868,7 @@ importers: version: link:../i18n '@objectstack/spec': specifier: ^17.1.0 - version: 17.4.0(ai@7.0.65(zod@4.4.3)) + version: 17.5.0(ai@7.0.65(zod@4.6.5)) lucide-react: specifier: ^1.43.0 version: 1.43.0(react@19.2.8) @@ -1993,7 +1993,7 @@ importers: version: link:../types '@objectstack/spec': specifier: ^17.0.0 - version: 17.4.0(ai@7.0.65(zod@4.4.3)) + version: 17.5.0(ai@7.0.65(zod@4.6.5)) lucide-react: specifier: ^1.43.0 version: 1.43.0(react@19.2.8) @@ -2054,7 +2054,7 @@ importers: version: link:../types '@objectstack/spec': specifier: ^17.0.0 - version: 17.4.0(ai@7.0.65(zod@4.4.3)) + version: 17.5.0(ai@7.0.65(zod@4.6.5)) lucide-react: specifier: ^1.43.0 version: 1.43.0(react@19.2.8) @@ -2124,7 +2124,7 @@ importers: version: link:../types '@objectstack/spec': specifier: ^17.4.0 - version: 17.4.0(ai@7.0.65(zod@4.4.3)) + version: 17.5.0(ai@7.0.65(zod@4.6.5)) '@tanstack/react-virtual': specifier: ^3.14.9 version: 3.14.9(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -2298,7 +2298,7 @@ importers: version: link:../types '@objectstack/spec': specifier: ^17.0.0 - version: 17.4.0(ai@7.0.65(zod@4.4.3)) + version: 17.5.0(ai@7.0.65(zod@4.6.5)) '@types/react': specifier: 19.2.18 version: 19.2.18 @@ -2475,7 +2475,7 @@ importers: version: link:../test-support '@objectstack/spec': specifier: ^17.0.0 - version: 17.4.0(ai@7.0.65(zod@4.4.3)) + version: 17.5.0(ai@7.0.65(zod@4.6.5)) '@types/node': specifier: ^26.2.0 version: 26.2.0 @@ -2523,7 +2523,7 @@ importers: version: link:../types '@objectstack/spec': specifier: ^17.0.0 - version: 17.4.0(ai@7.0.65(zod@4.4.3)) + version: 17.5.0(ai@7.0.65(zod@4.6.5)) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -2535,7 +2535,7 @@ importers: version: 19.2.8(react@19.2.8) zod: specifier: ^4.4.3 - version: 4.4.3 + version: 4.6.5 devDependencies: '@object-ui/data-objectstack': specifier: workspace:* @@ -2587,7 +2587,7 @@ importers: version: link:../types '@objectstack/spec': specifier: ^17.0.0 - version: 17.4.0(ai@7.0.65(zod@4.4.3)) + version: 17.5.0(ai@7.0.65(zod@4.6.5)) lucide-react: specifier: ^1.43.0 version: 1.43.0(react@19.2.8) @@ -2657,7 +2657,7 @@ importers: version: link:../types '@objectstack/spec': specifier: ^17.0.0 - version: 17.4.0(ai@7.0.65(zod@4.4.3)) + version: 17.5.0(ai@7.0.65(zod@4.6.5)) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -2722,7 +2722,7 @@ importers: version: link:../types '@objectstack/spec': specifier: ^17.1.0 - version: 17.4.0(ai@7.0.65(zod@4.4.3)) + version: 17.5.0(ai@7.0.65(zod@4.6.5)) react: specifier: 19.2.8 version: 19.2.8 @@ -2787,7 +2787,7 @@ importers: devDependencies: '@objectstack/spec': specifier: ^17.0.0 - version: 17.4.0(ai@7.0.65(zod@4.4.3)) + version: 17.5.0(ai@7.0.65(zod@4.6.5)) '@tailwindcss/postcss': specifier: ^4.3.3 version: 4.3.3 @@ -2826,13 +2826,13 @@ importers: version: link:../react '@objectstack/spec': specifier: ^17.0.0 - version: 17.4.0(ai@7.0.65(zod@4.4.3)) + version: 17.5.0(ai@7.0.65(zod@4.6.5)) packages/test-support: devDependencies: '@objectstack/spec': specifier: ^17.0.0 - version: 17.4.0(ai@7.0.65(zod@4.4.3)) + version: 17.5.0(ai@7.0.65(zod@4.6.5)) '@types/node': specifier: ^26.2.0 version: 26.2.0 @@ -2841,16 +2841,16 @@ importers: version: 6.0.3 zod: specifier: ^4.4.3 - version: 4.4.3 + version: 4.6.5 packages/types: dependencies: '@objectstack/spec': - specifier: ^17.4.0 - version: 17.4.0(ai@7.0.65(zod@4.4.3)) + specifier: ^17.5.0 + version: 17.5.0(ai@7.0.65(zod@4.6.5)) zod: - specifier: ^4.4.3 - version: 4.4.3 + specifier: ^4.6.1 + version: 4.6.5 devDependencies: '@object-ui/test-support': specifier: workspace:* @@ -4201,26 +4201,26 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} - '@objectstack/client@17.4.0': - resolution: {integrity: sha512-4Zsov0iRcZhaYAGiabX2XJPhDsE6XmRhlfYikxLECZ1cxbHhXjVpsNL3rl56wkTHuNV1jMzZZwzj2rJJUbVYcQ==} + '@objectstack/client@17.5.0': + resolution: {integrity: sha512-RT8CoPw7v+ejADcK1NJRD/1rHOQJMKC+rL7SjTvEqI+Cfxulz3K4NSgILpyPZMaic72O6DWiWSNueNiFp/7W1g==} engines: {node: '>=22.0.0'} - '@objectstack/core@17.4.0': - resolution: {integrity: sha512-e/br10MaIZAJGgD7pb9AnW8zn9I18aJyiUYswMQGdjGheZYpp98ZV3WlAVpZFhnKkfd42VDMltB+X494oB8/1Q==} + '@objectstack/core@17.5.0': + resolution: {integrity: sha512-KYVVHoqDQZzqvCD7B1REAnQc80ZeimrkSryaGvPfQc2bdurvTOjtubGXw2IkQsh3g2Gh0RD9QzohteEU7C1DFg==} engines: {node: '>=22.0.0'} - '@objectstack/formula@17.4.0': - resolution: {integrity: sha512-pWVkvXKXJxjnsx1i7mYIteltc6kjn/qS6swJ6bw5XG5B7bf8GjjBsuvcJR86nuTg+z8B9+snruaD9i9v8uw0LQ==} + '@objectstack/formula@17.5.0': + resolution: {integrity: sha512-2vot7CS7++4vuxOJ3fyWVUPOut+/oejJT1YbLwyyyVDxj4WjZ3tJqEk+WGRqNunwYbd3CTk7jrtMo7SRzF7+Xg==} - '@objectstack/lint@17.4.0': - resolution: {integrity: sha512-9Lst+TDusPVh3Fb1gxDPSsXpAFrkEIlusqLzBsCKQ0ebLaHxG2GrfMXQofDTox9vr7jOVSAKCW5jkHUmg5XpoA==} + '@objectstack/lint@17.5.0': + resolution: {integrity: sha512-sXNgX4PFAMwo5q7xRqUfTEBUE5OKStYQwmFqdwDDN5Zir8JRJi9z2cN1D42h9PciR4Y9+Qum0z9gOKgS6o0jsw==} engines: {node: '>=22.0.0'} - '@objectstack/sdui-parser@17.4.0': - resolution: {integrity: sha512-pKKTqP6siwsLtcO7tDxMufHoCWuUeWjoRBv8NRGlptB+a/uhO0WKXHzZ3+zWVqz9a/Ac9N2Xwp+hBPoGtG4Z1w==} + '@objectstack/sdui-parser@17.5.0': + resolution: {integrity: sha512-GBn5BONhKuWfI/wmI+X4P0mh4a/cKx6Zo0rgZojWVBVC00fPpxuMb5obO74Do509Nm6o0h7m7OAq65Rb0td7TQ==} - '@objectstack/spec@17.4.0': - resolution: {integrity: sha512-bOtSFih0Ed67JZMGuLmlHuIv2zPN62W8NL2qENQCYjZB174NMlIIvHC6Jlz2SIy5EwsTnTGBF5EPF6C2/TmqoA==} + '@objectstack/spec@17.5.0': + resolution: {integrity: sha512-G9IPkAnxbgk90wKTqyrIHsPm+kWyYDn2cUfmNnt5xwewdpvRtd5HqaHSjjn5wBCzO0OACerZULNR/f3gPtQu1w==} engines: {node: '>=22.0.0'} peerDependencies: ai: ^7.0.0 @@ -4228,8 +4228,8 @@ packages: ai: optional: true - '@objectstack/types@17.4.0': - resolution: {integrity: sha512-z7eCTBlGqAX8ChKifkItGg+woONgAMBbWtu8OmTqI6Fzn8z7JDCm07+K0gg0hivD1uoUUL253s8mshdiqpZnnQ==} + '@objectstack/types@17.5.0': + resolution: {integrity: sha512-QSwGeHxGmDd5SQbZFkHdoct9J6kOh7tijsU6BDE1k+zgTHqOnqUGNQkp6T8KggJTdPz2MHvRDBpmstHT8Rsgng==} engines: {node: '>=22.0.0'} '@open-draft/deferred-promise@2.2.0': @@ -11009,8 +11009,8 @@ packages: zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} - zod@4.4.3: - resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + zod@4.6.5: + resolution: {integrity: sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==} zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -11019,39 +11019,39 @@ snapshots: '@adobe/css-tools@4.5.0': {} - '@ai-sdk/gateway@4.0.52(zod@4.4.3)': + '@ai-sdk/gateway@4.0.52(zod@4.6.5)': dependencies: '@ai-sdk/provider': 4.0.7 - '@ai-sdk/provider-utils': 5.0.27(zod@4.4.3) + '@ai-sdk/provider-utils': 5.0.27(zod@4.6.5) '@vercel/oidc': 3.2.0 - zod: 4.4.3 + zod: 4.6.5 - '@ai-sdk/mcp@2.0.31(zod@4.4.3)': + '@ai-sdk/mcp@2.0.31(zod@4.6.5)': dependencies: '@ai-sdk/provider': 4.0.7 - '@ai-sdk/provider-utils': 5.0.27(zod@4.4.3) + '@ai-sdk/provider-utils': 5.0.27(zod@4.6.5) pkce-challenge: 5.0.1 - zod: 4.4.3 + zod: 4.6.5 - '@ai-sdk/provider-utils@5.0.27(zod@4.4.3)': + '@ai-sdk/provider-utils@5.0.27(zod@4.6.5)': dependencies: '@ai-sdk/provider': 4.0.7 '@standard-schema/spec': 1.1.0 '@workflow/serde': 4.1.0 eventsource-parser: 3.1.1 undici: 7.29.0 - zod: 4.4.3 + zod: 4.6.5 '@ai-sdk/provider@4.0.7': dependencies: json-schema: 0.4.0 - '@ai-sdk/react@4.0.68(react@19.2.8)(zod@4.4.3)': + '@ai-sdk/react@4.0.68(react@19.2.8)(zod@4.6.5)': dependencies: - '@ai-sdk/mcp': 2.0.31(zod@4.4.3) + '@ai-sdk/mcp': 2.0.31(zod@4.6.5) '@ai-sdk/provider': 4.0.7 - '@ai-sdk/provider-utils': 5.0.27(zod@4.4.3) - ai: 7.0.65(zod@4.4.3) + '@ai-sdk/provider-utils': 5.0.27(zod@4.6.5) + ai: 7.0.65(zod@4.6.5) react: 19.2.8 swr: 2.5.1(react@19.2.8) throttleit: 2.1.0 @@ -11358,52 +11358,52 @@ snapshots: '@bcoe/v8-coverage@1.0.2': {} - '@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.0)': + '@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.5))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.0)': dependencies: '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1 '@opentelemetry/semantic-conventions': 1.43.0 '@standard-schema/spec': 1.1.0 - better-call: 1.4.0(zod@4.4.3) + better-call: 1.4.0(zod@4.6.5) jose: 6.2.9 kysely: 0.29.5 nanostores: 1.5.0 - zod: 4.4.3 + zod: 4.6.5 optionalDependencies: '@opentelemetry/api': 1.9.1 - '@better-auth/drizzle-adapter@1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.0))(@better-auth/utils@0.4.2)': + '@better-auth/drizzle-adapter@1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.5))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.0))(@better-auth/utils@0.4.2)': dependencies: - '@better-auth/core': 1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.0) + '@better-auth/core': 1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.5))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.0) '@better-auth/utils': 0.4.2 - '@better-auth/kysely-adapter@1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.0))(@better-auth/utils@0.4.2)(kysely@0.29.5)': + '@better-auth/kysely-adapter@1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.5))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.0))(@better-auth/utils@0.4.2)(kysely@0.29.5)': dependencies: - '@better-auth/core': 1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.0) + '@better-auth/core': 1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.5))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.0) '@better-auth/utils': 0.4.2 optionalDependencies: kysely: 0.29.5 - '@better-auth/memory-adapter@1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.0))(@better-auth/utils@0.4.2)': + '@better-auth/memory-adapter@1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.5))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.0))(@better-auth/utils@0.4.2)': dependencies: - '@better-auth/core': 1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.0) + '@better-auth/core': 1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.5))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.0) '@better-auth/utils': 0.4.2 - '@better-auth/mongo-adapter@1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.0))(@better-auth/utils@0.4.2)(mongodb@7.2.0(socks@2.8.9))': + '@better-auth/mongo-adapter@1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.5))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.0))(@better-auth/utils@0.4.2)(mongodb@7.2.0(socks@2.8.9))': dependencies: - '@better-auth/core': 1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.0) + '@better-auth/core': 1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.5))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.0) '@better-auth/utils': 0.4.2 optionalDependencies: mongodb: 7.2.0(socks@2.8.9) - '@better-auth/prisma-adapter@1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.0))(@better-auth/utils@0.4.2)': + '@better-auth/prisma-adapter@1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.5))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.0))(@better-auth/utils@0.4.2)': dependencies: - '@better-auth/core': 1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.0) + '@better-auth/core': 1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.5))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.0) '@better-auth/utils': 0.4.2 - '@better-auth/telemetry@1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)': + '@better-auth/telemetry@1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.5))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)': dependencies: - '@better-auth/core': 1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.0) + '@better-auth/core': 1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.5))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.0) '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1 @@ -12294,33 +12294,33 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 - '@objectstack/client@17.4.0(ai@7.0.65(zod@4.4.3))': + '@objectstack/client@17.5.0(ai@7.0.65(zod@4.6.5))': dependencies: - '@objectstack/core': 17.4.0(ai@7.0.65(zod@4.4.3)) - '@objectstack/spec': 17.4.0(ai@7.0.65(zod@4.4.3)) + '@objectstack/core': 17.5.0(ai@7.0.65(zod@4.6.5)) + '@objectstack/spec': 17.5.0(ai@7.0.65(zod@4.6.5)) transitivePeerDependencies: - ai - '@objectstack/core@17.4.0(ai@7.0.65(zod@4.4.3))': + '@objectstack/core@17.5.0(ai@7.0.65(zod@4.6.5))': dependencies: - '@objectstack/spec': 17.4.0(ai@7.0.65(zod@4.4.3)) - '@objectstack/types': 17.4.0(ai@7.0.65(zod@4.4.3)) - zod: 4.4.3 + '@objectstack/spec': 17.5.0(ai@7.0.65(zod@4.6.5)) + '@objectstack/types': 17.5.0(ai@7.0.65(zod@4.6.5)) + zod: 4.6.5 transitivePeerDependencies: - ai - '@objectstack/formula@17.4.0(ai@7.0.65(zod@4.4.3))': + '@objectstack/formula@17.5.0(ai@7.0.65(zod@4.6.5))': dependencies: '@marcbachmann/cel-js': 8.0.0 - '@objectstack/spec': 17.4.0(ai@7.0.65(zod@4.4.3)) + '@objectstack/spec': 17.5.0(ai@7.0.65(zod@4.6.5)) transitivePeerDependencies: - ai - '@objectstack/lint@17.4.0(ai@7.0.65(zod@4.4.3))': + '@objectstack/lint@17.5.0(ai@7.0.65(zod@4.6.5))': dependencies: - '@objectstack/formula': 17.4.0(ai@7.0.65(zod@4.4.3)) - '@objectstack/sdui-parser': 17.4.0 - '@objectstack/spec': 17.4.0(ai@7.0.65(zod@4.4.3)) + '@objectstack/formula': 17.5.0(ai@7.0.65(zod@4.6.5)) + '@objectstack/sdui-parser': 17.5.0 + '@objectstack/spec': 17.5.0(ai@7.0.65(zod@4.6.5)) ajv: 8.20.0 ajv-formats: 3.0.1(ajv@8.20.0) sucrase: 3.35.1 @@ -12328,18 +12328,18 @@ snapshots: transitivePeerDependencies: - ai - '@objectstack/sdui-parser@17.4.0': {} + '@objectstack/sdui-parser@17.5.0': {} - '@objectstack/spec@17.4.0(ai@7.0.65(zod@4.4.3))': + '@objectstack/spec@17.5.0(ai@7.0.65(zod@4.6.5))': dependencies: pg-connection-string: 2.14.0 - zod: 4.4.3 + zod: 4.6.5 optionalDependencies: - ai: 7.0.65(zod@4.4.3) + ai: 7.0.65(zod@4.6.5) - '@objectstack/types@17.4.0(ai@7.0.65(zod@4.4.3))': + '@objectstack/types@17.5.0(ai@7.0.65(zod@4.6.5))': dependencies: - '@objectstack/spec': 17.4.0(ai@7.0.65(zod@4.4.3)) + '@objectstack/spec': 17.5.0(ai@7.0.65(zod@4.6.5)) transitivePeerDependencies: - ai @@ -14299,12 +14299,12 @@ snapshots: agent-base@7.1.4: {} - ai@7.0.65(zod@4.4.3): + ai@7.0.65(zod@4.6.5): dependencies: - '@ai-sdk/gateway': 4.0.52(zod@4.4.3) + '@ai-sdk/gateway': 4.0.52(zod@4.6.5) '@ai-sdk/provider': 4.0.7 - '@ai-sdk/provider-utils': 5.0.27(zod@4.4.3) - zod: 4.4.3 + '@ai-sdk/provider-utils': 5.0.27(zod@4.6.5) + zod: 4.6.5 ajv-draft-04@1.0.0(ajv@8.18.0): optionalDependencies: @@ -14485,23 +14485,23 @@ snapshots: better-auth@1.7.2(@opentelemetry/api@1.9.1)(better-sqlite3@12.9.0)(mongodb@7.2.0(socks@2.8.9))(next@16.3.3(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@4.1.10): dependencies: - '@better-auth/core': 1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.0) - '@better-auth/drizzle-adapter': 1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.0))(@better-auth/utils@0.4.2) - '@better-auth/kysely-adapter': 1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.0))(@better-auth/utils@0.4.2)(kysely@0.29.5) - '@better-auth/memory-adapter': 1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.0))(@better-auth/utils@0.4.2) - '@better-auth/mongo-adapter': 1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.0))(@better-auth/utils@0.4.2)(mongodb@7.2.0(socks@2.8.9)) - '@better-auth/prisma-adapter': 1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.0))(@better-auth/utils@0.4.2) - '@better-auth/telemetry': 1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1) + '@better-auth/core': 1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.5))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.0) + '@better-auth/drizzle-adapter': 1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.5))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.0))(@better-auth/utils@0.4.2) + '@better-auth/kysely-adapter': 1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.5))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.0))(@better-auth/utils@0.4.2)(kysely@0.29.5) + '@better-auth/memory-adapter': 1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.5))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.0))(@better-auth/utils@0.4.2) + '@better-auth/mongo-adapter': 1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.5))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.0))(@better-auth/utils@0.4.2)(mongodb@7.2.0(socks@2.8.9)) + '@better-auth/prisma-adapter': 1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.5))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.0))(@better-auth/utils@0.4.2) + '@better-auth/telemetry': 1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.6.5))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1) '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1 '@noble/ciphers': 2.3.0 '@noble/hashes': 2.3.0 - better-call: 1.4.0(zod@4.4.3) + better-call: 1.4.0(zod@4.6.5) defu: 6.1.7 jose: 6.2.9 kysely: 0.29.5 nanostores: 1.5.0 - zod: 4.4.3 + zod: 4.6.5 optionalDependencies: better-sqlite3: 12.9.0 mongodb: 7.2.0(socks@2.8.9) @@ -14513,14 +14513,14 @@ snapshots: - '@cloudflare/workers-types' - '@opentelemetry/api' - better-call@1.4.0(zod@4.4.3): + better-call@1.4.0(zod@4.6.5): dependencies: '@better-auth/utils': 0.5.0 '@better-fetch/fetch': 1.3.1 rou3: 0.9.2 set-cookie-parser: 3.1.2 optionalDependencies: - zod: 4.4.3 + zod: 4.6.5 better-sqlite3@12.9.0: dependencies: @@ -15419,8 +15419,8 @@ snapshots: '@babel/parser': 7.29.8 eslint: 10.8.1(jiti@2.7.0) hermes-parser: 0.25.1 - zod: 4.4.3 - zod-validation-error: 4.0.2(zod@4.4.3) + zod: 4.6.5 + zod-validation-error: 4.0.2(zod@4.6.5) transitivePeerDependencies: - supports-color @@ -15856,7 +15856,7 @@ snapshots: mkdirp: 0.5.6 rimraf: 2.7.1 - fumadocs-core@16.15.4(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.8(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.43.0(react@19.2.8))(next@16.3.3(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react-router@7.18.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(zod@4.4.3): + fumadocs-core@16.15.4(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.8(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.43.0(react@19.2.8))(next@16.3.3(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react-router@7.18.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(zod@4.6.5): dependencies: '@fumari/image-size': 0.1.0 estree-util-value-to-estree: 3.5.0 @@ -15889,18 +15889,18 @@ snapshots: react: 19.2.8 react-dom: 19.2.8(react@19.2.8) react-router: 7.18.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - zod: 4.4.3 + zod: 4.6.5 transitivePeerDependencies: - supports-color - fumadocs-mdx@15.2.3(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.2.18)(fumadocs-core@16.15.4(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.8(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.43.0(react@19.2.8))(next@16.3.3(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react-router@7.18.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(zod@4.4.3))(next@16.3.3(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(rolldown@1.2.7)(vite@8.2.2(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)): + fumadocs-mdx@15.2.3(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.2.18)(fumadocs-core@16.15.4(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.8(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.43.0(react@19.2.8))(next@16.3.3(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react-router@7.18.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(zod@4.6.5))(next@16.3.3(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(rolldown@1.2.7)(vite@8.2.2(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)): dependencies: '@mdx-js/mdx': 3.1.1 '@standard-schema/spec': 1.1.0 chokidar: 5.0.0 esbuild: 0.28.2 estree-util-value-to-estree: 3.5.0 - fumadocs-core: 16.15.4(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.8(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.43.0(react@19.2.8))(next@16.3.3(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react-router@7.18.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(zod@4.4.3) + fumadocs-core: 16.15.4(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.8(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.43.0(react@19.2.8))(next@16.3.3(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react-router@7.18.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(zod@4.6.5) github-slugger: 2.0.0 magic-string: 1.2.0 mdast-util-mdx: 3.0.0 @@ -15914,7 +15914,7 @@ snapshots: vfile: 6.0.3 yaml: 2.9.0 yuku-analyzer: 0.8.7 - zod: 4.4.3 + zod: 4.6.5 optionalDependencies: '@types/mdast': 4.0.4 '@types/mdx': 2.0.14 @@ -15926,7 +15926,7 @@ snapshots: transitivePeerDependencies: - supports-color - fumadocs-ui@16.15.4(@types/mdx@2.0.14)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(fumadocs-core@16.15.4(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.8(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.43.0(react@19.2.8))(next@16.3.3(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react-router@7.18.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(zod@4.4.3))(next@16.3.3(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3): + fumadocs-ui@16.15.4(@types/mdx@2.0.14)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(fumadocs-core@16.15.4(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.8(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.43.0(react@19.2.8))(next@16.3.3(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react-router@7.18.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(zod@4.6.5))(next@16.3.3(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3): dependencies: '@fuma-translate/react': 1.0.2(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@fumadocs/tailwind': 0.1.1(tailwindcss@4.3.3) @@ -15942,7 +15942,7 @@ snapshots: '@radix-ui/react-tabs': 1.1.21(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) class-variance-authority: 0.7.1 cnfast: 0.1.0 - fumadocs-core: 16.15.4(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.8(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.43.0(react@19.2.8))(next@16.3.3(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react-router@7.18.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(zod@4.4.3) + fumadocs-core: 16.15.4(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.8(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.43.0(react@19.2.8))(next@16.3.3(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react-router@7.18.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(zod@4.6.5) lucide-react: 1.43.0(react@19.2.8) motion: 13.2.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) next-themes: 0.4.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -19804,12 +19804,12 @@ snapshots: dependencies: zod: 3.25.76 - zod-validation-error@4.0.2(zod@4.4.3): + zod-validation-error@4.0.2(zod@4.6.5): dependencies: - zod: 4.4.3 + zod: 4.6.5 zod@3.25.76: {} - zod@4.4.3: {} + zod@4.6.5: {} zwitch@2.0.4: {} diff --git a/scripts/__tests__/check-eager-closure-budget.test.ts b/scripts/__tests__/check-eager-closure-budget.test.ts index c828b7e164..b1d1883682 100644 --- a/scripts/__tests__/check-eager-closure-budget.test.ts +++ b/scripts/__tests__/check-eager-closure-budget.test.ts @@ -778,10 +778,11 @@ describe('ceiling sensitivity, judged live (objectui#5924)', () => { // objectui#7122 UP to 3468.0 on the authorised raise, objectui#7479 down to // 3090.6 when nine locale catalogues left the eager closure, objectui#9251 // down to 3060.0 when lucide's 1,781-icon record left it, objectui#10996 UP - // to 3104.5 on the authorised re-pin over `main`'s own drift) — a - // rendering derived in the test would agree with the renderer by - // construction and pin nothing. - expect(result.message).toContain('3104.5'); + // to 3104.5 on the authorised re-pin over `main`'s own drift, objectui#11073 + // UP to 3563.2 on the ruled raise for `@objectstack/*` 17.5.0, + // objectui#11088 decision 1 = A) — a rendering derived in the test would + // agree with the renderer by construction and pin nothing. + expect(result.message).toContain('3563.2'); }); it('is exactly one regression wide, from either side of the line', () => { @@ -1611,7 +1612,7 @@ describe('main', () => { // about the FIXTURE while the gate under test behaved correctly. The number // this case is actually about is "the report's chunk count, echoed". expect(outputs.closure_chunks).toBe(String(fixture.files.length)); - expect(outputs.closure_gzip_kb).toBe('3104.5'); + expect(outputs.closure_gzip_kb).toBe('3563.2'); }); it('exits 1 — a verdict about the BUNDLE — when over budget', () => { diff --git a/scripts/__tests__/check-lockfile-integrity.test.ts b/scripts/__tests__/check-lockfile-integrity.test.ts index 6a20c4fb4d..23774c4575 100644 --- a/scripts/__tests__/check-lockfile-integrity.test.ts +++ b/scripts/__tests__/check-lockfile-integrity.test.ts @@ -89,23 +89,23 @@ describe('green on main — the negative control', () => { * * ⛔ It cannot be produced by running the resolver, and that impossibility is * the card's central claim: every workspace range on `@objectstack/*` is a - * floating `^17.x` and the registry's latest is 17.4.0, so no fresh resolve can - * pick 17.2.0. The sample is therefore a transformation of the real lockfile + * floating `^17.x` whose floor is at or above the resolved 17.5.0 (objectui#11073; + * 17.4.0 when this sample was written), so no fresh resolve can pick 17.2.0. The sample is therefore a transformation of the real lockfile * reproducing the identity facts measured on PRs #7053 / #7058 — the family * down to 17.2.0, and `@objectstack/spec` resolved at BOTH versions. */ function sampleA(): string { let out = realLock; for (const pkg of ['client', 'core', 'formula', 'lint', 'sdui-parser']) { - out = out.replace(new RegExp(`(@objectstack/${pkg}@)17\\.4\\.0`, 'g'), '$117.2.0'); + out = out.replace(new RegExp(`(@objectstack/${pkg}@)17\\.5\\.0`, 'g'), '$117.2.0'); } - const specKey = " '@objectstack/spec@17.4.0(ai@7.0.65(zod@4.4.3))':"; + const specKey = " '@objectstack/spec@17.5.0(ai@7.0.65(zod@4.6.5))':"; expect(out, 'the spec snapshot key moved — rebuild this sample before trusting it').toContain( specKey, ); return out.replace( specKey, - " '@objectstack/spec@17.2.0(ai@7.0.65(zod@4.4.3))':\n dependencies:\n zod: 4.4.3\n" + + " '@objectstack/spec@17.2.0(ai@7.0.65(zod@4.6.5))':\n dependencies:\n zod: 4.6.5\n" + specKey, ); } @@ -123,15 +123,15 @@ function sampleA(): string { function sampleB(): string { let out = realLock; const forked = [ - " '@objectstack/spec@17.4.0(ai@7.0.65(zod@4.4.3))':", - " '@objectstack/formula@17.4.0(ai@7.0.65(zod@4.4.3))':", - " ai@7.0.65(zod@4.4.3):", + " '@objectstack/spec@17.5.0(ai@7.0.65(zod@4.6.5))':", + " '@objectstack/formula@17.5.0(ai@7.0.65(zod@4.6.5))':", + " ai@7.0.65(zod@4.6.5):", ]; for (const key of forked) { expect(out, `snapshot key moved: ${key}`).toContain(key); - out = out.replace(key, `${key.replace('zod@4.4.3', 'zod@4.5.4')}\n dependencies: {}\n${key}`); + out = out.replace(key, `${key.replace('zod@4.6.5', 'zod@4.5.4')}\n dependencies: {}\n${key}`); } - const zodKey = ' zod@4.4.3: {}'; + const zodKey = ' zod@4.6.5: {}'; expect(out).toContain(zodKey); return out.replace(zodKey, `${zodKey}\n zod@4.5.4: {}`); } @@ -178,7 +178,8 @@ describe('cause 2 — a declared dependency forks with no version moving (object const zod = reading.duplicated.find((f) => f.name === 'zod'); expect(zod, 'the gate must not be blind to objectui#8333').toBeTruthy(); // ⛔ 2 -> 3, not 1 -> 2. Both cards say "forks a single-copy dependency"; - // measured on `main`, zod is ALREADY two copies (3.25.76 + 4.4.3), so a + // measured on `main`, zod is ALREADY two copies (3.25.76 + 4.4.3 then; + // 3.25.76 + 4.6.5 since objectui#11073, re-measured), so a // rule keyed on "was one, is now two" would be green here. expect(zod?.baseCount).toBe(2); expect(zod?.headCount).toBe(3); @@ -189,7 +190,7 @@ describe('cause 2 — a declared dependency forks with no version moving (object expect(spec?.headCount).toBe(2); // This is #8326's exact bundling mechanism — two real paths, nothing // dedupes them — arriving with no version change anywhere. - expect(spec?.head.every((k) => k.includes('@17.4.0'))).toBe(true); + expect(spec?.head.every((k) => k.includes('@17.5.0'))).toBe(true); }); }); diff --git a/scripts/__tests__/spec-main-shape-gate.test.ts b/scripts/__tests__/spec-main-shape-gate.test.ts index 6d901369e2..12a487907b 100644 --- a/scripts/__tests__/spec-main-shape-gate.test.ts +++ b/scripts/__tests__/spec-main-shape-gate.test.ts @@ -305,7 +305,7 @@ describe('the injection reads the spec against the dependencies it was built wit '2.14.0', ); const entry = path.join(store, '@objectstack+spec@17.4.0_zod@4.4.3', 'node_modules'); - const installed = writePackage(path.join(entry, '@objectstack', 'spec'), '@objectstack/spec', '17.4.0'); + const fixtureSpec = writePackage(path.join(entry, '@objectstack', 'spec'), '@objectstack/spec', '17.4.0'); fs.symlinkSync('../../zod@4.4.3/node_modules/zod', path.join(entry, 'zod')); fs.symlinkSync( '../../pg-connection-string@2.14.0/node_modules/pg-connection-string', @@ -313,7 +313,7 @@ describe('the injection reads the spec against the dependencies it was built wit ); const consumerModules = path.join(repo, 'packages', 'app', 'node_modules', '@objectstack'); fs.mkdirSync(consumerModules, { recursive: true }); - fs.symlinkSync(installed, path.join(consumerModules, 'spec')); + fs.symlinkSync(fixtureSpec, path.join(consumerModules, 'spec')); fs.writeFileSync( path.join(repo, 'packages', 'app', 'package.json'), JSON.stringify({ name: 'app', dependencies: { '@objectstack/spec': '^17.4.0' } }), @@ -375,7 +375,7 @@ describe('the injection reads the spec against the dependencies it was built wit { encoding: 'utf8', env: { ...process.env, GITHUB_STEP_SUMMARY: path.join(root, 'summary.md') } }, ); const summary = () => fs.readFileSync(path.join(root, 'summary.md'), 'utf8'); - return { repo, store, entry, installed, upstream, run, summary }; + return { repo, store, entry, installed: fixtureSpec, upstream, run, summary }; } it('re-points an unsatisfied sibling at the copy the objectstack checkout resolved, and reports it', () => { diff --git a/scripts/__tests__/vite-objectstack-spec-dist.test.ts b/scripts/__tests__/vite-objectstack-spec-dist.test.ts index c207932dce..c2174d1e29 100644 --- a/scripts/__tests__/vite-objectstack-spec-dist.test.ts +++ b/scripts/__tests__/vite-objectstack-spec-dist.test.ts @@ -24,7 +24,7 @@ import { * * 1. **Set → every subpath is mapped.** The client hook this mirrors is one * prefix alias, which is safe only because `@objectstack/client` exports a - * single entry. The spec's map has 19 and redirects each into `dist/`, so a + * single entry. The spec's map has 20 and redirects each into `dist/`, so a * copied client line rewrites `@objectstack/spec/ui` to a path that does not * exist — measured as 214 broken import sites for `/ui` alone. The * reconciliation case below therefore checks the derivation against Node's @@ -246,14 +246,19 @@ describe('objectui#4854: OBJECTSTACK_SPEC_DIST is subpath-aware', () => { ) as { exports: Record }; const declared = Object.keys(manifest.exports); - // Anti-vacuity: the map this is reconciled against is the measured 19-entry + // Anti-vacuity: the map this is reconciled against is the measured 20-entry // one, not an empty object a silently-changed reader would also "cover". // 18 -> 19 on the @objectstack/spec 17.2.0 refresh (objectui#5668): the // one added subpath, measured by diffing 17.1.0's exports map against // 17.2.0's, is `./meta-spelling` — nothing was removed. The pin did its // job on that bump: the un-updated 18 turned this red in CI rather than // letting the new entry ride through unreconciled. - expect(declared.length).toBe(19); + // 19 -> 20 on the @objectstack/spec 17.5.0 refresh (objectui#11073), + // measured by diffing 17.4.0's exports map against 17.5.0's: `./cloud` was + // REMOVED (the last step of the objectstack#16325 chain objectui#8225 names + // below), `./api-assembled` and `./marketplace` were ADDED. The pin did its + // job again: the un-updated 19 turned this red on the bump's CI. + expect(declared.length).toBe(20); expect(Object.keys(injection!.aliases).length).toBe(declared.length); // objectui#9408 — the population splits, and WHICH oracle applies is the @@ -271,13 +276,16 @@ describe('objectui#4854: OBJECTSTACK_SPEC_DIST is subpath-aware', () => { const browserKeys = declared.filter(declaresBrowser); const nodeOracleKeys = declared.filter((k) => !declaresBrowser(k)); - // Anti-vacuity, both halves. 5 on @objectstack/spec 17.4.0 (`.`, `./data`, - // `./system`, `./kernel`, `./cloud`) — pinned exactly, like the 19 above, + // Anti-vacuity, both halves. 5 on @objectstack/spec 17.5.0 (`.`, `./data`, + // `./system`, `./kernel`, `./api-assembled`). Still 5 after the 17.5.0 + // refresh (objectui#11073) but NOT the same 5: 17.4.0's set carried + // `./cloud`, which left with its subpath, and the new `./api-assembled` + // arrived browser-first. Pinned exactly, like the 20 above, // because an unpinned browser set is precisely how this went unnoticed: // objectui#9408 was FILED naming `./api` as browser-first, and by the time // it was worked `./api` had lost its browser arm upstream with nothing // anywhere to notice the move. A bump re-pins this and says what changed. - expect(browserKeys.sort()).toEqual(['.', './cloud', './data', './kernel', './system']); + expect(browserKeys.sort()).toEqual(['.', './api-assembled', './data', './kernel', './system']); expect(nodeOracleKeys.length).toBe(declared.length - browserKeys.length); expect(nodeOracleKeys.length).toBeGreaterThan(0); @@ -316,8 +324,8 @@ describe('objectui#4854: OBJECTSTACK_SPEC_DIST is subpath-aware', () => { it('agrees with a REAL Vite build on every entry, `browser` arm included', async () => { // The oracle that actually counts. Vite is the resolver this hook MODELS, - // and unlike Node it satisfies `browser`, so it can answer for all 19 - // entries where Node can only answer for 14. Run through a real build + // and unlike Node it satisfies `browser`, so it can answer for all 20 + // entries where Node can only answer for 15. Run through a real build // rather than a transcription, for the same reason the alias-matcher cases // below bundle for real: a transcribed algorithm agrees with its own // transcription, not with Vite. @@ -343,7 +351,7 @@ describe('objectui#4854: OBJECTSTACK_SPEC_DIST is subpath-aware', () => { expect(disagreed, 'entries where the hook and Vite pick different files').toEqual([]); // Anti-vacuity: the sweep really did exercise the browser arm, i.e. the - // agreement above is not agreement about 19 Node-arm files. + // agreement above is not agreement about 20 Node-arm files. const browserArm = specifiers.filter((s) => resolved.get(s)?.includes(`${path.sep}browser${path.sep}`)); expect(browserArm.length).toBe(5); }); @@ -838,7 +846,7 @@ describe('objectui#9408: the exports map declares precedence, not this module', // would surface as a bundler error at a pin bump nobody connects to this // file. A report that omitted the decided entries would leave that intact. const injection = inject(installedSpecDir)!; - expect(injection.resolutions).toHaveLength(19); + expect(injection.resolutions).toHaveLength(20); const report = formatConditionReport(injection).join('\n'); expect(report).toContain(injection.packageDir); @@ -852,7 +860,7 @@ describe('objectui#9408: the exports map declares precedence, not this module', const decided = injection.resolutions.filter((r) => r.passedOver.length > 0); expect(decided.map((r) => r.specifier).sort()).toEqual([ SPEC_PACKAGE_NAME, - `${SPEC_PACKAGE_NAME}/cloud`, + `${SPEC_PACKAGE_NAME}/api-assembled`, `${SPEC_PACKAGE_NAME}/data`, `${SPEC_PACKAGE_NAME}/kernel`, `${SPEC_PACKAGE_NAME}/system`, @@ -867,7 +875,7 @@ describe('objectui#9408: the exports map declares precedence, not this module', // Anti-vacuity on the other half: entries with no choice to make are still // reported, with their arm, rather than silently dropped. const forced = injection.resolutions.filter((r) => r.passedOver.length === 0); - expect(forced.length).toBe(19 - decided.length); + expect(forced.length).toBe(20 - decided.length); expect(forced.length).toBeGreaterThan(0); }); @@ -944,7 +952,7 @@ describe('objectui#4854: the four flagged surfaces in the console config', () => const injectedSpecKeys = Object.keys(injected.resolve.alias).filter((k: string) => k === SPEC_PACKAGE_NAME || k.startsWith(`${SPEC_PACKAGE_NAME}/`) ); - expect(injectedSpecKeys).toHaveLength(19); + expect(injectedSpecKeys).toHaveLength(20); expect(injectedSpecKeys[injectedSpecKeys.length - 1]).toBe(SPEC_PACKAGE_NAME); expect(injected.resolve.alias[`${SPEC_PACKAGE_NAME}/ui`]).toBe( path.join(fs.realpathSync(installedSpecDir), 'dist/ui/index.mjs') @@ -1191,7 +1199,7 @@ function consoleShapedBundle( * (`/…/objectstack/packages/spec`, or `/home/runner/work/objectstack/objectstack/ * packages/spec` on CI) has no `@objectstack` segment anywhere, and that is the * one property this fixture has to reproduce. It stays minimal on purpose: the - * exports-map derivation is covered above against the real 19-entry map, and + * exports-map derivation is covered above against the real 20-entry map, and * what these cases need is a legal package at a path of the wrong SHAPE. */ function makeOutOfTreeSpecPackage(): string { diff --git a/scripts/check-eager-closure-budget.mjs b/scripts/check-eager-closure-budget.mjs index a0c66e7062..f61e5d7f54 100644 --- a/scripts/check-eager-closure-budget.mjs +++ b/scripts/check-eager-closure-budget.mjs @@ -417,8 +417,8 @@ import { isEntrypoint } from './invoked-as.mjs'; * header for both builds and the three control rows that show the bytes * LEFT rather than moved. * - * Headroom above {@link BASELINE} is 45,945 bytes — 0.50x - * {@link REGRESSION_THIS_GATE_MUST_CATCH_BYTES}, on the pair objectui#10996 + * Headroom above {@link BASELINE} is 45,263 bytes — 0.50x + * {@link REGRESSION_THIS_GATE_MUST_CATCH_BYTES}, on the pair objectui#11073 * re-pinned. ⚠️ That is arithmetic on two * constants in this file, so it stays true while they do — it is NOT what the * closure has left today, which is smaller by every byte the payload has @@ -602,8 +602,80 @@ import { isEntrypoint } from './invoked-as.mjs'; * itself. Not {@link PER_CHUNK_GZIP_CEILINGS} or {@link PER_CHUNK_BASELINE}: * all four rows passed on the `5d689c3f6` build, and the first table shows them * unmoved. No exemption was added and no import was made lazy. + * + * ## ⚠️ RAISED A THIRD TIME, ON EXPLICIT MAINTAINER RULING (objectui#11088; objectui#11073) + * + * From 3,225,000 over a 3,179,055 baseline to 3,694,000 over 3,648,737, the + * latter measured on 2026-09-29 on `048e7f6` — the tip of objectui#11073's + * branch, the tree that carries `@objectstack/*` 17.5.0 into this repository. + * The ruling, recorded by the director seat as comment 5890854293 on + * objectui#11088 (batch #245 item 2), is the maintainer's 「11088 同意」 to the + * director's recommendation, decision 1 = A: + * + * The ceiling and the headroom assertion in + * `scripts/check-eager-closure-budget.mjs` are raised to the new baseline + * in the same commit of PR objectui#11086, the PR body states what the + * 413.8 KB gzip buys (17.5.0's refusals and schemas, the twenty unblocked + * cards), and two payback cards are filed in the same stroke. + * + * Precedent objectui#5531 (17.1.0, the maintainer's A). Option B — slim the + * console first — was ruled against on a reading of the spec source: the growth + * sits at `@objectstack/spec`'s ROOT entry, mostly the migration and conversion + * registries' retirement rationale and `os migrate meta` guidance strings, so a + * console-side lazy load could not recover the bulk. + * + * ⛔ WHAT THE BYTES ARE — one container, one instrument (`CI=true pnpm --filter + * @object-ui/console exec vite build`, reading + * `apps/console/dist/eager-closure.json`, under `scripts/pm/os-verify-lock.sh`). + * The control is `a5841be35`, the `main` commit this branch last merged, built + * the same way in a worktree of its own; both builds weigh 330 of 2446 chunks. + * + * | chunk | `a5841be35` | `048e7f6` | delta | + * |-------------------------------|--------------:|------------:|---------:| + * | `vendor-objectstack` | 1,237,912 | 1,703,690 | +465,778 | + * | `ui-components` | 277,610 | 277,336 | -274 | + * | `framework` | 72,294 | 72,093 | -201 | + * | `src` | 168,981 | 169,160 | +179 | + * | everything else (50 names) | 1,426,438 | 1,426,458 | +20 | + * | ⇒ aggregate | 3,183,235 | 3,648,737 | +465,502 | + * + * `vendor-objectstack` is the whole of it: the chunk the `@objectstack/*` + * packages land in, grown by the 17.5.0 release, while every other row nets + * -276 bytes across the bump's own edits. ⛔ Not attributed below the chunk: + * the split between the spec's root-entry registries and its schema growth is + * the director's source reading, recorded on objectui#11088 as a read of source + * bytes rather than a build, and it is objectstack#20646's to measure. + * + * WHAT THEY BUY: the console's client-side validation answering as the 17.5.0 + * server does — its refusals (retired keys refused by name, the terminal + * unknown-key refusal, the new object-level checks) and its schemas — so the + * window in which an author, or an AI, writes in the console what the server + * rejects closes; and the twenty cards the ruling counts as waiting on this + * bump. ⛔ It is NOT a new console capability. + * + * ⭐ PAID BACK AT THE SOURCE, and the ruling filed both cards with it: + * objectstack#20646 (the spec root entry stops carrying the migration and + * conversion registries) and objectui#11101 (the metadata-admin-only spec + * validation leaves the console's first screen). When each lands, this ceiling + * and {@link BASELINE} come DOWN together by the recovered amount — a + * re-baseline in the tightening direction, as objectui#9251's was. + * + * Headroom 45,263 bytes = 0.50x {@link REGRESSION_THIS_GATE_MUST_CATCH_BYTES}: + * 3,648,737 + 45,568 = 3,694,305, rounded to the nearest thousand, the rounding + * every re-pin above used. ⛔ The ceiling did not move alone: over the retired + * 3,179,055 baseline, 3,694,000 would carry 514,945 bytes, 5.65x the + * regression, and both the unit test's constant-vs-constant guard and + * {@link evaluateHeadroomSensitivity} red on it — so {@link BASELINE} moved in + * the same commit. + * + * ⛔ What moved with it, and what did not. `vendor-objectstack`'s per-chunk + * ceiling fired on the same build (its row above) and moved in this commit, + * with {@link PER_CHUNK_BASELINE}, under the same ruling. The other three + * per-chunk rows passed and did not move. Not + * {@link REGRESSION_THIS_GATE_MUST_CATCH_BYTES}. No exemption was added and no + * import was made lazy. */ -export const MAX_EAGER_CLOSURE_GZIP_BYTES = 3_225_000; +export const MAX_EAGER_CLOSURE_GZIP_BYTES = 3_694_000; /** * The measurement the ceiling above was derived from. Exported so the two @@ -616,44 +688,40 @@ export const BASELINE = Object.freeze({ /** * `emitEagerClosureReport`'s `eagerGzipBytes` on this commit. * - * `5d689c3f6` is `origin/main` itself, read with NO diff applied, and it is - * the first commit in this position that is an ordinary `main` commit rather - * than a branch tip. That follows from the change, not from a new convention: - * objectui#10996's whole diff is this file and its unit test, and the - * `scripts/vite-*.ts`-versus-`scripts/check-*.mjs` argument the earlier - * entries made (`bbf6b02d9`, `755d34a5f`, `34a1578ef`, and `3d257c85a` / - * `bd2a7ec50` before them) holds: nothing in either file reaches the bundler, - * so the ceiling edit cannot have moved the figure it pins, and the tree read - * here is byte-for-byte the bundler input of the branch that carries it. + * `048e7f6` is the tip of objectui#11073's branch — the tree carrying the + * `@objectstack/*` 17.5.0 bump — read with every bundler-relevant edit of that + * pull request applied, under the ruling recorded on + * {@link MAX_EAGER_CLOSURE_GZIP_BYTES}. It is a branch tip again rather than + * a `main` commit, as the entries before objectui#10996 were, because here the + * pull request's OWN diff is what moved the figure: its bytes are the subject. + * The `scripts/vite-*.ts`-versus-`scripts/check-*.mjs` argument holds for the + * re-pin itself: the commit that edits this file and its unit test reaches + * no bundler input, so it cannot move the figure it pins. * - * ⚠️ `bbf6b02d9`, the previous baseline, read 3,133,419 across 329 of 2309 - * chunks, and it is the one to compare against when reading the deltas. What - * lies between the two — two control builds, the rows that moved and the rows - * that did not — is recorded once, under objectui#10996's entry on + * ⚠️ `5d689c3f6`, the previous baseline, read 3,179,055 across 330 of 2446 + * chunks on `main`, and it is the one to compare against when reading the + * deltas. What lies between the two — a control build of `a5841be35`, the + * `main` commit the branch last merged, and the rows that moved and the rows + * that did not — is recorded once, under objectui#11073's entry on * {@link MAX_EAGER_CLOSURE_GZIP_BYTES}, ⛔ not restated here. * - * ⚠️ The reading is of `5d689c3f6`, ⛔ not of whatever `main` is when this - * lands. CI weighs the pull-request MERGE ref, so the two differ by whatever - * lands on `main` between the reading and the merge — and the merge that - * carries this change is not named here, for the reason `squashMerge` below - * gives. + * ⚠️ The reading is of `048e7f6`, ⛔ not of whatever `main` plus this branch + * is when it lands. CI weighs the pull-request MERGE ref, so the two differ by + * whatever lands on `main` between the reading and the merge. * - * Measured by `pnpm --filter @object-ui/console exec vite build` (exit 0, with - * `CI=true` as the workflow's runner sets it) reading - * `apps/console/dist/eager-closure.json`, under `scripts/pm/os-verify-lock.sh`. - * The same instrument, on the same day, read `4b742f41d` back to the byte the - * seat recorded on objectui#10996 — the calibration. ⛔ Not taken from CI's - * report and not extrapolated. + * Measured by `CI=true pnpm --filter @object-ui/console exec vite build` + * (exit 0) reading `apps/console/dist/eager-closure.json`, under + * `scripts/pm/os-verify-lock.sh`, in the same container and the same hour as + * the control build. ⛔ Not taken from CI's report and not extrapolated. * - * PROVENANCE resolves from a `main` checkout with `git` alone: - * `git cat-file -t 5d689c3f6` answers `commit`, which the retired - * `bbf6b02d9` never could — that tip was squash-merged away, and objectui#9355 - * added `squashMerge` below as the handle that resolved in its place. + * PROVENANCE: `048e7f6` resolves from a checkout that fetched the branch, and + * not from `main` once it is squash-merged — the dead end objectui#9355 added + * `squashMerge` below to route around. */ - gzipBytes: 3_179_055, + gzipBytes: 3_648_737, chunks: 330, totalChunks: 2446, - commit: '5d689c3f6', + commit: '048e7f6', /** * The squash merge that carried the reading above onto `main`, recorded when @@ -661,25 +729,18 @@ export const BASELINE = Object.freeze({ * (objectui#9355): a sha that DOES resolve, so a reader re-checking the * measurement gets a handle rather than a dead end. * - * `null` since objectui#10996, deliberately. This field can only ever be - * BACK-FILLED: a squash sha does not exist until the pull request merges, so - * the change that re-pins the field above ⛔ cannot write its own here. ⛔ Do - * not guess one, and ⛔ do not carry the retired `67485872ed` forward onto a - * reading it was not taken with: a wrong sha in this position is worse than - * an absent one, because it RESOLVES, and a reader who builds the wrong tree - * gets a plausible number instead of an error. A follow-up may name the merge - * once it exists. - * - * ⚠️ The dead end this field was added to route around is absent this time: - * the field above is itself a `main` commit. So a back-fill here would record - * which merge carried the constants, ⛔ not repair provenance — the reading - * is checkable without it. + * `null` since objectui#10996, and still `null` after objectui#11073, + * deliberately. This field can only ever be BACK-FILLED: a squash sha does + * not exist until the pull request merges, so the change that re-pins the + * field above ⛔ cannot write its own here. ⛔ Do not guess one: a wrong sha in + * this position is worse than an absent one, because it RESOLVES, and a + * reader who builds the wrong tree gets a plausible number instead of an + * error. A follow-up should name the merge once it exists, because the field + * above is a branch tip again and this is its only handle from `main`. * * The ledger case in `scripts/__tests__/check-eager-closure-budget.test.ts` - * that records what this constant carries as data reds on `null`, as it was - * written to. objectui#10996 re-pinned it in the same commit, to exactly the - * one commit this constant now carries, and ⛔ did not widen it to accept - * either shape. + * that records what this constant carries as data reds on a back-fill, as it + * was written to, and is re-pinned to two strings when that happens. */ squashMerge: null, }); @@ -1168,11 +1229,14 @@ export const REGRESSION_THIS_GATE_MUST_CATCH_BYTES = 89 * 1024; * re-measure and lower both numbers together. */ export const PER_CHUNK_GZIP_CEILINGS = Object.freeze({ - // Raised once with the aggregate above, same authorisation, same cause — - // `@objectstack/spec` 17.3.0's browser-dist prose growth lands in THIS chunk. - // Headroom 18,971 bytes = 0.21x REGRESSION_THIS_GATE_MUST_CATCH_BYTES, the - // proportion the retiring pair carried (18,539 = 0.20x). - 'vendor-objectstack': 1_254_000, + // Raised with the aggregate above, under the same ruling (objectui#11088 + // decision 1 = A; objectui#11073): `@objectstack/*` 17.5.0 lands in THIS + // chunk, +465,778 gzipped bytes on the two builds tabled on + // MAX_EAGER_CLOSURE_GZIP_BYTES, and the retired 1,254,000 fired on it. + // Headroom 19,310 bytes = 0.21x REGRESSION_THIS_GATE_MUST_CATCH_BYTES, the + // proportion this key has carried since objectui#7122 (18,971 = 0.21x), and + // it comes down with the aggregate when objectstack#20646 pays it back. + 'vendor-objectstack': 1_723_000, // ⭐ LOWERED, and RE-KEYED, by objectui#7479 — this line used to read // `'i18n-locales': 465_000` and budget TEN catalogues. Nine of them are // `import()`ed on demand now, so the chunk that name pointed at no longer @@ -1301,16 +1365,17 @@ export const PER_CHUNK_GZIP_CEILINGS = Object.freeze({ * Exported so the ceilings are CHECKED against it instead of merely asserted * in this comment. * - * ⚠️ These readings are on DIFFERENT commits from {@link BASELINE} above — + * ⚠️ These readings are on DIFFERENT commits from {@link BASELINE} above, + * except `vendor-objectstack` since objectui#11073 — * `i18n-locale-en` shared BASELINE's commit from objectui#7479 until * objectui#9251, and `ui-components` from objectui#9251 until objectui#10996 * re-pinned the aggregate onto a `main` commit; today neither does — and * WHICH ONE IS LATER flips every time either side is re-baselined, so read the - * commit names, never a direction asserted here. As of objectui#10996 the - * AGGREGATE is the later reading for every key: BASELINE's `5d689c3f6` is - * dated 2026-09-28, against `bbf6b02d9` (2026-09-13, objectui#9251) for - * `ui-components` and `34a1578ef` (2026-09-06, objectui#7122) for - * `vendor-objectstack`. ⚠️ `i18n-locale-en`'s commit was `755d34a5f` when it + * commit names, never a direction asserted here. As of objectui#11073, + * `vendor-objectstack` SHARES the aggregate's reading: BASELINE's `048e7f6`, + * dated 2026-09-29, re-pinned both. The aggregate is the later reading for the + * other keys: `bbf6b02d9` (2026-09-13, objectui#9251) for `ui-components`, + * and `3f775eeb8` for `framework`. ⚠️ `i18n-locale-en`'s commit was `755d34a5f` when it * was taken and the aggregate has moved on since, which is exactly why the two * are named per key rather than described by a direction. * This paragraph asserted the reverse, @@ -1369,8 +1434,10 @@ export const PER_CHUNK_GZIP_CEILINGS = Object.freeze({ * measurement with no ceiling weighs nothing. */ export const PER_CHUNK_BASELINE = Object.freeze({ - // `34a1578ef`, the same build as BASELINE above (objectui#7122). - 'vendor-objectstack': 1_235_029, + // `048e7f6`, the same build as BASELINE above (objectui#11073), re-pinned with + // its ceiling under the same ruling. It supersedes objectui#7122's `34a1578ef` + // reading of 1,235,029. + 'vendor-objectstack': 1_703_690, // `755d34a5f` (objectui#7479) — the SAME console build as // BASELINE above, so the two are directly comparable, and the same instrument // and container as the control build it is subtracted from. It supersedes diff --git a/scripts/check-installed-spec-pin-claims.mjs b/scripts/check-installed-spec-pin-claims.mjs index ef9bcc691e..90ba27f32c 100644 --- a/scripts/check-installed-spec-pin-claims.mjs +++ b/scripts/check-installed-spec-pin-claims.mjs @@ -750,14 +750,6 @@ export const LEDGER = [ class: "historical", why: "\"`publicPicker` arrives in @objectstack/spec 17.0.0 GA\" \u2014 the arrival release. \u26a0\ufe0f The stale claim in the SAME docblock (\"this repo is pinned to `^17.0.0-rc.6`\") is a RANGE and therefore outside this gate's predicate by construction; it is recorded in objectui#8924 rather than silently covered here.", }, - { - file: "packages/plugin-grid/src/__tests__/gridNavigationMembers-8071.test.tsx", - package: "@objectstack/spec", - version: "17.5.0", - sites: 1, - class: "historical", - why: "\"`@objectstack/spec` 17.5.0 retired `view.list.navigation.view` under ADR-0049\" names the RELEASE that removed the key, not what is installed -- the same shape as the `offline-nav-performance-spec-parity` entry below (\"retired that name in 17.0.0-rc.1\"). The marker that puts it in the population is the word `pinned` in \"the row that pinned this\", where it means a TEST ROW pinning a behaviour rather than a version pin; the `pinned-recording-sense` cue does not reach that phrasing. This version is AHEAD of the resolved pin rather than behind it, which is why restamping is not merely unnecessary but false: 17.4.0 is exactly the version where the key still EXISTS, so stamping the sentence at the pin would assert that 17.4.0 retired it. That is the fresh false premise this gate\u0027s docblock warns restamping plants (objectui#9874).", - }, { file: "packages/react/src/hooks/__tests__/offline-nav-performance-spec-parity.test.ts", package: "@objectstack/spec", diff --git a/scripts/check-lint-rule-coverage.mjs b/scripts/check-lint-rule-coverage.mjs index b11814bd3e..e59242187c 100644 --- a/scripts/check-lint-rule-coverage.mjs +++ b/scripts/check-lint-rule-coverage.mjs @@ -564,13 +564,6 @@ export const VACUOUS_GROUPS = [ reason: 'Root-level test helper, the only JS-family file at the repository root besides the config.', card: 'objectui#7908', }, - { - glob: 'e2e/**/*.mjs', - reason: - 'Playwright live-CI pin helpers under e2e/. Inside lint:root\'s population, and the only ' + - 'JS-family files there that are neither repo tooling nor a build-tool config.', - card: 'objectui#7908', - }, { glob: 'packages/*/scripts/**/*.mjs', reason: diff --git a/scripts/check-spec-symbol-derivation.mjs b/scripts/check-spec-symbol-derivation.mjs index 17aa50987d..97f75bd06c 100644 --- a/scripts/check-spec-symbol-derivation.mjs +++ b/scripts/check-spec-symbol-derivation.mjs @@ -476,6 +476,58 @@ const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); // // Key format: ":". const ALLOW = { + "@object-ui/plugin-gantt:ObjectGanttProps": { + reason: + "Two LAYERS under one name, measured when `@objectstack/spec` 17.5.0 began exporting " + + "`ObjectGanttProps` from `@objectstack/spec/ui` (objectui#11073). The spec's type is the " + + "component's AUTHORED props bag (label, objectName, data, staticData, filter, sort, gantt, navigation, skipWeekends, holidays, persistLayout, viewName, markers, criticalPath, showBaselines, readOnly, mobileReadOnly); this one is the React " + + "component's props envelope (schema, dataSource, className, onTaskClick, onRowClick, onEdit, onDelete, onBeforeTaskUpdate), which carries the node as `schema` and adds " + + "host wiring. Neither is assignable to the other, so neither import nor derivation fits. " + + "It is a PUBLISHED export of the package, so the rename the sibling components use " + + "(`ObjectGridComponentProps`, `ObjectKanbanComponentProps`, `ObjectCalendarComponentProps`) " + + "changes a public name and belongs to its own card, not to the dependency bump that " + + "surfaced the collision.", + issue: 11073, + }, + "@object-ui/plugin-map:ObjectMapProps": { + reason: + "Two LAYERS under one name, measured when `@objectstack/spec` 17.5.0 began exporting " + + "`ObjectMapProps` from `@objectstack/spec/ui` (objectui#11073). The spec's type is the " + + "component's AUTHORED props bag (objectName, data, staticData, filter, sort, navigation, map, mapStyle, enableClustering); this one is the React " + + "component's props envelope (schema, dataSource, className, data, onMarkerClick, onRowClick, onEdit, onDelete, enableClustering, clusterRadius), which carries the node as `schema` and adds " + + "host wiring. Neither is assignable to the other, so neither import nor derivation fits. " + + "It is a PUBLISHED export of the package, so the rename the sibling components use " + + "(`ObjectGridComponentProps`, `ObjectKanbanComponentProps`, `ObjectCalendarComponentProps`) " + + "changes a public name and belongs to its own card, not to the dependency bump that " + + "surfaced the collision.", + issue: 11073, + }, + "@object-ui/plugin-timeline:ObjectTimelineProps": { + reason: + "Two LAYERS under one name, measured when `@objectstack/spec` 17.5.0 began exporting " + + "`ObjectTimelineProps` from `@objectstack/spec/ui` (objectui#11073). The spec's type is the " + + "component's AUTHORED props bag (variant, objectName, data, filter, sort, navigation, items, descriptionField, timeline, limit, dateFormat, rowLabel, minDate, maxDate, mapping); this one is the React " + + "component's props envelope (schema, dataSource, className, onRowClick, onItemClick), which carries the node as `schema` and adds " + + "host wiring. Neither is assignable to the other, so neither import nor derivation fits. " + + "It is a PUBLISHED export of the package, so the rename the sibling components use " + + "(`ObjectGridComponentProps`, `ObjectKanbanComponentProps`, `ObjectCalendarComponentProps`) " + + "changes a public name and belongs to its own card, not to the dependency bump that " + + "surfaced the collision.", + issue: 11073, + }, + "@object-ui/plugin-tree:ObjectTreeProps": { + reason: + "Two LAYERS under one name, measured when `@objectstack/spec` 17.5.0 began exporting " + + "`ObjectTreeProps` from `@objectstack/spec/ui` (objectui#11073). The spec's type is the " + + "component's AUTHORED props bag (objectName, data, staticData, filter, navigation, tree); this one is the React " + + "component's props envelope (schema, dataSource, className, onRowClick, data, loading), which carries the node as `schema` and adds " + + "host wiring. Neither is assignable to the other, so neither import nor derivation fits. " + + "It is a PUBLISHED export of the package, so the rename the sibling components use " + + "(`ObjectGridComponentProps`, `ObjectKanbanComponentProps`, `ObjectCalendarComponentProps`) " + + "changes a public name and belongs to its own card, not to the dependency bump that " + + "surfaced the collision.", + issue: 11073, + }, "@object-ui/types:ActionSchema": { reason: "`crud.ts`'s explicitly @deprecated legacy action shape, kept for backward " +