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.
@@ -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