sxn 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 (156) hide show
  1. checksums.yaml +7 -0
  2. data/.gem_rbs_collection/addressable/2.8/.rbs_meta.yaml +9 -0
  3. data/.gem_rbs_collection/addressable/2.8/addressable.rbs +62 -0
  4. data/.gem_rbs_collection/async/2.12/.rbs_meta.yaml +9 -0
  5. data/.gem_rbs_collection/async/2.12/async.rbs +119 -0
  6. data/.gem_rbs_collection/async/2.12/kernel.rbs +5 -0
  7. data/.gem_rbs_collection/async/2.12/manifest.yaml +7 -0
  8. data/.gem_rbs_collection/bcrypt/3.1/.rbs_meta.yaml +9 -0
  9. data/.gem_rbs_collection/bcrypt/3.1/bcrypt.rbs +47 -0
  10. data/.gem_rbs_collection/bcrypt/3.1/manifest.yaml +2 -0
  11. data/.gem_rbs_collection/bigdecimal/3.1/.rbs_meta.yaml +9 -0
  12. data/.gem_rbs_collection/bigdecimal/3.1/bigdecimal-math.rbs +119 -0
  13. data/.gem_rbs_collection/bigdecimal/3.1/bigdecimal.rbs +1630 -0
  14. data/.gem_rbs_collection/concurrent-ruby/1.1/.rbs_meta.yaml +9 -0
  15. data/.gem_rbs_collection/concurrent-ruby/1.1/array.rbs +4 -0
  16. data/.gem_rbs_collection/concurrent-ruby/1.1/executor.rbs +26 -0
  17. data/.gem_rbs_collection/concurrent-ruby/1.1/hash.rbs +4 -0
  18. data/.gem_rbs_collection/concurrent-ruby/1.1/map.rbs +65 -0
  19. data/.gem_rbs_collection/concurrent-ruby/1.1/promises.rbs +249 -0
  20. data/.gem_rbs_collection/concurrent-ruby/1.1/utility/processor_counter.rbs +5 -0
  21. data/.gem_rbs_collection/diff-lcs/1.5/.rbs_meta.yaml +9 -0
  22. data/.gem_rbs_collection/diff-lcs/1.5/diff-lcs.rbs +11 -0
  23. data/.gem_rbs_collection/listen/3.9/.rbs_meta.yaml +9 -0
  24. data/.gem_rbs_collection/listen/3.9/listen.rbs +25 -0
  25. data/.gem_rbs_collection/listen/3.9/listener.rbs +24 -0
  26. data/.gem_rbs_collection/mini_mime/0.1/.rbs_meta.yaml +9 -0
  27. data/.gem_rbs_collection/mini_mime/0.1/mini_mime.rbs +14 -0
  28. data/.gem_rbs_collection/parallel/1.20/.rbs_meta.yaml +9 -0
  29. data/.gem_rbs_collection/parallel/1.20/parallel.rbs +86 -0
  30. data/.gem_rbs_collection/rake/13.0/.rbs_meta.yaml +9 -0
  31. data/.gem_rbs_collection/rake/13.0/manifest.yaml +2 -0
  32. data/.gem_rbs_collection/rake/13.0/rake.rbs +39 -0
  33. data/.gem_rbs_collection/rubocop-ast/1.46/.rbs_meta.yaml +9 -0
  34. data/.gem_rbs_collection/rubocop-ast/1.46/rubocop-ast.rbs +822 -0
  35. data/.gem_rbs_collection/sqlite3/2.0/.rbs_meta.yaml +9 -0
  36. data/.gem_rbs_collection/sqlite3/2.0/database.rbs +20 -0
  37. data/.gem_rbs_collection/sqlite3/2.0/pragmas.rbs +5 -0
  38. data/.rspec +4 -0
  39. data/.rubocop.yml +121 -0
  40. data/.simplecov +51 -0
  41. data/CHANGELOG.md +49 -0
  42. data/Gemfile +24 -0
  43. data/Gemfile.lock +329 -0
  44. data/LICENSE.txt +21 -0
  45. data/README.md +225 -0
  46. data/Rakefile +54 -0
  47. data/Steepfile +50 -0
  48. data/bin/sxn +6 -0
  49. data/lib/sxn/CLI.rb +275 -0
  50. data/lib/sxn/commands/init.rb +137 -0
  51. data/lib/sxn/commands/projects.rb +350 -0
  52. data/lib/sxn/commands/rules.rb +435 -0
  53. data/lib/sxn/commands/sessions.rb +300 -0
  54. data/lib/sxn/commands/worktrees.rb +416 -0
  55. data/lib/sxn/commands.rb +13 -0
  56. data/lib/sxn/config/config_cache.rb +295 -0
  57. data/lib/sxn/config/config_discovery.rb +242 -0
  58. data/lib/sxn/config/config_validator.rb +562 -0
  59. data/lib/sxn/config.rb +259 -0
  60. data/lib/sxn/core/config_manager.rb +290 -0
  61. data/lib/sxn/core/project_manager.rb +307 -0
  62. data/lib/sxn/core/rules_manager.rb +306 -0
  63. data/lib/sxn/core/session_manager.rb +336 -0
  64. data/lib/sxn/core/worktree_manager.rb +281 -0
  65. data/lib/sxn/core.rb +13 -0
  66. data/lib/sxn/database/errors.rb +29 -0
  67. data/lib/sxn/database/session_database.rb +691 -0
  68. data/lib/sxn/database.rb +24 -0
  69. data/lib/sxn/errors.rb +76 -0
  70. data/lib/sxn/rules/base_rule.rb +367 -0
  71. data/lib/sxn/rules/copy_files_rule.rb +346 -0
  72. data/lib/sxn/rules/errors.rb +28 -0
  73. data/lib/sxn/rules/project_detector.rb +871 -0
  74. data/lib/sxn/rules/rules_engine.rb +485 -0
  75. data/lib/sxn/rules/setup_commands_rule.rb +307 -0
  76. data/lib/sxn/rules/template_rule.rb +262 -0
  77. data/lib/sxn/rules.rb +148 -0
  78. data/lib/sxn/runtime_validations.rb +96 -0
  79. data/lib/sxn/security/secure_command_executor.rb +364 -0
  80. data/lib/sxn/security/secure_file_copier.rb +478 -0
  81. data/lib/sxn/security/secure_path_validator.rb +258 -0
  82. data/lib/sxn/security.rb +15 -0
  83. data/lib/sxn/templates/common/gitignore.liquid +99 -0
  84. data/lib/sxn/templates/common/session-info.md.liquid +58 -0
  85. data/lib/sxn/templates/errors.rb +36 -0
  86. data/lib/sxn/templates/javascript/README.md.liquid +59 -0
  87. data/lib/sxn/templates/javascript/session-info.md.liquid +206 -0
  88. data/lib/sxn/templates/rails/CLAUDE.md.liquid +78 -0
  89. data/lib/sxn/templates/rails/database.yml.liquid +31 -0
  90. data/lib/sxn/templates/rails/session-info.md.liquid +144 -0
  91. data/lib/sxn/templates/template_engine.rb +346 -0
  92. data/lib/sxn/templates/template_processor.rb +279 -0
  93. data/lib/sxn/templates/template_security.rb +410 -0
  94. data/lib/sxn/templates/template_variables.rb +713 -0
  95. data/lib/sxn/templates.rb +28 -0
  96. data/lib/sxn/ui/output.rb +103 -0
  97. data/lib/sxn/ui/progress_bar.rb +91 -0
  98. data/lib/sxn/ui/prompt.rb +116 -0
  99. data/lib/sxn/ui/table.rb +183 -0
  100. data/lib/sxn/ui.rb +12 -0
  101. data/lib/sxn/version.rb +5 -0
  102. data/lib/sxn.rb +63 -0
  103. data/rbs_collection.lock.yaml +180 -0
  104. data/rbs_collection.yaml +39 -0
  105. data/scripts/test.sh +31 -0
  106. data/sig/external/liquid.rbs +116 -0
  107. data/sig/external/thor.rbs +99 -0
  108. data/sig/external/tty.rbs +71 -0
  109. data/sig/sxn/cli.rbs +46 -0
  110. data/sig/sxn/commands/init.rbs +38 -0
  111. data/sig/sxn/commands/projects.rbs +72 -0
  112. data/sig/sxn/commands/rules.rbs +95 -0
  113. data/sig/sxn/commands/sessions.rbs +62 -0
  114. data/sig/sxn/commands/worktrees.rbs +82 -0
  115. data/sig/sxn/commands.rbs +6 -0
  116. data/sig/sxn/config/config_cache.rbs +67 -0
  117. data/sig/sxn/config/config_discovery.rbs +64 -0
  118. data/sig/sxn/config/config_validator.rbs +64 -0
  119. data/sig/sxn/config.rbs +74 -0
  120. data/sig/sxn/core/config_manager.rbs +67 -0
  121. data/sig/sxn/core/project_manager.rbs +52 -0
  122. data/sig/sxn/core/rules_manager.rbs +54 -0
  123. data/sig/sxn/core/session_manager.rbs +59 -0
  124. data/sig/sxn/core/worktree_manager.rbs +50 -0
  125. data/sig/sxn/core.rbs +87 -0
  126. data/sig/sxn/database/errors.rbs +37 -0
  127. data/sig/sxn/database/session_database.rbs +151 -0
  128. data/sig/sxn/database.rbs +83 -0
  129. data/sig/sxn/errors.rbs +89 -0
  130. data/sig/sxn/rules/base_rule.rbs +137 -0
  131. data/sig/sxn/rules/copy_files_rule.rbs +65 -0
  132. data/sig/sxn/rules/errors.rbs +33 -0
  133. data/sig/sxn/rules/project_detector.rbs +115 -0
  134. data/sig/sxn/rules/rules_engine.rbs +118 -0
  135. data/sig/sxn/rules/setup_commands_rule.rbs +60 -0
  136. data/sig/sxn/rules/template_rule.rbs +44 -0
  137. data/sig/sxn/rules.rbs +287 -0
  138. data/sig/sxn/runtime_validations.rbs +16 -0
  139. data/sig/sxn/security/secure_command_executor.rbs +63 -0
  140. data/sig/sxn/security/secure_file_copier.rbs +79 -0
  141. data/sig/sxn/security/secure_path_validator.rbs +30 -0
  142. data/sig/sxn/security.rbs +128 -0
  143. data/sig/sxn/templates/errors.rbs +43 -0
  144. data/sig/sxn/templates/template_engine.rbs +50 -0
  145. data/sig/sxn/templates/template_processor.rbs +44 -0
  146. data/sig/sxn/templates/template_security.rbs +62 -0
  147. data/sig/sxn/templates/template_variables.rbs +103 -0
  148. data/sig/sxn/templates.rbs +104 -0
  149. data/sig/sxn/ui/output.rbs +50 -0
  150. data/sig/sxn/ui/progress_bar.rbs +39 -0
  151. data/sig/sxn/ui/prompt.rbs +38 -0
  152. data/sig/sxn/ui/table.rbs +43 -0
  153. data/sig/sxn/ui.rbs +63 -0
  154. data/sig/sxn/version.rbs +5 -0
  155. data/sig/sxn.rbs +29 -0
  156. metadata +635 -0
@@ -0,0 +1,485 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_rule"
4
+ require_relative "copy_files_rule"
5
+ require_relative "setup_commands_rule"
6
+ require_relative "template_rule"
7
+
8
+ module Sxn
9
+ module Rules
10
+ # RulesEngine manages the loading, validation, dependency resolution, and execution
11
+ # of project setup rules. It provides transactional execution with rollback
12
+ # capabilities and supports parallel execution of independent rules.
13
+ #
14
+ # @example Basic usage
15
+ # engine = RulesEngine.new("/path/to/project", "/path/to/session")
16
+ #
17
+ # rules_config = {
18
+ # "copy_secrets" => {
19
+ # "type" => "copy_files",
20
+ # "config" => { "files" => [{"source" => "config/master.key"}] }
21
+ # },
22
+ # "install_deps" => {
23
+ # "type" => "setup_commands",
24
+ # "config" => { "commands" => [{"command" => ["bundle", "install"]}] },
25
+ # "dependencies" => ["copy_secrets"]
26
+ # }
27
+ # }
28
+ #
29
+ # result = engine.apply_rules(rules_config)
30
+ # puts "Applied #{result.applied_rules.size} rules successfully"
31
+ #
32
+ class RulesEngine
33
+ # Execution result for rule application
34
+ class ExecutionResult
35
+ attr_reader :applied_rules, :failed_rules, :total_duration, :errors
36
+
37
+ def skipped_rules
38
+ @skipped_rules.map { |s| s[:rule] }
39
+ end
40
+
41
+ def initialize
42
+ @applied_rules = []
43
+ @failed_rules = []
44
+ @skipped_rules = []
45
+ @total_duration = 0
46
+ @errors = []
47
+ @start_time = nil
48
+ @end_time = nil
49
+ end
50
+
51
+ def start!
52
+ @start_time = Time.now
53
+ end
54
+
55
+ def finish!
56
+ @end_time = Time.now
57
+ @total_duration = @end_time - @start_time if @start_time
58
+ end
59
+
60
+ def add_applied_rule(rule)
61
+ @applied_rules << rule
62
+ end
63
+
64
+ def add_failed_rule(rule, error)
65
+ @failed_rules << rule
66
+ @errors << { rule: rule.name, error: error }
67
+ end
68
+
69
+ def add_skipped_rule(rule, reason)
70
+ @skipped_rules << { rule: rule, reason: reason }
71
+ end
72
+
73
+ def add_engine_error(error)
74
+ @errors << { rule: "engine", error: error }
75
+ end
76
+
77
+ def success?
78
+ @failed_rules.empty? && @errors.empty?
79
+ end
80
+
81
+ def total_rules
82
+ @applied_rules.size + @failed_rules.size + @skipped_rules.size
83
+ end
84
+
85
+ def to_h
86
+ {
87
+ success: success?,
88
+ total_rules: total_rules,
89
+ applied_rules: @applied_rules.map(&:name),
90
+ failed_rules: @failed_rules.map(&:name),
91
+ skipped_rules: @skipped_rules.map { |sr| sr[:rule].name },
92
+ total_duration: @total_duration,
93
+ errors: @errors.map { |e| { rule: e[:rule], message: e[:error].message } }
94
+ }
95
+ end
96
+ end
97
+
98
+ # Rule type registry mapping type names to classes
99
+ RULE_TYPES = {
100
+ "copy_files" => CopyFilesRule,
101
+ "setup_commands" => SetupCommandsRule,
102
+ "template" => TemplateRule
103
+ }.freeze
104
+
105
+ attr_reader :project_path, :session_path, :logger
106
+
107
+ # Initialize the rules engine
108
+ #
109
+ # @param project_path [String] Absolute path to the project root
110
+ # @param session_path [String] Absolute path to the session directory
111
+ # @param logger [Logger] Optional logger instance
112
+ def initialize(project_path, session_path, logger: nil)
113
+ @project_path = File.realpath(project_path)
114
+ @session_path = File.realpath(session_path)
115
+ @logger = logger || Sxn.logger
116
+ @applied_rules = []
117
+
118
+ validate_paths!
119
+ rescue Errno::ENOENT => e
120
+ raise ArgumentError, "Invalid path provided: #{e.message}"
121
+ end
122
+
123
+ # Apply a set of rules with dependency resolution and parallel execution
124
+ #
125
+ # @param rules_config [Hash] Rules configuration hash
126
+ # @param options [Hash] Execution options
127
+ # @option options [Boolean] :parallel (true) Enable parallel execution
128
+ # @option options [Boolean] :continue_on_failure (false) Continue if a rule fails
129
+ # @option options [Integer] :max_parallelism (4) Maximum parallel rule execution
130
+ # @option options [Boolean] :validate_only (false) Only validate, don't execute
131
+ # @return [ExecutionResult] Result of rule execution
132
+ def apply_rules(rules_config, options = {})
133
+ options = default_options.merge(options)
134
+ result = ExecutionResult.new
135
+ result.start!
136
+
137
+ begin
138
+ # Load and validate all rules
139
+ all_rules = load_rules(rules_config)
140
+ valid_rules = validate_rules(all_rules)
141
+
142
+ # Track skipped rules (those that failed validation)
143
+ skipped_rules = all_rules - valid_rules
144
+ skipped_rules.each do |rule|
145
+ result.add_skipped_rule(rule, "Failed validation")
146
+ end
147
+
148
+ return result.tap(&:finish!) if options[:validate_only]
149
+
150
+ # Resolve execution order based on dependencies
151
+ execution_order = resolve_execution_order(valid_rules)
152
+
153
+ @logger&.info("Executing #{valid_rules.size} rules in #{execution_order.size} phases")
154
+
155
+ # Execute rules in phases (each phase can run in parallel)
156
+ execution_order.each_with_index do |phase_rules, phase_index|
157
+ execute_phase(phase_rules, phase_index, result, options)
158
+
159
+ # Stop if we have failures and not continuing on failure
160
+ break if !options[:continue_on_failure] && !result.failed_rules.empty?
161
+ end
162
+ rescue ValidationError => e
163
+ @logger&.error("Rules validation error: #{e.message}")
164
+ raise
165
+ rescue StandardError => e
166
+ @logger&.error("Rules engine error: #{e.message}")
167
+ result.add_engine_error(e)
168
+ ensure
169
+ result.finish!
170
+ end
171
+
172
+ result
173
+ end
174
+
175
+ # Rollback all applied rules in reverse order
176
+ #
177
+ # @return [Boolean] true if rollback successful
178
+ def rollback_rules
179
+ return true if @applied_rules.empty?
180
+
181
+ @logger&.info("Rolling back #{@applied_rules.size} applied rules")
182
+
183
+ @applied_rules.reverse_each do |rule|
184
+ if rule.rollbackable?
185
+ rule.rollback
186
+ @logger&.debug("Rolled back rule: #{rule.name}")
187
+ else
188
+ @logger&.debug("Rule not rollbackable: #{rule.name}")
189
+ end
190
+ rescue StandardError => e
191
+ @logger&.error("Failed to rollback rule #{rule.name}: #{e.message}")
192
+ end
193
+
194
+ @applied_rules.clear
195
+ true
196
+ end
197
+
198
+ # Validate rules configuration without executing
199
+ #
200
+ # @param rules_config [Hash] Rules configuration hash
201
+ # @return [Array<BaseRule>] Validated rules
202
+ def validate_rules_config(rules_config)
203
+ all_rules = load_rules(rules_config)
204
+ validate_rules_strict(all_rules)
205
+ all_rules
206
+ end
207
+
208
+ # Strict validation that raises errors on any validation failure
209
+ def validate_rules_strict(rules)
210
+ rules.each do |rule|
211
+ rule.validate
212
+ rescue StandardError => e
213
+ raise ValidationError, "Rule '#{rule.name}' validation failed: #{e.message}"
214
+ end
215
+
216
+ # Validate dependencies exist
217
+ validate_dependencies(rules)
218
+
219
+ # Check for circular dependencies
220
+ check_circular_dependencies(rules)
221
+ end
222
+
223
+ # Get available rule types
224
+ #
225
+ # @return [Array<String>] Available rule type names
226
+ def available_rule_types
227
+ RULE_TYPES.keys
228
+ end
229
+
230
+ private
231
+
232
+ # Default execution options
233
+ def default_options
234
+ {
235
+ parallel: true,
236
+ continue_on_failure: false,
237
+ max_parallelism: 4,
238
+ validate_only: false
239
+ }
240
+ end
241
+
242
+ # Validate that paths exist and are accessible
243
+ def validate_paths!
244
+ raise ArgumentError, "Project path is not a directory: #{@project_path}" unless File.directory?(@project_path)
245
+
246
+ raise ArgumentError, "Session path is not a directory: #{@session_path}" unless File.directory?(@session_path)
247
+
248
+ return if File.writable?(@session_path)
249
+
250
+ raise ArgumentError, "Session path is not writable: #{@session_path}"
251
+ end
252
+
253
+ # Load rules from configuration
254
+ def load_rules(rules_config)
255
+ raise ArgumentError, "Rules config must be a hash" unless rules_config.is_a?(Hash)
256
+
257
+ rules = []
258
+
259
+ rules_config.each do |rule_name, rule_spec|
260
+ rule = load_single_rule(rule_name, rule_spec)
261
+ rules << rule
262
+ rescue ArgumentError, ValidationError => e
263
+ # ArgumentError and ValidationError for invalid rule types should bubble up
264
+ raise e
265
+ rescue StandardError => e
266
+ # Other errors during rule creation are logged but don't stop loading
267
+ @logger&.warn("Failed to load rule '#{rule_name}': #{e.message}")
268
+ end
269
+
270
+ rules
271
+ end
272
+
273
+ # Load a single rule from specification
274
+ def load_single_rule(rule_name, rule_spec)
275
+ raise ArgumentError, "Rule spec for '#{rule_name}' must be a hash" unless rule_spec.is_a?(Hash)
276
+
277
+ rule_type = rule_spec["type"]
278
+ config = rule_spec.fetch("config", {})
279
+ dependencies = rule_spec.fetch("dependencies", [])
280
+
281
+ create_rule(rule_name, rule_type, config, dependencies, @session_path, @project_path)
282
+ end
283
+
284
+ # Create a rule instance
285
+ def create_rule(rule_name, rule_type, config, dependencies, session_path, project_path)
286
+ rule_class = get_rule_class(rule_type)
287
+ if rule_class.nil?
288
+ available_types = RULE_TYPES.keys.join(", ")
289
+ raise ValidationError,
290
+ "Unknown rule type '#{rule_type}' for rule '#{rule_name}'. Available: #{available_types}"
291
+ end
292
+
293
+ rule_class.new(rule_name, config, project_path, session_path, dependencies: dependencies)
294
+ end
295
+
296
+ # Get rule class for a given type
297
+ def get_rule_class(rule_type)
298
+ RULE_TYPES[rule_type]
299
+ end
300
+
301
+ # Validate all rules
302
+ def validate_rules(rules)
303
+ valid_rules = []
304
+
305
+ rules.each do |rule|
306
+ rule.validate
307
+ valid_rules << rule
308
+ rescue StandardError => e
309
+ @logger&.warn("Rule '#{rule.name}' validation failed: #{e.message}")
310
+ # Skip invalid rules but continue processing
311
+ end
312
+
313
+ # Validate dependencies exist for valid rules only
314
+ validate_dependencies(valid_rules)
315
+
316
+ # Check for circular dependencies for valid rules only
317
+ check_circular_dependencies(valid_rules)
318
+
319
+ valid_rules
320
+ end
321
+
322
+ # Validate that all dependencies exist
323
+ def validate_dependencies(rules)
324
+ rule_names = rules.map(&:name)
325
+ rules.each do |rule|
326
+ rule.dependencies.each do |dep|
327
+ raise ValidationError, "Rule '#{rule.name}' depends on non-existent rule '#{dep}'" unless rule_names.include?(dep)
328
+ end
329
+ end
330
+ end
331
+
332
+ # Check for circular dependencies
333
+ def check_circular_dependencies(rules)
334
+ detect_circular_dependencies(rules)
335
+ end
336
+
337
+ # Detect circular dependencies using DFS
338
+ def detect_circular_dependencies(rules)
339
+ rule_map = rules.to_h { |r| [r.name, r] }
340
+ visited = Set.new
341
+ rec_stack = Set.new
342
+
343
+ rules.each do |rule|
344
+ next if visited.include?(rule.name)
345
+
346
+ if has_circular_dependency?(rule, rule_map, visited, rec_stack)
347
+ raise ValidationError, "Circular dependency detected involving rule '#{rule.name}'"
348
+ end
349
+ end
350
+ end
351
+
352
+ # DFS helper for circular dependency detection
353
+ def has_circular_dependency?(rule, rule_map, visited, rec_stack)
354
+ visited.add(rule.name)
355
+ rec_stack.add(rule.name)
356
+
357
+ rule.dependencies.each do |dep_name|
358
+ dep_rule = rule_map[dep_name]
359
+ next unless dep_rule
360
+
361
+ if !visited.include?(dep_name)
362
+ return true if has_circular_dependency?(dep_rule, rule_map, visited, rec_stack)
363
+ elsif rec_stack.include?(dep_name)
364
+ return true
365
+ end
366
+ end
367
+
368
+ rec_stack.delete(rule.name)
369
+ false
370
+ end
371
+
372
+ # Resolve execution order based on dependencies (topological sort)
373
+ def resolve_execution_order(rules)
374
+ rules.to_h { |r| [r.name, r] }
375
+ phases = []
376
+ remaining_rules = rules.dup
377
+ completed_rules = Set.new
378
+
379
+ until remaining_rules.empty?
380
+ # Find rules that can be executed (all dependencies satisfied)
381
+ executable_rules = remaining_rules.select do |rule|
382
+ rule.can_execute?(completed_rules.to_a)
383
+ end
384
+
385
+ if executable_rules.empty?
386
+ missing_deps = remaining_rules.map do |rule|
387
+ unsatisfied = rule.dependencies.reject { |dep| completed_rules.include?(dep) }
388
+ "#{rule.name} needs: #{unsatisfied.join(", ")}" if unsatisfied.any?
389
+ end.compact
390
+
391
+ raise ValidationError, "Cannot resolve dependencies. Missing: #{missing_deps.join("; ")}"
392
+ end
393
+
394
+ # Add this phase and mark rules as completed
395
+ phases << executable_rules
396
+ executable_rules.each { |rule| completed_rules.add(rule.name) }
397
+ remaining_rules -= executable_rules
398
+ end
399
+
400
+ phases
401
+ end
402
+
403
+ # Execute a phase of rules (potentially in parallel)
404
+ def execute_phase(phase_rules, phase_index, result, options)
405
+ @logger&.debug("Executing phase #{phase_index + 1} with #{phase_rules.size} rules")
406
+
407
+ if options[:parallel] && phase_rules.size > 1
408
+ execute_phase_parallel(phase_rules, result, options)
409
+ else
410
+ execute_phase_sequential(phase_rules, result, options)
411
+ end
412
+ end
413
+
414
+ # Execute rules in a phase sequentially
415
+ def execute_phase_sequential(phase_rules, result, options)
416
+ phase_rules.each do |rule|
417
+ execute_single_rule(rule, result, options)
418
+ end
419
+ end
420
+
421
+ # Execute rules in a phase in parallel
422
+ def execute_phase_parallel(phase_rules, result, options)
423
+ max_threads = [phase_rules.size, options[:max_parallelism]].min
424
+ @logger&.debug("Using #{max_threads} threads for parallel execution")
425
+
426
+ # Use a thread pool pattern for controlled parallelism
427
+ threads = []
428
+ mutex = Mutex.new
429
+
430
+ phase_rules.each do |rule|
431
+ thread = Thread.new do
432
+ execute_single_rule(rule, result, options, mutex)
433
+ rescue StandardError => e
434
+ mutex.synchronize do
435
+ result.add_failed_rule(rule, e)
436
+ end
437
+ end
438
+ threads << thread
439
+
440
+ # Limit number of concurrent threads
441
+ threads.shift.join if threads.size >= max_threads
442
+ end
443
+
444
+ # Wait for all remaining threads
445
+ threads.each(&:join)
446
+ end
447
+
448
+ # Execute a single rule
449
+ def execute_single_rule(rule, result, options, mutex = nil)
450
+ @logger&.debug("Executing rule: #{rule.name}")
451
+
452
+ begin
453
+ rule.apply
454
+
455
+ # Thread-safe result updates
456
+ if mutex
457
+ mutex.synchronize { result.add_applied_rule(rule) }
458
+ else
459
+ result.add_applied_rule(rule)
460
+ end
461
+
462
+ @applied_rules << rule
463
+ @logger&.info("Successfully applied rule: #{rule.name}")
464
+ rescue StandardError => e
465
+ @logger&.error("Failed to apply rule #{rule.name}: #{e.message}")
466
+
467
+ if mutex
468
+ mutex.synchronize { result.add_failed_rule(rule, e) }
469
+ else
470
+ result.add_failed_rule(rule, e)
471
+ end
472
+
473
+ # Attempt rollback if the rule supports it
474
+ begin
475
+ rule.rollback if rule.rollbackable?
476
+ rescue StandardError => rollback_error
477
+ @logger&.error("Failed to rollback rule #{rule.name}: #{rollback_error.message}")
478
+ end
479
+
480
+ raise unless options[:continue_on_failure]
481
+ end
482
+ end
483
+ end
484
+ end
485
+ end