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