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.
data/database/gems.yml ADDED
@@ -0,0 +1,109 @@
1
+ # Public gems whose gemspec doesn't state their Rails limit, so Bundler won't warn before the upgrade breaks.
2
+ # Each entry cites where its limit comes from. Constraints a gemspec does declare are read from Gemfile.lock instead.
3
+ # breaks_in: first Rails minor the gem doesn't work on; a blocker on that hop. Omitted for gems that are
4
+ # retired or deprecated but still run: fix them when convenient.
5
+ # pattern: optional; counts the files that use the gem, so the report shows how big the move is.
6
+ # fixed_in: optional; only locked versions below it are affected.
7
+ # only_if_used: report only when the pattern matches, for gems that break in one use only.
8
+ gems:
9
+ - name: protected_attributes_continued
10
+ breaks_in: "7.0"
11
+ message: "supports Rails 5.0 to 6.1 only and won't be updated for 7.0. Move to strong parameters."
12
+ source: "https://github.com/westonganger/protected_attributes_continued#readme"
13
+ pattern: "^\\s*attr_(?:accessible|protected)\\b"
14
+ fix_effort: high
15
+ - name: paperclip
16
+ message: "is deprecated and archived. Move to Active Storage, or to the maintained kt-paperclip fork."
17
+ source: "https://github.com/thoughtbot/paperclip#deprecated"
18
+ pattern: "^\\s*(?:has_attached_file|validates_attachment\\w*)\\b"
19
+ fix_effort: high
20
+ - name: webpacker
21
+ message: "was retired after Rails 7.0. Move to jsbundling-rails or import maps, or to shakapacker to keep webpack."
22
+ source: "https://github.com/rails/webpacker#readme"
23
+ pattern: "\\b(?:javascript|stylesheet)_pack_tag\\b"
24
+ fix_effort: medium
25
+ - name: therubyracer
26
+ message: "is unmaintained and stuck on V8 3.16. Move to mini_racer, or drop it if Node.js is available to ExecJS."
27
+ source: "https://github.com/rubyjs/therubyracer#readme"
28
+ fix_effort: low
29
+ - name: turbolinks
30
+ message: "is archived and superseded by Turbo (turbo-rails), the default since Rails 7.0."
31
+ source: "https://github.com/turbolinks/turbolinks#readme"
32
+ fix_effort: medium
33
+ - name: sass
34
+ message: "(Ruby Sass) is archived and gets no updates. Move to dartsass-rails (sassc, the replacement its README names, is archived too)."
35
+ source: "https://github.com/sass/ruby-sass#readme"
36
+ fix_effort: low
37
+ - name: sassc
38
+ message: "is archived and gets no updates. Move to dartsass-rails or dartsass-sprockets, or to sassc-embedded as a drop-in."
39
+ source: "https://github.com/sass/sassc-ruby#readme"
40
+ fix_effort: low
41
+ - name: que
42
+ breaks_in: "7.1"
43
+ fixed_in: "2.3.0"
44
+ only_if_used: true
45
+ message: "runs Active Job through Rails' Que adapter, which Rails 7.1 removed. Upgrade que to 2.3.0+, which ships its own."
46
+ source: "https://github.com/que-rb/que/blob/master/CHANGELOG.md#230-2023-10-16"
47
+ pattern: "\\bqueue_adapter\\s*=\\s*:que\\b"
48
+ fix_effort: low
49
+
50
+ # Database adapter gems Rails checks itself when it loads the adapter (`gem "pg", "~> 1.1"`), so no gemspec
51
+ # declares the limit. Read from activerecord/lib/active_record/connection_adapters/*_adapter.rb on each
52
+ # X-Y-stable branch; a locked adapter outside the range blocks that hop.
53
+ adapter_requirements:
54
+ "5.1": { sqlite3: ["~> 1.3", ">= 1.3.6"], mysql2: [">= 0.3.18", "< 0.6.0"], pg: [">= 0.18", "< 2.0"] }
55
+ "5.2": { sqlite3: ["~> 1.3", ">= 1.3.6"], mysql2: [">= 0.4.4", "< 0.6.0"], pg: [">= 0.18", "< 2.0"] }
56
+ "6.0": { sqlite3: ["~> 1.4"], mysql2: [">= 0.4.4"], pg: [">= 0.18", "< 2.0"] }
57
+ "6.1": { sqlite3: ["~> 1.4"], mysql2: ["~> 0.5"], pg: ["~> 1.1"] }
58
+ "7.0": { sqlite3: ["~> 1.4"], mysql2: ["~> 0.5"], pg: ["~> 1.1"] }
59
+ "7.1": { sqlite3: [">= 1.4"], mysql2: ["~> 0.5"], pg: ["~> 1.1"], trilogy: ["~> 2.4"] }
60
+ "7.2": { sqlite3: [">= 1.4"], mysql2: ["~> 0.5"], pg: ["~> 1.1"], trilogy: ["~> 2.7"] }
61
+ "8.0": { sqlite3: [">= 2.1"], mysql2: ["~> 0.5"], pg: ["~> 1.1"], trilogy: ["~> 2.7"] }
62
+ "8.1": { sqlite3: [">= 2.1"], mysql2: ["~> 0.5"], pg: ["~> 1.1"], trilogy: ["~> 2.7"] }
63
+
64
+ # Gems the rails gem stopped depending on, from rails.gemspec on each X-Y-stable branch. An app that gets one
65
+ # only through rails loses it on that hop, which matters only if the app uses it: `paths` any of which exists,
66
+ # or `pattern` on an uncommented line in `files` (default config/**/*.rb). Without sprockets-rails, `config.assets`
67
+ # raises on boot (7.0's rails/all no longer requires sprockets/railtie); an app with neither never loaded it.
68
+ dropped_by_rails:
69
+ "7.0":
70
+ sprockets-rails:
71
+ paths: [app/assets]
72
+ pattern: "\\bconfig\\.assets\\b|require\\s*\\(?\\s*[\"']sprockets"
73
+
74
+ # Version limits no gemspec declares. Mostly gems Rails loads itself for one feature, with a requirement of its own
75
+ # (`gem "listen", "~> 3.5"`), read from each X-Y-stable branch; `raises` marks a gem that checks the Rails version
76
+ # itself instead. `from` maps the first hop that requires a version to that requirement, which holds until a later
77
+ # key. `used_if` works as in dropped_by_rails: an app that never uses the feature never loads the gem.
78
+ version_limits:
79
+ listen:
80
+ from: { "7.0": ["~> 3.5"] }
81
+ for: "the evented file watcher in development"
82
+ used_if: { pattern: "\\bEventedFileUpdateChecker\\b" }
83
+ source: "https://github.com/rails/rails/blob/7-0-stable/activesupport/lib/active_support/evented_file_update_checker.rb#L3"
84
+ capybara:
85
+ from: { "5.1": [">= 2.15"], "6.1": [">= 3.26"] }
86
+ for: "system tests"
87
+ used_if: { files: "{test,spec}/**/*.rb", pattern: "\\bdriven_by\\b|\\bSystemTestCase\\b|type:\\s*:system\\b" }
88
+ source: "https://github.com/rails/rails/blob/6-1-stable/actionpack/lib/action_dispatch/system_test_case.rb#L3"
89
+ selenium-webdriver:
90
+ from: { "7.0": [">= 4.0.0"] }
91
+ for: "system tests driven by :selenium"
92
+ used_if: { files: "{test,spec}/**/*.rb", pattern: "\\bdriven_by\\s*\\(?\\s*:selenium\\b" }
93
+ source: "https://github.com/rails/rails/blob/7-0-stable/actionpack/lib/action_dispatch/system_testing/driver.rb#L24"
94
+ redis:
95
+ from: { "5.1": [">= 3", "< 5"], "7.0": [">= 3", "< 6"], "7.1": [">= 4", "< 6"], "8.0": [">= 4", "< 7"] }
96
+ for: "Action Cable's redis adapter"
97
+ used_if: { files: "config/cable.yml", pattern: "^\\s*adapter:\\s*[\"']?redis\\b" }
98
+ source: "https://github.com/rails/rails/blob/7-1-stable/actioncable/lib/action_cable/subscription_adapter/redis.rb#L3"
99
+ google-cloud-storage:
100
+ from: { "5.2": ["~> 1.8"], "6.0": ["~> 1.11"] }
101
+ for: "Active Storage's GCS service"
102
+ used_if: { files: "config/storage.yml", pattern: "^\\s*service:\\s*[\"']?GCS\\b" }
103
+ source: "https://github.com/rails/rails/blob/6-0-stable/activestorage/lib/active_storage/service/gcs_service.rb#L3"
104
+ bullet:
105
+ from: { "5.1": [">= 5.4.0"], "5.2": [">= 5.7.0"], "6.0": [">= 6.0.0"], "6.1": [">= 6.1.1"], "7.0": [">= 7.0.0"],
106
+ "7.1": [">= 7.1.0"], "7.2": [">= 7.2.0"], "8.0": [">= 8.0.0"], "8.1": [">= 8.1.0"] }
107
+ raises: "Bullet raises on an Active Record minor it doesn't know"
108
+ used_if: { pattern: "\\bBullet\\.enable\\s*=" }
109
+ source: "https://github.com/flyerhzm/bullet/blob/8.1.0/lib/bullet/dependency.rb"
@@ -0,0 +1,46 @@
1
+ # lib/rails_preflight/config_analyzer.rb
2
+ module RailsPreflight
3
+ class ConfigAnalyzer
4
+ def initialize(root_path = Dir.pwd, current_rails = nil)
5
+ @root_path = root_path
6
+ # load_defaults takes a minor version ("7.1"), so compare at that level
7
+ @current_rails = Gem::Version.new(current_rails.to_s.split(".").first(2).join(".")) if current_rails
8
+ end
9
+
10
+ def run
11
+ result = { title: "Configuration", status: :passed, checks: [], confidence: :high }
12
+
13
+ app_config = File.join(@root_path, 'config', 'application.rb')
14
+ if File.exist?(app_config)
15
+ check_load_defaults(File.read(app_config), result[:checks])
16
+ else
17
+ result[:checks] << { message: "config/application.rb not found.", status: :warning, kind: :unknown }
18
+ end
19
+
20
+ if result[:checks].any?
21
+ result[:status] = :warning
22
+ else
23
+ result[:checks] << { message: "No configuration issues found.", status: :passed }
24
+ end
25
+
26
+ result
27
+ end
28
+
29
+ private
30
+
31
+ def check_load_defaults(content, checks)
32
+ match = content.match(/^\s*config\.load_defaults\b\s*\(?\s*["']?(\d+\.\d+)?/)
33
+
34
+ if match.nil?
35
+ # load_defaults arrived in Rails 5.1
36
+ return if @current_rails && @current_rails < Gem::Version.new("5.1")
37
+ checks << { message: "config.load_defaults missing in application.rb. Without it, the app keeps legacy framework defaults unless each is set by hand.", status: :warning, fix_effort: :medium }
38
+ elsif match[1] && @current_rails && Gem::Version.new(match[1]) < @current_rails
39
+ message = "load_defaults #{match[1]} is behind Rails #{@current_rails}: framework defaults added after #{match[1]} are not enabled."
40
+ pending = Dir.glob(File.join(@root_path, 'config', 'initializers', 'new_framework_defaults*.rb')).map { |f| File.basename(f) }
41
+ message += " Found #{pending.join(', ')}, so enabling them looks in progress." if pending.any?
42
+ checks << { message: message, status: :warning, fix_effort: :medium }
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,64 @@
1
+ # lib/rails_preflight/database_analyzer.rb
2
+
3
+ module RailsPreflight
4
+ class DatabaseAnalyzer
5
+ def initialize(project_path, rules = [])
6
+ @schema_path = File.join(project_path, "db/schema.rb")
7
+ @structure_path = File.join(project_path, "db/structure.sql")
8
+ @rules = rules || []
9
+ @warnings = []
10
+ end
11
+
12
+ def run
13
+ # Pattern-based, so medium confidence
14
+ result = { title: "Database Schema", status: :passed, checks: [], confidence: :medium }
15
+
16
+ unless File.exist?(@schema_path)
17
+ result[:status] = :warning
18
+ # ponytail: structure.sql is only noted; reading its CREATE TABLEs is on the roadmap.
19
+ message = if File.exist?(@structure_path)
20
+ "The schema is in db/structure.sql, which isn't read yet, so the schema checks (table charset, integer primary keys) were skipped. Check those in the SQL by hand."
21
+ else
22
+ "No db/schema.rb found. Skipping database checks."
23
+ end
24
+ result[:checks] << { message: message, status: :warning, kind: :unknown }
25
+ return result
26
+ end
27
+
28
+ if @rules.empty?
29
+ result[:checks] << { message: "No database checks apply to this target Rails version.", status: :passed }
30
+ return result
31
+ end
32
+
33
+ content = File.read(@schema_path)
34
+ scan_schema(content)
35
+
36
+ if @warnings.any?
37
+ result[:status] = @warnings.any? { |w| w[:severity] == 'critical' } ? :failed : :warning
38
+ @warnings.each do |w|
39
+ result[:checks] << {
40
+ message: w[:message],
41
+ status: w[:severity] == 'critical' ? :failed : :warning
42
+ }
43
+ end
44
+ end
45
+
46
+ result
47
+ end
48
+
49
+ private
50
+
51
+ def scan_schema(content)
52
+ @rules.each do |rule|
53
+ pattern = Regexp.new(rule['pattern'])
54
+ if content.match?(pattern)
55
+ @warnings << {
56
+ type: rule['name'],
57
+ message: rule['message'],
58
+ severity: rule['type']
59
+ }
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,214 @@
1
+ module RailsPreflight
2
+ require 'pathname'
3
+ class DeprecationAnalyzer
4
+ DATA_PATH = File.expand_path('../../database/deprecations.yml', __dir__)
5
+ SCAN_DIRS = %w[app config db lib test spec].freeze
6
+ SCAN_EXTS = %w[.rb .erb .rake .haml .slim].freeze
7
+ # Lines that never run, by file type: [line comment, silent comment]. A silent comment hides the lines indented
8
+ # under it too (HAML `-#`, Slim `/`); an HTML comment (HAML `/`, Slim `/!` or `/[if IE]`) is text, but the lines
9
+ # under it still render and run. In templates `#main` is an element id, not a comment.
10
+ COMMENTS = {
11
+ '.haml' => [%r{\A/}, /\A-#/],
12
+ '.slim' => [%r{\A/[!\[]}, %r{\A/(?![!\[])}]
13
+ }.freeze
14
+ RUBY_COMMENT = [/\A(?:#|<%#)/, nil].freeze
15
+ # A rule runs on the kinds of files its `files:` lists, code by default.
16
+ # scripts: where rake tasks get called from outside Ruby code. yaml: the app's own config files.
17
+ FILE_GLOBS = {
18
+ 'scripts' => %w[bin/* script/**/* Procfile* Makefile Dockerfile* *.sh .github/workflows/*.{yml,yaml}
19
+ .circleci/config.yml .gitlab-ci.yml .travis.yml],
20
+ 'yaml' => %w[config/**/*.yml config/**/*.yml.erb]
21
+ }.freeze
22
+ # Snippets go into a report people print and share: on a line that names a secret, values are hidden.
23
+ SECRET_NAME = /secret|token|passw(?:or)?d|api_?key|access_?key|private_?key|credential/i
24
+
25
+ def initialize(root_path = Dir.pwd, database_path = DATA_PATH, target_rails: nil, current_rails: nil)
26
+ @root_path = root_path
27
+ @rules = YAML.load_file(database_path)
28
+ @target_rails = target_rails
29
+ @current_rails = current_rails
30
+ end
31
+
32
+ def run
33
+ result = { title: "Deprecation Warnings", status: :passed, checks: [], confidence: :medium }
34
+
35
+ warnings = scan_files(@rules['deprecations'].reject { |rule| rule['unless_gem'] && kept_by_gem?(rule['unless_gem']) })
36
+
37
+ if warnings.any?
38
+ # Group warnings by message
39
+ grouped_warnings = warnings.group_by { |w| w[:message] }
40
+
41
+ grouped_warnings.each do |message, occurrences|
42
+ rule = occurrences.first
43
+ info = rule[:severity].to_s.downcase == "info"
44
+ # An API this upgrade removes blocks it; one removed before the current Rails is already broken
45
+ # (or dead code); otherwise it's a warning to fix.
46
+ removed_in = rule[:removed_in]
47
+ status = info ? :passed : (removed_by_target?(removed_in) || already_removed?(removed_in) ? :failed : :warning)
48
+
49
+ result[:checks] << {
50
+ message: message,
51
+ status: status,
52
+ kind: (:tip if info) || (:broken if already_removed?(removed_in)),
53
+ rule: rule[:rule],
54
+ removed_in: removed_in,
55
+ fix_effort: (rule[:fix_effort] || "low").to_sym,
56
+ confidence: rule[:confidence]&.downcase&.to_sym,
57
+ source: rule[:guide_link],
58
+ files: occurrences.map { |hit| hit.slice(:file, :line, :snippet, :test) }
59
+ }
60
+ end
61
+
62
+ statuses = result[:checks].map { |c| c[:status] }
63
+ result[:status] = statuses.include?(:failed) ? :failed : (statuses.include?(:warning) ? :warning : :passed)
64
+ else
65
+ result[:checks] << { message: "No obvious deprecated patterns found (this scan is static; check your deprecation logs too).", status: :passed }
66
+ end
67
+
68
+ # Rubocop Advisory Check
69
+ if check_rubocop_rails
70
+ result[:checks] << {
71
+ message: "rubocop-rails is installed. Run `bundle exec rubocop -a` to auto-fix more deprecations.",
72
+ status: :passed
73
+ }
74
+ else
75
+ result[:checks] << {
76
+ message: "Install rubocop-rails. It can auto-fix many deprecations this tool only detects.",
77
+ status: :passed,
78
+ kind: :tip
79
+ }
80
+ end
81
+
82
+ result
83
+ end
84
+
85
+ # One pass over the files, every rule tested per line: a rule costs a regex, not a re-read.
86
+ def scan_files(rules)
87
+ found = []
88
+ compiled = rules.map { |rule| [Regexp.new(rule['pattern']), rule] }
89
+
90
+ files = Dir.glob(File.join(@root_path, "{#{SCAN_DIRS.join(',')}}/**/*")).select { |file| SCAN_EXTS.include?(File.extname(file)) }.to_h { |file| [file, 'code'] }
91
+ FILE_GLOBS.each do |kind, globs|
92
+ Dir.glob(globs.map { |glob| File.join(@root_path, glob) }).each { |file| files[file] ||= kind }
93
+ end
94
+
95
+ files.each do |file, kind|
96
+ next if File.directory?(file)
97
+
98
+ relative_path = Pathname.new(file).relative_path_from(Pathname.new(@root_path)).to_s
99
+ test = relative_path.start_with?('test/', 'spec/')
100
+
101
+ under = ->(paths) { paths.any? { |path| relative_path == path || relative_path.start_with?("#{path}/") } }
102
+ # A rule with `paths:` only applies under those directories or to those files.
103
+ applicable = compiled.select do |_regex, rule|
104
+ rule.fetch('files', ['code']).include?(kind) && (rule['paths'].nil? || under.(rule['paths']))
105
+ end
106
+ next if applicable.empty?
107
+ content = File.read(file)
108
+ # Only `x.errors` counts under `needs_receiver_in:` (a bare `errors` in a helper or view is a local), and in a
109
+ # file that assigns `errors` or takes it as a parameter (`skip_bare_if_local:`), where the bare name is that local.
110
+ # ponytail: file-wide, so a model with an `errors` local in one method loses bare hits in the others.
111
+ needs_receiver = applicable.select do |_regex, rule|
112
+ (rule['needs_receiver_in'] && under.(rule['needs_receiver_in'])) ||
113
+ (rule['skip_bare_if_local'] && local?(content, rule['skip_bare_if_local']))
114
+ end.map(&:last)
115
+
116
+ line_comment, silent_comment = COMMENTS.fetch(File.extname(file), RUBY_COMMENT)
117
+ silenced = nil # indent of the silent comment the current lines sit under
118
+ content.each_line.with_index(1) do |line, line_num|
119
+ indent = line[/\A[ \t]*/].size
120
+ next if silenced && (line.strip.empty? || indent > silenced)
121
+
122
+ silenced = nil
123
+ code = line.lstrip
124
+ if silent_comment&.match?(code)
125
+ silenced = indent
126
+ next
127
+ end
128
+ # Skip comments and method definitions (e.g. "def update_attributes") to avoid false positives
129
+ next if line_comment.match?(code) || code.start_with?("def ")
130
+
131
+ applicable.each do |regex, rule|
132
+ next unless (match = regex.match(line))
133
+ next if needs_receiver.include?(rule) && !match.pre_match.end_with?(".")
134
+ # `respond_to?(:x)` or `try(:x)` checks for the API, it doesn't use it; the line still counts if it uses it too.
135
+ next if match.pre_match.match?(/(?:respond_to\?|try)\(\s*:\z/) && line.scan(regex).size == 1
136
+
137
+ found << {
138
+ message: rule['message'],
139
+ rule: rule['id'],
140
+ file: relative_path,
141
+ line: line_num,
142
+ test: test,
143
+ snippet: redact(line.strip),
144
+ confidence: rule['confidence'],
145
+ guide_link: rule['guide_link'],
146
+ removed_in: rule['removed_in'],
147
+ severity: rule['severity'],
148
+ fix_effort: rule['fix_effort']
149
+ }
150
+ end
151
+ end
152
+ end
153
+ found
154
+ end
155
+
156
+ private
157
+
158
+ # Quoted strings, and plain `name: value` / `NAME=value` values (YAML, shell, CI config), become [hidden].
159
+ # ponytail: over-hides (ENV["SECRET_TOKEN"] loses its name too); the file and line still point to it.
160
+ def redact(line)
161
+ return line unless line.match?(SECRET_NAME)
162
+
163
+ line.gsub(/(["'])(?:\\.|(?!\1).)*\1/) { "#{$1}[hidden]#{$1}" }
164
+ .gsub(/((?:#{SECRET_NAME.source})\w*\s*(?:=>|[:=])\s*)[^"'\s#,][^#,]*?(?=\s*(?:#|,|$))/i) { "#{$1}[hidden]" }
165
+ end
166
+
167
+ # `name = ...`, `name ||= ...`, a block parameter (`do |x, name|`) or a method parameter.
168
+ def local?(content, name)
169
+ # [ \t], not \s: each part stays on one line (`def valid?` then `errors` on the next isn't a parameter).
170
+ content.match?(/(?<![.@\w])#{name}[ \t]*(?:\|\|)?=(?![=~>])|(?:\bdo|\{)[ \t]*\|[\w \t,*&]*\b#{name}\b[\w \t,*&]*\||
171
+ ^[ \t]*def[ \t]+(?:self\.)?\w+[!?]?(?:[ \t]*\([^)\n]*\b#{name}\b|[ \t]+[^(=;\n]*\b#{name}\b)/x)
172
+ end
173
+
174
+ # Removed at or before the current version: the app already runs without it.
175
+ def already_removed?(removed_in)
176
+ removed_in && @current_rails && Gem::Version.new(removed_in.to_s) <= Gem::Version.new(@current_rails)
177
+ end
178
+
179
+ # Removed after the current version and by the target: this upgrade breaks it.
180
+ def removed_by_target?(removed_in)
181
+ return false unless removed_in && @target_rails
182
+ removed = Gem::Version.new(removed_in.to_s)
183
+ removed <= Gem::Version.new(@target_rails) && (@current_rails.nil? || removed > Gem::Version.new(@current_rails))
184
+ end
185
+
186
+ def check_rubocop_rails
187
+ locked?("rubocop-rails")
188
+ end
189
+
190
+ # `unless_gem:` names a gem, optionally with a version ("benchmark >= 0.5.0"), that keeps the API working when the
191
+ # Gemfile lists it: record_tag_helper keeps div_for, benchmark 0.5+ keeps Benchmark.ms. Only a gem the Gemfile lists
192
+ # counts; one another gem pulls in can disappear on the upgrade (Active Support 8.1 drops benchmark).
193
+ def kept_by_gem?(spec)
194
+ name, requirement = spec.split(" ", 2)
195
+ version = locked_version(name)
196
+ direct = lockfile[/^DEPENDENCIES\n((?: {2}.*\n?)*)/, 1].to_s.lines.any? { |line| line.strip[/\A[^\s!]+/] == name }
197
+ direct && version && Gem::Requirement.new(requirement || ">= 0").satisfied_by?(version)
198
+ end
199
+
200
+ def locked?(gem)
201
+ !locked_version(gem).nil?
202
+ end
203
+
204
+ def locked_version(gem)
205
+ version = lockfile[/^ {4}#{Regexp.escape(gem)} \(([^)\s-]+)/, 1]
206
+ version && Gem::Version.new(version)
207
+ end
208
+
209
+ def lockfile
210
+ @lockfile ||= File.exist?(File.join(@root_path, "Gemfile.lock")) ? File.read(File.join(@root_path, "Gemfile.lock")) : ""
211
+ end
212
+
213
+ end
214
+ end
@@ -0,0 +1,124 @@
1
+ # lib/rails_preflight/docker_analyzer.rb
2
+ module RailsPreflight
3
+ class DockerAnalyzer
4
+ # Update when a release goes EOL: Ruby 3.2 (2026-03-31), Node 20 (2026-04-30).
5
+ RUBY_EOL_BELOW = "3.3"
6
+ NODE_EOL_BELOW = 22
7
+
8
+ # Official image, with or without registry prefix: `ruby:3.3`, `docker.io/library/ruby:$RUBY_VERSION-slim`.
9
+ RUBY_IMAGE = %r{^\s*FROM\s+(?:--platform=\S+\s+)?(?:\S+/)?ruby:(\S+)}i
10
+
11
+ # checked_ruby: app Ruby the Ruby Version section already checked for EOL
12
+ def initialize(root_path = Dir.pwd, checked_ruby: nil)
13
+ @dockerfile_path = File.join(root_path, "Dockerfile")
14
+ @checked_ruby = checked_ruby
15
+ end
16
+
17
+ # Version from the ruby image tag, resolving ARG defaults as in Rails' generated Dockerfile.
18
+ def detect_ruby_version
19
+ return nil unless File.exist?(@dockerfile_path)
20
+ content = File.read(@dockerfile_path)
21
+ match = content.match(RUBY_IMAGE)
22
+ return nil unless match
23
+
24
+ args = content.scan(/^\s*ARG\s+(\w+)=["']?([^\s"']+)/).to_h
25
+ match[1].gsub(/\$\{?(\w+)\}?/) { args[$1].to_s }[/\A\d+\.\d+(?:\.\d+)?/]
26
+ end
27
+
28
+ def run
29
+ result = { title: "Docker Configuration", status: :passed, checks: [], confidence: :medium }
30
+
31
+ unless File.exist?(@dockerfile_path)
32
+ result[:checks] << { message: "No Dockerfile found. Docker checks skipped.", status: :passed }
33
+ return result
34
+ end
35
+
36
+ content = File.read(@dockerfile_path)
37
+ ruby_version = detect_ruby_version
38
+ # Official ruby images set LANG=C.UTF-8, and their Debian variants ship tzdata.
39
+ official_ruby = content.match?(RUBY_IMAGE)
40
+ alpine = content.match?(/alpine/)
41
+
42
+ check_ruby_version(ruby_version, result)
43
+ check_package_manager_pitfalls(content, result)
44
+ check_alpine_pitfalls(content, result) if alpine
45
+ check_node_version(content, result)
46
+ check_eol_base_image(ruby_version, result)
47
+ check_locale(content, result) unless official_ruby
48
+ check_tzdata(content, result) if alpine || !official_ruby
49
+ check_openssl_mismatch(content, ruby_version, result)
50
+
51
+ update_overall_status(result)
52
+ result
53
+ end
54
+
55
+ private
56
+
57
+ def check_ruby_version(ruby_version, result)
58
+ if ruby_version
59
+ result[:checks] << { message: "Dockerfile uses Ruby #{ruby_version} base image", status: :passed, fix_effort: :low }
60
+ else
61
+ result[:checks] << { message: "Could not detect a versioned ruby base image in Dockerfile.", status: :warning, kind: :unknown, fix_effort: :medium }
62
+ end
63
+ end
64
+
65
+ def check_package_manager_pitfalls(content, result)
66
+ if content.include?("bundle install") && !content.match?(/without/i)
67
+ result[:checks] << { message: "'bundle install' also installs development and test gems. Set BUNDLE_WITHOUT=\"development:test\".", status: :passed, kind: :tip, fix_effort: :low }
68
+ end
69
+ end
70
+
71
+ def check_alpine_pitfalls(content, result)
72
+ unless content.match?(/build-base|libxml2-dev|postgresql-dev/)
73
+ result[:checks] << { message: "Alpine image without build-base: gems with native extensions may fail to install. Add 'apk add build-base'.", status: :warning, fix_effort: :medium }
74
+ end
75
+ end
76
+
77
+ def check_node_version(content, result)
78
+ # Support ENV NODE_VERSION 12.0.0 (space) and ENV NODE_VERSION=12.0.0 (equals)
79
+ if match = content.match(/NODE_VERSION\s*[:=\s]\s*['"]?(\d+)/)
80
+ major_version = match[1].to_i
81
+ if major_version < NODE_EOL_BELOW
82
+ result[:checks] << { message: "Node #{major_version} is end-of-life. Upgrade to Node #{NODE_EOL_BELOW}+.", status: :warning, fix_effort: :medium }
83
+ end
84
+ end
85
+ end
86
+
87
+ def check_eol_base_image(ruby_version, result)
88
+ # EOL is per minor version; same minor as the checked app Ruby was already reported
89
+ return if ruby_version && @checked_ruby && ruby_version.split(".").first(2) == @checked_ruby.split(".").first(2)
90
+
91
+ if ruby_version && Gem::Version.new(ruby_version) < Gem::Version.new(RUBY_EOL_BELOW)
92
+ result[:checks] << { message: "Dockerfile Ruby #{ruby_version} is end-of-life. Upgrade to Ruby #{RUBY_EOL_BELOW}+.", status: :warning, fix_effort: :high }
93
+ end
94
+ end
95
+
96
+ def check_locale(content, result)
97
+ unless content.match?(/^\s*ENV\s+(LANG|LC_ALL)/)
98
+ result[:checks] << { message: "No ENV LANG or LC_ALL set, which can cause encoding errors.", status: :warning, fix_effort: :low }
99
+ end
100
+ end
101
+
102
+ def check_tzdata(content, result)
103
+ unless content.match?(/tzdata/)
104
+ result[:checks] << { message: "tzdata isn't installed. Time zone lookups need it, or the tzinfo-data gem.", status: :warning, fix_effort: :low }
105
+ end
106
+ end
107
+
108
+ def check_openssl_mismatch(content, ruby_version, result)
109
+ # Alpine >= 3.17 ships OpenSSL 3, which Ruby < 3.1's openssl gem doesn't support.
110
+ alpine_version = content[/alpine(\d+\.\d+)/, 1]
111
+ return unless ruby_version && alpine_version
112
+
113
+ if Gem::Version.new(alpine_version) >= Gem::Version.new("3.17") && Gem::Version.new(ruby_version) < Gem::Version.new("3.1")
114
+ result[:checks] << { message: "Alpine #{alpine_version} ships OpenSSL 3, which Ruby #{ruby_version} (< 3.1) does not support.", status: :warning, fix_effort: :high }
115
+ end
116
+ end
117
+
118
+ def update_overall_status(result)
119
+ if result[:checks].any? { |c| c[:status] != :passed }
120
+ result[:status] = :warning
121
+ end
122
+ end
123
+ end
124
+ end