rubocop-dev_doc 0.5.0.beta1 → 0.6.0.beta1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,123 @@
1
+ module RuboCop
2
+ module Cop
3
+ module DevDoc
4
+ module Style
5
+ # Flag `Object#tap` whose block ignores the yielded value.
6
+ #
7
+ # ## Rationale
8
+ # `tap` exists to **operate on the yielded receiver and return it** —
9
+ # `obj.tap { |x| configure(x) }`. The yielded value is the whole
10
+ # point of the method.
11
+ #
12
+ # When the block **ignores** the yielded value, `tap` is no longer
13
+ # doing that job. It's being used purely as a temp-variable-elision
14
+ # trick for "run a side effect, then return the receiver":
15
+ #
16
+ # ❌ Block never references the yielded value — `tap` is eliding a temp var
17
+ # read_value.tap { do_side_effect }
18
+ #
19
+ # This costs readability for no benefit:
20
+ #
21
+ # - **It hides the return value in the receiver position.** `tap`'s
22
+ # "returns self" behaviour is its *less obvious* property. A reader
23
+ # has to stop and recall that the expression returns the receiver,
24
+ # not the block's value. The explicit form puts the return value on
25
+ # its own line where "what does this return" is literal.
26
+ # - **Footgun magnet.** Return-value semantics here are subtle — it's
27
+ # easy to nearby-edit the block into something whose value you think
28
+ # matters, or to confuse it with the broken `expr; side_effect`
29
+ # sequence (which returns the side effect's value, not `expr`'s).
30
+ # Code whose contract is "return this value" reads and edits more
31
+ # safely when the return is explicit.
32
+ # - **Equal stakes → prefer the duller form.** Both forms are correct
33
+ # and neither is faster. With no functional difference, the more
34
+ # obvious one wins in a shared codebase.
35
+ #
36
+ # ✔️ Explicit intent, return value on its own line
37
+ # result = read_value
38
+ # do_side_effect
39
+ # result
40
+ #
41
+ # ## When `tap` IS the right choice
42
+ # Reserve `tap` for its canonical use — when the block references the
43
+ # yielded value:
44
+ #
45
+ # ✔️ Block uses the yielded value — this is what `tap` is for
46
+ # Model.new.tap { |m| m.role = :admin; m.save }
47
+ # record.tap { |r| logger.debug(r.to_sql) }
48
+ #
49
+ # ## Exception
50
+ # Genuine intentional uses (e.g. a logging side effect that reads
51
+ # clearer in tap position) go through an inline `# rubocop:disable`
52
+ # with a reason. The friction is the feature — it forces the choice
53
+ # to be articulated and reviewed.
54
+ #
55
+ # NOTE: Numbered-param (`_1`) and implicit-`it` (Ruby 3.4+) forms are
56
+ # not flagged — by construction, those forms reference the yielded
57
+ # value, which is the canonical use of `tap`. Block-pass (`&:sym`) is
58
+ # likewise not flagged; it isn't a block node.
59
+ #
60
+ # NOTE: If a nested block *shadows* the outer arg name
61
+ # (`tap { |v| items.each { |v| v.foo } }`), the inner reference is
62
+ # treated as covering the outer — a rare residual false negative.
63
+ # Inline-disable if it ever matters.
64
+ #
65
+ # @example
66
+ # # bad — block ignores the yielded value
67
+ # read_value.tap { do_side_effect }
68
+ # record.tap { |r| log_event }
69
+ # value.tap { }
70
+ #
71
+ # # good — block references the yielded value
72
+ # Model.new.tap { |m| m.role = :admin; m.save }
73
+ # record.tap { |r| logger.debug(r.to_sql) }
74
+ #
75
+ # # good — a reference inside a nested block still counts
76
+ # items.tap { |i| [1, 2].each { i << _1 } }
77
+ #
78
+ # # good — preferred explicit form when the value is unused
79
+ # result = read_value
80
+ # do_side_effect
81
+ # result
82
+ class TapBlockIgnoresValue < Base
83
+ MSG = '`tap` is for operating on the yielded value — when the ' \
84
+ 'block ignores it, prefer an explicit assign-and-return. ' \
85
+ 'Disable with a reason when the side effect reads clearer ' \
86
+ 'in `tap` position.'.freeze
87
+
88
+ def on_block(node)
89
+ return unless node.send_node.method?(:tap)
90
+ return if block_uses_yielded_value?(node)
91
+
92
+ add_offense(node.send_node.loc.selector)
93
+ end
94
+
95
+ private
96
+
97
+ def block_uses_yielded_value?(node)
98
+ args = node.arguments
99
+ return false unless args
100
+ return false if args.children.empty? # no param declared → value ignored
101
+
102
+ # `tap` yields one value; multi-arg / destructuring is unusual.
103
+ # Be conservative — don't flag what we can't confidently call
104
+ # "ignoring the value".
105
+ return true if args.children.length >= 2
106
+
107
+ arg = args.children.first
108
+ return true unless arg.respond_to?(:name)
109
+
110
+ return false unless node.body # empty body with declared arg → flag
111
+
112
+ # Walk from the block node (not `body`): `each_descendant`
113
+ # excludes the receiver, so a body that *is* an lvar
114
+ # (`tap { |v| v }`) would be missed otherwise. Nested blocks
115
+ # are walked too, so a reference inside one counts as "used".
116
+ # The shadowing edge case is documented above as a NOTE.
117
+ node.each_descendant(:lvar).any? { |n| n.children.first == arg.name }
118
+ end
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end
@@ -13,6 +13,18 @@ module RuboCop
13
13
  # controller test (very rare) — e.g. a search-ranking detail the
14
14
  # controller never exposes.
15
15
  #
16
+ # The danger a unit test hides: it can stay green while the feature is
17
+ # broken in production. It proves a method works in isolation — NOT that
18
+ # the real request path calls that method, in the right order, inside the
19
+ # transaction it needs. A model method can be flawless in a unit test
20
+ # while the controller calls the wrong method, skips it, or runs it
21
+ # outside its transaction; the unit test stays green and the feature is
22
+ # broken. Controller tests fail when the *wiring* is wrong — which is
23
+ # where regressions actually live. So a passing unit test is not evidence
24
+ # the feature works; it is false confidence about production. Reach for
25
+ # one only when you are sure a controller test genuinely cannot reach the
26
+ # path, not because it is quicker to write.
27
+ #
16
28
  # This cop flags only the literal `< ActiveSupport::TestCase`
17
29
  # superclass. The blessed blackbox bases —
18
30
  # `ActionDispatch::IntegrationTest`, `Glib::IntegrationTest`,
@@ -20,6 +32,18 @@ module RuboCop
20
32
  # though they inherit from `ActiveSupport::TestCase` transitively.
21
33
  #
22
34
  # ## Escape hatch
35
+ # Before reaching for a unit test, assume a controller test IS possible
36
+ # and look harder — that conclusion is almost always premature. Behaviour
37
+ # that feels inherently unit-level is usually reachable end-to-end:
38
+ # - Transaction rollback / "a failure mid-request": inject the failure
39
+ # at a class-method chokepoint the gem/service calls (stub it to
40
+ # raise), drive the real request, and assert the observable rollback
41
+ # (e.g. `assert_no_difference` on the record count). Even atomicity,
42
+ # which feels inherently unit-level, is reachable this way.
43
+ # - "The controller wraps it in a transaction so I can't isolate the
44
+ # model's own": you usually don't need to — assert the *observable*
45
+ # guarantee through the real path; that is what matters in production.
46
+ #
23
47
  # When a unit test is genuinely necessary, suppress with a reason that
24
48
  # explains why a controller test can't cover the path. That reason IS the
25
49
  # required justification — keep it specific and reviewable:
@@ -1,5 +1,5 @@
1
1
  module RuboCop
2
2
  module DevDoc
3
- VERSION = "0.5.0.beta1".freeze
3
+ VERSION = "0.6.0.beta1".freeze
4
4
  end
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rubocop-dev_doc
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0.beta1
4
+ version: 0.6.0.beta1
5
5
  platform: ruby
6
6
  authors:
7
7
  - dev-doc contributors
@@ -91,10 +91,11 @@ files:
91
91
  - lib/rubocop-dev_doc.rb
92
92
  - lib/rubocop/cop/dev_doc/auth/current_user_branching.rb
93
93
  - lib/rubocop/cop/dev_doc/auth/load_resource_current_user_guard.rb
94
+ - lib/rubocop/cop/dev_doc/i18n/avoid_titleize_humanize.rb
95
+ - lib/rubocop/cop/dev_doc/i18n/localizable_props.rb
94
96
  - lib/rubocop/cop/dev_doc/i18n/report_text.rb
95
97
  - lib/rubocop/cop/dev_doc/i18n/require_translation.rb
96
98
  - lib/rubocop/cop/dev_doc/i18n/translation_key_prefix.rb
97
- - lib/rubocop/cop/dev_doc/i18n/unverified_translation.rb
98
99
  - lib/rubocop/cop/dev_doc/migration/amount_column_in_cents.rb
99
100
  - lib/rubocop/cop/dev_doc/migration/avoid_bypassing_validation.rb
100
101
  - lib/rubocop/cop/dev_doc/migration/avoid_column_default.rb
@@ -109,7 +110,9 @@ files:
109
110
  - lib/rubocop/cop/dev_doc/migration/require_timestamps.rb
110
111
  - lib/rubocop/cop/dev_doc/rails/application_record_transaction.rb
111
112
  - lib/rubocop/cop/dev_doc/rails/avoid_lifecycle_method_override.rb
113
+ - lib/rubocop/cop/dev_doc/rails/avoid_ordering_by_id.rb
112
114
  - lib/rubocop/cop/dev_doc/rails/avoid_rails_callbacks.rb
115
+ - lib/rubocop/cop/dev_doc/rails/avoid_raw_sql.rb
113
116
  - lib/rubocop/cop/dev_doc/rails/bang_save_in_transaction.rb
114
117
  - lib/rubocop/cop/dev_doc/rails/enum_column_not_null.rb
115
118
  - lib/rubocop/cop/dev_doc/rails/enum_must_be_symbolized.rb
@@ -125,9 +128,11 @@ files:
125
128
  - lib/rubocop/cop/dev_doc/style/avoid_send.rb
126
129
  - lib/rubocop/cop/dev_doc/style/minimize_variable_scope.rb
127
130
  - lib/rubocop/cop/dev_doc/style/no_unscoped_method_definitions.rb
131
+ - lib/rubocop/cop/dev_doc/style/prefer_public_send.rb
128
132
  - lib/rubocop/cop/dev_doc/style/repeated_bracket_read.rb
129
133
  - lib/rubocop/cop/dev_doc/style/repeated_safe_navigation_receiver.rb
130
134
  - lib/rubocop/cop/dev_doc/style/string_symbol_comparison.rb
135
+ - lib/rubocop/cop/dev_doc/style/tap_block_ignores_value.rb
131
136
  - lib/rubocop/cop/dev_doc/test/avoid_glib_travel_freeze.rb
132
137
  - lib/rubocop/cop/dev_doc/test/avoid_unit_test.rb
133
138
  - lib/rubocop/cop/dev_doc/test/response_assert_equal.rb
@@ -1,106 +0,0 @@
1
- module RuboCop
2
- module Cop
3
- module DevDoc
4
- module I18n
5
- # Warn when a glib text prop is set from a non-literal value that isn't
6
- # a `t(...)` call — it may be an unlocalized string.
7
- #
8
- # ## Rationale
9
- # `DevDoc/I18n/RequireTranslation` only catches string literals written
10
- # at the call site. A value passed through a variable or method
11
- # (`label: label_text`) escapes it — the literal could be one hop away.
12
- # This cop flags those values so a human can confirm they resolve
13
- # through I18n.
14
- #
15
- # It can't tell a stashed literal from genuinely dynamic data
16
- # (`user.name`) or a translation reached via a variable, so it's a
17
- # review aid, not a clean lint: it's **disabled by default** and runs at
18
- # `info` severity. Run it during a localization pass, not on every
19
- # commit. Values that are obviously translated (`t(...)`, `translate`,
20
- # `I18n.t`) are excluded; literals are left to `RequireTranslation`.
21
- #
22
- # ⚠️ Can't be verified — confirm it's localized
23
- # view.fields_text label: label_text
24
- # view.p text: user.name
25
- #
26
- # ✔️ Obviously localized — not flagged
27
- # view.p text: t('home.welcome')
28
- #
29
- # @example
30
- # # warning (non-literal — may be unlocalized)
31
- # view.fields_text label: label_text
32
- #
33
- # # warning (attribute — may be unlocalized)
34
- # view.p text: user.name
35
- #
36
- # # good (obviously localized — not flagged)
37
- # view.p text: t('home.welcome')
38
- #
39
- # # ignored (literal — handled by RequireTranslation)
40
- # view.p text: 'Welcome'
41
- class UnverifiedTranslation < Base
42
- MSG = 'Possibly unlocalized: `%<key>s:` is set from a non-literal. ' \
43
- 'Use `t(...)` if this is user-facing text.'.freeze
44
-
45
- DYNAMIC_TYPES = %i[lvar ivar send csend].freeze
46
- TRANSLATION_METHODS = %i[t translate].freeze
47
-
48
- DEFAULT_WATCHED_METHODS = %w[
49
- h1 h2 h3 h4 h5 p label markdown
50
- fields_text fields_number fields_select fields_password
51
- fields_textarea fields_check fields_checkGroup fields_chipGroup
52
- fields_timeZone fields_radioGroup fields_date fields_datetime
53
- ].freeze
54
-
55
- DEFAULT_LOCALIZABLE_KEYS = %w[
56
- title subtitle subsubtitle label placeholder text
57
- ].freeze
58
-
59
- def on_send(node)
60
- return unless watched_methods.include?(node.method_name.to_s)
61
-
62
- node.arguments.each do |arg|
63
- next unless arg.hash_type?
64
-
65
- arg.pairs.each { |pair| check_pair(pair) }
66
- end
67
- end
68
- alias on_csend on_send
69
-
70
- private
71
-
72
- def check_pair(pair)
73
- key = pair.key
74
- return unless key.sym_type?
75
- return unless localizable_keys.include?(key.value.to_s)
76
- return unless unverified?(pair.value)
77
-
78
- add_offense(pair.value, message: format(MSG, key: key.value))
79
- end
80
-
81
- # A value we can neither confirm nor deny is localized: a variable or
82
- # method result (but not an obvious `t(...)`/`translate` call). String
83
- # and other literals are excluded — those are RequireTranslation's job.
84
- def unverified?(node)
85
- return false unless DYNAMIC_TYPES.include?(node.type)
86
-
87
- !translation_call?(node)
88
- end
89
-
90
- def translation_call?(node)
91
- (node.send_type? || node.csend_type?) &&
92
- TRANSLATION_METHODS.include?(node.method_name)
93
- end
94
-
95
- def watched_methods
96
- cop_config.fetch('WatchedMethods', DEFAULT_WATCHED_METHODS)
97
- end
98
-
99
- def localizable_keys
100
- cop_config.fetch('LocalizableKeys', DEFAULT_LOCALIZABLE_KEYS)
101
- end
102
- end
103
- end
104
- end
105
- end
106
- end