diff --git a/src/uix/morfo/README.md b/src/uix/morfo/README.md index 21cc2c78b..02a450e64 100644 --- a/src/uix/morfo/README.md +++ b/src/uix/morfo/README.md @@ -671,7 +671,8 @@ Two npm scripts exercise the morfos against the real DOM: - `npm run morfo:check` — For each morfo, navigates to `/test/soma/{kebab}` and validates: - Every declared data-attr with `severity: 'required'` is emitted. - Every emitted data-attr value matches `values[]` if declared. - - No undeclared `data-{component}-*` attrs are emitted (except `data-_*` private). + - No undeclared `data-{component}-*` attrs are emitted (except `data-_*` private + provider state, which is outside morfo). - `npm run morfo:vocabulary` — Flags `data-state` enums that diverge from canonical vocabularies (`open|closed`, `active|inactive`, `checked|unchecked|indeterminate`, etc.). WARN-level; novel vocabularies may be legitimate but should be reviewed. @@ -691,7 +692,10 @@ Both scripts require `npm run dev` running in another terminal. Never `data-soma-*`, never `data-eidos-*` — always `data-{component}[-{part}]`. -Private attrs for internal debug / state use the `data-_*` prefix (convention only — no enforcement), which is skipped by strict-mode validation. +Private attrs for internal debug / state use the reserved `data-_*` prefix and are +intentionally **outside** morfo. `validateMorfo()` rejects `data-_*` in a morfo +declaration; strict-mode tooling skips provider-private attrs when scanning the +real DOM. --- @@ -847,7 +851,7 @@ All 66 morfos in the codebase use the `as const satisfies Morfo` form. This is m **Missing `severity: 'optional'` on presence flags.** If you declare `{ attr: 'data-disabled' }` without severity, strict mode treats it as required. Add `severity: 'optional'` so morfo-check doesn't flag it missing when the flag is legitimately absent. -**Provider emits a data-attr not in the morfo.** Strict mode logs a warning at runtime; morfo-check fails in CI. Either add the attr to the morfo or rename the provider's emission to `data-_*` (private). +**Provider emits a data-attr not in the morfo.** Strict mode logs a warning at runtime; morfo-check fails in CI. Either add the attr to the morfo or rename the provider's emission to `data-_*` (private, not declared in morfo). --- diff --git a/src/uix/morfo/schema.test.ts b/src/uix/morfo/schema.test.ts new file mode 100644 index 000000000..6b5999cf9 --- /dev/null +++ b/src/uix/morfo/schema.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; + +import { MorfoInvariantError, validateMorfo } from './schema'; + +const baseMorfo = { + name: 'Sample', + kebab: 'sample', + scope: ['soma'], + parts: [ + { + name: 'Provider', + kebab: 'provider', + kind: 'public', + defaultElement: 'div', + optional: false, + data: [], + aria: [] + } + ] +} as const; + +function withDataAttr(attr: string) { + return { + ...baseMorfo, + parts: [ + { + ...baseMorfo.parts[0], + data: [{ attr }] + } + ] + }; +} + +describe('validateMorfo — data attrs', () => { + it('accepts public lowercase data attrs', () => { + expect(() => validateMorfo(withDataAttr('data-state'))).not.toThrow(); + expect(() => validateMorfo(withDataAttr('data-last-action'))).not.toThrow(); + }); + + it('rejects attrs outside the data-* namespace', () => { + expect(() => validateMorfo(withDataAttr('aria-expanded'))).toThrow(MorfoInvariantError); + expect(() => validateMorfo(withDataAttr('aria-expanded'))).toThrow(/must start with "data-"/); + }); + + it('reserves data-_ attrs for provider-private DOM state outside morfo', () => { + expect(() => validateMorfo(withDataAttr('data-_debug'))).toThrow(MorfoInvariantError); + expect(() => validateMorfo(withDataAttr('data-_debug'))).toThrow( + /reserved private prefix "data-_"/ + ); + }); + + it('rejects non-kebab public data attrs', () => { + expect(() => validateMorfo(withDataAttr('data-lastAction'))).toThrow(MorfoInvariantError); + expect(() => validateMorfo(withDataAttr('data-lastAction'))).toThrow( + /lowercase kebab-case/ + ); + }); +}); diff --git a/src/uix/morfo/schema.ts b/src/uix/morfo/schema.ts index e6ea0fdf9..4361bebb3 100644 --- a/src/uix/morfo/schema.ts +++ b/src/uix/morfo/schema.ts @@ -357,6 +357,31 @@ export const CANONICAL_VOCABULARIES = { /** 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. * @@ -452,6 +477,8 @@ function validateInvariants(morfo: Morfo): void { for (const { part, path } of flat) { for (const dataEntry of part.data) { + validateDataAttrName(dataEntry.attr, path, 'data attr'); + if (dataEntry.emit && !dataEntry.value) { throw new MorfoInvariantError(`data[${dataEntry.attr}].emit requires a value source`, path); } diff --git a/src/uix/sema/event.test.ts b/src/uix/sema/event.test.ts index 3ab4aa900..e0ef6b1f9 100644 --- a/src/uix/sema/event.test.ts +++ b/src/uix/sema/event.test.ts @@ -21,7 +21,7 @@ import { isSemaEventLabel, normalizeSemaEvent, parseSemaEventLabel, - resolveSemaIntent + resolveIntent } from './event'; import { SEMA_FAMILY_POLICY } from './types'; @@ -168,13 +168,13 @@ describe('normalizeSemaEvent — preserves intent on any family', () => { }); }); -describe('resolveSemaIntent', () => { +describe('resolveIntent', () => { it('returns the literal when intent is already a string', () => { - expect(resolveSemaIntent('threat')).toBe('threat'); + expect(resolveIntent('threat')).toBe('threat'); }); it('reads `fromProp` value when present and supported', () => { - const intent = resolveSemaIntent( + const intent = resolveIntent( { fromProp: 'intent', default: 'neutral', supported: ['neutral', 'risk', 'threat'] }, { intent: 'risk' } ); @@ -182,7 +182,7 @@ describe('resolveSemaIntent', () => { }); it('falls back to `default` when prop is missing', () => { - const intent = resolveSemaIntent( + const intent = resolveIntent( { fromProp: 'intent', default: 'fulfill', supported: ['fulfill', 'threat'] }, {} ); @@ -190,7 +190,7 @@ describe('resolveSemaIntent', () => { }); it('falls back to first supported when default is unsupported', () => { - const intent = resolveSemaIntent( + const intent = resolveIntent( { default: 'loss', supported: ['neutral', 'threat'] } as never, {} ); diff --git a/src/uix/sema/event.ts b/src/uix/sema/event.ts index 532a0c576..e1ad91886 100644 --- a/src/uix/sema/event.ts +++ b/src/uix/sema/event.ts @@ -5,7 +5,7 @@ import { type SemaEventKey, type SemaEventLabel, type SemaFamily, - type SemaIntentBinding, + type IntentBinding, type SemaTransitionalFamily, type SemaValencedFamily } from './types' @@ -64,7 +64,7 @@ export function isSemaTransitionalFamily(value: unknown): value is SemaTransitio return typeof value === 'string' && TRANSITIONAL_FAMILY_SET.has(value as SemaTransitionalFamily) } -export function isSemaIntent(value: unknown): value is Intent { +export function isIntent(value: unknown): value is Intent { return typeof value === 'string' && INTENT_SET.has(value as Intent) } @@ -72,13 +72,13 @@ export function isSemaEventLabel(value: unknown): value is SemaEventLabel { return typeof value === 'string' && LABEL_SET.has(value as SemaEventKey) } -export function isSemaIntentBinding(value: unknown): value is SemaIntentBinding { +export function isIntentBinding(value: unknown): value is IntentBinding { if (!isRecord(value)) return false - if (!isSemaIntent(value.default)) return false + if (!isIntent(value.default)) return false if ('fromProp' in value && value.fromProp !== undefined && typeof value.fromProp !== 'string') return false if ('supported' in value && value.supported !== undefined) { if (!Array.isArray(value.supported)) return false - if (!value.supported.every(isSemaIntent)) return false + if (!value.supported.every(isIntent)) return false } return true } @@ -105,7 +105,7 @@ export function isSemaEvent(value: unknown): value is SemaEvent { return policy !== 'expected' } - return isSemaIntent(intentValue) || isSemaIntentBinding(intentValue) + return isIntent(intentValue) || isIntentBinding(intentValue) } /** @@ -128,17 +128,17 @@ export function parseSemaEventLabel(label: SemaEventLabel): ParsedSemaEventLabel } } -export function resolveSemaIntent( - intent: Intent | SemaIntentBinding, +export function resolveIntent( + intent: Intent | IntentBinding, props?: Record ): Intent { - if (isSemaIntent(intent)) return intent + if (isIntent(intent)) return intent - const supported = intent.supported?.filter(isSemaIntent) ?? [...INTENTS] + const supported = intent.supported?.filter(isIntent) ?? [...INTENTS] const fallback = supported[0] ?? intent.default const candidate = intent.fromProp ? props?.[intent.fromProp] : undefined - if (isSemaIntent(candidate) && supported.includes(candidate)) { + if (isIntent(candidate) && supported.includes(candidate)) { return candidate } @@ -184,7 +184,7 @@ export function normalizeSemaEvent( } } - const intent = resolveSemaIntent(intentValue, props) + const intent = resolveIntent(intentValue, props) return { family: event.family, intent, diff --git a/src/uix/sema/exports.ts b/src/uix/sema/exports.ts index e35436b7b..26d417a72 100644 --- a/src/uix/sema/exports.ts +++ b/src/uix/sema/exports.ts @@ -2,7 +2,7 @@ export type { SemaValencedFamily, SemaTransitionalFamily, SemaFamily, - SemaIntentPolicy, + IntentPolicy, IntentExpectedFamily, IntentOptionalFamily, SemaMode, @@ -11,7 +11,7 @@ export type { SemaCause, SemaEventKey, SemaEventLabel, - SemaIntentBinding, + IntentBinding, SemaEvent, SemaEventExtensions, SemaActionEvent, @@ -29,12 +29,12 @@ export { isSemaFamily, isSemaValencedFamily, isSemaTransitionalFamily, - isSemaIntent, + isIntent, isSemaEventLabel, - isSemaIntentBinding, + isIntentBinding, isSemaEvent, parseSemaEventLabel, - resolveSemaIntent, + resolveIntent, normalizeSemaEvent, toSemaEventLabel } from './event'; @@ -98,4 +98,4 @@ export { } from './verbs'; export { SemaDuplicateChannelError, SemaInvariantError } from './errors'; -export { validateSemaEvent, validateSemaIntentBinding } from './validation'; +export { validateSemaEvent, validateIntentBinding } from './validation'; diff --git a/src/uix/sema/types.ts b/src/uix/sema/types.ts index b3b5e41de..84ed026e9 100644 --- a/src/uix/sema/types.ts +++ b/src/uix/sema/types.ts @@ -18,7 +18,7 @@ export type SemaFamily = SemaValencedFamily | SemaTransitionalFamily; // ── Family policy ────────────────────────────────────────────────────────── -export type SemaIntentPolicy = 'allowed' | 'expected' | 'optional'; +export type IntentPolicy = 'allowed' | 'expected' | 'optional'; /** * Per-family doctrinal policy. Today carries `intentPolicy` only; @@ -61,7 +61,7 @@ export const SEMA_FAMILY_POLICY = { emerge: { intentPolicy: 'optional' }, shift: { intentPolicy: 'optional' }, sustain: { intentPolicy: 'optional' } -} as const satisfies Record; +} as const satisfies Record; /** Family classifications derived from `SEMA_FAMILY_POLICY.intentPolicy`. */ export type IntentExpectedFamily = { @@ -98,7 +98,7 @@ export type SemaEventLabel = SemaEventKey; // ── Structured semantics ─────────────────────────────────────────────────── -export interface SemaIntentBinding { +export interface IntentBinding { fromProp?: string; default: Intent; supported?: readonly Intent[]; @@ -146,11 +146,11 @@ export interface SemaEventExtensions { export type SemaEvent = ( | { family: IntentOptionalFamily; - intent?: Intent | SemaIntentBinding; + intent?: Intent | IntentBinding; } | { family: IntentExpectedFamily; - intent: Intent | SemaIntentBinding; + intent: Intent | IntentBinding; } ) & SemaEventExtensions; diff --git a/src/uix/sema/validation.ts b/src/uix/sema/validation.ts index d82b88ac8..ea6982d8b 100644 --- a/src/uix/sema/validation.ts +++ b/src/uix/sema/validation.ts @@ -1,19 +1,19 @@ -import { isSemaEvent, isSemaEventLabel, isSemaIntent, isSemaIntentBinding } from './event' +import { isSemaEvent, isSemaEventLabel, isIntent, isIntentBinding } from './event' import { SemaInvariantError } from './errors' -import { SEMA_FAMILY_POLICY, type SemaActionEvent, type SemaIntentBinding } from './types' +import { SEMA_FAMILY_POLICY, type SemaActionEvent, type IntentBinding } from './types' export { SemaInvariantError } from './errors' -export function validateSemaIntentBinding(binding: SemaIntentBinding, ctx = 'sema.intent'): void { - if (!isSemaIntentBinding(binding)) { - throw new SemaInvariantError(`${ctx} is not a valid SemaIntentBinding`) +export function validateIntentBinding(binding: IntentBinding, ctx = 'sema.intent'): void { + if (!isIntentBinding(binding)) { + throw new SemaInvariantError(`${ctx} is not a valid IntentBinding`) } const supported = binding.supported ?? [] if (supported.length === 0) return for (const intent of supported) { - if (!isSemaIntent(intent)) { + if (!isIntent(intent)) { throw new SemaInvariantError(`${ctx}: intent "${String(intent)}" is not a valid Intent`) } } @@ -48,5 +48,5 @@ export function validateSemaEvent(event: SemaActionEvent, ctx = 'sema.event'): v // Bindings need structural validation; literals + absence are fine. if (intent === undefined || typeof intent === 'string') return - validateSemaIntentBinding(intent, `${ctx}.intent`) + validateIntentBinding(intent, `${ctx}.intent`) } diff --git a/src/uix/soma/runtime.svelte.ts b/src/uix/soma/runtime.svelte.ts index 9ac8c2c5e..9b95a96d8 100644 --- a/src/uix/soma/runtime.svelte.ts +++ b/src/uix/soma/runtime.svelte.ts @@ -45,7 +45,7 @@ import type { ActiveDom, DomAttrValue } from '$adom'; import type { Logger } from '$libs/logger'; import { resolveSemaDuration, - resolveSemaIntent, + resolveIntent, type EngineSemantic, type SemaChannelId, type SemaSignatureOverride @@ -303,6 +303,7 @@ function readBindings(reg: PartRegistration, sources: SomaRuntimeSources): Morfo export function createSomaRuntime(morfo: Morfo, sources: SomaRuntimeSources): SomaRuntime { const compiled = registerMorfo(morfo); const registrations = new Map(); + const rootPropsScratch = hasSourceKeys(sources.props) ? {} : undefined; function resolvePart(part: string): CompiledPart { const compiledPart = compiled.parts.byKebab.get(part); @@ -433,10 +434,10 @@ export function createSomaRuntime(morfo: Morfo, sources: SomaRuntimeSources): So } function snapshotRootProps(): Record { - if (!sources.props) return EMPTY_ROOT_PROPS; - const props: Record = {}; - for (const key in sources.props) props[key] = sources.props[key](); - return props; + if (!rootPropsScratch || !sources.props) return EMPTY_ROOT_PROPS; + clearBindingsBucket(rootPropsScratch); + readSourcesInto(rootPropsScratch, sources.props); + return rootPropsScratch; } async function trigger(eventName: string, opts: TriggerOptions = {}): Promise { @@ -497,7 +498,7 @@ export function createSomaRuntime(morfo: Morfo, sources: SomaRuntimeSources): So // has no intent and intent.deltas don't apply. const intent = 'intent' in action.semantic && action.semantic.intent !== undefined - ? resolveSemaIntent(action.semantic.intent, props) + ? resolveIntent(action.semantic.intent, props) : undefined; const hold = resolveSemaDuration(action.hold); // Per-call overrides win over morfo-declared. Channels: per-call