# machina v7 — Complete Documentation > Focused finite state machine library for JavaScript and TypeScript. States in, states out. - **Repository**: https://github.com/ifandelse/machina.js - **npm**: `npm install machina` - **Version**: 7.0.0 - **License**: MIT --- ## Introduction machina is a focused finite state machine library for JavaScript and TypeScript. You define states, the inputs each state responds to, and the handlers that decide what happens — including whether to transition. That's the whole model. The library provides two factory functions. `createFsm` produces a single-client FSM that owns its own context object — use it when you have one instance of a thing. `createBehavioralFsm` produces a multi-client FSM where state is tracked per-client in a `WeakMap` — one FSM definition, any number of independent clients, no properties stamped on your objects. Both produce FSMs with the same core API. Handlers are plain functions (or string shorthand for simple transitions) that receive a `{ ctx, inputName, defer, emit }` args object. Return a state name to transition; return nothing to stay put. Lifecycle hooks (`_onEnter`, `_onExit`) run on entry and exit of each state. Child FSMs can be attached to parent states via `_child` for hierarchical behavior, with unhandled inputs bubbling up. When a handler can't deal with an input yet, `defer()` re-queues it for replay after the next transition. ### Design Philosophy machina was originally inspired by the `gen_fsm` behavior module from Erlang/OTP. The v6/v7 line is TypeScript-first. State names, input names, and transition targets are all inferred from the config object — no manual type parameters needed for `createFsm`. String shorthand targets (`timeout: "red"`), `initialState`, `handle()` inputs, and `defer({ until })` targets are validated against actual state keys at compile time — in every config shape — so a typo is a type error rather than a runtime surprise. ### How machina differs from XState XState is a comprehensive statechart framework with actors, spawning, inspection tools, and SCXML compatibility. machina is for when you want a state machine (or a hierarchy of them) and nothing else. No actors, no invocations, no spawning. Just states, inputs, transitions, and the occasional child FSM. --- ## Concepts A finite state machine is a model that is always in exactly one state, transitions between states in response to inputs, and can run logic on entry and exit. ### Synchronous by Design machina's methods — `handle()`, `transition()`, `canHandle()`, `currentState()` — are all synchronous. When you call `handle("timeout")`, the entire transition sequence (exit → state change → enter → deferred replay) completes before `handle()` returns. This doesn't mean you can't use machina in async workflows. The pattern is: a handler kicks off async work (a fetch, a timer, a promise), and when that work resolves, it feeds a new input back into the FSM. The FSM stays synchronous; the async world calls back in. ```ts checking: { _onEnter({ ctx }) { fetch("/health") .then(res => fsm.handle(res.ok ? "passed" : "failed")) .catch(() => fsm.handle("failed")); }, passed: "online", failed: "offline", } ``` ### States An FSM is always in exactly one state. States are defined as keys of the `states` object in your config. TypeScript infers state names as string literals. ```ts const light = createFsm({ id: "traffic-light", initialState: "green", context: { tickCount: 0 }, states: { green: { /* ... */ }, yellow: { /* ... */ }, red: { /* ... */ }, }, }); ``` ### Inputs An input is a named signal dispatched to the FSM via `handle(inputName, ...args)`. The FSM looks up the current state's handler for that input name and runs it. ```ts light.handle("timeout"); light.handle("tick", { source: "timer" }); ``` If the current state has no handler for that input name and no wildcard handler, machina fires a `nohandler` event. ### Handlers A handler is a property on a state object. There are two forms: **Function handler** — receives a `HandlerArgs` object plus any extra args. Return a state name to transition; return nothing to stay put. ```ts green: { timeout({ ctx }) { if (ctx.tickCount >= 3) { return "yellow"; // transition } // return nothing → stay in green }, tick({ ctx }) { ctx.tickCount++; // side effect, no transition }, } ``` The `HandlerArgs` object: | Property | What it is | | ----------- | --------------------------------------------------------- | | `ctx` | The mutable context object (or client, for BehavioralFsm) | | `inputName` | The name of the input being handled | | `defer` | Function to defer this input for later replay | | `emit` | Function to emit a custom event | **String shorthand** — always transitions to the named state. `timeout: "yellow"` is exactly equivalent to `timeout() { return "yellow"; }`. **Wildcard handler** — `"*"` matches any input not handled by a named handler in that state. ```ts green: { timeout: "yellow", "*"({ inputName }) { console.log(`unhandled in green: ${inputName}`); }, } ``` ### Transitions When a handler returns a state name, machina runs the transition sequence: 1. Calls `_onExit` on the current state (if defined) 2. Emits a `transitioning` event: `{ fromState, toState }` 3. Updates the current state 4. Calls `_onEnter` on the new state (if defined) 5. Emits a `transitioned` event: `{ fromState, toState }` 6. Replays any deferred inputs targeting the new state If `_onEnter` returns a state name, machina immediately begins another transition — a "bounce." ### Context Context is the mutable data object available to all handlers as `ctx`. For `createFsm`, you provide context in the config: ```ts createFsm({ context: { tickCount: 0, lastEvent: null as string | null }, // ctx is inferred as { tickCount: number; lastEvent: string | null } }); ``` For `createBehavioralFsm`, there is no separate context — the client object itself IS the context. ### Lifecycle Hooks **`_onEnter`** — called when the FSM enters a state. Can return a state name to immediately transition again (a "bounce"). **`_onExit`** — called when the FSM leaves a state. Cannot trigger a transition — return values are ignored. ### Disposal `dispose()` permanently shuts down an FSM. All subsequent method calls become silent no-ops. Cascades to child FSMs by default. Pass `{ preserveChildren: true }` to prevent this. --- ## createFsm `createFsm` is the standard choice for most use cases. One config object produces one FSM instance with one internal context object. ### Config ```ts createFsm({ id: "traffic-light", // identifier for lifecycle events initialState: "green", // must match a key in states context: { tickCount: 0 }, // mutable data passed to handlers as ctx states: { ... }, // map of state names to handler definitions }); ``` | Property | Type | Description | | -------------- | -------- | ---------------------------------------------------------------------------------------------- | | `id` | `string` | Identifier for this FSM. Appears in lifecycle event payloads. | | `initialState` | `string` | The state the FSM boots into. Must match a key in `states`. | | `context` | `object` | Mutable data scoped to this FSM. Passed to every handler as `ctx`. Omit for context-free FSMs. | | `states` | `object` | Map of state names to handler definitions. Keys become the state name union. | ### State Handler Keys | Key | Type | Description | | ------------- | ---------------------- | --------------------------------------------------------------------- | | `_onEnter` | `HandlerFn` | Lifecycle hook — called when entering this state. | | `_onExit` | `HandlerFn` | Lifecycle hook — called when exiting this state. | | `_child` | `Fsm \| BehavioralFsm` | Child FSM — inputs delegated here first; unhandled inputs bubble up. | | `"*"` | `HandlerFn` | Catch-all — handles any input not explicitly defined. | | anything else | `string \| HandlerFn` | Named input handler. String = always transition; function = dynamic. | ### Public API | Method | Description | | ---------------------------- | ---------------------------------------------------------------------------------------- | | `handle(inputName, ...args)` | Dispatch an input to the current state's handler. | | `canHandle(inputName)` | `true` if the current state — or its `_child` chain — can handle this input. | | `transition(toState)` | Directly transition; fires `_onExit`, `_onEnter`, and lifecycle events. | | `reset()` | Transition back to `initialState`. | | `currentState()` | Returns the current state name. | | `compositeState()` | Dot-delimited path including active child FSM states. | | `on(eventName, callback)` | Subscribe to a lifecycle event. Returns `{ off() }`. | | `emit(eventName, data?)` | Emit a custom event through the FSM to all `on()` subscribers. | | `dispose(options?)` | Permanently shut down. Cascades to child FSMs unless `{ preserveChildren: true }`. | ### Full Example ```ts import { createFsm } from "machina"; const light = createFsm({ id: "traffic-light", initialState: "green", context: { tickCount: 0 }, states: { green: { _onEnter({ ctx }) { ctx.tickCount = 0; }, tick({ ctx }) { ctx.tickCount++; }, timeout({ ctx }) { if (ctx.tickCount >= 5) { return "yellow"; } }, }, yellow: { timeout: "red", }, red: { timeout: "green", }, }, }); const sub = light.on("transitioned", ({ fromState, toState }) => { console.log(`${fromState} -> ${toState}`); }); for (let i = 0; i < 5; i++) { light.handle("tick"); } light.handle("timeout"); // green -> yellow light.handle("timeout"); // yellow -> red light.currentState(); // "red" light.canHandle("tick"); // false sub.off(); light.reset(); light.dispose(); ``` ### TypeScript No manual type parameters needed. TypeScript infers context type from `config.context`, state names from `config.states` keys, and input names from handler keys. ```ts light.handle("bogus"); // compile error — not a valid input light.transition("yellw"); // compile error — not a valid state ``` `StateNamesOf` and `InputNamesOf` utility types extract inferred types from a states config when needed explicitly. --- ## createBehavioralFsm `createBehavioralFsm` defines states and transitions once, then applies that behavior to any number of independent client objects. Per-client state is tracked in a `WeakMap` — nothing is stamped onto the client object. The client IS the context. ### When to Use It - Many instances share the same state machine logic — network connections, game entities, UI components - You don't want the FSM to modify the client object's shape - The client already exists and you want to layer FSM behavior onto it ### Config Same as `createFsm` without the `context` property. The client type can't be inferred from the config, so it's provided via a curried call — `createBehavioralFsm()` fixes the client type, and the returned function infers everything else from the config: ```ts const connFsm = createBehavioralFsm()({ id: "connectivity", initialState: "disconnected", states: { ... }, }); ``` ### API Every method takes the client object as its first argument: | Method | Description | | ------------------------------------ | ----------------------------------------------------------------------- | | `handle(client, inputName, ...args)` | Dispatch an input to the client's current state handler | | `canHandle(client, inputName)` | True if the client's current state — or its `_child` chain — can handle this input | | `transition(client, toState)` | Directly transition the client | | `reset(client)` | Transition the client back to `initialState` | | `currentState(client)` | Returns the client's current state, or `undefined` if never initialized | | `compositeState(client)` | Dot-delimited path including active child FSM states | | `on(eventName, callback)` | Subscribe to a lifecycle event — returns `{ off() }` | | `emit(eventName, data?)` | Emit a custom event to subscribers | | `dispose(client, options?)` | Dispose a single client's state entry | | `dispose()` | Permanently shut down the entire FSM | `currentState(client)` returns `undefined` for a client the FSM has never seen. The first call to `handle()`, `transition()`, or `reset()` initializes the client in `initialState`. ### Events Same lifecycle events as `Fsm`, but every payload includes a `client` field: ```ts connFsm.on("transitioned", ({ fromState, toState, client }) => { console.log(`[${client.url}] ${fromState} -> ${toState}`); }); ``` ### Full Example ```ts import { createBehavioralFsm } from "machina"; interface Connection { url: string; retries: number; } const connFsm = createBehavioralFsm()({ id: "connectivity", initialState: "disconnected", states: { disconnected: { connect: "connecting", }, connecting: { connected: "online", failed({ ctx }) { ctx.retries++; if (ctx.retries >= 3) { return "error"; } return "disconnected"; }, }, online: { disconnect: "disconnected", }, error: { reset({ ctx }) { ctx.retries = 0; return "disconnected"; }, }, }, }); const connA = { url: "wss://host-a.example.com", retries: 0 }; const connB = { url: "wss://host-b.example.com", retries: 0 }; connFsm.handle(connA, "connect"); connFsm.handle(connB, "connect"); connFsm.handle(connB, "failed"); // connB retries++, back to disconnected connFsm.currentState(connA); // "connecting" connFsm.currentState(connB); // "disconnected" connFsm.on("transitioned", ({ fromState, toState, client }) => { console.log(`[${client.url}] ${fromState} -> ${toState}`); }); connFsm.reset(connA); connFsm.dispose(); ``` --- ## Hierarchical States Any state can own a child FSM by setting `_child` to an FSM instance. While the parent is in that state, inputs are checked against the child first — and the check looks through the child's entire `_child` chain, so delegation reaches any depth. If anything down the chain can handle the input, it's forwarded. If not, the parent's own handlers get it. ### Input Delegation 1. Input arrives at the parent via `handle()` 2. Parent checks if the current state has a `_child` 3. If yes, parent calls `canHandle()` on the child — which answers for the child's whole `_child` chain, so a grandchild's input counts 4. If anything down the chain can handle it, the input is forwarded to the child's `handle()`, which repeats the same dispatch one level down — until it reaches the FSM that actually handles it 5. If nothing down the chain can handle it, the parent's own state handlers get the input The descendant chain gets first shot at every level; the parent's handlers are the fallback. When both a descendant and an ancestor could handle an input, the descendant wins. ### Declaring Bubbled Inputs (`bubbles`) An FSM can declare inputs it fires at itself but never handles, expecting its container to catch them via nohandler bubbling: ```ts const phase = createFsm({ id: "phase", initialState: "running", context: {}, bubbles: ["phaseComplete"], // "I fire this; whoever mounts me must deal with it" states: { running: { /* fires phase.handle("phaseComplete") from a timer — no handler here */ }, }, }); ``` Declared bubbles join the FSM's own typed input union (the self-`handle()` call compiles; typos don't), and they form a compile-time wiring contract: a config that mounts the FSM via `_child` must handle each declared bubble, re-declare it in its own `bubbles` (passing the obligation upward), or carry a `"*"` catch-all — otherwise compilation fails on the `_child` property, naming exactly what's missing. The contract composes through arbitrarily deep hierarchies. Standalone FSMs owe nothing, and the engine never reads `bubbles` — it's purely type-level. A `BubblesOfInstance` extractor is exported alongside the other instance-type utilities. One sharp edge: the explicit-both call form (`createBehavioralFsm`) disables inference for trailing type parameters, so combining it with `bubbles` requires supplying all four type arguments. The curried and zero-argument forms are unaffected. ### compositeState() Returns the full state path as a dot-delimited string: `"active.uploading"`. Nesting is unbounded. A child can have its own `_child`, and `compositeState()` walks the whole chain. ### Child Auto-Reset When the parent transitions into a state with `_child`, machina automatically calls `reset()` on the child. Re-entering a parent state always starts the child fresh. ### Disposal `dispose()` cascades to child FSMs by default. Pass `{ preserveChildren: true }` to skip it. ### Example ```ts import { createFsm } from "machina"; const childFsm = createFsm({ id: "upload-phases", initialState: "preparing", context: {}, states: { preparing: { ready: "uploading" }, uploading: { done: "verifying" }, verifying: { verified: "complete" }, complete: {}, }, }); const uploader = createFsm({ id: "uploader", initialState: "idle", context: {}, states: { idle: { start: "active", }, active: { _child: childFsm, cancel: "idle", }, }, }); uploader.handle("start"); uploader.compositeState(); // "active.preparing" uploader.handle("ready"); uploader.compositeState(); // "active.uploading" uploader.handle("cancel"); // bubbles to parent — child has no "cancel" uploader.compositeState(); // "idle" uploader.handle("start"); uploader.compositeState(); // "active.preparing" — child auto-reset ``` --- ## Events Both `Fsm` and `BehavioralFsm` emit lifecycle events. Subscribe with `on()`, which returns `{ off() }`. ### Built-in Events | Event | Payload | Fired when | | --------------- | ------------------------ | --------------------------------------- | | `transitioning` | `{ fromState, toState }` | A transition is about to occur | | `transitioned` | `{ fromState, toState }` | A transition completed | | `handling` | `{ inputName }` | An input is about to be dispatched | | `handled` | `{ inputName }` | An input was successfully handled | | `nohandler` | `{ inputName, args }` | No handler found in current state | | `invalidstate` | `{ stateName }` | Transition targeted a nonexistent state | | `deferred` | `{ inputName }` | An input was deferred | Event naming: present participle (`transitioning`) = about to happen; past participle (`transitioned`) = just happened. `BehavioralFsm` payloads include a `client` field on every event. ### Wildcard Subscriber ```ts fsm.on("*", (eventName, data) => { console.log(eventName, data); }); ``` Wildcard listeners fire before named listeners. ### Custom Events Handlers emit custom events via the `emit` arg: ```ts tick({ ctx, emit }) { ctx.count++; if (ctx.count % 10 === 0) { emit("milestone", { count: ctx.count }); } } ``` Custom events are received by `on()` and wildcard subscribers identically to built-in events. --- ## Deferred Input Call `defer()` inside a handler to queue the current input for automatic replay after a future transition. ### Targeted Defer ```ts loading: { save({ defer }) { defer({ until: "ready" }); // replay "save" when we enter "ready" }, loaded: "ready", }, ready: { save() { console.log("saving"); }, }, ``` ### Untargeted Defer ```ts error: { "*"({ defer }) { defer(); // replay on the very next transition, wherever it goes }, retry: "idle", }, ``` ### Replay Mechanics - FIFO order - Replay happens after `_onEnter` completes on the target state - Re-deferring is allowed — the input goes back in the queue - Cascading: if a replayed input triggers a transition, remaining deferred inputs pause and wait ### With BehavioralFsm Deferred inputs are tracked per-client. Calling `defer()` queues the input for that specific client only. --- ## TypeScript Reference ### Exports ```ts // Factory functions (primary API) export { createFsm } from "./fsm"; export { createBehavioralFsm } from "./behavioral-fsm"; // Classes (for type annotations and advanced usage) export { Fsm } from "./fsm"; export { BehavioralFsm } from "./behavioral-fsm"; // Types export type { Subscription, FsmConfig, FsmEventMap, BehavioralFsmEventMap, HandlerArgs, HandlerFn, HandlerDef, MachinaInstance, StateNamesOf, InputNamesOf, DisposeOptions, }; ``` ### Key Types **HandlerArgs** — the object passed to every handler: ```ts interface HandlerArgs { ctx: TCtx; inputName: string; defer(opts?: { until: TStateNames }): void; emit(eventName: string, data?: unknown): void; } ``` **HandlerFn** — a handler function: ```ts type HandlerFn = ( args: HandlerArgs, ...extra: unknown[] ) => TStateNames | void; ``` **HandlerDef** — a handler is either a string (auto-transition) or a function: ```ts type HandlerDef = TStateNames | HandlerFn | MachinaInstance; ``` **StateNamesOf** — extracts state names as a union: `"green" | "yellow" | "red"` **InputNamesOf** — extracts input names as a union: `"timeout" | "tick"` **FsmEventMap** — built-in event payloads for Fsm (no `client` field). **BehavioralFsmEventMap** — same events, but every payload intersected with `{ client: TClient }`. --- ## Migrating from v6 to v7 v7's runtime API is unchanged except `canHandle()`, which now answers for the whole `_child` chain instead of one level (so delegation reaches any depth, and a descendant's handler wins over an ancestor's — inputs that used to dead-end as `nohandler` at an ancestor now reach the descendant that declares them). The type system got stricter: configs containing an empty state (`{}`) or a state with only unannotated lifecycle hooks were silently unvalidated under v6 and are fully checked under v7, so latent typos surface as new compile errors — each one is a real bug that was already there. `defer({ until })` is narrowed to declared state names; explicitly widened `defer: (o?: { until: string }) => void` annotations no longer compile (annotate with `HandlerArgs` instead). New in v7: parents accept child/grandchild inputs on `handle()` with no casts, `bubbles` declarations with the compile-time coverage contract, and flat literal-union compiler errors. Full guide: https://machina-js.org/migration/v6-to-v7/ ## Migrating from v4 to v6 v4.0.2 was the last public release. v5 was never released. v6 is a ground-up TypeScript rewrite: same mental model, substantially different API. ### Quick Reference | Pattern | v4 | v6 | | -------------------- | ---------------------------------------- | ---------------------------------------- | | Create FSM | `new machina.Fsm({ namespace, ... })` | `createFsm({ id, context, ... })` | | Create behavioral | `new machina.BehavioralFsm({ ... })` | `createBehavioralFsm()({ ... })` | | Handler context | `this` (bound to FSM) | `{ ctx, inputName, defer, emit }` arg | | Arrow functions | broken (wrong `this`) | work fine | | Transition (inside) | `this.transition("state")` | `return "state"` | | Transition (outside) | `fsm.transition("state")` | `fsm.transition("state")` (unchanged) | | Defer | `this.deferUntilTransition("state")` | `defer({ until: "state" })` | | Defer + transition | `this.deferAndTransition("state")` | `defer(…); return "state"` | | Get state | `fsm.state` | `fsm.currentState()` | | Subscribe | `fsm.on(event, cb)` | `const sub = fsm.on(event, cb)` | | Unsubscribe | `fsm.off(event, cb)` | `sub.off()` | | Behavioral state | `client.__machina__[ns].state` | `bfsm.currentState(client)` | | Child FSM | `_child: { factory: fn }` or constructor | `_child: fsmInstance` | ### Config Changes | v4 property | v6 equivalent | Notes | | -------------- | -------------- | ----------------------------------------------------------------- | | `namespace` | `id` | Renamed. | | `initialState` | `initialState` | Unchanged. | | `states` | `states` | Unchanged. | | `initialize()` | removed | Wire up external listeners after `createFsm()`. | | `useSafeEmit` | removed | Caller's responsibility. | | _(none)_ | `context` | New. Mutable data scoped to this FSM, passed as `ctx`. | ### Handler Signature Change (Biggest Change) v4 bound handlers to `this` (the FSM). v6 passes a plain `HandlerArgs` object. Arrow functions work. No `this`. ```js // v4 timeout: function() { if (this.tickCount >= 5) { this.transition("yellow"); } } ``` ```ts // v6 timeout({ ctx }) { if (ctx.tickCount >= 5) { return "yellow"; } } ``` ### Event Changes - `"transition"` → `"transitioning"` (renamed for clarity) - `"newfsm"` — removed - BehavioralFsm payloads include `client` field - Wildcard receives `(eventName, data)` as two args ### Removed Features - `.extend()` — use factory functions - `initialize()` — do setup after `createFsm()` - `useSafeEmit` — removed - `processQueue()` / `clearQueue()` — automatic - `deferAndTransition()` — call `defer()` then return state name - `__machina__` client stamping — replaced by WeakMap - Plugin/mixin system — removed - `machina.utils` — removed --- ## Common Patterns and Gotchas ### BehavioralFsm: Per-Client Data Must Go on the Client The FSM instance is shared across all clients. Storing per-user data (timers, flags, counters) on `this` or on the FSM is a concurrency bug. Store it on `ctx` (which is the client object): ```ts // WRONG — shared across all clients getContactInfo: { _onEnter() { this.timer = setTimeout(...); // BUG: overwrites for all clients } } // RIGHT — per-client getContactInfo: { _onEnter({ ctx }) { ctx.timer = setTimeout(...); // each client gets its own timer }, _onExit({ ctx }) { if (ctx.timer) { clearTimeout(ctx.timer); ctx.timer = null; } } } ``` ### Async Patterns machina is synchronous. For async work, kick off the operation and feed results back as inputs: ```ts checking: { _onEnter({ ctx }) { ctx.abortController = new AbortController(); fetch("/health", { signal: ctx.abortController.signal }) .then(res => fsm.handle(res.ok ? "passed" : "failed")) .catch(() => fsm.handle("failed")); }, _onExit({ ctx }) { ctx.abortController?.abort(); }, passed: "online", failed: "offline", } ``` ### Timer Cleanup Always clean up timers in `_onExit` to prevent ghost callbacks: ```ts waiting: { _onEnter({ ctx }) { ctx.timer = setTimeout(() => fsm.handle("timeout"), 5000); }, _onExit({ ctx }) { if (ctx.timer) { clearTimeout(ctx.timer); ctx.timer = null; } }, timeout: "expired", cancel: "idle", } ``` ### Transition Depth Limit machina caps transition depth at 20 to prevent infinite `_onEnter → return "nextState"` loops. If you hit this, you have a cycle in your bounce logic. --- ## Companion Tools machina ships with companion packages for catching structural issues in FSM configs at dev time and testing graph topology in your test suite. ### machina-inspect Static analysis library. Parses FSM configs (or live instances) into a directed graph IR, then runs structural checks against it. The graph is a first-class export for downstream tools. ```bash npm install machina-inspect ``` **Quick start:** ```ts import { inspect } from "machina-inspect"; const findings = inspect({ id: "traffic-light", initialState: "green", states: { green: { timeout: "yellow" }, yellow: { timeout: "red" }, red: { timeout: "green" }, broken: {}, }, }); // [{ type: "unreachable-state", states: ["broken"], ... }] ``` **Checks:** | Check | What it detects | | ----- | --------------- | | `unreachable-state` | States with no inbound path from `initialState` | | `onenter-loop` | Unconditional `_onEnter` transition cycles (infinite loops) | | `missing-handler` | States missing handlers for inputs other states handle | **Two-step usage** (build graph once, use for both checks and visualization): ```ts import { buildStateGraph, inspectGraph } from "machina-inspect"; const graph = buildStateGraph(config); const findings = inspectGraph(graph); // use `graph` for diagram generation, etc. ``` **Graph IR types:** ```ts interface StateGraph { fsmId: string; initialState: string; nodes: Record; children: Record; } interface TransitionEdge { inputName: string; from: string; to: string; confidence: "definite" | "possible"; } ``` **Confidence levels:** `"definite"` = unconditional (string shorthand, single return). `"possible"` = conditional (inside if/switch/ternary/try, multiple returns). **Findings** are a discriminated union: `UnreachableFinding | OnEnterLoopFinding | MissingHandlerFinding`. `MissingHandlerFinding` includes an `inputs: string[]` field listing the missing input names. - npm: https://www.npmjs.com/package/machina-inspect - Docs: https://machina-js.org/tools/machina-inspect/ ### machina-test Testing tools for machina FSMs — graph topology matchers and property-based runtime testing. ```bash npm install --save-dev machina-test ``` **Graph matchers** — check FSM wiring via BFS reachability: ```ts import "machina-test"; expect(fsm).toHaveNoUnreachableStates(); expect(fsm).toAlwaysReach("delivered", { from: "placed" }); expect(fsm).toNeverReach("shipped", { from: "cancelled" }); ``` `toAlwaysReach` and `toNeverReach` operate on the top-level graph only — they don't traverse into `_child` FSMs. To test a child FSM, pass it directly to `expect()`. `toHaveNoUnreachableStates` does recurse into children via `inspectGraph()`. Invalid state names produce clean test failures with available state lists. **`walkAll`** — property-based runtime testing. Feeds randomized inputs into a live FSM and checks a user-supplied invariant after every transition. Catches bugs in handler logic (conditional transitions, context mutations, guards) that graph matchers can't see. ```ts import { walkAll, WalkFailureError } from "machina-test"; const result = walkAll( () => createMyFsm(), // factory: fresh FSM per walk { walks: 200, // independent walks maxSteps: 20, // handle() calls per walk seed: 42, // deterministic input selection inputs: { // payload generators per input name begin: () => randomAmount(), }, invariant({ ctx, state, previousState, input, step }) { // checked after every transition — throw or return false to fail }, } ); ``` Key concepts: - **Factory function**: Each walk gets a fresh FSM instance. Prevents context bleed between walks. - **Seed**: Controls which input name is picked each step (deterministic PRNG). Same seed = same input sequence. Does NOT control payload generators. - **Invariant**: A rule checked after every transition. Receives state, previousState, compositeState, ctx, input, payload, step. Throw or return false to signal violation. - **Payload generators**: Functions keyed by input name that produce data for handle(). Inputs without generators fire with no payload. - **WalkFailureError**: Thrown on invariant violation. Carries seed, step (1-indexed), state, previousState, compositeState, ctx, and inputSequence for deterministic replay. - **Input filtering**: `include` (whitelist) or `exclude` (blacklist) to narrow which inputs are fired. - **BehavioralFsm support**: Auto-detected. Pass `client: () => ({...})` to create fresh clients per walk. Seed replay: catch WalkFailureError, pass `err.seed` back to walkAll — same inputs, same order, same failure. - npm: https://www.npmjs.com/package/machina-test - Docs: https://machina-js.org/tools/machina-test/ ### eslint-plugin-machina ESLint plugin that surfaces machina-inspect findings inline in your editor. ESLint 9 flat config only. ```bash npm install --save-dev eslint-plugin-machina ``` **Setup:** ```js // eslint.config.mjs import machina from "eslint-plugin-machina"; export default [ machina.configs.recommended, ]; ``` For TypeScript files, also install `@typescript-eslint/parser`. **Rules:** | Rule | Default | Type | Description | | ---- | ------- | ---- | ----------- | | `machina/unreachable-state` | `"warn"` | problem | Dead states with no inbound path | | `machina/onenter-loop` | `"error"` | problem | Unconditional `_onEnter` infinite loops | | `machina/missing-handler` | `"off"` | suggestion | Asymmetric handler coverage across states | The plugin listens for `createFsm()` and `createBehavioralFsm()` call expressions, builds a `StateGraph` from the AST, and runs the checks. `_child` references are resolved when they're inline calls or `const` references in the same module. - npm: https://www.npmjs.com/package/eslint-plugin-machina - Docs: https://machina-js.org/tools/eslint-plugin/ ### machina-explorer Browser-based paste-and-analyze tool built on machina-inspect. Paste an FSM config object, click Analyze, and the app runs structural checks and renders a mermaid `stateDiagram-v2` diagram. Supports hierarchical FSMs with nested `_child` configs. No install required. - Live: https://machina-js.org/demos/machina-explorer/ - Docs: https://machina-js.org/examples/machina-explorer/