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.
@@ -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