whodunit-chronicles 0.3.0 → 0.4.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 (54) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +22 -226
  3. data/LICENSE +1 -1
  4. data/README.md +96 -599
  5. data/exe/whodunit-chronicles +6 -0
  6. data/lib/whodunit/chronicles/chronicler.rb +62 -0
  7. data/lib/whodunit/chronicles/cli.rb +131 -0
  8. data/lib/whodunit/chronicles/errors.rb +7 -33
  9. data/lib/whodunit/chronicles/ledger.rb +69 -0
  10. data/lib/whodunit/chronicles/ledger_entry.rb +143 -0
  11. data/lib/whodunit/chronicles/ledger_factory.rb +66 -0
  12. data/lib/whodunit/chronicles/ledgers/file_ledger.rb +56 -0
  13. data/lib/whodunit/chronicles/ledgers/memory_ledger.rb +29 -0
  14. data/lib/whodunit/chronicles/ledgers/sqlite_ledger.rb +172 -0
  15. data/lib/whodunit/chronicles/version.rb +2 -1
  16. data/lib/whodunit/chronicles.rb +12 -65
  17. data/lib/whodunit-chronicles.rb +0 -1
  18. data/sig/whodunit/chronicles/chronicler.rbs +14 -0
  19. data/sig/whodunit/chronicles/cli.rbs +17 -0
  20. data/sig/whodunit/chronicles/errors.rbs +15 -0
  21. data/sig/whodunit/chronicles/ledger.rbs +13 -0
  22. data/sig/whodunit/chronicles/ledger_entry.rbs +62 -0
  23. data/sig/whodunit/chronicles/ledger_factory.rbs +14 -0
  24. data/sig/whodunit/chronicles/ledgers/file_ledger.rbs +14 -0
  25. data/sig/whodunit/chronicles/ledgers/memory_ledger.rbs +12 -0
  26. data/sig/whodunit/chronicles/ledgers/sqlite_ledger.rbs +30 -0
  27. data/sig/whodunit/chronicles.rbs +5 -0
  28. metadata +40 -326
  29. data/.codeclimate.yml +0 -50
  30. data/.rubocop.yml +0 -93
  31. data/.yardopts +0 -14
  32. data/CODE_OF_CONDUCT.md +0 -132
  33. data/CONTRIBUTING.md +0 -186
  34. data/Rakefile +0 -18
  35. data/docker/mysql/init.sql +0 -33
  36. data/docker/postgres/init.sql +0 -40
  37. data/docker-compose.yml +0 -138
  38. data/examples/images/campaign-performance-analytics.png +0 -0
  39. data/examples/images/candidate-journey-analytics.png +0 -0
  40. data/examples/images/recruitment-funnel-analytics.png +0 -0
  41. data/lib/.gitkeep +0 -0
  42. data/lib/whodunit/chronicles/adapter_loader.rb +0 -69
  43. data/lib/whodunit/chronicles/adapters/mysql.rb +0 -261
  44. data/lib/whodunit/chronicles/adapters/postgresql.rb +0 -278
  45. data/lib/whodunit/chronicles/change_event.rb +0 -201
  46. data/lib/whodunit/chronicles/composite_processor.rb +0 -86
  47. data/lib/whodunit/chronicles/configuration.rb +0 -112
  48. data/lib/whodunit/chronicles/connection.rb +0 -88
  49. data/lib/whodunit/chronicles/persistence.rb +0 -129
  50. data/lib/whodunit/chronicles/processor.rb +0 -127
  51. data/lib/whodunit/chronicles/service.rb +0 -207
  52. data/lib/whodunit/chronicles/stream_adapter.rb +0 -91
  53. data/lib/whodunit/chronicles/table.rb +0 -120
  54. data/whodunit-chronicles.gemspec +0 -79
@@ -1,127 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Whodunit
4
- module Chronicles
5
- # Processes database change events and creates chronicle records
6
- #
7
- # Transforms ChangeEvent objects into structured chronicle records
8
- # with complete object serialization and metadata.
9
- class Processor
10
- include Connection
11
- include Table
12
- include Persistence
13
-
14
- attr_reader :logger, :connection
15
-
16
- def initialize(
17
- audit_database_url: Chronicles.config.audit_database_url,
18
- logger: Chronicles.logger
19
- )
20
- @audit_database_url = audit_database_url
21
- @logger = logger
22
- @connection = nil
23
- end
24
-
25
- # Process a change event and create chronicle record
26
- #
27
- # @param change_event [ChangeEvent] The database change to chronicle
28
- # @return [Hash] The created chronicle record
29
- def process(change_event)
30
- ensure_connection
31
-
32
- record = build_record(change_event)
33
- persist_record(record)
34
-
35
- log(:debug, 'Processed change event',
36
- table: change_event.qualified_table_name,
37
- action: change_event.action,
38
- id: record[:id])
39
-
40
- record
41
- rescue StandardError => e
42
- log(:error, 'Failed to process change event',
43
- error: e.message,
44
- event: change_event.to_s)
45
- raise
46
- end
47
-
48
- # Process multiple change events in a batch
49
- #
50
- # @param change_events [Array<ChangeEvent>] Array of change events
51
- # @return [Array<Hash>] Array of created chronicle records
52
- def process_batch(change_events)
53
- return [] if change_events.empty?
54
-
55
- ensure_connection
56
-
57
- records = change_events.map { |event| build_record(event) }
58
- persist_records_batch(records)
59
-
60
- log(:info, 'Processed batch of change events', count: change_events.size)
61
-
62
- records
63
- rescue StandardError => e
64
- log(:error, 'Failed to process batch',
65
- error: e.message,
66
- count: change_events.size)
67
- raise
68
- end
69
-
70
- # Close database connection
71
- def close
72
- @connection&.close
73
- @connection = nil
74
- end
75
-
76
- private
77
-
78
- def build_record(change_event)
79
- user_info = extract_user_info(change_event)
80
-
81
- {
82
- id: nil, # Will be set by database
83
- table_name: change_event.table_name,
84
- schema_name: change_event.schema_name,
85
- record_id: change_event.primary_key,
86
- action: change_event.action,
87
- old_data: change_event.old_data,
88
- new_data: change_event.new_data,
89
- changes: change_event.changes,
90
- user_id: user_info[:user_id],
91
- user_type: user_info[:user_type],
92
- transaction_id: change_event.transaction_id,
93
- sequence_number: change_event.sequence_number,
94
- occurred_at: change_event.timestamp,
95
- created_at: Time.now,
96
- metadata: build_metadata(change_event),
97
- }
98
- end
99
-
100
- def extract_user_info(change_event)
101
- data = change_event.current_data || {}
102
-
103
- # Look for Whodunit user attribution fields
104
- user_id = data['creator_id'] || data['updater_id'] || data['deleter_id']
105
-
106
- {
107
- user_id: user_id,
108
- user_type: user_id ? 'User' : nil,
109
- }
110
- end
111
-
112
- def build_metadata(change_event)
113
- {
114
- table_schema: change_event.schema_name,
115
- qualified_table_name: change_event.qualified_table_name,
116
- changed_columns: change_event.changed_columns,
117
- adapter_metadata: change_event.metadata,
118
- chronicles_version: Chronicles::VERSION,
119
- }
120
- end
121
-
122
- def log(level, message, context = {})
123
- logger.public_send(level, message, processor: 'Processor', **context)
124
- end
125
- end
126
- end
127
- end
@@ -1,207 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'concurrent-ruby'
4
-
5
- module Whodunit
6
- module Chronicles
7
- # Main service orchestrator for chronicle streaming
8
- #
9
- # Coordinates the stream adapter and processor to provide
10
- # a complete chronicle streaming solution with error handling and monitoring.
11
- class Service
12
- attr_reader :adapter, :processor, :logger, :executor
13
-
14
- def initialize(
15
- adapter: nil,
16
- processor: nil,
17
- logger: Chronicles.logger
18
- )
19
- @adapter = adapter || build_adapter
20
- @processor = processor || Processor.new(logger: logger)
21
- @logger = logger
22
- @executor = Concurrent::ThreadPoolExecutor.new(
23
- min_threads: 1,
24
- max_threads: 4,
25
- max_queue: 100,
26
- fallback_policy: :caller_runs,
27
- )
28
- @running = false
29
- @retry_count = 0
30
- end
31
-
32
- # Start the chronicle streaming service
33
- #
34
- # @return [self]
35
- def start
36
- return self if running?
37
-
38
- log(:info, 'Starting Chronicles streaming service')
39
-
40
- validate_setup!
41
- test_connections!
42
-
43
- @running = true
44
- @retry_count = 0
45
-
46
- start_streaming_with_retry
47
-
48
- log(:info, 'Chronicles streaming service started successfully')
49
- self
50
- rescue StandardError => e
51
- log(:error, 'Failed to start service', error: e.message)
52
- @running = false
53
- raise
54
- end
55
-
56
- # Stop the chronicle streaming service
57
- #
58
- # @return [void]
59
- def stop
60
- return unless running?
61
-
62
- log(:info, 'Stopping Chronicles streaming service')
63
- @running = false
64
-
65
- adapter.stop_streaming if adapter.streaming?
66
- @executor.shutdown
67
- @executor.wait_for_termination(timeout: 30)
68
-
69
- processor.close
70
- log(:info, 'Chronicles streaming service stopped')
71
- end
72
-
73
- # Check if service is running
74
- #
75
- # @return [Boolean]
76
- def running?
77
- @running
78
- end
79
-
80
- # Get service status information
81
- #
82
- # @return [Hash]
83
- def status
84
- {
85
- running: running?,
86
- adapter_streaming: adapter.streaming?,
87
- adapter_position: adapter.current_position,
88
- retry_count: @retry_count,
89
- executor_status: {
90
- active_count: @executor.active_count,
91
- completed_task_count: @executor.completed_task_count,
92
- queue_length: @executor.queue_length,
93
- },
94
- }
95
- end
96
-
97
- # Set up the chronicle streaming infrastructure
98
- #
99
- # @return [void]
100
- def setup!
101
- log(:info, 'Setting up chronicle streaming infrastructure')
102
- adapter.setup
103
- log(:info, 'Chronicle streaming infrastructure setup completed')
104
- end
105
-
106
- # Tear down the chronicle streaming infrastructure
107
- #
108
- # @return [void]
109
- def teardown!
110
- log(:info, 'Tearing down chronicle streaming infrastructure')
111
- stop if running?
112
- adapter.teardown
113
- log(:info, 'Chronicle streaming infrastructure teardown completed')
114
- end
115
-
116
- private
117
-
118
- def build_adapter
119
- case Chronicles.config.adapter
120
- when :postgresql
121
- Whodunit::Chronicles::AdapterLoader.load(:postgresql, logger:)
122
- when :mysql
123
- Whodunit::Chronicles::AdapterLoader.load(:mysql, logger:)
124
- else
125
- raise ConfigurationError, "Unsupported adapter: #{Chronicles.config.adapter}"
126
- end
127
- end
128
-
129
- def validate_setup!
130
- Chronicles.config.validate!
131
-
132
- return if adapter.test_connection
133
-
134
- raise AdapterLoadError, 'Failed to connect to source database'
135
- end
136
-
137
- def test_connections!
138
- adapter.test_connection
139
- # Test processor connection by creating a dummy connection
140
- processor.send(:ensure_connection)
141
- rescue StandardError => e
142
- raise AdapterLoadError, "Connection test failed: #{e.message}"
143
- end
144
-
145
- def start_streaming_with_retry
146
- @executor.post do
147
- loop do
148
- break unless running?
149
-
150
- begin
151
- adapter.start_streaming do |change_event|
152
- process_change_event(change_event)
153
- end
154
- rescue StandardError => e
155
- handle_streaming_error(e)
156
- break unless should_retry?
157
- end
158
- end
159
- end
160
- end
161
-
162
- def process_change_event(change_event)
163
- return unless change_event
164
- return unless should_chronicle_table?(change_event)
165
-
166
- log(:debug, 'Processing change event',
167
- table: change_event.qualified_table_name,
168
- action: change_event.action
169
- )
170
-
171
- processor.process(change_event)
172
- rescue StandardError => e
173
- log(:error, 'Failed to process change event',
174
- error: e.message,
175
- event: change_event.to_s
176
- )
177
- end
178
-
179
- def should_chronicle_table?(change_event)
180
- Chronicles.config.chronicle_table?(
181
- change_event.table_name,
182
- change_event.schema_name,
183
- )
184
- end
185
-
186
- def handle_streaming_error(error)
187
- @retry_count += 1
188
- log(:error, 'Streaming error occurred',
189
- error: error.message,
190
- retry_count: @retry_count,
191
- max_retries: Chronicles.config.max_retry_attempts
192
- )
193
-
194
- # Wait before retry
195
- sleep(Chronicles.config.retry_delay) if should_retry?
196
- end
197
-
198
- def should_retry?
199
- running? && @retry_count < Chronicles.config.max_retry_attempts
200
- end
201
-
202
- def log(level, message, context = {})
203
- logger.public_send(level, message, service: 'Chronicles::Service', **context)
204
- end
205
- end
206
- end
207
- end
@@ -1,91 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Whodunit
4
- module Chronicles
5
- # Abstract base class for database streaming adapters
6
- #
7
- # Defines the interface that all database-specific adapters must implement
8
- # for streaming database changes into audit events.
9
- class StreamAdapter
10
- attr_reader :logger
11
-
12
- def initialize(logger: Chronicles.logger)
13
- @logger = logger
14
- @running = false
15
- @position = nil
16
- end
17
-
18
- # Start streaming database changes
19
- #
20
- # @param block [Proc] Block to call for each change event
21
- # @return [void]
22
- # @raise [NotImplementedError] Must be implemented by subclasses
23
- def start_streaming(&)
24
- raise NotImplementedError, "#{self.class} must implement #start_streaming"
25
- end
26
-
27
- # Stop streaming database changes
28
- #
29
- # @return [void]
30
- # @raise [NotImplementedError] Must be implemented by subclasses
31
- def stop_streaming
32
- raise NotImplementedError, "#{self.class} must implement #stop_streaming"
33
- end
34
-
35
- # Get current replication position
36
- #
37
- # @return [String, nil] Current position or nil if not available
38
- # @raise [NotImplementedError] Must be implemented by subclasses
39
- def current_position
40
- raise NotImplementedError, "#{self.class} must implement #current_position"
41
- end
42
-
43
- # Check if adapter is currently streaming
44
- #
45
- # @return [Boolean]
46
- def streaming?
47
- @running
48
- end
49
-
50
- # Set up the database for streaming (create publications, slots, etc.)
51
- #
52
- # @return [void]
53
- # @raise [NotImplementedError] Must be implemented by subclasses
54
- def setup
55
- raise NotImplementedError, "#{self.class} must implement #setup"
56
- end
57
-
58
- # Tear down streaming setup (remove publications, slots, etc.)
59
- #
60
- # @return [void]
61
- # @raise [NotImplementedError] Must be implemented by subclasses
62
- def teardown
63
- raise NotImplementedError, "#{self.class} must implement #teardown"
64
- end
65
-
66
- # Test connection to the database
67
- #
68
- # @return [Boolean] true if connection is successful
69
- # @raise [NotImplementedError] Must be implemented by subclasses
70
- def test_connection
71
- raise NotImplementedError, "#{self.class} must implement #test_connection"
72
- end
73
-
74
- protected
75
-
76
- attr_writer :running, :position
77
-
78
- # Log a message with context
79
- #
80
- # @param level [Symbol] Log level (:info, :warn, :error, etc.)
81
- # @param message [String] Log message
82
- # @param context [Hash] Additional context
83
- def log(level, message, context = {})
84
- logger.public_send(level, message,
85
- adapter: self.class.name.split('::').last,
86
- position: current_position,
87
- **context)
88
- end
89
- end
90
- end
91
- end
@@ -1,120 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Whodunit
4
- module Chronicles
5
- # Handles table creation for different database adapters
6
- #
7
- # Provides adapter-specific SQL for creating chronicles tables
8
- module Table
9
- private
10
-
11
- def ensure_table_exists
12
- db_type = detect_database_type(@audit_database_url || Chronicles.config.database_url)
13
-
14
- case db_type
15
- when :postgresql
16
- create_postgresql_table
17
- when :mysql
18
- create_mysql_table
19
- end
20
- end
21
-
22
- def create_postgresql_table
23
- create_sql = <<~SQL
24
- CREATE TABLE IF NOT EXISTS whodunit_chronicles_audits (
25
- id BIGSERIAL PRIMARY KEY,
26
- table_name TEXT NOT NULL,
27
- schema_name TEXT NOT NULL DEFAULT 'public',
28
- record_id JSONB,
29
- action TEXT NOT NULL CHECK (action IN ('INSERT', 'UPDATE', 'DELETE')),
30
- old_data JSONB,
31
- new_data JSONB,
32
- changes JSONB,
33
- user_id BIGINT,
34
- user_type TEXT,
35
- transaction_id TEXT,
36
- sequence_number INTEGER,
37
- occurred_at TIMESTAMP WITH TIME ZONE NOT NULL,
38
- created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
39
- metadata JSONB DEFAULT '{}'::jsonb,
40
- #{' '}
41
- -- Indexes for performance
42
- CONSTRAINT valid_data_for_action CHECK (
43
- (action = 'INSERT' AND old_data IS NULL AND new_data IS NOT NULL) OR
44
- (action = 'UPDATE' AND old_data IS NOT NULL AND new_data IS NOT NULL) OR#{' '}
45
- (action = 'DELETE' AND old_data IS NOT NULL AND new_data IS NULL)
46
- )
47
- );
48
-
49
- -- Performance indexes
50
- CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_chronicles_audits_table_record#{' '}
51
- ON whodunit_chronicles_audits (table_name, (record_id->>'id'));
52
-
53
- CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_chronicles_audits_occurred_at#{' '}
54
- ON whodunit_chronicles_audits (occurred_at DESC);
55
-
56
- CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_chronicles_audits_user#{' '}
57
- ON whodunit_chronicles_audits (user_id, user_type);
58
-
59
- CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_chronicles_audits_action#{' '}
60
- ON whodunit_chronicles_audits (action);
61
-
62
- -- GIN index for JSONB columns
63
- CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_chronicles_audits_record_id_gin#{' '}
64
- ON whodunit_chronicles_audits USING GIN (record_id);
65
-
66
- CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_chronicles_audits_changes_gin#{' '}
67
- ON whodunit_chronicles_audits USING GIN (changes);
68
- SQL
69
-
70
- @connection.exec(create_sql)
71
- rescue PG::Error => e
72
- # Ignore "already exists" errors from CONCURRENTLY
73
- raise unless e.message.include?('already exists')
74
- end
75
-
76
- def create_mysql_table
77
- create_sql = <<~SQL
78
- CREATE TABLE IF NOT EXISTS whodunit_chronicles_audits (
79
- id BIGINT AUTO_INCREMENT PRIMARY KEY,
80
- table_name TEXT NOT NULL,
81
- schema_name TEXT NOT NULL DEFAULT 'public',
82
- record_id JSON,
83
- action TEXT NOT NULL CHECK (action IN ('INSERT', 'UPDATE', 'DELETE')),
84
- old_data JSON,
85
- new_data JSON,
86
- changes JSON,
87
- user_id BIGINT,
88
- user_type TEXT,
89
- transaction_id TEXT,
90
- sequence_number INTEGER,
91
- occurred_at TIMESTAMP NOT NULL,
92
- created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
93
- metadata JSON DEFAULT (JSON_OBJECT()),
94
- #{' '}
95
- -- Constraint for data integrity
96
- CONSTRAINT valid_data_for_action CHECK (
97
- (action = 'INSERT' AND old_data IS NULL AND new_data IS NOT NULL) OR
98
- (action = 'UPDATE' AND old_data IS NOT NULL AND new_data IS NOT NULL) OR
99
- (action = 'DELETE' AND old_data IS NOT NULL AND new_data IS NULL)
100
- ),
101
- #{' '}
102
- -- Performance indexes
103
- INDEX idx_chronicles_audits_table_record (table_name(255), (JSON_UNQUOTE(JSON_EXTRACT(record_id, '$.id')))),
104
- INDEX idx_chronicles_audits_occurred_at (occurred_at DESC),
105
- INDEX idx_chronicles_audits_user (user_id, user_type(255)),
106
- INDEX idx_chronicles_audits_action (action(50))
107
- );
108
- SQL
109
-
110
- @connection.query(create_sql)
111
- rescue StandardError => e
112
- # Ignore "already exists" errors
113
- unless e.message.include?('already exists') ||
114
- (e.message.include?('Table') && e.message.include?('already exists'))
115
- raise
116
- end
117
- end
118
- end
119
- end
120
- end
@@ -1,79 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative 'lib/whodunit/chronicles/version'
4
-
5
- Gem::Specification.new do |spec|
6
- spec.name = 'whodunit-chronicles'
7
- spec.version = Whodunit::Chronicles::VERSION
8
- spec.authors = ['Ken C. Demanawa', 'Spherical Cow']
9
- spec.email = ['kenneth.c.demanawa@gmail.com']
10
-
11
- spec.summary = 'The complete historical record of your data'
12
- spec.description = 'While Whodunit tracks who made changes, Chronicles captures ' \
13
- 'what changed by streaming database events into comprehensive ' \
14
- 'audit trails with zero Rails application overhead.'
15
- spec.homepage = 'https://github.com/kanutocd/whodunit-chronicles'
16
- spec.license = 'MIT'
17
- spec.required_ruby_version = '>= 3.2.0'
18
-
19
- spec.metadata['allowed_push_host'] = 'https://rubygems.org'
20
- spec.metadata['homepage_uri'] = spec.homepage
21
- spec.metadata['source_code_uri'] = spec.homepage
22
- spec.metadata['changelog_uri'] = "#{spec.homepage}/blob/main/CHANGELOG.md"
23
- spec.metadata['rubygems_mfa_required'] = 'true'
24
-
25
- # Specify which files should be added to the gem when it is released.
26
- spec.files = Dir.chdir(__dir__) do
27
- `git ls-files -z`.split("\x0").reject do |f|
28
- (File.expand_path(f) == __FILE__) ||
29
- f.start_with?(*%w[bin/ test/ spec/ features/ .git .github appveyor Gemfile])
30
- end
31
- end
32
- spec.bindir = 'exe'
33
- spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
34
- spec.require_paths = ['lib']
35
-
36
- # ── Core runtime dependencies ───────────────────────────────
37
- spec.add_dependency 'concurrent-ruby', '~> 1.2'
38
- spec.add_dependency 'dry-configurable', '~> 1.0'
39
- spec.add_dependency 'dry-logger', '~> 1.0'
40
-
41
- # ── Database adapters — OPTIONAL at runtime ───────────────────────────────
42
- #
43
- # Chronicles lazy-loads the driver that matches your configured adapter.
44
- # You only need to install the gem for the database(s) you actually use:
45
- #
46
- # PostgreSQL → gem 'pg', '~> 1.5'
47
- # MySQL/MariaDB → gem 'trilogy', '~> 2.9'
48
- #
49
- # Both are listed here so `bundle install` in development installs them,
50
- # but they are NOT required at gem load time — only when the adapter loads.
51
- spec.add_development_dependency 'pg', '~> 1.5'
52
- spec.add_development_dependency 'trilogy', '~> 2.9'
53
-
54
- # bigdecimal: required by trilogy on Ruby 3.4+ (removed from stdlib).
55
- # Declared here so CI on Ruby 3.4+ doesn't break. Trilogy should own this
56
- # dependency — track https://github.com/trilogy-libraries/trilogy/issues
57
- # Required for Ruby 3.4+ compatibility (trilogy dependency)
58
- spec.add_development_dependency 'bigdecimal', '~> 3.1'
59
-
60
- # ── Development tooling ───────────────────────────────
61
- spec.add_development_dependency 'kramdown', '~> 2.5'
62
- spec.add_development_dependency 'minitest', '~> 5.20'
63
- spec.add_development_dependency 'mocha', '~> 2.1'
64
- spec.add_development_dependency 'pry', '~> 0.14'
65
- spec.add_development_dependency 'rake', '~> 13.0'
66
- spec.add_development_dependency 'rspec_junit_formatter', '~> 0.6.0'
67
- spec.add_development_dependency 'rubocop', '~> 1.60'
68
- spec.add_development_dependency 'rubocop-minitest', '~> 0.34'
69
- spec.add_development_dependency 'rubocop-performance', '~> 1.19'
70
- spec.add_development_dependency 'rubocop-rake', '~> 0.6'
71
- spec.add_development_dependency 'rubocop-thread_safety', '~> 0.5'
72
- spec.add_development_dependency 'simplecov', '~> 0.22'
73
- spec.add_development_dependency 'simplecov-cobertura', '~> 3.0'
74
- spec.add_development_dependency 'yard', '~> 0.9'
75
-
76
- # ── Security scanning dependencies ──────────────────────────────
77
- spec.add_development_dependency 'brakeman', '~> 7.1'
78
- spec.add_development_dependency 'bundler-audit', '~> 0.9'
79
- end