/** * Morfo validation. * * Two layers of validation: * * 1. **Structural** (sium): shape, tagged union discriminants, literal enums. * Catches type errors in the declaration. * * 2. **Invariant** (manual walker): cross-part references. * - `partRef.target` must match another part's `kebab` in the same morfo. * - `stateRef.state` must exist in the containing part's `states[]`. * - `translationRef.key` is either an absolute idlangref (starting with * `#?`) or a component-relative key (e.g. `'label'`) that compile.ts * normalizes to `#?components.{kebab}.{key}`. Catalog presence is * validated by `scripts/translations-check.ts`, not here — the morfo no * longer carries the catalog; it lives in `src/uix/langs/components/`. * - Every `kebab` unique across the whole morfo. * - `MorfoCondition` discriminator values resolve (e.g. `state-equals` * must refer to a state declared somewhere in the morfo). * - `MorfoFocus.initial`/`return` `partRef` must match a kebab. * * Sium has no `lazy()` today, so recursion in `MorfoPart.parts?` is * handled by a manual walker that calls `morfoPartSchema.decode()` for * every sub-part. Swap to `lazy()` when sium ships it. * * This module validates Morfo as the component contract source of truth: * parts, ARIA, data, focus, keyboard, and component-authored semantic * events. It still knows nothing about eidos recipes or runtime engines. */ import { object, array, string, boolean, literal, union, discriminated, optional } from '$sium'; import type { Schema } from '$sium'; import { SiumValidationError } from '$sium'; import { INTENTS, type Intent } from '../intent'; import type { Morfo, MorfoPart, MorfoPrimitiveValueSource, MorfoValueSource, MorfoCondition, MorfoElement, MorfoArchetype } from './types'; import { ARCHETYPE_VOCABULARY } from './types'; import { MorfoInvariantError } from './errors'; // ── Leaf schemas ────────────────────────────────────────────────────────── const layerSchema = union(literal('soma'), literal('sema'), literal('eidos')); /** * Literal union of allowed HTML elements. Keep in sync with `MorfoElement` * in `types.ts` — TS catches mismatches at compile time. */ const elementSchema = union( literal('button'), literal('div'), literal('span'), // 'p' was missing while `MorfoElement` had it — the trailing // `as Schema<…>` cast silences the very TS check the sync comment above // promises, so the drift only surfaced at RUNTIME (text-blur, the sole // `defaultElement: 'p'` morfo, crashed loadMorfos() in morfo:check + // morfo:vocabulary). Found by the gradient-finish audit, 2026-07-15. literal('p'), literal('a'), literal('input'), literal('ul'), literal('ol'), literal('li'), literal('tr'), literal('td'), literal('th'), literal('table'), literal('thead'), literal('tbody'), literal('tfoot'), literal('header'), literal('nav'), literal('section'), literal('article'), literal('main'), literal('aside'), literal('footer'), literal('img'), literal('video'), literal('audio'), literal('svg'), literal('path'), literal('g'), literal('text'), literal('rect'), literal('circle'), literal('line'), literal('figure'), literal('figcaption'), literal('label'), literal('form'), literal('fieldset'), literal('legend'), literal('select'), literal('option'), literal('textarea'), literal('time'), literal('none') ) as Schema; const severitySchema = union(literal('required'), literal('recommended'), literal('optional')); const partKindSchema = union(literal('public'), literal('private'), literal('virtual')); const archetypeSchema = union( ...(ARCHETYPE_VOCABULARY.map((archetype) => literal(archetype)) as [ ReturnType>, ReturnType>, ...ReturnType>[] ]) ) as Schema; // ── MorfoValueSource (tagged union) ─────────────────────────────────────── const primitiveValueSourceSchema = discriminated('kind', [ object({ kind: literal('literal'), value: string() }), object({ kind: literal('stateRef'), state: string() }), object({ kind: literal('partRef'), target: string() }), object({ kind: literal('propRef'), prop: string() }), object({ kind: literal('translationRef'), key: string(), fallback: optional(string()) }) ]) as Schema; const stringMapSchema = object({}, { unknownKeys: 'passthrough' }) as unknown as Schema< Record, Record >; const valueSourceSchema = discriminated('kind', [ object({ kind: literal('literal'), value: string() }), object({ kind: literal('stateRef'), state: string() }), object({ kind: literal('partRef'), target: string() }), object({ kind: literal('propRef'), prop: string() }), object({ kind: literal('translationRef'), key: string(), fallback: optional(string()) }), object({ kind: literal('mapRef'), source: primitiveValueSourceSchema, map: stringMapSchema, fallback: optional(string()) }) ]) as Schema; // ── MorfoCondition (tagged union with 'always' literal + objects) ───────── const conditionObjectSchema = discriminated('when', [ object({ when: literal('part-present'), part: string() }), object({ when: literal('part-absent'), part: string() }), object({ when: literal('state-equals'), state: string(), value: string() }), object({ when: literal('prop-truthy'), prop: string() }), object({ when: literal('prop-falsy'), prop: string() }), object({ when: literal('prop-defined'), prop: string() }) ]); const conditionSchema = union(literal('always'), conditionObjectSchema) as Schema< MorfoCondition, MorfoCondition >; // ── MorfoData / MorfoAriaEntry / MorfoKeyboard ──────────────────────────── const dataEmitSchema = union(literal('presence'), literal('value')); const dataSchema = object({ attr: string(), values: optional(array(string())), emit: optional(dataEmitSchema), value: optional(valueSourceSchema), condition: optional(conditionSchema), severity: optional(severitySchema) }); const ariaEntrySchema = object({ attr: string(), value: valueSourceSchema, condition: optional(conditionSchema), severity: optional(severitySchema), ariaBoolean: optional(boolean()) }); const keyboardSchema = object({ key: string(), action: string(), condition: optional(conditionSchema) }); // ── Semantic events ─────────────────────────────────────────────────────── const semaIntentSchema = union( ...(INTENTS.map((intent) => literal(intent)) as [ ReturnType>, ReturnType>, ...ReturnType>[] ]) ) as Schema; const semaTransitionalFamilySchema = union( literal('emerge'), literal('shift'), literal('sustain'), literal('delegate') ); const semaIntentOptionalFamilySchema = union( semaTransitionalFamilySchema, literal('contact'), literal('handle') ); const semaIntentRequiredFamilySchema = union(literal('commit'), literal('signal')); /** * Structural / frame families where the book keeps intent OUT * (BK-FRAME-NO-INTENT, Apéndice A). `handle` is intentionally excluded — the * book puts the evaluative tone on `handle.drop`. `commit` / `signal` are the * message families (intent expected). A non-neutral intent on any family below * requires an explicit, non-empty `intentRationale` (see the guardrail in * `validateInvariants` and `docs/audit/book-updates.md` A-1). */ const FRAME_INTENT_FAMILIES: ReadonlySet = new Set([ 'contact', 'emerge', 'shift', 'sustain', 'delegate' ]); const semanticIntentSchema = object({ fromProp: optional(string()), default: semaIntentSchema, supported: array(semaIntentSchema) }); const partRefSchema = object({ kind: literal('partRef'), target: string() }); const sequenceSchema = union(literal('pre'), literal('coincident'), literal('post')); const persistenceSchema = union( literal('transient'), literal('untilAction'), literal('untilFix'), literal('stateBound') ); const semaFamilySchema = union(semaIntentOptionalFamilySchema, semaIntentRequiredFamilySchema); // Polymorphism (book §5.3) is ADDITIVE on the canonical event shape: // the morfo declares its default `family` + `intent` + `verb` as usual // and may add `allowedFamilies` to authorize providers to override the // family at trigger time. const eventSemanticSchema = union( object({ family: semaIntentOptionalFamilySchema, target: partRefSchema, intent: optional(union(semaIntentSchema, semanticIntentSchema)), intentRationale: optional(string()), verb: optional(string()), sequence: optional(sequenceSchema), persistence: optional(persistenceSchema), allowedFamilies: optional(array(semaFamilySchema)), allowedTargets: optional(array(partRefSchema)), targetFallback: optional(array(partRefSchema)) }), object({ family: semaIntentRequiredFamilySchema, target: partRefSchema, intent: union(semaIntentSchema, semanticIntentSchema), intentRationale: optional(string()), verb: optional(string()), sequence: optional(sequenceSchema), persistence: optional(persistenceSchema), allowedFamilies: optional(array(semaFamilySchema)), allowedTargets: optional(array(partRefSchema)), targetFallback: optional(array(partRefSchema)) }) ); const attrWriteSchema = object({ part: object({ kind: literal('partRef'), target: string() }), attr: string(), value: string() }); const commitSchema = object({ part: object({ kind: literal('partRef'), target: string() }), attr: string(), value: string() }); const reducedMotionFallbackSchema = union( literal('state'), literal('text'), literal('focus'), literal('none') ); const a11ySemanticSchema = object({ requiresPersistentTrace: optional(boolean()), requiresLiveRegion: optional(boolean()), requiresFocusMove: optional(boolean()), keyboardEquivalent: optional(boolean()), reducedMotionFallback: optional(reducedMotionFallbackSchema) }); const eventSchema = object({ name: string(), semantic: eventSemanticSchema, // D.2 — who fires the event. Absent = 'runtime' (the norm). emission: optional( union(literal('runtime'), literal('host'), literal('external'), literal('declared-only')) ), a11ySemantic: optional(a11ySemanticSchema), mode: optional(union(literal('blocking'), literal('advisory'))), regime: optional( union(literal('replace'), literal('collapse'), literal('lock'), literal('queue')) ), scope: optional(union(literal('part'), literal('component'), literal('scene'))), prewrite: optional(array(attrWriteSchema)), commits: optional(commitSchema) }); // ── MorfoFocus ──────────────────────────────────────────────────────────── const focusTargetSchema = union( literal('first-focusable'), literal('trigger'), literal('previous'), object({ partRef: string() }) ); const focusSchema = object({ initial: optional(focusTargetSchema), trap: optional(boolean()), return: optional(focusTargetSchema), restore: optional(boolean()) }); // ── MorfoPart (non-recursive — `parts?` handled by manual walker) ───────── // // Sium lacks `lazy()`, so we validate a single part without its `parts?` // children, then the walker recurses. const partShallowSchema = object({ name: string(), kebab: string(), archetype: optional(archetypeSchema), kind: partKindSchema, defaultElement: elementSchema, role: optional(string()), optional: boolean(), supportsNesting: optional(boolean()), states: optional(array(string())), data: array(dataSchema), aria: array(ariaEntrySchema), keyboard: optional(array(keyboardSchema)), parts: optional(array(object({}, { unknownKeys: 'passthrough' }))) // ^ children passed through opaquely — shape is checked by the walker }); // ── Morfo root ──────────────────────────────────────────────────────────── const semaExpressionModeSchema = union( literal('pack'), literal('family-default'), literal('delegated'), literal('none') ); // `renderAttrs` lives in BOTH the TypeScript union (`types.ts`) and here — // same dual-list hazard as `MorfoElement`: extending only the type compiles // clean and throws at runtime. Touch both in one edit. const renderAttrSchema = object({ attr: string(), values: optional(array(string())) }); const morfoShallowSchema = object( { name: string(), kebab: string(), scope: array(layerSchema), apg: optional(string()), focus: optional(focusSchema), events: optional(array(eventSchema)), expression: optional(semaExpressionModeSchema), translations: optional(object({}, { unknownKeys: 'passthrough' })), renderAttrs: optional(array(renderAttrSchema)), parts: array(object({}, { unknownKeys: 'passthrough' })) // ^ parts are opaque here; walker recurses with `partShallowSchema` }, // Extension keys from downstream layers (e.g. `sema`) are tolerated so // `satisfies MorfoWithSema`-style authoring still passes the root shape // check. Each layer ships its own deep validator. { unknownKeys: 'passthrough' } ); // ── Manual walker + invariants ──────────────────────────────────────────── /** Collect every part (recursively) into a flat list with ancestor path. */ function flattenParts( parts: readonly MorfoPart[], path: ReadonlyArray = [] ): Array<{ part: MorfoPart; path: ReadonlyArray }> { const result: Array<{ part: MorfoPart; path: ReadonlyArray }> = []; for (const p of parts) { const nextPath = [...path, p.kebab]; result.push({ part: p, path: nextPath }); if (p.parts) { result.push(...flattenParts(p.parts, nextPath)); } } return result; } /** Validate every part (recursively) against `partShallowSchema`. */ function validatePartsRecursively(parts: readonly MorfoPart[]): void { for (const { part, path } of flattenParts(parts)) { try { // `decodeSync` throws SiumValidationError synchronously on shape mismatch. partShallowSchema.decodeSync(part as never); } catch (err) { if (err instanceof SiumValidationError) { throw new MorfoInvariantError( `Part at path ${path.join('.')} failed shape validation: ${err.message}`, path as readonly string[] ); } throw err; } } } /** Classify known vocabularies so the external vocabulary-check script can use them. */ export const CANONICAL_VOCABULARIES = { disclosure: ['open', 'closed'], active: ['active', 'inactive'], checked: ['checked', 'unchecked', 'indeterminate'], toggle: ['on', 'off'], lifecycle: ['loading', 'idle', 'success', 'error'], selection: ['selected', 'unselected'], orientation: ['horizontal', 'vertical'] } as const; /** Thrown when a morfo fails invariant validation after shape passes. */ export { MorfoInvariantError } from './errors'; const PUBLIC_DATA_ATTR_RE = /^data-[a-z0-9][a-z0-9-]*$/; const PRIVATE_DATA_ATTR_RE = /^data-_[a-z0-9][a-z0-9-]*$/; function validateDataAttrName(attr: string, path: ReadonlyArray, context: string): void { if (!attr.startsWith('data-')) { throw new MorfoInvariantError(`${context} "${attr}" must start with "data-"`, path); } if (PRIVATE_DATA_ATTR_RE.test(attr)) { throw new MorfoInvariantError( `${context} "${attr}" uses reserved private prefix "data-_"; private attrs must stay outside morfo`, path ); } if (!PUBLIC_DATA_ATTR_RE.test(attr)) { throw new MorfoInvariantError( `${context} "${attr}" must be lowercase kebab-case after "data-"`, path ); } } /** * Cross-reference invariants. * * 1. Every `kebab` unique across the whole morfo (flat namespace). * 2. Every `partRef.target` resolves to a declared part. * 3. Every `stateRef.state` resolves to a state declared in the SAME part. * 4. Every `state-equals` condition's `state` exists somewhere in the part. * 5. `focus.initial.partRef` / `focus.return.partRef` resolve to a part. * 6. Root `scope` non-empty. * 7. Component-relative `translationRef` keys normalize to * `#?components.{kebab}.{key}`; catalog presence is checked by * `scripts/translations-check.ts`, not here. * 8. Morfo `kebab` is valid kebab-case (a-z0-9-). * * Not validated here (requires external sources): * - Cross-component vocabulary consistency (needs morfo registry → * scripts/morfo-vocabulary-check.mjs). */ function validateInvariants(morfo: Morfo): void { if (morfo.scope.length === 0) { throw new MorfoInvariantError( `morfo "${morfo.kebab}" has empty scope — must implement at least one layer` ); } if (!/^[a-z][a-z0-9-]*$/.test(morfo.kebab)) { throw new MorfoInvariantError( `morfo kebab "${morfo.kebab}" must be kebab-case (a-z, 0-9, - only)` ); } const flat = flattenParts(morfo.parts); const kebabs = new Set(); for (const { part, path } of flat) { if (kebabs.has(part.kebab)) { throw new MorfoInvariantError( `duplicate kebab "${part.kebab}" in morfo "${morfo.kebab}" at path ${path.join('.')}`, path ); } kebabs.add(part.kebab); } const validateValueSource = ( value: MorfoValueSource, part: MorfoPart, path: ReadonlyArray, context: string ) => { if (value.kind === 'mapRef') { if (Object.keys(value.map).length === 0) { throw new MorfoInvariantError(`${context}.mapRef must declare at least one mapping`, path); } for (const [sourceValue, mappedValue] of Object.entries(value.map)) { if (typeof mappedValue !== 'string') { throw new MorfoInvariantError( `${context}.mapRef["${sourceValue}"] must resolve to a string`, path ); } } validateValueSource(value.source, part, path, `${context}.mapRef.source`); return; } if (value.kind === 'partRef' && !kebabs.has(value.target)) { throw new MorfoInvariantError( `${context}.partRef "${value.target}" does not match any part in "${morfo.kebab}"`, path ); } if (value.kind === 'stateRef') { const states = part.states ?? []; if (!states.includes(value.state)) { throw new MorfoInvariantError( `${context}.stateRef "${value.state}" is not in part.states of "${part.kebab}" (declared: ${states.join(', ') || '∅'})`, path ); } } // translationRef keys: absolute idlangrefs are passed through to runtime; // relative keys are normalized by compile.ts to `#?components.{kebab}.{key}`. // Catalog presence is checked by `scripts/translations-check.ts`. // No structural assertion needed here. }; for (const { part, path } of flat) { for (const dataEntry of part.data) { validateDataAttrName(dataEntry.attr, path, 'data attr'); if (dataEntry.emit && dataEntry.values && dataEntry.values.length > 0) { throw new MorfoInvariantError( `data[${dataEntry.attr}].emit is only valid for non-enum data attrs`, path ); } const v = dataEntry.value; if (!v) continue; validateValueSource(v, part, path, `data[${dataEntry.attr}]`); } for (const ariaEntry of part.aria) { validateValueSource(ariaEntry.value, part, path, `aria[${ariaEntry.attr}]`); } const checkCondition = (c: MorfoCondition | undefined, context: string) => { if (!c || c === 'always') return; if ((c.when === 'part-present' || c.when === 'part-absent') && !kebabs.has(c.part)) { throw new MorfoInvariantError( `${context}: condition ${c.when} references unknown part "${c.part}"`, path ); } if (c.when === 'state-equals') { const states = part.states ?? []; if (!states.includes(c.state)) { throw new MorfoInvariantError( `${context}: condition state-equals references unknown state "${c.state}" in part "${part.kebab}"`, path ); } } }; for (const d of part.data) { checkCondition(d.condition, `data[${d.attr}]`); } for (const a of part.aria) { checkCondition(a.condition, `aria[${a.attr}]`); } for (const k of part.keyboard ?? []) { checkCondition(k.condition, `keyboard[${k.key}]`); } } if (morfo.focus) { const f = morfo.focus; if (typeof f.initial === 'object' && !kebabs.has(f.initial.partRef)) { throw new MorfoInvariantError( `focus.initial.partRef "${f.initial.partRef}" does not match any part` ); } if (typeof f.return === 'object' && !kebabs.has(f.return.partRef)) { throw new MorfoInvariantError( `focus.return.partRef "${f.return.partRef}" does not match any part` ); } } const events = morfo.events ?? []; const eventNames = new Set(); const partByKebab = new Map(flat.map(({ part }) => [part.kebab, part] as const)); const prewriteDLAByPart = new Map>(); for (const event of events) { if (eventNames.has(event.name)) { throw new MorfoInvariantError( `duplicate event name "${event.name}" in morfo "${morfo.kebab}"` ); } eventNames.add(event.name); // The name declares the family the engine will resolve — `{family}-{verb} // [-{nuance}]`. Without this the framework drifts into two dialects for // the same event (`open` in eight components vs `emerge-open` in three), // which is not cosmetic: a motion signature keys on the name with `^=`, // so a bare name silently matches nothing and simply stops animating. // Three audits found that split without closing it; this closes it. const { family } = event.semantic; if (event.name !== family && !event.name.startsWith(`${family}-`)) { throw new MorfoInvariantError( `event "${event.name}" in morfo "${morfo.kebab}" must start with its family "${family}" — the convention is \`{family}-{verb}[-{nuance}]\`` ); } if (!kebabs.has(event.semantic.target.target)) { throw new MorfoInvariantError( `event "${event.name}" targets unknown part "${event.semantic.target.target}"` ); } for (const allowed of event.semantic.allowedTargets ?? []) { if (!kebabs.has(allowed.target)) { throw new MorfoInvariantError( `event "${event.name}" allows unknown target part "${allowed.target}"` ); } if (allowed.target === event.semantic.target.target) { throw new MorfoInvariantError( `event "${event.name}" lists its own target "${allowed.target}" in allowedTargets — the canonical target is always allowed` ); } } const fallbackSeen = new Set(); for (const fallback of event.semantic.targetFallback ?? []) { if (!kebabs.has(fallback.target)) { throw new MorfoInvariantError( `event "${event.name}" falls back to unknown part "${fallback.target}"` ); } if (fallback.target === event.semantic.target.target) { throw new MorfoInvariantError( `event "${event.name}" lists its own target "${fallback.target}" in targetFallback — the canonical target is always resolved first` ); } // Order is meaning here: a duplicate entry would silently shadow the // later one and read as two chances where there is one. if (fallbackSeen.has(fallback.target)) { throw new MorfoInvariantError( `event "${event.name}" lists part "${fallback.target}" twice in targetFallback` ); } fallbackSeen.add(fallback.target); } if ('intent' in event.semantic && typeof event.semantic.intent === 'object') { if (event.semantic.intent.supported.length === 0) { throw new MorfoInvariantError( `event "${event.name}" must declare at least one supported intent` ); } if (!event.semantic.intent.supported.includes(event.semantic.intent.default)) { throw new MorfoInvariantError( `event "${event.name}" default intent "${event.semantic.intent.default}" must be included in supported intents` ); } } // Guardrail (BK-FRAME-NO-INTENT / book-updates A-1): a structural / // frame family must not carry a non-neutral intent unless the author // declares WHY. The book keeps the tone in the evaluable event the // frame contains (signal / commit) — "emerge.open + threat es un // documento inválido". The sanctioned exception is the appearance that // IS the warning; declaring it costs a written justification. { const sem = event.semantic; const intent = 'intent' in sem ? sem.intent : undefined; const nonNeutral = intent === undefined ? false : typeof intent === 'string' ? intent !== 'neutral' : intent.default !== 'neutral' || intent.supported.some((i) => i !== 'neutral'); const rationale = typeof sem.intentRationale === 'string' && sem.intentRationale.trim().length > 0; if (FRAME_INTENT_FAMILIES.has(sem.family) && nonNeutral && !rationale) { throw new MorfoInvariantError( `event "${event.name}" (family "${sem.family}") declares a non-neutral intent on a structural/frame family. The book keeps the evaluative tone in the evaluable event it frames (signal / commit), never in the frame (BK-FRAME-NO-INTENT). If this is the sanctioned "appearance that is the warning" exception (docs/audit/book-updates.md A-1), declare a non-empty "intentRationale" explaining why the tone lives on this event.` ); } } for (const write of event.prewrite ?? []) { if (!kebabs.has(write.part.target)) { throw new MorfoInvariantError( `event "${event.name}" prewrite targets unknown part "${write.part.target}"` ); } const targetPart = partByKebab.get(write.part.target); const dataEntry = targetPart?.data.find((d) => d.attr === write.attr); if (!dataEntry) { throw new MorfoInvariantError( `event "${event.name}" prewrite attr "${write.attr}" is not declared on part "${write.part.target}"` ); } if (dataEntry.values && !dataEntry.values.includes(write.value)) { throw new MorfoInvariantError( `event "${event.name}" prewrite value "${write.value}" is not declared for "${write.attr}"` ); } if (write.attr === 'data-last-action') { const values = prewriteDLAByPart.get(write.part.target) ?? new Set(); values.add(write.value); prewriteDLAByPart.set(write.part.target, values); } } if (event.commits) { if (!kebabs.has(event.commits.part.target)) { throw new MorfoInvariantError( `event "${event.name}" commits unknown part "${event.commits.part.target}"` ); } if (event.commits.attr === 'data-state') { const targetPart = partByKebab.get(event.commits.part.target); const states = targetPart?.states ?? []; if (!states.includes(event.commits.value)) { throw new MorfoInvariantError( `event "${event.name}" commits state "${event.commits.value}" not declared on part "${event.commits.part.target}"` ); } } } } for (const part of flat.map(({ part }) => part)) { const dataLastAction = part.data.find((entry) => entry.attr === 'data-last-action'); if (!dataLastAction?.values) continue; const declared = new Set(dataLastAction.values); const written = prewriteDLAByPart.get(part.kebab) ?? new Set(); // Direction 1 (kept strict): any value an event prewrites must be // declared in values[]. Prevents typos and orphan prewrites. for (const value of written) { if (!declared.has(value)) { throw new MorfoInvariantError( `part "${part.kebab}" is prewritten with data-last-action="${value}" but the attr does not declare it in values[]` ); } } // Direction 2 (loosened — book §5.3 polymorphism): values declared in // `values[]` may also be set IMPERATIVELY by the provider (e.g. // `runtime.partRef(part)` + `dom.apply`) when a polymorphic event // can't bind a single `prewrite` per call. Requiring every value to // have a matching event prewrite breaks the polymorphic close // pattern (one `close` event, multiple data-last-action values set // by the provider). `data-last-action.values[]` remains the closed // enum of valid values — eidos and lint still consume it. } } // ── Public API ──────────────────────────────────────────────────────────── /** * Validate a `Morfo` value. Throws `SiumValidationError` on shape errors * and `MorfoInvariantError` on cross-reference errors. * * Intended for build-time / dev-time. Run once per morfo on first load; * results are cacheable. * * This validator also checks `morfo.events` when present. */ export function validateMorfo(morfo: unknown): Morfo { morfoShallowSchema.decodeSync(morfo as never); const m = morfo as Morfo; // Sium has no lazy() today, so parts are validated via an explicit walker. validatePartsRecursively(m.parts); // Cross-reference invariants: kebab uniqueness, partRef/stateRef, focus. validateInvariants(m); return m; } export { morfoShallowSchema, partShallowSchema };