Tighten intent and morfo contracts

active-uix
dev 5 months ago
parent e6e438b62a
commit a4cbe0c0b9

@ -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: - `npm run morfo:check` — For each morfo, navigates to `/test/soma/{kebab}` and validates:
- Every declared data-attr with `severity: 'required'` is emitted. - Every declared data-attr with `severity: 'required'` is emitted.
- Every emitted data-attr value matches `values[]` if declared. - 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. - `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}]`. 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. **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).
--- ---

@ -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/
);
});
});

@ -357,6 +357,31 @@ export const CANONICAL_VOCABULARIES = {
/** Thrown when a morfo fails invariant validation after shape passes. */ /** Thrown when a morfo fails invariant validation after shape passes. */
export { MorfoInvariantError } from './errors'; 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. * Cross-reference invariants.
* *
@ -452,6 +477,8 @@ function validateInvariants(morfo: Morfo): void {
for (const { part, path } of flat) { for (const { part, path } of flat) {
for (const dataEntry of part.data) { for (const dataEntry of part.data) {
validateDataAttrName(dataEntry.attr, path, 'data attr');
if (dataEntry.emit && !dataEntry.value) { if (dataEntry.emit && !dataEntry.value) {
throw new MorfoInvariantError(`data[${dataEntry.attr}].emit requires a value source`, path); throw new MorfoInvariantError(`data[${dataEntry.attr}].emit requires a value source`, path);
} }

@ -21,7 +21,7 @@ import {
isSemaEventLabel, isSemaEventLabel,
normalizeSemaEvent, normalizeSemaEvent,
parseSemaEventLabel, parseSemaEventLabel,
resolveSemaIntent resolveIntent
} from './event'; } from './event';
import { SEMA_FAMILY_POLICY } from './types'; 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', () => { 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', () => { it('reads `fromProp` value when present and supported', () => {
const intent = resolveSemaIntent( const intent = resolveIntent(
{ fromProp: 'intent', default: 'neutral', supported: ['neutral', 'risk', 'threat'] }, { fromProp: 'intent', default: 'neutral', supported: ['neutral', 'risk', 'threat'] },
{ intent: 'risk' } { intent: 'risk' }
); );
@ -182,7 +182,7 @@ describe('resolveSemaIntent', () => {
}); });
it('falls back to `default` when prop is missing', () => { it('falls back to `default` when prop is missing', () => {
const intent = resolveSemaIntent( const intent = resolveIntent(
{ fromProp: 'intent', default: 'fulfill', supported: ['fulfill', 'threat'] }, { fromProp: 'intent', default: 'fulfill', supported: ['fulfill', 'threat'] },
{} {}
); );
@ -190,7 +190,7 @@ describe('resolveSemaIntent', () => {
}); });
it('falls back to first supported when default is unsupported', () => { it('falls back to first supported when default is unsupported', () => {
const intent = resolveSemaIntent( const intent = resolveIntent(
{ default: 'loss', supported: ['neutral', 'threat'] } as never, { default: 'loss', supported: ['neutral', 'threat'] } as never,
{} {}
); );

@ -5,7 +5,7 @@ import {
type SemaEventKey, type SemaEventKey,
type SemaEventLabel, type SemaEventLabel,
type SemaFamily, type SemaFamily,
type SemaIntentBinding, type IntentBinding,
type SemaTransitionalFamily, type SemaTransitionalFamily,
type SemaValencedFamily type SemaValencedFamily
} from './types' } 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) 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) 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) 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 (!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 ('fromProp' in value && value.fromProp !== undefined && typeof value.fromProp !== 'string') return false
if ('supported' in value && value.supported !== undefined) { if ('supported' in value && value.supported !== undefined) {
if (!Array.isArray(value.supported)) return false if (!Array.isArray(value.supported)) return false
if (!value.supported.every(isSemaIntent)) return false if (!value.supported.every(isIntent)) return false
} }
return true return true
} }
@ -105,7 +105,7 @@ export function isSemaEvent(value: unknown): value is SemaEvent {
return policy !== 'expected' 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( export function resolveIntent(
intent: Intent | SemaIntentBinding, intent: Intent | IntentBinding,
props?: Record<string, unknown> props?: Record<string, unknown>
): Intent { ): 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 fallback = supported[0] ?? intent.default
const candidate = intent.fromProp ? props?.[intent.fromProp] : undefined const candidate = intent.fromProp ? props?.[intent.fromProp] : undefined
if (isSemaIntent(candidate) && supported.includes(candidate)) { if (isIntent(candidate) && supported.includes(candidate)) {
return candidate return candidate
} }
@ -184,7 +184,7 @@ export function normalizeSemaEvent(
} }
} }
const intent = resolveSemaIntent(intentValue, props) const intent = resolveIntent(intentValue, props)
return { return {
family: event.family, family: event.family,
intent, intent,

@ -2,7 +2,7 @@ export type {
SemaValencedFamily, SemaValencedFamily,
SemaTransitionalFamily, SemaTransitionalFamily,
SemaFamily, SemaFamily,
SemaIntentPolicy, IntentPolicy,
IntentExpectedFamily, IntentExpectedFamily,
IntentOptionalFamily, IntentOptionalFamily,
SemaMode, SemaMode,
@ -11,7 +11,7 @@ export type {
SemaCause, SemaCause,
SemaEventKey, SemaEventKey,
SemaEventLabel, SemaEventLabel,
SemaIntentBinding, IntentBinding,
SemaEvent, SemaEvent,
SemaEventExtensions, SemaEventExtensions,
SemaActionEvent, SemaActionEvent,
@ -29,12 +29,12 @@ export {
isSemaFamily, isSemaFamily,
isSemaValencedFamily, isSemaValencedFamily,
isSemaTransitionalFamily, isSemaTransitionalFamily,
isSemaIntent, isIntent,
isSemaEventLabel, isSemaEventLabel,
isSemaIntentBinding, isIntentBinding,
isSemaEvent, isSemaEvent,
parseSemaEventLabel, parseSemaEventLabel,
resolveSemaIntent, resolveIntent,
normalizeSemaEvent, normalizeSemaEvent,
toSemaEventLabel toSemaEventLabel
} from './event'; } from './event';
@ -98,4 +98,4 @@ export {
} from './verbs'; } from './verbs';
export { SemaDuplicateChannelError, SemaInvariantError } from './errors'; export { SemaDuplicateChannelError, SemaInvariantError } from './errors';
export { validateSemaEvent, validateSemaIntentBinding } from './validation'; export { validateSemaEvent, validateIntentBinding } from './validation';

@ -18,7 +18,7 @@ export type SemaFamily = SemaValencedFamily | SemaTransitionalFamily;
// ── Family policy ────────────────────────────────────────────────────────── // ── Family policy ──────────────────────────────────────────────────────────
export type SemaIntentPolicy = 'allowed' | 'expected' | 'optional'; export type IntentPolicy = 'allowed' | 'expected' | 'optional';
/** /**
* Per-family doctrinal policy. Today carries `intentPolicy` only; * Per-family doctrinal policy. Today carries `intentPolicy` only;
@ -61,7 +61,7 @@ export const SEMA_FAMILY_POLICY = {
emerge: { intentPolicy: 'optional' }, emerge: { intentPolicy: 'optional' },
shift: { intentPolicy: 'optional' }, shift: { intentPolicy: 'optional' },
sustain: { intentPolicy: 'optional' } sustain: { intentPolicy: 'optional' }
} as const satisfies Record<SemaFamily, { intentPolicy: SemaIntentPolicy }>; } as const satisfies Record<SemaFamily, { intentPolicy: IntentPolicy }>;
/** Family classifications derived from `SEMA_FAMILY_POLICY.intentPolicy`. */ /** Family classifications derived from `SEMA_FAMILY_POLICY.intentPolicy`. */
export type IntentExpectedFamily = { export type IntentExpectedFamily = {
@ -98,7 +98,7 @@ export type SemaEventLabel = SemaEventKey;
// ── Structured semantics ─────────────────────────────────────────────────── // ── Structured semantics ───────────────────────────────────────────────────
export interface SemaIntentBinding { export interface IntentBinding {
fromProp?: string; fromProp?: string;
default: Intent; default: Intent;
supported?: readonly Intent[]; supported?: readonly Intent[];
@ -146,11 +146,11 @@ export interface SemaEventExtensions {
export type SemaEvent = ( export type SemaEvent = (
| { | {
family: IntentOptionalFamily; family: IntentOptionalFamily;
intent?: Intent | SemaIntentBinding; intent?: Intent | IntentBinding;
} }
| { | {
family: IntentExpectedFamily; family: IntentExpectedFamily;
intent: Intent | SemaIntentBinding; intent: Intent | IntentBinding;
} }
) & ) &
SemaEventExtensions; SemaEventExtensions;

@ -1,19 +1,19 @@
import { isSemaEvent, isSemaEventLabel, isSemaIntent, isSemaIntentBinding } from './event' import { isSemaEvent, isSemaEventLabel, isIntent, isIntentBinding } from './event'
import { SemaInvariantError } from './errors' 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 { SemaInvariantError } from './errors'
export function validateSemaIntentBinding(binding: SemaIntentBinding, ctx = 'sema.intent'): void { export function validateIntentBinding(binding: IntentBinding, ctx = 'sema.intent'): void {
if (!isSemaIntentBinding(binding)) { if (!isIntentBinding(binding)) {
throw new SemaInvariantError(`${ctx} is not a valid SemaIntentBinding`) throw new SemaInvariantError(`${ctx} is not a valid IntentBinding`)
} }
const supported = binding.supported ?? [] const supported = binding.supported ?? []
if (supported.length === 0) return if (supported.length === 0) return
for (const intent of supported) { for (const intent of supported) {
if (!isSemaIntent(intent)) { if (!isIntent(intent)) {
throw new SemaInvariantError(`${ctx}: intent "${String(intent)}" is not a valid 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. // Bindings need structural validation; literals + absence are fine.
if (intent === undefined || typeof intent === 'string') return if (intent === undefined || typeof intent === 'string') return
validateSemaIntentBinding(intent, `${ctx}.intent`) validateIntentBinding(intent, `${ctx}.intent`)
} }

@ -45,7 +45,7 @@ import type { ActiveDom, DomAttrValue } from '$adom';
import type { Logger } from '$libs/logger'; import type { Logger } from '$libs/logger';
import { import {
resolveSemaDuration, resolveSemaDuration,
resolveSemaIntent, resolveIntent,
type EngineSemantic, type EngineSemantic,
type SemaChannelId, type SemaChannelId,
type SemaSignatureOverride type SemaSignatureOverride
@ -303,6 +303,7 @@ function readBindings(reg: PartRegistration, sources: SomaRuntimeSources): Morfo
export function createSomaRuntime(morfo: Morfo, sources: SomaRuntimeSources): SomaRuntime { export function createSomaRuntime(morfo: Morfo, sources: SomaRuntimeSources): SomaRuntime {
const compiled = registerMorfo(morfo); const compiled = registerMorfo(morfo);
const registrations = new Map<string, PartRegistration>(); const registrations = new Map<string, PartRegistration>();
const rootPropsScratch = hasSourceKeys(sources.props) ? {} : undefined;
function resolvePart(part: string): CompiledPart { function resolvePart(part: string): CompiledPart {
const compiledPart = compiled.parts.byKebab.get(part); const compiledPart = compiled.parts.byKebab.get(part);
@ -433,10 +434,10 @@ export function createSomaRuntime(morfo: Morfo, sources: SomaRuntimeSources): So
} }
function snapshotRootProps(): Record<string, unknown> { function snapshotRootProps(): Record<string, unknown> {
if (!sources.props) return EMPTY_ROOT_PROPS; if (!rootPropsScratch || !sources.props) return EMPTY_ROOT_PROPS;
const props: Record<string, unknown> = {}; clearBindingsBucket(rootPropsScratch);
for (const key in sources.props) props[key] = sources.props[key](); readSourcesInto(rootPropsScratch, sources.props);
return props; return rootPropsScratch;
} }
async function trigger(eventName: string, opts: TriggerOptions = {}): Promise<void> { async function trigger(eventName: string, opts: TriggerOptions = {}): Promise<void> {
@ -497,7 +498,7 @@ export function createSomaRuntime(morfo: Morfo, sources: SomaRuntimeSources): So
// has no intent and intent.deltas don't apply. // has no intent and intent.deltas don't apply.
const intent = const intent =
'intent' in action.semantic && action.semantic.intent !== undefined 'intent' in action.semantic && action.semantic.intent !== undefined
? resolveSemaIntent(action.semantic.intent, props) ? resolveIntent(action.semantic.intent, props)
: undefined; : undefined;
const hold = resolveSemaDuration(action.hold); const hold = resolveSemaDuration(action.hold);
// Per-call overrides win over morfo-declared. Channels: per-call // Per-call overrides win over morfo-declared. Channels: per-call

Loading…
Cancel
Save

Powered by TurnKey Linux.