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,409 @@
|
|
|
1
|
+
# lib/rails_preflight/upgrade_analyzer.rb
|
|
2
|
+
require 'bundler'
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'net/http'
|
|
5
|
+
require 'yaml'
|
|
6
|
+
require_relative 'summary_calculator'
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
module RailsPreflight
|
|
10
|
+
class UpgradeAnalyzer
|
|
11
|
+
DATA_PATH = File.expand_path('../../database/compatibility.yml', __dir__)
|
|
12
|
+
|
|
13
|
+
# target_rails nil: the next known minor after the app's current Rails.
|
|
14
|
+
# offline: never call the network; gems the lockfile can't place are reported as "couldn't check",
|
|
15
|
+
# and gem release dates aren't looked up.
|
|
16
|
+
# format: :html, :markdown or :json, written into the app as rails_preflight_report.<ext>.
|
|
17
|
+
# stdout: print the report instead of writing it, with progress on stderr, for pipes and coding agents.
|
|
18
|
+
# run returns the summary (nil when there's nothing to upgrade to), so the caller can set the exit code.
|
|
19
|
+
def initialize(target_rails = nil, project_path = Dir.pwd, offline: false, format: :html, stdout: false)
|
|
20
|
+
@target_rails = target_rails
|
|
21
|
+
@offline = offline
|
|
22
|
+
@format = format
|
|
23
|
+
@stdout = stdout
|
|
24
|
+
@out = stdout ? $stderr : $stdout
|
|
25
|
+
@project_path = project_path
|
|
26
|
+
@lockfile_path = File.join(project_path, "Gemfile.lock")
|
|
27
|
+
@rules = YAML.load_file(DATA_PATH)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def run
|
|
31
|
+
unless File.exist?(File.join(@project_path, "config", "environment.rb"))
|
|
32
|
+
raise Error, "#{@project_path} doesn't look like a Rails app (no config/environment.rb)."
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
defaulted = @target_rails.nil?
|
|
36
|
+
if defaulted
|
|
37
|
+
@target_rails = next_rails
|
|
38
|
+
unless @target_rails
|
|
39
|
+
@out.puts "Rails #{current_rails} is already at or past the newest version rails-preflight knows (#{latest_known_rails}). Nothing to upgrade to."
|
|
40
|
+
return
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
@out.puts "š Starting Audit: Rails #{current_rails || 'unknown'} ā #{@target_rails}..."
|
|
45
|
+
|
|
46
|
+
results = []
|
|
47
|
+
database_rules = @rules.fetch('database_rules', []).select { |rule| Gem::Version.new(rule['since']) <= Gem::Version.new(@target_rails) }
|
|
48
|
+
|
|
49
|
+
results << check_rails_version
|
|
50
|
+
results << check_ruby_version
|
|
51
|
+
results << scan_gems
|
|
52
|
+
results << DockerAnalyzer.new(@project_path, checked_ruby: @eol_checked_ruby).run
|
|
53
|
+
results << GemAnalyzer.new(@project_path, lockfile_specs, hops: upgrade_hops.map { |hop| hop[:version] }, direct: lockfile&.dependencies&.keys || [],
|
|
54
|
+
last_releases: (@last_releases || {} unless @offline), target_released: target_rails_rules&.dig('released')).run
|
|
55
|
+
results << DeprecationAnalyzer.new(@project_path, target_rails: @target_rails, current_rails: current_rails&.to_s).run
|
|
56
|
+
results << ConfigAnalyzer.new(@project_path, current_rails).run
|
|
57
|
+
results << DatabaseAnalyzer.new(@project_path, database_rules).run
|
|
58
|
+
|
|
59
|
+
report_data = {
|
|
60
|
+
app: File.basename(File.expand_path(@project_path)),
|
|
61
|
+
target_rails: @target_rails,
|
|
62
|
+
current_rails: current_rails&.to_s,
|
|
63
|
+
ruby: app_ruby,
|
|
64
|
+
offline: @offline,
|
|
65
|
+
looked_up: @looked_up,
|
|
66
|
+
later_rails: known_rails_versions.select { |v| Gem::Version.correct?(@target_rails) && Gem::Version.new(v) > Gem::Version.new(@target_rails) },
|
|
67
|
+
results: results
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
# Calculate Summary
|
|
71
|
+
summary_calc = SummaryCalculator.new(results, @target_rails, current_rails&.to_s, hops: upgrade_hops, app_ruby: app_ruby.first,
|
|
72
|
+
current_max_ruby: current_rails_rules&.dig('max_ruby'))
|
|
73
|
+
report_data[:summary] = summary_calc.calculate
|
|
74
|
+
summary = report_data[:summary]
|
|
75
|
+
@out.puts "#{"Already broken: #{summary[:broken].size} Ā· " if summary[:broken].any?}Blockers: #{summary[:blockers].size} Ā· To fix: #{summary[:to_fix].size} Ā· Couldn't check: #{summary[:unknowns].size}"
|
|
76
|
+
summary[:broken].each { |b| @out.puts " ā¼ #{b[:section]}: #{b[:message]}" }
|
|
77
|
+
summary[:blockers].each { |b| @out.puts " ā #{b[:section]}: #{b[:message]}" }
|
|
78
|
+
|
|
79
|
+
renderer, extension = { html: [ReportGenerator, "html"], markdown: [MarkdownReport, "md"], json: [JsonReport, "json"] }.fetch(@format)
|
|
80
|
+
report = renderer.new(report_data).generate
|
|
81
|
+
if @stdout
|
|
82
|
+
$stdout.print report
|
|
83
|
+
else
|
|
84
|
+
output_path = File.join(@project_path, "rails_preflight_report.#{extension}")
|
|
85
|
+
File.write(output_path, report)
|
|
86
|
+
@out.puts "\nā
Report generated at: #{output_path}"
|
|
87
|
+
@out.puts " Open it in your browser to see the results." if @format == :html
|
|
88
|
+
end
|
|
89
|
+
if defaulted && @target_rails != latest_known_rails
|
|
90
|
+
@out.puts "\nLatest known is #{latest_known_rails}: run `rails-preflight #{latest_known_rails}` for the full path."
|
|
91
|
+
end
|
|
92
|
+
summary
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
def check_rails_version
|
|
98
|
+
result = { title: "Rails Version", status: :passed, checks: [], confidence: :high }
|
|
99
|
+
|
|
100
|
+
if current_rails.nil?
|
|
101
|
+
result[:status] = :warning
|
|
102
|
+
result[:confidence] = :low
|
|
103
|
+
result[:checks] << { message: "Could not read current Rails version from Gemfile.lock.", status: :warning, kind: :unknown, fix_effort: :low }
|
|
104
|
+
elsif Gem::Version.new(current_rails.segments.first(2).join(".")) >= Gem::Version.new(@target_rails)
|
|
105
|
+
result[:status] = :warning
|
|
106
|
+
result[:checks] << { message: "Already on Rails #{current_rails}: #{@target_rails} is not an upgrade.", status: :passed }
|
|
107
|
+
else
|
|
108
|
+
result[:checks] << { message: "Rails #{current_rails} (Gemfile.lock) ā #{@target_rails}", status: :passed, fix_effort: :low }
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
result
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def check_ruby_version
|
|
115
|
+
result = { title: "Ruby Version", status: :passed, checks: [], confidence: :high }
|
|
116
|
+
|
|
117
|
+
constraints = target_rails_rules
|
|
118
|
+
|
|
119
|
+
unless constraints
|
|
120
|
+
result[:status] = :warning
|
|
121
|
+
result[:checks] << { message: "Unknown Rails version: #{@target_rails}", status: :warning, kind: :unknown, fix_effort: :unknown }
|
|
122
|
+
return result
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
current_str, source = app_ruby
|
|
126
|
+
|
|
127
|
+
unless current_str
|
|
128
|
+
result[:status] = :warning
|
|
129
|
+
result[:confidence] = :low
|
|
130
|
+
from = source ? " from #{source}" : " (no .ruby-version, Gemfile.lock RUBY VERSION, or ruby Dockerfile image)"
|
|
131
|
+
result[:checks] << { message: "Could not determine the Ruby version used by the app#{from}. Add a .ruby-version file for an accurate check.", status: :warning, kind: :unknown, fix_effort: :low }
|
|
132
|
+
return result
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
current_ver = Gem::Version.new(current_str)
|
|
136
|
+
|
|
137
|
+
min_ver = Gem::Version.new(constraints['required_ruby'].split.last)
|
|
138
|
+
max_ver = Gem::Version.new(constraints['max_ruby'])
|
|
139
|
+
|
|
140
|
+
if current_ver < min_ver
|
|
141
|
+
result[:status] = :failed
|
|
142
|
+
result[:checks] << { message: "Rails #{@target_rails} needs Ruby >= #{min_ver}. You have #{current_ver}.", status: :failed, fix_effort: :high }
|
|
143
|
+
elsif current_ver > max_ver
|
|
144
|
+
result[:status] = :failed
|
|
145
|
+
result[:checks] << { message: "Rails #{@target_rails} supports Ruby up to #{constraints['max_ruby'].delete_suffix('.99')}. You have #{current_ver}.", status: :failed, fix_effort: :high }
|
|
146
|
+
else
|
|
147
|
+
result[:checks] << { message: "Ruby #{current_ver} (#{source}) is compatible with Rails #{@target_rails}.", status: :passed, fix_effort: :low }
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
@eol_checked_ruby = current_str # Docker skips this version so it isn't reported twice
|
|
151
|
+
eol_below = DockerAnalyzer::RUBY_EOL_BELOW
|
|
152
|
+
if current_ver < Gem::Version.new(eol_below)
|
|
153
|
+
result[:status] = :warning if result[:status] == :passed
|
|
154
|
+
advice =
|
|
155
|
+
if max_ver >= Gem::Version.new(eol_below) then "Upgrade to Ruby #{eol_below}+."
|
|
156
|
+
else
|
|
157
|
+
later = @rules['rails_versions'].find { |_, rules| Gem::Version.new(rules['max_ruby']) >= Gem::Version.new(eol_below) }&.first
|
|
158
|
+
"Rails #{@target_rails} supports up to Ruby #{constraints['max_ruby'].delete_suffix('.99')}; Ruby #{eol_below}+ needs Rails #{later || 'newer than ' + latest_known_rails}."
|
|
159
|
+
end
|
|
160
|
+
result[:checks] << { message: "Ruby #{current_ver} is end-of-life. #{advice}", status: :warning, fix_effort: :high }
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
result
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# First source found wins. Never falls back to the Ruby running this tool.
|
|
167
|
+
def detect_app_ruby
|
|
168
|
+
ruby_version_file = File.join(@project_path, ".ruby-version")
|
|
169
|
+
return [File.read(ruby_version_file), ".ruby-version"] if File.exist?(ruby_version_file)
|
|
170
|
+
|
|
171
|
+
lock_ruby = File.exist?(@lockfile_path) && File.read(@lockfile_path)[/^RUBY VERSION\s+ruby (\S+)/, 1]
|
|
172
|
+
return [lock_ruby, "Gemfile.lock"] if lock_ruby
|
|
173
|
+
|
|
174
|
+
docker_ruby = DockerAnalyzer.new(@project_path).detect_ruby_version
|
|
175
|
+
[docker_ruby, "Dockerfile"] if docker_ruby
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# [version, source]; version is nil when unparseable. Handles 'ruby-2.5.9', '3.3', '3.3.5p100'.
|
|
179
|
+
def app_ruby
|
|
180
|
+
@app_ruby ||= begin
|
|
181
|
+
raw, source = detect_app_ruby
|
|
182
|
+
[raw.to_s[/\d+\.\d+(?:\.\d+)?/], source]
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Rails recommends one minor version at a time: every known version after the current one, up to the target.
|
|
187
|
+
def upgrade_hops
|
|
188
|
+
from = current_rails && Gem::Version.new(current_rails.segments.first(2).join("."))
|
|
189
|
+
to = Gem::Version.new(@target_rails)
|
|
190
|
+
hops = @rules.fetch('rails_versions', {}).filter_map do |version, rules|
|
|
191
|
+
v = Gem::Version.new(version)
|
|
192
|
+
next unless v <= to && (from ? v > from : v == to)
|
|
193
|
+
{ version: version, min_ruby: rules['required_ruby'].split.last, max_ruby: rules['max_ruby'] }
|
|
194
|
+
end
|
|
195
|
+
hops = hops.sort_by { |hop| Gem::Version.new(hop[:version]) }
|
|
196
|
+
hops << { version: @target_rails } unless target_rails_rules # target missing from compatibility.yml
|
|
197
|
+
hops
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def scan_gems
|
|
201
|
+
result = { title: "Private Gems", status: :passed, checks: [], confidence: :high }
|
|
202
|
+
|
|
203
|
+
unless File.exist?(@lockfile_path)
|
|
204
|
+
result[:status] = :warning
|
|
205
|
+
result[:confidence] = :medium
|
|
206
|
+
result[:checks] << {
|
|
207
|
+
message: "No Gemfile.lock found. Skipping gem compatibility checks.",
|
|
208
|
+
status: :warning,
|
|
209
|
+
kind: :unknown,
|
|
210
|
+
fix_effort: :low
|
|
211
|
+
}
|
|
212
|
+
return result
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
private_gems = []
|
|
216
|
+
mixed_gems = []
|
|
217
|
+
mixed_remotes = []
|
|
218
|
+
dated_gems = [] # public gems that depend on Rails: their last release date is looked up
|
|
219
|
+
|
|
220
|
+
lockfile_specs.each do |spec|
|
|
221
|
+
next if ['rails', 'rake'].include?(spec.name)
|
|
222
|
+
|
|
223
|
+
origin = gem_origin(spec)
|
|
224
|
+
case origin
|
|
225
|
+
when :private then private_gems << spec.name
|
|
226
|
+
when :mixed
|
|
227
|
+
mixed_gems << spec.name
|
|
228
|
+
mixed_remotes |= spec.source.remotes.map(&:to_s).reject { |r| rubygems_org?(r) }
|
|
229
|
+
end
|
|
230
|
+
dated_gems << spec.name if origin != :private && depends_on_rails?(spec)
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
if mixed_gems.any? && @offline
|
|
234
|
+
message = "#{mixed_gems.size} gems come from one Gemfile.lock section that lists rubygems.org and #{mixed_remotes.join(', ')}; can't tell which are private with --offline"
|
|
235
|
+
result[:checks] << { message: message, status: :warning, kind: :unknown, names: mixed_gems, fix_effort: :unknown }
|
|
236
|
+
result[:status] = :warning
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
unless @offline || (mixed_gems | dated_gems).empty?
|
|
240
|
+
names = mixed_gems | dated_gems
|
|
241
|
+
@looked_up = names.size
|
|
242
|
+
@out.puts "Looking up #{names.size} gems on rubygems.org (--offline skips this)..."
|
|
243
|
+
found, inconclusive = lookup_on_rubygems(names)
|
|
244
|
+
private_gems.concat(mixed_gems.select { |name| found[name] == false })
|
|
245
|
+
@last_releases = dated_gems.filter_map { |name| [name, found[name]['version_created_at']] if found[name].is_a?(Hash) && found[name]['version_created_at'] }.to_h
|
|
246
|
+
if mixed_gems.any?
|
|
247
|
+
result[:checks] << { message: "Looked up #{mixed_gems.size} gems on rubygems.org, since Gemfile.lock lists them under both rubygems.org and #{mixed_remotes.join(', ')}. Pass --offline to skip.", status: :passed, fix_effort: :low }
|
|
248
|
+
end
|
|
249
|
+
if inconclusive.any?
|
|
250
|
+
names = inconclusive.map { |name, error| "#{name} (#{error})" }
|
|
251
|
+
result[:checks] << { message: "Could not verify #{inconclusive.size} gems against rubygems.org", status: :warning, kind: :unknown, names: names, fix_effort: :unknown }
|
|
252
|
+
result[:status] = :warning
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
if private_gems.any?
|
|
257
|
+
result[:status] = :warning
|
|
258
|
+
result[:checks].unshift({ message: "Private gems (#{private_gems.size}): compatibility with Rails #{@target_rails} is unknown", status: :warning, kind: :unknown, names: private_gems.sort, fix_effort: :unknown })
|
|
259
|
+
else
|
|
260
|
+
result[:checks].unshift({ message: "No private gems detected.", status: :passed, fix_effort: :low })
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
result
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# 200 on rubygems.org means public, 404 private, anything else unchecked.
|
|
267
|
+
# Returns [{ name => gem info Hash (public) or false (private) }, { name => error }].
|
|
268
|
+
# Only public gems and gem names the lockfile can't place are sent; never git, path or private-registry-only gems.
|
|
269
|
+
def lookup_on_rubygems(names)
|
|
270
|
+
queue = Queue.new
|
|
271
|
+
names.each { |name| queue << name }
|
|
272
|
+
found = {}
|
|
273
|
+
inconclusive = {}
|
|
274
|
+
lock = Mutex.new
|
|
275
|
+
|
|
276
|
+
Array.new([names.size, 8].min) do
|
|
277
|
+
Thread.new do
|
|
278
|
+
while (name = (queue.pop(true) rescue nil))
|
|
279
|
+
begin
|
|
280
|
+
url = URI("https://rubygems.org/api/v1/gems/#{name}.json")
|
|
281
|
+
# Certificate errors are reported as inconclusive, never silently trusted.
|
|
282
|
+
response = Net::HTTP.start(url.host, url.port, use_ssl: true, open_timeout: 5, read_timeout: 5) do |http|
|
|
283
|
+
http.request(Net::HTTP::Get.new(url))
|
|
284
|
+
end
|
|
285
|
+
raise "HTTP #{response.code}" unless %w[200 404].include?(response.code) # e.g. 429 when rate limited
|
|
286
|
+
info = response.code == "200" && (JSON.parse(response.body.to_s) rescue {})
|
|
287
|
+
lock.synchronize { found[name] = info }
|
|
288
|
+
rescue StandardError => e
|
|
289
|
+
lock.synchronize { inconclusive[name] = "#{e.class.name}: #{e.message}" }
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
end.each(&:join)
|
|
294
|
+
|
|
295
|
+
[found, inconclusive]
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# From the lockfile: :public, :private (git, path, or only non-rubygems.org remotes), or, when one GEM
|
|
299
|
+
# section lists rubygems.org next to another remote (Bundler before 2.2), whatever the Gemfile says; :mixed if it doesn't.
|
|
300
|
+
def gem_origin(spec)
|
|
301
|
+
return :private unless spec.source.is_a?(Bundler::Source::Rubygems)
|
|
302
|
+
|
|
303
|
+
remotes = spec.source.remotes.map(&:to_s)
|
|
304
|
+
return :public if remotes.all? { |r| rubygems_org?(r) }
|
|
305
|
+
return :private if remotes.none? { |r| rubygems_org?(r) }
|
|
306
|
+
|
|
307
|
+
global, scoped = gemfile_sources
|
|
308
|
+
if scoped.key?(spec.name)
|
|
309
|
+
rubygems_org?(scoped[spec.name]) ? :public : :private
|
|
310
|
+
elsif global.any? && global.all? { |r| rubygems_org?(r) }
|
|
311
|
+
# ponytail: dependencies of scoped gems count as public; Bundler 1.x could resolve them from the scoped source too.
|
|
312
|
+
:public
|
|
313
|
+
else
|
|
314
|
+
:mixed
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def depends_on_rails?(spec)
|
|
319
|
+
!GemAnalyzer::RAILS_GEMS.include?(spec.name) && spec.dependencies.any? { |dep| GemAnalyzer::RAILS_GEMS.include?(dep.name) }
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def rubygems_org?(remote)
|
|
323
|
+
remote.include?("rubygems.org")
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
# Read, never evaluated: the Gemfile is the app's code. Returns [global sources, { gem name => source }]
|
|
327
|
+
# from `source "ā¦" do` blocks and `gem "ā¦", source: "ā¦"`. A source written as code (`ENV.fetch(ā¦)`) is kept
|
|
328
|
+
# as that code, which never reads as rubygems.org, so its gems count as private.
|
|
329
|
+
# ponytail: line-based; one-line `do ⦠end` or `{ ⦠}` blocks fall back to the rubygems.org lookup,
|
|
330
|
+
# and `x = if ⦠end` closes a block early. Parse with Ripper if real Gemfiles hit these.
|
|
331
|
+
def gemfile_sources
|
|
332
|
+
@gemfile_sources ||= begin
|
|
333
|
+
global = []
|
|
334
|
+
scoped = {}
|
|
335
|
+
blocks = [] # source for a `source ⦠do` block, nil for any other block
|
|
336
|
+
gemfile_lines(File.join(@project_path, "Gemfile")).each do |line|
|
|
337
|
+
source = line[/^\s*source\b\s*\(?\s*(.+?)\s*\)?\s*(?:do\b.*)?$/, 1]&.delete_prefix("\"")&.delete_suffix("\"")&.delete("'")
|
|
338
|
+
name = line[/^\s*gem\s*\(?\s*["']([^"']+)["']/, 1]
|
|
339
|
+
if name
|
|
340
|
+
option = line[/\bsource:\s*(.+?)\s*(?:,|\)|$)/, 1]&.delete("\"'")
|
|
341
|
+
scoped[name] = option || blocks.compact.last
|
|
342
|
+
scoped.delete(name) unless scoped[name]
|
|
343
|
+
end
|
|
344
|
+
if line =~ /\bdo\s*(\|[^|]*\|)?\s*$/ || line =~ /^\s*(if|unless|case|begin|while|until|def)\b/
|
|
345
|
+
blocks << source
|
|
346
|
+
elsif source
|
|
347
|
+
global << source
|
|
348
|
+
elsif line =~ /^\s*end\b/
|
|
349
|
+
blocks.pop
|
|
350
|
+
end
|
|
351
|
+
end
|
|
352
|
+
[global, scoped]
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
# Comments stripped, `gem "x",` joined with its next line, and quoted `eval_gemfile` paths read in place.
|
|
357
|
+
def gemfile_lines(path, seen = [])
|
|
358
|
+
return [] if !File.exist?(path) || seen.include?(path)
|
|
359
|
+
|
|
360
|
+
seen << path
|
|
361
|
+
text = File.read(path).gsub(/#(?!\{).*/, "").gsub(/,\s*\n\s*/, ", ")
|
|
362
|
+
text.lines.flat_map do |line|
|
|
363
|
+
included = line[/^\s*eval_gemfile\s*\(?\s*["']([^"']+)["']/, 1]
|
|
364
|
+
included ? gemfile_lines(File.expand_path(included, File.dirname(path)), seen) : [line]
|
|
365
|
+
end
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
# railties is in every Rails app's lockfile, even without the rails meta-gem.
|
|
369
|
+
def current_rails
|
|
370
|
+
lockfile_specs.find { |s| s.name == "railties" || s.name == "rails" }&.version
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
# Bypass Bundler IO to avoid version mismatch errors
|
|
374
|
+
def lockfile_specs
|
|
375
|
+
lockfile&.specs || []
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
def lockfile
|
|
379
|
+
return @lockfile if defined?(@lockfile)
|
|
380
|
+
@lockfile = File.exist?(@lockfile_path) ? Bundler::LockfileParser.new(File.read(@lockfile_path)) : nil
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
def known_rails_versions
|
|
384
|
+
@rules.fetch('rails_versions', {}).keys.sort_by { |v| Gem::Version.new(v) }
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
def latest_known_rails
|
|
388
|
+
known_rails_versions.last
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
# Next known minor after the current Rails (7.1.3 ā 7.2), or nil when already on the newest.
|
|
392
|
+
def next_rails
|
|
393
|
+
unless current_rails
|
|
394
|
+
reason = File.exist?(@lockfile_path) ? "No Rails in #{@lockfile_path}" : "No Gemfile.lock in #{@project_path}"
|
|
395
|
+
raise Error, "#{reason}, so the current Rails version is unknown. Pass a target, e.g. `rails-preflight #{latest_known_rails}`."
|
|
396
|
+
end
|
|
397
|
+
current_minor = Gem::Version.new(current_rails.segments.first(2).join("."))
|
|
398
|
+
known_rails_versions.find { |v| Gem::Version.new(v) > current_minor }
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
def current_rails_rules
|
|
402
|
+
current_rails && @rules.fetch('rails_versions', {})[current_rails.segments.first(2).join(".")]
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
def target_rails_rules
|
|
406
|
+
@target_rails_rules ||= @rules.fetch('rails_versions', {})[@target_rails]
|
|
407
|
+
end
|
|
408
|
+
end
|
|
409
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
require "rails_preflight/version"
|
|
2
|
+
require "rails_preflight/upgrade_analyzer"
|
|
3
|
+
require "rails_preflight/docker_analyzer"
|
|
4
|
+
require "rails_preflight/deprecation_analyzer"
|
|
5
|
+
require "rails_preflight/gem_analyzer"
|
|
6
|
+
require "rails_preflight/config_analyzer"
|
|
7
|
+
require "rails_preflight/database_analyzer"
|
|
8
|
+
require "rails_preflight/report_generator"
|
|
9
|
+
require "rails_preflight/markdown_report"
|
|
10
|
+
require "rails_preflight/json_report"
|
|
11
|
+
|
|
12
|
+
module RailsPreflight
|
|
13
|
+
class Error < StandardError; end
|
|
14
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: rails_preflight
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Syed Aslam
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: |
|
|
13
|
+
Point rails-preflight at a Rails app and a target version. It reads the lockfile,
|
|
14
|
+
.ruby-version, Dockerfile, db/schema.rb and config, and scans Ruby, ERB, HAML and Slim
|
|
15
|
+
files for removed APIs, then writes a single HTML report: blockers, items to fix, what it
|
|
16
|
+
couldn't check, and a suggested path with one step per Rails minor version. The same
|
|
17
|
+
findings print as Markdown for coding agents or JSON for CI. Read-only and static. It
|
|
18
|
+
never modifies the app, runs it, or runs its tests.
|
|
19
|
+
email:
|
|
20
|
+
- aslam.maqsood@gmail.com
|
|
21
|
+
executables:
|
|
22
|
+
- rails-preflight
|
|
23
|
+
extensions: []
|
|
24
|
+
extra_rdoc_files: []
|
|
25
|
+
files:
|
|
26
|
+
- CHANGELOG.md
|
|
27
|
+
- LICENSE.txt
|
|
28
|
+
- README.md
|
|
29
|
+
- bin/rails-preflight
|
|
30
|
+
- database/compatibility.yml
|
|
31
|
+
- database/deprecations.yml
|
|
32
|
+
- database/gems.yml
|
|
33
|
+
- lib/rails_preflight.rb
|
|
34
|
+
- lib/rails_preflight/config_analyzer.rb
|
|
35
|
+
- lib/rails_preflight/database_analyzer.rb
|
|
36
|
+
- lib/rails_preflight/deprecation_analyzer.rb
|
|
37
|
+
- lib/rails_preflight/docker_analyzer.rb
|
|
38
|
+
- lib/rails_preflight/gem_analyzer.rb
|
|
39
|
+
- lib/rails_preflight/json_report.rb
|
|
40
|
+
- lib/rails_preflight/markdown_report.rb
|
|
41
|
+
- lib/rails_preflight/report_generator.rb
|
|
42
|
+
- lib/rails_preflight/summary_calculator.rb
|
|
43
|
+
- lib/rails_preflight/upgrade_analyzer.rb
|
|
44
|
+
- lib/rails_preflight/version.rb
|
|
45
|
+
homepage: https://github.com/aslam/rails-preflight
|
|
46
|
+
licenses:
|
|
47
|
+
- MIT
|
|
48
|
+
metadata:
|
|
49
|
+
changelog_uri: https://github.com/aslam/rails-preflight/blob/main/CHANGELOG.md
|
|
50
|
+
bug_tracker_uri: https://github.com/aslam/rails-preflight/issues
|
|
51
|
+
rubygems_mfa_required: 'true'
|
|
52
|
+
rdoc_options: []
|
|
53
|
+
require_paths:
|
|
54
|
+
- lib
|
|
55
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
56
|
+
requirements:
|
|
57
|
+
- - ">="
|
|
58
|
+
- !ruby/object:Gem::Version
|
|
59
|
+
version: 2.7.0
|
|
60
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
61
|
+
requirements:
|
|
62
|
+
- - ">="
|
|
63
|
+
- !ruby/object:Gem::Version
|
|
64
|
+
version: '0'
|
|
65
|
+
requirements: []
|
|
66
|
+
rubygems_version: 3.7.2
|
|
67
|
+
specification_version: 4
|
|
68
|
+
summary: 'Static pre-upgrade audit for Rails apps: what stands between this app and
|
|
69
|
+
Rails X.'
|
|
70
|
+
test_files: []
|