@sublang/slc 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +43 -14
- package/dist/app.d.ts +2 -2
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +6 -4
- package/dist/app.js.map +1 -1
- package/dist/artifacts.d.ts +5 -1
- package/dist/artifacts.d.ts.map +1 -1
- package/dist/artifacts.js +19 -3
- package/dist/artifacts.js.map +1 -1
- package/dist/clarification.d.ts +32 -0
- package/dist/clarification.d.ts.map +1 -0
- package/dist/clarification.js +93 -0
- package/dist/clarification.js.map +1 -0
- package/dist/compiled-execution.js +1 -1
- package/dist/compiled-execution.js.map +1 -1
- package/dist/compiled-executor.d.ts.map +1 -1
- package/dist/compiled-executor.js +58 -22
- package/dist/compiled-executor.js.map +1 -1
- package/dist/config-file.d.ts.map +1 -1
- package/dist/config-file.js.map +1 -1
- package/dist/config.d.ts +1 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +1 -1
- package/dist/config.js.map +1 -1
- package/dist/entry-module.d.ts.map +1 -1
- package/dist/entry-module.js +16 -7
- package/dist/entry-module.js.map +1 -1
- package/dist/entry-options.d.ts +13 -0
- package/dist/entry-options.d.ts.map +1 -0
- package/dist/entry-options.js +116 -0
- package/dist/entry-options.js.map +1 -0
- package/dist/execution.d.ts +21 -4
- package/dist/execution.d.ts.map +1 -1
- package/dist/execution.js +58 -19
- package/dist/execution.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/interpreter.d.ts.map +1 -1
- package/dist/interpreter.js +20 -1
- package/dist/interpreter.js.map +1 -1
- package/dist/invocation.d.ts.map +1 -1
- package/dist/link.d.ts.map +1 -1
- package/dist/phase-runner.d.ts +1 -4
- package/dist/phase-runner.d.ts.map +1 -1
- package/dist/phase-runner.js +4 -1
- package/dist/phase-runner.js.map +1 -1
- package/dist/phase.d.ts.map +1 -1
- package/dist/phase.js.map +1 -1
- package/dist/pin-currency.js.map +1 -1
- package/dist/pin-generate.js.map +1 -1
- package/dist/pin-inputs.js.map +1 -1
- package/dist/pins.d.ts.map +1 -1
- package/dist/pins.js.map +1 -1
- package/dist/pipeline.d.ts.map +1 -1
- package/dist/playbook-contract.d.ts.map +1 -1
- package/dist/playbook-contract.js.map +1 -1
- package/dist/playbook-ports.d.ts +9 -1
- package/dist/playbook-ports.d.ts.map +1 -1
- package/dist/playbook-ports.js +24 -6
- package/dist/playbook-ports.js.map +1 -1
- package/dist/progress.js.map +1 -1
- package/dist/resolver.js.map +1 -1
- package/dist/reviewing-agent.d.ts +2 -2
- package/dist/reviewing-agent.d.ts.map +1 -1
- package/dist/reviewing-agent.js +34 -12
- package/dist/reviewing-agent.js.map +1 -1
- package/dist/runner.d.ts +4 -2
- package/dist/runner.d.ts.map +1 -1
- package/dist/runner.js +361 -45
- package/dist/runner.js.map +1 -1
- package/dist/slc.config.template.yaml +1 -1
- package/dist/verify-coverage.d.ts +7 -0
- package/dist/verify-coverage.d.ts.map +1 -1
- package/dist/verify-coverage.js +837 -251
- package/dist/verify-coverage.js.map +1 -1
- package/dist/verify-source.d.ts.map +1 -1
- package/dist/verify-source.js +61 -20
- package/dist/verify-source.js.map +1 -1
- package/dist/verify-support.js.map +1 -1
- package/dist/verify-typescript.d.ts +3 -0
- package/dist/verify-typescript.d.ts.map +1 -0
- package/dist/verify-typescript.js +87 -0
- package/dist/verify-typescript.js.map +1 -0
- package/dist/verify.d.ts +28 -10
- package/dist/verify.d.ts.map +1 -1
- package/dist/verify.js +624 -129
- package/dist/verify.js.map +1 -1
- package/package.json +17 -16
package/dist/verify-coverage.js
CHANGED
|
@@ -19,12 +19,77 @@
|
|
|
19
19
|
* The checker needs only the artifact and `xstate`; the emitted per-artifact
|
|
20
20
|
* test runs it beside the artifacts. See specs/packages/verification.md.
|
|
21
21
|
*/
|
|
22
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
22
23
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
23
24
|
import { join } from 'node:path';
|
|
25
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
24
26
|
import { createActor, fromPromise } from 'xstate';
|
|
25
|
-
import { AWAIT_BOSS_REPLY_STATE, BOSS_REPLY_EVENT, CONTROLLER_ACTION_GUARDS, INTERRUPT_EVENT, NEEDS_BOSS_REPLY, VERIFY_MODULE, controllerDecisionNearMiss, enumerateCaptainStates, enumerateScriptStates, isControllerDecisionResult, isControllerMachine, loadFsmModule, normalizeArms, } from './verify.js';
|
|
27
|
+
import { AWAIT_BOSS_REPLY_STATE, BOSS_REPLY_EVENT, CONTROLLER_ACTION_GUARDS, INTERRUPT_EVENT, NEEDS_BOSS_REPLY, VERIFY_MODULE, controllerDecisionNearMiss, enumerateCaptainStates, enumerateScriptStates, initialStateTarget, isControllerDecisionResult, isControllerMachine, loadFsmModule, normalizeArms, } from './verify.js';
|
|
26
28
|
/** The `gears2fsm`-mandated captain actor name a machine declares. */
|
|
27
29
|
export const CAPTAIN_ACTOR = 'captain';
|
|
30
|
+
// Each check owns its resources, including concurrent checks and async actor
|
|
31
|
+
// callbacks. No current-run variable is shared between invocations.
|
|
32
|
+
const coverageRuns = new AsyncLocalStorage();
|
|
33
|
+
/** Incomplete coverage is not an unsatisfiable transition or Source question. */
|
|
34
|
+
export class FsmCoverageDeadlineError extends Error {
|
|
35
|
+
constructor() {
|
|
36
|
+
super('FSM transition coverage incomplete: cooperative deadline exceeded');
|
|
37
|
+
this.name = 'FsmCoverageDeadlineError';
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
function coverageCheckpoint() {
|
|
41
|
+
const run = coverageRuns.getStore();
|
|
42
|
+
run?.signal?.throwIfAborted();
|
|
43
|
+
if (run !== undefined && performance.now() >= run.deadline)
|
|
44
|
+
throw new FsmCoverageDeadlineError();
|
|
45
|
+
}
|
|
46
|
+
/** Deliver pending cancellation between bounded blocks of synchronous work. */
|
|
47
|
+
async function coverageYield(force = false) {
|
|
48
|
+
coverageCheckpoint();
|
|
49
|
+
const run = coverageRuns.getStore();
|
|
50
|
+
if (run !== undefined && (force || performance.now() - run.lastYield >= 8)) {
|
|
51
|
+
try {
|
|
52
|
+
await delay(0, undefined, { signal: run.signal });
|
|
53
|
+
}
|
|
54
|
+
finally {
|
|
55
|
+
coverageCheckpoint();
|
|
56
|
+
}
|
|
57
|
+
run.lastYield = performance.now();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function trackCoverageActor(actor) {
|
|
61
|
+
const run = coverageRuns.getStore();
|
|
62
|
+
if (run !== undefined) {
|
|
63
|
+
run.actors.add(actor);
|
|
64
|
+
const stop = actor.stop.bind(actor);
|
|
65
|
+
actor.stop = () => {
|
|
66
|
+
try {
|
|
67
|
+
return stop();
|
|
68
|
+
}
|
|
69
|
+
finally {
|
|
70
|
+
run.actors.delete(actor);
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
return actor;
|
|
75
|
+
}
|
|
76
|
+
/** Release every actor without replacing an already propagating failure. */
|
|
77
|
+
function stopCoverageActors(actors, preserveFailure) {
|
|
78
|
+
let failed = false;
|
|
79
|
+
let firstError;
|
|
80
|
+
for (const actor of actors) {
|
|
81
|
+
try {
|
|
82
|
+
actor.stop();
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
if (!failed)
|
|
86
|
+
firstError = error;
|
|
87
|
+
failed = true;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (failed && !preserveFailure)
|
|
91
|
+
throw firstError;
|
|
92
|
+
}
|
|
28
93
|
function stateRefKey(ref) {
|
|
29
94
|
return ref.path.join('\u0000');
|
|
30
95
|
}
|
|
@@ -49,6 +114,15 @@ function interruptTargetForRef(ref, fallback) {
|
|
|
49
114
|
function captainInterruptTarget(captain) {
|
|
50
115
|
return interruptTargetForRef(captain.ref, captainPublicStateId(captain));
|
|
51
116
|
}
|
|
117
|
+
function invokeGuard(guard, args, ...params) {
|
|
118
|
+
coverageCheckpoint();
|
|
119
|
+
try {
|
|
120
|
+
return guard(args, ...params);
|
|
121
|
+
}
|
|
122
|
+
finally {
|
|
123
|
+
coverageCheckpoint();
|
|
124
|
+
}
|
|
125
|
+
}
|
|
52
126
|
/** Scalar values carried by a parameterized XState guard descriptor. */
|
|
53
127
|
function descriptorValues(value, seen = new Set()) {
|
|
54
128
|
if (typeof value === 'string' ||
|
|
@@ -66,7 +140,7 @@ function resolveGuard(machine, guard) {
|
|
|
66
140
|
if (typeof guard === 'function') {
|
|
67
141
|
const implementation = guard;
|
|
68
142
|
return {
|
|
69
|
-
run: (args) => implementation
|
|
143
|
+
run: (args) => invokeGuard(implementation, args),
|
|
70
144
|
probeValues: minedLiterals(implementation),
|
|
71
145
|
};
|
|
72
146
|
}
|
|
@@ -83,7 +157,7 @@ function resolveGuard(machine, guard) {
|
|
|
83
157
|
const implementation = candidate;
|
|
84
158
|
const params = descriptor.params;
|
|
85
159
|
return {
|
|
86
|
-
run: (args) => implementation
|
|
160
|
+
run: (args) => invokeGuard(implementation, args, typeof params === 'function'
|
|
87
161
|
? params(args)
|
|
88
162
|
: params),
|
|
89
163
|
probeValues: [
|
|
@@ -100,6 +174,7 @@ function guardLabel(guard) {
|
|
|
100
174
|
return JSON.stringify(guard) ?? String(guard);
|
|
101
175
|
}
|
|
102
176
|
catch {
|
|
177
|
+
coverageCheckpoint();
|
|
103
178
|
return String(guard);
|
|
104
179
|
}
|
|
105
180
|
}
|
|
@@ -135,6 +210,8 @@ const SETTLE_MS = 1_000;
|
|
|
135
210
|
const PARALLEL_SETTLE_MS = 250;
|
|
136
211
|
const PARALLEL_QUESTION_SETTLE_MS = 500;
|
|
137
212
|
const MAX_PARALLEL_COMBINATIONS = 64;
|
|
213
|
+
const MAX_COVERAGE_PATH_STEPS = 8;
|
|
214
|
+
const MAX_COVERAGE_PATH_ATTEMPTS = 64;
|
|
138
215
|
/** Stable values shared by machine input, result output, and guard probing. */
|
|
139
216
|
const COVERAGE_PLAYBOOK_ID = 'coverage-child-playbook';
|
|
140
217
|
const COVERAGE_PLAYBOOK_INPUT = 'coverage: complete the child request';
|
|
@@ -185,15 +262,20 @@ function coverageGuardContext(dynamic) {
|
|
|
185
262
|
}
|
|
186
263
|
/** Context produced by the machine's real initializer for the coverage input. */
|
|
187
264
|
function initializedMachineContext(machine) {
|
|
265
|
+
let actor;
|
|
266
|
+
let failed = false;
|
|
188
267
|
try {
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
actor.stop();
|
|
192
|
-
return context;
|
|
268
|
+
actor = trackCoverageActor(createActor(machine, { input: COVERAGE_MACHINE_INPUT }));
|
|
269
|
+
return actor.getSnapshot().context;
|
|
193
270
|
}
|
|
194
271
|
catch {
|
|
272
|
+
failed = true;
|
|
273
|
+
coverageCheckpoint();
|
|
195
274
|
return {};
|
|
196
275
|
}
|
|
276
|
+
finally {
|
|
277
|
+
stopCoverageActors(actor === undefined ? [] : [actor], failed);
|
|
278
|
+
}
|
|
197
279
|
}
|
|
198
280
|
/** Real initialized fields plus deterministic values needed by guard probes. */
|
|
199
281
|
function initializedCoverageContext(machine, dynamic) {
|
|
@@ -243,12 +325,28 @@ function synthesizedFieldValue(field) {
|
|
|
243
325
|
}
|
|
244
326
|
/** Synthesizes a captain output that selects `key` under the state's contract. */
|
|
245
327
|
function synthOutput(state, key) {
|
|
328
|
+
if (state.actor === 'script') {
|
|
329
|
+
return {
|
|
330
|
+
guard: key,
|
|
331
|
+
exitStatus: Object.keys(state.result)[0] === key ? 0 : 1,
|
|
332
|
+
};
|
|
333
|
+
}
|
|
246
334
|
const output = { guard: key };
|
|
247
335
|
for (const field of requiredFields(state.result[key] ?? '')) {
|
|
248
336
|
output[field] = synthesizedFieldValue(field);
|
|
249
337
|
}
|
|
250
338
|
return output;
|
|
251
339
|
}
|
|
340
|
+
/** One bounded runtime-valid candidate set for acceptance and arm auditing. */
|
|
341
|
+
function resultOutputCandidates(state, key, candidates) {
|
|
342
|
+
const output = synthOutput(state, key);
|
|
343
|
+
if (state.actor !== 'script' || output.exitStatus === 0)
|
|
344
|
+
return [output];
|
|
345
|
+
return [...new Set([1, ...candidates])]
|
|
346
|
+
.filter((value) => typeof value === 'number' && Number.isInteger(value) && value > 0)
|
|
347
|
+
.slice(0, MAX_SCRIPT_EXIT_STATUSES)
|
|
348
|
+
.map((exitStatus) => ({ guard: key, exitStatus }));
|
|
349
|
+
}
|
|
252
350
|
function invocations(state) {
|
|
253
351
|
if (Array.isArray(state.invoke))
|
|
254
352
|
return state.invoke;
|
|
@@ -291,21 +389,33 @@ function stateRefs(config) {
|
|
|
291
389
|
visit((config.states ?? {}));
|
|
292
390
|
return out;
|
|
293
391
|
}
|
|
294
|
-
/**
|
|
295
|
-
function
|
|
392
|
+
/** States entered by default, including every active parallel region. */
|
|
393
|
+
function enteredStateRefs(state, refs, parent) {
|
|
296
394
|
const active = [];
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
395
|
+
const initial = initialStateTarget(state.initial);
|
|
396
|
+
const keys = state.type === 'parallel'
|
|
397
|
+
? Object.keys(state.states ?? {})
|
|
398
|
+
: initial !== undefined
|
|
399
|
+
? [initial]
|
|
400
|
+
: [];
|
|
401
|
+
for (const key of keys) {
|
|
402
|
+
const path = [...(parent?.path ?? []), key];
|
|
301
403
|
const ref = refs.find((candidate) => candidate.path.join('\u0000') === path.join('\u0000'));
|
|
302
404
|
if (ref === undefined)
|
|
303
|
-
|
|
304
|
-
active.push(ref);
|
|
305
|
-
initial = ref.state.initial;
|
|
405
|
+
continue;
|
|
406
|
+
active.push(ref, ...enteredStateRefs(ref.state, refs, ref));
|
|
306
407
|
}
|
|
307
408
|
return active;
|
|
308
409
|
}
|
|
410
|
+
function initialActiveRefs(config, refs) {
|
|
411
|
+
return enteredStateRefs(config, refs);
|
|
412
|
+
}
|
|
413
|
+
/** A target enters itself and only its default active descendants. */
|
|
414
|
+
function targetEntersRef(refs, target, invocation) {
|
|
415
|
+
return (target !== undefined &&
|
|
416
|
+
(sameStateRef(target, invocation) ||
|
|
417
|
+
enteredStateRefs(target.state, refs, target).some((ref) => sameStateRef(ref, invocation))));
|
|
418
|
+
}
|
|
309
419
|
/** Captain bindings paired with the nested state node that owns the invoke. */
|
|
310
420
|
function captainRefs(config) {
|
|
311
421
|
const out = [];
|
|
@@ -361,6 +471,7 @@ function captainRefs(config) {
|
|
|
361
471
|
return matchingActor;
|
|
362
472
|
}
|
|
363
473
|
catch {
|
|
474
|
+
coverageCheckpoint();
|
|
364
475
|
return matchingActor && binding.sourceItem === '';
|
|
365
476
|
}
|
|
366
477
|
});
|
|
@@ -396,21 +507,10 @@ function coverageErrorMessage(error) {
|
|
|
396
507
|
return error instanceof Error ? error.message : String(error);
|
|
397
508
|
}
|
|
398
509
|
catch {
|
|
510
|
+
coverageCheckpoint();
|
|
399
511
|
return 'unknown error';
|
|
400
512
|
}
|
|
401
513
|
}
|
|
402
|
-
/** Replays a nested input against the context observed after a failed start. */
|
|
403
|
-
function playbookInputFailure(playbook, context) {
|
|
404
|
-
if (typeof playbook.invocation.input !== 'function')
|
|
405
|
-
return undefined;
|
|
406
|
-
try {
|
|
407
|
-
playbook.invocation.input({ context });
|
|
408
|
-
return undefined;
|
|
409
|
-
}
|
|
410
|
-
catch (error) {
|
|
411
|
-
return coverageErrorMessage(error);
|
|
412
|
-
}
|
|
413
|
-
}
|
|
414
514
|
/** Dynamic target/text context names declared by a nested playbook input. */
|
|
415
515
|
function dynamicPlaybookFields(playbook) {
|
|
416
516
|
if (typeof playbook.invocation.input !== 'function')
|
|
@@ -420,6 +520,7 @@ function dynamicPlaybookFields(playbook) {
|
|
|
420
520
|
input = playbook.invocation.input({ context: coverageGuardContext() });
|
|
421
521
|
}
|
|
422
522
|
catch {
|
|
523
|
+
coverageCheckpoint();
|
|
423
524
|
return undefined;
|
|
424
525
|
}
|
|
425
526
|
if (typeof input !== 'object' || input === null || Array.isArray(input)) {
|
|
@@ -470,22 +571,26 @@ function resolvedStateNode(machine, ref) {
|
|
|
470
571
|
return node;
|
|
471
572
|
}
|
|
472
573
|
function persistedSnapshotWithContext(provided, context) {
|
|
473
|
-
const seed = createActor(provided, { input: COVERAGE_MACHINE_INPUT });
|
|
574
|
+
const seed = trackCoverageActor(createActor(provided, { input: COVERAGE_MACHINE_INPUT }));
|
|
575
|
+
let completed = false;
|
|
474
576
|
try {
|
|
475
|
-
|
|
577
|
+
const snapshot = {
|
|
476
578
|
...seed.getPersistedSnapshot(),
|
|
477
579
|
context: { ...context },
|
|
478
580
|
children: {},
|
|
479
581
|
};
|
|
582
|
+
completed = true;
|
|
583
|
+
return snapshot;
|
|
480
584
|
}
|
|
481
585
|
finally {
|
|
482
|
-
seed
|
|
586
|
+
stopCoverageActors([seed], !completed);
|
|
483
587
|
}
|
|
484
588
|
}
|
|
485
589
|
function makeActor(machine, script, playbookScript = () => null, restoredContext, observeTransition) {
|
|
590
|
+
coverageCheckpoint();
|
|
486
591
|
const coverageErrors = [];
|
|
487
|
-
const workActor = fromPromise(async ({ input }) => {
|
|
488
|
-
const output = script(input ?? {});
|
|
592
|
+
const workActor = fromPromise(async ({ input, self, }) => {
|
|
593
|
+
const output = await script(input ?? {}, self.id);
|
|
489
594
|
if (output === null)
|
|
490
595
|
return new Promise(() => { });
|
|
491
596
|
if (output instanceof Error)
|
|
@@ -500,7 +605,7 @@ function makeActor(machine, script, playbookScript = () => null, restoredContext
|
|
|
500
605
|
// A child script is opt-in. All unrelated child invocations hang until
|
|
501
606
|
// the driven actor is stopped, preserving Captain and parallel probes.
|
|
502
607
|
playbook: fromPromise(async ({ input, self, }) => {
|
|
503
|
-
const output = playbookScript(input ?? {}, self.id);
|
|
608
|
+
const output = await playbookScript(input ?? {}, self.id);
|
|
504
609
|
if (output === null)
|
|
505
610
|
return new Promise(() => { });
|
|
506
611
|
if (output instanceof Error)
|
|
@@ -512,17 +617,18 @@ function makeActor(machine, script, playbookScript = () => null, restoredContext
|
|
|
512
617
|
const restoredSnapshot = restoredContext === undefined
|
|
513
618
|
? undefined
|
|
514
619
|
: persistedSnapshotWithContext(provided, restoredContext);
|
|
515
|
-
const actor = createActor(provided, {
|
|
620
|
+
const actor = trackCoverageActor(createActor(provided, {
|
|
516
621
|
input: COVERAGE_MACHINE_INPUT,
|
|
517
|
-
...(restoredSnapshot === undefined
|
|
622
|
+
...(restoredSnapshot === undefined
|
|
623
|
+
? {}
|
|
624
|
+
: { snapshot: restoredSnapshot }),
|
|
518
625
|
inspect: (inspection) => {
|
|
519
626
|
if (typeof inspection !== 'object' ||
|
|
520
627
|
inspection === null ||
|
|
521
628
|
!('event' in inspection)) {
|
|
522
629
|
return;
|
|
523
630
|
}
|
|
524
|
-
const inspectedTransitions = inspection
|
|
525
|
-
._transitions;
|
|
631
|
+
const inspectedTransitions = inspection._transitions;
|
|
526
632
|
if (inspection.type === '@xstate.microstep' &&
|
|
527
633
|
Array.isArray(inspectedTransitions)) {
|
|
528
634
|
for (const transition of inspectedTransitions) {
|
|
@@ -559,12 +665,13 @@ function makeActor(machine, script, playbookScript = () => null, restoredContext
|
|
|
559
665
|
});
|
|
560
666
|
}
|
|
561
667
|
},
|
|
562
|
-
});
|
|
668
|
+
}));
|
|
563
669
|
Object.defineProperty(actor, 'coverageErrors', {
|
|
564
670
|
value: coverageErrors,
|
|
565
671
|
});
|
|
566
672
|
actor.subscribe({ error: () => { } });
|
|
567
673
|
actor.start();
|
|
674
|
+
coverageCheckpoint();
|
|
568
675
|
return actor;
|
|
569
676
|
}
|
|
570
677
|
/** A script that rejects the captain invocation for the given source item. */
|
|
@@ -587,14 +694,30 @@ function onceScript(sourceItem, output, gate) {
|
|
|
587
694
|
}
|
|
588
695
|
/** Waits until the actor's snapshot satisfies `predicate`, or times out. */
|
|
589
696
|
function settle(actor, predicate, ms = SETTLE_MS) {
|
|
590
|
-
|
|
697
|
+
coverageCheckpoint();
|
|
698
|
+
const run = coverageRuns.getStore();
|
|
699
|
+
return new Promise((resolveSettled, reject) => {
|
|
591
700
|
let subscription = undefined;
|
|
701
|
+
let settled = false;
|
|
592
702
|
const finish = (outcome) => {
|
|
703
|
+
if (settled)
|
|
704
|
+
return;
|
|
705
|
+
settled = true;
|
|
593
706
|
clearTimeout(timer);
|
|
594
707
|
subscription?.unsubscribe();
|
|
595
|
-
|
|
708
|
+
run?.signal?.removeEventListener('abort', aborted);
|
|
709
|
+
try {
|
|
710
|
+
coverageCheckpoint();
|
|
711
|
+
resolveSettled(outcome);
|
|
712
|
+
}
|
|
713
|
+
catch (error) {
|
|
714
|
+
reject(error);
|
|
715
|
+
}
|
|
596
716
|
};
|
|
597
|
-
const
|
|
717
|
+
const aborted = () => finish(false);
|
|
718
|
+
const remaining = run === undefined ? ms : Math.max(0, run.deadline - performance.now());
|
|
719
|
+
const timer = setTimeout(() => finish(predicate(actor.getSnapshot())), Math.min(ms, Math.ceil(remaining)));
|
|
720
|
+
run?.signal?.addEventListener('abort', aborted, { once: true });
|
|
598
721
|
subscription = actor.subscribe({
|
|
599
722
|
next: (snapshot) => {
|
|
600
723
|
if (predicate(snapshot))
|
|
@@ -605,13 +728,26 @@ function settle(actor, predicate, ms = SETTLE_MS) {
|
|
|
605
728
|
// owning probe records the actionable coverage finding.
|
|
606
729
|
error: () => finish(false),
|
|
607
730
|
});
|
|
731
|
+
if (settled)
|
|
732
|
+
subscription.unsubscribe();
|
|
608
733
|
if (predicate(actor.getSnapshot()))
|
|
609
734
|
finish(true);
|
|
735
|
+
if (run?.signal?.aborted)
|
|
736
|
+
aborted();
|
|
610
737
|
});
|
|
611
738
|
}
|
|
612
739
|
/** Lets XState process an event whose correct outcome is no transition. */
|
|
613
740
|
async function settleNoTransition() {
|
|
614
|
-
|
|
741
|
+
coverageCheckpoint();
|
|
742
|
+
const run = coverageRuns.getStore();
|
|
743
|
+
try {
|
|
744
|
+
await delay(Math.min(10, run === undefined
|
|
745
|
+
? 10
|
|
746
|
+
: Math.max(0, Math.ceil(run.deadline - performance.now()))), undefined, { signal: run?.signal });
|
|
747
|
+
}
|
|
748
|
+
finally {
|
|
749
|
+
coverageCheckpoint();
|
|
750
|
+
}
|
|
615
751
|
}
|
|
616
752
|
function activeStateIds(snapshot) {
|
|
617
753
|
const ids = new Set();
|
|
@@ -641,6 +777,7 @@ function activeStateIds(snapshot) {
|
|
|
641
777
|
return;
|
|
642
778
|
}
|
|
643
779
|
for (const [key, nested] of Object.entries(value)) {
|
|
780
|
+
ids.add([...prefix, key].join('.'));
|
|
644
781
|
walkValue(nested, [...prefix, key]);
|
|
645
782
|
}
|
|
646
783
|
};
|
|
@@ -701,8 +838,12 @@ function directlySelectedEventArm(machine, arms, event, context = coverageGuardC
|
|
|
701
838
|
return index;
|
|
702
839
|
}
|
|
703
840
|
catch {
|
|
841
|
+
coverageCheckpoint();
|
|
704
842
|
return undefined;
|
|
705
843
|
}
|
|
844
|
+
finally {
|
|
845
|
+
coverageCheckpoint();
|
|
846
|
+
}
|
|
706
847
|
}
|
|
707
848
|
return undefined;
|
|
708
849
|
}
|
|
@@ -742,7 +883,6 @@ function orderedArmPredicate(machine, arms, selected) {
|
|
|
742
883
|
}
|
|
743
884
|
/** A bounded satisfying context/event pair for one authored interrupt arm. */
|
|
744
885
|
function interruptDriveForRef(machine, refs, target, targetId, extraValues = [], selectedArmIndex) {
|
|
745
|
-
const base = { type: INTERRUPT_EVENT, targetId };
|
|
746
886
|
const arms = transitionArms((machine.config.on ?? {})[INTERRUPT_EVENT]);
|
|
747
887
|
const armIndex = selectedArmIndex ??
|
|
748
888
|
arms.findIndex((arm) => {
|
|
@@ -750,9 +890,17 @@ function interruptDriveForRef(machine, refs, target, targetId, extraValues = [],
|
|
|
750
890
|
if (rawTarget === undefined)
|
|
751
891
|
return false;
|
|
752
892
|
const resolvedTarget = stateRefForTarget(refs, rawTarget);
|
|
753
|
-
return (
|
|
893
|
+
return (targetEntersRef(refs, resolvedTarget, target) ||
|
|
754
894
|
resolvedTarget?.stableId === targetId);
|
|
755
895
|
});
|
|
896
|
+
const rawTarget = rawArmTarget(arms[armIndex]);
|
|
897
|
+
const entryTarget = rawTarget === undefined ? undefined : stateRefForTarget(refs, rawTarget);
|
|
898
|
+
const base = {
|
|
899
|
+
type: INTERRUPT_EVENT,
|
|
900
|
+
targetId: entryTarget !== undefined && !sameStateRef(entryTarget, target)
|
|
901
|
+
? entryTarget.stableId
|
|
902
|
+
: targetId,
|
|
903
|
+
};
|
|
756
904
|
const targetPlaybook = playbookRefs(machine.config).find((playbook) => sameStateRef(playbook.ref, target));
|
|
757
905
|
const initialContext = initializedCoverageContext(machine, targetPlaybook === undefined
|
|
758
906
|
? undefined
|
|
@@ -802,7 +950,7 @@ function entryDriveForRef(machine, refs, target, extraValues = [], initialContex
|
|
|
802
950
|
for (const [armIndex, arm] of arms.entries()) {
|
|
803
951
|
const rawTarget = rawArmTarget(arm);
|
|
804
952
|
if (rawTarget === undefined ||
|
|
805
|
-
!
|
|
953
|
+
!targetEntersRef(refs, stateRefForTarget(refs, rawTarget, source), target)) {
|
|
806
954
|
continue;
|
|
807
955
|
}
|
|
808
956
|
const guard = orderedArmPredicate(machine, arms, armIndex);
|
|
@@ -867,15 +1015,18 @@ const GENERIC_VALUES = [
|
|
|
867
1015
|
COVERAGE_ENABLED_PLAYBOOKS,
|
|
868
1016
|
];
|
|
869
1017
|
const MAX_PROBES = 30_000;
|
|
1018
|
+
const MAX_SCRIPT_EXIT_STATUSES = 8;
|
|
870
1019
|
const PROBES_PER_TIMEOUT_MILLISECOND = 50;
|
|
871
1020
|
const COVERAGE_TIMEOUT_MARGIN_MS = 5_000;
|
|
872
1021
|
const MIN_COVERAGE_TEST_TIMEOUT_MS = 10_000;
|
|
1022
|
+
const MAX_COVERAGE_TEST_TIMEOUT_MS = 300_000;
|
|
873
1023
|
function minedLiterals(fn) {
|
|
874
1024
|
let source;
|
|
875
1025
|
try {
|
|
876
1026
|
source = String(fn);
|
|
877
1027
|
}
|
|
878
1028
|
catch {
|
|
1029
|
+
coverageCheckpoint();
|
|
879
1030
|
return [];
|
|
880
1031
|
}
|
|
881
1032
|
const literals = source.match(/'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"/g) ?? [];
|
|
@@ -923,6 +1074,22 @@ export function guardSatisfiable(guard, baseOutput, extraValues = []) {
|
|
|
923
1074
|
}
|
|
924
1075
|
function probeGuardAssignment(guard, fixedEvent, payloads, extraValues, options = {}) {
|
|
925
1076
|
const baseContext = { ...(options.initialContext ?? {}) };
|
|
1077
|
+
// Guards are predicates over fixed seed objects. Snapshot their descriptors
|
|
1078
|
+
// once within this probe, while keeping every candidate independently mutable
|
|
1079
|
+
// and retaining accessors, prototypes and non-enumerable Error properties.
|
|
1080
|
+
const bases = new WeakMap();
|
|
1081
|
+
const overlay = (base, assigned) => {
|
|
1082
|
+
let shape = bases.get(base);
|
|
1083
|
+
if (shape === undefined) {
|
|
1084
|
+
shape = {
|
|
1085
|
+
prototype: Object.getPrototypeOf(base),
|
|
1086
|
+
descriptors: Object.getOwnPropertyDescriptors(base),
|
|
1087
|
+
};
|
|
1088
|
+
bases.set(base, shape);
|
|
1089
|
+
}
|
|
1090
|
+
const copy = Object.create(shape.prototype, shape.descriptors);
|
|
1091
|
+
return Object.assign(copy, assigned);
|
|
1092
|
+
};
|
|
926
1093
|
// Guard-source literals first: the likeliest matches are tried earliest.
|
|
927
1094
|
const values = [
|
|
928
1095
|
...new Set([...minedLiterals(guard), ...extraValues, ...GENERIC_VALUES]),
|
|
@@ -932,27 +1099,33 @@ function probeGuardAssignment(guard, fixedEvent, payloads, extraValues, options
|
|
|
932
1099
|
let event = { ...fixedEvent };
|
|
933
1100
|
for (const payload of payloads) {
|
|
934
1101
|
if (payload.eventField === undefined) {
|
|
935
|
-
event =
|
|
1102
|
+
event = overlay(overlay(payload.base, event), assignment.payloads[payload.tag] ?? {});
|
|
936
1103
|
}
|
|
937
1104
|
else {
|
|
938
|
-
event[payload.eventField] =
|
|
1105
|
+
event[payload.eventField] = overlay(payload.base, assignment.payloads[payload.tag] ?? {});
|
|
939
1106
|
}
|
|
940
1107
|
}
|
|
941
1108
|
return event;
|
|
942
1109
|
};
|
|
943
1110
|
const passes = (assignment) => {
|
|
1111
|
+
coverageCheckpoint();
|
|
944
1112
|
probes++;
|
|
945
1113
|
try {
|
|
946
1114
|
return Boolean(guard({ context: assignment.context, event: eventFor(assignment) }));
|
|
947
1115
|
}
|
|
948
1116
|
catch {
|
|
1117
|
+
coverageCheckpoint();
|
|
949
1118
|
return false;
|
|
950
1119
|
}
|
|
1120
|
+
finally {
|
|
1121
|
+
coverageCheckpoint();
|
|
1122
|
+
}
|
|
951
1123
|
};
|
|
952
1124
|
// Records the unassigned fields the guard reads under the given assignment.
|
|
953
1125
|
const readsUnder = (assignment) => {
|
|
1126
|
+
coverageCheckpoint();
|
|
954
1127
|
const reads = new Set();
|
|
955
|
-
const recording = (base, assigned, tag, varyExisting = false) => new Proxy(
|
|
1128
|
+
const recording = (base, assigned, tag, varyExisting = false) => new Proxy(overlay(base, assigned), {
|
|
956
1129
|
get(target, prop) {
|
|
957
1130
|
if (typeof prop !== 'string')
|
|
958
1131
|
return undefined;
|
|
@@ -978,7 +1151,7 @@ function probeGuardAssignment(guard, fixedEvent, payloads, extraValues, options
|
|
|
978
1151
|
let event = { ...fixedEvent };
|
|
979
1152
|
for (const payload of payloads) {
|
|
980
1153
|
if (payload.eventField === undefined) {
|
|
981
|
-
event = recording(event, assignment.payloads[payload.tag] ?? {}, payload.tag);
|
|
1154
|
+
event = recording(overlay(payload.base, event), assignment.payloads[payload.tag] ?? {}, payload.tag);
|
|
982
1155
|
}
|
|
983
1156
|
else {
|
|
984
1157
|
event[payload.eventField] = recording(payload.base, assignment.payloads[payload.tag] ?? {}, payload.tag);
|
|
@@ -990,11 +1163,16 @@ function probeGuardAssignment(guard, fixedEvent, payloads, extraValues, options
|
|
|
990
1163
|
});
|
|
991
1164
|
}
|
|
992
1165
|
catch {
|
|
1166
|
+
coverageCheckpoint();
|
|
993
1167
|
// Reads observed before the throw still guide the search.
|
|
994
1168
|
}
|
|
1169
|
+
finally {
|
|
1170
|
+
coverageCheckpoint();
|
|
1171
|
+
}
|
|
995
1172
|
return [...reads];
|
|
996
1173
|
};
|
|
997
1174
|
const search = (assignment, depth) => {
|
|
1175
|
+
coverageCheckpoint();
|
|
998
1176
|
if (probes > MAX_PROBES)
|
|
999
1177
|
return undefined;
|
|
1000
1178
|
if (passes(assignment))
|
|
@@ -1020,6 +1198,7 @@ function probeGuardAssignment(guard, fixedEvent, payloads, extraValues, options
|
|
|
1020
1198
|
...values.filter((value) => !hasBaseline || value !== baseline),
|
|
1021
1199
|
];
|
|
1022
1200
|
for (const value of candidates) {
|
|
1201
|
+
coverageCheckpoint();
|
|
1023
1202
|
if (probes > MAX_PROBES)
|
|
1024
1203
|
return undefined;
|
|
1025
1204
|
const nextDepth = depth + (hasBaseline && value === baseline ? 0 : 1);
|
|
@@ -1066,8 +1245,8 @@ function overlaidObject(base, assigned) {
|
|
|
1066
1245
|
* named nested payloads are assignable; event-level fields such as `type` and
|
|
1067
1246
|
* `actorId` remain the values XState actually supplies.
|
|
1068
1247
|
*/
|
|
1069
|
-
function doneGuardSatisfiable(guard, event, output, extraValues) {
|
|
1070
|
-
return probeGuardSatisfiable(guard.run, event, [{ eventField: 'output', tag: 'o:', base: output }], [...guard.probeValues, ...extraValues]);
|
|
1248
|
+
function doneGuardSatisfiable(guard, event, output, extraValues, fixedOutput = false) {
|
|
1249
|
+
return probeGuardSatisfiable(guard.run, fixedOutput ? { ...event, output } : event, fixedOutput ? [] : [{ eventField: 'output', tag: 'o:', base: output }], [...guard.probeValues, ...extraValues]);
|
|
1071
1250
|
}
|
|
1072
1251
|
/** A concrete initialized-context/output assignment selecting one done arm. */
|
|
1073
1252
|
function controllerDoneGuardAssignment(guard, event, output, extraValues, initialContext) {
|
|
@@ -1085,11 +1264,7 @@ function parallelBranchCaptains(parallel, refs, captains) {
|
|
|
1085
1264
|
const regions = refs.filter((ref) => ref.parent === parallel);
|
|
1086
1265
|
const selected = [];
|
|
1087
1266
|
for (const region of regions) {
|
|
1088
|
-
const
|
|
1089
|
-
const initial = region.state.initial;
|
|
1090
|
-
const captain = (initial === undefined
|
|
1091
|
-
? undefined
|
|
1092
|
-
: candidates.find((candidate) => candidate.ref.path[region.path.length] === initial)) ?? candidates[0];
|
|
1267
|
+
const captain = captains.find((candidate) => targetEntersRef(refs, region, candidate.ref));
|
|
1093
1268
|
if (captain !== undefined)
|
|
1094
1269
|
selected.push(captain);
|
|
1095
1270
|
}
|
|
@@ -1153,21 +1328,300 @@ function callCount(calls, captain) {
|
|
|
1153
1328
|
calls.get(captain.binding.sourceItem) ??
|
|
1154
1329
|
0);
|
|
1155
1330
|
}
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1331
|
+
/** One public drive or the actual initialized state, never a private jump. */
|
|
1332
|
+
function childCoverageDrive(machine, refs, ref, targetId, candidates) {
|
|
1333
|
+
const interrupt = interruptDriveForRef(machine, refs, ref, targetId, candidates);
|
|
1334
|
+
if (interrupt.satisfiable)
|
|
1335
|
+
return { drive: interrupt };
|
|
1336
|
+
const entry = entryDriveForRef(machine, refs, ref, candidates);
|
|
1337
|
+
if (entry !== undefined)
|
|
1338
|
+
return { drive: entry };
|
|
1339
|
+
return initialActiveRefs(machine.config, refs).some((active) => sameStateRef(active, ref))
|
|
1340
|
+
? {}
|
|
1341
|
+
: undefined;
|
|
1342
|
+
}
|
|
1343
|
+
function coverageNodeKey(node) {
|
|
1344
|
+
return `${stateRefKey(node.value.ref)}:${node.value.invocationIndex}`;
|
|
1345
|
+
}
|
|
1346
|
+
function coverageNodes(captains, playbooks) {
|
|
1347
|
+
return [
|
|
1348
|
+
...captains.map((value) => ({ kind: 'acting', value })),
|
|
1349
|
+
...playbooks.map((value) => ({ kind: 'child', value })),
|
|
1350
|
+
];
|
|
1351
|
+
}
|
|
1352
|
+
/** The exact ordered output candidates used both for entry and target arms. */
|
|
1353
|
+
function coverageArmResults(machine, node, outcome, armIndex, context, input, candidates, dynamicTarget, requiredResult, includeBare = false, firstOnly = false) {
|
|
1354
|
+
const arms = transitionArms(node.value.invocation[outcome]);
|
|
1355
|
+
const guard = orderedArmPredicate(machine, arms, armIndex);
|
|
1356
|
+
if (guard === undefined)
|
|
1357
|
+
return [];
|
|
1358
|
+
const actorId = invocationActorId(machine, node.value);
|
|
1359
|
+
const event = {
|
|
1360
|
+
type: `xstate.${outcome === 'onDone' ? 'done' : 'error'}.actor.${actorId}`,
|
|
1361
|
+
actorId,
|
|
1362
|
+
};
|
|
1363
|
+
const bases = outcome === 'onError'
|
|
1364
|
+
? node.kind === 'child'
|
|
1365
|
+
? [
|
|
1366
|
+
nestedFailure(typeof input.playbookId === 'string'
|
|
1367
|
+
? input.playbookId
|
|
1368
|
+
: COVERAGE_PLAYBOOK_ID),
|
|
1369
|
+
nestedFailureTerminal(typeof input.playbookId === 'string'
|
|
1370
|
+
? input.playbookId
|
|
1371
|
+
: COVERAGE_PLAYBOOK_ID),
|
|
1372
|
+
new Error('coverage: generic nested playbook failure'),
|
|
1373
|
+
]
|
|
1374
|
+
: [new Error('coverage: forced captain failure')]
|
|
1375
|
+
: node.kind === 'child'
|
|
1376
|
+
? [{}, nestedSuccessOutput(), { invalidCoverageOutput: undefined }]
|
|
1377
|
+
: (requiredResult === undefined
|
|
1378
|
+
? Object.keys(node.value.binding.result)
|
|
1379
|
+
: [requiredResult]).flatMap((key) => resultOutputCandidates(node.value.binding, key, [
|
|
1380
|
+
...guard.probeValues,
|
|
1381
|
+
...candidates,
|
|
1382
|
+
]).map((output) => ({
|
|
1383
|
+
...output,
|
|
1384
|
+
...(dynamicTarget === undefined
|
|
1385
|
+
? {}
|
|
1386
|
+
: {
|
|
1387
|
+
[dynamicTarget.playbookIdContext]: COVERAGE_PLAYBOOK_ID,
|
|
1388
|
+
[dynamicTarget.textContext]: COVERAGE_PLAYBOOK_INPUT,
|
|
1389
|
+
}),
|
|
1390
|
+
})));
|
|
1391
|
+
if (includeBare &&
|
|
1392
|
+
node.kind === 'acting' &&
|
|
1393
|
+
node.value.binding.actor !== 'script' &&
|
|
1394
|
+
outcome === 'onDone') {
|
|
1395
|
+
bases.push(...Object.keys(node.value.binding.result).map((guard) => ({ guard })));
|
|
1396
|
+
}
|
|
1397
|
+
const results = [];
|
|
1398
|
+
for (const base of bases) {
|
|
1399
|
+
coverageCheckpoint();
|
|
1400
|
+
if (node.kind === 'acting' &&
|
|
1401
|
+
node.value.binding.actor === 'script' &&
|
|
1402
|
+
outcome === 'onDone') {
|
|
1403
|
+
if (directlySelectedEventArm(machine, arms, { ...event, output: base }, context) === armIndex) {
|
|
1404
|
+
results.push(base);
|
|
1405
|
+
if (firstOnly)
|
|
1406
|
+
return results;
|
|
1407
|
+
}
|
|
1408
|
+
continue;
|
|
1409
|
+
}
|
|
1410
|
+
const assignment = probeGuardAssignment(guard.run, event, [
|
|
1411
|
+
{
|
|
1412
|
+
eventField: outcome === 'onDone' ? 'output' : 'error',
|
|
1413
|
+
tag: outcome === 'onDone' ? 'o:' : 'r:',
|
|
1414
|
+
base,
|
|
1415
|
+
...(outcome === 'onError' ? { varyExisting: true } : {}),
|
|
1416
|
+
},
|
|
1417
|
+
], [...guard.probeValues, ...GENERIC_VALUES, ...candidates], { initialContext: context, assignContext: false });
|
|
1418
|
+
if (assignment !== undefined) {
|
|
1419
|
+
results.push(assignedPayload(base, assignment, outcome === 'onDone' ? 'o:' : 'r:'));
|
|
1420
|
+
if (firstOnly)
|
|
1421
|
+
return results;
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
return results;
|
|
1425
|
+
}
|
|
1426
|
+
/** Replay every prefix transition; no persisted private-state restoration. */
|
|
1427
|
+
async function replayCoveragePath(machine, nodes, path) {
|
|
1428
|
+
const gate = { armed: path.drive === undefined };
|
|
1429
|
+
let expected = path.root;
|
|
1430
|
+
let pending;
|
|
1431
|
+
const script = (input, actorId) => {
|
|
1432
|
+
const node = nodes.find((candidate) => invocationActorId(machine, candidate.value) === actorId);
|
|
1433
|
+
if (!gate.armed ||
|
|
1434
|
+
node === undefined ||
|
|
1435
|
+
coverageNodeKey(node) !== coverageNodeKey(expected))
|
|
1436
|
+
return null;
|
|
1437
|
+
const stateId = node.kind === 'acting'
|
|
1438
|
+
? captainPublicStateId(node.value)
|
|
1439
|
+
: node.value.ref.stableId;
|
|
1440
|
+
if (input.stateId !== stateId)
|
|
1441
|
+
return null;
|
|
1442
|
+
return new Promise((complete) => {
|
|
1443
|
+
pending = { node, input, complete };
|
|
1162
1444
|
});
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1445
|
+
};
|
|
1446
|
+
const actor = makeActor(machine, script, script, path.drive?.context);
|
|
1447
|
+
gate.armed = true;
|
|
1448
|
+
if (path.drive !== undefined)
|
|
1449
|
+
actor.send(path.drive.event);
|
|
1450
|
+
const wait = () => settle(actor, (snapshot) => (pending !== undefined && atState(expected.value.ref)(snapshot)) ||
|
|
1451
|
+
snapshot.status === 'done' ||
|
|
1452
|
+
snapshot.status === 'error', PARALLEL_SETTLE_MS);
|
|
1453
|
+
let completedPrefix = true;
|
|
1454
|
+
for (const step of path.steps) {
|
|
1455
|
+
await coverageYield();
|
|
1456
|
+
await wait();
|
|
1457
|
+
const current = pending;
|
|
1458
|
+
if (current === undefined ||
|
|
1459
|
+
coverageNodeKey(current.node) !== coverageNodeKey(step.node)) {
|
|
1460
|
+
completedPrefix = false;
|
|
1461
|
+
break;
|
|
1462
|
+
}
|
|
1463
|
+
pending = undefined;
|
|
1464
|
+
expected = step.next;
|
|
1465
|
+
current.complete(step.result);
|
|
1466
|
+
if (step.questionWait !== undefined) {
|
|
1467
|
+
if (!(await settle(actor, atState(step.questionWait), PARALLEL_SETTLE_MS))) {
|
|
1468
|
+
completedPrefix = false;
|
|
1469
|
+
break;
|
|
1470
|
+
}
|
|
1471
|
+
actor.send({
|
|
1472
|
+
type: BOSS_REPLY_EVENT,
|
|
1473
|
+
questionId: captainPublicStateId(step.node.value),
|
|
1474
|
+
answer: 'Proceed as planned.',
|
|
1475
|
+
});
|
|
1476
|
+
}
|
|
1166
1477
|
}
|
|
1167
|
-
|
|
1168
|
-
|
|
1478
|
+
await wait();
|
|
1479
|
+
const snapshot = actor.getSnapshot();
|
|
1480
|
+
const failure = actor.coverageErrors.find(({ eventType }) => eventType ===
|
|
1481
|
+
`xstate.error.actor.${invocationActorId(machine, expected.value)}`);
|
|
1482
|
+
return {
|
|
1483
|
+
actor,
|
|
1484
|
+
...(!completedPrefix ||
|
|
1485
|
+
pending === undefined ||
|
|
1486
|
+
!atState(expected.value.ref)(snapshot)
|
|
1487
|
+
? {}
|
|
1488
|
+
: { pending }),
|
|
1489
|
+
...(failure !== undefined
|
|
1490
|
+
? { inputFailure: coverageErrorMessage(failure.error) }
|
|
1491
|
+
: snapshot.status === 'error'
|
|
1492
|
+
? { inputFailure: coverageErrorMessage(snapshot.error) }
|
|
1493
|
+
: {}),
|
|
1494
|
+
};
|
|
1495
|
+
}
|
|
1496
|
+
/** Finite path replay, with one canonical question/reply revisit at most. */
|
|
1497
|
+
async function searchCoverageEntry(machine, target, refs, captains, playbooks, candidates, check) {
|
|
1498
|
+
const nodes = coverageNodes(captains, playbooks);
|
|
1499
|
+
const nodesForTarget = (raw, source) => {
|
|
1500
|
+
const targetName = rawArmTarget(raw);
|
|
1501
|
+
const targetRef = targetName === undefined
|
|
1502
|
+
? undefined
|
|
1503
|
+
: stateRefForTarget(refs, targetName, source);
|
|
1504
|
+
return nodes.filter((node) => targetEntersRef(refs, targetRef, node.value.ref));
|
|
1505
|
+
};
|
|
1506
|
+
const reachable = new Set([coverageNodeKey(target)]);
|
|
1507
|
+
for (let pass = 0; pass < nodes.length; pass++) {
|
|
1508
|
+
for (const node of nodes) {
|
|
1509
|
+
if (['onDone', 'onError'].some((outcome) => transitionArms(node.value.invocation[outcome]).some((arm) => {
|
|
1510
|
+
return nodesForTarget(arm, node.value.ref).some((next) => reachable.has(coverageNodeKey(next)));
|
|
1511
|
+
})))
|
|
1512
|
+
reachable.add(coverageNodeKey(node));
|
|
1513
|
+
}
|
|
1169
1514
|
}
|
|
1515
|
+
const queue = [];
|
|
1516
|
+
for (const node of [
|
|
1517
|
+
target,
|
|
1518
|
+
...nodes.filter((node) => coverageNodeKey(node) !== coverageNodeKey(target)),
|
|
1519
|
+
]) {
|
|
1520
|
+
if (!reachable.has(coverageNodeKey(node)))
|
|
1521
|
+
continue;
|
|
1522
|
+
const entry = childCoverageDrive(machine, refs, node.value.ref, node.kind === 'acting'
|
|
1523
|
+
? captainInterruptTarget(node.value)
|
|
1524
|
+
: interruptTargetForRef(node.value.ref, node.value.ref.stableId), candidates);
|
|
1525
|
+
if (entry !== undefined)
|
|
1526
|
+
queue.push({ root: node, ...entry, steps: [] });
|
|
1527
|
+
}
|
|
1528
|
+
let attempts = 0;
|
|
1529
|
+
let entered = false;
|
|
1530
|
+
let limitation;
|
|
1531
|
+
let inputFailure;
|
|
1532
|
+
while (queue.length > 0 && attempts < MAX_COVERAGE_PATH_ATTEMPTS) {
|
|
1533
|
+
await coverageYield();
|
|
1534
|
+
const path = queue.shift();
|
|
1535
|
+
attempts++;
|
|
1536
|
+
let replay = await replayCoveragePath(machine, nodes, path);
|
|
1537
|
+
try {
|
|
1538
|
+
if (replay.inputFailure !== undefined)
|
|
1539
|
+
inputFailure = replay.inputFailure;
|
|
1540
|
+
if (replay.pending !== undefined &&
|
|
1541
|
+
coverageNodeKey(replay.pending.node) === coverageNodeKey(target)) {
|
|
1542
|
+
entered = true;
|
|
1543
|
+
if (await check(replay.actor, replay.pending))
|
|
1544
|
+
return { covered: true, entered };
|
|
1545
|
+
// A target check may have completed its actor. Re-enter the same
|
|
1546
|
+
// authored prefix before considering successors, charging that replay
|
|
1547
|
+
// against the same attempt limit.
|
|
1548
|
+
replay.actor.stop();
|
|
1549
|
+
if (attempts >= MAX_COVERAGE_PATH_ATTEMPTS) {
|
|
1550
|
+
limitation = `exhausted ${MAX_COVERAGE_PATH_ATTEMPTS}-attempt path budget`;
|
|
1551
|
+
break;
|
|
1552
|
+
}
|
|
1553
|
+
attempts++;
|
|
1554
|
+
replay = await replayCoveragePath(machine, nodes, path);
|
|
1555
|
+
}
|
|
1556
|
+
const pending = replay.pending;
|
|
1557
|
+
if (pending === undefined)
|
|
1558
|
+
continue;
|
|
1559
|
+
const node = pending.node;
|
|
1560
|
+
const context = replay.actor.getSnapshot().context;
|
|
1561
|
+
const nextSteps = [];
|
|
1562
|
+
for (const outcome of ['onDone', 'onError']) {
|
|
1563
|
+
for (const [index, arm] of transitionArms(node.value.invocation[outcome]).entries()) {
|
|
1564
|
+
for (const next of nodesForTarget(arm, node.value.ref)) {
|
|
1565
|
+
if (!reachable.has(coverageNodeKey(next)))
|
|
1566
|
+
continue;
|
|
1567
|
+
const dynamic = next.kind === 'child'
|
|
1568
|
+
? dynamicPlaybookFields(next.value)
|
|
1569
|
+
: undefined;
|
|
1570
|
+
for (const result of coverageArmResults(machine, node, outcome, index, context, pending.input, candidates, dynamic)) {
|
|
1571
|
+
nextSteps.push({ node, result, next });
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
}
|
|
1576
|
+
if (node.kind === 'acting' &&
|
|
1577
|
+
Object.hasOwn(node.value.binding.result, NEEDS_BOSS_REPLY) &&
|
|
1578
|
+
!path.steps.some((step) => step.questionWait !== undefined)) {
|
|
1579
|
+
for (const [index, arm] of transitionArms(node.value.invocation.onDone).entries()) {
|
|
1580
|
+
const rawTarget = rawArmTarget(arm);
|
|
1581
|
+
const waitRef = rawTarget === undefined
|
|
1582
|
+
? undefined
|
|
1583
|
+
: stateRefForTarget(refs, rawTarget, node.value.ref);
|
|
1584
|
+
if (waitRef === undefined ||
|
|
1585
|
+
(waitRef.stableId !== AWAIT_BOSS_REPLY_STATE &&
|
|
1586
|
+
!tagsOf(waitRef.state).includes('playbook.parked')))
|
|
1587
|
+
continue;
|
|
1588
|
+
for (const result of coverageArmResults(machine, node, 'onDone', index, context, pending.input, candidates, undefined, NEEDS_BOSS_REPLY)) {
|
|
1589
|
+
nextSteps.push({ node, result, next: node, questionWait: waitRef });
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
}
|
|
1593
|
+
for (const step of nextSteps) {
|
|
1594
|
+
if (path.steps.length + 1 >= MAX_COVERAGE_PATH_STEPS) {
|
|
1595
|
+
limitation = `exhausted ${MAX_COVERAGE_PATH_STEPS}-invocation path depth`;
|
|
1596
|
+
continue;
|
|
1597
|
+
}
|
|
1598
|
+
const visited = [path.root, ...path.steps.map((step) => step.next)].map(coverageNodeKey);
|
|
1599
|
+
if (step.questionWait === undefined &&
|
|
1600
|
+
visited.includes(coverageNodeKey(step.next))) {
|
|
1601
|
+
limitation ??= 'unsupported repeated-invocation path';
|
|
1602
|
+
continue;
|
|
1603
|
+
}
|
|
1604
|
+
queue.push({ ...path, steps: [...path.steps, step] });
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
finally {
|
|
1608
|
+
replay.actor.stop();
|
|
1609
|
+
}
|
|
1610
|
+
}
|
|
1611
|
+
if (queue.length > 0)
|
|
1612
|
+
limitation = `exhausted ${MAX_COVERAGE_PATH_ATTEMPTS}-attempt path budget`;
|
|
1613
|
+
return {
|
|
1614
|
+
covered: false,
|
|
1615
|
+
entered,
|
|
1616
|
+
...(limitation === undefined ? {} : { limitation }),
|
|
1617
|
+
...(inputFailure === undefined ? {} : { inputFailure }),
|
|
1618
|
+
};
|
|
1170
1619
|
}
|
|
1620
|
+
/**
|
|
1621
|
+
* The value the bridge resolves a completed child with: a child that reached
|
|
1622
|
+
* a success terminal, or an older child that published no terminal kind,
|
|
1623
|
+
* resolves with its own output and reaches `onDone`.
|
|
1624
|
+
*/
|
|
1171
1625
|
function nestedSuccessOutput() {
|
|
1172
1626
|
return {
|
|
1173
1627
|
outcome: 'terminal',
|
|
@@ -1193,6 +1647,33 @@ function nestedFailure(playbookId) {
|
|
|
1193
1647
|
});
|
|
1194
1648
|
return error;
|
|
1195
1649
|
}
|
|
1650
|
+
/**
|
|
1651
|
+
* A child that completed at its own authored failure terminal. The bridge
|
|
1652
|
+
* rejects the call with that exact `ok` result, so a recovering first
|
|
1653
|
+
* `onError` arm written for it is satisfiable under probing.
|
|
1654
|
+
*/
|
|
1655
|
+
function nestedFailureTerminal(playbookId) {
|
|
1656
|
+
const terminal = {
|
|
1657
|
+
stateId: 'coverageFailureTerminal',
|
|
1658
|
+
kind: 'failure',
|
|
1659
|
+
description: 'coverage: the child reported its own failure terminal',
|
|
1660
|
+
};
|
|
1661
|
+
const error = new Error(`Child playbook ${playbookId} reached failure terminal ${terminal.stateId}`);
|
|
1662
|
+
error.name = 'NestedPlaybookCallError';
|
|
1663
|
+
Object.defineProperty(error, 'result', {
|
|
1664
|
+
configurable: true,
|
|
1665
|
+
enumerable: true,
|
|
1666
|
+
writable: true,
|
|
1667
|
+
value: {
|
|
1668
|
+
status: 'ok',
|
|
1669
|
+
playbookId,
|
|
1670
|
+
childSessionId: 'coverage-child-session',
|
|
1671
|
+
output: { response: 'coverage: nested playbook failure terminal' },
|
|
1672
|
+
terminal,
|
|
1673
|
+
},
|
|
1674
|
+
});
|
|
1675
|
+
return error;
|
|
1676
|
+
}
|
|
1196
1677
|
function assignedPayload(base, assignment, tag) {
|
|
1197
1678
|
return overlaidObject(base, assignment.payloads[tag] ?? {});
|
|
1198
1679
|
}
|
|
@@ -1208,7 +1689,7 @@ function playbookEntryPlan(machine, playbook, refs, captains, dynamic) {
|
|
|
1208
1689
|
for (const [armIndex, arm] of arms.entries()) {
|
|
1209
1690
|
const rawTarget = rawArmTarget(arm);
|
|
1210
1691
|
if (rawTarget === undefined ||
|
|
1211
|
-
!
|
|
1692
|
+
!targetEntersRef(refs, stateRefForTarget(refs, rawTarget, captain.ref), playbook.ref)) {
|
|
1212
1693
|
continue;
|
|
1213
1694
|
}
|
|
1214
1695
|
for (const key of Object.keys(captain.binding.result)) {
|
|
@@ -1256,7 +1737,7 @@ function captainPredecessorPlan(machine, target, playbooks, refs, captains) {
|
|
|
1256
1737
|
for (const [armIndex, arm] of arms.entries()) {
|
|
1257
1738
|
const rawTarget = rawArmTarget(arm);
|
|
1258
1739
|
if (rawTarget === undefined ||
|
|
1259
|
-
!
|
|
1740
|
+
!targetEntersRef(refs, stateRefForTarget(refs, rawTarget, playbook.ref), target.ref)) {
|
|
1260
1741
|
continue;
|
|
1261
1742
|
}
|
|
1262
1743
|
const guard = orderedArmPredicate(machine, arms, armIndex);
|
|
@@ -1310,7 +1791,7 @@ function controllerCaptainEntryPlan(machine, target, refs, captains, candidates,
|
|
|
1310
1791
|
for (const resultKey of Object.keys(current.captain.binding.result)) {
|
|
1311
1792
|
const output = synthOutput(current.captain.binding, resultKey);
|
|
1312
1793
|
const nextRef = directTargetRef(machine, refs, current.captain, output, initialContext);
|
|
1313
|
-
const nextCaptain = captains.find((candidate) => nextRef
|
|
1794
|
+
const nextCaptain = captains.find((candidate) => targetEntersRef(refs, nextRef, candidate.ref));
|
|
1314
1795
|
if (nextCaptain !== undefined &&
|
|
1315
1796
|
!visited.has(stateRefKey(nextCaptain.ref))) {
|
|
1316
1797
|
queue.push({
|
|
@@ -1333,7 +1814,7 @@ function controllerCaptainExitPlan(machine, captain, output, refs, captains, hub
|
|
|
1333
1814
|
if (sameStateRef(firstTarget, hub) || firstTarget.state.type === 'final') {
|
|
1334
1815
|
return { following: [], outcome: 'planned' };
|
|
1335
1816
|
}
|
|
1336
|
-
const firstCaptain = captains.find((candidate) =>
|
|
1817
|
+
const firstCaptain = captains.find((candidate) => targetEntersRef(refs, firstTarget, candidate.ref));
|
|
1337
1818
|
if (firstCaptain === undefined) {
|
|
1338
1819
|
return { following: [], outcome: 'runtime' };
|
|
1339
1820
|
}
|
|
@@ -1359,7 +1840,7 @@ function controllerCaptainExitPlan(machine, captain, output, refs, captains, hub
|
|
|
1359
1840
|
if (sameStateRef(nextRef, hub) || nextRef.state.type === 'final') {
|
|
1360
1841
|
return { following, outcome: 'planned' };
|
|
1361
1842
|
}
|
|
1362
|
-
const nextCaptain = captains.find((candidate) =>
|
|
1843
|
+
const nextCaptain = captains.find((candidate) => targetEntersRef(refs, nextRef, candidate.ref));
|
|
1363
1844
|
if (nextCaptain !== undefined &&
|
|
1364
1845
|
!visited.has(stateRefKey(nextCaptain.ref))) {
|
|
1365
1846
|
queue.push({ captain: nextCaptain, following });
|
|
@@ -1508,80 +1989,24 @@ async function probePlaybookOutcome(machine, playbook, refs, captains, outcome,
|
|
|
1508
1989
|
`state ${playbook.ref.stableId} declares no nested playbook ${outcome} transition`,
|
|
1509
1990
|
];
|
|
1510
1991
|
}
|
|
1511
|
-
const
|
|
1512
|
-
const
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
}
|
|
1521
|
-
const entryRef = entry?.captain.ref ?? playbook.ref;
|
|
1522
|
-
const entryId = entry === undefined
|
|
1523
|
-
? interruptTargetForRef(playbook.ref, playbook.ref.stableId)
|
|
1524
|
-
: captainInterruptTarget(entry.captain);
|
|
1525
|
-
const drive = interruptDriveForRef(machine, refs, entryRef, entryId, interruptValues);
|
|
1526
|
-
const input = playbookCoverageInput(machine, playbook, dynamic);
|
|
1527
|
-
const expectedPlaybookId = typeof input?.playbookId === 'string'
|
|
1528
|
-
? input.playbookId
|
|
1529
|
-
: COVERAGE_PLAYBOOK_ID;
|
|
1530
|
-
const fixedEvent = {
|
|
1531
|
-
type: `xstate.${outcome === 'onDone' ? 'done' : 'error'}.actor.${actorId}`,
|
|
1532
|
-
actorId,
|
|
1533
|
-
};
|
|
1534
|
-
const context = initializedCoverageContext(machine, dynamic);
|
|
1535
|
-
const candidateValues = refs.flatMap((ref) => [
|
|
1536
|
-
ref.key,
|
|
1537
|
-
ref.stableId,
|
|
1538
|
-
...(ref.configId === undefined ? [] : [ref.configId]),
|
|
1539
|
-
]);
|
|
1992
|
+
const node = { kind: 'child', value: playbook };
|
|
1993
|
+
const candidateValues = [
|
|
1994
|
+
...interruptValues,
|
|
1995
|
+
...refs.flatMap((ref) => [
|
|
1996
|
+
ref.key,
|
|
1997
|
+
ref.stableId,
|
|
1998
|
+
...(ref.configId === undefined ? [] : [ref.configId]),
|
|
1999
|
+
]),
|
|
2000
|
+
];
|
|
1540
2001
|
const findings = [];
|
|
1541
2002
|
for (const [armIndex, arm] of rawArms.entries()) {
|
|
2003
|
+
await coverageYield();
|
|
1542
2004
|
const rawGuard = armGuard(arm);
|
|
1543
2005
|
if (rawGuard !== undefined &&
|
|
1544
2006
|
resolveGuard(machine, rawGuard) === undefined) {
|
|
1545
2007
|
findings.push(`state ${playbook.ref.stableId}: nested playbook ${outcome} arm ${armIndex} names an unresolvable guard "${guardLabel(rawGuard)}"`);
|
|
1546
2008
|
continue;
|
|
1547
2009
|
}
|
|
1548
|
-
const guard = orderedArmPredicate(machine, rawArms, armIndex);
|
|
1549
|
-
if (guard === undefined)
|
|
1550
|
-
continue;
|
|
1551
|
-
let scriptedResult;
|
|
1552
|
-
if (outcome === 'onDone') {
|
|
1553
|
-
const outputs = [
|
|
1554
|
-
nestedSuccessOutput(),
|
|
1555
|
-
// A resolved promise may still return a value rejected by the linked
|
|
1556
|
-
// runtime's output validator; fallback arms must be independently
|
|
1557
|
-
// selectable and driven as well.
|
|
1558
|
-
{ invalidCoverageOutput: undefined },
|
|
1559
|
-
];
|
|
1560
|
-
for (const base of outputs) {
|
|
1561
|
-
const assignment = probeGuardAssignment(guard.run, fixedEvent, [{ eventField: 'output', tag: 'o:', base }], [...guard.probeValues, ...candidateValues], { initialContext: context, assignContext: false });
|
|
1562
|
-
if (assignment !== undefined) {
|
|
1563
|
-
scriptedResult = assignedPayload(base, assignment, 'o:');
|
|
1564
|
-
break;
|
|
1565
|
-
}
|
|
1566
|
-
}
|
|
1567
|
-
}
|
|
1568
|
-
else {
|
|
1569
|
-
const errors = [
|
|
1570
|
-
nestedFailure(expectedPlaybookId),
|
|
1571
|
-
new Error('coverage: generic nested playbook failure'),
|
|
1572
|
-
];
|
|
1573
|
-
for (const base of errors) {
|
|
1574
|
-
const assignment = probeGuardAssignment(guard.run, fixedEvent, [{ eventField: 'error', tag: 'r:', base, varyExisting: true }], [...guard.probeValues, ...candidateValues], { initialContext: context, assignContext: false });
|
|
1575
|
-
if (assignment !== undefined) {
|
|
1576
|
-
scriptedResult = assignedPayload(base, assignment, 'r:');
|
|
1577
|
-
break;
|
|
1578
|
-
}
|
|
1579
|
-
}
|
|
1580
|
-
}
|
|
1581
|
-
if (scriptedResult === undefined) {
|
|
1582
|
-
findings.push(`state ${playbook.ref.stableId}: nested playbook ${outcome} arm ${armIndex} is unsatisfiable under probing`);
|
|
1583
|
-
continue;
|
|
1584
|
-
}
|
|
1585
2010
|
const rawTarget = rawArmTarget(arm);
|
|
1586
2011
|
const target = rawTarget === undefined
|
|
1587
2012
|
? undefined
|
|
@@ -1594,49 +2019,129 @@ async function probePlaybookOutcome(machine, playbook, refs, captains, outcome,
|
|
|
1594
2019
|
findings.push(`state ${playbook.ref.stableId}: nested playbook ${outcome} arm ${armIndex} does not leave the call state`);
|
|
1595
2020
|
continue;
|
|
1596
2021
|
}
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
actorInput.stateId !== playbook.ref.stableId ||
|
|
1606
|
-
(dynamic !== undefined &&
|
|
1607
|
-
(actorInput.playbookId !== COVERAGE_PLAYBOOK_ID ||
|
|
1608
|
-
actorInput.text !== COVERAGE_PLAYBOOK_INPUT))) {
|
|
1609
|
-
return null;
|
|
1610
|
-
}
|
|
1611
|
-
calls++;
|
|
1612
|
-
return scriptedResult;
|
|
1613
|
-
}, drive.context);
|
|
1614
|
-
gate.armed = true;
|
|
1615
|
-
actor.send(drive.event);
|
|
1616
|
-
let enteredCall = false;
|
|
1617
|
-
const settled = await settle(actor, (snapshot) => {
|
|
1618
|
-
if (atState(playbook.ref)(snapshot))
|
|
1619
|
-
enteredCall = true;
|
|
1620
|
-
return ((calls === 1 && atState(target)(snapshot)) ||
|
|
1621
|
-
(calls === 0 && actor.coverageErrors.length > 0) ||
|
|
1622
|
-
(enteredCall && calls === 0 && leftState(playbook.ref)(snapshot)));
|
|
2022
|
+
let satisfiable = false;
|
|
2023
|
+
const result = await searchCoverageEntry(machine, node, refs, captains, playbookRefs(machine.config), candidateValues, async (actor, pending) => {
|
|
2024
|
+
const outputs = coverageArmResults(machine, node, outcome, armIndex, actor.getSnapshot().context, pending.input, candidateValues, undefined, undefined, false, true);
|
|
2025
|
+
if (outputs.length === 0)
|
|
2026
|
+
return false;
|
|
2027
|
+
satisfiable = true;
|
|
2028
|
+
pending.complete(outputs[0]);
|
|
2029
|
+
return await settle(actor, atState(target), PARALLEL_SETTLE_MS);
|
|
1623
2030
|
});
|
|
1624
|
-
|
|
1625
|
-
const observedStartFailure = actor.coverageErrors.find(({ eventType }) => eventType === `xstate.error.actor.${actorId}` || calls === 0);
|
|
1626
|
-
const inputFailure = calls === 0
|
|
1627
|
-
? observedStartFailure === undefined
|
|
1628
|
-
? finalSnapshot.status === 'error'
|
|
1629
|
-
? coverageErrorMessage(finalSnapshot.error)
|
|
1630
|
-
: playbookInputFailure(playbook, finalSnapshot.context)
|
|
1631
|
-
: coverageErrorMessage(observedStartFailure.error)
|
|
1632
|
-
: undefined;
|
|
1633
|
-
actor.stop();
|
|
1634
|
-
if (inputFailure !== undefined) {
|
|
1635
|
-
findings.push(`state ${playbook.ref.stableId}: nested playbook actor failed to start during ${outcome} coverage: ${inputFailure}`);
|
|
2031
|
+
if (result.covered)
|
|
1636
2032
|
continue;
|
|
2033
|
+
const label = `state ${playbook.ref.stableId}: nested playbook ${outcome} arm ${armIndex}`;
|
|
2034
|
+
if (result.limitation !== undefined)
|
|
2035
|
+
findings.push(`${label}: ${result.limitation}`);
|
|
2036
|
+
else if (result.inputFailure !== undefined && !result.entered)
|
|
2037
|
+
findings.push(`state ${playbook.ref.stableId}: nested playbook actor failed to start during ${outcome} coverage: ${result.inputFailure}`);
|
|
2038
|
+
else if (!result.entered)
|
|
2039
|
+
findings.push(`${label} has no reachable entry under probing (dead or unsupported entry path)`);
|
|
2040
|
+
else if (!satisfiable)
|
|
2041
|
+
findings.push(`${label} is unsatisfiable under probing`);
|
|
2042
|
+
else
|
|
2043
|
+
findings.push(`${label} did not reach ${target.stableId}`);
|
|
2044
|
+
}
|
|
2045
|
+
return findings;
|
|
2046
|
+
}
|
|
2047
|
+
/** No preemption is needed to audit ordinary acting outcomes and one reply. */
|
|
2048
|
+
async function probeNonPreemptiveActor(machine, captain, refs, captains, playbooks, candidates) {
|
|
2049
|
+
const node = { kind: 'acting', value: captain };
|
|
2050
|
+
const findings = [];
|
|
2051
|
+
const stateId = captainPublicStateId(captain);
|
|
2052
|
+
const label = `state ${stateId}`;
|
|
2053
|
+
const search = (check) => searchCoverageEntry(machine, node, refs, captains, playbooks, candidates, check);
|
|
2054
|
+
const detail = (result) => result.limitation ??
|
|
2055
|
+
(result.inputFailure !== undefined
|
|
2056
|
+
? `actor failed to start: ${result.inputFailure}`
|
|
2057
|
+
: result.entered
|
|
2058
|
+
? 'no candidate completed at the declared target under reached-context probing'
|
|
2059
|
+
: 'no reachable entry under probing (dead or unsupported entry path)');
|
|
2060
|
+
for (const key of Object.keys(captain.binding.result)) {
|
|
2061
|
+
await coverageYield();
|
|
2062
|
+
const check = (blankReply) => search(async (actor, pending) => {
|
|
2063
|
+
for (const [index, arm] of transitionArms(captain.invocation.onDone).entries()) {
|
|
2064
|
+
const outputs = coverageArmResults(machine, node, 'onDone', index, actor.getSnapshot().context, pending.input, candidates, undefined, key, false, true);
|
|
2065
|
+
if (outputs.length === 0)
|
|
2066
|
+
continue;
|
|
2067
|
+
const targetName = rawArmTarget(arm);
|
|
2068
|
+
const target = targetName === undefined
|
|
2069
|
+
? undefined
|
|
2070
|
+
: stateRefForTarget(refs, targetName, captain.ref);
|
|
2071
|
+
if (target === undefined || sameStateRef(target, captain.ref))
|
|
2072
|
+
return false;
|
|
2073
|
+
pending.complete(outputs[0]);
|
|
2074
|
+
if (!(await settle(actor, atState(target), PARALLEL_SETTLE_MS)))
|
|
2075
|
+
return false;
|
|
2076
|
+
if (key !== NEEDS_BOSS_REPLY)
|
|
2077
|
+
return true;
|
|
2078
|
+
if (target.stableId !== AWAIT_BOSS_REPLY_STATE &&
|
|
2079
|
+
!tagsOf(target.state).includes('playbook.parked'))
|
|
2080
|
+
return false;
|
|
2081
|
+
if (!blankReply && target.stableId !== AWAIT_BOSS_REPLY_STATE) {
|
|
2082
|
+
actor.send({
|
|
2083
|
+
type: BOSS_REPLY_EVENT,
|
|
2084
|
+
questionId: 'coverage-unknown-question',
|
|
2085
|
+
answer: 'This answer belongs to no pending branch.',
|
|
2086
|
+
});
|
|
2087
|
+
await settleNoTransition();
|
|
2088
|
+
if (!atState(target)(actor.getSnapshot()))
|
|
2089
|
+
return false;
|
|
2090
|
+
}
|
|
2091
|
+
actor.send({
|
|
2092
|
+
type: BOSS_REPLY_EVENT,
|
|
2093
|
+
questionId: stateId,
|
|
2094
|
+
answer: blankReply ? ' ' : 'Proceed as planned.',
|
|
2095
|
+
});
|
|
2096
|
+
if (blankReply) {
|
|
2097
|
+
await settleNoTransition();
|
|
2098
|
+
return !atState(captain.ref)(actor.getSnapshot());
|
|
2099
|
+
}
|
|
2100
|
+
return await settle(actor, atState(captain.ref), PARALLEL_SETTLE_MS);
|
|
2101
|
+
}
|
|
2102
|
+
return false;
|
|
2103
|
+
});
|
|
2104
|
+
const result = await check(false);
|
|
2105
|
+
if (!result.covered)
|
|
2106
|
+
findings.push(`${label}: result "${key}" remains unproved: ${detail(result)}`);
|
|
2107
|
+
if (key === NEEDS_BOSS_REPLY && result.covered) {
|
|
2108
|
+
const blank = await check(true);
|
|
2109
|
+
if (!blank.covered)
|
|
2110
|
+
findings.push(`${label}: a blank ${BOSS_REPLY_EVENT} answer must not resume the state (${detail(blank)})`);
|
|
1637
2111
|
}
|
|
1638
|
-
|
|
1639
|
-
|
|
2112
|
+
}
|
|
2113
|
+
for (const outcome of ['onDone', 'onError']) {
|
|
2114
|
+
await coverageYield();
|
|
2115
|
+
const arms = transitionArms(captain.invocation[outcome]);
|
|
2116
|
+
if (outcome === 'onError' && arms.length === 0)
|
|
2117
|
+
findings.push(`${label} declares no onError transition`);
|
|
2118
|
+
for (const [index, arm] of arms.entries()) {
|
|
2119
|
+
await coverageYield();
|
|
2120
|
+
const rawGuard = armGuard(arm);
|
|
2121
|
+
if (outcome === 'onDone' && rawGuard === undefined)
|
|
2122
|
+
continue;
|
|
2123
|
+
if (rawGuard !== undefined &&
|
|
2124
|
+
resolveGuard(machine, rawGuard) === undefined) {
|
|
2125
|
+
findings.push(`${label}: ${outcome} arm ${index} names an unresolvable guard "${guardLabel(rawGuard)}"`);
|
|
2126
|
+
continue;
|
|
2127
|
+
}
|
|
2128
|
+
const targetName = rawArmTarget(arm);
|
|
2129
|
+
const target = targetName === undefined
|
|
2130
|
+
? undefined
|
|
2131
|
+
: stateRefForTarget(refs, targetName, captain.ref);
|
|
2132
|
+
if (target === undefined || sameStateRef(target, captain.ref)) {
|
|
2133
|
+
findings.push(`${label}: ${outcome} arm ${index} has no distinct observable target`);
|
|
2134
|
+
continue;
|
|
2135
|
+
}
|
|
2136
|
+
const result = await search(async (actor, pending) => {
|
|
2137
|
+
const outputs = coverageArmResults(machine, node, outcome, index, actor.getSnapshot().context, pending.input, candidates, undefined, undefined, true, true);
|
|
2138
|
+
if (outputs.length === 0)
|
|
2139
|
+
return false;
|
|
2140
|
+
pending.complete(outputs[0]);
|
|
2141
|
+
return await settle(actor, atState(target), PARALLEL_SETTLE_MS);
|
|
2142
|
+
});
|
|
2143
|
+
if (!result.covered)
|
|
2144
|
+
findings.push(`${label}: ${outcome} arm ${index} remains unproved: ${detail(result)}`);
|
|
1640
2145
|
}
|
|
1641
2146
|
}
|
|
1642
2147
|
return findings;
|
|
@@ -1726,6 +2231,7 @@ async function probeParallelJoins(machine, parallel, refs, captains, interruptVa
|
|
|
1726
2231
|
const normalizedTargets = arms.map((arm) => rawArmTarget(arm) ?? null);
|
|
1727
2232
|
const findings = [];
|
|
1728
2233
|
for (const [armIndex, rawTarget] of normalizedTargets.entries()) {
|
|
2234
|
+
await coverageYield();
|
|
1729
2235
|
if (rawTarget === null) {
|
|
1730
2236
|
findings.push(`parallel state ${parallel.stableId}: onDone join arm ${armIndex} coverage is unsupported for a target-less arm`);
|
|
1731
2237
|
continue;
|
|
@@ -1738,6 +2244,7 @@ async function probeParallelJoins(machine, parallel, refs, captains, interruptVa
|
|
|
1738
2244
|
}
|
|
1739
2245
|
let exercised = false;
|
|
1740
2246
|
for (const combination of outputs) {
|
|
2247
|
+
await coverageYield();
|
|
1741
2248
|
const entries = branches.map((captain, index) => ({
|
|
1742
2249
|
captain,
|
|
1743
2250
|
output: combination[index],
|
|
@@ -1769,7 +2276,6 @@ async function probeParallelJoins(machine, parallel, refs, captains, interruptVa
|
|
|
1769
2276
|
export function fsmCoverageTestTimeout(fsmModule) {
|
|
1770
2277
|
const machine = findMachine(fsmModule);
|
|
1771
2278
|
const config = machine.config;
|
|
1772
|
-
const states = (config.states ?? {});
|
|
1773
2279
|
const refs = stateRefs(config);
|
|
1774
2280
|
const captains = captainRefs(config);
|
|
1775
2281
|
const playbooks = playbookRefs(config);
|
|
@@ -1777,54 +2283,96 @@ export function fsmCoverageTestTimeout(fsmModule) {
|
|
|
1777
2283
|
const controller = isControllerMachine(config);
|
|
1778
2284
|
const rootInterruptProbes = transitionArms((config.on ?? {})[INTERRUPT_EVENT]).length;
|
|
1779
2285
|
const rootSettles = rootInterruptProbes * SETTLE_MS;
|
|
1780
|
-
const initial = typeof config.initial === 'string' ? config.initial : undefined;
|
|
1781
2286
|
const entryEvents = new Set([
|
|
1782
|
-
...
|
|
2287
|
+
...initialActiveRefs(config, refs).flatMap((ref) => Object.keys(ref.state.on ?? {})),
|
|
1783
2288
|
...Object.keys(config.on ?? {}),
|
|
1784
2289
|
]);
|
|
1785
2290
|
entryEvents.delete(INTERRUPT_EVENT);
|
|
1786
2291
|
const entrySettles = entryEvents.size * SETTLE_MS;
|
|
1787
2292
|
let captainSettles = 0;
|
|
1788
2293
|
let guardProbeCalls = rootInterruptProbes;
|
|
2294
|
+
let pathSearches = controller
|
|
2295
|
+
? 0
|
|
2296
|
+
: playbooks.reduce((total, playbook) => total +
|
|
2297
|
+
transitionArms(playbook.invocation.onDone).length +
|
|
2298
|
+
transitionArms(playbook.invocation.onError).length, 0);
|
|
1789
2299
|
for (const captain of captains) {
|
|
1790
2300
|
const resultKeys = Object.keys(captain.binding.result);
|
|
2301
|
+
const guardedDoneArms = transitionArms(captain.invocation.onDone).filter((arm) => armGuard(arm) !== undefined).length;
|
|
2302
|
+
const errorArms = transitionArms(captain.invocation.onError).length;
|
|
2303
|
+
if (!controller && rootInterruptProbes === 0) {
|
|
2304
|
+
pathSearches +=
|
|
2305
|
+
resultKeys.length +
|
|
2306
|
+
(resultKeys.includes(NEEDS_BOSS_REPLY) ? 1 : 0) +
|
|
2307
|
+
guardedDoneArms +
|
|
2308
|
+
errorArms;
|
|
2309
|
+
continue;
|
|
2310
|
+
}
|
|
1791
2311
|
captainSettles +=
|
|
1792
2312
|
resultKeys.reduce((total, key) => total + (key === NEEDS_BOSS_REPLY ? 4 : controller ? 2 : 1), 0) * SETTLE_MS;
|
|
1793
2313
|
// One directly selectable onError arm is driven.
|
|
1794
2314
|
captainSettles += SETTLE_MS;
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
//
|
|
1798
|
-
|
|
1799
|
-
|
|
2315
|
+
// Acceptance includes ordered fallbacks. Script arm probes have one zero
|
|
2316
|
+
// status and a capped set of nonzero statuses; ordinary actors also probe
|
|
2317
|
+
// malformed bare outputs for defensive arms.
|
|
2318
|
+
const resultCandidates = captain.binding.actor === 'script'
|
|
2319
|
+
? 1 + MAX_SCRIPT_EXIT_STATUSES
|
|
2320
|
+
: resultKeys.length;
|
|
2321
|
+
guardProbeCalls +=
|
|
2322
|
+
resultCandidates * transitionArms(captain.invocation.onDone).length +
|
|
2323
|
+
guardedDoneArms *
|
|
2324
|
+
(captain.binding.actor === 'script'
|
|
2325
|
+
? resultCandidates
|
|
2326
|
+
: resultKeys.length * 2) +
|
|
2327
|
+
errorArms;
|
|
1800
2328
|
// Every result, the blank Boss-reply check, and onError enter through an
|
|
1801
2329
|
// independently context-probed interrupt plan.
|
|
1802
2330
|
guardProbeCalls +=
|
|
1803
2331
|
resultKeys.length + (resultKeys.includes(NEEDS_BOSS_REPLY) ? 1 : 0) + 1;
|
|
1804
2332
|
}
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
const
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
2333
|
+
const nodes = coverageNodes(captains, playbooks);
|
|
2334
|
+
const entryArmCount = [
|
|
2335
|
+
config.on ?? {},
|
|
2336
|
+
...initialActiveRefs(config, refs).map((ref) => ref.state.on ?? {}),
|
|
2337
|
+
].reduce((total, events) => total +
|
|
2338
|
+
Object.values(events).reduce((count, arms) => count + transitionArms(arms).length, 0), 0);
|
|
2339
|
+
const maxNodeCandidates = Math.max(0, ...nodes.map((node) => {
|
|
2340
|
+
const done = transitionArms(node.value.invocation.onDone).length;
|
|
2341
|
+
const error = transitionArms(node.value.invocation.onError).length;
|
|
2342
|
+
if (node.kind === 'child')
|
|
2343
|
+
return 3 * (done + error);
|
|
2344
|
+
const keys = Object.keys(node.value.binding.result);
|
|
2345
|
+
const candidates = node.value.binding.actor === 'script'
|
|
2346
|
+
? keys.length * MAX_SCRIPT_EXIT_STATUSES
|
|
2347
|
+
: 2 * keys.length;
|
|
2348
|
+
// Target malformed-output probes and predecessor question revisits both
|
|
2349
|
+
// fit this allowance; real searches stop early on a satisfying target.
|
|
2350
|
+
return (done * (candidates + (keys.includes(NEEDS_BOSS_REPLY) ? 1 : 0)) + error);
|
|
2351
|
+
}));
|
|
2352
|
+
guardProbeCalls +=
|
|
2353
|
+
pathSearches *
|
|
2354
|
+
(nodes.length * (1 + entryArmCount) +
|
|
2355
|
+
MAX_COVERAGE_PATH_ATTEMPTS * 2 * maxNodeCandidates);
|
|
2356
|
+
// Parallel question/join helpers retain their independent entry probes.
|
|
2357
|
+
guardProbeCalls += 2 * parallels.length;
|
|
2358
|
+
// Each charged replay contains at most eight invocation waits, one parked
|
|
2359
|
+
// question wait and two target/reply settles, plus no-transition checks.
|
|
2360
|
+
const pathSettles = pathSearches *
|
|
2361
|
+
MAX_COVERAGE_PATH_ATTEMPTS *
|
|
2362
|
+
((MAX_COVERAGE_PATH_STEPS + 3) * PARALLEL_SETTLE_MS + 2 * 10);
|
|
1815
2363
|
const parallelSettles = parallels.reduce((total, parallel) => total +
|
|
1816
2364
|
2 * PARALLEL_QUESTION_SETTLE_MS +
|
|
1817
2365
|
transitionArms(parallel.state.onDone).length *
|
|
1818
2366
|
MAX_PARALLEL_COMBINATIONS *
|
|
1819
2367
|
PARALLEL_SETTLE_MS, 0);
|
|
1820
2368
|
const guardAllowance = guardProbeCalls * Math.ceil(MAX_PROBES / PROBES_PER_TIMEOUT_MILLISECOND);
|
|
1821
|
-
return Math.max(MIN_COVERAGE_TEST_TIMEOUT_MS, rootSettles +
|
|
2369
|
+
return Math.min(MAX_COVERAGE_TEST_TIMEOUT_MS, Math.max(MIN_COVERAGE_TEST_TIMEOUT_MS, rootSettles +
|
|
1822
2370
|
entrySettles +
|
|
1823
2371
|
captainSettles +
|
|
1824
|
-
|
|
2372
|
+
pathSettles +
|
|
1825
2373
|
parallelSettles +
|
|
1826
2374
|
guardAllowance +
|
|
1827
|
-
COVERAGE_TIMEOUT_MARGIN_MS);
|
|
2375
|
+
COVERAGE_TIMEOUT_MARGIN_MS));
|
|
1828
2376
|
}
|
|
1829
2377
|
/**
|
|
1830
2378
|
* Checks transition coverage over a compiled `playbook` artifact's machine
|
|
@@ -1834,6 +2382,34 @@ export function fsmCoverageTestTimeout(fsmModule) {
|
|
|
1834
2382
|
* surface entirely, in which case interrupt coverage is skipped.
|
|
1835
2383
|
*/
|
|
1836
2384
|
export async function checkFsmCoverage(fsmModule, opts = {}) {
|
|
2385
|
+
opts.signal?.throwIfAborted();
|
|
2386
|
+
if (opts.timeoutMs !== undefined &&
|
|
2387
|
+
(!Number.isFinite(opts.timeoutMs) || opts.timeoutMs <= 0))
|
|
2388
|
+
throw new RangeError('FSM coverage timeout must be a positive finite number');
|
|
2389
|
+
const started = performance.now();
|
|
2390
|
+
const run = {
|
|
2391
|
+
signal: opts.signal,
|
|
2392
|
+
deadline: started +
|
|
2393
|
+
Math.min(opts.timeoutMs ?? Infinity, MAX_COVERAGE_TEST_TIMEOUT_MS),
|
|
2394
|
+
lastYield: started,
|
|
2395
|
+
actors: new Set(),
|
|
2396
|
+
};
|
|
2397
|
+
return coverageRuns.run(run, async () => {
|
|
2398
|
+
let completed = false;
|
|
2399
|
+
try {
|
|
2400
|
+
run.deadline = Math.min(run.deadline, started + fsmCoverageTestTimeout(fsmModule));
|
|
2401
|
+
await coverageYield(true);
|
|
2402
|
+
const findings = await runFsmCoverage(fsmModule, opts.sourceText ?? '');
|
|
2403
|
+
await coverageYield(true);
|
|
2404
|
+
completed = true;
|
|
2405
|
+
return findings;
|
|
2406
|
+
}
|
|
2407
|
+
finally {
|
|
2408
|
+
stopCoverageActors(run.actors, !completed);
|
|
2409
|
+
}
|
|
2410
|
+
});
|
|
2411
|
+
}
|
|
2412
|
+
async function runFsmCoverage(fsmModule, sourceText) {
|
|
1837
2413
|
const findings = [];
|
|
1838
2414
|
const machine = findMachine(fsmModule);
|
|
1839
2415
|
const config = machine.config;
|
|
@@ -1843,8 +2419,8 @@ export async function checkFsmCoverage(fsmModule, opts = {}) {
|
|
|
1843
2419
|
const playbooks = playbookRefs(config);
|
|
1844
2420
|
const captainByRef = new Map(captains.map((captain) => [stateRefKey(captain.ref), captain]));
|
|
1845
2421
|
const sourceCandidates = [
|
|
1846
|
-
...identifierLiterals(
|
|
1847
|
-
...numericLiterals(
|
|
2422
|
+
...identifierLiterals(sourceText),
|
|
2423
|
+
...numericLiterals(sourceText),
|
|
1848
2424
|
];
|
|
1849
2425
|
const isController = isControllerMachine(config);
|
|
1850
2426
|
const controllerNearMisses = captains.flatMap((captain) => {
|
|
@@ -1861,12 +2437,14 @@ export async function checkFsmCoverage(fsmModule, opts = {}) {
|
|
|
1861
2437
|
const parallelRefs = refs.filter((ref) => ref.state.type === 'parallel');
|
|
1862
2438
|
findings.push(...repeatedParallelRoleFindings(refs, captains));
|
|
1863
2439
|
for (const { captain, nearMiss } of controllerNearMisses) {
|
|
2440
|
+
await coverageYield();
|
|
1864
2441
|
const detail = nearMiss.missing.length > 0
|
|
1865
2442
|
? `missing ${JSON.stringify(nearMiss.missing[0])}`
|
|
1866
2443
|
: `extra ${JSON.stringify(nearMiss.extra[0])}`;
|
|
1867
2444
|
findings.push(`state ${captain.binding.stateId}: controller decision contract near-miss (${detail}); the controller domain requires exactly ${CONTROLLER_ACTION_GUARDS.join(', ')}`);
|
|
1868
2445
|
}
|
|
1869
2446
|
for (const ref of parallelRefs) {
|
|
2447
|
+
await coverageYield();
|
|
1870
2448
|
if (!normalizeArms(ref.state.onDone).some((arm) => arm.target !== null)) {
|
|
1871
2449
|
findings.push(`parallel state ${ref.stableId} declares no onDone join`);
|
|
1872
2450
|
}
|
|
@@ -1907,11 +2485,13 @@ export async function checkFsmCoverage(fsmModule, opts = {}) {
|
|
|
1907
2485
|
if (!canJump && handlesInterruptSomewhere && !controllerContractCandidate) {
|
|
1908
2486
|
findings.push(`machine declares no root ${INTERRUPT_EVENT} event`);
|
|
1909
2487
|
}
|
|
1910
|
-
if (
|
|
2488
|
+
if (!isController) {
|
|
1911
2489
|
for (const playbook of playbooks) {
|
|
2490
|
+
await coverageYield();
|
|
1912
2491
|
findings.push(...(await probePlaybookInvocation(machine, playbook, refs, captains, sourceCandidates)));
|
|
1913
2492
|
}
|
|
1914
|
-
for (const parallel of parallelRefs) {
|
|
2493
|
+
for (const parallel of canJump ? parallelRefs : []) {
|
|
2494
|
+
await coverageYield();
|
|
1915
2495
|
findings.push(...(await probeParallelQuestions(machine, parallel, refs, captains, sourceCandidates)), ...(await probeParallelJoins(machine, parallel, refs, captains, sourceCandidates)));
|
|
1916
2496
|
}
|
|
1917
2497
|
}
|
|
@@ -1928,6 +2508,7 @@ export async function checkFsmCoverage(fsmModule, opts = {}) {
|
|
|
1928
2508
|
// captain state parks in it).
|
|
1929
2509
|
if (canJump) {
|
|
1930
2510
|
for (const [armIndex, arm] of rootArms.entries()) {
|
|
2511
|
+
await coverageYield();
|
|
1931
2512
|
if (arm.target === null)
|
|
1932
2513
|
continue;
|
|
1933
2514
|
const target = stateRefForTarget(refs, arm.target);
|
|
@@ -1962,6 +2543,7 @@ export async function checkFsmCoverage(fsmModule, opts = {}) {
|
|
|
1962
2543
|
const initialRef = initialPath.at(-1);
|
|
1963
2544
|
const entryArms = Object.assign({}, config.on ?? {}, ...initialPath.map((ref) => ref.state.on ?? {}));
|
|
1964
2545
|
for (const [event, raw] of Object.entries(entryArms)) {
|
|
2546
|
+
await coverageYield();
|
|
1965
2547
|
if (event === INTERRUPT_EVENT)
|
|
1966
2548
|
continue;
|
|
1967
2549
|
const arms = normalizeArms(raw);
|
|
@@ -1991,8 +2573,11 @@ export async function checkFsmCoverage(fsmModule, opts = {}) {
|
|
|
1991
2573
|
if (unsupportedController)
|
|
1992
2574
|
return findings;
|
|
1993
2575
|
for (const captain of captains) {
|
|
1994
|
-
|
|
1995
|
-
|
|
2576
|
+
await coverageYield();
|
|
2577
|
+
if (!canJump && !isController) {
|
|
2578
|
+
findings.push(...(await probeNonPreemptiveActor(machine, captain, refs, captains, playbooks, [...stateCandidates, ...sourceCandidates])));
|
|
2579
|
+
continue;
|
|
2580
|
+
}
|
|
1996
2581
|
const state = captain.binding;
|
|
1997
2582
|
const stateKey = state.stateId;
|
|
1998
2583
|
const stateId = captainPublicStateId(captain);
|
|
@@ -2002,23 +2587,21 @@ export async function checkFsmCoverage(fsmModule, opts = {}) {
|
|
|
2002
2587
|
const rawDoneArms = transitionArms(captain.invocation.onDone);
|
|
2003
2588
|
const onDoneArms = normalizeArms(captain.invocation.onDone);
|
|
2004
2589
|
const controllerResultByArm = new Map();
|
|
2005
|
-
// Every declared result needs an arm
|
|
2006
|
-
//
|
|
2007
|
-
//
|
|
2008
|
-
// otherwise orphaned key look covered.
|
|
2590
|
+
// Every declared result needs an ordered arm accepting its valid output.
|
|
2591
|
+
// A fallback is reachable exactly when all preceding guards reject it.
|
|
2592
|
+
// Result names alone cannot distinguish authored failure from an orphan.
|
|
2009
2593
|
for (const key of Object.keys(state.result)) {
|
|
2594
|
+
await coverageYield();
|
|
2010
2595
|
const output = synthOutput(state, key);
|
|
2011
2596
|
const accepting = new Map();
|
|
2597
|
+
const acceptedOutputs = new Map();
|
|
2012
2598
|
for (const [index, arm] of rawDoneArms.entries()) {
|
|
2599
|
+
await coverageYield();
|
|
2013
2600
|
const target = onDoneArms[index]?.target ?? null;
|
|
2014
2601
|
const rawGuard = armGuard(arm);
|
|
2015
|
-
if (
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
}
|
|
2019
|
-
continue;
|
|
2020
|
-
}
|
|
2021
|
-
if (target === null || resolveGuard(machine, rawGuard) === undefined) {
|
|
2602
|
+
if (target === null ||
|
|
2603
|
+
(rawGuard !== undefined &&
|
|
2604
|
+
resolveGuard(machine, rawGuard) === undefined)) {
|
|
2022
2605
|
continue;
|
|
2023
2606
|
}
|
|
2024
2607
|
const guard = orderedArmPredicate(machine, rawDoneArms, index);
|
|
@@ -2029,8 +2612,18 @@ export async function checkFsmCoverage(fsmModule, opts = {}) {
|
|
|
2029
2612
|
if (assignment !== undefined)
|
|
2030
2613
|
accepting.set(index, assignment);
|
|
2031
2614
|
}
|
|
2032
|
-
else
|
|
2033
|
-
|
|
2615
|
+
else {
|
|
2616
|
+
const outputs = resultOutputCandidates(state, key, [
|
|
2617
|
+
...(resolveGuard(machine, rawGuard)?.probeValues ?? []),
|
|
2618
|
+
...candidates,
|
|
2619
|
+
]);
|
|
2620
|
+
for (const candidate of outputs) {
|
|
2621
|
+
await coverageYield();
|
|
2622
|
+
if (doneGuardSatisfiable(guard, doneEvent, candidate, candidates, state.actor === 'script')) {
|
|
2623
|
+
accepting.set(index, undefined);
|
|
2624
|
+
acceptedOutputs.set(JSON.stringify(candidate), candidate);
|
|
2625
|
+
}
|
|
2626
|
+
}
|
|
2034
2627
|
}
|
|
2035
2628
|
}
|
|
2036
2629
|
if (accepting.size === 0) {
|
|
@@ -2058,37 +2651,15 @@ export async function checkFsmCoverage(fsmModule, opts = {}) {
|
|
|
2058
2651
|
// initial context is a known accepting arm. Encountering an unresolved
|
|
2059
2652
|
// guard first makes driving unsafe: XState reports that error
|
|
2060
2653
|
// asynchronously, so the arm audit below owns the finding (c887fc4).
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
const
|
|
2064
|
-
if (
|
|
2065
|
-
directArm =
|
|
2066
|
-
|
|
2067
|
-
}
|
|
2068
|
-
const guard = resolveGuard(machine, rawGuard);
|
|
2069
|
-
if (guard === undefined) {
|
|
2070
|
-
safeToDrive = false;
|
|
2071
|
-
break;
|
|
2072
|
-
}
|
|
2073
|
-
try {
|
|
2074
|
-
if (guard.run({
|
|
2075
|
-
context: initialCoverageContext,
|
|
2076
|
-
event: { ...doneEvent, output },
|
|
2077
|
-
})) {
|
|
2078
|
-
directArm = index;
|
|
2079
|
-
break;
|
|
2080
|
-
}
|
|
2081
|
-
}
|
|
2082
|
-
catch {
|
|
2083
|
-
safeToDrive = false;
|
|
2654
|
+
for (const candidate of acceptedOutputs.values()) {
|
|
2655
|
+
await coverageYield();
|
|
2656
|
+
const selected = directlySelectedArm(machine, captain, candidate, initialCoverageContext);
|
|
2657
|
+
if (selected !== undefined && accepting.has(selected)) {
|
|
2658
|
+
directArm = selected;
|
|
2659
|
+
drivenOutput = candidate;
|
|
2084
2660
|
break;
|
|
2085
2661
|
}
|
|
2086
2662
|
}
|
|
2087
|
-
if (!safeToDrive ||
|
|
2088
|
-
directArm === undefined ||
|
|
2089
|
-
!accepting.has(directArm)) {
|
|
2090
|
-
continue;
|
|
2091
|
-
}
|
|
2092
2663
|
}
|
|
2093
2664
|
if (directArm === undefined) {
|
|
2094
2665
|
continue;
|
|
@@ -2216,9 +2787,11 @@ export async function checkFsmCoverage(fsmModule, opts = {}) {
|
|
|
2216
2787
|
actor.stop();
|
|
2217
2788
|
}
|
|
2218
2789
|
// Every onDone arm is satisfiable under the actual done-event identity.
|
|
2219
|
-
//
|
|
2220
|
-
//
|
|
2790
|
+
// Ordinary actors also probe bare malformed forms for authored defensive
|
|
2791
|
+
// arms. Scripts resolve only their exact runtime guard/exit-status pair;
|
|
2792
|
+
// invented payload fields or impossible statuses cannot cover an arm.
|
|
2221
2793
|
for (const [index, arm] of rawDoneArms.entries()) {
|
|
2794
|
+
await coverageYield();
|
|
2222
2795
|
const rawGuard = armGuard(arm);
|
|
2223
2796
|
if (rawGuard === undefined)
|
|
2224
2797
|
continue;
|
|
@@ -2232,8 +2805,15 @@ export async function checkFsmCoverage(fsmModule, opts = {}) {
|
|
|
2232
2805
|
// ordered reachability of later arms unsafe to evaluate.
|
|
2233
2806
|
if (guard === undefined)
|
|
2234
2807
|
continue;
|
|
2235
|
-
const anyOutput = Object.keys(state.result).some((key) =>
|
|
2236
|
-
|
|
2808
|
+
const anyOutput = Object.keys(state.result).some((key) => {
|
|
2809
|
+
const outputs = resultOutputCandidates(state, key, [
|
|
2810
|
+
...declaredGuard.probeValues,
|
|
2811
|
+
...candidates,
|
|
2812
|
+
]);
|
|
2813
|
+
return (outputs.some((candidate) => doneGuardSatisfiable(guard, doneEvent, candidate, candidates, state.actor === 'script')) ||
|
|
2814
|
+
(state.actor !== 'script' &&
|
|
2815
|
+
doneGuardSatisfiable(guard, doneEvent, { guard: key }, candidates)));
|
|
2816
|
+
});
|
|
2237
2817
|
if (!anyOutput) {
|
|
2238
2818
|
findings.push(`state ${stateKey}: onDone arm ${index} (target ${onDoneArms[index]?.target ?? 'none'}) is unsatisfiable under probing`);
|
|
2239
2819
|
}
|
|
@@ -2250,6 +2830,7 @@ export async function checkFsmCoverage(fsmModule, opts = {}) {
|
|
|
2250
2830
|
const forcedError = new Error('coverage: forced captain failure');
|
|
2251
2831
|
let hasUnresolvableErrorGuard = false;
|
|
2252
2832
|
for (const [index, arm] of rawErrorArms.entries()) {
|
|
2833
|
+
await coverageYield();
|
|
2253
2834
|
const rawGuard = armGuard(arm);
|
|
2254
2835
|
if (rawGuard !== undefined) {
|
|
2255
2836
|
const declaredGuard = resolveGuard(machine, rawGuard);
|
|
@@ -2271,6 +2852,7 @@ export async function checkFsmCoverage(fsmModule, opts = {}) {
|
|
|
2271
2852
|
let directErrorArm;
|
|
2272
2853
|
let errorDriveSafe = true;
|
|
2273
2854
|
for (const [index, arm] of rawErrorArms.entries()) {
|
|
2855
|
+
await coverageYield();
|
|
2274
2856
|
const rawGuard = armGuard(arm);
|
|
2275
2857
|
if (rawGuard === undefined) {
|
|
2276
2858
|
directErrorArm = index;
|
|
@@ -2291,9 +2873,13 @@ export async function checkFsmCoverage(fsmModule, opts = {}) {
|
|
|
2291
2873
|
}
|
|
2292
2874
|
}
|
|
2293
2875
|
catch {
|
|
2876
|
+
coverageCheckpoint();
|
|
2294
2877
|
errorDriveSafe = false;
|
|
2295
2878
|
break;
|
|
2296
2879
|
}
|
|
2880
|
+
finally {
|
|
2881
|
+
coverageCheckpoint();
|
|
2882
|
+
}
|
|
2297
2883
|
}
|
|
2298
2884
|
if (!errorDriveSafe || directErrorArm === undefined)
|
|
2299
2885
|
continue;
|