rubocop-vicenzo 0.8.0 → 0.9.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: '094cfb18e36f28e2f311e80f435b1b5159f22875cfb8f13b5a0551ca9b5e9218'
4
- data.tar.gz: b96a9634c956e7cec399e10a2f7f512a5f2894822ba07fecf7294a502fee56b5
3
+ metadata.gz: e5b813b05ed3c69c557d8b01bb90a4f142cbbb4ff06240d2df571f23ca5b720b
4
+ data.tar.gz: c121942b02a939b96de5a264fd53db21ebba6dd85e85c9e95424538e59c5240c
5
5
  SHA512:
6
- metadata.gz: ba554984ec7092154352415043f2868cd59bd89015de29b0098e01c1f0b2016b8b3a837845cd22e8219e1115b37f4b6e80eb3d5861d0f3325c0ad2894466de23
7
- data.tar.gz: 031a968654d68f7fa65e7a57a6178fc97651ae8c20cb4861f25a1f4c9feb5c6d044a3bc7e269e4de392a9a38483ce51296517f852abe61e88ba804ad723140b4
6
+ metadata.gz: 3d6832e1b1d8a011f1ded3b21451e79f1722234bd7424ccf418a59550a79b20edd4b8a3632345df748982b356b32d868d530fe96267ce5b81689f32d8e9559ae
7
+ data.tar.gz: b2513e3ab2f35243a88029d8f08ff2bd1bd29c08552c305e919cb76b10717e113cccf837276bc77b390bb87b5f34b276b08415adc1fd64c05566028107f65471
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "0.8.0"
2
+ ".": "0.9.0"
3
3
  }
data/.rubocop.yml CHANGED
@@ -1,3 +1,5 @@
1
+ inherit_from: config/style.yml
2
+
1
3
  plugins:
2
4
  - rubocop-performance
3
5
  - rubocop-rake
@@ -5,39 +7,17 @@ plugins:
5
7
  - rubocop-vicenzo
6
8
  - rubocop-internal_affairs
7
9
 
8
- AllCops:
9
- Exclude:
10
- - 'bin/**/*'
11
- - 'vendor/**/*'
12
- NewCops: enable
13
-
14
10
  InternalAffairs/UndefinedConfig: # False positive because conflicts with Rubocop Layout namespace
15
11
  Exclude:
16
12
  - 'lib/rubocop/cop/vicenzo/layout/**/*'
17
13
 
18
- Metrics/BlockLength:
19
- Exclude:
20
- - 'rakelib/**/*'
21
- - 'Rakefile'
22
-
23
14
  Naming/FileName:
24
15
  Exclude:
25
16
  - lib/rubocop-vicenzo.rb
26
17
 
27
- RSpec/ContextWording:
28
- Prefixes:
29
- - and
30
- - but
31
- - when
32
- - with
33
- - without
34
-
35
18
  RSpec/ExampleLength:
36
19
  Enabled: false
37
20
 
38
- RSpec/NestedGroups:
39
- Enabled: false
40
-
41
21
  Vicenzo/RSpec/LeakyDefinition:
42
22
  Exclude:
43
23
  - 'spec/support/**/*'
data/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.9.0](https://github.com/bvicenzo/rubocop-vicenzo/compare/v0.8.0...v0.9.0) (2026-09-23)
4
+
5
+
6
+ ### Features
7
+
8
+ * add Vicenzo/Naming/AnonymousUnusedName cop ([#33](https://github.com/bvicenzo/rubocop-vicenzo/issues/33)) ([fabbad6](https://github.com/bvicenzo/rubocop-vicenzo/commit/fabbad655dac0c362924dd0aaf245abd2c7023e1))
9
+ * add Vicenzo/Naming/ShortName cop and an opinionated config/style.yml ([#35](https://github.com/bvicenzo/rubocop-vicenzo/issues/35)) ([1b67f46](https://github.com/bvicenzo/rubocop-vicenzo/commit/1b67f4643b4348f5768a8fbb3b88cea35046e5e2))
10
+ * add Vicenzo/RSpec/DescribeInsideContext cop ([#37](https://github.com/bvicenzo/rubocop-vicenzo/issues/37)) ([3f3d6ae](https://github.com/bvicenzo/rubocop-vicenzo/commit/3f3d6aeb95d087e32594e8ded3d1ce0e3715dee7))
11
+ * add Vicenzo/RSpec/UnnestedContextImproperStart cop ([#36](https://github.com/bvicenzo/rubocop-vicenzo/issues/36)) ([0a8fb0f](https://github.com/bvicenzo/rubocop-vicenzo/commit/0a8fb0fec62370ac331b9a3275b95a0b22998b1d))
12
+
3
13
  ## [0.8.0](https://github.com/bvicenzo/rubocop-vicenzo/compare/v0.7.0...v0.8.0) (2026-09-09)
4
14
 
5
15
 
data/README.md CHANGED
@@ -11,7 +11,7 @@ Add it to the `development`/`test` group of your `Gemfile`:
11
11
  <!-- x-release-please-start-version -->
12
12
  ```ruby
13
13
  group :development, :test do
14
- gem 'rubocop-vicenzo', '~> 0.8.0', require: false
14
+ gem 'rubocop-vicenzo', '~> 0.9.0', require: false
15
15
  end
16
16
  ```
17
17
  <!-- x-release-please-end-version -->
@@ -64,6 +64,31 @@ cops together with the standard cops.
64
64
  rubocop --plugin rubocop-vicenzo
65
65
  ```
66
66
 
67
+ ## Opinionated style guide
68
+
69
+ Loading the plugin gives you the `Vicenzo/*` cops and nothing else: their defaults
70
+ live in `config/default.yml` and are applied automatically.
71
+
72
+ The gem also ships `config/style.yml`, an opinionated configuration of the cops the
73
+ `Vicenzo/*` ones build on — line length, multiline layout, metrics, and the naming
74
+ rules that back `Vicenzo/Naming/ShortName` (notably `Naming/BlockParameterName`,
75
+ which RuboCop ships with `MinNameLength: 1` and which therefore never fires). It is
76
+ **opt-in**:
77
+
78
+ ```yaml
79
+ inherit_gem:
80
+ rubocop-vicenzo: config/style.yml
81
+ ```
82
+
83
+ That single line brings the configuration **and** the plugins it builds on
84
+ (`rubocop-rspec` and this gem's own cops), so there is no `plugins:` key left for you
85
+ to write.
86
+
87
+ > [!NOTE]
88
+ > `AllCops: CacheRootDirectory` is not inheritable. RuboCop reads it from the
89
+ > top-level config file before resolving `inherit_from`/`inherit_gem`, so it has to
90
+ > stay in your own `.rubocop.yml`.
91
+
67
92
  ## Development
68
93
 
69
94
  After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
data/Rakefile CHANGED
@@ -4,8 +4,8 @@ require 'bundler/setup'
4
4
  require 'bundler/gem_tasks'
5
5
  require 'rspec/core/rake_task'
6
6
 
7
- RSpec::Core::RakeTask.new(:spec) do |t|
8
- t.rspec_opts = '--format progress'
7
+ RSpec::Core::RakeTask.new(:spec) do |rspec_task|
8
+ rspec_task.rspec_opts = '--format progress'
9
9
  end
10
10
 
11
11
  require 'rubocop/rake_task'
data/config/default.yml CHANGED
@@ -5,6 +5,19 @@ Vicenzo/Layout/MultilineMethodCallLineBreaks:
5
5
  IndentationWidth: 2
6
6
  VersionAdded: '0.3.0'
7
7
 
8
+ Vicenzo/Naming/AnonymousUnusedName:
9
+ Description: 'An unused declaration must not be anonymous; name it after what it stands for.'
10
+ Enabled: true
11
+ Severity: convention
12
+ VersionAdded: '0.9.0'
13
+
14
+ Vicenzo/Naming/ShortName:
15
+ Description: 'A name too short to carry meaning; spell out whole words from the domain.'
16
+ Enabled: true
17
+ Severity: convention
18
+ MinNameLength: 3
19
+ VersionAdded: '0.9.0'
20
+
8
21
  Vicenzo/Rails/EnumInclusionOfValidation:
9
22
  Description: 'Check if the enum has the inclusion of validation defined.'
10
23
  Enabled: true
@@ -45,6 +58,17 @@ Vicenzo/RSpec/DerivedPremises:
45
58
  - '**/spec/factories/**/*'
46
59
  VersionAdded: '0.6.0'
47
60
 
61
+ Vicenzo/RSpec/DescribeInsideContext:
62
+ Description: 'A describe must not be nested in a context, which is a scenario of the behaviour a describe names.'
63
+ Enabled: true
64
+ Severity: warning
65
+ Include:
66
+ - '**/spec/**/*_spec.rb'
67
+ Exclude:
68
+ - '**/spec/support/**/*'
69
+ - '**/spec/factories/**/*'
70
+ VersionAdded: '0.9.0'
71
+
48
72
  Vicenzo/RSpec/DynamicExampleGeneration:
49
73
  Description: 'Do not use iteration to dynamically generate example groups or examples.'
50
74
  Enabled: true
@@ -194,6 +218,21 @@ Vicenzo/RSpec/TopLevelSubject:
194
218
  - '**/spec/factories/**/*'
195
219
  VersionAdded: '0.8.0'
196
220
 
221
+ Vicenzo/RSpec/UnnestedContextImproperStart:
222
+ Description: 'Check if a context with no parent scenario starts with a conjunction.'
223
+ Enabled: true
224
+ Severity: convention
225
+ ForbiddenPrefixes:
226
+ - and
227
+ - but
228
+ - however
229
+ Include:
230
+ - '**/spec/**/*_spec.rb'
231
+ Exclude:
232
+ - '**/spec/support/**/*'
233
+ - '**/spec/factories/**/*'
234
+ VersionAdded: '0.9.0'
235
+
197
236
  Vicenzo/Style/JsonParseSymbolizeNames:
198
237
  Description: 'Enforces passing `symbolize_names: true` to `JSON.parse` so keys are symbols.'
199
238
  Enabled: true
data/config/style.yml ADDED
@@ -0,0 +1,228 @@
1
+ plugins:
2
+ - rubocop-vicenzo
3
+ - rubocop-rspec
4
+
5
+ AllCops:
6
+ SuggestExtensions: false
7
+ NewCops: enable
8
+ Exclude:
9
+ - 'node_modules/**/*'
10
+ - 'vendor/**/*'
11
+ - 'bin/**/*'
12
+ - '.git/**/*'
13
+ - 'db/**/*'
14
+
15
+ ##### LAYOUT #####
16
+
17
+ Layout/ArgumentAlignment:
18
+ Enabled: true
19
+ EnforcedStyle: with_fixed_indentation
20
+
21
+ Layout/CaseIndentation:
22
+ Enabled: true
23
+ EnforcedStyle: end
24
+
25
+ Layout/EndAlignment:
26
+ Enabled: true
27
+ EnforcedStyleAlignWith: variable
28
+
29
+ Layout/FirstArgumentIndentation:
30
+ Enabled: true
31
+ EnforcedStyle: consistent
32
+
33
+ Layout/FirstArrayElementIndentation:
34
+ Enabled: true
35
+ EnforcedStyle: consistent
36
+
37
+ Layout/FirstHashElementIndentation:
38
+ Enabled: true
39
+ EnforcedStyle: consistent
40
+
41
+ Layout/FirstMethodArgumentLineBreak:
42
+ Enabled: true
43
+
44
+ Layout/LineLength:
45
+ Max: 120
46
+ # (\A|\s) -> Start of line or whitespace
47
+ # # -> The hash character
48
+ # (?!\{) -> Negative lookahead: NOT followed by `{` (prevents matching interpolation)
49
+ AllowedPatterns: ['(\A|\s)#(?!\{)']
50
+
51
+ Layout/MultilineMethodArgumentLineBreaks:
52
+ Enabled: true
53
+
54
+ Layout/MultilineMethodCallBraceLayout:
55
+ Enabled: true
56
+ EnforcedStyle: new_line
57
+
58
+ Layout/MultilineMethodCallIndentation:
59
+ Enabled: true
60
+ EnforcedStyle: indented
61
+
62
+ Layout/FirstArrayElementLineBreak:
63
+ Enabled: true
64
+
65
+ Layout/MultilineArrayLineBreaks:
66
+ Enabled: true
67
+
68
+ Layout/MultilineArrayBraceLayout:
69
+ Enabled: true
70
+ EnforcedStyle: new_line
71
+
72
+ Layout/FirstHashElementLineBreak:
73
+ Enabled: true
74
+
75
+ Layout/MultilineHashKeyLineBreaks:
76
+ Enabled: true
77
+
78
+ Layout/MultilineHashBraceLayout:
79
+ Enabled: true
80
+ EnforcedStyle: new_line
81
+
82
+ ##### STYLE #####
83
+
84
+ Style/PreferredHashMethods:
85
+ Enabled: true
86
+ Safe: false
87
+ EnforcedStyle: verbose
88
+
89
+ Style/SymbolArray:
90
+ Enabled: true
91
+ EnforcedStyle: percent
92
+ MinSize: 3
93
+
94
+ Style/WordArray:
95
+ Enabled: true
96
+ EnforcedStyle: percent
97
+ MinSize: 3
98
+
99
+ Style/MultilineBlockChain:
100
+ Enabled: false
101
+
102
+ Style/Lambda:
103
+ Enabled: true
104
+ EnforcedStyle: literal
105
+
106
+ Style/LambdaCall:
107
+ Enabled: false
108
+
109
+ Style/Documentation:
110
+ Enabled: false
111
+
112
+ ##### LINT #####
113
+
114
+ Lint/MissingSuper:
115
+ Enabled: false
116
+
117
+ ##### NAMING #####
118
+
119
+ Naming/InclusiveLanguage:
120
+ Enabled: false
121
+
122
+ Naming/PredicatePrefix:
123
+ ForbiddenPrefixes:
124
+ - is_
125
+ - have_
126
+ # allows `has_` predicate
127
+
128
+ # A single letter says nothing about what was rescued.
129
+ Naming/RescuedExceptionsVariableName:
130
+ Enabled: true
131
+ PreferredName: error
132
+
133
+ # Vicenzo/Naming/ShortName covers local variables. Block parameters are the core
134
+ # cop's job, but it ships with MinNameLength: 1 and therefore never fires — raise
135
+ # it so both halves of the rule agree.
136
+ Naming/BlockParameterName:
137
+ MinNameLength: 3
138
+
139
+ ###### METRICS #####
140
+
141
+ Metrics/AbcSize:
142
+ Enabled: true
143
+ Max: 22
144
+
145
+ Metrics/BlockLength:
146
+ Enabled: true
147
+ CountComments: false
148
+ CountAsOne:
149
+ - array
150
+ - hash
151
+ - heredoc
152
+ - method_call
153
+ AllowedMethods:
154
+ - 'describe'
155
+ - 'context'
156
+ - 'it'
157
+ - 'let'
158
+ - 'subject'
159
+ - 'namespace'
160
+ - 'task'
161
+ - 'configure'
162
+ Exclude:
163
+ - '**/*.gemspec'
164
+ - 'Rakefile'
165
+ - 'rakelib/**/*'
166
+ - 'spec/**/*'
167
+
168
+ Metrics/ClassLength:
169
+ Enabled: true
170
+ Max: 200
171
+ CountAsOne:
172
+ - array
173
+ - hash
174
+ - heredoc
175
+ - method_call
176
+
177
+ Metrics/MethodLength:
178
+ Enabled: true
179
+ CountAsOne:
180
+ - array
181
+ - hash
182
+ - heredoc
183
+ - method_call
184
+ Max: 20
185
+
186
+ Metrics/ModuleLength:
187
+ Enabled: true
188
+ Max: 200
189
+ CountAsOne:
190
+ - array
191
+ - hash
192
+ - heredoc
193
+ - method_call
194
+
195
+ ##### RSPEC #####
196
+
197
+ RSpec/ContextWording:
198
+ Prefixes:
199
+ - and
200
+ - but
201
+ - when
202
+ - with
203
+ - without
204
+
205
+ RSpec/ExampleLength:
206
+ Max: 20
207
+ CountAsOne:
208
+ - array
209
+ - hash
210
+ - heredoc
211
+ - method_call
212
+
213
+ RSpec/MultipleMemoizedHelpers:
214
+ Max: 10
215
+
216
+ RSpec/NestedGroups:
217
+ Enabled: false
218
+
219
+ RSpec/PredicateMatcher:
220
+ Strict: false
221
+
222
+ ##### VICENZO #####
223
+
224
+ Vicenzo/Style/MultilineMethodCallParentheses:
225
+ AllowedMethods:
226
+ - to
227
+ - not_to
228
+ - to_not
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Vicenzo
6
+ module Naming
7
+ # Requires every declaration to carry a name, even when the value it
8
+ # holds is not used.
9
+ #
10
+ # A bare `_` states that a value is being ignored, but erases what that
11
+ # value is. Unused today does not mean unusable tomorrow: when the code
12
+ # grows, nobody reading the signature can tell whether the discarded
13
+ # value is the one the new problem needs.
14
+ #
15
+ # The cop only demands that a name exists — it cannot judge whether a
16
+ # name is a good one. That is the author's call, and the offense message
17
+ # asks for a name taken from the domain (`_previous_owner`) rather than
18
+ # one taken from the shape of the value (`_value`, `_key`, `_item`).
19
+ #
20
+ # Anonymous forwarding parameters (`*`, `**`, `&`, `...`) are ignored:
21
+ # they forward arguments instead of discarding them, and `Style/ArgumentsForwarding`
22
+ # actively pushes code towards that form.
23
+ #
24
+ # @example block parameters
25
+ # # bad
26
+ # catalog.each { |_, copies| copies.each(&:reserve) }
27
+ #
28
+ # # good
29
+ # catalog.each { |_isbn, copies| copies.each(&:reserve) }
30
+ #
31
+ # @example destructured block parameters
32
+ # # bad
33
+ # entries.each { |(_, id)| track(id) }
34
+ #
35
+ # # good
36
+ # entries.each { |(_label, id)| track(id) }
37
+ #
38
+ # @example method parameters
39
+ # # bad
40
+ # def notify(reader, _)
41
+ # reader.deliver
42
+ # end
43
+ #
44
+ # # good
45
+ # def notify(reader, _delivery_channel)
46
+ # reader.deliver
47
+ # end
48
+ #
49
+ # @example multiple assignment
50
+ # # bad
51
+ # title, _ = line.split(';')
52
+ #
53
+ # # good
54
+ # title, _author = line.split(';')
55
+ #
56
+ # @example rescued errors
57
+ # # bad
58
+ # begin
59
+ # shelf.reorder
60
+ # rescue ShelfLocked => _
61
+ # shelf.skip
62
+ # end
63
+ #
64
+ # # good
65
+ # begin
66
+ # shelf.reorder
67
+ # rescue ShelfLocked => _lock_conflict
68
+ # shelf.skip
69
+ # end
70
+ #
71
+ # @example anonymous forwarding (ignored)
72
+ # # good
73
+ # def notify(*)
74
+ # channel.deliver(*)
75
+ # end
76
+ #
77
+ # # good
78
+ # def notify(...)
79
+ # channel.deliver(...)
80
+ # end
81
+ class AnonymousUnusedName < Base
82
+ MSG = 'Name this %<subject>s: an unused value still means something, and `_` hides what. ' \
83
+ 'Name it after what it stands for in the domain (`_previous_owner`), not after its ' \
84
+ 'shape (`_value`, `_key`, `_item`) — that tells the next reader nothing.'
85
+
86
+ BLOCK_PARAMETER = 'block parameter'
87
+ METHOD_PARAMETER = 'method parameter'
88
+ DISCARDED_VALUE = 'discarded value'
89
+ RESCUED_ERROR = 'rescued error'
90
+
91
+ ANONYMOUS_NAME_PATTERN = /\A_+\z/
92
+
93
+ NAMED_ARGUMENT_TYPES = %i[arg optarg restarg kwarg kwoptarg kwrestarg blockarg shadowarg].freeze
94
+ DEFAULT_VALUE_ARGUMENT_TYPES = %i[optarg kwoptarg].freeze
95
+
96
+ # Numbered (`_1`) and implicit (`it`) block parameters are never
97
+ # declared, so they carry no name to check — both node types answer
98
+ # `arguments` with an empty list.
99
+ def on_block(node)
100
+ check_arguments(node.arguments, BLOCK_PARAMETER)
101
+ end
102
+ alias on_numblock on_block
103
+ alias on_itblock on_block
104
+
105
+ def on_def(node)
106
+ check_arguments(node.arguments, METHOD_PARAMETER)
107
+ end
108
+ alias on_defs on_def
109
+
110
+ def on_masgn(node)
111
+ node.lhs.assignments.each do |assignment|
112
+ next unless assignment.respond_to?(:lvasgn_type?) && assignment.lvasgn_type?
113
+
114
+ register_offense(assignment, DISCARDED_VALUE) if anonymous_name?(assignment.children.first)
115
+ end
116
+ end
117
+
118
+ def on_resbody(node)
119
+ error = node.exception_variable
120
+ return if error.nil?
121
+
122
+ register_offense(error, RESCUED_ERROR) if anonymous_name?(error.children.first)
123
+ end
124
+
125
+ private
126
+
127
+ def check_arguments(arguments, subject)
128
+ arguments.each do |argument|
129
+ if argument.mlhs_type?
130
+ check_arguments(argument.children, subject)
131
+ elsif anonymous_argument?(argument)
132
+ register_offense(argument_range(argument), subject)
133
+ end
134
+ end
135
+ end
136
+
137
+ # An anonymous forwarding parameter (`*`, `**`, `&`) carries no name
138
+ # child at all, and `...` is a `forward_arg`, outside the listed types.
139
+ # Both fall through as allowed.
140
+ def anonymous_argument?(argument)
141
+ NAMED_ARGUMENT_TYPES.include?(argument.type) && anonymous_name?(argument.children.first)
142
+ end
143
+
144
+ # An argument carrying a default value spans the whole expression, so
145
+ # only its name is highlighted; every other form is short enough that
146
+ # showing it whole (`*_`, `**_`, `&_`) tells the reader more.
147
+ def argument_range(argument)
148
+ return argument.loc.name if DEFAULT_VALUE_ARGUMENT_TYPES.include?(argument.type)
149
+
150
+ argument.source_range
151
+ end
152
+
153
+ def anonymous_name?(name)
154
+ !name.nil? && ANONYMOUS_NAME_PATTERN.match?(name.to_s)
155
+ end
156
+
157
+ def register_offense(node, subject)
158
+ add_offense(node, message: format(MSG, subject: subject))
159
+ end
160
+ end
161
+ end
162
+ end
163
+ end
164
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Vicenzo
6
+ module Naming
7
+ # Checks local variable names for how many letters they actually spell.
8
+ #
9
+ # A single letter or a two-letter abbreviation almost never carries the
10
+ # business meaning of the value it holds. `p`, `w`, `v`, `i` say nothing,
11
+ # and `_p` is still a single letter with an underscore in front of it.
12
+ #
13
+ # Underscores do not count towards the length, so `a_b` spells two
14
+ # letters, not three. The cop does not judge which name is a good one —
15
+ # it only measures how much was written down; whether the name belongs to
16
+ # the domain is the author's call, and the message asks for it.
17
+ #
18
+ # Block and method parameters are left to `Naming/BlockParameterName` and
19
+ # `Naming/MethodParameterName`; this gem's `config/style.yml` raises the
20
+ # former to the same minimum. Rescued errors are left to
21
+ # `Naming/RescuedExceptionsVariableName`, and names made only of
22
+ # underscores belong to `Vicenzo/Naming/AnonymousUnusedName`.
23
+ #
24
+ # A short name that exists only to feed a keyword argument shorthand is
25
+ # allowed: the name was imposed by the signature that receives it, not
26
+ # chosen, and it is that signature the parameter cops point at. The fix
27
+ # cascades from there.
28
+ #
29
+ # @example
30
+ # # bad
31
+ # v = shelf.capacity
32
+ #
33
+ # # good
34
+ # volume = shelf.capacity
35
+ #
36
+ # @example multiple assignment
37
+ # # bad
38
+ # x, y = coordinates
39
+ #
40
+ # # good
41
+ # column, row = coordinates
42
+ #
43
+ # @example underscores do not count as letters
44
+ # # bad
45
+ # a_b = shelf.capacity
46
+ #
47
+ # # good
48
+ # available_books = shelf.capacity
49
+ #
50
+ # @example an unused name is still a name
51
+ # # bad
52
+ # _p = catalog.first
53
+ #
54
+ # # good
55
+ # _publisher = catalog.first
56
+ #
57
+ # @example keyword argument shorthand (allowed)
58
+ # # good
59
+ # height = 10
60
+ # width = 15
61
+ # area(height:, width:)
62
+ #
63
+ # # good — `h` and `w` are imposed by the signature of `area`
64
+ # h = 10
65
+ # w = 15
66
+ # area(h:, w:)
67
+ #
68
+ # @example MinNameLength: 5
69
+ # # bad
70
+ # book = build
71
+ #
72
+ # # good
73
+ # book_title = build
74
+ class ShortName < Base
75
+ MSG = 'Name `%<name>s` spells %<length>d letter(s); at least %<minimum>d are required. ' \
76
+ 'A letter or an abbreviation carries no meaning to the next reader — write the ' \
77
+ 'whole word, in the language of the domain (`waiting_time`), not a shapeless ' \
78
+ 'label (`key`, `value`, `item`).'
79
+
80
+ DEFAULT_MIN_NAME_LENGTH = 3
81
+
82
+ ANONYMOUS_NAME_PATTERN = /\A_+\z/
83
+
84
+ SCOPE_TYPES = %i[def defs block numblock itblock].freeze
85
+
86
+ def on_lvasgn(node)
87
+ name = node.children.first
88
+ return if anonymous?(name) || rescued_error?(node) || forwarded_by_shorthand?(node, name)
89
+
90
+ length = spelled_length(name)
91
+ return if length >= min_name_length
92
+
93
+ add_offense(node.loc.name, message: format(MSG, name: name, length: length, minimum: min_name_length))
94
+ end
95
+
96
+ private
97
+
98
+ # Underscores separate words, they do not spell any, so they are not
99
+ # counted: `a_b` is two letters long.
100
+ def spelled_length(name)
101
+ name.to_s.delete('_').length
102
+ end
103
+
104
+ def anonymous?(name)
105
+ ANONYMOUS_NAME_PATTERN.match?(name.to_s)
106
+ end
107
+
108
+ def rescued_error?(node)
109
+ parent = node.parent
110
+ parent&.resbody_type? && parent.exception_variable == node
111
+ end
112
+
113
+ # `area(h:)` omits the value, so `h` is the name the receiving signature
114
+ # asks for. Looking within the enclosing scope keeps unrelated calls
115
+ # elsewhere in the file from excusing the name.
116
+ def forwarded_by_shorthand?(node, name)
117
+ enclosing_scope(node)
118
+ .each_node(:pair)
119
+ .any? { |pair| pair.value_omission? && pair.key.value == name }
120
+ end
121
+
122
+ def enclosing_scope(node)
123
+ node.each_ancestor(*SCOPE_TYPES).first || processed_source.ast
124
+ end
125
+
126
+ def min_name_length
127
+ cop_config.fetch('MinNameLength', DEFAULT_MIN_NAME_LENGTH)
128
+ end
129
+ end
130
+ end
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Vicenzo
6
+ module RSpec
7
+ # A describe nested in a context names a behaviour after the scenario was already set. Describe the
8
+ # behaviour first, and put the scenario inside it.
9
+ #
10
+ # The tree answers three questions, in this order: `describe` - what is being described;
11
+ # `context` - under which circumstances; `it` - with which outcome. A context only means something
12
+ # as a scenario of a behaviour, so a describe below it turns the question upside down.
13
+ #
14
+ # It usually comes from wanting to write a scenario once for two behaviours. Repeat the context
15
+ # under each describe that needs it instead: each behaviour then reads on its own. Do not reach for
16
+ # a `shared_context` to avoid the repetition - it becomes a single place every caller leans on,
17
+ # conditionals creep in to serve each of them, and years later nobody knows why they are there.
18
+ #
19
+ # When the describe states a circumstance rather than a behaviour, it is a context written with the
20
+ # wrong keyword.
21
+ #
22
+ # @example
23
+ # # bad - the scenario is set before the behaviours it belongs to
24
+ # RSpec.describe Person do
25
+ # context 'when the person is a minor' do
26
+ # subject(:person) { described_class.new(age: 17) }
27
+ #
28
+ # describe '#adult?' do
29
+ # it { is_expected.not_to be_adult }
30
+ # end
31
+ #
32
+ # describe '#voter?' do
33
+ # it { is_expected.not_to be_voter }
34
+ # end
35
+ # end
36
+ # end
37
+ #
38
+ # # good - each behaviour carries its own scenario
39
+ # RSpec.describe Person do
40
+ # describe '#adult?' do
41
+ # context 'when the person is a minor' do
42
+ # subject(:person) { described_class.new(age: 17) }
43
+ #
44
+ # it { is_expected.not_to be_adult }
45
+ # end
46
+ # end
47
+ #
48
+ # describe '#voter?' do
49
+ # context 'when the person is a minor' do
50
+ # subject(:person) { described_class.new(age: 17) }
51
+ #
52
+ # it { is_expected.not_to be_voter }
53
+ # end
54
+ # end
55
+ # end
56
+ class DescribeInsideContext < RuboCop::Cop::RSpec::Base
57
+ MSG = 'Do not describe inside a context: a context is a scenario of the behaviour a describe names. ' \
58
+ 'Describe the behaviour first and repeat the context under each describe that needs it. ' \
59
+ 'If this block states a circumstance, it is a context.'
60
+
61
+ # Only the groups that name a behaviour. Aliases a project adds to the RSpec language, such as
62
+ # rswag's `response`, are left out: they do not describe anything.
63
+ DESCRIBES = %i[describe fdescribe xdescribe feature ffeature xfeature example_group].freeze
64
+ CONTEXTS = %i[context fcontext xcontext].freeze
65
+
66
+ # @!method describe_definition?(node)
67
+ def_node_matcher :describe_definition?, <<~PATTERN
68
+ (any_block (send #rspec? {#{DESCRIBES.map(&:inspect).join(' ')}} ...) ...)
69
+ PATTERN
70
+
71
+ # @!method context_definition?(node)
72
+ def_node_matcher :context_definition?, <<~PATTERN
73
+ (any_block (send nil? {#{CONTEXTS.map(&:inspect).join(' ')}} ...) ...)
74
+ PATTERN
75
+
76
+ def on_block(node)
77
+ add_offense(node.send_node) if describe_definition?(node) && inside_a_context?(node)
78
+ end
79
+
80
+ alias on_numblock on_block
81
+
82
+ private
83
+
84
+ # A shared group's scenario lives where it is included, so it cannot be judged here.
85
+ def inside_a_context?(node)
86
+ parent = node.each_ancestor(:any_block).find { |ancestor| spec_group?(ancestor) }
87
+
88
+ parent && context_definition?(parent)
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
@@ -46,10 +46,25 @@ module RuboCop
46
46
  ENUMERATION_METHODS = %i[each each_with_index each_with_object map flat_map].freeze
47
47
 
48
48
  EXAMPLE_GROUP_DSL = %i[
49
- context describe feature experiment
50
- it specify example scenario focus
51
- let let! subject subject! before after around
52
- shared_examples shared_context shared_examples_for
49
+ context
50
+ describe
51
+ feature
52
+ experiment
53
+ it
54
+ specify
55
+ example
56
+ scenario
57
+ focus
58
+ let
59
+ let!
60
+ subject
61
+ subject!
62
+ before
63
+ after
64
+ around
65
+ shared_examples
66
+ shared_context
67
+ shared_examples_for
53
68
  ].freeze
54
69
 
55
70
  # @!method enumeration_block?(node)
@@ -76,8 +76,19 @@ module RuboCop
76
76
  'trait/transient, or declare the final state in the context that needs it.'
77
77
 
78
78
  PERSISTENCE_METHODS = %i[
79
- update update! update_attribute update_column update_columns save save! destroy destroy! touch
80
- increment! decrement! toggle!
79
+ update
80
+ update!
81
+ update_attribute
82
+ update_column
83
+ update_columns
84
+ save
85
+ save!
86
+ destroy
87
+ destroy!
88
+ touch
89
+ increment!
90
+ decrement!
91
+ toggle!
81
92
  ].freeze
82
93
 
83
94
  COLLECTION_METHODS = %i[<< push concat unshift store merge! deep_merge! clear delete].freeze
@@ -92,7 +92,7 @@ module RuboCop
92
92
  def check_let(let_node, let_definitions)
93
93
  name = (let_name(let_node) || let_it_be_name(let_node))&.to_s&.to_sym
94
94
 
95
- if let_definitions.key?(name)
95
+ if let_definitions.has_key?(name)
96
96
  add_offense(let_node, message: redefined_let_message(name, let_definitions))
97
97
 
98
98
  let_definitions[name] += [line_location(let_node)]
@@ -94,7 +94,7 @@ module RuboCop
94
94
  def check_subject(subject_node, subject_definitions)
95
95
  name = name_for(subject_node)
96
96
 
97
- if subject_definitions.key?(name)
97
+ if subject_definitions.has_key?(name)
98
98
  add_offense(subject_node, message: redefined_subject_message(name, subject_definitions))
99
99
  subject_definitions[name] += [line_location(subject_node)]
100
100
  else
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Vicenzo
6
+ module RSpec
7
+ # Checks for `context` blocks with no parent scenario that start with "and", "but", or "however".
8
+ # It suggests starting them with "when", "with", or "without" instead.
9
+ #
10
+ # A conjunction continues the scenario of the enclosing context. When the closest example group is
11
+ # not a context - a `describe`, or no group at all - there is no scenario to continue, and the
12
+ # description reads as a fragment. It usually shows up after a subtree is moved up a level: the
13
+ # parent goes away, the titles stay as they were.
14
+ #
15
+ # The closest example group decides. A shared group is left alone, since the scenario it continues
16
+ # lives where it is included.
17
+ #
18
+ # The words that continue a scenario come from `ForbiddenPrefixes`, compared against the first word
19
+ # of the description, ignoring case. Setting it replaces the defaults; to add words to them, declare
20
+ # `inherit_mode: { merge: [ForbiddenPrefixes] }` for the cop.
21
+ #
22
+ # @example
23
+ # # bad
24
+ # describe '#available_colors' do
25
+ # context 'but the color pink is not available' do
26
+ # it 'does not show the pink option'
27
+ # end
28
+ # end
29
+ #
30
+ # # good
31
+ # describe '#available_colors' do
32
+ # context 'when the color pink is not available' do
33
+ # it 'does not show the pink option'
34
+ # end
35
+ # end
36
+ #
37
+ # @example ForbiddenPrefixes: ['and', 'but', 'however', 'also']
38
+ # # bad
39
+ # describe '#available_colors' do
40
+ # context 'also when the color pink is not available' do
41
+ # it 'does not show the pink option'
42
+ # end
43
+ # end
44
+ class UnnestedContextImproperStart < RuboCop::Cop::RSpec::Base
45
+ MSG = 'Unnested `context` should start with `when`, `with`, or `without`, not `%<prefix>s`.'
46
+
47
+ CONTEXTS = %i[context fcontext xcontext].freeze
48
+ DEFAULT_FORBIDDEN_PREFIXES = %w[and but however].freeze
49
+
50
+ # @!method context_definition?(node)
51
+ def_node_matcher :context_definition?, <<~PATTERN
52
+ (any_block (send nil? {#{CONTEXTS.map(&:inspect).join(' ')}} ...) ...)
53
+ PATTERN
54
+
55
+ def on_block(node)
56
+ return unless context_definition?(node)
57
+ return if continues_a_scenario?(node)
58
+
59
+ prefix = first_word(node)
60
+
61
+ add_offense(node.send_node, message: format(MSG, prefix:)) if forbidden_prefixes.include?(prefix)
62
+ end
63
+
64
+ alias on_numblock on_block
65
+
66
+ private
67
+
68
+ # A context continues its scenario, and a shared group's scenario lives where it is included, so
69
+ # neither can be judged here.
70
+ def continues_a_scenario?(node)
71
+ parent = node.each_ancestor(:any_block).find { |ancestor| spec_group?(ancestor) }
72
+
73
+ parent && (shared_group?(parent) || context_definition?(parent))
74
+ end
75
+
76
+ def first_word(node)
77
+ description = node.send_node.first_argument
78
+
79
+ description.value.lstrip[/\A[[:alpha:]]+/]&.downcase if description&.str_type?
80
+ end
81
+
82
+ def forbidden_prefixes
83
+ cop_config.fetch('ForbiddenPrefixes', DEFAULT_FORBIDDEN_PREFIXES).map(&:downcase)
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -3,6 +3,7 @@
3
3
  require_relative 'vicenzo/rspec/competing_subjects'
4
4
  require_relative 'vicenzo/rspec/conditional_in_spec'
5
5
  require_relative 'vicenzo/rspec/derived_premises'
6
+ require_relative 'vicenzo/rspec/describe_inside_context'
6
7
  require_relative 'vicenzo/rspec/dynamic_example_generation'
7
8
  require_relative 'vicenzo/rspec/inconsistent_sibling_structure'
8
9
  require_relative 'vicenzo/rspec/iteration_inside_example'
@@ -19,7 +20,10 @@ require_relative 'vicenzo/rspec/top_level_example'
19
20
  require_relative 'vicenzo/rspec/top_level_hook'
20
21
  require_relative 'vicenzo/rspec/top_level_let'
21
22
  require_relative 'vicenzo/rspec/top_level_subject'
23
+ require_relative 'vicenzo/rspec/unnested_context_improper_start'
22
24
  require_relative 'vicenzo/rails/enum_inclusion_of_validation'
23
25
  require_relative 'vicenzo/layout/multiline_method_call_line_breaks'
26
+ require_relative 'vicenzo/naming/anonymous_unused_name'
27
+ require_relative 'vicenzo/naming/short_name'
24
28
  require_relative 'vicenzo/style/json_parse_symbolize_names'
25
29
  require_relative 'vicenzo/style/multiline_method_call_parentheses'
@@ -2,6 +2,6 @@
2
2
 
3
3
  module RuboCop
4
4
  module Vicenzo
5
- VERSION = '0.8.0'
5
+ VERSION = '0.9.0'
6
6
  end
7
7
  end
data/rakelib/docs.rake CHANGED
@@ -155,16 +155,26 @@ end
155
155
  def render_metadata_table(cop)
156
156
  enabled = cop[:enabled] ? 'Enabled' : 'Disabled'
157
157
  autocorrect = cop[:autocorrect] ? 'Yes' : 'No'
158
- ['[cols="1,1,1,1"]', '|===', '| Enabled by default | Safe | Supports autocorrection | Version Added',
159
- '', "| #{enabled}", '| Yes', "| #{autocorrect}", "| #{cop[:version]}", '|===', '']
158
+ [
159
+ '[cols="1,1,1,1"]',
160
+ '|===',
161
+ '| Enabled by default | Safe | Supports autocorrection | Version Added',
162
+ '',
163
+ "| #{enabled}",
164
+ '| Yes',
165
+ "| #{autocorrect}",
166
+ "| #{cop[:version]}",
167
+ '|===',
168
+ ''
169
+ ]
160
170
  end
161
171
 
162
172
  def render_example(example, index)
163
173
  title = if example[:title].empty?
164
- index.zero? ? 'Example' : "Example #{index + 1}"
165
- else
166
- example[:title]
167
- end
174
+ index.zero? ? 'Example' : "Example #{index + 1}"
175
+ else
176
+ example[:title]
177
+ end
168
178
  ["=== #{title}", '', '[source,ruby]', '----', *example[:code], '----', '']
169
179
  end
170
180
 
@@ -198,13 +208,28 @@ def installation_section_lines
198
208
  end
199
209
 
200
210
  def gemfile_installation_lines
201
- ['Add to your `Gemfile`:', '', '[source,ruby]', '----',
202
- "gem 'rubocop-vicenzo', require: false", '----', '']
211
+ [
212
+ 'Add to your `Gemfile`:',
213
+ '',
214
+ '[source,ruby]',
215
+ '----',
216
+ "gem 'rubocop-vicenzo', require: false",
217
+ '----',
218
+ ''
219
+ ]
203
220
  end
204
221
 
205
222
  def rubocop_yml_installation_lines
206
- ['Then add to your `.rubocop.yml`:', '', '[source,yaml]', '----',
207
- 'plugins:', ' - rubocop-vicenzo', '----', '']
223
+ [
224
+ 'Then add to your `.rubocop.yml`:',
225
+ '',
226
+ '[source,yaml]',
227
+ '----',
228
+ 'plugins:',
229
+ ' - rubocop-vicenzo',
230
+ '----',
231
+ ''
232
+ ]
208
233
  end
209
234
 
210
235
  def index_cops_table_lines(cop_data)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rubocop-vicenzo
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.0
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Bruno Vicenzo
@@ -70,14 +70,18 @@ files:
70
70
  - Rakefile
71
71
  - antora-playbook.yml
72
72
  - config/default.yml
73
+ - config/style.yml
73
74
  - docs/antora.yml
74
75
  - docs/supplemental-ui/partials/header-content.hbs
75
76
  - lib/rubocop-vicenzo.rb
76
77
  - lib/rubocop/cop/vicenzo/layout/multiline_method_call_line_breaks.rb
78
+ - lib/rubocop/cop/vicenzo/naming/anonymous_unused_name.rb
79
+ - lib/rubocop/cop/vicenzo/naming/short_name.rb
77
80
  - lib/rubocop/cop/vicenzo/rails/enum_inclusion_of_validation.rb
78
81
  - lib/rubocop/cop/vicenzo/rspec/competing_subjects.rb
79
82
  - lib/rubocop/cop/vicenzo/rspec/conditional_in_spec.rb
80
83
  - lib/rubocop/cop/vicenzo/rspec/derived_premises.rb
84
+ - lib/rubocop/cop/vicenzo/rspec/describe_inside_context.rb
81
85
  - lib/rubocop/cop/vicenzo/rspec/dynamic_example_generation.rb
82
86
  - lib/rubocop/cop/vicenzo/rspec/inconsistent_sibling_structure.rb
83
87
  - lib/rubocop/cop/vicenzo/rspec/iteration_inside_example.rb
@@ -98,6 +102,7 @@ files:
98
102
  - lib/rubocop/cop/vicenzo/rspec/top_level_hook.rb
99
103
  - lib/rubocop/cop/vicenzo/rspec/top_level_let.rb
100
104
  - lib/rubocop/cop/vicenzo/rspec/top_level_subject.rb
105
+ - lib/rubocop/cop/vicenzo/rspec/unnested_context_improper_start.rb
101
106
  - lib/rubocop/cop/vicenzo/style/json_parse_symbolize_names.rb
102
107
  - lib/rubocop/cop/vicenzo/style/multiline_method_call_parentheses.rb
103
108
  - lib/rubocop/cop/vicenzo_cops.rb
@@ -131,7 +136,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
131
136
  - !ruby/object:Gem::Version
132
137
  version: '0'
133
138
  requirements: []
134
- rubygems_version: 4.0.16
139
+ rubygems_version: 4.0.20
135
140
  specification_version: 4
136
141
  summary: Cops of Bruno Vicenzo
137
142
  test_files: []