Skip to content

Commit 225e769

Browse files
os-trumpclaude
andauthored
feat(spec,lint): author-time rejection for unknown component types in spec-reserved namespaces (#13117)
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. Claude-Session: https://claude.ai/code/session_01LpRNHxWZgSUgVnFT9mQQo4 Co-authored-by: Claude <noreply@anthropic.com>
1 parent d23ebb9 commit 225e769

12 files changed

Lines changed: 569 additions & 4 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/lint': minor
4+
---
5+
6+
Author-time rejection for unknown `PageComponentSchema.type` strings inside the spec's own namespaces — the type-vocabulary half of the "Component Placeholder" gap.
7+
8+
`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.
9+
10+
- `@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.
11+
- `@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.
12+
13+
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.

content/docs/references/ui/page.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -238,7 +238,7 @@ View filter rule
238238

239239
| Property | Type | Required | Description |
240240
| :--- | :--- | :--- | :--- |
241-
| **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) |
241+
| **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`). |
242242
| **id** | `string` | optional | Unique instance ID |
243243
| **label** | `string \| Record<string, string>` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time |
244244
| **properties** | `Record<string, any>` | optional (default: `{}`) | Component props passed to the widget. See component.zod.ts for schemas. |
@@ -337,7 +337,7 @@ View filter rule
337337

338338
| Property | Type | Required | Description |
339339
| :--- | :--- | :--- | :--- |
340-
| **type** | `Enum<'page:header' \| 'page:footer' \| 'page:sidebar' \| 'page:tabs' \| 'page:accordion' \| …> \| string` || Component Type (Standard enum or custom string) |
340+
| **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`). |
341341
| **id** | `string` | optional | Unique instance ID |
342342
| **label** | `string \| Record<string, string>` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time |
343343
| **properties** | `Record<string, any>` | optional (default: `{}`) | Component props passed to the widget. See component.zod.ts for schemas. |

packages/lint/src/authoring-rules.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ import { validatePresetComparands } from './validate-preset-comparands.js';
109109
import { validateEmptyCombinators } from './validate-empty-combinators.js';
110110
import { validateReferenceIntegrity } from './reference-integrity-suite.js';
111111
import { validateComponentProps } from './validate-component-props.js';
112+
import { validateComponentTypes } from './validate-component-types.js';
112113
import { validateResponsiveStyles } from './validate-responsive-styles.js';
113114
import { validateJsxPages } from './validate-jsx-pages.js';
114115
import { validateReactPages } from './validate-react-pages.js';
@@ -731,6 +732,34 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
731732
surfaceReason: RUNTIME_NEEDS_FULL_SNAPSHOT,
732733
run: (stack) => validateComponentProps(stack),
733734
},
735+
// The TYPE half of the same door (#12183's ruling): a component `type` inside
736+
// a spec-reserved namespace must be vocabulary the platform declares, or the
737+
// page ships a placeholder scaffold to the end user. Namespace-shaped on
738+
// purpose — custom/registered types outside the spec's own namespaces are the
739+
// open string arm's declared story and stay untouched (the accept set and its
740+
// ledger live in `component-type-vocabulary.ts`, `@objectstack/spec/ui`).
741+
{
742+
name: 'validateComponentTypes',
743+
tier: 'gating',
744+
input: 'normalized',
745+
commands: ALL,
746+
source: 'packages/lint/src/validate-component-types.ts',
747+
surfaces: CLI_ONLY,
748+
// Page-local judgment, so the per-write snapshot IS sufficient — what the
749+
// crossing owes is not a wider snapshot but the #4716 discipline: a gating
750+
// rule reaches the Studio/REST/MCP door only behind a measured
751+
// false-refusal budget over REAL stored page rows, and the population this
752+
// rule was measured on (the in-repo corpus, 0 findings) is authored
753+
// config-file metadata, not tenant rows. A tenant page carrying a
754+
// reserved-namespace type their own plugin registers would be refused at
755+
// the only door that tenant has. Crossing is its own rollout decision with
756+
// that replay as its evidence, not a bare `runtimeTypes` edit.
757+
surfaceReason:
758+
'Gating rule held off the runtime door pending the #4716 crossing discipline: a measured ' +
759+
'false-refusal budget over stored tenant page rows (the in-repo 0-finding measurement covers ' +
760+
'authored config-file metadata only). Crossing is its own rollout card.',
761+
run: (stack) => validateComponentTypes(stack),
762+
},
734763
// ADR-0065 — a styled node's responsiveStyles must be scopable (needs an
735764
// `id`), name real CSS properties + design tokens, and carry a `large` base.
736765
{

packages/lint/src/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -411,6 +411,12 @@ export {
411411
} from './validate-component-props.js';
412412
export type { ComponentPropsFinding, ComponentPropsSeverity } from './validate-component-props.js';
413413

414+
export {
415+
validateComponentTypes,
416+
COMPONENT_TYPE_UNKNOWN,
417+
} from './validate-component-types.js';
418+
export type { ComponentTypeFinding } from './validate-component-types.js';
419+
414420
export {
415421
validateChartBindings,
416422
CHART_DIMENSION_UNKNOWN,
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The `component-type-unknown` gate (#12950): a component `type` inside a
5+
* spec-reserved namespace must be vocabulary the platform declares. Both
6+
* directions matter equally here — the negative half (what the rule must NOT
7+
* flag) is the measured extension story a union collapse would have broken, so
8+
* each negative case names the face it protects.
9+
*/
10+
import { describe, it, expect } from 'vitest';
11+
import {
12+
validateComponentTypes,
13+
COMPONENT_TYPE_UNKNOWN,
14+
} from './validate-component-types.js';
15+
16+
const page = (components: unknown[], name = 'p1', extra: Record<string, unknown> = {}) => ({
17+
pages: [{ name, regions: [{ name: 'main', components }], ...extra }],
18+
});
19+
20+
describe('refuses undeclared types inside reserved namespaces', () => {
21+
it('flags a typo of an enum member and suggests the declared spelling', () => {
22+
const findings = validateComponentTypes(page([{ type: 'global:serch' }]));
23+
expect(findings).toHaveLength(1);
24+
const f = findings[0];
25+
expect(f.rule).toBe(COMPONENT_TYPE_UNKNOWN);
26+
expect(f.severity).toBe('error');
27+
expect(f.path).toBe('pages[0].regions[0].components[0].type');
28+
expect(f.where).toBe('page "p1" · global:serch');
29+
expect(f.message).toContain('`global:serch`');
30+
expect(f.message).toContain("'global:search'");
31+
expect(f.hint).toContain('global:search');
32+
});
33+
34+
it('flags a typo of a record component', () => {
35+
const findings = validateComponentTypes(page([{ type: 'record:detials' }]));
36+
expect(findings).toHaveLength(1);
37+
expect(findings[0].message).toContain("'record:details'");
38+
});
39+
40+
it('a far-from-anything reserved string gets the own-namespace prescription', () => {
41+
const findings = validateComponentTypes(page([{ type: 'record:zzzz_qqqq_wwww' }]));
42+
expect(findings).toHaveLength(1);
43+
expect(findings[0].hint).toContain('own namespace');
44+
expect(findings[0].hint).toContain('my-plugin:zzzz_qqqq_wwww');
45+
});
46+
47+
it('reaches nested components (tab item children)', () => {
48+
const findings = validateComponentTypes(
49+
page([
50+
{
51+
type: 'page:tabs',
52+
properties: { items: [{ label: 'T', children: [{ type: 'element:txt' }] }] },
53+
},
54+
]),
55+
);
56+
expect(findings).toHaveLength(1);
57+
expect(findings[0].path).toBe(
58+
'pages[0].regions[0].components[0].properties.items[0].children[0].type',
59+
);
60+
expect(findings[0].message).toContain("'element:text'");
61+
});
62+
63+
it('reaches slot-mounted components', () => {
64+
const findings = validateComponentTypes({
65+
pages: [{ name: 'sl', kind: 'record', slots: { header: { type: 'page:headr' } } }],
66+
});
67+
expect(findings).toHaveLength(1);
68+
expect(findings[0].path).toBe('pages[0].slots.header.type');
69+
expect(findings[0].message).toContain("'page:header'");
70+
});
71+
});
72+
73+
describe('leaves the declared vocabulary and the open arm alone', () => {
74+
it.each([
75+
// Enum members — including this card's two kept Phase-2 members.
76+
'global:search',
77+
'global:notifications',
78+
'page:header',
79+
'record:details',
80+
// ComponentPropsMap rows that are NOT enum members: the measured
81+
// string-arm registrations that earned a row.
82+
'element:metadata_viewer',
83+
// Retired at element grain with the row KEPT so the props gate dispatches
84+
// the tombstones — the type stays accepted; the keys refuse (#9220 shape).
85+
'element:filter',
86+
'element:form',
87+
// The string-arm registration ledger (registered in objectui, row-less by
88+
// pinned decision).
89+
'record:line_items',
90+
// Plugin namespaces — the open arm's declared story.
91+
'mcp:connect-agent',
92+
'cloud-connection:panel',
93+
'marketplace:installed-list',
94+
// Colon-free custom/SDUI shapes — ditto.
95+
'flex',
96+
'grid',
97+
'object-chart',
98+
'object-grid',
99+
'page-header',
100+
'custom.widget',
101+
])('accepts %s', (type) => {
102+
expect(validateComponentTypes(page([{ type }]))).toEqual([]);
103+
});
104+
105+
it('yields nothing for source-authored pages (react/jsx/html)', () => {
106+
const findings = validateComponentTypes({
107+
pages: [
108+
{
109+
name: 'r1',
110+
kind: 'react',
111+
source: 'export default () => null',
112+
regions: [{ name: 'main', components: [{ type: 'global:serch' }] }],
113+
},
114+
],
115+
});
116+
expect(findings).toEqual([]);
117+
});
118+
119+
it('tolerates malformed input shapes', () => {
120+
expect(validateComponentTypes({} as never)).toEqual([]);
121+
expect(validateComponentTypes({ pages: 'nope' } as never)).toEqual([]);
122+
expect(validateComponentTypes(page([{ type: 42 }, {}, null]))).toEqual([]);
123+
});
124+
125+
it('walks name-keyed page maps', () => {
126+
const findings = validateComponentTypes({
127+
pages: {
128+
keyed_page: { regions: [{ name: 'main', components: [{ type: 'nav:menue' }] }] },
129+
},
130+
});
131+
expect(findings).toHaveLength(1);
132+
expect(findings[0].where).toBe('page "keyed_page" · nav:menue');
133+
expect(findings[0].message).toContain("'nav:menu'");
134+
});
135+
});
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [ADR-0078] The page-component TYPE gate — the author-time rejection the open
5+
* `type` union never had (#12950, riding the #12183 ruling of 2026-08-26).
6+
*
7+
* ## What was missing
8+
*
9+
* `PageComponentSchema.type` is `z.union([PageComponentType, z.string()])`, so
10+
* an arbitrary string parses. The open arm is deliberate and load-bearing —
11+
* see `component-type-vocabulary.ts` for the measured inventory of what a
12+
* union collapse would break — but it also swallowed the spec's OWN
13+
* namespaces: `global:serch` parsed as happily as `global:search`, every
14+
* authoring command validated it clean, and the console drew the literal
15+
* "Component Placeholder" scaffold in front of an end user. The origin card
16+
* measured that in a real browser: two published pages whose entire content
17+
* was placeholder, with zero diagnostics anywhere on the authoring path.
18+
*
19+
* ## What this rule does
20+
*
21+
* One namespace-shaped judgment per authored component node: a `type` inside a
22+
* namespace the enum itself populates (derived, never restated) must be a type
23+
* the spec answers for — an enum member, a `ComponentPropsMap` row (which
24+
* carries the measured string-arm registrations, including the tombstoned
25+
* `element:filter` / `element:form`), or a `STRING_ARM_REGISTERED_TYPES`
26+
* ledger entry. Anything else is refused with `severity: 'error'` and the
27+
* closest declared spellings.
28+
*
29+
* Types OUTSIDE the reserved namespaces are untouched — plugin widgets
30+
* (`mcp:connect-agent`), kebab SDUI blocks (`flex`, `object-chart`,
31+
* `page-header`), dot shapes (`custom.widget`) all keep the open-arm contract.
32+
* This rule closes nothing the extension story declares open; it closes the
33+
* spec's own vocabulary, which nothing ever declared open — it was merely
34+
* unchecked.
35+
*
36+
* ## Why `error` from birth (contrast #5068's warning-first)
37+
*
38+
* The props gate launched advisory because the live corpus VIOLATED the
39+
* declarations it enforced. This rule's live corpus is clean, measured before
40+
* severity was chosen: across `examples/**` and `packages/**` page sources the
41+
* only reserved-namespace strings outside the accept set are conversion-fixture
42+
* stand-ins (`record:detail`, `record:list`, `element:custom` in
43+
* `conversions/registry.ts` — replayed by the conversion harness, never fed to
44+
* the authoring commands) and the ledgered `record:line_items`. An error gate
45+
* with zero live findings breaks no one and refuses the next `global:serch` at
46+
* the door instead of in front of a user.
47+
*/
48+
49+
import {
50+
hasReservedComponentNamespace,
51+
isKnownComponentType,
52+
KNOWN_COMPONENT_TYPE_CANDIDATES,
53+
} from '@objectstack/spec/ui';
54+
import { findClosestMatches, formatSuggestion } from '@objectstack/spec/shared';
55+
import { walkPageComponents, type AnyRec } from './page-walk.js';
56+
57+
/** A component `type` inside a spec-reserved namespace that the vocabulary does not declare. */
58+
export const COMPONENT_TYPE_UNKNOWN = 'component-type-unknown';
59+
60+
export interface ComponentTypeFinding {
61+
severity: 'error';
62+
/** Diagnostic rule id. */
63+
rule: string;
64+
/** Human-readable location, e.g. `page "app_launcher" · global:serch`. */
65+
where: string;
66+
/** Config path, e.g. `pages[0].regions[1].components[0].type`. */
67+
path: string;
68+
/** What is wrong. */
69+
message: string;
70+
/** How to fix it. */
71+
hint: string;
72+
}
73+
74+
function isRec(v: unknown): v is AnyRec {
75+
return !!v && typeof v === 'object' && !Array.isArray(v);
76+
}
77+
78+
function strName(v: unknown): string | undefined {
79+
return typeof v === 'string' && v.length > 0 ? v : undefined;
80+
}
81+
82+
/** Coerce a collection (array or name-keyed map) to an array of records. */
83+
function asArray(v: unknown): AnyRec[] {
84+
if (Array.isArray(v)) return v as AnyRec[];
85+
if (v && typeof v === 'object') {
86+
return Object.entries(v as AnyRec).map(([name, def]) => ({ name, ...(def as AnyRec) }));
87+
}
88+
return [];
89+
}
90+
91+
export function validateComponentTypes(stack: AnyRec): ComponentTypeFinding[] {
92+
const findings: ComponentTypeFinding[] = [];
93+
if (!isRec(stack)) return findings;
94+
95+
const pages = asArray(stack.pages);
96+
for (let pi = 0; pi < pages.length; pi++) {
97+
const page = pages[pi];
98+
if (!isRec(page)) continue;
99+
const pageName = strName(page.name) ?? `#${pi}`;
100+
101+
for (const { component, path } of walkPageComponents(page, `pages[${pi}]`)) {
102+
const type = strName(component.type);
103+
if (!type) continue;
104+
if (!hasReservedComponentNamespace(type)) continue; // the open arm's half — deliberately untouched
105+
if (isKnownComponentType(type)) continue;
106+
107+
const suggestions = findClosestMatches(type, KNOWN_COMPONENT_TYPE_CANDIDATES);
108+
const suggestion = formatSuggestion(suggestions);
109+
findings.push({
110+
severity: 'error',
111+
rule: COMPONENT_TYPE_UNKNOWN,
112+
where: `page "${pageName}" · ${type}`,
113+
path: `${path}.type`,
114+
message:
115+
`\`${type}\` is not a component type the platform vocabulary declares. Its namespace ` +
116+
`(\`${type.slice(0, type.indexOf(':'))}:\`) belongs to the standard component vocabulary, so nothing ` +
117+
'will ever render this node — the page would validate, publish, and then draw a placeholder ' +
118+
'scaffold in front of the end user.' +
119+
(suggestion ? ` ${suggestion}` : ''),
120+
hint: suggestions.length
121+
? `Rename \`${type}\` → \`${suggestions[0]}\`.`
122+
: `Use a declared component type from the standard vocabulary, or — for a custom component ` +
123+
`registered by your own plugin — give it its own namespace (e.g. \`my-plugin:${type.slice(type.indexOf(':') + 1)}\`) ` +
124+
'so it cannot be mistaken for platform vocabulary.',
125+
});
126+
}
127+
}
128+
129+
return findings;
130+
}

0 commit comments

Comments
 (0)