audition 0.2.4 → 0.3.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 +101 -9
- data/lib/audition/bundle_sweep.rb +11 -3
- data/lib/audition/cli.rb +110 -16
- data/lib/audition/config.rb +3 -3
- data/lib/audition/dynamic/harness.rb +113 -20
- data/lib/audition/dynamic/prober.rb +44 -3
- data/lib/audition/report/github.rb +68 -0
- data/lib/audition/report/json.rb +64 -0
- data/lib/audition/report/style.rb +74 -0
- data/lib/audition/report/text.rb +149 -0
- data/lib/audition/report.rb +8 -294
- data/lib/audition/rewriters.rb +4 -4
- data/lib/audition/static/checks/mutable_constants.rb +91 -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/graph_audit.rb +3 -5
- data/lib/audition/static/literal_classifier.rb +78 -7
- data/lib/audition/static/native_extensions.rb +163 -0
- data/lib/audition/static/source_file.rb +70 -0
- data/lib/audition/target.rb +74 -10
- data/lib/audition/version.rb +1 -1
- data/lib/audition.rb +1 -0
- metadata +6 -1
|
@@ -38,7 +38,10 @@ module Audition
|
|
|
38
38
|
|
|
39
39
|
# Runs the probe described by a {Target#entry} hash.
|
|
40
40
|
#
|
|
41
|
-
# @param entry [Hash] `:mode` plus mode-specific keys
|
|
41
|
+
# @param entry [Hash] `:mode` plus mode-specific keys; require
|
|
42
|
+
# and rails entries may carry `:compiled_files`, the target's
|
|
43
|
+
# own compiled extensions, which the static check already
|
|
44
|
+
# covers and the probe therefore does not report again
|
|
42
45
|
# @return [Result]
|
|
43
46
|
# @raise [Audition::Error] on an unknown mode
|
|
44
47
|
def probe(entry)
|
|
@@ -104,7 +107,8 @@ module Audition
|
|
|
104
107
|
raw = run("require",
|
|
105
108
|
"feature" => feature,
|
|
106
109
|
"load_paths" => Array(entry[:load_paths]),
|
|
107
|
-
"root" => entry[:root]
|
|
110
|
+
"root" => entry[:root],
|
|
111
|
+
"known_compiled" => Array(entry[:compiled_files]))
|
|
108
112
|
findings = runtime_findings(raw, feature)
|
|
109
113
|
Result.new(mode: :require, raw: raw, findings: findings,
|
|
110
114
|
passed: own_clean?(findings))
|
|
@@ -113,7 +117,8 @@ module Audition
|
|
|
113
117
|
def probe_rails(entry)
|
|
114
118
|
raw = run("rails",
|
|
115
119
|
"environment" => entry[:environment],
|
|
116
|
-
"root" => entry[:root]
|
|
120
|
+
"root" => entry[:root],
|
|
121
|
+
"known_compiled" => Array(entry[:compiled_files]))
|
|
117
122
|
boot = raw["boot"]
|
|
118
123
|
if boot && !boot["ok"]
|
|
119
124
|
finding = Finding.new(
|
|
@@ -198,9 +203,45 @@ module Audition
|
|
|
198
203
|
"or Ractor-local storage."
|
|
199
204
|
)
|
|
200
205
|
end
|
|
206
|
+
raw.fetch("native_extensions", []).each do |entry|
|
|
207
|
+
next if entry["ruby"] || entry["known"]
|
|
208
|
+
|
|
209
|
+
findings << native_finding(entry, label)
|
|
210
|
+
end
|
|
201
211
|
findings
|
|
202
212
|
end
|
|
203
213
|
|
|
214
|
+
# Ruby's own extensions are left to Ruby, and files the static
|
|
215
|
+
# check already reported are not repeated; what remains is the
|
|
216
|
+
# native code the target pulls in through its dependencies.
|
|
217
|
+
def native_finding(entry, label)
|
|
218
|
+
name = File.basename(entry["path"])
|
|
219
|
+
native = Static::NativeExtensions
|
|
220
|
+
if entry["declares"]
|
|
221
|
+
runtime_finding(
|
|
222
|
+
entry, label,
|
|
223
|
+
check: "runtime-native-extension",
|
|
224
|
+
severity: :info,
|
|
225
|
+
message: "compiled extension #{name} declares Ractor " \
|
|
226
|
+
"safety (imports #{native::SYMBOL})",
|
|
227
|
+
why: "#{native::DECLARED_WHY} Loaded while requiring " \
|
|
228
|
+
"the target.",
|
|
229
|
+
fix: native::DECLARED_FIX
|
|
230
|
+
)
|
|
231
|
+
else
|
|
232
|
+
runtime_finding(
|
|
233
|
+
entry, label,
|
|
234
|
+
check: "runtime-native-extension",
|
|
235
|
+
severity: :warning,
|
|
236
|
+
message: "compiled extension #{name} does not declare " \
|
|
237
|
+
"Ractor safety",
|
|
238
|
+
why: "#{native::SILENT_WHY}#{native::COMPILED_TAIL} " \
|
|
239
|
+
"Loaded while requiring the target.",
|
|
240
|
+
fix: native::SILENT_FIX
|
|
241
|
+
)
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
|
|
204
245
|
# Class-level state holding only shareable values is the
|
|
205
246
|
# warmed frozen-memoization shape: reads are legal from any
|
|
206
247
|
# Ractor, so it rates an info note; unshareable values are
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Audition
|
|
4
|
+
class Report
|
|
5
|
+
# GitHub Actions renderer: workflow-command annotations that
|
|
6
|
+
# land on the PR diff, plus a markdown table for the job
|
|
7
|
+
# summary page ($GITHUB_STEP_SUMMARY).
|
|
8
|
+
class Github
|
|
9
|
+
LEVELS = {
|
|
10
|
+
error: "error", warning: "warning", info: "notice"
|
|
11
|
+
}.freeze
|
|
12
|
+
|
|
13
|
+
def initialize(report)
|
|
14
|
+
@report = report
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# @return [String] one `::error`/`::warning`/`::notice` line
|
|
18
|
+
# per finding plus a verdict line
|
|
19
|
+
def render
|
|
20
|
+
lines = @report.findings.map { |f| annotation(f) }
|
|
21
|
+
lines <<
|
|
22
|
+
"audition verdict: #{VERDICTS.fetch(@report.verdict)}"
|
|
23
|
+
lines.join("\n")
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# @return [String] verdict heading plus a counts table
|
|
27
|
+
def summary
|
|
28
|
+
c = @report.counts
|
|
29
|
+
<<~MARKDOWN
|
|
30
|
+
## audition: #{VERDICTS.fetch(@report.verdict)}
|
|
31
|
+
|
|
32
|
+
| findings | count |
|
|
33
|
+
| --- | --- |
|
|
34
|
+
| errors | #{c[:error]} |
|
|
35
|
+
| dependency errors | #{c[:dep_error]} |
|
|
36
|
+
| warnings | #{c[:warning]} |
|
|
37
|
+
| info | #{c[:info]} |
|
|
38
|
+
| fixable | #{c[:fixable]} |
|
|
39
|
+
MARKDOWN
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def annotation(f)
|
|
45
|
+
level = LEVELS.fetch(f.severity)
|
|
46
|
+
location = f.line ? ",line=#{f.line}" : ""
|
|
47
|
+
body = workflow_escape("#{f.message}. #{f.why}")
|
|
48
|
+
# Annotations anchor to workspace-relative paths; a `./`
|
|
49
|
+
# prefix (from `audition .`) keeps them off the diff.
|
|
50
|
+
file = property_escape(f.path.delete_prefix("./"))
|
|
51
|
+
title = property_escape("audition #{f.check}")
|
|
52
|
+
"::#{level} file=#{file}#{location}," \
|
|
53
|
+
"title=#{title}::#{body}"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def workflow_escape(text)
|
|
57
|
+
text.gsub("%", "%25").gsub("\r", "%0D").gsub("\n", "%0A")
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Workflow command properties additionally reserve `:` and
|
|
61
|
+
# `,`; an unescaped comma in a path would end the property
|
|
62
|
+
# early.
|
|
63
|
+
def property_escape(text)
|
|
64
|
+
workflow_escape(text).gsub(":", "%3A").gsub(",", "%2C")
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Audition
|
|
6
|
+
class Report
|
|
7
|
+
# Machine-readable renderer for CI pipelines and --compare.
|
|
8
|
+
class Json
|
|
9
|
+
def initialize(report)
|
|
10
|
+
@report = report
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def render
|
|
14
|
+
JSON.pretty_generate(
|
|
15
|
+
"audition" => VERSION,
|
|
16
|
+
"ruby" => RUBY_VERSION,
|
|
17
|
+
"target" => {"type" => @report.target_type.to_s,
|
|
18
|
+
"root" => @report.target_root},
|
|
19
|
+
"verdict" => @report.verdict.to_s,
|
|
20
|
+
"summary" => summary,
|
|
21
|
+
"findings" => findings,
|
|
22
|
+
"dynamic" => dynamic
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def summary
|
|
29
|
+
counts = @report.counts
|
|
30
|
+
{
|
|
31
|
+
"errors" => counts[:error],
|
|
32
|
+
"dependency_errors" => counts[:dep_error],
|
|
33
|
+
"warnings" => counts[:warning],
|
|
34
|
+
"infos" => counts[:info],
|
|
35
|
+
"fixable" => counts[:fixable]
|
|
36
|
+
}
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def findings
|
|
40
|
+
@report.findings.map do |f|
|
|
41
|
+
{
|
|
42
|
+
"check" => f.check,
|
|
43
|
+
"severity" => f.severity.to_s,
|
|
44
|
+
"message" => f.message,
|
|
45
|
+
"why" => f.why,
|
|
46
|
+
"fix" => f.fix,
|
|
47
|
+
"path" => f.path,
|
|
48
|
+
"line" => f.line,
|
|
49
|
+
"source" => f.source,
|
|
50
|
+
"fixable" => f.fixable?,
|
|
51
|
+
"dependency" => f.dependency?
|
|
52
|
+
}
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def dynamic
|
|
57
|
+
@report.dynamic_results.map do |r|
|
|
58
|
+
{"mode" => r.mode.to_s, "passed" => r.passed,
|
|
59
|
+
"raw" => r.raw}
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pastel"
|
|
4
|
+
require "tty/link"
|
|
5
|
+
|
|
6
|
+
module Audition
|
|
7
|
+
class Report
|
|
8
|
+
# ANSI + OSC 8 styling with graceful degradation. Color and
|
|
9
|
+
# hyperlinks are decided once, at construction; pass color: false
|
|
10
|
+
# for pipes, NO_COLOR, or dumb terminals.
|
|
11
|
+
class Style
|
|
12
|
+
GLYPHS = Ractor.make_shareable(
|
|
13
|
+
{
|
|
14
|
+
error: ["✖", "x"], warning: ["⚠", "!"],
|
|
15
|
+
info: ["ℹ", "i"], pass: ["✔", "ok"],
|
|
16
|
+
section: ["◆", "*"], fix: ["✎", "+"]
|
|
17
|
+
}
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
PAINTS = %i[red yellow green cyan magenta dim bold].freeze
|
|
21
|
+
|
|
22
|
+
def self.detect(io: $stdout)
|
|
23
|
+
on = io.respond_to?(:tty?) && io.tty? &&
|
|
24
|
+
!ENV.key?("NO_COLOR") && ENV["TERM"] != "dumb"
|
|
25
|
+
new(color: on, hyperlinks: on && TTY::Link.link?)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def initialize(color:, hyperlinks:)
|
|
29
|
+
@pastel = Pastel.new(enabled: color)
|
|
30
|
+
@color = color
|
|
31
|
+
@hyperlinks = hyperlinks
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def color?
|
|
35
|
+
@color
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def glyph(kind)
|
|
39
|
+
GLYPHS.fetch(kind)[@color ? 0 : 1]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
PAINTS.each do |name|
|
|
43
|
+
define_method(name) do |text| # audition:disable unsafe-calls
|
|
44
|
+
@pastel.public_send(name, text)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def severity_color(severity, text)
|
|
49
|
+
case severity
|
|
50
|
+
when :error then red(text)
|
|
51
|
+
when :warning then yellow(text)
|
|
52
|
+
else cyan(text)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# OSC 8 hyperlink wrapping "path:line" display text in a
|
|
57
|
+
# file:// URI; supporting terminals make it clickable.
|
|
58
|
+
# tty-link emits when it detects support; when hyperlinks are
|
|
59
|
+
# forced on despite no detection (tests, --force scenarios)
|
|
60
|
+
# fall back to the raw OSC 8 template, since tty-link's
|
|
61
|
+
# fallback is "text -> url" prose.
|
|
62
|
+
def link(text, absolute_path)
|
|
63
|
+
return text unless @hyperlinks
|
|
64
|
+
|
|
65
|
+
uri = "file://#{absolute_path}"
|
|
66
|
+
if TTY::Link.link?
|
|
67
|
+
TTY::Link.link_to(text, uri)
|
|
68
|
+
else
|
|
69
|
+
"\e]8;;#{uri}\e\\#{text}\e]8;;\e\\"
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,149 @@
|
|
|
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
|
+
head = " #{glyph} #{loc}#{finding.message}" \
|
|
49
|
+
"#{fix_mark}#{dep_mark} #{s.dim(finding.check)}"
|
|
50
|
+
[head,
|
|
51
|
+
*annotation("why", finding.why),
|
|
52
|
+
*annotation("fix", finding.fix)]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def location_label(finding)
|
|
56
|
+
return "" unless finding.line
|
|
57
|
+
|
|
58
|
+
s = @style
|
|
59
|
+
text = "#{finding.path}:#{finding.line}"
|
|
60
|
+
absolute = File.expand_path(finding.path, @report.target_root)
|
|
61
|
+
"#{s.cyan(s.link(text, absolute))} "
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def annotation(label, content)
|
|
65
|
+
return [] if content.nil? || content.empty?
|
|
66
|
+
|
|
67
|
+
wrapped = wrap("#{label}: #{content}", WRAP - 6)
|
|
68
|
+
wrapped.map { |line| " #{@style.dim(line)}" }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Tokens longer than the width (long URLs) cannot end before
|
|
72
|
+
# whitespace, so the first alternative would drop their head;
|
|
73
|
+
# the second hard-slices them instead.
|
|
74
|
+
def wrap(text, width)
|
|
75
|
+
text.scan(/\S.{0,#{width - 1}}(?=\s|\z)|\S{#{width}}/m)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def dynamic_section
|
|
79
|
+
return [] if @report.dynamic_results.empty?
|
|
80
|
+
|
|
81
|
+
s = @style
|
|
82
|
+
lines = [s.bold(" dynamic probes")]
|
|
83
|
+
@report.dynamic_results.each do |result|
|
|
84
|
+
lines << if result.passed
|
|
85
|
+
" #{s.green(s.glyph(:pass))} " \
|
|
86
|
+
"#{result.mode} probe passed inside a Ractor"
|
|
87
|
+
else
|
|
88
|
+
" #{s.red(s.glyph(:error))} " \
|
|
89
|
+
"#{result.mode} probe failed " \
|
|
90
|
+
"#{s.dim("(details above)")}"
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
[lines.join("\n") + "\n"]
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def pluralize(count, noun)
|
|
97
|
+
(count == 1) ? "#{count} #{noun}" : "#{count} #{noun}s"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def summary
|
|
101
|
+
s = @style
|
|
102
|
+
c = @report.counts
|
|
103
|
+
parts = []
|
|
104
|
+
if c[:error].positive?
|
|
105
|
+
parts << s.red(pluralize(c[:error], "error"))
|
|
106
|
+
end
|
|
107
|
+
if c[:dep_error].positive?
|
|
108
|
+
parts << s.magenta(
|
|
109
|
+
pluralize(c[:dep_error], "dependency error")
|
|
110
|
+
)
|
|
111
|
+
end
|
|
112
|
+
if c[:warning].positive?
|
|
113
|
+
parts << s.yellow(pluralize(c[:warning], "warning"))
|
|
114
|
+
end
|
|
115
|
+
parts << s.cyan("#{c[:info]} info") if c[:info].positive?
|
|
116
|
+
if c[:fixable].positive?
|
|
117
|
+
parts << s.cyan(
|
|
118
|
+
"#{c[:fixable]} fixable #{s.glyph(:fix)} " \
|
|
119
|
+
"(run with --fix)"
|
|
120
|
+
)
|
|
121
|
+
end
|
|
122
|
+
if @report.unsafe_fixes.positive?
|
|
123
|
+
parts << s.cyan(
|
|
124
|
+
pluralize(@report.unsafe_fixes, "edit") +
|
|
125
|
+
" with --fix-unsafe"
|
|
126
|
+
)
|
|
127
|
+
end
|
|
128
|
+
if @report.baselined.positive?
|
|
129
|
+
parts << s.dim("#{@report.baselined} baselined")
|
|
130
|
+
end
|
|
131
|
+
parts << s.green("no findings") if parts.empty?
|
|
132
|
+
|
|
133
|
+
verdict = @report.verdict
|
|
134
|
+
glyph, paint =
|
|
135
|
+
case verdict
|
|
136
|
+
when :not_ready then [:error, :red]
|
|
137
|
+
when :blocked then [:warning, :magenta]
|
|
138
|
+
when :risky then [:warning, :yellow]
|
|
139
|
+
else [:pass, :green]
|
|
140
|
+
end
|
|
141
|
+
badge = s.public_send(paint,
|
|
142
|
+
"#{s.glyph(glyph)} " +
|
|
143
|
+
VERDICTS.fetch(verdict))
|
|
144
|
+
" summary: #{parts.join(" · ")}\n" \
|
|
145
|
+
" verdict: #{s.bold(badge)}\n"
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|