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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +18 -0
- data/LICENSE.txt +21 -0
- data/README.md +253 -0
- data/bin/rails-preflight +48 -0
- data/database/compatibility.yml +59 -0
- data/database/deprecations.yml +1119 -0
- data/database/gems.yml +109 -0
- data/lib/rails_preflight/config_analyzer.rb +46 -0
- data/lib/rails_preflight/database_analyzer.rb +64 -0
- data/lib/rails_preflight/deprecation_analyzer.rb +214 -0
- data/lib/rails_preflight/docker_analyzer.rb +124 -0
- data/lib/rails_preflight/gem_analyzer.rb +178 -0
- data/lib/rails_preflight/json_report.rb +45 -0
- data/lib/rails_preflight/markdown_report.rb +72 -0
- data/lib/rails_preflight/report_generator.rb +496 -0
- data/lib/rails_preflight/summary_calculator.rb +144 -0
- data/lib/rails_preflight/upgrade_analyzer.rb +409 -0
- data/lib/rails_preflight/version.rb +3 -0
- data/lib/rails_preflight.rb +14 -0
- metadata +70 -0
|
@@ -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&;])'(.+?)'(?![\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
|