quonfig 1.6.0 → 1.7.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 96d17a74751571b9c6099b8239b2ccaafb25a5f7e55d006cecae5cb1f8651979
4
- data.tar.gz: 6bbe54bd07a5fb1e20b987ef8a8aaef417533ca8ef26000873db8a671142d4bb
3
+ metadata.gz: 1b22d7d46b27ff2ab1452b41eaa68940d886216aaeb16c61a3faddf0778d352c
4
+ data.tar.gz: 0ca1f8e621bcfcd839a28d1d754560b5cf023eff205b24b83049196e64a8e4b0
5
5
  SHA512:
6
- metadata.gz: dcb86b3f98c5f986d61f0b0db084b992fe4c3bfefc86ef2f7d0f65487fa95dc6d66fdd4b5150dd2c0f8b6ddd819f6c0bc77980e4ae89d618d31daa3649bc48c6
7
- data.tar.gz: 9faba58f93536256b216dd70dc95849adbb1c4e18b033feb9419d87d26dd8b04ec132738f4bfcbee3b0e7dca0b9051bad43971644b3312d0320ee496e8550efc
6
+ metadata.gz: 720d2acbfe8d30a785c1197fa319d608b1b4e11a1b60657b23d7b3dd921ed0c4882c99e7446b469a009552f09ca54538482509d9dc6a6968dc11df6f0e42d7c2
7
+ data.tar.gz: 11a8f0fbb7851dba082a46a7b657e9ec49aa61ae4af155cf9bf2272a35a3e5f75fa8148acdf0f3df8b936798cae5d8619dbc86bb981199f5bf29ceadf5f79567
data/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.7.1 - 2026-10-03
4
+
5
+ - **Behaviour change (bug fix): weighted rollouts now hash non-ASCII text the same way as every other Quonfig SDK (qfg-1mvb).** The MurmurHash3 used to pick a weighted variant counted characters instead of UTF-8 bytes, so a config key or hash value containing non-ASCII text (for example `josé`, `日本語`, `🚀`) hashed to a different number than sdk-java, sdk-python, sdk-go and sdk-node. It now hashes the UTF-8 bytes. Ruby users whose hash value (or the flag's key) contains non-ASCII characters may move to a different variant on upgrade; afterwards they get the same variant as in the other SDKs. ASCII-only values and keys are unchanged.
6
+ - **Fix: `Quonfig::Reason.compute` now returns `:SPLIT` for weighted bucket 0 (qfg-mn8f).** It returned `:SPLIT` only for buckets 1 and up, so bucket 0 was reported as `:DEFAULT`/`:RULE_MATCH`. The SDK's own evaluation path does not use this method and already reported bucket 0 as `SPLIT`; this only affects code that calls `Quonfig::Reason.compute` directly.
7
+
8
+ ## 1.7.0 - 2026-10-02
9
+
10
+ - **New: public `Client#get_or_raise` and `Client#get_duration_details` (also on `BoundClient`) (qfg-2agi.27).** `get_or_raise(key, default:, context:)` returns the value like `get`; a missing key with no `default:` raises `MissingDefaultError` even under `on_no_default: :return_nil`, and an unset ENV_VAR (`MissingEnvVarError`), an uncoercible ENV_VAR or malformed stored duration (`EnvVarParseError`) or a decryption failure (`DecryptionError`) raises. `get_duration_details` returns integer milliseconds in `EvaluationDetails`; a non-duration config is `ERROR`/`TYPE_MISMATCH` and a malformed value is `ERROR` with a nil value. The shared integration suite's init-timeout `get_or_raise` case now calls the public method instead of falling back to `get`.
11
+ - **Fix: durations follow the one grammar all Quonfig SDKs share, and a malformed duration returns the default instead of a made-up number (qfg-2agi.10).** Grammar (integration-test-data `tests/duration/grammar.yaml`): `P[nD][T[nH][nM][n[.fff]S]]`, at least one component, a fraction only on seconds (at most 9 digits), at most `P36500D`. Before, the pattern was unanchored, so `xxPT5Sxx` read as 5000 ms, `PT5M3H` as 300000 ms and `garbage` as 0. Milliseconds are now rounded half up on both the stored and the ENV_VAR path (`PT1.9999S` from an env var was 1999, now 2000).
12
+ - `get_duration` on a malformed value (stored or ENV_VAR) returns `default:` and logs one warning per key. With no default it returns `nil` under `on_no_default: :return_nil` and raises `Quonfig::Errors::EnvVarParseError` under the default `:raise` policy.
13
+ - `get_duration` on a config whose value type is not duration now raises `TypeMismatchError`, like `get_int` on a string config. Before, an int config's number was returned as milliseconds.
14
+ - `get` on a malformed stored duration returns `nil` (was `0` or a wrong number).
15
+ - `Quonfig::Duration.parse` raises `ArgumentError` on an invalid string (it returned `0`). New `Quonfig::Duration.parse_millis` (Integer ms or `nil`) and `Quonfig::Duration.valid?`. Values that used fractions on D/H/M (`PT0.5H`, `PT1.5M`) are now invalid; write `PT30M`, `PT90S`.
16
+ - **Fix: `Client#get` on a duration provided by an ENV_VAR now returns integer milliseconds, the same as a stored duration (qfg-2agi.22).** Before, it returned the raw ISO string (`PT30M` came back as `"PT30M"` from the env var but `1800000` when stored). `get_duration` was already correct.
17
+ - **Behaviour change (bug fix): a named context from a newer tier now replaces the whole same-named context from an older tier, instead of being merged into it property by property (qfg-2agi.36).** Tiers, oldest to newest: `global_context`, `with_context` / `in_context` scopes (outer to inner), per-call context. Example: with `global_context: { user: { 'role' => 'admin' } }`, a call passing `user: { 'plan' => 'pro' }` now evaluates with `user.plan` only; `user.role` is no longer present. Named contexts the newer tier does not mention (for example `team`) are still kept. A symbol name and a string name (`:user` vs `'user'`) count as the same named context. This is the documented rule. Callers that relied on the old property-level merge should pass every attribute of a named context they set.
18
+ - **Fix (security): evaluation-summary telemetry no longer sends the plaintext of `confidential` or `decryptWith` config values (qfg-2agi.15).** `selectedValue` now carries the `*****<hash>` redacted form the resolver already computed; before, `Client#get` reported the decrypted value. Non-confidential values are reported unchanged.
19
+ - **Tests: the shared integration suite now drives every case through the public `Quonfig::Client` (qfg-2agi.33).** Each case uses a datadir client and the typed getter named by its `type:` (`get_string`, `get_int`, `get_float`, `get_bool`, `get_string_list`, `get_json`, `get_duration`), `enabled?` or `get_or_raise`. Context tiers go through `global_context:`, `with_context` and `in_context`. Telemetry cases flush the client's real reporter to a local HTTP server and check the POST. Before, most cases called the internal resolver, and the harness redacted telemetry values itself, so a broken typed getter, a negated `enabled?` or dropped redaction left the suite green. No library change.
20
+ - **Tests: the shared integration suite now checks every duration case through the public `Client#get_duration`, with an exact integer millisecond comparison (qfg-2agi.4).** Before, it checked the internal resolver, so a bug in `get_duration` could not fail the suite. No library change.
21
+
3
22
  ## 1.6.0 - 2026-09-28
4
23
 
5
24
  - **Change: a weighted rollout that hashes on a property missing from the context now hashes an empty value, so every such caller gets the same variant for that flag (qfg-9dxb.8).** Before, such evaluations got a random variant on every call. It is the same variant a context with the property set to `""` gets, and a variant with weight 0 is not served. The reason is still `SPLIT`. The `flag_metadata` of `get_*_details` results includes `'hashPropertyMissing' => true` when the property is missing, and the SDK logs one warning per flag. A weighted rollout with no hash property configured still picks a random variant on every evaluation, unchanged. Users whose context has the property keep the same variant as in 1.5.0.
data/README.md CHANGED
@@ -259,7 +259,7 @@ Quonfig::Client.new(
259
259
  fallback_poll_enabled: true,
260
260
  fallback_poll_interval_ms: 60_000,
261
261
  init_timeout_ms: 10_000,
262
- on_no_default: :error,
262
+ on_no_default: :raise,
263
263
  global_context: {},
264
264
  datadir: '/path/to/workspace',
265
265
  environment: 'production',
@@ -277,7 +277,7 @@ Quonfig::Client.new(
277
277
  | `fallback_poll_enabled` | `Boolean` | `true` | Engage HTTP polling as a fallback when SSE is unavailable for >= 2x `fallback_poll_interval_ms`. Deprecated alias: `enable_polling`. |
278
278
  | `fallback_poll_interval_ms` | `Integer` (ms) | `60_000` | Interval between fallback HTTP polls, in milliseconds. Deprecated alias: `poll_interval` (seconds, multiplied by 1000 internally). |
279
279
  | `init_timeout_ms` | `Integer` (ms) | `10_000` | Maximum time to wait for the initial config load, in milliseconds. Deprecated alias: `initialization_timeout_sec` (seconds, multiplied by 1000 internally). |
280
- | `on_no_default` | `Symbol` | `:error` | Behavior when a key has no value and no default: `:error`, `:warn`, or `:ignore`. |
280
+ | `on_no_default` | `Symbol` | `:raise` | Behavior when a key has no value and no default: `:raise` (raise `Quonfig::Errors::MissingDefaultError`) or `:return_nil`. |
281
281
  | `global_context` | `Hash` | `{}` | Context applied to every evaluation. |
282
282
  | `datadir` | `String` | `ENV['QUONFIG_DIR']` | Path to a local workspace. When set, the SDK runs offline from disk. |
283
283
  | `environment` | `String` | `ENV['QUONFIG_ENVIRONMENT']` | Environment to evaluate in datadir mode. Required when `datadir` is set. |
@@ -361,6 +361,14 @@ client.get_json('homepage.layout')
361
361
  client.enabled?('beta-feature', user: { key: 'user-123' })
362
362
  ```
363
363
 
364
+ `get_or_raise(key, default:, context:)` returns the value like `get`, but a
365
+ missing key without a `default:` always raises
366
+ `Quonfig::Errors::MissingDefaultError` (whatever `on_no_default` is set to), and
367
+ an unset or malformed ENV_VAR value, a malformed stored duration, or a
368
+ decryption failure raises its error. `get_duration_details(key, context:)`
369
+ returns `Quonfig::EvaluationDetails` with the duration in integer milliseconds,
370
+ like the other `*_details` getters.
371
+
364
372
  ## Dynamic log levels (SemanticLogger)
365
373
 
366
374
  Quonfig can drive per-class log levels at runtime. Set config keys like
@@ -40,6 +40,10 @@ module Quonfig
40
40
  @client.get_json(key, default: default, context: @context)
41
41
  end
42
42
 
43
+ def get_or_raise(key, default: NO_DEFAULT_PROVIDED)
44
+ @client.get_or_raise(key, default: default, context: @context)
45
+ end
46
+
43
47
  # ---- Details getters ----------------------------------------------
44
48
 
45
49
  def get_bool_details(key)
@@ -66,15 +70,18 @@ module Quonfig
66
70
  @client.get_json_details(key, context: @context)
67
71
  end
68
72
 
73
+ def get_duration_details(key)
74
+ @client.get_duration_details(key, context: @context)
75
+ end
76
+
69
77
  def enabled?(feature_name)
70
78
  @client.enabled?(feature_name, @context)
71
79
  end
72
80
 
73
- # Returns a new BoundClient whose bound context is the merge of this
74
- # bound context and +additional+. Merge is one level deep per named
75
- # context (mirrors sdk-node's mergeContexts): later values override
76
- # earlier within the same named context; keys unique to each side are
77
- # preserved.
81
+ # Returns a new BoundClient whose bound context is this bound context
82
+ # plus +additional+ (qfg-2agi.24): a named context in +additional+
83
+ # REPLACES the whole same-named context (no per-property merge); named
84
+ # contexts it does not mention are kept. Names compare as strings.
78
85
  def in_context(additional)
79
86
  self.class.new(@client, merge_contexts(@context, additional || {}))
80
87
  end
@@ -87,9 +94,10 @@ module Quonfig
87
94
 
88
95
  def merge_contexts(left, right)
89
96
  merged = {}
90
- left.each { |name, ctx| merged[name] = ctx.dup }
97
+ left.each { |name, ctx| merged[name] = ctx.dup }
91
98
  right.each do |name, ctx|
92
- merged[name] = merged[name] ? merged[name].merge(ctx) : ctx.dup
99
+ merged.delete_if { |existing, _| existing.to_s == name.to_s }
100
+ merged[name] = ctx.dup
93
101
  end
94
102
  merged
95
103
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'json'
4
4
  require 'timeout'
5
+ require 'set'
5
6
 
6
7
  module Quonfig
7
8
  # Public Quonfig SDK client.
@@ -63,6 +64,9 @@ module Quonfig
63
64
  @evaluator = Quonfig::Evaluator.new(@store, env_id: @options.environment)
64
65
  @resolver = Quonfig::Resolver.new(@store, @evaluator)
65
66
  @semantic_logger_filters = {}
67
+ # Keys already warned about for a malformed duration value (qfg-2agi.10).
68
+ @malformed_duration_warned = Set.new
69
+ @malformed_duration_mutex = Mutex.new
66
70
  @sse_client = nil
67
71
  @poll_supervisor = nil
68
72
  @stopped = false
@@ -161,14 +165,70 @@ module Quonfig
161
165
  typed_get(key, :string_list, default: default, context: context)
162
166
  end
163
167
 
168
+ # Integer milliseconds for a duration config (qfg-2agi.10).
169
+ #
170
+ # - A config whose valueType is not duration raises TypeMismatchError,
171
+ # like get_int on a string config.
172
+ # - Values follow the shared grammar (Quonfig::Duration); ms are rounded
173
+ # half up on both the stored and the ENV_VAR path.
174
+ # - A malformed value (stored or ENV_VAR) returns +default+ and logs one
175
+ # warning per key. With no default it returns nil under
176
+ # on_no_default: :return_nil and raises EnvVarParseError under :raise.
164
177
  def get_duration(key, default: NO_DEFAULT_PROVIDED, context: NO_DEFAULT_PROVIDED)
165
- typed_get(key, :duration, default: default, context: context)
178
+ ensure_initialized_after_fork
179
+ ctx = build_context(context)
180
+ record_context_for_telemetry(ctx)
181
+ result =
182
+ begin
183
+ @resolver.get(key, ctx)
184
+ rescue Quonfig::Errors::MissingDefaultError
185
+ nil
186
+ rescue Quonfig::Errors::EnvVarParseError => e
187
+ return malformed_duration(key, default, e)
188
+ end
189
+ return handle_missing(key, default) if result.nil?
190
+
191
+ record_evaluation_for_telemetry(result)
192
+ duration_millis(key, result, default)
166
193
  end
167
194
 
168
195
  def get_json(key, default: NO_DEFAULT_PROVIDED, context: NO_DEFAULT_PROVIDED)
169
196
  typed_get(key, :json, default: default, context: context)
170
197
  end
171
198
 
199
+ # Like +get+, but never applies the +on_no_default+ policy (qfg-2agi.27):
200
+ #
201
+ # - A missing key (or a config with no matching value) returns +default:+
202
+ # when one is given and otherwise raises MissingDefaultError, even
203
+ # under on_no_default: :return_nil.
204
+ # - A config provided by an unset ENV_VAR raises MissingEnvVarError; an
205
+ # ENV_VAR value that cannot be coerced to the config's type raises
206
+ # EnvVarParseError; a decryption failure raises DecryptionError.
207
+ # - A malformed stored duration raises EnvVarParseError (a given
208
+ # +default:+ is not applied; it only covers a missing key).
209
+ def get_or_raise(key, default: NO_DEFAULT_PROVIDED, context: NO_DEFAULT_PROVIDED)
210
+ ensure_initialized_after_fork
211
+ ctx = build_context(context)
212
+ record_context_for_telemetry(ctx)
213
+ result =
214
+ begin
215
+ @resolver.get(key, ctx)
216
+ rescue Quonfig::Errors::MissingDefaultError
217
+ nil
218
+ end
219
+ if result.nil?
220
+ raise Quonfig::Errors::MissingDefaultError, key if default == NO_DEFAULT_PROVIDED
221
+
222
+ return default
223
+ end
224
+
225
+ record_evaluation_for_telemetry(result)
226
+ value = result.unwrapped_value
227
+ raise malformed_stored_duration_error(result) if malformed_stored_duration?(result, value)
228
+
229
+ value
230
+ end
231
+
172
232
  # ---- Details getters ----------------------------------------------
173
233
  #
174
234
  # Mirrors the typed getters above but returns a +Quonfig::EvaluationDetails+
@@ -201,6 +261,13 @@ module Quonfig
201
261
  evaluate_details(key, :json, context)
202
262
  end
203
263
 
264
+ # Integer milliseconds in +value+ (qfg-2agi.27). A config whose valueType
265
+ # is not duration is ERROR / TYPE_MISMATCH; a malformed value (stored or
266
+ # ENV_VAR) is ERROR with a nil value.
267
+ def get_duration_details(key, context: NO_DEFAULT_PROVIDED)
268
+ evaluate_details(key, :duration, context)
269
+ end
270
+
204
271
  def enabled?(feature_name, jit_context = NO_DEFAULT_PROVIDED)
205
272
  value = get(feature_name, false, jit_context)
206
273
  [true, 'true'].include?(value)
@@ -1153,7 +1220,9 @@ module Quonfig
1153
1220
  config_type: config_field(config, :type),
1154
1221
  conditional_value_index: result.rule_index,
1155
1222
  weighted_value_index: result.weighted_value_index,
1156
- selected_value: result.unwrapped_value,
1223
+ # Confidential / decryptWith values ship as the `*****<hash>` redacted
1224
+ # form, never the plaintext (qfg-2agi.15).
1225
+ selected_value: result.reportable_value.nil? ? result.unwrapped_value : result.reportable_value,
1157
1226
  reason: result.wire_reason
1158
1227
  )
1159
1228
  rescue StandardError => e
@@ -1516,7 +1585,7 @@ module Quonfig
1516
1585
 
1517
1586
  # Combine the customer-supplied globalContext with the optional dev
1518
1587
  # context loaded from ~/.quonfig/tokens.json. Dev context goes UNDER the
1519
- # customer's so any explicit `quonfig-user` keys win on collision.
1588
+ # customer's, so an explicit customer `quonfig-user` context replaces it.
1520
1589
  def build_initial_global_context(options)
1521
1590
  customer = normalize_context(options.global_context)
1522
1591
  return customer unless dev_context_enabled?(options)
@@ -1549,21 +1618,19 @@ module Quonfig
1549
1618
  raise ArgumentError, "Quonfig context must be a Hash, got #{ctx.class}"
1550
1619
  end
1551
1620
 
1552
- # One-level-deep merge per named context (mirrors sdk-node's mergeContexts):
1553
- # later values override earlier within the same named context; keys unique
1554
- # to each side are preserved.
1621
+ # Combine two context tiers (qfg-2agi.24): a named context in +right+
1622
+ # REPLACES the whole same-named context in +left+ (no per-property merge);
1623
+ # named contexts only in +left+ survive. Names compare as strings, so
1624
+ # +:user+ and +'user'+ are the same named context.
1555
1625
  def merge_contexts(left, right)
1556
1626
  return right || {} if left.nil? || left.empty?
1557
1627
  return left if right.nil? || right.empty?
1558
1628
 
1559
1629
  merged = {}
1560
- left.each { |name, ctx| merged[name] = ctx.is_a?(Hash) ? ctx.dup : ctx }
1630
+ left.each { |name, ctx| merged[name] = ctx.is_a?(Hash) ? ctx.dup : ctx }
1561
1631
  right.each do |name, ctx|
1562
- merged[name] = if merged[name].is_a?(Hash) && ctx.is_a?(Hash)
1563
- merged[name].merge(ctx)
1564
- else
1565
- ctx.is_a?(Hash) ? ctx.dup : ctx
1566
- end
1632
+ merged.delete_if { |existing, _| existing.to_s == name.to_s }
1633
+ merged[name] = ctx.is_a?(Hash) ? ctx.dup : ctx
1567
1634
  end
1568
1635
  merged
1569
1636
  end
@@ -1623,6 +1690,10 @@ module Quonfig
1623
1690
  raw_value = result.unwrapped_value
1624
1691
 
1625
1692
  begin
1693
+ if expected_type == :duration
1694
+ raise Quonfig::Errors::TypeMismatchError.new(key, 'ISO-8601 Duration', raw_value) unless duration_result?(result)
1695
+ raise malformed_stored_duration_error(result) if malformed_stored_duration?(result, raw_value)
1696
+ end
1626
1697
  coerced = coerce_and_check(key, raw_value, expected_type) unless raw_value.nil?
1627
1698
  rescue Quonfig::Errors::TypeMismatchError => e
1628
1699
  return Quonfig::EvaluationDetails.new(
@@ -1701,6 +1772,42 @@ module Quonfig
1701
1772
  coerce_and_check(key, value, expected_type)
1702
1773
  end
1703
1774
 
1775
+ def duration_millis(key, result, default)
1776
+ raise Quonfig::Errors::TypeMismatchError.new(key, 'ISO-8601 Duration', result.unwrapped_value) unless duration_result?(result)
1777
+
1778
+ value = result.unwrapped_value
1779
+ millis = value.is_a?(Integer) ? value : Quonfig::Duration.parse_millis(value)
1780
+ return millis unless millis.nil?
1781
+
1782
+ malformed_duration(key, default, malformed_stored_duration_error(result))
1783
+ end
1784
+
1785
+ def duration_result?(result)
1786
+ config = result.config || {}
1787
+ value_type = config['valueType'] || config[:valueType] || config['value_type'] || config[:value_type]
1788
+ value_type == 'duration' || result.type == 'duration'
1789
+ end
1790
+
1791
+ # A stored duration string outside the shared grammar unwraps to nil.
1792
+ def malformed_stored_duration?(result, unwrapped)
1793
+ unwrapped.nil? && result.type == 'duration' && !result.raw_value.nil?
1794
+ end
1795
+
1796
+ def malformed_stored_duration_error(result)
1797
+ Quonfig::Errors::EnvVarParseError.new(result.raw_value, result.config || {}, 'stored value')
1798
+ end
1799
+
1800
+ def malformed_duration(key, default, error)
1801
+ if @malformed_duration_mutex.synchronize { @malformed_duration_warned.add?(key.to_s) }
1802
+ LOG.warn "[quonfig] #{key} is not a valid ISO-8601 duration; " \
1803
+ "#{default == NO_DEFAULT_PROVIDED ? 'no default given' : 'returning the default'}"
1804
+ end
1805
+ return default unless default == NO_DEFAULT_PROVIDED
1806
+ raise error if @options.on_no_default == Quonfig::Options::ON_NO_DEFAULT::RAISE
1807
+
1808
+ nil
1809
+ end
1810
+
1704
1811
  def coerce_and_check(key, value, expected_type)
1705
1812
  case expected_type
1706
1813
  when :bool
@@ -1713,10 +1820,12 @@ module Quonfig
1713
1820
 
1714
1821
  arr
1715
1822
  when :duration
1716
- return value.to_i if value.is_a?(Numeric)
1717
- return (Quonfig::Duration.parse(value) * 1000).to_i if value.is_a?(String)
1823
+ return value if value.is_a?(Integer)
1824
+
1825
+ millis = Quonfig::Duration.parse_millis(value)
1826
+ raise Quonfig::Errors::TypeMismatchError.new(key, 'ISO-8601 Duration', value) if millis.nil?
1718
1827
 
1719
- raise Quonfig::Errors::TypeMismatchError.new(key, 'ISO-8601 Duration', value)
1828
+ millis
1720
1829
  when :json
1721
1830
  # JSON values are returned as-is (Hash, Array, or scalar from the wire).
1722
1831
  value
@@ -1,27 +1,76 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Quonfig
4
+ # ISO-8601 duration parsing for the grammar every Quonfig SDK shares
5
+ # (integration-test-data/tests/duration/grammar.yaml, qfg-2agi.29):
6
+ #
7
+ # \AP(?:[0-9]+D)?(?:T(?:[0-9]+H)?(?:[0-9]+M)?(?:[0-9]+(?:\.[0-9]{1,9})?S)?)?\z
8
+ #
9
+ # plus: at least one component, no dangling T, a fraction only on S (at
10
+ # most 9 digits), total magnitude <= P36500D. Anchored with \A/\z (Ruby's
11
+ # ^/$ match at line boundaries) and [0-9] (never \d).
12
+ #
13
+ # Milliseconds use exact Rational arithmetic, rounded half up.
4
14
  class Duration
5
- PATTERN = /P(?:(?<days>\d+(?:\.\d+)?)D)?(?:T(?:(?<hours>\d+(?:\.\d+)?)H)?(?:(?<minutes>\d+(?:\.\d+)?)M)?(?:(?<seconds>\d+(?:\.\d+)?)S)?)?/
15
+ PATTERN = /\AP(?:(?<days>[0-9]+)D)?(?:T(?<time>(?:(?<hours>[0-9]+)H)?(?:(?<minutes>[0-9]+)M)?(?:(?<seconds>[0-9]+(?:\.[0-9]{1,9})?)S)?))?\z/
6
16
  MINUTES_IN_SECONDS = 60
7
17
  HOURS_IN_SECONDS = 60 * MINUTES_IN_SECONDS
8
18
  DAYS_IN_SECONDS = 24 * HOURS_IN_SECONDS
19
+ MAX_SECONDS = 36_500 * DAYS_IN_SECONDS
9
20
 
10
21
  def initialize(definition)
11
22
  @seconds = self.class.parse(definition)
12
23
  end
13
24
 
25
+ # Integer milliseconds for a valid duration string, or nil when +definition+
26
+ # is not a String or does not match the grammar.
27
+ def self.parse_millis(definition)
28
+ seconds = exact_seconds(definition)
29
+ return nil if seconds.nil?
30
+
31
+ (seconds * 1000).round(half: :up).to_i
32
+ end
33
+
34
+ def self.valid?(definition)
35
+ !exact_seconds(definition).nil?
36
+ end
37
+
38
+ # Seconds as a Float. Raises ArgumentError when +definition+ is not a valid
39
+ # duration (it used to return 0 for anything unparseable).
14
40
  def self.parse(definition)
41
+ seconds = exact_seconds(definition)
42
+ raise ArgumentError, "invalid ISO-8601 duration: #{definition.inspect}" if seconds.nil?
43
+
44
+ seconds.to_f
45
+ end
46
+
47
+ # Exact Rational seconds, or nil when invalid.
48
+ def self.exact_seconds(definition)
49
+ return nil unless definition.is_a?(String)
50
+
15
51
  match = PATTERN.match(definition)
16
- return 0 unless match
52
+ return nil if match.nil?
53
+ return nil if match[:days].nil? && match[:time].nil?
54
+ return nil if match[:time] && match[:time].empty?
55
+
56
+ total = (Integer(match[:days] || '0', 10) * DAYS_IN_SECONDS) +
57
+ (Integer(match[:hours] || '0', 10) * HOURS_IN_SECONDS) +
58
+ (Integer(match[:minutes] || '0', 10) * MINUTES_IN_SECONDS) +
59
+ decimal_seconds(match[:seconds])
60
+ return nil if total > MAX_SECONDS
61
+
62
+ total
63
+ end
64
+
65
+ def self.decimal_seconds(text)
66
+ return 0 if text.nil?
17
67
 
18
- days = match[:days]&.to_f || 0
19
- hours = match[:hours]&.to_f || 0
20
- minutes = match[:minutes]&.to_f || 0
21
- seconds = match[:seconds]&.to_f || 0
68
+ whole, frac = text.split('.', 2)
69
+ return Integer(whole, 10) if frac.nil?
22
70
 
23
- ((days * DAYS_IN_SECONDS) + (hours * HOURS_IN_SECONDS) + (minutes * MINUTES_IN_SECONDS) + seconds)
71
+ Integer(whole, 10) + Rational(Integer(frac, 10), 10**frac.length)
24
72
  end
73
+ private_class_method :decimal_seconds
25
74
 
26
75
  def in_seconds
27
76
  @seconds
@@ -556,8 +556,9 @@ module Quonfig
556
556
  when Numeric
557
557
  raw.to_i
558
558
  when String
559
- seconds = Quonfig::Duration.parse(raw)
560
- (seconds * 1000).round
559
+ # nil for a malformed value, so get() never returns the raw string or
560
+ # a made-up number; Client#get_duration applies the default.
561
+ Quonfig::Duration.parse_millis(raw)
561
562
  when Hash
562
563
  secs = (raw['seconds'] || raw[:seconds] || 0).to_f
563
564
  nanos = (raw['nanos'] || raw[:nanos] || 0).to_f
@@ -25,10 +25,14 @@ class Murmur3
25
25
  (k1 * 0x1b873593) & MASK32
26
26
  end
27
27
 
28
+ # Hashes the string's BYTES (UTF-8 for any normal Ruby string), like every
29
+ # other Quonfig SDK. Using the character count instead of the byte count put
30
+ # non-ASCII input in the wrong tail/length mix (qfg-1mvb).
28
31
  def self.murmur3_32(str, seed = 0)
32
+ bytes = str.b
29
33
  h1 = seed
30
- numbers = str.unpack('V*C*')
31
- tailn = str.length % 4
34
+ numbers = bytes.unpack('V*C*')
35
+ tailn = bytes.bytesize % 4
32
36
  tail = numbers.slice!(numbers.size - tailn, tailn)
33
37
  numbers.each do |k1|
34
38
  h1 ^= murmur3_32__mmix(k1)
@@ -44,7 +48,7 @@ class Murmur3
44
48
  h1 ^= murmur3_32__mmix(k1)
45
49
  end
46
50
 
47
- h1 ^= str.length
51
+ h1 ^= bytes.bytesize
48
52
  murmur3_32_fmix(h1)
49
53
  end
50
54
  end
@@ -7,7 +7,7 @@ module Quonfig
7
7
  # :DEFAULT — config has no targeting rules; matched value is the static default
8
8
  # :RULE_MATCH — at least one targeting rule exists on the config (the matched
9
9
  # conditional may itself be ALWAYS_TRUE, but the *config* is targeted)
10
- # :SPLIT — matched value came from a non-default weighted variant
10
+ # :SPLIT — matched value came from a weighted variant (any bucket, including 0)
11
11
  # :ERROR — evaluation failed
12
12
  # :UNKNOWN — unable to determine
13
13
  module Reason
@@ -20,7 +20,7 @@ module Quonfig
20
20
  module_function
21
21
 
22
22
  def compute(config:, conditional_value:, weighted_value_index: nil)
23
- return SPLIT if weighted_value_index&.positive?
23
+ return SPLIT unless weighted_value_index.nil?
24
24
  return RULE_MATCH if targeting_rules?(config)
25
25
  return RULE_MATCH if non_always_true_criteria?(conditional_value)
26
26
 
@@ -274,6 +274,8 @@ module Quonfig
274
274
  when 'string_list'
275
275
  env_value.split(/\s*,\s*/)
276
276
  when 'duration'
277
+ raise ArgumentError, 'invalid ISO-8601 duration' unless Quonfig::Duration.valid?(env_value)
278
+
277
279
  env_value
278
280
  else
279
281
  env_value
@@ -288,6 +290,9 @@ module Quonfig
288
290
  when 'double' then 'double'
289
291
  when 'bool' then 'bool'
290
292
  when 'string_list' then 'string_list'
293
+ # Keep 'duration' so Evaluator#unwrapped_value converts to ms and
294
+ # get() matches the stored path (qfg-2agi.22).
295
+ when 'duration' then 'duration'
291
296
  else 'string'
292
297
  end
293
298
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Quonfig
4
- VERSION = '1.6.0'
4
+ VERSION = '1.7.1'
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: quonfig
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.6.0
4
+ version: 1.7.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jeff Dwyer
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-28 00:00:00.000000000 Z
11
+ date: 2026-10-03 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport