@sublang/slc 0.9.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 +805 -254
- 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 +580 -126
- 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,20 +1328,294 @@ 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
|
+
}
|
|
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
|
+
}
|
|
1169
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
|
}
|
|
1171
1620
|
/**
|
|
1172
1621
|
* The value the bridge resolves a completed child with: a child that reached
|
|
@@ -1240,7 +1689,7 @@ function playbookEntryPlan(machine, playbook, refs, captains, dynamic) {
|
|
|
1240
1689
|
for (const [armIndex, arm] of arms.entries()) {
|
|
1241
1690
|
const rawTarget = rawArmTarget(arm);
|
|
1242
1691
|
if (rawTarget === undefined ||
|
|
1243
|
-
!
|
|
1692
|
+
!targetEntersRef(refs, stateRefForTarget(refs, rawTarget, captain.ref), playbook.ref)) {
|
|
1244
1693
|
continue;
|
|
1245
1694
|
}
|
|
1246
1695
|
for (const key of Object.keys(captain.binding.result)) {
|
|
@@ -1288,7 +1737,7 @@ function captainPredecessorPlan(machine, target, playbooks, refs, captains) {
|
|
|
1288
1737
|
for (const [armIndex, arm] of arms.entries()) {
|
|
1289
1738
|
const rawTarget = rawArmTarget(arm);
|
|
1290
1739
|
if (rawTarget === undefined ||
|
|
1291
|
-
!
|
|
1740
|
+
!targetEntersRef(refs, stateRefForTarget(refs, rawTarget, playbook.ref), target.ref)) {
|
|
1292
1741
|
continue;
|
|
1293
1742
|
}
|
|
1294
1743
|
const guard = orderedArmPredicate(machine, arms, armIndex);
|
|
@@ -1342,7 +1791,7 @@ function controllerCaptainEntryPlan(machine, target, refs, captains, candidates,
|
|
|
1342
1791
|
for (const resultKey of Object.keys(current.captain.binding.result)) {
|
|
1343
1792
|
const output = synthOutput(current.captain.binding, resultKey);
|
|
1344
1793
|
const nextRef = directTargetRef(machine, refs, current.captain, output, initialContext);
|
|
1345
|
-
const nextCaptain = captains.find((candidate) => nextRef
|
|
1794
|
+
const nextCaptain = captains.find((candidate) => targetEntersRef(refs, nextRef, candidate.ref));
|
|
1346
1795
|
if (nextCaptain !== undefined &&
|
|
1347
1796
|
!visited.has(stateRefKey(nextCaptain.ref))) {
|
|
1348
1797
|
queue.push({
|
|
@@ -1365,7 +1814,7 @@ function controllerCaptainExitPlan(machine, captain, output, refs, captains, hub
|
|
|
1365
1814
|
if (sameStateRef(firstTarget, hub) || firstTarget.state.type === 'final') {
|
|
1366
1815
|
return { following: [], outcome: 'planned' };
|
|
1367
1816
|
}
|
|
1368
|
-
const firstCaptain = captains.find((candidate) =>
|
|
1817
|
+
const firstCaptain = captains.find((candidate) => targetEntersRef(refs, firstTarget, candidate.ref));
|
|
1369
1818
|
if (firstCaptain === undefined) {
|
|
1370
1819
|
return { following: [], outcome: 'runtime' };
|
|
1371
1820
|
}
|
|
@@ -1391,7 +1840,7 @@ function controllerCaptainExitPlan(machine, captain, output, refs, captains, hub
|
|
|
1391
1840
|
if (sameStateRef(nextRef, hub) || nextRef.state.type === 'final') {
|
|
1392
1841
|
return { following, outcome: 'planned' };
|
|
1393
1842
|
}
|
|
1394
|
-
const nextCaptain = captains.find((candidate) =>
|
|
1843
|
+
const nextCaptain = captains.find((candidate) => targetEntersRef(refs, nextRef, candidate.ref));
|
|
1395
1844
|
if (nextCaptain !== undefined &&
|
|
1396
1845
|
!visited.has(stateRefKey(nextCaptain.ref))) {
|
|
1397
1846
|
queue.push({ captain: nextCaptain, following });
|
|
@@ -1540,83 +1989,24 @@ async function probePlaybookOutcome(machine, playbook, refs, captains, outcome,
|
|
|
1540
1989
|
`state ${playbook.ref.stableId} declares no nested playbook ${outcome} transition`,
|
|
1541
1990
|
];
|
|
1542
1991
|
}
|
|
1543
|
-
const
|
|
1544
|
-
const
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
}
|
|
1553
|
-
const entryRef = entry?.captain.ref ?? playbook.ref;
|
|
1554
|
-
const entryId = entry === undefined
|
|
1555
|
-
? interruptTargetForRef(playbook.ref, playbook.ref.stableId)
|
|
1556
|
-
: captainInterruptTarget(entry.captain);
|
|
1557
|
-
const drive = interruptDriveForRef(machine, refs, entryRef, entryId, interruptValues);
|
|
1558
|
-
const input = playbookCoverageInput(machine, playbook, dynamic);
|
|
1559
|
-
const expectedPlaybookId = typeof input?.playbookId === 'string'
|
|
1560
|
-
? input.playbookId
|
|
1561
|
-
: COVERAGE_PLAYBOOK_ID;
|
|
1562
|
-
const fixedEvent = {
|
|
1563
|
-
type: `xstate.${outcome === 'onDone' ? 'done' : 'error'}.actor.${actorId}`,
|
|
1564
|
-
actorId,
|
|
1565
|
-
};
|
|
1566
|
-
const context = initializedCoverageContext(machine, dynamic);
|
|
1567
|
-
const candidateValues = refs.flatMap((ref) => [
|
|
1568
|
-
ref.key,
|
|
1569
|
-
ref.stableId,
|
|
1570
|
-
...(ref.configId === undefined ? [] : [ref.configId]),
|
|
1571
|
-
]);
|
|
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
|
+
];
|
|
1572
2001
|
const findings = [];
|
|
1573
2002
|
for (const [armIndex, arm] of rawArms.entries()) {
|
|
2003
|
+
await coverageYield();
|
|
1574
2004
|
const rawGuard = armGuard(arm);
|
|
1575
2005
|
if (rawGuard !== undefined &&
|
|
1576
2006
|
resolveGuard(machine, rawGuard) === undefined) {
|
|
1577
2007
|
findings.push(`state ${playbook.ref.stableId}: nested playbook ${outcome} arm ${armIndex} names an unresolvable guard "${guardLabel(rawGuard)}"`);
|
|
1578
2008
|
continue;
|
|
1579
2009
|
}
|
|
1580
|
-
const guard = orderedArmPredicate(machine, rawArms, armIndex);
|
|
1581
|
-
if (guard === undefined)
|
|
1582
|
-
continue;
|
|
1583
|
-
let scriptedResult;
|
|
1584
|
-
if (outcome === 'onDone') {
|
|
1585
|
-
const outputs = [
|
|
1586
|
-
nestedSuccessOutput(),
|
|
1587
|
-
// A resolved promise may still return a value rejected by the linked
|
|
1588
|
-
// runtime's output validator; fallback arms must be independently
|
|
1589
|
-
// selectable and driven as well.
|
|
1590
|
-
{ invalidCoverageOutput: undefined },
|
|
1591
|
-
];
|
|
1592
|
-
for (const base of outputs) {
|
|
1593
|
-
const assignment = probeGuardAssignment(guard.run, fixedEvent, [{ eventField: 'output', tag: 'o:', base }], [...guard.probeValues, ...candidateValues], { initialContext: context, assignContext: false });
|
|
1594
|
-
if (assignment !== undefined) {
|
|
1595
|
-
scriptedResult = assignedPayload(base, assignment, 'o:');
|
|
1596
|
-
break;
|
|
1597
|
-
}
|
|
1598
|
-
}
|
|
1599
|
-
}
|
|
1600
|
-
else {
|
|
1601
|
-
const errors = [
|
|
1602
|
-
nestedFailure(expectedPlaybookId),
|
|
1603
|
-
// A completed child that reached its own failure terminal arrives on
|
|
1604
|
-
// this same path, so an arm written only for that shape is driven too.
|
|
1605
|
-
nestedFailureTerminal(expectedPlaybookId),
|
|
1606
|
-
new Error('coverage: generic nested playbook failure'),
|
|
1607
|
-
];
|
|
1608
|
-
for (const base of errors) {
|
|
1609
|
-
const assignment = probeGuardAssignment(guard.run, fixedEvent, [{ eventField: 'error', tag: 'r:', base, varyExisting: true }], [...guard.probeValues, ...candidateValues], { initialContext: context, assignContext: false });
|
|
1610
|
-
if (assignment !== undefined) {
|
|
1611
|
-
scriptedResult = assignedPayload(base, assignment, 'r:');
|
|
1612
|
-
break;
|
|
1613
|
-
}
|
|
1614
|
-
}
|
|
1615
|
-
}
|
|
1616
|
-
if (scriptedResult === undefined) {
|
|
1617
|
-
findings.push(`state ${playbook.ref.stableId}: nested playbook ${outcome} arm ${armIndex} is unsatisfiable under probing`);
|
|
1618
|
-
continue;
|
|
1619
|
-
}
|
|
1620
2010
|
const rawTarget = rawArmTarget(arm);
|
|
1621
2011
|
const target = rawTarget === undefined
|
|
1622
2012
|
? undefined
|
|
@@ -1629,49 +2019,129 @@ async function probePlaybookOutcome(machine, playbook, refs, captains, outcome,
|
|
|
1629
2019
|
findings.push(`state ${playbook.ref.stableId}: nested playbook ${outcome} arm ${armIndex} does not leave the call state`);
|
|
1630
2020
|
continue;
|
|
1631
2021
|
}
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
actorInput.stateId !== playbook.ref.stableId ||
|
|
1641
|
-
(dynamic !== undefined &&
|
|
1642
|
-
(actorInput.playbookId !== COVERAGE_PLAYBOOK_ID ||
|
|
1643
|
-
actorInput.text !== COVERAGE_PLAYBOOK_INPUT))) {
|
|
1644
|
-
return null;
|
|
1645
|
-
}
|
|
1646
|
-
calls++;
|
|
1647
|
-
return scriptedResult;
|
|
1648
|
-
}, drive.context);
|
|
1649
|
-
gate.armed = true;
|
|
1650
|
-
actor.send(drive.event);
|
|
1651
|
-
let enteredCall = false;
|
|
1652
|
-
const settled = await settle(actor, (snapshot) => {
|
|
1653
|
-
if (atState(playbook.ref)(snapshot))
|
|
1654
|
-
enteredCall = true;
|
|
1655
|
-
return ((calls === 1 && atState(target)(snapshot)) ||
|
|
1656
|
-
(calls === 0 && actor.coverageErrors.length > 0) ||
|
|
1657
|
-
(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);
|
|
1658
2030
|
});
|
|
1659
|
-
|
|
1660
|
-
const observedStartFailure = actor.coverageErrors.find(({ eventType }) => eventType === `xstate.error.actor.${actorId}` || calls === 0);
|
|
1661
|
-
const inputFailure = calls === 0
|
|
1662
|
-
? observedStartFailure === undefined
|
|
1663
|
-
? finalSnapshot.status === 'error'
|
|
1664
|
-
? coverageErrorMessage(finalSnapshot.error)
|
|
1665
|
-
: playbookInputFailure(playbook, finalSnapshot.context)
|
|
1666
|
-
: coverageErrorMessage(observedStartFailure.error)
|
|
1667
|
-
: undefined;
|
|
1668
|
-
actor.stop();
|
|
1669
|
-
if (inputFailure !== undefined) {
|
|
1670
|
-
findings.push(`state ${playbook.ref.stableId}: nested playbook actor failed to start during ${outcome} coverage: ${inputFailure}`);
|
|
2031
|
+
if (result.covered)
|
|
1671
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)})`);
|
|
1672
2111
|
}
|
|
1673
|
-
|
|
1674
|
-
|
|
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)}`);
|
|
1675
2145
|
}
|
|
1676
2146
|
}
|
|
1677
2147
|
return findings;
|
|
@@ -1761,6 +2231,7 @@ async function probeParallelJoins(machine, parallel, refs, captains, interruptVa
|
|
|
1761
2231
|
const normalizedTargets = arms.map((arm) => rawArmTarget(arm) ?? null);
|
|
1762
2232
|
const findings = [];
|
|
1763
2233
|
for (const [armIndex, rawTarget] of normalizedTargets.entries()) {
|
|
2234
|
+
await coverageYield();
|
|
1764
2235
|
if (rawTarget === null) {
|
|
1765
2236
|
findings.push(`parallel state ${parallel.stableId}: onDone join arm ${armIndex} coverage is unsupported for a target-less arm`);
|
|
1766
2237
|
continue;
|
|
@@ -1773,6 +2244,7 @@ async function probeParallelJoins(machine, parallel, refs, captains, interruptVa
|
|
|
1773
2244
|
}
|
|
1774
2245
|
let exercised = false;
|
|
1775
2246
|
for (const combination of outputs) {
|
|
2247
|
+
await coverageYield();
|
|
1776
2248
|
const entries = branches.map((captain, index) => ({
|
|
1777
2249
|
captain,
|
|
1778
2250
|
output: combination[index],
|
|
@@ -1804,7 +2276,6 @@ async function probeParallelJoins(machine, parallel, refs, captains, interruptVa
|
|
|
1804
2276
|
export function fsmCoverageTestTimeout(fsmModule) {
|
|
1805
2277
|
const machine = findMachine(fsmModule);
|
|
1806
2278
|
const config = machine.config;
|
|
1807
|
-
const states = (config.states ?? {});
|
|
1808
2279
|
const refs = stateRefs(config);
|
|
1809
2280
|
const captains = captainRefs(config);
|
|
1810
2281
|
const playbooks = playbookRefs(config);
|
|
@@ -1812,54 +2283,96 @@ export function fsmCoverageTestTimeout(fsmModule) {
|
|
|
1812
2283
|
const controller = isControllerMachine(config);
|
|
1813
2284
|
const rootInterruptProbes = transitionArms((config.on ?? {})[INTERRUPT_EVENT]).length;
|
|
1814
2285
|
const rootSettles = rootInterruptProbes * SETTLE_MS;
|
|
1815
|
-
const initial = typeof config.initial === 'string' ? config.initial : undefined;
|
|
1816
2286
|
const entryEvents = new Set([
|
|
1817
|
-
...
|
|
2287
|
+
...initialActiveRefs(config, refs).flatMap((ref) => Object.keys(ref.state.on ?? {})),
|
|
1818
2288
|
...Object.keys(config.on ?? {}),
|
|
1819
2289
|
]);
|
|
1820
2290
|
entryEvents.delete(INTERRUPT_EVENT);
|
|
1821
2291
|
const entrySettles = entryEvents.size * SETTLE_MS;
|
|
1822
2292
|
let captainSettles = 0;
|
|
1823
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);
|
|
1824
2299
|
for (const captain of captains) {
|
|
1825
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
|
+
}
|
|
1826
2311
|
captainSettles +=
|
|
1827
2312
|
resultKeys.reduce((total, key) => total + (key === NEEDS_BOSS_REPLY ? 4 : controller ? 2 : 1), 0) * SETTLE_MS;
|
|
1828
2313
|
// One directly selectable onError arm is driven.
|
|
1829
2314
|
captainSettles += SETTLE_MS;
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
//
|
|
1833
|
-
|
|
1834
|
-
|
|
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;
|
|
1835
2328
|
// Every result, the blank Boss-reply check, and onError enter through an
|
|
1836
2329
|
// independently context-probed interrupt plan.
|
|
1837
2330
|
guardProbeCalls +=
|
|
1838
2331
|
resultKeys.length + (resultKeys.includes(NEEDS_BOSS_REPLY) ? 1 : 0) + 1;
|
|
1839
2332
|
}
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
const
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
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);
|
|
1850
2363
|
const parallelSettles = parallels.reduce((total, parallel) => total +
|
|
1851
2364
|
2 * PARALLEL_QUESTION_SETTLE_MS +
|
|
1852
2365
|
transitionArms(parallel.state.onDone).length *
|
|
1853
2366
|
MAX_PARALLEL_COMBINATIONS *
|
|
1854
2367
|
PARALLEL_SETTLE_MS, 0);
|
|
1855
2368
|
const guardAllowance = guardProbeCalls * Math.ceil(MAX_PROBES / PROBES_PER_TIMEOUT_MILLISECOND);
|
|
1856
|
-
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 +
|
|
1857
2370
|
entrySettles +
|
|
1858
2371
|
captainSettles +
|
|
1859
|
-
|
|
2372
|
+
pathSettles +
|
|
1860
2373
|
parallelSettles +
|
|
1861
2374
|
guardAllowance +
|
|
1862
|
-
COVERAGE_TIMEOUT_MARGIN_MS);
|
|
2375
|
+
COVERAGE_TIMEOUT_MARGIN_MS));
|
|
1863
2376
|
}
|
|
1864
2377
|
/**
|
|
1865
2378
|
* Checks transition coverage over a compiled `playbook` artifact's machine
|
|
@@ -1869,6 +2382,34 @@ export function fsmCoverageTestTimeout(fsmModule) {
|
|
|
1869
2382
|
* surface entirely, in which case interrupt coverage is skipped.
|
|
1870
2383
|
*/
|
|
1871
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) {
|
|
1872
2413
|
const findings = [];
|
|
1873
2414
|
const machine = findMachine(fsmModule);
|
|
1874
2415
|
const config = machine.config;
|
|
@@ -1878,8 +2419,8 @@ export async function checkFsmCoverage(fsmModule, opts = {}) {
|
|
|
1878
2419
|
const playbooks = playbookRefs(config);
|
|
1879
2420
|
const captainByRef = new Map(captains.map((captain) => [stateRefKey(captain.ref), captain]));
|
|
1880
2421
|
const sourceCandidates = [
|
|
1881
|
-
...identifierLiterals(
|
|
1882
|
-
...numericLiterals(
|
|
2422
|
+
...identifierLiterals(sourceText),
|
|
2423
|
+
...numericLiterals(sourceText),
|
|
1883
2424
|
];
|
|
1884
2425
|
const isController = isControllerMachine(config);
|
|
1885
2426
|
const controllerNearMisses = captains.flatMap((captain) => {
|
|
@@ -1896,12 +2437,14 @@ export async function checkFsmCoverage(fsmModule, opts = {}) {
|
|
|
1896
2437
|
const parallelRefs = refs.filter((ref) => ref.state.type === 'parallel');
|
|
1897
2438
|
findings.push(...repeatedParallelRoleFindings(refs, captains));
|
|
1898
2439
|
for (const { captain, nearMiss } of controllerNearMisses) {
|
|
2440
|
+
await coverageYield();
|
|
1899
2441
|
const detail = nearMiss.missing.length > 0
|
|
1900
2442
|
? `missing ${JSON.stringify(nearMiss.missing[0])}`
|
|
1901
2443
|
: `extra ${JSON.stringify(nearMiss.extra[0])}`;
|
|
1902
2444
|
findings.push(`state ${captain.binding.stateId}: controller decision contract near-miss (${detail}); the controller domain requires exactly ${CONTROLLER_ACTION_GUARDS.join(', ')}`);
|
|
1903
2445
|
}
|
|
1904
2446
|
for (const ref of parallelRefs) {
|
|
2447
|
+
await coverageYield();
|
|
1905
2448
|
if (!normalizeArms(ref.state.onDone).some((arm) => arm.target !== null)) {
|
|
1906
2449
|
findings.push(`parallel state ${ref.stableId} declares no onDone join`);
|
|
1907
2450
|
}
|
|
@@ -1942,11 +2485,13 @@ export async function checkFsmCoverage(fsmModule, opts = {}) {
|
|
|
1942
2485
|
if (!canJump && handlesInterruptSomewhere && !controllerContractCandidate) {
|
|
1943
2486
|
findings.push(`machine declares no root ${INTERRUPT_EVENT} event`);
|
|
1944
2487
|
}
|
|
1945
|
-
if (
|
|
2488
|
+
if (!isController) {
|
|
1946
2489
|
for (const playbook of playbooks) {
|
|
2490
|
+
await coverageYield();
|
|
1947
2491
|
findings.push(...(await probePlaybookInvocation(machine, playbook, refs, captains, sourceCandidates)));
|
|
1948
2492
|
}
|
|
1949
|
-
for (const parallel of parallelRefs) {
|
|
2493
|
+
for (const parallel of canJump ? parallelRefs : []) {
|
|
2494
|
+
await coverageYield();
|
|
1950
2495
|
findings.push(...(await probeParallelQuestions(machine, parallel, refs, captains, sourceCandidates)), ...(await probeParallelJoins(machine, parallel, refs, captains, sourceCandidates)));
|
|
1951
2496
|
}
|
|
1952
2497
|
}
|
|
@@ -1963,6 +2508,7 @@ export async function checkFsmCoverage(fsmModule, opts = {}) {
|
|
|
1963
2508
|
// captain state parks in it).
|
|
1964
2509
|
if (canJump) {
|
|
1965
2510
|
for (const [armIndex, arm] of rootArms.entries()) {
|
|
2511
|
+
await coverageYield();
|
|
1966
2512
|
if (arm.target === null)
|
|
1967
2513
|
continue;
|
|
1968
2514
|
const target = stateRefForTarget(refs, arm.target);
|
|
@@ -1997,6 +2543,7 @@ export async function checkFsmCoverage(fsmModule, opts = {}) {
|
|
|
1997
2543
|
const initialRef = initialPath.at(-1);
|
|
1998
2544
|
const entryArms = Object.assign({}, config.on ?? {}, ...initialPath.map((ref) => ref.state.on ?? {}));
|
|
1999
2545
|
for (const [event, raw] of Object.entries(entryArms)) {
|
|
2546
|
+
await coverageYield();
|
|
2000
2547
|
if (event === INTERRUPT_EVENT)
|
|
2001
2548
|
continue;
|
|
2002
2549
|
const arms = normalizeArms(raw);
|
|
@@ -2026,8 +2573,11 @@ export async function checkFsmCoverage(fsmModule, opts = {}) {
|
|
|
2026
2573
|
if (unsupportedController)
|
|
2027
2574
|
return findings;
|
|
2028
2575
|
for (const captain of captains) {
|
|
2029
|
-
|
|
2030
|
-
|
|
2576
|
+
await coverageYield();
|
|
2577
|
+
if (!canJump && !isController) {
|
|
2578
|
+
findings.push(...(await probeNonPreemptiveActor(machine, captain, refs, captains, playbooks, [...stateCandidates, ...sourceCandidates])));
|
|
2579
|
+
continue;
|
|
2580
|
+
}
|
|
2031
2581
|
const state = captain.binding;
|
|
2032
2582
|
const stateKey = state.stateId;
|
|
2033
2583
|
const stateId = captainPublicStateId(captain);
|
|
@@ -2037,23 +2587,21 @@ export async function checkFsmCoverage(fsmModule, opts = {}) {
|
|
|
2037
2587
|
const rawDoneArms = transitionArms(captain.invocation.onDone);
|
|
2038
2588
|
const onDoneArms = normalizeArms(captain.invocation.onDone);
|
|
2039
2589
|
const controllerResultByArm = new Map();
|
|
2040
|
-
// Every declared result needs an arm
|
|
2041
|
-
//
|
|
2042
|
-
//
|
|
2043
|
-
// 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.
|
|
2044
2593
|
for (const key of Object.keys(state.result)) {
|
|
2594
|
+
await coverageYield();
|
|
2045
2595
|
const output = synthOutput(state, key);
|
|
2046
2596
|
const accepting = new Map();
|
|
2597
|
+
const acceptedOutputs = new Map();
|
|
2047
2598
|
for (const [index, arm] of rawDoneArms.entries()) {
|
|
2599
|
+
await coverageYield();
|
|
2048
2600
|
const target = onDoneArms[index]?.target ?? null;
|
|
2049
2601
|
const rawGuard = armGuard(arm);
|
|
2050
|
-
if (
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
}
|
|
2054
|
-
continue;
|
|
2055
|
-
}
|
|
2056
|
-
if (target === null || resolveGuard(machine, rawGuard) === undefined) {
|
|
2602
|
+
if (target === null ||
|
|
2603
|
+
(rawGuard !== undefined &&
|
|
2604
|
+
resolveGuard(machine, rawGuard) === undefined)) {
|
|
2057
2605
|
continue;
|
|
2058
2606
|
}
|
|
2059
2607
|
const guard = orderedArmPredicate(machine, rawDoneArms, index);
|
|
@@ -2064,8 +2612,18 @@ export async function checkFsmCoverage(fsmModule, opts = {}) {
|
|
|
2064
2612
|
if (assignment !== undefined)
|
|
2065
2613
|
accepting.set(index, assignment);
|
|
2066
2614
|
}
|
|
2067
|
-
else
|
|
2068
|
-
|
|
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
|
+
}
|
|
2069
2627
|
}
|
|
2070
2628
|
}
|
|
2071
2629
|
if (accepting.size === 0) {
|
|
@@ -2093,37 +2651,15 @@ export async function checkFsmCoverage(fsmModule, opts = {}) {
|
|
|
2093
2651
|
// initial context is a known accepting arm. Encountering an unresolved
|
|
2094
2652
|
// guard first makes driving unsafe: XState reports that error
|
|
2095
2653
|
// asynchronously, so the arm audit below owns the finding (c887fc4).
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
const
|
|
2099
|
-
if (
|
|
2100
|
-
directArm =
|
|
2101
|
-
|
|
2102
|
-
}
|
|
2103
|
-
const guard = resolveGuard(machine, rawGuard);
|
|
2104
|
-
if (guard === undefined) {
|
|
2105
|
-
safeToDrive = false;
|
|
2106
|
-
break;
|
|
2107
|
-
}
|
|
2108
|
-
try {
|
|
2109
|
-
if (guard.run({
|
|
2110
|
-
context: initialCoverageContext,
|
|
2111
|
-
event: { ...doneEvent, output },
|
|
2112
|
-
})) {
|
|
2113
|
-
directArm = index;
|
|
2114
|
-
break;
|
|
2115
|
-
}
|
|
2116
|
-
}
|
|
2117
|
-
catch {
|
|
2118
|
-
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;
|
|
2119
2660
|
break;
|
|
2120
2661
|
}
|
|
2121
2662
|
}
|
|
2122
|
-
if (!safeToDrive ||
|
|
2123
|
-
directArm === undefined ||
|
|
2124
|
-
!accepting.has(directArm)) {
|
|
2125
|
-
continue;
|
|
2126
|
-
}
|
|
2127
2663
|
}
|
|
2128
2664
|
if (directArm === undefined) {
|
|
2129
2665
|
continue;
|
|
@@ -2251,9 +2787,11 @@ export async function checkFsmCoverage(fsmModule, opts = {}) {
|
|
|
2251
2787
|
actor.stop();
|
|
2252
2788
|
}
|
|
2253
2789
|
// Every onDone arm is satisfiable under the actual done-event identity.
|
|
2254
|
-
//
|
|
2255
|
-
//
|
|
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.
|
|
2256
2793
|
for (const [index, arm] of rawDoneArms.entries()) {
|
|
2794
|
+
await coverageYield();
|
|
2257
2795
|
const rawGuard = armGuard(arm);
|
|
2258
2796
|
if (rawGuard === undefined)
|
|
2259
2797
|
continue;
|
|
@@ -2267,8 +2805,15 @@ export async function checkFsmCoverage(fsmModule, opts = {}) {
|
|
|
2267
2805
|
// ordered reachability of later arms unsafe to evaluate.
|
|
2268
2806
|
if (guard === undefined)
|
|
2269
2807
|
continue;
|
|
2270
|
-
const anyOutput = Object.keys(state.result).some((key) =>
|
|
2271
|
-
|
|
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
|
+
});
|
|
2272
2817
|
if (!anyOutput) {
|
|
2273
2818
|
findings.push(`state ${stateKey}: onDone arm ${index} (target ${onDoneArms[index]?.target ?? 'none'}) is unsatisfiable under probing`);
|
|
2274
2819
|
}
|
|
@@ -2285,6 +2830,7 @@ export async function checkFsmCoverage(fsmModule, opts = {}) {
|
|
|
2285
2830
|
const forcedError = new Error('coverage: forced captain failure');
|
|
2286
2831
|
let hasUnresolvableErrorGuard = false;
|
|
2287
2832
|
for (const [index, arm] of rawErrorArms.entries()) {
|
|
2833
|
+
await coverageYield();
|
|
2288
2834
|
const rawGuard = armGuard(arm);
|
|
2289
2835
|
if (rawGuard !== undefined) {
|
|
2290
2836
|
const declaredGuard = resolveGuard(machine, rawGuard);
|
|
@@ -2306,6 +2852,7 @@ export async function checkFsmCoverage(fsmModule, opts = {}) {
|
|
|
2306
2852
|
let directErrorArm;
|
|
2307
2853
|
let errorDriveSafe = true;
|
|
2308
2854
|
for (const [index, arm] of rawErrorArms.entries()) {
|
|
2855
|
+
await coverageYield();
|
|
2309
2856
|
const rawGuard = armGuard(arm);
|
|
2310
2857
|
if (rawGuard === undefined) {
|
|
2311
2858
|
directErrorArm = index;
|
|
@@ -2326,9 +2873,13 @@ export async function checkFsmCoverage(fsmModule, opts = {}) {
|
|
|
2326
2873
|
}
|
|
2327
2874
|
}
|
|
2328
2875
|
catch {
|
|
2876
|
+
coverageCheckpoint();
|
|
2329
2877
|
errorDriveSafe = false;
|
|
2330
2878
|
break;
|
|
2331
2879
|
}
|
|
2880
|
+
finally {
|
|
2881
|
+
coverageCheckpoint();
|
|
2882
|
+
}
|
|
2332
2883
|
}
|
|
2333
2884
|
if (!errorDriveSafe || directErrorArm === undefined)
|
|
2334
2885
|
continue;
|