route_guard 0.1.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 +7 -0
- data/.gitignore +13 -0
- data/Gemfile +5 -0
- data/Gemfile.lock +173 -0
- data/README.md +57 -0
- data/Rakefile +10 -0
- data/bin/route_guard +14 -0
- data/docs/publishing.md +76 -0
- data/docs/usage.md +81 -0
- data/lib/route_guard/analyzer.rb +69 -0
- data/lib/route_guard/cli.rb +123 -0
- data/lib/route_guard/configuration.rb +38 -0
- data/lib/route_guard/formatter/ci.rb +20 -0
- data/lib/route_guard/formatter/html.rb +297 -0
- data/lib/route_guard/formatter/json.rb +61 -0
- data/lib/route_guard/formatter/terminal.rb +95 -0
- data/lib/route_guard/inspector.rb +58 -0
- data/lib/route_guard/models/issue.rb +30 -0
- data/lib/route_guard/models/report.rb +29 -0
- data/lib/route_guard/models/route.rb +42 -0
- data/lib/route_guard/railtie.rb +12 -0
- data/lib/route_guard/route_loader.rb +110 -0
- data/lib/route_guard/route_tracker.rb +141 -0
- data/lib/route_guard/rules/base.rb +163 -0
- data/lib/route_guard/rules/complexity.rb +62 -0
- data/lib/route_guard/rules/duplicate_helpers.rb +43 -0
- data/lib/route_guard/rules/duplicate_resources.rb +56 -0
- data/lib/route_guard/rules/duplicate_routes.rb +40 -0
- data/lib/route_guard/rules/shadowed_routes.rb +78 -0
- data/lib/route_guard/rules/statistics.rb +81 -0
- data/lib/route_guard/rules/unreachable_routes.rb +42 -0
- data/lib/route_guard/rules/unused_routes.rb +66 -0
- data/lib/route_guard/version.rb +5 -0
- data/lib/route_guard.rb +14 -0
- data/lib/tasks/route_guard.rake +30 -0
- metadata +195 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "models/route"
|
|
4
|
+
require_relative "route_tracker"
|
|
5
|
+
|
|
6
|
+
module RouteGuard
|
|
7
|
+
class RouteLoader
|
|
8
|
+
def self.load
|
|
9
|
+
load_environment!
|
|
10
|
+
|
|
11
|
+
RouteTracker.reset!
|
|
12
|
+
RouteTracker.enable_tracking!
|
|
13
|
+
RouteTracker.enabled = true
|
|
14
|
+
|
|
15
|
+
begin
|
|
16
|
+
$stderr.puts "RouteGuard Debug: defined?(Rails) = #{defined?(Rails)}"
|
|
17
|
+
if defined?(Rails)
|
|
18
|
+
$stderr.puts "RouteGuard Debug: Rails.respond_to?(:application) = #{Rails.respond_to?(:application)}"
|
|
19
|
+
if Rails.respond_to?(:application) && Rails.application
|
|
20
|
+
$stderr.puts "RouteGuard Debug: Rails.application = #{Rails.application.class.name}"
|
|
21
|
+
$stderr.puts "RouteGuard Debug: Routes size before reload = #{Rails.application.routes.routes.size}"
|
|
22
|
+
Rails.application.reload_routes!
|
|
23
|
+
$stderr.puts "RouteGuard Debug: Routes size after reload = #{Rails.application.routes.routes.size}"
|
|
24
|
+
else
|
|
25
|
+
$stderr.puts "RouteGuard Debug: Rails.application = nil/undefined"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
if defined?(Rails) && Rails.respond_to?(:application) && Rails.application
|
|
30
|
+
load_from_route_set(Rails.application.routes)
|
|
31
|
+
else
|
|
32
|
+
[]
|
|
33
|
+
end
|
|
34
|
+
ensure
|
|
35
|
+
RouteTracker.enabled = false
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.load_environment!
|
|
40
|
+
return if defined?(Rails) && Rails.respond_to?(:application) && Rails.application
|
|
41
|
+
|
|
42
|
+
env_file = File.expand_path("config/environment.rb", Dir.pwd)
|
|
43
|
+
if File.exist?(env_file)
|
|
44
|
+
original_stdout = $stdout.clone
|
|
45
|
+
original_stderr = $stderr.clone
|
|
46
|
+
begin
|
|
47
|
+
$stdout.reopen(File::NULL, "w")
|
|
48
|
+
$stderr.reopen(File::NULL, "w")
|
|
49
|
+
require env_file
|
|
50
|
+
ensure
|
|
51
|
+
$stdout.reopen(original_stdout)
|
|
52
|
+
$stderr.reopen(original_stderr)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def self.load_from_route_set(route_set)
|
|
58
|
+
routes = []
|
|
59
|
+
route_set.routes.each do |r|
|
|
60
|
+
# Skip internal rails routes if the route itself responds to internal?
|
|
61
|
+
next if r.respond_to?(:internal?) && r.internal?
|
|
62
|
+
|
|
63
|
+
# Extract verb
|
|
64
|
+
verb = if r.verb.is_a?(Regexp)
|
|
65
|
+
r.verb.source.gsub(/[^A-Z|]/, "")
|
|
66
|
+
else
|
|
67
|
+
r.verb.to_s.upcase
|
|
68
|
+
end
|
|
69
|
+
verb = "ANY" if verb.empty?
|
|
70
|
+
|
|
71
|
+
# Extract path
|
|
72
|
+
original_path = if r.respond_to?(:path) && r.path.respond_to?(:spec)
|
|
73
|
+
r.path.spec.to_s
|
|
74
|
+
else
|
|
75
|
+
r.path.to_s
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
path = original_path.gsub(/\(\.\:format\)\z/, "").gsub(/\(\/:format\)\z/, "")
|
|
79
|
+
|
|
80
|
+
defaults = r.defaults || {}
|
|
81
|
+
controller = defaults[:controller]
|
|
82
|
+
action = defaults[:action]
|
|
83
|
+
name = r.name
|
|
84
|
+
constraints = r.requirements || {}
|
|
85
|
+
|
|
86
|
+
trace_info = RouteTracker.route_traces[r.object_id] || r.instance_variable_get(:@route_guard_trace)
|
|
87
|
+
file = trace_info&.[](:file)
|
|
88
|
+
line = trace_info&.[](:line)
|
|
89
|
+
|
|
90
|
+
route = Models::Route.new(
|
|
91
|
+
verb: verb,
|
|
92
|
+
path: path,
|
|
93
|
+
original_path: original_path,
|
|
94
|
+
controller: controller,
|
|
95
|
+
action: action,
|
|
96
|
+
name: name,
|
|
97
|
+
constraints: constraints,
|
|
98
|
+
file: file,
|
|
99
|
+
line: line,
|
|
100
|
+
defaults: defaults
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
next if route.internal?
|
|
104
|
+
|
|
105
|
+
routes << route
|
|
106
|
+
end
|
|
107
|
+
routes
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RouteGuard
|
|
4
|
+
module RouteTracker
|
|
5
|
+
class << self
|
|
6
|
+
attr_accessor :enabled
|
|
7
|
+
|
|
8
|
+
def route_traces
|
|
9
|
+
@route_traces ||= {}
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def resource_calls
|
|
13
|
+
@resource_calls ||= []
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def reset!
|
|
17
|
+
@route_traces = {}
|
|
18
|
+
@resource_calls = []
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def track_route(route_object_id, trace_info)
|
|
22
|
+
return unless @enabled
|
|
23
|
+
route_traces[route_object_id] = trace_info
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def track_resource(type, args, scope, trace_info)
|
|
27
|
+
return unless @enabled
|
|
28
|
+
resource_calls << {
|
|
29
|
+
type: type,
|
|
30
|
+
args: args,
|
|
31
|
+
scope: scope,
|
|
32
|
+
trace: trace_info
|
|
33
|
+
}
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def enable_tracking!
|
|
37
|
+
return if @prepended
|
|
38
|
+
|
|
39
|
+
if defined?(ActionDispatch::Routing::RouteSet)
|
|
40
|
+
ActionDispatch::Routing::RouteSet.prepend(RouteGuard::RouteSetExtension)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
if defined?(ActionDispatch::Routing::Mapper)
|
|
44
|
+
ActionDispatch::Routing::Mapper.prepend(RouteGuard::MapperExtension)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
if defined?(ActionDispatch::Routing::RouteSet::NamedRouteCollection)
|
|
48
|
+
ActionDispatch::Routing::RouteSet::NamedRouteCollection.prepend(RouteGuard::NamedRouteCollectionExtension)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
@prepended = true
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def find_routes_caller(locations)
|
|
55
|
+
return nil unless locations
|
|
56
|
+
loc = locations.find do |l|
|
|
57
|
+
path = l.path
|
|
58
|
+
path.include?("config/routes") ||
|
|
59
|
+
path.include?("/routes.rb") ||
|
|
60
|
+
(defined?(RSpec) && path.include?("_spec.rb"))
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
if loc
|
|
64
|
+
{ file: loc.path, line: loc.lineno }
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
module RouteSetExtension
|
|
71
|
+
def add_route(*args)
|
|
72
|
+
route = super
|
|
73
|
+
if RouteGuard::RouteTracker.enabled
|
|
74
|
+
trace_info = RouteGuard::RouteTracker.find_routes_caller(caller_locations(1, 25))
|
|
75
|
+
if trace_info
|
|
76
|
+
if route.is_a?(Array)
|
|
77
|
+
route.each do |r|
|
|
78
|
+
RouteGuard::RouteTracker.track_route(r.object_id, trace_info)
|
|
79
|
+
r.instance_variable_set(:@route_guard_trace, trace_info)
|
|
80
|
+
end
|
|
81
|
+
elsif route
|
|
82
|
+
RouteGuard::RouteTracker.track_route(route.object_id, trace_info)
|
|
83
|
+
route.instance_variable_set(:@route_guard_trace, trace_info)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
route
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
module MapperExtension
|
|
92
|
+
def resources(*args, &block)
|
|
93
|
+
if RouteGuard::RouteTracker.enabled
|
|
94
|
+
trace_info = RouteGuard::RouteTracker.find_routes_caller(caller_locations(1, 25))
|
|
95
|
+
if trace_info
|
|
96
|
+
RouteGuard::RouteTracker.track_resource(:resources, args, scope_info, trace_info)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
super
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def resource(*args, &block)
|
|
103
|
+
if RouteGuard::RouteTracker.enabled
|
|
104
|
+
trace_info = RouteGuard::RouteTracker.find_routes_caller(caller_locations(1, 25))
|
|
105
|
+
if trace_info
|
|
106
|
+
RouteGuard::RouteTracker.track_resource(:resource, args, scope_info, trace_info)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
super
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
private
|
|
113
|
+
|
|
114
|
+
def scope_info
|
|
115
|
+
return {} unless defined?(@scope) && @scope
|
|
116
|
+
{
|
|
117
|
+
path: @scope[:path],
|
|
118
|
+
module: @scope[:module],
|
|
119
|
+
as: @scope[:as]
|
|
120
|
+
}
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
module NamedRouteCollectionExtension
|
|
125
|
+
def [](name)
|
|
126
|
+
if RouteGuard::RouteTracker.enabled
|
|
127
|
+
nil
|
|
128
|
+
else
|
|
129
|
+
super
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def key?(name)
|
|
134
|
+
if RouteGuard::RouteTracker.enabled
|
|
135
|
+
false
|
|
136
|
+
else
|
|
137
|
+
super
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RouteGuard
|
|
4
|
+
module Rules
|
|
5
|
+
class Base
|
|
6
|
+
attr_reader :options
|
|
7
|
+
|
|
8
|
+
def initialize(options = {})
|
|
9
|
+
@options = options
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def analyze(routes, report)
|
|
13
|
+
raise NotImplementedError, "#{self.class} must implement #analyze(routes, report)"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Expands optional path segments in route paths
|
|
17
|
+
# e.g., "/users(.:format)" -> ["/users", "/users.:format"]
|
|
18
|
+
# "/posts(/:year(/:month))" -> ["/posts", "/posts/:year", "/posts/:year/:month"]
|
|
19
|
+
def expand_path(path)
|
|
20
|
+
start_idx = path.index("(")
|
|
21
|
+
return [path] unless start_idx
|
|
22
|
+
|
|
23
|
+
# Find matching ')' at top level
|
|
24
|
+
depth = 0
|
|
25
|
+
end_idx = nil
|
|
26
|
+
path.chars.each_with_index do |char, idx|
|
|
27
|
+
if char == "("
|
|
28
|
+
depth += 1
|
|
29
|
+
elsif char == ")"
|
|
30
|
+
depth -= 1
|
|
31
|
+
if depth == 0
|
|
32
|
+
end_idx = idx
|
|
33
|
+
break
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
raise "Unmatched parenthesis in path: #{path}" unless end_idx
|
|
39
|
+
|
|
40
|
+
before = path[0...start_idx]
|
|
41
|
+
inside = path[(start_idx + 1)...end_idx]
|
|
42
|
+
after = path[(end_idx + 1)..-1]
|
|
43
|
+
|
|
44
|
+
inside_expansions = expand_path(inside)
|
|
45
|
+
after_expansions = expand_path(after)
|
|
46
|
+
|
|
47
|
+
results = []
|
|
48
|
+
# Case 1: Omit the inside group
|
|
49
|
+
after_expansions.each do |after_exp|
|
|
50
|
+
results << (before + after_exp)
|
|
51
|
+
end
|
|
52
|
+
# Case 2: Include the inside group
|
|
53
|
+
inside_expansions.each do |inside_exp|
|
|
54
|
+
after_expansions.each do |after_exp|
|
|
55
|
+
results << (before + inside_exp + after_exp)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
results.uniq
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Splits a path string into segment tokens, handling dots and slashes
|
|
63
|
+
def split_segments(path)
|
|
64
|
+
segments = []
|
|
65
|
+
path.split("/").each do |seg|
|
|
66
|
+
if seg.include?(".")
|
|
67
|
+
parts = seg.split(".")
|
|
68
|
+
segments << parts.first
|
|
69
|
+
parts[1..-1].each { |p| segments << ".#{p}" }
|
|
70
|
+
else
|
|
71
|
+
segments << seg
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
segments
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Checks if verbA matches verbB (handling ANY/wildcard)
|
|
78
|
+
def verb_matches?(verbA, verbB)
|
|
79
|
+
return true if verbA == "ANY" || verbB == "ANY"
|
|
80
|
+
|
|
81
|
+
verbsA = verbA.split("|")
|
|
82
|
+
verbsB = verbB.split("|")
|
|
83
|
+
(verbsA & verbsB).any?
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Checks if Route A matches (shadows) Route B's path representation.
|
|
87
|
+
def path_shadows?(segA, segB, constraintsA, constraintsB)
|
|
88
|
+
idxA = 0
|
|
89
|
+
idxB = 0
|
|
90
|
+
|
|
91
|
+
while idxA < segA.length && idxB < segB.length
|
|
92
|
+
sA = segA[idxA]
|
|
93
|
+
sB = segB[idxB]
|
|
94
|
+
|
|
95
|
+
if sA.start_with?("*")
|
|
96
|
+
return true
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
if sB.start_with?("*")
|
|
100
|
+
return false
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
if sA.start_with?(":")
|
|
104
|
+
param_A = sA[1..-1]
|
|
105
|
+
constA = constraintsA[param_A.to_sym]
|
|
106
|
+
|
|
107
|
+
if sB.start_with?(":")
|
|
108
|
+
param_B = sB[1..-1]
|
|
109
|
+
constB = constraintsB[param_B.to_sym]
|
|
110
|
+
|
|
111
|
+
if constA
|
|
112
|
+
if constB
|
|
113
|
+
match = if constA.is_a?(Regexp)
|
|
114
|
+
if constB.is_a?(Regexp)
|
|
115
|
+
constB.source == constA.source || constA.match?(constB.source)
|
|
116
|
+
else
|
|
117
|
+
constA.match?(constB.to_s)
|
|
118
|
+
end
|
|
119
|
+
else
|
|
120
|
+
if constB.is_a?(Regexp)
|
|
121
|
+
constB.match?(constA.to_s)
|
|
122
|
+
else
|
|
123
|
+
constA.to_s == constB.to_s
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
return false unless match
|
|
127
|
+
else
|
|
128
|
+
return false
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
else
|
|
132
|
+
# sB is literal
|
|
133
|
+
if constA
|
|
134
|
+
match = if constA.is_a?(Regexp)
|
|
135
|
+
constA.match?(sB)
|
|
136
|
+
else
|
|
137
|
+
constA.to_s == sB
|
|
138
|
+
end
|
|
139
|
+
return false unless match
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
else
|
|
143
|
+
# sA is literal
|
|
144
|
+
if sB.start_with?(":")
|
|
145
|
+
return false
|
|
146
|
+
else
|
|
147
|
+
return false if sA != sB
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
idxA += 1
|
|
152
|
+
idxB += 1
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
if idxA == segA.length && idxB == segB.length
|
|
156
|
+
true
|
|
157
|
+
else
|
|
158
|
+
false
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module RouteGuard
|
|
6
|
+
module Rules
|
|
7
|
+
class Complexity < Base
|
|
8
|
+
def analyze(routes, report)
|
|
9
|
+
score = 100
|
|
10
|
+
|
|
11
|
+
# 1. Penalties based on issues present in the report
|
|
12
|
+
issues = report.issues || []
|
|
13
|
+
|
|
14
|
+
duplicate_count = issues.count { |i| i.rule_name == :duplicate_routes }
|
|
15
|
+
shadowed_count = issues.count { |i| i.rule_name == :shadowed_routes }
|
|
16
|
+
unreachable_count = issues.count { |i| i.rule_name == :unreachable_routes }
|
|
17
|
+
helper_count = issues.count { |i| i.rule_name == :duplicate_helpers }
|
|
18
|
+
resource_count = issues.count { |i| i.rule_name == :duplicate_resources }
|
|
19
|
+
|
|
20
|
+
score -= duplicate_count * 10
|
|
21
|
+
score -= shadowed_count * 15
|
|
22
|
+
score -= unreachable_count * 20
|
|
23
|
+
score -= helper_count * 5
|
|
24
|
+
score -= resource_count * 5
|
|
25
|
+
|
|
26
|
+
# 2. Nesting depth penalties
|
|
27
|
+
stats = report.stats || {}
|
|
28
|
+
max_depth = stats[:maximum_nesting_depth] || 0
|
|
29
|
+
avg_depth = stats[:average_nesting_depth] || 0.0
|
|
30
|
+
|
|
31
|
+
if max_depth > 3
|
|
32
|
+
score -= (max_depth - 3) * 5
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
if avg_depth > 2.0
|
|
36
|
+
score -= ((avg_depth - 2.0) * 10).round
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# 3. Wildcard usage penalty
|
|
40
|
+
wildcards = stats[:wildcards] || 0
|
|
41
|
+
score -= wildcards * 2
|
|
42
|
+
|
|
43
|
+
# Clamp score between 0 and 100
|
|
44
|
+
score = [0, [score, 100].min].max
|
|
45
|
+
report.complexity_score = score
|
|
46
|
+
|
|
47
|
+
# Return a warning if the score is critically low (< 70) and verbose is enabled
|
|
48
|
+
issues_to_return = []
|
|
49
|
+
if score < 70
|
|
50
|
+
issues_to_return << Models::Issue.new(
|
|
51
|
+
rule_name: :complexity,
|
|
52
|
+
severity: :warning,
|
|
53
|
+
message: "Route Complexity: Overall route health is low (#{score}/100). Consider refactoring nested resources or namespaces.",
|
|
54
|
+
route: nil
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
issues_to_return
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
require_relative "../models/issue"
|
|
5
|
+
|
|
6
|
+
module RouteGuard
|
|
7
|
+
module Rules
|
|
8
|
+
class DuplicateHelpers < Base
|
|
9
|
+
def analyze(routes, report)
|
|
10
|
+
issues = []
|
|
11
|
+
|
|
12
|
+
groups = Hash.new { |h, k| h[k] = [] }
|
|
13
|
+
routes.each do |route|
|
|
14
|
+
next if route.name.nil? || route.name.empty?
|
|
15
|
+
|
|
16
|
+
groups[route.name] << route
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
groups.each do |name, grp_routes|
|
|
20
|
+
paths = grp_routes.map(&:path).uniq
|
|
21
|
+
next if paths.length <= 1
|
|
22
|
+
|
|
23
|
+
primary = grp_routes.first
|
|
24
|
+
conflicts = grp_routes[1..-1]
|
|
25
|
+
|
|
26
|
+
conflicts.each do |conflict|
|
|
27
|
+
next if conflict.path == primary.path
|
|
28
|
+
|
|
29
|
+
issues << Models::Issue.new(
|
|
30
|
+
rule_name: :duplicate_helpers,
|
|
31
|
+
severity: :error,
|
|
32
|
+
message: "Duplicate Named Helper: Helper '#{name}_path' is defined for both '#{primary.path}' and '#{conflict.path}'.",
|
|
33
|
+
route: conflict,
|
|
34
|
+
related_routes: [primary]
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
issues
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
require_relative "../models/issue"
|
|
5
|
+
require_relative "../route_tracker"
|
|
6
|
+
|
|
7
|
+
module RouteGuard
|
|
8
|
+
module Rules
|
|
9
|
+
class DuplicateResources < Base
|
|
10
|
+
def analyze(routes, report)
|
|
11
|
+
issues = []
|
|
12
|
+
calls = RouteTracker.resource_calls
|
|
13
|
+
return issues if calls.empty?
|
|
14
|
+
|
|
15
|
+
groups = Hash.new { |h, k| h[k] = [] }
|
|
16
|
+
|
|
17
|
+
calls.each do |call|
|
|
18
|
+
resource_names = call[:args].take_while { |a| a.is_a?(Symbol) || a.is_a?(String) }
|
|
19
|
+
scope_path = call[:scope][:path]
|
|
20
|
+
scope_module = call[:scope][:module]
|
|
21
|
+
|
|
22
|
+
resource_names.each do |name|
|
|
23
|
+
key = [scope_path, scope_module, name.to_sym]
|
|
24
|
+
groups[key] << call
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
groups.each do |(scope_path, scope_module, name), grp_calls|
|
|
29
|
+
next if grp_calls.length <= 1
|
|
30
|
+
|
|
31
|
+
primary = grp_calls.first
|
|
32
|
+
duplicates = grp_calls[1..-1]
|
|
33
|
+
|
|
34
|
+
scope_desc = [
|
|
35
|
+
scope_module ? "module #{scope_module}" : nil,
|
|
36
|
+
scope_path ? "path '#{scope_path}'" : nil
|
|
37
|
+
].compact.join(", ")
|
|
38
|
+
scope_text = scope_desc.empty? ? "root scope" : "scope [#{scope_desc}]"
|
|
39
|
+
|
|
40
|
+
duplicates.each do |dup|
|
|
41
|
+
issues << Models::Issue.new(
|
|
42
|
+
rule_name: :duplicate_resources,
|
|
43
|
+
severity: :warning,
|
|
44
|
+
message: "Duplicate Resource: Resource ':#{name}' is declared multiple times in #{scope_text}.",
|
|
45
|
+
route: nil,
|
|
46
|
+
file: dup[:trace][:file],
|
|
47
|
+
line: dup[:trace][:line]
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
issues
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
require_relative "../models/issue"
|
|
5
|
+
|
|
6
|
+
module RouteGuard
|
|
7
|
+
module Rules
|
|
8
|
+
class DuplicateRoutes < Base
|
|
9
|
+
def analyze(routes, report)
|
|
10
|
+
issues = []
|
|
11
|
+
|
|
12
|
+
groups = Hash.new { |h, k| h[k] = [] }
|
|
13
|
+
routes.each do |route|
|
|
14
|
+
route.verb.split("|").each do |v|
|
|
15
|
+
groups[[v, route.path]] << route
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
groups.each do |(verb, path), grp_routes|
|
|
20
|
+
next if grp_routes.length <= 1
|
|
21
|
+
|
|
22
|
+
primary = grp_routes.first
|
|
23
|
+
duplicates = grp_routes[1..-1]
|
|
24
|
+
|
|
25
|
+
duplicates.each do |dup|
|
|
26
|
+
issues << Models::Issue.new(
|
|
27
|
+
rule_name: :duplicate_routes,
|
|
28
|
+
severity: :error,
|
|
29
|
+
message: "Duplicate Route: #{verb} #{path} matches a route defined earlier.",
|
|
30
|
+
route: dup,
|
|
31
|
+
related_routes: [primary]
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
issues
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
require_relative "../models/issue"
|
|
5
|
+
|
|
6
|
+
module RouteGuard
|
|
7
|
+
module Rules
|
|
8
|
+
class ShadowedRoutes < Base
|
|
9
|
+
def analyze(routes, report)
|
|
10
|
+
issues = []
|
|
11
|
+
return issues if routes.empty?
|
|
12
|
+
|
|
13
|
+
# Cache segment info and path expansions for all routes
|
|
14
|
+
route_info = routes.map do |r|
|
|
15
|
+
segs = split_segments(r.path)
|
|
16
|
+
{
|
|
17
|
+
route: r,
|
|
18
|
+
segs: segs,
|
|
19
|
+
first: segs[1],
|
|
20
|
+
is_catch_all: catch_all_wildcard?(r),
|
|
21
|
+
expansions: (expand_path(r.original_path).map { |p| split_segments(p) } rescue [[r.path]])
|
|
22
|
+
}
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
route_info.each_with_index do |info_b, idx|
|
|
26
|
+
next if info_b[:is_catch_all]
|
|
27
|
+
route_b = info_b[:route]
|
|
28
|
+
first_b = info_b[:first]
|
|
29
|
+
|
|
30
|
+
route_info[0...idx].each do |info_a|
|
|
31
|
+
next if info_a[:is_catch_all]
|
|
32
|
+
first_a = info_a[:first]
|
|
33
|
+
|
|
34
|
+
# Optimization: Skip comparisons if first literal segment doesn't match
|
|
35
|
+
if first_a && first_b && !first_a.start_with?(":") && !first_a.start_with?("*")
|
|
36
|
+
next if first_a != first_b
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
route_a = info_a[:route]
|
|
40
|
+
next unless verb_matches?(route_a.verb, route_b.verb)
|
|
41
|
+
|
|
42
|
+
if shadows_cached?(info_a, info_b)
|
|
43
|
+
issues << Models::Issue.new(
|
|
44
|
+
rule_name: :shadowed_routes,
|
|
45
|
+
severity: :warning,
|
|
46
|
+
message: "Shadowed Route: #{route_b} is shadowed by #{route_a} defined earlier.",
|
|
47
|
+
route: route_b,
|
|
48
|
+
related_routes: [route_a]
|
|
49
|
+
)
|
|
50
|
+
break
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
issues
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def catch_all_wildcard?(route)
|
|
61
|
+
path = route.path
|
|
62
|
+
path == "/*path" || path == "*path" || path.match?(/\A\/?\*[a-zA-Z_]+\z/)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def shadows_cached?(info_a, info_b)
|
|
66
|
+
route_a = info_a[:route]
|
|
67
|
+
route_b = info_b[:route]
|
|
68
|
+
return false if route_a.path == route_b.path
|
|
69
|
+
|
|
70
|
+
info_b[:expansions].all? do |seg_b|
|
|
71
|
+
info_a[:expansions].any? do |seg_a|
|
|
72
|
+
path_shadows?(seg_a, seg_b, route_a.constraints, route_b.constraints)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|