Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .changeset/tombstone-aware-spec-parity-3809.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
---
---

Test-only (objectui#3809). The gates that ask "does `@objectstack/spec` accept this key?" now
subtract ADR-0087 D2 tombstones instead of reading raw `Object.keys(schema.shape)`, and the
judgement lives in one place: `@object-ui/test-support`'s `spec-tombstones` module.

Retiring an authorable key upstream does not delete it. `retiredKey()` replaces the member with
`z.never().optional()` on purpose — a deleted key is silently stripped by a non-strict parse,
while a `never` member fails `tsc` at the authoring site and raises the upgrade prescription on
parse. So a retired key stays in the shape, and every gate deriving "the accepted keys" from raw
`Object.keys` was answering a different question than its name claimed. In
`apps/console/src/__tests__/registry-inputs-spec-parity.test.ts` both parity directions read that
one set and failed opposite ways on it: the forward direction (a block may not declare a key the
spec rejects) counted the tombstone as accepted and went falsely GREEN, while the reverse
direction (every spec key must be discoverable) counted it as declared and went falsely RED,
demanding that a block publish a key the contract refuses by name.

Not dormant, contrary to the issue's premise: it was filed against `17.0.0-rc.5`, which carried no
tombstone in `ComponentPropsMap`, and the `17.0.0-rc.6` pin (objectui#4167) brought eight —
`page:header.icon`, `page:card.actions`, `page:card.body`, `page:tabs.type`,
`record:details.layout` and the `element:record_picker` `displayField` / `searchFields` /
`multiple` trio. The `17.0.0` GA pin (objectui#4636), which landed while this change was in
flight, carries the same eight; both pins were measured. The reverse direction's red was live from
rc.6 onward, absorbed key by key by eight
explicit exemptions that each named this issue as the only thing that could clear them. All eight
are deleted here — not by hand-picking, but because the narrowing makes the existing
dangling-and-stale checks report every one of them. The mechanism is self-clearing from now on: a
key upstream retires later leaves the accepted set on arrival and takes any exemption covering it
with it, no follow-up issue required.

`packages/layout/src/__tests__/page-header-authorable-keys.test.tsx` drops its local copy of the
probe for the shared one, as its own note asked; the shared judge adds a second recognition
channel (the `[REMOVED]` marker `retiredKey()` stamps on the description, OR-ed with the
structural criterion so neither can quietly go permissive) and is calibrated once against what
the installed contract's `safeParse` really rejects. Both consuming gates assert the derivation's
premise — that a retirement KEEPS the member — so if upstream ever retires by deleting keys, the
filters are judged dead code instead of silently narrowing nothing. Two further local copies of
the same judgement remain in `packages/plugin-detail` and `packages/app-shell`, both correct
today; objectui#4947 tracks converting them. No published behaviour changes, so this declares no
release.
1 change: 1 addition & 0 deletions apps/console/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@
"@object-ui/plugin-view": "workspace:*",
"@object-ui/providers": "workspace:*",
"@object-ui/react": "workspace:*",
"@object-ui/test-support": "workspace:*",
"@object-ui/types": "workspace:*",
"@objectstack/client": "^17.0.0",
"@objectstack/spec": "^17.0.0",
Expand Down
447 changes: 287 additions & 160 deletions apps/console/src/__tests__/registry-inputs-spec-parity.test.ts

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions packages/layout/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
},
"devDependencies": {
"@object-ui/sdui-parser": "workspace:*",
"@object-ui/test-support": "workspace:*",
"@vitejs/plugin-react": "^6.0.5",
"react-router-dom": "^7.18.2",
"vite": "^8.2.1",
Expand Down
63 changes: 41 additions & 22 deletions packages/layout/src/__tests__/page-header-authorable-keys.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,51 +59,64 @@
* did, which is why upstream retired the key at all). The carve-out is a named,
* issue-backed, self-clearing list rather than a silent pass — see
* `RENDERER_OWN_DECLARED`.
*
* THE JUDGE IS SHARED NOW — objectui#3809, and this file's own note asked for it
* ("the criterion here is deliberately the same one objectui#3809 converges on
* repo-wide; when that lands, this local helper is what it replaces"). The local
* `z.never()` probe is gone, replaced by `@object-ui/test-support`'s. Three
* things change with it, none of them a loosening:
*
* - the same criterion now serves the repo-wide parity gate
* (`apps/console/src/__tests__/registry-inputs-spec-parity.test.ts`), where
* its absence was a live FALSE GREEN in one direction and a FALSE RED in the
* other. This file had the fix and could not lend it — that is what a copied
* judgement costs;
* - recognition gained a second channel (the `[REMOVED]` marker `retiredKey()`
* stamps on the description), OR-ed with the structural one, so a Zod
* internals rework can no longer silently turn the probe permissive;
* - the probe is calibrated once, centrally, against what the contract's own
* `safeParse` actually rejects — `packages/test-support/src/__tests__/
* spec-tombstones.test.ts`. The local non-vacuity test below is KEPT anyway:
* it pins this file's own subject (`icon` retired, `title` live), which no
* amount of central calibration can state.
*/

import { describe, it, expect, beforeAll } from 'vitest';
import { render, screen } from '@testing-library/react';
import { ComponentRegistry } from '@object-ui/core';
import { PageHeaderProps as SpecPageHeaderProps } from '@objectstack/spec/ui';
import {
authorableShapeKeys,
isShapeKeyTombstoned,
listedShapeKeys,
shapeMemberTypeName,
} from '@object-ui/test-support';

import { registerLayout, PageHeader } from '../index';

/** Every key `PageHeaderProps` still LISTS — ADR-0087 tombstones included. */
const specDeclaredKeys = new Set(Object.keys(SpecPageHeaderProps.shape));
const specDeclaredKeys = new Set(listedShapeKeys(SpecPageHeaderProps));

/**
* One `.shape` member's type, unwrapped past `.optional()`.
*
* Walking Zod internals is the only way to ask this question, so the probe is
* guarded by its own non-vacuity test below rather than trusted.
*/
const shapeMemberType = (key: string): string | undefined => {
const shape = SpecPageHeaderProps.shape as unknown as Record<string, unknown>;
const member = shape[key] as { unwrap?: () => unknown } | undefined;
const inner = (typeof member?.unwrap === 'function' ? member.unwrap() : member) as
| { _def?: { type?: string }; def?: { type?: string } }
| undefined;
return inner?._def?.type ?? inner?.def?.type;
};
/** One `.shape` member's type, unwrapped past `.optional()`. */
const shapeMemberType = (key: string): string | undefined =>
shapeMemberTypeName(SpecPageHeaderProps, key);

/**
* Is this key an ADR-0087 D2 tombstone — still listed, but typed `never` so the
* contract rejects every value by name with a migration message?
* Is this key an ADR-0087 D2 tombstone — still listed, but rejected by name with
* a migration message?
*
* The distinction is the whole reason this file changed in objectui#3829. A D2
* retirement does NOT delete the key from the shape; it REPLACES the member with
* `z.never()`. So `Object.keys(shape)` keeps answering "yes, declared" for a key
* the spec refuses — and every assertion below that derived its truth from raw
* `Object.keys` was therefore FALSE GREEN for `icon` from the moment
* @objectstack/spec 17.0.0 retired `PageHeaderProps.icon` (objectstack#6946 /
* PR objectstack#7115). The criterion here is deliberately the same one
* objectui#3809 converges on repo-wide; when that lands, this local helper is
* what it replaces.
* PR objectstack#7115).
*/
const isTombstoned = (key: string): boolean => shapeMemberType(key) === 'never';
const isTombstoned = (key: string): boolean => isShapeKeyTombstoned(SpecPageHeaderProps, key);

/** Authorable keys of the spec node this renderer serves — tombstones excluded. */
const specKeys = new Set([...specDeclaredKeys].filter((key) => !isTombstoned(key)));
const specKeys = new Set(authorableShapeKeys(SpecPageHeaderProps));

/**
* Keys this ALIAS declares on a renderer-read fact the spec no longer carries.
Expand Down Expand Up @@ -176,6 +189,12 @@ describe('the `page-header` registration declares the spec key, not a dialect',
expect(shapeMemberType('title')).toBeTruthy();
expect(isTombstoned('title')).toBe(false);
expect(isTombstoned('icon')).toBe(true);
// THE PREMISE, stated locally (objectui#3809): a D2 retirement KEEPS the
// member. Everything above narrows a set that only needs narrowing while
// that is true — if upstream ever retires by deleting the key, `icon` drops
// out of the listed set, this line reds, and the right response is to remove
// the narrowing rather than to keep filtering nothing.
expect(specDeclaredKeys.has('icon')).toBe(true);
// …and the narrowing is not a no-op, which is the third way this could rot.
expect(specKeys.size).toBeLessThan(specDeclaredKeys.size);
});
Expand Down
19 changes: 19 additions & 0 deletions packages/test-support/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,25 @@ code imports — nothing in `src/` of a released package may import this.
`packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsx`.
- `src/__tests__/dom-leak-judge.test.tsx` — the calibration fixtures that prove
the judge, once, for both gates.
- `src/spec-tombstones.ts` — the ADR-0087 D2 tombstone judge:
`authorableShapeKeys`, `listedShapeKeys`, `tombstonedShapeKeys`,
`isShapeKeyTombstoned`, `tombstoneEvidence`, `shapeMemberTypeName`,
`resolvePropsShape`. Answers "does `@objectstack/spec` still ACCEPT this key,
or does it list a tombstone that rejects it by name?" — the question raw
`Object.keys(schema.shape)` cannot answer, because a retired key stays in the
shape (objectui#3809). Consumed by
`apps/console/src/__tests__/registry-inputs-spec-parity.test.ts` (both parity
directions) and `packages/layout/src/__tests__/page-header-authorable-keys.test.tsx`.
Two more local copies of the same judgement still exist, in
`packages/plugin-detail/src/__tests__/recordDetailsInputs.spec-parity.test.ts`
and `packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config.test.ts`
— both correct today, both structural-channel-only, and both tracked for
conversion by objectui#4947. New gates import this module; they do not add a
fifth copy.
- `src/__tests__/spec-tombstones.test.ts` — the calibration for that judge: one
synthetic fixture per recognition channel (so neither can quietly stop
working), plus a cross-check of the structural verdict against what the
installed contract's own `safeParse` actually rejects.

## Conventions

Expand Down
4 changes: 3 additions & 1 deletion packages/test-support/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@
"lint": "eslint ."
},
"devDependencies": {
"typescript": "^6.0.3"
"@objectstack/spec": "^17.0.0",
"typescript": "^6.0.3",
"zod": "^4.4.3"
},
"repository": {
"type": "git",
Expand Down
Loading
Loading