@sublang/slc 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +192 -0
  3. package/dist/app.d.ts +85 -0
  4. package/dist/app.d.ts.map +1 -0
  5. package/dist/app.js +233 -0
  6. package/dist/app.js.map +1 -0
  7. package/dist/artifacts.d.ts +61 -0
  8. package/dist/artifacts.d.ts.map +1 -0
  9. package/dist/artifacts.js +97 -0
  10. package/dist/artifacts.js.map +1 -0
  11. package/dist/cli.d.ts +3 -0
  12. package/dist/cli.d.ts.map +1 -0
  13. package/dist/cli.js +26 -0
  14. package/dist/cli.js.map +1 -0
  15. package/dist/cligent-agent.d.ts +11 -0
  16. package/dist/cligent-agent.d.ts.map +1 -0
  17. package/dist/cligent-agent.js +66 -0
  18. package/dist/cligent-agent.js.map +1 -0
  19. package/dist/compiled-executor.d.ts +42 -0
  20. package/dist/compiled-executor.d.ts.map +1 -0
  21. package/dist/compiled-executor.js +433 -0
  22. package/dist/compiled-executor.js.map +1 -0
  23. package/dist/config-file.d.ts +52 -0
  24. package/dist/config-file.d.ts.map +1 -0
  25. package/dist/config-file.js +159 -0
  26. package/dist/config-file.js.map +1 -0
  27. package/dist/config.d.ts +82 -0
  28. package/dist/config.d.ts.map +1 -0
  29. package/dist/config.js +161 -0
  30. package/dist/config.js.map +1 -0
  31. package/dist/entry-module.d.ts +22 -0
  32. package/dist/entry-module.d.ts.map +1 -0
  33. package/dist/entry-module.js +216 -0
  34. package/dist/entry-module.js.map +1 -0
  35. package/dist/execution.d.ts +71 -0
  36. package/dist/execution.d.ts.map +1 -0
  37. package/dist/execution.js +207 -0
  38. package/dist/execution.js.map +1 -0
  39. package/dist/file-capability.d.ts +46 -0
  40. package/dist/file-capability.d.ts.map +1 -0
  41. package/dist/file-capability.js +234 -0
  42. package/dist/file-capability.js.map +1 -0
  43. package/dist/file-grants.d.ts +79 -0
  44. package/dist/file-grants.d.ts.map +1 -0
  45. package/dist/file-grants.js +133 -0
  46. package/dist/file-grants.js.map +1 -0
  47. package/dist/hash.d.ts +14 -0
  48. package/dist/hash.d.ts.map +1 -0
  49. package/dist/hash.js +34 -0
  50. package/dist/hash.js.map +1 -0
  51. package/dist/index.d.ts +20 -0
  52. package/dist/index.d.ts.map +1 -0
  53. package/dist/index.js +22 -0
  54. package/dist/index.js.map +1 -0
  55. package/dist/interpreter.d.ts +48 -0
  56. package/dist/interpreter.d.ts.map +1 -0
  57. package/dist/interpreter.js +118 -0
  58. package/dist/interpreter.js.map +1 -0
  59. package/dist/invocation.d.ts +64 -0
  60. package/dist/invocation.d.ts.map +1 -0
  61. package/dist/invocation.js +148 -0
  62. package/dist/invocation.js.map +1 -0
  63. package/dist/link.d.ts +63 -0
  64. package/dist/link.d.ts.map +1 -0
  65. package/dist/link.js +172 -0
  66. package/dist/link.js.map +1 -0
  67. package/dist/markdown.d.ts +12 -0
  68. package/dist/markdown.d.ts.map +1 -0
  69. package/dist/markdown.js +63 -0
  70. package/dist/markdown.js.map +1 -0
  71. package/dist/normalize.md +68 -0
  72. package/dist/phase-runner.d.ts +64 -0
  73. package/dist/phase-runner.d.ts.map +1 -0
  74. package/dist/phase-runner.js +322 -0
  75. package/dist/phase-runner.js.map +1 -0
  76. package/dist/phase.d.ts +62 -0
  77. package/dist/phase.d.ts.map +1 -0
  78. package/dist/phase.js +111 -0
  79. package/dist/phase.js.map +1 -0
  80. package/dist/pin-closure.d.ts +19 -0
  81. package/dist/pin-closure.d.ts.map +1 -0
  82. package/dist/pin-closure.js +110 -0
  83. package/dist/pin-closure.js.map +1 -0
  84. package/dist/pin-currency.d.ts +55 -0
  85. package/dist/pin-currency.d.ts.map +1 -0
  86. package/dist/pin-currency.js +435 -0
  87. package/dist/pin-currency.js.map +1 -0
  88. package/dist/pin-generate.d.ts +46 -0
  89. package/dist/pin-generate.d.ts.map +1 -0
  90. package/dist/pin-generate.js +131 -0
  91. package/dist/pin-generate.js.map +1 -0
  92. package/dist/pin-paths.d.ts +11 -0
  93. package/dist/pin-paths.d.ts.map +1 -0
  94. package/dist/pin-paths.js +108 -0
  95. package/dist/pin-paths.js.map +1 -0
  96. package/dist/pins.d.ts +84 -0
  97. package/dist/pins.d.ts.map +1 -0
  98. package/dist/pins.js +265 -0
  99. package/dist/pins.js.map +1 -0
  100. package/dist/pipeline.d.ts +61 -0
  101. package/dist/pipeline.d.ts.map +1 -0
  102. package/dist/pipeline.js +147 -0
  103. package/dist/pipeline.js.map +1 -0
  104. package/dist/playbook-contract.d.ts +194 -0
  105. package/dist/playbook-contract.d.ts.map +1 -0
  106. package/dist/playbook-contract.js +180 -0
  107. package/dist/playbook-contract.js.map +1 -0
  108. package/dist/playbook-ports.d.ts +53 -0
  109. package/dist/playbook-ports.d.ts.map +1 -0
  110. package/dist/playbook-ports.js +217 -0
  111. package/dist/playbook-ports.js.map +1 -0
  112. package/dist/resolver.d.ts +68 -0
  113. package/dist/resolver.d.ts.map +1 -0
  114. package/dist/resolver.js +133 -0
  115. package/dist/resolver.js.map +1 -0
  116. package/dist/runner.d.ts +45 -0
  117. package/dist/runner.d.ts.map +1 -0
  118. package/dist/runner.js +506 -0
  119. package/dist/runner.js.map +1 -0
  120. package/dist/runtime-package.d.ts +7 -0
  121. package/dist/runtime-package.d.ts.map +1 -0
  122. package/dist/runtime-package.js +132 -0
  123. package/dist/runtime-package.js.map +1 -0
  124. package/dist/slc.config.template.yaml +5 -0
  125. package/dist/verify-coverage.d.ts +99 -0
  126. package/dist/verify-coverage.d.ts.map +1 -0
  127. package/dist/verify-coverage.js +1949 -0
  128. package/dist/verify-coverage.js.map +1 -0
  129. package/dist/verify-support.d.ts +10 -0
  130. package/dist/verify-support.d.ts.map +1 -0
  131. package/dist/verify-support.js +52 -0
  132. package/dist/verify-support.js.map +1 -0
  133. package/dist/verify.d.ts +393 -0
  134. package/dist/verify.d.ts.map +1 -0
  135. package/dist/verify.js +1790 -0
  136. package/dist/verify.js.map +1 -0
  137. package/package.json +76 -0
@@ -0,0 +1,1949 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
3
+ /**
4
+ * FSM transition-coverage verification for a compiled `playbook` artifact
5
+ * (VERIFY-6; DR-009).
6
+ *
7
+ * {@link checkFsmCoverage} drives the artifact's machine with scripted Captain
8
+ * and nested-playbook actors and returns findings when a declared transition
9
+ * is not reachable: every Captain result key must fire a transition out (with
10
+ * `needsBossReply` suspending in the Boss-reply wait state and resuming on
11
+ * `BOSS_REPLY`), nested calls must transition on success and failure, every
12
+ * `onError` arm must land on its target, every `BOSS_INTERRUPT` target must be
13
+ * enterable, and guard-free root entry events must transition.
14
+ * Context-dependent `onDone` arms that a jumped-in actor cannot satisfy are
15
+ * covered by deterministic guard-satisfiability probing — candidate values
16
+ * mined from the guard's own source — so an unsatisfiable arm is still flagged.
17
+ * The checker needs only the artifact and `xstate`; the emitted per-artifact
18
+ * test runs it beside the artifacts. See specs/dev/verification.md.
19
+ */
20
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
21
+ import { join } from 'node:path';
22
+ import { createActor, fromPromise } from 'xstate';
23
+ import { AWAIT_BOSS_REPLY_STATE, BOSS_REPLY_EVENT, INTERRUPT_EVENT, NEEDS_BOSS_REPLY, VERIFY_MODULE, enumerateCaptainStates, enumerateScriptStates, loadFsmModule, normalizeArms, } from './verify.js';
24
+ /** The `gears2fsm`-mandated captain actor name a machine declares. */
25
+ export const CAPTAIN_ACTOR = 'captain';
26
+ function stateRefKey(ref) {
27
+ return ref.path.join('\u0000');
28
+ }
29
+ function sameStateRef(left, right) {
30
+ return (left !== undefined &&
31
+ right !== undefined &&
32
+ stateRefKey(left) === stateRefKey(right));
33
+ }
34
+ function captainPublicStateId(captain) {
35
+ return captain.binding.stateId || captain.ref.stableId;
36
+ }
37
+ /** The public interrupt id that enters a leaf's complete structured region. */
38
+ function interruptTargetForRef(ref, fallback) {
39
+ let ancestor = ref.parent;
40
+ while (ancestor !== undefined) {
41
+ if (ancestor.state.type === 'parallel')
42
+ return ancestor.stableId;
43
+ ancestor = ancestor.parent;
44
+ }
45
+ return fallback;
46
+ }
47
+ function captainInterruptTarget(captain) {
48
+ return interruptTargetForRef(captain.ref, captainPublicStateId(captain));
49
+ }
50
+ /** Scalar values carried by a parameterized XState guard descriptor. */
51
+ function descriptorValues(value, seen = new Set()) {
52
+ if (typeof value === 'string' ||
53
+ typeof value === 'number' ||
54
+ typeof value === 'boolean') {
55
+ return [value];
56
+ }
57
+ if (typeof value !== 'object' || value === null || seen.has(value))
58
+ return [];
59
+ seen.add(value);
60
+ return Object.values(value).flatMap((item) => descriptorValues(item, seen));
61
+ }
62
+ /** Resolves inline, named, and parameterized named XState guards. */
63
+ function resolveGuard(machine, guard) {
64
+ if (typeof guard === 'function') {
65
+ const implementation = guard;
66
+ return {
67
+ run: (args) => implementation(args),
68
+ probeValues: minedLiterals(implementation),
69
+ };
70
+ }
71
+ const descriptor = typeof guard === 'string'
72
+ ? { type: guard, params: undefined }
73
+ : typeof guard === 'object' && guard !== null && 'type' in guard
74
+ ? guard
75
+ : undefined;
76
+ if (typeof descriptor?.type !== 'string')
77
+ return undefined;
78
+ const candidate = machine.implementations?.guards?.[descriptor.type];
79
+ if (typeof candidate !== 'function')
80
+ return undefined;
81
+ const implementation = candidate;
82
+ const params = descriptor.params;
83
+ return {
84
+ run: (args) => implementation(args, typeof params === 'function'
85
+ ? params(args)
86
+ : params),
87
+ probeValues: [
88
+ ...minedLiterals(implementation),
89
+ ...(typeof params === 'function' ? minedLiterals(params) : []),
90
+ ...descriptorValues(params),
91
+ ],
92
+ };
93
+ }
94
+ function guardLabel(guard) {
95
+ if (typeof guard === 'string')
96
+ return guard;
97
+ try {
98
+ return JSON.stringify(guard) ?? String(guard);
99
+ }
100
+ catch {
101
+ return String(guard);
102
+ }
103
+ }
104
+ /**
105
+ * Finds the XState machine an `fsm` module exports — the export carrying a
106
+ * `.config.states` and a `.provide` — so the coverage driver can supply the
107
+ * scripted captain.
108
+ *
109
+ * @throws when the module exports no such machine.
110
+ */
111
+ export function findMachine(fsmModule) {
112
+ if (typeof fsmModule === 'object' && fsmModule !== null) {
113
+ for (const value of Object.values(fsmModule)) {
114
+ if (typeof value === 'object' &&
115
+ value !== null &&
116
+ 'config' in value &&
117
+ 'provide' in value &&
118
+ typeof value.provide === 'function') {
119
+ const config = value.config;
120
+ if (typeof config === 'object' &&
121
+ config !== null &&
122
+ 'states' in config) {
123
+ return value;
124
+ }
125
+ }
126
+ }
127
+ }
128
+ throw new Error('fsm module exports no providable XState machine with a `.config.states`');
129
+ }
130
+ /** How long a driven actor may take to settle; transitions resolve in microtasks. */
131
+ const SETTLE_MS = 1_000;
132
+ /** Bounded structured-machine probes use these shorter settle windows. */
133
+ const PARALLEL_SETTLE_MS = 250;
134
+ const PARALLEL_QUESTION_SETTLE_MS = 500;
135
+ const MAX_PARALLEL_COMBINATIONS = 64;
136
+ /** Stable values shared by machine input, result output, and guard probing. */
137
+ const COVERAGE_PLAYBOOK_ID = 'coverage-child-playbook';
138
+ const COVERAGE_PLAYBOOK_INPUT = 'coverage: complete the child request';
139
+ const COVERAGE_FINAL_RESPONSE = 'coverage: completed response';
140
+ const COVERAGE_ENABLED_PLAYBOOKS = [
141
+ {
142
+ id: COVERAGE_PLAYBOOK_ID,
143
+ command: '/coverage-child',
144
+ intent: 'Exercise a nested playbook transition.',
145
+ },
146
+ ];
147
+ /**
148
+ * Supplies the required Captain-session catalog without assuming that every
149
+ * artifact consumes these input fields. XState ignores unused machine input.
150
+ */
151
+ const COVERAGE_MACHINE_INPUT = {
152
+ stateId: 'coverage-root-playbook',
153
+ selfPlaybookId: 'coverage-root-playbook',
154
+ bossIntent: 'Exercise the compiled playbook transitions.',
155
+ enabledPlaybooks: COVERAGE_ENABLED_PLAYBOOKS,
156
+ };
157
+ /**
158
+ * A valid Captain context for direct guard evaluation. Dynamic call fields use
159
+ * the same id as the catalog entry, preserving exact catalog-membership guards.
160
+ */
161
+ function coverageGuardContext(dynamic) {
162
+ return {
163
+ ...COVERAGE_MACHINE_INPUT,
164
+ remainingPlan: [],
165
+ completedCallResults: [
166
+ {
167
+ playbookId: COVERAGE_PLAYBOOK_ID,
168
+ status: 'ok',
169
+ output: { response: 'coverage: prior child result' },
170
+ },
171
+ ],
172
+ completedCallSignatures: [],
173
+ nextPlaybookId: COVERAGE_PLAYBOOK_ID,
174
+ nextPlaybookInput: COVERAGE_PLAYBOOK_INPUT,
175
+ finalResponse: COVERAGE_FINAL_RESPONSE,
176
+ ...(dynamic === undefined
177
+ ? {}
178
+ : {
179
+ [dynamic.playbookIdContext]: COVERAGE_PLAYBOOK_ID,
180
+ [dynamic.textContext]: COVERAGE_PLAYBOOK_INPUT,
181
+ }),
182
+ };
183
+ }
184
+ /** Context produced by the machine's real initializer for the coverage input. */
185
+ function initializedMachineContext(machine) {
186
+ try {
187
+ const actor = createActor(machine, { input: COVERAGE_MACHINE_INPUT });
188
+ const context = actor.getSnapshot().context;
189
+ actor.stop();
190
+ return context;
191
+ }
192
+ catch {
193
+ return {};
194
+ }
195
+ }
196
+ /** Real initialized fields plus deterministic values needed by guard probes. */
197
+ function initializedCoverageContext(machine, dynamic) {
198
+ return {
199
+ ...initializedMachineContext(machine),
200
+ ...coverageGuardContext(dynamic),
201
+ };
202
+ }
203
+ /** Payload fields a result description requires, per the adjudicator convention. */
204
+ const REQUIRED_FIELD = /Output shall include `([A-Za-z_][A-Za-z0-9_]*):/g;
205
+ /** Structured fields named by the generic Captain result contracts. */
206
+ const STRUCTURED_RESULT_FIELD = /\b(response|question|remainingPlan|nextPlaybookId|nextPlaybookInput)\b/g;
207
+ function requiredFields(description) {
208
+ return [
209
+ ...new Set([
210
+ ...[...description.matchAll(REQUIRED_FIELD)].map((match) => match[1]),
211
+ ...[...description.matchAll(STRUCTURED_RESULT_FIELD)].map((match) => match[1]),
212
+ ]),
213
+ ];
214
+ }
215
+ function synthesizedFieldValue(field) {
216
+ switch (field) {
217
+ case 'question':
218
+ return 'What should happen next?';
219
+ case 'remainingPlan':
220
+ return [];
221
+ case 'nextPlaybookId':
222
+ return COVERAGE_PLAYBOOK_ID;
223
+ case 'nextPlaybookInput':
224
+ return COVERAGE_PLAYBOOK_INPUT;
225
+ case 'response':
226
+ return COVERAGE_FINAL_RESPONSE;
227
+ default:
228
+ return `coverage:${field}`;
229
+ }
230
+ }
231
+ /** Synthesizes a captain output that selects `key` under the state's contract. */
232
+ function synthOutput(state, key) {
233
+ const output = { guard: key };
234
+ for (const field of requiredFields(state.result[key] ?? '')) {
235
+ output[field] = synthesizedFieldValue(field);
236
+ }
237
+ return output;
238
+ }
239
+ function invocations(state) {
240
+ if (Array.isArray(state.invoke))
241
+ return state.invoke;
242
+ return state.invoke === undefined ? [] : [state.invoke];
243
+ }
244
+ function invocationSource(src) {
245
+ if (typeof src === 'string')
246
+ return src;
247
+ if (typeof src === 'object' &&
248
+ src !== null &&
249
+ 'type' in src &&
250
+ typeof src.type === 'string') {
251
+ return src.type;
252
+ }
253
+ return undefined;
254
+ }
255
+ /** Walks every state node in declaration order while retaining its ancestry. */
256
+ function stateRefs(config) {
257
+ const out = [];
258
+ const visit = (states, parent) => {
259
+ for (const [key, state] of Object.entries(states)) {
260
+ const path = [...(parent?.path ?? []), key];
261
+ const ref = {
262
+ key,
263
+ path,
264
+ ...(typeof state.id === 'string' ? { configId: state.id } : {}),
265
+ stableId: typeof state.meta?.playbook?.stateId === 'string'
266
+ ? state.meta.playbook.stateId
267
+ : typeof state.id === 'string'
268
+ ? state.id
269
+ : path.join('.'),
270
+ state,
271
+ ...(parent === undefined ? {} : { parent }),
272
+ };
273
+ out.push(ref);
274
+ if (state.states !== undefined)
275
+ visit(state.states, ref);
276
+ }
277
+ };
278
+ visit((config.states ?? {}));
279
+ return out;
280
+ }
281
+ /** Captain bindings paired with the nested state node that owns the invoke. */
282
+ function captainRefs(config) {
283
+ const out = [];
284
+ const refs = stateRefs(config);
285
+ const used = new Set();
286
+ // Script states drive like other work states: the scripted actor resolves
287
+ // one of the two declared exit-status guards (DR-013).
288
+ const workBindings = [
289
+ ...enumerateCaptainStates(config),
290
+ ...enumerateScriptStates(config).map((state) => ({
291
+ stateId: state.stateId,
292
+ sourceItem: state.sourceItem,
293
+ actor: 'script',
294
+ player: '',
295
+ prompt: state.command,
296
+ result: state.result,
297
+ ...(state.statePath === undefined ? {} : { statePath: state.statePath }),
298
+ })),
299
+ ];
300
+ for (const binding of workBindings) {
301
+ const statePath = binding.statePath;
302
+ const ref = (statePath === undefined
303
+ ? undefined
304
+ : refs.find((candidate) => candidate.path.join('.') === statePath)) ??
305
+ refs.find((candidate) => candidate.stableId === binding.stateId ||
306
+ (candidate.path.length === 1 && candidate.key === binding.stateId));
307
+ if (ref === undefined)
308
+ continue;
309
+ const choices = invocations(ref.state);
310
+ const invocationIndex = choices.findIndex((invocation) => {
311
+ if (used.has(invocation))
312
+ return false;
313
+ const source = invocationSource(invocation.src);
314
+ const matchingActor = source === binding.actor ||
315
+ // Playbook 0.9 represented delegated work as a `captain` invoke with
316
+ // an input.player field. Retain that immutable bundle until the atomic
317
+ // Playbook 1.0 refresh while driving new `player` invokes distinctly.
318
+ (binding.actor === 'player' && source === 'captain');
319
+ if ((!matchingActor && source !== undefined) ||
320
+ typeof invocation.input !== 'function') {
321
+ return matchingActor && binding.sourceItem === '';
322
+ }
323
+ try {
324
+ const input = invocation.input({ context: coverageGuardContext() });
325
+ if (typeof input !== 'object' ||
326
+ input === null ||
327
+ Array.isArray(input)) {
328
+ return matchingActor && binding.sourceItem === '';
329
+ }
330
+ const sourceItem = input.sourceItem;
331
+ if (binding.sourceItem !== '')
332
+ return sourceItem === binding.sourceItem;
333
+ return matchingActor;
334
+ }
335
+ catch {
336
+ return matchingActor && binding.sourceItem === '';
337
+ }
338
+ });
339
+ // A malformed explicit work invoke can lack a distinguishable input;
340
+ // retain declaration order rather than silently dropping coverage.
341
+ const selected = invocationIndex >= 0
342
+ ? invocationIndex
343
+ : choices.findIndex((invocation) => !used.has(invocation) &&
344
+ (invocationSource(invocation.src) === binding.actor ||
345
+ (binding.actor === 'player' &&
346
+ invocationSource(invocation.src) === 'captain')));
347
+ if (selected < 0)
348
+ continue;
349
+ used.add(choices[selected]);
350
+ out.push({
351
+ binding,
352
+ invocation: choices[selected],
353
+ invocationIndex: selected,
354
+ ref,
355
+ });
356
+ }
357
+ return out;
358
+ }
359
+ function playbookRefs(config) {
360
+ return stateRefs(config).flatMap((ref) => invocations(ref.state).flatMap((invocation, invocationIndex) => invocationSource(invocation.src) === 'playbook'
361
+ ? [{ invocation, invocationIndex, ref }]
362
+ : []));
363
+ }
364
+ function coverageErrorMessage(error) {
365
+ try {
366
+ return error instanceof Error ? error.message : String(error);
367
+ }
368
+ catch {
369
+ return 'unknown error';
370
+ }
371
+ }
372
+ /** Replays a nested input against the context observed after a failed start. */
373
+ function playbookInputFailure(playbook, context) {
374
+ if (typeof playbook.invocation.input !== 'function')
375
+ return undefined;
376
+ try {
377
+ playbook.invocation.input({ context });
378
+ return undefined;
379
+ }
380
+ catch (error) {
381
+ return coverageErrorMessage(error);
382
+ }
383
+ }
384
+ /** Dynamic target/text context names declared by a nested playbook input. */
385
+ function dynamicPlaybookFields(playbook) {
386
+ if (typeof playbook.invocation.input !== 'function')
387
+ return undefined;
388
+ let input;
389
+ try {
390
+ input = playbook.invocation.input({ context: coverageGuardContext() });
391
+ }
392
+ catch {
393
+ return undefined;
394
+ }
395
+ if (typeof input !== 'object' || input === null || Array.isArray(input)) {
396
+ return undefined;
397
+ }
398
+ const playbookIdContext = input
399
+ .playbookIdContext;
400
+ const textContext = input.textContext;
401
+ return typeof playbookIdContext === 'string' &&
402
+ typeof textContext === 'string'
403
+ ? { playbookIdContext, textContext }
404
+ : undefined;
405
+ }
406
+ function tagsOf(state) {
407
+ if (typeof state.tags === 'string')
408
+ return [state.tags];
409
+ return Array.isArray(state.tags) ? state.tags : [];
410
+ }
411
+ function stateRefForTarget(refs, target, source) {
412
+ const absolute = target.startsWith('#');
413
+ const normalized = absolute
414
+ ? target.slice(1)
415
+ : target.startsWith('.')
416
+ ? target.slice(1)
417
+ : target;
418
+ if (!absolute && source?.parent !== undefined) {
419
+ const siblingPath = [...source.parent.path, normalized].join('.');
420
+ const sibling = refs.find((ref) => ref.path.join('.') === siblingPath);
421
+ if (sibling !== undefined)
422
+ return sibling;
423
+ }
424
+ const byStableId = refs.find((ref) => ref.stableId === normalized || ref.configId === normalized);
425
+ if (byStableId !== undefined)
426
+ return byStableId;
427
+ const byPath = refs.find((ref) => ref.path.join('.') === normalized);
428
+ if (byPath !== undefined)
429
+ return byPath;
430
+ const byKey = refs.filter((ref) => ref.key === normalized);
431
+ return byKey.length === 1 ? byKey[0] : undefined;
432
+ }
433
+ function resolvedStateNode(machine, ref) {
434
+ let node = machine.root;
435
+ for (const key of ref.path) {
436
+ node = node?.states?.[key];
437
+ if (node === undefined)
438
+ return undefined;
439
+ }
440
+ return node;
441
+ }
442
+ function persistedSnapshotWithContext(provided, context) {
443
+ const seed = createActor(provided, { input: COVERAGE_MACHINE_INPUT });
444
+ try {
445
+ return {
446
+ ...seed.getPersistedSnapshot(),
447
+ context: { ...context },
448
+ children: {},
449
+ };
450
+ }
451
+ finally {
452
+ seed.stop();
453
+ }
454
+ }
455
+ function makeActor(machine, script, playbookScript = () => null, restoredContext) {
456
+ const coverageErrors = [];
457
+ const workActor = fromPromise(async ({ input }) => {
458
+ const output = script(input ?? {});
459
+ if (output === null)
460
+ return new Promise(() => { });
461
+ if (output instanceof Error)
462
+ throw output;
463
+ return output;
464
+ });
465
+ const provided = machine.provide({
466
+ actors: {
467
+ [CAPTAIN_ACTOR]: workActor,
468
+ player: workActor,
469
+ script: workActor,
470
+ // A child script is opt-in. All unrelated child invocations hang until
471
+ // the driven actor is stopped, preserving Captain and parallel probes.
472
+ playbook: fromPromise(async ({ input, self, }) => {
473
+ const output = playbookScript(input ?? {}, self.id);
474
+ if (output === null)
475
+ return new Promise(() => { });
476
+ if (output instanceof Error)
477
+ throw output;
478
+ return output;
479
+ }),
480
+ },
481
+ });
482
+ const restoredSnapshot = restoredContext === undefined
483
+ ? undefined
484
+ : persistedSnapshotWithContext(provided, restoredContext);
485
+ const actor = createActor(provided, {
486
+ input: COVERAGE_MACHINE_INPUT,
487
+ ...(restoredSnapshot === undefined ? {} : { snapshot: restoredSnapshot }),
488
+ inspect: (inspection) => {
489
+ if (typeof inspection !== 'object' ||
490
+ inspection === null ||
491
+ !('event' in inspection)) {
492
+ return;
493
+ }
494
+ const event = inspection.event;
495
+ if (typeof event !== 'object' || event === null)
496
+ return;
497
+ const eventType = event.type;
498
+ if (typeof eventType === 'string' &&
499
+ eventType.startsWith('xstate.error.actor.')) {
500
+ coverageErrors.push({
501
+ eventType,
502
+ error: event.error,
503
+ });
504
+ }
505
+ },
506
+ });
507
+ Object.defineProperty(actor, 'coverageErrors', {
508
+ value: coverageErrors,
509
+ });
510
+ actor.subscribe({ error: () => { } });
511
+ actor.start();
512
+ return actor;
513
+ }
514
+ /** A script that rejects the captain invocation for the given source item. */
515
+ function throwingScript(sourceItem, gate) {
516
+ return (input) => {
517
+ if (!gate.armed || input.sourceItem !== sourceItem)
518
+ return null;
519
+ return new Error('coverage: forced captain failure');
520
+ };
521
+ }
522
+ /** A script that resolves `output` once for the given source item, then hangs. */
523
+ function onceScript(sourceItem, output, gate) {
524
+ let used = false;
525
+ return (input) => {
526
+ if (!gate.armed || used || input.sourceItem !== sourceItem)
527
+ return null;
528
+ used = true;
529
+ return output;
530
+ };
531
+ }
532
+ /** Waits until the actor's snapshot satisfies `predicate`, or times out. */
533
+ function settle(actor, predicate, ms = SETTLE_MS) {
534
+ return new Promise((resolveSettled) => {
535
+ let subscription = undefined;
536
+ const finish = (outcome) => {
537
+ clearTimeout(timer);
538
+ subscription?.unsubscribe();
539
+ resolveSettled(outcome);
540
+ };
541
+ const timer = setTimeout(() => finish(predicate(actor.getSnapshot())), ms);
542
+ subscription = actor.subscribe({
543
+ next: (snapshot) => {
544
+ if (predicate(snapshot))
545
+ finish(true);
546
+ },
547
+ // XState reports an actor failure as unhandled when any observer lacks
548
+ // an error listener. A failed transition is a false settle result; its
549
+ // owning probe records the actionable coverage finding.
550
+ error: () => finish(false),
551
+ });
552
+ if (predicate(actor.getSnapshot()))
553
+ finish(true);
554
+ });
555
+ }
556
+ /** Lets XState process an event whose correct outcome is no transition. */
557
+ async function settleNoTransition() {
558
+ await new Promise((resolveSettled) => setTimeout(resolveSettled, 10));
559
+ }
560
+ function activeStateIds(snapshot) {
561
+ const ids = new Set();
562
+ if (typeof snapshot.getMeta === 'function') {
563
+ for (const [nodeId, raw] of Object.entries(snapshot.getMeta())) {
564
+ if (typeof raw !== 'object' || raw === null)
565
+ continue;
566
+ const playbook = raw.playbook;
567
+ if (typeof playbook !== 'object' || playbook === null)
568
+ continue;
569
+ const stateId = playbook.stateId;
570
+ if (typeof stateId === 'string')
571
+ ids.add(stateId);
572
+ // XState's metadata map keys are public state-node ids. Retain them as
573
+ // an additional compatibility surface for authored metadata that omits
574
+ // playbook.stateId.
575
+ ids.add(nodeId.startsWith('#') ? nodeId.slice(1) : nodeId);
576
+ }
577
+ }
578
+ const walkValue = (value, prefix = []) => {
579
+ if (typeof value === 'string') {
580
+ ids.add(value);
581
+ ids.add([...prefix, value].join('.'));
582
+ return;
583
+ }
584
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
585
+ return;
586
+ }
587
+ for (const [key, nested] of Object.entries(value)) {
588
+ walkValue(nested, [...prefix, key]);
589
+ }
590
+ };
591
+ walkValue(snapshot.value);
592
+ return ids;
593
+ }
594
+ const atState = (ref) => (snapshot) => {
595
+ const active = activeStateIds(snapshot);
596
+ return (active.has(ref.stableId) ||
597
+ active.has(ref.path.join('.')) ||
598
+ (ref.path.length === 1 && active.has(ref.key)));
599
+ };
600
+ const leftState = (ref) => (snapshot) => !atState(ref)(snapshot);
601
+ /** The actor id XState actually uses for a declared invocation. */
602
+ function invocationActorId(machine, invocation) {
603
+ const resolved = resolvedStateNode(machine, invocation.ref)?.invoke?.[invocation.invocationIndex]?.id;
604
+ if (typeof resolved === 'string')
605
+ return resolved;
606
+ const declared = invocation.invocation.id;
607
+ if (typeof declared === 'string')
608
+ return declared;
609
+ if (typeof invocation.ref.state.id === 'string') {
610
+ return `0.${invocation.ref.state.id}`;
611
+ }
612
+ return `0.${typeof machine.config.id === 'string' ? machine.config.id : '(machine)'}.${invocation.ref.path.join('.')}`;
613
+ }
614
+ function invocationEvent(machine, captain, kind) {
615
+ const actorId = invocationActorId(machine, captain);
616
+ return { type: `xstate.${kind}.actor.${actorId}`, actorId };
617
+ }
618
+ function transitionArms(raw) {
619
+ return Array.isArray(raw) ? raw : raw === undefined ? [] : [raw];
620
+ }
621
+ function rawArmTarget(arm) {
622
+ if (typeof arm === 'string')
623
+ return arm;
624
+ if (typeof arm !== 'object' || arm === null)
625
+ return undefined;
626
+ const target = arm.target;
627
+ return typeof target === 'string' ? target : undefined;
628
+ }
629
+ function armGuard(arm) {
630
+ return typeof arm === 'object' && arm !== null
631
+ ? arm.guard
632
+ : undefined;
633
+ }
634
+ /** The first transition arm XState selects for one concrete actor event. */
635
+ function directlySelectedEventArm(machine, arms, event, context = coverageGuardContext()) {
636
+ for (const [index, arm] of arms.entries()) {
637
+ const rawGuard = armGuard(arm);
638
+ if (rawGuard === undefined)
639
+ return index;
640
+ const guard = resolveGuard(machine, rawGuard);
641
+ if (guard === undefined)
642
+ return undefined;
643
+ try {
644
+ if (guard.run({ context, event }))
645
+ return index;
646
+ }
647
+ catch {
648
+ return undefined;
649
+ }
650
+ }
651
+ return undefined;
652
+ }
653
+ /**
654
+ * Builds the predicate that selects one ordered XState transition arm: every
655
+ * preceding guarded arm must reject and the selected arm must accept (or be
656
+ * the unguarded fallback). An earlier unguarded arm shadows all later arms.
657
+ */
658
+ function orderedArmPredicate(machine, arms, selected) {
659
+ const prior = [];
660
+ for (let index = 0; index <= selected; index++) {
661
+ const rawGuard = armGuard(arms[index]);
662
+ if (rawGuard === undefined) {
663
+ if (index < selected) {
664
+ return { run: () => false, probeValues: [] };
665
+ }
666
+ return {
667
+ run: (args) => prior.every((guard) => !guard.run(args)),
668
+ probeValues: prior.flatMap((guard) => guard.probeValues),
669
+ };
670
+ }
671
+ const guard = resolveGuard(machine, rawGuard);
672
+ if (guard === undefined)
673
+ return undefined;
674
+ if (index === selected) {
675
+ return {
676
+ run: (args) => prior.every((candidate) => !candidate.run(args)) && guard.run(args),
677
+ probeValues: [
678
+ ...prior.flatMap((candidate) => candidate.probeValues),
679
+ ...guard.probeValues,
680
+ ],
681
+ };
682
+ }
683
+ prior.push(guard);
684
+ }
685
+ return undefined;
686
+ }
687
+ /** A bounded satisfying context/event pair for one authored interrupt arm. */
688
+ function interruptDriveForRef(machine, refs, target, targetId, extraValues = [], selectedArmIndex) {
689
+ const base = { type: INTERRUPT_EVENT, targetId };
690
+ const arms = transitionArms((machine.config.on ?? {})[INTERRUPT_EVENT]);
691
+ const armIndex = selectedArmIndex ??
692
+ arms.findIndex((arm) => {
693
+ const rawTarget = rawArmTarget(arm);
694
+ if (rawTarget === undefined)
695
+ return false;
696
+ const resolvedTarget = stateRefForTarget(refs, rawTarget);
697
+ return (sameStateRef(resolvedTarget, target) ||
698
+ resolvedTarget?.stableId === targetId);
699
+ });
700
+ const targetPlaybook = playbookRefs(machine.config).find((playbook) => sameStateRef(playbook.ref, target));
701
+ const initialContext = initializedCoverageContext(machine, targetPlaybook === undefined
702
+ ? undefined
703
+ : dynamicPlaybookFields(targetPlaybook));
704
+ if (armIndex < 0 || armIndex >= arms.length) {
705
+ return { event: base, context: initialContext, satisfiable: false };
706
+ }
707
+ const guard = orderedArmPredicate(machine, arms, armIndex);
708
+ if (guard === undefined) {
709
+ return { event: base, context: initialContext, satisfiable: false };
710
+ }
711
+ const assignment = probeGuardAssignment(guard.run, {}, [{ tag: 'e:', base }], [
712
+ ...guard.probeValues,
713
+ ...refs.flatMap((ref) => [
714
+ ref.key,
715
+ ref.stableId,
716
+ ...(ref.configId === undefined ? [] : [ref.configId]),
717
+ ]),
718
+ ...extraValues,
719
+ ], {
720
+ initialContext,
721
+ varyExistingContext: true,
722
+ });
723
+ return assignment === undefined
724
+ ? { event: base, context: initialContext, satisfiable: false }
725
+ : {
726
+ event: assignedPayload(base, assignment, 'e:'),
727
+ context: assignment.context,
728
+ satisfiable: true,
729
+ };
730
+ }
731
+ /** The arm XState selects for one concrete invocation output and context. */
732
+ function directlySelectedArm(machine, captain, output, context = coverageGuardContext()) {
733
+ const event = {
734
+ ...invocationEvent(machine, captain, 'done'),
735
+ output,
736
+ };
737
+ return directlySelectedEventArm(machine, transitionArms(captain.invocation.onDone), event, context);
738
+ }
739
+ function directTargetRef(machine, refs, captain, output, context = coverageGuardContext()) {
740
+ const arms = transitionArms(captain.invocation.onDone);
741
+ const selected = directlySelectedArm(machine, captain, output, context);
742
+ if (selected === undefined)
743
+ return undefined;
744
+ const target = rawArmTarget(arms[selected]);
745
+ return target === undefined
746
+ ? undefined
747
+ : stateRefForTarget(refs, target, captain.ref);
748
+ }
749
+ /*
750
+ * Guard-satisfiability probing: a jumped-in actor carries the initial context,
751
+ * so an arm guarded on accumulated state (e.g. a routing field set by an
752
+ * earlier transition) cannot fire in the driven run. Such arms are checked
753
+ * deterministically instead: candidate context/event values are mined from the
754
+ * guard function's own source literals, and the arm is flagged only when no
755
+ * bounded assignment satisfies it.
756
+ */
757
+ const GENERIC_VALUES = [
758
+ 'coverage',
759
+ true,
760
+ 1,
761
+ ['coverage'],
762
+ COVERAGE_ENABLED_PLAYBOOKS,
763
+ ];
764
+ const MAX_PROBES = 30_000;
765
+ const PROBES_PER_TIMEOUT_MILLISECOND = 50;
766
+ const COVERAGE_TIMEOUT_MARGIN_MS = 5_000;
767
+ const MIN_COVERAGE_TEST_TIMEOUT_MS = 10_000;
768
+ function minedLiterals(fn) {
769
+ let source;
770
+ try {
771
+ source = String(fn);
772
+ }
773
+ catch {
774
+ return [];
775
+ }
776
+ const literals = source.match(/'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"/g) ?? [];
777
+ return [...new Set(literals.map((literal) => literal.slice(1, -1)))];
778
+ }
779
+ // Routing-field values (e.g. a change origin or review subject) are bound at
780
+ // helper call sites, invisible in the guard closures themselves; the module's
781
+ // identifier-like string literals recover them without dragging prompt prose
782
+ // into the candidate pool.
783
+ const IDENTIFIER_LITERAL = /^[A-Za-z][A-Za-z0-9_-]{0,31}$/;
784
+ /** Mines identifier-like string literals from an artifact's source text. */
785
+ export function identifierLiterals(sourceText) {
786
+ const literals = sourceText.match(/'(?:[^'\\\n]|\\.)*'|"(?:[^"\\\n]|\\.)*"/g) ?? [];
787
+ return [
788
+ ...new Set(literals
789
+ .map((literal) => literal.slice(1, -1))
790
+ .filter((literal) => IDENTIFIER_LITERAL.test(literal))),
791
+ ];
792
+ }
793
+ /**
794
+ * Reports whether a bounded search finds a context/output assignment under
795
+ * which the guard passes, seeding candidate values from the guard's source and
796
+ * the caller's extra candidates (typically the machine's state keys and the
797
+ * artifact's identifier literals, since typed routing fields hold values that
798
+ * guards compare against helper-bound constants).
799
+ *
800
+ * A conjunctive guard short-circuits, so each probe pass reveals at most one
801
+ * new field read; the search deepens iteratively — assign a discovered field
802
+ * each candidate value, re-probe for the next read — within a global probe
803
+ * budget, and an exhausted budget reports unsatisfiable ("under probing").
804
+ */
805
+ export function guardSatisfiable(guard, baseOutput, extraValues = []) {
806
+ return (probeGuardAssignment(guard, {}, [{ eventField: 'output', tag: 'o:', base: baseOutput }], extraValues) !== undefined);
807
+ }
808
+ function probeGuardAssignment(guard, fixedEvent, payloads, extraValues, options = {}) {
809
+ const baseContext = { ...(options.initialContext ?? {}) };
810
+ // Guard-source literals first: the likeliest matches are tried earliest.
811
+ const values = [
812
+ ...new Set([...minedLiterals(guard), ...extraValues, ...GENERIC_VALUES]),
813
+ ];
814
+ let probes = 0;
815
+ const eventFor = (assignment) => {
816
+ let event = { ...fixedEvent };
817
+ for (const payload of payloads) {
818
+ if (payload.eventField === undefined) {
819
+ event = overlaidObject(event, assignment.payloads[payload.tag] ?? {});
820
+ }
821
+ else {
822
+ event[payload.eventField] = overlaidObject(payload.base, assignment.payloads[payload.tag] ?? {});
823
+ }
824
+ }
825
+ return event;
826
+ };
827
+ const passes = (assignment) => {
828
+ probes++;
829
+ try {
830
+ return Boolean(guard({ context: assignment.context, event: eventFor(assignment) }));
831
+ }
832
+ catch {
833
+ return false;
834
+ }
835
+ };
836
+ // Records the unassigned fields the guard reads under the given assignment.
837
+ const readsUnder = (assignment) => {
838
+ const reads = new Set();
839
+ const recording = (base, assigned, tag, varyExisting = false) => new Proxy(overlaidObject(base, assigned), {
840
+ get(target, prop) {
841
+ if (typeof prop !== 'string')
842
+ return undefined;
843
+ if (prop in target) {
844
+ const payload = payloads.find((item) => item.tag === tag);
845
+ if ((varyExisting || payload?.varyExisting === true) &&
846
+ !(prop in assigned)) {
847
+ reads.add(`${tag}${prop}`);
848
+ }
849
+ return target[prop];
850
+ }
851
+ reads.add(`${tag}${prop}`);
852
+ return undefined;
853
+ },
854
+ has(target, prop) {
855
+ if (typeof prop === 'string' && !(prop in target)) {
856
+ reads.add(`${tag}${prop}`);
857
+ }
858
+ return prop in target;
859
+ },
860
+ });
861
+ try {
862
+ let event = { ...fixedEvent };
863
+ for (const payload of payloads) {
864
+ if (payload.eventField === undefined) {
865
+ event = recording(event, assignment.payloads[payload.tag] ?? {}, payload.tag);
866
+ }
867
+ else {
868
+ event[payload.eventField] = recording(payload.base, assignment.payloads[payload.tag] ?? {}, payload.tag);
869
+ }
870
+ }
871
+ guard({
872
+ context: recording(baseContext, assignment.contextOverrides, 'c:', options.varyExistingContext === true),
873
+ event,
874
+ });
875
+ }
876
+ catch {
877
+ // Reads observed before the throw still guide the search.
878
+ }
879
+ return [...reads];
880
+ };
881
+ const search = (assignment, depth) => {
882
+ if (probes > MAX_PROBES)
883
+ return undefined;
884
+ if (passes(assignment))
885
+ return assignment;
886
+ if (depth >= 4)
887
+ return undefined;
888
+ for (const key of readsUnder(assignment)) {
889
+ const tag = key.slice(0, 2);
890
+ const field = key.slice(2);
891
+ if (tag === 'c:' && options.assignContext === false)
892
+ continue;
893
+ const assigned = tag === 'c:'
894
+ ? assignment.contextOverrides
895
+ : (assignment.payloads[tag] ?? {});
896
+ const payload = payloads.find((item) => item.tag === tag);
897
+ const base = tag === 'c:' ? baseContext : payload?.base;
898
+ const hasBaseline = base !== undefined && Object.prototype.hasOwnProperty.call(base, field);
899
+ const baseline = hasBaseline
900
+ ? base[field]
901
+ : undefined;
902
+ const candidates = [
903
+ ...(hasBaseline ? [baseline] : []),
904
+ ...values.filter((value) => !hasBaseline || value !== baseline),
905
+ ];
906
+ for (const value of candidates) {
907
+ if (probes > MAX_PROBES)
908
+ return undefined;
909
+ const nextDepth = depth + (hasBaseline && value === baseline ? 0 : 1);
910
+ const next = tag === 'c:'
911
+ ? {
912
+ ...assignment,
913
+ context: { ...assignment.context, [field]: value },
914
+ contextOverrides: { ...assigned, [field]: value },
915
+ }
916
+ : {
917
+ ...assignment,
918
+ payloads: {
919
+ ...assignment.payloads,
920
+ [tag]: {
921
+ ...(assignment.payloads[tag] ?? {}),
922
+ [field]: value,
923
+ },
924
+ },
925
+ };
926
+ const found = search(next, nextDepth);
927
+ if (found !== undefined)
928
+ return found;
929
+ }
930
+ }
931
+ return undefined;
932
+ };
933
+ return search({
934
+ context: baseContext,
935
+ contextOverrides: {},
936
+ payloads: {},
937
+ }, 0);
938
+ }
939
+ function probeGuardSatisfiable(guard, fixedEvent, payloads, extraValues) {
940
+ return (probeGuardAssignment(guard, fixedEvent, payloads, extraValues) !== undefined);
941
+ }
942
+ function overlaidObject(base, assigned) {
943
+ const copy = Object.create(Object.getPrototypeOf(base));
944
+ Object.defineProperties(copy, Object.getOwnPropertyDescriptors(base));
945
+ Object.assign(copy, assigned);
946
+ return copy;
947
+ }
948
+ /**
949
+ * Bounded guard probing with a fixed, real event surface. Only context and the
950
+ * named nested payloads are assignable; event-level fields such as `type` and
951
+ * `actorId` remain the values XState actually supplies.
952
+ */
953
+ function doneGuardSatisfiable(guard, event, output, extraValues) {
954
+ return probeGuardSatisfiable(guard.run, event, [{ eventField: 'output', tag: 'o:', base: output }], [...guard.probeValues, ...extraValues]);
955
+ }
956
+ function errorGuardSatisfiable(guard, event, error, extraValues) {
957
+ return probeGuardSatisfiable(guard.run, event, [{ eventField: 'error', tag: 'r:', base: error, varyExisting: true }], [...guard.probeValues, ...extraValues]);
958
+ }
959
+ function isDescendantOf(ref, ancestor) {
960
+ return (ref.path.length > ancestor.path.length &&
961
+ ancestor.path.every((part, index) => ref.path[index] === part));
962
+ }
963
+ /** Picks the initially active Captain leaf from each immediate parallel region. */
964
+ function parallelBranchCaptains(parallel, refs, captains) {
965
+ const regions = refs.filter((ref) => ref.parent === parallel);
966
+ const selected = [];
967
+ for (const region of regions) {
968
+ const candidates = captains.filter((captain) => isDescendantOf(captain.ref, region));
969
+ const initial = region.state.initial;
970
+ const captain = (initial === undefined
971
+ ? undefined
972
+ : candidates.find((candidate) => candidate.ref.path[region.path.length] === initial)) ?? candidates[0];
973
+ if (captain !== undefined)
974
+ selected.push(captain);
975
+ }
976
+ return selected;
977
+ }
978
+ function scriptedOutputs(entries) {
979
+ const calls = new Map();
980
+ const byState = new Map(entries.map(({ captain, output }) => [
981
+ captainPublicStateId(captain),
982
+ output,
983
+ ]));
984
+ const bySource = new Map(entries.map(({ captain, output }) => [captain.binding.sourceItem, output]));
985
+ return {
986
+ calls,
987
+ script: (input) => {
988
+ const stateId = typeof input.stateId === 'string' ? input.stateId : undefined;
989
+ const sourceItem = typeof input.sourceItem === 'string' ? input.sourceItem : undefined;
990
+ const key = stateId ?? sourceItem;
991
+ const output = (stateId === undefined ? undefined : byState.get(stateId)) ??
992
+ (sourceItem === undefined ? undefined : bySource.get(sourceItem));
993
+ if (key === undefined || output === undefined)
994
+ return null;
995
+ const count = (calls.get(key) ?? 0) + 1;
996
+ calls.set(key, count);
997
+ return count === 1 ? output : null;
998
+ },
999
+ };
1000
+ }
1001
+ function callCount(calls, captain) {
1002
+ return (calls.get(captainPublicStateId(captain)) ??
1003
+ calls.get(captain.binding.sourceItem) ??
1004
+ 0);
1005
+ }
1006
+ function playbookCoverageInput(machine, playbook, dynamic) {
1007
+ if (typeof playbook.invocation.input !== 'function')
1008
+ return undefined;
1009
+ try {
1010
+ const input = playbook.invocation.input({
1011
+ context: initializedCoverageContext(machine, dynamic),
1012
+ });
1013
+ return typeof input === 'object' && input !== null && !Array.isArray(input)
1014
+ ? input
1015
+ : undefined;
1016
+ }
1017
+ catch {
1018
+ return undefined;
1019
+ }
1020
+ }
1021
+ function nestedSuccessOutput() {
1022
+ return {
1023
+ outcome: 'terminal',
1024
+ state: { value: 'done', context: {} },
1025
+ output: { response: 'coverage: nested playbook completed' },
1026
+ response: 'coverage: nested playbook completed',
1027
+ };
1028
+ }
1029
+ function nestedFailure(playbookId) {
1030
+ const error = new Error('coverage: forced nested playbook failure');
1031
+ Object.defineProperty(error, 'result', {
1032
+ configurable: true,
1033
+ enumerable: true,
1034
+ writable: true,
1035
+ value: {
1036
+ status: 'error',
1037
+ playbookId,
1038
+ error: {
1039
+ name: 'Error',
1040
+ message: 'coverage: forced nested playbook failure',
1041
+ },
1042
+ },
1043
+ });
1044
+ return error;
1045
+ }
1046
+ function assignedPayload(base, assignment, tag) {
1047
+ return overlaidObject(base, assignment.payloads[tag] ?? {});
1048
+ }
1049
+ /**
1050
+ * Finds a valid Captain result that populates a dynamic call's target and text
1051
+ * before entering it. This exercises the authored assignment and exact catalog
1052
+ * guard instead of jumping into the call with impossible empty context.
1053
+ */
1054
+ function playbookEntryPlan(machine, playbook, refs, captains, dynamic) {
1055
+ const context = initializedCoverageContext(machine, dynamic);
1056
+ for (const captain of captains) {
1057
+ const arms = transitionArms(captain.invocation.onDone);
1058
+ for (const [armIndex, arm] of arms.entries()) {
1059
+ const rawTarget = rawArmTarget(arm);
1060
+ if (rawTarget === undefined ||
1061
+ !sameStateRef(stateRefForTarget(refs, rawTarget, captain.ref), playbook.ref)) {
1062
+ continue;
1063
+ }
1064
+ for (const key of Object.keys(captain.binding.result)) {
1065
+ const output = {
1066
+ ...synthOutput(captain.binding, key),
1067
+ [dynamic.playbookIdContext]: COVERAGE_PLAYBOOK_ID,
1068
+ [dynamic.textContext]: COVERAGE_PLAYBOOK_INPUT,
1069
+ };
1070
+ if (directlySelectedArm(machine, captain, output, context) === armIndex) {
1071
+ return { captain, output };
1072
+ }
1073
+ }
1074
+ }
1075
+ }
1076
+ return undefined;
1077
+ }
1078
+ /**
1079
+ * Finds a child-success path into a Captain state whose public interrupt has
1080
+ * valid accumulated-context preconditions (for example Captain reassessment
1081
+ * after one child call). The checker then drives the authored predecessor
1082
+ * instead of manufacturing context or treating the guarded jump as dead.
1083
+ */
1084
+ function captainPredecessorPlan(machine, target, playbooks, refs, captains) {
1085
+ const candidateValues = refs.flatMap((ref) => [
1086
+ ref.key,
1087
+ ref.stableId,
1088
+ ...(ref.configId === undefined ? [] : [ref.configId]),
1089
+ ]);
1090
+ for (const playbook of playbooks) {
1091
+ const dynamic = dynamicPlaybookFields(playbook);
1092
+ const entry = dynamic === undefined
1093
+ ? undefined
1094
+ : playbookEntryPlan(machine, playbook, refs, captains, dynamic);
1095
+ if (dynamic !== undefined && entry === undefined)
1096
+ continue;
1097
+ if (entry !== undefined && sameStateRef(entry.captain.ref, target.ref)) {
1098
+ continue;
1099
+ }
1100
+ const actorId = invocationActorId(machine, playbook);
1101
+ const fixedEvent = {
1102
+ type: `xstate.done.actor.${actorId}`,
1103
+ actorId,
1104
+ };
1105
+ const arms = transitionArms(playbook.invocation.onDone);
1106
+ for (const [armIndex, arm] of arms.entries()) {
1107
+ const rawTarget = rawArmTarget(arm);
1108
+ if (rawTarget === undefined ||
1109
+ !sameStateRef(stateRefForTarget(refs, rawTarget, playbook.ref), target.ref)) {
1110
+ continue;
1111
+ }
1112
+ const guard = orderedArmPredicate(machine, arms, armIndex);
1113
+ if (guard === undefined)
1114
+ continue;
1115
+ for (const base of [
1116
+ nestedSuccessOutput(),
1117
+ { invalidCoverageOutput: undefined },
1118
+ ]) {
1119
+ const assignment = probeGuardAssignment(guard.run, fixedEvent, [{ eventField: 'output', tag: 'o:', base }], [...guard.probeValues, ...candidateValues], {
1120
+ initialContext: initializedCoverageContext(machine, dynamic),
1121
+ assignContext: false,
1122
+ });
1123
+ if (assignment !== undefined) {
1124
+ return {
1125
+ playbook,
1126
+ ...(entry === undefined ? {} : { entry }),
1127
+ childOutput: assignedPayload(base, assignment, 'o:'),
1128
+ };
1129
+ }
1130
+ }
1131
+ }
1132
+ }
1133
+ return undefined;
1134
+ }
1135
+ function captainProbeActor(machine, captain, result, gate, refs, captains, playbooks, interruptValues) {
1136
+ const predecessor = captainPredecessorPlan(machine, captain, playbooks, refs, captains);
1137
+ if (predecessor === undefined) {
1138
+ const drive = interruptDriveForRef(machine, refs, captain.ref, captainInterruptTarget(captain), interruptValues);
1139
+ return {
1140
+ actor: makeActor(machine, result instanceof Error
1141
+ ? throwingScript(captain.binding.sourceItem, gate)
1142
+ : onceScript(captain.binding.sourceItem, result, gate), () => null, drive.context),
1143
+ event: drive.event,
1144
+ };
1145
+ }
1146
+ let entryUsed = false;
1147
+ let targetUsed = false;
1148
+ let childUsed = false;
1149
+ const actorId = invocationActorId(machine, predecessor.playbook);
1150
+ const dynamic = dynamicPlaybookFields(predecessor.playbook);
1151
+ const entryRef = predecessor.entry?.captain.ref ?? predecessor.playbook.ref;
1152
+ const entryId = predecessor.entry === undefined
1153
+ ? interruptTargetForRef(predecessor.playbook.ref, predecessor.playbook.ref.stableId)
1154
+ : captainInterruptTarget(predecessor.entry.captain);
1155
+ const drive = interruptDriveForRef(machine, refs, entryRef, entryId, interruptValues);
1156
+ const actor = makeActor(machine, (input) => {
1157
+ if (!gate.armed)
1158
+ return null;
1159
+ if (predecessor.entry !== undefined &&
1160
+ !entryUsed &&
1161
+ input.sourceItem === predecessor.entry.captain.binding.sourceItem) {
1162
+ entryUsed = true;
1163
+ return predecessor.entry.output;
1164
+ }
1165
+ if (!targetUsed && input.sourceItem === captain.binding.sourceItem) {
1166
+ targetUsed = true;
1167
+ return result;
1168
+ }
1169
+ return null;
1170
+ }, (input, invokedActorId) => {
1171
+ if (!gate.armed ||
1172
+ childUsed ||
1173
+ invokedActorId !== actorId ||
1174
+ input.stateId !== predecessor.playbook.ref.stableId ||
1175
+ (dynamic !== undefined &&
1176
+ (input.playbookId !== COVERAGE_PLAYBOOK_ID ||
1177
+ input.text !== COVERAGE_PLAYBOOK_INPUT))) {
1178
+ return null;
1179
+ }
1180
+ childUsed = true;
1181
+ return predecessor.childOutput;
1182
+ }, drive.context);
1183
+ return {
1184
+ actor,
1185
+ event: drive.event,
1186
+ };
1187
+ }
1188
+ /**
1189
+ * Drives one nested invocation outcome. Literal calls enter through their
1190
+ * public id; dynamic calls first take a valid Captain transition that writes
1191
+ * the target/text context. The one-shot child is selected by both its resolved
1192
+ * XState actor id and its `PlaybookInput.stateId`; every other child remains
1193
+ * parked until actor stop.
1194
+ */
1195
+ async function probePlaybookOutcome(machine, playbook, refs, captains, outcome, interruptValues) {
1196
+ const rawArms = transitionArms(outcome === 'onDone'
1197
+ ? playbook.invocation.onDone
1198
+ : playbook.invocation.onError);
1199
+ if (rawArms.length === 0) {
1200
+ return [
1201
+ `state ${playbook.ref.stableId} declares no nested playbook ${outcome} transition`,
1202
+ ];
1203
+ }
1204
+ const actorId = invocationActorId(machine, playbook);
1205
+ const dynamic = dynamicPlaybookFields(playbook);
1206
+ const entry = dynamic === undefined
1207
+ ? undefined
1208
+ : playbookEntryPlan(machine, playbook, refs, captains, dynamic);
1209
+ if (dynamic !== undefined && entry === undefined) {
1210
+ return [
1211
+ `state ${playbook.ref.stableId}: dynamic nested playbook has no reachable Captain entry transition`,
1212
+ ];
1213
+ }
1214
+ const entryRef = entry?.captain.ref ?? playbook.ref;
1215
+ const entryId = entry === undefined
1216
+ ? interruptTargetForRef(playbook.ref, playbook.ref.stableId)
1217
+ : captainInterruptTarget(entry.captain);
1218
+ const drive = interruptDriveForRef(machine, refs, entryRef, entryId, interruptValues);
1219
+ const input = playbookCoverageInput(machine, playbook, dynamic);
1220
+ const expectedPlaybookId = typeof input?.playbookId === 'string'
1221
+ ? input.playbookId
1222
+ : COVERAGE_PLAYBOOK_ID;
1223
+ const fixedEvent = {
1224
+ type: `xstate.${outcome === 'onDone' ? 'done' : 'error'}.actor.${actorId}`,
1225
+ actorId,
1226
+ };
1227
+ const context = initializedCoverageContext(machine, dynamic);
1228
+ const candidateValues = refs.flatMap((ref) => [
1229
+ ref.key,
1230
+ ref.stableId,
1231
+ ...(ref.configId === undefined ? [] : [ref.configId]),
1232
+ ]);
1233
+ const findings = [];
1234
+ for (const [armIndex, arm] of rawArms.entries()) {
1235
+ const rawGuard = armGuard(arm);
1236
+ if (rawGuard !== undefined &&
1237
+ resolveGuard(machine, rawGuard) === undefined) {
1238
+ findings.push(`state ${playbook.ref.stableId}: nested playbook ${outcome} arm ${armIndex} names an unresolvable guard "${guardLabel(rawGuard)}"`);
1239
+ continue;
1240
+ }
1241
+ const guard = orderedArmPredicate(machine, rawArms, armIndex);
1242
+ if (guard === undefined)
1243
+ continue;
1244
+ let scriptedResult;
1245
+ if (outcome === 'onDone') {
1246
+ const outputs = [
1247
+ nestedSuccessOutput(),
1248
+ // A resolved promise may still return a value rejected by the linked
1249
+ // runtime's output validator; fallback arms must be independently
1250
+ // selectable and driven as well.
1251
+ { invalidCoverageOutput: undefined },
1252
+ ];
1253
+ for (const base of outputs) {
1254
+ const assignment = probeGuardAssignment(guard.run, fixedEvent, [{ eventField: 'output', tag: 'o:', base }], [...guard.probeValues, ...candidateValues], { initialContext: context, assignContext: false });
1255
+ if (assignment !== undefined) {
1256
+ scriptedResult = assignedPayload(base, assignment, 'o:');
1257
+ break;
1258
+ }
1259
+ }
1260
+ }
1261
+ else {
1262
+ const errors = [
1263
+ nestedFailure(expectedPlaybookId),
1264
+ new Error('coverage: generic nested playbook failure'),
1265
+ ];
1266
+ for (const base of errors) {
1267
+ const assignment = probeGuardAssignment(guard.run, fixedEvent, [{ eventField: 'error', tag: 'r:', base, varyExisting: true }], [...guard.probeValues, ...candidateValues], { initialContext: context, assignContext: false });
1268
+ if (assignment !== undefined) {
1269
+ scriptedResult = assignedPayload(base, assignment, 'r:');
1270
+ break;
1271
+ }
1272
+ }
1273
+ }
1274
+ if (scriptedResult === undefined) {
1275
+ findings.push(`state ${playbook.ref.stableId}: nested playbook ${outcome} arm ${armIndex} is unsatisfiable under probing`);
1276
+ continue;
1277
+ }
1278
+ const rawTarget = rawArmTarget(arm);
1279
+ const target = rawTarget === undefined
1280
+ ? undefined
1281
+ : stateRefForTarget(refs, rawTarget, playbook.ref);
1282
+ if (rawTarget === undefined || target === undefined) {
1283
+ findings.push(`state ${playbook.ref.stableId}: nested playbook ${outcome} arm ${armIndex} has no observable target`);
1284
+ continue;
1285
+ }
1286
+ if (sameStateRef(target, playbook.ref)) {
1287
+ findings.push(`state ${playbook.ref.stableId}: nested playbook ${outcome} arm ${armIndex} does not leave the call state`);
1288
+ continue;
1289
+ }
1290
+ const gate = { armed: false };
1291
+ let calls = 0;
1292
+ const actor = makeActor(machine, entry === undefined
1293
+ ? () => null
1294
+ : onceScript(entry.captain.binding.sourceItem, entry.output, gate), (actorInput, invokedActorId) => {
1295
+ if (!gate.armed ||
1296
+ calls > 0 ||
1297
+ invokedActorId !== actorId ||
1298
+ actorInput.stateId !== playbook.ref.stableId ||
1299
+ (dynamic !== undefined &&
1300
+ (actorInput.playbookId !== COVERAGE_PLAYBOOK_ID ||
1301
+ actorInput.text !== COVERAGE_PLAYBOOK_INPUT))) {
1302
+ return null;
1303
+ }
1304
+ calls++;
1305
+ return scriptedResult;
1306
+ }, drive.context);
1307
+ gate.armed = true;
1308
+ actor.send(drive.event);
1309
+ let enteredCall = false;
1310
+ const settled = await settle(actor, (snapshot) => {
1311
+ if (atState(playbook.ref)(snapshot))
1312
+ enteredCall = true;
1313
+ return ((calls === 1 && atState(target)(snapshot)) ||
1314
+ (calls === 0 && actor.coverageErrors.length > 0) ||
1315
+ (enteredCall && calls === 0 && leftState(playbook.ref)(snapshot)));
1316
+ });
1317
+ const finalSnapshot = actor.getSnapshot();
1318
+ const observedStartFailure = actor.coverageErrors.find(({ eventType }) => eventType === `xstate.error.actor.${actorId}` || calls === 0);
1319
+ const inputFailure = calls === 0
1320
+ ? observedStartFailure === undefined
1321
+ ? finalSnapshot.status === 'error'
1322
+ ? coverageErrorMessage(finalSnapshot.error)
1323
+ : playbookInputFailure(playbook, finalSnapshot.context)
1324
+ : coverageErrorMessage(observedStartFailure.error)
1325
+ : undefined;
1326
+ actor.stop();
1327
+ if (inputFailure !== undefined) {
1328
+ findings.push(`state ${playbook.ref.stableId}: nested playbook actor failed to start during ${outcome} coverage: ${inputFailure}`);
1329
+ continue;
1330
+ }
1331
+ if (!settled || calls !== 1 || !atState(target)(finalSnapshot)) {
1332
+ findings.push(`state ${playbook.ref.stableId}: nested playbook ${outcome} arm ${armIndex} did not reach ${target.stableId}`);
1333
+ }
1334
+ }
1335
+ return findings;
1336
+ }
1337
+ async function probePlaybookInvocation(machine, playbook, refs, captains, interruptValues) {
1338
+ return [
1339
+ ...(await probePlaybookOutcome(machine, playbook, refs, captains, 'onDone', interruptValues)),
1340
+ ...(await probePlaybookOutcome(machine, playbook, refs, captains, 'onError', interruptValues)),
1341
+ ];
1342
+ }
1343
+ async function probeParallelQuestions(machine, parallel, refs, captains, interruptValues) {
1344
+ const branches = parallelBranchCaptains(parallel, refs, captains);
1345
+ const plans = branches.flatMap((captain) => {
1346
+ if (captain.binding.result[NEEDS_BOSS_REPLY] === undefined)
1347
+ return [];
1348
+ const output = synthOutput(captain.binding, NEEDS_BOSS_REPLY);
1349
+ const wait = directTargetRef(machine, refs, captain, output);
1350
+ if (wait === undefined ||
1351
+ (wait.stableId !== AWAIT_BOSS_REPLY_STATE &&
1352
+ !tagsOf(wait.state).includes('playbook.parked'))) {
1353
+ return [];
1354
+ }
1355
+ return [{ captain, output, wait }];
1356
+ });
1357
+ if (plans.length < 2)
1358
+ return [];
1359
+ const drive = interruptDriveForRef(machine, refs, parallel, parallel.stableId, interruptValues);
1360
+ const { script, calls } = scriptedOutputs(plans);
1361
+ const actor = makeActor(machine, script, () => null, drive.context);
1362
+ actor.send(drive.event);
1363
+ const parked = await settle(actor, (snapshot) => plans.every(({ wait }) => atState(wait)(snapshot)), PARALLEL_QUESTION_SETTLE_MS);
1364
+ if (!parked) {
1365
+ actor.stop();
1366
+ return [
1367
+ `parallel state ${parallel.stableId}: branch questions did not become simultaneously pending`,
1368
+ ];
1369
+ }
1370
+ const [selected, ...others] = plans;
1371
+ actor.send({
1372
+ type: BOSS_REPLY_EVENT,
1373
+ questionId: captainPublicStateId(selected.captain),
1374
+ answer: 'Continue only this branch.',
1375
+ });
1376
+ const isolated = await settle(actor, (snapshot) => callCount(calls, selected.captain) >= 2 &&
1377
+ atState(selected.captain.ref)(snapshot) &&
1378
+ others.every(({ wait }) => atState(wait)(snapshot)), PARALLEL_QUESTION_SETTLE_MS);
1379
+ actor.stop();
1380
+ return isolated
1381
+ ? []
1382
+ : [
1383
+ `parallel state ${parallel.stableId}: a keyed Boss reply did not resume exactly one pending branch`,
1384
+ ];
1385
+ }
1386
+ function combinations(lists, limit = MAX_PARALLEL_COMBINATIONS) {
1387
+ let out = [[]];
1388
+ for (const list of lists) {
1389
+ out = out.flatMap((prefix) => list.map((item) => [...prefix, item]));
1390
+ if (out.length > limit)
1391
+ return out.slice(0, limit);
1392
+ }
1393
+ return out;
1394
+ }
1395
+ async function probeParallelJoins(machine, parallel, refs, captains, interruptValues) {
1396
+ const arms = transitionArms(parallel.state.onDone);
1397
+ if (arms.length === 0)
1398
+ return [];
1399
+ const branches = parallelBranchCaptains(parallel, refs, captains);
1400
+ if (branches.length < 2) {
1401
+ return [
1402
+ `parallel state ${parallel.stableId}: onDone join coverage is unsupported without one Captain leaf per branch`,
1403
+ ];
1404
+ }
1405
+ const branchOutputs = branches.map((captain) => Object.keys(captain.binding.result).flatMap((key) => {
1406
+ if (key === NEEDS_BOSS_REPLY)
1407
+ return [];
1408
+ const output = synthOutput(captain.binding, key);
1409
+ const target = directTargetRef(machine, refs, captain, output);
1410
+ return target?.state.type === 'final' ? [output] : [];
1411
+ }));
1412
+ if (branchOutputs.some((outputs) => outputs.length === 0)) {
1413
+ return [
1414
+ `parallel state ${parallel.stableId}: onDone join coverage is unsupported without a final-reaching branch result`,
1415
+ ];
1416
+ }
1417
+ const outputs = combinations(branchOutputs);
1418
+ const drive = interruptDriveForRef(machine, refs, parallel, parallel.stableId, interruptValues);
1419
+ const normalizedTargets = arms.map((arm) => rawArmTarget(arm) ?? null);
1420
+ const findings = [];
1421
+ for (const [armIndex, rawTarget] of normalizedTargets.entries()) {
1422
+ if (rawTarget === null) {
1423
+ findings.push(`parallel state ${parallel.stableId}: onDone join arm ${armIndex} coverage is unsupported for a target-less arm`);
1424
+ continue;
1425
+ }
1426
+ const duplicateTarget = normalizedTargets.some((candidate, index) => index !== armIndex && candidate === rawTarget);
1427
+ const target = stateRefForTarget(refs, rawTarget, parallel);
1428
+ if (duplicateTarget || target === undefined) {
1429
+ findings.push(`parallel state ${parallel.stableId}: onDone join arm ${armIndex} coverage is unsupported because its target is not uniquely observable`);
1430
+ continue;
1431
+ }
1432
+ let exercised = false;
1433
+ for (const combination of outputs) {
1434
+ const entries = branches.map((captain, index) => ({
1435
+ captain,
1436
+ output: combination[index],
1437
+ }));
1438
+ const { script, calls } = scriptedOutputs(entries);
1439
+ const actor = makeActor(machine, script, () => null, drive.context);
1440
+ actor.send(drive.event);
1441
+ exercised = await settle(actor, (snapshot) => sameStateRef(target, parallel)
1442
+ ? branches.every((captain) => callCount(calls, captain) >= 2) &&
1443
+ branches.every((captain) => atState(captain.ref)(snapshot))
1444
+ : atState(target)(snapshot), PARALLEL_SETTLE_MS);
1445
+ actor.stop();
1446
+ if (exercised)
1447
+ break;
1448
+ }
1449
+ if (!exercised) {
1450
+ findings.push(`parallel state ${parallel.stableId}: onDone join arm ${armIndex} could not be exercised under bounded branch-result probing`);
1451
+ }
1452
+ }
1453
+ return findings;
1454
+ }
1455
+ /**
1456
+ * Derives a conservative Vitest timeout from the same bounded work performed
1457
+ * by {@link checkFsmCoverage}. It includes every possible async settle window,
1458
+ * the bounded parallel-combination surface, an allowance for each worst-case
1459
+ * guard search, and a final scheduling margin. Generated tests use this instead
1460
+ * of Vitest's five-second default.
1461
+ */
1462
+ export function fsmCoverageTestTimeout(fsmModule) {
1463
+ const machine = findMachine(fsmModule);
1464
+ const config = machine.config;
1465
+ const states = (config.states ?? {});
1466
+ const refs = stateRefs(config);
1467
+ const captains = captainRefs(config);
1468
+ const playbooks = playbookRefs(config);
1469
+ const parallels = refs.filter((ref) => ref.state.type === 'parallel');
1470
+ const rootInterruptProbes = transitionArms((config.on ?? {})[INTERRUPT_EVENT]).length;
1471
+ const rootSettles = rootInterruptProbes * SETTLE_MS;
1472
+ const initial = typeof config.initial === 'string' ? config.initial : undefined;
1473
+ const entryEvents = new Set([
1474
+ ...Object.keys(initial === undefined ? {} : (states[initial]?.on ?? {})),
1475
+ ...Object.keys(config.on ?? {}),
1476
+ ]);
1477
+ entryEvents.delete(INTERRUPT_EVENT);
1478
+ const entrySettles = entryEvents.size * SETTLE_MS;
1479
+ let captainSettles = 0;
1480
+ let guardProbeCalls = rootInterruptProbes;
1481
+ for (const captain of captains) {
1482
+ const resultKeys = Object.keys(captain.binding.result);
1483
+ captainSettles +=
1484
+ resultKeys.reduce((total, key) => total + (key === NEEDS_BOSS_REPLY ? 4 : 1), 0) * SETTLE_MS;
1485
+ // One directly selectable onError arm is driven.
1486
+ captainSettles += SETTLE_MS;
1487
+ const guardedDoneArms = transitionArms(captain.invocation.onDone).filter((arm) => armGuard(arm) !== undefined).length;
1488
+ const errorArms = transitionArms(captain.invocation.onError).length;
1489
+ // Result acceptance probes each guarded arm once, then the arm audit probes
1490
+ // every result's structured and bare output forms.
1491
+ guardProbeCalls += resultKeys.length * guardedDoneArms * 3 + errorArms;
1492
+ // Every result, the blank Boss-reply check, and onError enter through an
1493
+ // independently context-probed interrupt plan.
1494
+ guardProbeCalls +=
1495
+ resultKeys.length + (resultKeys.includes(NEEDS_BOSS_REPLY) ? 1 : 0) + 1;
1496
+ }
1497
+ guardProbeCalls += playbooks.reduce((total, playbook) => total +
1498
+ transitionArms(playbook.invocation.onDone).length +
1499
+ transitionArms(playbook.invocation.onError).length, 0);
1500
+ // Nested success/error and parallel question/join helpers each reuse one
1501
+ // context-probed entry plan per state and outcome.
1502
+ guardProbeCalls += 2 * playbooks.length + 2 * parallels.length;
1503
+ const playbookSettles = playbooks.reduce((total, playbook) => total +
1504
+ (transitionArms(playbook.invocation.onDone).length +
1505
+ transitionArms(playbook.invocation.onError).length) *
1506
+ SETTLE_MS, 0);
1507
+ const parallelSettles = parallels.reduce((total, parallel) => total +
1508
+ 2 * PARALLEL_QUESTION_SETTLE_MS +
1509
+ transitionArms(parallel.state.onDone).length *
1510
+ MAX_PARALLEL_COMBINATIONS *
1511
+ PARALLEL_SETTLE_MS, 0);
1512
+ const guardAllowance = guardProbeCalls * Math.ceil(MAX_PROBES / PROBES_PER_TIMEOUT_MILLISECOND);
1513
+ return Math.max(MIN_COVERAGE_TEST_TIMEOUT_MS, rootSettles +
1514
+ entrySettles +
1515
+ captainSettles +
1516
+ playbookSettles +
1517
+ parallelSettles +
1518
+ guardAllowance +
1519
+ COVERAGE_TIMEOUT_MARGIN_MS);
1520
+ }
1521
+ /**
1522
+ * Checks transition coverage over a compiled `playbook` artifact's machine
1523
+ * (VERIFY-6) and returns findings (empty when every declared transition is
1524
+ * reachable). Drives the machine through the `gears2fsm` surfaces it
1525
+ * declares; a workflow without pre-emption may omit the `BOSS_INTERRUPT`
1526
+ * surface entirely, in which case interrupt coverage is skipped.
1527
+ */
1528
+ export async function checkFsmCoverage(fsmModule, opts = {}) {
1529
+ const findings = [];
1530
+ const machine = findMachine(fsmModule);
1531
+ const config = machine.config;
1532
+ const states = (config.states ?? {});
1533
+ const refs = stateRefs(config);
1534
+ const captains = captainRefs(config);
1535
+ const playbooks = playbookRefs(config);
1536
+ const captainByRef = new Map(captains.map((captain) => [stateRefKey(captain.ref), captain]));
1537
+ const sourceCandidates = identifierLiterals(opts.sourceText ?? '');
1538
+ const parallelRefs = refs.filter((ref) => ref.state.type === 'parallel');
1539
+ for (const ref of parallelRefs) {
1540
+ if (!normalizeArms(ref.state.onDone).some((arm) => arm.target !== null)) {
1541
+ findings.push(`parallel state ${ref.stableId} declares no onDone join`);
1542
+ }
1543
+ }
1544
+ for (const ref of refs.filter((candidate) => candidate.key === 'failed' ||
1545
+ candidate.configId === 'failed' ||
1546
+ candidate.stableId === 'failed')) {
1547
+ if (ref.state.meta?.playbook !== undefined &&
1548
+ !tagsOf(ref.state).includes('playbook.parked')) {
1549
+ findings.push(`recoverable failure state ${ref.stableId} lacks playbook.parked tag`);
1550
+ }
1551
+ }
1552
+ const finalStates = Object.entries(states).filter(([, state]) => state.type === 'final');
1553
+ if (finalStates.length === 0) {
1554
+ findings.push('machine declares no final state');
1555
+ }
1556
+ const rootArms = normalizeArms((config.on ?? {})[INTERRUPT_EVENT]);
1557
+ const canJump = rootArms.length > 0;
1558
+ // A workflow without pre-emption declares no interrupt surface at all
1559
+ // (gears2fsm.md "Boss entry events vs. BOSS_INTERRUPT"); only a machine
1560
+ // that handles the event somewhere but not at the root is malformed.
1561
+ // Prompt text merely mentioning the event name does not count.
1562
+ const handlesInterruptSomewhere = refs.some((ref) => Object.hasOwn(ref.state.on ?? {}, INTERRUPT_EVENT));
1563
+ if (!canJump && handlesInterruptSomewhere) {
1564
+ findings.push(`machine declares no root ${INTERRUPT_EVENT} event`);
1565
+ }
1566
+ if (canJump) {
1567
+ for (const playbook of playbooks) {
1568
+ findings.push(...(await probePlaybookInvocation(machine, playbook, refs, captains, sourceCandidates)));
1569
+ }
1570
+ for (const parallel of parallelRefs) {
1571
+ findings.push(...(await probeParallelQuestions(machine, parallel, refs, captains, sourceCandidates)), ...(await probeParallelJoins(machine, parallel, refs, captains, sourceCandidates)));
1572
+ }
1573
+ }
1574
+ const waitStates = refs.filter((ref) => ref.stableId === AWAIT_BOSS_REPLY_STATE ||
1575
+ (tagsOf(ref.state).includes('playbook.parked') &&
1576
+ ref.state.on?.[BOSS_REPLY_EVENT] !== undefined));
1577
+ if (waitStates.length === 0) {
1578
+ findings.push(`machine declares no ${AWAIT_BOSS_REPLY_STATE} state or branch-local Boss-reply wait state`);
1579
+ }
1580
+ // Every BOSS_INTERRUPT target is enterable (the captain hangs, so entering a
1581
+ // captain state parks in it).
1582
+ if (canJump) {
1583
+ for (const [armIndex, arm] of rootArms.entries()) {
1584
+ if (arm.target === null)
1585
+ continue;
1586
+ const target = stateRefForTarget(refs, arm.target);
1587
+ const targetPlaybook = playbooks.find((playbook) => target !== undefined && sameStateRef(playbook.ref, target));
1588
+ const targetCaptain = target === undefined
1589
+ ? undefined
1590
+ : captainByRef.get(stateRefKey(target));
1591
+ const targetId = targetCaptain === undefined
1592
+ ? (target?.stableId ?? arm.target)
1593
+ : captainPublicStateId(targetCaptain);
1594
+ if (target === undefined) {
1595
+ findings.push(`${INTERRUPT_EVENT} target ${arm.target} is not enterable`);
1596
+ continue;
1597
+ }
1598
+ const drive = interruptDriveForRef(machine, refs, target, targetId, sourceCandidates, armIndex);
1599
+ if (!drive.satisfiable) {
1600
+ findings.push(`${INTERRUPT_EVENT} target ${arm.target} is unsatisfiable under context/event probing`);
1601
+ continue;
1602
+ }
1603
+ const actor = makeActor(machine, () => null, () => null, drive.context);
1604
+ actor.send(drive.event);
1605
+ const entered = await settle(actor, atState(target));
1606
+ if (!entered &&
1607
+ !(targetPlaybook !== undefined && actor.coverageErrors.length > 0)) {
1608
+ findings.push(`${INTERRUPT_EVENT} target ${arm.target} is not enterable`);
1609
+ }
1610
+ actor.stop();
1611
+ }
1612
+ }
1613
+ // Guard-free root entry events transition from the initial state.
1614
+ const initial = typeof config.initial === 'string' ? config.initial : null;
1615
+ const initialRef = initial === null
1616
+ ? undefined
1617
+ : refs.find((ref) => ref.path.length === 1 && ref.key === initial);
1618
+ const entryArms = {
1619
+ ...(initial !== null ? (states[initial]?.on ?? {}) : {}),
1620
+ ...(config.on ?? {}),
1621
+ };
1622
+ for (const [event, raw] of Object.entries(entryArms)) {
1623
+ if (event === INTERRUPT_EVENT)
1624
+ continue;
1625
+ const arms = normalizeArms(raw);
1626
+ const free = arms.find((arm) => !arm.guarded && arm.target !== null && arm.target !== initial);
1627
+ if (free === undefined)
1628
+ continue;
1629
+ const actor = makeActor(machine, () => null);
1630
+ actor.send({ type: event });
1631
+ if (initialRef === undefined ||
1632
+ !(await settle(actor, leftState(initialRef)))) {
1633
+ findings.push(`root event ${event} fired no transition`);
1634
+ }
1635
+ actor.stop();
1636
+ }
1637
+ const stateCandidates = refs.flatMap((ref) => [
1638
+ ref.key,
1639
+ ref.stableId,
1640
+ ...(ref.configId === undefined ? [] : [ref.configId]),
1641
+ ]);
1642
+ for (const captain of captains) {
1643
+ if (!canJump)
1644
+ break;
1645
+ const state = captain.binding;
1646
+ const stateKey = state.stateId;
1647
+ const stateId = captainPublicStateId(captain);
1648
+ const candidates = [...stateCandidates, ...sourceCandidates];
1649
+ const doneEvent = invocationEvent(machine, captain, 'done');
1650
+ const errorEvent = invocationEvent(machine, captain, 'error');
1651
+ const rawDoneArms = transitionArms(captain.invocation.onDone);
1652
+ const onDoneArms = normalizeArms(captain.invocation.onDone);
1653
+ // Every declared result needs an arm that explicitly accepts its complete
1654
+ // valid output. A sole unguarded arm accepts the whole local result
1655
+ // contract; an array's unguarded arm is a fallback and cannot make an
1656
+ // otherwise orphaned key look covered.
1657
+ for (const key of Object.keys(state.result)) {
1658
+ const output = synthOutput(state, key);
1659
+ const accepting = new Set();
1660
+ for (const [index, arm] of rawDoneArms.entries()) {
1661
+ const target = onDoneArms[index]?.target ?? null;
1662
+ const rawGuard = armGuard(arm);
1663
+ if (rawGuard === undefined) {
1664
+ if (rawDoneArms.length === 1 && target !== null)
1665
+ accepting.add(index);
1666
+ continue;
1667
+ }
1668
+ if (target === null || resolveGuard(machine, rawGuard) === undefined) {
1669
+ continue;
1670
+ }
1671
+ const guard = orderedArmPredicate(machine, rawDoneArms, index);
1672
+ if (guard !== undefined &&
1673
+ doneGuardSatisfiable(guard, doneEvent, output, candidates)) {
1674
+ accepting.add(index);
1675
+ }
1676
+ }
1677
+ if (accepting.size === 0) {
1678
+ findings.push(`state ${stateKey}: result "${key}" has no reachable accepting transition`);
1679
+ continue;
1680
+ }
1681
+ // Drive only when the first arm XState would inspect under the real
1682
+ // initial context is a known accepting arm. Encountering an unresolved
1683
+ // guard first makes driving unsafe: XState reports that error
1684
+ // asynchronously, so the arm audit below owns the finding (c887fc4).
1685
+ let directArm;
1686
+ let safeToDrive = true;
1687
+ for (const [index, arm] of rawDoneArms.entries()) {
1688
+ const rawGuard = armGuard(arm);
1689
+ if (rawGuard === undefined) {
1690
+ directArm = index;
1691
+ break;
1692
+ }
1693
+ const guard = resolveGuard(machine, rawGuard);
1694
+ if (guard === undefined) {
1695
+ safeToDrive = false;
1696
+ break;
1697
+ }
1698
+ try {
1699
+ if (guard.run({
1700
+ context: initializedCoverageContext(machine),
1701
+ event: { ...doneEvent, output },
1702
+ })) {
1703
+ directArm = index;
1704
+ break;
1705
+ }
1706
+ }
1707
+ catch {
1708
+ safeToDrive = false;
1709
+ break;
1710
+ }
1711
+ }
1712
+ if (!safeToDrive ||
1713
+ directArm === undefined ||
1714
+ !accepting.has(directArm)) {
1715
+ continue;
1716
+ }
1717
+ const gate = { armed: false };
1718
+ const probe = captainProbeActor(machine, captain, output, gate, refs, captains, playbooks, sourceCandidates);
1719
+ const actor = probe.actor;
1720
+ gate.armed = true;
1721
+ actor.send(probe.event);
1722
+ const left = await settle(actor, leftState(captain.ref));
1723
+ if (!left) {
1724
+ findings.push(`state ${stateKey}: result "${key}" fired no transition`);
1725
+ }
1726
+ else if (key === NEEDS_BOSS_REPLY) {
1727
+ const waitTarget = rawArmTarget(rawDoneArms[directArm]) ?? onDoneArms[directArm]?.target;
1728
+ const waitRef = waitTarget === null || waitTarget === undefined
1729
+ ? undefined
1730
+ : stateRefForTarget(refs, waitTarget, captain.ref);
1731
+ if (waitRef === undefined ||
1732
+ (waitRef.stableId !== AWAIT_BOSS_REPLY_STATE &&
1733
+ !tagsOf(waitRef.state).includes('playbook.parked')) ||
1734
+ !(await settle(actor, atState(waitRef)))) {
1735
+ findings.push(`state ${stateKey}: ${NEEDS_BOSS_REPLY} did not suspend in ${AWAIT_BOSS_REPLY_STATE} or a branch-local Boss-reply wait state`);
1736
+ actor.stop();
1737
+ continue;
1738
+ }
1739
+ if (waitRef.stableId !== AWAIT_BOSS_REPLY_STATE) {
1740
+ actor.send({
1741
+ type: BOSS_REPLY_EVENT,
1742
+ questionId: 'coverage-unknown-question',
1743
+ answer: 'This answer belongs to no pending branch.',
1744
+ });
1745
+ await settleNoTransition();
1746
+ if (!atState(waitRef)(actor.getSnapshot())) {
1747
+ findings.push(`state ${stateKey}: an unknown ${BOSS_REPLY_EVENT} questionId moved the branch`);
1748
+ actor.stop();
1749
+ continue;
1750
+ }
1751
+ }
1752
+ // Boss-reply resume: BOSS_REPLY returns to the suspended state, and a
1753
+ // blank answer must not resume it.
1754
+ actor.send({
1755
+ type: BOSS_REPLY_EVENT,
1756
+ questionId: stateId,
1757
+ answer: 'Proceed as planned.',
1758
+ });
1759
+ if (!(await settle(actor, atState(captain.ref)))) {
1760
+ findings.push(`state ${stateKey}: ${BOSS_REPLY_EVENT} did not resume the suspended state`);
1761
+ }
1762
+ actor.stop();
1763
+ const blankGate = { armed: false };
1764
+ const blankProbe = captainProbeActor(machine, captain, synthOutput(state, NEEDS_BOSS_REPLY), blankGate, refs, captains, playbooks, sourceCandidates);
1765
+ const blank = blankProbe.actor;
1766
+ blankGate.armed = true;
1767
+ blank.send(blankProbe.event);
1768
+ if (await settle(blank, atState(waitRef))) {
1769
+ blank.send({
1770
+ type: BOSS_REPLY_EVENT,
1771
+ questionId: stateId,
1772
+ answer: ' ',
1773
+ });
1774
+ await settleNoTransition();
1775
+ if (atState(captain.ref)(blank.getSnapshot())) {
1776
+ findings.push(`state ${stateKey}: a blank ${BOSS_REPLY_EVENT} answer must not resume the state`);
1777
+ }
1778
+ }
1779
+ blank.stop();
1780
+ continue;
1781
+ }
1782
+ actor.stop();
1783
+ }
1784
+ // Every onDone arm is satisfiable under the actual done-event identity.
1785
+ // Try each key's full output and bare malformed form; neither probe may
1786
+ // invent a different event type or actor id.
1787
+ for (const [index, arm] of rawDoneArms.entries()) {
1788
+ const rawGuard = armGuard(arm);
1789
+ if (rawGuard === undefined)
1790
+ continue;
1791
+ const declaredGuard = resolveGuard(machine, rawGuard);
1792
+ if (declaredGuard === undefined) {
1793
+ findings.push(`state ${stateKey}: onDone arm ${index} names an unresolvable guard "${guardLabel(rawGuard)}"`);
1794
+ continue;
1795
+ }
1796
+ const guard = orderedArmPredicate(machine, rawDoneArms, index);
1797
+ // A prior unresolvable arm already owns the actionable finding, and makes
1798
+ // ordered reachability of later arms unsafe to evaluate.
1799
+ if (guard === undefined)
1800
+ continue;
1801
+ const anyOutput = Object.keys(state.result).some((key) => doneGuardSatisfiable(guard, doneEvent, synthOutput(state, key), candidates) ||
1802
+ doneGuardSatisfiable(guard, doneEvent, { guard: key }, candidates));
1803
+ if (!anyOutput) {
1804
+ findings.push(`state ${stateKey}: onDone arm ${index} (target ${onDoneArms[index]?.target ?? 'none'}) is unsatisfiable under probing`);
1805
+ }
1806
+ }
1807
+ // Audit every onError arm under the real error-event identity. Guarded arms
1808
+ // are probed deterministically; one directly selected arm is also driven to
1809
+ // confirm XState lands on its declared target.
1810
+ const rawErrorArms = transitionArms(captain.invocation.onError);
1811
+ const onErrorArms = normalizeArms(captain.invocation.onError);
1812
+ if (onErrorArms.length === 0) {
1813
+ findings.push(`state ${stateKey} declares no onError transition`);
1814
+ continue;
1815
+ }
1816
+ const forcedError = new Error('coverage: forced captain failure');
1817
+ let hasUnresolvableErrorGuard = false;
1818
+ for (const [index, arm] of rawErrorArms.entries()) {
1819
+ const rawGuard = armGuard(arm);
1820
+ if (rawGuard !== undefined) {
1821
+ const declaredGuard = resolveGuard(machine, rawGuard);
1822
+ if (declaredGuard === undefined) {
1823
+ hasUnresolvableErrorGuard = true;
1824
+ findings.push(`state ${stateKey}: onError arm ${index} names an unresolvable guard "${guardLabel(rawGuard)}"`);
1825
+ continue;
1826
+ }
1827
+ }
1828
+ const guard = orderedArmPredicate(machine, rawErrorArms, index);
1829
+ if (guard === undefined)
1830
+ continue;
1831
+ if (!errorGuardSatisfiable(guard, errorEvent, forcedError, candidates)) {
1832
+ findings.push(`state ${stateKey}: onError arm ${index} (target ${onErrorArms[index]?.target ?? 'none'}) is unsatisfiable under probing`);
1833
+ }
1834
+ }
1835
+ if (hasUnresolvableErrorGuard)
1836
+ continue;
1837
+ let directErrorArm;
1838
+ let errorDriveSafe = true;
1839
+ for (const [index, arm] of rawErrorArms.entries()) {
1840
+ const rawGuard = armGuard(arm);
1841
+ if (rawGuard === undefined) {
1842
+ directErrorArm = index;
1843
+ break;
1844
+ }
1845
+ const guard = resolveGuard(machine, rawGuard);
1846
+ if (guard === undefined) {
1847
+ errorDriveSafe = false;
1848
+ break;
1849
+ }
1850
+ try {
1851
+ if (guard.run({
1852
+ context: initializedCoverageContext(machine),
1853
+ event: { ...errorEvent, error: forcedError },
1854
+ })) {
1855
+ directErrorArm = index;
1856
+ break;
1857
+ }
1858
+ }
1859
+ catch {
1860
+ errorDriveSafe = false;
1861
+ break;
1862
+ }
1863
+ }
1864
+ if (!errorDriveSafe || directErrorArm === undefined)
1865
+ continue;
1866
+ const target = onErrorArms[directErrorArm]?.target ?? null;
1867
+ const rawTarget = rawArmTarget(rawErrorArms[directErrorArm]) ?? target;
1868
+ const targetRef = rawTarget === null
1869
+ ? undefined
1870
+ : stateRefForTarget(refs, rawTarget, captain.ref);
1871
+ const gate = { armed: false };
1872
+ const probe = captainProbeActor(machine, captain, forcedError, gate, refs, captains, playbooks, sourceCandidates);
1873
+ const actor = probe.actor;
1874
+ gate.armed = true;
1875
+ actor.send(probe.event);
1876
+ const landed = await settle(actor, target !== null && targetRef !== undefined
1877
+ ? atState(targetRef)
1878
+ : leftState(captain.ref));
1879
+ if (!landed || (target !== null && targetRef === undefined)) {
1880
+ findings.push(`state ${stateKey}: onError arm ${directErrorArm} did not reach ${target ?? 'a quiescent state'}`);
1881
+ }
1882
+ actor.stop();
1883
+ }
1884
+ return findings;
1885
+ }
1886
+ /**
1887
+ * Builds a per-artifact vitest module running the transition-coverage check
1888
+ * beside the artifacts (VERIFY-6).
1889
+ */
1890
+ export function generateFsmCoverageTest(opts) {
1891
+ const commentBasename = JSON.stringify(opts.basename)
1892
+ .replaceAll('\u2028', '\\u2028')
1893
+ .replaceAll('\u2029', '\\u2029');
1894
+ const fsmModule = JSON.stringify(opts.fsmModule);
1895
+ const fsmSourceFile = JSON.stringify(opts.fsmSourceFile);
1896
+ const verifyModule = JSON.stringify(opts.verifyModule);
1897
+ const suiteName = JSON.stringify(`${opts.basename}: FSM coverage`);
1898
+ return `// SPDX-License-Identifier: Apache-2.0
1899
+ // SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
1900
+
1901
+ // Generated by slc (DR-009): FSM transition coverage for ${commentBasename}.
1902
+ import { readFileSync } from 'node:fs';
1903
+ import { fileURLToPath } from 'node:url';
1904
+
1905
+ import { describe, expect, it } from 'vitest';
1906
+
1907
+ import { checkFsmCoverage, fsmCoverageTestTimeout } from ${verifyModule};
1908
+ import * as fsm from ${fsmModule};
1909
+
1910
+ describe(${suiteName}, () => {
1911
+ it('reaches every declared transition', async () => {
1912
+ const sourceText = readFileSync(
1913
+ fileURLToPath(new URL(${fsmSourceFile}, import.meta.url)),
1914
+ 'utf8',
1915
+ );
1916
+ expect(await checkFsmCoverage(fsm, { sourceText })).toEqual([]);
1917
+ }, fsmCoverageTestTimeout(fsm));
1918
+ });
1919
+ `;
1920
+ }
1921
+ /**
1922
+ * Emits the transition-coverage test beside a compiled `playbook` artifact
1923
+ * (VERIFY-6): validates the produced `fsm` drives cleanly, then writes
1924
+ * `<basename>.fsm.coverage.test.ts` and returns its path with any coverage
1925
+ * findings as diagnostics.
1926
+ *
1927
+ * @throws when the `fsm` artifact cannot be imported or exports no machine.
1928
+ */
1929
+ export async function emitFsmCoverageTest(opts) {
1930
+ const fsmPath = join(opts.artifactDir, `${opts.basename}.fsm.ts`);
1931
+ const module = await loadFsmModule(fsmPath);
1932
+ const findings = await checkFsmCoverage(module, {
1933
+ sourceText: await readFile(fsmPath, 'utf8'),
1934
+ });
1935
+ const content = generateFsmCoverageTest({
1936
+ basename: opts.basename,
1937
+ fsmModule: `./${opts.basename}.fsm.js`,
1938
+ fsmSourceFile: `./${opts.basename}.fsm.ts`,
1939
+ verifyModule: opts.verifyModule ?? VERIFY_MODULE,
1940
+ });
1941
+ await mkdir(opts.artifactDir, { recursive: true });
1942
+ const path = join(opts.artifactDir, `${opts.basename}.fsm.coverage.test.ts`);
1943
+ await writeFile(path, content);
1944
+ return {
1945
+ path,
1946
+ diagnostics: findings.map((finding) => `fsm coverage: ${finding}`),
1947
+ };
1948
+ }
1949
+ //# sourceMappingURL=verify-coverage.js.map