rubocop-dev_doc 0.1.0 → 0.3.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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/config/default.yml +318 -33
  3. data/lib/dev_doc/test/best_practice_lints.rb +31 -0
  4. data/lib/dev_doc/test/lints/cron_schedule.rb +345 -0
  5. data/lib/dev_doc/test/lints/duplicate_snapshot.rb +197 -0
  6. data/lib/dev_doc/test/lints/no_file_excludes.rb +128 -0
  7. data/lib/rubocop/cop/dev_doc/auth/current_user_branching.rb +203 -0
  8. data/lib/rubocop/cop/dev_doc/auth/load_resource_current_user_guard.rb +230 -0
  9. data/lib/rubocop/cop/dev_doc/migration/amount_column_in_cents.rb +92 -0
  10. data/lib/rubocop/cop/dev_doc/migration/avoid_bypassing_validation.rb +86 -0
  11. data/lib/rubocop/cop/dev_doc/migration/avoid_column_default.rb +68 -13
  12. data/lib/rubocop/cop/dev_doc/migration/avoid_conditional_schema_changes.rb +89 -0
  13. data/lib/rubocop/cop/dev_doc/migration/avoid_json_column.rb +18 -3
  14. data/lib/rubocop/cop/dev_doc/migration/avoid_non_null.rb +121 -0
  15. data/lib/rubocop/cop/dev_doc/migration/no_create_join_table.rb +53 -0
  16. data/lib/rubocop/cop/dev_doc/migration/require_primary_key.rb +55 -0
  17. data/lib/rubocop/cop/dev_doc/migration/require_timestamps.rb +4 -13
  18. data/lib/rubocop/cop/dev_doc/rails/application_record_transaction.rb +56 -0
  19. data/lib/rubocop/cop/dev_doc/rails/avoid_rails_callbacks.rb +135 -0
  20. data/lib/rubocop/cop/dev_doc/rails/bang_save_in_transaction.rb +127 -0
  21. data/lib/rubocop/cop/dev_doc/rails/enum_column_not_null.rb +99 -0
  22. data/lib/rubocop/cop/dev_doc/rails/enum_must_be_symbolized.rb +83 -0
  23. data/lib/rubocop/cop/dev_doc/rails/no_block_predicate_on_relation.rb +236 -0
  24. data/lib/rubocop/cop/dev_doc/rails/no_deliver_later_in_transaction.rb +22 -5
  25. data/lib/rubocop/cop/dev_doc/rails/strong_parameters_expect.rb +137 -0
  26. data/lib/rubocop/cop/dev_doc/route/no_custom_actions.rb +171 -0
  27. data/lib/rubocop/cop/dev_doc/route/resource_name_number.rb +77 -0
  28. data/lib/rubocop/cop/dev_doc/route/resources_require_only.rb +29 -15
  29. data/lib/rubocop/cop/dev_doc/style/avoid_head_response.rb +56 -22
  30. data/lib/rubocop/cop/dev_doc/style/avoid_options_hash.rb +102 -0
  31. data/lib/rubocop/cop/dev_doc/style/avoid_send.rb +42 -10
  32. data/lib/rubocop/cop/dev_doc/style/minimize_variable_scope.rb +158 -0
  33. data/lib/rubocop/cop/dev_doc/style/no_unscoped_method_definitions.rb +129 -0
  34. data/lib/rubocop/cop/dev_doc/style/repeated_bracket_read.rb +150 -0
  35. data/lib/rubocop/cop/dev_doc/style/repeated_safe_navigation_receiver.rb +118 -0
  36. data/lib/rubocop/cop/dev_doc/style/string_symbol_comparison.rb +91 -0
  37. data/lib/rubocop/cop/dev_doc/test/avoid_glib_travel_freeze.rb +53 -0
  38. data/lib/rubocop/cop/dev_doc/test/avoid_unit_test.rb +66 -0
  39. data/lib/rubocop/cop/dev_doc/test/response_assert_equal.rb +179 -0
  40. data/lib/rubocop/dev_doc/version.rb +1 -1
  41. data/lib/rubocop-dev_doc.rb +1 -0
  42. metadata +73 -10
  43. data/lib/rubocop/cop/dev_doc/migration/avoid_update_column.rb +0 -53
@@ -0,0 +1,135 @@
1
+ module RuboCop
2
+ module Cop
3
+ module DevDoc
4
+ module Rails
5
+ # Avoid Rails ActiveRecord callback DSL in model files.
6
+ #
7
+ # ## Rationale
8
+ # Rails callbacks (`after_create`, `before_save`, etc.) cause problems
9
+ # with transaction ordering, hidden side effects, and unclear control
10
+ # flow. When a callback fires is not always obvious to the reader, and
11
+ # callbacks can trigger unexpectedly during testing or data migrations.
12
+ #
13
+ # Instead, implement an explicit method that makes the side effect
14
+ # visible at the call site:
15
+ #
16
+ # ❌
17
+ # class Order < ApplicationRecord
18
+ # after_create :send_confirmation_email
19
+ # end
20
+ #
21
+ # ✔️
22
+ # class Order < ApplicationRecord
23
+ # def save_with_confirmation_email
24
+ # transaction do
25
+ # save!
26
+ # OrderMailer.confirmation(self).deliver_later
27
+ # end
28
+ # end
29
+ # end
30
+ #
31
+ # ## When you have multiple call sites needing the same guard
32
+ # The single-method-per-place pattern above works when there is one
33
+ # natural call site. When several controllers/jobs/bulk operations all
34
+ # need to enforce the same invariant before destroying or saving, the
35
+ # temptation is to reach for `before_destroy` / `before_save` so
36
+ # nothing slips through. There is a cleaner pattern that gives the
37
+ # same guarantee without a callback — the **fence-and-helper**:
38
+ #
39
+ # 1. Alias the dangerous primitive as private, e.g.
40
+ # alias _unguarded_hard_destroy hard_destroy
41
+ # private :_unguarded_hard_destroy
42
+ # 2. Override the public name to raise with a pointer to the safe
43
+ # method:
44
+ # def hard_destroy
45
+ # raise 'Use SomeModel#safely_hard_destroy — it keeps X in sync'
46
+ # end
47
+ # 3. Expose a `safely_*` helper that does the guard, calls the
48
+ # private alias, and runs any side effects, all wrapped in a
49
+ # transaction so atomicity is a property of the helper rather
50
+ # than of the caller:
51
+ # def safely_hard_destroy
52
+ # if invariant_would_be_violated?
53
+ # errors.add(:base, '...')
54
+ # return false
55
+ # end
56
+ # ApplicationRecord.transaction do
57
+ # _unguarded_hard_destroy
58
+ # cleanup_side_effects!
59
+ # end
60
+ # destroyed?
61
+ # end
62
+ #
63
+ # Direct calls to the dangerous primitive now raise with a helpful
64
+ # message; the only legal path is the safe helper. Every existing
65
+ # caller updates to `safely_*` and inherits the guard automatically.
66
+ #
67
+ # Bonus property: ad-hoc bypass for ops/debugging is clean. Calling
68
+ # `model.send(:_unguarded_hard_destroy)` in the Rails console is
69
+ # scoped to the single call (no global state), self-documenting (you
70
+ # have to type "unguarded"), and greppable. This is intended for
71
+ # console / data-cleanup / debugging use only — if app code reaches
72
+ # for `send(:_unguarded_*)`, that's a design smell and should be
73
+ # solved by extending the helper instead.
74
+ #
75
+ # ## Inline disable
76
+ # We have not yet found a case in our codebases where a callback was
77
+ # the right answer — explicit methods or the fence-and-helper pattern
78
+ # have always covered the legitimate intent. If you think you have a
79
+ # genuine exception, disable this cop inline with a written
80
+ # justification — expect pushback in review:
81
+ #
82
+ # after_create :some_callback # rubocop:disable DevDoc/Rails/AvoidRailsCallbacks
83
+ # # Reason: <explanation>
84
+ #
85
+ # Both the symbol form and the block form are flagged:
86
+ #
87
+ # ❌ Symbol form
88
+ # after_create :send_confirmation_email
89
+ #
90
+ # ❌ Block form
91
+ # after_create { send_confirmation_email }
92
+ #
93
+ # @example
94
+ # # bad
95
+ # after_create :send_confirmation
96
+ # before_save :normalize_name
97
+ # around_update :wrap_in_audit_log
98
+ #
99
+ # # bad (block form)
100
+ # after_create { send_confirmation }
101
+ #
102
+ # # good
103
+ # def save_with_confirmation
104
+ # transaction { save! }
105
+ # send_confirmation
106
+ # end
107
+ class AvoidRailsCallbacks < Base
108
+ CALLBACKS = %i[
109
+ after_create after_create_commit after_save after_update
110
+ after_destroy after_commit after_rollback after_initialize
111
+ after_find after_touch before_create before_save before_update
112
+ before_destroy before_validation after_validation
113
+ around_create around_save around_update around_destroy
114
+ ].freeze
115
+
116
+ MSG = 'Avoid `%<method>s` — extract an explicit method (e.g. `save_with_*`) ' \
117
+ 'so the side effect is visible at the call site.'.freeze
118
+ RESTRICT_ON_SEND = CALLBACKS
119
+
120
+ def on_send(node)
121
+ add_offense(node.loc.selector, message: format(MSG, method: node.method_name))
122
+ end
123
+
124
+ def on_block(node) # rubocop:disable InternalAffairs/NumblockHandler
125
+ send_node = node.send_node
126
+ return unless CALLBACKS.include?(send_node.method_name)
127
+
128
+ add_offense(send_node.loc.selector, message: format(MSG, method: send_node.method_name))
129
+ end
130
+ alias on_numblock on_block
131
+ end
132
+ end
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,127 @@
1
+ module RuboCop
2
+ module Cop
3
+ module DevDoc
4
+ module Rails
5
+ # Flag bare (return-value-discarded) `save`/`update`/`create` calls inside
6
+ # a `transaction` block.
7
+ #
8
+ # ## Rationale
9
+ # Inside a transaction a non-bang `save` / `update` / `create` whose return
10
+ # value is discarded is almost always a bug. The transaction does **not**
11
+ # roll back on a `false` return — execution continues as if the write
12
+ # succeeded, silently producing inconsistent data.
13
+ #
14
+ # Checking the return value (as a condition or assignment) is allowed,
15
+ # as is the bang form. Only the bare-statement form is flagged.
16
+ #
17
+ # ❌ silent failure — transaction does not roll back
18
+ # ApplicationRecord.transaction do
19
+ # @order.save
20
+ # create_child_records(...)
21
+ # end
22
+ #
23
+ # ✔️ return value gated — ok
24
+ # ApplicationRecord.transaction do
25
+ # if @order.save
26
+ # create_child_records(...)
27
+ # end
28
+ # end
29
+ #
30
+ # ✔️ bang method — raises on failure, rolls back
31
+ # ApplicationRecord.transaction do
32
+ # @order.save!
33
+ # create_child_records(...)
34
+ # end
35
+ #
36
+ # @example
37
+ # # bad
38
+ # ApplicationRecord.transaction do
39
+ # @order.save
40
+ # create_child_records(params)
41
+ # end
42
+ #
43
+ # # good
44
+ # ApplicationRecord.transaction do
45
+ # if @order.save
46
+ # create_child_records(params)
47
+ # end
48
+ # end
49
+ #
50
+ # # good
51
+ # ApplicationRecord.transaction do
52
+ # result = @order.save
53
+ # create_child_records(params) if result
54
+ # end
55
+ #
56
+ # # good
57
+ # ApplicationRecord.transaction do
58
+ # @order.save!
59
+ # create_child_records(params)
60
+ # end
61
+ class BangSaveInTransaction < Base
62
+ extend AutoCorrector
63
+
64
+ MSG = 'Use `%<bang>s` inside a `transaction` block, or check its return value. ' \
65
+ 'A non-bang call whose return value is discarded does not roll back the transaction on failure.'.freeze
66
+
67
+ FLAGGED_METHODS = %i[save update create].freeze
68
+
69
+ # Node types whose parent always means the return value is consumed.
70
+ CONSUMING_PARENT_TYPES = %i[
71
+ and or return
72
+ send csend
73
+ lvasgn ivasgn cvasgn gvasgn casgn masgn
74
+ array hash pair
75
+ ].freeze
76
+
77
+ def on_send(node)
78
+ return unless FLAGGED_METHODS.include?(node.method_name)
79
+ return unless inside_transaction?(node)
80
+ return if return_value_used?(node)
81
+
82
+ bang = :"#{node.method_name}!"
83
+ add_offense(node.loc.selector, message: format(MSG, bang: bang)) do |corrector|
84
+ corrector.replace(node.loc.selector, bang.to_s)
85
+ end
86
+ end
87
+
88
+ private
89
+
90
+ def inside_transaction?(node)
91
+ node.each_ancestor(:block).any? do |ancestor|
92
+ ancestor.method_name == :transaction
93
+ end
94
+ end
95
+
96
+ def return_value_used?(node)
97
+ parent = node.parent
98
+ return false if parent.nil?
99
+
100
+ return false if discarding_parent?(parent, node)
101
+ return true if CONSUMING_PARENT_TYPES.include?(parent.type)
102
+ return parent.condition == node if conditional_parent?(parent)
103
+
104
+ false
105
+ end
106
+
107
+ def discarding_parent?(parent, node)
108
+ bare_sequence?(parent) || block_body?(parent, node)
109
+ end
110
+
111
+ def bare_sequence?(parent)
112
+ %i[begin kwbegin].include?(parent.type)
113
+ end
114
+
115
+ # A single-statement block body: the node IS the body of the block.
116
+ def block_body?(parent, node)
117
+ parent.block_type? && parent.body == node
118
+ end
119
+
120
+ def conditional_parent?(parent)
121
+ %i[if while until].include?(parent.type)
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,99 @@
1
+ module RuboCop
2
+ module Cop
3
+ module DevDoc
4
+ module Rails
5
+ # Enum columns must be backed by a `null: false` database column.
6
+ #
7
+ # ## Rationale
8
+ # `null: false` is reserved for cases where NULL has no meaningful
9
+ # interpretation — and an enum is the clearest such case. NULL is
10
+ # outside the enum's domain (a type violation, not one of the defined
11
+ # values), and if "unset" is meaningful it should be modeled as an
12
+ # explicit enum value, never as NULL.
13
+ #
14
+ # The line is drawn here for standardization and non-subjectivity.
15
+ # Whether a *regular* column should be present is a business decision
16
+ # open to debate (could `email` become optional once phone signup
17
+ # exists?), so it is left to model-layer judgment. An enum's
18
+ # non-null-ness is objective — it does not depend on any business
19
+ # decision — so it is enforced mechanically rather than argued
20
+ # column-by-column.
21
+ #
22
+ # This cop is the inverse of `DevDoc/Migration/AvoidNonNull`: that cop
23
+ # runs on the migration and strips `null: false` from regular columns;
24
+ # this cop runs on the model, reads `db/schema.rb`, and requires
25
+ # `null: false` on the column backing each `enum`.
26
+ #
27
+ # ## Interaction with AvoidNonNull
28
+ # An enum is a plain `integer` column, so `AvoidNonNull` cannot tell it
29
+ # apart from a regular integer and WILL flag the `null: false` you add
30
+ # to satisfy this cop. Disable it on that migration with a brief `-- enum`
31
+ # reason, so the migration is self-documenting:
32
+ #
33
+ # # rubocop:disable DevDoc/Migration/AvoidNonNull -- enum
34
+ # add_column :orders, :status, :integer, null: false
35
+ # # rubocop:enable DevDoc/Migration/AvoidNonNull
36
+ #
37
+ # NOTE: This cop reads `db/schema.rb` and does nothing if it is absent
38
+ # (e.g. projects using `structure.sql`). It also relies on the schema
39
+ # being current, resolves the table name by Rails convention (STI,
40
+ # `self.table_name` overrides, or namespaced models may not resolve),
41
+ # recognizes only the positional `enum :name, …` form, and skips
42
+ # silently if the backing column cannot be found in the schema.
43
+ #
44
+ # @example
45
+ # # bad - the `status` column is nullable in db/schema.rb
46
+ # class Order < ApplicationRecord
47
+ # enum :status, { active: 0, archived: 1 }
48
+ # end
49
+ #
50
+ # # good - the `status` column is `null: false` in db/schema.rb
51
+ # class Order < ApplicationRecord
52
+ # enum :status, { active: 0, archived: 1 }
53
+ # end
54
+ class EnumColumnNotNull < Base
55
+ include ActiveRecordHelper
56
+
57
+ MSG = 'Enum column `%<name>s` should be `null: false` — NULL is outside an enum\'s domain. ' \
58
+ 'Model "unset" as an explicit enum value.'.freeze
59
+
60
+ RESTRICT_ON_SEND = %i[enum].freeze
61
+
62
+ def_node_matcher :enum_call, <<~PATTERN
63
+ (send nil? :enum (sym $_) ...)
64
+ PATTERN
65
+
66
+ def on_send(node)
67
+ name = nullable_enum_name(node)
68
+ return unless name
69
+
70
+ add_offense(node, message: format(MSG, name: name))
71
+ end
72
+
73
+ private
74
+
75
+ # The enum's attribute name if it is backed by a nullable column, else nil.
76
+ def nullable_enum_name(node)
77
+ return unless schema
78
+
79
+ name = enum_call(node)
80
+ klass = name && class_node(node)
81
+ return unless klass
82
+
83
+ column = enum_column(klass, name)
84
+ name if column && !column.not_null
85
+ end
86
+
87
+ def enum_column(klass, name)
88
+ table = schema.table_by(name: table_name(klass))
89
+ table&.columns&.find { |c| c.name == name.to_s }
90
+ end
91
+
92
+ def class_node(node)
93
+ node.each_ancestor.find(&:class_type?)
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,83 @@
1
+ module RuboCop
2
+ module Cop
3
+ module DevDoc
4
+ module Rails
5
+ # Every Rails `enum` declaration must be paired with `enum_symbolize`
6
+ # so the reader returns a symbol instead of Rails' default string form.
7
+ #
8
+ # ## Rationale
9
+ # Strings and symbols are not equal in Ruby (`:foo == 'foo'` is `false`).
10
+ # Rails' `enum` macro defines a reader that returns the string form, so
11
+ # downstream comparisons like `record.status == :active` silently fail.
12
+ # Pairing `enum :status, …` with `enum_symbolize :status` overrides the
13
+ # reader to hand back a symbol, eliminating the footgun.
14
+ #
15
+ # See `best_practices/backend/en/01a_defensive_programming.md` item 7.
16
+ #
17
+ # ❌
18
+ # class Order < ApplicationRecord
19
+ # enum :payment_status, { draft: 0, pending: 1, finalized: 2 }
20
+ # end
21
+ #
22
+ # ✔
23
+ # class Order < ApplicationRecord
24
+ # enum :payment_status, { draft: 0, pending: 1, finalized: 2 }
25
+ #
26
+ # enum_symbolize :payment_status
27
+ # end
28
+ #
29
+ # `enum_symbolize` accepts multiple names so several enums can be
30
+ # paired in one call:
31
+ #
32
+ # ✔
33
+ # enum_symbolize :payment_status, :finalize_intent
34
+ #
35
+ # @example
36
+ # # bad
37
+ # enum :status, { active: 0, archived: 1 }
38
+ #
39
+ # # good
40
+ # enum :status, { active: 0, archived: 1 }
41
+ # enum_symbolize :status
42
+ class EnumMustBeSymbolized < Base
43
+ MSG = 'Pair `enum :%<name>s` with `enum_symbolize :%<name>s` so the reader returns a symbol ' \
44
+ '(see backend/01a_defensive_programming.md item 7).'.freeze
45
+
46
+ def_node_matcher :enum_call, <<~PATTERN
47
+ (send nil? :enum (sym $_) ...)
48
+ PATTERN
49
+
50
+ def_node_matcher :enum_symbolize_call, <<~PATTERN
51
+ (send nil? :enum_symbolize $...)
52
+ PATTERN
53
+
54
+ def on_class(node)
55
+ body = node.body
56
+ return unless body
57
+
58
+ statements = body.begin_type? ? body.children : [body]
59
+
60
+ enum_decls = []
61
+ symbolized = []
62
+
63
+ statements.each do |stmt|
64
+ next unless stmt.respond_to?(:send_type?) && stmt.send_type?
65
+
66
+ if (name = enum_call(stmt))
67
+ enum_decls << [name, stmt]
68
+ elsif (args = enum_symbolize_call(stmt))
69
+ symbolized.concat(args.select(&:sym_type?).map(&:value))
70
+ end
71
+ end
72
+
73
+ enum_decls.each do |name, decl|
74
+ next if symbolized.include?(name)
75
+
76
+ add_offense(decl, message: format(MSG, name: name))
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,236 @@
1
+ module RuboCop
2
+ module Cop
3
+ module DevDoc
4
+ module Rails
5
+ # Avoid block-form predicates on ActiveRecord relations.
6
+ #
7
+ # ## Rationale
8
+ # `.count { }`, `.reject { }`, `.select { }`, `.any? { }`, and
9
+ # `.find { }` accept a block, which silently converts the relation to
10
+ # an Array — every row is loaded into Ruby memory and filtered there,
11
+ # defeating database indexes and pagination.
12
+ #
13
+ # Push the predicate into SQL with `.where(...)` or a model scope so
14
+ # the database does the filtering.
15
+ #
16
+ # ❌ Loads every pending record into memory before counting
17
+ # pending_subscriptions.count { |s| !s.expired_for_context? }
18
+ #
19
+ # ✔️ Becomes a SQL COUNT via a scope
20
+ # class Subscription < ApplicationRecord
21
+ # scope :not_expired_for_context, ->(context) { ... }
22
+ # end
23
+ #
24
+ # pending_subscriptions.not_expired_for_context(context).count
25
+ #
26
+ # ## Exception
27
+ # Some predicates genuinely can't be expressed in SQL (decrypted
28
+ # attributes, non-trivial Ruby logic). For those, add a
29
+ # `# rubocop:disable` with a brief reason.
30
+ #
31
+ # ## Excluded receivers
32
+ # To keep false positives low, the cop skips receivers that clearly
33
+ # aren't AR relations: array literals (`[...]`), hash literals
34
+ # (`{...}`), screaming-case constants (e.g. `PRICING_PLANS`), and
35
+ # send-chains ending in a method known to return a non-Relation:
36
+ # * Array-returning — `pluck`, `to_a`, `map`, `flatten`, `compact`,
37
+ # `uniq`, `sort`, `sort_by`, `reduce`, `inject`, `each_with_object`,
38
+ # `zip`, `take`, `drop`, `group_by`, `partition`, `tally`,
39
+ # `chunk_while`, `slice_when`, etc.
40
+ # * Hash-returning — `slice`, `except`, `merge`, `transform_values`,
41
+ # `transform_keys`, `to_h`, `compact_blank`,
42
+ # `with_indifferent_access`.
43
+ # * Enumerator-returning — `each_with_index`, `each_slice`,
44
+ # `each_cons`, `lazy`, `with_index`, `with_object`. Calling these
45
+ # on a Relation forces eager loading; the next `.select`/etc. then
46
+ # operates on an in-memory Enumerator, so pushing into SQL is no
47
+ # longer possible.
48
+ #
49
+ # Parenthesised receivers (`(expr).select { ... }`) are looked through
50
+ # to `expr` so the rules above still apply.
51
+ #
52
+ # ## Excluded block shapes
53
+ # Even when the receiver is opaque (a local/instance variable, method
54
+ # parameter, etc.), the block itself sometimes proves the element
55
+ # can't be an AR record:
56
+ # * **2+ block arguments** — `|k, v|`, `|item, _index|`, etc. AR
57
+ # relations only yield single records; multi-arg destructuring
58
+ # means the iterator is Hash#each, zip, or similar.
59
+ # * **Symbol-key indexing on the block arg** — `arg[:foo]` proves
60
+ # the element is a Hash (AR's `[]` is rarely called with literal
61
+ # symbol keys).
62
+ # * **Single-char string indexing on the block arg** — `c[0] == '+'`
63
+ # proves the element is a String.
64
+ #
65
+ # ## Configuration
66
+ # `AdditionalNonRelationMethods` (default `[]`): per-project list of
67
+ # method names that return non-Relation collections. Useful when a
68
+ # codebase has its own helper methods returning plain Arrays / Hashes
69
+ # (e.g. a presenter factory) — adding them here lets the cop skip
70
+ # send-chains ending in those methods. Example:
71
+ #
72
+ # DevDoc/Rails/NoBlockPredicateOnRelation:
73
+ # AdditionalNonRelationMethods:
74
+ # - all_items
75
+ # - for_account
76
+ # - gst_registration_ranges
77
+ #
78
+ # NOTE: The cop cannot determine whether a local variable, instance
79
+ # variable, or method parameter is an AR relation or a plain
80
+ # collection. When the receiver is in fact a plain Array/Hash, add
81
+ # `# rubocop:disable DevDoc/Rails/NoBlockPredicateOnRelation` with a
82
+ # brief reason — the friction is intentional and ensures the choice
83
+ # is reviewed.
84
+ #
85
+ # @example
86
+ # # bad
87
+ # pending_memberships.count { |m| !m.expired? }
88
+ #
89
+ # # bad
90
+ # user.posts.reject { |post| post.archived? }
91
+ #
92
+ # # bad
93
+ # Model.where(active: true).any? { |r| r.flagged? }
94
+ #
95
+ # # good
96
+ # pending_memberships.not_expired.count
97
+ #
98
+ # # good (receiver returns Array — excluded)
99
+ # user.posts.pluck(:title).reject(&:blank?)
100
+ #
101
+ # # good (Hash#values — excluded)
102
+ # PRICING_PLANS.reject { |_k, v| v.archived? }
103
+ class NoBlockPredicateOnRelation < Base
104
+ MSG = '`%<method>s` with a block loads every row into Ruby. ' \
105
+ 'Push the predicate into SQL with `.where(...)` or a model scope.'.freeze
106
+
107
+ RESTRICT_ON_SEND = %i[count reject select find any?].freeze
108
+
109
+ # Methods whose return value is known to be a non-Relation collection
110
+ # (Array, Hash, or Enumerator). When a `.select`/`.reject`/etc. with a
111
+ # block is chained onto a call to one of these, the block runs over
112
+ # the materialised collection — pushing into SQL isn't possible.
113
+ NON_RELATION_RETURNING_METHODS = %i[
114
+ pluck pluck_to_hash
115
+ to_a to_ary
116
+ values keys
117
+ map flat_map collect collect_concat filter_map
118
+ flatten compact uniq sort sort_by reverse
119
+ reduce inject each_with_object
120
+ split lines chars bytes
121
+ zip take drop drop_while take_while
122
+ group_by partition tally tally_by chunk_while slice_when
123
+
124
+ slice except merge transform_values transform_keys to_h
125
+ compact_blank with_indifferent_access index_by index_with
126
+
127
+ each_with_index each_slice each_cons each_entry each_key each_value each_pair
128
+ chunk slice_before slice_after lazy with_index with_object
129
+ ].freeze
130
+
131
+ def on_send(node)
132
+ return unless node.block_literal?
133
+ return if node.receiver.nil?
134
+ return if excluded_receiver?(node.receiver)
135
+ return if excluded_block?(node)
136
+
137
+ add_offense(node.loc.selector, message: format(MSG, method: node.method_name))
138
+ end
139
+
140
+ private
141
+
142
+ def excluded_receiver?(receiver)
143
+ return true if receiver.array_type? || receiver.hash_type?
144
+ return true if receiver.const_type? && screaming_case_const?(receiver)
145
+ return excluded_receiver?(receiver.children.first) if receiver.begin_type?
146
+ return true if receiver.send_type? && non_relation_method?(receiver.method_name)
147
+
148
+ false
149
+ end
150
+
151
+ # Merge built-in known-safe methods with project-specific ones from
152
+ # `AdditionalNonRelationMethods` in .rubocop.yml. The config knob
153
+ # lets each project opt-in their own domain methods (e.g. a
154
+ # `CashBookEntry.for_account` that returns an Array of plain Ruby
155
+ # presenters) without modifying this cop.
156
+ def non_relation_method?(method_name)
157
+ return true if NON_RELATION_RETURNING_METHODS.include?(method_name)
158
+
159
+ additional = cop_config['AdditionalNonRelationMethods'] || []
160
+ additional.map(&:to_sym).include?(method_name)
161
+ end
162
+
163
+ def screaming_case_const?(const_node)
164
+ const_node.short_name.to_s.match?(/\A[A-Z][A-Z0-9_]*\z/)
165
+ end
166
+
167
+ # Look at the block parameters and body for evidence that the
168
+ # iterated element can't be an ActiveRecord record.
169
+ def excluded_block?(send_node)
170
+ block_node = send_node.block_node
171
+ return false unless block_node
172
+
173
+ args = block_node.arguments
174
+ return false unless args
175
+
176
+ # 2+ arg destructuring means the iterator yields a pair/tuple
177
+ # (Hash#each, zip, etc.). AR relations only yield single records.
178
+ return true if args.children.length >= 2
179
+
180
+ # Single-arg block: inspect how the arg is used inside the body.
181
+ return false unless args.children.length == 1
182
+
183
+ arg_node = args.children.first
184
+ arg_name = block_arg_name(arg_node)
185
+ return false unless arg_name
186
+
187
+ body = block_node.body
188
+ return false unless body
189
+
190
+ block_arg_indicates_non_record?(body, arg_name)
191
+ end
192
+
193
+ # Extract the simple name of a block argument, regardless of whether
194
+ # it's a regular arg, optional arg, or splat. Skips destructured
195
+ # `(a, b)` (handled by the 2+ arg check at a structural level).
196
+ def block_arg_name(arg_node)
197
+ return nil unless arg_node.respond_to?(:children)
198
+
199
+ arg_node.children.first if %i[arg optarg restarg].include?(arg_node.type)
200
+ end
201
+
202
+ # Return true if the block body uses `arg` in a way that's only
203
+ # legal for non-AR elements:
204
+ # * Symbol-key indexing — `arg[:foo]` — implies Hash
205
+ # * Single-character string-literal indexing — `arg[0] == '+'` —
206
+ # implies String
207
+ def block_arg_indicates_non_record?(body, arg_name)
208
+ body.each_descendant(:send) do |send|
209
+ next unless send.method_name == :[]
210
+ next unless send.receiver&.lvar_type?
211
+ next unless send.receiver.children.first == arg_name
212
+ next unless send.arguments.length == 1
213
+
214
+ key = send.first_argument
215
+ return true if key.sym_type?
216
+ return true if key.int_type? && compared_to_single_char_string?(send)
217
+ end
218
+
219
+ false
220
+ end
221
+
222
+ # `arg[0] == '+'` — the [] send is one side of a `==` or `!=`
223
+ # whose other side is a single-char string literal.
224
+ def compared_to_single_char_string?(index_send)
225
+ parent = index_send.parent
226
+ return false unless parent&.send_type?
227
+ return false unless %i[== !=].include?(parent.method_name)
228
+
229
+ other = parent.receiver.equal?(index_send) ? parent.first_argument : parent.receiver
230
+ other&.str_type? && other.value.length == 1
231
+ end
232
+ end
233
+ end
234
+ end
235
+ end
236
+ end