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.
- checksums.yaml +4 -4
- data/README.md +26 -4
- data/lib/confeature/client.rb +397 -0
- data/lib/confeature/evaluation_details.rb +89 -0
- data/lib/confeature/flag_overrides.rb +186 -0
- data/lib/confeature/hooks.rb +84 -0
- data/lib/confeature/internal/cache.rb +166 -0
- data/lib/confeature/internal/comparators.rb +483 -0
- data/lib/confeature/internal/config_fetcher.rb +254 -0
- data/lib/confeature/internal/eval_log.rb +334 -0
- data/lib/confeature/internal/eval_logger.rb +140 -0
- data/lib/confeature/internal/evaluator.rb +866 -0
- data/lib/confeature/internal/hashing.rb +89 -0
- data/lib/confeature/internal/override_eval.rb +184 -0
- data/lib/confeature/internal/poll_service.rb +218 -0
- data/lib/confeature/internal/prerequisites.rb +88 -0
- data/lib/confeature/internal/sdk_key.rb +121 -0
- data/lib/confeature/internal/segments.rb +79 -0
- data/lib/confeature/internal/semver.rb +163 -0
- data/lib/confeature/log_level.rb +20 -0
- data/lib/confeature/logger.rb +59 -0
- data/lib/confeature/polling_mode.rb +62 -0
- data/lib/confeature/snapshot.rb +158 -0
- data/lib/confeature/user.rb +52 -0
- data/lib/confeature/version.rb +11 -0
- data/lib/confeature.rb +39 -1
- metadata +70 -7
|
@@ -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
|