decision_agent 0.1.7 → 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.
- checksums.yaml +4 -4
- data/README.md +41 -1
- data/bin/decision_agent +104 -0
- data/lib/decision_agent/dmn/adapter.rb +135 -0
- data/lib/decision_agent/dmn/cache.rb +306 -0
- data/lib/decision_agent/dmn/decision_graph.rb +327 -0
- data/lib/decision_agent/dmn/decision_tree.rb +192 -0
- data/lib/decision_agent/dmn/errors.rb +30 -0
- data/lib/decision_agent/dmn/exporter.rb +217 -0
- data/lib/decision_agent/dmn/feel/evaluator.rb +797 -0
- data/lib/decision_agent/dmn/feel/functions.rb +420 -0
- data/lib/decision_agent/dmn/feel/parser.rb +349 -0
- data/lib/decision_agent/dmn/feel/simple_parser.rb +276 -0
- data/lib/decision_agent/dmn/feel/transformer.rb +372 -0
- data/lib/decision_agent/dmn/feel/types.rb +276 -0
- data/lib/decision_agent/dmn/importer.rb +77 -0
- data/lib/decision_agent/dmn/model.rb +197 -0
- data/lib/decision_agent/dmn/parser.rb +191 -0
- data/lib/decision_agent/dmn/testing.rb +333 -0
- data/lib/decision_agent/dmn/validator.rb +315 -0
- data/lib/decision_agent/dmn/versioning.rb +229 -0
- data/lib/decision_agent/dmn/visualizer.rb +513 -0
- data/lib/decision_agent/dsl/condition_evaluator.rb +1132 -12
- data/lib/decision_agent/dsl/schema_validator.rb +12 -1
- data/lib/decision_agent/evaluators/dmn_evaluator.rb +221 -0
- data/lib/decision_agent/version.rb +1 -1
- data/lib/decision_agent/web/dmn_editor.rb +426 -0
- data/lib/decision_agent/web/public/app.js +119 -1
- data/lib/decision_agent/web/public/dmn-editor.css +596 -0
- data/lib/decision_agent/web/public/dmn-editor.html +250 -0
- data/lib/decision_agent/web/public/dmn-editor.js +553 -0
- data/lib/decision_agent/web/public/index.html +71 -0
- data/lib/decision_agent/web/public/styles.css +21 -0
- data/lib/decision_agent/web/server.rb +465 -0
- data/spec/ab_testing/ab_testing_agent_spec.rb +174 -0
- data/spec/advanced_operators_spec.rb +2147 -0
- data/spec/auth/rbac_adapter_spec.rb +228 -0
- data/spec/dmn/decision_graph_spec.rb +282 -0
- data/spec/dmn/decision_tree_spec.rb +203 -0
- data/spec/dmn/feel/errors_spec.rb +18 -0
- data/spec/dmn/feel/functions_spec.rb +400 -0
- data/spec/dmn/feel/simple_parser_spec.rb +274 -0
- data/spec/dmn/feel/types_spec.rb +176 -0
- data/spec/dmn/feel_parser_spec.rb +489 -0
- data/spec/dmn/hit_policy_spec.rb +202 -0
- data/spec/dmn/integration_spec.rb +226 -0
- data/spec/examples.txt +1909 -0
- data/spec/fixtures/dmn/complex_decision.dmn +81 -0
- data/spec/fixtures/dmn/invalid_structure.dmn +31 -0
- data/spec/fixtures/dmn/simple_decision.dmn +40 -0
- data/spec/monitoring/metrics_collector_spec.rb +37 -35
- data/spec/monitoring/monitored_agent_spec.rb +14 -11
- data/spec/performance_optimizations_spec.rb +10 -3
- data/spec/thread_safety_spec.rb +10 -2
- data/spec/web_ui_rack_spec.rb +294 -0
- metadata +66 -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
|