featureflip 2.0.0 → 2.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3b4009e153b6dc621cee9ed4d8f1aaa7f199396e5d8a8266d949b3367b0f06b8
4
- data.tar.gz: 1f45c271ce3d180c362cfa095d6027f0d5d48f51cc259fd31215baa8959732d6
3
+ metadata.gz: 2fdefb980d91435aca01a6f6344804e172dedef867fd826c5ac9a017ca6416ca
4
+ data.tar.gz: d98b0cda83055b8b984b2ea370b9717f6800dd44202ddacf21944fa19b0dc194
5
5
  SHA512:
6
- metadata.gz: debfee24678c85c688e94935396ef142f3b1f31aaec0b7788c6fc09e51e03e805104f273e53bda66686d4eded49baff6e33f58bb7e0b2543cbdf5cfac1cf9289
7
- data.tar.gz: ff2cc0874823a1dcd23c3fc29dd70deffb46b0a08fc6c9efce620299bb3200b6edbf78d5352579e2b476725e5870d9bcde1f2a48faafc8e5ccca11e113e750ba
6
+ metadata.gz: 360a193a8d0557ba71c45ce1c3f341d008f34f34996434328f7aa2c1da3eee9ef7cc531953ffbd21b8f83fa2674e750139c456854e59a322c0099fe04f76cc0f
7
+ data.tar.gz: f4f292e068e49a8688b94c530d5cce50a060f97c57742e5aaee3f4db02aa469b0623c8b3c95065c6293e0452b71a58f3dc57212b649bc78e66044dad025c24a6
@@ -1,13 +1,35 @@
1
+ require "time"
2
+
1
3
  module Featureflip
2
4
  module Evaluation
3
5
  class ConditionEvaluator
6
+ # Per-regex match timeout (seconds) for MatchesRegex, mirroring the
7
+ # engine's 100ms RegexMatchTimeout guard against catastrophic
8
+ # backtracking / ReDoS (#1460). Requires Ruby >= 3.2 (gemspec floor),
9
+ # which added the Regexp `timeout:` keyword.
10
+ REGEX_TIMEOUT_SECONDS = 0.1
11
+
4
12
  def evaluate_condition(condition, context)
5
13
  attr_value = context[condition.attribute]
6
14
 
7
15
  return condition.negate if attr_value.nil?
8
16
 
9
- str_value = attr_value.to_s.downcase
10
- targets = condition.values.map { |v| v.to_s.downcase }
17
+ # Issue #1458: when the attribute is a native numeric (Integer/Float —
18
+ # Ruby's `true`/`false` are NOT Numeric, so booleans are naturally
19
+ # excluded), the equality-family operators coerce the condition values to
20
+ # numbers and compare numerically, so 1.0 matches "1". This mirrors the
21
+ # engine's type-aware path and runs BEFORE stringification — a String
22
+ # attribute (even "1.0") stays on the string path below.
23
+ if attr_value.is_a?(Numeric) && NUMERIC_EQUALITY_OPERATORS.include?(condition.operator)
24
+ return evaluate_numeric_equality(condition, attr_value)
25
+ end
26
+
27
+ # Pass the raw (case-preserved) strings to the operator dispatcher.
28
+ # Most operators compare case-insensitively and downcase internally, but
29
+ # the semver operators rely on case-sensitive prerelease precedence
30
+ # (semver §11), so the original casing must survive to that point.
31
+ str_value = attr_value.to_s
32
+ targets = condition.values.map(&:to_s)
11
33
 
12
34
  result = evaluate_operator(condition.operator, str_value, targets)
13
35
  condition.negate ? !result : result
@@ -33,49 +55,136 @@ module Featureflip
33
55
 
34
56
  private
35
57
 
58
+ # The equality-family operators that get type-aware numeric coercion when
59
+ # the attribute is a native Numeric (Issue #1458). Relational/string ops
60
+ # are deliberately excluded — only these four coerce.
61
+ NUMERIC_EQUALITY_OPERATORS = %w[Equals NotEquals In NotIn].freeze
62
+ private_constant :NUMERIC_EQUALITY_OPERATORS
63
+
64
+ # A parsed semantic version: the release core as dot-separated numeric
65
+ # segments plus an optional dot-separated prerelease identifier list.
66
+ SemverVersion = Struct.new(:release, :prerelease)
67
+ private_constant :SemverVersion
68
+
69
+ # An ISO-8601 date-time with no timezone offset (no trailing "Z"/±hh:mm),
70
+ # so it must be assumed UTC before parsing.
71
+ ISO_NO_OFFSET = /\A\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2})?(\.\d+)?\z/
72
+ private_constant :ISO_NO_OFFSET
73
+
74
+ # A bare ISO-8601 calendar date ("2024-01-01") with no time component. The
75
+ # engine's DateTimeOffset.TryParse accepts these (midnight, assumed UTC),
76
+ # but Time.iso8601 rejects them, so they need their own midnight-UTC path.
77
+ ISO_DATE_ONLY = /\A\d{4}-\d{2}-\d{2}\z/
78
+ private_constant :ISO_DATE_ONLY
79
+
36
80
  def evaluate_operator(operator, value, targets)
81
+ # Case-insensitive views for the string/relational/date operators.
82
+ ci_value = value.downcase
83
+ ci_targets = targets.map(&:downcase)
84
+
37
85
  case operator
38
86
  when "Equals"
39
- targets.any? { |t| value == t }
87
+ ci_targets.any? { |t| ci_value == t }
40
88
  when "NotEquals"
41
- targets.all? { |t| value != t }
89
+ ci_targets.all? { |t| ci_value != t }
42
90
  when "Contains"
43
- targets.any? { |t| value.include?(t) }
91
+ ci_targets.any? { |t| ci_value.include?(t) }
44
92
  when "NotContains"
45
- targets.all? { |t| !value.include?(t) }
93
+ ci_targets.all? { |t| !ci_value.include?(t) }
46
94
  when "StartsWith"
47
- targets.any? { |t| value.start_with?(t) }
95
+ ci_targets.any? { |t| ci_value.start_with?(t) }
48
96
  when "EndsWith"
49
- targets.any? { |t| value.end_with?(t) }
97
+ ci_targets.any? { |t| ci_value.end_with?(t) }
50
98
  when "In"
51
- targets.include?(value)
99
+ ci_targets.include?(ci_value)
52
100
  when "NotIn"
53
- !targets.include?(value)
101
+ !ci_targets.include?(ci_value)
54
102
  when "MatchesRegex"
103
+ # Case-sensitive matching on the original-case value and pattern,
104
+ # mirroring the engine (RegexOptions.None). Case-insensitivity is
105
+ # opt-in via the (?i) inline flag in the pattern.
106
+ #
107
+ # A per-regex timeout bounds catastrophic backtracking / ReDoS like
108
+ # the engine's 100ms guard (#1460). rescue RegexpError covers BOTH an
109
+ # invalid pattern AND Regexp::TimeoutError (a RegexpError subclass),
110
+ # so either fails safe to no-match.
55
111
  targets.any? do |t|
56
- Regexp.new(t, Regexp::IGNORECASE).match?(value)
112
+ Regexp.new(t, timeout: REGEX_TIMEOUT_SECONDS).match?(value)
57
113
  rescue RegexpError
58
114
  false
59
115
  end
116
+ # Relational operators match if the value satisfies the comparison
117
+ # against ANY condition value (mirroring the server engine), not just
118
+ # values[0]. `.any?` over an empty array is false, so empty values
119
+ # returns false without error.
60
120
  when "GreaterThan"
61
- compare_numeric(value, targets[0], :>)
121
+ ci_targets.any? { |t| compare_numeric(ci_value, t, :>) }
62
122
  when "GreaterThanOrEqual"
63
- compare_numeric(value, targets[0], :>=)
123
+ ci_targets.any? { |t| compare_numeric(ci_value, t, :>=) }
64
124
  when "LessThan"
65
- compare_numeric(value, targets[0], :<)
125
+ ci_targets.any? { |t| compare_numeric(ci_value, t, :<) }
66
126
  when "LessThanOrEqual"
67
- compare_numeric(value, targets[0], :<=)
127
+ ci_targets.any? { |t| compare_numeric(ci_value, t, :<=) }
128
+ # Date operators compare against the RAW value/targets, not the
129
+ # lowercased copies: downcasing breaks ISO-8601 parsing (the "Z" UTC
130
+ # designator becomes "z", which is not valid). Both operands are parsed
131
+ # to UTC instants — offsets are honored, no-offset strings are assumed
132
+ # UTC, and a bare integer is treated as Unix seconds — so an unparseable
133
+ # operand matches nothing instead of falling back to a string compare.
68
134
  when "Before"
69
- return false if targets.empty?
70
- value < targets[0]
135
+ targets.any? { |t| compare_datetime(value, t, :<) }
71
136
  when "After"
72
- return false if targets.empty?
73
- value > targets[0]
137
+ targets.any? { |t| compare_datetime(value, t, :>) }
138
+ # Semantic-version operators compare against the RAW value/targets:
139
+ # prerelease precedence is case-sensitive (semver §11), so the casing
140
+ # preserved by `evaluate_condition` must not be folded here. An
141
+ # unparseable version matches nothing, like the numeric/date operators.
142
+ when "SemverEquals"
143
+ targets.any? { |t| compare_semver(value, t, :==) }
144
+ when "SemverGreaterThan"
145
+ targets.any? { |t| compare_semver(value, t, :>) }
146
+ when "SemverGreaterThanOrEqual"
147
+ targets.any? { |t| compare_semver(value, t, :>=) }
148
+ when "SemverLessThan"
149
+ targets.any? { |t| compare_semver(value, t, :<) }
150
+ when "SemverLessThanOrEqual"
151
+ targets.any? { |t| compare_semver(value, t, :<=) }
74
152
  else
75
153
  false
76
154
  end
77
155
  end
78
156
 
157
+ # Type-aware numeric equality for a native-Numeric attribute (Issue #1458).
158
+ # Coerces each condition value with a strict literal parse and compares it
159
+ # numerically against the attribute. Equals/In match if ANY value is equal;
160
+ # NotEquals/NotIn are their negation. The `negate` flag is then applied,
161
+ # mirroring `evaluate_condition`'s tail.
162
+ def evaluate_numeric_equality(condition, attr_value)
163
+ target = attr_value.to_f
164
+ any_equal = condition.values.any? do |v|
165
+ n = parse_numeric(v)
166
+ n && n == target
167
+ end
168
+
169
+ positive = NUMERIC_EQUALITY_POSITIVE_OPERATORS.include?(condition.operator)
170
+ result = positive ? any_equal : !any_equal
171
+ condition.negate ? !result : result
172
+ end
173
+
174
+ # Equals/In are the "positive" members of the equality family (match on
175
+ # equality); NotEquals/NotIn negate the same any-equal test.
176
+ NUMERIC_EQUALITY_POSITIVE_OPERATORS = %w[Equals In].freeze
177
+ private_constant :NUMERIC_EQUALITY_POSITIVE_OPERATORS
178
+
179
+ # Strict literal parse of a condition value to a Float, reusing the same
180
+ # `Float()` approach as `compare_numeric`: a partial number like "1abc"
181
+ # raises and yields nil (no match), matching the engine's strict parse.
182
+ def parse_numeric(value)
183
+ Float(value)
184
+ rescue ArgumentError, TypeError
185
+ nil
186
+ end
187
+
79
188
  def compare_numeric(value, target, op)
80
189
  val = Float(value)
81
190
  tgt = Float(target)
@@ -83,6 +192,164 @@ module Featureflip
83
192
  rescue ArgumentError, TypeError
84
193
  false
85
194
  end
195
+
196
+ # Compares `value` and `target` as UTC date-time instants and tests the
197
+ # ordering against `op` (:< for Before, :> for After). Returns false when
198
+ # either side is not parseable, mirroring how `compare_numeric` treats
199
+ # non-numeric input (no lexical-string fallback).
200
+ def compare_datetime(value, target, op)
201
+ left = parse_datetime(value)
202
+ right = parse_datetime(target)
203
+ return false if left.nil? || right.nil?
204
+
205
+ left.send(op, right)
206
+ end
207
+
208
+ # Parses a date-time to a UTC `Time`, mirroring the engine's
209
+ # TryParseDateTime. ISO-8601 strings honor any timezone offset; a string
210
+ # without an offset is assumed UTC. A bare integer is treated as Unix time
211
+ # in seconds. Returns nil when the input parses as neither.
212
+ def parse_datetime(value)
213
+ s = value.to_s.strip
214
+
215
+ begin
216
+ # `Time.iso8601` honors offsets/"Z" but raises on a no-offset string;
217
+ # append the missing time/offset to assume midnight UTC for bare dates
218
+ # and UTC for offset-less date-times (mirroring DateTimeOffset.TryParse
219
+ # with AssumeUniversal).
220
+ iso =
221
+ if s.match?(ISO_DATE_ONLY)
222
+ "#{s}T00:00:00Z"
223
+ elsif s.match?(ISO_NO_OFFSET)
224
+ "#{s}Z"
225
+ else
226
+ s
227
+ end
228
+ return Time.iso8601(iso).utc
229
+ rescue ArgumentError
230
+ # Fall through to the Unix-seconds fallback.
231
+ end
232
+
233
+ # Integer fallback: treat a bare integer as Unix time in seconds.
234
+ if s.match?(/\A-?\d+\z/)
235
+ begin
236
+ return Time.at(Integer(s)).utc
237
+ rescue RangeError, ArgumentError
238
+ return nil
239
+ end
240
+ end
241
+
242
+ nil
243
+ end
244
+
245
+ # Compares `value` and `target` as semantic versions and tests the
246
+ # resulting precedence sign (-1/0/1) against `op`. Returns false when
247
+ # either side is not a parseable version, mirroring how `compare_numeric`
248
+ # treats non-numeric input.
249
+ def compare_semver(value, target, op)
250
+ left = parse_semver(value)
251
+ right = parse_semver(target)
252
+ return false if left.nil? || right.nil?
253
+
254
+ compare_semver_parts(left, right).send(op, 0)
255
+ end
256
+
257
+ # Parses a semantic version (https://semver.org). Returns a SemverVersion,
258
+ # or nil when the release core is missing or any release segment is
259
+ # non-numeric. Tolerant of an optional leading "v"/"V", "+build" metadata
260
+ # (ignored for precedence), and an optional "-prerelease" suffix.
261
+ def parse_semver(value)
262
+ s = value.strip
263
+ return nil if s.empty?
264
+
265
+ # Optional leading "v"/"V".
266
+ s = s[1..] if s.start_with?("v", "V")
267
+
268
+ # Build metadata ("+...") does not affect precedence.
269
+ plus = s.index("+")
270
+ s = s[0...plus] if plus
271
+
272
+ # Split the release core from the optional "-prerelease" suffix.
273
+ core = s
274
+ prerelease = []
275
+ dash = s.index("-")
276
+ if dash
277
+ core = s[0...dash]
278
+ pre = s[(dash + 1)..]
279
+ return nil if pre.empty? # trailing "-" with no identifiers is malformed
280
+
281
+ # `split(".", -1)` keeps trailing empty fields so "rc." / "1.0.-x" are
282
+ # rejected; the default `split` would silently drop them.
283
+ prerelease = pre.split(".", -1)
284
+ return nil if prerelease.any?(&:empty?)
285
+ end
286
+
287
+ return nil if core.empty?
288
+
289
+ release = core.split(".", -1)
290
+ return nil unless release.all? { |seg| all_digits?(seg) }
291
+
292
+ SemverVersion.new(release, prerelease)
293
+ end
294
+
295
+ # Compares two parsed versions by semantic-version precedence: release
296
+ # segments first (missing trailing segments treated as 0), then
297
+ # prerelease. Returns -1, 0, or 1.
298
+ def compare_semver_parts(a, b)
299
+ [a.release.length, b.release.length].max.times do |i|
300
+ seg_a = a.release[i] || "0"
301
+ seg_b = b.release[i] || "0"
302
+ cmp = compare_numeric_string(seg_a, seg_b)
303
+ return cmp unless cmp.zero?
304
+ end
305
+
306
+ compare_prerelease(a.prerelease, b.prerelease)
307
+ end
308
+
309
+ # Compares two prerelease identifier lists per semver §11. A version with
310
+ # no prerelease ranks above one that has a prerelease.
311
+ def compare_prerelease(a, b)
312
+ return 0 if a.empty? && b.empty?
313
+ return 1 if a.empty?
314
+ return -1 if b.empty?
315
+
316
+ [a.length, b.length].min.times do |i|
317
+ cmp = compare_prerelease_id(a[i], b[i])
318
+ return cmp unless cmp.zero?
319
+ end
320
+
321
+ # All shared identifiers equal: the longer prerelease has higher precedence.
322
+ a.length <=> b.length
323
+ end
324
+
325
+ # Compares two prerelease identifiers: numeric identifiers compare
326
+ # numerically and rank below alphanumeric ones; alphanumeric identifiers
327
+ # compare in ASCII sort order (case-sensitive) per semver §11.
328
+ def compare_prerelease_id(a, b)
329
+ a_num = all_digits?(a)
330
+ b_num = all_digits?(b)
331
+ return compare_numeric_string(a, b) if a_num && b_num
332
+ return -1 if a_num
333
+ return 1 if b_num
334
+
335
+ a <=> b
336
+ end
337
+
338
+ # Compares two all-digit strings as non-negative integers without parsing
339
+ # (overflow-free): strip leading zeros, then the longer string is the
340
+ # larger number; equal lengths compare ordinally. Returns -1, 0, or 1.
341
+ def compare_numeric_string(a, b)
342
+ a = a.sub(/\A0+/, "")
343
+ b = b.sub(/\A0+/, "")
344
+ return a.length <=> b.length unless a.length == b.length
345
+
346
+ a <=> b
347
+ end
348
+
349
+ # Reports whether `s` is non-empty and contains only ASCII digits.
350
+ def all_digits?(s)
351
+ s.match?(/\A[0-9]+\z/)
352
+ end
86
353
  end
87
354
  end
88
355
  end
@@ -4,28 +4,54 @@ require_relative "bucketing"
4
4
  module Featureflip
5
5
  module Evaluation
6
6
  class Evaluator
7
+ # Mirrors packages/js-sdk/src/core/evaluator.ts. The guard uses `>` (not `>=`)
8
+ # so a chain of MAX_PREREQUISITE_DEPTH + 1 nested flags trips the cap — matches
9
+ # the JS reference implementation; see prerequisite_spec.rb depth test.
10
+ MAX_PREREQUISITE_DEPTH = 10
11
+
7
12
  def initialize
8
13
  @condition_evaluator = ConditionEvaluator.new
9
14
  end
10
15
 
11
- def evaluate(flag, context, get_segment: nil)
16
+ def evaluate(flag, context, get_segment: nil, all_flags: {})
17
+ evaluate_with_shared_memo(flag, context, get_segment: get_segment, all_flags: all_flags, memo: {})
18
+ end
19
+
20
+ def evaluate_with_shared_memo(flag, context, all_flags:, memo:, get_segment: nil)
21
+ evaluate_internal(flag, context, get_segment, all_flags, 0, memo)
22
+ end
23
+
24
+ private
25
+
26
+ def evaluate_internal(flag, context, get_segment, all_flags, depth, memo)
27
+ if depth > MAX_PREREQUISITE_DEPTH
28
+ return off_result(flag, reason: "Error")
29
+ end
30
+
12
31
  unless flag.enabled
13
- variation = flag.get_variation(flag.off_variation)
14
- return Models::EvaluationDetail.new(
15
- value: variation&.value,
16
- reason: "FlagDisabled",
17
- variation_key: flag.off_variation
18
- )
32
+ return off_result(flag, reason: "FlagDisabled")
19
33
  end
20
34
 
35
+ prereq_failure = resolve_prerequisites(flag, context, get_segment, all_flags, depth, memo)
36
+ return prereq_failure if prereq_failure
37
+
21
38
  sorted_rules = flag.rules.sort_by(&:priority)
22
39
  sorted_rules.each do |rule|
23
- conditions_match = if rule.segment_key && get_segment
24
- segment = get_segment.call(rule.segment_key)
25
- if segment
26
- @condition_evaluator.evaluate_conditions(
27
- segment.conditions, segment.condition_logic, context
28
- )
40
+ conditions_match = if rule.segment_key && !rule.segment_key.empty?
41
+ # A segment-keyed rule must resolve its segment to match. If the
42
+ # segment source isn't wired (get_segment is nil), or the segment
43
+ # can't be found, fail closed (no match) -- mirroring the engine +
44
+ # C# SDK -- rather than falling through to the rule's condition
45
+ # groups (which match unconditionally when empty). See #1459.
46
+ if get_segment
47
+ segment = get_segment.call(rule.segment_key)
48
+ if segment
49
+ @condition_evaluator.evaluate_conditions(
50
+ segment.conditions, segment.condition_logic, context
51
+ )
52
+ else
53
+ false
54
+ end
29
55
  else
30
56
  false
31
57
  end
@@ -38,25 +64,72 @@ module Featureflip
38
64
  if conditions_match
39
65
  variation_key = resolve_serve(rule.serve, context)
40
66
  variation = flag.get_variation(variation_key)
41
- return Models::EvaluationDetail.new(
67
+ result = Models::EvaluationDetail.new(
42
68
  value: variation&.value,
43
69
  reason: "RuleMatch",
44
70
  rule_id: rule.id,
45
71
  variation_key: variation_key
46
72
  )
73
+ memo[flag.key] = result
74
+ return result
47
75
  end
48
76
  end
49
77
 
50
78
  variation_key = resolve_serve(flag.fallthrough, context)
51
79
  variation = flag.get_variation(variation_key)
52
- Models::EvaluationDetail.new(
80
+ result = Models::EvaluationDetail.new(
53
81
  value: variation&.value,
54
82
  reason: "Fallthrough",
55
83
  variation_key: variation_key
56
84
  )
85
+ memo[flag.key] = result
86
+ result
57
87
  end
58
88
 
59
- private
89
+ # Returns nil when all prerequisites pass; otherwise returns the off-variation
90
+ # result for the flag and memoises it under flag.key. Mirrors the per-branch
91
+ # memo writes in the JS SDK evaluator.
92
+ def resolve_prerequisites(flag, context, get_segment, all_flags, depth, memo)
93
+ prerequisites = flag.prerequisites || []
94
+ prerequisites.each do |prereq|
95
+ key = prereq.prerequisite_flag_key
96
+ prereq_result = memo[key]
97
+
98
+ unless prereq_result
99
+ prereq_flag = all_flags[key]
100
+ unless prereq_flag
101
+ return memoise(memo, flag.key, off_result(flag, reason: "PrerequisiteFailed", prerequisite_key: key))
102
+ end
103
+
104
+ prereq_result = evaluate_internal(prereq_flag, context, get_segment, all_flags, depth + 1, memo)
105
+ memo[key] = prereq_result
106
+ end
107
+
108
+ if prereq_result.reason == "Error"
109
+ return memoise(memo, flag.key, off_result(flag, reason: "Error"))
110
+ end
111
+
112
+ if prereq_result.variation_key != prereq.expected_variation_key
113
+ return memoise(memo, flag.key, off_result(flag, reason: "PrerequisiteFailed", prerequisite_key: key))
114
+ end
115
+ end
116
+ nil
117
+ end
118
+
119
+ def memoise(memo, key, result)
120
+ memo[key] = result
121
+ result
122
+ end
123
+
124
+ def off_result(flag, reason:, prerequisite_key: nil)
125
+ variation = flag.get_variation(flag.off_variation)
126
+ Models::EvaluationDetail.new(
127
+ value: variation&.value,
128
+ reason: reason,
129
+ variation_key: flag.off_variation,
130
+ prerequisite_key: prerequisite_key
131
+ )
132
+ end
60
133
 
61
134
  def resolve_serve(serve, context)
62
135
  if serve.type == "Fixed"
@@ -74,6 +147,21 @@ module Featureflip
74
147
  bucket_value = context["userId"] if bucket_value.nil? && bucket_by == "user_id"
75
148
  bucket_value_str = bucket_value.nil? ? "" : bucket_value.to_s
76
149
 
150
+ # A Rollout serve can arrive with no weighted variations -- env-level PercentageRollout
151
+ # has nowhere to store per-variation weights, so the mapper emits type=Rollout with no
152
+ # variations (#1469). Degrade to the default fixed variation instead of returning an
153
+ # empty key. Mirrors the engine + C#/Java SDK evaluators.
154
+ return serve.variation || "" if (serve.variations || []).empty?
155
+
156
+ # Keyless user contexts can't be bucketed. Rather than hashing the empty value
157
+ # into an arbitrary salt-dependent bucket, serve the control (first) variation
158
+ # deterministically. The engine assigns a random GUID per eval (spreading
159
+ # anonymous users over HTTP); local SDK eval is deterministic, so parity is
160
+ # guaranteed only for keyed contexts (#1457).
161
+ if bucket_value_str == "" && %w[userId user_id].include?(bucket_by) && !(serve.variations || []).empty?
162
+ return serve.variations[0].key
163
+ end
164
+
77
165
  bucket = Bucketing.compute_bucket(serve.salt || "", bucket_value_str)
78
166
 
79
167
  cumulative = 0
@@ -82,7 +170,8 @@ module Featureflip
82
170
  return wv.key if bucket < cumulative
83
171
  end
84
172
 
85
- serve.variations&.last&.key || ""
173
+ # Guaranteed non-empty: the no-variations case returned the default above.
174
+ serve.variations.last.key
86
175
  end
87
176
  end
88
177
  end
@@ -82,7 +82,15 @@ module Featureflip
82
82
  variations: (data["variations"] || []).map { |v| Models::Variation.new(key: v["key"], value: v["value"]) },
83
83
  rules: (data["rules"] || []).map { |r| parse_rule(r) },
84
84
  fallthrough: parse_serve(data["fallthrough"]),
85
- off_variation: data["offVariation"]
85
+ off_variation: data["offVariation"],
86
+ prerequisites: (data["prerequisites"] || []).map { |p| parse_prerequisite(p) }
87
+ )
88
+ end
89
+
90
+ def parse_prerequisite(data)
91
+ Models::Prerequisite.new(
92
+ prerequisite_flag_key: data["prerequisiteFlagKey"],
93
+ expected_variation_key: data["expectedVariationKey"]
86
94
  )
87
95
  end
88
96
 
@@ -1,7 +1,7 @@
1
1
  module Featureflip
2
2
  module Models
3
- EvaluationDetail = Struct.new(:value, :reason, :rule_id, :variation_key, keyword_init: true) do
4
- def initialize(value:, reason:, rule_id: nil, variation_key: nil)
3
+ EvaluationDetail = Struct.new(:value, :reason, :rule_id, :variation_key, :prerequisite_key, keyword_init: true) do
4
+ def initialize(value:, reason:, rule_id: nil, variation_key: nil, prerequisite_key: nil)
5
5
  super
6
6
  end
7
7
  end
@@ -28,7 +28,16 @@ module Featureflip
28
28
  end
29
29
  end
30
30
 
31
- FlagConfiguration = Struct.new(:key, :version, :type, :enabled, :variations, :rules, :fallthrough, :off_variation, keyword_init: true) do
31
+ Prerequisite = Struct.new(:prerequisite_flag_key, :expected_variation_key, keyword_init: true)
32
+
33
+ FlagConfiguration = Struct.new(
34
+ :key, :version, :type, :enabled, :variations, :rules, :fallthrough, :off_variation, :prerequisites,
35
+ keyword_init: true
36
+ ) do
37
+ def initialize(key:, version:, type:, enabled:, variations:, rules:, fallthrough:, off_variation:, prerequisites: [])
38
+ super
39
+ end
40
+
32
41
  def get_variation(key)
33
42
  @variations_by_key ||= variations.each_with_object({}) { |v, h| h[v.key] = v }
34
43
  @variations_by_key[key]
@@ -135,7 +135,12 @@ module Featureflip
135
135
  return Models::EvaluationDetail.new(value: default_value, reason: "FlagNotFound")
136
136
  end
137
137
 
138
- result = @evaluator.evaluate(flag, context, get_segment: method(:get_segment))
138
+ result = @evaluator.evaluate(
139
+ flag,
140
+ context,
141
+ get_segment: method(:get_segment),
142
+ all_flags: @store.all_flags_map
143
+ )
139
144
  value = result.value.nil? ? default_value : result.value
140
145
  record_evaluation(key, context, result.variation_key)
141
146
 
@@ -143,10 +148,14 @@ module Featureflip
143
148
  value: value,
144
149
  reason: result.reason,
145
150
  rule_id: result.rule_id,
146
- variation_key: result.variation_key
151
+ variation_key: result.variation_key,
152
+ prerequisite_key: result.prerequisite_key
147
153
  )
148
154
  rescue StandardError
149
- Models::EvaluationDetail.new(value: default_value, reason: "Error")
155
+ # Prerequisite-resolution failures return PrerequisiteFailed cleanly through
156
+ # the evaluator; this rescue only fires on unexpected exceptions (malformed
157
+ # config, programming errors), so prerequisite_key has no defined value.
158
+ Models::EvaluationDetail.new(value: default_value, reason: "Error", prerequisite_key: nil)
150
159
  end
151
160
 
152
161
  # --- Event methods ---
@@ -28,6 +28,10 @@ module Featureflip
28
28
  @mutex.synchronize { @flags.values }
29
29
  end
30
30
 
31
+ def all_flags_map
32
+ @mutex.synchronize { @flags.dup }
33
+ end
34
+
31
35
  def upsert(flag)
32
36
  @mutex.synchronize do
33
37
  existing = @flags[flag.key]
@@ -1,3 +1,3 @@
1
1
  module Featureflip
2
- VERSION = "2.0.0"
2
+ VERSION = "2.2.0"
3
3
  end
metadata CHANGED
@@ -1,15 +1,29 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: featureflip
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.0
4
+ version: 2.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Featureflip
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-04-11 00:00:00.000000000 Z
11
+ date: 2026-06-19 00:00:00.000000000 Z
12
12
  dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: logger
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
13
27
  - !ruby/object:Gem::Dependency
14
28
  name: rspec
15
29
  requirement: !ruby/object:Gem::Requirement