txray 0.1.0 → 0.2.1

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.
data/lib/txray/config.rb CHANGED
@@ -43,8 +43,13 @@ module Txray
43
43
  new(data.is_a?(Hash) ? data : {})
44
44
  end
45
45
 
46
+ ARRAY_KEYS = %w[include exclude disabled_rules external_clients].freeze
47
+ HASH_KEYS = %w[severities runtime].freeze
48
+ FAIL_LEVELS = %w[low medium high none].freeze
49
+
46
50
  def initialize(data = {})
47
- @data = DEFAULTS.merge(data) { |_key, default, given| default.is_a?(Hash) ? default.merge(given.to_h) : given }
51
+ validate!(data)
52
+ @data = DEFAULTS.merge(data) { |_key, default, given| default.is_a?(Hash) ? default.merge(given) : given }
48
53
  end
49
54
 
50
55
  def includes = Array(@data["include"])
@@ -58,6 +63,26 @@ module Txray
58
63
 
59
64
  def rule_enabled?(id) = !disabled_rules.include?(id.to_s)
60
65
 
66
+ def validate!(data)
67
+ ARRAY_KEYS.each { |key| expect(data, key, Array) }
68
+ HASH_KEYS.each { |key| expect(data, key, Hash) }
69
+
70
+ depth = data["max_depth"]
71
+ raise Error, "max_depth must be a whole number, got #{depth.inspect}" if depth && !depth.is_a?(Integer)
72
+
73
+ level = data["fail_level"]
74
+ return if level.nil? || FAIL_LEVELS.include?(level.to_s)
75
+
76
+ raise Error, "fail_level must be one of #{FAIL_LEVELS.join(", ")}, got #{level.inspect}"
77
+ end
78
+
79
+ def expect(data, key, type)
80
+ value = data[key]
81
+ return if value.nil? || value.is_a?(type)
82
+
83
+ raise Error, "#{key} must be #{type == Array ? "a list" : "a mapping"}, got #{value.inspect}"
84
+ end
85
+
61
86
  def rule(id)
62
87
  severity = @data["severities"].to_h[id.to_s]
63
88
  base = Rules[id]
@@ -70,6 +95,8 @@ module Txray
70
95
  self.class.new(@data.merge(overrides.compact.transform_keys(&:to_s)))
71
96
  end
72
97
 
98
+ private :validate!, :expect
99
+
73
100
  def to_h = @data
74
101
  end
75
102
  end
data/lib/txray/monitor.rb CHANGED
@@ -30,9 +30,28 @@ module Txray
30
30
  nil
31
31
  end
32
32
 
33
+ BUCKETS = [ [ 10, "<10ms" ], [ 50, "<50ms" ], [ 100, "<100ms" ], [ 250, "<250ms" ],
34
+ [ 1000, "<1s" ], [ Float::INFINITY, "1s+" ] ].freeze
35
+
33
36
  def recent(limit) = @recent.first(limit)
37
+ def pids = @transactions.map { |event| event[:pid] }.uniq.size
38
+
39
+ def breakdown
40
+ flagged = @transactions.count { |event| event[:violations].to_a.any? }
41
+ slow = @transactions.count { |event| slow?(event) && event[:violations].to_a.empty? }
42
+ { ok: @transactions.size - flagged - slow, slow: slow, flagged: flagged }
43
+ end
44
+
45
+ def histogram
46
+ BUCKETS.map do |limit, label|
47
+ { label: label, limit: limit,
48
+ count: durations.count { |value| value < limit && value >= previous_limit(limit) } }
49
+ end
50
+ end
51
+
34
52
  def durations = @transactions.map { |event| event[:duration_ms].to_f }
35
- def slow = @transactions.count { |event| event[:duration_ms].to_f >= @threshold_ms }
53
+ def slow = @transactions.count { |event| slow?(event) }
54
+ def slow?(event) = event[:duration_ms].to_f >= @threshold_ms
36
55
  def flagged = @transactions.count { |event| event[:violations].to_a.any? }
37
56
  def uptime = Time.now - @started_at
38
57
  def empty? = @transactions.empty? && @violations.empty?
@@ -60,6 +79,11 @@ module Txray
60
79
 
61
80
  private
62
81
 
82
+ def previous_limit(limit)
83
+ index = BUCKETS.index { |bucket_limit, _| bucket_limit == limit }
84
+ index.zero? ? 0 : BUCKETS[index - 1].first
85
+ end
86
+
63
87
  def add(collection, event)
64
88
  collection << event
65
89
  collection.shift while collection.size > CAP
@@ -4,17 +4,83 @@ module Txray
4
4
  module Reporters
5
5
  class Github
6
6
  LEVELS = { high: "error", medium: "warning", low: "notice" }.freeze
7
+ SUMMARY_LIMIT = 50
7
8
 
8
- def initialize(io: $stdout)
9
+ def initialize(io: $stdout, summary_path: ENV.fetch("GITHUB_STEP_SUMMARY", nil))
9
10
  @io = io
11
+ @summary_path = summary_path
10
12
  end
11
13
 
12
14
  def report(result)
13
- result.offenses.each do |offense|
14
- level = LEVELS.fetch(offense.severity, "warning")
15
- title = "txray: #{offense.id}"
16
- @io.puts "::#{level} file=#{offense.path},line=#{offense.line},col=#{offense.column},title=#{title}::#{offense.message}"
15
+ result.offenses.each { |offense| @io.puts annotation(offense) }
16
+ write_summary(result)
17
+ end
18
+
19
+ private
20
+
21
+ def annotation(offense)
22
+ level = LEVELS.fetch(offense.severity, "warning")
23
+ properties = [ "file=#{property(offense.path)}", "line=#{offense.line}", "col=#{offense.column}",
24
+ "title=#{property("txray #{offense.id}")}" ].join(",")
25
+ "::#{level} #{properties}::#{message(offense)}"
26
+ end
27
+
28
+ def message(offense)
29
+ body = [ offense.message, *offense.trace.map { |frame| "via #{frame}" }, offense.rule.remedy ].join("\n")
30
+ escape(body)
31
+ end
32
+
33
+ def escape(text)
34
+ text.to_s.gsub("%", "%25").gsub("\r", "%0D").gsub("\n", "%0A")
35
+ end
36
+
37
+ def property(text)
38
+ escape(text).gsub(":", "%3A").gsub(",", "%2C")
39
+ end
40
+
41
+ def write_summary(result)
42
+ return if @summary_path.nil? || @summary_path.empty?
43
+
44
+ File.open(@summary_path, "a") { |file| file.puts(summary(result)) }
45
+ rescue StandardError
46
+ nil
47
+ end
48
+
49
+ def summary(result)
50
+ if result.offenses.empty?
51
+ return [ "## txray", "",
52
+ "No slow work found inside transactions across #{result.files.size} files." ].join("\n")
17
53
  end
54
+
55
+ [ "## txray", "", headline(result), "", table(result), footnote(result) ].compact.join("\n")
56
+ end
57
+
58
+ def headline(result)
59
+ counts = Offense::SEVERITIES.reverse.filter_map do |severity|
60
+ "#{result.counts[severity]} #{severity}" if result.counts[severity]
61
+ end
62
+ "**#{result.offenses.size} offenses** across #{result.files.size} files (#{counts.join(", ")})."
63
+ end
64
+
65
+ def table(result)
66
+ rows = result.offenses.first(SUMMARY_LIMIT).map do |offense|
67
+ "| #{offense.severity} | `#{offense.id}` | #{link(offense)} | #{cell(offense.message)} |"
68
+ end
69
+ [ "| Severity | Rule | Location | What happens |", "| --- | --- | --- | --- |", *rows ].join("\n")
70
+ end
71
+
72
+ def link(offense)
73
+ "`#{offense.path}:#{offense.line}`"
74
+ end
75
+
76
+ def cell(text)
77
+ text.to_s.gsub("|", "\\|").gsub("\n", " ")
78
+ end
79
+
80
+ def footnote(result)
81
+ return nil if result.offenses.size <= SUMMARY_LIMIT
82
+
83
+ "\n_Showing the first #{SUMMARY_LIMIT} of #{result.offenses.size}. Run `bundle exec txray` locally for the rest._"
18
84
  end
19
85
  end
20
86
  end
@@ -6,13 +6,17 @@ module Txray
6
6
  module Reporters
7
7
  class Live
8
8
  SPARKS = %w[▁ ▂ ▃ ▄ ▅ ▆ ▇ █].freeze
9
+ SEVERITY_COLORS = { high: 91, medium: 93, low: 96 }.freeze
9
10
  HIDE_CURSOR = "\e[?25l"
10
11
  SHOW_CURSOR = "\e[?25h"
11
12
 
12
- def initialize(path:, io: $stdout, threshold_ms: 250)
13
+ def initialize(path:, io: $stdout, threshold_ms: 250, color: Reporters.color?(io))
13
14
  @io = io
14
15
  @path = path
15
16
  @threshold_ms = threshold_ms
17
+ @color = color
18
+ @width = 100
19
+ @height = 40
16
20
  end
17
21
 
18
22
  def open = @io.print(HIDE_CURSOR)
@@ -27,11 +31,12 @@ module Txray
27
31
  def draw(monitor)
28
32
  @width, @height = viewport
29
33
  @io.print("\e[H\e[2J")
30
- header(monitor)
31
- stats(monitor)
34
+ title(monitor)
35
+ meters(monitor)
36
+ histogram(monitor)
32
37
  feed(monitor)
33
38
  hotspots(monitor)
34
- @io.print(dim(" ctrl-c to stop"))
39
+ footer
35
40
  @io.flush
36
41
  end
37
42
 
@@ -44,113 +49,202 @@ module Txray
44
49
  [ 100, 40 ]
45
50
  end
46
51
 
47
- def feed_limit = (@height - 19).clamp(3, 9)
48
-
49
- def clip(text, budget)
50
- budget = [ budget, 12 ].max
51
- text.length <= budget ? text : "..#{text[-(budget - 2)..]}"
52
+ def title(monitor)
53
+ workers = monitor.pids
54
+ right = "up #{clock(monitor.uptime)}#{" #{workers} pids" if workers > 1}"
55
+ left = " txray #{@path}"
56
+ @io.puts bar(left, right)
57
+ @io.puts
52
58
  end
53
59
 
54
- def header(monitor)
55
- @io.puts
56
- @io.puts " #{bold(magenta("txray"))} #{dim("watching #{@path}")}#{" " * 2}#{dim(clock(monitor.uptime))}"
60
+ def meters(monitor)
61
+ return setup_hint if monitor.transactions.empty?
62
+
63
+ counts = monitor.breakdown
64
+ summary = legend(monitor, counts)
65
+ @io.puts " #{label("txns")}#{stacked(counts, room(summary))} #{summary}"
66
+ spread = percentiles(monitor)
67
+ @io.puts " #{label("time")}#{sparkline(monitor, room(spread))} #{spread}"
57
68
  @io.puts
58
69
  end
59
70
 
60
- def stats(monitor)
61
- if monitor.empty?
71
+ def room(text) = (@width - 12 - visible(text)).clamp(8, 34)
72
+
73
+ def setup_hint
74
+ if File.exist?(@path)
62
75
  @io.puts " #{dim("waiting for the application to open a transaction")}"
63
- @io.puts
64
- return
76
+ return @io.puts
65
77
  end
66
78
 
67
- @io.puts " #{label("transactions")}#{count(monitor)}"
68
- @io.puts " #{label("duration")}#{spread(monitor)}"
69
- @io.puts " #{label("")}#{sparkline(monitor)}"
79
+ @io.puts " #{paint("no event log yet", 93)} #{dim("at #{@path}")}"
80
+ @io.puts
81
+ @io.puts " #{dim("the runtime guard writes it. to turn it on:")}"
82
+ @io.puts
83
+ @io.puts " #{paint("1", 97)} Gemfile #{dim('gem "txray" not require: false')}"
84
+ @io.puts " #{paint("2", 97)} .txray.yml #{dim("runtime:")}"
85
+ @io.puts " #{dim(" enabled: true")}"
86
+ @io.puts " #{paint("3", 97)} #{dim("restart the app, then exercise it")}"
70
87
  @io.puts
71
88
  end
72
89
 
73
- def count(monitor)
74
- [ "#{monitor.transactions.size} seen",
75
- tint("#{monitor.slow} slow", monitor.slow.zero? ? :dim : :yellow),
76
- tint("#{monitor.flagged} with findings", monitor.flagged.zero? ? :dim : :red) ].join(dim(" "))
90
+ def stacked(counts, width)
91
+ parts = [ [ counts[:ok], 92 ], [ counts[:slow], 93 ], [ counts[:flagged], 91 ] ]
92
+ return "#{dim("[")}#{dim("·" * width)}#{dim("]")}" if parts.sum(&:first).zero?
93
+
94
+ cells = allocate(parts.map(&:first), width)
95
+ rendered = parts.each_with_index.map { |(_, color), i| paint("█" * cells[i], color) }.join
96
+ "#{dim("[")}#{rendered}#{dim("]")}"
77
97
  end
78
98
 
79
- def spread(monitor)
80
- [ "p50 #{duration(monitor.percentile(0.5))}",
81
- "p95 #{duration(monitor.percentile(0.95))}",
82
- "max #{duration(monitor.durations.max.to_f)}" ].join(dim(" "))
99
+ def allocate(counts, width)
100
+ total = counts.sum
101
+ cells = counts.map { |count| count.zero? ? 0 : [ ((count.to_f / total) * width).round, 1 ].max }
102
+ cells[cells.index(cells.max)] += width - cells.sum
103
+ cells.map { |cell| [ cell, 0 ].max }
83
104
  end
84
105
 
85
- def sparkline(monitor)
86
- values = monitor.durations.last(48)
87
- return dim("-") if values.empty?
106
+ def legend(monitor, counts)
107
+ [ "#{monitor.transactions.size} txns",
108
+ paint("#{counts[:ok]} ok", 92),
109
+ paint("#{counts[:slow]} slow", 93),
110
+ paint("#{counts[:flagged]} flagged", 91) ].join(dim(" · "))
111
+ end
88
112
 
89
- peak = [ values.max, 1.0 ].max
90
- values.map { |value| SPARKS[((value / peak) * (SPARKS.size - 1)).round] }.join
113
+ def percentiles(monitor)
114
+ [ tinted("p50", monitor.percentile(0.5)),
115
+ tinted("p95", monitor.percentile(0.95)),
116
+ tinted("max", monitor.durations.max.to_f) ].join(dim(" · "))
91
117
  end
92
118
 
119
+ def tinted(name, value)
120
+ "#{dim(name)} #{paint(duration(value), heat(value))}"
121
+ end
122
+
123
+ def heat(value)
124
+ return 91 if value >= @threshold_ms
125
+ return 93 if value >= @threshold_ms / 2.0
126
+
127
+ 92
128
+ end
129
+
130
+ def sparkline(monitor, width)
131
+ values = monitor.durations.last(width)
132
+ return dim("·" * width) if values.empty?
133
+
134
+ scale = Math.log(1 + [ values.max, 1.0 ].max)
135
+ values.map do |value|
136
+ level = ((Math.log(1 + value) / scale) * (SPARKS.size - 1)).round
137
+ paint(SPARKS[level.clamp(0, SPARKS.size - 1)], heat(value))
138
+ end.join
139
+ end
140
+
141
+ def histogram(monitor)
142
+ rows = monitor.histogram.reject { |row| row[:count].zero? }
143
+ return if rows.empty?
144
+
145
+ peak = rows.map { |row| row[:count] }.max
146
+ width = @width < 72 ? [ @width - 20, 10 ].max : [ (@width - 30) / 2, 12 ].max
147
+ columns = @width < 72 ? 1 : 2
148
+ rows.each_slice(columns) { |group| @io.puts(" #{group.map { |row| bucket(row, peak, width) }.join(" ")}") }
149
+ @io.puts
150
+ end
151
+
152
+ def bucket(row, peak, width)
153
+ filled = [ ((row[:count].to_f / peak) * width).round, 1 ].max
154
+ color = heat(row[:limit] == Float::INFINITY ? @threshold_ms * 10 : row[:limit] - 1)
155
+ "#{dim(row[:label].rjust(7))} #{paint("█" * filled, color)}#{dim("·" * [ width - filled, 0 ].max)} " \
156
+ "#{count_label(row[:count])}"
157
+ end
158
+
159
+ def count_label(count) = paint(count.to_s.ljust(4), 97)
160
+
93
161
  def feed(monitor)
94
162
  rows = monitor.recent(feed_limit)
95
163
  return if rows.empty?
96
164
 
97
- @io.puts " #{dim("LIVE")}"
165
+ @io.puts bar(" TIME ELAPSED SOURCE", "")
98
166
  rows.each { |event| @io.puts(event[:type] == "transaction" ? transaction_row(event) : violation_row(event)) }
99
167
  @io.puts
100
168
  end
101
169
 
102
170
  def transaction_row(event)
103
- marker = if event[:violations].to_a.any?
104
- red("x")
105
- else
106
- (event[:duration_ms].to_f >= @threshold_ms ? yellow("!") : green("."))
107
- end
108
- line = " #{dim(time(event))} #{duration(event[:duration_ms]).rjust(18)} #{marker} " \
109
- "#{clip(source(event), @width - 36)}"
110
- [ line, *event[:violations].to_a.map { |violation| finding_row(violation) } ].join("\n")
171
+ findings = event[:violations].to_a
172
+ state = if findings.any?
173
+ paint("●", 91)
174
+ elsif event[:duration_ms].to_f >= @threshold_ms
175
+ paint("●", 93)
176
+ else
177
+ paint("·", 92)
178
+ end
179
+ elapsed = paint(duration(event[:duration_ms]).rjust(9), heat(event[:duration_ms].to_f))
180
+ line = " #{dim(time(event))} #{elapsed} #{state} #{clip(source(event), @width - 34)}"
181
+ [ line, *findings.map { |finding| finding_row(finding) } ].join("\n")
111
182
  end
112
183
 
113
184
  def violation_row(event)
114
- " #{dim(time(event))} #{elapsed(event[:duration_ms]).rjust(18)} #{red("x")} " \
115
- "#{red(event[:rule].to_s)} #{dim(clip(source(event), @width - 36 - event[:rule].to_s.length))}"
185
+ rule = event[:rule].to_s
186
+ " #{dim(time(event))} #{dim(" -")} #{paint("●", severity_color(rule))} " \
187
+ "#{paint(rule, severity_color(rule))} #{dim(clip(source(event), @width - 36 - rule.length))}"
116
188
  end
117
189
 
118
- def elapsed(milliseconds) = milliseconds ? duration(milliseconds) : dim("-")
119
-
120
- def finding_row(violation)
121
- detail = [ violation[:message], duration_suffix(violation) ].compact.join(" ")
122
- rule = violation[:rule].to_s
123
- " #{" " * 24}#{dim("|")} #{yellow(rule)} #{dim(clip(detail, @width - 28 - rule.length))}"
190
+ def finding_row(finding)
191
+ rule = finding[:rule].to_s
192
+ detail = [ finding[:message], duration_suffix(finding) ].compact.join(" ")
193
+ " #{" " * 20}#{dim("└")} #{paint(rule, severity_color(rule))} " \
194
+ "#{dim(clip(detail, @width - 26 - rule.length))}"
124
195
  end
125
196
 
126
- def duration_suffix(violation)
127
- "(#{duration(violation[:duration_ms])})" if violation[:duration_ms]
197
+ def severity_color(rule)
198
+ SEVERITY_COLORS.fetch(Rules.all[rule]&.severity, 95)
128
199
  end
129
200
 
130
- def source(event) = event[:source].to_s.sub(/:in\s+[`'"](.+)[`'"]\z/, " \\1")
201
+ def duration_suffix(finding)
202
+ "(#{duration(finding[:duration_ms])})" if finding[:duration_ms]
203
+ end
131
204
 
132
205
  def hotspots(monitor)
133
- rows = monitor.hotspots(5)
206
+ rows = monitor.hotspots(hotspot_limit)
134
207
  return if rows.empty?
135
208
 
136
- @io.puts " #{dim("HOTSPOTS")}"
209
+ @io.puts bar(" HOTSPOTS", "")
137
210
  rows.each do |entry|
138
- @io.puts " #{"#{entry[:count]}x".rjust(4)} #{yellow(entry[:rule].to_s.ljust(32))} " \
139
- "#{dim(clip(source(entry), @width - 42))}"
211
+ rule = entry[:rule].to_s
212
+ @io.puts " #{paint("#{entry[:count]}x".rjust(5), 97)} #{paint(rule.ljust(32), severity_color(rule))} " \
213
+ "#{dim(clip(source(entry), @width - 45))}"
140
214
  end
141
215
  @io.puts
142
216
  end
143
217
 
218
+ def footer = @io.print(bar(" ctrl-c stop", ""))
219
+
144
220
  def summary(monitor)
145
221
  return @io.puts(" no transactions observed") if monitor.empty?
146
222
 
147
- @io.puts " #{monitor.transactions.size} transactions, #{monitor.slow} slow, " \
148
- "#{monitor.findings.size} findings over #{clock(monitor.uptime)}"
223
+ counts = monitor.breakdown
224
+ @io.puts " #{monitor.transactions.size} transactions over #{clock(monitor.uptime)}: " \
225
+ "#{counts[:ok]} ok, #{counts[:slow]} slow, #{counts[:flagged]} with findings"
149
226
  monitor.hotspots(10).each do |entry|
150
- @io.puts " #{"#{entry[:count]}x".rjust(4)} #{entry[:rule].to_s.ljust(32)} #{source(entry)}"
227
+ @io.puts " #{"#{entry[:count]}x".rjust(5)} #{entry[:rule].to_s.ljust(32)} #{source(entry)}"
151
228
  end
152
229
  end
153
230
 
231
+ def bar(left, right)
232
+ padding = [ @width - visible(left) - visible(right) - 1, 1 ].max
233
+ return "#{left}#{" " * padding}#{right} " unless @color
234
+
235
+ "\e[7m#{left}#{" " * padding}#{right} \e[0m"
236
+ end
237
+
238
+ def feed_limit = (@height - 22).clamp(3, 10)
239
+ def hotspot_limit = (@height - 30).clamp(2, 5)
240
+
241
+ def clip(text, budget)
242
+ budget = [ budget, 12 ].max
243
+ text.length <= budget ? text : "..#{text[-(budget - 2)..]}"
244
+ end
245
+
246
+ def visible(text) = text.gsub(/\e\[[0-9;?]*[a-zA-Z]/, "").length
247
+
154
248
  def duration(milliseconds)
155
249
  value = milliseconds.to_f
156
250
  return "#{value.round}ms" if value < 1000
@@ -159,21 +253,15 @@ module Txray
159
253
  end
160
254
 
161
255
  def time(event) = Time.at(event[:at].to_f).strftime("%H:%M:%S")
256
+ def source(event) = event[:source].to_s.sub(/:in\s+[`'"](.+)[`'"]\z/, " \\1")
257
+ def label(text) = dim(text.ljust(6))
162
258
 
163
259
  def clock(seconds)
164
260
  format("%02d:%02d:%02d", seconds / 3600, (seconds % 3600) / 60, seconds % 60)
165
261
  end
166
262
 
167
- def label(text) = text.ljust(16)
168
-
169
- def tint(text, color) = color == :dim ? dim(text) : send(color, text)
170
-
171
- def bold(text) = "\e[1m#{text}\e[0m"
172
- def dim(text) = "\e[2m#{text}\e[0m"
173
- def red(text) = "\e[31m#{text}\e[0m"
174
- def green(text) = "\e[32m#{text}\e[0m"
175
- def yellow(text) = "\e[33m#{text}\e[0m"
176
- def magenta(text) = "\e[35m#{text}\e[0m"
263
+ def paint(text, color) = @color ? "\e[#{color}m#{text}\e[0m" : text
264
+ def dim(text) = @color ? "\e[2m#{text}\e[0m" : text
177
265
  end
178
266
  end
179
267
  end
@@ -1,44 +1,93 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "io/console"
4
+
3
5
  module Txray
4
6
  module Reporters
5
7
  class Text
6
- COLORS = { high: 31, medium: 33, low: 36 }.freeze
8
+ COLORS = { high: 91, medium: 93, low: 96 }.freeze
7
9
 
8
- def initialize(io: $stdout, color: io.tty?)
10
+ def initialize(io: $stdout, color: Reporters.color?(io))
9
11
  @io = io
10
12
  @color = color
13
+ @width = width
11
14
  end
12
15
 
13
16
  def report(result)
14
- result.offenses.group_by(&:path).each do |path, offenses|
15
- @io.puts bold(path)
16
- offenses.each { |offense| print_offense(offense) }
17
- @io.puts
17
+ result.offenses.group_by(&:path).each_with_index do |(path, offenses), index|
18
+ @io.puts if index.positive?
19
+ heading(path, offenses)
20
+ offenses.each_with_index do |offense, position|
21
+ @io.puts if position.positive?
22
+ offense_body(offense)
23
+ end
18
24
  end
19
- print_summary(result)
25
+
26
+ @io.puts
27
+ summary(result)
20
28
  end
21
29
 
22
30
  private
23
31
 
24
- def print_offense(offense)
25
- @io.puts " #{offense.line}:#{offense.column} #{tint(offense.severity.to_s.ljust(6), offense.severity)} #{offense.id}"
26
- @io.puts " #{offense.message}"
27
- offense.trace.each { |frame| @io.puts dim(" via #{frame}") }
28
- @io.puts dim(" #{offense.rule.remedy}")
32
+ def heading(path, offenses)
33
+ count = "#{offenses.size} #{offenses.size == 1 ? "offense" : "offenses"}"
34
+ leader = "·" * [ @width - path.length - count.length - 2, 3 ].max
35
+ @io.puts "#{bold(path)} #{dim(leader)} #{dim(count)}"
36
+ end
37
+
38
+ def offense_body(offense)
39
+ @io.puts " #{location(offense)} #{tint(offense.severity.to_s.ljust(6), offense.severity)} " \
40
+ "#{tint(offense.id, offense.severity)}"
41
+ paragraph(offense.message, " ", 6).each { |line| @io.puts line }
42
+ offense.trace.each { |frame| @io.puts dim(" └ via #{frame}") }
43
+ paragraph(offense.rule.remedy, " → ", 8).each { |line| @io.puts dim(line) }
44
+ end
45
+
46
+ def paragraph(text, prefix, indent)
47
+ lines = fold(text, [ @width - indent, 24 ].max)
48
+ [ "#{prefix}#{lines.first}", *lines.drop(1).map { |line| "#{" " * indent}#{line}" } ]
49
+ end
50
+
51
+ def fold(text, budget)
52
+ text.split.each_with_object([ "" ]) do |word, lines|
53
+ if lines.last.empty?
54
+ lines[-1] = word
55
+ elsif lines.last.length + 1 + word.length <= budget
56
+ lines[-1] = "#{lines.last} #{word}"
57
+ else
58
+ lines << word
59
+ end
60
+ end
29
61
  end
30
62
 
31
- def print_summary(result)
63
+ def location(offense) = "#{offense.line}:#{offense.column}".ljust(7)
64
+
65
+ def summary(result)
32
66
  counts = result.counts
33
- summary = Offense::SEVERITIES.reverse.filter_map { |s| "#{counts[s]} #{s}" if counts[s] }
34
- @io.puts "#{result.files.size} files scanned, #{result.offenses.size} offenses" \
35
- "#{" (#{summary.join(", ")})" unless summary.empty?}"
67
+ parts = [ "#{result.files.size} files scanned", "#{result.offenses.size} offenses" ]
68
+ parts += Offense::SEVERITIES.reverse.filter_map do |severity|
69
+ tint("#{counts[severity]} #{severity}", severity) if counts[severity]
70
+ end
71
+
72
+ @io.puts dim("─" * @width)
73
+ @io.puts parts.join(dim(" · "))
74
+ skipped(result)
75
+ end
76
+
77
+ def skipped(result)
36
78
  return if result.skipped.to_a.empty?
37
79
 
38
80
  @io.puts dim("#{result.skipped.size} files could not be parsed and were skipped:")
39
81
  result.skipped.each { |path| @io.puts dim(" #{path}") }
40
82
  end
41
83
 
84
+ def width
85
+ columns = IO.console&.winsize&.last
86
+ (columns.to_i.positive? ? columns : 88).clamp(40, 100)
87
+ rescue StandardError
88
+ 88
89
+ end
90
+
42
91
  def tint(text, severity) = @color ? "\e[#{COLORS.fetch(severity, 0)}m#{text}\e[0m" : text
43
92
  def bold(text) = @color ? "\e[1m#{text}\e[0m" : text
44
93
  def dim(text) = @color ? "\e[2m#{text}\e[0m" : text
@@ -8,6 +8,10 @@ require_relative "reporters/live"
8
8
 
9
9
  module Txray
10
10
  module Reporters
11
+ def self.color?(io)
12
+ ENV["NO_COLOR"].to_s.empty? && io.respond_to?(:tty?) && io.tty?
13
+ end
14
+
11
15
  FORMATS = { "text" => Text, "json" => Json, "sarif" => Sarif, "github" => Github }.freeze
12
16
 
13
17
  def self.build(format, io: $stdout)