@mantaq/viz 0.0.0 → 0.1.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 +21 -0
- package/README.md +45 -0
- package/dist/embed-BRQxeM4W.d.mts +32 -0
- package/dist/embed-BtlbyzmZ.mjs +1080 -0
- package/dist/html-BfvuzJs6.mjs +55 -0
- package/dist/index.d.mts +82 -0
- package/dist/index.mjs +13 -0
- package/dist/live.d.mts +1 -0
- package/dist/live.mjs +16 -0
- package/dist/page.d.mts +7 -0
- package/dist/page.mjs +53 -0
- package/dist/routing-BNJN5Oiu.mjs +759 -0
- package/dist/standalone.d.mts +1 -0
- package/dist/standalone.mjs +6 -0
- package/dist/style.css +404 -0
- package/dist/validate-3D5LrtlE.d.mts +189 -0
- package/dist/viewer.d.mts +2 -0
- package/dist/viewer.mjs +1 -0
- package/package.json +44 -5
- package/src/viewer/page.css +12 -0
- package/src/viewer/viewer.css +406 -0
|
@@ -0,0 +1,759 @@
|
|
|
1
|
+
import { Either } from "@mantaq/utils";
|
|
2
|
+
// src/schema.ts
|
|
3
|
+
/**
|
|
4
|
+
* Viz document schema. Bump `VIZ_SCHEMA_VERSION` on any breaking change.
|
|
5
|
+
* Everything not observable at runtime is `null`, never guessed.
|
|
6
|
+
*/
|
|
7
|
+
const VIZ_SCHEMA_VERSION = 7;
|
|
8
|
+
/**
|
|
9
|
+
* Edge source of an `onAny` registration: any non-final state of the machine,
|
|
10
|
+
* or `region.*` inside a region. Never listed in `states`.
|
|
11
|
+
*/
|
|
12
|
+
const ANY_STATE = "*";
|
|
13
|
+
/** Region prefix of a state id (`""` at the root). */
|
|
14
|
+
function scopeOf(id) {
|
|
15
|
+
const cut = id.lastIndexOf(".");
|
|
16
|
+
return cut === -1 ? "" : id.slice(0, cut);
|
|
17
|
+
}
|
|
18
|
+
function isAnyState(id) {
|
|
19
|
+
return id === "*" || id.endsWith(`.*`);
|
|
20
|
+
}
|
|
21
|
+
/** True when `stateId` is one of the states an `anyId` source stands for. */
|
|
22
|
+
function coveredByAny(anyId, stateId) {
|
|
23
|
+
return isAnyState(anyId) && !isAnyState(stateId) && scopeOf(anyId) === scopeOf(stateId);
|
|
24
|
+
}
|
|
25
|
+
// src/validate-read.ts
|
|
26
|
+
function isRecord(value) {
|
|
27
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
28
|
+
}
|
|
29
|
+
function report(reader, issue) {
|
|
30
|
+
reader.issues.push(issue);
|
|
31
|
+
}
|
|
32
|
+
function fail(reader, at, context) {
|
|
33
|
+
report(reader, {
|
|
34
|
+
path: `${at.path}.${at.key}`,
|
|
35
|
+
message: context.message
|
|
36
|
+
});
|
|
37
|
+
return context.fallback;
|
|
38
|
+
}
|
|
39
|
+
function readString(reader, at) {
|
|
40
|
+
const value = at.record[at.key];
|
|
41
|
+
return typeof value === "string" ? value : fail(reader, at, {
|
|
42
|
+
message: "expected string",
|
|
43
|
+
fallback: ""
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
function readNullableString(reader, at) {
|
|
47
|
+
return at.record[at.key] === null ? null : readString(reader, at);
|
|
48
|
+
}
|
|
49
|
+
function readBoolean(reader, at) {
|
|
50
|
+
const value = at.record[at.key];
|
|
51
|
+
return typeof value === "boolean" ? value : fail(reader, at, {
|
|
52
|
+
message: "expected boolean",
|
|
53
|
+
fallback: false
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
function readInteger(reader, at) {
|
|
57
|
+
const value = at.record[at.key];
|
|
58
|
+
return typeof value === "number" && Number.isInteger(value) ? value : fail(reader, at, {
|
|
59
|
+
message: "expected integer",
|
|
60
|
+
fallback: 0
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
function readStrings(reader, at) {
|
|
64
|
+
const value = at.record[at.key];
|
|
65
|
+
if (Array.isArray(value) && value.every((item) => typeof item === "string")) return value.filter((item) => typeof item === "string");
|
|
66
|
+
return fail(reader, at, {
|
|
67
|
+
message: "expected string[]",
|
|
68
|
+
fallback: []
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
function readNullableStrings(reader, at) {
|
|
72
|
+
return at.record[at.key] === null ? null : readStrings(reader, at);
|
|
73
|
+
}
|
|
74
|
+
function readEnum(reader, at, allowed) {
|
|
75
|
+
const value = at.record[at.key];
|
|
76
|
+
const match = allowed.find((candidate) => candidate === value);
|
|
77
|
+
if (match !== void 0) return match;
|
|
78
|
+
return fail(reader, at, {
|
|
79
|
+
message: `expected one of ${allowed.join(", ")}`,
|
|
80
|
+
fallback: void 0
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
function readCoverage(reader, at) {
|
|
84
|
+
const value = at.record[at.key];
|
|
85
|
+
if (value === null) return null;
|
|
86
|
+
const hits = isRecord(value) ? value["hits"] : void 0;
|
|
87
|
+
if (typeof hits === "number" && Number.isInteger(hits)) return { hits };
|
|
88
|
+
return fail(reader, at, {
|
|
89
|
+
message: "expected null or { hits: integer }",
|
|
90
|
+
fallback: null
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
function toJson(value) {
|
|
94
|
+
if (value === null || typeof value === "boolean" || typeof value === "string") return value;
|
|
95
|
+
if (typeof value === "number") return Number.isFinite(value) ? value : void 0;
|
|
96
|
+
if (Array.isArray(value)) {
|
|
97
|
+
const items = value.map(toJson);
|
|
98
|
+
return items.every((item) => item !== void 0) ? items.filter((item) => item !== void 0) : void 0;
|
|
99
|
+
}
|
|
100
|
+
if (!isRecord(value)) return void 0;
|
|
101
|
+
const entries = Object.entries(value).map(([key, child]) => [key, toJson(child)]);
|
|
102
|
+
if (entries.some(([, child]) => child === void 0)) return void 0;
|
|
103
|
+
return Object.fromEntries(entries.flatMap(([key, child]) => child === void 0 ? [] : [[key, child]]));
|
|
104
|
+
}
|
|
105
|
+
function readJson(reader, at) {
|
|
106
|
+
const value = toJson(at.record[at.key]);
|
|
107
|
+
return value === void 0 ? fail(reader, at, {
|
|
108
|
+
message: "expected JSON value",
|
|
109
|
+
fallback: null
|
|
110
|
+
}) : value;
|
|
111
|
+
}
|
|
112
|
+
function readList(reader, at, item) {
|
|
113
|
+
const value = at.record[at.key];
|
|
114
|
+
if (!Array.isArray(value)) return fail(reader, at, {
|
|
115
|
+
message: "expected array",
|
|
116
|
+
fallback: []
|
|
117
|
+
});
|
|
118
|
+
return value.flatMap((entry, index) => {
|
|
119
|
+
const path = `${at.path}.${at.key}[${index}]`;
|
|
120
|
+
if (isRecord(entry)) return [item(entry, path)];
|
|
121
|
+
report(reader, {
|
|
122
|
+
path,
|
|
123
|
+
message: "expected object"
|
|
124
|
+
});
|
|
125
|
+
return [];
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
// src/validate-explanation.ts
|
|
129
|
+
const OPERATORS = [
|
|
130
|
+
"<",
|
|
131
|
+
"<=",
|
|
132
|
+
">",
|
|
133
|
+
">=",
|
|
134
|
+
"===",
|
|
135
|
+
"!==",
|
|
136
|
+
"==",
|
|
137
|
+
"!="
|
|
138
|
+
];
|
|
139
|
+
const SOURCES = ["payload", "context"];
|
|
140
|
+
function readLocation(reader, record, path) {
|
|
141
|
+
return {
|
|
142
|
+
file: readString(reader, {
|
|
143
|
+
record,
|
|
144
|
+
key: "file",
|
|
145
|
+
path
|
|
146
|
+
}),
|
|
147
|
+
line: readInteger(reader, {
|
|
148
|
+
record,
|
|
149
|
+
key: "line",
|
|
150
|
+
path
|
|
151
|
+
}),
|
|
152
|
+
column: readInteger(reader, {
|
|
153
|
+
record,
|
|
154
|
+
key: "column",
|
|
155
|
+
path
|
|
156
|
+
})
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
function readNullableLocation(reader, record, path) {
|
|
160
|
+
const value = record["location"];
|
|
161
|
+
if (value === null) return null;
|
|
162
|
+
if (isRecord(value)) return readLocation(reader, value, `${path}.location`);
|
|
163
|
+
report(reader, {
|
|
164
|
+
path: `${path}.location`,
|
|
165
|
+
message: "expected null or object"
|
|
166
|
+
});
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
function readTarget(reader, record, path) {
|
|
170
|
+
const value = record["target"];
|
|
171
|
+
const at = `${path}.target`;
|
|
172
|
+
if (!isRecord(value)) {
|
|
173
|
+
report(reader, {
|
|
174
|
+
path: at,
|
|
175
|
+
message: "expected object"
|
|
176
|
+
});
|
|
177
|
+
return { kind: "stay" };
|
|
178
|
+
}
|
|
179
|
+
const kind = readEnum(reader, {
|
|
180
|
+
record: value,
|
|
181
|
+
key: "kind",
|
|
182
|
+
path: at
|
|
183
|
+
}, [
|
|
184
|
+
"state",
|
|
185
|
+
"stay",
|
|
186
|
+
"unresolved"
|
|
187
|
+
]);
|
|
188
|
+
if (kind === "state") return {
|
|
189
|
+
kind,
|
|
190
|
+
state: readString(reader, {
|
|
191
|
+
record: value,
|
|
192
|
+
key: "state",
|
|
193
|
+
path: at
|
|
194
|
+
})
|
|
195
|
+
};
|
|
196
|
+
if (kind === "unresolved") return {
|
|
197
|
+
kind,
|
|
198
|
+
text: readString(reader, {
|
|
199
|
+
record: value,
|
|
200
|
+
key: "text",
|
|
201
|
+
path: at
|
|
202
|
+
})
|
|
203
|
+
};
|
|
204
|
+
return { kind: "stay" };
|
|
205
|
+
}
|
|
206
|
+
function readBranch(reader, record, path) {
|
|
207
|
+
return {
|
|
208
|
+
conditions: readStrings(reader, {
|
|
209
|
+
record,
|
|
210
|
+
key: "conditions",
|
|
211
|
+
path
|
|
212
|
+
}),
|
|
213
|
+
target: readTarget(reader, record, path)
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
function readComparison(reader, record, path) {
|
|
217
|
+
const literal = record["literal"];
|
|
218
|
+
const valid = typeof literal === "number" || typeof literal === "string" || typeof literal === "boolean";
|
|
219
|
+
if (!valid) report(reader, {
|
|
220
|
+
path: `${path}.literal`,
|
|
221
|
+
message: "expected number, string or boolean"
|
|
222
|
+
});
|
|
223
|
+
return {
|
|
224
|
+
source: readEnum(reader, {
|
|
225
|
+
record,
|
|
226
|
+
key: "source",
|
|
227
|
+
path
|
|
228
|
+
}, SOURCES) ?? "payload",
|
|
229
|
+
path: readString(reader, {
|
|
230
|
+
record,
|
|
231
|
+
key: "path",
|
|
232
|
+
path
|
|
233
|
+
}),
|
|
234
|
+
operator: readEnum(reader, {
|
|
235
|
+
record,
|
|
236
|
+
key: "operator",
|
|
237
|
+
path
|
|
238
|
+
}, OPERATORS) ?? "===",
|
|
239
|
+
literal: valid ? literal : 0
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
function readLiterals(reader, record, path) {
|
|
243
|
+
const value = record["literals"];
|
|
244
|
+
if (Array.isArray(value) && value.every((item) => typeof item === "number" || typeof item === "string")) return value.filter((item) => typeof item === "number" || typeof item === "string");
|
|
245
|
+
report(reader, {
|
|
246
|
+
path: `${path}.literals`,
|
|
247
|
+
message: "expected (number | string)[]"
|
|
248
|
+
});
|
|
249
|
+
return [];
|
|
250
|
+
}
|
|
251
|
+
function readAnalyzed(reader, record, path) {
|
|
252
|
+
const location = record["location"];
|
|
253
|
+
return {
|
|
254
|
+
status: "analyzed",
|
|
255
|
+
location: isRecord(location) ? readLocation(reader, location, `${path}.location`) : readLocation(reader, {}, `${path}.location`),
|
|
256
|
+
registration: readEnum(reader, {
|
|
257
|
+
record,
|
|
258
|
+
key: "registration",
|
|
259
|
+
path
|
|
260
|
+
}, ["on", "onAny"]) ?? "on",
|
|
261
|
+
branches: readList(reader, {
|
|
262
|
+
record,
|
|
263
|
+
key: "branches",
|
|
264
|
+
path
|
|
265
|
+
}, (item, at) => readBranch(reader, item, at)),
|
|
266
|
+
payloadReads: readStrings(reader, {
|
|
267
|
+
record,
|
|
268
|
+
key: "payloadReads",
|
|
269
|
+
path
|
|
270
|
+
}),
|
|
271
|
+
contextReads: readStrings(reader, {
|
|
272
|
+
record,
|
|
273
|
+
key: "contextReads",
|
|
274
|
+
path
|
|
275
|
+
}),
|
|
276
|
+
contextWrites: readStrings(reader, {
|
|
277
|
+
record,
|
|
278
|
+
key: "contextWrites",
|
|
279
|
+
path
|
|
280
|
+
}),
|
|
281
|
+
readsActorState: readBoolean(reader, {
|
|
282
|
+
record,
|
|
283
|
+
key: "readsActorState",
|
|
284
|
+
path
|
|
285
|
+
}),
|
|
286
|
+
comparisons: readList(reader, {
|
|
287
|
+
record,
|
|
288
|
+
key: "comparisons",
|
|
289
|
+
path
|
|
290
|
+
}, (item, at) => readComparison(reader, item, at)),
|
|
291
|
+
literals: readLiterals(reader, record, path)
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
function readExplanation(reader, at) {
|
|
295
|
+
const value = at.record["explanation"];
|
|
296
|
+
const path = `${at.path}.explanation`;
|
|
297
|
+
if (value === null) return null;
|
|
298
|
+
if (!isRecord(value)) {
|
|
299
|
+
report(reader, {
|
|
300
|
+
path,
|
|
301
|
+
message: "expected null or object"
|
|
302
|
+
});
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
305
|
+
if (readEnum(reader, {
|
|
306
|
+
record: value,
|
|
307
|
+
key: "status",
|
|
308
|
+
path
|
|
309
|
+
}, ["analyzed", "not_analyzed"]) === "analyzed") return readAnalyzed(reader, value, path);
|
|
310
|
+
return {
|
|
311
|
+
status: "not_analyzed",
|
|
312
|
+
location: readNullableLocation(reader, value, path),
|
|
313
|
+
reason: readString(reader, {
|
|
314
|
+
record: value,
|
|
315
|
+
key: "reason",
|
|
316
|
+
path
|
|
317
|
+
}),
|
|
318
|
+
snippet: readNullableString(reader, {
|
|
319
|
+
record: value,
|
|
320
|
+
key: "snippet",
|
|
321
|
+
path
|
|
322
|
+
})
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
// src/validate-fuzz.ts
|
|
326
|
+
function readStep(reader, record, path) {
|
|
327
|
+
if (record["kind"] === "advance") return {
|
|
328
|
+
kind: "advance",
|
|
329
|
+
ms: readInteger(reader, {
|
|
330
|
+
record,
|
|
331
|
+
key: "ms",
|
|
332
|
+
path
|
|
333
|
+
})
|
|
334
|
+
};
|
|
335
|
+
if (record["kind"] !== "event") report(reader, {
|
|
336
|
+
path: `${path}.kind`,
|
|
337
|
+
message: "expected event or advance"
|
|
338
|
+
});
|
|
339
|
+
return {
|
|
340
|
+
kind: "event",
|
|
341
|
+
event: readString(reader, {
|
|
342
|
+
record,
|
|
343
|
+
key: "event",
|
|
344
|
+
path
|
|
345
|
+
}),
|
|
346
|
+
payload: readJson(reader, {
|
|
347
|
+
record,
|
|
348
|
+
key: "payload",
|
|
349
|
+
path
|
|
350
|
+
})
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
function readSteps(reader, record, path) {
|
|
354
|
+
return readList(reader, {
|
|
355
|
+
record,
|
|
356
|
+
key: "steps",
|
|
357
|
+
path
|
|
358
|
+
}, (item, at) => readStep(reader, item, at));
|
|
359
|
+
}
|
|
360
|
+
function readSequence(reader, record, path) {
|
|
361
|
+
return {
|
|
362
|
+
steps: readSteps(reader, record, path),
|
|
363
|
+
discovered: readStrings(reader, {
|
|
364
|
+
record,
|
|
365
|
+
key: "discovered",
|
|
366
|
+
path
|
|
367
|
+
})
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
function readError(reader, record, path) {
|
|
371
|
+
return {
|
|
372
|
+
from: readString(reader, {
|
|
373
|
+
record,
|
|
374
|
+
key: "from",
|
|
375
|
+
path
|
|
376
|
+
}),
|
|
377
|
+
event: readString(reader, {
|
|
378
|
+
record,
|
|
379
|
+
key: "event",
|
|
380
|
+
path
|
|
381
|
+
}),
|
|
382
|
+
message: readString(reader, {
|
|
383
|
+
record,
|
|
384
|
+
key: "message",
|
|
385
|
+
path
|
|
386
|
+
}),
|
|
387
|
+
steps: readSteps(reader, record, path)
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
function readFuzz(reader, at) {
|
|
391
|
+
const value = at.record["fuzz"];
|
|
392
|
+
if (value === null) return null;
|
|
393
|
+
const path = `${at.path}.fuzz`;
|
|
394
|
+
if (!isRecord(value)) {
|
|
395
|
+
report(reader, {
|
|
396
|
+
path,
|
|
397
|
+
message: "expected null or object"
|
|
398
|
+
});
|
|
399
|
+
return null;
|
|
400
|
+
}
|
|
401
|
+
return {
|
|
402
|
+
seed: readInteger(reader, {
|
|
403
|
+
record: value,
|
|
404
|
+
key: "seed",
|
|
405
|
+
path
|
|
406
|
+
}),
|
|
407
|
+
iterations: readInteger(reader, {
|
|
408
|
+
record: value,
|
|
409
|
+
key: "iterations",
|
|
410
|
+
path
|
|
411
|
+
}),
|
|
412
|
+
corpus: readList(reader, {
|
|
413
|
+
record: value,
|
|
414
|
+
key: "corpus",
|
|
415
|
+
path
|
|
416
|
+
}, (item, itemPath) => readSequence(reader, item, itemPath)),
|
|
417
|
+
errors: readList(reader, {
|
|
418
|
+
record: value,
|
|
419
|
+
key: "errors",
|
|
420
|
+
path
|
|
421
|
+
}, (item, itemPath) => readError(reader, item, itemPath))
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
// src/validate.ts
|
|
425
|
+
const EDGE_KINDS = [
|
|
426
|
+
"initial",
|
|
427
|
+
"transition",
|
|
428
|
+
"effect",
|
|
429
|
+
"stay"
|
|
430
|
+
];
|
|
431
|
+
const EDGE_ORIGINS = [
|
|
432
|
+
"declared",
|
|
433
|
+
"probe",
|
|
434
|
+
"fuzz"
|
|
435
|
+
];
|
|
436
|
+
function readState(reader, record, path) {
|
|
437
|
+
return {
|
|
438
|
+
id: readString(reader, {
|
|
439
|
+
record,
|
|
440
|
+
key: "id",
|
|
441
|
+
path
|
|
442
|
+
}),
|
|
443
|
+
label: readString(reader, {
|
|
444
|
+
record,
|
|
445
|
+
key: "label",
|
|
446
|
+
path
|
|
447
|
+
}),
|
|
448
|
+
region: readNullableString(reader, {
|
|
449
|
+
record,
|
|
450
|
+
key: "region",
|
|
451
|
+
path
|
|
452
|
+
}),
|
|
453
|
+
initial: readBoolean(reader, {
|
|
454
|
+
record,
|
|
455
|
+
key: "initial",
|
|
456
|
+
path
|
|
457
|
+
}),
|
|
458
|
+
final: readBoolean(reader, {
|
|
459
|
+
record,
|
|
460
|
+
key: "final",
|
|
461
|
+
path
|
|
462
|
+
}),
|
|
463
|
+
effects: readList(reader, {
|
|
464
|
+
record,
|
|
465
|
+
key: "effects",
|
|
466
|
+
path
|
|
467
|
+
}, (item, at) => ({
|
|
468
|
+
name: readString(reader, {
|
|
469
|
+
record: item,
|
|
470
|
+
key: "name",
|
|
471
|
+
path: at
|
|
472
|
+
}),
|
|
473
|
+
sends: readNullableStrings(reader, {
|
|
474
|
+
record: item,
|
|
475
|
+
key: "sends",
|
|
476
|
+
path: at
|
|
477
|
+
})
|
|
478
|
+
})),
|
|
479
|
+
coverage: readCoverage(reader, {
|
|
480
|
+
record,
|
|
481
|
+
key: "coverage",
|
|
482
|
+
path
|
|
483
|
+
}),
|
|
484
|
+
tested: readCoverage(reader, {
|
|
485
|
+
record,
|
|
486
|
+
key: "tested",
|
|
487
|
+
path
|
|
488
|
+
})
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
function readEvent(reader, record, path) {
|
|
492
|
+
if (record["payloadType"] !== null) report(reader, {
|
|
493
|
+
path: `${path}.payloadType`,
|
|
494
|
+
message: "expected null"
|
|
495
|
+
});
|
|
496
|
+
return {
|
|
497
|
+
id: readString(reader, {
|
|
498
|
+
record,
|
|
499
|
+
key: "id",
|
|
500
|
+
path
|
|
501
|
+
}),
|
|
502
|
+
internal: readBoolean(reader, {
|
|
503
|
+
record,
|
|
504
|
+
key: "internal",
|
|
505
|
+
path
|
|
506
|
+
}),
|
|
507
|
+
payloadType: null
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
function readEdge(reader, record, path) {
|
|
511
|
+
return {
|
|
512
|
+
id: readString(reader, {
|
|
513
|
+
record,
|
|
514
|
+
key: "id",
|
|
515
|
+
path
|
|
516
|
+
}),
|
|
517
|
+
from: readString(reader, {
|
|
518
|
+
record,
|
|
519
|
+
key: "from",
|
|
520
|
+
path
|
|
521
|
+
}),
|
|
522
|
+
event: readString(reader, {
|
|
523
|
+
record,
|
|
524
|
+
key: "event",
|
|
525
|
+
path
|
|
526
|
+
}),
|
|
527
|
+
to: readNullableString(reader, {
|
|
528
|
+
record,
|
|
529
|
+
key: "to",
|
|
530
|
+
path
|
|
531
|
+
}),
|
|
532
|
+
kind: readEnum(reader, {
|
|
533
|
+
record,
|
|
534
|
+
key: "kind",
|
|
535
|
+
path
|
|
536
|
+
}, EDGE_KINDS) ?? "transition",
|
|
537
|
+
origin: readEnum(reader, {
|
|
538
|
+
record,
|
|
539
|
+
key: "origin",
|
|
540
|
+
path
|
|
541
|
+
}, EDGE_ORIGINS) ?? "probe",
|
|
542
|
+
emits: readStrings(reader, {
|
|
543
|
+
record,
|
|
544
|
+
key: "emits",
|
|
545
|
+
path
|
|
546
|
+
}),
|
|
547
|
+
contexts: readStrings(reader, {
|
|
548
|
+
record,
|
|
549
|
+
key: "contexts",
|
|
550
|
+
path
|
|
551
|
+
}),
|
|
552
|
+
explanation: readExplanation(reader, {
|
|
553
|
+
record,
|
|
554
|
+
path
|
|
555
|
+
}),
|
|
556
|
+
coverage: readCoverage(reader, {
|
|
557
|
+
record,
|
|
558
|
+
key: "coverage",
|
|
559
|
+
path
|
|
560
|
+
}),
|
|
561
|
+
tested: readCoverage(reader, {
|
|
562
|
+
record,
|
|
563
|
+
key: "tested",
|
|
564
|
+
path
|
|
565
|
+
})
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
/** Edge endpoints must name declared states; a dangling reference is a broken document. */
|
|
569
|
+
function checkReferences(reader, machine, path) {
|
|
570
|
+
const stateIds = new Set(machine.states.map((state) => state.id));
|
|
571
|
+
const known = (id) => stateIds.has(id) || id === "__initial__" || isAnyState(id);
|
|
572
|
+
machine.edges.forEach((edge, index) => {
|
|
573
|
+
if (!known(edge.from)) report(reader, {
|
|
574
|
+
path: `${path}.edges[${index}].from`,
|
|
575
|
+
message: `unknown state ${edge.from}`
|
|
576
|
+
});
|
|
577
|
+
const anyStay = edge.kind === "stay" && edge.to === edge.from && isAnyState(edge.from);
|
|
578
|
+
if (edge.to !== null && !stateIds.has(edge.to) && !anyStay) report(reader, {
|
|
579
|
+
path: `${path}.edges[${index}].to`,
|
|
580
|
+
message: `unknown state ${edge.to}`
|
|
581
|
+
});
|
|
582
|
+
});
|
|
583
|
+
if (machine.initial !== null && !stateIds.has(machine.initial)) report(reader, {
|
|
584
|
+
path: `${path}.initial`,
|
|
585
|
+
message: `unknown state ${machine.initial}`
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
function readMachine(reader, record, path) {
|
|
589
|
+
const machine = {
|
|
590
|
+
id: readString(reader, {
|
|
591
|
+
record,
|
|
592
|
+
key: "id",
|
|
593
|
+
path
|
|
594
|
+
}),
|
|
595
|
+
initial: readNullableString(reader, {
|
|
596
|
+
record,
|
|
597
|
+
key: "initial",
|
|
598
|
+
path
|
|
599
|
+
}),
|
|
600
|
+
regions: readStrings(reader, {
|
|
601
|
+
record,
|
|
602
|
+
key: "regions",
|
|
603
|
+
path
|
|
604
|
+
}),
|
|
605
|
+
states: readList(reader, {
|
|
606
|
+
record,
|
|
607
|
+
key: "states",
|
|
608
|
+
path
|
|
609
|
+
}, (item, at) => readState(reader, item, at)),
|
|
610
|
+
events: readList(reader, {
|
|
611
|
+
record,
|
|
612
|
+
key: "events",
|
|
613
|
+
path
|
|
614
|
+
}, (item, at) => readEvent(reader, item, at)),
|
|
615
|
+
edges: readList(reader, {
|
|
616
|
+
record,
|
|
617
|
+
key: "edges",
|
|
618
|
+
path
|
|
619
|
+
}, (item, at) => readEdge(reader, item, at)),
|
|
620
|
+
fuzz: readFuzz(reader, {
|
|
621
|
+
record,
|
|
622
|
+
path
|
|
623
|
+
})
|
|
624
|
+
};
|
|
625
|
+
checkReferences(reader, machine, path);
|
|
626
|
+
return machine;
|
|
627
|
+
}
|
|
628
|
+
/**
|
|
629
|
+
* Read an untrusted value (parsed JSON) as an viz document. Returns every
|
|
630
|
+
* issue found, not just the first, so a broken file can be fixed in one pass.
|
|
631
|
+
*/
|
|
632
|
+
function validateViz(value) {
|
|
633
|
+
const reader = { issues: [] };
|
|
634
|
+
if (!isRecord(value)) return Either.left([{
|
|
635
|
+
path: "$",
|
|
636
|
+
message: "expected object"
|
|
637
|
+
}]);
|
|
638
|
+
if (value["schemaVersion"] !== 7) return Either.left([{
|
|
639
|
+
path: "$.schemaVersion",
|
|
640
|
+
message: `expected 7, got ${JSON.stringify(value["schemaVersion"])}`
|
|
641
|
+
}]);
|
|
642
|
+
const machines = readList(reader, {
|
|
643
|
+
record: value,
|
|
644
|
+
key: "machines",
|
|
645
|
+
path: "$"
|
|
646
|
+
}, (item, at) => readMachine(reader, item, at));
|
|
647
|
+
if (reader.issues.length > 0) return Either.left(reader.issues);
|
|
648
|
+
const document = {
|
|
649
|
+
schemaVersion: 7,
|
|
650
|
+
machines
|
|
651
|
+
};
|
|
652
|
+
return Either.right(document);
|
|
653
|
+
}
|
|
654
|
+
/** Parse JSON text and validate it. JSON syntax errors become an issue at `$`. */
|
|
655
|
+
function parseViz(text) {
|
|
656
|
+
const parsed = Either.from(() => JSON.parse(text));
|
|
657
|
+
if (Either.isLeft(parsed)) {
|
|
658
|
+
const reason = parsed[0] instanceof Error ? parsed[0].message : String(parsed[0]);
|
|
659
|
+
return Either.left([{
|
|
660
|
+
path: "$",
|
|
661
|
+
message: `invalid JSON: ${reason}`
|
|
662
|
+
}]);
|
|
663
|
+
}
|
|
664
|
+
return validateViz(parsed[1]);
|
|
665
|
+
}
|
|
666
|
+
// src/moves.ts
|
|
667
|
+
const GROUP_ORDER = {
|
|
668
|
+
input: 0,
|
|
669
|
+
effect: 1,
|
|
670
|
+
internal: 2
|
|
671
|
+
};
|
|
672
|
+
function targetsOf(edge, current) {
|
|
673
|
+
if (edge.kind === "stay") return [current];
|
|
674
|
+
if (edge.to !== null) return [edge.to];
|
|
675
|
+
if (edge.explanation?.status !== "analyzed") return [];
|
|
676
|
+
const targets = edge.explanation.branches.flatMap((branch) => {
|
|
677
|
+
if (branch.target.kind === "state") return [branch.target.state];
|
|
678
|
+
return branch.target.kind === "stay" ? [current] : [];
|
|
679
|
+
});
|
|
680
|
+
return [...new Set(targets)].sort();
|
|
681
|
+
}
|
|
682
|
+
function sendersOf(machine, state) {
|
|
683
|
+
const senders = /* @__PURE__ */ new Map();
|
|
684
|
+
const effects = machine.states.find((each) => each.id === state)?.effects ?? [];
|
|
685
|
+
for (const effect of effects) for (const sent of effect.sends ?? []) senders.set(sent, [...senders.get(sent) ?? [], effect.name]);
|
|
686
|
+
return senders;
|
|
687
|
+
}
|
|
688
|
+
/**
|
|
689
|
+
* Every edge an event can take out of `state`: its own registrations and the
|
|
690
|
+
* `onAny` ones. A final state has none: the actor has stopped.
|
|
691
|
+
*/
|
|
692
|
+
function movesFrom(machine, state) {
|
|
693
|
+
const current = machine.states.find((each) => each.id === state);
|
|
694
|
+
if (current === void 0 || current.final) return [];
|
|
695
|
+
const internal = new Set(machine.events.filter((each) => each.internal).map((each) => each.id));
|
|
696
|
+
const senders = sendersOf(machine, state);
|
|
697
|
+
return machine.edges.filter((edge) => (edge.kind === "transition" || edge.kind === "stay") && (edge.from === state || coveredByAny(edge.from, state))).map((edge) => {
|
|
698
|
+
const sentBy = senders.get(edge.event) ?? [];
|
|
699
|
+
const group = !internal.has(edge.event) ? "input" : sentBy.length > 0 ? "effect" : "internal";
|
|
700
|
+
return {
|
|
701
|
+
edge,
|
|
702
|
+
targets: targetsOf(edge, state),
|
|
703
|
+
group,
|
|
704
|
+
sentBy
|
|
705
|
+
};
|
|
706
|
+
}).sort((left, right) => GROUP_ORDER[left.group] - GROUP_ORDER[right.group] || left.edge.event.localeCompare(right.edge.event) || left.edge.id.localeCompare(right.edge.id));
|
|
707
|
+
}
|
|
708
|
+
/** Where a path ends: the last step's target, or the initial state. */
|
|
709
|
+
function stateAfter(machine, path) {
|
|
710
|
+
return path.at(-1)?.to ?? machine.initial;
|
|
711
|
+
}
|
|
712
|
+
/** Events an effect of `state` declares in `sends` that no handler in `state` takes. */
|
|
713
|
+
function unhandledSends(machine, state) {
|
|
714
|
+
const handled = new Set(movesFrom(machine, state).map((move) => move.edge.event));
|
|
715
|
+
return (machine.states.find((each) => each.id === state)?.effects ?? []).flatMap((effect) => (effect.sends ?? []).filter((event) => !handled.has(event)).map((event) => ({
|
|
716
|
+
effect: effect.name,
|
|
717
|
+
event
|
|
718
|
+
})));
|
|
719
|
+
}
|
|
720
|
+
/** One step as text for a URL: the edge id, plus `~target` when the edge has no single target. */
|
|
721
|
+
function encodeStep(machine, step) {
|
|
722
|
+
const edge = machine.edges.find((each) => each.id === step.edge);
|
|
723
|
+
return edge !== void 0 && edge.to === null && edge.kind !== "stay" ? `${step.edge}~${step.to}` : step.edge;
|
|
724
|
+
}
|
|
725
|
+
/**
|
|
726
|
+
* Rebuild a path from encoded steps (e.g. `?step=` in a shared link). Stops at
|
|
727
|
+
* the first step the graph does not allow from where the path has got to, so
|
|
728
|
+
* a link from an older document still opens at its last valid point.
|
|
729
|
+
*/
|
|
730
|
+
function decodePath(machine, encoded) {
|
|
731
|
+
const path = [];
|
|
732
|
+
for (const text of encoded) {
|
|
733
|
+
const cut = text.lastIndexOf("~");
|
|
734
|
+
const edgeId = cut === -1 ? text : text.slice(0, cut);
|
|
735
|
+
const from = stateAfter(machine, path);
|
|
736
|
+
const move = from === null ? void 0 : movesFrom(machine, from).find((each) => each.edge.id === edgeId);
|
|
737
|
+
const to = cut === -1 && move?.targets.length === 1 ? move.targets[0] : text.slice(cut + 1);
|
|
738
|
+
if (move === void 0 || to === void 0 || !move.targets.includes(to)) break;
|
|
739
|
+
path.push({
|
|
740
|
+
edge: edgeId,
|
|
741
|
+
to
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
return path;
|
|
745
|
+
}
|
|
746
|
+
// src/routing.ts
|
|
747
|
+
/**
|
|
748
|
+
* `onAny` handlers that pick their target from the current state. Drawn from
|
|
749
|
+
* `*`, they claim every declared target from every state; per-state `m.on`
|
|
750
|
+
* registrations would draw the exact source/target pairs.
|
|
751
|
+
*/
|
|
752
|
+
function routingWarnings(machine) {
|
|
753
|
+
return [...new Set(machine.edges.filter((edge) => isAnyState(edge.from) && edge.explanation?.status === "analyzed" && edge.explanation.readsActorState).map((edge) => edge.event))].sort().map((event) => ({
|
|
754
|
+
machine: machine.id,
|
|
755
|
+
event,
|
|
756
|
+
message: `onAny(${event}) reads actor state to pick its target, so the diagram cannot tell which state leads where. Register m.on per state instead.`
|
|
757
|
+
}));
|
|
758
|
+
}
|
|
759
|
+
export { stateAfter as a, validateViz as c, coveredByAny as d, isAnyState as f, movesFrom as i, ANY_STATE as l, decodePath as n, unhandledSends as o, scopeOf as p, encodeStep as r, parseViz as s, routingWarnings as t, VIZ_SCHEMA_VERSION as u };
|