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,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