rails-doctor 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/CHANGELOG.md +11 -0
- data/LICENSE +21 -0
- data/README.md +148 -0
- data/docs/adapter-architecture.md +21 -0
- data/docs/agent-handoff.md +22 -0
- data/docs/architecture.md +21 -0
- data/docs/cli-reference.md +48 -0
- data/docs/config-reference.md +50 -0
- data/docs/github-actions.md +36 -0
- data/docs/monetization.md +14 -0
- data/docs/output-schema.md +57 -0
- data/docs/scoring-model.md +23 -0
- data/examples/github-actions/rails-doctor.yml +40 -0
- data/examples/report.html +704 -0
- data/examples/report.json +971 -0
- data/examples/report.md +261 -0
- data/examples/report.txt +45 -0
- data/exe/rails-doctor +8 -0
- data/lib/rails_doctor/adapters/base.rb +109 -0
- data/lib/rails_doctor/adapters/brakeman.rb +47 -0
- data/lib/rails_doctor/adapters/bundler_audit.rb +54 -0
- data/lib/rails_doctor/adapters/dependency_freshness.rb +51 -0
- data/lib/rails_doctor/adapters/flay.rb +41 -0
- data/lib/rails_doctor/adapters/flog.rb +41 -0
- data/lib/rails_doctor/adapters/reek.rb +39 -0
- data/lib/rails_doctor/adapters/rubocop.rb +40 -0
- data/lib/rails_doctor/adapters/strong_migrations.rb +52 -0
- data/lib/rails_doctor/adapters/test_coverage.rb +400 -0
- data/lib/rails_doctor/adapters/test_runner.rb +79 -0
- data/lib/rails_doctor/adapters/zeitwerk.rb +42 -0
- data/lib/rails_doctor/agent/handoff.rb +159 -0
- data/lib/rails_doctor/checks/rails_checks.rb +371 -0
- data/lib/rails_doctor/cli.rb +232 -0
- data/lib/rails_doctor/command_runner.rb +55 -0
- data/lib/rails_doctor/config.rb +161 -0
- data/lib/rails_doctor/init/runner.rb +191 -0
- data/lib/rails_doctor/models.rb +280 -0
- data/lib/rails_doctor/project.rb +95 -0
- data/lib/rails_doctor/reporters/html.rb +400 -0
- data/lib/rails_doctor/reporters/json.rb +18 -0
- data/lib/rails_doctor/reporters/markdown.rb +132 -0
- data/lib/rails_doctor/reporters/terminal.rb +101 -0
- data/lib/rails_doctor/scanner.rb +173 -0
- data/lib/rails_doctor/scorer.rb +74 -0
- data/lib/rails_doctor/version.rb +5 -0
- data/lib/rails_doctor.rb +15 -0
- data/site/assets/cli-output.png +0 -0
- data/site/assets/report-preview.png +0 -0
- data/site/index.html +294 -0
- metadata +167 -0
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "erb"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module RailsDoctor
|
|
7
|
+
module Reporters
|
|
8
|
+
class Html
|
|
9
|
+
include ERB::Util
|
|
10
|
+
|
|
11
|
+
def initialize(result)
|
|
12
|
+
@result = result
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def render
|
|
16
|
+
ERB.new(template, trim_mode: "-").result(binding).gsub(/[ \t]+$/, "")
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def severity_counts
|
|
22
|
+
@result.summary.fetch(:severity_counts)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def top_findings
|
|
26
|
+
@result.findings.sort_by { |finding| -SEVERITY_WEIGHTS.fetch(finding.severity, 0) }.first(12)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def raw_tool_runs
|
|
30
|
+
@result.tool_runs.select { |tool| tool.stdout.to_s.strip != "" || tool.stderr.to_s.strip != "" }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def coverage
|
|
34
|
+
@result.coverage
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def low_coverage_files
|
|
38
|
+
return [] unless coverage&.available
|
|
39
|
+
|
|
40
|
+
low_files = coverage.top_files.select { |file| file[:below_threshold] }
|
|
41
|
+
(coverage.changed_files_below_threshold + low_files).uniq { |file| file.fetch(:file) }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def agent_brief_text
|
|
45
|
+
lines = []
|
|
46
|
+
if coverage.nil?
|
|
47
|
+
lines << "Coverage: not captured"
|
|
48
|
+
elsif !coverage.available
|
|
49
|
+
lines << "Coverage: #{coverage.status} at #{coverage.report_path}"
|
|
50
|
+
else
|
|
51
|
+
lines << "Coverage: #{format_percent(coverage.line_percent)} lines"
|
|
52
|
+
if low_coverage_files.any?
|
|
53
|
+
lines << ""
|
|
54
|
+
lines << "## Coverage"
|
|
55
|
+
low_coverage_files.first(10).each do |file|
|
|
56
|
+
lines << "- #{file.fetch(:file)}: #{format_percent(file.fetch(:line_percent))} lines (#{file.fetch(:covered_lines)}/#{file.fetch(:total_lines)})"
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
lines << ""
|
|
62
|
+
lines << "## Findings"
|
|
63
|
+
top_findings.each do |finding|
|
|
64
|
+
lines << "- #{finding.severity}: #{finding.agent_instruction || finding.message}"
|
|
65
|
+
end
|
|
66
|
+
lines.join("\n")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def coverage_text
|
|
70
|
+
return "n/a" unless coverage
|
|
71
|
+
return coverage.status.to_s unless coverage.available
|
|
72
|
+
|
|
73
|
+
format_percent(coverage.line_percent)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def coverage_threshold(key)
|
|
77
|
+
return nil unless coverage
|
|
78
|
+
|
|
79
|
+
coverage.thresholds[key] || coverage.thresholds[key.to_s]
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def format_percent(value)
|
|
83
|
+
return "n/a" if value.nil?
|
|
84
|
+
|
|
85
|
+
format("%.2f%%", value)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def json_payload
|
|
89
|
+
JSON.generate(@result.to_h).gsub(/[<>&]/, "<" => "\\u003c", ">" => "\\u003e", "&" => "\\u0026")
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def template
|
|
93
|
+
<<~'HTML'
|
|
94
|
+
<!doctype html>
|
|
95
|
+
<html lang="en">
|
|
96
|
+
<head>
|
|
97
|
+
<meta charset="utf-8">
|
|
98
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
99
|
+
<title>Rails Doctor Report</title>
|
|
100
|
+
<style>
|
|
101
|
+
:root {
|
|
102
|
+
--bg: #f7f5f0;
|
|
103
|
+
--ink: #171717;
|
|
104
|
+
--muted: #68645e;
|
|
105
|
+
--panel: #fffdf8;
|
|
106
|
+
--line: #d8d2c7;
|
|
107
|
+
--accent: #0f766e;
|
|
108
|
+
--critical: #9f1239;
|
|
109
|
+
--high: #b45309;
|
|
110
|
+
--medium: #0369a1;
|
|
111
|
+
--low: #4d7c0f;
|
|
112
|
+
--info: #52525b;
|
|
113
|
+
}
|
|
114
|
+
* { box-sizing: border-box; }
|
|
115
|
+
body {
|
|
116
|
+
margin: 0;
|
|
117
|
+
background: var(--bg);
|
|
118
|
+
color: var(--ink);
|
|
119
|
+
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
120
|
+
line-height: 1.45;
|
|
121
|
+
}
|
|
122
|
+
header {
|
|
123
|
+
min-height: 360px;
|
|
124
|
+
padding: 48px clamp(24px, 6vw, 80px);
|
|
125
|
+
background:
|
|
126
|
+
linear-gradient(120deg, rgba(15, 118, 110, 0.18), transparent 42%),
|
|
127
|
+
linear-gradient(180deg, #161616 0%, #29251f 100%);
|
|
128
|
+
color: #fffdf8;
|
|
129
|
+
display: grid;
|
|
130
|
+
align-items: end;
|
|
131
|
+
}
|
|
132
|
+
.hero {
|
|
133
|
+
max-width: 1180px;
|
|
134
|
+
width: 100%;
|
|
135
|
+
margin: 0 auto;
|
|
136
|
+
display: grid;
|
|
137
|
+
grid-template-columns: minmax(0, 1.2fr) minmax(260px, .8fr);
|
|
138
|
+
gap: 48px;
|
|
139
|
+
align-items: end;
|
|
140
|
+
}
|
|
141
|
+
.brand { font-size: clamp(48px, 9vw, 116px); line-height: .88; letter-spacing: 0; margin: 0 0 18px; }
|
|
142
|
+
.subtitle { max-width: 620px; color: #d8d2c7; font-size: 18px; margin: 0; }
|
|
143
|
+
.score-ring {
|
|
144
|
+
border: 1px solid rgba(255,255,255,.24);
|
|
145
|
+
padding: 28px;
|
|
146
|
+
background: rgba(255,255,255,.06);
|
|
147
|
+
backdrop-filter: blur(8px);
|
|
148
|
+
}
|
|
149
|
+
.score-number { font-size: clamp(64px, 10vw, 120px); line-height: .9; font-weight: 800; }
|
|
150
|
+
.score-label { color: #d8d2c7; text-transform: uppercase; font-size: 12px; letter-spacing: .12em; }
|
|
151
|
+
main { max-width: 1180px; margin: 0 auto; padding: 36px clamp(20px, 4vw, 48px) 80px; }
|
|
152
|
+
.metrics {
|
|
153
|
+
display: grid;
|
|
154
|
+
grid-template-columns: repeat(6, minmax(0, 1fr));
|
|
155
|
+
border-top: 1px solid var(--line);
|
|
156
|
+
border-bottom: 1px solid var(--line);
|
|
157
|
+
margin-bottom: 36px;
|
|
158
|
+
}
|
|
159
|
+
.metric { padding: 18px 16px; border-right: 1px solid var(--line); }
|
|
160
|
+
.metric:last-child { border-right: 0; }
|
|
161
|
+
.metric span { display: block; color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .1em; }
|
|
162
|
+
.metric strong { display: block; margin-top: 6px; font-size: 28px; }
|
|
163
|
+
.section { margin: 44px 0; }
|
|
164
|
+
.section h2 { font-size: 28px; margin: 0 0 16px; }
|
|
165
|
+
.filters { display: flex; flex-wrap: wrap; gap: 10px; margin: 14px 0 24px; }
|
|
166
|
+
.filters button {
|
|
167
|
+
border: 1px solid var(--line);
|
|
168
|
+
background: transparent;
|
|
169
|
+
color: var(--ink);
|
|
170
|
+
padding: 8px 12px;
|
|
171
|
+
cursor: pointer;
|
|
172
|
+
font: inherit;
|
|
173
|
+
}
|
|
174
|
+
.filters button.active { background: var(--ink); color: var(--bg); }
|
|
175
|
+
table { width: 100%; border-collapse: collapse; background: var(--panel); }
|
|
176
|
+
th, td { padding: 12px 10px; border-bottom: 1px solid var(--line); text-align: left; vertical-align: top; }
|
|
177
|
+
th { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .08em; }
|
|
178
|
+
.chip { display: inline-block; padding: 3px 8px; color: white; font-size: 12px; font-weight: 700; text-transform: uppercase; }
|
|
179
|
+
.critical { background: var(--critical); }
|
|
180
|
+
.high { background: var(--high); }
|
|
181
|
+
.medium { background: var(--medium); }
|
|
182
|
+
.low { background: var(--low); }
|
|
183
|
+
.info { background: var(--info); }
|
|
184
|
+
.top-fix {
|
|
185
|
+
display: grid;
|
|
186
|
+
grid-template-columns: 110px minmax(0, 1fr);
|
|
187
|
+
gap: 18px;
|
|
188
|
+
padding: 18px 0;
|
|
189
|
+
border-top: 1px solid var(--line);
|
|
190
|
+
}
|
|
191
|
+
.agent {
|
|
192
|
+
background: #171717;
|
|
193
|
+
color: #f7f5f0;
|
|
194
|
+
padding: 20px;
|
|
195
|
+
overflow: auto;
|
|
196
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
197
|
+
font-size: 13px;
|
|
198
|
+
}
|
|
199
|
+
details { border-top: 1px solid var(--line); padding: 14px 0; }
|
|
200
|
+
summary { cursor: pointer; font-weight: 700; }
|
|
201
|
+
pre { white-space: pre-wrap; overflow: auto; background: #171717; color: #f7f5f0; padding: 16px; }
|
|
202
|
+
.coverage-summary {
|
|
203
|
+
display: grid;
|
|
204
|
+
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
205
|
+
border-top: 1px solid var(--line);
|
|
206
|
+
border-bottom: 1px solid var(--line);
|
|
207
|
+
margin-bottom: 18px;
|
|
208
|
+
}
|
|
209
|
+
.coverage-item { padding: 16px 14px; border-right: 1px solid var(--line); }
|
|
210
|
+
.coverage-item:last-child { border-right: 0; }
|
|
211
|
+
.coverage-item span { display: block; color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .08em; }
|
|
212
|
+
.coverage-item strong { display: block; margin-top: 4px; font-size: 22px; }
|
|
213
|
+
@media (max-width: 820px) {
|
|
214
|
+
.hero { grid-template-columns: 1fr; }
|
|
215
|
+
.metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
216
|
+
.coverage-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
217
|
+
.metric { border-bottom: 1px solid var(--line); }
|
|
218
|
+
.coverage-item { border-bottom: 1px solid var(--line); }
|
|
219
|
+
table, thead, tbody, tr, th, td { display: block; }
|
|
220
|
+
thead { display: none; }
|
|
221
|
+
tr { border-bottom: 1px solid var(--line); padding: 10px 0; }
|
|
222
|
+
td { border: 0; }
|
|
223
|
+
.top-fix { grid-template-columns: 1fr; }
|
|
224
|
+
}
|
|
225
|
+
</style>
|
|
226
|
+
</head>
|
|
227
|
+
<body>
|
|
228
|
+
<header>
|
|
229
|
+
<div class="hero">
|
|
230
|
+
<div>
|
|
231
|
+
<h1 class="brand">Rails Doctor</h1>
|
|
232
|
+
<p class="subtitle">A Rails health report for developers, CI, and AI coding agents. Generated from normalized scanner findings, runtime signals, and Rails-specific checks.</p>
|
|
233
|
+
</div>
|
|
234
|
+
<div class="score-ring">
|
|
235
|
+
<div class="score-label">Overall health</div>
|
|
236
|
+
<div class="score-number"><%= h(@result.score&.overall || "n/a") %></div>
|
|
237
|
+
<div>Changed files: <strong><%= h(@result.score&.changed_files || "n/a") %></strong> · Confidence: <strong><%= h(@result.score&.confidence || "n/a") %>%</strong></div>
|
|
238
|
+
</div>
|
|
239
|
+
</div>
|
|
240
|
+
</header>
|
|
241
|
+
<main>
|
|
242
|
+
<section class="metrics" aria-label="Report summary">
|
|
243
|
+
<div class="metric"><span>Critical</span><strong><%= severity_counts["critical"] %></strong></div>
|
|
244
|
+
<div class="metric"><span>High</span><strong><%= severity_counts["high"] %></strong></div>
|
|
245
|
+
<div class="metric"><span>Medium</span><strong><%= severity_counts["medium"] %></strong></div>
|
|
246
|
+
<div class="metric"><span>Coverage</span><strong><%= h(coverage_text) %></strong></div>
|
|
247
|
+
<div class="metric"><span>Skipped</span><strong><%= @result.skipped_tools.size %></strong></div>
|
|
248
|
+
<div class="metric"><span>Duration</span><strong><%= @result.duration_ms || 0 %>ms</strong></div>
|
|
249
|
+
</section>
|
|
250
|
+
|
|
251
|
+
<section class="section">
|
|
252
|
+
<h2>Coverage</h2>
|
|
253
|
+
<% if coverage.nil? %>
|
|
254
|
+
<p>No coverage metrics were captured.</p>
|
|
255
|
+
<% elsif !coverage.available %>
|
|
256
|
+
<div class="coverage-summary" aria-label="Coverage summary">
|
|
257
|
+
<div class="coverage-item"><span>Status</span><strong><%= h(coverage.status) %></strong></div>
|
|
258
|
+
<div class="coverage-item"><span>Source</span><strong><%= h(coverage.source) %></strong></div>
|
|
259
|
+
<div class="coverage-item"><span>Report path</span><strong><%= h(coverage.report_path) %></strong></div>
|
|
260
|
+
<div class="coverage-item"><span>Threshold</span><strong><%= h(format_percent(coverage_threshold(:line))) %></strong></div>
|
|
261
|
+
</div>
|
|
262
|
+
<% else %>
|
|
263
|
+
<div class="coverage-summary" aria-label="Coverage summary">
|
|
264
|
+
<div class="coverage-item"><span>Line coverage</span><strong><%= h(format_percent(coverage.line_percent)) %></strong></div>
|
|
265
|
+
<div class="coverage-item"><span>Line threshold</span><strong><%= h(format_percent(coverage_threshold(:line))) %></strong></div>
|
|
266
|
+
<div class="coverage-item"><span>Covered lines</span><strong><%= h("#{coverage.covered_lines}/#{coverage.total_lines}") %></strong></div>
|
|
267
|
+
<div class="coverage-item"><span>Branch coverage</span><strong><%= h(format_percent(coverage.branch_percent)) %></strong></div>
|
|
268
|
+
</div>
|
|
269
|
+
<% if low_coverage_files.any? %>
|
|
270
|
+
<table>
|
|
271
|
+
<thead><tr><th>File</th><th>Line coverage</th><th>Covered</th><th>Threshold</th></tr></thead>
|
|
272
|
+
<tbody>
|
|
273
|
+
<% low_coverage_files.each do |file| %>
|
|
274
|
+
<tr>
|
|
275
|
+
<td><%= h(file.fetch(:file)) %></td>
|
|
276
|
+
<td><%= h(format_percent(file.fetch(:line_percent))) %></td>
|
|
277
|
+
<td><%= h("#{file.fetch(:covered_lines)}/#{file.fetch(:total_lines)}") %></td>
|
|
278
|
+
<td><%= h(format_percent(coverage_threshold(:file_line))) %></td>
|
|
279
|
+
</tr>
|
|
280
|
+
<% end %>
|
|
281
|
+
</tbody>
|
|
282
|
+
</table>
|
|
283
|
+
<% else %>
|
|
284
|
+
<p>All reported files meet the configured coverage threshold.</p>
|
|
285
|
+
<% end %>
|
|
286
|
+
<% end %>
|
|
287
|
+
</section>
|
|
288
|
+
|
|
289
|
+
<section class="section">
|
|
290
|
+
<h2>Top Fixes</h2>
|
|
291
|
+
<% if top_findings.empty? %>
|
|
292
|
+
<p>No findings detected.</p>
|
|
293
|
+
<% end %>
|
|
294
|
+
<% top_findings.each do |finding| %>
|
|
295
|
+
<article class="top-fix">
|
|
296
|
+
<div><span class="chip <%= h(finding.severity) %>"><%= h(finding.severity) %></span></div>
|
|
297
|
+
<div>
|
|
298
|
+
<strong><%= h(finding.message) %></strong>
|
|
299
|
+
<% if finding.file %><div><%= h([finding.file, finding.line].compact.join(":")) %></div><% end %>
|
|
300
|
+
<p><%= h(finding.recommendation) %></p>
|
|
301
|
+
</div>
|
|
302
|
+
</article>
|
|
303
|
+
<% end %>
|
|
304
|
+
</section>
|
|
305
|
+
|
|
306
|
+
<section class="section">
|
|
307
|
+
<h2>Agent Brief</h2>
|
|
308
|
+
<pre class="agent"><%= h(agent_brief_text) %></pre>
|
|
309
|
+
</section>
|
|
310
|
+
|
|
311
|
+
<section class="section">
|
|
312
|
+
<h2>Findings</h2>
|
|
313
|
+
<div class="filters" role="toolbar" aria-label="Finding filters">
|
|
314
|
+
<% %w[all critical high medium low info].each do |severity| %>
|
|
315
|
+
<button type="button" data-filter="<%= severity %>" class="<%= severity == "all" ? "active" : "" %>"><%= severity.capitalize %></button>
|
|
316
|
+
<% end %>
|
|
317
|
+
</div>
|
|
318
|
+
<table>
|
|
319
|
+
<thead><tr><th>Severity</th><th>Tool</th><th>Category</th><th>Location</th><th>Finding</th></tr></thead>
|
|
320
|
+
<tbody>
|
|
321
|
+
<% @result.findings.each do |finding| %>
|
|
322
|
+
<tr data-severity="<%= h(finding.severity) %>">
|
|
323
|
+
<td><span class="chip <%= h(finding.severity) %>"><%= h(finding.severity) %></span></td>
|
|
324
|
+
<td><%= h(finding.tool) %></td>
|
|
325
|
+
<td><%= h(finding.category) %></td>
|
|
326
|
+
<td><%= h([finding.file, finding.line].compact.join(":")) %></td>
|
|
327
|
+
<td><strong><%= h(finding.message) %></strong><br><%= h(finding.recommendation) %></td>
|
|
328
|
+
</tr>
|
|
329
|
+
<% end %>
|
|
330
|
+
</tbody>
|
|
331
|
+
</table>
|
|
332
|
+
</section>
|
|
333
|
+
|
|
334
|
+
<section class="section">
|
|
335
|
+
<h2>Hotspots</h2>
|
|
336
|
+
<table>
|
|
337
|
+
<thead><tr><th>File</th><th>Score</th><th>Findings</th><th>Churn</th><th>Changed</th><th>Summary</th></tr></thead>
|
|
338
|
+
<tbody>
|
|
339
|
+
<% @result.hotspots.each do |hotspot| %>
|
|
340
|
+
<tr>
|
|
341
|
+
<td><%= h(hotspot.file) %></td>
|
|
342
|
+
<td><%= h(hotspot.score) %></td>
|
|
343
|
+
<td><%= h(hotspot.finding_count) %></td>
|
|
344
|
+
<td><%= h(hotspot.churn) %></td>
|
|
345
|
+
<td><%= h(hotspot.changed) %></td>
|
|
346
|
+
<td><%= h(hotspot.summary) %></td>
|
|
347
|
+
</tr>
|
|
348
|
+
<% end %>
|
|
349
|
+
</tbody>
|
|
350
|
+
</table>
|
|
351
|
+
</section>
|
|
352
|
+
|
|
353
|
+
<section class="section">
|
|
354
|
+
<h2>Skipped Tools</h2>
|
|
355
|
+
<% if @result.skipped_tools.empty? %>
|
|
356
|
+
<p>No tools were skipped.</p>
|
|
357
|
+
<% else %>
|
|
358
|
+
<% @result.skipped_tools.each do |tool| %>
|
|
359
|
+
<details open>
|
|
360
|
+
<summary><%= h(tool.name) %></summary>
|
|
361
|
+
<p><%= h(tool.skip_reason) %></p>
|
|
362
|
+
<p><%= h(tool.metadata[:install]) %></p>
|
|
363
|
+
</details>
|
|
364
|
+
<% end %>
|
|
365
|
+
<% end %>
|
|
366
|
+
</section>
|
|
367
|
+
|
|
368
|
+
<section class="section">
|
|
369
|
+
<h2>Raw Tool Output</h2>
|
|
370
|
+
<% if raw_tool_runs.empty? %>
|
|
371
|
+
<p>No raw output captured.</p>
|
|
372
|
+
<% end %>
|
|
373
|
+
<% raw_tool_runs.each do |tool| %>
|
|
374
|
+
<details>
|
|
375
|
+
<summary><%= h(tool.name) %></summary>
|
|
376
|
+
<pre><%= h([tool.stdout, tool.stderr].join("\\n")) %></pre>
|
|
377
|
+
</details>
|
|
378
|
+
<% end %>
|
|
379
|
+
</section>
|
|
380
|
+
</main>
|
|
381
|
+
<script type="application/json" id="rails-doctor-data"><%= json_payload %></script>
|
|
382
|
+
<script>
|
|
383
|
+
document.querySelectorAll("[data-filter]").forEach((button) => {
|
|
384
|
+
button.addEventListener("click", () => {
|
|
385
|
+
document.querySelectorAll("[data-filter]").forEach((item) => item.classList.remove("active"));
|
|
386
|
+
button.classList.add("active");
|
|
387
|
+
const filter = button.dataset.filter;
|
|
388
|
+
document.querySelectorAll("tr[data-severity]").forEach((row) => {
|
|
389
|
+
row.style.display = filter === "all" || row.dataset.severity === filter ? "" : "none";
|
|
390
|
+
});
|
|
391
|
+
});
|
|
392
|
+
});
|
|
393
|
+
</script>
|
|
394
|
+
</body>
|
|
395
|
+
</html>
|
|
396
|
+
HTML
|
|
397
|
+
end
|
|
398
|
+
end
|
|
399
|
+
end
|
|
400
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module RailsDoctor
|
|
6
|
+
module Reporters
|
|
7
|
+
class Json
|
|
8
|
+
def initialize(result, include_raw: false)
|
|
9
|
+
@result = result
|
|
10
|
+
@include_raw = include_raw
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def render
|
|
14
|
+
JSON.pretty_generate(@result.to_h(include_raw: @include_raw)) + "\n"
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsDoctor
|
|
4
|
+
module Reporters
|
|
5
|
+
class Markdown
|
|
6
|
+
def initialize(result)
|
|
7
|
+
@result = result
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def render
|
|
11
|
+
lines = []
|
|
12
|
+
lines << "# Rails Doctor Report"
|
|
13
|
+
lines << ""
|
|
14
|
+
lines << "- Profile: `#{@result.profile}`"
|
|
15
|
+
lines << "- Overall score: `#{@result.score&.overall || "n/a"}/100`"
|
|
16
|
+
lines << "- Changed-files score: `#{@result.score&.changed_files || "n/a"}/100`"
|
|
17
|
+
lines << "- Confidence: `#{@result.score&.confidence || "n/a"}%`"
|
|
18
|
+
lines << "- Coverage: `#{coverage_text}`"
|
|
19
|
+
lines << "- Findings: `#{@result.findings.size}`"
|
|
20
|
+
lines << "- Duration: `#{@result.duration_ms}ms`"
|
|
21
|
+
lines << ""
|
|
22
|
+
lines << "## Coverage"
|
|
23
|
+
lines << ""
|
|
24
|
+
lines.concat(coverage_lines)
|
|
25
|
+
lines << ""
|
|
26
|
+
lines << "## Severity Breakdown"
|
|
27
|
+
lines << ""
|
|
28
|
+
@result.summary.fetch(:severity_counts).each do |severity, count|
|
|
29
|
+
lines << "- `#{severity}`: #{count}"
|
|
30
|
+
end
|
|
31
|
+
lines << ""
|
|
32
|
+
lines << "## Skipped Tools"
|
|
33
|
+
lines << ""
|
|
34
|
+
if @result.skipped_tools.empty?
|
|
35
|
+
lines << "No tools were skipped."
|
|
36
|
+
else
|
|
37
|
+
@result.skipped_tools.each do |tool|
|
|
38
|
+
lines << "- **#{tool.name}**: #{tool.skip_reason}"
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
lines << ""
|
|
42
|
+
lines << "## Top Findings"
|
|
43
|
+
lines << ""
|
|
44
|
+
top_findings.each do |finding|
|
|
45
|
+
lines << "### #{finding.severity.upcase}: #{finding.message}"
|
|
46
|
+
lines << ""
|
|
47
|
+
lines << "- Tool: `#{finding.tool}`"
|
|
48
|
+
lines << "- Category: `#{finding.category}`"
|
|
49
|
+
lines << "- Location: `#{[finding.file, finding.line].compact.join(":")}`" if finding.file
|
|
50
|
+
lines << "- Confidence: `#{finding.confidence}`"
|
|
51
|
+
lines << ""
|
|
52
|
+
lines << finding.recommendation.to_s
|
|
53
|
+
lines << ""
|
|
54
|
+
lines << "**Agent instruction:** #{finding.agent_instruction}" if finding.agent_instruction
|
|
55
|
+
lines << ""
|
|
56
|
+
end
|
|
57
|
+
lines << "No findings." if top_findings.empty?
|
|
58
|
+
lines << ""
|
|
59
|
+
lines << "## Hotspots"
|
|
60
|
+
lines << ""
|
|
61
|
+
if @result.hotspots.empty?
|
|
62
|
+
lines << "No hotspots detected."
|
|
63
|
+
else
|
|
64
|
+
@result.hotspots.each do |hotspot|
|
|
65
|
+
lines << "- `#{hotspot.file}`: score #{hotspot.score}, #{hotspot.finding_count} findings, churn #{hotspot.churn}, changed=#{hotspot.changed}"
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
lines << ""
|
|
69
|
+
lines.join("\n")
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def top_findings
|
|
75
|
+
@result.findings.sort_by { |finding| -SEVERITY_WEIGHTS.fetch(finding.severity, 0) }.first(20)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def coverage_lines
|
|
79
|
+
coverage = @result.coverage
|
|
80
|
+
return ["No coverage metrics were captured."] unless coverage
|
|
81
|
+
|
|
82
|
+
unless coverage.available
|
|
83
|
+
return [
|
|
84
|
+
"- Status: `#{coverage.status}`",
|
|
85
|
+
"- Source: `#{coverage.source}`",
|
|
86
|
+
"- Report path: `#{coverage.report_path}`"
|
|
87
|
+
]
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
lines = [
|
|
91
|
+
"- Line coverage: `#{format_percent(coverage.line_percent)}`",
|
|
92
|
+
"- Line threshold: `#{format_percent(threshold(:line))}`",
|
|
93
|
+
"- Covered lines: `#{coverage.covered_lines}/#{coverage.total_lines}`"
|
|
94
|
+
]
|
|
95
|
+
lines << "- Branch coverage: `#{format_percent(coverage.branch_percent)}`" if coverage.branch_percent
|
|
96
|
+
low_files = low_coverage_files(coverage)
|
|
97
|
+
if low_files.any?
|
|
98
|
+
lines << ""
|
|
99
|
+
lines << "Low-coverage files:"
|
|
100
|
+
low_files.first(10).each do |file|
|
|
101
|
+
lines << "- `#{file.fetch(:file)}`: #{format_percent(file.fetch(:line_percent))} lines (#{file.fetch(:covered_lines)}/#{file.fetch(:total_lines)})"
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
lines
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def coverage_text
|
|
108
|
+
coverage = @result.coverage
|
|
109
|
+
return "n/a" unless coverage
|
|
110
|
+
return "#{coverage.status} at #{coverage.report_path}" unless coverage.available
|
|
111
|
+
|
|
112
|
+
"#{format_percent(coverage.line_percent)} lines"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def low_coverage_files(coverage)
|
|
116
|
+
low_files = coverage.top_files.select { |file| file[:below_threshold] }
|
|
117
|
+
(coverage.changed_files_below_threshold + low_files).uniq { |file| file.fetch(:file) }
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def threshold(key)
|
|
121
|
+
coverage = @result.coverage
|
|
122
|
+
coverage.thresholds[key] || coverage.thresholds[key.to_s]
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def format_percent(value)
|
|
126
|
+
return "n/a" if value.nil?
|
|
127
|
+
|
|
128
|
+
format("%.2f%%", value)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsDoctor
|
|
4
|
+
module Reporters
|
|
5
|
+
class Terminal
|
|
6
|
+
def initialize(result)
|
|
7
|
+
@result = result
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def render
|
|
11
|
+
lines = []
|
|
12
|
+
lines << "Rails Doctor"
|
|
13
|
+
lines << ("=" * 12)
|
|
14
|
+
lines << "Profile: #{@result.profile}"
|
|
15
|
+
lines << "Score: #{score_text}"
|
|
16
|
+
lines << "Confidence: #{@result.score&.confidence || "n/a"}%"
|
|
17
|
+
lines << "Coverage: #{coverage_text}"
|
|
18
|
+
lines << "Findings: #{severity_counts}"
|
|
19
|
+
lines << "Duration: #{@result.duration_ms}ms" if @result.duration_ms
|
|
20
|
+
lines << ""
|
|
21
|
+
|
|
22
|
+
if @result.skipped_tools.any?
|
|
23
|
+
lines << "Skipped tools"
|
|
24
|
+
@result.skipped_tools.each do |tool|
|
|
25
|
+
lines << "- #{tool.name}: #{tool.skip_reason}"
|
|
26
|
+
lines << " #{tool.metadata[:install]}" if tool.metadata[:install]
|
|
27
|
+
end
|
|
28
|
+
lines << ""
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
if low_coverage_files.any?
|
|
32
|
+
lines << "Low coverage files"
|
|
33
|
+
low_coverage_files.each do |file|
|
|
34
|
+
lines << "- #{file.fetch(:file)}: #{format_percent(file.fetch(:line_percent))} lines"
|
|
35
|
+
end
|
|
36
|
+
lines << ""
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
lines << "Top fixes"
|
|
40
|
+
top_findings.each do |finding|
|
|
41
|
+
location = [finding.file, finding.line].compact.join(":")
|
|
42
|
+
lines << "- [#{finding.severity}] #{finding.message}"
|
|
43
|
+
lines << " #{location}" unless location.empty?
|
|
44
|
+
lines << " #{finding.recommendation}" if finding.recommendation
|
|
45
|
+
end
|
|
46
|
+
lines << "- No findings. Keep running Rails Doctor in CI." if top_findings.empty?
|
|
47
|
+
|
|
48
|
+
if @result.hotspots.any?
|
|
49
|
+
lines << ""
|
|
50
|
+
lines << "Hotspots"
|
|
51
|
+
@result.hotspots.first(5).each do |hotspot|
|
|
52
|
+
lines << "- #{hotspot.file}: score #{hotspot.score}, #{hotspot.finding_count} findings, churn #{hotspot.churn}"
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
lines.join("\n") + "\n"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def score_text
|
|
62
|
+
score = @result.score
|
|
63
|
+
return "n/a" unless score
|
|
64
|
+
|
|
65
|
+
"#{score.overall}/100 overall, #{score.changed_files}/100 changed files"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def severity_counts
|
|
69
|
+
counts = @result.summary.fetch(:severity_counts)
|
|
70
|
+
%w[critical high medium low info].map { |severity| "#{severity}=#{counts[severity]}" }.join(", ")
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def coverage_text
|
|
74
|
+
coverage = @result.coverage
|
|
75
|
+
return "n/a" unless coverage
|
|
76
|
+
return "#{coverage.status} (#{coverage.report_path})" unless coverage.available
|
|
77
|
+
|
|
78
|
+
threshold = coverage.thresholds[:line] || coverage.thresholds["line"]
|
|
79
|
+
"#{format_percent(coverage.line_percent)} lines (threshold #{format_percent(threshold)})"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def low_coverage_files
|
|
83
|
+
coverage = @result.coverage
|
|
84
|
+
return [] unless coverage&.available
|
|
85
|
+
|
|
86
|
+
low_files = coverage.top_files.select { |file| file[:below_threshold] }
|
|
87
|
+
(coverage.changed_files_below_threshold + low_files).uniq { |file| file.fetch(:file) }.first(5)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def format_percent(value)
|
|
91
|
+
return "n/a" if value.nil?
|
|
92
|
+
|
|
93
|
+
format("%.2f%%", value)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def top_findings
|
|
97
|
+
@result.findings.sort_by { |finding| -SEVERITY_WEIGHTS.fetch(finding.severity, 0) }.first(8)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|