audition 0.3.0 → 0.4.1
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 +125 -49
- data/lib/audition/bundle_sweep.rb +25 -15
- data/lib/audition/cli.rb +128 -140
- data/lib/audition/config.rb +30 -4
- data/lib/audition/dynamic/harness.rb +415 -41
- data/lib/audition/dynamic/prober.rb +271 -54
- data/lib/audition/finding.rb +14 -2
- data/lib/audition/progress.rb +418 -0
- data/lib/audition/reconciliation.rb +86 -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 +29 -2
- data/lib/audition/static/analyzer.rb +86 -17
- data/lib/audition/static/checks/dependency_class_state.rb +160 -0
- data/lib/audition/static/checks/instance_memoization.rb +226 -0
- data/lib/audition/static/checks/mutable_constants.rb +172 -9
- data/lib/audition/static/checks/ractor_isolation.rb +214 -15
- data/lib/audition/static/checks/unsafe_calls.rb +7 -4
- data/lib/audition/static/checks/unshareable_reads.rb +259 -0
- data/lib/audition/static/checks.rb +6 -2
- data/lib/audition/static/gem_calls.rb +1770 -0
- data/lib/audition/static/graph_audit.rb +1113 -15
- data/lib/audition/static/literal_classifier.rb +385 -20
- 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 +3 -0
- metadata +12 -4
|
@@ -12,17 +12,17 @@ module Audition
|
|
|
12
12
|
# @return [Symbol] `:script`, `:require`, `:rack`, `:rails`,
|
|
13
13
|
# or `:capabilities`
|
|
14
14
|
# @!attribute [r] raw
|
|
15
|
-
# @return [Hash] the harness's
|
|
15
|
+
# @return [Hash] the harness's document, verbatim
|
|
16
16
|
# @!attribute [r] findings
|
|
17
17
|
# @return [Array<Finding>] findings derived from `raw`
|
|
18
18
|
# @!attribute [r] passed
|
|
19
19
|
# @return [Boolean] whether the target's own surface passed
|
|
20
20
|
Result = Data.define(:mode, :raw, :findings, :passed)
|
|
21
21
|
|
|
22
|
-
# Spawns the harness subprocess per probe mode,
|
|
23
|
-
# and converts observations into findings.
|
|
22
|
+
# Spawns the harness subprocess per probe mode, loads the
|
|
23
|
+
# document it prints, 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,42 +107,144 @@ 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
|
|
155
|
+
findings.concat(ractorize_findings(raw["ractorize"], entry))
|
|
140
156
|
Result.new(mode: :rails, raw: raw, findings: findings,
|
|
141
157
|
passed: own_clean?(findings))
|
|
142
158
|
end
|
|
143
159
|
|
|
160
|
+
# What happened once the booted application was frozen: no
|
|
161
|
+
# entry point on this Rails, the first object it could not
|
|
162
|
+
# share, a request that broke on the frozen graph, or one
|
|
163
|
+
# that only broke inside a worker.
|
|
164
|
+
def ractorize_findings(info, entry)
|
|
165
|
+
return [] unless info.is_a?(Hash)
|
|
166
|
+
|
|
167
|
+
environment = entry[:environment]
|
|
168
|
+
prefix = own_prefix(entry[:root])
|
|
169
|
+
unless info["available"]
|
|
170
|
+
return [Finding.new(
|
|
171
|
+
check: "dynamic-rails",
|
|
172
|
+
severity: :info,
|
|
173
|
+
message: "Rails #{info["rails"]} has no ractorize!; " \
|
|
174
|
+
"the application graph was not frozen",
|
|
175
|
+
why: "Rails 8.2 adds Rails::Application#ractorize!, " \
|
|
176
|
+
"which deep-freezes the application and " \
|
|
177
|
+
"everything it reaches; a lazy memoization on " \
|
|
178
|
+
"any object in that graph raises FrozenError " \
|
|
179
|
+
"afterwards. Without it the probe can only " \
|
|
180
|
+
"sweep constants and class-level state.",
|
|
181
|
+
fix: "Upgrade to Rails 8.2 so the probe can freeze " \
|
|
182
|
+
"the application and serve a request through it.",
|
|
183
|
+
path: environment,
|
|
184
|
+
line: nil
|
|
185
|
+
)]
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
unless info["ok"]
|
|
189
|
+
site = failure_site(info, prefix: prefix)
|
|
190
|
+
return [Finding.new(
|
|
191
|
+
check: "dynamic-rails",
|
|
192
|
+
severity: :error,
|
|
193
|
+
message: "ractorize! failed: #{describe(info)}",
|
|
194
|
+
why: "Rails::Application#ractorize! deep-freezes the " \
|
|
195
|
+
"application and everything it reaches (routes, " \
|
|
196
|
+
"middleware, configuration); the object the " \
|
|
197
|
+
"error names is the first one that cannot be " \
|
|
198
|
+
"shared. #{RUNTIME_WHY}",
|
|
199
|
+
fix: "Make the named object shareable: freeze it, " \
|
|
200
|
+
"drop the Proc, Mutex, or IO it holds, or keep " \
|
|
201
|
+
"it per-Ractor; then re-run.",
|
|
202
|
+
path: site&.first || environment,
|
|
203
|
+
line: site&.last
|
|
204
|
+
)]
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
main = info["main_request"] || {}
|
|
208
|
+
unless main["ok"]
|
|
209
|
+
site = failure_site(main, prefix: prefix)
|
|
210
|
+
return [Finding.new(
|
|
211
|
+
check: "dynamic-rails",
|
|
212
|
+
severity: :error,
|
|
213
|
+
message: "GET / after ractorize! failed on the main " \
|
|
214
|
+
"Ractor: #{describe(main)}",
|
|
215
|
+
why: "The application is frozen, so a lazy " \
|
|
216
|
+
"memoization on any object the request touches " \
|
|
217
|
+
"writes an instance variable on a frozen object " \
|
|
218
|
+
"and raises FrozenError. #{RUNTIME_WHY}",
|
|
219
|
+
fix: "Compute the value eagerly in initialize, warm " \
|
|
220
|
+
"it in a freeze override that calls the reader " \
|
|
221
|
+
"before super, or drop the memo and recompute.",
|
|
222
|
+
path: site&.first || environment,
|
|
223
|
+
line: site&.last
|
|
224
|
+
)]
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
ractor = info["ractor_request"] || {}
|
|
228
|
+
return [] if ractor["ok"]
|
|
229
|
+
|
|
230
|
+
site = failure_site(ractor, prefix: prefix)
|
|
231
|
+
[Finding.new(
|
|
232
|
+
check: "dynamic-rails",
|
|
233
|
+
severity: :error,
|
|
234
|
+
message: "GET / inside a Ractor after ractorize! " \
|
|
235
|
+
"failed: #{describe(ractor)}",
|
|
236
|
+
why: "Serving on the main Ractor after ractorize! " \
|
|
237
|
+
"worked; inside a worker the request touched " \
|
|
238
|
+
"state a non-main Ractor cannot reach. " \
|
|
239
|
+
"#{RUNTIME_WHY}",
|
|
240
|
+
fix: "Remove global and class-level state touched " \
|
|
241
|
+
"during request handling; keep per-Ractor state " \
|
|
242
|
+
"in Ractor.store_if_absent.",
|
|
243
|
+
path: site&.first || environment,
|
|
244
|
+
line: site&.last
|
|
245
|
+
)]
|
|
246
|
+
end
|
|
247
|
+
|
|
144
248
|
# A probe passes when the target's own surface is clean;
|
|
145
249
|
# dependency errors surface in the findings and drive the
|
|
146
250
|
# blocked verdict instead.
|
|
@@ -150,10 +254,18 @@ module Audition
|
|
|
150
254
|
|
|
151
255
|
def probe_rack(entry)
|
|
152
256
|
config_ru = entry[:config_ru]
|
|
153
|
-
|
|
154
|
-
|
|
257
|
+
root = entry[:root] || File.dirname(config_ru)
|
|
258
|
+
raw = run("rack",
|
|
259
|
+
{"config_ru" => config_ru,
|
|
260
|
+
"root" => root,
|
|
261
|
+
"known_compiled" => Array(entry[:compiled_files]),
|
|
262
|
+
"max_constants" => entry[:max_constants]},
|
|
263
|
+
root: root)
|
|
264
|
+
findings = rack_findings(raw, config_ru, root)
|
|
265
|
+
findings += runtime_findings(raw, config_ru, root: root) unless raw["error"]
|
|
155
266
|
passed = raw.dig("ractor_boot_call", "ok") == true &&
|
|
156
|
-
raw.dig("concurrency", "failures").to_i.zero?
|
|
267
|
+
raw.dig("concurrency", "failures").to_i.zero? &&
|
|
268
|
+
own_clean?(findings)
|
|
157
269
|
Result.new(mode: :rack, raw: raw, findings: findings,
|
|
158
270
|
passed: passed)
|
|
159
271
|
end
|
|
@@ -166,19 +278,28 @@ module Audition
|
|
|
166
278
|
|
|
167
279
|
# -- findings builders ---------------------------------------
|
|
168
280
|
|
|
169
|
-
def runtime_findings(raw, label)
|
|
281
|
+
def runtime_findings(raw, label, root: nil)
|
|
170
282
|
if raw["error"]
|
|
171
|
-
return [load_failure_finding(raw, label)]
|
|
283
|
+
return [load_failure_finding(raw, label, root)]
|
|
172
284
|
end
|
|
173
285
|
|
|
174
286
|
findings = []
|
|
175
287
|
raw.fetch("unshareable_constants", []).each do |entry|
|
|
288
|
+
blocker = entry["blocker"]
|
|
289
|
+
detail =
|
|
290
|
+
if blocker.nil? || blocker == entry["class"]
|
|
291
|
+
""
|
|
292
|
+
elsif entry["blocker_nested"]
|
|
293
|
+
" (blocked by #{blocker} inside)"
|
|
294
|
+
else
|
|
295
|
+
" (just not frozen)"
|
|
296
|
+
end
|
|
176
297
|
findings << runtime_finding(
|
|
177
298
|
entry, label,
|
|
178
299
|
check: "runtime-unshareable-constant",
|
|
179
300
|
severity: :error,
|
|
180
301
|
message: "constant #{entry["const"]} holds an " \
|
|
181
|
-
"unshareable #{entry["class"]}",
|
|
302
|
+
"unshareable #{entry["class"]}#{detail}",
|
|
182
303
|
why: "Reading it from a non-main Ractor raises " \
|
|
183
304
|
"Ractor::IsolationError. #{RUNTIME_WHY}",
|
|
184
305
|
fix: "Freeze it deeply at definition time " \
|
|
@@ -188,6 +309,24 @@ module Audition
|
|
|
188
309
|
raw.fetch("class_state", []).each do |entry|
|
|
189
310
|
findings << class_state_finding(entry, label)
|
|
190
311
|
end
|
|
312
|
+
raw.fetch("unshareable_procs", []).each do |entry|
|
|
313
|
+
findings << runtime_finding(
|
|
314
|
+
entry, label,
|
|
315
|
+
check: "runtime-unshareable-proc",
|
|
316
|
+
severity: :warning,
|
|
317
|
+
message: "Rails could not make a callback block " \
|
|
318
|
+
"Ractor-shareable: #{entry["proc"]}",
|
|
319
|
+
why: "With unshareable_proc_action set to :warn, " \
|
|
320
|
+
"Rails ran Ractor.shareable_proc on this block " \
|
|
321
|
+
"and it raised Ractor::IsolationError, so the " \
|
|
322
|
+
"callback keeps an unshareable Proc; a non-main " \
|
|
323
|
+
"Ractor running it raises. #{RUNTIME_WHY}",
|
|
324
|
+
fix: "Capture only shareable values: freeze the " \
|
|
325
|
+
"local, inline it, or hoist a shareable leaf " \
|
|
326
|
+
"such as a Symbol into a fresh local assigned " \
|
|
327
|
+
"once before the block."
|
|
328
|
+
)
|
|
329
|
+
end
|
|
191
330
|
raw.fetch("class_variables", []).each do |entry|
|
|
192
331
|
findings << runtime_finding(
|
|
193
332
|
entry, label,
|
|
@@ -208,6 +347,22 @@ module Audition
|
|
|
208
347
|
|
|
209
348
|
findings << native_finding(entry, label)
|
|
210
349
|
end
|
|
350
|
+
# A truncated sweep must never read as a clean one.
|
|
351
|
+
if raw["truncated"]
|
|
352
|
+
findings.unshift(Finding.new(
|
|
353
|
+
check: "runtime-scan",
|
|
354
|
+
severity: :warning,
|
|
355
|
+
message: "constant sweep truncated after " \
|
|
356
|
+
"#{raw["scanned"]} constants " \
|
|
357
|
+
"(limit #{raw["limit"]})",
|
|
358
|
+
why: "Constants beyond the limit were never probed, " \
|
|
359
|
+
"so their absence from the findings proves " \
|
|
360
|
+
"nothing.",
|
|
361
|
+
fix: "Raise the probe's max_constants and re-run.",
|
|
362
|
+
path: label,
|
|
363
|
+
line: nil
|
|
364
|
+
))
|
|
365
|
+
end
|
|
211
366
|
findings
|
|
212
367
|
end
|
|
213
368
|
|
|
@@ -292,7 +447,8 @@ module Audition
|
|
|
292
447
|
)
|
|
293
448
|
end
|
|
294
449
|
|
|
295
|
-
def load_failure_finding(raw, label)
|
|
450
|
+
def load_failure_finding(raw, label, root = nil)
|
|
451
|
+
site = failure_site(raw, prefix: own_prefix(root))
|
|
296
452
|
Finding.new(
|
|
297
453
|
check: "runtime-load",
|
|
298
454
|
severity: :error,
|
|
@@ -300,14 +456,14 @@ module Audition
|
|
|
300
456
|
why: "Ractor-readiness cannot be assessed until the " \
|
|
301
457
|
"target loads.",
|
|
302
458
|
fix: "Make `require` succeed on a bare Ruby first.",
|
|
303
|
-
path: label,
|
|
304
|
-
line:
|
|
459
|
+
path: site&.first || label,
|
|
460
|
+
line: site&.last
|
|
305
461
|
)
|
|
306
462
|
end
|
|
307
463
|
|
|
308
|
-
def rack_findings(raw, config_ru)
|
|
464
|
+
def rack_findings(raw, config_ru, root = nil)
|
|
309
465
|
if raw.dig("ractor_boot_call", "ok")
|
|
310
|
-
return concurrency_findings(raw, config_ru)
|
|
466
|
+
return concurrency_findings(raw, config_ru, root)
|
|
311
467
|
end
|
|
312
468
|
|
|
313
469
|
if raw["rack_available"] == false
|
|
@@ -316,7 +472,7 @@ module Audition
|
|
|
316
472
|
severity: :warning,
|
|
317
473
|
message: "rack gem not available in the probe process",
|
|
318
474
|
why: "The rack probe boots the app via Rack::Builder.",
|
|
319
|
-
fix: "Install rack next to
|
|
475
|
+
fix: "Install rack next to Audition and re-run.",
|
|
320
476
|
path: config_ru,
|
|
321
477
|
line: nil
|
|
322
478
|
)]
|
|
@@ -332,6 +488,10 @@ module Audition
|
|
|
332
488
|
"booting config.ru and serving one GET / inside a " \
|
|
333
489
|
"Ractor failed."
|
|
334
490
|
end
|
|
491
|
+
site = failure_site(raw["ractor_boot_call"],
|
|
492
|
+
prefix: own_prefix(root)) ||
|
|
493
|
+
failure_site({"error" => raw["main_boot_error"]},
|
|
494
|
+
prefix: own_prefix(root))
|
|
335
495
|
[Finding.new(
|
|
336
496
|
check: "dynamic-rack",
|
|
337
497
|
severity: :error,
|
|
@@ -340,16 +500,18 @@ module Audition
|
|
|
340
500
|
fix: "Remove global/class-level state touched during " \
|
|
341
501
|
"boot and request handling; keep middleware config " \
|
|
342
502
|
"frozen; open connections per-Ractor.",
|
|
343
|
-
path: config_ru,
|
|
344
|
-
line:
|
|
503
|
+
path: site&.first || config_ru,
|
|
504
|
+
line: site&.last
|
|
345
505
|
)]
|
|
346
506
|
end
|
|
347
507
|
|
|
348
|
-
def concurrency_findings(raw, config_ru)
|
|
508
|
+
def concurrency_findings(raw, config_ru, root = nil)
|
|
349
509
|
stats = raw["concurrency"] || {}
|
|
350
510
|
failures = stats["failures"].to_i
|
|
351
511
|
return [] if failures.zero?
|
|
352
512
|
|
|
513
|
+
site = failure_site({"error" => stats["first_error"]},
|
|
514
|
+
prefix: own_prefix(root))
|
|
353
515
|
[Finding.new(
|
|
354
516
|
check: "dynamic-rack-concurrency",
|
|
355
517
|
severity: :error,
|
|
@@ -361,11 +523,43 @@ module Audition
|
|
|
361
523
|
"shared state races. #{RUNTIME_WHY}",
|
|
362
524
|
fix: "Look for process-global state touched during " \
|
|
363
525
|
"request handling and boot.",
|
|
364
|
-
path: config_ru,
|
|
365
|
-
line:
|
|
526
|
+
path: site&.first || config_ru,
|
|
527
|
+
line: site&.last
|
|
366
528
|
)]
|
|
367
529
|
end
|
|
368
530
|
|
|
531
|
+
# A backtrace frame inside the target names the failing
|
|
532
|
+
# line; frames outside it stay unattributed rather than
|
|
533
|
+
# pinning a finding to a dependency's file.
|
|
534
|
+
BACKTRACE_FRAME = /\A(.+?):(\d+):in /
|
|
535
|
+
|
|
536
|
+
def failure_site(hash, prefix:)
|
|
537
|
+
prefixes = Array(prefix)
|
|
538
|
+
return nil if prefixes.empty? || !hash.is_a?(Hash)
|
|
539
|
+
|
|
540
|
+
error = hash["error"].is_a?(Hash) ? hash["error"] : hash
|
|
541
|
+
frame = Array(error["backtrace"]).find do |f|
|
|
542
|
+
f.is_a?(String) && prefixes.any? { |p| f.start_with?(p) }
|
|
543
|
+
end
|
|
544
|
+
match = frame&.match(BACKTRACE_FRAME)
|
|
545
|
+
match && [match[1], Integer(match[2], 10)]
|
|
546
|
+
end
|
|
547
|
+
|
|
548
|
+
# require realpaths frames while eval keeps paths as given,
|
|
549
|
+
# so a symlinked root (macOS /var) must match both spellings.
|
|
550
|
+
def own_prefix(root)
|
|
551
|
+
return nil if root.nil?
|
|
552
|
+
|
|
553
|
+
[root + File::SEPARATOR,
|
|
554
|
+
realpath(root) + File::SEPARATOR].uniq
|
|
555
|
+
end
|
|
556
|
+
|
|
557
|
+
def realpath(path)
|
|
558
|
+
File.realpath(path)
|
|
559
|
+
rescue SystemCallError
|
|
560
|
+
path
|
|
561
|
+
end
|
|
562
|
+
|
|
369
563
|
def describe(hash)
|
|
370
564
|
error = hash.is_a?(Hash) ? (hash["error"] || hash) : {}
|
|
371
565
|
klass = error["class"] || "UnknownError"
|
|
@@ -375,12 +569,12 @@ module Audition
|
|
|
375
569
|
|
|
376
570
|
# -- subprocess plumbing -------------------------------------
|
|
377
571
|
|
|
378
|
-
#
|
|
379
|
-
#
|
|
380
|
-
#
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
out =
|
|
572
|
+
# The harness prints one Marshal document; anything else on
|
|
573
|
+
# its stdout (a crash before the document) is reported with
|
|
574
|
+
# the tail of its stderr. Stderr can carry arbitrary target
|
|
575
|
+
# bytes, so it is forced to valid UTF-8 first.
|
|
576
|
+
def run(mode, payload = {}, root: nil)
|
|
577
|
+
out, err, timed_out = execute(mode, payload, root: root)
|
|
384
578
|
err = sanitize(err)
|
|
385
579
|
if timed_out
|
|
386
580
|
return {"error" => {
|
|
@@ -388,8 +582,11 @@ module Audition
|
|
|
388
582
|
"message" => "harness exceeded #{@timeout}s"
|
|
389
583
|
}}
|
|
390
584
|
end
|
|
391
|
-
|
|
392
|
-
|
|
585
|
+
document = Marshal.load(out)
|
|
586
|
+
raise TypeError, "not a document" unless document.is_a?(Hash)
|
|
587
|
+
|
|
588
|
+
document
|
|
589
|
+
rescue TypeError, ArgumentError, EOFError
|
|
393
590
|
{"error" => {
|
|
394
591
|
"class" => "HarnessFailure",
|
|
395
592
|
"message" => err.split("\n").last(5).join("; ")
|
|
@@ -405,10 +602,30 @@ module Audition
|
|
|
405
602
|
# child the target spawned inherits our pipes and would
|
|
406
603
|
# otherwise hold the read until it exits, defeating the
|
|
407
604
|
# timeout and leaving orphans behind.
|
|
408
|
-
def execute(mode, payload)
|
|
605
|
+
def execute(mode, payload, root: nil)
|
|
409
606
|
cmd = [@ruby, "-W0", HARNESS, mode]
|
|
410
|
-
|
|
411
|
-
|
|
607
|
+
# An app boots against its own bundle: the subprocess runs
|
|
608
|
+
# from the target root with the target's Gemfile. When
|
|
609
|
+
# Audition itself runs under bundle exec, the inherited
|
|
610
|
+
# Bundler environment (RUBYOPT's -rbundler/setup above all)
|
|
611
|
+
# would activate Audition's bundle inside the child before
|
|
612
|
+
# the harness starts, so it is scrubbed first.
|
|
613
|
+
env = {}
|
|
614
|
+
opts = {pgroup: true}
|
|
615
|
+
if root && File.directory?(root)
|
|
616
|
+
opts[:chdir] = root
|
|
617
|
+
gemfile = File.join(root, "Gemfile")
|
|
618
|
+
if File.file?(gemfile)
|
|
619
|
+
ENV.each_key do |key|
|
|
620
|
+
env[key] = nil if key.start_with?("BUNDLE") ||
|
|
621
|
+
%w[RUBYOPT RUBYLIB].include?(key)
|
|
622
|
+
end
|
|
623
|
+
env["BUNDLE_GEMFILE"] = gemfile
|
|
624
|
+
end
|
|
625
|
+
end
|
|
626
|
+
Open3.popen3(env, *cmd, **opts) do |stdin, stdout, stderr, wait|
|
|
627
|
+
stdin.binmode
|
|
628
|
+
stdin.write(Marshal.dump(payload))
|
|
412
629
|
stdin.close
|
|
413
630
|
out_reader = reader(stdout)
|
|
414
631
|
err_reader = reader(stderr)
|
data/lib/audition/finding.rb
CHANGED
|
@@ -64,12 +64,18 @@ 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?}
|
|
69
|
+
# @!attribute [r] subject
|
|
70
|
+
# @return [String, nil] the object a finding is about, in a
|
|
71
|
+
# form the dynamic probe can match ("Owner/@ivar"), so a
|
|
72
|
+
# runtime proof of shareability can retire the static guess
|
|
67
73
|
Finding = Data.define(
|
|
68
74
|
:check, :severity, :message, :why, :fix,
|
|
69
|
-
:path, :line, :source, :autofix, :dependency
|
|
75
|
+
:path, :line, :source, :autofix, :dependency, :test, :subject
|
|
70
76
|
) do
|
|
71
77
|
def initialize(source: nil, autofix: nil, dependency: false,
|
|
72
|
-
**rest)
|
|
78
|
+
test: false, subject: nil, **rest)
|
|
73
79
|
super
|
|
74
80
|
end
|
|
75
81
|
|
|
@@ -82,6 +88,12 @@ module Audition
|
|
|
82
88
|
# @return [Boolean]
|
|
83
89
|
def dependency? = dependency
|
|
84
90
|
|
|
91
|
+
# True when the problem lives in the target's test or spec
|
|
92
|
+
# code: real, but never loaded by the production boot.
|
|
93
|
+
#
|
|
94
|
+
# @return [Boolean]
|
|
95
|
+
def test? = test
|
|
96
|
+
|
|
85
97
|
# @return [Boolean] whether an {Autofix} is attached
|
|
86
98
|
def fixable? = !autofix.nil?
|
|
87
99
|
|