@typeonce/effect-machine 0.6.0 → 0.6.1

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.
Files changed (38) hide show
  1. package/package.json +5 -5
  2. package/src/Machine.ts +6873 -0
  3. package/src/index.ts +1 -0
  4. package/src/internal/machine/activities.ts +108 -0
  5. package/src/internal/machine/atom.ts +636 -0
  6. package/src/internal/machine/cluster.ts +394 -0
  7. package/src/internal/machine/command.ts +58 -0
  8. package/src/internal/machine/commandRuntime.ts +43 -0
  9. package/src/internal/machine/configuration.ts +1331 -0
  10. package/src/internal/machine/errors.ts +87 -0
  11. package/src/internal/machine/executionPlan.ts +996 -0
  12. package/src/internal/machine/invocation.ts +119 -0
  13. package/src/internal/machine/machine.ts +1747 -0
  14. package/src/internal/machine/planner.ts +1933 -0
  15. package/src/internal/machine/process.ts +906 -0
  16. package/src/internal/machine/protocol.ts +322 -0
  17. package/src/internal/machine/readiness.ts +10 -0
  18. package/src/internal/machine/runtime.ts +2512 -0
  19. package/src/internal/machine/serialization.ts +498 -0
  20. package/src/internal/machine/stateDefinition.ts +270 -0
  21. package/src/internal/machine/symbols.ts +2 -0
  22. package/src/internal/machine/topology.ts +479 -0
  23. package/src/internal/testing/machine/arbitrary.ts +102 -0
  24. package/src/internal/testing/machine/exploration.ts +331 -0
  25. package/src/internal/testing/machine/finiteModel.ts +1498 -0
  26. package/src/internal/testing/machine/invariant.ts +372 -0
  27. package/src/internal/testing/machine/probe.ts +79 -0
  28. package/src/internal/testing/machine/referenceModel.ts +1505 -0
  29. package/src/internal/testing/machine/runtime.ts +1710 -0
  30. package/src/internal/testing/machine/runtimeInvariant.ts +486 -0
  31. package/src/internal/testing/machine/trace.ts +150 -0
  32. package/src/internal/testing/machine/verification.ts +1890 -0
  33. package/src/testing/MachineTest.ts +2067 -0
  34. package/src/testing/index.ts +7 -0
  35. package/src/unstable/cluster/ClusterMachine.ts +390 -0
  36. package/src/unstable/cluster/index.ts +1 -0
  37. package/src/unstable/reactivity/AtomMachine.ts +649 -0
  38. package/src/unstable/reactivity/index.ts +1 -0
@@ -0,0 +1,1890 @@
1
+ /**
2
+ * Property-based scenario generation and planner trace utilities.
3
+ *
4
+ * @since 0.4.0
5
+ */
6
+
7
+ import * as Data from "effect/Data"
8
+ import * as Effect from "effect/Effect"
9
+ import * as Graph from "effect/Graph"
10
+ import * as Schema from "effect/Schema"
11
+ import * as SchemaAST from "effect/SchemaAST"
12
+ import { FastCheck } from "effect/testing"
13
+ import * as Machine from "../../../Machine.js"
14
+ import type {
15
+ Coverage,
16
+ CoverageSummary,
17
+ EventCoverageItem,
18
+ InitialTrace,
19
+ Microstep,
20
+ ObservedGraph,
21
+ ObservedGraphEdge,
22
+ ObservedGraphNode,
23
+ PlanCompletion,
24
+ RunFailure,
25
+ Scenario,
26
+ ScenarioOptions,
27
+ Scenarios,
28
+ SchemaArbitraryDiagnostic,
29
+ StateCoverageItem,
30
+ Trace,
31
+ TraceStep,
32
+ TransitionCoverageItem,
33
+ VerificationLaw,
34
+ VerificationLawGroup,
35
+ VerificationViolation,
36
+ VerifyOptions
37
+ } from "../../../testing/MachineTest.js"
38
+ import { toArbitraryWithReport } from "./arbitrary.js"
39
+ import type { FiniteModel } from "./finiteModel.js"
40
+ import * as ReferenceModel from "./referenceModel.js"
41
+ import { rawConfigurationPaths, run } from "./trace.js"
42
+
43
+ export {
44
+ advanceCommand,
45
+ type CausalRuntimeAssertionContext,
46
+ type CausalRuntimeCommandActual,
47
+ CausalRuntimeCommandFailure,
48
+ type CausalRuntimeCommandRecord,
49
+ type CausalRuntimeCommandResult,
50
+ type CausalRuntimeInspectionContext,
51
+ type CausalRuntimeModelOptions,
52
+ type CausalRuntimeModelStep,
53
+ type CausalRuntimeTranscript,
54
+ type CausalVerificationAwaitContext,
55
+ type CausalVerificationOptions,
56
+ type CausalVerificationTranscript,
57
+ checkpointCommand,
58
+ type EnqueuedRuntimeAssertionContext,
59
+ type EnqueuedRuntimeCommandActual,
60
+ type EnqueuedRuntimeCommandRecord,
61
+ type EnqueuedRuntimeInspectionContext,
62
+ type EnqueuedRuntimeModelOptions,
63
+ type EnqueuedRuntimeModelStep,
64
+ type EnqueuedRuntimeTranscript,
65
+ formatCausalTranscript,
66
+ formatEnqueuedTranscript,
67
+ formatRuntimeTranscript,
68
+ runCausalCommands,
69
+ runEnqueuedCommands,
70
+ runRuntimeCommands,
71
+ type RuntimeAssertionContext,
72
+ type RuntimeAwait,
73
+ type RuntimeCommand,
74
+ type RuntimeCommandActual,
75
+ RuntimeCommandFailure,
76
+ type RuntimeCommandRecord,
77
+ type RuntimeCommandResult,
78
+ type RuntimeCommands,
79
+ runtimeCommands,
80
+ type RuntimeCommandsDiagnostics,
81
+ type RuntimeCommandsOptions,
82
+ type RuntimeInspectionContext,
83
+ type RuntimeModelOptions,
84
+ type RuntimeModelStep,
85
+ RuntimeObservationError,
86
+ RuntimeSynchronization,
87
+ type RuntimeTranscript,
88
+ sendCommand,
89
+ stopCommand,
90
+ verifyCausalCommands
91
+ } from "./runtime.js"
92
+
93
+ export type { SchemaArbitraryOpaqueFilterWarning, SchemaArbitraryReport, SchemaArbitraryWarning } from "./arbitrary.js"
94
+
95
+ export {
96
+ compileModel,
97
+ type FiniteAtomicState,
98
+ type FiniteAutomaticTransition,
99
+ type FiniteCompoundState,
100
+ type FiniteEventTransition,
101
+ type FiniteFinalState,
102
+ type FiniteHistoryMutation,
103
+ type FiniteHistoryScenario,
104
+ type FiniteHistoryState,
105
+ type FiniteHistoryTransfer,
106
+ type FiniteModel,
107
+ type FiniteModelDiagnostics,
108
+ type FiniteModelOptions,
109
+ type FiniteModels,
110
+ finiteModels,
111
+ type FiniteParallelState,
112
+ type FiniteState,
113
+ type FiniteTransition,
114
+ type FiniteTransitionTrigger
115
+ } from "./finiteModel.js"
116
+
117
+ export {
118
+ ModelVerificationError,
119
+ type ModelVerificationField,
120
+ type ModelVerificationLocation,
121
+ type ModelVerificationMismatch,
122
+ type ReferenceCompletion,
123
+ type ReferenceHistoryRecord,
124
+ type ReferenceInitialStep,
125
+ type ReferenceMicrostep,
126
+ type ReferenceState,
127
+ type ReferenceStateValue,
128
+ type ReferenceStep,
129
+ type ReferenceTrace,
130
+ type ReferenceTransition
131
+ } from "./referenceModel.js"
132
+
133
+ export { assertInvariants, checkInvariants, Invariant, InvariantError, invariants } from "./invariant.js"
134
+
135
+ export {
136
+ assertPlannerRuntimeAgreement,
137
+ assertRuntimeInvariants,
138
+ checkRuntimeInvariants,
139
+ PlannerRuntimeAgreementError,
140
+ RuntimeInvariantError,
141
+ runtimeInvariants
142
+ } from "./runtimeInvariant.js"
143
+
144
+ export { assertReachable, assertUnreachable, explore, findShortest, ReachabilityError } from "./exploration.js"
145
+
146
+ export { probe, ProbeUnavailableError } from "./probe.js"
147
+
148
+ export { run }
149
+
150
+ export const interpretModel = ReferenceModel.interpretModel
151
+
152
+ type AnyMachine = Machine.Machine.Any
153
+
154
+ type InputValue<M extends AnyMachine> = Machine.Machine.Input<M>["Type"]
155
+
156
+ type StatePath<M extends AnyMachine> = Machine.Machine.StateIdentifier<Machine.Machine.States<M>>
157
+
158
+ type StateNodePath<M extends AnyMachine> = Machine.Machine.StateNodeIdentifier<Machine.Machine.States<M>>
159
+
160
+ const validateLength = (name: "minEvents" | "maxEvents", value: number): void => {
161
+ if (!Number.isSafeInteger(value) || value < 0) {
162
+ throw new Error(`MachineTest.scenarios expected ${name} to be a non-negative safe integer`)
163
+ }
164
+ }
165
+
166
+ export const scenarios = <M extends AnyMachine>(
167
+ machine: M,
168
+ options: ScenarioOptions<M> = {} as ScenarioOptions<M>
169
+ ): Scenarios<M> => {
170
+ const minEvents = options.minEvents ?? 0
171
+ const maxEvents = options.maxEvents ?? 50
172
+ if (options.eventsArbitrary === undefined) {
173
+ validateLength("minEvents", minEvents)
174
+ validateLength("maxEvents", maxEvents)
175
+ if (minEvents > maxEvents) {
176
+ throw new Error("MachineTest.scenarios expected minEvents to be less than or equal to maxEvents")
177
+ }
178
+ if (machine.events.length === 0 && minEvents > 0) {
179
+ throw new Error(
180
+ "MachineTest.scenarios cannot generate a non-empty event sequence for a machine without public events"
181
+ )
182
+ }
183
+ }
184
+
185
+ const diagnostics: Array<SchemaArbitraryDiagnostic> = []
186
+ const eventArbitraries = options.eventsArbitrary === undefined
187
+ ? machine.events.map((schema, index) => {
188
+ const derived = toArbitraryWithReport(schema)
189
+ diagnostics.push({
190
+ boundary: "event",
191
+ index,
192
+ report: derived.report
193
+ })
194
+ return derived.value as FastCheck.Arbitrary<Machine.Machine.InputEvent<M>>
195
+ })
196
+ : []
197
+
198
+ const eventsArbitrary = options.eventsArbitrary ?? (eventArbitraries.length === 0
199
+ ? FastCheck.constant<ReadonlyArray<Machine.Machine.InputEvent<M>>>([])
200
+ : FastCheck.array(
201
+ FastCheck.oneof(
202
+ ...eventArbitraries as [
203
+ FastCheck.Arbitrary<Machine.Machine.InputEvent<M>>,
204
+ ...Array<FastCheck.Arbitrary<Machine.Machine.InputEvent<M>>>
205
+ ]
206
+ ),
207
+ { minLength: minEvents, maxLength: maxEvents }
208
+ ))
209
+
210
+ if (machine.input === undefined || machine.input === Schema.Void) {
211
+ if (options.inputArbitrary !== undefined) {
212
+ throw new Error("MachineTest.scenarios cannot override input for a machine without an input schema")
213
+ }
214
+ return {
215
+ arbitrary: eventsArbitrary.map((events) => ({ events }) as Scenario<M>),
216
+ diagnostics: {
217
+ input: "none",
218
+ events: options.eventsArbitrary !== undefined ? "override" : eventArbitraries.length === 0 ? "empty" : "schema",
219
+ schemas: diagnostics
220
+ }
221
+ }
222
+ }
223
+
224
+ let inputArbitrary: FastCheck.Arbitrary<InputValue<M>>
225
+ if (options.inputArbitrary !== undefined) {
226
+ inputArbitrary = options.inputArbitrary
227
+ } else {
228
+ const derived = toArbitraryWithReport(machine.input)
229
+ diagnostics.unshift({
230
+ boundary: "input",
231
+ index: undefined,
232
+ report: derived.report
233
+ })
234
+ inputArbitrary = derived.value as FastCheck.Arbitrary<InputValue<M>>
235
+ }
236
+
237
+ return {
238
+ arbitrary: FastCheck.tuple(inputArbitrary, eventsArbitrary).map(([input, events]) =>
239
+ ({
240
+ input,
241
+ events
242
+ }) as Scenario<M>
243
+ ),
244
+ diagnostics: {
245
+ input: options.inputArbitrary !== undefined ? "override" : "schema",
246
+ events: options.eventsArbitrary !== undefined ? "override" : eventArbitraries.length === 0 ? "empty" : "schema",
247
+ schemas: diagnostics
248
+ }
249
+ }
250
+ }
251
+
252
+ export const verifyModel = <M extends AnyMachine>(
253
+ model: FiniteModel,
254
+ actualTrace: Trace<M>
255
+ ): Effect.Effect<void, ReferenceModel.ModelVerificationError> => ReferenceModel.verifyModelTrace(model, actualTrace)
256
+
257
+ const canonicalize = (value: unknown, active: WeakSet<object>): unknown => {
258
+ if (value === undefined) return { $undefined: true }
259
+ if (typeof value === "bigint") return { $bigint: String(value) }
260
+ if (typeof value === "symbol") return { $symbol: String(value) }
261
+ if (typeof value === "function") return { $function: value.name || "anonymous" }
262
+ if (typeof value !== "object" || value === null) return value
263
+ if (active.has(value)) return { $circular: true }
264
+ active.add(value)
265
+ let result: unknown
266
+ if (value instanceof Error) {
267
+ result = {
268
+ $error: value.name,
269
+ message: value.message,
270
+ ...Object.fromEntries(
271
+ Object.keys(value).sort().map((
272
+ key
273
+ ) => [key, canonicalize((value as unknown as Record<string, unknown>)[key], active)])
274
+ )
275
+ }
276
+ } else if (Array.isArray(value)) {
277
+ result = value.map((item) => canonicalize(item, active))
278
+ } else if (value instanceof Date) {
279
+ result = { $date: value.toISOString() }
280
+ } else if (value instanceof Map) {
281
+ result = {
282
+ $map: Array.from(value, ([key, item]) => [canonicalize(key, active), canonicalize(item, active)]).sort((a, b) =>
283
+ JSON.stringify(a).localeCompare(JSON.stringify(b))
284
+ )
285
+ }
286
+ } else if (value instanceof Set) {
287
+ result = {
288
+ $set: Array.from(value, (item) => canonicalize(item, active)).sort((a, b) =>
289
+ JSON.stringify(a).localeCompare(JSON.stringify(b))
290
+ )
291
+ }
292
+ } else {
293
+ result = Object.fromEntries(
294
+ Object.keys(value).sort().map((key) => [key, canonicalize((value as Record<string, unknown>)[key], active)])
295
+ )
296
+ }
297
+ active.delete(value)
298
+ return result
299
+ }
300
+
301
+ const formatValue = (value: unknown): string => JSON.stringify(canonicalize(value, new WeakSet()))
302
+
303
+ const structuralFingerprint = (value: unknown): string => {
304
+ const references = new WeakMap<object, number>()
305
+ let nextReference = 0
306
+ const visit = (current: unknown): unknown => {
307
+ if (current === undefined) return ["undefined"]
308
+ if (current === null) return ["null"]
309
+ if (typeof current === "number") {
310
+ if (Number.isNaN(current)) return ["number", "NaN"]
311
+ if (Object.is(current, -0)) return ["number", "-0"]
312
+ return ["number", current]
313
+ }
314
+ if (typeof current === "bigint") return ["bigint", String(current)]
315
+ if (typeof current === "symbol") {
316
+ return [
317
+ "symbol",
318
+ Symbol.keyFor(current) === undefined ? "local" : "global",
319
+ Symbol.keyFor(current) ?? current.description
320
+ ]
321
+ }
322
+ if (typeof current === "function") return ["function", current.name, current.length]
323
+ if (typeof current !== "object") return [typeof current, current]
324
+
325
+ const existing = references.get(current)
326
+ if (existing !== undefined) return ["reference", existing]
327
+ const reference = nextReference++
328
+ references.set(current, reference)
329
+
330
+ if (Array.isArray(current)) return ["array", reference, current.map(visit)]
331
+ if (current instanceof Date) return ["date", reference, current.getTime()]
332
+ if (current instanceof RegExp) return ["regexp", reference, current.source, current.flags]
333
+ if (current instanceof ArrayBuffer) {
334
+ return ["array-buffer", reference, Array.from(new Uint8Array(current))]
335
+ }
336
+ if (typeof SharedArrayBuffer !== "undefined" && current instanceof SharedArrayBuffer) {
337
+ return ["shared-array-buffer", reference, Array.from(new Uint8Array(current))]
338
+ }
339
+ if (ArrayBuffer.isView(current)) {
340
+ return [
341
+ "array-buffer-view",
342
+ reference,
343
+ current.constructor.name,
344
+ current.byteOffset,
345
+ current.byteLength,
346
+ Array.from(new Uint8Array(current.buffer, current.byteOffset, current.byteLength))
347
+ ]
348
+ }
349
+ if (current instanceof Error) {
350
+ return ["error", reference, current.name, current.message, visit(Object.fromEntries(Object.entries(current)))]
351
+ }
352
+ if (current instanceof Map) {
353
+ return ["map", reference, Array.from(current, ([key, item]) => [visit(key), visit(item)])]
354
+ }
355
+ if (current instanceof Set) return ["set", reference, Array.from(current, visit)]
356
+
357
+ const stringKeys = Object.getOwnPropertyNames(current).sort()
358
+ const symbolKeys = Object.getOwnPropertySymbols(current).sort((left, right) =>
359
+ String(Symbol.keyFor(left) ?? left.description).localeCompare(String(Symbol.keyFor(right) ?? right.description))
360
+ )
361
+ return [
362
+ "object",
363
+ reference,
364
+ Object.getPrototypeOf(current)?.constructor?.name ?? null,
365
+ stringKeys.map((key) => [key, visit((current as Record<string, unknown>)[key])]),
366
+ symbolKeys.map((key) => [visit(key), visit((current as Record<symbol, unknown>)[key])])
367
+ ]
368
+ }
369
+ return JSON.stringify(visit(value))
370
+ }
371
+
372
+ const structurallyEqual = (left: unknown, right: unknown): boolean => {
373
+ const leftToRight = new WeakMap<object, object>()
374
+ const rightToLeft = new WeakMap<object, object>()
375
+ const compare = (a: unknown, b: unknown): boolean => {
376
+ if (Object.is(a, b)) return true
377
+ if (typeof a !== typeof b || a === null || b === null) return false
378
+ if (typeof a !== "object" || typeof b !== "object") return false
379
+ const knownRight = leftToRight.get(a)
380
+ const knownLeft = rightToLeft.get(b)
381
+ if (knownRight !== undefined || knownLeft !== undefined) return knownRight === b && knownLeft === a
382
+ leftToRight.set(a, b)
383
+ rightToLeft.set(b, a)
384
+
385
+ if (Object.getPrototypeOf(a) !== Object.getPrototypeOf(b)) return false
386
+ if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime()
387
+ if (a instanceof RegExp && b instanceof RegExp) return a.source === b.source && a.flags === b.flags
388
+ if (a instanceof ArrayBuffer && b instanceof ArrayBuffer) {
389
+ return a.byteLength === b.byteLength &&
390
+ new Uint8Array(a).every((byte, index) => byte === new Uint8Array(b)[index])
391
+ }
392
+ if (
393
+ typeof SharedArrayBuffer !== "undefined" && a instanceof SharedArrayBuffer && b instanceof SharedArrayBuffer
394
+ ) {
395
+ return a.byteLength === b.byteLength &&
396
+ new Uint8Array(a).every((byte, index) => byte === new Uint8Array(b)[index])
397
+ }
398
+ if (ArrayBuffer.isView(a) && ArrayBuffer.isView(b)) {
399
+ if (a.constructor !== b.constructor || a.byteOffset !== b.byteOffset || a.byteLength !== b.byteLength) {
400
+ return false
401
+ }
402
+ const leftBytes = new Uint8Array(a.buffer, a.byteOffset, a.byteLength)
403
+ const rightBytes = new Uint8Array(b.buffer, b.byteOffset, b.byteLength)
404
+ return leftBytes.every((byte, index) => byte === rightBytes[index])
405
+ }
406
+ if (a instanceof Error && b instanceof Error && (a.name !== b.name || a.message !== b.message)) return false
407
+ if (a instanceof Map && b instanceof Map) {
408
+ if (a.size !== b.size) return false
409
+ const leftEntries = Array.from(a)
410
+ const rightEntries = Array.from(b)
411
+ return leftEntries.every(([key, item], index) =>
412
+ compare(key, rightEntries[index]![0]) && compare(item, rightEntries[index]![1])
413
+ )
414
+ }
415
+ if (a instanceof Set && b instanceof Set) {
416
+ if (a.size !== b.size) return false
417
+ const rightValues = Array.from(b)
418
+ return Array.from(a).every((item, index) => compare(item, rightValues[index]))
419
+ }
420
+ if (Array.isArray(a) !== Array.isArray(b)) return false
421
+ if (!Array.isArray(a) && Object.getPrototypeOf(a) !== Object.prototype && Object.getPrototypeOf(a) !== null) {
422
+ return false
423
+ }
424
+
425
+ const leftKeys = Reflect.ownKeys(a).sort((first, second) => String(first).localeCompare(String(second)))
426
+ const rightKeys = Reflect.ownKeys(b).sort((first, second) => String(first).localeCompare(String(second)))
427
+ return leftKeys.length === rightKeys.length &&
428
+ leftKeys.every((key, index) =>
429
+ Object.is(key, rightKeys[index]) && compare(
430
+ (a as Record<PropertyKey, unknown>)[key],
431
+ (b as Record<PropertyKey, unknown>)[rightKeys[index]!]
432
+ )
433
+ )
434
+ }
435
+ return compare(left, right)
436
+ }
437
+
438
+ const makeStructuralIdentityIndex = () => {
439
+ const buckets = new Map<string, Array<{ readonly id: string; readonly value: unknown }>>()
440
+ return (value: unknown): string => {
441
+ const fingerprint = structuralFingerprint(value)
442
+ let bucket = buckets.get(fingerprint)
443
+ if (bucket === undefined) buckets.set(fingerprint, bucket = [])
444
+ const existing = bucket.find((candidate) => structurallyEqual(candidate.value, value))
445
+ if (existing !== undefined) return existing.id
446
+ const id = bucket.length === 0 ? fingerprint : `${fingerprint}#collision:${bucket.length}`
447
+ bucket.push({ id, value })
448
+ return id
449
+ }
450
+ }
451
+
452
+ const coverageSummary = <Item>(declared: ReadonlyArray<Item>, hit: ReadonlySet<number>): CoverageSummary<Item> => {
453
+ const hits: Array<Item> = []
454
+ const misses: Array<Item> = []
455
+ declared.forEach((item, index) => (hit.has(index) ? hits : misses).push(item))
456
+ return {
457
+ total: declared.length,
458
+ hit: hits.length,
459
+ missing: misses.length,
460
+ hits,
461
+ misses
462
+ }
463
+ }
464
+
465
+ const normalizeTraces = <M extends AnyMachine>(
466
+ traceOrTraces: Trace<M> | ReadonlyArray<Trace<M>>
467
+ ): ReadonlyArray<Trace<M>> =>
468
+ Array.isArray(traceOrTraces) ? traceOrTraces as ReadonlyArray<Trace<M>> : [traceOrTraces as Trace<M>]
469
+
470
+ const sameCoverageTrigger = (
471
+ left: Machine.Machine.TransitionTrigger,
472
+ right: Machine.Machine.TransitionTrigger
473
+ ): boolean =>
474
+ left.type === right.type && (left.type !== "event" || right.type === "event" && left.event === right.event)
475
+
476
+ const targetWithinDeclaredBounds = (
477
+ target: string | undefined,
478
+ bounds: Machine.Machine.TransitionTargets
479
+ ): boolean =>
480
+ target === undefined || bounds.type === "dynamic" ||
481
+ bounds.paths.some((path) => target === path || target.startsWith(`${path}.`))
482
+
483
+ const finiteTagValues = (ast: SchemaAST.AST): ReadonlyArray<PropertyKey> | undefined => {
484
+ if (SchemaAST.isLiteral(ast)) {
485
+ return typeof ast.literal === "string" || typeof ast.literal === "number" ? [ast.literal] : undefined
486
+ }
487
+ if (SchemaAST.isUniqueSymbol(ast)) return [ast.symbol]
488
+ if (SchemaAST.isUnion(ast)) {
489
+ const values: Array<PropertyKey> = []
490
+ for (const member of ast.types) {
491
+ const memberValues = finiteTagValues(member)
492
+ if (memberValues === undefined) return undefined
493
+ for (const value of memberValues) if (!values.includes(value)) values.push(value)
494
+ }
495
+ return values
496
+ }
497
+ if (SchemaAST.isObjects(ast)) {
498
+ const tag = ast.propertySignatures.find(({ name }) => name === "_tag")?.type
499
+ return tag === undefined ? undefined : finiteTagValues(tag)
500
+ }
501
+ if (SchemaAST.isDeclaration(ast)) {
502
+ const sentinels = ast.annotations?.["~sentinels"]
503
+ if (Array.isArray(sentinels)) {
504
+ const tag = sentinels.find((sentinel): sentinel is { readonly key: "_tag"; readonly literal: PropertyKey } =>
505
+ typeof sentinel === "object" && sentinel !== null && sentinel.key === "_tag" &&
506
+ (typeof sentinel.literal === "string" ||
507
+ typeof sentinel.literal === "number" ||
508
+ typeof sentinel.literal === "symbol")
509
+ )
510
+ if (tag !== undefined) return [tag.literal]
511
+ }
512
+ for (const parameter of ast.typeParameters) {
513
+ const values = finiteTagValues(parameter)
514
+ if (values !== undefined) return values
515
+ }
516
+ }
517
+ if (SchemaAST.isSuspend(ast)) return finiteTagValues(ast.thunk())
518
+ return undefined
519
+ }
520
+
521
+ const publicEventTags = <M extends AnyMachine>(machine: M): {
522
+ readonly tags: ReadonlyArray<Machine.Machine.TagOf<Machine.Machine.InputEvents<M>[number]>>
523
+ readonly diagnostics: ReadonlyArray<{ readonly schemaIndex: number; readonly message: string }>
524
+ } => {
525
+ const tags: Array<Machine.Machine.TagOf<Machine.Machine.InputEvents<M>[number]>> = []
526
+ const diagnostics: Array<{ readonly schemaIndex: number; readonly message: string }> = []
527
+ machine.events.forEach((schema, schemaIndex) => {
528
+ const values = finiteTagValues(SchemaAST.toType(schema.ast))
529
+ if (values === undefined) {
530
+ diagnostics.push({
531
+ schemaIndex,
532
+ message: "The decoded _tag schema is not a finite literal, unique symbol, or finite union"
533
+ })
534
+ return
535
+ }
536
+ for (const value of values) {
537
+ const tag = value as Machine.Machine.TagOf<Machine.Machine.InputEvents<M>[number]>
538
+ if (!tags.includes(tag)) tags.push(tag)
539
+ }
540
+ })
541
+ return { tags, diagnostics }
542
+ }
543
+
544
+ export const coverage = <M extends AnyMachine>(
545
+ machine: M,
546
+ traceOrTraces: Trace<M> | ReadonlyArray<Trace<M>>
547
+ ): Coverage<M> => {
548
+ const traces = normalizeTraces(traceOrTraces)
549
+ const stateNodes = Machine.stateNodes(machine)
550
+ const activeNodes = stateNodes.filter((node) => node.type !== "history" && node.type !== "choice").map(
551
+ (node): StateCoverageItem<StatePath<M>> => ({
552
+ path: node.path as StatePath<M>,
553
+ type: node.type as StateCoverageItem["type"]
554
+ })
555
+ )
556
+ const activeIndex = new Map<string, number>(activeNodes.map((node, index) => [node.path, index]))
557
+ const activationHits = new Set<number>()
558
+ const entryHits = new Set<number>()
559
+ const exitHits = new Set<number>()
560
+
561
+ const definitions = Machine.transitionDefinitions(machine).map(
562
+ (
563
+ definition,
564
+ index
565
+ ): TransitionCoverageItem<
566
+ StateNodePath<M>,
567
+ Machine.Machine.TagOf<Machine.Machine.Events<M>[number]>,
568
+ StateNodePath<M>
569
+ > => ({
570
+ id: `transition:${index}:${formatValue(definition)}`,
571
+ index,
572
+ source: definition.source,
573
+ trigger: definition.trigger,
574
+ reenter: definition.reenter,
575
+ targets: definition.targets
576
+ })
577
+ )
578
+ const transitionHits = new Set<number>()
579
+
580
+ const declaredEvents = publicEventTags(machine)
581
+ const declaredEventTags = declaredEvents.tags
582
+ const eventCounts = new Map<PropertyKey, number>(declaredEventTags.map((tag) => [tag, 0]))
583
+ const logicalConfigurationIdentities = new Set<string>()
584
+ const logicalConfigurationIdentity = makeStructuralIdentityIndex()
585
+ let configurationObservations = 0
586
+ let scenarioEvents = 0
587
+ let emptyScenarios = 0
588
+ let startupWithMicrosteps = 0
589
+ let microsteps = 0
590
+ let changedMicrosteps = 0
591
+ let targetlessTransitions = 0
592
+ let raisedEvents = 0
593
+ let emittedEvents = 0
594
+ let eventTriggered = 0
595
+ let alwaysTriggered = 0
596
+ let doneTriggered = 0
597
+ let choiceTriggered = 0
598
+ let donePlans = 0
599
+ let completionRecordObservations = 0
600
+ const completionPaths = new Set<string>()
601
+ let historyRecordObservations = 0
602
+ const historyModes = new Map<string, Set<"shallow" | "deep">>()
603
+ let historyTargets = 0
604
+ let resolvedHistoryTargets = 0
605
+ const nodeByPath = new Map(stateNodes.map((node) => [node.path, node]))
606
+
607
+ const hitPaths = (paths: ReadonlyArray<string>, hits: Set<number>): void => {
608
+ for (const path of paths) {
609
+ const index = activeIndex.get(path)
610
+ if (index !== undefined) hits.add(index)
611
+ }
612
+ }
613
+
614
+ const observeSnapshot = (snapshot: Machine.Machine.Snapshot<Machine.Machine.States<M>>): void => {
615
+ const paths = rawConfigurationPaths(machine, snapshot) as ReadonlyArray<string>
616
+ hitPaths(paths, activationHits)
617
+ configurationObservations += 1
618
+ logicalConfigurationIdentities.add(logicalConfigurationIdentity(snapshot))
619
+ const metadata = snapshot as unknown as {
620
+ readonly completed?: ReadonlyArray<{ readonly path: string }>
621
+ readonly history?: Readonly<Record<string, { readonly mode: "shallow" | "deep" }>>
622
+ }
623
+ for (const completion of metadata.completed ?? []) {
624
+ completionRecordObservations += 1
625
+ completionPaths.add(completion.path)
626
+ }
627
+ for (const [path, record] of Object.entries(metadata.history ?? {})) {
628
+ historyRecordObservations += 1
629
+ let modes = historyModes.get(path)
630
+ if (modes === undefined) historyModes.set(path, modes = new Set())
631
+ modes.add(record.mode)
632
+ }
633
+ }
634
+
635
+ const observeMicrostep = (microstep: Microstep<M, any>): void => {
636
+ microsteps += 1
637
+ if (microstep.changed) changedMicrosteps += 1
638
+ raisedEvents += microstep.raisedEvents.length
639
+ emittedEvents += microstep.emittedEvents.length
640
+ hitPaths(microstep.entryPaths, entryHits)
641
+ hitPaths(microstep.exitPaths, exitHits)
642
+ observeSnapshot(microstep.next)
643
+ for (const retained of microstep.transitions) {
644
+ if (retained.target === undefined) targetlessTransitions += 1
645
+ if (retained.trigger.type === "event") eventTriggered += 1
646
+ else if (retained.trigger.type === "always") alwaysTriggered += 1
647
+ else if (retained.trigger.type === "done") doneTriggered += 1
648
+ else choiceTriggered += 1
649
+ if (retained.target !== undefined && nodeByPath.get(retained.target)?.type === "history") {
650
+ historyTargets += 1
651
+ if (retained.resolvedTarget !== undefined) resolvedHistoryTargets += 1
652
+ }
653
+ const definitionIndex = definitions.findIndex((definition) =>
654
+ definition.source === retained.source &&
655
+ definition.reenter === retained.reenter &&
656
+ sameCoverageTrigger(definition.trigger, retained.trigger) &&
657
+ targetWithinDeclaredBounds(retained.target, definition.targets)
658
+ )
659
+ if (definitionIndex !== -1) transitionHits.add(definitionIndex)
660
+ }
661
+ }
662
+
663
+ for (const trace of traces) {
664
+ scenarioEvents += trace.scenario.events.length
665
+ if (trace.scenario.events.length === 0) emptyScenarios += 1
666
+ for (const event of trace.scenario.events) {
667
+ const tag = event._tag
668
+ eventCounts.set(tag, (eventCounts.get(tag) ?? 0) + 1)
669
+ }
670
+
671
+ observeSnapshot(trace.initial.startingState)
672
+ hitPaths(trace.initial.initialEntryPaths, entryHits)
673
+ if (trace.initial.plan.microsteps.length > 0) startupWithMicrosteps += 1
674
+ for (const microstep of trace.initial.plan.microsteps) observeMicrostep(microstep)
675
+ observeSnapshot(trace.initial.plan.state)
676
+ if (trace.initial.plan.done) donePlans += 1
677
+
678
+ for (const step of trace.steps) {
679
+ observeSnapshot(step.before)
680
+ for (const microstep of step.plan.microsteps) observeMicrostep(microstep)
681
+ observeSnapshot(step.after)
682
+ if (step.plan.done) donePlans += 1
683
+ }
684
+ }
685
+
686
+ const eventItems = declaredEventTags.map((tag): EventCoverageItem<any> => ({
687
+ tag,
688
+ count: eventCounts.get(tag) ?? 0
689
+ }))
690
+ const eventHits = eventItems.filter(({ count }) => count > 0)
691
+ const eventMisses = eventItems.filter(({ count }) => count === 0)
692
+
693
+ return {
694
+ states: {
695
+ activation: coverageSummary(activeNodes, activationHits),
696
+ entry: coverageSummary(activeNodes, entryHits),
697
+ exit: coverageSummary(activeNodes, exitHits)
698
+ },
699
+ transitions: coverageSummary(definitions, transitionHits),
700
+ events: declaredEvents.diagnostics.length === 0
701
+ ? {
702
+ available: true,
703
+ total: eventItems.length,
704
+ hit: eventHits.length,
705
+ missing: eventMisses.length,
706
+ hits: eventHits,
707
+ misses: eventMisses,
708
+ observed: eventItems,
709
+ diagnostics: []
710
+ }
711
+ : {
712
+ available: false,
713
+ total: undefined,
714
+ hit: undefined,
715
+ missing: undefined,
716
+ hits: undefined,
717
+ misses: undefined,
718
+ observed: Array.from(eventCounts, ([tag, count]) => ({ tag, count })) as ReadonlyArray<EventCoverageItem<any>>,
719
+ diagnostics: declaredEvents.diagnostics
720
+ },
721
+ scenarios: {
722
+ traces: traces.length,
723
+ events: scenarioEvents,
724
+ empty: emptyScenarios
725
+ },
726
+ logicalConfigurations: {
727
+ observations: configurationObservations,
728
+ hit: logicalConfigurationIdentities.size,
729
+ identities: Array.from(logicalConfigurationIdentities).sort()
730
+ },
731
+ startup: {
732
+ traces: traces.length,
733
+ withMicrosteps: startupWithMicrosteps
734
+ },
735
+ microsteps: {
736
+ total: microsteps,
737
+ changed: changedMicrosteps,
738
+ targetless: targetlessTransitions,
739
+ raised: raisedEvents,
740
+ emitted: emittedEvents,
741
+ eventTriggered,
742
+ alwaysTriggered,
743
+ doneTriggered,
744
+ choiceTriggered
745
+ },
746
+ completion: {
747
+ donePlans,
748
+ recordObservations: completionRecordObservations,
749
+ paths: Array.from(completionPaths).sort() as unknown as ReadonlyArray<StatePath<M>>
750
+ },
751
+ history: {
752
+ recordObservations: historyRecordObservations,
753
+ recorded: Array.from(historyModes, ([path, modes]) => ({
754
+ path: path as StateNodePath<M>,
755
+ modes: Array.from(modes).sort()
756
+ })).sort((left, right) => left.path.localeCompare(right.path)),
757
+ targets: historyTargets,
758
+ resolvedTargets: resolvedHistoryTargets
759
+ }
760
+ } as Coverage<M>
761
+ }
762
+
763
+ type SnapshotObservationRole = "startup" | "event" | "microstep"
764
+
765
+ interface SnapshotOccurrence<M extends AnyMachine> {
766
+ readonly snapshot: Machine.Machine.Snapshot<Machine.Machine.States<M>>
767
+ readonly role: SnapshotObservationRole
768
+ }
769
+
770
+ interface GraphMicrostepDraft<M extends AnyMachine> {
771
+ readonly nextOccurrence: number
772
+ readonly microstep: Microstep<M, any>
773
+ }
774
+
775
+ type GraphEdgeDraftData<M extends AnyMachine> = ObservedGraphEdge<M> extends infer Edge
776
+ ? Edge extends ObservedGraphEdge<M> ? Omit<Edge, "microsteps"> & {
777
+ readonly microsteps: ReadonlyArray<GraphMicrostepDraft<M>>
778
+ }
779
+ : never
780
+ : never
781
+
782
+ interface GraphEdgeDraft<M extends AnyMachine> {
783
+ readonly sourceOccurrence: number
784
+ readonly targetOccurrence: number
785
+ readonly edge: GraphEdgeDraftData<M>
786
+ }
787
+
788
+ export const observedGraph: <M extends AnyMachine>(
789
+ machine: M,
790
+ traceOrTraces: Trace<M> | ReadonlyArray<Trace<M>>
791
+ ) => Effect.Effect<
792
+ ObservedGraph<M>,
793
+ Machine.MachineSchemaEncodeError,
794
+ Machine.Machine.SnapshotEncodingServices<Machine.Machine.States<M>>
795
+ > = Effect.fnUntraced(function*<M extends AnyMachine>(
796
+ machine: M,
797
+ traceOrTraces: Trace<M> | ReadonlyArray<Trace<M>>
798
+ ) {
799
+ const traces = normalizeTraces(traceOrTraces)
800
+ const occurrences: Array<SnapshotOccurrence<M>> = []
801
+ const edgeDrafts: Array<GraphEdgeDraft<M>> = []
802
+ const startOccurrences: Array<number> = []
803
+ const startupSourceOccurrences: Array<number> = []
804
+ const addOccurrence = (
805
+ snapshot: Machine.Machine.Snapshot<Machine.Machine.States<M>>,
806
+ role: SnapshotObservationRole
807
+ ): number => {
808
+ const index = occurrences.length
809
+ occurrences.push({ snapshot, role })
810
+ return index
811
+ }
812
+ const addMicrosteps = (microsteps: ReadonlyArray<Microstep<M, any>>): ReadonlyArray<GraphMicrostepDraft<M>> =>
813
+ microsteps.map((microstep) => ({
814
+ nextOccurrence: addOccurrence(microstep.next, "microstep"),
815
+ microstep
816
+ }))
817
+
818
+ traces.forEach((trace, traceIndex) => {
819
+ const start = addOccurrence(trace.initial.startingState, "startup")
820
+ startupSourceOccurrences.push(start)
821
+ const startupMicrosteps = addMicrosteps(trace.initial.plan.microsteps)
822
+ const initialized = addOccurrence(trace.initial.plan.state, "startup")
823
+ startOccurrences.push(initialized)
824
+ edgeDrafts.push({
825
+ sourceOccurrence: start,
826
+ targetOccurrence: initialized,
827
+ edge: {
828
+ _tag: "Startup",
829
+ traceIndex,
830
+ microsteps: startupMicrosteps,
831
+ completion: trace.initial.plan.done
832
+ ? { done: true, output: trace.initial.plan.output }
833
+ : { done: false, output: undefined }
834
+ }
835
+ })
836
+
837
+ for (const step of trace.steps) {
838
+ const before = addOccurrence(step.before, "event")
839
+ const stepMicrosteps = addMicrosteps(step.plan.microsteps)
840
+ const after = addOccurrence(step.after, "event")
841
+ edgeDrafts.push({
842
+ sourceOccurrence: before,
843
+ targetOccurrence: after,
844
+ edge: {
845
+ _tag: "Event",
846
+ traceIndex,
847
+ eventIndex: step.index,
848
+ event: step.event,
849
+ microsteps: stepMicrosteps,
850
+ completion: step.plan.done
851
+ ? { done: true, output: step.plan.output }
852
+ : { done: false, output: undefined }
853
+ }
854
+ })
855
+ }
856
+ })
857
+
858
+ const encoded = yield* Effect.forEach(
859
+ occurrences,
860
+ ({ snapshot }) =>
861
+ (Machine.encodeSnapshot as any)(machine, snapshot) as Effect.Effect<
862
+ Machine.Machine.EncodedSnapshot,
863
+ Machine.MachineSchemaEncodeError,
864
+ Machine.Machine.SnapshotEncodingServices<Machine.Machine.States<M>>
865
+ >
866
+ )
867
+ const encodedIdentity = makeStructuralIdentityIndex()
868
+ const occurrenceIds = encoded.map(encodedIdentity)
869
+ const grouped = new Map<string, {
870
+ readonly encoded: Machine.Machine.EncodedSnapshot
871
+ readonly snapshot: Machine.Machine.Snapshot<Machine.Machine.States<M>>
872
+ startup: number
873
+ event: number
874
+ microstep: number
875
+ }>()
876
+ occurrences.forEach((occurrence, index) => {
877
+ const id = occurrenceIds[index]!
878
+ let group = grouped.get(id)
879
+ if (group === undefined) {
880
+ grouped.set(
881
+ id,
882
+ group = {
883
+ encoded: encoded[index]!,
884
+ snapshot: occurrence.snapshot,
885
+ startup: 0,
886
+ event: 0,
887
+ microstep: 0
888
+ }
889
+ )
890
+ }
891
+ group[occurrence.role] += 1
892
+ })
893
+
894
+ const nodesById = new Map<string, Graph.NodeIndex>()
895
+ const graph = Graph.directed<ObservedGraphNode<M>, ObservedGraphEdge<M>>((mutable) => {
896
+ for (const [id, group] of grouped) {
897
+ const node = Graph.addNode(mutable, {
898
+ id,
899
+ snapshot: group.snapshot,
900
+ encoded: group.encoded,
901
+ configuration: group.encoded.active.map(({ path }) => path) as unknown as ReadonlyArray<StatePath<M>>,
902
+ observations: {
903
+ total: group.startup + group.event + group.microstep,
904
+ startup: group.startup,
905
+ event: group.event,
906
+ microstep: group.microstep
907
+ }
908
+ })
909
+ nodesById.set(id, node)
910
+ }
911
+ for (const draft of edgeDrafts) {
912
+ const source = nodesById.get(occurrenceIds[draft.sourceOccurrence]!)!
913
+ const target = nodesById.get(occurrenceIds[draft.targetOccurrence]!)!
914
+ Graph.addEdge(mutable, source, target, {
915
+ ...draft.edge,
916
+ microsteps: draft.edge.microsteps.map(({ microstep, nextOccurrence }) => ({
917
+ next: occurrenceIds[nextOccurrence]!,
918
+ event: microstep.event,
919
+ transitions: microstep.transitions,
920
+ raisedEvents: microstep.raisedEvents,
921
+ emittedEvents: microstep.emittedEvents,
922
+ exitPaths: microstep.exitPaths as ReadonlyArray<StatePath<M>>,
923
+ entryPaths: microstep.entryPaths as ReadonlyArray<StatePath<M>>,
924
+ changed: microstep.changed
925
+ }))
926
+ } as ObservedGraphEdge<M>)
927
+ }
928
+ })
929
+ return {
930
+ graph,
931
+ nodesById,
932
+ starts: Array.from(new Set(startOccurrences.map((index) => nodesById.get(occurrenceIds[index]!)!))),
933
+ startupSources: Array.from(
934
+ new Set(startupSourceOccurrences.map((index) => nodesById.get(occurrenceIds[index]!)!))
935
+ )
936
+ }
937
+ }) as any
938
+
939
+ export class VerificationError extends Data.TaggedError("MachineTestVerificationError")<{
940
+ readonly violations: ReadonlyArray<VerificationViolation>
941
+ }> {}
942
+
943
+ interface VerificationLocation {
944
+ readonly eventIndex: number | undefined
945
+ readonly microstepIndex?: number
946
+ }
947
+
948
+ interface SnapshotInspection {
949
+ readonly active: ReadonlySet<string>
950
+ readonly paths: ReadonlyArray<string>
951
+ readonly values: ReadonlyMap<string, unknown>
952
+ readonly root: Record<string, unknown> | undefined
953
+ }
954
+
955
+ type PublicStateNode = Machine.Machine.StateNode<string>
956
+
957
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
958
+ typeof value === "object" && value !== null && !Array.isArray(value)
959
+
960
+ const hasOwn = (value: object, key: PropertyKey): boolean => Object.prototype.hasOwnProperty.call(value, key)
961
+
962
+ const sameValue = (left: unknown, right: unknown): boolean => formatValue(left) === formatValue(right)
963
+
964
+ const samePaths = (left: ReadonlyArray<string>, right: ReadonlyArray<string>): boolean =>
965
+ left.length === right.length && left.every((path, index) => path === right[index])
966
+
967
+ const sameTrigger = (
968
+ left: Machine.Machine.TransitionTrigger,
969
+ right: Machine.Machine.TransitionTrigger
970
+ ): boolean =>
971
+ left.type === right.type && (left.type !== "event" || right.type === "event" && left.event === right.event)
972
+
973
+ const makeNodeUtilities = (nodes: ReadonlyArray<PublicStateNode>) => {
974
+ const byPath = new Map(nodes.map((node) => [node.path, node]))
975
+ const depth = (path: string): number => {
976
+ let current = byPath.get(path)
977
+ let result = 0
978
+ const seen = new Set<string>()
979
+ while (current !== undefined && !seen.has(current.path)) {
980
+ seen.add(current.path)
981
+ result += 1
982
+ current = current.parent === undefined ? undefined : byPath.get(current.parent)
983
+ }
984
+ return result
985
+ }
986
+ const isDescendantOrSelf = (path: string, ancestor: string): boolean => {
987
+ let current = byPath.get(path)
988
+ const seen = new Set<string>()
989
+ while (current !== undefined && !seen.has(current.path)) {
990
+ if (current.path === ancestor) return true
991
+ seen.add(current.path)
992
+ current = current.parent === undefined ? undefined : byPath.get(current.parent)
993
+ }
994
+ return false
995
+ }
996
+ const ancestors = (path: string): ReadonlyArray<string> => {
997
+ const result: Array<string> = []
998
+ let current = byPath.get(path)
999
+ const seen = new Set<string>()
1000
+ while (current !== undefined && !seen.has(current.path)) {
1001
+ seen.add(current.path)
1002
+ result.unshift(current.path)
1003
+ current = current.parent === undefined ? undefined : byPath.get(current.parent)
1004
+ }
1005
+ return result
1006
+ }
1007
+ return { ancestors, byPath, depth, isDescendantOrSelf }
1008
+ }
1009
+
1010
+ export const verify = <M extends AnyMachine>(
1011
+ machine: M,
1012
+ trace: Trace<M>,
1013
+ options: VerifyOptions = {}
1014
+ ): Effect.Effect<void, VerificationError> => {
1015
+ const selected = new Set<VerificationLawGroup>(
1016
+ options.laws ?? ["configuration", "microsteps", "completion", "history", "targetBounds"]
1017
+ )
1018
+ const nodes = Machine.stateNodes(machine) as ReadonlyArray<PublicStateNode>
1019
+ const definitions = Machine.transitionDefinitions(machine)
1020
+ const { ancestors, byPath, depth, isDescendantOrSelf } = makeNodeUtilities(nodes)
1021
+ const violations: Array<VerificationViolation> = []
1022
+
1023
+ const add = (
1024
+ law: VerificationLaw,
1025
+ location: VerificationLocation,
1026
+ message: string,
1027
+ path?: string
1028
+ ): void => {
1029
+ violations.push({
1030
+ law,
1031
+ eventIndex: location.eventIndex,
1032
+ ...(location.microstepIndex === undefined ? {} : { microstepIndex: location.microstepIndex }),
1033
+ ...(path === undefined ? {} : { path }),
1034
+ message
1035
+ })
1036
+ }
1037
+
1038
+ const schemaMatches = (schema: Schema.Top | undefined, value: unknown): boolean => {
1039
+ if (schema === undefined) return false
1040
+ try {
1041
+ return Schema.is(schema)(value)
1042
+ } catch {
1043
+ return false
1044
+ }
1045
+ }
1046
+
1047
+ const inspectSnapshot = (
1048
+ snapshot: unknown,
1049
+ location: VerificationLocation,
1050
+ label: string
1051
+ ): SnapshotInspection => {
1052
+ const active = new Set<string>()
1053
+ const paths: Array<string> = []
1054
+ const values = new Map<string, unknown>()
1055
+ const reportConfiguration = selected.has("configuration")
1056
+ const root = isRecord(snapshot) ? snapshot : undefined
1057
+
1058
+ const visit = (current: unknown, expectedParent: string | undefined, expectedPath?: string): void => {
1059
+ if (!isRecord(current)) {
1060
+ if (reportConfiguration) {
1061
+ add("configuration.shape", location, `${label} must contain an object snapshot`)
1062
+ }
1063
+ return
1064
+ }
1065
+ if (typeof current.path !== "string") {
1066
+ if (reportConfiguration) {
1067
+ add("configuration.path", location, `${label} contains a snapshot without a string path`)
1068
+ }
1069
+ return
1070
+ }
1071
+ const path = current.path
1072
+ if (active.has(path)) {
1073
+ if (reportConfiguration) {
1074
+ add("configuration.duplicate", location, `${label} activates state "${path}" more than once`, path)
1075
+ }
1076
+ return
1077
+ }
1078
+ active.add(path)
1079
+ paths.push(path)
1080
+ values.set(path, current.value)
1081
+
1082
+ const node = byPath.get(path)
1083
+ if (node === undefined) {
1084
+ if (reportConfiguration) {
1085
+ add("configuration.path", location, `${label} activates unknown state "${path}"`, path)
1086
+ }
1087
+ if (isRecord(current.state)) visit(current.state, path)
1088
+ if (isRecord(current.states)) {
1089
+ for (const child of Object.values(current.states)) visit(child, path)
1090
+ }
1091
+ return
1092
+ }
1093
+ if (expectedPath !== undefined && path !== expectedPath && reportConfiguration) {
1094
+ add(
1095
+ "configuration.hierarchy",
1096
+ location,
1097
+ `${label} expected region "${expectedPath}" but found "${path}"`,
1098
+ path
1099
+ )
1100
+ }
1101
+ if (node.parent !== expectedParent && reportConfiguration) {
1102
+ add(
1103
+ "configuration.hierarchy",
1104
+ location,
1105
+ expectedParent === undefined
1106
+ ? `${label} root state "${path}" is not a machine root`
1107
+ : `${label} state "${path}" is not a direct child of "${expectedParent}"`,
1108
+ path
1109
+ )
1110
+ }
1111
+ if (node.type === "history" || node.type === "choice") {
1112
+ if (reportConfiguration) {
1113
+ add("configuration.path", location, `${label} activates ${node.type} pseudo-state "${path}"`, path)
1114
+ }
1115
+ return
1116
+ }
1117
+ if (reportConfiguration && !schemaMatches(node.schema, current.value)) {
1118
+ add("configuration.schema", location, `${label} value for "${path}" does not match its schema`, path)
1119
+ }
1120
+
1121
+ if (node.type === "compound") {
1122
+ if (!isRecord(current.state)) {
1123
+ if (reportConfiguration) {
1124
+ add(
1125
+ "configuration.compound",
1126
+ location,
1127
+ `${label} compound state "${path}" must have exactly one child`,
1128
+ path
1129
+ )
1130
+ }
1131
+ } else {
1132
+ visit(current.state, path)
1133
+ }
1134
+ if (hasOwn(current, "states") && reportConfiguration) {
1135
+ add("configuration.compound", location, `${label} compound state "${path}" contains parallel regions`, path)
1136
+ }
1137
+ return
1138
+ }
1139
+
1140
+ if (node.type === "parallel") {
1141
+ if (!isRecord(current.states)) {
1142
+ if (reportConfiguration) {
1143
+ add("configuration.parallel", location, `${label} parallel state "${path}" has no region map`, path)
1144
+ }
1145
+ return
1146
+ }
1147
+ const expectedKeys = new Set<string>()
1148
+ for (const childPath of node.children) {
1149
+ const child = byPath.get(childPath)
1150
+ if (child === undefined) continue
1151
+ expectedKeys.add(child.key)
1152
+ if (!hasOwn(current.states, child.key)) {
1153
+ if (reportConfiguration) {
1154
+ add(
1155
+ "configuration.parallel",
1156
+ location,
1157
+ `${label} parallel state "${path}" omits region "${child.key}"`,
1158
+ childPath
1159
+ )
1160
+ }
1161
+ } else {
1162
+ visit(current.states[child.key], path, child.path)
1163
+ }
1164
+ }
1165
+ for (const key of Object.keys(current.states)) {
1166
+ if (!expectedKeys.has(key)) {
1167
+ if (reportConfiguration) {
1168
+ add(
1169
+ "configuration.parallel",
1170
+ location,
1171
+ `${label} parallel state "${path}" contains extra region "${key}"`,
1172
+ path
1173
+ )
1174
+ }
1175
+ visit(current.states[key], path)
1176
+ }
1177
+ }
1178
+ if (hasOwn(current, "state") && reportConfiguration) {
1179
+ add("configuration.parallel", location, `${label} parallel state "${path}" contains a compound child`, path)
1180
+ }
1181
+ return
1182
+ }
1183
+
1184
+ if ((hasOwn(current, "state") || hasOwn(current, "states")) && reportConfiguration) {
1185
+ add("configuration.shape", location, `${label} leaf state "${path}" contains active children`, path)
1186
+ }
1187
+ }
1188
+
1189
+ visit(snapshot, undefined)
1190
+ return { active, paths, values, root }
1191
+ }
1192
+
1193
+ const validateTraceConfiguration = (
1194
+ expected: SnapshotInspection,
1195
+ actual: ReadonlyArray<string>,
1196
+ location: VerificationLocation,
1197
+ label: string
1198
+ ): void => {
1199
+ if (selected.has("configuration") && !samePaths(expected.paths, actual)) {
1200
+ add(
1201
+ "configuration.trace",
1202
+ location,
1203
+ `${label} configuration [${actual.join(", ")}] does not match snapshot [${expected.paths.join(", ")}]`
1204
+ )
1205
+ }
1206
+ }
1207
+
1208
+ const validateHistory = (
1209
+ snapshot: SnapshotInspection,
1210
+ location: VerificationLocation,
1211
+ label: string
1212
+ ): void => {
1213
+ if (!selected.has("history") || snapshot.root === undefined || !hasOwn(snapshot.root, "history")) return
1214
+ const history = snapshot.root.history
1215
+ if (!isRecord(history)) {
1216
+ add("history.record", location, `${label} history metadata must be a record`)
1217
+ return
1218
+ }
1219
+ for (const [historyPath, unknownEntry] of Object.entries(history)) {
1220
+ const historyNode = byPath.get(historyPath)
1221
+ if (historyNode === undefined || historyNode.type !== "history" || historyNode.parent === undefined) {
1222
+ add("history.path", location, `${label} contains unknown history record "${historyPath}"`, historyPath)
1223
+ continue
1224
+ }
1225
+ if (!isRecord(unknownEntry)) {
1226
+ add("history.record", location, `${label} history record "${historyPath}" must be an object`, historyPath)
1227
+ continue
1228
+ }
1229
+ const entry = unknownEntry
1230
+ if (entry.mode !== historyNode.history) {
1231
+ add(
1232
+ "history.mode",
1233
+ location,
1234
+ `${label} history record "${historyPath}" has mode "${
1235
+ String(entry.mode)
1236
+ }", expected "${historyNode.history}"`,
1237
+ historyPath
1238
+ )
1239
+ }
1240
+ if (!Array.isArray(entry.active) || !entry.active.every((path) => typeof path === "string")) {
1241
+ add(
1242
+ "history.record",
1243
+ location,
1244
+ `${label} history record "${historyPath}" must contain string paths`,
1245
+ historyPath
1246
+ )
1247
+ continue
1248
+ }
1249
+ if (!isRecord(entry.values)) {
1250
+ add(
1251
+ "history.record",
1252
+ location,
1253
+ `${label} history record "${historyPath}" must contain a values record`,
1254
+ historyPath
1255
+ )
1256
+ continue
1257
+ }
1258
+
1259
+ const rememberedPaths = entry.active as ReadonlyArray<string>
1260
+ const remembered = new Set<string>()
1261
+ for (const path of rememberedPaths) {
1262
+ if (remembered.has(path)) {
1263
+ add("history.path", location, `${label} history record "${historyPath}" repeats "${path}"`, path)
1264
+ continue
1265
+ }
1266
+ remembered.add(path)
1267
+ const node = byPath.get(path)
1268
+ if (node === undefined || node.type === "history" || node.type === "choice") {
1269
+ add(
1270
+ "history.path",
1271
+ location,
1272
+ `${label} history record "${historyPath}" contains invalid state "${path}"`,
1273
+ path
1274
+ )
1275
+ continue
1276
+ }
1277
+ const inOwnerSubtree = isDescendantOrSelf(path, historyNode.parent)
1278
+ const inOwnerAncestry = ancestors(historyNode.parent).includes(path)
1279
+ if (!inOwnerSubtree && !inOwnerAncestry) {
1280
+ add(
1281
+ "history.path",
1282
+ location,
1283
+ `${label} history record "${historyPath}" contains state "${path}" outside its owner`,
1284
+ path
1285
+ )
1286
+ }
1287
+ if (!hasOwn(entry.values, path)) {
1288
+ add("history.value", location, `${label} history record "${historyPath}" omits value for "${path}"`, path)
1289
+ } else if (!schemaMatches(node.schema, entry.values[path])) {
1290
+ add(
1291
+ "history.value",
1292
+ location,
1293
+ `${label} history value for "${path}" does not match its state schema`,
1294
+ path
1295
+ )
1296
+ }
1297
+ if (
1298
+ entry.mode === "shallow" && isDescendantOrSelf(path, historyNode.parent) && path !== historyNode.parent &&
1299
+ node.parent !== historyNode.parent
1300
+ ) {
1301
+ add(
1302
+ "history.shallow",
1303
+ location,
1304
+ `${label} shallow history record "${historyPath}" contains deep descendant "${path}"`,
1305
+ path
1306
+ )
1307
+ }
1308
+ }
1309
+ for (const path of Object.keys(entry.values)) {
1310
+ if (!remembered.has(path)) {
1311
+ add("history.value", location, `${label} history record "${historyPath}" has extra value "${path}"`, path)
1312
+ }
1313
+ }
1314
+ if (entry.mode === "deep") {
1315
+ for (const path of remembered) {
1316
+ if (!isDescendantOrSelf(path, historyNode.parent) || path === historyNode.parent) continue
1317
+ let parent: string | undefined = byPath.get(path)?.parent
1318
+ while (parent !== undefined && isDescendantOrSelf(parent, historyNode.parent)) {
1319
+ if (!remembered.has(parent)) {
1320
+ add(
1321
+ "history.deep",
1322
+ location,
1323
+ `${label} deep history record "${historyPath}" remembers "${path}" without ancestor "${parent}"`,
1324
+ path
1325
+ )
1326
+ }
1327
+ if (parent === historyNode.parent) break
1328
+ parent = byPath.get(parent)?.parent
1329
+ }
1330
+ }
1331
+ }
1332
+ for (const ancestor of ancestors(historyNode.parent)) {
1333
+ if (!remembered.has(ancestor)) {
1334
+ add(
1335
+ "history.path",
1336
+ location,
1337
+ `${label} history record "${historyPath}" omits owner ancestry state "${ancestor}"`,
1338
+ ancestor
1339
+ )
1340
+ }
1341
+ }
1342
+
1343
+ const validateRememberedControl = (path: string, recurse: boolean): void => {
1344
+ const node = byPath.get(path)
1345
+ if (node === undefined) return
1346
+ const activeChildren = node.children.filter((child) => remembered.has(child))
1347
+ if (node.type === "compound") {
1348
+ if (activeChildren.length !== 1) {
1349
+ add(
1350
+ entry.mode === "deep" ? "history.deep" : "history.shallow",
1351
+ location,
1352
+ `${label} history record "${historyPath}" must remember one child of compound state "${path}"`,
1353
+ path
1354
+ )
1355
+ } else if (recurse) {
1356
+ validateRememberedControl(activeChildren[0]!, true)
1357
+ }
1358
+ } else if (node.type === "parallel") {
1359
+ for (const child of node.children) {
1360
+ if (!remembered.has(child)) {
1361
+ add(
1362
+ entry.mode === "deep" ? "history.deep" : "history.shallow",
1363
+ location,
1364
+ `${label} history record "${historyPath}" omits parallel region "${child}"`,
1365
+ child
1366
+ )
1367
+ } else if (recurse) {
1368
+ validateRememberedControl(child, true)
1369
+ }
1370
+ }
1371
+ }
1372
+ }
1373
+ validateRememberedControl(historyNode.parent, entry.mode === "deep")
1374
+ }
1375
+ }
1376
+
1377
+ const isCompletedControl = (path: string, active: ReadonlySet<string>): boolean => {
1378
+ if (!active.has(path)) return false
1379
+ const node = byPath.get(path)
1380
+ if (node === undefined) return false
1381
+ if (node.type === "final") return true
1382
+ if (node.type === "compound") {
1383
+ const child = node.children.find((candidate) => active.has(candidate))
1384
+ return child !== undefined && byPath.get(child)?.type === "final"
1385
+ }
1386
+ if (node.type === "parallel") {
1387
+ return node.children.length > 0 && node.children.every((child) => isCompletedControl(child, active))
1388
+ }
1389
+ return false
1390
+ }
1391
+
1392
+ const completionSchema = (path: string, active: ReadonlySet<string>): Schema.Top | undefined => {
1393
+ const node = byPath.get(path)
1394
+ if (node === undefined) return undefined
1395
+ if (node.type === "compound") {
1396
+ const child = node.children.find((candidate) => active.has(candidate))
1397
+ return child === undefined ? undefined : completionSchema(child, active)
1398
+ }
1399
+ return node.output ?? Schema.Void
1400
+ }
1401
+
1402
+ const validateCompletions = (
1403
+ snapshot: SnapshotInspection,
1404
+ location: VerificationLocation,
1405
+ label: string,
1406
+ settled: boolean
1407
+ ): ReadonlyMap<string, unknown> => {
1408
+ const result = new Map<string, unknown>()
1409
+ if (!selected.has("completion") || snapshot.root === undefined) {
1410
+ return result
1411
+ }
1412
+ if (hasOwn(snapshot.root, "completed")) {
1413
+ const completed = snapshot.root.completed
1414
+ if (!Array.isArray(completed)) {
1415
+ add("completion.record", location, `${label} completed metadata must be an array`)
1416
+ } else {
1417
+ for (const unknownEntry of completed) {
1418
+ if (!isRecord(unknownEntry) || typeof unknownEntry.path !== "string") {
1419
+ add("completion.record", location, `${label} contains an invalid completion record`)
1420
+ continue
1421
+ }
1422
+ const path = unknownEntry.path
1423
+ if (result.has(path)) {
1424
+ add("completion.record", location, `${label} repeats completion "${path}"`, path)
1425
+ continue
1426
+ }
1427
+ result.set(path, unknownEntry.output)
1428
+ if (!snapshot.active.has(path) || !isCompletedControl(path, snapshot.active)) {
1429
+ add("completion.record", location, `${label} completion "${path}" is not actively complete`, path)
1430
+ continue
1431
+ }
1432
+ const schema = completionSchema(path, snapshot.active)
1433
+ if (!schemaMatches(schema, unknownEntry.output)) {
1434
+ add(
1435
+ "completion.output",
1436
+ location,
1437
+ `${label} completion output for "${path}" does not match its schema`,
1438
+ path
1439
+ )
1440
+ }
1441
+ }
1442
+ }
1443
+ }
1444
+ if (settled) {
1445
+ for (const path of snapshot.paths) {
1446
+ if (isCompletedControl(path, snapshot.active) && !result.has(path)) {
1447
+ add("completion.record", location, `${label} omits settled completion "${path}"`, path)
1448
+ }
1449
+ }
1450
+ }
1451
+ return result
1452
+ }
1453
+
1454
+ const validateSnapshotMetadata = (
1455
+ snapshot: SnapshotInspection,
1456
+ location: VerificationLocation,
1457
+ label: string,
1458
+ settled = false
1459
+ ): ReadonlyMap<string, unknown> => {
1460
+ validateHistory(snapshot, location, label)
1461
+ return validateCompletions(snapshot, location, label, settled)
1462
+ }
1463
+
1464
+ const sameControl = (left: SnapshotInspection, right: SnapshotInspection): boolean => {
1465
+ if (!samePaths(left.paths, right.paths)) return false
1466
+ for (const path of left.paths) {
1467
+ if (!sameValue(left.values.get(path), right.values.get(path))) return false
1468
+ }
1469
+ return sameValue(left.root?.history, right.root?.history)
1470
+ }
1471
+
1472
+ const sameActivePaths = (left: SnapshotInspection, right: SnapshotInspection): boolean =>
1473
+ left.active.size === right.active.size && Array.from(left.active).every((path) => right.active.has(path))
1474
+
1475
+ const sortedPaths = (paths: ReadonlyArray<string>, direction: "entry" | "exit"): ReadonlyArray<string> =>
1476
+ [...new Set(paths)].sort((left, right) => {
1477
+ const depthDifference = direction === "entry" ? depth(left) - depth(right) : depth(right) - depth(left)
1478
+ if (depthDifference !== 0) return depthDifference
1479
+ const leftOrder = byPath.get(left)?.order ?? Number.MAX_SAFE_INTEGER
1480
+ const rightOrder = byPath.get(right)?.order ?? Number.MAX_SAFE_INTEGER
1481
+ return direction === "entry" ? leftOrder - rightOrder : rightOrder - leftOrder
1482
+ })
1483
+
1484
+ const validateTransitionBounds = (
1485
+ transition: Microstep<M>["transitions"][number],
1486
+ location: VerificationLocation
1487
+ ): void => {
1488
+ if (!selected.has("targetBounds")) return
1489
+ const definition = definitions.find((candidate) =>
1490
+ candidate.source === transition.source && candidate.reenter === transition.reenter &&
1491
+ sameTrigger(candidate.trigger, transition.trigger)
1492
+ )
1493
+ if (definition === undefined) {
1494
+ add(
1495
+ "targetBounds.definition",
1496
+ location,
1497
+ `retained transition from "${transition.source}" has no public definition`,
1498
+ transition.source
1499
+ )
1500
+ return
1501
+ }
1502
+ if (transition.target === undefined || definition.targets.type === "dynamic") return
1503
+ if (!definition.targets.paths.some((bound) => isDescendantOrSelf(String(transition.target), String(bound)))) {
1504
+ add(
1505
+ "targetBounds.target",
1506
+ location,
1507
+ `transition target "${String(transition.target)}" is outside declared bounds ` +
1508
+ `[${definition.targets.paths.join(", ")}]`,
1509
+ String(transition.target)
1510
+ )
1511
+ }
1512
+ }
1513
+
1514
+ const validateMicrostep = (
1515
+ microstep: Microstep<M, any>,
1516
+ before: SnapshotInspection,
1517
+ after: SnapshotInspection,
1518
+ location: VerificationLocation
1519
+ ): void => {
1520
+ if (selected.has("microsteps")) {
1521
+ const uniqueExit = new Set(microstep.exitPaths)
1522
+ const uniqueEntry = new Set(microstep.entryPaths)
1523
+ const reentering = microstep.transitions.filter((transition) => transition.reenter)
1524
+ const inReentryScope = (
1525
+ path: string,
1526
+ transition: Microstep<M>["transitions"][number]
1527
+ ): boolean => {
1528
+ const target = transition.target === undefined ? undefined : byPath.get(String(transition.target))
1529
+ if (target?.type === "history" && target.parent !== undefined && before.active.has(target.parent)) {
1530
+ return isDescendantOrSelf(path, target.parent)
1531
+ }
1532
+ const parent = byPath.get(String(transition.source))?.parent
1533
+ return parent === undefined || path !== parent && isDescendantOrSelf(path, parent)
1534
+ }
1535
+ const commonLifecycleExplained = (path: string): boolean =>
1536
+ microstep.transitions.some((transition) => {
1537
+ if (transition.reenter) {
1538
+ const target = transition.target === undefined ? undefined : byPath.get(String(transition.target))
1539
+ if (target?.type === "history" && target.parent !== undefined && before.active.has(target.parent)) {
1540
+ // Reentry into an active history owner has a dedicated boundary:
1541
+ // only the owner subtree is exited and entered, regardless of
1542
+ // the ordinary source/resolved-target LCA.
1543
+ return isDescendantOrSelf(path, target.parent)
1544
+ }
1545
+ if (inReentryScope(path, transition)) return true
1546
+ }
1547
+ if (transition.resolvedTarget === undefined) return false
1548
+ const sourceAncestors = ancestors(String(transition.source))
1549
+ const targetAncestors = ancestors(String(transition.resolvedTarget))
1550
+ let boundary: string | undefined
1551
+ for (let index = 0; index < Math.min(sourceAncestors.length, targetAncestors.length); index++) {
1552
+ if (sourceAncestors[index] !== targetAncestors[index]) break
1553
+ boundary = sourceAncestors[index]
1554
+ }
1555
+ return boundary === undefined || path !== boundary && isDescendantOrSelf(path, boundary)
1556
+ })
1557
+ if (uniqueExit.size !== microstep.exitPaths.length) {
1558
+ add("microsteps.unique", location, "microstep exit paths contain duplicates")
1559
+ }
1560
+ if (uniqueEntry.size !== microstep.entryPaths.length) {
1561
+ add("microsteps.unique", location, "microstep entry paths contain duplicates")
1562
+ }
1563
+ if (!samePaths(microstep.exitPaths, sortedPaths(microstep.exitPaths, "exit"))) {
1564
+ add("microsteps.order", location, "microstep exit paths are not deepest-first in reverse document order")
1565
+ }
1566
+ if (!samePaths(microstep.entryPaths, sortedPaths(microstep.entryPaths, "entry"))) {
1567
+ add("microsteps.order", location, "microstep entry paths are not parent-first in document order")
1568
+ }
1569
+ for (const path of microstep.exitPaths) {
1570
+ if (!before.active.has(path)) {
1571
+ add("microsteps.activeBefore", location, `microstep exits inactive state "${path}"`, path)
1572
+ }
1573
+ }
1574
+ for (const path of microstep.entryPaths) {
1575
+ if (!after.active.has(path)) {
1576
+ add("microsteps.activeAfter", location, `microstep enters state "${path}" absent from its next state`, path)
1577
+ }
1578
+ }
1579
+ for (const path of before.paths) {
1580
+ if (!after.active.has(path) && !uniqueExit.has(path)) {
1581
+ add("microsteps.activeBefore", location, `removed state "${path}" is missing from exit paths`, path)
1582
+ }
1583
+ }
1584
+ for (const path of after.paths) {
1585
+ if (!before.active.has(path) && !uniqueEntry.has(path)) {
1586
+ add("microsteps.activeAfter", location, `added state "${path}" is missing from entry paths`, path)
1587
+ }
1588
+ }
1589
+ for (const transition of reentering) {
1590
+ for (const path of before.paths) {
1591
+ if (inReentryScope(path, transition) && !uniqueExit.has(path)) {
1592
+ add(
1593
+ "microsteps.reentry",
1594
+ location,
1595
+ `reentering transition from "${String(transition.source)}" omits exit lifecycle for "${path}"`,
1596
+ path
1597
+ )
1598
+ }
1599
+ }
1600
+ for (const path of after.paths) {
1601
+ if (inReentryScope(path, transition) && !uniqueEntry.has(path)) {
1602
+ add(
1603
+ "microsteps.reentry",
1604
+ location,
1605
+ `reentering transition from "${String(transition.source)}" omits entry lifecycle for "${path}"`,
1606
+ path
1607
+ )
1608
+ }
1609
+ }
1610
+ }
1611
+ for (const path of before.paths) {
1612
+ if (!after.active.has(path) || !uniqueExit.has(path) && !uniqueEntry.has(path)) continue
1613
+ if (!commonLifecycleExplained(path)) {
1614
+ add(
1615
+ "microsteps.reentry",
1616
+ location,
1617
+ `common state "${path}" has lifecycle without a reentering transition`,
1618
+ path
1619
+ )
1620
+ } else if (!uniqueExit.has(path) || !uniqueEntry.has(path)) {
1621
+ add(
1622
+ "microsteps.reentry",
1623
+ location,
1624
+ `reentered common state "${path}" must have both exit and entry lifecycle`,
1625
+ path
1626
+ )
1627
+ }
1628
+ }
1629
+ if (!microstep.changed) {
1630
+ if (microstep.exitPaths.length > 0 || microstep.entryPaths.length > 0) {
1631
+ add("microsteps.changed", location, "unchanged microstep contains entry or exit paths")
1632
+ }
1633
+ if (!sameActivePaths(before, after)) {
1634
+ add("microsteps.changed", location, "unchanged microstep changes its active state paths")
1635
+ }
1636
+ } else if (
1637
+ microstep.exitPaths.length === 0 && microstep.entryPaths.length === 0 && sameActivePaths(before, after)
1638
+ ) {
1639
+ add("microsteps.changed", location, "changed microstep has no control-state change or reentry evidence")
1640
+ }
1641
+ for (const transition of microstep.transitions) {
1642
+ if (
1643
+ !before.active.has(String(transition.source)) &&
1644
+ byPath.get(String(transition.source))?.type !== "choice"
1645
+ ) {
1646
+ add(
1647
+ "microsteps.activeBefore",
1648
+ location,
1649
+ `transition source "${String(transition.source)}" is inactive before the microstep`,
1650
+ String(transition.source)
1651
+ )
1652
+ }
1653
+ if (
1654
+ transition.resolvedTarget !== undefined && !after.active.has(String(transition.resolvedTarget)) &&
1655
+ microstep.transitions.length === 1
1656
+ ) {
1657
+ add(
1658
+ "microsteps.activeAfter",
1659
+ location,
1660
+ `resolved target "${String(transition.resolvedTarget)}" is inactive after the microstep`,
1661
+ String(transition.resolvedTarget)
1662
+ )
1663
+ }
1664
+ }
1665
+ }
1666
+ for (const transition of microstep.transitions) validateTransitionBounds(transition, location)
1667
+ }
1668
+
1669
+ const validatePlanCompletion = (
1670
+ plan: PlanCompletion<M>,
1671
+ snapshot: SnapshotInspection,
1672
+ completions: ReadonlyMap<string, unknown>,
1673
+ location: VerificationLocation,
1674
+ label: string
1675
+ ): void => {
1676
+ if (!selected.has("completion")) return
1677
+ const rootPath = snapshot.paths.find((path) => byPath.get(path)?.parent === undefined)
1678
+ const hasRootDoneTransition = rootPath !== undefined &&
1679
+ definitions.some((definition) => definition.source === rootPath && definition.trigger.type === "done")
1680
+ const terminal = rootPath !== undefined && isCompletedControl(rootPath, snapshot.active) && !hasRootDoneTransition
1681
+ if (plan.done !== terminal) {
1682
+ add(
1683
+ "completion.done",
1684
+ location,
1685
+ `${label} reports done=${String(plan.done)} for terminal=${String(terminal)}`,
1686
+ rootPath
1687
+ )
1688
+ }
1689
+ if (plan.done) {
1690
+ if (rootPath === undefined || !completions.has(rootPath)) {
1691
+ add("completion.output", location, `${label} done plan has no root completion output`, rootPath)
1692
+ } else if (!sameValue(plan.output, completions.get(rootPath))) {
1693
+ add("completion.output", location, `${label} output differs from its root completion`, rootPath)
1694
+ }
1695
+ } else if (plan.output !== undefined) {
1696
+ add("completion.output", location, `${label} non-done plan exposes an output`, rootPath)
1697
+ }
1698
+ }
1699
+
1700
+ const initialLocation: VerificationLocation = { eventIndex: undefined }
1701
+ const starting = inspectSnapshot(trace.initial.startingState, initialLocation, "initial starting state")
1702
+ validateSnapshotMetadata(starting, initialLocation, "initial starting state")
1703
+ validateTraceConfiguration(
1704
+ starting,
1705
+ trace.initial.startingConfiguration as ReadonlyArray<string>,
1706
+ initialLocation,
1707
+ "initial starting"
1708
+ )
1709
+ if (selected.has("microsteps")) {
1710
+ if (new Set(trace.initial.initialEntryPaths).size !== trace.initial.initialEntryPaths.length) {
1711
+ add("microsteps.unique", initialLocation, "initial entry paths contain duplicates")
1712
+ }
1713
+ if (!samePaths(trace.initial.initialEntryPaths as ReadonlyArray<string>, starting.paths)) {
1714
+ add(
1715
+ "microsteps.order",
1716
+ initialLocation,
1717
+ "initial entry paths do not cover the starting configuration in definition order"
1718
+ )
1719
+ }
1720
+ }
1721
+
1722
+ let current = starting
1723
+ for (let index = 0; index < trace.initial.plan.microsteps.length; index++) {
1724
+ const location: VerificationLocation = { eventIndex: undefined, microstepIndex: index }
1725
+ const microstep = trace.initial.plan.microsteps[index]!
1726
+ const next = inspectSnapshot(microstep.next, location, `initial microstep ${index} next state`)
1727
+ validateSnapshotMetadata(next, location, `initial microstep ${index} next state`)
1728
+ validateMicrostep(microstep, current, next, location)
1729
+ current = next
1730
+ }
1731
+ const initialState = inspectSnapshot(trace.initial.plan.state, initialLocation, "initial plan state")
1732
+ const initialCompletions = validateSnapshotMetadata(initialState, initialLocation, "initial plan state", true)
1733
+ if (selected.has("microsteps") && !sameControl(current, initialState)) {
1734
+ add("microsteps.continuity", initialLocation, "initial plan state does not continue from its final microstep")
1735
+ }
1736
+ validatePlanCompletion(trace.initial.plan, initialState, initialCompletions, initialLocation, "initial plan")
1737
+ validateTraceConfiguration(
1738
+ initialState,
1739
+ trace.initial.configuration as ReadonlyArray<string>,
1740
+ initialLocation,
1741
+ "initial"
1742
+ )
1743
+ if (selected.has("microsteps") && !sameValue(trace.initial.startingState, trace.initial.plan.startingState)) {
1744
+ add("microsteps.continuity", initialLocation, "initial trace starting state differs from its plan")
1745
+ }
1746
+ if (
1747
+ selected.has("microsteps") &&
1748
+ !samePaths(trace.initial.initialEntryPaths as ReadonlyArray<string>, trace.initial.plan.initialEntryPaths)
1749
+ ) {
1750
+ add("microsteps.continuity", initialLocation, "initial trace entry paths differ from its plan")
1751
+ }
1752
+
1753
+ let previous = initialState
1754
+ for (let eventIndex = 0; eventIndex < trace.steps.length; eventIndex++) {
1755
+ const step = trace.steps[eventIndex]!
1756
+ const location: VerificationLocation = { eventIndex }
1757
+ const before = inspectSnapshot(step.before, location, `event ${eventIndex} before state`)
1758
+ validateSnapshotMetadata(before, location, `event ${eventIndex} before state`, true)
1759
+ validateTraceConfiguration(
1760
+ before,
1761
+ step.beforeConfiguration as ReadonlyArray<string>,
1762
+ location,
1763
+ `event ${eventIndex} before`
1764
+ )
1765
+ if (selected.has("microsteps")) {
1766
+ if (step.index !== eventIndex) {
1767
+ add("microsteps.continuity", location, `trace step index ${step.index} does not equal ${eventIndex}`)
1768
+ }
1769
+ if (!sameValue(previous.root, before.root)) {
1770
+ add("microsteps.continuity", location, `event ${eventIndex} before state does not equal the previous state`)
1771
+ }
1772
+ if (!sameValue(step.event, trace.scenario.events[eventIndex])) {
1773
+ add("microsteps.continuity", location, `event ${eventIndex} differs from its scenario event`)
1774
+ }
1775
+ }
1776
+
1777
+ current = before
1778
+ for (let microstepIndex = 0; microstepIndex < step.plan.microsteps.length; microstepIndex++) {
1779
+ const microstepLocation: VerificationLocation = { eventIndex, microstepIndex }
1780
+ const microstep = step.plan.microsteps[microstepIndex]!
1781
+ const next = inspectSnapshot(
1782
+ microstep.next,
1783
+ microstepLocation,
1784
+ `event ${eventIndex} microstep ${microstepIndex} next state`
1785
+ )
1786
+ validateSnapshotMetadata(next, microstepLocation, `event ${eventIndex} microstep ${microstepIndex} next state`)
1787
+ validateMicrostep(microstep, current, next, microstepLocation)
1788
+ current = next
1789
+ }
1790
+
1791
+ const plannedNext = inspectSnapshot(step.plan.next, location, `event ${eventIndex} plan next state`)
1792
+ const completions = validateSnapshotMetadata(plannedNext, location, `event ${eventIndex} plan next state`, true)
1793
+ if (selected.has("microsteps") && !sameControl(current, plannedNext)) {
1794
+ add(
1795
+ "microsteps.continuity",
1796
+ location,
1797
+ `event ${eventIndex} plan next state does not continue its final microstep`
1798
+ )
1799
+ }
1800
+ validatePlanCompletion(step.plan, plannedNext, completions, location, `event ${eventIndex} plan`)
1801
+
1802
+ const after = inspectSnapshot(step.after, location, `event ${eventIndex} after state`)
1803
+ validateSnapshotMetadata(after, location, `event ${eventIndex} after state`, true)
1804
+ validateTraceConfiguration(
1805
+ after,
1806
+ step.afterConfiguration as ReadonlyArray<string>,
1807
+ location,
1808
+ `event ${eventIndex} after`
1809
+ )
1810
+ if (selected.has("microsteps") && !sameValue(step.plan.next, step.after)) {
1811
+ add("microsteps.continuity", location, `event ${eventIndex} after state differs from its plan next state`)
1812
+ }
1813
+ previous = after
1814
+ }
1815
+
1816
+ const finalEventIndex = trace.steps.length === 0 ? undefined : trace.steps.length - 1
1817
+ const finalLocation: VerificationLocation = { eventIndex: finalEventIndex }
1818
+ const final = inspectSnapshot(trace.final, finalLocation, "trace final state")
1819
+ validateSnapshotMetadata(final, finalLocation, "trace final state", true)
1820
+ validateTraceConfiguration(final, trace.finalConfiguration as ReadonlyArray<string>, finalLocation, "final")
1821
+ if (selected.has("microsteps") && !sameValue(previous.root, final.root)) {
1822
+ add("microsteps.continuity", finalLocation, "trace final state differs from its final planned state")
1823
+ }
1824
+
1825
+ return violations.length === 0 ? Effect.void : Effect.fail(new VerificationError({ violations }))
1826
+ }
1827
+
1828
+ const formatConfiguration = (paths: ReadonlyArray<string>): string => `[${paths.join(", ")}]`
1829
+
1830
+ const formatMicrosteps = <M extends AnyMachine>(microsteps: ReadonlyArray<Microstep<M, any>>): Array<string> =>
1831
+ microsteps.map((microstep, index) => {
1832
+ const transitions = microstep.transitions.map((transition) => ({
1833
+ source: transition.source,
1834
+ trigger: transition.trigger,
1835
+ reenter: transition.reenter,
1836
+ target: transition.target,
1837
+ resolvedTarget: transition.resolvedTarget
1838
+ }))
1839
+ return ` microstep ${index}: event=${formatValue(microstep.event)} changed=${String(microstep.changed)} ` +
1840
+ `transitions=${formatValue(transitions)} exit=${formatConfiguration(microstep.exitPaths)} ` +
1841
+ `entry=${formatConfiguration(microstep.entryPaths)} commands=${microstep.commands.length} ` +
1842
+ `raised=${formatValue(microstep.raisedEvents)} emitted=${formatValue(microstep.emittedEvents)} ` +
1843
+ `next=${formatValue(microstep.next)}`
1844
+ })
1845
+
1846
+ const formatInitial = <M extends AnyMachine>(initial: InitialTrace<M>): Array<string> => [
1847
+ `initial: startingConfiguration=${formatConfiguration(initial.startingConfiguration)} ` +
1848
+ `startingState=${formatValue(initial.startingState)} initialEntry=${
1849
+ formatConfiguration(initial.initialEntryPaths)
1850
+ } ` +
1851
+ `configuration=${formatConfiguration(initial.configuration)} state=${formatValue(initial.plan.state)} ` +
1852
+ `done=${String(initial.plan.done)} output=${formatValue(initial.plan.output)} ` +
1853
+ `commands=${initial.plan.commands.length} emitted=${formatValue(initial.plan.emittedEvents)}`,
1854
+ ...formatMicrosteps(initial.plan.microsteps)
1855
+ ]
1856
+
1857
+ const formatStep = <M extends AnyMachine>(step: TraceStep<M>): Array<string> => [
1858
+ `step ${step.index}: event=${formatValue(step.event)} before=${formatConfiguration(step.beforeConfiguration)} ` +
1859
+ `after=${formatConfiguration(step.afterConfiguration)} state=${formatValue(step.after)} ` +
1860
+ `done=${String(step.plan.done)} output=${formatValue(step.plan.output)} ` +
1861
+ `commands=${step.plan.commands.length} emitted=${formatValue(step.plan.emittedEvents)}`,
1862
+ ...formatMicrosteps(step.plan.microsteps)
1863
+ ]
1864
+
1865
+ const isRunFailure = <M extends AnyMachine, Cause>(
1866
+ trace: Trace<M> | RunFailure<Cause, M>
1867
+ ): trace is RunFailure<Cause, M> => "_tag" in trace && trace._tag === "MachineTestRunFailure"
1868
+
1869
+ export const formatTrace = <M extends AnyMachine, Cause>(trace: Trace<M> | RunFailure<Cause, M>): string => {
1870
+ const lines = [`scenario: ${formatValue(trace.scenario)}`]
1871
+ if (isRunFailure(trace)) {
1872
+ if (trace.initial !== undefined) {
1873
+ lines.push(...formatInitial(trace.initial))
1874
+ for (const step of trace.steps) {
1875
+ lines.push(...formatStep(step))
1876
+ }
1877
+ }
1878
+ lines.push(
1879
+ `failure: phase=${trace.phase} eventIndex=${formatValue(trace.eventIndex)} ` +
1880
+ `event=${formatValue(trace.event)} cause=${formatValue(trace.cause)}`
1881
+ )
1882
+ return lines.join("\n")
1883
+ }
1884
+ lines.push(...formatInitial(trace.initial))
1885
+ for (const step of trace.steps) {
1886
+ lines.push(...formatStep(step))
1887
+ }
1888
+ lines.push(`final: configuration=${formatConfiguration(trace.finalConfiguration)} state=${formatValue(trace.final)}`)
1889
+ return lines.join("\n")
1890
+ }