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,213 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Featurevisor
4
+ module Mutations
5
+ DANGEROUS_KEYS = %w[__proto__ prototype constructor].freeze
6
+ OPERATIONS = %w[set append prepend appendUnique removeValue before after remove].freeze
7
+ MAX_SAFE_INTEGER = 9_007_199_254_740_991
8
+ private_constant :DANGEROUS_KEYS, :OPERATIONS, :MAX_SAFE_INTEGER
9
+
10
+ def self.apply(value, operations)
11
+ raise ArgumentError, "Invalid variable mutation operations" unless operations.is_a?(Array)
12
+
13
+ operations.reduce(value) do |current, operation|
14
+ validate(operation)
15
+ update_at_path(current, operation, 0)
16
+ end
17
+ end
18
+
19
+ def self.validate(operation)
20
+ unless operation.is_a?(Hash) && operation.key?(:op) && operation.key?(:path)
21
+ raise ArgumentError, "Invalid variable mutation operation"
22
+ end
23
+ op = operation[:op]
24
+ raise ArgumentError, "Invalid variable mutation operation" unless OPERATIONS.include?(op)
25
+ if op != "remove" && !operation.key?(:value)
26
+ raise ArgumentError, "Variable mutation operation requires a value"
27
+ end
28
+ if %w[appendUnique removeValue].include?(op)
29
+ value = operation[:value]
30
+ unless primitive?(value) && (!value.is_a?(Numeric) || !value.respond_to?(:finite?) || value.finite?)
31
+ raise ArgumentError, "Variable mutation operation requires a primitive value"
32
+ end
33
+ end
34
+ raise ArgumentError, "Invalid variable mutation path" unless operation[:path].is_a?(Array)
35
+
36
+ operation[:path].each do |part|
37
+ key = if part.is_a?(String)
38
+ part
39
+ elsif !array_index(part).nil?
40
+ nil
41
+ else
42
+ select = selector(part)
43
+ raise ArgumentError, "Invalid variable mutation path" unless select
44
+ select[:property]
45
+ end
46
+ raise ArgumentError, "Unsafe variable mutation key" if key && DANGEROUS_KEYS.include?(key)
47
+ end
48
+ end
49
+ private_class_method :validate
50
+
51
+ def self.primitive?(value)
52
+ value.nil? || value.is_a?(String) || value.is_a?(Numeric) || value == true || value == false
53
+ end
54
+ private_class_method :primitive?
55
+
56
+ def self.same?(left, right)
57
+ return true if left.nil? && right.nil?
58
+ return left == right if left.is_a?(Numeric) && right.is_a?(Numeric)
59
+ return left == right if left.is_a?(String) && right.is_a?(String)
60
+ return left == right if [true, false].include?(left) && [true, false].include?(right)
61
+
62
+ left.equal?(right)
63
+ end
64
+ private_class_method :same?
65
+
66
+ def self.array_index(value)
67
+ return nil unless value.is_a?(Numeric)
68
+ return nil if value.respond_to?(:finite?) && !value.finite?
69
+ return nil unless value >= 0 && value <= MAX_SAFE_INTEGER && value.to_i == value
70
+
71
+ value.to_i
72
+ end
73
+ private_class_method :array_index
74
+
75
+ def self.selector(part)
76
+ return nil unless part.is_a?(Hash)
77
+ select = part[:select] || part["select"]
78
+ return nil unless select.is_a?(Hash)
79
+ property = select[:property] || select["property"]
80
+ values = select[:values] || select["values"]
81
+ return nil unless property.is_a?(String) && values.is_a?(Array) && values.all? { |value| primitive?(value) }
82
+
83
+ { property: property, values: values }
84
+ end
85
+ private_class_method :selector
86
+
87
+ def self.hash_key(hash, key)
88
+ return key if hash.key?(key)
89
+ symbol = key.to_sym
90
+ hash.key?(symbol) ? symbol : symbol
91
+ end
92
+ private_class_method :hash_key
93
+
94
+ def self.selected_index(array, select)
95
+ array.each_with_index do |candidate, index|
96
+ next unless candidate.is_a?(Hash)
97
+ key = hash_key(candidate, select[:property])
98
+ next unless candidate.key?(key)
99
+ value = candidate[key]
100
+ return index if primitive?(value) && select[:values].any? { |expected| same?(value, expected) }
101
+ end
102
+ -1
103
+ end
104
+ private_class_method :selected_index
105
+
106
+ def self.path_key(container, part)
107
+ if part.is_a?(String)
108
+ raise ArgumentError, "Variable mutation expected an object" unless container.is_a?(Hash)
109
+ return hash_key(container, part)
110
+ end
111
+ raise ArgumentError, "Variable mutation expected an array" unless container.is_a?(Array)
112
+
113
+ index = array_index(part)
114
+ return index unless index.nil?
115
+
116
+ select = selector(part)
117
+ raise ArgumentError, "Invalid variable mutation path" unless select
118
+
119
+ selected_index(container, select)
120
+ end
121
+ private_class_method :path_key
122
+
123
+ def self.exists?(container, key)
124
+ container.is_a?(Hash) ? container.key?(key) : key >= 0 && key < container.length
125
+ end
126
+ private_class_method :exists?
127
+
128
+ def self.replace(container, key, value)
129
+ result = container.dup
130
+ if result.is_a?(Array) && key == result.length
131
+ result << value
132
+ else
133
+ result[key] = value
134
+ end
135
+ result
136
+ end
137
+ private_class_method :replace
138
+
139
+ def self.update_array(array, op, item)
140
+ if op == "removeValue"
141
+ return array unless array.any? { |value| same?(value, item) }
142
+ return array.reject { |value| same?(value, item) }
143
+ end
144
+ return array if op == "appendUnique" && array.any? { |value| same?(value, item) }
145
+
146
+ op == "prepend" ? [item, *array] : [*array, item]
147
+ end
148
+ private_class_method :update_array
149
+
150
+ def self.update_final(container, part, operation)
151
+ return container if container.nil?
152
+ op = operation[:op]
153
+ if %w[before after].include?(op) && part.is_a?(String)
154
+ raise ArgumentError, "Variable mutation before/after requires an array element path"
155
+ end
156
+ key = path_key(container, part)
157
+ found = exists?(container, key)
158
+
159
+ if op == "remove"
160
+ return container unless found
161
+ result = container.dup
162
+ result.is_a?(Array) ? result.delete_at(key) : result.delete(key)
163
+ return result
164
+ end
165
+
166
+ item = operation[:value]
167
+ if op == "set"
168
+ return container if key.is_a?(Integer) && (key.negative? || key > container.length)
169
+ return container if found && same?(container[key], item)
170
+ return replace(container, key, item)
171
+ end
172
+ if %w[before after].include?(op)
173
+ return container unless found
174
+ result = container.dup
175
+ result.insert(key + (op == "after" ? 1 : 0), item)
176
+ return result
177
+ end
178
+
179
+ current = found ? container[key] : nil
180
+ return container if current.nil? && key.is_a?(Integer)
181
+ return container if current.nil? && op == "removeValue"
182
+ raise ArgumentError, "Variable mutation expected an array" if !current.nil? && !current.is_a?(Array)
183
+ array = current || []
184
+ result = update_array(array, op, item)
185
+ result.equal?(current) ? container : replace(container, key, result)
186
+ end
187
+ private_class_method :update_final
188
+
189
+ def self.update_at_path(value, operation, offset)
190
+ path = operation[:path]
191
+ if offset == path.length
192
+ return same?(value, operation[:value]) ? value : operation[:value] if operation[:op] == "set"
193
+ raise ArgumentError, "Cannot remove the root variable value" if operation[:op] == "remove"
194
+ if %w[before after].include?(operation[:op])
195
+ raise ArgumentError, "Variable mutation before/after requires an array element path"
196
+ end
197
+ return value if value.nil?
198
+ raise ArgumentError, "Variable mutation expected an array" unless value.is_a?(Array)
199
+ return update_array(value, operation[:op], operation[:value])
200
+ end
201
+
202
+ return update_final(value, path[offset], operation) if offset == path.length - 1
203
+ return value if value.nil?
204
+ key = path_key(value, path[offset])
205
+ return value unless exists?(value, key)
206
+ current = value[key]
207
+ return value if current.nil?
208
+ result = update_at_path(current, operation, offset + 1)
209
+ result.equal?(current) ? value : replace(value, key, result)
210
+ end
211
+ private_class_method :update_at_path
212
+ end
213
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Featurevisor
6
+ module Normalize
7
+ class InvalidConditions < Hash
8
+ def initialize
9
+ super
10
+ self[:or] = []
11
+ end
12
+ end
13
+
14
+ class ParseFailure < StandardError; end
15
+
16
+ def self.legacy(value, features = false)
17
+ return value if value.is_a?(InvalidConditions)
18
+ case value
19
+ when String
20
+ features ? { feature: value } : value
21
+ when Array
22
+ value.empty? ? "*" : value.map { |entry| legacy(entry, features) }
23
+ when Hash
24
+ group = %i[and or not].find { |name| value[name].is_a?(Array) }
25
+ return value unless group
26
+ return "*" if group == :and && value[group].empty?
27
+ { group => value[group].map { |entry| legacy(entry, features) } }
28
+ else
29
+ value
30
+ end
31
+ end
32
+
33
+ def self.entity(kind, key, source, legacy = false)
34
+ errors = []
35
+ parse = lambda do |value|
36
+ next value unless value.is_a?(String) && value.lstrip.match?(/\A[\{\[\"]/)
37
+ begin
38
+ JSON.parse(value, symbolize_names: true)
39
+ rescue JSON::ParserError => error
40
+ errors << { level: "error", code: "conditions_parse_error", message: "Error parsing conditions",
41
+ original_error: error,
42
+ details: { conditions: value, entityType: kind.to_s, entityKey: key.to_s } }
43
+ InvalidConditions.new
44
+ end
45
+ end
46
+ select = lambda do |entry, old = false|
47
+ result = entry.reject { |name, _| %i[segments requiredFeatures requiredVariables required].include?(name) }
48
+ parts = []
49
+ if entry.key?(:conditions)
50
+ value = parse.call(entry[:conditions])
51
+ parts << (old ? self.legacy(value) : value)
52
+ end
53
+ parts << self.legacy(parse.call(entry[:segments])) if entry.key?(:segments)
54
+ if entry.key?(:requiredFeatures)
55
+ features = entry[:requiredFeatures]
56
+ features = [features] unless features.is_a?(Array)
57
+ parts << features.map { |value| self.legacy(value, true) } unless features.empty?
58
+ end
59
+ if parts.any? { |part| part.is_a?(InvalidConditions) }
60
+ result[:conditions] = InvalidConditions.new
61
+ elsif parts.length == 1
62
+ result[:conditions] = parts.first
63
+ elsif !parts.empty?
64
+ result[:conditions] = parts.flat_map { |part| part.is_a?(Array) ? (part.empty? ? [{ and: [] }] : part) : [part] }
65
+ end
66
+ result
67
+ end
68
+ with_overrides = lambda do |entry|
69
+ next entry unless entry[:variableOverrides]
70
+ entry.merge(variableOverrides: entry[:variableOverrides].transform_values do |values|
71
+ values.map { |override| select.call(override, legacy) }
72
+ end)
73
+ end
74
+ if kind.to_s == "segment"
75
+ value = parse.call(source[:conditions])
76
+ result = source.merge(conditions: legacy ? self.legacy(value) : value)
77
+ else
78
+ result = select.call(source)
79
+ if kind.to_s == "feature"
80
+ result[:traffic] = (source[:traffic] || []).map { |entry| with_overrides.call(select.call(entry)) }
81
+ result[:force] = source[:force].map { |entry| select.call(entry, legacy) } if source[:force]
82
+ result[:variations] = source[:variations].map { |entry| with_overrides.call(entry) } if source[:variations]
83
+ else
84
+ %i[overrides overrideProgram].each do |name|
85
+ result[name] = source[name].map { |entry| select.call(entry, legacy && name == :overrides) } if source[name]
86
+ end
87
+ end
88
+ end
89
+ [result, errors]
90
+ end
91
+
92
+ def self.datafile(source)
93
+ errors = []
94
+ result = source.dup
95
+ { features: :feature, segments: :segment, variables: :variable }.each do |name, kind|
96
+ next unless source[name]
97
+ result[name] = source[name].each_with_object({}) do |(key, entry), values|
98
+ values[key], diagnostics = entity(kind, key, entry, true)
99
+ errors.concat(diagnostics)
100
+ end
101
+ end
102
+ [result, errors]
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Featurevisor
4
+ module UnifiedConditions
5
+ def self.matches?(conditions, options)
6
+ match(conditions, options)
7
+ rescue Normalize::ParseFailure
8
+ false
9
+ end
10
+
11
+ def self.match(conditions, options)
12
+ raise Normalize::ParseFailure if conditions.is_a?(Normalize::InvalidConditions)
13
+ return true if conditions == "*"
14
+ if conditions.is_a?(String)
15
+ datafile = options[:__featurevisor_datafile_source]&.call || options[:datafile]
16
+ segment = datafile.get_segment(conditions)
17
+ return false unless segment
18
+ raise Normalize::ParseFailure if segment[:conditions].is_a?(Normalize::InvalidConditions)
19
+ if datafile.attribute_only_conditions?(segment[:conditions])
20
+ return datafile.all_conditions_are_matched(segment[:conditions], options[:context])
21
+ end
22
+ session = options[:__featurevisor_session] ||= EvaluationSession.new
23
+ return session.segment(conditions) { match(segment[:conditions], options) }
24
+ end
25
+ if conditions.is_a?(Array)
26
+ return !conditions.empty? && conditions.all? { |child| match(child, options) }
27
+ end
28
+ return false unless conditions.is_a?(Hash)
29
+ if conditions.key?(:variable)
30
+ begin
31
+ return options[:__featurevisor_instance].send(:variable_condition_matches?, conditions, options)
32
+ rescue RequirementError
33
+ raise
34
+ rescue => error
35
+ raise RequirementError, error.message
36
+ end
37
+ end
38
+ if conditions.key?(:feature)
39
+ clean = options.reject { |key, _| %i[feature_key variable_key default_variable_value default_variation_value].include?(key) }
40
+ clean = clean.merge(__featurevisor_prerequisite: true, dependency_purpose: "condition")
41
+ flag = Evaluate.evaluate_with_modules(clean.merge(type: "flag", feature_key: conditions[:feature]))
42
+ enabled = conditions[:enabled].nil? ? true : conditions[:enabled]
43
+ return false unless (flag[:enabled] == true) == enabled
44
+ return true unless conditions.key?(:variation)
45
+ variation = Evaluate.evaluate_with_modules(clean.merge(type: "variation", feature_key: conditions[:feature]))
46
+ value = variation.key?(:variation_value) ? variation[:variation_value] : variation.dig(:variation, :value)
47
+ return value == conditions[:variation]
48
+ end
49
+ if conditions.key?(:attribute)
50
+ return options[:datafile].all_conditions_are_matched(conditions, options[:context])
51
+ end
52
+ %i[and or not].each do |group|
53
+ next unless conditions[group].is_a?(Array)
54
+ children = conditions[group]
55
+ return false if children.empty?
56
+ return children.any? { |child| match(child, options) } if group == :or
57
+ result = children.all? { |child| match(child, options) }
58
+ return group == :not ? !result : result
59
+ end
60
+ false
61
+ end
62
+ private_class_method :match
63
+
64
+ def self.attribute_only?(conditions)
65
+ case conditions
66
+ when Array
67
+ conditions.all? { |child| attribute_only?(child) }
68
+ when Hash
69
+ return false if conditions.is_a?(Normalize::InvalidConditions) || conditions.key?(:feature) || conditions.key?(:variable)
70
+ return true if conditions.key?(:attribute)
71
+ group = %i[and or not].find { |name| conditions.key?(name) }
72
+ group.nil? || attribute_only?(conditions[group])
73
+ else
74
+ false
75
+ end
76
+ end
77
+ private_class_method :attribute_only?
78
+ end
79
+ end
@@ -1,3 +1,3 @@
1
1
  module Featurevisor
2
- VERSION = "3.0.0"
2
+ VERSION = "4.0.0"
3
3
  end
data/lib/featurevisor.rb CHANGED
@@ -4,9 +4,14 @@ require_relative "featurevisor/compare_versions"
4
4
  require_relative "featurevisor/diagnostics"
5
5
  require_relative "featurevisor/emitter"
6
6
  require_relative "featurevisor/conditions"
7
+ require_relative "featurevisor/normalize"
8
+ require_relative "featurevisor/mutations"
9
+ require_relative "featurevisor/dependencies"
7
10
  require_relative "featurevisor/evaluation_data_provider"
8
11
  require_relative "featurevisor/bucketer"
9
12
  require_relative "featurevisor/modules"
13
+ require_relative "featurevisor/evaluation_session"
14
+ require_relative "featurevisor/unified_conditions"
10
15
  require_relative "featurevisor/evaluate"
11
16
  require_relative "featurevisor/instance"
12
17
  require_relative "featurevisor/child_instance"
@@ -17,4 +22,6 @@ module Featurevisor
17
22
 
18
23
  private_constant :InstanceEvaluationDataProvider
19
24
  private_constant :DiagnosticReporter
25
+ private_constant :Mutations
26
+ private_constant :Dependencies
20
27
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: featurevisor
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.0.0
4
+ version: 4.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Fahad Heylaal
@@ -69,21 +69,30 @@ files:
69
69
  - bin/commands.rb
70
70
  - bin/commands/assess_distribution.rb
71
71
  - bin/commands/benchmark.rb
72
+ - bin/commands/node_cli.rb
73
+ - bin/commands/runtime_project.rb
72
74
  - bin/commands/test.rb
73
75
  - bin/featurevisor
76
+ - bin/segment_tests.rb
77
+ - bin/test_checks.rb
74
78
  - lib/featurevisor.rb
75
79
  - lib/featurevisor/bucketer.rb
76
80
  - lib/featurevisor/child_instance.rb
77
81
  - lib/featurevisor/compare_versions.rb
78
82
  - lib/featurevisor/conditions.rb
83
+ - lib/featurevisor/dependencies.rb
79
84
  - lib/featurevisor/diagnostics.rb
80
85
  - lib/featurevisor/emitter.rb
81
86
  - lib/featurevisor/evaluate.rb
82
87
  - lib/featurevisor/evaluation_data_provider.rb
88
+ - lib/featurevisor/evaluation_session.rb
83
89
  - lib/featurevisor/events.rb
84
90
  - lib/featurevisor/instance.rb
85
91
  - lib/featurevisor/modules.rb
86
92
  - lib/featurevisor/murmurhash.rb
93
+ - lib/featurevisor/mutations.rb
94
+ - lib/featurevisor/normalize.rb
95
+ - lib/featurevisor/unified_conditions.rb
87
96
  - lib/featurevisor/version.rb
88
97
  homepage: https://featurevisor.com
89
98
  licenses: