audition 0.3.0 → 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 +77 -33
- data/lib/audition/bundle_sweep.rb +24 -15
- data/lib/audition/cli.rb +127 -140
- data/lib/audition/config.rb +30 -4
- data/lib/audition/dynamic/harness.rb +219 -20
- data/lib/audition/dynamic/prober.rb +150 -44
- data/lib/audition/finding.rb +10 -2
- data/lib/audition/progress.rb +418 -0
- data/lib/audition/report/github.rb +4 -3
- data/lib/audition/report/json.rb +5 -1
- data/lib/audition/report/sweep.rb +182 -0
- data/lib/audition/report/text.rb +13 -2
- data/lib/audition/report.rb +8 -1
- data/lib/audition/rewriters.rb +4 -1
- 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 +71 -0
- 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 +1050 -7
- data/lib/audition/static/literal_classifier.rb +118 -18
- data/lib/audition/static/native_extensions.rb +28 -16
- data/lib/audition/static/work_split.rb +54 -0
- data/lib/audition/target.rb +82 -13
- data/lib/audition/version.rb +1 -1
- data/lib/audition.rb +2 -0
- metadata +10 -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 " \
|
|
@@ -60,13 +60,13 @@ module Audition
|
|
|
60
60
|
|
|
61
61
|
def probe_script(entry)
|
|
62
62
|
path = entry[:path]
|
|
63
|
-
ractor = run("script_ractor", "path" => path)
|
|
63
|
+
ractor = run("script_ractor", {"path" => path})
|
|
64
64
|
if ractor["ok"]
|
|
65
65
|
return Result.new(mode: :script, raw: ractor,
|
|
66
66
|
findings: [], passed: true)
|
|
67
67
|
end
|
|
68
68
|
|
|
69
|
-
main = run("script_main", "path" => path)
|
|
69
|
+
main = run("script_main", {"path" => path})
|
|
70
70
|
finding = script_finding(path, ractor, main)
|
|
71
71
|
Result.new(mode: :script,
|
|
72
72
|
raw: {"ractor" => ractor, "main" => main},
|
|
@@ -76,6 +76,7 @@ module Audition
|
|
|
76
76
|
def script_finding(path, ractor, main)
|
|
77
77
|
if main["ok"]
|
|
78
78
|
error = describe(ractor)
|
|
79
|
+
site = failure_site(ractor, prefix: "#{path}:")
|
|
79
80
|
Finding.new(
|
|
80
81
|
check: "dynamic-script",
|
|
81
82
|
severity: :error,
|
|
@@ -84,11 +85,12 @@ module Audition
|
|
|
84
85
|
"failed under Ractor.new; the static findings " \
|
|
85
86
|
"usually pinpoint the exact line.",
|
|
86
87
|
fix: "Fix the static findings for this file, then " \
|
|
87
|
-
"re-
|
|
88
|
+
"re-run Audition.",
|
|
88
89
|
path: path,
|
|
89
|
-
line:
|
|
90
|
+
line: site&.last
|
|
90
91
|
)
|
|
91
92
|
else
|
|
93
|
+
site = failure_site(main, prefix: "#{path}:")
|
|
92
94
|
Finding.new(
|
|
93
95
|
check: "dynamic-script",
|
|
94
96
|
severity: :error,
|
|
@@ -97,7 +99,7 @@ module Audition
|
|
|
97
99
|
"Ractor, so Ractor-readiness cannot be assessed.",
|
|
98
100
|
fix: "Make the script run standalone first.",
|
|
99
101
|
path: path,
|
|
100
|
-
line:
|
|
102
|
+
line: site&.last
|
|
101
103
|
)
|
|
102
104
|
end
|
|
103
105
|
end
|
|
@@ -105,38 +107,51 @@ module Audition
|
|
|
105
107
|
def probe_require(entry)
|
|
106
108
|
feature = entry[:feature]
|
|
107
109
|
raw = run("require",
|
|
108
|
-
"feature" => feature,
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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])
|
|
113
117
|
Result.new(mode: :require, raw: raw, findings: findings,
|
|
114
118
|
passed: own_clean?(findings))
|
|
115
119
|
end
|
|
116
120
|
|
|
117
121
|
def probe_rails(entry)
|
|
118
122
|
raw = run("rails",
|
|
119
|
-
"environment" => entry[:environment],
|
|
120
|
-
|
|
121
|
-
|
|
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])
|
|
122
128
|
boot = raw["boot"]
|
|
129
|
+
findings = runtime_findings(raw, entry[:environment],
|
|
130
|
+
root: entry[:root])
|
|
123
131
|
if boot && !boot["ok"]
|
|
124
|
-
|
|
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(
|
|
125
142
|
check: "dynamic-rails",
|
|
126
143
|
severity: :error,
|
|
127
144
|
message: "Rails failed to boot: #{describe(boot)}",
|
|
128
|
-
why:
|
|
129
|
-
"application boots.",
|
|
145
|
+
why: why,
|
|
130
146
|
fix: "Boot the app (bin/rails runner 1) and fix " \
|
|
131
|
-
"whatever breaks, then re-
|
|
132
|
-
path: entry[:environment],
|
|
133
|
-
line:
|
|
134
|
-
)
|
|
147
|
+
"whatever breaks, then re-run Audition.",
|
|
148
|
+
path: site&.first || entry[:environment],
|
|
149
|
+
line: site&.last
|
|
150
|
+
))
|
|
135
151
|
return Result.new(mode: :rails, raw: raw,
|
|
136
|
-
findings:
|
|
152
|
+
findings: findings, passed: false)
|
|
137
153
|
end
|
|
138
154
|
|
|
139
|
-
findings = runtime_findings(raw, entry[:environment])
|
|
140
155
|
Result.new(mode: :rails, raw: raw, findings: findings,
|
|
141
156
|
passed: own_clean?(findings))
|
|
142
157
|
end
|
|
@@ -150,10 +165,18 @@ module Audition
|
|
|
150
165
|
|
|
151
166
|
def probe_rack(entry)
|
|
152
167
|
config_ru = entry[:config_ru]
|
|
153
|
-
|
|
154
|
-
|
|
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"]
|
|
155
177
|
passed = raw.dig("ractor_boot_call", "ok") == true &&
|
|
156
|
-
raw.dig("concurrency", "failures").to_i.zero?
|
|
178
|
+
raw.dig("concurrency", "failures").to_i.zero? &&
|
|
179
|
+
own_clean?(findings)
|
|
157
180
|
Result.new(mode: :rack, raw: raw, findings: findings,
|
|
158
181
|
passed: passed)
|
|
159
182
|
end
|
|
@@ -166,19 +189,28 @@ module Audition
|
|
|
166
189
|
|
|
167
190
|
# -- findings builders ---------------------------------------
|
|
168
191
|
|
|
169
|
-
def runtime_findings(raw, label)
|
|
192
|
+
def runtime_findings(raw, label, root: nil)
|
|
170
193
|
if raw["error"]
|
|
171
|
-
return [load_failure_finding(raw, label)]
|
|
194
|
+
return [load_failure_finding(raw, label, root)]
|
|
172
195
|
end
|
|
173
196
|
|
|
174
197
|
findings = []
|
|
175
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
|
|
176
208
|
findings << runtime_finding(
|
|
177
209
|
entry, label,
|
|
178
210
|
check: "runtime-unshareable-constant",
|
|
179
211
|
severity: :error,
|
|
180
212
|
message: "constant #{entry["const"]} holds an " \
|
|
181
|
-
"unshareable #{entry["class"]}",
|
|
213
|
+
"unshareable #{entry["class"]}#{detail}",
|
|
182
214
|
why: "Reading it from a non-main Ractor raises " \
|
|
183
215
|
"Ractor::IsolationError. #{RUNTIME_WHY}",
|
|
184
216
|
fix: "Freeze it deeply at definition time " \
|
|
@@ -208,6 +240,22 @@ module Audition
|
|
|
208
240
|
|
|
209
241
|
findings << native_finding(entry, label)
|
|
210
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
|
|
211
259
|
findings
|
|
212
260
|
end
|
|
213
261
|
|
|
@@ -292,7 +340,8 @@ module Audition
|
|
|
292
340
|
)
|
|
293
341
|
end
|
|
294
342
|
|
|
295
|
-
def load_failure_finding(raw, label)
|
|
343
|
+
def load_failure_finding(raw, label, root = nil)
|
|
344
|
+
site = failure_site(raw, prefix: own_prefix(root))
|
|
296
345
|
Finding.new(
|
|
297
346
|
check: "runtime-load",
|
|
298
347
|
severity: :error,
|
|
@@ -300,14 +349,14 @@ module Audition
|
|
|
300
349
|
why: "Ractor-readiness cannot be assessed until the " \
|
|
301
350
|
"target loads.",
|
|
302
351
|
fix: "Make `require` succeed on a bare Ruby first.",
|
|
303
|
-
path: label,
|
|
304
|
-
line:
|
|
352
|
+
path: site&.first || label,
|
|
353
|
+
line: site&.last
|
|
305
354
|
)
|
|
306
355
|
end
|
|
307
356
|
|
|
308
|
-
def rack_findings(raw, config_ru)
|
|
357
|
+
def rack_findings(raw, config_ru, root = nil)
|
|
309
358
|
if raw.dig("ractor_boot_call", "ok")
|
|
310
|
-
return concurrency_findings(raw, config_ru)
|
|
359
|
+
return concurrency_findings(raw, config_ru, root)
|
|
311
360
|
end
|
|
312
361
|
|
|
313
362
|
if raw["rack_available"] == false
|
|
@@ -316,7 +365,7 @@ module Audition
|
|
|
316
365
|
severity: :warning,
|
|
317
366
|
message: "rack gem not available in the probe process",
|
|
318
367
|
why: "The rack probe boots the app via Rack::Builder.",
|
|
319
|
-
fix: "Install rack next to
|
|
368
|
+
fix: "Install rack next to Audition and re-run.",
|
|
320
369
|
path: config_ru,
|
|
321
370
|
line: nil
|
|
322
371
|
)]
|
|
@@ -332,6 +381,10 @@ module Audition
|
|
|
332
381
|
"booting config.ru and serving one GET / inside a " \
|
|
333
382
|
"Ractor failed."
|
|
334
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))
|
|
335
388
|
[Finding.new(
|
|
336
389
|
check: "dynamic-rack",
|
|
337
390
|
severity: :error,
|
|
@@ -340,16 +393,18 @@ module Audition
|
|
|
340
393
|
fix: "Remove global/class-level state touched during " \
|
|
341
394
|
"boot and request handling; keep middleware config " \
|
|
342
395
|
"frozen; open connections per-Ractor.",
|
|
343
|
-
path: config_ru,
|
|
344
|
-
line:
|
|
396
|
+
path: site&.first || config_ru,
|
|
397
|
+
line: site&.last
|
|
345
398
|
)]
|
|
346
399
|
end
|
|
347
400
|
|
|
348
|
-
def concurrency_findings(raw, config_ru)
|
|
401
|
+
def concurrency_findings(raw, config_ru, root = nil)
|
|
349
402
|
stats = raw["concurrency"] || {}
|
|
350
403
|
failures = stats["failures"].to_i
|
|
351
404
|
return [] if failures.zero?
|
|
352
405
|
|
|
406
|
+
site = failure_site({"error" => stats["first_error"]},
|
|
407
|
+
prefix: own_prefix(root))
|
|
353
408
|
[Finding.new(
|
|
354
409
|
check: "dynamic-rack-concurrency",
|
|
355
410
|
severity: :error,
|
|
@@ -361,11 +416,43 @@ module Audition
|
|
|
361
416
|
"shared state races. #{RUNTIME_WHY}",
|
|
362
417
|
fix: "Look for process-global state touched during " \
|
|
363
418
|
"request handling and boot.",
|
|
364
|
-
path: config_ru,
|
|
365
|
-
line:
|
|
419
|
+
path: site&.first || config_ru,
|
|
420
|
+
line: site&.last
|
|
366
421
|
)]
|
|
367
422
|
end
|
|
368
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
|
+
|
|
369
456
|
def describe(hash)
|
|
370
457
|
error = hash.is_a?(Hash) ? (hash["error"] || hash) : {}
|
|
371
458
|
klass = error["class"] || "UnknownError"
|
|
@@ -378,8 +465,8 @@ module Audition
|
|
|
378
465
|
# Harness output can carry arbitrary target bytes; force
|
|
379
466
|
# valid UTF-8 before any string work or a binary exception
|
|
380
467
|
# message crashes the whole run.
|
|
381
|
-
def run(mode, payload = {})
|
|
382
|
-
out, err, timed_out = execute(mode, payload)
|
|
468
|
+
def run(mode, payload = {}, root: nil)
|
|
469
|
+
out, err, timed_out = execute(mode, payload, root: root)
|
|
383
470
|
out = sanitize(out)
|
|
384
471
|
err = sanitize(err)
|
|
385
472
|
if timed_out
|
|
@@ -405,9 +492,28 @@ module Audition
|
|
|
405
492
|
# child the target spawned inherits our pipes and would
|
|
406
493
|
# otherwise hold the read until it exits, defeating the
|
|
407
494
|
# timeout and leaving orphans behind.
|
|
408
|
-
def execute(mode, payload)
|
|
495
|
+
def execute(mode, payload, root: nil)
|
|
409
496
|
cmd = [@ruby, "-W0", HARNESS, mode]
|
|
410
|
-
|
|
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|
|
|
411
517
|
stdin.write(JSON.generate(payload))
|
|
412
518
|
stdin.close
|
|
413
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
|
|