launchdarkly-server-sdk 5.7.3 → 6.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.
Files changed (70) hide show
  1. checksums.yaml +5 -5
  2. data/.circleci/config.yml +28 -122
  3. data/.gitignore +1 -1
  4. data/.ldrelease/build-docs.sh +18 -0
  5. data/.ldrelease/circleci/linux/execute.sh +18 -0
  6. data/.ldrelease/circleci/mac/execute.sh +18 -0
  7. data/.ldrelease/circleci/template/build.sh +29 -0
  8. data/.ldrelease/circleci/template/publish.sh +23 -0
  9. data/.ldrelease/circleci/template/set-gem-home.sh +7 -0
  10. data/.ldrelease/circleci/template/test.sh +10 -0
  11. data/.ldrelease/circleci/template/update-version.sh +8 -0
  12. data/.ldrelease/circleci/windows/execute.ps1 +19 -0
  13. data/.ldrelease/config.yml +14 -2
  14. data/CHANGELOG.md +36 -0
  15. data/CONTRIBUTING.md +1 -1
  16. data/Gemfile.lock +92 -76
  17. data/README.md +5 -3
  18. data/azure-pipelines.yml +1 -1
  19. data/docs/Makefile +26 -0
  20. data/docs/index.md +9 -0
  21. data/launchdarkly-server-sdk.gemspec +20 -13
  22. data/lib/ldclient-rb.rb +0 -1
  23. data/lib/ldclient-rb/config.rb +15 -3
  24. data/lib/ldclient-rb/evaluation_detail.rb +293 -0
  25. data/lib/ldclient-rb/events.rb +1 -4
  26. data/lib/ldclient-rb/file_data_source.rb +1 -1
  27. data/lib/ldclient-rb/impl/evaluator.rb +225 -0
  28. data/lib/ldclient-rb/impl/evaluator_bucketing.rb +74 -0
  29. data/lib/ldclient-rb/impl/evaluator_operators.rb +160 -0
  30. data/lib/ldclient-rb/impl/event_sender.rb +56 -40
  31. data/lib/ldclient-rb/impl/integrations/consul_impl.rb +5 -5
  32. data/lib/ldclient-rb/impl/integrations/dynamodb_impl.rb +5 -5
  33. data/lib/ldclient-rb/impl/integrations/redis_impl.rb +8 -7
  34. data/lib/ldclient-rb/impl/model/serialization.rb +62 -0
  35. data/lib/ldclient-rb/impl/unbounded_pool.rb +34 -0
  36. data/lib/ldclient-rb/integrations/redis.rb +3 -0
  37. data/lib/ldclient-rb/ldclient.rb +16 -11
  38. data/lib/ldclient-rb/polling.rb +1 -4
  39. data/lib/ldclient-rb/redis_store.rb +1 -0
  40. data/lib/ldclient-rb/requestor.rb +25 -23
  41. data/lib/ldclient-rb/stream.rb +10 -30
  42. data/lib/ldclient-rb/user_filter.rb +3 -2
  43. data/lib/ldclient-rb/util.rb +12 -8
  44. data/lib/ldclient-rb/version.rb +1 -1
  45. data/spec/evaluation_detail_spec.rb +135 -0
  46. data/spec/event_sender_spec.rb +20 -2
  47. data/spec/events_spec.rb +11 -0
  48. data/spec/http_util.rb +11 -1
  49. data/spec/impl/evaluator_bucketing_spec.rb +111 -0
  50. data/spec/impl/evaluator_clause_spec.rb +55 -0
  51. data/spec/impl/evaluator_operators_spec.rb +141 -0
  52. data/spec/impl/evaluator_rule_spec.rb +96 -0
  53. data/spec/impl/evaluator_segment_spec.rb +125 -0
  54. data/spec/impl/evaluator_spec.rb +305 -0
  55. data/spec/impl/evaluator_spec_base.rb +75 -0
  56. data/spec/impl/model/serialization_spec.rb +41 -0
  57. data/spec/launchdarkly-server-sdk_spec.rb +1 -1
  58. data/spec/ldclient_end_to_end_spec.rb +34 -0
  59. data/spec/ldclient_spec.rb +10 -8
  60. data/spec/polling_spec.rb +2 -2
  61. data/spec/redis_feature_store_spec.rb +32 -3
  62. data/spec/requestor_spec.rb +11 -45
  63. data/spec/spec_helper.rb +0 -3
  64. data/spec/stream_spec.rb +1 -16
  65. metadata +110 -60
  66. data/.yardopts +0 -9
  67. data/lib/ldclient-rb/evaluation.rb +0 -462
  68. data/scripts/gendocs.sh +0 -11
  69. data/scripts/release.sh +0 -27
  70. data/spec/evaluation_spec.rb +0 -789
@@ -238,10 +238,7 @@ module LaunchDarkly
238
238
  diagnostic_event_workers.shutdown
239
239
  diagnostic_event_workers.wait_for_termination
240
240
  end
241
- begin
242
- @client.finish
243
- rescue
244
- end
241
+ @event_sender.stop if @event_sender.respond_to?(:stop)
245
242
  end
246
243
 
247
244
  def synchronize_for_testing(flush_workers, diagnostic_event_workers)
@@ -51,7 +51,7 @@ module LaunchDarkly
51
51
  # output as the starting point for your file. In Linux you would do this:
52
52
  #
53
53
  # ```
54
- # curl -H "Authorization: YOUR_SDK_KEY" https://app.launchdarkly.com/sdk/latest-all
54
+ # curl -H "Authorization: YOUR_SDK_KEY" https://sdk.launchdarkly.com/sdk/latest-all
55
55
  # ```
56
56
  #
57
57
  # The output will look something like this (but with many more properties):
@@ -0,0 +1,225 @@
1
+ require "ldclient-rb/evaluation_detail"
2
+ require "ldclient-rb/impl/evaluator_bucketing"
3
+ require "ldclient-rb/impl/evaluator_operators"
4
+
5
+ module LaunchDarkly
6
+ module Impl
7
+ # Encapsulates the feature flag evaluation logic. The Evaluator has no knowledge of the rest of the SDK environment;
8
+ # if it needs to retrieve flags or segments that are referenced by a flag, it does so through a simple function that
9
+ # is provided in the constructor. It also produces feature requests as appropriate for any referenced prerequisite
10
+ # flags, but does not send them.
11
+ class Evaluator
12
+ # A single Evaluator is instantiated for each client instance.
13
+ #
14
+ # @param get_flag [Function] called if the Evaluator needs to query a different flag from the one that it is
15
+ # currently evaluating (i.e. a prerequisite flag); takes a single parameter, the flag key, and returns the
16
+ # flag data - or nil if the flag is unknown or deleted
17
+ # @param get_segment [Function] similar to `get_flag`, but is used to query a user segment.
18
+ # @param logger [Logger] the client's logger
19
+ def initialize(get_flag, get_segment, logger)
20
+ @get_flag = get_flag
21
+ @get_segment = get_segment
22
+ @logger = logger
23
+ end
24
+
25
+ # Used internally to hold an evaluation result and the events that were generated from prerequisites. The
26
+ # `detail` property is an EvaluationDetail. The `events` property can be either an array of feature request
27
+ # events or nil.
28
+ EvalResult = Struct.new(:detail, :events)
29
+
30
+ # Helper function used internally to construct an EvaluationDetail for an error result.
31
+ def self.error_result(errorKind, value = nil)
32
+ EvaluationDetail.new(value, nil, EvaluationReason.error(errorKind))
33
+ end
34
+
35
+ # The client's entry point for evaluating a flag. The returned `EvalResult` contains the evaluation result and
36
+ # any events that were generated for prerequisite flags; its `value` will be `nil` if the flag returns the
37
+ # default value. Error conditions produce a result with a nil value and an error reason, not an exception.
38
+ #
39
+ # @param flag [Object] the flag
40
+ # @param user [Object] the user properties
41
+ # @param event_factory [EventFactory] called to construct a feature request event when a prerequisite flag is
42
+ # evaluated; the caller is responsible for constructing the feature event for the top-level evaluation
43
+ # @return [EvalResult] the evaluation result
44
+ def evaluate(flag, user, event_factory)
45
+ if user.nil? || user[:key].nil?
46
+ return EvalResult.new(Evaluator.error_result(EvaluationReason::ERROR_USER_NOT_SPECIFIED), [])
47
+ end
48
+
49
+ # If the flag doesn't have any prerequisites (which most flags don't) then it cannot generate any feature
50
+ # request events for prerequisites and we can skip allocating an array.
51
+ if flag[:prerequisites] && !flag[:prerequisites].empty?
52
+ events = []
53
+ else
54
+ events = nil
55
+ end
56
+
57
+ detail = eval_internal(flag, user, events, event_factory)
58
+ return EvalResult.new(detail, events.nil? || events.empty? ? nil : events)
59
+ end
60
+
61
+ private
62
+
63
+ def eval_internal(flag, user, events, event_factory)
64
+ if !flag[:on]
65
+ return get_off_value(flag, EvaluationReason::off)
66
+ end
67
+
68
+ prereq_failure_reason = check_prerequisites(flag, user, events, event_factory)
69
+ if !prereq_failure_reason.nil?
70
+ return get_off_value(flag, prereq_failure_reason)
71
+ end
72
+
73
+ # Check user target matches
74
+ (flag[:targets] || []).each do |target|
75
+ (target[:values] || []).each do |value|
76
+ if value == user[:key]
77
+ return get_variation(flag, target[:variation], EvaluationReason::target_match)
78
+ end
79
+ end
80
+ end
81
+
82
+ # Check custom rules
83
+ rules = flag[:rules] || []
84
+ rules.each_index do |i|
85
+ rule = rules[i]
86
+ if rule_match_user(rule, user)
87
+ reason = rule[:_reason] # try to use cached reason for this rule
88
+ reason = EvaluationReason::rule_match(i, rule[:id]) if reason.nil?
89
+ return get_value_for_variation_or_rollout(flag, rule, user, reason)
90
+ end
91
+ end
92
+
93
+ # Check the fallthrough rule
94
+ if !flag[:fallthrough].nil?
95
+ return get_value_for_variation_or_rollout(flag, flag[:fallthrough], user, EvaluationReason::fallthrough)
96
+ end
97
+
98
+ return EvaluationDetail.new(nil, nil, EvaluationReason::fallthrough)
99
+ end
100
+
101
+ def check_prerequisites(flag, user, events, event_factory)
102
+ (flag[:prerequisites] || []).each do |prerequisite|
103
+ prereq_ok = true
104
+ prereq_key = prerequisite[:key]
105
+ prereq_flag = @get_flag.call(prereq_key)
106
+
107
+ if prereq_flag.nil?
108
+ @logger.error { "[LDClient] Could not retrieve prerequisite flag \"#{prereq_key}\" when evaluating \"#{flag[:key]}\"" }
109
+ prereq_ok = false
110
+ else
111
+ begin
112
+ prereq_res = eval_internal(prereq_flag, user, events, event_factory)
113
+ # Note that if the prerequisite flag is off, we don't consider it a match no matter what its
114
+ # off variation was. But we still need to evaluate it in order to generate an event.
115
+ if !prereq_flag[:on] || prereq_res.variation_index != prerequisite[:variation]
116
+ prereq_ok = false
117
+ end
118
+ event = event_factory.new_eval_event(prereq_flag, user, prereq_res, nil, flag)
119
+ events.push(event)
120
+ rescue => exn
121
+ Util.log_exception(@logger, "Error evaluating prerequisite flag \"#{prereq_key}\" for flag \"#{flag[:key]}\"", exn)
122
+ prereq_ok = false
123
+ end
124
+ end
125
+ if !prereq_ok
126
+ reason = prerequisite[:_reason] # try to use cached reason
127
+ return reason.nil? ? EvaluationReason::prerequisite_failed(prereq_key) : reason
128
+ end
129
+ end
130
+ nil
131
+ end
132
+
133
+ def rule_match_user(rule, user)
134
+ return false if !rule[:clauses]
135
+
136
+ (rule[:clauses] || []).each do |clause|
137
+ return false if !clause_match_user(clause, user)
138
+ end
139
+
140
+ return true
141
+ end
142
+
143
+ def clause_match_user(clause, user)
144
+ # In the case of a segment match operator, we check if the user is in any of the segments,
145
+ # and possibly negate
146
+ if clause[:op].to_sym == :segmentMatch
147
+ result = (clause[:values] || []).any? { |v|
148
+ segment = @get_segment.call(v)
149
+ !segment.nil? && segment_match_user(segment, user)
150
+ }
151
+ clause[:negate] ? !result : result
152
+ else
153
+ clause_match_user_no_segments(clause, user)
154
+ end
155
+ end
156
+
157
+ def clause_match_user_no_segments(clause, user)
158
+ user_val = EvaluatorOperators.user_value(user, clause[:attribute])
159
+ return false if user_val.nil?
160
+
161
+ op = clause[:op].to_sym
162
+ clause_vals = clause[:values]
163
+ result = if user_val.is_a? Enumerable
164
+ user_val.any? { |uv| clause_vals.any? { |cv| EvaluatorOperators.apply(op, uv, cv) } }
165
+ else
166
+ clause_vals.any? { |cv| EvaluatorOperators.apply(op, user_val, cv) }
167
+ end
168
+ clause[:negate] ? !result : result
169
+ end
170
+
171
+ def segment_match_user(segment, user)
172
+ return false unless user[:key]
173
+
174
+ return true if segment[:included].include?(user[:key])
175
+ return false if segment[:excluded].include?(user[:key])
176
+
177
+ (segment[:rules] || []).each do |r|
178
+ return true if segment_rule_match_user(r, user, segment[:key], segment[:salt])
179
+ end
180
+
181
+ return false
182
+ end
183
+
184
+ def segment_rule_match_user(rule, user, segment_key, salt)
185
+ (rule[:clauses] || []).each do |c|
186
+ return false unless clause_match_user_no_segments(c, user)
187
+ end
188
+
189
+ # If the weight is absent, this rule matches
190
+ return true if !rule[:weight]
191
+
192
+ # All of the clauses are met. See if the user buckets in
193
+ bucket = EvaluatorBucketing.bucket_user(user, segment_key, rule[:bucketBy].nil? ? "key" : rule[:bucketBy], salt)
194
+ weight = rule[:weight].to_f / 100000.0
195
+ return bucket < weight
196
+ end
197
+
198
+ private
199
+
200
+ def get_variation(flag, index, reason)
201
+ if index < 0 || index >= flag[:variations].length
202
+ @logger.error("[LDClient] Data inconsistency in feature flag \"#{flag[:key]}\": invalid variation index")
203
+ return Evaluator.error_result(EvaluationReason::ERROR_MALFORMED_FLAG)
204
+ end
205
+ EvaluationDetail.new(flag[:variations][index], index, reason)
206
+ end
207
+
208
+ def get_off_value(flag, reason)
209
+ if flag[:offVariation].nil? # off variation unspecified - return default value
210
+ return EvaluationDetail.new(nil, nil, reason)
211
+ end
212
+ get_variation(flag, flag[:offVariation], reason)
213
+ end
214
+
215
+ def get_value_for_variation_or_rollout(flag, vr, user, reason)
216
+ index = EvaluatorBucketing.variation_index_for_user(flag, vr, user)
217
+ if index.nil?
218
+ @logger.error("[LDClient] Data inconsistency in feature flag \"#{flag[:key]}\": variation/rollout object with no variation or rollout")
219
+ return Evaluator.error_result(EvaluationReason::ERROR_MALFORMED_FLAG)
220
+ end
221
+ return get_variation(flag, index, reason)
222
+ end
223
+ end
224
+ end
225
+ end
@@ -0,0 +1,74 @@
1
+
2
+ module LaunchDarkly
3
+ module Impl
4
+ # Encapsulates the logic for percentage rollouts.
5
+ module EvaluatorBucketing
6
+ # Applies either a fixed variation or a rollout for a rule (or the fallthrough rule).
7
+ #
8
+ # @param flag [Object] the feature flag
9
+ # @param rule [Object] the rule
10
+ # @param user [Object] the user properties
11
+ # @return [Number] the variation index, or nil if there is an error
12
+ def self.variation_index_for_user(flag, rule, user)
13
+ variation = rule[:variation]
14
+ return variation if !variation.nil? # fixed variation
15
+ rollout = rule[:rollout]
16
+ return nil if rollout.nil?
17
+ variations = rollout[:variations]
18
+ if !variations.nil? && variations.length > 0 # percentage rollout
19
+ rollout = rule[:rollout]
20
+ bucket_by = rollout[:bucketBy].nil? ? "key" : rollout[:bucketBy]
21
+ bucket = bucket_user(user, flag[:key], bucket_by, flag[:salt])
22
+ sum = 0;
23
+ variations.each do |variate|
24
+ sum += variate[:weight].to_f / 100000.0
25
+ if bucket < sum
26
+ return variate[:variation]
27
+ end
28
+ end
29
+ # The user's bucket value was greater than or equal to the end of the last bucket. This could happen due
30
+ # to a rounding error, or due to the fact that we are scaling to 100000 rather than 99999, or the flag
31
+ # data could contain buckets that don't actually add up to 100000. Rather than returning an error in
32
+ # this case (or changing the scaling, which would potentially change the results for *all* users), we
33
+ # will simply put the user in the last bucket.
34
+ variations[-1][:variation]
35
+ else # the rule isn't well-formed
36
+ nil
37
+ end
38
+ end
39
+
40
+ # Returns a user's bucket value as a floating-point value in `[0, 1)`.
41
+ #
42
+ # @param user [Object] the user properties
43
+ # @param key [String] the feature flag key (or segment key, if this is for a segment rule)
44
+ # @param bucket_by [String|Symbol] the name of the user attribute to be used for bucketing
45
+ # @param salt [String] the feature flag's or segment's salt value
46
+ # @return [Number] the bucket value, from 0 inclusive to 1 exclusive
47
+ def self.bucket_user(user, key, bucket_by, salt)
48
+ return nil unless user[:key]
49
+
50
+ id_hash = bucketable_string_value(EvaluatorOperators.user_value(user, bucket_by))
51
+ if id_hash.nil?
52
+ return 0.0
53
+ end
54
+
55
+ if user[:secondary]
56
+ id_hash += "." + user[:secondary].to_s
57
+ end
58
+
59
+ hash_key = "%s.%s.%s" % [key, salt, id_hash]
60
+
61
+ hash_val = (Digest::SHA1.hexdigest(hash_key))[0..14]
62
+ hash_val.to_i(16) / Float(0xFFFFFFFFFFFFFFF)
63
+ end
64
+
65
+ private
66
+
67
+ def self.bucketable_string_value(value)
68
+ return value if value.is_a? String
69
+ return value.to_s if value.is_a? Integer
70
+ nil
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,160 @@
1
+ require "date"
2
+ require "semantic"
3
+ require "set"
4
+
5
+ module LaunchDarkly
6
+ module Impl
7
+ # Defines the behavior of all operators that can be used in feature flag rules and segment rules.
8
+ module EvaluatorOperators
9
+ # Applies an operator to produce a boolean result.
10
+ #
11
+ # @param op [Symbol] one of the supported LaunchDarkly operators, as a symbol
12
+ # @param user_value the value of the user attribute that is referenced in the current clause (left-hand
13
+ # side of the expression)
14
+ # @param clause_value the constant value that `user_value` is being compared to (right-hand side of the
15
+ # expression)
16
+ # @return [Boolean] true if the expression should be considered a match; false if it is not a match, or
17
+ # if the values cannot be compared because they are of the wrong types, or if the operator is unknown
18
+ def self.apply(op, user_value, clause_value)
19
+ case op
20
+ when :in
21
+ user_value == clause_value
22
+ when :startsWith
23
+ string_op(user_value, clause_value, lambda { |a, b| a.start_with? b })
24
+ when :endsWith
25
+ string_op(user_value, clause_value, lambda { |a, b| a.end_with? b })
26
+ when :contains
27
+ string_op(user_value, clause_value, lambda { |a, b| a.include? b })
28
+ when :matches
29
+ string_op(user_value, clause_value, lambda { |a, b|
30
+ begin
31
+ re = Regexp.new b
32
+ !re.match(a).nil?
33
+ rescue
34
+ false
35
+ end
36
+ })
37
+ when :lessThan
38
+ numeric_op(user_value, clause_value, lambda { |a, b| a < b })
39
+ when :lessThanOrEqual
40
+ numeric_op(user_value, clause_value, lambda { |a, b| a <= b })
41
+ when :greaterThan
42
+ numeric_op(user_value, clause_value, lambda { |a, b| a > b })
43
+ when :greaterThanOrEqual
44
+ numeric_op(user_value, clause_value, lambda { |a, b| a >= b })
45
+ when :before
46
+ date_op(user_value, clause_value, lambda { |a, b| a < b })
47
+ when :after
48
+ date_op(user_value, clause_value, lambda { |a, b| a > b })
49
+ when :semVerEqual
50
+ semver_op(user_value, clause_value, lambda { |a, b| a == b })
51
+ when :semVerLessThan
52
+ semver_op(user_value, clause_value, lambda { |a, b| a < b })
53
+ when :semVerGreaterThan
54
+ semver_op(user_value, clause_value, lambda { |a, b| a > b })
55
+ when :segmentMatch
56
+ # We should never reach this; it can't be evaluated based on just two parameters, because it requires
57
+ # looking up the segment from the data store. Instead, we special-case this operator in clause_match_user.
58
+ false
59
+ else
60
+ false
61
+ end
62
+ end
63
+
64
+ # Retrieves the value of a user attribute by name.
65
+ #
66
+ # Built-in attributes correspond to top-level properties in the user object. They are treated as strings and
67
+ # non-string values are coerced to strings, except for `anonymous` which is meant to be a boolean if present
68
+ # and is not currently coerced. This behavior is consistent with earlier versions of the Ruby SDK, but is not
69
+ # guaranteed to be consistent with other SDKs, since the evaluator specification is based on the strongly-typed
70
+ # SDKs where it is not possible for an attribute to have the wrong type.
71
+ #
72
+ # Custom attributes correspond to properties within the `custom` property, if any, and can be of any type.
73
+ #
74
+ # @param user [Object] the user properties
75
+ # @param attribute [String|Symbol] the attribute to get, for instance `:key` or `:name` or `:some_custom_attr`
76
+ # @return the attribute value, or nil if the attribute is unknown
77
+ def self.user_value(user, attribute)
78
+ attribute = attribute.to_sym
79
+ if BUILTINS.include? attribute
80
+ value = user[attribute]
81
+ return nil if value.nil?
82
+ (attribute == :anonymous) ? value : value.to_s
83
+ elsif !user[:custom].nil?
84
+ user[:custom][attribute]
85
+ else
86
+ nil
87
+ end
88
+ end
89
+
90
+ private
91
+
92
+ BUILTINS = Set[:key, :ip, :country, :email, :firstName, :lastName, :avatar, :name, :anonymous]
93
+ NUMERIC_VERSION_COMPONENTS_REGEX = Regexp.new("^[0-9.]*")
94
+
95
+ private_constant :BUILTINS
96
+ private_constant :NUMERIC_VERSION_COMPONENTS_REGEX
97
+
98
+ def self.string_op(user_value, clause_value, fn)
99
+ (user_value.is_a? String) && (clause_value.is_a? String) && fn.call(user_value, clause_value)
100
+ end
101
+
102
+ def self.numeric_op(user_value, clause_value, fn)
103
+ (user_value.is_a? Numeric) && (clause_value.is_a? Numeric) && fn.call(user_value, clause_value)
104
+ end
105
+
106
+ def self.date_op(user_value, clause_value, fn)
107
+ ud = to_date(user_value)
108
+ if !ud.nil?
109
+ cd = to_date(clause_value)
110
+ !cd.nil? && fn.call(ud, cd)
111
+ else
112
+ false
113
+ end
114
+ end
115
+
116
+ def self.semver_op(user_value, clause_value, fn)
117
+ uv = to_semver(user_value)
118
+ if !uv.nil?
119
+ cv = to_semver(clause_value)
120
+ !cv.nil? && fn.call(uv, cv)
121
+ else
122
+ false
123
+ end
124
+ end
125
+
126
+ def self.to_date(value)
127
+ if value.is_a? String
128
+ begin
129
+ DateTime.rfc3339(value).strftime("%Q").to_i
130
+ rescue => e
131
+ nil
132
+ end
133
+ elsif value.is_a? Numeric
134
+ value
135
+ else
136
+ nil
137
+ end
138
+ end
139
+
140
+ def self.to_semver(value)
141
+ if value.is_a? String
142
+ for _ in 0..2 do
143
+ begin
144
+ return Semantic::Version.new(value)
145
+ rescue ArgumentError
146
+ value = add_zero_version_component(value)
147
+ end
148
+ end
149
+ end
150
+ nil
151
+ end
152
+
153
+ def self.add_zero_version_component(v)
154
+ NUMERIC_VERSION_COMPONENTS_REGEX.match(v) { |m|
155
+ m[0] + ".0" + v[m[0].length..-1]
156
+ }
157
+ end
158
+ end
159
+ end
160
+ end