From d8eb79c870c3bda8154d2b27df3c5bf82fd52bef Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 03:39:26 +0000 Subject: [PATCH 1/2] fix(sdui-parser): port the union-arm coarse type check in lockstep with objectui Port objectui#3832 into this hoisted copy: checkType now checks every arm a manifest input declares (inputTypeArms), clears the prop when any arm accepts, and reports ONE type-mismatch naming every arm when none does, at error severity when an enum arm is present. Single-arm inputs keep byte-identical diagnostics. codegen and manifestFromConfigs read the union form through the same input-type.ts module. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PfaSTikked61BkcsB5Rn69 --- .../__tests__/union-arm-type-mismatch.test.ts | 221 ++++++++++++++++++ packages/sdui-parser/src/codegen.ts | 45 +++- packages/sdui-parser/src/index.ts | 30 +-- packages/sdui-parser/src/input-type.ts | 75 ++++++ packages/sdui-parser/src/types.ts | 15 +- packages/sdui-parser/src/validate.ts | 123 +++++++--- 6 files changed, 457 insertions(+), 52 deletions(-) create mode 100644 packages/sdui-parser/src/__tests__/union-arm-type-mismatch.test.ts create mode 100644 packages/sdui-parser/src/input-type.ts diff --git a/packages/sdui-parser/src/__tests__/union-arm-type-mismatch.test.ts b/packages/sdui-parser/src/__tests__/union-arm-type-mismatch.test.ts new file mode 100644 index 0000000000..884c2c52ef --- /dev/null +++ b/packages/sdui-parser/src/__tests__/union-arm-type-mismatch.test.ts @@ -0,0 +1,221 @@ +/** + * Union-arm coarse type checking — the objectui#3832 ruling, ported into this + * copy in lockstep (objectstack#12814, measured on objectstack#12810). + * + * `ManifestInput.type` carries ONE coarse kind, or an ARRAY of kinds when the + * key's contract is a union. Before this port, this copy's `checkType` was the + * older single-arm `switch (input.type)`: a union-typed input fell through + * `default: return null` and drew NO diagnostic at all — the exact + * "reports nothing, which looks like it validated cleanly" failure the + * `input-type.ts` header names. objectui's copy has checked every arm since + * objectui#3832 landed there, so the same authored page produced diagnostics + * on one surface and silence on the other — the dialect split the #12719 + * invariant forbids (both copies agree on the accepted grammar AND on + * diagnostic codes). + * + * WHY THESE PINS EXIST HERE. Two copies of this parser exist — objectui's + * `packages/sdui-parser` and this hoisted one. These pins are the objectstack + * half of the lockstep for the coarse type check; the ported functions are + * byte-equal to objectui's. The properties pinned below are objectui#3832's + * deliberate ones: + * + * - ANY declared arm accepting the value clears the prop; + * - when NO arm accepts, a multi-arm input draws ONE `type-mismatch` naming + * every arm, at the STRICTEST arm's severity — `error` when an `enum` arm + * is present, `warning` otherwise; + * - a single-arm input produces the byte-identical diagnostic it always did, + * `invalid-enum` included — the port adds a form, it does not restate the + * old one. + */ +import { describe, expect, it } from 'vitest'; +import { compile, generateDts, manifestFromConfigs } from '../index.js'; +import { validateTree } from '../validate.js'; +import type { Manifest } from '../types.js'; + +// A manifest with union-typed inputs, written directly (not through +// `manifestFromConfigs`) because production manifests arrive as JSON — +// `sdui.manifest.json` is serialized on the objectui side, where unions +// already exist. The adapter's own union handling is pinned separately below. +const manifest: Manifest = { + components: { + 'stat-card': { + type: 'stat-card', + namespace: 'ui', + inputs: [ + // a real union: string | number (e.g. a formatted or raw metric) + { name: 'value', type: ['string', 'number'] }, + // enum arm + object arm: a named preset or an inline definition + { + name: 'variant', + type: ['enum', 'object'], + enum: ['compact', { value: 'detailed', label: 'Detailed' }], + }, + // a slot arm in a union accepts anything (a slot names a child + // position, not a value) + { name: 'footer', type: ['slot', 'string'] }, + // single arms, unchanged by the port + { name: 'label', type: 'string' }, + { name: 'align', type: 'enum', enum: ['left', 'right'] }, + ], + }, + }, +}; + +describe('union-arm type-mismatch: any arm accepting clears the prop', () => { + it('a value accepted by the FIRST arm draws nothing', () => { + const r = compile(``, manifest); + expect(r.diagnostics).toEqual([]); + expect(r.ok).toBe(true); + }); + + it('a value accepted only by the SECOND arm draws nothing — the union widens, it does not pick one arm', () => { + const r = compile(``, manifest); + expect(r.diagnostics).toEqual([]); + expect(r.ok).toBe(true); + }); + + it('an enum arm clears its listed values; the object arm clears an inline object', () => { + expect(compile(``, manifest).diagnostics).toEqual([]); + expect(compile(``, manifest).diagnostics).toEqual([]); + expect( + compile(``, manifest).diagnostics, + ).toEqual([]); + }); + + it('a slot arm accepts everything — those inputs never drew a diagnostic and must not start now', () => { + expect(compile(``, manifest).diagnostics).toEqual([]); + }); +}); + +describe('union-arm type-mismatch: no arm accepting draws ONE diagnostic naming every arm', () => { + // Before this port, BOTH cases below compiled with zero diagnostics: the + // union fell through the single-arm switch's `default: return null`. That + // silence is the drift this file closes — do not restore it. + it('non-enum union → ONE warning-severity `type-mismatch` naming both arms', () => { + const r = compile(``, manifest); + expect(r.diagnostics).toEqual([ + { + severity: 'warning', + code: 'type-mismatch', + message: ' prop "value" expected a string or a number', + tag: 'stat-card', + }, + ]); + // warning does not move the save gate's pass/fail + expect(r.ok).toBe(true); + }); + + it('enum arm present → the ONE diagnostic is ERROR severity, code `type-mismatch` (not `invalid-enum`), and carries the allowed values', () => { + const r = compile(``, manifest); + expect(r.diagnostics).toEqual([ + { + severity: 'error', + code: 'type-mismatch', + message: ' prop "variant" expected one of ["compact","detailed"] or an object', + tag: 'stat-card', + }, + ]); + // the strictest arm's severity gates the save + expect(r.ok).toBe(false); + }); +}); + +describe('single-arm inputs are byte-identical to the pre-port diagnostics', () => { + it('single string arm → the same warning `type-mismatch` as always', () => { + const r = compile(``, manifest); + expect(r.diagnostics).toEqual([ + { + severity: 'warning', + code: 'type-mismatch', + message: ' prop "label" expected a string', + tag: 'stat-card', + }, + ]); + }); + + it('single enum arm → still `invalid-enum` at error severity, same message shape', () => { + const r = compile(``, manifest); + expect(r.diagnostics).toEqual([ + { + severity: 'error', + code: 'invalid-enum', + message: ' prop "align"="center" is not one of ["left","right"]', + tag: 'stat-card', + }, + ]); + }); + + it('an off-vocabulary arm accepts everything (the old `default: return null`, preserved)', () => { + const loose: Manifest = { + components: { + x: { + type: 'x', + inputs: [{ name: 'p', type: 'mystery' as never }], + }, + }, + }; + expect(validateTree({ type: 'x', p: 42 }, loose).diagnostics).toEqual([]); + }); +}); + +describe('manifestFromConfigs canonicalizes union declarations (input-type.ts)', () => { + const built = manifestFromConfigs([ + { + type: 'w', + inputs: [ + { name: 'union', type: ['string', 'number'] }, + { name: 'oneArm', type: ['number'] }, + { name: 'dropped', type: ['number', 'mystery'] }, + { name: 'emptied', type: ['mystery'] }, + { name: 'coerced', type: 'mystery' }, + { name: 'deduped', type: ['string', 'string', 'number'] }, + ], + }, + ]); + const types = Object.fromEntries(built.components.w.inputs.map((i) => [i.name, i.type])); + + it('a real union survives as an array', () => { + expect(types.union).toEqual(['string', 'number']); + }); + + it('a one-element array collapses to the bare string — already-published entries serialize byte-identically', () => { + expect(types.oneArm).toBe('number'); + }); + + it('an unrecognized arm INSIDE an array is dropped, not coerced — no invented widening', () => { + expect(types.dropped).toBe('number'); + }); + + it('dropping every arm falls back to the single-arm coercion', () => { + expect(types.emptied).toBe('string'); + }); + + it('a single unrecognized kind still coerces to string (pre-union behaviour, kept exactly)', () => { + expect(types.coerced).toBe('string'); + }); + + it('duplicate arms are deduplicated', () => { + expect(types.deduped).toEqual(['string', 'number']); + }); +}); + +describe('generateDts emits a TS union for a union declaration', () => { + it('the .d.ts accepts exactly the arms the manifest gate accepts', () => { + const dts = generateDts(manifest); + expect(dts).toContain('value?: string | number;'); + }); + + it('arms collapsing to the same TS type are de-duplicated', () => { + const m: Manifest = { + components: { + y: { type: 'y', inputs: [{ name: 'tone', type: ['string', 'color'] }] }, + }, + }; + expect(generateDts(m)).toContain('tone?: string;'); + }); + + it('a slot+value union is typed from its non-slot arms (the old `!== slot` test would have passed the array through)', () => { + const dts = generateDts(manifest); + expect(dts).toContain('footer?: string;'); + }); +}); diff --git a/packages/sdui-parser/src/codegen.ts b/packages/sdui-parser/src/codegen.ts index 571715aeaa..19bf92694b 100644 --- a/packages/sdui-parser/src/codegen.ts +++ b/packages/sdui-parser/src/codegen.ts @@ -8,7 +8,8 @@ * fulfills it via the registry at render time. */ -import type { Manifest, ManifestComponent, ManifestInput } from './types.js'; +import type { Manifest, ManifestComponent, ManifestInput, ManifestInputType } from './types.js'; +import { inputTypeArms } from './input-type.js'; export interface CodegenOptions { /** include a self-contained minimal JSX namespace so the d.ts type-checks @@ -63,21 +64,39 @@ export {}; `; } +/** + * A `'slot'` arm names a child position, not a prop value — `SduiBaseProps` + * already types `children`, so a slot input contributes no attribute. An input + * is therefore emitted when it has at least one NON-slot arm, and typed from + * 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. + */ function emitInterface(comp: ManifestComponent): string { const lines = comp.inputs - .filter((i) => i.type !== 'slot') + .filter((i) => valueArms(i).length > 0) .map((i) => ` ${propLine(i)}`) .join('\n'); return `export interface ${propsName(comp.type)} extends SduiBaseProps {\n${lines}\n}`; } +/** The arms that describe a VALUE (every arm except `'slot'`). */ +const valueArms = (input: ManifestInput): ManifestInputType[] => + inputTypeArms(input.type).filter((arm) => arm !== 'slot'); + function propLine(input: ManifestInput): string { const opt = input.required ? '' : '?'; return `${quoteKeyIfNeeded(input.name)}${opt}: ${tsType(input)};`; } -function tsType(input: ManifestInput): string { - switch (input.type) { +/** + * The TypeScript type for one arm. Kinds that are a string with a narrower + * authoring control (`color`, `date`, `code`, `file`) are `string` here, as + * before — the JSX surface types the VALUE, and the control kind is the + * designer's business. + */ +function armTsType(arm: ManifestInputType, input: ManifestInput): string { + switch (arm) { case 'number': return 'number'; case 'boolean': @@ -90,16 +109,24 @@ function tsType(input: ManifestInput): string { const vals = (input.enum ?? []).map((e) => (typeof e === 'object' ? e.value : e)); return vals.length ? vals.map((v) => JSON.stringify(v)).join(' | ') : 'string'; } - case 'string': - case 'color': - case 'date': - case 'code': - case 'file': default: return 'string'; } } +/** + * A union declaration emits a TypeScript union, so the `.d.ts` an author + * type-checks their page against accepts exactly the arms the manifest gate + * accepts. Arms that collapse to the same TS type (`string` and `color`, say) + * are de-duplicated rather than emitted as `string | string`. + */ +function tsType(input: ManifestInput): string { + const arms = valueArms(input); + if (arms.length === 0) return 'string'; + const emitted = [...new Set(arms.map((arm) => armTsType(arm, input)))]; + return emitted.join(' | '); +} + const IDENT = /^[A-Za-z_$][A-Za-z0-9_$]*$/; const quoteKeyIfNeeded = (name: string): string => (IDENT.test(name) ? name : JSON.stringify(name)); diff --git a/packages/sdui-parser/src/index.ts b/packages/sdui-parser/src/index.ts index 256d7c7cd4..a0126374a3 100644 --- a/packages/sdui-parser/src/index.ts +++ b/packages/sdui-parser/src/index.ts @@ -11,9 +11,11 @@ export { parseJsx, interpretBrace } from './parse.js'; export { validateTree } from './validate.js'; export { generateDts, propsName, generateBlockList } from './codegen.js'; export type { CodegenOptions } from './codegen.js'; +export { inputTypeArms, canonicalizeInputType, MANIFEST_INPUT_TYPES } from './input-type.js'; import { parseJsx } from './parse.js'; import { validateTree } from './validate.js'; +import { canonicalizeInputType } from './input-type.js'; import type { Diagnostic, Manifest, SchemaElement, ValidationResult } from './types.js'; export interface CompileResult { @@ -60,7 +62,14 @@ export interface RegistryConfigLike { category?: string; inputs?: Array<{ name: string; - type: string; + /** + * One coarse kind, or the arms of a union (objectui#3832). Typed loosely + * (`string`) on purpose — this interface is the STRUCTURAL boundary that + * keeps this package free of a dependency on the registry, so an + * off-vocabulary value has to be representable here and is normalized by + * `canonicalizeInputType` on the way in. + */ + type: string | string[]; required?: boolean; enum?: Array; binding?: 'object' | 'field'; @@ -68,19 +77,10 @@ export interface RegistryConfigLike { }>; } -const INPUT_TYPES = new Set([ - 'string', - 'number', - 'boolean', - 'enum', - 'array', - 'object', - 'color', - 'date', - 'code', - 'file', - 'slot', -]); +/* The arm vocabulary and the two projections over it now live in + * `input-type.ts` — `manifestFromConfigs` below, `validateTree` and the codegen + * all read `ManifestInput.type` and must agree on how, since it holds one arm + * or an array of them (objectui#3832). */ export function manifestFromConfigs( configs: RegistryConfigLike[], @@ -96,7 +96,7 @@ export function manifestFromConfigs( isContainer: c.isContainer, inputs: (c.inputs ?? []).map((i) => ({ name: i.name, - type: (INPUT_TYPES.has(i.type) ? i.type : 'string') as Manifest['components'][string]['inputs'][number]['type'], + type: canonicalizeInputType(i.type), required: i.required, enum: i.enum, binding: i.binding, diff --git a/packages/sdui-parser/src/input-type.ts b/packages/sdui-parser/src/input-type.ts new file mode 100644 index 0000000000..abd1c44e81 --- /dev/null +++ b/packages/sdui-parser/src/input-type.ts @@ -0,0 +1,75 @@ +/** + * ObjectUI — the arms of a manifest input's coarse type (objectui#3832) + * + * `ManifestInput.type` carries ONE coarse kind, or an ARRAY of kinds when the + * key's contract is a union. Every reader of that field needs the same two + * decisions made the same way — how to see the arms, and which single form to + * publish — so they live here once instead of at each call site. A reader that + * forgets is not loud: `switch (input.type)` handed an array falls through to + * the default branch and reports NOTHING, which looks exactly like a value that + * validated cleanly. + */ + +import type { ManifestInput, ManifestInputType } from './types.js'; + +/** The eleven coarse kinds, as a runtime set for guarding untyped input. */ +export const MANIFEST_INPUT_TYPES: ReadonlySet = new Set([ + 'string', + 'number', + 'boolean', + 'enum', + 'array', + 'object', + 'color', + 'date', + 'code', + 'file', + 'slot', +]); + +/** + * The declared arms of an input's coarse type, always as an array. + * + * Exported (not merely internal) so a third-party manifest consumer — a + * designer panel, a codegen, a validator of its own — reads the arms through + * the same accessor this package's own gate does, rather than re-deriving the + * `Array.isArray` branch and getting it subtly wrong on the union form. + */ +export function inputTypeArms( + type: ManifestInput['type'] | undefined, +): ManifestInputType[] { + if (type === undefined) return []; + return Array.isArray(type) ? type : [type]; +} + +/** + * Project a DECLARED type (which may come from an untyped registry config) into + * the canonical published form: a bare string for one arm, an array for a real + * union. + * + * Two deliberate asymmetries between the single and array forms: + * + * - A single unrecognized kind still becomes `'string'`. That coercion predates + * the union work and is kept exactly: with one arm there is no other + * information to fall back on, and a manifest whose `type` is off-vocabulary + * would make every consumer's switch silently inert. + * - An unrecognized arm INSIDE an array is DROPPED, not coerced. Promoting it + * to `'string'` would publish an arm the author never declared — a widening + * invented by the serializer — and the surviving arms already carry the + * declaration. If dropping empties the array, the single-arm fallback applies + * so the output is always a valid manifest. + * + * Collapsing one arm to the bare string is what keeps this a backward-compatible + * extension of `sdui.manifest.json`: every input declared today serializes to + * the byte-identical entry it does now, and arrays appear only where a union was + * really declared. + */ +export function canonicalizeInputType(type: unknown): ManifestInputType | ManifestInputType[] { + const arms = (Array.isArray(type) ? type : [type]).filter( + (arm): arm is ManifestInputType => typeof arm === 'string' && MANIFEST_INPUT_TYPES.has(arm), + ); + const distinct = [...new Set(arms)]; + if (distinct.length === 0) return 'string'; + if (distinct.length === 1) return distinct[0]; + return distinct; +} diff --git a/packages/sdui-parser/src/types.ts b/packages/sdui-parser/src/types.ts index ace9766471..4ad6839b17 100644 --- a/packages/sdui-parser/src/types.ts +++ b/packages/sdui-parser/src/types.ts @@ -63,7 +63,20 @@ export type ManifestInputType = export interface ManifestInput { name: string; - type: ManifestInputType; + /** + * The input's coarse type: ONE kind, or an ARRAY of kinds when the key's + * contract is a union (objectui#3832). + * + * A value passes {@link validateTree}'s coarse check when ANY arm accepts it, + * and is reported when none does — the array widens what is legal, it does + * not switch the check off. + * + * The single-kind form is unchanged and stays the canonical spelling for a + * one-arm key: `manifestFromConfigs` collapses a one-element array back to + * the bare string, so a manifest gains arrays only where a union was really + * declared and every already-published entry serializes byte-identically. + */ + type: ManifestInputType | ManifestInputType[]; required?: boolean; /** allowed values for `enum` inputs */ enum?: Array; diff --git a/packages/sdui-parser/src/validate.ts b/packages/sdui-parser/src/validate.ts index fd17e94633..8563adb4db 100644 --- a/packages/sdui-parser/src/validate.ts +++ b/packages/sdui-parser/src/validate.ts @@ -11,10 +11,12 @@ import type { Diagnostic, Manifest, ManifestInput, + ManifestInputType, SchemaElement, SchemaNode, ValidationResult, } from './types.js'; +import { inputTypeArms } from './input-type.js'; /** Base props every node may carry (mirrors BaseSchema) — never "unknown prop". */ const BASE_PROPS = new Set([ @@ -134,42 +136,109 @@ export function validateTree(tree: SchemaElement | null, manifest: Manifest): Va return { diagnostics, requires: [...requires], bindings }; } -function checkType(tag: string, input: ManifestInput, value: unknown): Diagnostic | null { - const mismatch = (expected: string): Diagnostic => ({ - severity: 'warning', - code: 'type-mismatch', - message: `<${tag}> prop "${input.name}" expected ${expected}`, - tag, - }); - switch (input.type) { +/* LOCKSTEP: everything below this line is the byte-equal port of objectui's + * `packages/sdui-parser` coarse type check (objectui#3832 — union-typed inputs + * are checked over their arms). The two copies must agree on the accepted + * grammar AND on diagnostic codes/severities — if they drift, the save gate + * and the renderer speak different dialects. Change these functions only + * together with the objectui copy. */ + +/** The values an `enum` arm admits, flattened from either declaration form. */ +const enumValues = (input: ManifestInput): unknown[] => + (input.enum ?? []).map((e) => (typeof e === 'object' ? e.value : e)); + +/** + * Does ONE coarse arm accept this value? + * + * An arm outside the vocabulary — and `'slot'`, which describes a child + * position rather than a value — accepts everything, preserving the old + * `default: return null` branch: those inputs never drew a diagnostic and must + * not start now. + */ +function armAccepts(arm: ManifestInputType, input: ManifestInput, value: unknown): boolean { + switch (arm) { case 'number': - return typeof value === 'number' ? null : mismatch('a number'); + return typeof value === 'number'; case 'boolean': - return typeof value === 'boolean' ? null : mismatch('a boolean'); + return typeof value === 'boolean'; case 'string': case 'color': case 'date': case 'code': case 'file': - return typeof value === 'string' ? null : mismatch('a string'); + return typeof value === 'string'; case 'array': - return Array.isArray(value) ? null : mismatch('an array'); + return Array.isArray(value); case 'object': - return typeof value === 'object' && value !== null && !Array.isArray(value) - ? null - : mismatch('an object'); - case 'enum': { - const allowed = (input.enum ?? []).map((e) => (typeof e === 'object' ? e.value : e)); - return allowed.includes(value as never) - ? null - : { - severity: 'error', - code: 'invalid-enum', - message: `<${tag}> prop "${input.name}"=${JSON.stringify(value)} is not one of ${JSON.stringify(allowed)}`, - tag, - }; - } + return typeof value === 'object' && value !== null && !Array.isArray(value); + case 'enum': + return enumValues(input).includes(value as never); + default: + return true; + } +} + +/** How one arm is named in a diagnostic message. */ +function armExpectation(arm: ManifestInputType, input: ManifestInput): string { + switch (arm) { + case 'number': + return 'a number'; + case 'boolean': + return 'a boolean'; + case 'array': + return 'an array'; + case 'object': + return 'an object'; + case 'enum': + return `one of ${JSON.stringify(enumValues(input))}`; default: - return null; + return 'a string'; + } +} + +/** + * Coarse type check, over the arms an input declares (objectui#3832). + * + * ANY arm accepting the value clears the prop — that is what lets a key whose + * contract is a union (`string | number`, or a string plus an inline + * translation map) be declared honestly instead of picking one arm and having + * this function report the other arm's legal values. + * + * When NO arm accepts it the prop is still reported; a union widens what counts + * as legal, it does not turn the check off. Two properties of the reporting are + * deliberate: + * + * - A single-arm input produces the byte-identical diagnostic it always did, + * `invalid-enum` included. This change adds a form; it does not restate the + * old one. + * - A multi-arm input produces ONE diagnostic naming every arm, at the + * STRICTEST arm's severity — `error` when an `enum` arm is present, because + * an enum's closed list is the one fact this layer can be certain about, and + * a value outside it should not become dismissible merely because a second + * arm was added next to it. Its code is `type-mismatch` (not `invalid-enum`) + * since the reported fact is "fits none of the declared arms", and the + * message carries the allowed values so the author still sees the list. + */ +function checkType(tag: string, input: ManifestInput, value: unknown): Diagnostic | null { + const arms = inputTypeArms(input.type); + if (arms.length === 0) return null; + if (arms.some((arm) => armAccepts(arm, input, value))) return null; + + if (arms.length === 1 && arms[0] === 'enum') { + return { + severity: 'error', + code: 'invalid-enum', + message: `<${tag}> prop "${input.name}"=${JSON.stringify(value)} is not one of ${JSON.stringify(enumValues(input))}`, + tag, + }; } + + return { + severity: arms.includes('enum') ? 'error' : 'warning', + code: 'type-mismatch', + message: `<${tag}> prop "${input.name}" expected ${arms + .map((arm) => armExpectation(arm, input)) + .join(' or ')}`, + tag, + }; } From 6c694015e2c9f86ccd411e33d00fe24a11a1cf15 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 03:52:13 +0000 Subject: [PATCH 2/2] docs(changeset): union-arm coarse type check for @objectstack/sdui-parser Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PfaSTikked61BkcsB5Rn69 --- .../sdui-parser-union-arm-type-mismatch.md | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 .changeset/sdui-parser-union-arm-type-mismatch.md diff --git a/.changeset/sdui-parser-union-arm-type-mismatch.md b/.changeset/sdui-parser-union-arm-type-mismatch.md new file mode 100644 index 0000000000..0a9ab339a1 --- /dev/null +++ b/.changeset/sdui-parser-union-arm-type-mismatch.md @@ -0,0 +1,36 @@ +--- +'@objectstack/sdui-parser': minor +--- + +html tier: a union-typed manifest input is now coarse-type-checked over every declared arm instead of drawing no diagnostic at all + +`ManifestInput.type` now carries ONE coarse kind, or an ARRAY of kinds when the +key's contract is a union (objectui#3832). Before this change, this copy's +`checkType` was the older single-arm `switch (input.type)`: a manifest input +declaring a union fell through `default: return null` and drew **no diagnostic +at all** — silence indistinguishable from a value that validated cleanly — +while objectui's copy checked every arm. The same authored page produced +diagnostics on one surface and none on the other: the dialect split the two +parser copies' invariant forbids (objectstack#12719 — both copies agree on the +accepted grammar **and** on diagnostic codes). + +`validateTree`'s coarse check now clears a prop when **any** declared arm +accepts the value, and when **no** arm accepts it emits **one** `type-mismatch` +diagnostic naming every arm — at `error` severity when an `enum` arm is +present (an enum's closed list is the one fact this layer can be certain +about), `warning` otherwise. A single-arm input produces the byte-identical +diagnostic it always did, `invalid-enum` included. `generateDts` emits a +TypeScript union for a union declaration, and `manifestFromConfigs` +canonicalizes union declarations through the new `input-type.ts` module +(`inputTypeArms`, `canonicalizeInputType`, `MANIFEST_INPUT_TYPES` — all +exported, so third-party manifest consumers read arms through the same +accessor the gate does). + +This is the lockstep port of the objectui#3832 ruling into this repo's hoisted +copy of the parser — the ported check is byte-equal to objectui's. It changes +what the save gate accepts and rejects for union-typed inputs: a value fitting +no arm of an enum-carrying union now draws an `error` where it previously drew +nothing. Today that change is latent in the production gate — this repo +resolves no `sdui.manifest.json`, so `validateJsxPages` runs parse-only; wiring +the manifest (the second gap recorded on objectstack#12719) is what makes it +author-visible, and this port lands ahead of that wiring deliberately.