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.
- 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
|