From 6aa8de55a96f5bed91e83062f1b997e75d3ddee8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 07:22:29 +0000 Subject: [PATCH 1/8] fix(react,core): declare the spec `dataSource` binding at the gate-wrapping seam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `PageComponentSchema.dataSource` is the one spelling that resolves a saved view for an object-bound block. It works, and the html tier reported it with the same `unknown-prop` warning it gives the 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 semantics. `Registry.register` emits it for a registration whose renderer passed through `elementDataSourceBlock()`, the new seam exported from `@object-ui/react` next to `ElementDataSourceGate`. One mechanism, one copy — not a hand-written declaration per block. - Thirteen renderers across eleven packages reach the seam. - `dataSource` on a block that does NOT read it (`flex`, `card`) still reports `unknown-prop`; `BASE_PROPS` is untouched. - New `check:element-data-source-declaration` fails any source that consumes the gate without reaching the seam, so a new block cannot forget. - The forward spec/registry parity derivation now reads the whole node contract (`PageComponentSchema`, derived — not a listed exemption), because `ComponentPropsMap[type]` is only the per-block PROPS half and `dataSource` is a node-level spec key. Part of #6678 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CRJge11jso9TpXRWFt1Z49 --- ...-datasource-input-declaration-injection.md | 52 ++++ .github/workflows/ci.yml | 14 + ...ement-data-source-input-injection.test.tsx | 262 ++++++++++++++++++ ...erDataSourceInputDeclaration-6678.test.tsx | 204 ++++++++++++++ .../registry-inputs-spec-parity.test.ts | 54 +++- package.json | 1 + .../src/renderers/basic/record-picker.tsx | 11 +- .../src/data-scope/element-data-source.ts | 99 +++++++ packages/core/src/data-scope/index.ts | 4 + packages/core/src/registry/Registry.ts | 53 +++- packages/plugin-calendar/src/index.tsx | 5 +- packages/plugin-charts/src/ObjectChart.tsx | 6 +- packages/plugin-dashboard/src/index.tsx | 10 +- packages/plugin-detail/src/index.tsx | 5 +- .../src/renderers/record-related-list.tsx | 5 +- packages/plugin-form/src/index.tsx | 17 +- packages/plugin-gantt/src/index.tsx | 5 +- packages/plugin-grid/src/index.tsx | 5 +- packages/plugin-kanban/src/index.tsx | 5 +- packages/plugin-list/src/ListViewBlock.tsx | 5 +- packages/plugin-map/src/index.tsx | 5 +- packages/plugin-timeline/src/index.tsx | 5 +- .../ElementDataSourceGate.tsx | 47 ++++ .../check-element-data-source-declaration.mjs | 193 +++++++++++++ 24 files changed, 1033 insertions(+), 39 deletions(-) create mode 100644 .changeset/6678-datasource-input-declaration-injection.md create mode 100644 apps/console/src/__tests__/element-data-source-input-injection.test.tsx create mode 100644 apps/console/src/__tests__/htmlTierDataSourceInputDeclaration-6678.test.tsx create mode 100644 scripts/check-element-data-source-declaration.mjs diff --git a/.changeset/6678-datasource-input-declaration-injection.md b/.changeset/6678-datasource-input-declaration-injection.md new file mode 100644 index 0000000000..4b89ddbd64 --- /dev/null +++ b/.changeset/6678-datasource-input-declaration-injection.md @@ -0,0 +1,52 @@ +--- +'@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, exported from `@object-ui/react` next to `ElementDataSourceGate`. One + mechanism, one copy — not a hand-kept declaration per block, which is the + shape that drifts and that a new block forgets. +- Thirteen renderers across eleven 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. +- `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. 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..5a64b32227 --- /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/react — ' + + '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/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..698264b75f 100644 --- a/packages/components/src/renderers/basic/record-picker.tsx +++ b/packages/components/src/renderers/basic/record-picker.tsx @@ -34,6 +34,7 @@ import * as React from 'react'; import { ComponentRegistry } from '@object-ui/core'; import { + elementDataSourceBlock, ElementDataSourceErrorPanel, ElementDataSourceLoadingPanel, useAdapter, @@ -300,7 +301,15 @@ 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 `` 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. +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..e6467f2dc2 100644 --- a/packages/core/src/data-scope/element-data-source.ts +++ b/packages/core/src/data-scope/element-data-source.ts @@ -278,3 +278,102 @@ 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 wraps the gate and by nothing else — which is what keeps + * the `flex` / `card` direction of the ruling true. `@object-ui/react` re-exports + * it as `elementDataSourceBlock` beside the gate itself, and + * `scripts/check-element-data-source-declaration.mjs` fails any source that + * renders the gate without passing through it, so a tenth block cannot forget. + */ +export function markElementDataSourceBlock(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 markElementDataSourceBlock}. */ +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..03e2bce2bc 100644 --- a/packages/core/src/data-scope/index.ts +++ b/packages/core/src/data-scope/index.ts @@ -25,8 +25,12 @@ export { export { collectSavedViews, composeElementDataSource, + ELEMENT_DATA_SOURCE_INPUT, + ELEMENT_DATA_SOURCE_KEY, elementDataSourceViewNotFoundMessage, + isElementDataSourceBlock, isElementDataSourceConfig, + markElementDataSourceBlock, resolveSavedView, type ComposedElementDataSource, type ElementDataSourceConfig, 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..21d5cd8609 100644 --- a/packages/plugin-calendar/src/index.tsx +++ b/packages/plugin-calendar/src/index.tsx @@ -10,6 +10,7 @@ import React from 'react'; import { ComponentRegistry } from '@object-ui/core'; import { ElementDataSourceGate, + elementDataSourceBlock, useSchemaContext, type ElementDataSourceMapping, } from '@object-ui/react'; @@ -236,7 +237,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 +289,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..4133d674e9 100644 --- a/packages/plugin-charts/src/ObjectChart.tsx +++ b/packages/plugin-charts/src/ObjectChart.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect, useContext, useCallback, useMemo } from 'react'; -import { useDataScope, SchemaRendererContext, SchemaRenderer, useDrillNavigation, useFilterScope, ElementDataSourceGate, type ElementDataSourceMapping } from '@object-ui/react'; +import { useDataScope, SchemaRendererContext, SchemaRenderer, useDrillNavigation, useFilterScope, ElementDataSourceGate, elementDataSourceBlock, 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 { Sheet, SheetContent, SheetHeader, SheetTitle, Dialog, DialogContent, DialogHeader, DialogTitle, RefreshIndicator, Button, ChartSkeleton } from '@object-ui/components'; @@ -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..3f28c808e4 100644 --- a/packages/plugin-dashboard/src/index.tsx +++ b/packages/plugin-dashboard/src/index.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { ComponentRegistry } from '@object-ui/core'; -import { ElementDataSourceGate, type ElementDataSourceMapping } from '@object-ui/react'; +import { ElementDataSourceGate, elementDataSourceBlock, type ElementDataSourceMapping } from '@object-ui/react'; import { DashboardRenderer } from './DashboardRenderer'; import { DashboardGridLayout } from './DashboardGridLayout'; import { MetricWidget } from './MetricWidget'; @@ -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/index.tsx b/packages/plugin-detail/src/index.tsx index 2a6670f7e5..31ab087638 100644 --- a/packages/plugin-detail/src/index.tsx +++ b/packages/plugin-detail/src/index.tsx @@ -10,6 +10,7 @@ import * as React from 'react'; import { ComponentRegistry, type ComponentInput } from '@object-ui/core'; import { ElementDataSourceGate, + elementDataSourceBlock, noDataSourceMessage, useResolvedDataSource, } from '@object-ui/react'; @@ -241,7 +242,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 +275,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..28f90a2efa 100644 --- a/packages/plugin-detail/src/renderers/record-related-list.tsx +++ b/packages/plugin-detail/src/renderers/record-related-list.tsx @@ -14,6 +14,7 @@ import React from 'react'; import { ElementDataSourceGate, + elementDataSourceBlock, useRecordContext, useSafeFieldLabel, useRelatedRecordActions, @@ -315,7 +316,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 +333,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..acd06e701a 100644 --- a/packages/plugin-form/src/index.tsx +++ b/packages/plugin-form/src/index.tsx @@ -10,6 +10,7 @@ import React, { useContext } from 'react'; import { ComponentRegistry } from '@object-ui/core'; import { ElementDataSourceGate, + elementDataSourceBlock, SchemaRendererContext, noDataSourceMessage, useResolvedDataSource, @@ -102,7 +103,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 +154,7 @@ const ObjectFormRenderer: React.FC<{ schema: any; dataSource?: unknown }> = ({ {(bound) => } ); -}; +}); ComponentRegistry.register('object-form', ObjectFormRenderer, { namespace: 'plugin-form', @@ -233,7 +234,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 +265,7 @@ const EmbeddableFormRenderer: React.FC<{ schema: any }> = ({ schema }) => { {(bound) => } ); -}; +}); ComponentRegistry.register('embeddable-form', EmbeddableFormRenderer, { namespace: 'plugin-form', @@ -302,7 +303,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 +330,7 @@ const MasterDetailFormRenderer: React.FC<{ schema: any }> = ({ schema }) => { {(bound) => } ); -}; +}); ComponentRegistry.register('object-master-detail-form', MasterDetailFormRenderer, { namespace: 'plugin-form', @@ -412,7 +413,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 +427,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..d5231a6831 100644 --- a/packages/plugin-gantt/src/index.tsx +++ b/packages/plugin-gantt/src/index.tsx @@ -10,6 +10,7 @@ import React from 'react'; import { ComponentRegistry } from '@object-ui/core'; import { ElementDataSourceGate, + elementDataSourceBlock, useSchemaContext, type ElementDataSourceMapping, } from '@object-ui/react'; @@ -80,7 +81,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 +98,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..f437e99132 100644 --- a/packages/plugin-grid/src/index.tsx +++ b/packages/plugin-grid/src/index.tsx @@ -10,6 +10,7 @@ import React from 'react'; import { ComponentRegistry, type ComponentInput } from '@object-ui/core'; import { ElementDataSourceGate, + elementDataSourceBlock, noDataSourceMessage, useResolvedDataSource, useSchemaContext, @@ -110,7 +111,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 +145,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..a3ddeb5764 100644 --- a/packages/plugin-kanban/src/index.tsx +++ b/packages/plugin-kanban/src/index.tsx @@ -10,6 +10,7 @@ import React, { Suspense } from 'react'; import { ComponentRegistry } from '@object-ui/core'; import { ElementDataSourceGate, + elementDataSourceBlock, useSchemaContext, type ElementDataSourceMapping, } from '@object-ui/react'; @@ -398,7 +399,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 +416,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..fa3fa8b739 100644 --- a/packages/plugin-list/src/ListViewBlock.tsx +++ b/packages/plugin-list/src/ListViewBlock.tsx @@ -10,6 +10,7 @@ import React, { useContext } from 'react'; import { isElementDataSourceConfig } from '@object-ui/core'; import { ElementDataSourceGate, + elementDataSourceBlock, SchemaRendererContext, type ElementDataSourceMapping, } from '@object-ui/react'; @@ -98,7 +99,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 +123,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..9baac55c0e 100644 --- a/packages/plugin-map/src/index.tsx +++ b/packages/plugin-map/src/index.tsx @@ -10,6 +10,7 @@ import React from 'react'; import { ComponentRegistry } from '@object-ui/core'; import { ElementDataSourceGate, + elementDataSourceBlock, useSchemaContext, type ElementDataSourceMapping, } from '@object-ui/react'; @@ -41,7 +42,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 +58,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..26b98be8f7 100644 --- a/packages/plugin-timeline/src/index.tsx +++ b/packages/plugin-timeline/src/index.tsx @@ -307,6 +307,7 @@ import { ComponentRegistry } from '@object-ui/core'; import { ObjectTimeline } from './ObjectTimeline'; import { ElementDataSourceGate, + elementDataSourceBlock, useSchemaContext, type ElementDataSourceMapping, } from '@object-ui/react'; @@ -334,7 +335,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 +352,7 @@ export const ObjectTimelineRenderer: React.FC = ({ schema, ...props }) => { {(bound) => } ); -}; +}); ComponentRegistry.register('object-timeline', ObjectTimelineRenderer, { namespace: 'plugin-timeline', diff --git a/packages/react/src/element-data-source/ElementDataSourceGate.tsx b/packages/react/src/element-data-source/ElementDataSourceGate.tsx index dd6a5c385e..2491c760a9 100644 --- a/packages/react/src/element-data-source/ElementDataSourceGate.tsx +++ b/packages/react/src/element-data-source/ElementDataSourceGate.tsx @@ -82,6 +82,7 @@ import * as React from 'react'; import { isElementDataSourceConfig, + markElementDataSourceBlock, mergeFilterNodes, type ElementDataSourceConfig, } from '@object-ui/core'; @@ -390,6 +391,52 @@ 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. + */ +export function elementDataSourceBlock(renderer: C): C { + return markElementDataSourceBlock(renderer); +} + 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..a7fdfa510c --- /dev/null +++ b/scripts/check-element-data-source-declaration.mjs @@ -0,0 +1,193 @@ +#!/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 wrapped renderer declare the key. */ +const REACHES_SEAM = /\belementDataSourceBlock\s*[(<]/; + +/** + * 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; + } + wrapping.push({ rel, reachesSeam: REACHES_SEAM.test(text) }); +} + +if (process.argv.includes('--list')) { + for (const site of wrapping.sort((a, b) => a.rel.localeCompare(b.rel))) { + console.log(`${site.reachesSeam ? 'ok ' : 'MISS'} ${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(...)` from\n' + + '@object-ui/react. The declaration itself is emitted by Registry.register; do not write a\n' + + '`dataSource` input by hand -- nine hand-kept copies is the shape the 2026-08-29 ruling refused.', + ); + 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 consumers reach the seam.`, +); From d3c62e07e97826d144a5c5e14c09f775131b24c3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 08:00:42 +0000 Subject: [PATCH 2/8] fix(detail,react): carry the node-level parity widening to the per-block pin, document the seam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ons to the injection landing in the previous commit. `recordRelatedListInputs.spec-parity.test.ts` is the per-block half of the same forward-direction gate the repo-wide file carries, and it made the same assumption: that `RecordRelatedListProps` — the block's PROPS contract — is the whole contract of the node an `inputs` list describes. It is not. `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 live. Widened the same way: DERIVED from the spec, never listed, and asserted to still discriminate (`dataSource` accepted, `relationshipField` and an invented key refused). `packages/react/README.md`'s worked example is what a new block author copies, and it showed the wrapper without the seam — so it taught exactly the omission `check:element-data-source-declaration` now fails the build for. The example wraps the renderer, and the surrounding prose says what the seam is for and that the hook/panel form (a renderer whose hooks must run before the panels) owes it at its registration instead. Part of #6678 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CRJge11jso9TpXRWFt1Z49 --- ...ecordRelatedListInputs.spec-parity.test.ts | 35 +++++++++++++++++-- packages/react/README.md | 20 +++++++++-- 2 files changed, 50 insertions(+), 5 deletions(-) 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/react/README.md b/packages/react/README.md index 3f856228ca..aee709d0ae 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -130,7 +130,7 @@ views so `view` can be matched); these two apply the composed result to the block's schema and render the two non-final states. ```tsx -import { ElementDataSourceGate } from '@object-ui/react' +import { ElementDataSourceGate, elementDataSourceBlock } from '@object-ui/react' // `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 +142,25 @@ 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. + `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 +169,8 @@ 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), { … })`. ### useSettledSchema From fce8b57ca84ff435aa26a60143e3770dae1505d1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 08:09:13 +0000 Subject: [PATCH 3/8] docs(changeset): correct the seam population and record the parity derivation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seventeen renderers in thirteen files across twelve packages, not thirteen across eleven — counted from `check:element-data-source-declaration --list` rather than from the card's nine. Also records that the two spec/registry parity gates were corrected (derived from `PageComponentSchema`) rather than exempted. Part of #6678 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CRJge11jso9TpXRWFt1Z49 --- ...-datasource-input-declaration-injection.md | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/.changeset/6678-datasource-input-declaration-injection.md b/.changeset/6678-datasource-input-declaration-injection.md index 4b89ddbd64..208deb8ae9 100644 --- a/.changeset/6678-datasource-input-declaration-injection.md +++ b/.changeset/6678-datasource-input-declaration-injection.md @@ -36,9 +36,14 @@ form**: seam, exported from `@object-ui/react` next to `ElementDataSourceGate`. One mechanism, one copy — not a hand-kept declaration per block, which is the shape that drifts and that a new block forgets. -- Thirteen renderers across eleven 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. +- 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 @@ -50,3 +55,11 @@ 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. From d019ee61a389647c04a75bc92e22c3735334458f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 08:51:48 +0000 Subject: [PATCH 4/8] fix(core,components,plugins): take the seam from @object-ui/core, not from the mocked package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI was red on all four shards. Reproduced locally with the same partition (`pnpm test --shard=2/4`): three files failed, `Tests 6694 passed (6694)` — zero failed assertions. They were COLLECTION failures: Error: [vitest] No "elementDataSourceBlock" export is defined on the "@object-ui/react" mock. Cause, and it is this PR's: a registration runs at MODULE SCOPE, and 101 suites in this repo partially mock `@object-ui/react` by hand-listing the exports they return. A module-scope read of a name absent from such a list throws while the test file is being collected, so the file dies before running one assertion. Measured blast radius over every suite that mocks the package without `importOriginal`: 17 files, which is why all four shards were red rather than a subset — an appended input breaking exact-array assertions would have hit a subset and produced failed ASSERTIONS, and there were none. The fix is not to hand-list the export in 17 mocks. That leaves the hazard in place for the next module-scope addition, and the mocks are only incomplete because this PR made widely-imported packages reach into a commonly-mocked one at module scope. So the seam moves to the package nothing mocks: - `@object-ui/core` owns the one function under the one name (renamed from `markElementDataSourceBlock`); `@object-ui/react` re-exports it beside the gate for discoverability rather than wrapping it, so there is still exactly one implementation. - All 17 call sites import it from `@object-ui/core`. Every one of them already imported `ComponentRegistry` from there at module scope, so this removes a module-scope dependency rather than adding one, and nothing in this repo mocks `@object-ui/core`. - `check:element-data-source-declaration` now enforces the import source, so this cannot recur. The rule was proven able to fail (one file flipped back to the react import: exit 1, naming that file; restored and proven by blob hash). No test was skipped, quarantined or edited to get green: the 17 files were never wrong, they could not load. Part of #6678 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CRJge11jso9TpXRWFt1Z49 --- .../src/renderers/basic/record-picker.tsx | 18 +++++-- .../src/data-scope/element-data-source.ts | 28 +++++++--- packages/core/src/data-scope/index.ts | 2 +- packages/plugin-calendar/src/index.tsx | 3 +- packages/plugin-charts/src/ObjectChart.tsx | 4 +- packages/plugin-dashboard/src/index.tsx | 4 +- packages/plugin-detail/src/index.tsx | 3 +- .../src/renderers/record-related-list.tsx | 3 +- packages/plugin-form/src/index.tsx | 3 +- packages/plugin-gantt/src/index.tsx | 3 +- packages/plugin-grid/src/index.tsx | 3 +- packages/plugin-kanban/src/index.tsx | 3 +- packages/plugin-list/src/ListViewBlock.tsx | 3 +- packages/plugin-map/src/index.tsx | 3 +- packages/plugin-timeline/src/index.tsx | 3 +- packages/react/README.md | 18 ++++++- .../ElementDataSourceGate.tsx | 13 +++-- .../check-element-data-source-declaration.mjs | 52 +++++++++++++++++-- 18 files changed, 124 insertions(+), 45 deletions(-) diff --git a/packages/components/src/renderers/basic/record-picker.tsx b/packages/components/src/renderers/basic/record-picker.tsx index 698264b75f..d990059569 100644 --- a/packages/components/src/renderers/basic/record-picker.tsx +++ b/packages/components/src/renderers/basic/record-picker.tsx @@ -32,9 +32,8 @@ */ import * as React from 'react'; -import { ComponentRegistry } from '@object-ui/core'; +import { ComponentRegistry, elementDataSourceBlock } from '@object-ui/core'; import { - elementDataSourceBlock, ElementDataSourceErrorPanel, ElementDataSourceLoadingPanel, useAdapter, @@ -305,10 +304,21 @@ function ElementRecordPickerRenderer({ schema }: { schema: any }) { // 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 `` tag it -// does not have. Found by the render probe in +// 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, diff --git a/packages/core/src/data-scope/element-data-source.ts b/packages/core/src/data-scope/element-data-source.ts index e6467f2dc2..5ff170ba04 100644 --- a/packages/core/src/data-scope/element-data-source.ts +++ b/packages/core/src/data-scope/element-data-source.ts @@ -356,20 +356,36 @@ const elementDataSourceBlocks = new WeakSet(); * * 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 wraps the gate and by nothing else — which is what keeps - * the `flex` / `card` direction of the ruling true. `@object-ui/react` re-exports - * it as `elementDataSourceBlock` beside the gate itself, and + * 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 - * renders the gate without passing through it, so a tenth block cannot forget. + * 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`, which is + * where a block that renders the gate should import it from — the seam reads + * best next to the thing it is about. + * + * Import it from HERE instead when the calling module is in a widely-imported + * package and the call is at MODULE SCOPE, as `element:record_picker`'s + * registration in `@object-ui/components` is. 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 markElementDataSourceBlock(renderer: C): C { +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 markElementDataSourceBlock}. */ +/** Whether `renderer` was marked by {@link elementDataSourceBlock}. */ export function isElementDataSourceBlock(renderer: unknown): boolean { return ( !!renderer diff --git a/packages/core/src/data-scope/index.ts b/packages/core/src/data-scope/index.ts index 03e2bce2bc..bfa9fc70d2 100644 --- a/packages/core/src/data-scope/index.ts +++ b/packages/core/src/data-scope/index.ts @@ -27,10 +27,10 @@ export { composeElementDataSource, ELEMENT_DATA_SOURCE_INPUT, ELEMENT_DATA_SOURCE_KEY, + elementDataSourceBlock, elementDataSourceViewNotFoundMessage, isElementDataSourceBlock, isElementDataSourceConfig, - markElementDataSourceBlock, resolveSavedView, type ComposedElementDataSource, type ElementDataSourceConfig, diff --git a/packages/plugin-calendar/src/index.tsx b/packages/plugin-calendar/src/index.tsx index 21d5cd8609..b3e5105009 100644 --- a/packages/plugin-calendar/src/index.tsx +++ b/packages/plugin-calendar/src/index.tsx @@ -7,10 +7,9 @@ */ import React from 'react'; -import { ComponentRegistry } from '@object-ui/core'; +import { ComponentRegistry, elementDataSourceBlock } from '@object-ui/core'; import { ElementDataSourceGate, - elementDataSourceBlock, useSchemaContext, type ElementDataSourceMapping, } from '@object-ui/react'; diff --git a/packages/plugin-charts/src/ObjectChart.tsx b/packages/plugin-charts/src/ObjectChart.tsx index 4133d674e9..122ec10b5e 100644 --- a/packages/plugin-charts/src/ObjectChart.tsx +++ b/packages/plugin-charts/src/ObjectChart.tsx @@ -1,8 +1,8 @@ import React, { useState, useEffect, useContext, useCallback, useMemo } from 'react'; -import { useDataScope, SchemaRendererContext, SchemaRenderer, useDrillNavigation, useFilterScope, ElementDataSourceGate, elementDataSourceBlock, type ElementDataSourceMapping } from '@object-ui/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'; diff --git a/packages/plugin-dashboard/src/index.tsx b/packages/plugin-dashboard/src/index.tsx index 3f28c808e4..2730153e99 100644 --- a/packages/plugin-dashboard/src/index.tsx +++ b/packages/plugin-dashboard/src/index.tsx @@ -7,8 +7,8 @@ */ import React from 'react'; -import { ComponentRegistry } from '@object-ui/core'; -import { ElementDataSourceGate, elementDataSourceBlock, type ElementDataSourceMapping } from '@object-ui/react'; +import { ComponentRegistry, elementDataSourceBlock } from '@object-ui/core'; +import { ElementDataSourceGate, type ElementDataSourceMapping } from '@object-ui/react'; import { DashboardRenderer } from './DashboardRenderer'; import { DashboardGridLayout } from './DashboardGridLayout'; import { MetricWidget } from './MetricWidget'; diff --git a/packages/plugin-detail/src/index.tsx b/packages/plugin-detail/src/index.tsx index 31ab087638..89a66f3c1f 100644 --- a/packages/plugin-detail/src/index.tsx +++ b/packages/plugin-detail/src/index.tsx @@ -7,10 +7,9 @@ */ import * as React from 'react'; -import { ComponentRegistry, type ComponentInput } from '@object-ui/core'; +import { ComponentRegistry, elementDataSourceBlock, type ComponentInput } from '@object-ui/core'; import { ElementDataSourceGate, - elementDataSourceBlock, noDataSourceMessage, useResolvedDataSource, } from '@object-ui/react'; diff --git a/packages/plugin-detail/src/renderers/record-related-list.tsx b/packages/plugin-detail/src/renderers/record-related-list.tsx index 28f90a2efa..91e54d3e16 100644 --- a/packages/plugin-detail/src/renderers/record-related-list.tsx +++ b/packages/plugin-detail/src/renderers/record-related-list.tsx @@ -14,7 +14,6 @@ import React from 'react'; import { ElementDataSourceGate, - elementDataSourceBlock, useRecordContext, useSafeFieldLabel, useRelatedRecordActions, @@ -23,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'; diff --git a/packages/plugin-form/src/index.tsx b/packages/plugin-form/src/index.tsx index acd06e701a..a8da5196b8 100644 --- a/packages/plugin-form/src/index.tsx +++ b/packages/plugin-form/src/index.tsx @@ -7,10 +7,9 @@ */ import React, { useContext } from 'react'; -import { ComponentRegistry } from '@object-ui/core'; +import { ComponentRegistry, elementDataSourceBlock } from '@object-ui/core'; import { ElementDataSourceGate, - elementDataSourceBlock, SchemaRendererContext, noDataSourceMessage, useResolvedDataSource, diff --git a/packages/plugin-gantt/src/index.tsx b/packages/plugin-gantt/src/index.tsx index d5231a6831..b982e2f263 100644 --- a/packages/plugin-gantt/src/index.tsx +++ b/packages/plugin-gantt/src/index.tsx @@ -7,10 +7,9 @@ */ import React from 'react'; -import { ComponentRegistry } from '@object-ui/core'; +import { ComponentRegistry, elementDataSourceBlock } from '@object-ui/core'; import { ElementDataSourceGate, - elementDataSourceBlock, useSchemaContext, type ElementDataSourceMapping, } from '@object-ui/react'; diff --git a/packages/plugin-grid/src/index.tsx b/packages/plugin-grid/src/index.tsx index f437e99132..117564b0a1 100644 --- a/packages/plugin-grid/src/index.tsx +++ b/packages/plugin-grid/src/index.tsx @@ -7,10 +7,9 @@ */ import React from 'react'; -import { ComponentRegistry, type ComponentInput } from '@object-ui/core'; +import { ComponentRegistry, elementDataSourceBlock, type ComponentInput } from '@object-ui/core'; import { ElementDataSourceGate, - elementDataSourceBlock, noDataSourceMessage, useResolvedDataSource, useSchemaContext, diff --git a/packages/plugin-kanban/src/index.tsx b/packages/plugin-kanban/src/index.tsx index a3ddeb5764..ab9cc7cf24 100644 --- a/packages/plugin-kanban/src/index.tsx +++ b/packages/plugin-kanban/src/index.tsx @@ -7,10 +7,9 @@ */ import React, { Suspense } from 'react'; -import { ComponentRegistry } from '@object-ui/core'; +import { ComponentRegistry, elementDataSourceBlock } from '@object-ui/core'; import { ElementDataSourceGate, - elementDataSourceBlock, useSchemaContext, type ElementDataSourceMapping, } from '@object-ui/react'; diff --git a/packages/plugin-list/src/ListViewBlock.tsx b/packages/plugin-list/src/ListViewBlock.tsx index fa3fa8b739..3af6bf68a7 100644 --- a/packages/plugin-list/src/ListViewBlock.tsx +++ b/packages/plugin-list/src/ListViewBlock.tsx @@ -7,10 +7,9 @@ */ import React, { useContext } from 'react'; -import { isElementDataSourceConfig } from '@object-ui/core'; +import { elementDataSourceBlock, isElementDataSourceConfig } from '@object-ui/core'; import { ElementDataSourceGate, - elementDataSourceBlock, SchemaRendererContext, type ElementDataSourceMapping, } from '@object-ui/react'; diff --git a/packages/plugin-map/src/index.tsx b/packages/plugin-map/src/index.tsx index 9baac55c0e..69d63fa93f 100644 --- a/packages/plugin-map/src/index.tsx +++ b/packages/plugin-map/src/index.tsx @@ -7,10 +7,9 @@ */ import React from 'react'; -import { ComponentRegistry } from '@object-ui/core'; +import { ComponentRegistry, elementDataSourceBlock } from '@object-ui/core'; import { ElementDataSourceGate, - elementDataSourceBlock, useSchemaContext, type ElementDataSourceMapping, } from '@object-ui/react'; diff --git a/packages/plugin-timeline/src/index.tsx b/packages/plugin-timeline/src/index.tsx index 26b98be8f7..517e3f72ff 100644 --- a/packages/plugin-timeline/src/index.tsx +++ b/packages/plugin-timeline/src/index.tsx @@ -303,11 +303,10 @@ 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, - elementDataSourceBlock, useSchemaContext, type ElementDataSourceMapping, } from '@object-ui/react'; diff --git a/packages/react/README.md b/packages/react/README.md index aee709d0ae..04da5d47a5 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -130,7 +130,9 @@ views so `view` can be matched); these two apply the composed result to the block's schema and render the two non-final states. ```tsx -import { ElementDataSourceGate, elementDataSourceBlock } from '@object-ui/react' +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 @@ -161,6 +163,17 @@ emitted from this one seam, so never hand-write a `dataSource` entry in a block' `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 @@ -170,7 +183,8 @@ 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), { … })`. +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 2491c760a9..6da9d0b883 100644 --- a/packages/react/src/element-data-source/ElementDataSourceGate.tsx +++ b/packages/react/src/element-data-source/ElementDataSourceGate.tsx @@ -82,7 +82,6 @@ import * as React from 'react'; import { isElementDataSourceConfig, - markElementDataSourceBlock, mergeFilterNodes, type ElementDataSourceConfig, } from '@object-ui/core'; @@ -432,10 +431,16 @@ export function NoDataSourcePanel({ * 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, reachable from either + * package. Import it from there instead when the call is at MODULE SCOPE inside a + * widely-imported package: 101 suites hand-list the `@object-ui/react` exports + * their partial mocks return, and a module-scope read of a name absent from such + * a list throws at COLLECTION time. `@object-ui/core` is not mocked anywhere in + * this repo. The core declaration carries the measurement. */ -export function elementDataSourceBlock(renderer: C): C { - return markElementDataSourceBlock(renderer); -} +export { elementDataSourceBlock } from '@object-ui/core'; export interface ElementDataSourceGateProps { /** The block's schema node. */ diff --git a/scripts/check-element-data-source-declaration.mjs b/scripts/check-element-data-source-declaration.mjs index a7fdfa510c..ec654136b4 100644 --- a/scripts/check-element-data-source-declaration.mjs +++ b/scripts/check-element-data-source-declaration.mjs @@ -94,9 +94,28 @@ const PACKAGES = path.join(ROOT, 'packages'); * 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 wrapped renderer declare the key. */ +/** 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 @@ -129,12 +148,19 @@ for (const full of sources(PACKAGES)) { excluded.push({ rel, registers: findComponentRegistrations(text).calls }); continue; } - wrapping.push({ rel, reachesSeam: REACHES_SEAM.test(text) }); + 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))) { - console.log(`${site.reachesSeam ? 'ok ' : 'MISS'} ${site.rel}`); + const verdict = !site.reachesSeam ? 'MISS' : !site.seamFromCore ? 'FROM-REACT' : 'ok '; + console.log(`${verdict} ${site.rel}`); } } @@ -187,7 +213,25 @@ if (missing.length > 0) { 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 consumers reach the seam.`, + + `${excluded.length} definition file(s) excluded; all reach the seam and take it from core.`, ); From 6505cf8933ea192490d19e30d9f926e48ee59f26 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 09:03:32 +0000 Subject: [PATCH 5/8] docs(changeset): record where the seam is imported from, and why Prose only, no code change: the seam lives in `@object-ui/core` and is re-exported by `@object-ui/react` beside the gate, and call sites take the core import because a registration runs at module scope while this repo's suites partially mock `@object-ui/react`. Part of #6678 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CRJge11jso9TpXRWFt1Z49 --- .../6678-datasource-input-declaration-injection.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.changeset/6678-datasource-input-declaration-injection.md b/.changeset/6678-datasource-input-declaration-injection.md index 208deb8ae9..fdc390acec 100644 --- a/.changeset/6678-datasource-input-declaration-injection.md +++ b/.changeset/6678-datasource-input-declaration-injection.md @@ -33,9 +33,12 @@ 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, exported from `@object-ui/react` next to `ElementDataSourceGate`. One - mechanism, one copy — not a hand-kept declaration per block, which is the - shape that drifts and that a new block forgets. + 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 From 5103fdbf1d8d69fa7e9d5cb1f3a3c40a472274ad Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 09:04:46 +0000 Subject: [PATCH 6/8] docs: state the enforced import rule for the seam, not optional guidance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Comment-only (proven: every changed line in both files is a comment line). The react-side re-export exists for discoverability; call sites import the seam from `@object-ui/core`, and check:element-data-source-declaration enforces that — so the docblocks should not read as a suggestion. Part of #6678 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CRJge11jso9TpXRWFt1Z49 --- .../src/data-scope/element-data-source.ts | 13 ++++++------ .../ElementDataSourceGate.tsx | 20 ++++++++++++------- 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/packages/core/src/data-scope/element-data-source.ts b/packages/core/src/data-scope/element-data-source.ts index 5ff170ba04..0d312484c2 100644 --- a/packages/core/src/data-scope/element-data-source.ts +++ b/packages/core/src/data-scope/element-data-source.ts @@ -363,13 +363,14 @@ const elementDataSourceBlocks = new WeakSet(); * * ## Two import paths, ONE name and ONE function * - * `@object-ui/react` re-exports this beside `ElementDataSourceGate`, which is - * where a block that renders the gate should import it from — the seam reads - * best next to the thing it is about. + * `@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. * - * Import it from HERE instead when the calling module is in a widely-imported - * package and the call is at MODULE SCOPE, as `element:record_picker`'s - * registration in `@object-ui/components` is. 101 suites in this repo partially + * 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. diff --git a/packages/react/src/element-data-source/ElementDataSourceGate.tsx b/packages/react/src/element-data-source/ElementDataSourceGate.tsx index 6da9d0b883..7c8c8f7c11 100644 --- a/packages/react/src/element-data-source/ElementDataSourceGate.tsx +++ b/packages/react/src/element-data-source/ElementDataSourceGate.tsx @@ -432,13 +432,19 @@ export function NoDataSourcePanel({ * 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, reachable from either - * package. Import it from there instead when the call is at MODULE SCOPE inside a - * widely-imported package: 101 suites hand-list the `@object-ui/react` exports - * their partial mocks return, and a module-scope read of a name absent from such - * a list throws at COLLECTION time. `@object-ui/core` is not mocked anywhere in - * this repo. The core declaration carries the measurement. + * ⚠️ 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'; From 4f0bb02a21d232e8a423dc14bf6f47472d031720 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 09:05:39 +0000 Subject: [PATCH 7/8] fix: point both failure messages at the import the gate actually enforces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two diagnostics still told the reader to take the seam from `@object-ui/react` — the console pin's assertion message and the gate script's own fix instruction. Following either would reproduce the collection failure this PR just fixed, which is the worst kind of stale message: it is read only by someone already stuck. Part of #6678 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CRJge11jso9TpXRWFt1Z49 --- .../element-data-source-input-injection.test.tsx | 2 +- scripts/check-element-data-source-declaration.mjs | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) 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 index 5a64b32227..8e80e6af52 100644 --- a/apps/console/src/__tests__/element-data-source-input-injection.test.tsx +++ b/apps/console/src/__tests__/element-data-source-input-injection.test.tsx @@ -206,7 +206,7 @@ describe('the `dataSource` declaration is emitted at the gate-wrapping seam (#66 expect( undeclared, 'These registrations render ElementDataSourceGate but publish no `dataSource` input. ' - + 'Wrap the registered renderer in `elementDataSourceBlock(...)` from @object-ui/react — ' + + 'Wrap the registered renderer in `elementDataSourceBlock(...)` from @object-ui/core — ' + 'do NOT hand-write the input.', ).toEqual([]); diff --git a/scripts/check-element-data-source-declaration.mjs b/scripts/check-element-data-source-declaration.mjs index ec654136b4..fcbc3fab1e 100644 --- a/scripts/check-element-data-source-declaration.mjs +++ b/scripts/check-element-data-source-declaration.mjs @@ -206,9 +206,11 @@ if (missing.length > 0) { '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(...)` from\n' + - '@object-ui/react. The declaration itself is emitted by Registry.register; do not write a\n' + - '`dataSource` input by hand -- nine hand-kept copies is the shape the 2026-08-29 ruling refused.', + '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); } From 67340561aceaa092df8e6d69dcebe754745fcf63 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 09:38:00 +0000 Subject: [PATCH 8/8] docs(ci): name the ElementDataSourceGate declaration gate in the type-check row `ci.yml`'s `type-check` job runs `pnpm check:element-data-source-declaration` between `check:side-effects-array` and `check:esm-specifiers`, but the job table's "What it runs" cell did not name it, so `scripts/__tests__/ci-cd-pipeline-doc.test.ts` was red on this branch. Add it to the cell in the order ci.yml runs it, with the one-sentence explanation the page gives every other gate in that row. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CRJge11jso9TpXRWFt1Z49 --- content/docs/guide/ci-cd-pipeline.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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** |