You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/src/uix/morfo/schema.ts

771 lines
26 KiB

/**
* 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<MorfoElement, MorfoElement>;
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<typeof literal<string>>,
ReturnType<typeof literal<string>>,
...ReturnType<typeof literal<string>>[]
])
) as Schema<MorfoArchetype, MorfoArchetype>;
// ── 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<MorfoPrimitiveValueSource, MorfoPrimitiveValueSource>;
const stringMapSchema = object({}, { unknownKeys: 'passthrough' }) as unknown as Schema<
Record<string, string>,
Record<string, string>
>;
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<MorfoValueSource, MorfoValueSource>;
// ── 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<typeof literal<string>>,
ReturnType<typeof literal<string>>,
...ReturnType<typeof literal<string>>[]
])
) as Schema<Intent, Intent>;
const semaTransitionalFamilySchema = union(
literal('emerge'),
literal('shift'),
literal('sustain'),
literal('delegate')
);
const semaIntentOptionalFamilySchema = union(
semaTransitionalFamilySchema,
literal('contact'),
literal('handle')
);
const semaIntentExpectedFamilySchema = 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<string> = 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, semaIntentExpectedFamilySchema);
// 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))
}),
object({
family: semaIntentExpectedFamilySchema,
target: partRefSchema,
intent: union(semaIntentSchema, semanticIntentSchema),
intentRationale: optional(string()),
verb: optional(string()),
sequence: optional(sequenceSchema),
persistence: optional(persistenceSchema),
allowedFamilies: optional(array(semaFamilySchema))
})
);
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,
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')
);
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' })),
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<string> = []
): Array<{ part: MorfoPart; path: ReadonlyArray<string> }> {
const result: Array<{ part: MorfoPart; path: ReadonlyArray<string> }> = [];
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<string>, 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<string>();
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<string>,
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<string>();
const partByKebab = new Map(flat.map(({ part }) => [part.kebab, part] as const));
const prewriteDLAByPart = new Map<string, Set<string>>();
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);
if (!kebabs.has(event.semantic.target.target)) {
throw new MorfoInvariantError(
`event "${event.name}" targets unknown part "${event.semantic.target.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<string>();
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<string>();
// 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 };

Powered by TurnKey Linux.