diff --git a/.changeset/9533-dashboard-bare-key-converge-plugin-namespace.md b/.changeset/9533-dashboard-bare-key-converge-plugin-namespace.md new file mode 100644 index 0000000000..7a62fbebff --- /dev/null +++ b/.changeset/9533-dashboard-bare-key-converge-plugin-namespace.md @@ -0,0 +1,93 @@ +--- +'@object-ui/plugin-dashboard': minor +'@object-ui/cli': minor +--- + +Converge the bare `dashboard` key on `plugin-dashboard`, and retire +`view:dashboard` with a by-name tombstone (objectui#9533). + +**Breaking for authored metadata.** `{ "type": "view:dashboard" }` no longer +renders a dashboard. It now resolves to a refusal that names the spelling and +names its replacement, and `objectui check` reports it as an unknown schema +type. `{ "type": "dashboard" }` and `{ "type": "plugin-dashboard:dashboard" }` +both render `DashboardRenderer`, unchanged and newly-working respectively. + +**What was wrong.** `apps/console` declares the lazy stub for bare `dashboard` +under `plugin-dashboard` — twice, in `preview-gallery.tsx` and +`register-plugins.ts` — while this package registered the renderer as +`view:dashboard`. Neither site passed `skipFallback`, so both also claimed the +bare key (`Registry.register` and `Registry.registerLazy` share the +`meta?.namespace && !meta?.skipFallback` branch, and `registerLazy` has no +collision check at all, so nothing warned). Two consequences followed, and both +were reproduced against a real `Registry` before anything here was written: + +1. **The bare key was double-claimed, phase-dependently.** After the stub step + bare `dashboard` declared namespace `plugin-dashboard`; after the chunk + loaded the same key declared `view`. Which answer a host got depended on when + it asked — the objectui#6416 shape, which converged `plugin-report` off it. +2. **The namespaced stub was never cleared.** `register()` clears the lazy stub + of the type IT registers, and that type was `view:dashboard`. So + `hasLazy('dashboard', 'plugin-dashboard')` read `true` after the package had + fully loaded and `get('dashboard', 'plugin-dashboard')` read `undefined`, + while the generated CLI whitelist listed that spelling as renderable. A node + authored with it could only ever paint `Loading plugin-dashboard:dashboard…` + forever: `Registry.loadLazy` resolves whether or not the loaded module + registered the expected type, and `SchemaRenderer` re-checks `hasLazy` on + every pass. That is the objectui#8760 shape — a key that passes every + authoring check and fails only in front of a user. + +**What changed.** The renderer registers as `plugin-dashboard:dashboard`, the +namespace every sibling plugin, both console stubs and the CLI whitelist already +use, so the console stubs are cleared on load and all three claimants of the bare +key name ONE full type. The retired `view:dashboard` key answers +`RetiredDashboardNodeTombstone` — an inline refusal, plus a `console.error` +carrying the same text — registered with `skipFallback: true` so it claims no +bare key. Its spelling is withheld from the derived key universe by declaration +in `scripts/check-doc-component-types.mjs`, the same disposition the +`RETIRED_FIELD_TYPES` tombstones take, so `objectui check` names it rather than +blessing it; `packages/cli/src/utils/known-schema-types.ts` regenerates and loses +that one entry. + +**Also published: four new symbols on the package entry.** `src/index.tsx` +re-exports `RETIRED_DASHBOARD_NODE_TYPES`, `RetiredDashboardNodeTombstone`, +`reportRetiredDashboardNodeType` and `resetRetiredDashboardNodeTypeReports` from +`./retired-node-types`, and `exports["."]` is what publishes them — so these are +**published** surface, not an internal one, and removing or renaming one later is +a breaking change like any other export. They exist for the same reason +`@object-ui/fields` publishes its tombstone: the pin has to import the refusal +text it asserts rather than restate it. + +**Also visible in the DOM: the `data-obj-type` value on a dashboard grid.** That +attribute carries the type the node was AUTHORED with, so the spelling that puts +a dashboard grid on the page moves with the registration. +`[data-obj-type="view:dashboard"]` no longer selects one — that node now renders +the tombstone, which publishes `data-retired-node-type` and no `data-obj-type` at +all — while `[data-obj-type="plugin-dashboard:dashboard"]` now does. +`{ "type": "dashboard" }` is unchanged and still reads `dashboard`. A stylesheet, +a selector or a DOM assertion keyed on the retired value stops matching, and +stops matching silently, so it is declared here rather than left to be found. + +**Ruled, not chosen.** Director seat summon #24, batch #152 item 5 letter 1, +maintainer 「其他同意」 2026-09-18. The two rejected routes are on the record: +converging on `view` would leave the whitelist advertising a dead +`plugin-dashboard:dashboard`, and standing one side down resolves the naming +contest but not the unsatisfiable stub. + +**`Clause-②: yes`**, declared by that ruling: the published full name moves. Note +the shape, because it is unusual — the accept set **narrows** (an authored +`view:dashboard` stops resolving) and the clause is `yes` anyway, on the +published-name move rather than on a widened surface. `minor` per AGENTS.md's +版本号策略: objectui's own breaking changes take `minor`, with the breaking +semantics written out here. + +`packages/plugin-dashboard/src/__tests__/dashboardBareKeyOwnership.test.tsx` is +the half that outlives the fix. It reads the package's real declared metadata +back out of the registry and replays it into a fresh `Registry` in **both** +orders — eager-then-lazy and lazy-then-eager — checking the bare key's declared +namespace after every step, so order- and phase-independence are properties under +test rather than properties of the file it imports. It also asserts `hasLazy` is +false after the load, and asserts the `view:dashboard` refusal **together with** +the tombstone's own migration text, because a pin that asserts only "no dashboard +rendered" passes identically against a registration that was simply deleted — +the one outcome the ruling refuses. `objectui#9264`'s shrink-only bare-name +collision ledger loses its row: it is now empty. diff --git a/content/docs/plugins/plugin-dashboard.mdx b/content/docs/plugins/plugin-dashboard.mdx index 3607cee3ad..b0fa1826d9 100644 --- a/content/docs/plugins/plugin-dashboard.mdx +++ b/content/docs/plugins/plugin-dashboard.mdx @@ -90,13 +90,13 @@ import '@object-ui/plugin-dashboard'; ### What the side-effect import registers That single import is the whole of registration — there is no components map to -iterate over. Importing the entry runs the eight `ComponentRegistry.register(...)` +iterate over. Importing the entry runs the eight live `ComponentRegistry.register(...)` calls in `packages/plugin-dashboard/src/index.tsx`, which claim exactly these schema types. The keys below are read off those calls. | Namespaced key | Bare-name fallback | Renderer behind it | | --- | --- | --- | -| `view:dashboard` | `dashboard` | `DashboardRenderer` — the widget container | +| `plugin-dashboard:dashboard` | `dashboard` | `DashboardRenderer` — the widget container | | `plugin-dashboard:metric` | `metric` | `MetricWidget` — one KPI value | | `plugin-dashboard:metric-card` | `metric-card` | `MetricCard` — KPI with trend and icon | | `plugin-dashboard:object-metric` | `object-metric` | internal wrapper around `ObjectMetricWidget` — a metric aggregated over an object | @@ -107,9 +107,23 @@ schema types. The keys below are read off those calls. `ComponentRegistry.register` publishes `namespace:type`, and — unless the call passes `skipFallback: true` — the bare `type` as a back-compat fallback -(`packages/core/src/registry/Registry.ts:194`, fallback branch at `:226`). No -call in this package passes `skipFallback`, so each type above resolves under -both spellings; the schema snippets on this page use the bare one. +(`packages/core/src/registry/Registry.ts:194`, fallback branch at `:226`). Every +call behind the table above leaves `skipFallback` unset, so each type there +resolves under both spellings; the schema snippets on this page use the bare one. + +⛔ One further registration is deliberately absent from the table above. Until +objectui#9533 the dashboard renderer was published as `view:dashboard`, while +`apps/console`'s lazy stubs and the CLI's known-type whitelist already spelled it +`plugin-dashboard:dashboard`; the bare `dashboard` key therefore declared one +namespace before the chunk loaded and the other after it, and the +`plugin-dashboard:dashboard` stub was never cleared, so that spelling could never +resolve. The renderer now registers under `plugin-dashboard`, and the retired +`view:dashboard` key is answered by a tombstone widget that refuses BY NAME and +names `plugin-dashboard:dashboard` as its replacement — so an authored +`view:dashboard` gets a visible refusal carrying its own migration, never a +silent fall-through. That tombstone registration passes `skipFallback: true`, so +it claims no bare key, and `view:dashboard` is deliberately NOT a renderable key: +`objectui check` reports it as unknown. The two `object-*` rows name a wrapper rather than an export on purpose. `ObjectMetricBlock` and `ObjectPivotBlock` are internal to `src/index.tsx`: each diff --git a/packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsx b/packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsx index bc892c4df1..a40510f198 100644 --- a/packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsx +++ b/packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsx @@ -49,7 +49,8 @@ * - `plugin-chatbot:chatbot` / `chatbot-enhanced` — 14 attributes each, * objectui#4431 / PR #4485, which also lifted `toDomProps` to * `@object-ui/core` so later cards consume one executor. - * - `view:dashboard` — `DashboardRenderer`'s widget-grid container, 13 + * - `plugin-dashboard:dashboard` (spelled `view:dashboard` until + * objectui#9533) — `DashboardRenderer`'s widget-grid container, 13 * attributes, objectui#4432 / this file's most recent edit. * * The three packages now reading 0 are NOT clean for the same reason, and the @@ -875,7 +876,9 @@ const TARGETS: Readonly> = { { type: 'plugin-dashboard:object-pivot', schemaExtras: { objectName: 'accounts' }, ready: '[data-testid="pivot-empty-state"]' }, { type: 'plugin-dashboard:object-data-table', schemaExtras: { objectName: 'accounts' }, ready: '[data-testid="table-empty-state"]' }, // `DashboardRenderer`'s widget grid — the element its `{...props}` lands on. - { type: 'view:dashboard', ready: '.grid.auto-rows-min' }, + // Spelled `view:dashboard` until objectui#9533 converged the bare `dashboard` + // key onto this package's own namespace; the renderer is the same one. + { type: 'plugin-dashboard:dashboard', ready: '.grid.auto-rows-min' }, ], // objectui#5574 — 159 targets, built above rather than spelled here because // 140 of them need nothing but the shared readiness class. @@ -916,7 +919,16 @@ const OWNED_VIEW_ALIASES: Readonly> = { 'plugin-charts': ['view:chart'], 'plugin-calendar': ['view:calendar'], 'plugin-chatbot': [], - 'plugin-dashboard': ['view:dashboard'], + // objectui#9533 — EMPTY, and measured rather than defaulted. This package's + // dashboard renderer moved out of the shared `view:` namespace onto + // `plugin-dashboard:dashboard`, so it is covered by the prefix case above. + // `view:dashboard` is still a registered key, but what answers it is a + // retirement TOMBSTONE (a refusal alert naming the migration), not a widget: + // it destructures nothing and spreads nothing onto its element, so there is no + // pass-through surface for this sweep to measure. Its contract — the refusal + // and the text of it — is pinned in + // `packages/plugin-dashboard/src/__tests__/dashboardBareKeyOwnership.test.tsx`. + 'plugin-dashboard': [], }; /* ════════════════════════════════════════════════════════════════════════════ diff --git a/packages/cli/src/__tests__/retired-view-dashboard-key-9533.test.ts b/packages/cli/src/__tests__/retired-view-dashboard-key-9533.test.ts new file mode 100644 index 0000000000..67a53f91b3 --- /dev/null +++ b/packages/cli/src/__tests__/retired-view-dashboard-key-9533.test.ts @@ -0,0 +1,126 @@ +/** + * 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 check` on the two spellings objectui#9533 moved — the + * AUTHORING-TIME half of that convergence, on the published surface. + * + * ## Why this pin and not only the derivation's + * + * `scripts/__tests__/known-schema-types-derivation-5115.test.ts` holds the + * generated snapshot against the derivation, which is the MECHANISM. This holds + * the outcome an author observes from outside the repository, in both + * directions, because objectui#9533 moved a key in each: + * + * - `plugin-dashboard:dashboard` was WHITELISTED AND UNRENDERABLE. The console + * declared the lazy stub under that name while the package registered + * `view:dashboard`, so `register()` never cleared the stub and nothing was + * ever stored under it. This command was silent on a document that could + * only ever paint `Loading plugin-dashboard:dashboard…` — the objectui#8760 + * shape, whose grading is the line this pin sits on: "an unknown key is + * refused loudly at authoring time; a registered-but-unfulfilled key passes + * every check, is taught by the documentation, and fails only at render in + * front of a user." + * - `view:dashboard` was the package's published full name and is now RETIRED. + * It stays registered at runtime — a tombstone that refuses by name and + * names its replacement — but its keys are WITHHELD from the derived + * universe by declaration (`INDIRECT_REGISTRATIONS` in + * `scripts/check-doc-component-types.mjs`, the disposition objectui#9717 + * holds open), precisely so that this command NAMES it instead of blessing + * it. Blessing it here would rebuild the defect the card removed, one + * spelling over. + * + * ## The controls + * + * Silence is one of the two readings under test, so the silent cases must be + * individually non-zero — a command that judged nothing at all would pass them. + * Bare `dashboard` and `plugin-dashboard:dashboard` are each asserted to be IN + * the shipped vocabulary before their documents are checked. + * + * Fixtures live under `os.tmpdir()` and carry the `className` marker, for the + * two reasons `check-known-types.test.ts` states at length: a fixture inside + * this workspace would be scanned by the repo's own `pnpm check`, and a file + * with no ObjectUI marker key is not judged at all (objectui#5127) — which would + * leave the silence assertions green while measuring nothing. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +import { check } from '../commands/check.js'; +import { KNOWN_SCHEMA_TYPES } from '../utils/known-schema-types.js'; + +let cwd: string; +let lines: string[]; +let restoreLog: () => void; + +/** The escape byte chalk opens a CSI sequence with, spelled rather than typed. */ +const ESC = String.fromCharCode(27); +const ANSI = new RegExp(`${ESC}\\[[0-9;]*m`, 'g'); + +function writeSchema(name: string, type: string): void { + writeFileSync(join(cwd, name), JSON.stringify({ className: 'p-0', type, widgets: [] })); +} + +function unknownTypeWarnings(): string[] { + return lines.map((l) => l.replace(ANSI, '')).filter((l) => l.includes('Unknown schema type')); +} + +beforeEach(() => { + cwd = mkdtempSync(join(tmpdir(), 'objectui-check-9533-')); + lines = []; + const original = console.log; + console.log = (...args: unknown[]) => { + lines.push(args.map(String).join(' ')); + }; + restoreLog = () => { + console.log = original; + }; +}); + +afterEach(() => { + restoreLog(); + rmSync(cwd, { recursive: true, force: true }); +}); + +describe('objectui#9533 — the retired `view:dashboard` spelling is named, not blessed', () => { + it('`objectui check` reports it by name', async () => { + writeSchema('retired.json', 'view:dashboard'); + await check(cwd); + expect(unknownTypeWarnings()).toEqual([ + expect.stringContaining('Unknown schema type "view:dashboard" in retired.json'), + ]); + }); + + it('it is gone from the shipped vocabulary', () => { + // Non-vacuity for the assertion beside it: the snapshot must be a real, + // populated set, or "does not contain" is true of everything. + expect(KNOWN_SCHEMA_TYPES.length).toBeGreaterThan(100); + expect(KNOWN_SCHEMA_TYPES).not.toContain('view:dashboard'); + }); +}); + +describe('objectui#9533 — CONTROL: the spellings that now really resolve stay silent', () => { + it.each(['dashboard', 'plugin-dashboard:dashboard'])( + '`%s` is in the vocabulary and draws no warning', + async (type) => { + // Individually non-zero: each names a key the plugin really registers. + // `plugin-dashboard:dashboard` was in this list BEFORE the fix too — what + // changed is that it now names a component rather than a stub nothing ever + // fulfilled, which is why the runtime half is pinned next to the plugin + // (`packages/plugin-dashboard/src/__tests__/dashboardBareKeyOwnership.test.tsx`) + // and not here. + expect(KNOWN_SCHEMA_TYPES).toContain(type); + writeSchema('live.json', type); + await check(cwd); + expect(unknownTypeWarnings()).toEqual([]); + }, + ); +}); diff --git a/packages/cli/src/utils/known-schema-types.ts b/packages/cli/src/utils/known-schema-types.ts index 45bf0e990f..4612b4be18 100644 --- a/packages/cli/src/utils/known-schema-types.ts +++ b/packages/cli/src/utils/known-schema-types.ts @@ -646,7 +646,6 @@ export const KNOWN_SCHEMA_TYPES: readonly string[] = [ 'view-switcher', 'view:calendar', 'view:chart', - 'view:dashboard', 'view:detail', 'view:drawer', 'view:filter-ui', diff --git a/packages/plugin-dashboard/README.md b/packages/plugin-dashboard/README.md index 57c8d91668..66d0a9e5be 100644 --- a/packages/plugin-dashboard/README.md +++ b/packages/plugin-dashboard/README.md @@ -66,13 +66,13 @@ const schema = { ### What the side-effect import registers That single import is the whole of registration — there is no components map to -iterate over. Importing the entry runs the eight `ComponentRegistry.register(...)` +iterate over. Importing the entry runs the eight live `ComponentRegistry.register(...)` calls in `src/index.tsx`, which claim exactly these schema types. The keys below are read off those calls: | Namespaced key | Bare-name fallback | Renderer behind it | | --- | --- | --- | -| `view:dashboard` | `dashboard` | `DashboardRenderer` — the widget container | +| `plugin-dashboard:dashboard` | `dashboard` | `DashboardRenderer` — the widget container | | `plugin-dashboard:metric` | `metric` | `MetricWidget` — one KPI value | | `plugin-dashboard:metric-card` | `metric-card` | `MetricCard` — KPI with trend and icon | | `plugin-dashboard:object-metric` | `object-metric` | internal wrapper around `ObjectMetricWidget` — aggregates over an object | @@ -83,13 +83,27 @@ are read off those calls: `ComponentRegistry.register` publishes `namespace:type`, and — unless the call passes `skipFallback: true` — the bare `type` as a back-compat fallback -(`packages/core/src/registry/Registry.ts:194`, fallback branch at `:226`). No -call in this package passes `skipFallback`, so each type above resolves under -both spellings. The two `object-*` types are served by internal wrappers that +(`packages/core/src/registry/Registry.ts:194`, fallback branch at `:226`). Every +call behind the table above leaves `skipFallback` unset, so each type there +resolves under both spellings. The two `object-*` types are served by internal wrappers that first resolve the spec's per-element `dataSource` binding (through `ElementDataSourceGate` from `@object-ui/react`) and then render the exported component, which is why those rows name a wrapper rather than an export. +⛔ One further registration is deliberately absent from the table above. Until +objectui#9533 the dashboard renderer was published as `view:dashboard`, while +`apps/console`'s lazy stubs and the CLI's known-type whitelist already spelled it +`plugin-dashboard:dashboard`; the bare `dashboard` key therefore declared one +namespace before the chunk loaded and the other after it, and the +`plugin-dashboard:dashboard` stub was never cleared, so that spelling could never +resolve. The renderer now registers under `plugin-dashboard`, and the retired +`view:dashboard` key is answered by a tombstone widget that refuses BY NAME and +names `plugin-dashboard:dashboard` as its replacement — so an authored +`view:dashboard` gets a visible refusal carrying its own migration, never a +silent fall-through. That tombstone registration passes `skipFallback: true`, so +it claims no bare key, and `view:dashboard` is deliberately NOT a renderable key: +`objectui check` reports it as unknown. + ### Registering a component under your own key To serve one of this package's components under a key of your own, register the diff --git a/packages/plugin-dashboard/src/DashboardRenderer.tsx b/packages/plugin-dashboard/src/DashboardRenderer.tsx index 87838d9806..c4405eaf9a 100644 --- a/packages/plugin-dashboard/src/DashboardRenderer.tsx +++ b/packages/plugin-dashboard/src/DashboardRenderer.tsx @@ -1126,7 +1126,9 @@ const DashboardRendererInner = forwardRef }) ?.inputs ?? []).map((i) => i.name); - it.each([undefined, 'view'] as const)( + // ⚠️ The namespaced spelling is `plugin-dashboard`, not `view`: objectui#9533 + // converged the bare `dashboard` key onto the namespace the console stubs and + // the CLI whitelist declare. `view:dashboard` still resolves — to a retirement + // TOMBSTONE that publishes no `inputs` at all — so looking the authoring + // surface up there would read an empty list and pass the "publishes neither" + // half while measuring nothing. The `label` / `widgets` controls below are + // what catch that, and they are why this row moved rather than being dropped. + it.each([undefined, 'plugin-dashboard'] as const)( 'the registration publishes neither, looked up %s', (namespace) => { const declared = inputNames(namespace); diff --git a/packages/plugin-dashboard/src/__tests__/dashboardBareKeyOwnership.test.tsx b/packages/plugin-dashboard/src/__tests__/dashboardBareKeyOwnership.test.tsx new file mode 100644 index 0000000000..bf01359876 --- /dev/null +++ b/packages/plugin-dashboard/src/__tests__/dashboardBareKeyOwnership.test.tsx @@ -0,0 +1,318 @@ +/** + * 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. + */ + +/** + * Which namespace this package registers the dashboard renderer under, who owns + * the bare `dashboard` key, and what an authored `view:dashboard` gets now + * (objectui#9533 — director summon #24, batch #152 item 5, letter 1, + * maintainer-approved). + * + * ## The two consequences this pins, both of them MEASURED before the fix + * + * `apps/console` declares its lazy stubs for bare `dashboard` under + * `plugin-dashboard` (two `registerLazy` loops), and this package used to + * register the renderer as `view:dashboard`. Neither site passed `skipFallback`, + * so both also claimed the bare key — `Registry.register` and + * `Registry.registerLazy` share the `meta?.namespace && !meta?.skipFallback` + * branch. The reproduction against a real `Registry`, run before any of this + * landed, read: + * + * 1. THE BARE KEY WAS DOUBLE-CLAIMED, PHASE-DEPENDENTLY. After the stub step, + * bare `dashboard` declared namespace `plugin-dashboard`; after the chunk + * loaded, the same key declared `view`. Which answer a host got depended on + * when it asked — objectui#6416's shape, in that card's own words. + * 2. THE NAMESPACED STUB WAS NEVER CLEARED. `register()` clears the lazy stub + * of the type IT registers, and that type was `view:dashboard` — so + * `hasLazy('dashboard', 'plugin-dashboard')` read `true` AFTER the package + * had fully loaded and `get('dashboard', 'plugin-dashboard')` read + * `undefined`, while the generated CLI whitelist listed that spelling as + * renderable. A node authored with it could only ever paint + * `Loading plugin-dashboard:dashboard…` forever: `Registry.loadLazy` + * resolves whether or not the loaded module registered the expected type, + * and `SchemaRenderer` re-checks `hasLazy` on every pass. That is the + * objectui#8760 shape, which graded a key that passes every authoring check + * and fails only in front of a user as the wrong side of the line. + * + * ## WHY THE REPLAY (`in EITHER registration order`) + * + * Borrowed whole from `report-bare-key-ownership.test.ts` (objectui#6416) and + * `timeline-bare-key-ownership.test.ts` (objectui#6353): asserting only today's + * resolved outcome cannot tell "declared" apart from "happened to be observed + * after the right step". The replay reads this package's REAL declared metadata + * back out of the registry — nothing here is a hand-copied mirror of `../index` + * — re-registers it into a fresh `Registry` alongside a console-shaped lazy + * stub, in both orders, and checks the bare key's declared namespace after EVERY + * step. Order- and phase-independence are then properties under test rather than + * properties of the file this test imports. + * + * ## The refusal is asserted WITH its text, deliberately + * + * A retirement pin that asserts only "the dashboard did not render" passes + * identically against a registration that was simply deleted, which is the one + * outcome the ruling refuses (⛔ not a silent fall-through). So the authored + * `view:dashboard` case renders through the real SDUI host and asserts the + * REFUSAL plus the tombstone's own migration text — read from the table the + * component reads, never restated here. + */ +import * as React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; +import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react'; +import { ComponentRegistry, Registry, type ComponentMeta } from '@object-ui/core'; +// Importing the package entry is what performs the registrations, exactly as a +// host does. The renderer is compared by IDENTITY below, so these pins cannot be +// satisfied by a look-alike. +import { + DashboardRenderer, + RETIRED_DASHBOARD_NODE_TYPES, + RetiredDashboardNodeTombstone, + resetRetiredDashboardNodeTypeReports, +} from '../index'; + +afterEach(cleanup); + +/** + * The consumer-facing spelling: what `apps/console`'s two `registerLazy` loops + * declare and what the CLI whitelist ships. Stated rather than read out of this + * package's own metadata on purpose — deriving it from the thing under test + * would make the comparison circular. + */ +const NS = 'plugin-dashboard'; + +/** The namespace the renderer used to publish under, now a tombstone. */ +const RETIRED_NS = 'view'; + +/** The one short name this card is about. */ +const SHORT = 'dashboard'; + +const COLLISION_WARNING = 'bare-name fallback is being overwritten'; + +/** Every LOADED namespaced key whose short name is `SHORT`. */ +function namespacedKeysFor(short: string): string[] { + return ComponentRegistry.getAllTypes() + .filter((t) => t.includes(':') && t.slice(t.indexOf(':') + 1) === short) + .sort(); +} + +/** + * The registration this package actually declared for a given FULL type — + * component plus meta, read back from the registry rather than restated here. + * + * `type` is dropped because `register` re-derives it from the bare name plus the + * namespace; replaying it would double the prefix. + */ +function declaredRegistration(fullType: string): { component: unknown; meta: ComponentMeta } { + const config = ComponentRegistry.getConfig(fullType); + expect(config, `nothing is registered as "${fullType}"`).toBeDefined(); + const { type: _fullType, component, ...meta } = config!; + return { component, meta: meta as ComponentMeta }; +} + +/** The lazy stub both `apps/console` loops declare for this key. */ +function consoleStubMeta(): ComponentMeta { + return { namespace: NS, category: 'view' } as ComponentMeta; +} + +describe('the dashboard renderer registers under the namespace its consumers declare', () => { + it('bare `dashboard` has exactly two namespaced spellings: the live one and the tombstone', () => { + // Both halves in one assertion so neither can drift: a third namespaced + // spelling is a key the console stubs and the CLI whitelist cannot both + // satisfy, and a MISSING tombstone is a silent fall-through. + expect(namespacedKeysFor(SHORT)).toEqual([`${NS}:${SHORT}`, `${RETIRED_NS}:${SHORT}`]); + }); + + it('the whitelisted `plugin-dashboard:dashboard` key names the real renderer', () => { + // Consequence 2, from the consumer's side: this lookup read `undefined` + // before the fix, for the whole life of the process. + expect(ComponentRegistry.get(SHORT, NS)).toBe(DashboardRenderer); + }); + + it('the bare key resolves to the renderer, and declares the consumers‘ namespace', () => { + expect(ComponentRegistry.get(SHORT)).toBe(DashboardRenderer); + expect(ComponentRegistry.getMeta(SHORT)?.namespace).toBe(NS); + }); + + it('`view:dashboard` resolves to the TOMBSTONE, not to the renderer', () => { + const answered = ComponentRegistry.get(SHORT, RETIRED_NS); + expect(answered).toBe(RetiredDashboardNodeTombstone); + // Stated as its own assertion rather than inferred from the one above: the + // failure this guards is the registration moving back, and "is not the + // renderer" is the sentence that fails then. + expect(answered).not.toBe(DashboardRenderer); + }); +}); + +// The pin the card is actually about. Both rows replay the SAME declared +// metadata into a fresh registry; only the sequence differs. The bare key's +// declared namespace is checked after EVERY step, so a mismatch between the stub +// and the real registration reddens here even though each step on its own +// succeeds. +const ORDERS: Array<[label: string, order: Array<'stub' | 'eager' | 'tombstone'>]> = [ + [ + 'stub first — what the console does: boot stubs, then the chunk loads', + ['stub', 'eager', 'tombstone'], + ], + [ + 'eager first — a host that imports the package before declaring stubs', + ['eager', 'tombstone', 'stub'], + ], +]; + +describe('bare `dashboard` ownership is declared, not decided by registration order', () => { + it.each(ORDERS)('resolves the same way in EITHER order (%s)', (_label, order) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const fresh = new Registry(); + const live = declaredRegistration(`${NS}:${SHORT}`); + const tombstone = declaredRegistration(`${RETIRED_NS}:${SHORT}`); + + for (const step of order) { + if (step === 'stub') { + fresh.registerLazy(SHORT, () => Promise.resolve({}), consoleStubMeta()); + } else if (step === 'eager') { + fresh.register(SHORT, live.component as never, live.meta); + } else { + fresh.register(SHORT, tombstone.component as never, tombstone.meta); + } + // The invariant that used to break: whoever last touched the bare key + // must declare the SAME namespace, at every point in the sequence. The + // tombstone step is in the sequence precisely because it must NOT move + // this reading — that is what its `skipFallback: true` buys. + expect( + fresh.getMeta(SHORT)?.namespace, + `after the "${step}" step in order [${order.join(', ')}], bare "${SHORT}" declares a ` + + 'different namespace than the step before it — the bare key is double-claimed again ' + + '(objectui#9533)', + ).toBe(NS); + } + + expect( + fresh.get(SHORT), + `registering in the order [${order.join(', ')}] changed who answers bare "${SHORT}"`, + ).toBe(DashboardRenderer); + // The key the CLI whitelist declares renderable must name a real component + // once the chunk has loaded, in either order. + expect(fresh.get(SHORT, NS)).toBe(DashboardRenderer); + // And the retired spelling answers the tombstone in either order. + expect(fresh.get(SHORT, RETIRED_NS)).toBe(RetiredDashboardNodeTombstone); + + // The registry's collision guard is the mechanism this class of fix uses, + // so its silence is part of the contract: a warning here means two + // registrations are fighting over the bare key again. ⚠️ Silence is also + // the PRE-fix reading for this particular card — `registerLazy` has no + // collision check at all — which is why it is asserted alongside the + // namespace readings above rather than on its own. + const collided = warn.mock.calls.some( + (args: unknown[]) => typeof args[0] === 'string' && args[0].includes(COLLISION_WARNING), + ); + expect(collided, 'the registry warned that the bare-name fallback was overwritten').toBe( + false, + ); + } finally { + warn.mockRestore(); + } + }); + + it('the real registration CLEARS the console stub instead of stranding it', () => { + const fresh = new Registry(); + const live = declaredRegistration(`${NS}:${SHORT}`); + fresh.registerLazy(SHORT, () => Promise.resolve({}), consoleStubMeta()); + // Non-vacuity: the stub really is pending before the load, so the assertion + // below measures a transition rather than an absence. + expect(fresh.hasLazy(SHORT, NS)).toBe(true); + + fresh.register(SHORT, live.component as never, live.meta); + + // Consequence 2. While the namespaces disagreed, `register()` deleted + // `view:dashboard` and left `plugin-dashboard:dashboard` pending FOREVER, so + // the whitelisted key was permanently unrenderable. + expect( + fresh.hasLazy(SHORT, NS), + `"${NS}:${SHORT}" is still a pending lazy stub after the module registered — the ` + + 'registration is landing under a different full type (objectui#9533)', + ).toBe(false); + expect(fresh.hasLazy(SHORT)).toBe(false); + }); + + it('the LIVE registry the package loaded into holds no pending dashboard stub either', () => { + // The replay above proves the property; this proves it of the process every + // other test in this package shares, which is the registry a host gets. + expect(ComponentRegistry.hasLazy(SHORT, NS)).toBe(false); + expect(ComponentRegistry.hasLazy(SHORT)).toBe(false); + expect(ComponentRegistry.hasLazy(SHORT, RETIRED_NS)).toBe(false); + }); +}); + +/* ════════════════════════════════════════════════════════════════════════════ + * The retired spelling is refused BY NAME, with its migration + * ══════════════════════════════════════════════════════════════════════════ */ + +/** Answers empty so nothing lands in an error state of its own. */ +const ADAPTER = { + find: async () => [], + findOne: async () => null, + aggregate: async () => [], + count: async () => 0, + getObject: async () => null, + queryDataset: async () => ({ rows: [] }), +}; + +function renderNode(type: string): void { + render( + + + , + ); +} + +describe('an authored `view:dashboard` is refused by name (objectui#9533)', () => { + it('renders the tombstone, carrying the migration text the table declares', () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + resetRetiredDashboardNodeTypeReports(); + try { + renderNode(`${RETIRED_NS}:${SHORT}`); + + const alert = screen.getByTestId('dashboard-retired-node-tombstone'); + expect(alert).toHaveAttribute('role', 'alert'); + expect(alert).toHaveAttribute('data-retired-node-type', `${RETIRED_NS}:${SHORT}`); + + // ⭐ The refusal is the assertion most easily faked, so the TEXT is + // asserted, not merely the absence of a dashboard. Read from the table the + // component reads — a restated copy here would go stale silently. + const prescription = RETIRED_DASHBOARD_NODE_TYPES[SHORT]; + expect(prescription, 'the retirement table lost its entry').toBeTruthy(); + expect(alert).toHaveTextContent(`${RETIRED_NS}:${SHORT}`); + expect(alert).toHaveTextContent(`${NS}:${SHORT}`); + expect(alert.textContent).toBe(prescription); + + // The log half carries the same string, so a headless host sees it too. + expect(error).toHaveBeenCalledWith(prescription); + + // ⛔ And it is a REFUSAL, not a degraded render: the widget grid the real + // renderer paints must be absent. Asserted alongside the text above, never + // instead of it — this half alone passes against a deleted registration. + expect(document.querySelector('.grid.auto-rows-min')).toBeNull(); + } finally { + error.mockRestore(); + } + }); + + it('CONTROL: the surviving spellings still paint the real dashboard', () => { + // Non-vacuous on its own: without this, the assertion above would pass + // against a package that renders nothing at all. + for (const type of [SHORT, `${NS}:${SHORT}`]) { + renderNode(type); + expect( + document.querySelector('.grid.auto-rows-min'), + `"${type}" stopped resolving to the dashboard renderer`, + ).not.toBeNull(); + expect(screen.queryByTestId('dashboard-retired-node-tombstone')).toBeNull(); + cleanup(); + } + }); +}); diff --git a/packages/plugin-dashboard/src/index.tsx b/packages/plugin-dashboard/src/index.tsx index 1479e50aab..cf3896527c 100644 --- a/packages/plugin-dashboard/src/index.tsx +++ b/packages/plugin-dashboard/src/index.tsx @@ -21,6 +21,10 @@ import { DashboardConfigPanel } from './DashboardConfigPanel'; import { WidgetConfigPanel } from './WidgetConfigPanel'; import { DashboardWithConfig } from './DashboardWithConfig'; import { DrillDownDrawer } from './DrillDownDrawer'; +import { + RETIRED_DASHBOARD_NODE_TYPES, + RetiredDashboardNodeTombstone, +} from './retired-node-types'; export { DashboardRenderer, DashboardGridLayout, MetricWidget, MetricCard, ObjectMetricWidget, PivotTable, ObjectPivotTable, ObjectDataTable, DashboardConfigPanel, WidgetConfigPanel, DashboardWithConfig, DrillDownDrawer }; export type { WidgetConfigPanelProps } from './WidgetConfigPanel'; @@ -38,6 +42,17 @@ export type { WidgetDatasetDimension, WidgetDatasetMeasure, } from './dataset-catalog'; +// objectui#9533 — the retirement table and the widget it renders. Exported for +// the same reason the sibling packages export their tombstones: the assertion +// that an authored `view:dashboard` is refused BY NAME, with the migration in +// the refusal's own text, has to be able to import the text it asserts rather +// than restate it. +export { + RETIRED_DASHBOARD_NODE_TYPES, + RetiredDashboardNodeTombstone, + reportRetiredDashboardNodeType, + resetRetiredDashboardNodeTypeReports, +} from './retired-node-types'; // Register dashboard component // @@ -76,11 +91,29 @@ export type { // authors — AI authors especially — to fabricate a dashboard identity that // resolves NO translations and fails silently, minting a fresh // silently-inert key, the exact defect class this card removes. +// +// ⛔ THE NAMESPACE IS `plugin-dashboard`, NOT `view` (objectui#9533, director +// summon #24 / batch #152 item 5, letter 1, maintainer-approved). This +// registration used to declare `view`, while `apps/console`'s two `registerLazy` +// loops declared `plugin-dashboard` for the same bare `dashboard` key. Two +// consequences followed, and both are pinned in +// `__tests__/dashboardBareKeyOwnership.test.tsx`: +// 1. bare `dashboard` declared one namespace before the chunk loaded and the +// other after it — which answer a host got depended on when it asked; +// 2. `register()` clears the lazy stub of the type IT registers, and the type +// it registered was `view:dashboard`, so `plugin-dashboard:dashboard` was +// never cleared: no component was ever stored under it, +// `hasLazy('dashboard', 'plugin-dashboard')` stayed true forever, and the +// generated CLI whitelist advertised a spelling that could never resolve. +// Converging on the namespace the consumers already declare makes the bare key +// have ONE owner by construction rather than by whichever phase was observed. +// ⛔ Do not move it back; the retired `view:dashboard` spelling is answered by +// the tombstone registered at the bottom of this file. ComponentRegistry.register( 'dashboard', DashboardRenderer, { - namespace: 'view', + namespace: 'plugin-dashboard', label: 'Dashboard', category: 'Complex', icon: 'layout-dashboard', @@ -377,6 +410,36 @@ ComponentRegistry.register( } ); +// RETIRED SPELLINGS, registered LAST (objectui#9533). +// +// `view:dashboard` was this package's own full type until the bare `dashboard` +// key was converged onto `plugin-dashboard` above. The key stays registered and +// answers with `RetiredDashboardNodeTombstone` — a visible refusal that names +// the spelling the author wrote and the spelling that replaces it — because the +// ruling requires the refusal to be BY NAME and ⛔ not a silent fall-through. +// +// ⛔ `skipFallback: true` is load-bearing twice over: a tombstone must not claim +// the bare `dashboard` key (`plugin-dashboard:dashboard` owns it), and without +// it this call would re-contest the very key the card converged. +// +// LAST, for the same reason `registerAllFields()` registers its tombstones last: +// `register()` clears the lazy stub of the bare name as well as of its own full +// type, so a tombstone running BEFORE the real registration would drop a stub +// the real registration is about to satisfy. +// +// The loop shape is what the key derivation reads: `deriveRegistryKeys` pairs an +// unresolvable key argument with the collection that supplies it, and +// `INDIRECT_REGISTRATIONS` in `scripts/check-doc-component-types.mjs` declares +// this collection WITHHELD — so a retired spelling is refused by `objectui +// check` instead of being blessed by the generated whitelist, which is the whole +// point of retiring it. +for (const retired of Object.keys(RETIRED_DASHBOARD_NODE_TYPES)) { + ComponentRegistry.register(retired, RetiredDashboardNodeTombstone, { + namespace: 'view', + skipFallback: true, + }); +} + // Standard Export Protocol - for manual integration. Keyed by the schema // `type` each entry serves (objectui#5064 — aligned with the four sibling // `*Components` maps); every value is the exact component the side-effect diff --git a/packages/plugin-dashboard/src/retired-node-types.tsx b/packages/plugin-dashboard/src/retired-node-types.tsx new file mode 100644 index 0000000000..1e9c9ec8bb --- /dev/null +++ b/packages/plugin-dashboard/src/retired-node-types.tsx @@ -0,0 +1,115 @@ +/** + * 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. + */ + +/** + * Node-type spellings this package USED to publish and no longer does + * (objectui#9533), and the widget each one renders now. + * + * ## Why a tombstone rather than nothing + * + * `view:dashboard` was this package's own full type until objectui#9533 + * converged the bare `dashboard` key onto `plugin-dashboard`. Simply deleting + * the registration would leave an authored `{ "type": "view:dashboard" }` node + * resolving to nothing, and "nothing" reads to an author as the same OBJUI-001 + * panel a typo produces — it names the spelling they wrote and says nothing + * about the spelling that replaced it. The maintainer-approved ruling on + * objectui#9533 (director summon #24, batch #152 item 5, letter 1) requires + * the refusal to be BY NAME and to point at `plugin-dashboard:dashboard`, so + * the key stays registered and answers with a visible refusal carrying its own + * migration. + * + * ## Shape + * + * Borrowed whole from `@object-ui/fields`' `RetiredFieldTombstone` + * (objectui#4814), which is this repository's settled answer to "an authored + * entry this renderer will not honour": an inline alert that NAMES the + * offending spelling plus a `console.error` whose text doubles as the fix + * instruction. Nothing is thrown — one retired node must not take down the + * dashboard around it — and nothing is silently substituted, which is the + * point: the author sees a refusal where they expected a dashboard, not a + * dashboard that quietly taught them a dead spelling. + * + * ⛔ The registration that consumes this table passes `skipFallback: true`: a + * tombstone must never claim the bare `dashboard` key, which + * `plugin-dashboard:dashboard` owns. + */ +import React from 'react'; + +/** + * The bare name of every retired spelling, mapped to the refusal text the + * tombstone renders and logs. Keyed by the BARE name because that is what + * `ComponentRegistry.register` takes; the namespace is spelled at the call. + * + * The message names BOTH halves deliberately — the spelling that was written + * and the spelling that replaces it. A refusal that only says "unknown" is the + * one this table exists to stop being. + */ +export const RETIRED_DASHBOARD_NODE_TYPES: Readonly> = Object.freeze({ + dashboard: + '[object-ui] Node type `view:dashboard` was RETIRED (objectui#9533). This package ' + + 'registers the dashboard renderer as `plugin-dashboard:dashboard`, the namespace the ' + + "console's lazy stubs and the CLI's known-type whitelist already declare. Write " + + '`{ "type": "plugin-dashboard:dashboard" }`, or the bare `{ "type": "dashboard" }`, ' + + 'which resolves to the same renderer.', +}); + +/** Spellings already reported, so one retired node logs once per session. */ +const reported = new Set(); + +/** + * Log a retired node-type spelling once. Exported for the pin test, which has + * to be able to observe a SECOND occurrence being suppressed without inventing + * its own copy of the set. + */ +export function reportRetiredDashboardNodeType(spelling: string): void { + if (reported.has(spelling)) return; + reported.add(spelling); + console.error( + RETIRED_DASHBOARD_NODE_TYPES[spelling] ?? + `[object-ui] Node type \`${spelling}\` was retired by @object-ui/plugin-dashboard.`, + ); +} + +/** Test seam: forget what has been reported, so a pin can measure the first one. */ +export function resetRetiredDashboardNodeTypeReports(): void { + reported.clear(); +} + +/** + * The widget a RETIRED dashboard node-type spelling renders. + * + * The rendered text and the logged text are the SAME string, read from + * {@link RETIRED_DASHBOARD_NODE_TYPES}. A pin that asserts only "nothing + * rendered" cannot tell this refusal apart from the key having been deleted + * outright, which is why the text is part of the contract and not decoration. + */ +export const RetiredDashboardNodeTombstone: React.FC> = (props) => { + const schema = props?.schema as { type?: unknown } | undefined; + const fromSchema = typeof schema?.type === 'string' ? schema.type : undefined; + const fromProps = typeof props?.type === 'string' ? (props.type as string) : undefined; + const written = fromSchema ?? fromProps ?? 'view:dashboard'; + // The table is keyed by the BARE name; an author reaches this component by + // either spelling, so the lookup drops any namespace prefix before reading it. + const bare = written.includes(':') ? written.slice(written.indexOf(':') + 1) : written; + const prescription = + RETIRED_DASHBOARD_NODE_TYPES[bare] ?? + `[object-ui] Node type \`${written}\` was retired by @object-ui/plugin-dashboard.`; + React.useEffect(() => { + reportRetiredDashboardNodeType(bare); + }, [bare]); + return ( +
+ {prescription} +
+ ); +}; diff --git a/scripts/__tests__/check-doc-component-types.test.ts b/scripts/__tests__/check-doc-component-types.test.ts index 6b7582a333..2070b0c3fc 100644 --- a/scripts/__tests__/check-doc-component-types.test.ts +++ b/scripts/__tests__/check-doc-component-types.test.ts @@ -1736,7 +1736,7 @@ describe('objectui#5106 — plugin key tables are judged, on both halves', () => 'content/docs/plugins/plugin-grid.mdx', 'content/docs/plugins/plugin-view.mdx', ]); - expect(tableRows.map((r) => r.namespaced)).toContain('`view:dashboard`'); + expect(tableRows.map((r) => r.namespaced)).toContain('`plugin-dashboard:dashboard`'); }); }); diff --git a/scripts/check-doc-component-types.mjs b/scripts/check-doc-component-types.mjs index e165969ddf..c1730247e4 100644 --- a/scripts/check-doc-component-types.mjs +++ b/scripts/check-doc-component-types.mjs @@ -253,12 +253,21 @@ * Both halves of the row are judged, and judging the namespaced half is the point * objectui#5106 was filed for: this gate never judged a namespace at all. It * compared bare keys against a universe that happens to contain namespaced keys - * too, so `view:dashboard` documented as `plugin-dashboard:dashboard` produced no - * signal from any static check — the bare `dashboard` matched and the row passed. - * Flip `namespace: 'view'` to `'dash'` in `plugin-dashboard/src/index.tsx` and - * `deriveRegistryKeys` follows it live to `dash:dashboard`, while every doc that - * teaches `view:dashboard` stays green. That is the hole; the namespaced cell - * closes it. + * too, so the dashboard's namespaced spelling documented as any OTHER namespace + * over the same bare name produced no signal from any static check — the bare + * `dashboard` matched and the row passed. Flip `namespace: 'plugin-dashboard'` + * to `'dash'` on the dashboard registration in `plugin-dashboard/src/index.tsx` + * and `deriveRegistryKeys` follows it live to `dash:dashboard`, while every doc + * that teaches the old spelling stays green. That is the hole; the namespaced + * cell closes it. + * + * ⚠️ This paragraph used to run the same example over the literal pair + * `view:dashboard` / `plugin-dashboard:dashboard`, which was a live reading when + * it was written and is not one now: objectui#9533 converged that bare key onto + * `plugin-dashboard` and retired `view:dashboard`, so a reader following the old + * wording would look for a `namespace: 'view'` on that registration and not find + * it. Re-pointed with the example kept, because the example is the mechanism and + * the spelling was only ever an illustration of it. * * What is deliberately NOT checked, and why: when the fallback cell reads * "none — `skipFallback: true`", this gate does not assert that the bare name is @@ -591,6 +600,40 @@ export const INDIRECT_REGISTRATIONS = [ 'read it anyway. What re-checks this entry is the registration call itself: delete the tombstone ' + 'loop and this entry reports `stale-indirect-registration`.', }, + { + site: 'packages/plugin-dashboard/src/index.tsx', + collection: 'RETIRED_DASHBOARD_NODE_TYPES', + kind: 'object-keys', + namespace: 'view', + excluded: + 'WITHHELD, the same disposition and the same open question as the RETIRED_FIELD_TYPES entry ' + + 'above (objectui#9717): does a RETIRED TOMBSTONE SPELLING belong in a universe whose job is ' + + '"does this string name a component that exists"? Held the same way here so the two answers ' + + 'cannot drift apart. ⭐ For this collection the exclusion also carries the point of the ' + + 'retirement: objectui#8760 graded a key that passes every authoring check and fails only in ' + + "front of a user as the WRONG side of the line, so a spelling this package has retired must be " + + 'named by `objectui check` rather than blessed by the generated whitelist. ⚠️ DROPPING this ' + + '`excluded` line does NOT re-admit `view:dashboard` — measured on this tree it makes THIS entry ' + + 'report `stale-indirect-registration`, and `check:doc-types` and ' + + '`regenerate-known-schema-types.mjs --check` both exit non-zero and write nothing. Without the ' + + 'exclusion the derivation has to read the collection literal in the site file, and ' + + 'RETIRED_DASHBOARD_NODE_TYPES is IMPORTED there from `./retired-node-types` (the `reason` below ' + + 'says so for its own purposes), so it resolves to no literal keys at all: the universe does not ' + + 'regain the spelling, `KNOWN_SCHEMA_TYPES` is not rewritten, and a document teaching ' + + '`view:dashboard` stays RED — it just stays red for a DIFFERENT reason, with the gate now ' + + 'refusing the whole entry. ⇒ admitting the spelling is NOT a one-line change here; the keys ' + + 'would first have to be readable from this file. Re-derive both halves by deleting the line and ' + + 'running those two commands.', + reason: + '`packages/plugin-dashboard` registers every key of RETIRED_DASHBOARD_NODE_TYPES last, under the ' + + '`view` namespace with `skipFallback: true` — a tombstone widget that renders a visible refusal ' + + 'naming the migration to `plugin-dashboard:dashboard` (objectui#9533, director summon #24 / ' + + 'batch #152 item 5, letter 1). ⚠️ Two things this entry is deliberately NOT: it does not name ' + + 'the keys, and it does not read the collection literal — RETIRED_DASHBOARD_NODE_TYPES is ' + + 'IMPORTED into the site file from `./retired-node-types`, so nothing here could read it anyway. ' + + 'What re-checks this entry is the registration call itself: delete the tombstone loop and this ' + + 'entry reports `stale-indirect-registration`.', + }, ]; /** diff --git a/scripts/check-registry-bare-name-collisions.mjs b/scripts/check-registry-bare-name-collisions.mjs index eac78264b1..e8115e6acc 100644 --- a/scripts/check-registry-bare-name-collisions.mjs +++ b/scripts/check-registry-bare-name-collisions.mjs @@ -164,6 +164,12 @@ export const OPEN_REGISTRATION_SITES = { '`registerField(fieldType)` and the retired-field tombstone registrar both take the key as a ' + 'parameter. The live keys are read from fieldWidgetMap through INDIRECT_REGISTRATIONS; the ' + 'tombstone registrar declares `skipFallback: true` at its call site, so it claims no bare key.', + 'packages/plugin-dashboard/src/index.tsx': + 'The retired-node-type tombstone registrar loops over RETIRED_DASHBOARD_NODE_TYPES, so its key ' + + 'argument is a loop binding rather than a literal (objectui#9533). It declares ' + + '`skipFallback: true` at its call site, so it claims no bare key and decides no bare-name ' + + "ownership; the package's eight literal registrations, `plugin-dashboard:dashboard` included, " + + 'are read normally.', }; /** @@ -385,20 +391,15 @@ export function groupBareKeys(claims) { * a card, not in this constant. */ export const KNOWN_BARE_NAME_COLLISIONS = [ - { - key: 'dashboard', - claimants: [ - 'apps/console/src/preview-gallery.tsx · plugin-dashboard:dashboard · registerLazy', - 'apps/console/src/register-plugins.ts · plugin-dashboard:dashboard · registerLazy', - 'packages/plugin-dashboard/src/index.tsx · view:dashboard · register', - ], - note: - 'The objectui#6416 shape, still live. The console lazy stubs declare `plugin-dashboard:dashboard` ' + - 'and the plugin itself registers `view:dashboard`, so bare `dashboard` declares one namespace ' + - 'before the chunk loads and the other after. ⛔ Not resolvable here: picking a spelling decides ' + - 'which declaration governs an authored `{ "type": "dashboard" }` node, and objectui#9256 holds ' + - 'registrations for exactly that class of ruling.', - }, + // EMPTY, and that is a reading rather than a default. The one entry this + // ledger was born with — bare `dashboard`, claimed as `plugin-dashboard:dashboard` + // by the two console `registerLazy` loops and as `view:dashboard` by the plugin + // itself — was RESOLVED by objectui#9533 (director summon #24 / batch #152 + // item 5, letter 1, maintainer-approved): the package now registers + // `plugin-dashboard:dashboard`, so all three claimants name ONE full type and + // the key has one owner by construction. The row is gone rather than reworded, + // which is what "shrink-only" means here; a stale entry would fail anyway. + // ⛔ Nothing goes back in to make a red run green — see the header. ]; /** Every finding the gate has, as human-readable records. */