Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# rubocop-github

## Unreleased

- Added `GitHub/UnreliableSubclasses` cop. Flags `Class#descendants` and `Class#subclasses` when the receiver is a constant. Both happily skip classes that haven't been autoloaded yet. Both also depend on GC timing for dynamically-defined classes, which is great fun in tests.

## v0.26.0

- Read the automatic release notes on [the /releases page for this gem](https://github.com/github/rubocop-github/releases).
Expand Down
41 changes: 41 additions & 0 deletions STYLEGUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ This is GitHub's Ruby Style Guide, inspired by [RuboCop's guide][rubocop-guide].
18. [Rails](#rails)
1. [content_for](#content_for)
2. [Instance Variables in Views](#instance-variables-in-views)
19. [Subclasses](#subclasses)
1. [Avoid Class#descendants and Class#subclasses](#avoid-classdescendants-and-classsubclasses)

## Layout

Expand Down Expand Up @@ -1096,4 +1098,43 @@ If you need to call a subview that expects an instance variable be set. If possi

Unfortunately the only way to get data into a layout template is with instance variables. You can't explicitly pass locals to them.

## Subclasses

### Avoid `Class#descendants` and `Class#subclasses`

Skip `Class#descendants` (ActiveSupport) and `Class#subclasses` (Ruby). They might lie to you in two ways:

* If a class hasn't been autoloaded yet, they don't see it. So your answer depends on what the app happened to touch first.
* GC can drop dynamically defined classes whenever it feels like it. Tests love this one.

If you really, really need it, add a `rubocop:disable` on the line with a short note so that future-you isn't confused.

* <a href="https://github.com/github/rubocop-github/blob/main/lib/rubocop/cop/github/unreliable_subclasses.rb">RuboCop rule: GitHub/UnreliableSubclasses</a>

``` ruby
class Person < ApplicationRecord
end

class Employee < Person
end

# bad
Person.descendants # => maybe [Employee], maybe [], who knows? Not me! I never lost control.
Person.subclasses # => same problem

# good. Keep an explicit registry
class Person < ApplicationRecord
TYPES = []

def self.inherited(subclass)
super
TYPES << subclass
end
end

Person::TYPES # => [Employee, ...]
```

Other alternatives: eager load the dependency tree ([`Rails.application.eager_load!`](https://api.rubyonrails.org/classes/Rails/Application.html#method-i-eager_load-21) in tests, `config.eager_load = true` in prod) or use [`ActiveSupport::DescendantsTracker`](https://api.rubyonrails.org/classes/ActiveSupport/DescendantsTracker.html) directly if you really need the reflection.

[rubocop-guide]: https://github.com/rubocop-hq/ruby-style-guide
4 changes: 4 additions & 0 deletions config/default.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ GitHub/AvoidObjectSendWithDynamicMethod:
GitHub/InsecureHashAlgorithm:
Enabled: true

GitHub/UnreliableSubclasses:
Enabled: true
StyleGuide: https://github.com/github/rubocop-github/blob/main/STYLEGUIDE.md#avoid-classdescendants-and-classsubclasses

Layout/AccessModifierIndentation:
Enabled: false
StyleGuide: https://github.com/github/rubocop-github/blob/main/STYLEGUIDE.md#user-content-access-modifier-indentation
Expand Down
4 changes: 4 additions & 0 deletions config/default_cops.yml
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
GitHub/InsecureHashAlgorithm:
Description: 'Encourage usage of secure hash algorithms'
Enabled: pending

GitHub/UnreliableSubclasses:
Description: "Avoid `Class#subclasses` and `Class#descendants`; they miss not-yet-autoloaded classes and depend on GC timing."
Enabled: pending
1 change: 1 addition & 0 deletions lib/rubocop-github.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@

require "rubocop/cop/github/avoid_object_send_with_dynamic_method"
require "rubocop/cop/github/insecure_hash_algorithm"
require "rubocop/cop/github/unreliable_subclasses"
48 changes: 48 additions & 0 deletions lib/rubocop/cop/github/unreliable_subclasses.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# frozen_string_literal: true

require "rubocop"

module RuboCop
module Cop
module GitHub
# Inform people when they reach for Class#subclasses (Ruby) or
# Class#descendants (ActiveSupport).
#
# two reasons these can be unreliable:
# 1. autoload hasn't run yet, so half the tree is invisible
# 2. GC may have eaten dynamically-defined classes (i.e. in test suites)
#
# If you really need it, add a rubocop:disable on the line with a note
# explaining why future-you won't be sad.
class UnreliableSubclasses < Base
MSG = "Avoid `%<method>s` here. It may miss not-yet-autoloaded classes and depends on GC timing. " \
"Prefer an explicit registry or eager loading."

RESTRICT_ON_SEND = %i[descendants subclasses].freeze

# matches Foo.descendants, Foo::Bar.subclasses, self.descendants
# receiver has to be a constant or `self`
def_node_matcher :unreliable_call?, <<~PATTERN
(send {const self} {:descendants :subclasses})
PATTERN

# the same thing but with safe navigation operator
def_node_matcher :unreliable_csend?, <<~PATTERN
(csend {const self} {:descendants :subclasses})
PATTERN

def on_send(node)
return unless unreliable_call?(node)

add_offense(node, message: format(MSG, method: node.method_name))
end

def on_csend(node)
return unless unreliable_csend?(node)

add_offense(node, message: format(MSG, method: node.method_name))
end
end
end
end
end
92 changes: 92 additions & 0 deletions test/test_unreliable_subclasses.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# frozen_string_literal: true

require_relative "cop_test"
require "minitest/autorun"
require "rubocop/cop/github/unreliable_subclasses"

class TestUnreliableSubclasses < CopTest
def cop_class
RuboCop::Cop::GitHub::UnreliableSubclasses
end

def test_offended_by_descendants_call
offenses = investigate cop, <<~RUBY
ApplicationRecord.descendants
RUBY
assert_equal 1, offenses.size
assert_match(/Avoid `descendants`/, offenses.first.message)
end

def test_offended_by_subclasses_call
offenses = investigate cop, <<~RUBY
ApplicationRecord.subclasses
RUBY
assert_equal 1, offenses.size
assert_match(/Avoid `subclasses`/, offenses.first.message)
end

def test_offended_on_namespaced_constant
offenses = investigate cop, <<~RUBY
Foo::Bar::Baz.descendants
RUBY
assert_equal 1, offenses.size
end

def test_offended_on_self_receiver
# self.subclasses in a class method body is still Class#subclasses
offenses = investigate cop, <<~RUBY
class ApplicationRecord
def self.known_subclasses
self.subclasses
end
end
RUBY
assert_equal 1, offenses.size
end

def test_offended_when_chained
offenses = investigate cop, <<~RUBY
Tea.descendants.map(&:name)
Tea.subclasses.each { |k| k.foo }
Tea.descendants.size
Tea.subclasses.count
RUBY
assert_equal 4, offenses.size
end

def test_offended_by_safe_navigation_on_constant
offenses = investigate cop, <<~RUBY
Tea&.descendants
Tea&.subclasses
RUBY
assert_equal 2, offenses.size
end

def test_unoffended_by_non_constant_receiver
offenses = investigate cop, <<~RUBY
comment.descendants
category.subclasses
tree_node&.descendants
registry[:foo].subclasses
RUBY
assert_equal 0, offenses.size
end

def test_unoffended_when_called_with_arguments
# arity mismatch
offenses = investigate cop, <<~RUBY
Registry.subclasses(:include_abstract)
Tree.descendants(depth: 2)
RUBY
assert_equal 0, offenses.size
end

def test_unoffended_by_unrelated_methods
offenses = investigate cop, <<~RUBY
Tea.all
Tea.where(roasted: true)
ApplicationRecord.connection
RUBY
assert_equal 0, offenses.size
end
end