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
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reviewer
4
+ class Runner
5
+ # Immutable value object representing the result of running a single tool
6
+ #
7
+ # @!attribute [r] tool_key
8
+ # @return [Symbol] the unique identifier for the tool
9
+ # @!attribute [r] tool_name
10
+ # @return [String] the human-readable name of the tool
11
+ # @!attribute [r] command_type
12
+ # @return [Symbol] the type of command run (:review, :format, etc.)
13
+ # @!attribute [r] command_string
14
+ # @return [String] the full command string that was executed
15
+ # @!attribute [r] success
16
+ # @return [Boolean] whether the command completed successfully
17
+ # @!attribute [r] exit_status
18
+ # @return [Integer] the exit status code from the command
19
+ # @!attribute [r] duration
20
+ # @return [Float] the execution time in seconds
21
+ # @!attribute [r] stdout
22
+ # @return [String, nil] the standard output from the command
23
+ # @!attribute [r] stderr
24
+ # @return [String, nil] the standard error from the command
25
+ # @!attribute [r] skipped
26
+ # @return [Boolean] whether the tool was skipped
27
+ # @!attribute [r] missing
28
+ # @return [Boolean] whether the tool's executable was not found
29
+ Result = Struct.new(
30
+ :tool_key,
31
+ :tool_name,
32
+ :command_type,
33
+ :command_string,
34
+ :success,
35
+ :exit_status,
36
+ :duration,
37
+ :stdout,
38
+ :stderr,
39
+ :skipped,
40
+ :missing,
41
+ :summary_pattern,
42
+ :summary_label,
43
+ keyword_init: true
44
+ ) do
45
+ # Freeze on initialization to maintain immutability like Data.define
46
+ def initialize(...)
47
+ super
48
+ freeze
49
+ end
50
+
51
+ # Builds an immutable Result from a runner's current state.
52
+ # @param runner [Runner] the runner after command execution
53
+ #
54
+ # @return [Result] an immutable result for reporting
55
+ def self.from_runner(runner)
56
+ if runner.skipped?
57
+ build_skipped(runner)
58
+ elsif runner.missing?
59
+ build_missing(runner)
60
+ else
61
+ build_executed(runner)
62
+ end
63
+ end
64
+
65
+ def self.base_attributes(runner)
66
+ tool = runner.tool
67
+ {
68
+ tool_key: tool.key,
69
+ tool_name: tool.name,
70
+ command_type: runner.command.type,
71
+ command_string: runner.command.string
72
+ }
73
+ end
74
+
75
+ def self.build_skipped(runner)
76
+ new(
77
+ **base_attributes(runner),
78
+ command_string: nil,
79
+ success: true, exit_status: 0, duration: 0,
80
+ stdout: nil, stderr: nil, skipped: true
81
+ )
82
+ end
83
+
84
+ def self.build_missing(runner)
85
+ new(
86
+ **base_attributes(runner),
87
+ success: false, exit_status: runner.shell.result.exit_status, duration: 0,
88
+ stdout: nil, stderr: nil, skipped: nil, missing: true
89
+ )
90
+ end
91
+
92
+ def self.build_executed(runner)
93
+ shell = runner.shell
94
+ shell_result = shell.result
95
+ settings = runner.tool.settings
96
+ new(
97
+ **base_attributes(runner),
98
+ success: runner.success?, exit_status: shell_result.exit_status,
99
+ duration: shell.timer.total_seconds,
100
+ stdout: shell_result.stdout, stderr: shell_result.stderr, skipped: nil,
101
+ summary_pattern: settings.summary_pattern,
102
+ summary_label: settings.summary_label
103
+ )
104
+ end
105
+
106
+ private_class_method :base_attributes, :build_skipped, :build_missing, :build_executed
107
+
108
+ alias_method :success?, :success
109
+ alias_method :skipped?, :skipped
110
+ alias_method :missing?, :missing
111
+
112
+ # Whether this result represents a tool that actually ran (not skipped or missing)
113
+ #
114
+ # @return [Boolean] true if the tool was executed
115
+ def executed? = !skipped? && !missing?
116
+
117
+ # Extracts a short summary detail from stdout for display purposes.
118
+ # Each tool type may have its own summary format (test count, offense count, etc.)
119
+ #
120
+ # @return [String, nil] a brief summary or nil if no detail can be extracted
121
+ def detail_summary
122
+ return nil unless summary_pattern
123
+
124
+ match = stdout&.match(/#{summary_pattern}/i)
125
+ return nil unless match
126
+
127
+ summary_label.gsub(/\\(\d+)/) { match[Regexp.last_match(1).to_i] }
128
+ end
129
+
130
+ # Converts the result to a hash suitable for serialization
131
+ #
132
+ # @return [Hash] hash representation with nil values removed
133
+ def to_h
134
+ {
135
+ tool: tool_key,
136
+ name: tool_name,
137
+ command_type: command_type,
138
+ command: command_string,
139
+ success: success,
140
+ exit_status: exit_status,
141
+ duration: duration,
142
+ stdout: stdout,
143
+ stderr: stderr,
144
+ skipped: skipped,
145
+ missing: missing
146
+ }.compact # Excludes summary_pattern/summary_label (config, not results)
147
+ end
148
+ end
149
+ end
150
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'ruby-progressbar'
4
+
3
5
  module Reviewer
4
6
  class Runner
5
7
  module Strategies
@@ -8,6 +10,12 @@ module Reviewer
8
10
  # @attr_reader runner [Runner] the instance of the runner that will be executed with this strategy
9
11
  # @attr_reader start_time [Time] the start time for the strategy_for timing purposes
10
12
  class Captured
13
+ # 256-color ANSI codes for progress bar styling (not in AnsiStyles' 16-color palette)
14
+ PROGRESS_GRAY = "\e[38;5;245m"
15
+ PROGRESS_DARK_GRAY = "\e[38;5;240m"
16
+ ANSI_RESET = "\e[0m"
17
+ ERASE_LINE = "\r\e[2K"
18
+
11
19
  attr_reader :runner, :start_time
12
20
 
13
21
  # Create an instance of the captured strategy for a command runner so that any output is
@@ -28,6 +36,9 @@ module Reviewer
28
36
 
29
37
  display_progress(command) { runner.shell.capture_prep(command) }
30
38
 
39
+ # Erase the prep progress bar — the run step will show its own
40
+ stream.print(ERASE_LINE) if style_enabled? && runner.streaming?
41
+
31
42
  # Running the prepare command, so make sure the timestamp is updated
32
43
  runner.update_last_prepared_at
33
44
  end
@@ -40,6 +51,12 @@ module Reviewer
40
51
 
41
52
  display_progress(command) { runner.shell.capture_main(command) }
42
53
 
54
+ # Skip output for non-streaming modes - results are formatted at the end
55
+ return unless runner.streaming?
56
+
57
+ # Missing tools are handled by the Runner (shows "Skipped (not installed)")
58
+ return if runner.shell.result.executable_not_found?
59
+
43
60
  # If it's successful, show that it was a success and how long it took to run, otherwise,
44
61
  # it wasn't successful and we got some explaining to do...
45
62
  runner.success? ? show_timing_result : show_command_output
@@ -51,36 +68,92 @@ module Reviewer
51
68
  # Helps people know that the sub-command is running within expectations.
52
69
  # @param command [String] the precise command string generated by Reviewer. Serves as the
53
70
  # key for looking up the command's history.
54
- # @param &block [Block] the runner for the command that's being timed and having its
71
+ # @param block [Block] the runner for the command that's being timed and having its
55
72
  # progress updated and printed
56
73
  #
57
74
  # @return [void]
58
- def display_progress(command, &block) # rubocop:disable Metrics/AbcSize
59
- start_time = Time.now
75
+ def display_progress(command)
60
76
  average_time = runner.tool.average_time(command)
77
+ start_time = Time.now
78
+ thread = Thread.new { yield }
61
79
 
62
- thread = Thread.new { block.call }
80
+ # Skip progress output for non-streaming modes
81
+ return thread.join unless runner.streaming?
82
+
83
+ print_progress(thread, start_time, average_time)
84
+ end
85
+
86
+ def print_progress(thread, start_time, average_time)
87
+ bar = create_progress_bar(average_time)
63
88
 
64
89
  while thread.alive?
65
- elapsed = (Time.now - start_time).to_f.round(1)
66
- progress = if average_time.zero?
67
- "#{elapsed}s"
68
- else
69
- "~#{((elapsed / average_time) * 100).round}%"
70
- end
71
-
72
- $stdout.print "> #{progress} \r"
73
- $stdout.flush
90
+ update_progress(bar, start_time, average_time)
91
+ sleep 0.1
92
+ end
93
+
94
+ thread.join
95
+
96
+ # Erase the progress bar line if the executable wasn't found
97
+ if runner.shell.result.executable_not_found?
98
+ stream.print(ERASE_LINE) if style_enabled?
99
+ return
100
+ end
101
+
102
+ finish_progress_bar(bar, average_time.positive?)
103
+ end
104
+
105
+ def finish_progress_bar(bar, timed)
106
+ if timed
107
+ bar.format = style_enabled? ? "#{PROGRESS_GRAY}%b#{PROGRESS_DARK_GRAY}%i %p%%#{ANSI_RESET}" : '%b%i %p%%'
108
+ bar.finish
109
+ else
110
+ bar.stop
111
+ end
112
+ end
113
+
114
+ def create_progress_bar(average_time)
115
+ shared = {
116
+ output: stream,
117
+ title: '',
118
+ progress_mark: "\u2501",
119
+ remainder_mark: "\u2500"
120
+ }
121
+
122
+ if average_time.positive?
123
+ eta = average_time >= 3 ? ' %e' : ''
124
+ format = style_enabled? ? "#{PROGRESS_GRAY}%b#{PROGRESS_DARK_GRAY}%i %p%%#{eta}#{ANSI_RESET}" : "%b%i %p%%#{eta}"
125
+ ProgressBar.create(**shared, total: 100, format: format)
126
+ else
127
+ format = style_enabled? ? "#{PROGRESS_GRAY}%B#{ANSI_RESET}" : '%B'
128
+ ProgressBar.create(**shared, total: nil, format: format)
129
+ end
130
+ end
131
+
132
+ def update_progress(bar, start_time, average_time)
133
+ if average_time.positive?
134
+ elapsed = Time.now - start_time
135
+ percent = [(elapsed / average_time * 100).round, 99].min
136
+ bar.progress = percent if percent > bar.progress
137
+ else
138
+ bar.increment
74
139
  end
75
140
  end
76
141
 
142
+ # The output stream from the runner's printer
143
+ # @return [IO]
144
+ def stream = runner.output.printer.stream
145
+
146
+ # Whether ANSI styling is enabled on the output stream
147
+ # @return [Boolean]
148
+ def style_enabled? = runner.output.printer.style_enabled?
149
+
77
150
  # Determines if stdout or stderr captured any useful output that can be displayed in order
78
151
  # to more rapidly display output when a command fails. As long as both aren't nil or
79
152
  # otherwise 'blank' strings, then that's enough.
80
153
  #
81
154
  # @return [Boolean] true if either stdout or stderr contain printable content
82
155
  def usable_output_captured?
83
- [runner.stdout, runner.stderr].reject { |value| value.nil? || value.strip.empty? }.any?
156
+ [runner.stdout, runner.stderr].reject { |value| value.to_s.strip.empty? }.any?
84
157
  end
85
158
 
86
159
  # Prints "Success" and the resulting timing details before moving on to the next tool
@@ -88,7 +161,7 @@ module Reviewer
88
161
  # @return [void]
89
162
  def show_timing_result
90
163
  runner.record_timing
91
- runner.output.success(runner.timer)
164
+ runner.formatter.success(runner.timer)
92
165
  end
93
166
 
94
167
  # Prints "Failure" and the resulting exit status. Shows the precise command that led to the
@@ -101,7 +174,7 @@ module Reviewer
101
174
  runner.output.clear
102
175
 
103
176
  # Show the exit status and failed command
104
- runner.output.failure("Exit Status #{runner.exit_status}", command: runner.command)
177
+ runner.formatter.failure("Exit Status #{runner.exit_status}", command: runner.command)
105
178
 
106
179
  # If it can't be rerun, then don't try
107
180
  usable_output_captured? ? show_captured_output : rerun_via_passthrough
@@ -118,25 +191,27 @@ module Reviewer
118
191
  # If there's a useful stdout value, display it with a divider to visually separate it.
119
192
  #
120
193
  # @return [void]
121
- def show_captured_stdout # rubocop:disable Metrics/AbcSize
122
- return if runner.stdout.nil? || runner.stdout.empty?
194
+ def show_captured_stdout
195
+ stdout = runner.stdout.to_s
196
+ return if stdout.empty?
123
197
 
124
198
  runner.output.divider
125
199
  runner.output.newline
126
- runner.output.unfiltered(runner.stdout)
200
+ runner.output.unfiltered(stdout)
127
201
  end
128
202
 
129
203
  # If there's a useful stderr value, display it with a divider to visually separate it.
130
204
  #
131
205
  # @return [void]
132
- def show_captured_stderr # rubocop:disable Metrics/AbcSize
133
- return if runner.stderr.nil? || runner.stderr.empty?
206
+ def show_captured_stderr
207
+ stderr = runner.stderr.to_s
208
+ return if stderr.empty?
134
209
 
135
- scrubbed_stderr = Reviewer::Output::Scrubber.new(runner.stderr).clean
210
+ scrubbed_stderr = Output.scrub(stderr)
136
211
 
137
212
  runner.output.divider
138
213
  runner.output.newline
139
- runner.output.guidance('Runtime Errors:', scrubbed_stderr)
214
+ runner.formatter.guidance('Runtime Errors:', scrubbed_stderr)
140
215
  end
141
216
 
142
217
  # If for some reason, the command didn't send anything to stdout/stderr, the only option to
@@ -30,10 +30,7 @@ module Reviewer
30
30
  # Display the exact command syntax that's being run. This can come in handy if there's an
31
31
  # issue and the command can be copied/pasted or if the generated command somehow has some
32
32
  # incorrect syntax or options that need to be corrected.
33
- runner.output.current_command(runner.prepare_command)
34
-
35
- # Add a divider to visually delineate the results
36
- runner.output.divider
33
+ runner.formatter.current_command(runner.prepare_command)
37
34
 
38
35
  # Run the command through the shell directly so no output is suppressed
39
36
  runner.shell.direct(runner.prepare_command)
@@ -46,16 +43,10 @@ module Reviewer
46
43
  # Display the exact command syntax that's being run. This can come in handy if there's an
47
44
  # issue and the command can be copied/pasted or if the generated command somehow has some
48
45
  # incorrect syntax or options that need to be corrected.
49
- runner.output.current_command(runner.command)
50
-
51
- # Add a divider to visually delineate the results
52
- runner.output.divider
46
+ runner.formatter.current_command(runner.command)
53
47
 
54
48
  # Run the command through the shell directly so no output is suppressed
55
49
  runner.shell.direct(runner.command)
56
-
57
- # Add a final divider to visually delineate the results
58
- runner.output.divider
59
50
  end
60
51
  end
61
52
  end
@@ -1,5 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'runner/failed_files'
4
+ require_relative 'runner/formatter'
5
+ require_relative 'runner/guidance'
6
+ require_relative 'runner/result'
3
7
  require_relative 'runner/strategies/captured'
4
8
  require_relative 'runner/strategies/passthrough'
5
9
 
@@ -8,60 +12,106 @@ module Reviewer
8
12
  class Runner
9
13
  extend Forwardable
10
14
 
15
+ # @!attribute strategy
16
+ # @return [Class] the strategy class for running the command (Captured or Passthrough)
11
17
  attr_accessor :strategy
12
18
 
13
- attr_reader :command, :shell, :output
19
+ attr_reader :command, :shell
14
20
 
15
21
  def_delegators :@command, :tool
16
22
  def_delegators :@shell, :result, :timer
17
23
  def_delegators :result, :exit_status, :stdout, :stderr, :rerunnable?
18
24
 
19
- # Creates a wrapper for running commansd through Reviewer in order to provide a more accessible
25
+ # Creates a wrapper for running commands through Reviewer in order to provide a more accessible
20
26
  # API for recording execution time and interpreting the results of a command in a more
21
- # generous way so that non-zero exit statuses can still potentiall be passing.
27
+ # generous way so that non-zero exit statuses can still potentially be passing.
22
28
  # @param tool [Symbol] the key for the desired tool to run
23
29
  # @param command_type [Symbol] the key for the type of command to run
24
- # @param strategy = Strategies::Captured [Runner::Strategies] how to execute and handle the
25
- # results of the command
26
- # @param output: Reviewer.output [Review::Output] the output formatter for the results
30
+ # @param strategy [Runner::Strategies] how to execute and handle the results of the command
31
+ # @param context [Context] the shared runtime dependencies (arguments, output, history)
27
32
  #
28
33
  # @return [self]
29
- def initialize(tool, command_type, strategy = Strategies::Captured, output: Reviewer.output)
30
- @command = Command.new(tool, command_type)
34
+ def initialize(tool, command_type, strategy = Strategies::Captured, context:)
35
+ @command = Command.new(tool, command_type, context: context)
31
36
  @strategy = strategy
32
- @shell = Shell.new
33
- @output = output
37
+ @shell = Shell.new(stream: context.output.printer.stream)
38
+ @context = context
39
+ @skipped = false
40
+ @missing = false
34
41
  end
35
42
 
43
+ # The output channel for displaying content, delegated from context.
44
+ #
45
+ # @return [Output]
46
+ def output = @context.output
47
+
48
+ # Display formatter for runner-specific output (tool summary, success, failure, etc.)
49
+ # Computed rather than stored to avoid exceeding instance variable threshold.
50
+ #
51
+ # @return [Runner::Formatter]
52
+ def formatter = @formatter ||= Runner::Formatter.new(output)
53
+
54
+ # Whether this runner is operating in streaming mode
55
+ #
56
+ # @return [Boolean] true if output should be streamed
57
+ def streaming? = @context.arguments.streaming?
58
+
59
+ # Executes the command and returns the exit status
60
+ #
61
+ # @return [Integer] the exit status from the command
36
62
  def run
63
+ # Skip if files were requested but none match this tool's pattern
64
+ if command.skip?
65
+ @skipped = true
66
+ show_skipped
67
+ return 0
68
+ end
69
+
37
70
  # Show which tool is running
38
71
  identify_tool
39
72
 
40
73
  # Use the provided strategy to run the command
41
74
  execute_strategy
42
75
 
43
- # If it failed, display guidance to help them get back on track
44
- guidance.show unless success?
45
-
46
- # Return the exit status generated by the tool as interpreted by the Result
47
- exit_status
76
+ # Handle the result based on whether the tool was found
77
+ result.executable_not_found? ? handle_missing : handle_result
48
78
  end
49
79
 
80
+ # Whether this runner was skipped due to no matching files
81
+ #
82
+ # @return [Boolean] true if the tool was skipped
83
+ def skipped? = @skipped == true
84
+
85
+ # Whether this runner's executable was not found (exit status 127)
86
+ #
87
+ # @return [Boolean] true if the tool was missing
88
+ def missing? = @missing == true
89
+
90
+ # Some review tools return a range of non-zero exit statuses and almost never return 0.
91
+ # (`yarn audit` is a good example.) Those tools can be configured to accept a non-zero exit
92
+ # status so they aren't constantly considered to be failing over minor issues.
93
+ #
94
+ # But when other command types (prepare, install, format) are run, they either succeed or they
95
+ # fail. With no shades of gray in those cases, anything other than a 0 is a failure.
96
+ #
97
+ # Skipped tools are always considered successful.
50
98
  def success?
51
- # Some review tools return a range of non-zero exit statuses and almost never return 0.
52
- # (`yarn audit` is a good example.) Those tools can be configured to accept a non-zero exit
53
- # status so they aren't constantly considered to be failing over minor issues.
54
- #
55
- # But when other command types (prepare, install, format) are run, they either succeed or they
56
- # fail. With no shades of gray in those cases, anything other than a 0 is a failure.
57
- if command.type == :review
58
- exit_status <= tool.max_exit_status
59
- else
60
- exit_status.zero?
61
- end
99
+ return true if skipped?
100
+
101
+ command.type == :review ? exit_status <= tool.max_exit_status : exit_status.zero?
102
+ end
103
+
104
+ def failure? = !success?
105
+
106
+ # Extracts file paths from stdout/stderr for failed-file tracking
107
+ #
108
+ # @return [Array<String>] file paths found in the command output
109
+ def failed_files
110
+ FailedFiles.new(stdout, stderr).to_a
62
111
  end
63
112
 
64
- # Prints the tool name and description to the console as a frame of reference
113
+ # Prints the tool name and description to the console as a frame of reference.
114
+ # Only displays in streaming mode; non-streaming strategies handle their own output.
65
115
  #
66
116
  # @return [void]
67
117
  def identify_tool
@@ -69,7 +119,18 @@ module Reviewer
69
119
  # be redundant.
70
120
  return if result.exists?
71
121
 
72
- output.tool_summary(tool)
122
+ stream_output { formatter.tool_summary(tool) }
123
+ end
124
+
125
+ # Shows that a tool was skipped due to no matching files.
126
+ # Only displays in streaming mode; non-streaming modes report skips in the final summary.
127
+ #
128
+ # @return [void]
129
+ def show_skipped
130
+ stream_output do
131
+ formatter.tool_summary(tool)
132
+ formatter.skipped
133
+ end
73
134
  end
74
135
 
75
136
  # Runs the relevant strategy to either capture or pass through command output.
@@ -88,25 +149,18 @@ module Reviewer
88
149
  # be a superfluous run of the preparation.
89
150
  #
90
151
  # @return [Boolean] true the primary command is not prepare and the tool needs to be prepare
91
- def run_prepare_step?
92
- command.type != :prepare && tool.prepare?
93
- end
152
+ def run_prepare_step? = command.type != :prepare && tool.prepare?
94
153
 
95
154
  # Creates_an instance of the prepare command for a tool
96
155
  #
97
156
  # @return [Comman] the current tool's prepare command
98
- def prepare_command
99
- @prepare_command ||= Command.new(tool, :prepare)
100
- end
157
+ def prepare_command = @prepare_command ||= Command.new(tool, :prepare, context: @context)
101
158
 
102
159
  # Updates the 'last prepared at' timestamp that Reviewer uses to know if a tool's preparation
103
160
  # step is stale and needs to be run again.
104
161
  #
105
162
  # @return [Time] the timestamp `last_prepared_at` is updated to
106
- def update_last_prepared_at
107
- # Touch the `last_prepared_at` timestamp for the tool so it waits before running again.
108
- tool.last_prepared_at = Time.now
109
- end
163
+ def update_last_prepared_at = tool.last_prepared_at = Time.now
110
164
 
111
165
  # Saves the last 5 elapsed times for the commands used this run by using the raw command as a
112
166
  # unique key. This enables the ability to compare times across runs while taking into
@@ -124,8 +178,40 @@ module Reviewer
124
178
  # get back on track in the event of an unsuccessful run.
125
179
  #
126
180
  # @return [Guidance] the relevant guidance based on the result of the runner
127
- def guidance
128
- @guidance ||= Reviewer::Guidance.new(command: command, result: result, output: output)
181
+ def guidance = @guidance ||= Guidance.new(command: command, result: result, context: @context)
182
+
183
+ # Builds an immutable Result object from the current runner state
184
+ #
185
+ # @return [Runner::Result] the result of running this tool
186
+ def to_result
187
+ Result.from_runner(self)
188
+ end
189
+
190
+ private
191
+
192
+ # Yields the block only when in streaming mode.
193
+ # Centralizes the streaming guard so display methods don't each check independently.
194
+ #
195
+ # @return [void]
196
+ def stream_output
197
+ yield if streaming?
198
+ end
199
+
200
+ # Marks the tool as missing and shows a skip message
201
+ #
202
+ # @return [Integer] the exit status from the command
203
+ def handle_missing
204
+ @missing = true
205
+ stream_output { formatter.skipped('not installed') }
206
+ exit_status
207
+ end
208
+
209
+ # Shows failure guidance if needed and returns the exit status
210
+ #
211
+ # @return [Integer] the exit status from the command
212
+ def handle_result
213
+ stream_output { guidance.show } if failure?
214
+ exit_status
129
215
  end
130
216
  end
131
217
  end