audition 0.2.4 → 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 +175 -39
- data/lib/audition/bundle_sweep.rb +32 -15
- data/lib/audition/cli.rb +181 -100
- data/lib/audition/config.rb +33 -7
- data/lib/audition/dynamic/harness.rb +323 -31
- data/lib/audition/dynamic/prober.rb +190 -43
- data/lib/audition/finding.rb +10 -2
- data/lib/audition/progress.rb +418 -0
- data/lib/audition/report/github.rb +69 -0
- data/lib/audition/report/json.rb +68 -0
- data/lib/audition/report/style.rb +74 -0
- data/lib/audition/report/sweep.rb +182 -0
- data/lib/audition/report/text.rb +160 -0
- data/lib/audition/report.rb +16 -295
- data/lib/audition/rewriters.rb +8 -5
- 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 +162 -28
- data/lib/audition/static/checks/runtime_require.rb +4 -1
- data/lib/audition/static/checks/unsafe_calls.rb +7 -6
- 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 +1053 -12
- data/lib/audition/static/literal_classifier.rb +191 -20
- data/lib/audition/static/native_extensions.rb +175 -0
- data/lib/audition/static/source_file.rb +70 -0
- data/lib/audition/static/work_split.rb +54 -0
- data/lib/audition/target.rb +147 -14
- data/lib/audition/version.rb +1 -1
- data/lib/audition.rb +3 -0
- metadata +15 -4
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "table_tennis"
|
|
5
|
+
|
|
6
|
+
module Audition
|
|
7
|
+
class Report
|
|
8
|
+
# Renders a bundle sweep. Its unit is a gem rather than a
|
|
9
|
+
# finding, so it gets its own renderer instead of bending
|
|
10
|
+
# Report around a shape it does not have.
|
|
11
|
+
class Sweep
|
|
12
|
+
CELLS = {
|
|
13
|
+
:not_ready => "not ready", :blocked => "blocked",
|
|
14
|
+
:risky => "risky", :ready => "ready", nil => "-"
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
# The severity glyphs the text report uses, so a verdict
|
|
18
|
+
# reads the same in a cell as it does in a summary.
|
|
19
|
+
GLYPHS = {
|
|
20
|
+
not_ready: :error, blocked: :warning,
|
|
21
|
+
risky: :warning, ready: :pass
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
# Foreground only, and applied to the whole row: a
|
|
25
|
+
# background fill reads as a bar across the table, and a
|
|
26
|
+
# painted cell would widen its column by the invisible
|
|
27
|
+
# length of its own escape sequence. Ready gems keep the
|
|
28
|
+
# default color, so what is colored is what needs reading.
|
|
29
|
+
PAINTS = Ractor.make_shareable({
|
|
30
|
+
:not_ready => [:red], :blocked => [:magenta],
|
|
31
|
+
:risky => [:yellow], nil => [:faint]
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
TITLE = "Audition bundle sweep"
|
|
35
|
+
|
|
36
|
+
# @param rows [Array<BundleSweep::Row>] one per locked gem
|
|
37
|
+
# @param style [Style] palette for the text rendering
|
|
38
|
+
def initialize(rows, style)
|
|
39
|
+
@rows = rows
|
|
40
|
+
@style = style
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# @param table_opts [Hash] what the terminal supports; see
|
|
44
|
+
# the caller for why color cannot be detected here
|
|
45
|
+
# @return [String] the table plus a summary line
|
|
46
|
+
def render(**table_opts)
|
|
47
|
+
table = TableTennis.new(cells, title: TITLE, mark: paint,
|
|
48
|
+
**table_opts)
|
|
49
|
+
"#{table}\n#{summary}"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def json
|
|
53
|
+
JSON.pretty_generate(
|
|
54
|
+
"audition" => VERSION,
|
|
55
|
+
"ruby" => RUBY_VERSION,
|
|
56
|
+
"bundle" => @rows.map { |r| json_row(r) }
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Sweep rows carry no file or line, so annotations land on
|
|
61
|
+
# the run summary rather than a diff.
|
|
62
|
+
# @return [String] annotation lines plus a summary line
|
|
63
|
+
def annotations
|
|
64
|
+
lines = @rows.filter_map do |row|
|
|
65
|
+
level = annotation_level(row)
|
|
66
|
+
next unless level
|
|
67
|
+
|
|
68
|
+
"::#{level} title=Audition::gem #{row.name} " \
|
|
69
|
+
"#{row.version}: #{row.errors} errors, " \
|
|
70
|
+
"#{row.dep_errors} dependency errors, " \
|
|
71
|
+
"#{row.warnings} warnings (#{CELLS.fetch(row.verdict)})"
|
|
72
|
+
end
|
|
73
|
+
(lines << plain_summary).join("\n")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# @return [String] job summary page table for Actions
|
|
77
|
+
def markdown
|
|
78
|
+
lines = [
|
|
79
|
+
"## #{TITLE}", "",
|
|
80
|
+
"| gem | version | verdict | errors | dep errors " \
|
|
81
|
+
"| warnings | fixable |",
|
|
82
|
+
"| --- | --- | --- | --- | --- | --- | --- |"
|
|
83
|
+
]
|
|
84
|
+
@rows.each do |r|
|
|
85
|
+
lines << "| #{r.name} | #{r.version} | " \
|
|
86
|
+
"#{CELLS.fetch(r.verdict)} | #{r.errors} | " \
|
|
87
|
+
"#{r.dep_errors} | #{r.warnings} | #{r.fixable} |"
|
|
88
|
+
end
|
|
89
|
+
lines.push("", plain_summary).join("\n")
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
def cells
|
|
95
|
+
@rows.map do |r|
|
|
96
|
+
{
|
|
97
|
+
"gem" => r.name,
|
|
98
|
+
"version" => r.version,
|
|
99
|
+
"verdict" => verdict_cell(r.verdict),
|
|
100
|
+
"errors" => clean_as_blank(r.errors),
|
|
101
|
+
"dep errors" => clean_as_blank(r.dep_errors),
|
|
102
|
+
"warnings" => clean_as_blank(r.warnings),
|
|
103
|
+
"fixable" => clean_as_blank(r.fixable),
|
|
104
|
+
"status" => r.status
|
|
105
|
+
}
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def verdict_cell(verdict)
|
|
110
|
+
cell = CELLS.fetch(verdict)
|
|
111
|
+
glyph = GLYPHS[verdict]
|
|
112
|
+
glyph ? "#{@style.glyph(glyph)} #{cell}" : cell
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# A clean count is the common case across hundreds of gems;
|
|
116
|
+
# leaving it to the table's placeholder keeps the eye on the
|
|
117
|
+
# rows that carry something.
|
|
118
|
+
def clean_as_blank(count)
|
|
119
|
+
count.positive? ? count : nil
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# The table gem hands the lambda back the row it was given,
|
|
123
|
+
# which carries the gem but not the verdict symbol.
|
|
124
|
+
def paint
|
|
125
|
+
paints = @rows.to_h do |r|
|
|
126
|
+
[[r.name, r.version], PAINTS[r.verdict]]
|
|
127
|
+
end
|
|
128
|
+
->(row) { paints[[row["gem"], row["version"]]] }
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def summary
|
|
132
|
+
glyph, paint = if blockers.positive?
|
|
133
|
+
[:error, :red]
|
|
134
|
+
elsif ready == @rows.size
|
|
135
|
+
[:pass, :green]
|
|
136
|
+
else
|
|
137
|
+
[:warning, :yellow]
|
|
138
|
+
end
|
|
139
|
+
head = @style.public_send(paint,
|
|
140
|
+
"#{@style.glyph(glyph)} #{plain_summary}")
|
|
141
|
+
return head if blockers.zero?
|
|
142
|
+
|
|
143
|
+
"#{head} #{@style.dim("· #{blockers} not ready")}"
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def plain_summary
|
|
147
|
+
"#{ready} of #{@rows.size} gems ractor-ready"
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def ready
|
|
151
|
+
@ready ||= @rows.count { |r| r.verdict == :ready }
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def blockers
|
|
155
|
+
@blockers ||= @rows.count { |r| r.verdict == :not_ready }
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def annotation_level(row)
|
|
159
|
+
if row.verdict == :not_ready ||
|
|
160
|
+
(row.errors + row.dep_errors).positive?
|
|
161
|
+
"error"
|
|
162
|
+
elsif row.warnings.positive?
|
|
163
|
+
"warning"
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def json_row(row)
|
|
168
|
+
{
|
|
169
|
+
"gem" => row.name,
|
|
170
|
+
"version" => row.version,
|
|
171
|
+
"verdict" => row.verdict&.to_s,
|
|
172
|
+
"errors" => row.errors,
|
|
173
|
+
"dependency_errors" => row.dep_errors,
|
|
174
|
+
"warnings" => row.warnings,
|
|
175
|
+
"infos" => row.infos,
|
|
176
|
+
"fixable" => row.fixable,
|
|
177
|
+
"status" => row.status
|
|
178
|
+
}
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Audition
|
|
4
|
+
class Report
|
|
5
|
+
# Terminal renderer; styles stay injectable so pipes and
|
|
6
|
+
# --plain degrade cleanly.
|
|
7
|
+
class Text
|
|
8
|
+
WRAP = 74
|
|
9
|
+
|
|
10
|
+
def initialize(report, style)
|
|
11
|
+
@report = report
|
|
12
|
+
@style = style
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def render
|
|
16
|
+
[header, *file_sections, *dynamic_section, summary]
|
|
17
|
+
.join("\n")
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def header
|
|
23
|
+
s = @style
|
|
24
|
+
title = s.bold("Audition #{VERSION}")
|
|
25
|
+
meta = s.dim(
|
|
26
|
+
"ruby #{RUBY_VERSION} · #{@report.target_type} at " \
|
|
27
|
+
"#{@report.target_root}"
|
|
28
|
+
)
|
|
29
|
+
"#{s.glyph(:section)} #{title} #{meta}\n"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def file_sections
|
|
33
|
+
@report.findings.group_by(&:path).map do |path, findings|
|
|
34
|
+
lines = [@style.bold(" #{path}")]
|
|
35
|
+
findings.each { |f| lines.concat(finding_lines(f)) }
|
|
36
|
+
lines.join("\n") + "\n"
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def finding_lines(finding)
|
|
41
|
+
s = @style
|
|
42
|
+
glyph = s.severity_color(finding.severity,
|
|
43
|
+
s.glyph(finding.severity))
|
|
44
|
+
loc = location_label(finding)
|
|
45
|
+
fix_mark = finding.fixable? ? " #{s.cyan(s.glyph(:fix))}" : ""
|
|
46
|
+
dep_mark =
|
|
47
|
+
finding.dependency? ? " #{s.dim("(dependency)")}" : ""
|
|
48
|
+
test_mark = finding.test? ? " #{s.dim("(tests)")}" : ""
|
|
49
|
+
head = " #{glyph} #{loc}#{finding.message}" \
|
|
50
|
+
"#{fix_mark}#{dep_mark}#{test_mark} " \
|
|
51
|
+
"#{s.dim(finding.check)}"
|
|
52
|
+
[head,
|
|
53
|
+
*annotation("why", finding.why),
|
|
54
|
+
*annotation("fix", finding.fix)]
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def location_label(finding)
|
|
58
|
+
return "" unless finding.line
|
|
59
|
+
|
|
60
|
+
s = @style
|
|
61
|
+
text = "#{finding.path}:#{finding.line}"
|
|
62
|
+
absolute = File.expand_path(finding.path, @report.target_root)
|
|
63
|
+
"#{s.cyan(s.link(text, absolute))} "
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def annotation(label, content)
|
|
67
|
+
return [] if content.nil? || content.empty?
|
|
68
|
+
|
|
69
|
+
wrapped = wrap("#{label}: #{content}", WRAP - 6)
|
|
70
|
+
wrapped.map { |line| " #{@style.dim(line)}" }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Tokens longer than the width (long URLs) cannot end before
|
|
74
|
+
# whitespace, so the first alternative would drop their head;
|
|
75
|
+
# the second hard-slices them instead.
|
|
76
|
+
def wrap(text, width)
|
|
77
|
+
text.scan(/\S.{0,#{width - 1}}(?=\s|\z)|\S{#{width}}/m)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def dynamic_section
|
|
81
|
+
return [] if @report.dynamic_results.empty?
|
|
82
|
+
|
|
83
|
+
s = @style
|
|
84
|
+
lines = [s.bold(" dynamic probes")]
|
|
85
|
+
@report.dynamic_results.each do |result|
|
|
86
|
+
lines << if result.passed
|
|
87
|
+
" #{s.green(s.glyph(:pass))} " \
|
|
88
|
+
"#{result.mode} probe passed inside a Ractor"
|
|
89
|
+
else
|
|
90
|
+
" #{s.red(s.glyph(:error))} " \
|
|
91
|
+
"#{result.mode} probe failed " \
|
|
92
|
+
"#{s.dim("(details above)")}"
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
[lines.join("\n") + "\n"]
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def pluralize(count, noun)
|
|
99
|
+
(count == 1) ? "#{count} #{noun}" : "#{count} #{noun}s"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def summary
|
|
103
|
+
s = @style
|
|
104
|
+
c = @report.counts
|
|
105
|
+
parts = []
|
|
106
|
+
if c[:error].positive?
|
|
107
|
+
parts << s.red(pluralize(c[:error], "error"))
|
|
108
|
+
end
|
|
109
|
+
if c[:dep_error].positive?
|
|
110
|
+
parts << s.magenta(
|
|
111
|
+
pluralize(c[:dep_error], "dependency error")
|
|
112
|
+
)
|
|
113
|
+
end
|
|
114
|
+
if c[:warning].positive?
|
|
115
|
+
parts << s.yellow(pluralize(c[:warning], "warning"))
|
|
116
|
+
end
|
|
117
|
+
parts << s.cyan("#{c[:info]} info") if c[:info].positive?
|
|
118
|
+
test_total = c[:test_error] + c[:test_warning] +
|
|
119
|
+
c[:test_info]
|
|
120
|
+
if test_total.positive?
|
|
121
|
+
parts << s.dim(
|
|
122
|
+
pluralize(test_total, "test finding") +
|
|
123
|
+
" (#{c[:test_error]} error / " \
|
|
124
|
+
"#{c[:test_warning]} warning / #{c[:test_info]} info)"
|
|
125
|
+
)
|
|
126
|
+
end
|
|
127
|
+
if c[:fixable].positive?
|
|
128
|
+
parts << s.cyan(
|
|
129
|
+
"#{c[:fixable]} fixable #{s.glyph(:fix)} " \
|
|
130
|
+
"(run with --fix)"
|
|
131
|
+
)
|
|
132
|
+
end
|
|
133
|
+
if @report.unsafe_fixes.positive?
|
|
134
|
+
parts << s.cyan(
|
|
135
|
+
pluralize(@report.unsafe_fixes, "edit") +
|
|
136
|
+
" with --fix-unsafe"
|
|
137
|
+
)
|
|
138
|
+
end
|
|
139
|
+
if @report.baselined.positive?
|
|
140
|
+
parts << s.dim("#{@report.baselined} baselined")
|
|
141
|
+
end
|
|
142
|
+
parts << s.green("no findings") if parts.empty?
|
|
143
|
+
|
|
144
|
+
verdict = @report.verdict
|
|
145
|
+
glyph, paint =
|
|
146
|
+
case verdict
|
|
147
|
+
when :not_ready then [:error, :red]
|
|
148
|
+
when :blocked then [:warning, :magenta]
|
|
149
|
+
when :risky then [:warning, :yellow]
|
|
150
|
+
else [:pass, :green]
|
|
151
|
+
end
|
|
152
|
+
badge = s.public_send(paint,
|
|
153
|
+
"#{s.glyph(glyph)} " +
|
|
154
|
+
VERDICTS.fetch(verdict))
|
|
155
|
+
" summary: #{parts.join(" · ")}\n" \
|
|
156
|
+
" verdict: #{s.bold(badge)}\n"
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
data/lib/audition/report.rb
CHANGED
|
@@ -1,84 +1,15 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "json"
|
|
4
|
-
require "pastel"
|
|
5
|
-
require "tty/link"
|
|
6
|
-
|
|
7
3
|
module Audition
|
|
8
|
-
# Aggregates static findings and dynamic results into a verdict
|
|
9
|
-
#
|
|
4
|
+
# Aggregates static findings and dynamic results into a verdict
|
|
5
|
+
# and the counts the renderers work from. Rendering lives in the
|
|
6
|
+
# Report::Text, Report::Json, and Report::Github classes.
|
|
10
7
|
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 color?
|
|
38
|
-
@color
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
def glyph(kind)
|
|
42
|
-
GLYPHS.fetch(kind)[@color ? 0 : 1]
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
PAINTS.each do |name|
|
|
46
|
-
define_method(name) do |text| # audition:disable unsafe-calls
|
|
47
|
-
@pastel.public_send(name, text)
|
|
48
|
-
end
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
def severity_color(severity, text)
|
|
52
|
-
case severity
|
|
53
|
-
when :error then red(text)
|
|
54
|
-
when :warning then yellow(text)
|
|
55
|
-
else cyan(text)
|
|
56
|
-
end
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
# OSC 8 hyperlink wrapping "path:line" display text in a
|
|
60
|
-
# file:// URI; supporting terminals make it clickable.
|
|
61
|
-
# tty-link emits when it detects support; when hyperlinks are
|
|
62
|
-
# forced on despite no detection (tests, --force scenarios)
|
|
63
|
-
# fall back to the raw OSC 8 template, since tty-link's
|
|
64
|
-
# fallback is "text -> url" prose.
|
|
65
|
-
def link(text, absolute_path)
|
|
66
|
-
return text unless @hyperlinks
|
|
67
|
-
|
|
68
|
-
uri = "file://#{absolute_path}"
|
|
69
|
-
if TTY::Link.link?
|
|
70
|
-
TTY::Link.link_to(text, uri)
|
|
71
|
-
else
|
|
72
|
-
"\e]8;;#{uri}\e\\#{text}\e]8;;\e\\"
|
|
73
|
-
end
|
|
74
|
-
end
|
|
75
|
-
end
|
|
76
|
-
|
|
77
8
|
VERDICTS = {
|
|
78
9
|
not_ready: "not ractor-ready",
|
|
79
10
|
blocked: "own code is ractor-ready; blocked by dependencies",
|
|
80
11
|
risky: "risky: warnings only, no hard errors",
|
|
81
|
-
ready: "ractor-ready as far as
|
|
12
|
+
ready: "ractor-ready as far as Audition can tell"
|
|
82
13
|
}.freeze
|
|
83
14
|
|
|
84
15
|
attr_reader :target_type, :target_root, :findings,
|
|
@@ -129,13 +60,19 @@ module Audition
|
|
|
129
60
|
counts[:dep_error].positive?
|
|
130
61
|
end
|
|
131
62
|
|
|
63
|
+
# Test findings count apart at every severity: they are the
|
|
64
|
+
# target's code, but a production boot never loads them, so
|
|
65
|
+
# they never touch the verdict.
|
|
132
66
|
def counts
|
|
133
67
|
@counts ||= begin
|
|
134
68
|
base = {error: 0, dep_error: 0, warning: 0, info: 0,
|
|
69
|
+
test_error: 0, test_warning: 0, test_info: 0,
|
|
135
70
|
fixable: 0}
|
|
136
71
|
findings.each_with_object(base) do |f, acc|
|
|
137
72
|
if f.error? && f.dependency?
|
|
138
73
|
acc[:dep_error] += 1
|
|
74
|
+
elsif f.test?
|
|
75
|
+
acc[:"test_#{f.severity}"] += 1
|
|
139
76
|
else
|
|
140
77
|
acc[f.severity] += 1
|
|
141
78
|
end
|
|
@@ -148,227 +85,11 @@ module Audition
|
|
|
148
85
|
end
|
|
149
86
|
end
|
|
150
87
|
end
|
|
151
|
-
|
|
152
|
-
# @param style [Style] rendering style (auto-detected default)
|
|
153
|
-
# @return [String] the human-facing terminal report
|
|
154
|
-
def to_text(style: Style.detect)
|
|
155
|
-
Text.new(self, style).render
|
|
156
|
-
end
|
|
157
|
-
|
|
158
|
-
GITHUB_LEVELS = {
|
|
159
|
-
error: "error", warning: "warning", info: "notice"
|
|
160
|
-
}.freeze
|
|
161
|
-
|
|
162
|
-
# GitHub Actions workflow commands: findings become inline PR
|
|
163
|
-
# annotations when this runs in CI.
|
|
164
|
-
#
|
|
165
|
-
# @return [String] one `::error`/`::warning`/`::notice` line
|
|
166
|
-
# per finding plus a verdict line
|
|
167
|
-
def to_github
|
|
168
|
-
lines = findings.map do |f|
|
|
169
|
-
level = GITHUB_LEVELS.fetch(f.severity)
|
|
170
|
-
location = f.line ? ",line=#{f.line}" : ""
|
|
171
|
-
body = workflow_escape("#{f.message}. #{f.why}")
|
|
172
|
-
file = property_escape(f.path)
|
|
173
|
-
title = property_escape("audition #{f.check}")
|
|
174
|
-
"::#{level} file=#{file}#{location}," \
|
|
175
|
-
"title=#{title}::#{body}"
|
|
176
|
-
end
|
|
177
|
-
lines << "audition verdict: #{VERDICTS.fetch(verdict)}"
|
|
178
|
-
lines.join("\n")
|
|
179
|
-
end
|
|
180
|
-
|
|
181
|
-
def to_json(*)
|
|
182
|
-
JSON.pretty_generate(
|
|
183
|
-
"audition" => VERSION,
|
|
184
|
-
"ruby" => RUBY_VERSION,
|
|
185
|
-
"target" => {"type" => target_type.to_s,
|
|
186
|
-
"root" => target_root},
|
|
187
|
-
"verdict" => verdict.to_s,
|
|
188
|
-
"summary" => {
|
|
189
|
-
"errors" => counts[:error],
|
|
190
|
-
"dependency_errors" => counts[:dep_error],
|
|
191
|
-
"warnings" => counts[:warning],
|
|
192
|
-
"infos" => counts[:info],
|
|
193
|
-
"fixable" => counts[:fixable]
|
|
194
|
-
},
|
|
195
|
-
"findings" => findings.map do |f|
|
|
196
|
-
{
|
|
197
|
-
"check" => f.check,
|
|
198
|
-
"severity" => f.severity.to_s,
|
|
199
|
-
"message" => f.message,
|
|
200
|
-
"why" => f.why,
|
|
201
|
-
"fix" => f.fix,
|
|
202
|
-
"path" => f.path,
|
|
203
|
-
"line" => f.line,
|
|
204
|
-
"source" => f.source,
|
|
205
|
-
"fixable" => f.fixable?,
|
|
206
|
-
"dependency" => f.dependency?
|
|
207
|
-
}
|
|
208
|
-
end,
|
|
209
|
-
"dynamic" => dynamic_results.map do |r|
|
|
210
|
-
{"mode" => r.mode.to_s, "passed" => r.passed,
|
|
211
|
-
"raw" => r.raw}
|
|
212
|
-
end
|
|
213
|
-
)
|
|
214
|
-
end
|
|
215
|
-
|
|
216
|
-
private
|
|
217
|
-
|
|
218
|
-
def workflow_escape(text)
|
|
219
|
-
text.gsub("%", "%25").gsub("\r", "%0D").gsub("\n", "%0A")
|
|
220
|
-
end
|
|
221
|
-
|
|
222
|
-
# Workflow command properties additionally reserve `:` and `,`;
|
|
223
|
-
# an unescaped comma in a path would end the property early.
|
|
224
|
-
def property_escape(text)
|
|
225
|
-
workflow_escape(text).gsub(":", "%3A").gsub(",", "%2C")
|
|
226
|
-
end
|
|
227
|
-
|
|
228
|
-
public
|
|
229
|
-
|
|
230
|
-
# Text renderer, kept separate from the data so styles stay
|
|
231
|
-
# injectable.
|
|
232
|
-
class Text
|
|
233
|
-
WRAP = 74
|
|
234
|
-
|
|
235
|
-
def initialize(report, style)
|
|
236
|
-
@report = report
|
|
237
|
-
@style = style
|
|
238
|
-
end
|
|
239
|
-
|
|
240
|
-
def render
|
|
241
|
-
[header, *file_sections, *dynamic_section, summary]
|
|
242
|
-
.join("\n")
|
|
243
|
-
end
|
|
244
|
-
|
|
245
|
-
private
|
|
246
|
-
|
|
247
|
-
def header
|
|
248
|
-
s = @style
|
|
249
|
-
title = s.bold("audition #{VERSION}")
|
|
250
|
-
meta = s.dim(
|
|
251
|
-
"ruby #{RUBY_VERSION} · #{@report.target_type} at " \
|
|
252
|
-
"#{@report.target_root}"
|
|
253
|
-
)
|
|
254
|
-
"#{s.glyph(:section)} #{title} #{meta}\n"
|
|
255
|
-
end
|
|
256
|
-
|
|
257
|
-
def file_sections
|
|
258
|
-
@report.findings.group_by(&:path).map do |path, findings|
|
|
259
|
-
lines = [@style.bold(" #{path}")]
|
|
260
|
-
findings.each { |f| lines.concat(finding_lines(f)) }
|
|
261
|
-
lines.join("\n") + "\n"
|
|
262
|
-
end
|
|
263
|
-
end
|
|
264
|
-
|
|
265
|
-
def finding_lines(finding)
|
|
266
|
-
s = @style
|
|
267
|
-
glyph = s.severity_color(finding.severity,
|
|
268
|
-
s.glyph(finding.severity))
|
|
269
|
-
loc = location_label(finding)
|
|
270
|
-
fix_mark = finding.fixable? ? " #{s.cyan(s.glyph(:fix))}" : ""
|
|
271
|
-
dep_mark =
|
|
272
|
-
finding.dependency? ? " #{s.dim("(dependency)")}" : ""
|
|
273
|
-
head = " #{glyph} #{loc}#{finding.message}" \
|
|
274
|
-
"#{fix_mark}#{dep_mark} #{s.dim(finding.check)}"
|
|
275
|
-
[head,
|
|
276
|
-
*annotation("why", finding.why),
|
|
277
|
-
*annotation("fix", finding.fix)]
|
|
278
|
-
end
|
|
279
|
-
|
|
280
|
-
def location_label(finding)
|
|
281
|
-
return "" unless finding.line
|
|
282
|
-
|
|
283
|
-
s = @style
|
|
284
|
-
text = "#{finding.path}:#{finding.line}"
|
|
285
|
-
absolute = File.expand_path(finding.path, @report.target_root)
|
|
286
|
-
"#{s.cyan(s.link(text, absolute))} "
|
|
287
|
-
end
|
|
288
|
-
|
|
289
|
-
def annotation(label, content)
|
|
290
|
-
return [] if content.nil? || content.empty?
|
|
291
|
-
|
|
292
|
-
wrapped = wrap("#{label}: #{content}", WRAP - 6)
|
|
293
|
-
wrapped.map { |line| " #{@style.dim(line)}" }
|
|
294
|
-
end
|
|
295
|
-
|
|
296
|
-
# Tokens longer than the width (long URLs) cannot end before
|
|
297
|
-
# whitespace, so the first alternative would drop their head;
|
|
298
|
-
# the second hard-slices them instead.
|
|
299
|
-
def wrap(text, width)
|
|
300
|
-
text.scan(/\S.{0,#{width - 1}}(?=\s|\z)|\S{#{width}}/m)
|
|
301
|
-
end
|
|
302
|
-
|
|
303
|
-
def dynamic_section
|
|
304
|
-
return [] if @report.dynamic_results.empty?
|
|
305
|
-
|
|
306
|
-
s = @style
|
|
307
|
-
lines = [s.bold(" dynamic probes")]
|
|
308
|
-
@report.dynamic_results.each do |result|
|
|
309
|
-
lines << if result.passed
|
|
310
|
-
" #{s.green(s.glyph(:pass))} " \
|
|
311
|
-
"#{result.mode} probe passed inside a Ractor"
|
|
312
|
-
else
|
|
313
|
-
" #{s.red(s.glyph(:error))} " \
|
|
314
|
-
"#{result.mode} probe failed " \
|
|
315
|
-
"#{s.dim("(details above)")}"
|
|
316
|
-
end
|
|
317
|
-
end
|
|
318
|
-
[lines.join("\n") + "\n"]
|
|
319
|
-
end
|
|
320
|
-
|
|
321
|
-
def pluralize(count, noun)
|
|
322
|
-
(count == 1) ? "#{count} #{noun}" : "#{count} #{noun}s"
|
|
323
|
-
end
|
|
324
|
-
|
|
325
|
-
def summary
|
|
326
|
-
s = @style
|
|
327
|
-
c = @report.counts
|
|
328
|
-
parts = []
|
|
329
|
-
if c[:error].positive?
|
|
330
|
-
parts << s.red(pluralize(c[:error], "error"))
|
|
331
|
-
end
|
|
332
|
-
if c[:dep_error].positive?
|
|
333
|
-
parts << s.magenta(
|
|
334
|
-
pluralize(c[:dep_error], "dependency error")
|
|
335
|
-
)
|
|
336
|
-
end
|
|
337
|
-
if c[:warning].positive?
|
|
338
|
-
parts << s.yellow(pluralize(c[:warning], "warning"))
|
|
339
|
-
end
|
|
340
|
-
parts << s.cyan("#{c[:info]} info") if c[:info].positive?
|
|
341
|
-
if c[:fixable].positive?
|
|
342
|
-
parts << s.cyan(
|
|
343
|
-
"#{c[:fixable]} fixable #{s.glyph(:fix)} " \
|
|
344
|
-
"(run with --fix)"
|
|
345
|
-
)
|
|
346
|
-
end
|
|
347
|
-
if @report.unsafe_fixes.positive?
|
|
348
|
-
parts << s.cyan(
|
|
349
|
-
pluralize(@report.unsafe_fixes, "edit") +
|
|
350
|
-
" with --fix-unsafe"
|
|
351
|
-
)
|
|
352
|
-
end
|
|
353
|
-
if @report.baselined.positive?
|
|
354
|
-
parts << s.dim("#{@report.baselined} baselined")
|
|
355
|
-
end
|
|
356
|
-
parts << s.green("no findings") if parts.empty?
|
|
357
|
-
|
|
358
|
-
verdict = @report.verdict
|
|
359
|
-
glyph, paint =
|
|
360
|
-
case verdict
|
|
361
|
-
when :not_ready then [:error, :red]
|
|
362
|
-
when :blocked then [:warning, :magenta]
|
|
363
|
-
when :risky then [:warning, :yellow]
|
|
364
|
-
else [:pass, :green]
|
|
365
|
-
end
|
|
366
|
-
badge = s.public_send(paint,
|
|
367
|
-
"#{s.glyph(glyph)} " +
|
|
368
|
-
VERDICTS.fetch(verdict))
|
|
369
|
-
" summary: #{parts.join(" · ")}\n" \
|
|
370
|
-
" verdict: #{s.bold(badge)}\n"
|
|
371
|
-
end
|
|
372
|
-
end
|
|
373
88
|
end
|
|
374
89
|
end
|
|
90
|
+
|
|
91
|
+
require_relative "report/style"
|
|
92
|
+
require_relative "report/text"
|
|
93
|
+
require_relative "report/json"
|
|
94
|
+
require_relative "report/github"
|
|
95
|
+
require_relative "report/sweep"
|