bullematic 0.1.0 → 0.2.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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +18 -1
  3. data/README.md +23 -11
  4. data/exe/bullematic +7 -0
  5. data/lib/bullematic/ast/finder.rb +190 -35
  6. data/lib/bullematic/ast/parser.rb +1 -17
  7. data/lib/bullematic/ast/rewriter.rb +90 -28
  8. data/lib/bullematic/cli.rb +185 -0
  9. data/lib/bullematic/configuration.rb +15 -4
  10. data/lib/bullematic/detection.rb +110 -13
  11. data/lib/bullematic/evidence_store.rb +78 -0
  12. data/lib/bullematic/fixer.rb +258 -31
  13. data/lib/bullematic/integrations/minitest.rb +20 -6
  14. data/lib/bullematic/integrations/rails.rb +23 -9
  15. data/lib/bullematic/integrations/rspec.rb +19 -7
  16. data/lib/bullematic/logger.rb +24 -12
  17. data/lib/bullematic/notifier.rb +131 -36
  18. data/lib/bullematic/version.rb +1 -1
  19. data/lib/bullematic.rb +12 -4
  20. data/sig/generated/bullematic/ast/finder.rbs +70 -11
  21. data/sig/generated/bullematic/ast/parser.rbs +0 -6
  22. data/sig/generated/bullematic/ast/rewriter.rbs +28 -11
  23. data/sig/generated/bullematic/cli.rbs +56 -0
  24. data/sig/generated/bullematic/configuration.rbs +16 -20
  25. data/sig/generated/bullematic/detection.rbs +77 -7
  26. data/sig/generated/bullematic/evidence_store.rbs +35 -0
  27. data/sig/generated/bullematic/fixer.rbs +59 -3
  28. data/sig/generated/bullematic/integrations/rails.rbs +7 -0
  29. data/sig/generated/bullematic/logger.rbs +5 -0
  30. data/sig/generated/bullematic/notifier.rbs +17 -13
  31. data/sig/generated/bullematic.rbs +5 -0
  32. data/sig/stubs/rails.rbs +60 -0
  33. metadata +161 -8
  34. data/Rakefile +0 -26
  35. data/Steepfile +0 -15
  36. data/assets/logo-header.svg +0 -28
  37. data/rbs_collection.lock.yaml +0 -24
  38. data/rbs_collection.yaml +0 -14
@@ -1,37 +1,49 @@
1
1
  # rbs_inline: enabled
2
2
  # frozen_string_literal: true
3
3
 
4
- require "fileutils"
4
+ require "digest"
5
+ require "tempfile"
6
+ require "tmpdir"
5
7
 
6
8
  module Bullematic
7
9
  class Fixer
8
10
  class << self
9
11
  #: () -> Array[Detection]
10
12
  def detection_queue
11
- @detection_queue ||= [] #: Array[Detection]
13
+ queue_mutex.synchronize { queue_store.dup }
12
14
  end
13
15
 
14
16
  # @rbs detection: Detection
15
17
  # @rbs return: void
16
18
  def queue(detection)
17
- detection_queue << detection
19
+ if EvidenceStore.recording?
20
+ EvidenceStore.append(detection)
21
+ return
22
+ end
23
+
24
+ queue_mutex.synchronize do
25
+ queue_store << detection unless queue_store.any? { |queued| queued.fingerprint == detection.fingerprint }
26
+ end
18
27
  end
19
28
 
20
29
  #: () -> void
21
30
  def clear
22
- @detection_queue = [] #: Array[Detection]
31
+ queue_mutex.synchronize { @detection_queue = [] }
23
32
  end
24
33
 
25
- #: () -> void
34
+ #: () -> Hash[Symbol, Integer]
26
35
  def apply_fixes
27
- return if detection_queue.empty?
28
-
29
36
  logger = BullematicLogger.new
37
+ pending = drain_queue
38
+ return logger.stats if pending.empty?
30
39
 
31
- grouped = detection_queue.group_by(&:source_file)
40
+ grouped = pending.group_by(&:source_file)
32
41
 
33
42
  grouped.each do |filepath, detections|
34
- next unless filepath
43
+ unless filepath
44
+ detections.each { |detection| logger.log_skip("(unknown)", detection, "source unavailable") }
45
+ next
46
+ end
35
47
 
36
48
  process_file(filepath, detections, logger)
37
49
  rescue ParseError, FixError => e
@@ -42,44 +54,84 @@ module Bullematic
42
54
  end
43
55
 
44
56
  logger.log_summary
45
- clear
57
+ logger.stats
46
58
  end
47
59
 
48
60
  private
49
61
 
62
+ #: () -> Array[Detection]
63
+ def queue_store
64
+ @detection_queue ||= [] #: Array[Detection]
65
+ end
66
+
67
+ #: () -> Mutex
68
+ def queue_mutex
69
+ @queue_mutex ||= Mutex.new
70
+ end
71
+
72
+ #: () -> Array[Detection]
73
+ def drain_queue
74
+ queue_mutex.synchronize do
75
+ pending = queue_store
76
+ @detection_queue = []
77
+ pending
78
+ end
79
+ end
80
+
50
81
  # @rbs filepath: String
51
82
  # @rbs detections: Array[Detection]
52
83
  # @rbs logger: BullematicLogger
53
84
  # @rbs return: void
54
85
  def process_file(filepath, detections, logger)
55
- return unless File.exist?(filepath)
86
+ detections = detections.reject do |detection|
87
+ unsafe = !detection.fixable?
88
+ logger.log_skip(filepath, detection, "outside configured fix scope") if unsafe
89
+ unsafe
90
+ end
91
+ return if detections.empty?
92
+
93
+ unless File.exist?(filepath)
94
+ detections.each { |detection| logger.log_skip(filepath, detection, "source file not found") }
95
+ return
96
+ end
97
+
98
+ original_source = File.binread(filepath)
99
+ expected_digest = Digest::SHA256.digest(original_source)
100
+ current_hexdigest = Digest::SHA256.hexdigest(original_source)
101
+ detections = detections.reject do |detection|
102
+ stale = detection.source_digest && detection.source_digest != current_hexdigest
103
+ logger.log_skip(filepath, detection, "source changed since detection") if stale
104
+ stale
105
+ end
106
+ return if detections.empty?
56
107
 
57
- original_source = File.read(filepath)
58
- parse_result = AST::Parser.parse_file(filepath)
108
+ parse_result = AST::Parser.new(original_source, filepath: filepath).parse
59
109
  finder = AST::Finder.new(parse_result)
60
110
  rewriter = AST::Rewriter.new(original_source)
111
+ changed = [] #: Array[Detection]
61
112
 
62
- detections.each do |detection|
63
- location = find_query_location(finder, detection)
64
-
65
- if location.nil?
66
- logger.log_skip(filepath, detection, "could not locate query")
67
- next
113
+ build_requests(finder, detections, logger, filepath).each do |request|
114
+ detection = request[:detection]
115
+ result = rewriter.add_includes(request[:location], request[:associations])
116
+ if result == :changed
117
+ changed << detection
118
+ else
119
+ logger.log_skip(filepath, detection, result.to_s.tr("_", " "))
68
120
  end
69
-
70
- rewriter.add_includes(location, detection.associations)
71
- logger.log_fix(filepath, detection)
72
121
  end
73
122
 
74
123
  new_source = rewriter.rewrite
75
124
 
76
125
  return if new_source == original_source
77
126
 
127
+ AST::Parser.new(new_source, filepath: filepath).parse
128
+
78
129
  if Bullematic.configuration&.dry_run
79
130
  logger.log_dry_run(filepath, original_source, new_source)
131
+ changed.each { |detection| logger.log_plan(filepath, detection) }
80
132
  else
81
- backup_file(filepath) if Bullematic.configuration&.backup
82
- File.write(filepath, new_source)
133
+ atomic_write(filepath, new_source, expected_digest)
134
+ changed.each { |detection| logger.log_fix(filepath, detection) }
83
135
  end
84
136
  end
85
137
 
@@ -87,23 +139,198 @@ module Bullematic
87
139
  # @rbs detection: Detection
88
140
  # @rbs return: AST::Finder::QueryLocation?
89
141
  def find_query_location(finder, detection)
90
- queries = finder.find_model_queries(
142
+ return nil unless detection.line_number
143
+
144
+ queries = finder.find_model_queries_for_variables_at_line(
91
145
  detection.model_class_name,
92
- line_number: detection.line_number
146
+ detection.line_number,
147
+ detection.associations
93
148
  )
149
+ queries.one? ? queries.first : nil
150
+ end
151
+
152
+ # @rbs finder: AST::Finder
153
+ # @rbs detections: Array[Detection]
154
+ # @rbs logger: BullematicLogger
155
+ # @rbs filepath: String
156
+ # @rbs return: Array[Hash[Symbol, untyped]]
157
+ def build_requests(finder, detections, logger, filepath)
158
+ requests = [] #: Array[Hash[Symbol, untyped]]
159
+ unresolved = [] #: Array[Detection]
94
160
 
95
- return queries.first unless queries.empty?
161
+ detections.each do |detection|
162
+ unless valid_associations?(detection)
163
+ logger.log_skip(filepath, detection, "invalid association")
164
+ next
165
+ end
96
166
 
97
- return nil unless detection.line_number
167
+ location = find_query_location(finder, detection)
168
+ if location
169
+ requests << { detection: detection, location: location, associations: detection.associations }
170
+ else
171
+ unresolved << detection
172
+ end
173
+ end
174
+
175
+ consumed = [] #: Array[Detection]
176
+ requests.each do |request|
177
+ request[:associations] = association_tree(request[:detection], unresolved, consumed, [], finder)
178
+ end
179
+ (unresolved - consumed).each { |detection| logger.log_skip(filepath, detection, "could not locate query") }
180
+ requests
181
+ end
182
+
183
+ # @rbs detection: Detection
184
+ # @rbs return: bool
185
+ def valid_associations?(detection)
186
+ model = constantize_model(detection.model_class_name)
187
+ return false unless model&.respond_to?(:reflect_on_association)
188
+
189
+ detection.associations.all? do |association|
190
+ reflection = model.reflect_on_association(association)
191
+ reflection && !reflection.polymorphic?
192
+ end
193
+ rescue NameError
194
+ false
195
+ end
196
+
197
+ # @rbs detection: Detection
198
+ # @rbs candidates: Array[Detection]
199
+ # @rbs consumed: Array[Detection]
200
+ # @rbs seen: Array[String]
201
+ # @rbs finder: AST::Finder
202
+ # @rbs return: Array[untyped]
203
+ def association_tree(detection, candidates, consumed, seen, finder)
204
+ return detection.associations if seen.include?(detection.model_class_name)
205
+
206
+ detection.associations.map do |association|
207
+ child = nested_detection(detection, association, candidates - consumed, finder)
208
+ next association unless child
98
209
 
99
- finder.find_query_at_line(detection.line_number)
210
+ consumed << child
211
+ nested = association_tree(child, candidates, consumed, seen + [detection.model_class_name], finder)
212
+ { association => nested.one? ? nested.first : nested }
213
+ end
214
+ end
215
+
216
+ # @rbs parent: Detection
217
+ # @rbs association: Symbol
218
+ # @rbs candidates: Array[Detection]
219
+ # @rbs finder: AST::Finder
220
+ # @rbs return: Detection?
221
+ def nested_detection(parent, association, candidates, finder)
222
+ return nil unless parent.context_id
223
+
224
+ model = constantize_model(parent.model_class_name)
225
+ return nil unless model&.respond_to?(:reflect_on_association)
226
+
227
+ reflection = model.reflect_on_association(association)
228
+ return nil unless reflection && !reflection.polymorphic?
229
+
230
+ child_class_name = reflection.klass.name
231
+ return nil unless parent.associations.one? do |parent_association|
232
+ candidate = model.reflect_on_association(parent_association)
233
+ candidate && !candidate.polymorphic? && candidate.klass.name == child_class_name
234
+ end
235
+
236
+ matches = candidates.select do |candidate|
237
+ candidate.context_id == parent.context_id &&
238
+ same_execution_location?(parent, candidate) &&
239
+ candidate.model_class_name == child_class_name
240
+ end
241
+ return nil unless matches.one?
242
+
243
+ child = matches.first
244
+ finder.nested_association?(association, child.associations, child.line_number) ? child : nil
245
+ rescue NameError
246
+ nil
247
+ end
248
+
249
+ # @rbs detection: Detection
250
+ # @rbs return: String?
251
+ def normalized_method(detection)
252
+ detection.method_name&.split(" in ")&.last&.sub(/\Ablock /, "")&.split("#")&.last
253
+ end
254
+
255
+ # @rbs left: Detection
256
+ # @rbs right: Detection
257
+ # @rbs return: bool
258
+ def same_execution_location?(left, right)
259
+ left_method = normalized_method(left)
260
+ right_method = normalized_method(right)
261
+ return left_method == right_method if left_method && right_method
262
+
263
+ left.line_number == right.line_number
264
+ end
265
+
266
+ # @rbs name: String
267
+ # @rbs return: untyped
268
+ def constantize_model(name)
269
+ normalized = name.delete_prefix("::")
270
+ return nil unless normalized.match?(/\A[A-Z]\w*(?:::[A-Z]\w*)*\z/)
271
+
272
+ normalized.split("::").reduce(Object) { |scope, constant| scope.const_get(constant, false) }
273
+ end
274
+
275
+ # @rbs filepath: String
276
+ # @rbs source: String
277
+ # @rbs expected_digest: String
278
+ # @rbs return: void
279
+ def atomic_write(filepath, source, expected_digest)
280
+ raise FixError, "symlink sources are unsupported" if File.symlink?(filepath)
281
+ raise FixError, "read-only sources are unsupported" if (File.stat(filepath).mode & 0o222).zero?
282
+
283
+ lock_name = "bullematic-#{Digest::SHA256.hexdigest(File.expand_path(filepath))}.lock"
284
+ File.open(File.join(Dir.tmpdir, lock_name), File::RDWR | File::CREAT, 0o600) do |lock|
285
+ lock.flock(File::LOCK_EX)
286
+ current_source = File.binread(filepath)
287
+ raise FixError, "source changed while planning fix" unless Digest::SHA256.digest(current_source) == expected_digest
288
+
289
+ mode = File.stat(filepath).mode
290
+ backup_file(filepath, current_source, mode) if Bullematic.configuration&.backup
291
+ Tempfile.create([".bullematic", ".tmp"], File.dirname(filepath)) do |tempfile|
292
+ tempfile.binmode
293
+ tempfile.write(source)
294
+ tempfile.flush
295
+ tempfile.fsync
296
+ File.chmod(mode, tempfile.path)
297
+ tempfile.close
298
+ File.rename(tempfile.path, filepath)
299
+ end
300
+ begin
301
+ File.open(File.dirname(filepath), File::RDONLY, &:fsync)
302
+ rescue Errno::EACCES, Errno::EINVAL, Errno::EISDIR
303
+ # Directory fsync is unavailable on some filesystems and Windows.
304
+ end
305
+ end
100
306
  end
101
307
 
102
308
  # @rbs filepath: String
309
+ # @rbs source: String
310
+ # @rbs mode: Integer
103
311
  # @rbs return: void
104
- def backup_file(filepath)
312
+ def backup_file(filepath, source, mode)
105
313
  backup_path = "#{filepath}.bullematic.bak"
106
- FileUtils.cp(filepath, backup_path)
314
+ raise FixError, "symlink backup files are unsupported" if File.symlink?(backup_path)
315
+ if File.exist?(backup_path)
316
+ raise FixError, "backup path is not a regular file" unless File.file?(backup_path)
317
+
318
+ return
319
+ end
320
+
321
+ flags = File::WRONLY | File::CREAT | File::EXCL
322
+ flags |= File::NOFOLLOW if File.const_defined?(:NOFOLLOW)
323
+ begin
324
+ File.open(backup_path, flags, mode & 0o777) do |backup|
325
+ backup.binmode
326
+ backup.write(source)
327
+ backup.flush
328
+ backup.fsync
329
+ end
330
+ rescue Errno::EEXIST, Errno::ELOOP
331
+ raise FixError, "symlink backup files are unsupported" if File.symlink?(backup_path)
332
+ raise FixError, "backup path is not a regular file" unless File.file?(backup_path)
333
+ end
107
334
  end
108
335
  end
109
336
  end
@@ -7,6 +7,8 @@ module Bullematic
7
7
  class << self
8
8
  #: () -> void
9
9
  def setup
10
+ Bullematic::Fixer.clear
11
+
10
12
  ::Minitest.after_run do
11
13
  Bullematic::Fixer.apply_fixes if Bullematic.configuration&.enabled && Bullematic.configuration&.auto_fix
12
14
  end
@@ -17,19 +19,31 @@ module Bullematic
17
19
  #: () -> void
18
20
  def setup
19
21
  super
22
+ @bullematic_request_started = false
20
23
  return unless Bullematic.enabled?
21
24
 
22
- Bullet.start_request if defined?(Bullet) && Bullet.enable?
23
- Bullematic::Fixer.clear
25
+ if defined?(Bullet) && Bullet.enable?
26
+ Bullet.start_request
27
+ @bullematic_request_started = true
28
+ end
24
29
  end
25
30
 
26
31
  #: () -> void
27
32
  def teardown
28
- if Bullematic.enabled? && defined?(Bullet) && Bullet.enable?
29
- Bullematic::Notifier.process_notifications if Bullet.notification?
30
- Bullet.end_request
33
+ begin
34
+ if @bullematic_request_started
35
+ begin
36
+ if Bullematic.enabled? && Bullet.enable?
37
+ Bullematic::Notifier.process_notifications(context_id: object_id)
38
+ end
39
+ ensure
40
+ Bullet.end_request
41
+ @bullematic_request_started = false
42
+ end
43
+ end
44
+ ensure
45
+ super
31
46
  end
32
- super
33
47
  end
34
48
  end
35
49
  end
@@ -4,12 +4,22 @@
4
4
  module Bullematic
5
5
  module Integrations
6
6
  class Railtie < ::Rails::Railtie
7
+ BULLET_MIDDLEWARE_INITIALIZER = Bullet::BulletRailtie.initializers.find do |initializer|
8
+ initializer.name.start_with?("bullet.")
9
+ end&.name
10
+
7
11
  initializer "bullematic.configure" do |_app|
8
12
  Bullematic.setup_bullet_hook if Rails.env.development? || Rails.env.test?
9
13
  end
10
14
 
11
- initializer "bullematic.middleware" do |app|
12
- app.middleware.use Bullematic::Middleware if Rails.env.development?
15
+ initializer "bullematic.middleware", after: BULLET_MIDDLEWARE_INITIALIZER do |app|
16
+ next unless Rails.env.development?
17
+
18
+ if defined?(Bullet::Rack)
19
+ app.middleware.insert_after Bullet::Rack, Bullematic::Middleware
20
+ else
21
+ app.middleware.use Bullematic::Middleware
22
+ end
13
23
  end
14
24
  end
15
25
  end
@@ -26,15 +36,19 @@ module Bullematic
26
36
  # @rbs env: Hash[String, untyped]
27
37
  # @rbs return: untyped
28
38
  def call(env)
29
- return @app.call(env) unless Bullematic.enabled?
30
-
31
- Bullematic::Fixer.clear
32
-
33
- response = @app.call(env)
39
+ @app.call(env)
40
+ ensure
41
+ capture_notifications(env)
42
+ end
34
43
 
35
- Bullematic::Fixer.apply_fixes if Bullematic.configuration&.auto_fix
44
+ private
36
45
 
37
- response
46
+ # @rbs env: Hash[String, untyped]
47
+ # @rbs return: void
48
+ def capture_notifications(env)
49
+ if Bullematic.enabled? && EvidenceStore.recording? && defined?(Bullet)
50
+ Bullematic::Notifier.process_notifications(context_id: env.object_id)
51
+ end
38
52
  end
39
53
  end
40
54
  end
@@ -8,17 +8,29 @@ module Bullematic
8
8
  #: () -> void
9
9
  def setup
10
10
  ::RSpec.configure do |config|
11
- config.before(:each) do
11
+ config.before(:suite) do
12
+ Bullematic::Fixer.clear
13
+ end
14
+
15
+ config.before(:each) do |example|
16
+ example.metadata[:bullematic_request_started] = false
12
17
  if Bullematic.enabled?
13
- Bullet.start_request if defined?(Bullet) && Bullet.enable?
14
- Bullematic::Fixer.clear
18
+ if defined?(Bullet) && Bullet.enable?
19
+ Bullet.start_request
20
+ example.metadata[:bullematic_request_started] = true
21
+ end
15
22
  end
16
23
  end
17
24
 
18
- config.after(:each) do
19
- if Bullematic.enabled? && defined?(Bullet) && Bullet.enable?
20
- Bullematic::Notifier.process_notifications if Bullet.notification?
21
- Bullet.end_request
25
+ config.after(:each) do |example|
26
+ if example.metadata.delete(:bullematic_request_started)
27
+ begin
28
+ if Bullematic.enabled? && Bullet.enable?
29
+ Bullematic::Notifier.process_notifications(context_id: example.id)
30
+ end
31
+ ensure
32
+ Bullet.end_request
33
+ end
22
34
  end
23
35
  end
24
36
 
@@ -14,7 +14,7 @@ module Bullematic
14
14
  # @rbs return: void
15
15
  def initialize(logger = nil)
16
16
  @logger = logger || Bullematic.configuration&.logger || ::Logger.new($stdout)
17
- @stats = { fixed: 0, skipped: 0, errors: 0 }
17
+ @stats = { fixed: 0, planned: 0, skipped: 0, errors: 0 }
18
18
  end
19
19
 
20
20
  # @rbs message: String
@@ -53,6 +53,14 @@ module Bullematic
53
53
  info(" Association: #{detection.associations.inspect}")
54
54
  end
55
55
 
56
+ # @rbs filepath: String
57
+ # @rbs detection: Detection
58
+ # @rbs return: void
59
+ def log_plan(filepath, detection)
60
+ @stats[:planned] += 1
61
+ info("Planned N+1 fix in #{filepath}:#{detection.line_number}")
62
+ end
63
+
56
64
  # @rbs filepath: String
57
65
  # @rbs detection: Detection
58
66
  # @rbs reason: String
@@ -77,27 +85,31 @@ module Bullematic
77
85
  # @rbs modified: String
78
86
  # @rbs return: void
79
87
  def log_dry_run(filepath, original, modified)
80
- info("[DRY RUN] Would modify #{filepath}:")
81
-
82
88
  original_lines = original.lines
83
89
  modified_lines = modified.lines
84
-
85
- modified_lines.each_with_index do |line, idx|
86
- next if original_lines[idx] == line
87
-
88
- info(" - #{original_lines[idx]&.chomp}")
89
- info(" + #{line.chomp}")
90
- end
90
+ prefix = 0
91
+ prefix += 1 while original_lines[prefix] == modified_lines[prefix] && prefix < original_lines.size
92
+ suffix = 0
93
+ suffix += 1 while suffix < original_lines.size - prefix && suffix < modified_lines.size - prefix &&
94
+ original_lines[-suffix - 1] == modified_lines[-suffix - 1]
95
+ removed = original_lines.slice(prefix, original_lines.size - prefix - suffix) || []
96
+ added = modified_lines.slice(prefix, modified_lines.size - prefix - suffix) || []
97
+ diff = ["[DRY RUN] Would modify #{filepath}:\n", "--- #{filepath}\n", "+++ #{filepath}\n",
98
+ "@@ -#{prefix + 1},#{removed.size} +#{prefix + 1},#{added.size} @@\n"]
99
+ removed.each { |line| diff << "-#{line.encode('UTF-8', invalid: :replace, undef: :replace)}" }
100
+ added.each { |line| diff << "+#{line.encode('UTF-8', invalid: :replace, undef: :replace)}" }
101
+ info(diff.join)
91
102
  end
92
103
 
93
104
  #: () -> void
94
105
  def log_summary
95
- info("Summary: #{@stats[:fixed]} fixed, #{@stats[:skipped]} skipped, #{@stats[:errors]} errors")
106
+ info("Summary: #{@stats[:fixed]} fixed, #{@stats[:planned]} planned, " \
107
+ "#{@stats[:skipped]} skipped, #{@stats[:errors]} errors")
96
108
  end
97
109
 
98
110
  #: () -> void
99
111
  def reset_stats
100
- @stats = { fixed: 0, skipped: 0, errors: 0 }
112
+ @stats = { fixed: 0, planned: 0, skipped: 0, errors: 0 }
101
113
  end
102
114
  end
103
115
  end