decision_agent 0.2.0 → 0.3.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 (54) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +41 -1
  3. data/bin/decision_agent +104 -0
  4. data/lib/decision_agent/dmn/adapter.rb +135 -0
  5. data/lib/decision_agent/dmn/cache.rb +306 -0
  6. data/lib/decision_agent/dmn/decision_graph.rb +327 -0
  7. data/lib/decision_agent/dmn/decision_tree.rb +192 -0
  8. data/lib/decision_agent/dmn/errors.rb +30 -0
  9. data/lib/decision_agent/dmn/exporter.rb +217 -0
  10. data/lib/decision_agent/dmn/feel/evaluator.rb +797 -0
  11. data/lib/decision_agent/dmn/feel/functions.rb +420 -0
  12. data/lib/decision_agent/dmn/feel/parser.rb +349 -0
  13. data/lib/decision_agent/dmn/feel/simple_parser.rb +276 -0
  14. data/lib/decision_agent/dmn/feel/transformer.rb +372 -0
  15. data/lib/decision_agent/dmn/feel/types.rb +276 -0
  16. data/lib/decision_agent/dmn/importer.rb +77 -0
  17. data/lib/decision_agent/dmn/model.rb +197 -0
  18. data/lib/decision_agent/dmn/parser.rb +191 -0
  19. data/lib/decision_agent/dmn/testing.rb +333 -0
  20. data/lib/decision_agent/dmn/validator.rb +315 -0
  21. data/lib/decision_agent/dmn/versioning.rb +229 -0
  22. data/lib/decision_agent/dmn/visualizer.rb +513 -0
  23. data/lib/decision_agent/dsl/condition_evaluator.rb +3 -0
  24. data/lib/decision_agent/dsl/schema_validator.rb +2 -1
  25. data/lib/decision_agent/evaluators/dmn_evaluator.rb +221 -0
  26. data/lib/decision_agent/version.rb +1 -1
  27. data/lib/decision_agent/web/dmn_editor.rb +426 -0
  28. data/lib/decision_agent/web/public/dmn-editor.css +596 -0
  29. data/lib/decision_agent/web/public/dmn-editor.html +250 -0
  30. data/lib/decision_agent/web/public/dmn-editor.js +553 -0
  31. data/lib/decision_agent/web/public/index.html +3 -0
  32. data/lib/decision_agent/web/public/styles.css +21 -0
  33. data/lib/decision_agent/web/server.rb +465 -0
  34. data/spec/ab_testing/ab_testing_agent_spec.rb +174 -0
  35. data/spec/auth/rbac_adapter_spec.rb +228 -0
  36. data/spec/dmn/decision_graph_spec.rb +282 -0
  37. data/spec/dmn/decision_tree_spec.rb +203 -0
  38. data/spec/dmn/feel/errors_spec.rb +18 -0
  39. data/spec/dmn/feel/functions_spec.rb +400 -0
  40. data/spec/dmn/feel/simple_parser_spec.rb +274 -0
  41. data/spec/dmn/feel/types_spec.rb +176 -0
  42. data/spec/dmn/feel_parser_spec.rb +489 -0
  43. data/spec/dmn/hit_policy_spec.rb +202 -0
  44. data/spec/dmn/integration_spec.rb +226 -0
  45. data/spec/examples.txt +1846 -1570
  46. data/spec/fixtures/dmn/complex_decision.dmn +81 -0
  47. data/spec/fixtures/dmn/invalid_structure.dmn +31 -0
  48. data/spec/fixtures/dmn/simple_decision.dmn +40 -0
  49. data/spec/monitoring/metrics_collector_spec.rb +37 -35
  50. data/spec/monitoring/monitored_agent_spec.rb +14 -11
  51. data/spec/performance_optimizations_spec.rb +10 -3
  52. data/spec/thread_safety_spec.rb +10 -2
  53. data/spec/web_ui_rack_spec.rb +294 -0
  54. metadata +65 -1
@@ -0,0 +1,333 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../testing/test_scenario"
4
+ require_relative "../evaluators/dmn_evaluator"
5
+ require_relative "parser"
6
+
7
+ module DecisionAgent
8
+ module Dmn
9
+ # DMN Testing Framework
10
+ # Provides testing capabilities for DMN models
11
+ class DmnTester
12
+ attr_reader :model, :test_scenarios, :test_results
13
+
14
+ def initialize(model)
15
+ @model = model
16
+ @test_scenarios = []
17
+ @test_results = []
18
+ end
19
+
20
+ # Add a test scenario
21
+ def add_scenario(decision_id:, inputs:, expected_output:, description: nil)
22
+ scenario = {
23
+ decision_id: decision_id,
24
+ inputs: inputs,
25
+ expected_output: expected_output,
26
+ description: description || "Test #{@test_scenarios.size + 1}"
27
+ }
28
+
29
+ @test_scenarios << scenario
30
+ scenario
31
+ end
32
+
33
+ # Run all test scenarios
34
+ def run_all_tests
35
+ @test_results = []
36
+
37
+ @test_scenarios.each_with_index do |scenario, idx|
38
+ result = run_test(scenario, idx)
39
+ @test_results << result
40
+ end
41
+
42
+ generate_test_report
43
+ end
44
+
45
+ # Run a single test scenario
46
+ # rubocop:disable Metrics/MethodLength
47
+ def run_test(scenario, index = nil)
48
+ decision = @model.find_decision(scenario[:decision_id])
49
+
50
+ unless decision
51
+ return {
52
+ index: index,
53
+ scenario: scenario,
54
+ status: :error,
55
+ error: "Decision '#{scenario[:decision_id]}' not found",
56
+ passed: false
57
+ }
58
+ end
59
+
60
+ begin
61
+ # Create evaluator for this decision
62
+ evaluator = Evaluators::DmnEvaluator.new(
63
+ dmn_model: @model,
64
+ decision_id: scenario[:decision_id]
65
+ )
66
+
67
+ # Evaluate with test inputs
68
+ context = Context.new(scenario[:inputs])
69
+ result = evaluator.evaluate(context: context)
70
+
71
+ # Compare result with expected output
72
+ actual_output = result.decision
73
+ expected_output = scenario[:expected_output]
74
+
75
+ passed = outputs_match?(actual_output, expected_output)
76
+
77
+ {
78
+ index: index,
79
+ scenario: scenario,
80
+ status: :completed,
81
+ actual_output: actual_output,
82
+ expected_output: expected_output,
83
+ passed: passed,
84
+ result: result
85
+ }
86
+ rescue StandardError => e
87
+ {
88
+ index: index,
89
+ scenario: scenario,
90
+ status: :error,
91
+ error: e.message,
92
+ passed: false
93
+ }
94
+ end
95
+ # rubocop:enable Metrics/MethodLength
96
+ end
97
+
98
+ # Generate test coverage report
99
+ # rubocop:disable Metrics/AbcSize
100
+ def generate_coverage_report
101
+ coverage = {
102
+ total_decisions: @model.decisions.size,
103
+ tested_decisions: Set.new,
104
+ untested_decisions: [],
105
+ decision_coverage: {}
106
+ }
107
+
108
+ # Track which decisions are tested
109
+ @test_scenarios.each do |scenario|
110
+ coverage[:tested_decisions].add(scenario[:decision_id])
111
+ end
112
+
113
+ # Find untested decisions
114
+ @model.decisions.each do |decision|
115
+ coverage[:untested_decisions] << decision.id unless coverage[:tested_decisions].include?(decision.id)
116
+
117
+ # Calculate coverage for each decision
118
+ decision_tests = @test_scenarios.select { |s| s[:decision_id] == decision.id }
119
+ coverage[:decision_coverage][decision.id] = {
120
+ test_count: decision_tests.size,
121
+ tested: !decision_tests.empty?
122
+ }
123
+
124
+ # For decision tables, calculate rule coverage
125
+ if decision.decision_table
126
+ rule_coverage = calculate_rule_coverage(decision, decision_tests)
127
+ coverage[:decision_coverage][decision.id][:rule_coverage] = rule_coverage
128
+ end
129
+ end
130
+
131
+ coverage[:coverage_percentage] = if coverage[:total_decisions].positive?
132
+ (coverage[:tested_decisions].size.to_f / coverage[:total_decisions] * 100).round(2)
133
+ else
134
+ 0
135
+ end
136
+
137
+ coverage
138
+ end
139
+
140
+ # Import test scenarios from CSV
141
+ def import_scenarios_from_csv(file_path)
142
+ require "csv"
143
+
144
+ CSV.foreach(file_path, headers: true) do |row|
145
+ inputs = {}
146
+ row.headers.each do |header|
147
+ next if %w[decision_id expected_output description].include?(header)
148
+
149
+ inputs[header] = parse_value(row[header])
150
+ end
151
+
152
+ add_scenario(
153
+ decision_id: row["decision_id"],
154
+ inputs: inputs,
155
+ expected_output: parse_value(row["expected_output"]),
156
+ description: row["description"]
157
+ )
158
+ end
159
+
160
+ @test_scenarios.size
161
+ end
162
+
163
+ # Export test scenarios to CSV
164
+ def export_scenarios_to_csv(file_path)
165
+ require "csv"
166
+
167
+ return if @test_scenarios.empty?
168
+
169
+ # Collect all unique input keys
170
+ input_keys = @test_scenarios.flat_map { |s| s[:inputs].keys }.uniq.sort
171
+
172
+ CSV.open(file_path, "w") do |csv|
173
+ # Write headers
174
+ headers = ["decision_id"] + input_keys + %w[expected_output description]
175
+ csv << headers
176
+
177
+ # Write scenarios
178
+ @test_scenarios.each do |scenario|
179
+ row = [scenario[:decision_id]]
180
+ input_keys.each { |key| row << scenario[:inputs][key] }
181
+ row << scenario[:expected_output]
182
+ row << scenario[:description]
183
+ csv << row
184
+ end
185
+ end
186
+
187
+ @test_scenarios.size
188
+ end
189
+
190
+ # Clear all test scenarios
191
+ def clear_scenarios
192
+ @test_scenarios = []
193
+ @test_results = []
194
+ end
195
+
196
+ private
197
+
198
+ def outputs_match?(actual, expected)
199
+ # Handle nil cases
200
+ return true if actual.nil? && expected.nil?
201
+ return false if actual.nil? || expected.nil?
202
+
203
+ # For simple values, do direct comparison
204
+ if actual.is_a?(Hash) && expected.is_a?(Hash)
205
+ actual.all? { |k, v| expected[k] == v }
206
+ else
207
+ actual.to_s == expected.to_s
208
+ end
209
+ end
210
+
211
+ def calculate_rule_coverage(decision, tests)
212
+ table = decision.decision_table
213
+ return { coverage: 0, tested_rules: [], untested_rules: [] } unless table
214
+
215
+ tested_rules = Set.new
216
+
217
+ # Run each test and track which rules matched
218
+ tests.each do |test|
219
+ evaluator = Evaluators::DmnEvaluator.new(
220
+ dmn_model: @model,
221
+ decision_id: decision.id
222
+ )
223
+
224
+ context = Context.new(test[:inputs])
225
+
226
+ begin
227
+ # This would need to be enhanced to track which rule matched
228
+ evaluator.evaluate(context: context)
229
+ # In a full implementation, we'd track the matched rule
230
+ rescue StandardError
231
+ # Ignore errors for coverage calculation
232
+ end
233
+ end
234
+
235
+ untested_rules = table.rules.map(&:id).reject { |rid| tested_rules.include?(rid) }
236
+
237
+ {
238
+ total_rules: table.rules.size,
239
+ tested_rules: tested_rules.size,
240
+ untested_rules: untested_rules,
241
+ coverage_percentage: if table.rules.size.positive?
242
+ (tested_rules.size.to_f / table.rules.size * 100).round(2)
243
+ else
244
+ 0
245
+ end
246
+ }
247
+ end
248
+
249
+ def generate_test_report
250
+ total = @test_results.size
251
+ passed = @test_results.count { |r| r[:passed] }
252
+ failed = @test_results.count { |r| !r[:passed] }
253
+ errors = @test_results.count { |r| r[:status] == :error }
254
+
255
+ {
256
+ summary: {
257
+ total: total,
258
+ passed: passed,
259
+ failed: failed,
260
+ errors: errors,
261
+ pass_rate: total.positive? ? (passed.to_f / total * 100).round(2) : 0
262
+ },
263
+ results: @test_results,
264
+ coverage: generate_coverage_report
265
+ }
266
+ end
267
+
268
+ def parse_value(value)
269
+ return nil if value.nil? || value.empty?
270
+
271
+ # Try to parse as number
272
+ return value.to_i if value.match?(/^\d+$/)
273
+ return value.to_f if value.match?(/^\d+\.\d+$/)
274
+
275
+ # Try to parse as boolean
276
+ return true if value.downcase == "true"
277
+ return false if value.downcase == "false"
278
+
279
+ # Return as string
280
+ value
281
+ end
282
+ end
283
+
284
+ # Test Suite for organizing multiple DMN models' tests
285
+ class DmnTestSuite
286
+ attr_reader :models, :testers
287
+
288
+ def initialize
289
+ @models = {}
290
+ @testers = {}
291
+ end
292
+
293
+ # Add a model to the test suite
294
+ def add_model(model_id, model)
295
+ @models[model_id] = model
296
+ @testers[model_id] = DmnTester.new(model)
297
+ end
298
+
299
+ # Get tester for a model
300
+ def tester_for(model_id)
301
+ @testers[model_id]
302
+ end
303
+
304
+ # Run all tests for all models
305
+ def run_all
306
+ results = {}
307
+
308
+ @testers.each do |model_id, tester|
309
+ results[model_id] = tester.run_all_tests
310
+ end
311
+
312
+ generate_suite_report(results)
313
+ end
314
+
315
+ # Generate overall suite report
316
+ def generate_suite_report(results)
317
+ total_tests = results.values.sum { |r| r[:summary][:total] }
318
+ total_passed = results.values.sum { |r| r[:summary][:passed] }
319
+ total_failed = results.values.sum { |r| r[:summary][:failed] }
320
+
321
+ {
322
+ models_tested: results.size,
323
+ total_tests: total_tests,
324
+ total_passed: total_passed,
325
+ total_failed: total_failed,
326
+ overall_pass_rate: total_tests.positive? ? (total_passed.to_f / total_tests * 100).round(2) : 0,
327
+ model_results: results
328
+ }
329
+ end
330
+ # rubocop:enable Metrics/AbcSize
331
+ end
332
+ end
333
+ end
@@ -0,0 +1,315 @@
1
+ require_relative "errors"
2
+ require_relative "feel/evaluator"
3
+
4
+ module DecisionAgent
5
+ module Dmn
6
+ # Validates DMN model structure and semantics with enhanced validation
7
+ class Validator
8
+ attr_reader :errors, :warnings
9
+
10
+ def initialize(model = nil)
11
+ @model = model
12
+ @errors = []
13
+ @warnings = []
14
+ @feel_evaluator = Feel::Evaluator.new
15
+ end
16
+
17
+ # rubocop:disable Naming/PredicateMethod
18
+ def validate(model = nil)
19
+ @model = model if model
20
+ return false unless @model
21
+
22
+ @errors = []
23
+ @warnings = []
24
+
25
+ # Basic structure validation
26
+ validate_model_structure
27
+ validate_decisions
28
+
29
+ # Semantic validation
30
+ validate_decision_dependencies
31
+ validate_decision_graph_cycles
32
+
33
+ # Business rule validation
34
+ validate_decision_tables
35
+
36
+ @errors.empty?
37
+ end
38
+ # rubocop:enable Naming/PredicateMethod
39
+
40
+ # rubocop:disable Naming/PredicateMethod
41
+ def validate!
42
+ validate
43
+ raise InvalidDmnModelError, format_errors if @errors.any?
44
+
45
+ true
46
+ end
47
+ # rubocop:enable Naming/PredicateMethod
48
+
49
+ def valid?
50
+ @errors.empty?
51
+ end
52
+
53
+ private
54
+
55
+ # Basic structure validation
56
+ def validate_model_structure
57
+ @errors << "Model must have an ID" if @model.id.nil? || @model.id.empty?
58
+ @errors << "Model must have a name" if @model.name.nil? || @model.name.empty?
59
+ @errors << "Model must have a namespace" if @model.namespace.nil? || @model.namespace.empty?
60
+ @warnings << "Model has no decisions defined" if @model.decisions.empty?
61
+ end
62
+
63
+ def validate_decisions
64
+ decision_ids = Set.new
65
+
66
+ @model.decisions.each_with_index do |decision, idx|
67
+ validate_decision(decision, idx)
68
+
69
+ # Check for duplicate decision IDs
70
+ if decision_ids.include?(decision.id)
71
+ @errors << "Duplicate decision ID: #{decision.id}"
72
+ else
73
+ decision_ids.add(decision.id)
74
+ end
75
+ end
76
+ end
77
+
78
+ def validate_decision(decision, idx)
79
+ path = "Decision[#{idx}](#{decision.id})"
80
+
81
+ @errors << "#{path}: Decision must have an ID" if decision.id.nil? || decision.id.empty?
82
+ @errors << "#{path}: Decision must have a name" if decision.name.nil? || decision.name.empty?
83
+
84
+ # Validate decision has some form of logic
85
+ unless decision.decision_table || decision.decision_tree || decision.instance_variable_get(:@literal_expression)
86
+ @errors << "#{path}: Decision must have decision logic (table, tree, or literal expression)"
87
+ end
88
+
89
+ # Validate information requirements
90
+ validate_information_requirements(decision, path)
91
+ end
92
+
93
+ def validate_information_requirements(decision, path)
94
+ return unless decision.information_requirements
95
+
96
+ decision.information_requirements.each do |req|
97
+ required_decision_id = req[:decision_id]
98
+
99
+ # Check if required decision exists
100
+ @errors << "#{path}: References non-existent decision: #{required_decision_id}" unless @model.find_decision(required_decision_id)
101
+ end
102
+ end
103
+
104
+ # Semantic validation - check for circular dependencies
105
+ def validate_decision_graph_cycles
106
+ visited = Set.new
107
+ rec_stack = Set.new
108
+
109
+ @model.decisions.each do |decision|
110
+ @errors << "Circular dependency detected in decision graph involving: #{decision.id}" if cycle?(decision, visited, rec_stack)
111
+ end
112
+ end
113
+
114
+ def cycle?(decision, visited, rec_stack)
115
+ return false if visited.include?(decision.id)
116
+
117
+ visited.add(decision.id)
118
+ rec_stack.add(decision.id)
119
+
120
+ # Check all dependencies
121
+ decision.information_requirements.each do |req|
122
+ dep = @model.find_decision(req[:decision_id])
123
+ next unless dep
124
+
125
+ return true if !visited.include?(dep.id) && cycle?(dep, visited, rec_stack)
126
+ return true if rec_stack.include?(dep.id)
127
+ end
128
+
129
+ rec_stack.delete(decision.id)
130
+ false
131
+ end
132
+
133
+ def validate_decision_dependencies
134
+ # Check for unreachable decisions
135
+ reachable = Set.new
136
+ leaves = find_leaf_decisions
137
+
138
+ leaves.each do |leaf|
139
+ mark_reachable(leaf, reachable)
140
+ end
141
+
142
+ @model.decisions.each do |decision|
143
+ @warnings << "Decision #{decision.id} is not reachable from any leaf decision" unless reachable.include?(decision.id)
144
+ end
145
+ end
146
+
147
+ def find_leaf_decisions
148
+ # Leaf decisions are those that no other decision depends on
149
+ required_decisions = Set.new
150
+ @model.decisions.each do |decision|
151
+ decision.information_requirements.each do |req|
152
+ required_decisions.add(req[:decision_id])
153
+ end
154
+ end
155
+
156
+ @model.decisions.reject { |d| required_decisions.include?(d.id) }
157
+ end
158
+
159
+ def mark_reachable(decision, reachable)
160
+ return if reachable.include?(decision.id)
161
+
162
+ reachable.add(decision.id)
163
+
164
+ decision.information_requirements.each do |req|
165
+ dep = @model.find_decision(req[:decision_id])
166
+ mark_reachable(dep, reachable) if dep
167
+ end
168
+ end
169
+
170
+ # Business rule validation for decision tables
171
+ def validate_decision_tables
172
+ @model.decisions.each_with_index do |decision, idx|
173
+ next unless decision.decision_table
174
+
175
+ path = "Decision[#{idx}](#{decision.id})"
176
+ table = decision.decision_table
177
+
178
+ validate_table_structure(table, path)
179
+ validate_table_hit_policy(table, path)
180
+ validate_table_completeness(table, path)
181
+ validate_table_rules(table, path)
182
+ end
183
+ end
184
+
185
+ def validate_table_structure(table, path)
186
+ @errors << "#{path}: Decision table must have at least one input" if table.inputs.empty?
187
+ @errors << "#{path}: Decision table must have at least one output" if table.outputs.empty?
188
+ @warnings << "#{path}: Decision table has no rules defined" if table.rules.empty?
189
+
190
+ # Check for duplicate input/output IDs
191
+ input_ids = table.inputs.map(&:id)
192
+ @errors << "#{path}: Duplicate input IDs detected" if input_ids.size != input_ids.uniq.size
193
+
194
+ output_ids = table.outputs.map(&:id)
195
+ @errors << "#{path}: Duplicate output IDs detected" if output_ids.size != output_ids.uniq.size
196
+ end
197
+
198
+ def validate_table_hit_policy(table, path)
199
+ valid_policies = %w[UNIQUE FIRST PRIORITY ANY COLLECT]
200
+
201
+ unless valid_policies.include?(table.hit_policy)
202
+ @errors << "#{path}: Invalid hit policy '#{table.hit_policy}'. Must be one of: #{valid_policies.join(', ')}"
203
+ end
204
+
205
+ # Validate hit policy requirements
206
+ case table.hit_policy
207
+ when "UNIQUE"
208
+ # Check for overlapping rules (not fully implemented - would require rule evaluation)
209
+ @warnings << "#{path}: UNIQUE hit policy requires rules to be mutually exclusive"
210
+ when "PRIORITY"
211
+ # Check that outputs have defined allowed values with priorities
212
+ table.outputs.each do |output|
213
+ unless output.instance_variable_get(:@allowed_values)
214
+ @warnings << "#{path}: PRIORITY hit policy requires outputs to have defined allowed values"
215
+ end
216
+ end
217
+ end
218
+ end
219
+
220
+ def validate_table_completeness(table, path)
221
+ return if table.rules.empty?
222
+
223
+ # Check for rules with all wildcards and empty outputs
224
+ table.rules.each_with_index do |rule, idx|
225
+ all_wildcards = rule.input_entries.all? { |entry| entry.nil? || entry == "-" || entry.empty? }
226
+ @warnings << "#{path}.Rule[#{idx}]: Rule has all wildcard inputs - will match everything" if all_wildcards
227
+
228
+ # Check for empty output entries
229
+ rule.output_entries.each_with_index do |entry, output_idx|
230
+ next unless entry.nil? || entry.empty?
231
+
232
+ output = table.outputs[output_idx]
233
+ @warnings << "#{path}.Rule[#{idx}]: Empty output for '#{output&.label}'"
234
+ end
235
+ end
236
+ end
237
+
238
+ def validate_table_rules(table, path)
239
+ table.rules.each_with_index do |rule, idx|
240
+ validate_rule(rule, table, "#{path}.Rule[#{idx}]")
241
+ validate_rule_feel_expressions(rule, table, "#{path}.Rule[#{idx}]")
242
+ end
243
+ end
244
+
245
+ def validate_rule(rule, table, path)
246
+ expected_inputs = table.inputs.size
247
+ expected_outputs = table.outputs.size
248
+
249
+ if rule.input_entries.size != expected_inputs
250
+ @errors << "#{path}: Expected #{expected_inputs} input entries, got #{rule.input_entries.size}"
251
+ end
252
+
253
+ if rule.output_entries.size != expected_outputs
254
+ @errors << "#{path}: Expected #{expected_outputs} output entries, got #{rule.output_entries.size}"
255
+ end
256
+
257
+ # Validate rule has an ID
258
+ @errors << "#{path}: Rule must have an ID" if rule.id.nil? || rule.id.empty?
259
+ end
260
+
261
+ def validate_rule_feel_expressions(rule, table, path)
262
+ # Validate input entry expressions are valid FEEL
263
+ rule.input_entries.each_with_index do |entry, idx|
264
+ next if entry.nil? || entry == "-" || entry.empty?
265
+
266
+ input = table.inputs[idx]
267
+ validate_feel_expression(entry, "#{path}.Input[#{idx}](#{input&.label})")
268
+ end
269
+
270
+ # Validate output entry expressions
271
+ rule.output_entries.each_with_index do |entry, idx|
272
+ next if entry.nil? || entry.empty?
273
+
274
+ output = table.outputs[idx]
275
+ validate_feel_expression(entry, "#{path}.Output[#{idx}](#{output&.label})")
276
+ end
277
+ end
278
+
279
+ def validate_feel_expression(expression, path)
280
+ return if expression.nil? || expression.empty?
281
+
282
+ begin
283
+ # Try to parse the expression (basic validation)
284
+ # Full validation would require evaluating with sample context
285
+ @feel_evaluator.evaluate(expression.to_s, {})
286
+ rescue StandardError => e
287
+ # Only warn for FEEL validation errors since they might be context-dependent
288
+ @warnings << "#{path}: Possible FEEL expression issue: #{e.message}"
289
+ end
290
+ end
291
+
292
+ def format_errors
293
+ parts = []
294
+
295
+ if @errors.any?
296
+ parts << "DMN model validation failed with #{@errors.size} error(s):"
297
+ parts << ""
298
+ @errors.each_with_index do |err, idx|
299
+ parts << " #{idx + 1}. #{err}"
300
+ end
301
+ end
302
+
303
+ if @warnings.any?
304
+ parts << "" if @errors.any?
305
+ parts << "Warnings (#{@warnings.size}):"
306
+ @warnings.each_with_index do |warn, idx|
307
+ parts << " #{idx + 1}. #{warn}"
308
+ end
309
+ end
310
+
311
+ parts.join("\n")
312
+ end
313
+ end
314
+ end
315
+ end