@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/README.md CHANGED
@@ -28,13 +28,48 @@ const sm = createMachine({
28
28
  })
29
29
  ```
30
30
 
31
+ ## Hierarchical regions, parallel states & join
32
+
33
+ A state may declare `regions` to run several orthogonal sub-machines at once. Entry/exit follow SCXML/UML ordering, and a region may complete via a `final` state that raises a `done.state.<id>` join event.
34
+
35
+ ```ts
36
+ const sm = createMachine({
37
+ name: 'proc',
38
+ initialState: 'proc',
39
+ states: {
40
+ proc: {
41
+ initial: 'a.run|b.run', // both regions active in parallel
42
+ onEnter: () => {/* parent runs BEFORE region children (ancestor-first) */},
43
+ regions: {
44
+ a: { run: {}, done: { final: true } },
45
+ b: { run: {}, done: { final: true } },
46
+ },
47
+ },
48
+ complete: {},
49
+ },
50
+ events: {
51
+ finishA: { transitions: [{ from: 'proc.a.run', to: 'proc.a.done' }] },
52
+ finishB: { transitions: [{ from: 'proc.b.run', to: 'proc.b.done' }] },
53
+ // Join: raised automatically once EVERY region reached a `final` state.
54
+ 'done.state.proc': { transitions: [{ from: 'proc', to: 'complete' }] },
55
+ },
56
+ })
57
+ ```
58
+
59
+ - **Expansion** — entering `proc` (as initial state *or* via a transition) expands to the parallel configuration `proc.a.run|proc.b.run`.
60
+ - **Ordering** — entry is ancestor-first (`proc` then region children); exit is descendant-first (region children then `proc`).
61
+ - **Parallel-exit** — a plain transition `from: 'proc'` on a user event preempts and exits all active regions immediately (LCCA).
62
+ - **Join** — when all regions are `final`, the engine raises `done.state.proc`; `sm.isDone('proc')` reflects the all-final configuration. (`done.state.*` is never matched by a `from: '*'` wildcard.)
63
+
64
+ See [`docs/regions-and-parallel.md`](./docs/regions-and-parallel.md) for the full model, ordering rules, and validation.
65
+
31
66
  ## Documentation
32
67
 
33
68
  Full API documentation: [https://vedmalex.github.io/statemachine/](https://vedmalex.github.io/statemachine/)
34
69
 
35
70
  ## Extension Points
36
71
 
37
- This package exposes 7 extension points (`IMonitor`, `ITimerScheduler`, `IErrorHandler`, `Adapter<T>`, `ILogger`, `StatePersistenceAdapter`, `validateConfig`) for host integration. See [`docs/extension-points.md`](./docs/extension-points.md) for the full catalog.
72
+ This package exposes 7 extension points (`IMonitor`, `ITimerScheduler`, `IErrorHandler`, `Adapter<T>`, `ILogger`, `StatePersistenceAdapter`, `validateConfig`) for host integration. Callbacks resolved from config or `setContext()` receive the underlying owner object directly, so host code does not need to unwrap `Adapter<T>` inside each callback. See [`docs/extension-points.md`](./docs/extension-points.md) for the full catalog.
38
73
 
39
74
  ## Stability policy
40
75
 
package/dist/index.cjs CHANGED
@@ -1850,6 +1850,9 @@ var StateMachine = class _StateMachine {
1850
1850
  setContext(context) {
1851
1851
  this.context = context;
1852
1852
  }
1853
+ resolveCallbackOwner(value) {
1854
+ return isAdapter(value) ? value.adaptee : value;
1855
+ }
1853
1856
  enqueueEvent(eventName, obj, args, type) {
1854
1857
  if (this.externalQueue.length + this.internalQueue.length >= this.maxQueueDepth) {
1855
1858
  const _s0 = this.getCurrentState(obj);
@@ -1958,7 +1961,8 @@ var StateMachine = class _StateMachine {
1958
1961
  let transitions = event ? event.transitions.filter(
1959
1962
  (t) => this.isTransitionPossible(t, currentState)
1960
1963
  ) : [];
1961
- if (!transitions.length) {
1964
+ const isEngineDoneEvent = String(eventName).startsWith("done.state.");
1965
+ if (!transitions.length && !isEngineDoneEvent) {
1962
1966
  const wildcardEvent = this.events.get("*");
1963
1967
  if (wildcardEvent) {
1964
1968
  const wildcardTransitions = wildcardEvent.transitions.filter(
@@ -2163,6 +2167,10 @@ var StateMachine = class _StateMachine {
2163
2167
  if (!currentState) return expectedState === "";
2164
2168
  const currentParts = currentState.split("|").sort();
2165
2169
  const expectedParts = expectedState.split("|").sort();
2170
+ const ancestorMatch = expectedParts.every(
2171
+ (expectedPart) => currentParts.some((leaf) => this.isParentState(expectedPart, leaf))
2172
+ );
2173
+ if (ancestorMatch) return true;
2166
2174
  if (currentParts.length !== expectedParts.length) return false;
2167
2175
  return currentParts.every((part, index) => part === expectedParts[index]);
2168
2176
  }
@@ -2541,7 +2549,7 @@ var StateMachine = class _StateMachine {
2541
2549
  const currentState = this.getCurrentState(adaptee) || "";
2542
2550
  const currentStateMap = this.parseCompositeState(currentState);
2543
2551
  const newStateParts = state.split("|");
2544
- const isRootState = newStateParts.length === 1 && !state.includes(".");
2552
+ const isRootState = newStateParts.length === 1 && !state.includes(".") && !this.states.get(state)?.regions;
2545
2553
  if (isRootState) {
2546
2554
  currentStateMap.clear();
2547
2555
  currentStateMap.set(state, state);
@@ -2617,12 +2625,13 @@ var StateMachine = class _StateMachine {
2617
2625
  initialStates = initialState;
2618
2626
  }
2619
2627
  this.setCurrentState(initialStates, targetAdaptee);
2620
- const stateParts = initialStates.split("|");
2628
+ const { enterStates } = this.computeEnterExitSets("", initialStates);
2629
+ const enterFireOrder = enterStates.length > 0 ? enterStates : [initialStates];
2621
2630
  const context = {
2622
2631
  state: initialStates,
2623
2632
  phase: "enter"
2624
2633
  };
2625
- for (const statePart of stateParts) {
2634
+ for (const statePart of enterFireOrder) {
2626
2635
  this.executeEnterActions(
2627
2636
  targetAdaptee,
2628
2637
  statePart,
@@ -2636,6 +2645,11 @@ var StateMachine = class _StateMachine {
2636
2645
  );
2637
2646
  });
2638
2647
  }
2648
+ this.checkCompletion(
2649
+ targetAdaptee,
2650
+ "",
2651
+ initialStates
2652
+ );
2639
2653
  }
2640
2654
  getInitialCompositeState(initialState) {
2641
2655
  const stateConfig = this.states.get(initialState);
@@ -2675,6 +2689,211 @@ var StateMachine = class _StateMachine {
2675
2689
  }
2676
2690
  return regionStates.join("|");
2677
2691
  }
2692
+ /**
2693
+ * Whether `leaf` is a UML/SCXML `<final>` atomic state of its region.
2694
+ *
2695
+ * Reads the `final` marker straight from the flattened state map populated by
2696
+ * processStates, so it works for any registered atomic leaf regardless of
2697
+ * nesting depth. Returns `false` for unregistered names and for composites
2698
+ * (the all-regions-final join derives doneness from atomic leaves, not from a
2699
+ * `final` flag on a composite parent).
2700
+ */
2701
+ isStateFinal(leaf) {
2702
+ return Boolean(this.states.get(leaf)?.final);
2703
+ }
2704
+ /**
2705
+ * Whether composite `compositeId` has reached its UML/SCXML "done"
2706
+ * configuration: every one of its regions has its active atomic leaf in a
2707
+ * `final` state (recursively, for nested composites).
2708
+ *
2709
+ * D10 (mustFix): doneness is derived by scanning the active atomic `|`-leaves
2710
+ * against the STATIC regions tree (`this.states.get(C)?.regions`), NEVER via a
2711
+ * region-key Map lookup (`configMap.get`) — that map keys leaves by their
2712
+ * deepest region container and so cannot answer "which leaf is active in
2713
+ * region X of composite C". For each region we locate the active leaf via the
2714
+ * `C.region.` dotted prefix; the region is final iff that leaf is `final`, or
2715
+ * the leaf lives under a nested composite that is itself `isCompositeDone`.
2716
+ *
2717
+ * Returns `false` for a non-composite id, for a composite with no active leaf
2718
+ * in some region, or when no substate under it is ever `final` (cheap miss).
2719
+ */
2720
+ isCompositeDone(compositeId, atomicLeaves) {
2721
+ const regions = this.states.get(compositeId)?.regions;
2722
+ if (!regions) return false;
2723
+ for (const regionName of Object.keys(regions)) {
2724
+ const regionPrefix = `${compositeId}.${regionName}.`;
2725
+ const activeLeaf = atomicLeaves.find(
2726
+ (leaf) => leaf.startsWith(regionPrefix)
2727
+ );
2728
+ if (!activeLeaf) return false;
2729
+ const nestedComposite = this.regionComposite(activeLeaf, regionPrefix);
2730
+ if (nestedComposite) {
2731
+ if (this.isCompositeDone(nestedComposite, atomicLeaves)) continue;
2732
+ return false;
2733
+ }
2734
+ if (this.isStateFinal(activeLeaf)) continue;
2735
+ return false;
2736
+ }
2737
+ return true;
2738
+ }
2739
+ /**
2740
+ * The OUTERMOST registered composite (regions-bearing) ancestor of `leaf`
2741
+ * that lives strictly under `regionPrefix`, or `undefined` if the region
2742
+ * holds a simple atomic state directly. Used by {@link isCompositeDone} to
2743
+ * delegate a region's completeness to its nested composite (recursing over
2744
+ * every parallel branch), independent of whether any single branch leaf
2745
+ * happens to carry the `final` flag.
2746
+ *
2747
+ * ancestorChain is root-to-leaf, so the FIRST matching ancestor is the
2748
+ * region's direct composite child (e.g. `C.p.D` for leaf `C.p.D.s.s2` under
2749
+ * region prefix `C.p.`); isCompositeDone then recurses into its regions.
2750
+ */
2751
+ regionComposite(leaf, regionPrefix) {
2752
+ for (const ancestor of this.ancestorChain(leaf)) {
2753
+ if (ancestor !== leaf && ancestor.startsWith(regionPrefix) && this.states.get(ancestor)?.regions) {
2754
+ return ancestor;
2755
+ }
2756
+ }
2757
+ return void 0;
2758
+ }
2759
+ /**
2760
+ * Whether composite `compositeId` has reached its all-regions-final ("done")
2761
+ * configuration in the CURRENT active state. Public guard surface (`@stable`)
2762
+ * for authoring a join as `guard: () => sm.isDone('C')` instead of (or
2763
+ * alongside) listening on the engine `done.state.<C>` event.
2764
+ *
2765
+ * @param compositeId - The dotted id of the composite/parallel state.
2766
+ * @returns `true` iff every region's active atomic leaf is `final`.
2767
+ */
2768
+ isDone(compositeId, adaptee) {
2769
+ const currentState = this.getCurrentState(adaptee);
2770
+ if (!currentState) return false;
2771
+ const atomicLeaves = currentState.split("|").filter(Boolean);
2772
+ return this.isCompositeDone(compositeId, atomicLeaves);
2773
+ }
2774
+ /**
2775
+ * SCXML completion hook (D10/D11/D12): after a new configuration is written,
2776
+ * raise `done.state.<C>` for each composite that became all-regions-final.
2777
+ *
2778
+ * - Scans only composites that GAINED an active leaf (the ancestor chain of
2779
+ * each `|`-leaf of `newState`), so unaffected composites are not re-checked.
2780
+ * - Emits INNERMOST-first (a deeper composite's `done.state` precedes its
2781
+ * parent's), matching SCXML's inner-before-outer completion ordering, via a
2782
+ * per-config emitted-id Set so each id is raised at most once per call.
2783
+ * - Gates each emission on `this.events.has('done.state.'+C)` (D11 mustFix):
2784
+ * raising an undeclared event would hit the `Invalid event` throw
2785
+ * (executeQueuedTransition) as an unhandled microtask rejection. No declared
2786
+ * `done.state.<C>` event => no emission, no crash, no observable effect.
2787
+ * - Uses the internal queue (`raiseEvent`) + `scheduleProcessing` so the
2788
+ * completion event is processed before subsequent external events.
2789
+ */
2790
+ checkCompletion(obj, oldState, newState) {
2791
+ const atomicLeaves = newState.split("|").filter(Boolean);
2792
+ if (atomicLeaves.length === 0) return;
2793
+ const oldLeaves = oldState.split("|").filter(Boolean);
2794
+ const seen = /* @__PURE__ */ new Set();
2795
+ for (const leaf of atomicLeaves) {
2796
+ for (const ancestor of this.ancestorChain(leaf)) {
2797
+ if (ancestor === leaf) continue;
2798
+ if (seen.has(ancestor)) continue;
2799
+ if (!this.states.get(ancestor)?.regions) continue;
2800
+ seen.add(ancestor);
2801
+ }
2802
+ }
2803
+ const candidates = Array.from(seen).sort(
2804
+ (a, b) => b.split(".").length - a.split(".").length
2805
+ );
2806
+ const emitted = /* @__PURE__ */ new Set();
2807
+ for (const compositeId of candidates) {
2808
+ if (emitted.has(compositeId)) continue;
2809
+ if (!this.isCompositeDone(compositeId, atomicLeaves)) continue;
2810
+ if (this.isCompositeDone(compositeId, oldLeaves)) continue;
2811
+ emitted.add(compositeId);
2812
+ const doneEvent = `done.state.${compositeId}`;
2813
+ if (!this.events.has(doneEvent)) continue;
2814
+ this.raiseEvent(doneEvent, obj);
2815
+ this.scheduleProcessing();
2816
+ }
2817
+ }
2818
+ /**
2819
+ * Build the registered ancestor chain for an atomic leaf, ordered root-to-leaf.
2820
+ *
2821
+ * Walks every dot-prefix of `leaf` and keeps only the ones that are real
2822
+ * registered states (`this.states.has`). Region containers are never
2823
+ * registered (only composite parents and atomic leaves are — see
2824
+ * processStates/processRegions), so they are filtered out automatically. The
2825
+ * result is exactly `[parent..leaf]` for a leaf inside a composite, and
2826
+ * `[leaf]` for a flat state.
2827
+ *
2828
+ * Example: `ancestorChain('a.r1.c1')` -> `['a', 'a.r1.c1']` (the `a.r1`
2829
+ * region container is excluded). Nested:
2830
+ * `ancestorChain('a.r1.c1.r3.x')` -> `['a', 'a.r1.c1', 'a.r1.c1.r3.x']`.
2831
+ */
2832
+ ancestorChain(leaf) {
2833
+ const chain = [];
2834
+ let dotIndex = leaf.indexOf(".");
2835
+ while (dotIndex !== -1) {
2836
+ const prefix = leaf.substring(0, dotIndex);
2837
+ if (this.states.has(prefix)) {
2838
+ chain.push(prefix);
2839
+ }
2840
+ dotIndex = leaf.indexOf(".", dotIndex + 1);
2841
+ }
2842
+ if (this.states.has(leaf)) {
2843
+ chain.push(leaf);
2844
+ }
2845
+ return chain;
2846
+ }
2847
+ /**
2848
+ * Compute the ordered enter/exit sets between two composite configurations
2849
+ * (SCXML ancestor-first entry / descendant-first exit).
2850
+ *
2851
+ * For each `|`-separated atomic leaf in `oldComposite` and `newComposite` the
2852
+ * union of its {@link ancestorChain} forms the old/new active ancestry. A
2853
+ * state shared by both ancestries lands in NEITHER diff, so a surviving
2854
+ * ancestor is never re-entered nor exited (no onEnter/onExit re-fire, no
2855
+ * timer re-arm/leak).
2856
+ *
2857
+ * - `enterStates` = new ancestry MINUS old, sorted ascending by depth then
2858
+ * document (insertion) order -> root-to-leaf entry.
2859
+ * - `exitStates` = old ancestry MINUS new, sorted descending by depth ->
2860
+ * leaf-to-root exit.
2861
+ *
2862
+ * Consumed by BOTH R1 (applyTransition / setInitialState / reset) and R2.
2863
+ */
2864
+ computeEnterExitSets(oldComposite, newComposite) {
2865
+ const collectAncestry = (composite) => {
2866
+ const ancestry = /* @__PURE__ */ new Set();
2867
+ if (!composite) return ancestry;
2868
+ for (const leaf of composite.split("|")) {
2869
+ if (!leaf) continue;
2870
+ for (const ancestor of this.ancestorChain(leaf)) {
2871
+ ancestry.add(ancestor);
2872
+ }
2873
+ }
2874
+ return ancestry;
2875
+ };
2876
+ const oldAncestry = collectAncestry(oldComposite);
2877
+ const newAncestry = collectAncestry(newComposite);
2878
+ const depthOf = (state) => {
2879
+ let depth = 0;
2880
+ for (let i = 0; i < state.length; i++) {
2881
+ if (state[i] === ".") depth++;
2882
+ }
2883
+ return depth;
2884
+ };
2885
+ const enterRaw = [];
2886
+ for (const state of newAncestry) {
2887
+ if (!oldAncestry.has(state)) enterRaw.push(state);
2888
+ }
2889
+ const exitRaw = [];
2890
+ for (const state of oldAncestry) {
2891
+ if (!newAncestry.has(state)) exitRaw.push(state);
2892
+ }
2893
+ 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);
2894
+ 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);
2895
+ return { enterStates, exitStates };
2896
+ }
2678
2897
  validateCompositeState(compositeState) {
2679
2898
  const stateParts = compositeState.split("|");
2680
2899
  const regionKeys = /* @__PURE__ */ new Set();
@@ -2748,10 +2967,11 @@ var StateMachine = class _StateMachine {
2748
2967
  return (...args) => {
2749
2968
  const targetAdaptee = args.length >= 2 ? args[0] : adaptee;
2750
2969
  const error = args.length >= 2 ? args[1] : args[0];
2751
- return handler(targetAdaptee, error);
2970
+ return handler(this.resolveCallbackOwner(targetAdaptee), error);
2752
2971
  };
2753
2972
  }
2754
2973
  async callAction(obj, actionName, ...args) {
2974
+ const targetOwner = this.resolveCallbackOwner(obj);
2755
2975
  const _callActionState = this.getCurrentState(
2756
2976
  obj
2757
2977
  );
@@ -2766,16 +2986,16 @@ var StateMachine = class _StateMachine {
2766
2986
  if (this.context && this.context[actionName]) {
2767
2987
  const action = this.context[actionName];
2768
2988
  if (typeof action === "function") {
2769
- const result = action(this.context, ...args);
2989
+ const result = action(targetOwner, ...args);
2770
2990
  return result instanceof Promise ? await result : result;
2771
2991
  }
2772
2992
  } else if (typeof actionName === "function") {
2773
- const result = actionName(obj, ...args);
2993
+ const result = actionName(targetOwner, ...args);
2774
2994
  return result instanceof Promise ? await result : result;
2775
2995
  } else if (obj.get(actionName)) {
2776
2996
  const action = obj.get(actionName);
2777
2997
  if (typeof action === "function") {
2778
- const result = action(obj, ...args);
2998
+ const result = action(targetOwner, ...args);
2779
2999
  return result instanceof Promise ? await result : result;
2780
3000
  }
2781
3001
  }
@@ -2847,8 +3067,12 @@ var StateMachine = class _StateMachine {
2847
3067
  if (fromState === "*") return true;
2848
3068
  const regionKey = this.getRegionKey(fromState);
2849
3069
  const currentStateForRegion = currentStates.get(regionKey);
2850
- if (!currentStateForRegion) return false;
2851
- return currentStateForRegion === fromState || this.isParentState(currentStateForRegion, fromState);
3070
+ if (currentStateForRegion && (currentStateForRegion === fromState || this.isParentState(currentStateForRegion, fromState))) {
3071
+ return true;
3072
+ }
3073
+ return Array.from(currentStates.values()).some(
3074
+ (leaf) => leaf === fromState || this.isParentState(fromState, leaf)
3075
+ );
2852
3076
  });
2853
3077
  }
2854
3078
  isParentState(parentState, childState) {
@@ -2864,6 +3088,26 @@ var StateMachine = class _StateMachine {
2864
3088
  const targetState = transition.to === "*" ? currentState : transition.to;
2865
3089
  this._isTransitioning = true;
2866
3090
  this._targetState = targetState;
3091
+ let newState;
3092
+ let enterStates;
3093
+ let exitStates;
3094
+ try {
3095
+ newState = this.updateState(currentState, targetState);
3096
+ const sets = this.computeEnterExitSets(currentState, newState);
3097
+ enterStates = sets.enterStates;
3098
+ exitStates = sets.exitStates;
3099
+ } catch (error) {
3100
+ this.logger.warn("Transition aborted: invalid target configuration", {
3101
+ state: currentState,
3102
+ target: String(targetState),
3103
+ error
3104
+ });
3105
+ this._isTransitioning = false;
3106
+ this._targetState = void 0;
3107
+ return void 0;
3108
+ }
3109
+ const exitFireOrder = exitStates.length > 0 ? exitStates : [transition.from];
3110
+ const enterFireOrder = enterStates.length > 0 ? enterStates : [transition.to];
2867
3111
  try {
2868
3112
  if (transition.guard) {
2869
3113
  const allow = await this.callAction(obj, transition.guard, ...args).catch(
@@ -2889,7 +3133,9 @@ var StateMachine = class _StateMachine {
2889
3133
  );
2890
3134
  }
2891
3135
  try {
2892
- await this.executeExitActions(obj, transition.from, args, context);
3136
+ for (const exitStateName of exitFireOrder) {
3137
+ await this.executeExitActions(obj, exitStateName, args, context);
3138
+ }
2893
3139
  } catch (error) {
2894
3140
  if (this.abortOnExitError) {
2895
3141
  this.logger.warn("Transition aborted due to onExit error", { state: currentState, error });
@@ -2909,12 +3155,14 @@ var StateMachine = class _StateMachine {
2909
3155
  );
2910
3156
  }
2911
3157
  try {
2912
- await this.executeEnterActions(obj, transition.to, args, context);
3158
+ for (const enterStateName of enterFireOrder) {
3159
+ await this.executeEnterActions(obj, enterStateName, args, context);
3160
+ }
2913
3161
  } catch (error) {
2914
3162
  if (this.errorState) {
2915
3163
  this.logger.error(`Failed to enter state '${targetState}'. Fallback to error state '${this.errorState}'`, { error });
2916
- const newState2 = this.updateState(currentState, this.errorState);
2917
- this.setCurrentState(newState2, obj);
3164
+ const errorNewState = this.updateState(currentState, this.errorState);
3165
+ this.setCurrentState(errorNewState, obj);
2918
3166
  return this.states.get(this.errorState);
2919
3167
  }
2920
3168
  throw error;
@@ -2929,12 +3177,12 @@ var StateMachine = class _StateMachine {
2929
3177
  event.onError,
2930
3178
  this.onError
2931
3179
  );
2932
- errorHandler(obj, error);
3180
+ errorHandler(this.resolveCallbackOwner(obj), error);
2933
3181
  }
2934
3182
  }
2935
3183
  const transitionStartTime = Date.now();
2936
- const newState = this.updateState(currentState, targetState);
2937
3184
  this.setCurrentState(newState, obj);
3185
+ this.checkCompletion(obj, currentState, newState);
2938
3186
  const transitionTime = Date.now() - transitionStartTime;
2939
3187
  this.monitor.recordTransition(transitionTime, true);
2940
3188
  return this.states.get(targetState);
@@ -3110,7 +3358,7 @@ var StateMachine = class _StateMachine {
3110
3358
  updateState(currentState, toState) {
3111
3359
  const currentStateMap = this.parseCompositeState(currentState);
3112
3360
  const toStateParts = toState.split("|");
3113
- if (toStateParts.length === 1 && !toState.includes(".")) {
3361
+ if (toStateParts.length === 1 && !toState.includes(".") && !this.states.get(toState)?.regions) {
3114
3362
  currentStateMap.clear();
3115
3363
  currentStateMap.set(toState, toState);
3116
3364
  return toState;
@@ -3492,6 +3740,7 @@ var ConfigValidator = class {
3492
3740
  this.validateInitialState(smConfig);
3493
3741
  this.validateCrossReferences(smConfig);
3494
3742
  this.validatePerformanceConstraints(smConfig);
3743
+ this.validateFinalStates(smConfig);
3495
3744
  this.runCustomRules(smConfig);
3496
3745
  } catch (error) {
3497
3746
  const msg = error instanceof Error ? error.message : String(error);
@@ -3853,6 +4102,10 @@ var ConfigValidator = class {
3853
4102
  }
3854
4103
  }
3855
4104
  }
4105
+ const finalStatePaths = this.collectFinalStatePaths(smConfig.states);
4106
+ for (const finalPath of finalStatePaths) {
4107
+ reachableStates.add(finalPath);
4108
+ }
3856
4109
  for (const stateName of allStateNames) {
3857
4110
  if (!reachableStates.has(stateName)) {
3858
4111
  this.addWarning(
@@ -3863,8 +4116,9 @@ var ConfigValidator = class {
3863
4116
  }
3864
4117
  }
3865
4118
  for (const [eventName, event] of Object.entries(smConfig.events)) {
4119
+ const isDoneStateEvent = eventName.startsWith("done.state.");
3866
4120
  const hasValidTransitions = event.transitions.some(
3867
- (t) => allStateNames.has(t.from) && allStateNames.has(t.to)
4121
+ (t) => (allStateNames.has(t.from) || isDoneStateEvent && allStateNames.has(eventName.slice("done.state.".length))) && allStateNames.has(t.to)
3868
4122
  );
3869
4123
  if (!hasValidTransitions) {
3870
4124
  this.addWarning(
@@ -3892,6 +4146,168 @@ var ConfigValidator = class {
3892
4146
  }
3893
4147
  }
3894
4148
  }
4149
+ /**
4150
+ * Collects every state in the tree as {dottedPath, config} entries, walking
4151
+ * region containers (region names are NOT registered states, so they are part
4152
+ * of the path but never emitted as an entry — mirroring runtime expansion).
4153
+ */
4154
+ collectStateEntries(states, prefix, collector) {
4155
+ for (const [stateName, state] of Object.entries(states)) {
4156
+ const fullName = prefix ? `${prefix}.${stateName}` : stateName;
4157
+ collector.push({ path: fullName, state });
4158
+ if (state.regions) {
4159
+ for (const [regionName, regionStates] of Object.entries(
4160
+ state.regions
4161
+ )) {
4162
+ this.collectStateEntries(
4163
+ regionStates,
4164
+ `${fullName}.${regionName}`,
4165
+ collector
4166
+ );
4167
+ }
4168
+ }
4169
+ }
4170
+ }
4171
+ /**
4172
+ * True when `composite` has at least one `final:true` atomic substate reachable
4173
+ * through its (possibly nested) region tree — i.e. some region of the composite
4174
+ * can become done. Used by REGION_NO_REACHABLE_FINAL.
4175
+ */
4176
+ hasReachableFinal(composite) {
4177
+ if (!composite.regions) return false;
4178
+ for (const regionStates of Object.values(composite.regions)) {
4179
+ for (const state of Object.values(regionStates)) {
4180
+ if (state.final) return true;
4181
+ if (state.regions && this.hasReachableFinal(state)) return true;
4182
+ }
4183
+ }
4184
+ return false;
4185
+ }
4186
+ /**
4187
+ * SCXML/UML final-state and all-regions-final (done.state.<C>) join validation.
4188
+ *
4189
+ * - FINAL_STATE_HAS_OUTGOING (error): a `final:true` state is the `from` of a
4190
+ * transition (a final pseudo-state cannot itself transition out).
4191
+ * - FINAL_ON_COMPOSITE (warning): `final:true` on a state that has regions
4192
+ * (final is meaningful only on an atomic leaf; ignored at runtime).
4193
+ * - REGION_NO_REACHABLE_FINAL (warning): a `done.state.<C>` transition exists
4194
+ * but composite C declares no `final` substate, so the join can never fire.
4195
+ * - DONE_VS_PARALLEL_EXIT_AMBIGUITY (warning): the same composite C has BOTH a
4196
+ * `done.state.C` join AND a plain `from:'C'` user-event parallel-exit, which
4197
+ * can preempt the all-final join (the user event is eligible on ANY leaf).
4198
+ * - REGION_MISSING_INITIAL (advisory warning, valid:true): a region omits an
4199
+ * explicit `initial`, so expansion relies on first-key insertion order.
4200
+ */
4201
+ validateFinalStates(smConfig) {
4202
+ const entries = [];
4203
+ this.collectStateEntries(smConfig.states, "", entries);
4204
+ const finalStatePaths = /* @__PURE__ */ new Set();
4205
+ for (const { path, state } of entries) {
4206
+ if (!state.final) continue;
4207
+ if (state.regions) {
4208
+ this.addWarning(
4209
+ "FINAL_ON_COMPOSITE",
4210
+ `Final flag on composite state "${path}" is ignored; mark an atomic leaf inside a region as final instead`,
4211
+ `states.${path}.final`,
4212
+ void 0,
4213
+ "Remove `final: true` from this composite and set it on the atomic leaf state that represents the region's completion."
4214
+ );
4215
+ } else {
4216
+ finalStatePaths.add(path);
4217
+ }
4218
+ }
4219
+ const userFromStates = /* @__PURE__ */ new Set();
4220
+ const doneStateComposites = /* @__PURE__ */ new Set();
4221
+ for (const [eventName, event] of Object.entries(smConfig.events)) {
4222
+ if (!event.transitions || !Array.isArray(event.transitions)) continue;
4223
+ if (eventName.startsWith("done.state.")) {
4224
+ const compositeId = eventName.slice("done.state.".length);
4225
+ if (compositeId) doneStateComposites.add(compositeId);
4226
+ }
4227
+ for (const transition of event.transitions) {
4228
+ if (typeof transition.from !== "string") continue;
4229
+ for (const fromPart of transition.from.split("|")) {
4230
+ if (finalStatePaths.has(fromPart)) {
4231
+ this.addError(
4232
+ "FINAL_STATE_HAS_OUTGOING",
4233
+ `Final state "${fromPart}" cannot be the source of transition on event "${eventName}"; a final pseudo-state has no outgoing transitions`,
4234
+ `events.${eventName}`,
4235
+ void 0,
4236
+ `Remove the outgoing transition from "${fromPart}", or clear its \`final: true\` flag if it is not actually a final state.`
4237
+ );
4238
+ }
4239
+ if (!eventName.startsWith("done.state.")) {
4240
+ userFromStates.add(fromPart);
4241
+ }
4242
+ }
4243
+ }
4244
+ }
4245
+ const entryByPath = new Map(entries.map((e) => [e.path, e.state]));
4246
+ for (const compositeId of doneStateComposites) {
4247
+ const composite = entryByPath.get(compositeId);
4248
+ if (!composite) {
4249
+ continue;
4250
+ }
4251
+ if (!this.hasReachableFinal(composite)) {
4252
+ this.addWarning(
4253
+ "REGION_NO_REACHABLE_FINAL",
4254
+ `Join event "done.state.${compositeId}" can never fire: composite "${compositeId}" has no \`final\` substate in any region`,
4255
+ `events.done.state.${compositeId}`,
4256
+ void 0,
4257
+ `Mark the completion leaf of each region under "${compositeId}" with \`final: true\`, or remove the done.state join.`
4258
+ );
4259
+ }
4260
+ if (userFromStates.has(compositeId)) {
4261
+ this.addWarning(
4262
+ "DONE_VS_PARALLEL_EXIT_AMBIGUITY",
4263
+ `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`,
4264
+ `events.done.state.${compositeId}`,
4265
+ void 0,
4266
+ `Disambiguate by using the done.state.${compositeId} join exclusively, or scope the user-event transition to a specific leaf instead of the bare composite.`
4267
+ );
4268
+ }
4269
+ }
4270
+ for (const { path, state } of entries) {
4271
+ if (!state.regions) continue;
4272
+ for (const [regionName, regionStates] of Object.entries(state.regions)) {
4273
+ const regionKeys = Object.keys(regionStates);
4274
+ if (regionKeys.length === 0) continue;
4275
+ const parentInitialInRegion = state.initial !== void 0 && regionKeys.includes(state.initial);
4276
+ if (!parentInitialInRegion) {
4277
+ this.addWarning(
4278
+ "REGION_MISSING_INITIAL",
4279
+ `Region "${regionName}" of composite "${path}" has no explicit initial; expansion falls back to the first key "${regionKeys[0]}" (insertion-order dependent)`,
4280
+ `states.${path}.regions.${regionName}`,
4281
+ void 0,
4282
+ `Set \`initial\` on composite "${path}" to a leaf of region "${regionName}", to make the starting substate explicit and order-independent.`
4283
+ );
4284
+ }
4285
+ }
4286
+ }
4287
+ }
4288
+ /**
4289
+ * Collects the dotted paths of every `final:true` atomic leaf in the tree.
4290
+ */
4291
+ collectFinalStatePaths(states, prefix = "", collector = /* @__PURE__ */ new Set()) {
4292
+ for (const [stateName, state] of Object.entries(states)) {
4293
+ const fullName = prefix ? `${prefix}.${stateName}` : stateName;
4294
+ if (state.final && !state.regions) {
4295
+ collector.add(fullName);
4296
+ }
4297
+ if (state.regions) {
4298
+ for (const [regionName, regionStates] of Object.entries(
4299
+ state.regions
4300
+ )) {
4301
+ this.collectFinalStatePaths(
4302
+ regionStates,
4303
+ `${fullName}.${regionName}`,
4304
+ collector
4305
+ );
4306
+ }
4307
+ }
4308
+ }
4309
+ return collector;
4310
+ }
3895
4311
  validatePerformanceConstraints(smConfig) {
3896
4312
  const stateCount = this.countStates(smConfig.states);
3897
4313
  const eventCount = Object.keys(smConfig.events).length;