audition 0.1.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.txt +21 -0
- data/README.md +290 -0
- data/exe/audition +6 -0
- data/lib/audition/baseline.rb +66 -0
- data/lib/audition/bundle_sweep.rb +110 -0
- data/lib/audition/cli.rb +415 -0
- data/lib/audition/config.rb +67 -0
- data/lib/audition/directives.rb +56 -0
- data/lib/audition/dynamic/harness.rb +423 -0
- data/lib/audition/dynamic/prober.rb +362 -0
- data/lib/audition/finding.rb +94 -0
- data/lib/audition/fixer.rb +141 -0
- data/lib/audition/report.rb +344 -0
- data/lib/audition/rewriters.rb +459 -0
- data/lib/audition/static/analyzer.rb +96 -0
- data/lib/audition/static/checks/base.rb +160 -0
- data/lib/audition/static/checks/global_variables.rb +67 -0
- data/lib/audition/static/checks/mutable_constants.rb +145 -0
- data/lib/audition/static/checks/ractor_isolation.rb +105 -0
- data/lib/audition/static/checks/runtime_require.rb +126 -0
- data/lib/audition/static/checks/unsafe_calls.rb +166 -0
- data/lib/audition/static/checks.rb +46 -0
- data/lib/audition/static/graph_audit.rb +143 -0
- data/lib/audition/static/literal_classifier.rb +145 -0
- data/lib/audition/static/source_file.rb +109 -0
- data/lib/audition/target.rb +172 -0
- data/lib/audition/version.rb +5 -0
- data/lib/audition.rb +37 -0
- metadata +143 -0
data/lib/audition/cli.rb
ADDED
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
require "table_tennis"
|
|
5
|
+
|
|
6
|
+
module Audition
|
|
7
|
+
class CLI
|
|
8
|
+
USAGE = "usage: audition [options] TARGET " \
|
|
9
|
+
"(a .rb script, directory, config.ru dir, Rails root, " \
|
|
10
|
+
"gem dir, or installed gem name)"
|
|
11
|
+
|
|
12
|
+
# Entry point used by `exe/audition`.
|
|
13
|
+
#
|
|
14
|
+
# @param argv [Array<String>] command line arguments
|
|
15
|
+
# @param stdout [IO]
|
|
16
|
+
# @param stderr [IO]
|
|
17
|
+
# @return [Integer] exit code: 0 clean, 1 findings at or above
|
|
18
|
+
# the `--fail-on` threshold, 2 usage error
|
|
19
|
+
def self.run(argv, stdout: $stdout, stderr: $stderr)
|
|
20
|
+
new(stdout: stdout, stderr: stderr).run(argv)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def initialize(stdout:, stderr:)
|
|
24
|
+
@stdout = stdout
|
|
25
|
+
@stderr = stderr
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def run(argv)
|
|
29
|
+
options = parse(argv.dup)
|
|
30
|
+
return options if options.is_a?(Integer)
|
|
31
|
+
|
|
32
|
+
return print_capabilities(options) if options[:capabilities]
|
|
33
|
+
|
|
34
|
+
target_arg = options[:args].first
|
|
35
|
+
unless target_arg
|
|
36
|
+
@stderr.puts(USAGE)
|
|
37
|
+
return 2
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
target = Target.detect(target_arg)
|
|
41
|
+
target = deps_target(target) if options[:deps]
|
|
42
|
+
if target.type == :bundle
|
|
43
|
+
sweep(target, options)
|
|
44
|
+
else
|
|
45
|
+
audit(target, options)
|
|
46
|
+
end
|
|
47
|
+
rescue Error => e
|
|
48
|
+
@stderr.puts("audition: #{e.message}")
|
|
49
|
+
2
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def parse(argv)
|
|
55
|
+
options = {
|
|
56
|
+
format: :text, fail_on: :error, static_only: false,
|
|
57
|
+
dynamic_only: false, fix: false, unsafe: false,
|
|
58
|
+
dry_run: false, capabilities: false, plain: false,
|
|
59
|
+
timeout: 30, write_baseline: false, no_baseline: false,
|
|
60
|
+
deps: false, explicit: []
|
|
61
|
+
}
|
|
62
|
+
parser = OptionParser.new do |o|
|
|
63
|
+
o.banner = USAGE
|
|
64
|
+
o.on("-f", "--format FORMAT", %w[text json github],
|
|
65
|
+
"output format: text (default), json, or github " \
|
|
66
|
+
"(workflow command annotations)") do |v|
|
|
67
|
+
options[:format] = v.to_sym
|
|
68
|
+
end
|
|
69
|
+
o.on("--compare PATH",
|
|
70
|
+
"report deltas against a previous --format json " \
|
|
71
|
+
"report (text/github formats)") do |v|
|
|
72
|
+
options[:compare] = v
|
|
73
|
+
end
|
|
74
|
+
o.on("--static-only", "skip dynamic in-Ractor probing") do
|
|
75
|
+
options[:static_only] = true
|
|
76
|
+
end
|
|
77
|
+
o.on("--dynamic-only", "skip static analysis") do
|
|
78
|
+
options[:dynamic_only] = true
|
|
79
|
+
end
|
|
80
|
+
o.on("--fix", "apply safe corrections, then re-check") do
|
|
81
|
+
options[:fix] = true
|
|
82
|
+
end
|
|
83
|
+
o.on("--fix-unsafe",
|
|
84
|
+
"also apply semantics-affecting corrections") do
|
|
85
|
+
options[:fix] = true
|
|
86
|
+
options[:unsafe] = true
|
|
87
|
+
end
|
|
88
|
+
o.on("--dry-run",
|
|
89
|
+
"with --fix: show planned edits, change nothing") do
|
|
90
|
+
options[:dry_run] = true
|
|
91
|
+
end
|
|
92
|
+
o.on("--fail-on LEVEL", %w[error warning info],
|
|
93
|
+
"exit 1 threshold (default: error)") do |v|
|
|
94
|
+
options[:fail_on] = v.to_sym
|
|
95
|
+
options[:explicit] << :fail_on
|
|
96
|
+
end
|
|
97
|
+
o.on("--write-baseline",
|
|
98
|
+
"record current findings as the baseline") do
|
|
99
|
+
options[:write_baseline] = true
|
|
100
|
+
end
|
|
101
|
+
o.on("--no-baseline", "ignore an existing baseline") do
|
|
102
|
+
options[:no_baseline] = true
|
|
103
|
+
end
|
|
104
|
+
o.on("--capabilities",
|
|
105
|
+
"probe what this Ruby allows inside Ractors") do
|
|
106
|
+
options[:capabilities] = true
|
|
107
|
+
end
|
|
108
|
+
o.on("--deps",
|
|
109
|
+
"sweep the target's Gemfile.lock gem by gem") do
|
|
110
|
+
options[:deps] = true
|
|
111
|
+
end
|
|
112
|
+
o.on("--timeout SECONDS", Integer,
|
|
113
|
+
"dynamic probe timeout (default: 30)") do |v|
|
|
114
|
+
options[:timeout] = v
|
|
115
|
+
options[:explicit] << :timeout
|
|
116
|
+
end
|
|
117
|
+
o.on("--plain", "disable colors and hyperlinks") do
|
|
118
|
+
options[:plain] = true
|
|
119
|
+
end
|
|
120
|
+
o.on("-v", "--version") do
|
|
121
|
+
@stdout.puts(VERSION)
|
|
122
|
+
return 0
|
|
123
|
+
end
|
|
124
|
+
o.on("-h", "--help") do
|
|
125
|
+
@stdout.puts(o)
|
|
126
|
+
return 0
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
options[:args] = parser.parse(argv)
|
|
130
|
+
options
|
|
131
|
+
rescue OptionParser::ParseError => e
|
|
132
|
+
@stderr.puts("audition: #{e.message}")
|
|
133
|
+
@stderr.puts(USAGE)
|
|
134
|
+
2
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def audit(target, options)
|
|
138
|
+
config = Config.load(target.root)
|
|
139
|
+
options = apply_config(options, config)
|
|
140
|
+
directives = Directives.new
|
|
141
|
+
findings = []
|
|
142
|
+
unless options[:dynamic_only]
|
|
143
|
+
findings = filter(static_findings(target, config),
|
|
144
|
+
directives, config)
|
|
145
|
+
findings = run_fix(target, findings, options) if options[:fix]
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
results = []
|
|
149
|
+
if target.entry && !options[:static_only]
|
|
150
|
+
results << prober(options).probe(target.entry)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
all = findings +
|
|
154
|
+
filter(results.flat_map(&:findings), directives, config)
|
|
155
|
+
|
|
156
|
+
if options[:write_baseline]
|
|
157
|
+
recorded = Baseline.write(target.root, all)
|
|
158
|
+
@stdout.puts(
|
|
159
|
+
"baseline written: #{recorded} finding(s) recorded in " \
|
|
160
|
+
"#{Baseline.path_for(target.root)}"
|
|
161
|
+
)
|
|
162
|
+
return 0
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
all, baselined = apply_baseline(all, target, options)
|
|
166
|
+
|
|
167
|
+
report = Report.new(
|
|
168
|
+
target_type: target.type,
|
|
169
|
+
target_root: target.root,
|
|
170
|
+
findings: all,
|
|
171
|
+
dynamic_results: results,
|
|
172
|
+
unsafe_fixes: options[:unsafe] ? 0 : Fixer.unsafe_gain(all),
|
|
173
|
+
baselined: baselined
|
|
174
|
+
)
|
|
175
|
+
emit(report, options)
|
|
176
|
+
if options[:compare] && options[:format] != :json
|
|
177
|
+
emit_comparison(report, options)
|
|
178
|
+
end
|
|
179
|
+
exit_code(report, options)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def emit_comparison(report, options)
|
|
183
|
+
old = begin
|
|
184
|
+
JSON.parse(File.read(options[:compare]))
|
|
185
|
+
rescue JSON::ParserError, SystemCallError => e
|
|
186
|
+
raise Error, "cannot read #{options[:compare]}: #{e.message}"
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
budget = Hash.new(0)
|
|
190
|
+
old.fetch("findings", []).each do |f|
|
|
191
|
+
budget[[f["check"], f["path"], f["message"]]] += 1
|
|
192
|
+
end
|
|
193
|
+
total_old = budget.values.sum
|
|
194
|
+
|
|
195
|
+
introduced = report.findings.reject do |f|
|
|
196
|
+
key = [f.check, f.path, f.message]
|
|
197
|
+
next false unless budget[key].positive?
|
|
198
|
+
|
|
199
|
+
budget[key] -= 1
|
|
200
|
+
true
|
|
201
|
+
end
|
|
202
|
+
fixed = total_old -
|
|
203
|
+
(report.findings.size - introduced.size)
|
|
204
|
+
|
|
205
|
+
s = style(options)
|
|
206
|
+
@stdout.puts(
|
|
207
|
+
" compared to #{options[:compare]}: " \
|
|
208
|
+
"#{s.green("#{fixed} fixed")} · " \
|
|
209
|
+
"#{s.red("#{introduced.size} introduced")}"
|
|
210
|
+
)
|
|
211
|
+
introduced.each do |f|
|
|
212
|
+
@stdout.puts(" + #{f.message} (#{f.location})")
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def apply_config(options, config)
|
|
217
|
+
merged = options.dup
|
|
218
|
+
if config.fail_on && !options[:explicit].include?(:fail_on)
|
|
219
|
+
merged[:fail_on] = config.fail_on
|
|
220
|
+
end
|
|
221
|
+
if config.timeout && !options[:explicit].include?(:timeout)
|
|
222
|
+
merged[:timeout] = config.timeout
|
|
223
|
+
end
|
|
224
|
+
merged
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def filter(findings, directives, config)
|
|
228
|
+
directives.filter(findings).reject do |finding|
|
|
229
|
+
config.check_disabled?(finding.check)
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def apply_baseline(findings, target, options)
|
|
234
|
+
return [findings, 0] if options[:no_baseline]
|
|
235
|
+
|
|
236
|
+
baseline = Baseline.load(target.root)
|
|
237
|
+
return [findings, 0] unless baseline
|
|
238
|
+
|
|
239
|
+
baseline.filter(findings, root: target.root)
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def static_findings(target, config)
|
|
243
|
+
files = target.ruby_files.reject do |file|
|
|
244
|
+
config.excluded?(file.delete_prefix("#{target.root}/"))
|
|
245
|
+
end
|
|
246
|
+
per_file = Static::Analyzer.new.analyze_paths(files)
|
|
247
|
+
per_file + Static::GraphAudit.new.analyze_paths(files)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def run_fix(target, findings, options)
|
|
251
|
+
fixer = Fixer.new(unsafe: options[:unsafe])
|
|
252
|
+
if options[:dry_run]
|
|
253
|
+
render_preview(fixer.preview(findings), options)
|
|
254
|
+
return findings
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
applied = fixer.apply(findings)
|
|
258
|
+
total = applied.values.sum
|
|
259
|
+
@stdout.puts(
|
|
260
|
+
"fixed #{total} finding(s) in #{applied.size} file(s)"
|
|
261
|
+
)
|
|
262
|
+
return findings unless total.positive?
|
|
263
|
+
|
|
264
|
+
filter(static_findings(target, Config.load(target.root)),
|
|
265
|
+
Directives.new, Config.load(target.root))
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def render_preview(previews, options)
|
|
269
|
+
s = style(options)
|
|
270
|
+
previews.each do |preview|
|
|
271
|
+
@stdout.puts(s.bold(preview[:path]))
|
|
272
|
+
preview[:hunks].each do |hunk|
|
|
273
|
+
@stdout.puts(" @ line #{hunk[:line]}")
|
|
274
|
+
hunk[:old].each_line do |line|
|
|
275
|
+
@stdout.puts(s.red(" - #{line.chomp}"))
|
|
276
|
+
end
|
|
277
|
+
hunk[:new].each_line do |line|
|
|
278
|
+
@stdout.puts(s.green(" + #{line.chomp}"))
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
@stdout.puts("dry run: no files were changed")
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def prober(options)
|
|
286
|
+
Dynamic::Prober.new(timeout: options[:timeout])
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def emit(report, options)
|
|
290
|
+
case options[:format]
|
|
291
|
+
when :json then @stdout.puts(report.to_json)
|
|
292
|
+
when :github then @stdout.puts(report.to_github)
|
|
293
|
+
else @stdout.puts(report.to_text(style: style(options)))
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def style(options)
|
|
298
|
+
if options[:plain]
|
|
299
|
+
Report::Style.new(color: false, hyperlinks: false)
|
|
300
|
+
else
|
|
301
|
+
Report::Style.detect(io: @stdout)
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def exit_code(report, options)
|
|
306
|
+
threshold = SEVERITIES.fetch(options[:fail_on])
|
|
307
|
+
failed =
|
|
308
|
+
report.findings.any? do |f|
|
|
309
|
+
f.severity_rank >= threshold
|
|
310
|
+
end || report.dynamic_results.any? { |r| !r.passed }
|
|
311
|
+
failed ? 1 : 0
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def deps_target(target)
|
|
315
|
+
return target if target.type == :bundle
|
|
316
|
+
|
|
317
|
+
lockfile = File.join(target.root, "Gemfile.lock")
|
|
318
|
+
unless File.file?(lockfile)
|
|
319
|
+
raise Error, "no Gemfile.lock found in #{target.root}"
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
Target.detect(lockfile)
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
VERDICT_CELLS = {
|
|
326
|
+
:not_ready => "not ready", :blocked => "blocked",
|
|
327
|
+
:risky => "risky", :ready => "ready", nil => "-"
|
|
328
|
+
}.freeze
|
|
329
|
+
|
|
330
|
+
def sweep(target, options)
|
|
331
|
+
sweeper = BundleSweep.new(
|
|
332
|
+
lockfile: target.entry[:lockfile],
|
|
333
|
+
static_only: options[:static_only],
|
|
334
|
+
timeout: options[:timeout]
|
|
335
|
+
)
|
|
336
|
+
rows = sweeper.rows(progress: sweep_progress)
|
|
337
|
+
|
|
338
|
+
if options[:format] == :json
|
|
339
|
+
emit_sweep_json(rows)
|
|
340
|
+
else
|
|
341
|
+
emit_sweep_table(rows)
|
|
342
|
+
end
|
|
343
|
+
(rows.any? { |r| r.verdict == :not_ready }) ? 1 : 0
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def sweep_progress
|
|
347
|
+
return nil unless @stderr.respond_to?(:tty?) && @stderr.tty?
|
|
348
|
+
|
|
349
|
+
lambda do |row, done, total|
|
|
350
|
+
@stderr.puts("audited #{row.name} (#{done}/#{total})")
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
def emit_sweep_table(rows)
|
|
355
|
+
ready = rows.count { |r| r.verdict == :ready }
|
|
356
|
+
table = rows.map do |r|
|
|
357
|
+
{
|
|
358
|
+
"gem" => r.name,
|
|
359
|
+
"version" => r.version,
|
|
360
|
+
"verdict" => VERDICT_CELLS.fetch(r.verdict),
|
|
361
|
+
"errors" => r.errors,
|
|
362
|
+
"dep errors" => r.dep_errors,
|
|
363
|
+
"warnings" => r.warnings,
|
|
364
|
+
"fixable" => r.fixable,
|
|
365
|
+
"status" => r.status
|
|
366
|
+
}
|
|
367
|
+
end
|
|
368
|
+
@stdout.puts(TableTennis.new(table, layout: false).to_s)
|
|
369
|
+
@stdout.puts(
|
|
370
|
+
"#{ready} of #{rows.size} gems ractor-ready"
|
|
371
|
+
)
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
def emit_sweep_json(rows)
|
|
375
|
+
@stdout.puts(JSON.pretty_generate(
|
|
376
|
+
"audition" => VERSION,
|
|
377
|
+
"ruby" => RUBY_VERSION,
|
|
378
|
+
"bundle" => rows.map do |r|
|
|
379
|
+
{
|
|
380
|
+
"gem" => r.name,
|
|
381
|
+
"version" => r.version,
|
|
382
|
+
"verdict" => r.verdict&.to_s,
|
|
383
|
+
"errors" => r.errors,
|
|
384
|
+
"dependency_errors" => r.dep_errors,
|
|
385
|
+
"warnings" => r.warnings,
|
|
386
|
+
"fixable" => r.fixable,
|
|
387
|
+
"status" => r.status
|
|
388
|
+
}
|
|
389
|
+
end
|
|
390
|
+
))
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
def print_capabilities(options)
|
|
394
|
+
result = prober(options).probe(mode: :capabilities)
|
|
395
|
+
caps = result.raw["capabilities"]
|
|
396
|
+
unless caps
|
|
397
|
+
@stderr.puts(
|
|
398
|
+
"audition: capabilities probe failed: #{result.raw}"
|
|
399
|
+
)
|
|
400
|
+
return 2
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
rows = caps.map do |probe, info|
|
|
404
|
+
{
|
|
405
|
+
"works in Ractor" => probe,
|
|
406
|
+
"ok" => info["ok"] ? "yes" : "no",
|
|
407
|
+
"raises" => info["error"] || "-"
|
|
408
|
+
}
|
|
409
|
+
end
|
|
410
|
+
@stdout.puts("ruby #{RUBY_VERSION} at #{RbConfig.ruby}")
|
|
411
|
+
@stdout.puts(TableTennis.new(rows, layout: false).to_s)
|
|
412
|
+
0
|
|
413
|
+
end
|
|
414
|
+
end
|
|
415
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module Audition
|
|
6
|
+
# Project configuration from .audition.yml at the target root:
|
|
7
|
+
#
|
|
8
|
+
# fail_on: warning
|
|
9
|
+
# timeout: 60
|
|
10
|
+
# exclude:
|
|
11
|
+
# - legacy/**
|
|
12
|
+
# - db/schema.rb
|
|
13
|
+
# checks:
|
|
14
|
+
# disable:
|
|
15
|
+
# - at-exit
|
|
16
|
+
#
|
|
17
|
+
# CLI flags always win over config values.
|
|
18
|
+
class Config
|
|
19
|
+
FILE = ".audition.yml"
|
|
20
|
+
|
|
21
|
+
EMPTY = Ractor.make_shareable(
|
|
22
|
+
{fail_on: nil, timeout: nil, exclude: [],
|
|
23
|
+
disabled_checks: []}
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
attr_reader :fail_on, :timeout, :exclude, :disabled_checks
|
|
27
|
+
|
|
28
|
+
# @param root [String] directory that may contain .audition.yml
|
|
29
|
+
# @return [Config] empty config when the file is absent
|
|
30
|
+
# @raise [Audition::Error] on malformed YAML
|
|
31
|
+
def self.load(root)
|
|
32
|
+
path = File.join(root.to_s, FILE)
|
|
33
|
+
return new(**EMPTY) unless File.file?(path)
|
|
34
|
+
|
|
35
|
+
data = YAML.safe_load_file(path) || {}
|
|
36
|
+
new(
|
|
37
|
+
fail_on: data["fail_on"]&.to_sym,
|
|
38
|
+
timeout: data["timeout"],
|
|
39
|
+
exclude: Array(data["exclude"]).map(&:to_s),
|
|
40
|
+
disabled_checks:
|
|
41
|
+
Array(data.dig("checks", "disable")).map(&:to_s)
|
|
42
|
+
)
|
|
43
|
+
rescue Psych::Exception => e
|
|
44
|
+
raise Error, "#{path}: #{e.message}"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def initialize(fail_on:, timeout:, exclude:, disabled_checks:)
|
|
48
|
+
@fail_on = fail_on
|
|
49
|
+
@timeout = timeout
|
|
50
|
+
@exclude = exclude
|
|
51
|
+
@disabled_checks = disabled_checks
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def excluded?(relative_path)
|
|
55
|
+
exclude.any? do |pattern|
|
|
56
|
+
next true if File.fnmatch?(pattern, relative_path)
|
|
57
|
+
|
|
58
|
+
prefix = pattern[%r{\A(.+?)/\*\*(?:/\*+)?\z}, 1]
|
|
59
|
+
prefix && relative_path.start_with?("#{prefix}/")
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def check_disabled?(check)
|
|
64
|
+
disabled_checks.include?(check)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Audition
|
|
4
|
+
# Same-line suppression pragmas:
|
|
5
|
+
#
|
|
6
|
+
# $flag = true # audition:disable global-variables
|
|
7
|
+
# legacy_call # audition:disable
|
|
8
|
+
#
|
|
9
|
+
# A bare pragma silences every check on that line; otherwise only
|
|
10
|
+
# the listed check names. Applied to any finding carrying a real
|
|
11
|
+
# path and line, including runtime findings.
|
|
12
|
+
class Directives
|
|
13
|
+
PATTERN = /#\s*audition:disable\b[ \t]*(?<list>[^#\n]*)/
|
|
14
|
+
|
|
15
|
+
def initialize
|
|
16
|
+
@by_path = {}
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# @param findings [Array<Finding>]
|
|
20
|
+
# @return [Array<Finding>] findings not silenced by a pragma
|
|
21
|
+
def filter(findings)
|
|
22
|
+
findings.reject { |finding| disabled?(finding) }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def disabled?(finding)
|
|
26
|
+
return false unless finding.path && finding.line
|
|
27
|
+
|
|
28
|
+
checks = directives_for(finding.path)[finding.line]
|
|
29
|
+
return false unless checks
|
|
30
|
+
|
|
31
|
+
checks.empty? || checks.include?(finding.check)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def directives_for(path)
|
|
37
|
+
@by_path[path] ||= scan(path)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def scan(path)
|
|
41
|
+
return {} unless File.file?(path)
|
|
42
|
+
|
|
43
|
+
directives = {}
|
|
44
|
+
File.foreach(path).with_index(1) do |line, number|
|
|
45
|
+
match = PATTERN.match(line)
|
|
46
|
+
next unless match
|
|
47
|
+
|
|
48
|
+
directives[number] =
|
|
49
|
+
match[:list].split(/[,\s]+/).reject(&:empty?)
|
|
50
|
+
end
|
|
51
|
+
directives
|
|
52
|
+
rescue SystemCallError
|
|
53
|
+
{}
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|