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.
836 lines
29 KiB
836 lines
29 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 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<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, 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<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);
|
|
|
|
// 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<string>();
|
|
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<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 };
|