Skip to content

Commit 2453fe8

Browse files
committed
add UnreliableSubclasses cop with tests
flags Class#descendants / Class#subclasses on constant (or self) receivers. skips non-constant receivers so tree-model #descendants don't false-positive.
1 parent 3e539de commit 2453fe8

2 files changed

Lines changed: 140 additions & 0 deletions

File tree

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# frozen_string_literal: true
2+
3+
require "rubocop"
4+
5+
module RuboCop
6+
module Cop
7+
module GitHub
8+
# Inform people when they reach for Class#subclasses (Ruby) or
9+
# Class#descendants (ActiveSupport).
10+
#
11+
# two reasons these can be unreliable:
12+
# 1. autoload hasn't run yet, so half the tree is invisible
13+
# 2. GC may have eaten dynamically-defined classes (i.e. in test suites)
14+
#
15+
# If you really need it, add a rubocop:disable on the line with a note
16+
# explaining why future-you won't be sad.
17+
class UnreliableSubclasses < Base
18+
MSG = "Avoid `%<method>s` here. It may miss not-yet-autoloaded classes and depends on GC timing. " \
19+
"Prefer an explicit registry or eager loading."
20+
21+
RESTRICT_ON_SEND = %i[descendants subclasses].freeze
22+
23+
# matches Foo.descendants, Foo::Bar.subclasses, self.descendants
24+
# receiver has to be a constant or `self`
25+
def_node_matcher :unreliable_call?, <<~PATTERN
26+
(send {const self} {:descendants :subclasses})
27+
PATTERN
28+
29+
# the same thing but with safe navigation operator
30+
def_node_matcher :unreliable_csend?, <<~PATTERN
31+
(csend {const self} {:descendants :subclasses})
32+
PATTERN
33+
34+
def on_send(node)
35+
return unless unreliable_call?(node)
36+
37+
add_offense(node, message: format(MSG, method: node.method_name))
38+
end
39+
40+
def on_csend(node)
41+
return unless unreliable_csend?(node)
42+
43+
add_offense(node, message: format(MSG, method: node.method_name))
44+
end
45+
end
46+
end
47+
end
48+
end

test/test_unreliable_subclasses.rb

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
# frozen_string_literal: true
2+
3+
require_relative "cop_test"
4+
require "minitest/autorun"
5+
require "rubocop/cop/github/unreliable_subclasses"
6+
7+
class TestUnreliableSubclasses < CopTest
8+
def cop_class
9+
RuboCop::Cop::GitHub::UnreliableSubclasses
10+
end
11+
12+
def test_offended_by_descendants_call
13+
offenses = investigate cop, <<~RUBY
14+
ApplicationRecord.descendants
15+
RUBY
16+
assert_equal 1, offenses.size
17+
assert_match(/Avoid `descendants`/, offenses.first.message)
18+
end
19+
20+
def test_offended_by_subclasses_call
21+
offenses = investigate cop, <<~RUBY
22+
ApplicationRecord.subclasses
23+
RUBY
24+
assert_equal 1, offenses.size
25+
assert_match(/Avoid `subclasses`/, offenses.first.message)
26+
end
27+
28+
def test_offended_on_namespaced_constant
29+
offenses = investigate cop, <<~RUBY
30+
Foo::Bar::Baz.descendants
31+
RUBY
32+
assert_equal 1, offenses.size
33+
end
34+
35+
def test_offended_on_self_receiver
36+
# self.subclasses in a class method body is still Class#subclasses
37+
offenses = investigate cop, <<~RUBY
38+
class ApplicationRecord
39+
def self.known_subclasses
40+
self.subclasses
41+
end
42+
end
43+
RUBY
44+
assert_equal 1, offenses.size
45+
end
46+
47+
def test_offended_when_chained
48+
offenses = investigate cop, <<~RUBY
49+
Tea.descendants.map(&:name)
50+
Tea.subclasses.each { |k| k.foo }
51+
Tea.descendants.size
52+
Tea.subclasses.count
53+
RUBY
54+
assert_equal 4, offenses.size
55+
end
56+
57+
def test_offended_by_safe_navigation_on_constant
58+
offenses = investigate cop, <<~RUBY
59+
Tea&.descendants
60+
Tea&.subclasses
61+
RUBY
62+
assert_equal 2, offenses.size
63+
end
64+
65+
def test_unoffended_by_non_constant_receiver
66+
offenses = investigate cop, <<~RUBY
67+
comment.descendants
68+
category.subclasses
69+
tree_node&.descendants
70+
registry[:foo].subclasses
71+
RUBY
72+
assert_equal 0, offenses.size
73+
end
74+
75+
def test_unoffended_when_called_with_arguments
76+
# arity mismatch
77+
offenses = investigate cop, <<~RUBY
78+
Registry.subclasses(:include_abstract)
79+
Tree.descendants(depth: 2)
80+
RUBY
81+
assert_equal 0, offenses.size
82+
end
83+
84+
def test_unoffended_by_unrelated_methods
85+
offenses = investigate cop, <<~RUBY
86+
Tea.all
87+
Tea.where(roasted: true)
88+
ApplicationRecord.connection
89+
RUBY
90+
assert_equal 0, offenses.size
91+
end
92+
end

0 commit comments

Comments
 (0)