rails_preflight 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,496 @@
1
+ require 'erb'
2
+ require_relative 'version'
3
+
4
+ module RailsPreflight
5
+ class ReportGenerator
6
+ include ERB::Util
7
+
8
+ KIND_LABELS = { broken: "Broken", blocker: "Blocker", to_fix: "To fix", tip: "Tip", later: "Later" }.freeze
9
+ KIND_CLASSES = { broken: "broken", blocker: "blocker", to_fix: "fix", tip: "tip", later: "later" }.freeze
10
+ # Short label for the right-hand column when a finding has no link
11
+ SECTION_LABELS = { "Gem Compatibility" => "Gems", "Private Gems" => "Gems", "Deprecation Warnings" => "Code",
12
+ "Configuration" => "Config", "Database Schema" => "Schema", "Ruby Version" => "Ruby",
13
+ "Docker Configuration" => "Docker", "Rails Version" => "Rails" }.freeze
14
+ GEM_SECTIONS = ["Gem Compatibility", "Private Gems"].freeze
15
+ EFFORTS = %w[low medium high].freeze
16
+ HELP_URL = "https://syedaslam.com/work-with-me/".freeze
17
+ REPO_URL = "https://github.com/aslam/rails-preflight".freeze
18
+
19
+ # data: :results from the analyzers, :summary from SummaryCalculator, plus :app, :current_rails, :target_rails,
20
+ # :ruby ([version, source]), :offline, :looked_up (gems asked about on rubygems.org) and :later_rails (known versions past the target).
21
+ def initialize(data)
22
+ @data = data
23
+ @summary = data[:summary] || {}
24
+ @generated_at = Time.now
25
+ end
26
+
27
+ def generate
28
+ # Quoted heredoc: #{} inside the template is evaluated by ERB at render time, not here.
29
+ template = <<~'ERB'
30
+ <!DOCTYPE html>
31
+ <html lang="en">
32
+ <head>
33
+ <meta charset="UTF-8">
34
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
35
+ <title><%= h(@data[:app] || "Rails") %>: Rails <%= h(current_label) %> to <%= h(@data[:target_rails]) %></title>
36
+ <style>
37
+ /* An engineer's checklist on a sheet of paper. System fonts only: the report makes no network requests. */
38
+ :root {
39
+ --desk: #eceef1; --paper: #ffffff; --card: #fafbfc; --ink: #18202c; --muted: #5a6474; --rule: #d9dee5; --tint: #f1f5fa;
40
+ --accent: #1f4f8f; --broken: #8a1c2b; --blocker: #b8322b; --fix: #9a5a00; --unknown: #64708a;
41
+ --blocker-bg: #fbeceb;
42
+ --display: "Iowan Old Style", "Palatino Linotype", Palatino, Georgia, serif;
43
+ --sans: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
44
+ --mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
45
+ color-scheme: light;
46
+ }
47
+ @media (prefers-color-scheme: dark) { :root {
48
+ --desk: #0e1218; --paper: #161b23; --card: #1a202a; --ink: #e5e8ee; --muted: #9aa3b2; --rule: #2c3442; --tint: #1d2532;
49
+ --accent: #8fb3ea; --broken: #f08a98; --blocker: #ff8a80; --fix: #f0b35a; --unknown: #a0a9bb; --blocker-bg: #2d1615;
50
+ color-scheme: dark; } }
51
+
52
+ * { box-sizing: border-box; }
53
+ body { margin: 0; background: var(--desk); color: var(--ink); font: 15px/1.55 var(--sans); }
54
+ .outer { padding: 24px 16px 64px; }
55
+ .sheet { max-width: 960px; margin: 0 auto; background: var(--paper); padding: clamp(20px, 5vw, 48px); display: grid; gap: 36px; box-shadow: 0 1px 2px rgba(0,0,0,.06), 0 8px 30px rgba(20,30,50,.06); }
56
+ a { color: var(--accent); }
57
+ a:focus-visible, summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
58
+ code { font: 0.88em var(--mono); }
59
+ h1, h2, h3 { margin: 0; text-wrap: balance; }
60
+ p { margin: 0; }
61
+
62
+ header { display: grid; gap: 12px; }
63
+ .tool { font: 500 12px var(--mono); color: var(--muted); letter-spacing: 0.03em; }
64
+ h1 { font: 500 clamp(30px, 5.4vw, 46px)/1.08 var(--display); letter-spacing: -0.01em; }
65
+ h1 .app { color: var(--accent); }
66
+ h1 .to { color: var(--muted); }
67
+ .verdict { font: 400 19px/1.45 var(--display); max-width: 62ch; }
68
+ .facts { display: flex; flex-wrap: wrap; gap: 4px 18px; color: var(--muted); font-size: 13px; }
69
+ .facts b { color: var(--ink); font-weight: 500; }
70
+
71
+ .counts { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); border-block: 1px solid var(--rule); }
72
+ .count { padding: 14px 16px 14px 0; display: grid; gap: 2px; align-content: start; text-decoration: none; color: inherit; }
73
+ .count + .count { padding-left: 16px; border-left: 1px solid var(--rule); }
74
+ .count:hover .l { text-decoration: underline; }
75
+ .count .n { font: 600 30px/1 var(--mono); color: var(--c); font-variant-numeric: tabular-nums; }
76
+ .count.zero .n { color: var(--muted); }
77
+ .count .l { font-weight: 600; font-size: 13px; }
78
+ .count .d { font-size: 12px; color: var(--muted); }
79
+ @media (max-width: 640px) { .counts { grid-template-columns: repeat(2, minmax(0, 1fr)); } .count:nth-child(3) { padding-left: 0; border-left: 0; } .count:nth-child(n+3) { border-top: 1px solid var(--rule); } }
80
+
81
+ h2 { font: 600 12.5px var(--mono); text-transform: uppercase; letter-spacing: 0.09em; color: var(--muted); }
82
+ .block { display: grid; gap: 12px; }
83
+
84
+ .route-box { border: 1px solid var(--rule); border-radius: 6px; }
85
+ .route-scroll { overflow-x: auto; }
86
+ .route { display: grid; grid-template-columns: repeat(var(--stops), minmax(88px, 1fr)); min-width: calc(var(--stops) * 90px); padding: 18px 8px 12px; }
87
+ .stop { display: grid; justify-items: center; align-content: start; gap: 6px; position: relative; text-decoration: none; color: inherit; }
88
+ .stop::before { content: ""; position: absolute; top: 9px; left: -50%; width: 100%; height: 2px; background: var(--rule); }
89
+ .stop:first-child::before { display: none; }
90
+ .dot { width: 20px; height: 20px; border-radius: 50%; background: var(--paper); border: 2px solid var(--muted); z-index: 1; }
91
+ .stop.now .dot { background: var(--ink); border-color: var(--ink); }
92
+ .stop.has .dot { border-color: var(--blocker); background: var(--blocker-bg); }
93
+ .stop.target .v { text-decoration: underline 2px; text-underline-offset: 4px; }
94
+ .stop.ahead { opacity: 0.6; }
95
+ .stop.ahead .dot { border-style: dashed; }
96
+ .stop.ahead::before { background: repeating-linear-gradient(90deg, var(--rule) 0 6px, transparent 6px 10px); }
97
+ .stop .v { font: 600 15px var(--mono); }
98
+ .stop .b { font-size: 12px; color: var(--muted); font-variant-numeric: tabular-nums; text-align: center; }
99
+ .stop.has .b { color: var(--blocker); font-weight: 500; }
100
+ .route-note { font-size: 12px; color: var(--muted); padding: 0 14px 12px; }
101
+
102
+ .table-scroll { overflow-x: auto; }
103
+ table.plan { border-collapse: collapse; width: 100%; min-width: 600px; font-size: 14px; }
104
+ .plan th { text-align: left; font: 600 11px var(--mono); text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted); padding: 8px 10px; border-bottom: 2px solid var(--ink); }
105
+ .plan td { padding: 11px 10px; border-bottom: 1px solid var(--rule); vertical-align: top; }
106
+ .plan td:first-child { font: 500 14px var(--mono); white-space: nowrap; }
107
+ .plan td:first-child a { color: inherit; }
108
+ .plan .num { text-align: right; font-variant-numeric: tabular-nums; }
109
+ .plan tr.target td { background: var(--tint); }
110
+ .plan tr.later td { color: var(--muted); }
111
+ .plan td:last-child { white-space: nowrap; }
112
+ .plan .ruby { display: block; margin-top: 3px; font-size: 12.5px; color: var(--accent); font-weight: 500; }
113
+ .effort { display: inline-flex; gap: 3px; vertical-align: middle; margin-right: 6px; }
114
+ .effort i { width: 9px; height: 9px; border-radius: 2px; background: var(--rule); }
115
+ .effort.low i:nth-child(-n+1), .effort.medium i:nth-child(-n+2), .effort.high i:nth-child(-n+3) { background: var(--ink); }
116
+
117
+ .step { background: var(--card); border: 1px solid var(--rule); border-radius: 6px; scroll-margin-top: 16px; }
118
+ .step.ahead-card { border-style: dashed; }
119
+ .step-head { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 6px 16px; padding: 11px 16px; border-bottom: 1px solid var(--rule); }
120
+ .step-head:last-child { border-bottom: 0; }
121
+ .step-head h3 { font: 600 17px var(--mono); }
122
+ .ahead-card .step-head h3 { color: var(--muted); }
123
+ .step-head .tag { font-size: 12px; color: var(--muted); }
124
+ .rubynote { display: flex; gap: 10px; align-items: baseline; padding: 9px 16px; font-size: 13px; color: var(--muted); border-bottom: 1px solid var(--rule); }
125
+ .rubynote:last-child { border-bottom: 0; }
126
+ .rubynote .k { font: 600 11px var(--mono); text-transform: uppercase; letter-spacing: 0.06em; min-width: 40px; color: var(--accent); }
127
+ ul.rows { list-style: none; margin: 0; padding: 0; }
128
+ .row { display: grid; grid-template-columns: 88px minmax(0, 1fr) auto; gap: 4px 14px; padding: 11px 16px; border-bottom: 1px solid var(--rule); align-items: start; }
129
+ .row:last-child { border-bottom: 0; }
130
+ .kind { font: 600 10.5px/2 var(--mono); text-transform: uppercase; letter-spacing: 0.05em; color: var(--c); }
131
+ .kind::before { content: ""; display: inline-block; width: 8px; height: 8px; border-radius: 2px; background: var(--c); margin-right: 6px; }
132
+ .msg { min-width: 0; overflow-wrap: anywhere; }
133
+ .meta { display: flex; flex-wrap: wrap; gap: 2px 12px; font-size: 12px; color: var(--muted); margin-top: 3px; font-variant-numeric: tabular-nums; }
134
+ .cite { font: 12px var(--mono); white-space: nowrap; color: var(--muted); }
135
+ a.cite { color: var(--accent); }
136
+ .empty { padding: 12px 16px; color: var(--muted); font-size: 14px; }
137
+ @media (max-width: 640px) { .row { grid-template-columns: minmax(0, 1fr); } .cite { white-space: normal; } }
138
+ .broken { --c: var(--broken); } .blocker { --c: var(--blocker); } .fix { --c: var(--fix); } .unknown { --c: var(--unknown); }
139
+ .tip { --c: var(--accent); } .later { --c: var(--muted); }
140
+
141
+ /* The meta line is the toggle for its files */
142
+ details.occ { margin-top: 3px; }
143
+ details.occ > summary { list-style: none; cursor: pointer; display: inline-flex; flex-wrap: wrap; gap: 2px 12px; font-size: 12px; color: var(--accent); font-variant-numeric: tabular-nums; }
144
+ details.occ > summary::-webkit-details-marker { display: none; }
145
+ details.occ > summary::before { content: "▸"; transition: transform .15s; }
146
+ details.occ[open] > summary::before { transform: rotate(90deg); }
147
+ @media (prefers-reduced-motion: reduce) { details.occ > summary::before { transition: none; } }
148
+ .snip { overflow: auto; max-height: 360px; margin-top: 8px; border: 1px solid var(--rule); border-radius: 6px; background: var(--paper); }
149
+ .snip table { border-collapse: collapse; font: 12px/1.5 var(--mono); width: 100%; }
150
+ .snip td { padding: 4px 10px; border-bottom: 1px solid var(--rule); white-space: nowrap; }
151
+ .snip td.line { color: var(--muted); text-align: right; }
152
+ .snip tr:last-child td { border-bottom: 0; }
153
+
154
+ .limits { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1px; background: var(--rule); border: 1px solid var(--rule); border-radius: 6px; overflow: hidden; }
155
+ .limit { background: var(--card); padding: 14px 16px; display: grid; gap: 6px; align-content: start; }
156
+ .limit h3 { font: 600 14.5px var(--sans); }
157
+ .limit p { font-size: 13.5px; color: var(--muted); }
158
+ .limit .next { color: var(--ink); }
159
+ .limit .next::before { content: "→ "; color: var(--accent); }
160
+ .names { font: 12px/1.7 var(--mono); color: var(--ink); overflow-wrap: anywhere; }
161
+ @media (max-width: 640px) { .limits { grid-template-columns: minmax(0, 1fr); } }
162
+
163
+ footer { border-top: 1px solid var(--rule); padding-top: 18px; display: grid; gap: 12px; font-size: 13px; color: var(--muted); }
164
+ footer dl { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 4px 14px; margin: 0; }
165
+ footer dt { font-weight: 600; color: var(--ink); }
166
+ footer dd { margin: 0; }
167
+ .sig { display: flex; flex-wrap: wrap; gap: 4px 18px; }
168
+
169
+ @media print {
170
+ :root { --desk: #fff; --paper: #fff; --card: #fff; --ink: #18202c; --muted: #5a6474; --rule: #d9dee5; --tint: #f1f5fa;
171
+ --accent: #1f4f8f; --broken: #8a1c2b; --blocker: #b8322b; --fix: #9a5a00; --unknown: #64708a; --blocker-bg: #fbeceb; color-scheme: light; }
172
+ /* Dots, effort bars and markers are backgrounds, which browsers drop when printing */
173
+ * { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
174
+ body { font-size: 10pt; }
175
+ .outer { padding: 0; }
176
+ .sheet { box-shadow: none; padding: 0 2mm; max-width: none; gap: 24px; }
177
+ details.occ > summary { color: var(--muted); }
178
+ details.occ > summary::before { content: ""; }
179
+ details.occ > :not(summary) { display: none; }
180
+ .route-scroll { overflow: visible; }
181
+ .route, table.plan { min-width: 0; }
182
+ .step-head, .rubynote, .row, .plan tr, .limit, .counts, .route-box { break-inside: avoid; }
183
+ a { color: inherit; text-decoration: none; }
184
+ @page { size: A4; margin: 14mm; }
185
+ }
186
+ </style>
187
+ </head>
188
+ <body>
189
+ <div class="outer">
190
+ <article class="sheet">
191
+ <header>
192
+ <div class="tool">rails-preflight <%= h(VERSION) %> · <%= h(@generated_at.strftime("%-d %b %Y, %H:%M")) %></div>
193
+ <h1><% if @data[:app] %><span class="app"><%= h(@data[:app]) %>:</span> <% end %>Rails <%= h(current_label) %> <span class="to">to</span> <%= h(@data[:target_rails]) %></h1>
194
+ <p class="verdict"><%= h(@summary[:verdict]) %></p>
195
+ <div class="facts">
196
+ <% ruby, source = @data[:ruby] %>
197
+ <% if ruby %><span>Ruby <b><%= h(ruby) %></b> (<%= h(source) %>)</span><% end %>
198
+ <% if @data[:offline] %><span><b>Offline</b>: rubygems.org not asked</span><% end %>
199
+ <% if @data[:looked_up] %><span>Online: <b><%= @data[:looked_up] %></b> gems looked up on rubygems.org</span><% end %>
200
+ </div>
201
+ </header>
202
+
203
+ <nav class="counts" aria-label="Totals">
204
+ <% count_tiles.each do |css, n, label, caption, href| %>
205
+ <a class="count <%= css %><%= " zero" if n.zero? %>" href="<%= href %>"><span class="n"><%= n %></span><span class="l"><%= h(label) %></span><span class="d"><%= h(caption) %></span></a>
206
+ <% end %>
207
+ </nav>
208
+
209
+ <section class="block" aria-labelledby="plan-h">
210
+ <h2 id="plan-h">Route and plan</h2>
211
+ <div class="route-box">
212
+ <div class="route-scroll">
213
+ <div class="route" style="--stops: <%= route_stops.size %>">
214
+ <% route_stops.each do |css, version, note, href| %>
215
+ <a class="stop <%= css %>" href="<%= href %>"><span class="dot"></span><span class="v"><%= h(version) %></span><span class="b"><%= h(note) %></span></a>
216
+ <% end %>
217
+ </div>
218
+ </div>
219
+ <% if route_ahead.any? %>
220
+ <p class="route-note">Checked through <%= h(@data[:target_rails]) %>. Faded stops count code that later versions remove, found in the same scan; gem limits past <%= h(@data[:target_rails]) %> aren't checked. Run <code>rails-preflight <%= h(route_ahead.last) %></code> for the full route.</p>
221
+ <% end %>
222
+ </div>
223
+ <div class="table-scroll">
224
+ <table class="plan">
225
+ <thead><tr><th>Step</th><th>What to do</th><th class="num">Blockers</th><th>Effort</th></tr></thead>
226
+ <tbody>
227
+ <% if before.any? %>
228
+ <tr><td><a href="#before"><%= h(@data[:current_rails] ? "Now, on #{minor(@data[:current_rails])}" : "Before you start") %></a></td><td><%= h(what_to_do(before)) %></td><td class="num"><%= blockers_in(before) %></td><td><%= effort_html(before) %></td></tr>
229
+ <% end %>
230
+ <% steps.each do |step| %>
231
+ <tr<%= ' class="target"' if step.equal?(steps.last) %>><td><a href="#<%= step_id(step[:version]) %>">→ <%= h(step[:version]) %></a></td><td><%= h(what_to_do(step[:checks])) %><span class="ruby"><%= h(step[:ruby]) %></span></td><td class="num"><%= blockers_in(step[:checks]) %></td><td><%= effort_html(step[:checks]) %></td></tr>
232
+ <% end %>
233
+ <% if ahead.any? %>
234
+ <% later = ahead.values.flatten %>
235
+ <tr class="later"><td><a href="#ahead-h">Optional</a></td><td><%= h("Start on what later versions remove while you're in the code: #{what_to_do(later)}") %></td><td class="num">–</td><td><%= effort_html(later) %></td></tr>
236
+ <% end %>
237
+ </tbody>
238
+ </table>
239
+ </div>
240
+ </section>
241
+
242
+ <section class="block" aria-labelledby="steps-h">
243
+ <h2 id="steps-h">Steps</h2>
244
+ <% if before.any? %>
245
+ <article class="step" id="before">
246
+ <div class="step-head"><h3>Before you start</h3><% if @data[:current_rails] %><span class="tag">on Rails <%= h(minor(@data[:current_rails])) %></span><% end %></div>
247
+ <ul class="rows"><% before.each do |entry| %><%= row_html(entry) %><% end %></ul>
248
+ </article>
249
+ <% end %>
250
+ <% steps.each do |step| %>
251
+ <article class="step" id="<%= step_id(step[:version]) %>">
252
+ <div class="step-head"><h3><%= h(step[:from] ? "#{step[:from]} → #{step[:version]}" : "Rails #{step[:version]}") %></h3><span class="tag"><%= h(blocker_tag(step[:checks])) %></span></div>
253
+ <div class="rubynote"><span class="k">Ruby</span><span><%= h(step[:ruby]) %></span></div>
254
+ <% if step[:checks].any? %>
255
+ <ul class="rows"><% step[:checks].each do |entry| %><%= row_html(entry) %><% end %></ul>
256
+ <% end %>
257
+ </article>
258
+ <% end %>
259
+ <% if steps.empty? %>
260
+ <p class="empty">Already on Rails <%= h(@data[:target_rails]) %>: no upgrade steps.</p>
261
+ <% end %>
262
+ </section>
263
+
264
+ <% if ahead.any? %>
265
+ <section class="block" aria-labelledby="ahead-h">
266
+ <h2 id="ahead-h">Ahead of <%= h(@data[:target_rails]) %></h2>
267
+ <% ahead.each do |version, entries| %>
268
+ <article class="step ahead-card" id="<%= ahead_id(version) %>">
269
+ <div class="step-head"><h3><%= h(version) %></h3><span class="tag">not needed for <%= h(@data[:target_rails]) %></span></div>
270
+ <ul class="rows"><% entries.each do |entry| %><%= row_html(entry, :later) %><% end %></ul>
271
+ </article>
272
+ <% end %>
273
+ <% clear = route_ahead - ahead.keys %>
274
+ <% if clear.any? %><p class="empty">Nothing in the code is removed by <%= h(clear.join(" or ")) %>.</p><% end %>
275
+ </section>
276
+ <% end %>
277
+
278
+ <section class="block" id="cant-see" aria-labelledby="cant-h">
279
+ <h2 id="cant-h">What this report can't see</h2>
280
+ <div class="limits">
281
+ <% @summary.fetch(:cant_see, []).each do |entry| %>
282
+ <div class="limit">
283
+ <h3><%= h(entry[:section]) %></h3>
284
+ <p><%= code_html(entry[:message]) %></p>
285
+ <% names = Array(entry[:names]) %>
286
+ <% if names.any? %><p class="names"><%= h(names.join(" · ")) %></p><% end %>
287
+ <% if (next_step = cant_see_next(entry)) %><p class="next"><%= next_step %></p><% end %>
288
+ </div>
289
+ <% end %>
290
+ <div class="limit">
291
+ <h3>Behavior changes</h3>
292
+ <p>Some changes leave no telltale line: new framework defaults and changed query behavior.</p>
293
+ <p class="next">Run the test suite with <code>config.active_support.deprecation = :raise</code>.</p>
294
+ </div>
295
+ <div class="limit">
296
+ <h3>Multi-line code and test coverage</h3>
297
+ <p>The scan matches single lines and never runs the app, so a call split across lines is missed, and it can't tell how much of the app the tests cover.</p>
298
+ <p class="next">Treat a clean step as "nothing found", not "nothing there".</p>
299
+ </div>
300
+ </div>
301
+ </section>
302
+
303
+ <footer>
304
+ <dl>
305
+ <dt>Confidence</dt><dd><%= h(confidence_line) %></dd>
306
+ <dt>Sources</dt><dd>Removed APIs link to the Rails release notes for the version that removed them. Gem limits come from <code>Gemfile.lock</code> or the gem's README.</dd>
307
+ </dl>
308
+ <div class="sig">
309
+ <span>Made with <a href="<%= REPO_URL %>">rails-preflight</a>. Run it again after each step.</span>
310
+ <span>Need a hand with the upgrade? <a href="<%= HELP_URL %>">syedaslam.com/work-with-me</a></span>
311
+ </div>
312
+ </footer>
313
+ </article>
314
+ </div>
315
+ </body>
316
+ </html>
317
+ ERB
318
+
319
+ ERB.new(template).result(binding)
320
+ end
321
+
322
+ private
323
+
324
+ def before
325
+ @summary.fetch(:before, [])
326
+ end
327
+
328
+ def steps
329
+ @summary.fetch(:steps, [])
330
+ end
331
+
332
+ def ahead
333
+ @summary.fetch(:ahead, {})
334
+ end
335
+
336
+ def current_label
337
+ @data[:current_rails] || "unknown"
338
+ end
339
+
340
+ def minor(version)
341
+ version.to_s.split(".").first(2).join(".")
342
+ end
343
+
344
+ def count(key)
345
+ @summary.fetch(key, []).size
346
+ end
347
+
348
+ def step_id(version)
349
+ "s#{version.to_s.tr('.', '-')}"
350
+ end
351
+
352
+ def ahead_id(version)
353
+ "a#{version.to_s.tr('.', '-')}"
354
+ end
355
+
356
+ def blockers_in(entries)
357
+ entries.count { |entry| entry[:kind] == :blocker }
358
+ end
359
+
360
+ def count_tiles
361
+ later = ahead.values.sum(&:size)
362
+ now = count(:to_fix) - later
363
+ first_blocked = steps.find { |step| blockers_in(step[:checks]).positive? }
364
+ [
365
+ ["broken", count(:broken), "Already broken", "Removed before #{@data[:current_rails] ? minor(@data[:current_rails]) : 'your Rails'}: fails when it runs", "#before"],
366
+ ["blocker", count(:blockers), "Blockers", "Must be fixed for #{@data[:target_rails]} to work", first_blocked ? "##{step_id(first_blocked[:version])}" : "#steps-h"],
367
+ ["fix", count(:to_fix), "To fix", later.positive? ? "#{now} now, #{later} before later versions" : "Will warn or break along the way", now.positive? ? "#before" : "#ahead-h"],
368
+ ["unknown", count(:unknowns), "Couldn't check", "Review by hand", "#cant-see"]
369
+ ]
370
+ end
371
+
372
+ # Known versions past the target, plus any version a finding names
373
+ def route_ahead
374
+ @route_ahead ||= (Array(@data[:later_rails]) | ahead.keys).sort_by { |version| Gem::Version.new(version) }
375
+ end
376
+
377
+ # [css, version, note, href] for each stop: where the app is, each step, then later versions faded.
378
+ def route_stops
379
+ stops = []
380
+ stops << ["now", minor(@data[:current_rails]), "you are here", before.any? ? "#before" : "#steps-h"] if @data[:current_rails]
381
+ steps.each do |step|
382
+ blockers = blockers_in(step[:checks])
383
+ css = [("has" if blockers.positive?), ("target" if step.equal?(steps.last))].compact.join(" ")
384
+ stops << [css, step[:version], blockers.zero? ? "no blockers" : "#{blockers} #{blockers == 1 ? 'blocker' : 'blockers'}", "##{step_id(step[:version])}"]
385
+ end
386
+ route_ahead.each do |version|
387
+ seen = ahead.fetch(version, []).size
388
+ stops << ["ahead", version, seen.zero? ? "none seen" : "#{seen} seen ahead", seen.zero? ? "#ahead-h" : "##{ahead_id(version)}"]
389
+ end
390
+ stops
391
+ end
392
+
393
+ # A short summary of a step's work, by kind and area.
394
+ def what_to_do(entries)
395
+ work = entries.reject { |entry| entry[:kind] == :tip }
396
+ return "Nothing found." if work.empty?
397
+
398
+ broken, rest = work.partition { |entry| entry[:kind] == :broken }
399
+ gems, rest = rest.partition { |entry| GEM_SECTIONS.include?(entry[:section]) }
400
+ code, rest = rest.partition { |entry| entry[:section] == "Deprecation Warnings" }
401
+ lines = code.sum { |entry| entry[:files].size }
402
+ parts = []
403
+ parts << "#{broken.size} already broken" if broken.any?
404
+ parts << plural(gems.size, "gem") if gems.any?
405
+ parts << "#{plural(code.size, 'removed API')} (#{plural(lines, 'line')})" if code.any?
406
+ parts << "#{rest.size} more (#{rest.map { |entry| SECTION_LABELS.fetch(entry[:section], entry[:section]) }.uniq.join(', ')})" if rest.any?
407
+ "#{parts.join(', ')}."
408
+ end
409
+
410
+ def plural(n, word)
411
+ "#{n} #{word}#{'s' unless n == 1}"
412
+ end
413
+
414
+ def blocker_tag(entries)
415
+ blockers = blockers_in(entries)
416
+ return "nothing to fix" if entries.empty?
417
+
418
+ blockers.zero? ? "no blockers" : plural(blockers, "blocker")
419
+ end
420
+
421
+ # The highest fix effort among the step's findings; relative only, never hours.
422
+ def effort_html(entries)
423
+ level = entries.filter_map { |entry| EFFORTS.index(entry[:fix_effort].to_s) }.max
424
+ return "–" unless level
425
+
426
+ %(<span class="effort #{EFFORTS[level]}"><i></i><i></i><i></i></span>#{EFFORTS[level].capitalize})
427
+ end
428
+
429
+ # One finding: kind, message with its meta line (the toggle for its files), and a link or its area.
430
+ def row_html(entry, kind = entry[:kind])
431
+ css = KIND_CLASSES.fetch(kind, "unknown")
432
+ %(<li class="row #{css}"><span class="kind">#{h(KIND_LABELS.fetch(kind, kind.to_s))}</span>) +
433
+ %(<div class="msg">#{code_html(entry[:message])}#{occurrences_html(entry)}</div>#{cite_html(entry)}</li>)
434
+ end
435
+
436
+ # Code matches list file, line and snippet; gem usage lists files. Everything is escaped.
437
+ def occurrences_html(entry)
438
+ files = Array(entry[:files])
439
+ lines = files.select { |hit| hit[:line] }
440
+ meta = []
441
+ meta << occurrence_count(lines) if lines.any?
442
+ meta << plural(files.map { |hit| hit[:file] }.uniq.size, "file") if files.any?
443
+ meta << "Fix: #{entry[:fix_effort]}" if entry[:fix_effort] && entry[:fix_effort].to_s != "unknown"
444
+ return "" if meta.empty?
445
+
446
+ spans = meta.map { |part| "<span>#{h(part)}</span>" }.join
447
+ return %(<div class="meta">#{spans}</div>) if files.empty?
448
+
449
+ rows = files.map do |hit|
450
+ hit[:line] ? %(<tr><td>#{h(hit[:file])}</td><td class="line">#{h(hit[:line])}</td><td>#{h(hit[:snippet])}</td></tr>) : %(<tr><td>#{h(hit[:file])}</td></tr>)
451
+ end
452
+ %(<details class="occ"><summary>#{spans}</summary><div class="snip"><table>#{rows.join}</table></div></details>)
453
+ end
454
+
455
+ def occurrence_count(hits)
456
+ total = hits.size
457
+ test = hits.count { |hit| hit[:test] }
458
+ text = plural(total, "occurrence")
459
+ return text if test.zero?
460
+ return "#{text}, #{total == 1 ? 'in a test' : 'all in tests'}" if test == total
461
+
462
+ "#{text} (#{total - test} app, #{test} test)"
463
+ end
464
+
465
+ # The release notes or source a finding cites; its area when it has none. Links render https only.
466
+ def cite_html(entry)
467
+ url = entry[:source] if entry[:source].to_s.start_with?("https://")
468
+ return %(<span class="cite">#{h(SECTION_LABELS.fetch(entry[:section], entry[:section]))}</span>) unless url
469
+
470
+ notes = url[%r{guides\.rubyonrails\.org/(\d+)_(\d+)_release_notes}] && "#{$1}.#{$2} notes"
471
+ label = notes || (url.include?("guides.rubyonrails.org") ? "Guide" : "Source")
472
+ %(<a class="cite" href="#{h(url)}" target="_blank" rel="noopener">#{label} ↗</a>)
473
+ end
474
+
475
+ # Escaped, with 'quoted' names set as code. An apostrophe inside a word (doesn't) isn't a quote.
476
+ def code_html(text)
477
+ h(text).gsub(/(?<![\w&;])&#39;(.+?)&#39;(?![\w&])/) { "<code>#{$1}</code>" }
478
+ end
479
+
480
+ def cant_see_next(entry)
481
+ case entry[:section]
482
+ when "Private Gems" then "Check each gemspec's Rails dependency, and bump them in the same step."
483
+ when "Gem Compatibility"
484
+ %(<a href="https://railsbump.org">RailsBump</a> or next_rails' <code>bundle_report compatibility</code> show which releases support #{h(@data[:target_rails])}.)
485
+ end
486
+ end
487
+
488
+ def confidence_line
489
+ by_level = Array(@data[:results]).select { |section| section[:confidence] }.group_by { |section| section[:confidence].to_sym }
490
+ { high: "read from project files", medium: "pattern matches", low: "a key input is missing" }.filter_map do |level, meaning|
491
+ sections = by_level[level]
492
+ "#{level.to_s.capitalize}, #{meaning}: #{sections.map { |section| section[:title] }.join(', ')}." if sections
493
+ end.join(" ")
494
+ end
495
+ end
496
+ end
@@ -0,0 +1,144 @@
1
+ module RailsPreflight
2
+ # Each analyzer returns { title:, status:, confidence:, checks: [...] }. A check is a Hash with :message and :status,
3
+ # plus the parts it's made of, so every format renders the same facts and none parses the message:
4
+ # kind :broken, :unknown or :tip; otherwise failed → blocker, warning → to_fix
5
+ # removed_in the Rails minor that removes the API, or that the gem stops working on
6
+ # fix_effort :low, :medium, :high or :unknown; relative, never hours
7
+ # source URL of the release notes, README or Rails source the finding rests on
8
+ # rule, confidence deprecation rule id, and that rule's :high, :medium or :low
9
+ # gem, version, requires the gem, its locked version, and the requirement that fails
10
+ # files [{ file:, line:, snippet:, test: }] where it's used; line, snippet and test for code matches only
11
+ # names what the report couldn't check, e.g. private gems
12
+ class SummaryCalculator
13
+ # Explicit check[:kind] (:broken, :unknown, :tip) wins; otherwise failures block and warnings need fixing.
14
+ KIND_BY_STATUS = { failed: :blocker, warning: :to_fix }.freeze
15
+ # Order of the rows in a step card
16
+ KINDS = %i[broken blocker to_fix tip].freeze
17
+
18
+ # hops: Rails minor versions to pass through, each { version:, min_ruby:, max_ruby: }
19
+ # (see UpgradeAnalyzer#upgrade_hops); empty when already on the target. app_ruby: the app's Ruby version, if known.
20
+ # current_max_ruby: newest Ruby the current Rails supports, if known.
21
+ def initialize(results, target_rails = "Unknown", current_rails = nil, hops: nil, app_ruby: nil, current_max_ruby: nil)
22
+ @results = results
23
+ @target_rails = target_rails
24
+ @current_rails = current_rails
25
+ @hops = hops || [{ version: target_rails }]
26
+ @app_ruby = app_ruby
27
+ @current_max_ruby = current_max_ruby
28
+ end
29
+
30
+ # Counts by kind, then every finding placed once: before the first step, in the step that removes it,
31
+ # ahead of the target (removed by a later Rails), or under what the report couldn't check.
32
+ def calculate
33
+ shown = entries.reject { |entry| entry[:kind] == :unknown }
34
+ in_steps, rest = shown.partition { |entry| step_for(entry) }
35
+ ahead, before = rest.partition { |entry| ahead?(entry) }
36
+ steps = steps(in_steps)
37
+ {
38
+ verdict: verdict(steps),
39
+ broken: findings(:broken),
40
+ blockers: findings(:blocker),
41
+ to_fix: findings(:to_fix),
42
+ unknowns: findings(:unknown),
43
+ before: in_order(before),
44
+ steps: steps,
45
+ ahead: ahead.group_by { |entry| entry[:removed_in].to_s }.sort_by { |version, _| Gem::Version.new(version) }.to_h,
46
+ cant_see: entries.select { |entry| entry[:kind] == :unknown }
47
+ }
48
+ end
49
+
50
+ private
51
+
52
+ # One sentence on the shape of the upgrade, from the counts.
53
+ def verdict(steps)
54
+ return "Already on Rails #{@target_rails}: there is nothing to upgrade." if steps.empty?
55
+
56
+ upgrades = steps.select { |step| step[:ruby_upgrade] }
57
+ route = steps.one? ? "One step" : "#{steps.size} steps"
58
+ route +=
59
+ if upgrades.any? then ", upgrading Ruby to #{upgrades.map { |step| "#{step[:ruby_upgrade]} before #{step[:version]}" }.join(', then ')}."
60
+ elsif @app_ruby && steps.all? { |step| step[:ruby].end_with?("works") } then ", and Ruby #{@app_ruby} can stay."
61
+ else "."
62
+ end
63
+
64
+ blockers = findings(:blocker).size
65
+ gems = findings(:blocker).count { |finding| finding[:gem] }
66
+ blocking =
67
+ if blockers.zero? then "No blockers."
68
+ else "#{blockers} #{blockers == 1 ? 'blocker' : 'blockers'}#{", #{gems == blockers ? 'all' : gems} of them #{gems == 1 && blockers == 1 ? 'a gem' : 'gems'}" if gems.positive?}."
69
+ end
70
+ broken = findings(:broken).size
71
+ [route, blocking, ("#{broken} #{broken == 1 ? 'is' : 'are'} already broken today." if broken.positive?)].compact.join(" ")
72
+ end
73
+
74
+ def kind(check)
75
+ check[:kind] || KIND_BY_STATUS[check[:status]]
76
+ end
77
+
78
+ def findings(wanted)
79
+ entries.select { |entry| entry[:kind] == wanted }
80
+ end
81
+
82
+ # Every check that isn't a plain pass, with its section and kind.
83
+ def entries
84
+ @entries ||= @results.flat_map do |section|
85
+ section[:checks].filter_map { |check| check.merge(section: section[:title], kind: kind(check)) if kind(check) }
86
+ end
87
+ end
88
+
89
+ def in_order(entries)
90
+ entries.sort_by.with_index { |entry, i| [KINDS.index(entry[:kind]) || KINDS.size, i] }
91
+ end
92
+
93
+ # One step per Rails minor version, since Rails recommends upgrading one minor version at a time.
94
+ def steps(in_steps)
95
+ from = @current_rails
96
+ ruby = @app_ruby # the Ruby the app runs at each step, after the upgrades suggested so far
97
+ max_now = @current_max_ruby
98
+ @hops.map do |hop|
99
+ note, upgraded = ruby_note(hop, ruby, max_now)
100
+ step = { version: hop[:version], from: from, ruby: note, ruby_upgrade: upgraded,
101
+ checks: in_order(in_steps.select { |entry| step_for(entry) == hop[:version] }) }
102
+ ruby = upgraded || ruby
103
+ from = hop[:version]
104
+ max_now = hop[:max_ruby]
105
+ step
106
+ end
107
+ end
108
+
109
+ # An API removed in one of the step versions is fixed as part of that step. A Ruby too old or too new for
110
+ # the target is checked against the target, so it sits in the last step, next to its Ruby note.
111
+ def step_for(entry)
112
+ return @hops.last&.dig(:version) if entry[:section] == "Ruby Version" && entry[:kind] == :blocker
113
+
114
+ removed = entry[:removed_in]&.to_s
115
+ removed if removed && %i[blocker to_fix].include?(entry[:kind]) && @hops.any? { |hop| hop[:version] == removed }
116
+ end
117
+
118
+ # Removed by a Rails past the target: not needed now, but found in the same scan.
119
+ def ahead?(entry)
120
+ entry[:kind] == :to_fix && entry[:removed_in] && Gem::Version.correct?(@target_rails) &&
121
+ Gem::Version.new(entry[:removed_in].to_s) > Gem::Version.new(@target_rails)
122
+ end
123
+
124
+ # [note, Ruby to upgrade to before this step or nil]. A Ruby too old for the step is upgraded on the Rails
125
+ # before it, to the newest Ruby both support, so later steps need as few Ruby upgrades as possible.
126
+ def ruby_note(hop, ruby, max_now)
127
+ return ["Ruby requirements for Rails #{hop[:version]} are unknown", nil] unless hop[:min_ruby]
128
+
129
+ range = "Ruby #{hop[:min_ruby]}–#{short(hop[:max_ruby])}"
130
+ version = ruby && Gem::Version.new(ruby)
131
+ if version.nil? then ["Needs #{range}", nil]
132
+ elsif version < Gem::Version.new(hop[:min_ruby])
133
+ upgraded = [max_now, hop[:max_ruby]].compact.min_by { |v| Gem::Version.new(v) }
134
+ ["Needs #{range}: upgrade Ruby from #{short(ruby)} to #{short(upgraded)} first", short(upgraded)]
135
+ elsif version > Gem::Version.new(hop[:max_ruby]) then ["Needs #{range}: Ruby #{short(ruby)} is newer than it supports", nil]
136
+ else ["Needs #{range}: #{short(ruby)} works", nil]
137
+ end
138
+ end
139
+
140
+ def short(ruby)
141
+ ruby.delete_suffix(".99")
142
+ end
143
+ end
144
+ end