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,344 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "pastel"
|
|
5
|
+
require "tty/link"
|
|
6
|
+
|
|
7
|
+
module Audition
|
|
8
|
+
# Aggregates static findings and dynamic results into a verdict and
|
|
9
|
+
# renders them as npm-CLI-style text or JSON.
|
|
10
|
+
class Report
|
|
11
|
+
# ANSI + OSC 8 styling with graceful degradation. Color and
|
|
12
|
+
# hyperlinks are decided once, at construction; pass color: false
|
|
13
|
+
# for pipes, NO_COLOR, or dumb terminals.
|
|
14
|
+
class Style
|
|
15
|
+
GLYPHS = Ractor.make_shareable(
|
|
16
|
+
{
|
|
17
|
+
error: ["✖", "x"], warning: ["⚠", "!"],
|
|
18
|
+
info: ["ℹ", "i"], pass: ["✔", "ok"],
|
|
19
|
+
section: ["◆", "*"], fix: ["✎", "+"]
|
|
20
|
+
}
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
PAINTS = %i[red yellow green cyan magenta dim bold].freeze
|
|
24
|
+
|
|
25
|
+
def self.detect(io: $stdout)
|
|
26
|
+
on = io.respond_to?(:tty?) && io.tty? &&
|
|
27
|
+
!ENV.key?("NO_COLOR") && ENV["TERM"] != "dumb"
|
|
28
|
+
new(color: on, hyperlinks: on && TTY::Link.link?)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def initialize(color:, hyperlinks:)
|
|
32
|
+
@pastel = Pastel.new(enabled: color)
|
|
33
|
+
@color = color
|
|
34
|
+
@hyperlinks = hyperlinks
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def glyph(kind)
|
|
38
|
+
GLYPHS.fetch(kind)[@color ? 0 : 1]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
PAINTS.each do |name|
|
|
42
|
+
define_method(name) do |text|
|
|
43
|
+
@pastel.public_send(name, text)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def severity_color(severity, text)
|
|
48
|
+
case severity
|
|
49
|
+
when :error then red(text)
|
|
50
|
+
when :warning then yellow(text)
|
|
51
|
+
else cyan(text)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# OSC 8 hyperlink wrapping "path:line" display text in a
|
|
56
|
+
# file:// URI; supporting terminals make it clickable.
|
|
57
|
+
# tty-link emits when it detects support; when hyperlinks are
|
|
58
|
+
# forced on despite no detection (tests, --force scenarios)
|
|
59
|
+
# fall back to the raw OSC 8 template, since tty-link's
|
|
60
|
+
# fallback is "text -> url" prose.
|
|
61
|
+
def link(text, absolute_path)
|
|
62
|
+
return text unless @hyperlinks
|
|
63
|
+
|
|
64
|
+
uri = "file://#{absolute_path}"
|
|
65
|
+
if TTY::Link.link?
|
|
66
|
+
TTY::Link.link_to(text, uri)
|
|
67
|
+
else
|
|
68
|
+
"\e]8;;#{uri}\e\\#{text}\e]8;;\e\\"
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
VERDICTS = {
|
|
74
|
+
not_ready: "not ractor-ready",
|
|
75
|
+
blocked: "own code is ractor-ready; blocked by dependencies",
|
|
76
|
+
risky: "risky: warnings only, no hard errors",
|
|
77
|
+
ready: "ractor-ready as far as audition can tell"
|
|
78
|
+
}.freeze
|
|
79
|
+
|
|
80
|
+
attr_reader :target_type, :target_root, :findings,
|
|
81
|
+
:dynamic_results, :unsafe_fixes, :baselined
|
|
82
|
+
|
|
83
|
+
# @param target_type [Symbol] see {Target#type}
|
|
84
|
+
# @param target_root [String]
|
|
85
|
+
# @param findings [Array<Finding>] static plus dynamic findings
|
|
86
|
+
# @param dynamic_results [Array<Dynamic::Result>]
|
|
87
|
+
# @param unsafe_fixes [Integer] shown as the `--fix-unsafe` hint
|
|
88
|
+
# @param baselined [Integer] findings hidden by the baseline
|
|
89
|
+
def initialize(target_type:, target_root:, findings:,
|
|
90
|
+
dynamic_results: [], unsafe_fixes: 0,
|
|
91
|
+
baselined: 0)
|
|
92
|
+
@target_type = target_type
|
|
93
|
+
@target_root = target_root
|
|
94
|
+
@findings = findings.sort_by do |f|
|
|
95
|
+
[f.path, f.line || 0, -f.severity_rank]
|
|
96
|
+
end
|
|
97
|
+
@dynamic_results = dynamic_results
|
|
98
|
+
@unsafe_fixes = unsafe_fixes
|
|
99
|
+
@baselined = baselined
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Policy: own errors condemn the target outright; dependency
|
|
103
|
+
# errors (or a failed probe with clean own findings) mean the
|
|
104
|
+
# target is fine but cannot run here yet; anything softer is
|
|
105
|
+
# merely risky.
|
|
106
|
+
#
|
|
107
|
+
# @return [Symbol] `:not_ready`, `:blocked`, `:risky`, or
|
|
108
|
+
# `:ready`
|
|
109
|
+
def verdict
|
|
110
|
+
return :not_ready if own_errors?
|
|
111
|
+
return :blocked if dependency_errors? ||
|
|
112
|
+
dynamic_results.any? { |r| !r.passed }
|
|
113
|
+
return :risky if counts[:warning].positive? ||
|
|
114
|
+
counts[:info].positive?
|
|
115
|
+
|
|
116
|
+
:ready
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def own_errors?
|
|
120
|
+
counts[:error].positive?
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def dependency_errors?
|
|
124
|
+
counts[:dep_error].positive?
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def counts
|
|
128
|
+
@counts ||= begin
|
|
129
|
+
base = {error: 0, dep_error: 0, warning: 0, info: 0,
|
|
130
|
+
fixable: 0}
|
|
131
|
+
findings.each_with_object(base) do |f, acc|
|
|
132
|
+
if f.error? && f.dependency?
|
|
133
|
+
acc[:dep_error] += 1
|
|
134
|
+
else
|
|
135
|
+
acc[f.severity] += 1
|
|
136
|
+
end
|
|
137
|
+
acc[:fixable] += 1 if f.fixable?
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# @param style [Style] rendering style (auto-detected default)
|
|
143
|
+
# @return [String] the human-facing terminal report
|
|
144
|
+
def to_text(style: Style.detect)
|
|
145
|
+
Text.new(self, style).render
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
GITHUB_LEVELS = {
|
|
149
|
+
error: "error", warning: "warning", info: "notice"
|
|
150
|
+
}.freeze
|
|
151
|
+
|
|
152
|
+
# GitHub Actions workflow commands: findings become inline PR
|
|
153
|
+
# annotations when this runs in CI.
|
|
154
|
+
#
|
|
155
|
+
# @return [String] one `::error`/`::warning`/`::notice` line
|
|
156
|
+
# per finding plus a verdict line
|
|
157
|
+
def to_github
|
|
158
|
+
lines = findings.map do |f|
|
|
159
|
+
level = GITHUB_LEVELS.fetch(f.severity)
|
|
160
|
+
location = f.line ? ",line=#{f.line}" : ""
|
|
161
|
+
body = workflow_escape("#{f.message}. #{f.why}")
|
|
162
|
+
"::#{level} file=#{f.path}#{location}," \
|
|
163
|
+
"title=audition #{f.check}::#{body}"
|
|
164
|
+
end
|
|
165
|
+
lines << "audition verdict: #{VERDICTS.fetch(verdict)}"
|
|
166
|
+
lines.join("\n")
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def to_json(*)
|
|
170
|
+
JSON.pretty_generate(
|
|
171
|
+
"audition" => VERSION,
|
|
172
|
+
"ruby" => RUBY_VERSION,
|
|
173
|
+
"target" => {"type" => target_type.to_s,
|
|
174
|
+
"root" => target_root},
|
|
175
|
+
"verdict" => verdict.to_s,
|
|
176
|
+
"summary" => {
|
|
177
|
+
"errors" => counts[:error],
|
|
178
|
+
"dependency_errors" => counts[:dep_error],
|
|
179
|
+
"warnings" => counts[:warning],
|
|
180
|
+
"infos" => counts[:info],
|
|
181
|
+
"fixable" => counts[:fixable]
|
|
182
|
+
},
|
|
183
|
+
"findings" => findings.map do |f|
|
|
184
|
+
{
|
|
185
|
+
"check" => f.check,
|
|
186
|
+
"severity" => f.severity.to_s,
|
|
187
|
+
"message" => f.message,
|
|
188
|
+
"why" => f.why,
|
|
189
|
+
"fix" => f.fix,
|
|
190
|
+
"path" => f.path,
|
|
191
|
+
"line" => f.line,
|
|
192
|
+
"source" => f.source,
|
|
193
|
+
"fixable" => f.fixable?,
|
|
194
|
+
"dependency" => f.dependency?
|
|
195
|
+
}
|
|
196
|
+
end,
|
|
197
|
+
"dynamic" => dynamic_results.map do |r|
|
|
198
|
+
{"mode" => r.mode.to_s, "passed" => r.passed,
|
|
199
|
+
"raw" => r.raw}
|
|
200
|
+
end
|
|
201
|
+
)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
private
|
|
205
|
+
|
|
206
|
+
def workflow_escape(text)
|
|
207
|
+
text.gsub("%", "%25").gsub("\r", "%0D").gsub("\n", "%0A")
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
public
|
|
211
|
+
|
|
212
|
+
# Text renderer, kept separate from the data so styles stay
|
|
213
|
+
# injectable.
|
|
214
|
+
class Text
|
|
215
|
+
WRAP = 74
|
|
216
|
+
|
|
217
|
+
def initialize(report, style)
|
|
218
|
+
@report = report
|
|
219
|
+
@style = style
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def render
|
|
223
|
+
[header, *file_sections, *dynamic_section, summary]
|
|
224
|
+
.join("\n")
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
private
|
|
228
|
+
|
|
229
|
+
def header
|
|
230
|
+
s = @style
|
|
231
|
+
title = s.bold("audition #{VERSION}")
|
|
232
|
+
meta = s.dim(
|
|
233
|
+
"ruby #{RUBY_VERSION} · #{@report.target_type} at " \
|
|
234
|
+
"#{@report.target_root}"
|
|
235
|
+
)
|
|
236
|
+
"#{s.glyph(:section)} #{title} #{meta}\n"
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def file_sections
|
|
240
|
+
@report.findings.group_by(&:path).map do |path, findings|
|
|
241
|
+
lines = [@style.bold(" #{path}")]
|
|
242
|
+
findings.each { |f| lines.concat(finding_lines(f)) }
|
|
243
|
+
lines.join("\n") + "\n"
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def finding_lines(finding)
|
|
248
|
+
s = @style
|
|
249
|
+
glyph = s.severity_color(finding.severity,
|
|
250
|
+
s.glyph(finding.severity))
|
|
251
|
+
loc = location_label(finding)
|
|
252
|
+
fix_mark = finding.fixable? ? " #{s.cyan(s.glyph(:fix))}" : ""
|
|
253
|
+
dep_mark =
|
|
254
|
+
finding.dependency? ? " #{s.dim("(dependency)")}" : ""
|
|
255
|
+
head = " #{glyph} #{loc}#{finding.message}" \
|
|
256
|
+
"#{fix_mark}#{dep_mark} #{s.dim(finding.check)}"
|
|
257
|
+
[head,
|
|
258
|
+
*annotation("why", finding.why),
|
|
259
|
+
*annotation("fix", finding.fix)]
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def location_label(finding)
|
|
263
|
+
return "" unless finding.line
|
|
264
|
+
|
|
265
|
+
s = @style
|
|
266
|
+
text = "#{finding.path}:#{finding.line}"
|
|
267
|
+
absolute = File.expand_path(finding.path, @report.target_root)
|
|
268
|
+
"#{s.cyan(s.link(text, absolute))} "
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def annotation(label, content)
|
|
272
|
+
return [] if content.nil? || content.empty?
|
|
273
|
+
|
|
274
|
+
wrapped = wrap("#{label}: #{content}", WRAP - 6)
|
|
275
|
+
wrapped.map { |line| " #{@style.dim(line)}" }
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def wrap(text, width)
|
|
279
|
+
text.scan(/\S.{0,#{width - 1}}(?=\s|\z)/m)
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def dynamic_section
|
|
283
|
+
return [] if @report.dynamic_results.empty?
|
|
284
|
+
|
|
285
|
+
s = @style
|
|
286
|
+
lines = [s.bold(" dynamic probes")]
|
|
287
|
+
@report.dynamic_results.each do |result|
|
|
288
|
+
lines << if result.passed
|
|
289
|
+
" #{s.green(s.glyph(:pass))} " \
|
|
290
|
+
"#{result.mode} probe passed inside a Ractor"
|
|
291
|
+
else
|
|
292
|
+
" #{s.red(s.glyph(:error))} " \
|
|
293
|
+
"#{result.mode} probe failed " \
|
|
294
|
+
"#{s.dim("(details above)")}"
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
[lines.join("\n") + "\n"]
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def summary
|
|
301
|
+
s = @style
|
|
302
|
+
c = @report.counts
|
|
303
|
+
parts = []
|
|
304
|
+
parts << s.red("#{c[:error]} errors") if c[:error].positive?
|
|
305
|
+
if c[:dep_error].positive?
|
|
306
|
+
parts << s.magenta("#{c[:dep_error]} dependency errors")
|
|
307
|
+
end
|
|
308
|
+
if c[:warning].positive?
|
|
309
|
+
parts << s.yellow("#{c[:warning]} warnings")
|
|
310
|
+
end
|
|
311
|
+
parts << s.cyan("#{c[:info]} info") if c[:info].positive?
|
|
312
|
+
if c[:fixable].positive?
|
|
313
|
+
parts << s.cyan(
|
|
314
|
+
"#{c[:fixable]} fixable #{s.glyph(:fix)} " \
|
|
315
|
+
"(run with --fix)"
|
|
316
|
+
)
|
|
317
|
+
end
|
|
318
|
+
if @report.unsafe_fixes.positive?
|
|
319
|
+
parts << s.cyan(
|
|
320
|
+
"#{@report.unsafe_fixes} more with --fix-unsafe"
|
|
321
|
+
)
|
|
322
|
+
end
|
|
323
|
+
if @report.baselined.positive?
|
|
324
|
+
parts << s.dim("#{@report.baselined} baselined")
|
|
325
|
+
end
|
|
326
|
+
parts << s.green("no findings") if parts.empty?
|
|
327
|
+
|
|
328
|
+
verdict = @report.verdict
|
|
329
|
+
glyph, paint =
|
|
330
|
+
case verdict
|
|
331
|
+
when :not_ready then [:error, :red]
|
|
332
|
+
when :blocked then [:warning, :magenta]
|
|
333
|
+
when :risky then [:warning, :yellow]
|
|
334
|
+
else [:pass, :green]
|
|
335
|
+
end
|
|
336
|
+
badge = s.public_send(paint,
|
|
337
|
+
"#{s.glyph(glyph)} " +
|
|
338
|
+
VERDICTS.fetch(verdict))
|
|
339
|
+
" summary: #{parts.join(" · ")}\n" \
|
|
340
|
+
" verdict: #{s.bold(badge)}\n"
|
|
341
|
+
end
|
|
342
|
+
end
|
|
343
|
+
end
|
|
344
|
+
end
|