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,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
@@ -0,0 +1,233 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reviewer
4
+ module Setup
5
+ # Frozen hash of known tool definitions with detection signals.
6
+ # Each entry contains the config structure needed for .reviewer.yml
7
+ # plus a :detect key with signals for auto-detection.
8
+ module Catalog
9
+ # Known tool definitions with detection signals and default configuration
10
+ TOOLS = {
11
+ bundle_audit: {
12
+ name: 'Bundle Audit',
13
+ description: 'Review gem dependencies for security issues',
14
+ tags: %w[security dependencies ruby],
15
+ commands: {
16
+ install: 'bundle exec gem install bundler-audit',
17
+ prepare: 'bundle exec bundle-audit update',
18
+ review: 'bundle exec bundle-audit check --no-update'
19
+ },
20
+ detect: {
21
+ gems: %w[bundler-audit]
22
+ }
23
+ },
24
+ rubocop: {
25
+ name: 'RuboCop',
26
+ description: 'Review Ruby syntax and formatting for consistency',
27
+ tags: %w[ruby syntax],
28
+ commands: {
29
+ install: 'bundle exec gem install rubocop',
30
+ review: 'bundle exec rubocop --parallel',
31
+ format: 'bundle exec rubocop --auto-correct'
32
+ },
33
+ files: { flag: '', separator: ' ', pattern: '*.rb' },
34
+ detect: {
35
+ gems: %w[rubocop],
36
+ files: %w[.rubocop.yml]
37
+ }
38
+ },
39
+ standard: {
40
+ name: 'Standard',
41
+ description: 'Zero-configuration Ruby linter',
42
+ tags: %w[ruby syntax],
43
+ commands: {
44
+ review: 'bundle exec standardrb',
45
+ format: 'bundle exec standardrb --fix'
46
+ },
47
+ detect: {
48
+ gems: %w[standard]
49
+ }
50
+ },
51
+ reek: {
52
+ name: 'Reek',
53
+ description: 'Examine Ruby classes for code smells',
54
+ tags: %w[ruby quality],
55
+ commands: {
56
+ install: 'bundle exec gem install reek',
57
+ review: 'bundle exec reek'
58
+ },
59
+ files: { flag: '', separator: ' ', pattern: '*.rb' },
60
+ detect: {
61
+ gems: %w[reek],
62
+ files: %w[.reek.yml]
63
+ }
64
+ },
65
+ flog: {
66
+ name: 'Flog',
67
+ description: 'Reports the most tortured Ruby code in a pain report',
68
+ tags: %w[ruby quality],
69
+ commands: {
70
+ install: 'bundle exec gem install flog',
71
+ review: 'bundle exec flog -g lib'
72
+ },
73
+ detect: {
74
+ gems: %w[flog]
75
+ }
76
+ },
77
+ flay: {
78
+ name: 'Flay',
79
+ description: 'Review Ruby code for structural similarities',
80
+ tags: %w[ruby quality],
81
+ commands: {
82
+ install: 'bundle exec gem install flay',
83
+ review: 'bundle exec flay ./lib'
84
+ },
85
+ detect: {
86
+ gems: %w[flay]
87
+ }
88
+ },
89
+ brakeman: {
90
+ name: 'Brakeman',
91
+ description: 'Static analysis security scanner for Rails',
92
+ tags: %w[security ruby],
93
+ commands: {
94
+ install: 'bundle exec gem install brakeman',
95
+ review: 'bundle exec brakeman --no-pager -q'
96
+ },
97
+ detect: {
98
+ gems: %w[brakeman],
99
+ directories: %w[app/controllers]
100
+ }
101
+ },
102
+ fasterer: {
103
+ name: 'Fasterer',
104
+ description: 'Suggest performance improvements for Ruby code',
105
+ tags: %w[ruby quality performance],
106
+ commands: {
107
+ install: 'bundle exec gem install fasterer',
108
+ review: 'bundle exec fasterer'
109
+ },
110
+ files: { flag: '', separator: ' ', pattern: '*.rb' },
111
+ detect: {
112
+ gems: %w[fasterer]
113
+ }
114
+ },
115
+ tests: {
116
+ name: 'Minitest',
117
+ description: 'Unit tests and coverage',
118
+ tags: %w[ruby tests],
119
+ commands: {
120
+ review: 'bundle exec rake test'
121
+ },
122
+ files: { review: 'bundle exec ruby -Itest', pattern: '*_test.rb', map_to_tests: 'minitest' },
123
+ detect: {
124
+ gems: %w[minitest],
125
+ directories: %w[test]
126
+ }
127
+ },
128
+ specs: {
129
+ name: 'RSpec',
130
+ description: 'Behavior-driven tests and coverage',
131
+ tags: %w[ruby tests],
132
+ commands: {
133
+ review: 'bundle exec rspec'
134
+ },
135
+ files: { flag: '', separator: ' ', pattern: '*_spec.rb', map_to_tests: 'rspec' },
136
+ detect: {
137
+ gems: %w[rspec],
138
+ directories: %w[spec]
139
+ }
140
+ },
141
+ eslint: {
142
+ name: 'ESLint',
143
+ description: 'Lint JavaScript and TypeScript code',
144
+ tags: %w[javascript linting syntax],
145
+ commands: {
146
+ review: 'npx eslint .',
147
+ format: 'npx eslint . --fix'
148
+ },
149
+ files: { flag: '', separator: ' ', pattern: '*.js' },
150
+ detect: {
151
+ files: %w[.eslintrc .eslintrc.js .eslintrc.json .eslintrc.yml eslint.config.js eslint.config.mjs]
152
+ }
153
+ },
154
+ prettier: {
155
+ name: 'Prettier',
156
+ description: 'Check code formatting consistency',
157
+ tags: %w[javascript formatting],
158
+ commands: {
159
+ review: 'npx prettier --check .',
160
+ format: 'npx prettier --write .'
161
+ },
162
+ detect: {
163
+ files: %w[.prettierrc .prettierrc.js .prettierrc.json .prettierrc.yml .prettierrc.yaml]
164
+ }
165
+ },
166
+ stylelint: {
167
+ name: 'Stylelint',
168
+ description: 'Lint CSS and SCSS for errors and consistency',
169
+ tags: %w[css linting],
170
+ commands: {
171
+ review: 'npx stylelint "**/*.css"',
172
+ format: 'npx stylelint "**/*.css" --fix'
173
+ },
174
+ files: { flag: '', separator: ' ', pattern: '*.css' },
175
+ detect: {
176
+ files: %w[.stylelintrc .stylelintrc.js .stylelintrc.json .stylelintrc.yml]
177
+ }
178
+ },
179
+ typescript: {
180
+ name: 'TypeScript',
181
+ description: 'Type-check TypeScript code',
182
+ tags: %w[javascript typescript],
183
+ commands: {
184
+ review: 'npx tsc --noEmit'
185
+ },
186
+ detect: {
187
+ files: %w[tsconfig.json]
188
+ }
189
+ },
190
+ biome: {
191
+ name: 'Biome',
192
+ description: 'Lint and format JavaScript and TypeScript',
193
+ tags: %w[javascript linting formatting],
194
+ commands: {
195
+ review: 'npx @biomejs/biome check .',
196
+ format: 'npx @biomejs/biome check . --fix'
197
+ },
198
+ files: { flag: '', separator: ' ', pattern: '*.js' },
199
+ detect: {
200
+ files: %w[biome.json biome.jsonc]
201
+ }
202
+ }
203
+ }.freeze
204
+
205
+ # Returns the full catalog of known tools
206
+ #
207
+ # @return [Hash] frozen hash of tool definitions
208
+ def self.all = TOOLS
209
+
210
+ # Returns the config for a tool key without the :detect key
211
+ #
212
+ # @param key [Symbol] the tool key
213
+ # @return [Hash, nil] config hash without :detect, or nil if not found
214
+ def self.config_for(key)
215
+ definition = TOOLS[key]
216
+ return nil unless definition
217
+
218
+ definition.except(:detect)
219
+ end
220
+
221
+ # Returns the detection signals for a tool key
222
+ #
223
+ # @param key [Symbol] the tool key
224
+ # @return [Hash, nil] detect hash, or nil if not found
225
+ def self.detect_for(key)
226
+ definition = TOOLS[key]
227
+ return nil unless definition
228
+
229
+ definition[:detect]
230
+ end
231
+ end
232
+ end
233
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reviewer
4
+ module Setup
5
+ # Scans a project directory to detect which review tools are applicable
6
+ # based on Gemfile.lock contents, config files, and directory structure.
7
+ class Detector
8
+ # Value object for a single detection result (tool key + evidence).
9
+ # @!attribute key [rw]
10
+ # @return [Symbol] the tool identifier from the catalog
11
+ # @!attribute reasons [rw]
12
+ # @return [Array<String>] evidence strings explaining why the tool was detected
13
+ Result = Struct.new(:key, :reasons, keyword_init: true) do
14
+ # @return [String] human-readable tool name from the catalog, or the key as fallback
15
+ def name = Catalog.config_for(key)&.dig(:name) || key.to_s
16
+ # @return [String] formatted line for display (name + reasons)
17
+ def summary = " #{name.ljust(22)}#{reasons.join(', ')}"
18
+ end
19
+
20
+ attr_reader :project_dir
21
+
22
+ # Creates a detector for scanning a project directory for supported tools
23
+ # @param project_dir [Pathname, String] the project root to scan
24
+ #
25
+ # @return [Detector]
26
+ def initialize(project_dir = Pathname.pwd)
27
+ @project_dir = Pathname(project_dir)
28
+ end
29
+
30
+ # Scans the project and returns detection results for matching tools
31
+ #
32
+ # @return [Array<Result>] detected tools with evidence
33
+ def detect
34
+ gems = GemfileLock.new(project_dir.join('Gemfile.lock')).gem_names
35
+
36
+ Catalog.all.filter_map do |key, definition|
37
+ reasons = reasons_for(definition[:detect], gems)
38
+ Result.new(key: key, reasons: reasons) if reasons.any?
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ def reasons_for(detect, gems)
45
+ gem_reasons(detect, gems) + file_reasons(detect) + directory_reasons(detect)
46
+ end
47
+
48
+ def gem_reasons(detect, gems)
49
+ Array(detect[:gems]).select { |name| gems.include?(name) }.map { |name| "#{name} in Gemfile.lock" }
50
+ end
51
+
52
+ def file_reasons(detect)
53
+ Array(detect[:files]).select { |name| project_dir.join(name).exist? }
54
+ end
55
+
56
+ def directory_reasons(detect)
57
+ Array(detect[:directories]).select { |name| project_dir.join(name).directory? }.map { |name| "#{name}/ directory" }
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../output/formatting'
4
+
5
+ module Reviewer
6
+ module Setup
7
+ # Display logic for the first-run setup flow
8
+ class Formatter
9
+ include Output::Formatting
10
+
11
+ attr_reader :output, :printer
12
+ private :output, :printer
13
+
14
+ # Creates a formatter for setup flow 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
+ # Displays the welcome message when Reviewer has no configuration file
24
+ #
25
+ # @return [void]
26
+ def first_run_greeting
27
+ output.newline
28
+ printer.puts(:bold, "It looks like you're setting up Reviewer for the first time on this project.")
29
+ output.newline
30
+ printer.puts(:muted, 'This will auto-detect your tools and generate a .reviewer.yml configuration file.')
31
+ output.newline
32
+ end
33
+
34
+ # Displays a hint about `rvw init` when the user declines initial setup
35
+ #
36
+ # @return [void]
37
+ def first_run_skip
38
+ printer.puts(:muted, 'You can run `rvw init` any time to auto-detect and configure your tools.')
39
+ output.newline
40
+ end
41
+
42
+ # Displays a notice when `rvw init` is run but .reviewer.yml already exists
43
+ # @param config_file [Pathname] the existing configuration file path
44
+ #
45
+ # @return [void]
46
+ def setup_already_exists(config_file)
47
+ output.newline
48
+ printer.puts(:bold, "Configuration already exists: #{config_file.basename}")
49
+ output.newline
50
+ printer.puts(:muted, 'Run `rvw doctor` for a diagnostic report.')
51
+ printer.puts(:muted, 'To regenerate, remove the file and run `rvw init` again.')
52
+ output.newline
53
+ end
54
+
55
+ # Displays a message when auto-detection finds no supported tools in the project
56
+ #
57
+ # @return [void]
58
+ def setup_no_tools_detected
59
+ output.newline
60
+ printer.puts(:bold, 'No supported tools detected.')
61
+ output.newline
62
+ printer.puts(:muted, 'Create .reviewer.yml manually:')
63
+ printer.puts(:muted, " #{Setup::CONFIG_URL}")
64
+ output.newline
65
+ end
66
+
67
+ # Displays the results of a successful setup with the detected tools
68
+ # @param results [Array<Detector::Result>] the tools that were detected and configured
69
+ #
70
+ # @return [void]
71
+ def setup_success(results)
72
+ output.newline
73
+ printer.puts(:success, 'Created .reviewer.yml')
74
+ print_detected_tools(results)
75
+ print_setup_footer
76
+ end
77
+
78
+ private
79
+
80
+ def print_detected_tools(results)
81
+ output.newline
82
+ printer.puts(:bold, 'Detected tools:')
83
+ results.each { |result| printer.puts(:default, result.summary) }
84
+ end
85
+
86
+ def print_setup_footer
87
+ output.newline
88
+ printer.puts(:muted, "Configure further: #{Setup::CONFIG_URL}")
89
+ printer.puts(:muted, 'Run `rvw` to review your code.')
90
+ output.newline
91
+ end
92
+ end
93
+ end
94
+ end