featurevisor 3.0.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +307 -193
- data/bin/cli.rb +84 -17
- data/bin/commands/assess_distribution.rb +10 -52
- data/bin/commands/benchmark.rb +10 -48
- data/bin/commands/node_cli.rb +20 -0
- data/bin/commands/runtime_project.rb +63 -0
- data/bin/commands/test.rb +771 -259
- data/bin/commands.rb +1 -0
- data/bin/segment_tests.rb +89 -0
- data/bin/test_checks.rb +767 -0
- data/lib/featurevisor/child_instance.rb +15 -2
- data/lib/featurevisor/compare_versions.rb +18 -36
- data/lib/featurevisor/conditions.rb +91 -9
- data/lib/featurevisor/dependencies.rb +150 -0
- data/lib/featurevisor/emitter.rb +4 -0
- data/lib/featurevisor/evaluate.rb +64 -115
- data/lib/featurevisor/evaluation_data_provider.rb +100 -51
- data/lib/featurevisor/evaluation_session.rb +191 -0
- data/lib/featurevisor/events.rb +39 -100
- data/lib/featurevisor/instance.rb +258 -59
- data/lib/featurevisor/modules.rb +6 -46
- data/lib/featurevisor/mutations.rb +213 -0
- data/lib/featurevisor/normalize.rb +105 -0
- data/lib/featurevisor/unified_conditions.rb +79 -0
- data/lib/featurevisor/version.rb +1 -1
- data/lib/featurevisor.rb +7 -0
- metadata +10 -1
data/bin/test_checks.rb
ADDED
|
@@ -0,0 +1,767 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "date"
|
|
3
|
+
require_relative "segment_tests"
|
|
4
|
+
|
|
5
|
+
module FeaturevisorCLI
|
|
6
|
+
module TestChecks
|
|
7
|
+
MISSING = Object.new.freeze
|
|
8
|
+
MAX_SAFE_INTEGER = 9_007_199_254_740_991
|
|
9
|
+
UNSAFE_PATH_PARTS = %w[__proto__ prototype constructor].freeze
|
|
10
|
+
VALUELESS = %w[exists notExists isNull notNull empty notEmpty].freeze
|
|
11
|
+
OPERATORS = (VALUELESS + %w[
|
|
12
|
+
equals notEquals greaterThan greaterThanOrEquals lessThan lessThanOrEquals
|
|
13
|
+
contains notContains startsWith endsWith matches notMatches semverEquals
|
|
14
|
+
semverNotEquals semverGreaterThan semverGreaterThanOrEquals semverLessThan
|
|
15
|
+
semverLessThanOrEquals before after in notIn includes notIncludes includesAny notIncludesAny
|
|
16
|
+
]).freeze
|
|
17
|
+
|
|
18
|
+
class CheckError < StandardError; end
|
|
19
|
+
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
def evaluate(evaluator, datafile, checks, owner_variable:, diagnostics:, child_index: nil, segments: {}, context: {}, segment_matcher: nil)
|
|
23
|
+
evaluate_result(
|
|
24
|
+
evaluator,
|
|
25
|
+
datafile,
|
|
26
|
+
checks,
|
|
27
|
+
owner_variable: owner_variable,
|
|
28
|
+
diagnostics: diagnostics,
|
|
29
|
+
child_index: child_index,
|
|
30
|
+
segments: segments,
|
|
31
|
+
context: context,
|
|
32
|
+
segment_matcher: segment_matcher
|
|
33
|
+
)[:failures]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def evaluate_result(evaluator, datafile, checks, owner_variable:, diagnostics:, child_index: nil, segments: {}, context: {}, segment_matcher: nil)
|
|
37
|
+
unless checks.is_a?(Array) && !checks.empty?
|
|
38
|
+
errors = [comparison_error("checks must be a nonempty array", "valid checks", checks, child_index: child_index)]
|
|
39
|
+
return result_from_errors(errors, true)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
resolver = Resolver.new(evaluator, datafile, diagnostics, owner_variable, segments, context, segment_matcher)
|
|
43
|
+
begin
|
|
44
|
+
validate_sibling_keys(checks, "checks")
|
|
45
|
+
rescue CheckError => e
|
|
46
|
+
errors = [comparison_error(e.message, "valid checks", checks, child_index: child_index)]
|
|
47
|
+
return result_from_errors(errors, true)
|
|
48
|
+
end
|
|
49
|
+
validation_errors = checks.each_with_index.filter_map do |check, index|
|
|
50
|
+
path = "checks[#{index}]"
|
|
51
|
+
begin
|
|
52
|
+
validate_node(check, owner_variable, path)
|
|
53
|
+
nil
|
|
54
|
+
rescue CheckError => e
|
|
55
|
+
message = e.message.start_with?(path) ? e.message : "#{path}: #{e.message}"
|
|
56
|
+
comparison_error(
|
|
57
|
+
message,
|
|
58
|
+
"valid check",
|
|
59
|
+
check,
|
|
60
|
+
check_index: index,
|
|
61
|
+
check: check,
|
|
62
|
+
child_index: child_index
|
|
63
|
+
)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
unless validation_errors.empty?
|
|
67
|
+
return result_from_errors(validation_errors, true)
|
|
68
|
+
end
|
|
69
|
+
begin
|
|
70
|
+
checks.each_with_index do |check, index|
|
|
71
|
+
preflight_node(check, datafile, owner_variable, segments, "checks[#{index}]")
|
|
72
|
+
end
|
|
73
|
+
rescue CheckError => e
|
|
74
|
+
errors = [comparison_error(e.message, "valid references", checks, child_index: child_index)]
|
|
75
|
+
return result_from_errors(errors, true)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
has_error = false
|
|
79
|
+
errors = checks.each_with_index.filter_map do |check, index|
|
|
80
|
+
evaluated = []
|
|
81
|
+
begin
|
|
82
|
+
matched = evaluate_node(check, resolver, [index], evaluated)
|
|
83
|
+
unless matched
|
|
84
|
+
comparison_error(
|
|
85
|
+
format_failed_check(check, evaluated),
|
|
86
|
+
true,
|
|
87
|
+
false,
|
|
88
|
+
check_index: index,
|
|
89
|
+
check: check,
|
|
90
|
+
child_index: child_index,
|
|
91
|
+
evaluated: evaluated
|
|
92
|
+
)
|
|
93
|
+
end
|
|
94
|
+
rescue CheckError => e
|
|
95
|
+
has_error = true
|
|
96
|
+
comparison_error(
|
|
97
|
+
e.message,
|
|
98
|
+
"successful comparison",
|
|
99
|
+
e.message,
|
|
100
|
+
check_index: index,
|
|
101
|
+
check: check,
|
|
102
|
+
child_index: child_index,
|
|
103
|
+
evaluated: evaluated
|
|
104
|
+
)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
result_from_errors(errors, has_error)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def preflight!(datafile, checks, owner_variable:, segments: {})
|
|
111
|
+
raise CheckError, "checks must be a nonempty array" unless checks.is_a?(Array) && !checks.empty?
|
|
112
|
+
validate_sibling_keys(checks, "checks")
|
|
113
|
+
checks.each_with_index { |check, index| validate_node(check, owner_variable, "checks[#{index}]") }
|
|
114
|
+
checks.each_with_index do |check, index|
|
|
115
|
+
preflight_node(check, datafile, owner_variable, segments, "checks[#{index}]")
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def result_from_errors(errors, has_error)
|
|
120
|
+
{ passed: errors.empty?, error: has_error, failures: errors.map { |error| error[:message] }, errors: errors }
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def comparison_error(message, expected, actual, check_index: nil, check: nil, child_index: nil, evaluated: nil)
|
|
124
|
+
details = {}
|
|
125
|
+
details[:checkIndex] = check_index unless check_index.nil?
|
|
126
|
+
details[:checkKey] = check[:key] if check.is_a?(Hash) && check.key?(:key)
|
|
127
|
+
details[:checkDescription] = check[:description] if check.is_a?(Hash) && check.key?(:description)
|
|
128
|
+
details[:evaluated] = evaluated if evaluated
|
|
129
|
+
details[:childIndex] = child_index unless child_index.nil?
|
|
130
|
+
{ type: "check", message: message, expected: expected, actual: actual, details: details }
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def validate_sibling_keys(nodes, path)
|
|
134
|
+
seen = {}
|
|
135
|
+
nodes.each_with_index do |node, index|
|
|
136
|
+
next unless node.is_a?(Hash) && node.key?(:key)
|
|
137
|
+
key = node[:key]
|
|
138
|
+
raise CheckError, "#{path}[#{index}].key: key must be a nonempty string" unless key.is_a?(String) && !key.empty?
|
|
139
|
+
if seen.key?(key)
|
|
140
|
+
raise CheckError, "#{path}[#{index}].key: duplicate sibling key #{key.inspect}, first used at #{path}[#{seen[key]}]"
|
|
141
|
+
end
|
|
142
|
+
seen[key] = index
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def keyed(hash, key)
|
|
147
|
+
return nil unless hash.is_a?(Hash)
|
|
148
|
+
hash[key.to_sym] || hash[key.to_s]
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def validate_reference_membership(reference, datafile, owner_variable, path)
|
|
152
|
+
variable = reference[:variable] || owner_variable
|
|
153
|
+
feature = reference[:feature]
|
|
154
|
+
if feature
|
|
155
|
+
definition = keyed(keyed(datafile, :features), feature)
|
|
156
|
+
raise CheckError, "#{path}: feature #{feature.inspect} is absent from the selected datafile" unless definition.is_a?(Hash)
|
|
157
|
+
variables = keyed(definition, :variablesSchema)
|
|
158
|
+
unless keyed(variables, variable)
|
|
159
|
+
raise CheckError, "#{path}: variable #{variable.inspect} is absent from feature #{feature.inspect} in the selected datafile"
|
|
160
|
+
end
|
|
161
|
+
elsif keyed(keyed(datafile, :variables), variable).nil?
|
|
162
|
+
raise CheckError, "#{path}: global variable #{variable.inspect} is absent from the selected datafile"
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def preflight_node(node, datafile, owner_variable, segments, path)
|
|
167
|
+
if segment_check?(node)
|
|
168
|
+
definition = keyed(segments, node[:segment])
|
|
169
|
+
unless definition.is_a?(Hash) && (definition.key?(:conditions) || definition.key?("conditions"))
|
|
170
|
+
raise CheckError, "#{path}: segment #{node[:segment].inspect} definition is missing from the normalized test spec"
|
|
171
|
+
end
|
|
172
|
+
SegmentTests.references!(node[:segment], datafile, segments)
|
|
173
|
+
return
|
|
174
|
+
end
|
|
175
|
+
%i[and or not].each do |group|
|
|
176
|
+
next unless node.key?(group)
|
|
177
|
+
node[group].each_with_index do |child, index|
|
|
178
|
+
preflight_node(child, datafile, owner_variable, segments, "#{path}.#{group}[#{index}]")
|
|
179
|
+
end
|
|
180
|
+
return
|
|
181
|
+
end
|
|
182
|
+
if feature_check?(node)
|
|
183
|
+
unless keyed(keyed(datafile, :features), node[:feature]).is_a?(Hash)
|
|
184
|
+
raise CheckError, "#{path}: feature #{node[:feature].inspect} is absent from the selected datafile"
|
|
185
|
+
end
|
|
186
|
+
return
|
|
187
|
+
end
|
|
188
|
+
validate_reference_membership(node, datafile, owner_variable, path)
|
|
189
|
+
validate_reference_membership(node[:compareTo], datafile, owner_variable, "#{path}.compareTo") if node.key?(:compareTo)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def validate_node(node, owner_variable, path)
|
|
193
|
+
raise CheckError, "#{path}: check must be an object" unless node.is_a?(Hash)
|
|
194
|
+
if node.key?(:key) && (!node[:key].is_a?(String) || node[:key].empty?)
|
|
195
|
+
raise CheckError, "#{path}.key: key must be a nonempty string"
|
|
196
|
+
end
|
|
197
|
+
if node.key?(:description) && !node[:description].is_a?(String)
|
|
198
|
+
raise CheckError, "#{path}.description: description must be a string"
|
|
199
|
+
end
|
|
200
|
+
if segment_check?(node)
|
|
201
|
+
validate_segment_check(node, path)
|
|
202
|
+
return
|
|
203
|
+
end
|
|
204
|
+
groups = %i[and or not].select { |key| node.key?(key) }
|
|
205
|
+
unless groups.empty?
|
|
206
|
+
allowed = [groups.first, :key, :description]
|
|
207
|
+
unknown = node.keys.find { |key| !allowed.include?(key) }
|
|
208
|
+
unless groups.length == 1 && unknown.nil?
|
|
209
|
+
raise CheckError, "#{path}: group must contain exactly one of and, or or not plus optional key and description"
|
|
210
|
+
end
|
|
211
|
+
children = node[groups.first]
|
|
212
|
+
raise CheckError, "#{path}.#{groups.first}: group must be a nonempty array" unless children.is_a?(Array) && !children.empty?
|
|
213
|
+
validate_sibling_keys(children, "#{path}.#{groups.first}")
|
|
214
|
+
children.each_with_index { |child, index| validate_node(child, owner_variable, "#{path}.#{groups.first}[#{index}]") }
|
|
215
|
+
return
|
|
216
|
+
end
|
|
217
|
+
if feature_check?(node)
|
|
218
|
+
validate_feature_check(node, path)
|
|
219
|
+
return
|
|
220
|
+
end
|
|
221
|
+
operator = node[:operator]
|
|
222
|
+
allowed = %i[variable feature path operator value compareTo regexFlags key description]
|
|
223
|
+
unknown = node.keys.find { |key| !allowed.include?(key) }
|
|
224
|
+
raise CheckError, "#{path}: unknown check property #{unknown.inspect}" if unknown
|
|
225
|
+
raise CheckError, "#{path}: unknown check operator #{operator.inspect}" unless OPERATORS.include?(operator)
|
|
226
|
+
if node.key?(:feature) && (!node[:feature].is_a?(String) || node[:feature].empty?)
|
|
227
|
+
raise CheckError, "#{path}: feature must be a nonempty string"
|
|
228
|
+
end
|
|
229
|
+
if node.key?(:variable) && (!node[:variable].is_a?(String) || node[:variable].empty?)
|
|
230
|
+
raise CheckError, "#{path}: variable must be a nonempty string"
|
|
231
|
+
end
|
|
232
|
+
raise CheckError, "#{path}: a feature reference requires an explicit variable" if node.key?(:feature) && !node.key?(:variable)
|
|
233
|
+
raise CheckError, "#{path}: variable reference requires an explicit variable" unless node[:variable] || owner_variable
|
|
234
|
+
raise CheckError, "#{path}: path must not be null" if node.key?(:path) && node[:path].nil?
|
|
235
|
+
path_parts(node[:path])
|
|
236
|
+
supplied = (node.key?(:value) ? 1 : 0) + (node.key?(:compareTo) ? 1 : 0)
|
|
237
|
+
if VALUELESS.include?(operator)
|
|
238
|
+
raise CheckError, "#{path}: operator #{operator.inspect} accepts neither value nor compareTo" unless supplied.zero?
|
|
239
|
+
elsif supplied != 1
|
|
240
|
+
raise CheckError, "#{path}: exactly one of value or compareTo is required"
|
|
241
|
+
end
|
|
242
|
+
validate_literal(node, path) if node.key?(:value)
|
|
243
|
+
if node.key?(:regexFlags) && !%w[matches notMatches].include?(operator)
|
|
244
|
+
raise CheckError, "#{path}: regexFlags are only allowed with matches or notMatches"
|
|
245
|
+
end
|
|
246
|
+
portable_regex_flags(node[:regexFlags], allow_nil: false) if node.key?(:regexFlags)
|
|
247
|
+
return unless node.key?(:compareTo)
|
|
248
|
+
other = node[:compareTo]
|
|
249
|
+
raise CheckError, "#{path}.compareTo: reference must be an object" unless other.is_a?(Hash)
|
|
250
|
+
if other.key?(:feature) && (!other[:feature].is_a?(String) || other[:feature].empty?)
|
|
251
|
+
raise CheckError, "#{path}.compareTo: feature must be a nonempty string"
|
|
252
|
+
end
|
|
253
|
+
if other.key?(:variable) && (!other[:variable].is_a?(String) || other[:variable].empty?)
|
|
254
|
+
raise CheckError, "#{path}.compareTo: variable must be a nonempty string"
|
|
255
|
+
end
|
|
256
|
+
raise CheckError, "#{path}.compareTo: a feature reference requires an explicit variable" if other.key?(:feature) && !other.key?(:variable)
|
|
257
|
+
raise CheckError, "#{path}.compareTo: variable reference requires an explicit variable" unless other[:variable] || owner_variable
|
|
258
|
+
unknown = other.keys.find { |key| !%i[variable feature path].include?(key) }
|
|
259
|
+
raise CheckError, "#{path}.compareTo: unknown reference property #{unknown.inspect}" if unknown
|
|
260
|
+
raise CheckError, "#{path}.compareTo: path must not be null" if other.key?(:path) && other[:path].nil?
|
|
261
|
+
path_parts(other[:path])
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def feature_check?(node)
|
|
265
|
+
node.key?(:enabled) || node.key?(:variation) ||
|
|
266
|
+
(node.key?(:feature) && !node.key?(:variable) && !node.key?(:operator))
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def segment_check?(node)
|
|
270
|
+
node.key?(:segment) || node.key?(:matches)
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def validate_segment_check(node, path)
|
|
274
|
+
unknown = node.keys.find { |key| !%i[segment matches key description].include?(key) }
|
|
275
|
+
raise CheckError, "#{path}: segment check cannot contain property #{unknown.inspect}" if unknown
|
|
276
|
+
unless node[:segment].is_a?(String) && !node[:segment].empty?
|
|
277
|
+
raise CheckError, "#{path}: segment check requires a nonempty segment"
|
|
278
|
+
end
|
|
279
|
+
unless node.key?(:matches) && [true, false].include?(node[:matches])
|
|
280
|
+
raise CheckError, "#{path}: segment #{node[:segment].inspect} matches must be a boolean"
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def validate_feature_check(node, path)
|
|
285
|
+
unknown = node.keys.find { |key| !%i[feature enabled variation key description].include?(key) }
|
|
286
|
+
raise CheckError, "#{path}: feature check cannot contain property #{unknown.inspect}" if unknown
|
|
287
|
+
unless node[:feature].is_a?(String) && !node[:feature].empty?
|
|
288
|
+
raise CheckError, "#{path}: feature must be a nonempty string"
|
|
289
|
+
end
|
|
290
|
+
unless node.key?(:enabled) || node.key?(:variation)
|
|
291
|
+
raise CheckError, "#{path}: feature check requires enabled or variation"
|
|
292
|
+
end
|
|
293
|
+
if node.key?(:enabled) && ![true, false].include?(node[:enabled])
|
|
294
|
+
raise CheckError, "#{path}: enabled must be a boolean"
|
|
295
|
+
end
|
|
296
|
+
if node.key?(:variation) && !node[:variation].nil? && !node[:variation].is_a?(String)
|
|
297
|
+
raise CheckError, "#{path}: variation must be a string or null"
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def validate_literal(node, path)
|
|
302
|
+
operator = node[:operator]
|
|
303
|
+
value = node[:value]
|
|
304
|
+
numeric = %w[greaterThan greaterThanOrEquals lessThan lessThanOrEquals]
|
|
305
|
+
strings = %w[contains notContains startsWith endsWith matches notMatches semverEquals semverNotEquals semverGreaterThan semverGreaterThanOrEquals semverLessThan semverLessThanOrEquals before after]
|
|
306
|
+
if %w[equals notEquals].include?(operator) && !primitive?(value)
|
|
307
|
+
raise CheckError, "#{path}: operator #{operator.inspect} requires a scalar value"
|
|
308
|
+
elsif numeric.include?(operator) && (!value.is_a?(Numeric) || !value.finite?)
|
|
309
|
+
raise CheckError, "#{path}: operator #{operator.inspect} requires a numeric value"
|
|
310
|
+
elsif strings.include?(operator) && !value.is_a?(String)
|
|
311
|
+
raise CheckError, "#{path}: operator #{operator.inspect} requires a string value"
|
|
312
|
+
elsif %w[in notIn].include?(operator) && (!value.is_a?(Array) || !value.all? { |item| membership_primitive?(item) })
|
|
313
|
+
raise CheckError, "#{path}: operator #{operator.inspect} requires an array of string, number or null values"
|
|
314
|
+
elsif %w[includes notIncludes].include?(operator) && !membership_primitive?(value)
|
|
315
|
+
raise CheckError, "#{path}: operator #{operator.inspect} requires a scalar value"
|
|
316
|
+
elsif %w[includesAny notIncludesAny].include?(operator) && (!value.is_a?(Array) || !value.all? { |item| primitive?(item) })
|
|
317
|
+
raise CheckError, "#{path}: operator #{operator.inspect} requires an array of primitive values"
|
|
318
|
+
end
|
|
319
|
+
portable_regex(value, node[:regexFlags]) if %w[matches notMatches].include?(operator)
|
|
320
|
+
portable_datetime(value) if %w[before after].include?(operator)
|
|
321
|
+
Featurevisor.compare_versions(value, value) if operator.start_with?("semver")
|
|
322
|
+
rescue TypeError, ArgumentError => e
|
|
323
|
+
raise CheckError, e.message
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def evaluate_node(node, resolver, path, evaluated)
|
|
327
|
+
%i[and or not].each do |group|
|
|
328
|
+
next unless node.key?(group)
|
|
329
|
+
if %i[and not].include?(group)
|
|
330
|
+
node[group].each_with_index do |child, index|
|
|
331
|
+
matched = evaluate_node(child, resolver, path + [group.to_s, index], evaluated)
|
|
332
|
+
return group == :not unless matched
|
|
333
|
+
end
|
|
334
|
+
return group != :not
|
|
335
|
+
end
|
|
336
|
+
node[group].each_with_index do |child, index|
|
|
337
|
+
matched = evaluate_node(child, resolver, path + [group.to_s, index], evaluated)
|
|
338
|
+
return true if matched
|
|
339
|
+
end
|
|
340
|
+
return false
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
return evaluate_segment_check(node, resolver, path, evaluated) if segment_check?(node)
|
|
344
|
+
return evaluate_feature_check(node, resolver, path, evaluated) if feature_check?(node)
|
|
345
|
+
|
|
346
|
+
left_ref = node.select { |key, _| %i[variable feature path].include?(key) }
|
|
347
|
+
trace = {
|
|
348
|
+
path: path,
|
|
349
|
+
reference: normalized_reference(left_ref, resolver.owner_variable),
|
|
350
|
+
operator: node[:operator],
|
|
351
|
+
actual: missing_value,
|
|
352
|
+
expected: node.key?(:compareTo) ? missing_value : report_operand(node.key?(:value) ? node[:value] : MISSING)
|
|
353
|
+
}
|
|
354
|
+
trace[:key] = node[:key] if node.key?(:key)
|
|
355
|
+
trace[:description] = node[:description] if node.key?(:description)
|
|
356
|
+
if node.key?(:compareTo)
|
|
357
|
+
trace[:compareTo] = normalized_reference(node[:compareTo], resolver.owner_variable)
|
|
358
|
+
end
|
|
359
|
+
evaluated << trace
|
|
360
|
+
begin
|
|
361
|
+
left = resolver.resolve(left_ref)
|
|
362
|
+
rescue CheckError => e
|
|
363
|
+
raise CheckError, "#{format_path(path)} left operand (#{reference_label(left_ref, resolver.owner_variable)}): #{e.message}"
|
|
364
|
+
end
|
|
365
|
+
trace[:actual] = report_operand(left)
|
|
366
|
+
trace[:actualMissing] = true if left.equal?(MISSING)
|
|
367
|
+
if node.key?(:compareTo)
|
|
368
|
+
right_ref = node[:compareTo]
|
|
369
|
+
begin
|
|
370
|
+
right = resolver.resolve(right_ref)
|
|
371
|
+
rescue CheckError => e
|
|
372
|
+
raise CheckError, "#{format_path(path)} compareTo operand (#{reference_label(right_ref, resolver.owner_variable)}): #{e.message}"
|
|
373
|
+
end
|
|
374
|
+
else
|
|
375
|
+
right = node.key?(:value) ? node[:value] : MISSING
|
|
376
|
+
end
|
|
377
|
+
trace[:expected] = report_operand(right)
|
|
378
|
+
trace[:expectedMissing] = true if node.key?(:compareTo) && right.equal?(MISSING)
|
|
379
|
+
compare(node[:operator], left, right, node[:regexFlags])
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
def evaluate_feature_check(node, resolver, path, evaluated)
|
|
383
|
+
feature = node[:feature]
|
|
384
|
+
matched = true
|
|
385
|
+
if node.key?(:enabled)
|
|
386
|
+
matched = evaluate_feature_property(node, resolver, path, evaluated, :enabled) && matched
|
|
387
|
+
end
|
|
388
|
+
if node.key?(:variation)
|
|
389
|
+
matched = evaluate_feature_property(node, resolver, path, evaluated, :variation) && matched
|
|
390
|
+
end
|
|
391
|
+
matched
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
def evaluate_segment_check(node, resolver, path, evaluated)
|
|
395
|
+
trace = {
|
|
396
|
+
path: path,
|
|
397
|
+
reference: { segment: node[:segment] },
|
|
398
|
+
property: "matches",
|
|
399
|
+
operator: "equals",
|
|
400
|
+
actual: missing_value,
|
|
401
|
+
expected: node[:matches]
|
|
402
|
+
}
|
|
403
|
+
trace[:key] = node[:key] if node.key?(:key)
|
|
404
|
+
trace[:description] = node[:description] if node.key?(:description)
|
|
405
|
+
evaluated << trace
|
|
406
|
+
actual = resolver.resolve_segment(node[:segment])
|
|
407
|
+
trace[:actual] = actual
|
|
408
|
+
actual == node[:matches]
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
def evaluate_feature_property(node, resolver, path, evaluated, property)
|
|
412
|
+
trace = {
|
|
413
|
+
path: path,
|
|
414
|
+
reference: { feature: node[:feature] },
|
|
415
|
+
property: property.to_s,
|
|
416
|
+
operator: "equals",
|
|
417
|
+
actual: missing_value,
|
|
418
|
+
expected: node[property]
|
|
419
|
+
}
|
|
420
|
+
trace[:key] = node[:key] if node.key?(:key)
|
|
421
|
+
trace[:description] = node[:description] if node.key?(:description)
|
|
422
|
+
evaluated << trace
|
|
423
|
+
actual = resolver.resolve_feature(node[:feature], property)
|
|
424
|
+
trace[:actual] = report_operand(actual)
|
|
425
|
+
strict_equal(actual, node[property])
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
def normalized_reference(reference, owner_variable)
|
|
429
|
+
normalized = { variable: reference[:variable] || owner_variable }
|
|
430
|
+
normalized[:feature] = reference[:feature] if reference.key?(:feature)
|
|
431
|
+
normalized[:path] = reference[:path] if reference.key?(:path)
|
|
432
|
+
normalized.delete(:variable) if normalized[:variable].nil?
|
|
433
|
+
normalized
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
def missing_value
|
|
437
|
+
{ "$type" => "undefined" }
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
def report_operand(value)
|
|
441
|
+
value.equal?(MISSING) ? missing_value : value
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
def format_path(path)
|
|
445
|
+
path.each_with_index.map { |part, index| index.zero? ? "checks[#{part}]" : part.is_a?(Integer) ? "[#{part}]" : ".#{part}" }.join
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
def format_failed_check(check, evaluated)
|
|
449
|
+
label = check_label(format_path([evaluated.first&.dig(:path)&.first || 0]), check)
|
|
450
|
+
if evaluated.all? { |trace| trace.dig(:reference, :segment) }
|
|
451
|
+
evidence = evaluated.map do |trace|
|
|
452
|
+
"segment #{trace.dig(:reference, :segment).inspect}: expected matches=#{json(trace[:expected])}, actual matches=#{json(trace[:actual])}"
|
|
453
|
+
end
|
|
454
|
+
return "#{label}: segment expectations failed (#{evidence.join(', ')})"
|
|
455
|
+
end
|
|
456
|
+
if evaluated.all? { |trace| trace.dig(:reference, :feature) }
|
|
457
|
+
evidence = evaluated.map do |trace|
|
|
458
|
+
"expected #{trace[:property]}=#{json(trace[:expected])}, actual #{trace[:property]}=#{json(trace[:actual])}"
|
|
459
|
+
end
|
|
460
|
+
return "#{label}: feature #{evaluated.first.dig(:reference, :feature).inspect} expectations failed (#{evidence.join(', ')})"
|
|
461
|
+
end
|
|
462
|
+
if evaluated.all? { |trace| trace[:property] }
|
|
463
|
+
evidence = evaluated.map do |trace|
|
|
464
|
+
kind = trace.dig(:reference, :segment) ? "segment" : "feature"
|
|
465
|
+
reference = trace.dig(:reference, :segment) || trace.dig(:reference, :feature)
|
|
466
|
+
"#{kind} #{reference.inspect}: expected #{trace[:property]}=#{json(trace[:expected])}, actual #{trace[:property]}=#{json(trace[:actual])}"
|
|
467
|
+
end
|
|
468
|
+
return "#{label}: expectations failed (#{evidence.join(', ')})"
|
|
469
|
+
end
|
|
470
|
+
evidence = evaluated.map do |trace|
|
|
471
|
+
reference = reference_label(trace[:reference], nil)
|
|
472
|
+
"#{reference} #{trace[:operator]} failed (left=#{json(trace[:actual])}, right=#{json(trace[:expected])})"
|
|
473
|
+
end
|
|
474
|
+
"#{label}: #{evidence.join('; ')}"
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
def check_label(path, node)
|
|
478
|
+
metadata = []
|
|
479
|
+
metadata << "key=#{node[:key].inspect}" if node.key?(:key)
|
|
480
|
+
metadata << "description=#{node[:description].inspect}" if node.key?(:description)
|
|
481
|
+
metadata.empty? ? path : "#{path} [#{metadata.join(', ')}]"
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
def path_parts(path)
|
|
485
|
+
return [] if path.nil?
|
|
486
|
+
if path.is_a?(String)
|
|
487
|
+
parts = path.split(".", -1)
|
|
488
|
+
raise CheckError, "path must not contain empty dot components" if parts.any?(&:empty?)
|
|
489
|
+
parts = parts.map do |part|
|
|
490
|
+
if part.match?(/\A(?:0|[1-9]\d*)\z/)
|
|
491
|
+
index = Integer(part, 10)
|
|
492
|
+
raise CheckError, "path indices must be safe nonnegative integers" if index > MAX_SAFE_INTEGER
|
|
493
|
+
index
|
|
494
|
+
else
|
|
495
|
+
part
|
|
496
|
+
end
|
|
497
|
+
end
|
|
498
|
+
elsif path.is_a?(Array)
|
|
499
|
+
parts = path
|
|
500
|
+
else
|
|
501
|
+
raise CheckError, "path must be a dot separated string or an array"
|
|
502
|
+
end
|
|
503
|
+
parts.each do |part|
|
|
504
|
+
unless part.is_a?(String) || (part.is_a?(Integer) && part.between?(0, MAX_SAFE_INTEGER))
|
|
505
|
+
raise CheckError, "path entries must be string keys or nonnegative integer indices"
|
|
506
|
+
end
|
|
507
|
+
raise CheckError, "unsafe path segment #{part.inspect} is not allowed" if part.is_a?(String) && UNSAFE_PATH_PARTS.include?(part)
|
|
508
|
+
raise CheckError, "wildcards are not allowed in paths" if part == "*"
|
|
509
|
+
end
|
|
510
|
+
parts
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
def at_path(value, parts)
|
|
514
|
+
parts.reduce(value) do |current, part|
|
|
515
|
+
if part.is_a?(Integer)
|
|
516
|
+
return MISSING unless current.is_a?(Array) && part < current.length
|
|
517
|
+
current[part]
|
|
518
|
+
else
|
|
519
|
+
if current.is_a?(Array)
|
|
520
|
+
if part == "length"
|
|
521
|
+
current.length
|
|
522
|
+
elsif part.match?(/\A(?:0|[1-9]\d*)\z/) && part.to_i < current.length
|
|
523
|
+
current[part.to_i]
|
|
524
|
+
else
|
|
525
|
+
return MISSING
|
|
526
|
+
end
|
|
527
|
+
elsif current.is_a?(Hash)
|
|
528
|
+
key = current.key?(part) ? part : part.to_sym
|
|
529
|
+
return MISSING unless current.key?(key)
|
|
530
|
+
current[key]
|
|
531
|
+
else
|
|
532
|
+
return MISSING
|
|
533
|
+
end
|
|
534
|
+
end
|
|
535
|
+
end
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
def primitive?(value)
|
|
539
|
+
value.nil? || value.is_a?(String) || value == true || value == false ||
|
|
540
|
+
(value.is_a?(Numeric) && value.finite?)
|
|
541
|
+
end
|
|
542
|
+
|
|
543
|
+
def membership_primitive?(value)
|
|
544
|
+
value.nil? || value.is_a?(String) || (value.is_a?(Numeric) && value.finite?)
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
def strict_equal(left, right)
|
|
548
|
+
return left.nil? && right.nil? if left.nil? || right.nil?
|
|
549
|
+
return left == right if [true, false].include?(left) && [true, false].include?(right)
|
|
550
|
+
return left == right if left.is_a?(Numeric) && right.is_a?(Numeric) && left.finite? && right.finite?
|
|
551
|
+
left.is_a?(String) && right.is_a?(String) && left == right
|
|
552
|
+
end
|
|
553
|
+
|
|
554
|
+
def compare(operator, left, right, regex_flags)
|
|
555
|
+
return !left.equal?(MISSING) if operator == "exists"
|
|
556
|
+
return left.equal?(MISSING) if operator == "notExists"
|
|
557
|
+
return left.nil? if operator == "isNull"
|
|
558
|
+
return !left.equal?(MISSING) && !left.nil? if operator == "notNull"
|
|
559
|
+
if %w[empty notEmpty].include?(operator)
|
|
560
|
+
return false if left.equal?(MISSING) || left.nil?
|
|
561
|
+
unless left.is_a?(String) || left.is_a?(Array) || left.is_a?(Hash)
|
|
562
|
+
raise CheckError, "operator #{operator.inspect} requires a string, array or object operand"
|
|
563
|
+
end
|
|
564
|
+
return operator == "empty" ? left.empty? : !left.empty?
|
|
565
|
+
end
|
|
566
|
+
return false if left.equal?(MISSING) || right.equal?(MISSING)
|
|
567
|
+
if %w[equals notEquals].include?(operator)
|
|
568
|
+
raise CheckError, "operator #{operator.inspect} only supports scalar values" unless primitive?(left) && primitive?(right)
|
|
569
|
+
matched = strict_equal(left, right)
|
|
570
|
+
return operator == "equals" ? matched : !matched
|
|
571
|
+
end
|
|
572
|
+
if %w[greaterThan greaterThanOrEquals lessThan lessThanOrEquals].include?(operator)
|
|
573
|
+
unless left.is_a?(Numeric) && right.is_a?(Numeric) && ![left, right].any? { |value| [true, false].include?(value) } && left.finite? && right.finite?
|
|
574
|
+
raise CheckError, "operator #{operator.inspect} requires numeric operands"
|
|
575
|
+
end
|
|
576
|
+
return { "greaterThan" => left > right, "greaterThanOrEquals" => left >= right,
|
|
577
|
+
"lessThan" => left < right, "lessThanOrEquals" => left <= right }[operator]
|
|
578
|
+
end
|
|
579
|
+
if %w[contains notContains startsWith endsWith matches notMatches].include?(operator)
|
|
580
|
+
raise CheckError, "operator #{operator.inspect} requires string operands" unless left.is_a?(String) && right.is_a?(String)
|
|
581
|
+
return left.include?(right) if operator == "contains"
|
|
582
|
+
return !left.include?(right) if operator == "notContains"
|
|
583
|
+
return left.start_with?(right) if operator == "startsWith"
|
|
584
|
+
return left.end_with?(right) if operator == "endsWith"
|
|
585
|
+
matched = !portable_regex(right, regex_flags).match(left).nil?
|
|
586
|
+
return operator == "matches" ? matched : !matched
|
|
587
|
+
end
|
|
588
|
+
if operator.start_with?("semver")
|
|
589
|
+
raise CheckError, "operator #{operator.inspect} requires string operands" unless left.is_a?(String) && right.is_a?(String)
|
|
590
|
+
compared = Featurevisor.compare_versions(left, right)
|
|
591
|
+
return { "semverEquals" => compared == 0, "semverNotEquals" => compared != 0,
|
|
592
|
+
"semverGreaterThan" => compared == 1, "semverGreaterThanOrEquals" => compared >= 0,
|
|
593
|
+
"semverLessThan" => compared == -1, "semverLessThanOrEquals" => compared <= 0 }[operator]
|
|
594
|
+
end
|
|
595
|
+
if %w[before after].include?(operator)
|
|
596
|
+
begin
|
|
597
|
+
left_date = portable_datetime(left)
|
|
598
|
+
right_date = portable_datetime(right)
|
|
599
|
+
rescue TypeError, ArgumentError => e
|
|
600
|
+
raise CheckError, "date comparisons require valid portable timestamps: #{e.message}"
|
|
601
|
+
end
|
|
602
|
+
return operator == "before" ? left_date < right_date : left_date > right_date
|
|
603
|
+
end
|
|
604
|
+
if %w[in notIn].include?(operator)
|
|
605
|
+
unless membership_primitive?(left) && right.is_a?(Array) && right.all? { |value| membership_primitive?(value) }
|
|
606
|
+
raise CheckError, "operator #{operator.inspect} requires a scalar left operand and scalar array right operand"
|
|
607
|
+
end
|
|
608
|
+
matched = right.any? { |value| strict_equal(left, value) }
|
|
609
|
+
return operator == "in" ? matched : !matched
|
|
610
|
+
end
|
|
611
|
+
if %w[includesAny notIncludesAny].include?(operator)
|
|
612
|
+
unless left.is_a?(Array) && right.is_a?(Array) && right.all? { |value| primitive?(value) }
|
|
613
|
+
raise CheckError, "operator #{operator.inspect} requires an array left operand and primitive array right operand"
|
|
614
|
+
end
|
|
615
|
+
matched = right.any? { |candidate| left.any? { |actual| strict_equal(actual, candidate) } }
|
|
616
|
+
return operator == "includesAny" ? matched : !matched
|
|
617
|
+
end
|
|
618
|
+
if %w[includes notIncludes].include?(operator)
|
|
619
|
+
unless left.is_a?(Array) && membership_primitive?(right)
|
|
620
|
+
raise CheckError, "operator #{operator.inspect} requires an array left operand and scalar right operand"
|
|
621
|
+
end
|
|
622
|
+
matched = left.any? { |value| strict_equal(value, right) }
|
|
623
|
+
return operator == "includes" ? matched : !matched
|
|
624
|
+
end
|
|
625
|
+
raise CheckError, "unknown check operator #{operator.inspect}"
|
|
626
|
+
rescue ArgumentError => e
|
|
627
|
+
raise CheckError, e.message
|
|
628
|
+
end
|
|
629
|
+
|
|
630
|
+
def portable_regex(pattern, flags)
|
|
631
|
+
flags = portable_regex_flags(flags)
|
|
632
|
+
if pattern.match?(/\(\?/) || pattern.match?(/\\(?:[1-9]|k<|k'|g<|g')/) || pattern.match?(/(?:[?*+]|\{\d+(?:,\d*)?\})\+/)
|
|
633
|
+
raise CheckError, "regular expression uses syntax outside the portable subset"
|
|
634
|
+
end
|
|
635
|
+
native = 0
|
|
636
|
+
native |= Regexp::IGNORECASE if flags.include?("i")
|
|
637
|
+
native |= Regexp::MULTILINE if flags.include?("s")
|
|
638
|
+
Regexp.new(pattern, native)
|
|
639
|
+
rescue RegexpError => e
|
|
640
|
+
raise CheckError, "invalid regular expression: #{e.message}"
|
|
641
|
+
end
|
|
642
|
+
|
|
643
|
+
def portable_regex_flags(flags, allow_nil: true)
|
|
644
|
+
flags = "" if flags.nil? && allow_nil
|
|
645
|
+
unless flags.is_a?(String) && flags.match?(/\A[gims]*\z/) && flags.chars.uniq.length == flags.length
|
|
646
|
+
raise CheckError, "regexFlags must contain unique characters from g, i, m and s"
|
|
647
|
+
end
|
|
648
|
+
flags
|
|
649
|
+
end
|
|
650
|
+
|
|
651
|
+
def portable_datetime(value)
|
|
652
|
+
match = /\A(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,9}))?(Z|[+-]\d{2}:\d{2})\z/.match(value)
|
|
653
|
+
raise CheckError, "date comparisons require valid portable timestamps" unless match
|
|
654
|
+
|
|
655
|
+
year, month, day, hour, minute, second = match.captures.first(6).map(&:to_i)
|
|
656
|
+
unless year.between?(1, 9999) && Date.valid_date?(year, month, day) &&
|
|
657
|
+
hour.between?(0, 23) && minute.between?(0, 59) && second.between?(0, 59)
|
|
658
|
+
raise CheckError, "date comparisons require valid portable timestamps"
|
|
659
|
+
end
|
|
660
|
+
|
|
661
|
+
zone = match[8]
|
|
662
|
+
offset = 0
|
|
663
|
+
unless zone == "Z"
|
|
664
|
+
offset_hour, offset_minute = zone[1..].split(":").map(&:to_i)
|
|
665
|
+
unless offset_hour <= 14 && offset_minute <= 59 && (offset_hour < 14 || offset_minute.zero?)
|
|
666
|
+
raise CheckError, "date comparisons require valid portable timestamps"
|
|
667
|
+
end
|
|
668
|
+
offset = (offset_hour * 3_600) + (offset_minute * 60)
|
|
669
|
+
offset = -offset if zone.start_with?("-")
|
|
670
|
+
end
|
|
671
|
+
|
|
672
|
+
fraction = match[7] ? Rational(match[7][0, 3].ljust(3, "0").to_i, 1_000) : 0
|
|
673
|
+
instant = Date.new(year, month, day, Date::GREGORIAN).jd * 86_400 + hour * 3_600 + minute * 60 + second + fraction - offset
|
|
674
|
+
minimum = Date.new(1, 1, 1, Date::GREGORIAN).jd * 86_400
|
|
675
|
+
maximum = Date.new(9999, 12, 31, Date::GREGORIAN).jd * 86_400 + 86_399 + Rational(999, 1_000)
|
|
676
|
+
raise CheckError, "date comparisons require valid portable timestamps" unless instant.between?(minimum, maximum)
|
|
677
|
+
instant
|
|
678
|
+
rescue TypeError
|
|
679
|
+
raise CheckError, "date comparisons require valid portable timestamps"
|
|
680
|
+
end
|
|
681
|
+
|
|
682
|
+
def json(value)
|
|
683
|
+
return "<missing>" if value.equal?(MISSING)
|
|
684
|
+
JSON.generate(value)
|
|
685
|
+
rescue JSON::GeneratorError
|
|
686
|
+
value.inspect
|
|
687
|
+
end
|
|
688
|
+
|
|
689
|
+
def reference_label(reference, owner_variable)
|
|
690
|
+
variable = reference[:variable] || owner_variable || "<missing variable>"
|
|
691
|
+
root = reference[:feature] ? "#{reference[:feature]}.#{variable}" : variable
|
|
692
|
+
reference[:path].nil? || reference[:path] == [] ? root : "#{root}:#{json(reference[:path])}"
|
|
693
|
+
end
|
|
694
|
+
|
|
695
|
+
class Resolver
|
|
696
|
+
attr_reader :owner_variable
|
|
697
|
+
|
|
698
|
+
def initialize(evaluator, datafile, diagnostics, owner_variable, segments, context, segment_matcher)
|
|
699
|
+
@evaluator = evaluator
|
|
700
|
+
@datafile = datafile
|
|
701
|
+
@diagnostics = diagnostics
|
|
702
|
+
@owner_variable = owner_variable
|
|
703
|
+
@segments = segments.is_a?(Hash) ? segments : {}
|
|
704
|
+
@context = context.is_a?(Hash) ? context : {}
|
|
705
|
+
@segment_matcher = segment_matcher
|
|
706
|
+
end
|
|
707
|
+
|
|
708
|
+
def resolve(reference)
|
|
709
|
+
variable = reference[:variable] || @owner_variable
|
|
710
|
+
feature = reference[:feature]
|
|
711
|
+
raise CheckError, "variable reference requires a nonempty variable" unless variable.is_a?(String) && !variable.empty?
|
|
712
|
+
if feature
|
|
713
|
+
raise CheckError, "feature reference requires a nonempty feature" unless feature.is_a?(String) && !feature.empty?
|
|
714
|
+
definition = (@datafile[:features] || {})[feature.to_sym]
|
|
715
|
+
raise CheckError, "feature #{feature.inspect} is absent from the selected datafile" unless definition.is_a?(Hash)
|
|
716
|
+
unless (definition[:variablesSchema] || {}).key?(variable.to_sym)
|
|
717
|
+
raise CheckError, "variable #{variable.inspect} is absent from feature #{feature.inspect} in the selected datafile"
|
|
718
|
+
end
|
|
719
|
+
elsif !(@datafile[:variables] || {}).key?(variable.to_sym)
|
|
720
|
+
raise CheckError, "global variable #{variable.inspect} is absent from the selected datafile"
|
|
721
|
+
end
|
|
722
|
+
@diagnostics.clear
|
|
723
|
+
value = feature ? @evaluator.get_variable(feature, variable, {}) : @evaluator.get_variable(variable, {})
|
|
724
|
+
diagnostic = @diagnostics.find { |item| (item[:level] || item["level"]).to_s == "error" }
|
|
725
|
+
if diagnostic
|
|
726
|
+
raise CheckError, "variable getter reported #{diagnostic[:code] || diagnostic["code"] || "an error"}: " \
|
|
727
|
+
"#{diagnostic[:message] || diagnostic["message"] || "evaluation failed"}"
|
|
728
|
+
end
|
|
729
|
+
TestChecks.at_path(value, TestChecks.path_parts(reference[:path]))
|
|
730
|
+
rescue CheckError
|
|
731
|
+
raise
|
|
732
|
+
rescue => e
|
|
733
|
+
raise CheckError, "variable getter failed: #{e.message}"
|
|
734
|
+
end
|
|
735
|
+
|
|
736
|
+
def resolve_feature(feature, getter)
|
|
737
|
+
definition = (@datafile[:features] || {})[feature.to_sym]
|
|
738
|
+
raise CheckError, "feature #{feature.inspect} is absent from the selected datafile" unless definition.is_a?(Hash)
|
|
739
|
+
@diagnostics.clear
|
|
740
|
+
value = getter == :enabled ? @evaluator.is_enabled(feature, {}) : @evaluator.get_variation(feature, {})
|
|
741
|
+
diagnostic = @diagnostics.find { |item| (item[:level] || item["level"]).to_s == "error" }
|
|
742
|
+
if diagnostic
|
|
743
|
+
raise CheckError, "feature #{feature.inspect} #{getter} getter reported #{diagnostic[:code] || diagnostic["code"] || "an error"}: " \
|
|
744
|
+
"#{diagnostic[:message] || diagnostic["message"] || "evaluation failed"}"
|
|
745
|
+
end
|
|
746
|
+
value
|
|
747
|
+
rescue CheckError
|
|
748
|
+
raise
|
|
749
|
+
rescue => e
|
|
750
|
+
raise CheckError, "feature #{feature.inspect} #{getter} getter failed: #{e.message}"
|
|
751
|
+
end
|
|
752
|
+
|
|
753
|
+
def resolve_segment(segment)
|
|
754
|
+
definition = @segments[segment.to_sym] || @segments[segment]
|
|
755
|
+
unless definition.is_a?(Hash) && (definition.key?(:conditions) || definition.key?("conditions"))
|
|
756
|
+
raise CheckError, "segment #{segment.inspect} definition is missing from the normalized test spec"
|
|
757
|
+
end
|
|
758
|
+
raise CheckError, "segment #{segment.inspect} cannot be evaluated" unless @segment_matcher.respond_to?(:call)
|
|
759
|
+
@segment_matcher.call(segment, @context)
|
|
760
|
+
rescue CheckError
|
|
761
|
+
raise
|
|
762
|
+
rescue => e
|
|
763
|
+
raise CheckError, "segment #{segment.inspect} evaluation failed: #{e.message}"
|
|
764
|
+
end
|
|
765
|
+
end
|
|
766
|
+
end
|
|
767
|
+
end
|