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,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,3 @@
1
+ module RailsPreflight
2
+ VERSION = "0.1.0"
3
+ 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: []