ruby-rego 0.1.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 (124) hide show
  1. checksums.yaml +7 -0
  2. data/.reek.yml +80 -0
  3. data/.vscode/extensions.json +19 -0
  4. data/.vscode/launch.json +35 -0
  5. data/.vscode/settings.json +25 -0
  6. data/.vscode/tasks.json +117 -0
  7. data/.yardopts +12 -0
  8. data/ARCHITECTURE.md +39 -0
  9. data/CHANGELOG.md +25 -0
  10. data/CODE_OF_CONDUCT.md +10 -0
  11. data/LICENSE.txt +21 -0
  12. data/README.md +183 -0
  13. data/RELEASING.md +37 -0
  14. data/Rakefile +38 -0
  15. data/SECURITY.md +26 -0
  16. data/Steepfile +10 -0
  17. data/TODO.md +35 -0
  18. data/benchmark/builtin_calls.rb +29 -0
  19. data/benchmark/complex_policy.rb +19 -0
  20. data/benchmark/comprehensions.rb +19 -0
  21. data/benchmark/simple_rules.rb +20 -0
  22. data/examples/README.md +27 -0
  23. data/examples/sample_config.yaml +2 -0
  24. data/examples/simple_policy.rego +7 -0
  25. data/examples/validation_policy.rego +11 -0
  26. data/exe/rego-validate +6 -0
  27. data/lib/ruby/rego/ast/base.rb +95 -0
  28. data/lib/ruby/rego/ast/binary_op.rb +64 -0
  29. data/lib/ruby/rego/ast/call.rb +27 -0
  30. data/lib/ruby/rego/ast/composite.rb +48 -0
  31. data/lib/ruby/rego/ast/comprehension.rb +63 -0
  32. data/lib/ruby/rego/ast/every.rb +37 -0
  33. data/lib/ruby/rego/ast/import.rb +32 -0
  34. data/lib/ruby/rego/ast/literal.rb +70 -0
  35. data/lib/ruby/rego/ast/module.rb +32 -0
  36. data/lib/ruby/rego/ast/package.rb +22 -0
  37. data/lib/ruby/rego/ast/query.rb +63 -0
  38. data/lib/ruby/rego/ast/reference.rb +58 -0
  39. data/lib/ruby/rego/ast/rule.rb +114 -0
  40. data/lib/ruby/rego/ast/unary_op.rb +42 -0
  41. data/lib/ruby/rego/ast/variable.rb +22 -0
  42. data/lib/ruby/rego/ast.rb +17 -0
  43. data/lib/ruby/rego/builtins/aggregates.rb +124 -0
  44. data/lib/ruby/rego/builtins/base.rb +95 -0
  45. data/lib/ruby/rego/builtins/collections/array_ops.rb +103 -0
  46. data/lib/ruby/rego/builtins/collections/object_ops.rb +120 -0
  47. data/lib/ruby/rego/builtins/collections/set_ops.rb +51 -0
  48. data/lib/ruby/rego/builtins/collections.rb +137 -0
  49. data/lib/ruby/rego/builtins/comparisons/casts.rb +139 -0
  50. data/lib/ruby/rego/builtins/comparisons.rb +84 -0
  51. data/lib/ruby/rego/builtins/numeric_helpers.rb +56 -0
  52. data/lib/ruby/rego/builtins/registry.rb +199 -0
  53. data/lib/ruby/rego/builtins/registry_helpers.rb +27 -0
  54. data/lib/ruby/rego/builtins/strings/case_ops.rb +22 -0
  55. data/lib/ruby/rego/builtins/strings/concat.rb +19 -0
  56. data/lib/ruby/rego/builtins/strings/formatting.rb +35 -0
  57. data/lib/ruby/rego/builtins/strings/helpers.rb +62 -0
  58. data/lib/ruby/rego/builtins/strings/number_helpers.rb +48 -0
  59. data/lib/ruby/rego/builtins/strings/search.rb +63 -0
  60. data/lib/ruby/rego/builtins/strings/split.rb +19 -0
  61. data/lib/ruby/rego/builtins/strings/substring.rb +22 -0
  62. data/lib/ruby/rego/builtins/strings/trim.rb +42 -0
  63. data/lib/ruby/rego/builtins/strings/trim_helpers.rb +62 -0
  64. data/lib/ruby/rego/builtins/strings.rb +58 -0
  65. data/lib/ruby/rego/builtins/types.rb +89 -0
  66. data/lib/ruby/rego/call_name.rb +55 -0
  67. data/lib/ruby/rego/cli.rb +1122 -0
  68. data/lib/ruby/rego/compiled_module.rb +114 -0
  69. data/lib/ruby/rego/compiler.rb +1097 -0
  70. data/lib/ruby/rego/environment/overrides.rb +33 -0
  71. data/lib/ruby/rego/environment/reference_resolution.rb +86 -0
  72. data/lib/ruby/rego/environment.rb +230 -0
  73. data/lib/ruby/rego/environment_pool.rb +71 -0
  74. data/lib/ruby/rego/error_handling.rb +58 -0
  75. data/lib/ruby/rego/error_payload.rb +34 -0
  76. data/lib/ruby/rego/errors.rb +196 -0
  77. data/lib/ruby/rego/evaluator/assignment_support.rb +126 -0
  78. data/lib/ruby/rego/evaluator/binding_helpers.rb +60 -0
  79. data/lib/ruby/rego/evaluator/comprehension_evaluator.rb +182 -0
  80. data/lib/ruby/rego/evaluator/expression_dispatch.rb +45 -0
  81. data/lib/ruby/rego/evaluator/expression_evaluator.rb +492 -0
  82. data/lib/ruby/rego/evaluator/object_literal_evaluator.rb +52 -0
  83. data/lib/ruby/rego/evaluator/operator_evaluator.rb +163 -0
  84. data/lib/ruby/rego/evaluator/query_node_builder.rb +38 -0
  85. data/lib/ruby/rego/evaluator/reference_key_resolver.rb +50 -0
  86. data/lib/ruby/rego/evaluator/reference_resolver.rb +352 -0
  87. data/lib/ruby/rego/evaluator/rule_evaluator/bindings.rb +70 -0
  88. data/lib/ruby/rego/evaluator/rule_evaluator.rb +550 -0
  89. data/lib/ruby/rego/evaluator/rule_value_provider.rb +56 -0
  90. data/lib/ruby/rego/evaluator/variable_collector.rb +221 -0
  91. data/lib/ruby/rego/evaluator.rb +174 -0
  92. data/lib/ruby/rego/lexer/number_reader.rb +68 -0
  93. data/lib/ruby/rego/lexer/stream.rb +137 -0
  94. data/lib/ruby/rego/lexer/string_reader.rb +90 -0
  95. data/lib/ruby/rego/lexer/template_string_reader.rb +62 -0
  96. data/lib/ruby/rego/lexer.rb +206 -0
  97. data/lib/ruby/rego/location.rb +73 -0
  98. data/lib/ruby/rego/memoization.rb +67 -0
  99. data/lib/ruby/rego/parser/collections.rb +173 -0
  100. data/lib/ruby/rego/parser/expressions.rb +216 -0
  101. data/lib/ruby/rego/parser/precedence.rb +42 -0
  102. data/lib/ruby/rego/parser/query.rb +139 -0
  103. data/lib/ruby/rego/parser/references.rb +115 -0
  104. data/lib/ruby/rego/parser/rules.rb +310 -0
  105. data/lib/ruby/rego/parser.rb +210 -0
  106. data/lib/ruby/rego/policy.rb +50 -0
  107. data/lib/ruby/rego/result.rb +91 -0
  108. data/lib/ruby/rego/token.rb +206 -0
  109. data/lib/ruby/rego/unifier.rb +451 -0
  110. data/lib/ruby/rego/value.rb +379 -0
  111. data/lib/ruby/rego/version.rb +7 -0
  112. data/lib/ruby/rego/with_modifiers/with_modifier.rb +37 -0
  113. data/lib/ruby/rego/with_modifiers/with_modifier_applier.rb +48 -0
  114. data/lib/ruby/rego/with_modifiers/with_modifier_builtin_override.rb +128 -0
  115. data/lib/ruby/rego/with_modifiers/with_modifier_context.rb +120 -0
  116. data/lib/ruby/rego/with_modifiers/with_modifier_path_key_resolver.rb +42 -0
  117. data/lib/ruby/rego/with_modifiers/with_modifier_path_override.rb +99 -0
  118. data/lib/ruby/rego/with_modifiers/with_modifier_root_scope.rb +58 -0
  119. data/lib/ruby/rego.rb +72 -0
  120. data/sig/objspace.rbs +4 -0
  121. data/sig/psych.rbs +7 -0
  122. data/sig/rego_validate.rbs +382 -0
  123. data/sig/ruby/rego.rbs +2150 -0
  124. metadata +172 -0
@@ -0,0 +1,1097 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "ast"
4
+ require_relative "compiled_module"
5
+ require_relative "call_name"
6
+ require_relative "errors"
7
+ require_relative "environment"
8
+ require_relative "builtins/registry"
9
+ require_relative "evaluator/variable_collector"
10
+
11
+ module Ruby
12
+ # Rego compilation helpers.
13
+ module Rego
14
+ # Compiles AST modules into indexed structures for evaluation.
15
+ class Compiler # rubocop:disable Metrics/ClassLength
16
+ # Create a compiler instance.
17
+ #
18
+ # @param builtin_registry [Builtins::BuiltinRegistry] registry for builtin lookup
19
+ # @param default_rule_validator [DefaultRuleValidator, nil] override validator
20
+ def initialize(builtin_registry: Builtins::BuiltinRegistry.instance, default_rule_validator: nil)
21
+ @builtin_registry = builtin_registry
22
+ @default_rule_validator = default_rule_validator
23
+ end
24
+
25
+ # Compile an AST module into a compiled module.
26
+ #
27
+ # @param ast_module [AST::Module] parsed module
28
+ # @return [CompiledModule] compiled module
29
+ def compile(ast_module)
30
+ rules_by_name = compile_rules(ast_module)
31
+ package_path = ast_module.package.path
32
+ dependency_graph = dependency_graph_builder.build(rules_by_name, package_path)
33
+ artifacts = CompilationArtifacts.new(
34
+ rules_by_name: rules_by_name,
35
+ package_path: package_path,
36
+ dependency_graph: dependency_graph
37
+ )
38
+ CompiledModuleBuilder.build(ast_module, artifacts)
39
+ end
40
+
41
+ # Index rules by name.
42
+ #
43
+ # @param rules [Array<AST::Rule>] rules to index
44
+ # @return [Hash{String => Array<AST::Rule>}] rules indexed by name
45
+ def index_rules(rules)
46
+ rule_indexer.index(rules)
47
+ end
48
+
49
+ # Validate a rule set for conflicts.
50
+ #
51
+ # @param rules [Array<AST::Rule>, Hash{String => Array<AST::Rule>}] rules to check
52
+ # @return [void]
53
+ def check_conflicts(rules)
54
+ conflict_checker.check(rules)
55
+ end
56
+
57
+ # Validate a rule for safety (unbound variables).
58
+ #
59
+ # @param rule [AST::Rule] rule to check
60
+ # @return [void]
61
+ def check_safety(rule)
62
+ safety_checker.check_rule(rule)
63
+ end
64
+
65
+ private
66
+
67
+ # :reek:TooManyStatements
68
+ def compile_rules(ast_module)
69
+ rule_names = (rules_by_name = index_rules(ast_module.rules)).keys
70
+ imports = ast_module.imports
71
+ validate_import_aliases(imports, rule_names)
72
+ check_conflicts(rules_by_name)
73
+ validate_function_name_conflicts(rules_by_name)
74
+ safe_names = safe_names_for_imports(imports) | rule_names
75
+ safety_checker.check_rules(rules_by_name, safe_names: safe_names)
76
+ default_rule_validator.check(rules_by_name)
77
+ rules_by_name
78
+ end
79
+
80
+ def rule_indexer
81
+ @rule_indexer ||= RuleIndexer
82
+ end
83
+
84
+ def conflict_checker
85
+ @conflict_checker ||= ConflictChecker.new
86
+ end
87
+
88
+ def safety_checker
89
+ @safety_checker ||= SafetyChecker.new
90
+ end
91
+
92
+ def safe_names_for_imports(imports)
93
+ base = Environment::RESERVED_NAMES + ["_"]
94
+ import_names = Array(imports).filter_map { |import| import_alias_name(import) }
95
+ (base + import_names).uniq
96
+ end
97
+
98
+ def validate_import_aliases(imports, rule_names)
99
+ seen = {} # @type var seen: Hash[String, true]
100
+ Array(imports).each { |import| validate_import_alias(import, seen, rule_names) }
101
+ end
102
+
103
+ # rubocop:disable Metrics/MethodLength
104
+ # :reek:TooManyStatements
105
+ def validate_import_alias(import, seen, rule_names)
106
+ name = import_alias_name(import)
107
+ return unless name
108
+
109
+ location = import.location
110
+
111
+ if reserved_import_alias?(name, import)
112
+ raise CompilationError.new(
113
+ "Import alias conflicts with reserved name: #{name}",
114
+ location: location
115
+ )
116
+ end
117
+
118
+ if rule_names.include?(name)
119
+ raise CompilationError.new(
120
+ "Import alias conflicts with rule name: #{name}",
121
+ location: location
122
+ )
123
+ end
124
+
125
+ if seen.key?(name)
126
+ raise CompilationError.new(
127
+ "Duplicate import alias: #{name}",
128
+ location: location
129
+ )
130
+ end
131
+
132
+ seen[name] = true
133
+ end
134
+ # rubocop:enable Metrics/MethodLength
135
+
136
+ # :reek:UtilityFunction
137
+ def reserved_import_alias?(name, import)
138
+ reserved_names = Environment::RESERVED_NAMES + ["_"]
139
+ return false unless reserved_names.include?(name)
140
+
141
+ return false if !import.alias_name && import_path_exact?(import, name)
142
+
143
+ true
144
+ end
145
+
146
+ # :reek:UtilityFunction
147
+ def import_path_exact?(import, name)
148
+ path = import.path
149
+ return path == [name] if path.is_a?(Array)
150
+
151
+ path.to_s == name
152
+ end
153
+
154
+ # :reek:TooManyStatements
155
+ # :reek:UtilityFunction
156
+ def import_alias_name(import)
157
+ alias_name = import.alias_name
158
+ return alias_name.to_s if alias_name
159
+
160
+ path = import.path
161
+ return path.last.to_s if path.is_a?(Array) && !path.empty?
162
+ return path.to_s.split(".").last if path
163
+
164
+ nil
165
+ end
166
+
167
+ def default_rule_validator
168
+ @default_rule_validator ||= DefaultRuleValidator.new(builtin_registry: builtin_registry)
169
+ end
170
+
171
+ def dependency_graph_builder
172
+ @dependency_graph_builder ||= DependencyGraphBuilder.new
173
+ end
174
+
175
+ def validate_function_name_conflicts(rules_by_name)
176
+ rules_by_name.each do |name, rules|
177
+ rule = conflicting_function_rule(name, rules)
178
+ next unless rule
179
+
180
+ raise CompilationError.new(
181
+ "Function name conflicts with builtin: #{name}",
182
+ location: rule.location
183
+ )
184
+ end
185
+ end
186
+
187
+ def conflicting_function_rule(name, rules)
188
+ return nil unless builtin_registry.registered?(name)
189
+
190
+ rules.find(&:function?)
191
+ end
192
+
193
+ attr_reader :builtin_registry
194
+ end
195
+
196
+ # Bundles compiled module inputs.
197
+ CompilationArtifacts = Struct.new(
198
+ :rules_by_name,
199
+ :package_path,
200
+ :dependency_graph,
201
+ keyword_init: true
202
+ )
203
+
204
+ # Groups rules with the same name for conflict checks.
205
+ class RuleGroup
206
+ # Create a rule group.
207
+ #
208
+ # @param name [String] rule name
209
+ # @param rules [Array<AST::Rule>] rules sharing the name
210
+ def initialize(name:, rules:)
211
+ @name = name
212
+ @rules = rules
213
+ end
214
+
215
+ # The rule name for the group.
216
+ #
217
+ # @return [String]
218
+ attr_reader :name
219
+
220
+ # Rules in the group.
221
+ #
222
+ # @return [Array<AST::Rule>]
223
+ attr_reader :rules
224
+
225
+ # Validate the group against type and conflict rules.
226
+ #
227
+ # @param type_resolver [#type_for]
228
+ # @return [void]
229
+ def validate(type_resolver)
230
+ ensure_consistent_types(type_resolver)
231
+ ensure_function_arity
232
+ ensure_single_default
233
+ end
234
+
235
+ # Resolve the unique rule types present in the group.
236
+ #
237
+ # @param type_resolver [#type_for]
238
+ # @return [Array<Symbol, nil>]
239
+ def types(type_resolver)
240
+ rules.map { |rule| type_resolver.type_for(rule) }.uniq
241
+ end
242
+
243
+ # Select complete rules.
244
+ #
245
+ # @return [Array<AST::Rule>]
246
+ def complete_rules
247
+ rules.select(&:complete?)
248
+ end
249
+
250
+ # Select complete rules with explicit values.
251
+ #
252
+ # @return [Array<AST::Rule>]
253
+ def value_rules
254
+ complete_rules.reject(&:default_value).select do |rule|
255
+ head = rule.head
256
+ head && head[:value]
257
+ end
258
+ end
259
+
260
+ # Select function rules.
261
+ #
262
+ # @return [Array<AST::Rule>]
263
+ def function_rules
264
+ rules.select(&:function?)
265
+ end
266
+
267
+ # Select default rules.
268
+ #
269
+ # @return [Array<AST::Rule>]
270
+ def default_rules
271
+ rules.select(&:default_value)
272
+ end
273
+
274
+ private
275
+
276
+ def ensure_consistent_types(type_resolver)
277
+ types = types(type_resolver)
278
+ return if types.length <= 1
279
+
280
+ raise CompilationError.new(
281
+ "Conflicting rule types for #{name}: #{types.compact.join(", ")}",
282
+ location: rules.first.location
283
+ )
284
+ end
285
+
286
+ def ensure_complete_rule_consistency
287
+ # NOTE: Complete-rule conflicts are resolved during evaluation.
288
+ value_rule_list = value_rules
289
+ return if value_rule_list.length <= 1
290
+
291
+ raise CompilationError.new(
292
+ "Conflicting complete rules for #{name}",
293
+ location: value_rule_list.first.location
294
+ )
295
+ end
296
+
297
+ def ensure_function_arity
298
+ arities = function_arities
299
+ return if arities.length <= 1
300
+
301
+ raise CompilationError.new(
302
+ "Conflicting function arity for #{name}: #{arities.sort.join(", ")}",
303
+ location: function_rules.first.location
304
+ )
305
+ end
306
+
307
+ def ensure_single_default
308
+ defaults = default_rules
309
+ return if defaults.length <= 1
310
+
311
+ raise CompilationError.new(
312
+ "Conflicting default rules for #{name}",
313
+ location: defaults.first.location
314
+ )
315
+ end
316
+
317
+ def function_arities
318
+ rules = function_rules
319
+ return [] if rules.empty?
320
+
321
+ rules.map { |rule| Array(rule.head[:args]).length }.uniq
322
+ end
323
+ end
324
+
325
+ # Indexes rules by their name for lookup.
326
+ module RuleIndexer
327
+ # Index rules by name.
328
+ #
329
+ # @param rules [Array<AST::Rule>]
330
+ # @return [Hash{String => Array<AST::Rule>}]
331
+ def self.index(rules)
332
+ rules.group_by(&:name)
333
+ end
334
+ end
335
+
336
+ # Resolves rule types for conflict checks.
337
+ module RuleTypeResolver
338
+ # Determine the rule type for conflict checks.
339
+ #
340
+ # @param rule [AST::Rule]
341
+ # @return [Symbol, nil]
342
+ def self.type_for(rule)
343
+ return :complete if rule.complete?
344
+ return :partial_set if rule.partial_set?
345
+ return :partial_object if rule.partial_object?
346
+ return :function if rule.function?
347
+
348
+ nil
349
+ end
350
+ end
351
+
352
+ # Validates rule groups for compilation conflicts.
353
+ class ConflictChecker
354
+ # Create a conflict checker.
355
+ #
356
+ # @param indexer [#index] rule indexer
357
+ # @param type_resolver [#type_for] rule type resolver
358
+ def initialize(indexer: RuleIndexer, type_resolver: RuleTypeResolver)
359
+ @indexer = indexer
360
+ @type_resolver = type_resolver
361
+ end
362
+
363
+ # Check for conflicts in a rule set.
364
+ #
365
+ # @param rules [Array<AST::Rule>, Hash{String => Array<AST::Rule>}]
366
+ # @return [void]
367
+ def check(rules)
368
+ rule_groups(rules).each { |group| group.validate(type_resolver) }
369
+ end
370
+
371
+ private
372
+
373
+ attr_reader :indexer, :type_resolver
374
+
375
+ def rule_groups(rules)
376
+ grouped = rules.is_a?(Hash) ? rules : indexer.index(rules)
377
+ grouped.map { |name, group| RuleGroup.new(name: name, rules: group) }
378
+ end
379
+ end
380
+
381
+ # Builds compiled module instances.
382
+ module CompiledModuleBuilder
383
+ # Build a compiled module from AST and artifacts.
384
+ #
385
+ # @param ast_module [AST::Module]
386
+ # @param artifacts [CompilationArtifacts]
387
+ # @return [CompiledModule]
388
+ def self.build(ast_module, artifacts)
389
+ CompiledModule.new(
390
+ package_path: artifacts.package_path,
391
+ rules_by_name: artifacts.rules_by_name,
392
+ imports: ast_module.imports,
393
+ dependency_graph: artifacts.dependency_graph
394
+ )
395
+ end
396
+ end
397
+
398
+ # Normalizes a rule head into reusable accessors.
399
+ class RuleHead
400
+ # Create a wrapper for a rule head hash.
401
+ #
402
+ # @param head [Hash, nil] rule head data
403
+ def initialize(head)
404
+ head_hash = head.is_a?(Hash) ? head : {} # @type var head_hash: Hash[Symbol, untyped]
405
+ @head = head_hash
406
+ end
407
+
408
+ # Return the rule head type.
409
+ #
410
+ # @return [Symbol, nil]
411
+ def type
412
+ head[:type]
413
+ end
414
+
415
+ # Return AST nodes that appear in the rule head.
416
+ #
417
+ # @return [Array<AST::Base>]
418
+ def nodes
419
+ return value_nodes if type == :complete
420
+ return [head[:term]].compact if type == :partial_set
421
+ return [head[:key], head[:value]].compact if type == :partial_object
422
+ return function_nodes if type == :function
423
+
424
+ []
425
+ end
426
+
427
+ # Return function argument names for a function rule head.
428
+ #
429
+ # @return [Array<String>]
430
+ def function_arg_names
431
+ return [] unless type == :function
432
+
433
+ function_arg_nodes.filter_map { |arg| arg.is_a?(AST::Variable) ? arg.name : nil }
434
+ end
435
+
436
+ private
437
+
438
+ attr_reader :head
439
+
440
+ def value_nodes
441
+ value = head[:value]
442
+ value ? [value] : []
443
+ end
444
+
445
+ def function_nodes
446
+ args = Array(head[:args]).compact
447
+ value = head[:value]
448
+ value ? args + [value] : args
449
+ end
450
+
451
+ def function_arg_nodes
452
+ args = head[:args]
453
+ args.is_a?(Array) ? args : []
454
+ end
455
+ end
456
+
457
+ # Validates rule safety for unbound variables.
458
+ class SafetyChecker
459
+ # Create a safety checker.
460
+ #
461
+ # @param bound_collector [Evaluator::BoundVariableCollector]
462
+ # @param variable_collector_class [Class]
463
+ # @param safe_names [Array<String>]
464
+ def initialize(
465
+ bound_collector: Evaluator::BoundVariableCollector.new,
466
+ variable_collector_class: Evaluator::VariableCollector,
467
+ safe_names: Environment::RESERVED_NAMES + ["_"]
468
+ )
469
+ @bound_collector = bound_collector
470
+ @variable_collector_class = variable_collector_class
471
+ @safe_names = safe_names
472
+ end
473
+
474
+ # Validate all rules in the provided index.
475
+ #
476
+ # @param rules_by_name [Hash{String => Array<AST::Rule>}]
477
+ # @return [void]
478
+ def check_rules(rules_by_name, safe_names: @safe_names)
479
+ rules_by_name.values.flatten.each { |rule| check_rule(rule, safe_names: safe_names) }
480
+ end
481
+
482
+ # Validate a single rule for unbound variables.
483
+ #
484
+ # @param rule [AST::Rule]
485
+ # @return [void]
486
+ def check_rule(rule, safe_names: @safe_names)
487
+ context = RuleSafetyContext.new(
488
+ head: RuleHead.new(rule.head),
489
+ bound_collector: bound_collector,
490
+ variable_collector_class: variable_collector_class,
491
+ safe_names: safe_names
492
+ )
493
+ RuleSafety.new(rule: rule, context: context).check
494
+ end
495
+
496
+ private
497
+
498
+ attr_reader :bound_collector, :variable_collector_class, :safe_names
499
+ end
500
+
501
+ DEFAULT_RULE_CHILD_NODE_EXTRACTORS = {
502
+ AST::BinaryOp => ->(node) { [node.left, node.right] },
503
+ AST::UnaryOp => ->(node) { [node.operand] },
504
+ AST::ArrayLiteral => :elements.to_proc,
505
+ AST::SetLiteral => :elements.to_proc,
506
+ AST::ObjectLiteral => lambda do |node|
507
+ node.pairs.flat_map { |key_node, value_node| [key_node, value_node] }
508
+ end,
509
+ AST::ArrayComprehension => lambda do |node|
510
+ term = node.term
511
+ body = Array(node.body)
512
+ [term] + body
513
+ end,
514
+ AST::SetComprehension => lambda do |node|
515
+ term = node.term
516
+ body = Array(node.body)
517
+ [term] + body
518
+ end,
519
+ AST::ObjectComprehension => lambda do |node|
520
+ key_node, value_node = node.term
521
+ body = Array(node.body)
522
+ [key_node, value_node] + body
523
+ end,
524
+ AST::Call => ->(node) { [node.name] + node.args },
525
+ AST::QueryLiteral => ->(node) { [node.expression] + node.with_modifiers },
526
+ AST::Every => lambda do |node|
527
+ body = Array(node.body)
528
+ [node.key_var, node.value_var, node.domain] + body
529
+ end,
530
+ AST::SomeDecl => ->(node) { node.variables + Array(node.collection) },
531
+ AST::WithModifier => ->(node) { [node.target, node.value] },
532
+ AST::TemplateString => :parts.to_proc
533
+ }.freeze
534
+
535
+ # Resolves builtin call names for default rule validation.
536
+ module DefaultRuleCallName
537
+ module_function
538
+
539
+ def call_name(node)
540
+ CallName.call_name(node)
541
+ end
542
+
543
+ def reference_call_name(reference)
544
+ CallName.reference_call_name(reference)
545
+ end
546
+
547
+ def reference_base_name(reference)
548
+ CallName.reference_base_name(reference)
549
+ end
550
+ private_class_method :reference_base_name
551
+
552
+ def reference_call_segments(path)
553
+ CallName.reference_call_segments(path)
554
+ end
555
+ private_class_method :reference_call_segments
556
+
557
+ def dot_ref_segment_value(segment)
558
+ CallName.dot_ref_segment_value(segment)
559
+ end
560
+ private_class_method :dot_ref_segment_value
561
+ end
562
+
563
+ # Validates default rule values for groundness.
564
+ class DefaultRuleValidator
565
+ def initialize(
566
+ child_node_extractors: DEFAULT_RULE_CHILD_NODE_EXTRACTORS,
567
+ builtin_registry: Builtins::BuiltinRegistry.instance
568
+ )
569
+ @child_node_extractors = child_node_extractors
570
+ _ = builtin_registry
571
+ end
572
+
573
+ # :reek:TooManyStatements
574
+ def check(rules_by_name)
575
+ Array(rules_by_name.values).flatten.each do |rule|
576
+ value = rule.default_value
577
+ next unless value
578
+ next if comprehension_value?(value)
579
+ next unless contains_variable_or_reference?(value)
580
+
581
+ raise CompilationError.new(
582
+ "Default rule values must be ground (no variables, references, or calls)",
583
+ location: rule.location
584
+ )
585
+ end
586
+ end
587
+
588
+ private
589
+
590
+ attr_reader :child_node_extractors
591
+
592
+ # :reek:TooManyStatements
593
+ def contains_variable_or_reference?(node)
594
+ return false unless node
595
+ return true if node.is_a?(AST::Variable)
596
+ return true if node.is_a?(AST::Reference)
597
+ return true if node.is_a?(AST::Call)
598
+ return false if comprehension_value?(node)
599
+
600
+ child_nodes(node).any? do |child|
601
+ contains_variable_or_reference?(child)
602
+ end
603
+ end
604
+
605
+ # :reek:UtilityFunction
606
+ def comprehension_value?(value)
607
+ value.is_a?(AST::ArrayComprehension) ||
608
+ value.is_a?(AST::SetComprehension) ||
609
+ value.is_a?(AST::ObjectComprehension)
610
+ end
611
+
612
+ def child_nodes(node)
613
+ extractor = child_node_extractors[node.class]
614
+ return [] unless extractor
615
+
616
+ extractor.call(node)
617
+ end
618
+ end
619
+
620
+ # Bundles dependencies for rule safety checks.
621
+ RuleSafetyContext = Struct.new(
622
+ :head,
623
+ :bound_collector,
624
+ :variable_collector_class,
625
+ :safe_names,
626
+ keyword_init: true
627
+ )
628
+
629
+ # Represents a safety check section.
630
+ RuleSafetySection = Struct.new(:body, :head_nodes, keyword_init: true)
631
+
632
+ # Runs safety checks for a single rule.
633
+ class RuleSafety
634
+ # Create a safety checker for a specific rule.
635
+ #
636
+ # @param rule [AST::Rule]
637
+ # @param context [RuleSafetyContext]
638
+ def initialize(rule:, context:)
639
+ @rule = rule
640
+ @context = context
641
+ end
642
+
643
+ # Perform safety checks on the rule body and else clause.
644
+ #
645
+ # @return [void]
646
+ def check
647
+ check_body
648
+ check_else_clause
649
+ end
650
+
651
+ private
652
+
653
+ attr_reader :rule, :context
654
+
655
+ def head
656
+ context.head
657
+ end
658
+
659
+ def bound_collector
660
+ context.bound_collector
661
+ end
662
+
663
+ def variable_collector_class
664
+ context.variable_collector_class
665
+ end
666
+
667
+ def safe_names
668
+ context.safe_names
669
+ end
670
+
671
+ def check_body
672
+ check_section(RuleSafetySection.new(body: rule.body, head_nodes: head.nodes))
673
+ end
674
+
675
+ def check_else_clause
676
+ section = else_section
677
+ return unless section
678
+
679
+ check_section(section)
680
+ end
681
+
682
+ def else_section
683
+ clause = rule.else_clause
684
+ return unless clause
685
+
686
+ RuleSafetySection.new(body: clause[:body], head_nodes: else_nodes(clause))
687
+ end
688
+
689
+ def else_nodes(clause)
690
+ nodes = head.nodes
691
+ else_value = clause[:value]
692
+ else_value ? nodes + [else_value] : nodes
693
+ end
694
+
695
+ def check_section(section)
696
+ unbound = unbound_variables(section)
697
+ return if unbound.empty?
698
+
699
+ raise CompilationError.new(error_message(unbound), location: rule.location)
700
+ end
701
+
702
+ def unbound_variables(section)
703
+ referenced_names(section) - bound_variables(section.body) - safe_names
704
+ end
705
+
706
+ def bound_variables(body)
707
+ bound = bound_collector.collect_details(Array(body))[:all]
708
+ bound.concat(head.function_arg_names)
709
+ bound.uniq
710
+ end
711
+
712
+ def referenced_names(section)
713
+ names = variable_collector_class.new.collect_literals(Array(section.body))
714
+ Array(section.head_nodes).compact.each do |node|
715
+ names.concat(variable_collector_class.new.collect(node))
716
+ end
717
+ names.uniq
718
+ end
719
+
720
+ def error_message(unbound)
721
+ "Unsafe rule #{rule.name}: unbound variables #{unbound.sort.join(", ")}"
722
+ end
723
+ end
724
+
725
+ # Captures rule metadata for dependency resolution.
726
+ class DependencyContext
727
+ # Create a dependency context.
728
+ #
729
+ # @param rule_names [Array<String>] known rule names
730
+ # @param package_path [Array<String>] module package path
731
+ def initialize(rule_names:, package_path:)
732
+ @rule_names = rule_names
733
+ @package_path = package_path
734
+ end
735
+
736
+ # Rule names to resolve.
737
+ #
738
+ # @return [Array<String>]
739
+ attr_reader :rule_names
740
+
741
+ # Package path for the module.
742
+ #
743
+ # @return [Array<String>]
744
+ attr_reader :package_path
745
+
746
+ # Package path depth.
747
+ #
748
+ # @return [Integer]
749
+ def package_depth
750
+ @package_depth ||= package_path.length
751
+ end
752
+
753
+ # Check whether a reference path matches the package prefix.
754
+ #
755
+ # @param keys [Array<String>]
756
+ # @return [Boolean]
757
+ def package_match?(keys)
758
+ keys.length > package_depth && keys[0, package_depth] == package_path
759
+ end
760
+
761
+ # Resolve a rule name from a reference key path.
762
+ #
763
+ # @param keys [Array<String>]
764
+ # @return [String, nil]
765
+ def resolve_rule_name(keys)
766
+ package_candidate = package_candidate(keys)
767
+ return package_candidate if package_candidate
768
+
769
+ direct_candidate(keys)
770
+ end
771
+
772
+ private
773
+
774
+ def package_candidate(keys)
775
+ return nil unless package_match?(keys)
776
+
777
+ rule_name_for(keys[package_depth])
778
+ end
779
+
780
+ def direct_candidate(keys)
781
+ rule_name_for(keys.first)
782
+ end
783
+
784
+ def rule_name_for(value)
785
+ candidate = value.to_s
786
+ rule_names.include?(candidate) ? candidate : nil
787
+ end
788
+ end
789
+
790
+ # Builds dependency graphs for compiled modules.
791
+ class DependencyGraphBuilder
792
+ # Create a dependency graph builder.
793
+ #
794
+ # @param extractor [RuleDependencyExtractor]
795
+ def initialize(extractor: RuleDependencyExtractor.new)
796
+ @extractor = extractor
797
+ end
798
+
799
+ # Build a dependency graph for a compiled module.
800
+ #
801
+ # @param rules_by_name [Hash{String => Array<AST::Rule>}]
802
+ # @param package_path [Array<String>]
803
+ # @return [Hash{String => Array<String>}]
804
+ def build(rules_by_name, package_path)
805
+ context = DependencyContext.new(rule_names: rules_by_name.keys, package_path: package_path)
806
+ DependencyGraph.new(rules_by_name: rules_by_name, context: context, extractor: extractor).build
807
+ end
808
+
809
+ private
810
+
811
+ attr_reader :extractor
812
+ end
813
+
814
+ # Computes dependencies for each rule group.
815
+ class DependencyGraph
816
+ # Create a dependency graph for a module.
817
+ #
818
+ # @param rules_by_name [Hash{String => Array<AST::Rule>}]
819
+ # @param context [DependencyContext]
820
+ # @param extractor [RuleDependencyExtractor]
821
+ def initialize(rules_by_name:, context:, extractor:)
822
+ @rules_by_name = rules_by_name
823
+ @context = context
824
+ @extractor = extractor
825
+ end
826
+
827
+ # Build the dependency graph.
828
+ #
829
+ # @return [Hash{String => Array<String>}]
830
+ def build
831
+ rules_by_name.transform_values { |rules| dependencies_for(rules) }
832
+ end
833
+
834
+ private
835
+
836
+ attr_reader :rules_by_name, :context, :extractor
837
+
838
+ def dependencies_for(rules)
839
+ rules.flat_map { |rule| extractor.dependencies_for(rule, context) }.uniq
840
+ end
841
+ end
842
+
843
+ # Extracts dependency names for a rule.
844
+ class RuleDependencyExtractor
845
+ # Create a dependency extractor.
846
+ #
847
+ # @param reference_walker [ReferenceWalker]
848
+ # @param resolver [RuleReferenceResolver]
849
+ # @param node_extractor_class [Class]
850
+ def initialize(
851
+ reference_walker: ReferenceWalker.new,
852
+ resolver: RuleReferenceResolver.new,
853
+ node_extractor_class: RuleNodeExtractor
854
+ )
855
+ @reference_walker = reference_walker
856
+ @resolver = resolver
857
+ @node_extractor_class = node_extractor_class
858
+ end
859
+
860
+ # Extract referenced rule names for a rule.
861
+ #
862
+ # @param rule [AST::Rule]
863
+ # @param context [DependencyContext]
864
+ # @return [Array<String>]
865
+ def dependencies_for(rule, context)
866
+ nodes = node_extractor_class.new(rule).nodes
867
+ reference_walker
868
+ .references(nodes)
869
+ .filter_map { |ref| resolver.resolve(ref, context) }
870
+ .uniq
871
+ end
872
+
873
+ private
874
+
875
+ attr_reader :reference_walker, :resolver, :node_extractor_class
876
+ end
877
+
878
+ # Collects AST nodes to analyze rule dependencies.
879
+ class RuleNodeExtractor
880
+ # Create a node extractor for a rule.
881
+ #
882
+ # @param rule [AST::Rule]
883
+ def initialize(rule)
884
+ @rule = rule
885
+ @else_clause = rule.else_clause
886
+ end
887
+
888
+ # Gather nodes that contribute to dependency analysis.
889
+ #
890
+ # @return [Array<AST::Base>]
891
+ def nodes
892
+ base_nodes + else_nodes
893
+ end
894
+
895
+ private
896
+
897
+ attr_reader :rule, :else_clause
898
+
899
+ def base_nodes
900
+ RuleHead.new(rule.head).nodes + Array(rule.body)
901
+ end
902
+
903
+ def else_nodes
904
+ return [] unless else_clause
905
+
906
+ nodes = Array(else_clause[:body])
907
+ else_value = else_clause[:value]
908
+ else_value ? nodes + [else_value] : nodes
909
+ end
910
+ end
911
+
912
+ # Walks AST nodes and yields reference nodes.
913
+ class ReferenceWalker
914
+ NODE_CHILDREN = {
915
+ AST::Reference => ->(node) { [node.base] + node.path.map(&:value) },
916
+ AST::BinaryOp => ->(node) { [node.left, node.right] },
917
+ AST::UnaryOp => ->(node) { [node.operand] },
918
+ AST::ArrayLiteral => :elements.to_proc,
919
+ AST::SetLiteral => :elements.to_proc,
920
+ AST::ObjectLiteral => lambda do |node|
921
+ node.pairs.flat_map { |key_node, value_node| [key_node, value_node] }
922
+ end,
923
+ AST::ArrayComprehension => ->(node) { [node.term] + Array(node.body) },
924
+ AST::SetComprehension => ->(node) { [node.term] + Array(node.body) },
925
+ AST::ObjectComprehension => lambda do |node|
926
+ key_node, value_node = node.term
927
+ [key_node, value_node] + Array(node.body)
928
+ end,
929
+ AST::QueryLiteral => lambda do |node|
930
+ modifier_nodes = node.with_modifiers.flat_map { |modifier| [modifier.target, modifier.value] }
931
+ [node.expression] + modifier_nodes
932
+ end,
933
+ AST::WithModifier => ->(node) { [node.target, node.value] },
934
+ AST::SomeDecl => ->(node) { Array(node.variables) + [node.collection].compact },
935
+ AST::Every => lambda do |node|
936
+ [node.key_var, node.value_var, node.domain].compact + Array(node.body)
937
+ end,
938
+ AST::Call => ->(node) { [node.name] + node.args }
939
+ }.freeze
940
+ NODE_HANDLERS = {
941
+ AST::Reference => lambda do |node, walker, &block|
942
+ block.call(node)
943
+ walker.walk_children(node, &block)
944
+ end
945
+ }.freeze
946
+
947
+ # Create a reference walker.
948
+ #
949
+ # @param children_extractors [Hash{Class => Proc}]
950
+ # @param handlers [Hash{Class => Proc}]
951
+ def initialize(children_extractors: NODE_CHILDREN, handlers: NODE_HANDLERS)
952
+ @children_extractors = children_extractors
953
+ @handlers = handlers
954
+ end
955
+
956
+ # Collect references from a set of nodes.
957
+ #
958
+ # @param nodes [Array<AST::Base>]
959
+ # @return [Array<AST::Reference>]
960
+ def references(nodes)
961
+ refs = [] # @type var refs: Array[AST::Reference]
962
+ each_reference(nodes) { |ref| refs << ref }
963
+ refs
964
+ end
965
+
966
+ # Yield each reference found in the nodes.
967
+ #
968
+ # @param nodes [Array<AST::Base>]
969
+ # @yieldparam reference [AST::Reference]
970
+ # @return [Enumerator, void]
971
+ def each_reference(nodes, &block)
972
+ return enum_for(:each_reference, nodes) unless block
973
+
974
+ Array(nodes).each { |node| walk(node, &block) }
975
+ end
976
+
977
+ # Walk child nodes of a node.
978
+ #
979
+ # @param node [AST::Base]
980
+ # @yieldparam reference [AST::Reference]
981
+ # @return [void]
982
+ def walk_children(node, &block)
983
+ children_for(node).each { |child| walk(child, &block) }
984
+ end
985
+
986
+ private
987
+
988
+ attr_reader :children_extractors, :handlers
989
+
990
+ def walk(node, &)
991
+ handler = handlers[node.class]
992
+ return handler.call(node, self, &) if handler
993
+
994
+ walk_children(node, &)
995
+ end
996
+
997
+ def children_for(node)
998
+ extractor = children_extractors[node.class]
999
+ return [] unless extractor
1000
+
1001
+ Array(extractor.call(node))
1002
+ end
1003
+ end
1004
+
1005
+ # Resolves rule names referenced via data paths.
1006
+ class RuleReferenceResolver
1007
+ # Create a reference resolver.
1008
+ #
1009
+ # @param key_extractor [ReferenceKeyExtractor]
1010
+ # @param data_root [String]
1011
+ def initialize(key_extractor: ReferenceKeyExtractor.new, data_root: "data")
1012
+ @key_extractor = key_extractor
1013
+ @data_root = data_root
1014
+ end
1015
+
1016
+ # Resolve a rule name from a reference node.
1017
+ #
1018
+ # @param ref [AST::Reference]
1019
+ # @param context [DependencyContext]
1020
+ # @return [String, nil]
1021
+ def resolve(ref, context)
1022
+ base = ref.base
1023
+ return nil unless base.is_a?(AST::Variable) && base.name == data_root
1024
+
1025
+ keys = reference_keys(ref.path)
1026
+ return nil if keys.empty?
1027
+
1028
+ context.resolve_rule_name(keys)
1029
+ end
1030
+
1031
+ private
1032
+
1033
+ attr_reader :key_extractor, :data_root
1034
+
1035
+ def reference_keys(path)
1036
+ keys = path.map { |segment| key_extractor.extract(segment) }
1037
+ keys.any?(&:nil?) ? [] : keys
1038
+ end
1039
+ end
1040
+
1041
+ # Extracts scalar keys from reference segments.
1042
+ class ReferenceKeyExtractor
1043
+ DEFAULT_EXTRACTORS = {
1044
+ AST::DotRefArg => ->(segment, extractor) { extractor.extract(segment.value) },
1045
+ AST::BracketRefArg => ->(segment, extractor) { extractor.extract(segment.value) },
1046
+ AST::StringLiteral => ->(segment, _extractor) { segment.value },
1047
+ AST::NumberLiteral => ->(segment, _extractor) { segment.value },
1048
+ AST::BooleanLiteral => ->(segment, _extractor) { segment.value },
1049
+ AST::NullLiteral => ->(_segment, _extractor) {},
1050
+ String => ->(segment, _extractor) { segment },
1051
+ Symbol => ->(segment, _extractor) { segment }
1052
+ }.freeze
1053
+
1054
+ # Create a key extractor.
1055
+ #
1056
+ # @param extractors [Hash{Class => Proc}]
1057
+ def initialize(extractors: DEFAULT_EXTRACTORS)
1058
+ @extractors = extractors
1059
+ end
1060
+
1061
+ # Extract a scalar key from a reference segment.
1062
+ #
1063
+ # @param segment [Object]
1064
+ # @return [Object, nil]
1065
+ def extract(segment)
1066
+ extractor = extractors[segment.class]
1067
+ return nil unless extractor
1068
+
1069
+ extractor.call(segment, self)
1070
+ end
1071
+
1072
+ private
1073
+
1074
+ attr_reader :extractors
1075
+ end
1076
+
1077
+ private_constant :RuleGroup,
1078
+ :RuleIndexer,
1079
+ :RuleTypeResolver,
1080
+ :ConflictChecker,
1081
+ :CompilationArtifacts,
1082
+ :CompiledModuleBuilder,
1083
+ :RuleHead,
1084
+ :SafetyChecker,
1085
+ :RuleSafetyContext,
1086
+ :RuleSafetySection,
1087
+ :RuleSafety,
1088
+ :DependencyContext,
1089
+ :DependencyGraphBuilder,
1090
+ :DependencyGraph,
1091
+ :RuleDependencyExtractor,
1092
+ :RuleNodeExtractor,
1093
+ :ReferenceWalker,
1094
+ :RuleReferenceResolver,
1095
+ :ReferenceKeyExtractor
1096
+ end
1097
+ end