gemfile-audit 1.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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +108 -0
- data/exe/gemfile-audit +5 -0
- data/lib/gemfile_audit/ci.rb +85 -0
- data/lib/gemfile_audit/cli.rb +267 -0
- data/lib/gemfile_audit/collector.rb +295 -0
- data/lib/gemfile_audit/loader.rb +16 -0
- data/lib/gemfile_audit/measurement.rb +637 -0
- data/lib/gemfile_audit/shell.rb +70 -0
- data/lib/gemfile_audit/uploader.rb +60 -0
- data/lib/gemfile_audit/version.rb +3 -0
- data/lib/gemfile_audit.rb +20 -0
- metadata +99 -0
|
@@ -0,0 +1,637 @@
|
|
|
1
|
+
# Measures one Ruby process of the test run: line coverage of the project's
|
|
2
|
+
# own files, ActiveSupport deprecations, RSpec or Minitest counts and the
|
|
3
|
+
# database server version. At exit it writes one JSON file into the audit
|
|
4
|
+
# folder. Loaded by loader.rb only when the audit folder and root are set.
|
|
5
|
+
#
|
|
6
|
+
# Rules for this file, because it runs inside other people's processes:
|
|
7
|
+
# standard library only, Ruby 2.3 syntax and APIs, never print, never raise
|
|
8
|
+
# into the app and never change the app's behaviour, warnings or exit status.
|
|
9
|
+
module GemfileAudit
|
|
10
|
+
module Measurement
|
|
11
|
+
COVERAGE_EXCLUDED = %w(vendor/ node_modules/ tmp/ spec/ test/ features/ db/).freeze
|
|
12
|
+
LOCATION_EXCLUDED = %w(vendor/ node_modules/ tmp/).freeze
|
|
13
|
+
MAX_DEPRECATIONS = 5000
|
|
14
|
+
MAX_FRAMES = 200
|
|
15
|
+
MODULE_NAME = Module.instance_method(:name)
|
|
16
|
+
JSON_ESCAPES = {
|
|
17
|
+
"\"" => "\\\"", "\\" => "\\\\", "\b" => "\\b", "\f" => "\\f",
|
|
18
|
+
"\n" => "\\n", "\r" => "\\r", "\t" => "\\t"
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
# Prepended to the Coverage singleton. Keeps a copy of every result the
|
|
22
|
+
# app takes, so coverage survives tools such as SimpleCov stopping it,
|
|
23
|
+
# and hands coverage over when the app starts its own measurement.
|
|
24
|
+
module CoverageHooks
|
|
25
|
+
def start(*args)
|
|
26
|
+
GemfileAudit::Measurement.release_coverage
|
|
27
|
+
super
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def result(*args)
|
|
31
|
+
value = super
|
|
32
|
+
GemfileAudit::Measurement.keep_coverage(value, args)
|
|
33
|
+
value
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
ruby2_keywords(:start, :result) if respond_to?(:ruby2_keywords, true)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Coverage.setup exists from Ruby 3.1 only.
|
|
40
|
+
module CoverageSetupHook
|
|
41
|
+
def setup(*args)
|
|
42
|
+
GemfileAudit::Measurement.release_coverage
|
|
43
|
+
super
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
ruby2_keywords(:setup) if respond_to?(:ruby2_keywords, true)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Prepended to the SimpleCov singleton. SimpleCov 0.22 starts coverage
|
|
50
|
+
# only when it is not running, so hand our measurement over first and
|
|
51
|
+
# SimpleCov starts its own with the options it wants, such as branches.
|
|
52
|
+
# Coverage.running? itself always tells the truth: bootsnap relies on it.
|
|
53
|
+
module SimpleCovHooks
|
|
54
|
+
def start_coverage_measurement(*args)
|
|
55
|
+
GemfileAudit::Measurement.release_coverage
|
|
56
|
+
super
|
|
57
|
+
end
|
|
58
|
+
private :start_coverage_measurement
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Prepended to ActiveSupport::Deprecation. Records the warning, then
|
|
62
|
+
# always calls the original with the call stack Rails itself would have
|
|
63
|
+
# used, so messages and the :raise behaviour stay exactly the same.
|
|
64
|
+
module DeprecationHooks
|
|
65
|
+
def warn(message = nil, callstack = nil, *rest)
|
|
66
|
+
begin
|
|
67
|
+
if callstack.nil?
|
|
68
|
+
callstack = GemfileAudit::Measurement.string_callstack? ? caller(2) : caller_locations(2)
|
|
69
|
+
end
|
|
70
|
+
GemfileAudit::Measurement.record_deprecation(message, caller_locations(1))
|
|
71
|
+
rescue StandardError
|
|
72
|
+
nil
|
|
73
|
+
end
|
|
74
|
+
super(message, callstack, *rest)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Prepended to the Minitest singleton. Adds a counting reporter once
|
|
79
|
+
# Minitest has set up its own reporters for a run.
|
|
80
|
+
module MinitestHooks
|
|
81
|
+
def init_plugins(*args)
|
|
82
|
+
value = super
|
|
83
|
+
GemfileAudit::Measurement.add_minitest_counter(self)
|
|
84
|
+
value
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# A Minitest reporter that only counts results. It never fails a run.
|
|
89
|
+
class MinitestCounter
|
|
90
|
+
def start
|
|
91
|
+
GemfileAudit::Measurement.minitest_started
|
|
92
|
+
rescue StandardError
|
|
93
|
+
nil
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def prerecord(_klass, _name)
|
|
97
|
+
nil
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def record(result)
|
|
101
|
+
GemfileAudit::Measurement.minitest_record(result)
|
|
102
|
+
rescue StandardError
|
|
103
|
+
nil
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def report
|
|
107
|
+
nil
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def passed?
|
|
111
|
+
true
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
@active = false
|
|
116
|
+
|
|
117
|
+
class << self
|
|
118
|
+
def start(dir, root)
|
|
119
|
+
return false if @active
|
|
120
|
+
@active = true
|
|
121
|
+
@dir = File.expand_path(dir)
|
|
122
|
+
@pid = Process.pid
|
|
123
|
+
use_root(root, Dir.pwd)
|
|
124
|
+
@deprecations = []
|
|
125
|
+
@deprecations_pid = @pid
|
|
126
|
+
@minitest_counts = nil
|
|
127
|
+
@minitest_mutex = Mutex.new
|
|
128
|
+
@coverage_kept_pid = @pid
|
|
129
|
+
@lookup_running = false
|
|
130
|
+
@simplecov_hooked = false
|
|
131
|
+
@coverage_kept = {}
|
|
132
|
+
@own_coverage = false
|
|
133
|
+
@deprecation_hooked = false
|
|
134
|
+
@minitest_hooked = false
|
|
135
|
+
@trace = nil
|
|
136
|
+
start_coverage
|
|
137
|
+
watch_for_frameworks
|
|
138
|
+
at_exit { finish }
|
|
139
|
+
true
|
|
140
|
+
rescue StandardError, ScriptError
|
|
141
|
+
false
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def use_root(root, cwd)
|
|
145
|
+
@cwd = cwd
|
|
146
|
+
@roots = path_forms(root)
|
|
147
|
+
@gem_dirs = nil
|
|
148
|
+
@gem_dirs_with_bundler = nil
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Coverage
|
|
152
|
+
|
|
153
|
+
def start_coverage
|
|
154
|
+
require "coverage"
|
|
155
|
+
begin
|
|
156
|
+
if RUBY_VERSION >= "2.5"
|
|
157
|
+
Coverage.start(lines: true)
|
|
158
|
+
else
|
|
159
|
+
Coverage.start
|
|
160
|
+
end
|
|
161
|
+
@own_coverage = true
|
|
162
|
+
rescue StandardError
|
|
163
|
+
@own_coverage = false
|
|
164
|
+
end
|
|
165
|
+
hooks = Coverage.singleton_class
|
|
166
|
+
hooks.send(:prepend, CoverageHooks)
|
|
167
|
+
hooks.send(:prepend, CoverageSetupHook) if Coverage.respond_to?(:setup)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# The app is starting coverage itself while ours runs: take our counts
|
|
171
|
+
# and stop, so the app gets the fresh measurement it asked for.
|
|
172
|
+
def release_coverage
|
|
173
|
+
return unless @own_coverage
|
|
174
|
+
@own_coverage = false
|
|
175
|
+
begin
|
|
176
|
+
Coverage.result
|
|
177
|
+
rescue StandardError
|
|
178
|
+
nil
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def keep_coverage(value, args)
|
|
183
|
+
options = args.last.is_a?(Hash) ? args.last : {}
|
|
184
|
+
stop = options.key?(:stop) ? options[:stop] : true
|
|
185
|
+
clear = options.key?(:clear) ? options[:clear] : true
|
|
186
|
+
return unless stop || clear
|
|
187
|
+
@own_coverage = false if stop
|
|
188
|
+
unless @coverage_kept_pid == Process.pid
|
|
189
|
+
@coverage_kept = {}
|
|
190
|
+
@coverage_kept_pid = Process.pid
|
|
191
|
+
end
|
|
192
|
+
add_coverage(@coverage_kept, project_coverage(value))
|
|
193
|
+
rescue StandardError
|
|
194
|
+
nil
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def collected_coverage
|
|
198
|
+
total = {}
|
|
199
|
+
add_coverage(total, @coverage_kept) if @coverage_kept_pid == Process.pid
|
|
200
|
+
current = begin
|
|
201
|
+
Coverage.peek_result
|
|
202
|
+
rescue StandardError
|
|
203
|
+
nil
|
|
204
|
+
end
|
|
205
|
+
add_coverage(total, project_coverage(current)) if current
|
|
206
|
+
# A forked child starts with cleared counters: files it never ran
|
|
207
|
+
# carry no information of its own.
|
|
208
|
+
total.reject { |_path, lines| lines.none? { |count| count.is_a?(Integer) && count > 0 } }
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Turns either coverage format into { path => [count or nil per line] }:
|
|
212
|
+
# Ruby 2.3 and 2.4 give an array per file, later versions a hash.
|
|
213
|
+
def normalise_coverage(raw)
|
|
214
|
+
result = {}
|
|
215
|
+
raw.each do |path, data|
|
|
216
|
+
lines = line_counts(data)
|
|
217
|
+
result[path] = lines if lines
|
|
218
|
+
end
|
|
219
|
+
result
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def line_counts(data)
|
|
223
|
+
if data.is_a?(Array)
|
|
224
|
+
data.dup
|
|
225
|
+
elsif data.is_a?(Hash) && data[:lines].is_a?(Array)
|
|
226
|
+
data[:lines].dup
|
|
227
|
+
elsif data.is_a?(Hash) && data[:oneshot_lines].is_a?(Array)
|
|
228
|
+
lines = []
|
|
229
|
+
data[:oneshot_lines].each { |number| lines[number - 1] = 1 if number.is_a?(Integer) && number > 0 }
|
|
230
|
+
lines
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def project_coverage(raw)
|
|
235
|
+
result = {}
|
|
236
|
+
return result unless raw.is_a?(Hash)
|
|
237
|
+
normalise_coverage(raw).each do |path, lines|
|
|
238
|
+
next unless path.is_a?(String) && path.end_with?(".rb")
|
|
239
|
+
relative = project_path(path, COVERAGE_EXCLUDED)
|
|
240
|
+
add_lines(result, relative, lines) if relative
|
|
241
|
+
end
|
|
242
|
+
result
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def add_coverage(total, coverage)
|
|
246
|
+
coverage.each { |path, lines| add_lines(total, path, lines) }
|
|
247
|
+
total
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def add_lines(total, path, lines)
|
|
251
|
+
existing = total[path]
|
|
252
|
+
unless existing
|
|
253
|
+
total[path] = lines.dup
|
|
254
|
+
return
|
|
255
|
+
end
|
|
256
|
+
size = existing.size > lines.size ? existing.size : lines.size
|
|
257
|
+
merged = Array.new(size) do |index|
|
|
258
|
+
left = existing[index]
|
|
259
|
+
right = lines[index]
|
|
260
|
+
if left.nil?
|
|
261
|
+
right
|
|
262
|
+
elsif right.nil?
|
|
263
|
+
left
|
|
264
|
+
else
|
|
265
|
+
left + right
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
total[path] = merged
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# Paths
|
|
272
|
+
|
|
273
|
+
def path_forms(path)
|
|
274
|
+
forms = [File.expand_path(path)]
|
|
275
|
+
begin
|
|
276
|
+
forms << File.realpath(path)
|
|
277
|
+
rescue StandardError
|
|
278
|
+
nil
|
|
279
|
+
end
|
|
280
|
+
forms.uniq.map { |form| form.end_with?("/") ? form : "#{form}/" }
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# The path relative to the project root, or nil when the file is outside
|
|
284
|
+
# it, inside an installed gem or under one of the excluded folders.
|
|
285
|
+
def project_path(path, excluded)
|
|
286
|
+
absolute = File.expand_path(path.to_s, @cwd)
|
|
287
|
+
return nil if in_gem_dirs?(absolute)
|
|
288
|
+
relative = relative_to_root(absolute)
|
|
289
|
+
if relative.nil?
|
|
290
|
+
real = begin
|
|
291
|
+
File.realpath(absolute)
|
|
292
|
+
rescue StandardError
|
|
293
|
+
nil
|
|
294
|
+
end
|
|
295
|
+
return nil if real.nil? || in_gem_dirs?(real)
|
|
296
|
+
relative = relative_to_root(real)
|
|
297
|
+
return nil if relative.nil?
|
|
298
|
+
end
|
|
299
|
+
return nil if excluded.any? { |prefix| relative.start_with?(prefix) }
|
|
300
|
+
relative
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def relative_to_root(absolute)
|
|
304
|
+
@roots.each do |root|
|
|
305
|
+
return absolute[root.size..-1] if absolute.start_with?(root)
|
|
306
|
+
end
|
|
307
|
+
nil
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def in_gem_dirs?(absolute)
|
|
311
|
+
gem_dirs.any? { |dir| absolute.start_with?(dir) }
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def gem_dirs
|
|
315
|
+
with_bundler = defined?(::Bundler) ? true : false
|
|
316
|
+
return @gem_dirs if @gem_dirs && @gem_dirs_with_bundler == with_bundler
|
|
317
|
+
dirs = []
|
|
318
|
+
if defined?(::Gem)
|
|
319
|
+
dirs << safely(nil) { ::Gem.dir }
|
|
320
|
+
dirs.concat(safely([]) { ::Gem.path })
|
|
321
|
+
end
|
|
322
|
+
if with_bundler && ::Bundler.respond_to?(:bundle_path)
|
|
323
|
+
dirs << safely(nil) { ::Bundler.bundle_path.to_s }
|
|
324
|
+
end
|
|
325
|
+
dirs.concat(ruby_library_dirs)
|
|
326
|
+
forms = dirs.compact.map(&:to_s).reject(&:empty?).uniq.map { |dir| path_forms(dir) }.flatten.uniq
|
|
327
|
+
# A gem folder that holds the whole project must not hide the project.
|
|
328
|
+
forms = forms.reject { |dir| @roots.any? { |root| root.start_with?(dir) } }
|
|
329
|
+
@gem_dirs_with_bundler = with_bundler
|
|
330
|
+
@gem_dirs = forms
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
# Ruby's own library, which is never the customer's code.
|
|
334
|
+
def ruby_library_dirs
|
|
335
|
+
%w(rubylibdir rubyarchdir sitelibdir vendorlibdir).map do |key|
|
|
336
|
+
safely(nil) { ::RbConfig::CONFIG[key] }
|
|
337
|
+
end.compact.reject { |dir| dir.to_s.empty? }
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
# Deprecations and Minitest, hooked as soon as their classes exist
|
|
341
|
+
|
|
342
|
+
def watch_for_frameworks
|
|
343
|
+
hook_deprecation(::ActiveSupport::Deprecation) if defined?(::ActiveSupport::Deprecation)
|
|
344
|
+
hook_minitest(::Minitest) if defined?(::Minitest)
|
|
345
|
+
hook_simplecov(::SimpleCov) if defined?(::SimpleCov)
|
|
346
|
+
return if all_hooked?
|
|
347
|
+
@trace = TracePoint.new(:end) { |point| module_closed(point.self) }
|
|
348
|
+
@trace.enable
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def module_closed(mod)
|
|
352
|
+
name = MODULE_NAME.bind(mod).call
|
|
353
|
+
if name == "ActiveSupport::Deprecation"
|
|
354
|
+
hook_deprecation(mod)
|
|
355
|
+
elsif name == "Minitest"
|
|
356
|
+
hook_minitest(mod)
|
|
357
|
+
elsif name == "SimpleCov"
|
|
358
|
+
hook_simplecov(mod)
|
|
359
|
+
end
|
|
360
|
+
@trace.disable if @trace && all_hooked?
|
|
361
|
+
rescue StandardError
|
|
362
|
+
nil
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def hook_deprecation(klass)
|
|
366
|
+
return if @deprecation_hooked || !klass.is_a?(Module)
|
|
367
|
+
klass.send(:prepend, DeprecationHooks)
|
|
368
|
+
@deprecation_hooked = true
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
def hook_minitest(mod)
|
|
372
|
+
return if @minitest_hooked || !mod.is_a?(Module)
|
|
373
|
+
mod.singleton_class.send(:prepend, MinitestHooks)
|
|
374
|
+
@minitest_hooked = true
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
def hook_simplecov(mod)
|
|
378
|
+
return if @simplecov_hooked || !mod.is_a?(Module)
|
|
379
|
+
mod.singleton_class.send(:prepend, SimpleCovHooks)
|
|
380
|
+
@simplecov_hooked = true
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
def all_hooked?
|
|
384
|
+
@deprecation_hooked && @minitest_hooked && @simplecov_hooked ? true : false
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
# Rails 4 builds its call stack with caller, Rails 5 and later with
|
|
388
|
+
# caller_locations.
|
|
389
|
+
def string_callstack?
|
|
390
|
+
defined?(::ActiveSupport::VERSION::MAJOR) && ::ActiveSupport::VERSION::MAJOR.to_i < 5 ? true : false
|
|
391
|
+
rescue StandardError
|
|
392
|
+
false
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
def record_deprecation(message, callstack)
|
|
396
|
+
return if @lookup_running
|
|
397
|
+
unless @deprecations_pid == Process.pid
|
|
398
|
+
@deprecations = []
|
|
399
|
+
@deprecations_pid = Process.pid
|
|
400
|
+
end
|
|
401
|
+
return if @deprecations.size >= MAX_DEPRECATIONS
|
|
402
|
+
file, line = project_location(callstack)
|
|
403
|
+
@deprecations << { "message" => message.to_s, "file" => file, "line" => line }
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
def project_location(callstack)
|
|
407
|
+
Array(callstack).first(MAX_FRAMES).each do |frame|
|
|
408
|
+
if frame.is_a?(String)
|
|
409
|
+
match = /\A(.+?):(\d+)/.match(frame)
|
|
410
|
+
next unless match
|
|
411
|
+
path = match[1]
|
|
412
|
+
line = match[2].to_i
|
|
413
|
+
else
|
|
414
|
+
path = frame.absolute_path || frame.path
|
|
415
|
+
line = frame.lineno
|
|
416
|
+
end
|
|
417
|
+
next unless path
|
|
418
|
+
relative = project_path(path, LOCATION_EXCLUDED)
|
|
419
|
+
return [relative, line] if relative
|
|
420
|
+
end
|
|
421
|
+
[nil, nil]
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
def add_minitest_counter(minitest)
|
|
425
|
+
reporter = minitest.reporter if minitest.respond_to?(:reporter)
|
|
426
|
+
reporter << MinitestCounter.new if reporter.respond_to?(:<<)
|
|
427
|
+
rescue StandardError
|
|
428
|
+
nil
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
def minitest_started
|
|
432
|
+
@minitest_mutex.synchronize do
|
|
433
|
+
return if @minitest_counts && @minitest_counts["pid"] == Process.pid
|
|
434
|
+
@minitest_counts = { "pid" => Process.pid, "run" => 0, "passed" => 0, "failed" => 0, "skipped" => 0 }
|
|
435
|
+
end
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
def minitest_record(result)
|
|
439
|
+
outcome = if result.respond_to?(:skipped?) && result.skipped?
|
|
440
|
+
"skipped"
|
|
441
|
+
elsif result.respond_to?(:passed?) && result.passed?
|
|
442
|
+
"passed"
|
|
443
|
+
else
|
|
444
|
+
"failed"
|
|
445
|
+
end
|
|
446
|
+
@minitest_mutex.synchronize do
|
|
447
|
+
counts = @minitest_counts
|
|
448
|
+
return unless counts && counts["pid"] == Process.pid
|
|
449
|
+
counts["run"] += 1
|
|
450
|
+
counts[outcome] += 1
|
|
451
|
+
end
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
# At exit
|
|
455
|
+
|
|
456
|
+
def finish
|
|
457
|
+
report = build_report
|
|
458
|
+
write_report(@dir, report) if report
|
|
459
|
+
rescue Exception
|
|
460
|
+
nil
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
def build_report
|
|
464
|
+
coverage = safely({}) { collected_coverage }
|
|
465
|
+
deprecations = safely([]) { @deprecations_pid == Process.pid ? @deprecations : [] }
|
|
466
|
+
tests = safely(nil) { test_counts }
|
|
467
|
+
return nil if coverage.empty? && deprecations.empty? && tests.nil?
|
|
468
|
+
{
|
|
469
|
+
"ruby" => RUBY_VERSION,
|
|
470
|
+
"bundler" => safely(nil) { defined?(::Bundler::VERSION) ? ::Bundler::VERSION.to_s : nil },
|
|
471
|
+
"database" => safely(nil) { database },
|
|
472
|
+
"coverage" => coverage,
|
|
473
|
+
"deprecations" => deprecations,
|
|
474
|
+
"tests" => tests
|
|
475
|
+
}
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
def test_counts
|
|
479
|
+
counts = @minitest_mutex.synchronize { @minitest_counts && @minitest_counts.dup }
|
|
480
|
+
if counts && counts["pid"] == Process.pid
|
|
481
|
+
return {
|
|
482
|
+
"runner" => "minitest", "run" => counts["run"], "passed" => counts["passed"],
|
|
483
|
+
"failed" => counts["failed"], "skipped" => counts["skipped"]
|
|
484
|
+
}
|
|
485
|
+
end
|
|
486
|
+
rspec_counts
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
# Reads the reporter RSpec already built. Never creates a configuration
|
|
490
|
+
# or a reporter, and ignores counts a forked child inherited.
|
|
491
|
+
def rspec_counts
|
|
492
|
+
return nil unless Process.pid == @pid && defined?(::RSpec)
|
|
493
|
+
return nil unless ::RSpec.instance_variable_defined?(:@configuration)
|
|
494
|
+
configuration = ::RSpec.instance_variable_get(:@configuration)
|
|
495
|
+
return nil unless configuration && configuration.instance_variable_defined?(:@reporter)
|
|
496
|
+
reporter = configuration.instance_variable_get(:@reporter)
|
|
497
|
+
return nil unless reporter.respond_to?(:examples) && reporter.respond_to?(:failed_examples) &&
|
|
498
|
+
reporter.respond_to?(:pending_examples)
|
|
499
|
+
run = reporter.examples.size
|
|
500
|
+
failed = reporter.failed_examples.size
|
|
501
|
+
skipped = reporter.pending_examples.size
|
|
502
|
+
{ "runner" => "rspec", "run" => run, "passed" => run - failed - skipped, "failed" => failed, "skipped" => skipped }
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
# Only in the process that started the run: a forked child shares the
|
|
506
|
+
# parent's database connection and must not use it.
|
|
507
|
+
# Deprecations raised by the lookup itself are not the app's and are
|
|
508
|
+
# not recorded.
|
|
509
|
+
def database
|
|
510
|
+
return nil unless Process.pid == @pid && defined?(::ActiveRecord::Base)
|
|
511
|
+
base = ::ActiveRecord::Base
|
|
512
|
+
return nil unless base.respond_to?(:connected?) && base.connected?
|
|
513
|
+
@lookup_running = true
|
|
514
|
+
begin
|
|
515
|
+
if base.respond_to?(:with_connection)
|
|
516
|
+
base.with_connection { |connection| database_version(base, connection) }
|
|
517
|
+
else
|
|
518
|
+
database_version(base, base.connection)
|
|
519
|
+
end
|
|
520
|
+
ensure
|
|
521
|
+
@lookup_running = false
|
|
522
|
+
end
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
# Rails 7.2 deprecates ActiveRecord::Base.connection, so with_connection
|
|
526
|
+
# is used where it exists.
|
|
527
|
+
def database_version(base, connection)
|
|
528
|
+
name = connection.adapter_name.to_s.downcase
|
|
529
|
+
adapter, query = database_query(name)
|
|
530
|
+
version = query ? quiet_select(base, connection, query) : nil
|
|
531
|
+
adapter = "mariadb" if adapter == "mysql" && version.to_s.downcase.include?("mariadb")
|
|
532
|
+
number = version.to_s[/\d+(\.\d+)*/]
|
|
533
|
+
{ "adapter" => adapter, "version" => number }
|
|
534
|
+
end
|
|
535
|
+
|
|
536
|
+
def database_query(name)
|
|
537
|
+
if name.include?("postg")
|
|
538
|
+
["postgresql", "SHOW server_version"]
|
|
539
|
+
elsif name.include?("mysql") || name.include?("trilogy")
|
|
540
|
+
["mysql", "SELECT VERSION()"]
|
|
541
|
+
elsif name.include?("sqlite")
|
|
542
|
+
["sqlite", "SELECT sqlite_version()"]
|
|
543
|
+
else
|
|
544
|
+
[name, nil]
|
|
545
|
+
end
|
|
546
|
+
end
|
|
547
|
+
|
|
548
|
+
# Runs the version query with the app's logger switched off, so the
|
|
549
|
+
# query never shows up in output the app sends to the terminal.
|
|
550
|
+
def quiet_select(base, connection, query)
|
|
551
|
+
logger = base.respond_to?(:logger) ? base.logger : nil
|
|
552
|
+
swap = logger && base.respond_to?(:logger=)
|
|
553
|
+
base.logger = nil if swap
|
|
554
|
+
begin
|
|
555
|
+
connection.select_value(query)
|
|
556
|
+
ensure
|
|
557
|
+
base.logger = logger if swap
|
|
558
|
+
end
|
|
559
|
+
end
|
|
560
|
+
|
|
561
|
+
# Writing
|
|
562
|
+
|
|
563
|
+
# Writes to a hidden temporary name in the same folder, then renames,
|
|
564
|
+
# so the collector never sees half a file.
|
|
565
|
+
def write_report(dir, report)
|
|
566
|
+
json = generate(report)
|
|
567
|
+
name = "process-#{Process.pid}-#{random_token}"
|
|
568
|
+
temporary = File.join(dir, ".#{name}.json.tmp")
|
|
569
|
+
final = File.join(dir, "#{name}.json")
|
|
570
|
+
begin
|
|
571
|
+
File.open(temporary, "wb") { |file| file.write(json) }
|
|
572
|
+
File.rename(temporary, final)
|
|
573
|
+
rescue Exception
|
|
574
|
+
begin
|
|
575
|
+
File.unlink(temporary) if File.exist?(temporary)
|
|
576
|
+
rescue StandardError
|
|
577
|
+
nil
|
|
578
|
+
end
|
|
579
|
+
raise
|
|
580
|
+
end
|
|
581
|
+
final
|
|
582
|
+
end
|
|
583
|
+
|
|
584
|
+
def random_token
|
|
585
|
+
"#{rand(0x100000000).to_s(36)}#{(Time.now.to_f * 1_000_000).to_i.to_s(36)}"
|
|
586
|
+
end
|
|
587
|
+
|
|
588
|
+
# A small JSON writer, so the loader never loads the json gem into the
|
|
589
|
+
# app's process.
|
|
590
|
+
def generate(value)
|
|
591
|
+
case value
|
|
592
|
+
when Hash
|
|
593
|
+
"{" + value.map { |key, item| "#{generate_string(key.to_s)}:#{generate(item)}" }.join(",") + "}"
|
|
594
|
+
when Array
|
|
595
|
+
"[" + value.map { |item| generate(item) }.join(",") + "]"
|
|
596
|
+
when String
|
|
597
|
+
generate_string(value)
|
|
598
|
+
when Integer
|
|
599
|
+
value.to_s
|
|
600
|
+
when Float
|
|
601
|
+
value.finite? ? value.to_s : "null"
|
|
602
|
+
when true
|
|
603
|
+
"true"
|
|
604
|
+
when false
|
|
605
|
+
"false"
|
|
606
|
+
when nil
|
|
607
|
+
"null"
|
|
608
|
+
else
|
|
609
|
+
generate_string(value.to_s)
|
|
610
|
+
end
|
|
611
|
+
end
|
|
612
|
+
|
|
613
|
+
def generate_string(string)
|
|
614
|
+
text = string.dup
|
|
615
|
+
if text.encoding == Encoding::UTF_8 || text.encoding == Encoding::BINARY || text.encoding == Encoding::US_ASCII
|
|
616
|
+
text.force_encoding(Encoding::UTF_8)
|
|
617
|
+
else
|
|
618
|
+
begin
|
|
619
|
+
text = text.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "?")
|
|
620
|
+
rescue EncodingError
|
|
621
|
+
# Dummy encodings such as UTF-7 have no converter.
|
|
622
|
+
text = text.force_encoding(Encoding::UTF_8)
|
|
623
|
+
end
|
|
624
|
+
end
|
|
625
|
+
text = text.scrub("?") unless text.valid_encoding?
|
|
626
|
+
escaped = text.gsub(/["\\\x00-\x1f]/) { |char| JSON_ESCAPES[char] || format("\\u%04x", char.ord) }
|
|
627
|
+
"\"#{escaped}\""
|
|
628
|
+
end
|
|
629
|
+
|
|
630
|
+
def safely(default)
|
|
631
|
+
yield
|
|
632
|
+
rescue StandardError, ScriptError, SystemStackError
|
|
633
|
+
default
|
|
634
|
+
end
|
|
635
|
+
end
|
|
636
|
+
end
|
|
637
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
module GemfileAudit
|
|
2
|
+
# Runs a short command and returns what it printed, giving up after a
|
|
3
|
+
# deadline so a stuck tool can never hold the build.
|
|
4
|
+
module Shell
|
|
5
|
+
TIMEOUT = 5
|
|
6
|
+
|
|
7
|
+
# The standard output of the command, or nil when it failed, was not
|
|
8
|
+
# found or did not finish in time.
|
|
9
|
+
def self.capture(*command)
|
|
10
|
+
options = { :err => File::NULL }
|
|
11
|
+
options[:chdir] = command.pop[:chdir] if command.last.is_a?(Hash)
|
|
12
|
+
reader, writer = IO.pipe
|
|
13
|
+
options[:out] = writer
|
|
14
|
+
pid = nil
|
|
15
|
+
begin
|
|
16
|
+
pid = Process.spawn(*command, options)
|
|
17
|
+
writer.close
|
|
18
|
+
read_until(reader, pid, Time.now + TIMEOUT)
|
|
19
|
+
rescue StandardError
|
|
20
|
+
stop(pid)
|
|
21
|
+
nil
|
|
22
|
+
ensure
|
|
23
|
+
writer.close unless writer.closed?
|
|
24
|
+
reader.close unless reader.closed?
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.read_until(reader, pid, deadline)
|
|
29
|
+
output = String.new
|
|
30
|
+
loop do
|
|
31
|
+
remaining = deadline - Time.now
|
|
32
|
+
return timed_out(pid) if remaining <= 0
|
|
33
|
+
return timed_out(pid) unless IO.select([reader], nil, nil, remaining)
|
|
34
|
+
begin
|
|
35
|
+
output << reader.read_nonblock(4096)
|
|
36
|
+
rescue IO::WaitReadable
|
|
37
|
+
next
|
|
38
|
+
rescue EOFError
|
|
39
|
+
break
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
until (done = Process.wait2(pid, Process::WNOHANG))
|
|
43
|
+
return timed_out(pid) if Time.now >= deadline
|
|
44
|
+
sleep 0.01
|
|
45
|
+
end
|
|
46
|
+
done.last.success? ? output : nil
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def self.timed_out(pid)
|
|
50
|
+
stop(pid)
|
|
51
|
+
nil
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def self.stop(pid)
|
|
55
|
+
return unless pid
|
|
56
|
+
begin
|
|
57
|
+
Process.kill("KILL", pid)
|
|
58
|
+
rescue StandardError
|
|
59
|
+
nil
|
|
60
|
+
end
|
|
61
|
+
begin
|
|
62
|
+
Process.wait(pid)
|
|
63
|
+
rescue StandardError
|
|
64
|
+
nil
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private_class_method :read_until, :timed_out, :stop
|
|
69
|
+
end
|
|
70
|
+
end
|