Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/page-component-type-vocabulary-gate.md
Original file line numberDiff line numberDiff line change
@@ -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.
4 changes: 2 additions & 2 deletions content/docs/references/ui/page.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<string, string>` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time |
| **properties** | `Record<string, any>` | optional (default: `{}`) | Component props passed to the widget. See component.zod.ts for schemas. |
Expand DownExpand Up@@ -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<string, string>` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time |
| **properties** | `Record<string, any>` | optional (default: `{}`) | Component props passed to the widget. See component.zod.ts for schemas. |
Expand Down
29 changes: 29 additions & 0 deletions packages/lint/src/authoring-rules.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';
Expand DownExpand Up@@ -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.
{
Expand Down
6 changes: 6 additions & 0 deletions packages/lint/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand Down
135 changes: 135 additions & 0 deletions packages/lint/src/validate-component-types.test.ts
Original file line numberDiff line numberDiff line change
@@ -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<string, unknown> = {}) => ({
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'");
});
});
130 changes: 130 additions & 0 deletions packages/lint/src/validate-component-types.ts
Original file line numberDiff line numberDiff line change
@@ -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;
}
Loading
Loading