@mantaq/traversal 0.1.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,8 +1,155 @@
1
1
  # @mantaq/traversal
2
2
 
3
- Graph extraction, traversal algorithms, and runtime tracking for
3
+ Graph extraction, traversal algorithms, and runtime coverage tracking for
4
4
  [@mantaq/core](https://www.npmjs.com/package/@mantaq/core) actors.
5
5
 
6
- Build the state-machine graph from an actor, walk it with traversal
7
- algorithms, and instrument coverage as the actor runs — useful for
8
- verifying that every declared transition is reachable and exercised.
6
+ Use it to:
7
+
8
+ - **Inspect** a state machine statically. Turn an actor into a
9
+ `nodes`/`edges` graph of its states and transitions.
10
+ - **Reason** about that graph. Which states are reachable, what paths exist
11
+ between two states, and whether any declared transition can never fire.
12
+ - **Verify** coverage at runtime. Wrap an actor so every visited state,
13
+ fired transition, and executed effect is recorded as it runs.
14
+
15
+ All three pieces work on the same `ActorGraph` shape, so you can build the
16
+ graph once and walk it however your check needs.
17
+
18
+ ## Install
19
+
20
+ ```sh
21
+ npm install @mantaq/traversal
22
+ ```
23
+
24
+ > `@mantaq/traversal` depends on `@mantaq/core` and `@mantaq/utils`, which are
25
+ > resolved as workspace packages in this repo.
26
+
27
+ ## Building the graph
28
+
29
+ `buildGraph` walks an actor (and any nested regions) and returns an
30
+ `ActorGraph`:
31
+
32
+ ```ts
33
+ import { buildGraph } from "@mantaq/traversal";
34
+ import { createActor } from "@mantaq/core";
35
+
36
+ const actor = createActor(myMachine);
37
+ const graph = buildGraph(actor);
38
+ // graph.nodes: GraphNode[] — one per declared state (+ an `__initial__` node)
39
+ // graph.edges: GraphEdge[] — one per (state, event) handler
40
+ ```
41
+
42
+ Each `GraphNode` carries `id`, `label`, `isActive`, `isFinal`, and
43
+ `isInitial`. Each `GraphEdge` carries `source`, `target`, `label` (the event
44
+ that triggers it), `isActive`, `isInternal`, and `isUndetermined`.
45
+
46
+ An edge is **undetermined** when its handler runs but does not declare a target
47
+ state for the sampled context (e.g. a guard that resolves only at runtime). The
48
+ graph is a _static_ approximation: it invokes each handler with a sample
49
+ context to discover the transitions it _could_ take, so it documents intent
50
+ and reachability, not live execution.
51
+
52
+ ### Sampling contexts
53
+
54
+ Handlers may branch on context. Pass one or more named sample contexts to see
55
+ how the graph differs per context shape:
56
+
57
+ ```ts
58
+ const graph = buildGraph(actor, {
59
+ sampleContexts: {
60
+ guest: { loggedIn: false },
61
+ member: { loggedIn: true },
62
+ },
63
+ });
64
+ ```
65
+
66
+ Each context name is recorded on the relevant edges (`edge.contexts`), so you
67
+ can tell which branches belong to which context. With a single context, use
68
+ `sampleContext` instead.
69
+
70
+ ### Constants
71
+
72
+ - `INITIAL_NODE_ID`. The synthetic id of the graph's entry node (the edge
73
+ from it points to the actor's initial state).
74
+
75
+ ## Walking the graph
76
+
77
+ The algorithms module operates on an `ActorGraph` and is pure. No actor
78
+ needed:
79
+
80
+ ```ts
81
+ import { reachable, allPaths, findCycles, unreachableNodes, shortestPath } from "@mantaq/traversal";
82
+ ```
83
+
84
+ | Function | Returns | Use |
85
+ | ----------------------------------- | ------------------ | --------------------------------------------------- |
86
+ | `reachable(graph, fromId, toId)` | `boolean` | Is `toId` reachable from `fromId`? |
87
+ | `allPaths(graph, fromId, toId)` | `string[][]` | Every distinct path of node ids between two states. |
88
+ | `findCycles(graph)` | `string[][]` | Every cycle in the graph, as node-id loops. |
89
+ | `unreachableNodes(graph, fromId)` | `string[]` | Nodes no walk from `fromId` can ever reach. |
90
+ | `shortestPath(graph, fromId, toId)` | `string[] \| null` | The fewest-edge path, or `null` if unreachable. |
91
+
92
+ A common check is "every declared state is reachable from the initial state":
93
+
94
+ ```ts
95
+ import { INITIAL_NODE_ID, unreachableNodes } from "@mantaq/traversal";
96
+
97
+ const dead = unreachableNodes(graph, INITIAL_NODE_ID);
98
+ if (dead.length > 0) {
99
+ throw new Error(`Unreachable states: ${dead.join(", ")}`);
100
+ }
101
+ ```
102
+
103
+ ## Tracking coverage at runtime
104
+
105
+ `instrument` wraps an actor so it records everything it does into a `History`.
106
+ The wrapped actor has the same surface as the original (`send`, `on`,
107
+ `snapshot`, `context`, `regions`, `inject`, `dispose`, `recover`, `settled`)
108
+ plus a `history` property.
109
+
110
+ ```ts
111
+ import { instrument } from "@mantaq/traversal";
112
+
113
+ const wrapped = instrument(actor);
114
+ wrapped.send({ type: "START" });
115
+
116
+ const history = wrapped.history;
117
+ history.visitedStates(); // Set<string> — every state the actor entered
118
+ history.firedTransitions(); // Set<string> — "from:event" for each fired transition
119
+ history.effects(); // EffectRecord[] — effects executed per state
120
+ history.transitions(); // TransitionRecord[] — full from/event/to log
121
+ history.sends(); // { event: string }[] — every event sent in
122
+ history.entries(); // HistoryEntry[] — the raw, ordered record
123
+ ```
124
+
125
+ `History` records state visits, transitions, effects, and sends. Call
126
+ `history.reset()` to reuse a wrapper across independent runs.
127
+
128
+ Combine static and runtime views to assert coverage. E.g. that every
129
+ reachable state in the graph was actually visited by a scenario:
130
+
131
+ ```ts
132
+ const graph = buildGraph(actor);
133
+ const wrapped = instrument(actor);
134
+ runScenario(wrapped); // exercise the actor
135
+
136
+ const graphStates = new Set(graph.nodes.map((n) => n.id));
137
+ const missed = [...wrapped.history.visitedStates()].filter((s) => !graphStates.has(s));
138
+ ```
139
+
140
+ ## Types
141
+
142
+ ```ts
143
+ import type {
144
+ ActorGraph,
145
+ GraphNode,
146
+ GraphEdge,
147
+ StateVisit,
148
+ TransitionRecord,
149
+ EffectRecord,
150
+ HistoryEntry,
151
+ } from "@mantaq/traversal";
152
+ ```
153
+
154
+ `ActorGraph` is `{ nodes: GraphNode[]; edges: GraphEdge[] }`. See the package
155
+ source for the full field shapes.
package/dist/index.d.mts CHANGED
@@ -1,11 +1,45 @@
1
1
  import { AnyActor, Context, ErrorInfo, InternalEvent, Snapshot, TransitionInfo } from "@mantaq/core";
2
- //#region src/types.d.ts
2
+ /**
3
+ * Type guard for untyped transition-handler results. The single sanctioned
4
+ * `as` site for graph invocation: handler output crosses the untyped boundary.
5
+ */
6
+ interface HandlerResult {
7
+ state?: {
8
+ name?: string;
9
+ };
10
+ emit?: Array<{
11
+ type?: string;
12
+ }>;
13
+ }
14
+ /** Synthetic event object for graph introspection — matches minimal InternalEvent shape */
15
+ interface SyntheticEvent {
16
+ type: string;
17
+ payload?: unknown;
18
+ }
19
+ type TransitionHandler = (event: SyntheticEvent, options: {
20
+ context: Context<Record<string, unknown>>;
21
+ actor: AnyActor<unknown>;
22
+ }) => HandlerResult | undefined;
23
+ /** Map of state/Any → event → handler */
24
+ type TransitionDispatchMap = Record<string, Record<string, TransitionHandler>>;
25
+ /** State definition — matches AnyActor.options.states element type */
26
+ type StateDef = {
27
+ name: string;
28
+ isFinal: boolean;
29
+ };
3
30
  interface GraphNode {
4
31
  id: string;
5
32
  label: string;
6
33
  isActive: boolean;
7
34
  isFinal: boolean;
8
35
  isInitial?: boolean;
36
+ /** The `*` source of `onAny` edges: any non-final state. */
37
+ isAny?: boolean;
38
+ }
39
+ /** An effect a state runs on entry. `sends`: declared event types; null = not declared. */
40
+ interface GraphEffect {
41
+ name: string;
42
+ sends: string[] | null;
9
43
  }
10
44
  interface GraphEdge {
11
45
  id: string;
@@ -15,10 +49,16 @@ interface GraphEdge {
15
49
  isActive: boolean;
16
50
  isInternal?: boolean;
17
51
  isUndetermined?: boolean;
52
+ /** Handled without a state change: the registration declares no `to`. */
53
+ isStay?: boolean;
54
+ /** Target comes from the builder's `to` list, not from running the handler. */
55
+ isDeclared?: boolean;
18
56
  payload?: {
19
57
  action?: string;
20
58
  };
21
- contexts?: string[];
59
+ contexts: string[];
60
+ /** Effect self-loops only: the effects the state runs on entry, in registration order. */
61
+ effects?: GraphEffect[];
22
62
  }
23
63
  interface ActorGraph {
24
64
  nodes: GraphNode[];
@@ -26,66 +66,70 @@ interface ActorGraph {
26
66
  }
27
67
  interface StateVisit {
28
68
  stateName: string;
29
- timestamp: number;
30
69
  }
31
70
  interface TransitionRecord {
32
71
  from: string;
33
72
  event: string;
34
73
  to: string | undefined;
35
- timestamp: number;
36
74
  }
37
75
  interface EffectRecord {
38
76
  stateName: string;
39
- timestamp: number;
77
+ effectName: string;
40
78
  }
79
+ type SendRecord = {
80
+ event: string;
81
+ };
41
82
  interface HistoryEntry {
42
83
  type: "state_visit" | "transition" | "effect" | "send";
43
- data: StateVisit | TransitionRecord | EffectRecord | {
44
- event: string;
45
- timestamp: number;
46
- };
84
+ data: StateVisit | TransitionRecord | EffectRecord | SendRecord;
47
85
  }
48
- //#endregion
49
- //#region src/graph.d.ts
50
- declare const INITIAL_NODE_ID = "__initial__";
51
- declare function collectActiveStates(snapshot: Snapshot, prefix: string, activeSet: Set<string>): void;
52
- declare function buildGraph<C>(actor: AnyActor<C>, options?: {
53
- internalIds?: Set<string>;
54
- sampleContext?: Record<string, unknown>;
55
- sampleContexts?: Record<string, Record<string, unknown>>;
56
- }): ActorGraph;
57
- //#endregion
58
- //#region src/history.d.ts
59
- declare class History {
60
- constructor();
86
+ interface History {
61
87
  append(entry: HistoryEntry): void;
62
88
  entries(): readonly HistoryEntry[];
63
89
  stateVisits(): StateVisit[];
64
90
  transitions(): TransitionRecord[];
65
91
  effects(): EffectRecord[];
66
- sends(): Array<{
67
- event: string;
68
- timestamp: number;
69
- }>;
92
+ sends(): SendRecord[];
70
93
  visitedStates(): Set<string>;
71
94
  firedTransitions(): Set<string>;
72
95
  reset(): void;
73
96
  }
74
- //#endregion
75
- //#region src/instrument.d.ts
97
+ /**
98
+ * History records the runtime trace of an instrumented actor. A closure over
99
+ * the entry list keeps the recorder mutable without exposing the raw array.
100
+ */
101
+ export declare function createHistory(): History;
102
+ export declare const INITIAL_NODE_ID = "__initial__";
103
+ /** Source of `onAny` edges: any non-final state of the machine (`region.*` inside a region). */
104
+ export declare const ANY_NODE_NAME = "*";
105
+ export declare function isAnyNode(id: string): boolean;
106
+ export declare function collectActiveStates(snapshot: Snapshot, options: {
107
+ prefix: string;
108
+ activeSet: Set<string>;
109
+ }): void;
110
+ export declare function buildGraph<C>(actor: AnyActor<C>, options?: {
111
+ internalIds?: Set<string>;
112
+ sampleContext?: Record<string, unknown>;
113
+ sampleContexts?: Record<string, Record<string, unknown>>;
114
+ }): ActorGraph;
115
+ export declare function parseStates(options: unknown): ReadonlyArray<StateDef>;
116
+ export declare function parseTransitionMap(source: unknown): TransitionDispatchMap;
117
+ export declare function parseInitialName(options: unknown): string | undefined;
118
+ export declare function parseContextRecord(context: unknown): Record<string, unknown>;
119
+ export declare function parseInternalEventIds(options: unknown): Set<string>;
120
+ type SubscriberEventName = "change" | "done" | "transition" | "error" | "output";
121
+ type SubscriberHandler<C, E extends SubscriberEventName> = E extends "change" ? (snapshot: Snapshot<C>, prev: Snapshot<C>) => void : E extends "done" ? () => void : E extends "transition" ? (info: TransitionInfo) => void : E extends "error" ? (info: ErrorInfo) => void : E extends "output" ? (event: InternalEvent) => void : never;
76
122
  interface InstrumentedActor<C = Record<string, unknown>> {
77
123
  history: History;
78
- send(event: unknown): void;
124
+ send(event: InternalEvent): void;
79
125
  state: AnyActor["state"];
80
126
  snapshot(): Snapshot<C>;
81
127
  regions: Record<string, AnyActor>;
82
128
  context?: C;
83
129
  clock: AnyActor["clock"];
84
- on(event: "change", fn: (snapshot: Snapshot<C>, prev: Snapshot<C>) => void): () => void;
85
- on(event: "transition", fn: (info: TransitionInfo) => void): () => void;
86
- on(event: "done", fn: () => void): () => void;
87
- on(event: "error", fn: (info: ErrorInfo) => void): () => void;
88
- on(event: "output", fn: (event: InternalEvent) => void): () => void;
130
+ on<E extends SubscriberEventName>(event: E, options: {
131
+ fn: SubscriberHandler<C, E>;
132
+ }): () => void;
89
133
  recover(target: {
90
134
  state: AnyActor["state"];
91
135
  context: C;
@@ -95,13 +139,21 @@ interface InstrumentedActor<C = Record<string, unknown>> {
95
139
  inject(event: InternalEvent): void;
96
140
  dispose(): void;
97
141
  }
98
- declare function instrument<C>(actor: AnyActor<C>): InstrumentedActor<C>;
99
- //#endregion
100
- //#region src/algorithms.d.ts
101
- declare function reachable(graph: ActorGraph, fromId: string, toId: string): boolean;
102
- declare function allPaths(graph: ActorGraph, fromId: string, toId: string): string[][];
103
- declare function findCycles(graph: ActorGraph): string[][];
104
- declare function unreachableNodes(graph: ActorGraph, fromId: string): string[];
105
- declare function shortestPath(graph: ActorGraph, fromId: string, toId: string): string[] | null;
106
- //#endregion
107
- export { type ActorGraph, type EffectRecord, type GraphEdge, type GraphNode, History, type HistoryEntry, INITIAL_NODE_ID, type InstrumentedActor, type StateVisit, type TransitionRecord, allPaths, buildGraph, collectActiveStates, findCycles, instrument, reachable, shortestPath, unreachableNodes };
142
+ export declare function instrument<C>(actor: AnyActor<C>): InstrumentedActor<C>;
143
+ export declare function reachable(graph: ActorGraph, endpoints: {
144
+ fromId: string;
145
+ toId: string;
146
+ }): boolean;
147
+ export declare function allPaths(graph: ActorGraph, endpoints: {
148
+ fromId: string;
149
+ toId: string;
150
+ }): string[][];
151
+ export declare function findCycles(graph: ActorGraph): string[][];
152
+ export declare function unreachableNodes(graph: ActorGraph, options: {
153
+ fromId: string;
154
+ }): string[];
155
+ export declare function shortestPath(graph: ActorGraph, endpoints: {
156
+ fromId: string;
157
+ toId: string;
158
+ }): string[] | undefined;
159
+ export type { ActorGraph, EffectRecord, GraphEdge, GraphNode, History, HistoryEntry, InstrumentedActor, SendRecord, StateDef, StateVisit, SyntheticEvent, TransitionDispatchMap, TransitionHandler, TransitionRecord };