@sublang/slc 0.4.0 → 0.6.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 +100 -131
- package/dist/app.d.ts +20 -20
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +37 -30
- package/dist/app.js.map +1 -1
- package/dist/artifacts.d.ts +6 -6
- package/dist/artifacts.d.ts.map +1 -1
- package/dist/artifacts.js +9 -9
- package/dist/artifacts.js.map +1 -1
- package/dist/cli.js +3 -3
- package/dist/cligent-agent.d.ts +1 -1
- package/dist/cligent-agent.js +5 -5
- package/dist/compiled-executor.d.ts +14 -6
- package/dist/compiled-executor.d.ts.map +1 -1
- package/dist/compiled-executor.js +133 -31
- package/dist/compiled-executor.js.map +1 -1
- package/dist/config-file.d.ts +10 -6
- package/dist/config-file.d.ts.map +1 -1
- package/dist/config-file.js +23 -10
- package/dist/config-file.js.map +1 -1
- package/dist/config.d.ts +22 -18
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +90 -27
- package/dist/config.js.map +1 -1
- package/dist/emitted-imports.d.ts +14 -0
- package/dist/emitted-imports.d.ts.map +1 -1
- package/dist/emitted-imports.js +63 -7
- package/dist/emitted-imports.js.map +1 -1
- package/dist/entry-module.d.ts +5 -3
- package/dist/entry-module.d.ts.map +1 -1
- package/dist/entry-module.js +41 -13
- package/dist/entry-module.js.map +1 -1
- package/dist/execution.d.ts +10 -10
- package/dist/execution.d.ts.map +1 -1
- package/dist/execution.js +13 -13
- package/dist/execution.js.map +1 -1
- package/dist/hash.js +2 -2
- package/dist/host-capabilities.d.ts +115 -0
- package/dist/host-capabilities.d.ts.map +1 -0
- package/dist/host-capabilities.js +507 -0
- package/dist/host-capabilities.js.map +1 -0
- 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 +2 -2
- package/dist/interpreter.d.ts.map +1 -1
- package/dist/interpreter.js +7 -7
- package/dist/invocation.d.ts +6 -6
- package/dist/invocation.d.ts.map +1 -1
- package/dist/invocation.js +1 -1
- package/dist/link.d.ts +4 -4
- package/dist/link.d.ts.map +1 -1
- package/dist/link.js +11 -11
- package/dist/link.js.map +1 -1
- package/dist/phase-runner.d.ts +4 -4
- package/dist/phase-runner.js +9 -9
- package/dist/phase.d.ts +4 -4
- package/dist/phase.d.ts.map +1 -1
- package/dist/phase.js +7 -7
- package/dist/phase.js.map +1 -1
- package/dist/pin-closure.d.ts +35 -7
- package/dist/pin-closure.d.ts.map +1 -1
- package/dist/pin-closure.js +135 -21
- package/dist/pin-closure.js.map +1 -1
- package/dist/pin-currency.d.ts +9 -4
- package/dist/pin-currency.d.ts.map +1 -1
- package/dist/pin-currency.js +57 -20
- package/dist/pin-currency.js.map +1 -1
- package/dist/pin-generate.d.ts +7 -7
- package/dist/pin-generate.d.ts.map +1 -1
- package/dist/pin-generate.js +14 -8
- package/dist/pin-generate.js.map +1 -1
- package/dist/pin-inputs.d.ts +41 -0
- package/dist/pin-inputs.d.ts.map +1 -0
- package/dist/pin-inputs.js +179 -0
- package/dist/pin-inputs.js.map +1 -0
- package/dist/pin-paths.d.ts +1 -1
- package/dist/pin-paths.js +4 -4
- package/dist/pin-paths.js.map +1 -1
- package/dist/pins.d.ts +4 -4
- package/dist/pins.d.ts.map +1 -1
- package/dist/pins.js +5 -5
- package/dist/pins.js.map +1 -1
- package/dist/pipeline.d.ts +5 -5
- package/dist/pipeline.js +10 -10
- package/dist/playbook-contract.d.ts +53 -5
- package/dist/playbook-contract.d.ts.map +1 -1
- package/dist/playbook-contract.js +70 -21
- package/dist/playbook-contract.js.map +1 -1
- package/dist/playbook-ports.d.ts +9 -9
- package/dist/playbook-ports.d.ts.map +1 -1
- package/dist/playbook-ports.js +6 -4
- package/dist/playbook-ports.js.map +1 -1
- package/dist/progress.d.ts +8 -9
- package/dist/progress.d.ts.map +1 -1
- package/dist/progress.js +3 -3
- package/dist/progress.js.map +1 -1
- package/dist/resolver.d.ts +9 -9
- package/dist/resolver.d.ts.map +1 -1
- package/dist/resolver.js +12 -12
- package/dist/resolver.js.map +1 -1
- package/dist/reviewing-agent.d.ts +18 -0
- package/dist/reviewing-agent.d.ts.map +1 -0
- package/dist/reviewing-agent.js +295 -0
- package/dist/reviewing-agent.js.map +1 -0
- package/dist/runner.d.ts +1 -1
- package/dist/runner.d.ts.map +1 -1
- package/dist/runner.js +195 -80
- package/dist/runner.js.map +1 -1
- package/dist/slc.config.template.yaml +6 -2
- package/dist/verify-coverage.d.ts +4 -3
- package/dist/verify-coverage.d.ts.map +1 -1
- package/dist/verify-coverage.js +509 -71
- package/dist/verify-coverage.js.map +1 -1
- package/dist/verify.d.ts +111 -15
- package/dist/verify.d.ts.map +1 -1
- package/dist/verify.js +656 -59
- package/dist/verify.js.map +1 -1
- package/package.json +3 -3
package/dist/verify-coverage.js
CHANGED
|
@@ -2,25 +2,27 @@
|
|
|
2
2
|
// SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
|
|
3
3
|
/**
|
|
4
4
|
* FSM transition-coverage verification for a compiled `playbook` artifact
|
|
5
|
-
* (
|
|
5
|
+
* (verification-6; DR-009).
|
|
6
6
|
*
|
|
7
7
|
* {@link checkFsmCoverage} drives the artifact's machine with scripted Captain
|
|
8
8
|
* and nested-playbook actors and returns findings when a declared transition
|
|
9
|
-
* is not reachable: every Captain result key must fire a transition
|
|
10
|
-
* `needsBossReply` suspending in the Boss-reply wait state and
|
|
11
|
-
* `BOSS_REPLY`),
|
|
12
|
-
*
|
|
13
|
-
*
|
|
9
|
+
* is not reachable: every ordinary Captain result key must fire a transition
|
|
10
|
+
* out (with `needsBossReply` suspending in the Boss-reply wait state and
|
|
11
|
+
* resuming on `BOSS_REPLY`), every controller action must return to its session
|
|
12
|
+
* hub or shutdown final without a wait, nested calls must transition on success
|
|
13
|
+
* and failure, every `onError` arm must land on its target, every
|
|
14
|
+
* `BOSS_INTERRUPT` target must be enterable, and guard-free root entry events
|
|
15
|
+
* must transition.
|
|
14
16
|
* Context-dependent `onDone` arms that a jumped-in actor cannot satisfy are
|
|
15
17
|
* covered by deterministic guard-satisfiability probing — candidate values
|
|
16
18
|
* mined from the guard's own source — so an unsatisfiable arm is still flagged.
|
|
17
19
|
* The checker needs only the artifact and `xstate`; the emitted per-artifact
|
|
18
|
-
* test runs it beside the artifacts. See specs/
|
|
20
|
+
* test runs it beside the artifacts. See specs/packages/verification.md.
|
|
19
21
|
*/
|
|
20
22
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
21
23
|
import { join } from 'node:path';
|
|
22
24
|
import { createActor, fromPromise } from 'xstate';
|
|
23
|
-
import { AWAIT_BOSS_REPLY_STATE, BOSS_REPLY_EVENT, INTERRUPT_EVENT, NEEDS_BOSS_REPLY, VERIFY_MODULE, enumerateCaptainStates, enumerateScriptStates, loadFsmModule, normalizeArms, } from './verify.js';
|
|
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';
|
|
24
26
|
/** The `gears2fsm`-mandated captain actor name a machine declares. */
|
|
25
27
|
export const CAPTAIN_ACTOR = 'captain';
|
|
26
28
|
function stateRefKey(ref) {
|
|
@@ -200,20 +202,31 @@ function initializedCoverageContext(machine, dynamic) {
|
|
|
200
202
|
...coverageGuardContext(dynamic),
|
|
201
203
|
};
|
|
202
204
|
}
|
|
203
|
-
/**
|
|
204
|
-
const
|
|
205
|
+
/** The `Output shall include` clause a result description declares. */
|
|
206
|
+
const OUTPUT_CLAUSE = /Output shall include\s+(.+)$/;
|
|
207
|
+
/** Every backticked `field:` property named inside that clause. */
|
|
208
|
+
const REQUIRED_FIELD = /`([A-Za-z_$][A-Za-z0-9_$]*):/g;
|
|
205
209
|
/** Structured fields named by the generic Captain result contracts. */
|
|
206
210
|
const STRUCTURED_RESULT_FIELD = /\b(response|question|remainingPlan|nextPlaybookId|nextPlaybookInput)\b/g;
|
|
207
211
|
function requiredFields(description) {
|
|
212
|
+
const clause = OUTPUT_CLAUSE.exec(description)?.[1] ?? '';
|
|
208
213
|
return [
|
|
209
214
|
...new Set([
|
|
210
|
-
...[...
|
|
215
|
+
...[...clause.matchAll(REQUIRED_FIELD)].map((match) => match[1]),
|
|
211
216
|
...[...description.matchAll(STRUCTURED_RESULT_FIELD)].map((match) => match[1]),
|
|
212
217
|
]),
|
|
213
218
|
];
|
|
214
219
|
}
|
|
215
220
|
function synthesizedFieldValue(field) {
|
|
216
221
|
switch (field) {
|
|
222
|
+
case 'text':
|
|
223
|
+
return COVERAGE_FINAL_RESPONSE;
|
|
224
|
+
case 'playbookId':
|
|
225
|
+
return COVERAGE_PLAYBOOK_ID;
|
|
226
|
+
case 'input':
|
|
227
|
+
return COVERAGE_PLAYBOOK_INPUT;
|
|
228
|
+
case 'actionId':
|
|
229
|
+
return 'coverage-runtime-action';
|
|
217
230
|
case 'question':
|
|
218
231
|
return 'What should happen next?';
|
|
219
232
|
case 'remainingPlan':
|
|
@@ -278,6 +291,21 @@ function stateRefs(config) {
|
|
|
278
291
|
visit((config.states ?? {}));
|
|
279
292
|
return out;
|
|
280
293
|
}
|
|
294
|
+
/** The root initial state followed through every compound initial child. */
|
|
295
|
+
function initialActiveRefs(config, refs) {
|
|
296
|
+
const active = [];
|
|
297
|
+
let path = [];
|
|
298
|
+
let initial = config.initial;
|
|
299
|
+
while (typeof initial === 'string') {
|
|
300
|
+
path = [...path, initial];
|
|
301
|
+
const ref = refs.find((candidate) => candidate.path.join('\u0000') === path.join('\u0000'));
|
|
302
|
+
if (ref === undefined)
|
|
303
|
+
break;
|
|
304
|
+
active.push(ref);
|
|
305
|
+
initial = ref.state.initial;
|
|
306
|
+
}
|
|
307
|
+
return active;
|
|
308
|
+
}
|
|
281
309
|
/** Captain bindings paired with the nested state node that owns the invoke. */
|
|
282
310
|
function captainRefs(config) {
|
|
283
311
|
const out = [];
|
|
@@ -361,7 +389,7 @@ function playbookRefs(config) {
|
|
|
361
389
|
? [{ invocation, invocationIndex, ref }]
|
|
362
390
|
: []));
|
|
363
391
|
}
|
|
364
|
-
// Local copy: this module is copied verbatim beside the artifact (
|
|
392
|
+
// Local copy: this module is copied verbatim beside the artifact (verification-12),
|
|
365
393
|
// so it may not import a sibling module.
|
|
366
394
|
function coverageErrorMessage(error) {
|
|
367
395
|
try {
|
|
@@ -454,7 +482,7 @@ function persistedSnapshotWithContext(provided, context) {
|
|
|
454
482
|
seed.stop();
|
|
455
483
|
}
|
|
456
484
|
}
|
|
457
|
-
function makeActor(machine, script, playbookScript = () => null, restoredContext) {
|
|
485
|
+
function makeActor(machine, script, playbookScript = () => null, restoredContext, observeTransition) {
|
|
458
486
|
const coverageErrors = [];
|
|
459
487
|
const workActor = fromPromise(async ({ input }) => {
|
|
460
488
|
const output = script(input ?? {});
|
|
@@ -493,6 +521,32 @@ function makeActor(machine, script, playbookScript = () => null, restoredContext
|
|
|
493
521
|
!('event' in inspection)) {
|
|
494
522
|
return;
|
|
495
523
|
}
|
|
524
|
+
const inspectedTransitions = inspection
|
|
525
|
+
._transitions;
|
|
526
|
+
if (inspection.type === '@xstate.microstep' &&
|
|
527
|
+
Array.isArray(inspectedTransitions)) {
|
|
528
|
+
for (const transition of inspectedTransitions) {
|
|
529
|
+
if (typeof transition !== 'object' || transition === null) {
|
|
530
|
+
continue;
|
|
531
|
+
}
|
|
532
|
+
const source = transition.source;
|
|
533
|
+
const target = transition.target;
|
|
534
|
+
observeTransition?.({
|
|
535
|
+
...(typeof source === 'object' &&
|
|
536
|
+
source !== null &&
|
|
537
|
+
typeof source.id === 'string'
|
|
538
|
+
? { sourceId: source.id }
|
|
539
|
+
: {}),
|
|
540
|
+
targetIds: Array.isArray(target)
|
|
541
|
+
? target.flatMap((candidate) => typeof candidate === 'object' &&
|
|
542
|
+
candidate !== null &&
|
|
543
|
+
typeof candidate.id === 'string'
|
|
544
|
+
? [candidate.id]
|
|
545
|
+
: [])
|
|
546
|
+
: [],
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
}
|
|
496
550
|
const event = inspection.event;
|
|
497
551
|
if (typeof event !== 'object' || event === null)
|
|
498
552
|
return;
|
|
@@ -730,6 +784,55 @@ function interruptDriveForRef(machine, refs, target, targetId, extraValues = [],
|
|
|
730
784
|
satisfiable: true,
|
|
731
785
|
};
|
|
732
786
|
}
|
|
787
|
+
/** A bounded satisfying initial/root event that enters one Captain state. */
|
|
788
|
+
function entryDriveForRef(machine, refs, target, extraValues = [], initialContext = initializedCoverageContext(machine)) {
|
|
789
|
+
const initialPath = initialActiveRefs(machine.config, refs);
|
|
790
|
+
const surfaces = [
|
|
791
|
+
...initialPath
|
|
792
|
+
.slice()
|
|
793
|
+
.reverse()
|
|
794
|
+
.map((source) => ({ source, events: source.state.on ?? {} })),
|
|
795
|
+
{ source: undefined, events: machine.config.on ?? {} },
|
|
796
|
+
];
|
|
797
|
+
for (const { source, events } of surfaces) {
|
|
798
|
+
for (const [eventType, raw] of Object.entries(events)) {
|
|
799
|
+
if (eventType === INTERRUPT_EVENT)
|
|
800
|
+
continue;
|
|
801
|
+
const arms = transitionArms(raw);
|
|
802
|
+
for (const [armIndex, arm] of arms.entries()) {
|
|
803
|
+
const rawTarget = rawArmTarget(arm);
|
|
804
|
+
if (rawTarget === undefined ||
|
|
805
|
+
!sameStateRef(stateRefForTarget(refs, rawTarget, source), target)) {
|
|
806
|
+
continue;
|
|
807
|
+
}
|
|
808
|
+
const guard = orderedArmPredicate(machine, arms, armIndex);
|
|
809
|
+
if (guard === undefined)
|
|
810
|
+
continue;
|
|
811
|
+
const base = { type: eventType };
|
|
812
|
+
const assignment = probeGuardAssignment(guard.run, {}, [{ tag: 'e:', base }], [
|
|
813
|
+
...guard.probeValues,
|
|
814
|
+
...refs.flatMap((ref) => [
|
|
815
|
+
ref.key,
|
|
816
|
+
ref.stableId,
|
|
817
|
+
...(ref.configId === undefined ? [] : [ref.configId]),
|
|
818
|
+
]),
|
|
819
|
+
...extraValues,
|
|
820
|
+
], {
|
|
821
|
+
initialContext,
|
|
822
|
+
varyExistingContext: true,
|
|
823
|
+
});
|
|
824
|
+
if (assignment !== undefined) {
|
|
825
|
+
return {
|
|
826
|
+
event: assignedPayload(base, assignment, 'e:'),
|
|
827
|
+
context: assignment.context,
|
|
828
|
+
satisfiable: true,
|
|
829
|
+
};
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
return undefined;
|
|
835
|
+
}
|
|
733
836
|
/** The arm XState selects for one concrete invocation output and context. */
|
|
734
837
|
function directlySelectedArm(machine, captain, output, context = coverageGuardContext()) {
|
|
735
838
|
const event = {
|
|
@@ -783,6 +886,17 @@ function minedLiterals(fn) {
|
|
|
783
886
|
// identifier-like string literals recover them without dragging prompt prose
|
|
784
887
|
// into the candidate pool.
|
|
785
888
|
const IDENTIFIER_LITERAL = /^[A-Za-z][A-Za-z0-9_-]{0,31}$/;
|
|
889
|
+
// Bound comparisons (cycle limits, retry counters, exit statuses) route on
|
|
890
|
+
// integers that neither the string miner nor the generic value pool supplies:
|
|
891
|
+
// GENERIC_VALUES carries only 1, and minedLiterals matches quoted text alone. A
|
|
892
|
+
// machine gating on `>= 2` or `=== 0` is therefore reported unsatisfiable purely
|
|
893
|
+
// for want of a candidate. Mining the artifact's own integers recovers exactly
|
|
894
|
+
// the bounds it routes on.
|
|
895
|
+
function numericLiterals(sourceText) {
|
|
896
|
+
return [
|
|
897
|
+
...new Set((sourceText.match(/(?<![\w.$])\d+(?![\w.$])/g) ?? []).map(Number)),
|
|
898
|
+
];
|
|
899
|
+
}
|
|
786
900
|
/** Mines identifier-like string literals from an artifact's source text. */
|
|
787
901
|
export function identifierLiterals(sourceText) {
|
|
788
902
|
const literals = sourceText.match(/'(?:[^'\\\n]|\\.)*'|"(?:[^"\\\n]|\\.)*"/g) ?? [];
|
|
@@ -955,6 +1069,10 @@ function overlaidObject(base, assigned) {
|
|
|
955
1069
|
function doneGuardSatisfiable(guard, event, output, extraValues) {
|
|
956
1070
|
return probeGuardSatisfiable(guard.run, event, [{ eventField: 'output', tag: 'o:', base: output }], [...guard.probeValues, ...extraValues]);
|
|
957
1071
|
}
|
|
1072
|
+
/** A concrete initialized-context/output assignment selecting one done arm. */
|
|
1073
|
+
function controllerDoneGuardAssignment(guard, event, output, extraValues, initialContext) {
|
|
1074
|
+
return probeGuardAssignment(guard.run, event, [{ eventField: 'output', tag: 'o:', base: output }], [...guard.probeValues, ...extraValues], { initialContext, varyExistingContext: true });
|
|
1075
|
+
}
|
|
958
1076
|
function errorGuardSatisfiable(guard, event, error, extraValues) {
|
|
959
1077
|
return probeGuardSatisfiable(guard.run, event, [{ eventField: 'error', tag: 'r:', base: error, varyExisting: true }], [...guard.probeValues, ...extraValues]);
|
|
960
1078
|
}
|
|
@@ -977,6 +1095,36 @@ function parallelBranchCaptains(parallel, refs, captains) {
|
|
|
977
1095
|
}
|
|
978
1096
|
return selected;
|
|
979
1097
|
}
|
|
1098
|
+
function repeatedParallelRoleFindings(refs, captains) {
|
|
1099
|
+
const findings = [];
|
|
1100
|
+
for (const parallel of refs.filter((ref) => ref.state.type === 'parallel')) {
|
|
1101
|
+
const regions = refs.filter((ref) => ref.parent === parallel);
|
|
1102
|
+
const priorRoles = new Set();
|
|
1103
|
+
for (const region of regions) {
|
|
1104
|
+
// Sequential appearances inside one region are never concurrent. Fold
|
|
1105
|
+
// the complete descendant region to a set, then compare across siblings;
|
|
1106
|
+
// nested parallel descendants are therefore visible to their ancestors
|
|
1107
|
+
// and are also checked independently at their own parallel boundary.
|
|
1108
|
+
const regionRoles = new Set(captains.flatMap((captain) => {
|
|
1109
|
+
if (captain.binding.actor !== 'player' ||
|
|
1110
|
+
(!sameStateRef(captain.ref, region) &&
|
|
1111
|
+
!isDescendantOf(captain.ref, region))) {
|
|
1112
|
+
return [];
|
|
1113
|
+
}
|
|
1114
|
+
const role = captain.binding.role?.trim().toLowerCase();
|
|
1115
|
+
return role === undefined || role === '' ? [] : [role];
|
|
1116
|
+
}));
|
|
1117
|
+
for (const role of regionRoles) {
|
|
1118
|
+
if (priorRoles.has(role)) {
|
|
1119
|
+
findings.push(`parallel state ${parallel.stableId} repeats canonical role ${role}`);
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
for (const role of regionRoles)
|
|
1123
|
+
priorRoles.add(role);
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
return [...new Set(findings)];
|
|
1127
|
+
}
|
|
980
1128
|
function scriptedOutputs(entries) {
|
|
981
1129
|
const calls = new Map();
|
|
982
1130
|
const byState = new Map(entries.map(({ captain, output }) => [
|
|
@@ -1134,10 +1282,165 @@ function captainPredecessorPlan(machine, target, playbooks, refs, captains) {
|
|
|
1134
1282
|
}
|
|
1135
1283
|
return undefined;
|
|
1136
1284
|
}
|
|
1137
|
-
|
|
1285
|
+
/**
|
|
1286
|
+
* Finds a controller path from a hub event to an acting state. Controller-only
|
|
1287
|
+
* intermediate states are entered through earlier Captain results rather than
|
|
1288
|
+
* through the ordinary workflow interrupt surface.
|
|
1289
|
+
*/
|
|
1290
|
+
function controllerCaptainEntryPlan(machine, target, refs, captains, candidates, initialContext) {
|
|
1291
|
+
const queue = [];
|
|
1292
|
+
for (const captain of captains) {
|
|
1293
|
+
const drive = entryDriveForRef(machine, refs, captain.ref, candidates, initialContext);
|
|
1294
|
+
if (drive?.satisfiable === true) {
|
|
1295
|
+
queue.push({ captain, drive, preceding: [] });
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
const visited = new Set();
|
|
1299
|
+
while (queue.length > 0) {
|
|
1300
|
+
const current = queue.shift();
|
|
1301
|
+
if (current === undefined)
|
|
1302
|
+
break;
|
|
1303
|
+
const key = stateRefKey(current.captain.ref);
|
|
1304
|
+
if (visited.has(key))
|
|
1305
|
+
continue;
|
|
1306
|
+
visited.add(key);
|
|
1307
|
+
if (sameStateRef(current.captain.ref, target.ref)) {
|
|
1308
|
+
return { drive: current.drive, preceding: current.preceding };
|
|
1309
|
+
}
|
|
1310
|
+
for (const resultKey of Object.keys(current.captain.binding.result)) {
|
|
1311
|
+
const output = synthOutput(current.captain.binding, resultKey);
|
|
1312
|
+
const nextRef = directTargetRef(machine, refs, current.captain, output, initialContext);
|
|
1313
|
+
const nextCaptain = captains.find((candidate) => nextRef !== undefined && sameStateRef(candidate.ref, nextRef));
|
|
1314
|
+
if (nextCaptain !== undefined &&
|
|
1315
|
+
!visited.has(stateRefKey(nextCaptain.ref))) {
|
|
1316
|
+
queue.push({
|
|
1317
|
+
captain: nextCaptain,
|
|
1318
|
+
drive: current.drive,
|
|
1319
|
+
preceding: [
|
|
1320
|
+
...current.preceding,
|
|
1321
|
+
{ captain: current.captain, output },
|
|
1322
|
+
],
|
|
1323
|
+
});
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
return undefined;
|
|
1328
|
+
}
|
|
1329
|
+
function controllerCaptainExitPlan(machine, captain, output, refs, captains, hub, initialContext) {
|
|
1330
|
+
const firstTarget = directTargetRef(machine, refs, captain, output, initialContext);
|
|
1331
|
+
if (firstTarget === undefined)
|
|
1332
|
+
return { following: [], outcome: 'dead' };
|
|
1333
|
+
if (sameStateRef(firstTarget, hub) || firstTarget.state.type === 'final') {
|
|
1334
|
+
return { following: [], outcome: 'planned' };
|
|
1335
|
+
}
|
|
1336
|
+
const firstCaptain = captains.find((candidate) => sameStateRef(candidate.ref, firstTarget));
|
|
1337
|
+
if (firstCaptain === undefined) {
|
|
1338
|
+
return { following: [], outcome: 'runtime' };
|
|
1339
|
+
}
|
|
1340
|
+
const queue = [{ captain: firstCaptain, following: [] }];
|
|
1341
|
+
const visited = new Set();
|
|
1342
|
+
while (queue.length > 0) {
|
|
1343
|
+
const current = queue.shift();
|
|
1344
|
+
if (current === undefined)
|
|
1345
|
+
break;
|
|
1346
|
+
const currentKey = stateRefKey(current.captain.ref);
|
|
1347
|
+
if (visited.has(currentKey))
|
|
1348
|
+
continue;
|
|
1349
|
+
visited.add(currentKey);
|
|
1350
|
+
for (const resultKey of Object.keys(current.captain.binding.result)) {
|
|
1351
|
+
const nextOutput = synthOutput(current.captain.binding, resultKey);
|
|
1352
|
+
const following = [
|
|
1353
|
+
...current.following,
|
|
1354
|
+
{ captain: current.captain, output: nextOutput },
|
|
1355
|
+
];
|
|
1356
|
+
const nextRef = directTargetRef(machine, refs, current.captain, nextOutput, initialContext);
|
|
1357
|
+
if (nextRef === undefined)
|
|
1358
|
+
continue;
|
|
1359
|
+
if (sameStateRef(nextRef, hub) || nextRef.state.type === 'final') {
|
|
1360
|
+
return { following, outcome: 'planned' };
|
|
1361
|
+
}
|
|
1362
|
+
const nextCaptain = captains.find((candidate) => sameStateRef(candidate.ref, nextRef));
|
|
1363
|
+
if (nextCaptain !== undefined &&
|
|
1364
|
+
!visited.has(stateRefKey(nextCaptain.ref))) {
|
|
1365
|
+
queue.push({ captain: nextCaptain, following });
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
return { following: [], outcome: 'dead' };
|
|
1370
|
+
}
|
|
1371
|
+
function controllerCaptainProbeActor(machine, captain, result, gate, refs, captains, candidates, initialContext) {
|
|
1372
|
+
const entry = controllerCaptainEntryPlan(machine, captain, refs, captains, candidates, initialContext);
|
|
1373
|
+
if (entry === undefined)
|
|
1374
|
+
return undefined;
|
|
1375
|
+
const hub = initialActiveRefs(machine.config, refs).at(-1);
|
|
1376
|
+
const outcomes = [...entry.preceding, { captain, output: result }];
|
|
1377
|
+
let controllerExitPlanned;
|
|
1378
|
+
if (!(result instanceof Error)) {
|
|
1379
|
+
const exit = controllerCaptainExitPlan(machine, captain, result, refs, captains, hub, initialContext);
|
|
1380
|
+
controllerExitPlanned =
|
|
1381
|
+
exit.outcome === 'runtime' ? undefined : exit.outcome === 'planned';
|
|
1382
|
+
outcomes.push(...exit.following);
|
|
1383
|
+
}
|
|
1384
|
+
const probedOccurrence = entry.preceding.length;
|
|
1385
|
+
let controllerResultConsumed = false;
|
|
1386
|
+
let controllerDirectTransitionTargets;
|
|
1387
|
+
let occurrence = 0;
|
|
1388
|
+
const actor = makeActor(machine, (input) => {
|
|
1389
|
+
if (!gate.armed)
|
|
1390
|
+
return null;
|
|
1391
|
+
const planned = outcomes[occurrence];
|
|
1392
|
+
if (planned === undefined)
|
|
1393
|
+
return null;
|
|
1394
|
+
const stateId = typeof input.stateId === 'string' ? input.stateId : undefined;
|
|
1395
|
+
const sourceItem = typeof input.sourceItem === 'string' ? input.sourceItem : undefined;
|
|
1396
|
+
const matchesState = stateId === undefined ||
|
|
1397
|
+
stateId === captainPublicStateId(planned.captain);
|
|
1398
|
+
const matchesSource = sourceItem === undefined ||
|
|
1399
|
+
sourceItem === planned.captain.binding.sourceItem;
|
|
1400
|
+
if ((stateId === undefined && sourceItem === undefined) ||
|
|
1401
|
+
!matchesState ||
|
|
1402
|
+
!matchesSource) {
|
|
1403
|
+
return null;
|
|
1404
|
+
}
|
|
1405
|
+
if (occurrence === probedOccurrence) {
|
|
1406
|
+
controllerResultConsumed = true;
|
|
1407
|
+
}
|
|
1408
|
+
occurrence += 1;
|
|
1409
|
+
return planned.output;
|
|
1410
|
+
}, () => null, entry.drive.context, (transition) => {
|
|
1411
|
+
if (controllerResultConsumed &&
|
|
1412
|
+
controllerDirectTransitionTargets === undefined &&
|
|
1413
|
+
transition.sourceId === resolvedStateNode(machine, captain.ref)?.id) {
|
|
1414
|
+
controllerDirectTransitionTargets = transition.targetIds;
|
|
1415
|
+
}
|
|
1416
|
+
});
|
|
1417
|
+
return {
|
|
1418
|
+
actor,
|
|
1419
|
+
event: entry.drive.event,
|
|
1420
|
+
controllerDirectTransitionTargets: () => controllerDirectTransitionTargets,
|
|
1421
|
+
...(controllerExitPlanned === undefined ? {} : { controllerExitPlanned }),
|
|
1422
|
+
};
|
|
1423
|
+
}
|
|
1424
|
+
function captainProbeActor(machine, captain, result, gate, refs, captains, playbooks, interruptValues, controllerContext) {
|
|
1425
|
+
if (controllerContext !== undefined) {
|
|
1426
|
+
return controllerCaptainProbeActor(machine, captain, result, gate, refs, captains, interruptValues, controllerContext);
|
|
1427
|
+
}
|
|
1138
1428
|
const predecessor = captainPredecessorPlan(machine, captain, playbooks, refs, captains);
|
|
1139
1429
|
if (predecessor === undefined) {
|
|
1140
|
-
|
|
1430
|
+
// A root BOSS_INTERRUPT makes the interrupt route PREFERRED, not exclusive.
|
|
1431
|
+
// A state legitimately outside the interrupt target set - every script
|
|
1432
|
+
// state, since a script state is not agent-invoking and so cannot receive a
|
|
1433
|
+
// Boss turn - is still entered by its own typed entry event. Falling back to
|
|
1434
|
+
// the entry drive keeps such a state auditable instead of reporting it
|
|
1435
|
+
// unreachable.
|
|
1436
|
+
const interruptDrive = transitionArms((machine.config.on ?? {})[INTERRUPT_EVENT]).length > 0
|
|
1437
|
+
? interruptDriveForRef(machine, refs, captain.ref, captainInterruptTarget(captain), interruptValues)
|
|
1438
|
+
: undefined;
|
|
1439
|
+
const drive = interruptDrive?.satisfiable === true
|
|
1440
|
+
? interruptDrive
|
|
1441
|
+
: entryDriveForRef(machine, refs, captain.ref, interruptValues);
|
|
1442
|
+
if (drive === undefined || !drive.satisfiable)
|
|
1443
|
+
return undefined;
|
|
1141
1444
|
return {
|
|
1142
1445
|
actor: makeActor(machine, result instanceof Error
|
|
1143
1446
|
? throwingScript(captain.binding.sourceItem, gate)
|
|
@@ -1155,6 +1458,8 @@ function captainProbeActor(machine, captain, result, gate, refs, captains, playb
|
|
|
1155
1458
|
? interruptTargetForRef(predecessor.playbook.ref, predecessor.playbook.ref.stableId)
|
|
1156
1459
|
: captainInterruptTarget(predecessor.entry.captain);
|
|
1157
1460
|
const drive = interruptDriveForRef(machine, refs, entryRef, entryId, interruptValues);
|
|
1461
|
+
if (!drive.satisfiable)
|
|
1462
|
+
return undefined;
|
|
1158
1463
|
const actor = makeActor(machine, (input) => {
|
|
1159
1464
|
if (!gate.armed)
|
|
1160
1465
|
return null;
|
|
@@ -1469,6 +1774,7 @@ export function fsmCoverageTestTimeout(fsmModule) {
|
|
|
1469
1774
|
const captains = captainRefs(config);
|
|
1470
1775
|
const playbooks = playbookRefs(config);
|
|
1471
1776
|
const parallels = refs.filter((ref) => ref.state.type === 'parallel');
|
|
1777
|
+
const controller = isControllerMachine(config);
|
|
1472
1778
|
const rootInterruptProbes = transitionArms((config.on ?? {})[INTERRUPT_EVENT]).length;
|
|
1473
1779
|
const rootSettles = rootInterruptProbes * SETTLE_MS;
|
|
1474
1780
|
const initial = typeof config.initial === 'string' ? config.initial : undefined;
|
|
@@ -1483,7 +1789,7 @@ export function fsmCoverageTestTimeout(fsmModule) {
|
|
|
1483
1789
|
for (const captain of captains) {
|
|
1484
1790
|
const resultKeys = Object.keys(captain.binding.result);
|
|
1485
1791
|
captainSettles +=
|
|
1486
|
-
resultKeys.reduce((total, key) => total + (key === NEEDS_BOSS_REPLY ? 4 : 1), 0) * SETTLE_MS;
|
|
1792
|
+
resultKeys.reduce((total, key) => total + (key === NEEDS_BOSS_REPLY ? 4 : controller ? 2 : 1), 0) * SETTLE_MS;
|
|
1487
1793
|
// One directly selectable onError arm is driven.
|
|
1488
1794
|
captainSettles += SETTLE_MS;
|
|
1489
1795
|
const guardedDoneArms = transitionArms(captain.invocation.onDone).filter((arm) => armGuard(arm) !== undefined).length;
|
|
@@ -1522,7 +1828,7 @@ export function fsmCoverageTestTimeout(fsmModule) {
|
|
|
1522
1828
|
}
|
|
1523
1829
|
/**
|
|
1524
1830
|
* Checks transition coverage over a compiled `playbook` artifact's machine
|
|
1525
|
-
* (
|
|
1831
|
+
* (verification-6) and returns findings (empty when every declared transition is
|
|
1526
1832
|
* reachable). Drives the machine through the `gears2fsm` surfaces it
|
|
1527
1833
|
* declares; a workflow without pre-emption may omit the `BOSS_INTERRUPT`
|
|
1528
1834
|
* surface entirely, in which case interrupt coverage is skipped.
|
|
@@ -1536,8 +1842,30 @@ export async function checkFsmCoverage(fsmModule, opts = {}) {
|
|
|
1536
1842
|
const captains = captainRefs(config);
|
|
1537
1843
|
const playbooks = playbookRefs(config);
|
|
1538
1844
|
const captainByRef = new Map(captains.map((captain) => [stateRefKey(captain.ref), captain]));
|
|
1539
|
-
const sourceCandidates =
|
|
1845
|
+
const sourceCandidates = [
|
|
1846
|
+
...identifierLiterals(opts.sourceText ?? ''),
|
|
1847
|
+
...numericLiterals(opts.sourceText ?? ''),
|
|
1848
|
+
];
|
|
1849
|
+
const isController = isControllerMachine(config);
|
|
1850
|
+
const controllerNearMisses = captains.flatMap((captain) => {
|
|
1851
|
+
if (captain.binding.actor !== 'captain' ||
|
|
1852
|
+
captain.binding.result[NEEDS_BOSS_REPLY] !== undefined) {
|
|
1853
|
+
return [];
|
|
1854
|
+
}
|
|
1855
|
+
const nearMiss = controllerDecisionNearMiss(captain.binding.result);
|
|
1856
|
+
return nearMiss === undefined ? [] : [{ captain, nearMiss }];
|
|
1857
|
+
});
|
|
1858
|
+
const controllerContractCandidate = isController || controllerNearMisses.length > 0;
|
|
1859
|
+
const initialCoverageContext = initializedCoverageContext(machine);
|
|
1860
|
+
const controllerContext = isController ? initialCoverageContext : undefined;
|
|
1540
1861
|
const parallelRefs = refs.filter((ref) => ref.state.type === 'parallel');
|
|
1862
|
+
findings.push(...repeatedParallelRoleFindings(refs, captains));
|
|
1863
|
+
for (const { captain, nearMiss } of controllerNearMisses) {
|
|
1864
|
+
const detail = nearMiss.missing.length > 0
|
|
1865
|
+
? `missing ${JSON.stringify(nearMiss.missing[0])}`
|
|
1866
|
+
: `extra ${JSON.stringify(nearMiss.extra[0])}`;
|
|
1867
|
+
findings.push(`state ${captain.binding.stateId}: controller decision contract near-miss (${detail}); the controller domain requires exactly ${CONTROLLER_ACTION_GUARDS.join(', ')}`);
|
|
1868
|
+
}
|
|
1541
1869
|
for (const ref of parallelRefs) {
|
|
1542
1870
|
if (!normalizeArms(ref.state.onDone).some((arm) => arm.target !== null)) {
|
|
1543
1871
|
findings.push(`parallel state ${ref.stableId} declares no onDone join`);
|
|
@@ -1555,6 +1883,20 @@ export async function checkFsmCoverage(fsmModule, opts = {}) {
|
|
|
1555
1883
|
if (finalStates.length === 0) {
|
|
1556
1884
|
findings.push('machine declares no final state');
|
|
1557
1885
|
}
|
|
1886
|
+
const controllerHasPlayer = isController &&
|
|
1887
|
+
captains.some((captain) => captain.binding.actor === 'player');
|
|
1888
|
+
const controllerHasPlaybook = isController && playbooks.length > 0;
|
|
1889
|
+
const controllerHasParallel = isController && parallelRefs.length > 0;
|
|
1890
|
+
if (controllerHasPlayer) {
|
|
1891
|
+
findings.push('controller machine declares delegated-player invocation');
|
|
1892
|
+
}
|
|
1893
|
+
if (controllerHasPlaybook) {
|
|
1894
|
+
findings.push('controller machine declares nested-playbook invocation');
|
|
1895
|
+
}
|
|
1896
|
+
if (controllerHasParallel) {
|
|
1897
|
+
findings.push('controller machine declares parallel state');
|
|
1898
|
+
}
|
|
1899
|
+
const unsupportedController = controllerHasPlayer || controllerHasPlaybook || controllerHasParallel;
|
|
1558
1900
|
const rootArms = normalizeArms((config.on ?? {})[INTERRUPT_EVENT]);
|
|
1559
1901
|
const canJump = rootArms.length > 0;
|
|
1560
1902
|
// A workflow without pre-emption declares no interrupt surface at all
|
|
@@ -1562,10 +1904,10 @@ export async function checkFsmCoverage(fsmModule, opts = {}) {
|
|
|
1562
1904
|
// that handles the event somewhere but not at the root is malformed.
|
|
1563
1905
|
// Prompt text merely mentioning the event name does not count.
|
|
1564
1906
|
const handlesInterruptSomewhere = refs.some((ref) => Object.hasOwn(ref.state.on ?? {}, INTERRUPT_EVENT));
|
|
1565
|
-
if (!canJump && handlesInterruptSomewhere) {
|
|
1907
|
+
if (!canJump && handlesInterruptSomewhere && !controllerContractCandidate) {
|
|
1566
1908
|
findings.push(`machine declares no root ${INTERRUPT_EVENT} event`);
|
|
1567
1909
|
}
|
|
1568
|
-
if (canJump) {
|
|
1910
|
+
if (canJump && !isController) {
|
|
1569
1911
|
for (const playbook of playbooks) {
|
|
1570
1912
|
findings.push(...(await probePlaybookInvocation(machine, playbook, refs, captains, sourceCandidates)));
|
|
1571
1913
|
}
|
|
@@ -1576,7 +1918,10 @@ export async function checkFsmCoverage(fsmModule, opts = {}) {
|
|
|
1576
1918
|
const waitStates = refs.filter((ref) => ref.stableId === AWAIT_BOSS_REPLY_STATE ||
|
|
1577
1919
|
(tagsOf(ref.state).includes('playbook.parked') &&
|
|
1578
1920
|
ref.state.on?.[BOSS_REPLY_EVENT] !== undefined));
|
|
1579
|
-
if (waitStates.length
|
|
1921
|
+
if (isController && waitStates.length > 0) {
|
|
1922
|
+
findings.push('controller machine declares a Boss-reply wait state');
|
|
1923
|
+
}
|
|
1924
|
+
else if (!controllerContractCandidate && waitStates.length === 0) {
|
|
1580
1925
|
findings.push(`machine declares no ${AWAIT_BOSS_REPLY_STATE} state or branch-local Boss-reply wait state`);
|
|
1581
1926
|
}
|
|
1582
1927
|
// Every BOSS_INTERRUPT target is enterable (the captain hangs, so entering a
|
|
@@ -1613,19 +1958,17 @@ export async function checkFsmCoverage(fsmModule, opts = {}) {
|
|
|
1613
1958
|
}
|
|
1614
1959
|
}
|
|
1615
1960
|
// Guard-free root entry events transition from the initial state.
|
|
1616
|
-
const
|
|
1617
|
-
const initialRef =
|
|
1618
|
-
|
|
1619
|
-
: refs.find((ref) => ref.path.length === 1 && ref.key === initial);
|
|
1620
|
-
const entryArms = {
|
|
1621
|
-
...(initial !== null ? (states[initial]?.on ?? {}) : {}),
|
|
1622
|
-
...(config.on ?? {}),
|
|
1623
|
-
};
|
|
1961
|
+
const initialPath = initialActiveRefs(config, refs);
|
|
1962
|
+
const initialRef = initialPath.at(-1);
|
|
1963
|
+
const entryArms = Object.assign({}, config.on ?? {}, ...initialPath.map((ref) => ref.state.on ?? {}));
|
|
1624
1964
|
for (const [event, raw] of Object.entries(entryArms)) {
|
|
1625
1965
|
if (event === INTERRUPT_EVENT)
|
|
1626
1966
|
continue;
|
|
1627
1967
|
const arms = normalizeArms(raw);
|
|
1628
|
-
const free = arms.find((arm) => !arm.guarded &&
|
|
1968
|
+
const free = arms.find((arm) => !arm.guarded &&
|
|
1969
|
+
arm.target !== null &&
|
|
1970
|
+
arm.target !== initialRef?.key &&
|
|
1971
|
+
arm.target !== initialRef?.stableId);
|
|
1629
1972
|
if (free === undefined)
|
|
1630
1973
|
continue;
|
|
1631
1974
|
const actor = makeActor(machine, () => null);
|
|
@@ -1641,8 +1984,14 @@ export async function checkFsmCoverage(fsmModule, opts = {}) {
|
|
|
1641
1984
|
ref.stableId,
|
|
1642
1985
|
...(ref.configId === undefined ? [] : [ref.configId]),
|
|
1643
1986
|
]);
|
|
1987
|
+
// Playbook 10 controllers are host decision loops. Player delegation,
|
|
1988
|
+
// nested playbook invocation, and parallel regions are invalid controller
|
|
1989
|
+
// surfaces, so their explicit findings above own the result; do not mask
|
|
1990
|
+
// them by trying the ordinary workflow drivers against an unsupported shape.
|
|
1991
|
+
if (unsupportedController)
|
|
1992
|
+
return findings;
|
|
1644
1993
|
for (const captain of captains) {
|
|
1645
|
-
if (!canJump)
|
|
1994
|
+
if (!canJump && !isController)
|
|
1646
1995
|
break;
|
|
1647
1996
|
const state = captain.binding;
|
|
1648
1997
|
const stateKey = state.stateId;
|
|
@@ -1652,78 +2001,155 @@ export async function checkFsmCoverage(fsmModule, opts = {}) {
|
|
|
1652
2001
|
const errorEvent = invocationEvent(machine, captain, 'error');
|
|
1653
2002
|
const rawDoneArms = transitionArms(captain.invocation.onDone);
|
|
1654
2003
|
const onDoneArms = normalizeArms(captain.invocation.onDone);
|
|
2004
|
+
const controllerResultByArm = new Map();
|
|
1655
2005
|
// Every declared result needs an arm that explicitly accepts its complete
|
|
1656
2006
|
// valid output. A sole unguarded arm accepts the whole local result
|
|
1657
2007
|
// contract; an array's unguarded arm is a fallback and cannot make an
|
|
1658
2008
|
// otherwise orphaned key look covered.
|
|
1659
2009
|
for (const key of Object.keys(state.result)) {
|
|
1660
2010
|
const output = synthOutput(state, key);
|
|
1661
|
-
const accepting = new
|
|
2011
|
+
const accepting = new Map();
|
|
1662
2012
|
for (const [index, arm] of rawDoneArms.entries()) {
|
|
1663
2013
|
const target = onDoneArms[index]?.target ?? null;
|
|
1664
2014
|
const rawGuard = armGuard(arm);
|
|
1665
2015
|
if (rawGuard === undefined) {
|
|
1666
|
-
if (rawDoneArms.length === 1 && target !== null)
|
|
1667
|
-
accepting.
|
|
2016
|
+
if (rawDoneArms.length === 1 && target !== null) {
|
|
2017
|
+
accepting.set(index, undefined);
|
|
2018
|
+
}
|
|
1668
2019
|
continue;
|
|
1669
2020
|
}
|
|
1670
2021
|
if (target === null || resolveGuard(machine, rawGuard) === undefined) {
|
|
1671
2022
|
continue;
|
|
1672
2023
|
}
|
|
1673
2024
|
const guard = orderedArmPredicate(machine, rawDoneArms, index);
|
|
1674
|
-
if (guard
|
|
1675
|
-
|
|
1676
|
-
|
|
2025
|
+
if (guard === undefined)
|
|
2026
|
+
continue;
|
|
2027
|
+
if (isController) {
|
|
2028
|
+
const assignment = controllerDoneGuardAssignment(guard, doneEvent, output, candidates, initialCoverageContext);
|
|
2029
|
+
if (assignment !== undefined)
|
|
2030
|
+
accepting.set(index, assignment);
|
|
2031
|
+
}
|
|
2032
|
+
else if (doneGuardSatisfiable(guard, doneEvent, output, candidates)) {
|
|
2033
|
+
accepting.set(index, undefined);
|
|
1677
2034
|
}
|
|
1678
2035
|
}
|
|
1679
2036
|
if (accepting.size === 0) {
|
|
1680
2037
|
findings.push(`state ${stateKey}: result "${key}" has no reachable accepting transition`);
|
|
1681
2038
|
continue;
|
|
1682
2039
|
}
|
|
1683
|
-
// Drive only when the first arm XState would inspect under the real
|
|
1684
|
-
// initial context is a known accepting arm. Encountering an unresolved
|
|
1685
|
-
// guard first makes driving unsafe: XState reports that error
|
|
1686
|
-
// asynchronously, so the arm audit below owns the finding (c887fc4).
|
|
1687
2040
|
let directArm;
|
|
1688
|
-
let
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
if (
|
|
1692
|
-
|
|
1693
|
-
|
|
2041
|
+
let drivenOutput = output;
|
|
2042
|
+
let drivenControllerContext = controllerContext;
|
|
2043
|
+
if (isController) {
|
|
2044
|
+
if (isControllerDecisionResult(state.result) && accepting.size !== 1) {
|
|
2045
|
+
findings.push(`state ${stateKey}: controller result "${key}" selects ${accepting.size} accepting action arms`);
|
|
2046
|
+
continue;
|
|
1694
2047
|
}
|
|
1695
|
-
const
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
2048
|
+
const selected = accepting.entries().next().value;
|
|
2049
|
+
directArm = selected?.[0];
|
|
2050
|
+
const assignment = selected?.[1];
|
|
2051
|
+
if (assignment !== undefined) {
|
|
2052
|
+
drivenOutput = assignedPayload(output, assignment, 'o:');
|
|
2053
|
+
drivenControllerContext = assignment.context;
|
|
1699
2054
|
}
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
2055
|
+
}
|
|
2056
|
+
else {
|
|
2057
|
+
// Drive only when the first arm XState would inspect under the real
|
|
2058
|
+
// initial context is a known accepting arm. Encountering an unresolved
|
|
2059
|
+
// guard first makes driving unsafe: XState reports that error
|
|
2060
|
+
// asynchronously, so the arm audit below owns the finding (c887fc4).
|
|
2061
|
+
let safeToDrive = true;
|
|
2062
|
+
for (const [index, arm] of rawDoneArms.entries()) {
|
|
2063
|
+
const rawGuard = armGuard(arm);
|
|
2064
|
+
if (rawGuard === undefined) {
|
|
1705
2065
|
directArm = index;
|
|
1706
2066
|
break;
|
|
1707
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;
|
|
2084
|
+
break;
|
|
2085
|
+
}
|
|
1708
2086
|
}
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
2087
|
+
if (!safeToDrive ||
|
|
2088
|
+
directArm === undefined ||
|
|
2089
|
+
!accepting.has(directArm)) {
|
|
2090
|
+
continue;
|
|
1712
2091
|
}
|
|
1713
2092
|
}
|
|
1714
|
-
if (
|
|
1715
|
-
directArm === undefined ||
|
|
1716
|
-
!accepting.has(directArm)) {
|
|
2093
|
+
if (directArm === undefined) {
|
|
1717
2094
|
continue;
|
|
1718
2095
|
}
|
|
2096
|
+
let controllerDirectTarget;
|
|
2097
|
+
let controllerDirectTargetNodeId;
|
|
2098
|
+
if (isController) {
|
|
2099
|
+
if (isControllerDecisionResult(state.result)) {
|
|
2100
|
+
const priorResult = controllerResultByArm.get(directArm);
|
|
2101
|
+
if (priorResult !== undefined) {
|
|
2102
|
+
findings.push(`state ${stateKey}: controller result "${key}" selects direct arm ${directArm}, already selected by result "${priorResult}"`);
|
|
2103
|
+
continue;
|
|
2104
|
+
}
|
|
2105
|
+
controllerResultByArm.set(directArm, key);
|
|
2106
|
+
}
|
|
2107
|
+
const directTarget = rawArmTarget(rawDoneArms[directArm]);
|
|
2108
|
+
controllerDirectTarget =
|
|
2109
|
+
directTarget === undefined
|
|
2110
|
+
? undefined
|
|
2111
|
+
: stateRefForTarget(refs, directTarget, captain.ref);
|
|
2112
|
+
controllerDirectTargetNodeId =
|
|
2113
|
+
controllerDirectTarget === undefined
|
|
2114
|
+
? undefined
|
|
2115
|
+
: resolvedStateNode(machine, controllerDirectTarget)?.id;
|
|
2116
|
+
if (directTarget === undefined ||
|
|
2117
|
+
controllerDirectTarget === undefined ||
|
|
2118
|
+
controllerDirectTargetNodeId === undefined) {
|
|
2119
|
+
findings.push(`state ${stateKey}: controller result "${key}" declares no resolvable direct action target`);
|
|
2120
|
+
continue;
|
|
2121
|
+
}
|
|
2122
|
+
}
|
|
1719
2123
|
const gate = { armed: false };
|
|
1720
|
-
const probe = captainProbeActor(machine, captain,
|
|
2124
|
+
const probe = captainProbeActor(machine, captain, drivenOutput, gate, refs, captains, playbooks, sourceCandidates, drivenControllerContext);
|
|
2125
|
+
if (probe === undefined) {
|
|
2126
|
+
const finding = `state ${stateKey} has no reachable entry transition`;
|
|
2127
|
+
if (!findings.includes(finding))
|
|
2128
|
+
findings.push(finding);
|
|
2129
|
+
continue;
|
|
2130
|
+
}
|
|
1721
2131
|
const actor = probe.actor;
|
|
1722
2132
|
gate.armed = true;
|
|
1723
2133
|
actor.send(probe.event);
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
2134
|
+
if (controllerDirectTarget !== undefined &&
|
|
2135
|
+
controllerDirectTargetNodeId !== undefined &&
|
|
2136
|
+
!(await settle(actor, () => probe
|
|
2137
|
+
.controllerDirectTransitionTargets?.()
|
|
2138
|
+
?.includes(controllerDirectTargetNodeId) === true))) {
|
|
2139
|
+
findings.push(`state ${stateKey}: controller result "${key}" did not reach its declared direct action target ${controllerDirectTarget.stableId}`);
|
|
2140
|
+
actor.stop();
|
|
2141
|
+
continue;
|
|
2142
|
+
}
|
|
2143
|
+
const transitioned = isController && probe.controllerExitPlanned === false
|
|
2144
|
+
? false
|
|
2145
|
+
: await settle(actor, isController
|
|
2146
|
+
? (snapshot) => snapshot.status === 'done' ||
|
|
2147
|
+
(initialRef !== undefined && atState(initialRef)(snapshot))
|
|
2148
|
+
: leftState(captain.ref));
|
|
2149
|
+
if (!transitioned) {
|
|
2150
|
+
findings.push(isController
|
|
2151
|
+
? `state ${stateKey}: controller result "${key}" reached neither the session hub nor a shutdown final`
|
|
2152
|
+
: `state ${stateKey}: result "${key}" fired no transition`);
|
|
1727
2153
|
}
|
|
1728
2154
|
else if (key === NEEDS_BOSS_REPLY) {
|
|
1729
2155
|
const waitTarget = rawArmTarget(rawDoneArms[directArm]) ?? onDoneArms[directArm]?.target;
|
|
@@ -1763,7 +2189,13 @@ export async function checkFsmCoverage(fsmModule, opts = {}) {
|
|
|
1763
2189
|
}
|
|
1764
2190
|
actor.stop();
|
|
1765
2191
|
const blankGate = { armed: false };
|
|
1766
|
-
const blankProbe = captainProbeActor(machine, captain, synthOutput(state, NEEDS_BOSS_REPLY), blankGate, refs, captains, playbooks, sourceCandidates);
|
|
2192
|
+
const blankProbe = captainProbeActor(machine, captain, synthOutput(state, NEEDS_BOSS_REPLY), blankGate, refs, captains, playbooks, sourceCandidates, controllerContext);
|
|
2193
|
+
if (blankProbe === undefined) {
|
|
2194
|
+
const finding = `state ${stateKey} has no reachable entry transition`;
|
|
2195
|
+
if (!findings.includes(finding))
|
|
2196
|
+
findings.push(finding);
|
|
2197
|
+
continue;
|
|
2198
|
+
}
|
|
1767
2199
|
const blank = blankProbe.actor;
|
|
1768
2200
|
blankGate.armed = true;
|
|
1769
2201
|
blank.send(blankProbe.event);
|
|
@@ -1851,7 +2283,7 @@ export async function checkFsmCoverage(fsmModule, opts = {}) {
|
|
|
1851
2283
|
}
|
|
1852
2284
|
try {
|
|
1853
2285
|
if (guard.run({
|
|
1854
|
-
context:
|
|
2286
|
+
context: initialCoverageContext,
|
|
1855
2287
|
event: { ...errorEvent, error: forcedError },
|
|
1856
2288
|
})) {
|
|
1857
2289
|
directErrorArm = index;
|
|
@@ -1871,7 +2303,13 @@ export async function checkFsmCoverage(fsmModule, opts = {}) {
|
|
|
1871
2303
|
? undefined
|
|
1872
2304
|
: stateRefForTarget(refs, rawTarget, captain.ref);
|
|
1873
2305
|
const gate = { armed: false };
|
|
1874
|
-
const probe = captainProbeActor(machine, captain, forcedError, gate, refs, captains, playbooks, sourceCandidates);
|
|
2306
|
+
const probe = captainProbeActor(machine, captain, forcedError, gate, refs, captains, playbooks, sourceCandidates, controllerContext);
|
|
2307
|
+
if (probe === undefined) {
|
|
2308
|
+
const finding = `state ${stateKey} has no reachable entry transition`;
|
|
2309
|
+
if (!findings.includes(finding))
|
|
2310
|
+
findings.push(finding);
|
|
2311
|
+
continue;
|
|
2312
|
+
}
|
|
1875
2313
|
const actor = probe.actor;
|
|
1876
2314
|
gate.armed = true;
|
|
1877
2315
|
actor.send(probe.event);
|
|
@@ -1887,7 +2325,7 @@ export async function checkFsmCoverage(fsmModule, opts = {}) {
|
|
|
1887
2325
|
}
|
|
1888
2326
|
/**
|
|
1889
2327
|
* Builds a per-artifact vitest module running the transition-coverage check
|
|
1890
|
-
* beside the artifacts (
|
|
2328
|
+
* beside the artifacts (verification-6).
|
|
1891
2329
|
*/
|
|
1892
2330
|
export function generateFsmCoverageTest(opts) {
|
|
1893
2331
|
const commentBasename = JSON.stringify(opts.basename)
|
|
@@ -1922,7 +2360,7 @@ describe(${suiteName}, () => {
|
|
|
1922
2360
|
}
|
|
1923
2361
|
/**
|
|
1924
2362
|
* Emits the transition-coverage test beside a compiled `playbook` artifact
|
|
1925
|
-
* (
|
|
2363
|
+
* (verification-6): validates the produced `fsm` drives cleanly, then writes
|
|
1926
2364
|
* `<basename>.fsm.coverage.test.ts` and returns its path with any coverage
|
|
1927
2365
|
* findings as diagnostics.
|
|
1928
2366
|
*
|