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.
- checksums.yaml +7 -0
- data/.reek.yml +80 -0
- data/.vscode/extensions.json +19 -0
- data/.vscode/launch.json +35 -0
- data/.vscode/settings.json +25 -0
- data/.vscode/tasks.json +117 -0
- data/.yardopts +12 -0
- data/ARCHITECTURE.md +39 -0
- data/CHANGELOG.md +25 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +183 -0
- data/RELEASING.md +37 -0
- data/Rakefile +38 -0
- data/SECURITY.md +26 -0
- data/Steepfile +10 -0
- data/TODO.md +35 -0
- data/benchmark/builtin_calls.rb +29 -0
- data/benchmark/complex_policy.rb +19 -0
- data/benchmark/comprehensions.rb +19 -0
- data/benchmark/simple_rules.rb +20 -0
- data/examples/README.md +27 -0
- data/examples/sample_config.yaml +2 -0
- data/examples/simple_policy.rego +7 -0
- data/examples/validation_policy.rego +11 -0
- data/exe/rego-validate +6 -0
- data/lib/ruby/rego/ast/base.rb +95 -0
- data/lib/ruby/rego/ast/binary_op.rb +64 -0
- data/lib/ruby/rego/ast/call.rb +27 -0
- data/lib/ruby/rego/ast/composite.rb +48 -0
- data/lib/ruby/rego/ast/comprehension.rb +63 -0
- data/lib/ruby/rego/ast/every.rb +37 -0
- data/lib/ruby/rego/ast/import.rb +32 -0
- data/lib/ruby/rego/ast/literal.rb +70 -0
- data/lib/ruby/rego/ast/module.rb +32 -0
- data/lib/ruby/rego/ast/package.rb +22 -0
- data/lib/ruby/rego/ast/query.rb +63 -0
- data/lib/ruby/rego/ast/reference.rb +58 -0
- data/lib/ruby/rego/ast/rule.rb +114 -0
- data/lib/ruby/rego/ast/unary_op.rb +42 -0
- data/lib/ruby/rego/ast/variable.rb +22 -0
- data/lib/ruby/rego/ast.rb +17 -0
- data/lib/ruby/rego/builtins/aggregates.rb +124 -0
- data/lib/ruby/rego/builtins/base.rb +95 -0
- data/lib/ruby/rego/builtins/collections/array_ops.rb +103 -0
- data/lib/ruby/rego/builtins/collections/object_ops.rb +120 -0
- data/lib/ruby/rego/builtins/collections/set_ops.rb +51 -0
- data/lib/ruby/rego/builtins/collections.rb +137 -0
- data/lib/ruby/rego/builtins/comparisons/casts.rb +139 -0
- data/lib/ruby/rego/builtins/comparisons.rb +84 -0
- data/lib/ruby/rego/builtins/numeric_helpers.rb +56 -0
- data/lib/ruby/rego/builtins/registry.rb +199 -0
- data/lib/ruby/rego/builtins/registry_helpers.rb +27 -0
- data/lib/ruby/rego/builtins/strings/case_ops.rb +22 -0
- data/lib/ruby/rego/builtins/strings/concat.rb +19 -0
- data/lib/ruby/rego/builtins/strings/formatting.rb +35 -0
- data/lib/ruby/rego/builtins/strings/helpers.rb +62 -0
- data/lib/ruby/rego/builtins/strings/number_helpers.rb +48 -0
- data/lib/ruby/rego/builtins/strings/search.rb +63 -0
- data/lib/ruby/rego/builtins/strings/split.rb +19 -0
- data/lib/ruby/rego/builtins/strings/substring.rb +22 -0
- data/lib/ruby/rego/builtins/strings/trim.rb +42 -0
- data/lib/ruby/rego/builtins/strings/trim_helpers.rb +62 -0
- data/lib/ruby/rego/builtins/strings.rb +58 -0
- data/lib/ruby/rego/builtins/types.rb +89 -0
- data/lib/ruby/rego/call_name.rb +55 -0
- data/lib/ruby/rego/cli.rb +1122 -0
- data/lib/ruby/rego/compiled_module.rb +114 -0
- data/lib/ruby/rego/compiler.rb +1097 -0
- data/lib/ruby/rego/environment/overrides.rb +33 -0
- data/lib/ruby/rego/environment/reference_resolution.rb +86 -0
- data/lib/ruby/rego/environment.rb +230 -0
- data/lib/ruby/rego/environment_pool.rb +71 -0
- data/lib/ruby/rego/error_handling.rb +58 -0
- data/lib/ruby/rego/error_payload.rb +34 -0
- data/lib/ruby/rego/errors.rb +196 -0
- data/lib/ruby/rego/evaluator/assignment_support.rb +126 -0
- data/lib/ruby/rego/evaluator/binding_helpers.rb +60 -0
- data/lib/ruby/rego/evaluator/comprehension_evaluator.rb +182 -0
- data/lib/ruby/rego/evaluator/expression_dispatch.rb +45 -0
- data/lib/ruby/rego/evaluator/expression_evaluator.rb +492 -0
- data/lib/ruby/rego/evaluator/object_literal_evaluator.rb +52 -0
- data/lib/ruby/rego/evaluator/operator_evaluator.rb +163 -0
- data/lib/ruby/rego/evaluator/query_node_builder.rb +38 -0
- data/lib/ruby/rego/evaluator/reference_key_resolver.rb +50 -0
- data/lib/ruby/rego/evaluator/reference_resolver.rb +352 -0
- data/lib/ruby/rego/evaluator/rule_evaluator/bindings.rb +70 -0
- data/lib/ruby/rego/evaluator/rule_evaluator.rb +550 -0
- data/lib/ruby/rego/evaluator/rule_value_provider.rb +56 -0
- data/lib/ruby/rego/evaluator/variable_collector.rb +221 -0
- data/lib/ruby/rego/evaluator.rb +174 -0
- data/lib/ruby/rego/lexer/number_reader.rb +68 -0
- data/lib/ruby/rego/lexer/stream.rb +137 -0
- data/lib/ruby/rego/lexer/string_reader.rb +90 -0
- data/lib/ruby/rego/lexer/template_string_reader.rb +62 -0
- data/lib/ruby/rego/lexer.rb +206 -0
- data/lib/ruby/rego/location.rb +73 -0
- data/lib/ruby/rego/memoization.rb +67 -0
- data/lib/ruby/rego/parser/collections.rb +173 -0
- data/lib/ruby/rego/parser/expressions.rb +216 -0
- data/lib/ruby/rego/parser/precedence.rb +42 -0
- data/lib/ruby/rego/parser/query.rb +139 -0
- data/lib/ruby/rego/parser/references.rb +115 -0
- data/lib/ruby/rego/parser/rules.rb +310 -0
- data/lib/ruby/rego/parser.rb +210 -0
- data/lib/ruby/rego/policy.rb +50 -0
- data/lib/ruby/rego/result.rb +91 -0
- data/lib/ruby/rego/token.rb +206 -0
- data/lib/ruby/rego/unifier.rb +451 -0
- data/lib/ruby/rego/value.rb +379 -0
- data/lib/ruby/rego/version.rb +7 -0
- data/lib/ruby/rego/with_modifiers/with_modifier.rb +37 -0
- data/lib/ruby/rego/with_modifiers/with_modifier_applier.rb +48 -0
- data/lib/ruby/rego/with_modifiers/with_modifier_builtin_override.rb +128 -0
- data/lib/ruby/rego/with_modifiers/with_modifier_context.rb +120 -0
- data/lib/ruby/rego/with_modifiers/with_modifier_path_key_resolver.rb +42 -0
- data/lib/ruby/rego/with_modifiers/with_modifier_path_override.rb +99 -0
- data/lib/ruby/rego/with_modifiers/with_modifier_root_scope.rb +58 -0
- data/lib/ruby/rego.rb +72 -0
- data/sig/objspace.rbs +4 -0
- data/sig/psych.rbs +7 -0
- data/sig/rego_validate.rbs +382 -0
- data/sig/ruby/rego.rbs +2150 -0
- 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
|