Skip to content

Commit 39d46e3

Browse files
committed
Add auditor to find unused actions and routes
1 parent 02b328e commit 39d46e3

File tree

1 file changed

+128
-0
lines changed

1 file changed

+128
-0
lines changed

lib/routes_coverage/auditor.rb

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
# frozen_string_literal: true
2+
3+
module RoutesCoverage
4+
class Auditor
5+
def logger
6+
@logger ||= Logger.new($stdout).tap do |log|
7+
log.formatter = ->(_severity, _datetime, _progname, msg) { "#{msg}\n" }
8+
end
9+
end
10+
11+
def controllers
12+
@controllers ||= begin
13+
logger.info "Eager-loading app to collect controllers"
14+
Rails.application.eager_load!
15+
16+
logger.info "Collecting controllers"
17+
ActionController::Base.descendants + ActionController::API.descendants
18+
end
19+
end
20+
21+
def controllers_hash
22+
@controllers_hash ||= controllers.index_by { |controller| controller.name.sub(/Controller$/, "").underscore }
23+
end
24+
25+
def controller_class_by_name(controller_name)
26+
controller = controllers_hash[controller_name]
27+
return controller if controller
28+
29+
@missing_controllers ||= Set.new
30+
return if @missing_controllers.include?(controller_name)
31+
32+
controllers_hash[controller_name] ||= "#{controller_name}_controller".classify.constantize
33+
logger.warn "Controller #{controller_name} was not collected, but exists"
34+
controllers_hash[controller_name]
35+
rescue NameError
36+
@missing_controllers << controller_name
37+
logger.warn "Controller #{controller_name} looks not existing"
38+
end
39+
40+
def existing_actions_usage_hash
41+
@existing_actions_usage_hash ||= begin
42+
logger.info "Collecting actions"
43+
controller_actions = controllers.map do |controller|
44+
# cannot use controller.controller_name - it has no namespace, same thing without demodulize:
45+
controller_name = controller.name.sub(/Controller$/, "").underscore
46+
controller.action_methods.map { |action| "#{controller_name}##{action}" }
47+
end
48+
controller_actions.flatten.to_h { |action| [action, 0] }
49+
end
50+
end
51+
52+
def perform
53+
require 'routes_coverage'
54+
# NB: there're no engines
55+
routes = RoutesCoverage._collect_all_routes
56+
57+
@missing_actions = Hash.new(0)
58+
@existing_actions_usage_hash = nil
59+
routes.each do |route|
60+
next unless route.respond_to?(:requirements) && route.requirements[:controller]
61+
62+
action = "#{route.requirements[:controller]}##{route.requirements[:action]}"
63+
if existing_actions_usage_hash[action]
64+
existing_actions_usage_hash[action] += 1
65+
else
66+
# there may be inheritance or implicit renders
67+
controller_instance = controller_class_by_name(route.requirements[:controller])&.new
68+
unless controller_instance&.available_action?(route.requirements[:action])
69+
if controller_instance.respond_to?(route.requirements[:action])
70+
logger.warn "No action, but responds: #{action}"
71+
end
72+
@missing_actions[action] += 1
73+
end
74+
end
75+
end
76+
end
77+
78+
def missing_actions
79+
perform unless @missing_actions
80+
@missing_actions
81+
end
82+
83+
def unused_actions
84+
perform unless @existing_actions_usage_hash
85+
86+
root = "#{Rails.root}/" # rubocop:disable Rails/FilePath
87+
@unused_actions ||= begin
88+
# methods with special suffixes are obviously not actions, reduce noise:
89+
unused_actions_from_hash = existing_actions_usage_hash.reject do |action, count|
90+
count.positive? || action.end_with?('?') || action.end_with?('!') || action.end_with?('=')
91+
end
92+
93+
unused_actions_from_hash.keys.map do |action|
94+
controller_name, action_name = action.split('#', 2)
95+
controller = controller_class_by_name(controller_name)&.new
96+
method = controller.method(action_name.to_sym)
97+
if method&.source_location && method.source_location.first.start_with?(root)
98+
"#{method.source_location.first.sub(root, '')}:#{method.source_location.second} - #{action}"
99+
else
100+
action
101+
end
102+
end.uniq.sort
103+
end
104+
end
105+
106+
def print_missing_actions
107+
logger.info "Missing #{missing_actions.count} actions:"
108+
109+
# NB: для `resource` могут лезть лишние index в преложениях
110+
restful_actions = %w[index new create show edit update destroy].freeze
111+
missing_actions.keys.map { |action| action.split('#', 2) }.group_by(&:first).each do |(controller, actions)|
112+
missing = actions.map(&:last)
113+
if (restful_actions & missing).any?
114+
logger.info "#{controller}, except: %i[#{(restful_actions & missing).join(' ')}], "\
115+
"only: %i[#{(restful_actions - missing).join(' ')}]"
116+
end
117+
118+
missing_custom = missing - restful_actions
119+
logger.info "#{controller} missing custom: #{missing_custom.join(', ')}" if missing_custom.any?
120+
end
121+
end
122+
123+
def print_unused_actions
124+
logger.info "Unused #{unused_actions.count} actions:"
125+
unused_actions.each { |action| logger.info action }
126+
end
127+
end
128+
end

0 commit comments

Comments
 (0)