circulator 2.1.0 → 2.1.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a33499a72d0661bf32e27e39213e4b0193f292eef8537c1946f0eb139e751a91
4
- data.tar.gz: 6c3fa67b66bef192886a17cc9d756cb491bce6509127b43b87e7f5ca74347ef4
3
+ metadata.gz: 1a3d83bc194cf2440d13e088b1b23e72cf3ec89071ae9d046d575a7e5d1014c7
4
+ data.tar.gz: 4c1afec2234a2edf3675048519aac13e4b64f2c8bbb08b98387603fc5f354ea8
5
5
  SHA512:
6
- metadata.gz: 3755e25bcb3a1b34ba3768e8e3b501489dd7827ad9c1695b7365c2ffee9cbf9160181e2bd7226ba49e5c76d1763811e425beef4a0250a28811f22e57f970df98
7
- data.tar.gz: 0c970bbeca86d6835911ae4ec7369585a14a9815ff6fc4fcdf28886ae556f0fc3b3d1be97fc301a45799bb5cc3b702681b3160f0072489a5eb96cfdb222fd225
6
+ metadata.gz: a5bf023b56fe2f8352fbb2fadce2c8a29cb9a47cfd5b011c80c098646696b9dcd5735c2b027c8f4a126c285005f11c8b46e9c009ac8ad243df3e1bc8a5979523
7
+ data.tar.gz: e4f99d8cd3e1cdbf87a15eb0b588ffc778f0fdf412c897fb5bf6796ff1295351fcf542c6ea80d1f883b1c3fa6afd817925ecddbda39ac1e16ffc9a59665754cb
data/CHANGELOG.md CHANGED
@@ -5,8 +5,8 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
- ## [2.1.0] - 2025-10-02
8
+ ## [2.1.2] - 2025-10-17
9
9
 
10
10
  ### Added
11
11
 
12
- - Configure directory for generating diagrams, defaulting to "docs"
12
+ - Hash-based allow_if for nested state dependencies (411a6b5)
data/README.md CHANGED
@@ -8,6 +8,7 @@ A lightweight and flexible state machine implementation for Ruby that allows you
8
8
  - **Flexible DSL**: Intuitive syntax for defining states and transitions
9
9
  - **Dynamic Method Generation**: Automatically creates helper methods for state transitions
10
10
  - **Conditional Transitions**: Support for guards and conditional logic
11
+ - **Nested State Dependencies**: State machines can depend on the state of other attributes
11
12
  - **Transition Callbacks**: Execute code before, during, or after transitions
12
13
  - **Multiple State Machines**: Define multiple independent state machines per class
13
14
  - **Framework Agnostic**: Works with plain Ruby objects, no Rails or ActiveRecord required
@@ -93,6 +94,43 @@ class Document
93
94
  end
94
95
  ```
95
96
 
97
+ #### Nested State Dependencies
98
+
99
+ You can make one state machine depend on another using hash-based `allow_if`:
100
+
101
+ ```ruby
102
+ class Document
103
+ extend Circulator
104
+
105
+ attr_accessor :status, :review_status
106
+
107
+ # Review must be completed first
108
+ flow :review_status do
109
+ state :pending do
110
+ action :approve, to: :approved
111
+ end
112
+ state :approved
113
+ end
114
+
115
+ # Document status depends on review status
116
+ flow :status do
117
+ state :draft do
118
+ # Can only publish if review is approved
119
+ action :publish, to: :published, allow_if: {review_status: [:approved]}
120
+ end
121
+ end
122
+ end
123
+
124
+ doc = Document.new
125
+ doc.status = :draft
126
+ doc.review_status = :pending
127
+
128
+ doc.status_publish # => blocked, status remains :draft
129
+
130
+ doc.review_status_approve # => :approved
131
+ doc.status_publish # => :published ✓
132
+ ```
133
+
96
134
  #### Dynamic Destination States
97
135
 
98
136
  ```ruby
@@ -8,7 +8,7 @@ require_relative "../lib/circulator/dot"
8
8
  require_relative "../lib/circulator/plantuml"
9
9
 
10
10
  # Parse command-line options
11
- options = {format: "dot", require: nil, directory: "docs"}
11
+ options = {format: "dot", require: nil, directory: "docs", separate: false}
12
12
  parser = OptionParser.new do |opts|
13
13
  opts.banner = "Usage: circulator-diagram MODEL_NAME [options]"
14
14
  opts.separator ""
@@ -27,6 +27,10 @@ parser = OptionParser.new do |opts|
27
27
  options[:directory] = directory
28
28
  end
29
29
 
30
+ opts.on("-s", "--separate", "Generate separate diagram files for each flow attribute") do
31
+ options[:separate] = true
32
+ end
33
+
30
34
  opts.on("-r", "--require FILE", "Require a file before loading the model (e.g., config/environment)") do |file|
31
35
  options[:require] = file
32
36
  end
@@ -84,7 +88,7 @@ rescue NameError
84
88
  exit 1
85
89
  end
86
90
 
87
- # Generate diagram file
91
+ # Generate diagram file(s)
88
92
  begin
89
93
  generator = case options[:format]
90
94
  when "plantuml"
@@ -93,9 +97,7 @@ begin
93
97
  Circulator::Dot.new(model_class)
94
98
  end
95
99
 
96
- content = generator.generate
97
-
98
- # Determine output filename and extension
100
+ # Determine base output filename and extension
99
101
  # Convert namespaced class names to directory paths
100
102
  # Something::Other becomes something/other
101
103
  class_name = model_class.name || "diagram"
@@ -104,30 +106,62 @@ begin
104
106
  }
105
107
  base_name = path_parts.join("/")
106
108
 
107
- if options[:format] == "plantuml"
108
- # Use model class name for PlantUML files
109
- output_file = File.join(options[:directory], "#{base_name}.puml")
110
-
111
- # Create directory if needed
112
- dir = File.dirname(output_file)
113
- FileUtils.mkdir_p(dir) unless File.exist?(dir)
114
-
115
- File.write(output_file, content)
116
- puts "Generated PlantUML file: #{output_file}"
117
- puts "To create an image, run:"
118
- puts " plantuml #{output_file}"
109
+ if options[:separate]
110
+ # Generate separate diagram file for each flow attribute
111
+ diagrams = generator.generate_separate
112
+ extension = (options[:format] == "plantuml") ? ".puml" : ".dot"
113
+
114
+ diagrams.each do |attribute_name, content|
115
+ # Create filename with attribute name
116
+ output_file = File.join(options[:directory], "#{base_name}_#{attribute_name}#{extension}")
117
+
118
+ # Create directory if needed
119
+ dir = File.dirname(output_file)
120
+ FileUtils.mkdir_p(dir) unless File.exist?(dir)
121
+
122
+ File.write(output_file, content)
123
+ puts "Generated #{options[:format]} file: #{output_file}"
124
+ end
125
+
126
+ puts ""
127
+ puts "To create images, run:"
128
+ if options[:format] == "plantuml"
129
+ puts " plantuml #{File.join(options[:directory], "#{base_name}_*#{extension}")}"
130
+ else
131
+ diagrams.keys.each do |attribute_name|
132
+ output_file = File.join(options[:directory], "#{base_name}_#{attribute_name}#{extension}")
133
+ puts " dot -Tpng #{output_file} -o #{File.join(options[:directory], "#{base_name}_#{attribute_name}")}.png"
134
+ end
135
+ end
119
136
  else
120
- # Use model class name for DOT files
121
- output_file = File.join(options[:directory], "#{base_name}.dot")
122
-
123
- # Create directory if needed
124
- dir = File.dirname(output_file)
125
- FileUtils.mkdir_p(dir) unless File.exist?(dir)
126
-
127
- File.write(output_file, content)
128
- puts "Generated DOT file: #{output_file}"
129
- puts "To create an image, run:"
130
- puts " dot -Tpng #{output_file} -o #{File.join(options[:directory], base_name)}.png"
137
+ # Generate single combined diagram file
138
+ content = generator.generate
139
+
140
+ if options[:format] == "plantuml"
141
+ # Use model class name for PlantUML files
142
+ output_file = File.join(options[:directory], "#{base_name}.puml")
143
+
144
+ # Create directory if needed
145
+ dir = File.dirname(output_file)
146
+ FileUtils.mkdir_p(dir) unless File.exist?(dir)
147
+
148
+ File.write(output_file, content)
149
+ puts "Generated PlantUML file: #{output_file}"
150
+ puts "To create an image, run:"
151
+ puts " plantuml #{output_file}"
152
+ else
153
+ # Use model class name for DOT files
154
+ output_file = File.join(options[:directory], "#{base_name}.dot")
155
+
156
+ # Create directory if needed
157
+ dir = File.dirname(output_file)
158
+ FileUtils.mkdir_p(dir) unless File.exist?(dir)
159
+
160
+ File.write(output_file, content)
161
+ puts "Generated DOT file: #{output_file}"
162
+ puts "To create an image, run:"
163
+ puts " dot -Tpng #{output_file} -o #{File.join(options[:directory], base_name)}.png"
164
+ end
131
165
  end
132
166
 
133
167
  exit 0
@@ -20,12 +20,14 @@ module Circulator
20
20
  output = []
21
21
  output << header
22
22
 
23
- # Collect all states and transitions
24
- states = Set.new
25
- transitions = []
23
+ # Collect states and transitions grouped by attribute
24
+ flows_data = []
26
25
 
27
26
  @flows.each do |model_key, attribute_flows|
28
27
  attribute_flows.each do |attribute_name, flow|
28
+ states = Set.new
29
+ transitions = []
30
+
29
31
  # Extract states and transitions from the flow
30
32
  flow.transition_map.each do |action, state_transitions|
31
33
  state_transitions.each do |from_state, transition_info|
@@ -41,19 +43,68 @@ module Circulator
41
43
  end
42
44
  end
43
45
  end
46
+
47
+ flows_data << {
48
+ attribute_name: attribute_name,
49
+ states: states,
50
+ transitions: transitions
51
+ }
44
52
  end
45
53
  end
46
54
 
47
- # Output state nodes
48
- states_output(states, output)
49
-
50
- # Output transition edges
51
- transitions_output(transitions, output)
55
+ # Output flows (grouped or combined based on subclass implementation)
56
+ flows_output(flows_data, output)
52
57
 
53
58
  output << footer
54
59
  output.join("\n") + "\n"
55
60
  end
56
61
 
62
+ # Generate separate diagrams for each flow attribute
63
+ # Returns a hash mapping attribute_name => diagram_content
64
+ def generate_separate
65
+ result = {}
66
+
67
+ @flows.each do |model_key, attribute_flows|
68
+ attribute_flows.each do |attribute_name, flow|
69
+ states = Set.new
70
+ transitions = []
71
+
72
+ # Extract states and transitions from the flow
73
+ flow.transition_map.each do |action, state_transitions|
74
+ state_transitions.each do |from_state, transition_info|
75
+ states.add(from_state)
76
+
77
+ to_state = transition_info[:to]
78
+ if to_state.respond_to?(:call)
79
+ states.add(:"?")
80
+ transitions << dynamic_transition(action, from_state, :"?")
81
+ else
82
+ states.add(to_state)
83
+ transitions << standard_transition(action, from_state, to_state, conditional: transition_info[:allow_if])
84
+ end
85
+ end
86
+ end
87
+
88
+ # Generate diagram for this flow only
89
+ output = []
90
+ output << header_for_attribute(attribute_name)
91
+
92
+ flows_data = [{
93
+ attribute_name: attribute_name,
94
+ states: states,
95
+ transitions: transitions
96
+ }]
97
+
98
+ flows_output(flows_data, output)
99
+ output << footer
100
+
101
+ result[attribute_name] = output.join("\n") + "\n"
102
+ end
103
+ end
104
+
105
+ result
106
+ end
107
+
57
108
  private
58
109
 
59
110
  def graph_name
@@ -74,15 +125,15 @@ module Circulator
74
125
  raise NotImplementedError, "Subclasses must implement #{__method__}"
75
126
  end
76
127
 
77
- def footer
128
+ def header_for_attribute(attribute_name)
78
129
  raise NotImplementedError, "Subclasses must implement #{__method__}"
79
130
  end
80
131
 
81
- def transitions_output(transitions, output)
132
+ def footer
82
133
  raise NotImplementedError, "Subclasses must implement #{__method__}"
83
134
  end
84
135
 
85
- def states_output(states, output)
136
+ def flows_output(flows_data, output)
86
137
  raise NotImplementedError, "Subclasses must implement #{__method__}"
87
138
  end
88
139
 
@@ -6,6 +6,49 @@ module Circulator
6
6
  class Dot < Diagram
7
7
  private
8
8
 
9
+ def flows_output(flows_data, output)
10
+ if flows_data.size == 1
11
+ # Single flow: no grouping needed
12
+ flow = flows_data.first
13
+ states_output(flow[:states], output)
14
+ transitions_output(flow[:transitions], output)
15
+ else
16
+ # Multiple flows: use subgraph clusters
17
+ flows_data.each_with_index do |flow, index|
18
+ output << ""
19
+ output << " subgraph cluster_#{index} {"
20
+ output << " label=\":#{flow[:attribute_name]}\";"
21
+ output << " style=dashed;"
22
+ output << " color=blue;"
23
+ output << ""
24
+
25
+ # Output states within this cluster
26
+ flow[:states].sort_by(&:to_s).each do |state|
27
+ state_label = state.nil? ? "nil" : state.to_s
28
+ # Prefix state names with attribute to avoid conflicts
29
+ prefixed_name = "#{flow[:attribute_name]}_#{state_label}"
30
+ output << " #{prefixed_name} [label=\"#{state_label}\", shape=circle];"
31
+ end
32
+
33
+ output << " }"
34
+ end
35
+
36
+ # Output all transitions after clusters
37
+ output << ""
38
+ output << " // Transitions"
39
+ flows_data.each do |flow|
40
+ flow[:transitions].sort_by { |t| [t[:from].to_s, t[:to].to_s, t[:label]] }.each do |transition|
41
+ from_label = transition[:from].nil? ? "nil" : transition[:from].to_s
42
+ to_label = transition[:to].nil? ? "nil" : transition[:to].to_s
43
+ # Use prefixed names
44
+ prefixed_from = "#{flow[:attribute_name]}_#{from_label}"
45
+ prefixed_to = "#{flow[:attribute_name]}_#{to_label}"
46
+ output << " #{prefixed_from} -> #{prefixed_to} [label=\"#{transition[:label]}\"];"
47
+ end
48
+ end
49
+ end
50
+ end
51
+
9
52
  # def graph_name
10
53
  # # Use the model class name if available, otherwise use the model key
11
54
  # class_name = @model_class.name
@@ -45,6 +88,14 @@ module Circulator
45
88
  DOT
46
89
  end
47
90
 
91
+ def header_for_attribute(attribute_name)
92
+ class_name = @model_class.name || "diagram"
93
+ <<~DOT
94
+ digraph "#{class_name} :#{attribute_name} flow" {
95
+ rankdir=LR;
96
+ DOT
97
+ end
98
+
48
99
  def footer
49
100
  "}"
50
101
  end
@@ -23,6 +23,11 @@ module Circulator
23
23
  def action(name, to:, from: :__not_specified__, allow_if: nil, &block)
24
24
  raise "You must be in a state block or have a `from` option to declare an action" unless defined?(@current_state) || from != :__not_specified__
25
25
 
26
+ # Validate allow_if parameter
27
+ if allow_if
28
+ validate_allow_if(allow_if)
29
+ end
30
+
26
31
  @transition_map[name] ||= {}
27
32
  selected_state = (from == :__not_specified__) ? @current_state : from
28
33
 
@@ -36,6 +41,13 @@ module Circulator
36
41
  states_to_process.each do |from_state|
37
42
  from_state = from_state.to_sym if from_state.respond_to?(:to_sym)
38
43
  @states.add(from_state)
44
+
45
+ # Add the target state to @states if it's not a callable
46
+ unless to.respond_to?(:call)
47
+ to_state = to.respond_to?(:to_sym) ? to.to_sym : to
48
+ @states.add(to_state)
49
+ end
50
+
39
51
  @transition_map[name][from_state] = {to:, block:}
40
52
  @transition_map[name][from_state][:allow_if] = allow_if if allow_if
41
53
  end
@@ -67,5 +79,51 @@ module Circulator
67
79
  @no_action
68
80
  end
69
81
  end
82
+
83
+ private
84
+
85
+ def validate_allow_if(allow_if)
86
+ # Must be either a Proc or a Hash
87
+ unless allow_if.is_a?(Proc) || allow_if.is_a?(Hash)
88
+ raise ArgumentError, "allow_if must be a Proc or Hash, got: #{allow_if.class}"
89
+ end
90
+
91
+ # If it's a Hash, validate the structure
92
+ if allow_if.is_a?(Hash)
93
+ validate_hash_allow_if(allow_if)
94
+ end
95
+ end
96
+
97
+ def validate_hash_allow_if(allow_if_hash)
98
+ # Must have exactly one key
99
+ if allow_if_hash.size != 1
100
+ raise ArgumentError, "allow_if hash must contain exactly one attribute, got: #{allow_if_hash.keys.inspect}"
101
+ end
102
+
103
+ attribute_name, valid_states = allow_if_hash.first
104
+
105
+ # Convert attribute name to symbol
106
+ attribute_name = attribute_name.to_sym if attribute_name.respond_to?(:to_sym)
107
+
108
+ # Get model_key from the class name string, not the Class object
109
+ model_key = Circulator.model_key(@klass.to_s)
110
+ unless @klass.flows&.dig(model_key, attribute_name)
111
+ available_flows = @klass.flows&.dig(model_key)&.keys || []
112
+ raise ArgumentError, "allow_if references undefined flow attribute :#{attribute_name}. Available flows: #{available_flows.inspect}"
113
+ end
114
+
115
+ # Get the states from the referenced flow
116
+ referenced_flow = @klass.flows.dig(model_key, attribute_name)
117
+ referenced_states = referenced_flow.instance_variable_get(:@states)
118
+
119
+ # Convert valid_states to array of symbols
120
+ valid_states_array = Array(valid_states).map { |s| s.respond_to?(:to_sym) ? s.to_sym : s }
121
+
122
+ # Check if all specified states exist in the referenced flow
123
+ invalid_states = valid_states_array - referenced_states.to_a
124
+ if invalid_states.any?
125
+ raise ArgumentError, "allow_if references invalid states #{invalid_states.inspect} for :#{attribute_name}. Valid states: #{referenced_states.to_a.inspect}"
126
+ end
127
+ end
70
128
  end
71
129
  end
@@ -6,9 +6,53 @@ module Circulator
6
6
  class PlantUml < Diagram
7
7
  private
8
8
 
9
- # def graph_name
10
- # @model_class.name || "diagram"
11
- # end
9
+ def flows_output(flows_data, output)
10
+ if flows_data.size == 1
11
+ # Single flow: no grouping needed
12
+ flow = flows_data.first
13
+ states_output(flow[:states], output)
14
+ transitions_output(flow[:transitions], output)
15
+ else
16
+ # Multiple flows: use composite states (state containers) with visible labels
17
+ flows_data.each do |flow|
18
+ output << ""
19
+ output << "state \":#{flow[:attribute_name]}\" as #{flow[:attribute_name]}_group {"
20
+
21
+ # Output states for this flow
22
+ flow[:states].reject(&:nil?).sort_by(&:to_s).each do |state|
23
+ # Replace characters that PlantUML doesn't like in identifiers
24
+ safe_state = state.to_s.gsub("?", "unknown")
25
+ prefixed_name = "#{flow[:attribute_name]}_#{safe_state}"
26
+ output << " state \"#{state}\" as #{prefixed_name}"
27
+ end
28
+
29
+ output << "}"
30
+ end
31
+
32
+ # Output all transitions after composite states
33
+ output << ""
34
+ flows_data.each do |flow|
35
+ flow[:transitions].sort_by { |t| [t[:from].to_s, t[:to].to_s, t[:label]] }.each do |transition|
36
+ from_label = transition[:from].nil? ? "[*]" : transition[:from].to_s
37
+ to_label = transition[:to].nil? ? "[*]" : transition[:to].to_s
38
+ # Use prefixed names for non-nil states
39
+ # Replace characters that PlantUML doesn't like in identifiers
40
+ safe_from = from_label.gsub("?", "unknown")
41
+ safe_to = to_label.gsub("?", "unknown")
42
+ prefixed_from = transition[:from].nil? ? "[*]" : "#{flow[:attribute_name]}_#{safe_from}"
43
+ prefixed_to = transition[:to].nil? ? "[*]" : "#{flow[:attribute_name]}_#{safe_to}"
44
+ output << "#{prefixed_from} --> #{prefixed_to} : #{transition[:label]}"
45
+
46
+ # Add note if present
47
+ if transition[:note]
48
+ output << "note on link"
49
+ output << " #{transition[:note]}"
50
+ output << "end note"
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
12
56
 
13
57
  def header
14
58
  <<~PLANTUML
@@ -17,6 +61,14 @@ module Circulator
17
61
  PLANTUML
18
62
  end
19
63
 
64
+ def header_for_attribute(attribute_name)
65
+ class_name = @model_class.name || "diagram"
66
+ <<~PLANTUML
67
+ @startuml #{class_name}_#{attribute_name}
68
+ title #{class_name} :#{attribute_name} flow
69
+ PLANTUML
70
+ end
71
+
20
72
  def footer
21
73
  <<~PLANTUML
22
74
 
@@ -48,7 +100,13 @@ module Circulator
48
100
 
49
101
  def states_output(states, output)
50
102
  states.reject(&:nil?).sort_by(&:to_s).each do |state|
51
- output << "state #{state}"
103
+ # Replace characters that PlantUML doesn't like in identifiers
104
+ safe_state = state.to_s.gsub("?", "unknown")
105
+ output << if safe_state != state.to_s
106
+ "state \"#{state}\" as #{safe_state}"
107
+ else
108
+ "state #{state}"
109
+ end
52
110
  end
53
111
  end
54
112
 
@@ -56,7 +114,10 @@ module Circulator
56
114
  transitions.sort_by { |t| [t[:from].to_s, t[:to].to_s, t[:label]] }.each do |transition|
57
115
  from_label = transition[:from].nil? ? "[*]" : transition[:from].to_s
58
116
  to_label = transition[:to].nil? ? "[*]" : transition[:to].to_s
59
- output << "#{from_label} --> #{to_label} : #{transition[:label]}"
117
+ # Replace characters that PlantUML doesn't like in identifiers
118
+ safe_from = (from_label == "[*]") ? from_label : from_label.gsub("?", "unknown")
119
+ safe_to = (to_label == "[*]") ? to_label : to_label.gsub("?", "unknown")
120
+ output << "#{safe_from} --> #{safe_to} : #{transition[:label]}"
60
121
 
61
122
  # Add note if present
62
123
  if transition[:note]
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Circulator
4
- VERSION = "2.1.0"
4
+ VERSION = "2.1.2"
5
5
  end
data/lib/circulator.rb CHANGED
@@ -129,7 +129,7 @@ module Circulator
129
129
  @flows ||= {}
130
130
  model_key = Circulator.model_key(model)
131
131
  @flows[model_key] ||= {}
132
- @flows[model_key][attribute_name] = Flow.new(model, attribute_name, &block)
132
+ @flows[model_key][attribute_name] = Flow.new(self, attribute_name, &block)
133
133
 
134
134
  flow_module = ancestors.find { |ancestor|
135
135
  ancestor.name.to_s =~ /FlowMethods/
@@ -170,7 +170,23 @@ module Circulator
170
170
  end
171
171
 
172
172
  if transition[:allow_if]
173
- return unless flow_target.instance_exec(*args, **kwargs, &transition[:allow_if])
173
+ # Handle hash-based allow_if (checking other attribute states)
174
+ if transition[:allow_if].is_a?(Hash)
175
+ attribute_name_to_check, valid_states = transition[:allow_if].first
176
+ current_state = flow_target.send(attribute_name_to_check)
177
+
178
+ # Convert current state to symbol if possible
179
+ current_state = current_state.to_sym if current_state.respond_to?(:to_sym)
180
+
181
+ # Convert valid_states to array of symbols
182
+ valid_states_array = Array(valid_states).map { |s| s.respond_to?(:to_sym) ? s.to_sym : s }
183
+
184
+ # Return early if current state is not in the valid states
185
+ return unless valid_states_array.include?(current_state)
186
+ else
187
+ # Handle proc-based allow_if (original behavior)
188
+ return unless flow_target.instance_exec(*args, **kwargs, &transition[:allow_if])
189
+ end
174
190
  end
175
191
 
176
192
  if transition[:block]
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: circulator
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.1.0
4
+ version: 2.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jim Gay