diff --git a/.changeset/6678-datasource-input-declaration-injection.md b/.changeset/6678-datasource-input-declaration-injection.md new file mode 100644 index 0000000000..fdc390acec --- /dev/null +++ b/.changeset/6678-datasource-input-declaration-injection.md @@ -0,0 +1,68 @@ +--- +'@object-ui/core': minor +'@object-ui/react': minor +'@object-ui/components': patch +'@object-ui/plugin-calendar': patch +'@object-ui/plugin-charts': patch +'@object-ui/plugin-dashboard': patch +'@object-ui/plugin-detail': patch +'@object-ui/plugin-form': patch +'@object-ui/plugin-gantt': patch +'@object-ui/plugin-grid': patch +'@object-ui/plugin-kanban': patch +'@object-ui/plugin-list': patch +'@object-ui/plugin-map': patch +'@object-ui/plugin-timeline': patch +--- + +The spec's `dataSource` element binding is now DECLARED by the blocks that read +it, so the html tier stops reporting the one working saved-view spelling as +`unknown-prop` (objectui#6678). + +`PageComponentSchema.dataSource` — `{ object, view, filter, sort, limit }` — is +the one spelling that resolves a saved view for an object-bound block. It works, +and it drew the identical `unknown-prop` warning as the two spellings that do +nothing (`viewName`, `view`), because `validateTree` looks a prop up in the +block's declared `inputs` and no registration declared this key. On the tier +built to accept AI-authored pages, where the diagnostic IS the contract, the +only signal pointed away from the key that works. + +Adopting the maintainer ruling of 2026-08-29 — option B **in the injection +form**: + +- `ELEMENT_DATA_SOURCE_INPUT` is the single declaration, in `@object-ui/core` + beside the binding's own semantics; `Registry.register` emits it for any + registration whose renderer passed through the new `elementDataSourceBlock()` + seam. One mechanism, one copy — not a hand-kept declaration per block, which is + the shape that drifts and that a new block forgets. The seam lives in + `@object-ui/core` and is re-exported by `@object-ui/react` beside + `ElementDataSourceGate` for discoverability; call sites take the core import, + because a registration runs at module scope and this repo's suites partially + mock `@object-ui/react`. +- Seventeen renderers, in thirteen files across twelve packages, reach the seam + and now publish the key to the save gate, the parser whitelist, the generated + JSX authoring types and the block list. The card named nine blocks; the tree + also has `plugin-grid`, `plugin-timeline`, two further `plugin-form` blocks and + `element:record_picker` — nothing was hand-listed, so the mechanism covered + them. `element:record_picker` consumes the gate's HOOK and status panels rather + than the wrapper tag (its object lives under `properties`), and was found by a + render probe rather than by reading sources. +- `dataSource` on a block that does NOT read it (`flex`, `card`) still reports + `unknown-prop`. Adding the key to `sdui-parser`'s `BASE_PROPS` was refused for + exactly this reason — that set mirrors `BaseSchema`, and silencing the key + everywhere would make the diagnostic lie in the other direction. +- New `check:element-data-source-declaration` fails any source that consumes the + gate without reaching the seam, so a block added tomorrow cannot forget. + +Behaviour of the binding itself is unchanged — this is a declaration, not a +resolution change. The saved view still resolves its columns, and an +unresolvable `view` still fails loudly rather than widening to the object's full +scope. + +The spec/registry parity gates (repo-wide and the `record:related_list` per-block +pin) now derive their accepted set from the WHOLE node contract rather than from +`ComponentPropsMap[type]` alone. `PageComponentSchema` accepts and keeps +`dataSource` on a page-component node — it is a node-level key, a sibling of +`type` and `className`, not a per-block prop — so the gates' previous complaint +was measurably wrong. Derived from the spec, not exempted, and both still +discriminate against an invented key. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a37ea94566..a8b8e84536 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -284,6 +284,20 @@ jobs: if: steps.relevant.outputs.should_run == 'true' run: pnpm check:side-effects-array + # A block that WRAPS `ElementDataSourceGate` reads the spec's + # `PageComponentSchema.dataSource`; the input declaration for that key is + # emitted mechanically at the wrapping seam (`elementDataSourceBlock` -> + # `Registry.register`), per the maintainer ruling of 2026-08-29, so no + # block hand-writes it. This gate is the other half of "and cannot forget + # it": a file that starts rendering the gate without reaching the seam + # would publish an authoring surface missing the one key its own runtime + # honours, and the html tier would go back to reporting the only spelling + # that works as `unknown-prop` (objectui#6678). Source-only — it needs the + # install and nothing built. + - name: Verify every ElementDataSourceGate wrapper reaches the declaration seam + if: steps.relevant.outputs.should_run == 'true' + run: pnpm check:element-data-source-declaration + # Node's ESM resolver does not extension-search relative specifiers, so an # extensionless `./SchemaRenderer` in a published `dist/` is unloadable # under plain Node — `@object-ui/react`'s entry died with diff --git a/apps/console/src/__tests__/element-data-source-input-injection.test.tsx b/apps/console/src/__tests__/element-data-source-input-injection.test.tsx new file mode 100644 index 0000000000..8e80e6af52 --- /dev/null +++ b/apps/console/src/__tests__/element-data-source-input-injection.test.tsx @@ -0,0 +1,262 @@ +/** + * 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 `dataSource` input is EMITTED at the gate-wrapping seam — the mechanism, + * pinned once, over the whole registration graph (objectui#6678). + * + * ## What is being pinned, and what deliberately is not + * + * `PageComponentSchema.dataSource` is read by `ElementDataSourceGate` on behalf + * of every object-bound block that wraps itself in it, and was declared by none + * of them — so the html tier reported the one spelling that resolves a saved + * view with the same `unknown-prop` warning it gives the spellings that do + * nothing. The maintainer ruling of 2026-08-29 took option B **in the injection + * form**: emitted mechanically at the wrapping seam, from the same place that + * reads the key. Nine hand-kept copies across nine packages is what that ruling + * refused, because copies drift and the tenth block forgets. + * + * So this file pins the MECHANISM, not a list of blocks. It never names which + * blocks are gate-wrapped; it derives that set from the live graph and asserts a + * property that holds of the whole set. A block added, moved between packages or + * renamed tomorrow moves this test with it — which is the only way a pin on + * "no drift" can itself be drift-free. Eleven packages are covered without one + * of them appearing in this file. + * + * ## Why it lives here + * + * The claim is *package-agnostic*, so it needs the full registration graph — the + * same pair `dev/manifest-dump.tsx` builds the published artifacts from, which + * `public-contract.test.ts` and `registry-inputs-spec-parity.test.ts` next door + * already read for the same reason. A hand-assembled list would agree with + * itself and prove nothing. + * + * ## The three legs, and why none of them is redundant + * + * 1. **Wrapping is established INDEPENDENTLY of the declaration.** A test that + * read the marker and then asserted the injection keyed on that marker would + * be checking one line against itself. So gate-wrapping is detected by + * RENDERING each candidate against an unresolvable saved view and looking + * for the gate's own DOM signature — a `data-testid` only the gate emits. + * That is evidence from the render path, which is the only place it exists. + * 2. **Every wrapping registration declares the key** — the injection reached + * it, whichever package it came from. + * 3. **Nothing else declares it.** The control direction, and the reason option + * A was refused: a fix that published `dataSource` on blocks that never read + * it makes the diagnostic lie the other way instead of not at all. + * + * Plus the consumers: the key must reach the three artifacts `inputs` feeds — + * `sdui.manifest.json`, `sdui-intrinsics.d.ts` (the JSX authoring types) and + * `sdui-blocks.md`. Asserted through the generators themselves rather than in + * prose, because "codegen and the designer see it" is exactly the claim that + * cannot be taken on trust: it was false for this key for the whole life of the + * defect. + * + * The `unknown-prop` half of the story — an author's actual experience, over the + * real page path — is pinned next to the block that reports it, in + * `packages/plugin-list/src/__tests__/htmlTierDataSourceInputDeclaration-6678.test.tsx`. + * + * The static half of "cannot forget" — a file that starts wrapping the gate + * without reaching the seam — is `scripts/check-element-data-source-declaration.mjs`. + * That gate and this one are complementary: it reads sources and cannot see the + * registry, this reads the registry and cannot see an unmarked wrapper's source. + */ + +import { describe, it, expect } from 'vitest'; +import React from 'react'; +import { render, cleanup } from '@testing-library/react'; +import { ComponentRegistry } from '@object-ui/core'; +import { SchemaRendererProvider } from '@object-ui/react'; +import { + generateBlockList, + generateDts, + manifestFromConfigs, +} from '@object-ui/sdui-parser'; + +// The two graphs whose registrations this reads — the layout/content primitives +// and the console's own plugin layer, from the module main.tsx boots from. Same +// posture as `public-contract.test.ts`: the REAL registration list. +import '@object-ui/components'; +import '../register-plugins'; + +const KEY = 'dataSource'; +const PROBE_OBJECT = 'probe_object__c'; +/** A view name no adapter below serves, so the gate resolves it to `missing`. */ +const ABSENT_VIEW = 'no_such_saved_view__probe'; + +/** + * The gate's own DOM signatures. Each is emitted by one of the three panels + * `ElementDataSourceGate` renders instead of the block, and by nothing else in + * the repo — which is what makes them usable as "this renderer wraps the gate" + * without asking the renderer. + */ +const GATE_TESTID = /-(datasource-error|no-data-source|resolving-view)$/; + +/** An adapter that can answer "what saved views does this object have?" — with none. */ +const viewCapableAdapter = { + find: async () => ({ data: [], total: 0 }), + findOne: async () => null, + create: async () => ({}), + update: async () => ({}), + delete: async () => ({}), + count: async () => 0, + getObjectSchema: async (name: string) => ({ name, fields: {}, listViews: {} }), + getObjects: async () => [], + listViews: async () => [], + onMutation: () => () => {}, +} as any; + +interface Config { + type: string; + namespace?: string; + component?: unknown; + inputs?: Array<{ name: string }>; + lazy?: boolean; +} + +/** + * Every registration, de-duplicated by the namespaced type. The registry stores + * a bare-name alias pointing at the same entry, so reading `getAllConfigs()` + * raw would count several blocks twice and let a bare alias stand in for its + * namespaced original. + */ +const configs = (): Config[] => { + const byType = new Map(); + for (const c of ComponentRegistry.getAllConfigs() as Config[]) { + if (!byType.has(c.type)) byType.set(c.type, c); + } + return [...byType.values()]; +}; + +const declaresKey = (c: Config) => (c.inputs ?? []).some((i) => i?.name === KEY); + +/** + * Does rendering this registration produce one of the gate's panels? + * + * The probe names an object and a saved view that does not exist. A renderer + * that wraps the gate reports that (or reports the missing adapter, or is still + * resolving) through a panel with a `data-testid` no other component emits. A + * renderer that does not wrap the gate has no path to any of them, whatever else + * it does with the props. + * + * A renderer that THROWS is reported as such rather than silently counted as + * "not wrapping" — a swallowed exception here would quietly shrink the + * population this file's whole claim is quantified over. + */ +function wrapsGate(c: Config): { wraps: boolean; threw?: string } { + const Component = c.component as React.ComponentType | undefined; + if (typeof Component !== 'function' && typeof Component !== 'object') return { wraps: false }; + const schema = { + type: c.type, + objectName: PROBE_OBJECT, + [KEY]: { object: PROBE_OBJECT, view: ABSENT_VIEW }, + }; + try { + const { container } = render( + + {React.createElement(Component as any, { schema, ...schema })} + , + ); + const wraps = Array.from(container.querySelectorAll('[data-testid]')).some((el) => + GATE_TESTID.test(el.getAttribute('data-testid') || ''), + ); + return { wraps }; + } catch (e) { + return { wraps: false, threw: e instanceof Error ? e.message : String(e) }; + } finally { + cleanup(); + } +} + +describe('the `dataSource` declaration is emitted at the gate-wrapping seam (#6678)', () => { + const all = configs().filter((c) => !c.lazy); + const declaring = all.filter(declaresKey); + + it('is declared by a non-trivial set of registrations, spanning several packages', () => { + // Anti-vacuity, in both dimensions. Every assertion below is quantified over + // one of these sets, and each would pass trivially over an empty one — which + // is the exact shape of "the injection silently stopped running". + expect(declaring.length).toBeGreaterThan(1); + const namespaces = new Set(declaring.map((c) => c.namespace).filter(Boolean)); + expect(namespaces.size).toBeGreaterThan(1); + }); + + it('every registration that renders the gate declares the key — from one mechanism, in any package', () => { + // Candidates: everything that either declares the key or could plausibly + // wrap the gate. Kept WIDER than the declaring set on purpose — a block that + // started wrapping the gate without reaching the seam is exactly the drift + // this pin exists to catch, and it is invisible to a probe that only looks + // at blocks already declaring the key. + const threw: string[] = []; + const wrapping: string[] = []; + for (const c of all) { + const verdict = wrapsGate(c); + if (verdict.threw) threw.push(`${c.type}: ${verdict.threw}`); + if (verdict.wraps) wrapping.push(c.type); + } + + // The probe must actually be able to SEE a gate — a detector that matches + // nothing would report "every wrapper declares the key" over an empty set. + expect(wrapping.length).toBeGreaterThan(1); + + const undeclared = wrapping.filter((t) => !declaring.some((c) => c.type === t)); + expect( + undeclared, + 'These registrations render ElementDataSourceGate but publish no `dataSource` input. ' + + 'Wrap the registered renderer in `elementDataSourceBlock(...)` from @object-ui/core — ' + + 'do NOT hand-write the input.', + ).toEqual([]); + + // Renderers that could not be probed are recorded, not hidden: a growing + // list here shrinks what the assertion above actually covers. + expect(threw.length, `renderers that threw during the probe:\n${threw.join('\n')}`) + .toBeLessThan(all.length); + }); + + it('does NOT declare the key on blocks that never read it — the direction option A would have broken', () => { + // `flex` and `card` are the two the ruling names. They do not wrap the gate, + // so an author who writes `dataSource` on them must still be told; adding + // the key to `sdui-parser`'s `BASE_PROPS` would have silenced both. + for (const type of ['flex', 'card']) { + const found = all.find((c) => c.type === type || c.type.endsWith(`:${type}`)); + expect(found, `expected a registration for "${type}"`).toBeTruthy(); + expect(declaresKey(found as Config)).toBe(false); + } + }); +}); + +describe('the emitted input reaches the artifacts `inputs` feeds (#6678)', () => { + // "Codegen and the designer see the input" is the half of the ruling that + // cannot be taken on trust — it was false for this key for the whole life of + // the defect. Asserted through the generators the repo publishes with, not in + // prose. + const all = configs().filter((c) => !c.lazy); + const sample = all.find(declaresKey); + + it('has at least one declaring block to project', () => { + expect(sample).toBeTruthy(); + }); + + it('reaches the SDUI manifest — the save gate and the parser whitelist', () => { + const manifest = manifestFromConfigs(all as never); + const entry = manifest.components[(sample as Config).type]; + expect(entry.inputs.map((i) => i.name)).toContain(KEY); + // The binding marker survives the projection: it is what makes the key read + // as naming an OBJECT rather than an opaque blob. + expect(entry.inputs.find((i) => i.name === KEY)?.binding).toBe('object'); + }); + + it('reaches the generated JSX authoring types — `sdui-intrinsics.d.ts`', () => { + const manifest = manifestFromConfigs(all as never); + const dts = generateDts(manifest); + expect(dts).toContain(`${KEY}?:`); + }); + + it('reaches the published block list — `sdui-blocks.md`', () => { + const manifest = manifestFromConfigs(all as never); + expect(generateBlockList(manifest)).toContain(KEY); + }); +}); diff --git a/apps/console/src/__tests__/htmlTierDataSourceInputDeclaration-6678.test.tsx b/apps/console/src/__tests__/htmlTierDataSourceInputDeclaration-6678.test.tsx new file mode 100644 index 0000000000..35a5f67ee8 --- /dev/null +++ b/apps/console/src/__tests__/htmlTierDataSourceInputDeclaration-6678.test.tsx @@ -0,0 +1,204 @@ +/** + * 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. + * + * `PageComponentSchema.dataSource` on the html tier: the one working saved-view + * spelling must stop being reported as a prop that does not exist + * (objectui#6678). + * + * ## What was measured, and why it is worse than noise + * + * `dataSource={{object, view}}` is the spec's per-element data binding and the + * ONLY spelling that resolves a saved view for an object-bound block. It works + * — and it drew the same `unknown-prop` warning as the two spellings that do + * nothing: + * + * | page source | diagnostic | columns | + * | ------------------------------------------ | -------------- | ------------ | + * | `dataSource={{object:'…', view:'all'}}` | `unknown-prop` | from the view| + * | `viewName="all"` | `unknown-prop` | none | + * | `view="all"` | `unknown-prop` | none | + * + * Identical reports for one key that works and two that do not, on the tier + * meant to accept AI-authored pages, where the diagnostic IS the contract. The + * objectui#6598 reporter tried the two that do nothing and gave up. + * + * ## The ruling this pins (2026-08-29, maintainer) + * + * Option B in the INJECTION form: the declaration is emitted mechanically at + * the `ElementDataSourceGate` wrapping seam, so a block declares the key from + * the same place that reads it. Option A — adding `dataSource` to + * `sdui-parser`'s `BASE_PROPS` — was refused, because that set mirrors + * `BaseSchema` and silencing the key on blocks that do NOT read it would make + * the diagnostic lie in the other direction. + * + * So this file pins BOTH directions, and the second one is the one that catches + * the over-broad fix: + * + * 1. gate-wrapped block (`list-view`) — no diagnostic AND the binding still + * works. The columns are asserted, not just the absence of a warning: a fix + * that silenced the diagnostic and broke the binding would pass half of + * this pin and be worse than the defect. + * 2. NON-wrapped block (`flex`, `card`) — `unknown-prop` still fires. Had + * `BASE_PROPS` been touched, this goes red. + * + * ## Why it goes through the REAL page path + * + * Both halves are properties of the live tier, not of a fixture: the diagnostic + * comes from `compile()` against a manifest built the way + * `packages/components/src/renderers/layout/page.tsx` builds it (see + * {@link livePageManifest}), and the columns come from the real `list-view` + * registration rendering through the real `object-grid`. A hand-written + * manifest would agree with itself and prove nothing about what an author sees. + * + * It lives in `apps/console` rather than beside `plugin-list` for the reason the + * suites next door already state: the claim is about what an AUTHOR is told, and + * that answer is produced from the whole registration graph plus the real + * `object-grid`. `plugin-list` depends on neither `@object-ui/sdui-parser` nor + * `@object-ui/plugin-grid`, and taking those dependencies to host a test would + * be the heavier change. + */ +import { describe, it, expect } from 'vitest'; +import { render, waitFor } from '@testing-library/react'; +import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react'; +import { ComponentRegistry } from '@object-ui/core'; +import { compile, manifestFromConfigs } from '@object-ui/sdui-parser'; +import type { Diagnostic } from '@object-ui/sdui-parser'; + +// The full registration graph — the same pair `dev/manifest-dump.tsx` builds the +// published artifacts from, and the pair every live-path suite in this directory +// reads. `list-view` and the REAL `object-grid` both arrive through it. +import '@object-ui/components'; +import '../register-plugins'; + +const OBJECT = 'opportunity'; + +/** + * The saved view the binding names. Its `columns` are the observable proof that + * the binding RESOLVED — they are not the object's default column set, so a + * block that ignored the binding cannot produce them by accident. + */ +const SAVED_VIEW = { + name: 'all', + label: 'All Opportunities', + columns: ['name', 'amount'], +}; + +const dataSource = { + find: async () => ({ + data: [ + { id: 'o-1', name: 'Acme expansion', amount: 1000, stage: 'new' }, + { id: 'o-2', name: 'Globex renewal', amount: 2000, stage: 'won' }, + ], + total: 23, + hasMore: true, + }), + findOne: async () => null, + create: async () => ({}), + update: async () => ({}), + delete: async () => ({}), + count: async () => 23, + getObjectSchema: async (name: string) => ({ + name, + label: 'Opportunity', + fields: { + id: { type: 'text', label: 'Id', hidden: true }, + name: { type: 'text', label: 'Opportunity Name' }, + stage: { type: 'text', label: 'Stage' }, + amount: { type: 'currency', label: 'Amount' }, + }, + // Where `useElementDataSource` reads an object's saved views from. + listViews: { all: SAVED_VIEW }, + }), + getObjects: async () => [], + onMutation: () => () => {}, +} as any; + +/** + * The manifest an html-kind page validates against, built the way the renderer + * builds it — `ComponentRegistry.getKnownTypes()` + each type's declared + * `inputs`, mirroring `page.tsx`'s `getJsxManifest()`. Rebuilt per call rather + * than cached, because the whole point of this file is that a registration's + * `inputs` decide what an author is told. + */ +const livePageManifest = () => + manifestFromConfigs( + ComponentRegistry.getKnownTypes().map((t) => { + const meta = ComponentRegistry.getMeta(t); + return { + type: t, + namespace: meta?.namespace, + isContainer: meta?.isContainer, + inputs: meta?.inputs, + }; + }) as unknown as Parameters[0], + ); + +/** Every `unknown-prop` diagnostic the live tier reports for `source`. */ +const unknownProps = (source: string) => + compile(source, livePageManifest()) + .diagnostics.filter((d: Diagnostic) => d.code === 'unknown-prop') + .map((d: Diagnostic) => d.message); + +async function renderHtmlPage(source: string) { + const { container } = render( + + + , + ); + await waitFor(() => expect(container.querySelector('table')).toBeTruthy()); + return container; +} + +const headersOf = (container: HTMLElement) => + Array.from(container.querySelectorAll('th')).map((th) => (th.textContent || '').trim()); + +const BINDING = `dataSource={{object: '${OBJECT}', view: 'all'}}`; + +describe("kind:'html' — the spec `dataSource` binding on a gate-wrapped block (#6678)", () => { + it('draws no unknown-prop diagnostic', () => { + expect(unknownProps(``)).toEqual([]); + }); + + it('still resolves the saved view it names — the binding was not silenced, it was declared', async () => { + const container = await renderHtmlPage(``); + + await waitFor(() => expect(headersOf(container).length).toBeGreaterThan(1)); + const headers = headersOf(container); + // The saved view's own column list, not the object's defaults: `stage` is a + // visible field of the object and the view does not show it. + expect(headers).toContain('Opportunity Name'); + expect(headers).toContain('Amount'); + expect(headers).not.toContain('Stage'); + }); + + it('leaves the two non-working spellings reported, which is what makes the signal usable', () => { + // The control for the pin above. If `viewName` / `view` went quiet too, the + // fix would have widened the whitelist rather than declared the one key the + // block reads — and the tier would be back to reporting nothing useful. + expect(unknownProps(``)).toEqual([ + ' has no prop "viewName"', + ]); + expect(unknownProps(``)).toEqual([ + ' has no prop "view"', + ]); + }); +}); + +describe("kind:'html' — `dataSource` on a NON gate-wrapped block still reports (#6678)", () => { + // The pin that refuses option A. `flex` and `card` do not wrap + // `ElementDataSourceGate` and read nothing from the binding, so an author who + // writes it there must still be told. Adding `dataSource` to + // `sdui-parser`'s `BASE_PROPS` would turn both of these green and make the + // diagnostic lie in the other direction. + it.each(['flex', 'card'])('%s reports unknown-prop for dataSource', (tag) => { + expect(unknownProps(`<${tag} ${BINDING} />`)).toEqual([ + `<${tag}> has no prop "dataSource"`, + ]); + }); +}); diff --git a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts index 775f1c16f8..e3a3617696 100644 --- a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts +++ b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts @@ -231,7 +231,7 @@ import { describe, it, expect } from 'vitest'; import { ComponentRegistry } from '@object-ui/core'; -import { ComponentPropsMap } from '@objectstack/spec/ui'; +import { ComponentPropsMap, PageComponentSchema } from '@objectstack/spec/ui'; import { MANIFEST_INPUT_TYPES, inputTypeArms } from '@object-ui/sdui-parser'; import type { ComponentInput } from '@object-ui/types'; import { @@ -310,9 +310,42 @@ function declaredInputs(type: string): string[] | null { return declaredInputEntries(type)?.map((input) => input.name) ?? null; } +/** + * Keys the spec accepts on the NODE itself, on every page component + * (objectui#6678). + * + * `ComponentPropsMap[type]` is the per-block PROPS half of a component's + * contract. It is not the whole contract: `PageComponentSchema` — the schema of + * the node these `inputs` describe — carries its own top-level keys, and the + * html tier validates an author's attributes against `BASE_PROPS` ∪ `inputs`, + * with no third place for a node-level key to be declared. + * + * That distinction was invisible while no block declared a node-level key, and + * it stopped being invisible with `dataSource`: the spec's per-element data + * binding, read by every block that wraps `ElementDataSourceGate`, declared by + * none, and therefore reported by the html tier as a prop that does not exist — + * the one spelling that resolves a saved view, reported exactly like the + * spellings that do nothing (objectui#6678). The maintainer ruling of + * 2026-08-29 declares it on the blocks that read it, emitted at the wrapping + * seam; `BASE_PROPS` (which mirrors `BaseSchema`) was refused, because it would + * also silence the key on `flex` and `card`, which do not read it. + * + * So the forward direction's question — "does the contract accept this key on + * this node?" — has to be asked of the whole node contract. It is DERIVED here, + * not listed: `PageComponentSchema` is a `.pipe()`, whose input side is the + * object whose shape names those keys. A spec release that adds or removes one + * moves this set with it. + * + * ⚠️ Used by the FORWARD direction only. Feeding it to the reverse direction + * would demand that every covered block publish `dataSource` — including the + * blocks that do not read it, which is the exact lie the ruling refused. + */ +const nodeLevelSpecKeys = (): string[] => + authorableShapeKeys((PageComponentSchema as unknown as { _def?: { in?: unknown } })._def?.in); + /** Top-level inputs this block declares that its spec props schema rejects. */ function offSpecInputs(type: string): string[] { - const allowed = new Set(specTopLevelKeys(type)); + const allowed = new Set([...specTopLevelKeys(type), ...nodeLevelSpecKeys()]); return (declaredInputs(type) ?? []).filter((name) => !allowed.has(name)); } @@ -1338,6 +1371,23 @@ describe('registry `inputs` vs `@objectstack/spec` ComponentPropsMap (repo-wide) expect(specTopLevelKeys('page:card')).toContain('title'); }); + it('the node-level key set is derived, discriminating, and not empty', () => { + // Non-vacuity and calibration for the widening above, in both directions — + // a reader that returned [] would silently stop widening (every gate-wrapped + // block reds), and one that returned everything would stop being a gate. + const nodeKeys = nodeLevelSpecKeys(); + expect(nodeKeys.length).toBeGreaterThan(0); + // Accepted, and measured rather than assumed: `PageComponentSchema.safeParse` + // keeps `dataSource` on a bare node and drops the two spellings the + // objectui#6598 reporter tried instead. + expect(nodeKeys).toContain('dataSource'); + expect(nodeKeys).toContain('className'); + // Refused — a per-block prop is not a node key, and neither is an invention. + expect(nodeKeys).not.toContain('objectName'); + expect(nodeKeys).not.toContain('viewName'); + expect(nodeKeys).not.toContain('__not_a_spec_key__'); + }); + it.each(covered)('%s declares no top-level input the spec does not accept', (type) => { const exempt = new Set(exemptedFor(type)); const unregistered = offSpecInputs(type).filter((name) => !exempt.has(name)); diff --git a/content/docs/guide/ci-cd-pipeline.md b/content/docs/guide/ci-cd-pipeline.md index f4cfc739f7..6a3908a2d5 100644 --- a/content/docs/guide/ci-cd-pipeline.md +++ b/content/docs/guide/ci-cd-pipeline.md @@ -205,7 +205,7 @@ it green — which is how two of `type-check`'s gates came to be missing from th | Job key | Appears as | What it runs | When | |---|---|---|---| | `changeset-check` | Changeset Fixed Group Check | `scripts/check-changeset-fixed.mjs` — every workspace package must be in the changeset `fixed` group or explicitly ignored. It checks group *membership*; it does **not** check whether the PR added a changeset. | Every run | -| `type-check` | Type Check | `scripts/check-type-check-coverage.mjs`, then `pnpm check:phantom-deps`, then `pnpm check:self-import`, then `pnpm check:side-effects-array`, then `pnpm check:esm-specifiers`, then `pnpm check:spec-symbols`, then `pnpm check:action-forward-parity`, then `pnpm check:designer-field-key-parity`, then `pnpm check:icon-record-names`, then `pnpm check:i18n-keys`, then `pnpm check:i18n-drift`, then `pnpm type-check:scripts`, then `pnpm type-check`, then `pnpm type-check:vitest-setup`. The coverage guard runs first because turbo silently skips packages that have no `type-check` script, so a package without one would otherwise read as passing (#2911). `pnpm check:phantom-deps` fails when a released package imports a bare specifier its own `package.json` does not declare — a *phantom dependency*, invisible locally because the workspace root's `devDependencies` sit on the upward resolution path from every package directory and on no consumer's, so `require.resolve('react', { paths: ['packages/core/src'] })` succeeds while `@object-ui/core` declares react in no field at all ([#4394](https://github.com/objectstack-ai/objectui/issues/4394)). `pnpm check:self-import` runs next because it reuses that gate's parser: it fails when a file inside a package names its OWN package, a specifier that resolves through the package's `exports` map to `dist/` while `type-check` waits on `^build` — the *dependencies'* builds, never the package's own — so on a cold cache the declarations do not exist yet and the file fails with `TS2307`. Locally it is always green, because every local workflow builds before it type-checks and leaves a `dist/` behind; PR #4789's first run was red on exactly one such line ([#4801](https://github.com/objectstack-ai/objectui/issues/4801)). `pnpm check:side-effects-array` runs next, sources only and no build: it fails when a package's `sideEffects` ARRAY and its module bodies disagree in either direction — a module that registers something at load time and is not named (a bundler drops it, and the registration is gone from a *consumer's* app with no error, no warning and exit 0), or a name whose module no longer registers anything. `@object-ui/app-shell` declares such an array because both simpler answers are measurably wrong for it: omitting the field makes the whole package unshakeable, and `"sideEffects": false` silently drops three live SDUI widget registrations to zero chunks ([#6535](https://github.com/objectstack-ai/objectui/issues/6535), [#6683](https://github.com/objectstack-ai/objectui/issues/6683)). The enumeration is re-derived from the module bodies on every run rather than listed, so there is no second copy to rot. The artifact half of the same contract — do those registrations survive a real bundler — cannot run in this job at all: it needs a built console, so it lives in the SDUI registration pin step of `performance-budget.yml`. `pnpm check:esm-specifiers` follows it for the same reason — sources only, no build: it fails when a published package whose build preserves import specifiers (a bare emitting `tsc`, which never rewrites them) writes a relative specifier with no file extension. Node's ESM resolver does not extension-search relative specifiers, so such a specifier makes the published entry unloadable outside a bundler; `@object-ui/react`'s entry died with `ERR_MODULE_NOT_FOUND` while every bundler-based consumer, the whole test suite and CI stayed green ([#4538](https://github.com/objectstack-ai/objectui/issues/4538)). The half that actually *imports* each built entry needs a full build and runs in `node-esm-load-gate.yml`. `pnpm check:action-forward-parity` fails when an action renderer's forward whitelist drops a key the action runtime reads — the class that shipped six times one key at a time, each time green, because the key parses and publishes while the payload is dropped one hop before the runner ([#4050](https://github.com/objectstack-ai/objectui/issues/4050)). `pnpm check:designer-field-key-parity` fails when one of the field designers' statically declared payload shapes (`FieldMetadataPayload`, `ServerFieldSchema`, `DesignerFieldDefinition`) declares a key the installed `@objectstack/spec` `FieldSchema` refuses by NAME. Such a key makes `PUT /api/v1/meta/object/:name` return a hard 422 `INVALID_METADATA` that blocks *every subsequent save* of that object, and the author cannot tell from the designer UI which key did it — the class had been filed three times, each closed with a per-key tombstone written after the instance was found in production, with nothing detecting the next one ([#4644](https://github.com/objectstack-ai/objectui/issues/4644) `indexed`, [#4687](https://github.com/objectstack-ai/objectui/issues/4687) `distance_metric`, [#4676](https://github.com/objectstack-ai/objectui/issues/4676) `placeholder`, gated by [#5761](https://github.com/objectstack-ai/objectui/issues/5761)). It reads the accept set off the schema itself rather than from a list, and it covers a deliberately documented *subset* of the write path: a key that reaches the payload only through a `patchDef` spread or an index signature is outside its reach, and the boundary is stated in the script's own docblock. Its draft-I/O half — the `readFields`/`writeFields` round-trip, which has no declared shape to read — runs in the test suite as `object-fields-io.spec-keys.test.ts`. Same placement rationale as the gates around it: it parses the sources with `typescript` and imports the installed spec, so it needs the install and nothing built. `pnpm check:icon-record-names` fails when an authored icon NAME that reaches a resolver reading lucide's runtime `icons` record is not a live key of that record. lucide retires a spelling by dropping it from that record while keeping it as a deprecated named export, so the retired name still imports, still type-checks and still renders wherever it is used as a *component* — `Edit === SquarePen` is true — and resolves to nothing wherever it is used as a *string*: nothing goes red in either direction, which is why the class was repaired twice in two packages before anyone gated it ([#5586](https://github.com/objectstack-ai/objectui/issues/5586), [#5622](https://github.com/objectstack-ai/objectui/issues/5622), [#5633](https://github.com/objectstack-ai/objectui/issues/5633)). It carries no list of retired spellings — the record itself is the judgement — and it re-discovers the resolver population from source on every run, which is how its first pass found four record-reading resolvers nobody had catalogued. It sits here because it parses the sources with `typescript` and reads the installed lucide: the install, and nothing built. The two locale gates sit in the middle because both parse the sources with `typescript`: they need the install and nothing built. `pnpm check:i18n-keys` fails when a `t()` call site asks for a key the `en` pack does not define ([#3530](https://github.com/objectstack-ai/objectui/issues/3530)); `pnpm check:i18n-drift` fails when a change to an `en` string is not accompanied by the nine translation packs ([#3650](https://github.com/objectstack-ai/objectui/issues/3650)), and it is why this job's checkout sets `fetch-depth: 0` — it diffs against the merge base, which a depth-1 clone cannot resolve. `pnpm type-check:scripts` (`tsconfig.scripts.json`) covers `scripts/**/*.ts`, which `pnpm type-check` cannot reach at all — `scripts/` has no package.json, so turbo never walks it, and the coverage guard decides coverage per *package*. Until [#3494](https://github.com/objectstack-ai/objectui/issues/3494) that left the pin tests in `scripts/__tests__/` — including the one pinning this very page — compiled by nothing. `pnpm type-check:vitest-setup` (`tsconfig.vitest-setup.json`) closes the same gap for the four repo-root `vitest.setup.*` files, uncovered until [#3515](https://github.com/objectstack-ai/objectui/issues/3515); it runs *last*, after `pnpm type-check`, because `vitest.setup.dom.tsx` side-effect-imports four `@object-ui/*` packages and resolves them through the declarations that turbo's `^build` produces. | Every run; on a PR the steps short-circuit when only ignored paths changed | +| `type-check` | Type Check | `scripts/check-type-check-coverage.mjs`, then `pnpm check:phantom-deps`, then `pnpm check:self-import`, then `pnpm check:side-effects-array`, then `pnpm check:element-data-source-declaration`, then `pnpm check:esm-specifiers`, then `pnpm check:spec-symbols`, then `pnpm check:action-forward-parity`, then `pnpm check:designer-field-key-parity`, then `pnpm check:icon-record-names`, then `pnpm check:i18n-keys`, then `pnpm check:i18n-drift`, then `pnpm type-check:scripts`, then `pnpm type-check`, then `pnpm type-check:vitest-setup`. The coverage guard runs first because turbo silently skips packages that have no `type-check` script, so a package without one would otherwise read as passing (#2911). `pnpm check:phantom-deps` fails when a released package imports a bare specifier its own `package.json` does not declare — a *phantom dependency*, invisible locally because the workspace root's `devDependencies` sit on the upward resolution path from every package directory and on no consumer's, so `require.resolve('react', { paths: ['packages/core/src'] })` succeeds while `@object-ui/core` declares react in no field at all ([#4394](https://github.com/objectstack-ai/objectui/issues/4394)). `pnpm check:self-import` runs next because it reuses that gate's parser: it fails when a file inside a package names its OWN package, a specifier that resolves through the package's `exports` map to `dist/` while `type-check` waits on `^build` — the *dependencies'* builds, never the package's own — so on a cold cache the declarations do not exist yet and the file fails with `TS2307`. Locally it is always green, because every local workflow builds before it type-checks and leaves a `dist/` behind; PR #4789's first run was red on exactly one such line ([#4801](https://github.com/objectstack-ai/objectui/issues/4801)). `pnpm check:side-effects-array` runs next, sources only and no build: it fails when a package's `sideEffects` ARRAY and its module bodies disagree in either direction — a module that registers something at load time and is not named (a bundler drops it, and the registration is gone from a *consumer's* app with no error, no warning and exit 0), or a name whose module no longer registers anything. `@object-ui/app-shell` declares such an array because both simpler answers are measurably wrong for it: omitting the field makes the whole package unshakeable, and `"sideEffects": false` silently drops three live SDUI widget registrations to zero chunks ([#6535](https://github.com/objectstack-ai/objectui/issues/6535), [#6683](https://github.com/objectstack-ai/objectui/issues/6683)). The enumeration is re-derived from the module bodies on every run rather than listed, so there is no second copy to rot. The artifact half of the same contract — do those registrations survive a real bundler — cannot run in this job at all: it needs a built console, so it lives in the SDUI registration pin step of `performance-budget.yml`. `pnpm check:element-data-source-declaration` runs next, sources only and no build: it fails when a source that consumes `ElementDataSourceGate` does not also pass through `elementDataSourceBlock()`, the seam that declares the `dataSource` key the gate reads. A block that wraps the gate off-seam publishes an authoring surface missing the one key its own runtime honours, and the html tier reports that key with the same `unknown-prop` warning it gives the spellings that do nothing ([#6678](https://github.com/objectstack-ai/objectui/issues/6678)). `pnpm check:esm-specifiers` follows it for the same reason — sources only, no build: it fails when a published package whose build preserves import specifiers (a bare emitting `tsc`, which never rewrites them) writes a relative specifier with no file extension. Node's ESM resolver does not extension-search relative specifiers, so such a specifier makes the published entry unloadable outside a bundler; `@object-ui/react`'s entry died with `ERR_MODULE_NOT_FOUND` while every bundler-based consumer, the whole test suite and CI stayed green ([#4538](https://github.com/objectstack-ai/objectui/issues/4538)). The half that actually *imports* each built entry needs a full build and runs in `node-esm-load-gate.yml`. `pnpm check:action-forward-parity` fails when an action renderer's forward whitelist drops a key the action runtime reads — the class that shipped six times one key at a time, each time green, because the key parses and publishes while the payload is dropped one hop before the runner ([#4050](https://github.com/objectstack-ai/objectui/issues/4050)). `pnpm check:designer-field-key-parity` fails when one of the field designers' statically declared payload shapes (`FieldMetadataPayload`, `ServerFieldSchema`, `DesignerFieldDefinition`) declares a key the installed `@objectstack/spec` `FieldSchema` refuses by NAME. Such a key makes `PUT /api/v1/meta/object/:name` return a hard 422 `INVALID_METADATA` that blocks *every subsequent save* of that object, and the author cannot tell from the designer UI which key did it — the class had been filed three times, each closed with a per-key tombstone written after the instance was found in production, with nothing detecting the next one ([#4644](https://github.com/objectstack-ai/objectui/issues/4644) `indexed`, [#4687](https://github.com/objectstack-ai/objectui/issues/4687) `distance_metric`, [#4676](https://github.com/objectstack-ai/objectui/issues/4676) `placeholder`, gated by [#5761](https://github.com/objectstack-ai/objectui/issues/5761)). It reads the accept set off the schema itself rather than from a list, and it covers a deliberately documented *subset* of the write path: a key that reaches the payload only through a `patchDef` spread or an index signature is outside its reach, and the boundary is stated in the script's own docblock. Its draft-I/O half — the `readFields`/`writeFields` round-trip, which has no declared shape to read — runs in the test suite as `object-fields-io.spec-keys.test.ts`. Same placement rationale as the gates around it: it parses the sources with `typescript` and imports the installed spec, so it needs the install and nothing built. `pnpm check:icon-record-names` fails when an authored icon NAME that reaches a resolver reading lucide's runtime `icons` record is not a live key of that record. lucide retires a spelling by dropping it from that record while keeping it as a deprecated named export, so the retired name still imports, still type-checks and still renders wherever it is used as a *component* — `Edit === SquarePen` is true — and resolves to nothing wherever it is used as a *string*: nothing goes red in either direction, which is why the class was repaired twice in two packages before anyone gated it ([#5586](https://github.com/objectstack-ai/objectui/issues/5586), [#5622](https://github.com/objectstack-ai/objectui/issues/5622), [#5633](https://github.com/objectstack-ai/objectui/issues/5633)). It carries no list of retired spellings — the record itself is the judgement — and it re-discovers the resolver population from source on every run, which is how its first pass found four record-reading resolvers nobody had catalogued. It sits here because it parses the sources with `typescript` and reads the installed lucide: the install, and nothing built. The two locale gates sit in the middle because both parse the sources with `typescript`: they need the install and nothing built. `pnpm check:i18n-keys` fails when a `t()` call site asks for a key the `en` pack does not define ([#3530](https://github.com/objectstack-ai/objectui/issues/3530)); `pnpm check:i18n-drift` fails when a change to an `en` string is not accompanied by the nine translation packs ([#3650](https://github.com/objectstack-ai/objectui/issues/3650)), and it is why this job's checkout sets `fetch-depth: 0` — it diffs against the merge base, which a depth-1 clone cannot resolve. `pnpm type-check:scripts` (`tsconfig.scripts.json`) covers `scripts/**/*.ts`, which `pnpm type-check` cannot reach at all — `scripts/` has no package.json, so turbo never walks it, and the coverage guard decides coverage per *package*. Until [#3494](https://github.com/objectstack-ai/objectui/issues/3494) that left the pin tests in `scripts/__tests__/` — including the one pinning this very page — compiled by nothing. `pnpm type-check:vitest-setup` (`tsconfig.vitest-setup.json`) closes the same gap for the four repo-root `vitest.setup.*` files, uncovered until [#3515](https://github.com/objectstack-ai/objectui/issues/3515); it runs *last*, after `pnpm type-check`, because `vitest.setup.dom.tsx` side-effect-imports four `@object-ui/*` packages and resolves them through the declarations that turbo's `^build` produces. | Every run; on a PR the steps short-circuit when only ignored paths changed | | `test` | Test (shard N/4) | `pnpm test --shard=N/4` across a 4-runner matrix with `fail-fast: false`, so every shard reports its own failures. No coverage instrumentation — v8 adds 40–100% overhead. | Pull requests and merge-queue builds (everything but `push`); steps short-circuit on a PR that changed only ignored paths | | `test-coverage` | Test (coverage shard N/4) | `pnpm test:coverage --reporter=blob --shard=N/4` across a 4-runner matrix with `fail-fast: false`. Each shard writes `.vitest-reports/blob-N-4.json` — raw coverage and test results in one file — and uploads it as an artifact even when the shard is red, which is what makes a failing coverage run diagnosable at all (vitest deletes `coverage/` on a red run unless `coverage.reportOnFailure` is set, [#5402](https://github.com/objectstack-ai/objectui/issues/5402)). The configured coverage thresholds are neutralised on the shard legs, because a quarter of the suite judged against a whole-suite threshold is not a defect signal; they are enforced once, on the merged report, by the job below ([#5403](https://github.com/objectstack-ai/objectui/issues/5403)). | **Push only** | | `coverage-report` | Test (coverage) | Downloads the four blob reports, refuses to continue unless all four arrived, merges them with `pnpm test:coverage --merge-reports` into one complete report — which is where the configured coverage thresholds are enforced, over the whole merged map, the shard legs having overridden them to zero — and publishes that report as the `coverage-report` artifact (kept 7 days, the same as the blobs it is derived from). Its last step runs on every path and states the outcome: the job is **red, with an error annotation**, whenever the gate did not run for the commit — before [#5403](https://github.com/objectstack-ai/objectui/issues/5403) the final step carried the implicit `success()` and was silently skipped by 311 of 373 coverage jobs, which is how four days of a 100%-failing coverage job went unnoticed. A breach of the thresholds is reported *separately* from a lane that never delivered, because the two call for opposite actions. ⛔ It never merges a report from fewer than four shards: a wrong coverage number is worse than a missing one. The Codecov upload this job used to carry was retired by [#5436](https://github.com/objectstack-ai/objectui/issues/5436) — `CODECOV_TOKEN` was never set, so it failed on every push; the trend dashboard and PR coverage comments are gone with it, the gate is not. | **Push only** | diff --git a/package.json b/package.json index c7be49433a..4373f1d8f3 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "check:eager-closure": "node scripts/check-eager-closure-budget.mjs", "check:side-effects-array": "node scripts/check-side-effects-array.mjs", "check:sdui-registration-pins": "node scripts/check-sdui-registration-pins.mjs", + "check:element-data-source-declaration": "node scripts/check-element-data-source-declaration.mjs", "check:docs-route-closure": "node scripts/check-docs-route-eager-closure.mjs", "check:entry-guard": "node scripts/check-entry-guard.mjs", "check:upstream-port-parity": "node scripts/check-upstream-port-parity.mjs", diff --git a/packages/components/src/renderers/basic/record-picker.tsx b/packages/components/src/renderers/basic/record-picker.tsx index 5e33357425..d990059569 100644 --- a/packages/components/src/renderers/basic/record-picker.tsx +++ b/packages/components/src/renderers/basic/record-picker.tsx @@ -32,7 +32,7 @@ */ import * as React from 'react'; -import { ComponentRegistry } from '@object-ui/core'; +import { ComponentRegistry, elementDataSourceBlock } from '@object-ui/core'; import { ElementDataSourceErrorPanel, ElementDataSourceLoadingPanel, @@ -300,7 +300,26 @@ function ElementRecordPickerRenderer({ schema }: { schema: any }) { ); } -ComponentRegistry.register('record_picker', ElementRecordPickerRenderer, { +// This block CONSUMES the gate's family without the JSX wrapper — the hook plus +// the two status panels — because its object lives under `properties` rather +// than on a schema key the gate could write. It reads `dataSource` exactly as +// the wrapping blocks do, so it declares it from the same seam: the marker is +// applied to the renderer at its registration rather than at a gate tag it does +// not have. Found by the render probe in +// `apps/console/src/__tests__/element-data-source-input-injection.test.tsx`, +// which detects the gate's own panels and does not care how they got there. +// +// ⚠️ The seam is imported from `@object-ui/core`, NOT from `@object-ui/react` +// where the sibling blocks take it — the ONE function under the ONE name, by a +// second path. This is the only site in the family that calls it at MODULE +// SCOPE inside a package this widely imported, and that combination is a real +// hazard here: 101 suites partially mock `@object-ui/react` by hand-listing the +// exports they return, so a module-scope read of a name those lists do not carry +// throws at COLLECTION time — the whole test file fails before it runs an +// assertion. Measured on this change: 17 files, all four CI shards, and not one +// failed assertion among them. Nothing in this repo mocks `@object-ui/core`, and +// this module already imports `ComponentRegistry` from it at module scope. +ComponentRegistry.register('record_picker', elementDataSourceBlock(ElementRecordPickerRenderer), { namespace: 'element', skipFallback: true, label: 'Record Picker', diff --git a/packages/core/src/data-scope/element-data-source.ts b/packages/core/src/data-scope/element-data-source.ts index 40c70d4c70..0d312484c2 100644 --- a/packages/core/src/data-scope/element-data-source.ts +++ b/packages/core/src/data-scope/element-data-source.ts @@ -278,3 +278,119 @@ export function elementDataSourceViewNotFoundMessage( : 'This object has no saved views.'; return `dataSource.view "${view}" was not found on object "${object}". ${known}`; } + +/* ------------------------------------------------------------------ * + * The DECLARATION half — one copy, next to the semantics (objectui#6678) + * ------------------------------------------------------------------ */ + +/** + * The spec key this module resolves, spelled once. + * + * Every consumer that needs the NAME (the registry injection, its gates, the + * pins) reads it from here rather than repeating the literal, so "which key is + * this?" has exactly one answer in the tree. + */ +export const ELEMENT_DATA_SOURCE_KEY = 'dataSource'; + +/** + * The authoring-surface declaration of {@link ELEMENT_DATA_SOURCE_KEY} — the one + * copy, emitted into every registration that wraps the runtime gate. + * + * ## Why it lives here and is injected rather than written per block + * + * `PageComponentSchema.dataSource` is READ by `ElementDataSourceGate` in + * `@object-ui/react` on behalf of every object-bound block that wraps itself in + * it — nine of them at the time of writing. It was declared by NONE: `validateTree` + * skips `BASE_PROPS` and otherwise looks a prop up in the component's `inputs`, + * and `dataSource` was in neither. So the html tier reported the one spelling + * that resolves a saved view — the only one that works — with the identical + * `unknown-prop` warning it gives the spellings that do nothing, on the tier + * meant to accept AI-authored pages, where the diagnostic IS the contract + * (objectui#6678, the reporter of objectui#6598 gave up on exactly this). + * + * The maintainer ruling of 2026-08-29 took option B **in the injection form**: + * emitted mechanically at the wrapping seam, so a block declares the key from + * the same place that reads it. Nine hand-kept copies is the shape that ruling + * refused — they drift, and a tenth block would simply forget. Adding the key to + * `sdui-parser`'s `BASE_PROPS` (option A) was refused too: that set mirrors + * `BaseSchema`, and silencing `dataSource` on blocks that do NOT read it + * (`flex`, `card`) would make the diagnostic lie in the other direction. + * + * ## Why `object`, and not a bare `'object'` kind + * + * The `binding: 'object'` marker is what tells a consumer this input names an + * OBJECT — `validateTree` records it as a binding site, and the designer offers + * an object picker rather than a free-text blob. The `type` stays the coarse + * `'object'` kind because the value is a record; the two words are unrelated and + * both are correct here. + */ +export const ELEMENT_DATA_SOURCE_INPUT: { + name: string; + type: 'object'; + label: string; + binding: 'object'; + description: string; +} = { + name: ELEMENT_DATA_SOURCE_KEY, + type: 'object', + label: 'Data Source', + binding: 'object', + description: + 'Per-element data binding: { object, view, filter, sort, limit }. Overrides the ' + + 'page-level object context for this block; `view` names a saved view of `object`, ' + + 'and `filter` is ANDed with the view’s own rather than replacing it.', +}; + +/** + * The renderers that wrap the runtime gate. + * + * A `WeakSet` rather than a property on the component: marking must not mutate a + * `React.forwardRef` exotic object or show up when a test snapshots a + * registration, and an unregistered renderer must stay collectable. + */ +const elementDataSourceBlocks = new WeakSet(); + +/** + * Declare that `renderer` wraps `ElementDataSourceGate`, so its registrations + * publish {@link ELEMENT_DATA_SOURCE_INPUT}. + * + * This is the SEAM, and it is deliberately the only way in: `Registry.register` + * injects the declaration for exactly the renderers marked here, so the key is + * published by whatever consumes the gate and by nothing else — which is what + * keeps the `flex` / `card` direction of the ruling true. + * `scripts/check-element-data-source-declaration.mjs` fails any source that + * consumes the gate without passing through it, so a new block cannot forget. + * + * ## Two import paths, ONE name and ONE function + * + * `@object-ui/react` re-exports this beside `ElementDataSourceGate` so the seam is + * findable next to the thing it is about. That export is for DISCOVERABILITY: + * every CALL SITE imports from here, and + * `scripts/check-element-data-source-declaration.mjs` enforces it. + * + * The rule is measured, not stylistic. A registration runs at MODULE SCOPE, and + * `element:record_picker`'s registration in `@object-ui/components` is the + * worked example. 101 suites in this repo partially + * mock `@object-ui/react` by hand-listing the exports they return, and a + * module-scope read of a name those lists do not carry throws at COLLECTION + * time — the importing test file fails before it runs a single assertion. + * Measured: 17 files, all four CI shards, zero failed assertions among them. + * Nothing mocks `@object-ui/core`, and `@object-ui/components` already imports + * `ComponentRegistry` from it at module scope, so this path adds no coupling + * that was not already there. + */ +export function elementDataSourceBlock(renderer: C): C { + if (renderer && (typeof renderer === 'object' || typeof renderer === 'function')) { + elementDataSourceBlocks.add(renderer as unknown as object); + } + return renderer; +} + +/** Whether `renderer` was marked by {@link elementDataSourceBlock}. */ +export function isElementDataSourceBlock(renderer: unknown): boolean { + return ( + !!renderer + && (typeof renderer === 'object' || typeof renderer === 'function') + && elementDataSourceBlocks.has(renderer as object) + ); +} diff --git a/packages/core/src/data-scope/index.ts b/packages/core/src/data-scope/index.ts index 9aa78fa777..bfa9fc70d2 100644 --- a/packages/core/src/data-scope/index.ts +++ b/packages/core/src/data-scope/index.ts @@ -25,7 +25,11 @@ export { export { collectSavedViews, composeElementDataSource, + ELEMENT_DATA_SOURCE_INPUT, + ELEMENT_DATA_SOURCE_KEY, + elementDataSourceBlock, elementDataSourceViewNotFoundMessage, + isElementDataSourceBlock, isElementDataSourceConfig, resolveSavedView, type ComposedElementDataSource, diff --git a/packages/core/src/registry/Registry.ts b/packages/core/src/registry/Registry.ts index a297282163..4749cfc1a3 100644 --- a/packages/core/src/registry/Registry.ts +++ b/packages/core/src/registry/Registry.ts @@ -7,6 +7,10 @@ */ import type { ComponentMeta as CanonicalComponentMeta } from '@object-ui/types'; +import { + ELEMENT_DATA_SOURCE_INPUT, + isElementDataSourceBlock, +} from '../data-scope/element-data-source.js'; import { PUBLIC_BLOCKS } from './public-blocks.js'; export type ComponentRenderer = T; @@ -176,6 +180,48 @@ type LazyEntry = { pending?: Promise; }; +/** + * Emit the spec's `dataSource` input for a registration whose renderer wraps + * `ElementDataSourceGate` (objectui#6678). + * + * ## The one place, and why it is this one + * + * The maintainer ruling of 2026-08-29 adopted option B **in the injection + * form**: the declaration is emitted mechanically at the wrapping seam so every + * gate-wrapping registration declares the key from the same place that reads it + * — one mechanism rather than nine hand-kept copies across nine packages, which + * drift and which a tenth block would simply forget. `register()` is where every + * one of those registrations passes through, so it is where the emission lands; + * the DECLARATION itself is `ELEMENT_DATA_SOURCE_INPUT`, which lives beside the + * binding's own semantics in `data-scope/element-data-source.ts`. + * + * ## What it must NOT do, which is half the ruling + * + * Widening this to every registration is option A wearing a different hat: it + * would silence `dataSource` on `flex` and `card`, which do not read it, and the + * diagnostic would lie in the other direction instead of the one it lied in + * before. So the condition is the marker and nothing else — no heuristic over + * `objectName`, no "looks object-bound". + * + * ## Idempotent, and it never overwrites + * + * A registration that declares `dataSource` ITSELF keeps its own entry: the + * emission fills a gap, it does not own the key. (Nothing declares it today — + * that is the defect — but a block whose binding needs a narrower description + * later must be able to say so without fighting this function.) Re-registering + * the same component, which the registry allows and tests do constantly, is + * likewise a no-op rather than a growing `inputs` array. + */ +export function withElementDataSourceInput( + component: ComponentRenderer, + meta?: ComponentMeta, +): ComponentMeta | undefined { + if (!isElementDataSourceBlock(component)) return meta; + const inputs: NonNullable = meta?.inputs ?? []; + if (inputs.some((input) => input?.name === ELEMENT_DATA_SOURCE_INPUT.name)) return meta; + return { ...(meta ?? {}), inputs: [...inputs, { ...ELEMENT_DATA_SOURCE_INPUT }] } as ComponentMeta; +} + export class Registry { private components = new Map>(); private lazyEntries = new Map(); @@ -214,6 +260,9 @@ export class Registry { */ register(type: string, component: ComponentRenderer, meta?: ComponentMeta) { const fullType = meta?.namespace ? `${meta.namespace}:${type}` : type; + // The `dataSource` declaration is EMITTED here, not written by the blocks + // (objectui#6678). See `withElementDataSourceInput`. + const resolvedMeta = withElementDataSourceInput(component, meta); // Warn if registering without namespace (deprecated pattern) if (!meta?.namespace) { @@ -236,7 +285,7 @@ export class Registry { this.components.set(fullType, { type: fullType, component, - ...meta + ...resolvedMeta }); // Also register without namespace for backward compatibility @@ -261,7 +310,7 @@ export class Registry { this.components.set(type, { type: fullType, // Keep reference to namespaced type component, - ...meta + ...resolvedMeta }); } diff --git a/packages/plugin-calendar/src/index.tsx b/packages/plugin-calendar/src/index.tsx index d343c0ca5e..b3e5105009 100644 --- a/packages/plugin-calendar/src/index.tsx +++ b/packages/plugin-calendar/src/index.tsx @@ -7,7 +7,7 @@ */ import React from 'react'; -import { ComponentRegistry } from '@object-ui/core'; +import { ComponentRegistry, elementDataSourceBlock } from '@object-ui/core'; import { ElementDataSourceGate, useSchemaContext, @@ -236,7 +236,7 @@ function resolveHostDataSource(raw: unknown): ObjectCalendarComponentProps['data } // Register object-calendar component -export const ObjectCalendarRenderer: React.FC<{ schema: any; [key: string]: any }> = ({ +export const ObjectCalendarRenderer: React.FC<{ schema: any; [key: string]: any }> = elementDataSourceBlock(({ schema, // The merged node className + SDUI scope class, set by `SchemaRenderer` AFTER // the node's `props` container: CONSUMED and forwarded, as before. @@ -288,7 +288,7 @@ export const ObjectCalendarRenderer: React.FC<{ schema: any; [key: string]: any )} ); -}; +}); ComponentRegistry.register('object-calendar', ObjectCalendarRenderer, { namespace: 'plugin-calendar', diff --git a/packages/plugin-charts/src/ObjectChart.tsx b/packages/plugin-charts/src/ObjectChart.tsx index 514b73feaa..122ec10b5e 100644 --- a/packages/plugin-charts/src/ObjectChart.tsx +++ b/packages/plugin-charts/src/ObjectChart.tsx @@ -2,7 +2,7 @@ import React, { useState, useEffect, useContext, useCallback, useMemo } from 'react'; import { useDataScope, SchemaRendererContext, SchemaRenderer, useDrillNavigation, useFilterScope, ElementDataSourceGate, type ElementDataSourceMapping } from '@object-ui/react'; import { ChartRenderer } from './ChartRenderer'; -import { ComponentRegistry, humanizeLabel, extractRecords, computeDrillFilter, isDrillEnabled, resolveDrillTitle, resolveFilterPlaceholders, resolveContextTokens, shiftFilterByCompareTo, compareToTrendLabelKey, buildChartSeries, buildOptionColorMap, deriveDimensionLabelMaps, dimensionOptionTranslator, loadDimensionFieldMeta, relabelDimensions, localizeFieldOptions, type DimensionFieldMeta, type CompareToConfig, type DrillEvent, type ChartResultField, type ChartSegmentClickEvent } from '@object-ui/core'; +import { ComponentRegistry, humanizeLabel, extractRecords, computeDrillFilter, isDrillEnabled, resolveDrillTitle, resolveFilterPlaceholders, resolveContextTokens, shiftFilterByCompareTo, compareToTrendLabelKey, buildChartSeries, buildOptionColorMap, deriveDimensionLabelMaps, dimensionOptionTranslator, loadDimensionFieldMeta, relabelDimensions, localizeFieldOptions, elementDataSourceBlock, type DimensionFieldMeta, type CompareToConfig, type DrillEvent, type ChartResultField, type ChartSegmentClickEvent } from '@object-ui/core'; import { Sheet, SheetContent, SheetHeader, SheetTitle, Dialog, DialogContent, DialogHeader, DialogTitle, RefreshIndicator, Button, ChartSkeleton } from '@object-ui/components'; import { AlertCircle, ArrowUpRight } from 'lucide-react'; import { useSafeFieldLabel, useSafeTranslate } from '@object-ui/i18n'; @@ -1042,7 +1042,7 @@ const OBJECT_CHART_DATA_SOURCE: ElementDataSourceMapping = { * here, beside the registration, rather than in `ChartContainerImpl` — the * binding is a registry-boundary concern, not a rendering one. */ -export const ObjectChartBlock = (props: any) => ( +export const ObjectChartBlock = elementDataSourceBlock((props: any) => ( ( > {(bound) => } -); +)); // Register it ComponentRegistry.register('object-chart', ObjectChartBlock, { diff --git a/packages/plugin-dashboard/src/index.tsx b/packages/plugin-dashboard/src/index.tsx index a53d1b8289..2730153e99 100644 --- a/packages/plugin-dashboard/src/index.tsx +++ b/packages/plugin-dashboard/src/index.tsx @@ -7,7 +7,7 @@ */ import React from 'react'; -import { ComponentRegistry } from '@object-ui/core'; +import { ComponentRegistry, elementDataSourceBlock } from '@object-ui/core'; import { ElementDataSourceGate, type ElementDataSourceMapping } from '@object-ui/react'; import { DashboardRenderer } from './DashboardRenderer'; import { DashboardGridLayout } from './DashboardGridLayout'; @@ -171,7 +171,7 @@ const OBJECT_METRIC_DATA_SOURCE: ElementDataSourceMapping = { * spread already provided, and a host that renders this component with explicit * props and no schema at all (the dashboard grid path) is untouched. */ -const ObjectMetricBlock: React.FC<{ schema?: any; [key: string]: any }> = ({ schema, ...props }) => ( +const ObjectMetricBlock: React.FC<{ schema?: any; [key: string]: any }> = elementDataSourceBlock(({ schema, ...props }) => ( = ({ sch /> )} -); +)); // Register object-aware metric widget (async data loading with error states) ComponentRegistry.register( @@ -286,7 +286,7 @@ const OBJECT_PIVOT_DATA_SOURCE: ElementDataSourceMapping = { * is no binding — so the dashboard/manual paths that render this component with a * plain schema behave exactly as before. */ -const ObjectPivotBlock: React.FC<{ schema?: any; [key: string]: any }> = ({ schema, ...props }) => ( +const ObjectPivotBlock: React.FC<{ schema?: any; [key: string]: any }> = elementDataSourceBlock(({ schema, ...props }) => ( = ({ sche > {(bound) => } -); +)); // Register object-aware pivot table (async data loading) ComponentRegistry.register( diff --git a/packages/plugin-detail/src/__tests__/recordRelatedListInputs.spec-parity.test.ts b/packages/plugin-detail/src/__tests__/recordRelatedListInputs.spec-parity.test.ts index 462064c57f..a09a789cfe 100644 --- a/packages/plugin-detail/src/__tests__/recordRelatedListInputs.spec-parity.test.ts +++ b/packages/plugin-detail/src/__tests__/recordRelatedListInputs.spec-parity.test.ts @@ -33,7 +33,7 @@ import { describe, it, expect } from 'vitest'; import { ComponentRegistry } from '@object-ui/core'; -import { RecordRelatedListProps } from '@objectstack/spec/ui'; +import { PageComponentSchema, RecordRelatedListProps } from '@objectstack/spec/ui'; import '../index'; type ShapeCarrier = { shape?: unknown; _def?: { shape?: unknown } }; @@ -70,6 +70,26 @@ function innerObject(schema: unknown, key: string): unknown { } const specTopLevelKeys = (): string[] => shapeKeys(RecordRelatedListProps); + +/** + * Keys the spec accepts on the NODE, on every page component (objectui#6678). + * + * `RecordRelatedListProps` is this block's PROPS contract, which is not the whole + * contract of the node an `inputs` list describes: `PageComponentSchema` carries + * its own top-level keys, and the html tier validates an author's attributes + * against `BASE_PROPS` + `inputs` with no third place for a node-level key to be + * declared. `dataSource` — the spec's per-element binding, which this block reads + * through `ElementDataSourceGate` and now declares from that seam — is one of + * them, and it is accepted here for the same reason `className` would be. + * + * DERIVED from `PageComponentSchema`'s own input shape (it is a `.pipe()`), never + * listed, so a spec release moves it. The repo-wide half of this gate makes the + * same widening in + * `apps/console/src/__tests__/registry-inputs-spec-parity.test.ts`, and both + * assert the derivation still discriminates. + */ +const nodeLevelSpecKeys = (): string[] => + shapeKeys((PageComponentSchema as unknown as { _def?: { in?: unknown } })._def?.in); const specAddKeys = (): string[] => shapeKeys(innerObject(RecordRelatedListProps, 'add')); const specPickerKeys = (): string[] => shapeKeys(innerObject(innerObject(RecordRelatedListProps, 'add'), 'picker')); @@ -90,10 +110,21 @@ describe('record:related_list — registry inputs vs @objectstack/spec', () => { }); it('declares no top-level input the spec does not accept', () => { - const allowed = new Set(specTopLevelKeys()); + const allowed = new Set([...specTopLevelKeys(), ...nodeLevelSpecKeys()]); expect(inputNames().filter((name) => !allowed.has(name))).toEqual([]); }); + it('the node-level widening is derived, discriminating and non-empty', () => { + // Calibration, in both directions: [] would silently stop widening and this + // block would red on a key the spec accepts, while an everything-set would + // stop this pin being a gate at all. + const nodeKeys = nodeLevelSpecKeys(); + expect(nodeKeys.length).toBeGreaterThan(0); + expect(nodeKeys).toContain('dataSource'); + expect(nodeKeys).not.toContain('relationshipField'); + expect(nodeKeys).not.toContain('__not_a_spec_key__'); + }); + it('publishes `relationshipValueField`, which the renderer has read all along', () => { // A KEY-reachability claim, so the criterion is that the key SURVIVES the // parse rather than merely that the parse succeeds. The verdict behind that diff --git a/packages/plugin-detail/src/index.tsx b/packages/plugin-detail/src/index.tsx index 2a6670f7e5..89a66f3c1f 100644 --- a/packages/plugin-detail/src/index.tsx +++ b/packages/plugin-detail/src/index.tsx @@ -7,7 +7,7 @@ */ import * as React from 'react'; -import { ComponentRegistry, type ComponentInput } from '@object-ui/core'; +import { ComponentRegistry, elementDataSourceBlock, type ComponentInput } from '@object-ui/core'; import { ElementDataSourceGate, noDataSourceMessage, @@ -241,7 +241,7 @@ export const DetailViewRenderer: React.FC<{ schema: any; dataSource?: unknown; [key: string]: any; -}> = ({ schema, dataSource: dataSourceProp, ...props }) => { +}> = elementDataSourceBlock(({ schema, dataSource: dataSourceProp, ...props }) => { // The family's ONE resolution rule (`@object-ui/react`): explicit adapter // first, `SchemaRendererProvider` context second, and the spec BINDING never // mistaken for an adapter. Sharing the hook is what keeps the three blocks @@ -274,7 +274,7 @@ export const DetailViewRenderer: React.FC<{ {(bound) => } ); -}; +}); // Register detail-view through the wrapper above, NOT the raw component: the // registry entry is what a `SchemaRenderer` reaches, and the wrapper is what diff --git a/packages/plugin-detail/src/renderers/record-related-list.tsx b/packages/plugin-detail/src/renderers/record-related-list.tsx index d8b3487e2e..91e54d3e16 100644 --- a/packages/plugin-detail/src/renderers/record-related-list.tsx +++ b/packages/plugin-detail/src/renderers/record-related-list.tsx @@ -22,7 +22,7 @@ import { import { useFieldPermissions, usePermissions } from '@object-ui/permissions'; import { useObjectTranslation, pickLocalized } from '@object-ui/i18n'; import { humanizeLabel } from '@object-ui/fields'; -import { columnIdentity } from '@object-ui/core'; +import { columnIdentity, elementDataSourceBlock } from '@object-ui/core'; import type { RecordRelatedListComponentProps } from '@object-ui/types'; import { RelatedList } from '../RelatedList'; @@ -315,7 +315,7 @@ const NO_SCHEMA = {} as RecordRelatedListRendererProps['schema']; * block bound under one entry point and unbound under another is the same * "declared but not reached" shape in miniature. */ -export const RecordRelatedListRenderer: React.FC = (props) => { +export const RecordRelatedListRenderer: React.FC = elementDataSourceBlock((props) => { // The record context's adapter, not the schema-renderer context's: this list // reads its rows through `ctx.dataSource`, and resolving `view` against a // different source than the rows come from could report a view as missing on @@ -332,6 +332,6 @@ export const RecordRelatedListRenderer: React.FC {(bound) => } ); -}; +}); export default RecordRelatedListRenderer; diff --git a/packages/plugin-form/src/index.tsx b/packages/plugin-form/src/index.tsx index bece399aa4..a8da5196b8 100644 --- a/packages/plugin-form/src/index.tsx +++ b/packages/plugin-form/src/index.tsx @@ -7,7 +7,7 @@ */ import React, { useContext } from 'react'; -import { ComponentRegistry } from '@object-ui/core'; +import { ComponentRegistry, elementDataSourceBlock } from '@object-ui/core'; import { ElementDataSourceGate, SchemaRendererContext, @@ -102,7 +102,7 @@ export type { DerivedDetail, InlineMode } from './deriveMasterDetail'; export { omitServerResolvedDefaults, isRequiredInForm } from './schemaDefaults'; // Register object-form component -const ObjectFormRenderer: React.FC<{ schema: any; dataSource?: unknown }> = ({ +const ObjectFormRenderer: React.FC<{ schema: any; dataSource?: unknown }> = elementDataSourceBlock(({ schema, dataSource: dataSourceProp, }) => { @@ -153,7 +153,7 @@ const ObjectFormRenderer: React.FC<{ schema: any; dataSource?: unknown }> = ({ {(bound) => } ); -}; +}); ComponentRegistry.register('object-form', ObjectFormRenderer, { namespace: 'plugin-form', @@ -233,7 +233,7 @@ ComponentRegistry.register('form', ObjectFormRenderer, { // Register embeddable-form component for standalone public forms import { EmbeddableForm } from './EmbeddableForm'; -const EmbeddableFormRenderer: React.FC<{ schema: any }> = ({ schema }) => { +const EmbeddableFormRenderer: React.FC<{ schema: any }> = elementDataSourceBlock(({ schema }) => { // Same bridge `object-form` above does, and for the same reason (#3144): // `EmbeddableForm` needs a dataSource to fetch the object schema — its own // comment says so — and `SchemaRenderer` only ever puts one on the context, @@ -264,7 +264,7 @@ const EmbeddableFormRenderer: React.FC<{ schema: any }> = ({ schema }) => { {(bound) => } ); -}; +}); ComponentRegistry.register('embeddable-form', EmbeddableFormRenderer, { namespace: 'plugin-form', @@ -302,7 +302,7 @@ ComponentRegistry.register('form-analytics', FormAnalyticsRenderer, { // together — see ADR-0001). import { MasterDetailForm } from './MasterDetailForm'; -const MasterDetailFormRenderer: React.FC<{ schema: any }> = ({ schema }) => { +const MasterDetailFormRenderer: React.FC<{ schema: any }> = elementDataSourceBlock(({ schema }) => { const ctx = useContext(SchemaRendererContext as React.Context); const dataSource = ctx?.dataSource ?? undefined; // The spec's `PageComponentSchema.dataSource` binding (objectstack#7121). @@ -329,7 +329,7 @@ const MasterDetailFormRenderer: React.FC<{ schema: any }> = ({ schema }) => { {(bound) => } ); -}; +}); ComponentRegistry.register('object-master-detail-form', MasterDetailFormRenderer, { namespace: 'plugin-form', @@ -412,7 +412,7 @@ const RECORD_LINE_ITEMS_DATA_SOURCE: ElementDataSourceMapping = { limit: 'limit', }; -const LineItemsPanelRenderer: React.FC<{ schema: any }> = ({ schema }) => ( +const LineItemsPanelRenderer: React.FC<{ schema: any }> = elementDataSourceBlock(({ schema }) => ( // The spec's `PageComponentSchema.dataSource` binding (objectstack#7121). No // `dataSource` is passed: the gate falls back to `SchemaRendererContext`, which // is the very adapter `LineItemsPanel` loads its rows through — resolving `view` @@ -426,7 +426,7 @@ const LineItemsPanelRenderer: React.FC<{ schema: any }> = ({ schema }) => ( > {(bound) => } -); +)); ComponentRegistry.register('line_items', LineItemsPanelRenderer, { namespace: 'record', diff --git a/packages/plugin-gantt/src/index.tsx b/packages/plugin-gantt/src/index.tsx index 363867ee2d..b982e2f263 100644 --- a/packages/plugin-gantt/src/index.tsx +++ b/packages/plugin-gantt/src/index.tsx @@ -7,7 +7,7 @@ */ import React from 'react'; -import { ComponentRegistry } from '@object-ui/core'; +import { ComponentRegistry, elementDataSourceBlock } from '@object-ui/core'; import { ElementDataSourceGate, useSchemaContext, @@ -80,7 +80,7 @@ const OBJECT_GANTT_DATA_SOURCE: ElementDataSourceMapping = { }; // Register component -export const ObjectGanttRenderer: React.FC<{ schema: any }> = ({ schema }) => { +export const ObjectGanttRenderer: React.FC<{ schema: any }> = elementDataSourceBlock(({ schema }) => { const { dataSource } = useSchemaContext() || {}; // The spec's `PageComponentSchema.dataSource` binding (objectstack#7121). A // gantt authored with the binding and no flat `objectName` produced no data @@ -97,7 +97,7 @@ export const ObjectGanttRenderer: React.FC<{ schema: any }> = ({ schema }) => { {(bound) => } ); -}; +}); ComponentRegistry.register('object-gantt', ObjectGanttRenderer, { namespace: 'plugin-gantt', diff --git a/packages/plugin-grid/src/index.tsx b/packages/plugin-grid/src/index.tsx index 96fbae13f8..117564b0a1 100644 --- a/packages/plugin-grid/src/index.tsx +++ b/packages/plugin-grid/src/index.tsx @@ -7,7 +7,7 @@ */ import React from 'react'; -import { ComponentRegistry, type ComponentInput } from '@object-ui/core'; +import { ComponentRegistry, elementDataSourceBlock, type ComponentInput } from '@object-ui/core'; import { ElementDataSourceGate, noDataSourceMessage, @@ -110,7 +110,7 @@ const gridNeedsDataSource = (schema: any, hostRows: unknown): boolean => { }; // Register object-grid component -export const ObjectGridRenderer: React.FC<{ schema: any; [key: string]: any }> = ({ schema, ...props }) => { +export const ObjectGridRenderer: React.FC<{ schema: any; [key: string]: any }> = elementDataSourceBlock(({ schema, ...props }) => { // ONE resolution rule for the whole family (objectui#5378): an explicit // adapter first, the `SchemaRendererProvider` context second. // @@ -144,7 +144,7 @@ export const ObjectGridRenderer: React.FC<{ schema: any; [key: string]: any }> = {(bound) => } ); -}; +}); /** * The authoring surface for this block's query filter, in ONE spelling. diff --git a/packages/plugin-kanban/src/index.tsx b/packages/plugin-kanban/src/index.tsx index 83a0af8afc..ab9cc7cf24 100644 --- a/packages/plugin-kanban/src/index.tsx +++ b/packages/plugin-kanban/src/index.tsx @@ -7,7 +7,7 @@ */ import React, { Suspense } from 'react'; -import { ComponentRegistry } from '@object-ui/core'; +import { ComponentRegistry, elementDataSourceBlock } from '@object-ui/core'; import { ElementDataSourceGate, useSchemaContext, @@ -398,7 +398,7 @@ const OBJECT_KANBAN_DATA_SOURCE: ElementDataSourceMapping = { }; // Register object-kanban for ListView integration -export const ObjectKanbanRenderer: React.FC<{ schema: any; [key: string]: any }> = ({ schema, ...props }) => { +export const ObjectKanbanRenderer: React.FC<{ schema: any; [key: string]: any }> = elementDataSourceBlock(({ schema, ...props }) => { const { dataSource } = useSchemaContext() || {}; // The spec's `PageComponentSchema.dataSource` binding (objectstack#6953): // before this, a board authored with `dataSource: { object, view }` and no @@ -415,7 +415,7 @@ export const ObjectKanbanRenderer: React.FC<{ schema: any; [key: string]: any }> {(bound) => } ); -}; +}); ComponentRegistry.register( 'object-kanban', diff --git a/packages/plugin-list/src/ListViewBlock.tsx b/packages/plugin-list/src/ListViewBlock.tsx index 9c7be7fcbb..3af6bf68a7 100644 --- a/packages/plugin-list/src/ListViewBlock.tsx +++ b/packages/plugin-list/src/ListViewBlock.tsx @@ -7,7 +7,7 @@ */ import React, { useContext } from 'react'; -import { isElementDataSourceConfig } from '@object-ui/core'; +import { elementDataSourceBlock, isElementDataSourceConfig } from '@object-ui/core'; import { ElementDataSourceGate, SchemaRendererContext, @@ -98,7 +98,7 @@ const LIST_VIEW_DATA_SOURCE: ElementDataSourceMapping = { * a named view to "all records" is the failure class the binding exists to * remove, and the one an AI-authored page hides best. */ -const ListViewBlock = React.forwardRef((props, ref) => { +const ListViewBlock = elementDataSourceBlock(React.forwardRef((props, ref) => { const context = useContext(SchemaRendererContext as React.Context); // Defence in depth for the collision above: even though SchemaRenderer no @@ -122,7 +122,7 @@ const ListViewBlock = React.forwardRef((props, re {(schema) => } ); -}); +})); ListViewBlock.displayName = 'ListViewBlock'; export { ListViewBlock }; diff --git a/packages/plugin-map/src/index.tsx b/packages/plugin-map/src/index.tsx index ee2ede2708..69d63fa93f 100644 --- a/packages/plugin-map/src/index.tsx +++ b/packages/plugin-map/src/index.tsx @@ -7,7 +7,7 @@ */ import React from 'react'; -import { ComponentRegistry } from '@object-ui/core'; +import { ComponentRegistry, elementDataSourceBlock } from '@object-ui/core'; import { ElementDataSourceGate, useSchemaContext, @@ -41,7 +41,7 @@ const OBJECT_MAP_DATA_SOURCE: ElementDataSourceMapping = { }; // Register component -export const ObjectMapRenderer: React.FC = ({ schema, ...props }) => { +export const ObjectMapRenderer: React.FC = elementDataSourceBlock(({ schema, ...props }) => { const { dataSource } = useSchemaContext() || {}; // The spec's `PageComponentSchema.dataSource` binding (objectstack#7121): a map // authored with the binding and no flat `objectName` got a null data config, so @@ -57,7 +57,7 @@ export const ObjectMapRenderer: React.FC = ({ schema, ...props }) => { {(bound) => } ); -}; +}); ComponentRegistry.register('object-map', ObjectMapRenderer, { namespace: 'plugin-map', diff --git a/packages/plugin-timeline/src/index.tsx b/packages/plugin-timeline/src/index.tsx index 73e3eddbbe..517e3f72ff 100644 --- a/packages/plugin-timeline/src/index.tsx +++ b/packages/plugin-timeline/src/index.tsx @@ -303,7 +303,7 @@ export * from './renderer'; export { ObjectTimeline } from './ObjectTimeline'; export type { ObjectTimelineProps } from './ObjectTimeline'; -import { ComponentRegistry } from '@object-ui/core'; +import { ComponentRegistry, elementDataSourceBlock } from '@object-ui/core'; import { ObjectTimeline } from './ObjectTimeline'; import { ElementDataSourceGate, @@ -334,7 +334,7 @@ const OBJECT_TIMELINE_DATA_SOURCE: ElementDataSourceMapping = { }; // Register object-timeline component -export const ObjectTimelineRenderer: React.FC = ({ schema, ...props }) => { +export const ObjectTimelineRenderer: React.FC = elementDataSourceBlock(({ schema, ...props }) => { const { dataSource } = useSchemaContext() || {}; // The spec's `PageComponentSchema.dataSource` binding (objectstack#7121): a // timeline authored with the binding and no flat `objectName` never fetched — @@ -351,7 +351,7 @@ export const ObjectTimelineRenderer: React.FC = ({ schema, ...props }) => { {(bound) => } ); -}; +}); ComponentRegistry.register('object-timeline', ObjectTimelineRenderer, { namespace: 'plugin-timeline', diff --git a/packages/react/README.md b/packages/react/README.md index 3f856228ca..04da5d47a5 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -131,6 +131,8 @@ block's schema and render the two non-final states. ```tsx import { ElementDataSourceGate } from '@object-ui/react' +// The seam is imported from CORE, not from here — see the note below. +import { elementDataSourceBlock } from '@object-ui/core' // `mapping` names ONLY the keys this block reads. A composed value written onto // a key the block ignores would be accepted and silently dropped — the defect @@ -142,13 +144,36 @@ const OBJECT_GRID_BINDING = { limit: 'pagination.pageSize' as const, } -const ObjectGridRenderer = ({ schema, ...props }) => ( +// `elementDataSourceBlock` is not optional decoration — see below. +const ObjectGridRenderer = elementDataSourceBlock(({ schema, ...props }) => ( {(bound) => } -) +)) ``` +**Wrap the registered renderer in `elementDataSourceBlock`.** It is what makes +`ComponentRegistry.register` emit the `dataSource` input on that block's +authoring surface, so the key the gate READS is also the key the manifest, the +save gate, the generated JSX types and the designer DECLARE. Skip it and the +binding still works at runtime while the html tier reports `dataSource` as a prop +that does not exist — the one spelling that resolves a saved view, reported +exactly like the spellings that do nothing (objectui#6678). The declaration is +emitted from this one seam, so never hand-write a `dataSource` entry in a block's +`inputs`. `pnpm check:element-data-source-declaration` fails any source that +consumes the gate without reaching the seam. + +⚠️ **Import the seam from `@object-ui/core`, not from this package** — it is one +function under one name, re-exported here for discoverability, and the check +above enforces the core import at call sites. The reason is measured, not +stylistic: a registration runs at MODULE SCOPE, 101 suites in this repo partially +mock `@object-ui/react` by hand-listing the exports they return, and a +module-scope read of a name absent from such a list throws at COLLECTION time — +the importing test file dies before running a single assertion. Taking the seam +from `@object-ui/react` reddened 17 files across all four CI shards with zero +failed assertions among them. Nothing mocks `@object-ui/core`, and every +registration module already imports `ComponentRegistry` from it. + `object` lands on `objectName` by default (pass `object: 'apiName'` for another key, or `object: false` for a block that reads the composed binding itself). Precedence: binding keys beat the component's own, view-sourced values are only a @@ -157,6 +182,9 @@ A `view` name that does not resolve renders a configuration error rather than falling back to the object's full scope. Use `useElementDataSourceSchema` (plus the exported `ElementDataSourceErrorPanel` / `ElementDataSourceLoadingPanel`) when a block cannot be wrapped — a renderer whose hooks must run before the panels. +That form still reads `dataSource`, so it still owes the seam: mark the renderer +at its registration, `register('x', elementDataSourceBlock(XRenderer), { … })` — +again importing `elementDataSourceBlock` from `@object-ui/core`. ### useSettledSchema diff --git a/packages/react/src/element-data-source/ElementDataSourceGate.tsx b/packages/react/src/element-data-source/ElementDataSourceGate.tsx index dd6a5c385e..7c8c8f7c11 100644 --- a/packages/react/src/element-data-source/ElementDataSourceGate.tsx +++ b/packages/react/src/element-data-source/ElementDataSourceGate.tsx @@ -390,6 +390,64 @@ export function NoDataSourcePanel({ ); } +/** + * The wrapping seam — mark `renderer` as one that wraps {@link + * ElementDataSourceGate}, so its registrations DECLARE the `dataSource` key this + * gate READS (objectui#6678). + * + * ```tsx + * export const ObjectMapRenderer = elementDataSourceBlock>(({ schema }) => ( + * + * {(bound) => } + * + * )); + * ``` + * + * ## What this closes + * + * `PageComponentSchema.dataSource` is the one spelling that resolves a saved + * view for an object-bound block. It works — and because no registration + * declared it, `sdui-parser`'s `validateTree` reported it with the SAME + * `unknown-prop` warning it gives the spellings that do nothing. On the tier + * built to accept AI-authored pages the diagnostic IS the contract, so the + * tier's only signal pointed away from the one key that works. + * + * ## Why a seam and not nine declarations + * + * Maintainer ruling, 2026-08-29: option B **in the injection form**. Nine + * hand-written copies across nine packages is the shape that ruling refused — + * they drift, and the tenth block forgets. Passing through here is the only + * thing a block does; `Registry.register` emits the declaration + * (`withElementDataSourceInput`), and `ELEMENT_DATA_SOURCE_INPUT` in + * `@object-ui/core` is its single copy, beside the binding's own semantics. + * + * ## And it cannot be forgotten + * + * `scripts/check-element-data-source-declaration.mjs` fails any source that + * renders `ElementDataSourceGate` for a registration without passing that + * registration's renderer through this function. That is the mechanical half of + * "a tenth block gets it automatically"; this function is the seam it enforces. + * + * Returns the renderer UNCHANGED (the mark is held in a `WeakSet`), so it is safe + * over a `React.forwardRef` object, over `React.memo`, and over a component that + * something else already re-exports by reference. + * + * ⚠️ Re-exported from `@object-ui/core` — where the marker and the declaration + * live — rather than wrapped here: ONE function under ONE name. This export + * exists for DISCOVERABILITY, so the seam is findable beside the gate it is + * about; **call sites must import it from `@object-ui/core`**, and + * `check:element-data-source-declaration` enforces that. + * + * The rule is measured, not stylistic. A registration runs at MODULE SCOPE, 101 + * suites in this repo partially mock `@object-ui/react` by hand-listing the + * exports they return, and a module-scope read of a name absent from such a list + * throws at COLLECTION time — the importing test file dies before running one + * assertion. Taking the seam from here reddened 17 files across all four CI + * shards with zero failed assertions among them. Nothing mocks `@object-ui/core`, + * and every registration module already imports `ComponentRegistry` from it. + */ +export { elementDataSourceBlock } from '@object-ui/core'; + export interface ElementDataSourceGateProps { /** The block's schema node. */ schema: S; diff --git a/scripts/check-element-data-source-declaration.mjs b/scripts/check-element-data-source-declaration.mjs new file mode 100644 index 0000000000..fcbc3fab1e --- /dev/null +++ b/scripts/check-element-data-source-declaration.mjs @@ -0,0 +1,239 @@ +#!/usr/bin/env node +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * check-element-data-source-declaration -- a block that WRAPS the runtime gate + * must go through the seam that DECLARES the key the gate reads + * (objectui#6678). + * + * node scripts/check-element-data-source-declaration.mjs # the verdict + * node scripts/check-element-data-source-declaration.mjs --list # the sites + * + * ## What this is the mechanical half of + * + * `PageComponentSchema.dataSource` is the spec's per-element data binding and + * the one spelling that resolves a saved view for an object-bound block. It is + * READ by `ElementDataSourceGate` in `@object-ui/react` on behalf of every block + * that wraps itself in the gate. It was DECLARED by none of them, so the html + * tier reported the one key that works with the same `unknown-prop` warning it + * gives the spellings that do nothing -- on the tier built to accept AI-authored + * pages, where the diagnostic IS the contract. + * + * The maintainer ruling of 2026-08-29 took option B **in the injection form**: + * the declaration is emitted mechanically at the wrapping seam, from the same + * place that reads the key, rather than hand-written once per block. Nine + * hand-kept copies is what that ruling refused -- copies drift, and the tenth + * block forgets. + * + * The emission is `Registry.register` -> `withElementDataSourceInput`, keyed on + * the renderer having passed through `elementDataSourceBlock()`. That is one + * mechanism and one copy of the declaration. What it cannot do by itself is + * notice a block that starts wrapping the gate WITHOUT passing through the seam + * -- and that block would publish a page-authoring surface missing the one key + * its own runtime honours, silently, exactly as before. So the "cannot forget" + * half is this gate: wrapping the gate and skipping the seam is a red build, not + * a discovery six months later. + * + * ## The rule, and why it is stated over FILES + * + * A source file that CONSUMES the gate must also call `elementDataSourceBlock(`. + * + * "Consumes" is wider than the JSX tag on purpose, and that width was measured + * rather than guessed: `element:record_picker` reads the binding through + * `useElementDataSource` and renders the gate's own status panels WITHOUT the + * `` wrapper -- its object lives under `properties`, so + * there is no schema key for the gate to write. A rule that only knew the tag + * would have declared that file compliant while it published exactly the defect + * this work closes. It was found by the render probe in + * `apps/console/src/__tests__/element-data-source-input-injection.test.tsx`, + * which detects the gate's panels and does not care how they got there, and the + * rule here was widened to the family the probe actually sees. + * + * Prose does not count: the population is read through + * `scripts/js-comment-mask.mjs`, the tree's one answer to "comment, or code?". + * Three modules discuss `useElementDataSourceSchema` in docblocks without + * consuming it, and a naive reader would have demanded the seam from all three. + * + * File granularity is deliberate and it is the honest limit of a static reader: + * matching a particular use site to the particular renderer that encloses it + * needs a TypeScript parse, and a reader that guesses would fail in the + * direction that matters (a wrong pairing reads as compliance). A file that + * consumes the gate twice and marks once is therefore NOT caught here -- it is + * caught by the render probe above, which reads the LIVE registry and needs no + * parsing at all. The two together are complete; neither is alone. + * + * ## Both zeroes are loud + * + * - a file wraps the gate and never reaches the seam -> exit 1 + * - ZERO gate-wrapping files found -> exit 2. "All + * compliant" over an empty population is green for nothing, and this reader + * going blind (a rename, a moved package root) looks exactly like a repo + * that stopped using the gate. + * - the DEFINING package is excluded, and the exclusion is asserted to + * exclude something AND to be incapable of hiding a consumer: a filter that + * matched nothing would leave the definition sites in the population and + * demand they mark themselves, and one that matched too much would silently + * stop checking. `@object-ui/react` registers no component, so anything it + * excludes cannot be a registration this gate is about -- and that premise + * is CHECKED rather than trusted. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { maskComments } from './js-comment-mask.mjs'; +import { findComponentRegistrations } from './component-registrations.mjs'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(HERE, '..'); +const PACKAGES = path.join(ROOT, 'packages'); + +/** + * Consuming the gate -- the act this check is about, in every form the render + * probe can see: the wrapper tag, the hook the wrapper is built on, and the two + * status panels a block renders when it drives the resolution itself. + */ +const CONSUMES_GATE = /]|\buseElementDataSourceSchema\b|\buseElementDataSource\b|\bElementDataSource(Error|Loading)Panel\b/; +/** The seam that makes a consuming renderer declare the key. */ +const REACHES_SEAM = /\belementDataSourceBlock\s*[(<]/; + +/** + * Where the seam must be imported FROM at a call site. + * + * It is one function under one name, exported by `@object-ui/core` (where the + * marker and the declaration live) and re-exported by `@object-ui/react` beside + * the gate for discoverability. Call sites must take the CORE one, and that is a + * measured rule rather than a stylistic one: a registration runs at MODULE + * SCOPE, 101 suites in this repo partially mock `@object-ui/react` by + * hand-listing the exports they return, and a module-scope read of a name absent + * from such a list throws at COLLECTION time -- the importing test file dies + * before running a single assertion, so the failure arrives as an unexplained + * red suite rather than as a failed expectation. Taking the seam from + * `@object-ui/react` reddened 17 files across all four CI shards, with zero + * failed assertions among them. Nothing in this repo mocks `@object-ui/core`, + * and every registration module already imports `ComponentRegistry` from it, so + * the core path adds no coupling that was not already there. + */ +const SEAM_FROM_CORE = /import\s*\{[^}]*\belementDataSourceBlock\b[^}]*\}\s*from\s*['"]@object-ui\/core['"]/; + +/** + * The package that DEFINES the gate, its hook and its panels, and exports the + * seam. Demanding that a definition mark itself would be nonsense; it is safe to + * exclude precisely because `@object-ui/react` registers no component, which is + * asserted below rather than assumed. + */ +const DEFINING_PACKAGE = `${path.join('packages', 'react', 'src')}${path.sep}`; + +const isTest = (rel) => + /\.(test|spec)\.[cm]?[jt]sx?$/.test(rel) || /(^|[\\/])__tests__[\\/]/.test(rel); + +function* sources(dir) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name.startsWith('.')) continue; + const full = path.join(dir, entry.name); + if (entry.isDirectory()) yield* sources(full); + else if (/\.tsx?$/.test(entry.name)) yield full; + } +} + +const wrapping = []; +const excluded = []; +for (const full of sources(PACKAGES)) { + const rel = path.relative(ROOT, full); + if (isTest(rel)) continue; + // Prose is not consumption -- several modules name the hook in docblocks only. + const text = maskComments(fs.readFileSync(full, 'utf8')); + if (!CONSUMES_GATE.test(text)) continue; + if (rel.startsWith(DEFINING_PACKAGE)) { + excluded.push({ rel, registers: findComponentRegistrations(text).calls }); + continue; + } + const reachesSeam = REACHES_SEAM.test(text); + wrapping.push({ + rel, + reachesSeam, + // Only meaningful for a file that actually calls the seam. + seamFromCore: !reachesSeam || SEAM_FROM_CORE.test(text), + }); +} + +if (process.argv.includes('--list')) { + for (const site of wrapping.sort((a, b) => a.rel.localeCompare(b.rel))) { + const verdict = !site.reachesSeam ? 'MISS' : !site.seamFromCore ? 'FROM-REACT' : 'ok '; + console.log(`${verdict} ${site.rel}`); + } +} + +if (wrapping.length === 0) { + console.error( + 'check-element-data-source-declaration: found ZERO files that consume ElementDataSourceGate.\n' + + 'That is reported as a failure, not a pass: every assertion below is an absence, and an\n' + + 'absence over an empty population is green for nothing. Either the gate is genuinely gone\n' + + '(then this gate should be deleted, deliberately), or this reader has stopped seeing it.', + ); + process.exit(2); +} + +if (excluded.length === 0) { + console.error( + 'check-element-data-source-declaration: the defining-package exclusion matched NOTHING.\n' + + `Path is stale (${DEFINING_PACKAGE}). A filter that matches nothing silently stops excluding,\n` + + "so the gate's own definition sites would be told to mark themselves.", + ); + process.exit(2); +} + +const hidden = excluded.filter((f) => f.registers > 0); +if (hidden.length > 0) { + console.error( + 'check-element-data-source-declaration: the defining-package exclusion is hiding a REGISTRATION:\n' + + hidden.map((f) => ` ${f.rel} (${f.registers} register call(s))`).join('\n') + + '\n\nThe exclusion is sound only while that package registers no component. It now does, so the\n' + + 'exclusion has stopped being "definitions only" and has started suppressing the very thing this\n' + + 'gate checks. Narrow it to the definition modules, or move the registration out.', + ); + process.exit(2); +} + +const missing = wrapping.filter((s) => !s.reachesSeam); +if (missing.length > 0) { + console.error( + `check-element-data-source-declaration: ${missing.length} file(s) consume ElementDataSourceGate\n` + + 'without passing their registered renderer through the seam:\n' + + missing.map((s) => ` ${s.rel}`).join('\n') + + '\n\n' + + 'The gate READS `PageComponentSchema.dataSource`. A block that consumes it and skips the seam\n' + + 'publishes an authoring surface with no `dataSource` input, so the html tier reports the one\n' + + 'spelling that resolves a saved view as `unknown-prop` -- objectui#6678, on the tier where the\n' + + 'diagnostic is the contract.\n\n' + + 'Fix: wrap the RENDERER this file registers in `elementDataSourceBlock(...)`, imported from\n' + + '@object-ui/core (see the import-source rule below -- @object-ui/react re-exports the same\n' + + 'function, but a module-scope read of it breaks every suite that mocks that package). The\n' + + 'declaration itself is emitted by Registry.register; do not write a `dataSource` input by\n' + + 'hand -- hand-kept copies is the shape the 2026-08-29 ruling refused.', + ); + process.exit(1); +} + +const fromReact = wrapping.filter((s) => s.reachesSeam && !s.seamFromCore); +if (fromReact.length > 0) { + console.error( + `check-element-data-source-declaration: ${fromReact.length} file(s) import the seam from\n` + + '@object-ui/react instead of @object-ui/core:\n' + + fromReact.map((s) => ` ${s.rel}`).join('\n') + + '\n\n' + + 'It is the same function under the same name, so this is not a naming preference. A\n' + + 'registration runs at MODULE SCOPE, and 101 suites here partially mock @object-ui/react by\n' + + 'hand-listing the exports they return: a module-scope read of a name absent from such a list\n' + + 'throws at COLLECTION time, so the importing test file dies before running one assertion.\n' + + 'Measured on objectui#6678: 17 files red across all four CI shards, zero failed assertions.\n\n' + + "Fix: import { elementDataSourceBlock } from '@object-ui/core'. Nothing mocks that package,\n" + + 'and every registration module already imports ComponentRegistry from it.', + ); + process.exit(1); +} + +console.log( + `check-element-data-source-declaration: OK — ${wrapping.length} gate-consuming file(s) checked, ` + + `${excluded.length} definition file(s) excluded; all reach the seam and take it from core.`, +);