rails_routes_analyzer 1.0.4 → 2.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,52 @@
1
+ module RailsRoutesAnalyzer
2
+ module GemManager
3
+ extend self
4
+
5
+ # Replaces gem locations in file paths with the name of the gem.
6
+ #
7
+ # @param location [String] full path to a ruby source file.
8
+ # @return [String] path to ruby source file with gem location replaced.
9
+ def clean_gem_path(location)
10
+ location.gsub(gem_path_prefix_cleanup_regex) do |val|
11
+ gem_path_prefix_replacements[val] || val
12
+ end
13
+ end
14
+
15
+ # Identifies a gem based on a location from a backtrace.
16
+ #
17
+ # @param location [String] full path to a source file possibly in a gem.
18
+ # @return [String] name of a gem.
19
+ def identify_gem(location)
20
+ gem_locations[location[gem_locations_regexp]]
21
+ end
22
+
23
+ private
24
+
25
+ # @return { String => String } mapping of gem path prefix to gem name.
26
+ def gem_path_prefix_replacements
27
+ @gem_path_prefix_replacements ||=
28
+ Gem.loaded_specs.values.each_with_object({}) do |spec, sum|
29
+ path = spec.full_gem_path.sub %r{/?\z}, '/'
30
+ sum[path] = "#{spec.name} @ "
31
+ end
32
+ end
33
+
34
+ # @return [Regexp] a regexp that matches all gem paths.
35
+ def gem_path_prefix_cleanup_regex
36
+ @gem_path_prefix_cleanup_regex ||=
37
+ /\A#{Regexp.union(gem_path_prefix_replacements.keys)}/
38
+ end
39
+
40
+ # @return {String=>String} mapping of gem path to gem name.
41
+ def gem_locations
42
+ @gem_locations ||= Gem.loaded_specs.values.each_with_object({}) do |spec, sum|
43
+ sum[spec.full_gem_path] = spec.name
44
+ end
45
+ end
46
+
47
+ # @return [Regexp] a regexp covering paths of all available gems.
48
+ def gem_locations_regexp
49
+ @gem_locations_regexp ||= /\A#{Regexp.union(gem_locations.keys)}/
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,41 @@
1
+ module RailsRoutesAnalyzer
2
+ module ParameterHandler
3
+ def self.params_for_route_analysis(env = ENV)
4
+ {
5
+ only_only: env['ONLY_ONLY'].present?,
6
+ only_except: env['ONLY_EXCEPT'].present?,
7
+ verbose: env['ROUTES_VERBOSE'].present?,
8
+ }
9
+ end
10
+
11
+ def self.params_for_annotate(env = ENV, extras = [])
12
+ params_for_route_analysis.merge(
13
+ try_to_fix: false,
14
+ allow_deleting: false,
15
+ force_overwrite: env['ROUTES_FORCE'].present? || extras.include?('force'),
16
+ )
17
+ end
18
+
19
+ def self.file_to_annotate(env = ENV)
20
+ env['ROUTES_FILE']
21
+ end
22
+
23
+ def self.params_for_fix(env = ENV, extras = [])
24
+ params_for_route_analysis.merge(
25
+ try_to_fix: true,
26
+ allow_deleting: true,
27
+ force_overwrite: env['ROUTES_FORCE'].present? || extras.include?('force'),
28
+ )
29
+ end
30
+
31
+ def self.params_for_action_analysis(env = ENV, extras = [])
32
+ {
33
+ report_duplicates: env['ROUTES_DUPLICATES'].present? || extras.include?('duplicates'),
34
+ report_gems: env['ROUTES_GEMS'].present? || extras.include?('gems'),
35
+ report_modules: env['ROUTES_MODULES'].present? || extras.include?('modules'),
36
+ full_path: env['ROUTES_FULL_PATH'].present? || extras.include?('full'),
37
+ metadata: env['ROUTES_METADATA'].present? || extras.include?('metadata'),
38
+ }
39
+ end
40
+ end
41
+ end
@@ -1,45 +1,84 @@
1
- require 'rails/railtie'
1
+ require_relative 'gem_manager'
2
2
 
3
3
  module RailsRoutesAnalyzer
4
4
 
5
- class Railtie < ::Rails::Railtie
6
- rake_tasks do
7
- load File.join(File.dirname(__FILE__), '../tasks/rails_routes_analyzer.rake')
5
+ MULTI_METHODS = %w(resource resources).freeze
6
+ SINGLE_METHODS = %w(match get head post patch put delete options root).freeze
7
+ ROUTE_METHODS = (MULTI_METHODS + SINGLE_METHODS).freeze
8
+
9
+ class << self
10
+ # Converts Rails.root-relative filenames to be absolute.
11
+ def get_full_filename(filename)
12
+ return filename.to_s if filename.to_s.starts_with?('/')
13
+ Rails.root.join(filename).to_s
8
14
  end
9
- end
10
15
 
11
- MULTI_METHODS = %w[resource resources].freeze
12
- SINGLE_METHODS = %w[match get head post patch put delete options root].freeze
13
- ROUTE_METHODS = (MULTI_METHODS + SINGLE_METHODS).freeze
16
+ # Shortens full file path, replacing Rails.root and gem path with
17
+ # appropriate short prefixes to make the file names look good.
18
+ def sanitize_source_location(source_location, full_path: false)
19
+ source_location.dup.tap do |clean_location|
20
+ unless full_path
21
+ clean_location.gsub! "#{Rails.root}/", './'
14
22
 
15
- def self.get_full_filename(filename)
16
- return filename.to_s if filename.to_s.starts_with?('/')
17
- Rails.root.join(filename).to_s
18
- end
23
+ clean_location.replace GemManager.clean_gem_path(clean_location)
24
+ end
25
+ end
26
+ end
19
27
 
20
- RESOURCE_ACTIONS = [:index, :create, :new, :show, :update, :destroy, :edit]
28
+ def routes_dead(env)
29
+ params = RailsRoutesAnalyzer::ParameterHandler.params_for_route_analysis(env)
30
+ analysis = RailsRoutesAnalyzer::RouteAnalysis.new(params)
21
31
 
22
- def self.identify_route_issues
23
- RouteAnalysis.new
24
- end
32
+ analysis.print_report
33
+ end
25
34
 
26
- def self.get_all_defined_routes
27
- identify_route_issues[:implemented_routes]
28
- end
35
+ def routes_dead_annotate(env)
36
+ routes_dead_annotate_common(env)
37
+ end
29
38
 
30
- def self.get_all_action_methods(ignore_parent_provided: true)
31
- [].tap do |result|
32
- ApplicationController.descendants.each do |controller_class|
33
- action_methods = controller_class.action_methods
39
+ def routes_dead_annotate_inplace(env, extras)
40
+ routes_dead_annotate_common(env, extras, inplace: true)
41
+ end
34
42
 
35
- if ignore_parent_provided && (super_class_actions = controller_class.superclass.try(:action_methods)).present?
36
- action_methods -= super_class_actions
37
- end
43
+ def routes_dead_fix(env)
44
+ routes_dead_fix_common(env)
45
+ end
38
46
 
39
- action_methods.each do |action_method|
40
- result << [controller_class.name, action_method.to_sym]
41
- end
42
- end
47
+ def routes_dead_fix_inplace(env, extras)
48
+ routes_dead_fix_common(env, extras, inplace: true)
49
+ end
50
+
51
+ def routes_actions_missing_route(env, extras)
52
+ routes_actions_common(env, extras, report_routed: false)
53
+ end
54
+
55
+ def routes_actions_list_all(env, extras)
56
+ routes_actions_common(env, extras, report_routed: true)
57
+ end
58
+
59
+ protected
60
+
61
+ def routes_dead_common(env, params, **opts)
62
+ annotator = RailsRoutesAnalyzer::RouteFileAnnotator.new(params)
63
+ routes_file = RailsRoutesAnalyzer::ParameterHandler.file_to_annotate(env)
64
+
65
+ annotator.annotate_routes_file(routes_file, **opts)
66
+ end
67
+
68
+ def routes_dead_annotate_common(env, extras = [], **opts)
69
+ params = RailsRoutesAnalyzer::ParameterHandler.params_for_annotate(env, extras)
70
+ routes_dead_common(env, params, **opts)
71
+ end
72
+
73
+ def routes_dead_fix_common(env, extras = [], **opts)
74
+ params = RailsRoutesAnalyzer::ParameterHandler.params_for_fix(env, extras)
75
+ routes_dead_common(env, params, **opts)
76
+ end
77
+
78
+ def routes_actions_common(env, extras, **opts)
79
+ params = RailsRoutesAnalyzer::ParameterHandler.params_for_action_analysis(env, extras)
80
+ analysis = RailsRoutesAnalyzer::ActionAnalysis.new(params.merge(opts))
81
+ analysis.print_report
43
82
  end
44
83
  end
45
84
 
@@ -0,0 +1,11 @@
1
+ require 'rails/railtie'
2
+
3
+ module RailsRoutesAnalyzer
4
+
5
+ class Railtie < ::Rails::Railtie
6
+ rake_tasks do
7
+ load File.join(File.dirname(__FILE__), '../tasks/rails_routes_analyzer.rake')
8
+ end
9
+ end
10
+
11
+ end
@@ -1,9 +1,15 @@
1
+ require_relative 'route_line'
2
+ require_relative 'route_call'
3
+ require_relative 'route_issue'
4
+ require_relative 'route_interceptor'
5
+
1
6
  module RailsRoutesAnalyzer
2
7
 
8
+ RESOURCE_ACTIONS = [:index, :create, :new, :show, :update, :destroy, :edit].freeze
9
+
3
10
  class RouteAnalysis
4
11
  attr_accessor :app, :verbose, :only_only, :only_except
5
- attr_accessor :route_log
6
- attr_writer :all_issues
12
+ attr_accessor :route_log, :route_lines, :route_calls
7
13
 
8
14
  def initialize(app: Rails.application, verbose: false, only_only: false, only_except: false)
9
15
  self.app = app
@@ -14,7 +20,13 @@ module RailsRoutesAnalyzer
14
20
  analyze!
15
21
  end
16
22
 
17
- def analyze!
23
+ def clear_data
24
+ self.route_lines = []
25
+ self.route_calls = []
26
+ self.route_log = []
27
+ end
28
+
29
+ def prepare_for_analysis
18
30
  app.eager_load! # all controller classes need to be loaded
19
31
 
20
32
  ::ActionDispatch::Routing::Mapper::Mapping.prepend RouteInterceptor
@@ -22,110 +34,135 @@ module RailsRoutesAnalyzer
22
34
  RouteInterceptor.route_log.clear
23
35
 
24
36
  app.reload_routes!
37
+ end
25
38
 
26
- all_issues = []
39
+ def analyze!
40
+ clear_data
41
+ prepare_for_analysis
27
42
 
28
43
  RouteInterceptor.route_data.each do |(file_location, route_creation_method, controller_name), action_names|
29
- controller_class_name = "#{controller_name}_controller".camelize
30
-
31
- action_names = action_names.uniq.sort
32
-
33
- opts = {
44
+ analyse_route_call(
34
45
  file_location: file_location,
35
46
  route_creation_method: route_creation_method,
36
47
  controller_name: controller_name,
37
- controller_class_name: controller_class_name,
38
- action_names: action_names,
39
- }
40
-
41
- controller = nil
42
- begin
43
- controller = Object.const_get(controller_class_name)
44
- rescue LoadError, RuntimeError, NameError => e
45
- all_issues << RouteIssue.new(opts.merge(type: :no_controller, error: e.message))
46
- next
47
- end
48
+ action_names: action_names.uniq.sort,
49
+ )
50
+ end
48
51
 
49
- if controller.nil?
50
- all_issues << RouteIssue.new(opts.merge(type: :no_controller))
51
- next
52
- end
52
+ route_log.concat RouteInterceptor.route_log
53
+ generate_route_lines
54
+ end
53
55
 
54
- present, missing = action_names.partition {|name| controller.action_methods.include?(name.to_s) }
55
- extra = action_names - RESOURCE_ACTIONS
56
+ def generate_route_lines
57
+ calls_per_line = route_calls.group_by do |record|
58
+ [record.full_filename, record.line_number]
59
+ end
56
60
 
57
- if present.any?
58
- all_issues << RouteIssue.new(opts.merge(type: :non_issue, present_actions: present))
59
- end
61
+ calls_per_line.each do |(full_filename, line_number), records|
62
+ route_lines << RouteLine.new(full_filename: full_filename,
63
+ line_number: line_number,
64
+ records: records)
65
+ end
66
+ end
60
67
 
61
- if SINGLE_METHODS.include?(route_creation_method)
62
- # NOTE a single call like 'get' can add multiple actions if called in a loop
63
- if missing.present?
64
- all_issues << RouteIssue.new(opts.merge(type: :no_action, missing_actions: missing))
65
- end
68
+ def analyse_route_call(**kwargs)
69
+ controller_class_name = "#{kwargs[:controller_name]}_controller".camelize
66
70
 
67
- next
68
- end
71
+ opts = kwargs.merge(controller_class_name: controller_class_name)
69
72
 
70
- next if missing.empty? # Everything is perfect
73
+ route_call = RouteCall.new(opts)
74
+ route_calls << route_call
71
75
 
72
- if present.sort == RESOURCE_ACTIONS.sort
73
- unless missing.empty?
74
- raise "shouldn't get all methods being present and missing at the same time: #{present.inspect} #{missing.inspect}"
75
- end
76
- next
77
- end
76
+ controller = nil
77
+ begin
78
+ controller = Object.const_get(controller_class_name)
79
+ rescue LoadError, RuntimeError, NameError => e
80
+ route_call.add_issue RouteIssue::NoController.new(error: e.message)
81
+ return
82
+ end
78
83
 
79
- suggested_param = if (present.size < 4 || only_only) && !only_except
80
- "only: [#{present.sort.map {|x| ":#{x}" }.join(', ')}]"
81
- else
82
- "except: [#{(RESOURCE_ACTIONS - present).sort.map {|x| ":#{x}" }.join(', ')}]"
83
- end
84
+ if controller.nil?
85
+ route_call.add_issue RouteIssue::NoController.new(error: "#{controller_class_name} is nil")
86
+ return
87
+ end
88
+
89
+ analyze_action_availability(controller, route_call, **opts)
90
+ end
84
91
 
85
- if verbose
86
- verbose_message = "This route currently covers unimplemented actions: [#{missing.sort.map {|x| ":#{x}" }.join(', ')}]"
92
+ # Checks which if any actions referred to by the route don't exist.
93
+ def analyze_action_availability(controller, route_call, **opts)
94
+ present, missing = opts[:action_names].partition { |name| controller.action_methods.include?(name.to_s) }
95
+
96
+ route_call[:present_actions] = present if present.any?
97
+
98
+ if SINGLE_METHODS.include?(opts[:route_creation_method])
99
+ # NOTE a single call like 'get' can add multiple actions if called in a loop
100
+ if missing.present?
101
+ route_call.add_issue RouteIssue::NoAction.new(missing_actions: missing)
87
102
  end
103
+ return
104
+ end
88
105
 
89
- all_issues << RouteIssue.new(opts.merge(type: :resources, suggested_param: suggested_param, verbose_message: verbose_message))
106
+ return if missing.empty? # Everything is perfect, all routes match an action
107
+
108
+ if present.sort == RESOURCE_ACTIONS.sort
109
+ # Should happen only if RESOURCE_ACTIONS doesn't match which actions rails supports
110
+ raise "shouldn't get all methods being present and yet some missing at the same time: #{present.inspect} #{missing.inspect}"
90
111
  end
91
112
 
92
- self.route_log = RouteInterceptor.route_log.dup
93
- self.all_issues = all_issues
113
+ suggested_param = resource_route_suggested_param(present)
114
+
115
+ route_call.add_issue RouteIssue::Resources.new(suggested_param: suggested_param)
94
116
  end
95
117
 
96
- def all_issues
97
- @all_issues || []
118
+ def resource_route_suggested_param(present)
119
+ if (present.size < 4 || only_only) && !only_except
120
+ "only: [#{present.sort.map { |x| ":#{x}" }.join(', ')}]"
121
+ else
122
+ "except: [#{(RESOURCE_ACTIONS - present).sort.map { |x| ":#{x}" }.join(', ')}]"
123
+ end
98
124
  end
99
125
 
100
126
  def issues
101
- all_issues.select(&:issue?)
127
+ route_calls.select(&:issue?)
102
128
  end
103
129
 
104
130
  def non_issues
105
- all_issues.reject(&:issue?)
131
+ route_calls.reject(&:issue?)
106
132
  end
107
133
 
108
134
  def all_unique_issues_file_names
109
- all_issues.map { |issue| issue.full_filename }.uniq.sort
110
- end
111
-
112
- def unique_issues_file_names
113
- issues.map { |issue| issue.full_filename }.uniq.sort
135
+ issues.map(&:full_filename).uniq.sort
114
136
  end
115
137
 
116
- def all_issues_for_file_name(full_filename)
117
- all_issues.select { |issue| issue.full_filename == full_filename.to_s }
138
+ def route_calls_for_file_name(full_filename)
139
+ route_calls.select { |record| record.full_filename == full_filename.to_s }
118
140
  end
119
141
 
120
142
  def implemented_routes
121
143
  Set.new.tap do |implemented_routes|
122
- non_issues.each do |non_issue|
123
- non_issue.present_actions.each do |action|
124
- implemented_routes << [non_issue.controller_class_name, action]
144
+ route_calls.each do |route_call|
145
+ (route_call.present_actions || []).each do |action|
146
+ implemented_routes << [route_call.controller_class_name, action]
125
147
  end
126
148
  end
127
149
  end
128
150
  end
151
+
152
+ def route_lines_for_file(full_filename)
153
+ route_lines.select { |line| line.full_filename == full_filename.to_s }
154
+ end
155
+
156
+ def print_report
157
+ if issues.empty?
158
+ puts "No route issues found"
159
+ return
160
+ end
161
+
162
+ issues.each do |issue|
163
+ puts issue.human_readable_error(verbose: verbose)
164
+ end
165
+ end
129
166
  end
130
167
 
131
168
  end