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,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 • 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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
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
|