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
@@ -1,73 +1,217 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative 'runner/strategies/quiet'
4
- require_relative 'runner/strategies/verbose'
3
+ require_relative 'runner/failed_files'
4
+ require_relative 'runner/formatter'
5
+ require_relative 'runner/guidance'
6
+ require_relative 'runner/result'
7
+ require_relative 'runner/strategies/captured'
8
+ require_relative 'runner/strategies/passthrough'
5
9
 
6
10
  module Reviewer
7
11
  # Wrapper for executng a command and printing the results
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
- def_delegators :result, :exit_status
23
+ def_delegators :result, :exit_status, :stdout, :stderr, :rerunnable?
18
24
 
19
- def initialize(tool, command_type, strategy = Strategies::Quiet, output: Reviewer.output)
20
- @command = Command.new(tool, command_type)
25
+ # Creates a wrapper for running commands through Reviewer in order to provide a more accessible
26
+ # API for recording execution time and interpreting the results of a command in a more
27
+ # generous way so that non-zero exit statuses can still potentially be passing.
28
+ # @param tool [Symbol] the key for the desired tool to run
29
+ # @param command_type [Symbol] the key for the type of command to run
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)
32
+ #
33
+ # @return [self]
34
+ def initialize(tool, command_type, strategy = Strategies::Captured, context:)
35
+ @command = Command.new(tool, command_type, context: context)
21
36
  @strategy = strategy
22
- @shell = Shell.new
23
- @output = output
37
+ @shell = Shell.new(stream: context.output.printer.stream)
38
+ @context = context
39
+ @skipped = false
40
+ @missing = false
24
41
  end
25
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
26
62
  def run
27
- # Show which tool is about to run
28
- output.tool_summary(tool)
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
+
70
+ # Show which tool is running
71
+ identify_tool
72
+
73
+ # Use the provided strategy to run the command
74
+ execute_strategy
75
+
76
+ # Handle the result based on whether the tool was found
77
+ result.executable_not_found? ? handle_missing : handle_result
78
+ end
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.
98
+ def success?
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
111
+ end
29
112
 
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.
115
+ #
116
+ # @return [void]
117
+ def identify_tool
118
+ # If there's an existing result, the runner is being re-run, and identifying the tool would
119
+ # be redundant.
120
+ return if result.exists?
121
+
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
134
+ end
135
+
136
+ # Runs the relevant strategy to either capture or pass through command output.
137
+ #
138
+ # @return [void]
139
+ def execute_strategy
30
140
  # Run the provided strategy
31
141
  strategy.new(self).tap do |run_strategy|
32
142
  run_strategy.prepare if run_prepare_step?
33
143
  run_strategy.run
34
144
  end
145
+ end
35
146
 
36
- # If it failed,
37
- guidance.show unless success?
147
+ # Determines whether a preparation step should be run before the primary command. If/when the
148
+ # primary command is a `:prepare` command, then it shouldn't run twice. So it skips what would
149
+ # be a superfluous run of the preparation.
150
+ #
151
+ # @return [Boolean] true the primary command is not prepare and the tool needs to be prepare
152
+ def run_prepare_step? = command.type != :prepare && tool.prepare?
38
153
 
39
- exit_status
40
- end
154
+ # Creates_an instance of the prepare command for a tool
155
+ #
156
+ # @return [Comman] the current tool's prepare command
157
+ def prepare_command = @prepare_command ||= Command.new(tool, :prepare, context: @context)
41
158
 
42
- def success?
43
- # Some review tools return a range of non-zero exit statuses and almost never return 0.
44
- # (`yarn audit` is a good example.) Those tools can be configured to accept a non-zero exit
45
- # status so they aren't constantly considered to be failing over minor issues.
46
- #
47
- # But when other command types (prepare, install, format) are run, they either succeed or they
48
- # fail. With no shades of gray in those cases, anything other than a 0 is a failure.
49
- if command.type == :review
50
- exit_status <= tool.max_exit_status
51
- else
52
- exit_status.zero?
53
- end
159
+ # Updates the 'last prepared at' timestamp that Reviewer uses to know if a tool's preparation
160
+ # step is stale and needs to be run again.
161
+ #
162
+ # @return [Time] the timestamp `last_prepared_at` is updated to
163
+ def update_last_prepared_at = tool.last_prepared_at = Time.now
164
+
165
+ # Saves the last 5 elapsed times for the commands used this run by using the raw command as a
166
+ # unique key. This enables the ability to compare times across runs while taking into
167
+ # consideration that different iterations of the command may be running on fewer files. So
168
+ # comparing a full run to the average time for a partial run wouldn't be helpful. By using the
169
+ # raw command string, it will always be apples to apples.
170
+ #
171
+ # @return [void]
172
+ def record_timing
173
+ tool.record_timing(prepare_command, timer.prep)
174
+ tool.record_timing(command, timer.main)
54
175
  end
55
176
 
56
- def run_prepare_step?
57
- command.type != :prepare && tool.prepare?
177
+ # Uses the result of the runner to determine what, if any, guidance to display to help the user
178
+ # get back on track in the event of an unsuccessful run.
179
+ #
180
+ # @return [Guidance] the relevant guidance based on the result of the runner
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)
58
188
  end
59
189
 
60
- def prepare_command
61
- @prepare_command ||= Command.new(tool, :prepare, command.verbosity)
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?
62
198
  end
63
199
 
64
- def update_last_prepared_at
65
- # Touch the `last_prepared_at` timestamp for the tool so it waits before running again.
66
- tool.last_prepared_at = Time.now
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
67
207
  end
68
208
 
69
- def guidance
70
- @guidance ||= Reviewer::Guidance.new(command: command, result: result, output: output)
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
71
215
  end
72
216
  end
73
217
  end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../output/formatting'
4
+
5
+ module Reviewer
6
+ class Session
7
+ # Display logic for lifecycle warnings: unrecognized keywords, no matching tools, etc.
8
+ class Formatter
9
+ include Output::Formatting
10
+
11
+ attr_reader :output, :printer
12
+ private :output, :printer
13
+
14
+ # Creates a formatter for session lifecycle warnings
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
+ # Displays warnings for keywords that don't match any tool or git scope
24
+ # @param unrecognized [Array<String>] the unrecognized keyword strings
25
+ # @param suggestions [Hash{String => String}] keyword => suggested correction
26
+ #
27
+ # @return [void]
28
+ def unrecognized_keywords(unrecognized, suggestions)
29
+ unrecognized.each do |keyword|
30
+ printer.puts(:warning, "Unrecognized: #{keyword}")
31
+ suggestion = suggestions[keyword]
32
+ printer.puts(:muted, " did you mean '#{suggestion}'?") if suggestion
33
+ end
34
+ output.newline
35
+ end
36
+
37
+ # Displays a warning when an unrecognized output format is requested
38
+ # @param value [String] the invalid format name
39
+ # @param known [Array<Symbol>] the valid format options
40
+ #
41
+ # @return [void]
42
+ def invalid_format(value, known)
43
+ printer.puts(:warning, "Unknown format '#{value}', using 'streaming'")
44
+ printer.puts(:muted, "Valid formats: #{known.join(', ')}")
45
+ output.newline
46
+ end
47
+
48
+ # Displays a git-related error with context-appropriate messaging
49
+ # @param message [String] the error message from the git command
50
+ #
51
+ # @return [void]
52
+ def git_error(message)
53
+ if message.include?('not a git repository')
54
+ printer.puts(:warning, 'Not a git repository')
55
+ printer.puts(:muted, 'Git keywords (staged, modified, etc.) require a git repository')
56
+ else
57
+ printer.puts(:warning, 'Git command failed')
58
+ printer.puts(:muted, message)
59
+ printer.puts(:muted, 'Continuing without file filtering')
60
+ end
61
+ end
62
+
63
+ # Displays a message when file-scoping keywords resolved to no files
64
+ # @param keywords [Array<String>] the file keywords that were requested (e.g. ['staged'])
65
+ #
66
+ # @return [void]
67
+ def no_reviewable_files(keywords:)
68
+ output.newline
69
+ printer.puts(:muted, "No reviewable #{keywords.join(', ')} files found")
70
+ output.newline
71
+ end
72
+
73
+ # Displays a warning when no configured tools match the requested names or tags
74
+ # @param requested [Array<String>] tool names or tags the user asked for
75
+ # @param available [Array<String>] all configured tool keys
76
+ #
77
+ # @return [void]
78
+ def no_matching_tools(requested:, available:)
79
+ output.newline
80
+ printer.puts(:warning, 'No matching tools found')
81
+ printer.puts(:muted, "Requested: #{requested.join(', ')}") if requested.any?
82
+ printer.puts(:muted, "Available: #{available.join(', ')}") if available.any?
83
+ output.newline
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,208 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'session/formatter'
4
+
5
+ module Reviewer
6
+ # Run lifecycle with full dependency injection.
7
+ # Owns the review/format lifecycle that was previously in Reviewer module methods.
8
+ class Session
9
+ attr_reader :context, :tools
10
+ private :context, :tools
11
+
12
+ # Creates a session with all dependencies injected
13
+ # @param context [Context] the shared runtime dependencies (arguments, output, history)
14
+ # @param tools [Tools] the collection of configured tools
15
+ #
16
+ # @return [Session]
17
+ def initialize(context:, tools:)
18
+ @context = context
19
+ @tools = tools
20
+ context.arguments.keywords.tools = tools
21
+ end
22
+
23
+ # Runs the review command for the current set of tools
24
+ #
25
+ # @return [Integer] the maximum exit status from all tools
26
+ def review
27
+ run_tools(:review)
28
+ end
29
+
30
+ # Runs the format command for the current set of tools
31
+ #
32
+ # @return [Integer] the maximum exit status from all tools
33
+ def format
34
+ run_tools(:format)
35
+ end
36
+
37
+ private
38
+
39
+ def arguments = context.arguments
40
+ def output = context.output
41
+ def history = context.history
42
+
43
+ def run_tools(command_type)
44
+ if json_output?
45
+ run_json(command_type)
46
+ else
47
+ run_text(command_type)
48
+ end
49
+ end
50
+
51
+ def run_json(command_type)
52
+ message = json_early_exit_message
53
+ return emit_json_early_exit(message) if message
54
+
55
+ current_tools = tools.current
56
+ return 0 if current_tools.empty?
57
+
58
+ strategy = runner_strategy(current_tools)
59
+ report = Batch.new(command_type, current_tools, strategy: strategy, context: context).run
60
+ puts report.to_json
61
+ report.max_exit_status
62
+ end
63
+
64
+ def run_text(command_type)
65
+ return 0 if handle_failed_with_nothing_to_run?
66
+ return 0 if handle_file_scoping_with_no_files?
67
+
68
+ warn_unrecognized_keywords
69
+
70
+ current_tools = tools.current
71
+ return warn_no_matching_tools if current_tools.empty?
72
+
73
+ show_run_summary(current_tools, command_type)
74
+
75
+ strategy = runner_strategy(current_tools)
76
+ report = Batch.new(command_type, current_tools, strategy: strategy, context: context).run
77
+ display_text_report(report)
78
+ show_missing_tools(report, current_tools)
79
+
80
+ report.max_exit_status
81
+ end
82
+
83
+ def warn_no_matching_tools
84
+ formatter.no_matching_tools(
85
+ requested: arguments.keywords.provided + arguments.tags.to_a,
86
+ available: tools.all.map { |tool| tool.key.to_s }
87
+ )
88
+ 0
89
+ end
90
+
91
+ def json_output?
92
+ arguments.format == :json
93
+ end
94
+
95
+ def json_early_exit_message
96
+ if failed_with_nothing_to_run?
97
+ 'No failures to retry'
98
+ elsif file_scoping_with_no_files?
99
+ "No reviewable #{arguments.files.keywords.join(', ')} files found"
100
+ end
101
+ end
102
+
103
+ def emit_json_early_exit(message)
104
+ puts JSON.pretty_generate(
105
+ success: true,
106
+ message: message,
107
+ summary: { total: 0, passed: 0, failed: 0, missing: 0, duration: 0 },
108
+ tools: []
109
+ )
110
+ 0
111
+ end
112
+
113
+ def warn_unrecognized_keywords
114
+ unrecognized = arguments.keywords.unrecognized
115
+ return if unrecognized.empty?
116
+
117
+ suggestions = build_suggestions(unrecognized)
118
+ formatter.unrecognized_keywords(unrecognized, suggestions)
119
+ end
120
+
121
+ def build_suggestions(unrecognized)
122
+ possible = arguments.keywords.possible
123
+ checker = DidYouMean::SpellChecker.new(dictionary: possible)
124
+
125
+ unrecognized.each_with_object({}) do |keyword, map|
126
+ corrections = checker.correct(keyword)
127
+ map[keyword] = corrections.first if corrections.any?
128
+ end
129
+ end
130
+
131
+ # Returns true if failed keyword is present with nothing to re-run (caller should return early)
132
+ def handle_failed_with_nothing_to_run?
133
+ return false unless failed_with_nothing_to_run?
134
+
135
+ display_failed_empty_message
136
+ true
137
+ end
138
+
139
+ def failed_with_nothing_to_run?
140
+ keywords = arguments.keywords
141
+ keywords.failed? &&
142
+ tools.failed_from_history.empty? &&
143
+ keywords.for_tool_names.empty? &&
144
+ keywords.for_tags.empty? &&
145
+ arguments.tags.to_a.empty?
146
+ end
147
+
148
+ # Returns true if file keywords were provided but resolved to no files (caller should return early)
149
+ def handle_file_scoping_with_no_files?
150
+ return false unless file_scoping_with_no_files?
151
+
152
+ formatter.no_reviewable_files(keywords: arguments.files.keywords)
153
+ true
154
+ end
155
+
156
+ def file_scoping_with_no_files?
157
+ arguments.files.keywords.any? && arguments.files.to_a.empty?
158
+ end
159
+
160
+ def display_failed_empty_message
161
+ if tools.all.any? { |tool| history.get(tool.key, :last_status) }
162
+ batch_formatter.no_failures_to_retry
163
+ else
164
+ batch_formatter.no_previous_run
165
+ end
166
+ end
167
+
168
+ def display_text_report(report)
169
+ if arguments.format == :summary
170
+ Report::Formatter.new(report, output: output).print
171
+ elsif report.success?
172
+ ran_count = report.results.count { |result| !result.missing? && !result.skipped? }
173
+ batch_formatter.summary(ran_count, report.duration)
174
+ end
175
+ end
176
+
177
+ def show_missing_tools(report, current_tools)
178
+ return unless report.missing?
179
+
180
+ batch_formatter.missing_tools(report.missing_tools, tools: current_tools)
181
+ end
182
+
183
+ def show_run_summary(current_tools, command_type)
184
+ return unless arguments.keywords.provided.any?
185
+
186
+ entries = build_run_summary(current_tools, command_type)
187
+ return if entries.size <= 1 && entries.none? { |entry| entry[:files].any? }
188
+
189
+ batch_formatter.run_summary(entries)
190
+ end
191
+
192
+ def build_run_summary(current_tools, command_type)
193
+ current_tools.filter_map do |tool|
194
+ Command.new(tool, command_type, context: context).run_summary
195
+ end
196
+ end
197
+
198
+ def runner_strategy(current_tools)
199
+ arguments.runner_strategy(multiple_tools: current_tools.size > 1)
200
+ end
201
+
202
+ def formatter = @formatter ||= Session::Formatter.new(output)
203
+
204
+ def batch_formatter
205
+ Batch::Formatter.new(output)
206
+ end
207
+ end
208
+ end