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.
@@ -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"