convert_sdk 1.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 (47) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +191 -0
  4. data/.yardopts +16 -0
  5. data/CONTRIBUTING.md +131 -0
  6. data/LICENSE +201 -0
  7. data/README.md +183 -0
  8. data/RELEASE.md +313 -0
  9. data/Rakefile +16 -0
  10. data/convert_sdk.gemspec +50 -0
  11. data/lib/convert_sdk/api_manager.rb +288 -0
  12. data/lib/convert_sdk/background_timer.rb +129 -0
  13. data/lib/convert_sdk/bucketed_feature.rb +35 -0
  14. data/lib/convert_sdk/bucketed_variation.rb +43 -0
  15. data/lib/convert_sdk/bucketing_manager.rb +134 -0
  16. data/lib/convert_sdk/client.rb +417 -0
  17. data/lib/convert_sdk/comparisons.rb +257 -0
  18. data/lib/convert_sdk/config.rb +214 -0
  19. data/lib/convert_sdk/config_validator.rb +127 -0
  20. data/lib/convert_sdk/context.rb +618 -0
  21. data/lib/convert_sdk/data_manager.rb +897 -0
  22. data/lib/convert_sdk/data_store_manager.rb +185 -0
  23. data/lib/convert_sdk/enums/bucketing_error.rb +18 -0
  24. data/lib/convert_sdk/enums/feature_status.rb +13 -0
  25. data/lib/convert_sdk/enums/goal_data_key.rb +62 -0
  26. data/lib/convert_sdk/enums/log_level.rb +22 -0
  27. data/lib/convert_sdk/enums/rule_error.rb +19 -0
  28. data/lib/convert_sdk/enums/system_events.rb +29 -0
  29. data/lib/convert_sdk/event_manager.rb +125 -0
  30. data/lib/convert_sdk/experience_manager.rb +69 -0
  31. data/lib/convert_sdk/feature_manager.rb +367 -0
  32. data/lib/convert_sdk/fork_guard.rb +144 -0
  33. data/lib/convert_sdk/http_client.rb +198 -0
  34. data/lib/convert_sdk/log_manager.rb +168 -0
  35. data/lib/convert_sdk/murmur_hash3.rb +129 -0
  36. data/lib/convert_sdk/redactor.rb +93 -0
  37. data/lib/convert_sdk/rule_manager.rb +242 -0
  38. data/lib/convert_sdk/segments_manager.rb +241 -0
  39. data/lib/convert_sdk/sentinel.rb +57 -0
  40. data/lib/convert_sdk/stores/memory_store.rb +55 -0
  41. data/lib/convert_sdk/stores/redis_store.rb +126 -0
  42. data/lib/convert_sdk/version.rb +14 -0
  43. data/lib/convert_sdk/visitors_queue.rb +190 -0
  44. data/lib/convert_sdk.rb +218 -0
  45. data/scripts/check-generated-rbs-header.sh +41 -0
  46. data/steep/config_contract_probe.rb +154 -0
  47. metadata +93 -0
@@ -0,0 +1,417 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module ConvertSdk
6
+ # The SDK runtime handle returned by {ConvertSdk.create}.
7
+ #
8
+ # +Client+ owns the wiring of the injected managers (config, logging, HTTP,
9
+ # store, events, data) and drives the config lifecycle at construction:
10
+ #
11
+ # * *Direct data mode* (+data:+ supplied) — the inline object is normalised to
12
+ # string keys and installed straight into {DataManager}; NO config fetch
13
+ # happens (a single network-free path for testing / advanced setups).
14
+ # * *Fetch mode* (+sdk_key:+ only) — config is fetched via
15
+ # +GET {config_endpoint}/config/{sdkKey}+ (+?environment=...+ when set)
16
+ # through {HttpClient} ONLY, with +Authorization: Bearer {sdk_key_secret}+
17
+ # attached when a secret is configured. A failed fetch is logged at +warn+
18
+ # and the client is constructed WITHOUT config (degrade-gracefully, NFR12) —
19
+ # it never raises.
20
+ #
21
+ # == ready exactly once (FR9)
22
+ #
23
+ # The first successful config install (fetched OR direct) fires
24
+ # {SystemEvents::READY} exactly once for the client's lifetime; the once-guard
25
+ # is the +:first+ marker {DataManager#install_config} computes atomically inside
26
+ # its config mutex. Subsequent installs (Story 2.7's refresh) fire
27
+ # {SystemEvents::CONFIG_UPDATED}, never +ready+ again.
28
+ #
29
+ # == Never-crash boundary
30
+ #
31
+ # Every public method rescues +StandardError+, logs it, and returns its
32
+ # per-contract value — only {ConvertSdk.create}'s +ArgumentError+ (raised by
33
+ # {Config} on misconfiguration) is allowed to escape. The endpoints are touched
34
+ # ONLY through {HttpClient} (the single hardened HTTP port); the Client never
35
+ # touches the network library directly or builds wire headers beyond passing
36
+ # the Bearer header VALUE through the port.
37
+ #
38
+ # == Decisioning surface (fully wired)
39
+ #
40
+ # {#create_context} injects the per-context decisioning managers
41
+ # ({ExperienceManager}, {FeatureManager}, {SegmentsManager}) and the outbound
42
+ # {ApiManager} into each {Context}, so a context returned by a factory-built
43
+ # client decides through the real Story 2.9–3.2 machinery and enqueues bucketing
44
+ # / conversion events for delivery. No background threads are started by the
45
+ # Client or the factory (NFR4 lazy start); the refresh timer (Story 2.7),
46
+ # flush/fork/at_exit (Epic 4) wiring is lazy. Constructor injection throughout —
47
+ # no globals.
48
+ class Client
49
+ # @param config [Config] the validated configuration surface.
50
+ # @param log_manager [LogManager] shared logging surface (secrets armed).
51
+ # @param http_client [HttpClient] the single HTTP port (config fetch only here).
52
+ # @param data_store_manager [DataStoreManager] persistence port (wired; config
53
+ # caching is Story 2.7 — in-memory install only here).
54
+ # @param event_manager [EventManager] lifecycle pub/sub (fires +ready+).
55
+ # @param data_manager [DataManager] holds the deep-frozen config snapshot.
56
+ # @param api_manager [ApiManager] the outbound event queue + delivery surface
57
+ # (Story 4.1) — drives {#flush} and the bucketing-event enqueue seam.
58
+ # @param experience_manager [ExperienceManager, nil] the variation-selection
59
+ # surface (Story 2.11) injected into every {Context}. nil leaves contexts
60
+ # decisioning-less (the never-crash unit harness builds clients without it).
61
+ # @param feature_manager [FeatureManager, nil] the feature-resolution surface
62
+ # (Story 3.1) injected into every {Context}. nil leaves features miss-only.
63
+ # @param segments_manager [SegmentsManager, nil] the visitor-segmentation
64
+ # surface (Story 3.2) injected into every {Context}. nil leaves segmentation
65
+ # inert.
66
+ def initialize(config:, log_manager:, http_client:, data_store_manager:,
67
+ event_manager:, data_manager:, api_manager:,
68
+ experience_manager: nil, feature_manager: nil, segments_manager: nil)
69
+ @config = config
70
+ @log_manager = log_manager
71
+ @http_client = http_client
72
+ @data_store_manager = data_store_manager
73
+ @event_manager = event_manager
74
+ @data_manager = data_manager
75
+ @api_manager = api_manager
76
+ @experience_manager = experience_manager
77
+ @feature_manager = feature_manager
78
+ @segments_manager = segments_manager
79
+ # The lazily-started config-refresh BackgroundTimer (Story 2.7). Created
80
+ # here (interval bound, registered with ForkGuard) but NEVER started in the
81
+ # factory — #ensure_refresh_timer! starts it on first decision-path use
82
+ # (NFR4). A nil data_refresh_interval makes #start a guarded no-op (2.6),
83
+ # so timer-off mode never spawns a thread.
84
+ @refresh_timer = build_refresh_timer
85
+ # Wire the synchronous timer-off refresh callable into the DataManager so
86
+ # its decision-time TTL check (#ensure_fresh_config!) runs one full refresh
87
+ # cycle through the single HTTP port (the I/O happens under DataManager's
88
+ # thundering-herd fetch mutex, never the config mutex).
89
+ @data_manager.refetch = -> { refresh_config }
90
+ bootstrap_config
91
+ # Story 4.4 AC#5 — register the PID-guarded at_exit flush ONCE per client
92
+ # (the gem's ONLY at_exit site; the third and final single-site after the
93
+ # BackgroundTimer thread-spawn site and the ForkGuard _fork site).
94
+ # Registering an at_exit handler creates NO thread (NFR4-safe). The test
95
+ # harness disables live registration (the handler body is tested directly).
96
+ register_at_exit_flush
97
+ rescue StandardError => e
98
+ # Construction must never crash the host: log and continue config-less.
99
+ @log_manager.error("Client#initialize: #{e.class}: #{e.message}")
100
+ end
101
+
102
+ # @return [Config] the configuration this client was built with.
103
+ attr_reader :config
104
+
105
+ # @return [DataManager] the config snapshot reader surface.
106
+ attr_reader :data_manager
107
+
108
+ # @return [ApiManager] the outbound event queue + delivery surface (Story 4.1).
109
+ attr_reader :api_manager
110
+
111
+ # Subscribe to a lifecycle event. Public API; delegates to {EventManager#on}
112
+ # (which normalises {SystemEvents} constants and matching strings to one key
113
+ # and replays deferred one-shot events to late subscribers).
114
+ #
115
+ # @param event [String] a {SystemEvents} value or matching string.
116
+ # @yieldparam payload [Object, nil]
117
+ # @yieldparam err [Object, nil]
118
+ # @return [self]
119
+ def on(event, &)
120
+ @event_manager.on(event, &)
121
+ self
122
+ rescue StandardError => e
123
+ @log_manager.error("Client#on: #{e.class}: #{e.message}")
124
+ self
125
+ end
126
+
127
+ # @return [Boolean] true once a config snapshot is installed (degrade probe).
128
+ def config_available?
129
+ @data_manager.config_available?
130
+ end
131
+
132
+ # Create a per-visitor decisioning {Context} — the object an integrator holds
133
+ # for the lifetime of one request/job.
134
+ #
135
+ # The +visitor_id+ is validated for presence (blank/nil → an +error+ log line
136
+ # + +nil+ return, NOT an +ArgumentError+: validation here is request-time, and
137
+ # the never-crash contract forbids raising into the host on a per-request call;
138
+ # only {ConvertSdk.create}'s config-time misconfiguration raises). Creation is
139
+ # the SDK's "first use" trigger, so it fires the lazy-start refresh-timer hook
140
+ # ({#ensure_refresh_timer!}, NFR4) — no threads start before the first context.
141
+ #
142
+ # Each call returns a NEW, independent {Context} (no caching, no shared mutable
143
+ # in-memory visitor state across instances — FR12); contexts for the same
144
+ # visitor share only the store-backed {StoreData} (stickiness). Attributes are
145
+ # deep-stringified at the {Context} boundary.
146
+ #
147
+ # @param visitor_id [String] the visitor id (must be non-blank).
148
+ # @param attributes [Hash, nil] optional per-visitor attributes.
149
+ # @return [Context, nil] the new Context, or nil for a blank visitor id.
150
+ def create_context(visitor_id = nil, attributes = nil)
151
+ if visitor_id.nil? || (visitor_id.respond_to?(:strip) && visitor_id.strip.empty?)
152
+ @log_manager.error("Client#create_context: blank visitor_id; returning nil")
153
+ return nil
154
+ end
155
+
156
+ # Story 4.4 — re-arm when a Process.daemon bypass left owner_pid stale.
157
+ # Mirrors ApiManager#guard_fork_boundary and Client#postfork: after rearm!
158
+ # marks the inherited refresh timer dead, the ensure_refresh_timer! call
159
+ # below spawns a fresh thread (BackgroundTimer#start is a no-op only when
160
+ # @running is true; mark_dead resets it to false). The check is a free PID
161
+ # comparison; always false on JRuby.
162
+ ForkGuard.rearm! if ForkGuard.forked?
163
+
164
+ # Context creation is "first use" — lazily arm the background refresh timer.
165
+ ensure_refresh_timer!
166
+ build_context(visitor_id, attributes)
167
+ rescue StandardError => e
168
+ @log_manager.error("Client#create_context: #{e.class}: #{e.message}")
169
+ nil
170
+ end
171
+
172
+ # Lazily start the background config-refresh timer (NFR4 — never in the
173
+ # factory). Called at the first decision-path entry (consumed by 2.8/2.11);
174
+ # idempotent (2.6 {BackgroundTimer#start} is idempotent and re-arms after a
175
+ # fork). A nil +data_refresh_interval+ makes this a guarded no-op (no thread
176
+ # is ever created — timer-off mode). Never raises into the host.
177
+ # @return [self]
178
+ def ensure_refresh_timer!
179
+ @refresh_timer.start
180
+ self
181
+ rescue StandardError => e
182
+ @log_manager.error("Client#ensure_refresh_timer!: #{e.class}: #{e.message}")
183
+ self
184
+ end
185
+
186
+ # Decision-time freshness hook for timer-off mode (Lambda/CLI). Delegates to
187
+ # {DataManager#ensure_fresh_config!}, which performs an on-demand TTL check
188
+ # and a synchronous, thundering-herd-guarded refetch when the cached config
189
+ # is stale. A no-op when the refresh timer is enabled. Never raises.
190
+ # @return [self]
191
+ def ensure_fresh_config!
192
+ @data_manager.ensure_fresh_config!
193
+ self
194
+ rescue StandardError => e
195
+ @log_manager.error("Client#ensure_fresh_config!: #{e.class}: #{e.message}")
196
+ self
197
+ end
198
+
199
+ # Explicitly release the queued visitor events synchronously (FR40). THE
200
+ # single flush entry point — delegates to {ApiManager#release_queue}, which
201
+ # drains-and-swaps inside the queue lock and POSTs OUTSIDE it (the enqueue
202
+ # path is never blocked on network I/O). A failed POST is logged inside the
203
+ # ApiManager and never raised; the full failed-POST queue-retention behaviour
204
+ # lands in Story 4.2. An empty queue is a no-op.
205
+ #
206
+ # +release_queues+ is the frozen-name alias (FR40) and shares this exact path.
207
+ #
208
+ # NOTE (Story 4.4 seam): this is the single point where a ForkGuard PID check
209
+ # will gate the flush in a forked child — added here so all flush callers
210
+ # (explicit, at_exit, timer) inherit it from one place.
211
+ #
212
+ # Never raises into the host (NFR9 never-crash boundary).
213
+ #
214
+ # @param reason [String, nil] a human-readable release reason (logged).
215
+ # @return [self]
216
+ def flush(reason = nil)
217
+ @api_manager.release_queue(reason)
218
+ self
219
+ rescue StandardError => e
220
+ @log_manager.error("Client#flush: #{e.class}: #{e.message}")
221
+ self
222
+ end
223
+ alias release_queues flush
224
+
225
+ # Manually re-arm the SDK after a fork (Story 4.4 AC#4 — frozen API name
226
+ # +postfork+). The exotic-setup escape hatch: in the default Puma/Unicorn/
227
+ # Sidekiq deployments the +Process._fork+ hook (ForkGuard) and the PID checks
228
+ # at flush boundaries detect forks AUTOMATICALLY with zero configuration, so
229
+ # +postfork+ is rarely needed. It exists for setups that bypass +_fork+
230
+ # entirely and never reach a flush boundary in time (or integrators who prefer
231
+ # an explicit +on_worker_boot+/+after_fork+ call, LaunchDarkly-style).
232
+ #
233
+ # Delegates to the SAME {ForkGuard.rearm!} path as automatic detection: marks
234
+ # both registered timers dead (lazy re-arm on next use), clears queue
235
+ # ownership in this process, and resets the owning PID. Idempotent (calling it
236
+ # in the owning process simply resets owner_pid to the current PID and
237
+ # re-fires the harmless clears). Never raises into the host (NFR9).
238
+ #
239
+ # @return [self]
240
+ def postfork
241
+ ForkGuard.rearm!
242
+ self
243
+ rescue StandardError => e
244
+ @log_manager.error("Client#postfork: #{e.class}: #{e.message}")
245
+ self
246
+ end
247
+
248
+ private
249
+
250
+ # Build a fully-wired {Context} for +visitor_id+: the shared config/store/event/
251
+ # log surfaces plus the per-context decisioning managers (Story 2.11 / 3.1 / 3.2)
252
+ # and the outbound {ApiManager} (Story 4.1). A nil decisioning manager leaves
253
+ # the corresponding Context method inert (the never-crash unit harness path).
254
+ def build_context(visitor_id, attributes)
255
+ Context.new(
256
+ visitor_id: visitor_id,
257
+ attributes: attributes,
258
+ data_manager: @data_manager,
259
+ data_store_manager: @data_store_manager,
260
+ event_manager: @event_manager,
261
+ log_manager: @log_manager,
262
+ config: @config,
263
+ experience_manager: @experience_manager,
264
+ feature_manager: @feature_manager,
265
+ segments_manager: @segments_manager,
266
+ api_manager: @api_manager
267
+ )
268
+ end
269
+
270
+ # Story 4.4 AC#5 — capture the registering PID and register the gem's single
271
+ # at_exit handler (the Split-gem ROOT_PROCESS_ID pattern). Skipped under the
272
+ # test harness (+ConvertSdk.at_exit_registration_enabled?+ is false there) so
273
+ # specs never register a live handler that would fire flush at suite exit.
274
+ # Registering an at_exit handler creates no thread (NFR4-safe).
275
+ def register_at_exit_flush
276
+ @at_exit_pid = Process.pid
277
+ return unless ConvertSdk.at_exit_registration_enabled?
278
+
279
+ at_exit { run_at_exit_flush }
280
+ end
281
+
282
+ # The at_exit handler body (Story 4.4 AC#5). PID-guarded: it flushes ONLY in
283
+ # the process that registered it — a forked child that inherited the handler
284
+ # is suppressed (its events belong to the child's own lifecycle, and the
285
+ # parent flushes the parent's). Best-effort: it does not run under SIGKILL
286
+ # (Lambda) — that path relies on the size/interval triggers. Never raises at
287
+ # exit (a raise during interpreter teardown is especially hostile).
288
+ def run_at_exit_flush
289
+ if Process.pid == @at_exit_pid
290
+ @log_manager.debug("Client#run_at_exit_flush: registering process exiting, flushing")
291
+ @api_manager.release_queue("exit")
292
+ else
293
+ @log_manager.debug("Client#run_at_exit_flush: suppressed in forked child (pid mismatch)")
294
+ end
295
+ rescue StandardError => e
296
+ @log_manager.error("Client#run_at_exit_flush: #{e.class}: #{e.message}")
297
+ end
298
+
299
+ # Build the config-refresh BackgroundTimer bound to +data_refresh_interval+
300
+ # and register it with ForkGuard (so a forked child marks it dead and the
301
+ # next #ensure_refresh_timer! re-arms it). The tick body is {#refresh_config}.
302
+ def build_refresh_timer
303
+ timer = BackgroundTimer.new(
304
+ interval: @config.data_refresh_interval,
305
+ log_manager: @log_manager,
306
+ name: "refresh"
307
+ ) { refresh_config }
308
+ ForkGuard.register_timer(timer)
309
+ timer
310
+ end
311
+
312
+ # One refresh cycle (the timer tick AND the synchronous timer-off refetch
313
+ # share this path): refetch through the HTTP port and, on success, install
314
+ # (deep-freeze + atomic swap + cache write via DataManager) which fires
315
+ # +config.updated+ (never +ready+ again — the 2.5 ready-once guard). A failed
316
+ # refetch keeps the current snapshot, warns, and lets the timer retry on the
317
+ # next tick (no inline retry, no backoff — frozen decision).
318
+ def refresh_config
319
+ envelope = refetch_config
320
+ if envelope.is_a?(Hash)
321
+ install(envelope, "Client#refresh_config: refreshed config installed")
322
+ else
323
+ @log_manager.warn("Client#refresh_config: refresh failed, serving cached config")
324
+ end
325
+ end
326
+
327
+ # Refetch the config through the single HTTP port and return the parsed
328
+ # envelope on success, or nil on any failed response. The single network
329
+ # refetch primitive — used by the timer tick and the timer-off synchronous
330
+ # refresh; the I/O here never holds the config mutex.
331
+ def refetch_config
332
+ response = @http_client.request(method: :get, url: config_url, headers: fetch_headers)
333
+ response.success? && response.body.is_a?(Hash) ? response.body : nil
334
+ end
335
+
336
+ # Drive the config lifecycle at construction: direct-data install when a
337
+ # +data:+ object was supplied, otherwise a live fetch. Either path that
338
+ # yields a usable config installs it identically (and fires +ready+ once).
339
+ def bootstrap_config
340
+ if @config.data.nil?
341
+ fetch_and_install_config
342
+ else
343
+ install(@config.data, "Client#initialize: installed direct data config")
344
+ end
345
+ end
346
+
347
+ # Fetch config through the HTTP port and install it on success. A failed
348
+ # response degrades gracefully: it MAY fall back to a non-stale cached entry
349
+ # from the store (meaningful across processes with a shared store like
350
+ # Redis), otherwise a +warn+ line, no config, no raise.
351
+ def fetch_and_install_config
352
+ response = @http_client.request(method: :get, url: config_url, headers: fetch_headers)
353
+ if response.success? && response.body.is_a?(Hash)
354
+ install(response.body, "Client#initialize: installed fetched config")
355
+ elsif @data_manager.install_from_cache_if_fresh
356
+ @event_manager.fire(SystemEvents::READY, deferred: true)
357
+ else
358
+ @log_manager.warn(
359
+ "Client#initialize: config fetch failed (status #{response.status}); " \
360
+ "continuing without config"
361
+ )
362
+ end
363
+ end
364
+
365
+ # Install a config envelope and fire the correct lifecycle event based on the
366
+ # atomic first/updated marker from {DataManager}. Symbol-keyed inputs (direct
367
+ # data mode) are normalised to string keys at this public boundary before
368
+ # install so readers see the same string-keyed wire shape as fetched config.
369
+ def install(source, log_message)
370
+ marker = @data_manager.install_config(stringify_keys(source))
371
+ return unless marker
372
+
373
+ @log_manager.info(log_message)
374
+ if marker == :first
375
+ @event_manager.fire(SystemEvents::READY, deferred: true)
376
+ else
377
+ @event_manager.fire(SystemEvents::CONFIG_UPDATED)
378
+ end
379
+ end
380
+
381
+ # Build the config-fetch URL: +{config_endpoint}/config/{sdkKey}+ with an
382
+ # +environment+ query parameter appended only when one is configured.
383
+ def config_url
384
+ url = "#{@config.config_endpoint}/config/#{@config.sdk_key}"
385
+ env = @config.environment
386
+ return url if env.nil?
387
+
388
+ "#{url}?environment=#{URI.encode_www_form_component(env)}"
389
+ end
390
+
391
+ # The fetch headers: an +Authorization: Bearer {secret}+ value when a secret
392
+ # is configured, else none. The port (HttpClient) owns UA / HTTPS / Bearer
393
+ # plaintext-stripping mechanics — the Client only supplies the header VALUE.
394
+ def fetch_headers
395
+ secret = @config.sdk_key_secret
396
+ return {} if secret.nil?
397
+
398
+ { "Authorization" => "Bearer #{secret}" }
399
+ end
400
+
401
+ # Recursively normalise a (possibly symbol-keyed) config object to string
402
+ # keys — the public-boundary normalisation for direct data mode, so the
403
+ # installed snapshot matches the string-keyed fetched wire shape exactly.
404
+ def stringify_keys(node)
405
+ case node
406
+ when Hash
407
+ result = {} #: Hash[String, untyped]
408
+ node.each { |k, v| result[k.to_s] = stringify_keys(v) }
409
+ result
410
+ when Array
411
+ node.map { |element| stringify_keys(element) }
412
+ else
413
+ node
414
+ end
415
+ end
416
+ end
417
+ end
@@ -0,0 +1,257 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ConvertSdk
4
+ # The 13 rule comparison operators — the cross-SDK audience-targeting predicate
5
+ # set, ported BYTE-FOR-BYTE from the JS SDK
6
+ # +packages/utils/src/comparisons.ts+.
7
+ #
8
+ # JS is the ONLY truth here. The PHP reference is QUARANTINED for this surface:
9
+ # it ships zero +exists+/+not_exists+ handling (a disk-verified gap) and folds
10
+ # case on the +isIn+ values side (a second divergence), so it must not influence
11
+ # the Ruby contract. Every operator below mirrors its JS body at the cited
12
+ # +comparisons.ts+ line; the goldens in the cross-SDK vector suite are the CI
13
+ # proof of parity.
14
+ #
15
+ # Two-worlds dispatch (operators): the platform sends operator names as
16
+ # camelCase WIRE strings inside the rule JSON (+equalsNumber+, +startsWith+, …).
17
+ # Those strings are config-world identifiers and stay byte-identical; the Ruby
18
+ # methods underneath are snake_case. {dispatch} is the map from the wire name to
19
+ # the Ruby method symbol, consumed by {RuleManager}.
20
+ #
21
+ # The undefined/nil distinction (the subtle one): JS distinguishes +undefined+
22
+ # (a data key is ABSENT) from +null+ (the key is present with a null value).
23
+ # Ruby hashes collapse both to +nil+, so absence is modeled EXPLICITLY with the
24
+ # frozen private {UNDEFINED} marker — {RuleManager} passes {UNDEFINED} for an
25
+ # absent key so the existence operators (and JS-parity need-more-data
26
+ # propagation) behave exactly as JS does with +undefined+. For the existence
27
+ # operators themselves +UNDEFINED+, +nil+, and +""+ are all "does not exist",
28
+ # matching +value !== undefined && value !== null && value !== ''+
29
+ # (+comparisons.ts:159+).
30
+ #
31
+ # Pure and stateless (NFR1): every method is a singleton (+self.+) method with
32
+ # no I/O and no instance state (the same module form as {MurmurHash3}).
33
+ #
34
+ # @api private
35
+ module Comparisons
36
+ # Sentinel for an ABSENT data key, distinct from +nil+ (a present null value).
37
+ # Frozen so it is a stable, comparable singleton. Mirrors JS +undefined+ on
38
+ # the rule-evaluation path.
39
+ UNDEFINED = Object.new.freeze
40
+
41
+ # JS +isNumeric+ regex (+string-utils.ts:69+): optional leading minus, then
42
+ # either grouped thousands (+1,234+) or plain digits, with an optional
43
+ # fractional part, or a bare fraction (+.5+).
44
+ NUMERIC_REGEXP = /\A-?(?:(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?|\.\d+)\z/
45
+
46
+ # Case-insensitive equality. Mirrors +comparisons.ts:15-40+:
47
+ # Array value -> membership of +test_against+; non-empty Hash value -> key
48
+ # membership; otherwise both sides are stringified, lowercased, and compared.
49
+ #
50
+ # @param value [Object] the data value (String, Numeric, Boolean, Array, Hash).
51
+ # @param test_against [Object] the rule's expected value.
52
+ # @param negation [Boolean] when true, the result is inverted.
53
+ # @return [Boolean]
54
+ def self.equals(value, test_against, negation = false)
55
+ return negation_check(value.include?(test_against), negation) if value.is_a?(Array)
56
+ return negation_check(value.key?(test_against.to_s), negation) if value.is_a?(Hash) && !value.empty?
57
+
58
+ negation_check(value.to_s.downcase == test_against.to_s.downcase, negation)
59
+ end
60
+
61
+ # Alias of {equals} (+comparisons.ts:42+ — +equalsNumber = this.equals+).
62
+ def self.equals_number(value, test_against, negation = false)
63
+ equals(value, test_against, negation)
64
+ end
65
+
66
+ # Alias of {equals} (+comparisons.ts:43+ — +matches = this.equals+).
67
+ def self.matches(value, test_against, negation = false)
68
+ equals(value, test_against, negation)
69
+ end
70
+
71
+ # Strict less-than over numerically-normalized inputs (+comparisons.ts:45-56+).
72
+ # Numeric-looking strings/numbers normalize to a number; anything else keeps
73
+ # its type. When the normalized types differ the result is +false+ (JS
74
+ # +typeof value !== typeof testAgainst+, ts:52-54).
75
+ #
76
+ # @return [Boolean]
77
+ def self.less(value, test_against, negation = false)
78
+ compare_numeric(value, test_against, negation) { |a, b| a < b }
79
+ end
80
+
81
+ # Less-than-or-equal counterpart of {less} (+comparisons.ts:58-69+).
82
+ #
83
+ # @return [Boolean]
84
+ def self.less_equal(value, test_against, negation = false)
85
+ compare_numeric(value, test_against, negation) { |a, b| a <= b }
86
+ end
87
+
88
+ # Case-insensitive substring test (+comparisons.ts:71-87+). PRESERVED JS
89
+ # quirk: an empty or whitespace-only +test_against+ returns +true+
90
+ # (ts:80-81) — do not "fix" it.
91
+ #
92
+ # @return [Boolean]
93
+ def self.contains(value, test_against, negation = false)
94
+ value = value.to_s.downcase
95
+ test_against = test_against.to_s.downcase
96
+ return negation_check(true, negation) if test_against.gsub(/\A\s*|\s*\z/, "").empty?
97
+
98
+ negation_check(value.include?(test_against), negation)
99
+ end
100
+
101
+ # Pipe-split membership (+comparisons.ts:89-115+). BOTH +values+ and a string
102
+ # +test_against+ are split on the +splitter+. Only the +test_against+ items
103
+ # are lowercased after splitting; the +values+ items are compared AS-IS
104
+ # against the lowercased list (so an uppercased value does not match a
105
+ # lowercased entry — exact JS semantics at ts:106-110).
106
+ #
107
+ # @param values [Object] the data value (pipe-joined string or scalar).
108
+ # @param test_against [Object] an Array, or a pipe-joined string to split.
109
+ # @param negation [Boolean]
110
+ # @param splitter [String] the delimiter (default +"|"+).
111
+ # @return [Boolean]
112
+ def self.is_in(values, test_against, negation = false, splitter = "|")
113
+ matched_values = values.to_s.split(splitter, -1).map(&:to_s)
114
+ test_against = test_against.split(splitter, -1) if test_against.is_a?(String)
115
+ unless test_against.is_a?(Array)
116
+ test_against = [] #: Array[untyped]
117
+ end
118
+ test_against = test_against.map { |item| item.to_s.downcase }
119
+ negation_check(matched_values.any? { |item| test_against.include?(item) }, negation)
120
+ end
121
+
122
+ # Case-insensitive prefix test (+comparisons.ts:117-128+ — +indexOf === 0+).
123
+ #
124
+ # @return [Boolean]
125
+ def self.starts_with(value, test_against, negation = false)
126
+ value = value.to_s.downcase
127
+ test_against = test_against.to_s.downcase
128
+ negation_check(value.start_with?(test_against), negation)
129
+ end
130
+
131
+ # Case-insensitive suffix test (+comparisons.ts:130-141+).
132
+ #
133
+ # @return [Boolean]
134
+ def self.ends_with(value, test_against, negation = false)
135
+ value = value.to_s.downcase
136
+ test_against = test_against.to_s.downcase
137
+ negation_check(value.end_with?(test_against), negation)
138
+ end
139
+
140
+ # Case-insensitive regex test (+comparisons.ts:143-152+ — +new RegExp(t, 'i')+).
141
+ # +value+ is lowercased; the pattern keeps its case but matches
142
+ # case-insensitively via the +i+ flag.
143
+ #
144
+ # @return [Boolean]
145
+ def self.regex_matches(value, test_against, negation = false)
146
+ value = value.to_s.downcase
147
+ pattern = Regexp.new(test_against.to_s, Regexp::IGNORECASE)
148
+ negation_check(!pattern.match(value).nil?, negation)
149
+ end
150
+
151
+ # Presence test (+comparisons.ts:154-161+): true unless the value is
152
+ # +UNDEFINED+ (JS +undefined+), +nil+ (JS +null+), or the empty string.
153
+ #
154
+ # @return [Boolean]
155
+ def self.exists(value, _test_against = nil, negation = false)
156
+ value_exists = !value.equal?(UNDEFINED) && !value.nil? && value != ""
157
+ negation_check(value_exists, negation)
158
+ end
159
+
160
+ # Absence test — the logical inverse of {exists} (+comparisons.ts:163-170+).
161
+ #
162
+ # @return [Boolean]
163
+ def self.not_exists(value, _test_against = nil, negation = false)
164
+ value_not_exists = value.equal?(UNDEFINED) || value.nil? || value == ""
165
+ negation_check(value_not_exists, negation)
166
+ end
167
+
168
+ # Alias of {not_exists} (+comparisons.ts:172+ — +doesNotExist = this.not_exists+).
169
+ def self.does_not_exist(value, test_against = nil, negation = false)
170
+ not_exists(value, test_against, negation)
171
+ end
172
+
173
+ # Maps each wire comparison operator name to its implementing method symbol.
174
+ DISPATCH = {
175
+ "equals" => :equals,
176
+ "equalsNumber" => :equals_number,
177
+ "matches" => :matches,
178
+ "less" => :less,
179
+ "lessEqual" => :less_equal,
180
+ "contains" => :contains,
181
+ "isIn" => :is_in,
182
+ "startsWith" => :starts_with,
183
+ "endsWith" => :ends_with,
184
+ "regexMatches" => :regex_matches,
185
+ "exists" => :exists,
186
+ "not_exists" => :not_exists,
187
+ "doesNotExist" => :does_not_exist
188
+ }.freeze
189
+
190
+ # The wire-name -> Ruby-method dispatch map (the two-worlds rule for
191
+ # operators). Keys are byte-identical to the rule JSON +match_type+ strings;
192
+ # {RuleManager} looks an operator up here and invokes the mapped method.
193
+ #
194
+ # @return [Hash{String=>Symbol}] frozen 13-entry map.
195
+ def self.dispatch
196
+ DISPATCH
197
+ end
198
+
199
+ # JS +isNumeric+ (+string-utils.ts:68-74+): numbers are numeric when finite;
200
+ # strings are numeric only when they match {NUMERIC_REGEXP} and parse finite.
201
+ # @api private
202
+ def self.numeric?(value)
203
+ return value.finite? if value.is_a?(Numeric)
204
+ return false unless value.is_a?(String) && NUMERIC_REGEXP.match?(value)
205
+
206
+ Float(value.delete(",")).finite?
207
+ rescue ArgumentError, TypeError
208
+ false
209
+ end
210
+
211
+ # JS +toNumber+ (+string-utils.ts:81-91+): numbers pass through; strings with
212
+ # a leading +"0"+ thousands segment treat commas as decimal points (all commas
213
+ # replaced with dots via +tr+, matching JS's global +replace(/,/g, '.')+),
214
+ # otherwise commas are stripped. The result is parsed with +to_f+ (lenient,
215
+ # never raises) — matching JS +parseFloat()+: the leading numeric portion is
216
+ # extracted and a trailing-garbage input like +"0.123.456"+ returns +0.123+
217
+ # rather than raising. This is a verified parity fix: Ruby +Float()+ (strict)
218
+ # raises on that input while JS +parseFloat+ and Ruby +to_f+ do not.
219
+ # @api private
220
+ def self.to_number(value)
221
+ return value if value.is_a?(Numeric)
222
+
223
+ str = value.to_s
224
+ parts = str.split(",")
225
+ normalized = parts[0] == "0" ? str.tr(",", ".") : str.delete(",")
226
+ normalized.to_f
227
+ end
228
+
229
+ # Numeric comparison core shared by {less}/{lessEqual}. Both sides are
230
+ # numerically normalized when numeric-looking; if the resulting types differ
231
+ # (one number, one non-number) the comparison is +false+ — JS ts:52-54/65-67.
232
+ # @api private
233
+ def self.compare_numeric(value, test_against, negation)
234
+ value = to_number(value) if numeric?(value)
235
+ test_against = to_number(test_against) if numeric?(test_against)
236
+ return negation_check(false, negation) unless same_compare_type?(value, test_against)
237
+
238
+ negation_check(yield(value, test_against), negation)
239
+ end
240
+
241
+ # JS +typeof+ parity for the {compare_numeric} guard: a normalized numeric is
242
+ # type "number"; everything else compares by whether BOTH are Numeric or
243
+ # NEITHER is (a String stays "string").
244
+ # @api private
245
+ def self.same_compare_type?(value, test_against)
246
+ value.is_a?(Numeric) == test_against.is_a?(Numeric)
247
+ end
248
+
249
+ # JS +_returnNegationCheck+ (+comparisons.ts:174-183+): invert when negated.
250
+ # @api private
251
+ def self.negation_check(result, negation)
252
+ negation ? !result : result
253
+ end
254
+
255
+ private_class_method :compare_numeric, :same_compare_type?, :negation_check
256
+ end
257
+ end