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,178 @@
|
|
|
1
|
+
module RailsPreflight
|
|
2
|
+
# Gems that stop working, or are on their way out, across the upgrade: Rails limits their locked versions
|
|
3
|
+
# declare in Gemfile.lock, plus the curated list in database/gems.yml for limits only a README states.
|
|
4
|
+
class GemAnalyzer
|
|
5
|
+
DATA_PATH = File.expand_path('../../database/gems.yml', __dir__)
|
|
6
|
+
RAILS_GEMS = %w[rails railties actioncable actionmailbox actionmailer actionpack actiontext actionview
|
|
7
|
+
activejob activemodel activerecord activestorage activesupport].freeze
|
|
8
|
+
|
|
9
|
+
# specs: Gemfile.lock specs. hops: the Rails minors the upgrade passes through, after the current one, ending at the target.
|
|
10
|
+
# direct: gem names the Gemfile lists (Gemfile.lock DEPENDENCIES).
|
|
11
|
+
# last_releases: { gem name => latest release time (ISO 8601) } from rubygems.org; nil when not looked up (--offline).
|
|
12
|
+
# target_released: the target Rails release date (YYYY-MM-DD), nil when unknown.
|
|
13
|
+
def initialize(project_path, specs, hops:, direct: [], last_releases: {}, target_released: nil, database_path: DATA_PATH)
|
|
14
|
+
@project_path = project_path
|
|
15
|
+
@specs = specs
|
|
16
|
+
@hops = hops
|
|
17
|
+
@direct = direct
|
|
18
|
+
@last_releases = last_releases
|
|
19
|
+
@target_released = target_released
|
|
20
|
+
data = YAML.load_file(database_path)
|
|
21
|
+
@entries = data.fetch('gems', [])
|
|
22
|
+
@adapter_requirements = data.fetch('adapter_requirements', {})
|
|
23
|
+
@dropped_by_rails = data.fetch('dropped_by_rails', {})
|
|
24
|
+
@version_limits = data.fetch('version_limits', {})
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def run
|
|
28
|
+
result = { title: "Gem Compatibility", status: :passed, checks: [], confidence: :medium }
|
|
29
|
+
return result if @hops.empty? # already on the target
|
|
30
|
+
result[:checks].concat(locked_limits, adapter_limits, version_limits, dropped_by_rails, curated)
|
|
31
|
+
result[:checks].concat(stale(result[:checks].filter_map { |check| check[:gem] }))
|
|
32
|
+
|
|
33
|
+
if result[:checks].empty?
|
|
34
|
+
result[:checks] << { message: "No locked gem limits Rails below #{@hops.last}, and none is on the list of retired gems.", status: :passed }
|
|
35
|
+
else
|
|
36
|
+
statuses = result[:checks].map { |c| c[:status] }
|
|
37
|
+
result[:status] = statuses.include?(:failed) ? :failed : :warning
|
|
38
|
+
end
|
|
39
|
+
result
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
# A gem whose locked version requires, say, railties < 6.1 blocks the 6.1 hop until it's upgraded or replaced.
|
|
45
|
+
def locked_limits
|
|
46
|
+
@specs.filter_map do |spec|
|
|
47
|
+
next if RAILS_GEMS.include?(spec.name)
|
|
48
|
+
|
|
49
|
+
limits = spec.dependencies.select { |dep| RAILS_GEMS.include?(dep.name) }
|
|
50
|
+
breaks_in = @hops.find { |hop| limits.any? { |dep| !allows?(dep.requirement, hop) } }
|
|
51
|
+
next unless breaks_in
|
|
52
|
+
|
|
53
|
+
requires = limits.map { |dep| "#{dep.name} #{dep.requirement}" }.join(", ")
|
|
54
|
+
{ message: "#{spec.name} #{spec.version} requires #{requires}, so it doesn't install on Rails #{breaks_in}. Upgrade it to a release that allows #{breaks_in}, or replace it.",
|
|
55
|
+
status: :failed, removed_in: breaks_in, fix_effort: :medium, gem: spec.name, version: spec.version.to_s, requires: requires }
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# sqlite3 1.3 still installs next to Rails 6.0, but Rails refuses to load it as the adapter.
|
|
60
|
+
def adapter_limits
|
|
61
|
+
@specs.filter_map do |spec|
|
|
62
|
+
breaks_in = @hops.find do |hop|
|
|
63
|
+
requirement = @adapter_requirements.dig(hop, spec.name)
|
|
64
|
+
requirement && !Gem::Requirement.new(*requirement).satisfied_by?(spec.version)
|
|
65
|
+
end
|
|
66
|
+
next unless breaks_in
|
|
67
|
+
|
|
68
|
+
requirement = @adapter_requirements[breaks_in][spec.name].join(", ")
|
|
69
|
+
adapter = spec.name == "pg" ? "postgresql" : spec.name
|
|
70
|
+
{ message: "#{spec.name} #{spec.version} doesn't load on Rails #{breaks_in}, which requires #{spec.name} #{requirement}. Upgrade it in the same step.",
|
|
71
|
+
status: :failed, removed_in: breaks_in, fix_effort: :low, gem: spec.name, version: spec.version.to_s, requires: "#{spec.name} #{requirement}",
|
|
72
|
+
source: "https://github.com/rails/rails/blob/#{breaks_in.tr('.', '-')}-stable/activerecord/lib/active_record/connection_adapters/#{adapter}_adapter.rb" }
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# listen 3.1 installs next to Rails 7.0, but the evented file watcher refuses to load it. Only checked when the app uses the feature.
|
|
77
|
+
def version_limits
|
|
78
|
+
locked = @specs.to_h { |spec| [spec.name, spec.version] }
|
|
79
|
+
@version_limits.filter_map do |name, entry|
|
|
80
|
+
version = locked[name]
|
|
81
|
+
breaks_in = version && @hops.find do |hop|
|
|
82
|
+
requirement = requirement_at(entry['from'], hop)
|
|
83
|
+
requirement && !Gem::Requirement.new(*requirement).satisfied_by?(version)
|
|
84
|
+
end
|
|
85
|
+
next unless breaks_in && used?(entry['used_if'])
|
|
86
|
+
|
|
87
|
+
requirement = "#{name} #{requirement_at(entry['from'], breaks_in).join(', ')}"
|
|
88
|
+
why = entry['raises'] ? ": #{entry['raises']}; Rails #{breaks_in} needs #{requirement}" : ", which requires #{requirement} for #{entry['for']}"
|
|
89
|
+
{ message: "#{name} #{version} doesn't load on Rails #{breaks_in}#{why}. Upgrade it in the same step.",
|
|
90
|
+
status: :failed, removed_in: breaks_in, fix_effort: :low, gem: name, version: version.to_s, requires: requirement, source: entry['source'] }
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# The requirement under the latest `from` key at or before the hop.
|
|
95
|
+
def requirement_at(from, hop)
|
|
96
|
+
from.select { |since, _| Gem::Version.new(since) <= Gem::Version.new(hop) }.max_by { |since, _| Gem::Version.new(since) }&.last
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# A gem the app gets only through `rails` disappears on the hop where rails stops depending on it.
|
|
100
|
+
# Still pulled in by the Gemfile or another gem (sass-rails needs sprockets-rails), it stays; unused, it doesn't matter.
|
|
101
|
+
def dropped_by_rails
|
|
102
|
+
locked = @specs.map(&:name)
|
|
103
|
+
kept = @direct + @specs.reject { |spec| spec.name == "rails" }.flat_map { |spec| spec.dependencies.map(&:name) }
|
|
104
|
+
@hops.flat_map do |hop|
|
|
105
|
+
@dropped_by_rails.fetch(hop, {}).select { |name, used_if| locked.include?(name) && !kept.include?(name) && used?(used_if) }.keys.map do |name|
|
|
106
|
+
{ message: "Rails #{hop} no longer depends on #{name}, and the Gemfile doesn't list it. Add gem \"#{name}\" to the Gemfile in the same step.",
|
|
107
|
+
status: :failed, removed_in: hop, fix_effort: :low, gem: name }
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def used?(used_if)
|
|
113
|
+
return true if used_if.fetch('paths', []).any? { |path| File.exist?(File.join(@project_path, path)) }
|
|
114
|
+
|
|
115
|
+
pattern = used_if['pattern'] && Regexp.new(used_if['pattern'])
|
|
116
|
+
pattern && Dir.glob(File.join(@project_path, used_if.fetch('files', "config/**/*.rb"))).any? do |file|
|
|
117
|
+
File.foreach(file).any? { |line| !line.lstrip.start_with?("#") && line.match?(pattern) }
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Gems with no release since before the target Rails shipped: nothing says they break, but nobody may have tried.
|
|
122
|
+
# One grouped "couldn't check", oldest first. Gems already reported, or on the curated list, are left out.
|
|
123
|
+
def stale(reported)
|
|
124
|
+
if @last_releases.nil?
|
|
125
|
+
return [{ message: "Skipped the gem release-date check (--offline): gems not updated since before Rails #{@hops.last} aren't flagged.", status: :passed, kind: :tip }]
|
|
126
|
+
end
|
|
127
|
+
return [] unless @target_released
|
|
128
|
+
|
|
129
|
+
skip = reported + @entries.map { |entry| entry['name'] }
|
|
130
|
+
old = @last_releases.select { |name, at| at[0, 10] < @target_released && !skip.include?(name) }.sort_by { |_, at| at }
|
|
131
|
+
return [] if old.empty?
|
|
132
|
+
|
|
133
|
+
[{ message: "#{old.size} #{old.size == 1 ? 'gem' : 'gems'} that depend on Rails had no release since before Rails #{@hops.last} shipped (#{@target_released}). Check they work on #{@hops.last}, or find replacements.",
|
|
134
|
+
status: :warning, kind: :unknown, fix_effort: :unknown,
|
|
135
|
+
names: old.map { |name, at| "#{name} (last release #{at[0, 10]})" } }]
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Any patch release of the minor counts: `>= 7.0.1` allows 7.0.
|
|
139
|
+
def allows?(requirement, minor)
|
|
140
|
+
[minor, "#{minor}.99"].any? { |v| requirement.satisfied_by?(Gem::Version.new(v)) }
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def curated
|
|
144
|
+
locked = @specs.to_h { |spec| [spec.name, spec.version] }
|
|
145
|
+
@entries.filter_map do |entry|
|
|
146
|
+
version = locked[entry['name']]
|
|
147
|
+
next unless version
|
|
148
|
+
next if entry['fixed_in'] && version >= Gem::Version.new(entry['fixed_in'])
|
|
149
|
+
|
|
150
|
+
files = usage(entry['pattern'])
|
|
151
|
+
next if entry['only_if_used'] && files.empty?
|
|
152
|
+
|
|
153
|
+
breaks_in = entry['breaks_in']
|
|
154
|
+
blocks = @hops.include?(breaks_in) # every hop is past the current Rails
|
|
155
|
+
message = "#{entry['name']} #{entry['fixed_in'] ? "#{version} " : ''}#{entry['message']}"
|
|
156
|
+
broken = breaks_in && !blocks && Gem::Version.new(breaks_in) <= Gem::Version.new(@hops.last)
|
|
157
|
+
if broken
|
|
158
|
+
message += " It doesn't work from Rails #{breaks_in}, which this app is already on."
|
|
159
|
+
elsif breaks_in && !blocks
|
|
160
|
+
message += " It doesn't work from Rails #{breaks_in}, so plan the move before then."
|
|
161
|
+
end
|
|
162
|
+
message += " Used in #{files.size} #{files.size == 1 ? 'file' : 'files'}." if files
|
|
163
|
+
|
|
164
|
+
{ message: message, status: blocks || broken ? :failed : :warning, kind: (:broken if broken), removed_in: breaks_in,
|
|
165
|
+
gem: entry['name'], version: version.to_s, source: entry['source'], files: Array(files).map { |file| { file: file } },
|
|
166
|
+
fix_effort: entry['fix_effort']&.to_sym }
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Files matching the entry's pattern, nil when it has none.
|
|
171
|
+
def usage(pattern)
|
|
172
|
+
return unless pattern
|
|
173
|
+
|
|
174
|
+
rule = { 'pattern' => pattern, 'message' => pattern }
|
|
175
|
+
DeprecationAnalyzer.new(@project_path).scan_files([rule]).map { |hit| hit[:file] }.uniq.sort
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
|
|
3
|
+
module RailsPreflight
|
|
4
|
+
# The report as JSON, for CI and other tools. Findings are the analyzers' checks with their parts (see
|
|
5
|
+
# SummaryCalculator), plus :section and :kind. No timestamp: the same app gives the same output.
|
|
6
|
+
# Schema 1 may still change before 1.0; the number goes up when it does.
|
|
7
|
+
class JsonReport
|
|
8
|
+
SCHEMA = 1
|
|
9
|
+
|
|
10
|
+
def initialize(data)
|
|
11
|
+
@data = data
|
|
12
|
+
@summary = data[:summary] || {}
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def generate
|
|
16
|
+
ruby, source = @data[:ruby]
|
|
17
|
+
report = {
|
|
18
|
+
schema: SCHEMA,
|
|
19
|
+
tool: { name: "rails-preflight", version: VERSION },
|
|
20
|
+
app: @data[:app],
|
|
21
|
+
current_rails: @data[:current_rails],
|
|
22
|
+
target_rails: @data[:target_rails],
|
|
23
|
+
ruby: ({ version: ruby, source: source } if ruby),
|
|
24
|
+
offline: @data[:offline],
|
|
25
|
+
verdict: @summary[:verdict],
|
|
26
|
+
counts: %i[broken blockers to_fix unknowns].to_h { |key| [key, @summary.fetch(key, []).size] },
|
|
27
|
+
before: @summary.fetch(:before, []).map { |entry| finding(entry) },
|
|
28
|
+
steps: @summary.fetch(:steps, []).map do |step|
|
|
29
|
+
step.slice(:from, :version, :ruby, :ruby_upgrade).merge(findings: step[:checks].map { |entry| finding(entry) })
|
|
30
|
+
end,
|
|
31
|
+
ahead: @summary.fetch(:ahead, {}).transform_values { |entries| entries.map { |entry| finding(entry) } },
|
|
32
|
+
cant_see: @summary.fetch(:cant_see, []).map { |entry| finding(entry) }
|
|
33
|
+
}
|
|
34
|
+
"#{JSON.pretty_generate(report)}\n"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
# :status is how the analyzer saw it; :kind is what the report counts it as.
|
|
40
|
+
def finding(entry)
|
|
41
|
+
# Hash#except needs Ruby 3.0; the tool runs on 2.7.
|
|
42
|
+
entry.slice(:section, :kind).merge(entry.reject { |key, _| %i[status section kind].include?(key) }).compact
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
module RailsPreflight
|
|
2
|
+
# The report as Markdown: for issues, PRs and coding agents. Takes the same data as ReportGenerator
|
|
3
|
+
# and renders only the findings' parts, never the HTML.
|
|
4
|
+
class MarkdownReport
|
|
5
|
+
LABELS = { broken: "Broken", blocker: "Blocker", to_fix: "To fix", tip: "Tip" }.freeze
|
|
6
|
+
|
|
7
|
+
def initialize(data)
|
|
8
|
+
@data = data
|
|
9
|
+
@summary = data[:summary] || {}
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def generate
|
|
13
|
+
out = []
|
|
14
|
+
out << "# #{@data[:app]}: Rails #{@data[:current_rails] || 'unknown'} to #{@data[:target_rails]}"
|
|
15
|
+
out << @summary[:verdict]
|
|
16
|
+
counts = { "Already broken" => :broken, "Blockers" => :blockers, "To fix" => :to_fix, "Couldn't check" => :unknowns }
|
|
17
|
+
out << counts.map { |label, key| "#{label}: #{@summary.fetch(key, []).size}" }.join(" · ")
|
|
18
|
+
out << "> For a coding agent: take one step at a time, in order. After each step, run the test suite and fix what fails " \
|
|
19
|
+
"before starting the next. Leave \"Ahead\" and \"Couldn't check\" alone unless asked."
|
|
20
|
+
|
|
21
|
+
before = @summary.fetch(:before, [])
|
|
22
|
+
out << section("Before you start#{" (on Rails #{@data[:current_rails]})" if @data[:current_rails]}", before) if before.any?
|
|
23
|
+
@summary.fetch(:steps, []).each.with_index(1) do |step, i|
|
|
24
|
+
title = "Step #{i}: #{step[:from] ? "#{step[:from]} to #{step[:version]}" : "Rails #{step[:version]}"}"
|
|
25
|
+
out << section(title, step[:checks], "#{step[:ruby]}.")
|
|
26
|
+
end
|
|
27
|
+
@summary.fetch(:ahead, {}).each do |version, entries|
|
|
28
|
+
out << section("Ahead: removed in #{version}, not needed for #{@data[:target_rails]}", entries)
|
|
29
|
+
end
|
|
30
|
+
cant_see = @summary.fetch(:cant_see, [])
|
|
31
|
+
if cant_see.any?
|
|
32
|
+
out << "## Couldn't check\n\n" + cant_see.map { |entry| "- #{text(entry[:message])}#{": #{entry[:names].join(', ')}" if entry[:names]&.any?}" }.join("\n")
|
|
33
|
+
end
|
|
34
|
+
out << "Made with [rails-preflight](#{ReportGenerator::REPO_URL}) #{VERSION}. Run it again after each step."
|
|
35
|
+
"#{out.join("\n\n")}\n"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def section(title, entries, note = nil)
|
|
41
|
+
lines = entries.map { |entry| finding(entry) }
|
|
42
|
+
lines = ["Nothing found."] if lines.empty?
|
|
43
|
+
["## #{title}", note, lines.join("\n")].compact.join("\n\n")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# A checkbox per finding to fix, then where it is: file:line with the matched line, or the files that use a gem.
|
|
47
|
+
def finding(entry)
|
|
48
|
+
label = LABELS.fetch(entry[:kind], entry[:kind].to_s)
|
|
49
|
+
box = entry[:kind] == :tip ? "-" : "- [ ]"
|
|
50
|
+
cite = " ([source](#{entry[:source]}))" if entry[:source].to_s.start_with?("https://")
|
|
51
|
+
lines = ["#{box} **#{label}:** #{text(entry[:message])}#{cite}"]
|
|
52
|
+
Array(entry[:files]).each do |hit|
|
|
53
|
+
place = hit[:line] ? "#{hit[:file]}:#{hit[:line]}" : hit[:file]
|
|
54
|
+
lines << " - #{code(place)}#{" #{code(hit[:snippet])}" if hit[:snippet]}"
|
|
55
|
+
end
|
|
56
|
+
lines.join("\n")
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# 'quoted' names become code, as in the HTML; an apostrophe inside a word (doesn't) stays.
|
|
60
|
+
def text(message)
|
|
61
|
+
message.to_s.gsub(/(?<!\w)'(.+?)'(?!\w)/) { code($1) }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# A code span that survives backticks in the snippet.
|
|
65
|
+
def code(value)
|
|
66
|
+
value = value.to_s
|
|
67
|
+
fence = "`" * ((value.scan(/`+/).map(&:size).max || 0) + 1)
|
|
68
|
+
pad = value.start_with?("`") || value.end_with?("`") ? " " : ""
|
|
69
|
+
"#{fence}#{pad}#{value}#{pad}#{fence}"
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|