commit 13a734cf24b694f81919f1a5c0feb97b4903e0f2 Author: Rob Watson Date: Thu Jul 20 19:20:18 2017 +0100 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5dfd5c0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +/.bundle/ +/.yardoc +/Gemfile.lock +/_yardoc/ +/coverage/ +/doc/ +/pkg/ +/spec/reports/ +/tmp/ + +# rspec failure tracking +.rspec_status + +.byebug_history + +*.gem diff --git a/.rspec b/.rspec new file mode 100644 index 0000000..8c18f1a --- /dev/null +++ b/.rspec @@ -0,0 +1,2 @@ +--format documentation +--color diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..b64d770 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,5 @@ +sudo: false +language: ruby +rvm: + - 2.4.1 +before_install: gem install bundler -v 1.14.6 diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..ebc23b6 --- /dev/null +++ b/Gemfile @@ -0,0 +1,4 @@ +source 'https://rubygems.org' + +# Specify your gem's dependencies in routing_report.gemspec +gemspec diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..38d79e2 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2017 Rob Watson + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..18ee592 --- /dev/null +++ b/README.md @@ -0,0 +1,33 @@ +# routing_report + +Identify cruft in a Rails app. + +Detects: + +* routes with no matching controller actions +* controller actions with no matching routes + +## Installation + +Add this line to your application's Gemfile: + +```ruby +gem 'routing_report' +``` + +And then execute: + + $ bundle + +## Usage + +`bundle exec rake routing_report:run` + +## Contributing + +Bug reports and pull requests are welcome on GitHub at https://github.com/rfwatson/routing_report. + +## License + +The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT). + diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..b7e9ed5 --- /dev/null +++ b/Rakefile @@ -0,0 +1,6 @@ +require "bundler/gem_tasks" +require "rspec/core/rake_task" + +RSpec::Core::RakeTask.new(:spec) + +task :default => :spec diff --git a/bin/console b/bin/console new file mode 100755 index 0000000..aa11361 --- /dev/null +++ b/bin/console @@ -0,0 +1,14 @@ +#!/usr/bin/env ruby + +require "bundler/setup" +require "routing_report" + +# You can add fixtures and/or initialization code here to make experimenting +# with your gem easier. You can also use a different console, if you like. + +# (If you use this, don't forget to add pry to your Gemfile!) +# require "pry" +# Pry.start + +require "irb" +IRB.start(__FILE__) diff --git a/bin/setup b/bin/setup new file mode 100755 index 0000000..dce67d8 --- /dev/null +++ b/bin/setup @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail +IFS=$'\n\t' +set -vx + +bundle install + +# Do any other automated setup that you need to do here diff --git a/lib/routing_report.rb b/lib/routing_report.rb new file mode 100644 index 0000000..12a1a06 --- /dev/null +++ b/lib/routing_report.rb @@ -0,0 +1,6 @@ +require "routing_report/version" +require 'routing_report/report' +require 'routing_report/railtie' if defined? Rails + +module RoutingReport +end diff --git a/lib/routing_report/railtie.rb b/lib/routing_report/railtie.rb new file mode 100644 index 0000000..3256189 --- /dev/null +++ b/lib/routing_report/railtie.rb @@ -0,0 +1,5 @@ +module RoutingReport + class Railtie < Rails::Railtie + rake_tasks { load 'tasks/routing_report.rake' } + end +end diff --git a/lib/routing_report/report.rb b/lib/routing_report/report.rb new file mode 100644 index 0000000..d6e0df4 --- /dev/null +++ b/lib/routing_report/report.rb @@ -0,0 +1,66 @@ +require 'terminal-table' + +module RoutingReport + class Report + def initialize(base_class: Object, routes: []) + @base_class, @routes = base_class, routes + end + + def print(to: STDOUT) + print_table('Routes without actions', routes_without_actions, to) + print_table('Actions without routes', actions_without_routes, to) + end + + def routes_without_actions + routes.each_with_object([]) do |route, accum| + controller_name = route.defaults[:controller] + action_name = route.defaults[:action] + + if controller_name && action_name + begin + controller = "#{controller_name}_controller".classify.constantize + rescue NameError + accum << "#{controller_name}##{action_name}" + next + end + + # get all superclasses that descend from ActionController::Base + # this allows us to avoid false positives when routes are fulfilled by + # actions in a superclass: + matching_controllers = controller.ancestors.select { |c| c < base_class } + + unless matching_controllers.any? { |c| c.public_instance_methods(false).include?(action_name.to_sym) } + accum << "#{controller_name}##{action_name}" + end + end + end.sort + end + + def actions_without_routes + base_class.descendants.each_with_object([]) do |controller_class, accum| + controller_name = controller_class.name.underscore.match(/\A(.*)_controller\z/)[1] + + controller_class.public_instance_methods(false).each do |method| + unless routes.any? { |r| r.defaults[:controller] == controller_name && r.defaults[:action] == method.to_s } + accum << "#{controller_name}##{method.to_s}" + end + end + end.sort + end + + private + attr_reader :routes, :base_class + + def print_table(title, rows, to) + count = rows.size + rows << "No #{title.downcase} detected" if rows.none? + + to.puts Terminal::Table.new( + headings: ["#{title} (#{count})"], + rows: rows.map { |r| [r] }, + style: { width: 80 } + ) + to.puts + end + end +end diff --git a/lib/routing_report/version.rb b/lib/routing_report/version.rb new file mode 100644 index 0000000..5dd4723 --- /dev/null +++ b/lib/routing_report/version.rb @@ -0,0 +1,3 @@ +module RoutingReport + VERSION = "0.1.0" +end diff --git a/lib/tasks/routing_report.rake b/lib/tasks/routing_report.rake new file mode 100644 index 0000000..d8c0373 --- /dev/null +++ b/lib/tasks/routing_report.rake @@ -0,0 +1,13 @@ +namespace :routing_report do + task :run => :environment do + # pre-load all controllers: + Dir.glob(Rails.root.join('app', 'controllers', '**', '*_controller.rb')).each do |path| + require_dependency(path) + end + + RoutingReport::Report.new( + base_class: ActionController::Base, + routes: Rails.application.routes.set + ).print + end +end diff --git a/routing_report.gemspec b/routing_report.gemspec new file mode 100644 index 0000000..6ddfc4a --- /dev/null +++ b/routing_report.gemspec @@ -0,0 +1,30 @@ +# coding: utf-8 +lib = File.expand_path('../lib', __FILE__) +$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) +require 'routing_report/version' + +Gem::Specification.new do |spec| + spec.name = "routing_report" + spec.version = RoutingReport::VERSION + spec.authors = ["Rob Watson"] + spec.email = ["rob@mixlr.com"] + + spec.summary = %q{Identify unused routes and controller actions in Rails apps} + spec.homepage = "https://github.com/rfwatson/routing_report" + spec.license = "MIT" + + spec.files = `git ls-files -z`.split("\x0").reject do |f| + f.match(%r{^(test|spec|features)/}) + end + spec.bindir = "exe" + spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } + spec.require_paths = ["lib"] + + spec.add_dependency 'terminal-table', '~> 1.8' + spec.add_dependency 'activesupport', '~> 3' + + spec.add_development_dependency "bundler", "~> 1.14" + spec.add_development_dependency "rake", "~> 10.0" + spec.add_development_dependency "rspec", "~> 3.0" + spec.add_development_dependency 'byebug', '~> 9' +end diff --git a/spec/routing_report_spec.rb b/spec/routing_report_spec.rb new file mode 100644 index 0000000..4f82430 --- /dev/null +++ b/spec/routing_report_spec.rb @@ -0,0 +1,138 @@ +require "spec_helper" + +RSpec.describe RoutingReport::Report do + ActionControllerBase = Class.new + + ApplicationController = Class.new(ActionControllerBase) + + SubController = Class.new(ApplicationController) do + def show + end + + def index + end + end + + SubSubController = Class.new(SubController) do + def create + end + end + + subject(:report) { + described_class.new( + base_class: ActionControllerBase, + routes: routes + ) + } + + describe '#print' do + let(:routes) { [] } + + it 'does not raise an exception' do + File.open('/dev/null', 'a') do |output| + expect { report.print(to: output) }.not_to raise_error + end + end + end + + describe '#routes_without_actions' do + context 'with no routes' do + let(:routes) { [] } + + it 'returns no routes' do + expect(report.routes_without_actions).to be_empty + end + end + + context 'with a route defined that references a non-existent controller' do + let(:routes) { + [ + double(defaults: { controller: 'nope', action: 'show' }) + ] + } + + it 'returns the route' do + expect(report.routes_without_actions).to eq ['nope#show'] + end + end + + context 'with a route defined that is implemented by a superclass' do + let(:routes) { + [ + double(defaults: { controller: 'sub_sub', action: 'index' }) + ] + } + + it 'returns no routes' do + expect(report.routes_without_actions).to be_empty + end + end + + context 'with a route defined that is implemented by a controller' do + let(:routes) { + [ + double(defaults: { controller: 'sub', action: 'show' }) + ] + } + + it 'returns no routes' do + expect(report.routes_without_actions).to be_empty + end + end + + context 'with a route defined that is not implemented by a controller' do + let(:routes) { + [ + double(defaults: { controller: 'sub', action: 'non_existent' }) + ] + } + + it 'returns the route' do + expect(report.routes_without_actions).to eq ['sub#non_existent'] + end + end + end + + describe '#actions_without_routes' do + context 'with no routes' do + let(:routes) { [] } + + it 'returns all the actions' do + expect(report.actions_without_routes).to eq [ + 'sub#index', + 'sub#show', + 'sub_sub#create' + ] + end + end + + context 'with one of the routes defined' do + let(:routes) { + [ + double(defaults: { controller: 'sub', action: 'index' }) + ] + } + + it 'returns the other two actions' do + expect(report.actions_without_routes).to eq [ + 'sub#show', + 'sub_sub#create' + ] + end + end + + context 'with all of the routes defined' do + let(:routes) { + [ + double(defaults: { controller: 'sub', action: 'show' }), + double(defaults: { controller: 'sub', action: 'index' }), + double(defaults: { controller: 'sub_sub', action: 'create' }) + ] + } + + it 'returns no actions' do + expect(report.actions_without_routes).to be_empty + end + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 0000000..92691d3 --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,14 @@ +require "bundler/setup" +require "routing_report" +require 'byebug' +require 'active_support/core_ext/string' +require 'active_support/core_ext/class' + +RSpec.configure do |config| + # Enable flags like --only-failures and --next-failure + config.example_status_persistence_file_path = ".rspec_status" + + config.expect_with :rspec do |c| + c.syntax = :expect + end +end