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.
- checksums.yaml +4 -4
- data/.autotest +3 -0
- data/.gitignore +2 -0
- data/.rubocop.yml +141 -0
- data/LICENSE.txt +1 -1
- data/README.md +45 -13
- data/Rakefile +1 -1
- data/lib/rails_routes_analyzer.rb +7 -6
- data/lib/rails_routes_analyzer/action_analysis.rb +276 -0
- data/lib/rails_routes_analyzer/gem_manager.rb +52 -0
- data/lib/rails_routes_analyzer/parameter_handler.rb +41 -0
- data/lib/rails_routes_analyzer/rails_routes_analyzer.rb +69 -30
- data/lib/rails_routes_analyzer/railtie.rb +11 -0
- data/lib/rails_routes_analyzer/route_analysis.rb +104 -67
- data/lib/rails_routes_analyzer/route_call.rb +65 -0
- data/lib/rails_routes_analyzer/route_file_annotator.rb +117 -37
- data/lib/rails_routes_analyzer/route_interceptor.rb +12 -13
- data/lib/rails_routes_analyzer/route_issue.rb +4 -109
- data/lib/rails_routes_analyzer/route_issue/base.rb +68 -0
- data/lib/rails_routes_analyzer/route_issue/no_action.rb +37 -0
- data/lib/rails_routes_analyzer/route_issue/no_controller.rb +30 -0
- data/lib/rails_routes_analyzer/route_issue/resources.rb +133 -0
- data/lib/rails_routes_analyzer/route_line.rb +85 -0
- data/lib/rails_routes_analyzer/version.rb +1 -1
- data/lib/tasks/rails_routes_analyzer.rake +28 -50
- data/rails_routes_analyzer.gemspec +13 -4
- metadata +129 -18
@@ -0,0 +1,65 @@
|
|
1
|
+
module RailsRoutesAnalyzer
|
2
|
+
|
3
|
+
# Represents both positive and negative information collected
|
4
|
+
# about a specific call that generated Rails routes.
|
5
|
+
#
|
6
|
+
# If called in a loop each iteration generates a new record.
|
7
|
+
class RouteCall < Hash
|
8
|
+
def self.fields(*names)
|
9
|
+
names.each { |name| define_method(name) { self[name] } }
|
10
|
+
end
|
11
|
+
|
12
|
+
fields \
|
13
|
+
:action,
|
14
|
+
:action_names,
|
15
|
+
:controller_class_name,
|
16
|
+
:controller_name,
|
17
|
+
:file_location,
|
18
|
+
:route_creation_method,
|
19
|
+
:present_actions
|
20
|
+
|
21
|
+
def initialize(**kwargs)
|
22
|
+
update(kwargs)
|
23
|
+
end
|
24
|
+
|
25
|
+
def issues
|
26
|
+
self[:issues] ||= []
|
27
|
+
end
|
28
|
+
|
29
|
+
def add_issue(issue)
|
30
|
+
issue.route_call = self
|
31
|
+
issues << issue
|
32
|
+
end
|
33
|
+
|
34
|
+
def issue?
|
35
|
+
issues.any?
|
36
|
+
end
|
37
|
+
|
38
|
+
def present_actions?
|
39
|
+
present_actions.present?
|
40
|
+
end
|
41
|
+
|
42
|
+
def full_filename
|
43
|
+
@full_filename ||= RailsRoutesAnalyzer.get_full_filename(file_location.sub(/:[0-9]*\z/, ''))
|
44
|
+
end
|
45
|
+
|
46
|
+
def line_number
|
47
|
+
@line_number ||= file_location[/:([0-9]+)\z/, 1].to_i
|
48
|
+
end
|
49
|
+
|
50
|
+
def suggestion(**kwargs)
|
51
|
+
issues.map { |issue| issue.suggestion(**kwargs) }.join('; ')
|
52
|
+
end
|
53
|
+
|
54
|
+
def human_readable_error(**kwargs)
|
55
|
+
issues.map { |issue| issue.human_readable_error(**kwargs) }.join('; ')
|
56
|
+
end
|
57
|
+
|
58
|
+
def try_to_fix_line(line)
|
59
|
+
return if issues.size != 1
|
60
|
+
|
61
|
+
issues[0].try_to_fix_line(line)
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
end
|
@@ -1,73 +1,153 @@
|
|
1
1
|
module RailsRoutesAnalyzer
|
2
2
|
|
3
3
|
class RouteFileAnnotator
|
4
|
-
|
5
|
-
|
4
|
+
# @param try_to_fix [Boolean] should automatic fixes be attempted
|
5
|
+
# @param allow_deleting [Boolean] should route lines be deleted when they match no actions
|
6
|
+
# @param force_overwrite [Boolean] allow overwriting routes file even if it has uncommited changes or is outside Rails.root
|
7
|
+
def initialize(try_to_fix: false, allow_deleting: false, force_overwrite: false, analysis: nil, **kwargs)
|
8
|
+
@analysis = analysis || RailsRoutesAnalyzer::RouteAnalysis.new(**kwargs)
|
9
|
+
@try_to_fix = try_to_fix
|
10
|
+
@allow_deleting = allow_deleting
|
11
|
+
@force_overwrite = force_overwrite
|
6
12
|
end
|
7
13
|
|
8
14
|
def annotated_file_content(route_filename)
|
9
|
-
|
15
|
+
route_lines = @analysis.route_lines_for_file(route_filename)
|
10
16
|
|
11
|
-
if
|
12
|
-
log_notice { "Didn't find any route issues for file: #{route_filename}, only
|
17
|
+
if route_lines.none?(&:issues?)
|
18
|
+
log_notice { "Didn't find any route issues for file: #{route_filename}, only found issues in files: #{@analysis.all_unique_issues_file_names.join(', ')}" }
|
13
19
|
end
|
14
20
|
|
15
21
|
log_notice { "Annotating #{route_filename}" }
|
16
22
|
|
17
|
-
|
18
|
-
issue_map = relevant_issues.group_by { |issue| issue.line_number }
|
23
|
+
route_lines_map = route_lines.index_by(&:line_number)
|
19
24
|
|
20
25
|
"".tap do |output|
|
21
26
|
File.readlines(route_filename).each_with_index do |line, index|
|
22
|
-
|
27
|
+
route_line = route_lines_map[index + 1]
|
28
|
+
|
29
|
+
output <<
|
30
|
+
if route_line
|
31
|
+
route_line.annotate(line,
|
32
|
+
try_to_fix: @try_to_fix,
|
33
|
+
allow_deleting: @allow_deleting)
|
34
|
+
else
|
35
|
+
line
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
def annotate_routes_file(route_filename, inplace: false, do_exit: true)
|
42
|
+
filenames = files_to_work_on(route_filename, inplace: inplace)
|
43
|
+
if filenames.is_a?(Integer)
|
44
|
+
exit filenames if do_exit
|
45
|
+
return filenames
|
46
|
+
end
|
47
|
+
|
48
|
+
filenames.map! { |file| RailsRoutesAnalyzer.get_full_filename(file) }
|
23
49
|
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
50
|
+
filenames.each do |filename|
|
51
|
+
unless File.exist?(filename)
|
52
|
+
$stderr.puts "Can't find routes file: #{filename}"
|
53
|
+
exit 1
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
if filenames.size != 1 && !inplace
|
58
|
+
raise ArgumentError, "got #{filenames.size} files but can annotate only one at a time to stdout"
|
59
|
+
end
|
60
|
+
|
61
|
+
filenames.each do |filename|
|
62
|
+
if inplace
|
63
|
+
if @force_overwrite || self.class.check_file_is_modifiable(filename, report: true)
|
64
|
+
content = annotated_file_content(filename)
|
65
|
+
File.open(filename, 'w') { |f| f.write content }
|
28
66
|
end
|
67
|
+
else
|
68
|
+
puts annotated_file_content(filename)
|
29
69
|
end
|
30
70
|
end
|
31
71
|
end
|
32
72
|
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
73
|
+
protected
|
74
|
+
|
75
|
+
def log_notice(*args, &block)
|
76
|
+
self.class.log_notice(*args, &block)
|
37
77
|
end
|
38
78
|
|
39
|
-
|
40
|
-
|
79
|
+
class << self
|
80
|
+
def log_notice(message = nil)
|
81
|
+
return if ENV['RAILS_ENV'] == 'test'
|
82
|
+
message ||= yield if block_given?
|
83
|
+
$stderr.puts "# #{message}" if message.present?
|
84
|
+
end
|
41
85
|
|
42
|
-
|
86
|
+
def check_file_is_modifiable(filename, report: false, **kwargs)
|
87
|
+
unless filename.to_s.starts_with?(Rails.root.to_s)
|
88
|
+
log_notice "Refusing to modify files outside Rails root: #{Rails.root}" if report
|
89
|
+
return false
|
90
|
+
end
|
43
91
|
|
44
|
-
|
45
|
-
|
46
|
-
num_controllers: all_issues.map(&:controller_class_name).uniq.count,
|
47
|
-
}
|
92
|
+
check_file_git_status(filename, report: report, **kwargs)
|
93
|
+
end
|
48
94
|
|
49
|
-
|
50
|
-
|
95
|
+
def check_file_git_status(filename, report: false, skip_git: false, repo_root: Rails.root.to_s)
|
96
|
+
return skip_git if skip_git
|
51
97
|
|
52
|
-
|
53
|
-
|
98
|
+
git = nil
|
99
|
+
begin
|
100
|
+
require 'git'
|
101
|
+
git = Git.open(repo_root)
|
102
|
+
rescue => e
|
103
|
+
log_notice "Couldn't access git repository at Rails root #{repo_root}. #{e.message}" if report
|
104
|
+
return false
|
105
|
+
end
|
54
106
|
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
107
|
+
repo_relative_filename = filename.to_s.sub("#{repo_root}/", '')
|
108
|
+
|
109
|
+
# This seems to be required to force some kind of git status
|
110
|
+
# refresh because without it tests would randomly detect a file
|
111
|
+
# as modified by git-status when the file in fact has no changes.
|
112
|
+
#
|
113
|
+
# Currently randomly causes a NoMethodError exception but can still help.
|
114
|
+
begin
|
115
|
+
git.diff.each { |file| }
|
116
|
+
rescue NoMethodError # rubocop:disable Lint/HandleExceptions
|
117
|
+
end
|
118
|
+
|
119
|
+
if git.status.changed.key?(repo_relative_filename)
|
120
|
+
log_notice "Refusing to modify '#{repo_relative_filename}' as it has uncommited changes" if report
|
121
|
+
return false
|
59
122
|
end
|
60
|
-
|
123
|
+
|
124
|
+
true
|
61
125
|
end
|
126
|
+
end
|
62
127
|
|
63
|
-
|
128
|
+
def annotatable_routes_files(inplace:)
|
129
|
+
filenames = @analysis.all_unique_issues_file_names
|
64
130
|
|
65
|
-
|
66
|
-
|
67
|
-
exit 1
|
131
|
+
if inplace && !@force_overwrite
|
132
|
+
filenames.select! { |filename| self.class.check_file_is_modifiable(filename) }
|
68
133
|
end
|
69
134
|
|
70
|
-
|
135
|
+
filenames
|
136
|
+
end
|
137
|
+
|
138
|
+
def files_to_work_on(filename, inplace:)
|
139
|
+
return [filename] if filename.present?
|
140
|
+
|
141
|
+
filenames = annotatable_routes_files(inplace: inplace)
|
142
|
+
|
143
|
+
if filenames.empty?
|
144
|
+
$stderr.puts "All routes are good, nothing to annotate"
|
145
|
+
return 0
|
146
|
+
elsif filenames.size > 1 && !inplace
|
147
|
+
$stderr.puts "Please specify routes file with ROUTES_FILE='path/routes.rb' as you have more than one file with problems:\n #{filenames.join("\n ")}"
|
148
|
+
return 1
|
149
|
+
end
|
150
|
+
filenames
|
71
151
|
end
|
72
152
|
end
|
73
153
|
|
@@ -1,15 +1,15 @@
|
|
1
|
-
require 'rails'
|
1
|
+
require 'rails/version'
|
2
2
|
|
3
3
|
module RailsRoutesAnalyzer
|
4
4
|
|
5
5
|
# Plugs into ActionDispatch::Routing::Mapper::Mapping to help get detailed information
|
6
6
|
# on which route was generated, exactly where and if there is a matching controller action
|
7
7
|
module RouteInterceptor
|
8
|
-
ROUTE_METHOD_REGEX = /
|
8
|
+
ROUTE_METHOD_REGEX = %r{action_dispatch/routing/mapper.rb:[0-9]+:in `(#{Regexp.union(*::RailsRoutesAnalyzer::ROUTE_METHODS)})'\z}
|
9
9
|
|
10
10
|
def self.route_data
|
11
11
|
{}.tap do |result|
|
12
|
-
route_log.each do |(location,
|
12
|
+
route_log.each do |(location, _controller_name, action, _request_methods)|
|
13
13
|
(result[location] ||= []) << action
|
14
14
|
end
|
15
15
|
end
|
@@ -20,36 +20,35 @@ module RailsRoutesAnalyzer
|
|
20
20
|
end
|
21
21
|
|
22
22
|
# Finds the most interesting Rails.root file from the backtrace that called a method in mapper.rb
|
23
|
-
def
|
23
|
+
def routes_rb_location
|
24
24
|
bt = caller
|
25
25
|
base = 0
|
26
|
-
|
27
|
-
index = bt[base..-1].index {|l| l =~ ROUTE_METHOD_REGEX }
|
26
|
+
loop do
|
27
|
+
index = bt[base..-1].index { |l| l =~ ROUTE_METHOD_REGEX }
|
28
28
|
return "" if index.nil?
|
29
29
|
|
30
30
|
next_line = bt[base + index + 1]
|
31
31
|
|
32
|
-
if next_line =~ /
|
32
|
+
if next_line =~ %r{action_dispatch/routing/mapper.rb}
|
33
33
|
base += index + 1
|
34
34
|
next
|
35
35
|
else
|
36
|
-
file_location = next_line[
|
36
|
+
file_location = next_line[%r{:?\A#{Rails.root}\/(.*:[0-9]+)}, 1] || next_line
|
37
37
|
|
38
|
-
bt[base + index]
|
39
|
-
route_creation_method = $1
|
38
|
+
route_creation_method = bt[base + index][ROUTE_METHOD_REGEX, 1]
|
40
39
|
|
41
40
|
return [file_location, route_creation_method]
|
42
41
|
end
|
43
42
|
end
|
44
43
|
end
|
45
44
|
|
46
|
-
if Rails
|
45
|
+
if Rails::VERSION::MAJOR == 3
|
47
46
|
def initialize(*args)
|
48
47
|
super.tap do
|
49
48
|
record_route(@options[:controller], @options[:action], conditions[:request_method])
|
50
49
|
end
|
51
50
|
end
|
52
|
-
elsif Rails
|
51
|
+
elsif Rails::VERSION::MAJOR == 4
|
53
52
|
def initialize(*args)
|
54
53
|
super.tap do
|
55
54
|
record_route(@defaults[:controller], @defaults[:action], conditions[:request_method])
|
@@ -66,7 +65,7 @@ module RailsRoutesAnalyzer
|
|
66
65
|
def record_route(controller_name, action, request_methods)
|
67
66
|
return unless controller_name && action
|
68
67
|
|
69
|
-
location =
|
68
|
+
location = routes_rb_location + [controller_name]
|
70
69
|
|
71
70
|
if location[0].nil?
|
72
71
|
puts "Failed to find call location for: #{controller_name}/#{action}"
|
@@ -1,109 +1,4 @@
|
|
1
|
-
|
2
|
-
|
3
|
-
|
4
|
-
|
5
|
-
action
|
6
|
-
action_names
|
7
|
-
missing_actions
|
8
|
-
present_actions
|
9
|
-
controller_class_name
|
10
|
-
controller_name
|
11
|
-
error
|
12
|
-
file_location
|
13
|
-
route_creation_method
|
14
|
-
suggested_param
|
15
|
-
type
|
16
|
-
verbose_message
|
17
|
-
].each do |name|
|
18
|
-
define_method(name) { self[name] }
|
19
|
-
end
|
20
|
-
|
21
|
-
def initialize(opts={})
|
22
|
-
self.update(opts)
|
23
|
-
end
|
24
|
-
|
25
|
-
def issue?
|
26
|
-
type != :non_issue
|
27
|
-
end
|
28
|
-
|
29
|
-
def resource?
|
30
|
-
type == :resources
|
31
|
-
end
|
32
|
-
|
33
|
-
def full_filename
|
34
|
-
RailsRoutesAnalyzer.get_full_filename(file_location.sub(/:[0-9]*\z/, ''))
|
35
|
-
end
|
36
|
-
|
37
|
-
def line_number
|
38
|
-
file_location[/:([0-9]+)\z/, 1].to_i
|
39
|
-
end
|
40
|
-
|
41
|
-
def human_readable
|
42
|
-
case self[:type]
|
43
|
-
when :non_issue
|
44
|
-
''
|
45
|
-
when :no_controller
|
46
|
-
"`#{route_creation_method}' call at #{file_location} there is no controller: #{controller_class_name} for '#{controller_name}' (actions: #{action_names.inspect})".tap do |msg|
|
47
|
-
msg << " error: #{error}" if error.present?
|
48
|
-
end
|
49
|
-
when :no_action
|
50
|
-
missing_actions.map do |action|
|
51
|
-
"`#{route_creation_method} :#{action}' call at #{file_location} there is no matching action in #{controller_class_name}"
|
52
|
-
end.tap do |result|
|
53
|
-
return nil if result.size == 0
|
54
|
-
return result[0] if result.size == 1
|
55
|
-
end
|
56
|
-
when :resources
|
57
|
-
"`#{route_creation_method}' call at #{file_location} for #{controller_class_name} should use #{suggested_param}"
|
58
|
-
else
|
59
|
-
raise ArgumentError, "Unknown issue_type: #{self[:type].inspect}"
|
60
|
-
end.tap do |message|
|
61
|
-
message << "| #{verbose_message}" if verbose_message
|
62
|
-
end
|
63
|
-
end
|
64
|
-
|
65
|
-
def suggestion(non_issues:, num_controllers:)
|
66
|
-
case self[:type]
|
67
|
-
when :non_issue
|
68
|
-
nil
|
69
|
-
when :no_controller
|
70
|
-
if non_issues
|
71
|
-
"remove case for #{controller_class_name} as it doesn't exist"
|
72
|
-
else
|
73
|
-
"delete, #{controller_class_name} not found"
|
74
|
-
end
|
75
|
-
when :no_action
|
76
|
-
actions = format_actions(missing_actions)
|
77
|
-
if non_issues
|
78
|
-
"remove case#{'s' if missing_actions.size > 1} for #{actions}"
|
79
|
-
else
|
80
|
-
"delete line, #{actions} matches nothing"
|
81
|
-
end.tap do |message|
|
82
|
-
message << " for controller #{controller_class_name}" if num_controllers > 1
|
83
|
-
end
|
84
|
-
when :resources
|
85
|
-
"use #{suggested_param}".tap do |message|
|
86
|
-
if num_controllers > 1
|
87
|
-
message << " only for #{controller_class_name}"
|
88
|
-
end
|
89
|
-
end
|
90
|
-
else
|
91
|
-
raise ArgumentError, "Unknown issue_type: #{self[:type].inspect}"
|
92
|
-
end.tap do |message|
|
93
|
-
message << "| #{verbose_message}" if verbose_message
|
94
|
-
end
|
95
|
-
end
|
96
|
-
|
97
|
-
def format_actions(actions)
|
98
|
-
case actions.size
|
99
|
-
when 0
|
100
|
-
when 1
|
101
|
-
":#{actions.first}"
|
102
|
-
else
|
103
|
-
list = actions.map { |action| ":#{action}" }.sort.join(', ')
|
104
|
-
"[#{list}]"
|
105
|
-
end
|
106
|
-
end
|
107
|
-
end
|
108
|
-
|
109
|
-
end
|
1
|
+
require_relative 'route_issue/base'
|
2
|
+
require_relative 'route_issue/no_action'
|
3
|
+
require_relative 'route_issue/no_controller'
|
4
|
+
require_relative 'route_issue/resources'
|
@@ -0,0 +1,68 @@
|
|
1
|
+
require_relative '../route_call'
|
2
|
+
|
3
|
+
module RailsRoutesAnalyzer
|
4
|
+
module RouteIssue
|
5
|
+
|
6
|
+
class Base < Hash
|
7
|
+
def self.fields(*names)
|
8
|
+
names.each do |name|
|
9
|
+
define_method(name) { self[name] }
|
10
|
+
define_method("#{name}=") { |val| self[name] = val }
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
fields :route_call
|
15
|
+
|
16
|
+
delegate \
|
17
|
+
:action,
|
18
|
+
:action_names,
|
19
|
+
:controller_class_name,
|
20
|
+
:controller_name,
|
21
|
+
:file_location,
|
22
|
+
:route_creation_method,
|
23
|
+
:present_actions,
|
24
|
+
to: :route_call
|
25
|
+
|
26
|
+
def initialize(opts = {})
|
27
|
+
update(opts)
|
28
|
+
end
|
29
|
+
|
30
|
+
def human_readable_error(verbose: false)
|
31
|
+
human_readable_error_message.tap do |message|
|
32
|
+
append_verbose_message(message) if verbose
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
def verbose_message
|
37
|
+
end
|
38
|
+
|
39
|
+
def suggestion(verbose: false, **kwargs)
|
40
|
+
error_suggestion(**kwargs).tap do |message|
|
41
|
+
append_verbose_message(message) if verbose
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
def append_verbose_message(message)
|
46
|
+
verbose = verbose_message
|
47
|
+
message << "| #{verbose}" if verbose.present?
|
48
|
+
end
|
49
|
+
|
50
|
+
def try_to_fix_line(_line)
|
51
|
+
raise NotImplementedError, 'should be provided by subclasses'
|
52
|
+
end
|
53
|
+
|
54
|
+
def format_actions(actions)
|
55
|
+
case actions.size
|
56
|
+
when 0
|
57
|
+
nil
|
58
|
+
when 1
|
59
|
+
":#{actions.first}"
|
60
|
+
else
|
61
|
+
list = actions.map { |action| ":#{action}" }.sort.join(', ')
|
62
|
+
"[#{list}]"
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
end
|
68
|
+
end
|