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,295 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "coverage"
|
|
3
|
+
|
|
4
|
+
module GemfileAudit
|
|
5
|
+
# Turns the files the measured processes wrote into one version 1 upload.
|
|
6
|
+
# Only paths with line counts, deprecation messages with a file and line,
|
|
7
|
+
# versions, test counts and the lockfile go in. Source code is read here to
|
|
8
|
+
# count lines and is never sent.
|
|
9
|
+
class Collector
|
|
10
|
+
class Missing < StandardError; end
|
|
11
|
+
|
|
12
|
+
MAX_FILES = 20_000
|
|
13
|
+
MAX_DEPRECATIONS = 2_000
|
|
14
|
+
MAX_LINES_IN_MESSAGE = 4
|
|
15
|
+
MAX_MESSAGE_LENGTH = 1_000
|
|
16
|
+
UNLISTED_FOLDERS = %w(vendor/ node_modules/ tmp/ spec/ test/ features/ db/).freeze
|
|
17
|
+
LISTED_FOLDERS = %w(app lib).freeze
|
|
18
|
+
PATH_PATTERN = %r{\A[\w@+.-]+(/[\w@+.-]+)*\z}
|
|
19
|
+
VERSION_PATTERN = /\A\d+(\.\d+){0,3}\z/
|
|
20
|
+
TEST_KEYS = %w(run passed failed skipped).freeze
|
|
21
|
+
|
|
22
|
+
def initialize(dir:, root:, ci:, env: ENV)
|
|
23
|
+
@dir = dir.to_s
|
|
24
|
+
@root = File.expand_path(root.to_s)
|
|
25
|
+
@ci = ci
|
|
26
|
+
@env = env
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def upload
|
|
30
|
+
lockfile = read_lockfile
|
|
31
|
+
processes = read_processes
|
|
32
|
+
{
|
|
33
|
+
"version" => 1,
|
|
34
|
+
"repository" => @ci.repository,
|
|
35
|
+
"ref" => @ci.ref,
|
|
36
|
+
"sha" => @ci.sha,
|
|
37
|
+
"event" => @ci.event,
|
|
38
|
+
"lockfile" => lockfile,
|
|
39
|
+
"tests" => tests(processes),
|
|
40
|
+
"coverage" => coverage(processes),
|
|
41
|
+
"deprecations" => deprecations(processes),
|
|
42
|
+
"runtimes" => runtimes(processes)
|
|
43
|
+
}
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def read_lockfile
|
|
49
|
+
candidates = []
|
|
50
|
+
gemfile = @env["BUNDLE_GEMFILE"]
|
|
51
|
+
candidates << File.join(File.dirname(File.expand_path(gemfile, @root)), "Gemfile.lock") if gemfile && !gemfile.empty?
|
|
52
|
+
candidates << File.join(@root, "Gemfile.lock")
|
|
53
|
+
path = candidates.find { |candidate| File.file?(candidate) }
|
|
54
|
+
raise Missing, "No Gemfile.lock found in #{@root}." unless path
|
|
55
|
+
text = File.read(path, mode: "rb").force_encoding(Encoding::UTF_8)
|
|
56
|
+
text = text.scrub("?") unless text.valid_encoding?
|
|
57
|
+
raise Missing, "No Gemfile.lock found in #{@root}." if text.strip.empty?
|
|
58
|
+
text
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def read_processes
|
|
62
|
+
Dir.glob(File.join(@dir, "process-*.json")).sort.map do |path|
|
|
63
|
+
begin
|
|
64
|
+
data = JSON.parse(File.read(path))
|
|
65
|
+
data.is_a?(Hash) ? data : nil
|
|
66
|
+
rescue StandardError
|
|
67
|
+
nil
|
|
68
|
+
end
|
|
69
|
+
end.compact
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Coverage
|
|
73
|
+
|
|
74
|
+
def coverage(processes)
|
|
75
|
+
merged = merge_coverage(processes)
|
|
76
|
+
return nil if merged.empty?
|
|
77
|
+
files = merged.map { |path, counts| coverage_entry(path, counts) }.compact
|
|
78
|
+
seen = real_paths(merged.keys)
|
|
79
|
+
listed_on_disk.each do |path|
|
|
80
|
+
next if merged.key?(path)
|
|
81
|
+
real = real_path(path)
|
|
82
|
+
next if real && seen[real]
|
|
83
|
+
seen[real] = true if real
|
|
84
|
+
entry = unloaded_entry(path)
|
|
85
|
+
files << entry if entry
|
|
86
|
+
end
|
|
87
|
+
files = files.sort_by { |entry| [-entry["lines"], entry["path"]] }.first(MAX_FILES)
|
|
88
|
+
{ "files" => files }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def merge_coverage(processes)
|
|
92
|
+
merged = {}
|
|
93
|
+
processes.each do |process|
|
|
94
|
+
files = process["coverage"]
|
|
95
|
+
next unless files.is_a?(Hash)
|
|
96
|
+
files.each do |path, counts|
|
|
97
|
+
next unless counts.is_a?(Array) && valid_path?(path)
|
|
98
|
+
merged[path] = max_counts(merged[path], counts)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
merged
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def max_counts(left, right)
|
|
105
|
+
return right.dup unless left
|
|
106
|
+
size = left.size > right.size ? left.size : right.size
|
|
107
|
+
Array.new(size) do |index|
|
|
108
|
+
a = left[index]
|
|
109
|
+
b = right[index]
|
|
110
|
+
if !a.is_a?(Integer)
|
|
111
|
+
b
|
|
112
|
+
elsif !b.is_a?(Integer)
|
|
113
|
+
a
|
|
114
|
+
else
|
|
115
|
+
a > b ? a : b
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def coverage_entry(path, counts)
|
|
121
|
+
relevant = counts.select { |count| count.is_a?(Integer) }
|
|
122
|
+
return nil if relevant.empty?
|
|
123
|
+
{ "path" => path, "lines" => relevant.size, "covered" => relevant.count { |count| count > 0 } }
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Ruby files under app and lib on disk, as paths relative to the root.
|
|
127
|
+
def listed_on_disk
|
|
128
|
+
real_root = begin
|
|
129
|
+
File.realpath(@root)
|
|
130
|
+
rescue StandardError
|
|
131
|
+
@root
|
|
132
|
+
end
|
|
133
|
+
paths = []
|
|
134
|
+
LISTED_FOLDERS.each do |folder|
|
|
135
|
+
Dir.glob(File.join(@root, folder, "**", "*.rb")).sort.each do |absolute|
|
|
136
|
+
relative = absolute[(@root.size + 1)..-1]
|
|
137
|
+
next if UNLISTED_FOLDERS.any? { |prefix| relative.start_with?(prefix) }
|
|
138
|
+
next unless valid_path?(relative) && File.file?(absolute)
|
|
139
|
+
next unless inside?(real_root, absolute)
|
|
140
|
+
paths << relative
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
paths
|
|
144
|
+
rescue StandardError
|
|
145
|
+
[]
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def real_paths(paths)
|
|
149
|
+
seen = {}
|
|
150
|
+
paths.each do |path|
|
|
151
|
+
real = real_path(path)
|
|
152
|
+
seen[real] = true if real
|
|
153
|
+
end
|
|
154
|
+
seen
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def real_path(path)
|
|
158
|
+
File.realpath(File.join(@root, path))
|
|
159
|
+
rescue StandardError
|
|
160
|
+
nil
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def inside?(real_root, absolute)
|
|
164
|
+
File.realpath(absolute).start_with?(real_root + "/")
|
|
165
|
+
rescue StandardError
|
|
166
|
+
false
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def unloaded_entry(path)
|
|
170
|
+
lines = relevant_lines(File.join(@root, path))
|
|
171
|
+
lines > 0 ? { "path" => path, "lines" => lines, "covered" => 0 } : nil
|
|
172
|
+
rescue StandardError
|
|
173
|
+
nil
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Coverage.line_stub where Ruby has it, otherwise the lines that are
|
|
177
|
+
# neither blank nor comments.
|
|
178
|
+
def relevant_lines(absolute)
|
|
179
|
+
if Coverage.respond_to?(:line_stub)
|
|
180
|
+
begin
|
|
181
|
+
verbose = $VERBOSE
|
|
182
|
+
$VERBOSE = nil
|
|
183
|
+
return Coverage.line_stub(absolute).count { |entry| !entry.nil? }
|
|
184
|
+
rescue StandardError, ScriptError
|
|
185
|
+
nil
|
|
186
|
+
ensure
|
|
187
|
+
$VERBOSE = verbose
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
File.read(absolute, mode: "rb").force_encoding(Encoding::BINARY).each_line.count do |line|
|
|
191
|
+
stripped = line.strip
|
|
192
|
+
!stripped.empty? && !stripped.start_with?("#")
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def valid_path?(path)
|
|
197
|
+
path.is_a?(String) && path.length <= 512 && PATH_PATTERN =~ path &&
|
|
198
|
+
!path.split("/").include?("..")
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Deprecations
|
|
202
|
+
|
|
203
|
+
def deprecations(processes)
|
|
204
|
+
counts = Hash.new(0)
|
|
205
|
+
order = []
|
|
206
|
+
processes.each do |process|
|
|
207
|
+
items = process["deprecations"]
|
|
208
|
+
next unless items.is_a?(Array)
|
|
209
|
+
items.each do |item|
|
|
210
|
+
next unless item.is_a?(Hash) && item["message"].is_a?(String)
|
|
211
|
+
key = [shorten(item["message"]), deprecation_file(item["file"]), deprecation_line(item["line"])]
|
|
212
|
+
order << key unless counts.key?(key)
|
|
213
|
+
counts[key] += 1
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
ranked = order.each_with_index.sort_by { |key, index| [-counts[key], index] }.first(MAX_DEPRECATIONS)
|
|
217
|
+
ranked.map do |key, _index|
|
|
218
|
+
{ "message" => key[0], "file" => key[1], "line" => key[2], "count" => counts[key] }
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def shorten(message)
|
|
223
|
+
message.split(/\R/).first(MAX_LINES_IN_MESSAGE).join("\n")[0, MAX_MESSAGE_LENGTH]
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def deprecation_file(file)
|
|
227
|
+
valid_path?(file) ? file : nil
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def deprecation_line(line)
|
|
231
|
+
line.is_a?(Integer) && line > 0 ? line : nil
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Tests
|
|
235
|
+
|
|
236
|
+
def tests(processes)
|
|
237
|
+
totals = nil
|
|
238
|
+
processes.each do |process|
|
|
239
|
+
counts = process["tests"]
|
|
240
|
+
next unless counts.is_a?(Hash)
|
|
241
|
+
totals ||= Hash[TEST_KEYS.map { |key| [key, 0] }]
|
|
242
|
+
TEST_KEYS.each { |key| totals[key] += counts[key] if counts[key].is_a?(Integer) && counts[key] >= 0 }
|
|
243
|
+
end
|
|
244
|
+
return nil unless totals
|
|
245
|
+
totals["duration_seconds"] = duration_seconds
|
|
246
|
+
totals
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def duration_seconds
|
|
250
|
+
started = Integer(@env["GEMFILE_AUDIT_STARTED_AT"].to_s, 10)
|
|
251
|
+
seconds = Time.now.to_i - started
|
|
252
|
+
seconds < 0 ? 0 : seconds
|
|
253
|
+
rescue ArgumentError
|
|
254
|
+
0
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# Runtimes
|
|
258
|
+
|
|
259
|
+
def runtimes(processes)
|
|
260
|
+
found = {}
|
|
261
|
+
found["ruby"] = first_version(processes) { |process| process["ruby"] }
|
|
262
|
+
found["bundler"] = first_version(processes) { |process| process["bundler"] }
|
|
263
|
+
found["postgresql"] = first_version(processes) { |process| database_version(process, "postgresql") }
|
|
264
|
+
found["mysql"] = first_version(processes) { |process| database_version(process, "mysql") }
|
|
265
|
+
found["node"] = clean_version(command_output("node", "--version"))
|
|
266
|
+
found["redis"] = clean_version(command_output("redis-server", "--version")[/v=(\d+(?:\.\d+)*)/, 1])
|
|
267
|
+
found.reject { |_name, version| version.nil? }
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def first_version(processes)
|
|
271
|
+
processes.each do |process|
|
|
272
|
+
version = clean_version(yield(process))
|
|
273
|
+
return version if version
|
|
274
|
+
end
|
|
275
|
+
nil
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def database_version(process, adapter)
|
|
279
|
+
database = process["database"]
|
|
280
|
+
database.is_a?(Hash) && database["adapter"] == adapter ? database["version"] : nil
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# Keeps at most four numeric parts, the most the server accepts.
|
|
284
|
+
def clean_version(value)
|
|
285
|
+
number = value.to_s.strip.sub(/\Av/, "")[/\A\d+(\.\d+)*/]
|
|
286
|
+
return nil unless number
|
|
287
|
+
number = number.split(".").first(4).join(".")
|
|
288
|
+
VERSION_PATTERN =~ number ? number : nil
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def command_output(*command)
|
|
292
|
+
Shell.capture(*command).to_s
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Required into every Ruby process of the test run through RUBYOPT, before
|
|
2
|
+
# Bundler and the app load. It does nothing at all unless GEMFILE_AUDIT_DIR
|
|
3
|
+
# and GEMFILE_AUDIT_ROOT both name folders. Standard library only and Ruby 2.3
|
|
4
|
+
# syntax, because it runs inside other people's processes.
|
|
5
|
+
gemfile_audit_dir = ENV["GEMFILE_AUDIT_DIR"]
|
|
6
|
+
gemfile_audit_root = ENV["GEMFILE_AUDIT_ROOT"]
|
|
7
|
+
|
|
8
|
+
if gemfile_audit_dir && !gemfile_audit_dir.empty? && File.directory?(gemfile_audit_dir) &&
|
|
9
|
+
gemfile_audit_root && !gemfile_audit_root.empty? && File.directory?(gemfile_audit_root)
|
|
10
|
+
begin
|
|
11
|
+
require File.expand_path("measurement.rb", __dir__)
|
|
12
|
+
GemfileAudit::Measurement.start(gemfile_audit_dir, gemfile_audit_root)
|
|
13
|
+
rescue StandardError, ScriptError
|
|
14
|
+
nil
|
|
15
|
+
end
|
|
16
|
+
end
|