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,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ require "digest"
6
+
7
+ module Confeature
8
+ module Internal
9
+ # ConfigCat evaluation hashes — SHA-1 (unsalted %) + SHA-256 (salted
10
+ # sensitive). Ported field-for-field from the spec-117 TS reference
11
+ # (`packages/sdk-shared/src/hash.ts`) and the spec-118 Python port
12
+ # (`apps/sdk-python/src/confeature_client/hash.py`). The flag evaluator
13
+ # needs two synchronous hashes:
14
+ #
15
+ # - **SHA-1, UNSALTED** for percentage bucketing —
16
+ # `Digest::SHA1.hexdigest(key + attr_value)[0, 7].to_i(16) % 100`.
17
+ # - **SHA-256, SALTED** for sensitive comparators —
18
+ # `sha256(utf8(value) + utf8(config_salt) + utf8(context_salt))`.
19
+ #
20
+ # Unlike the TS reference (which vendors a pure-JS implementation because
21
+ # `crypto.subtle` is async on every runtime), Ruby's stdlib `Digest` is
22
+ # synchronous and present everywhere, so we use it directly. The bytes
23
+ # hashed are identical to the TS / Python implementations — UTF-8-encoded
24
+ # text for the text paths, raw byte slices for the startsWith/endsWith
25
+ # slice scheme — so every bucket / hash comes out byte-identical across the
26
+ # federation (the parity corpus requires this).
27
+ # Named `Hashing` (not `Hash`) to avoid shadowing Ruby's core `::Hash`
28
+ # class for any unqualified `Hash` reference elsewhere in
29
+ # `Confeature::Internal` (e.g. `body.is_a?(Hash)` in `config_fetcher.rb`).
30
+ module Hashing
31
+ module_function
32
+
33
+ # Lowercase-hex SHA-1 of raw bytes (mirrors `hash.ts::sha1`).
34
+ def sha1_hex(data)
35
+ Digest::SHA1.hexdigest(data)
36
+ end
37
+
38
+ # Lowercase-hex SHA-256 of raw bytes (mirrors `hash.ts::sha256Bytes`).
39
+ def sha256_hex(data)
40
+ Digest::SHA256.hexdigest(data)
41
+ end
42
+
43
+ # Hash a user-attribute value for a **sensitive** comparator. Mirrors
44
+ # `hash.ts::hashComparisonValue` (common-js `hashComparisonValue`):
45
+ #
46
+ # sha256( utf8(value) + utf8(config_json_salt) + utf8(context_salt) )
47
+ #
48
+ # concatenated with no separators, output as lowercase hex.
49
+ #
50
+ # @param value [String] attribute value as text (after conversion).
51
+ # @param config_json_salt [String] top-level config salt (`config["p"]["s"]`).
52
+ # @param context_salt [String] per-evaluation discriminator — the setting
53
+ # key for a user condition, the segment name for a segment condition.
54
+ def hash_comparison_value(value, config_json_salt, context_salt)
55
+ sha256_hex(b("#{value}#{config_json_salt}#{context_salt}"))
56
+ end
57
+
58
+ # Hash a UTF-8 byte slice for a sensitive startsWith/endsWith comparator.
59
+ # Mirrors `comparators.ts::hashComparisonValueSlice`:
60
+ #
61
+ # sha256( slice_bytes ++ utf8(config_salt) ++ utf8(context_salt) )
62
+ #
63
+ # The slice is **raw bytes** — a byte-boundary cut of the attribute, which
64
+ # may land mid-codepoint — so the hash runs over the concatenated byte
65
+ # sequence (re-encoding a binary string as UTF-8 would corrupt multibyte
66
+ # attributes, the `testmatrix_unicode` `*Hashed` rows). `slice_utf8` is an
67
+ # ASCII-8BIT (binary) string.
68
+ def hash_comparison_value_slice(slice_utf8, config_json_salt, context_salt)
69
+ sha256_hex(slice_utf8 + b(config_json_salt) + b(context_salt))
70
+ end
71
+
72
+ # Compute a ConfigCat percentage bucket in `[0, 99]` for
73
+ # `key + attr_value`. Mirrors `hash.ts::percentageBucket`:
74
+ #
75
+ # sha1(key + attr_value).hexdigest[0, 7].to_i(16) % 100
76
+ #
77
+ # SHA-1, **unsalted**. 7 hex chars = 28 bits, well within a safe integer.
78
+ def percentage_bucket(key, attr_value)
79
+ sha1_hex(b("#{key}#{attr_value}"))[0, 7].to_i(16) % 100
80
+ end
81
+
82
+ # Force a string to its UTF-8 bytes as a binary blob so `Digest` hashes
83
+ # the exact byte sequence (and concatenation with binary slices works).
84
+ def b(str)
85
+ str.encode(Encoding::UTF_8).b
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,184 @@
1
+ # frozen_string_literal: true
2
+
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ require_relative "../flag_overrides"
6
+ require_relative "evaluator"
7
+
8
+ module Confeature
9
+ module Internal
10
+ # Synchronous override evaluation — Ruby port of `EvaluateSync`.
11
+ #
12
+ # Byte-identical 7-branch decision tree from
13
+ # `apps/sdk-go/internal/overrideeval/eval.go` (Go) and
14
+ # `packages/sdk-shared/src/override-eval.ts` (TS canonical). The
15
+ # Python port lives at
16
+ # `apps/sdk-python/src/confeature_client/override_eval.py`. The
17
+ # parity corpus is the canonical gate.
18
+ #
19
+ # The helper is intentionally pure: no logger access, no SDK-key
20
+ # access, no I/O. Pure data in → tuple `[found, value]` out.
21
+ #
22
+ # Returns:
23
+ # - `[false, nil]` when overrides are absent, behaviour is
24
+ # non-LocalOnly, or no synchronous data source is available
25
+ # (Branches 1-3: caller falls through to the cache).
26
+ # - `[true, <value>]` when overrides + LocalOnly + key match
27
+ # (Branch 7 — typed override value); also
28
+ # `[true, default_value]` for the LocalOnly fallback branches
29
+ # 4-6 (key absent / empty slot / TYPE_MISMATCH).
30
+ module OverrideEval
31
+ module_function
32
+
33
+ def evaluate_sync(flag_overrides, key, default_value)
34
+ # Branch 1: no overrides configured → caller falls through.
35
+ return [false, nil] if flag_overrides.nil?
36
+
37
+ # Branch 2: non-LocalOnly behaviours short-circuit (spec 031 §R8).
38
+ return [false, nil] if flag_overrides.behaviour != OverrideBehaviour::LOCAL_ONLY
39
+
40
+ # Branch 3: no synchronous data source available → fall through.
41
+ data_source = flag_overrides.data_source
42
+ return [false, nil] if data_source.nil?
43
+ return [false, nil] unless data_source.respond_to?(:get_overrides_sync)
44
+
45
+ overrides_map = data_source.get_overrides_sync
46
+ return [false, nil] if overrides_map.nil?
47
+
48
+ # From here we are committed to the LocalOnly contract: NEVER
49
+ # fall through to the cache — return either the typed override
50
+ # or the supplied default.
51
+
52
+ # Branch 4: key absent → return default.
53
+ raw = overrides_map[key]
54
+ return [true, default_value] if raw.nil?
55
+
56
+ # Branch 5: override slot empty → return default.
57
+ candidate = raw.picked
58
+ return [true, default_value] if candidate.nil?
59
+
60
+ # Branch 6: TYPE_MISMATCH — primitive types diverge → return default.
61
+ # A `nil` default_value intentionally bypasses the check (matches
62
+ # configcat-js's `defaultValue !== null` ternary). In Ruby
63
+ # `true.class` and `false.class` differ (TrueClass / FalseClass)
64
+ # — treat both as the same "boolean" bucket so a `true`
65
+ # override matched against a `false` default is NOT a type
66
+ # mismatch.
67
+ if !default_value.nil? && !boolean_kind_match?(candidate, default_value) &&
68
+ candidate.class != default_value.class
69
+ return [true, default_value]
70
+ end
71
+
72
+ # Branch 7: happy path — typed override value.
73
+ [true, candidate]
74
+ end
75
+
76
+ def boolean_kind_match?(candidate, default_value)
77
+ boolean?(candidate) && boolean?(default_value)
78
+ end
79
+
80
+ def boolean?(value)
81
+ value.is_a?(TrueClass) || value.is_a?(FalseClass)
82
+ end
83
+
84
+ # Full-config override resolution — Ruby port of
85
+ # `evaluateFullConfigOverrideSync` (spec 135 / #431).
86
+ #
87
+ # When the override source is a `ConfigOverrideDataSource`, resolve
88
+ # `key` through the real Ruby evaluator so targeting rules /
89
+ # segments / prerequisites / percentage options apply offline — the
90
+ # rule-bearing sibling of `evaluate_sync`'s flat lookup.
91
+ #
92
+ # Returns:
93
+ # - `[true, <value>]` when overrides + LocalOnly + a
94
+ # `ConfigOverrideDataSource` apply (LocalOnly: never fall
95
+ # through to the cache — the evaluator already returns
96
+ # `default_value` for any non-clean resolution, so this
97
+ # preserves the contract);
98
+ # - `[false, nil]` when overrides are absent, behaviour is
99
+ # non-LocalOnly, or the source is NOT a full-config source
100
+ # (caller falls through to the flat-map helper / normal eval).
101
+ #
102
+ # INV-4: the evaluator receives the FULL config Hash (`{ p, f, s }`)
103
+ # — salt (`p.s`) + segments (`s`), never just `f` (the spec-129
104
+ # dropped-salt regression class).
105
+ def evaluate_full_config_override_sync(flag_overrides, key, default_value, user)
106
+ return [false, nil] if flag_overrides.nil?
107
+ return [false, nil] if flag_overrides.behaviour != OverrideBehaviour::LOCAL_ONLY
108
+
109
+ data_source = flag_overrides.data_source
110
+ return [false, nil] unless data_source.is_a?(ConfigOverrideDataSource)
111
+
112
+ # Pass the FULL config Hash (salt + segments + f), never just `f`
113
+ # (INV-4).
114
+ result = Evaluator.evaluate(data_source.config, key, default_value, user)
115
+ [true, result.value]
116
+ end
117
+
118
+ # Details-returning sibling of `evaluate_full_config_override_sync`
119
+ # (spec 152 / #475, US2 — Ruby port of Python's
120
+ # `evaluate_full_config_override_details_sync`).
121
+ #
122
+ # Resolves `key` through the real evaluator and returns the full
123
+ # `EvaluationResult` (value + served `variation_id` + matched
124
+ # rule / option) so the PUBLIC `get_value_details` surfaces the
125
+ # override's identity. Closes the #395 gap class: the snapshot's
126
+ # LocalOnly details branch previously routed a
127
+ # `ConfigOverrideDataSource` through the value-only flat-map helper
128
+ # (which cannot read rule-bearing settings and never carries a
129
+ # `variation_id`).
130
+ #
131
+ # Returns:
132
+ # - `[true, <EvaluationResult>]` when overrides + LocalOnly + a
133
+ # `ConfigOverrideDataSource` apply;
134
+ # - `[false, nil]` when overrides are absent, behaviour is
135
+ # non-LocalOnly, or the source is NOT a full-config source
136
+ # (caller falls through to the flat-map details branch —
137
+ # unchanged value-only override semantics).
138
+ #
139
+ # INV-4: the evaluator receives the FULL config Hash (`{ p, f, s }`)
140
+ # — salt (`p.s`) + segments (`s`), never just `f`.
141
+ def evaluate_full_config_override_details_sync(flag_overrides, key, default_value, user)
142
+ return [false, nil] if flag_overrides.nil?
143
+ return [false, nil] if flag_overrides.behaviour != OverrideBehaviour::LOCAL_ONLY
144
+
145
+ data_source = flag_overrides.data_source
146
+ return [false, nil] unless data_source.is_a?(ConfigOverrideDataSource)
147
+
148
+ # Pass the FULL config Hash (salt + segments + f), never just `f`
149
+ # (INV-4).
150
+ result = Evaluator.evaluate(data_source.config, key, default_value, user)
151
+ [true, result]
152
+ end
153
+
154
+ # Shared override router — Ruby port of `evaluateOverride`
155
+ # (spec 135 / #431).
156
+ #
157
+ # Dispatches between the flat-map path and the full-config path so
158
+ # the `get_value` call site changes minimally: a
159
+ # `ConfigOverrideDataSource` routes through the real evaluator;
160
+ # every other source (map / custom) keeps the existing
161
+ # byte-identical flat-map resolution.
162
+ #
163
+ # Returns `[found, value]` — `[false, nil]` when no override applies
164
+ # (the caller then falls through to the normal cache-driven eval).
165
+ # Preserves the LocalOnly no-fall-through contract on both paths.
166
+ def evaluate(flag_overrides, key, default_value, user)
167
+ # Full-config source → ALWAYS resolve through the evaluator; NEVER
168
+ # fall through to the flat-map path (which would mis-read
169
+ # rule-bearing settings as value-only). Dispatch on the source
170
+ # TYPE, not the resolved value, so a full-config source whose
171
+ # evaluator returns the default still stays on the rule path.
172
+ if !flag_overrides.nil? &&
173
+ flag_overrides.behaviour == OverrideBehaviour::LOCAL_ONLY &&
174
+ flag_overrides.data_source.is_a?(ConfigOverrideDataSource)
175
+ return evaluate_full_config_override_sync(flag_overrides, key, default_value, user)
176
+ end
177
+
178
+ # Every other source (map / custom) keeps the byte-identical
179
+ # flat-map path.
180
+ evaluate_sync(flag_overrides, key, default_value)
181
+ end
182
+ end
183
+ end
184
+ end
@@ -0,0 +1,218 @@
1
+ # frozen_string_literal: true
2
+
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ require_relative "../polling_mode"
6
+
7
+ module Confeature
8
+ module Internal
9
+ # Polling strategies: AutoPoll (daemon thread), LazyLoad (on-demand
10
+ # staleness), ManualPoll (caller-driven).
11
+ #
12
+ # Lifecycle:
13
+ # - constructor accepts a `fetch_callback` (no args, returns
14
+ # ConfigFetcher::FetchResult) bound to the client's
15
+ # ConfigFetcher.
16
+ # - `start(callback)` is idempotent. AutoPoll spawns its daemon
17
+ # thread here. LazyLoad / ManualPoll record the callback but
18
+ # spawn nothing.
19
+ # - `force_refresh` invokes the callback synchronously and
20
+ # returns a `RefreshResult`.
21
+ # - `stop(timeout: 5.0)` signals the thread and joins (no-op for
22
+ # non-AutoPoll modes).
23
+ #
24
+ # AutoPoll uses `Thread.new` + `Mutex` + `ConditionVariable` for
25
+ # cancel signalling. Threads are daemon so process shutdown never
26
+ # hangs on a poll-thread join.
27
+ module PollService
28
+ RefreshResult = Struct.new(:success, :error, keyword_init: true) do
29
+ def self.from_fetch(fetch_result)
30
+ if %i[fetched not_modified].include?(fetch_result.status)
31
+ new(success: true)
32
+ else
33
+ new(success: false, error: fetch_result.error || "FETCH_FAILED")
34
+ end
35
+ end
36
+ end
37
+
38
+ # Abstract base — subclasses provide the per-mode behaviour.
39
+ class Base
40
+ def start(_callback)
41
+ raise NotImplementedError
42
+ end
43
+
44
+ def stop(timeout: 5.0)
45
+ raise NotImplementedError
46
+ end
47
+
48
+ def force_refresh
49
+ raise NotImplementedError
50
+ end
51
+
52
+ # Hook for LazyLoad's on-demand staleness-driven fetch.
53
+ # AutoPoll + ManualPoll return immediately.
54
+ def maybe_fetch_for_get_value
55
+ nil
56
+ end
57
+ end
58
+
59
+ class AutoPollService < Base
60
+ def initialize(options)
61
+ super()
62
+ @options = options
63
+ @callback = nil
64
+ @started = false
65
+ @mutex = Mutex.new
66
+ @stop_mutex = Mutex.new
67
+ @stop_cond = ConditionVariable.new
68
+ @stopped = false
69
+ @thread = nil
70
+ end
71
+
72
+ def start(callback)
73
+ @mutex.synchronize do
74
+ return if @started
75
+
76
+ @callback = callback
77
+ @started = true
78
+ end
79
+
80
+ # Eager initial fetch so first get_value lands on real config.
81
+ safe_invoke(@callback)
82
+
83
+ @thread = Thread.new { run_loop }
84
+ @thread.name = "confeature-autopoll" if @thread.respond_to?(:name=)
85
+ @thread.report_on_exception = false if @thread.respond_to?(:report_on_exception=)
86
+ end
87
+
88
+ def stop(timeout: 5.0)
89
+ @stop_mutex.synchronize do
90
+ @stopped = true
91
+ @stop_cond.broadcast
92
+ end
93
+ thread = @thread
94
+ @thread = nil
95
+ thread&.join(timeout)
96
+ nil
97
+ end
98
+
99
+ def force_refresh
100
+ cb = @callback
101
+ return RefreshResult.new(success: false, error: "NOT_STARTED") if cb.nil?
102
+
103
+ result = safe_invoke(cb)
104
+ return RefreshResult.new(success: false, error: "CALLBACK_RAISED") if result.nil?
105
+
106
+ RefreshResult.from_fetch(result)
107
+ end
108
+
109
+ private
110
+
111
+ def run_loop
112
+ interval = @options.poll_interval_seconds
113
+ loop do
114
+ @stop_mutex.synchronize do
115
+ return if @stopped
116
+
117
+ @stop_cond.wait(@stop_mutex, interval)
118
+ return if @stopped
119
+ end
120
+ cb = @callback
121
+ return if cb.nil?
122
+
123
+ safe_invoke(cb)
124
+ end
125
+ end
126
+
127
+ # Returns the callback's result, or nil if the callback raised.
128
+ # The shipped `ConfigFetcher.fetch` is fail-closed; this guard
129
+ # exists so a misbehaving custom callback can't crash the
130
+ # daemon thread or the force_refresh path.
131
+ def safe_invoke(cb)
132
+ cb.call
133
+ rescue StandardError
134
+ nil
135
+ end
136
+ end
137
+
138
+ class LazyLoadService < Base
139
+ def initialize(options)
140
+ super()
141
+ @options = options
142
+ @callback = nil
143
+ @last_fetched_at = 0.0
144
+ @mutex = Mutex.new
145
+ end
146
+
147
+ def start(callback)
148
+ @mutex.synchronize { @callback = callback }
149
+ end
150
+
151
+ def stop(timeout: 5.0) # rubocop:disable Lint/UnusedMethodArgument
152
+ nil
153
+ end
154
+
155
+ def force_refresh
156
+ cb = @callback
157
+ return RefreshResult.new(success: false, error: "NOT_STARTED") if cb.nil?
158
+
159
+ result = RefreshResult.from_fetch(cb.call)
160
+ @mutex.synchronize { @last_fetched_at = Time.now.to_f } if result.success
161
+ result
162
+ end
163
+
164
+ def maybe_fetch_for_get_value
165
+ cb = @callback
166
+ return nil if cb.nil?
167
+
168
+ now = Time.now.to_f
169
+ stale = (now - @last_fetched_at) >= @options.cache_refresh_interval_seconds
170
+ return nil unless stale
171
+
172
+ begin
173
+ cb.call
174
+ @mutex.synchronize { @last_fetched_at = Time.now.to_f }
175
+ rescue StandardError
176
+ # Swallow — fetch errors are logged inside ConfigFetcher.
177
+ end
178
+ nil
179
+ end
180
+ end
181
+
182
+ class ManualPollService < Base
183
+ def initialize
184
+ super
185
+ @callback = nil
186
+ end
187
+
188
+ def start(callback)
189
+ @callback = callback
190
+ end
191
+
192
+ def stop(timeout: 5.0) # rubocop:disable Lint/UnusedMethodArgument
193
+ nil
194
+ end
195
+
196
+ def force_refresh
197
+ cb = @callback
198
+ return RefreshResult.new(success: false, error: "NOT_STARTED") if cb.nil?
199
+
200
+ RefreshResult.from_fetch(cb.call)
201
+ end
202
+ end
203
+
204
+ # Build the service matching the supplied PollingMode value.
205
+ def self.build(mode)
206
+ case mode
207
+ when PollingMode::AutoPoll then AutoPollService.new(mode)
208
+ when PollingMode::LazyLoad then LazyLoadService.new(mode)
209
+ when PollingMode::ManualPoll then ManualPollService.new
210
+ else
211
+ raise ArgumentError,
212
+ "Unsupported polling mode: #{mode.inspect}. " \
213
+ "Use PollingMode.auto_poll, .lazy_load, or .manual_poll."
214
+ end
215
+ end
216
+ end
217
+ end
218
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ module Confeature
6
+ module Internal
7
+ # Prerequisite-flag condition resolution + circular-dependency detection.
8
+ # Ported from the spec-117 TS reference
9
+ # (`packages/sdk-shared/src/prerequisites.ts`) and the spec-118 Python port,
10
+ # itself from common-js `RolloutEvaluator.evaluatePrerequisiteFlagCondition`.
11
+ #
12
+ # A prerequisite condition (`{ "f" => key, "c" => comparator, "v" => env }`)
13
+ # is resolved by **re-evaluating the prerequisite flag** in a child context
14
+ # (same config / user, SHARED `visited_flags` list), then comparing its
15
+ # resolved value to the expected `v` via the comparator (`Equals` /
16
+ # `NotEquals`). Both the resolved value and `v` are read through the
17
+ # prerequisite flag's own declared `SettingType`.
18
+ #
19
+ # **Cycle detection** uses the shared `visited_flags` list: push the CURRENT
20
+ # key, check whether the prerequisite key is already on the list BEFORE
21
+ # recursing — if so, push the prerequisite key too and raise
22
+ # `CircularDependencyError`. The outer `evaluate` rescue-all turns that into
23
+ # a fail-closed fallback + error, so a cycle is **bounded** (the key set is
24
+ # finite; the second visit raises) — never a hang, never a `SystemStackError`
25
+ # escape (FR-007, SC-004). On the non-cycle path the current key is popped
26
+ # after the child evaluation returns, restoring the list for siblings.
27
+ module Prerequisites
28
+ PREREQUISITE_EQUALS = 0
29
+ PREREQUISITE_NOT_EQUALS = 1
30
+
31
+ # Raised on a detected prerequisite cycle (caught by `evaluate`'s frame).
32
+ # `cycle` carries the `key -> … -> key` chain so the trace logger can emit
33
+ # ConfigCat's distinct ERROR 1002 line (`[<cycle>]`) without re-parsing the
34
+ # message (mirrors the .NET / Rust ports).
35
+ class CircularDependencyError < StandardError
36
+ attr_reader :cycle
37
+
38
+ def initialize(cycle)
39
+ @cycle = cycle
40
+ super("Circular dependency detected between the following depending flags: #{cycle}.")
41
+ end
42
+ end
43
+
44
+ # A missing / invalid prerequisite flag — a fatal config error
45
+ # (fail-closed, caught by the evaluator's rescue-all).
46
+ class PrerequisiteReferenceError < StandardError; end
47
+
48
+ module_function
49
+
50
+ # Resolve one prerequisite-flag condition. The setting re-evaluator +
51
+ # value selector are injected so this module has no back-reference to the
52
+ # evaluator. Returns the comparison result (`true` / `false`). Raises
53
+ # `CircularDependencyError` on a detected cycle or
54
+ # `PrerequisiteReferenceError` on a missing prerequisite flag.
55
+ def evaluate_prerequisite_flag_condition(
56
+ condition, current_key, visited_flags, prereq_type, evaluate_prereq_flag, select_value
57
+ )
58
+ prereq_key = condition["f"]
59
+ raise PrerequisiteReferenceError, "Prerequisite flag key is missing or invalid." unless prereq_key.is_a?(String)
60
+
61
+ declared_type = prereq_type.call(prereq_key)
62
+ raise PrerequisiteReferenceError, "Prerequisite flag is missing or invalid." if declared_type.nil?
63
+
64
+ # The expected comparison value `v`, read through the prereq flag's type.
65
+ comparison_value = select_value.call(condition["v"], declared_type)
66
+
67
+ # Cycle detection: push current key, detect a revisit BEFORE recursing.
68
+ visited_flags.push(current_key)
69
+ if visited_flags.include?(prereq_key)
70
+ visited_flags.push(prereq_key)
71
+ raise CircularDependencyError, visited_flags.join(" -> ")
72
+ end
73
+
74
+ resolved_envelope = evaluate_prereq_flag.call(prereq_key)
75
+ # Restore the list for sibling prerequisites (common-js pops here).
76
+ visited_flags.pop
77
+
78
+ resolved_value = select_value.call(resolved_envelope, declared_type)
79
+
80
+ case condition["c"]
81
+ when PREREQUISITE_EQUALS then resolved_value == comparison_value
82
+ when PREREQUISITE_NOT_EQUALS then resolved_value != comparison_value
83
+ else raise PrerequisiteReferenceError, "Comparison operator is missing or invalid."
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end