kaizo 0.7.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 +7 -0
- data/CHANGELOG.md +92 -0
- data/LICENSE.txt +21 -0
- data/README.md +529 -0
- data/config/default.yml +210 -0
- data/lib/kaizo/plugin.rb +27 -0
- data/lib/kaizo/version.rb +3 -0
- data/lib/kaizo.rb +15 -0
- data/lib/rubocop/cop/kaizo/agent_noun_class_name.rb +76 -0
- data/lib/rubocop/cop/kaizo/argument_counting.rb +74 -0
- data/lib/rubocop/cop/kaizo/explicit_begin.rb +119 -0
- data/lib/rubocop/cop/kaizo/file_utils_inclusion.rb +92 -0
- data/lib/rubocop/cop/kaizo/keyword_arguments.rb +35 -0
- data/lib/rubocop/cop/kaizo/nested_method_calls.rb +130 -0
- data/lib/rubocop/cop/kaizo/next_in_non_void_enumerable.rb +134 -0
- data/lib/rubocop/cop/kaizo/positional_arguments.rb +35 -0
- data/lib/rubocop/cop/kaizo/prefer_pathname.rb +63 -0
- data/lib/rubocop/cop/kaizo/spec_comment.rb +75 -0
- data/lib/rubocop/cop/kaizo/spec_description_prose.rb +136 -0
- data/lib/rubocop/cop/kaizo/total_arguments.rb +39 -0
- data/lib/rubocop/cop/kaizo_cops.rb +12 -0
- metadata +96 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
module RuboCop
|
|
2
|
+
module Cop
|
|
3
|
+
module Kaizo
|
|
4
|
+
# Checks for method calls nested too deeply in argument positions.
|
|
5
|
+
#
|
|
6
|
+
# A call whose arguments are themselves the results of other calls --
|
|
7
|
+
# `foo(SomeClass.new(another("bar").chain))` -- packs several steps into one
|
|
8
|
+
# expression. Naming the intermediate results (or extracting a method) almost
|
|
9
|
+
# always reads better and is easier to debug than peeling parentheses apart.
|
|
10
|
+
#
|
|
11
|
+
# Only nesting through *argument* positions is counted; a receiver chain such
|
|
12
|
+
# as `user.account.owner.name` is a separate concern. Operator methods
|
|
13
|
+
# (`a + b`, `arr[i]`) never count, and calls to `AllowedMethods` are exempt.
|
|
14
|
+
# Depth is measured from each outermost call and reported once. There is no
|
|
15
|
+
# autocorrection: choosing the intermediate name is a design decision.
|
|
16
|
+
#
|
|
17
|
+
# @example Max: 1 (default)
|
|
18
|
+
# # bad
|
|
19
|
+
# foo(SomeClass.new(another("bar").chain))
|
|
20
|
+
#
|
|
21
|
+
# # bad
|
|
22
|
+
# wrap(parse(read(io)))
|
|
23
|
+
#
|
|
24
|
+
# # good - name the intermediate result
|
|
25
|
+
# parsed = parse(read(io))
|
|
26
|
+
# wrap(parsed)
|
|
27
|
+
#
|
|
28
|
+
# # good - a single nested call is allowed
|
|
29
|
+
# puts compute(value)
|
|
30
|
+
#
|
|
31
|
+
class NestedMethodCalls < Base
|
|
32
|
+
include AllowedMethods
|
|
33
|
+
|
|
34
|
+
exclude_limit "Max"
|
|
35
|
+
|
|
36
|
+
MSG = "Avoid nesting method calls in arguments; name an intermediate " \
|
|
37
|
+
"result instead. [%<depth>d/%<max>d]".freeze
|
|
38
|
+
|
|
39
|
+
# Node types whose children sit in argument position -- looked through to
|
|
40
|
+
# reach nested calls (but never into a block body).
|
|
41
|
+
TRANSPARENT_ARGUMENT_TYPES = %i[array hash begin pair splat kwsplat].freeze
|
|
42
|
+
|
|
43
|
+
def on_send(node)
|
|
44
|
+
return if nested_in_call_argument?(node)
|
|
45
|
+
return if allowed_method?(node.method_name)
|
|
46
|
+
|
|
47
|
+
max = cop_config["Max"]
|
|
48
|
+
depth = nesting_depth(node)
|
|
49
|
+
return unless max && depth > max
|
|
50
|
+
|
|
51
|
+
add_offense(node, message: format(MSG, depth: depth, max: max)) do
|
|
52
|
+
self.max = depth
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
alias on_csend on_send
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
# Longest chain of significant calls reachable through this call's
|
|
60
|
+
# argument positions. Receivers and block bodies are not traversed.
|
|
61
|
+
def nesting_depth(call)
|
|
62
|
+
nested = argument_calls(call)
|
|
63
|
+
return 0 if nested.empty?
|
|
64
|
+
|
|
65
|
+
1 + nested.map { |inner| nesting_depth(inner) }.max
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def argument_calls(call)
|
|
69
|
+
call.arguments.flat_map { |argument| calls_in(argument) }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Significant calls at the top of an argument expression, seen through
|
|
73
|
+
# array/hash literals but never into a block body.
|
|
74
|
+
def calls_in(node)
|
|
75
|
+
return significant_calls(node) if node.call_type?
|
|
76
|
+
return significant_calls(node.send_node) if node.any_block_type?
|
|
77
|
+
return [] unless TRANSPARENT_ARGUMENT_TYPES.include?(node.type)
|
|
78
|
+
|
|
79
|
+
node.children.flat_map { |child| calls_in(child) }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# `node` wrapped in an array when it is a significant call, else empty.
|
|
83
|
+
def significant_calls(node)
|
|
84
|
+
significant_call?(node) ? [node] : []
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def significant_call?(node)
|
|
88
|
+
return false unless node.call_type?
|
|
89
|
+
return false if node.operator_method?
|
|
90
|
+
return false if allowed_method?(node.method_name)
|
|
91
|
+
|
|
92
|
+
chain_takes_arguments?(node)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# A call reads as a real nested step -- rather than a plain receiver
|
|
96
|
+
# chain like `user.account.owner.name` -- only when it, or some call in
|
|
97
|
+
# its receiver chain, actually takes arguments. Bare reader chains do
|
|
98
|
+
# not count (that is the planned chaining cop's concern), while
|
|
99
|
+
# `another("bar").chain` still does.
|
|
100
|
+
def chain_takes_arguments?(node)
|
|
101
|
+
return false unless node.call_type?
|
|
102
|
+
return true if node.arguments.any?
|
|
103
|
+
|
|
104
|
+
node.receiver ? chain_takes_arguments?(node.receiver) : false
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Whether `node` sits in an enclosing call's argument list (through
|
|
108
|
+
# literal containers or the call a block is attached to), so an outer
|
|
109
|
+
# call already accounts for it.
|
|
110
|
+
def nested_in_call_argument?(node)
|
|
111
|
+
parent = node.parent
|
|
112
|
+
return false unless parent
|
|
113
|
+
return parent.arguments.include?(node) if parent.call_type?
|
|
114
|
+
return nested_in_call_argument?(parent) if reached_through?(parent, node)
|
|
115
|
+
|
|
116
|
+
false
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# A parent an outer call sees through to reach `node`: any literal
|
|
120
|
+
# container, or a block -- but only via the call it is attached to,
|
|
121
|
+
# never its body.
|
|
122
|
+
def reached_through?(parent, node)
|
|
123
|
+
return true if TRANSPARENT_ARGUMENT_TYPES.include?(parent.type)
|
|
124
|
+
|
|
125
|
+
parent.any_block_type? && parent.send_node.equal?(node)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
module RuboCop
|
|
2
|
+
module Cop
|
|
3
|
+
module Kaizo
|
|
4
|
+
# Flags `next` used inside the block of a value-returning `Enumerable`
|
|
5
|
+
# method such as `map`, `select`, or `reduce`, whose block value is
|
|
6
|
+
# significant. In those methods `next` doubles as a way to produce the
|
|
7
|
+
# block's value through control flow, which this cop discourages in
|
|
8
|
+
# favor of an expression that produces the value directly.
|
|
9
|
+
#
|
|
10
|
+
# "Void" iteration methods, whose block return value is ignored (`each`,
|
|
11
|
+
# `each_with_index`, `each_slice`, `each_with_object`, `reverse_each`,
|
|
12
|
+
# and so on), are intentionally *not* flagged: there `next` merely skips
|
|
13
|
+
# to the next iteration. The same goes for non-`Enumerable` looping
|
|
14
|
+
# constructs such as `loop`, `Integer#times`, and `while`. Additional
|
|
15
|
+
# methods can be exempted through `AllowedMethods` / `AllowedPatterns`.
|
|
16
|
+
#
|
|
17
|
+
# A `next` that binds to a nested block or loop is attributed to that
|
|
18
|
+
# inner scope, so an inner `each { next }` does not flag an outer `map`.
|
|
19
|
+
#
|
|
20
|
+
# There is no autocorrection: the right fix depends on intent -- a guard
|
|
21
|
+
# clause might become a ternary, a `select`/`reject`, a `filter_map`, or a
|
|
22
|
+
# restructured block.
|
|
23
|
+
#
|
|
24
|
+
# @example
|
|
25
|
+
# # bad
|
|
26
|
+
# array.map do |item|
|
|
27
|
+
# next if skip?(item)
|
|
28
|
+
#
|
|
29
|
+
# transform(item)
|
|
30
|
+
# end
|
|
31
|
+
#
|
|
32
|
+
# # bad - `next <value>` is still control-flow-as-value
|
|
33
|
+
# array.reduce(0) do |sum, item|
|
|
34
|
+
# next sum if skip?(item)
|
|
35
|
+
#
|
|
36
|
+
# sum + item
|
|
37
|
+
# end
|
|
38
|
+
#
|
|
39
|
+
# # good - a void iteration method skips with `next`
|
|
40
|
+
# array.each do |item|
|
|
41
|
+
# next if skip?(item)
|
|
42
|
+
#
|
|
43
|
+
# process(item)
|
|
44
|
+
# end
|
|
45
|
+
#
|
|
46
|
+
# # good - express the intent with a value-returning form
|
|
47
|
+
# array.filter_map do |item|
|
|
48
|
+
# transform(item) unless skip?(item)
|
|
49
|
+
# end
|
|
50
|
+
#
|
|
51
|
+
# @example AllowedMethods: ['reduce'] (default: [])
|
|
52
|
+
# # good - `reduce` exempted by configuration
|
|
53
|
+
# array.reduce(0) do |sum, item|
|
|
54
|
+
# next sum if skip?(item)
|
|
55
|
+
#
|
|
56
|
+
# sum + item
|
|
57
|
+
# end
|
|
58
|
+
#
|
|
59
|
+
class NextInNonVoidEnumerable < Base
|
|
60
|
+
include AllowedMethods
|
|
61
|
+
include AllowedPattern
|
|
62
|
+
|
|
63
|
+
MSG = "Avoid `next` inside `%<method>s`; return a value from the " \
|
|
64
|
+
"block instead of using `next` for control flow.".freeze
|
|
65
|
+
|
|
66
|
+
# `Enumerable` methods whose block return value determines the result.
|
|
67
|
+
# Void iteration methods (`each`, `each_with_object`, `reverse_each`,
|
|
68
|
+
# `cycle`, ...) are deliberately absent, which is why `next` is allowed
|
|
69
|
+
# in them.
|
|
70
|
+
FLAGGED_METHODS = %i[
|
|
71
|
+
all? any? none? one?
|
|
72
|
+
chunk_while
|
|
73
|
+
collect collect_concat
|
|
74
|
+
count
|
|
75
|
+
detect
|
|
76
|
+
drop_while
|
|
77
|
+
filter filter_map find find_all find_index
|
|
78
|
+
flat_map
|
|
79
|
+
grep grep_v
|
|
80
|
+
group_by
|
|
81
|
+
inject
|
|
82
|
+
map
|
|
83
|
+
max_by min_by minmax_by
|
|
84
|
+
partition
|
|
85
|
+
reduce reject
|
|
86
|
+
select
|
|
87
|
+
slice_when
|
|
88
|
+
sort_by
|
|
89
|
+
sum
|
|
90
|
+
take_while
|
|
91
|
+
].freeze
|
|
92
|
+
|
|
93
|
+
# Node types that introduce a new `next` binding. A `next` found below
|
|
94
|
+
# one of these belongs to that inner scope, not the enumerable block
|
|
95
|
+
# under inspection, so descent stops here.
|
|
96
|
+
SCOPE_BOUNDARIES = %i[
|
|
97
|
+
block numblock itblock while until while_post until_post for
|
|
98
|
+
].freeze
|
|
99
|
+
|
|
100
|
+
def on_block(node)
|
|
101
|
+
call = node.children.first
|
|
102
|
+
return unless call.respond_to?(:method_name)
|
|
103
|
+
|
|
104
|
+
method = call.method_name
|
|
105
|
+
return unless FLAGGED_METHODS.include?(method)
|
|
106
|
+
return if allowed_method?(method) || matches_allowed_pattern?(method.to_s)
|
|
107
|
+
|
|
108
|
+
flag_block_local_nexts(node, method)
|
|
109
|
+
end
|
|
110
|
+
alias on_numblock on_block
|
|
111
|
+
alias on_itblock on_block
|
|
112
|
+
|
|
113
|
+
private
|
|
114
|
+
|
|
115
|
+
def flag_block_local_nexts(block_node, method)
|
|
116
|
+
message = format(MSG, method: method)
|
|
117
|
+
|
|
118
|
+
block_node.each_child_node do |child|
|
|
119
|
+
each_block_local_next(child) do |next_node|
|
|
120
|
+
add_offense(next_node.loc.keyword, message: message)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def each_block_local_next(node, &)
|
|
126
|
+
yield node if node.next_type?
|
|
127
|
+
return if SCOPE_BOUNDARIES.include?(node.type)
|
|
128
|
+
|
|
129
|
+
node.each_child_node { |child| each_block_local_next(child, &) }
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
module RuboCop
|
|
2
|
+
module Cop
|
|
3
|
+
module Kaizo
|
|
4
|
+
# Checks that a method does not declare too many positional arguments.
|
|
5
|
+
#
|
|
6
|
+
# Positional arguments are unnamed and order-dependent, which makes a long
|
|
7
|
+
# list of them especially prone to primitive obsession. Required (`arg`)
|
|
8
|
+
# and optional (`optarg`) parameters are counted; `*rest` and `&block` are
|
|
9
|
+
# not.
|
|
10
|
+
#
|
|
11
|
+
# @example Max: 2
|
|
12
|
+
# # bad
|
|
13
|
+
# def move(x, y, z)
|
|
14
|
+
# end
|
|
15
|
+
#
|
|
16
|
+
# # good
|
|
17
|
+
# def move(point)
|
|
18
|
+
# end
|
|
19
|
+
#
|
|
20
|
+
class PositionalArguments < Base
|
|
21
|
+
include ArgumentCounting
|
|
22
|
+
|
|
23
|
+
exclude_limit "Max"
|
|
24
|
+
|
|
25
|
+
KIND = "positional arguments".freeze
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def arity(arguments)
|
|
30
|
+
positional_arity(arguments)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
module RuboCop
|
|
2
|
+
module Cop
|
|
3
|
+
module Kaizo
|
|
4
|
+
# Prefer `Pathname` over `File` for the operations `Pathname` provides.
|
|
5
|
+
#
|
|
6
|
+
# Once a path is a `Pathname`, calling `pathname.read` or `pathname.exist?`
|
|
7
|
+
# reads better than threading a string through `File.read(...)` or
|
|
8
|
+
# `File.exist?(...)`. This cop flags calls to `File` class methods that have
|
|
9
|
+
# a `Pathname` instance-method equivalent.
|
|
10
|
+
#
|
|
11
|
+
# By default the cop runs on `**/*.rb` and skips `exe/**/*` and `bin/**/*`
|
|
12
|
+
# (executables often work with raw path strings); adjust with the standard
|
|
13
|
+
# `Include`/`Exclude` options.
|
|
14
|
+
#
|
|
15
|
+
# There is no autocorrection: rewriting `File.read(path)` as
|
|
16
|
+
# `Pathname(path).read` changes the receiver and is left to a human.
|
|
17
|
+
#
|
|
18
|
+
# @example
|
|
19
|
+
# # bad
|
|
20
|
+
# File.read(path)
|
|
21
|
+
# File.exist?(path)
|
|
22
|
+
# File.join(dir, name)
|
|
23
|
+
#
|
|
24
|
+
# # good
|
|
25
|
+
# path.read
|
|
26
|
+
# path.exist?
|
|
27
|
+
# dir.join(name)
|
|
28
|
+
#
|
|
29
|
+
class PreferPathname < Base
|
|
30
|
+
MSG = "Use `Pathname#%<method>s` instead of `File.%<method>s`.".freeze
|
|
31
|
+
|
|
32
|
+
# `File` class methods that have a `Pathname` instance-method equivalent:
|
|
33
|
+
# the intersection of File's class methods and Pathname's own public
|
|
34
|
+
# instance methods, excluding generic Object methods. `public_` matters:
|
|
35
|
+
# `Pathname#path` is protected, so `File.path` must not be suggested.
|
|
36
|
+
# Regenerate with:
|
|
37
|
+
# (Pathname.public_instance_methods(false) & File.methods).reject { |m| Object.respond_to?(m) }.sort
|
|
38
|
+
RESTRICT_ON_SEND = %i[
|
|
39
|
+
atime basename binread binwrite birthtime blockdev? chardev? chmod
|
|
40
|
+
chown ctime delete directory? dirname empty? executable?
|
|
41
|
+
executable_real? exist? expand_path extname file? fnmatch fnmatch?
|
|
42
|
+
ftype grpowned? join lchmod lchown lstat lutime mtime open owned?
|
|
43
|
+
pipe? read readable? readable_real? readlines readlink
|
|
44
|
+
realdirpath realpath rename setgid? setuid? size size? socket? split
|
|
45
|
+
stat sticky? symlink? sysopen truncate unlink utime world_readable?
|
|
46
|
+
world_writable? writable? writable_real? write zero?
|
|
47
|
+
].freeze
|
|
48
|
+
|
|
49
|
+
# @!method file_call?(node)
|
|
50
|
+
def_node_matcher :file_call?, <<~PATTERN
|
|
51
|
+
(send (const {nil? cbase} :File) _ ...)
|
|
52
|
+
PATTERN
|
|
53
|
+
|
|
54
|
+
def on_send(node)
|
|
55
|
+
return unless file_call?(node)
|
|
56
|
+
|
|
57
|
+
range = node.receiver.source_range.join(node.loc.selector)
|
|
58
|
+
add_offense(range, message: format(MSG, method: node.method_name))
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
module RuboCop
|
|
2
|
+
module Cop
|
|
3
|
+
module Kaizo
|
|
4
|
+
# Checks for comments in spec files.
|
|
5
|
+
#
|
|
6
|
+
# A comment in a spec is almost always a sign that the spec is doing the
|
|
7
|
+
# job of its own description. If you need a sentence to explain what an
|
|
8
|
+
# example sets up or asserts, that sentence usually wants to be a
|
|
9
|
+
# `context`/`it` description, a clearer example name, or another example --
|
|
10
|
+
# not prose riding alongside the code.
|
|
11
|
+
#
|
|
12
|
+
# By default only `*_spec.rb` files are inspected (see `Include`). Magic
|
|
13
|
+
# comments (`# frozen_string_literal: true`, `# encoding: ...`), RuboCop
|
|
14
|
+
# directives (any `# rubocop:` comment), and shebangs are
|
|
15
|
+
# never flagged; add further exemptions with `AllowedPatterns`. There is no
|
|
16
|
+
# autocorrection: turning an explanation into a spec is a design decision.
|
|
17
|
+
#
|
|
18
|
+
# @example
|
|
19
|
+
# # bad
|
|
20
|
+
# it 'permits the request' do
|
|
21
|
+
# # an admin can see everything
|
|
22
|
+
# user = create(:user, admin: true)
|
|
23
|
+
# expect(policy).to permit(user)
|
|
24
|
+
# end
|
|
25
|
+
#
|
|
26
|
+
# # good
|
|
27
|
+
# it 'permits an admin to see everything' do
|
|
28
|
+
# admin = create(:user, admin: true)
|
|
29
|
+
# expect(policy).to permit(admin)
|
|
30
|
+
# end
|
|
31
|
+
#
|
|
32
|
+
class SpecComment < Base
|
|
33
|
+
include AllowedPattern
|
|
34
|
+
|
|
35
|
+
MSG = "Avoid comments in specs. Express the intent as a `context`/`it` " \
|
|
36
|
+
"description or a clearer example instead.".freeze
|
|
37
|
+
|
|
38
|
+
def on_new_investigation
|
|
39
|
+
processed_source.comments.each do |comment|
|
|
40
|
+
next if allowed?(comment)
|
|
41
|
+
|
|
42
|
+
add_offense(comment)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def allowed?(comment)
|
|
49
|
+
magic_comment?(comment) ||
|
|
50
|
+
directive_comment?(comment) ||
|
|
51
|
+
shebang?(comment) ||
|
|
52
|
+
matches_allowed_pattern?(comment.text)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def magic_comment?(comment)
|
|
56
|
+
comment.loc.line <= leading_magic_comment_limit && MagicComment.parse(comment.text).any?
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Magic comments are only honoured at the top of the file (the first
|
|
60
|
+
# line, or the second after a shebang); below that they are plain comments.
|
|
61
|
+
def leading_magic_comment_limit
|
|
62
|
+
processed_source.lines.first&.start_with?("#!") ? 2 : 1
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def directive_comment?(comment)
|
|
66
|
+
DirectiveComment.new(comment).start_with_marker?
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def shebang?(comment)
|
|
70
|
+
comment.text.start_with?("#!")
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
module RuboCop
|
|
2
|
+
module Cop
|
|
3
|
+
module Kaizo
|
|
4
|
+
# Requires RSpec `it`/`context` descriptions to read as one-behavior prose
|
|
5
|
+
# specifications, after the spec-skeleton naming law.
|
|
6
|
+
#
|
|
7
|
+
# An `it`/`specify`/`example` description must not contain a comma (a list
|
|
8
|
+
# is several behaviors), a coordinating or conditional conjunction
|
|
9
|
+
# (`Conjunctions` -- joined clauses are separate examples; a condition
|
|
10
|
+
# belongs in a `context`), or code (`_ : # = { } ! [ ]`, a backtick, or a
|
|
11
|
+
# nested quoted literal -- a description is prose, not identifiers or wire
|
|
12
|
+
# values). Each rule is structural: it signals that one example is really
|
|
13
|
+
# more than one, or that the assertion is leaking into the name.
|
|
14
|
+
#
|
|
15
|
+
# A `context` description must not contain code, and must open with one of
|
|
16
|
+
# `ContextPrefixes` (`when`/`with`/`without`/`after`). `describe` strings
|
|
17
|
+
# name the unit under test and are exempt.
|
|
18
|
+
#
|
|
19
|
+
# Wording preferences that do not change the spec's structure (e.g. `should`
|
|
20
|
+
# vs a present-tense verb) are out of scope -- see rubocop-rspec's
|
|
21
|
+
# `RSpec/ExampleWording`.
|
|
22
|
+
#
|
|
23
|
+
# There is no autocorrection: splitting an example, or extracting a
|
|
24
|
+
# condition into a `context`, is a modelling decision for a human.
|
|
25
|
+
#
|
|
26
|
+
# @example
|
|
27
|
+
# # bad
|
|
28
|
+
# it "renders the name, image, and flag"
|
|
29
|
+
# it "omits the key when the role is unset"
|
|
30
|
+
# it "renders the :cpu member"
|
|
31
|
+
# context "the role is unset" do
|
|
32
|
+
# end
|
|
33
|
+
#
|
|
34
|
+
# # good
|
|
35
|
+
# it "renders the name"
|
|
36
|
+
# it "renders the cpu member"
|
|
37
|
+
# context "when the role is unset" do
|
|
38
|
+
# it "omits the key"
|
|
39
|
+
# end
|
|
40
|
+
#
|
|
41
|
+
class SpecDescriptionProse < Base
|
|
42
|
+
EXAMPLE_METHODS = %i[
|
|
43
|
+
it specify example fit xit fspecify xspecify fexample xexample
|
|
44
|
+
].freeze
|
|
45
|
+
CONTEXT_METHODS = %i[context fcontext xcontext].freeze
|
|
46
|
+
RESTRICT_ON_SEND = (EXAMPLE_METHODS + CONTEXT_METHODS).freeze
|
|
47
|
+
|
|
48
|
+
COMMA_MSG = "Split this example: its description contains a comma.".freeze
|
|
49
|
+
CONJUNCTION_MSG = "Split this example: its description contains `%<word>s`; " \
|
|
50
|
+
"use separate examples or a `context`.".freeze
|
|
51
|
+
CODE_MSG = "Write the description as prose; it contains code, not English.".freeze
|
|
52
|
+
CONTEXT_CODE_MSG = "Write the context description as prose; it contains code, not English.".freeze
|
|
53
|
+
CONTEXT_PREFIX_MSG = "Begin the context description with %<prefixes>s.".freeze
|
|
54
|
+
|
|
55
|
+
# Coordinating (FANBOYS, minus the preposition-heavy `for`) plus the
|
|
56
|
+
# conditional subordinators that reliably signal a hidden "given".
|
|
57
|
+
# Homographs like `even`/`given`/`regardless` are deliberately absent --
|
|
58
|
+
# they collide with adjectives/nouns (`even numbers`) -- add them via
|
|
59
|
+
# config if you want them.
|
|
60
|
+
DEFAULT_CONJUNCTIONS = %w[
|
|
61
|
+
and but or nor so yet
|
|
62
|
+
when whenever if unless while until because although though
|
|
63
|
+
].freeze
|
|
64
|
+
DEFAULT_CONTEXT_PREFIXES = %w[when with without after].freeze
|
|
65
|
+
|
|
66
|
+
CODE_CHARS = /[_:#={}!`\[\]]/
|
|
67
|
+
NESTED_QUOTE = /(['"]).+\1/
|
|
68
|
+
|
|
69
|
+
# @!method description(node)
|
|
70
|
+
def_node_matcher :description, <<~PATTERN
|
|
71
|
+
(send nil? _ (str $_) ...)
|
|
72
|
+
PATTERN
|
|
73
|
+
|
|
74
|
+
def on_send(node)
|
|
75
|
+
text = description(node)
|
|
76
|
+
return unless text
|
|
77
|
+
|
|
78
|
+
message = violation(node.method_name, text)
|
|
79
|
+
return unless message
|
|
80
|
+
|
|
81
|
+
add_offense(node.first_argument, message: message)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def violation(method, text)
|
|
87
|
+
if EXAMPLE_METHODS.include?(method)
|
|
88
|
+
example_violation(text)
|
|
89
|
+
else
|
|
90
|
+
context_violation(text)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def example_violation(text)
|
|
95
|
+
return COMMA_MSG if text.include?(",")
|
|
96
|
+
|
|
97
|
+
word = forbidden(text, conjunctions)
|
|
98
|
+
return format(CONJUNCTION_MSG, word: word) if word
|
|
99
|
+
|
|
100
|
+
CODE_MSG if code?(text)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def context_violation(text)
|
|
104
|
+
return CONTEXT_CODE_MSG if code?(text)
|
|
105
|
+
return if text.match?(prefix_regexp)
|
|
106
|
+
|
|
107
|
+
format(CONTEXT_PREFIX_MSG, prefixes: quoted_prefixes)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def code?(text)
|
|
111
|
+
CODE_CHARS.match?(text) || NESTED_QUOTE.match?(text)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def forbidden(text, words)
|
|
115
|
+
words.find { |word| text.match?(/\b#{Regexp.escape(word)}\b/i) }
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def prefix_regexp
|
|
119
|
+
/\A\s*(?:#{context_prefixes.map { |prefix| Regexp.escape(prefix) }.join("|")})\b/i
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def quoted_prefixes
|
|
123
|
+
context_prefixes.map { |prefix| "`#{prefix}`" }.join("/")
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def conjunctions
|
|
127
|
+
cop_config.fetch("Conjunctions", DEFAULT_CONJUNCTIONS)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def context_prefixes
|
|
131
|
+
cop_config.fetch("ContextPrefixes", DEFAULT_CONTEXT_PREFIXES)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
module RuboCop
|
|
2
|
+
module Cop
|
|
3
|
+
module Kaizo
|
|
4
|
+
# Checks that a method does not declare too many arguments in total
|
|
5
|
+
# (positional plus keyword).
|
|
6
|
+
#
|
|
7
|
+
# The goal is to apply pressure toward good domain modeling: a long
|
|
8
|
+
# argument list is often a sign of primitive obsession, and bundling the
|
|
9
|
+
# related values into an object usually expresses the intent better.
|
|
10
|
+
#
|
|
11
|
+
# `*rest`, `**keyword-rest`, and `&block` arguments are not counted. The
|
|
12
|
+
# `initialize` of a `Struct.new`/`Data.define` block is exempt, since those
|
|
13
|
+
# parameters mirror the value object's attributes.
|
|
14
|
+
#
|
|
15
|
+
# @example Max: 3
|
|
16
|
+
# # bad
|
|
17
|
+
# def calculate_volume(width, length, height, shape_type)
|
|
18
|
+
# end
|
|
19
|
+
#
|
|
20
|
+
# # good
|
|
21
|
+
# def calculate_volume(shape)
|
|
22
|
+
# end
|
|
23
|
+
#
|
|
24
|
+
class TotalArguments < Base
|
|
25
|
+
include ArgumentCounting
|
|
26
|
+
|
|
27
|
+
exclude_limit "Max"
|
|
28
|
+
|
|
29
|
+
KIND = "arguments".freeze
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def arity(arguments)
|
|
34
|
+
positional_arity(arguments) + keyword_arity(arguments)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
require_relative "kaizo/argument_counting"
|
|
2
|
+
require_relative "kaizo/keyword_arguments"
|
|
3
|
+
require_relative "kaizo/positional_arguments"
|
|
4
|
+
require_relative "kaizo/total_arguments"
|
|
5
|
+
require_relative "kaizo/agent_noun_class_name"
|
|
6
|
+
require_relative "kaizo/nested_method_calls"
|
|
7
|
+
require_relative "kaizo/spec_comment"
|
|
8
|
+
require_relative "kaizo/spec_description_prose"
|
|
9
|
+
require_relative "kaizo/file_utils_inclusion"
|
|
10
|
+
require_relative "kaizo/prefer_pathname"
|
|
11
|
+
require_relative "kaizo/explicit_begin"
|
|
12
|
+
require_relative "kaizo/next_in_non_void_enumerable"
|