rubocop-obsession 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (29) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +91 -0
  4. data/config/default.yml +163 -0
  5. data/lib/rubocop/cop/mixin/files/verbs.txt +8507 -0
  6. data/lib/rubocop/cop/mixin/helpers.rb +27 -0
  7. data/lib/rubocop/cop/obsession/graphql/mutation_name.rb +40 -0
  8. data/lib/rubocop/cop/obsession/method_order.rb +244 -0
  9. data/lib/rubocop/cop/obsession/no_break_or_next.rb +94 -0
  10. data/lib/rubocop/cop/obsession/no_paragraphs.rb +62 -0
  11. data/lib/rubocop/cop/obsession/no_todos.rb +26 -0
  12. data/lib/rubocop/cop/obsession/rails/callback_one_method.rb +35 -0
  13. data/lib/rubocop/cop/obsession/rails/fully_defined_json_field.rb +71 -0
  14. data/lib/rubocop/cop/obsession/rails/migration_belongs_to.rb +44 -0
  15. data/lib/rubocop/cop/obsession/rails/no_callback_conditions.rb +60 -0
  16. data/lib/rubocop/cop/obsession/rails/private_callback.rb +59 -0
  17. data/lib/rubocop/cop/obsession/rails/safety_assured_comment.rb +37 -0
  18. data/lib/rubocop/cop/obsession/rails/service_name.rb +82 -0
  19. data/lib/rubocop/cop/obsession/rails/service_perform_method.rb +57 -0
  20. data/lib/rubocop/cop/obsession/rails/short_after_commit.rb +90 -0
  21. data/lib/rubocop/cop/obsession/rails/short_validate.rb +36 -0
  22. data/lib/rubocop/cop/obsession/rails/validate_one_field.rb +33 -0
  23. data/lib/rubocop/cop/obsession/rails/validation_method_name.rb +32 -0
  24. data/lib/rubocop/cop/obsession/rspec/describe_public_method.rb +125 -0
  25. data/lib/rubocop/cop/obsession/rspec/empty_line_after_final_let.rb +36 -0
  26. data/lib/rubocop/cop/obsession/too_many_paragraphs.rb +56 -0
  27. data/lib/rubocop/obsession/version.rb +5 -0
  28. data/lib/rubocop/obsession.rb +9 -0
  29. metadata +100 -0
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Helpers
6
+ VERBS = File.read("#{__dir__}/files/verbs.txt").split
7
+
8
+ def rails_callback?(callback)
9
+ return true if callback == 'validate'
10
+
11
+ callback.match?(
12
+ /
13
+ ^(before|after|around)
14
+ _.*
15
+ (action|validation|create|update|save|destroy|commit|rollback)$
16
+ /x
17
+ )
18
+ end
19
+
20
+ def verb?(string)
21
+ short_string = string[2..] if string.start_with?('re')
22
+
23
+ VERBS.include?(string) || VERBS.include?(short_string)
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Obsession
6
+ module Graphql
7
+ # This cop checks for mutation names that do not start with a verb.
8
+ #
9
+ # Mutation names should start with a verb, because mutations are actions,
10
+ # and actions are best described with verbs.
11
+ #
12
+ # @example
13
+ #
14
+ # # bad
15
+ # module Mutations
16
+ # class Event < Base
17
+ # end
18
+ # end
19
+ #
20
+ # # good
21
+ # module Mutations
22
+ # class TrackEvent < Base
23
+ # end
24
+ # end
25
+ class MutationName < Cop
26
+ include Helpers
27
+
28
+ MSG = 'Mutation name should start with a verb.'
29
+
30
+ def on_class(class_node)
31
+ class_name = class_node.identifier.source
32
+ class_name_first_word = class_name.underscore.split('_').first
33
+
34
+ add_offense(class_node) if !verb?(class_name_first_word)
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,244 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Obsession
6
+ # This cop checks for private/protected methods that are not ordered correctly.
7
+ #
8
+ # Code should read from top to bottom: methods should be defined in the
9
+ # same order as the order when they are first mentioned.
10
+ # Private/protected methods should follow that rule.
11
+ #
12
+ # Note 1: public methods do not have to follow that rule, and can be
13
+ # defined in any order the developer wants, like by order of
14
+ # importance. This is because they are usually called outside of the
15
+ # class and often not called within the class at all. If possible though,
16
+ # developers should still try to order their public methods from top to
17
+ # bottom when it makes sense.
18
+ #
19
+ # Note 2: method order cannot be computed for methods called by `send`,
20
+ # metaprogramming, private methods called by superclasses or modules,
21
+ # etc. This cop's suggestions and autocorrections may be slightly off for
22
+ # these kinds of edge cases.
23
+ #
24
+ # Note 3: for more information on this style of method ordering, see
25
+ # Robert C. Martin's "Clean Code" book > "Chapter 3: Functions" > "One
26
+ # level of abstraction per function" > "Reading Code from Top to Bottom:
27
+ # The Stepdown Rule" chapter.
28
+ #
29
+ # @example
30
+ #
31
+ # # bad
32
+ # def perform
33
+ # return if method_a?
34
+ # method_b
35
+ # method_c
36
+ # end
37
+ #
38
+ # private
39
+ #
40
+ # def method_c; ...; end
41
+ # def method_b; ...; end
42
+ # def method_a?; ...; end
43
+ #
44
+ # # good
45
+ # def perform
46
+ # return if method_a?
47
+ # method_b
48
+ # method_c
49
+ # end
50
+ #
51
+ # private
52
+ #
53
+ # def method_a?; ...; end
54
+ # def method_b; ...; end
55
+ # def method_c; ...; end
56
+ class MethodOrder < Base
57
+ include Helpers
58
+ include RangeHelp
59
+ include VisibilityHelp
60
+ extend AutoCorrector
61
+
62
+ MSG = 'Method `%<after>s` should appear below `%<previous>s`.'
63
+
64
+ def_node_search :private_node, <<~PATTERN
65
+ (send nil? {:private :protected})
66
+ PATTERN
67
+
68
+ def_node_matcher :on_callback, <<~PATTERN
69
+ (send nil? $_ (sym $_) ...)
70
+ PATTERN
71
+
72
+ def_node_search :method_calls, <<~PATTERN
73
+ (send nil? $_ ...)
74
+ PATTERN
75
+
76
+ class Node
77
+ attr_accessor :value, :children
78
+
79
+ def initialize(value:, children: [])
80
+ @value = value
81
+ @children = children
82
+ end
83
+ end
84
+
85
+ def on_class(class_node)
86
+ @class_node = class_node
87
+ find_private_node || return
88
+ build_methods || return
89
+ build_callback_methods
90
+
91
+ build_method_call_tree
92
+ build_ordered_private_methods
93
+ build_private_methods
94
+
95
+ verify_private_methods_order
96
+ end
97
+
98
+ private
99
+
100
+ def find_private_node
101
+ @private_node = private_node(@class_node)&.first
102
+ end
103
+
104
+ def build_methods
105
+ @methods = {}
106
+ return false if @class_node&.body&.type != :begin
107
+
108
+ @class_node.body.children.each do |child|
109
+ @methods[child.method_name] = child if child.type == :def
110
+ end
111
+
112
+ @methods.any?
113
+ end
114
+
115
+ def build_callback_methods
116
+ @callback_methods = []
117
+
118
+ @class_node.body.children.each do |node|
119
+ on_callback(node) do |callback, method_name|
120
+ if rails_callback?(callback.to_s) && @methods[method_name]
121
+ @callback_methods << @methods[method_name]
122
+ end
123
+ end
124
+ end
125
+ end
126
+
127
+ def build_method_call_tree
128
+ methods = (@callback_methods + @methods.values).uniq
129
+
130
+ @method_call_tree =
131
+ Node.new(value: nil, children: methods.map { |method| method_call_tree(method) })
132
+ end
133
+
134
+ def method_call_tree(method_node, seen_method_calls = Set.new)
135
+ method_name = method_node.method_name
136
+ return nil if seen_method_calls.include?(method_name)
137
+ called_methods = find_called_methods(method_node)
138
+ return Node.new(value: method_node, children: []) if called_methods.empty?
139
+
140
+ children =
141
+ called_methods.filter_map do |called_method|
142
+ method_call_tree(called_method, seen_method_calls + [method_name])
143
+ end
144
+
145
+ Node.new(value: method_node, children: children)
146
+ end
147
+
148
+ def find_called_methods(method_node)
149
+ called_methods =
150
+ method_calls(method_node).filter_map { |method_call| @methods[method_call] }
151
+
152
+ @called_methods ||= Set.new(@callback_methods)
153
+ @called_methods += called_methods
154
+
155
+ called_methods
156
+ end
157
+
158
+ def build_ordered_private_methods
159
+ @ordered_private_methods = ordered_private_methods(@method_call_tree)
160
+ end
161
+
162
+ def ordered_private_methods(node)
163
+ ast_node = node.value
164
+ method_name = should_ignore?(ast_node) ? nil : ast_node.method_name
165
+
166
+ next_names = node.children.flat_map { |child| ordered_private_methods(child) }
167
+
168
+ ([method_name] + next_names).compact.uniq
169
+ end
170
+
171
+ def should_ignore?(ast_node)
172
+ ast_node.nil? || node_visibility(ast_node) == :public ||
173
+ !@called_methods.include?(ast_node)
174
+ end
175
+
176
+ def build_private_methods
177
+ @private_methods = @methods.keys.intersection(@ordered_private_methods)
178
+ end
179
+
180
+ def verify_private_methods_order
181
+ @ordered_private_methods.each_with_index do |ordered_method, ordered_index|
182
+ index = @private_methods.index(ordered_method)
183
+
184
+ add_method_offense(ordered_method, ordered_index) && return if index != ordered_index
185
+ end
186
+ end
187
+
188
+ def add_method_offense(method_name, method_index)
189
+ method = @methods[method_name]
190
+ previous_method =
191
+ if method_index > 0
192
+ previous_method_name = @ordered_private_methods[method_index - 1]
193
+ @methods[previous_method_name]
194
+ else
195
+ @private_node
196
+ end
197
+
198
+ message = format(MSG, previous: previous_method.method_name, after: method_name)
199
+
200
+ add_offense(method, message: message) do |corrector|
201
+ autocorrect(corrector, method, previous_method)
202
+ end
203
+ end
204
+
205
+ def autocorrect(corrector, method, previous_method)
206
+ previous_method_range = source_range_with_comment(previous_method)
207
+ method_range = source_range_with_comment(method)
208
+
209
+ corrector.insert_after(previous_method_range, method_range.source)
210
+ corrector.remove(method_range)
211
+ end
212
+
213
+ def source_range_with_comment(node)
214
+ range_between(start_position_with_comment(node), end_position(node) + 1)
215
+ end
216
+
217
+ def start_position_with_comment(node)
218
+ first_comment = nil
219
+
220
+ (node.first_line - 1).downto(1) do |annotation_line|
221
+ comment = processed_source.comment_at_line(annotation_line)
222
+ break if !comment
223
+ first_comment = comment if whole_line_comment_at_line?(annotation_line)
224
+ end
225
+
226
+ start_line_position(first_comment || node)
227
+ end
228
+
229
+ def whole_line_comment_at_line?(line)
230
+ /\A\s*#/.match?(processed_source.lines[line - 1])
231
+ end
232
+
233
+ def start_line_position(node)
234
+ processed_source.buffer.line_range(node.loc.line).begin_pos - 1
235
+ end
236
+
237
+ def end_position(node)
238
+ end_line = processed_source.buffer.line_for_position(node.loc.expression.end_pos)
239
+ processed_source.buffer.line_range(end_line).end_pos
240
+ end
241
+ end
242
+ end
243
+ end
244
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Obsession
6
+ # This cop checks for `next` (and sometimes `break`) in loops.
7
+ #
8
+ # - For big loops, `next` or `break` indicates that the loop body has
9
+ # significant logic, which means it should be moved into its own method,
10
+ # and you can convert the `next` or `break` into `return` and the like.
11
+ # - For small loops, you can just use normal conditions instead of `next`.
12
+ # `break` is allowed.
13
+ #
14
+ # Note: Sometimes loops can also be rethought, like transforming a `loop`
15
+ # + `break` into a `while`.
16
+ #
17
+ # @example
18
+ #
19
+ # # bad
20
+ # github_teams.each do |github_team|
21
+ # next if github_team['size'] == 0
22
+ # team = @company.teams.find_or_initialize_by(github_team['id'])
23
+ #
24
+ # team.update!(
25
+ # name: github_team['name'],
26
+ # description: github_team['description'],
27
+ # owner: @company,
28
+ # )
29
+ # end
30
+ #
31
+ # # good
32
+ # github_teams.each do |github_team| { |github_team| upsert_team(github_team) }
33
+ #
34
+ # def upsert_team(github_team)
35
+ # return if github_team['size'] == 0
36
+ # team = @company.teams.find_or_initialize_by(github_team['id'])
37
+ #
38
+ # team.update!(
39
+ # name: github_team['name'],
40
+ # description: github_team['description'],
41
+ # owner: @company,
42
+ # )
43
+ # end
44
+ #
45
+ # # bad
46
+ # def highlight
47
+ # blog_posts.each do |blog_post|
48
+ # next if !blog_post.published?
49
+ #
50
+ # self.highlighted = true
51
+ # end
52
+ # end
53
+ #
54
+ # # good
55
+ # def highlight
56
+ # blog_posts.each do |blog_post|
57
+ # if blog_post.published?
58
+ # self.highlighted = true
59
+ # end
60
+ # end
61
+ # end
62
+ class NoBreakOrNext < Cop
63
+ BIG_LOOP_MSG =
64
+ 'Avoid `break`/`next` in big loop, decompose into private method or rethink loop.'
65
+ NO_NEXT_MSG = 'Avoid `next` in loop, use conditions or rethink loop.'
66
+ BIG_LOOP_MIN_LINES = 7
67
+
68
+ def_node_matcher :contains_break_or_next?, <<~PATTERN
69
+ `(if <({next break}) ...>)
70
+ PATTERN
71
+
72
+ def_node_matcher :contains_next?, <<~PATTERN
73
+ `(if <(next) ...>)
74
+ PATTERN
75
+
76
+ def on_block(node)
77
+ return if !contains_break_or_next?(node)
78
+
79
+ if big_loop?(node)
80
+ add_offense(node, message: BIG_LOOP_MSG)
81
+ elsif contains_next?(node)
82
+ add_offense(node, message: NO_NEXT_MSG)
83
+ end
84
+ end
85
+
86
+ private
87
+
88
+ def big_loop?(node)
89
+ node.line_count >= BIG_LOOP_MIN_LINES + 2
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Obsession
6
+ # This cop checks for methods with many instructions but no paragraphs.
7
+ #
8
+ # If your method code has many instructions that are not organized into
9
+ # paragraphs, you should break it up into multiple paragraphs to make the
10
+ # code more breathable and readable. The 3 possible paragraphs themes
11
+ # always are: initialization, action, and result.
12
+ #
13
+ # @example
14
+ #
15
+ # # bad
16
+ # def set_seo_content
17
+ # return if slug.blank?
18
+ # return if seo_content.present?
19
+ # template = SeoTemplate.find_by(template_type: 'BlogPost')
20
+ # return if template.blank?
21
+ # self.seo_content = build_seo_content(seo_template: template, slug: slug)
22
+ # Rails.logger.info('Content has been set')
23
+ # end
24
+ #
25
+ # # good
26
+ # def set_seo_content
27
+ # return if slug.blank?
28
+ # return if seo_content.present?
29
+ # template = SeoTemplate.find_by(template_type: 'BlogPost')
30
+ # return if template.blank?
31
+ #
32
+ # self.seo_content = build_seo_content(seo_template: template, slug: slug)
33
+ #
34
+ # Rails.logger.info('Content has been set')
35
+ # end
36
+ class NoParagraphs < Cop
37
+ MSG = 'Method has many instructions and should be broken up into paragraphs.'
38
+ MAX_CONSECUTIVE_INSTRUCTIONS_ALLOWED = 5
39
+
40
+ def on_def(node)
41
+ lines = processed_source.lines[node.first_line..(node.last_line - 2)]
42
+ return if lines.any?(&:blank?)
43
+ node_body_type = node&.body&.type
44
+ return if %i[send if or case array hash].include?(node_body_type)
45
+ return if %w[asgn str].any? { |string| node_body_type.to_s.include?(string) }
46
+
47
+ too_big =
48
+ case node_body_type
49
+ when :begin, :block, :rescue
50
+ node.body.children.count > MAX_CONSECUTIVE_INSTRUCTIONS_ALLOWED &&
51
+ !node.body.children.all? { |child| child.type.to_s.include?('asgn') }
52
+ else
53
+ lines.count > MAX_CONSECUTIVE_INSTRUCTIONS_ALLOWED
54
+ end
55
+
56
+ add_offense(node) if too_big
57
+ end
58
+ alias_method :on_defs, :on_def
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Obsession
6
+ # This cop checks for TODO/FIXME/etc comments.
7
+ #
8
+ # Avoid TODO comments, instead create tasks for them in your project
9
+ # management software, and assign them to the right person. Half of the
10
+ # TODOs usually never get done, and the code is then polluted with old
11
+ # stale TODOs. Sometimes developers really mean to work on their TODOs
12
+ # soon, but then Product re-prioritizes their work, or the developer
13
+ # leaves the company, and never gets a chance to tackle them.
14
+ class NoTodos < Cop
15
+ MSG = 'Avoid TODO comment, create a task in your project management tool instead.'
16
+ KEYWORD_REGEX = /(^|[^\w])(TODO|FIXME|OPTIMIZE|HACK)($|[^\w])/i
17
+
18
+ def on_new_investigation
19
+ processed_source.comments.each do |comment|
20
+ add_offense(comment) if comment.text.match?(KEYWORD_REGEX)
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Obsession
6
+ module Rails
7
+ # This cop checks for Rails callbacks with multiple fields.
8
+ #
9
+ # One method per callback definition makes the definition extra clear.
10
+ #
11
+ # @example
12
+ #
13
+ # # bad
14
+ # after_create :notify_followers, :send_stats
15
+ #
16
+ # # good
17
+ # after_create :notify_followers
18
+ # after_create :send_stats
19
+ class CallbackOneMethod < Cop
20
+ include Helpers
21
+
22
+ MSG = 'Declare only one method per callback definition.'
23
+
24
+ def_node_matcher :on_callback, <<~PATTERN
25
+ (send nil? $_ (sym _) (sym _) ...)
26
+ PATTERN
27
+
28
+ def on_send(node)
29
+ on_callback(node) { |callback| add_offense(node) if rails_callback?(callback.to_s) }
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Obsession
6
+ module Rails
7
+ # This cop checks for json(b) fields that are not fully defined with
8
+ # defaults or comments.
9
+ #
10
+ # - json(b) fields should have a default value like {} or [] so code can
11
+ # do my_field['field'] or my_field.first without fear that my_field is
12
+ # nil.
13
+ # - It is impossible to know the structure of a json(b) field just by
14
+ # reading the schema, because json(b) is an unstructured type. That's why
15
+ # an "Example: ..." Postgres comment should always be present when
16
+ # defining the field.
17
+ #
18
+ # @example
19
+ #
20
+ # # bad
21
+ # add_column :languages, :items, :jsonb
22
+ #
23
+ # # good
24
+ # add_column :languages,
25
+ # :items,
26
+ # :jsonb,
27
+ # default: [],
28
+ # comment: "Example: [{ 'name': 'ruby' }, { 'name': 'python' }]"
29
+ class FullyDefinedJsonField < Cop
30
+ def_node_matcher :json_field?, <<~PATTERN
31
+ (send nil? :add_column _ _ (sym {:json :jsonb}) ...)
32
+ PATTERN
33
+
34
+ def_node_matcher :has_default?, <<~PATTERN
35
+ (hash <(pair (sym :default) ...) ...>)
36
+ PATTERN
37
+
38
+ def_node_matcher :has_comment_with_example?, <<~PATTERN
39
+ (hash <
40
+ (pair
41
+ (sym :comment)
42
+ `{
43
+ (str /^Example: [\\[\\{].{4,}[\\]\\}]/) |
44
+ (dstr (str /^Example: [\\[\\{]/) ... (str /[\\]\\}]/) )
45
+ }
46
+ )
47
+ ...
48
+ >)
49
+ PATTERN
50
+
51
+ def on_send(node)
52
+ return if !json_field?(node)
53
+ options_node = node.children[5]
54
+
55
+ if !has_default?(options_node)
56
+ add_offense(node, message: 'Add default value of {} or []')
57
+ end
58
+
59
+ if !has_comment_with_example?(options_node)
60
+ add_offense(
61
+ node,
62
+ message:
63
+ 'Add `comment: "Example: <example>"` option with an example array or hash value'
64
+ )
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Obsession
6
+ module Rails
7
+ # This cop checks for migrations that use `references` instead of `belongs_to`.
8
+ #
9
+ # Instead of adding `references` in migrations, use the `belongs_to`
10
+ # alias. It reads nicer and is more similar to the `belongs_to`
11
+ # declarations that you find in model code.
12
+ #
13
+ # @example
14
+ #
15
+ # # bad
16
+ # def change
17
+ # add_reference :blog_posts, :user
18
+ # end
19
+ #
20
+ # # good
21
+ # def change
22
+ # add_belongs_to :blog_posts, :user
23
+ # end
24
+ class MigrationBelongsTo < Cop
25
+ def_node_matcher :add_reference?, <<~PATTERN
26
+ (send nil? :add_reference ...)
27
+ PATTERN
28
+
29
+ def_node_matcher :table_reference?, <<~PATTERN
30
+ (send (lvar :t) :references ...)
31
+ PATTERN
32
+
33
+ def on_send(node)
34
+ if add_reference?(node)
35
+ add_offense(node, message: 'Use add_belongs_to instead of add_reference')
36
+ elsif table_reference?(node)
37
+ add_offense(node, message: 'Use t.belongs_to instead of t.references')
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end