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