featurevisor 3.0.0 → 4.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.
@@ -35,6 +35,8 @@ module Featurevisor
35
35
  @sticky_features = options[:sticky_features] || options[:stickyFeatures] || {}
36
36
  @sticky_variables = options[:sticky_variables] || options[:stickyVariables] || {}
37
37
  @closed = false
38
+ @evaluation_generation = 0
39
+ @datafile_mutex = Mutex.new
38
40
  @module_diagnostic_subscriptions = []
39
41
 
40
42
  # datafile
@@ -47,6 +49,7 @@ module Featurevisor
47
49
  modules: options[:modules] || [],
48
50
  report_diagnostic: method(:report_diagnostic),
49
51
  module_api_factory: method(:create_module_api),
52
+ on_change: method(:invalidate_evaluation_memo),
50
53
  clear_module_diagnostic_subscriptions: method(:clear_module_diagnostic_subscriptions)
51
54
  )
52
55
 
@@ -68,6 +71,8 @@ module Featurevisor
68
71
  end
69
72
 
70
73
  def handle_evaluation_diagnostic(level, message, details = nil)
74
+ return unless diagnostic_interested?(level)
75
+
71
76
  details = (details || {}).dup
72
77
  code = details.delete(:code) || details.delete("code") || details[:reason] || details["reason"] || message
73
78
  code = "deprecated_feature" if message == "feature is deprecated"
@@ -97,8 +102,9 @@ module Featurevisor
97
102
  # Set the datafile
98
103
  # @param datafile [Hash, String] Datafile content or JSON string
99
104
  # @param replace [Boolean] Whether to replace instead of merge
105
+ # @return [Boolean] Whether the datafile was accepted, even if unchanged
100
106
  def set_datafile(datafile, replace = false)
101
- return if @closed
107
+ return false if @closed
102
108
 
103
109
  begin
104
110
  parsed_datafile = if datafile.is_a?(String)
@@ -114,14 +120,26 @@ module Featurevisor
114
120
  (!parsed_datafile.key?(:variables) || parsed_datafile[:variables].is_a?(Hash))
115
121
  raise ArgumentError, "Invalid datafile"
116
122
  end
117
- next_datafile = replace ? parsed_datafile : merge_datafiles(@datafile.get_datafile, parsed_datafile)
118
- new_datafile = InstanceEvaluationDataProvider.new(
119
- datafile: next_datafile,
120
- diagnostics: @diagnostics
121
- )
123
+ normalization_errors = []
124
+ if parsed_datafile[:schemaVersion] == "2"
125
+ parsed_datafile, normalization_errors = Normalize.datafile(parsed_datafile)
126
+ end
127
+ # Serialize merge and publication, leaving application callbacks outside.
128
+ details = @datafile_mutex.synchronize do
129
+ return false if @closed
130
+ next_datafile = replace ? parsed_datafile : merge_datafiles(@datafile.get_datafile, parsed_datafile)
131
+ new_datafile = InstanceEvaluationDataProvider.new(
132
+ datafile: next_datafile,
133
+ diagnostics: @diagnostics
134
+ )
135
+ new_datafile.retain_normalized(@datafile, parsed_datafile) unless replace
136
+ event = Featurevisor::Events.get_params_for_datafile_set_event(@datafile, new_datafile, replace, parsed_datafile)
137
+ @datafile = new_datafile
138
+ @evaluation_generation += 1
139
+ event
140
+ end
122
141
 
123
- details = Featurevisor::Events.get_params_for_datafile_set_event(@datafile, new_datafile, replace)
124
- @datafile = new_datafile
142
+ normalization_errors.each { |diagnostic| report_diagnostic(diagnostic) }
125
143
 
126
144
  report_diagnostic(
127
145
  level: "info",
@@ -130,6 +148,7 @@ module Featurevisor
130
148
  details: details
131
149
  )
132
150
  @emitter.trigger("datafile_set", details)
151
+ true
133
152
  rescue => e
134
153
  report_diagnostic(
135
154
  level: "error",
@@ -137,6 +156,7 @@ module Featurevisor
137
156
  message: "Could not parse datafile",
138
157
  original_error: e
139
158
  )
159
+ false
140
160
  end
141
161
  end
142
162
 
@@ -156,6 +176,7 @@ module Featurevisor
156
176
  end
157
177
 
158
178
  params = Featurevisor::Events.get_params_for_sticky_features_set_event(previous_sticky_features, @sticky_features, replace)
179
+ @evaluation_generation += 1
159
180
 
160
181
  report_diagnostic(
161
182
  level: "info",
@@ -169,6 +190,7 @@ module Featurevisor
169
190
  def set_sticky_variables(sticky, replace = false)
170
191
  previous = @sticky_variables || {}
171
192
  @sticky_variables = replace ? sticky : { **@sticky_variables, **sticky }
193
+ @evaluation_generation += 1
172
194
  params = Featurevisor::Events.get_params_for_sticky_variables_set_event(previous, @sticky_variables, replace)
173
195
  report_diagnostic(level: "info", code: "sticky_variables_set", message: "Sticky variables set", details: params)
174
196
  @emitter.trigger("sticky_variables_set", params)
@@ -214,6 +236,11 @@ module Featurevisor
214
236
  @modules_manager.add(mod)
215
237
  end
216
238
 
239
+ def invalidate_evaluation_memo
240
+ @evaluation_generation += 1
241
+ end
242
+ private :invalidate_evaluation_memo
243
+
217
244
  def remove_module(name)
218
245
  @modules_manager.remove(name)
219
246
  end
@@ -230,12 +257,15 @@ module Featurevisor
230
257
 
231
258
  # Close the instance
232
259
  def close
233
- return if @closed
234
-
235
- @closed = true
260
+ @datafile_mutex.synchronize do
261
+ return if @closed
262
+ @closed = true
263
+ end
236
264
  @modules_manager.close_all
237
265
  @module_diagnostic_subscriptions = []
238
266
  @emitter.clear_all
267
+ @on_diagnostic = nil
268
+ @datafile.clear_caches
239
269
  end
240
270
 
241
271
  # Set context
@@ -247,6 +277,7 @@ module Featurevisor
247
277
  else
248
278
  @context = { **@context, **context }
249
279
  end
280
+ @evaluation_generation += 1
250
281
 
251
282
  @emitter.trigger("context_set", {
252
283
  context: @context,
@@ -315,6 +346,7 @@ module Featurevisor
315
346
  evaluation = evaluate_flag(feature_key, context, options)
316
347
  evaluation[:enabled] == true
317
348
  rescue => e
349
+ raise if e.is_a?(RequirementError) && options[:__featurevisor_prerequisite]
318
350
  report_diagnostic(level: "error", code: "evaluation_error", message: "isEnabled failed", originalError: e, details: { featureKey: feature_key })
319
351
  false
320
352
  end
@@ -351,6 +383,7 @@ module Featurevisor
351
383
  nil
352
384
  end
353
385
  rescue => e
386
+ raise if e.is_a?(RequirementError) && options[:__featurevisor_prerequisite]
354
387
  report_diagnostic(level: "error", code: "evaluation_error", message: "getVariation failed", originalError: e, details: { featureKey: feature_key })
355
388
  nil
356
389
  end
@@ -389,7 +422,7 @@ module Featurevisor
389
422
 
390
423
  if evaluation.key?(:variable_value)
391
424
  variable_type = evaluation.dig(:variable_schema, :type) || evaluation.dig(:variable, :type)
392
- if variable_type == "json" &&
425
+ if variable_type == "json" && !evaluation[:__featurevisor_decoded_json] && !evaluation.dig(:variable, :compose) &&
393
426
  evaluation[:variable_value].is_a?(String)
394
427
  JSON.parse(evaluation[:variable_value], symbolize_names: true)
395
428
  else
@@ -399,6 +432,8 @@ module Featurevisor
399
432
  nil
400
433
  end
401
434
  rescue => e
435
+ getter_options = variable_key_or_context.is_a?(String) ? options : context_or_options
436
+ raise if e.is_a?(RequirementError) && getter_options[:__featurevisor_prerequisite]
402
437
  report_diagnostic(level: "error", code: "evaluation_error", message: "getVariable failed", originalError: e, details: { variableKey: variable_key_or_context || feature_or_variable_key })
403
438
  nil
404
439
  end
@@ -437,15 +472,15 @@ module Featurevisor
437
472
  get_value_by_type(variable_value, "integer")
438
473
  end
439
474
 
440
- # Get variable as double
475
+ # Get variable as a number, whole or fractional
441
476
  # @param feature_key [String] Feature key
442
477
  # @param variable_key [String] Variable key
443
478
  # @param context [Hash] Context
444
479
  # @param options [Hash] Override options
445
480
  # @return [Float, nil] Float value or nil
446
- def get_variable_double(*args)
481
+ def get_variable_number(*args)
447
482
  variable_value = get_variable(*args)
448
- get_value_by_type(variable_value, "double")
483
+ get_value_by_type(variable_value, "number")
449
484
  end
450
485
 
451
486
  # Get variable as array
@@ -538,14 +573,24 @@ module Featurevisor
538
573
  evaluation_options = {
539
574
  type: "variable",
540
575
  variable_key: variable_key.to_s,
541
- context: get_context(context)
576
+ context: options[:__featurevisor_effective_context] ? context : get_context(context)
542
577
  }
578
+ session = options[:__featurevisor_session] || EvaluationSession.new
579
+ evaluation_options[:dependency_of] = session.dependency_of if options[:__featurevisor_prerequisite]
580
+ evaluation_options[:dependency_purpose] = options[:dependency_purpose] || "condition" if options[:__featurevisor_prerequisite]
543
581
  evaluation_options[:default_variable_value] = options[:default_variable_value] if options.key?(:default_variable_value)
544
582
  begin
545
- evaluation_options = @modules_manager.run_before_evaluation_modules(evaluation_options)
583
+ # Only prerequisite entries fingerprint effective dependency inputs.
584
+ session_options = options[:__featurevisor_prerequisite] ? global_variable_session_options(evaluation_options, options) : evaluation_options
585
+ return session.run(session_options,
586
+ prerequisite: options[:__featurevisor_prerequisite]) do
587
+ unless @modules_manager.get_all.empty?
588
+ evaluation_options = @modules_manager.run_before_evaluation_modules(evaluation_options)
589
+ end
590
+ session.update_current(evaluation_options)
546
591
  resolved_key = evaluation_options[:variable_key]
547
592
  variable = @datafile.get_global_variable(resolved_key)
548
- sticky = options[:__featurevisor_child_sticky_variables] || @sticky_variables
593
+ sticky = options[:__featurevisor_sticky_variables_source]&.call || options[:__featurevisor_child_sticky_variables] || @sticky_variables
549
594
  evaluation = { type: "variable", variable_key: resolved_key, reason: Featurevisor::EvaluationReason::VARIABLE_NOT_FOUND }
550
595
 
551
596
  if sticky.key?(resolved_key) || sticky.key?(resolved_key.to_sym)
@@ -553,28 +598,49 @@ module Featurevisor
553
598
  evaluation.merge!(reason: Featurevisor::EvaluationReason::STICKY, variable: variable,
554
599
  variable_value: sticky_value)
555
600
  elsif variable
556
- unless required_features_are_matched(variable[:requiredFeatures], evaluation_options[:context], options)
557
- value_key = variable[:useDefaultWhenDisabled] ? :defaultValue : :disabledValue
558
- evaluation.merge!(reason: Featurevisor::EvaluationReason::REQUIRED_FEATURES_UNMET,
559
- variable: variable)
601
+ dependencies = get_evaluation_dependencies(evaluation_options[:context], options.merge(__featurevisor_effective_context: true, __featurevisor_session: session))
602
+ if variable.key?(:conditions) && !UnifiedConditions.matches?(variable[:conditions], dependencies)
603
+ value_key = !variable[:compose] && variable[:useDefaultWhenDisabled] ? :defaultValue : :disabledValue
604
+ evaluation.merge!(reason: Featurevisor::EvaluationReason::CONDITIONS_UNMET,
605
+ conditions: variable[:conditions], variable: variable)
560
606
  evaluation[:variable_value] = variable[value_key] if variable.key?(value_key)
607
+ evaluation[:variable_value] = nil if variable[:compose] && !variable.key?(value_key)
608
+ if variable[:compose].is_a?(Array) && evaluation[:variable_value].is_a?(Hash)
609
+ evaluation[:variable_value] = evaluation[:variable_value].dup
610
+ end
561
611
  else
562
- (variable[:overrides] || []).each_with_index do |override, index|
563
- next unless required_features_are_matched(override[:requiredFeatures], evaluation_options[:context], options)
564
- conditions_match = !override[:conditions] || @datafile.all_conditions_are_matched(
565
- @datafile.parse_conditions_if_stringified(override[:conditions]), evaluation_options[:context]
566
- )
567
- segments_match = !override[:segments] || @datafile.all_segments_are_matched(
568
- @datafile.parse_segments_if_stringified(override[:segments]), evaluation_options[:context]
569
- )
570
- next unless conditions_match && segments_match
571
-
572
- evaluation.merge!(reason: Featurevisor::EvaluationReason::VARIABLE_OVERRIDE_RULE,
573
- variable: variable,
574
- variable_override_index: index, variable_override_key: override[:key],
575
- variable_override_path: override[:keyPath])
576
- evaluation[:variable_value] = override[:value] if override.key?(:value)
577
- break
612
+ if variable[:compose]
613
+ evaluation.merge!(reason: Featurevisor::EvaluationReason::VARIABLE_COMPOSED,
614
+ variable: variable, variable_value: compose_value(variable[:compose], dependencies))
615
+ end
616
+ program = variable[:overrideProgram]
617
+ overrides = program || (variable[:compose] ? [] : variable[:overrides]) || []
618
+ accumulated_value = variable[:compose] ? evaluation[:variable_value] : variable[:defaultValue]
619
+ applied_paths = []
620
+ index = 0
621
+ while index < overrides.length
622
+ override = overrides[index]
623
+ matched = !override.key?(:conditions) || UnifiedConditions.matches?(override[:conditions], dependencies)
624
+ if matched
625
+ if program
626
+ accumulated_value = Mutations.apply(accumulated_value, override[:operations])
627
+ if variable[:compose].is_a?(Array) && override[:operations].any? { |operation| operation[:op] == "set" && operation[:path].empty? }
628
+ accumulated_value = accumulated_value.dup
629
+ end
630
+ applied_paths << (override[:keyPath] || [override[:key]])
631
+ end
632
+ evaluation.merge!(reason: Featurevisor::EvaluationReason::VARIABLE_OVERRIDE_RULE,
633
+ variable: variable, variable_override_index: index,
634
+ variable_override_key: override[:key], variable_override_path: override[:keyPath])
635
+ evaluation[:variable_value] = program ? accumulated_value : override[:value]
636
+ evaluation[:variable_override_paths] = applied_paths.dup if program
637
+ break unless program
638
+ end
639
+ next_index = program ? override[matched ? :onMatch : :onMiss] : index + 1
640
+ if program && (!next_index.is_a?(Integer) || next_index <= index || next_index > overrides.length)
641
+ raise ArgumentError, "Invalid variable override program jump"
642
+ end
643
+ index = next_index
578
644
  end
579
645
  if evaluation[:reason] == Featurevisor::EvaluationReason::VARIABLE_NOT_FOUND
580
646
  evaluation.merge!(reason: Featurevisor::EvaluationReason::VARIABLE_DEFAULT,
@@ -582,58 +648,167 @@ module Featurevisor
582
648
  evaluation[:variable_value] = variable[:defaultValue] if variable.key?(:defaultValue)
583
649
  end
584
650
  end
585
- report_diagnostic(level: "warn", code: "variable_deprecated", message: "Variable \"#{resolved_key}\" is deprecated",
651
+ report_diagnostic(level: "warn", code: "deprecated_variable", message: "Variable \"#{resolved_key}\" is deprecated",
586
652
  details: { variableKey: resolved_key, evaluation: evaluation }) if variable[:deprecated]
587
653
  end
588
654
 
589
655
  if !evaluation.key?(:variable_value) && evaluation_options.key?(:default_variable_value)
590
656
  evaluation[:variable_value] = evaluation_options[:default_variable_value]
591
657
  end
592
- evaluation = @modules_manager.run_global_after_modules(evaluation, evaluation_options)
658
+ unless @modules_manager.get_all.empty?
659
+ evaluation = @modules_manager.run_after_evaluation_modules(evaluation, evaluation_options)
660
+ end
593
661
  report_diagnostic(level: "debug", code: evaluation[:reason], message: "Global variable evaluated", details: evaluation)
594
662
  evaluation
663
+ end
595
664
  rescue => e
665
+ raise if e.is_a?(RequirementError) && options[:__featurevisor_prerequisite]
596
666
  evaluation = { type: "variable", variable_key: evaluation_options[:variable_key],
597
667
  reason: Featurevisor::EvaluationReason::ERROR, error: e }
668
+ session.remember(global_variable_session_options(evaluation_options, options).merge(__featurevisor_prerequisite: true), evaluation) if options[:__featurevisor_prerequisite]
598
669
  report_diagnostic(level: "error", code: "evaluation_error", message: "Global variable evaluation failed",
599
670
  originalError: e, details: evaluation)
600
671
  evaluation
601
672
  end
602
673
  end
603
674
 
604
- def required_features_are_matched(requirements, context, options)
605
- return true if requirements.nil?
675
+ def global_variable_session_options(evaluation_options, options)
676
+ evaluation_options.merge(
677
+ sticky: options[:__featurevisor_sticky_features_source]&.call || options[:__featurevisor_child_sticky_features] || @sticky_features,
678
+ sticky_variables: options[:__featurevisor_sticky_variables_source]&.call || options[:__featurevisor_child_sticky_variables] || @sticky_variables,
679
+ datafile: @datafile,
680
+ __featurevisor_generation: -> { @evaluation_generation },
681
+ __featurevisor_child_generation: options[:__featurevisor_child_generation],
682
+ __featurevisor_project_variable: options[:__featurevisor_project_variable]
683
+ )
684
+ end
606
685
 
607
- items = requirements.is_a?(Array) ? requirements : [requirements]
608
- clean_options = options.reject { |key, _| %i[default_variation_value default_variable_value].include?(key) }
609
- items.all? do |required|
610
- if required.is_a?(String)
611
- key = required
612
- expected_enabled = true
613
- expected_variation = nil
614
- else
615
- key = required[:feature]
616
- expected_enabled = required.key?(:enabled) ? required[:enabled] : true
617
- expected_variation = required[:variation]
686
+ def project_variable_dependency(evaluation)
687
+ return evaluation unless evaluation.key?(:variable_value)
688
+ type = evaluation.dig(:variable_schema, :type) || evaluation.dig(:variable, :type)
689
+ return evaluation unless type == "json" && !evaluation.dig(:variable, :compose) && evaluation[:variable_value].is_a?(String)
690
+
691
+ evaluation.merge(variable_value: JSON.parse(evaluation[:variable_value], symbolize_names: true), __featurevisor_decoded_json: true)
692
+ rescue JSON::ParserError => error
693
+ report_diagnostic(level: "error", code: "evaluation_error", message: "getVariable failed", originalError: error,
694
+ details: { featureKey: evaluation[:feature_key], variableKey: evaluation[:variable_key] })
695
+ evaluation.merge(variable_value: nil, __featurevisor_decoded_json: true)
696
+ end
697
+
698
+ def evaluate_required_variable(required, dependencies)
699
+ options = {
700
+ __featurevisor_sticky_features_source: dependencies[:__featurevisor_sticky_features_source],
701
+ __featurevisor_sticky_variables_source: dependencies[:__featurevisor_sticky_variables_source],
702
+ __featurevisor_child_generation: dependencies[:__featurevisor_child_generation],
703
+ __featurevisor_session: dependencies[:__featurevisor_session],
704
+ __featurevisor_prerequisite: true,
705
+ __featurevisor_effective_context: true,
706
+ dependency_purpose: "condition",
707
+ __featurevisor_project_variable: method(:project_variable_dependency)
708
+ }
709
+ if required[:feature]
710
+ get_variable(required[:feature], required[:variable], dependencies[:context], options)
711
+ else
712
+ get_variable(required[:variable], dependencies[:context], options)
713
+ end
714
+ end
715
+
716
+ def composition_source(reference, dependencies)
717
+ context = dependencies[:context]
718
+ options = {
719
+ __featurevisor_session: dependencies[:__featurevisor_session],
720
+ __featurevisor_prerequisite: true,
721
+ __featurevisor_effective_context: true,
722
+ __featurevisor_sticky_features_source: dependencies[:__featurevisor_sticky_features_source],
723
+ __featurevisor_sticky_variables_source: dependencies[:__featurevisor_sticky_variables_source],
724
+ __featurevisor_child_generation: dependencies[:__featurevisor_child_generation],
725
+ dependency_purpose: "composition",
726
+ __featurevisor_project_variable: method(:project_variable_dependency)
727
+ }
728
+ if reference.key?(:variable)
729
+ reference.key?(:feature) ? get_variable(reference[:feature], reference[:variable], context, options) : get_variable(reference[:variable], context, options)
730
+ elsif reference[:variation]
731
+ get_variation(reference[:feature], context, options)
732
+ else
733
+ is_enabled(reference[:feature], context, options)
734
+ end
735
+ end
736
+
737
+ def compose_value(compose, dependencies)
738
+ return composition_source({ variable: compose }, dependencies) if compose.is_a?(String)
739
+ raise RequirementError, "Invalid composition" unless compose.is_a?(Array) && !compose.empty?
740
+ compose.each_with_object({}) do |reference, result|
741
+ key = reference[:key]
742
+ if !key.is_a?(String) || key.empty? || %w[__proto__ prototype constructor].include?(key)
743
+ raise RequirementError, "Invalid composition property"
618
744
  end
619
- next false unless is_enabled(key, context, clean_options) == expected_enabled
620
- expected_variation.nil? || get_variation(key, context, clean_options) == expected_variation
745
+ result[key.to_sym] = composition_source(reference, dependencies)
621
746
  end
622
747
  end
623
748
 
749
+ def variable_condition_matches?(required, dependencies)
750
+ value = evaluate_required_variable(required, dependencies)
751
+ if required.key?(:path)
752
+ raise ArgumentError, "Variable requirement path must be compiled" unless required[:path].is_a?(Array)
753
+ required[:path].each do |part|
754
+ raise ArgumentError, "Unsafe variable requirement path" if %w[__proto__ prototype constructor].include?(part)
755
+ index = Mutations.send(:array_index, part)
756
+ if part.is_a?(Numeric) && index.nil?
757
+ raise ArgumentError, "Invalid variable requirement path"
758
+ end
759
+ value = if !index.nil?
760
+ value.is_a?(Array) && index < value.length ? value[index] : Conditions.missing_value
761
+ elsif part.is_a?(String)
762
+ if value.is_a?(Hash)
763
+ key = value.key?(part.to_sym) ? part.to_sym : part
764
+ value.key?(key) ? value[key] : Conditions.missing_value
765
+ elsif value.is_a?(Array) && part == "length"
766
+ value.length
767
+ elsif value.is_a?(Array) && part.match?(/\A(?:0|[1-9]\d*)\z/) && part.to_i < value.length
768
+ value[part.to_i]
769
+ else
770
+ Conditions.missing_value
771
+ end
772
+ else
773
+ raise ArgumentError, "Invalid variable requirement path"
774
+ end
775
+ end
776
+ end
777
+ Conditions.value_is_matched(value, required, ->(pattern, flags) { @datafile.get_regex(pattern, flags) })
778
+ rescue RequirementError
779
+ raise
780
+ rescue => error
781
+ raise RequirementError, error.message
782
+ end
783
+
624
784
  # Get evaluation dependencies
625
785
  # @param context [Hash] Context
626
786
  # @param options [Hash] Override options
627
787
  # @return [Hash] Evaluation dependencies
628
788
  def get_evaluation_dependencies(context, options = {})
789
+ sticky_source = options[:__featurevisor_sticky_features_source] ||
790
+ -> { options[:__featurevisor_child_sticky_features] || @sticky_features }
791
+ variable_source = options[:__featurevisor_sticky_variables_source] ||
792
+ -> { options[:__featurevisor_child_sticky_variables] || @sticky_variables }
629
793
  {
630
- context: get_context(context),
794
+ context: options[:__featurevisor_effective_context] ? context : get_context(context),
631
795
  diagnostics: @diagnostics,
632
796
  modules_manager: @modules_manager,
633
797
  datafile: @datafile,
634
- sticky: options[:__featurevisor_child_sticky_features] || @sticky_features,
635
- sticky_variables: options[:__featurevisor_child_sticky_variables] || @sticky_variables,
798
+ __featurevisor_instance: self,
799
+ sticky: sticky_source.call,
800
+ sticky_variables: variable_source.call,
801
+ __featurevisor_project_variable: options[:__featurevisor_project_variable],
802
+ __featurevisor_session: options[:__featurevisor_session],
803
+ __featurevisor_prerequisite: options[:__featurevisor_prerequisite],
804
+ __featurevisor_generation: -> { @evaluation_generation },
805
+ __featurevisor_child_generation: options[:__featurevisor_child_generation],
806
+ __featurevisor_sticky_features_source: sticky_source,
807
+ __featurevisor_sticky_variables_source: variable_source,
808
+ __featurevisor_datafile_source: -> { @datafile },
636
809
  }.tap do |dependencies|
810
+ dependencies[:dependency_purpose] = options[:dependency_purpose] if options[:dependency_purpose]
811
+ dependencies[:dependency_of] = options[:__featurevisor_session].dependency_of if options[:__featurevisor_prerequisite] && options[:__featurevisor_session]
637
812
  dependencies[:default_variation_value] = options[:default_variation_value] if options.key?(:default_variation_value)
638
813
  dependencies[:default_variable_value] = options[:default_variable_value] if options.key?(:default_variable_value)
639
814
  end
@@ -652,7 +827,7 @@ module Featurevisor
652
827
  when "integer"
653
828
  return value if value.is_a?(Integer)
654
829
  value.is_a?(Float) && value.finite? && value == value.to_i ? value.to_i : nil
655
- when "double"
830
+ when "number"
656
831
  value.is_a?(Numeric) && value.finite? ? value.to_f : nil
657
832
  when "boolean"
658
833
  value == true || value == false ? value : nil
@@ -696,6 +871,8 @@ module Featurevisor
696
871
  {
697
872
  get_revision: -> { instance.get_revision },
698
873
  on_diagnostic: lambda do |handler, options = {}|
874
+ next -> {} if @closed
875
+
699
876
  subscription = {
700
877
  id: SecureRandom.uuid,
701
878
  module_id: mod.id,
@@ -714,6 +891,8 @@ module Featurevisor
714
891
  end
715
892
 
716
893
  def report_diagnostic(diagnostic, source_module = nil)
894
+ return unless diagnostic_interested?((diagnostic || {})[:level] || "info", source_module)
895
+
717
896
  diagnostic = (diagnostic || {}).dup
718
897
  diagnostic[:level] ||= "info"
719
898
  diagnostic[:module] = source_module.name if source_module && source_module.name
@@ -772,6 +951,26 @@ module Featurevisor
772
951
  subscriber_index >= diagnostic_index
773
952
  end
774
953
 
954
+ # Check live subscriptions before copying and formatting evaluation details.
955
+ # Errors also reach the error event, independently of diagnostic levels.
956
+ def diagnostic_interested?(level, source_module = nil)
957
+ return true if level == "error"
958
+
959
+ # The default console reporter and explicit handlers have historically
960
+ # handled unrecognized levels differently. Preserve both policies.
961
+ instance_interested = if @on_diagnostic
962
+ should_report_diagnostic?(level, @diagnostics.level)
963
+ else
964
+ LOG_LEVELS.index(level).to_i <= LOG_LEVELS.index(@diagnostics.level).to_i
965
+ end
966
+ instance_interested ||
967
+ @module_diagnostic_subscriptions.any? do |subscription|
968
+ (!source_module || subscription[:module_id] != source_module.id) &&
969
+ should_report_diagnostic?(level, subscription[:level])
970
+ end
971
+ end
972
+ private :diagnostic_interested?
973
+
775
974
  def camelize_diagnostic_value(value)
776
975
  case value
777
976
  when Hash
@@ -12,11 +12,9 @@ module Featurevisor
12
12
  @id = SecureRandom.uuid
13
13
  @name = options[:name]
14
14
  @setup = options[:setup]
15
- @before = options[:before]
16
15
  @before_evaluation = options[:before_evaluation] || options[:beforeEvaluation]
17
16
  @bucket_key = options[:bucket_key]
18
17
  @bucket_value = options[:bucket_value]
19
- @after = options[:after]
20
18
  @after_evaluation = options[:after_evaluation] || options[:afterEvaluation]
21
19
  @close = options[:close]
22
20
  end
@@ -25,12 +23,6 @@ module Featurevisor
25
23
  @setup.call(api) if @setup
26
24
  end
27
25
 
28
- def call_before(options)
29
- return options unless @before
30
-
31
- @before.call(options)
32
- end
33
-
34
26
  def call_before_evaluation(options)
35
27
  return options unless @before_evaluation
36
28
 
@@ -49,12 +41,6 @@ module Featurevisor
49
41
  @bucket_value.call(options)
50
42
  end
51
43
 
52
- def call_after(evaluation, options)
53
- return evaluation unless @after
54
-
55
- @after.call(evaluation, options)
56
- end
57
-
58
44
  def call_after_evaluation(evaluation, options)
59
45
  return evaluation unless @after_evaluation
60
46
 
@@ -71,6 +57,7 @@ module Featurevisor
71
57
 
72
58
  def initialize(options = {})
73
59
  @modules = []
60
+ @on_change = options[:on_change]
74
61
  @report_diagnostic = options[:report_diagnostic]
75
62
  @module_api_factory = options[:module_api_factory]
76
63
  @clear_module_diagnostic_subscriptions = options[:clear_module_diagnostic_subscriptions]
@@ -114,7 +101,9 @@ module Featurevisor
114
101
  close_module(mod)
115
102
  return nil
116
103
  end
117
- @modules << mod
104
+ # Replace membership so an active phase keeps its starting list.
105
+ @modules = @modules + [mod]
106
+ @on_change&.call
118
107
 
119
108
  -> { remove(mod) }
120
109
  end
@@ -126,6 +115,7 @@ module Featurevisor
126
115
  removed << mod if matches
127
116
  matches
128
117
  end
118
+ @on_change&.call unless removed.empty?
129
119
 
130
120
  removed.each do |mod|
131
121
  @clear_module_diagnostic_subscriptions.call(mod) if @clear_module_diagnostic_subscriptions
@@ -137,15 +127,6 @@ module Featurevisor
137
127
  @modules
138
128
  end
139
129
 
140
- def run_before_modules(options)
141
- result = @modules.reduce(options) do |current, mod|
142
- mod.call_before(current)
143
- end
144
- @modules.reduce(result) do |current, mod|
145
- mod.call_before_evaluation(current)
146
- end
147
- end
148
-
149
130
  def run_before_evaluation_modules(options)
150
131
  @modules.reduce(options) do |result, mod|
151
132
  mod.call_before_evaluation(result)
@@ -158,28 +139,6 @@ module Featurevisor
158
139
  end
159
140
  end
160
141
 
161
- def run_after_modules(evaluation, options)
162
- result = @modules.reduce(evaluation) do |current, mod|
163
- mod.call_after_evaluation(current, options)
164
- end
165
- @modules.reduce(result) do |current, mod|
166
- mod.call_after(current, options)
167
- end
168
- end
169
-
170
- # Deprecated feature-only callbacks are intentionally excluded here.
171
- def run_global_after_modules(evaluation, options)
172
- @modules.reduce(evaluation) do |result, mod|
173
- mod.call_after_evaluation(result, options)
174
- end
175
- end
176
-
177
- def run_legacy_before_modules(options)
178
- @modules.reduce(options) do |result, mod|
179
- mod.call_before(result)
180
- end
181
- end
182
-
183
142
  def run_bucket_key_modules(options)
184
143
  bucket_key = options[:bucket_key]
185
144
  @modules.each do |mod|
@@ -197,6 +156,7 @@ module Featurevisor
197
156
  end
198
157
 
199
158
  def close_all
159
+ @on_change&.call unless @modules.empty?
200
160
  @modules.each do |mod|
201
161
  @clear_module_diagnostic_subscriptions.call(mod) if @clear_module_diagnostic_subscriptions
202
162
  close_module(mod)