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
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "open3"
|
|
5
|
+
require "rbconfig"
|
|
6
|
+
|
|
7
|
+
module Audition
|
|
8
|
+
module Dynamic
|
|
9
|
+
# Outcome of one dynamic probe.
|
|
10
|
+
#
|
|
11
|
+
# @!attribute [r] mode
|
|
12
|
+
# @return [Symbol] `:script`, `:require`, `:rack`, `:rails`,
|
|
13
|
+
# or `:capabilities`
|
|
14
|
+
# @!attribute [r] raw
|
|
15
|
+
# @return [Hash] the harness's parsed JSON, verbatim
|
|
16
|
+
# @!attribute [r] findings
|
|
17
|
+
# @return [Array<Finding>] findings derived from `raw`
|
|
18
|
+
# @!attribute [r] passed
|
|
19
|
+
# @return [Boolean] whether the target's own surface passed
|
|
20
|
+
Result = Data.define(:mode, :raw, :findings, :passed)
|
|
21
|
+
|
|
22
|
+
# Spawns the harness subprocess per probe mode, parses its JSON,
|
|
23
|
+
# and converts observations into findings.
|
|
24
|
+
class Prober
|
|
25
|
+
HARNESS = File.expand_path("harness.rb", __dir__)
|
|
26
|
+
|
|
27
|
+
RUNTIME_WHY =
|
|
28
|
+
"Observed on the live object graph after loading the " \
|
|
29
|
+
"target; this is ground truth, not a static guess."
|
|
30
|
+
|
|
31
|
+
# @param ruby [String] Ruby executable for the harness
|
|
32
|
+
# @param timeout [Integer] seconds before a probe subprocess
|
|
33
|
+
# is killed
|
|
34
|
+
def initialize(ruby: RbConfig.ruby, timeout: 30)
|
|
35
|
+
@ruby = ruby
|
|
36
|
+
@timeout = timeout
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Runs the probe described by a {Target#entry} hash.
|
|
40
|
+
#
|
|
41
|
+
# @param entry [Hash] `:mode` plus mode-specific keys
|
|
42
|
+
# @return [Result]
|
|
43
|
+
# @raise [Audition::Error] on an unknown mode
|
|
44
|
+
def probe(entry)
|
|
45
|
+
case (entry[:mode] || entry["mode"]).to_sym
|
|
46
|
+
when :script then probe_script(entry)
|
|
47
|
+
when :require then probe_require(entry)
|
|
48
|
+
when :rack then probe_rack(entry)
|
|
49
|
+
when :rails then probe_rails(entry)
|
|
50
|
+
when :capabilities then probe_capabilities
|
|
51
|
+
else
|
|
52
|
+
raise Error, "unknown dynamic probe mode in #{entry}"
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def probe_script(entry)
|
|
59
|
+
path = entry[:path]
|
|
60
|
+
ractor = run("script_ractor", "path" => path)
|
|
61
|
+
if ractor["ok"]
|
|
62
|
+
return Result.new(mode: :script, raw: ractor,
|
|
63
|
+
findings: [], passed: true)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
main = run("script_main", "path" => path)
|
|
67
|
+
finding = script_finding(path, ractor, main)
|
|
68
|
+
Result.new(mode: :script,
|
|
69
|
+
raw: {"ractor" => ractor, "main" => main},
|
|
70
|
+
findings: [finding], passed: false)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def script_finding(path, ractor, main)
|
|
74
|
+
if main["ok"]
|
|
75
|
+
error = describe(ractor)
|
|
76
|
+
Finding.new(
|
|
77
|
+
check: "dynamic-script",
|
|
78
|
+
severity: :error,
|
|
79
|
+
message: "raises inside a Ractor: #{error}",
|
|
80
|
+
why: "The script ran fine on the main Ractor but " \
|
|
81
|
+
"failed under Ractor.new; the static findings " \
|
|
82
|
+
"usually pinpoint the exact line.",
|
|
83
|
+
fix: "Fix the static findings for this file, then " \
|
|
84
|
+
"re-audition.",
|
|
85
|
+
path: path,
|
|
86
|
+
line: nil
|
|
87
|
+
)
|
|
88
|
+
else
|
|
89
|
+
Finding.new(
|
|
90
|
+
check: "dynamic-script",
|
|
91
|
+
severity: :error,
|
|
92
|
+
message: "fails outside Ractors too: #{describe(main)}",
|
|
93
|
+
why: "The script does not even run on the main " \
|
|
94
|
+
"Ractor, so Ractor-readiness cannot be assessed.",
|
|
95
|
+
fix: "Make the script run standalone first.",
|
|
96
|
+
path: path,
|
|
97
|
+
line: nil
|
|
98
|
+
)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def probe_require(entry)
|
|
103
|
+
feature = entry[:feature]
|
|
104
|
+
raw = run("require",
|
|
105
|
+
"feature" => feature,
|
|
106
|
+
"load_paths" => Array(entry[:load_paths]),
|
|
107
|
+
"root" => entry[:root])
|
|
108
|
+
findings = runtime_findings(raw, feature)
|
|
109
|
+
Result.new(mode: :require, raw: raw, findings: findings,
|
|
110
|
+
passed: own_clean?(findings))
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def probe_rails(entry)
|
|
114
|
+
raw = run("rails",
|
|
115
|
+
"environment" => entry[:environment],
|
|
116
|
+
"root" => entry[:root])
|
|
117
|
+
boot = raw["boot"]
|
|
118
|
+
if boot && !boot["ok"]
|
|
119
|
+
finding = Finding.new(
|
|
120
|
+
check: "dynamic-rails",
|
|
121
|
+
severity: :error,
|
|
122
|
+
message: "Rails failed to boot: #{describe(boot)}",
|
|
123
|
+
why: "Ractor-readiness cannot be assessed until the " \
|
|
124
|
+
"application boots.",
|
|
125
|
+
fix: "Boot the app (bin/rails runner 1) and fix " \
|
|
126
|
+
"whatever breaks, then re-audition.",
|
|
127
|
+
path: entry[:environment],
|
|
128
|
+
line: nil
|
|
129
|
+
)
|
|
130
|
+
return Result.new(mode: :rails, raw: raw,
|
|
131
|
+
findings: [finding], passed: false)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
findings = runtime_findings(raw, entry[:environment])
|
|
135
|
+
Result.new(mode: :rails, raw: raw, findings: findings,
|
|
136
|
+
passed: own_clean?(findings))
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# A probe passes when the target's own surface is clean;
|
|
140
|
+
# dependency errors surface in the findings and drive the
|
|
141
|
+
# blocked verdict instead.
|
|
142
|
+
def own_clean?(findings)
|
|
143
|
+
findings.none? { |f| f.error? && !f.dependency? }
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def probe_rack(entry)
|
|
147
|
+
config_ru = entry[:config_ru]
|
|
148
|
+
raw = run("rack", "config_ru" => config_ru)
|
|
149
|
+
findings = rack_findings(raw, config_ru)
|
|
150
|
+
passed = raw.dig("ractor_boot_call", "ok") == true &&
|
|
151
|
+
raw.dig("concurrency", "failures").to_i.zero?
|
|
152
|
+
Result.new(mode: :rack, raw: raw, findings: findings,
|
|
153
|
+
passed: passed)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def probe_capabilities
|
|
157
|
+
raw = run("capabilities")
|
|
158
|
+
Result.new(mode: :capabilities, raw: raw, findings: [],
|
|
159
|
+
passed: raw.key?("capabilities"))
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# -- findings builders ---------------------------------------
|
|
163
|
+
|
|
164
|
+
def runtime_findings(raw, label)
|
|
165
|
+
if raw["error"]
|
|
166
|
+
return [load_failure_finding(raw, label)]
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
findings = []
|
|
170
|
+
raw.fetch("unshareable_constants", []).each do |entry|
|
|
171
|
+
findings << runtime_finding(
|
|
172
|
+
entry, label,
|
|
173
|
+
check: "runtime-unshareable-constant",
|
|
174
|
+
severity: :error,
|
|
175
|
+
message: "constant #{entry["const"]} holds an " \
|
|
176
|
+
"unshareable #{entry["class"]}",
|
|
177
|
+
why: "Reading it from a non-main Ractor raises " \
|
|
178
|
+
"Ractor::IsolationError. #{RUNTIME_WHY}",
|
|
179
|
+
fix: "Freeze it deeply at definition time " \
|
|
180
|
+
"(Ractor.make_shareable) or make it per-Ractor."
|
|
181
|
+
)
|
|
182
|
+
end
|
|
183
|
+
raw.fetch("class_state", []).each do |entry|
|
|
184
|
+
findings << class_state_finding(entry, label)
|
|
185
|
+
end
|
|
186
|
+
raw.fetch("class_variables", []).each do |entry|
|
|
187
|
+
findings << runtime_finding(
|
|
188
|
+
entry, label,
|
|
189
|
+
check: "runtime-class-variable",
|
|
190
|
+
severity: :error,
|
|
191
|
+
message: "class variable(s) " \
|
|
192
|
+
"#{entry["cvars"].join(", ")} on " \
|
|
193
|
+
"#{entry["const"]}",
|
|
194
|
+
why: "Class variables raise Ractor::IsolationError " \
|
|
195
|
+
"on any access from a non-main Ractor. " \
|
|
196
|
+
"#{RUNTIME_WHY}",
|
|
197
|
+
fix: "Replace with frozen constants, instance state, " \
|
|
198
|
+
"or Ractor-local storage."
|
|
199
|
+
)
|
|
200
|
+
end
|
|
201
|
+
findings
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def class_state_finding(entry, label)
|
|
205
|
+
unshareable = entry.fetch("unshareable", [])
|
|
206
|
+
hot = unshareable.any?
|
|
207
|
+
detail =
|
|
208
|
+
hot ? " (unshareable: #{unshareable.join(", ")})" : ""
|
|
209
|
+
runtime_finding(
|
|
210
|
+
entry, label,
|
|
211
|
+
check: "runtime-class-state",
|
|
212
|
+
severity: hot ? :error : :warning,
|
|
213
|
+
message: "class-level state " \
|
|
214
|
+
"#{entry["ivars"].join(", ")} on " \
|
|
215
|
+
"#{entry["const"]}#{detail}",
|
|
216
|
+
why: "Writes raise Ractor::IsolationError from non-main " \
|
|
217
|
+
"Ractors; reads raise too while the value is " \
|
|
218
|
+
"unshareable. #{RUNTIME_WHY}",
|
|
219
|
+
fix: "Precompute and freeze at load, use " \
|
|
220
|
+
"Ractor.store_if_absent, or keep per-Ractor state."
|
|
221
|
+
)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Findings keep their true severity; those tracing to a
|
|
225
|
+
# dependency's source file carry dependency: true so the
|
|
226
|
+
# report can attribute them (and the verdict can distinguish
|
|
227
|
+
# not_ready from blocked). Unknown origins count as own.
|
|
228
|
+
def runtime_finding(entry, label, check:, severity:,
|
|
229
|
+
message:, why:, fix:)
|
|
230
|
+
Finding.new(
|
|
231
|
+
check: check,
|
|
232
|
+
severity: severity,
|
|
233
|
+
message: message,
|
|
234
|
+
why: why,
|
|
235
|
+
fix: fix,
|
|
236
|
+
path: entry["path"] || label,
|
|
237
|
+
line: entry["line"],
|
|
238
|
+
dependency: !entry.fetch("own", true)
|
|
239
|
+
)
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def load_failure_finding(raw, label)
|
|
243
|
+
Finding.new(
|
|
244
|
+
check: "runtime-load",
|
|
245
|
+
severity: :error,
|
|
246
|
+
message: "could not load target: #{describe(raw)}",
|
|
247
|
+
why: "Ractor-readiness cannot be assessed until the " \
|
|
248
|
+
"target loads.",
|
|
249
|
+
fix: "Make `require` succeed on a bare Ruby first.",
|
|
250
|
+
path: label,
|
|
251
|
+
line: nil
|
|
252
|
+
)
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def rack_findings(raw, config_ru)
|
|
256
|
+
if raw.dig("ractor_boot_call", "ok")
|
|
257
|
+
return concurrency_findings(raw, config_ru)
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
if raw["rack_available"] == false
|
|
261
|
+
return [Finding.new(
|
|
262
|
+
check: "dynamic-rack",
|
|
263
|
+
severity: :warning,
|
|
264
|
+
message: "rack gem not available in the probe process",
|
|
265
|
+
why: "The rack probe boots the app via Rack::Builder.",
|
|
266
|
+
fix: "Install rack next to audition and re-run.",
|
|
267
|
+
path: config_ru,
|
|
268
|
+
line: nil
|
|
269
|
+
)]
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
detail = describe(raw["ractor_boot_call"] || raw)
|
|
273
|
+
why =
|
|
274
|
+
if raw["main_boot_error"]
|
|
275
|
+
"config.ru does not even boot on the main Ractor " \
|
|
276
|
+
"(#{describe(raw["main_boot_error"])})."
|
|
277
|
+
else
|
|
278
|
+
"Ractor web servers boot the app once per Ractor; " \
|
|
279
|
+
"booting config.ru and serving one GET / inside a " \
|
|
280
|
+
"Ractor failed."
|
|
281
|
+
end
|
|
282
|
+
[Finding.new(
|
|
283
|
+
check: "dynamic-rack",
|
|
284
|
+
severity: :error,
|
|
285
|
+
message: "boot + call inside a Ractor failed: #{detail}",
|
|
286
|
+
why: "#{why} #{RUNTIME_WHY}",
|
|
287
|
+
fix: "Remove global/class-level state touched during " \
|
|
288
|
+
"boot and request handling; keep middleware config " \
|
|
289
|
+
"frozen; open connections per-Ractor.",
|
|
290
|
+
path: config_ru,
|
|
291
|
+
line: nil
|
|
292
|
+
)]
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def concurrency_findings(raw, config_ru)
|
|
296
|
+
stats = raw["concurrency"] || {}
|
|
297
|
+
failures = stats["failures"].to_i
|
|
298
|
+
return [] if failures.zero?
|
|
299
|
+
|
|
300
|
+
[Finding.new(
|
|
301
|
+
check: "dynamic-rack-concurrency",
|
|
302
|
+
severity: :error,
|
|
303
|
+
message: "#{failures} of #{stats["workers"]} concurrent " \
|
|
304
|
+
"Ractors failed: " \
|
|
305
|
+
"#{describe(stats["first_error"])}",
|
|
306
|
+
why: "Single-Ractor serving worked; failures appeared " \
|
|
307
|
+
"only under concurrent load, which usually means " \
|
|
308
|
+
"shared state races. #{RUNTIME_WHY}",
|
|
309
|
+
fix: "Look for process-global state touched during " \
|
|
310
|
+
"request handling and boot.",
|
|
311
|
+
path: config_ru,
|
|
312
|
+
line: nil
|
|
313
|
+
)]
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def describe(hash)
|
|
317
|
+
error = hash.is_a?(Hash) ? (hash["error"] || hash) : {}
|
|
318
|
+
klass = error["class"] || "UnknownError"
|
|
319
|
+
message = error["message"] || hash.inspect[0, 120]
|
|
320
|
+
"#{klass}: #{message}"
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# -- subprocess plumbing -------------------------------------
|
|
324
|
+
|
|
325
|
+
def run(mode, payload = {})
|
|
326
|
+
out, err, timed_out = execute(mode, payload)
|
|
327
|
+
if timed_out
|
|
328
|
+
return {"error" => {
|
|
329
|
+
"class" => "AuditionTimeout",
|
|
330
|
+
"message" => "harness exceeded #{@timeout}s"
|
|
331
|
+
}}
|
|
332
|
+
end
|
|
333
|
+
JSON.parse(out)
|
|
334
|
+
rescue JSON::ParserError
|
|
335
|
+
{"error" => {
|
|
336
|
+
"class" => "HarnessFailure",
|
|
337
|
+
"message" => (err || "").split("\n").last(5).join("; ")
|
|
338
|
+
}}
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def execute(mode, payload)
|
|
342
|
+
cmd = [@ruby, "-W0", HARNESS, mode]
|
|
343
|
+
Open3.popen3(*cmd) do |stdin, stdout, stderr, wait|
|
|
344
|
+
stdin.write(JSON.generate(payload))
|
|
345
|
+
stdin.close
|
|
346
|
+
out_reader = Thread.new { stdout.read }
|
|
347
|
+
err_reader = Thread.new { stderr.read }
|
|
348
|
+
if wait.join(@timeout)
|
|
349
|
+
[out_reader.value, err_reader.value, false]
|
|
350
|
+
else
|
|
351
|
+
begin
|
|
352
|
+
Process.kill("KILL", wait.pid)
|
|
353
|
+
rescue Errno::ESRCH
|
|
354
|
+
nil
|
|
355
|
+
end
|
|
356
|
+
[out_reader.value.to_s, err_reader.value.to_s, true]
|
|
357
|
+
end
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
end
|
|
361
|
+
end
|
|
362
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Audition
|
|
4
|
+
# Severity ranks, higher is worse. Meanings:
|
|
5
|
+
#
|
|
6
|
+
# - `:error`: will raise `Ractor::IsolationError` (or equivalent)
|
|
7
|
+
# if this code runs in a non-main Ractor
|
|
8
|
+
# - `:warning`: raises depending on the value or usage (e.g.
|
|
9
|
+
# reading class-level ivars is fine for shareable values, fatal
|
|
10
|
+
# for mutable ones)
|
|
11
|
+
# - `:info`: works on Ruby 4.0+, but with caveats worth knowing
|
|
12
|
+
#
|
|
13
|
+
# @return [Hash{Symbol => Integer}]
|
|
14
|
+
SEVERITIES = {error: 3, warning: 2, info: 1}.freeze
|
|
15
|
+
|
|
16
|
+
# A machine-applicable correction: replace source bytes
|
|
17
|
+
# `start_offset...end_offset` with `replacement`. Safety follows
|
|
18
|
+
# the RuboCop convention: `:safe` edits preserve semantics
|
|
19
|
+
# exactly; `:unsafe` edits trade a small semantic change for
|
|
20
|
+
# Ractor-readiness and only apply under `--fix-unsafe`.
|
|
21
|
+
#
|
|
22
|
+
# @!attribute [r] start_offset
|
|
23
|
+
# @return [Integer] byte offset where the edit begins
|
|
24
|
+
# @!attribute [r] end_offset
|
|
25
|
+
# @return [Integer] byte offset where the edit ends (exclusive)
|
|
26
|
+
# @!attribute [r] replacement
|
|
27
|
+
# @return [String] text spliced over the range
|
|
28
|
+
# @!attribute [r] safety
|
|
29
|
+
# @return [Symbol] `:safe` or `:unsafe`
|
|
30
|
+
Autofix = Data.define(
|
|
31
|
+
:start_offset, :end_offset, :replacement, :safety
|
|
32
|
+
) do
|
|
33
|
+
def initialize(safety: :safe, **rest)
|
|
34
|
+
super
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @return [Boolean] whether this edit needs `--fix-unsafe`
|
|
38
|
+
def unsafe? = safety == :unsafe
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# One diagnosed problem: what was found (`message`), the Ractor
|
|
42
|
+
# rule it violates (`why`), what to write instead (`fix`), where
|
|
43
|
+
# (`path`/`line`/`source`), and optionally a machine-applicable
|
|
44
|
+
# {Autofix}. Produced by static checks, the graph audit, and the
|
|
45
|
+
# dynamic prober alike.
|
|
46
|
+
#
|
|
47
|
+
# @!attribute [r] check
|
|
48
|
+
# @return [String] kebab-case check identifier
|
|
49
|
+
# @!attribute [r] severity
|
|
50
|
+
# @return [Symbol] `:error`, `:warning`, or `:info`
|
|
51
|
+
# @!attribute [r] message
|
|
52
|
+
# @return [String] one-line statement of the problem
|
|
53
|
+
# @!attribute [r] why
|
|
54
|
+
# @return [String] the Ractor rule behind the finding
|
|
55
|
+
# @!attribute [r] fix
|
|
56
|
+
# @return [String] suggested remediation
|
|
57
|
+
# @!attribute [r] path
|
|
58
|
+
# @return [String] file path, or a label for runtime findings
|
|
59
|
+
# @!attribute [r] line
|
|
60
|
+
# @return [Integer, nil] 1-based line, nil for whole-target
|
|
61
|
+
# @!attribute [r] source
|
|
62
|
+
# @return [String, nil] the offending source line, stripped
|
|
63
|
+
# @!attribute [r] autofix
|
|
64
|
+
# @return [Autofix, nil] machine-applicable correction
|
|
65
|
+
# @!attribute [r] dependency
|
|
66
|
+
# @return [Boolean] see {#dependency?}
|
|
67
|
+
Finding = Data.define(
|
|
68
|
+
:check, :severity, :message, :why, :fix,
|
|
69
|
+
:path, :line, :source, :autofix, :dependency
|
|
70
|
+
) do
|
|
71
|
+
def initialize(source: nil, autofix: nil, dependency: false,
|
|
72
|
+
**rest)
|
|
73
|
+
super
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# @return [Boolean] whether severity is `:error`
|
|
77
|
+
def error? = severity == :error
|
|
78
|
+
|
|
79
|
+
# True when the problem lives in a dependency's source, not the
|
|
80
|
+
# audited target: real, but not the target's bug to fix.
|
|
81
|
+
#
|
|
82
|
+
# @return [Boolean]
|
|
83
|
+
def dependency? = dependency
|
|
84
|
+
|
|
85
|
+
# @return [Boolean] whether an {Autofix} is attached
|
|
86
|
+
def fixable? = !autofix.nil?
|
|
87
|
+
|
|
88
|
+
# @return [Integer] rank from {SEVERITIES}, higher is worse
|
|
89
|
+
def severity_rank = SEVERITIES.fetch(severity)
|
|
90
|
+
|
|
91
|
+
# @return [String] "path:line", or just the path label
|
|
92
|
+
def location = line ? "#{path}:#{line}" : path
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "rewriters"
|
|
4
|
+
|
|
5
|
+
module Audition
|
|
6
|
+
# Applies machine-generated corrections (rubocop-style --fix).
|
|
7
|
+
# Two tiers: safe inline autofixes attached to findings, and,
|
|
8
|
+
# under unsafe: true, file-level rewrites planned by
|
|
9
|
+
# Audition::Rewriters. Byte offsets come from Prism, so edits are
|
|
10
|
+
# applied bottom-up to keep earlier offsets valid.
|
|
11
|
+
class Fixer
|
|
12
|
+
Plan = Data.define(:path, :source, :edits)
|
|
13
|
+
|
|
14
|
+
# @param unsafe [Boolean] also apply `:unsafe` autofixes and
|
|
15
|
+
# the multi-site rewriters
|
|
16
|
+
def initialize(unsafe: false)
|
|
17
|
+
@unsafe = unsafe
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Applies planned edits to the files on disk.
|
|
21
|
+
#
|
|
22
|
+
# @param findings [Array<Finding>]
|
|
23
|
+
# @return [Hash{String => Integer}] path => applied edit count
|
|
24
|
+
def apply(findings)
|
|
25
|
+
plans(findings).to_h do |plan|
|
|
26
|
+
File.write(plan.path, patched(plan))
|
|
27
|
+
[plan.path, plan.edits.size]
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# For `--dry-run`: what would change, without touching anything.
|
|
32
|
+
#
|
|
33
|
+
# @param findings [Array<Finding>]
|
|
34
|
+
# @return [Array<Hash>] per file: `:path` and `:hunks`
|
|
35
|
+
# (`:line`, `:old`, `:new`)
|
|
36
|
+
def preview(findings)
|
|
37
|
+
plans(findings).map do |plan|
|
|
38
|
+
{path: plan.path, hunks: hunks(plan)}
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def edit_count(findings)
|
|
43
|
+
plans(findings).sum { |plan| plan.edits.size }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# How many additional edits the unsafe tier would unlock; used
|
|
47
|
+
# for the "N more with --fix-unsafe" summary hint.
|
|
48
|
+
#
|
|
49
|
+
# @param findings [Array<Finding>]
|
|
50
|
+
# @return [Integer]
|
|
51
|
+
def self.unsafe_gain(findings)
|
|
52
|
+
new(unsafe: true).edit_count(findings) -
|
|
53
|
+
new(unsafe: false).edit_count(findings)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def plans(findings)
|
|
57
|
+
findings.group_by(&:path).filter_map do |path, group|
|
|
58
|
+
next unless path && File.file?(path)
|
|
59
|
+
|
|
60
|
+
source = File.read(path)
|
|
61
|
+
edits = build_edits(path, source, group)
|
|
62
|
+
next if edits.empty?
|
|
63
|
+
|
|
64
|
+
Plan.new(path: path, source: source,
|
|
65
|
+
edits: edits.sort_by { |e| -e.start_offset })
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def build_edits(path, source, group)
|
|
72
|
+
magic = nil
|
|
73
|
+
planned = []
|
|
74
|
+
if @unsafe
|
|
75
|
+
file = Static::SourceFile.new(source: source, path: path)
|
|
76
|
+
if file.valid_syntax?
|
|
77
|
+
magic = Rewriters::MagicComments.plan(file, group)
|
|
78
|
+
planned += Rewriters::Memoization.plan(file, group)
|
|
79
|
+
planned += Rewriters::WriteOnce.plan(file, group)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
inline = group.filter_map do |finding|
|
|
84
|
+
autofix = finding.autofix
|
|
85
|
+
next unless autofix
|
|
86
|
+
next if autofix.unsafe? && !@unsafe
|
|
87
|
+
next if magic && magic[:covers].include?(finding.check)
|
|
88
|
+
|
|
89
|
+
autofix
|
|
90
|
+
end
|
|
91
|
+
inline << magic[:edit] if magic
|
|
92
|
+
accept_non_overlapping(inline + planned)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# First edit wins on overlap.
|
|
96
|
+
def accept_non_overlapping(edits)
|
|
97
|
+
accepted = []
|
|
98
|
+
edits.each do |edit|
|
|
99
|
+
overlap = accepted.any? do |other|
|
|
100
|
+
edit.start_offset < other.end_offset &&
|
|
101
|
+
other.start_offset < edit.end_offset
|
|
102
|
+
end
|
|
103
|
+
accepted << edit unless overlap
|
|
104
|
+
end
|
|
105
|
+
accepted
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def patched(plan)
|
|
109
|
+
source = plan.source.dup
|
|
110
|
+
plan.edits.each do |edit|
|
|
111
|
+
source[edit.start_offset...edit.end_offset] =
|
|
112
|
+
edit.replacement
|
|
113
|
+
end
|
|
114
|
+
source
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def hunks(plan)
|
|
118
|
+
plan.edits.sort_by(&:start_offset).map do |edit|
|
|
119
|
+
line_start =
|
|
120
|
+
if edit.start_offset.zero?
|
|
121
|
+
0
|
|
122
|
+
else
|
|
123
|
+
before = plan.source.rindex("\n", edit.start_offset - 1)
|
|
124
|
+
before ? before + 1 : 0
|
|
125
|
+
end
|
|
126
|
+
line_end = plan.source.index("\n", edit.end_offset) ||
|
|
127
|
+
plan.source.length
|
|
128
|
+
old = plan.source[line_start...line_end]
|
|
129
|
+
updated = old.dup
|
|
130
|
+
span = ((edit.start_offset - line_start)...
|
|
131
|
+
(edit.end_offset - line_start))
|
|
132
|
+
updated[span] = edit.replacement
|
|
133
|
+
{
|
|
134
|
+
line: plan.source[0, edit.start_offset].count("\n") + 1,
|
|
135
|
+
old: old,
|
|
136
|
+
new: updated.chomp
|
|
137
|
+
}
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|