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