From 63b361403a81885f45a6a0b6bdfecb1d66233472 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 04:58:52 +0000 Subject: [PATCH] feat(spec,lint): author-time rejection for unknown component types in spec-reserved namespaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The type-vocabulary half of the Component Placeholder gap: PageComponentSchema.type keeps its deliberate open string arm (custom/registered components are measured, load-bearing consumers of it), but the spec now answers for its own namespaces — a type inside them that neither the enum, nor ComponentPropsMap, nor the evidenced string-arm ledger declares is refused at author time by the new gating rule component-type-unknown, with closest-spelling suggestions. The parse is unchanged; the refusal lands at os validate / os build / os lint. Readiness read for the Phase-2 members (evidence in the PR): global:search and global:notifications both have shipped platform data sources, so per the ruling both STAY declared — no retirement rides this change. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LpRNHxWZgSUgVnFT9mQQo4 --- .../page-component-type-vocabulary-gate.md | 13 ++ content/docs/references/ui/page.mdx | 4 +- packages/lint/src/authoring-rules.ts | 29 ++++ packages/lint/src/index.ts | 6 + .../lint/src/validate-component-types.test.ts | 135 ++++++++++++++++++ packages/lint/src/validate-component-types.ts | 130 +++++++++++++++++ packages/spec/api-surface/ui.json | 6 + packages/spec/export-origins/ui.json | 6 + .../src/ui/component-type-vocabulary.test.ts | 114 +++++++++++++++ .../spec/src/ui/component-type-vocabulary.ts | 114 +++++++++++++++ packages/spec/src/ui/index.ts | 1 + packages/spec/src/ui/page.zod.ts | 15 +- 12 files changed, 569 insertions(+), 4 deletions(-) create mode 100644 .changeset/page-component-type-vocabulary-gate.md create mode 100644 packages/lint/src/validate-component-types.test.ts create mode 100644 packages/lint/src/validate-component-types.ts create mode 100644 packages/spec/src/ui/component-type-vocabulary.test.ts create mode 100644 packages/spec/src/ui/component-type-vocabulary.ts diff --git a/.changeset/page-component-type-vocabulary-gate.md b/.changeset/page-component-type-vocabulary-gate.md new file mode 100644 index 0000000000..c8bbfd03e3 --- /dev/null +++ b/.changeset/page-component-type-vocabulary-gate.md @@ -0,0 +1,13 @@ +--- +'@objectstack/spec': minor +'@objectstack/lint': minor +--- + +Author-time rejection for unknown `PageComponentSchema.type` strings inside the spec's own namespaces — the type-vocabulary half of the "Component Placeholder" gap. + +`PageComponentSchema.type` is `z.union([PageComponentType, z.string()])`, and the open string arm is deliberate: custom and registered components (`object-grid`, `mcp:connect-agent`, `custom.widget`, kebab SDUI blocks) keep parsing exactly as before — nothing about the parse changed. What is new is that the spec now answers for its own namespaces (`page:` `record:` `nav:` `global:` `user:` `ai:` `app:` `element:`, derived from the enum): a type inside them that the vocabulary does not declare is refused at author time by the new gating rule `component-type-unknown` (`os validate` / `os build` / `os lint`), with the closest declared spellings suggested. Previously `global:serch` validated clean and the published page drew a literal "Component Placeholder" scaffold in front of the end user. + +- `@objectstack/spec` exports the vocabulary claim from `@objectstack/spec/ui`: `RESERVED_COMPONENT_TYPE_NAMESPACES` (derived), `KNOWN_COMPONENT_TYPES` / `KNOWN_COMPONENT_TYPE_CANDIDATES`, `STRING_ARM_REGISTERED_TYPES` (the evidenced ledger of registered-but-row-less types, currently `record:line_items`), and the `hasReservedComponentNamespace` / `isKnownComponentType` predicates. +- `@objectstack/lint` ships `validateComponentTypes` (rule id `component-type-unknown`, severity `error`) on all three CLI commands; the runtime publish door is deliberately deferred pending a measured false-refusal budget over stored tenant page rows. + +If a page authored a type in a reserved namespace that nothing declares, the fix is the rule's own hint: rename to the suggested declared type, or move a genuinely custom component to its own namespace (e.g. `my-plugin:widget`) so it cannot be mistaken for platform vocabulary. diff --git a/content/docs/references/ui/page.mdx b/content/docs/references/ui/page.mdx index 71c0d1eb09..d03ef7ec13 100644 --- a/content/docs/references/ui/page.mdx +++ b/content/docs/references/ui/page.mdx @@ -238,7 +238,7 @@ View filter rule | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **type** | `Enum<'page:header' \| 'page:footer' \| 'page:sidebar' \| 'page:tabs' \| 'page:accordion' \| 'page:card' \| 'page:section' \| 'record:details' \| 'record:highlights' \| 'record:related_list' \| 'record:activity' \| 'record:chatter' \| 'record:discussion' \| 'record:path' \| 'record:alert' \| 'record:quick_actions' \| 'record:reference_rail' \| 'record:history' \| 'app:launcher' \| 'nav:menu' \| 'nav:breadcrumb' \| 'global:search' \| 'global:notifications' \| 'user:profile' \| 'ai:chat_window' \| 'ai:suggestion' \| 'element:text' \| 'element:number' \| 'element:image' \| 'element:divider' \| 'element:button' \| 'element:record_picker' \| 'element:text_input'> \| string` | ✅ | Component Type (Standard enum or custom string) | +| **type** | `Enum<'page:header' \| 'page:footer' \| 'page:sidebar' \| 'page:tabs' \| 'page:accordion' \| 'page:card' \| 'page:section' \| 'record:details' \| 'record:highlights' \| 'record:related_list' \| 'record:activity' \| 'record:chatter' \| 'record:discussion' \| 'record:path' \| 'record:alert' \| 'record:quick_actions' \| 'record:reference_rail' \| 'record:history' \| 'app:launcher' \| 'nav:menu' \| 'nav:breadcrumb' \| 'global:search' \| 'global:notifications' \| 'user:profile' \| 'ai:chat_window' \| 'ai:suggestion' \| 'element:text' \| 'element:number' \| 'element:image' \| 'element:divider' \| 'element:button' \| 'element:record_picker' \| 'element:text_input'> \| string` | ✅ | Component Type — a standard vocabulary member, or a custom/registered component type in its own namespace (e.g. `object-grid`, `mcp:connect-agent`). The spec's own type namespaces are a closed vocabulary at author time: inside them, a type the vocabulary does not declare is refused by `os validate` / `os build` / `os lint` (rule `component-type-unknown`). | | **id** | `string` | optional | Unique instance ID | | **label** | `string \| Record` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time | | **properties** | `Record` | optional (default: `{}`) | Component props passed to the widget. See component.zod.ts for schemas. | @@ -337,7 +337,7 @@ View filter rule | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **type** | `Enum<'page:header' \| 'page:footer' \| 'page:sidebar' \| 'page:tabs' \| 'page:accordion' \| …> \| string` | ✅ | Component Type (Standard enum or custom string) | +| **type** | `Enum<'page:header' \| 'page:footer' \| 'page:sidebar' \| 'page:tabs' \| 'page:accordion' \| …> \| string` | ✅ | Component Type — a standard vocabulary member, or a custom/registered component type in its own namespace (e.g. `object-grid`, `mcp:connect-agent`). The spec's own type namespaces are a closed vocabulary at author time: inside them, a type the vocabulary does not declare is refused by `os validate` / `os build` / `os lint` (rule `component-type-unknown`). | | **id** | `string` | optional | Unique instance ID | | **label** | `string \| Record` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time | | **properties** | `Record` | optional (default: `{}`) | Component props passed to the widget. See component.zod.ts for schemas. | diff --git a/packages/lint/src/authoring-rules.ts b/packages/lint/src/authoring-rules.ts index 2a671a8b51..edbf225371 100644 --- a/packages/lint/src/authoring-rules.ts +++ b/packages/lint/src/authoring-rules.ts @@ -109,6 +109,7 @@ import { validatePresetComparands } from './validate-preset-comparands.js'; import { validateEmptyCombinators } from './validate-empty-combinators.js'; import { validateReferenceIntegrity } from './reference-integrity-suite.js'; import { validateComponentProps } from './validate-component-props.js'; +import { validateComponentTypes } from './validate-component-types.js'; import { validateResponsiveStyles } from './validate-responsive-styles.js'; import { validateJsxPages } from './validate-jsx-pages.js'; import { validateReactPages } from './validate-react-pages.js'; @@ -731,6 +732,34 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [ surfaceReason: RUNTIME_NEEDS_FULL_SNAPSHOT, run: (stack) => validateComponentProps(stack), }, + // The TYPE half of the same door (#12183's ruling): a component `type` inside + // a spec-reserved namespace must be vocabulary the platform declares, or the + // page ships a placeholder scaffold to the end user. Namespace-shaped on + // purpose — custom/registered types outside the spec's own namespaces are the + // open string arm's declared story and stay untouched (the accept set and its + // ledger live in `component-type-vocabulary.ts`, `@objectstack/spec/ui`). + { + name: 'validateComponentTypes', + tier: 'gating', + input: 'normalized', + commands: ALL, + source: 'packages/lint/src/validate-component-types.ts', + surfaces: CLI_ONLY, + // Page-local judgment, so the per-write snapshot IS sufficient — what the + // crossing owes is not a wider snapshot but the #4716 discipline: a gating + // rule reaches the Studio/REST/MCP door only behind a measured + // false-refusal budget over REAL stored page rows, and the population this + // rule was measured on (the in-repo corpus, 0 findings) is authored + // config-file metadata, not tenant rows. A tenant page carrying a + // reserved-namespace type their own plugin registers would be refused at + // the only door that tenant has. Crossing is its own rollout decision with + // that replay as its evidence, not a bare `runtimeTypes` edit. + surfaceReason: + 'Gating rule held off the runtime door pending the #4716 crossing discipline: a measured ' + + 'false-refusal budget over stored tenant page rows (the in-repo 0-finding measurement covers ' + + 'authored config-file metadata only). Crossing is its own rollout card.', + run: (stack) => validateComponentTypes(stack), + }, // ADR-0065 — a styled node's responsiveStyles must be scopable (needs an // `id`), name real CSS properties + design tokens, and carry a `large` base. { diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index 92672a43a9..397a3cbaae 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -411,6 +411,12 @@ export { } from './validate-component-props.js'; export type { ComponentPropsFinding, ComponentPropsSeverity } from './validate-component-props.js'; +export { + validateComponentTypes, + COMPONENT_TYPE_UNKNOWN, +} from './validate-component-types.js'; +export type { ComponentTypeFinding } from './validate-component-types.js'; + export { validateChartBindings, CHART_DIMENSION_UNKNOWN, diff --git a/packages/lint/src/validate-component-types.test.ts b/packages/lint/src/validate-component-types.test.ts new file mode 100644 index 0000000000..4a6748fbf4 --- /dev/null +++ b/packages/lint/src/validate-component-types.test.ts @@ -0,0 +1,135 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The `component-type-unknown` gate (#12950): a component `type` inside a + * spec-reserved namespace must be vocabulary the platform declares. Both + * directions matter equally here — the negative half (what the rule must NOT + * flag) is the measured extension story a union collapse would have broken, so + * each negative case names the face it protects. + */ +import { describe, it, expect } from 'vitest'; +import { + validateComponentTypes, + COMPONENT_TYPE_UNKNOWN, +} from './validate-component-types.js'; + +const page = (components: unknown[], name = 'p1', extra: Record = {}) => ({ + pages: [{ name, regions: [{ name: 'main', components }], ...extra }], +}); + +describe('refuses undeclared types inside reserved namespaces', () => { + it('flags a typo of an enum member and suggests the declared spelling', () => { + const findings = validateComponentTypes(page([{ type: 'global:serch' }])); + expect(findings).toHaveLength(1); + const f = findings[0]; + expect(f.rule).toBe(COMPONENT_TYPE_UNKNOWN); + expect(f.severity).toBe('error'); + expect(f.path).toBe('pages[0].regions[0].components[0].type'); + expect(f.where).toBe('page "p1" · global:serch'); + expect(f.message).toContain('`global:serch`'); + expect(f.message).toContain("'global:search'"); + expect(f.hint).toContain('global:search'); + }); + + it('flags a typo of a record component', () => { + const findings = validateComponentTypes(page([{ type: 'record:detials' }])); + expect(findings).toHaveLength(1); + expect(findings[0].message).toContain("'record:details'"); + }); + + it('a far-from-anything reserved string gets the own-namespace prescription', () => { + const findings = validateComponentTypes(page([{ type: 'record:zzzz_qqqq_wwww' }])); + expect(findings).toHaveLength(1); + expect(findings[0].hint).toContain('own namespace'); + expect(findings[0].hint).toContain('my-plugin:zzzz_qqqq_wwww'); + }); + + it('reaches nested components (tab item children)', () => { + const findings = validateComponentTypes( + page([ + { + type: 'page:tabs', + properties: { items: [{ label: 'T', children: [{ type: 'element:txt' }] }] }, + }, + ]), + ); + expect(findings).toHaveLength(1); + expect(findings[0].path).toBe( + 'pages[0].regions[0].components[0].properties.items[0].children[0].type', + ); + expect(findings[0].message).toContain("'element:text'"); + }); + + it('reaches slot-mounted components', () => { + const findings = validateComponentTypes({ + pages: [{ name: 'sl', kind: 'record', slots: { header: { type: 'page:headr' } } }], + }); + expect(findings).toHaveLength(1); + expect(findings[0].path).toBe('pages[0].slots.header.type'); + expect(findings[0].message).toContain("'page:header'"); + }); +}); + +describe('leaves the declared vocabulary and the open arm alone', () => { + it.each([ + // Enum members — including this card's two kept Phase-2 members. + 'global:search', + 'global:notifications', + 'page:header', + 'record:details', + // ComponentPropsMap rows that are NOT enum members: the measured + // string-arm registrations that earned a row. + 'element:metadata_viewer', + // Retired at element grain with the row KEPT so the props gate dispatches + // the tombstones — the type stays accepted; the keys refuse (#9220 shape). + 'element:filter', + 'element:form', + // The string-arm registration ledger (registered in objectui, row-less by + // pinned decision). + 'record:line_items', + // Plugin namespaces — the open arm's declared story. + 'mcp:connect-agent', + 'cloud-connection:panel', + 'marketplace:installed-list', + // Colon-free custom/SDUI shapes — ditto. + 'flex', + 'grid', + 'object-chart', + 'object-grid', + 'page-header', + 'custom.widget', + ])('accepts %s', (type) => { + expect(validateComponentTypes(page([{ type }]))).toEqual([]); + }); + + it('yields nothing for source-authored pages (react/jsx/html)', () => { + const findings = validateComponentTypes({ + pages: [ + { + name: 'r1', + kind: 'react', + source: 'export default () => null', + regions: [{ name: 'main', components: [{ type: 'global:serch' }] }], + }, + ], + }); + expect(findings).toEqual([]); + }); + + it('tolerates malformed input shapes', () => { + expect(validateComponentTypes({} as never)).toEqual([]); + expect(validateComponentTypes({ pages: 'nope' } as never)).toEqual([]); + expect(validateComponentTypes(page([{ type: 42 }, {}, null]))).toEqual([]); + }); + + it('walks name-keyed page maps', () => { + const findings = validateComponentTypes({ + pages: { + keyed_page: { regions: [{ name: 'main', components: [{ type: 'nav:menue' }] }] }, + }, + }); + expect(findings).toHaveLength(1); + expect(findings[0].where).toBe('page "keyed_page" · nav:menue'); + expect(findings[0].message).toContain("'nav:menu'"); + }); +}); diff --git a/packages/lint/src/validate-component-types.ts b/packages/lint/src/validate-component-types.ts new file mode 100644 index 0000000000..2833f80b70 --- /dev/null +++ b/packages/lint/src/validate-component-types.ts @@ -0,0 +1,130 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0078] The page-component TYPE gate — the author-time rejection the open + * `type` union never had (#12950, riding the #12183 ruling of 2026-08-26). + * + * ## What was missing + * + * `PageComponentSchema.type` is `z.union([PageComponentType, z.string()])`, so + * an arbitrary string parses. The open arm is deliberate and load-bearing — + * see `component-type-vocabulary.ts` for the measured inventory of what a + * union collapse would break — but it also swallowed the spec's OWN + * namespaces: `global:serch` parsed as happily as `global:search`, every + * authoring command validated it clean, and the console drew the literal + * "Component Placeholder" scaffold in front of an end user. The origin card + * measured that in a real browser: two published pages whose entire content + * was placeholder, with zero diagnostics anywhere on the authoring path. + * + * ## What this rule does + * + * One namespace-shaped judgment per authored component node: a `type` inside a + * namespace the enum itself populates (derived, never restated) must be a type + * the spec answers for — an enum member, a `ComponentPropsMap` row (which + * carries the measured string-arm registrations, including the tombstoned + * `element:filter` / `element:form`), or a `STRING_ARM_REGISTERED_TYPES` + * ledger entry. Anything else is refused with `severity: 'error'` and the + * closest declared spellings. + * + * Types OUTSIDE the reserved namespaces are untouched — plugin widgets + * (`mcp:connect-agent`), kebab SDUI blocks (`flex`, `object-chart`, + * `page-header`), dot shapes (`custom.widget`) all keep the open-arm contract. + * This rule closes nothing the extension story declares open; it closes the + * spec's own vocabulary, which nothing ever declared open — it was merely + * unchecked. + * + * ## Why `error` from birth (contrast #5068's warning-first) + * + * The props gate launched advisory because the live corpus VIOLATED the + * declarations it enforced. This rule's live corpus is clean, measured before + * severity was chosen: across `examples/**` and `packages/**` page sources the + * only reserved-namespace strings outside the accept set are conversion-fixture + * stand-ins (`record:detail`, `record:list`, `element:custom` in + * `conversions/registry.ts` — replayed by the conversion harness, never fed to + * the authoring commands) and the ledgered `record:line_items`. An error gate + * with zero live findings breaks no one and refuses the next `global:serch` at + * the door instead of in front of a user. + */ + +import { + hasReservedComponentNamespace, + isKnownComponentType, + KNOWN_COMPONENT_TYPE_CANDIDATES, +} from '@objectstack/spec/ui'; +import { findClosestMatches, formatSuggestion } from '@objectstack/spec/shared'; +import { walkPageComponents, type AnyRec } from './page-walk.js'; + +/** A component `type` inside a spec-reserved namespace that the vocabulary does not declare. */ +export const COMPONENT_TYPE_UNKNOWN = 'component-type-unknown'; + +export interface ComponentTypeFinding { + severity: 'error'; + /** Diagnostic rule id. */ + rule: string; + /** Human-readable location, e.g. `page "app_launcher" · global:serch`. */ + where: string; + /** Config path, e.g. `pages[0].regions[1].components[0].type`. */ + path: string; + /** What is wrong. */ + message: string; + /** How to fix it. */ + hint: string; +} + +function isRec(v: unknown): v is AnyRec { + return !!v && typeof v === 'object' && !Array.isArray(v); +} + +function strName(v: unknown): string | undefined { + return typeof v === 'string' && v.length > 0 ? v : undefined; +} + +/** Coerce a collection (array or name-keyed map) to an array of records. */ +function asArray(v: unknown): AnyRec[] { + if (Array.isArray(v)) return v as AnyRec[]; + if (v && typeof v === 'object') { + return Object.entries(v as AnyRec).map(([name, def]) => ({ name, ...(def as AnyRec) })); + } + return []; +} + +export function validateComponentTypes(stack: AnyRec): ComponentTypeFinding[] { + const findings: ComponentTypeFinding[] = []; + if (!isRec(stack)) return findings; + + const pages = asArray(stack.pages); + for (let pi = 0; pi < pages.length; pi++) { + const page = pages[pi]; + if (!isRec(page)) continue; + const pageName = strName(page.name) ?? `#${pi}`; + + for (const { component, path } of walkPageComponents(page, `pages[${pi}]`)) { + const type = strName(component.type); + if (!type) continue; + if (!hasReservedComponentNamespace(type)) continue; // the open arm's half — deliberately untouched + if (isKnownComponentType(type)) continue; + + const suggestions = findClosestMatches(type, KNOWN_COMPONENT_TYPE_CANDIDATES); + const suggestion = formatSuggestion(suggestions); + findings.push({ + severity: 'error', + rule: COMPONENT_TYPE_UNKNOWN, + where: `page "${pageName}" · ${type}`, + path: `${path}.type`, + message: + `\`${type}\` is not a component type the platform vocabulary declares. Its namespace ` + + `(\`${type.slice(0, type.indexOf(':'))}:\`) belongs to the standard component vocabulary, so nothing ` + + 'will ever render this node — the page would validate, publish, and then draw a placeholder ' + + 'scaffold in front of the end user.' + + (suggestion ? ` ${suggestion}` : ''), + hint: suggestions.length + ? `Rename \`${type}\` → \`${suggestions[0]}\`.` + : `Use a declared component type from the standard vocabulary, or — for a custom component ` + + `registered by your own plugin — give it its own namespace (e.g. \`my-plugin:${type.slice(type.indexOf(':') + 1)}\`) ` + + 'so it cannot be mistaken for platform vocabulary.', + }); + } + } + + return findings; +} diff --git a/packages/spec/api-surface/ui.json b/packages/spec/api-surface/ui.json index 74b169d406..a72455a5aa 100644 --- a/packages/spec/api-surface/ui.json +++ b/packages/spec/api-surface/ui.json @@ -200,6 +200,8 @@ "InterfacePageConfigSchema (const)", "JoinedReportBlock (type)", "JoinedReportBlockSchema (const)", + "KNOWN_COMPONENT_TYPES (const)", + "KNOWN_COMPONENT_TYPE_CANDIDATES (const)", "KanbanConfig (type)", "KanbanConfigSchema (const)", "ListChartConfig (type)", @@ -283,6 +285,7 @@ "REACT_RECORD_BLOCK_ALTERNATIVES (const)", "RECORD_CONTEXT_BLOCK_TAGS (const)", "RECORD_CONTEXT_TYPE_PREFIX (const)", + "RESERVED_COMPONENT_TYPE_NAMESPACES (const)", "ReactBlockDef (interface)", "ReactInteractionProp (interface)", "ReactPropKind (type)", @@ -322,6 +325,7 @@ "RowColorConfigSchema (const)", "RowHeight (type)", "RowHeightSchema (const)", + "STRING_ARM_REGISTERED_TYPES (const)", "SelectionConfig (type)", "SelectionConfigParsed (type)", "SelectionConfigSchema (const)", @@ -411,9 +415,11 @@ "expandViewContainer (function)", "expandViewContainerWithDiagnostics (function)", "expressionBindableTextKeysFor (function)", + "hasReservedComponentNamespace (function)", "isActionParamValuePresent (function)", "isAggregatedViewContainer (function)", "isExpressionBindableTextKey (function)", + "isKnownComponentType (function)", "isRecordContextBlockType (function)", "isViewContainerShaped (function)", "normalizeFilterOperator (function)", diff --git a/packages/spec/export-origins/ui.json b/packages/spec/export-origins/ui.json index e73c6dab6c..e71c8012d9 100644 --- a/packages/spec/export-origins/ui.json +++ b/packages/spec/export-origins/ui.json @@ -200,6 +200,8 @@ "InterfacePageConfigSchema": "src/ui/page.zod.ts#InterfacePageConfigSchema (const)", "JoinedReportBlock": "src/ui/report.zod.ts#JoinedReportBlock (type)", "JoinedReportBlockSchema": "src/ui/report.zod.ts#JoinedReportBlockSchema (const)", + "KNOWN_COMPONENT_TYPES": "src/ui/component-type-vocabulary.ts#KNOWN_COMPONENT_TYPES (const)", + "KNOWN_COMPONENT_TYPE_CANDIDATES": "src/ui/component-type-vocabulary.ts#KNOWN_COMPONENT_TYPE_CANDIDATES (const)", "KanbanConfig": "src/ui/view.zod.ts#KanbanConfig (type)", "KanbanConfigSchema": "src/ui/view.zod.ts#KanbanConfigSchema (const)", "ListChartConfig": "src/ui/view.zod.ts#ListChartConfig (type)", @@ -283,6 +285,7 @@ "REACT_RECORD_BLOCK_ALTERNATIVES": "src/ui/react-blocks.ts#REACT_RECORD_BLOCK_ALTERNATIVES (const)", "RECORD_CONTEXT_BLOCK_TAGS": "src/ui/react-blocks.ts#RECORD_CONTEXT_BLOCK_TAGS (const)", "RECORD_CONTEXT_TYPE_PREFIX": "src/ui/react-blocks.ts#RECORD_CONTEXT_TYPE_PREFIX (const)", + "RESERVED_COMPONENT_TYPE_NAMESPACES": "src/ui/component-type-vocabulary.ts#RESERVED_COMPONENT_TYPE_NAMESPACES (const)", "ReactBlockDef": "src/ui/react-blocks.ts#ReactBlockDef (interface)", "ReactInteractionProp": "src/ui/react-blocks.ts#ReactInteractionProp (interface)", "ReactPropKind": "src/ui/react-blocks.ts#ReactPropKind (type)", @@ -322,6 +325,7 @@ "RowColorConfigSchema": "src/ui/view.zod.ts#RowColorConfigSchema (const)", "RowHeight": "src/ui/view.zod.ts#RowHeight (type)", "RowHeightSchema": "src/ui/view.zod.ts#RowHeightSchema (const)", + "STRING_ARM_REGISTERED_TYPES": "src/ui/component-type-vocabulary.ts#STRING_ARM_REGISTERED_TYPES (const)", "SelectionConfig": "src/ui/view.zod.ts#SelectionConfig (type)", "SelectionConfigParsed": "src/ui/view.zod.ts#SelectionConfigParsed (type)", "SelectionConfigSchema": "src/ui/view.zod.ts#SelectionConfigSchema (const)", @@ -411,9 +415,11 @@ "expandViewContainer": "src/ui/view.zod.ts#expandViewContainer (function)", "expandViewContainerWithDiagnostics": "src/ui/view.zod.ts#expandViewContainerWithDiagnostics (function)", "expressionBindableTextKeysFor": "src/ui/expression-bindable-text-keys.zod.ts#expressionBindableTextKeysFor (function)", + "hasReservedComponentNamespace": "src/ui/component-type-vocabulary.ts#hasReservedComponentNamespace (function)", "isActionParamValuePresent": "src/ui/action-params.zod.ts#isActionParamValuePresent (function)", "isAggregatedViewContainer": "src/ui/view.zod.ts#isAggregatedViewContainer (function)", "isExpressionBindableTextKey": "src/ui/expression-bindable-text-keys.zod.ts#isExpressionBindableTextKey (function)", + "isKnownComponentType": "src/ui/component-type-vocabulary.ts#isKnownComponentType (function)", "isRecordContextBlockType": "src/ui/react-blocks.ts#isRecordContextBlockType (function)", "isViewContainerShaped": "src/ui/assembled-views.zod.ts#isViewContainerShaped (function)", "normalizeFilterOperator": "src/ui/view.zod.ts#normalizeFilterOperator (function)", diff --git a/packages/spec/src/ui/component-type-vocabulary.test.ts b/packages/spec/src/ui/component-type-vocabulary.test.ts new file mode 100644 index 0000000000..d56d3be07c --- /dev/null +++ b/packages/spec/src/ui/component-type-vocabulary.test.ts @@ -0,0 +1,114 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Pins for the page-component type vocabulary claim (#12950) — the + * `vocabulary-derivation.test.ts` discipline applied to the namespace claim: + * every set here is DERIVED in the source module, so these tests assert the + * derivation still holds and the ledger still earns its rows. The failure mode + * of a restated list is silence; the failure mode of an unpinned ledger is a + * grandfather clause nobody can date. + */ +import { describe, it, expect } from 'vitest'; +import { + RESERVED_COMPONENT_TYPE_NAMESPACES, + STRING_ARM_REGISTERED_TYPES, + KNOWN_COMPONENT_TYPES, + KNOWN_COMPONENT_TYPE_CANDIDATES, + hasReservedComponentNamespace, + isKnownComponentType, +} from './component-type-vocabulary'; +import { PageComponentType } from './page.zod'; +import { ComponentPropsMap } from './component.zod'; + +describe('RESERVED_COMPONENT_TYPE_NAMESPACES is derived from the enum', () => { + it('claims exactly the namespaces the enum populates', () => { + // A new namespace appearing here is a NEW VOCABULARY CLAIM — the + // `component-type-unknown` rule starts refusing undeclared strings under + // it the day the enum member lands. Update this list consciously. + expect([...RESERVED_COMPONENT_TYPE_NAMESPACES].sort()).toEqual([ + 'ai', 'app', 'element', 'global', 'nav', 'page', 'record', 'user', + ]); + }); + + it('every enum member sits inside a reserved namespace', () => { + for (const member of PageComponentType.options) { + expect(hasReservedComponentNamespace(member), member).toBe(true); + } + }); +}); + +describe('KNOWN_COMPONENT_TYPES covers every declared face', () => { + it('contains every enum member and every ComponentPropsMap row', () => { + for (const member of PageComponentType.options) { + expect(isKnownComponentType(member), member).toBe(true); + } + for (const key of Object.keys(ComponentPropsMap)) { + expect(isKnownComponentType(key), key).toBe(true); + } + }); + + it('the candidate list is the known set, sorted and stable', () => { + expect(KNOWN_COMPONENT_TYPE_CANDIDATES).toEqual([...KNOWN_COMPONENT_TYPES].sort()); + }); + + /** + * #12950's own readiness verdict, pinned: `global:search` and + * `global:notifications` STAY declared — the 2026-08-26 ruling retires a + * member only when no data source covers the horizon, and both are backed by + * shipped platform data sources (the cross-object search protocol behind + * `GET /api/v1/search`; the inbox materialization behind + * `GET /api/v1/notifications`). Retiring either later is a conscious edit + * here, through the spec-property-retirement playbook, not a drive-by. + */ + it('the Phase-2 members stay declared', () => { + expect(PageComponentType.options).toContain('global:search'); + expect(PageComponentType.options).toContain('global:notifications'); + expect(isKnownComponentType('global:search')).toBe(true); + expect(isKnownComponentType('global:notifications')).toBe(true); + }); +}); + +describe('STRING_ARM_REGISTERED_TYPES ledger discipline', () => { + it('every entry is reserved-namespace, not an enum member, not a map row', () => { + const enumSet = new Set(PageComponentType.options); + const mapKeys = new Set(Object.keys(ComponentPropsMap)); + for (const entry of STRING_ARM_REGISTERED_TYPES) { + // Outside a reserved namespace the open string arm already accepts the + // type — a ledger row there is dead weight. + expect(hasReservedComponentNamespace(entry), entry).toBe(true); + // An enum member or a map row is already known — a ledger row for one is + // a grandfather clause pretending to be an exemption. When + // `record:line_items` is measured into the map, this assertion forces + // its ledger row OUT in the same PR. + expect(enumSet.has(entry), entry).toBe(false); + expect(mapKeys.has(entry), entry).toBe(false); + } + }); + + it('ledger entries are known', () => { + for (const entry of STRING_ARM_REGISTERED_TYPES) { + expect(isKnownComponentType(entry), entry).toBe(true); + } + }); +}); + +describe('the namespace predicate leaves the open arm open', () => { + it.each(['mcp:connect-agent', 'cloud-connection:panel', 'marketplace:installed-list'])( + 'plugin namespace %s is not reserved', + (type) => { + expect(hasReservedComponentNamespace(type)).toBe(false); + }, + ); + + it.each(['flex', 'grid', 'object-chart', 'page-header', 'custom.widget'])( + 'colon-free shape %s is not reserved', + (type) => { + expect(hasReservedComponentNamespace(type)).toBe(false); + }, + ); + + it('an undeclared string inside a reserved namespace is reserved and unknown', () => { + expect(hasReservedComponentNamespace('global:serch')).toBe(true); + expect(isKnownComponentType('global:serch')).toBe(false); + }); +}); diff --git a/packages/spec/src/ui/component-type-vocabulary.ts b/packages/spec/src/ui/component-type-vocabulary.ts new file mode 100644 index 0000000000..9b1a95bc1a --- /dev/null +++ b/packages/spec/src/ui/component-type-vocabulary.ts @@ -0,0 +1,114 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The page-component TYPE vocabulary claim (#12950, riding the #12183 ruling of + * 2026-08-26) — which `PageComponentSchema.type` strings the spec answers for, + * and which half of the string space stays open. + * + * ## The contract this file states + * + * `PageComponentSchema.type` is `z.union([PageComponentType, z.string()])` — an + * open namespace for custom components, and that HALF IS DELIBERATE (maintainer + * ruling 2026-08-05 on the props-gate direction; re-affirmed by every + * registered-through-the-string-arm widget since: `cloud-connection:panel`, + * `marketplace:installed-list`, `mcp:connect-agent`, the `object-*` SDUI block + * family, objectui's kebab legacy `page-header`, and the `custom.*` shapes + * pinned in `page.test.ts`). Collapsing the union to the enum would reject all + * of them — measured, not assumed. + * + * What was NOT deliberate is that the open arm also swallowed the spec's OWN + * namespaces: `global:serch` parsed as happily as `global:search`, validated + * clean, and rendered the "Component Placeholder" scaffold in front of an end + * user — the exact ADR-0078 failure shape, measured in a real browser on the + * origin card. So the claim is namespace-shaped: + * + * - **Inside a namespace the enum itself populates** (derived below, never + * restated), the vocabulary is CLOSED at author time: a type neither the + * enum, nor `ComponentPropsMap`, nor the string-arm registration ledger + * below declares is refused by the `component-type-unknown` authoring rule + * (`@objectstack/lint`). + * - **Everywhere else** — plugin namespaces (`mcp:`, `cloud-connection:`, + * `marketplace:`), block names without a namespace (`flex`, `grid`, + * `object-chart`), dot shapes (`custom.widget`) — the string arm stays + * exactly as open as it was. Custom components keep their own namespaces; + * the spec only answers for its own. + * + * The parse itself is UNCHANGED — this is a validate-time claim, not a union + * narrowing. Stored documents keep loading, conversions keep walking, and the + * refusal lands at the authoring doors (`os validate` / `os build` / `os lint`) + * where the author who typed the string is still present to fix it. + */ + +import { PageComponentType } from './page.zod'; +import { ComponentPropsMap } from './component.zod'; + +/** + * Registered renderers reachable ONLY through the type union's open string arm, + * inside a spec-reserved namespace, with no `ComponentPropsMap` row — the + * ledger of why each one is exempt from the closed-vocabulary claim, in the + * `REACT_OVERLAY_SHADOWS` house shape: an exemption is deliberate, evidenced, + * and written down next to itself, or it does not exist. + * + * - `record:line_items` — objectui registers it (`plugin-form/src/index.tsx`, + * the inline-editable child grid of objectui ADR-0001) and lists it as a + * public block (`core/src/registry/public-blocks.ts`); the showcase authors + * it (`examples/app-showcase/src/ui/pages/project-detail.page.ts`). Its + * row-lessness in `ComponentPropsMap` is pinned deliberately in + * `validate-component-props.test.ts` — a registered-but-unmeasured gap of the + * record-blocks class, to be measured into the map by the renderer-read-point + * method, not silently grandfathered by this file. When that row lands, the + * vocabulary test beside this file forces this entry OUT (a ledger row for a + * type the map declares is dead weight). + * + * Growing this list is a contract decision, not a convenience: every entry is a + * type the spec's own namespace claim cannot see, so each one needs the same + * three-part evidence face as the entry above (registration, publication, + * authorship) written into its comment. + */ +export const STRING_ARM_REGISTERED_TYPES: readonly string[] = ['record:line_items']; + +/** + * The namespaces the enum itself populates — DERIVED, never restated, so a new + * enum member in a new namespace claims that namespace the day it lands + * (`vocabulary-derivation.test.ts` discipline: a restated list keeps validating + * while the enum no longer says what it says). + */ +export const RESERVED_COMPONENT_TYPE_NAMESPACES: ReadonlySet = new Set( + PageComponentType.options.map((t) => t.slice(0, t.indexOf(':'))), +); + +/** + * Every type string the spec answers for: the enum vocabulary, every + * `ComponentPropsMap` row (which is a superset of the enum by exactly the + * measured string-arm registrations that DID get a row — `element:metadata_viewer`, + * the retired-with-tombstones `element:filter` / `element:form`, the plugin + * console widgets, the `object-*` blocks), and the string-arm ledger above. + */ +export const KNOWN_COMPONENT_TYPES: ReadonlySet = new Set([ + ...PageComponentType.options, + ...Object.keys(ComponentPropsMap), + ...STRING_ARM_REGISTERED_TYPES, +]); + +/** + * Stable candidate list for typo suggestions — only the types an author may + * actually write inside a reserved namespace, sorted for deterministic output. + */ +export const KNOWN_COMPONENT_TYPE_CANDIDATES: readonly string[] = + [...KNOWN_COMPONENT_TYPES].sort(); + +/** + * Is this type inside a namespace the spec's enum claims? (`record:detials` → + * true; `mcp:connect-agent` → false; `flex` / `custom.widget` → false — no + * colon-namespace at all.) + */ +export function hasReservedComponentNamespace(type: string): boolean { + const colon = type.indexOf(':'); + if (colon <= 0) return false; + return RESERVED_COMPONENT_TYPE_NAMESPACES.has(type.slice(0, colon)); +} + +/** Does the spec answer for this exact type string? */ +export function isKnownComponentType(type: string): boolean { + return KNOWN_COMPONENT_TYPES.has(type); +} diff --git a/packages/spec/src/ui/index.ts b/packages/spec/src/ui/index.ts index 6546e4c47a..e40b4ed779 100644 --- a/packages/spec/src/ui/index.ts +++ b/packages/spec/src/ui/index.ts @@ -40,6 +40,7 @@ export * from './action-params.zod'; export * from './page.zod'; export * from './widget.zod'; export * from './component.zod'; +export * from './component-type-vocabulary'; export * from './react-blocks'; // `theme.zod.ts` was RETIRED WHOLE at #10485 (ADR-0049 enforce-or-remove; // maintainer ruling 2026-08-21: 退役授权面 — `app.branding` is the one colour diff --git a/packages/spec/src/ui/page.zod.ts b/packages/spec/src/ui/page.zod.ts index acf3f3babd..2183197394 100644 --- a/packages/spec/src/ui/page.zod.ts +++ b/packages/spec/src/ui/page.zod.ts @@ -145,11 +145,22 @@ export const PageComponentSchema = lazySchema(() => strictObject({ // name (#8199's placement rule). surface: 'this page component', }, { - /** Definition */ + /** + * Definition. + * + * The union's string arm is DELIBERATE — an open namespace for custom and + * registered components (kebab SDUI blocks, plugin console widgets, + * `custom.*` shapes) — but it is not unclaimed: inside the namespaces the + * enum itself populates, the vocabulary is closed at author time by the + * `component-type-unknown` authoring rule, against the claim stated in + * `component-type-vocabulary.ts`. The parse stays open so stored documents + * keep loading; the refusal lands at the authoring doors, where the author + * is still present to fix the string. + */ type: z.union([ PageComponentType, z.string() - ]).describe('Component Type (Standard enum or custom string)'), + ]).describe('Component Type — a standard vocabulary member, or a custom/registered component type in its own namespace (e.g. `object-grid`, `mcp:connect-agent`). The spec\'s own type namespaces are a closed vocabulary at author time: inside them, a type the vocabulary does not declare is refused by `os validate` / `os build` / `os lint` (rule `component-type-unknown`).'), id: z.string().optional().describe('Unique instance ID'), /** Configuration */