reviewer 0.1.4 → 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 (93) hide show
  1. checksums.yaml +4 -4
  2. data/.alexignore +1 -0
  3. data/.github/FUNDING.yml +3 -0
  4. data/.github/workflows/main.yml +81 -11
  5. data/.github/workflows/release.yml +98 -0
  6. data/.gitignore +1 -1
  7. data/.inch.yml +3 -1
  8. data/.reek.yml +175 -0
  9. data/.reviewer.example.yml +27 -12
  10. data/.reviewer.future.yml +221 -0
  11. data/.reviewer.yml +191 -28
  12. data/.reviewer_stdout +0 -0
  13. data/.rubocop.yml +34 -1
  14. data/CHANGELOG.md +42 -2
  15. data/Gemfile +39 -1
  16. data/Gemfile.lock +294 -72
  17. data/README.md +315 -7
  18. data/RELEASING.md +190 -0
  19. data/Rakefile +117 -0
  20. data/dependency_decisions.yml +61 -0
  21. data/exe/fmt +1 -1
  22. data/exe/rvw +1 -1
  23. data/lib/reviewer/arguments/files.rb +60 -27
  24. data/lib/reviewer/arguments/keywords.rb +39 -43
  25. data/lib/reviewer/arguments/tags.rb +21 -14
  26. data/lib/reviewer/arguments.rb +107 -29
  27. data/lib/reviewer/batch/formatter.rb +87 -0
  28. data/lib/reviewer/batch.rb +46 -35
  29. data/lib/reviewer/capabilities.rb +81 -0
  30. data/lib/reviewer/command/string/env.rb +16 -6
  31. data/lib/reviewer/command/string/flags.rb +14 -5
  32. data/lib/reviewer/command/string.rb +53 -24
  33. data/lib/reviewer/command.rb +69 -39
  34. data/lib/reviewer/configuration/loader.rb +70 -0
  35. data/lib/reviewer/configuration.rb +14 -4
  36. data/lib/reviewer/context.rb +15 -0
  37. data/lib/reviewer/doctor/config_check.rb +46 -0
  38. data/lib/reviewer/doctor/environment_check.rb +58 -0
  39. data/lib/reviewer/doctor/formatter.rb +75 -0
  40. data/lib/reviewer/doctor/keyword_check.rb +85 -0
  41. data/lib/reviewer/doctor/opportunity_check.rb +88 -0
  42. data/lib/reviewer/doctor/report.rb +63 -0
  43. data/lib/reviewer/doctor/tool_inventory.rb +41 -0
  44. data/lib/reviewer/doctor.rb +28 -0
  45. data/lib/reviewer/history.rb +36 -12
  46. data/lib/reviewer/output/formatting.rb +40 -0
  47. data/lib/reviewer/output/printer.rb +105 -0
  48. data/lib/reviewer/output.rb +54 -65
  49. data/lib/reviewer/prompt.rb +38 -0
  50. data/lib/reviewer/report/formatter.rb +124 -0
  51. data/lib/reviewer/report.rb +100 -0
  52. data/lib/reviewer/runner/failed_files.rb +66 -0
  53. data/lib/reviewer/runner/formatter.rb +103 -0
  54. data/lib/reviewer/runner/guidance.rb +79 -0
  55. data/lib/reviewer/runner/result.rb +150 -0
  56. data/lib/reviewer/runner/strategies/captured.rb +232 -0
  57. data/lib/reviewer/runner/strategies/{verbose.rb → passthrough.rb} +15 -24
  58. data/lib/reviewer/runner.rb +179 -35
  59. data/lib/reviewer/session/formatter.rb +87 -0
  60. data/lib/reviewer/session.rb +208 -0
  61. data/lib/reviewer/setup/catalog.rb +233 -0
  62. data/lib/reviewer/setup/detector.rb +61 -0
  63. data/lib/reviewer/setup/formatter.rb +94 -0
  64. data/lib/reviewer/setup/gemfile_lock.rb +55 -0
  65. data/lib/reviewer/setup/generator.rb +54 -0
  66. data/lib/reviewer/setup/tool_block.rb +112 -0
  67. data/lib/reviewer/setup.rb +41 -0
  68. data/lib/reviewer/shell/result.rb +25 -11
  69. data/lib/reviewer/shell/timer.rb +47 -27
  70. data/lib/reviewer/shell.rb +46 -21
  71. data/lib/reviewer/tool/conversions.rb +20 -0
  72. data/lib/reviewer/tool/file_resolver.rb +54 -0
  73. data/lib/reviewer/tool/settings.rb +107 -56
  74. data/lib/reviewer/tool/test_file_mapper.rb +73 -0
  75. data/lib/reviewer/tool/timing.rb +78 -0
  76. data/lib/reviewer/tool.rb +88 -47
  77. data/lib/reviewer/tools.rb +47 -33
  78. data/lib/reviewer/version.rb +1 -1
  79. data/lib/reviewer.rb +114 -54
  80. data/reviewer.gemspec +21 -20
  81. data/structure.svg +1 -0
  82. metadata +113 -148
  83. data/.ruby-version +0 -1
  84. data/lib/reviewer/command/string/verbosity.rb +0 -51
  85. data/lib/reviewer/command/verbosity.rb +0 -65
  86. data/lib/reviewer/conversions.rb +0 -27
  87. data/lib/reviewer/guidance.rb +0 -73
  88. data/lib/reviewer/keywords/git/staged.rb +0 -48
  89. data/lib/reviewer/keywords/git.rb +0 -14
  90. data/lib/reviewer/keywords.rb +0 -9
  91. data/lib/reviewer/loader.rb +0 -59
  92. data/lib/reviewer/printer.rb +0 -25
  93. data/lib/reviewer/runner/strategies/quiet.rb +0 -90
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'io/console' # For determining console width/height
4
+
5
+ module Reviewer
6
+ class Output
7
+ # ANSI terminal escape sequences for styled console output.
8
+ # Extracted from Printer so style definitions are separated from printing mechanics.
9
+ module AnsiStyles
10
+ ESC = "\e["
11
+ RESET = "#{ESC}0m".freeze
12
+
13
+ # Weight codes
14
+ WEIGHTS = { default: 0, bold: 1, light: 2, italic: 3 }.freeze
15
+
16
+ # Color codes
17
+ COLORS = {
18
+ black: 30, red: 31, green: 32, yellow: 33,
19
+ blue: 34, magenta: 35, cyan: 36, gray: 37, default: 39
20
+ }.freeze
21
+
22
+ # Style definitions: [weight, color]
23
+ STYLE_DEFS = {
24
+ success_bold: %i[bold green],
25
+ success: %i[default green],
26
+ success_light: %i[light green],
27
+ error: %i[bold red],
28
+ failure: %i[default red],
29
+ warning: %i[bold yellow],
30
+ warning_light: %i[light yellow],
31
+ source: %i[italic default],
32
+ bold: %i[default default],
33
+ default: %i[default default],
34
+ muted: %i[light gray]
35
+ }.freeze
36
+
37
+ # Pre-computed ANSI escape strings for each style
38
+ STYLES = STYLE_DEFS.transform_values do |weight_key, color_key|
39
+ "#{ESC}#{WEIGHTS.fetch(weight_key)};#{COLORS.fetch(color_key)}m"
40
+ end.freeze
41
+ end
42
+
43
+ # Wrapper to encapsulate some lower-level details of printing to $stdout.
44
+ # Handles ANSI styling via the pre-computed AnsiStyles::STYLES constant.
45
+ class Printer
46
+ include AnsiStyles
47
+
48
+ attr_reader :stream
49
+
50
+ # Creates a printer for styled console output
51
+ # @param stream [IO] the output stream to write to
52
+ #
53
+ # @return [Printer]
54
+ def initialize(stream = $stdout)
55
+ @stream = stream
56
+ @stream.sync = true if @stream.respond_to?(:sync=)
57
+ end
58
+
59
+ # Prints styled content without a newline
60
+ # @param style [Symbol] the style key for color and weight
61
+ # @param content [String] the text to print
62
+ #
63
+ # @return [void]
64
+ def print(style, content)
65
+ text(style, content)
66
+ end
67
+
68
+ # Prints styled content followed by a newline
69
+ # @param style [Symbol] the style key for color and weight
70
+ # @param content [String] the text to print
71
+ #
72
+ # @return [void]
73
+ def puts(style, content)
74
+ text(style, content)
75
+ stream.puts
76
+ end
77
+
78
+ # Writes content directly to the stream without styling.
79
+ # Skips if content is nil or blank.
80
+ #
81
+ # @param content [String, nil] the raw text to write
82
+ # @return [void]
83
+ def write_raw(content)
84
+ return if content.to_s.strip.empty?
85
+
86
+ stream << content
87
+ end
88
+
89
+ # Whether the output stream is a TTY (interactive terminal)
90
+ # @return [Boolean] true if the stream supports ANSI styling
91
+ def tty? = stream.tty?
92
+ alias style_enabled? tty?
93
+
94
+ private
95
+
96
+ def text(style, content)
97
+ if style_enabled?
98
+ stream.print "#{STYLES.fetch(style)}#{content}#{RESET}"
99
+ else
100
+ stream.print content
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end
@@ -1,92 +1,81 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'colorize'
3
+ require 'io/console/size' # For determining console width/height
4
+
5
+ require_relative 'output/formatting'
6
+ require_relative 'output/printer'
4
7
 
5
8
  module Reviewer
6
- # 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.
7
11
  class Output
8
- SUCCESS = 'Success'
9
- FAILURE = 'Failure ·'
10
- DIVIDER = ('-' * 60).to_s
12
+ DEFAULT_CONSOLE_WIDTH = 120
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
11
25
 
12
26
  attr_reader :printer
13
27
 
14
- def initialize(printer: Reviewer.configuration.printer)
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]
32
+ def initialize(printer = Printer.new)
15
33
  @printer = printer
16
34
  end
17
35
 
18
- def info(message)
19
- printer.info message
20
- end
21
-
22
- def blank_line
23
- printer.info
24
- end
25
-
26
- def divider
27
- blank_line
28
- printer.info DIVIDER.light_black
29
- blank_line
30
- end
36
+ # === Primitives ===
31
37
 
32
- def tool_summary(tool)
33
- printer.info "\n#{tool.name}".bold + ' · '.light_black + tool.description
38
+ # Clears the terminal screen (no-op when output is not a TTY)
39
+ # @return [void]
40
+ def clear
41
+ system('clear') if printer.tty?
34
42
  end
35
43
 
36
- def current_command(command)
37
- command = String(command)
38
-
39
- printer.info "\nNow Running:"
40
- printer.info command.light_black
41
- end
44
+ # Prints a blank line
45
+ # @return [void]
46
+ def newline = printer.puts(:default, '')
42
47
 
43
- def exit_status(value)
44
- failure("Exit Status #{value}")
48
+ # Prints a horizontal rule spanning the console width
49
+ # @return [void]
50
+ def divider
51
+ newline
52
+ printer.print(:muted, DIVIDER * console_width)
45
53
  end
46
54
 
47
- def success(timer)
48
- message = SUCCESS.green.bold + " #{timer.total_seconds}s".green
49
- message += " (#{timer.prep_percent}% preparation)".yellow if timer.prepped?
50
-
51
- printer.info message
55
+ # Prints an unformatted help message
56
+ # @param message [String] the help text to display
57
+ # @return [void]
58
+ def help(message)
59
+ printer.puts(:default, message)
52
60
  end
53
61
 
54
- def failure(details, command: nil)
55
- printer.error "#{FAILURE} #{details}".red.bold
56
-
57
- return if command.nil?
58
-
59
- blank_line
60
- printer.error 'Failed Command:'.red.bold
61
- printer.error String(command).light_black
62
+ # Writes raw output directly without formatting
63
+ # @param value [String] the raw output to write
64
+ # @return [void]
65
+ def unfiltered(value)
66
+ printer.write_raw(value)
62
67
  end
63
68
 
64
- def unrecoverable(details)
65
- printer.error 'Unrecoverable Error:'.red.bold
66
- printer.error details
67
- end
69
+ private
68
70
 
69
- def guidance(summary, details)
70
- return if details.nil?
71
+ # Returns the current console width, falling back to a default
72
+ # @return [Integer] the console width in columns
73
+ def console_width
74
+ return DEFAULT_CONSOLE_WIDTH if IO.console.nil?
71
75
 
72
- blank_line
73
- printer.info summary
74
- printer.info details.to_s.light_black
75
- end
76
-
77
- def missing_executable_guidance(command)
78
- tool = command.tool
79
- installation_command = Command.new(tool, :install, :no_silence).string if tool.installable?
80
- install_link = tool.install_link
81
-
82
- failure("Missing executable for '#{tool}'", command: command)
83
- guidance('Try installing the tool:', installation_command)
84
- guidance('Read the installation guidance:', install_link)
85
- end
76
+ _height, width = IO.console.winsize
86
77
 
87
- def syntax_guidance(ignore_link: nil, disable_link: nil)
88
- guidance('Selectively Ignore a Rule:', ignore_link)
89
- guidance('Fully Disable a Rule:', disable_link)
78
+ width.positive? ? width : DEFAULT_CONSOLE_WIDTH
90
79
  end
91
80
  end
92
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