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
@@ -1,83 +1,67 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'batch/formatter'
4
+
3
5
  module Reviewer
4
6
  # Provides a structure for running commands for a given set of tools
5
7
  class Batch
8
+ # Raised when a tool specifies an unrecognized command type
6
9
  class UnrecognizedCommandError < ArgumentError; end
7
10
 
8
- attr_reader :command_type, :tools, :output, :results
11
+ attr_reader :command_type, :tools, :report, :context, :strategy
12
+ private :context, :strategy
9
13
 
10
14
  # Generates an instance of Batch for running multiple tools together
11
15
  # @param command_type [Symbol] the type of command to run for each tool.
12
16
  # @param tools [Array<Tool>] the tools to run the commands for
13
- # @param output: Reviewer.output [Output] the output channel to print results to
17
+ # @param strategy [Class] the runner strategy class (Captured or Passthrough)
18
+ # @param context [Context] the shared runtime dependencies (arguments, output, history)
14
19
  #
15
20
  # @return [self]
16
- def initialize(command_type, tools, output: Reviewer.output)
21
+ def initialize(command_type, tools, strategy:, context:)
17
22
  @command_type = command_type
18
23
  @tools = tools
19
- @output = output
20
- @results = {}
24
+ @strategy = strategy
25
+ @context = context
26
+ @report = Report.new
21
27
  end
22
28
 
23
29
  # Iterates over the tools in the batch to successfully run the commands. Also times the entire
24
30
  # batch in order to provide a total execution time.
25
31
  #
26
- # @return [Results] the results summary for all commands run
32
+ # @return [Report] the report containing results for all commands run
27
33
  def run
28
- benchmark_batch do
34
+ elapsed_time = Benchmark.realtime do
35
+ clear_last_statuses
29
36
  matching_tools.each do |tool|
30
- # Create and execute a runner for the given tool, command type, and strategy
31
- runner = Runner.new(tool, command_type, strategy)
32
- runner.run
33
-
34
- # Record the exit status for this tool
35
- record_exit_status(runner)
36
-
37
- # If the tool fails, stop running other tools
38
- break unless runner.success?
37
+ runner = run_tool(tool)
38
+ break unless runner.success? || runner.missing?
39
39
  end
40
40
  end
41
41
 
42
- results
42
+ @report.record_duration(elapsed_time)
43
+ @report
43
44
  end
44
45
 
45
46
  private
46
47
 
47
- def multiple_tools?
48
- tools.size > 1
49
- end
48
+ # Runs a single tool and records its result in the report.
49
+ # @return [Runner] the runner after execution
50
+ def run_tool(tool)
51
+ runner = Runner.new(tool, command_type, strategy, context: context)
52
+ runner.run
50
53
 
51
- def strategy
52
- multiple_tools? ? Runner::Strategies::Captured : Runner::Strategies::Passthrough
53
- end
54
+ result = runner.to_result
55
+ @report.add(result)
56
+ tool.record_run(result) unless runner.missing?
54
57
 
55
- # Notes the exit status for the runner based on whether the runner was considered successful or
56
- # not based on the configured `max_exit_status` for the tool. For example, some tools use exit
57
- # status to convey significance. So even though it returns a non-zero exit status like 2, it
58
- # can still be successful.
59
- # @param runner [Runner] the instance of the runner that's being inspected
60
- #
61
- # @return [Integer] the adjusted exit status for the runner
62
- def record_exit_status(runner)
63
- # Since some tools can "succeed" with a positive exit status, the overall batch is only
64
- # interested in subjective failure. So if the runner succeeded according to the tool's max
65
- # exit status, it should record the tool's run as a success for the purposes of the larger
66
- # batch success/failure
67
- @results[runner.tool.key] = runner.success? ? 0 : runner.exit_status
58
+ runner
68
59
  end
69
60
 
70
- # Records and prints the total runtime of a block
71
- # @param &block [type] section of code to be timed
72
- #
73
- # @return [void] prints the elapsed time
74
- def benchmark_batch(&block)
75
- elapsed_time = Benchmark.realtime(&block)
76
-
77
- # If there's failures, skip showing the total time to focus on the issues
78
- return if @results.values.sum.positive?
79
-
80
- output.batch_summary(results.size, elapsed_time)
61
+ def clear_last_statuses
62
+ matching_tools.each do |tool|
63
+ context.history.set(tool.key, :last_status, nil)
64
+ end
81
65
  end
82
66
 
83
67
  # Returns the set of tools matching the provided command. So when formatting, if a tool does not
@@ -85,7 +69,7 @@ module Reviewer
85
69
  #
86
70
  # @return [Array<Tool>] the enabled tools that support the provided command
87
71
  def matching_tools
88
- tools.select { |tool| tool.settings.commands.key?(command_type) }
72
+ tools.select { |tool| tool.command?(command_type) }
89
73
  end
90
74
  end
91
75
  end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Reviewer
6
+ # Provides machine-readable output describing available tools and usage patterns.
7
+ # Designed for AI agents and automation tools to discover and use Reviewer correctly.
8
+ #
9
+ # @example
10
+ # puts Reviewer::Capabilities.new.to_json
11
+ class Capabilities
12
+ attr_reader :tools
13
+
14
+ # Creates a capabilities report for machine-readable tool discovery
15
+ # @param tools [Tools] the tools collection to report on
16
+ #
17
+ # @return [Capabilities]
18
+ def initialize(tools:)
19
+ @tools = tools
20
+ end
21
+
22
+ KEYWORDS = {
23
+ staged: 'Files staged for commit',
24
+ unstaged: 'Files with unstaged changes',
25
+ modified: 'All changed files',
26
+ untracked: 'New files not yet tracked'
27
+ }.freeze
28
+
29
+ SCENARIOS = {
30
+ before_commit: 'rvw staged',
31
+ during_development: 'rvw modified',
32
+ full_review: 'rvw'
33
+ }.freeze
34
+
35
+ # Convert capabilities to a hash representation
36
+ #
37
+ # @return [Hash] structured capabilities data
38
+ def to_h
39
+ {
40
+ version: VERSION,
41
+ tools: tools_data,
42
+ keywords: KEYWORDS,
43
+ scenarios: SCENARIOS
44
+ }
45
+ end
46
+
47
+ # Convert capabilities to formatted JSON string
48
+ #
49
+ # @return [String] JSON representation of capabilities
50
+ def to_json(*_args)
51
+ JSON.pretty_generate(to_h)
52
+ end
53
+
54
+ private
55
+
56
+ # Build tool data from configured tools
57
+ #
58
+ # @return [Array<Hash>] array of tool capability hashes
59
+ def tools_data
60
+ tools.all.map { |tool| tool_data(tool) }
61
+ end
62
+
63
+ # Build capability data for a single tool
64
+ #
65
+ # @param tool [Tool] the tool to extract data from
66
+ # @return [Hash] tool capability hash
67
+ def tool_data(tool)
68
+ {
69
+ key: tool.key.to_s,
70
+ name: tool.name,
71
+ description: tool.description,
72
+ tags: tool.tags,
73
+ skip_in_batch: tool.skip_in_batch?,
74
+ commands: {
75
+ review: tool.reviewable?,
76
+ format: tool.formattable?
77
+ }
78
+ }
79
+ end
80
+ end
81
+ end
@@ -15,24 +15,30 @@ module Reviewer
15
15
  @env_pairs = env_pairs
16
16
  end
17
17
 
18
+ # Converts environment variables to a space-separated string
19
+ #
20
+ # @return [String] formatted environment variables (e.g., "KEY=value KEY2=value2")
18
21
  def to_s
19
22
  to_a.compact.join(' ')
20
23
  end
21
24
 
25
+ # Converts environment variables to an array of KEY=value strings
26
+ #
27
+ # @return [Array<String, nil>] array of formatted env vars, nil for empty values
22
28
  def to_a
23
- env = []
24
- env_pairs.each { |key, value| env << env(key, value) }
25
- env
29
+ env_pairs.map { |key, value| env(key, value) }
26
30
  end
27
31
 
28
32
  private
29
33
 
30
34
  def env(key, value)
31
- return nil if key.to_s.strip.empty? || value.to_s.strip.empty?
35
+ key_str = key.to_s.strip
36
+ value_str = value.to_s.strip
37
+ return nil if key_str.empty? || value_str.empty?
32
38
 
33
- value = needs_quotes?(value) ? "'#{value}'" : value
39
+ value_str = "'#{value_str}'" if needs_quotes?(value)
34
40
 
35
- "#{key.to_s.strip.upcase}=#{value.to_s.strip}"
41
+ "#{key_str.upcase}=#{value_str}"
36
42
  end
37
43
 
38
44
  def needs_quotes?(value)
@@ -27,9 +27,7 @@ module Reviewer
27
27
  #
28
28
  # @return [Array<String>] array of all flag strings to use to when running the command
29
29
  def to_a
30
- flags = []
31
- flag_pairs.each { |key, value| flags << flag(key, value) }
32
- flags
30
+ flag_pairs.map { |key, value| flag(key, value) }
33
31
  end
34
32
 
35
33
  private
@@ -37,7 +35,7 @@ module Reviewer
37
35
  def flag(key, value)
38
36
  dash = key.to_s.size == 1 ? '-' : '--'
39
37
 
40
- value = needs_quotes?(value) ? "'#{value}'" : value
38
+ value = "'#{value}'" if needs_quotes?(value)
41
39
 
42
40
  "#{dash}#{key} #{value}".strip
43
41
  end
@@ -7,15 +7,23 @@ module Reviewer
7
7
  class Command
8
8
  # Assembles tool tool_settings into a usable command string for the command type
9
9
  class String
10
- include Conversions
10
+ attr_reader :command_type, :tool_settings, :files
11
11
 
12
- attr_reader :command_type, :tool_settings
13
-
14
- def initialize(command_type, tool_settings:)
12
+ # Creates a command string builder for a tool
13
+ # @param command_type [Symbol] the command type (:install, :prepare, :review, :format)
14
+ # @param tool_settings [Tool::Settings] the tool's configuration settings
15
+ # @param files [Array<String>] files to include in the command
16
+ #
17
+ # @return [String] a command string builder instance
18
+ def initialize(command_type, tool_settings:, files: [])
15
19
  @command_type = command_type
16
20
  @tool_settings = tool_settings
21
+ @files = Array(files)
17
22
  end
18
23
 
24
+ # Converts the command to a complete string ready for execution
25
+ #
26
+ # @return [String] the full command string
19
27
  def to_s
20
28
  to_a
21
29
  .map(&:strip) # Remove extra spaces on the components
@@ -23,23 +31,29 @@ module Reviewer
23
31
  .strip # Strip extra spaces from the end result
24
32
  end
25
33
 
34
+ # Converts the command to an array of its components
35
+ #
36
+ # @return [Array<String, nil>] env vars, body, flags, and files
26
37
  def to_a
27
38
  [
28
39
  env_variables,
29
40
  body,
30
- flags
41
+ flags,
42
+ files_string
31
43
  ].compact
32
44
  end
33
45
 
34
46
  # The string of environment variables built from a tool's configuration settings
35
47
  #
36
48
  # @return [String] the environment variable names and values concatened for the command
37
- def env_variables
38
- Env.new(tool_settings.env).to_s
39
- end
49
+ def env_variables = Env.new(tool_settings.env).to_s
40
50
 
51
+ # The base command string from the tool's configuration.
52
+ # Uses the file-scoped command when files are present and one is configured.
53
+ #
54
+ # @return [String] the configured command for the command type
41
55
  def body
42
- tool_settings.commands.fetch(command_type)
56
+ file_scoped_command || tool_settings.commands.fetch(command_type)
43
57
  end
44
58
 
45
59
  # Gets the flags to be used in conjunction with the review command for a tool
@@ -53,14 +67,35 @@ module Reviewer
53
67
  Flags.new(tool_settings.flags).to_s
54
68
  end
55
69
 
70
+ # Builds the files portion of the command string
71
+ #
72
+ # @return [String, nil] the formatted files string or nil if not applicable
73
+ def files_string
74
+ return nil unless files_applicable?
75
+
76
+ file_list = files.join(tool_settings.files_separator)
77
+ flag = tool_settings.files_flag
78
+
79
+ flag.empty? ? file_list : "#{flag} #{file_list}"
80
+ end
81
+
56
82
  private
57
83
 
84
+ def file_scoped_command
85
+ return nil unless files.any?
86
+
87
+ tool_settings.files_command(command_type)
88
+ end
89
+
58
90
  # Determines whether the string needs flags added
59
91
  #
60
92
  # @return [Boolean] true if it's a review command and it has flags configured
61
- def flags?
62
- command_type == :review && tool_settings.flags.any?
63
- end
93
+ def flags? = command_type == :review && tool_settings.flags.any?
94
+
95
+ # Determines whether files should be appended to the command
96
+ #
97
+ # @return [Boolean] true if tool supports files and files were provided
98
+ def files_applicable? = tool_settings.supports_files? && files.any?
64
99
  end
65
100
  end
66
101
  end
@@ -5,23 +5,30 @@ require_relative 'command/string'
5
5
  module Reviewer
6
6
  # The core funtionality to translate a tool, command type, and verbosity into a runnable command
7
7
  class Command
8
- include Conversions
8
+ include Tool::Conversions
9
9
 
10
10
  SEED_SUBSTITUTION_VALUE = '$SEED'
11
11
 
12
- attr_reader :tool
12
+ attr_reader :tool, :arguments, :history
13
+ private :arguments, :history
13
14
 
15
+ # @!attribute type
16
+ # @return [Symbol] the command type (:install, :prepare, :review, :format)
14
17
  attr_accessor :type
15
18
 
16
19
  # Creates an instance of the Command class to synthesize a command string using the tool,
17
20
  # command type, and verbosity.
18
21
  # @param tool [Tool, Symbol] a tool or tool key to use to look up the command and options
19
22
  # @param type [Symbol] the desired command type (:install, :prepare, :review, :format)
23
+ # @param context [Context] the shared runtime dependencies (arguments, output, history)
20
24
  #
21
25
  # @return [Command] the intersection of a tool, command type, and verbosity
22
- def initialize(tool, type)
26
+ def initialize(tool, type, context:)
23
27
  @tool = Tool(tool)
24
28
  @type = type.to_sym
29
+ @seed = nil
30
+ @arguments = context.arguments
31
+ @history = context.history
25
32
  end
26
33
 
27
34
  # The final command string with all of the conditions appled
@@ -39,10 +46,14 @@ module Reviewer
39
46
  #
40
47
  # @return [Integer] a random integer to pass to tools that use seeds
41
48
  def seed
42
- @seed ||= Random.rand(100_000)
49
+ @seed ||= if arguments.keywords.failed?
50
+ history.get(tool.key, :last_seed) || Random.rand(100_000)
51
+ else
52
+ Random.rand(100_000)
53
+ end
43
54
 
44
55
  # Store the seed for reference
45
- Reviewer.history.set(tool.key, :last_seed, @seed)
56
+ history.set(tool.key, :last_seed, @seed)
46
57
 
47
58
  @seed
48
59
  end
@@ -50,13 +61,59 @@ module Reviewer
50
61
  # The raw command string before any substitutions. For example, since seeds need to remain
51
62
  # consistent from one run to the next, they're
52
63
  #
53
- # @return [type] [description]
64
+ # @return [String] the command string before seed substitution
54
65
  def raw_string
55
- @raw_string ||= String.new(type, tool_settings: tool.settings).to_s
66
+ @raw_string ||= String.new(type, tool_settings: tool.settings, files: target_files).to_s # rubocop:disable Lint/RedundantTypeConversion
67
+ end
68
+
69
+ # Gets the list of files to target for this command, resolved by the tool
70
+ #
71
+ # @return [Array<String>] the list of files from arguments, resolved by tool
72
+ def target_files
73
+ @target_files ||= tool.resolve_files(requested_files)
74
+ end
75
+
76
+ # Determines if this command should be skipped because files were requested but none match
77
+ #
78
+ # @return [Boolean] true if files were requested but resolution left none for this tool
79
+ def skip?
80
+ tool.skip_files?(requested_files)
81
+ end
82
+
83
+ # Returns a summary hash for run display, or nil if this command should be skipped
84
+ #
85
+ # @return [Hash, nil] { name:, files: } or nil if skipped
86
+ def run_summary
87
+ return nil if skip?
88
+
89
+ { name: tool.name, files: target_files }
56
90
  end
57
91
 
58
92
  private
59
93
 
94
+ # The raw list of files from arguments before resolution.
95
+ # Falls through to stored failed files when the `failed` keyword is present
96
+ # and no explicit files were provided.
97
+ #
98
+ # @return [Array<String>] files from -f flag, keywords like 'staged', or stored failed files
99
+ def requested_files
100
+ @requested_files ||= begin
101
+ explicit = arguments.files.to_a
102
+ if explicit.empty? && arguments.keywords.failed?
103
+ stored_failed_files
104
+ else
105
+ explicit
106
+ end
107
+ end
108
+ end
109
+
110
+ # Retrieves failed files stored from the previous run for this tool
111
+ #
112
+ # @return [Array<String>] stored failed file paths, or empty array
113
+ def stored_failed_files
114
+ history.get(tool.key, :last_failed_files) || []
115
+ end
116
+
60
117
  # The version of the command with the SEED_SUBSTITUTION_VALUE replaced
61
118
  #
62
119
  # @return [String] the command string with the SEED_SUBSTITUTION_VALUE replaced
@@ -68,8 +125,6 @@ module Reviewer
68
125
  # Determines if the raw command string has a SEED_SUBSTITUTION_VALUE that needs replacing
69
126
  #
70
127
  # @return [Boolean] true if the raw command string contains the SEED_SUBSTITUTION_VALUE
71
- def seed_substitution?
72
- raw_string.include?(SEED_SUBSTITUTION_VALUE)
73
- end
128
+ def seed_substitution? = raw_string.include?(SEED_SUBSTITUTION_VALUE)
74
129
  end
75
130
  end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+
5
+ module Reviewer
6
+ class Configuration
7
+ # Provides a collection of the configured tools
8
+ class Loader
9
+ # Raised when the .reviewer.yml configuration file cannot be found
10
+ class MissingConfigurationError < StandardError; end
11
+
12
+ # Raised when the .reviewer.yml file contains invalid YAML syntax
13
+ class InvalidConfigurationError < StandardError; end
14
+
15
+ # Raised when a configured tool is missing a required review command
16
+ class MissingReviewCommandError < StandardError; end
17
+
18
+ attr_reader :configuration, :file
19
+
20
+ # Creates a loader instance for the configuration file
21
+ # @param file [Pathname] the path to the configuration YAML file
22
+ #
23
+ # @return [Loader] a loader with parsed configuration
24
+ # @raise [MissingConfigurationError] if the file doesn't exist
25
+ # @raise [InvalidConfigurationError] if the YAML is malformed
26
+ # @raise [MissingReviewCommandError] if a tool lacks a review command
27
+ def initialize(file:)
28
+ @file = file
29
+ @configuration = configuration_hash
30
+
31
+ validate_configuration
32
+ end
33
+
34
+ # Whether all configured tools have a review command
35
+ #
36
+ # @return [Boolean] true if every tool has a review command configured
37
+ def review_commands_present?
38
+ configuration.all? { |_key, value| value[:commands]&.key?(:review) }
39
+ end
40
+
41
+ # Converts the loader to its configuration hash
42
+ #
43
+ # @return [Hash] the parsed configuration
44
+ def to_h = configuration
45
+
46
+ # Loads and returns the tools configuration hash
47
+ #
48
+ # @return [Hash] the parsed configuration from the YAML file
49
+ def self.configuration(file:) = new(file: file).configuration
50
+
51
+ private
52
+
53
+ def require_review_commands
54
+ return if review_commands_present?
55
+
56
+ missing = configuration.find { |_key, value| !value[:commands]&.key?(:review) }
57
+ raise MissingReviewCommandError, "'#{missing[0]}' does not have a 'review' key under 'commands' in `#{file}`"
58
+ end
59
+ alias validate_configuration require_review_commands
60
+
61
+ def configuration_hash
62
+ @configuration_hash ||= Psych.safe_load_file(@file, symbolize_names: true)
63
+ rescue Errno::ENOENT
64
+ raise MissingConfigurationError, "Tools configuration file couldn't be found at `#{file}`"
65
+ rescue Psych::SyntaxError => e
66
+ raise InvalidConfigurationError, "Tools configuration file (#{file}) has a syntax error: #{e.message}"
67
+ end
68
+ end
69
+ end
70
+ end
@@ -9,6 +9,8 @@ module Reviewer
9
9
  # @return [Pathname] the pathname for the primary configuraton file
10
10
  # @!attribute history_file
11
11
  # @return [Pathname] the pathname for the history file to store data across runs
12
+ # @!attribute printer
13
+ # @return [Output::Printer] the printer instance for console output
12
14
  #
13
15
  # @author [garrettdimon]
14
16
  #
@@ -18,10 +20,11 @@ module Reviewer
18
20
  DEFAULT_CONFIG_FILE_NAME = '.reviewer.yml'
19
21
  DEFAULT_HISTORY_FILE_NAME = '.reviewer_history.yml'
20
22
 
21
- DEFAULT_CONFIG_LOCATION = "#{DEFAULT_PATH}/#{DEFAULT_CONFIG_FILE_NAME}"
22
- DEFAULT_HISTORY_LOCATION = "#{DEFAULT_PATH}/#{DEFAULT_HISTORY_FILE_NAME}"
23
+ DEFAULT_CONFIG_LOCATION = "#{DEFAULT_PATH}/#{DEFAULT_CONFIG_FILE_NAME}".freeze
24
+ DEFAULT_HISTORY_LOCATION = "#{DEFAULT_PATH}/#{DEFAULT_HISTORY_FILE_NAME}".freeze
23
25
 
24
- attr_accessor :file, :history_file, :printer
26
+ attr_accessor :file, :history_file
27
+ attr_reader :printer
25
28
 
26
29
  def initialize
27
30
  @file = Pathname(DEFAULT_CONFIG_LOCATION)
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reviewer
4
+ # Bundles the shared runtime dependencies that flow through the review/format lifecycle.
5
+ # Passed from Session → Batch → Runner → Command so that no class needs to reach
6
+ # into module-level globals for arguments, output, or history.
7
+ #
8
+ # @!attribute [rw] arguments
9
+ # @return [Arguments] the parsed command-line arguments
10
+ # @!attribute [rw] output
11
+ # @return [Output] the output channel for displaying content
12
+ # @!attribute [rw] history
13
+ # @return [History] the YAML store for timing data and prepare timestamps
14
+ Context = Struct.new(:arguments, :output, :history, keyword_init: true)
15
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reviewer
4
+ module Doctor
5
+ # Validates the configuration file by delegating to Configuration::Loader
6
+ class ConfigCheck
7
+ attr_reader :report
8
+
9
+ # Creates a config check that validates the .reviewer.yml file
10
+ # @param report [Doctor::Report] the report to add findings to
11
+ # @param configuration [Configuration] the configuration to validate
12
+ #
13
+ # @return [ConfigCheck]
14
+ def initialize(report, configuration:)
15
+ @report = report
16
+ @configuration = configuration
17
+ end
18
+
19
+ # Checks for .reviewer.yml existence and validity
20
+ def check
21
+ config_file = @configuration.file
22
+
23
+ unless config_file.exist?
24
+ report.add(:configuration, status: :error,
25
+ message: 'No .reviewer.yml found', detail: 'Run `rvw init` to generate one')
26
+ return
27
+ end
28
+
29
+ report.add(:configuration, status: :ok, message: '.reviewer.yml found')
30
+ validate_via_loader
31
+ end
32
+
33
+ private
34
+
35
+ # Exercises the full Configuration::Loader pipeline (parse + validate) to surface config errors
36
+ def validate_via_loader
37
+ Configuration::Loader.configuration(file: @configuration.file)
38
+ report.add(:configuration, status: :ok, message: 'Configuration is valid')
39
+ rescue Configuration::Loader::InvalidConfigurationError => e
40
+ report.add(:configuration, status: :error, message: 'YAML syntax error', detail: e.message)
41
+ rescue Configuration::Loader::MissingReviewCommandError => e
42
+ report.add(:configuration, status: :error, message: 'Missing review command', detail: e.message)
43
+ end
44
+ end
45
+ end
46
+ end