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.
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,
+ };
}