machina-inspect 1.0.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/LICENSE +69 -0
- package/README.md +185 -0
- package/dist/index.cjs +589 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +200 -0
- package/dist/index.d.cts.map +1 -0
- package/dist/index.d.mts +200 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +556 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +67 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,589 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
|
|
2
|
+
//#region \0rolldown/runtime.js
|
|
3
|
+
var __create = Object.create;
|
|
4
|
+
var __defProp = Object.defineProperty;
|
|
5
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
6
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
7
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
8
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
9
|
+
var __copyProps = (to, from, except, desc) => {
|
|
10
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
11
|
+
for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
|
|
12
|
+
key = keys[i];
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except) {
|
|
14
|
+
__defProp(to, key, {
|
|
15
|
+
get: ((k) => from[k]).bind(null, key),
|
|
16
|
+
enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return to;
|
|
22
|
+
};
|
|
23
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", {
|
|
24
|
+
value: mod,
|
|
25
|
+
enumerable: true
|
|
26
|
+
}) : target, mod));
|
|
27
|
+
|
|
28
|
+
//#endregion
|
|
29
|
+
let machina = require("machina");
|
|
30
|
+
let acorn = require("acorn");
|
|
31
|
+
acorn = __toESM(acorn);
|
|
32
|
+
|
|
33
|
+
//#region src/handler-analyzer.ts
|
|
34
|
+
const CONDITIONAL_ANCESTORS = new Set([
|
|
35
|
+
"IfStatement",
|
|
36
|
+
"SwitchStatement",
|
|
37
|
+
"SwitchCase",
|
|
38
|
+
"ConditionalExpression",
|
|
39
|
+
"LogicalExpression",
|
|
40
|
+
"TryStatement"
|
|
41
|
+
]);
|
|
42
|
+
const PARSE_OPTS = {
|
|
43
|
+
ecmaVersion: 2022,
|
|
44
|
+
sourceType: "script"
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* Try to parse a function's toString() output using several strategies.
|
|
48
|
+
*
|
|
49
|
+
* Function.prototype.toString() produces different formats:
|
|
50
|
+
* - Named function declaration: `function name() { ... }` → parses as-is
|
|
51
|
+
* - Anonymous function expression: `function () { ... }` → needs parens wrapper
|
|
52
|
+
* - Arrow function: `() => { ... }` → parses as-is
|
|
53
|
+
* - Method shorthand: `name() { ... }` → needs object literal wrapper
|
|
54
|
+
*
|
|
55
|
+
* Returns null if none of the strategies succeed (unusual syntax, etc.).
|
|
56
|
+
*/
|
|
57
|
+
function tryParse(source) {
|
|
58
|
+
try {
|
|
59
|
+
return acorn.parse(source, PARSE_OPTS);
|
|
60
|
+
} catch {}
|
|
61
|
+
try {
|
|
62
|
+
return acorn.parse(`(${source})`, PARSE_OPTS);
|
|
63
|
+
} catch {}
|
|
64
|
+
try {
|
|
65
|
+
return acorn.parse(`({${source}})`, PARSE_OPTS);
|
|
66
|
+
} catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Find the first ArrowFunctionExpression node in a parsed AST.
|
|
72
|
+
* Returns undefined if no arrow function exists at the top level.
|
|
73
|
+
*/
|
|
74
|
+
function findArrowFunction(node) {
|
|
75
|
+
if (node.type === "ArrowFunctionExpression") return node;
|
|
76
|
+
if (node.type === "Program" || node.type === "ExpressionStatement") {
|
|
77
|
+
const children = getChildren(node);
|
|
78
|
+
for (const child of children) {
|
|
79
|
+
const found = findArrowFunction(child);
|
|
80
|
+
if (found) return found;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Handle concise arrow functions (`() => "state"` with no block body).
|
|
86
|
+
*
|
|
87
|
+
* Acorn represents these with `expression: true` on the ArrowFunctionExpression
|
|
88
|
+
* and the body as a Literal directly — there is no ReturnStatement, so the
|
|
89
|
+
* general walkForReturns() misses them entirely.
|
|
90
|
+
*
|
|
91
|
+
* Returns:
|
|
92
|
+
* - HandlerTarget[] with one definite entry if the body is a string Literal
|
|
93
|
+
* - [] if the body is a non-string expression (no false edges produced)
|
|
94
|
+
* - undefined if the AST doesn't match the concise arrow pattern at all
|
|
95
|
+
* (caller should fall through to the general walker)
|
|
96
|
+
*/
|
|
97
|
+
function checkConciseArrow(node) {
|
|
98
|
+
if (node.type !== "ArrowFunctionExpression" || !node.expression) return;
|
|
99
|
+
const body = node.body;
|
|
100
|
+
if (body && body.type === "Literal" && typeof body.value === "string") return [{
|
|
101
|
+
target: body.value,
|
|
102
|
+
confidence: "definite"
|
|
103
|
+
}];
|
|
104
|
+
return [];
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Handle concise arrow functions found inside a parsed Program/ExpressionStatement.
|
|
108
|
+
* Used by analyzeHandler after tryParse() wraps the source in a Program node.
|
|
109
|
+
*/
|
|
110
|
+
function checkConciseArrowInAst(ast) {
|
|
111
|
+
const arrowNode = findArrowFunction(ast);
|
|
112
|
+
if (!arrowNode || !arrowNode.expression) return;
|
|
113
|
+
const body = arrowNode.body;
|
|
114
|
+
if (body && body.type === "Literal" && typeof body.value === "string") return [{
|
|
115
|
+
target: body.value,
|
|
116
|
+
confidence: "definite"
|
|
117
|
+
}];
|
|
118
|
+
return [];
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Walk a pre-parsed handler function AST node and extract transition targets.
|
|
122
|
+
*
|
|
123
|
+
* Accepts a FunctionDeclaration, FunctionExpression, or ArrowFunctionExpression
|
|
124
|
+
* node (as produced by acorn or the ESLint rule engine). Returns an array of
|
|
125
|
+
* { target, confidence } pairs. Empty array means no statically-determinable
|
|
126
|
+
* string return targets were found.
|
|
127
|
+
*
|
|
128
|
+
* This is the shared core used by both:
|
|
129
|
+
* - analyzeHandler() — the runtime path (parses fn.toString() first)
|
|
130
|
+
* - ESLint rules — the AST path (ESLint already has the parsed node)
|
|
131
|
+
*/
|
|
132
|
+
function walkHandlerAst(node) {
|
|
133
|
+
const conciseResult = checkConciseArrow(node);
|
|
134
|
+
if (conciseResult !== void 0) return conciseResult;
|
|
135
|
+
const returns = [];
|
|
136
|
+
walkForReturns(node, [], returns);
|
|
137
|
+
if (returns.length === 0) return [];
|
|
138
|
+
const allNonConditional = returns.every((r) => !r.isConditional);
|
|
139
|
+
const isSoleReturn = returns.length === 1;
|
|
140
|
+
return returns.map(({ target, isConditional }) => ({
|
|
141
|
+
target,
|
|
142
|
+
confidence: allNonConditional && isSoleReturn && !isConditional ? "definite" : "possible"
|
|
143
|
+
}));
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Analyze a handler function and extract transition targets.
|
|
147
|
+
*
|
|
148
|
+
* Returns an array of { target, confidence } pairs. Empty array means
|
|
149
|
+
* no statically-determinable string return targets were found.
|
|
150
|
+
*
|
|
151
|
+
* Safe to call on any function — parse errors return empty (fail silently).
|
|
152
|
+
*/
|
|
153
|
+
function analyzeHandler(fn) {
|
|
154
|
+
const source = fn.toString();
|
|
155
|
+
if (source.includes("[native code]")) return [];
|
|
156
|
+
const ast = tryParse(source);
|
|
157
|
+
if (!ast) return [];
|
|
158
|
+
const conciseResult = checkConciseArrowInAst(ast);
|
|
159
|
+
if (conciseResult !== void 0) return conciseResult;
|
|
160
|
+
const fnNode = findTopLevelFunctionNode(ast);
|
|
161
|
+
if (fnNode) return walkHandlerAst(fnNode);
|
|
162
|
+
const returns = [];
|
|
163
|
+
walkForReturns(ast, [], returns);
|
|
164
|
+
if (returns.length === 0) return [];
|
|
165
|
+
const allNonConditional = returns.every((r) => !r.isConditional);
|
|
166
|
+
const isSoleReturn = returns.length === 1;
|
|
167
|
+
return returns.map(({ target, isConditional }) => ({
|
|
168
|
+
target,
|
|
169
|
+
confidence: allNonConditional && isSoleReturn && !isConditional ? "definite" : "possible"
|
|
170
|
+
}));
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Find the top-level function node in a parsed Program AST.
|
|
174
|
+
* Returns the first FunctionDeclaration, FunctionExpression, or
|
|
175
|
+
* ArrowFunctionExpression at the top level.
|
|
176
|
+
*/
|
|
177
|
+
function findTopLevelFunctionNode(ast) {
|
|
178
|
+
const children = getChildren(ast);
|
|
179
|
+
for (const child of children) {
|
|
180
|
+
if (child.type === "FunctionDeclaration" || child.type === "FunctionExpression" || child.type === "ArrowFunctionExpression") return child;
|
|
181
|
+
if (child.type === "ExpressionStatement") {
|
|
182
|
+
const grandChildren = getChildren(child);
|
|
183
|
+
for (const gc of grandChildren) if (gc.type === "FunctionDeclaration" || gc.type === "FunctionExpression" || gc.type === "ArrowFunctionExpression") return gc;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Collect string literals from a conditional expression tree.
|
|
189
|
+
* Used when a ReturnStatement's argument is a ternary or logical expression.
|
|
190
|
+
* All findings are marked as "possible" since they're conditional.
|
|
191
|
+
*/
|
|
192
|
+
function collectStringLiteralsFromExpression(node, results) {
|
|
193
|
+
if (!node || typeof node !== "object") return;
|
|
194
|
+
if (node.type === "Literal" && typeof node.value === "string") {
|
|
195
|
+
results.push({
|
|
196
|
+
target: node.value,
|
|
197
|
+
isConditional: true
|
|
198
|
+
});
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
if (node.type === "ConditionalExpression") {
|
|
202
|
+
collectStringLiteralsFromExpression(node.consequent, results);
|
|
203
|
+
collectStringLiteralsFromExpression(node.alternate, results);
|
|
204
|
+
} else if (node.type === "LogicalExpression") {
|
|
205
|
+
collectStringLiteralsFromExpression(node.left, results);
|
|
206
|
+
collectStringLiteralsFromExpression(node.right, results);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Recursively walk the AST, collecting ReturnStatement nodes that
|
|
211
|
+
* return string literals. Tracks whether any CONDITIONAL_ANCESTOR
|
|
212
|
+
* node exists in the ancestor chain.
|
|
213
|
+
*
|
|
214
|
+
* We skip nested function bodies (arrow functions, regular functions,
|
|
215
|
+
* class methods) — their returns belong to a different function scope.
|
|
216
|
+
*/
|
|
217
|
+
function walkForReturns(node, ancestors, results) {
|
|
218
|
+
if (!node || typeof node !== "object") return;
|
|
219
|
+
if (node.type === "ReturnStatement") {
|
|
220
|
+
const arg = node.argument;
|
|
221
|
+
if (!arg) return;
|
|
222
|
+
if (arg.type === "Literal" && typeof arg.value === "string") {
|
|
223
|
+
const isConditional = ancestors.some((a) => CONDITIONAL_ANCESTORS.has(a));
|
|
224
|
+
results.push({
|
|
225
|
+
target: arg.value,
|
|
226
|
+
isConditional
|
|
227
|
+
});
|
|
228
|
+
} else collectStringLiteralsFromExpression(arg, results);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
if (node.type === "FunctionDeclaration" || node.type === "FunctionExpression" || node.type === "ArrowFunctionExpression") {
|
|
232
|
+
if (ancestors.some((a) => a === "FunctionDeclaration" || a === "FunctionExpression" || a === "ArrowFunctionExpression")) return;
|
|
233
|
+
}
|
|
234
|
+
const nextAncestors = [...ancestors, node.type];
|
|
235
|
+
const children = getChildren(node);
|
|
236
|
+
for (const child of children) if (child && typeof child === "object") walkForReturns(child, nextAncestors, results);
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Extract the child nodes we want to recurse into from an AST node.
|
|
240
|
+
* Returns a flat list of child nodes (some may be arrays in the AST).
|
|
241
|
+
*/
|
|
242
|
+
function getChildren(node) {
|
|
243
|
+
const children = [];
|
|
244
|
+
const pushIfNode = (val) => {
|
|
245
|
+
if (val && typeof val === "object" && "type" in val) children.push(val);
|
|
246
|
+
};
|
|
247
|
+
const pushArray = (arr) => {
|
|
248
|
+
if (Array.isArray(arr)) for (const item of arr) pushIfNode(item);
|
|
249
|
+
};
|
|
250
|
+
switch (node.type) {
|
|
251
|
+
case "Program":
|
|
252
|
+
pushArray(node.body);
|
|
253
|
+
break;
|
|
254
|
+
case "ExpressionStatement":
|
|
255
|
+
pushIfNode(node.expression);
|
|
256
|
+
break;
|
|
257
|
+
case "FunctionDeclaration":
|
|
258
|
+
case "FunctionExpression":
|
|
259
|
+
case "ArrowFunctionExpression":
|
|
260
|
+
if (node.body) pushIfNode(node.body);
|
|
261
|
+
break;
|
|
262
|
+
case "BlockStatement":
|
|
263
|
+
pushArray(node.body);
|
|
264
|
+
break;
|
|
265
|
+
case "ReturnStatement":
|
|
266
|
+
pushIfNode(node.argument);
|
|
267
|
+
break;
|
|
268
|
+
case "IfStatement":
|
|
269
|
+
pushIfNode(node.consequent);
|
|
270
|
+
pushIfNode(node.alternate);
|
|
271
|
+
break;
|
|
272
|
+
case "SwitchStatement":
|
|
273
|
+
pushArray(node.cases);
|
|
274
|
+
break;
|
|
275
|
+
case "SwitchCase":
|
|
276
|
+
pushArray(node.consequent);
|
|
277
|
+
break;
|
|
278
|
+
case "TryStatement":
|
|
279
|
+
pushIfNode(node.block);
|
|
280
|
+
pushIfNode(node.handler);
|
|
281
|
+
pushIfNode(node.finalizer);
|
|
282
|
+
break;
|
|
283
|
+
case "CatchClause":
|
|
284
|
+
pushIfNode(node.body);
|
|
285
|
+
break;
|
|
286
|
+
case "ConditionalExpression":
|
|
287
|
+
pushIfNode(node.consequent);
|
|
288
|
+
pushIfNode(node.alternate);
|
|
289
|
+
break;
|
|
290
|
+
case "LogicalExpression":
|
|
291
|
+
pushIfNode(node.left);
|
|
292
|
+
pushIfNode(node.right);
|
|
293
|
+
break;
|
|
294
|
+
case "VariableDeclaration":
|
|
295
|
+
pushArray(node.declarations);
|
|
296
|
+
break;
|
|
297
|
+
case "VariableDeclarator":
|
|
298
|
+
pushIfNode(node.init);
|
|
299
|
+
break;
|
|
300
|
+
case "ForStatement":
|
|
301
|
+
pushIfNode(node.body);
|
|
302
|
+
break;
|
|
303
|
+
case "WhileStatement":
|
|
304
|
+
case "DoWhileStatement":
|
|
305
|
+
pushIfNode(node.body);
|
|
306
|
+
break;
|
|
307
|
+
default: for (const key of Object.keys(node)) {
|
|
308
|
+
if (key === "type" || key === "start" || key === "end" || key === "loc" || key === "parent" || key === "range") continue;
|
|
309
|
+
const val = node[key];
|
|
310
|
+
if (Array.isArray(val)) pushArray(val);
|
|
311
|
+
else pushIfNode(val);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return children;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
//#endregion
|
|
318
|
+
//#region src/graph-builder.ts
|
|
319
|
+
const SPECIAL_KEYS = new Set([
|
|
320
|
+
"_onEnter",
|
|
321
|
+
"_onExit",
|
|
322
|
+
"_child"
|
|
323
|
+
]);
|
|
324
|
+
/**
|
|
325
|
+
* Normalize the input to a consistent internal shape.
|
|
326
|
+
* Config objects are passed through; live instances have their
|
|
327
|
+
* public fields extracted.
|
|
328
|
+
*/
|
|
329
|
+
function normalizeInput(input) {
|
|
330
|
+
if (machina.MACHINA_TYPE in input) {
|
|
331
|
+
const instance = input;
|
|
332
|
+
return {
|
|
333
|
+
id: instance.id,
|
|
334
|
+
initialState: instance.initialState,
|
|
335
|
+
states: instance.states
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
return input;
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Extract transition edges from a single handler value.
|
|
342
|
+
* String shorthands produce one definite edge. Function handlers
|
|
343
|
+
* are analyzed by the acorn-based handler-analyzer.
|
|
344
|
+
*/
|
|
345
|
+
function extractEdges(fromState, inputName, handlerValue) {
|
|
346
|
+
if (typeof handlerValue === "string") return [{
|
|
347
|
+
inputName,
|
|
348
|
+
from: fromState,
|
|
349
|
+
to: handlerValue,
|
|
350
|
+
confidence: "definite"
|
|
351
|
+
}];
|
|
352
|
+
if (typeof handlerValue === "function") return analyzeHandler(handlerValue).map(({ target, confidence }) => ({
|
|
353
|
+
inputName,
|
|
354
|
+
from: fromState,
|
|
355
|
+
to: target,
|
|
356
|
+
confidence
|
|
357
|
+
}));
|
|
358
|
+
return [];
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* Detect whether a _child value is a ChildLink wrapper (post-construction)
|
|
362
|
+
* vs a raw FSM instance (pre-construction config path).
|
|
363
|
+
*
|
|
364
|
+
* ChildLink objects have a `canHandle` method and an `instance` field.
|
|
365
|
+
* Raw FSM instances have MACHINA_TYPE stamped on them.
|
|
366
|
+
*/
|
|
367
|
+
function isChildLink(value) {
|
|
368
|
+
return typeof value === "object" && value !== null && "canHandle" in value && "instance" in value;
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* Detect a plain config object that looks like an FSM config (has `initialState`
|
|
372
|
+
* and `states`). This covers eval'd user input in the explorer, where _child is
|
|
373
|
+
* a bare object literal rather than a createFsm() instance.
|
|
374
|
+
*/
|
|
375
|
+
function isConfigShaped(value) {
|
|
376
|
+
return typeof value === "object" && value !== null && "initialState" in value && "states" in value;
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* Build a StateGraph from an FSM config or live instance.
|
|
380
|
+
* Child FSMs declared via _child are recursively built into child graphs.
|
|
381
|
+
*
|
|
382
|
+
* @param input - A config object or a live Fsm/BehavioralFsm instance
|
|
383
|
+
*/
|
|
384
|
+
function buildStateGraph(input) {
|
|
385
|
+
const fsm = normalizeInput(input);
|
|
386
|
+
const nodes = {};
|
|
387
|
+
const children = {};
|
|
388
|
+
for (const stateName of Object.keys(fsm.states)) {
|
|
389
|
+
const stateObj = fsm.states[stateName];
|
|
390
|
+
const edges = [];
|
|
391
|
+
for (const key of Object.keys(stateObj)) {
|
|
392
|
+
const value = stateObj[key];
|
|
393
|
+
if (key === "_child") {
|
|
394
|
+
if (isChildLink(value)) children[stateName] = buildStateGraph(value.instance);
|
|
395
|
+
else if (typeof value === "object" && value !== null && machina.MACHINA_TYPE in value) children[stateName] = buildStateGraph(value);
|
|
396
|
+
else if (isConfigShaped(value)) children[stateName] = buildStateGraph(value);
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
if (SPECIAL_KEYS.has(key)) {
|
|
400
|
+
if (key === "_onEnter" && typeof value === "function") {
|
|
401
|
+
const targets = analyzeHandler(value);
|
|
402
|
+
for (const { target, confidence } of targets) edges.push({
|
|
403
|
+
inputName: "_onEnter",
|
|
404
|
+
from: stateName,
|
|
405
|
+
to: target,
|
|
406
|
+
confidence
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
for (const edge of extractEdges(stateName, key, value)) edges.push(edge);
|
|
412
|
+
}
|
|
413
|
+
nodes[stateName] = {
|
|
414
|
+
name: stateName,
|
|
415
|
+
edges
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
return {
|
|
419
|
+
fsmId: fsm.id,
|
|
420
|
+
initialState: fsm.initialState,
|
|
421
|
+
nodes,
|
|
422
|
+
children
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
//#endregion
|
|
427
|
+
//#region src/checks/unreachable.ts
|
|
428
|
+
/**
|
|
429
|
+
* Check for unreachable states in the graph.
|
|
430
|
+
* BFS from initialState — any unvisited node is a finding.
|
|
431
|
+
* Recurses into child graphs independently.
|
|
432
|
+
*/
|
|
433
|
+
function checkUnreachable(graph, parentState) {
|
|
434
|
+
const findings = [];
|
|
435
|
+
findings.push(...findUnreachableInGraph(graph, parentState));
|
|
436
|
+
for (const [childParentState, childGraph] of Object.entries(graph.children)) findings.push(...checkUnreachable(childGraph, childParentState));
|
|
437
|
+
return findings;
|
|
438
|
+
}
|
|
439
|
+
function findUnreachableInGraph(graph, parentState) {
|
|
440
|
+
const { fsmId, initialState, nodes } = graph;
|
|
441
|
+
const visited = /* @__PURE__ */ new Set();
|
|
442
|
+
const queue = [];
|
|
443
|
+
if (initialState in nodes) {
|
|
444
|
+
queue.push(initialState);
|
|
445
|
+
visited.add(initialState);
|
|
446
|
+
}
|
|
447
|
+
while (queue.length > 0) {
|
|
448
|
+
const node = nodes[queue.shift()];
|
|
449
|
+
if (!node) continue;
|
|
450
|
+
for (const edge of node.edges) if (!visited.has(edge.to) && edge.to in nodes) {
|
|
451
|
+
visited.add(edge.to);
|
|
452
|
+
queue.push(edge.to);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
const findings = [];
|
|
456
|
+
for (const stateName of Object.keys(nodes)) if (!visited.has(stateName)) findings.push({
|
|
457
|
+
type: "unreachable-state",
|
|
458
|
+
message: `State "${stateName}" is unreachable from initialState "${initialState}" in FSM "${fsmId}".`,
|
|
459
|
+
fsmId,
|
|
460
|
+
states: [stateName],
|
|
461
|
+
parentState
|
|
462
|
+
});
|
|
463
|
+
return findings;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
//#endregion
|
|
467
|
+
//#region src/checks/onenter-loop.ts
|
|
468
|
+
/**
|
|
469
|
+
* Check for _onEnter transition loops in the graph.
|
|
470
|
+
* Only reports cycles where every edge is "definite".
|
|
471
|
+
* Recurses into child graphs.
|
|
472
|
+
*/
|
|
473
|
+
function checkOnEnterLoops(graph, parentState) {
|
|
474
|
+
const findings = [];
|
|
475
|
+
findings.push(...findOnEnterLoopsInGraph(graph, parentState));
|
|
476
|
+
for (const [childParentState, childGraph] of Object.entries(graph.children)) findings.push(...checkOnEnterLoops(childGraph, childParentState));
|
|
477
|
+
return findings;
|
|
478
|
+
}
|
|
479
|
+
function findOnEnterLoopsInGraph(graph, parentState) {
|
|
480
|
+
const { fsmId, nodes } = graph;
|
|
481
|
+
const onEnterEdges = {};
|
|
482
|
+
for (const [stateName, node] of Object.entries(nodes)) {
|
|
483
|
+
const definiteOnEnterTargets = node.edges.filter((e) => e.inputName === "_onEnter" && e.confidence === "definite" && e.to !== stateName).map((e) => e.to);
|
|
484
|
+
if (definiteOnEnterTargets.length > 0) onEnterEdges[stateName] = definiteOnEnterTargets;
|
|
485
|
+
}
|
|
486
|
+
const WHITE = 0, GRAY = 1, BLACK = 2;
|
|
487
|
+
const color = {};
|
|
488
|
+
for (const stateName of Object.keys(nodes)) color[stateName] = WHITE;
|
|
489
|
+
const findings = [];
|
|
490
|
+
const reportedCycles = /* @__PURE__ */ new Set();
|
|
491
|
+
const dfs = (node, stack) => {
|
|
492
|
+
color[node] = GRAY;
|
|
493
|
+
stack.push(node);
|
|
494
|
+
for (const neighbor of onEnterEdges[node] ?? []) if (color[neighbor] === GRAY) {
|
|
495
|
+
const cycleStart = stack.indexOf(neighbor);
|
|
496
|
+
const cycle = stack.slice(cycleStart);
|
|
497
|
+
const cycleKey = [...cycle].sort().join(",");
|
|
498
|
+
if (!reportedCycles.has(cycleKey)) {
|
|
499
|
+
reportedCycles.add(cycleKey);
|
|
500
|
+
findings.push({
|
|
501
|
+
type: "onenter-loop",
|
|
502
|
+
message: `Unconditional _onEnter loop detected in FSM "${fsmId}": ` + cycle.join(" → ") + ` → ${neighbor}`,
|
|
503
|
+
fsmId,
|
|
504
|
+
states: cycle,
|
|
505
|
+
parentState
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
} else if (color[neighbor] === WHITE) dfs(neighbor, stack);
|
|
509
|
+
stack.pop();
|
|
510
|
+
color[node] = BLACK;
|
|
511
|
+
};
|
|
512
|
+
for (const stateName of Object.keys(nodes)) if (color[stateName] === WHITE) dfs(stateName, []);
|
|
513
|
+
return findings;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
//#endregion
|
|
517
|
+
//#region src/checks/missing-handler.ts
|
|
518
|
+
const EXCLUDED_INPUTS = new Set([
|
|
519
|
+
"_onEnter",
|
|
520
|
+
"_onExit",
|
|
521
|
+
"*"
|
|
522
|
+
]);
|
|
523
|
+
/**
|
|
524
|
+
* Check for states with missing handlers relative to the FSM's full input set.
|
|
525
|
+
* States with a `*` catch-all edge are skipped entirely.
|
|
526
|
+
* Recurses into child graphs independently.
|
|
527
|
+
*/
|
|
528
|
+
function checkMissingHandlers(graph, parentState) {
|
|
529
|
+
const findings = [];
|
|
530
|
+
findings.push(...findMissingHandlersInGraph(graph, parentState));
|
|
531
|
+
for (const [childParentState, childGraph] of Object.entries(graph.children)) findings.push(...checkMissingHandlers(childGraph, childParentState));
|
|
532
|
+
return findings;
|
|
533
|
+
}
|
|
534
|
+
function findMissingHandlersInGraph(graph, parentState) {
|
|
535
|
+
const { fsmId, nodes } = graph;
|
|
536
|
+
const allInputs = /* @__PURE__ */ new Set();
|
|
537
|
+
for (const node of Object.values(nodes)) for (const edge of node.edges) if (!EXCLUDED_INPUTS.has(edge.inputName)) allInputs.add(edge.inputName);
|
|
538
|
+
if (allInputs.size === 0) return [];
|
|
539
|
+
const findings = [];
|
|
540
|
+
for (const [stateName, node] of Object.entries(nodes)) {
|
|
541
|
+
if (node.edges.some((e) => e.inputName === "*")) continue;
|
|
542
|
+
const stateInputs = /* @__PURE__ */ new Set();
|
|
543
|
+
for (const edge of node.edges) if (!EXCLUDED_INPUTS.has(edge.inputName)) stateInputs.add(edge.inputName);
|
|
544
|
+
const missingInputs = [...allInputs].filter((input) => !stateInputs.has(input));
|
|
545
|
+
if (missingInputs.length > 0) findings.push({
|
|
546
|
+
type: "missing-handler",
|
|
547
|
+
message: `State "${stateName}" in FSM "${fsmId}" is missing handlers for: ` + missingInputs.map((i) => `"${i}"`).join(", ") + ".",
|
|
548
|
+
fsmId,
|
|
549
|
+
states: [stateName],
|
|
550
|
+
inputs: missingInputs,
|
|
551
|
+
parentState
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
return findings;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
//#endregion
|
|
558
|
+
//#region src/inspect.ts
|
|
559
|
+
/**
|
|
560
|
+
* Build a state graph from the input and run all structural checks.
|
|
561
|
+
* Returns an array of findings — empty means no issues detected.
|
|
562
|
+
*
|
|
563
|
+
* @param input - A config object or a live Fsm/BehavioralFsm instance
|
|
564
|
+
*/
|
|
565
|
+
function inspect(input) {
|
|
566
|
+
return inspectGraph(buildStateGraph(input));
|
|
567
|
+
}
|
|
568
|
+
/**
|
|
569
|
+
* Run all structural checks against a pre-built state graph.
|
|
570
|
+
* Useful when you've already called buildStateGraph() for another purpose
|
|
571
|
+
* (e.g., diagram export) and want to avoid building it twice.
|
|
572
|
+
*
|
|
573
|
+
* @param graph - A StateGraph produced by buildStateGraph()
|
|
574
|
+
*/
|
|
575
|
+
function inspectGraph(graph) {
|
|
576
|
+
return [
|
|
577
|
+
...checkUnreachable(graph),
|
|
578
|
+
...checkOnEnterLoops(graph),
|
|
579
|
+
...checkMissingHandlers(graph)
|
|
580
|
+
];
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
//#endregion
|
|
584
|
+
exports.analyzeHandler = analyzeHandler;
|
|
585
|
+
exports.buildStateGraph = buildStateGraph;
|
|
586
|
+
exports.inspect = inspect;
|
|
587
|
+
exports.inspectGraph = inspectGraph;
|
|
588
|
+
exports.walkHandlerAst = walkHandlerAst;
|
|
589
|
+
//# sourceMappingURL=index.cjs.map
|