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,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reviewer
4
+ module Setup
5
+ # Parses a Gemfile.lock to extract gem names from the specs section
6
+ class GemfileLock
7
+ # Spec lines are indented with 4 spaces: " gem-name (version)"
8
+ SPEC_LINE = /\A {4}(\S+)\s/
9
+
10
+ attr_reader :path
11
+
12
+ # Creates a parser for extracting gem names from a Gemfile.lock
13
+ # @param path [Pathname] the path to the Gemfile.lock file
14
+ #
15
+ # @return [GemfileLock]
16
+ def initialize(path)
17
+ @path = path
18
+ end
19
+
20
+ # Returns the set of gem names found in the specs section
21
+ #
22
+ # @return [Set<String>] gem names
23
+ def gem_names
24
+ return Set.new unless path.exist?
25
+
26
+ parse_specs
27
+ end
28
+
29
+ private
30
+
31
+ def parse_specs
32
+ in_specs = false
33
+ gems = Set.new
34
+
35
+ path.each_line do |line|
36
+ in_specs, gem_name = process_line(line, in_specs)
37
+ gems.add(gem_name) if gem_name
38
+ end
39
+
40
+ gems
41
+ end
42
+
43
+ def process_line(line, in_specs)
44
+ return [true, nil] if line.strip == 'specs:'
45
+ return [in_specs, nil] unless in_specs
46
+
47
+ match = line.match(SPEC_LINE)
48
+ return [true, match[1]] if match
49
+
50
+ still_in_specs = line.start_with?(' ') || line.strip.empty?
51
+ [still_in_specs, nil]
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+
5
+ require_relative 'tool_block'
6
+
7
+ module Reviewer
8
+ module Setup
9
+ # Produces .reviewer.yml YAML content from a list of detected tool keys.
10
+ # Orchestrates which tools to include; delegates per-tool rendering to ToolBlock.
11
+ class Generator
12
+ attr_reader :tool_keys, :project_dir
13
+
14
+ # Creates a generator for producing .reviewer.yml configuration
15
+ # @param tool_keys [Array<Symbol>] catalog tool keys to include in config
16
+ # @param project_dir [Pathname] the project root (used for package manager detection)
17
+ #
18
+ # @return [Generator]
19
+ def initialize(tool_keys, project_dir: Pathname.pwd)
20
+ @tool_keys = tool_keys
21
+ @project_dir = Pathname(project_dir)
22
+ end
23
+
24
+ # Generates YAML configuration string for the detected tools
25
+ #
26
+ # @return [String] valid YAML for .reviewer.yml
27
+ def generate
28
+ return "--- {}\n" if tool_keys.empty?
29
+
30
+ blocks = tool_keys.filter_map do |key|
31
+ definition = Catalog.config_for(key)
32
+ next unless definition
33
+
34
+ ToolBlock.new(key, definition, js_runner: js_runner).to_s
35
+ end
36
+
37
+ "---\n#{blocks.join("\n")}"
38
+ end
39
+
40
+ private
41
+
42
+ # Detects the JS package manager based on lockfile presence
43
+ def js_runner
44
+ @js_runner ||= if project_dir.join('yarn.lock').exist?
45
+ 'yarn'
46
+ elsif project_dir.join('pnpm-lock.yaml').exist?
47
+ 'pnpm exec'
48
+ else
49
+ 'npx'
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reviewer
4
+ module Setup
5
+ # Renders the YAML configuration block for a single tool definition.
6
+ # Owns the definition data so rendering methods reference self's state
7
+ # rather than reaching into parameters.
8
+ class ToolBlock
9
+ YAML_BARE_WORDS = %w[true false yes no on off null ~].freeze
10
+
11
+ # Creates a renderer for a single tool's YAML configuration block
12
+ # @param key [Symbol] the tool key (e.g., :rubocop)
13
+ # @param definition [Hash] the catalog definition for this tool
14
+ # @param js_runner [String] the JS package runner to substitute for npx
15
+ #
16
+ # @return [ToolBlock]
17
+ def initialize(key, definition, js_runner:)
18
+ @key = key
19
+ @definition = definition
20
+ @js_runner = js_runner
21
+ end
22
+
23
+ # Renders the full YAML block for this tool
24
+ #
25
+ # @return [String] the YAML text for this tool's configuration
26
+ def to_s
27
+ lines = header_lines
28
+ lines.concat(commands_block)
29
+ lines.concat(files_block) if @definition[:files]
30
+ lines << ''
31
+ lines.join("\n")
32
+ end
33
+
34
+ private
35
+
36
+ def header_lines
37
+ lines = []
38
+ lines << "# #{@definition[:description]}"
39
+ lines << "#{@key}:"
40
+ lines << " name: #{quote(@definition[:name])}"
41
+ lines << " description: #{quote(@definition[:description])}"
42
+ lines << tags_line if @definition[:tags]&.any?
43
+ lines
44
+ end
45
+
46
+ def tags_line
47
+ " tags: [#{@definition[:tags].join(', ')}]"
48
+ end
49
+
50
+ def commands_block
51
+ commands = @definition[:commands]
52
+ lines = [' commands:']
53
+ %i[install prepare review format].each do |type|
54
+ next unless commands[type]
55
+
56
+ lines << " #{type}: #{quote(apply_js_runner(commands[type].to_s))}"
57
+ end
58
+ lines
59
+ end
60
+
61
+ def files_block
62
+ lines = [' files:']
63
+ lines.concat(files_command_lines)
64
+ lines.concat(files_targeting_lines)
65
+ lines
66
+ end
67
+
68
+ def files_command_lines
69
+ %i[review format].filter_map do |type|
70
+ " #{type}: #{quote(apply_js_runner(tool_files[type].to_s))}" if tool_files[type]
71
+ end
72
+ end
73
+
74
+ def files_targeting_lines
75
+ [
76
+ (file_setting_line(:flag) if tool_files.key?(:flag)),
77
+ (file_setting_line(:separator) if tool_files.key?(:separator)),
78
+ (file_setting_line(:pattern) if tool_files[:pattern]),
79
+ (" map_to_tests: #{tool_files[:map_to_tests]}" if tool_files[:map_to_tests])
80
+ ].compact
81
+ end
82
+
83
+ def file_setting_line(key)
84
+ " #{key}: #{quote(tool_files[key])}"
85
+ end
86
+
87
+ def tool_files
88
+ @definition[:files]
89
+ end
90
+
91
+ def apply_js_runner(command)
92
+ return command unless command.start_with?('npx ')
93
+
94
+ command.sub('npx ', "#{@js_runner} ")
95
+ end
96
+
97
+ def quote(value)
98
+ str = value.to_s
99
+ return "''" if str.empty?
100
+
101
+ needs_quoting?(str) ? "'#{str.gsub("'", "''")}'" : str
102
+ end
103
+
104
+ def needs_quoting?(str)
105
+ str.match?(/[:#\[\]{}&*!|>'"@`,]/) ||
106
+ str.strip != str ||
107
+ str.empty? ||
108
+ YAML_BARE_WORDS.include?(str.downcase)
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'setup/catalog'
4
+ require_relative 'setup/detector'
5
+ require_relative 'setup/formatter'
6
+ require_relative 'setup/gemfile_lock'
7
+ require_relative 'setup/generator'
8
+
9
+ module Reviewer
10
+ # Handles first-run setup: detecting tools and generating .reviewer.yml
11
+ module Setup
12
+ # URL to the configuration documentation for setup output messages
13
+ CONFIG_URL = 'https://github.com/garrettdimon/reviewer#configuration'
14
+
15
+ # Runs the full setup flow: detect tools, generate config, display results
16
+ # @param project_dir [Pathname, String] the project root to scan (defaults to pwd)
17
+ # @param output [Output] the console output handler
18
+ #
19
+ # @return [void]
20
+ def self.run(configuration:, project_dir: Pathname.pwd, output: Output.new)
21
+ config_file = configuration.file
22
+ formatter = Formatter.new(output)
23
+
24
+ if config_file.exist?
25
+ formatter.setup_already_exists(config_file)
26
+ return
27
+ end
28
+
29
+ results = Detector.new(project_dir).detect
30
+
31
+ if results.empty?
32
+ formatter.setup_no_tools_detected
33
+ return
34
+ end
35
+
36
+ yaml = Generator.new(results.map(&:key), project_dir: project_dir).generate
37
+ config_file.write(yaml)
38
+ formatter.setup_success(results)
39
+ end
40
+ end
41
+ end
@@ -18,7 +18,16 @@ module Reviewer
18
18
  executable_not_found: "can't find executable"
19
19
  }.freeze
20
20
 
21
- attr_accessor :stdout, :stderr, :status, :exit_status
21
+ # @!attribute stdout
22
+ # @return [String, nil] standard output from the command
23
+ # @!attribute stderr
24
+ # @return [String, nil] standard error from the command
25
+ # @!attribute status
26
+ # @return [Process::Status, nil] the process status object
27
+ # @!attribute exit_status
28
+ # @return [Integer, nil] the exit status code from the command
29
+ attr_reader :stdout, :stderr, :status
30
+ attr_accessor :exit_status
22
31
 
23
32
  # An instance of a result from running a local command. Captures the values for `$stdout`,
24
33
  # `$stderr`, and the exit status of the command to provide a reliable way of interpreting
@@ -39,24 +48,18 @@ module Reviewer
39
48
  @exit_status = status&.exitstatus
40
49
  end
41
50
 
42
- def exists?
43
- [stdout, stderr, exit_status].compact.any?
44
- end
51
+ def exists? = [stdout, stderr, exit_status].compact.any?
45
52
 
46
53
  # Determines if re-running a command is entirely futile. Primarily to help when a command
47
54
  # fails within a batch and needs to be re-run to show the output
48
55
  #
49
56
  # @return [Boolean] true if the exit status code is greater than or equal to 126
50
- def rerunnable?
51
- exit_status < EXIT_STATUS_CODES[:cannot_execute]
52
- end
57
+ def rerunnable? = exit_status < EXIT_STATUS_CODES[:cannot_execute]
53
58
 
54
59
  # Determines whether a command simply cannot be executed.
55
60
  #
56
61
  # @return [Boolean] true if the exit sttaus code equals 126
57
- def cannot_execute?
58
- exit_status == EXIT_STATUS_CODES[:cannot_execute]
59
- end
62
+ def cannot_execute? = exit_status == EXIT_STATUS_CODES[:cannot_execute]
60
63
 
61
64
  # Determines whether the command failed because the executable cannot be found. Since this is
62
65
  # an error that can be corrected fairly predictably and easily, it provides the ability to
@@ -73,11 +76,7 @@ module Reviewer
73
76
  #
74
77
  # @return [String] stdout if present, otherwise stderr
75
78
  def to_s
76
- result_string = ''
77
- result_string += stderr
78
- result_string += stdout
79
-
80
- result_string.strip
79
+ [stderr, stdout].compact.join("\n").strip
81
80
  end
82
81
  end
83
82
  end
@@ -6,67 +6,72 @@ module Reviewer
6
6
  class Shell
7
7
  # Provides a structured interface for measuring realtime main while running comamnds
8
8
  class Timer
9
- attr_accessor :prep, :main
9
+ # @!attribute prep
10
+ # @return [Float, nil] the preparation time in seconds
11
+ # @!attribute main
12
+ # @return [Float, nil] the main execution time in seconds
13
+ attr_reader :prep, :main
10
14
 
11
- # A 'Smart' timer that understands preparation time and main time and can easily do the math
12
- # to help determine what percentage of time was prep. The times can be passed in directly or
13
- # recorded using the `record_prep` and `record_main` methods
14
- # @param prep: nil [Float] the amount of time in seconds the preparation command ran
15
- # @param main: nil [Float] the amount of time in seconds the primary command ran
15
+ # A timer that tracks preparation and main execution times separately.
16
+ # Times can be passed directly or recorded using `record_prep` and `record_main`.
17
+ # @param prep [Float, nil] the preparation time in seconds
18
+ # @param main [Float, nil] the main execution time in seconds
16
19
  #
17
- # @return [self]
20
+ # @return [Timer]
18
21
  def initialize(prep: nil, main: nil)
19
22
  @prep = prep
20
23
  @main = main
21
24
  end
22
25
 
23
26
  # Records the execution time for the block and assigns it to the `prep` time
24
- # @param &block [Block] the commands to be timed
27
+ # @param block [Block] the commands to be timed
25
28
  #
26
29
  # @return [Float] the execution time for the preparation
27
- def record_prep(&block)
28
- @prep = record(&block)
29
- end
30
+ def record_prep(&) = @prep = record(&)
30
31
 
31
32
  # Records the execution time for the block and assigns it to the `main` time
32
- # @param &block [Block] the commands to be timed
33
+ # @param block [Block] the commands to be timed
33
34
  #
34
35
  # @return [Float] the execution time for the main command
35
- def record_main(&block)
36
- @main = record(&block)
37
- end
36
+ def record_main(&) = @main = record(&)
38
37
 
39
- def prep_seconds
40
- prep.round(2)
41
- end
38
+ # The preparation time rounded to two decimal places
39
+ #
40
+ # @return [Float] prep time in seconds
41
+ def prep_seconds = prep.round(2)
42
42
 
43
- def main_seconds
44
- main.round(2)
45
- end
43
+ # The main execution time rounded to two decimal places
44
+ #
45
+ # @return [Float] main time in seconds
46
+ def main_seconds = main.round(2)
46
47
 
47
- def total_seconds
48
- total.round(2)
49
- end
48
+ # The total execution time (prep + main) rounded to two decimal places
49
+ #
50
+ # @return [Float] total time in seconds
51
+ def total_seconds = total.round(2)
52
+
53
+ # The total time (prep + main) without rounding
54
+ #
55
+ # @return [Float] total time in seconds
56
+ def total = [prep, main].compact.sum
57
+
58
+ # Whether both prep and main times have been recorded
59
+ #
60
+ # @return [Boolean] true if both phases were timed
61
+ def prepped? = [prep, main].all?
50
62
 
63
+ # The percentage of total time spent on preparation
64
+ #
65
+ # @return [Integer, nil] percentage (0-100) or nil if not prepped
51
66
  def prep_percent
52
67
  return nil unless prepped?
53
68
 
54
69
  (prep / total.to_f * 100).round
55
70
  end
56
71
 
57
- def total
58
- [prep, main].compact.sum
59
- end
60
-
61
- def prepped?
62
- !(prep.nil? || main.nil?)
63
- end
64
-
65
72
  private
66
73
 
67
- def record(&block)
68
- Benchmark.realtime(&block)
69
- end
74
+ def record(&) = Benchmark.realtime(&)
70
75
  end
71
76
  end
72
77
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'open3'
4
+ require 'pty'
4
5
 
5
6
  require_relative 'shell/result'
6
7
  require_relative 'shell/timer'
@@ -15,40 +16,68 @@ module Reviewer
15
16
  def_delegators :@result, :exit_status
16
17
 
17
18
  # Initializes a Reviewer shell for running and benchmarking commands, and capturing output
19
+ # @param stream [IO] the output stream for direct (passthrough) output
18
20
  #
19
21
  # @return [Shell] a shell instance for running and benchmarking commands
20
- def initialize
22
+ def initialize(stream: $stdout)
23
+ @stream = stream
21
24
  @timer = Timer.new
22
25
  @result = Result.new
26
+ @captured_results = nil
23
27
  end
24
28
 
25
- # Run a command without capturing the output. This ensures the results are displayed realtime
26
- # if the command was run directly in the shell. So it keeps any color or other formatting that
27
- # would be stripped out by capturing $stdout as a basic string.
29
+ # Run a command via PTY, streaming output in real-time while capturing it for later use
30
+ # (e.g. failed file extraction). PTY allocates a pseudo-terminal so the child process
31
+ # preserves ANSI colors and interactive behavior.
28
32
  # @param command [String] the command to run
29
33
  #
30
- # @return [Integer] exit status vaue of 0 when successful or 1 when unsuccessful
34
+ # @return [Result] the captured result including stdout and exit status
31
35
  def direct(command)
32
36
  command = String(command)
37
+ buffer = +''
33
38
 
34
- result.exit_status = system(command) ? 0 : 1
35
- end
39
+ reader, _writer, pid = PTY.spawn(command)
40
+ begin
41
+ reader.each_line do |line|
42
+ @stream.print line
43
+ buffer << line
44
+ end
45
+ rescue Errno::EIO
46
+ # Expected when child process exits before all output is read
47
+ end
36
48
 
37
- def capture_prep(command)
38
- timer.record_prep { capture_results(command) }
49
+ _, status = Process.waitpid2(pid)
50
+ @result = Result.new(buffer, nil, status)
51
+ rescue Errno::ENOENT
52
+ @result = Result.new(buffer, nil, nil)
53
+ @result.exit_status = Result::EXIT_STATUS_CODES[:executable_not_found]
39
54
  end
40
55
 
41
- def capture_main(command)
42
- timer.record_main { capture_results(command) }
43
- end
56
+ # Captures and times the preparation command execution
57
+ # @param command [String, Command] the command to run
58
+ #
59
+ # @return [Result] the captured result including stdout, stderr, and exit status
60
+ def capture_prep(command) = timer.record_prep { capture_results(command) }
61
+
62
+ # Captures and times the main command execution
63
+ # @param command [String, Command] the command to run
64
+ #
65
+ # @return [Result] the captured result including stdout, stderr, and exit status
66
+ def capture_main(command) = timer.record_main { capture_results(command) }
44
67
 
45
68
  private
46
69
 
70
+ # Open3.capture3 returns stdout, stderr, and status separately. Keeping them
71
+ # separate matters for FailedFiles, which merges the streams intentionally
72
+ # when scanning for file paths after a failure.
47
73
  def capture_results(command)
48
74
  command = String(command)
49
75
 
50
76
  @captured_results = Open3.capture3(command)
51
77
  @result = Result.new(*@captured_results)
78
+ rescue Errno::ENOENT
79
+ @result = Result.new(nil, nil, nil)
80
+ @result.exit_status = Result::EXIT_STATUS_CODES[:executable_not_found]
52
81
  end
53
82
  end
54
83
  end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reviewer
4
+ class Tool
5
+ # Conversion functions for coercing values to Tool instances
6
+ module Conversions
7
+ # Coerces a value into a Tool instance
8
+ # @param value [Tool] the value to convert
9
+ # @return [Tool] the resulting Tool instance
10
+ # @raise [TypeError] if the value is not a Tool
11
+ def Tool(value) # rubocop:disable Naming/MethodName
12
+ case value
13
+ in Tool then value
14
+ else raise TypeError, "Cannot convert #{value.class} to Tool"
15
+ end
16
+ end
17
+ module_function :Tool
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reviewer
4
+ class Tool
5
+ # Resolves which files a tool should process by mapping and filtering.
6
+ class FileResolver
7
+ # Creates a FileResolver for a tool's settings
8
+ # @param settings [Tool::Settings] the tool's settings containing file configuration
9
+ #
10
+ # @return [FileResolver] a resolver instance for the tool
11
+ def initialize(settings)
12
+ @settings = settings
13
+ end
14
+
15
+ # Resolves input files by mapping source files to test files (if configured) and
16
+ # filtering by the tool's file pattern
17
+ # @param files [Array<String>] the input files to resolve
18
+ #
19
+ # @return [Array<String>] files after mapping and filtering
20
+ def resolve(files)
21
+ return files unless pattern
22
+
23
+ filter(map(files))
24
+ end
25
+
26
+ # Determines if the tool should be skipped because files were requested but none match
27
+ # @param files [Array<String>] the requested files
28
+ #
29
+ # @return [Boolean] true if files were requested but none remain after resolution
30
+ def skip?(files)
31
+ files.any? && resolve(files).empty?
32
+ end
33
+
34
+ private
35
+
36
+ attr_reader :settings
37
+
38
+ def map(files)
39
+ mapper = settings.map_to_tests
40
+ return files unless mapper
41
+
42
+ TestFileMapper.new(mapper).map(files)
43
+ end
44
+
45
+ def filter(files)
46
+ files.select { |file| File.fnmatch(pattern, File.basename(file)) }
47
+ end
48
+
49
+ def pattern
50
+ settings.files_pattern
51
+ end
52
+ end
53
+ end
54
+ end