onni 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.
Files changed (51) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +30 -0
  3. data/CODE_OF_CONDUCT.md +26 -0
  4. data/CONTRIBUTING.md +23 -0
  5. data/GOVERNANCE.md +25 -0
  6. data/LICENSE.md +21 -0
  7. data/README.md +301 -0
  8. data/SECURITY.md +25 -0
  9. data/app/admin/features.rb +97 -0
  10. data/app/models/onni/feature.rb +14 -0
  11. data/app/models/onni/gate.rb +12 -0
  12. data/app/models/onni/hit_chart.rb +82 -0
  13. data/app/models/onni/stat_bucket.rb +12 -0
  14. data/app/views/admin/features/_chart.html.erb +24 -0
  15. data/app/views/admin/features/_stats.html.erb +49 -0
  16. data/app/views/admin/features/show.html.erb +60 -0
  17. data/config/locales/en.yml +6 -0
  18. data/config/polyrun_coverage.yml +8 -0
  19. data/lib/generators/onni/install_generator.rb +37 -0
  20. data/lib/generators/onni/stats_cleanup_generator.rb +50 -0
  21. data/lib/generators/onni/templates/add_onni_stats_period_start_index.rb.tt +9 -0
  22. data/lib/generators/onni/templates/create_onni_features.rb.tt +34 -0
  23. data/lib/generators/onni/templates/features.yml.tt +9 -0
  24. data/lib/generators/onni/templates/initializer.rb.tt +41 -0
  25. data/lib/generators/onni/templates/purge_stats_job.rb.tt +26 -0
  26. data/lib/onni/active_admin.rb +13 -0
  27. data/lib/onni/actor.rb +31 -0
  28. data/lib/onni/adapters/active_record.rb +128 -0
  29. data/lib/onni/adapters/memory.rb +161 -0
  30. data/lib/onni/conditions.rb +30 -0
  31. data/lib/onni/configuration.rb +54 -0
  32. data/lib/onni/engine.rb +50 -0
  33. data/lib/onni/flag.rb +53 -0
  34. data/lib/onni/group.rb +36 -0
  35. data/lib/onni/instrumenter.rb +27 -0
  36. data/lib/onni/migrator/importer.rb +55 -0
  37. data/lib/onni/migrator/snapshot.rb +15 -0
  38. data/lib/onni/registry.rb +78 -0
  39. data/lib/onni/request_store.rb +33 -0
  40. data/lib/onni/stats/buffer.rb +35 -0
  41. data/lib/onni/stats/overview.rb +34 -0
  42. data/lib/onni/stats/purge.rb +77 -0
  43. data/lib/onni/stats/sampler.rb +62 -0
  44. data/lib/onni/stats.rb +45 -0
  45. data/lib/onni/store.rb +155 -0
  46. data/lib/onni/sync.rb +43 -0
  47. data/lib/onni/version.rb +5 -0
  48. data/lib/onni.rb +193 -0
  49. data/onni.gemspec +72 -0
  50. data/sig/onni.rbs +210 -0
  51. metadata +373 -0
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Onni
4
+ module Stats
5
+ # Deletes hourly hit buckets older than a cutoff, in id-ordered batches.
6
+ # With enqueue, one batch runs then the next page is scheduled.
7
+ # Without enqueue (console `Purge.call`), remaining pages drain in-process.
8
+ class Purge
9
+ DEFAULT_BATCH_SIZE = 1_000
10
+
11
+ def self.call(**kwargs)
12
+ new(**kwargs).call
13
+ end
14
+
15
+ def initialize(older_than: nil, batch_size: DEFAULT_BATCH_SIZE, after_id: 0, processed_count: 0, enqueue: nil)
16
+ @older_than = older_than
17
+ @batch_size = batch_size
18
+ @after_id = after_id
19
+ @processed_count = processed_count
20
+ @enqueue = enqueue
21
+ end
22
+
23
+ def call
24
+ older_than = resolve_older_than
25
+ processed = @processed_count
26
+ after_id = @after_id
27
+
28
+ loop do
29
+ batch = Onni.adapter.purge_stats_batch(
30
+ older_than: older_than,
31
+ batch_size: @batch_size,
32
+ after_id: after_id
33
+ )
34
+ processed += batch[:deleted]
35
+ break if batch[:last_id].nil?
36
+
37
+ if @enqueue
38
+ @enqueue.call(
39
+ older_than: older_than,
40
+ batch_size: @batch_size,
41
+ after_id: batch[:last_id],
42
+ processed_count: processed
43
+ )
44
+ return {
45
+ deleted: processed - @processed_count,
46
+ processed_count: processed,
47
+ after_id: batch[:last_id],
48
+ continued: true
49
+ }
50
+ end
51
+
52
+ after_id = batch[:last_id]
53
+ end
54
+
55
+ {
56
+ deleted: processed - @processed_count,
57
+ processed_count: processed,
58
+ after_id: after_id,
59
+ continued: false
60
+ }
61
+ end
62
+
63
+ private
64
+
65
+ def resolve_older_than
66
+ return @older_than if @older_than
67
+
68
+ retention = Onni.configuration.stats_retention
69
+ if retention.nil?
70
+ raise ArgumentError, "Pass older_than: or set Onni.configuration.stats_retention"
71
+ end
72
+
73
+ retention.ago
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Onni
4
+ module Stats
5
+ # Sampling decision for hit stats:
6
+ # - float rate in 0.0..1.0 with clamp
7
+ # - skip Random when rate is 1.0
8
+ # - optional per-evaluation sampler callable
9
+ # - ignore list for noisy keys
10
+ # - optional inverse-rate weight so stored counters estimate full traffic
11
+ module Sampler
12
+ MAX_WEIGHT = 1_000_000
13
+
14
+ module_function
15
+
16
+ def decide(name:, enabled:)
17
+ config = Onni.configuration
18
+ return :skip unless config.stats_enabled
19
+ return :skip if ignored?(name, config)
20
+
21
+ rate = coerce_rate(rate_for(name: name, enabled: enabled, config: config))
22
+ return :skip if rate <= 0.0
23
+ return {record: true, weight: 1} if rate >= 1.0
24
+ return :skip unless Random.rand < rate
25
+
26
+ weight = config.stats_scale_sampled_hits ? weight_for(rate) : 1
27
+ {record: true, weight: weight}
28
+ end
29
+
30
+ def coerce_rate(rate)
31
+ case rate
32
+ when true then 1.0
33
+ when false, nil then 0.0
34
+ else
35
+ value = rate.to_f
36
+ value.clamp(0.0, 1.0)
37
+ end
38
+ end
39
+
40
+ def weight_for(rate)
41
+ return 1 if rate <= 0.0
42
+
43
+ (1.0 / rate).round.clamp(1, MAX_WEIGHT)
44
+ end
45
+
46
+ def rate_for(name:, enabled:, config:)
47
+ if config.stats_sampler
48
+ sampled = config.stats_sampler.call({name: name.to_s, enabled: enabled})
49
+ return sampled unless sampled.nil?
50
+ end
51
+
52
+ config.stats_sample_rate
53
+ end
54
+ private_class_method :rate_for
55
+
56
+ def ignored?(name, config)
57
+ Array(config.stats_ignore).any? { |key| key.to_s == name.to_s }
58
+ end
59
+ private_class_method :ignored?
60
+ end
61
+ end
62
+ end
data/lib/onni/stats.rb ADDED
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "onni/stats/purge"
4
+ require "onni/stats/sampler"
5
+ require "onni/stats/overview"
6
+ require "onni/stats/buffer"
7
+
8
+ module Onni
9
+ module Stats
10
+ module_function
11
+
12
+ def record(name, enabled:)
13
+ decision = Sampler.decide(name: name, enabled: enabled)
14
+ return if decision == :skip
15
+
16
+ Instrumenter.instrument("stats.record", name: name.to_s, enabled: enabled, weight: decision[:weight]) do
17
+ if Onni.configuration.stats_buffer
18
+ Buffer.add(name, enabled: enabled, weight: decision[:weight])
19
+ else
20
+ Onni.adapter.record_stat(name, enabled: enabled, weight: decision[:weight])
21
+ end
22
+ end
23
+ rescue => error
24
+ Rails.logger.warn("Onni stats failed: #{error.class}: #{error.message}") if defined?(Rails)
25
+ end
26
+
27
+ def for(name, period: "hour", from: 24.hours.ago, to: Time.current)
28
+ Onni.adapter.stats_for(name, period: period, from: from, to: to)
29
+ end
30
+
31
+ def purge!(**kwargs)
32
+ Instrumenter.instrument("stats.purge") do |payload|
33
+ result = Purge.call(**kwargs)
34
+ payload[:deleted] = result[:deleted]
35
+ payload[:processed_count] = result[:processed_count]
36
+ payload[:continued] = result[:continued]
37
+ result
38
+ end
39
+ end
40
+
41
+ def overview
42
+ Overview.call
43
+ end
44
+ end
45
+ end
data/lib/onni/store.rb ADDED
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require "json"
5
+
6
+ module Onni
7
+ module Store
8
+ CACHE_KEY_PREFIX = "onni/gates/"
9
+
10
+ module_function
11
+
12
+ def enabled?(name, actor = nil, record_stats: true)
13
+ return false unless Registry.known?(name)
14
+
15
+ result = evaluate(name, actor)
16
+ Stats.record(name, enabled: result) if record_stats
17
+ result
18
+ end
19
+
20
+ def variant(name, actor, record_stats: true)
21
+ return unless Registry.known?(name)
22
+ return if actor.nil?
23
+
24
+ definition = Registry.definition(name) || {}
25
+ return if expired?(definition)
26
+
27
+ gates = cached_gates(name)
28
+ return if gates.empty?
29
+
30
+ matched =
31
+ boolean_enabled?(gates) ||
32
+ actor_enabled?(gates, actor) ||
33
+ group_enabled?(gates, actor) ||
34
+ percentage_enabled?(gates, name, actor)
35
+ return unless matched
36
+ return unless Conditions.met?(name, actor, definition: definition)
37
+
38
+ Stats.record(name, enabled: true) if record_stats
39
+
40
+ variants = variant_weights(definition, gates)
41
+ return if variants.blank?
42
+
43
+ Actor.variant_for(actor, name, variants)
44
+ end
45
+
46
+ def evaluate(name, actor)
47
+ definition = Registry.definition(name) || {}
48
+ return false if expired?(definition)
49
+
50
+ gates = cached_gates(name)
51
+ return false if gates.empty?
52
+
53
+ matched =
54
+ boolean_enabled?(gates) ||
55
+ actor_enabled?(gates, actor) ||
56
+ group_enabled?(gates, actor) ||
57
+ percentage_enabled?(gates, name, actor)
58
+ return false unless matched
59
+
60
+ Conditions.met?(name, actor, definition: definition)
61
+ end
62
+ private_class_method :evaluate
63
+
64
+ def variant_weights(definition, gates)
65
+ raw = definition["variants"]
66
+ return normalize_variants(raw) if raw.present?
67
+
68
+ gate = Array(gates).find { |entry| entry["key"] == "variants" }
69
+ return {} unless gate
70
+
71
+ normalize_variants(JSON.parse(gate["value"]))
72
+ rescue JSON::ParserError
73
+ {}
74
+ end
75
+ private_class_method :variant_weights
76
+
77
+ def normalize_variants(raw)
78
+ raw.transform_keys(&:to_s).transform_values(&:to_i)
79
+ end
80
+ private_class_method :normalize_variants
81
+
82
+ def expired?(definition)
83
+ raw = definition["expires_at"]
84
+ return false if raw.nil? || raw.to_s.empty?
85
+
86
+ deadline = definition["expires_at_time"]
87
+ if deadline.nil?
88
+ begin
89
+ deadline = Time.parse(raw.to_s).utc
90
+ rescue ArgumentError, TypeError
91
+ return true
92
+ end
93
+ end
94
+ deadline < Time.now.utc
95
+ rescue ArgumentError, TypeError
96
+ true
97
+ end
98
+ private_class_method :expired?
99
+
100
+ def cached_gates(name)
101
+ key = name.to_s
102
+ preloaded = RequestStore.gates
103
+ return preloaded[key] if preloaded&.key?(key)
104
+
105
+ cache = Onni.configuration.cache
106
+ return Onni.adapter.gates_for(key) unless cache
107
+
108
+ cache.fetch(cache_key(key), expires_in: Onni.configuration.cache_ttl) do
109
+ Onni.adapter.gates_for(key)
110
+ end
111
+ end
112
+ private_class_method :cached_gates
113
+
114
+ def bust_cache!(name)
115
+ key = name.to_s
116
+ Onni.configuration.cache&.delete(cache_key(key))
117
+ RequestStore.gates&.delete(key)
118
+ end
119
+
120
+ def cache_key(name)
121
+ "#{CACHE_KEY_PREFIX}#{Onni.adapter.object_id}/#{name}"
122
+ end
123
+ private_class_method :cache_key
124
+
125
+ def boolean_enabled?(gates)
126
+ gates.any? { |gate| gate["key"] == "boolean" && gate["value"] == "true" }
127
+ end
128
+ private_class_method :boolean_enabled?
129
+
130
+ def actor_enabled?(gates, actor)
131
+ actor_id = Actor.id(actor)
132
+ return false if actor_id.blank?
133
+
134
+ gates.any? { |gate| gate["key"] == "actors" && gate["value"] == actor_id }
135
+ end
136
+ private_class_method :actor_enabled?
137
+
138
+ def group_enabled?(gates, actor)
139
+ return false if actor.nil?
140
+
141
+ gates.any? { |gate| gate["key"] == "groups" && Group.member?(gate["value"], actor) }
142
+ end
143
+ private_class_method :group_enabled?
144
+
145
+ def percentage_enabled?(gates, name, actor)
146
+ return false if actor.nil?
147
+
148
+ gate = gates.find { |entry| entry["key"] == "percentage_of_actors" }
149
+ return false unless gate
150
+
151
+ Actor.percentage_value(actor, name) < gate["value"].to_i
152
+ end
153
+ private_class_method :percentage_enabled?
154
+ end
155
+ end
data/lib/onni/sync.rb ADDED
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Onni
4
+ # Configurable mutual exclusion for shared in-process state.
5
+ # On Ruby 3.4+, Mutex ownership is per Fiber, so :mutex covers threads and fibers.
6
+ # Do not Fiber.yield inside a synchronize block.
7
+ module Sync
8
+ BOOTSTRAP = Mutex.new
9
+
10
+ class NullLock
11
+ def synchronize
12
+ yield
13
+ end
14
+ end
15
+
16
+ module_function
17
+
18
+ def new_lock
19
+ case Onni.configuration.concurrency
20
+ when :mutex then Mutex.new
21
+ when :none then NullLock.new
22
+ else
23
+ raise ArgumentError, "Unknown Onni concurrency #{Onni.configuration.concurrency.inspect}"
24
+ end
25
+ end
26
+
27
+ def synchronize(&block)
28
+ shared_lock.synchronize(&block)
29
+ end
30
+
31
+ def reset!
32
+ BOOTSTRAP.synchronize { @shared_lock = nil }
33
+ end
34
+
35
+ def shared_lock
36
+ lock = @shared_lock
37
+ return lock if lock
38
+
39
+ BOOTSTRAP.synchronize { @shared_lock ||= new_lock }
40
+ end
41
+ private_class_method :shared_lock
42
+ end
43
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Onni
4
+ VERSION = "0.1.0"
5
+ end
data/lib/onni.rb ADDED
@@ -0,0 +1,193 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zlib"
4
+ require "json"
5
+ require "onni/version"
6
+ require "onni/configuration"
7
+ require "onni/sync"
8
+ require "onni/instrumenter"
9
+ require "onni/registry"
10
+ require "onni/actor"
11
+ require "onni/group"
12
+ require "onni/conditions"
13
+ require "onni/stats"
14
+ require "onni/flag"
15
+ require "onni/request_store"
16
+ require "onni/store"
17
+ require "onni/adapters/memory"
18
+ require "onni/adapters/active_record"
19
+ require "onni/migrator/snapshot"
20
+ require "onni/migrator/importer"
21
+ require "onni/engine" if defined?(Rails::Engine)
22
+
23
+ module Onni
24
+ class << self
25
+ def configuration
26
+ @configuration ||= Configuration.new
27
+ end
28
+
29
+ def configure
30
+ yield configuration
31
+ Sync.reset!
32
+ @adapter = nil
33
+ end
34
+
35
+ def adapter
36
+ @adapter ||= build_adapter
37
+ end
38
+
39
+ attr_writer :adapter
40
+
41
+ def reset_adapter!
42
+ @adapter = nil
43
+ end
44
+
45
+ def enabled?(name, actor = nil, record_stats: true)
46
+ Store.enabled?(name, actor, record_stats: record_stats)
47
+ end
48
+
49
+ def disabled?(name, actor = nil, record_stats: true)
50
+ !enabled?(name, actor, record_stats: record_stats)
51
+ end
52
+
53
+ def variant(name, actor = nil, record_stats: true)
54
+ Store.variant(name, actor, record_stats: record_stats)
55
+ end
56
+
57
+ def enable(name, actor = nil)
58
+ feature_operation(name, :enable) do |payload|
59
+ add(name)
60
+ if actor
61
+ adapter.add_gate(name, "actors", Actor.id(actor))
62
+ payload[:actor_id] = Actor.id(actor)
63
+ else
64
+ adapter.add_gate(name, "boolean", "true")
65
+ end
66
+ end
67
+ end
68
+
69
+ def disable(name, actor = nil)
70
+ feature_operation(name, :disable) do |payload|
71
+ if actor
72
+ adapter.remove_gate(name, "actors", Actor.id(actor))
73
+ payload[:actor_id] = Actor.id(actor)
74
+ else
75
+ adapter.clear_gates(name)
76
+ end
77
+ end
78
+ end
79
+
80
+ def enable_group(name, group_name)
81
+ feature_operation(name, :enable_group, group_name: group_name.to_s) do
82
+ add(name)
83
+ adapter.add_gate(name, "groups", group_name.to_s)
84
+ end
85
+ end
86
+
87
+ def disable_group(name, group_name)
88
+ feature_operation(name, :disable_group, group_name: group_name.to_s) do
89
+ adapter.remove_gate(name, "groups", group_name.to_s)
90
+ end
91
+ end
92
+
93
+ def enable_percentage_of_actors(name, percentage)
94
+ clamped = percentage.to_i.clamp(0, 100)
95
+ feature_operation(name, :enable_percentage_of_actors, percentage: clamped) do
96
+ add(name)
97
+ adapter.remove_gate(name, "percentage_of_actors")
98
+ adapter.add_gate(name, "percentage_of_actors", clamped.to_s)
99
+ end
100
+ end
101
+
102
+ def set_variants(name, variants)
103
+ feature_operation(name, :set_variants) do
104
+ add(name)
105
+ adapter.remove_gate(name, "variants")
106
+ adapter.add_gate(name, "variants", JSON.generate(variants.transform_keys(&:to_s)))
107
+ end
108
+ end
109
+
110
+ def add(name)
111
+ Registry.require_definition!(name)
112
+ adapter.add(name)
113
+ end
114
+
115
+ def exist?(name)
116
+ adapter.exist?(name)
117
+ end
118
+
119
+ def features
120
+ adapter.feature_keys.map { |key| Flag.new(key) }
121
+ end
122
+
123
+ def [](name)
124
+ Flag.new(name)
125
+ end
126
+
127
+ def register_group(name, &block)
128
+ Group.register(name, &block)
129
+ end
130
+
131
+ # Import from any prior feature-flag store via a source with #feature_keys and #gates.
132
+ def migrate!(source)
133
+ Migrator::Importer.new(source).call
134
+ end
135
+
136
+ def sync_registry!
137
+ Registry.config.each_key { |key| add(key) unless exist?(key) }
138
+ end
139
+
140
+ def purge_stats!(**kwargs)
141
+ Stats.purge!(**kwargs)
142
+ end
143
+
144
+ def stats_overview
145
+ Stats.overview
146
+ end
147
+
148
+ # Preload gates into the fiber-local request store (one adapter round-trip).
149
+ # Pass names, or omit to load every YAML registry key.
150
+ def preload!(*names)
151
+ keys = names.flatten.map!(&:to_s)
152
+ keys = Registry.config.keys.map(&:to_s) if keys.empty?
153
+ loaded =
154
+ if adapter.respond_to?(:gates_for_many)
155
+ adapter.gates_for_many(keys)
156
+ else
157
+ keys.each_with_object({}) { |key, hash| hash[key] = adapter.gates_for(key) }
158
+ end
159
+ RequestStore.gates = (RequestStore.gates || {}).merge(loaded)
160
+ loaded.size
161
+ end
162
+
163
+ def flush_stats!
164
+ Stats::Buffer.flush!
165
+ end
166
+
167
+ def reset_request!
168
+ Stats::Buffer.flush! if configuration.stats_buffer
169
+ RequestStore.clear!
170
+ end
171
+
172
+ private
173
+
174
+ def feature_operation(name, operation, **fields)
175
+ Instrumenter.instrument("feature_operation", feature_name: name.to_s, operation: operation, **fields) do |payload|
176
+ Registry.require_definition!(name)
177
+ yield(payload)
178
+ Store.bust_cache!(name)
179
+ payload[:result] = true
180
+ true
181
+ end
182
+ end
183
+
184
+ def build_adapter
185
+ case configuration.adapter
186
+ when :memory then Adapters::Memory.new
187
+ when :active_record then Adapters::ActiveRecord.new
188
+ else
189
+ raise ArgumentError, "Unknown Onni adapter #{configuration.adapter.inspect}"
190
+ end
191
+ end
192
+ end
193
+ end
data/onni.gemspec ADDED
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/onni/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "onni"
7
+ spec.version = Onni::VERSION
8
+ spec.authors = ["Andrei Makarov"]
9
+ spec.email = ["contact@kiskolabs.com"]
10
+
11
+ spec.summary = "Feature flags, A/B variants, and hit stats for Ruby apps"
12
+ spec.description = <<~TEXT
13
+ Feature-flag and experiment core for any Ruby app: YAML registry, runtime hot
14
+ switches, actor and audience-group targeting, percentage / A/B assignment, and
15
+ hit stats for analysis. Not an authorization system. Optional ActiveRecord
16
+ adapter and pluggable ActiveAdmin dashboard. Includes a pluggable migrator
17
+ for importing gates from another feature-flag library.
18
+ TEXT
19
+ spec.license = "MIT"
20
+ spec.platform = Gem::Platform::RUBY
21
+ spec.required_ruby_version = ">= 3.4.0"
22
+
23
+ repository_url = "https://github.com/amkisko/onni.rb"
24
+
25
+ spec.homepage = repository_url
26
+ spec.metadata = {
27
+ "homepage_uri" => repository_url,
28
+ "source_code_uri" => "#{repository_url}/tree/main",
29
+ "changelog_uri" => "#{repository_url}/blob/main/CHANGELOG.md",
30
+ "bug_tracker_uri" => "#{repository_url}/issues",
31
+ "documentation_uri" => "#{repository_url}#readme",
32
+ "allowed_push_host" => "https://rubygems.org",
33
+ "rubygems_mfa_required" => "true"
34
+ }
35
+
36
+ spec.files = Dir.chdir(__dir__) do
37
+ %w[
38
+ CHANGELOG.md
39
+ CODE_OF_CONDUCT.md
40
+ CONTRIBUTING.md
41
+ GOVERNANCE.md
42
+ LICENSE.md
43
+ README.md
44
+ SECURITY.md
45
+ onni.gemspec
46
+ ].select { |path| File.file?(path) } +
47
+ Dir["{app,config,lib,sig}/**/*"]
48
+ end
49
+
50
+ spec.require_paths = ["lib"]
51
+
52
+ spec.add_dependency "activesupport", ">= 7.2.3.2"
53
+
54
+ spec.add_development_dependency "activeadmin", ">= 4.0.0.beta23", "< 5"
55
+ spec.add_development_dependency "appraisal", "~> 2"
56
+ spec.add_development_dependency "bundler", ">= 2"
57
+ spec.add_development_dependency "bundler-audit", "~> 0.9"
58
+ spec.add_development_dependency "importmap-rails"
59
+ spec.add_development_dependency "polyrun", ">= 3.0.0"
60
+ spec.add_development_dependency "rails", ">= 7.2.3.2"
61
+ spec.add_development_dependency "rake", "~> 13"
62
+ spec.add_development_dependency "rbs", "~> 4"
63
+ spec.add_development_dependency "rspec", "~> 3"
64
+ spec.add_development_dependency "rspec-rails", ">= 6"
65
+ spec.add_development_dependency "sqlite3", ">= 2.10.2"
66
+ spec.add_development_dependency "rubocop-rspec", "~> 3.8"
67
+ spec.add_development_dependency "rubocop-thread_safety", "~> 0.7"
68
+ spec.add_development_dependency "standard", "~> 1.52"
69
+ spec.add_development_dependency "standard-custom", "~> 1.0"
70
+ spec.add_development_dependency "standard-performance", "~> 1.8"
71
+ spec.add_development_dependency "standard-rspec", "~> 0.3"
72
+ end