From 3640aba1a5bec9d48ed512bad79ff4b3ed479082 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 15:25:53 +0000 Subject: [PATCH] refactor(types)!: one ComponentMeta declaration, and deprecate PluginComponentMeta MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `plugin-scope.ts`' `ComponentMeta` becomes `export type { ComponentMeta } from './base.js'` — objectui#4580's ruling applied to the second member of the family, following the shape objectui#5671 executed for the sibling type `ComponentInput` in the same file. Either side had moved: `base.ts` carried eleven keys, the plugin-scoped copy nine — the same nine minus `tags` and `description`. `ComponentMetaSchema` (the zod mirror) already declared all eleven, so two of three authorities agreed and the plugin-facing one did not. The published alias `PluginComponentMeta` is then deprecated at its final meaning, on objectui#5674's two-stage pattern. The ordering is the point: deprecating first would have warned about a name still about to change meaning. Pinned by IDENTITY, not by member set — a member-identical structural copy passes every type-level check (measured: `type-check` exits 0 on one), which is exactly the state objectui#4580 predicted would drift. --- ...-meta-converge-and-deprecate-alias-5893.md | 70 +++++ .../component-meta-single-declaration.test.ts | 250 ++++++++++++++++++ ...lt-children-retired-contract-twins.test.ts | 19 +- packages/types/src/index.ts | 15 ++ packages/types/src/plugin-scope.ts | 62 +++-- 5 files changed, 391 insertions(+), 25 deletions(-) create mode 100644 .changeset/component-meta-converge-and-deprecate-alias-5893.md create mode 100644 packages/types/src/__tests__/component-meta-single-declaration.test.ts diff --git a/.changeset/component-meta-converge-and-deprecate-alias-5893.md b/.changeset/component-meta-converge-and-deprecate-alias-5893.md new file mode 100644 index 0000000000..657412bdca --- /dev/null +++ b/.changeset/component-meta-converge-and-deprecate-alias-5893.md @@ -0,0 +1,70 @@ +--- +'@object-ui/types': minor +--- + +`ComponentMeta` is now declared once and re-exported, and `PluginComponentMeta` is +deprecated in favour of `ComponentMeta` (objectui#5893). + +## The convergence + +`@object-ui/types` published `ComponentMeta` twice, from two different declarations: +`base.ts` and `plugin-scope.ts` (the latter published as `PluginComponentMeta`). They +were structural copies, not an alias pair. `plugin-scope.ts`' `ComponentMeta` is now +`export type { ComponentMeta } from './base.js'` — the disposition objectui#4580 ruled +for the identical shape, *a structural copy would reproduce the defect the moment either +side moved*, and the same move objectui#5671 made for the sibling type `ComponentInput` +in the same file. + +Either side had already moved. `base.ts` declared eleven keys; the plugin-scoped copy +declared nine — the same nine, **minus `tags` and `description`**. So a plugin author +typing against the plugin-facing declaration could not write two keys the main surface +advertises, and which the runtime validator already accepted: `ComponentMetaSchema` in +`zod/base.zod.ts` declares all eleven, so two of the three authorities agreed and the +plugin-facing one did not. `resizeConstraints`' six members were identical in both, so +the delta was exactly those two keys. + +What changes for a consumer: `tags` and `description` become writable on the +plugin-facing type. Nothing narrows — no key is removed and no key's type changes, so no +existing registration stops compiling. The convergence buys **acceptance** of two keys; +it buys no rejection of anything. `ComponentMetaSchema` is a plain `z.object` with no +`.strict()`, so it strips unknown keys rather than refusing them, and that is unchanged +here. + +## The alias deprecation, sequenced after it + +`PluginComponentMeta` — the published alias for the plugin-scoped declaration — is now +`@deprecated` in favour of `ComponentMeta`. **`PluginComponentMeta` is the name to search +for** if you import it; replace it with `ComponentMeta` from the same entry point. + +This is stage 1 of objectui#5674's two-stage retirement (maintainer ruling, 2026-08-22: +deprecate for a release, then remove). Nothing is removed here — the export still exists +and still names the same type. + +The ordering is deliberate and is why the two halves ship together. Until the convergence +above, the alias named a genuinely different nine-key interface; deprecating it then +would have warned consumers about a name that was still about to change meaning. It is +deprecated now, at its final meaning. + +**Why a deprecation window rather than a deletion.** The measurement that licenses +deleting an export from a published package is *"no importer"*, and what can be measured +from inside this repository is only *"no importer here"*. In-repo, `PluginComponentMeta` +has exactly one occurrence — its own export line — searched across every root +(`packages/`, `apps/`, `content/`, `docs/`, `skills/`, `examples/`, `e2e/`, `scripts/`, +`eslint-rules/`, `public/`, `.changeset/` and the root docs) plus the sibling +`objectstack` framework checkout, with controls searched identically so a broken search +could not read as a clean one. What no search here can see is a consumer on npm. **That +external caveat is unchanged from objectui#5674 and is not being dropped:** the window +converts a silent break into a warned one before stage 2 lands. Stage 2 removes the alias +and the now-dead re-export in `plugin-scope.ts` that exists only to feed it, and ships as +a `minor` under this repo's policy that its own breaking changes never declare `major`. + +## Pinned by identity, not by member set + +A new test asserts that `plugin-scope.ts` re-exports the declaration and declares no +`ComponentMeta` of its own. A member-set assertion cannot do this job: TypeScript is +structurally typed, so a local re-declaration carrying the same eleven keys is mutually +assignable with the imported one and passes every type-level check. A member-identical +structural copy is exactly what objectui#4580 predicted would drift and exactly the state +this card recorded — this copy started identical and acquired its two-key delta later. +The member-set checks are kept alongside the identity pin, labelled as the control that +shows what it cannot see. diff --git a/packages/types/src/__tests__/component-meta-single-declaration.test.ts b/packages/types/src/__tests__/component-meta-single-declaration.test.ts new file mode 100644 index 0000000000..55bf809f66 --- /dev/null +++ b/packages/types/src/__tests__/component-meta-single-declaration.test.ts @@ -0,0 +1,250 @@ +/** + * 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. + */ + +/** + * Convergence pin — `ComponentMeta` has ONE declaration (objectui#5893), and + * the second published name for it is deprecated. + * + * ## What was wrong + * + * `index.ts` published `ComponentMeta` twice, from two different declarations: + * `base.ts` (eleven keys) and `plugin-scope.ts` (nine — the same nine, minus + * `tags` and `description`). They were structural COPIES, not an alias pair. + * The runtime validator `ComponentMetaSchema` (`zod/base.zod.ts`) declares all + * eleven, so two of the three authorities agreed and the plugin-facing one did + * not: a plugin author typing against it could not write two keys the main + * surface and the validator both advertise. + * + * That is objectui#4580's ruling coming true rather than being cited — *"a + * structural copy would reproduce the defect the moment either side moved"* — + * and objectui#5671 had already executed the identical convergence for the + * sibling type `ComponentInput` in the same file. + * + * ## Why this pins the DIVERGENCE and not the resulting shape + * + * The obvious test — assert both spellings carry the same member set — is + * NOT sufficient, and the file below demonstrates why rather than asserting + * it. TypeScript is structurally typed: a local `interface ComponentMeta` + * re-declaring the same eleven keys is mutually assignable with the imported + * one, so every type-level check passes on it. A member-identical structural + * copy is EXACTLY the state objectui#4580 predicted would drift, and exactly + * the state this card is the proof of — the copy here started member-identical + * and acquired its two-key delta later. + * + * So the load-bearing assertion is an IDENTITY pin: `plugin-scope.ts` must + * RE-EXPORT the declaration and must not declare a `ComponentMeta` of its own. + * The member-set checks are kept alongside it, labelled, as the control that + * shows the identity pin catches something they cannot. + * + * ## Why the identity half reads SOURCE TEXT + * + * Two reasons, the first structural and the second practical. + * + * There is no type-level operator that distinguishes "the same declaration" + * from "an identical declaration" — structural identity is the whole point of + * TypeScript's type system, and `keyof`-based comparisons erase the difference + * by construction. The distinction survives only in the source and in the + * emitted `.d.ts` (a re-export line versus an `interface` body). + * + * And the emitted `.d.ts` is not available here: this repo's per-PR `test` job + * runs `pnpm test` with no build of the package under test ahead of it (turbo's + * `test` task `dependsOn: ["^build"]` — the DEPENDENCY closure, never the + * package's own build). A test requiring a fresh `dist/` would be vacuously + * absent-or-red on a cold cache, not a signal. Same constraint + * `plugin-component-input-deprecation.test.ts` and + * `package-exports-manifest.test.ts` record, same resolution. + */ + +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { describe, it, expect } from 'vitest'; +import { ComponentMetaSchema } from '../zod/base.zod.js'; +import type { ComponentMeta } from '../base.js'; +import type { ComponentMeta as PluginScopeComponentMeta } from '../plugin-scope.js'; +import type { ComponentMeta as IndexComponentMeta, PluginComponentMeta } from '../index.js'; + +const PLUGIN_SCOPE_SRC = readFileSync( + fileURLToPath(new URL('../plugin-scope.ts', import.meta.url)), + 'utf8', +); + +const INDEX_SRC = readFileSync( + fileURLToPath(new URL('../index.ts', import.meta.url)), + 'utf8', +); + +/** The re-export line that makes `plugin-scope.ts` share `base.ts`' declaration. */ +const RE_EXPORT = "export type { ComponentMeta } from './base.js';"; + +/** + * Any LOCAL declaration of the name, exported or not. The re-export form + * (`export type { ComponentMeta } from …`) cannot match: the brace after + * `type` is not the identifier. + */ +const LOCAL_DECLARATION = /^\s*(?:export\s+)?(?:interface|type|class)\s+ComponentMeta\b/m; + +describe('ComponentMeta — the identity pin (the assertion a structural copy fails)', () => { + it('plugin-scope.ts re-exports the declaration from base.ts', () => { + expect(PLUGIN_SCOPE_SRC).toContain(RE_EXPORT); + }); + + it('plugin-scope.ts declares no ComponentMeta of its own', () => { + // THIS is the pin. It goes red on a local re-declaration even when that + // re-declaration is member-identical — which is the case every type-level + // assertion below stays green on, and the case objectui#4580 ruled about. + expect(PLUGIN_SCOPE_SRC).not.toMatch(LOCAL_DECLARATION); + }); + + it('base.ts is the declaration site, so the pin is not vacuous', () => { + // Control for the regex itself: the same pattern MUST match where the one + // declaration actually lives. A pattern that matched nothing anywhere + // would pass the assertion above on any tree, including a re-diverged one. + const BASE_SRC = readFileSync( + fileURLToPath(new URL('../base.ts', import.meta.url)), + 'utf8', + ); + expect(BASE_SRC).toMatch(LOCAL_DECLARATION); + }); +}); + +describe('ComponentMeta — the member-set control (green on a structural copy, kept to show the contrast)', () => { + it('is mutually assignable across both spellings', () => { + // Type-level, and really compiled: `packages/types/tsconfig.test.json` is + // chained from this package's `type-check` script (#3009). It is ALSO + // exactly what a member-identical local copy would satisfy — recorded here + // as the control, not as the guarantee. + const bothWays: [ + PluginScopeComponentMeta extends ComponentMeta ? true : false, + ComponentMeta extends PluginScopeComponentMeta ? true : false, + ] = [true, true]; + + expect(bothWays).toEqual([true, true]); + }); + + it('reaches the published entry point under both names', () => { + const published: [ + PluginComponentMeta extends IndexComponentMeta ? true : false, + IndexComponentMeta extends PluginComponentMeta ? true : false, + ] = [true, true]; + + expect(published).toEqual([true, true]); + }); +}); + +describe('ComponentMeta — the two keys the convergence delivers', () => { + it('lets a plugin registration write `tags` and `description`', () => { + // The counter-probe the convergence has to survive: a consumer legitimately + // using the converged type must still type-check. "The duplicate is gone" + // is otherwise satisfiable by breaking the type for everyone. + // + // Before objectui#5893 the two annotated keys were a plain TS error on this + // spelling and legal on `base.ts`' — the divergence, at a call site. + const registration: PluginScopeComponentMeta = { + label: 'Kanban Board', + icon: 'layout-board', + category: 'data', + inputs: [{ name: 'columns', type: 'array' }], + isContainer: false, + resizable: true, + tags: ['board', 'kanban'], + description: 'Drag-and-drop board view over a grouped dataset.', + }; + + expect(registration.tags).toEqual(['board', 'kanban']); + expect(registration.description).toBe( + 'Drag-and-drop board view over a grouped dataset.', + ); + }); + + it('agrees with the runtime validator, which already carried both keys', () => { + // The zod mirror is the third authority and it never diverged: it declared + // `tags` and `description` throughout. The convergence brings the plugin + // face up to it rather than moving it. + // + // Note what this does NOT assert: `ComponentMetaSchema` is a plain + // `z.object` with no `.strict()`, so it STRIPS unknown keys rather than + // rejecting them (measured on zod 4.4.3 by + // `default-children-retired-contract-twins.test.ts`). The convergence buys + // ACCEPTANCE of two keys on the plugin-facing type; it buys no rejection of + // anything, here or anywhere else. + const parsed = ComponentMetaSchema.safeParse({ + label: 'Kanban Board', + tags: ['board', 'kanban'], + description: 'Drag-and-drop board view over a grouped dataset.', + }); + + expect(parsed.success).toBe(true); + expect(parsed.data).toHaveProperty('tags', ['board', 'kanban']); + expect(parsed.data).toHaveProperty( + 'description', + 'Drag-and-drop board view over a grouped dataset.', + ); + }); +}); + +/** + * The JSDoc block immediately preceding an export specifier, or `null` when the + * specifier is not preceded by one. Anchored to the specifier rather than + * searching the file for `@deprecated`: this package has many unrelated + * deprecations and a file-wide search would go green on any of them. + * + * Same helper, same reasoning, as `plugin-component-input-deprecation.test.ts`. + */ +function docBlockBefore(specifier: string): string | null { + const at = INDEX_SRC.indexOf(specifier); + if (at === -1) return null; + const before = INDEX_SRC.slice(0, at); + const close = before.lastIndexOf('*/'); + // Only whitespace may sit between the block and the specifier, otherwise the + // block belongs to some earlier specifier and says nothing about this one. + if (close === -1 || before.slice(close + 2).trim() !== '') return null; + const open = before.lastIndexOf('/**', close); + if (open === -1) return null; + return before.slice(open, close + 2); +} + +/** The published alias specifier, exactly as `index.ts` spells it. */ +const ALIAS = 'ComponentMeta as PluginComponentMeta,'; + +describe('PluginComponentMeta — stage 1: deprecated, at its final meaning', () => { + it('is still published (deprecating is not deleting)', () => { + // In-repo the name has zero importers; what cannot be measured from inside + // this repository is an importer on npm, and the window is the answer to + // that unmeasurable half. Stage 2 removes it. + expect(INDEX_SRC).toContain(ALIAS); + }); + + it('carries a JSDoc block attached to the alias specifier itself', () => { + expect(docBlockBefore(ALIAS)).not.toBeNull(); + }); + + it('tags that block `@deprecated`', () => { + expect(docBlockBefore(ALIAS)).toContain('@deprecated'); + }); + + it('names the replacement, so the warning is actionable', () => { + expect(docBlockBefore(ALIAS)).toMatch(/`ComponentMeta`/); + }); + + it('does not deprecate its neighbours in the same export block', () => { + // Control: the tag must attach to ONE specifier. A block comment widened + // to cover the whole `export type { … }` group would make every + // plugin-scope name read as deprecated. + expect(docBlockBefore('AppMetadataPlugin,')).toBeNull(); + expect(docBlockBefore('PluginEventHandler,')).toBeNull(); + }); + + it('is deprecated only AFTER the convergence — the ordering is the point', () => { + // Sequencing pin. Deprecating the alias while it still named a separate + // nine-key declaration would have warned consumers about a name that was + // about to change meaning. The tag is only honest because the re-export + // above it is in place, so the two facts are asserted together. + expect(PLUGIN_SCOPE_SRC).toContain(RE_EXPORT); + expect(docBlockBefore(ALIAS)).toContain('@deprecated'); + }); +}); diff --git a/packages/types/src/__tests__/default-children-retired-contract-twins.test.ts b/packages/types/src/__tests__/default-children-retired-contract-twins.test.ts index 22a76d4cf0..1c464658a7 100644 --- a/packages/types/src/__tests__/default-children-retired-contract-twins.test.ts +++ b/packages/types/src/__tests__/default-children-retired-contract-twins.test.ts @@ -27,6 +27,20 @@ * The Registry twin (`@object-ui/core`) is pinned in that package instead, so * this suite does not have to import its own dependent. * + * ⚠️ UPDATED by objectui#5893: item 3 is no longer a separate declaration. + * `plugin-scope.ts` now RE-EXPORTS `base.ts`' `ComponentMeta` (the objectui#4580 + * convergence, following objectui#5671's execution for `ComponentInput`), so + * the third case below no longer exercises a second declaration — it exercises + * a second published SPELLING of the first, which is a real consumer-facing + * fact only for as long as the deprecated `PluginComponentMeta` alias exists. + * It is deliberately kept rather than deleted: while the alias is published, an + * author can still reach the type by that name, and the pin costs one + * `@ts-expect-error`. Drop it together with the alias in objectui#5674's + * stage 2. Whether the declaration stays single is pinned separately and by + * identity, in `component-meta-single-declaration.test.ts` — a member-set + * assertion cannot see a structural copy, which is the defect this file was + * paying for. + * * Two kinds of assertion, deliberately different because the surfaces differ: * * The Zod half is NOT a refusal. Measured on zod 4.4.3, a `z.object` STRIPS @@ -135,7 +149,10 @@ describe('the published TS twins no longer offer the retired key', () => { const retired: PluginScopeComponentMeta = { label: 'Span', // @ts-expect-error `defaultChildren` was retired by objectui#5051; the - // plugin-facing twin spelt it `any[]` and is retired in lockstep. + // plugin-facing twin spelt it `any[]` and was retired in lockstep. Since + // objectui#5893 this spelling re-exports `base.ts`' declaration, so the + // error above is the SAME one the case above pins, reached by the second + // published name. defaultChildren: [{ type: 'text', content: 'Inline text' }], }; expect(retired.label).toBe('Span'); diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index b6f04576bd..5d592d00a5 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -939,6 +939,21 @@ export type { PluginScopeConfig, AppPluginContext, AppMetadataPlugin, + /** + * @deprecated Use `ComponentMeta` instead. Since objectui#5893 converged the + * plugin-scoped declaration onto `base.ts`, this alias names the SAME type + * under a second name — it carries no information `ComponentMeta` does not. + * Before that convergence it named a genuinely different nine-key interface, + * which is why it is deprecated only now and not alongside + * `PluginComponentInput`: deprecating it earlier would have warned about a + * name that was still about to change meaning. Retirement follows + * objectui#5674's two-stage pattern (maintainer ruling, 2026-08-22: + * deprecate for a release, then remove). This deprecation window exists for + * consumers outside this repository, which cannot be measured from here; + * in-repo the name has zero importers. Removal ships as a `minor` under this + * repo's version policy (objectui's own breaking changes never declare + * `major`). + */ ComponentMeta as PluginComponentMeta, /** * @deprecated Use `ComponentInput` instead. Since objectui#4972 converged the diff --git a/packages/types/src/plugin-scope.ts b/packages/types/src/plugin-scope.ts index dcbf330e71..476b63d6ab 100644 --- a/packages/types/src/plugin-scope.ts +++ b/packages/types/src/plugin-scope.ts @@ -16,7 +16,7 @@ * @packageDocumentation */ -import type { ComponentInput } from './base.js'; +import type { ComponentMeta } from './base.js'; /** * Plugin Scope Interface @@ -145,25 +145,36 @@ export interface PluginScope { /** * Component metadata for registration + * + * The plugin-scoped twin of `base.ts`' {@link ComponentMeta} is no longer a + * twin: it is the SAME declaration, RE-EXPORTED rather than restated + * (objectui#5893), following the shape objectui#5671 executed for the sibling + * type `ComponentInput` in this same file. This is objectui#4580's ruling + * applied to the second member of the family — *a structural copy would + * reproduce the defect the moment either side moved.* + * + * Either side HAD moved, which is why this was not hypothetical. `base.ts` + * carried eleven keys; this copy carried nine — `tags` and `description` were + * missing here. A plugin author typing against the plugin-facing declaration + * could not write two keys the main surface advertises, and which the runtime + * validator (`ComponentMetaSchema` in `zod/base.zod.ts`) already accepted: two + * of the three authorities agreed and this one did not. Nothing a user hits + * was broken today, because no plugin registration had tried to write one yet. + * + * Re-exported under this name so that `index.ts`' public + * `ComponentMeta as PluginComponentMeta` alias keeps naming a real export. + * That alias is `@deprecated` as of the same card, on objectui#5674's pattern: + * with the declaration shared, the second published name carries no + * information the first does not. + * + * Note for whoever completes that retirement: this re-export is a SEPARATE + * binding from the `import type { ComponentMeta }` at the top of the file. The + * import is what types `PluginScope.registerComponent`'s `meta` parameter and + * stays regardless; this re-export's only consumer is the aliased specifier in + * `index.ts`, so it goes dead the moment that alias is deleted and should be + * removed with it. */ -export interface ComponentMeta { - label?: string; - icon?: string; - category?: string; - inputs?: ComponentInput[]; - defaultProps?: Record; - examples?: Record; - isContainer?: boolean; - resizable?: boolean; - resizeConstraints?: { - width?: boolean; - height?: boolean; - minWidth?: number; - maxWidth?: number; - minHeight?: number; - maxHeight?: number; - }; -} +export type { ComponentMeta } from './base.js'; /** * Component input definition @@ -179,11 +190,14 @@ export interface ComponentMeta { * That alias is now `@deprecated` (objectui#5674): with the declaration shared, * the second published name carries no information the first does not. * - * Note for whoever completes that retirement: this re-export is a SEPARATE - * binding from the `import type { ComponentInput }` at the top of the file. - * The import is what types `ComponentMeta.inputs` and stays regardless; this - * re-export's only consumer is the aliased specifier in `index.ts`, so it goes - * dead the moment that alias is deleted and should be removed with it. + * Note for whoever completes that retirement: this file no longer imports + * `ComponentInput` by name at all. Until objectui#5893 it did, to type the + * local `ComponentMeta.inputs`; that local declaration is now itself a + * re-export, so this line is the ONLY mention left and its only consumer is + * the aliased specifier in `index.ts`. It goes dead the moment that alias is + * deleted and should be removed with it — unlike the `ComponentMeta` + * re-export above, whose sibling `import type` is still load-bearing for + * `PluginScope.registerComponent`. */ export type { ComponentInput } from './base.js';