From eb852a052bbe69267003db56920c4b601d4de7a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 18:11:41 +0000 Subject: [PATCH] feat(core): declare component deprecation in registry metadata, readable by a gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A deprecated component type was stated in exactly two places, neither of which a gate, a test or a type can consult: a `console.warn` string literal inside the renderer, and the word "(Deprecated)" inside a human-readable `label`. Both gates that touch component types ask whether the type RESOLVES, and a deprecated type resolves — which is how one was authored 85 times across 27 shipped exemplars with every check green. - `@object-ui/core`: `AuthoringSurface` + `ComponentDeprecation`, the `deprecated` key on `RegistryComponentMetaExtras`, and `Registry.deprecationFor(type, surface)` to read it back. The declaration carries the surfaces it applies to rather than being a boolean, because objectui#4000 ruled that `div`/`span` are deprecated on the JSON authoring surface and are permanent vocabulary of the `kind:'html'` tier. - `@object-ui/components`: `div` and `span` now declare the deprecation their notices already state, and their provenance tests pin the declaration to the runtime `isHtmlTierNode` exemption so neither can move alone. - The catalog ratchet's premise arm stops reading the renderer's source for a console literal and asks the registry; a new arm catches a loaded declaration the hand-kept list omits. Nothing new is deprecated and no build starts failing. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CRJge11jso9TpXRWFt1Z49 --- .../6674-registry-deprecation-declaration.md | 25 ++ .../test/deprecated-component-types.test.ts | 127 ++++++++-- .../div-deprecation-provenance.test.tsx | 35 +++ .../span-deprecation-provenance.test.tsx | 22 ++ .../components/src/renderers/basic/div.tsx | 28 ++- .../components/src/renderers/basic/span.tsx | 18 +- packages/core/src/registry/Registry.ts | 138 +++++++++- .../component-deprecation-declaration.test.ts | 237 ++++++++++++++++++ ...ponent-meta-derives-from-canonical.test.ts | 48 +++- 9 files changed, 638 insertions(+), 40 deletions(-) create mode 100644 .changeset/6674-registry-deprecation-declaration.md create mode 100644 packages/core/src/registry/__tests__/component-deprecation-declaration.test.ts diff --git a/.changeset/6674-registry-deprecation-declaration.md b/.changeset/6674-registry-deprecation-declaration.md new file mode 100644 index 0000000000..fc6178381f --- /dev/null +++ b/.changeset/6674-registry-deprecation-declaration.md @@ -0,0 +1,25 @@ +--- +'@object-ui/components': minor +'@object-ui/core': minor +--- + +Component deprecation is now DECLARED, not just warned about (objectui#6674). + +A deprecated component type used to be stated in exactly two places, neither of +which a gate, a test or a type can consult: a `console.warn` string literal +inside the renderer, and the word "(Deprecated)" inside a human-readable +`label`. Both gates that touch component types ask a different question — +whether the type RESOLVES — and a deprecated type resolves, which is how one +could be authored 85 times across 27 shipped exemplars with every check green. + +- `@object-ui/core` gains `ComponentDeprecation` / `AuthoringSurface` and the + `deprecated` key on the registration metadata, plus + `ComponentRegistry.deprecationFor(type, surface)` to read it back. The + declaration carries the SURFACES it applies to rather than being a boolean: + `div` and `span` are deprecated on the JSON authoring surface and are at the + same time permanent vocabulary of the `kind:'html'` tier, so a bare flag would + be false for one of its two readers. +- `@object-ui/components` marks `div` and `span` with the declaration their + console notices already state. Nothing new is deprecated and no build starts + failing: the catalog ratchet keeps the existing stock frozen, and draining it + stays objectui#3965's worklist. diff --git a/examples/schema-catalog/test/deprecated-component-types.test.ts b/examples/schema-catalog/test/deprecated-component-types.test.ts index 33e3dfb13b..6fb8613a12 100644 --- a/examples/schema-catalog/test/deprecated-component-types.test.ts +++ b/examples/schema-catalog/test/deprecated-component-types.test.ts @@ -28,14 +28,33 @@ * and walks `content/docs` and nothing else (`DOCS_ROOT = 'content/docs'`), * so `examples/**` is outside its scan surface entirely. * - * It is worse than the usual declared-but-unenforced shape: the deprecation is - * not declared anywhere MACHINE-READABLE. `RegistryComponentMetaExtras` carries - * `tier` / `namespace` / `skipFallback` / `labelAssociation` and has no - * `deprecated` field, so the only statements of it are a `console.warn` string - * literal in `div.tsx` / `span.tsx` and the human-readable label - * `'Container (Deprecated)'`. `DEPRECATED_TYPES` below is therefore a hand-kept - * mirror, and `the deprecation this ratchet mirrors is still declared` is the - * arm that stops the mirror from outliving the thing it mirrors. + * It was worse than the usual declared-but-unenforced shape: when this file + * landed, the deprecation was not declared anywhere MACHINE-READABLE. + * `RegistryComponentMetaExtras` carried `tier` / `namespace` / `skipFallback` / + * `labelling` and no `deprecated` field, so the only statements of it were a + * `console.warn` string literal in `div.tsx` / `span.tsx` and the + * human-readable label `'Container (Deprecated)'`. This file was therefore + * built on a hand-kept mirror whose premise arm READ THE RENDERER'S SOURCE and + * regex-matched that console literal — the closest thing to asking "is this + * type deprecated?" that existed. + * + * ## What objectui#6674 changed, and what it did not + * + * The registration now DECLARES it: `deprecated: { surfaces: ['json'], + * replacement: … }`, read back through `ComponentRegistry.deprecationFor(type, + * surface)`. So `the deprecation this ratchet mirrors is still declared` asks + * the registry instead of grepping a `.tsx` for a console string, and + * `no LOADED registration declares a deprecation this list omits` is the new + * arm that direction makes possible at all. + * + * ⚠️ `DEPRECATED_TYPES` stays HAND-KEPT on purpose, and deriving it wholesale + * from the registry would be a regression rather than the obvious next step. + * This file loads `@object-ui/components` and nothing else; a type declared + * deprecated by a plugin package it does not import would silently drop out of + * a derived list, and the census would shrink to green. The list is the + * ratchet's authority precisely because it is complete by construction. What + * the declaration buys is that the list can now be CHECKED — in both directions + * — against something a machine can read. * * ## Why this ratchet freezes the stock instead of demanding zero * @@ -114,17 +133,30 @@ import { describe, it, expect } from 'vitest'; import { readdirSync, readFileSync, statSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; +// Registers `div` / `span` (and the rest of the basic set) at module scope, so +// the two arms below can ASK the registry what is deprecated instead of reading +// a renderer's source. Module scope, not a hook — objectui#3010/#3021. +import '@object-ui/components'; +import { ComponentRegistry } from '@object-ui/core'; /** Resolved off this module, so the gate does not depend on the process cwd. */ const SCHEMAS_ROOT = fileURLToPath(new URL('../src/schemas', import.meta.url)); -const COMPONENTS_SRC = fileURLToPath( - new URL('../../../packages/components/src/renderers/basic', import.meta.url), -); /** - * The deprecated JSON-authored component types, mirrored by hand from the - * renderers' notices because the registry carries no `deprecated` flag. Kept - * honest by `the deprecation this ratchet mirrors is still declared`. + * The surface this corpus is authored on. Every fixture under `SCHEMAS_ROOT` is + * JSON metadata, so the question this ratchet asks the registry is scoped to + * it: `div` and `span` are ALSO permanent vocabulary of the `kind:'html'` tier + * (objectui#4000), where the parser compiles the plain tag straight through and + * no other spelling exists to migrate to. A gate that dropped the scope would + * be refusing a spelling that is correct on the other surface. + */ +const CORPUS_SURFACE = 'json' as const; + +/** + * The deprecated JSON-authored component types this ratchet refuses. Hand-kept + * — see the header for why deriving it from the registry would shrink the + * census silently — and now checked in BOTH directions against the + * machine-readable declaration the registrations carry (objectui#6674). */ const DEPRECATED_TYPES = ['div', 'span'] as const; @@ -293,19 +325,60 @@ describe('deprecated component types in the catalog are ratcheted (#3965)', () = it('the deprecation this ratchet mirrors is still declared', () => { // The mirror's premise. If a type is UN-deprecated, this file must die - // loudly rather than keep refusing a spelling that became legal again; - // `DEPRECATED_TYPES` is hand-kept precisely because the registry carries no - // machine-readable flag to derive it from. - for (const type of DEPRECATED_TYPES) { - const source = readFileSync(`${COMPONENTS_SRC}/${type}.tsx`, 'utf8'); - expect( - source, - `renderers/basic/${type}.tsx no longer declares its deprecation notice. ` + - `Either the type was un-deprecated — in which case drop it from ` + - `DEPRECATED_TYPES and retire the matching baseline — or the notice ` + - `moved and this mirror needs re-pointing.`, - ).toContain(`The "${type}" component is deprecated`); - } + // loudly rather than keep refusing a spelling that became legal again. + // + // This arm used to `readFileSync` the renderer and regex-match its + // `console.warn` literal, because that string was one of only two places a + // deprecation was stated and the only one a test could reach. It now asks + // the registry, which is objectui#6674's whole delivery: the question "is + // this type deprecated?" has an asker. + const undeclared = DEPRECATED_TYPES.filter( + (type) => ComponentRegistry.deprecationFor(type, CORPUS_SURFACE) === undefined, + ); + + expect( + undeclared, + 'A type this ratchet refuses no longer DECLARES a deprecation for the ' + + 'json authoring surface. Either it was un-deprecated — in which case ' + + 'drop it from DEPRECATED_TYPES and retire the matching baseline — or ' + + 'the declaration moved and this mirror needs re-pointing. (A type ' + + 'whose `surfaces` no longer lists `json` reads as un-deprecated HERE ' + + 'and is still deprecated elsewhere; that is the objectui#4000 scope ' + + 'working, not a bug in this arm.)', + ).toEqual([]); + }); + + it('no LOADED registration declares a deprecation this list omits', () => { + // The direction the hand-kept mirror could never check. Before the + // declaration existed there was nothing to enumerate: a third deprecated + // type could have been added to `@object-ui/components` with a console + // string and a label, and this file would have gone on refusing exactly two. + // + // Scoped honestly to what this file LOADS — `@object-ui/components`. A + // plugin package's declaration is out of range here, which is the reason + // DEPRECATED_TYPES stays the authority rather than being derived. + // + // Non-vacuity is the arm ABOVE: an empty result here would also be produced + // by a registry that answered `undefined` for everything, and that state + // turns the premise arm red first. The two hold each other up. + const listed = new Set(DEPRECATED_TYPES); + // A namespaced registration answers under BOTH spellings (`ui:div` and + // `div`); the corpus authors the bare one and the baseline is keyed on it. + // Either spelling being listed counts, and the raw key is what gets + // reported so the message names something that exists in the registry. + const bare = (key: string) => (key.includes(':') ? key.slice(key.indexOf(':') + 1) : key); + const missing = ComponentRegistry.getKnownTypes() + .filter((type) => ComponentRegistry.deprecationFor(type, CORPUS_SURFACE)) + .filter((type) => !listed.has(type) && !listed.has(bare(type))) + .sort(); + + expect( + missing, + 'A loaded registration declares a json-surface deprecation that this ' + + 'ratchet does not refuse. Add it to DEPRECATED_TYPES — and if the ' + + 'corpus already authors it, baseline the existing stock in the same PR ' + + 'rather than leaving the type unguarded.', + ).toEqual([]); }); it('the stock is exactly what this card measured, and the exemption is not a hole', () => { diff --git a/packages/components/src/__tests__/div-deprecation-provenance.test.tsx b/packages/components/src/__tests__/div-deprecation-provenance.test.tsx index 6f015e9fb3..7f0485b2d8 100644 --- a/packages/components/src/__tests__/div-deprecation-provenance.test.tsx +++ b/packages/components/src/__tests__/div-deprecation-provenance.test.tsx @@ -37,6 +37,7 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; import { render } from '@testing-library/react'; +import { ComponentRegistry } from '@object-ui/core'; import { SchemaRenderer } from '@object-ui/react'; // Registers the renderers at module scope, NOT inside a `beforeAll` — there the // cold transform is billed to `hookTimeout`. See @@ -131,4 +132,38 @@ describe('div deprecation notice — scoped by provenance (#4000)', () => { const attrs = Array.from(el!.attributes).map((a) => a.name); expect(attrs.filter((n) => n.includes('provenance') || n.includes('tier'))).toHaveLength(0); }); + + /** + * objectui#6674 — the same scope, DECLARED, so a gate can read what the four + * cases above can only demonstrate by rendering. + * + * Before the registration carried `deprecated`, the only statements that this + * type is deprecated were the notice string literal and the word inside + * `label`; no gate, test or type could consult either, so both gates that + * touch component types ask whether the type RESOLVES instead — and it does. + * + * This case is the join. Above, the renderer EXEMPTS html-tier nodes at + * runtime; here, the registration DECLARES the identical scope. Asserting + * them in one file is what stops them drifting: widening `surfaces` to + * `['json', 'html']` without touching the exemption, or dropping the + * exemption without narrowing `surfaces`, turns this red — which no + * assertion about either one alone can do. + */ + it('DECLARES the scope those four cases demonstrate — deprecated on json, not on html', () => { + // Deprecated where the notice is fired… + expect(ComponentRegistry.deprecationFor('div', 'json')).toEqual({ + surfaces: ['json'], + replacement: + 'use "card", "flex", or layout components like "container", "stack", or "grid"', + }); + + // …and NOT where the first case above proves the renderer stays silent. A + // declaration that said "deprecated" full stop would be false for the html + // tier, which is the objectui#4000 ruling this pair encodes. + expect(ComponentRegistry.deprecationFor('div', 'html')).toBeUndefined(); + + // The bare and namespaced spellings answer alike, because a corpus authors + // whichever it likes and the gate must not have to know which. + expect(ComponentRegistry.deprecationFor('ui:div', 'json')).toBeDefined(); + }); }); diff --git a/packages/components/src/__tests__/span-deprecation-provenance.test.tsx b/packages/components/src/__tests__/span-deprecation-provenance.test.tsx index 5d8df8b0aa..1c768971d7 100644 --- a/packages/components/src/__tests__/span-deprecation-provenance.test.tsx +++ b/packages/components/src/__tests__/span-deprecation-provenance.test.tsx @@ -58,6 +58,7 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; import { render } from '@testing-library/react'; +import { ComponentRegistry } from '@object-ui/core'; import { SchemaRenderer } from '@object-ui/react'; // Registers the renderers at module scope, NOT inside a `beforeAll` — there the // cold transform is billed to `hookTimeout`. See @@ -158,4 +159,25 @@ describe('span deprecation notice — scoped by provenance (#4917)', () => { const attrs = Array.from(el!.attributes).map((a) => a.name); expect(attrs.filter((n) => n.includes('provenance') || n.includes('tier'))).toHaveLength(0); }); + + /** + * objectui#6674 — the same scope, DECLARED. Level with the sibling case in + * `div-deprecation-provenance.test.tsx`, for the reason objectui#4917 gave + * for bringing this renderer level in the first place: the two carry the same + * ruling, and a fact stated for one of them and not the other is how they + * diverge. + * + * The runtime exemption above and the declaration below are the same fact. + * Moving either alone turns this red. + */ + it('DECLARES the scope those cases demonstrate — deprecated on json, not on html', () => { + expect(ComponentRegistry.deprecationFor('span', 'json')).toEqual({ + surfaces: ['json'], + replacement: + 'use "badge" for labels, or "text" with a className for inline emphasis', + }); + + expect(ComponentRegistry.deprecationFor('span', 'html')).toBeUndefined(); + expect(ComponentRegistry.deprecationFor('ui:span', 'json')).toBeDefined(); + }); }); diff --git a/packages/components/src/renderers/basic/div.tsx b/packages/components/src/renderers/basic/div.tsx index c3ca5fba39..5c0bdb67d6 100644 --- a/packages/components/src/renderers/basic/div.tsx +++ b/packages/components/src/renderers/basic/div.tsx @@ -103,11 +103,37 @@ const DivRenderer = forwardRef = T; */ export type { ComponentInput } from '@object-ui/types'; +/** + * An AUTHORING SURFACE a component type can be reached from — the two this + * engine has, named as its own code already names them. + * + * - `'json'` — the JSON/SDUI authoring surface. A node an author (or an AI) + * writes as `{ "type": "…" }` in metadata. + * - `'html'` — a `kind:'html'` page, written as constrained JSX text that + * `@object-ui/sdui-parser` COMPILES (never executes) into SDUI nodes, tag + * name straight through. `isHtmlTierNode` marks what that parser emitted. + * + * The distinction is not decorative here: it is the whole reason + * {@link ComponentDeprecation} carries a surface list rather than a boolean. + */ +export type AuthoringSurface = 'json' | 'html'; + +/** + * That a component type is DEPRECATED for authoring — the machine-readable + * statement of it (objectui#6674). + * + * ## Why this exists at all + * + * Before this type, a deprecation was stated in exactly two places, neither of + * which any gate, test or type can consult: a `console.warn` STRING LITERAL + * inside the renderer, and the word "(Deprecated)" inside a human-readable + * `label`. So the question "is this type deprecated?" had no asker. The two + * gates that touch component types — `examples/schema-catalog/test/ + * catalog-gallery-render.test.tsx` and `scripts/check-doc-component-types.mjs` + * — both ask only whether a type RESOLVES, and a deprecated type resolves + * perfectly well. The measurement objectui#6674 filed is what that costs: the + * catalog suite passes 583/583 with 85 authored `div` nodes in the corpus. The + * green was the finding. + * + * This is a layer BELOW the usual "declared but enforced nowhere" defect: there + * was nothing declared to enforce. + * + * ## Why `surfaces` is required, and why a boolean would have been wrong + * + * `deprecated: true` would restate, as a contract, the exact falsehood the + * maintainer ruled against on 2026-08-10 (objectui#4000): the `div` and `span` + * notices are scoped BY PROVENANCE because those tags are deprecated on the + * JSON surface and simultaneously PERMANENT, first-class vocabulary of the + * `kind:'html'` tier — an author there writes the plain tag, our own parser + * maps it straight through, and no other spelling exists for them to migrate + * to. "A notice that says the type is deprecated FULL STOP is therefore false + * for one of its two readers" (`div.tsx`), and the reader it was false for was + * the one who could do nothing about it. A declaration that dropped the scope + * would hand every future gate the same false premise, in a form that is harder + * to see than a console string. So the scope travels WITH the declaration, and + * {@link Registry.deprecationFor} makes callers name the surface they are + * asking about instead of re-deriving the exemption locally. + * + * ## What it deliberately does not do + * + * Nothing here fails a build, and nothing here deprecates anything: this is the + * vocabulary plus the reader. Which types get marked, and when, is + * objectui#3965's to decide — the ordering matters, because marking a type + * while the corpus still authors it 85 times produces a red with nowhere to go. + */ +export type ComponentDeprecation = { + /** + * The surfaces on which authoring this type is deprecated. A surface NOT + * listed keeps the type as first-class vocabulary there — that is the whole + * content of the objectui#4000 ruling, said once, in the declaration. + * + * Required and expected non-empty: an empty list declares a deprecation that + * applies to no reader, which is indistinguishable from not declaring one. + */ + surfaces: AuthoringSurface[]; + /** + * One line of migration guidance for the surfaces above — what to author + * instead. Optional, but it is what makes a gate's failure ACTIONABLE: a gate + * that can only say "deprecated" sends its reader back to the console string + * this declaration exists to replace. + */ + replacement?: string; +}; + /** * The keys the REGISTRY adds on top of the one `ComponentMeta` declaration: - * registration mechanics (`tier` / `namespace` / `skipFallback`) and the - * host-labelling contract (`labelling`). None of the four has a counterpart on + * registration mechanics (`tier` / `namespace` / `skipFallback`), the + * host-labelling contract (`labelling`), and the authoring-time + * `deprecated` declaration. None of the five has a counterpart on * the general type in `@object-ui/types`, and none is being moved there — * publishing registry mechanics on the general type was the alternative * objectui#6067 weighed and rejected. @@ -116,6 +194,19 @@ export type RegistryComponentMetaExtras = { * unlabelled group. */ labelling?: 'control' | 'group' | 'display'; + /** + * That authoring this type is DEPRECATED, and on which surfaces + * (objectui#6674). Absent ⇒ not deprecated anywhere; see + * {@link ComponentDeprecation} for why the surfaces are part of the + * declaration rather than a boolean, and {@link Registry.deprecationFor} for + * the reader a gate asks. + * + * This is a DECLARATION about the TYPE, which is why it sits on the + * registration rather than on a node: every node of a deprecated type is + * deprecated, and the one place that fact can be stated once is where the + * type is registered. + */ + deprecated?: ComponentDeprecation; }; /** @@ -547,6 +638,49 @@ export class Registry { return this.components.get(key) ?? this.lazyEntries.get(key)?.meta; } + /** + * The deprecation `type` declares FOR `surface` — or `undefined` when it + * declares none there (objectui#6674). + * + * ## The question this makes askable + * + * "Is this type deprecated?" — which, before the {@link ComponentDeprecation} + * declaration existed, nothing could ask. The only two statements of a + * deprecation were a `console.warn` string literal in a renderer and the word + * "(Deprecated)" inside a human `label`; no gate, test or type can consult + * either. Both gates that touch component types ask whether a type RESOLVES, + * and a deprecated type resolves. + * + * ## Why the caller must name a surface + * + * Because the honest answer differs by surface, and a reader that dropped the + * distinction would let every caller re-derive it — which is how the same + * exemption ends up written N times and wrong in N-1 of them. `div` is + * deprecated for JSON-authored pages and is permanent vocabulary of the + * `kind:'html'` tier (objectui#4000). Asking `deprecationFor('div', 'html')` + * therefore correctly answers `undefined` even when the type declares a + * `'json'` deprecation, and a gate over html-tier sources gets the right + * answer without knowing the ruling. + * + * Returning the DECLARATION rather than a boolean is deliberate: the caller + * that has to report the finding also needs `replacement` to say what to + * author instead, and a boolean would send it back to the console string. + * + * Resolution is {@link getMeta}'s, so both spellings of a namespaced + * registration answer alike (`div` and `ui:div`), a `skipFallback` type + * answers only under its namespaced key, and a pending `registerLazy` stub + * answers from the meta it was registered with. + */ + deprecationFor( + type: string, + surface: AuthoringSurface, + namespace?: string, + ): ComponentDeprecation | undefined { + const declared = this.getMeta(type, namespace)?.deprecated; + if (!declared) return undefined; + return declared.surfaces.includes(surface) ? declared : undefined; + } + /** * Get all registered component configurations. * diff --git a/packages/core/src/registry/__tests__/component-deprecation-declaration.test.ts b/packages/core/src/registry/__tests__/component-deprecation-declaration.test.ts new file mode 100644 index 0000000000..9343cb1b9c --- /dev/null +++ b/packages/core/src/registry/__tests__/component-deprecation-declaration.test.ts @@ -0,0 +1,237 @@ +/** + * 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. + */ + +/** + * The question a gate can now ask — "is this type deprecated?" (objectui#6674). + * + * ## What could not be asked before + * + * Nothing. A component deprecation was stated in exactly two places, and no + * gate, test or type can consult either one: + * + * 1. a `console.warn` STRING LITERAL inside the renderer + * (`packages/components/src/renderers/basic/div.tsx`, and the same shape + * in `span.tsx`), and + * 2. the word "(Deprecated)" inside the human-readable `label` + * (`'Container (Deprecated)'`). + * + * Both gates that touch component types ask a DIFFERENT question — whether the + * type RESOLVES. `examples/schema-catalog/test/catalog-gallery-render.test.tsx` + * asserts `ComponentRegistry.get(type)` is truthy and that no OBJUI-001 panel + * paints; `scripts/check-doc-component-types.mjs` asks the same existence + * question over `content/docs/**`. A deprecated type resolves and renders, so + * both stay green. objectui#6674 demonstrated the cost rather than arguing it: + * that catalog suite passes 583/583 with 85 authored `div` nodes in the corpus. + * The green WAS the finding. + * + * This lane's usual defect is "declared but enforced nowhere". This was a layer + * below it: nothing was declared to enforce. + * + * ## What each case here pins + * + * The last case is the one that names the defect directly: a registration whose + * label says "(Deprecated)" and whose renderer warns still answers `undefined`, + * because PROSE IS NOT A DECLARATION. That case would have been green — with + * the identical registration — before this reader existed, in the sense that + * there was no reader to disagree with; it is here so the distinction the card + * draws has a test that states it. + * + * The surface-scoping cases carry the maintainer ruling of 2026-08-10 + * (objectui#4000). `div` is deprecated on the JSON authoring surface and is at + * the same time permanent, first-class vocabulary of the `kind:'html'` tier, + * where our own parser compiles the plain tag straight through and no other + * spelling exists to migrate to. A boolean would have made the declaration + * false for one of its two readers; `deprecationFor` makes every caller name + * the surface it is asking about, so no gate re-derives that exemption locally. + * + * ## Deliberately NOT here + * + * Any assertion that a REAL type is deprecated. This file registers its own + * fixtures into a private `Registry` instance. Marking `div` (or anything else) + * is objectui#3965's decision, and the corpus that would go red for it is + * measured in `examples/schema-catalog/test/catalog-deprecated-types.test.ts`, + * which is also where the corpus-wide gate lives. Nothing in this repo declares + * a deprecation today; this is the vocabulary and the reader. + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { Registry } from '../Registry.js'; +import type { ComponentDeprecation } from '../Registry.js'; + +/** An inert renderer — every case here reads metadata, none of them renders. */ +const NOOP = () => null; + +/** + * A private registry per case. The exported `ComponentRegistry` is a + * process-level singleton shared across every test file in the run, and these + * fixtures must not reach it. + */ +const fresh = () => new Registry(); + +const DIV_LIKE: ComponentDeprecation = { + surfaces: ['json'], + replacement: 'use "card", "flex", or semantic layout components', +}; + +describe('Registry.deprecationFor — the reader (objectui#6674)', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('answers undefined for a registration that declares nothing — and the type IS registered', () => { + const registry = fresh(); + registry.register('plain', NOOP, { namespace: 'probe' }); + + // The control comes FIRST: `undefined` from an unknown type and `undefined` + // from a type that declares no deprecation are the same value, so without + // this the case cannot tell them apart. + expect(registry.has('probe:plain')).toBe(true); + + expect(registry.deprecationFor('probe:plain', 'json')).toBeUndefined(); + expect(registry.deprecationFor('probe:plain', 'html')).toBeUndefined(); + }); + + it('answers with the declaration on a surface the registration names', () => { + const registry = fresh(); + registry.register('boxy', NOOP, { namespace: 'probe', deprecated: DIV_LIKE }); + + expect(registry.deprecationFor('probe:boxy', 'json')).toEqual(DIV_LIKE); + }); + + it('hands back the migration guidance, not just a yes — so a gate can say what to author instead', () => { + const registry = fresh(); + registry.register('boxy', NOOP, { namespace: 'probe', deprecated: DIV_LIKE }); + + // The reader returns the DECLARATION rather than a boolean precisely for + // this: a gate that can only say "deprecated" sends its reader back to the + // console string this declaration replaces. + expect(registry.deprecationFor('probe:boxy', 'json')?.replacement).toBe( + 'use "card", "flex", or semantic layout components', + ); + }); + + it('answers undefined on a surface the registration does NOT name (the objectui#4000 ruling)', () => { + const registry = fresh(); + registry.register('boxy', NOOP, { namespace: 'probe', deprecated: DIV_LIKE }); + + // This is the whole reason the declaration carries surfaces. `div` is + // deprecated for JSON-authored pages and is permanent vocabulary of the + // `kind:'html'` tier, where the parser maps the plain tag straight through. + // A gate sweeping html-tier sources must get "not deprecated" here without + // knowing that ruling. + expect(registry.deprecationFor('probe:boxy', 'html')).toBeUndefined(); + + // Paired control: the same call on the declared surface still answers, so + // the `undefined` above is the SCOPE and not a registration that failed. + expect(registry.deprecationFor('probe:boxy', 'json')).toEqual(DIV_LIKE); + }); + + it('answers on both surfaces when the registration names both', () => { + const registry = fresh(); + const everywhere: ComponentDeprecation = { surfaces: ['json', 'html'] }; + registry.register('gone', NOOP, { namespace: 'probe', deprecated: everywhere }); + + expect(registry.deprecationFor('probe:gone', 'json')).toEqual(everywhere); + expect(registry.deprecationFor('probe:gone', 'html')).toEqual(everywhere); + }); + + it('treats an empty surface list as declaring nothing, on every surface', () => { + const registry = fresh(); + registry.register('empty', NOOP, { + namespace: 'probe', + deprecated: { surfaces: [] }, + }); + + // A deprecation that applies to no reader is indistinguishable from no + // deprecation, and the reader says so rather than leaking a truthy object + // that every caller would then have to re-check. + expect(registry.deprecationFor('probe:empty', 'json')).toBeUndefined(); + expect(registry.deprecationFor('probe:empty', 'html')).toBeUndefined(); + }); + + it('answers under BOTH spellings of a namespaced registration', () => { + const registry = fresh(); + registry.register('boxy', NOOP, { namespace: 'probe', deprecated: DIV_LIKE }); + + // `register` stores a namespaced registration under the bare key too, and a + // corpus authors whichever spelling it likes. Resolution is `getMeta`'s, so + // a gate does not have to reproduce the fallback rule to ask the question. + expect(registry.deprecationFor('boxy', 'json')).toEqual(DIV_LIKE); + expect(registry.deprecationFor('probe:boxy', 'json')).toEqual(DIV_LIKE); + // And through the explicit-namespace parameter, the third spelling. + expect(registry.deprecationFor('boxy', 'json', 'probe')).toEqual(DIV_LIKE); + }); + + it('answers only under the namespaced key when the registration sets skipFallback', () => { + const registry = fresh(); + registry.register('boxy', NOOP, { + namespace: 'probe', + skipFallback: true, + deprecated: DIV_LIKE, + }); + + expect(registry.deprecationFor('probe:boxy', 'json')).toEqual(DIV_LIKE); + // The bare key was never claimed, so this is "unknown type", and the + // control below is what distinguishes that from "declares nothing". + expect(registry.has('boxy')).toBe(false); + expect(registry.deprecationFor('boxy', 'json')).toBeUndefined(); + }); + + it('answers from a registerLazy stub, before the plugin chunk has loaded', () => { + const registry = fresh(); + registry.registerLazy('heavy', () => Promise.resolve(), { + namespace: 'probe', + deprecated: DIV_LIKE, + }); + + // A gate must not have to import every plugin package to learn that one of + // its types is deprecated — that would make the answer depend on how much + // of the registry a given test happened to load. `getMeta` already reads a + // pending stub's meta; this reader inherits that. + expect(registry.deprecationFor('probe:heavy', 'json')).toEqual(DIV_LIKE); + }); + + it('answers undefined for a type nothing registered, without throwing', () => { + const registry = fresh(); + + expect(registry.deprecationFor('no-such-type', 'json')).toBeUndefined(); + }); + + it('is NOT satisfied by prose — a "(Deprecated)" label and a console.warn declare nothing', () => { + const registry = fresh(); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + // The pre-#6674 state of `div`, reproduced exactly: the two statements that + // existed, and no third one. The renderer warns on every render and the + // palette label says the word — and the registry still cannot answer the + // question, which is the finding this card filed. + registry.register( + 'legacy-box', + () => { + console.warn('[ObjectUI] The "legacy-box" component is deprecated.'); + return null; + }, + { namespace: 'probe', label: 'Legacy Box (Deprecated)' }, + ); + + const meta = registry.getMeta('probe:legacy-box'); + expect(meta?.label).toContain('(Deprecated)'); + expect(registry.deprecationFor('probe:legacy-box', 'json')).toBeUndefined(); + + // And the renderer really does warn, so the two prose statements are both + // genuinely present — this is a registration in the shape the card + // describes, not a straw man with the label alone. + const render = registry.get('probe:legacy-box') as () => null; + render(); + expect( + warn.mock.calls.filter((args: unknown[]) => + /is deprecated/.test(String(args[0])), + ), + ).toHaveLength(1); + }); +}); diff --git a/packages/core/src/registry/__tests__/component-meta-derives-from-canonical.test.ts b/packages/core/src/registry/__tests__/component-meta-derives-from-canonical.test.ts index db94013365..b74823aab5 100644 --- a/packages/core/src/registry/__tests__/component-meta-derives-from-canonical.test.ts +++ b/packages/core/src/registry/__tests__/component-meta-derives-from-canonical.test.ts @@ -52,6 +52,13 @@ * member-set checks being insufficient on their own, as * `packages/types/src/__tests__/component-meta-single-declaration.test.ts`. * + * ⚠️ THE TWO READINGS ABOVE ARE RECORDED MEASUREMENTS, taken on the tree + * immediately before and after objectui#6067. They are history and are NOT + * updated as the key set grows — `onlyInCore` has since gained a fifth key, + * `deprecated` (objectui#6674), which the live assertions below carry. Editing + * a recorded reading to match today's tree would destroy the only evidence that + * the assignability pair was green on the diverged one. + * * ## Which declaration these type-level assertions actually read * * The EMITTED one. `packages/core/tsconfig.test.json` sets `"paths": {}`, @@ -103,18 +110,26 @@ describe('ComponentMeta (core registry) — the key-set pin (the assertion a str expect(noCanonicalKeyIsMissing).toBe(true); }); - it('adds exactly the four registry-only keys, named', () => { + it('adds exactly the five registry-only keys, named', () => { // The other half of the symmetric difference, pinned to a literal union // rather than to `keyof RegistryComponentMetaExtras` — comparing the extras - // type against itself would be true by construction. Spelling the four out - // is what catches a fifth key drifting onto the registration surface, and - // what catches one of these four being quietly moved onto the general type + // type against itself would be true by construction. Spelling them out + // is what catches a sixth key drifting onto the registration surface, and + // what catches one of these being quietly moved onto the general type // in `@object-ui/types` (the direction objectui#6067 weighed and rejected: // `skipFallback` and `namespace` are registry mechanics and do not belong // on a type published to every metadata author). + // + // `deprecated` is the fifth, added by objectui#6674. It is registry-only + // for the same reason the other four are: it states something about the + // REGISTRATION (which surfaces may still author this type), and the + // authoring-surface vocabulary it is scoped by is this engine's, not a + // general property of component metadata. This union is the pin the + // dispatch for #6674 flagged in advance — a new key that does not update it + // turns this case red on a change that has nothing to do with it. const registryOnlyKeys: Exact< OnlyOnRegistry, - 'tier' | 'namespace' | 'skipFallback' | 'labelling' + 'tier' | 'namespace' | 'skipFallback' | 'labelling' | 'deprecated' > = true; // And the extras type is the thing that supplies them, so the named type @@ -160,6 +175,7 @@ describe('ComponentMeta (core registry) — the two keys the convergence deliver namespace: 'view', skipFallback: true, labelling: 'group', + deprecated: { surfaces: ['json'], replacement: 'use "object-grid"' }, inputs: [{ name: 'columns', type: 'array' }], isContainer: false, resizable: true, @@ -171,7 +187,7 @@ describe('ComponentMeta (core registry) — the two keys the convergence deliver expect(registration.description).toBe( 'Drag-and-drop board view over a grouped dataset.', ); - // The four registry keys are still writable on the same object — the + // The registry keys are still writable on the same object — the // convergence widened the surface, it did not swap one half for the other. expect([ registration.tier, @@ -179,6 +195,14 @@ describe('ComponentMeta (core registry) — the two keys the convergence deliver registration.skipFallback, registration.labelling, ]).toEqual(['public', 'view', true, 'group']); + // …and the fifth, objectui#6674's. Writing it HERE is what proves the key + // reached the declaration every component registration imports — the exact + // failure mode objectui#6067 was filed about, where a key was legal on the + // canonical type and a TS error at the registration surface. + expect(registration.deprecated).toEqual({ + surfaces: ['json'], + replacement: 'use "object-grid"', + }); }); }); @@ -213,8 +237,14 @@ const CANONICAL_MEMBERS = [ 'description', ]; -/** The four this file legitimately declares. */ -const REGISTRY_MEMBERS = ['tier', 'namespace', 'skipFallback', 'labelling']; +/** The five this file legitimately declares. */ +const REGISTRY_MEMBERS = [ + 'tier', + 'namespace', + 'skipFallback', + 'labelling', + 'deprecated', +]; describe('ComponentMeta (core registry) — the source-identity pin', () => { it('imports the canonical declaration instead of restating it', () => { @@ -236,7 +266,7 @@ describe('ComponentMeta (core registry) — the source-identity pin', () => { expect(restated).toEqual([]); }); - it('still declares the four registry-only members here, so the pattern is live', () => { + it('still declares the five registry-only members here, so the pattern is live', () => { // Control for the regex itself. A pattern that matched nothing anywhere // would pass the assertion above on any tree, including a re-diverged one. const declared = REGISTRY_MEMBERS.filter((m) =>