@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 +151 -4
- package/dist/index.d.mts +96 -44
- package/dist/index.mjs +527 -300
- package/package.json +7 -7
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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():
|
|
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
|
-
|
|
75
|
-
|
|
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:
|
|
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:
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
declare function
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
export
|
|
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 };
|