@vedmalex/statemachine 1.0.0-beta.1 → 1.0.0-beta.2

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/dist/index.js CHANGED
@@ -1805,6 +1805,9 @@ var StateMachine = class _StateMachine {
1805
1805
  setContext(context) {
1806
1806
  this.context = context;
1807
1807
  }
1808
+ resolveCallbackOwner(value) {
1809
+ return isAdapter(value) ? value.adaptee : value;
1810
+ }
1808
1811
  enqueueEvent(eventName, obj, args, type) {
1809
1812
  if (this.externalQueue.length + this.internalQueue.length >= this.maxQueueDepth) {
1810
1813
  const _s0 = this.getCurrentState(obj);
@@ -1913,7 +1916,8 @@ var StateMachine = class _StateMachine {
1913
1916
  let transitions = event ? event.transitions.filter(
1914
1917
  (t) => this.isTransitionPossible(t, currentState)
1915
1918
  ) : [];
1916
- if (!transitions.length) {
1919
+ const isEngineDoneEvent = String(eventName).startsWith("done.state.");
1920
+ if (!transitions.length && !isEngineDoneEvent) {
1917
1921
  const wildcardEvent = this.events.get("*");
1918
1922
  if (wildcardEvent) {
1919
1923
  const wildcardTransitions = wildcardEvent.transitions.filter(
@@ -2118,6 +2122,10 @@ var StateMachine = class _StateMachine {
2118
2122
  if (!currentState) return expectedState === "";
2119
2123
  const currentParts = currentState.split("|").sort();
2120
2124
  const expectedParts = expectedState.split("|").sort();
2125
+ const ancestorMatch = expectedParts.every(
2126
+ (expectedPart) => currentParts.some((leaf) => this.isParentState(expectedPart, leaf))
2127
+ );
2128
+ if (ancestorMatch) return true;
2121
2129
  if (currentParts.length !== expectedParts.length) return false;
2122
2130
  return currentParts.every((part, index) => part === expectedParts[index]);
2123
2131
  }
@@ -2496,7 +2504,7 @@ var StateMachine = class _StateMachine {
2496
2504
  const currentState = this.getCurrentState(adaptee) || "";
2497
2505
  const currentStateMap = this.parseCompositeState(currentState);
2498
2506
  const newStateParts = state.split("|");
2499
- const isRootState = newStateParts.length === 1 && !state.includes(".");
2507
+ const isRootState = newStateParts.length === 1 && !state.includes(".") && !this.states.get(state)?.regions;
2500
2508
  if (isRootState) {
2501
2509
  currentStateMap.clear();
2502
2510
  currentStateMap.set(state, state);
@@ -2572,12 +2580,13 @@ var StateMachine = class _StateMachine {
2572
2580
  initialStates = initialState;
2573
2581
  }
2574
2582
  this.setCurrentState(initialStates, targetAdaptee);
2575
- const stateParts = initialStates.split("|");
2583
+ const { enterStates } = this.computeEnterExitSets("", initialStates);
2584
+ const enterFireOrder = enterStates.length > 0 ? enterStates : [initialStates];
2576
2585
  const context = {
2577
2586
  state: initialStates,
2578
2587
  phase: "enter"
2579
2588
  };
2580
- for (const statePart of stateParts) {
2589
+ for (const statePart of enterFireOrder) {
2581
2590
  this.executeEnterActions(
2582
2591
  targetAdaptee,
2583
2592
  statePart,
@@ -2591,6 +2600,11 @@ var StateMachine = class _StateMachine {
2591
2600
  );
2592
2601
  });
2593
2602
  }
2603
+ this.checkCompletion(
2604
+ targetAdaptee,
2605
+ "",
2606
+ initialStates
2607
+ );
2594
2608
  }
2595
2609
  getInitialCompositeState(initialState) {
2596
2610
  const stateConfig = this.states.get(initialState);
@@ -2630,6 +2644,211 @@ var StateMachine = class _StateMachine {
2630
2644
  }
2631
2645
  return regionStates.join("|");
2632
2646
  }
2647
+ /**
2648
+ * Whether `leaf` is a UML/SCXML `<final>` atomic state of its region.
2649
+ *
2650
+ * Reads the `final` marker straight from the flattened state map populated by
2651
+ * processStates, so it works for any registered atomic leaf regardless of
2652
+ * nesting depth. Returns `false` for unregistered names and for composites
2653
+ * (the all-regions-final join derives doneness from atomic leaves, not from a
2654
+ * `final` flag on a composite parent).
2655
+ */
2656
+ isStateFinal(leaf) {
2657
+ return Boolean(this.states.get(leaf)?.final);
2658
+ }
2659
+ /**
2660
+ * Whether composite `compositeId` has reached its UML/SCXML "done"
2661
+ * configuration: every one of its regions has its active atomic leaf in a
2662
+ * `final` state (recursively, for nested composites).
2663
+ *
2664
+ * D10 (mustFix): doneness is derived by scanning the active atomic `|`-leaves
2665
+ * against the STATIC regions tree (`this.states.get(C)?.regions`), NEVER via a
2666
+ * region-key Map lookup (`configMap.get`) — that map keys leaves by their
2667
+ * deepest region container and so cannot answer "which leaf is active in
2668
+ * region X of composite C". For each region we locate the active leaf via the
2669
+ * `C.region.` dotted prefix; the region is final iff that leaf is `final`, or
2670
+ * the leaf lives under a nested composite that is itself `isCompositeDone`.
2671
+ *
2672
+ * Returns `false` for a non-composite id, for a composite with no active leaf
2673
+ * in some region, or when no substate under it is ever `final` (cheap miss).
2674
+ */
2675
+ isCompositeDone(compositeId, atomicLeaves) {
2676
+ const regions = this.states.get(compositeId)?.regions;
2677
+ if (!regions) return false;
2678
+ for (const regionName of Object.keys(regions)) {
2679
+ const regionPrefix = `${compositeId}.${regionName}.`;
2680
+ const activeLeaf = atomicLeaves.find(
2681
+ (leaf) => leaf.startsWith(regionPrefix)
2682
+ );
2683
+ if (!activeLeaf) return false;
2684
+ const nestedComposite = this.regionComposite(activeLeaf, regionPrefix);
2685
+ if (nestedComposite) {
2686
+ if (this.isCompositeDone(nestedComposite, atomicLeaves)) continue;
2687
+ return false;
2688
+ }
2689
+ if (this.isStateFinal(activeLeaf)) continue;
2690
+ return false;
2691
+ }
2692
+ return true;
2693
+ }
2694
+ /**
2695
+ * The OUTERMOST registered composite (regions-bearing) ancestor of `leaf`
2696
+ * that lives strictly under `regionPrefix`, or `undefined` if the region
2697
+ * holds a simple atomic state directly. Used by {@link isCompositeDone} to
2698
+ * delegate a region's completeness to its nested composite (recursing over
2699
+ * every parallel branch), independent of whether any single branch leaf
2700
+ * happens to carry the `final` flag.
2701
+ *
2702
+ * ancestorChain is root-to-leaf, so the FIRST matching ancestor is the
2703
+ * region's direct composite child (e.g. `C.p.D` for leaf `C.p.D.s.s2` under
2704
+ * region prefix `C.p.`); isCompositeDone then recurses into its regions.
2705
+ */
2706
+ regionComposite(leaf, regionPrefix) {
2707
+ for (const ancestor of this.ancestorChain(leaf)) {
2708
+ if (ancestor !== leaf && ancestor.startsWith(regionPrefix) && this.states.get(ancestor)?.regions) {
2709
+ return ancestor;
2710
+ }
2711
+ }
2712
+ return void 0;
2713
+ }
2714
+ /**
2715
+ * Whether composite `compositeId` has reached its all-regions-final ("done")
2716
+ * configuration in the CURRENT active state. Public guard surface (`@stable`)
2717
+ * for authoring a join as `guard: () => sm.isDone('C')` instead of (or
2718
+ * alongside) listening on the engine `done.state.<C>` event.
2719
+ *
2720
+ * @param compositeId - The dotted id of the composite/parallel state.
2721
+ * @returns `true` iff every region's active atomic leaf is `final`.
2722
+ */
2723
+ isDone(compositeId, adaptee) {
2724
+ const currentState = this.getCurrentState(adaptee);
2725
+ if (!currentState) return false;
2726
+ const atomicLeaves = currentState.split("|").filter(Boolean);
2727
+ return this.isCompositeDone(compositeId, atomicLeaves);
2728
+ }
2729
+ /**
2730
+ * SCXML completion hook (D10/D11/D12): after a new configuration is written,
2731
+ * raise `done.state.<C>` for each composite that became all-regions-final.
2732
+ *
2733
+ * - Scans only composites that GAINED an active leaf (the ancestor chain of
2734
+ * each `|`-leaf of `newState`), so unaffected composites are not re-checked.
2735
+ * - Emits INNERMOST-first (a deeper composite's `done.state` precedes its
2736
+ * parent's), matching SCXML's inner-before-outer completion ordering, via a
2737
+ * per-config emitted-id Set so each id is raised at most once per call.
2738
+ * - Gates each emission on `this.events.has('done.state.'+C)` (D11 mustFix):
2739
+ * raising an undeclared event would hit the `Invalid event` throw
2740
+ * (executeQueuedTransition) as an unhandled microtask rejection. No declared
2741
+ * `done.state.<C>` event => no emission, no crash, no observable effect.
2742
+ * - Uses the internal queue (`raiseEvent`) + `scheduleProcessing` so the
2743
+ * completion event is processed before subsequent external events.
2744
+ */
2745
+ checkCompletion(obj, oldState, newState) {
2746
+ const atomicLeaves = newState.split("|").filter(Boolean);
2747
+ if (atomicLeaves.length === 0) return;
2748
+ const oldLeaves = oldState.split("|").filter(Boolean);
2749
+ const seen = /* @__PURE__ */ new Set();
2750
+ for (const leaf of atomicLeaves) {
2751
+ for (const ancestor of this.ancestorChain(leaf)) {
2752
+ if (ancestor === leaf) continue;
2753
+ if (seen.has(ancestor)) continue;
2754
+ if (!this.states.get(ancestor)?.regions) continue;
2755
+ seen.add(ancestor);
2756
+ }
2757
+ }
2758
+ const candidates = Array.from(seen).sort(
2759
+ (a, b) => b.split(".").length - a.split(".").length
2760
+ );
2761
+ const emitted = /* @__PURE__ */ new Set();
2762
+ for (const compositeId of candidates) {
2763
+ if (emitted.has(compositeId)) continue;
2764
+ if (!this.isCompositeDone(compositeId, atomicLeaves)) continue;
2765
+ if (this.isCompositeDone(compositeId, oldLeaves)) continue;
2766
+ emitted.add(compositeId);
2767
+ const doneEvent = `done.state.${compositeId}`;
2768
+ if (!this.events.has(doneEvent)) continue;
2769
+ this.raiseEvent(doneEvent, obj);
2770
+ this.scheduleProcessing();
2771
+ }
2772
+ }
2773
+ /**
2774
+ * Build the registered ancestor chain for an atomic leaf, ordered root-to-leaf.
2775
+ *
2776
+ * Walks every dot-prefix of `leaf` and keeps only the ones that are real
2777
+ * registered states (`this.states.has`). Region containers are never
2778
+ * registered (only composite parents and atomic leaves are — see
2779
+ * processStates/processRegions), so they are filtered out automatically. The
2780
+ * result is exactly `[parent..leaf]` for a leaf inside a composite, and
2781
+ * `[leaf]` for a flat state.
2782
+ *
2783
+ * Example: `ancestorChain('a.r1.c1')` -> `['a', 'a.r1.c1']` (the `a.r1`
2784
+ * region container is excluded). Nested:
2785
+ * `ancestorChain('a.r1.c1.r3.x')` -> `['a', 'a.r1.c1', 'a.r1.c1.r3.x']`.
2786
+ */
2787
+ ancestorChain(leaf) {
2788
+ const chain = [];
2789
+ let dotIndex = leaf.indexOf(".");
2790
+ while (dotIndex !== -1) {
2791
+ const prefix = leaf.substring(0, dotIndex);
2792
+ if (this.states.has(prefix)) {
2793
+ chain.push(prefix);
2794
+ }
2795
+ dotIndex = leaf.indexOf(".", dotIndex + 1);
2796
+ }
2797
+ if (this.states.has(leaf)) {
2798
+ chain.push(leaf);
2799
+ }
2800
+ return chain;
2801
+ }
2802
+ /**
2803
+ * Compute the ordered enter/exit sets between two composite configurations
2804
+ * (SCXML ancestor-first entry / descendant-first exit).
2805
+ *
2806
+ * For each `|`-separated atomic leaf in `oldComposite` and `newComposite` the
2807
+ * union of its {@link ancestorChain} forms the old/new active ancestry. A
2808
+ * state shared by both ancestries lands in NEITHER diff, so a surviving
2809
+ * ancestor is never re-entered nor exited (no onEnter/onExit re-fire, no
2810
+ * timer re-arm/leak).
2811
+ *
2812
+ * - `enterStates` = new ancestry MINUS old, sorted ascending by depth then
2813
+ * document (insertion) order -> root-to-leaf entry.
2814
+ * - `exitStates` = old ancestry MINUS new, sorted descending by depth ->
2815
+ * leaf-to-root exit.
2816
+ *
2817
+ * Consumed by BOTH R1 (applyTransition / setInitialState / reset) and R2.
2818
+ */
2819
+ computeEnterExitSets(oldComposite, newComposite) {
2820
+ const collectAncestry = (composite) => {
2821
+ const ancestry = /* @__PURE__ */ new Set();
2822
+ if (!composite) return ancestry;
2823
+ for (const leaf of composite.split("|")) {
2824
+ if (!leaf) continue;
2825
+ for (const ancestor of this.ancestorChain(leaf)) {
2826
+ ancestry.add(ancestor);
2827
+ }
2828
+ }
2829
+ return ancestry;
2830
+ };
2831
+ const oldAncestry = collectAncestry(oldComposite);
2832
+ const newAncestry = collectAncestry(newComposite);
2833
+ const depthOf = (state) => {
2834
+ let depth = 0;
2835
+ for (let i = 0; i < state.length; i++) {
2836
+ if (state[i] === ".") depth++;
2837
+ }
2838
+ return depth;
2839
+ };
2840
+ const enterRaw = [];
2841
+ for (const state of newAncestry) {
2842
+ if (!oldAncestry.has(state)) enterRaw.push(state);
2843
+ }
2844
+ const exitRaw = [];
2845
+ for (const state of oldAncestry) {
2846
+ if (!newAncestry.has(state)) exitRaw.push(state);
2847
+ }
2848
+ const enterStates = enterRaw.map((state, index) => ({ state, index, depth: depthOf(state) })).sort((a, b) => a.depth - b.depth || a.index - b.index).map((entry) => entry.state);
2849
+ const exitStates = exitRaw.map((state, index) => ({ state, index, depth: depthOf(state) })).sort((a, b) => b.depth - a.depth || a.index - b.index).map((entry) => entry.state);
2850
+ return { enterStates, exitStates };
2851
+ }
2633
2852
  validateCompositeState(compositeState) {
2634
2853
  const stateParts = compositeState.split("|");
2635
2854
  const regionKeys = /* @__PURE__ */ new Set();
@@ -2703,10 +2922,11 @@ var StateMachine = class _StateMachine {
2703
2922
  return (...args) => {
2704
2923
  const targetAdaptee = args.length >= 2 ? args[0] : adaptee;
2705
2924
  const error = args.length >= 2 ? args[1] : args[0];
2706
- return handler(targetAdaptee, error);
2925
+ return handler(this.resolveCallbackOwner(targetAdaptee), error);
2707
2926
  };
2708
2927
  }
2709
2928
  async callAction(obj, actionName, ...args) {
2929
+ const targetOwner = this.resolveCallbackOwner(obj);
2710
2930
  const _callActionState = this.getCurrentState(
2711
2931
  obj
2712
2932
  );
@@ -2721,16 +2941,16 @@ var StateMachine = class _StateMachine {
2721
2941
  if (this.context && this.context[actionName]) {
2722
2942
  const action = this.context[actionName];
2723
2943
  if (typeof action === "function") {
2724
- const result = action(this.context, ...args);
2944
+ const result = action(targetOwner, ...args);
2725
2945
  return result instanceof Promise ? await result : result;
2726
2946
  }
2727
2947
  } else if (typeof actionName === "function") {
2728
- const result = actionName(obj, ...args);
2948
+ const result = actionName(targetOwner, ...args);
2729
2949
  return result instanceof Promise ? await result : result;
2730
2950
  } else if (obj.get(actionName)) {
2731
2951
  const action = obj.get(actionName);
2732
2952
  if (typeof action === "function") {
2733
- const result = action(obj, ...args);
2953
+ const result = action(targetOwner, ...args);
2734
2954
  return result instanceof Promise ? await result : result;
2735
2955
  }
2736
2956
  }
@@ -2802,8 +3022,12 @@ var StateMachine = class _StateMachine {
2802
3022
  if (fromState === "*") return true;
2803
3023
  const regionKey = this.getRegionKey(fromState);
2804
3024
  const currentStateForRegion = currentStates.get(regionKey);
2805
- if (!currentStateForRegion) return false;
2806
- return currentStateForRegion === fromState || this.isParentState(currentStateForRegion, fromState);
3025
+ if (currentStateForRegion && (currentStateForRegion === fromState || this.isParentState(currentStateForRegion, fromState))) {
3026
+ return true;
3027
+ }
3028
+ return Array.from(currentStates.values()).some(
3029
+ (leaf) => leaf === fromState || this.isParentState(fromState, leaf)
3030
+ );
2807
3031
  });
2808
3032
  }
2809
3033
  isParentState(parentState, childState) {
@@ -2819,6 +3043,26 @@ var StateMachine = class _StateMachine {
2819
3043
  const targetState = transition.to === "*" ? currentState : transition.to;
2820
3044
  this._isTransitioning = true;
2821
3045
  this._targetState = targetState;
3046
+ let newState;
3047
+ let enterStates;
3048
+ let exitStates;
3049
+ try {
3050
+ newState = this.updateState(currentState, targetState);
3051
+ const sets = this.computeEnterExitSets(currentState, newState);
3052
+ enterStates = sets.enterStates;
3053
+ exitStates = sets.exitStates;
3054
+ } catch (error) {
3055
+ this.logger.warn("Transition aborted: invalid target configuration", {
3056
+ state: currentState,
3057
+ target: String(targetState),
3058
+ error
3059
+ });
3060
+ this._isTransitioning = false;
3061
+ this._targetState = void 0;
3062
+ return void 0;
3063
+ }
3064
+ const exitFireOrder = exitStates.length > 0 ? exitStates : [transition.from];
3065
+ const enterFireOrder = enterStates.length > 0 ? enterStates : [transition.to];
2822
3066
  try {
2823
3067
  if (transition.guard) {
2824
3068
  const allow = await this.callAction(obj, transition.guard, ...args).catch(
@@ -2844,7 +3088,9 @@ var StateMachine = class _StateMachine {
2844
3088
  );
2845
3089
  }
2846
3090
  try {
2847
- await this.executeExitActions(obj, transition.from, args, context);
3091
+ for (const exitStateName of exitFireOrder) {
3092
+ await this.executeExitActions(obj, exitStateName, args, context);
3093
+ }
2848
3094
  } catch (error) {
2849
3095
  if (this.abortOnExitError) {
2850
3096
  this.logger.warn("Transition aborted due to onExit error", { state: currentState, error });
@@ -2864,12 +3110,14 @@ var StateMachine = class _StateMachine {
2864
3110
  );
2865
3111
  }
2866
3112
  try {
2867
- await this.executeEnterActions(obj, transition.to, args, context);
3113
+ for (const enterStateName of enterFireOrder) {
3114
+ await this.executeEnterActions(obj, enterStateName, args, context);
3115
+ }
2868
3116
  } catch (error) {
2869
3117
  if (this.errorState) {
2870
3118
  this.logger.error(`Failed to enter state '${targetState}'. Fallback to error state '${this.errorState}'`, { error });
2871
- const newState2 = this.updateState(currentState, this.errorState);
2872
- this.setCurrentState(newState2, obj);
3119
+ const errorNewState = this.updateState(currentState, this.errorState);
3120
+ this.setCurrentState(errorNewState, obj);
2873
3121
  return this.states.get(this.errorState);
2874
3122
  }
2875
3123
  throw error;
@@ -2884,12 +3132,12 @@ var StateMachine = class _StateMachine {
2884
3132
  event.onError,
2885
3133
  this.onError
2886
3134
  );
2887
- errorHandler(obj, error);
3135
+ errorHandler(this.resolveCallbackOwner(obj), error);
2888
3136
  }
2889
3137
  }
2890
3138
  const transitionStartTime = Date.now();
2891
- const newState = this.updateState(currentState, targetState);
2892
3139
  this.setCurrentState(newState, obj);
3140
+ this.checkCompletion(obj, currentState, newState);
2893
3141
  const transitionTime = Date.now() - transitionStartTime;
2894
3142
  this.monitor.recordTransition(transitionTime, true);
2895
3143
  return this.states.get(targetState);
@@ -3065,7 +3313,7 @@ var StateMachine = class _StateMachine {
3065
3313
  updateState(currentState, toState) {
3066
3314
  const currentStateMap = this.parseCompositeState(currentState);
3067
3315
  const toStateParts = toState.split("|");
3068
- if (toStateParts.length === 1 && !toState.includes(".")) {
3316
+ if (toStateParts.length === 1 && !toState.includes(".") && !this.states.get(toState)?.regions) {
3069
3317
  currentStateMap.clear();
3070
3318
  currentStateMap.set(toState, toState);
3071
3319
  return toState;
@@ -3447,6 +3695,7 @@ var ConfigValidator = class {
3447
3695
  this.validateInitialState(smConfig);
3448
3696
  this.validateCrossReferences(smConfig);
3449
3697
  this.validatePerformanceConstraints(smConfig);
3698
+ this.validateFinalStates(smConfig);
3450
3699
  this.runCustomRules(smConfig);
3451
3700
  } catch (error) {
3452
3701
  const msg = error instanceof Error ? error.message : String(error);
@@ -3808,6 +4057,10 @@ var ConfigValidator = class {
3808
4057
  }
3809
4058
  }
3810
4059
  }
4060
+ const finalStatePaths = this.collectFinalStatePaths(smConfig.states);
4061
+ for (const finalPath of finalStatePaths) {
4062
+ reachableStates.add(finalPath);
4063
+ }
3811
4064
  for (const stateName of allStateNames) {
3812
4065
  if (!reachableStates.has(stateName)) {
3813
4066
  this.addWarning(
@@ -3818,8 +4071,9 @@ var ConfigValidator = class {
3818
4071
  }
3819
4072
  }
3820
4073
  for (const [eventName, event] of Object.entries(smConfig.events)) {
4074
+ const isDoneStateEvent = eventName.startsWith("done.state.");
3821
4075
  const hasValidTransitions = event.transitions.some(
3822
- (t) => allStateNames.has(t.from) && allStateNames.has(t.to)
4076
+ (t) => (allStateNames.has(t.from) || isDoneStateEvent && allStateNames.has(eventName.slice("done.state.".length))) && allStateNames.has(t.to)
3823
4077
  );
3824
4078
  if (!hasValidTransitions) {
3825
4079
  this.addWarning(
@@ -3847,6 +4101,168 @@ var ConfigValidator = class {
3847
4101
  }
3848
4102
  }
3849
4103
  }
4104
+ /**
4105
+ * Collects every state in the tree as {dottedPath, config} entries, walking
4106
+ * region containers (region names are NOT registered states, so they are part
4107
+ * of the path but never emitted as an entry — mirroring runtime expansion).
4108
+ */
4109
+ collectStateEntries(states, prefix, collector) {
4110
+ for (const [stateName, state] of Object.entries(states)) {
4111
+ const fullName = prefix ? `${prefix}.${stateName}` : stateName;
4112
+ collector.push({ path: fullName, state });
4113
+ if (state.regions) {
4114
+ for (const [regionName, regionStates] of Object.entries(
4115
+ state.regions
4116
+ )) {
4117
+ this.collectStateEntries(
4118
+ regionStates,
4119
+ `${fullName}.${regionName}`,
4120
+ collector
4121
+ );
4122
+ }
4123
+ }
4124
+ }
4125
+ }
4126
+ /**
4127
+ * True when `composite` has at least one `final:true` atomic substate reachable
4128
+ * through its (possibly nested) region tree — i.e. some region of the composite
4129
+ * can become done. Used by REGION_NO_REACHABLE_FINAL.
4130
+ */
4131
+ hasReachableFinal(composite) {
4132
+ if (!composite.regions) return false;
4133
+ for (const regionStates of Object.values(composite.regions)) {
4134
+ for (const state of Object.values(regionStates)) {
4135
+ if (state.final) return true;
4136
+ if (state.regions && this.hasReachableFinal(state)) return true;
4137
+ }
4138
+ }
4139
+ return false;
4140
+ }
4141
+ /**
4142
+ * SCXML/UML final-state and all-regions-final (done.state.<C>) join validation.
4143
+ *
4144
+ * - FINAL_STATE_HAS_OUTGOING (error): a `final:true` state is the `from` of a
4145
+ * transition (a final pseudo-state cannot itself transition out).
4146
+ * - FINAL_ON_COMPOSITE (warning): `final:true` on a state that has regions
4147
+ * (final is meaningful only on an atomic leaf; ignored at runtime).
4148
+ * - REGION_NO_REACHABLE_FINAL (warning): a `done.state.<C>` transition exists
4149
+ * but composite C declares no `final` substate, so the join can never fire.
4150
+ * - DONE_VS_PARALLEL_EXIT_AMBIGUITY (warning): the same composite C has BOTH a
4151
+ * `done.state.C` join AND a plain `from:'C'` user-event parallel-exit, which
4152
+ * can preempt the all-final join (the user event is eligible on ANY leaf).
4153
+ * - REGION_MISSING_INITIAL (advisory warning, valid:true): a region omits an
4154
+ * explicit `initial`, so expansion relies on first-key insertion order.
4155
+ */
4156
+ validateFinalStates(smConfig) {
4157
+ const entries = [];
4158
+ this.collectStateEntries(smConfig.states, "", entries);
4159
+ const finalStatePaths = /* @__PURE__ */ new Set();
4160
+ for (const { path, state } of entries) {
4161
+ if (!state.final) continue;
4162
+ if (state.regions) {
4163
+ this.addWarning(
4164
+ "FINAL_ON_COMPOSITE",
4165
+ `Final flag on composite state "${path}" is ignored; mark an atomic leaf inside a region as final instead`,
4166
+ `states.${path}.final`,
4167
+ void 0,
4168
+ "Remove `final: true` from this composite and set it on the atomic leaf state that represents the region's completion."
4169
+ );
4170
+ } else {
4171
+ finalStatePaths.add(path);
4172
+ }
4173
+ }
4174
+ const userFromStates = /* @__PURE__ */ new Set();
4175
+ const doneStateComposites = /* @__PURE__ */ new Set();
4176
+ for (const [eventName, event] of Object.entries(smConfig.events)) {
4177
+ if (!event.transitions || !Array.isArray(event.transitions)) continue;
4178
+ if (eventName.startsWith("done.state.")) {
4179
+ const compositeId = eventName.slice("done.state.".length);
4180
+ if (compositeId) doneStateComposites.add(compositeId);
4181
+ }
4182
+ for (const transition of event.transitions) {
4183
+ if (typeof transition.from !== "string") continue;
4184
+ for (const fromPart of transition.from.split("|")) {
4185
+ if (finalStatePaths.has(fromPart)) {
4186
+ this.addError(
4187
+ "FINAL_STATE_HAS_OUTGOING",
4188
+ `Final state "${fromPart}" cannot be the source of transition on event "${eventName}"; a final pseudo-state has no outgoing transitions`,
4189
+ `events.${eventName}`,
4190
+ void 0,
4191
+ `Remove the outgoing transition from "${fromPart}", or clear its \`final: true\` flag if it is not actually a final state.`
4192
+ );
4193
+ }
4194
+ if (!eventName.startsWith("done.state.")) {
4195
+ userFromStates.add(fromPart);
4196
+ }
4197
+ }
4198
+ }
4199
+ }
4200
+ const entryByPath = new Map(entries.map((e) => [e.path, e.state]));
4201
+ for (const compositeId of doneStateComposites) {
4202
+ const composite = entryByPath.get(compositeId);
4203
+ if (!composite) {
4204
+ continue;
4205
+ }
4206
+ if (!this.hasReachableFinal(composite)) {
4207
+ this.addWarning(
4208
+ "REGION_NO_REACHABLE_FINAL",
4209
+ `Join event "done.state.${compositeId}" can never fire: composite "${compositeId}" has no \`final\` substate in any region`,
4210
+ `events.done.state.${compositeId}`,
4211
+ void 0,
4212
+ `Mark the completion leaf of each region under "${compositeId}" with \`final: true\`, or remove the done.state join.`
4213
+ );
4214
+ }
4215
+ if (userFromStates.has(compositeId)) {
4216
+ this.addWarning(
4217
+ "DONE_VS_PARALLEL_EXIT_AMBIGUITY",
4218
+ `Composite "${compositeId}" has both a done.state join and a user-event transition with from:"${compositeId}"; the user event can preempt the all-final join because it is eligible on any active leaf`,
4219
+ `events.done.state.${compositeId}`,
4220
+ void 0,
4221
+ `Disambiguate by using the done.state.${compositeId} join exclusively, or scope the user-event transition to a specific leaf instead of the bare composite.`
4222
+ );
4223
+ }
4224
+ }
4225
+ for (const { path, state } of entries) {
4226
+ if (!state.regions) continue;
4227
+ for (const [regionName, regionStates] of Object.entries(state.regions)) {
4228
+ const regionKeys = Object.keys(regionStates);
4229
+ if (regionKeys.length === 0) continue;
4230
+ const parentInitialInRegion = state.initial !== void 0 && regionKeys.includes(state.initial);
4231
+ if (!parentInitialInRegion) {
4232
+ this.addWarning(
4233
+ "REGION_MISSING_INITIAL",
4234
+ `Region "${regionName}" of composite "${path}" has no explicit initial; expansion falls back to the first key "${regionKeys[0]}" (insertion-order dependent)`,
4235
+ `states.${path}.regions.${regionName}`,
4236
+ void 0,
4237
+ `Set \`initial\` on composite "${path}" to a leaf of region "${regionName}", to make the starting substate explicit and order-independent.`
4238
+ );
4239
+ }
4240
+ }
4241
+ }
4242
+ }
4243
+ /**
4244
+ * Collects the dotted paths of every `final:true` atomic leaf in the tree.
4245
+ */
4246
+ collectFinalStatePaths(states, prefix = "", collector = /* @__PURE__ */ new Set()) {
4247
+ for (const [stateName, state] of Object.entries(states)) {
4248
+ const fullName = prefix ? `${prefix}.${stateName}` : stateName;
4249
+ if (state.final && !state.regions) {
4250
+ collector.add(fullName);
4251
+ }
4252
+ if (state.regions) {
4253
+ for (const [regionName, regionStates] of Object.entries(
4254
+ state.regions
4255
+ )) {
4256
+ this.collectFinalStatePaths(
4257
+ regionStates,
4258
+ `${fullName}.${regionName}`,
4259
+ collector
4260
+ );
4261
+ }
4262
+ }
4263
+ }
4264
+ return collector;
4265
+ }
3850
4266
  validatePerformanceConstraints(smConfig) {
3851
4267
  const stateCount = this.countStates(smConfig.states);
3852
4268
  const eventCount = Object.keys(smConfig.events).length;