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
@@ -18,34 +18,48 @@ module Reviewer
18
18
  executable_not_found: "can't find executable"
19
19
  }.freeze
20
20
 
21
- attr_accessor :stdout, :stderr, :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
- # An instance of a result from running a local command
32
+ # An instance of a result from running a local command. Captures the values for `$stdout`,
33
+ # `$stderr`, and the exit status of the command to provide a reliable way of interpreting
34
+ # the results for commands that otherwise use these values inconsistently.
24
35
  # @param stdout = nil [String] standard out output from a command
25
36
  # @param stderr = nil [String] standard error output from a command
26
37
  # @param status = nil [ProcessStatus] an instance of ProcessStatus for a command
27
38
  #
28
- # @return [Shell::Result] result from running a command-line command
39
+ # @example Using with `Open3.capture3`
40
+ # captured_results = Open3.capture3(command)
41
+ # result = Result.new(*captured_results)
42
+ #
43
+ # @return [self]
29
44
  def initialize(stdout = nil, stderr = nil, status = nil)
30
45
  @stdout = stdout
31
46
  @stderr = stderr
47
+ @status = status
32
48
  @exit_status = status&.exitstatus
33
49
  end
34
50
 
35
- # Determines whether re-running a command is entirely futile. Primarily to help when a command
51
+ def exists? = [stdout, stderr, exit_status].compact.any?
52
+
53
+ # Determines if re-running a command is entirely futile. Primarily to help when a command
36
54
  # fails within a batch and needs to be re-run to show the output
37
55
  #
38
56
  # @return [Boolean] true if the exit status code is greater than or equal to 126
39
- def total_failure?
40
- exit_status >= EXIT_STATUS_CODES[:cannot_execute]
41
- end
57
+ def rerunnable? = exit_status < EXIT_STATUS_CODES[:cannot_execute]
42
58
 
43
59
  # Determines whether a command simply cannot be executed.
44
60
  #
45
61
  # @return [Boolean] true if the exit sttaus code equals 126
46
- def cannot_execute?
47
- exit_status == EXIT_STATUS_CODES[:cannot_execute]
48
- end
62
+ def cannot_execute? = exit_status == EXIT_STATUS_CODES[:cannot_execute]
49
63
 
50
64
  # Determines whether the command failed because the executable cannot be found. Since this is
51
65
  # an error that can be corrected fairly predictably and easily, it provides the ability to
@@ -62,7 +76,7 @@ module Reviewer
62
76
  #
63
77
  # @return [String] stdout if present, otherwise stderr
64
78
  def to_s
65
- stderr.strip.empty? ? stdout : stderr
79
+ [stderr, stdout].compact.join("\n").strip
66
80
  end
67
81
  end
68
82
  end
@@ -6,52 +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
 
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
19
+ #
20
+ # @return [Timer]
11
21
  def initialize(prep: nil, main: nil)
12
22
  @prep = prep
13
23
  @main = main
14
24
  end
15
25
 
16
- def record_prep(&block)
17
- @prep = record(&block)
18
- end
26
+ # Records the execution time for the block and assigns it to the `prep` time
27
+ # @param block [Block] the commands to be timed
28
+ #
29
+ # @return [Float] the execution time for the preparation
30
+ def record_prep(&) = @prep = record(&)
19
31
 
20
- def record_main(&block)
21
- @main = record(&block)
22
- end
32
+ # Records the execution time for the block and assigns it to the `main` time
33
+ # @param block [Block] the commands to be timed
34
+ #
35
+ # @return [Float] the execution time for the main command
36
+ def record_main(&) = @main = record(&)
23
37
 
24
- def prep_seconds
25
- prep.round(2)
26
- 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)
27
42
 
28
- def main_seconds
29
- main.round(2)
30
- 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)
31
47
 
32
- def total_seconds
33
- total.round(2)
34
- 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
35
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?
62
+
63
+ # The percentage of total time spent on preparation
64
+ #
65
+ # @return [Integer, nil] percentage (0-100) or nil if not prepped
36
66
  def prep_percent
37
67
  return nil unless prepped?
38
68
 
39
69
  (prep / total.to_f * 100).round
40
70
  end
41
71
 
42
- def total
43
- [prep, main].compact.sum
44
- end
45
-
46
- def prepped?
47
- !(prep.nil? || main.nil?)
48
- end
49
-
50
72
  private
51
73
 
52
- def record(&block)
53
- Benchmark.realtime(&block)
54
- end
74
+ def record(&) = Benchmark.realtime(&)
55
75
  end
56
76
  end
57
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'
@@ -10,49 +11,73 @@ module Reviewer
10
11
  class Shell
11
12
  extend Forwardable
12
13
 
13
- attr_reader :timer, :result
14
+ attr_reader :timer, :result, :captured_results
14
15
 
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 the same as
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
- result.exit_status = print_results(command) ? 0 : 1
33
- end
36
+ command = String(command)
37
+ buffer = +''
34
38
 
35
- def capture_prep(command)
36
- timer.record_prep { capture_results(command) }
37
- 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
38
48
 
39
- def capture_main(command)
40
- timer.record_main { 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]
41
54
  end
42
55
 
43
- private
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) }
44
61
 
45
- def capture_results(command)
46
- command = String(command)
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) }
47
67
 
48
- captured_results = Open3.capture3(command)
49
- @result = Result.new(*captured_results)
50
- end
68
+ private
51
69
 
52
- def print_results(command)
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.
73
+ def capture_results(command)
53
74
  command = String(command)
54
75
 
55
- system(command)
76
+ @captured_results = Open3.capture3(command)
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]
56
81
  end
57
82
  end
58
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
@@ -2,80 +2,131 @@
2
2
 
3
3
  module Reviewer
4
4
  class Tool
5
- # Converts/casts tool configuration values and provides default values if not set.
5
+ # Converts/casts tool configuration values and provides appropriate default values if not set.
6
6
  class Settings
7
7
  attr_reader :tool_key, :config
8
8
 
9
9
  alias key tool_key
10
10
 
11
- def initialize(tool_key, config: nil)
11
+ # Creates an instance of settings for retrieving values from the configuration file
12
+ # @param tool_key [Symbol] the unique identifier for the tool in the config file
13
+ # @param config [Hash] the configuration values to examine for the settings
14
+ #
15
+ # @return [Settings]
16
+ def initialize(tool_key, config:)
12
17
  @tool_key = tool_key.to_sym
13
- @config = config || load_config
18
+ @config = config
14
19
  end
15
20
 
16
- def hash
17
- state.hash
18
- end
21
+ # Returns a hash code for comparing settings instances
22
+ #
23
+ # @return [Integer] hash code based on configuration state
24
+ def hash = state.hash
19
25
 
26
+ # Compares two settings instances for equality based on their configuration
27
+ # @param other [Settings] the settings to compare against
28
+ # @return [Boolean] true if both have the same configuration
20
29
  def eql?(other)
21
30
  self.class == other.class &&
22
31
  state == other.state
23
32
  end
24
33
  alias :== eql?
25
34
 
26
- def disabled?
27
- config.fetch(:disabled, false)
28
- end
29
-
30
- def enabled?
31
- !disabled?
32
- end
33
-
34
- def name
35
- config.fetch(:name) { tool_key.to_s.capitalize }
36
- end
37
-
38
- def description
39
- config.fetch(:description) { "(No description provided for '#{name}')" }
40
- end
41
-
42
- def tags
43
- config.fetch(:tags) { [] }
44
- end
45
-
46
- def links
47
- config.fetch(:links) { {} }
48
- end
49
-
50
- def env
51
- config.fetch(:env) { {} }
52
- end
53
-
54
- def flags
55
- config.fetch(:flags) { {} }
56
- end
57
-
58
- def commands
59
- config.fetch(:commands) { {} }
60
- end
61
-
62
- def max_exit_status
63
- commands.fetch(:max_exit_status, 0)
64
- end
65
-
66
- def quiet_option
67
- commands.fetch(:quiet_option, '')
68
- end
35
+ def skip_in_batch?
36
+ if config.key?(:skip_in_batch)
37
+ config.fetch(:skip_in_batch) { false }
38
+ else
39
+ config.fetch(:disabled) { false }
40
+ end
41
+ end
42
+
43
+ def disabled? = skip_in_batch?
44
+ def enabled? = !skip_in_batch?
45
+
46
+ # The human-readable name of the tool
47
+ #
48
+ # @return [String] the configured name or capitalized tool key
49
+ def name = config.fetch(:name) { tool_key.to_s.capitalize }
50
+
51
+ # The human-readable description of what the tool does
52
+ #
53
+ # @return [String] the configured description or a default placeholder
54
+ def description = config.fetch(:description) { "(No description provided for '#{name}')" }
55
+
56
+ # The tags used to categorize and filter the tool
57
+ #
58
+ # @return [Array<String>] configured tags or empty array
59
+ def tags = config.fetch(:tags) { [] }
60
+
61
+ # The collection of reference links for the tool (home, install, usage, etc.)
62
+ #
63
+ # @return [Hash] configured links or empty hash if none
64
+ def links = config.fetch(:links) { {} }
65
+
66
+ # The environment variables to set when running the tool
67
+ #
68
+ # @return [Hash] configured env vars or empty hash
69
+ def env = config.fetch(:env) { {} }
70
+
71
+ # The CLI flags to pass to the tool's review command
72
+ #
73
+ # @return [Hash] configured flags or empty hash
74
+ def flags = config.fetch(:flags) { {} }
75
+
76
+ # The CLI flag used to pass files to the tool (e.g., '--files')
77
+ #
78
+ # @return [String] the configured flag or empty string if files are passed directly
79
+ def files_flag = config.dig(:files, :flag) || ''
80
+
81
+ # The separator used to join multiple file paths in the command
82
+ #
83
+ # @return [String] the configured separator or a space by default
84
+ def files_separator = config.dig(:files, :separator) || ' '
85
+
86
+ # The glob pattern used to filter which files this tool should process
87
+ #
88
+ # @return [String, nil] the pattern (e.g., '*.rb') or nil if not configured
89
+ def files_pattern = config.dig(:files, :pattern)
90
+
91
+ # The test framework to use for mapping source files to test files
92
+ #
93
+ # @return [String, nil] the framework name ('minitest' or 'rspec') or nil if not configured
94
+ def map_to_tests = config.dig(:files, :map_to_tests)
95
+
96
+ def supports_files? = config.key?(:files)
97
+
98
+ # The regex pattern for extracting a summary detail from tool output
99
+ #
100
+ # @return [String, nil] the configured pattern or nil
101
+ def summary_pattern = config.dig(:summary, :pattern)
102
+
103
+ # The label template for displaying the extracted summary detail
104
+ #
105
+ # @return [String, nil] the configured label or nil
106
+ def summary_label = config.dig(:summary, :label)
107
+
108
+ # Returns the file-scoped command override for a given command type.
109
+ # When configured, this command replaces the standard command when files are passed.
110
+ #
111
+ # @param command_type [Symbol] the command type (:review, :format)
112
+ # @return [String, nil] the file-scoped command or nil if not configured
113
+ def files_command(command_type) = config.dig(:files, command_type)
114
+
115
+ # The collection of configured commands for the tool
116
+ #
117
+ # @return [Hash] all of the commands configured for the tool
118
+ def commands = config.fetch(:commands) { {} }
119
+
120
+ # The largest exit status that can still be considered a success for the command
121
+ #
122
+ # @return [Integer] the configured `max_exit_status` for the tool or 0 if one isn't configured
123
+ def max_exit_status = commands.fetch(:max_exit_status) { 0 }
69
124
 
70
125
  protected
71
126
 
72
- def state
73
- config.to_hash
74
- end
75
-
76
- def load_config
77
- Reviewer.tools.to_h.fetch(key) { {} }
78
- end
127
+ # Returns the configuration as a plain hash for comparison
128
+ # @return [Hash] the configuration state
129
+ def state = config.to_hash
79
130
  end
80
131
  end
81
132
  end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reviewer
4
+ class Tool
5
+ # Maps source files to their corresponding test files based on framework conventions.
6
+ class TestFileMapper
7
+ FRAMEWORKS = {
8
+ minitest: { dir: 'test', suffix: '_test.rb', source_dirs: %w[app lib] },
9
+ rspec: { dir: 'spec', suffix: '_spec.rb', source_dirs: %w[app lib] }
10
+ }.freeze
11
+
12
+ # Creates a mapper for the specified test framework
13
+ # @param framework [Symbol, String, nil] the test framework (:minitest or :rspec)
14
+ #
15
+ # @return [TestFileMapper] a mapper instance for the framework
16
+ def initialize(framework)
17
+ @framework = framework&.to_sym
18
+ end
19
+
20
+ # Maps source files to their corresponding test files
21
+ # @param files [Array<String>] source files to map
22
+ #
23
+ # @return [Array<String>] mapped test files (only those that exist on disk)
24
+ def map(files)
25
+ return files unless supported?
26
+
27
+ files.map { |file| map_file(file) }.compact.uniq
28
+ end
29
+
30
+ # Checks if the framework is supported for mapping
31
+ #
32
+ # @return [Boolean] true if the framework is :minitest or :rspec
33
+ def supported?
34
+ @framework && FRAMEWORKS.key?(@framework)
35
+ end
36
+
37
+ private
38
+
39
+ def map_file(file)
40
+ return file if test_file?(file)
41
+
42
+ mapped = source_to_test(file)
43
+ mapped && File.exist?(mapped) ? mapped : nil
44
+ end
45
+
46
+ def test_file?(file)
47
+ file.end_with?(config[:suffix])
48
+ end
49
+
50
+ def source_to_test(file)
51
+ return nil unless file.end_with?('.rb')
52
+
53
+ replace_source_dir_with_test_dir(file).sub(/\.rb$/, config[:suffix])
54
+ end
55
+
56
+ def replace_source_dir_with_test_dir(path)
57
+ config[:source_dirs].each do |dir|
58
+ return path.sub(%r{^#{dir}/}, "#{config[:dir]}/") if path.start_with?("#{dir}/")
59
+ end
60
+ prepend_test_dir(path)
61
+ end
62
+
63
+ def prepend_test_dir(path)
64
+ dir = config[:dir]
65
+ path.start_with?(dir) ? path : "#{dir}/#{path}"
66
+ end
67
+
68
+ def config
69
+ FRAMEWORKS[@framework]
70
+ end
71
+ end
72
+ end
73
+ end