rubocop-yiffspace 0.0.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.
Files changed (47) hide show
  1. checksums.yaml +7 -0
  2. data/.idea/.gitignore +8 -0
  3. data/.idea/jsonSchemas.xml +25 -0
  4. data/.idea/misc.xml +4 -0
  5. data/.idea/modules.xml +8 -0
  6. data/.idea/rubocop-plugin.iml +65 -0
  7. data/.idea/vcs.xml +6 -0
  8. data/.rspec +3 -0
  9. data/.ruby-version +1 -0
  10. data/LICENSE.txt +21 -0
  11. data/README.md +39 -0
  12. data/Rakefile +33 -0
  13. data/config/custom.yml +17 -0
  14. data/config/default.yml +54 -0
  15. data/config/rails.yml +8 -0
  16. data/config/shared/bundler.yml +1 -0
  17. data/config/shared/gemspec.yml +1 -0
  18. data/config/shared/layout.yml +18 -0
  19. data/config/shared/lint.yml +4 -0
  20. data/config/shared/metrics.yml +35 -0
  21. data/config/shared/migration.yml +1 -0
  22. data/config/shared/naming.yml +7 -0
  23. data/config/shared/rails.yml +14 -0
  24. data/config/shared/security.yml +1 -0
  25. data/config/shared/style.yml +45 -0
  26. data/config/shared.yml +12 -0
  27. data/lib/rubocop/cop/mixin/active_record_helper.rb +18 -0
  28. data/lib/rubocop/cop/mixin/node_formatting_helper.rb +59 -0
  29. data/lib/rubocop/cop/yiff_space/belongs_to_user.rb +121 -0
  30. data/lib/rubocop/cop/yiff_space/belongs_to_user_invalid_ip.rb +107 -0
  31. data/lib/rubocop/cop/yiff_space/belongs_to_user_missing_ip.rb +105 -0
  32. data/lib/rubocop/cop/yiff_space/current_user_outside_of_requests.rb +194 -0
  33. data/lib/rubocop/cop/yiff_space/resolvable_user.rb +113 -0
  34. data/lib/rubocop/cop/yiff_space/user_to_id.rb +64 -0
  35. data/lib/rubocop/cop/yiffspace_cops.rb +10 -0
  36. data/lib/rubocop/yiff_space/plugin.rb +31 -0
  37. data/lib/rubocop/yiff_space/version.rb +7 -0
  38. data/lib/rubocop/yiff_space.rb +8 -0
  39. data/lib/rubocop-yiffspace.rb +9 -0
  40. data/spec/rubocop/cop/yiff_space/belongs_to_user_invalid_ip_spec.rb +109 -0
  41. data/spec/rubocop/cop/yiff_space/belongs_to_user_missing_ip_spec.rb +91 -0
  42. data/spec/rubocop/cop/yiff_space/belongs_to_user_spec.rb +158 -0
  43. data/spec/rubocop/cop/yiff_space/current_user_outside_of_requests_spec.rb +398 -0
  44. data/spec/rubocop/cop/yiff_space/resolvable_user_spec.rb +99 -0
  45. data/spec/rubocop/cop/yiff_space/user_to_id_spec.rb +67 -0
  46. data/spec/spec_helper.rb +20 -0
  47. metadata +118 -0
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module YiffSpace
6
+ # Enforces using `belongs_to_user` instead of `belongs_to` for
7
+ # User associations. `belongs_to_user` provides additional helper methods
8
+ # and is the project standard for any relation backed by a User record.
9
+ #
10
+ # It matches both `belongs_to(:user)` and associations with an explicit
11
+ # `class_name: "User"` option, auto-correcting both forms.
12
+ #
13
+ # The class name is configurable via `ClassName` (default: `User`).
14
+ # The inferred attribute name is the snake_case of the last segment
15
+ # (e.g. `ClassName: CurrentUser` matches `belongs_to(:current_user)`).
16
+ #
17
+ # @safety
18
+ # `belongs_to_user` must be defined in the target codebase, and the
19
+ # backing column must reference a User. Auto-correct is unsafe when
20
+ # either condition cannot be verified statically.
21
+ #
22
+ # @example
23
+ # # bad
24
+ # belongs_to(:user)
25
+ #
26
+ # # bad
27
+ # belongs_to(:creator, class_name: "User")
28
+ #
29
+ # # good
30
+ # belongs_to_user(:user)
31
+ #
32
+ # # good
33
+ # belongs_to_user(:creator)
34
+ #
35
+ # @example ClassName: CurrentUser
36
+ # # bad
37
+ # belongs_to(:current_user, optional: false)
38
+ #
39
+ # # bad
40
+ # belongs_to(:creator, class_name: "CurrentUser")
41
+ #
42
+ # # good
43
+ # belongs_to_user(:current_user, optional: false)
44
+ #
45
+ # # good
46
+ # belongs_to_user(:creator)
47
+ #
48
+ class BelongsToUser < Base
49
+ extend(AutoCorrector)
50
+ include(NodeFormattingHelper)
51
+
52
+ MSG = "Use `belongs_to_user(%<attr>s)` instead of `belongs_to(%<attr>s)`"
53
+
54
+ # requires_gem("activerecord")
55
+
56
+ # @!method belongs_to_user?(node)
57
+ def_node_matcher(:belongs_to_user?, <<~PATTERN)
58
+ (send nil? :belongs_to $_ $...)
59
+ PATTERN
60
+
61
+ def on_send(node)
62
+ belongs_to_user?(node) do |receiver, code|
63
+ return unless receiver.type?(:str, :sym)
64
+
65
+ attr = format_node(receiver)
66
+
67
+ return if attr.nil? || attr.empty? || !code.last&.hash_type?
68
+
69
+ options = format_node(code.last, {})
70
+
71
+ # Match belongs_to(:user) (or configured attr name)
72
+ if attr.to_sym == user_attr_name && !options.key?(:class_name)
73
+ register_offense(node, attr, options)
74
+ return
75
+ end
76
+
77
+ # Match belongs_to(attr, class_name: "User") (or configured ClassName)
78
+ register_offense(node, attr, options) if options[:class_name] == user_class_name
79
+ end
80
+ end
81
+
82
+ def on_csend(node)
83
+ on_send(node)
84
+ end
85
+
86
+ private
87
+
88
+ def register_offense(node, attr, options)
89
+ message = format(MSG, attr: attr.inspect)
90
+
91
+ add_offense(node, message: message) do |corrector|
92
+ new_options = format_new_options(attr, options)
93
+
94
+ corrector.replace(node, "belongs_to_user(#{new_options})")
95
+ end
96
+ end
97
+
98
+ def user_class_name
99
+ cop_config.fetch("ClassName", "User")
100
+ end
101
+
102
+ def user_attr_name
103
+ last_segment = user_class_name.split("::").last
104
+ last_segment.gsub(/([a-z])([A-Z])/, '\1_\2').downcase.to_sym
105
+ end
106
+
107
+ def format_new_options(attr, options)
108
+ list = [attr.inspect]
109
+
110
+ options.delete(:class_name)
111
+ if options.any?
112
+ options.each do |k, v|
113
+ list << "#{k}: #{v.inspect}"
114
+ end
115
+ end
116
+ list.join(", ")
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module YiffSpace
6
+ # Detects when `ip:` is specified in a `belongs_to_user` call
7
+ # but the corresponding IP address column does not exist in the database
8
+ # schema. It auto-corrects by removing the invalid `ip:` option.
9
+ #
10
+ # @safety
11
+ # This cop reads the database schema to determine whether the column
12
+ # exists. It will not flag anything if the schema cannot be loaded.
13
+ #
14
+ # @example
15
+ # # bad - no `creator_ip_addr` column exists in the schema
16
+ # belongs_to_user(:creator, ip: true)
17
+ #
18
+ # # bad - no `creator_custom` column exists in the schema
19
+ # belongs_to_user(:creator, ip: "creator_custom")
20
+ #
21
+ # # good
22
+ # belongs_to_user(:creator)
23
+ #
24
+ # # good - `creator_ip_addr` column exists in the schema
25
+ # belongs_to_user(:creator, ip: true)
26
+ #
27
+ class BelongsToUserInvalidIp < Base
28
+ extend(AutoCorrector)
29
+ include(ActiveRecordHelper)
30
+ include(NodeFormattingHelper)
31
+
32
+ MSG = "`ip: %<ip_set>s` set for `belongs_to_user(%<attr>s)` when `%<ip_attr>s` column does not exist"
33
+
34
+ # requires_gem("activerecord")
35
+
36
+ # @!method belongs_to_user?(node)
37
+ def_node_matcher(:belongs_to_user?, <<~PATTERN)
38
+ (send nil? :belongs_to_user $_ $...)
39
+ PATTERN
40
+
41
+ def on_send(node)
42
+ belongs_to_user?(node) do |receiver, code|
43
+ return unless receiver.type?(:str, :sym)
44
+
45
+ attr = format_node(receiver)
46
+ return if attr.nil? || attr.empty?
47
+
48
+ options = format_node(code.last, {})
49
+ return if [nil, "", false].include?(options[:ip])
50
+
51
+ return unless schema
52
+
53
+ table = table(node)
54
+ return unless table
55
+
56
+ column = options[:ip] == true ? "#{attr}_ip_addr" : options[:ip].to_s
57
+ exists = table.with_column?(name: column)
58
+
59
+ return if exists
60
+
61
+ register_offense(node, attr, options, column, options[:ip])
62
+ end
63
+ end
64
+
65
+ def on_csend(node)
66
+ on_send(node)
67
+ end
68
+
69
+ private
70
+
71
+ def class_node(node)
72
+ node.each_ancestor.find(&:class_type?)
73
+ end
74
+
75
+ def table(node)
76
+ klass = class_node(node)
77
+ return unless klass
78
+
79
+ schema.table_by(name: table_name(klass))
80
+ end
81
+
82
+ def register_offense(node, attr, options, column, original)
83
+ message = format(MSG, attr: attr.inspect, ip_attr: "#{table(node).name}.#{column}".inspect,
84
+ ip_set: original.inspect)
85
+
86
+ add_offense(node, message: message) do |corrector|
87
+ new_options = format_new_options(attr, options)
88
+
89
+ corrector.replace(node, "belongs_to_user(#{new_options})")
90
+ end
91
+ end
92
+
93
+ def format_new_options(attr, options)
94
+ list = [attr.inspect]
95
+
96
+ options.delete(:ip)
97
+ if options.any?
98
+ options.each do |k, v|
99
+ list << "#{k}: #{v.inspect}"
100
+ end
101
+ end
102
+ list.join(", ")
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module YiffSpace
6
+ # Detects when a `belongs_to_user` call is missing `ip: true`
7
+ # even though a corresponding `<attr>_ip_addr` column exists in the
8
+ # database schema. It auto-corrects by adding `ip: true`.
9
+ #
10
+ # @safety
11
+ # This cop reads the database schema to determine whether the column
12
+ # exists. It will not flag anything if the schema cannot be loaded.
13
+ #
14
+ # @example
15
+ # # bad - `creator_ip_addr` column exists but `ip:` is not specified
16
+ # belongs_to_user(:creator)
17
+ #
18
+ # # bad - `ip: false` explicitly opts out but the column exists
19
+ # belongs_to_user(:creator, ip: false)
20
+ #
21
+ # # good
22
+ # belongs_to_user(:creator, ip: true)
23
+ #
24
+ # # good - no `creator_ip_addr` column exists in the schema
25
+ # belongs_to_user(:creator)
26
+ #
27
+ class BelongsToUserMissingIp < Base
28
+ extend(AutoCorrector)
29
+ include(ActiveRecordHelper)
30
+ include(NodeFormattingHelper)
31
+
32
+ MSG = "Specify `ip: true` when a `belongs_to_user(%<attr>s)` relation has a corresponding `%<ip_attr>s` column"
33
+
34
+ # requires_gem("activerecord")
35
+
36
+ # @!method belongs_to_user?(node)
37
+ def_node_matcher(:belongs_to_user?, <<~PATTERN)
38
+ (send nil? :belongs_to_user $_ $...)
39
+ PATTERN
40
+
41
+ def on_send(node)
42
+ belongs_to_user?(node) do |receiver, code|
43
+ return unless receiver.type?(:str, :sym)
44
+
45
+ attr = format_node(receiver)
46
+ return if attr.nil? || attr.empty?
47
+
48
+ options = format_node(code.last, {})
49
+ return unless [nil, "", false].include?(options[:ip])
50
+
51
+ return unless schema
52
+
53
+ table = table(node)
54
+ return unless table
55
+
56
+ column = "#{attr}_ip_addr"
57
+ exists = table.with_column?(name: column)
58
+
59
+ return unless exists
60
+
61
+ register_offense(node, attr, options, column)
62
+ end
63
+ end
64
+
65
+ def on_csend(node)
66
+ on_send(node)
67
+ end
68
+
69
+ private
70
+
71
+ def class_node(node)
72
+ node.each_ancestor.find(&:class_type?)
73
+ end
74
+
75
+ def table(node)
76
+ klass = class_node(node)
77
+ return unless klass
78
+
79
+ schema.table_by(name: table_name(klass))
80
+ end
81
+
82
+ def register_offense(node, attr, options, column)
83
+ message = format(MSG, attr: attr.inspect, ip_attr: column.inspect)
84
+
85
+ add_offense(node, message: message) do |corrector|
86
+ new_options = format_new_options(attr, options)
87
+
88
+ corrector.replace(node, "belongs_to_user(#{new_options})")
89
+ end
90
+ end
91
+
92
+ def format_new_options(attr, options)
93
+ list = [attr.inspect, "ip: true"]
94
+
95
+ if options.any?
96
+ options.each do |k, v|
97
+ list << "#{k}: #{v.inspect}"
98
+ end
99
+ end
100
+ list.join(", ")
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,194 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module YiffSpace
6
+ # Prevents use of the CurrentUser class outside of the request
7
+ # cycle. CurrentUser relies on thread-local state set by request
8
+ # middleware and is unavailable in background jobs, rake tasks, or other
9
+ # contexts outside of the request lifecycle.
10
+ #
11
+ # The class name is configurable via `ClassName` (default: `CurrentUser`).
12
+ # References inside the class that defines CurrentUser itself are always
13
+ # ignored. Controllers, views, helpers, presenters, and decorators are
14
+ # excluded by default. Use `IgnoredMethods`, `IgnoredMethodPrefixes`,
15
+ # `IgnoredMethodSuffixes`, or `IgnoredMethodPatterns` to allow
16
+ # CurrentUser in specific methods in checked files.
17
+ #
18
+ # @example
19
+ # # bad
20
+ # def process_records
21
+ # CurrentUser.id
22
+ # end
23
+ #
24
+ # # bad
25
+ # def notify
26
+ # CurrentUser.email
27
+ # end
28
+ #
29
+ # # good - inside the CurrentUser class definition itself
30
+ # class CurrentUser
31
+ # def self.id
32
+ # RequestStore[:current_user]&.id
33
+ # end
34
+ # end
35
+ #
36
+ # # good (IgnoredMethodSuffixes: [_current])
37
+ # def user_current
38
+ # CurrentUser.id
39
+ # end
40
+ #
41
+ # # good (IgnoredMethodPrefixes: [apionly_])
42
+ # def apionly_serialize
43
+ # CurrentUser.email
44
+ # end
45
+ #
46
+ # # good (IgnoredMethodPatterns: [/_current_user_/])
47
+ # def serialize_current_user_email
48
+ # CurrentUser.email
49
+ # end
50
+ #
51
+ class CurrentUserOutsideOfRequests < Base
52
+ MSG = "`%<class_name>s` should only be used within the request cycle (controllers, views, helpers, decorators)"
53
+
54
+ def on_send(node)
55
+ return unless starts_with_current_user?(node)
56
+ return if ignored_method?(node)
57
+ return if inside_current_user_class?(node)
58
+
59
+ add_offense(node, message: message)
60
+ end
61
+
62
+ def on_csend(node)
63
+ on_send(node)
64
+ end
65
+
66
+ def on_const(node)
67
+ return unless current_user?(node)
68
+ return if ignored_method?(node)
69
+ return if inside_current_user_class?(node)
70
+ return if node.parent&.send_type? && node.parent.receiver == node
71
+
72
+ add_offense(node, message: message)
73
+ end
74
+
75
+ private
76
+
77
+ def message
78
+ format(MSG, class_name: current_user_class_name)
79
+ end
80
+
81
+ def current_user_class_name
82
+ cop_config.fetch("ClassName", "CurrentUser")
83
+ end
84
+
85
+ def current_user?(node)
86
+ return false unless node.const_type?
87
+
88
+ class_parts = current_user_class_name.split("::")
89
+ is_absolute, ref_parts = extract_const_info(node)
90
+
91
+ return ref_parts == class_parts if is_absolute
92
+
93
+ return false if ref_parts.length > class_parts.length
94
+ return false unless class_parts.last(ref_parts.length) == ref_parts
95
+
96
+ expected_ns = class_parts.first(class_parts.length - ref_parts.length)
97
+
98
+ ancestors = node.each_ancestor(:module, :class).to_a.reverse
99
+ cumulative = []
100
+ idx = 0
101
+ inner_ancestors = []
102
+
103
+ ancestors.each do |anc|
104
+ parts = const_parts(anc.identifier)
105
+ inner_ancestors << anc if idx + parts.length > expected_ns.length
106
+ cumulative.concat(parts)
107
+ idx += parts.length
108
+ end
109
+
110
+ return false unless cumulative.first(expected_ns.length) == expected_ns
111
+
112
+ inner_ancestors.none? { |anc| defines_const_in_body?(anc, ref_parts[0]) }
113
+ end
114
+
115
+ def starts_with_current_user?(node)
116
+ node.receiver && current_user?(node.receiver)
117
+ end
118
+
119
+ def inside_current_user_class?(node)
120
+ node.each_ancestor(:class).any? do |class_node|
121
+ resolved_class_name(class_node) == current_user_class_name
122
+ end
123
+ end
124
+
125
+ def resolved_class_name(class_node)
126
+ parts = const_parts(class_node.identifier)
127
+ class_node.each_ancestor(:module, :class) { |ancestor| parts = const_parts(ancestor.identifier) + parts }
128
+ parts.join("::")
129
+ end
130
+
131
+ def const_parts(const_node)
132
+ parts = []
133
+ node = const_node
134
+ while node&.const_type?
135
+ parts.unshift(node.short_name.to_s)
136
+ node = node.namespace
137
+ end
138
+ parts
139
+ end
140
+
141
+ def extract_const_info(node)
142
+ parts = []
143
+ current = node
144
+ while current&.const_type?
145
+ parts.unshift(current.short_name.to_s)
146
+ current = current.namespace
147
+ end
148
+ [current&.cbase_type? || false, parts]
149
+ end
150
+
151
+ def defines_const_in_body?(scope_node, name)
152
+ scope_node.body&.each_child_node(:class, :module)&.any? do |child|
153
+ child.identifier.short_name.to_s == name
154
+ end
155
+ end
156
+
157
+ def ignored_method?(node)
158
+ enclosing_method = node.each_ancestor(:any_def).first
159
+
160
+ return false unless enclosing_method&.any_def_type?
161
+
162
+ method_name = enclosing_method.method_name.to_s
163
+ ignored.any? { |pattern| pattern.match?(method_name) }
164
+ end
165
+
166
+ def ignored
167
+ @ignored ||= begin
168
+ pattern = ignored_patterns
169
+ prefix = ignored_prefixes.map { |p| Regexp.new("^#{p}") }
170
+ suffix = ignored_suffixes.map { |p| Regexp.new("#{p}$") }
171
+ methods = ignored_methods.map { |p| Regexp.new("^#{p}$") }
172
+ [*pattern, *prefix, *suffix, *methods]
173
+ end
174
+ end
175
+
176
+ def ignored_patterns
177
+ @ignored_patterns ||= Array(cop_config["IgnoredMethodPatterns"]).map { |p| Regexp.new(p) }
178
+ end
179
+
180
+ def ignored_prefixes
181
+ @ignored_prefixes ||= Array(cop_config["IgnoredMethodPrefixes"])
182
+ end
183
+
184
+ def ignored_suffixes
185
+ @ignored_suffixes ||= Array(cop_config["IgnoredMethodSuffixes"])
186
+ end
187
+
188
+ def ignored_methods
189
+ @ignored_methods ||= Array(cop_config["IgnoredMethods"])
190
+ end
191
+ end
192
+ end
193
+ end
194
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module YiffSpace
6
+ # Detects `resolvable` calls that should instead use
7
+ # `belongs_to_user` because the corresponding `<attr>_id` column exists
8
+ # in the database schema. It auto-corrects to `belongs_to_user`, adding
9
+ # `ip: true` when a `<attr>_ip_addr` column also exists.
10
+ #
11
+ # @safety
12
+ # This cop reads the database schema to determine whether the columns
13
+ # exist. It will not flag anything if the schema cannot be loaded.
14
+ # `belongs_to_user` must be defined in the target codebase.
15
+ #
16
+ # @example
17
+ # # bad - `creator_id` column exists in the schema
18
+ # resolvable(:creator)
19
+ #
20
+ # # good
21
+ # belongs_to_user(:creator)
22
+ #
23
+ # # bad - `creator_id` and `creator_ip_addr` columns exist in the schema
24
+ # resolvable(:creator)
25
+ #
26
+ # # good
27
+ # belongs_to_user(:creator, ip: true)
28
+ #
29
+ class ResolvableUser < Base
30
+ extend(AutoCorrector)
31
+ include(ActiveRecordHelper)
32
+ include(NodeFormattingHelper)
33
+
34
+ MSG = "use `belongs_to_user(%<attr>s)` when `%<id_column>s` column exists"
35
+ MSG_IP = "use `belongs_to_user(%<attr>s, ip: true)` when `%<id_column>s` and `%<ip_column>s` columns exist"
36
+
37
+ # @!method resolvable?(node)
38
+ def_node_matcher(:resolvable?, <<~PATTERN)
39
+ (send nil? :resolvable $_ $...)
40
+ PATTERN
41
+
42
+ def on_send(node)
43
+ resolvable?(node) do |receiver, code|
44
+ return unless receiver.type?(:str, :sym)
45
+
46
+ attr = format_node(receiver)
47
+ return if attr.nil? || attr.empty?
48
+
49
+ options = format_node(code.last, {})
50
+
51
+ return unless schema
52
+
53
+ table = table(node)
54
+ return unless table
55
+
56
+ column = "#{attr}_id"
57
+ ip_column = "#{attr}_ip_addr"
58
+ exists = table.with_column?(name: column)
59
+ ip_exists = table.with_column?(name: ip_column)
60
+
61
+ return unless exists
62
+
63
+ options[:ip] = true if ip_exists
64
+
65
+ register_offense(node, attr, options, column, ip_column)
66
+ end
67
+ end
68
+
69
+ def on_csend(node)
70
+ on_send(node)
71
+ end
72
+
73
+ private
74
+
75
+ def class_node(node)
76
+ node.each_ancestor.find(&:class_type?)
77
+ end
78
+
79
+ def table(node)
80
+ klass = class_node(node)
81
+ return unless klass
82
+
83
+ schema.table_by(name: table_name(klass))
84
+ end
85
+
86
+ def register_offense(node, attr, options, id_column, ip_column)
87
+ message = if options[:ip]
88
+ format(MSG_IP, attr: attr.inspect, id_column: id_column.inspect, ip_column: ip_column.inspect)
89
+ else
90
+ format(MSG, attr: attr.inspect, id_column: id_column.inspect)
91
+ end
92
+
93
+ add_offense(node, message: message) do |corrector|
94
+ new_options = format_new_options(attr, options)
95
+
96
+ corrector.replace(node, "belongs_to_user(#{new_options})")
97
+ end
98
+ end
99
+
100
+ def format_new_options(attr, options)
101
+ list = [attr.inspect]
102
+
103
+ if options.any?
104
+ options.slice(:ip, :clone).merge(options.except(:ip, :clone)).each do |k, v|
105
+ list << "#{k}: #{v.inspect}"
106
+ end
107
+ end
108
+ list.join(", ")
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module YiffSpace
6
+ # Detects the ternary pattern `x.is_a?(User) ? x.id : x` and
7
+ # enforces use of the `u2id` helper instead, which handles both User
8
+ # objects and raw IDs without the inline type check.
9
+ #
10
+ # @safety
11
+ # `u2id` must be defined in the target codebase. The auto-correct is
12
+ # safe when `u2id` accepts both User objects and integer IDs.
13
+ #
14
+ # @example
15
+ # # bad
16
+ # user.is_a?(User) ? user.id : user
17
+ #
18
+ # # bad
19
+ # creator.is_a?(User) ? creator.id : creator
20
+ #
21
+ # # good
22
+ # u2id(user)
23
+ #
24
+ # # good
25
+ # u2id(creator)
26
+ #
27
+ class UserToId < Base
28
+ extend(AutoCorrector)
29
+ include(NodeFormattingHelper)
30
+
31
+ MSG = "Use `u2id(%<var>s)` instead of `%<original>s`"
32
+
33
+ def on_if(node)
34
+ return unless node.ternary?
35
+
36
+ return unless user_check?(node.condition)
37
+ return unless id_call?(node.if_branch)
38
+ return unless same_value?(node.if_branch.receiver, node.else_branch)
39
+
40
+ message = format(MSG, var: node.else_branch.source, original: node.source)
41
+ add_offense(node, message: message) do |corrector|
42
+ corrector.replace(node, "u2id(#{node.else_branch.source})")
43
+ end
44
+ end
45
+
46
+ private
47
+
48
+ def user_check?(node)
49
+ node&.send_type? &&
50
+ node.method?(:is_a?) &&
51
+ node.first_argument&.const_name == "User"
52
+ end
53
+
54
+ def id_call?(node)
55
+ node&.send_type? && node.method?(:id)
56
+ end
57
+
58
+ def same_value?(a, b)
59
+ a && b && a.source == b.source
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end