confeature 0.0.0 → 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,866 @@
1
+ # frozen_string_literal: true
2
+
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ require "json"
6
+ require_relative "hashing"
7
+ require_relative "semver"
8
+ require_relative "comparators"
9
+ require_relative "segments"
10
+ require_relative "prerequisites"
11
+ require_relative "eval_log"
12
+ require_relative "eval_logger"
13
+
14
+ module Confeature
15
+ module Internal
16
+ # ConfigCat-parity flag evaluator — full decision engine (spec 120).
17
+ #
18
+ # Ported from the spec-117 TS reference
19
+ # (`packages/sdk-shared/src/evaluator.ts`) and the spec-118 Python port
20
+ # (`apps/sdk-python/src/confeature_client/evaluator.py`). The evaluator
21
+ # consumes a parsed `config_v6.json` Hash and a (key, default, user) tuple
22
+ # and returns an `EvaluationResult` carrying the resolved value, an optional
23
+ # reason-coded error, the variation id, and the matched targeting rule /
24
+ # percentage option.
25
+ #
26
+ # Decision flow (mirrors `evaluator.ts::evaluateSetting`):
27
+ # 1. walk `setting["r"]` first-match-wins; each rule's `c` conditions are
28
+ # AND-grouped (short-circuit); a matched rule serves its fixed value
29
+ # `s` or buckets into its percentage options `p`;
30
+ # 2. no rule matches → setting-level percentage options `p`;
31
+ # 3. else → served fallback `setting["v"]`.
32
+ #
33
+ # Conditions dispatch to the 36 comparators (`comparators.rb`), segment
34
+ # membership (`segments.rb`), and prerequisite flags with bounded
35
+ # circular-dependency detection (`prerequisites.rb`).
36
+ #
37
+ # Preserved invariants (SC-006 — NON-NEGOTIABLE, Principle II):
38
+ # - the existing typed-widening + TYPE_MISMATCH / VALUE_NOT_BOOL decision
39
+ # tree (strict int/double, the bool-is-int guard) runs UNCHANGED on the
40
+ # resolved value envelope, whatever its source (rule / % / fallback);
41
+ # - fail-closed: the function NEVER raises; any unexpected state (incl. a
42
+ # detected prerequisite cycle) resolves to the caller's default with a
43
+ # reason-coded error (`EVAL_EXCEPTION`), bounded (FR-007/SC-004);
44
+ # - the rule-less / no-user path is byte-identical to the pre-engine
45
+ # behaviour.
46
+ #
47
+ # No-user / missing-attribute on a rule is NOT an error to the caller — the
48
+ # rule simply does not match and evaluation continues; an informational
49
+ # reason is surfaced on `EvaluationResult.eval_error` (FR-016), while `error`
50
+ # stays nil because a value WAS cleanly resolved.
51
+ module Evaluator
52
+ # `SettingType` discriminators from
53
+ # `packages/sdk-shared/src/config-json.ts`.
54
+ SETTING_TYPE_BOOL = 0
55
+ SETTING_TYPE_STRING = 1
56
+ SETTING_TYPE_INT = 2
57
+ SETTING_TYPE_DOUBLE = 3
58
+
59
+ MISSING_USER_OBJECT_ERROR = "cannot evaluate, User Object is missing"
60
+
61
+ IDENTIFIER_ATTRIBUTE = "Identifier"
62
+ EMAIL_ATTRIBUTE = "Email"
63
+ COUNTRY_ATTRIBUTE = "Country"
64
+
65
+ # Result of `evaluate(...)`. `error` carries the reason-coded fail-closed
66
+ # signal (`KEY_NOT_FOUND`, `TYPE_MISMATCH`, `EVAL_EXCEPTION`, …) — nil
67
+ # whenever a value was cleanly resolved (incl. via a targeting rule,
68
+ # percentage option, or served fallback). `eval_error` is the
69
+ # informational rule-engine reason (e.g. "User Object is missing")
70
+ # surfaced when a rule could not be evaluated but a value WAS still
71
+ # resolved (FR-016) — it never changes `error`.
72
+ EvaluationResult = Struct.new(
73
+ :value, :error, :variation_id,
74
+ :matched_targeting_rule, :matched_percentage_option, :eval_error,
75
+ keyword_init: true
76
+ ) do
77
+ def ok?
78
+ error.nil?
79
+ end
80
+ end
81
+
82
+ # Raw result of `evaluate_setting`: the resolved `v` envelope + identity.
83
+ SettingResult = Struct.new(:value, :variation_id, :matched_rule, :matched_option, keyword_init: true)
84
+
85
+ # Per-evaluation context threaded through rule / condition / prereq walks.
86
+ #
87
+ # `log` + `log_builder` are the eval-log trace seam (ADR 0003), present
88
+ # only when an INFO-enabled trace logger is attached. Every trace append is
89
+ # guarded by `unless ctx.log_builder.nil?`, so the no-trace hot path pays
90
+ # nothing. `log_builder` is shared across the recursion (a prerequisite
91
+ # re-evaluates nested in `( … )` on the SAME builder).
92
+ EvalContext = Struct.new(
93
+ :key, :setting, :settings, :segments, :config_salt, :user, :visited_flags, :state,
94
+ :log, :log_builder,
95
+ keyword_init: true
96
+ )
97
+
98
+ # Mutable evaluation-scoped flags shared across the recursion.
99
+ #
100
+ # `missing_user_object_logged` dedups the no-user WARN 3001 to once per
101
+ # context (common-js parity): a prerequisite flag evaluates in its OWN
102
+ # context, so a prereq referenced by N rules logs the WARN N times, but a
103
+ # single rule with multiple no-user conditions logs it once.
104
+ EvalState = Struct.new(:eval_error, :missing_user_object_logged)
105
+
106
+ # Internal: a malformed rule/option config that must fail closed.
107
+ class MalformedConfigError < StandardError; end
108
+
109
+ module_function
110
+
111
+ # Read the typed value out of a `v` envelope by the setting's `t`.
112
+ # Mirrors `evaluator.ts::selectSettingValue`. The key is chosen from `t`,
113
+ # never from the runtime shape of the parsed value.
114
+ def select_setting_value(value, setting_type)
115
+ return nil unless value.is_a?(::Hash)
116
+
117
+ case setting_type
118
+ when SETTING_TYPE_BOOL then value["b"]
119
+ when SETTING_TYPE_STRING then value["s"]
120
+ when SETTING_TYPE_INT then value["i"]
121
+ when SETTING_TYPE_DOUBLE then value["d"]
122
+ end
123
+ end
124
+
125
+ # Resolve `key` against `config`. Fail-closed to `default_value`.
126
+ #
127
+ # The function NEVER raises. Any internal exception (incl. a detected
128
+ # prerequisite cycle) is converted to a fail-closed result with
129
+ # `error="EVAL_EXCEPTION"`.
130
+ def evaluate(config, key, default_value, user = nil)
131
+ evaluate_traced(config, key, default_value, user, nil)
132
+ end
133
+
134
+ # `evaluate` with an optional trace logger. When the logger is
135
+ # INFO-enabled it drives the ConfigCat byte-identical evaluation-log trace:
136
+ # the WARN 3001/3003/3004 + ERROR 1002 lines emitted during the walk, and
137
+ # the final INFO 5000 multi-line indented trace (ADR 0003 / epic #494). A
138
+ # `nil` logger (the hot path) is zero-cost — identical to `evaluate`.
139
+ def evaluate_traced(config, key, default_value, user = nil, logger = nil)
140
+ return EvaluationResult.new(value: default_value, error: "NO_CONFIG") if config.nil?
141
+ return EvaluationResult.new(value: default_value, error: "MALFORMED_CONFIG") unless config.is_a?(::Hash)
142
+
143
+ log = EvalLogger::TraceLog.new(logger)
144
+
145
+ settings = config["f"]
146
+ return EvaluationResult.new(value: default_value, error: "MALFORMED_CONFIG") unless settings.is_a?(::Hash)
147
+
148
+ setting = settings[key]
149
+ return EvaluationResult.new(value: default_value, error: "KEY_NOT_FOUND") if setting.nil?
150
+
151
+ setting_type = setting["t"]
152
+ # `t` must be an Integer discriminator. Anything else is malformed.
153
+ unless setting_type.is_a?(Integer) && !boolean?(setting_type)
154
+ return EvaluationResult.new(value: default_value, error: "MALFORMED_CONFIG")
155
+ end
156
+
157
+ # Type guard BEFORE evaluating rules (mirrors evaluator.ts): the default
158
+ # must be type-compatible with the setting's declared type. A `nil`
159
+ # default bypasses the check — used by the variation-id matrix, which
160
+ # evaluates with a `nil` default to read `variation_id` regardless of
161
+ # value type.
162
+ unless default_value.nil?
163
+ mismatch = check_default_type(setting_type, default_value)
164
+ return EvaluationResult.new(value: default_value, error: mismatch) unless mismatch.nil?
165
+ end
166
+
167
+ run_eval(config, key, setting, settings, setting_type, default_value, user, log)
168
+ rescue Prerequisites::CircularDependencyError => e
169
+ # A prerequisite cycle fail-closes with ConfigCat's distinct ERROR 1002
170
+ # (naming the setting + the chain), not the generic evaluation-error path.
171
+ log.circular_dependency(key, e.cycle)
172
+ EvaluationResult.new(value: default_value, error: "EVAL_EXCEPTION")
173
+ rescue StandardError
174
+ # Bounded fail-closed (FR-007/SC-004): every internal failure — a
175
+ # malformed prereq / segment reference, a malformed-config raise, or any
176
+ # unexpected error — resolves to the caller's default with an
177
+ # `EVAL_EXCEPTION` reason. The cycle raise is what bounds an otherwise
178
+ # unbounded recursion (no SystemStackError escape).
179
+ EvaluationResult.new(value: default_value, error: "EVAL_EXCEPTION")
180
+ end
181
+
182
+ # Evaluation core (the seam): resolve the setting to a `v` envelope +
183
+ # matched-rule/option identity, then run the typed-widening decision tree.
184
+ # For a rule-less config this collapses to the served `setting["v"]`,
185
+ # byte-identical to the pre-engine behaviour. Split out so the entry's
186
+ # guard ladder stays flat.
187
+ def run_eval(config, key, setting, settings, setting_type, default_value, user, log)
188
+ log_builder = nil
189
+ if log.enabled?(EvalLogger::TraceLevel::INFO)
190
+ log_builder = EvalLog::Builder.new
191
+ log_builder.append("Evaluating '#{key}'")
192
+ log_builder.append(" for User '#{user_attributes_for_log_json(user)}'") unless user.nil?
193
+ end
194
+
195
+ ctx = EvalContext.new(
196
+ key: key, setting: setting, settings: settings,
197
+ segments: config_segments(config), config_salt: config_salt(config),
198
+ user: user, visited_flags: [], state: EvalState.new(nil, false),
199
+ log: log, log_builder: log_builder
200
+ )
201
+ result = evaluate_setting(ctx)
202
+
203
+ value_slot = result.value
204
+ return EvaluationResult.new(value: default_value, error: "MALFORMED_VALUE") unless value_slot.is_a?(::Hash)
205
+
206
+ variation_id = result.variation_id
207
+ variation_id = setting["i"] if variation_id.nil? && setting["i"].is_a?(String)
208
+
209
+ typed = resolve_typed(setting_type, value_slot, default_value, variation_id, result, ctx.state.eval_error)
210
+
211
+ # Footer + emit. The INFO 5000 trace is emitted only when the resolved
212
+ # value is clean (no error); a type/value error fails closed without a
213
+ # footer (matching evaluator.ts, which emits after the value guard).
214
+ unless log_builder.nil? || !typed.error.nil?
215
+ log_builder.new_line("Returning '#{EvalLog.setting_value_to_log_string(typed.value)}'.")
216
+ log.info(EvalLog::EVAL_LOG_EVENT_ID, log_builder.to_s)
217
+ end
218
+ typed
219
+ end
220
+
221
+ def config_salt(config)
222
+ prefs = config["p"]
223
+ salt = prefs["s"] if prefs.is_a?(::Hash)
224
+ salt.is_a?(String) ? salt : ""
225
+ end
226
+
227
+ def config_segments(config)
228
+ raw = config["s"]
229
+ raw.is_a?(Array) ? raw : []
230
+ end
231
+
232
+ # Render the user's attributes as the eval-log header shows them — a
233
+ # Confeature-canonical object `{Identifier, Email?, Country?, …custom}`
234
+ # JSON-serialized to the `for User '…'` token (ADR 0003 addendum). This is
235
+ # the configcat-js form (the vendored go-sdk oracle's map is transformed to
236
+ # it in the corpus so the cross-SDK trace stays byte-identical).
237
+ #
238
+ # Insertion order is fixed (Identifier, Email, Country, then custom in the
239
+ # order the user's custom map yields) and the serialization is compact (no
240
+ # spaces) with JS-JSON string escaping — mirroring
241
+ # `JSON.stringify(userAttributesForLog(user))`.
242
+ def user_attributes_for_log_json(user)
243
+ parts = ["\"Identifier\":#{js_json_string(user_identifier(user))}"]
244
+ parts << "\"Email\":#{js_json_string(user.email)}" unless user.email.nil?
245
+ parts << "\"Country\":#{js_json_string(user.country)}" unless user.country.nil?
246
+ custom = user.custom
247
+ if custom.is_a?(::Hash)
248
+ custom.each do |name, value|
249
+ parts << "#{js_json_string(name)}:#{js_json_string(Comparators.user_attribute_value_to_string(value))}"
250
+ end
251
+ end
252
+ "{#{parts.join(",")}}"
253
+ end
254
+
255
+ # Serialize a string exactly as ECMAScript `JSON.stringify` of a string:
256
+ # wrapped in double quotes, with `"`, `\`, and the C0 control characters
257
+ # escaped (`\"`, `\\`, `\b`, `\t`, `\n`, `\f`, `\r`, else `\u00XX`), all
258
+ # other code points (incl. `/` and non-ASCII) verbatim.
259
+ def js_json_string(str)
260
+ out = +'"'
261
+ str.each_char do |ch|
262
+ out << js_json_escape_char(ch)
263
+ end
264
+ out << '"'
265
+ out
266
+ end
267
+
268
+ def js_json_escape_char(char)
269
+ case char
270
+ when '"' then '\\"'
271
+ when "\\" then "\\\\"
272
+ when "\b" then "\\b"
273
+ when "\t" then "\\t"
274
+ when "\n" then "\\n"
275
+ when "\f" then "\\f"
276
+ when "\r" then "\\r"
277
+ else
278
+ char.ord < 0x20 ? format("\\u%04x", char.ord) : char
279
+ end
280
+ end
281
+
282
+ # Return a TYPE_MISMATCH reason code if the default type is wrong, else nil.
283
+ def check_default_type(setting_type, default_value)
284
+ case setting_type
285
+ when SETTING_TYPE_BOOL
286
+ return "TYPE_MISMATCH" unless boolean?(default_value)
287
+ when SETTING_TYPE_STRING
288
+ return "TYPE_MISMATCH" unless default_value.is_a?(String)
289
+ when SETTING_TYPE_INT
290
+ return "TYPE_MISMATCH" if boolean?(default_value) || !default_value.is_a?(Integer)
291
+ when SETTING_TYPE_DOUBLE
292
+ return "TYPE_MISMATCH" unless default_value.is_a?(Float)
293
+ end
294
+ nil
295
+ end
296
+
297
+ # Read the typed primitive from the resolved envelope (spec-072 widening).
298
+ def resolve_typed(setting_type, value_slot, default_value, variation_id, result, eval_error)
299
+ ok = lambda do |raw|
300
+ EvaluationResult.new(
301
+ value: raw,
302
+ variation_id: variation_id,
303
+ matched_targeting_rule: result.matched_rule,
304
+ matched_percentage_option: result.matched_option,
305
+ eval_error: eval_error
306
+ )
307
+ end
308
+
309
+ case setting_type
310
+ when SETTING_TYPE_BOOL
311
+ raw = value_slot["b"]
312
+ return EvaluationResult.new(value: default_value, error: "VALUE_NOT_BOOL") unless boolean?(raw)
313
+
314
+ ok.call(raw)
315
+ when SETTING_TYPE_STRING
316
+ raw = value_slot["s"]
317
+ return EvaluationResult.new(value: default_value, error: "MALFORMED_VALUE") unless raw.is_a?(String)
318
+
319
+ ok.call(raw)
320
+ when SETTING_TYPE_INT
321
+ raw = value_slot["i"]
322
+ if boolean?(raw) || !raw.is_a?(Integer)
323
+ return EvaluationResult.new(value: default_value, error: "MALFORMED_VALUE")
324
+ end
325
+
326
+ ok.call(raw)
327
+ when SETTING_TYPE_DOUBLE
328
+ raw = value_slot["d"]
329
+ return EvaluationResult.new(value: default_value, error: "MALFORMED_VALUE") if boolean?(raw)
330
+
331
+ # JSON numbers without a fractional part decode as Integer; ConfigCat
332
+ # double settings legitimately carry integral doubles (e.g. 5.0 → 5).
333
+ raw = raw.to_f if raw.is_a?(Integer)
334
+ return EvaluationResult.new(value: default_value, error: "MALFORMED_VALUE") unless raw.is_a?(Float)
335
+
336
+ ok.call(raw)
337
+ else
338
+ EvaluationResult.new(value: default_value, error: "UNSUPPORTED_TYPE")
339
+ end
340
+ end
341
+
342
+ # -----------------------------------------------------------------------
343
+ # Evaluation core — rule walk / conditions / percentage
344
+ # -----------------------------------------------------------------------
345
+
346
+ def evaluate_setting(ctx)
347
+ setting = ctx.setting
348
+
349
+ # 1. Targeting rules — first-match-wins (US1).
350
+ rules = setting["r"] || []
351
+ unless rules.empty?
352
+ rule_result = evaluate_targeting_rules(rules, ctx)
353
+ return rule_result unless rule_result.nil?
354
+ end
355
+
356
+ # 2. Setting-level percentage options (US3).
357
+ options = setting["p"] || []
358
+ unless options.empty?
359
+ option_result = evaluate_percentage_options(options, nil, ctx)
360
+ return option_result unless option_result.nil?
361
+ end
362
+
363
+ # 3. Served fallback `setting["v"]`.
364
+ SettingResult.new(value: setting["v"], variation_id: setting["i"])
365
+ end
366
+
367
+ # Walk rules in document order; return the first whose conditions all match.
368
+ def evaluate_targeting_rules(rules, ctx)
369
+ lb = ctx.log_builder
370
+ lb&.new_line("Evaluating targeting rules and applying the first match if any:")
371
+ rules.each do |rule|
372
+ outcome = evaluate_conditions(rule["c"] || [], ctx.key, ctx, rule)
373
+ unless outcome == true
374
+ # A rule whose conditions ERRORED (missing user/attr) is ignored and
375
+ # evaluation continues — ConfigCat emits this trace line. A rule that
376
+ # merely did NOT match (false) just continues, with no such line.
377
+ if !lb.nil? && outcome.is_a?(Comparators::ValidationError)
378
+ lb.increase_indent.new_line(
379
+ "The current targeting rule is ignored and the evaluation continues with the next rule."
380
+ ).decrease_indent
381
+ end
382
+ next
383
+ end
384
+
385
+ served = rule["s"]
386
+ return SettingResult.new(value: served["v"], variation_id: served["i"], matched_rule: rule) unless served.nil?
387
+
388
+ # A rule serving % options: the block is traced one level deeper (under
389
+ # the rule). If the options can't resolve (no user / missing attr) the
390
+ # rule is ignored and evaluation continues.
391
+ options = rule["p"] || []
392
+ lb&.increase_indent
393
+ option_result = evaluate_percentage_options(options, rule, ctx)
394
+ unless option_result.nil?
395
+ lb&.decrease_indent
396
+ return option_result
397
+ end
398
+ lb&.new_line(
399
+ "The current targeting rule is ignored and the evaluation continues with the next rule."
400
+ )&.decrease_indent
401
+ end
402
+ nil
403
+ end
404
+
405
+ # Resolve a percentage-option list to its bucket (US3, SHA-1 unsalted).
406
+ # Returns nil (skip) when there is no user or the keying attribute is
407
+ # missing — never raises on a skippable condition. Mirrors common-js.
408
+ def evaluate_percentage_options(options, matched_rule, ctx)
409
+ lb = ctx.log_builder
410
+ if ctx.user.nil?
411
+ surface_missing_user(ctx)
412
+ lb&.new_line("Skipping % options because the User Object is missing.")
413
+ return nil
414
+ end
415
+
416
+ attr_name = ctx.setting["a"]
417
+ if attr_name.nil?
418
+ attr_name = IDENTIFIER_ATTRIBUTE
419
+ attr_value = user_identifier(ctx.user)
420
+ else
421
+ attr_value = get_user_attribute(ctx.user, attr_name)
422
+ end
423
+ if attr_value.nil?
424
+ ctx.state.eval_error ||= missing_user_attribute_error(attr_name)
425
+ ctx.log.user_object_attribute_is_missing_for_percentage(ctx.key, attr_name)
426
+ lb&.new_line("Skipping % options because the User.#{attr_name} attribute is missing.")
427
+ return nil
428
+ end
429
+
430
+ hash_value = Hashing.percentage_bucket(ctx.key, Comparators.user_attribute_value_to_string(attr_value))
431
+
432
+ unless lb.nil?
433
+ lb.new_line("Evaluating % options based on the User.#{attr_name} attribute:")
434
+ lb.new_line(
435
+ "- Computing hash in the [0..99] range from User.#{attr_name} => #{hash_value} " \
436
+ "(this value is sticky and consistent across all SDKs)"
437
+ )
438
+ end
439
+
440
+ select_percentage_option(options, matched_rule, ctx, hash_value)
441
+ end
442
+
443
+ def select_percentage_option(options, matched_rule, ctx, hash_value)
444
+ lb = ctx.log_builder
445
+ setting_type = ctx.setting["t"]
446
+ bucket = 0
447
+ options.each_with_index do |option, index|
448
+ width = option["p"] || 0
449
+ bucket += width
450
+ next if hash_value >= bucket
451
+
452
+ value = option["v"]
453
+ if !lb.nil? && setting_type.is_a?(Integer) && !boolean?(setting_type)
454
+ selected = select_setting_value(value, setting_type)
455
+ lb.new_line(
456
+ "- Hash value #{hash_value} selects % option #{index + 1} (#{width}%), " \
457
+ "'#{EvalLog.setting_value_to_log_string(selected)}'."
458
+ )
459
+ end
460
+ return SettingResult.new(
461
+ value: value, variation_id: option["i"],
462
+ matched_rule: matched_rule, matched_option: option
463
+ )
464
+ end
465
+ # Sum of widths < 100 — a malformed config. Raise so the outer
466
+ # fail-closed frame returns the default (common-js throws here too).
467
+ raise MalformedConfigError, "Sum of percentage option percentages is less than 100."
468
+ end
469
+
470
+ # Evaluate an AND-grouped condition list, short-circuiting on first
471
+ # non-match / error. `true` = all matched; `false` = a condition did not
472
+ # match; ValidationError = a condition errored (surfaced — FR-010).
473
+ #
474
+ # `rule` is the owning targeting rule when this is a rule's condition list
475
+ # (nil for a bare segment's condition list). When present and tracing is
476
+ # on, the rule's THEN consequence is appended after the AND group.
477
+ def evaluate_conditions(conditions, context_salt, ctx, rule = nil)
478
+ lb = ctx.log_builder
479
+ lb&.new_line("- ")
480
+ result = true
481
+ conditions.each_with_index do |condition, i|
482
+ trace_condition_prefix(lb, i) unless lb.nil?
483
+ result = dispatch_condition(condition, context_salt, ctx)
484
+ unless lb.nil?
485
+ # The go-sdk oracle always shows the per-condition consequence (even
486
+ # for a single-condition rule). A clean match is true; anything else
487
+ # (no-match or surfaced error) renders the `=> false, skipping…` form.
488
+ lb.append_condition_consequence(result == true)
489
+ lb.decrease_indent
490
+ end
491
+ break if result != true
492
+ end
493
+
494
+ append_rule_consequence(lb, rule, conditions, result, ctx) unless rule.nil? || lb.nil?
495
+ result
496
+ end
497
+
498
+ def trace_condition_prefix(builder, index)
499
+ if index.zero?
500
+ builder.append("IF ").increase_indent
501
+ else
502
+ builder.increase_indent.new_line("AND ")
503
+ end
504
+ end
505
+
506
+ def dispatch_condition(condition, context_salt, ctx)
507
+ if !condition["u"].nil?
508
+ evaluate_user_condition(condition["u"], context_salt, ctx)
509
+ elsif !condition["p"].nil?
510
+ evaluate_prerequisite_condition(condition["p"], ctx)
511
+ elsif !condition["s"].nil?
512
+ evaluate_segment_condition(condition["s"], ctx)
513
+ else
514
+ Comparators.error("empty condition")
515
+ end
516
+ end
517
+
518
+ # Append the rule's THEN consequence (rule-owned condition list only). The
519
+ # go oracle newlines THEN when the rule has conditions.
520
+ def append_rule_consequence(builder, rule, conditions, result, ctx)
521
+ then_value, has_then = rule_then_value(rule, ctx)
522
+ outcome =
523
+ if result == true
524
+ EvalLog.match_true
525
+ elsif result.is_a?(Comparators::ValidationError)
526
+ EvalLog.match_error(result.message)
527
+ else
528
+ EvalLog.match_false
529
+ end
530
+ builder.append_targeting_rule_consequence(has_then ? then_value : nil, outcome, !conditions.empty?)
531
+ end
532
+
533
+ # A rule's formatted served value (e.g. `Dog`) for a fixed-value rule;
534
+ # `[nil, false]` for a rule serving `% options`.
535
+ def rule_then_value(rule, ctx)
536
+ served = rule["s"]
537
+ return [nil, false] unless served.is_a?(::Hash)
538
+
539
+ setting_type = ctx.setting["t"]
540
+ return [nil, false] unless setting_type.is_a?(Integer) && !boolean?(setting_type)
541
+
542
+ value = select_setting_value(served["v"], setting_type)
543
+ [EvalLog.setting_value_to_log_string(value), true]
544
+ end
545
+
546
+ INVALID_ATTR_RE = /\Acannot evaluate, the User\.(.+?) attribute is invalid \((.+)\)\z/
547
+
548
+ # Dispatch one user condition to its comparator (US2).
549
+ def evaluate_user_condition(condition, context_salt, ctx)
550
+ ctx.log_builder&.append_user_condition(condition)
551
+ if ctx.user.nil?
552
+ surface_missing_user(ctx)
553
+ return Comparators.error(MISSING_USER_OBJECT_ERROR)
554
+ end
555
+
556
+ attr_name = condition["a"].is_a?(String) ? condition["a"] : nil
557
+ attr_value = attr_name.nil? ? nil : get_user_attribute(ctx.user, attr_name)
558
+ if attr_name.nil? || attr_value.nil? || attr_value == ""
559
+ msg = missing_user_attribute_error(condition["a"].to_s)
560
+ ctx.state.eval_error ||= msg
561
+ # ConfigCat logs a WARN 3003 per targeting rule whose attribute is
562
+ # missing (not deduped — AND short-circuit means one per rule).
563
+ ctx.log.user_object_attribute_is_missing(ctx.key, condition["a"].to_s)
564
+ return Comparators.error(msg)
565
+ end
566
+
567
+ result = dispatch_user_comparator(condition, attr_name, attr_value, context_salt, ctx)
568
+ surface_user_condition_error(result, ctx) if result.is_a?(Comparators::ValidationError)
569
+ result
570
+ end
571
+
572
+ # Surface + log the typed validation / malformed-operand outcomes the trace
573
+ # replays. An attribute-invalid error → WARN 3004
574
+ # (user_object_attribute_is_invalid); a malformed config operand (#509) →
575
+ # WARN 3004 (comparison_value_is_invalid). Both also feed state.eval_error
576
+ # (FR-016) the way the missing paths do.
577
+ def surface_user_condition_error(result, ctx)
578
+ ctx.state.eval_error ||= result.message
579
+ m = INVALID_ATTR_RE.match(result.message)
580
+ if m
581
+ ctx.log.user_object_attribute_is_invalid(ctx.key, m[1], m[2])
582
+ elsif result.is_a?(Comparators::ComparisonValueError)
583
+ ctx.log.comparison_value_is_invalid(ctx.key, result.message)
584
+ end
585
+ end
586
+
587
+ def dispatch_user_comparator(condition, attr_name, attr_value, context_salt, ctx)
588
+ cmp = condition["c"]
589
+ list = condition["l"] || []
590
+ sv = condition["s"] || ""
591
+ dv = condition["d"] || 0
592
+ dispatch_comparator(cmp, attr_name, attr_value, list, sv, dv, ctx.config_salt, context_salt, condition["d"])
593
+ end
594
+
595
+ def dispatch_comparator(cmp, attr_name, attr_value, list, sv, dv, salt, context_salt, raw_dv = nil) # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/MethodLength,Metrics/PerceivedComplexity
596
+ c = Comparators
597
+ text = -> { c.attribute_as_text(attr_value) }
598
+ case cmp
599
+ when c::TEXT_EQUALS, c::TEXT_NOT_EQUALS
600
+ c.evaluate_text_equals(text.call, sv, cmp == c::TEXT_NOT_EQUALS)
601
+ when c::SENSITIVE_TEXT_EQUALS, c::SENSITIVE_TEXT_NOT_EQUALS
602
+ c.evaluate_sensitive_text_equals(text.call, sv, salt, context_salt, cmp == c::SENSITIVE_TEXT_NOT_EQUALS)
603
+ when c::TEXT_IS_ONE_OF, c::TEXT_IS_NOT_ONE_OF
604
+ c.evaluate_text_is_one_of(text.call, list, cmp == c::TEXT_IS_NOT_ONE_OF)
605
+ when c::SENSITIVE_TEXT_IS_ONE_OF, c::SENSITIVE_TEXT_IS_NOT_ONE_OF
606
+ c.evaluate_sensitive_text_is_one_of(text.call, list, salt, context_salt,
607
+ cmp == c::SENSITIVE_TEXT_IS_NOT_ONE_OF)
608
+ when c::TEXT_STARTS_WITH_ANY_OF, c::TEXT_NOT_STARTS_WITH_ANY_OF
609
+ c.evaluate_text_slice_equals_any_of(text.call, list, true, cmp == c::TEXT_NOT_STARTS_WITH_ANY_OF)
610
+ when c::SENSITIVE_TEXT_STARTS_WITH_ANY_OF, c::SENSITIVE_TEXT_NOT_STARTS_WITH_ANY_OF
611
+ c.evaluate_sensitive_text_slice_equals_any_of(
612
+ text.call, list, salt, context_salt, true, cmp == c::SENSITIVE_TEXT_NOT_STARTS_WITH_ANY_OF
613
+ )
614
+ when c::TEXT_ENDS_WITH_ANY_OF, c::TEXT_NOT_ENDS_WITH_ANY_OF
615
+ c.evaluate_text_slice_equals_any_of(text.call, list, false, cmp == c::TEXT_NOT_ENDS_WITH_ANY_OF)
616
+ when c::SENSITIVE_TEXT_ENDS_WITH_ANY_OF, c::SENSITIVE_TEXT_NOT_ENDS_WITH_ANY_OF
617
+ c.evaluate_sensitive_text_slice_equals_any_of(
618
+ text.call, list, salt, context_salt, false, cmp == c::SENSITIVE_TEXT_NOT_ENDS_WITH_ANY_OF
619
+ )
620
+ when c::TEXT_CONTAINS_ANY_OF, c::TEXT_NOT_CONTAINS_ANY_OF
621
+ c.evaluate_text_contains_any_of(text.call, list, cmp == c::TEXT_NOT_CONTAINS_ANY_OF)
622
+ when c::SEMVER_IS_ONE_OF, c::SEMVER_IS_NOT_ONE_OF
623
+ r = c.attribute_as_semver(attr_name, attr_value)
624
+ r.is_a?(c::ValidationError) ? r : c.evaluate_semver_is_one_of(r, list, cmp == c::SEMVER_IS_NOT_ONE_OF)
625
+ when c::SEMVER_LESS, c::SEMVER_LESS_OR_EQUALS, c::SEMVER_GREATER, c::SEMVER_GREATER_OR_EQUALS
626
+ # configcat-common validates the CONFIG comparison value BEFORE the
627
+ # user attribute (#509): a malformed operand wins even when the
628
+ # attribute is also malformed.
629
+ cv = c.comparison_value_as_semver(sv)
630
+ return cv if cv.is_a?(c::ValidationError)
631
+
632
+ r = c.attribute_as_semver(attr_name, attr_value)
633
+ r.is_a?(c::ValidationError) ? r : c.evaluate_semver_relation(r, cmp, sv)
634
+ when c::NUMBER_EQUALS, c::NUMBER_NOT_EQUALS, c::NUMBER_LESS, c::NUMBER_LESS_OR_EQUALS,
635
+ c::NUMBER_GREATER, c::NUMBER_GREATER_OR_EQUALS
636
+ cv = c.comparison_value_as_number(raw_dv&.to_f)
637
+ return cv if cv.is_a?(c::ValidationError)
638
+
639
+ r = c.attribute_as_number(attr_name, attr_value)
640
+ r.is_a?(c::ValidationError) ? r : c.evaluate_number_relation(r, cmp, dv.to_f)
641
+ when c::DATETIME_BEFORE, c::DATETIME_AFTER
642
+ cv = c.comparison_value_as_unix_time_seconds(raw_dv&.to_f)
643
+ return cv if cv.is_a?(c::ValidationError)
644
+
645
+ r = c.attribute_as_unix_time_seconds(attr_name, attr_value)
646
+ r.is_a?(c::ValidationError) ? r : c.evaluate_datetime_relation(r, dv.to_f, cmp == c::DATETIME_BEFORE)
647
+ when c::ARRAY_CONTAINS_ANY_OF, c::ARRAY_NOT_CONTAINS_ANY_OF
648
+ r = c.attribute_as_string_array(attr_name, attr_value)
649
+ if r.is_a?(c::ValidationError)
650
+ r
651
+ else
652
+ c.evaluate_array_contains_any_of(r, list,
653
+ cmp == c::ARRAY_NOT_CONTAINS_ANY_OF)
654
+ end
655
+ when c::SENSITIVE_ARRAY_CONTAINS_ANY_OF, c::SENSITIVE_ARRAY_NOT_CONTAINS_ANY_OF
656
+ r = c.attribute_as_string_array(attr_name, attr_value)
657
+ if r.is_a?(c::ValidationError)
658
+ r
659
+ else
660
+ c.evaluate_sensitive_array_contains_any_of(
661
+ r, list, salt, context_salt, cmp == c::SENSITIVE_ARRAY_NOT_CONTAINS_ANY_OF
662
+ )
663
+ end
664
+ else
665
+ c.error("unknown comparator #{cmp}")
666
+ end
667
+ end
668
+
669
+ # Resolve a prerequisite-flag condition (US4) — child context, shared stack.
670
+ def evaluate_prerequisite_condition(condition, ctx)
671
+ prereq_type = lambda do |prereq_key|
672
+ s = ctx.settings[prereq_key]
673
+ t = s["t"] if s.is_a?(::Hash)
674
+ t.is_a?(Integer) && !boolean?(t) ? t : nil
675
+ end
676
+
677
+ trace_prerequisite_condition(condition, ctx, prereq_type) unless ctx.log_builder.nil?
678
+
679
+ evaluate_prereq_flag = ->(prereq_key) { evaluate_prereq_flag(prereq_key, ctx, prereq_type) }
680
+
681
+ Prerequisites.evaluate_prerequisite_flag_condition(
682
+ condition, ctx.key, ctx.visited_flags,
683
+ prereq_type, evaluate_prereq_flag, method(:select_setting_value)
684
+ )
685
+ end
686
+
687
+ # Trace the `Flag 'X' EQUALS 'Y'` condition before recursing (the
688
+ # comparison value is read through the prereq flag's own declared type).
689
+ def trace_prerequisite_condition(condition, ctx, prereq_type)
690
+ prereq_key = condition["f"]
691
+ cmp_text = ""
692
+ if prereq_key.is_a?(String)
693
+ pt = prereq_type.call(prereq_key)
694
+ unless pt.nil?
695
+ cmp_val = select_setting_value(condition["v"], pt)
696
+ cmp_text = EvalLog.setting_value_to_log_string(cmp_val) unless cmp_val.nil?
697
+ end
698
+ end
699
+ cmp = condition["c"]
700
+ ctx.log_builder.append_prerequisite_flag_condition(
701
+ prereq_key.is_a?(String) ? prereq_key : "",
702
+ cmp.is_a?(Integer) && !boolean?(cmp) ? cmp : -1,
703
+ cmp_text
704
+ )
705
+ end
706
+
707
+ def evaluate_prereq_flag(prereq_key, ctx, prereq_type)
708
+ prereq_setting = ctx.settings[prereq_key]
709
+ unless prereq_setting.is_a?(::Hash)
710
+ raise Prerequisites::PrerequisiteReferenceError, "Prerequisite flag is missing or invalid."
711
+ end
712
+
713
+ # A prerequisite flag evaluates in its OWN context (configcat
714
+ # forPrerequisiteFlag): the no-user WARN dedups per flag, so a prereq
715
+ # referenced by N rules logs it N times — hence a fresh state that
716
+ # carries forward the already-surfaced eval_error (FR-016).
717
+ lb = ctx.log_builder
718
+ child_state = EvalState.new(ctx.state.eval_error, false)
719
+ child = EvalContext.new(
720
+ key: prereq_key, setting: prereq_setting, settings: ctx.settings,
721
+ segments: ctx.segments, config_salt: ctx.config_salt, user: ctx.user,
722
+ visited_flags: ctx.visited_flags, state: child_state, log: ctx.log, log_builder: lb
723
+ )
724
+
725
+ unless lb.nil?
726
+ lb.new_line("(").increase_indent.new_line("Evaluating '#{prereq_key}'")
727
+ lb.append(" for User '#{user_attributes_for_log_json(ctx.user)}'") unless ctx.user.nil?
728
+ end
729
+ resolved = evaluate_setting(child)
730
+ ctx.state.eval_error ||= child_state.eval_error
731
+ unless lb.nil?
732
+ declared_type = prereq_type.call(prereq_key)
733
+ child_value = declared_type.nil? ? nil : select_setting_value(resolved.value, declared_type)
734
+ lb.new_line("Returning '#{EvalLog.setting_value_to_log_string(child_value)}'.")
735
+ .decrease_indent.new_line(")")
736
+ end
737
+ resolved.value
738
+ end
739
+
740
+ # Resolve a segment condition (US4) — segment name as the context salt.
741
+ #
742
+ # On the no-trace hot path this delegates to
743
+ # `Segments.evaluate_segment_condition` unchanged. When tracing is on it
744
+ # ports `evaluator.ts::evaluateSegmentCondition` incl. its nested `( … )`
745
+ # block: the segment's `r[]` is an AND-group of bare user conditions routed
746
+ # through `evaluate_conditions` so the trace renders the IF/AND structure
747
+ # (the segment NAME is the sensitive-comparator context salt).
748
+ def evaluate_segment_condition(condition, ctx)
749
+ return evaluate_segment_condition_hot(condition, ctx) if ctx.log_builder.nil?
750
+
751
+ evaluate_segment_condition_traced(condition, ctx)
752
+ end
753
+
754
+ def evaluate_segment_condition_hot(condition, ctx)
755
+ surface = lambda do
756
+ surface_missing_user(ctx)
757
+ MISSING_USER_OBJECT_ERROR
758
+ end
759
+
760
+ Segments.evaluate_segment_condition(
761
+ condition, ctx.segments, !ctx.user.nil?, surface,
762
+ ->(uc, salt) { evaluate_user_condition(uc, salt, ctx) }
763
+ )
764
+ end
765
+
766
+ # Traced path (mirrors Go's evaluateSegmentCond). Resolve the segment name
767
+ # first so the `User IS [NOT] IN SEGMENT 'X'` line is appended before the
768
+ # no-user / reference guards, exactly like the oracle.
769
+ def evaluate_segment_condition_traced(condition, ctx)
770
+ lb = ctx.log_builder
771
+ segment = resolve_segment(condition, ctx)
772
+ segment_name = segment.is_a?(::Hash) && segment["n"].is_a?(String) ? segment["n"] : ""
773
+ cmp = condition["c"]
774
+ cmp_int = cmp.is_a?(Integer) && !boolean?(cmp) ? cmp : Segments::SEGMENT_IS_IN
775
+ lb.append_segment_condition(segment_name, cmp_int)
776
+
777
+ if ctx.user.nil?
778
+ surface_missing_user(ctx)
779
+ return Comparators.error(MISSING_USER_OBJECT_ERROR)
780
+ end
781
+ raise Segments::SegmentReferenceError, "Segment reference is invalid." if segment.nil? || segment_name == ""
782
+
783
+ lb.new_line("(").increase_indent.new_line("Evaluating segment '#{segment_name}':")
784
+ wrapped = (segment["r"] || []).map { |uc| { "u" => uc } }
785
+ in_segment = evaluate_conditions(wrapped, segment_name, ctx, nil)
786
+ trace_segment_result(lb, segment_name, cmp_int, in_segment, ctx)
787
+ end
788
+
789
+ def resolve_segment(condition, ctx)
790
+ index = condition["s"]
791
+ return nil unless index.is_a?(Integer) && !boolean?(index) && index >= 0 && index < ctx.segments.length
792
+
793
+ candidate = ctx.segments[index]
794
+ candidate.is_a?(::Hash) ? candidate : nil
795
+ end
796
+
797
+ def trace_segment_result(builder, segment_name, cmp_int, in_segment, ctx)
798
+ errored = in_segment.is_a?(Comparators::ValidationError)
799
+ # IsNotIn negates a clean boolean outcome; an errored segment is surfaced
800
+ # un-negated — IsNotIn never flips an error into a match.
801
+ result = in_segment
802
+ result = !in_segment if !errored && cmp_int == Segments::SEGMENT_IS_NOT_IN
803
+
804
+ builder.new_line("Segment evaluation result: ")
805
+ if errored
806
+ builder.append(ctx.state.eval_error || "")
807
+ else
808
+ builder.append(in_segment == true ? "User IS IN SEGMENT" : "User IS NOT IN SEGMENT")
809
+ end
810
+ builder.append(".")
811
+ builder.new_line("Condition (").append_segment_condition(segment_name, cmp_int).append(")")
812
+ if errored
813
+ builder.append(" failed to evaluate")
814
+ else
815
+ builder.append(" evaluates to ").append_condition_result(result == true)
816
+ end
817
+ builder.append(".").decrease_indent.new_line(")")
818
+
819
+ return Comparators.error(ctx.state.eval_error || MISSING_USER_OBJECT_ERROR) if errored
820
+
821
+ result
822
+ end
823
+
824
+ # Surface the "User Object is missing" reason once per context (FR-016).
825
+ # Also logs the WARN 3001 once per context (common-js parity).
826
+ def surface_missing_user(ctx)
827
+ ctx.state.eval_error ||= MISSING_USER_OBJECT_ERROR
828
+ return if ctx.state.missing_user_object_logged
829
+
830
+ ctx.log.user_object_is_missing(ctx.key)
831
+ ctx.state.missing_user_object_logged = true
832
+ end
833
+
834
+ def missing_user_attribute_error(attr)
835
+ "cannot evaluate, the User.#{attr} attribute is missing"
836
+ end
837
+
838
+ # Read a well-known or custom attribute off a user-like object. Mirrors
839
+ # `user.ts::getUserAttribute`: `Identifier` → identifier ?? "", `Email` →
840
+ # email, `Country` → country, else → custom[name]. Returns nil for an
841
+ # absent attribute (the evaluator treats nil and "" as "missing"). Reads
842
+ # are duck-typed (`identifier/email/country/custom` readers) so the corpus
843
+ # harness can feed typed custom values without redesigning the public
844
+ # `Confeature::User`.
845
+ def get_user_attribute(user, name)
846
+ case name
847
+ when IDENTIFIER_ATTRIBUTE then user_identifier(user)
848
+ when EMAIL_ATTRIBUTE then user.email
849
+ when COUNTRY_ATTRIBUTE then user.country
850
+ else
851
+ custom = user.custom
852
+ custom.is_a?(::Hash) ? custom[name] : nil
853
+ end
854
+ end
855
+
856
+ def user_identifier(user)
857
+ id = user.identifier
858
+ id.nil? ? "" : id
859
+ end
860
+
861
+ def boolean?(value)
862
+ [true, false].include?(value)
863
+ end
864
+ end
865
+ end
866
+ end