Move docs/ package mirror into document-navigator plugin
Relocate the ~6k-file docs tree from the repo root into plugins/document-navigator/docs/ so the plugin is self-contained and portable. docs/superpowers/ (specs and plans) stays at the repo root. Moved via cp+rm rather than git mv because directory rename() fails with EPERM on this WSL /mnt DrvFs mount; git rename-detection pairs the identical-content files at commit time. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
# fsm-interpreter
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
The `FSMInterpreter` class is the runtime engine for `@suaveplan/fsm`. It accepts a validated `FSMDefinition`, manages the current state value and extended context, executes entry/exit/transition actions, records a full immutable history of every transition taken, and emits `transition` events via an internal `EventBus` for reactive subscriptions.
|
||||
|
||||
`createMachine` is the primary factory function and validates the definition eagerly during construction, so malformed definitions are caught before any events are processed.
|
||||
|
||||
## Features
|
||||
|
||||
- Type-safe state transitions with generic `TContext` extended context
|
||||
- Entry and exit lifecycle actions that may return an updated context
|
||||
- Ordered transition action arrays with accumulated context threading
|
||||
- Guard predicates on each transition candidate with first-match semantics
|
||||
- Immutable `FSMState<TContext>` snapshots returned from every `send()` call
|
||||
- Full `HistoryEntry[]` log with `from`, `event`, `to`, and `timestamp`
|
||||
- `done: true` flag set automatically when entering a `type: 'final'` state
|
||||
- `onTransition(listener)` subscription with typed state snapshots
|
||||
- `reset()` returns to initial state and clears history
|
||||
- OTel counter `fsm.transition.total` and histogram `fsm.transition.duration_ms`
|
||||
- Graceful no-op when OTel is not available in the environment
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { createMachine } from '@suaveplan/fsm';
|
||||
|
||||
const machine = createMachine({
|
||||
id: 'traffic',
|
||||
initial: 'red',
|
||||
context: { count: 0 },
|
||||
states: {
|
||||
red: { on: { NEXT: { target: 'green' } } },
|
||||
green: { on: { NEXT: { target: 'yellow' } } },
|
||||
yellow: {
|
||||
on: {
|
||||
NEXT: {
|
||||
target: 'red',
|
||||
actions: [ctx => ({ count: ctx.count + 1 })],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
machine.send('NEXT'); // red → green
|
||||
machine.send('NEXT'); // green → yellow
|
||||
const state = machine.send('NEXT'); // yellow → red
|
||||
console.log(state.context.count); // 1
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { createMachine } from '@suaveplan/fsm';
|
||||
import { assign } from '@suaveplan/fsm';
|
||||
|
||||
interface AuthCtx {
|
||||
user: string | null;
|
||||
attempts: number;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const authMachine = createMachine<AuthCtx>({
|
||||
id: 'auth',
|
||||
initial: 'idle',
|
||||
context: { user: null, attempts: 0, error: null },
|
||||
states: {
|
||||
idle: {
|
||||
on: {
|
||||
LOGIN: { target: 'authenticating' },
|
||||
},
|
||||
},
|
||||
authenticating: {
|
||||
entry: assign<AuthCtx>(ctx => ({ ...ctx, error: null })),
|
||||
on: {
|
||||
SUCCESS: {
|
||||
target: 'authenticated',
|
||||
actions: [assign<AuthCtx>((ctx) => ({ ...ctx, user: 'alice', attempts: 0 }))],
|
||||
},
|
||||
FAILURE: [
|
||||
{
|
||||
target: 'locked',
|
||||
guard: (ctx) => ctx.attempts >= 2,
|
||||
actions: [assign<AuthCtx>(ctx => ({ ...ctx, attempts: ctx.attempts + 1 }))],
|
||||
},
|
||||
{
|
||||
target: 'idle',
|
||||
actions: [assign<AuthCtx>(ctx => ({
|
||||
...ctx,
|
||||
attempts: ctx.attempts + 1,
|
||||
error: 'Invalid credentials',
|
||||
}))],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
authenticated: { type: 'final' },
|
||||
locked: { type: 'final' },
|
||||
},
|
||||
});
|
||||
|
||||
const unsub = authMachine.onTransition(state => {
|
||||
console.log('Transitioned to:', state.value, 'done:', state.done);
|
||||
});
|
||||
|
||||
authMachine.send('LOGIN');
|
||||
authMachine.send({ type: 'FAILURE' });
|
||||
authMachine.send('LOGIN');
|
||||
authMachine.send('FAILURE');
|
||||
authMachine.send('LOGIN');
|
||||
const final = authMachine.send('FAILURE'); // → locked (guard: attempts >= 2)
|
||||
console.log(final.done); // true
|
||||
|
||||
unsub(); // unsubscribe listener
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class FSMInterpreter<TContext>`
|
||||
|
||||
#### Constructor
|
||||
|
||||
```ts
|
||||
new FSMInterpreter(definition: FSMDefinition<TContext>)
|
||||
```
|
||||
|
||||
Validates `definition` via `validateDefinition` on construction. Throws `FSMError(INVALID_DEFINITION)` for structural errors.
|
||||
|
||||
#### `get state(): FSMState<TContext>`
|
||||
|
||||
Returns an immutable snapshot with `value`, `context`, `history`, and `done`.
|
||||
|
||||
#### `get current(): StateValue`
|
||||
|
||||
Shorthand for `state.value`.
|
||||
|
||||
#### `send(event: FSMEvent | EventType): FSMState<TContext>`
|
||||
|
||||
Processes an event against the current state. Returns the new snapshot.
|
||||
|
||||
**Throws:**
|
||||
|
||||
- `FSMError(INVALID_TRANSITION)` — no matching (unguarded) transition exists
|
||||
- `FSMError(ACTION_ERROR)` — an action or lifecycle function threw
|
||||
- `FSMError(INVALID_STATE)` — current state not found in definition (should not happen post-construction)
|
||||
|
||||
#### `matches(state: StateValue): boolean`
|
||||
|
||||
Returns `true` if the current state value equals `state`.
|
||||
|
||||
#### `can(eventType: EventType): boolean`
|
||||
|
||||
Returns `true` if at least one transition for `eventType` is declared in the current state (regardless of guard outcome).
|
||||
|
||||
#### `reset(): void`
|
||||
|
||||
Resets to `definition.initial`, restores original `definition.context`, clears history.
|
||||
|
||||
#### `onTransition(listener): () => void`
|
||||
|
||||
Registers a synchronous listener called after each successful transition. Returns an unsubscribe function.
|
||||
|
||||
#### `toJSON(): FSMDefinition<TContext>`
|
||||
|
||||
Returns a serializable definition snapshot. Function-valued guards and actions are included as-is (not serializable to JSON text), but the shape is correct for in-memory round-tripping.
|
||||
|
||||
### `createMachine<TContext>(definition): FSMInterpreter<TContext>`
|
||||
|
||||
Factory function. Equivalent to `new FSMInterpreter(definition)`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
OTel integration uses a dynamic `import('@opentelemetry/api')` wrapped in a try/catch. If OTel is absent from the environment, the import silently fails and metric recording is skipped. Meters and instruments are created per `send()` call via `getMeter().createCounter(...)` — this is safe because the OTel SDK caches instrument instances by name.
|
||||
|
||||
Context accumulation across multiple transition actions threads the result of each action into the next: if action A returns a new context, action B receives that new context rather than the original. This matches XState's "assign" pipeline semantics.
|
||||
|
||||
The internal `EventBus` is typed with `FSMEventMap<TContext>` to ensure the `transition` event payload is always a `FSMState<TContext>`.
|
||||
@@ -0,0 +1,106 @@
|
||||
# fsm-error
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Defines the stable error-code registry and typed error class for the `@suaveplan/fsm` package. Every error thrown by the FSM interpreter, definition validator, and context helpers extends `FSMError`, giving callers a single `instanceof` check and a machine-readable `code` property drawn from `FSM_CODES`.
|
||||
|
||||
The registry is built with `defineCodes` from `@suaveplan/error`, which guarantees that each code string is unique across the entire `@suaveplan/*` ecosystem and can be compared with `===` without string literals.
|
||||
|
||||
## Features
|
||||
|
||||
- Five stable error codes covering all failure scenarios: `INVALID_DEFINITION`, `INVALID_TRANSITION`, `INVALID_STATE`, `GUARD_FAILED`, `ACTION_ERROR`
|
||||
- `FSMError` extends `SuaveplanError<FSMCode>` for narrow discriminated-union typing
|
||||
- Supports optional `cause` and `context` metadata on every error instance
|
||||
- Fully compatible with the `@suaveplan/error` wire-format serialization
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { FSMError, FSM_CODES } from '@suaveplan/fsm/errors';
|
||||
|
||||
try {
|
||||
machine.send('UNKNOWN_EVENT');
|
||||
} catch (err) {
|
||||
if (err instanceof FSMError) {
|
||||
console.error(err.code, err.message);
|
||||
// "FSM.INVALID_TRANSITION" "No valid transition for event in current state"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { FSMError, FSM_CODES } from '@suaveplan/fsm/errors';
|
||||
|
||||
function handleFSMError(err: unknown): void {
|
||||
if (!(err instanceof FSMError)) throw err;
|
||||
|
||||
switch (err.code) {
|
||||
case FSM_CODES.INVALID_DEFINITION:
|
||||
console.error('Machine definition is malformed:', err.message);
|
||||
break;
|
||||
case FSM_CODES.INVALID_TRANSITION:
|
||||
console.warn('Event not handled in current state');
|
||||
break;
|
||||
case FSM_CODES.GUARD_FAILED:
|
||||
console.info('Transition blocked by guard');
|
||||
break;
|
||||
case FSM_CODES.ACTION_ERROR:
|
||||
console.error('Action threw during transition:', err.cause);
|
||||
break;
|
||||
default:
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `FSM_CODES`
|
||||
|
||||
```ts
|
||||
const FSM_CODES: {
|
||||
INVALID_DEFINITION: 'FSM.INVALID_DEFINITION';
|
||||
INVALID_TRANSITION: 'FSM.INVALID_TRANSITION';
|
||||
INVALID_STATE: 'FSM.INVALID_STATE';
|
||||
GUARD_FAILED: 'FSM.GUARD_FAILED';
|
||||
ACTION_ERROR: 'FSM.ACTION_ERROR';
|
||||
}
|
||||
```
|
||||
|
||||
Stable string constants for each FSM error domain. Use these instead of raw string comparisons to benefit from TypeScript narrowing.
|
||||
|
||||
### `type FSMCode`
|
||||
|
||||
Union type of all values in `FSM_CODES`. Used as the generic parameter of `FSMError`.
|
||||
|
||||
### `class FSMError`
|
||||
|
||||
```ts
|
||||
class FSMError extends SuaveplanError<FSMCode> {
|
||||
constructor(
|
||||
code: FSMCode,
|
||||
message: string,
|
||||
options?: { cause?: Error; context?: Record<string, unknown> }
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `code` — one of the `FSM_CODES` values; stored as `this.code`
|
||||
- `message` — human-readable description of the failure
|
||||
- `options.cause` — underlying error that triggered this one (chained)
|
||||
- `options.context` — arbitrary key/value metadata (state name, event type, etc.)
|
||||
|
||||
**Throws:** Never (constructor only).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `defineCodes` helper from `@suaveplan/error` namespaces all codes under `"FSM."` to prevent collisions with codes from other packages sharing the same registry. The `as const` assertion on the descriptor object ensures the resulting type is a fully-discriminated union rather than `string`.
|
||||
|
||||
The `noUselessConstructor` biome lint suppression is intentional: the constructor is required to widen the `protected` base constructor to `public`, which is a documented pattern in this repository for all `SuaveplanError` subclasses.
|
||||
@@ -0,0 +1,144 @@
|
||||
# fsm.types
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the complete TypeScript type vocabulary for `@suaveplan/fsm`. Every public interface, type alias, and generic constraint used by the interpreter, definition validator, context helpers, and package consumers lives here. Centralising types in one place prevents circular imports and makes the type contract auditable at a glance.
|
||||
|
||||
All types are pure TypeScript — no runtime code is emitted by this file.
|
||||
|
||||
## Features
|
||||
|
||||
- `StateValue` — opaque string alias for state node identifiers
|
||||
- `EventType` — opaque string alias for event discriminants
|
||||
- `FSMEvent<TPayload>` — typed event object sent to `send()` with optional payload
|
||||
- `HistoryEntry` — immutable record of a single state transition with timestamp
|
||||
- `FSMState<TContext>` — immutable snapshot of the machine's current state, context, history, and `done` flag
|
||||
- `TransitionDefinition<TContext>` — single transition edge with optional guard and ordered action array
|
||||
- `StateDefinition<TContext>` — full state node definition: entry/exit hooks, event-to-transition map, type, and nested states
|
||||
- `FSMDefinition<TContext>` — top-level machine definition passed to `createMachine()`
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
FSMDefinition,
|
||||
FSMEvent,
|
||||
FSMState,
|
||||
TransitionDefinition,
|
||||
} from '@suaveplan/fsm';
|
||||
|
||||
interface TrafficCtx { count: number }
|
||||
|
||||
const def: FSMDefinition<TrafficCtx> = {
|
||||
id: 'traffic',
|
||||
initial: 'red',
|
||||
context: { count: 0 },
|
||||
states: {
|
||||
red: { on: { NEXT: { target: 'green' } } },
|
||||
green: { on: { NEXT: { target: 'yellow' } } },
|
||||
yellow: { on: { NEXT: { target: 'red' } } },
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
FSMDefinition,
|
||||
StateDefinition,
|
||||
TransitionDefinition,
|
||||
FSMEvent,
|
||||
FSMState,
|
||||
HistoryEntry,
|
||||
} from '@suaveplan/fsm';
|
||||
|
||||
interface AuthCtx {
|
||||
user: string | null;
|
||||
attempts: number;
|
||||
}
|
||||
|
||||
// Typed transition with guard
|
||||
const loginTransition: TransitionDefinition<AuthCtx> = {
|
||||
target: 'authenticated',
|
||||
guard: (ctx, event: FSMEvent<{ token: string }>) =>
|
||||
typeof event.payload?.token === 'string',
|
||||
actions: [
|
||||
(ctx, event: FSMEvent<{ user: string }>) =>
|
||||
({ ...ctx, user: event.payload?.user ?? null }),
|
||||
],
|
||||
};
|
||||
|
||||
// Typed state with entry/exit
|
||||
const authState: StateDefinition<AuthCtx> = {
|
||||
entry: (ctx) => ({ ...ctx, attempts: 0 }),
|
||||
exit: (ctx) => ctx,
|
||||
on: { LOGIN: loginTransition },
|
||||
};
|
||||
|
||||
// Inspect the FSMState snapshot
|
||||
function onTransition(state: FSMState<AuthCtx>): void {
|
||||
const last: HistoryEntry | undefined = state.history[state.history.length - 1];
|
||||
console.log(`Transitioned from ${last?.from} to ${state.value}`);
|
||||
if (state.done) console.log('Machine is done');
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `type StateValue = string`
|
||||
|
||||
Opaque alias for state node names. Using a named alias rather than `string` makes function signatures self-documenting.
|
||||
|
||||
### `type EventType = string`
|
||||
|
||||
Opaque alias for event type discriminants.
|
||||
|
||||
### `interface FSMEvent<TPayload = unknown>`
|
||||
|
||||
- `type: EventType` — the event discriminant
|
||||
- `payload?: TPayload` — optional structured payload carried with the event
|
||||
|
||||
### `interface HistoryEntry`
|
||||
|
||||
- `from: StateValue` — state before transition
|
||||
- `event: FSMEvent` — the event that triggered the transition
|
||||
- `to: StateValue` — state after transition
|
||||
- `timestamp: number` — `Date.now()` at transition time
|
||||
|
||||
### `interface FSMState<TContext = unknown>`
|
||||
|
||||
- `value: StateValue` — current state name
|
||||
- `context: TContext` — extended context at snapshot time
|
||||
- `history: ReadonlyArray<HistoryEntry>` — full transition log (immutable copy)
|
||||
- `done: boolean` — `true` when current state has `type: 'final'`
|
||||
|
||||
### `interface TransitionDefinition<TContext = unknown>`
|
||||
|
||||
- `target: StateValue` — destination state
|
||||
- `guard?: (context, event) => boolean` — optional predicate; transition is skipped if it returns `false`
|
||||
- `actions?: Array<(context, event) => TContext | undefined>` — ordered action functions; returning a value replaces context
|
||||
|
||||
### `interface StateDefinition<TContext = unknown>`
|
||||
|
||||
- `entry?: (context, event) => TContext | undefined` — called on state entry
|
||||
- `exit?: (context, event) => TContext | undefined` — called before state exit
|
||||
- `on?: Record<EventType, TransitionDefinition | TransitionDefinition[]>` — event-to-transition map
|
||||
- `type?: 'final' | 'parallel'` — special state semantics
|
||||
- `states?: Record<StateValue, StateDefinition>` — nested child states
|
||||
- `initial?: StateValue` — required initial child (when `states` is defined)
|
||||
|
||||
### `interface FSMDefinition<TContext = unknown>`
|
||||
|
||||
- `id?: string` — optional identifier (used in OTel metric labels)
|
||||
- `initial: StateValue` — initial state (must exist in `states`)
|
||||
- `context: TContext` — initial extended context value
|
||||
- `states: Record<StateValue, StateDefinition<TContext>>` — top-level state map
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
All interfaces use `TContext = unknown` as the default generic parameter so that consumers who do not pass a context type get `unknown` rather than `any`, preserving type safety at call sites.
|
||||
|
||||
Action return types use `TContext | undefined` rather than `TContext | void` to satisfy Biome's `noConfusingVoidType` rule, which forbids `void` inside union types. In practice, returning `undefined` and returning nothing (implicit `void`) are semantically identical in the interpreter — both leave the context unchanged.
|
||||
|
||||
`ReadonlyArray<HistoryEntry>` on `FSMState.history` signals to consumers that they must not mutate the history array. The interpreter returns a fresh `slice()` on each `state` access to further enforce immutability.
|
||||
@@ -0,0 +1,107 @@
|
||||
# context-helpers
|
||||
|
||||
## Purpose
|
||||
|
||||
Provides ergonomic factory helpers for building context-update and side-effect actions that can be slotted directly into `TransitionDefinition.actions` arrays. Without these helpers, every action must be written as an explicit arrow function returning a new context, which becomes verbose for simple partial-update or logging scenarios.
|
||||
|
||||
The two helpers mirror the `assign` and `pure` patterns popularised by XState, adapted to the `@suaveplan/fsm` action signature.
|
||||
|
||||
## Features
|
||||
|
||||
- `assign(patch)` — shallow-merges a static partial object into the context
|
||||
- `assign(fn)` — delegates to a context-mapping function for complex derivations
|
||||
- `pure(fn)` — wraps a side-effect function ensuring its return value never corrupts the context
|
||||
- Both helpers are fully typed against the machine's `TContext` generic parameter
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { assign, pure, createMachine } from '@suaveplan/fsm';
|
||||
|
||||
interface Ctx { count: number; lastEvent: string }
|
||||
|
||||
const machine = createMachine<Ctx>({
|
||||
initial: 'idle',
|
||||
context: { count: 0, lastEvent: '' },
|
||||
states: {
|
||||
idle: {
|
||||
on: {
|
||||
INCREMENT: {
|
||||
target: 'idle',
|
||||
actions: [
|
||||
assign<Ctx>(ctx => ({ ...ctx, count: ctx.count + 1 })),
|
||||
pure<Ctx>((ctx, evt) => { console.log('incremented', ctx, evt); }),
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { assign } from '@suaveplan/fsm';
|
||||
|
||||
interface WizardCtx {
|
||||
step: number;
|
||||
formData: Record<string, string>;
|
||||
completed: boolean;
|
||||
}
|
||||
|
||||
// Static patch (shallow merge)
|
||||
const resetWizard = assign<WizardCtx>({ step: 1, completed: false });
|
||||
|
||||
// Functional update with derived logic
|
||||
const advanceStep = assign<WizardCtx>(ctx => ({
|
||||
...ctx,
|
||||
step: ctx.step + 1,
|
||||
completed: ctx.step + 1 >= 5,
|
||||
}));
|
||||
|
||||
// Compose them in a transition
|
||||
const machine = createMachine<WizardCtx>({
|
||||
initial: 'wizard',
|
||||
context: { step: 1, formData: {}, completed: false },
|
||||
states: {
|
||||
wizard: {
|
||||
on: {
|
||||
NEXT: { target: 'wizard', actions: [advanceStep] },
|
||||
RESET: { target: 'wizard', actions: [resetWizard] },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `assign<TContext>(updater): (context: TContext, event: FSMEvent) => TContext`
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `updater` — either a `Partial<TContext>` patch object or a function `(ctx: TContext) => TContext`
|
||||
|
||||
**Returns:** An action function compatible with `TransitionDefinition.actions`.
|
||||
|
||||
**Behaviour:**
|
||||
|
||||
- If `updater` is a plain object, it performs `{ ...context, ...updater }`.
|
||||
- If `updater` is a function, it calls `updater(context)` and returns the result.
|
||||
|
||||
### `pure<TContext>(fn): (context: TContext, event: FSMEvent) => void`
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `fn` — a `(context: TContext, event: FSMEvent) => void` side-effect function
|
||||
|
||||
**Returns:** A wrapped action function whose return value is always `void`.
|
||||
|
||||
**Behaviour:** Calls `fn(context, event)` and discards any return value. This prevents accidental context mutations from side-effect functions that inadvertently return a value.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`assign` accepts both object and function forms by checking `typeof updater === 'function'`. The cast `updater as (ctx: TContext) => TContext` is required because TypeScript cannot narrow a union of `Partial<TContext>` and `(ctx) => TContext` cleanly at the call site without the explicit cast.
|
||||
|
||||
`pure` exists as a safety wrapper rather than a direct alias because some developers write side-effect actions as `(ctx, evt) => doSomething()` where `doSomething` returns a non-void value. Without `pure`, that non-void value would be treated as a context replacement by the interpreter.
|
||||
@@ -0,0 +1,89 @@
|
||||
# definition-validator
|
||||
|
||||
## Purpose
|
||||
|
||||
Validates an `FSMDefinition` object before the interpreter begins executing. Early structural validation prevents subtle runtime bugs such as transitions leading to undefined states or machines that can never leave their initial state due to a misconfigured `initial` key.
|
||||
|
||||
The validator is called automatically by `FSMInterpreter`'s constructor and by `createMachine()`. It can also be called standalone to check definitions at authoring time, in build pipelines, or within test suites that assert that certain configurations are correct.
|
||||
|
||||
## Features
|
||||
|
||||
- Verifies the top-level `initial` state exists in `states`
|
||||
- Recursively collects all transition `target` references (including nested state trees) and confirms every target is a known state name
|
||||
- Ensures any state node that declares child `states` also provides an `initial` child key
|
||||
- Ensures that declared `initial` children actually exist within their parent's `states` map
|
||||
- Throws `FSMError` with code `FSM.INVALID_DEFINITION` and rich `context` metadata on any failure
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { validateDefinition } from '@suaveplan/fsm';
|
||||
|
||||
validateDefinition({
|
||||
initial: 'idle',
|
||||
context: {},
|
||||
states: {
|
||||
idle: { on: { START: { target: 'active' } } },
|
||||
active: { on: { STOP: { target: 'idle' } } },
|
||||
},
|
||||
});
|
||||
// No error — definition is valid
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { validateDefinition } from '@suaveplan/fsm';
|
||||
import { FSMError, FSM_CODES } from '@suaveplan/fsm/errors';
|
||||
|
||||
// Catch and inspect validation errors
|
||||
try {
|
||||
validateDefinition({
|
||||
initial: 'ghost', // "ghost" is not in states
|
||||
context: {},
|
||||
states: { idle: {} },
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof FSMError && err.code === FSM_CODES.INVALID_DEFINITION) {
|
||||
// err.context?.initial === 'ghost'
|
||||
console.error('Bad definition:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Hierarchical definition with nested initial
|
||||
validateDefinition({
|
||||
initial: 'form',
|
||||
context: { step: 1 },
|
||||
states: {
|
||||
form: {
|
||||
initial: 'step1',
|
||||
states: {
|
||||
step1: { on: { NEXT: { target: 'step2' } } },
|
||||
step2: { on: { BACK: { target: 'step1' } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `validateDefinition<TContext>(def: FSMDefinition<TContext>): void`
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `def` — the complete FSM definition to validate
|
||||
|
||||
**Returns:** `void` (pure side-effect: throws on invalid input)
|
||||
|
||||
**Throws:** `FSMError` with code `FSM.INVALID_DEFINITION` and a `context` object containing the offending state/target names.
|
||||
|
||||
**Generic parameter:**
|
||||
|
||||
- `TContext` — the machine's extended context type; used only for correct typing of the definition shape.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
State name collection is done with a recursive depth-first traversal of the `states` map. Both fully-qualified dotted names (e.g. `"form.step1"`) and short names (e.g. `"step1"`) are added to the lookup set so that authors can use either convention in their `target` strings.
|
||||
|
||||
The validator deliberately does **not** check guard function arity or action return types — those are runtime concerns enforced by TypeScript at compile time. The focus here is purely structural integrity of the state graph.
|
||||
Reference in New Issue
Block a user