decision_agent 0.1.3 → 0.1.6

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 (110) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +84 -233
  3. data/lib/decision_agent/ab_testing/ab_test.rb +197 -0
  4. data/lib/decision_agent/ab_testing/ab_test_assignment.rb +76 -0
  5. data/lib/decision_agent/ab_testing/ab_test_manager.rb +317 -0
  6. data/lib/decision_agent/ab_testing/ab_testing_agent.rb +188 -0
  7. data/lib/decision_agent/ab_testing/storage/activerecord_adapter.rb +155 -0
  8. data/lib/decision_agent/ab_testing/storage/adapter.rb +67 -0
  9. data/lib/decision_agent/ab_testing/storage/memory_adapter.rb +116 -0
  10. data/lib/decision_agent/agent.rb +5 -3
  11. data/lib/decision_agent/auth/access_audit_logger.rb +122 -0
  12. data/lib/decision_agent/auth/authenticator.rb +127 -0
  13. data/lib/decision_agent/auth/password_reset_manager.rb +57 -0
  14. data/lib/decision_agent/auth/password_reset_token.rb +33 -0
  15. data/lib/decision_agent/auth/permission.rb +29 -0
  16. data/lib/decision_agent/auth/permission_checker.rb +43 -0
  17. data/lib/decision_agent/auth/rbac_adapter.rb +278 -0
  18. data/lib/decision_agent/auth/rbac_config.rb +51 -0
  19. data/lib/decision_agent/auth/role.rb +56 -0
  20. data/lib/decision_agent/auth/session.rb +33 -0
  21. data/lib/decision_agent/auth/session_manager.rb +57 -0
  22. data/lib/decision_agent/auth/user.rb +70 -0
  23. data/lib/decision_agent/context.rb +24 -4
  24. data/lib/decision_agent/decision.rb +10 -3
  25. data/lib/decision_agent/dsl/condition_evaluator.rb +378 -1
  26. data/lib/decision_agent/dsl/schema_validator.rb +8 -1
  27. data/lib/decision_agent/errors.rb +38 -0
  28. data/lib/decision_agent/evaluation.rb +10 -3
  29. data/lib/decision_agent/evaluation_validator.rb +8 -13
  30. data/lib/decision_agent/monitoring/dashboard_server.rb +1 -0
  31. data/lib/decision_agent/monitoring/metrics_collector.rb +164 -7
  32. data/lib/decision_agent/monitoring/storage/activerecord_adapter.rb +253 -0
  33. data/lib/decision_agent/monitoring/storage/base_adapter.rb +90 -0
  34. data/lib/decision_agent/monitoring/storage/memory_adapter.rb +222 -0
  35. data/lib/decision_agent/testing/batch_test_importer.rb +373 -0
  36. data/lib/decision_agent/testing/batch_test_runner.rb +244 -0
  37. data/lib/decision_agent/testing/test_coverage_analyzer.rb +191 -0
  38. data/lib/decision_agent/testing/test_result_comparator.rb +235 -0
  39. data/lib/decision_agent/testing/test_scenario.rb +42 -0
  40. data/lib/decision_agent/version.rb +10 -1
  41. data/lib/decision_agent/versioning/activerecord_adapter.rb +1 -1
  42. data/lib/decision_agent/versioning/file_storage_adapter.rb +96 -28
  43. data/lib/decision_agent/web/middleware/auth_middleware.rb +45 -0
  44. data/lib/decision_agent/web/middleware/permission_middleware.rb +94 -0
  45. data/lib/decision_agent/web/public/app.js +184 -29
  46. data/lib/decision_agent/web/public/batch_testing.html +640 -0
  47. data/lib/decision_agent/web/public/index.html +37 -9
  48. data/lib/decision_agent/web/public/login.html +298 -0
  49. data/lib/decision_agent/web/public/users.html +679 -0
  50. data/lib/decision_agent/web/server.rb +873 -7
  51. data/lib/decision_agent.rb +59 -0
  52. data/lib/generators/decision_agent/install/install_generator.rb +37 -0
  53. data/lib/generators/decision_agent/install/templates/ab_test_assignment_model.rb +45 -0
  54. data/lib/generators/decision_agent/install/templates/ab_test_model.rb +54 -0
  55. data/lib/generators/decision_agent/install/templates/ab_testing_migration.rb +43 -0
  56. data/lib/generators/decision_agent/install/templates/ab_testing_tasks.rake +189 -0
  57. data/lib/generators/decision_agent/install/templates/decision_agent_tasks.rake +114 -0
  58. data/lib/generators/decision_agent/install/templates/decision_log.rb +57 -0
  59. data/lib/generators/decision_agent/install/templates/error_metric.rb +53 -0
  60. data/lib/generators/decision_agent/install/templates/evaluation_metric.rb +43 -0
  61. data/lib/generators/decision_agent/install/templates/monitoring_migration.rb +109 -0
  62. data/lib/generators/decision_agent/install/templates/performance_metric.rb +76 -0
  63. data/lib/generators/decision_agent/install/templates/rule_version.rb +1 -1
  64. data/spec/ab_testing/ab_test_assignment_spec.rb +253 -0
  65. data/spec/ab_testing/ab_test_manager_spec.rb +612 -0
  66. data/spec/ab_testing/ab_test_spec.rb +270 -0
  67. data/spec/ab_testing/ab_testing_agent_spec.rb +481 -0
  68. data/spec/ab_testing/storage/adapter_spec.rb +64 -0
  69. data/spec/ab_testing/storage/memory_adapter_spec.rb +485 -0
  70. data/spec/advanced_operators_spec.rb +1003 -0
  71. data/spec/agent_spec.rb +40 -0
  72. data/spec/audit_adapters_spec.rb +18 -0
  73. data/spec/auth/access_audit_logger_spec.rb +394 -0
  74. data/spec/auth/authenticator_spec.rb +112 -0
  75. data/spec/auth/password_reset_spec.rb +294 -0
  76. data/spec/auth/permission_checker_spec.rb +207 -0
  77. data/spec/auth/permission_spec.rb +73 -0
  78. data/spec/auth/rbac_adapter_spec.rb +550 -0
  79. data/spec/auth/rbac_config_spec.rb +82 -0
  80. data/spec/auth/role_spec.rb +51 -0
  81. data/spec/auth/session_manager_spec.rb +172 -0
  82. data/spec/auth/session_spec.rb +112 -0
  83. data/spec/auth/user_spec.rb +130 -0
  84. data/spec/context_spec.rb +43 -0
  85. data/spec/decision_agent_spec.rb +96 -0
  86. data/spec/decision_spec.rb +423 -0
  87. data/spec/dsl/condition_evaluator_spec.rb +774 -0
  88. data/spec/evaluation_spec.rb +364 -0
  89. data/spec/evaluation_validator_spec.rb +165 -0
  90. data/spec/examples.txt +1542 -548
  91. data/spec/issue_verification_spec.rb +95 -21
  92. data/spec/monitoring/metrics_collector_spec.rb +221 -3
  93. data/spec/monitoring/monitored_agent_spec.rb +1 -1
  94. data/spec/monitoring/prometheus_exporter_spec.rb +1 -1
  95. data/spec/monitoring/storage/activerecord_adapter_spec.rb +498 -0
  96. data/spec/monitoring/storage/base_adapter_spec.rb +61 -0
  97. data/spec/monitoring/storage/memory_adapter_spec.rb +247 -0
  98. data/spec/performance_optimizations_spec.rb +486 -0
  99. data/spec/spec_helper.rb +23 -0
  100. data/spec/testing/batch_test_importer_spec.rb +693 -0
  101. data/spec/testing/batch_test_runner_spec.rb +307 -0
  102. data/spec/testing/test_coverage_analyzer_spec.rb +292 -0
  103. data/spec/testing/test_result_comparator_spec.rb +392 -0
  104. data/spec/testing/test_scenario_spec.rb +113 -0
  105. data/spec/versioning/adapter_spec.rb +156 -0
  106. data/spec/versioning_spec.rb +253 -0
  107. data/spec/web/middleware/auth_middleware_spec.rb +133 -0
  108. data/spec/web/middleware/permission_middleware_spec.rb +247 -0
  109. data/spec/web_ui_rack_spec.rb +1705 -0
  110. metadata +123 -6
@@ -0,0 +1,278 @@
1
+ module DecisionAgent
2
+ module Auth
3
+ # Base adapter interface for RBAC integration
4
+ # Users can extend this to integrate with any authentication/authorization system
5
+ class RbacAdapter
6
+ # Check if a user has a specific permission
7
+ # @param user [Object] The user object from your auth system
8
+ # @param permission [Symbol, String] The permission to check
9
+ # @param resource [Object, nil] Optional resource for resource-level permissions
10
+ # @return [Boolean] true if user has permission, false otherwise
11
+ def can?(user, permission, resource = nil)
12
+ raise NotImplementedError, "Subclasses must implement #can?"
13
+ end
14
+
15
+ # Check if a user has a specific role
16
+ # @param user [Object] The user object from your auth system
17
+ # @param role [Symbol, String] The role to check
18
+ # @return [Boolean] true if user has role, false otherwise
19
+ def has_role?(user, role)
20
+ raise NotImplementedError, "Subclasses must implement #has_role?"
21
+ end
22
+
23
+ # Check if a user is active/enabled
24
+ # @param user [Object] The user object from your auth system
25
+ # @return [Boolean] true if user is active, false otherwise
26
+ def active?(user)
27
+ return false unless user
28
+
29
+ # Default implementation - can be overridden
30
+ user.respond_to?(:active?) ? user.active? : true
31
+ end
32
+
33
+ # Get user ID for audit/logging purposes
34
+ # @param user [Object] The user object from your auth system
35
+ # @return [String, Integer] User identifier
36
+ def user_id(user)
37
+ return nil unless user
38
+
39
+ user.respond_to?(:id) ? user.id : user.to_s
40
+ end
41
+
42
+ # Get user email for display/logging purposes
43
+ # @param user [Object] The user object from your auth system
44
+ # @return [String, nil] User email
45
+ def user_email(user)
46
+ return nil unless user
47
+
48
+ user.respond_to?(:email) ? user.email : nil
49
+ end
50
+ end
51
+
52
+ # Default adapter using the built-in User/Role/Permission system
53
+ class DefaultAdapter < RbacAdapter
54
+ def can?(user, permission, _resource = nil)
55
+ return false unless user
56
+ return false unless active?(user)
57
+
58
+ # Check if user has any role with the required permission
59
+ roles = extract_roles(user)
60
+ roles.any? do |role|
61
+ Role.has_permission?(role, permission)
62
+ end
63
+ end
64
+
65
+ def has_role?(user, role)
66
+ return false unless user
67
+
68
+ roles = extract_roles(user)
69
+ roles.include?(role.to_sym)
70
+ end
71
+
72
+ def active?(user)
73
+ return false unless user
74
+
75
+ user.respond_to?(:active) ? user.active : true
76
+ end
77
+
78
+ private
79
+
80
+ def extract_roles(user)
81
+ if user.respond_to?(:roles)
82
+ Array(user.roles).map(&:to_sym)
83
+ elsif user.respond_to?(:role)
84
+ [user.role.to_sym]
85
+ else
86
+ []
87
+ end
88
+ end
89
+ end
90
+
91
+ # Adapter for Devise + CanCanCan integration
92
+ class DeviseCanCanAdapter < RbacAdapter
93
+ def initialize(ability_class: nil)
94
+ super()
95
+ @ability_class = ability_class
96
+ end
97
+
98
+ def can?(user, permission, resource = nil)
99
+ return false unless user
100
+ return false unless active?(user)
101
+
102
+ # CanCanCan uses :can? method with action and resource
103
+ if user.respond_to?(:can?)
104
+ # Map permission to CanCanCan action
105
+ action = map_permission_to_action(permission)
106
+ user.can?(action, resource || Object)
107
+ elsif @ability_class
108
+ # Use Ability class if provided
109
+ ability = @ability_class.new(user)
110
+ action = map_permission_to_action(permission)
111
+ ability.can?(action, resource || Object)
112
+ else
113
+ false
114
+ end
115
+ end
116
+
117
+ def has_role?(user, role)
118
+ return false unless user
119
+ return false unless active?(user)
120
+
121
+ # Check if user has role via CanCanCan roles or other methods
122
+ if user.respond_to?(:has_role?)
123
+ user.has_role?(role)
124
+ elsif user.respond_to?(:roles)
125
+ user.roles.any? { |r| r.to_s == role.to_s || r.name.to_s == role.to_s }
126
+ else
127
+ false
128
+ end
129
+ end
130
+
131
+ def active?(user)
132
+ return false unless user
133
+
134
+ # Devise typically uses active_for_authentication? or active?
135
+ if user.respond_to?(:active_for_authentication?)
136
+ user.active_for_authentication?
137
+ elsif user.respond_to?(:active?)
138
+ user.active?
139
+ else
140
+ true
141
+ end
142
+ end
143
+
144
+ private
145
+
146
+ def map_permission_to_action(permission)
147
+ # Map decision_agent permissions to CanCanCan actions
148
+ mapping = {
149
+ read: :read,
150
+ write: :create,
151
+ delete: :destroy,
152
+ approve: :approve,
153
+ deploy: :deploy,
154
+ manage_users: :manage,
155
+ audit: :read
156
+ }
157
+ mapping[permission.to_sym] || permission.to_sym
158
+ end
159
+ end
160
+
161
+ # Adapter for Pundit authorization
162
+ class PunditAdapter < RbacAdapter
163
+ def can?(user, permission, resource = nil)
164
+ return false unless user
165
+ return false unless active?(user)
166
+
167
+ # Pundit uses policy classes
168
+ if resource.respond_to?(:policy_class)
169
+ policy = resource.policy_class.new(user, resource)
170
+ action = map_permission_to_action(permission)
171
+ policy.respond_to?(action) && policy.public_send(action)
172
+ elsif resource
173
+ # Try to infer policy class from resource
174
+ policy_class_name = "#{resource.class.name}Policy"
175
+ if Object.const_defined?(policy_class_name)
176
+ policy_class = Object.const_get(policy_class_name)
177
+ policy = policy_class.new(user, resource)
178
+ action = map_permission_to_action(permission)
179
+ policy.respond_to?(action) && policy.public_send(action)
180
+ else
181
+ false
182
+ end
183
+ else
184
+ false
185
+ end
186
+ end
187
+
188
+ def has_role?(user, role)
189
+ return false unless user
190
+ return false unless active?(user)
191
+
192
+ if user.respond_to?(:has_role?)
193
+ user.has_role?(role)
194
+ elsif user.respond_to?(:roles)
195
+ user.roles.any? { |r| r.to_s == role.to_s || r.name.to_s == role.to_s }
196
+ else
197
+ false
198
+ end
199
+ end
200
+
201
+ private
202
+
203
+ def map_permission_to_action(permission)
204
+ mapping = {
205
+ read: :show,
206
+ write: :create,
207
+ delete: :destroy,
208
+ approve: :approve,
209
+ deploy: :deploy,
210
+ manage_users: :manage,
211
+ audit: :audit
212
+ }
213
+ mapping[permission.to_sym] || permission.to_sym
214
+ end
215
+ end
216
+
217
+ # Custom adapter that allows users to provide their own logic via blocks/procs
218
+ class CustomAdapter < RbacAdapter
219
+ def initialize(
220
+ can_proc: nil,
221
+ has_role_proc: nil,
222
+ active_proc: nil,
223
+ user_id_proc: nil,
224
+ user_email_proc: nil
225
+ )
226
+ super()
227
+ @can_proc = can_proc
228
+ @has_role_proc = has_role_proc
229
+ @active_proc = active_proc
230
+ @user_id_proc = user_id_proc
231
+ @user_email_proc = user_email_proc
232
+ end
233
+
234
+ def can?(user, permission, resource = nil)
235
+ return false unless user
236
+ return false unless active?(user)
237
+
238
+ raise NotImplementedError, "CustomAdapter requires can_proc to be provided" unless @can_proc
239
+
240
+ @can_proc.call(user, permission, resource)
241
+ end
242
+
243
+ def has_role?(user, role)
244
+ return false unless user
245
+
246
+ raise NotImplementedError, "CustomAdapter requires has_role_proc to be provided" unless @has_role_proc
247
+
248
+ @has_role_proc.call(user, role)
249
+ end
250
+
251
+ def active?(user)
252
+ return false unless user
253
+
254
+ if @active_proc
255
+ @active_proc.call(user)
256
+ else
257
+ super
258
+ end
259
+ end
260
+
261
+ def user_id(user)
262
+ if @user_id_proc
263
+ @user_id_proc.call(user)
264
+ else
265
+ super
266
+ end
267
+ end
268
+
269
+ def user_email(user)
270
+ if @user_email_proc
271
+ @user_email_proc.call(user)
272
+ else
273
+ super
274
+ end
275
+ end
276
+ end
277
+ end
278
+ end
@@ -0,0 +1,51 @@
1
+ module DecisionAgent
2
+ module Auth
3
+ # Configuration class for RBAC adapter
4
+ class RbacConfig
5
+ attr_accessor :authenticator, :user_store
6
+
7
+ def initialize
8
+ @adapter = nil
9
+ @authenticator = nil
10
+ @user_store = nil
11
+ end
12
+
13
+ # Configure with a built-in adapter
14
+ # @param adapter_type [Symbol] :default, :devise_cancan, :pundit, or :custom
15
+ # @param options [Hash] Options for the adapter
16
+ def use(adapter_type, **options)
17
+ case adapter_type.to_sym
18
+ when :default
19
+ @adapter = DefaultAdapter.new
20
+ when :devise_cancan
21
+ @adapter = DeviseCanCanAdapter.new(**options)
22
+ when :pundit
23
+ @adapter = PunditAdapter.new(**options)
24
+ when :custom
25
+ @adapter = CustomAdapter.new(**options)
26
+ else
27
+ raise ArgumentError, "Unknown adapter type: #{adapter_type}. Use :default, :devise_cancan, :pundit, or :custom"
28
+ end
29
+ self
30
+ end
31
+
32
+ # Configure with a custom adapter instance
33
+ # @param adapter_instance [RbacAdapter] An instance of RbacAdapter or subclass
34
+ def adapter=(adapter_instance)
35
+ raise ArgumentError, "Adapter must be an instance of DecisionAgent::Auth::RbacAdapter" unless adapter_instance.is_a?(RbacAdapter)
36
+
37
+ @adapter = adapter_instance
38
+ end
39
+
40
+ # Get the configured adapter, or return default if none configured
41
+ def adapter
42
+ @adapter || DefaultAdapter.new
43
+ end
44
+
45
+ # Check if an adapter has been configured
46
+ def configured?
47
+ !@adapter.nil?
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,56 @@
1
+ module DecisionAgent
2
+ module Auth
3
+ class Role
4
+ ROLES = {
5
+ admin: {
6
+ name: "Admin",
7
+ permissions: %i[read write delete approve deploy manage_users audit]
8
+ },
9
+ editor: {
10
+ name: "Editor",
11
+ permissions: %i[read write]
12
+ },
13
+ viewer: {
14
+ name: "Viewer",
15
+ permissions: [:read]
16
+ },
17
+ auditor: {
18
+ name: "Auditor",
19
+ permissions: %i[read audit]
20
+ },
21
+ approver: {
22
+ name: "Approver",
23
+ permissions: %i[read approve]
24
+ }
25
+ }.freeze
26
+
27
+ class << self
28
+ def all
29
+ ROLES.keys
30
+ end
31
+
32
+ def exists?(role)
33
+ ROLES.key?(role.to_sym)
34
+ end
35
+
36
+ def permissions_for(role)
37
+ role_data = ROLES[role.to_sym]
38
+ return [] unless role_data
39
+
40
+ role_data[:permissions]
41
+ end
42
+
43
+ def name_for(role)
44
+ role_data = ROLES[role.to_sym]
45
+ return nil unless role_data
46
+
47
+ role_data[:name]
48
+ end
49
+
50
+ def has_permission?(role, permission)
51
+ permissions_for(role).include?(permission.to_sym)
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,33 @@
1
+ require "securerandom"
2
+
3
+ module DecisionAgent
4
+ module Auth
5
+ class Session
6
+ attr_reader :token, :user_id, :created_at, :expires_at
7
+
8
+ def initialize(user_id:, expires_in: 3600)
9
+ @token = SecureRandom.hex(32)
10
+ @user_id = user_id
11
+ @created_at = Time.now.utc
12
+ @expires_at = @created_at + expires_in
13
+ end
14
+
15
+ def expired?
16
+ Time.now.utc > @expires_at
17
+ end
18
+
19
+ def valid?
20
+ !expired?
21
+ end
22
+
23
+ def to_h
24
+ {
25
+ token: @token,
26
+ user_id: @user_id,
27
+ created_at: @created_at.iso8601,
28
+ expires_at: @expires_at.iso8601
29
+ }
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,57 @@
1
+ module DecisionAgent
2
+ module Auth
3
+ class SessionManager
4
+ def initialize
5
+ @sessions = {}
6
+ @mutex = Mutex.new
7
+ @cleanup_interval = 300 # 5 minutes
8
+ @last_cleanup = Time.now
9
+ end
10
+
11
+ def create_session(user_id, expires_in: 3600)
12
+ session = Session.new(user_id: user_id, expires_in: expires_in)
13
+ @mutex.synchronize do
14
+ @sessions[session.token] = session
15
+ cleanup_expired_sessions
16
+ end
17
+ session
18
+ end
19
+
20
+ def get_session(token)
21
+ @mutex.synchronize do
22
+ session = @sessions[token]
23
+ return nil unless session
24
+ return nil if session.expired?
25
+
26
+ session
27
+ end
28
+ end
29
+
30
+ def delete_session(token)
31
+ @mutex.synchronize do
32
+ @sessions.delete(token)
33
+ end
34
+ end
35
+
36
+ def delete_user_sessions(user_id)
37
+ @mutex.synchronize do
38
+ @sessions.delete_if { |_token, session| session.user_id == user_id }
39
+ end
40
+ end
41
+
42
+ def cleanup_expired_sessions
43
+ now = Time.now
44
+ return if (now - @last_cleanup) < @cleanup_interval
45
+
46
+ @sessions.delete_if { |_token, session| session.expired? }
47
+ @last_cleanup = now
48
+ end
49
+
50
+ def count
51
+ @mutex.synchronize do
52
+ @sessions.size
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,70 @@
1
+ require "bcrypt"
2
+ require "securerandom"
3
+
4
+ module DecisionAgent
5
+ module Auth
6
+ class User
7
+ attr_reader :id, :email, :roles, :created_at, :updated_at
8
+ attr_accessor :active
9
+
10
+ def initialize(email:, password: nil, password_hash: nil, roles: [], active: true, id: nil)
11
+ @id = id || SecureRandom.uuid
12
+ @email = email
13
+ @roles = Array(roles)
14
+ @active = active
15
+ @created_at = Time.now.utc
16
+ @updated_at = Time.now.utc
17
+
18
+ if password_hash
19
+ @password_hash = password_hash
20
+ elsif password
21
+ @password_hash = BCrypt::Password.create(password)
22
+ else
23
+ raise ArgumentError, "Either password or password_hash must be provided"
24
+ end
25
+ end
26
+
27
+ def authenticate(password)
28
+ return false unless @active
29
+
30
+ BCrypt::Password.new(@password_hash) == password
31
+ end
32
+
33
+ def assign_role(role)
34
+ role_symbol = role.to_sym
35
+ @roles << role_symbol unless @roles.include?(role_symbol)
36
+ @updated_at = Time.now.utc
37
+ end
38
+
39
+ def remove_role(role)
40
+ role_symbol = role.to_sym
41
+ @roles.delete(role_symbol)
42
+ @updated_at = Time.now.utc
43
+ end
44
+
45
+ def has_role?(role)
46
+ @roles.include?(role.to_sym)
47
+ end
48
+
49
+ def update_password(new_password)
50
+ @password_hash = BCrypt::Password.create(new_password)
51
+ @updated_at = Time.now.utc
52
+ end
53
+
54
+ def to_h
55
+ {
56
+ id: @id,
57
+ email: @email,
58
+ roles: @roles.map(&:to_s),
59
+ active: @active,
60
+ created_at: @created_at.iso8601,
61
+ updated_at: @updated_at.iso8601
62
+ }
63
+ end
64
+
65
+ def to_json(*args)
66
+ to_h.to_json(*args)
67
+ end
68
+ end
69
+ end
70
+ end
@@ -3,7 +3,9 @@ module DecisionAgent
3
3
  attr_reader :data
4
4
 
5
5
  def initialize(data)
6
- @data = deep_freeze(data.is_a?(Hash) ? data : {})
6
+ # Create a deep copy before freezing to avoid mutating the original
7
+ data_hash = data.is_a?(Hash) ? data : {}
8
+ @data = deep_freeze(deep_dup(data_hash))
7
9
  end
8
10
 
9
11
  def [](key)
@@ -28,15 +30,33 @@ module DecisionAgent
28
30
 
29
31
  private
30
32
 
31
- def deep_freeze(obj)
33
+ def deep_dup(obj)
32
34
  case obj
33
35
  when Hash
34
- obj.transform_values { |v| deep_freeze(v) }.freeze
36
+ obj.transform_values { |v| deep_dup(v) }
35
37
  when Array
36
- obj.map { |v| deep_freeze(v) }.freeze
38
+ obj.map { |v| deep_dup(v) }
37
39
  else
40
+ obj
41
+ end
42
+ end
43
+
44
+ def deep_freeze(obj)
45
+ return obj if obj.frozen?
46
+
47
+ case obj
48
+ when Hash
49
+ obj.each_value { |v| deep_freeze(v) }
50
+ obj.freeze
51
+ when Array
52
+ obj.each { |v| deep_freeze(v) }
53
+ obj.freeze
54
+ when String, Symbol, Numeric, TrueClass, FalseClass, NilClass
38
55
  obj.freeze
56
+ else
57
+ obj.freeze if obj.respond_to?(:freeze)
39
58
  end
59
+ obj
40
60
  end
41
61
  end
42
62
  end
@@ -40,14 +40,21 @@ module DecisionAgent
40
40
  end
41
41
 
42
42
  def deep_freeze(obj)
43
+ return obj if obj.frozen?
44
+
43
45
  case obj
44
46
  when Hash
45
- obj.transform_values { |v| deep_freeze(v) }.freeze
47
+ obj.each_value { |v| deep_freeze(v) }
48
+ obj.freeze
46
49
  when Array
47
- obj.map { |v| deep_freeze(v) }.freeze
48
- else
50
+ obj.each { |v| deep_freeze(v) }
49
51
  obj.freeze
52
+ when String, Symbol, Numeric, TrueClass, FalseClass, NilClass
53
+ obj.freeze
54
+ else
55
+ obj.freeze if obj.respond_to?(:freeze)
50
56
  end
57
+ obj
51
58
  end
52
59
  end
53
60
  end