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 +4 -4
- data/lib/featureflip/evaluation/condition_evaluator.rb +286 -19
- data/lib/featureflip/evaluation/evaluator.rb +106 -17
- data/lib/featureflip/http/client.rb +9 -1
- data/lib/featureflip/models/evaluation_detail.rb +2 -2
- data/lib/featureflip/models/flag.rb +10 -1
- data/lib/featureflip/shared_core.rb +12 -3
- data/lib/featureflip/store/flag_store.rb +4 -0
- data/lib/featureflip/version.rb +1 -1
- metadata +16 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2fdefb980d91435aca01a6f6344804e172dedef867fd826c5ac9a017ca6416ca
|
|
4
|
+
data.tar.gz: d98b0cda83055b8b984b2ea370b9717f6800dd44202ddacf21944fa19b0dc194
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
10
|
-
|
|
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
|
-
|
|
87
|
+
ci_targets.any? { |t| ci_value == t }
|
|
40
88
|
when "NotEquals"
|
|
41
|
-
|
|
89
|
+
ci_targets.all? { |t| ci_value != t }
|
|
42
90
|
when "Contains"
|
|
43
|
-
|
|
91
|
+
ci_targets.any? { |t| ci_value.include?(t) }
|
|
44
92
|
when "NotContains"
|
|
45
|
-
|
|
93
|
+
ci_targets.all? { |t| !ci_value.include?(t) }
|
|
46
94
|
when "StartsWith"
|
|
47
|
-
|
|
95
|
+
ci_targets.any? { |t| ci_value.start_with?(t) }
|
|
48
96
|
when "EndsWith"
|
|
49
|
-
|
|
97
|
+
ci_targets.any? { |t| ci_value.end_with?(t) }
|
|
50
98
|
when "In"
|
|
51
|
-
|
|
99
|
+
ci_targets.include?(ci_value)
|
|
52
100
|
when "NotIn"
|
|
53
|
-
!
|
|
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,
|
|
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(
|
|
121
|
+
ci_targets.any? { |t| compare_numeric(ci_value, t, :>) }
|
|
62
122
|
when "GreaterThanOrEqual"
|
|
63
|
-
compare_numeric(
|
|
123
|
+
ci_targets.any? { |t| compare_numeric(ci_value, t, :>=) }
|
|
64
124
|
when "LessThan"
|
|
65
|
-
compare_numeric(
|
|
125
|
+
ci_targets.any? { |t| compare_numeric(ci_value, t, :<) }
|
|
66
126
|
when "LessThanOrEqual"
|
|
67
|
-
compare_numeric(
|
|
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
|
-
|
|
70
|
-
value < targets[0]
|
|
135
|
+
targets.any? { |t| compare_datetime(value, t, :<) }
|
|
71
136
|
when "After"
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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 &&
|
|
24
|
-
segment
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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 ---
|
data/lib/featureflip/version.rb
CHANGED
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.
|
|
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-
|
|
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
|