13 KiB
Capability Spec Delta: fsm (fsm-completion)
Package: @suaveplan/fsm
Status: PROPOSED
Every requirement below carries a
**Verified by:**line at authoring time. No exceptions. (Seeopenspec/AGENTS.mdSection 13.)
ADDED Requirements
Requirement: Hierarchical FSM with nested states
The package SHALL provide a HierarchicalStateMachine<TEvent, TContext> class implementing UML-statechart nested-state semantics. State paths are addressed as dot-separated strings (e.g. "active.editing.text"); on a transition the machine SHALL exit nested states from leaf upward to the deepest common ancestor of source and target paths, run the transition action, then enter from the common ancestor down to the new leaf, executing each node's onEnter / onExit hook in that order. Initial child states resolve recursively via each node's initial property.
Scenario: nested initial path resolves on construction
- WHEN a config defines
states.active.children.editing.children.textwithinitialchained at each level - THEN
machine.statePathreturns"active.editing.text"immediately after construction andrunEnterChainfiresonEnterforactive,active.editing,active.editing.textin that order
Scenario: cross-boundary transition exits to common ancestor
- WHEN the machine is in
"active.editing.text"and a transition's target is"active.viewing" - THEN
onExitfires foractive.editing.textthenactive.editing, the transition action runs, thenonEnterfires foractive.viewing
Scenario: guard blocks transition without modifying state
- WHEN a transition's
guardreturnsfalse - THEN
send()returns{ success: false, error: 'Guard blocked event "<event>"' }andstatePathis unchanged
Verified by: src/hierarchy/hierarchical-fsm.test.ts::"resolves the nested initial path on construction" AND src/hierarchy/hierarchical-fsm.test.ts::"exits to the common ancestor on cross-boundary transitions" AND src/hierarchy/hierarchical-fsm.spec-conformance.test.ts::"orders enter/exit hooks per UML statechart contract"
Requirement: Parallel regions with independent active state per region
The package SHALL provide a ParallelStateMachine<TState, TEvent, TContext> class supporting multiple orthogonal regions. Each region holds its own active state but shares one TContext. The send(event) method SHALL dispatch the event to every region in declaration order; regions whose current state matches a transition from AND whose guard passes SHALL transition independently. The result reports the affected region IDs. Sync points fire a configured event when all listed regions reach their listed states; sync-point listeners that throw SHALL route through an injected LoggerLike.warn rather than being silently swallowed.
Scenario: regions transition independently on the same event
- WHEN two regions A and B both have a transition from their initial state on event
"X" - THEN after
send("X")both region states update,result.affectedRegionscontains both IDs, and the sharedcontextreflects merged action mutations
Scenario: sendToRegionById scopes the event to a single region
- WHEN
sendToRegionById("A", "X")is called - THEN only region A's transitions are evaluated; region B's state is unchanged
Scenario: unknown region raises FSMError
- WHEN
sendToRegionById("does-not-exist", ...)is called - THEN the call throws
FSMErrorwith codeFSM_CODES.UNKNOWN_REGION
Scenario: sync point fires when all conditions are met
- WHEN all regions reach the states listed in a
SyncPoint.conditions - THEN every subscribed
onSynclistener is invoked with the sync point'sevent
Verified by: src/parallel/parallel-fsm.test.ts::"transitions all regions that handle the event" AND src/parallel/parallel-fsm.test.ts::"sendToRegionById scopes to one region" AND src/parallel/parallel-fsm.spec-conformance.test.ts::"throws FSM_CODES.UNKNOWN_REGION for unknown regionId" AND src/parallel/parallel-fsm.test.ts::"fires sync point listeners when matchesAll conditions hold"
Requirement: History states (shallow and deep)
The package SHALL provide a HistoryStateManager class that records the last-seen child state for a registered parent path. register(parentPath, { type, defaultState }) accepts type: 'shallow' (remember immediate child) or type: 'deep' (remember full nested path). record(parentPath, childState) stamps the entry with Clock.now() from an injected @suaveplan/types::Clock. resolve(parentPath) returns the remembered state if present, otherwise the configured defaultState. snapshot() and restore(data) round-trip all entries; restore SHALL drop any keys whose parentPath is not currently registered.
Scenario: shallow history remembers the last immediate child
- WHEN
register("menu", { type: "shallow", defaultState: "main" })is called andrecord("menu", "settings")is then called - THEN
resolve("menu")returns"settings"
Scenario: default fallback when no history recorded
- WHEN
register("menu", { type: "shallow", defaultState: "main" })is called and norecordfollows - THEN
resolve("menu")returns"main"
Scenario: clear-on-trigger drops history entry
- WHEN
addClearTrigger("menu", "settings", "exit")is registered,record("menu", "settings")is called, thenprocessTransition("settings", "exit")is called - THEN
has("menu")returnsfalse
Scenario: snapshot/restore preserves only registered keys
- WHEN
restore({ unregistered: { state: "x", timestamp: 0 }, menu: { state: "settings", timestamp: 1 } })is called and only"menu"is registered - THEN
resolve("menu")returns"settings"andhas("unregistered")returnsfalse
Scenario: invalid history type raises FSMError
- WHEN
register("menu", { type: "wrong" as HistoryType, defaultState: "main" })is called - THEN the call throws
FSMErrorwith codeFSM_CODES.INVALID_HISTORY_TYPE
Verified by: src/history/history-state.test.ts::"shallow history remembers the last immediate child" AND src/history/history-state.test.ts::"falls back to defaultState when no history recorded" AND src/history/history-state.test.ts::"clear trigger drops history on matching transition" AND src/history/history-state.spec-conformance.test.ts::"restore drops keys not registered in current configuration" AND src/history/history-state.spec-conformance.test.ts::"throws FSM_CODES.INVALID_HISTORY_TYPE for unknown HistoryType"
Requirement: Deferred event queue with replay-on-handle
The package SHALL provide a DeferredEventQueue<TState, TEvent, TContext> class that holds events the current state cannot handle and replays them after the next successful transition. configureState(state, { defer }) registers events to defer per state. send(machine, event) SHALL enqueue (returning null) if shouldDefer(currentState, event) is true, else call machine.send(event) and on success drain the queue in FIFO order. The queue is bounded by DeferredQueueConfig.maxSize (default 100). The overflow config SHALL accept 'drop-newest' | 'drop-oldest' | 'throw'; on 'throw', exceeding capacity raises FSMError(FSM_CODES.DEFERRED_QUEUE_OVERFLOW, ...). Timestamps SHALL come from an injected Clock. The processQueue method SHALL guard against re-entrancy via an internal _processing flag.
Scenario: event deferred when state is configured to defer it
- WHEN the machine is in state
Sconfigured to defer eventE, andqueue.send(machine, E)is called - THEN the call returns
null,queue.sizeis1, andmachine.stateis unchanged
Scenario: replay drains the queue after a successful transition
- WHEN event
Eis deferred inS, the machine then transitions out ofStoT(whereTdoes not deferE), andqueue.processQueue(machine)is called - THEN
machine.send(E)is invoked once, the queue is empty, and the returned array contains the transition result
Scenario: max-size with drop-oldest evicts the head
- WHEN
maxSize: 2,overflow: 'drop-oldest', and three events are enqueued - THEN the oldest event is dropped,
queue.sizeis2, and the head of the queue is the second-enqueued event
Scenario: overflow throw raises FSMError
- WHEN
maxSize: 1,overflow: 'throw', and a secondenqueueis attempted - THEN
FSMErrorwith codeFSM_CODES.DEFERRED_QUEUE_OVERFLOWis thrown
Scenario: re-entrant processQueue is a no-op
- WHEN
processQueueis called recursively (e.g. from a transition action that calls back into the queue) - THEN the inner call returns an empty array and the outer call drains the queue exactly once
Verified by: src/deferred/deferred-queue.test.ts::"defers events configured for the current state" AND src/deferred/deferred-queue.test.ts::"replays queued events in FIFO order on successful transition" AND src/deferred/deferred-queue.spec-conformance.test.ts::"drop-oldest overflow evicts the head when capacity is reached" AND src/deferred/deferred-queue.spec-conformance.test.ts::"throw overflow raises FSM_CODES.DEFERRED_QUEUE_OVERFLOW" AND src/deferred/deferred-queue.test.ts::"re-entrant processQueue returns empty array"
Requirement: Per-state lifecycle hooks on FSMInterpreter
The FSMInterpreter SHALL expose onEnter(state, listener) and onExit(state, listener) subscription methods that fire after a state's entry action and before a state's exit action. Each method returns a disposer; calling the disposer SHALL remove the listener idempotently. Both channels emit enter / exit events on the existing internal EventBus so external observers can subscribe directly. The existing entry / exit action contract (returning a new context to replace it) SHALL be preserved unchanged.
Scenario: onEnter fires after entry action
- WHEN state
Shas both anentryaction that mutatescontextand anonEnterlistener registered - THEN the
entryaction runs first, then theonEnterlistener observes the mutatedcontext
Scenario: onExit fires before exit action
- WHEN state
Shas both anexitaction and anonExitlistener registered, and the machine transitions out ofS - THEN the
onExitlistener is invoked before theexitaction mutates context
Scenario: disposer removes the listener idempotently
- WHEN the disposer returned by
onEnter('S', listener)is called twice - THEN the listener is removed exactly once and the second call is a no-op (no error thrown)
Verified by: src/core/interpreter/fsm-interpreter.lifecycle.test.ts::"onEnter listener fires after entry action with mutated context" AND src/core/interpreter/fsm-interpreter.lifecycle.test.ts::"onExit listener fires before exit action" AND src/core/interpreter/fsm-interpreter.lifecycle.test.ts::"disposer is idempotent on repeated invocation"
Requirement: Visualization exporters — toDot and toMermaid
The package SHALL provide two pure functions that serialize an FSMDefinition<TContext> to human-readable diagram syntax:
toMermaid(definition, options?)produces a MermaidstateDiagram-v2document.toDot(definition, options?)produces a Graphviz DOT digraph.
Both SHALL accept VisualizerOptions with currentState?: StateValue, showGuards?: boolean, showActions?: boolean, direction?: 'LR' | 'TD'. Both SHALL walk the genesis FSMDefinition.states: Record<StateValue, StateDefinition> shape recursively, emit one edge per (state, event, target) triple, label edges with event, optionally annotate with [guard] / / action markers, and highlight currentState if provided. DOT output SHALL escape " and \ in labels.
Scenario: toMermaid emits a parseable stateDiagram-v2
- WHEN
toMermaid(definition)is called with a non-empty definition - THEN the output starts with
stateDiagram-v2, contains[*] --> <initial>, and contains one<from> --> <to> : <event>line per transition
Scenario: toDot escapes special characters in labels
- WHEN an event name contains
"(e.g.'click "X"') - THEN the corresponding DOT edge label is rendered with
\"escaping
Scenario: showGuards = false suppresses guard annotation
- WHEN a transition has a guard and
showGuards: falseis set - THEN the rendered label is exactly the event name with no
[guard]suffix
Scenario: currentState highlight in toDot
- WHEN
toDot(definition, { currentState: 'active' })is called - THEN the
"active"node line includesstyle="rounded,filled", fillcolor="#ffccff"
Verified by: src/visualization/fsm-visualizer.test.ts::"toMermaid emits valid stateDiagram-v2" AND src/visualization/fsm-visualizer.spec-conformance.test.ts::"toDot escapes quotes in edge labels" AND src/visualization/fsm-visualizer.spec-conformance.test.ts::"showGuards: false suppresses [guard] annotation" AND src/visualization/fsm-visualizer.test.ts::"toDot highlights currentState with fillcolor"