reviewer 0.1.3 → 0.1.4

Sign up to get free protection for your applications and to get access to all the features.
Files changed (55) hide show
  1. checksums.yaml +4 -4
  2. data/.flayignore +1 -0
  3. data/.github/workflows/main.yml +11 -3
  4. data/.gitignore +5 -0
  5. data/.reviewer.example.yml +27 -23
  6. data/.reviewer.yml +58 -5
  7. data/.rubocop.yml +2 -0
  8. data/.ruby-version +1 -1
  9. data/CHANGELOG.md +14 -0
  10. data/Gemfile +0 -3
  11. data/Gemfile.lock +54 -29
  12. data/README.md +5 -50
  13. data/exe/fmt +1 -1
  14. data/exe/rvw +1 -1
  15. data/lib/reviewer.rb +39 -26
  16. data/lib/reviewer/arguments.rb +25 -9
  17. data/lib/reviewer/arguments/files.rb +37 -5
  18. data/lib/reviewer/arguments/keywords.rb +23 -9
  19. data/lib/reviewer/arguments/tags.rb +26 -3
  20. data/lib/reviewer/batch.rb +64 -0
  21. data/lib/reviewer/command.rb +100 -0
  22. data/lib/reviewer/command/string.rb +72 -0
  23. data/lib/reviewer/command/string/env.rb +40 -0
  24. data/lib/reviewer/command/string/flags.rb +40 -0
  25. data/lib/reviewer/command/string/verbosity.rb +51 -0
  26. data/lib/reviewer/command/verbosity.rb +65 -0
  27. data/lib/reviewer/configuration.rb +24 -4
  28. data/lib/reviewer/conversions.rb +27 -0
  29. data/lib/reviewer/guidance.rb +73 -0
  30. data/lib/reviewer/history.rb +38 -0
  31. data/lib/reviewer/keywords.rb +9 -0
  32. data/lib/reviewer/keywords/git.rb +14 -0
  33. data/lib/reviewer/keywords/git/staged.rb +48 -0
  34. data/lib/reviewer/loader.rb +2 -3
  35. data/lib/reviewer/output.rb +92 -0
  36. data/lib/reviewer/printer.rb +25 -0
  37. data/lib/reviewer/runner.rb +43 -72
  38. data/lib/reviewer/runner/strategies/quiet.rb +90 -0
  39. data/lib/reviewer/runner/strategies/verbose.rb +63 -0
  40. data/lib/reviewer/shell.rb +58 -0
  41. data/lib/reviewer/shell/result.rb +69 -0
  42. data/lib/reviewer/shell/timer.rb +57 -0
  43. data/lib/reviewer/tool.rb +109 -40
  44. data/lib/reviewer/tool/settings.rb +18 -32
  45. data/lib/reviewer/tools.rb +38 -3
  46. data/lib/reviewer/version.rb +1 -1
  47. data/reviewer.gemspec +10 -2
  48. metadata +143 -16
  49. data/lib/reviewer/arguments/keywords/git.rb +0 -16
  50. data/lib/reviewer/arguments/keywords/git/staged.rb +0 -64
  51. data/lib/reviewer/logger.rb +0 -62
  52. data/lib/reviewer/tool/command.rb +0 -80
  53. data/lib/reviewer/tool/env.rb +0 -38
  54. data/lib/reviewer/tool/flags.rb +0 -38
  55. data/lib/reviewer/tool/verbosity.rb +0 -39
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml/store'
4
+
5
+ module Reviewer
6
+ # Handles the logic around what to display after a command has been run
7
+ class Guidance
8
+ attr_reader :command, :result, :output
9
+
10
+ # Create an instance of guidance for suggesting recovery steps after errors
11
+ # @param command: [Command] the command that was run and needs recovery guidance
12
+ # @param result: [Result] the result of the command
13
+ # @param output: Reviewer.output [Output] the output channel for displaying content
14
+ #
15
+ # @return [Guidance] the guidance class to suggest relevant recovery steps
16
+ def initialize(command:, result:, output: Reviewer.output)
17
+ @command = command
18
+ @result = result
19
+ @output = output
20
+ end
21
+
22
+ # Prints the relevant guidance based on the command and result context
23
+ #
24
+ # @return [void] prints the relevant guidance to the stream
25
+ def show
26
+ case result
27
+ when executable_not_found? then show_missing_executable_guidance
28
+ when cannot_execute? then show_unrecoverable_guidance
29
+ else show_syntax_guidance
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ # Conditional check for when the command result was that the executable couldn't be found
36
+ #
37
+ # @return [Boolean] true if the result indicates the command couldn't be found
38
+ def executable_not_found?
39
+ ->(result) { result.executable_not_found? }
40
+ end
41
+
42
+ # Conditional check for when the command result was that it was unable to be executed
43
+ #
44
+ # @return [Boolean] true if the result indicates the command couldn't be executed
45
+ def cannot_execute?
46
+ ->(result) { result.cannot_execute? }
47
+ end
48
+
49
+ # Shows the recovery guidance for when a command is missing
50
+ #
51
+ # @return [void] prints missing executable guidance
52
+ def show_missing_executable_guidance
53
+ output.missing_executable_guidance(command)
54
+ end
55
+
56
+ # Shows the recovery guidance for when a command generates an unrecoverable error
57
+ #
58
+ # @return [void] prints unrecoverable error guidance
59
+ def show_unrecoverable_guidance
60
+ output.unrecoverable(result.stderr)
61
+ end
62
+
63
+ # Shows suggestions for ignoring or disable rules when a command fails after reviewing code
64
+ #
65
+ # @return [void] prints syntax guidance
66
+ def show_syntax_guidance
67
+ output.syntax_guidance(
68
+ ignore_link: command.tool.links[:ignore_syntax],
69
+ disable_link: command.tool.links[:disable_syntax]
70
+ )
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml/store'
4
+
5
+ module Reviewer
6
+ # Provides an instance of a storage resource for persisting data across runs
7
+ class History
8
+ attr_reader :file, :store
9
+
10
+ def initialize(file = Reviewer.configuration.history_file)
11
+ @file = file
12
+ @store = YAML::Store.new(file)
13
+ end
14
+
15
+ def set(group, attribute, value)
16
+ store.transaction do |s|
17
+ s[group] = {} if s[group].nil?
18
+ s[group][attribute] = value
19
+ end
20
+ end
21
+
22
+ def get(group, attribute)
23
+ store.transaction do |s|
24
+ s[group].nil? ? nil : s[group][attribute]
25
+ end
26
+ end
27
+
28
+ def reset!
29
+ return unless File.exist?(file)
30
+
31
+ FileUtils.rm(file)
32
+ end
33
+
34
+ def self.reset!
35
+ new.reset!
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'keywords/git'
4
+
5
+ module Keywords
6
+ # Grouping for individual commands
7
+ module Git
8
+ end
9
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'git/staged'
4
+
5
+ module Reviewer
6
+ module Keywords
7
+ module Git
8
+ BASE_COMMAND = [
9
+ 'git',
10
+ '--no-pager'
11
+ ].freeze
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reviewer
4
+ module Keywords
5
+ module Git
6
+ # Provides a convenient interface to get the list of staged files via Git
7
+ class Staged
8
+ OPTIONS = [
9
+ 'diff',
10
+ '--staged',
11
+ '--name-only'
12
+ ].freeze
13
+
14
+ attr_reader :stdout, :stderr, :status, :exit_status
15
+
16
+ def to_a
17
+ stdout.strip.empty? ? [] : stdout.split("\n")
18
+ end
19
+
20
+ def list
21
+ @stdout, @stderr, @status = Open3.capture3(command)
22
+ @exit_status = @status.exitstatus.to_i
23
+
24
+ @status.success? ? to_a : raise_command_line_error
25
+ end
26
+
27
+ def self.list
28
+ new.list
29
+ end
30
+
31
+ def command
32
+ command_parts.join(' ')
33
+ end
34
+
35
+ private
36
+
37
+ def raise_command_line_error
38
+ message = "Git Error: #{stderr} (#{command})"
39
+ raise SystemCallError.new(message, exit_status)
40
+ end
41
+
42
+ def command_parts
43
+ BASE_COMMAND + OPTIONS
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'yaml'
4
- require 'active_support/core_ext/hash/indifferent_access'
5
4
 
6
5
  module Reviewer
7
6
  # Provides a collection of the configured tools
@@ -16,7 +15,7 @@ module Reviewer
16
15
 
17
16
  def initialize(file = Reviewer.configuration.file)
18
17
  @file = file
19
- @configuration = HashWithIndifferentAccess.new(configuration_hash)
18
+ @configuration = configuration_hash
20
19
 
21
20
  validate_configuration!
22
21
  end
@@ -50,7 +49,7 @@ module Reviewer
50
49
  end
51
50
 
52
51
  def configuration_hash
53
- @configuration_hash ||= YAML.load_file(@file)
52
+ @configuration_hash ||= Psych.safe_load_file(@file, symbolize_names: true)
54
53
  rescue Errno::ENOENT
55
54
  raise MissingConfigurationError, "Tools configuration file couldn't be found at `#{file}`"
56
55
  rescue Psych::SyntaxError => e
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'colorize'
4
+
5
+ module Reviewer
6
+ # Friendly API for printing nicely-formatted output to the console
7
+ class Output
8
+ SUCCESS = 'Success'
9
+ FAILURE = 'Failure ·'
10
+ DIVIDER = ('-' * 60).to_s
11
+
12
+ attr_reader :printer
13
+
14
+ def initialize(printer: Reviewer.configuration.printer)
15
+ @printer = printer
16
+ end
17
+
18
+ def info(message)
19
+ printer.info message
20
+ end
21
+
22
+ def blank_line
23
+ printer.info
24
+ end
25
+
26
+ def divider
27
+ blank_line
28
+ printer.info DIVIDER.light_black
29
+ blank_line
30
+ end
31
+
32
+ def tool_summary(tool)
33
+ printer.info "\n#{tool.name}".bold + ' · '.light_black + tool.description
34
+ end
35
+
36
+ def current_command(command)
37
+ command = String(command)
38
+
39
+ printer.info "\nNow Running:"
40
+ printer.info command.light_black
41
+ end
42
+
43
+ def exit_status(value)
44
+ failure("Exit Status #{value}")
45
+ end
46
+
47
+ def success(timer)
48
+ message = SUCCESS.green.bold + " #{timer.total_seconds}s".green
49
+ message += " (#{timer.prep_percent}% preparation)".yellow if timer.prepped?
50
+
51
+ printer.info message
52
+ end
53
+
54
+ def failure(details, command: nil)
55
+ printer.error "#{FAILURE} #{details}".red.bold
56
+
57
+ return if command.nil?
58
+
59
+ blank_line
60
+ printer.error 'Failed Command:'.red.bold
61
+ printer.error String(command).light_black
62
+ end
63
+
64
+ def unrecoverable(details)
65
+ printer.error 'Unrecoverable Error:'.red.bold
66
+ printer.error details
67
+ end
68
+
69
+ def guidance(summary, details)
70
+ return if details.nil?
71
+
72
+ blank_line
73
+ printer.info summary
74
+ printer.info details.to_s.light_black
75
+ end
76
+
77
+ def missing_executable_guidance(command)
78
+ tool = command.tool
79
+ installation_command = Command.new(tool, :install, :no_silence).string if tool.installable?
80
+ install_link = tool.install_link
81
+
82
+ failure("Missing executable for '#{tool}'", command: command)
83
+ guidance('Try installing the tool:', installation_command)
84
+ guidance('Read the installation guidance:', install_link)
85
+ end
86
+
87
+ def syntax_guidance(ignore_link: nil, disable_link: nil)
88
+ guidance('Selectively Ignore a Rule:', ignore_link)
89
+ guidance('Fully Disable a Rule:', disable_link)
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reviewer
4
+ # Clean formatter for logging to $stdout
5
+ class StandardOutFormatter < ::Logger::Formatter
6
+ # Overrides ::Logger::Formatter `call` to present output more concisely
7
+ # @param _severity [Logger::Severity] Unused - Logger severity for etnry
8
+ # @param _time [DateTime] Unused - Timestamp for entry
9
+ # @param _progname [String] Unused - Name of the current program for entry
10
+ # @param message [String] The string to print to $stdout
11
+ #
12
+ # @return [type] [description]
13
+ def call(_severity, _time, _progname, message)
14
+ "#{message}\n"
15
+ end
16
+ end
17
+
18
+ # Logger for $stdout
19
+ class Printer < ::Logger
20
+ def initialize(formatter = StandardOutFormatter.new)
21
+ super($stdout)
22
+ @formatter = formatter
23
+ end
24
+ end
25
+ end
@@ -1,102 +1,73 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'open3'
3
+ require_relative 'runner/strategies/quiet'
4
+ require_relative 'runner/strategies/verbose'
4
5
 
5
6
  module Reviewer
6
- # Handles running, benchmarking, and printing output for a single command
7
+ # Wrapper for executng a command and printing the results
7
8
  class Runner
8
- EXECUTABLE_NOT_FOUND_EXIT_STATUS_CODE = 127
9
+ extend Forwardable
9
10
 
10
- attr_accessor :tool, :command
11
+ attr_accessor :strategy
11
12
 
12
- attr_reader :elapsed_time,
13
- :last_command_run,
14
- :stdout,
15
- :stderr,
16
- :status,
17
- :exit_status,
18
- :logger
13
+ attr_reader :command, :shell, :output
19
14
 
20
- def initialize(tool, command, logger: Logger.new)
21
- @tool = tool
22
- @command = command
23
- @logger = logger
15
+ def_delegators :@command, :tool
16
+ def_delegators :@shell, :result, :timer
17
+ def_delegators :result, :exit_status
18
+
19
+ def initialize(tool, command_type, strategy = Strategies::Quiet, output: Reviewer.output)
20
+ @command = Command.new(tool, command_type)
21
+ @strategy = strategy
22
+ @shell = Shell.new
23
+ @output = output
24
24
  end
25
25
 
26
26
  def run
27
- logger.running(tool)
27
+ # Show which tool is about to run
28
+ output.tool_summary(tool)
28
29
 
29
- @elapsed_time = Benchmark.realtime do
30
- tool.format_command? ? run_format : run_review
30
+ # Run the provided strategy
31
+ strategy.new(self).tap do |run_strategy|
32
+ run_strategy.prepare if run_prepare_step?
33
+ run_strategy.run
31
34
  end
32
35
 
33
- print_result
34
- exit_status
35
- end
36
+ # If it failed,
37
+ guidance.show unless success?
36
38
 
37
- private
38
-
39
- def shell_out(cmd)
40
- @stdout, @stderr, @status = Open3.capture3(cmd)
41
- @exit_status = status.exitstatus
42
- @last_command_run = cmd
43
- end
44
-
45
- def run_review
46
- shell_out(tool.preparation_command) if tool.prepare_command?
47
- shell_out(tool.review_command(seed: seed))
48
- end
49
-
50
- def run_format
51
- shell_out(tool.format_command) if tool.format_command?
52
- end
53
-
54
- def review_verbosely
55
- cmd = tool.review_command(:no_silence, seed: seed)
56
- logger.rerunning(tool, cmd)
57
- system(cmd)
58
- end
59
-
60
- def print_result
61
- if status.success?
62
- logger.success(elapsed_time)
63
- else
64
- recovery_guidance
65
- end
39
+ exit_status
66
40
  end
67
41
 
68
- def recovery_guidance
69
- logger.failure(error_message)
70
- logger.command(last_command_run)
71
- if missing_executable?
72
- missing_executable_guidance
42
+ def success?
43
+ # Some review tools return a range of non-zero exit statuses and almost never return 0.
44
+ # (`yarn audit` is a good example.) Those tools can be configured to accept a non-zero exit
45
+ # status so they aren't constantly considered to be failing over minor issues.
46
+ #
47
+ # But when other command types (prepare, install, format) are run, they either succeed or they
48
+ # fail. With no shades of gray in those cases, anything other than a 0 is a failure.
49
+ if command.type == :review
50
+ exit_status <= tool.max_exit_status
73
51
  else
74
- review_verbosely
52
+ exit_status.zero?
75
53
  end
76
54
  end
77
55
 
78
- def error_message
79
- if missing_executable?
80
- "Missing executable for '#{tool}'"
81
- else
82
- "Exit Status #{exit_status}"
83
- end
56
+ def run_prepare_step?
57
+ command.type != :prepare && tool.prepare?
84
58
  end
85
59
 
86
- def missing_executable_guidance
87
- logger.guidance('Try installing the tool:', tool.installation_command) if tool.install_command?
88
- logger.guidance('Read the installation guidance:', tool.settings.links[:install]) if tool.install_link?
60
+ def prepare_command
61
+ @prepare_command ||= Command.new(tool, :prepare, command.verbosity)
89
62
  end
90
63
 
91
- def missing_executable?
92
- (@exit_status == EXECUTABLE_NOT_FOUND_EXIT_STATUS_CODE) ||
93
- stderr.include?("can't find executable")
64
+ def update_last_prepared_at
65
+ # Touch the `last_prepared_at` timestamp for the tool so it waits before running again.
66
+ tool.last_prepared_at = Time.now
94
67
  end
95
68
 
96
- def seed
97
- # Keep the same seed for each instance so re-running generates the same results as the failure.
98
- # Otherwise, re-running after the failure will change the seed and show different results.
99
- @seed ||= Random.rand(100_000)
69
+ def guidance
70
+ @guidance ||= Reviewer::Guidance.new(command: command, result: result, output: output)
100
71
  end
101
72
  end
102
73
  end