reviewer 0.1.5 → 1.0.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.
Files changed (87) hide show
  1. checksums.yaml +4 -4
  2. data/.github/FUNDING.yml +3 -0
  3. data/.github/workflows/main.yml +79 -11
  4. data/.github/workflows/release.yml +98 -0
  5. data/.gitignore +1 -1
  6. data/.inch.yml +3 -1
  7. data/.reek.yml +175 -0
  8. data/.reviewer.example.yml +7 -2
  9. data/.reviewer.yml +166 -40
  10. data/.rubocop.yml +34 -2
  11. data/CHANGELOG.md +42 -2
  12. data/Gemfile +39 -1
  13. data/Gemfile.lock +291 -70
  14. data/LICENSE.txt +20 -4
  15. data/README.md +310 -21
  16. data/RELEASING.md +190 -0
  17. data/Rakefile +117 -0
  18. data/dependency_decisions.yml +61 -0
  19. data/exe/fmt +1 -1
  20. data/exe/rvw +1 -1
  21. data/lib/reviewer/arguments/files.rb +47 -20
  22. data/lib/reviewer/arguments/keywords.rb +34 -41
  23. data/lib/reviewer/arguments/tags.rb +11 -11
  24. data/lib/reviewer/arguments.rb +100 -29
  25. data/lib/reviewer/batch/formatter.rb +87 -0
  26. data/lib/reviewer/batch.rb +32 -48
  27. data/lib/reviewer/capabilities.rb +81 -0
  28. data/lib/reviewer/command/string/env.rb +12 -6
  29. data/lib/reviewer/command/string/flags.rb +2 -4
  30. data/lib/reviewer/command/string.rb +47 -12
  31. data/lib/reviewer/command.rb +65 -10
  32. data/lib/reviewer/configuration/loader.rb +70 -0
  33. data/lib/reviewer/configuration.rb +6 -3
  34. data/lib/reviewer/context.rb +15 -0
  35. data/lib/reviewer/doctor/config_check.rb +46 -0
  36. data/lib/reviewer/doctor/environment_check.rb +58 -0
  37. data/lib/reviewer/doctor/formatter.rb +75 -0
  38. data/lib/reviewer/doctor/keyword_check.rb +85 -0
  39. data/lib/reviewer/doctor/opportunity_check.rb +88 -0
  40. data/lib/reviewer/doctor/report.rb +63 -0
  41. data/lib/reviewer/doctor/tool_inventory.rb +41 -0
  42. data/lib/reviewer/doctor.rb +28 -0
  43. data/lib/reviewer/history.rb +10 -17
  44. data/lib/reviewer/output/formatting.rb +40 -0
  45. data/lib/reviewer/output/printer.rb +70 -9
  46. data/lib/reviewer/output.rb +37 -78
  47. data/lib/reviewer/prompt.rb +38 -0
  48. data/lib/reviewer/report/formatter.rb +124 -0
  49. data/lib/reviewer/report.rb +100 -0
  50. data/lib/reviewer/runner/failed_files.rb +66 -0
  51. data/lib/reviewer/runner/formatter.rb +103 -0
  52. data/lib/reviewer/runner/guidance.rb +79 -0
  53. data/lib/reviewer/runner/result.rb +150 -0
  54. data/lib/reviewer/runner/strategies/captured.rb +98 -23
  55. data/lib/reviewer/runner/strategies/passthrough.rb +2 -11
  56. data/lib/reviewer/runner.rb +126 -40
  57. data/lib/reviewer/session/formatter.rb +87 -0
  58. data/lib/reviewer/session.rb +208 -0
  59. data/lib/reviewer/setup/catalog.rb +233 -0
  60. data/lib/reviewer/setup/detector.rb +61 -0
  61. data/lib/reviewer/setup/formatter.rb +94 -0
  62. data/lib/reviewer/setup/gemfile_lock.rb +55 -0
  63. data/lib/reviewer/setup/generator.rb +54 -0
  64. data/lib/reviewer/setup/tool_block.rb +112 -0
  65. data/lib/reviewer/setup.rb +41 -0
  66. data/lib/reviewer/shell/result.rb +14 -15
  67. data/lib/reviewer/shell/timer.rb +40 -35
  68. data/lib/reviewer/shell.rb +41 -12
  69. data/lib/reviewer/tool/conversions.rb +20 -0
  70. data/lib/reviewer/tool/file_resolver.rb +54 -0
  71. data/lib/reviewer/tool/settings.rb +88 -44
  72. data/lib/reviewer/tool/test_file_mapper.rb +73 -0
  73. data/lib/reviewer/tool/timing.rb +78 -0
  74. data/lib/reviewer/tool.rb +88 -69
  75. data/lib/reviewer/tools.rb +47 -33
  76. data/lib/reviewer/version.rb +1 -1
  77. data/lib/reviewer.rb +109 -50
  78. data/reviewer.gemspec +16 -19
  79. metadata +101 -142
  80. data/lib/reviewer/conversions.rb +0 -16
  81. data/lib/reviewer/guidance.rb +0 -77
  82. data/lib/reviewer/keywords/git/staged.rb +0 -64
  83. data/lib/reviewer/keywords/git.rb +0 -14
  84. data/lib/reviewer/keywords.rb +0 -9
  85. data/lib/reviewer/loader.rb +0 -59
  86. data/lib/reviewer/output/scrubber.rb +0 -48
  87. data/lib/reviewer/output/token.rb +0 -85
@@ -2,121 +2,80 @@
2
2
 
3
3
  require 'io/console/size' # For determining console width/height
4
4
 
5
+ require_relative 'output/formatting'
5
6
  require_relative 'output/printer'
6
- require_relative 'output/scrubber'
7
- require_relative 'output/token'
8
7
 
9
8
  module Reviewer
10
- # Friendly API for printing nicely-formatted output to the console
9
+ # Console display infrastructure primitives for styled terminal output.
10
+ # Domain-specific display logic lives in each concept's Formatter class.
11
11
  class Output
12
12
  DEFAULT_CONSOLE_WIDTH = 120
13
- DIVIDER = '·'
13
+ DIVIDER = ''
14
+ RAKE_ABORTED_TEXT = "rake aborted!\n"
15
+
16
+ # Removes unhelpful rake exit status noise from stderr
17
+ # @param text [String, nil] the stderr output to clean up
18
+ # @return [String] the cleaned text with rake noise removed
19
+ def self.scrub(text)
20
+ text = text.to_s
21
+ return '' if text.empty?
22
+
23
+ text.include?(RAKE_ABORTED_TEXT) ? text.split(RAKE_ABORTED_TEXT).first : text
24
+ end
14
25
 
15
26
  attr_reader :printer
16
27
 
17
28
  # Creates an instance of Output to print Reviewer activity and results to the console
29
+ # @param printer [Printer] the low-level printer for styled terminal output
30
+ #
31
+ # @return [Output]
18
32
  def initialize(printer = Printer.new)
19
33
  @printer = printer
20
34
  end
21
35
 
36
+ # === Primitives ===
37
+
38
+ # Clears the terminal screen (no-op when output is not a TTY)
39
+ # @return [void]
22
40
  def clear
23
- system('clear')
41
+ system('clear') if printer.tty?
24
42
  end
25
43
 
26
- def newline
27
- printer.puts(:default, '')
28
- end
44
+ # Prints a blank line
45
+ # @return [void]
46
+ def newline = printer.puts(:default, '')
29
47
 
48
+ # Prints a horizontal rule spanning the console width
49
+ # @return [void]
30
50
  def divider
31
51
  newline
32
52
  printer.print(:muted, DIVIDER * console_width)
33
53
  end
34
54
 
35
- # Prints plain text to the console
36
- # @param message [String] the text to write to the console
37
- #
55
+ # Prints an unformatted help message
56
+ # @param message [String] the help text to display
38
57
  # @return [void]
39
58
  def help(message)
40
59
  printer.puts(:default, message)
41
60
  end
42
61
 
43
- # Prints a summary of the total time and results for a batch run. If multiple tools, it will
44
- # show the total tool count
45
- # @param tool_count [Integer] the number of commands run in the batch
46
- # @param seconds [Float] the total number of seconds the batch ran in realtime
47
- #
62
+ # Writes raw output directly without formatting
63
+ # @param value [String] the raw output to write
48
64
  # @return [void]
49
- def batch_summary(tool_count, seconds)
50
- printer.print(:bold, "~#{seconds.round(1)} seconds")
51
- printer.puts(:muted, " for #{tool_count} tools") if tool_count > 1
52
- end
53
-
54
- # Print a tool summary using the name and description. Used before running a command to help
55
- # identify which tool is running at any given moment.
56
- # @param tool [Tool] the tool to identify and describe
57
- #
58
- # @return [void]
59
- def tool_summary(tool)
60
- printer.print(:bold, tool.name)
61
- printer.puts(:muted, " #{tool.description}")
62
- end
63
-
64
- # Prints the text of a command to the console to help proactively expose potentials issues with
65
- # syntax if Reviewer translated thte provided options in an unexpected way
66
- # @param command [String, Command] the command to identify on the console
67
- #
68
- # @return [void] [description]
69
- def current_command(command)
70
- printer.puts(:bold, 'Now Running:')
71
- printer.puts(:default, String(command))
72
- end
73
-
74
- def success(timer)
75
- printer.print(:success, 'Success')
76
- printer.print(:success_light, " #{timer.total_seconds}s")
77
- printer.print(:warning_light, " (#{timer.prep_percent}% prep ~#{timer.prep_seconds}s)") if timer.prepped?
78
- newline
79
- newline
80
- end
81
-
82
- def failure(details, command: nil)
83
- printer.print(:failure, 'Failure')
84
- printer.puts(:muted, " #{details}")
85
-
86
- return if command.nil?
87
-
88
- newline
89
- printer.puts(:bold, 'Failed Command:')
90
- printer.puts(:muted, String(command))
91
- end
92
-
93
- def unrecoverable(details)
94
- printer.puts(:error, 'Unrecoverable Error:')
95
- printer.puts(:muted, details)
96
- end
97
-
98
- def guidance(summary, details)
99
- return if details.nil?
100
-
101
- newline
102
- printer.puts(:bold, summary)
103
- printer.puts(:muted, details)
104
- end
105
-
106
65
  def unfiltered(value)
107
- return if value.nil? || value.strip.empty?
108
-
109
- printer.stream << value
66
+ printer.write_raw(value)
110
67
  end
111
68
 
112
- protected
69
+ private
113
70
 
71
+ # Returns the current console width, falling back to a default
72
+ # @return [Integer] the console width in columns
114
73
  def console_width
115
74
  return DEFAULT_CONSOLE_WIDTH if IO.console.nil?
116
75
 
117
76
  _height, width = IO.console.winsize
118
77
 
119
- width
78
+ width.positive? ? width : DEFAULT_CONSOLE_WIDTH
120
79
  end
121
80
  end
122
81
  end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reviewer
4
+ # Simple interactive prompt for yes/no questions.
5
+ # Wraps input/output streams for testability.
6
+ class Prompt
7
+ attr_reader :input, :output
8
+
9
+ # Creates an interactive prompt for yes/no questions
10
+ # @param input [IO] the input stream (defaults to $stdin)
11
+ # @param output [IO] the output stream (defaults to $stdout)
12
+ #
13
+ # @return [Prompt]
14
+ def initialize(input: $stdin, output: $stdout)
15
+ @input = input
16
+ @output = output
17
+ end
18
+
19
+ # Asks a yes/no question and returns the boolean result.
20
+ # Returns false in non-interactive contexts (CI, pipes).
21
+ #
22
+ # @param message [String] the question to display
23
+ # @return [Boolean] true if the user answered yes
24
+ def yes?(message)
25
+ return false unless interactive?
26
+
27
+ @output.print "#{message} (y/n) "
28
+ response = @input.gets&.strip&.downcase
29
+ response&.start_with?('y') || false
30
+ end
31
+
32
+ private
33
+
34
+ def interactive?
35
+ @input.respond_to?(:tty?) && @input.tty?
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reviewer
4
+ class Report
5
+ # Formats a Report for summary output to the console
6
+ class Formatter
7
+ include Output::Formatting
8
+
9
+ attr_reader :report, :output
10
+
11
+ # Creates a formatter for displaying a report
12
+ # @param report [Report] the report to format
13
+ # @param output [Output] the output handler for console display
14
+ #
15
+ # @return [Formatter] a formatter instance
16
+ def initialize(report, output: Output.new)
17
+ @report = report
18
+ @output = output
19
+ @name_width = 0
20
+ end
21
+
22
+ # Prints the formatted report to the console
23
+ #
24
+ # @return [void]
25
+ def print
26
+ if report.results.empty?
27
+ output.printer.puts(:muted, 'No tools to run')
28
+ return
29
+ end
30
+
31
+ print_tool_lines
32
+ output.newline
33
+ print_summary
34
+ end
35
+
36
+ private
37
+
38
+ def print_tool_lines
39
+ @name_width = max_name_width
40
+ report.results.each { |result| print_tool_line(result) }
41
+ end
42
+
43
+ def print_tool_line(result)
44
+ if result.missing?
45
+ print_missing_tool(result)
46
+ else
47
+ print_executed_tool(result)
48
+ end
49
+
50
+ output.newline
51
+ end
52
+
53
+ def print_missing_tool(result)
54
+ output.printer.print(:warning, "- #{result.tool_name.ljust(@name_width)}")
55
+ output.printer.print(:muted, ' not installed')
56
+ end
57
+
58
+ def print_executed_tool(result)
59
+ style = status_style(result.success?)
60
+ mark = status_mark(result.success?)
61
+ output.printer.print(style, "#{mark} #{result.tool_name.ljust(@name_width)}")
62
+ print_timing(result)
63
+ print_details(result)
64
+ end
65
+
66
+ def print_timing(result)
67
+ output.printer.print(:muted, " #{format_duration(result.duration).rjust(6)}")
68
+ end
69
+
70
+ def max_name_width
71
+ report.results.map { |result| result.tool_name.length }.max || 0
72
+ end
73
+
74
+ def print_details(result)
75
+ detail = result.detail_summary
76
+ return unless detail
77
+
78
+ output.printer.print(:muted, " #{detail}")
79
+ end
80
+
81
+ def print_summary
82
+ if report.success?
83
+ print_success_summary
84
+ else
85
+ print_failure_summary
86
+ end
87
+ end
88
+
89
+ def print_success_summary
90
+ output.printer.print(:success, 'All passed')
91
+ output.printer.puts(:muted, " (#{format_duration(report.duration)})")
92
+ end
93
+
94
+ def print_failure_summary
95
+ failed_results = report.results.reject(&:success?).reject(&:missing?)
96
+
97
+ failed_results.each do |result|
98
+ output.newline
99
+ output.printer.puts(:failure, "#{result.tool_name}:")
100
+ print_truncated_output(result.stdout)
101
+ end
102
+ end
103
+
104
+ def print_truncated_output(text)
105
+ content = text.to_s.strip
106
+ return if content.empty?
107
+
108
+ lines = content.lines
109
+ print_lines(lines.first(10))
110
+ print_truncation_notice(lines.size - 10)
111
+ end
112
+
113
+ def print_lines(lines)
114
+ lines.each { |line| output.printer.puts(:default, line.chomp) }
115
+ end
116
+
117
+ def print_truncation_notice(remaining)
118
+ return unless remaining.positive?
119
+
120
+ output.printer.puts(:muted, "[#{remaining} more lines]")
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require_relative 'report/formatter'
5
+
6
+ module Reviewer
7
+ # Collects results from multiple tool runs and provides serialization
8
+ class Report
9
+ attr_reader :results, :duration
10
+
11
+ def initialize
12
+ @results = []
13
+ @duration = nil
14
+ end
15
+
16
+ # Adds a Runner::Result to the collection
17
+ #
18
+ # @param result [Runner::Result] the result to add
19
+ # @return [Array<Runner::Result>] the updated results array
20
+ def add(result)
21
+ @results << result
22
+ end
23
+
24
+ # Records the total duration for all tool runs
25
+ #
26
+ # @param seconds [Float] the total elapsed time in seconds
27
+ # @return [Float] the recorded duration
28
+ def record_duration(seconds)
29
+ @duration = seconds
30
+ end
31
+
32
+ # Whether all executed tools in the report succeeded (excludes missing and skipped)
33
+ #
34
+ # @return [Boolean] true if all executed results are successful
35
+ def success?
36
+ executed_results.all?(&:success?)
37
+ end
38
+
39
+ # Returns the highest exit status from executed results (excludes missing and skipped)
40
+ #
41
+ # @return [Integer] the maximum exit status, or 0 if empty
42
+ def max_exit_status
43
+ executed_results.map(&:exit_status).max || 0
44
+ end
45
+
46
+ # Returns results for tools whose executables were not found
47
+ #
48
+ # @return [Array<Runner::Result>] missing tool results
49
+ def missing_results
50
+ results.select(&:missing?)
51
+ end
52
+
53
+ # Whether any tools were missing
54
+ #
55
+ # @return [Boolean] true if any results are missing
56
+ def missing?
57
+ missing_results.any?
58
+ end
59
+
60
+ # Returns data for missing tools (name and key)
61
+ #
62
+ # @return [Array<Runner::Result>] the missing results with tool info
63
+ def missing_tools
64
+ missing_results
65
+ end
66
+
67
+ # Converts the report to a hash suitable for serialization
68
+ #
69
+ # @return [Hash] structured hash with summary and tool results
70
+ def to_h
71
+ {
72
+ success: success?,
73
+ summary: {
74
+ total: results.size,
75
+ passed: results.count(&:success?),
76
+ failed: results.count { |result| !result.success? && !result.missing? },
77
+ missing: missing_results.size,
78
+ duration: duration
79
+ },
80
+ tools: results.map(&:to_h)
81
+ }
82
+ end
83
+
84
+ # Converts the report to formatted JSON
85
+ #
86
+ # @return [String] JSON representation of the report
87
+ def to_json(*_args)
88
+ JSON.pretty_generate(to_h)
89
+ end
90
+
91
+ private
92
+
93
+ # Results for tools that actually executed (excludes skipped and missing)
94
+ #
95
+ # @return [Array<Runner::Result>] executed results only
96
+ def executed_results
97
+ results.select(&:executed?)
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reviewer
4
+ class Runner
5
+ # Extracts file paths from tool output so that subsequent re-runs can be scoped
6
+ # to only the files that had issues.
7
+ #
8
+ # Merges stdout and stderr before scanning because linters (rubocop, reek, etc.)
9
+ # write findings to stdout, not stderr. No common tool splits output with
10
+ # "passing files" on stdout and "failing files" on stderr, so merging is safe.
11
+ # The regex pattern and File.exist? guard filter out any incidental matches.
12
+ class FailedFiles
13
+ # Matches relative path-like tokens at or near the start of a line, allowing
14
+ # leading whitespace for tools that indent output (reek groupings, rspec nesting).
15
+ # Rejects absolute paths (starting with /) to exclude Ruby runtime warnings and
16
+ # gem internals that would otherwise match. Tool findings always use relative
17
+ # paths from the project root. Supports any file extension so non-Ruby tools
18
+ # (eslint, stylelint) also work. File.exist? in to_a provides a final guard.
19
+ #
20
+ # lib/foo.rb:45:3: C: Style/... (rubocop)
21
+ # test/foo_test.rb:45 (minitest)
22
+ # lib/foo.rb -- message (reek)
23
+ # src/app.js:10:5: error ... (eslint)
24
+ FILE_PATH_PATTERN = %r{^\s*([^/\s]\S*\.\w+)(?::\d| -- )}
25
+
26
+ attr_reader :stdout, :stderr
27
+
28
+ # Creates a failed-file extractor from captured tool output
29
+ # @param stdout [String, nil] captured standard output from the tool
30
+ # @param stderr [String, nil] captured standard error from the tool
31
+ #
32
+ # @return [FailedFiles]
33
+ def initialize(stdout, stderr)
34
+ @stdout = stdout
35
+ @stderr = stderr
36
+ end
37
+
38
+ # Regex-matched paths filtered to only those that exist on disk, deduplicated.
39
+ # @return [Array<String>] unique file paths that exist in the working directory
40
+ def to_a
41
+ matched_paths.select { |path| File.exist?(path) }.uniq
42
+ end
43
+
44
+ # All regex-matched paths before filesystem filtering. Useful for testing
45
+ # pattern matching without requiring real files on disk.
46
+ # @return [Array<String>] raw paths extracted from combined output
47
+ def matched_paths
48
+ combined_output.scan(FILE_PATH_PATTERN).flatten
49
+ end
50
+
51
+ private
52
+
53
+ # Merges both streams and strips ANSI escape codes before scanning. Linters
54
+ # write diagnostic output (file paths with line numbers) to stdout, while
55
+ # stderr typically only contains crash or startup errors. Scanning both catches
56
+ # paths regardless of which stream the tool uses. ANSI codes are stripped via
57
+ # Rainbow::StringUtils.uncolor because tools run with --color embed escape
58
+ # sequences around file paths, which would otherwise become part of the
59
+ # captured path string.
60
+ # @return [String] merged stdout and stderr, stripped of ANSI codes
61
+ def combined_output
62
+ Rainbow::StringUtils.uncolor([stdout, stderr].compact.join("\n"))
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../output/formatting'
4
+
5
+ module Reviewer
6
+ class Runner
7
+ # Display logic for tool execution: tool identity, success, failure, skipped, guidance
8
+ class Formatter
9
+ include Output::Formatting
10
+
11
+ attr_reader :output, :printer
12
+ private :output, :printer
13
+
14
+ # Creates a formatter for runner-specific display
15
+ # @param output [Output] the console output handler
16
+ #
17
+ # @return [Formatter]
18
+ def initialize(output)
19
+ @output = output
20
+ @printer = output.printer
21
+ end
22
+
23
+ # Prints the tool name and description as a header before execution
24
+ # @param tool [Tool] the tool being run
25
+ #
26
+ # @return [void]
27
+ def tool_summary(tool)
28
+ printer.print(:bold, tool.name)
29
+ printer.puts(:muted, " #{tool.description}")
30
+ end
31
+
32
+ # Displays the exact command string being executed for debugging and copy/paste
33
+ # @param command [Command, String] the command to display
34
+ #
35
+ # @return [void]
36
+ def current_command(command)
37
+ printer.print(:default, ' ↳ ')
38
+ printer.puts(:muted, String(command))
39
+ output.newline
40
+ end
41
+
42
+ # Displays a success message with timing breakdown
43
+ # @param timer [Shell::Timer] the timer with prep and main execution times
44
+ #
45
+ # @return [void]
46
+ def success(timer)
47
+ printer.print(:success, 'Success')
48
+ printer.print(:success_light, " #{timer.total_seconds}s")
49
+ printer.print(:warning_light, " (#{timer.prep_percent}% prep ~#{timer.prep_seconds}s)") if timer.prepped?
50
+ output.newline
51
+ output.newline
52
+ end
53
+
54
+ # Displays a skip notice with the reason
55
+ # @param reason [String] why the tool was skipped
56
+ #
57
+ # @return [void]
58
+ def skipped(reason = 'no matching files')
59
+ printer.print(:muted, 'Skipped')
60
+ printer.puts(:muted, " (#{reason})")
61
+ output.newline
62
+ end
63
+
64
+ # Displays a failure message with details and optionally the failed command
65
+ # @param details [String] the failure summary (e.g. exit status)
66
+ # @param command [Command, String, nil] the command that failed, if applicable
67
+ #
68
+ # @return [void]
69
+ def failure(details, command: nil)
70
+ printer.print(:failure, 'Failure')
71
+ printer.puts(:muted, " #{details}")
72
+
73
+ return unless command
74
+
75
+ output.newline
76
+ printer.puts(:bold, 'Failed Command:')
77
+ printer.puts(:muted, String(command))
78
+ end
79
+
80
+ # Displays an unrecoverable error that prevents further execution
81
+ # @param details [String] the error description
82
+ #
83
+ # @return [void]
84
+ def unrecoverable(details)
85
+ printer.puts(:error, 'Unrecoverable Error:')
86
+ printer.puts(:muted, details)
87
+ end
88
+
89
+ # Displays contextual guidance after a failure to help the user recover
90
+ # @param summary [String] the guidance heading
91
+ # @param details [String, nil] the guidance body (skipped if nil)
92
+ #
93
+ # @return [void]
94
+ def guidance(summary, details)
95
+ return unless details
96
+
97
+ output.newline
98
+ printer.puts(:bold, summary)
99
+ printer.puts(:muted, details)
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reviewer
4
+ class Runner
5
+ # Handles the logic around what to display after a command has been run
6
+ class Guidance
7
+ attr_reader :command, :result, :formatter, :context
8
+ private :context
9
+
10
+ # Create an instance of guidance for suggesting recovery steps after errors
11
+ # @param command [Command] the command that was run and needs recovery guidance
12
+ # @param result [Result] the result of the command
13
+ # @param context [Context] the shared runtime dependencies
14
+ #
15
+ # @return [Guidance] the guidance class to suggest relevant recovery steps
16
+ def initialize(command:, result:, context:)
17
+ @command = command
18
+ @result = result
19
+ @context = context
20
+ @formatter = Runner::Formatter.new(context.output)
21
+ end
22
+
23
+ # Prints the relevant guidance based on the command and result context
24
+ #
25
+ # @return [void] prints the relevant guidance to the stream
26
+ def show
27
+ case result
28
+ when executable_not_found? then show_missing_executable_guidance
29
+ when cannot_execute? then show_unrecoverable_guidance
30
+ else show_syntax_guidance
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ # Conditional check for when the command result was that the executable couldn't be found
37
+ #
38
+ # @return [Boolean] true if the result indicates the command couldn't be found
39
+ def executable_not_found?
40
+ lambda(&:executable_not_found?)
41
+ end
42
+
43
+ # Conditional check for when the command result was that it was unable to be executed
44
+ #
45
+ # @return [Boolean] true if the result indicates the command couldn't be executed
46
+ def cannot_execute?
47
+ lambda(&:cannot_execute?)
48
+ end
49
+
50
+ # Shows the recovery guidance for when a command is missing
51
+ #
52
+ # @return [void] prints missing executable guidance
53
+ def show_missing_executable_guidance
54
+ tool = command.tool
55
+ installation_command = Command.new(tool, :install, context: context).string if tool.installable?
56
+ install_link = tool.install_link
57
+
58
+ formatter.failure("Missing executable for '#{tool}'", command: command)
59
+ formatter.guidance('Try installing the tool:', installation_command)
60
+ formatter.guidance('Read the installation guidance:', install_link)
61
+ end
62
+
63
+ # Shows the recovery guidance for when a command generates an unrecoverable error
64
+ #
65
+ # @return [void] prints unrecoverable error guidance
66
+ def show_unrecoverable_guidance
67
+ formatter.unrecoverable(result.stderr)
68
+ end
69
+
70
+ # Shows suggestions for ignoring or disable rules when a command fails after reviewing code
71
+ #
72
+ # @return [void] prints syntax guidance
73
+ def show_syntax_guidance
74
+ formatter.guidance('Selectively Ignore a Rule:', command.tool.links[:ignore_syntax])
75
+ formatter.guidance('Fully Disable a Rule:', command.tool.links[:disable_syntax])
76
+ end
77
+ end
78
+ end
79
+ end