rubocop-obsession 0.1.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.
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