featurevisor 1.0.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5951351cf7893e83e9f3994999f48389e1998bb8dec84092f3e7627f986bfd02
4
- data.tar.gz: 3d24e02b8fd0708084b7716863396119728bcd83cbd0ac209b7a1c62fb982f68
3
+ metadata.gz: e7745fb77b8646de51fe54b58c32274829b60b046e6a47d81872e3148107eac6
4
+ data.tar.gz: ef5f69f50e1af01af4b45aece70603a67dc77f3af08da98b10cf6e50dd3d77df
5
5
  SHA512:
6
- metadata.gz: 368e45cb26cb6a98d13bc14b4fe9116bd36af47acadad3fbc99fc1926c8ce768edf5c9ade31aa36dd3c85ff95fc6dab2d3433d4d607bbeee99076a21838e0dae
7
- data.tar.gz: 160b9eb960915113e3f9dfa3507b9f06664224ac3517d7a84432ec64d2d26d26c2ba379a09aeba64efe78a310972b5f497ad5d5ef1bbef1cc41ffa1408ffa70f
6
+ metadata.gz: 745e81a4af47bc19e0a0bfe550badafb52e7541a067f461bdbc8b73be259399da5ca3fd519e7458ee0afa3ee6f810e0d7e28446add35ee13e970f25ecc7a0d2d
7
+ data.tar.gz: 44947e427ceb1cf6b87e2e0784e7c77dddcbd8e27677c6794dd885a3a83dec65a4a2a1cec1815198abbe3e47187399700e9ec7e4342fd729428fafb21ab2dadd
data/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  The MIT License (MIT)
2
2
 
3
- Copyright (c) 2025 Fahad Heylaal (https://fahad19.com)
3
+ Copyright (c) 2026 Fahad Heylaal (https://fahad19.com)
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
data/README.md CHANGED
@@ -43,6 +43,7 @@ This SDK is compatible with Featurevisor v3 projects and v2 datafiles.
43
43
  - [Registering modules](#registering-modules)
44
44
  - [Child instance](#child-instance)
45
45
  - [Close](#close)
46
+ - [OpenFeature](#openfeature)
46
47
  - [CLI usage](#cli-usage)
47
48
  - [Test](#test)
48
49
  - [Test against local monorepo's example-1](#test-against-local-monorepos-example-1)
@@ -88,6 +89,8 @@ f = Featurevisor.create_featurevisor(
88
89
 
89
90
  Most applications only need this factory and the returned `Featurevisor::Instance`. Public extension and observability APIs include modules, diagnostics, events, and the datafile structures accepted by the factory.
90
91
 
92
+ Concurrent evaluations are safe after an instance is configured. Do not mutate or close the same instance concurrently with evaluations. Serialize calls to `set_datafile`, `set_context`, `set_sticky`, `add_module`, `remove_module`, and `close`. Module, event, and diagnostic callbacks must synchronize mutable state that they capture.
93
+
91
94
  ## Initialization
92
95
 
93
96
  The SDK can be initialized by passing [datafile](https://featurevisor.com/docs/building-datafiles/) content directly:
@@ -696,6 +699,8 @@ f.remove_module('my-custom-module')
696
699
 
697
700
  ## Child instance
698
701
 
702
+ A child snapshots the parent keys that exist when it is spawned. Child values win for those keys. Parent keys introduced later are still inherited. Calling `close` removes both child-owned listeners and subscriptions delegated to the parent.
703
+
699
704
  When dealing with purely client-side applications, it is understandable that there is only one user involved, like in browser or mobile applications.
700
705
 
701
706
  But when using Featurevisor SDK in server-side applications, where a single server instance can handle multiple user requests simultaneously, it is important to isolate the context for each request.
@@ -721,8 +726,11 @@ Similar to parent SDK, child instances also support several additional methods:
721
726
 
722
727
  - `set_context`
723
728
  - `set_sticky`
729
+ - `evaluate_flag`
724
730
  - `is_enabled`
731
+ - `evaluate_variation`
725
732
  - `get_variation`
733
+ - `evaluate_variable`
726
734
  - `get_variable`
727
735
  - `get_variable_boolean`
728
736
  - `get_variable_string`
@@ -778,7 +786,7 @@ All three commands accept repeatable `--target=<target>` options. `test` builds
778
786
 
779
787
  ```bash
780
788
  $ cd /absolute/path/to/featurevisor-ruby
781
- $ bundle exec ruby bin/featurevisor test --projectDirectoryPath=/Users/fahad/Projects/featurevisor/featurevisor/examples/example-1 --onlyFailures
789
+ $ bundle exec ruby bin/featurevisor test --projectDirectoryPath=../featurevisor/examples/example-1 --onlyFailures
782
790
  $ make test-example-1
783
791
  ```
784
792
 
@@ -811,30 +819,100 @@ $ bundle exec featurevisor assess-distribution \
811
819
  --n=1000
812
820
  ```
813
821
 
822
+ ## OpenFeature
823
+
824
+ The OpenFeature provider is published as a separate gem. This keeps OpenFeature code and dependencies out of applications that only use the Featurevisor SDK.
825
+
826
+ The provider currently requires Ruby 3.4 or newer because that is the minimum version supported by the official OpenFeature Ruby SDK.
827
+
828
+ Install the provider:
829
+
830
+ ```ruby
831
+ gem "featurevisor-openfeature", "~> 2.0"
832
+ ```
833
+
834
+ It installs the matching `featurevisor` gem and the official `openfeature-sdk` dependency. The provider and base SDK deliberately share the same version, and the provider requires that exact Featurevisor version.
835
+
836
+ ```ruby
837
+ require "featurevisor/openfeature_provider"
838
+
839
+ provider = Featurevisor::OpenFeatureProvider.new(
840
+ datafile: datafile_content,
841
+ )
842
+
843
+ OpenFeature::SDK.configure do |config|
844
+ config.set_provider_and_wait(provider)
845
+ end
846
+
847
+ client = OpenFeature::SDK.build_client
848
+ enabled = client.fetch_boolean_value(
849
+ flag_key: "checkout",
850
+ default_value: false,
851
+ evaluation_context: OpenFeature::SDK::EvaluationContext.new(targeting_key: "user-123"),
852
+ )
853
+ ```
854
+
855
+ Use `checkout` for a flag, `checkout:variation` for its variation, and `checkout:title` for its `title` variable. Boolean variables use the boolean resolver. Arrays, hashes, and JSON variables use the object resolver.
856
+
857
+ OpenFeature's targeting key maps to `userId` by default. `targeting_key_field`, `key_separator`, and `variation_key` can customize the mapping.
858
+
859
+ You can pass any Featurevisor initialization options directly to the provider. These options are used to create the Featurevisor instance owned by the provider:
860
+
861
+ ```ruby
862
+ provider = Featurevisor::OpenFeatureProvider.new(
863
+ datafile: datafile_content,
864
+ targeting_key_field: "accountId",
865
+ key_separator: "/",
866
+ variation_key: "$variation",
867
+ )
868
+ ```
869
+
870
+ You can also reuse an existing Featurevisor instance:
871
+
872
+ ```ruby
873
+ featurevisor = Featurevisor.create_featurevisor(datafile: datafile_content)
874
+ provider = Featurevisor::OpenFeatureProvider.new(featurevisor: featurevisor)
875
+ ```
876
+
877
+ The caller owns an instance passed this way. Provider shutdown does not close it. Call `featurevisor.close` when every consumer is finished with it. When the provider creates the instance from options, the provider owns and closes it. If both are supplied, `featurevisor` takes precedence over the options hash.
878
+
879
+ See the [OpenFeature provider guide](https://featurevisor.com/docs/sdks/openfeature/) for resolution reasons, errors, metadata, tracking, lifecycle, and providers for other languages.
880
+
814
881
  <!-- FEATUREVISOR_DOCS_END -->
815
882
 
816
883
  ## Development
817
884
 
818
885
  ### Setting up
819
886
 
820
- After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
887
+ After checking out the repository, run `make install` to install dependencies.
821
888
 
822
889
  ### Running tests
823
890
 
824
891
  ```bash
825
892
  $ bundle exec rspec
893
+ $ make test-base
894
+ $ make test-example-1
826
895
  ```
827
896
 
828
- To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
897
+ Build and verify both gems with:
898
+
899
+ ```bash
900
+ $ make build
901
+ ```
902
+
903
+ The build produces `featurevisor-VERSION.gem` and `featurevisor-openfeature-VERSION.gem`. The artifact verifier confirms that OpenFeature code and dependencies are absent from the base gem and present only in the provider gem.
829
904
 
830
905
  ### Releasing
831
906
 
832
- - Update version in `lib/featurevisor/version.rb`
907
+ - Update the shared version in `lib/featurevisor/version.rb`
833
908
  - Run `bundle install`
834
909
  - Push commit to `main` branch
835
910
  - Wait for CI to complete
836
- - Tag the release with the version number
837
- - This will trigger a new release to [RubyGems](https://rubygems.org/gems/featurevisor)
911
+ - Tag the release with the same version number, for example `v2.0.0`
912
+ - The workflow verifies that the tag matches the shared version
913
+ - The workflow publishes `featurevisor` first, followed by `featurevisor-openfeature`
914
+
915
+ The gems are separate RubyGems packages built from the same repository. Publishing the base gem first ensures the provider's exact Featurevisor dependency is available when the provider is published. If the second push fails, rerun or retry the provider publication without republishing the existing base version.
838
916
 
839
917
  ## License
840
918
 
data/bin/commands/test.rb CHANGED
@@ -671,7 +671,7 @@ module FeaturevisorCLI
671
671
 
672
672
  if assertion.key?(:expectedToMatch)
673
673
  expected_to_match = assertion[:expectedToMatch]
674
- actual = instance.instance_variable_get(:@datafile_reader).all_conditions_are_matched(conditions, context)
674
+ actual = instance.instance_variable_get(:@datafile).all_conditions_are_matched(conditions, context)
675
675
 
676
676
  if actual != expected_to_match
677
677
  has_error = true
@@ -30,16 +30,16 @@ module Featurevisor
30
30
  # - feature_key [String] The feature key
31
31
  # - bucket_by [String, Array<String>, Hash] Bucketing strategy
32
32
  # - context [Hash] User context
33
- # - logger [Logger] Logger instance
33
+ # - diagnostics [DiagnosticReporter] Diagnostic reporter
34
34
  # @return [String] The bucket key
35
35
  # @raise [StandardError] If bucket_by is invalid
36
36
  def self.get_bucket_key(options)
37
37
  feature_key = options[:feature_key]
38
38
  bucket_by = options[:bucket_by]
39
39
  context = options[:context]
40
- logger = options[:logger]
40
+ diagnostics = options[:diagnostics]
41
41
 
42
- type, attribute_keys = parse_bucket_by(bucket_by, logger, feature_key)
42
+ type, attribute_keys = parse_bucket_by(bucket_by, diagnostics, feature_key)
43
43
 
44
44
  bucket_key = build_bucket_key(attribute_keys, context, type, feature_key)
45
45
 
@@ -50,10 +50,10 @@ module Featurevisor
50
50
 
51
51
  # Parse bucket_by configuration to determine type and attribute keys
52
52
  # @param bucket_by [String, Array<String>, Hash] Bucketing strategy
53
- # @param logger [Logger] Logger instance
53
+ # @param diagnostics [DiagnosticReporter] Diagnostic reporter
54
54
  # @param feature_key [String] Feature key for error logging
55
55
  # @return [Array] Tuple of [type, attribute_keys]
56
- def self.parse_bucket_by(bucket_by, logger, feature_key)
56
+ def self.parse_bucket_by(bucket_by, diagnostics, feature_key)
57
57
  if bucket_by.is_a?(String)
58
58
  ["plain", [bucket_by]]
59
59
  elsif bucket_by.is_a?(Array)
@@ -61,7 +61,7 @@ module Featurevisor
61
61
  elsif bucket_by.is_a?(Hash) && bucket_by[:or].is_a?(Array)
62
62
  ["or", bucket_by[:or]]
63
63
  else
64
- logger.error("invalid bucketBy", { feature_key: feature_key, bucket_by: bucket_by })
64
+ diagnostics.error("invalid bucketBy", { feature_key: feature_key, bucket_by: bucket_by })
65
65
  raise StandardError, "invalid bucketBy"
66
66
  end
67
67
  end
@@ -78,7 +78,7 @@ module Featurevisor
78
78
  attribute_keys.each do |attribute_key|
79
79
  attribute_value = Featurevisor::Conditions.get_value_from_context(context, attribute_key)
80
80
 
81
- next if attribute_value.nil?
81
+ next if attribute_value.nil? && !context_path_exists?(context, attribute_key)
82
82
 
83
83
  if type == "plain" || type == "and"
84
84
  bucket_key << attribute_value
@@ -89,7 +89,61 @@ module Featurevisor
89
89
  end
90
90
 
91
91
  bucket_key << feature_key
92
- bucket_key
92
+ bucket_key.map { |value| javascript_string(value) }
93
93
  end
94
+
95
+ def self.context_path_exists?(context, path)
96
+ path.split(".").reduce(context) do |current, key|
97
+ return false unless current.is_a?(Hash)
98
+ return false unless current.key?(key.to_sym) || current.key?(key)
99
+
100
+ current.key?(key.to_sym) ? current[key.to_sym] : current[key]
101
+ end
102
+ true
103
+ end
104
+
105
+ def self.javascript_string(value)
106
+ return "" if value.nil?
107
+ return value ? "true" : "false" if value == true || value == false
108
+ if value.is_a?(Float)
109
+ return "NaN" if value.nan?
110
+ return value.positive? ? "Infinity" : "-Infinity" if value.infinite?
111
+ return "0" if value.zero?
112
+ return value.to_i.to_s if value == value.to_i && value.abs < 1e21
113
+ if value.abs >= 1e-6 && value.abs < 1e21
114
+ return scientific_to_fixed(value.to_s)
115
+ end
116
+
117
+ coefficient, exponent = value.to_s.downcase.split("e")
118
+ return "#{coefficient.delete_suffix('.0')}e#{Integer(exponent) >= 0 ? '+' : ''}#{Integer(exponent)}"
119
+ end
120
+ return value.map { |item| javascript_string(item) }.join(",") if value.is_a?(Array)
121
+ return "[object Object]" if value.is_a?(Hash)
122
+
123
+ value.to_s
124
+ end
125
+
126
+ def self.scientific_to_fixed(value)
127
+ return value unless value.downcase.include?("e")
128
+
129
+ coefficient, exponent_text = value.downcase.split("e")
130
+ exponent = Integer(exponent_text)
131
+ negative = coefficient.start_with?("-")
132
+ digits = coefficient.delete_prefix("-").delete(".")
133
+ decimal_position = coefficient.delete_prefix("-").index(".") || coefficient.delete_prefix("-").length
134
+ decimal_position += exponent
135
+
136
+ result = if decimal_position <= 0
137
+ "0.#{"0" * -decimal_position}#{digits}"
138
+ elsif decimal_position >= digits.length
139
+ "#{digits}#{"0" * (decimal_position - digits.length)}"
140
+ else
141
+ "#{digits[0...decimal_position]}.#{digits[decimal_position..]}"
142
+ end
143
+
144
+ result = result.sub(/(\.\d*?)0+\z/, '\1').delete_suffix(".")
145
+ negative ? "-#{result}" : result
146
+ end
147
+ private_class_method :scientific_to_fixed
94
148
  end
95
149
  end
@@ -13,6 +13,7 @@ module Featurevisor
13
13
  @context = options[:context] || {}
14
14
  @sticky = options[:sticky] || {}
15
15
  @emitter = Featurevisor::Emitter.new
16
+ @parent_unsubscribers = []
16
17
  end
17
18
 
18
19
  # Subscribe to an event
@@ -25,12 +26,25 @@ module Featurevisor
25
26
  if event_name == "context_set" || event_name == "sticky_set"
26
27
  @emitter.on(event_name, callback)
27
28
  else
28
- @parent.on(event_name, callback)
29
+ parent_unsubscribe = @parent.on(event_name, callback)
30
+ active = true
31
+ unsubscribe = nil
32
+ unsubscribe = proc do
33
+ next unless active
34
+
35
+ active = false
36
+ parent_unsubscribe.call
37
+ @parent_unsubscribers.delete(unsubscribe)
38
+ end
39
+ @parent_unsubscribers << unsubscribe
40
+ unsubscribe
29
41
  end
30
42
  end
31
43
 
32
44
  # Close the child instance
33
45
  def close
46
+ @parent_unsubscribers.dup.each(&:call)
47
+ @parent_unsubscribers.clear
34
48
  @emitter.clear_all
35
49
  end
36
50
 
@@ -98,6 +112,15 @@ module Featurevisor
98
112
  )
99
113
  end
100
114
 
115
+ # Evaluate a feature flag and return its full evaluation details.
116
+ def evaluate_flag(feature_key, context = {}, options = {})
117
+ @parent.evaluate_flag(
118
+ feature_key,
119
+ { **@context, **context },
120
+ { **options, __featurevisor_child_sticky: @sticky }
121
+ )
122
+ end
123
+
101
124
  # Get variation value
102
125
  # @param feature_key [String] Feature key
103
126
  # @param context [Hash] Context
@@ -117,6 +140,15 @@ module Featurevisor
117
140
  )
118
141
  end
119
142
 
143
+ # Evaluate a variation and return its full evaluation details.
144
+ def evaluate_variation(feature_key, context = {}, options = {})
145
+ @parent.evaluate_variation(
146
+ feature_key,
147
+ { **@context, **context },
148
+ { **options, __featurevisor_child_sticky: @sticky }
149
+ )
150
+ end
151
+
120
152
  # Get variable value
121
153
  # @param feature_key [String] Feature key
122
154
  # @param variable_key [String] Variable key
@@ -138,6 +170,16 @@ module Featurevisor
138
170
  )
139
171
  end
140
172
 
173
+ # Evaluate a variable and return its full evaluation details.
174
+ def evaluate_variable(feature_key, variable_key, context = {}, options = {})
175
+ @parent.evaluate_variable(
176
+ feature_key,
177
+ variable_key,
178
+ { **@context, **context },
179
+ { **options, __featurevisor_child_sticky: @sticky }
180
+ )
181
+ end
182
+
141
183
  # Get variable as boolean
142
184
  # @param feature_key [String] Feature key
143
185
  # @param variable_key [String] Variable key
@@ -1,10 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "date"
4
+ require "time"
4
5
 
5
6
  module Featurevisor
6
7
  # Conditions module for evaluating feature flags and segments
7
8
  module Conditions
9
+ MISSING = Object.new.freeze
10
+ private_constant :MISSING
11
+
8
12
  # Get value from context object using dot notation path
9
13
  # @param obj [Hash] Context object
10
14
  # @param path [String] Dot-separated path to the value
@@ -12,12 +16,36 @@ module Featurevisor
12
16
  def self.get_value_from_context(obj, path)
13
17
  return nil if obj.nil? || path.nil?
14
18
 
15
- if path.index(".") == -1
16
- return obj[path.to_sym] || obj[path]
19
+ value = get_value_with_presence(obj, path)
20
+ value.equal?(MISSING) ? nil : value
21
+ end
22
+
23
+ def self.get_value_with_presence(obj, path)
24
+ return MISSING unless obj.is_a?(Hash) && path.is_a?(String)
25
+
26
+ path.split(".").reduce(obj) do |current, key|
27
+ break MISSING unless current.is_a?(Hash)
28
+
29
+ if current.key?(key.to_sym)
30
+ current[key.to_sym]
31
+ elsif current.key?(key)
32
+ current[key]
33
+ else
34
+ break MISSING
35
+ end
17
36
  end
37
+ end
38
+ private_class_method :get_value_with_presence
39
+
40
+ def self.strict_equal?(left, right)
41
+ return true if left.nil? && right.nil?
42
+ return left.to_f == right.to_f if left.is_a?(Numeric) && right.is_a?(Numeric)
43
+ return left == right if left.is_a?(String) && right.is_a?(String)
44
+ return left == right if (left == true || left == false) && (right == true || right == false)
18
45
 
19
- path.split(".").reduce(obj) { |o, i| o&.[](i.to_sym) || o&.[](i) }
46
+ false
20
47
  end
48
+ private_class_method :strict_equal?
21
49
 
22
50
  # Check if a condition is matched against context
23
51
  # @param condition [Hash] Condition to evaluate
@@ -30,18 +58,19 @@ module Featurevisor
30
58
  value = condition["value"] || condition[:value]
31
59
  regex_flags = condition["regexFlags"] || condition[:regexFlags]
32
60
 
33
- context_value_from_path = get_value_from_context(context, attribute)
61
+ raw_context_value = get_value_with_presence(context, attribute)
62
+ context_value_from_path = raw_context_value.equal?(MISSING) ? nil : raw_context_value
63
+ attribute_exists = !raw_context_value.equal?(MISSING)
34
64
 
35
65
  case operator
36
66
  when "equals"
37
- context_value_from_path == value
67
+ attribute_exists && strict_equal?(context_value_from_path, value)
38
68
  when "notEquals"
39
- context_value_from_path != value
69
+ !attribute_exists || !strict_equal?(context_value_from_path, value)
40
70
  when "before", "after"
41
- # date comparisons
42
- value_in_context = context_value_from_path
43
- date_in_context = value_in_context.is_a?(Date) ? value_in_context : Date.parse(value_in_context.to_s)
44
- date_in_condition = value.is_a?(Date) ? value : Date.parse(value.to_s)
71
+ date_in_context = portable_date(context_value_from_path)
72
+ date_in_condition = portable_date(value)
73
+ return false unless date_in_context && date_in_condition
45
74
 
46
75
  if operator == "before"
47
76
  date_in_context < date_in_condition
@@ -50,21 +79,13 @@ module Featurevisor
50
79
  end
51
80
  when "in", "notIn"
52
81
  # in / notIn (where condition value is an array)
53
- if value.is_a?(Array) && (context_value_from_path.is_a?(String) || context_value_from_path.is_a?(Numeric) || context_value_from_path.nil?)
54
- # Check if the attribute key actually exists in the context
55
- key_exists = context.key?(attribute.to_sym) || context.key?(attribute.to_s)
56
-
57
- # If key doesn't exist, notIn should fail (return false), in should also fail
58
- if !key_exists
59
- return false
60
- end
61
-
62
- value_in_context = context_value_from_path.to_s
63
-
82
+ if attribute_exists && value.is_a?(Array) &&
83
+ (context_value_from_path.is_a?(String) || context_value_from_path.is_a?(Numeric) || context_value_from_path.nil?)
84
+ matched = value.any? { |candidate| strict_equal?(candidate, context_value_from_path) }
64
85
  if operator == "in"
65
- value.include?(value_in_context)
86
+ matched
66
87
  else # notIn
67
- !value.include?(value_in_context)
88
+ !matched
68
89
  end
69
90
  else
70
91
  false
@@ -124,18 +145,18 @@ module Featurevisor
124
145
  false
125
146
  end
126
147
  when "exists"
127
- context_value_from_path != nil
148
+ attribute_exists
128
149
  when "notExists"
129
- context_value_from_path.nil?
150
+ !attribute_exists
130
151
  when "includes", "notIncludes"
131
152
  # includes / notIncludes (where context value is an array)
132
- if context_value_from_path.is_a?(Array) && value.is_a?(String)
133
- value_in_context = context_value_from_path
134
-
153
+ if context_value_from_path.is_a?(Array) &&
154
+ (value.is_a?(String) || value.is_a?(Numeric) || value == true || value == false || value.nil?)
155
+ matched = context_value_from_path.any? { |candidate| strict_equal?(candidate, value) }
135
156
  if operator == "includes"
136
- value_in_context.include?(value)
157
+ matched
137
158
  else # notIncludes
138
- !value_in_context.include?(value)
159
+ !matched
139
160
  end
140
161
  else
141
162
  false
@@ -143,10 +164,18 @@ module Featurevisor
143
164
  else
144
165
  false
145
166
  end
146
- rescue => e
147
- # Log error but don't stop execution
148
- warn "Error in condition evaluation: #{e.message}"
149
- false
150
167
  end
168
+
169
+ def self.portable_date(value)
170
+ return value if value.is_a?(Time) || value.is_a?(DateTime)
171
+ return value.to_time if value.is_a?(Date)
172
+ return nil unless value.is_a?(String)
173
+ return nil unless value.match?(/T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+\-]\d{2}:\d{2})\z/)
174
+
175
+ Time.iso8601(value)
176
+ rescue ArgumentError
177
+ nil
178
+ end
179
+ private_class_method :portable_date
151
180
  end
152
181
  end
@@ -1,21 +1,22 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Featurevisor
4
- # Log levels for the logger
4
+ # Diagnostic severity levels
5
5
  LOG_LEVELS = %w[fatal error warn info debug].freeze
6
6
  DEFAULT_LOG_LEVEL = "info".freeze
7
- LOGGER_PREFIX = "[Featurevisor]".freeze
7
+ DIAGNOSTIC_PREFIX = "[Featurevisor]".freeze
8
8
 
9
- # Logger class for handling different log levels
10
- class Logger
9
+ # Private evaluator adapter for structured diagnostics.
10
+ class DiagnosticReporter
11
11
  attr_reader :level, :handler
12
12
 
13
- # Initialize a new logger
14
- # @param options [Hash] Logger options
13
+ # Initialize a diagnostic reporter.
14
+ # @param options [Hash] Reporter options
15
15
  # @option options [String] :level Log level (default: "info")
16
- # @option options [Proc] :handler Custom log handler (default: default_log_handler)
16
+ # @option options [Proc] :handler Internal structured diagnostic sink
17
17
  def initialize(options = {})
18
18
  @level = options[:level] || DEFAULT_LOG_LEVEL
19
+ @filter = !options.key?(:handler)
19
20
  @handler = options[:handler] || method(:default_log_handler)
20
21
  end
21
22
 
@@ -25,12 +26,13 @@ module Featurevisor
25
26
  @level = level
26
27
  end
27
28
 
28
- # Log a message at a specific level
29
- # @param level [String] Log level
30
- # @param message [String] Log message
29
+ # Forward an evaluator diagnostic. Filtering is performed centrally by
30
+ # Instance so module subscriptions and error events remain independent.
31
+ # @param level [String] Diagnostic level
32
+ # @param message [String] Diagnostic message
31
33
  # @param details [Hash, nil] Additional details
32
34
  def log(level, message, details = nil)
33
- return unless should_handle?(level)
35
+ return if @filter && !should_handle?(level)
34
36
 
35
37
  @handler.call(level, message, details)
36
38
  end
@@ -72,16 +74,8 @@ module Featurevisor
72
74
 
73
75
  private
74
76
 
75
- # Check if the current level should handle the given log level
76
- # @param log_level [String] Log level to check
77
- # @return [Boolean] True if should handle
78
- def should_handle?(log_level)
79
- current_index = LOG_LEVELS.index(@level)
80
- target_index = LOG_LEVELS.index(log_level)
81
-
82
- return false if current_index.nil? || target_index.nil?
83
-
84
- current_index >= target_index
77
+ def should_handle?(level)
78
+ LOG_LEVELS.index(level).to_i <= LOG_LEVELS.index(@level).to_i
85
79
  end
86
80
 
87
81
  # Default log handler that outputs to console
@@ -99,45 +93,18 @@ module Featurevisor
99
93
  case method_name
100
94
  when "puts"
101
95
  if details && !details.empty?
102
- Kernel.puts("#{LOGGER_PREFIX} #{message} #{details.inspect}")
96
+ Kernel.puts("#{DIAGNOSTIC_PREFIX} #{message} #{details.inspect}")
103
97
  else
104
- Kernel.puts("#{LOGGER_PREFIX} #{message}")
98
+ Kernel.puts("#{DIAGNOSTIC_PREFIX} #{message}")
105
99
  end
106
100
  when "warn"
107
101
  if details && !details.empty?
108
- Kernel.warn("#{LOGGER_PREFIX} #{message} #{details.inspect}")
102
+ Kernel.warn("#{DIAGNOSTIC_PREFIX} #{message} #{details.inspect}")
109
103
  else
110
- Kernel.warn("#{LOGGER_PREFIX} #{message}")
104
+ Kernel.warn("#{DIAGNOSTIC_PREFIX} #{message}")
111
105
  end
112
106
  end
113
107
  end
114
108
  end
115
109
 
116
- # Default log handler function
117
- # @param level [String] Log level
118
- # @param message [String] Log message
119
- # @param details [Hash, nil] Additional details
120
- def self.default_log_handler(level, message, details = nil)
121
- method_name = case level
122
- when "info" then "puts"
123
- when "warn" then "warn"
124
- when "error", "fatal" then "warn"
125
- else "puts"
126
- end
127
-
128
- case method_name
129
- when "puts"
130
- if details && !details.empty?
131
- Kernel.puts("#{LOGGER_PREFIX} #{message} #{details.inspect}")
132
- else
133
- Kernel.puts("#{LOGGER_PREFIX} #{message}")
134
- end
135
- when "warn"
136
- if details && !details.empty?
137
- Kernel.warn("#{LOGGER_PREFIX} #{message} #{details.inspect}")
138
- else
139
- Kernel.warn("#{LOGGER_PREFIX} #{message}")
140
- end
141
- end
142
- end
143
110
  end