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.
@@ -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