diff --git a/.changeset/20578-sdui-parser-base-props-one-list.md b/.changeset/20578-sdui-parser-base-props-one-list.md new file mode 100644 index 00000000000..355c637e022 --- /dev/null +++ b/.changeset/20578-sdui-parser-base-props-one-list.md @@ -0,0 +1,10 @@ +--- +'@objectstack/sdui-parser': minor +--- + +`@objectstack/sdui-parser` now reads one base-prop list, ported from objectui's `SDUI_BASE_PROPS` at the console pin `db11afd4967c` (objectui#11008, #11044). Both `validateTree` and the generated JSX types (`generateDts`'s `SduiBaseProps`) are driven by it. + +- On every node, whatever the component declares: `bind`, `hidden`, `visibleWhen`, `hiddenOn`, `testId` are newly accepted. They no longer draw `unknown-prop`, and the generated types accept them as attributes. +- Only on a type whose registration declares no input of that name: `name`, `label`, `description`, `placeholder`, `data`, `ariaLabel` are newly accepted. A type that declares one keeps its declared type check and its declared attribute type; its generated interface `Omit`s that key from `SduiBaseProps`. + +Effect for consumers: `os validate` stops warning `unknown-prop` on those keys, and a `.tsx` page that authors them now type-checks against `generateDts` output where it was a TypeScript error before. Measured on the tracked `sdui.manifest.json` (107 components), no component declares any of the five every-node keys, and every declared where-undeclared key is checked as before, so no diagnostic of error severity is removed for that manifest. The wider type surface is why this is a minor, not a patch. diff --git a/packages/sdui-parser/src/__tests__/base-props-one-list.test.ts b/packages/sdui-parser/src/__tests__/base-props-one-list.test.ts new file mode 100644 index 00000000000..7d02522b107 --- /dev/null +++ b/packages/sdui-parser/src/__tests__/base-props-one-list.test.ts @@ -0,0 +1,84 @@ +/** + * The base-prop list, ported in lockstep from objectui (objectui#11008 added + * `bind` / `hidden`; objectui#11044 made ONE list with an `every-node` and a + * `where-undeclared` scope) at the console pin `db11afd4967c`. + * + * Three facts, kept apart: + * 1. every `every-node` member draws no `unknown-prop`; + * 2. a `where-undeclared` member draws none where the type declares no input + * of that name; + * 3. a `where-undeclared` member the type DECLARES keeps its declared type + * check (the reason it is not `every-node`). + * Measured on the tracked `sdui.manifest.json`: no component declares any of + * the five newly-`every-node` keys, so no declared type check moves. + */ +import { describe, expect, it } from 'vitest'; +import { generateDts, manifestFromConfigs, validateTree } from '../index.js'; +import { SDUI_BASE_PROPS } from '../validate.js'; +import type { Manifest, SchemaElement } from '../types.js'; + +const manifest: Manifest = manifestFromConfigs([ + { type: 'leaf', namespace: 'ui', inputs: [{ name: 'content', type: 'string' }] }, + // `label` declared as a string, the `badge` / `button` shape + { type: 'labelled', namespace: 'ui', inputs: [{ name: 'label', type: 'string' }] }, +]); + +const codes = (node: SchemaElement): string[] => + validateTree(node, manifest).diagnostics.map((d) => d.code); + +const EVERY_NODE = [ + 'id', 'className', 'style', 'visible', 'visibleWhen', 'visibleOn', 'hidden', + 'hiddenOn', 'disabled', 'disabledOn', 'bind', 'testId', 'children', +]; +// `type` is the one `every-node` entry with no attribute form: on a node it +// IS the tag, so a node cannot carry it without being the component under test. +// It is pinned through the list below, not through `validateTree`. +const WHERE_UNDECLARED = ['name', 'label', 'description', 'placeholder', 'data', 'ariaLabel']; + +describe('base props — one list, two scopes (objectui#11008, #11044)', () => { + it.each(EVERY_NODE)('every-node `%s` draws no unknown-prop', (key) => { + const node = { type: 'leaf', [key]: key === 'children' ? [] : 'x' } as SchemaElement; + expect(codes(node)).not.toContain('unknown-prop'); + }); + + it.each(WHERE_UNDECLARED)('undeclared `%s` draws no unknown-prop', (key) => { + expect(codes({ type: 'leaf', [key]: 'x' } as SchemaElement)).toEqual([]); + }); + + it('a DECLARED where-undeclared key keeps its type check', () => { + const diags = validateTree({ type: 'labelled', label: 5 } as SchemaElement, manifest).diagnostics; + expect(diags.map((d) => d.code)).toEqual(['type-mismatch']); + expect(validateTree({ type: 'labelled', label: 'ok' } as SchemaElement, manifest).diagnostics).toEqual([]); + }); + + it('the list is the 20 entries, `type` alone having no attribute type', () => { + expect(SDUI_BASE_PROPS.map((p) => p.name)).toEqual(['type', ...EVERY_NODE, ...WHERE_UNDECLARED]); + expect(SDUI_BASE_PROPS.filter((p) => p.tsType === null).map((p) => p.name)).toEqual(['type']); + expect(SDUI_BASE_PROPS.filter((p) => p.scope === 'every-node').map((p) => p.name)).toEqual(['type', ...EVERY_NODE]); + }); + + it('a non-base key is still unknown', () => { + expect(codes({ type: 'leaf', nonsense: 1 } as SchemaElement)).toEqual(['unknown-prop']); + }); +}); + +describe('generateDts — `SduiBaseProps` is emitted from the same list', () => { + const dts = generateDts(manifest); + const emitted = /export interface SduiBaseProps \{\n([\s\S]*?)\n\}/.exec(dts)?.[1].split('\n') ?? []; + + it('carries every list entry that has an attribute type, in list order, and nothing else', () => { + expect(emitted).toEqual( + SDUI_BASE_PROPS.filter((p) => p.tsType !== null).map((p) => ` ${p.name}?: ${p.tsType};`), + ); + expect(emitted).toContain(' bind?: string;'); + expect(emitted).toContain(' hidden?: boolean;'); + }); + + it('a component extends the whole base when it declares no base attribute', () => { + expect(dts).toContain('export interface LeafProps extends SduiBaseProps {'); + }); + + it('a component that declares a base attribute Omits exactly that one from the base', () => { + expect(dts).toContain('export interface LabelledProps extends Omit {\n label?: string;\n}'); + }); +}); diff --git a/packages/sdui-parser/src/__tests__/html-tier-manifest.test.ts b/packages/sdui-parser/src/__tests__/html-tier-manifest.test.ts index 5992f4bc336..4975f433785 100644 --- a/packages/sdui-parser/src/__tests__/html-tier-manifest.test.ts +++ b/packages/sdui-parser/src/__tests__/html-tier-manifest.test.ts @@ -124,7 +124,7 @@ describe('generateDts publishes the html tier into JSX.IntrinsicElements', () => const dts = generateDts(published); expect(dts).toContain('"p": PProps;'); expect(dts).toContain('"a": AProps;'); - expect(dts).toContain('export interface AProps extends SduiBaseProps {\n className?: string;\n href?: string;\n}'); + expect(dts).toContain('export interface AProps extends Omit {\n className?: string;\n href?: string;\n}'); expect(dts).toContain('"flex": FlexProps;'); }); }); diff --git a/packages/sdui-parser/src/codegen.ts b/packages/sdui-parser/src/codegen.ts index 895d403f5a8..c598ce9a383 100644 --- a/packages/sdui-parser/src/codegen.ts +++ b/packages/sdui-parser/src/codegen.ts @@ -10,6 +10,26 @@ import type { Manifest, ManifestComponent, ManifestInput, ManifestInputType } from './types.js'; import { inputTypeArms } from './input-type.js'; +import { SDUI_BASE_PROPS } from './validate.js'; + +/** + * `SduiBaseProps`, emitted from {@link SDUI_BASE_PROPS} — the list + * `validateTree` reads — so the generated types and the validator cannot + * disagree about a base prop (objectui#11044). ⛔ Never a second hand-kept + * copy of the list here: that is how this interface came to refuse `bind` and + * `hidden` after the validator accepted them. + */ +const emitBaseProps = (): string => + `export interface SduiBaseProps {\n${SDUI_BASE_PROPS.filter((prop) => prop.tsType !== null) + .map((prop) => ` ${prop.name}?: ${prop.tsType};`) + .join('\n')}\n}`; + +/** + * The attributes `SduiBaseProps` carries — every entry of the list but `type`, + * whatever its scope. A declared input of the same name outranks each of them + * in the generated types (objectui#11044, objectui#11075). + */ +const BASE_ATTRIBUTES = new Set(SDUI_BASE_PROPS.filter((prop) => prop.tsType !== null).map((prop) => prop.name)); export interface CodegenOptions { /** include a self-contained minimal JSX namespace so the d.ts type-checks @@ -38,16 +58,7 @@ export function generateDts(manifest: Manifest, options: CodegenOptions = {}): s return `// AUTO-GENERATED by @object-ui/sdui-parser — DO NOT EDIT. // Source of truth: ComponentRegistry inputs (ADR-0080 §3). Regenerate via codegen. -export interface SduiBaseProps { - id?: string; - className?: string; - style?: Record; - visible?: boolean; - visibleOn?: string; - disabled?: boolean; - disabledOn?: string; - children?: unknown; -} +${emitBaseProps()} ${interfaces} @@ -70,13 +81,31 @@ export {}; * those arms only (objectui#3832): the old test was `i.type !== 'slot'`, which a * union like `['slot', 'string']` would have passed while `tsType` fell through * to the default and typed it `string` anyway. + * + * A base attribute the component declares is `Omit`ted from the base it + * extends, whatever its scope: the declared input wins with its type. So a + * declared type the base one does not admit is no TS2430 conflict — neither + * `label?: string | Record` on a `'where-undeclared'` member + * (objectui#11044) nor `record:alert`'s + * `visible?: boolean | string | Record` on an `'every-node'` + * one (objectui#11075) — and one the base admits (`className?: string`) types + * the same either way. + * + * The scope is the VALIDATOR's distinction — `validateTree` skips an + * `'every-node'` key before the declared-input lookup — and does not reach this + * surface. ⛔ Every declared base attribute is `Omit`ted, not only the ones + * whose types conflict: assignability is TypeScript's judgement, and a + * conflicts-only rule here would re-derive it from type strings. + * + * A slot-only input emits no attribute, so a declared `children: 'slot'` is + * not `Omit`ted and the base `children` stays. */ function emitInterface(comp: ManifestComponent): string { - const lines = comp.inputs - .filter((i) => valueArms(i).length > 0) - .map((i) => ` ${propLine(i)}`) - .join('\n'); - return `export interface ${propsName(comp.type)} extends SduiBaseProps {\n${lines}\n}`; + const emitted = comp.inputs.filter((i) => valueArms(i).length > 0); + const lines = emitted.map((i) => ` ${propLine(i)}`).join('\n'); + const yielded = emitted.filter((i) => BASE_ATTRIBUTES.has(i.name)).map((i) => JSON.stringify(i.name)); + const base = yielded.length > 0 ? `Omit` : 'SduiBaseProps'; + return `export interface ${propsName(comp.type)} extends ${base} {\n${lines}\n}`; } /** The arms that describe a VALUE (every arm except `'slot'`). */ diff --git a/packages/sdui-parser/src/validate.ts b/packages/sdui-parser/src/validate.ts index 819b7b6a83a..55b456ca8b8 100644 --- a/packages/sdui-parser/src/validate.ts +++ b/packages/sdui-parser/src/validate.ts @@ -60,24 +60,122 @@ export function acceptsChildren(comp: Pick): boolea } /** - * Base props every node may carry (mirrors BaseSchema) — never "unknown prop". + * Where a base prop is legal without a declaration (objectui#11044). + * + * - `'every-node'` — on every node, and a registration's own input of the + * same name is not consulted: {@link validateTree} skips the key before the + * declared-input lookup. + * - `'where-undeclared'` — on a type whose registration declares NO input of + * that name. Where one does, the declared input wins, its `type-mismatch` + * check included, and the generated JSX types take the declared type too. + */ +export type SduiBasePropScope = 'every-node' | 'where-undeclared'; + +/** One entry of {@link SDUI_BASE_PROPS}. */ +export interface SduiBaseProp { + /** The `BaseSchema` member (`@object-ui/types`). */ + readonly name: string; + readonly scope: SduiBasePropScope; + /** + * The attribute's type in the generated JSX surface (`SduiBaseProps` in + * `sdui-intrinsics.d.ts`), or `null` for the one key that is no attribute: + * `type`, which the tag name carries (`parse.ts` refuses a `type` attribute). + */ + readonly tsType: string | null; +} + +/** + * The base props: the `BaseSchema` members this tier accepts on a node without + * a registration declaring them. ONE list, read by BOTH of its consumers — + * {@link validateTree}'s `unknown-prop` branch and `generateDts`'s + * `SduiBaseProps` (objectui#11044). Before this list the two were separate + * hand-kept copies and had drifted: the validator accepted `bind` and `hidden` + * (objectui#11008) while the generated types still refused both. + * + * `bind` and `hidden` are `'every-node'` (objectui#11008). `BaseSchema` declares + * both for every node and no registration declares either as an input, so + * before they joined, the undeclared-key branch below answered every authored + * one with `unknown-prop` — "has no prop" about a key the protocol declares, + * on the nodes that honour it: `hidden` is read for every node by + * `SchemaRenderer`'s hide chain, and `bind` by every renderer that calls + * `useDataScope` (`list`, `tree-view`, the `object-*` widgets). The + * declaration outranks the implementation, so the parser's view is the + * declared type's, not a per-registration subset. The cost is accepted and + * named: a `bind` on a node that does not read it — `data-table` + * (objectui#6575) — draws nothing here either, and its render-time console + * warning is the one signal left. + * + * `visibleWhen`, `hiddenOn` and `testId` are `'every-node'` for the same reason + * (objectui#11044): no registration declares any of them, `SchemaRenderer`'s + * hide chain reads the first two for every node, and it strips `testId` and + * re-emits it as `data-testid`. `visibleWhen` is the canonical ADR-0089 + * predicate; before it joined, the deprecated `visibleOn` was silent while it + * drew `unknown-prop`. + * + * The `'where-undeclared'` members (objectui#11044, triage ruling) are the + * `BaseSchema` members some registrations DECLARE as typed inputs — the input + * family's `placeholder`, `label`, `name`, … . Skipping them the way the + * `'every-node'` members are skipped would silence those registrations' + * `type-mismatch`, so they are base props only where the type declares no + * input of that name. ⛔ Never move one to `'every-node'` to accept a key: that + * silences a declared type check. + * + * Held over the live registry by `base-props-one-list-11044.test.tsx` in + * `@object-ui/components` — every member a `BaseSchema` member, and `body` the + * one member left out. + * + * ⛔ `body` is NOT here and must not be added. It was `BaseSchema`'s second + * child-list spelling until objectui#6771 retired it; teaching this list the + * key was the option that ruling refused, because it would have blessed a + * second permanent spelling of one concept. `./body-dialect.ts` answers it by + * name instead. + * + * LOCKSTEP: the list, its scopes and the two Sets below are the port of + * objectui's `SDUI_BASE_PROPS` (objectui#11008, #11044, pin `db11afd4967c`). + * Both consumers are ported too: {@link validateTree} and `codegen.ts`'s + * `SduiBaseProps`. The list is exported for `codegen.ts` only; `index.ts` does + * not re-export it. * * `children` IS here: the key is legal on every node, so it never draws * `unknown-prop` and its declared `slot` input is never type-checked. Whether * a given component RENDERS it is the containment question below, answered by * {@link acceptsChildren} from the declared input. */ -const BASE_PROPS = new Set([ - 'type', - 'id', - 'className', - 'style', - 'visible', - 'visibleOn', - 'disabled', - 'disabledOn', - CHILD_LIST_KEY, -]); +export const SDUI_BASE_PROPS: readonly SduiBaseProp[] = Object.freeze([ + { name: 'type', scope: 'every-node', tsType: null }, + { name: 'id', scope: 'every-node', tsType: 'string' }, + { name: 'className', scope: 'every-node', tsType: 'string' }, + { name: 'style', scope: 'every-node', tsType: 'Record' }, + { name: 'visible', scope: 'every-node', tsType: 'boolean' }, + { name: 'visibleWhen', scope: 'every-node', tsType: 'string' }, + { name: 'visibleOn', scope: 'every-node', tsType: 'string' }, + { name: 'hidden', scope: 'every-node', tsType: 'boolean' }, + { name: 'hiddenOn', scope: 'every-node', tsType: 'string' }, + { name: 'disabled', scope: 'every-node', tsType: 'boolean' }, + { name: 'disabledOn', scope: 'every-node', tsType: 'string' }, + { name: 'bind', scope: 'every-node', tsType: 'string' }, + { name: 'testId', scope: 'every-node', tsType: 'string' }, + { name: CHILD_LIST_KEY, scope: 'every-node', tsType: 'unknown' }, + { name: 'name', scope: 'where-undeclared', tsType: 'string' }, + { name: 'label', scope: 'where-undeclared', tsType: 'string | Record' }, + { name: 'description', scope: 'where-undeclared', tsType: 'string | Record' }, + { name: 'placeholder', scope: 'where-undeclared', tsType: 'string' }, + { name: 'data', scope: 'where-undeclared', tsType: 'unknown' }, + { + name: 'ariaLabel', + scope: 'where-undeclared', + tsType: 'string | { key: string; defaultValue?: string; params?: Record }', + }, +] satisfies SduiBaseProp[]); + +const basePropNames = (scope: SduiBasePropScope): Set => + new Set(SDUI_BASE_PROPS.filter((prop) => prop.scope === scope).map((prop) => prop.name)); + +/** The `'every-node'` members of {@link SDUI_BASE_PROPS}: never "unknown prop". */ +const BASE_PROPS = basePropNames('every-node'); + +/** The `'where-undeclared'` members of {@link SDUI_BASE_PROPS}. */ +const WHERE_UNDECLARED_BASE_PROPS = basePropNames('where-undeclared'); const isExpr = (v: unknown): boolean => typeof v === 'object' && v !== null && '$expr' in (v as Record); @@ -116,6 +214,10 @@ export function validateTree(tree: SchemaElement | null, manifest: Manifest): Va // each provided prop for (const [key, value] of Object.entries(node)) { if (BASE_PROPS.has(key)) continue; + // A declaration outranks a `'where-undeclared'` base prop + // (objectui#11044): skipped only when this type declares no input of + // that name, so a declared one keeps its type check below. + if (WHERE_UNDECLARED_BASE_PROPS.has(key) && !byName.has(key)) continue; // The `object-kanban` Quick Add pair (objectui#8285): a key // `@objectstack/spec` still publishes and the renderer cannot honour, // because the control is gated on a RUNTIME SLOT no parsed page can