rubocop-fourshark 0.2.1

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,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rubocop'
4
+
5
+ module RuboCop
6
+ module Cop
7
+ module Rails
8
+ # Ensures that every ActiveRecord association
9
+ # (belongs_to, has_many, has_one) has an `inverse_of` option.
10
+ #
11
+ # Stricter than the stock `Rails/InverseOf`, which only flags associations
12
+ # where Active Record cannot auto-detect the inverse. This cop mandates
13
+ # `inverse_of` on every association. Disable the stock cop when enabling
14
+ # this one.
15
+ #
16
+ # @example
17
+ # # bad
18
+ # belongs_to :user
19
+ #
20
+ # # good
21
+ # belongs_to :user, inverse_of: :posts
22
+ #
23
+ class MandatoryInverseOf < ::RuboCop::Cop::Base
24
+ MSG = 'All associations must declare `inverse_of`.'
25
+
26
+ def self.default_configuration
27
+ super.merge(
28
+ 'Include' => ['app/models/**/*.rb'],
29
+ 'Exclude' => ['app/serializers/**/*.rb']
30
+ )
31
+ end
32
+
33
+ def on_send(node)
34
+ return unless in_model_file?(processed_source.file_path)
35
+ return unless association?(node)
36
+ return if polymorphic_or_through?(node)
37
+
38
+ kwargs = node.last_argument
39
+
40
+ missing_inverse_of =
41
+ !kwargs || !kwargs.hash_type? || kwargs.keys.none? { |k| k.value == :inverse_of }
42
+
43
+ add_offense(node.loc.selector) if missing_inverse_of
44
+ rescue StandardError => e
45
+ source_name =
46
+ if processed_source && processed_source.buffer
47
+ processed_source.file_path
48
+ else
49
+ 'unknown'
50
+ end
51
+
52
+ warn "Rails/MandatoryInverseOf failed on #{source_name}: #{e.message}"
53
+ end
54
+
55
+ private
56
+
57
+ def association?(node)
58
+ %i[belongs_to has_many has_one].include?(node.method_name)
59
+ end
60
+
61
+ def in_model_file?(path)
62
+ return false if path.nil?
63
+
64
+ path.start_with?(File.join(Dir.pwd, 'app/models'))
65
+ end
66
+
67
+ # Detects if the association has `polymorphic: true` or `through: ...`
68
+ def polymorphic_or_through?(node)
69
+ # Look for any hash arguments and check their keys
70
+ node.arguments.any? do |arg|
71
+ next false unless arg.hash_type?
72
+
73
+ arg.keys.any? { |k| %i[polymorphic through].include?(k.value) }
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rubocop'
4
+
5
+ module RuboCop
6
+ module Cop
7
+ module Rails
8
+ # Requires `belongs_to` to declare `optional: true`. The 4Shark convention
9
+ # is to skip Rails' automatic presence/existence validation (a SELECT per
10
+ # record) and validate presence manually with `validates :x_id, presence: true`.
11
+ #
12
+ # Scoped to `app/models` via the `Include` config.
13
+ #
14
+ # @example
15
+ # # bad
16
+ # belongs_to :user
17
+ #
18
+ # # good
19
+ # belongs_to :user, optional: true
20
+ #
21
+ class OptionalBelongsTo < ::RuboCop::Cop::Base
22
+ MSG = 'Declare `belongs_to` with `optional: true` and validate presence manually.'
23
+
24
+ def self.default_configuration
25
+ super.merge(
26
+ 'Include' => ['app/models/**/*.rb']
27
+ )
28
+ end
29
+
30
+ def on_send(node)
31
+ return unless node.method?(:belongs_to)
32
+ return if optional_true?(node)
33
+
34
+ add_offense(node.loc.selector)
35
+ end
36
+
37
+ private
38
+
39
+ def optional_true?(node)
40
+ kwargs = node.last_argument
41
+ return false if !kwargs || !kwargs.hash_type?
42
+
43
+ kwargs.pairs.any? { |pair| pair.key.value == :optional && pair.value.true_type? }
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rubocop'
4
+
5
+ module RuboCop
6
+ module Cop
7
+ module Rails
8
+ # Requires class-level macro declarations of the same kind to be sorted
9
+ # alphabetically by their first symbol argument. Checked per macro name:
10
+ # all `belongs_to` sorted among themselves, all `validates` sorted, etc.
11
+ #
12
+ # Exceptions (lifecycle callbacks, dependency-ordered items, logical
13
+ # grouping) are NOT modelled — this is an experimental cop; if it flags
14
+ # more legitimate cases than it helps, drop it.
15
+ #
16
+ # @example
17
+ # # bad
18
+ # validates :name
19
+ # validates :email
20
+ #
21
+ # # good
22
+ # validates :email
23
+ # validates :name
24
+ #
25
+ class OrderedMacros < ::RuboCop::Cop::Base
26
+ MSG = 'Sort `%<macro>s` declarations alphabetically (`%<name>s` should come before `%<previous>s`).'
27
+
28
+ MACROS = %i[belongs_to has_one has_many has_and_belongs_to_many validates scope].freeze
29
+
30
+ def self.default_configuration
31
+ super.merge('Include' => ['app/models/**/*.rb'])
32
+ end
33
+
34
+ def on_class(node)
35
+ body = node.body
36
+ return unless body
37
+
38
+ statements = body.begin_type? ? body.children : [body]
39
+
40
+ MACROS.each do |macro|
41
+ flag_unsorted(statements.select { |statement| macro_call?(statement, macro) })
42
+ end
43
+ end
44
+
45
+ private
46
+
47
+ def flag_unsorted(calls)
48
+ calls.each_cons(2) do |previous, current|
49
+ previous_name = macro_name(previous)
50
+ current_name = macro_name(current)
51
+ next if current_name >= previous_name
52
+
53
+ add_offense(
54
+ current.loc.selector,
55
+ message: format(MSG, macro: current.method_name, name: current_name, previous: previous_name)
56
+ )
57
+ end
58
+ end
59
+
60
+ def macro_call?(node, macro)
61
+ return false unless node.is_a?(::RuboCop::AST::Node) && node.send_type?
62
+ return false unless node.method?(macro)
63
+
64
+ first = node.first_argument
65
+ first && first.sym_type?
66
+ end
67
+
68
+ def macro_name(node)
69
+ node.first_argument.value.to_s
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rubocop'
4
+
5
+ module RuboCop
6
+ module Cop
7
+ module RSpec
8
+ # Forbids conditional logic (`if`/`case`) inside a `let`/`let!` block.
9
+ # Branching object setup belongs in separate `context` blocks, not in a
10
+ # single `let`. Ternaries are allowed.
11
+ #
12
+ # @example
13
+ # # bad
14
+ # let(:user) do
15
+ # if admin?
16
+ # create(:user, :admin)
17
+ # else
18
+ # create(:user)
19
+ # end
20
+ # end
21
+ #
22
+ # # good — one context each
23
+ # context 'when admin' do
24
+ # let(:user) { create(:user, :admin) }
25
+ # end
26
+ #
27
+ class ConditionalInLet < ::RuboCop::Cop::Base
28
+ MSG = 'Do not put conditional logic in a `let` — use separate contexts.'
29
+
30
+ LET_METHODS = %i[let let!].freeze
31
+
32
+ def on_block(node)
33
+ return unless let_block?(node)
34
+
35
+ body = node.body
36
+ return unless body
37
+
38
+ body.each_node(:if, :case) do |conditional|
39
+ next if conditional.if_type? && conditional.ternary?
40
+
41
+ add_offense(conditional.loc.keyword)
42
+ end
43
+ end
44
+
45
+ alias on_numblock on_block
46
+ alias on_itblock on_block
47
+
48
+ private
49
+
50
+ def let_block?(node)
51
+ send = node.send_node
52
+ LET_METHODS.include?(send.method_name) && send.receiver.nil?
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rubocop'
4
+
5
+ module RuboCop
6
+ module Cop
7
+ module RSpec
8
+ # Forbids object creation (`create`/`build`/`create_list`/`build_list`)
9
+ # inside a `before` block. Object creation belongs in `let`; `before` is
10
+ # for actions only.
11
+ #
12
+ # @example
13
+ # # bad
14
+ # before { @user = create(:user) }
15
+ #
16
+ # # good
17
+ # let(:user) { create(:user) }
18
+ #
19
+ class FactoryBotInBefore < ::RuboCop::Cop::Base
20
+ MSG = 'Do not create objects in `before` — use `let` for object creation.'
21
+
22
+ CREATION_METHODS = %i[create build create_list build_list].freeze
23
+
24
+ def on_block(node)
25
+ return unless before_block?(node)
26
+
27
+ node.each_node(:send) do |send|
28
+ next unless CREATION_METHODS.include?(send.method_name) && send.receiver.nil?
29
+
30
+ add_offense(send.loc.selector)
31
+ end
32
+ end
33
+
34
+ alias on_numblock on_block
35
+ alias on_itblock on_block
36
+
37
+ private
38
+
39
+ def before_block?(node)
40
+ send = node.send_node
41
+ send.method?(:before) && send.receiver.nil?
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rubocop'
4
+
5
+ module RuboCop
6
+ module Cop
7
+ module RSpec
8
+ # Ensures that RSpec association specs handle `.inverse_of` correctly
9
+ # depending on whether the model is a root (directly `< ApplicationRecord`)
10
+ # or an STI subclass.
11
+ #
12
+ # Rules:
13
+ # - Root models (direct superclass = `ApplicationRecord`) must include `.inverse_of`.
14
+ # - STI subclasses (superclass is another model) must NOT include `.inverse_of` (it belongs to the parent).
15
+ # - Associations marked as polymorphic or through are ignored.
16
+ # - Classes whose model file is missing or whose superclass is not a model are ignored.
17
+ #
18
+ # The root/subclass decision is made **statically** — by reading the model
19
+ # file and parsing its `class X < Y` declaration. The cop never loads the
20
+ # model class (a linter runs without the Rails app booted).
21
+ #
22
+ class InverseOfMatcher < ::RuboCop::Cop::Base
23
+ MSG_MISSING_INVERSE = 'Root models must include `.inverse_of` in association specs.'
24
+ MSG_FORBIDDEN_INVERSE = 'Subclasses must NOT include `.inverse_of` in specs (it belongs to the parent).'
25
+
26
+ def self.default_configuration
27
+ super.merge(
28
+ 'Include' => ['spec/models/**/*_spec.rb']
29
+ )
30
+ end
31
+
32
+ def on_send(node)
33
+ return unless node.method?(:belong_to)
34
+ return if chain_has_option?(node, :polymorphic)
35
+ return if chain_has_option?(node, :through)
36
+
37
+ model_name = model_class_from_spec
38
+ return unless model_name
39
+
40
+ classification = classify_model(model_name)
41
+ return if classification == :non_ar
42
+
43
+ if classification == :root
44
+ add_offense(node.loc.selector, message: MSG_MISSING_INVERSE) unless chain_has_inverse_of?(node)
45
+ elsif classification == :subclass
46
+ add_offense(node.loc.selector, message: MSG_FORBIDDEN_INVERSE) if chain_has_inverse_of?(node)
47
+ end
48
+ rescue StandardError => e
49
+ source_name =
50
+ if processed_source && processed_source.buffer
51
+ processed_source.file_path
52
+ else
53
+ 'unknown'
54
+ end
55
+
56
+ warn "RSpec/InverseOfMatcher failed on #{source_name}: #{e.message}"
57
+ end
58
+
59
+ private
60
+
61
+ # Check if .inverse_of exists in the send chain
62
+ def chain_has_inverse_of?(node)
63
+ node.each_ancestor(:send).any? { |ancestor| ancestor.method?(:inverse_of) }
64
+ end
65
+
66
+ # Check if association has a given option (e.g., :polymorphic, :through)
67
+ def chain_has_option?(node, option_name)
68
+ node.each_ancestor(:send).any? do |ancestor|
69
+ ancestor.arguments.any? do |arg|
70
+ arg.hash_type? && arg.keys.any? { |k| k.value == option_name }
71
+ end
72
+ end
73
+ end
74
+
75
+ # Convert spec file path into model class name
76
+ def model_class_from_spec
77
+ path = processed_source.file_path if processed_source && processed_source.buffer
78
+ return nil unless path
79
+
80
+ match = path.match(%r{spec/models/(.+)_spec\.rb})
81
+ return nil unless match
82
+
83
+ relative = match[1]
84
+ return nil if relative.empty?
85
+
86
+ relative.split('/').map { |part| part.split('_').map(&:capitalize).join }.join('::')
87
+ end
88
+
89
+ # Classify the model as :root, :subclass, or :non_ar by reading the
90
+ # model file statically (no class loading).
91
+ def classify_model(model_name)
92
+ content = model_source(model_name)
93
+ return :non_ar unless content
94
+
95
+ superclass = superclass_of(content)
96
+ return :non_ar unless superclass
97
+ return :root if superclass == 'ApplicationRecord'
98
+ return :subclass if model_source(superclass)
99
+
100
+ :non_ar
101
+ rescue StandardError
102
+ :non_ar
103
+ end
104
+
105
+ def model_source(model_name)
106
+ path = File.join(Dir.pwd, 'app/models', "#{camel_to_snake(model_name)}.rb")
107
+ File.exist?(path) ? File.read(path) : nil
108
+ end
109
+
110
+ def superclass_of(content)
111
+ match = content.match(/^\s*class\s+[\w:]+\s*<\s*([\w:]+)/)
112
+ match && match[1]
113
+ end
114
+
115
+ # "UserAccount" → "user_account"; "Plan::Statement" → "plan/statement"
116
+ def camel_to_snake(name)
117
+ name.gsub('::', '/')
118
+ .gsub(/([A-Z\d]+)([A-Z][a-z])/, '\1_\2')
119
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
120
+ .downcase
121
+ end
122
+ end
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rubocop'
4
+
5
+ module RuboCop
6
+ module Cop
7
+ module RSpec
8
+ # Forbids overriding a `let`/`let!` that is already defined in an outer
9
+ # example group. Shadowing an ancestor's `let` makes it ambiguous which
10
+ # value applies in a given example.
11
+ #
12
+ # A `let` that is specific to a single scenario is allowed, even when a
13
+ # sibling context defines a `let` of the same name — siblings are separate
14
+ # scopes and do not override each other. Only an inner `let` that shadows
15
+ # an `let` from an ancestor example group is flagged.
16
+ #
17
+ # @example
18
+ # # bad — inner let shadows the outer one
19
+ # let(:user) { create(:user) }
20
+ #
21
+ # context 'when admin' do
22
+ # let(:user) { create(:user, :admin) }
23
+ # end
24
+ #
25
+ # # good — scenario-specific let, no ancestor defines it
26
+ # context 'when admin' do
27
+ # let(:user) { create(:user, :admin) }
28
+ # end
29
+ #
30
+ # context 'when regular' do
31
+ # let(:user) { create(:user) }
32
+ # end
33
+ #
34
+ class OverwrittenLet < ::RuboCop::Cop::Base
35
+ MSG = 'Do not override the outer `let` `%<name>s`; use a distinct name or set the value in `before`.'
36
+
37
+ LET_METHODS = %i[let let!].freeze
38
+ EXAMPLE_GROUP_METHODS = %i[describe context].freeze
39
+
40
+ def on_send(node)
41
+ return unless LET_METHODS.include?(node.method_name) && node.receiver.nil?
42
+
43
+ name = let_name(node)
44
+ return unless name
45
+
46
+ immediate_group = node.each_ancestor(:block).find { |block| example_group?(block) }
47
+ return unless immediate_group
48
+ return unless outer_groups(immediate_group).any? { |group| defines_let?(group, name) }
49
+
50
+ add_offense(node.loc.selector, message: format(MSG, name: name))
51
+ end
52
+
53
+ private
54
+
55
+ def outer_groups(group)
56
+ group.each_ancestor(:block).select { |block| example_group?(block) }
57
+ end
58
+
59
+ def example_group?(block)
60
+ send = block.send_node
61
+ EXAMPLE_GROUP_METHODS.include?(send.method_name)
62
+ end
63
+
64
+ def defines_let?(group, name)
65
+ body = group.body
66
+ return false unless body
67
+
68
+ statements = body.begin_type? ? body.children : [body]
69
+
70
+ statements.any? { |statement| let_named?(statement, name) }
71
+ end
72
+
73
+ def let_named?(statement, name)
74
+ return false unless statement.is_a?(::RuboCop::AST::Node)
75
+
76
+ send = statement.respond_to?(:send_node) ? statement.send_node : statement
77
+ return false unless send.send_type?
78
+ return false unless LET_METHODS.include?(send.method_name) && send.receiver.nil?
79
+
80
+ first = send.first_argument
81
+ first && first.sym_type? && first.value == name
82
+ end
83
+
84
+ def let_name(node)
85
+ first = node.first_argument
86
+ first && first.sym_type? ? first.value : nil
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rubocop'
4
+
5
+ module RuboCop
6
+ module Cop
7
+ module Style
8
+ class DisallowSafeNavigation < ::RuboCop::Cop::Base
9
+ MSG = 'Do not use safe navigation (`&.`). Use explicit conditionals instead.'
10
+
11
+ def on_csend(node)
12
+ add_offense(node)
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rubocop'
4
+
5
+ module RuboCop
6
+ module Cop
7
+ module Style
8
+ class DisallowTry < ::RuboCop::Cop::Base
9
+ MSG = 'Do not use `try` or `try!`. Use explicit conditionals instead.'
10
+
11
+ RESTRICT_ON_SEND = %i[try try!].freeze
12
+
13
+ def on_send(node)
14
+ add_offense(node)
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'lint_roller'
4
+
5
+ module RuboCop
6
+ module Fourshark
7
+ # A plugin that integrates rubocop-fourshark with RuboCop's plugin system.
8
+ class Plugin < LintRoller::Plugin
9
+ def about
10
+ LintRoller::About.new(
11
+ name: 'rubocop-fourshark',
12
+ version: VERSION,
13
+ homepage: 'https://github.com/4shark/rubocop-fourshark',
14
+ description: "4Shark's Ruby, Rails, and RSpec conventions as RuboCop cops."
15
+ )
16
+ end
17
+
18
+ def supported?(context)
19
+ context.engine == :rubocop
20
+ end
21
+
22
+ def rules(_context)
23
+ LintRoller::Rules.new(
24
+ type: :path,
25
+ config_format: :rubocop,
26
+ value: Pathname.new(__dir__).join('../../../config/default.yml')
27
+ )
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Fourshark
5
+ VERSION = '0.2.1'
6
+ end
7
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'fourshark/version'
4
+
5
+ module RuboCop
6
+ module Fourshark
7
+ class Error < StandardError; end
8
+ # Your code goes here...
9
+ end
10
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rubocop'
4
+
5
+ require_relative 'rubocop/fourshark'
6
+ require_relative 'rubocop/fourshark/version'
7
+ require_relative 'rubocop/fourshark/plugin'
8
+
9
+ require_relative 'rubocop/cop/fourshark_cops'