roast-ai 0.1.6 → 0.2.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.
Files changed (111) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yaml +1 -1
  3. data/CHANGELOG.md +48 -0
  4. data/CLAUDE.md +20 -0
  5. data/Gemfile +1 -0
  6. data/Gemfile.lock +11 -6
  7. data/README.md +225 -13
  8. data/bin/roast +27 -0
  9. data/docs/INSTRUMENTATION.md +42 -1
  10. data/docs/ITERATION_SYNTAX.md +119 -0
  11. data/examples/conditional/README.md +161 -0
  12. data/examples/conditional/check_condition/prompt.md +1 -0
  13. data/examples/conditional/simple_workflow.yml +15 -0
  14. data/examples/conditional/workflow.yml +23 -0
  15. data/examples/dot_notation/README.md +37 -0
  16. data/examples/dot_notation/workflow.yml +44 -0
  17. data/examples/exit_on_error/README.md +50 -0
  18. data/examples/exit_on_error/analyze_lint_output/prompt.md +9 -0
  19. data/examples/exit_on_error/apply_fixes/prompt.md +2 -0
  20. data/examples/exit_on_error/workflow.yml +19 -0
  21. data/examples/grading/workflow.yml +5 -1
  22. data/examples/iteration/IMPLEMENTATION.md +88 -0
  23. data/examples/iteration/README.md +68 -0
  24. data/examples/iteration/analyze_complexity/prompt.md +22 -0
  25. data/examples/iteration/generate_recommendations/prompt.md +21 -0
  26. data/examples/iteration/generate_report/prompt.md +129 -0
  27. data/examples/iteration/implement_fix/prompt.md +25 -0
  28. data/examples/iteration/prioritize_issues/prompt.md +24 -0
  29. data/examples/iteration/prompts/analyze_file.md +28 -0
  30. data/examples/iteration/prompts/generate_summary.md +24 -0
  31. data/examples/iteration/prompts/update_report.md +29 -0
  32. data/examples/iteration/prompts/write_report.md +22 -0
  33. data/examples/iteration/read_file/prompt.md +9 -0
  34. data/examples/iteration/select_next_issue/prompt.md +25 -0
  35. data/examples/iteration/simple_workflow.md +39 -0
  36. data/examples/iteration/simple_workflow.yml +58 -0
  37. data/examples/iteration/update_fix_count/prompt.md +26 -0
  38. data/examples/iteration/verify_fix/prompt.md +29 -0
  39. data/examples/iteration/workflow.yml +42 -0
  40. data/examples/openrouter_example/workflow.yml +2 -2
  41. data/examples/workflow_generator/README.md +27 -0
  42. data/examples/workflow_generator/analyze_user_request/prompt.md +34 -0
  43. data/examples/workflow_generator/create_workflow_files/prompt.md +32 -0
  44. data/examples/workflow_generator/get_user_input/prompt.md +14 -0
  45. data/examples/workflow_generator/info_from_roast.rb +22 -0
  46. data/examples/workflow_generator/workflow.yml +35 -0
  47. data/lib/roast/errors.rb +9 -0
  48. data/lib/roast/factories/api_provider_factory.rb +61 -0
  49. data/lib/roast/helpers/function_caching_interceptor.rb +1 -1
  50. data/lib/roast/helpers/minitest_coverage_runner.rb +1 -1
  51. data/lib/roast/helpers/prompt_loader.rb +50 -1
  52. data/lib/roast/resources/base_resource.rb +7 -0
  53. data/lib/roast/resources.rb +6 -6
  54. data/lib/roast/tools/ask_user.rb +40 -0
  55. data/lib/roast/tools/cmd.rb +1 -1
  56. data/lib/roast/tools/search_file.rb +1 -1
  57. data/lib/roast/tools/update_files.rb +413 -0
  58. data/lib/roast/tools.rb +12 -1
  59. data/lib/roast/value_objects/api_token.rb +49 -0
  60. data/lib/roast/value_objects/step_name.rb +39 -0
  61. data/lib/roast/value_objects/workflow_path.rb +77 -0
  62. data/lib/roast/value_objects.rb +5 -0
  63. data/lib/roast/version.rb +1 -1
  64. data/lib/roast/workflow/api_configuration.rb +61 -0
  65. data/lib/roast/workflow/base_iteration_step.rb +165 -0
  66. data/lib/roast/workflow/base_step.rb +4 -24
  67. data/lib/roast/workflow/base_workflow.rb +76 -73
  68. data/lib/roast/workflow/command_executor.rb +88 -0
  69. data/lib/roast/workflow/conditional_executor.rb +50 -0
  70. data/lib/roast/workflow/conditional_step.rb +96 -0
  71. data/lib/roast/workflow/configuration.rb +35 -158
  72. data/lib/roast/workflow/configuration_loader.rb +78 -0
  73. data/lib/roast/workflow/configuration_parser.rb +13 -248
  74. data/lib/roast/workflow/context_path_resolver.rb +43 -0
  75. data/lib/roast/workflow/dot_access_hash.rb +198 -0
  76. data/lib/roast/workflow/each_step.rb +86 -0
  77. data/lib/roast/workflow/error_handler.rb +97 -0
  78. data/lib/roast/workflow/expression_utils.rb +36 -0
  79. data/lib/roast/workflow/file_state_repository.rb +3 -2
  80. data/lib/roast/workflow/interpolator.rb +34 -0
  81. data/lib/roast/workflow/iteration_executor.rb +85 -0
  82. data/lib/roast/workflow/llm_boolean_coercer.rb +55 -0
  83. data/lib/roast/workflow/output_handler.rb +35 -0
  84. data/lib/roast/workflow/output_manager.rb +77 -0
  85. data/lib/roast/workflow/parallel_executor.rb +49 -0
  86. data/lib/roast/workflow/repeat_step.rb +75 -0
  87. data/lib/roast/workflow/replay_handler.rb +123 -0
  88. data/lib/roast/workflow/resource_resolver.rb +77 -0
  89. data/lib/roast/workflow/session_manager.rb +6 -2
  90. data/lib/roast/workflow/state_manager.rb +97 -0
  91. data/lib/roast/workflow/step_executor_coordinator.rb +205 -0
  92. data/lib/roast/workflow/step_executor_factory.rb +47 -0
  93. data/lib/roast/workflow/step_executor_registry.rb +79 -0
  94. data/lib/roast/workflow/step_executors/base_step_executor.rb +23 -0
  95. data/lib/roast/workflow/step_executors/hash_step_executor.rb +43 -0
  96. data/lib/roast/workflow/step_executors/parallel_step_executor.rb +54 -0
  97. data/lib/roast/workflow/step_executors/string_step_executor.rb +29 -0
  98. data/lib/roast/workflow/step_finder.rb +97 -0
  99. data/lib/roast/workflow/step_loader.rb +154 -0
  100. data/lib/roast/workflow/step_orchestrator.rb +45 -0
  101. data/lib/roast/workflow/step_runner.rb +23 -0
  102. data/lib/roast/workflow/step_type_resolver.rb +117 -0
  103. data/lib/roast/workflow/workflow_context.rb +60 -0
  104. data/lib/roast/workflow/workflow_executor.rb +90 -209
  105. data/lib/roast/workflow/workflow_initializer.rb +112 -0
  106. data/lib/roast/workflow/workflow_runner.rb +87 -0
  107. data/lib/roast/workflow.rb +3 -0
  108. data/lib/roast.rb +96 -3
  109. data/roast.gemspec +3 -1
  110. data/schema/workflow.json +85 -0
  111. metadata +112 -4
@@ -0,0 +1,205 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "roast/workflow/conditional_executor"
4
+ require "roast/workflow/step_executor_factory"
5
+ require "roast/workflow/step_type_resolver"
6
+
7
+ module Roast
8
+ module Workflow
9
+ # Coordinates the execution of different types of steps
10
+ #
11
+ # This class is responsible for routing steps to their appropriate executors
12
+ # based on the step type. It acts as a central dispatcher that determines
13
+ # which execution strategy to use for each step.
14
+ #
15
+ # Current Architecture:
16
+ # - WorkflowExecutor.execute_steps still handles basic routing for backward compatibility
17
+ # - This coordinator is used by WorkflowExecutor.execute_step for named steps
18
+ # - Some step types (parallel) use the StepExecutorFactory pattern
19
+ # - Other step types use direct execution methods
20
+ #
21
+ # TODO: Future refactoring should move all execution logic from WorkflowExecutor
22
+ # to this coordinator and use the factory pattern consistently for all step types.
23
+ class StepExecutorCoordinator
24
+ def initialize(context:, dependencies:)
25
+ @context = context
26
+ @dependencies = dependencies
27
+ end
28
+
29
+ # Execute a list of steps
30
+ def execute_steps(workflow_steps)
31
+ workflow_steps.each do |step|
32
+ case step
33
+ when Hash
34
+ execute(step)
35
+ when Array
36
+ execute(step)
37
+ when String
38
+ execute(step)
39
+ # Handle pause after string steps
40
+ if @context.workflow.pause_step_name == step
41
+ Kernel.binding.irb # rubocop:disable Lint/Debugger
42
+ end
43
+ else
44
+ step_orchestrator.execute_step(step)
45
+ end
46
+ end
47
+ end
48
+
49
+ # Execute a single step (alias for compatibility)
50
+ def execute_step(step, options = {})
51
+ execute(step, options)
52
+ end
53
+
54
+ # Execute a step based on its type
55
+ # @param step [String, Hash, Array] The step to execute
56
+ # @param options [Hash] Execution options
57
+ # @return [Object] The result of the step execution
58
+ def execute(step, options = {})
59
+ step_type = StepTypeResolver.resolve(step, @context)
60
+
61
+ case step_type
62
+ when StepTypeResolver::COMMAND_STEP
63
+ # Command steps should also go through interpolation
64
+ execute_string_step(step, options)
65
+ when StepTypeResolver::GLOB_STEP
66
+ execute_glob_step(step)
67
+ when StepTypeResolver::ITERATION_STEP
68
+ execute_iteration_step(step)
69
+ when StepTypeResolver::CONDITIONAL_STEP
70
+ execute_conditional_step(step)
71
+ when StepTypeResolver::HASH_STEP
72
+ execute_hash_step(step)
73
+ when StepTypeResolver::PARALLEL_STEP
74
+ # Use factory for parallel steps
75
+ executor = StepExecutorFactory.for(step, workflow_executor)
76
+ executor.execute(step)
77
+ when StepTypeResolver::STRING_STEP
78
+ execute_string_step(step, options)
79
+ else
80
+ execute_standard_step(step, options)
81
+ end
82
+ end
83
+
84
+ private
85
+
86
+ attr_reader :context, :dependencies
87
+
88
+ def workflow_executor
89
+ dependencies[:workflow_executor]
90
+ end
91
+
92
+ def interpolator
93
+ dependencies[:interpolator]
94
+ end
95
+
96
+ def command_executor
97
+ dependencies[:command_executor]
98
+ end
99
+
100
+ def iteration_executor
101
+ dependencies[:iteration_executor]
102
+ end
103
+
104
+ def conditional_executor
105
+ dependencies[:conditional_executor]
106
+ end
107
+
108
+ def step_orchestrator
109
+ dependencies[:step_orchestrator]
110
+ end
111
+
112
+ def error_handler
113
+ dependencies[:error_handler]
114
+ end
115
+
116
+ def execute_command_step(step, options)
117
+ exit_on_error = options.fetch(:exit_on_error, true)
118
+ resource_type = @context.resource_type
119
+
120
+ error_handler.with_error_handling(step, resource_type: resource_type) do
121
+ $stderr.puts "Executing: #{step} (Resource type: #{resource_type || "unknown"})"
122
+
123
+ output = command_executor.execute(step, exit_on_error: exit_on_error)
124
+
125
+ # Add to transcript
126
+ workflow = context.workflow
127
+ workflow.transcript << {
128
+ user: "I just executed the following command: ```\n#{step}\n```\n\nHere is the output:\n\n```\n#{output}\n```",
129
+ }
130
+ workflow.transcript << { assistant: "Noted, thank you." }
131
+
132
+ output
133
+ end
134
+ end
135
+
136
+ def execute_glob_step(step)
137
+ Dir.glob(step).join("\n")
138
+ end
139
+
140
+ def execute_iteration_step(step)
141
+ name = step.keys.first
142
+ command = step[name]
143
+
144
+ case name
145
+ when "repeat"
146
+ iteration_executor.execute_repeat(command)
147
+ when "each"
148
+ validate_each_step!(step)
149
+ iteration_executor.execute_each(step)
150
+ end
151
+ end
152
+
153
+ def execute_conditional_step(step)
154
+ conditional_executor.execute_conditional(step)
155
+ end
156
+
157
+ def execute_hash_step(step)
158
+ name, command = step.to_a.flatten
159
+ interpolated_name = interpolator.interpolate(name)
160
+
161
+ if command.is_a?(Hash)
162
+ execute_steps([command])
163
+ else
164
+ interpolated_command = interpolator.interpolate(command)
165
+ exit_on_error = context.exit_on_error?(interpolated_name)
166
+
167
+ # Execute the command directly using the appropriate executor
168
+ result = execute(interpolated_command, { exit_on_error: exit_on_error })
169
+ context.workflow.output[interpolated_name] = result
170
+ result
171
+ end
172
+ end
173
+
174
+ def execute_string_step(step, options = {})
175
+ # Check for glob before interpolation
176
+ if StepTypeResolver.glob_step?(step, context)
177
+ return execute_glob_step(step)
178
+ end
179
+
180
+ interpolated_step = interpolator.interpolate(step)
181
+
182
+ if StepTypeResolver.command_step?(interpolated_step)
183
+ # Command step - execute directly, preserving any passed options
184
+ exit_on_error = options.fetch(:exit_on_error, true)
185
+ execute_command_step(interpolated_step, { exit_on_error: exit_on_error })
186
+ else
187
+ exit_on_error = options.fetch(:exit_on_error, context.exit_on_error?(step))
188
+ execute_standard_step(interpolated_step, { exit_on_error: exit_on_error })
189
+ end
190
+ end
191
+
192
+ def execute_standard_step(step, options)
193
+ exit_on_error = options.fetch(:exit_on_error, true)
194
+ step_orchestrator.execute_step(step, exit_on_error: exit_on_error)
195
+ end
196
+
197
+ def validate_each_step!(step)
198
+ unless step.key?("as") && step.key?("steps")
199
+ raise WorkflowExecutor::ConfigurationError,
200
+ "Invalid 'each' step format. 'as' and 'steps' must be at the same level as 'each'"
201
+ end
202
+ end
203
+ end
204
+ end
205
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "roast/workflow/step_executor_registry"
4
+ require "roast/workflow/step_executors/hash_step_executor"
5
+ require "roast/workflow/step_executors/parallel_step_executor"
6
+ require "roast/workflow/step_executors/string_step_executor"
7
+
8
+ module Roast
9
+ module Workflow
10
+ # Factory for creating step executors - now delegates to registry
11
+ class StepExecutorFactory
12
+ class << self
13
+ # Method to ensure default executors are registered
14
+ def ensure_defaults_registered
15
+ return if @defaults_registered
16
+
17
+ StepExecutorRegistry.register(Hash, StepExecutors::HashStepExecutor)
18
+ StepExecutorRegistry.register(Array, StepExecutors::ParallelStepExecutor)
19
+ StepExecutorRegistry.register(String, StepExecutors::StringStepExecutor)
20
+
21
+ @defaults_registered = true
22
+ end
23
+ end
24
+
25
+ # Initialize on first use
26
+ ensure_defaults_registered
27
+
28
+ class << self
29
+ # Delegate to the registry for backward compatibility
30
+ def for(step, workflow_executor)
31
+ ensure_defaults_registered
32
+ StepExecutorRegistry.for(step, workflow_executor)
33
+ end
34
+
35
+ # Allow registration of new executors
36
+ def register(klass, executor_class)
37
+ StepExecutorRegistry.register(klass, executor_class)
38
+ end
39
+
40
+ # Allow registration with custom matchers
41
+ def register_with_matcher(matcher, executor_class)
42
+ StepExecutorRegistry.register_with_matcher(matcher, executor_class)
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Roast
4
+ module Workflow
5
+ # Registry pattern for step executors - eliminates case statements
6
+ # and follows Open/Closed Principle
7
+ class StepExecutorRegistry
8
+ class UnknownStepTypeError < StandardError; end
9
+
10
+ @executors = {}
11
+ @type_matchers = []
12
+
13
+ class << self
14
+ # Register an executor for a specific class
15
+ # @param klass [Class] The class to match
16
+ # @param executor_class [Class] The executor class to use
17
+ def register(klass, executor_class)
18
+ @executors[klass] = executor_class
19
+ end
20
+
21
+ # Register an executor with a custom matcher
22
+ # @param matcher [Proc] A proc that returns true if the step matches
23
+ # @param executor_class [Class] The executor class to use
24
+ def register_with_matcher(matcher, executor_class)
25
+ @type_matchers << { matcher: matcher, executor_class: executor_class }
26
+ end
27
+
28
+ # Find the appropriate executor for a step
29
+ # @param step [Object] The step to find an executor for
30
+ # @param workflow_executor [WorkflowExecutor] The workflow executor instance
31
+ # @return [Object] An instance of the appropriate executor
32
+ def for(step, workflow_executor)
33
+ executor_class = find_executor_class(step)
34
+
35
+ unless executor_class
36
+ raise UnknownStepTypeError, "No executor registered for step type: #{step.class} (#{step.inspect})"
37
+ end
38
+
39
+ executor_class.new(workflow_executor)
40
+ end
41
+
42
+ # Clear all registrations (useful for testing)
43
+ def clear!
44
+ @executors.clear
45
+ @type_matchers.clear
46
+ # Reset the factory's defaults flag if it's defined
47
+ if defined?(StepExecutorFactory)
48
+ StepExecutorFactory.instance_variable_set(:@defaults_registered, false)
49
+ end
50
+ end
51
+
52
+ # Get all registered executors (useful for debugging)
53
+ def registered_executors
54
+ @executors.dup
55
+ end
56
+
57
+ private
58
+
59
+ def find_executor_class(step)
60
+ # First check exact class matches
61
+ executor_class = @executors[step.class]
62
+ return executor_class if executor_class
63
+
64
+ # Then check custom matchers
65
+ matcher_entry = @type_matchers.find { |entry| entry[:matcher].call(step) }
66
+ return matcher_entry[:executor_class] if matcher_entry
67
+
68
+ # Finally check inheritance chain
69
+ step.class.ancestors.each do |ancestor|
70
+ executor_class = @executors[ancestor]
71
+ return executor_class if executor_class
72
+ end
73
+
74
+ nil
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Roast
4
+ module Workflow
5
+ module StepExecutors
6
+ class BaseStepExecutor
7
+ def initialize(workflow_executor)
8
+ @workflow_executor = workflow_executor
9
+ @workflow = workflow_executor.workflow
10
+ @config_hash = workflow_executor.config_hash
11
+ end
12
+
13
+ def execute(step)
14
+ raise NotImplementedError, "Subclasses must implement execute"
15
+ end
16
+
17
+ protected
18
+
19
+ attr_reader :workflow_executor, :workflow, :config_hash
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "roast/workflow/step_executors/base_step_executor"
4
+ require "roast/workflow/step_runner"
5
+
6
+ module Roast
7
+ module Workflow
8
+ module StepExecutors
9
+ class HashStepExecutor < BaseStepExecutor
10
+ def execute(step)
11
+ # execute a command and store the output in a variable
12
+ name, command = step.to_a.flatten
13
+
14
+ # Interpolate variable name if it contains {{}}
15
+ interpolated_name = workflow_executor.interpolate(name)
16
+
17
+ if command.is_a?(Hash)
18
+ step_runner.execute_steps([command])
19
+ else
20
+ # Interpolate command value
21
+ interpolated_command = workflow_executor.interpolate(command)
22
+
23
+ # Check if this step has exit_on_error configuration
24
+ step_config = config_hash[interpolated_name]
25
+ exit_on_error = step_config.is_a?(Hash) ? step_config.fetch("exit_on_error", true) : true
26
+
27
+ workflow.output[interpolated_name] = step_runner.execute_step(interpolated_command, exit_on_error: exit_on_error)
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ def step_runner
34
+ @step_runner ||= StepRunner.new(coordinator)
35
+ end
36
+
37
+ def coordinator
38
+ workflow_executor.step_executor_coordinator
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "roast/workflow/step_executors/base_step_executor"
4
+ require "roast/workflow/step_runner"
5
+
6
+ module Roast
7
+ module Workflow
8
+ module StepExecutors
9
+ class ParallelStepExecutor < BaseStepExecutor
10
+ def execute(steps)
11
+ # run steps in parallel, don't proceed until all are done
12
+ threads = steps.map do |sub_step|
13
+ Thread.new do
14
+ # Each thread needs its own isolated execution context
15
+ Thread.current[:step] = sub_step
16
+ Thread.current[:result] = nil
17
+ Thread.current[:error] = nil
18
+
19
+ begin
20
+ # Execute the single step in this thread
21
+ step_runner.execute_steps([sub_step])
22
+ Thread.current[:result] = :success
23
+ rescue => e
24
+ Thread.current[:error] = e
25
+ end
26
+ end
27
+ end
28
+
29
+ # Wait for all threads to complete
30
+ threads.each(&:join)
31
+
32
+ # Check for errors in any thread
33
+ threads.each_with_index do |thread, _index|
34
+ if thread[:error]
35
+ raise thread[:error]
36
+ end
37
+ end
38
+
39
+ :success
40
+ end
41
+
42
+ private
43
+
44
+ def step_runner
45
+ @step_runner ||= StepRunner.new(coordinator)
46
+ end
47
+
48
+ def coordinator
49
+ workflow_executor.step_executor_coordinator
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "roast/workflow/step_executors/base_step_executor"
4
+
5
+ module Roast
6
+ module Workflow
7
+ module StepExecutors
8
+ class StringStepExecutor < BaseStepExecutor
9
+ def execute(step)
10
+ # Interpolate any {{}} expressions before executing the step
11
+ interpolated_step = workflow_executor.interpolate(step)
12
+
13
+ # For command steps, check if there's an exit_on_error configuration
14
+ # We need to extract the step name to look up configuration
15
+ if interpolated_step.starts_with?("$(")
16
+ # This is a direct command without a name, so exit_on_error defaults to true
17
+ workflow_executor.execute_step(interpolated_step)
18
+ else
19
+ # Check if this step has exit_on_error configuration
20
+ step_config = config_hash[step]
21
+ exit_on_error = step_config.is_a?(Hash) ? step_config.fetch("exit_on_error", true) : true
22
+
23
+ workflow_executor.execute_step(interpolated_step, exit_on_error: exit_on_error)
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Roast
4
+ module Workflow
5
+ # Finds step indices within workflow step arrays
6
+ # Handles various step formats: strings, hashes, parallel arrays
7
+ class StepFinder
8
+ attr_reader :steps
9
+
10
+ def initialize(steps = nil)
11
+ @steps = steps || []
12
+ end
13
+
14
+ class << self
15
+ def find_index(steps_array, step_name)
16
+ new(steps_array).find_index(step_name)
17
+ end
18
+ end
19
+
20
+ # Find the index of a step in the workflow steps array
21
+ # @param step_name [String] The name of the step to find
22
+ # @param steps_array [Array, nil] Optional steps array to search in
23
+ # @return [Integer, nil] The index of the step, or nil if not found
24
+ def find_index(step_name, steps_array = nil)
25
+ search_array = steps_array || @steps
26
+ # First, try direct search
27
+ index = find_by_direct_search(search_array, step_name)
28
+ return index if index
29
+
30
+ # Fall back to extracted name search
31
+ find_by_extracted_name(search_array, step_name)
32
+ end
33
+
34
+ # Extract the name from a step definition
35
+ # @param step [String, Hash, Array] The step definition
36
+ # @return [String, Array] The step name(s)
37
+ def extract_name(step)
38
+ case step
39
+ when String
40
+ step
41
+ when Hash
42
+ step.keys.first
43
+ when Array
44
+ # For arrays, extract names from all contained steps
45
+ step.map { |s| extract_name(s) }
46
+ end
47
+ end
48
+
49
+ private
50
+
51
+ def find_by_direct_search(steps_array, step_name)
52
+ steps_array.each_with_index do |step, index|
53
+ case step
54
+ when Hash
55
+ # Could be {name: command} or {name: {substeps}}
56
+ step_key = step.keys.first
57
+ return index if step_key == step_name
58
+ when Array
59
+ # This is a parallel step container, search inside it
60
+ if contains_step?(step, step_name)
61
+ return index
62
+ end
63
+ when String
64
+ return index if step == step_name
65
+ end
66
+ end
67
+ nil
68
+ end
69
+
70
+ def find_by_extracted_name(steps_array, step_name)
71
+ steps_array.each_with_index do |step, index|
72
+ name = extract_name(step)
73
+ if name.is_a?(Array)
74
+ # For arrays (parallel steps), check if target is in the array
75
+ return index if name.flatten.include?(step_name)
76
+ elsif name == step_name
77
+ return index
78
+ end
79
+ end
80
+ nil
81
+ end
82
+
83
+ def contains_step?(parallel_steps, step_name)
84
+ parallel_steps.any? do |substep|
85
+ case substep
86
+ when Hash
87
+ substep.keys.first == step_name
88
+ when String
89
+ substep == step_name
90
+ else
91
+ false
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end