@mantaq/inspect 0.0.0 → 0.2.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 +96 -0
- package/dist/cli.d.mts +1 -0
- package/dist/cli.mjs +108 -0
- package/dist/drive.d.mts +7 -0
- package/dist/drive.mjs +92 -0
- package/dist/index.d.mts +152 -0
- package/dist/index.mjs +4 -0
- package/dist/inspect-DvQtAcwv.d.mts +66 -0
- package/dist/live-entry.d.mts +1 -0
- package/dist/live-entry.mjs +106 -0
- package/dist/load-DOwsxTyL.mjs +1840 -0
- package/dist/page-rr9IQ-4k.mjs +38 -0
- package/dist/run-B5huYj_r.mjs +117 -0
- package/dist/run-BLt0mwXe.d.mts +60 -0
- package/dist/vite.d.mts +11 -0
- package/dist/vite.mjs +159 -0
- package/package.json +53 -5
|
@@ -0,0 +1,1840 @@
|
|
|
1
|
+
import { i as execute, r as eventCatalog } from "./run-B5huYj_r.mjs";
|
|
2
|
+
import { ANY_STATE, VIZ_SCHEMA_VERSION, coveredByAny, isAnyState, routingWarnings, unhandledSends } from "@mantaq/viz";
|
|
3
|
+
import { INITIAL_NODE_ID, buildGraph, parseInternalEventIds } from "@mantaq/traversal";
|
|
4
|
+
import { Either } from "@mantaq/utils";
|
|
5
|
+
import ts from "typescript";
|
|
6
|
+
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
7
|
+
import { DEFAULT_SEED } from "@mantaq/pbt";
|
|
8
|
+
import { VirtualClock } from "@mantaq/core";
|
|
9
|
+
import { existsSync } from "node:fs";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
import { loadConfigFromFile, loadEnv, runnerImport } from "vite";
|
|
12
|
+
// src/structure.ts
|
|
13
|
+
function byId$1(left, right) {
|
|
14
|
+
if (left.id < right.id) return -1;
|
|
15
|
+
return left.id > right.id ? 1 : 0;
|
|
16
|
+
}
|
|
17
|
+
function regionOf(nodeId) {
|
|
18
|
+
const cut = nodeId.lastIndexOf(".");
|
|
19
|
+
return cut === -1 ? null : nodeId.slice(0, cut);
|
|
20
|
+
}
|
|
21
|
+
function countBy(keys) {
|
|
22
|
+
const counts = /* @__PURE__ */ new Map();
|
|
23
|
+
for (const key of keys) counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
24
|
+
return counts;
|
|
25
|
+
}
|
|
26
|
+
function hits(count) {
|
|
27
|
+
return { hits: count ?? 0 };
|
|
28
|
+
}
|
|
29
|
+
function toState(node, context) {
|
|
30
|
+
const { visits } = context;
|
|
31
|
+
return {
|
|
32
|
+
id: node.id,
|
|
33
|
+
label: node.label,
|
|
34
|
+
region: regionOf(node.id),
|
|
35
|
+
initial: node.id === context.initialId,
|
|
36
|
+
final: node.isFinal,
|
|
37
|
+
effects: context.effects.find((edge) => edge.source === node.id)?.effects ?? [],
|
|
38
|
+
coverage: visits ? hits(visits.get(node.id)) : null,
|
|
39
|
+
tested: null,
|
|
40
|
+
doc: null
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
function edgeKind(edge) {
|
|
44
|
+
if (edge.source === INITIAL_NODE_ID) return "initial";
|
|
45
|
+
if (edge.isStay === true) return "stay";
|
|
46
|
+
return edge.isInternal === true && edge.label.startsWith("effect:") ? "effect" : "transition";
|
|
47
|
+
}
|
|
48
|
+
function emitsOf(edge) {
|
|
49
|
+
const match = (edge.payload?.action)?.match(/^emit\((.*)\)$/);
|
|
50
|
+
return match?.[1] ? match[1].split(", ").sort() : [];
|
|
51
|
+
}
|
|
52
|
+
/** Count of recorded transitions matching an edge by source, event and (if determined) target. */
|
|
53
|
+
function edgeHits$1(edge, transitions) {
|
|
54
|
+
if (isAnyState(edge.source)) {
|
|
55
|
+
let total = 0;
|
|
56
|
+
for (const [key, byTarget] of transitions) {
|
|
57
|
+
const [from = "", event] = key.split("\0");
|
|
58
|
+
if (event !== edge.label || !coveredByAny(edge.source, from)) continue;
|
|
59
|
+
total += isAnyHit(edge, byTarget);
|
|
60
|
+
}
|
|
61
|
+
return total;
|
|
62
|
+
}
|
|
63
|
+
const byTarget = transitions.get(`${edge.source}\u0000${edge.label}`);
|
|
64
|
+
if (!byTarget) return 0;
|
|
65
|
+
if (edge.isUndetermined === true) return [...byTarget.values()].reduce((sum, count) => sum + count, 0);
|
|
66
|
+
return byTarget.get(edge.target) ?? 0;
|
|
67
|
+
}
|
|
68
|
+
/** Hits of an `onAny` edge from one source: its target, or every outcome when undetermined. */
|
|
69
|
+
function isAnyHit(edge, byTarget) {
|
|
70
|
+
if (edge.isUndetermined === true) return [...byTarget.values()].reduce((sum, n) => sum + n, 0);
|
|
71
|
+
return byTarget.get(edge.target) ?? 0;
|
|
72
|
+
}
|
|
73
|
+
function indexTransitions(history) {
|
|
74
|
+
const index = /* @__PURE__ */ new Map();
|
|
75
|
+
for (const record of history.transitions()) {
|
|
76
|
+
const key = `${record.from}\u0000${record.event}`;
|
|
77
|
+
const byTarget = index.get(key) ?? /* @__PURE__ */ new Map();
|
|
78
|
+
const target = record.to ?? "";
|
|
79
|
+
byTarget.set(target, (byTarget.get(target) ?? 0) + 1);
|
|
80
|
+
index.set(key, byTarget);
|
|
81
|
+
}
|
|
82
|
+
return index;
|
|
83
|
+
}
|
|
84
|
+
function toEdge(edge, transitions) {
|
|
85
|
+
const kind = edgeKind(edge);
|
|
86
|
+
return {
|
|
87
|
+
id: edge.id,
|
|
88
|
+
from: edge.source,
|
|
89
|
+
event: kind === "initial" ? "" : edge.label,
|
|
90
|
+
to: edge.isUndetermined === true ? null : edge.target,
|
|
91
|
+
kind,
|
|
92
|
+
origin: edge.isDeclared === true ? "declared" : "probe",
|
|
93
|
+
emits: emitsOf(edge),
|
|
94
|
+
contexts: [...new Set(edge.contexts)].sort(),
|
|
95
|
+
explanation: null,
|
|
96
|
+
doc: null,
|
|
97
|
+
coverage: transitions && kind === "transition" ? hits(edgeHits$1(edge, transitions)) : null,
|
|
98
|
+
tested: null
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
/** One machine; `toViz` is this plus sorting into a document. */
|
|
102
|
+
function toVizMachine(input) {
|
|
103
|
+
const graph = buildGraph(input.actor, { sampleContexts: input.sampleContexts });
|
|
104
|
+
const internalIds = parseInternalEventIds(input.actor.options);
|
|
105
|
+
const initial = graph.edges.find((edge) => edge.source === INITIAL_NODE_ID)?.target ?? null;
|
|
106
|
+
const visits = input.history ? countBy(input.history.stateVisits().map((visit) => visit.stateName)) : void 0;
|
|
107
|
+
const transitions = input.history ? indexTransitions(input.history) : void 0;
|
|
108
|
+
const effectEdges = graph.edges.filter((edge) => edgeKind(edge) === "effect");
|
|
109
|
+
const states = graph.nodes.filter((node) => node.id !== INITIAL_NODE_ID && node.isAny !== true).map((node) => toState(node, {
|
|
110
|
+
visits,
|
|
111
|
+
initialId: initial,
|
|
112
|
+
effects: effectEdges
|
|
113
|
+
})).sort(byId$1);
|
|
114
|
+
const edges = graph.edges.map((edge) => toEdge(edge, transitions)).sort(byId$1);
|
|
115
|
+
const events = [...new Set(edges.map((edge) => edge.event).filter(Boolean))].filter((eventId) => !eventId.startsWith("effect:")).sort().map((eventId) => ({
|
|
116
|
+
id: eventId,
|
|
117
|
+
internal: internalIds.has(eventId),
|
|
118
|
+
doc: null,
|
|
119
|
+
payloadType: null
|
|
120
|
+
}));
|
|
121
|
+
const regions = [...new Set(states.flatMap((state) => state.region ? [state.region] : []))].sort();
|
|
122
|
+
return {
|
|
123
|
+
id: input.id,
|
|
124
|
+
initial,
|
|
125
|
+
regions,
|
|
126
|
+
states,
|
|
127
|
+
events,
|
|
128
|
+
edges,
|
|
129
|
+
fuzz: null
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
function toViz(inputs) {
|
|
133
|
+
return {
|
|
134
|
+
schemaVersion: VIZ_SCHEMA_VERSION,
|
|
135
|
+
machines: inputs.map(toVizMachine).sort(byId$1)
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
// src/fuzz/mutate.ts
|
|
139
|
+
const REPEAT_COUNTS = [
|
|
140
|
+
2,
|
|
141
|
+
3,
|
|
142
|
+
5,
|
|
143
|
+
8,
|
|
144
|
+
13
|
|
145
|
+
];
|
|
146
|
+
const MAX_REPEAT = 64;
|
|
147
|
+
const ADVANCES = [
|
|
148
|
+
1,
|
|
149
|
+
10,
|
|
150
|
+
100,
|
|
151
|
+
500,
|
|
152
|
+
1e3,
|
|
153
|
+
5e3
|
|
154
|
+
];
|
|
155
|
+
function isObject(value) {
|
|
156
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
157
|
+
}
|
|
158
|
+
function freshEvent(context) {
|
|
159
|
+
const event = context.rng.pick(context.events) ?? "";
|
|
160
|
+
const samples = context.payloads[event] ?? [];
|
|
161
|
+
return {
|
|
162
|
+
kind: "event",
|
|
163
|
+
event,
|
|
164
|
+
payload: context.rng.pick(samples) ?? null
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
function freshStep(context) {
|
|
168
|
+
if (context.rng.chance(.1)) return {
|
|
169
|
+
kind: "advance",
|
|
170
|
+
ms: context.rng.pick(ADVANCES) ?? 1
|
|
171
|
+
};
|
|
172
|
+
return freshEvent(context);
|
|
173
|
+
}
|
|
174
|
+
function mutateNumber(value, context) {
|
|
175
|
+
const { rng, literals } = context;
|
|
176
|
+
if (literals.numbers.length > 0 && rng.chance(.5)) return rng.pick(literals.numbers) ?? value;
|
|
177
|
+
const options = [
|
|
178
|
+
0,
|
|
179
|
+
-1,
|
|
180
|
+
1,
|
|
181
|
+
value + 1,
|
|
182
|
+
value - 1,
|
|
183
|
+
value * 2,
|
|
184
|
+
Math.trunc(value / 2),
|
|
185
|
+
rng.int(2001) - 1e3
|
|
186
|
+
];
|
|
187
|
+
return options[rng.int(options.length)] ?? value;
|
|
188
|
+
}
|
|
189
|
+
function mutateString(value, context) {
|
|
190
|
+
const { rng, literals } = context;
|
|
191
|
+
if (literals.strings.length > 0 && rng.chance(.5)) return rng.pick(literals.strings) ?? value;
|
|
192
|
+
const options = [
|
|
193
|
+
"",
|
|
194
|
+
"x",
|
|
195
|
+
`${value}x`,
|
|
196
|
+
value.slice(1),
|
|
197
|
+
value.toUpperCase()
|
|
198
|
+
];
|
|
199
|
+
return options[rng.int(options.length)] ?? value;
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Replace one leaf somewhere inside a JSON value, keeping its JSON type. Keys
|
|
203
|
+
* are never dropped: payloads stay shaped like the samples, so a handler that
|
|
204
|
+
* throws is a real finding, not a type violation the compiler already rejects.
|
|
205
|
+
*/
|
|
206
|
+
function mutateJson(value, context) {
|
|
207
|
+
if (typeof value === "number") return mutateNumber(value, context);
|
|
208
|
+
if (typeof value === "string") return mutateString(value, context);
|
|
209
|
+
if (typeof value === "boolean") return !value;
|
|
210
|
+
if (Array.isArray(value)) {
|
|
211
|
+
if (value.length === 0) return value;
|
|
212
|
+
const at = context.rng.int(value.length);
|
|
213
|
+
return value.map((item, index) => index === at ? mutateJson(item, context) : item);
|
|
214
|
+
}
|
|
215
|
+
if (!isObject(value)) return value;
|
|
216
|
+
const keys = Object.keys(value).sort();
|
|
217
|
+
const key = context.rng.pick(keys);
|
|
218
|
+
if (key === void 0) return value;
|
|
219
|
+
return {
|
|
220
|
+
...value,
|
|
221
|
+
[key]: mutateJson(value[key] ?? null, context)
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
function replaceAt(steps, at, step) {
|
|
225
|
+
return steps.map((current, index) => index === at ? step : current);
|
|
226
|
+
}
|
|
227
|
+
function mutatePayload(steps, context) {
|
|
228
|
+
const withPayload = steps.flatMap((step, index) => step.kind === "event" && step.payload !== null ? [index] : []);
|
|
229
|
+
const at = context.rng.pick(withPayload);
|
|
230
|
+
const step = at === void 0 ? void 0 : steps[at];
|
|
231
|
+
if (at === void 0 || step?.kind !== "event") return steps;
|
|
232
|
+
return replaceAt(steps, at, {
|
|
233
|
+
...step,
|
|
234
|
+
payload: mutateJson(step.payload, context)
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
function insert(steps, context) {
|
|
238
|
+
if (steps.length >= context.maxLength) return steps;
|
|
239
|
+
const at = context.rng.chance(.6) ? steps.length : context.rng.int(steps.length + 1);
|
|
240
|
+
return [
|
|
241
|
+
...steps.slice(0, at),
|
|
242
|
+
freshStep(context),
|
|
243
|
+
...steps.slice(at)
|
|
244
|
+
];
|
|
245
|
+
}
|
|
246
|
+
function remove(steps, context) {
|
|
247
|
+
if (steps.length === 0) return steps;
|
|
248
|
+
const at = context.rng.int(steps.length);
|
|
249
|
+
return steps.filter((_, index) => index !== at);
|
|
250
|
+
}
|
|
251
|
+
function swap(steps, context) {
|
|
252
|
+
if (steps.length < 2) return steps;
|
|
253
|
+
const at = context.rng.int(steps.length - 1);
|
|
254
|
+
const left = steps[at];
|
|
255
|
+
const right = steps[at + 1];
|
|
256
|
+
if (left === void 0 || right === void 0) return steps;
|
|
257
|
+
return replaceAt(replaceAt(steps, at, right), at + 1, left);
|
|
258
|
+
}
|
|
259
|
+
function advance(steps, context) {
|
|
260
|
+
if (steps.length >= context.maxLength) return steps;
|
|
261
|
+
const at = context.rng.int(steps.length + 1);
|
|
262
|
+
const ms = context.rng.pick(ADVANCES) ?? 1;
|
|
263
|
+
return [
|
|
264
|
+
...steps.slice(0, at),
|
|
265
|
+
{
|
|
266
|
+
kind: "advance",
|
|
267
|
+
ms
|
|
268
|
+
},
|
|
269
|
+
...steps.slice(at)
|
|
270
|
+
];
|
|
271
|
+
}
|
|
272
|
+
const OPERATORS = [
|
|
273
|
+
insert,
|
|
274
|
+
insert,
|
|
275
|
+
insert,
|
|
276
|
+
remove,
|
|
277
|
+
swap,
|
|
278
|
+
mutatePayload,
|
|
279
|
+
mutatePayload,
|
|
280
|
+
advance
|
|
281
|
+
];
|
|
282
|
+
/** Loop lengths to try: small counts plus any integer literal a handler compares against. */
|
|
283
|
+
function repeatCounts(context) {
|
|
284
|
+
const fromLiterals = context.literals.numbers.filter((value) => Number.isInteger(value) && value >= 2 && value <= MAX_REPEAT);
|
|
285
|
+
return [...REPEAT_COUNTS, ...fromLiterals];
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Loop a step that changed the actor's context (a counter, an accumulating
|
|
289
|
+
* list): `if (retries > 10)` needs the same event many times, and repeating
|
|
290
|
+
* a step that changes nothing would only add noise. Inserts `count` copies.
|
|
291
|
+
*/
|
|
292
|
+
function repeat(parent, context) {
|
|
293
|
+
const at = context.rng.pick(parent.contextSteps);
|
|
294
|
+
const step = at === void 0 ? void 0 : parent.steps[at];
|
|
295
|
+
if (at === void 0 || step === void 0) return [...parent.steps];
|
|
296
|
+
const room = context.maxLength * 4 - parent.steps.length;
|
|
297
|
+
const count = Math.min(context.rng.pick(repeatCounts(context)) ?? 2, room);
|
|
298
|
+
if (count <= 0) return [...parent.steps];
|
|
299
|
+
const copies = Array.from({ length: count }, () => step);
|
|
300
|
+
return [
|
|
301
|
+
...parent.steps.slice(0, at + 1),
|
|
302
|
+
...copies,
|
|
303
|
+
...parent.steps.slice(at + 1)
|
|
304
|
+
];
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* Apply 1-3 random operators: insert, delete, swap, payload tweak, time
|
|
308
|
+
* advance. Sometimes first loops a context-changing step of the parent.
|
|
309
|
+
*/
|
|
310
|
+
function mutate(parent, context) {
|
|
311
|
+
const loop = context.repeat && parent.contextSteps.length > 0 && context.rng.chance(.2);
|
|
312
|
+
let child = loop ? repeat(parent, context) : [...parent.steps];
|
|
313
|
+
const rounds = (loop ? 0 : 1) + context.rng.int(3);
|
|
314
|
+
for (let round = 0; round < rounds; round += 1) child = (context.rng.pick(OPERATORS) ?? insert)(child, context);
|
|
315
|
+
return child;
|
|
316
|
+
}
|
|
317
|
+
// src/fuzz/rng.ts
|
|
318
|
+
function createRng(seed) {
|
|
319
|
+
let state = seed >>> 0;
|
|
320
|
+
function next() {
|
|
321
|
+
state = state + 1831565813 >>> 0;
|
|
322
|
+
let mixed = state;
|
|
323
|
+
mixed = Math.imul(mixed ^ mixed >>> 15, mixed | 1);
|
|
324
|
+
mixed ^= mixed + Math.imul(mixed ^ mixed >>> 7, mixed | 61);
|
|
325
|
+
return ((mixed ^ mixed >>> 14) >>> 0) / 4294967296;
|
|
326
|
+
}
|
|
327
|
+
function int(bound) {
|
|
328
|
+
return Math.floor(next() * bound);
|
|
329
|
+
}
|
|
330
|
+
return {
|
|
331
|
+
next,
|
|
332
|
+
int,
|
|
333
|
+
pick: (items) => items[int(items.length)],
|
|
334
|
+
chance: (probability) => next() < probability
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
// src/fuzz/fuzz.ts
|
|
338
|
+
function covers(target, steps, wanted) {
|
|
339
|
+
const reached = new Set(execute(target, steps).features);
|
|
340
|
+
return wanted.every((feature) => reached.has(feature));
|
|
341
|
+
}
|
|
342
|
+
/** Greedy one-step deletion, last to first, while `wanted` stays covered. Deterministic. */
|
|
343
|
+
function shrink(target, steps, wanted) {
|
|
344
|
+
let current = [...steps];
|
|
345
|
+
for (let at = current.length - 1; at >= 0; at -= 1) {
|
|
346
|
+
const candidate = current.filter((_, index) => index !== at);
|
|
347
|
+
if (covers(target, candidate, wanted)) current = candidate;
|
|
348
|
+
}
|
|
349
|
+
return current;
|
|
350
|
+
}
|
|
351
|
+
function mutationContext(target, options) {
|
|
352
|
+
const catalog = eventCatalog(target.create().actor);
|
|
353
|
+
return {
|
|
354
|
+
rng: createRng(options.seed),
|
|
355
|
+
events: [...catalog.inputs, ...catalog.internal].sort(),
|
|
356
|
+
payloads: target.payloads ?? {},
|
|
357
|
+
literals: options.literals ?? {
|
|
358
|
+
numbers: [],
|
|
359
|
+
strings: []
|
|
360
|
+
},
|
|
361
|
+
maxLength: options.maxLength ?? 24,
|
|
362
|
+
repeat: options.repeat ?? true
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
function collectErrors(target, corpus) {
|
|
366
|
+
const errors = /* @__PURE__ */ new Map();
|
|
367
|
+
for (const entry of corpus) for (const error of execute(target, entry.steps).errors) {
|
|
368
|
+
const key = `${error.from}>${error.event}`;
|
|
369
|
+
if (errors.has(key)) continue;
|
|
370
|
+
const steps = shrink(target, entry.steps.slice(0, error.step + 1), [`error:${key}`]);
|
|
371
|
+
errors.set(key, {
|
|
372
|
+
from: error.from,
|
|
373
|
+
event: error.event,
|
|
374
|
+
message: error.message,
|
|
375
|
+
steps
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
return [...errors.values()].sort((left, right) => `${left.from}>${left.event}` < `${right.from}>${right.event}` ? -1 : 1);
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* Coverage-guided fuzzing. Each iteration mutates a corpus entry, runs it on a
|
|
382
|
+
* fresh actor and keeps it if it reaches a feature (state, edge, stay, error)
|
|
383
|
+
* no earlier run reached. Same target + options => identical result.
|
|
384
|
+
*/
|
|
385
|
+
function fuzz(target, options) {
|
|
386
|
+
const context = mutationContext(target, options);
|
|
387
|
+
const seen = /* @__PURE__ */ new Set();
|
|
388
|
+
const firstHit = /* @__PURE__ */ new Map();
|
|
389
|
+
const corpus = [];
|
|
390
|
+
/** Parallel to `corpus`: which steps of each entry changed context. */
|
|
391
|
+
const contextSteps = [];
|
|
392
|
+
function consider(steps, iteration) {
|
|
393
|
+
const trace = execute(target, steps);
|
|
394
|
+
const discovered = trace.features.filter((feature) => !seen.has(feature));
|
|
395
|
+
const guided = options.guide ? options.guide(trace) : false;
|
|
396
|
+
for (const feature of discovered) {
|
|
397
|
+
seen.add(feature);
|
|
398
|
+
firstHit.set(feature, iteration);
|
|
399
|
+
}
|
|
400
|
+
if (discovered.length > 0 || guided) {
|
|
401
|
+
corpus.push({
|
|
402
|
+
steps,
|
|
403
|
+
discovered,
|
|
404
|
+
iteration
|
|
405
|
+
});
|
|
406
|
+
contextSteps.push(trace.contextSteps);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
consider([], 0);
|
|
410
|
+
for (let iteration = 1; iteration <= options.iterations; iteration += 1) {
|
|
411
|
+
const at = context.rng.int(corpus.length);
|
|
412
|
+
consider(mutate({
|
|
413
|
+
steps: corpus[at]?.steps ?? [],
|
|
414
|
+
contextSteps: contextSteps[at] ?? []
|
|
415
|
+
}, context), iteration);
|
|
416
|
+
}
|
|
417
|
+
const kept = corpus.filter((entry) => entry.discovered.length > 0).map((entry) => ({
|
|
418
|
+
...entry,
|
|
419
|
+
steps: shrink(target, entry.steps, entry.discovered)
|
|
420
|
+
}));
|
|
421
|
+
return {
|
|
422
|
+
seed: options.seed,
|
|
423
|
+
iterations: options.iterations,
|
|
424
|
+
corpus: kept,
|
|
425
|
+
firstHit,
|
|
426
|
+
errors: collectErrors(target, kept)
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
// src/fuzz/coverage.ts
|
|
430
|
+
/**
|
|
431
|
+
* An `onAny` edge counts observations from every state it covers. A state's
|
|
432
|
+
* own handler for the same event is not told apart: observations carry no
|
|
433
|
+
* handler identity.
|
|
434
|
+
*/
|
|
435
|
+
function matches(edge, seen) {
|
|
436
|
+
if (!(seen.from === edge.from || coveredByAny(edge.from, seen.from)) || seen.event !== edge.event) return false;
|
|
437
|
+
if (edge.kind === "stay") return !seen.transitioned;
|
|
438
|
+
if (edge.to === null) return true;
|
|
439
|
+
return seen.transitioned && seen.to === edge.to;
|
|
440
|
+
}
|
|
441
|
+
/** Times each edge was taken in `traces`; null for edges no handler drives (initial, effect). */
|
|
442
|
+
function edgeHits(edge, traces) {
|
|
443
|
+
if (edge.kind === "initial" || edge.kind === "effect") return null;
|
|
444
|
+
return traces.reduce((sum, trace) => sum + trace.observations.filter((seen) => matches(edge, seen)).length, 0);
|
|
445
|
+
}
|
|
446
|
+
function stateVisits(state, traces) {
|
|
447
|
+
return traces.reduce((sum, trace) => sum + trace.visited.filter((visited) => visited === state).length, 0);
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* Fill `tested` from replaying test sequences (each from a fresh actor).
|
|
451
|
+
* Lets the viewer show what the tests never exercise, next to what fuzzing reached.
|
|
452
|
+
*/
|
|
453
|
+
function withTestCoverage(target, machine, sequences) {
|
|
454
|
+
const traces = sequences.map((steps) => execute(target, steps));
|
|
455
|
+
return {
|
|
456
|
+
...machine,
|
|
457
|
+
states: machine.states.map((state) => ({
|
|
458
|
+
...state,
|
|
459
|
+
tested: { hits: stateVisits(state.id, traces) }
|
|
460
|
+
})),
|
|
461
|
+
edges: machine.edges.map((edge) => {
|
|
462
|
+
const hits = edgeHits(edge, traces);
|
|
463
|
+
return {
|
|
464
|
+
...edge,
|
|
465
|
+
tested: hits === null ? null : { hits }
|
|
466
|
+
};
|
|
467
|
+
})
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
// src/fuzz/to-viz.ts
|
|
471
|
+
function observedKey(seen, from) {
|
|
472
|
+
return seen.transitioned ? `${from}>${seen.event}>${seen.to}` : `${from}>${seen.event}`;
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* Source to draw an observation from: its state when that state registers the
|
|
476
|
+
* event, else the `*` source when an `onAny` handler does. One `*` edge beats
|
|
477
|
+
* the same stay repeated on every state.
|
|
478
|
+
*/
|
|
479
|
+
function anySourceOf(machine, seen) {
|
|
480
|
+
return machine.edges.find((edge) => edge.event === seen.event && coveredByAny(edge.from, seen.from))?.from;
|
|
481
|
+
}
|
|
482
|
+
function sourceOf(machine, seen) {
|
|
483
|
+
return machine.edges.some((edge) => edge.from === seen.from && edge.event === seen.event) ? seen.from : anySourceOf(machine, seen) ?? seen.from;
|
|
484
|
+
}
|
|
485
|
+
function fuzzEdge(seen, from) {
|
|
486
|
+
const kind = seen.transitioned ? "transition" : "stay";
|
|
487
|
+
const to = kind === "stay" && from.endsWith(ANY_STATE) ? from : seen.to;
|
|
488
|
+
return {
|
|
489
|
+
id: `fuzz:${from}-${seen.event}-${kind === "stay" ? "stay" : seen.to}`,
|
|
490
|
+
from,
|
|
491
|
+
event: seen.event,
|
|
492
|
+
to,
|
|
493
|
+
kind,
|
|
494
|
+
origin: "fuzz",
|
|
495
|
+
emits: [],
|
|
496
|
+
contexts: [],
|
|
497
|
+
explanation: null,
|
|
498
|
+
doc: null,
|
|
499
|
+
coverage: null,
|
|
500
|
+
tested: null
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
function knownKey(edge) {
|
|
504
|
+
if (edge.kind === "stay") return `${edge.from}>${edge.event}`;
|
|
505
|
+
return edge.to === null || edge.kind !== "transition" ? null : `${edge.from}>${edge.event}>${edge.to}`;
|
|
506
|
+
}
|
|
507
|
+
/**
|
|
508
|
+
* Edges the fuzzer observed that the structure lacks: concrete targets of
|
|
509
|
+
* undetermined handlers, and `stay` from handlers that declare targets but may
|
|
510
|
+
* also return none. A transition outside `to` would land here too.
|
|
511
|
+
*/
|
|
512
|
+
function discoveredEdges(machine, traces) {
|
|
513
|
+
const known = new Set(machine.edges.flatMap((edge) => knownKey(edge) ?? []));
|
|
514
|
+
const found = /* @__PURE__ */ new Map();
|
|
515
|
+
for (const seen of traces.flatMap((trace) => trace.observations)) {
|
|
516
|
+
const from = sourceOf(machine, seen);
|
|
517
|
+
const key = observedKey(seen, from);
|
|
518
|
+
if ([seen.from, anySourceOf(machine, seen)].flatMap((each) => each ?? []).some((each) => known.has(observedKey(seen, each)))) continue;
|
|
519
|
+
if (!found.has(key)) found.set(key, fuzzEdge(seen, from));
|
|
520
|
+
}
|
|
521
|
+
return [...found.values()];
|
|
522
|
+
}
|
|
523
|
+
function byId(left, right) {
|
|
524
|
+
if (left.id < right.id) return -1;
|
|
525
|
+
return left.id > right.id ? 1 : 0;
|
|
526
|
+
}
|
|
527
|
+
/**
|
|
528
|
+
* Viz machine with fuzz results written in: coverage from replaying the
|
|
529
|
+
* shrunk corpus, newly discovered edges (origin `fuzz`), and the corpus itself.
|
|
530
|
+
*/
|
|
531
|
+
function fuzzedMachine(target, result) {
|
|
532
|
+
const traces = result.corpus.map((entry) => execute(target, entry.steps));
|
|
533
|
+
const fresh = target.create().actor;
|
|
534
|
+
const internal = new Set(eventCatalog(fresh).internal);
|
|
535
|
+
const base = toVizMachine({
|
|
536
|
+
id: target.id,
|
|
537
|
+
actor: fresh
|
|
538
|
+
});
|
|
539
|
+
const added = discoveredEdges(base, traces);
|
|
540
|
+
const known = new Set(base.events.map((event) => event.id));
|
|
541
|
+
const events = [...base.events, ...[...new Set(added.map((edge) => edge.event))].filter((id) => !known.has(id)).map((id) => ({
|
|
542
|
+
id,
|
|
543
|
+
internal: internal.has(id),
|
|
544
|
+
doc: null,
|
|
545
|
+
payloadType: null
|
|
546
|
+
}))].sort(byId);
|
|
547
|
+
return {
|
|
548
|
+
...base,
|
|
549
|
+
events,
|
|
550
|
+
states: base.states.map((state) => ({
|
|
551
|
+
...state,
|
|
552
|
+
coverage: { hits: stateVisits(state.id, traces) }
|
|
553
|
+
})),
|
|
554
|
+
edges: [...base.edges, ...added].sort(byId).map((edge) => {
|
|
555
|
+
const hits = edgeHits(edge, traces);
|
|
556
|
+
return {
|
|
557
|
+
...edge,
|
|
558
|
+
coverage: hits === null ? null : { hits }
|
|
559
|
+
};
|
|
560
|
+
}),
|
|
561
|
+
fuzz: {
|
|
562
|
+
seed: result.seed,
|
|
563
|
+
iterations: result.iterations,
|
|
564
|
+
corpus: result.corpus.map((entry) => ({
|
|
565
|
+
steps: entry.steps,
|
|
566
|
+
discovered: entry.discovered
|
|
567
|
+
})),
|
|
568
|
+
errors: result.errors
|
|
569
|
+
}
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
// src/fuzz/guided.ts
|
|
573
|
+
/**
|
|
574
|
+
* Literal seeding: every literal the handlers compare against, plus its
|
|
575
|
+
* neighbours (`L - 1`, `L + 1`) so boundary sides are both reachable.
|
|
576
|
+
*/
|
|
577
|
+
function literalPool(machine) {
|
|
578
|
+
const numbers = /* @__PURE__ */ new Set();
|
|
579
|
+
const strings = /* @__PURE__ */ new Set();
|
|
580
|
+
for (const edge of machine.edges) {
|
|
581
|
+
if (edge.explanation?.status !== "analyzed") continue;
|
|
582
|
+
for (const literal of edge.explanation.literals) if (typeof literal === "string") strings.add(literal);
|
|
583
|
+
else for (const near of [
|
|
584
|
+
literal - 1,
|
|
585
|
+
literal,
|
|
586
|
+
literal + 1
|
|
587
|
+
]) numbers.add(near);
|
|
588
|
+
}
|
|
589
|
+
return {
|
|
590
|
+
numbers: [...numbers].sort((left, right) => left - right),
|
|
591
|
+
strings: [...strings].sort()
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
function probesOf(machine) {
|
|
595
|
+
const seen = /* @__PURE__ */ new Set();
|
|
596
|
+
const probes = [];
|
|
597
|
+
for (const edge of machine.edges) {
|
|
598
|
+
const explanation = edge.explanation;
|
|
599
|
+
if (explanation?.status !== "analyzed") continue;
|
|
600
|
+
const state = explanation.registration === "onAny" ? null : edge.from;
|
|
601
|
+
for (const comparison of explanation.comparisons) {
|
|
602
|
+
const key = `${state}>${edge.event}>${JSON.stringify(comparison)}`;
|
|
603
|
+
if (seen.has(key)) continue;
|
|
604
|
+
seen.add(key);
|
|
605
|
+
probes.push({
|
|
606
|
+
state,
|
|
607
|
+
event: edge.event,
|
|
608
|
+
comparison
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
return probes;
|
|
613
|
+
}
|
|
614
|
+
function valueAt(payload, path) {
|
|
615
|
+
let current = payload;
|
|
616
|
+
for (const key of path.split(".").filter(Boolean)) {
|
|
617
|
+
if (typeof current !== "object" || current === null) return void 0;
|
|
618
|
+
current = Reflect.get(current, key);
|
|
619
|
+
}
|
|
620
|
+
return current;
|
|
621
|
+
}
|
|
622
|
+
function stringDistance(left, right) {
|
|
623
|
+
let distance = Math.abs(left.length - right.length);
|
|
624
|
+
for (let index = 0; index < Math.min(left.length, right.length); index += 1) if (left[index] !== right[index]) distance += 1;
|
|
625
|
+
return distance;
|
|
626
|
+
}
|
|
627
|
+
function numericDistance(operator, gap) {
|
|
628
|
+
return {
|
|
629
|
+
"<": [Math.max(0, gap + 1), Math.max(0, -gap)],
|
|
630
|
+
"<=": [Math.max(0, gap), Math.max(0, 1 - gap)],
|
|
631
|
+
">": [Math.max(0, 1 - gap), Math.max(0, gap)],
|
|
632
|
+
">=": [Math.max(0, -gap), Math.max(0, gap + 1)],
|
|
633
|
+
"===": [Math.abs(gap), gap === 0 ? 1 : 0],
|
|
634
|
+
"==": [Math.abs(gap), gap === 0 ? 1 : 0],
|
|
635
|
+
"!==": [gap === 0 ? 1 : 0, Math.abs(gap)],
|
|
636
|
+
"!=": [gap === 0 ? 1 : 0, Math.abs(gap)]
|
|
637
|
+
}[operator];
|
|
638
|
+
}
|
|
639
|
+
const EQUALITY = /* @__PURE__ */ new Set(["===", "=="]);
|
|
640
|
+
const INEQUALITY = /* @__PURE__ */ new Set(["!==", "!="]);
|
|
641
|
+
/**
|
|
642
|
+
* Branch distance (Korel/Tracey): how far `value` is from making the
|
|
643
|
+
* comparison `outcome`. 0 = that side is taken. Undefined when incomparable.
|
|
644
|
+
*/
|
|
645
|
+
function branchDistance(comparison, context) {
|
|
646
|
+
const { value, outcome } = context;
|
|
647
|
+
const { literal, operator } = comparison;
|
|
648
|
+
if (typeof value === "number" && typeof literal === "number") {
|
|
649
|
+
const [whenTrue, whenFalse] = numericDistance(operator, value - literal);
|
|
650
|
+
return outcome ? whenTrue : whenFalse;
|
|
651
|
+
}
|
|
652
|
+
if (typeof value !== "string" || typeof literal !== "string") return void 0;
|
|
653
|
+
if (!EQUALITY.has(operator) && !INEQUALITY.has(operator)) return void 0;
|
|
654
|
+
const distance = stringDistance(value, literal);
|
|
655
|
+
if (EQUALITY.has(operator) === outcome) return distance;
|
|
656
|
+
return distance === 0 ? 1 : 0;
|
|
657
|
+
}
|
|
658
|
+
function applies(probe, observation) {
|
|
659
|
+
return probe.event === observation.event && (probe.state === null || probe.state === observation.from);
|
|
660
|
+
}
|
|
661
|
+
/**
|
|
662
|
+
* Stateful guide for `fuzz({ guide })`. For every comparison outcome not yet
|
|
663
|
+
* reached it remembers the smallest distance seen; a run that gets closer is
|
|
664
|
+
* kept as a stepping stone. Reached outcomes stop counting.
|
|
665
|
+
*/
|
|
666
|
+
function branchDistanceGuide(machine) {
|
|
667
|
+
const probes = probesOf(machine);
|
|
668
|
+
const best = /* @__PURE__ */ new Map();
|
|
669
|
+
return (trace) => {
|
|
670
|
+
let improved = false;
|
|
671
|
+
for (const observation of trace.observations) probes.forEach((probe, index) => {
|
|
672
|
+
if (!applies(probe, observation)) return;
|
|
673
|
+
const value = valueAt(probe.comparison.source === "payload" ? observation.payload : observation.context, probe.comparison.path);
|
|
674
|
+
for (const outcome of [true, false]) {
|
|
675
|
+
const key = `${index}:${outcome}`;
|
|
676
|
+
const distance = branchDistance(probe.comparison, {
|
|
677
|
+
value,
|
|
678
|
+
outcome
|
|
679
|
+
});
|
|
680
|
+
const previous = best.get(key) ?? Number.POSITIVE_INFINITY;
|
|
681
|
+
if (distance === void 0 || previous === 0 || distance >= previous) continue;
|
|
682
|
+
best.set(key, distance);
|
|
683
|
+
improved = true;
|
|
684
|
+
}
|
|
685
|
+
});
|
|
686
|
+
return improved;
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
/** Phase 5 search options for an AST-annotated machine: literal seeding + branch distance. */
|
|
690
|
+
function guidance(machine) {
|
|
691
|
+
return {
|
|
692
|
+
literals: literalPool(machine),
|
|
693
|
+
guide: branchDistanceGuide(machine)
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
// src/ast/discover.ts
|
|
697
|
+
const ACTOR_KEYS = [
|
|
698
|
+
"send",
|
|
699
|
+
"snapshot",
|
|
700
|
+
"inject",
|
|
701
|
+
"settled"
|
|
702
|
+
];
|
|
703
|
+
const CLOCK_KEYS = [
|
|
704
|
+
"setTimeout",
|
|
705
|
+
"clearTimeout",
|
|
706
|
+
"now"
|
|
707
|
+
];
|
|
708
|
+
function hasAll(type, keys) {
|
|
709
|
+
return keys.every((key) => type.getProperty(key) !== void 0);
|
|
710
|
+
}
|
|
711
|
+
function unwrapOf(checker, type, node) {
|
|
712
|
+
if (hasAll(type, ACTOR_KEYS)) return "self";
|
|
713
|
+
const actor = type.getProperty("actor");
|
|
714
|
+
if (actor === void 0) return null;
|
|
715
|
+
return hasAll(checker.getTypeOfSymbolAtLocation(actor, node), ACTOR_KEYS) ? "actor" : null;
|
|
716
|
+
}
|
|
717
|
+
function isRequired(parameter) {
|
|
718
|
+
const declaration = parameter.valueDeclaration;
|
|
719
|
+
return declaration !== void 0 && ts.isParameter(declaration) && declaration.questionToken === void 0 && declaration.initializer === void 0 && declaration.dotDotDotToken === void 0;
|
|
720
|
+
}
|
|
721
|
+
/** How to call a factory so its actor runs on a clock we control, or null when it cannot be called bare. */
|
|
722
|
+
function callOf(checker, signature, node) {
|
|
723
|
+
const [first, ...rest] = signature.parameters;
|
|
724
|
+
if (rest.some(isRequired)) return null;
|
|
725
|
+
if (first === void 0) return "call";
|
|
726
|
+
const type = checker.getNonNullableType(checker.getTypeOfSymbolAtLocation(first, node));
|
|
727
|
+
if (hasAll(type, CLOCK_KEYS)) return "clock";
|
|
728
|
+
const clock = type.getProperty("clock");
|
|
729
|
+
if (clock !== void 0) {
|
|
730
|
+
if (hasAll(checker.getNonNullableType(checker.getTypeOfSymbolAtLocation(clock, node)), CLOCK_KEYS)) return "options";
|
|
731
|
+
}
|
|
732
|
+
return isRequired(first) ? null : "call";
|
|
733
|
+
}
|
|
734
|
+
function machineExport(checker, symbol, node) {
|
|
735
|
+
const target = symbol.getFlags() & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(symbol) : symbol;
|
|
736
|
+
const type = checker.getTypeOfSymbolAtLocation(target, node);
|
|
737
|
+
const name = symbol.getName();
|
|
738
|
+
const self = unwrapOf(checker, type, node);
|
|
739
|
+
if (self !== null) return {
|
|
740
|
+
name,
|
|
741
|
+
call: "instance",
|
|
742
|
+
unwrap: self
|
|
743
|
+
};
|
|
744
|
+
for (const signature of checker.getSignaturesOfType(type, ts.SignatureKind.Call)) {
|
|
745
|
+
const unwrap = unwrapOf(checker, signature.getReturnType(), node);
|
|
746
|
+
const call = unwrap === null ? null : callOf(checker, signature, node);
|
|
747
|
+
if (unwrap !== null && call !== null) return {
|
|
748
|
+
name,
|
|
749
|
+
call,
|
|
750
|
+
unwrap
|
|
751
|
+
};
|
|
752
|
+
}
|
|
753
|
+
return null;
|
|
754
|
+
}
|
|
755
|
+
/** Exports of the file that are actors or build one, in source order. */
|
|
756
|
+
function machineExports(loaded) {
|
|
757
|
+
const { checker, sourceFile } = loaded;
|
|
758
|
+
const module = checker.getSymbolAtLocation(sourceFile);
|
|
759
|
+
if (module === void 0) return [];
|
|
760
|
+
return checker.getExportsOfModule(module).flatMap((symbol) => {
|
|
761
|
+
const found = machineExport(checker, symbol, sourceFile);
|
|
762
|
+
return found === null ? [] : [{
|
|
763
|
+
found,
|
|
764
|
+
at: symbol.declarations?.[0]?.getStart() ?? 0
|
|
765
|
+
}];
|
|
766
|
+
}).sort((left, right) => left.at - right.at).map(({ found }) => found);
|
|
767
|
+
}
|
|
768
|
+
// src/ast/program.ts
|
|
769
|
+
/**
|
|
770
|
+
* Prose of the JSDoc block found in the trivia starting at `pos`, or null when
|
|
771
|
+
* there is none. Block tags (`@param`, ...) and everything after them are cut;
|
|
772
|
+
* `{@link x}` reads as `x`.
|
|
773
|
+
*/
|
|
774
|
+
function docAt(sourceFile, pos) {
|
|
775
|
+
const block = (ts.getLeadingCommentRanges(sourceFile.text, pos) ?? []).filter((range) => sourceFile.text.startsWith("/**", range.pos)).at(-1);
|
|
776
|
+
if (block === void 0) return null;
|
|
777
|
+
const lines = sourceFile.text.slice(block.pos + 3, block.end - 2).split(/\r?\n/).map((line) => line.replace(/^\s*\* ?/, "").trim());
|
|
778
|
+
const tag = lines.findIndex((line) => line.startsWith("@"));
|
|
779
|
+
const text = (tag === -1 ? lines : lines.slice(0, tag)).join("\n").replace(/\{@link(?:code|plain)?\s+([^}|\s]+)[^}]*\}/g, "$1").trim();
|
|
780
|
+
return text === "" ? null : text;
|
|
781
|
+
}
|
|
782
|
+
function docOf(node) {
|
|
783
|
+
return docAt(node.getSourceFile(), node.getFullStart());
|
|
784
|
+
}
|
|
785
|
+
/**
|
|
786
|
+
* Doc of one `m.on(...)` link. The first link of a chain owns the comment above
|
|
787
|
+
* the statement; later links own the comment just before their `.on`.
|
|
788
|
+
*/
|
|
789
|
+
function callDoc(call) {
|
|
790
|
+
const callee = call.expression;
|
|
791
|
+
if (ts.isPropertyAccessExpression(callee) && ts.isCallExpression(callee.expression)) return docAt(call.getSourceFile(), callee.expression.end);
|
|
792
|
+
let top = call;
|
|
793
|
+
while (ts.isPropertyAccessExpression(top.parent) || ts.isCallExpression(top.parent)) top = top.parent;
|
|
794
|
+
return docOf(ts.isExpressionStatement(top.parent) ? top.parent : call);
|
|
795
|
+
}
|
|
796
|
+
function compilerOptions(source) {
|
|
797
|
+
const configPath = source.tsconfig ?? ts.findConfigFile(dirname(source.file), (path) => ts.sys.fileExists(path));
|
|
798
|
+
if (configPath === void 0) return {
|
|
799
|
+
strict: true,
|
|
800
|
+
allowImportingTsExtensions: true,
|
|
801
|
+
noEmit: true
|
|
802
|
+
};
|
|
803
|
+
const read = ts.readConfigFile(configPath, (path) => ts.sys.readFile(path));
|
|
804
|
+
return ts.parseJsonConfigFileContent(read.config, ts.sys, dirname(configPath)).options;
|
|
805
|
+
}
|
|
806
|
+
/** Literal string type of `expr.<property>` (e.g. a StateRef's `name`), or null. */
|
|
807
|
+
function literalProperty(checker, expr, property) {
|
|
808
|
+
const symbol = checker.getTypeAtLocation(expr).getProperty(property);
|
|
809
|
+
if (symbol === void 0) return null;
|
|
810
|
+
const type = checker.getTypeOfSymbolAtLocation(symbol, expr);
|
|
811
|
+
return type.isStringLiteral() ? type.value : null;
|
|
812
|
+
}
|
|
813
|
+
function property(literal, name) {
|
|
814
|
+
for (const element of literal.properties) {
|
|
815
|
+
if (!element.name || !ts.isIdentifier(element.name) || element.name.text !== name) continue;
|
|
816
|
+
if (ts.isPropertyAssignment(element)) return element.initializer;
|
|
817
|
+
if (ts.isShorthandPropertyAssignment(element)) return element.name;
|
|
818
|
+
if (ts.isMethodDeclaration(element)) return void 0;
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
function toRegistration(checker, call) {
|
|
822
|
+
if (!ts.isPropertyAccessExpression(call.expression)) return null;
|
|
823
|
+
const method = call.expression.name.text;
|
|
824
|
+
if (method !== "on" && method !== "onAny") return null;
|
|
825
|
+
const options = call.arguments[method === "on" ? 1 : 0];
|
|
826
|
+
if (options === void 0 || !ts.isObjectLiteralExpression(options)) return null;
|
|
827
|
+
const eventRef = property(options, "event");
|
|
828
|
+
if (eventRef === void 0) return null;
|
|
829
|
+
const stateArg = method === "on" ? call.arguments[0] : void 0;
|
|
830
|
+
return {
|
|
831
|
+
kind: method,
|
|
832
|
+
state: stateArg === void 0 ? null : literalProperty(checker, stateArg, "name"),
|
|
833
|
+
event: literalProperty(checker, eventRef, "type"),
|
|
834
|
+
eventRef,
|
|
835
|
+
handler: property(options, "handler"),
|
|
836
|
+
call,
|
|
837
|
+
doc: callDoc(call)
|
|
838
|
+
};
|
|
839
|
+
}
|
|
840
|
+
function locationOf(node, root) {
|
|
841
|
+
const sourceFile = node.getSourceFile();
|
|
842
|
+
const position = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile));
|
|
843
|
+
return {
|
|
844
|
+
file: relative(root, sourceFile.fileName).split(sep).join("/"),
|
|
845
|
+
line: position.line + 1,
|
|
846
|
+
column: position.character + 1
|
|
847
|
+
};
|
|
848
|
+
}
|
|
849
|
+
/** Name literal of `state("x")()` / `event("X")()`, with which builder it came from. */
|
|
850
|
+
function declaredName(init) {
|
|
851
|
+
if (!ts.isCallExpression(init) || !ts.isCallExpression(init.expression)) return null;
|
|
852
|
+
const builder = init.expression.expression;
|
|
853
|
+
const [name] = init.expression.arguments;
|
|
854
|
+
if (!ts.isIdentifier(builder) || name === void 0 || !ts.isStringLiteralLike(name)) return null;
|
|
855
|
+
return {
|
|
856
|
+
builder: builder.text,
|
|
857
|
+
name: name.text
|
|
858
|
+
};
|
|
859
|
+
}
|
|
860
|
+
function collectDeclarationDocs(statement, docs) {
|
|
861
|
+
const doc = docOf(statement);
|
|
862
|
+
if (doc === null) return;
|
|
863
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
864
|
+
const declared = declaration.initializer && declaredName(declaration.initializer);
|
|
865
|
+
if (!declared) continue;
|
|
866
|
+
if (declared.builder === "state") docs.stateDocs.set(declared.name, doc);
|
|
867
|
+
if (declared.builder === "event") docs.eventDocs.set(declared.name, doc);
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
/**
|
|
871
|
+
* Type-check one file and collect its handler registrations. Any compiler
|
|
872
|
+
* failure becomes a Left reason; it never throws.
|
|
873
|
+
*/
|
|
874
|
+
function loadSource(source) {
|
|
875
|
+
const loaded = Either.from(() => {
|
|
876
|
+
const program = ts.createProgram([source.file], compilerOptions(source));
|
|
877
|
+
const sourceFile = program.getSourceFile(source.file);
|
|
878
|
+
if (sourceFile === void 0) return `source not found: ${source.file}`;
|
|
879
|
+
const checker = program.getTypeChecker();
|
|
880
|
+
const registrations = [];
|
|
881
|
+
const stateDocs = /* @__PURE__ */ new Map();
|
|
882
|
+
const eventDocs = /* @__PURE__ */ new Map();
|
|
883
|
+
const visit = (node) => {
|
|
884
|
+
const found = ts.isCallExpression(node) ? toRegistration(checker, node) : null;
|
|
885
|
+
if (found) registrations.push(found);
|
|
886
|
+
if (ts.isVariableStatement(node)) collectDeclarationDocs(node, {
|
|
887
|
+
stateDocs,
|
|
888
|
+
eventDocs
|
|
889
|
+
});
|
|
890
|
+
ts.forEachChild(node, visit);
|
|
891
|
+
};
|
|
892
|
+
visit(sourceFile);
|
|
893
|
+
return {
|
|
894
|
+
checker,
|
|
895
|
+
sourceFile,
|
|
896
|
+
root: source.root,
|
|
897
|
+
registrations,
|
|
898
|
+
stateDocs,
|
|
899
|
+
eventDocs
|
|
900
|
+
};
|
|
901
|
+
});
|
|
902
|
+
if (Either.isLeft(loaded)) return Either.left(`compiler failed: ${String(loaded[0])}`);
|
|
903
|
+
const value = loaded[1];
|
|
904
|
+
return typeof value === "string" ? Either.left(value) : Either.right(value);
|
|
905
|
+
}
|
|
906
|
+
// src/ast/reads.ts
|
|
907
|
+
const COMPARISONS = /* @__PURE__ */ new Map([
|
|
908
|
+
[ts.SyntaxKind.LessThanToken, "<"],
|
|
909
|
+
[ts.SyntaxKind.LessThanEqualsToken, "<="],
|
|
910
|
+
[ts.SyntaxKind.GreaterThanToken, ">"],
|
|
911
|
+
[ts.SyntaxKind.GreaterThanEqualsToken, ">="],
|
|
912
|
+
[ts.SyntaxKind.EqualsEqualsEqualsToken, "==="],
|
|
913
|
+
[ts.SyntaxKind.ExclamationEqualsEqualsToken, "!=="],
|
|
914
|
+
[ts.SyntaxKind.EqualsEqualsToken, "=="],
|
|
915
|
+
[ts.SyntaxKind.ExclamationEqualsToken, "!="]
|
|
916
|
+
]);
|
|
917
|
+
const FLIPPED = {
|
|
918
|
+
"<": ">",
|
|
919
|
+
"<=": ">=",
|
|
920
|
+
">": "<",
|
|
921
|
+
">=": "<=",
|
|
922
|
+
"===": "===",
|
|
923
|
+
"!==": "!==",
|
|
924
|
+
"==": "==",
|
|
925
|
+
"!=": "!="
|
|
926
|
+
};
|
|
927
|
+
function unwrap(expr) {
|
|
928
|
+
if (ts.isParenthesizedExpression(expr) || ts.isNonNullExpression(expr)) return unwrap(expr.expression);
|
|
929
|
+
if (ts.isBinaryExpression(expr) && expr.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken) return unwrap(expr.left);
|
|
930
|
+
return expr;
|
|
931
|
+
}
|
|
932
|
+
/** `a.b.c` -> root `a`, names `[b, c]`. */
|
|
933
|
+
function chainOf(expr) {
|
|
934
|
+
const names = [];
|
|
935
|
+
let current = unwrap(expr);
|
|
936
|
+
while (ts.isPropertyAccessExpression(current)) {
|
|
937
|
+
names.unshift(current.name.text);
|
|
938
|
+
current = unwrap(current.expression);
|
|
939
|
+
}
|
|
940
|
+
return {
|
|
941
|
+
root: current,
|
|
942
|
+
names
|
|
943
|
+
};
|
|
944
|
+
}
|
|
945
|
+
function isContextGet(expr, scope) {
|
|
946
|
+
if (!ts.isCallExpression(expr) || !ts.isPropertyAccessExpression(expr.expression)) return false;
|
|
947
|
+
const target = expr.expression;
|
|
948
|
+
return target.name.text === "get" && ts.isIdentifier(target.expression) && target.expression.text === scope.contextName;
|
|
949
|
+
}
|
|
950
|
+
function join$1(parts) {
|
|
951
|
+
return parts.filter(Boolean).join(".");
|
|
952
|
+
}
|
|
953
|
+
/** Where an expression's value comes from: the event payload, the context, or neither. */
|
|
954
|
+
function classify(expr, scope) {
|
|
955
|
+
const { root, names } = chainOf(expr);
|
|
956
|
+
if (isContextGet(root, scope)) return {
|
|
957
|
+
source: "context",
|
|
958
|
+
path: join$1(names)
|
|
959
|
+
};
|
|
960
|
+
if (!ts.isIdentifier(root)) return null;
|
|
961
|
+
if (root.text === scope.eventName && names[0] === "payload") return {
|
|
962
|
+
source: "payload",
|
|
963
|
+
path: join$1(names.slice(1))
|
|
964
|
+
};
|
|
965
|
+
const alias = scope.payloadAliases.get(root.text);
|
|
966
|
+
if (alias !== void 0) return {
|
|
967
|
+
source: "payload",
|
|
968
|
+
path: join$1([alias, ...names])
|
|
969
|
+
};
|
|
970
|
+
if (scope.contextAliases.has(root.text)) return {
|
|
971
|
+
source: "context",
|
|
972
|
+
path: join$1(names)
|
|
973
|
+
};
|
|
974
|
+
return null;
|
|
975
|
+
}
|
|
976
|
+
function literalValue(expr) {
|
|
977
|
+
const node = unwrap(expr);
|
|
978
|
+
if (ts.isNumericLiteral(node)) return Number(node.text);
|
|
979
|
+
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text;
|
|
980
|
+
if (node.kind === ts.SyntaxKind.TrueKeyword) return true;
|
|
981
|
+
if (node.kind === ts.SyntaxKind.FalseKeyword) return false;
|
|
982
|
+
if (ts.isPrefixUnaryExpression(node) && node.operator === ts.SyntaxKind.MinusToken) {
|
|
983
|
+
const inner = literalValue(node.operand);
|
|
984
|
+
return typeof inner === "number" ? -inner : void 0;
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
function comparison(node, scope) {
|
|
988
|
+
const operator = COMPARISONS.get(node.operatorToken.kind);
|
|
989
|
+
if (operator === void 0) return null;
|
|
990
|
+
const rightLiteral = literalValue(node.right);
|
|
991
|
+
const leftLiteral = literalValue(node.left);
|
|
992
|
+
if (rightLiteral !== void 0) {
|
|
993
|
+
const side = classify(node.left, scope);
|
|
994
|
+
return side ? {
|
|
995
|
+
...side,
|
|
996
|
+
operator,
|
|
997
|
+
literal: rightLiteral
|
|
998
|
+
} : null;
|
|
999
|
+
}
|
|
1000
|
+
if (leftLiteral === void 0) return null;
|
|
1001
|
+
const side = classify(node.right, scope);
|
|
1002
|
+
return side ? {
|
|
1003
|
+
...side,
|
|
1004
|
+
operator: FLIPPED[operator],
|
|
1005
|
+
literal: leftLiteral
|
|
1006
|
+
} : null;
|
|
1007
|
+
}
|
|
1008
|
+
/** Outermost access in a chain, minus a trailing method name when it is called. */
|
|
1009
|
+
function readPath(node) {
|
|
1010
|
+
const parent = node.parent;
|
|
1011
|
+
if (ts.isPropertyAccessExpression(parent) && parent.expression === node) return null;
|
|
1012
|
+
if (ts.isCallExpression(parent) && parent.expression === node && ts.isPropertyAccessExpression(node)) return node.expression;
|
|
1013
|
+
return node;
|
|
1014
|
+
}
|
|
1015
|
+
function isAssignmentTarget(node) {
|
|
1016
|
+
const parent = node.parent;
|
|
1017
|
+
return ts.isBinaryExpression(parent) && parent.left === node && parent.operatorToken.kind === ts.SyntaxKind.EqualsToken;
|
|
1018
|
+
}
|
|
1019
|
+
function recordAccess(node, context) {
|
|
1020
|
+
const target = readPath(node);
|
|
1021
|
+
if (target === null) return;
|
|
1022
|
+
const source = classify(target, context.scope);
|
|
1023
|
+
if (source === null) {
|
|
1024
|
+
const { root, names } = chainOf(target);
|
|
1025
|
+
if (ts.isIdentifier(root) && names[0] === "state" && root.text !== context.scope.eventName) context.reads.readsActorState = true;
|
|
1026
|
+
return;
|
|
1027
|
+
}
|
|
1028
|
+
if (ts.isVariableDeclaration(target.parent) && !ts.isIdentifier(target.parent.name)) return;
|
|
1029
|
+
const path = source.path || "*";
|
|
1030
|
+
if (source.source === "payload") context.reads.payloadReads.add(path);
|
|
1031
|
+
else if (isAssignmentTarget(node)) context.reads.contextWrites.add(path);
|
|
1032
|
+
else if (path !== "*") context.reads.contextReads.add(path);
|
|
1033
|
+
}
|
|
1034
|
+
/** Walk a handler body collecting payload/context reads, context writes and comparisons. */
|
|
1035
|
+
function collectReads(body, scope) {
|
|
1036
|
+
const reads = {
|
|
1037
|
+
payloadReads: /* @__PURE__ */ new Set(),
|
|
1038
|
+
contextReads: /* @__PURE__ */ new Set(),
|
|
1039
|
+
contextWrites: /* @__PURE__ */ new Set(),
|
|
1040
|
+
readsActorState: false,
|
|
1041
|
+
comparisons: []
|
|
1042
|
+
};
|
|
1043
|
+
const visit = (node) => {
|
|
1044
|
+
if (ts.isPropertyAccessExpression(node) || ts.isIdentifier(node)) {
|
|
1045
|
+
if (!ts.isPropertyAccessExpression(node.parent) || node.parent.expression === node) recordAccess(node, {
|
|
1046
|
+
scope,
|
|
1047
|
+
reads
|
|
1048
|
+
});
|
|
1049
|
+
}
|
|
1050
|
+
if (ts.isBinaryExpression(node)) {
|
|
1051
|
+
const found = comparison(node, scope);
|
|
1052
|
+
if (found) reads.comparisons.push(found);
|
|
1053
|
+
}
|
|
1054
|
+
ts.forEachChild(node, visit);
|
|
1055
|
+
};
|
|
1056
|
+
visit(body);
|
|
1057
|
+
return reads;
|
|
1058
|
+
}
|
|
1059
|
+
// src/ast/handler.ts
|
|
1060
|
+
const SNIPPET_LINES = 4;
|
|
1061
|
+
function text(node) {
|
|
1062
|
+
return node.getText().replace(/\s+/g, " ");
|
|
1063
|
+
}
|
|
1064
|
+
function snippet(node) {
|
|
1065
|
+
return node.getText().split("\n").slice(0, SNIPPET_LINES).join("\n");
|
|
1066
|
+
}
|
|
1067
|
+
function negate(condition) {
|
|
1068
|
+
return `!(${condition})`;
|
|
1069
|
+
}
|
|
1070
|
+
function stateTarget(checker, expr) {
|
|
1071
|
+
const name = literalProperty(checker, expr, "name");
|
|
1072
|
+
return name === null ? {
|
|
1073
|
+
kind: "unresolved",
|
|
1074
|
+
text: text(expr)
|
|
1075
|
+
} : {
|
|
1076
|
+
kind: "state",
|
|
1077
|
+
state: name
|
|
1078
|
+
};
|
|
1079
|
+
}
|
|
1080
|
+
function stateProperty(literal) {
|
|
1081
|
+
for (const element of literal.properties) {
|
|
1082
|
+
if (!element.name || !ts.isIdentifier(element.name) || element.name.text !== "state") continue;
|
|
1083
|
+
if (ts.isPropertyAssignment(element)) return element.initializer;
|
|
1084
|
+
if (ts.isShorthandPropertyAssignment(element)) return element.name;
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
/** Split a returned expression into branches: ternaries fork, object literals end a path. */
|
|
1088
|
+
function branchesOf(expr, at) {
|
|
1089
|
+
const { walk, conditions } = at;
|
|
1090
|
+
if (expr === void 0) {
|
|
1091
|
+
walk.branches.push({
|
|
1092
|
+
conditions,
|
|
1093
|
+
target: { kind: "stay" }
|
|
1094
|
+
});
|
|
1095
|
+
return;
|
|
1096
|
+
}
|
|
1097
|
+
if (ts.isParenthesizedExpression(expr)) return branchesOf(expr.expression, at);
|
|
1098
|
+
if (ts.isConditionalExpression(expr)) {
|
|
1099
|
+
walk.conditions.push(expr.condition);
|
|
1100
|
+
const condition = text(expr.condition);
|
|
1101
|
+
branchesOf(expr.whenTrue, {
|
|
1102
|
+
walk,
|
|
1103
|
+
conditions: [...conditions, condition]
|
|
1104
|
+
});
|
|
1105
|
+
branchesOf(expr.whenFalse, {
|
|
1106
|
+
walk,
|
|
1107
|
+
conditions: [...conditions, negate(condition)]
|
|
1108
|
+
});
|
|
1109
|
+
return;
|
|
1110
|
+
}
|
|
1111
|
+
if (!ts.isObjectLiteralExpression(expr)) {
|
|
1112
|
+
walk.branches.push({
|
|
1113
|
+
conditions,
|
|
1114
|
+
target: {
|
|
1115
|
+
kind: "unresolved",
|
|
1116
|
+
text: text(expr)
|
|
1117
|
+
}
|
|
1118
|
+
});
|
|
1119
|
+
return;
|
|
1120
|
+
}
|
|
1121
|
+
const state = stateProperty(expr);
|
|
1122
|
+
if (state === void 0) {
|
|
1123
|
+
walk.branches.push({
|
|
1124
|
+
conditions,
|
|
1125
|
+
target: { kind: "stay" }
|
|
1126
|
+
});
|
|
1127
|
+
return;
|
|
1128
|
+
}
|
|
1129
|
+
if (ts.isConditionalExpression(state)) {
|
|
1130
|
+
walk.conditions.push(state.condition);
|
|
1131
|
+
const condition = text(state.condition);
|
|
1132
|
+
walk.branches.push({
|
|
1133
|
+
conditions: [...conditions, condition],
|
|
1134
|
+
target: stateTarget(walk.checker, state.whenTrue)
|
|
1135
|
+
});
|
|
1136
|
+
walk.branches.push({
|
|
1137
|
+
conditions: [...conditions, negate(condition)],
|
|
1138
|
+
target: stateTarget(walk.checker, state.whenFalse)
|
|
1139
|
+
});
|
|
1140
|
+
return;
|
|
1141
|
+
}
|
|
1142
|
+
walk.branches.push({
|
|
1143
|
+
conditions,
|
|
1144
|
+
target: stateTarget(walk.checker, state)
|
|
1145
|
+
});
|
|
1146
|
+
}
|
|
1147
|
+
/** Walk statements; returns true when every path through them returns. */
|
|
1148
|
+
function walkStatements(statements, at) {
|
|
1149
|
+
let guards = at.conditions;
|
|
1150
|
+
for (const statement of statements) {
|
|
1151
|
+
if (walkStatement(statement, {
|
|
1152
|
+
walk: at.walk,
|
|
1153
|
+
conditions: guards
|
|
1154
|
+
}) === "returns") return true;
|
|
1155
|
+
if (ts.isIfStatement(statement) && statement.elseStatement === void 0) {
|
|
1156
|
+
if (alwaysReturns(statement.thenStatement)) guards = [...guards, negate(text(statement.expression))];
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
return false;
|
|
1160
|
+
}
|
|
1161
|
+
function alwaysReturns(statement) {
|
|
1162
|
+
if (ts.isReturnStatement(statement)) return true;
|
|
1163
|
+
if (ts.isBlock(statement)) return statement.statements.some(alwaysReturns);
|
|
1164
|
+
if (ts.isIfStatement(statement)) return statement.elseStatement !== void 0 && alwaysReturns(statement.thenStatement) && alwaysReturns(statement.elseStatement);
|
|
1165
|
+
return false;
|
|
1166
|
+
}
|
|
1167
|
+
function walkStatement(statement, at) {
|
|
1168
|
+
const { walk, conditions } = at;
|
|
1169
|
+
if (ts.isReturnStatement(statement)) {
|
|
1170
|
+
walk.returnsSeen += 1;
|
|
1171
|
+
branchesOf(statement.expression, at);
|
|
1172
|
+
return "returns";
|
|
1173
|
+
}
|
|
1174
|
+
if (ts.isBlock(statement)) return walkStatements(statement.statements, at) ? "returns" : "continues";
|
|
1175
|
+
if (!ts.isIfStatement(statement)) return "continues";
|
|
1176
|
+
walk.conditions.push(statement.expression);
|
|
1177
|
+
const condition = text(statement.expression);
|
|
1178
|
+
walkStatement(statement.thenStatement, {
|
|
1179
|
+
walk,
|
|
1180
|
+
conditions: [...conditions, condition]
|
|
1181
|
+
});
|
|
1182
|
+
if (statement.elseStatement) walkStatement(statement.elseStatement, {
|
|
1183
|
+
walk,
|
|
1184
|
+
conditions: [...conditions, negate(condition)]
|
|
1185
|
+
});
|
|
1186
|
+
return alwaysReturns(statement) ? "returns" : "continues";
|
|
1187
|
+
}
|
|
1188
|
+
function countReturns(body) {
|
|
1189
|
+
let count = 0;
|
|
1190
|
+
const visit = (node) => {
|
|
1191
|
+
if (ts.isFunctionLike(node) && node !== body) return;
|
|
1192
|
+
if (ts.isReturnStatement(node)) count += 1;
|
|
1193
|
+
ts.forEachChild(node, visit);
|
|
1194
|
+
};
|
|
1195
|
+
ts.forEachChild(body, visit);
|
|
1196
|
+
return count;
|
|
1197
|
+
}
|
|
1198
|
+
function bindingName(node) {
|
|
1199
|
+
return node !== void 0 && ts.isIdentifier(node) ? node.text : null;
|
|
1200
|
+
}
|
|
1201
|
+
function contextBinding(parameter) {
|
|
1202
|
+
if (parameter === void 0 || !ts.isObjectBindingPattern(parameter.name)) return null;
|
|
1203
|
+
for (const element of parameter.name.elements) {
|
|
1204
|
+
const key = element.propertyName ?? element.name;
|
|
1205
|
+
if (ts.isIdentifier(key) && key.text === "context") return bindingName(element.name);
|
|
1206
|
+
}
|
|
1207
|
+
return null;
|
|
1208
|
+
}
|
|
1209
|
+
/** Record locals bound to the payload (`const { a } = event.payload`) or to `context.get()`. */
|
|
1210
|
+
function collectAliases(body, scope) {
|
|
1211
|
+
const visit = (node) => {
|
|
1212
|
+
if (ts.isVariableDeclaration(node) && node.initializer) {
|
|
1213
|
+
if (ts.isIdentifier(node.name) && isContextGet(node.initializer, scope)) scope.contextAliases.add(node.name.text);
|
|
1214
|
+
const source = classify(node.initializer, scope);
|
|
1215
|
+
if (source?.source === "payload") bindPayload(node.name, {
|
|
1216
|
+
scope,
|
|
1217
|
+
path: source.path
|
|
1218
|
+
});
|
|
1219
|
+
}
|
|
1220
|
+
ts.forEachChild(node, visit);
|
|
1221
|
+
};
|
|
1222
|
+
visit(body);
|
|
1223
|
+
}
|
|
1224
|
+
function bindPayload(name, at) {
|
|
1225
|
+
if (ts.isIdentifier(name)) {
|
|
1226
|
+
at.scope.payloadAliases.set(name.text, at.path);
|
|
1227
|
+
return;
|
|
1228
|
+
}
|
|
1229
|
+
if (!ts.isObjectBindingPattern(name)) return;
|
|
1230
|
+
for (const element of name.elements) {
|
|
1231
|
+
const key = element.propertyName ?? element.name;
|
|
1232
|
+
if (!ts.isIdentifier(key)) continue;
|
|
1233
|
+
bindPayload(element.name, {
|
|
1234
|
+
scope: at.scope,
|
|
1235
|
+
path: [at.path, key.text].filter(Boolean).join(".")
|
|
1236
|
+
});
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
function conditionLiterals(conditions) {
|
|
1240
|
+
const literals = /* @__PURE__ */ new Set();
|
|
1241
|
+
const visit = (node) => {
|
|
1242
|
+
if (ts.isExpression(node)) {
|
|
1243
|
+
const value = literalValue(node);
|
|
1244
|
+
if (typeof value === "number" || typeof value === "string") literals.add(value);
|
|
1245
|
+
}
|
|
1246
|
+
ts.forEachChild(node, visit);
|
|
1247
|
+
};
|
|
1248
|
+
for (const condition of conditions) visit(condition);
|
|
1249
|
+
return [...literals].sort((left, right) => String(left).localeCompare(String(right)));
|
|
1250
|
+
}
|
|
1251
|
+
function analyzeFunction(fn, at) {
|
|
1252
|
+
const scope = {
|
|
1253
|
+
eventName: bindingName(fn.parameters[0]?.name),
|
|
1254
|
+
contextName: contextBinding(fn.parameters[1]),
|
|
1255
|
+
payloadAliases: /* @__PURE__ */ new Map(),
|
|
1256
|
+
contextAliases: /* @__PURE__ */ new Set()
|
|
1257
|
+
};
|
|
1258
|
+
const location = locationOf(at.registration.call, at.root);
|
|
1259
|
+
const walk = {
|
|
1260
|
+
checker: at.checker,
|
|
1261
|
+
branches: [],
|
|
1262
|
+
returnsSeen: 0,
|
|
1263
|
+
conditions: []
|
|
1264
|
+
};
|
|
1265
|
+
collectAliases(fn.body, scope);
|
|
1266
|
+
if (ts.isBlock(fn.body)) {
|
|
1267
|
+
walkStatements(fn.body.statements, {
|
|
1268
|
+
walk,
|
|
1269
|
+
conditions: []
|
|
1270
|
+
});
|
|
1271
|
+
if (walk.returnsSeen !== countReturns(fn.body)) return {
|
|
1272
|
+
status: "not_analyzed",
|
|
1273
|
+
location,
|
|
1274
|
+
reason: "unsupported control flow (switch, loop or try around a return)",
|
|
1275
|
+
snippet: snippet(fn)
|
|
1276
|
+
};
|
|
1277
|
+
if (!alwaysReturns(fn.body)) walk.branches.push({
|
|
1278
|
+
conditions: [],
|
|
1279
|
+
target: { kind: "stay" }
|
|
1280
|
+
});
|
|
1281
|
+
} else branchesOf(fn.body, {
|
|
1282
|
+
walk,
|
|
1283
|
+
conditions: []
|
|
1284
|
+
});
|
|
1285
|
+
const reads = collectReads(fn.body, scope);
|
|
1286
|
+
const sorted = (values) => [...values].sort();
|
|
1287
|
+
return {
|
|
1288
|
+
status: "analyzed",
|
|
1289
|
+
location,
|
|
1290
|
+
registration: at.registration.kind,
|
|
1291
|
+
branches: walk.branches,
|
|
1292
|
+
payloadReads: sorted(reads.payloadReads),
|
|
1293
|
+
contextReads: sorted(reads.contextReads),
|
|
1294
|
+
contextWrites: sorted(reads.contextWrites),
|
|
1295
|
+
readsActorState: reads.readsActorState,
|
|
1296
|
+
comparisons: reads.comparisons,
|
|
1297
|
+
literals: conditionLiterals(walk.conditions)
|
|
1298
|
+
};
|
|
1299
|
+
}
|
|
1300
|
+
/** Explain one registration's handler. Anything not understood is `not_analyzed`, never guessed. */
|
|
1301
|
+
function explainRegistration(registration, context) {
|
|
1302
|
+
const handler = registration.handler;
|
|
1303
|
+
const location = locationOf(registration.call, context.root);
|
|
1304
|
+
if (handler === void 0) return {
|
|
1305
|
+
status: "not_analyzed",
|
|
1306
|
+
location,
|
|
1307
|
+
reason: "handler is not a plain property",
|
|
1308
|
+
snippet: snippet(registration.call)
|
|
1309
|
+
};
|
|
1310
|
+
if (ts.isArrowFunction(handler) || ts.isFunctionExpression(handler)) return analyzeFunction(handler, {
|
|
1311
|
+
registration,
|
|
1312
|
+
...context
|
|
1313
|
+
});
|
|
1314
|
+
return {
|
|
1315
|
+
status: "not_analyzed",
|
|
1316
|
+
location,
|
|
1317
|
+
reason: `handler \`${text(handler)}\` is a reference, not an inline function`,
|
|
1318
|
+
snippet: snippet(handler)
|
|
1319
|
+
};
|
|
1320
|
+
}
|
|
1321
|
+
// src/ast/payloads.ts
|
|
1322
|
+
/** Most samples one type yields; unions and object variants stop here. */
|
|
1323
|
+
const MAX_SAMPLES = 8;
|
|
1324
|
+
/** Deepest nesting followed; deeper values become null. */
|
|
1325
|
+
const MAX_DEPTH = 5;
|
|
1326
|
+
function unique(values) {
|
|
1327
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1328
|
+
return values.filter((value) => {
|
|
1329
|
+
const key = JSON.stringify(value);
|
|
1330
|
+
if (seen.has(key)) return false;
|
|
1331
|
+
seen.add(key);
|
|
1332
|
+
return true;
|
|
1333
|
+
}).slice(0, MAX_SAMPLES);
|
|
1334
|
+
}
|
|
1335
|
+
function primitiveSamples(type, checker) {
|
|
1336
|
+
if (type.isStringLiteral()) return [type.value];
|
|
1337
|
+
if (type.isNumberLiteral()) return [type.value];
|
|
1338
|
+
const flags = type.getFlags();
|
|
1339
|
+
if (flags & ts.TypeFlags.BooleanLiteral) return [checker.typeToString(type) === "true"];
|
|
1340
|
+
if (flags & ts.TypeFlags.StringLike) return ["sample"];
|
|
1341
|
+
if (flags & ts.TypeFlags.NumberLike) return [1];
|
|
1342
|
+
if (flags & ts.TypeFlags.BigIntLike) return [1];
|
|
1343
|
+
if (flags & ts.TypeFlags.Null) return [null];
|
|
1344
|
+
if (flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) return [null];
|
|
1345
|
+
return null;
|
|
1346
|
+
}
|
|
1347
|
+
/**
|
|
1348
|
+
* One value per property from its first sample, plus one variant per further
|
|
1349
|
+
* sample of each property: a union of literals in one field shows up as
|
|
1350
|
+
* separate payloads without multiplying across fields.
|
|
1351
|
+
*/
|
|
1352
|
+
function objectSamples(type, walk) {
|
|
1353
|
+
const fields = walk.checker.getPropertiesOfType(type).flatMap((symbol) => {
|
|
1354
|
+
const fieldType = walk.checker.getTypeOfSymbolAtLocation(symbol, walk.node);
|
|
1355
|
+
if (walk.checker.getSignaturesOfType(fieldType, ts.SignatureKind.Call).length > 0) return [];
|
|
1356
|
+
const optional = (symbol.getFlags() & ts.SymbolFlags.Optional) !== 0;
|
|
1357
|
+
const samples = samplesOf(walk.checker.getNonNullableType(fieldType), {
|
|
1358
|
+
...walk,
|
|
1359
|
+
depth: walk.depth + 1
|
|
1360
|
+
});
|
|
1361
|
+
return optional && samples.length === 0 ? [] : [{
|
|
1362
|
+
name: symbol.getName(),
|
|
1363
|
+
samples
|
|
1364
|
+
}];
|
|
1365
|
+
});
|
|
1366
|
+
const base = Object.fromEntries(fields.map((field) => [field.name, field.samples[0] ?? null]));
|
|
1367
|
+
return unique([base, ...fields.flatMap((field) => field.samples.slice(1).map((sample) => ({
|
|
1368
|
+
...base,
|
|
1369
|
+
[field.name]: sample
|
|
1370
|
+
})))]);
|
|
1371
|
+
}
|
|
1372
|
+
/** JSON samples a value of `type` could take. Empty when the type has no JSON form (void, functions). */
|
|
1373
|
+
function samplesOf(type, walk) {
|
|
1374
|
+
if (walk.depth > MAX_DEPTH) return [null];
|
|
1375
|
+
const flags = type.getFlags();
|
|
1376
|
+
if (flags & (ts.TypeFlags.Void | ts.TypeFlags.Undefined | ts.TypeFlags.Never)) return [];
|
|
1377
|
+
if (type.isUnion()) return unique(type.types.flatMap((member) => samplesOf(member, walk)));
|
|
1378
|
+
const { checker } = walk;
|
|
1379
|
+
const primitive = primitiveSamples(type, checker);
|
|
1380
|
+
if (primitive !== null) return primitive;
|
|
1381
|
+
if (checker.isArrayType(type) || checker.isTupleType(type)) {
|
|
1382
|
+
const element = checker.getIndexTypeOfType(type, ts.IndexKind.Number);
|
|
1383
|
+
const first = element === void 0 ? void 0 : samplesOf(element, {
|
|
1384
|
+
...walk,
|
|
1385
|
+
depth: walk.depth + 1
|
|
1386
|
+
})[0];
|
|
1387
|
+
return [first === void 0 ? [] : [first]];
|
|
1388
|
+
}
|
|
1389
|
+
if (checker.getSignaturesOfType(type, ts.SignatureKind.Call).length > 0) return [];
|
|
1390
|
+
return flags & ts.TypeFlags.Object || type.isIntersection() ? objectSamples(type, walk) : [null];
|
|
1391
|
+
}
|
|
1392
|
+
/** Payload type of an EventRef: the parameter of its one-argument `create` overload. */
|
|
1393
|
+
function payloadType(checker, eventRef) {
|
|
1394
|
+
const create = checker.getTypeAtLocation(eventRef).getProperty("create");
|
|
1395
|
+
if (create === void 0) return null;
|
|
1396
|
+
const parameter = checker.getSignaturesOfType(checker.getTypeOfSymbolAtLocation(create, eventRef), ts.SignatureKind.Call).find((signature) => signature.parameters.length === 1)?.parameters[0];
|
|
1397
|
+
return parameter === void 0 ? null : checker.getTypeOfSymbolAtLocation(parameter, eventRef);
|
|
1398
|
+
}
|
|
1399
|
+
/**
|
|
1400
|
+
* Sample payloads per event, read from the payload types of the events the
|
|
1401
|
+
* file's handlers register (`event("x")<{ ... }>()`). Events without a payload
|
|
1402
|
+
* are left out, as the fuzzer expects.
|
|
1403
|
+
*/
|
|
1404
|
+
function payloadSamples(loaded) {
|
|
1405
|
+
const entries = loaded.registrations.flatMap((registration) => {
|
|
1406
|
+
if (registration.event === null) return [];
|
|
1407
|
+
const type = payloadType(loaded.checker, registration.eventRef);
|
|
1408
|
+
if (type === null) return [];
|
|
1409
|
+
const samples = samplesOf(type, {
|
|
1410
|
+
checker: loaded.checker,
|
|
1411
|
+
node: registration.eventRef,
|
|
1412
|
+
depth: 0
|
|
1413
|
+
});
|
|
1414
|
+
return samples.length === 0 ? [] : [[registration.event, samples]];
|
|
1415
|
+
});
|
|
1416
|
+
return Object.fromEntries(entries.sort(([left], [right]) => left.localeCompare(right)));
|
|
1417
|
+
}
|
|
1418
|
+
// src/ast/annotate.ts
|
|
1419
|
+
function sitesOf(loaded) {
|
|
1420
|
+
return loaded.registrations.flatMap((registration) => {
|
|
1421
|
+
if (registration.event === null) return [];
|
|
1422
|
+
const explanation = Either.from(() => explainRegistration(registration, {
|
|
1423
|
+
checker: loaded.checker,
|
|
1424
|
+
root: loaded.root
|
|
1425
|
+
}));
|
|
1426
|
+
return [{
|
|
1427
|
+
state: registration.state,
|
|
1428
|
+
event: registration.event,
|
|
1429
|
+
doc: registration.doc,
|
|
1430
|
+
explanation: Either.getOrElse(explanation, { onLeft: (error) => ({
|
|
1431
|
+
status: "not_analyzed",
|
|
1432
|
+
location: null,
|
|
1433
|
+
reason: `analysis failed: ${String(error)}`,
|
|
1434
|
+
snippet: null
|
|
1435
|
+
}) })
|
|
1436
|
+
}];
|
|
1437
|
+
});
|
|
1438
|
+
}
|
|
1439
|
+
/** Explain every handler registration in a file. Left = the file could not be analyzed at all. */
|
|
1440
|
+
function analyzeSource(source) {
|
|
1441
|
+
return Either.chain(loadSource(source), { onRight: (loaded) => Either.right(sitesOf(loaded)) });
|
|
1442
|
+
}
|
|
1443
|
+
function docsOf(loaded) {
|
|
1444
|
+
return {
|
|
1445
|
+
sites: sitesOf(loaded),
|
|
1446
|
+
stateDocs: loaded.stateDocs,
|
|
1447
|
+
eventDocs: loaded.eventDocs
|
|
1448
|
+
};
|
|
1449
|
+
}
|
|
1450
|
+
/** Handlers, payload samples and machine exports of a file, from one TypeScript program. */
|
|
1451
|
+
function analyzeModule(source) {
|
|
1452
|
+
return Either.chain(loadSource(source), { onRight: (loaded) => Either.right({
|
|
1453
|
+
...docsOf(loaded),
|
|
1454
|
+
payloads: payloadSamples(loaded),
|
|
1455
|
+
machines: machineExports(loaded)
|
|
1456
|
+
}) });
|
|
1457
|
+
}
|
|
1458
|
+
/** Region-prefixed ids (`region.state`) match registrations by their last segment. */
|
|
1459
|
+
function localState(id) {
|
|
1460
|
+
return id.slice(id.lastIndexOf(".") + 1);
|
|
1461
|
+
}
|
|
1462
|
+
function explain(edge, context) {
|
|
1463
|
+
if (edge.kind === "initial" || edge.kind === "effect") return {
|
|
1464
|
+
explanation: null,
|
|
1465
|
+
doc: null
|
|
1466
|
+
};
|
|
1467
|
+
const state = localState(edge.from);
|
|
1468
|
+
const site = context.sites.find((candidate) => candidate.state === state && candidate.event === edge.event) ?? context.sites.find((candidate) => candidate.state === null && candidate.event === edge.event);
|
|
1469
|
+
if (site) return {
|
|
1470
|
+
explanation: site.explanation,
|
|
1471
|
+
doc: site.doc
|
|
1472
|
+
};
|
|
1473
|
+
return {
|
|
1474
|
+
explanation: {
|
|
1475
|
+
status: "not_analyzed",
|
|
1476
|
+
location: null,
|
|
1477
|
+
reason: `no m.on/m.onAny registration for (${state}, ${edge.event}) found in ${context.file}`,
|
|
1478
|
+
snippet: null
|
|
1479
|
+
},
|
|
1480
|
+
doc: null
|
|
1481
|
+
};
|
|
1482
|
+
}
|
|
1483
|
+
/**
|
|
1484
|
+
* Fill `explanation` on handler-driven edges. Never adds or removes edges:
|
|
1485
|
+
* the runtime graph stays the source of truth. A source that cannot be
|
|
1486
|
+
* analyzed marks every handler edge `not_analyzed` with the reason.
|
|
1487
|
+
*/
|
|
1488
|
+
function annotateMachine(machine, source) {
|
|
1489
|
+
return annotateWith(machine, {
|
|
1490
|
+
analysis: Either.chain(loadSource(source), { onRight: (loaded) => Either.right(docsOf(loaded)) }),
|
|
1491
|
+
source
|
|
1492
|
+
});
|
|
1493
|
+
}
|
|
1494
|
+
/** `annotateMachine` with the analysis already done (one program for many machines). */
|
|
1495
|
+
function annotateWith(machine, context) {
|
|
1496
|
+
const { analysis, source } = context;
|
|
1497
|
+
const file = source.file.slice(source.root.length).replace(/^[\\/]/, "");
|
|
1498
|
+
const edges = machine.edges.map((edge) => {
|
|
1499
|
+
if (Either.isLeft(analysis)) {
|
|
1500
|
+
const explanation = edge.kind === "transition" || edge.kind === "stay" ? {
|
|
1501
|
+
status: "not_analyzed",
|
|
1502
|
+
location: null,
|
|
1503
|
+
reason: analysis[0],
|
|
1504
|
+
snippet: null
|
|
1505
|
+
} : null;
|
|
1506
|
+
return {
|
|
1507
|
+
...edge,
|
|
1508
|
+
explanation,
|
|
1509
|
+
doc: null
|
|
1510
|
+
};
|
|
1511
|
+
}
|
|
1512
|
+
return {
|
|
1513
|
+
...edge,
|
|
1514
|
+
...explain(edge, {
|
|
1515
|
+
sites: analysis[1].sites,
|
|
1516
|
+
file
|
|
1517
|
+
})
|
|
1518
|
+
};
|
|
1519
|
+
});
|
|
1520
|
+
if (Either.isLeft(analysis)) return {
|
|
1521
|
+
...machine,
|
|
1522
|
+
edges
|
|
1523
|
+
};
|
|
1524
|
+
const { stateDocs, eventDocs } = analysis[1];
|
|
1525
|
+
return {
|
|
1526
|
+
...machine,
|
|
1527
|
+
states: machine.states.map((state) => ({
|
|
1528
|
+
...state,
|
|
1529
|
+
doc: stateDocs.get(localState(state.id)) ?? null
|
|
1530
|
+
})),
|
|
1531
|
+
events: machine.events.map((event) => ({
|
|
1532
|
+
...event,
|
|
1533
|
+
doc: eventDocs.get(event.id) ?? null
|
|
1534
|
+
})),
|
|
1535
|
+
edges
|
|
1536
|
+
};
|
|
1537
|
+
}
|
|
1538
|
+
// src/inspect.ts
|
|
1539
|
+
const DEFAULT_ITERATIONS = 2e3;
|
|
1540
|
+
function isVirtualClock(clock) {
|
|
1541
|
+
return typeof Reflect.get(clock, "advance") === "function";
|
|
1542
|
+
}
|
|
1543
|
+
function warningsOf(machine) {
|
|
1544
|
+
const routing = routingWarnings(machine).map((warning) => warning.message);
|
|
1545
|
+
const sends = machine.states.flatMap((state) => unhandledSends(machine, state.id).map(({ effect, event }) => `effect ${effect} in ${state.id} sends ${event}, but ${state.id} has no handler for it`));
|
|
1546
|
+
return [...routing, ...sends];
|
|
1547
|
+
}
|
|
1548
|
+
/** The target as the fuzzer drives it, or why it cannot run on a virtual clock. */
|
|
1549
|
+
function runner(target, payloads) {
|
|
1550
|
+
if (!isVirtualClock(target.create().clock)) return Either.left("not fuzzed: the actor does not run on a VirtualClock");
|
|
1551
|
+
return Either.right({
|
|
1552
|
+
id: target.id,
|
|
1553
|
+
create: () => {
|
|
1554
|
+
const actor = target.create();
|
|
1555
|
+
return {
|
|
1556
|
+
actor,
|
|
1557
|
+
clock: isVirtualClock(actor.clock) ? actor.clock : VirtualClock()
|
|
1558
|
+
};
|
|
1559
|
+
},
|
|
1560
|
+
payloads
|
|
1561
|
+
});
|
|
1562
|
+
}
|
|
1563
|
+
function annotator(target, analysis) {
|
|
1564
|
+
const { source } = target;
|
|
1565
|
+
if (analysis === null || source === void 0) return (machine) => machine;
|
|
1566
|
+
return (machine) => annotateWith(machine, {
|
|
1567
|
+
analysis,
|
|
1568
|
+
source
|
|
1569
|
+
});
|
|
1570
|
+
}
|
|
1571
|
+
/** Fuzz and test coverage on top of the structure, as far as the target allows. */
|
|
1572
|
+
function driven(target, context) {
|
|
1573
|
+
const { structure, run } = context;
|
|
1574
|
+
if (Either.isLeft(run)) return {
|
|
1575
|
+
machine: structure,
|
|
1576
|
+
skipped: [run[0]]
|
|
1577
|
+
};
|
|
1578
|
+
const settings = target.fuzz;
|
|
1579
|
+
const fuzzed = settings === false ? structure : fuzzedMachine(run[1], fuzz(run[1], {
|
|
1580
|
+
seed: settings?.seed ?? DEFAULT_SEED,
|
|
1581
|
+
iterations: settings?.iterations ?? DEFAULT_ITERATIONS,
|
|
1582
|
+
...guidance(structure)
|
|
1583
|
+
}));
|
|
1584
|
+
return {
|
|
1585
|
+
machine: target.tests === void 0 ? fuzzed : withTestCoverage(run[1], fuzzed, target.tests),
|
|
1586
|
+
skipped: []
|
|
1587
|
+
};
|
|
1588
|
+
}
|
|
1589
|
+
function inspectWith(target, analysis) {
|
|
1590
|
+
const annotate = annotator(target, analysis);
|
|
1591
|
+
const inferred = Either.getRight(analysis ?? Either.left("none"))?.payloads ?? {};
|
|
1592
|
+
const { machine, skipped } = driven(target, {
|
|
1593
|
+
structure: annotate(toVizMachine({
|
|
1594
|
+
id: target.id,
|
|
1595
|
+
actor: target.create()
|
|
1596
|
+
})),
|
|
1597
|
+
run: runner(target, {
|
|
1598
|
+
...inferred,
|
|
1599
|
+
...target.payloads
|
|
1600
|
+
})
|
|
1601
|
+
});
|
|
1602
|
+
const quiet = target.fuzz === false && target.tests === void 0;
|
|
1603
|
+
const unanalyzed = analysis === null ? [] : [Either.getLeft(analysis)].filter((each) => each !== void 0);
|
|
1604
|
+
const annotated = annotate(machine);
|
|
1605
|
+
return {
|
|
1606
|
+
machine: annotated,
|
|
1607
|
+
warnings: [
|
|
1608
|
+
...unanalyzed,
|
|
1609
|
+
...quiet ? [] : skipped,
|
|
1610
|
+
...warningsOf(annotated)
|
|
1611
|
+
]
|
|
1612
|
+
};
|
|
1613
|
+
}
|
|
1614
|
+
/**
|
|
1615
|
+
* One machine in, one viz machine out: structure, edge explanations and
|
|
1616
|
+
* payloads from the source's types, fuzz coverage, test coverage. Each part
|
|
1617
|
+
* that cannot run is skipped with a warning, never guessed.
|
|
1618
|
+
*/
|
|
1619
|
+
function inspect(target) {
|
|
1620
|
+
return inspectWith(target, target.source === void 0 ? null : analyzeModule(target.source));
|
|
1621
|
+
}
|
|
1622
|
+
/** Directory of the nearest package.json above `file`: locations are reported relative to it. */
|
|
1623
|
+
function projectRoot(file) {
|
|
1624
|
+
let dir = dirname(file);
|
|
1625
|
+
while (!existsSync(join(dir, "package.json")) && dirname(dir) !== dir) dir = dirname(dir);
|
|
1626
|
+
return dir;
|
|
1627
|
+
}
|
|
1628
|
+
/** `createCheckoutActor` → `checkout`; other names are kept. */
|
|
1629
|
+
function machineId(exportName) {
|
|
1630
|
+
const core = exportName.replace(/^create/, "").replace(/Actor$/, "");
|
|
1631
|
+
return core === "" ? exportName : core.charAt(0).toLowerCase() + core.slice(1);
|
|
1632
|
+
}
|
|
1633
|
+
function isActor(value) {
|
|
1634
|
+
return typeof value === "object" && value !== null && [
|
|
1635
|
+
"send",
|
|
1636
|
+
"snapshot",
|
|
1637
|
+
"inject"
|
|
1638
|
+
].every((key) => typeof Reflect.get(value, key) === "function");
|
|
1639
|
+
}
|
|
1640
|
+
/** Call an export the way its type says, and take the actor out of what it returns. */
|
|
1641
|
+
function factoryOf(found, value) {
|
|
1642
|
+
if (found.call === "instance") return () => value;
|
|
1643
|
+
if (typeof value !== "function") return null;
|
|
1644
|
+
const argument = found.call === "clock" ? () => [VirtualClock()] : found.call === "options" ? () => [{ clock: VirtualClock() }] : () => [];
|
|
1645
|
+
return () => Reflect.apply(value, void 0, argument());
|
|
1646
|
+
}
|
|
1647
|
+
function actorFactory(found, value) {
|
|
1648
|
+
const factory = factoryOf(found, value);
|
|
1649
|
+
if (factory === null) return null;
|
|
1650
|
+
const unwrap = (result) => found.unwrap === "actor" && typeof result === "object" && result !== null ? Reflect.get(result, "actor") : result;
|
|
1651
|
+
const sample = unwrap(factory());
|
|
1652
|
+
if (!isActor(sample)) return null;
|
|
1653
|
+
return () => {
|
|
1654
|
+
const actor = unwrap(factory());
|
|
1655
|
+
return isActor(actor) ? actor : sample;
|
|
1656
|
+
};
|
|
1657
|
+
}
|
|
1658
|
+
/**
|
|
1659
|
+
* Every machine a file exports, found from its types: actors, and functions
|
|
1660
|
+
* that build one (optionally taking a clock or `{ clock }`). When a file has
|
|
1661
|
+
* factories, plain actor exports are skipped: they are usually one of those
|
|
1662
|
+
* factories' results, and cannot be fuzzed.
|
|
1663
|
+
*/
|
|
1664
|
+
async function inspectModule(file, options) {
|
|
1665
|
+
const source = {
|
|
1666
|
+
file,
|
|
1667
|
+
root: options.root ?? projectRoot(file),
|
|
1668
|
+
...options.tsconfig === void 0 ? {} : { tsconfig: options.tsconfig }
|
|
1669
|
+
};
|
|
1670
|
+
const analysis = analyzeModule(source);
|
|
1671
|
+
if (Either.isLeft(analysis)) return {
|
|
1672
|
+
machines: [],
|
|
1673
|
+
warnings: [`${file}: ${analysis[0]}`]
|
|
1674
|
+
};
|
|
1675
|
+
const found = analysis[1].machines;
|
|
1676
|
+
const factories = found.filter((each) => each.call !== "instance");
|
|
1677
|
+
const chosen = factories.length > 0 ? factories : found;
|
|
1678
|
+
if (chosen.length === 0) return {
|
|
1679
|
+
machines: [],
|
|
1680
|
+
warnings: [`${file}: no exported actor or actor factory found`]
|
|
1681
|
+
};
|
|
1682
|
+
const exports = await options.load(file);
|
|
1683
|
+
const results = chosen.map((each) => {
|
|
1684
|
+
const id = machineId(each.name);
|
|
1685
|
+
const create = actorFactory(each, exports[each.name]);
|
|
1686
|
+
if (create === null) return {
|
|
1687
|
+
machine: null,
|
|
1688
|
+
warnings: [`${id}: ${each.name} did not return an actor`]
|
|
1689
|
+
};
|
|
1690
|
+
const fuzzSettings = each.call === "instance" ? false : options.fuzz ?? {};
|
|
1691
|
+
const inspected = inspectWith({
|
|
1692
|
+
id,
|
|
1693
|
+
create,
|
|
1694
|
+
source,
|
|
1695
|
+
fuzz: fuzzSettings
|
|
1696
|
+
}, analysis);
|
|
1697
|
+
const instance = each.call === "instance" ? ["not fuzzed: exported as one actor, not a factory"] : [];
|
|
1698
|
+
return {
|
|
1699
|
+
machine: inspected.machine,
|
|
1700
|
+
warnings: [...instance, ...inspected.warnings].map((warning) => `${id}: ${warning}`)
|
|
1701
|
+
};
|
|
1702
|
+
});
|
|
1703
|
+
return {
|
|
1704
|
+
machines: results.flatMap((result) => result.machine === null ? [] : [result.machine]),
|
|
1705
|
+
warnings: results.flatMap((result) => result.warnings)
|
|
1706
|
+
};
|
|
1707
|
+
}
|
|
1708
|
+
/** Machines as a document, ready for `serializeViz`. */
|
|
1709
|
+
function vizDocument(machines) {
|
|
1710
|
+
return {
|
|
1711
|
+
schemaVersion: VIZ_SCHEMA_VERSION,
|
|
1712
|
+
machines: [...machines]
|
|
1713
|
+
};
|
|
1714
|
+
}
|
|
1715
|
+
// src/load.ts
|
|
1716
|
+
const ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g");
|
|
1717
|
+
const ENTRY = "virtual:mantaq-inspect";
|
|
1718
|
+
const RESOLVED = `\0${ENTRY}`;
|
|
1719
|
+
/**
|
|
1720
|
+
* A module that imports the machine file and the inspector side by side. Run
|
|
1721
|
+
* through one module graph, both see the same `@mantaq/core` instance: core
|
|
1722
|
+
* recognizes its state refs by a module-level registry, so a machine loaded
|
|
1723
|
+
* through a bundler and inspected from plain Node would look stateless.
|
|
1724
|
+
*/
|
|
1725
|
+
function entrySource(file) {
|
|
1726
|
+
const inspect = fileURLToPath(import.meta.resolve("@mantaq/inspect"));
|
|
1727
|
+
return [
|
|
1728
|
+
`import * as machines from ${JSON.stringify(file)};`,
|
|
1729
|
+
`import { inspectModule } from ${JSON.stringify(inspect)};`,
|
|
1730
|
+
`export function run(options) {`,
|
|
1731
|
+
` return inspectModule(${JSON.stringify(file)}, { ...options, load: async () => machines });`,
|
|
1732
|
+
`}`
|
|
1733
|
+
].join("\n");
|
|
1734
|
+
}
|
|
1735
|
+
/** Vite plugin serving the entry for each `virtual:mantaq-inspect?file=<path>` id. */
|
|
1736
|
+
function inspectEntry() {
|
|
1737
|
+
return {
|
|
1738
|
+
name: "mantaq-inspect-entry",
|
|
1739
|
+
resolveId: (id) => id.startsWith(ENTRY) ? `\0${id}` : null,
|
|
1740
|
+
load: (id) => {
|
|
1741
|
+
if (!id.startsWith(RESOLVED)) return null;
|
|
1742
|
+
const file = new URLSearchParams(id.slice(id.indexOf("?") + 1)).get("file");
|
|
1743
|
+
return file === null ? null : entrySource(file);
|
|
1744
|
+
}
|
|
1745
|
+
};
|
|
1746
|
+
}
|
|
1747
|
+
function entryId(file) {
|
|
1748
|
+
return `${ENTRY}?${new URLSearchParams({ file }).toString()}`;
|
|
1749
|
+
}
|
|
1750
|
+
/** The `run` export of an entry module, checked: module objects are untyped. */
|
|
1751
|
+
function runOf(module) {
|
|
1752
|
+
const run = typeof module === "object" && module !== null ? Reflect.get(module, "run") : void 0;
|
|
1753
|
+
if (typeof run !== "function") return null;
|
|
1754
|
+
return async (options) => Reflect.apply(run, void 0, [options]);
|
|
1755
|
+
}
|
|
1756
|
+
const ENV_ACCESS = [
|
|
1757
|
+
"import",
|
|
1758
|
+
"meta",
|
|
1759
|
+
"env"
|
|
1760
|
+
].join(".");
|
|
1761
|
+
/**
|
|
1762
|
+
* Replaces the env object with the project's env: `runnerImport` never reads
|
|
1763
|
+
* env files, so `VITE_*` variables would otherwise be missing. A textual
|
|
1764
|
+
* replace: it also rewrites matches in strings and comments, and a frozen
|
|
1765
|
+
* snapshot replaces Vite's own `import.meta.env`, so assigning to it fails.
|
|
1766
|
+
*/
|
|
1767
|
+
function projectEnv(env, mode) {
|
|
1768
|
+
const value = JSON.stringify({
|
|
1769
|
+
...env,
|
|
1770
|
+
BASE_URL: "/",
|
|
1771
|
+
MODE: mode,
|
|
1772
|
+
DEV: true,
|
|
1773
|
+
PROD: false,
|
|
1774
|
+
SSR: true
|
|
1775
|
+
});
|
|
1776
|
+
return {
|
|
1777
|
+
name: "mantaq-inspect-env",
|
|
1778
|
+
enforce: "pre",
|
|
1779
|
+
transform: (code, id) => id.includes("/node_modules/") || !code.includes(ENV_ACCESS) ? null : {
|
|
1780
|
+
code: code.replaceAll(ENV_ACCESS, `(${value})`),
|
|
1781
|
+
map: null
|
|
1782
|
+
}
|
|
1783
|
+
};
|
|
1784
|
+
}
|
|
1785
|
+
/**
|
|
1786
|
+
* The inline config for a load: empty without `--config`; otherwise the
|
|
1787
|
+
* project's Vite config (plugins, aliases, define) plus its env files.
|
|
1788
|
+
*/
|
|
1789
|
+
async function inlineConfig({ config, mode }) {
|
|
1790
|
+
const base = {
|
|
1791
|
+
configFile: false,
|
|
1792
|
+
logLevel: "silent"
|
|
1793
|
+
};
|
|
1794
|
+
if (config === void 0) return {
|
|
1795
|
+
...base,
|
|
1796
|
+
...mode === void 0 ? {} : { mode }
|
|
1797
|
+
};
|
|
1798
|
+
const chosen = mode ?? "development";
|
|
1799
|
+
const user = (await loadConfigFromFile({
|
|
1800
|
+
command: "serve",
|
|
1801
|
+
mode: chosen
|
|
1802
|
+
}, config))?.config ?? {};
|
|
1803
|
+
const envDir = resolve(dirname(config), user.envDir === false ? "." : user.envDir ?? ".");
|
|
1804
|
+
const env = user.envDir === false ? {} : loadEnv(chosen, envDir, user.envPrefix ?? "VITE_");
|
|
1805
|
+
return {
|
|
1806
|
+
...user,
|
|
1807
|
+
...base,
|
|
1808
|
+
mode: chosen,
|
|
1809
|
+
plugins: [...user.plugins ?? [], projectEnv(env, chosen)]
|
|
1810
|
+
};
|
|
1811
|
+
}
|
|
1812
|
+
/** Load `file` with Vite (TS, tsconfig paths, extensionless imports) and inspect its machines. */
|
|
1813
|
+
async function inspectFile(file, options = {}, load = {}) {
|
|
1814
|
+
if (load.config !== void 0 && !existsSync(load.config)) return {
|
|
1815
|
+
machines: [],
|
|
1816
|
+
warnings: [`${file}: Vite config not found: ${load.config}`],
|
|
1817
|
+
failed: true
|
|
1818
|
+
};
|
|
1819
|
+
const loaded = await inlineConfig(load).then((inline) => runnerImport(entryId(file), {
|
|
1820
|
+
...inline,
|
|
1821
|
+
plugins: [...inline.plugins ?? [], inspectEntry()]
|
|
1822
|
+
})).then((value) => Either.right(value), (error) => Either.left((error instanceof Error ? error.message : String(error)).replace(ANSI, "")));
|
|
1823
|
+
if (Either.isLeft(loaded)) return {
|
|
1824
|
+
machines: [],
|
|
1825
|
+
warnings: [`${file}: ${loaded[0]}`],
|
|
1826
|
+
failed: true
|
|
1827
|
+
};
|
|
1828
|
+
const { module } = loaded[1];
|
|
1829
|
+
const run = runOf(module);
|
|
1830
|
+
if (run === null) return {
|
|
1831
|
+
machines: [],
|
|
1832
|
+
warnings: [`${file}: could not load the inspector`],
|
|
1833
|
+
failed: true
|
|
1834
|
+
};
|
|
1835
|
+
return {
|
|
1836
|
+
...await run(options),
|
|
1837
|
+
failed: false
|
|
1838
|
+
};
|
|
1839
|
+
}
|
|
1840
|
+
export { toVizMachine as S, fuzzedMachine as _, inspect as a, shrink as b, projectRoot as c, analyzeSource as d, annotateMachine as f, literalPool as g, guidance as h, runOf as i, vizDocument as l, branchDistanceGuide as m, inspectEntry as n, inspectModule as o, branchDistance as p, inspectFile as r, machineId as s, entryId as t, analyzeModule as u, withTestCoverage as v, toViz as x, fuzz as y };
|