├── .gitignore ├── .rspec ├── CODE_OF_CONDUCT.md ├── Gemfile ├── Gemfile.lock ├── LICENSE.txt ├── README.md ├── Rakefile ├── bin ├── console └── setup ├── dead_code_detector.gemspec ├── lib ├── dead_code_detector.rb └── dead_code_detector │ ├── base_method_wrapper.rb │ ├── class_method_wrapper.rb │ ├── configuration.rb │ ├── initializer.rb │ ├── instance_method_wrapper.rb │ ├── report.rb │ ├── storage.rb │ ├── storage │ ├── memory_backend.rb │ └── redis_backend.rb │ └── version.rb ├── overlord.yml └── spec ├── dead_code_detector ├── class_method_wrapper_spec.rb ├── initializer_spec.rb ├── instance_method_wrapper_spec.rb └── report_spec.rb ├── dead_code_detector_spec.rb └── spec_helper.rb /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | .rspec_status 3 | .byebug_history -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --colour 2 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at kyle.doliveira@clio.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [http://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: http://contributor-covenant.org 74 | [version]: http://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | # Specify your gem's dependencies in dead_code_detector.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | dead_code_detector (0.0.13) 5 | 6 | GEM 7 | remote: https://rubygems.org/ 8 | specs: 9 | byebug (11.0.1) 10 | diff-lcs (1.3) 11 | rake (13.0.1) 12 | rspec (3.7.0) 13 | rspec-core (~> 3.7.0) 14 | rspec-expectations (~> 3.7.0) 15 | rspec-mocks (~> 3.7.0) 16 | rspec-core (3.7.1) 17 | rspec-support (~> 3.7.0) 18 | rspec-expectations (3.7.0) 19 | diff-lcs (>= 1.2.0, < 2.0) 20 | rspec-support (~> 3.7.0) 21 | rspec-mocks (3.7.0) 22 | diff-lcs (>= 1.2.0, < 2.0) 23 | rspec-support (~> 3.7.0) 24 | rspec-support (3.7.1) 25 | 26 | PLATFORMS 27 | ruby 28 | 29 | DEPENDENCIES 30 | bundler 31 | byebug 32 | dead_code_detector! 33 | rake (~> 13.0) 34 | rspec (~> 3.0) 35 | 36 | BUNDLED WITH 37 | 2.6.6 38 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019 Kyle d'Oliveira 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DeadCodeDetector 2 | 3 | DeadCodeDetector is a gem which finds code that hasn't been used in production environments so that it can be removed. 4 | 5 | ## Installation 6 | 7 | Add this line to your application's Gemfile: 8 | 9 | ```ruby 10 | gem 'dead_code_detector' 11 | ``` 12 | 13 | And then execute: 14 | 15 | $ bundle 16 | 17 | Or install it yourself as: 18 | 19 | $ gem install dead_code_detector 20 | 21 | ## How it works 22 | 23 | DeadCodeDetector takes advantage of Ruby's ability to dynamically define methods. For each class that you want to track, it dynamically rewrites every method on that class to track its usage. Here's a simplified version of what it does: 24 | 25 | ```ruby 26 | # Consider a class like this 27 | class Foo 28 | def bar 29 | puts "hello world" 30 | end 31 | end 32 | 33 | # Once DeadCodeDetector wraps the methods (Part 3 - Enabling it) it might look like this 34 | class Foo 35 | def bar 36 | begin 37 | DeadCodeDetector::InstanceMethodWrapper.track_method(Foo, :bar) # Track that this method was called 38 | Foo.define_method(:bar) do 39 | puts "hello world" 40 | end 41 | rescue 42 | # To ensure that if DeadCodeDetector breaks it doesn't break the existing code 43 | end 44 | puts "hello world" 45 | end 46 | end 47 | 48 | # That's how the method looks until it is hit once: 49 | Foo.new.bar 50 | 51 | # At this point we know that the method has been hit, so we restore the 52 | # original version of the method. 53 | ``` 54 | 55 | Because DeadCodeDetector only records method calls once, the performance overhead at runtime is negligible. 56 | 57 | DeadCodeDetector only tracks method calls and does not track which code is used inside the method. If that is what you are after, consider looking at [coverband](https://github.com/danmayer/coverband). It can track code usage at a more granular level, but it has its own tradeoffs. 58 | 59 | ## Usage 60 | 61 | There are four steps to using DeadCodeDetector: 62 | 63 | ### Part 1 - Configuration 64 | 65 | This is where you tell DeadCodeDetector what you want to do. In Rails, the configuration could live in `config/initializers`. 66 | 67 | ```ruby 68 | DeadCodeDetector.configure do |config| 69 | # Two possible values: 70 | # :memory - In-memory storage, not persisted anywhere else. Useful for test environments. 71 | # :redis - Data is stored as a set in Redis so that it is persisted across processes. 72 | # config.storage = :redis 73 | # config.storage = :memory 74 | 75 | # If using the `redis` storage option, this needs to be set. 76 | # config.redis = 77 | 78 | # This controls whether DeadCodeDetector is enabled for this particular process, and takes either 79 | # a boolean or a object that responds to `call` that returns a boolean. 80 | # There is some overhead whenever DeadCodeDetector enables itself, so you might not want to enable 81 | # it on all of your processes. 82 | # config.allowed = true 83 | # config.allowed = -> { `hostname`.include?("01") } 84 | 85 | # DeadCodeDetector will filter out methods whose source_location matches this regular expression. 86 | # This is useful for filtering out methods from gems (such as the methods from ActiveRecord::Base) 87 | # Specifying a value here will cause DeadCodeDetector to ignore methods defined in C 88 | # config.ignore_paths = /\/vendor\// 89 | 90 | # A list of classes that DeadCodeDetector will monitor method usage on. 91 | # All descendants of these classes will be included. 92 | config.classes_to_monitor = [ActiveRecord::Base, ApplicationController] 93 | end 94 | ``` 95 | 96 | ### Part 2 - Cache Setup 97 | 98 | Before DeadCodeDetector can do anything, it needs to calculate and store a list of methods that it's going to track. Call this method from a production console to initialize that database: 99 | 100 | ```ruby 101 | DeadCodeDetector::Initializer.refresh_caches 102 | ``` 103 | 104 | If you add new classes or methods to your code which you want to track, you can call `refresh_caches` again at any time to clear all the accumulated data in Redis and start fresh. Until `refresh_caches` has been called at least once, DeadCodeDetector won't do anything. 105 | 106 | ### Part 3 - Enabling it 107 | 108 | Wrap the code that you want to monitor in an `DeadCodeDetector.enable` block. Any code inside that block will record method calls in DeadCodeDetector's storage when they're called for the first time. 109 | 110 | ```ruby 111 | DeadCodeDetector.enable do 112 | # Do some stuff 113 | end 114 | ``` 115 | 116 | In Rails controllers, this could look like: 117 | ```ruby 118 | around_perform :enable_dead_code_detector 119 | 120 | def enable_dead_code_detector 121 | DeadCodeDetector.enable { yield } 122 | end 123 | ``` 124 | 125 | ### Part 4 - The Report 126 | 127 | Once DeadCodeDetector has been running for a while, you can generate a report on what methods have not been called by calling `DeadCodeDetector::Report.unused_methods` 128 | 129 | **Note**: This report doesn't say that methods are _never_ called, only that they _haven't_ been called. The longer DeadCodeDetector runs for, the more confident you can be that the method is unused. 130 | 131 | Also, it's possible that some methods are being used, but are only called during the application boot process. DeadCodeDetector is unable to track those and may mis-report them as unused. 132 | 133 | ## Development 134 | 135 | After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. 136 | 137 | To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org). 138 | 139 | ## Contributing 140 | 141 | Bug reports and pull requests are welcome on GitHub at https://github.com/clio/dead_code_detector. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct. 142 | 143 | ## License 144 | 145 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 146 | 147 | ## Code of Conduct 148 | 149 | Everyone interacting in the DeadCodeDetector project’s codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/clio/dead_code_detector/blob/master/CODE_OF_CONDUCT.md). 150 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rspec/core/rake_task" 3 | 4 | RSpec::Core::RakeTask.new(:spec) 5 | 6 | task :default => :spec 7 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "dead_code_detector" 5 | 6 | # You can add fixtures and/or initialization code here to make experimenting 7 | # with your gem easier. You can also use a different console, if you like. 8 | 9 | # (If you use this, don't forget to add pry to your Gemfile!) 10 | # require "pry" 11 | # Pry.start 12 | 13 | require "irb" 14 | IRB.start(__FILE__) 15 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /dead_code_detector.gemspec: -------------------------------------------------------------------------------- 1 | 2 | lib = File.expand_path("../lib", __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require "dead_code_detector/version" 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "dead_code_detector" 8 | spec.version = DeadCodeDetector::VERSION 9 | spec.authors = ["Kyle d'Oliveira"] 10 | spec.email = ["kyle.doliveira@clio.com"] 11 | 12 | spec.summary = %q{Help find unused code in production.} 13 | spec.description = %q{This monitors methods being called and can be used to produce a report of all methods that have not been called.} 14 | spec.homepage = "https://github.com/clio/dead_code_detector" 15 | spec.license = "MIT" 16 | 17 | spec.required_ruby_version = '>= 3.0.0' 18 | 19 | if spec.respond_to?(:metadata) 20 | spec.metadata["homepage_uri"] = spec.homepage 21 | spec.metadata["source_code_uri"] = "https://github.com/clio/dead_code_detector" 22 | # spec.metadata["changelog_uri"] = "TODO: Put your gem's CHANGELOG.md URL here." 23 | else 24 | raise "RubyGems 2.0 or newer is required to protect against " \ 25 | "public gem pushes." 26 | end 27 | 28 | # Specify which files should be added to the gem when it is released. 29 | # The `git ls-files -z` loads the files in the RubyGem that have been added into git. 30 | spec.files = Dir.chdir(File.expand_path('..', __FILE__)) do 31 | `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } 32 | end 33 | spec.bindir = "exe" 34 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 35 | spec.require_paths = ["lib"] 36 | 37 | spec.add_development_dependency "bundler" 38 | spec.add_development_dependency "rake", "~> 13.0" 39 | spec.add_development_dependency "rspec", "~> 3.0" 40 | spec.add_development_dependency "byebug" 41 | end 42 | -------------------------------------------------------------------------------- /lib/dead_code_detector.rb: -------------------------------------------------------------------------------- 1 | require "dead_code_detector/version" 2 | require "dead_code_detector/base_method_wrapper" 3 | require "dead_code_detector/class_method_wrapper" 4 | require "dead_code_detector/instance_method_wrapper" 5 | require "dead_code_detector/storage" 6 | require "dead_code_detector/initializer" 7 | require "dead_code_detector/configuration" 8 | require "dead_code_detector/report" 9 | 10 | require "set" 11 | 12 | module DeadCodeDetector 13 | 14 | def self.configure(&block) 15 | block.call(config) 16 | end 17 | 18 | def self.config 19 | @config ||= DeadCodeDetector::Configuration.new 20 | end 21 | 22 | def self.enable(&block) 23 | begin 24 | DeadCodeDetector::Initializer.enable_for_cached_classes! 25 | block.call 26 | ensure 27 | config.storage.flush 28 | end 29 | end 30 | 31 | end 32 | -------------------------------------------------------------------------------- /lib/dead_code_detector/base_method_wrapper.rb: -------------------------------------------------------------------------------- 1 | module DeadCodeDetector 2 | class BaseMethodWrapper 3 | 4 | attr_reader :klass 5 | 6 | def initialize(klass) 7 | @klass = klass 8 | end 9 | 10 | class << self 11 | def track_method(klass, method_name) 12 | DeadCodeDetector.config.storage.delete(record_key(klass.name), method_name) 13 | end 14 | 15 | def unwrap_method(klass, original_method) 16 | raise NotImplementedError 17 | end 18 | end 19 | 20 | def wrap_methods! 21 | potentially_unused_methods.each do |method_name| 22 | wrap_method(get_method(method_name)) 23 | end 24 | end 25 | 26 | def number_of_tracked_methods 27 | default_methods.count 28 | end 29 | 30 | def clear_cache 31 | DeadCodeDetector.config.storage.clear(self.class.record_key(klass.name)) 32 | end 33 | 34 | def refresh_cache 35 | clear_cache 36 | if default_methods.any? 37 | DeadCodeDetector.config.storage.add(self.class.record_key(klass.name), default_methods) 38 | end 39 | end 40 | 41 | private 42 | 43 | def default_methods 44 | raise NotImplementedError 45 | end 46 | 47 | def get_method(method_name) 48 | raise NotImplementedError 49 | end 50 | 51 | def wrap_method(original_method) 52 | raise NotImplementedError 53 | end 54 | 55 | def owned_method?(method_name) 56 | raise NotImplementedError 57 | end 58 | 59 | # Due to caching, new methods won't show up automatically in this call 60 | def potentially_unused_methods 61 | stored_methods = DeadCodeDetector.config.storage.get(self.class.record_key(klass.name)) 62 | 63 | stored_methods & default_methods.map(&:to_s) 64 | end 65 | 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /lib/dead_code_detector/class_method_wrapper.rb: -------------------------------------------------------------------------------- 1 | module DeadCodeDetector 2 | class ClassMethodWrapper < BaseMethodWrapper 3 | 4 | class << self 5 | def unwrap_method(klass, original_method) 6 | if klass.singleton_class == original_method.owner 7 | klass.define_singleton_method(original_method.name, original_method) 8 | else 9 | klass.singleton_class.send(:remove_method, original_method.name) 10 | end 11 | track_method(klass, original_method.name) 12 | end 13 | 14 | def record_key(class_name) 15 | "dead_code_detector/record_keeper/#{class_name}/class_methods" 16 | end 17 | end 18 | 19 | def get_method(method_name) 20 | klass.method(method_name) 21 | end 22 | 23 | private 24 | 25 | def wrap_method(original_method) 26 | original_class = klass 27 | klass.define_singleton_method(original_method.name) do |*args, **kwargs, &block| 28 | begin 29 | DeadCodeDetector::ClassMethodWrapper.unwrap_method(original_class, original_method) 30 | rescue StandardError => e 31 | if DeadCodeDetector.config.error_handler 32 | DeadCodeDetector.config.error_handler.call(e) 33 | end 34 | end 35 | # We may have a method like `ActiveRecord::Base.sti_name` 36 | # that begins bound to `ActiveRecord::Base` 37 | # However, it may be called from `User.sti_name` 38 | # We need to bind the original method to the class that 39 | # is calling the method 40 | unbound_method = original_method.unbind 41 | method_bound_to_caller = unbound_method.bind(self) 42 | method_bound_to_caller.call(*args, **kwargs, &block) 43 | end 44 | end 45 | 46 | def default_methods 47 | @default_methods ||= klass.methods.map(&:to_s).select do |method_name| 48 | owned_method?(method_name) && target_directory?(method_name) 49 | end 50 | end 51 | 52 | def target_directory?(method_name) 53 | return true if DeadCodeDetector.config.ignore_paths.nil? 54 | source_location = klass.method(method_name).source_location&.first 55 | return false if source_location.nil? 56 | return false if source_location == "(eval)" 57 | source_location !~ DeadCodeDetector.config.ignore_paths 58 | end 59 | 60 | def owned_method?(method_name) 61 | original_method = klass.method(method_name) 62 | if klass.respond_to?(:superclass) 63 | klass.singleton_class <= original_method.owner && !(klass.superclass.singleton_class <= original_method.owner) 64 | else 65 | klass.singleton_class <= original_method.owner 66 | end 67 | end 68 | 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /lib/dead_code_detector/configuration.rb: -------------------------------------------------------------------------------- 1 | module DeadCodeDetector 2 | class Configuration 3 | 4 | attr_accessor :redis, :classes_to_monitor, :error_handler, :allowed, :cache_expiry, :ignore_paths, :max_seconds_to_enable 5 | 6 | STORAGE_BACKENDS = { 7 | memory: Storage::MemoryBackend, 8 | redis: Storage::RedisBackend, 9 | } 10 | 11 | def initialize 12 | @allowed = true 13 | @classes_to_monitor = [] 14 | @cache_expiry = 60 * 60 * 24 * 14 15 | @max_seconds_to_enable = 1 16 | end 17 | 18 | def storage=(backend_type) 19 | @storage ||= STORAGE_BACKENDS.fetch(backend_type).new 20 | end 21 | 22 | def storage 23 | if @storage 24 | @storage 25 | else 26 | raise "#{self.class.name}#storage is not configured" 27 | end 28 | end 29 | 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/dead_code_detector/initializer.rb: -------------------------------------------------------------------------------- 1 | module DeadCodeDetector 2 | class Initializer 3 | 4 | class << self 5 | 6 | attr_accessor :fully_enabled, :last_enabled_class 7 | 8 | def refresh_caches 9 | DeadCodeDetector.config.classes_to_monitor.each do |klass| 10 | refresh_cache_for(klass) 11 | end 12 | end 13 | 14 | def refresh_cache_for(klass) 15 | self.fully_enabled = false 16 | self.last_enabled_class = nil 17 | classes = [klass, *descendants_of(klass)] 18 | classes.each do |class_to_enable| 19 | cache_methods_for(class_to_enable) 20 | end 21 | end 22 | 23 | def clear_cache 24 | cached_classes.each do |class_name| 25 | klass = Object.const_get(class_name) rescue nil 26 | if klass 27 | DeadCodeDetector::ClassMethodWrapper.new(klass).clear_cache 28 | DeadCodeDetector::InstanceMethodWrapper.new(klass).clear_cache 29 | end 30 | end 31 | DeadCodeDetector.config.storage.clear(tracked_classes_key) 32 | end 33 | 34 | def enable(klass) 35 | DeadCodeDetector::ClassMethodWrapper.new(klass).wrap_methods! 36 | DeadCodeDetector::InstanceMethodWrapper.new(klass).wrap_methods! 37 | end 38 | 39 | def enable_for_cached_classes! 40 | return if fully_enabled 41 | return unless allowed? 42 | classes = cached_classes.sort.to_a 43 | starting_index = (classes.index(last_enabled_class) || -1) + 1 44 | start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) 45 | classes[starting_index..-1].each do |class_name| 46 | klass = Object.const_get(class_name) rescue nil 47 | enable(klass) if klass 48 | self.last_enabled_class = class_name 49 | return if Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time > DeadCodeDetector.config.max_seconds_to_enable 50 | end 51 | self.fully_enabled = true 52 | end 53 | 54 | def allowed? 55 | if DeadCodeDetector.config.allowed.respond_to?(:call) 56 | DeadCodeDetector.config.allowed.call 57 | else 58 | DeadCodeDetector.config.allowed 59 | end 60 | end 61 | 62 | def cached_classes 63 | DeadCodeDetector.config.storage.get(tracked_classes_key) 64 | end 65 | 66 | private 67 | def descendants_of(parent_class) 68 | ObjectSpace.each_object(parent_class.singleton_class).select do |klass| 69 | klass < parent_class && !klass.name.nil? 70 | end 71 | end 72 | 73 | def cache_methods_for(klass) 74 | class_wrapper = DeadCodeDetector::ClassMethodWrapper.new(klass).tap(&:refresh_cache) 75 | instance_wrapper = DeadCodeDetector::InstanceMethodWrapper.new(klass).tap(&:refresh_cache) 76 | if class_wrapper.number_of_tracked_methods + instance_wrapper.number_of_tracked_methods > 0 77 | DeadCodeDetector.config.storage.add(tracked_classes_key, klass.name) 78 | end 79 | end 80 | 81 | def tracked_classes_key 82 | "dead_code_detector/method_wrapper/tracked_classes" 83 | end 84 | end 85 | 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /lib/dead_code_detector/instance_method_wrapper.rb: -------------------------------------------------------------------------------- 1 | module DeadCodeDetector 2 | class InstanceMethodWrapper < BaseMethodWrapper 3 | 4 | class << self 5 | def unwrap_method(klass, original_method) 6 | if original_method.owner == klass 7 | klass.send(:define_method, original_method.name, original_method) 8 | else 9 | klass.send(:remove_method, original_method.name) 10 | end 11 | track_method(klass, original_method.name) 12 | end 13 | 14 | def record_key(class_name) 15 | "dead_code_detector/record_keeper/#{class_name}/instance_methods" 16 | end 17 | end 18 | 19 | def get_method(method_name) 20 | klass.instance_method(method_name) 21 | end 22 | 23 | private 24 | 25 | def wrap_method(original_method) 26 | original_class = klass 27 | klass.send(:define_method, original_method.name) do |*args, **kwargs, &block| 28 | begin 29 | DeadCodeDetector::InstanceMethodWrapper.unwrap_method(original_class, original_method) 30 | rescue StandardError => e 31 | if DeadCodeDetector.config.error_handler 32 | DeadCodeDetector.config.error_handler.call(e) 33 | end 34 | end 35 | method_bound_to_caller = original_method.bind(self) 36 | method_bound_to_caller.call(*args, **kwargs, &block) 37 | end 38 | end 39 | 40 | def default_methods 41 | @default_methods ||= klass.instance_methods.map(&:to_s).select do |method_name| 42 | owned_method?(method_name) && target_directory?(method_name) 43 | end 44 | end 45 | 46 | def target_directory?(method_name) 47 | return true if DeadCodeDetector.config.ignore_paths.nil? 48 | source_location = klass.instance_method(method_name).source_location&.first 49 | return false if source_location.nil? 50 | return false if source_location == "(eval)" 51 | source_location !~ DeadCodeDetector.config.ignore_paths 52 | end 53 | 54 | def owned_method?(method_name) 55 | original_method = klass.instance_method(method_name) 56 | if klass.respond_to?(:superclass) 57 | klass <= original_method.owner && !(klass.superclass <= original_method.owner) 58 | else 59 | klass <= original_method.owner 60 | end 61 | end 62 | 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /lib/dead_code_detector/report.rb: -------------------------------------------------------------------------------- 1 | module DeadCodeDetector 2 | class Report 3 | 4 | class << self 5 | 6 | def unused_methods 7 | Initializer.cached_classes.flat_map do |class_name| 8 | unused_methods_for(class_name) 9 | end 10 | end 11 | 12 | def unused_methods_for(class_name) 13 | methods = [] 14 | unused_class_methods_for(class_name).each_with_object(methods) do |method_name, collection| 15 | collection << "#{class_name}.#{method_name}" 16 | end 17 | unused_instance_methods_for(class_name).each_with_object(methods) do |method_name, collection| 18 | collection << "#{class_name}##{method_name}" 19 | end 20 | methods 21 | end 22 | 23 | private 24 | def unused_class_methods_for(class_name) 25 | DeadCodeDetector.config.storage.get(DeadCodeDetector::ClassMethodWrapper.record_key(class_name)) 26 | end 27 | 28 | def unused_instance_methods_for(class_name) 29 | DeadCodeDetector.config.storage.get(DeadCodeDetector::InstanceMethodWrapper.record_key(class_name)) 30 | end 31 | end 32 | 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/dead_code_detector/storage.rb: -------------------------------------------------------------------------------- 1 | require "dead_code_detector/storage/memory_backend" 2 | require "dead_code_detector/storage/redis_backend" 3 | 4 | module DeadCodeDetector 5 | class Storage 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/dead_code_detector/storage/memory_backend.rb: -------------------------------------------------------------------------------- 1 | module DeadCodeDetector 2 | class Storage 3 | 4 | class MemoryBackend 5 | 6 | attr_accessor :flush_immediately 7 | attr_reader :pending_deletions 8 | 9 | def initialize 10 | @mapping = Hash.new{|h,k| h[k] = Set.new } 11 | @pending_deletions = Hash.new{|h,k| h[k] = Set.new } 12 | end 13 | 14 | def clear(key) 15 | @mapping.delete(key) 16 | end 17 | 18 | def add(key, values) 19 | @mapping[key].merge(Array(values)) 20 | end 21 | 22 | def get(key) 23 | if @pending_deletions.key?(key) 24 | @mapping[key] - @pending_deletions[key] 25 | else 26 | @mapping[key] 27 | end 28 | end 29 | 30 | def delete(key, value) 31 | @pending_deletions[key] << value.to_s 32 | flush if flush_immediately 33 | end 34 | 35 | def flush 36 | @pending_deletions.each do |key, values| 37 | @mapping[key].subtract(values) 38 | end 39 | @pending_deletions.clear 40 | end 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/dead_code_detector/storage/redis_backend.rb: -------------------------------------------------------------------------------- 1 | module DeadCodeDetector 2 | class Storage 3 | 4 | class RedisBackend 5 | 6 | attr_accessor :flush_immediately 7 | 8 | def initialize 9 | @pending_deletions = Hash.new{|h,k| h[k] = Set.new } 10 | end 11 | 12 | def clear(key) 13 | DeadCodeDetector.config.redis.del(key) 14 | end 15 | 16 | def add(key, values) 17 | values = Array(values) 18 | return if values.empty? 19 | DeadCodeDetector.config.redis.sadd(key, values) 20 | DeadCodeDetector.config.redis.expire(key, DeadCodeDetector.config.cache_expiry) 21 | end 22 | 23 | def get(key) 24 | members = DeadCodeDetector.config.redis.smembers(key) 25 | members = Set.new(members) if members.is_a?(Array) 26 | if @pending_deletions.key?(key) 27 | members - @pending_deletions[key] 28 | else 29 | members 30 | end 31 | end 32 | 33 | def delete(key, value) 34 | @pending_deletions[key] << value.to_s 35 | flush if flush_immediately 36 | end 37 | 38 | def flush 39 | @pending_deletions.each do |key, values| 40 | DeadCodeDetector.config.redis.srem(key, values.to_a) 41 | end 42 | @pending_deletions.clear 43 | end 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /lib/dead_code_detector/version.rb: -------------------------------------------------------------------------------- 1 | module DeadCodeDetector 2 | VERSION = "0.0.13" 3 | end 4 | -------------------------------------------------------------------------------- /overlord.yml: -------------------------------------------------------------------------------- 1 | tier: 4 2 | -------------------------------------------------------------------------------- /spec/dead_code_detector/class_method_wrapper_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | RSpec.describe DeadCodeDetector::ClassMethodWrapper do 4 | 5 | let(:anonymous_class) do 6 | Class.new do 7 | def self.name 8 | @name ||= "AnonymousClass-#{object_id}" 9 | end 10 | 11 | def self.counter=(val) 12 | @counter = val 13 | end 14 | 15 | def self.counter 16 | @counter ||= 0 17 | end 18 | 19 | def self.bar 20 | self.counter ||= 0 21 | self.counter += 1 22 | end 23 | end 24 | end 25 | 26 | before do 27 | DeadCodeDetector::Initializer.refresh_cache_for(anonymous_class) 28 | end 29 | 30 | describe "#wrap_methods!" do 31 | 32 | it "wraps methods defined in the class" do 33 | expect do 34 | described_class.new(anonymous_class).wrap_methods! 35 | end.to change{ anonymous_class.method(:bar).source_location } 36 | end 37 | 38 | it "doesn't wrap methods it inherits but doesn't redefine" do 39 | expect do 40 | described_class.new(anonymous_class).wrap_methods! 41 | end.to_not change{ anonymous_class.method(:ancestors).source_location } 42 | end 43 | end 44 | 45 | context ".refresh_cache" do 46 | before do 47 | DeadCodeDetector.config.storage.clear(described_class.record_key(anonymous_class.name)) 48 | end 49 | 50 | context "when there is a ignore_paths set" do 51 | context "and it doesn't match the source location" do 52 | around(:each) do |example| 53 | begin 54 | old_path = DeadCodeDetector.config.ignore_paths 55 | DeadCodeDetector.config.ignore_paths = /foo/ 56 | example.run 57 | ensure 58 | DeadCodeDetector.config.ignore_paths = old_path 59 | end 60 | end 61 | 62 | context "and the method has an eval source location" do 63 | it "excludes it" do 64 | allow_any_instance_of(Method).to receive(:source_location).and_return(["(eval)"]) 65 | 66 | expect do 67 | described_class.new(anonymous_class).refresh_cache 68 | end.to_not change{ DeadCodeDetector.config.storage.get(described_class.record_key(anonymous_class.name)) } 69 | end 70 | end 71 | 72 | context "and the method doesn't have a source location" do 73 | it "excludes it" do 74 | allow_any_instance_of(Method).to receive(:source_location).and_return(nil) 75 | 76 | expect do 77 | described_class.new(anonymous_class).refresh_cache 78 | end.to_not change{ DeadCodeDetector.config.storage.get(described_class.record_key(anonymous_class.name)) } 79 | 80 | end 81 | end 82 | it "includes the methods" do 83 | expect do 84 | described_class.new(anonymous_class).refresh_cache 85 | end.to change{ DeadCodeDetector.config.storage.get(described_class.record_key(anonymous_class.name)) } 86 | .from(Set.new) 87 | .to(Set.new(["bar", "name", "counter", "counter="])) 88 | end 89 | end 90 | 91 | context "and it matches the source location" do 92 | around(:each) do |example| 93 | begin 94 | old_path = DeadCodeDetector.config.ignore_paths 95 | DeadCodeDetector.config.ignore_paths = /spec/ 96 | example.run 97 | ensure 98 | DeadCodeDetector.config.ignore_paths = old_path 99 | end 100 | end 101 | 102 | it "doesn't includes the methods" do 103 | expect do 104 | described_class.new(anonymous_class).refresh_cache 105 | end.to_not change{ DeadCodeDetector.config.storage.get(described_class.record_key(anonymous_class.name)) } 106 | end 107 | end 108 | 109 | end 110 | 111 | it "sets up the cache with the full list of methods" do 112 | expect do 113 | described_class.new(anonymous_class).refresh_cache 114 | end.to change{ DeadCodeDetector.config.storage.get(described_class.record_key(anonymous_class.name)) } 115 | .from(Set.new) 116 | .to(Set.new(["bar", "name", "counter", "counter="])) 117 | end 118 | 119 | context "when the class contains methods from a module" do 120 | let(:anonymous_class) do 121 | m = Module.new do 122 | def bar 123 | self.counter ||= 0 124 | self.counter += 1 125 | end 126 | end 127 | 128 | Class.new do 129 | attr_accessor :counter 130 | extend m 131 | def self.name 132 | @name ||= "AnonymousClass-#{object_id}" 133 | end 134 | end 135 | end 136 | 137 | context "and the module is include in the parent" do 138 | let(:second_anonymous_class) do 139 | Class.new(anonymous_class) 140 | end 141 | 142 | it "does not include the module method" do 143 | expect do 144 | described_class.new(second_anonymous_class).refresh_cache 145 | end.to_not change{ DeadCodeDetector.config.storage.get(described_class.record_key(second_anonymous_class.name)).include?("bar") } 146 | end 147 | end 148 | 149 | it "includes the module method" do 150 | expect do 151 | described_class.new(anonymous_class).refresh_cache 152 | end.to change{ DeadCodeDetector.config.storage.get(described_class.record_key(anonymous_class.name)).include?("bar") } 153 | .from(false) 154 | .to(true) 155 | 156 | end 157 | end 158 | 159 | end 160 | 161 | context "when a wrapped method is called" do 162 | 163 | it "marks the method as being used" do 164 | wrapper = described_class.new(anonymous_class) 165 | wrapper.wrap_methods! 166 | 167 | expect do 168 | anonymous_class.bar 169 | end.to change { DeadCodeDetector::Report.unused_methods_for(anonymous_class.name).include?("#{anonymous_class.name}.bar") } 170 | .from(true) 171 | .to(false) 172 | end 173 | 174 | context "and the method is from a module and uses super" do 175 | let(:second_anonymous_class) do 176 | m = Module.new do 177 | def bar 178 | self.counter ||= 0 179 | self.counter += 1 180 | super 181 | end 182 | end 183 | 184 | Class.new(anonymous_class) do 185 | extend m 186 | end 187 | end 188 | 189 | before do 190 | DeadCodeDetector::Initializer.refresh_cache_for(second_anonymous_class) 191 | wrapper = described_class.new(second_anonymous_class) 192 | wrapper.wrap_methods! 193 | end 194 | 195 | it "calls the method on the module and the superclass when unwrapped" do 196 | expect { 197 | second_anonymous_class.bar 198 | }.to change { second_anonymous_class.counter }.from(0).to(2) 199 | 200 | expect { 201 | second_anonymous_class.bar 202 | }.to change { second_anonymous_class.counter }.from(2).to(4) 203 | end 204 | end 205 | 206 | it "removes the wrapper" do 207 | original_source_location = anonymous_class.method(:bar).source_location 208 | described_class.new(anonymous_class).wrap_methods! 209 | wrapped_source_location = anonymous_class.method(:bar).source_location 210 | 211 | expect do 212 | anonymous_class.bar 213 | end.to change { anonymous_class.method(:bar).source_location } 214 | .from(wrapped_source_location) 215 | .to(original_source_location) 216 | end 217 | 218 | it "calls the original method" do 219 | described_class.new(anonymous_class).wrap_methods! 220 | 221 | expect do 222 | anonymous_class.bar 223 | end.to change{ anonymous_class.counter }.from(0).to(1) 224 | end 225 | end 226 | end 227 | -------------------------------------------------------------------------------- /spec/dead_code_detector/initializer_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | RSpec.describe DeadCodeDetector::Initializer do 4 | let(:anonymous_class) do 5 | Class.new do 6 | def self.name 7 | @name ||= "AnonymousClass-#{object_id}" 8 | end 9 | end 10 | end 11 | 12 | describe ".allowed?" do 13 | before do 14 | count = 0 15 | allow(DeadCodeDetector.config).to receive(:allowed).and_return(->{ 16 | count += 1 17 | count == 1 18 | }) 19 | end 20 | it "is true for the first call and false for all subsequent calls" do 21 | expect(described_class).to be_allowed 22 | expect(described_class).to_not be_allowed 23 | end 24 | end 25 | 26 | describe ".refresh_cache_for" do 27 | it "marks the class as being tracked" do 28 | expect do 29 | described_class.refresh_cache_for(anonymous_class) 30 | end.to change{ DeadCodeDetector::Initializer.cached_classes.include?(anonymous_class.name) } 31 | .from(false) 32 | .to(true) 33 | end 34 | 35 | context "when the class has no tracked methods" do 36 | let(:anonymous_class) { Class.new } 37 | it "doesn't include it in the cached classes" do 38 | expect do 39 | described_class.refresh_cache_for(anonymous_class) 40 | end.to_not change{ DeadCodeDetector::Initializer.cached_classes } 41 | end 42 | end 43 | end 44 | 45 | describe ".enable_for_cached_classes!" do 46 | context "when the process takes longer than the max" do 47 | before do 48 | allow(DeadCodeDetector::Initializer).to receive(:cached_classes).and_return([anonymous_class.name]) 49 | allow(DeadCodeDetector.config).to receive(:allowed).and_return(true) 50 | allow(DeadCodeDetector.config).to receive(:max_seconds_to_enable).and_return(-1) 51 | end 52 | 53 | after do 54 | DeadCodeDetector::Initializer.fully_enabled = nil 55 | DeadCodeDetector::Initializer.last_enabled_class = nil 56 | end 57 | 58 | it "stops when it hits the cutoff" do 59 | expect(DeadCodeDetector::Initializer.fully_enabled).to be_falsey 60 | 61 | DeadCodeDetector::Initializer.enable_for_cached_classes! 62 | 63 | expect(DeadCodeDetector::Initializer.fully_enabled).to be_falsey 64 | expect(DeadCodeDetector::Initializer.last_enabled_class).to eql anonymous_class.name 65 | end 66 | 67 | it "restarts from the last_enabled_class" do 68 | expect(DeadCodeDetector::Initializer.fully_enabled).to be_falsey 69 | DeadCodeDetector::Initializer.last_enabled_class = anonymous_class.name 70 | 71 | DeadCodeDetector::Initializer.enable_for_cached_classes! 72 | expect(DeadCodeDetector::Initializer.fully_enabled).to be_truthy 73 | end 74 | end 75 | end 76 | 77 | describe ".enable" do 78 | before do 79 | described_class.refresh_cache_for(anonymous_class) 80 | end 81 | 82 | it "wraps the class methods" do 83 | wrapper = double 84 | expect(DeadCodeDetector::ClassMethodWrapper).to receive(:new).with(anonymous_class).and_return(wrapper) 85 | expect(wrapper).to receive(:wrap_methods!) 86 | described_class.enable(anonymous_class) 87 | end 88 | 89 | it "wraps the instance methods" do 90 | wrapper = double 91 | expect(DeadCodeDetector::InstanceMethodWrapper).to receive(:new).with(anonymous_class).and_return(wrapper) 92 | expect(wrapper).to receive(:wrap_methods!) 93 | described_class.enable(anonymous_class) 94 | end 95 | end 96 | end 97 | -------------------------------------------------------------------------------- /spec/dead_code_detector/instance_method_wrapper_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | RSpec.describe DeadCodeDetector::InstanceMethodWrapper do 4 | 5 | let(:anonymous_class) do 6 | Class.new do 7 | def self.name 8 | @name ||= "AnonymousClass-#{object_id}" 9 | end 10 | 11 | def counter=(val) 12 | @counter = val 13 | end 14 | 15 | def counter 16 | @counter ||= 0 17 | end 18 | 19 | def bar 20 | self.counter ||= 0 21 | self.counter += 1 22 | end 23 | end 24 | end 25 | 26 | before do 27 | DeadCodeDetector::Initializer.refresh_cache_for(anonymous_class) 28 | end 29 | 30 | describe "#wrap_methods!" do 31 | it "wraps methods defined in the class" do 32 | expect do 33 | described_class.new(anonymous_class).wrap_methods! 34 | end.to change{ anonymous_class.instance_method(:bar).source_location } 35 | end 36 | 37 | it "doesn't wrap methods it inherits but doesn't redefine" do 38 | expect do 39 | described_class.new(anonymous_class).wrap_methods! 40 | end.to_not change{ anonymous_class.instance_method(:object_id).source_location } 41 | end 42 | end 43 | 44 | context ".refresh_cache" do 45 | before do 46 | DeadCodeDetector.config.storage.clear(described_class.record_key(anonymous_class.name)) 47 | end 48 | 49 | 50 | context "when there is a ignore_paths set" do 51 | context "and it doesn't match the source location" do 52 | around(:each) do |example| 53 | begin 54 | old_path = DeadCodeDetector.config.ignore_paths 55 | DeadCodeDetector.config.ignore_paths = /foo/ 56 | example.run 57 | ensure 58 | DeadCodeDetector.config.ignore_paths = old_path 59 | end 60 | end 61 | 62 | context "and the method has an eval source location" do 63 | it "excludes it" do 64 | allow_any_instance_of(UnboundMethod).to receive(:source_location).and_return(["(eval)"]) 65 | 66 | expect do 67 | described_class.new(anonymous_class).refresh_cache 68 | end.to_not change{ DeadCodeDetector.config.storage.get(described_class.record_key(anonymous_class.name)) } 69 | end 70 | end 71 | 72 | context "and the method doesn't have a source location" do 73 | it "excludes it" do 74 | allow_any_instance_of(UnboundMethod).to receive(:source_location).and_return(nil) 75 | 76 | expect do 77 | described_class.new(anonymous_class).refresh_cache 78 | end.to_not change{ DeadCodeDetector.config.storage.get(described_class.record_key(anonymous_class.name)) } 79 | end 80 | end 81 | 82 | it "includes the methods" do 83 | expect do 84 | described_class.new(anonymous_class).refresh_cache 85 | end.to change{ DeadCodeDetector.config.storage.get(described_class.record_key(anonymous_class.name)) } 86 | .from(Set.new) 87 | .to(Set.new(["bar", "counter", "counter="])) 88 | end 89 | end 90 | 91 | context "and it matches the source location" do 92 | around(:each) do |example| 93 | begin 94 | old_path = DeadCodeDetector.config.ignore_paths 95 | DeadCodeDetector.config.ignore_paths = /spec/ 96 | example.run 97 | ensure 98 | DeadCodeDetector.config.ignore_paths = old_path 99 | end 100 | end 101 | 102 | it "doesn't includes the methods" do 103 | expect do 104 | described_class.new(anonymous_class).refresh_cache 105 | end.to_not change{ DeadCodeDetector.config.storage.get(described_class.record_key(anonymous_class.name)) } 106 | end 107 | end 108 | 109 | end 110 | 111 | it "sets up the cache with the full list of methods" do 112 | expect do 113 | described_class.new(anonymous_class).refresh_cache 114 | end.to change{ DeadCodeDetector.config.storage.get(described_class.record_key(anonymous_class.name)) } 115 | .from(Set.new) 116 | .to(Set.new(["bar", "counter", "counter="])) 117 | end 118 | 119 | context "when the class contains methods from a module" do 120 | let(:anonymous_class) do 121 | m = Module.new do 122 | def bar 123 | self.counter ||= 0 124 | self.counter += 1 125 | end 126 | end 127 | 128 | Class.new do 129 | include m 130 | 131 | def counter=(val) 132 | @counter = val 133 | end 134 | 135 | def counter 136 | @counter ||= 0 137 | end 138 | def self.name 139 | @name ||= "AnonymousClass-#{object_id}" 140 | end 141 | end 142 | end 143 | 144 | context "and the module is include in the parent" do 145 | let(:second_anonymous_class) do 146 | Class.new(anonymous_class) 147 | end 148 | 149 | it "does not include the module method" do 150 | expect do 151 | described_class.new(second_anonymous_class).refresh_cache 152 | end.to_not change{ DeadCodeDetector.config.storage.get(described_class.record_key(second_anonymous_class.name)).include?("bar") } 153 | end 154 | end 155 | 156 | it "includes the module method" do 157 | expect do 158 | described_class.new(anonymous_class).refresh_cache 159 | end.to change{ DeadCodeDetector.config.storage.get(described_class.record_key(anonymous_class.name)).include?("bar") } 160 | .from(false) 161 | .to(true) 162 | 163 | end 164 | end 165 | 166 | end 167 | 168 | context "when a wrapped method is called" do 169 | 170 | it "marks the method as being used" do 171 | wrapper = described_class.new(anonymous_class) 172 | wrapper.wrap_methods! 173 | 174 | expect do 175 | anonymous_class.new.bar 176 | end.to change { DeadCodeDetector::Report.unused_methods_for(anonymous_class.name).include?("#{anonymous_class.name}#bar") } 177 | .from(true) 178 | .to(false) 179 | end 180 | 181 | it "removes the wrapper" do 182 | original_source_location = anonymous_class.instance_method(:bar).source_location 183 | described_class.new(anonymous_class).wrap_methods! 184 | wrapped_source_location = anonymous_class.instance_method(:bar).source_location 185 | 186 | expect do 187 | anonymous_class.new.bar 188 | end.to change { anonymous_class.instance_method(:bar).source_location } 189 | .from(wrapped_source_location) 190 | .to(original_source_location) 191 | end 192 | 193 | context "and the method is from a module and uses super" do 194 | let(:second_anonymous_class) do 195 | m = Module.new do 196 | def bar 197 | self.counter ||= 0 198 | self.counter += 1 199 | super 200 | end 201 | end 202 | 203 | Class.new(anonymous_class) do 204 | include m 205 | end 206 | end 207 | 208 | before do 209 | DeadCodeDetector::Initializer.refresh_cache_for(second_anonymous_class) 210 | wrapper = described_class.new(second_anonymous_class) 211 | wrapper.wrap_methods! 212 | end 213 | 214 | it "calls the method on the module and the superclass when unwrapped" do 215 | instance = second_anonymous_class.new 216 | expect { 217 | instance.bar 218 | }.to change { instance.counter }.from(0).to(2) 219 | 220 | expect { 221 | instance.bar 222 | }.to change { instance.counter }.from(2).to(4) 223 | end 224 | end 225 | 226 | it "calls the original method" do 227 | described_class.new(anonymous_class).wrap_methods! 228 | instance = anonymous_class.new 229 | expect do 230 | instance.bar 231 | end.to change{ instance.counter }.from(0).to(1) 232 | end 233 | end 234 | end 235 | -------------------------------------------------------------------------------- /spec/dead_code_detector/report_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | RSpec.describe DeadCodeDetector::Report do 4 | class DeadCodeDetector::Report::TestClass 5 | def self.foo; end 6 | end 7 | 8 | class DeadCodeDetector::Report::TestClass2 9 | def bar; end 10 | end 11 | 12 | before do 13 | DeadCodeDetector::Initializer.refresh_cache_for(DeadCodeDetector::Report::TestClass) 14 | DeadCodeDetector::Initializer.refresh_cache_for(DeadCodeDetector::Report::TestClass2) 15 | end 16 | 17 | describe ".unused_methods" do 18 | subject { DeadCodeDetector::Report.unused_methods } 19 | 20 | it { is_expected.to include "DeadCodeDetector::Report::TestClass.foo" } 21 | it { is_expected.to include "DeadCodeDetector::Report::TestClass2#bar" } 22 | end 23 | 24 | describe ".unused_methods_for" do 25 | subject { DeadCodeDetector::Report.unused_methods_for(DeadCodeDetector::Report::TestClass2.name) } 26 | 27 | it { is_expected.to_not include "DeadCodeDetector::Report::TestClass.foo" } 28 | it { is_expected.to include "DeadCodeDetector::Report::TestClass2#bar" } 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /spec/dead_code_detector_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | RSpec.describe DeadCodeDetector do 4 | class DeadCodeDetector::TestClass 5 | def self.foo; end 6 | end 7 | 8 | describe ".enable" do 9 | before do 10 | DeadCodeDetector::Initializer.refresh_cache_for(DeadCodeDetector::TestClass) 11 | end 12 | 13 | it "tracks method calls inside of the block" do 14 | expect do 15 | DeadCodeDetector.enable do 16 | DeadCodeDetector::TestClass.foo 17 | end 18 | end.to change{ DeadCodeDetector::Report.unused_methods_for(DeadCodeDetector::TestClass.name) }.from(["DeadCodeDetector::TestClass.foo"]).to([]) 19 | 20 | expect(DeadCodeDetector.config.storage.pending_deletions).to be_empty 21 | end 22 | 23 | it "doesn't record method calls outside of the block" do 24 | DeadCodeDetector.enable {} 25 | DeadCodeDetector::TestClass.foo 26 | 27 | expect(DeadCodeDetector.config.storage.pending_deletions.values).to include(Set.new(["foo"])) 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require "bundler/setup" 2 | require "dead_code_detector" 3 | 4 | DeadCodeDetector.configure do |config| 5 | config.storage = :memory 6 | end 7 | 8 | RSpec.configure do |config| 9 | # Enable flags like --only-failures and --next-failure 10 | config.example_status_persistence_file_path = ".rspec_status" 11 | 12 | # Disable RSpec exposing methods globally on `Module` and `main` 13 | config.disable_monkey_patching! 14 | 15 | config.expect_with :rspec do |c| 16 | c.syntax = :expect 17 | end 18 | end 19 | --------------------------------------------------------------------------------