|
|
import {
|
|
|
SEMA_FAMILY_POLICY,
|
|
|
type SemaActionEvent,
|
|
|
type SemaEvent,
|
|
|
type SemaEventKey,
|
|
|
type SemaEventLabel,
|
|
|
type SemaFamily,
|
|
|
type IntentBinding,
|
|
|
type SemaTransitionalFamily,
|
|
|
type SemaValencedFamily
|
|
|
} from './types'
|
|
|
import { INTENTS, type Intent } from '../intent'
|
|
|
|
|
|
export const SEMA_VALENCED_FAMILIES = [
|
|
|
'contact',
|
|
|
'commit',
|
|
|
'signal',
|
|
|
'handle'
|
|
|
] as const satisfies readonly SemaValencedFamily[]
|
|
|
|
|
|
export const SEMA_TRANSITIONAL_FAMILIES = [
|
|
|
'emerge',
|
|
|
'shift',
|
|
|
'sustain'
|
|
|
] as const satisfies readonly SemaTransitionalFamily[]
|
|
|
|
|
|
export const SEMA_FAMILIES = [
|
|
|
...SEMA_VALENCED_FAMILIES,
|
|
|
...SEMA_TRANSITIONAL_FAMILIES
|
|
|
] as const satisfies readonly SemaFamily[]
|
|
|
|
|
|
/**
|
|
|
* All valid `SemaEventKey` values, generated from the family × intent
|
|
|
* product plus family-only forms. Open template-literal types can't be
|
|
|
* enumerated by the type system; we materialise the array at module load
|
|
|
* for runtime membership checks.
|
|
|
*/
|
|
|
export const SEMA_EVENT_LABELS: readonly SemaEventKey[] = [
|
|
|
...SEMA_FAMILIES,
|
|
|
...SEMA_FAMILIES.flatMap((family) =>
|
|
|
INTENTS.map((intent) => `${family}-${intent}` as SemaEventKey)
|
|
|
)
|
|
|
]
|
|
|
|
|
|
const FAMILY_SET = new Set<SemaFamily>(SEMA_FAMILIES)
|
|
|
const VALENCED_FAMILY_SET = new Set<SemaValencedFamily>(SEMA_VALENCED_FAMILIES)
|
|
|
const TRANSITIONAL_FAMILY_SET = new Set<SemaTransitionalFamily>(SEMA_TRANSITIONAL_FAMILIES)
|
|
|
const INTENT_SET = new Set<Intent>(INTENTS)
|
|
|
const LABEL_SET = new Set<SemaEventKey>(SEMA_EVENT_LABELS)
|
|
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
|
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
|
}
|
|
|
|
|
|
export function isSemaFamily(value: unknown): value is SemaFamily {
|
|
|
return typeof value === 'string' && FAMILY_SET.has(value as SemaFamily)
|
|
|
}
|
|
|
|
|
|
export function isSemaValencedFamily(value: unknown): value is SemaValencedFamily {
|
|
|
return typeof value === 'string' && VALENCED_FAMILY_SET.has(value as SemaValencedFamily)
|
|
|
}
|
|
|
|
|
|
export function isSemaTransitionalFamily(value: unknown): value is SemaTransitionalFamily {
|
|
|
return typeof value === 'string' && TRANSITIONAL_FAMILY_SET.has(value as SemaTransitionalFamily)
|
|
|
}
|
|
|
|
|
|
export function isIntent(value: unknown): value is Intent {
|
|
|
return typeof value === 'string' && INTENT_SET.has(value as Intent)
|
|
|
}
|
|
|
|
|
|
export function isSemaEventLabel(value: unknown): value is SemaEventLabel {
|
|
|
return typeof value === 'string' && LABEL_SET.has(value as SemaEventKey)
|
|
|
}
|
|
|
|
|
|
export function isIntentBinding(value: unknown): value is IntentBinding {
|
|
|
if (!isRecord(value)) 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(isIntent)) return false
|
|
|
}
|
|
|
return true
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* Validate the runtime shape of a `SemaEvent` against the per-family
|
|
|
* `SEMA_FAMILY_POLICY`:
|
|
|
*
|
|
|
* - `expected` policy → intent MUST be a valid literal or binding.
|
|
|
* - `allowed` / `optional` policy → intent may be absent; if present,
|
|
|
* must still be a valid literal or binding.
|
|
|
*
|
|
|
* Replaces the previous valenced/transitional split (which forbade
|
|
|
* intent on transitional families). Post-canon doctrine: any family can
|
|
|
* carry intent unless its policy says otherwise.
|
|
|
*/
|
|
|
export function isSemaEvent(value: unknown): value is SemaEvent {
|
|
|
if (!isRecord(value) || !isSemaFamily(value.family)) return false
|
|
|
|
|
|
const policy = SEMA_FAMILY_POLICY[value.family].intentPolicy
|
|
|
const intentValue = 'intent' in value ? value.intent : undefined
|
|
|
|
|
|
if (intentValue === undefined) {
|
|
|
return policy !== 'expected'
|
|
|
}
|
|
|
|
|
|
return isIntent(intentValue) || isIntentBinding(intentValue)
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* Parsed shape of a `SemaEventKey`. With the policy reform, ANY family
|
|
|
* may carry an intent (or none) — the parsed shape only distinguishes
|
|
|
* "intent present" from "intent absent" without locking the family kind.
|
|
|
*/
|
|
|
export type ParsedSemaEventLabel =
|
|
|
| { family: SemaFamily }
|
|
|
| { family: SemaFamily; intent: Intent }
|
|
|
|
|
|
export function parseSemaEventLabel(label: SemaEventLabel): ParsedSemaEventLabel {
|
|
|
const [family, rawIntent] = label.split('-')
|
|
|
if (rawIntent === undefined) {
|
|
|
return { family: family as SemaFamily }
|
|
|
}
|
|
|
return {
|
|
|
family: family as SemaFamily,
|
|
|
intent: rawIntent as Intent
|
|
|
}
|
|
|
}
|
|
|
|
|
|
export function resolveIntent(
|
|
|
intent: Intent | IntentBinding,
|
|
|
props?: Record<string, unknown>
|
|
|
): Intent {
|
|
|
if (isIntent(intent)) return intent
|
|
|
|
|
|
const supported = intent.supported?.filter(isIntent) ?? [...INTENTS]
|
|
|
const fallback = supported[0] ?? intent.default
|
|
|
const candidate = intent.fromProp ? props?.[intent.fromProp] : undefined
|
|
|
|
|
|
if (isIntent(candidate) && supported.includes(candidate)) {
|
|
|
return candidate
|
|
|
}
|
|
|
|
|
|
return supported.includes(intent.default) ? intent.default : fallback
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* Normalize a `SemaActionEvent` (label or structured event) into its
|
|
|
* canonical resolved form. Preserves intent on ANY family that carries
|
|
|
* one — the previous "transitional families never carry intent" rule
|
|
|
* was the bug Phase 2 of the codex refactor fixes.
|
|
|
*/
|
|
|
export function normalizeSemaEvent(
|
|
|
event: SemaActionEvent,
|
|
|
props?: Record<string, unknown>
|
|
|
): {
|
|
|
family: SemaFamily
|
|
|
intent: Intent | null
|
|
|
label: SemaEventLabel
|
|
|
} {
|
|
|
if (typeof event === 'string') {
|
|
|
const parsed = parseSemaEventLabel(event)
|
|
|
if ('intent' in parsed) {
|
|
|
return {
|
|
|
family: parsed.family,
|
|
|
intent: parsed.intent,
|
|
|
label: event
|
|
|
}
|
|
|
}
|
|
|
return {
|
|
|
family: parsed.family,
|
|
|
intent: null,
|
|
|
label: event
|
|
|
}
|
|
|
}
|
|
|
|
|
|
const intentValue = 'intent' in event ? event.intent : undefined
|
|
|
if (intentValue === undefined) {
|
|
|
return {
|
|
|
family: event.family,
|
|
|
intent: null,
|
|
|
label: event.family as SemaEventLabel
|
|
|
}
|
|
|
}
|
|
|
|
|
|
const intent = resolveIntent(intentValue, props)
|
|
|
return {
|
|
|
family: event.family,
|
|
|
intent,
|
|
|
label: `${event.family}-${intent}` as SemaEventLabel
|
|
|
}
|
|
|
}
|
|
|
|
|
|
export function toSemaEventLabel(event: SemaActionEvent, props?: Record<string, unknown>): SemaEventLabel {
|
|
|
return normalizeSemaEvent(event, props).label
|
|
|
}
|