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,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