reviewer 1.1.0 → 1.2.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8d918dfc0f53f9d2283d846207fc760f0aa0cafaa2d9db0f2225396935cdbd38
4
- data.tar.gz: 2f8635ce31fee7646608c5dd591c277190e918b3c940b773d04731254c3ea707
3
+ metadata.gz: 9e521c12a7d3f686337af0ad6dff4adb7e1c0f6e7ec49e05ed244931bf27d14f
4
+ data.tar.gz: 779c50bbe81345a91f80d31b5b9c269d283fc8e3b0d54f5193e8e01e4ca9b0d3
5
5
  SHA512:
6
- metadata.gz: fc770176795e88d1c88e56903eb1ce3d756f0f46176edc1d1a280bff14069b0739a27a4aefa6ca04313c08f1324e3f9d45da86860c9563c97f7a82472005f7ca
7
- data.tar.gz: dbfdb47cbc0c321bc2338251b4a16242d976d4b6fb893019e7dc5578d875bfc85ef4124297864fa50cbe52cf7189471e6fc2ece940e114cf92fe8ab0e429e369
6
+ metadata.gz: 764931418180d96bc1331f43a67c333b1788169f41584a3603684e1fbd1f801a00a72fc9a578a4052535327256281de299cb90d86419f1438ac4e7edc87bdab0
7
+ data.tar.gz: 4045484668eab50715cb5b1d61961db4ea9377a12c5e5bb1bd097425c7413cde923dc17cc6a1351b11d6f63336ebf1e33bdbbe2d6ccda0d130e447937d179a52
data/CHANGELOG.md CHANGED
@@ -1,5 +1,34 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [1.2.0] - 2026-09-24
4
+
5
+ ### Added
6
+
7
+ - `branched` keyword: selects every file that differs from `origin/HEAD` since the current work split
8
+ from it, including uncommitted and new files, so a whole branch can be reviewed before a pull
9
+ request. It never substitutes another branch: when it can't compare against `origin/HEAD`, it
10
+ stops with a usage error that names the fix or shows Git's message.
11
+
12
+ ### Fixed
13
+
14
+ - Report tool failures normally when a tool prints non-ASCII text under a non-UTF-8 locale (such as
15
+ `LC_ALL=C` or an unrecognized `LC_ALL`), or prints bytes that aren't valid UTF-8, instead of crashing
16
+ with an encoding error. Captured output is read as UTF-8 and invalid bytes are replaced.
17
+ - Include files with non-ASCII names in `staged`, `unstaged`, `modified` and `untracked` runs. Git's
18
+ escaped path names previously never matched the real files, so those files were silently skipped,
19
+ and a non-UTF-8 locale could crash the run.
20
+ - Report a mistyped or unsupported option, such as `rvw --list`, as a usage error with exit status 2
21
+ instead of crashing with a backtrace.
22
+ - Describe `modified` accurately in `--capabilities` and `--help`: it covers tracked files changed
23
+ since the last commit and excludes untracked files.
24
+
25
+ ## [1.1.1] - 2026-09-02
26
+
27
+ ### Fixed
28
+
29
+ - Keep explicit and Git-derived file-scoped runs focused by skipping tools that cannot accept files,
30
+ while preserving bare runs and failed retries; reject empty file options instead of running broadly.
31
+
3
32
  ## [1.1.0] - 2026-08-28
4
33
 
5
34
  Reviewer 1.1 makes review outcomes more precise and dependable for people and automated consumers.
@@ -106,8 +106,11 @@ rubocop:
106
106
  | `files.pattern` | Keeps matching paths before the command runs |
107
107
  | `files.map_to_tests` | Maps Ruby source paths to existing `minitest` or `rspec` test paths |
108
108
 
109
- Only tools with a `files:` block receive resolved file arguments; tools without one run their
110
- configured command unchanged.
109
+ Only tools with a `files:` block receive resolved file arguments. File-scoped requests skip tools
110
+ without one so `rvw staged`, `rvw modified`, and `rvw -f ...` cannot expand into full-project checks.
111
+ Bare `rvw` still runs every tool in the default batch. See the
112
+ [file-scoped behavior matrix](usage.md#file-scoped-behavior) for explicit scopes, composed Git
113
+ selectors, and failed-run precedence.
111
114
 
112
115
  Slashless patterns match each path's basename. Patterns containing `/` match normalized
113
116
  repository-relative paths with pathname semantics. In path patterns, `**/` crosses directory
data/docs/usage.md CHANGED
@@ -59,22 +59,36 @@ File selectors resolve paths from Git:
59
59
  |---|---|
60
60
  | `staged` | Staged changes |
61
61
  | `unstaged` | Unstaged changes |
62
- | `modified` | Staged and unstaged changes compared with `HEAD` |
62
+ | `modified` | Tracked files changed since the last commit (staged and unstaged); excludes untracked files |
63
63
  | `untracked` | Untracked, non-ignored files |
64
+ | `branched` | Every file that differs from `origin/HEAD` since this work split from it, including uncommitted and new files |
65
+
66
+ `branched` always compares against `origin/HEAD` and never substitutes another branch. When it
67
+ can't, it stops with a usage error rather than reviewing nothing:
68
+
69
+ - If `origin/HEAD` isn't set, run `git remote set-head origin --auto`.
70
+ - In a shallow clone, such as a default CI checkout, fetch the default branch with its history
71
+ (for example, `fetch-depth: 0`).
64
72
 
65
73
  `failed` selects tools whose last executed review failed and, when available, reuses each tool's
66
- stored failed file paths. A tool remains selected until an executed review records a pass. Skipped,
67
- missing, formatted, and not-run tools leave that review history unchanged, so a passing scoped run
68
- can coexist with tools still selected by `rvw failed`.
74
+ stored failed file paths. File-aware tools retry those paths; tools without file support retry their
75
+ full command. A tool remains selected until an executed review records a pass. Skipped, missing,
76
+ formatted, and not-run tools leave that review history unchanged, so a passing scoped run can coexist
77
+ with tools still selected by `rvw failed`.
69
78
 
70
79
  Selections compose:
71
80
 
72
81
  ```console
73
82
  rvw rubocop staged
74
83
  rvw -t ruby modified
84
+ rvw rubocop branched
75
85
  rvw tests -f test/reviewer_test.rb
76
86
  ```
77
87
 
88
+ Explicit `-f`, `staged`, `unstaged`, `modified`, `untracked`, and `branched` requests skip tools
89
+ without a `files:` configuration instead of running their full-project command. Bare `rvw` remains
90
+ the full configured review.
91
+
78
92
  The [configuration reference](configuration.md#file-targeting) describes filtering, file-scoped
79
93
  commands, and Minitest/RSpec source-to-test mapping.
80
94
 
@@ -117,7 +131,7 @@ State describes the terminal disposition Reviewer acts on. For executed failures
117
131
  |---|---|---:|---|
118
132
  | `passed` | The command executed within its configured threshold | `true` | None |
119
133
  | `failed` | The command executed outside its configured threshold | `false` | Exit `1` |
120
- | `skipped` | Requested files did not match the tool | `false` | None |
134
+ | `skipped` | The tool cannot accept the file scope, or no requested files matched | `false` | None |
121
135
  | `missing` | The executable was unavailable | `false` | None in 1.1 |
122
136
  | `not_run` | An earlier tool failed and fail-fast stopped the batch | `false` | None |
123
137
 
@@ -161,7 +175,10 @@ Every summary contains `total`, all five state counts, and `duration`; the state
161
175
  |---|---|---|
162
176
  | Nonempty report | `schema_version`, `success`, `summary`, `tools` | `state`, `message`, `error` |
163
177
  | Empty report | `schema_version`, `state: "empty"`, `success: true`, `message`, zero summary, `tools: []` | `error` |
164
- | Selector error | `schema_version`, `state: "error"`, `error`, zero summary, `tools: []` | `success`, `message` |
178
+ | Usage error | `schema_version`, `state: "error"`, `error`, zero summary, `tools: []` | `success`, `message` |
179
+
180
+ Usage errors include `error.code: "unrecognized_selector"` for an unknown tool, tag, or keyword and
181
+ `error.code: "missing_files"` for an empty or missing `-f`/`--files` value.
165
182
 
166
183
  | Tool state | Execution fields | Compatibility flags |
167
184
  |---|---|---|
@@ -175,13 +192,31 @@ tool now has `success: false`, and its unavailable execution fields are null ins
175
192
  omitted. Valid 1.x `Runner::Result.new` keyword calls and the legacy true-valued payload flags remain
176
193
  supported through the 1.x line.
177
194
 
195
+ ## File-scoped behavior
196
+
197
+ | Request | Tools with `files:` | Tools without `files:` |
198
+ |---|---|---|
199
+ | `rvw` | Run without a file filter | Run without a file filter |
200
+ | `rvw -f app/a.rb` | Run with `app/a.rb` | Skip |
201
+ | `rvw staged untracked` | Run with the deduplicated union | Skip |
202
+ | `rvw staged` with no staged files | Do not run | Do not run |
203
+ | `rvw failed` | Use stored failed paths when available; otherwise retry without a file filter | Retry without a file filter |
204
+ | `rvw failed staged` | Use the newly resolved staged paths instead of stored paths | Skip |
205
+ | `rvw failed -f app/a.rb` | Use `app/a.rb` instead of stored paths | Skip |
206
+
207
+ Repeated `-f` options and Git selectors compose. For review and format execution, every `-f` or
208
+ `--files` occurrence requires at least one path; an empty or missing value exits 2 without running
209
+ any tool. Informational `--help` and `--version` requests, plus the `init` and `doctor` control
210
+ commands, exit before file-scope validation. A valid Git selector that finds no files is instead a
211
+ successful no-op because the selector itself was complete.
212
+
178
213
  ## Exit statuses
179
214
 
180
215
  | Status | Meaning |
181
216
  |---|---|
182
217
  | `0` | Every executed tool passed; skipped, missing, and not-run tools do not fail the run |
183
218
  | `1` | At least one executed tool failed |
184
- | `2` | The request used an unrecognized tool, tag, or keyword |
219
+ | `2` | The request used an unrecognized selector or an empty `-f`/`--files` option |
185
220
 
186
221
  A tool may pass with a nonzero process status when its `commands.max_exit_status` allows it. Reviewer
187
222
  returns its own status above instead of forwarding the tool's process status.
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+
5
+ module Reviewer
6
+ class Arguments
7
+ class Files
8
+ # Selects the files for the `branched` keyword: every file that differs from origin/HEAD since
9
+ # this work split from it, including uncommitted and new files. It compares against origin/HEAD
10
+ # only, so when any git step fails, `missing` says why and no files are selected rather than
11
+ # substituting another branch or reviewing nothing.
12
+ class BranchedFiles
13
+ # The problem, then how to fix it. An unexpected git error has no known fix, so git's own
14
+ # message is shown instead.
15
+ MISSING_MESSAGES = {
16
+ origin_head: [
17
+ "Can't compare against origin/HEAD: origin/HEAD isn't set",
18
+ 'Run `git remote set-head origin --auto`, then try again.'
19
+ ],
20
+ merge_base: [
21
+ "Can't find where this work split from origin/HEAD",
22
+ 'Fetch the default branch with its history (for example, `fetch-depth: 0` in CI), then try again.'
23
+ ],
24
+ git_error: ["Git couldn't list the files that differ from origin/HEAD"]
25
+ }.freeze
26
+
27
+ # Why the files couldn't be selected. `reason` is :origin_head when origin/HEAD isn't set,
28
+ # :merge_base when HEAD shares no history with it (as in a shallow clone), or :git_error for
29
+ # any other git failure, whose message is kept in `detail`.
30
+ Missing = Struct.new(:reason, :detail) do
31
+ # @return [String] what went wrong
32
+ def problem = MISSING_MESSAGES.fetch(reason).first
33
+
34
+ # @return [String, nil] how to fix it, or git's message for an unexpected error
35
+ def fix = MISSING_MESSAGES.fetch(reason)[1] || detail
36
+
37
+ # @return [String] the problem and its fix as one line
38
+ def message = "#{problem}. #{fix}"
39
+ end
40
+
41
+ # @!attribute [r] files
42
+ # @return [Array<String>] the selected paths, empty when `missing` is set
43
+ # @!attribute [r] missing
44
+ # @return [Missing, nil] why the files couldn't be selected, or nil when they were
45
+ attr_reader :files, :missing
46
+
47
+ # Runs every git step immediately so both attributes reflect the same git state
48
+ #
49
+ # @return [self]
50
+ def initialize
51
+ @missing = nil
52
+ @files = select || []
53
+ end
54
+
55
+ private
56
+
57
+ def select
58
+ merge_base = find_merge_base
59
+ changed = merge_base && lookup(%W[diff --name-only #{merge_base}])
60
+ untracked = changed && lookup(%w[ls-files --others --exclude-standard])
61
+ return unless untracked
62
+
63
+ (changed.split("\n") + untracked.split("\n")).reject(&:empty?)
64
+ end
65
+
66
+ def find_merge_base
67
+ lookup(%w[rev-parse --verify --quiet refs/remotes/origin/HEAD], when_absent: :origin_head) &&
68
+ lookup(%w[merge-base HEAD origin/HEAD], when_absent: :merge_base)
69
+ end
70
+
71
+ # Git exits 1 when a ref or merge base doesn't exist. Any other failure is reported with git's
72
+ # own message, since the fix for an absent ref wouldn't apply to it. Output is read as UTF-8
73
+ # with paths unescaped, like the other git keywords.
74
+ def lookup(options, when_absent: :git_error)
75
+ stdout, stderr, status = Open3.capture3('git', '-c', 'core.quotePath=false', '--no-pager', *options)
76
+ case status.exitstatus
77
+ when 0 then Shell::Result.decode(stdout).strip
78
+ when 1 then record_missing(when_absent, stderr)
79
+ else record_missing(:git_error, stderr)
80
+ end
81
+ end
82
+
83
+ # Only an unexpected error keeps git's message; an absent ref or merge base has a known fix
84
+ def record_missing(reason, stderr)
85
+ detail = (Shell::Result.decode(stderr).lines.first&.strip if reason == :git_error)
86
+ @missing = Missing.new(reason, detail)
87
+ nil
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
@@ -2,6 +2,8 @@
2
2
 
3
3
  require 'open3'
4
4
 
5
+ require_relative 'files/branched_files'
6
+
5
7
  module Reviewer
6
8
  class Arguments
7
9
  # Generates a Ruby-friendly list (Array) of files to run the command against from the provided
@@ -56,6 +58,15 @@ module Reviewer
56
58
  end
57
59
  alias inspect to_h
58
60
 
61
+ # Why `branched` couldn't select files. `branched` never substitutes another ref, so the
62
+ # caller reports this instead of reviewing nothing.
63
+ #
64
+ # @return [BranchedFiles::Missing, nil] why the files couldn't be selected, or nil when they
65
+ # were or `branched` wasn't requested
66
+ def missing_base
67
+ branched_files.missing if keywords.include?('branched')
68
+ end
69
+
59
70
  private
60
71
 
61
72
  # Combines the sorted list of unique files/paths by merging the explicitly-provided file
@@ -101,15 +112,21 @@ module Reviewer
101
112
  git_files(%w[ls-files --others --exclude-standard])
102
113
  end
103
114
 
104
- # Executes a git command and returns the output as an array of file paths
115
+ def branched = branched_files.files
116
+
117
+ def branched_files = @branched_files ||= BranchedFiles.new
118
+
119
+ # Executes a git command and returns the output as an array of file paths. Git escapes
120
+ # non-ASCII paths unless `core.quotePath` is off, and its output is read as UTF-8 like any
121
+ # other command output so the paths match the real files regardless of locale.
105
122
  # @param options [Array<String>] the git command options
106
123
  #
107
124
  # @return [Array<String>] the output lines from the command
108
125
  def git_files(options)
109
- command = (%w[git --no-pager] + options).join(' ')
126
+ command = (%w[git -c core.quotePath=false --no-pager] + options).join(' ')
110
127
  stdout, stderr, status = Open3.capture3(command)
111
128
 
112
- return stdout.split("\n").reject(&:empty?) if status.success?
129
+ return Shell::Result.decode(stdout).split("\n").reject(&:empty?) if status.success?
113
130
 
114
131
  raise SystemCallError.new("Git Error: #{stderr} (#{command})", status.exitstatus.to_i)
115
132
  rescue SystemCallError => e
@@ -10,7 +10,7 @@ module Reviewer
10
10
  class Keywords
11
11
  # Keywords that resolve to a list of files. `Arguments::Files` implements one method per
12
12
  # entry, so a keyword here without a matching method silently contributes nothing.
13
- FOR_FILES = %w[staged unstaged modified untracked].freeze
13
+ FOR_FILES = %w[staged unstaged modified untracked branched].freeze
14
14
 
15
15
  # Keywords that select tools rather than files. `failed` reads `last_status` from history via
16
16
  # `Tools#failed_from_history`; the per-tool file scope comes from `Command#stored_failed_files`.
@@ -15,9 +15,10 @@ module Reviewer
15
15
  #
16
16
  # @return [void]
17
17
  # :reek:TooManyStatements -- a flag declaration list, not branching logic
18
- def configure(opts)
18
+ def configure(opts, files_callback: nil)
19
19
  # Narrowing what gets reviewed
20
- opts.array '-f', '--files', 'a list of comma-separated files or paths', delimiter: ',', default: []
20
+ opts.array '-f', '--files', 'a list of comma-separated files or paths',
21
+ delimiter: ',', default: [], &files_callback
21
22
  opts.array '-t', '--tags', 'a list of comma-separated tags', delimiter: ',', default: []
22
23
 
23
24
  # Selecting how results are displayed
@@ -42,7 +42,11 @@ module Reviewer
42
42
  # @return [self]
43
43
  def initialize(options = ARGV, output: Output.new)
44
44
  @output = output
45
- @options = Slop.parse(options) { |opts| Options.configure(opts) }
45
+ @raw_options = options.to_a.dup
46
+ @invalid_files_option = false
47
+ @parser = Slop::Options.new
48
+ Options.configure(@parser, files_callback: files_validation_callback)
49
+ @options = @parser.parse(normalized_options)
46
50
  end
47
51
 
48
52
  private
@@ -105,6 +109,16 @@ module Reviewer
105
109
  # @return [Boolean] true if JSON output mode is requested
106
110
  def json? = options[:json]
107
111
 
112
+ # Whether the --capabilities flag was passed
113
+ #
114
+ # @return [Boolean] true if capabilities were requested
115
+ def capabilities? = options[:capabilities]
116
+
117
+ # Whether a files option was supplied without a usable value
118
+ #
119
+ # @return [Boolean] true when any -f/--files occurrence is empty
120
+ def invalid_files_option? = @invalid_files_option
121
+
108
122
  # The output format for results
109
123
  #
110
124
  # @return [Symbol] the output format (:streaming, :summary, or :json)
@@ -114,7 +128,7 @@ module Reviewer
114
128
  value = options[:format].to_sym
115
129
  return value if KNOWN_FORMATS.include?(value)
116
130
 
117
- session_formatter.invalid_format(options[:format], KNOWN_FORMATS)
131
+ session_formatter.invalid_format(options[:format], KNOWN_FORMATS) unless invalid_files_option?
118
132
  :streaming
119
133
  end
120
134
 
@@ -133,5 +147,53 @@ module Reviewer
133
147
 
134
148
  multiple_tools ? Runner::Strategies::Captured : Runner::Strategies::Passthrough
135
149
  end
150
+
151
+ private
152
+
153
+ def files_validation_callback
154
+ previous_file_count = 0
155
+ lambda do |files|
156
+ current_files = Array(files)
157
+ file_count = current_files.length
158
+ new_files = current_files.drop(previous_file_count)
159
+ @invalid_files_option = true if file_count <= previous_file_count ||
160
+ new_files.all? { |file| file.strip.empty? }
161
+ previous_file_count = file_count
162
+ end
163
+ end
164
+
165
+ def normalized_options
166
+ parsing_options = true
167
+ @raw_options.each_with_index.flat_map do |token, index|
168
+ parsing_options = false if token == '--'
169
+ missing_value = parsing_options && files_option_awaiting_value?(token) &&
170
+ known_option_token?(@raw_options[index + 1])
171
+ normalized_token = parsing_options ? normalize_compact_files_option(token) : token
172
+
173
+ missing_value ? [normalized_token, ''] : [normalized_token]
174
+ end
175
+ end
176
+
177
+ def normalize_compact_files_option(token)
178
+ return token unless token.start_with?('-f') && token.length > 2 && !token.start_with?('-f=')
179
+
180
+ "-f=#{token.delete_prefix('-f')}"
181
+ end
182
+
183
+ def known_option_token?(token)
184
+ return false unless token&.start_with?('-') && token != '--'
185
+
186
+ Slop.parse([token]) { |opts| Options.configure(opts) }
187
+ true
188
+ rescue Slop::UnknownOption
189
+ false
190
+ end
191
+
192
+ def files_option_awaiting_value?(token)
193
+ return true if %w[-f --files].include?(token)
194
+ return false if token.start_with?('-f')
195
+
196
+ token.match?(/\A-[^-]+f\z/) && known_option_token?(token)
197
+ end
136
198
  end
137
199
  end
@@ -34,7 +34,7 @@ module Reviewer
34
34
  runnable_tools = matching_tools
35
35
  elapsed_time = Benchmark.realtime do
36
36
  failed_index = runnable_tools.find_index { |tool| run_tool(tool).failed? }
37
- record_not_run(runnable_tools.drop(failed_index + 1)) if failed_index
37
+ record_remaining(runnable_tools.drop(failed_index + 1)) if failed_index
38
38
  end
39
39
 
40
40
  @report.record_duration(elapsed_time)
@@ -43,8 +43,13 @@ module Reviewer
43
43
 
44
44
  private
45
45
 
46
- def record_not_run(tools)
46
+ def record_remaining(tools)
47
47
  tools.each do |tool|
48
+ if Command.new(tool, command_type, context: context).skip?
49
+ run_tool(tool)
50
+ next
51
+ end
52
+
48
53
  result = Runner::Result.not_run(tool: tool, command_type: command_type)
49
54
  @report.add(result)
50
55
  end
@@ -26,8 +26,10 @@ module Reviewer
26
26
  KEYWORD_DESCRIPTIONS = {
27
27
  'staged' => 'Files staged for commit',
28
28
  'unstaged' => 'Files with unstaged changes',
29
- 'modified' => 'All changed files',
29
+ 'modified' => 'Tracked files changed since the last commit (staged and unstaged); excludes untracked files',
30
30
  'untracked' => 'New files not yet tracked',
31
+ 'branched' => 'Every file that differs from origin/HEAD since this work split from it, ' \
32
+ 'including uncommitted and new files',
31
33
  'failed' => 'Tools whose last executed review failed and have not passed since'
32
34
  }.freeze
33
35
 
@@ -38,6 +40,7 @@ module Reviewer
38
40
  SCENARIOS = {
39
41
  before_commit: 'rvw staged',
40
42
  during_development: 'rvw modified',
43
+ before_pull_request: 'rvw branched',
41
44
  full_review: 'rvw'
42
45
  }.freeze
43
46
 
@@ -73,10 +73,12 @@ module Reviewer
73
73
  @target_files ||= tool.resolve_files(requested_files)
74
74
  end
75
75
 
76
- # Determines if this command should be skipped because files were requested but none match
76
+ # Determines if this command should be skipped for the requested file scope
77
77
  #
78
- # @return [Boolean] true if files were requested but resolution left none for this tool
78
+ # @return [Boolean] true if the tool cannot accept an explicit file scope or no files match it
79
79
  def skip?
80
+ return true if explicit_files.any? && !tool.supports_files?
81
+
80
82
  tool.skip_files?(requested_files)
81
83
  end
82
84
 
@@ -97,16 +99,15 @@ module Reviewer
97
99
  #
98
100
  # @return [Array<String>] files from -f flag, keywords like 'staged', or stored failed files
99
101
  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
102
+ @requested_files ||= if explicit_files.empty? && arguments.keywords.failed?
103
+ stored_failed_files
104
+ else
105
+ explicit_files
106
+ end
108
107
  end
109
108
 
109
+ def explicit_files = @explicit_files ||= arguments.files.to_a
110
+
110
111
  # Retrieves failed files stored from the last executed review for this tool
111
112
  #
112
113
  # @return [Array<String>] stored failed file paths, or empty array
@@ -8,6 +8,10 @@ module Reviewer
8
8
  class Formatter
9
9
  include Output::Formatting
10
10
 
11
+ MISSING_FILES_MESSAGE = 'The --files option requires at least one file or path.'
12
+
13
+ BRANCHED_HINT = "'branched' always compares against origin/HEAD and doesn't take a branch name"
14
+
11
15
  attr_reader :output, :printer
12
16
  private :output, :printer
13
17
 
@@ -23,31 +27,84 @@ module Reviewer
23
27
  # Displays warnings for keywords that don't match any tool or git scope
24
28
  # @param unrecognized [Array<String>] the unrecognized keyword strings
25
29
  # @param suggestions [Hash{String => String}] keyword => suggested correction
30
+ # @param hint [String, nil] a closing note about how the request was misread
26
31
  #
27
32
  # @return [void]
28
- def unrecognized_keywords(unrecognized, suggestions)
33
+ def unrecognized_keywords(unrecognized, suggestions, hint = nil)
29
34
  unrecognized.each do |keyword|
30
35
  printer.puts(:warning, "Unrecognized: #{keyword}")
31
36
  suggestion = suggestions[keyword]
32
37
  printer.puts(:muted, " did you mean '#{suggestion}'?") if suggestion
33
38
  end
39
+ printer.puts(:muted, hint) if hint
34
40
  output.newline
35
41
  end
36
42
 
37
43
  # Renders a machine-readable envelope for a name Reviewer cannot honour
38
44
  # @param unrecognized [Array<String>] the names that matched nothing
39
45
  # @param suggestions [Hash] closest known name per unrecognized name
46
+ # @param hint [String, nil] a note about how the request was misread, omitted when nil
40
47
  #
41
48
  # @return [void]
42
- def unrecognized_keywords_json(unrecognized, suggestions)
49
+ def unrecognized_keywords_json(unrecognized, suggestions, hint = nil)
43
50
  payload = {
44
51
  schema_version: Report::SCHEMA_VERSION,
45
52
  state: 'error',
46
53
  error: {
47
54
  code: 'unrecognized_selector',
48
55
  message: "Unrecognized: #{unrecognized.join(', ')}",
49
- suggestions: suggestions
50
- },
56
+ suggestions: suggestions,
57
+ hint: hint
58
+ }.compact,
59
+ summary: Report.empty_summary,
60
+ tools: []
61
+ }
62
+
63
+ printer.write_raw("#{JSON.pretty_generate(payload)}\n")
64
+ end
65
+
66
+ # Displays a usage error when -f/--files has no value
67
+ #
68
+ # @return [void]
69
+ def missing_files_option
70
+ printer.puts(:warning, MISSING_FILES_MESSAGE)
71
+ output.newline
72
+ end
73
+
74
+ # Renders the machine-readable form of the missing files usage error
75
+ #
76
+ # @return [void]
77
+ def missing_files_option_json
78
+ payload = {
79
+ schema_version: Report::SCHEMA_VERSION,
80
+ state: 'error',
81
+ error: { code: 'missing_files', message: MISSING_FILES_MESSAGE },
82
+ summary: Report.empty_summary,
83
+ tools: []
84
+ }
85
+
86
+ printer.write_raw("#{JSON.pretty_generate(payload)}\n")
87
+ end
88
+
89
+ # Displays a usage error when `branched` can't find where the work split from origin/HEAD
90
+ # @param missing [Arguments::Files::BranchedFiles::Missing] why the branched files couldn't be selected
91
+ #
92
+ # @return [void]
93
+ def missing_base(missing)
94
+ printer.puts(:warning, missing.problem)
95
+ printer.puts(:muted, missing.fix)
96
+ output.newline
97
+ end
98
+
99
+ # Renders the machine-readable form of the missing base usage error
100
+ # @param missing [Arguments::Files::BranchedFiles::Missing] why the branched files couldn't be selected
101
+ #
102
+ # @return [void]
103
+ def missing_base_json(missing)
104
+ payload = {
105
+ schema_version: Report::SCHEMA_VERSION,
106
+ state: 'error',
107
+ error: { code: 'missing_base', message: missing.message },
51
108
  summary: Report.empty_summary,
52
109
  tools: []
53
110
  }
@@ -45,11 +45,23 @@ module Reviewer
45
45
  def history = context.history
46
46
 
47
47
  def run_tools(command_type)
48
+ return reject_invalid_files_option if arguments.invalid_files_option?
48
49
  return reject_unrecognized_selectors if unrecognized_selectors.any?
50
+ return reject_missing_base if arguments.files.missing_base
49
51
 
50
52
  json_output? ? run_json(command_type) : run_text(command_type)
51
53
  end
52
54
 
55
+ def reject_invalid_files_option = reject_as_usage_error(:missing_files_option)
56
+
57
+ def reject_missing_base = reject_as_usage_error(:missing_base, arguments.files.missing_base)
58
+
59
+ # Renders a usage error through the formatter's text or JSON form, then returns its status
60
+ def reject_as_usage_error(formatter_method, *)
61
+ formatter.public_send(json_output? ? :"#{formatter_method}_json" : formatter_method, *)
62
+ USAGE_ERROR
63
+ end
64
+
53
65
  def run_json(command_type)
54
66
  message = json_early_exit_message
55
67
  return emit_json_early_exit(message) if message
@@ -86,15 +98,11 @@ module Reviewer
86
98
  # attributing that success to a tool that never ran.
87
99
  def reject_unrecognized_selectors
88
100
  names = unrecognized_selectors
89
- suggestions = build_suggestions(names)
90
-
91
- if json_output?
92
- formatter.unrecognized_keywords_json(names, suggestions)
93
- else
94
- formatter.unrecognized_keywords(names, suggestions)
95
- end
101
+ reject_as_usage_error(:unrecognized_keywords, names, build_suggestions(names), unrecognized_selector_hint)
102
+ end
96
103
 
97
- USAGE_ERROR
104
+ def unrecognized_selector_hint
105
+ Session::Formatter::BRANCHED_HINT if arguments.files.keywords.include?('branched')
98
106
  end
99
107
 
100
108
  # Positional selectors and `-t` values are the same request spelled two ways,
@@ -42,12 +42,21 @@ module Reviewer
42
42
  #
43
43
  # @return [self]
44
44
  def initialize(stdout = nil, stderr = nil, status = nil)
45
- @stdout = stdout
46
- @stderr = stderr
45
+ @stdout = self.class.decode(stdout)
46
+ @stderr = self.class.decode(stderr)
47
47
  @status = status
48
48
  @exit_status = status&.exitstatus
49
49
  end
50
50
 
51
+ # Ruby labels command output with the locale's encoding, which is US-ASCII under `LC_ALL=C`
52
+ # or an unrecognized locale. Tools overwhelmingly emit UTF-8, so output is read as UTF-8
53
+ # regardless of locale, and bytes that aren't valid UTF-8 are replaced so that later string
54
+ # operations can't raise. Passthrough output reaches the terminal before this, unchanged.
55
+ # @param text [String, nil] raw output from a command
56
+ #
57
+ # @return [String, nil] valid UTF-8 text
58
+ def self.decode(text) = text && String.new(text, encoding: Encoding::UTF_8).scrub
59
+
51
60
  def exists? = [stdout, stderr, exit_status].compact.any?
52
61
 
53
62
  # Determines if re-running a command is entirely futile. Primarily to help when a command
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Reviewer
4
- VERSION = '1.1.0'
4
+ VERSION = '1.2.0'
5
5
  end
data/lib/reviewer.rb CHANGED
@@ -41,6 +41,8 @@ module Reviewer
41
41
  # @return [void] Prints output to the console
42
42
  def review
43
43
  handle_early_exits { exit build_session.review }
44
+ rescue Slop::Error => e
45
+ reject_invalid_option(e.message)
44
46
  end
45
47
 
46
48
  # Runs the `format` command for the specified tools/files for which it is configured.
@@ -48,6 +50,8 @@ module Reviewer
48
50
  # @return [void] Prints output to the console
49
51
  def format
50
52
  handle_early_exits { exit build_session.format }
53
+ rescue Slop::Error => e
54
+ reject_invalid_option(e.message)
51
55
  end
52
56
 
53
57
  # The collection of arguments that were passed via the command line.
@@ -100,13 +104,28 @@ module Reviewer
100
104
  return Setup.run(configuration: configuration) if subcommand?(:init)
101
105
  return run_doctor if subcommand?(:doctor)
102
106
  return run_capabilities if capabilities_flag?
103
- return run_first_time_setup unless configuration.file.exist?
107
+ return run_first_time_setup if first_time_setup?
104
108
 
105
109
  yield
106
110
  end
107
111
 
112
+ # A mistyped or unsupported option is a usage error, not a crash
113
+ # @param message [String] the parser's description of the invalid option
114
+ #
115
+ # @return [void]
116
+ def reject_invalid_option(message)
117
+ output.printer.puts(:warning, message)
118
+ output.printer.puts(:muted, 'Run `rvw --help` for usage.')
119
+ exit Session::USAGE_ERROR
120
+ end
121
+
122
+ def first_time_setup? = !arguments.invalid_files_option? && !configuration.file.exist?
123
+
108
124
  def subcommand?(name) = ARGV.first == name.to_s
109
- def capabilities_flag? = ARGV.include?('--capabilities') || ARGV.include?('-c')
125
+
126
+ def capabilities_flag?
127
+ !arguments.invalid_files_option? && arguments.capabilities?
128
+ end
110
129
 
111
130
  def show_help
112
131
  output.help(help_text)
@@ -128,8 +147,9 @@ module Reviewer
128
147
  Keywords:
129
148
  staged Files staged for commit
130
149
  unstaged Files with unstaged changes
131
- modified All changed files (staged + unstaged)
150
+ modified Tracked files changed since the last commit
132
151
  untracked New files not yet tracked by git
152
+ branched All files that differ from origin/HEAD, including new ones
133
153
  failed Re-run tools whose last executed review failed
134
154
 
135
155
  Options:
@@ -139,6 +159,7 @@ module Reviewer
139
159
  rvw rubocop Run RuboCop only
140
160
  rvw staged Review staged files across all tools
141
161
  rvw -t security modified Run security-tagged tools on changed files
162
+ rvw branched Review everything this branch changes before a PR
142
163
  rvw tests -f test/user_test.rb Run tests on a specific file
143
164
  rvw failed Re-run tools whose last executed review failed
144
165
  fmt rubocop Auto-fix with RuboCop
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: reviewer
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.0
4
+ version: 1.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Garrett Dimon
@@ -190,6 +190,7 @@ files:
190
190
  - lib/reviewer.rb
191
191
  - lib/reviewer/arguments.rb
192
192
  - lib/reviewer/arguments/files.rb
193
+ - lib/reviewer/arguments/files/branched_files.rb
193
194
  - lib/reviewer/arguments/keywords.rb
194
195
  - lib/reviewer/arguments/options.rb
195
196
  - lib/reviewer/arguments/tags.rb