audition 0.2.4 → 0.4.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/README.md +175 -39
- data/lib/audition/bundle_sweep.rb +32 -15
- data/lib/audition/cli.rb +181 -100
- data/lib/audition/config.rb +33 -7
- data/lib/audition/dynamic/harness.rb +323 -31
- data/lib/audition/dynamic/prober.rb +190 -43
- data/lib/audition/finding.rb +10 -2
- data/lib/audition/progress.rb +418 -0
- data/lib/audition/report/github.rb +69 -0
- data/lib/audition/report/json.rb +68 -0
- data/lib/audition/report/style.rb +74 -0
- data/lib/audition/report/sweep.rb +182 -0
- data/lib/audition/report/text.rb +160 -0
- data/lib/audition/report.rb +16 -295
- data/lib/audition/rewriters.rb +8 -5
- data/lib/audition/static/analyzer.rb +86 -17
- data/lib/audition/static/checks/dependency_class_state.rb +160 -0
- data/lib/audition/static/checks/mutable_constants.rb +162 -28
- data/lib/audition/static/checks/runtime_require.rb +4 -1
- data/lib/audition/static/checks/unsafe_calls.rb +7 -6
- data/lib/audition/static/checks/unshareable_reads.rb +259 -0
- data/lib/audition/static/checks.rb +5 -2
- data/lib/audition/static/gem_calls.rb +1770 -0
- data/lib/audition/static/graph_audit.rb +1053 -12
- data/lib/audition/static/literal_classifier.rb +191 -20
- data/lib/audition/static/native_extensions.rb +175 -0
- data/lib/audition/static/source_file.rb +70 -0
- data/lib/audition/static/work_split.rb +54 -0
- data/lib/audition/target.rb +147 -14
- data/lib/audition/version.rb +1 -1
- data/lib/audition.rb +3 -0
- metadata +15 -4
|
@@ -22,7 +22,7 @@ module Audition
|
|
|
22
22
|
# Spawns the harness subprocess per probe mode, parses its JSON,
|
|
23
23
|
# and converts observations into findings.
|
|
24
24
|
class Prober
|
|
25
|
-
HARNESS = File.expand_path("harness.rb", __dir__)
|
|
25
|
+
HARNESS = File.expand_path("harness.rb", __dir__).freeze
|
|
26
26
|
|
|
27
27
|
RUNTIME_WHY =
|
|
28
28
|
"Observed on the live object graph after loading the " \
|
|
@@ -38,7 +38,10 @@ module Audition
|
|
|
38
38
|
|
|
39
39
|
# Runs the probe described by a {Target#entry} hash.
|
|
40
40
|
#
|
|
41
|
-
# @param entry [Hash] `:mode` plus mode-specific keys
|
|
41
|
+
# @param entry [Hash] `:mode` plus mode-specific keys; require
|
|
42
|
+
# and rails entries may carry `:compiled_files`, the target's
|
|
43
|
+
# own compiled extensions, which the static check already
|
|
44
|
+
# covers and the probe therefore does not report again
|
|
42
45
|
# @return [Result]
|
|
43
46
|
# @raise [Audition::Error] on an unknown mode
|
|
44
47
|
def probe(entry)
|
|
@@ -57,13 +60,13 @@ module Audition
|
|
|
57
60
|
|
|
58
61
|
def probe_script(entry)
|
|
59
62
|
path = entry[:path]
|
|
60
|
-
ractor = run("script_ractor", "path" => path)
|
|
63
|
+
ractor = run("script_ractor", {"path" => path})
|
|
61
64
|
if ractor["ok"]
|
|
62
65
|
return Result.new(mode: :script, raw: ractor,
|
|
63
66
|
findings: [], passed: true)
|
|
64
67
|
end
|
|
65
68
|
|
|
66
|
-
main = run("script_main", "path" => path)
|
|
69
|
+
main = run("script_main", {"path" => path})
|
|
67
70
|
finding = script_finding(path, ractor, main)
|
|
68
71
|
Result.new(mode: :script,
|
|
69
72
|
raw: {"ractor" => ractor, "main" => main},
|
|
@@ -73,6 +76,7 @@ module Audition
|
|
|
73
76
|
def script_finding(path, ractor, main)
|
|
74
77
|
if main["ok"]
|
|
75
78
|
error = describe(ractor)
|
|
79
|
+
site = failure_site(ractor, prefix: "#{path}:")
|
|
76
80
|
Finding.new(
|
|
77
81
|
check: "dynamic-script",
|
|
78
82
|
severity: :error,
|
|
@@ -81,11 +85,12 @@ module Audition
|
|
|
81
85
|
"failed under Ractor.new; the static findings " \
|
|
82
86
|
"usually pinpoint the exact line.",
|
|
83
87
|
fix: "Fix the static findings for this file, then " \
|
|
84
|
-
"re-
|
|
88
|
+
"re-run Audition.",
|
|
85
89
|
path: path,
|
|
86
|
-
line:
|
|
90
|
+
line: site&.last
|
|
87
91
|
)
|
|
88
92
|
else
|
|
93
|
+
site = failure_site(main, prefix: "#{path}:")
|
|
89
94
|
Finding.new(
|
|
90
95
|
check: "dynamic-script",
|
|
91
96
|
severity: :error,
|
|
@@ -94,7 +99,7 @@ module Audition
|
|
|
94
99
|
"Ractor, so Ractor-readiness cannot be assessed.",
|
|
95
100
|
fix: "Make the script run standalone first.",
|
|
96
101
|
path: path,
|
|
97
|
-
line:
|
|
102
|
+
line: site&.last
|
|
98
103
|
)
|
|
99
104
|
end
|
|
100
105
|
end
|
|
@@ -102,36 +107,51 @@ module Audition
|
|
|
102
107
|
def probe_require(entry)
|
|
103
108
|
feature = entry[:feature]
|
|
104
109
|
raw = run("require",
|
|
105
|
-
"feature" => feature,
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
110
|
+
{"feature" => feature,
|
|
111
|
+
"load_paths" => Array(entry[:load_paths]),
|
|
112
|
+
"root" => entry[:root],
|
|
113
|
+
"known_compiled" => Array(entry[:compiled_files]),
|
|
114
|
+
"max_constants" => entry[:max_constants]})
|
|
115
|
+
findings = runtime_findings(raw, feature,
|
|
116
|
+
root: entry[:root])
|
|
109
117
|
Result.new(mode: :require, raw: raw, findings: findings,
|
|
110
118
|
passed: own_clean?(findings))
|
|
111
119
|
end
|
|
112
120
|
|
|
113
121
|
def probe_rails(entry)
|
|
114
122
|
raw = run("rails",
|
|
115
|
-
"environment" => entry[:environment],
|
|
116
|
-
|
|
123
|
+
{"environment" => entry[:environment],
|
|
124
|
+
"root" => entry[:root],
|
|
125
|
+
"known_compiled" => Array(entry[:compiled_files]),
|
|
126
|
+
"max_constants" => entry[:max_constants]},
|
|
127
|
+
root: entry[:root])
|
|
117
128
|
boot = raw["boot"]
|
|
129
|
+
findings = runtime_findings(raw, entry[:environment],
|
|
130
|
+
root: entry[:root])
|
|
118
131
|
if boot && !boot["ok"]
|
|
119
|
-
|
|
132
|
+
# The sweep of whatever loaded before the failure stays:
|
|
133
|
+
# a partial dynamic result beats none.
|
|
134
|
+
why = "Ractor-readiness cannot be fully assessed " \
|
|
135
|
+
"until the application boots."
|
|
136
|
+
if raw["scanned"].to_i.positive?
|
|
137
|
+
why += " Findings below cover what loaded before " \
|
|
138
|
+
"the failure."
|
|
139
|
+
end
|
|
140
|
+
site = failure_site(boot, prefix: own_prefix(entry[:root]))
|
|
141
|
+
findings.unshift(Finding.new(
|
|
120
142
|
check: "dynamic-rails",
|
|
121
143
|
severity: :error,
|
|
122
144
|
message: "Rails failed to boot: #{describe(boot)}",
|
|
123
|
-
why:
|
|
124
|
-
"application boots.",
|
|
145
|
+
why: why,
|
|
125
146
|
fix: "Boot the app (bin/rails runner 1) and fix " \
|
|
126
|
-
"whatever breaks, then re-
|
|
127
|
-
path: entry[:environment],
|
|
128
|
-
line:
|
|
129
|
-
)
|
|
147
|
+
"whatever breaks, then re-run Audition.",
|
|
148
|
+
path: site&.first || entry[:environment],
|
|
149
|
+
line: site&.last
|
|
150
|
+
))
|
|
130
151
|
return Result.new(mode: :rails, raw: raw,
|
|
131
|
-
findings:
|
|
152
|
+
findings: findings, passed: false)
|
|
132
153
|
end
|
|
133
154
|
|
|
134
|
-
findings = runtime_findings(raw, entry[:environment])
|
|
135
155
|
Result.new(mode: :rails, raw: raw, findings: findings,
|
|
136
156
|
passed: own_clean?(findings))
|
|
137
157
|
end
|
|
@@ -145,10 +165,18 @@ module Audition
|
|
|
145
165
|
|
|
146
166
|
def probe_rack(entry)
|
|
147
167
|
config_ru = entry[:config_ru]
|
|
148
|
-
|
|
149
|
-
|
|
168
|
+
root = entry[:root] || File.dirname(config_ru)
|
|
169
|
+
raw = run("rack",
|
|
170
|
+
{"config_ru" => config_ru,
|
|
171
|
+
"root" => root,
|
|
172
|
+
"known_compiled" => Array(entry[:compiled_files]),
|
|
173
|
+
"max_constants" => entry[:max_constants]},
|
|
174
|
+
root: root)
|
|
175
|
+
findings = rack_findings(raw, config_ru, root)
|
|
176
|
+
findings += runtime_findings(raw, config_ru, root: root) unless raw["error"]
|
|
150
177
|
passed = raw.dig("ractor_boot_call", "ok") == true &&
|
|
151
|
-
raw.dig("concurrency", "failures").to_i.zero?
|
|
178
|
+
raw.dig("concurrency", "failures").to_i.zero? &&
|
|
179
|
+
own_clean?(findings)
|
|
152
180
|
Result.new(mode: :rack, raw: raw, findings: findings,
|
|
153
181
|
passed: passed)
|
|
154
182
|
end
|
|
@@ -161,19 +189,28 @@ module Audition
|
|
|
161
189
|
|
|
162
190
|
# -- findings builders ---------------------------------------
|
|
163
191
|
|
|
164
|
-
def runtime_findings(raw, label)
|
|
192
|
+
def runtime_findings(raw, label, root: nil)
|
|
165
193
|
if raw["error"]
|
|
166
|
-
return [load_failure_finding(raw, label)]
|
|
194
|
+
return [load_failure_finding(raw, label, root)]
|
|
167
195
|
end
|
|
168
196
|
|
|
169
197
|
findings = []
|
|
170
198
|
raw.fetch("unshareable_constants", []).each do |entry|
|
|
199
|
+
blocker = entry["blocker"]
|
|
200
|
+
detail =
|
|
201
|
+
if blocker.nil? || blocker == entry["class"]
|
|
202
|
+
""
|
|
203
|
+
elsif entry["blocker_nested"]
|
|
204
|
+
" (blocked by #{blocker} inside)"
|
|
205
|
+
else
|
|
206
|
+
" (just not frozen)"
|
|
207
|
+
end
|
|
171
208
|
findings << runtime_finding(
|
|
172
209
|
entry, label,
|
|
173
210
|
check: "runtime-unshareable-constant",
|
|
174
211
|
severity: :error,
|
|
175
212
|
message: "constant #{entry["const"]} holds an " \
|
|
176
|
-
"unshareable #{entry["class"]}",
|
|
213
|
+
"unshareable #{entry["class"]}#{detail}",
|
|
177
214
|
why: "Reading it from a non-main Ractor raises " \
|
|
178
215
|
"Ractor::IsolationError. #{RUNTIME_WHY}",
|
|
179
216
|
fix: "Freeze it deeply at definition time " \
|
|
@@ -198,9 +235,61 @@ module Audition
|
|
|
198
235
|
"or Ractor-local storage."
|
|
199
236
|
)
|
|
200
237
|
end
|
|
238
|
+
raw.fetch("native_extensions", []).each do |entry|
|
|
239
|
+
next if entry["ruby"] || entry["known"]
|
|
240
|
+
|
|
241
|
+
findings << native_finding(entry, label)
|
|
242
|
+
end
|
|
243
|
+
# A truncated sweep must never read as a clean one.
|
|
244
|
+
if raw["truncated"]
|
|
245
|
+
findings.unshift(Finding.new(
|
|
246
|
+
check: "runtime-scan",
|
|
247
|
+
severity: :warning,
|
|
248
|
+
message: "constant sweep truncated after " \
|
|
249
|
+
"#{raw["scanned"]} constants " \
|
|
250
|
+
"(limit #{raw["limit"]})",
|
|
251
|
+
why: "Constants beyond the limit were never probed, " \
|
|
252
|
+
"so their absence from the findings proves " \
|
|
253
|
+
"nothing.",
|
|
254
|
+
fix: "Raise the probe's max_constants and re-run.",
|
|
255
|
+
path: label,
|
|
256
|
+
line: nil
|
|
257
|
+
))
|
|
258
|
+
end
|
|
201
259
|
findings
|
|
202
260
|
end
|
|
203
261
|
|
|
262
|
+
# Ruby's own extensions are left to Ruby, and files the static
|
|
263
|
+
# check already reported are not repeated; what remains is the
|
|
264
|
+
# native code the target pulls in through its dependencies.
|
|
265
|
+
def native_finding(entry, label)
|
|
266
|
+
name = File.basename(entry["path"])
|
|
267
|
+
native = Static::NativeExtensions
|
|
268
|
+
if entry["declares"]
|
|
269
|
+
runtime_finding(
|
|
270
|
+
entry, label,
|
|
271
|
+
check: "runtime-native-extension",
|
|
272
|
+
severity: :info,
|
|
273
|
+
message: "compiled extension #{name} declares Ractor " \
|
|
274
|
+
"safety (imports #{native::SYMBOL})",
|
|
275
|
+
why: "#{native::DECLARED_WHY} Loaded while requiring " \
|
|
276
|
+
"the target.",
|
|
277
|
+
fix: native::DECLARED_FIX
|
|
278
|
+
)
|
|
279
|
+
else
|
|
280
|
+
runtime_finding(
|
|
281
|
+
entry, label,
|
|
282
|
+
check: "runtime-native-extension",
|
|
283
|
+
severity: :warning,
|
|
284
|
+
message: "compiled extension #{name} does not declare " \
|
|
285
|
+
"Ractor safety",
|
|
286
|
+
why: "#{native::SILENT_WHY}#{native::COMPILED_TAIL} " \
|
|
287
|
+
"Loaded while requiring the target.",
|
|
288
|
+
fix: native::SILENT_FIX
|
|
289
|
+
)
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
|
|
204
293
|
# Class-level state holding only shareable values is the
|
|
205
294
|
# warmed frozen-memoization shape: reads are legal from any
|
|
206
295
|
# Ractor, so it rates an info note; unshareable values are
|
|
@@ -251,7 +340,8 @@ module Audition
|
|
|
251
340
|
)
|
|
252
341
|
end
|
|
253
342
|
|
|
254
|
-
def load_failure_finding(raw, label)
|
|
343
|
+
def load_failure_finding(raw, label, root = nil)
|
|
344
|
+
site = failure_site(raw, prefix: own_prefix(root))
|
|
255
345
|
Finding.new(
|
|
256
346
|
check: "runtime-load",
|
|
257
347
|
severity: :error,
|
|
@@ -259,14 +349,14 @@ module Audition
|
|
|
259
349
|
why: "Ractor-readiness cannot be assessed until the " \
|
|
260
350
|
"target loads.",
|
|
261
351
|
fix: "Make `require` succeed on a bare Ruby first.",
|
|
262
|
-
path: label,
|
|
263
|
-
line:
|
|
352
|
+
path: site&.first || label,
|
|
353
|
+
line: site&.last
|
|
264
354
|
)
|
|
265
355
|
end
|
|
266
356
|
|
|
267
|
-
def rack_findings(raw, config_ru)
|
|
357
|
+
def rack_findings(raw, config_ru, root = nil)
|
|
268
358
|
if raw.dig("ractor_boot_call", "ok")
|
|
269
|
-
return concurrency_findings(raw, config_ru)
|
|
359
|
+
return concurrency_findings(raw, config_ru, root)
|
|
270
360
|
end
|
|
271
361
|
|
|
272
362
|
if raw["rack_available"] == false
|
|
@@ -275,7 +365,7 @@ module Audition
|
|
|
275
365
|
severity: :warning,
|
|
276
366
|
message: "rack gem not available in the probe process",
|
|
277
367
|
why: "The rack probe boots the app via Rack::Builder.",
|
|
278
|
-
fix: "Install rack next to
|
|
368
|
+
fix: "Install rack next to Audition and re-run.",
|
|
279
369
|
path: config_ru,
|
|
280
370
|
line: nil
|
|
281
371
|
)]
|
|
@@ -291,6 +381,10 @@ module Audition
|
|
|
291
381
|
"booting config.ru and serving one GET / inside a " \
|
|
292
382
|
"Ractor failed."
|
|
293
383
|
end
|
|
384
|
+
site = failure_site(raw["ractor_boot_call"],
|
|
385
|
+
prefix: own_prefix(root)) ||
|
|
386
|
+
failure_site({"error" => raw["main_boot_error"]},
|
|
387
|
+
prefix: own_prefix(root))
|
|
294
388
|
[Finding.new(
|
|
295
389
|
check: "dynamic-rack",
|
|
296
390
|
severity: :error,
|
|
@@ -299,16 +393,18 @@ module Audition
|
|
|
299
393
|
fix: "Remove global/class-level state touched during " \
|
|
300
394
|
"boot and request handling; keep middleware config " \
|
|
301
395
|
"frozen; open connections per-Ractor.",
|
|
302
|
-
path: config_ru,
|
|
303
|
-
line:
|
|
396
|
+
path: site&.first || config_ru,
|
|
397
|
+
line: site&.last
|
|
304
398
|
)]
|
|
305
399
|
end
|
|
306
400
|
|
|
307
|
-
def concurrency_findings(raw, config_ru)
|
|
401
|
+
def concurrency_findings(raw, config_ru, root = nil)
|
|
308
402
|
stats = raw["concurrency"] || {}
|
|
309
403
|
failures = stats["failures"].to_i
|
|
310
404
|
return [] if failures.zero?
|
|
311
405
|
|
|
406
|
+
site = failure_site({"error" => stats["first_error"]},
|
|
407
|
+
prefix: own_prefix(root))
|
|
312
408
|
[Finding.new(
|
|
313
409
|
check: "dynamic-rack-concurrency",
|
|
314
410
|
severity: :error,
|
|
@@ -320,11 +416,43 @@ module Audition
|
|
|
320
416
|
"shared state races. #{RUNTIME_WHY}",
|
|
321
417
|
fix: "Look for process-global state touched during " \
|
|
322
418
|
"request handling and boot.",
|
|
323
|
-
path: config_ru,
|
|
324
|
-
line:
|
|
419
|
+
path: site&.first || config_ru,
|
|
420
|
+
line: site&.last
|
|
325
421
|
)]
|
|
326
422
|
end
|
|
327
423
|
|
|
424
|
+
# A backtrace frame inside the target names the failing
|
|
425
|
+
# line; frames outside it stay unattributed rather than
|
|
426
|
+
# pinning a finding to a dependency's file.
|
|
427
|
+
BACKTRACE_FRAME = /\A(.+?):(\d+):in /
|
|
428
|
+
|
|
429
|
+
def failure_site(hash, prefix:)
|
|
430
|
+
prefixes = Array(prefix)
|
|
431
|
+
return nil if prefixes.empty? || !hash.is_a?(Hash)
|
|
432
|
+
|
|
433
|
+
error = hash["error"].is_a?(Hash) ? hash["error"] : hash
|
|
434
|
+
frame = Array(error["backtrace"]).find do |f|
|
|
435
|
+
f.is_a?(String) && prefixes.any? { |p| f.start_with?(p) }
|
|
436
|
+
end
|
|
437
|
+
match = frame&.match(BACKTRACE_FRAME)
|
|
438
|
+
match && [match[1], Integer(match[2], 10)]
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
# require realpaths frames while eval keeps paths as given,
|
|
442
|
+
# so a symlinked root (macOS /var) must match both spellings.
|
|
443
|
+
def own_prefix(root)
|
|
444
|
+
return nil if root.nil?
|
|
445
|
+
|
|
446
|
+
[root + File::SEPARATOR,
|
|
447
|
+
realpath(root) + File::SEPARATOR].uniq
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
def realpath(path)
|
|
451
|
+
File.realpath(path)
|
|
452
|
+
rescue SystemCallError
|
|
453
|
+
path
|
|
454
|
+
end
|
|
455
|
+
|
|
328
456
|
def describe(hash)
|
|
329
457
|
error = hash.is_a?(Hash) ? (hash["error"] || hash) : {}
|
|
330
458
|
klass = error["class"] || "UnknownError"
|
|
@@ -337,8 +465,8 @@ module Audition
|
|
|
337
465
|
# Harness output can carry arbitrary target bytes; force
|
|
338
466
|
# valid UTF-8 before any string work or a binary exception
|
|
339
467
|
# message crashes the whole run.
|
|
340
|
-
def run(mode, payload = {})
|
|
341
|
-
out, err, timed_out = execute(mode, payload)
|
|
468
|
+
def run(mode, payload = {}, root: nil)
|
|
469
|
+
out, err, timed_out = execute(mode, payload, root: root)
|
|
342
470
|
out = sanitize(out)
|
|
343
471
|
err = sanitize(err)
|
|
344
472
|
if timed_out
|
|
@@ -364,9 +492,28 @@ module Audition
|
|
|
364
492
|
# child the target spawned inherits our pipes and would
|
|
365
493
|
# otherwise hold the read until it exits, defeating the
|
|
366
494
|
# timeout and leaving orphans behind.
|
|
367
|
-
def execute(mode, payload)
|
|
495
|
+
def execute(mode, payload, root: nil)
|
|
368
496
|
cmd = [@ruby, "-W0", HARNESS, mode]
|
|
369
|
-
|
|
497
|
+
# An app boots against its own bundle: the subprocess runs
|
|
498
|
+
# from the target root with the target's Gemfile. When
|
|
499
|
+
# Audition itself runs under bundle exec, the inherited
|
|
500
|
+
# Bundler environment (RUBYOPT's -rbundler/setup above all)
|
|
501
|
+
# would activate Audition's bundle inside the child before
|
|
502
|
+
# the harness starts, so it is scrubbed first.
|
|
503
|
+
env = {}
|
|
504
|
+
opts = {pgroup: true}
|
|
505
|
+
if root && File.directory?(root)
|
|
506
|
+
opts[:chdir] = root
|
|
507
|
+
gemfile = File.join(root, "Gemfile")
|
|
508
|
+
if File.file?(gemfile)
|
|
509
|
+
ENV.each_key do |key|
|
|
510
|
+
env[key] = nil if key.start_with?("BUNDLE") ||
|
|
511
|
+
%w[RUBYOPT RUBYLIB].include?(key)
|
|
512
|
+
end
|
|
513
|
+
env["BUNDLE_GEMFILE"] = gemfile
|
|
514
|
+
end
|
|
515
|
+
end
|
|
516
|
+
Open3.popen3(env, *cmd, **opts) do |stdin, stdout, stderr, wait|
|
|
370
517
|
stdin.write(JSON.generate(payload))
|
|
371
518
|
stdin.close
|
|
372
519
|
out_reader = reader(stdout)
|
data/lib/audition/finding.rb
CHANGED
|
@@ -64,12 +64,14 @@ module Audition
|
|
|
64
64
|
# @return [Autofix, nil] machine-applicable correction
|
|
65
65
|
# @!attribute [r] dependency
|
|
66
66
|
# @return [Boolean] see {#dependency?}
|
|
67
|
+
# @!attribute [r] test
|
|
68
|
+
# @return [Boolean] see {#test?}
|
|
67
69
|
Finding = Data.define(
|
|
68
70
|
:check, :severity, :message, :why, :fix,
|
|
69
|
-
:path, :line, :source, :autofix, :dependency
|
|
71
|
+
:path, :line, :source, :autofix, :dependency, :test
|
|
70
72
|
) do
|
|
71
73
|
def initialize(source: nil, autofix: nil, dependency: false,
|
|
72
|
-
**rest)
|
|
74
|
+
test: false, **rest)
|
|
73
75
|
super
|
|
74
76
|
end
|
|
75
77
|
|
|
@@ -82,6 +84,12 @@ module Audition
|
|
|
82
84
|
# @return [Boolean]
|
|
83
85
|
def dependency? = dependency
|
|
84
86
|
|
|
87
|
+
# True when the problem lives in the target's test or spec
|
|
88
|
+
# code: real, but never loaded by the production boot.
|
|
89
|
+
#
|
|
90
|
+
# @return [Boolean]
|
|
91
|
+
def test? = test
|
|
92
|
+
|
|
85
93
|
# @return [Boolean] whether an {Autofix} is attached
|
|
86
94
|
def fixable? = !autofix.nil?
|
|
87
95
|
|