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 +4 -4
- data/CHANGELOG.md +29 -0
- data/docs/configuration.md +5 -2
- data/docs/usage.md +42 -7
- data/lib/reviewer/arguments/files/branched_files.rb +92 -0
- data/lib/reviewer/arguments/files.rb +20 -3
- data/lib/reviewer/arguments/keywords.rb +1 -1
- data/lib/reviewer/arguments/options.rb +3 -2
- data/lib/reviewer/arguments.rb +64 -2
- data/lib/reviewer/batch.rb +7 -2
- data/lib/reviewer/capabilities.rb +4 -1
- data/lib/reviewer/command.rb +11 -10
- data/lib/reviewer/session/formatter.rb +61 -4
- data/lib/reviewer/session.rb +16 -8
- data/lib/reviewer/shell/result.rb +11 -2
- data/lib/reviewer/version.rb +1 -1
- data/lib/reviewer.rb +24 -3
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9e521c12a7d3f686337af0ad6dff4adb7e1c0f6e7ec49e05ed244931bf27d14f
|
|
4
|
+
data.tar.gz: 779c50bbe81345a91f80d31b5b9c269d283fc8e3b0d54f5193e8e01e4ca9b0d3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
data/docs/configuration.md
CHANGED
|
@@ -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
|
|
110
|
-
|
|
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` |
|
|
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.
|
|
67
|
-
|
|
68
|
-
|
|
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` |
|
|
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
|
-
|
|
|
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
|
|
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
|
-
|
|
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',
|
|
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
|
data/lib/reviewer/arguments.rb
CHANGED
|
@@ -42,7 +42,11 @@ module Reviewer
|
|
|
42
42
|
# @return [self]
|
|
43
43
|
def initialize(options = ARGV, output: Output.new)
|
|
44
44
|
@output = output
|
|
45
|
-
@
|
|
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
|
data/lib/reviewer/batch.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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' => '
|
|
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
|
|
data/lib/reviewer/command.rb
CHANGED
|
@@ -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
|
|
76
|
+
# Determines if this command should be skipped for the requested file scope
|
|
77
77
|
#
|
|
78
|
-
# @return [Boolean] true if
|
|
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 ||=
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
}
|
data/lib/reviewer/session.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
data/lib/reviewer/version.rb
CHANGED
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
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|