ui_guardrails 1.2.0 → 1.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 +20 -2
- data/lib/guardrails/a11y_audit.rb +26 -3
- data/lib/guardrails/a11y_deep.rb +27 -2
- data/lib/guardrails/audit.rb +31 -3
- data/lib/guardrails/class_itis.rb +30 -3
- data/lib/guardrails/cross_codebase_patterns.rb +30 -3
- data/lib/guardrails/partial_similarity.rb +30 -3
- data/lib/guardrails/report/finding.rb +63 -0
- data/lib/guardrails/report/html/template.html.erb +237 -0
- data/lib/guardrails/report/html.rb +104 -0
- data/lib/guardrails/report/run.rb +224 -0
- data/lib/guardrails/stimulus_audit.rb +88 -12
- data/lib/guardrails/tui/editor.rb +71 -0
- data/lib/guardrails/tui/keys.rb +101 -0
- data/lib/guardrails/tui/screen.rb +361 -0
- data/lib/guardrails/tui/state.rb +356 -0
- data/lib/guardrails/tui.rb +154 -0
- data/lib/guardrails/version.rb +1 -1
- data/lib/guardrails/view_component_audit.rb +57 -7
- data/lib/guardrails/visual_diff.rb +36 -6
- data/lib/tasks/guardrails.rake +41 -192
- metadata +10 -1
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "pathname"
|
|
4
4
|
require_relative "report/style"
|
|
5
|
+
require_relative "report/finding"
|
|
5
6
|
|
|
6
7
|
module Guardrails
|
|
7
8
|
class StimulusAudit
|
|
@@ -56,16 +57,92 @@ module Guardrails
|
|
|
56
57
|
result
|
|
57
58
|
end
|
|
58
59
|
|
|
60
|
+
# Detector-agnostic view of `result` (see Report::Finding). Unlike
|
|
61
|
+
# the text report, findings carry locations: every view line that
|
|
62
|
+
# references an orphaned controller, and the JS file behind a dead
|
|
63
|
+
# one — the names alone don't tell you where to go.
|
|
64
|
+
def categories(result)
|
|
65
|
+
[
|
|
66
|
+
Report::Category.new(
|
|
67
|
+
name: "stimulus orphaned", severity: :warning, framing: ORPHANED_FRAMING.join(" "),
|
|
68
|
+
findings: result.orphaned.map { |name|
|
|
69
|
+
Report::Finding.new(
|
|
70
|
+
category: "stimulus orphaned", severity: :warning,
|
|
71
|
+
title: "stimulus orphaned: #{name}", suggestion: orphaned_suggestion(name),
|
|
72
|
+
locations: reference_locations(name)
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
),
|
|
76
|
+
Report::Category.new(
|
|
77
|
+
name: "stimulus dead", severity: :warning, framing: DEAD_FRAMING.join(" "),
|
|
78
|
+
findings: result.dead.map { |name|
|
|
79
|
+
Report::Finding.new(
|
|
80
|
+
category: "stimulus dead", severity: :warning,
|
|
81
|
+
title: "stimulus dead: #{name}", suggestion: dead_suggestion(name),
|
|
82
|
+
locations: controller_locations(name)
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
)
|
|
86
|
+
].reject { |c| c.findings.empty? }
|
|
87
|
+
end
|
|
88
|
+
|
|
59
89
|
private
|
|
60
90
|
|
|
61
|
-
|
|
62
|
-
|
|
91
|
+
ORPHANED_FRAMING = [
|
|
92
|
+
"data-controller=\"…\" references a Stimulus controller, but no matching",
|
|
93
|
+
"*_controller.{js,ts} file exists. Either create the controller or",
|
|
94
|
+
"remove the reference."
|
|
95
|
+
].freeze
|
|
96
|
+
|
|
97
|
+
DEAD_FRAMING = [
|
|
98
|
+
"*_controller.{js,ts} file exists, but no view references it via",
|
|
99
|
+
"data-controller=\"…\". Either wire the controller into a template",
|
|
100
|
+
"or delete the file."
|
|
101
|
+
].freeze
|
|
102
|
+
|
|
103
|
+
def orphaned_suggestion(name)
|
|
104
|
+
"create app/javascript/controllers/#{name}_controller.js or remove the data-controller=\"#{name}\" reference"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def dead_suggestion(name)
|
|
108
|
+
"reference it via data-controller=\"#{name}\" in a view, or delete the JS file"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def controller_paths
|
|
112
|
+
CONTROLLER_BASES.flat_map do |base|
|
|
63
113
|
absolute = @root.join(base)
|
|
64
114
|
next [] unless absolute.exist?
|
|
65
115
|
|
|
66
116
|
Dir.glob(absolute.join(CONTROLLER_GLOB))
|
|
67
117
|
end
|
|
68
|
-
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def controller_locations(name)
|
|
121
|
+
controller_paths.select { |path| controller_name_from_path(path) == name }.sort.map do |path|
|
|
122
|
+
Report::Location.new(file: Pathname(path).relative_path_from(@root).to_s)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Line-level lookup, only run for the (usually few) orphaned names
|
|
127
|
+
# — the main pass reads whole files and keeps names only. A
|
|
128
|
+
# reference the patterns only match across lines (a multi-line
|
|
129
|
+
# `data: { controller: ... }` hash) falls back to a file-level
|
|
130
|
+
# location rather than dropping out.
|
|
131
|
+
def reference_locations(name)
|
|
132
|
+
VIEW_PATTERNS.flat_map { |pattern| Dir.glob(@root.join(pattern)) }.sort.flat_map do |path|
|
|
133
|
+
content = File.read(path, encoding: Encoding::UTF_8)
|
|
134
|
+
next [] unless extract_names(content).include?(name)
|
|
135
|
+
|
|
136
|
+
relative = Pathname(path).relative_path_from(@root).to_s
|
|
137
|
+
lines = content.each_line.with_index(1).filter_map do |line, number|
|
|
138
|
+
Report::Location.new(file: relative, line: number) if extract_names(line).include?(name)
|
|
139
|
+
end
|
|
140
|
+
lines.empty? ? [Report::Location.new(file: relative)] : lines
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def collect_defined_controllers
|
|
145
|
+
controller_paths.map { |path| controller_name_from_path(path) }.compact.uniq
|
|
69
146
|
end
|
|
70
147
|
|
|
71
148
|
# Derive a Stimulus controller identifier from a file path. We anchor
|
|
@@ -95,7 +172,10 @@ module Guardrails
|
|
|
95
172
|
end
|
|
96
173
|
|
|
97
174
|
def extract_referenced(file)
|
|
98
|
-
|
|
175
|
+
extract_names(File.read(file, encoding: Encoding::UTF_8))
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def extract_names(content)
|
|
99
179
|
[DATA_CONTROLLER_PATTERN, RUBY_DATA_CONTROLLER_PATTERN].flat_map do |pattern|
|
|
100
180
|
content.scan(pattern).flat_map { |captures| captures[0].strip.split(/\s+/) }
|
|
101
181
|
end
|
|
@@ -111,13 +191,11 @@ module Guardrails
|
|
|
111
191
|
:warning,
|
|
112
192
|
"stimulus orphaned (#{result.orphaned.length} #{noun})"
|
|
113
193
|
)
|
|
114
|
-
@output.puts "
|
|
115
|
-
@output.puts " *_controller.{js,ts} file exists. Either create the controller or"
|
|
116
|
-
@output.puts " remove the reference."
|
|
194
|
+
ORPHANED_FRAMING.each { |line| @output.puts " #{line}" }
|
|
117
195
|
result.orphaned.each do |name|
|
|
118
196
|
@output.puts ""
|
|
119
197
|
@output.puts " #{@style.severity(:warning, "stimulus orphaned: #{name}")}"
|
|
120
|
-
@output.puts " #{@style.suggestion(
|
|
198
|
+
@output.puts " #{@style.suggestion(orphaned_suggestion(name))}"
|
|
121
199
|
end
|
|
122
200
|
end
|
|
123
201
|
|
|
@@ -128,13 +206,11 @@ module Guardrails
|
|
|
128
206
|
:warning,
|
|
129
207
|
"stimulus dead (#{result.dead.length} #{noun})"
|
|
130
208
|
)
|
|
131
|
-
@output.puts "
|
|
132
|
-
@output.puts " data-controller=\"…\". Either wire the controller into a template"
|
|
133
|
-
@output.puts " or delete the file."
|
|
209
|
+
DEAD_FRAMING.each { |line| @output.puts " #{line}" }
|
|
134
210
|
result.dead.each do |name|
|
|
135
211
|
@output.puts ""
|
|
136
212
|
@output.puts " #{@style.severity(:warning, "stimulus dead: #{name}")}"
|
|
137
|
-
@output.puts " #{@style.suggestion(
|
|
213
|
+
@output.puts " #{@style.suggestion(dead_suggestion(name))}"
|
|
138
214
|
end
|
|
139
215
|
end
|
|
140
216
|
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "shellwords"
|
|
4
|
+
require "rbconfig"
|
|
5
|
+
|
|
6
|
+
module Guardrails
|
|
7
|
+
class TUI
|
|
8
|
+
# Builds the command that opens a Report::Location in the user's
|
|
9
|
+
# editor, at the right line when the editor has a way to say so.
|
|
10
|
+
#
|
|
11
|
+
# Editor choice: $GUARDRAILS_EDITOR, then $VISUAL, then $EDITOR;
|
|
12
|
+
# with none set, the OS opener (`open` / `xdg-open`), which loses
|
|
13
|
+
# the line number but still gets you to the file.
|
|
14
|
+
#
|
|
15
|
+
# Always returns an argv array (never a shell string) — paths come
|
|
16
|
+
# from the audited project's filenames, and they go nowhere near a
|
|
17
|
+
# shell.
|
|
18
|
+
module Editor
|
|
19
|
+
# file:line:col as one argument, behind a --goto flag.
|
|
20
|
+
GOTO_FLAG = %w[code code-insiders codium cursor windsurf].freeze
|
|
21
|
+
# file:line:col as one bare argument.
|
|
22
|
+
COLON_SUFFIX = %w[zed subl sublime_text hx].freeze
|
|
23
|
+
# +line before the file.
|
|
24
|
+
PLUS_LINE = %w[vi vim nvim nano emacs emacsclient micro kak].freeze
|
|
25
|
+
# --line N before the file.
|
|
26
|
+
LINE_FLAG = %w[rubymine idea mine].freeze
|
|
27
|
+
|
|
28
|
+
# `code --wait` is the usual $EDITOR value for git's benefit. Here
|
|
29
|
+
# it would freeze the TUI until the editor tab closes.
|
|
30
|
+
WAIT_FLAGS = %w[--wait -w -W].freeze
|
|
31
|
+
TERMINAL_EDITORS = (PLUS_LINE + %w[hx]).freeze
|
|
32
|
+
|
|
33
|
+
module_function
|
|
34
|
+
|
|
35
|
+
def command(location, root:, env: ENV, host_os: RbConfig::CONFIG["host_os"])
|
|
36
|
+
path = File.join(root.to_s, location.file)
|
|
37
|
+
editor = [env["GUARDRAILS_EDITOR"], env["VISUAL"], env["EDITOR"]].find { |v| v && !v.strip.empty? }
|
|
38
|
+
return opener(path, host_os) unless editor
|
|
39
|
+
|
|
40
|
+
parts = Shellwords.split(editor)
|
|
41
|
+
name = File.basename(parts.first)
|
|
42
|
+
parts -= WAIT_FLAGS unless TERMINAL_EDITORS.include?(name)
|
|
43
|
+
parts + target_args(name, path, location)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Terminal editors need the TUI to get out of the way (leave the
|
|
47
|
+
# alternate screen, restore cooked mode) while they run.
|
|
48
|
+
def terminal?(command)
|
|
49
|
+
TERMINAL_EDITORS.include?(File.basename(command.first))
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def opener(path, host_os = RbConfig::CONFIG["host_os"])
|
|
53
|
+
case host_os
|
|
54
|
+
when /darwin/ then ["open", path]
|
|
55
|
+
when /linux|bsd/ then ["xdg-open", path]
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def target_args(name, path, location)
|
|
60
|
+
position = [path, location.line, location.line && location.column].compact.join(":")
|
|
61
|
+
if GOTO_FLAG.include?(name) then ["--goto", position]
|
|
62
|
+
elsif COLON_SUFFIX.include?(name) then [position]
|
|
63
|
+
elsif name == "mate" then location.line ? ["-l", location.line.to_s, path] : [path]
|
|
64
|
+
elsif PLUS_LINE.include?(name) then location.line ? ["+#{location.line}", path] : [path]
|
|
65
|
+
elsif LINE_FLAG.include?(name) then location.line ? ["--line", location.line.to_s, path] : [path]
|
|
66
|
+
else [path]
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Guardrails
|
|
4
|
+
class TUI
|
|
5
|
+
# Turns raw terminal bytes into key events: symbols for named keys
|
|
6
|
+
# (:up, :enter, :escape, …), one-character strings for printable
|
|
7
|
+
# input.
|
|
8
|
+
#
|
|
9
|
+
# Reads with read_nonblock into its own buffer rather than
|
|
10
|
+
# getc/getch. Arrow keys arrive as a multi-byte escape sequence,
|
|
11
|
+
# and telling a lone Esc from the start of one needs a "is more
|
|
12
|
+
# coming?" check — which IO.select can't answer correctly if Ruby's
|
|
13
|
+
# buffered reader has already swallowed the rest of the sequence.
|
|
14
|
+
class Keys
|
|
15
|
+
ESCAPE_SEQUENCES = {
|
|
16
|
+
"[A" => :up, "[B" => :down, "[C" => :right, "[D" => :left,
|
|
17
|
+
"OA" => :up, "OB" => :down, "OC" => :right, "OD" => :left,
|
|
18
|
+
"[H" => :home, "[F" => :end, "OH" => :home, "OF" => :end,
|
|
19
|
+
"[1~" => :home, "[4~" => :end, "[7~" => :home, "[8~" => :end,
|
|
20
|
+
"[5~" => :page_up, "[6~" => :page_down, "[Z" => :tab
|
|
21
|
+
}.freeze
|
|
22
|
+
|
|
23
|
+
CONTROL = {
|
|
24
|
+
"\r" => :enter, "\n" => :enter, "\t" => :tab,
|
|
25
|
+
"\x7F" => :backspace, "\b" => :backspace, "\x03" => :ctrl_c
|
|
26
|
+
}.freeze
|
|
27
|
+
|
|
28
|
+
# How long a lone Esc waits for the rest of a sequence before
|
|
29
|
+
# it's taken at face value. Long enough for a slow SSH hop,
|
|
30
|
+
# short enough that Esc-to-go-back doesn't feel laggy.
|
|
31
|
+
ESCAPE_TIMEOUT = 0.05
|
|
32
|
+
|
|
33
|
+
def initialize(input)
|
|
34
|
+
@input = input
|
|
35
|
+
@buffer = +""
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Next key event; nil if `timeout` seconds pass with no input
|
|
39
|
+
# (the main loop uses the nil to notice terminal resizes); :eof
|
|
40
|
+
# once the input has hung up.
|
|
41
|
+
def next(timeout: nil)
|
|
42
|
+
fill(timeout) if @buffer.empty?
|
|
43
|
+
return :eof if @eof && @buffer.empty?
|
|
44
|
+
return nil if @buffer.empty?
|
|
45
|
+
|
|
46
|
+
fill(ESCAPE_TIMEOUT) if @buffer == "\e"
|
|
47
|
+
parse
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def parse
|
|
53
|
+
return parse_escape if @buffer.start_with?("\e")
|
|
54
|
+
|
|
55
|
+
char = @buffer.slice!(0)
|
|
56
|
+
CONTROL.fetch(char) { char.match?(/[[:print:]]/) ? char : :unknown }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def parse_escape
|
|
60
|
+
rest = @buffer[1..]
|
|
61
|
+
sequence = ESCAPE_SEQUENCES.keys.find { |s| rest.start_with?(s) }
|
|
62
|
+
if sequence
|
|
63
|
+
@buffer.slice!(0, 1 + sequence.length)
|
|
64
|
+
ESCAPE_SEQUENCES[sequence]
|
|
65
|
+
elsif rest.empty?
|
|
66
|
+
@buffer.clear
|
|
67
|
+
:escape
|
|
68
|
+
else
|
|
69
|
+
# Unrecognized sequence (function key, mouse report, paste
|
|
70
|
+
# bracket): drop all of it rather than leaking its tail into
|
|
71
|
+
# the filter box as literal characters.
|
|
72
|
+
@buffer.clear
|
|
73
|
+
:unknown
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def fill(timeout)
|
|
78
|
+
return unless ready?(timeout)
|
|
79
|
+
|
|
80
|
+
chunk = @input.read_nonblock(256, exception: false)
|
|
81
|
+
return if chunk == :wait_readable
|
|
82
|
+
|
|
83
|
+
# A selectable input that reads nil has hung up (terminal
|
|
84
|
+
# closed). Surface it so the main loop exits instead of spinning.
|
|
85
|
+
return @eof = true if chunk.nil?
|
|
86
|
+
|
|
87
|
+
@buffer << chunk.dup.force_encoding(Encoding::UTF_8).scrub("")
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# StringIO (specs) has no file descriptor to select on; it's
|
|
91
|
+
# "ready" whenever it has bytes left.
|
|
92
|
+
def ready?(timeout)
|
|
93
|
+
return !@input.eof? unless @input.respond_to?(:fileno) && @input.fileno
|
|
94
|
+
|
|
95
|
+
!IO.select([@input], nil, nil, timeout).nil?
|
|
96
|
+
rescue NotImplementedError
|
|
97
|
+
!@input.eof?
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../report/style"
|
|
4
|
+
|
|
5
|
+
module Guardrails
|
|
6
|
+
class TUI
|
|
7
|
+
# Renders a State into exactly `height` terminal lines. Pure: takes
|
|
8
|
+
# state and dimensions, returns strings, touches no terminal — the
|
|
9
|
+
# main loop owns writing them out. (It does write `scroll` and
|
|
10
|
+
# `page_size` back onto the state, since both depend on how tall
|
|
11
|
+
# the list region turned out to be.)
|
|
12
|
+
#
|
|
13
|
+
# Layout, top to bottom:
|
|
14
|
+
#
|
|
15
|
+
# title bar totals by severity, grouping / filter
|
|
16
|
+
# breadcrumb where you are in the drill-down
|
|
17
|
+
# ─────
|
|
18
|
+
# [info block] detail screens only: title, suggestion, framing
|
|
19
|
+
# list the rows State built for this frame
|
|
20
|
+
# ─────
|
|
21
|
+
# [preview] findings + detail: source around the location
|
|
22
|
+
# ─────
|
|
23
|
+
# hints keys for this screen, or the filter prompt
|
|
24
|
+
class Screen
|
|
25
|
+
ATTRIBUTES = { bold: 1, dim: 2, reverse: 7 }.freeze
|
|
26
|
+
COLORS = { red: 31, green: 32, yellow: 33, cyan: 36 }.freeze
|
|
27
|
+
SEVERITY = Report::Style::SEVERITY_FORMAT
|
|
28
|
+
|
|
29
|
+
MAX_SOURCE_BYTES = 2 * 1024 * 1024
|
|
30
|
+
|
|
31
|
+
HELP = [
|
|
32
|
+
["↑ ↓ j k", "move"],
|
|
33
|
+
["PgUp PgDn space", "move a page"],
|
|
34
|
+
["g G", "jump to top / bottom"],
|
|
35
|
+
["⏎ → l", "open the highlighted row"],
|
|
36
|
+
["esc ← h", "go back (at the top level: clear the filter)"],
|
|
37
|
+
["tab", "switch grouping: by category ↔ by file"],
|
|
38
|
+
["/", "filter by category, title, suggestion, or file"],
|
|
39
|
+
["o", "open the highlighted location in your editor"],
|
|
40
|
+
["w", "write the HTML report and open it in a browser"],
|
|
41
|
+
["r", "re-run the audit"],
|
|
42
|
+
["q", "quit"]
|
|
43
|
+
].freeze
|
|
44
|
+
|
|
45
|
+
def initialize(state:, root:, width:, height:, color: true, sources: {})
|
|
46
|
+
@state = state
|
|
47
|
+
@root = root.to_s
|
|
48
|
+
@width = [width, 20].max
|
|
49
|
+
@height = [height, 8].max
|
|
50
|
+
@color = color
|
|
51
|
+
@sources = sources
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def lines
|
|
55
|
+
body_height = @height - 5
|
|
56
|
+
body = @state.help? ? help_lines : body_lines(body_height)
|
|
57
|
+
body = body.first(body_height)
|
|
58
|
+
body += [""] * (body_height - body.length)
|
|
59
|
+
|
|
60
|
+
[title_bar, breadcrumb, rule, *body, rule(position_label), footer]
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
# ---- chrome -----------------------------------------------------
|
|
66
|
+
|
|
67
|
+
def title_bar
|
|
68
|
+
totals = @state.totals
|
|
69
|
+
left = [[" Guardrails", %i[bold]], [" — #{pluralize(totals.values.sum, 'finding')} ", []]]
|
|
70
|
+
totals.each do |severity, count|
|
|
71
|
+
next if count.zero?
|
|
72
|
+
|
|
73
|
+
format = SEVERITY.fetch(severity)
|
|
74
|
+
left << ["#{format[:glyph]} #{count} ", [:bold, format[:color]]]
|
|
75
|
+
end
|
|
76
|
+
right = @state.filter.empty? ? "by #{@state.grouping}" : "by #{@state.grouping} · filter: #{@state.filter}"
|
|
77
|
+
justify(left, [[right, %i[dim]]])
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def breadcrumb
|
|
81
|
+
crumbs = @state.breadcrumb
|
|
82
|
+
segments = [[" ", []]]
|
|
83
|
+
crumbs.each_with_index do |crumb, i|
|
|
84
|
+
segments << [" ▸ ", %i[dim]] unless i.zero?
|
|
85
|
+
segments << [crumb, i == crumbs.length - 1 ? %i[bold] : %i[dim]]
|
|
86
|
+
end
|
|
87
|
+
compose(segments)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def rule(label = nil)
|
|
91
|
+
return paint("─" * @width, :dim) if label.nil? || label.empty?
|
|
92
|
+
|
|
93
|
+
tail = " #{label} ──"
|
|
94
|
+
paint("#{'─' * [@width - tail.length, 0].max}#{tail}", :dim)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def position_label
|
|
98
|
+
return nil if @state.help? || @state.frame.cursor.nil?
|
|
99
|
+
|
|
100
|
+
selectable = @state.rows.each_index.select { |i| @state.rows[i].selectable? }
|
|
101
|
+
"#{selectable.index(@state.frame.cursor).to_i + 1}/#{selectable.length}"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def footer
|
|
105
|
+
if @state.filtering?
|
|
106
|
+
compose([[" / ", %i[bold cyan]], [@state.filter, %i[bold]], ["█", %i[dim]],
|
|
107
|
+
[" ⏎ keep esc clear", %i[dim]]])
|
|
108
|
+
elsif @state.notice
|
|
109
|
+
compose([[" #{@state.notice}", %i[yellow]]])
|
|
110
|
+
else
|
|
111
|
+
compose([[" #{hints}", %i[dim]]])
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def hints
|
|
116
|
+
case @state.frame.kind
|
|
117
|
+
when :rollup, :files
|
|
118
|
+
other = @state.grouping == :category ? "by file" : "by category"
|
|
119
|
+
"↑↓ move ⏎ open tab #{other} / filter w web report r re-run ? help q quit"
|
|
120
|
+
when :findings
|
|
121
|
+
"↑↓ move ⏎ details o editor esc back / filter w web report ? help q quit"
|
|
122
|
+
when :detail
|
|
123
|
+
"↑↓ location ⏎ open in editor esc back w web report ? help q quit"
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# ---- body -------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
def body_lines(height)
|
|
130
|
+
return empty_lines if @state.rows.empty? && @state.frame.kind != :detail
|
|
131
|
+
|
|
132
|
+
case @state.frame.kind
|
|
133
|
+
when :findings then list_with_preview(height, [])
|
|
134
|
+
when :detail then list_with_preview(height, info_block(height / 2))
|
|
135
|
+
else list_lines(height)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Splits the body between the list and a source preview. The
|
|
140
|
+
# preview is worth having only when there's room for context
|
|
141
|
+
# around the line, so short terminals get the list alone.
|
|
142
|
+
def list_with_preview(height, info)
|
|
143
|
+
remaining = height - info.length
|
|
144
|
+
preview_height = remaining >= 12 ? [remaining / 2, 14].min : 0
|
|
145
|
+
list_height = remaining - preview_height - (preview_height.zero? ? 0 : 1)
|
|
146
|
+
|
|
147
|
+
out = info + list_lines(list_height)
|
|
148
|
+
out += [rule, *preview_lines(preview_height)] unless preview_height.zero?
|
|
149
|
+
out
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def list_lines(height)
|
|
153
|
+
return [] if height <= 0
|
|
154
|
+
|
|
155
|
+
@state.page_size = [height - 1, 1].max
|
|
156
|
+
rows = @state.rows
|
|
157
|
+
scroll = scroll_for(rows, height)
|
|
158
|
+
visible = rows[scroll, height] || []
|
|
159
|
+
out = visible.each_with_index.map { |row, i| row_line(row, selected: scroll + i == @state.frame.cursor) }
|
|
160
|
+
out + [""] * (height - out.length)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def scroll_for(rows, height)
|
|
164
|
+
frame = @state.frame
|
|
165
|
+
cursor = frame.cursor || 0
|
|
166
|
+
scroll = frame.scroll || 0
|
|
167
|
+
scroll = cursor if cursor < scroll
|
|
168
|
+
scroll = cursor - height + 1 if cursor >= scroll + height
|
|
169
|
+
# Keep a leading header on screen when the cursor sits on the
|
|
170
|
+
# first item beneath it.
|
|
171
|
+
scroll = 0 if cursor < height && rows[0...cursor].none?(&:selectable?)
|
|
172
|
+
frame.scroll = scroll.clamp(0, [rows.length - height, 0].max)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def row_line(row, selected:)
|
|
176
|
+
return header_line(row) unless row.selectable?
|
|
177
|
+
|
|
178
|
+
format = row.severity && SEVERITY.fetch(row.severity)
|
|
179
|
+
marker = selected ? " › " : " "
|
|
180
|
+
glyph = format ? "#{format[:glyph]} " : ""
|
|
181
|
+
right = [row.meta, row.flag && "[#{row.flag}]"].compact.reject(&:empty?).join(" ")
|
|
182
|
+
|
|
183
|
+
if selected
|
|
184
|
+
paint(plain_justified("#{marker}#{glyph}#{sanitize(row.label)}", right), :reverse, :bold)
|
|
185
|
+
else
|
|
186
|
+
justify(
|
|
187
|
+
[[marker, []], [glyph, [:bold, format && format[:color]]], [sanitize(row.label), []]],
|
|
188
|
+
[[right, %i[dim]]]
|
|
189
|
+
)
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def header_line(row)
|
|
194
|
+
format = SEVERITY.fetch(row.severity)
|
|
195
|
+
compose([[" #{format[:glyph]} #{format[:label]}", [:bold, format[:color]]], [" — #{row.label}", %i[dim]]])
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def empty_lines
|
|
199
|
+
message = if @state.filter.empty?
|
|
200
|
+
["✓ No findings — the audit is clean.", %i[green bold]]
|
|
201
|
+
else
|
|
202
|
+
["No findings match “#{@state.filter}”. Esc clears the filter.", %i[dim]]
|
|
203
|
+
end
|
|
204
|
+
["", compose([[" ", []], message])]
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def help_lines
|
|
208
|
+
["", compose([[" Keys", %i[bold]]]), ""] +
|
|
209
|
+
HELP.map { |keys, what| compose([[" #{keys.ljust(20)}", %i[cyan]], [what, []]]) } +
|
|
210
|
+
["", compose([[" Editor: $GUARDRAILS_EDITOR, then $VISUAL, then $EDITOR. Any key closes this.", %i[dim]]])]
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# ---- detail info block --------------------------------------------
|
|
214
|
+
|
|
215
|
+
def info_block(max_height)
|
|
216
|
+
finding = @state.selected_finding
|
|
217
|
+
format = SEVERITY.fetch(finding.severity)
|
|
218
|
+
out = wrap(sanitize(finding.title), @width - 14).first(3).each_with_index.map do |text, i|
|
|
219
|
+
tag = i.zero? ? "[#{format[:label].downcase}]".ljust(10) : " " * 10
|
|
220
|
+
compose([[" ", []], [tag, [:bold, format[:color]]], [" #{text}", %i[bold]]])
|
|
221
|
+
end
|
|
222
|
+
wrap(finding.suggestion.to_s, @width - 6).first(3).each_with_index do |text, i|
|
|
223
|
+
out << compose([[i.zero? ? " → " : " ", %i[cyan]], [sanitize(text), []]])
|
|
224
|
+
end
|
|
225
|
+
finding.details.each do |label, value|
|
|
226
|
+
wrap("#{label}: #{sanitize(value.to_s)}", @width - 4).first(3).each { |text| out << compose([[" #{text}", []]]) }
|
|
227
|
+
end
|
|
228
|
+
framing = @state.category_for(finding)&.framing
|
|
229
|
+
if framing
|
|
230
|
+
out << ""
|
|
231
|
+
wrap(framing, @width - 4).first(4).each { |text| out << compose([[" #{text}", %i[dim]]]) }
|
|
232
|
+
end
|
|
233
|
+
out << ""
|
|
234
|
+
out << compose([[locations_heading(finding), %i[bold]]])
|
|
235
|
+
out.last([max_height, 3].max)
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def locations_heading(finding)
|
|
239
|
+
count = finding.locations.length
|
|
240
|
+
count.zero? ? " No file locations for this finding." : " #{pluralize(count, 'location')}"
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# ---- source preview ---------------------------------------------
|
|
244
|
+
|
|
245
|
+
def preview_lines(height)
|
|
246
|
+
finding = @state.selected_finding
|
|
247
|
+
location = @state.selected_location
|
|
248
|
+
return [""] * height unless finding
|
|
249
|
+
|
|
250
|
+
out = []
|
|
251
|
+
if @state.frame.kind == :findings && finding.suggestion
|
|
252
|
+
wrap(finding.suggestion, @width - 6).first(2).each_with_index do |text, i|
|
|
253
|
+
out << compose([[i.zero? ? " → " : " ", %i[cyan]], [sanitize(text), []]])
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
out << compose([[" #{location || 'no file location'}", %i[dim]]])
|
|
257
|
+
out += source_lines(location, height - out.length) if location
|
|
258
|
+
out.first(height) + [""] * [height - out.length, 0].max
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def source_lines(location, height)
|
|
262
|
+
return [] if height <= 0
|
|
263
|
+
|
|
264
|
+
source = read_source(location.file)
|
|
265
|
+
return [compose([[" (#{source})", %i[dim]]])] if source.is_a?(String)
|
|
266
|
+
|
|
267
|
+
target = location.line
|
|
268
|
+
first = target ? (target - (height / 2)).clamp(1, [source.length - height + 1, 1].max) : 1
|
|
269
|
+
gutter = (first + height).to_s.length
|
|
270
|
+
source[first - 1, height].to_a.each_with_index.map do |text, i|
|
|
271
|
+
number = first + i
|
|
272
|
+
hit = number == target
|
|
273
|
+
compose([
|
|
274
|
+
[hit ? " ▸ " : " ", %i[bold yellow]],
|
|
275
|
+
["#{number.to_s.rjust(gutter)} │ ", hit ? %i[bold] : %i[dim]],
|
|
276
|
+
[sanitize(text), hit ? %i[bold] : []]
|
|
277
|
+
])
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# Array of lines, or a String explaining why there aren't any.
|
|
282
|
+
def read_source(file)
|
|
283
|
+
@sources[file] ||= begin
|
|
284
|
+
path = File.join(@root, file)
|
|
285
|
+
if !File.file?(path) then "file not found"
|
|
286
|
+
elsif File.size(path) > MAX_SOURCE_BYTES then "file too large to preview"
|
|
287
|
+
else
|
|
288
|
+
raw = File.binread(path)
|
|
289
|
+
raw.include?("\0") ? "binary file" : raw.force_encoding(Encoding::UTF_8).scrub("?").lines(chomp: true)
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# ---- text plumbing ------------------------------------------------
|
|
295
|
+
|
|
296
|
+
# File contents and snippets end up on screen verbatim, so strip
|
|
297
|
+
# anything a terminal would act on rather than display — a view
|
|
298
|
+
# containing a literal escape sequence mustn't be able to move
|
|
299
|
+
# the cursor or recolor the UI.
|
|
300
|
+
def sanitize(text)
|
|
301
|
+
text.to_s.gsub("\t", " ").gsub(/[[:cntrl:]]/, "")
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def paint(text, *styles)
|
|
305
|
+
codes = styles.flatten.compact.filter_map { |s| ATTRIBUTES[s] || (@color ? COLORS[s] : nil) }
|
|
306
|
+
codes.empty? ? text : "\e[#{codes.join(';')}m#{text}\e[0m"
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# Segments are [text, styles] pairs. Truncation happens on the
|
|
310
|
+
# plain text *before* painting, so an ANSI sequence is never cut
|
|
311
|
+
# in half and never counts toward the width.
|
|
312
|
+
def compose(segments, width = @width)
|
|
313
|
+
remaining = width
|
|
314
|
+
segments.filter_map do |text, styles|
|
|
315
|
+
next if remaining <= 0 || text.nil? || text.empty?
|
|
316
|
+
|
|
317
|
+
text = "#{text[0, [remaining - 1, 0].max]}…" if text.length > remaining
|
|
318
|
+
remaining -= text.length
|
|
319
|
+
paint(text, *styles)
|
|
320
|
+
end.join
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# Left segments, then right segments flush against the right
|
|
324
|
+
# edge. The right side wins when space is tight (it's the count /
|
|
325
|
+
# location — the part you scan down), capped at 3/5 of the width.
|
|
326
|
+
def justify(left, right)
|
|
327
|
+
right_text = right.sum { |text, _| text.to_s.length }
|
|
328
|
+
right_width = [right_text, @width * 3 / 5].min
|
|
329
|
+
left_width = @width - right_width - 2
|
|
330
|
+
left_text = left.sum { |text, _| text.to_s.length }
|
|
331
|
+
gap = [@width - [left_text, left_width].min - right_width - 1, 1].max
|
|
332
|
+
|
|
333
|
+
"#{compose(left, left_width)}#{' ' * gap}#{compose(right, right_width)}"
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def plain_justified(left, right)
|
|
337
|
+
right = truncate(right, @width * 3 / 5)
|
|
338
|
+
left = truncate(left, @width - right.length - 2)
|
|
339
|
+
"#{left}#{' ' * [@width - left.length - right.length - 1, 1].max}#{right} "
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def truncate(text, limit)
|
|
343
|
+
text.length <= limit ? text : "#{text[0, [limit - 1, 0].max]}…"
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def wrap(text, width)
|
|
347
|
+
width = [width, 10].max
|
|
348
|
+
text.to_s.split(/\s+/).each_with_object([+""]) do |word, lines|
|
|
349
|
+
if lines.last.empty? then lines.last << word
|
|
350
|
+
elsif lines.last.length + 1 + word.length <= width then lines.last << " " << word
|
|
351
|
+
else lines << +word
|
|
352
|
+
end
|
|
353
|
+
end.reject(&:empty?)
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
def pluralize(count, singular)
|
|
357
|
+
"#{count} #{count == 1 ? singular : "#{singular}s"}"
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
end
|
|
361
|
+
end
|