From 334e7bd76b80cb3d82da4f03b8c6545a1b95e260 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 14:11:03 +0000 Subject: [PATCH] =?UTF-8?q?feat(spec):=20declare=20cloud-connection:panel?= =?UTF-8?q?=20/=20marketplace:installed-list=20in=20ComponentPropsMap=20?= =?UTF-8?q?=E2=80=94=20undeclared=20keys=20refused=20(#11575)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ui/component.zod.ts: two emptyProps rows — key sets measured from the renderers' ACTUAL read points at the .objectui-sha pin (app-shell CloudConnectionPanel.tsx / InstalledListWidget.tsx): both registrations discard the schema node ('() => ') and neither component takes a prop, so the accepted key set is EMPTY (strict). The registrations' declared 'inputs: []' agree here; the row is the measurement, not the claim. - cloud-connection canonical-expression-envelopes.test.ts: the two exemptUnmappedTypes entries and the emptiness pins deleted — door 3 now reads both bags; the exact-set assert (still in place) demands exactly this. - pins: spec-side (component.test.ts — rows defined, {} accepted, any key refused naming the surface) + lint-side dispatch pins (validate-component-props.test.ts #11575 suite). - semantic migration entry under protocol-18 + regenerated registry (111). - changeset: minor + BREAKING + ADR-0087 disposition (#8744 precedent). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Rxnd8cyFnoU8V5y21PaTsy --- ...connection-widgets-unknown-keys-refused.md | 57 +++++++++++++++++++ .../canonical-expression-envelopes.test.ts | 50 +++++++--------- .../lint/src/validate-component-props.test.ts | 54 ++++++++++++++++++ ...connection-widgets-unknown-keys-refused.ts | 34 +++++++++++ packages/spec/src/migrations/registry.ts | 30 ++++++++++ packages/spec/src/ui/component.test.ts | 37 ++++++++++++ packages/spec/src/ui/component.zod.ts | 24 +++++++- 7 files changed, 253 insertions(+), 33 deletions(-) create mode 100644 .changeset/ui-cloud-connection-widgets-unknown-keys-refused.md create mode 100644 packages/spec/src/migrations/entries/semantic/18.ui-cloud-connection-widgets-unknown-keys-refused.ts diff --git a/.changeset/ui-cloud-connection-widgets-unknown-keys-refused.md b/.changeset/ui-cloud-connection-widgets-unknown-keys-refused.md new file mode 100644 index 0000000000..1a2516187a --- /dev/null +++ b/.changeset/ui-cloud-connection-widgets-unknown-keys-refused.md @@ -0,0 +1,57 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec): declare `cloud-connection:panel` / `marketplace:installed-list` in `ComponentPropsMap` — undeclared keys on the two are refused (#11575) + +**BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep +launch-window convention ships it as `minor`; the migration prescription is +registered under protocol major 18, where `os migrate meta` users will look). + +These were two more instances of the #8691/#8744 silent no-op class: +console-registered widgets on `@objectstack/cloud-connection`'s published +Setup pages, reachable through the component type union's open string arm, +with registered renderers but no `ComponentPropsMap` row — so the #5068 +component-props gate's dispatch skipped them as unregistered and any authored +key rode through every validator in silence. + +The new rows are strict and **empty**, measured from the renderers' actual +read points at the objectui pin, not from the registrations' declared-input +lists (#8691/#8744 record where those diverge — here the two happen to +agree): both registrations discard the schema node entirely +(`() => `, `() => `) and neither +component function takes a prop, so the widgets accept **no configuration at +all**, and an authored key is now a publish-time refusal naming the surface +instead of a silent no-op. + +**What stays accepted:** the empty bag (`{}`, or `properties` omitted) — the +shape both plugin-shipped pages (`cloud_connection_settings`, +`marketplace_installed`) author today, byte-identically. Node-level keys +(`visibleWhen`, `id`, `style`, …) are unaffected: they live on the component +node, and the refusal's guidance says so. + +## FROM → TO + +```ts +// before — parsed green everywhere; the panel polls on its own schedule anyway +{ + type: 'cloud-connection:panel', + properties: { pollInterval: 5 }, // silent no-op: the widget reads nothing +} + +// after — any key is a publish-time refusal naming the zero-prop surface; +// write the measured shape +{ + type: 'cloud-connection:panel', + properties: {}, +} +``` + +There is deliberately no automatic rewrite: a key authored on either widget +configures nothing and is removed, not renamed — behaviour that seems to need +one is a renderer capability request against objectui, not a metadata key. +`os migrate meta` surfaces the change as a structured TODO (semantic entry +`ui-cloud-connection-widgets-unknown-keys-refused`, protocol major 18 — this +refusal is not part of the v17.0.0 cut). + + diff --git a/packages/cloud-connection/src/canonical-expression-envelopes.test.ts b/packages/cloud-connection/src/canonical-expression-envelopes.test.ts index 7ba94613bf..0809657703 100644 --- a/packages/cloud-connection/src/canonical-expression-envelopes.test.ts +++ b/packages/cloud-connection/src/canonical-expression-envelopes.test.ts @@ -20,16 +20,19 @@ * preconditions, the verdict, and a downgrade control proving the detector * reaches these real exports. * - * ## The two exempted component types + * ## No standing exemptions (#11575) * - * `cloud-connection:panel` and `marketplace:installed-list` are - * console-registered widgets with no `ComponentPropsMap` row, so door 3 has - * no schema to read their `properties` with. The exemption is asserted - * EXACTLY (a new unmapped type reds), and it is valid only while those - * components author an EMPTY props bag — nothing authored is nothing to - * serve bare. The moment either widget grows a real authored prop, the - * emptiness assert reds and forces the decision: give the type a - * `ComponentPropsMap` row, or widen the exemption knowingly. + * `cloud-connection:panel` and `marketplace:installed-list` were exempted + * here between #11480 and #11575: console-registered widgets with no + * `ComponentPropsMap` row, so door 3 had no schema to read their + * `properties` with. #11575 gave both types their rows (strict, empty — + * measured from the renderers' read points at the `.objectui-sha` pin), so + * door 3 now reads both bags and the exemption lists are empty. The + * machinery stays: the exemption set is still asserted EXACTLY, so any NEW + * unmapped type reds and forces the same decision — declare the props + * schema in `ComponentPropsMap`, or record the exemption here with the + * reason (and then also pin the exempted bags empty, as the pre-#11575 + * revision of this file did). */ import { readFileSync, readdirSync } from 'node:fs'; @@ -40,7 +43,6 @@ import type { Page } from '@objectstack/spec/ui'; import { auditPageExpressionEnvelopes, renderBareExpressionFindings, - walkPageComponents, } from '@objectstack/lint'; import { CloudConnectionSettingsPage } from './cloud-connection-ui.js'; import { MarketplaceInstalledPage } from './marketplace-ui.js'; @@ -56,18 +58,19 @@ const HERE = dirname(fileURLToPath(import.meta.url)); /** * Every page this package ships, audited by export name — with the unmapped - * component types each page is EXPECTED to report (the exemptions above). + * component types each page is EXPECTED to report (none since #11575; see + * the module header). */ const AUDITED_PAGES: { exportName: string; page: Page; exemptUnmappedTypes: string[] }[] = [ { exportName: 'CloudConnectionSettingsPage', page: CloudConnectionSettingsPage, - exemptUnmappedTypes: ['cloud-connection:panel'], + exemptUnmappedTypes: [], }, { exportName: 'MarketplaceInstalledPage', page: MarketplaceInstalledPage, - exemptUnmappedTypes: ['marketplace:installed-list'], + exemptUnmappedTypes: [], }, ]; @@ -170,27 +173,14 @@ describe('cloud-connection Page exports serve canonical expression envelopes', ( }); it.each(AUDITS)('$exportName: unmapped component types are EXACTLY the recorded exemptions (door 3 precondition)', ({ audit, exemptUnmappedTypes }) => { - // See the module header for why these two types are exempt. Anything else + // No exemptions stand since #11575 (see the module header). Anything // unmapped is a new door-3 blind spot: declare the props schema in - // `ComponentPropsMap`, or record the exemption here with the reason. + // `ComponentPropsMap`, or record the exemption here with the reason — + // and then also pin the exempted bags empty, as the pre-#11575 revision + // of this file did. expect(audit.unmappedTypes.map(e => e.type).sort()).toEqual([...exemptUnmappedTypes].sort()); }); - it.each(AUDITS)('$exportName: every exempted component authors an EMPTY props bag', ({ page, exemptUnmappedTypes }) => { - // The exemption above is only sound while there is nothing authored for - // door 3 to miss. A real key landing in one of these bags must force a - // decision (props schema row, or a conscious wider exemption) — not ride - // through a standing exemption silently. - const offenders = walkPageComponents(page as AnyRec, '') - .filter(w => typeof w.component.type === 'string' && exemptUnmappedTypes.includes(w.component.type)) - .filter(w => { - const props = w.component.properties; - return !!props && typeof props === 'object' && Object.keys(props).length > 0; - }) - .map(w => `${w.path} [${String(w.component.type)}]`); - expect(offenders.join('\n')).toBe(''); - }); - it.each(AUDITS)('$exportName: every authored `properties` bag parses against its props schema (door 3 precondition)', ({ audit }) => { expect( audit.unreadableProps.map(e => `${e.path} [${e.type}]: ${e.issues}`).join('\n'), diff --git a/packages/lint/src/validate-component-props.test.ts b/packages/lint/src/validate-component-props.test.ts index 959ba9d532..038bfce505 100644 --- a/packages/lint/src/validate-component-props.test.ts +++ b/packages/lint/src/validate-component-props.test.ts @@ -775,3 +775,57 @@ describe('validateComponentProps — record:alert / record:quick_actions / recor expect(findings[0].message).toContain('`dock`'); }); }); + +/** + * #11575 — the two `@objectstack/cloud-connection` console widgets, so the + * gate's dispatch reaches them. + * + * The pre-fix state these pin against: `cloud-connection:panel` and + * `marketplace:installed-list` had no `ComponentPropsMap` row (they are + * open-string-arm types — never in `PageComponentType` — so nothing else + * judged them either), and the walker's unregistered-type skip swallowed the + * whole props bag: any authored key produced ZERO findings from + * validate/build. Their rows are strict and EMPTY — measured from the + * renderers' read points at the `.objectui-sha` pin, where both registrations + * discard the schema node (`() => `) — so EVERY authored key is a + * finding. Remove either map row and its loud test here goes back to that + * silence. + */ +describe('validateComponentProps — cloud-connection:panel / marketplace:installed-list are dispatched (#11575)', () => { + it('reports any key authored on `cloud-connection:panel`, naming the zero-prop surface', () => { + const findings = validateComponentProps( + stackWith([{ + type: 'cloud-connection:panel', + properties: { pollInterval: 5 }, + }]), + ); + expect(findings).toHaveLength(1); + expect(findings[0].rule).toBe(COMPONENT_PROPS_UNKNOWN_KEY); + expect(findings[0].where).toBe('page "probe_page" · cloud-connection:panel'); + expect(findings[0].message).toContain('`pollInterval`'); + expect(findings[0].message).toContain('cloud-connection:panel'); + }); + + it('reports any key authored on `marketplace:installed-list` the same way', () => { + const findings = validateComponentProps( + stackWith([{ + type: 'marketplace:installed-list', + properties: { filter: 'installed' }, + }]), + ); + expect(findings).toHaveLength(1); + expect(findings[0].rule).toBe(COMPONENT_PROPS_UNKNOWN_KEY); + expect(findings[0].where).toBe('page "probe_page" · marketplace:installed-list'); + expect(findings[0].message).toContain('`filter`'); + }); + + it('stays silent on the empty bag both plugin-shipped pages author', () => { + const findings = validateComponentProps( + stackWith([ + { type: 'cloud-connection:panel', properties: {} }, + { type: 'marketplace:installed-list', properties: {} }, + ]), + ); + expect(findings).toEqual([]); + }); +}); diff --git a/packages/spec/src/migrations/entries/semantic/18.ui-cloud-connection-widgets-unknown-keys-refused.ts b/packages/spec/src/migrations/entries/semantic/18.ui-cloud-connection-widgets-unknown-keys-refused.ts new file mode 100644 index 0000000000..0f700b526b --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.ui-cloud-connection-widgets-unknown-keys-refused.ts @@ -0,0 +1,34 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'ui-cloud-connection-widgets-unknown-keys-refused', + surface: 'page `cloud-connection:panel` / `marketplace:installed-list` components — ' + + '`properties` (any key at all: both widgets declare no props)', + replacement: 'an empty `properties` bag (`{}`), or omit `properties` entirely. Neither ' + + 'widget reads any prop: the console registrations discard the schema node ' + + '(`() => `) and the components take no arguments, so there is no declared ' + + 'key to move to — a key authored on either widget configures nothing and is removed, ' + + 'not renamed. Node-level keys (`visibleWhen`, `id`, `style`, …) stay on the component ' + + 'node, where the page runtime reads them.', + reason: + 'These were two more instances of the #8691/#8744 class: console-registered widgets on ' + + '`@objectstack/cloud-connection`\'s published Setup pages, reachable through the ' + + 'component type union\'s open string arm, with registered renderers but no ' + + '`ComponentPropsMap` row — so the #5068 props gate\'s dispatch skipped them as ' + + 'unregistered and any authored key rode through every validator in silence. The new ' + + 'rows are strict and EMPTY, measured from the renderers\' actual read points at the ' + + 'objectui pin (not from the registrations\' declared-input lists): both registrations ' + + 'ignore the component node entirely, so the widgets accept no configuration at all, ' + + 'and an authored key is now a publish-time refusal naming the surface instead of a ' + + 'silent no-op.', + acceptanceCriteria: + 'Every `cloud-connection:panel` / `marketplace:installed-list` node authors an empty ' + + '(or absent) `properties` bag and validates clean — the two plugin-shipped pages ' + + '(`cloud_connection_settings`, `marketplace_installed`) already do; `objectstack ' + + 'validate` reports no `component-props-unknown-key` finding for these types. Any ' + + 'remaining authored key on either widget is deleted (it never configured anything), ' + + 'and behaviour that seems to need one is a renderer capability request against ' + + 'objectui, not a metadata key.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 3daee72644..f73986fa94 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -6139,6 +6139,36 @@ const step18: MigrationStep = { + 'of an envelope-level code; constructing an ApiError with a retired spelling ' + 'fails `StandardErrorCode`/`ApiErrorSchema` parse rather than passing silently.', }, + { + id: 'ui-cloud-connection-widgets-unknown-keys-refused', + surface: 'page `cloud-connection:panel` / `marketplace:installed-list` components — ' + + '`properties` (any key at all: both widgets declare no props)', + replacement: 'an empty `properties` bag (`{}`), or omit `properties` entirely. Neither ' + + 'widget reads any prop: the console registrations discard the schema node ' + + '(`() => `) and the components take no arguments, so there is no declared ' + + 'key to move to — a key authored on either widget configures nothing and is removed, ' + + 'not renamed. Node-level keys (`visibleWhen`, `id`, `style`, …) stay on the component ' + + 'node, where the page runtime reads them.', + reason: + 'These were two more instances of the #8691/#8744 class: console-registered widgets on ' + + '`@objectstack/cloud-connection`\'s published Setup pages, reachable through the ' + + 'component type union\'s open string arm, with registered renderers but no ' + + '`ComponentPropsMap` row — so the #5068 props gate\'s dispatch skipped them as ' + + 'unregistered and any authored key rode through every validator in silence. The new ' + + 'rows are strict and EMPTY, measured from the renderers\' actual read points at the ' + + 'objectui pin (not from the registrations\' declared-input lists): both registrations ' + + 'ignore the component node entirely, so the widgets accept no configuration at all, ' + + 'and an authored key is now a publish-time refusal naming the surface instead of a ' + + 'silent no-op.', + acceptanceCriteria: + 'Every `cloud-connection:panel` / `marketplace:installed-list` node authors an empty ' + + '(or absent) `properties` bag and validates clean — the two plugin-shipped pages ' + + '(`cloud_connection_settings`, `marketplace_installed`) already do; `objectstack ' + + 'validate` reports no `component-props-unknown-key` finding for these types. Any ' + + 'remaining authored key on either widget is deleted (it never configured anything), ' + + 'and behaviour that seems to need one is a renderer capability request against ' + + 'objectui, not a metadata key.', + }, { id: 'ui-record-blocks-unknown-keys-refused', surface: 'page `record:alert` / `record:quick_actions` / `record:history` / ' diff --git a/packages/spec/src/ui/component.test.ts b/packages/spec/src/ui/component.test.ts index 7a71c46489..aa6b5fd173 100644 --- a/packages/spec/src/ui/component.test.ts +++ b/packages/spec/src/ui/component.test.ts @@ -885,6 +885,43 @@ describe('ComponentPropsMap', () => { expect(() => ComponentPropsMap['global:search'].parse({})).not.toThrow(); expect(() => ComponentPropsMap['user:profile'].parse({})).not.toThrow(); }); + + // #11575 — the two `@objectstack/cloud-connection` console widgets. Rows + // exist so the #5068 gate's dispatch reaches them; the accepted key set is + // EMPTY, measured from the renderers' read points at the `.objectui-sha` + // pin (both registrations discard the schema node — `() => `). + describe('plugin console widgets (#11575)', () => { + it('declares rows for cloud-connection:panel and marketplace:installed-list', () => { + expect(ComponentPropsMap['cloud-connection:panel']).toBeDefined(); + expect(ComponentPropsMap['marketplace:installed-list']).toBeDefined(); + }); + + it('accepts the empty bag both shipped pages author', () => { + expect(() => ComponentPropsMap['cloud-connection:panel'].parse({})).not.toThrow(); + expect(() => ComponentPropsMap['marketplace:installed-list'].parse({})).not.toThrow(); + }); + + it('refuses any authored key, naming the surface — the pre-row silent no-op', () => { + // Before the rows, both keys below rode through every validator in + // silence (the widgets read nothing). The refusal must name WHICH + // zero-prop component refused, or the author is left guessing. + const panel = ComponentPropsMap['cloud-connection:panel'].safeParse({ pollInterval: 5 }); + expect(panel.success).toBe(false); + if (!panel.success) { + const message = panel.error.issues.map((i) => i.message).join('\n'); + expect(message).toContain('cloud-connection:panel'); + expect(message).toContain('pollInterval'); + } + + const list = ComponentPropsMap['marketplace:installed-list'].safeParse({ filter: 'installed' }); + expect(list.success).toBe(false); + if (!list.success) { + const message = list.error.issues.map((i) => i.message).join('\n'); + expect(message).toContain('marketplace:installed-list'); + expect(message).toContain('filter'); + } + }); + }); }); // --------------------------------------------------------------------------- diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index 5df09fe0b3..f0795909c6 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -316,12 +316,13 @@ const COMPONENT_LEVEL_GUIDANCE: readonly KeySetGuidance[] = [ /** * A component that declares no props at all — `app:launcher`, `nav:menu`, * `nav:breadcrumb`, `global:search`, `global:notifications`, `user:profile`, - * `element:divider`. + * `element:divider`, and the two plugin console widgets `cloud-connection:panel` + * and `marketplace:installed-list` (#11575). * * A factory rather than one shared `EmptyProps` const, because the surface name * is the whole value of the rejection here: an empty shape has no candidate * keys, so the edit-distance fallback can say nothing, and "unrecognized key on - * this component" would leave the author guessing which of the seven it meant. + * this component" would leave the author guessing which of the nine it meant. * One `strictObject(` call site either way — the ledger counts sites from the * AST, and this is one. * @@ -2574,7 +2575,24 @@ export const ComponentPropsMap = { 'global:search': emptyProps('global:search'), 'global:notifications': emptyProps('global:notifications'), 'user:profile': emptyProps('user:profile'), - + + // Plugin console widgets — #11575, the #8691/#8744 mechanism two instances + // over, on `@objectstack/cloud-connection`'s published Setup pages: both + // types are console-registered renderers reachable only through the type + // union's open string arm, and with no row here the #5068 gate's dispatch + // skipped them as unregistered — any authored key would have ridden through + // in silence (nothing authors one today: both shipped pages carry `{}`). + // Key sets measured from the renderers' ACTUAL read points at the + // `.objectui-sha` pin (app-shell `console/cloud-connection/ + // CloudConnectionPanel.tsx`, `console/marketplace/InstalledListWidget.tsx`): + // both registrations discard the schema node entirely (`() => `) + // and neither component function takes a prop, so the accepted key set is + // EMPTY — strict, refuses every key. The registrations' declared + // `inputs: []` happen to agree here, but the row is the measurement, not + // the claim (#8691/#8744 record where those diverge). + 'cloud-connection:panel': emptyProps('cloud-connection:panel'), + 'marketplace:installed-list': emptyProps('marketplace:installed-list'), + // AI 'ai:chat_window': AIChatWindowProps, 'ai:suggestion': strictObject({