e-volv-logs 0.1.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,228 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EvolveLogs
4
+ module Flags
5
+ # JavaScript value rules the Launch kernel depends on
6
+ # (packages/flags-kernel/PORTING.md). The kernel is TypeScript; where its
7
+ # answer depends on how JavaScript prints, parses or compares a value,
8
+ # this module reproduces JavaScript on purpose so a flag evaluates
9
+ # identically in Ruby and Node.
10
+ module JsValues
11
+ # The absent sentinel — JavaScript `undefined`, separate from nil.
12
+ MISSING = Object.new.freeze
13
+
14
+ # The exact set JavaScript's String.prototype.trim removes: WhiteSpace +
15
+ # LineTerminator (ECMA-262). Keep in sync with PORTING.md.
16
+ JS_WHITESPACE = " \t\n\v\f\r             

   "
17
+ DECIMAL = /\A[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?\z/.freeze
18
+ RADIX = /\A0(?:[xX][0-9a-fA-F]+|[oO][0-7]+|[bB][01]+)\z/.freeze
19
+ ISO =
20
+ /\A(\d{4})-(\d{2})-(\d{2})(?:[Tt ](\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,9}))?)?)?([Zz]|[+-]\d{2}:?\d{2})?\z/
21
+ .freeze
22
+ SEMVER_PART = /\A\s*[+-]?\d+/.freeze
23
+
24
+ module_function
25
+
26
+ def js_trim(s)
27
+ s.gsub(/\A[#{Regexp.escape(JS_WHITESPACE)}]+|[#{Regexp.escape(JS_WHITESPACE)}]+\z/o, "")
28
+ end
29
+
30
+ def number?(v)
31
+ v.is_a?(Integer) || v.is_a?(Float)
32
+ end
33
+
34
+ def js_type(v)
35
+ return "undefined" if v.equal?(MISSING)
36
+ return "object" if v.nil? || v.is_a?(Array) || v.is_a?(Hash)
37
+ return "boolean" if v.equal?(true) || v.equal?(false)
38
+ return "number" if number?(v)
39
+ return "string" if v.is_a?(String)
40
+
41
+ "object"
42
+ end
43
+
44
+ # js_number_string is ECMAScript Number::toString over Ruby's shortest
45
+ # round-trip Float#to_s ("1.0e+20", "0.1"): digits d (length k) and
46
+ # exponent n with x = 0.d × 10^n, then the ECMA digit-splitting rules.
47
+ def js_number_string(x)
48
+ x = x.to_f
49
+ return "NaN" if x.nan?
50
+ return(x.positive? ? "Infinity" : "-Infinity") if x.infinite?
51
+ return "0" if x.zero?
52
+ return "-#{js_number_string(-x)}" if x.negative?
53
+
54
+ mantissa, exponent = x.to_s.split("e")
55
+ exponent = exponent.nil? ? 0 : exponent.to_i
56
+ int_part, frac_part = mantissa.split(".")
57
+ frac_part ||= ""
58
+ raw = int_part + frac_part
59
+ n = int_part.length + exponent
60
+ stripped = raw.sub(/\A0+/, "")
61
+ n -= raw.length - stripped.length
62
+ d = stripped.sub(/0+\z/, "")
63
+ k = d.length
64
+ return d + ("0" * (n - k)) if k <= n && n <= 21
65
+ return "#{d[0, n]}.#{d[n..-1]}" if n.positive? && n <= 21
66
+ return "0.#{'0' * -n}#{d}" if n > -6 && n <= 0
67
+
68
+ e = n - 1
69
+ head = k == 1 ? d : "#{d[0]}.#{d[1..-1]}"
70
+ "#{head}e#{e >= 0 ? '+' : '-'}#{e.abs}"
71
+ end
72
+
73
+ # js_string is JavaScript's String(v) over the values a context carries.
74
+ def js_string(v)
75
+ return "null" if v.nil?
76
+ return "true" if v.equal?(true)
77
+ return "false" if v.equal?(false)
78
+ return js_number_string(v) if number?(v)
79
+ return v if v.is_a?(String)
80
+ if v.is_a?(Array)
81
+ return v.map { |item| item.nil? || item.equal?(MISSING) ? "" : js_string(item) }.join(",")
82
+ end
83
+
84
+ "[object Object]"
85
+ end
86
+
87
+ def strict_equals(a, b)
88
+ ta = js_type(a)
89
+ tb = js_type(b)
90
+ return false unless ta == tb
91
+ return a.to_f == b.to_f if ta == "number"
92
+ return a == b if ta == "string" || ta == "boolean"
93
+
94
+ ta == "undefined"
95
+ end
96
+
97
+ # loose_equals is JavaScript `==` over the scalars a context carries
98
+ # (PORTING.md): strict equality first, then string comparison across
99
+ # differing types.
100
+ def loose_equals(a, b)
101
+ return true if a.nil? && b.nil?
102
+ return true if strict_equals(a, b)
103
+ return false if a.nil? || a.equal?(MISSING) || b.nil? || b.equal?(MISSING)
104
+ return false if js_type(a) == js_type(b)
105
+
106
+ js_string(a) == js_string(b)
107
+ end
108
+
109
+ # as_number is JavaScript Number(v) for the value shapes a context
110
+ # carries: decimal strings, 0x/0o/0b radices; booleans and the rest
111
+ # refuse. Never raises.
112
+ def as_number(v)
113
+ return nil if v.equal?(true) || v.equal?(false)
114
+ return(v.to_f.finite? ? v.to_f : nil) if number?(v)
115
+ return nil unless v.is_a?(String)
116
+
117
+ s = js_trim(v)
118
+ return nil if s.empty?
119
+ if DECIMAL.match?(s)
120
+ # Ruby's Float() refuses "5." (JavaScript allows it) and accepts
121
+ # ".5" already; normalise both spellings.
122
+ normalised = s.end_with?(".") ? "#{s}0" : s
123
+ if normalised.start_with?("+.", "-.")
124
+ normalised = "#{normalised[0]}0#{normalised[1..-1]}"
125
+ elsif normalised.start_with?(".")
126
+ normalised = "0#{normalised}"
127
+ end
128
+ f = Float(normalised)
129
+ return f.finite? ? f : nil
130
+ end
131
+ return Integer(s, 0).to_f if RADIX.match?(s) # Integer("0b11", 0) accepts 0x/0o/0b
132
+
133
+ nil
134
+ rescue ArgumentError, TypeError
135
+ nil
136
+ end
137
+
138
+ # parse_iso_date is the one date grammar every SDK parses identically
139
+ # (src/dates.ts): ISO-8601 calendar date, optional time/fraction/offset,
140
+ # a missing offset is UTC, year 0000 is refused.
141
+ def parse_iso_date(s)
142
+ m = ISO.match(js_trim(s.to_s))
143
+ return nil unless m
144
+
145
+ y = m[1].to_i
146
+ mo = m[2].to_i
147
+ d = m[3].to_i
148
+ h = m[4].nil? ? 0 : m[4].to_i
149
+ mi = m[5].nil? ? 0 : m[5].to_i
150
+ sec = m[6].nil? ? 0 : m[6].to_i
151
+ frac = m[7] || ""
152
+ zone = m[8]
153
+ return nil if y.zero?
154
+ return nil if h > 23 || mi > 59 || sec > 59
155
+
156
+ # Time.utc normalises overflow instead of raising (2026-02-30 →
157
+ # March 2); the round-trip check below refuses it like every port.
158
+ moment = Time.utc(y, mo, d, h, mi, sec)
159
+ return nil unless moment.year == y && moment.month == mo && moment.day == d
160
+
161
+ millis = (frac + "000")[0, 3].to_i
162
+ offset = 0
163
+ if zone && zone != "Z" && zone != "z"
164
+ digits = zone[1..-1].delete(":")
165
+ offset = (zone[0] == "-" ? -1 : 1) * (digits[0, 2].to_i * 60 + digits[2, 2].to_i)
166
+ end
167
+ (moment.to_i * 1000 + millis - offset * 60_000).to_f
168
+ rescue ArgumentError, TypeError
169
+ nil
170
+ end
171
+
172
+ def as_date(v)
173
+ return nil if v.equal?(true) || v.equal?(false)
174
+ if number?(v)
175
+ f = v.to_f
176
+ return f.finite? ? f : nil
177
+ end
178
+ if v.is_a?(String) && !js_trim(v).empty?
179
+ parsed = parse_iso_date(v)
180
+ return parsed unless parsed.nil?
181
+
182
+ return as_number(v)
183
+ end
184
+ nil
185
+ end
186
+
187
+ # semver_parts: strip one leading lowercase v, take the text before the
188
+ # first "-", split on ".", each part reads its leading signed digits
189
+ # (else 0). Missing parts count as 0.
190
+ def semver_parts(v)
191
+ v = v.to_s
192
+ v = v[1..-1] if v.start_with?("v")
193
+ v.split("-").first.split(".").map do |part|
194
+ m = SEMVER_PART.match(part)
195
+ m ? m[0].to_i : 0
196
+ end
197
+ end
198
+
199
+ def semver_compare(a, b)
200
+ left = semver_parts(a)
201
+ right = semver_parts(b)
202
+ [left.length, right.length].max.times do |i|
203
+ x = i < left.length ? left[i] : 0
204
+ y = i < right.length ? right[i] : 0
205
+ diff = x - y
206
+ return(diff.negative? ? -1 : 1) unless diff.zero?
207
+ end
208
+ 0
209
+ end
210
+
211
+ # stringify is a bucketing subject: a stable string, or nothing.
212
+ def stringify(v)
213
+ return "true" if v.equal?(true)
214
+ return "false" if v.equal?(false)
215
+ return(v.empty? ? nil : v) if v.is_a?(String)
216
+ return js_number_string(v) if number?(v) && v.to_f.finite?
217
+
218
+ nil
219
+ end
220
+
221
+ # utf16_length counts UTF-16 code units — the 1,024 match limit is
222
+ # counted in these, so a code point above U+FFFF counts 2.
223
+ def utf16_length(s)
224
+ s.each_char.sum { |ch| ch.ord > 0xFFFF ? 2 : 1 }
225
+ end
226
+ end
227
+ end
228
+ end
@@ -0,0 +1,334 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest/sha1"
4
+
5
+ require "evolve_logs/flags/js_values"
6
+ require "evolve_logs/flags/context"
7
+ require "evolve_logs/flags/regex_cache"
8
+
9
+ module EvolveLogs
10
+ module Flags
11
+ # The answer to one evaluation — the served value, the chosen variant,
12
+ # and why (docs/LAUNCH-SDK.md §3).
13
+ Evaluation = Struct.new(:value, :variant, :reason)
14
+
15
+ # Port of packages/flags-kernel/src/evaluate.ts; PORTING.md has the port
16
+ # map. Evaluation is synchronous, never raises and never performs I/O —
17
+ # a malformed ruleset answers the caller's fallback with reason ERROR.
18
+ module Kernel
19
+ RULESET_VERSION = 2
20
+ MAX_PREREQUISITE_DEPTH = 16
21
+ MAX_MATCH_ATTRIBUTE_LENGTH = 1024
22
+ BUCKET_SPACE = 10_000
23
+
24
+ module_function
25
+
26
+ # bucket is the deterministic rollout bucket for (flag, subject):
27
+ # sha1, first eight hex digits, modulo the bucket space (src/bucket.ts).
28
+ def bucket(flag_key, subject, salt = "")
29
+ source = salt && !salt.empty? ? "#{flag_key}:#{salt}:#{subject}" : "#{flag_key}:#{subject}"
30
+ Digest::SHA1.hexdigest(source.encode(Encoding::UTF_8))[0, 8].to_i(16) % BUCKET_SPACE
31
+ end
32
+
33
+ # evaluate resolves one flag for one subject. Precedence, first match
34
+ # wins: not found → killed → off → unmet prerequisite → individual
35
+ # target → the first matching rule (its rollout, else its variant) →
36
+ # the default rollout, else the default variant, else the fallback when
37
+ # the variant named does not exist.
38
+ def evaluate(ruleset, flag_key, fallback, context = {}, is_member: nil, resolved: nil, depth: 0)
39
+ return Evaluation.new(fallback, nil, "ERROR") if depth > MAX_PREREQUISITE_DEPTH
40
+
41
+ flag = own(ruleset.is_a?(Hash) ? ruleset["flags"] : nil, flag_key)
42
+ return Evaluation.new(fallback, nil, "FLAG_NOT_FOUND") if flag.nil? || flag == false
43
+
44
+ contexts = resolved || Context.resolve(context.nil? ? {} : context)
45
+ segments = ruleset.is_a?(Hash) ? (ruleset["segments"] || {}) : {}
46
+ targeting = flag["targeting"]
47
+
48
+ serve = lambda do |variant_key, reason|
49
+ found = variant_value(flag, variant_key)
50
+ found.nil? ? Evaluation.new(fallback, nil, "ERROR") : Evaluation.new(found[0], found[1], reason)
51
+ end
52
+ served_off = lambda do |reason|
53
+ # offVariant falls back to defaultVariant when absent or null (??).
54
+ off = targeting["offVariant"]
55
+ off = targeting["defaultVariant"] if off.nil?
56
+ serve.call(off, reason)
57
+ end
58
+
59
+ return served_off.call("KILLED") if targeting["killed"]
60
+ return served_off.call("OFF") if targeting["on"] == false
61
+
62
+ (targeting["prerequisites"] || []).each do |prerequisite|
63
+ upstream = evaluate(ruleset, prerequisite["flag"], nil, context,
64
+ is_member: is_member, resolved: contexts, depth: depth + 1)
65
+ if upstream.variant.nil? || !(prerequisite["variants"] || []).include?(upstream.variant)
66
+ return served_off.call("PREREQUISITE_FAILED")
67
+ end
68
+ end
69
+
70
+ (targeting["targets"] || []).each do |target|
71
+ context_kind = target.key?("contextKind") ? target["contextKind"] : JsValues::MISSING
72
+ key = Context.key_of_kind(contexts, context_kind)
73
+ if !key.nil? && (target["keys"] || []).include?(key)
74
+ return serve.call(target["variant"], "TARGET_MATCH")
75
+ end
76
+ end
77
+
78
+ (targeting["rules"] || []).each_with_index do |rule, i|
79
+ next unless match_rule(rule, contexts, segments, is_member)
80
+
81
+ rollout = rule["rollout"]
82
+ if rollout.is_a?(Array) && !rollout.empty?
83
+ bucket_by = rule.key?("bucketBy") ? rule["bucketBy"] : JsValues::MISSING
84
+ rollout_kind = rule.key?("rolloutContextKind") ? rule["rolloutContextKind"] : JsValues::MISSING
85
+ subject, misconfigured = rollout_subject(contexts, bucket_by, rollout_kind)
86
+ return served_off.call("ERROR") if misconfigured
87
+
88
+ unless subject.nil?
89
+ salt = rule["id"].nil? ? "" : rule["id"]
90
+ found = variant_value(flag, slice_for(rollout, flag_key, salt, subject))
91
+ return Evaluation.new(found[0], found[1], "ROLLOUT") unless found.nil?
92
+ end
93
+ else
94
+ found = variant_value(flag, rule["variant"])
95
+ return Evaluation.new(found[0], found[1], "RULE:#{i}") unless found.nil?
96
+ end
97
+ # No identity to bucket on, or a rule naming a variant the flag does
98
+ # not have: fall through to the default rather than to the next
99
+ # rule — the rule DID match, and skipping on would serve a variant
100
+ # the author did not intend for this subject.
101
+ break
102
+ end
103
+
104
+ default_rollout = targeting["defaultRollout"]
105
+ if default_rollout.is_a?(Array) && !default_rollout.empty?
106
+ bucket_by = targeting.key?("defaultBucketBy") ? targeting["defaultBucketBy"] : JsValues::MISSING
107
+ rollout_kind = targeting.key?("defaultRolloutContextKind") ? targeting["defaultRolloutContextKind"] : JsValues::MISSING
108
+ subject, misconfigured = rollout_subject(contexts, bucket_by, rollout_kind)
109
+ return served_off.call("ERROR") if misconfigured
110
+
111
+ unless subject.nil?
112
+ found = variant_value(flag, slice_for(default_rollout, flag_key, "default", subject))
113
+ return Evaluation.new(found[0], found[1], "ROLLOUT") unless found.nil?
114
+ end
115
+ end
116
+
117
+ serve.call(targeting["defaultVariant"], "DEFAULT")
118
+ rescue StandardError, SystemStackError
119
+ Evaluation.new(fallback, nil, "ERROR")
120
+ end
121
+
122
+ # -- matching ----------------------------------------------------------
123
+
124
+ # match_operator answers one clause operator against the actual value.
125
+ # An operator this build does not know never matches: refusing is the
126
+ # safe direction for a rule nobody understands.
127
+ def match_operator(op, actual, values)
128
+ is_str = actual.is_a?(String)
129
+ case op
130
+ when "exists"
131
+ !actual.equal?(JsValues::MISSING) && !actual.nil?
132
+ when "notExists"
133
+ actual.equal?(JsValues::MISSING) || actual.nil?
134
+ when "eq", "in"
135
+ values.any? { |v| JsValues.loose_equals(actual, v) }
136
+ when "neq", "notIn"
137
+ !values.any? { |v| JsValues.loose_equals(actual, v) }
138
+ when "contains"
139
+ is_str && values.any? { |v| actual.include?(JsValues.js_string(v)) }
140
+ when "notContains"
141
+ !is_str || !values.any? { |v| actual.include?(JsValues.js_string(v)) }
142
+ when "startsWith"
143
+ is_str && values.any? { |v| actual.start_with?(JsValues.js_string(v)) }
144
+ when "endsWith"
145
+ is_str && values.any? { |v| actual.end_with?(JsValues.js_string(v)) }
146
+ when "matches"
147
+ is_str &&
148
+ JsValues.utf16_length(actual) <= MAX_MATCH_ATTRIBUTE_LENGTH &&
149
+ values.any? { |v| search(v, actual) }
150
+ when "notMatches"
151
+ # A pattern that will not compile matches nothing, so its negation
152
+ # matches everything — but an over-long attribute is refused
153
+ # outright rather than folded into that fallback.
154
+ if is_str && JsValues.utf16_length(actual) > MAX_MATCH_ATTRIBUTE_LENGTH
155
+ return false
156
+ end
157
+
158
+ !is_str || !values.any? { |v| search(v, actual) }
159
+ when "gt", "gte", "lt", "lte"
160
+ values.any? { |v| compare_numeric(actual, v, op) }
161
+ when "before", "after"
162
+ a = JsValues.as_date(actual)
163
+ return false if a.nil?
164
+
165
+ values.any? do |v|
166
+ b = JsValues.as_date(v)
167
+ !b.nil? && (op == "before" ? a < b : a > b)
168
+ end
169
+ when "semverEq", "semverGt", "semverLt", "semverGte", "semverLte"
170
+ return false unless is_str
171
+
172
+ values.any? do |v|
173
+ comparison = JsValues.semver_compare(actual, JsValues.js_string(v))
174
+ case op
175
+ when "semverEq" then comparison.zero?
176
+ when "semverGt" then comparison.positive?
177
+ when "semverLt" then comparison.negative?
178
+ when "semverGte" then comparison >= 0
179
+ else comparison <= 0
180
+ end
181
+ end
182
+ else
183
+ false
184
+ end
185
+ end
186
+
187
+ # match_clauses: every clause must match; absent clauses match
188
+ # everything.
189
+ def match_clauses(clauses, contexts)
190
+ (clauses || []).each do |clause|
191
+ kind = clause.key?("contextKind") ? clause["contextKind"] : JsValues::MISSING
192
+ actual = Context.attribute_of_kind(contexts, kind, clause["attribute"])
193
+ return false unless match_operator(clause["op"], actual, clause["values"] || [])
194
+ end
195
+ true
196
+ end
197
+
198
+ # match_segment: excluded beats included beats rules — the order people
199
+ # expect from an allow list with an override. A rule carrying a numeric
200
+ # weight matches only a deterministic share of what it otherwise would,
201
+ # bucketed on the segment key.
202
+ def match_segment(segment, contexts, is_member)
203
+ return false unless segment.is_a?(Hash)
204
+
205
+ kind = segment.key?("contextKind") ? segment["contextKind"] : JsValues::MISSING
206
+ kind = Context.kind_or_default(kind)
207
+ key = Context.key_of_kind(contexts, kind)
208
+
209
+ if segment["external"]
210
+ # The lists are too large to ship; "not a member" is the narrower
211
+ # answer when no callback can say.
212
+ return false if is_member.nil? || key.nil?
213
+ return true if is_member.call(segment["key"], kind, key)
214
+ elsif !key.nil?
215
+ return false if (segment["excluded"] || []).include?(key)
216
+ return true if (segment["included"] || []).include?(key)
217
+ end
218
+
219
+ (segment["rules"] || []).each do |rule|
220
+ next unless match_clauses(rule["clauses"], contexts)
221
+
222
+ weight = rule["weight"]
223
+ return true unless weight.is_a?(Numeric)
224
+
225
+ # bucketBy present (even null) means "bucket on this attribute".
226
+ subject = if rule.key?("bucketBy")
227
+ JsValues.stringify(Context.attribute_of_kind(contexts, kind, rule["bucketBy"]))
228
+ else
229
+ key
230
+ end
231
+ next if subject.nil?
232
+
233
+ return true if bucket(segment["key"], subject, "segment") < clamp_weight(weight)
234
+ end
235
+ false
236
+ end
237
+
238
+ # segment_conditions normalises the two shapes Rule.segments carries:
239
+ # a bare string list (pre-negation rulesets) means "in all of these".
240
+ def segment_conditions(segments)
241
+ return [] if segments.nil? || segments.empty?
242
+ return segments.map { |k| { "keys" => [k] } } if segments[0].is_a?(String)
243
+
244
+ segments
245
+ end
246
+
247
+ def match_rule(rule, contexts, segments, is_member)
248
+ return false unless match_clauses(rule["clauses"], contexts)
249
+
250
+ segment_conditions(rule["segments"]).each do |condition|
251
+ in_any = (condition["keys"] || []).any? do |k|
252
+ match_segment(own(segments, k), contexts, is_member)
253
+ end
254
+ negate = condition["negate"]
255
+ return false if negate ? in_any : !in_any
256
+ end
257
+ true
258
+ end
259
+
260
+ # -- rollouts ------------------------------------------------------------
261
+
262
+ # slice_for: which slice of a rollout this subject lands in. Weights
263
+ # summing under the bucket space serve the last slice rather than
264
+ # nothing — a mis-summed rollout degrades instead of failing.
265
+ def slice_for(slices, flag_key, salt, subject)
266
+ point = bucket(flag_key, subject, salt)
267
+ cursor = 0
268
+ slices.each do |s|
269
+ cursor += s["weight"]
270
+ return s["variant"] if point < cursor
271
+ end
272
+ slices.empty? ? nil : slices[-1]["variant"]
273
+ end
274
+
275
+ # rollout_subject: the subject a rollout buckets on. No context key at
276
+ # all is an anonymous caller — the rollout declines and lets the
277
+ # default serve. An explicit bucketBy naming an attribute the context
278
+ # does not carry is a misconfigured rollout: serving anything at all
279
+ # would measure against an identity nobody chose.
280
+ def rollout_subject(contexts, bucket_by, context_kind)
281
+ kind = Context.kind_or_default(context_kind)
282
+ if bucket_by.equal?(JsValues::MISSING)
283
+ return [Context.key_of_kind(contexts, kind), false]
284
+ end
285
+
286
+ subject = JsValues.stringify(Context.attribute_of_kind(contexts, kind, bucket_by))
287
+ [subject, subject.nil?]
288
+ end
289
+
290
+ def variant_value(flag, variant_key)
291
+ return nil if variant_key.nil? || variant_key == false || variant_key == ""
292
+
293
+ flag["definition"]["variants"].each do |v|
294
+ return [v["value"], v["key"]] if v["key"] == variant_key
295
+ end
296
+ nil
297
+ end
298
+
299
+ def clamp_weight(weight)
300
+ return 0 unless weight.is_a?(Numeric) && weight.to_f.finite?
301
+
302
+ [[weight, 0].max, BUCKET_SPACE].min
303
+ end
304
+
305
+ # search runs one `matches` pattern: compiled once, cached, never
306
+ # raising — a pattern that fails to compile matches nothing.
307
+ def search(pattern, actual)
308
+ compiled = RegexCache.compile(JsValues.js_string(pattern))
309
+ !compiled.nil? && compiled.match?(actual)
310
+ end
311
+
312
+ def compare_numeric(actual, expected, op)
313
+ a = JsValues.as_number(actual)
314
+ b = JsValues.as_number(expected)
315
+ return false if a.nil? || b.nil?
316
+
317
+ case op
318
+ when "gt" then a > b
319
+ when "gte" then a >= b
320
+ when "lt" then a < b
321
+ else a <= b
322
+ end
323
+ end
324
+
325
+ # own reads a record through its own keys only: a flag or segment named
326
+ # like a prototype member must stay "not found". JSON.parse-built hashes
327
+ # are own-key by construction; the key? guard keeps it true for any
328
+ # hash, default-proc or not.
329
+ def own(record, key)
330
+ record.is_a?(Hash) && record.key?(key) ? record[key] : nil
331
+ end
332
+ end
333
+ end
334
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EvolveLogs
4
+ module Flags
5
+ # Launch flags client options (docs/LAUNCH-SDK.md §4, Ruby spellings).
6
+ #
7
+ # `cache` is nil for the default cache dir, false to disable caching, or
8
+ # a directory path. `exposures` carries the §7 knobs: enabled,
9
+ # sample_rate, dedupe_window_seconds, send_attributes.
10
+ class Options
11
+ attr_reader :enabled, :url, :mode, :poll_interval_seconds, :cache, :bootstrap,
12
+ :exposures, :private_attributes, :stale_after_seconds
13
+
14
+ # Exposure option keys, snake_case (built from a Hash that may use
15
+ # strings or symbols).
16
+ EXPOSURE_KEYS = %i[enabled sample_rate dedupe_window_seconds send_attributes].freeze
17
+
18
+ def initialize(enabled: true, url: nil, mode: "stream", poll_interval_seconds: 30,
19
+ cache: nil, bootstrap: nil, exposures: {}, private_attributes: [],
20
+ stale_after_seconds: 300)
21
+ @enabled = enabled
22
+ @url = url
23
+ @mode = mode
24
+ @poll_interval_seconds = poll_interval_seconds
25
+ @cache = cache
26
+ @bootstrap = bootstrap
27
+ @exposures = self.class.normalize_exposures(exposures)
28
+ @private_attributes = private_attributes || []
29
+ @stale_after_seconds = stale_after_seconds
30
+ end
31
+
32
+ # build normalises the `flags:` argument of EvolveLogs.init /
33
+ # EvolveLogs::Client.new: nil, an Options, or a Hash with string or
34
+ # symbol keys. Unknown keys are ignored so a shared config hash never
35
+ # raises.
36
+ def self.build(value)
37
+ return new if value.nil?
38
+ return value if value.is_a?(Options)
39
+
40
+ hash = {}
41
+ if value.is_a?(Hash)
42
+ value.each do |k, v|
43
+ key = k.to_s.to_sym
44
+ hash[key] = v if %i[enabled url mode poll_interval_seconds cache bootstrap
45
+ exposures private_attributes stale_after_seconds].include?(key)
46
+ end
47
+ end
48
+ hash[:exposures] = normalize_exposures(hash[:exposures])
49
+ new(**hash)
50
+ end
51
+
52
+ def self.normalize_exposures(value)
53
+ return {} unless value.is_a?(Hash)
54
+
55
+ out = {}
56
+ value.each do |k, v|
57
+ key = k.to_s.to_sym
58
+ out[key] = v if EXPOSURE_KEYS.include?(key)
59
+ end
60
+ out
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EvolveLogs
4
+ module Flags
5
+ # Port of flags-kernel/src/regex.ts on Onigmo: compile once, cache ≤ 256
6
+ # (FIFO eviction), a pattern that fails to compile never matches.
7
+ #
8
+ # JavaScript (no m flag) anchors ^ and $ to the whole input; Ruby anchors
9
+ # them to lines — translate() rewrites them outside character classes so
10
+ # the same pattern answers the same question in both engines. `\d`, `\w`
11
+ # and `\b` are already ASCII in Ruby, as in JavaScript.
12
+ module RegexCache
13
+ LIMIT = 256
14
+ @cache = {}
15
+ @mutex = Mutex.new
16
+
17
+ class << self
18
+ # compile returns the Regexp or nil; nil is cached too — a pattern
19
+ # that does not compile does not compile again.
20
+ def compile(pattern)
21
+ @mutex.synchronize do
22
+ return @cache[pattern] if @cache.key?(pattern)
23
+
24
+ compiled = begin
25
+ Regexp.new(translate(pattern))
26
+ rescue RegexpError, ArgumentError
27
+ nil
28
+ end
29
+ @cache.delete(@cache.keys.first) if @cache.size >= LIMIT
30
+ @cache[pattern] = compiled
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ # translate rewrites JavaScript anchors to Ruby's input anchors. A `^`
37
+ # directly after `[` is a negation and stays; escaped characters pass
38
+ # through; `(?` constructs are untouched (lookahead is Onigmo-valid,
39
+ # and the write-time portability check refuses it before a ruleset
40
+ # can carry it).
41
+ def translate(pattern)
42
+ out = +""
43
+ in_class = false
44
+ i = 0
45
+ while i < pattern.length
46
+ ch = pattern[i]
47
+ if ch == "\\" && i + 1 < pattern.length
48
+ out << pattern[i, 2]
49
+ i += 2
50
+ next
51
+ end
52
+ if in_class
53
+ in_class = false if ch == "]"
54
+ out << ch
55
+ elsif ch == "["
56
+ in_class = true
57
+ out << ch
58
+ elsif ch == "^"
59
+ out << "\\A"
60
+ elsif ch == "$"
61
+ out << "\\z"
62
+ else
63
+ out << ch
64
+ end
65
+ i += 1
66
+ end
67
+ out
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end