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,297 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+ require "json"
5
+ require_relative "../version"
6
+
7
+ module RouteGuard
8
+ module Formatter
9
+ class Html
10
+ def format(report, io = $stdout)
11
+ io.puts html_template(report)
12
+ end
13
+
14
+ private
15
+
16
+ def html_template(report)
17
+ issues_json = report.issues.map { |i| format_issue_for_js(i) }
18
+ stats = report.stats || {}
19
+ score = report.complexity_score
20
+
21
+ # Color configurations based on score
22
+ score_color = if score >= 90
23
+ "from-emerald-400 to-teal-600"
24
+ elsif score >= 70
25
+ "from-amber-400 to-orange-500"
26
+ else
27
+ "from-rose-500 to-red-700"
28
+ end
29
+
30
+ <<-HTML
31
+ <!DOCTYPE html>
32
+ <html lang="en" class="h-full bg-slate-50 text-slate-900">
33
+ <head>
34
+ <meta charset="UTF-8">
35
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
36
+ <title>RouteGuard Report</title>
37
+ <script src="https://cdn.tailwindcss.com"></script>
38
+ <link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
39
+ <style>
40
+ body {
41
+ font-family: 'Plus Jakarta Sans', sans-serif;
42
+ }
43
+ code, pre {
44
+ font-family: 'JetBrains Mono', monospace;
45
+ }
46
+ </style>
47
+ </head>
48
+ <body class="min-h-full flex flex-col antialiased bg-slate-50">
49
+ <!-- Header -->
50
+ <header class="border-b border-slate-200 bg-white/80 backdrop-blur-md sticky top-0 z-50 shadow-sm">
51
+ <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between">
52
+ <div class="flex items-center space-x-3">
53
+ <div class="bg-gradient-to-tr from-indigo-600 to-cyan-500 p-2 rounded-xl shadow-md shadow-indigo-600/10">
54
+ <svg class="w-6 h-6 text-white font-bold" fill="none" stroke="currentColor" viewBox="0 0 24 24">
55
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/>
56
+ </svg>
57
+ </div>
58
+ <div>
59
+ <span class="text-xl font-bold tracking-tight bg-gradient-to-r from-slate-900 to-slate-700 bg-clip-text text-transparent">RouteGuard</span>
60
+ <span class="text-xs ml-2 text-indigo-600 font-semibold px-2 py-0.5 rounded-full bg-indigo-50 border border-indigo-100">v#{RouteGuard::VERSION}</span>
61
+ </div>
62
+ </div>
63
+ <div class="text-sm text-slate-500 flex items-center space-x-4">
64
+ <span>Analyzed: <strong class="text-slate-800">#{Time.now.strftime('%Y-%m-%d %H:%M:%S UTC')}</strong></span>
65
+ <span class="h-4 w-px bg-slate-200"></span>
66
+ <span>Duration: <strong class="text-slate-800">#{(report.duration * 1000).round(2)}ms</strong></span>
67
+ </div>
68
+ </div>
69
+ </header>
70
+
71
+ <main class="flex-grow max-w-7xl w-full mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-8">
72
+ <!-- Top Dashboard Section -->
73
+ <div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
74
+ <!-- Health Score Card -->
75
+ <div class="bg-white border border-slate-200 rounded-3xl p-6 flex flex-col items-center justify-center relative overflow-hidden shadow-sm">
76
+ <div class="absolute inset-0 bg-gradient-to-br from-indigo-50/50 to-cyan-50/30"></div>
77
+ <h2 class="text-sm font-semibold tracking-wide text-slate-500 uppercase mb-4 z-10">Route Health Score</h2>
78
+ <div class="relative flex items-center justify-center">
79
+ <!-- Circular Progress SVG -->
80
+ <svg class="w-40 h-40 transform -rotate-90">
81
+ <circle cx="80" cy="80" r="70" stroke="currentColor" stroke-width="8" class="text-slate-100" fill="transparent" />
82
+ <circle cx="80" cy="80" r="70" stroke="url(#gradient)" stroke-width="10" stroke-dasharray="440" stroke-dashoffset="#{440 - (440 * score / 100)}" stroke-linecap="round" fill="transparent" class="transition-all duration-1000 ease-out" />
83
+ <defs>
84
+ <linearGradient id="gradient" x1="0%" y1="0%" x2="100%" y2="100%">
85
+ <stop offset="0%" class="text-cyan-500" stop-color="currentColor"/>
86
+ <stop offset="100%" class="text-indigo-600" stop-color="currentColor"/>
87
+ </linearGradient>
88
+ </defs>
89
+ </svg>
90
+ <div class="absolute text-center">
91
+ <span class="text-4xl font-extrabold tracking-tight text-slate-900">#{score}</span>
92
+ <span class="text-slate-500 text-sm block">/ 100</span>
93
+ </div>
94
+ </div>
95
+ <p class="mt-4 text-xs text-slate-500 text-center font-medium z-10">
96
+ #{score >= 90 ? "Excellent. Your route file is well organized." : (score >= 70 ? "Good. Some optimization opportunities found." : "Critically low health. Refactoring recommended.")}
97
+ </p>
98
+ </div>
99
+
100
+ <!-- Statistics Grid -->
101
+ <div class="lg:col-span-2 bg-white border border-slate-200 rounded-3xl p-6 grid grid-cols-2 sm:grid-cols-3 gap-6 shadow-sm relative">
102
+ <div class="absolute inset-0 bg-gradient-to-br from-white to-slate-50/30 pointer-events-none rounded-3xl"></div>
103
+ <div class="relative z-10 flex flex-col justify-between p-4 bg-slate-50/50 border border-slate-100 rounded-2xl">
104
+ <span class="text-xs font-semibold text-slate-500 uppercase tracking-wider">Total Routes</span>
105
+ <span class="text-3xl font-extrabold text-slate-900 mt-2">#{stats[:total_routes] || 0}</span>
106
+ </div>
107
+ <div class="relative z-10 flex flex-col justify-between p-4 bg-slate-50/50 border border-slate-100 rounded-2xl">
108
+ <span class="text-xs font-semibold text-slate-500 uppercase tracking-wider">REST Resources</span>
109
+ <span class="text-3xl font-extrabold text-slate-900 mt-2">#{stats[:rest_resources] || 0}</span>
110
+ </div>
111
+ <div class="relative z-10 flex flex-col justify-between p-4 bg-slate-50/50 border border-slate-100 rounded-2xl">
112
+ <span class="text-xs font-semibold text-slate-500 uppercase tracking-wider">Namespaces</span>
113
+ <span class="text-3xl font-extrabold text-slate-900 mt-2">#{stats[:namespaces] || 0}</span>
114
+ </div>
115
+ <div class="relative z-10 flex flex-col justify-between p-4 bg-slate-50/50 border border-slate-100 rounded-2xl">
116
+ <span class="text-xs font-semibold text-slate-500 uppercase tracking-wider">Scopes</span>
117
+ <span class="text-3xl font-extrabold text-slate-900 mt-2">#{stats[:scopes] || 0}</span>
118
+ </div>
119
+ <div class="relative z-10 flex flex-col justify-between p-4 bg-slate-50/50 border border-slate-100 rounded-2xl">
120
+ <span class="text-xs font-semibold text-slate-500 uppercase tracking-wider">Wildcard Paths</span>
121
+ <span class="text-3xl font-extrabold text-amber-600 mt-2">#{stats[:wildcards] || 0}</span>
122
+ </div>
123
+ <div class="relative z-10 flex flex-col justify-between p-4 bg-slate-50/50 border border-slate-100 rounded-2xl">
124
+ <span class="text-xs font-semibold text-slate-500 uppercase tracking-wider">Nesting Depth</span>
125
+ <span class="text-3xl font-extrabold text-slate-900 mt-2">#{stats[:average_nesting_depth] || 0.0}<span class="text-xs text-slate-400 font-normal"> avg / #{stats[:maximum_nesting_depth] || 0} max</span></span>
126
+ </div>
127
+ </div>
128
+ </div>
129
+
130
+ <!-- Quick info alert if any low-health issues -->
131
+ <div class="grid grid-cols-1 md:grid-cols-3 gap-6">
132
+ <div class="bg-rose-50 border border-rose-100 rounded-2xl p-4 flex items-center space-x-3 shadow-sm">
133
+ <div class="p-2 bg-rose-100 text-rose-600 rounded-lg">
134
+ <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/></svg>
135
+ </div>
136
+ <div>
137
+ <span class="text-xs text-slate-500 block font-semibold">Errors</span>
138
+ <span class="text-lg font-bold text-rose-700">#{report.errors.length}</span>
139
+ </div>
140
+ </div>
141
+ <div class="bg-amber-50 border border-amber-100 rounded-2xl p-4 flex items-center space-x-3 shadow-sm">
142
+ <div class="p-2 bg-amber-100 text-amber-600 rounded-lg">
143
+ <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/></svg>
144
+ </div>
145
+ <div>
146
+ <span class="text-xs text-slate-500 block font-semibold">Warnings</span>
147
+ <span class="text-lg font-bold text-amber-700">#{report.warnings.length}</span>
148
+ </div>
149
+ </div>
150
+ <div class="bg-slate-50 border border-slate-100 rounded-2xl p-4 flex items-center space-x-3 shadow-sm">
151
+ <div class="p-2 bg-slate-200/60 text-slate-600 rounded-lg">
152
+ <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/></svg>
153
+ </div>
154
+ <div>
155
+ <span class="text-xs text-slate-500 block font-semibold">Most Common Controller</span>
156
+ <span class="text-sm font-bold text-slate-800 truncate max-w-[200px]" title="#{stats[:most_common_controller]}">
157
+ #{stats[:most_common_controller] || "N/A"} <span class="text-xs text-slate-500 font-normal">(#{stats[:most_common_count] || 0} routes)</span>
158
+ </span>
159
+ </div>
160
+ </div>
161
+ </div>
162
+
163
+ <!-- Issues Explorer -->
164
+ <div class="bg-white border border-slate-200 rounded-3xl overflow-hidden shadow-sm">
165
+ <div class="px-6 py-5 border-b border-slate-150 bg-slate-50/50 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
166
+ <div>
167
+ <h2 class="text-lg font-bold text-slate-900">Inspections & Issues</h2>
168
+ <p class="text-xs text-slate-500 mt-1">Review lint warnings and details for optimized routing behavior.</p>
169
+ </div>
170
+ <!-- Filters -->
171
+ <div class="flex items-center space-x-2">
172
+ <button onclick="filterIssues('all')" id="btn-all" class="px-3 py-1.5 rounded-lg text-xs font-semibold bg-indigo-600 text-white shadow-md shadow-indigo-600/10 transition-all">All</button>
173
+ <button onclick="filterIssues('error')" id="btn-error" class="px-3 py-1.5 rounded-lg text-xs font-semibold bg-slate-100 text-slate-500 hover:bg-slate-200 transition-all">Errors</button>
174
+ <button onclick="filterIssues('warning')" id="btn-warning" class="px-3 py-1.5 rounded-lg text-xs font-semibold bg-slate-100 text-slate-500 hover:bg-slate-200 transition-all">Warnings</button>
175
+ </div>
176
+ </div>
177
+
178
+ <!-- Issues List -->
179
+ <div class="divide-y divide-slate-100" id="issues-container">
180
+ <!-- JS will populate these -->
181
+ </div>
182
+ <div id="no-issues-placeholder" class="hidden py-16 text-center">
183
+ <svg class="w-12 h-12 text-slate-300 mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
184
+ <span class="text-slate-500 font-medium">Hurrah! No routing issues detected.</span>
185
+ </div>
186
+ </div>
187
+ </main>
188
+
189
+ <footer class="border-t border-slate-200 bg-white py-6 mt-12 text-center text-xs text-slate-400">
190
+ <p>Generated by RouteGuard &bull; Keep your Rails routes performant and tidy.</p>
191
+ </footer>
192
+
193
+ <script>
194
+ const issues = #{JSON.generate(issues_json)};
195
+
196
+ function renderIssues(list) {
197
+ const container = document.getElementById('issues-container');
198
+ const placeholder = document.getElementById('no-issues-placeholder');
199
+
200
+ container.innerHTML = '';
201
+
202
+ if (list.length === 0) {
203
+ placeholder.classList.remove('hidden');
204
+ return;
205
+ }
206
+ placeholder.classList.add('hidden');
207
+
208
+ list.forEach((issue, index) => {
209
+ const severityBadge = issue.severity === 'error'
210
+ ? '<span class="px-2 py-0.5 text-[10px] font-bold uppercase rounded bg-rose-100 text-rose-700 border border-rose-200">Error</span>'
211
+ : '<span class="px-2 py-0.5 text-[10px] font-bold uppercase rounded bg-amber-100 text-amber-700 border border-amber-200">Warning</span>';
212
+
213
+ const relatedSection = issue.related_routes.length > 0
214
+ ? `<div class="mt-3 p-3 bg-slate-50/50 rounded-xl border border-slate-200/60">
215
+ <span class="text-xs font-semibold text-slate-500 block mb-2">Related Route(s):</span>
216
+ <div class="space-y-2">
217
+ ${issue.related_routes.map(r => `
218
+ <div class="text-xs flex items-center justify-between">
219
+ <code class="text-indigo-600 font-semibold">${r.verb} ${r.path}</code>
220
+ <code class="text-slate-500 text-[11px] underline">${r.location}</code>
221
+ </div>
222
+ `).join('')}
223
+ </div>
224
+ </div>`
225
+ : '';
226
+
227
+ const item = document.createElement('div');
228
+ item.className = 'p-6 hover:bg-slate-50/40 transition-colors';
229
+ item.innerHTML = `
230
+ <div class="flex items-start justify-between gap-4">
231
+ <div class="space-y-2">
232
+ <div class="flex items-center space-x-2">
233
+ ${severityBadge}
234
+ <span class="text-xs font-bold text-indigo-600 tracking-wide uppercase">${issue.rule_name.replace(/_/g, ' ')}</span>
235
+ </div>
236
+ <h3 class="text-sm font-semibold text-slate-900 mt-1">${escapeHtml(issue.message)}</h3>
237
+ ${issue.route ? `<div class="text-xs flex items-center space-x-2 text-slate-600">
238
+ <span class="font-medium text-slate-500">Route:</span>
239
+ <code class="px-1.5 py-0.5 rounded bg-slate-50 border border-slate-200 text-cyan-700 font-semibold">${issue.route.verb} ${issue.route.path}</code>
240
+ </div>` : ''}
241
+ </div>
242
+
243
+ <div class="text-right">
244
+ ${issue.location ? `<code class="text-xs text-slate-400 underline block">${issue.location}</code>` : ''}
245
+ </div>
246
+ </div>
247
+ ${relatedSection}
248
+ `;
249
+ container.appendChild(item);
250
+ });
251
+ }
252
+
253
+ function filterIssues(severity) {
254
+ const buttons = ['all', 'error', 'warning'];
255
+ buttons.forEach(b => {
256
+ const btn = document.getElementById('btn-' + b);
257
+ if (b === severity) {
258
+ btn.className = 'px-3 py-1.5 rounded-lg text-xs font-semibold bg-indigo-600 text-white shadow-md shadow-indigo-600/10 transition-all';
259
+ } else {
260
+ btn.className = 'px-3 py-1.5 rounded-lg text-xs font-semibold bg-slate-100 text-slate-500 hover:bg-slate-200 transition-all';
261
+ }
262
+ });
263
+
264
+ if (severity === 'all') {
265
+ renderIssues(issues);
266
+ } else {
267
+ const filtered = issues.filter(i => i.severity === severity);
268
+ renderIssues(filtered);
269
+ }
270
+ }
271
+
272
+ function escapeHtml(str) {
273
+ if (!str) return '';
274
+ return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#039;");
275
+ }
276
+
277
+ // Initial load
278
+ renderIssues(issues);
279
+ </script>
280
+ </body>
281
+ </html>
282
+ HTML
283
+ end
284
+
285
+ def format_issue_for_js(issue)
286
+ {
287
+ rule_name: issue.rule_name.to_s,
288
+ severity: issue.severity.to_s,
289
+ message: issue.message,
290
+ location: issue.location,
291
+ route: issue.route ? { verb: issue.route.verb, path: issue.route.path } : nil,
292
+ related_routes: issue.related_routes.map { |r| { verb: r.verb, path: r.path, location: r.location } }
293
+ }
294
+ end
295
+ end
296
+ end
297
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+ require_relative "../version"
6
+
7
+ module RouteGuard
8
+ module Formatter
9
+ class Json
10
+ def format(report, io = $stdout)
11
+ data = {
12
+ metadata: {
13
+ version: RouteGuard::VERSION,
14
+ timestamp: Time.now.utc.iso8601,
15
+ duration: report.duration
16
+ },
17
+ summary: {
18
+ routes_count: report.routes.length,
19
+ errors_count: report.errors.length,
20
+ warnings_count: report.warnings.length,
21
+ health_score: report.complexity_score
22
+ },
23
+ statistics: report.stats || {},
24
+ issues: report.issues.map { |issue| format_issue(issue) }
25
+ }
26
+
27
+ io.puts JSON.pretty_generate(data)
28
+ end
29
+
30
+ private
31
+
32
+ def format_issue(issue)
33
+ {
34
+ rule_name: issue.rule_name.to_s,
35
+ severity: issue.severity.to_s,
36
+ message: issue.message,
37
+ location: {
38
+ file: issue.file,
39
+ line: issue.line,
40
+ formatted: issue.location
41
+ },
42
+ route: issue.route ? format_route(issue.route) : nil,
43
+ related_routes: issue.related_routes.map { |r| format_route(r) }
44
+ }
45
+ end
46
+
47
+ def format_route(route)
48
+ {
49
+ verb: route.verb,
50
+ path: route.path,
51
+ original_path: route.original_path,
52
+ controller: route.controller,
53
+ action: route.action,
54
+ name: route.name,
55
+ constraints: route.constraints.transform_values(&:to_s),
56
+ location: route.location
57
+ }
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rainbow"
4
+ require_relative "../configuration"
5
+
6
+ module RouteGuard
7
+ module Formatter
8
+ class Terminal
9
+ attr_reader :verbose
10
+
11
+ def initialize(verbose: false)
12
+ @verbose = verbose
13
+ end
14
+
15
+ def format(report, io = $stdout)
16
+ io.puts Rainbow("─" * 50).faint
17
+ io.puts Rainbow("RouteGuard").bold.cyan
18
+ io.puts "Analyzing Rails Routes..."
19
+ io.puts "#{report.routes.length} routes loaded"
20
+ io.puts "Running #{Configuration::ALL_RULES.length} inspections..."
21
+ io.puts
22
+
23
+ Configuration::ALL_RULES.each do |rule|
24
+ issues_for_rule = report.issues.select { |i| i.rule_name == rule }
25
+ if rule == :statistics || rule == :complexity
26
+ io.puts " #{Rainbow("✓").green} #{rule_display_name(rule)}"
27
+ elsif issues_for_rule.any? { |i| i.severity == :error }
28
+ io.puts " #{Rainbow("✗").red} #{rule_display_name(rule)}"
29
+ elsif issues_for_rule.any? { |i| i.severity == :warning }
30
+ io.puts " #{Rainbow("⚠").yellow} #{rule_display_name(rule)}"
31
+ else
32
+ io.puts " #{Rainbow("✓").green} #{rule_display_name(rule)}"
33
+ end
34
+ end
35
+
36
+ io.puts Rainbow("─" * 50).faint
37
+
38
+ if report.issues.any?
39
+ io.puts Rainbow("Issues Found").bold.yellow
40
+ io.puts
41
+
42
+ report.issues.each do |issue|
43
+ severity_color = issue.severity == :error ? :red : :yellow
44
+ severity_prefix = issue.severity == :error ? "✗ Error" : "⚠ Warning"
45
+
46
+ io.puts Rainbow("#{severity_prefix} [#{rule_display_name(issue.rule_name)}]:").bold.send(severity_color)
47
+ io.puts " #{issue.message}"
48
+ if issue.route
49
+ io.puts " Route: #{Rainbow(issue.route.to_s).bold}"
50
+ end
51
+
52
+ if issue.file && issue.line
53
+ io.puts " Location: #{Rainbow(issue.location).underline}"
54
+ end
55
+
56
+ if issue.related_routes.any?
57
+ io.puts " Related Route(s):"
58
+ issue.related_routes.each do |rel|
59
+ io.puts " - #{Rainbow(rel.to_s).bold} at #{Rainbow(rel.location).underline}"
60
+ end
61
+ end
62
+ io.puts
63
+ end
64
+ io.puts Rainbow("─" * 50).faint
65
+ else
66
+ io.puts Rainbow("✓ No issues found! Your routes look excellent.").green.bold
67
+ io.puts
68
+ end
69
+
70
+ # Summary
71
+ io.puts Rainbow("Summary").bold.cyan
72
+ io.puts " %-15s %d" % ["Routes", report.routes.length]
73
+ io.puts " %-15s %d" % ["Errors", report.errors.length]
74
+ io.puts " %-15s %d" % ["Warnings", report.warnings.length]
75
+
76
+ health_color = if report.complexity_score >= 90
77
+ :green
78
+ elsif report.complexity_score >= 70
79
+ :yellow
80
+ else
81
+ :red
82
+ end
83
+ io.puts " %-15s %s" % ["Health", Rainbow("#{report.complexity_score} / 100").bold.send(health_color)]
84
+ io.puts " %-15s %.3f seconds" % ["Duration", report.duration]
85
+ io.puts Rainbow("─" * 50).faint
86
+ end
87
+
88
+ private
89
+
90
+ def rule_display_name(rule)
91
+ rule.to_s.split("_").map(&:capitalize).join(" ")
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "configuration"
4
+ require_relative "route_loader"
5
+ require_relative "analyzer"
6
+
7
+ module RouteGuard
8
+ class Inspector
9
+ attr_reader :config
10
+
11
+ def initialize(config = Configuration.new)
12
+ @config = config
13
+ end
14
+
15
+ def run
16
+ $stderr.puts "RouteGuard: Loading Rails environment and reloading routes..."
17
+
18
+ routes = RouteLoader.load
19
+
20
+ $stderr.puts "RouteGuard: Loaded #{routes.size} routes. Running #{config.enabled_rules.size} inspections..."
21
+
22
+ report = Analyzer.analyze(routes, config.enabled_rules, {
23
+ strict: config.strict,
24
+ verbose: config.verbose
25
+ })
26
+
27
+ $stderr.puts "RouteGuard: Inspections complete."
28
+
29
+ report
30
+ end
31
+
32
+ def format(report, formatter_sym, io = $stdout)
33
+ formatter = load_formatter(formatter_sym)
34
+ formatter.format(report, io)
35
+ end
36
+
37
+ private
38
+
39
+ def load_formatter(fmt)
40
+ case fmt.to_sym
41
+ when :terminal
42
+ require_relative "formatter/terminal"
43
+ Formatter::Terminal.new(verbose: config.verbose)
44
+ when :json
45
+ require_relative "formatter/json"
46
+ Formatter::Json.new
47
+ when :html
48
+ require_relative "formatter/html"
49
+ Formatter::Html.new
50
+ when :ci
51
+ require_relative "formatter/ci"
52
+ Formatter::Ci.new
53
+ else
54
+ raise "Unknown formatter: #{fmt}"
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RouteGuard
4
+ module Models
5
+ class Issue
6
+ attr_reader :rule_name, :severity, :message, :route, :related_routes, :file, :line
7
+
8
+ def initialize(rule_name:, severity:, message:, route:, related_routes: [], file: nil, line: nil)
9
+ @rule_name = rule_name.to_sym
10
+ @severity = severity.to_sym # :error, :warning
11
+ @message = message
12
+ @route = route
13
+ @related_routes = Array(related_routes)
14
+ @file = file || route&.file
15
+ @line = line || route&.line
16
+ end
17
+
18
+ def location
19
+ if file && line
20
+ relative_file = file.sub(/\A#{Regexp.escape(Dir.pwd)}\//, "")
21
+ "#{relative_file}:#{line}"
22
+ elsif file
23
+ file.sub(/\A#{Regexp.escape(Dir.pwd)}\//, "")
24
+ else
25
+ "unknown location"
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RouteGuard
4
+ module Models
5
+ class Report
6
+ attr_accessor :routes, :issues, :stats, :complexity_score, :duration
7
+
8
+ def initialize(routes = [])
9
+ @routes = routes
10
+ @issues = []
11
+ @stats = {}
12
+ @complexity_score = 100
13
+ @duration = 0.0
14
+ end
15
+
16
+ def errors
17
+ issues.select { |i| i.severity == :error }
18
+ end
19
+
20
+ def warnings
21
+ issues.select { |i| i.severity == :warning }
22
+ end
23
+
24
+ def passed?
25
+ errors.empty?
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RouteGuard
4
+ module Models
5
+ class Route
6
+ attr_reader :verb, :path, :original_path, :controller, :action, :name, :constraints, :file, :line, :defaults
7
+
8
+ def initialize(verb:, path:, original_path:, controller:, action:, name: nil, constraints: {}, file: nil, line: nil, defaults: {})
9
+ @verb = verb.to_s.upcase
10
+ @original_path = original_path.to_s
11
+ @path = path.to_s
12
+ @controller = controller&.to_s
13
+ @action = action&.to_s
14
+ @name = name&.to_s
15
+ @constraints = constraints || {}
16
+ @file = file
17
+ @line = line ? line.to_i : nil
18
+ @defaults = defaults || {}
19
+ end
20
+
21
+ def internal?
22
+ controller.to_s.start_with?("rails/") || path.start_with?("/rails/") || path == "/assets"
23
+ end
24
+
25
+ def location
26
+ if file && line
27
+ # Try to make path relative to pwd for better readability
28
+ relative_file = file.sub(/\A#{Regexp.escape(Dir.pwd)}\//, "")
29
+ "#{relative_file}:#{line}"
30
+ elsif file
31
+ file.sub(/\A#{Regexp.escape(Dir.pwd)}\//, "")
32
+ else
33
+ "unknown location"
34
+ end
35
+ end
36
+
37
+ def to_s
38
+ "#{verb} #{path}"
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/railtie"
4
+
5
+ module RouteGuard
6
+ class Railtie < Rails::Railtie
7
+ rake_tasks do
8
+ path = File.expand_path("../tasks/route_guard.rake", __dir__)
9
+ load path if File.exist?(path)
10
+ end
11
+ end
12
+ end