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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 202b492f9486b827fd67079c3a7bb113bb194a87fd968646eebbf30b881140cc
4
+ data.tar.gz: 20f9b022eb67bad4c30fe2295c3d56a30c027594c15142303dcfe707c5390f85
5
+ SHA512:
6
+ metadata.gz: 6cd1bc1df875dc06da1fbe6091e896afe434e99f13e64c3f564e5ac43bcf23ac5cb5a4e3e0ff6792829df9df109ec331066fac1395472b536372ced8dd025d65
7
+ data.tar.gz: f62cfe9f582d0ef9108800eef733aa8957852b7183cc3bee53f7182680a51ceda57293d19137f4baf9badb345cf9add64f9a15643734d0b515672188026435a9
data/.ruby-gemset ADDED
@@ -0,0 +1 @@
1
+ rubocop-fourshark
data/.ruby-version ADDED
@@ -0,0 +1 @@
1
+ 4.0.5
data/CHANGELOG.md ADDED
@@ -0,0 +1,20 @@
1
+ ## [0.2.1] - 2026-05-29
2
+
3
+ ### Fixed
4
+
5
+ - `RSpec/InverseOfMatcher` no longer crashes RuboCop — it classifies root vs subclass statically (reading the model file) instead of loading the model class
6
+
7
+ ## [0.2.0] - 2026-05-29
8
+
9
+ ### Added
10
+
11
+ - Disable stock `Rails/InverseOf` and `Style/SafeNavigation`, which are superseded or contradicted by 4Shark cops
12
+
13
+ ### Changed
14
+
15
+ - Cop names aligned with RuboCop naming conventions
16
+ - `RSpec/LetNotInContext` renamed to `RSpec/OverwrittenLet` and relaxed to flag only a `let` that overrides one from an outer scope
17
+
18
+ ## [0.1.0] - 2026-05-29
19
+
20
+ - Initial release
@@ -0,0 +1,10 @@
1
+ # Code of Conduct
2
+
3
+ "rubocop-fourshark" follows [The Ruby Community Conduct Guideline](https://www.ruby-lang.org/en/conduct) in all "collaborative space", which is defined as community communications channels (such as mailing lists, submitted patches, commit comments, etc.):
4
+
5
+ * Participants will be tolerant of opposing views.
6
+ * Participants must ensure that their language and actions are free of personal attacks and disparaging personal remarks.
7
+ * When interpreting the words and actions of others, participants should always assume good intentions.
8
+ * Behaviour which can be reasonably considered harassment will not be tolerated.
9
+
10
+ If you have any concerns about behaviour within this project, please contact us at ["plribeiro3000@gmail.com"](mailto:"plribeiro3000@gmail.com").
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Paulo Ribeiro
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,129 @@
1
+ # rubocop-fourshark
2
+
3
+ A RuboCop extension that encodes 4Shark's Ruby, Rails, and RSpec conventions as enforceable cops.
4
+
5
+ ## Why this gem exists
6
+
7
+ 4Shark has its own definition of good Ruby/Rails/RSpec code. Two needs the stock RuboCop ecosystem does not cover on its own:
8
+
9
+ 1. **Conventions stock RuboCop has no cop for.** Rules like "every association declares `inverse_of`", "`belongs_to` is `optional: true` with manual presence validation", or "no associations inside factories" are 4Shark decisions with no equivalent in `rubocop-rails`/`rubocop-rspec`. This gem ships them as real cops.
10
+ 2. **Stock conventions 4Shark deliberately rejects.** RuboCop's defaults nudge toward patterns 4Shark does not want — safe navigation (`&.`) and `try` being the clearest examples. Rather than each repo re-litigating the same `.rubocop.yml` overrides, the position is made enforceable in one place: a custom cop that flags the rejected construct.
11
+
12
+ The goal is a single dependency that every 4Shark Ruby repository inherits, so the conventions are identical across all of them — and a new convention is added once, here, instead of in each repository.
13
+
14
+ ## Installation
15
+
16
+ The gem is published to RubyGems. Add it to the application's `Gemfile`:
17
+
18
+ ```ruby
19
+ gem 'rubocop-fourshark', require: false
20
+ ```
21
+
22
+ or:
23
+
24
+ ```bash
25
+ bundle add rubocop-fourshark --require=false
26
+ ```
27
+
28
+ Then activate it as a plugin in `.rubocop.yml`:
29
+
30
+ ```yaml
31
+ plugins:
32
+ - rubocop-fourshark
33
+ ```
34
+
35
+ Activating the plugin auto-loads the gem's `config/default.yml`, which enables every cop below.
36
+
37
+ > **Note — plugins are not transitively activated.** `rubocop-fourshark` depends on the upstream plugins (`rubocop-rails`, `rubocop-rspec`, `rubocop-rspec_rails`, `rubocop-performance`, `rubocop-factory_bot`), but `lint_roller` does not auto-activate a plugin's dependencies. Each repo still lists the upstream plugins it uses in its own `plugins:` block.
38
+
39
+ ## Cops
40
+
41
+ All cops are **enabled by default** the moment the plugin is activated. Cops scoped to a path (models, specs, factories) only run on files matching that path. Each cop's source file under [`lib/rubocop/cop`](lib/rubocop/cop) carries runnable `@example` blocks showing the bad/good shapes.
42
+
43
+ ### Naming
44
+
45
+ Cop names follow RuboCop's empirical convention — a noun phrase describing the construct or smell, no `Disallow*`/`No*` prefixes — with two deliberate exceptions:
46
+
47
+ - **`Style/DisallowSafeNavigation` / `Style/DisallowTry`** keep the `Disallow*` prefix. RuboCop has no idiom for "forbid a construct it otherwise permits" (it uses `EnforcedStyle` on one cop), and the natural noun name is already taken by a stock cop with the *opposite* intent — `Style/SafeNavigation` *converts to* `&.`. `Disallow*` is unambiguous and collision-free.
48
+ - **`Rails/MandatoryInverseOf`** is named to distinguish it from stock `Rails/InverseOf`, which only flags associations where Active Record cannot auto-detect the inverse. Ours mandates `inverse_of` on *every* association — a strict superset — so the gem disables the stock cop (below).
49
+
50
+ ### Stock cops disabled
51
+
52
+ Where a 4Shark cop supersedes or contradicts a stock cop, `config/default.yml` turns the stock one off:
53
+
54
+ | Disabled stock cop | Why |
55
+ |---|---|
56
+ | `Rails/InverseOf` | superseded by `Rails/MandatoryInverseOf` (covers its cases and more) |
57
+ | `Style/SafeNavigation` | contradicts `Style/DisallowSafeNavigation` — it pushes `&.`, we forbid it |
58
+
59
+ ### Style
60
+
61
+ | Cop | Intent |
62
+ |---|---|
63
+ | `Style/DisallowSafeNavigation` | Flags safe navigation (`&.`). 4Shark rejects it — a `&.` chain silently swallows a `nil` that usually signals a real bug. Use an explicit conditional so the `nil` case is handled on purpose. |
64
+ | `Style/DisallowTry` | Flags `try` / `try!`. Same rationale — it hides the `nil`/missing-method case instead of handling it explicitly. |
65
+
66
+ ### Layout
67
+
68
+ | Cop | Intent |
69
+ |---|---|
70
+ | `Layout/MultilineStatementSpacing` | Requires a blank line between two consecutive statements when either spans multiple lines, so multi-line statements read as distinct units. |
71
+
72
+ ### Rails (models)
73
+
74
+ | Cop | Intent |
75
+ |---|---|
76
+ | `Rails/MandatoryInverseOf` | Every association (`belongs_to`/`has_many`/`has_one`) must declare `inverse_of`, so both sides resolve to the same in-memory object. Stricter than stock `Rails/InverseOf`, which only fires when AR can't auto-detect the inverse. Scoped to `app/models`. |
77
+ | `Rails/BidirectionalAssociation` | An association must be declared on both sides of the relationship — the opposite model must carry the matching association. Scoped to `app/models`. |
78
+ | `Rails/OptionalBelongsTo` | `belongs_to` must be `optional: true` (see the rationale below). Scoped to `app/models`. |
79
+ | `Rails/OrderedMacros` | Same-kind class macros (associations, validations, scopes) must be sorted alphabetically within their group. Scoped to `app/models`. |
80
+
81
+ ### RSpec (specs)
82
+
83
+ | Cop | Intent |
84
+ |---|---|
85
+ | `RSpec/InverseOfMatcher` | Root models must assert `.inverse_of` in association specs; subclasses must not (it belongs to the parent). Scoped to `spec/models`. |
86
+ | `RSpec/OverwrittenLet` | A `let`/`let!` must not override one defined in an outer example group — shadowing makes it ambiguous which value applies. Scenario-specific `let`s, and the same name across sibling contexts, are fine. |
87
+ | `RSpec/ConditionalInLet` | A `let` must not contain conditional logic (`if`/`case`) — branch with separate `context`s instead. Ternaries are allowed. |
88
+ | `RSpec/FactoryBotInBefore` | Object creation belongs in `let`, not `before`. `before` is for actions, not for building the subjects under test. |
89
+
90
+ ### FactoryBot (factories)
91
+
92
+ | Cop | Intent |
93
+ |---|---|
94
+ | `FactoryBot/AssociationInFactory` | No associations declared inside a factory — they trigger cascading object creation and callbacks. Set the association manually in the spec. Scoped to `spec/factories`. |
95
+
96
+ The full rationale behind each convention (the "why this is good 4Shark code") lives in the team's engineering docs; this README states the intent each cop enforces. One convention deviates from a safe Rails default and is worth spelling out — see below.
97
+
98
+ ### Why `belongs_to` is `optional: true` by default
99
+
100
+ 4Shark never exposes internal database IDs across its API and upload boundaries. Clients send their **own** identifiers; each external identifier is mapped to its internal record (a surrogate-key cross-reference), and that lookup is scoped to the client's account — so it confirms both that the record **exists** and that it **belongs to the caller**, in a single step.
101
+
102
+ By the time an association is assigned, existence and ownership have already been verified — more strictly than Rails would. Rails' default `belongs_to` (`optional: false`) then adds an existence `SELECT` per record on top of that: redundant work, and at high API throughput a measurable per-request cost.
103
+
104
+ So the convention is:
105
+
106
+ - `belongs_to` is always declared `optional: true` (enforced by `Rails/OptionalBelongsTo`), turning off Rails' automatic existence validation.
107
+ - Presence is validated manually with `validates :x_id, presence: true` **where the business rule requires it** — case by case, not globally (which is why no cop enforces the presence side).
108
+
109
+ A deliberate performance trade-off backed by the external-identifier mapping — not an omission.
110
+
111
+ ## Development
112
+
113
+ After checking out the repo, install dependencies and run the suite:
114
+
115
+ ```bash
116
+ bundle install
117
+ bundle exec rspec # cop specs
118
+ bundle exec rubocop # self-lint (includes rubocop-internal_affairs)
119
+ ```
120
+
121
+ Each new cop ships with a spec (`expect_offense` / `expect_no_offenses`) and a `config/default.yml` entry.
122
+
123
+ ## Releasing
124
+
125
+ This project does **not** use HubFlow. Releases are cut from `main`: feature branches merge into `main` via PR, the version is bumped, a `vX.Y.Z` tag is created from `main`, and the gem is published to RubyGems. Consuming repos depend on the published version in their `Gemfile`.
126
+
127
+ ## License
128
+
129
+ Released under the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+ require 'rspec/core/rake_task'
5
+ require 'rubocop/rake_task'
6
+
7
+ RSpec::Core::RakeTask.new(:spec) do |spec|
8
+ spec.pattern = FileList['spec/**/*_spec.rb']
9
+ end
10
+
11
+ RuboCop::RakeTask.new
12
+
13
+ task default: %i[spec rubocop]
14
+
15
+ desc 'Generate a new cop with a template'
16
+
17
+ task :new_cop, [:cop] do |_task, args|
18
+ require 'rubocop'
19
+
20
+ cop_name = args.fetch(:cop) do
21
+ warn 'usage: bundle exec rake new_cop[Department/Name]'
22
+ exit!
23
+ end
24
+
25
+ generator = RuboCop::Cop::Generator.new(cop_name)
26
+
27
+ generator.write_source
28
+ generator.write_spec
29
+ generator.inject_require(root_file_path: 'lib/rubocop/cop/fourshark_cops.rb')
30
+ generator.inject_config(config_file_path: 'config/default.yml')
31
+
32
+ puts generator.todo
33
+ end
@@ -0,0 +1,96 @@
1
+ # rubocop-fourshark — custom cops (4Shark conventions).
2
+ # Cops live in their natural stock departments (Style/Rails/RSpec/Layout/FactoryBot)
3
+ # so the names read like any other cop. Where a 4Shark cop supersedes or
4
+ # contradicts a stock cop, the stock cop is disabled here (see the bottom block).
5
+
6
+ Layout/MultilineStatementSpacing:
7
+ Description: 'Add a blank line between consecutive statements when either spans multiple lines.'
8
+ Enabled: true
9
+ VersionAdded: '0.2.0'
10
+
11
+ Style/DisallowSafeNavigation:
12
+ Description: 'Do not use safe navigation (`&.`). Use explicit conditionals instead.'
13
+ Enabled: true
14
+ VersionAdded: '0.1.0'
15
+
16
+ Style/DisallowTry:
17
+ Description: 'Do not use `try` or `try!`. Use explicit conditionals instead.'
18
+ Enabled: true
19
+ VersionAdded: '0.1.0'
20
+
21
+ Rails/MandatoryInverseOf:
22
+ Description: 'Every association must declare `inverse_of` (stricter than stock `Rails/InverseOf`).'
23
+ Enabled: true
24
+ Include:
25
+ - 'app/models/**/*.rb'
26
+ Exclude:
27
+ - 'app/serializers/**/*.rb'
28
+ VersionAdded: '0.2.0'
29
+
30
+ Rails/BidirectionalAssociation:
31
+ Description: 'Associations must be declared on both sides of the relationship.'
32
+ Enabled: true
33
+ Include:
34
+ - 'app/models/**/*.rb'
35
+ Exclude:
36
+ - 'app/serializers/**/*.rb'
37
+ VersionAdded: '0.2.0'
38
+
39
+ Rails/OptionalBelongsTo:
40
+ Description: 'Declare `belongs_to` with `optional: true` and validate presence manually.'
41
+ Enabled: true
42
+ Include:
43
+ - 'app/models/**/*.rb'
44
+ VersionAdded: '0.1.0'
45
+
46
+ Rails/OrderedMacros:
47
+ Description: 'Sort same-kind class macro declarations (associations, validations, scopes) alphabetically.'
48
+ Enabled: true
49
+ Include:
50
+ - 'app/models/**/*.rb'
51
+ VersionAdded: '0.2.0'
52
+
53
+ RSpec/InverseOfMatcher:
54
+ Description: 'Root models must include `.inverse_of` in association specs; subclasses must not.'
55
+ Enabled: true
56
+ Include:
57
+ - 'spec/models/**/*_spec.rb'
58
+ VersionAdded: '0.2.0'
59
+
60
+ RSpec/OverwrittenLet:
61
+ Description: 'Do not override a `let` defined in an outer example group.'
62
+ Enabled: true
63
+ Include:
64
+ - 'spec/**/*_spec.rb'
65
+ VersionAdded: '0.2.0'
66
+
67
+ RSpec/ConditionalInLet:
68
+ Description: 'Do not put conditional logic in a `let` — use separate contexts.'
69
+ Enabled: true
70
+ Include:
71
+ - 'spec/**/*_spec.rb'
72
+ VersionAdded: '0.2.0'
73
+
74
+ RSpec/FactoryBotInBefore:
75
+ Description: 'Do not create objects in `before` — use `let` for object creation.'
76
+ Enabled: true
77
+ Include:
78
+ - 'spec/**/*_spec.rb'
79
+ VersionAdded: '0.2.0'
80
+
81
+ FactoryBot/AssociationInFactory:
82
+ Description: 'Do not declare associations in factories — set them manually in the spec.'
83
+ Enabled: true
84
+ Include:
85
+ - 'spec/factories/**/*.rb'
86
+ VersionAdded: '0.2.0'
87
+
88
+ # Stock cops disabled because a 4Shark cop supersedes or contradicts them.
89
+ # `Rails/InverseOf` only flags when AR can't auto-detect the inverse;
90
+ # `Rails/MandatoryInverseOf` covers it and more.
91
+ # `Style/SafeNavigation` converts to `&.`, the opposite of `Style/DisallowSafeNavigation`.
92
+ Rails/InverseOf:
93
+ Enabled: false
94
+
95
+ Style/SafeNavigation:
96
+ Enabled: false
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rubocop'
4
+
5
+ module RuboCop
6
+ module Cop
7
+ module FactoryBot
8
+ # Forbids declaring associations inside a `factory`/`trait` block. An
9
+ # association is a bare attribute call with no value (e.g. `customer`),
10
+ # which triggers cascading object creation and callbacks. Set the
11
+ # association manually in the spec instead.
12
+ #
13
+ # @example
14
+ # # bad
15
+ # factory :payment do
16
+ # amount { 100.0 }
17
+ # customer
18
+ # end
19
+ #
20
+ # # good
21
+ # factory :payment do
22
+ # amount { 100.0 }
23
+ # end
24
+ #
25
+ class AssociationInFactory < ::RuboCop::Cop::Base
26
+ MSG = 'Do not declare associations in factories — set them manually in the spec.'
27
+
28
+ CONTAINER_METHODS = %i[factory trait].freeze
29
+ # FactoryBot DSL methods that are bare calls but are NOT associations.
30
+ DSL_KEYWORDS = %i[skip_create initialize_with].freeze
31
+
32
+ def on_block(node)
33
+ return unless container_block?(node)
34
+
35
+ body = node.body
36
+ return unless body
37
+
38
+ statements = body.begin_type? ? body.children : [body]
39
+
40
+ statements.each do |statement|
41
+ add_offense(statement.loc.selector) if association_call?(statement)
42
+ end
43
+ end
44
+
45
+ alias on_numblock on_block
46
+ alias on_itblock on_block
47
+
48
+ private
49
+
50
+ def container_block?(node)
51
+ send = node.send_node
52
+ CONTAINER_METHODS.include?(send.method_name) && send.receiver.nil?
53
+ end
54
+
55
+ # A bare attribute call (no receiver, no arguments, no block) inside a
56
+ # factory body is an implicit association.
57
+ def association_call?(node)
58
+ return false unless node.is_a?(::RuboCop::AST::Node) && node.send_type?
59
+ return false unless node.receiver.nil? && node.arguments.empty?
60
+ return false if DSL_KEYWORDS.include?(node.method_name)
61
+
62
+ true
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'factory_bot/association_in_factory'
4
+ require_relative 'layout/multiline_statement_spacing'
5
+ require_relative 'rails/bidirectional_association'
6
+ require_relative 'rails/mandatory_inverse_of'
7
+ require_relative 'rails/optional_belongs_to'
8
+ require_relative 'rails/ordered_macros'
9
+ require_relative 'rspec/conditional_in_let'
10
+ require_relative 'rspec/factory_bot_in_before'
11
+ require_relative 'rspec/inverse_of_matcher'
12
+ require_relative 'rspec/overwritten_let'
13
+ require_relative 'style/disallow_safe_navigation'
14
+ require_relative 'style/disallow_try'
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rubocop'
4
+
5
+ module RuboCop
6
+ module Cop
7
+ module Layout
8
+ # Requires a blank line between two consecutive statements when either of
9
+ # them spans multiple lines. Adjacent single-line statements need no blank
10
+ # line. The boundary with the enclosing `def`/`do`/`end` is left to other
11
+ # cops.
12
+ #
13
+ # Detection-only (no autocorrect) for now — the comment-handling and
14
+ # autocorrect behaviour need validation against the real repos first.
15
+ #
16
+ # @example
17
+ # # bad
18
+ # foo(
19
+ # bar
20
+ # )
21
+ # baz
22
+ #
23
+ # # good
24
+ # foo(
25
+ # bar
26
+ # )
27
+ #
28
+ # baz
29
+ #
30
+ class MultilineStatementSpacing < ::RuboCop::Cop::Base
31
+ MSG = 'Add a blank line around multi-line statements.'
32
+
33
+ def on_begin(node)
34
+ node.children.each_cons(2) do |first, second|
35
+ next unless first.is_a?(::RuboCop::AST::Node) && second.is_a?(::RuboCop::AST::Node)
36
+ next unless first.multiline? || second.multiline?
37
+ next if blank_line_between?(first, second)
38
+
39
+ add_offense(second)
40
+ end
41
+ end
42
+
43
+ private
44
+
45
+ def blank_line_between?(first, second)
46
+ return false if second.first_line - first.last_line < 2
47
+
48
+ ((first.last_line + 1)...second.first_line).any? do |line|
49
+ processed_source.lines[line - 1].strip.empty?
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rubocop'
4
+
5
+ module RuboCop
6
+ module Cop
7
+ module Rails
8
+ # Ensures that all ActiveRecord associations are declared on both sides.
9
+ #
10
+ # Example:
11
+ # # bad
12
+ # class Post < ApplicationRecord
13
+ # belongs_to :user
14
+ # end
15
+ #
16
+ # class User < ApplicationRecord
17
+ # end
18
+ #
19
+ # # good
20
+ # class Post < ApplicationRecord
21
+ # belongs_to :user, inverse_of: :posts
22
+ # end
23
+ #
24
+ # class User < ApplicationRecord
25
+ # has_many :posts, inverse_of: :user
26
+ # end
27
+ #
28
+ class BidirectionalAssociation < ::RuboCop::Cop::Base
29
+ MSG = 'Associations must be declared on both sides of the relationship.'
30
+
31
+ def self.default_configuration
32
+ super.merge(
33
+ 'Include' => ['app/models/**/*.rb'],
34
+ 'Exclude' => ['app/serializers/**/*.rb']
35
+ )
36
+ end
37
+
38
+ def on_new_investigation
39
+ return unless in_model_file?(processed_source.file_path)
40
+ return unless processed_source.ast
41
+
42
+ associations.each do |model_name, _assoc_name, inverse_name, target_class|
43
+ next if model_name.nil? || target_class.nil?
44
+
45
+ opposite_model_path = File.join(Dir.pwd, "app/models/#{camel_to_snake(target_class)}.rb")
46
+ next unless File.exist?(opposite_model_path)
47
+
48
+ opposite_content = File.read(opposite_model_path)
49
+
50
+ expected_inverse =
51
+ if inverse_name
52
+ inverse_name.to_s
53
+ else
54
+ camel_to_snake(model_name).split('/').last.pluralize
55
+ end
56
+
57
+ unless /(belongs_to|has_many|has_one)\s+:#{expected_inverse}/.match?(opposite_content)
58
+ add_global_offense("#{target_class} is missing opposite association for #{model_name}")
59
+ end
60
+ end
61
+ rescue StandardError => e
62
+ source_name =
63
+ if processed_source && processed_source.buffer
64
+ processed_source.file_path
65
+ else
66
+ 'unknown'
67
+ end
68
+
69
+ warn "Rails/BidirectionalAssociation failed on #{source_name}: #{e.message}"
70
+ end
71
+
72
+ private
73
+
74
+ def associations
75
+ processed_source.ast.each_descendant(:send).filter_map do |node|
76
+ next unless %i[belongs_to has_many has_one].include?(node.method_name)
77
+
78
+ # Skip polymorphic associations and `as:` options
79
+ next if polymorphic_or_as?(node)
80
+
81
+ model_name = class_name_from_ast
82
+
83
+ assoc_name =
84
+ begin
85
+ node.first_argument.value if node.first_argument
86
+ rescue StandardError
87
+ nil
88
+ end
89
+
90
+ inverse_name = extract_inverse_of(node)
91
+ target_class = extract_class_name(node, assoc_name)
92
+
93
+ [model_name, assoc_name, inverse_name, target_class]
94
+ end
95
+ end
96
+
97
+ def extract_inverse_of(node)
98
+ kwargs = node.last_argument
99
+ return nil unless kwargs && kwargs.hash_type?
100
+
101
+ pair =
102
+ begin
103
+ kwargs.pairs.find { |p| p.key.value == :inverse_of }
104
+ rescue StandardError
105
+ nil
106
+ end
107
+
108
+ pair.value.value if pair && pair.value
109
+ end
110
+
111
+ def extract_class_name(node, assoc_name)
112
+ kwargs = node.last_argument
113
+ return assoc_name.to_s.camelize unless kwargs && kwargs.hash_type?
114
+
115
+ class_name_pair =
116
+ begin
117
+ kwargs.pairs.find { |p| p.key.value == :class_name }
118
+ rescue StandardError
119
+ nil
120
+ end
121
+
122
+ if class_name_pair
123
+ class_name_pair.value.value
124
+ else
125
+ assoc_name.to_s.camelize
126
+ end
127
+ end
128
+
129
+ def polymorphic_or_as?(node)
130
+ kwargs = node.last_argument
131
+ return false unless kwargs && kwargs.hash_type?
132
+
133
+ kwargs.pairs.any? do |pair|
134
+ key = pair.key.value
135
+ %i[polymorphic as].include?(key)
136
+ end
137
+ end
138
+
139
+ def class_name_from_ast
140
+ class_node = processed_source.ast.each_descendant(:class).first
141
+ return nil unless class_node
142
+
143
+ const_node = class_node.children.first
144
+ const_node.const_name if const_node
145
+ end
146
+
147
+ def in_model_file?(path)
148
+ return false if path.nil?
149
+
150
+ path.start_with?(File.join(Dir.pwd, 'app/models'))
151
+ end
152
+
153
+ # Replace ActiveSupport's underscore with plain Ruby equivalent
154
+ #
155
+ # Example:
156
+ # "UserAccount" → "user_account"
157
+ # "PlanStatementAudit::Row" → "plan_statement_audit/row"
158
+ def camel_to_snake(name)
159
+ return '' if name.nil?
160
+
161
+ name.gsub('::', '/')
162
+ .gsub(/([A-Z\d]+)([A-Z][a-z])/, '\1_\2')
163
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
164
+ .downcase
165
+ end
166
+ end
167
+ end
168
+ end
169
+ end