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.
- checksums.yaml +7 -0
- data/.gem_rbs_collection/addressable/2.8/.rbs_meta.yaml +9 -0
- data/.gem_rbs_collection/addressable/2.8/addressable.rbs +62 -0
- data/.gem_rbs_collection/async/2.12/.rbs_meta.yaml +9 -0
- data/.gem_rbs_collection/async/2.12/async.rbs +119 -0
- data/.gem_rbs_collection/async/2.12/kernel.rbs +5 -0
- data/.gem_rbs_collection/async/2.12/manifest.yaml +7 -0
- data/.gem_rbs_collection/bcrypt/3.1/.rbs_meta.yaml +9 -0
- data/.gem_rbs_collection/bcrypt/3.1/bcrypt.rbs +47 -0
- data/.gem_rbs_collection/bcrypt/3.1/manifest.yaml +2 -0
- data/.gem_rbs_collection/bigdecimal/3.1/.rbs_meta.yaml +9 -0
- data/.gem_rbs_collection/bigdecimal/3.1/bigdecimal-math.rbs +119 -0
- data/.gem_rbs_collection/bigdecimal/3.1/bigdecimal.rbs +1630 -0
- data/.gem_rbs_collection/concurrent-ruby/1.1/.rbs_meta.yaml +9 -0
- data/.gem_rbs_collection/concurrent-ruby/1.1/array.rbs +4 -0
- data/.gem_rbs_collection/concurrent-ruby/1.1/executor.rbs +26 -0
- data/.gem_rbs_collection/concurrent-ruby/1.1/hash.rbs +4 -0
- data/.gem_rbs_collection/concurrent-ruby/1.1/map.rbs +65 -0
- data/.gem_rbs_collection/concurrent-ruby/1.1/promises.rbs +249 -0
- data/.gem_rbs_collection/concurrent-ruby/1.1/utility/processor_counter.rbs +5 -0
- data/.gem_rbs_collection/diff-lcs/1.5/.rbs_meta.yaml +9 -0
- data/.gem_rbs_collection/diff-lcs/1.5/diff-lcs.rbs +11 -0
- data/.gem_rbs_collection/listen/3.9/.rbs_meta.yaml +9 -0
- data/.gem_rbs_collection/listen/3.9/listen.rbs +25 -0
- data/.gem_rbs_collection/listen/3.9/listener.rbs +24 -0
- data/.gem_rbs_collection/mini_mime/0.1/.rbs_meta.yaml +9 -0
- data/.gem_rbs_collection/mini_mime/0.1/mini_mime.rbs +14 -0
- data/.gem_rbs_collection/parallel/1.20/.rbs_meta.yaml +9 -0
- data/.gem_rbs_collection/parallel/1.20/parallel.rbs +86 -0
- data/.gem_rbs_collection/rake/13.0/.rbs_meta.yaml +9 -0
- data/.gem_rbs_collection/rake/13.0/manifest.yaml +2 -0
- data/.gem_rbs_collection/rake/13.0/rake.rbs +39 -0
- data/.gem_rbs_collection/rubocop-ast/1.46/.rbs_meta.yaml +9 -0
- data/.gem_rbs_collection/rubocop-ast/1.46/rubocop-ast.rbs +822 -0
- data/.gem_rbs_collection/sqlite3/2.0/.rbs_meta.yaml +9 -0
- data/.gem_rbs_collection/sqlite3/2.0/database.rbs +20 -0
- data/.gem_rbs_collection/sqlite3/2.0/pragmas.rbs +5 -0
- data/.rspec +4 -0
- data/.rubocop.yml +121 -0
- data/.simplecov +51 -0
- data/CHANGELOG.md +49 -0
- data/Gemfile +24 -0
- data/Gemfile.lock +329 -0
- data/LICENSE.txt +21 -0
- data/README.md +225 -0
- data/Rakefile +54 -0
- data/Steepfile +50 -0
- data/bin/sxn +6 -0
- data/lib/sxn/CLI.rb +275 -0
- data/lib/sxn/commands/init.rb +137 -0
- data/lib/sxn/commands/projects.rb +350 -0
- data/lib/sxn/commands/rules.rb +435 -0
- data/lib/sxn/commands/sessions.rb +300 -0
- data/lib/sxn/commands/worktrees.rb +416 -0
- data/lib/sxn/commands.rb +13 -0
- data/lib/sxn/config/config_cache.rb +295 -0
- data/lib/sxn/config/config_discovery.rb +242 -0
- data/lib/sxn/config/config_validator.rb +562 -0
- data/lib/sxn/config.rb +259 -0
- data/lib/sxn/core/config_manager.rb +290 -0
- data/lib/sxn/core/project_manager.rb +307 -0
- data/lib/sxn/core/rules_manager.rb +306 -0
- data/lib/sxn/core/session_manager.rb +336 -0
- data/lib/sxn/core/worktree_manager.rb +281 -0
- data/lib/sxn/core.rb +13 -0
- data/lib/sxn/database/errors.rb +29 -0
- data/lib/sxn/database/session_database.rb +691 -0
- data/lib/sxn/database.rb +24 -0
- data/lib/sxn/errors.rb +76 -0
- data/lib/sxn/rules/base_rule.rb +367 -0
- data/lib/sxn/rules/copy_files_rule.rb +346 -0
- data/lib/sxn/rules/errors.rb +28 -0
- data/lib/sxn/rules/project_detector.rb +871 -0
- data/lib/sxn/rules/rules_engine.rb +485 -0
- data/lib/sxn/rules/setup_commands_rule.rb +307 -0
- data/lib/sxn/rules/template_rule.rb +262 -0
- data/lib/sxn/rules.rb +148 -0
- data/lib/sxn/runtime_validations.rb +96 -0
- data/lib/sxn/security/secure_command_executor.rb +364 -0
- data/lib/sxn/security/secure_file_copier.rb +478 -0
- data/lib/sxn/security/secure_path_validator.rb +258 -0
- data/lib/sxn/security.rb +15 -0
- data/lib/sxn/templates/common/gitignore.liquid +99 -0
- data/lib/sxn/templates/common/session-info.md.liquid +58 -0
- data/lib/sxn/templates/errors.rb +36 -0
- data/lib/sxn/templates/javascript/README.md.liquid +59 -0
- data/lib/sxn/templates/javascript/session-info.md.liquid +206 -0
- data/lib/sxn/templates/rails/CLAUDE.md.liquid +78 -0
- data/lib/sxn/templates/rails/database.yml.liquid +31 -0
- data/lib/sxn/templates/rails/session-info.md.liquid +144 -0
- data/lib/sxn/templates/template_engine.rb +346 -0
- data/lib/sxn/templates/template_processor.rb +279 -0
- data/lib/sxn/templates/template_security.rb +410 -0
- data/lib/sxn/templates/template_variables.rb +713 -0
- data/lib/sxn/templates.rb +28 -0
- data/lib/sxn/ui/output.rb +103 -0
- data/lib/sxn/ui/progress_bar.rb +91 -0
- data/lib/sxn/ui/prompt.rb +116 -0
- data/lib/sxn/ui/table.rb +183 -0
- data/lib/sxn/ui.rb +12 -0
- data/lib/sxn/version.rb +5 -0
- data/lib/sxn.rb +63 -0
- data/rbs_collection.lock.yaml +180 -0
- data/rbs_collection.yaml +39 -0
- data/scripts/test.sh +31 -0
- data/sig/external/liquid.rbs +116 -0
- data/sig/external/thor.rbs +99 -0
- data/sig/external/tty.rbs +71 -0
- data/sig/sxn/cli.rbs +46 -0
- data/sig/sxn/commands/init.rbs +38 -0
- data/sig/sxn/commands/projects.rbs +72 -0
- data/sig/sxn/commands/rules.rbs +95 -0
- data/sig/sxn/commands/sessions.rbs +62 -0
- data/sig/sxn/commands/worktrees.rbs +82 -0
- data/sig/sxn/commands.rbs +6 -0
- data/sig/sxn/config/config_cache.rbs +67 -0
- data/sig/sxn/config/config_discovery.rbs +64 -0
- data/sig/sxn/config/config_validator.rbs +64 -0
- data/sig/sxn/config.rbs +74 -0
- data/sig/sxn/core/config_manager.rbs +67 -0
- data/sig/sxn/core/project_manager.rbs +52 -0
- data/sig/sxn/core/rules_manager.rbs +54 -0
- data/sig/sxn/core/session_manager.rbs +59 -0
- data/sig/sxn/core/worktree_manager.rbs +50 -0
- data/sig/sxn/core.rbs +87 -0
- data/sig/sxn/database/errors.rbs +37 -0
- data/sig/sxn/database/session_database.rbs +151 -0
- data/sig/sxn/database.rbs +83 -0
- data/sig/sxn/errors.rbs +89 -0
- data/sig/sxn/rules/base_rule.rbs +137 -0
- data/sig/sxn/rules/copy_files_rule.rbs +65 -0
- data/sig/sxn/rules/errors.rbs +33 -0
- data/sig/sxn/rules/project_detector.rbs +115 -0
- data/sig/sxn/rules/rules_engine.rbs +118 -0
- data/sig/sxn/rules/setup_commands_rule.rbs +60 -0
- data/sig/sxn/rules/template_rule.rbs +44 -0
- data/sig/sxn/rules.rbs +287 -0
- data/sig/sxn/runtime_validations.rbs +16 -0
- data/sig/sxn/security/secure_command_executor.rbs +63 -0
- data/sig/sxn/security/secure_file_copier.rbs +79 -0
- data/sig/sxn/security/secure_path_validator.rbs +30 -0
- data/sig/sxn/security.rbs +128 -0
- data/sig/sxn/templates/errors.rbs +43 -0
- data/sig/sxn/templates/template_engine.rbs +50 -0
- data/sig/sxn/templates/template_processor.rbs +44 -0
- data/sig/sxn/templates/template_security.rbs +62 -0
- data/sig/sxn/templates/template_variables.rbs +103 -0
- data/sig/sxn/templates.rbs +104 -0
- data/sig/sxn/ui/output.rbs +50 -0
- data/sig/sxn/ui/progress_bar.rbs +39 -0
- data/sig/sxn/ui/prompt.rbs +38 -0
- data/sig/sxn/ui/table.rbs +43 -0
- data/sig/sxn/ui.rbs +63 -0
- data/sig/sxn/version.rbs +5 -0
- data/sig/sxn.rbs +29 -0
- 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
|