rubocop-dev_doc 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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 32fcdd022e9dde952826050d447d83e9fa7f9bf1cfaf7caaaddda3309e392a3b
4
+ data.tar.gz: e6775d4b37a1d763966c25eb3b2bf7926026b2ba2fdeae6d11fc05ae9d2ba46e
5
+ SHA512:
6
+ metadata.gz: fb0572fc498d052e2f49d45490c37ddfcfa349d5a4e17b199159537b713cb5206ee828ffa96d5466c5e386e8da7713a22b5d3e51eca9daf228041be278fbe014
7
+ data.tar.gz: a96d7bcdb8c34f3b0ab557a8bee46ef6ce91028174728cf06efa7df2943dafe7b67a47b9dff813260f2c824c6d7b1094bb6bc69c15bcb63fbf1c2f0bfab62333
@@ -0,0 +1,81 @@
1
+ # Default configuration for rubocop-dev_doc cops.
2
+ # Cops are enabled by default; disable them in your project's .rubocop.yml if needed.
3
+
4
+ DevDoc/Migration/AvoidJsonColumn:
5
+ Description: 'Use `jsonb` instead of `json` for column types.'
6
+ Enabled: true
7
+ Include:
8
+ - 'db/migrate/*.rb'
9
+ - 'db/migrate/**/*.rb'
10
+
11
+ DevDoc/Migration/RequireTimestamps:
12
+ Description: 'Always include `t.timestamps` in every `create_table` migration.'
13
+ Enabled: true
14
+ Include:
15
+ - 'db/migrate/*.rb'
16
+ - 'db/migrate/**/*.rb'
17
+
18
+ DevDoc/Migration/PreferBelongsTo:
19
+ Description: 'Use `t.belongs_to` instead of `t.references` for foreign keys.'
20
+ Enabled: true
21
+ Include:
22
+ - 'db/migrate/*.rb'
23
+ - 'db/migrate/**/*.rb'
24
+
25
+ DevDoc/Migration/AvoidColumnDefault:
26
+ Description: 'Avoid setting `default:` in migrations; keep business logic in the application layer.'
27
+ Enabled: true
28
+ Include:
29
+ - 'db/migrate/*.rb'
30
+ - 'db/migrate/**/*.rb'
31
+
32
+ DevDoc/Migration/AvoidUpdateColumn:
33
+ Description: 'Avoid `update_column`/`update_all`/`update_columns`; they bypass validations and callbacks.'
34
+ Enabled: true
35
+ Include:
36
+ - 'db/migrate/*.rb'
37
+ - 'db/migrate/**/*.rb'
38
+
39
+ DevDoc/Migration/DateColumnNaming:
40
+ Description: 'Date columns should end with `_on`; datetime columns should end with `_at`.'
41
+ Enabled: true
42
+ Include:
43
+ - 'db/migrate/*.rb'
44
+ - 'db/migrate/**/*.rb'
45
+
46
+ DevDoc/Migration/AvoidVagueColumnNames:
47
+ Description: 'Avoid vague column names like `status` or `group`. Use more specific names.'
48
+ Enabled: true
49
+ VagueNames:
50
+ - status
51
+ - group
52
+ Include:
53
+ - 'db/migrate/*.rb'
54
+ - 'db/migrate/**/*.rb'
55
+
56
+ DevDoc/Route/ResourcesRequireOnly:
57
+ Description: 'Always use `only:` or `except:` when defining `resources` or `resource` routes.'
58
+ Enabled: true
59
+ Include:
60
+ - 'config/routes.rb'
61
+ - 'config/routes/**/*.rb'
62
+
63
+ DevDoc/Rails/NoDeliverLaterInTransaction:
64
+ Description: 'Avoid `deliver_later`/`perform_later` inside a `transaction` block; the job may use stale data.'
65
+ Enabled: true
66
+
67
+ DevDoc/Rails/NoPerformLaterInModel:
68
+ Description: 'Avoid `perform_later` inside model files; use explicit methods called from the controller.'
69
+ Enabled: true
70
+ Include:
71
+ - 'app/models/**/*.rb'
72
+
73
+ DevDoc/Style/AvoidSend:
74
+ Description: 'Avoid `send`/`public_send` with an explicit receiver; prefer direct calls or safer alternatives.'
75
+ Enabled: true
76
+
77
+ DevDoc/Style/AvoidHeadResponse:
78
+ Description: 'Avoid `head()` responses; delegate error handling to Rails exceptions or model validations.'
79
+ Enabled: true
80
+ Include:
81
+ - 'app/controllers/**/*.rb'
@@ -0,0 +1,101 @@
1
+ module RuboCop
2
+ module Cop
3
+ module DevDoc
4
+ module Migration
5
+ # Avoid setting a `default:` value in migrations.
6
+ #
7
+ # ## Rationale
8
+ # Avoid adding business logic to the database. Keep it centralized in the
9
+ # application layer (controller or model) for easier maintenance and
10
+ # flexibility. A `default:` in a migration embeds a business-logic
11
+ # assumption into the database schema, which is harder to change later
12
+ # than code.
13
+ #
14
+ # Instead of relying on a database default, set the value explicitly in
15
+ # the application — and for existing rows, backfill via a reversible
16
+ # migration that goes through model validations:
17
+ #
18
+ # ❌
19
+ # class AddProfileCompletionRateToUsers < ActiveRecord::Migration[6.1]
20
+ # def change
21
+ # add_column :users, :profile_completion_rate, :float, default: 0.0
22
+ # end
23
+ # end
24
+ #
25
+ # ✔
26
+ # class AddProfileCompletionRateToUsers < ActiveRecord::Migration[6.1]
27
+ # def change
28
+ # add_column :users, :profile_completion_rate, :float
29
+ #
30
+ # reversible do |dir|
31
+ # dir.up do
32
+ # # Make sure Rails picks up the new column.
33
+ # User.reset_column_information
34
+ #
35
+ # User.where(profile_completion_rate: nil).find_each do |user|
36
+ # user.profile_completion_rate = 0.0
37
+ # # This may fail if existing records are invalid (e.g. nil required fields).
38
+ # # In that case, fix those records first rather than bypassing validation.
39
+ # user.save!
40
+ # end
41
+ # end
42
+ # end
43
+ # end
44
+ # end
45
+ #
46
+ # ## Exception
47
+ # For performance reasons (large tables with millions of records) or when
48
+ # using `null: false`, you may temporarily set a default and then
49
+ # immediately remove it in the same migration:
50
+ #
51
+ # ✔
52
+ # add_column :users, :profile_completion_rate, :float, default: 0.0
53
+ # change_column_default :users, :profile_completion_rate, from: 0.0, to: nil
54
+ #
55
+ # NOTE: This cop currently flags the first line of the exception pattern.
56
+ # The follow-up `change_column_default ..., to: nil` is not auto-detected.
57
+ #
58
+ # @example
59
+ # # bad
60
+ # add_column :users, :score, :integer, default: 0
61
+ #
62
+ # # bad
63
+ # t.string :status, default: 'active'
64
+ #
65
+ # # good
66
+ # add_column :users, :score, :integer
67
+ #
68
+ # # good (temporary default immediately removed)
69
+ # add_column :users, :score, :integer, default: 0
70
+ # change_column_default :users, :score, from: 0, to: nil
71
+ class AvoidColumnDefault < Base
72
+ MSG = 'Avoid setting `default:` in migrations. Keep business logic defaults in the application layer.'.freeze
73
+
74
+ COLUMN_METHODS = %i[
75
+ string integer float boolean datetime date text binary decimal
76
+ json jsonb bigint primary_key references belongs_to
77
+ ].freeze
78
+
79
+ def_node_matcher :has_default_option?, <<~PATTERN
80
+ (hash <(pair (sym :default) _) ...>)
81
+ PATTERN
82
+
83
+ def on_send(node)
84
+ return unless node.method?(:add_column) || COLUMN_METHODS.include?(node.method_name)
85
+
86
+ check_options(node.arguments.find(&:hash_type?))
87
+ end
88
+
89
+ private
90
+
91
+ def check_options(options)
92
+ return unless options && has_default_option?(options)
93
+
94
+ default_pair = options.pairs.find { |p| p.key.sym_type? && p.key.value == :default }
95
+ add_offense(default_pair)
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,40 @@
1
+ module RuboCop
2
+ module Cop
3
+ module DevDoc
4
+ module Migration
5
+ # Prefer `jsonb` over `json` for column types.
6
+ #
7
+ # ## Rationale
8
+ # `jsonb` stores data in a binary format that supports indexing,
9
+ # querying with operators (`->`, `->>`, `@>`), and is generally faster to
10
+ # read. Use `json` only if you need to preserve key order or exact
11
+ # formatting of the original JSON string — which is rare in practice.
12
+ #
13
+ # ❌
14
+ # t.json :metadata
15
+ #
16
+ # ✔️
17
+ # t.jsonb :metadata
18
+ #
19
+ # @example
20
+ # # bad
21
+ # t.json :metadata
22
+ #
23
+ # # good
24
+ # t.jsonb :metadata
25
+ class AvoidJsonColumn < Base
26
+ extend AutoCorrector
27
+
28
+ MSG = 'Use `jsonb` instead of `json`. `jsonb` supports indexing and is faster to read.'.freeze
29
+ RESTRICT_ON_SEND = %i[json].freeze
30
+
31
+ def on_send(node)
32
+ add_offense(node.loc.selector) do |corrector|
33
+ corrector.replace(node.loc.selector, 'jsonb')
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,53 @@
1
+ module RuboCop
2
+ module Cop
3
+ module DevDoc
4
+ module Migration
5
+ # Avoid `update_column`, `update_columns`, and `update_all` in migrations.
6
+ #
7
+ # ## Rationale
8
+ # Avoid bypassing validation unless absolutely necessary. These methods
9
+ # skip validations and callbacks, which hides data integrity issues
10
+ # rather than surfacing them.
11
+ #
12
+ # Even in migrations, check the code to see if there is any blatant
13
+ # reason why existing records may be invalid. If there is, fix those
14
+ # records first rather than bypassing validation.
15
+ #
16
+ # ❌ Bypasses validation — hides data integrity issues
17
+ # Faq.where(purpose: nil).update_all(purpose: :intro)
18
+ #
19
+ # ✔️ Runs validation — surfaces problems early
20
+ # Faq.where(purpose: nil).find_each do |faq|
21
+ # faq.purpose = :intro
22
+ # faq.save!
23
+ # end
24
+ #
25
+ # NOTE: The broader principle ("avoid bypassing validation") also covers
26
+ # things this cop does not catch, e.g. `save(validate: false)`,
27
+ # `insert_all`, `upsert_all`, `delete_all`. Apply the same judgement to
28
+ # those patterns.
29
+ #
30
+ # @example
31
+ # # bad
32
+ # Faq.where(purpose: nil).update_all(purpose: :intro)
33
+ #
34
+ # # bad
35
+ # user.update_column(:status, 'active')
36
+ #
37
+ # # good
38
+ # Faq.where(purpose: nil).find_each do |faq|
39
+ # faq.purpose = :intro
40
+ # faq.save!
41
+ # end
42
+ class AvoidUpdateColumn < Base
43
+ MSG = 'Avoid `%<method>s` in migrations; it bypasses validations. Use `save!` instead.'.freeze
44
+ RESTRICT_ON_SEND = %i[update_column update_columns update_all].freeze
45
+
46
+ def on_send(node)
47
+ add_offense(node.loc.selector, message: format(MSG, method: node.method_name))
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,66 @@
1
+ module RuboCop
2
+ module Cop
3
+ module DevDoc
4
+ module Migration
5
+ # Avoid vague column names like `status` or `group`.
6
+ #
7
+ # ## Rationale
8
+ # Use more specific names that describe the domain context. A column
9
+ # named `status` reveals nothing about *what* status it tracks; a column
10
+ # named `processing_status` makes the intent obvious at the call site.
11
+ #
12
+ # ❌
13
+ # t.string :status
14
+ # add_column :orders, :group, :integer
15
+ #
16
+ # ✔️
17
+ # t.string :processing_status
18
+ # add_column :orders, :user_group, :integer
19
+ #
20
+ # ## Note about `type`
21
+ # `type` is reserved by Rails for Single Table Inheritance (STI). Even
22
+ # if STI is not in use, naming a column `type` is misleading and should
23
+ # be avoided. This cop does not flag `type` by default — that is left
24
+ # to the user's discretion via the `VagueNames` config.
25
+ #
26
+ # Configure the list of vague names via `VagueNames` in .rubocop.yml.
27
+ #
28
+ # @example
29
+ # # bad
30
+ # t.string :status
31
+ # add_column :orders, :group, :integer
32
+ #
33
+ # # good
34
+ # t.string :processing_status
35
+ # add_column :orders, :user_group, :integer
36
+ class AvoidVagueColumnNames < Base
37
+ MSG = 'Avoid vague column name `%<name>s`. Use a more specific name that includes context.'.freeze
38
+
39
+ COLUMN_METHODS = %i[
40
+ string integer float boolean datetime date text binary decimal
41
+ json jsonb bigint primary_key
42
+ ].freeze
43
+
44
+ def on_send(node)
45
+ col_name_node = if node.method?(:add_column)
46
+ node.arguments[1]
47
+ elsif COLUMN_METHODS.include?(node.method_name)
48
+ node.first_argument
49
+ end
50
+
51
+ return unless col_name_node&.sym_type?
52
+ return unless vague_names.include?(col_name_node.value.to_s)
53
+
54
+ add_offense(col_name_node, message: format(MSG, name: col_name_node.value))
55
+ end
56
+
57
+ private
58
+
59
+ def vague_names
60
+ cop_config.fetch('VagueNames', %w[status group])
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,71 @@
1
+ module RuboCop
2
+ module Cop
3
+ module DevDoc
4
+ module Migration
5
+ # Date columns should end with `_on`; datetime columns should end with `_at`.
6
+ #
7
+ # ## Rationale
8
+ # Use the suffix `_on` for `date` columns and `_at` for `datetime`
9
+ # columns. This convention makes the column type immediately clear from
10
+ # the name, without having to look at the schema. For example:
11
+ #
12
+ # service_subscription.expiring_on # date
13
+ # service_subscription.expired_at # datetime
14
+ #
15
+ # @example
16
+ # # bad
17
+ # t.date :expiry
18
+ # t.datetime :created
19
+ # add_column :users, :birth, :date
20
+ #
21
+ # # good
22
+ # t.date :expiring_on
23
+ # t.datetime :created_at
24
+ # add_column :users, :born_on, :date
25
+ class DateColumnNaming < Base
26
+ DATE_MSG = 'Date column `%<name>s` should end with `_on` (e.g. `%<name>s_on`).'.freeze
27
+ DATETIME_MSG = 'Datetime column `%<name>s` should end with `_at` (e.g. `%<name>s_at`).'.freeze
28
+
29
+ def on_send(node)
30
+ if node.method?(:add_column)
31
+ check_add_column(node)
32
+ elsif %i[date datetime timestamptz].include?(node.method_name)
33
+ check_column_definition(node)
34
+ end
35
+ end
36
+
37
+ private
38
+
39
+ def check_add_column(node)
40
+ col_name_node = node.arguments[1]
41
+ type_node = node.arguments[2]
42
+ return unless col_name_node&.sym_type? && type_node&.sym_type?
43
+
44
+ check_name(col_name_node, type_node.value)
45
+ end
46
+
47
+ def check_column_definition(node)
48
+ col_name_node = node.first_argument
49
+ return unless col_name_node&.sym_type?
50
+
51
+ check_name(col_name_node, node.method_name)
52
+ end
53
+
54
+ def check_name(col_name_node, col_type)
55
+ name = col_name_node.value.to_s
56
+ case col_type.to_sym
57
+ when :date
58
+ return if name.end_with?('_on')
59
+
60
+ add_offense(col_name_node, message: format(DATE_MSG, name: name))
61
+ when :datetime, :timestamptz
62
+ return if name.end_with?('_at')
63
+
64
+ add_offense(col_name_node, message: format(DATETIME_MSG, name: name))
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,41 @@
1
+ module RuboCop
2
+ module Cop
3
+ module DevDoc
4
+ module Migration
5
+ # Prefer `t.belongs_to` over `t.references` for foreign keys.
6
+ #
7
+ # ## Rationale
8
+ # `t.belongs_to` and `t.references` are aliases and functionally
9
+ # identical. Prefer `belongs_to` because it matches the model's
10
+ # association declaration (`belongs_to :user`), which makes the
11
+ # migration easier to map to the model and improves readability.
12
+ #
13
+ # ❌
14
+ # t.references :user, foreign_key: true, null: false
15
+ #
16
+ # ✔️
17
+ # t.belongs_to :user, foreign_key: true, null: false
18
+ #
19
+ # @example
20
+ # # bad
21
+ # t.references :user, foreign_key: true, null: false
22
+ #
23
+ # # good
24
+ # t.belongs_to :user, foreign_key: true, null: false
25
+ class PreferBelongsTo < Base
26
+ extend AutoCorrector
27
+
28
+ MSG = 'Use `t.belongs_to` instead of `t.references` ' \
29
+ '(aliases, but `belongs_to` matches the model association).'.freeze
30
+ RESTRICT_ON_SEND = %i[references].freeze
31
+
32
+ def on_send(node)
33
+ add_offense(node.loc.selector) do |corrector|
34
+ corrector.replace(node.loc.selector, 'belongs_to')
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,99 @@
1
+ module RuboCop
2
+ module Cop
3
+ module DevDoc
4
+ module Migration
5
+ # Every `create_table` migration must include `t.timestamps`.
6
+ #
7
+ # ## Rationale
8
+ # Every table should have `created_at` and `updated_at` columns —
9
+ # there is no reason to omit them. They are essential for debugging,
10
+ # auditing, and ordering records, and adding them later is much more
11
+ # painful than including them up front.
12
+ #
13
+ # ❌
14
+ # create_table :ai_api_failures do |t|
15
+ # t.belongs_to :ai_chat_message, null: false, foreign_key: true
16
+ # t.string :error_type
17
+ # t.datetime :created_at
18
+ # end
19
+ #
20
+ # ✔️
21
+ # create_table :ai_api_failures do |t|
22
+ # t.belongs_to :ai_chat_message, null: false, foreign_key: true
23
+ # t.string :error_type
24
+ # t.timestamps
25
+ # end
26
+ #
27
+ # NOTE: This cop skips tables declared with `id: false` (typically join
28
+ # tables), where omitting timestamps is a deliberate choice.
29
+ #
30
+ # @example
31
+ # # bad
32
+ # create_table :users do |t|
33
+ # t.string :name
34
+ # end
35
+ #
36
+ # # good
37
+ # create_table :users do |t|
38
+ # t.string :name
39
+ # t.timestamps
40
+ # end
41
+ class RequireTimestamps < Base
42
+ extend AutoCorrector
43
+
44
+ MSG = 'Add `t.timestamps` to this `create_table` migration.'.freeze
45
+ RESTRICT_ON_SEND = %i[create_table].freeze
46
+
47
+ def_node_search :timestamps_included?, <<~PATTERN
48
+ (send _ :timestamps ...)
49
+ PATTERN
50
+
51
+ def_node_search :manual_timestamp_column?, <<~PATTERN
52
+ (send _ {:datetime :timestamptz}
53
+ {(sym {:created_at :updated_at}) (str {"created_at" "updated_at"})}
54
+ ...)
55
+ PATTERN
56
+
57
+ def_node_matcher :id_false_option?, <<~PATTERN
58
+ (pair (sym :id) (false))
59
+ PATTERN
60
+
61
+ def on_send(node)
62
+ return if skip_table?(node)
63
+
64
+ block = node.parent
65
+ return unless block&.block_type?
66
+ return if timestamps_present?(block.body)
67
+
68
+ add_offense(node.loc.selector) do |corrector|
69
+ autocorrect(corrector, block)
70
+ end
71
+ end
72
+
73
+ private
74
+
75
+ def skip_table?(node)
76
+ node.each_descendant(:pair).any? { |pair| id_false_option?(pair) }
77
+ end
78
+
79
+ def timestamps_present?(body)
80
+ return false if body.nil?
81
+
82
+ timestamps_included?(body) || manual_timestamp_column?(body)
83
+ end
84
+
85
+ def autocorrect(corrector, block)
86
+ return unless block.multiline?
87
+
88
+ table_var = block.arguments.first&.source || 't'
89
+ end_range = block.loc.end
90
+ indent = ' ' * (end_range.column + 2)
91
+ line_start_pos = end_range.begin_pos - end_range.column
92
+ insert_range = end_range.with(begin_pos: line_start_pos, end_pos: line_start_pos)
93
+ corrector.insert_before(insert_range, "#{indent}#{table_var}.timestamps\n")
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,69 @@
1
+ module RuboCop
2
+ module Cop
3
+ module DevDoc
4
+ module Rails
5
+ # Avoid `deliver_later` and `perform_later` inside a `transaction` block.
6
+ #
7
+ # ## Rationale
8
+ # Do not use `perform_later` or `deliver_later` inside a transaction
9
+ # block because there is a possibility that the job will use stale data,
10
+ # as the transaction has not yet completed (not yet committed changes
11
+ # to the database).
12
+ #
13
+ # ❌
14
+ # organization.transaction do
15
+ # if organization.save
16
+ # # This mailer may receive the organization with stale data
17
+ # # (data before `save()`)
18
+ # OrganizationMailer.with(organization: organization).deliver_later
19
+ # end
20
+ # end
21
+ #
22
+ # ✔️
23
+ # organization.transaction do
24
+ # save_succeeded = organization.save
25
+ # end
26
+ # if save_succeeded
27
+ # OrganizationMailer.with(organization: organization).deliver_later
28
+ # end
29
+ #
30
+ # ## Watch out for indirect calls
31
+ # Some libraries call `perform_later` / `deliver_later` behind the
32
+ # scenes — e.g. `@user.send_verification_email!` from the Devise gem.
33
+ # This cop cannot detect those wrappers; reviewers should still flag
34
+ # them when they appear inside a `transaction` block.
35
+ #
36
+ # @example
37
+ # # bad
38
+ # organization.transaction do
39
+ # organization.save!
40
+ # OrganizationMailer.with(organization: organization).deliver_later
41
+ # end
42
+ #
43
+ # # good
44
+ # organization.transaction do
45
+ # organization.save!
46
+ # end
47
+ # OrganizationMailer.with(organization: organization).deliver_later
48
+ class NoDeliverLaterInTransaction < Base
49
+ MSG = '`%<method>s` inside a `transaction` block may use stale data. Move it outside the transaction.'.freeze
50
+ RESTRICT_ON_SEND = %i[deliver_later perform_later].freeze
51
+
52
+ def on_send(node)
53
+ return unless inside_transaction?(node)
54
+
55
+ add_offense(node.loc.selector, message: format(MSG, method: node.method_name))
56
+ end
57
+
58
+ private
59
+
60
+ def inside_transaction?(node)
61
+ node.each_ancestor(:block).any? do |ancestor|
62
+ ancestor.method_name == :transaction
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,61 @@
1
+ module RuboCop
2
+ module Cop
3
+ module DevDoc
4
+ module Rails
5
+ # Avoid `perform_later` calls inside model files.
6
+ #
7
+ # ## Rationale
8
+ # Avoid using ActiveJob inside Models, because:
9
+ #
10
+ # - It is prone to conflicts between `transaction` and `perform_later`
11
+ # (the job may run before the transaction commits and read stale data
12
+ # — see `DevDoc/Rails/NoDeliverLaterInTransaction`).
13
+ # - Execution flow control should be done in the Controller, not in the
14
+ # Model.
15
+ # - If it really must be done in the Model, name the method explicitly
16
+ # so the side effect is obvious at the call site.
17
+ #
18
+ # ❌ (in app/models/order.rb)
19
+ # def finalize
20
+ # save!
21
+ # OrderJob.perform_later(self)
22
+ # end
23
+ #
24
+ # ✔️ Explicit method name signals the side effect; reviewers
25
+ # immediately see that this should not be called inside a
26
+ # `transaction` block.
27
+ # def save_with_email_sending
28
+ # save!
29
+ # OrderJob.perform_later(self)
30
+ # end
31
+ #
32
+ # NOTE: This cop flags *any* `perform_later` in `app/models/**/*.rb`,
33
+ # including the legitimate "explicitly named method" case above. The
34
+ # cop is intentionally conservative — disable per-line with a rubocop
35
+ # comment when you have deliberately followed the naming convention.
36
+ #
37
+ # @example
38
+ # # bad (in app/models/order.rb)
39
+ # def finalize
40
+ # save!
41
+ # OrderJob.perform_later(self)
42
+ # end
43
+ #
44
+ # # good — explicit method communicates the side effect
45
+ # def finalize_with_job
46
+ # save!
47
+ # OrderJob.perform_later(self)
48
+ # end
49
+ class NoPerformLaterInModel < Base
50
+ MSG = 'Avoid `perform_later` in model files. Call it from the controller, ' \
51
+ 'or use an explicit method name to signal the side effect.'.freeze
52
+ RESTRICT_ON_SEND = %i[perform_later].freeze
53
+
54
+ def on_send(node)
55
+ add_offense(node.loc.selector)
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,59 @@
1
+ module RuboCop
2
+ module Cop
3
+ module DevDoc
4
+ module Route
5
+ # Always use `only:` (or `except:`) for `resources` / `resource` in routes.rb.
6
+ #
7
+ # ## Rationale
8
+ # When defining routes in routes.rb, it is important to explicitly
9
+ # specify the desired actions using the `only` option. This helps
10
+ # prevent accidentally exposing actions that should not be accessible
11
+ # — leaving the default opens the full RESTful set, which often
12
+ # exposes routes the application has no controller action for, or
13
+ # routes that probably should be locked down.
14
+ #
15
+ # ✔️
16
+ # resources :job_applications, only: [:index, :new, :create]
17
+ #
18
+ # In this example, only three actions are exposed for
19
+ # `job_applications`: index, new, and create. This is safer because
20
+ # only the needed actions are declared and accessible.
21
+ #
22
+ # `except:` is also acceptable, but `only:` is preferred because it
23
+ # is more explicit about what is being exposed.
24
+ #
25
+ # @example
26
+ # # bad
27
+ # resources :users
28
+ # resource :profile
29
+ #
30
+ # # good
31
+ # resources :users, only: %i[index show]
32
+ # resource :profile, only: %i[show edit update]
33
+ # resources :users, except: [:destroy]
34
+ class ResourcesRequireOnly < Base
35
+ MSG = 'Specify `only:` or `except:` for `%<method>s :%<name>s` to avoid exposing unintended actions.'.freeze
36
+ RESTRICT_ON_SEND = %i[resources resource].freeze
37
+
38
+ def on_send(node)
39
+ return if only_or_except?(node)
40
+
41
+ name = node.first_argument&.value || '?'
42
+ add_offense(node.loc.selector, message: format(MSG, method: node.method_name, name: name))
43
+ end
44
+
45
+ private
46
+
47
+ def only_or_except?(node)
48
+ options = node.arguments.find(&:hash_type?)
49
+ return false unless options
50
+
51
+ options.pairs.any? do |pair|
52
+ pair.key.sym_type? && %i[only except].include?(pair.key.value)
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,57 @@
1
+ module RuboCop
2
+ module Cop
3
+ module DevDoc
4
+ module Style
5
+ # Avoid `head()` responses in controllers.
6
+ #
7
+ # ## Rationale
8
+ # `head()` returns an empty body with no useful information for the
9
+ # client. Its presence is usually a sign that error handling should be
10
+ # delegated to Rails exceptions (e.g. `ActiveRecord::RecordNotFound`)
11
+ # or model validations instead.
12
+ #
13
+ # If you find yourself reaching for `head()`, consider whether a
14
+ # well-known Rails exception or a model validation can handle the
15
+ # case more cleanly:
16
+ #
17
+ # ❌ Manually returns 404 with no body
18
+ # def show
19
+ # @user = User.find_by(id: params[:id])
20
+ # head(:not_found) unless @user
21
+ # end
22
+ #
23
+ # ✔️ Let Rails raise RecordNotFound — it renders the standard 404
24
+ # def show
25
+ # @user = User.find(params[:id])
26
+ # end
27
+ #
28
+ # NOTE: The cop flags every bare `head(...)` call. Some legitimate uses
29
+ # (e.g. `head :no_content` for a successful DELETE, or simple webhook
30
+ # acknowledgements) still get flagged — disable per-line in those cases.
31
+ #
32
+ # @example
33
+ # # bad
34
+ # def glib_load_resource
35
+ # @user = User.find_by(id: params[:id])
36
+ # head(:not_found) unless @user
37
+ # end
38
+ #
39
+ # # good
40
+ # def glib_load_resource
41
+ # @user = User.find(params[:id])
42
+ # end
43
+ class AvoidHeadResponse < Base
44
+ MSG = 'Avoid `head()`. Delegate error handling to Rails exceptions ' \
45
+ '(e.g. use `find` instead of `find_by` + `head(:not_found)`) or model validations.'.freeze
46
+ RESTRICT_ON_SEND = %i[head].freeze
47
+
48
+ def on_send(node)
49
+ return unless node.receiver.nil?
50
+
51
+ add_offense(node.loc.selector)
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,61 @@
1
+ module RuboCop
2
+ module Cop
3
+ module DevDoc
4
+ module Style
5
+ # Avoid `send` and `public_send` with an explicit receiver.
6
+ #
7
+ # ## Rationale
8
+ # `send()` can call *any* method, including destructive ones like
9
+ # `destroy`. When the method name is dynamic, this is a real risk — a
10
+ # crafted parameter can invoke methods the developer never intended to
11
+ # expose. If `send()` is unavoidable, add safeguards to restrict which
12
+ # methods can be called.
13
+ #
14
+ # ## Safer alternatives
15
+ #
16
+ # **a) For model attributes — use bracket notation instead.**
17
+ # `@model[column_name]` only accesses database columns, so it cannot
18
+ # accidentally invoke methods like `destroy`.
19
+ #
20
+ # ❌ Dangerous — method_name could be :destroy or any other method
21
+ # @user.send(method_name)
22
+ #
23
+ # ✔️ Safe — only accesses database columns
24
+ # @user[method_name]
25
+ #
26
+ # **b) For non-model objects — use a prefix to restrict callable methods.**
27
+ # By interpolating the dynamic part into a fixed prefix, only methods
28
+ # with that prefix (e.g. `export_csv`, `export_pdf`) can be invoked,
29
+ # preventing accidental calls to unintended methods.
30
+ #
31
+ # ❌ Unrestricted — any method can be called
32
+ # obj.send(method_name)
33
+ #
34
+ # ✔️ Restricted — only methods with the prefix can be called
35
+ # obj.send("export_#{method_name}")
36
+ #
37
+ # **c) For known methods — call directly instead of via `send`.**
38
+ #
39
+ # @example
40
+ # # bad
41
+ # @user.send(method_name)
42
+ # obj.public_send(action)
43
+ #
44
+ # # good
45
+ # @user[attribute_name]
46
+ # obj.send("export_#{method_name}")
47
+ class AvoidSend < Base
48
+ MSG = 'Avoid `%<method>s` with an explicit receiver. ' \
49
+ 'Use bracket notation for model attributes, or restrict callable methods with a prefix.'.freeze
50
+ RESTRICT_ON_SEND = %i[send public_send].freeze
51
+
52
+ def on_send(node)
53
+ return if node.receiver.nil?
54
+
55
+ add_offense(node.loc.selector, message: format(MSG, method: node.method_name))
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,26 @@
1
+ module RuboCop
2
+ module DevDoc
3
+ class Plugin < LintRoller::Plugin
4
+ def about
5
+ LintRoller::About.new(
6
+ name: "rubocop-dev_doc",
7
+ version: VERSION,
8
+ homepage: "https://github.com/hgani/dev-doc",
9
+ description: "RuboCop cops enforcing dev-doc best practices"
10
+ )
11
+ end
12
+
13
+ def supported?(context)
14
+ context.engine == :rubocop
15
+ end
16
+
17
+ def rules(_context)
18
+ LintRoller::Rules.new(
19
+ type: :path,
20
+ config_format: :rubocop,
21
+ value: RuboCop::DevDoc::CONFIG_DEFAULT
22
+ )
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,5 @@
1
+ module RuboCop
2
+ module DevDoc
3
+ VERSION = "0.1.0".freeze
4
+ end
5
+ end
@@ -0,0 +1,13 @@
1
+ require_relative "dev_doc/version"
2
+ require_relative "dev_doc/plugin"
3
+
4
+ module RuboCop
5
+ module DevDoc
6
+ PROJECT_ROOT = Pathname.new(__dir__).parent.parent.expand_path.freeze
7
+ CONFIG_DEFAULT = PROJECT_ROOT.join("config", "default.yml").freeze
8
+ end
9
+ end
10
+
11
+ RuboCop::ConfigLoader.ignore_parent_exclusion = true
12
+
13
+ Dir[File.join(__dir__, "cop", "dev_doc", "**", "*.rb")].each { |f| require f }
@@ -0,0 +1,3 @@
1
+ require "rubocop"
2
+ require "lint_roller"
3
+ require_relative "rubocop/dev_doc"
metadata ADDED
@@ -0,0 +1,96 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rubocop-dev_doc
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - dev-doc contributors
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rubocop
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '1.72'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '1.72'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rubocop-rails
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '2.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '2.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: lint_roller
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ executables: []
55
+ extensions: []
56
+ extra_rdoc_files: []
57
+ files:
58
+ - config/default.yml
59
+ - lib/rubocop-dev_doc.rb
60
+ - lib/rubocop/cop/dev_doc/migration/avoid_column_default.rb
61
+ - lib/rubocop/cop/dev_doc/migration/avoid_json_column.rb
62
+ - lib/rubocop/cop/dev_doc/migration/avoid_update_column.rb
63
+ - lib/rubocop/cop/dev_doc/migration/avoid_vague_column_names.rb
64
+ - lib/rubocop/cop/dev_doc/migration/date_column_naming.rb
65
+ - lib/rubocop/cop/dev_doc/migration/prefer_belongs_to.rb
66
+ - lib/rubocop/cop/dev_doc/migration/require_timestamps.rb
67
+ - lib/rubocop/cop/dev_doc/rails/no_deliver_later_in_transaction.rb
68
+ - lib/rubocop/cop/dev_doc/rails/no_perform_later_in_model.rb
69
+ - lib/rubocop/cop/dev_doc/route/resources_require_only.rb
70
+ - lib/rubocop/cop/dev_doc/style/avoid_head_response.rb
71
+ - lib/rubocop/cop/dev_doc/style/avoid_send.rb
72
+ - lib/rubocop/dev_doc.rb
73
+ - lib/rubocop/dev_doc/plugin.rb
74
+ - lib/rubocop/dev_doc/version.rb
75
+ licenses: []
76
+ metadata:
77
+ default_lint_roller_plugin: RuboCop::DevDoc::Plugin
78
+ rubygems_mfa_required: 'true'
79
+ rdoc_options: []
80
+ require_paths:
81
+ - lib
82
+ required_ruby_version: !ruby/object:Gem::Requirement
83
+ requirements:
84
+ - - ">="
85
+ - !ruby/object:Gem::Version
86
+ version: '0'
87
+ required_rubygems_version: !ruby/object:Gem::Requirement
88
+ requirements:
89
+ - - ">="
90
+ - !ruby/object:Gem::Version
91
+ version: '0'
92
+ requirements: []
93
+ rubygems_version: 4.0.6
94
+ specification_version: 4
95
+ summary: RuboCop cops enforcing dev-doc best practices
96
+ test_files: []