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.
Files changed (51) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +11 -0
  3. data/LICENSE +21 -0
  4. data/README.md +148 -0
  5. data/docs/adapter-architecture.md +21 -0
  6. data/docs/agent-handoff.md +22 -0
  7. data/docs/architecture.md +21 -0
  8. data/docs/cli-reference.md +48 -0
  9. data/docs/config-reference.md +50 -0
  10. data/docs/github-actions.md +36 -0
  11. data/docs/monetization.md +14 -0
  12. data/docs/output-schema.md +57 -0
  13. data/docs/scoring-model.md +23 -0
  14. data/examples/github-actions/rails-doctor.yml +40 -0
  15. data/examples/report.html +704 -0
  16. data/examples/report.json +971 -0
  17. data/examples/report.md +261 -0
  18. data/examples/report.txt +45 -0
  19. data/exe/rails-doctor +8 -0
  20. data/lib/rails_doctor/adapters/base.rb +109 -0
  21. data/lib/rails_doctor/adapters/brakeman.rb +47 -0
  22. data/lib/rails_doctor/adapters/bundler_audit.rb +54 -0
  23. data/lib/rails_doctor/adapters/dependency_freshness.rb +51 -0
  24. data/lib/rails_doctor/adapters/flay.rb +41 -0
  25. data/lib/rails_doctor/adapters/flog.rb +41 -0
  26. data/lib/rails_doctor/adapters/reek.rb +39 -0
  27. data/lib/rails_doctor/adapters/rubocop.rb +40 -0
  28. data/lib/rails_doctor/adapters/strong_migrations.rb +52 -0
  29. data/lib/rails_doctor/adapters/test_coverage.rb +400 -0
  30. data/lib/rails_doctor/adapters/test_runner.rb +79 -0
  31. data/lib/rails_doctor/adapters/zeitwerk.rb +42 -0
  32. data/lib/rails_doctor/agent/handoff.rb +159 -0
  33. data/lib/rails_doctor/checks/rails_checks.rb +371 -0
  34. data/lib/rails_doctor/cli.rb +232 -0
  35. data/lib/rails_doctor/command_runner.rb +55 -0
  36. data/lib/rails_doctor/config.rb +161 -0
  37. data/lib/rails_doctor/init/runner.rb +191 -0
  38. data/lib/rails_doctor/models.rb +280 -0
  39. data/lib/rails_doctor/project.rb +95 -0
  40. data/lib/rails_doctor/reporters/html.rb +400 -0
  41. data/lib/rails_doctor/reporters/json.rb +18 -0
  42. data/lib/rails_doctor/reporters/markdown.rb +132 -0
  43. data/lib/rails_doctor/reporters/terminal.rb +101 -0
  44. data/lib/rails_doctor/scanner.rb +173 -0
  45. data/lib/rails_doctor/scorer.rb +74 -0
  46. data/lib/rails_doctor/version.rb +5 -0
  47. data/lib/rails_doctor.rb +15 -0
  48. data/site/assets/cli-output.png +0 -0
  49. data/site/assets/report-preview.png +0 -0
  50. data/site/index.html +294 -0
  51. 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