rubocop-dev_doc 0.11.0 → 0.12.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 (33) hide show
  1. checksums.yaml +4 -4
  2. data/config/default.yml +28 -0
  3. data/lib/dev_doc/i18n/pseudo_locale.rb +23 -19
  4. data/lib/dev_doc/test/lints/cop_drift_check.rb +43 -0
  5. data/lib/dev_doc/test/lints/cop_drift_test.rb +40 -0
  6. data/lib/dev_doc/test/lints/cron_schedule.rb +137 -115
  7. data/lib/dev_doc/test/lints/duplicate_snapshot.rb +5 -2
  8. data/lib/dev_doc/test/lints/http_driven_controller_tests.rb +5 -0
  9. data/lib/dev_doc/test/lints/no_file_excludes.rb +32 -21
  10. data/lib/dev_doc/test/pseudo_i18n_crawler.rb +96 -60
  11. data/lib/rubocop/cop/dev_doc/auth/load_resource_current_user_guard.rb +3 -3
  12. data/lib/rubocop/cop/dev_doc/i18n/localizable_props.rb +12 -11
  13. data/lib/rubocop/cop/dev_doc/i18n/translation_key_prefix.rb +3 -0
  14. data/lib/rubocop/cop/dev_doc/migration/date_column_naming.rb +3 -0
  15. data/lib/rubocop/cop/dev_doc/rails/avoid_bypassing_validation.rb +3 -2
  16. data/lib/rubocop/cop/dev_doc/rails/avoid_lifecycle_method_override.rb +2 -2
  17. data/lib/rubocop/cop/dev_doc/rails/avoid_rails_callbacks.rb +3 -3
  18. data/lib/rubocop/cop/dev_doc/rails/enum_column_not_null.rb +3 -1
  19. data/lib/rubocop/cop/dev_doc/route/no_custom_actions.rb +4 -1
  20. data/lib/rubocop/cop/dev_doc/route/resource_name_number.rb +5 -4
  21. data/lib/rubocop/cop/dev_doc/route/resources_require_only.rb +3 -4
  22. data/lib/rubocop/cop/dev_doc/style/avoid_symbolizing_boundary_input.rb +116 -0
  23. data/lib/rubocop/cop/dev_doc/style/case_else_decision.rb +2 -1
  24. data/lib/rubocop/cop/dev_doc/style/minimize_variable_scope.rb +23 -13
  25. data/lib/rubocop/cop/dev_doc/style/no_unscoped_method_definitions.rb +21 -0
  26. data/lib/rubocop/cop/dev_doc/style/repeated_bracket_read.rb +13 -9
  27. data/lib/rubocop/cop/dev_doc/style/repeated_safe_navigation_receiver.rb +13 -10
  28. data/lib/rubocop/cop/dev_doc/style/string_symbol_comparison.rb +15 -7
  29. data/lib/rubocop/cop/dev_doc/test/avoid_glib_travel_freeze.rb +2 -2
  30. data/lib/rubocop/cop/dev_doc/test/no_unit_idioms_in_integration_tests.rb +134 -0
  31. data/lib/rubocop/dev_doc/plugin.rb +2 -0
  32. data/lib/rubocop/dev_doc/version.rb +1 -1
  33. metadata +6 -2
@@ -102,17 +102,21 @@ module RuboCop
102
102
 
103
103
  seen = {}
104
104
  body.each_descendant(:send) do |send_node|
105
- next unless bracket_read?(send_node)
105
+ track_bracket_read(send_node, seen)
106
+ end
107
+ end
108
+
109
+ def track_bracket_read(send_node, seen)
110
+ return unless bracket_read?(send_node)
106
111
 
107
- receiver = send_node.receiver
108
- key = send_node.first_argument
109
- composite = composite_key(receiver, key)
112
+ receiver = send_node.receiver
113
+ key = send_node.first_argument
114
+ composite = composite_key(receiver, key)
110
115
 
111
- if seen[composite]
112
- add_offense(send_node.loc.selector, message: format(MSG, receiver: receiver.source, key: key.source))
113
- else
114
- seen[composite] = true
115
- end
116
+ if seen[composite]
117
+ add_offense(send_node.loc.selector, message: format(MSG, receiver: receiver.source, key: key.source))
118
+ else
119
+ seen[composite] = true
116
120
  end
117
121
  end
118
122
 
@@ -85,7 +85,8 @@ module RuboCop
85
85
  # current_user&.full_name
86
86
  # end
87
87
  class RepeatedSafeNavigationReceiver < Base
88
- MSG = "Receiver `%<receiver>s` already used with `&.` earlier in this method — assign once and use `.` after.".freeze
88
+ MSG = "Receiver `%<receiver>s` already used with `&.` earlier in this method — " \
89
+ "assign once and use `.` after.".freeze
89
90
 
90
91
  def on_def(node)
91
92
  check_method(node)
@@ -99,16 +100,18 @@ module RuboCop
99
100
  return unless body
100
101
 
101
102
  seen = {}
102
- body.each_descendant(:csend) do |csend|
103
- receiver = csend.receiver
104
- next unless receiver
103
+ body.each_descendant(:csend) { |csend| track_receiver(csend, seen) }
104
+ end
105
+
106
+ def track_receiver(csend, seen)
107
+ receiver = csend.receiver
108
+ return unless receiver
105
109
 
106
- key = receiver.source
107
- if seen[key]
108
- add_offense(csend.loc.dot, message: format(MSG, receiver: key))
109
- else
110
- seen[key] = true
111
- end
110
+ key = receiver.source
111
+ if seen[key]
112
+ add_offense(csend.loc.dot, message: format(MSG, receiver: key))
113
+ else
114
+ seen[key] = true
112
115
  end
113
116
  end
114
117
  end
@@ -11,16 +11,23 @@ module RuboCop
11
11
  # Strings and symbols are not equal in Ruby, and string-typed values
12
12
  # arrive from boundaries the developer doesn't control: HTTP params,
13
13
  # request headers, ENV. Comparing one of these directly against a
14
- # symbol literal is a guaranteed bug. Convert at the boundary instead
15
- # — see `best_practices/backend/en/01a_defensive_programming.md` item 7.
14
+ # symbol literal is a guaranteed bug. Allow-list into a symbol at the
15
+ # boundary instead — see
16
+ # `best_practices/backend/en/01a_defensive_programming.md` item 7.
16
17
  #
17
18
  # ❌
18
19
  # if params[:status] == :draft # always false
19
20
  #
20
- # ✔ Convert at the boundary, then compare
21
- # @filter_status = params[:status]&.to_sym
21
+ # ✔ Allow-list into a symbol at the boundary, then compare
22
+ # @filter_status = glib_allowlist_symbol_param(params[:status], STATUSES, default: :draft)
22
23
  # if @filter_status == :draft
23
24
  #
25
+ # A bare `&.to_sym` conversion would also fix the always-false bug,
26
+ # but it lets ANY client value into the symbol domain — the sibling
27
+ # `DevDoc/Style/AvoidSymbolizingBoundaryInput` flags exactly that.
28
+ # The two cops are a complementary pair: this one polices comparing
29
+ # without converting; that one polices converting without validating.
30
+ #
24
31
  # ## Sources detected
25
32
  # - `params[…]`
26
33
  # - `request.headers[…]`
@@ -39,12 +46,13 @@ module RuboCop
39
46
  # request.headers['X-Mode'] != :debug
40
47
  #
41
48
  # # good
42
- # params[:status]&.to_sym == :draft
49
+ # glib_allowlist_symbol_param(params[:status], STATUSES, default: :draft) == :draft
43
50
  # ENV['MODE'] == 'production'
44
51
  class StringSymbolComparison < Base
45
52
  MSG = 'Comparing string-typed `%<source>s` to a symbol literal is always false. ' \
46
- 'Convert at the boundary with `&.to_sym` or compare against a string instead ' \
47
- '(see backend/01a_defensive_programming.md item 7).'.freeze
53
+ 'Allow-list it into a symbol at the boundary ' \
54
+ '(`glib_allowlist_symbol_param(value, ALLOWED, default:)`) or compare against ' \
55
+ 'a string instead (see backend/01a_defensive_programming.md item 7).'.freeze
48
56
 
49
57
  RESTRICT_ON_SEND = %i[== !=].freeze
50
58
 
@@ -17,8 +17,8 @@ module RuboCop
17
17
  # ## Escape hatch
18
18
  # Disable per-line with a comment explaining why `glib_travel` doesn't work:
19
19
  #
20
- # glib_travel_freeze(time) do # rubocop:disable DevDoc/Test/AvoidGlibTravelFreeze
21
- # # Reason: <explanation>
20
+ # # rubocop:disable DevDoc/Test/AvoidGlibTravelFreeze -- <explanation>
21
+ # glib_travel_freeze(time) do
22
22
  # end
23
23
  #
24
24
  # @example
@@ -0,0 +1,134 @@
1
+ module RuboCop
2
+ module Cop
3
+ module DevDoc
4
+ module Test
5
+ # Integration tests must be BOUNDARY tests: exercise one entry point
6
+ # (an HTTP request, or a job for cron-driven flows with no HTTP
7
+ # surface) and assert only its observable outputs — response status
8
+ # and body, database state, delivered/enqueued mail. Unit-test idioms
9
+ # under the integration base class are dishonest by construction: the
10
+ # base class is a declaration of WHAT KIND of test this is, and the
11
+ # runtime/static lints trust that declaration.
12
+ #
13
+ # ## What flags, and why
14
+ # - `@controller`, `assigns`, `instance_variable_get`: reaching into
15
+ # request-internal state the boundary is supposed to hide.
16
+ # - `FooService.new` / `FooPolicy.new` (any suffix in
17
+ # `UnitClassSuffixes`): instantiating app-layer objects to test or
18
+ # query them directly. When used to COMPUTE an expected value, this
19
+ # is a circular assertion — the test asks production code what to
20
+ # expect, so a bug in that code passes its own test. Pin literals
21
+ # or fixture facts instead.
22
+ # - `FooMailer.with(...)` and any other direct mailer invocation:
23
+ # manufacture mail through the flow that actually sends it, then
24
+ # read `deliveries`.
25
+ # - `include FooHelper`: importing production helpers to compute
26
+ # expectations — circular, same as above (`*TestHelper` modules are
27
+ # exempt).
28
+ # - `Minitest::Mock`, `.stub(...)`, `require 'minitest/mock'`:
29
+ # mock/stub machinery is unit-test tooling by definition.
30
+ #
31
+ # ## What deliberately does NOT flag
32
+ # - Model reads (`Organization.last`, `Member.find_by!`, counts,
33
+ # attribute readers): the database is an OUTBOUND edge of the
34
+ # boundary — asserting persisted state is the point — and fixture
35
+ # lookup is setup.
36
+ # - Job classes (`FooJob.new`, `perform_now`, `perform_enqueued_jobs`):
37
+ # jobs are an INBOUND edge — the entry point for cron flows.
38
+ #
39
+ # ## The honesty rule
40
+ # If the property cannot be observed at any boundary, the test is a
41
+ # unit test. Declare it: `Glib::LastResortUnitTest` in an exempt
42
+ # directory with a justification header. An observed evasion (2026-07,
43
+ # a guard-aware agent bolting a GET onto direct policy assertions)
44
+ # is why this cop assumes disguise rather than accident. Do not
45
+ # disable this cop.
46
+ class NoUnitIdiomsInIntegrationTests < Base
47
+ MSG = '`%<ref>s` is a unit-test idiom inside an integration test. A boundary test ' \
48
+ 'exercises one entry point (HTTP request, or a job for cron flows) and asserts ' \
49
+ 'only its observable outputs (status, body, DB state, mails). Computing an ' \
50
+ 'expectation with production code is a CIRCULAR assertion (the code grades its ' \
51
+ 'own homework); instantiating app objects or reaching into the controller is ' \
52
+ 'unit testing in disguise, and a bolted-on request does not change what it is. ' \
53
+ 'Be honest about what this test is: assert through the boundary, or declare a ' \
54
+ 'real unit test (Glib::LastResortUnitTest + exempt directory + justification ' \
55
+ 'header). Do not disable this cop.'.freeze
56
+
57
+ CONTROLLER_INTERNALS = %i[assigns instance_variable_get].freeze
58
+ HELPER_EXEMPT = /TestHelper\z|MailerTester\z/
59
+ MOCK_REQUIRES = ['minitest/mock'].freeze
60
+
61
+ # Baseline lives in code (config lists are droppable; see
62
+ # CurrentUserBranching's DEFAULT_ALLOWED_METHODS for the incident
63
+ # that taught this). The yml key ADDS suffixes, it cannot remove
64
+ # these.
65
+ DEFAULT_UNIT_CLASS_SUFFIXES = %w[Service Policy Form Presenter Serializer Decorator].freeze
66
+
67
+ def on_ivar(node)
68
+ return unless node.children.first == :@controller
69
+
70
+ add_offense(node, message: format(MSG, ref: '@controller'))
71
+ end
72
+
73
+ def on_send(node)
74
+ ref = offense_ref(node)
75
+ return unless ref
76
+
77
+ add_offense(node, message: format(MSG, ref: ref))
78
+ end
79
+
80
+ private
81
+
82
+ def offense_ref(node)
83
+ return node.method_name.to_s if CONTROLLER_INTERNALS.include?(node.method_name)
84
+ return "#{node.receiver.const_name}.new" if unit_class_instantiation?(node)
85
+ return "#{node.receiver.const_name}.#{node.method_name}" if mailer_invocation?(node)
86
+ return "include #{node.first_argument.const_name}" if helper_include?(node)
87
+
88
+ mock_machinery_ref(node)
89
+ end
90
+
91
+ def unit_class_instantiation?(node)
92
+ node.method_name == :new && receiver_const_suffix?(node, unit_class_suffixes)
93
+ end
94
+
95
+ def mailer_invocation?(node)
96
+ receiver_const_suffix?(node, ['Mailer'])
97
+ end
98
+
99
+ def helper_include?(node)
100
+ return false unless node.method_name == :include && node.first_argument&.const_type?
101
+
102
+ name = node.first_argument.const_name
103
+ name.end_with?('Helper') && !name.match?(HELPER_EXEMPT)
104
+ end
105
+
106
+ def mock_machinery_ref(node)
107
+ return 'Minitest::Mock' if receiver_const_suffix?(node, ['Minitest::Mock']) || minitest_mock_const?(node)
108
+ return 'stub' if node.method_name == :stub && node.receiver
109
+ return "require 'minitest/mock'" if mock_require?(node)
110
+
111
+ nil
112
+ end
113
+
114
+ def minitest_mock_const?(node)
115
+ node.method_name == :new && node.receiver&.const_type? && node.receiver.const_name == 'Minitest::Mock'
116
+ end
117
+
118
+ def mock_require?(node)
119
+ node.method_name == :require && node.first_argument&.str_type? &&
120
+ MOCK_REQUIRES.include?(node.first_argument.value)
121
+ end
122
+
123
+ def receiver_const_suffix?(node, suffixes)
124
+ node.receiver&.const_type? && suffixes.any? { |suffix| node.receiver.const_name.end_with?(suffix) }
125
+ end
126
+
127
+ def unit_class_suffixes
128
+ DEFAULT_UNIT_CLASS_SUFFIXES + cop_config.fetch('UnitClassSuffixes', []).map(&:to_s)
129
+ end
130
+ end
131
+ end
132
+ end
133
+ end
134
+ end
@@ -1,5 +1,7 @@
1
1
  module RuboCop
2
2
  module DevDoc
3
+ # lint_roller entry point: tells RuboCop where this gem's cops and
4
+ # default configuration live.
3
5
  class Plugin < LintRoller::Plugin
4
6
  def about
5
7
  LintRoller::About.new(
@@ -1,5 +1,5 @@
1
1
  module RuboCop
2
2
  module DevDoc
3
- VERSION = "0.11.0".freeze
3
+ VERSION = "0.12.0".freeze
4
4
  end
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rubocop-dev_doc
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.11.0
4
+ version: 0.12.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - dev-doc contributors
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-07-10 00:00:00.000000000 Z
11
+ date: 2026-07-11 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -90,6 +90,8 @@ files:
90
90
  - config/obsoletion.yml
91
91
  - lib/dev_doc/i18n/pseudo_locale.rb
92
92
  - lib/dev_doc/test/best_practice_lints.rb
93
+ - lib/dev_doc/test/lints/cop_drift_check.rb
94
+ - lib/dev_doc/test/lints/cop_drift_test.rb
93
95
  - lib/dev_doc/test/lints/cron_schedule.rb
94
96
  - lib/dev_doc/test/lints/duplicate_snapshot.rb
95
97
  - lib/dev_doc/test/lints/http_driven_controller_tests.rb
@@ -137,6 +139,7 @@ files:
137
139
  - lib/rubocop/cop/dev_doc/style/avoid_head_response.rb
138
140
  - lib/rubocop/cop/dev_doc/style/avoid_insecure_send.rb
139
141
  - lib/rubocop/cop/dev_doc/style/avoid_options_hash.rb
142
+ - lib/rubocop/cop/dev_doc/style/avoid_symbolizing_boundary_input.rb
140
143
  - lib/rubocop/cop/dev_doc/style/case_else_decision.rb
141
144
  - lib/rubocop/cop/dev_doc/style/literal_operator_in_condition.rb
142
145
  - lib/rubocop/cop/dev_doc/style/literal_or_in_when_clause.rb
@@ -152,6 +155,7 @@ files:
152
155
  - lib/rubocop/cop/dev_doc/test/avoid_unit_test.rb
153
156
  - lib/rubocop/cop/dev_doc/test/justification_header.rb
154
157
  - lib/rubocop/cop/dev_doc/test/no_persistence_in_mailer_previews.rb
158
+ - lib/rubocop/cop/dev_doc/test/no_unit_idioms_in_integration_tests.rb
155
159
  - lib/rubocop/cop/dev_doc/test/require_glib_integration_base.rb
156
160
  - lib/rubocop/cop/dev_doc/test/require_glib_travel.rb
157
161
  - lib/rubocop/cop/dev_doc/test/require_glib_travel_block.rb