rails_routes_analyzer 1.0.4 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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