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:
- 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).
---

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

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

@ -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<string, unknown>
): 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,

@ -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';

@ -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<SemaFamily, { intentPolicy: SemaIntentPolicy }>;
} as const satisfies Record<SemaFamily, { intentPolicy: IntentPolicy }>;
/** 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;

@ -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`)
}

@ -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<string, PartRegistration>();
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<string, unknown> {
if (!sources.props) return EMPTY_ROOT_PROPS;
const props: Record<string, unknown> = {};
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<void> {
@ -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

Loading…
Cancel
Save

Powered by TurnKey Linux.