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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +18 -1
- data/README.md +23 -11
- data/exe/bullematic +7 -0
- data/lib/bullematic/ast/finder.rb +190 -35
- data/lib/bullematic/ast/parser.rb +1 -17
- data/lib/bullematic/ast/rewriter.rb +90 -28
- data/lib/bullematic/cli.rb +185 -0
- data/lib/bullematic/configuration.rb +15 -4
- data/lib/bullematic/detection.rb +110 -13
- data/lib/bullematic/evidence_store.rb +78 -0
- data/lib/bullematic/fixer.rb +258 -31
- data/lib/bullematic/integrations/minitest.rb +20 -6
- data/lib/bullematic/integrations/rails.rb +23 -9
- data/lib/bullematic/integrations/rspec.rb +19 -7
- data/lib/bullematic/logger.rb +24 -12
- data/lib/bullematic/notifier.rb +131 -36
- data/lib/bullematic/version.rb +1 -1
- data/lib/bullematic.rb +12 -4
- data/sig/generated/bullematic/ast/finder.rbs +70 -11
- data/sig/generated/bullematic/ast/parser.rbs +0 -6
- data/sig/generated/bullematic/ast/rewriter.rbs +28 -11
- data/sig/generated/bullematic/cli.rbs +56 -0
- data/sig/generated/bullematic/configuration.rbs +16 -20
- data/sig/generated/bullematic/detection.rbs +77 -7
- data/sig/generated/bullematic/evidence_store.rbs +35 -0
- data/sig/generated/bullematic/fixer.rbs +59 -3
- data/sig/generated/bullematic/integrations/rails.rbs +7 -0
- data/sig/generated/bullematic/logger.rbs +5 -0
- data/sig/generated/bullematic/notifier.rbs +17 -13
- data/sig/generated/bullematic.rbs +5 -0
- data/sig/stubs/rails.rbs +60 -0
- metadata +161 -8
- data/Rakefile +0 -26
- data/Steepfile +0 -15
- data/assets/logo-header.svg +0 -28
- data/rbs_collection.lock.yaml +0 -24
- data/rbs_collection.yaml +0 -14
data/lib/bullematic/fixer.rb
CHANGED
|
@@ -1,37 +1,49 @@
|
|
|
1
1
|
# rbs_inline: enabled
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
|
-
require "
|
|
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
|
-
|
|
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
|
-
|
|
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 = []
|
|
31
|
+
queue_mutex.synchronize { @detection_queue = [] }
|
|
23
32
|
end
|
|
24
33
|
|
|
25
|
-
#: () ->
|
|
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 =
|
|
40
|
+
grouped = pending.group_by(&:source_file)
|
|
32
41
|
|
|
33
42
|
grouped.each do |filepath, detections|
|
|
34
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 |
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
if
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
82
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
23
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
response = @app.call(env)
|
|
39
|
+
@app.call(env)
|
|
40
|
+
ensure
|
|
41
|
+
capture_notifications(env)
|
|
42
|
+
end
|
|
34
43
|
|
|
35
|
-
|
|
44
|
+
private
|
|
36
45
|
|
|
37
|
-
|
|
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(:
|
|
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
|
-
|
|
14
|
-
|
|
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
|
|
20
|
-
|
|
21
|
-
|
|
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
|
|
data/lib/bullematic/logger.rb
CHANGED
|
@@ -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
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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[:
|
|
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
|