gem_radar 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/.gitignore +4 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE +21 -0
- data/README.md +78 -0
- data/assets/banner.png +0 -0
- data/exe/gem_radar +6 -0
- data/gem_radar.gemspec +26 -0
- data/lib/gem_radar/cli.rb +423 -0
- data/lib/gem_radar/version.rb +6 -0
- data/lib/gem_radar.rb +7 -0
- metadata +57 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 6a7c235545aa90c12bd62cc2adcc11f3ba3ed212863e4dc3b94240c1311658eb
|
|
4
|
+
data.tar.gz: e9c2b3cff58d9a03af3af57f8e1db3c3ad8d6d1a5a4da9b09bd1114f05ca065d
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: f893e96da321bec629f7c8c05f7533112b612c1f5bdfab3900494a130c1024c69ef3ccfdb1d0a8c8a9df3a2bfd263d97ad30dea65272f2aa77505dafa421bcab
|
|
7
|
+
data.tar.gz: 412a70978527836e6fc4e2e20beedd010a7485c21d275e7e6ee268ca637b3535fda19c4d0bc5a4db49f3508ad376591b9e09067d2a532560f2ffcd833e2b842e
|
data/.gitignore
ADDED
data/CHANGELOG.md
ADDED
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Laura Jaime
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# gem_radar
|
|
2
|
+
|
|
3
|
+

|
|
4
|
+
|
|
5
|
+
A gem that audits your Ruby on Rails project's gems and reports their
|
|
6
|
+
compatibility and update status.
|
|
7
|
+
|
|
8
|
+
For each gem, the report links only to its repository — it does not
|
|
9
|
+
download or embed changelog content.
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
Add it to your project's `Gemfile`:
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
gem "gem_radar", group: :development
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
then `bundle install`.
|
|
20
|
+
|
|
21
|
+
## Usage
|
|
22
|
+
|
|
23
|
+
Run it from the root of the project (where `Gemfile.lock` lives):
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
bundle exec gem_radar
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Options:
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
--ruby VERSION Project's Ruby version (default: .ruby-version or Gemfile.lock)
|
|
33
|
+
--rails VERSION Project's Rails version (default: the one in Gemfile.lock)
|
|
34
|
+
--lockfile PATH Path to Gemfile.lock (default: ./Gemfile.lock)
|
|
35
|
+
-o, --output PATH Output file (default: gem_radar.md)
|
|
36
|
+
-h, --help Shows this help
|
|
37
|
+
--version Shows the version
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Export `GITHUB_TOKEN` (or have `gh` authenticated) to avoid GitHub's
|
|
41
|
+
60 requests/hour limit for unauthenticated API access — used to check
|
|
42
|
+
whether a gem's repository has been archived.
|
|
43
|
+
|
|
44
|
+
## Report categories
|
|
45
|
+
|
|
46
|
+
- ❗ **Mandatory update** — the installed version no longer satisfies the
|
|
47
|
+
project's current Ruby version, or the Rails-component version it depends
|
|
48
|
+
on (`rails`, `railties`, `activerecord`, …). Not a suggestion: the gem is
|
|
49
|
+
already out of bounds for your current Ruby/Rails.
|
|
50
|
+
- ✅ **Compatible with Ruby and Rails** — up to date, nothing to do.
|
|
51
|
+
- 🗑️ **Deprecated / archived** — the GitHub repository is archived, or the
|
|
52
|
+
gem's own description mentions it's deprecated.
|
|
53
|
+
- ⬆️ **Safely updatable** — a newer version exists within the same major
|
|
54
|
+
version line (for `0.x` gems, the minor is treated as the breaking-change
|
|
55
|
+
boundary, following common SemVer convention).
|
|
56
|
+
- ⚠️ **Updatable with changes** — updating means a major version bump, or no
|
|
57
|
+
version compatible with the current Ruby/Rails could be found (so the
|
|
58
|
+
update would also require upgrading the framework).
|
|
59
|
+
- ❔ **No data** — not found on rubygems.org (private gem, or installed
|
|
60
|
+
from git/path).
|
|
61
|
+
|
|
62
|
+
## How it works
|
|
63
|
+
|
|
64
|
+
- Reads direct dependencies, installed versions and the Ruby version from
|
|
65
|
+
`Gemfile.lock` (or `.ruby-version` / `--ruby`).
|
|
66
|
+
- Queries the rubygems.org API for each gem's metadata, published versions,
|
|
67
|
+
and per-version runtime dependencies.
|
|
68
|
+
- Rails compatibility is inferred from a gem's dependency on any Rails
|
|
69
|
+
component; gems unrelated to Rails are judged on Ruby compatibility only.
|
|
70
|
+
- Queries the GitHub API for the repository's `archived` status.
|
|
71
|
+
|
|
72
|
+
## Requirements
|
|
73
|
+
|
|
74
|
+
Ruby standard library only — no runtime dependencies.
|
|
75
|
+
|
|
76
|
+
## License
|
|
77
|
+
|
|
78
|
+
MIT
|
data/assets/banner.png
ADDED
|
Binary file
|
data/exe/gem_radar
ADDED
data/gem_radar.gemspec
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "lib/gem_radar/version"
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = "gem_radar"
|
|
7
|
+
spec.version = GemRadar::VERSION
|
|
8
|
+
spec.authors = ["Laura Jaime"]
|
|
9
|
+
|
|
10
|
+
spec.summary = "Audits your Ruby/Rails project's gems and reports their compatibility and update status."
|
|
11
|
+
spec.description = "Reads a project's Gemfile.lock and reports, for each direct gem, whether it's " \
|
|
12
|
+
"compatible with the project's Ruby/Rails, needs a mandatory update, is " \
|
|
13
|
+
"deprecated or archived, or can be upgraded safely vs. with breaking changes."
|
|
14
|
+
spec.homepage = "https://github.com/laurajaime/gem_radar"
|
|
15
|
+
spec.license = "MIT"
|
|
16
|
+
spec.required_ruby_version = ">= 3.0.0"
|
|
17
|
+
|
|
18
|
+
spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
|
|
19
|
+
|
|
20
|
+
spec.files = Dir.chdir(__dir__) do
|
|
21
|
+
`git ls-files -z`.split("\x0").reject { |f| f.match(%r{\A(?:test|spec|features)/}) }
|
|
22
|
+
end
|
|
23
|
+
spec.bindir = "exe"
|
|
24
|
+
spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
|
|
25
|
+
spec.require_paths = ["lib"]
|
|
26
|
+
end
|
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
#
|
|
3
|
+
# gem_radar — generates a Markdown report on the health of a Ruby/Rails
|
|
4
|
+
# project's direct gems, grouped into categories:
|
|
5
|
+
# - mandatory update (the installed version is no longer compatible with
|
|
6
|
+
# the project's current Ruby/Rails)
|
|
7
|
+
# - compatible with the project's Ruby and Rails version
|
|
8
|
+
# - deprecated / archived
|
|
9
|
+
# - safely updatable (same major version line)
|
|
10
|
+
# - updatable with changes (major version bump, or requires a newer
|
|
11
|
+
# Ruby/Rails)
|
|
12
|
+
# - no data (not found on rubygems.org: private gems, git/path sources…)
|
|
13
|
+
#
|
|
14
|
+
# Each gem links only to its repository (changelog content is not
|
|
15
|
+
# downloaded).
|
|
16
|
+
#
|
|
17
|
+
# Data sources:
|
|
18
|
+
# - rubygems.org API: metadata, versions and per-version dependencies
|
|
19
|
+
# - GitHub API: repository "archived" status
|
|
20
|
+
#
|
|
21
|
+
# Export GITHUB_TOKEN (or have `gh` authenticated) to avoid GitHub's
|
|
22
|
+
# 60 requests/hour limit for unauthenticated API access.
|
|
23
|
+
|
|
24
|
+
require "net/http"
|
|
25
|
+
require "json"
|
|
26
|
+
require "uri"
|
|
27
|
+
require "optparse"
|
|
28
|
+
|
|
29
|
+
module GemRadar
|
|
30
|
+
module CLI
|
|
31
|
+
module_function
|
|
32
|
+
|
|
33
|
+
USER_AGENT = "gem_radar/#{VERSION} (https://github.com/laurajaime/gem_radar)"
|
|
34
|
+
|
|
35
|
+
RAILS_FAMILY = %w[
|
|
36
|
+
rails railties actionpack actionview activerecord activesupport
|
|
37
|
+
activejob activemodel actioncable activestorage actionmailer
|
|
38
|
+
actionmailbox actiontext
|
|
39
|
+
].freeze
|
|
40
|
+
|
|
41
|
+
MAX_RAILS_CHECKS = 12
|
|
42
|
+
|
|
43
|
+
def run(argv)
|
|
44
|
+
options = parse_options(argv)
|
|
45
|
+
lock = parse_lockfile(options[:lockfile])
|
|
46
|
+
ruby_version = detect_ruby_version(options, lock)
|
|
47
|
+
rails_version = detect_rails_version(options, lock)
|
|
48
|
+
project = File.basename(File.expand_path(File.dirname(options[:lockfile]) == "." ? Dir.pwd : File.dirname(options[:lockfile])))
|
|
49
|
+
|
|
50
|
+
warn "Project: #{project} · Ruby: #{ruby_version || 'not detected (use --ruby)'} · " \
|
|
51
|
+
"Rails: #{rails_version || 'not detected (use --rails)'} · #{lock[:dependencies].size} direct gems"
|
|
52
|
+
warn "Warning: no GITHUB_TOKEN and no authenticated `gh`; GitHub's API is limited to 60 requests/hour." unless github_token
|
|
53
|
+
|
|
54
|
+
results = lock[:dependencies].each_with_index.map do |name, i|
|
|
55
|
+
warn format(" [%d/%d] %s…", i + 1, lock[:dependencies].size, name)
|
|
56
|
+
process_gem(name, lock[:specs][name], ruby_version, rails_version)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
File.write(options[:output], build_report(project, ruby_version, rails_version, results, options))
|
|
60
|
+
by_category = results.group_by { |r| classify(r) }
|
|
61
|
+
warn "Done: #{options[:output]} (#{results.size} gems · " \
|
|
62
|
+
"#{by_category[:mandatory]&.size || 0} mandatory update · " \
|
|
63
|
+
"#{by_category[:updatable_safe]&.size || 0} safely updatable · " \
|
|
64
|
+
"#{by_category[:updatable_breaking]&.size || 0} updatable with changes · " \
|
|
65
|
+
"#{by_category[:deprecated]&.size || 0} deprecated)"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def parse_options(argv)
|
|
69
|
+
options = { output: "gem_radar.md", lockfile: "Gemfile.lock" }
|
|
70
|
+
OptionParser.new do |o|
|
|
71
|
+
o.banner = "Usage: gem_radar [options] (run from inside the project directory)"
|
|
72
|
+
o.on("--ruby VERSION", "Project's Ruby version (default: .ruby-version or Gemfile.lock)") { |v| options[:ruby] = v }
|
|
73
|
+
o.on("--rails VERSION", "Project's Rails version (default: the one in Gemfile.lock)") { |v| options[:rails] = v }
|
|
74
|
+
o.on("--lockfile PATH", "Path to Gemfile.lock (default: ./Gemfile.lock)") { |v| options[:lockfile] = v }
|
|
75
|
+
o.on("-o", "--output PATH", "Output file (default: gem_radar.md)") { |v| options[:output] = v }
|
|
76
|
+
o.on("-h", "--help", "Shows this help") { puts o; exit }
|
|
77
|
+
o.on("--version", "Shows the version") { puts "gem_radar #{VERSION} (\"#{CODENAME}\")"; exit }
|
|
78
|
+
end.parse!(argv)
|
|
79
|
+
options
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# --- HTTP -----------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
def http_get(url, headers = {}, limit = 5)
|
|
85
|
+
return nil if limit.zero?
|
|
86
|
+
uri = URI(url)
|
|
87
|
+
req = Net::HTTP::Get.new(uri)
|
|
88
|
+
req["User-Agent"] = USER_AGENT
|
|
89
|
+
headers.each { |k, v| req[k] = v }
|
|
90
|
+
res = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
|
|
91
|
+
open_timeout: 10, read_timeout: 30) { |h| h.request(req) }
|
|
92
|
+
if res.is_a?(Net::HTTPRedirection) && res["location"]
|
|
93
|
+
http_get(URI.join(url, res["location"]).to_s, headers, limit - 1)
|
|
94
|
+
else
|
|
95
|
+
res
|
|
96
|
+
end
|
|
97
|
+
rescue StandardError => e
|
|
98
|
+
warn " warning: HTTP request failed for #{url}: #{e.class}: #{e.message}"
|
|
99
|
+
nil
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def github_token
|
|
103
|
+
return @github_token if defined?(@github_token)
|
|
104
|
+
token = ENV["GITHUB_TOKEN"] || ENV["GH_TOKEN"]
|
|
105
|
+
token ||= begin
|
|
106
|
+
out = `gh auth token 2>/dev/null`.strip
|
|
107
|
+
out.empty? ? nil : out
|
|
108
|
+
rescue StandardError
|
|
109
|
+
nil
|
|
110
|
+
end
|
|
111
|
+
@github_token = token
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def github_api(path)
|
|
115
|
+
headers = { "Accept" => "application/vnd.github+json" }
|
|
116
|
+
headers["Authorization"] = "Bearer #{github_token}" if github_token
|
|
117
|
+
res = http_get("https://api.github.com#{path}", headers)
|
|
118
|
+
return nil unless res.is_a?(Net::HTTPSuccess)
|
|
119
|
+
JSON.parse(res.body)
|
|
120
|
+
rescue JSON::ParserError
|
|
121
|
+
nil
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# --- Gemfile.lock -----------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
# Returns { dependencies: [names], specs: {name => version}, ruby: "x.y.z" or nil }
|
|
127
|
+
def parse_lockfile(path)
|
|
128
|
+
abort "Can't find #{path}. Run this from the project root (or use --lockfile)." unless File.file?(path)
|
|
129
|
+
deps = []
|
|
130
|
+
specs = {}
|
|
131
|
+
ruby = nil
|
|
132
|
+
section = nil
|
|
133
|
+
File.read(path).each_line do |line|
|
|
134
|
+
if line =~ /\A[A-Z][A-Z ]*\s*\z/
|
|
135
|
+
section = line.strip
|
|
136
|
+
next
|
|
137
|
+
end
|
|
138
|
+
case section
|
|
139
|
+
when "GEM", "GIT", "PATH"
|
|
140
|
+
specs[$1] = $2 if line =~ /\A (\S+) \(([^)\s]+)\)/
|
|
141
|
+
when "DEPENDENCIES"
|
|
142
|
+
deps << $1 if line =~ /\A ([^\s!(]+)/
|
|
143
|
+
when "RUBY VERSION"
|
|
144
|
+
ruby = $1 if line =~ /ruby (\d+\.\d+\.\d+)/
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
{ dependencies: deps.uniq.sort, specs: specs, ruby: ruby }
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def detect_ruby_version(options, lockfile_data)
|
|
151
|
+
return options[:ruby] if options[:ruby]
|
|
152
|
+
if File.file?(".ruby-version")
|
|
153
|
+
v = File.read(".ruby-version").strip.sub(/\Aruby-/, "")
|
|
154
|
+
return v unless v.empty?
|
|
155
|
+
end
|
|
156
|
+
lockfile_data[:ruby]
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def detect_rails_version(options, lockfile_data)
|
|
160
|
+
options[:rails] || lockfile_data[:specs]["rails"]
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# --- rubygems.org -----------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
def rubygems_info(name)
|
|
166
|
+
res = http_get("https://rubygems.org/api/v1/gems/#{name}.json")
|
|
167
|
+
return nil unless res.is_a?(Net::HTTPSuccess)
|
|
168
|
+
JSON.parse(res.body)
|
|
169
|
+
rescue JSON::ParserError
|
|
170
|
+
nil
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def rubygems_versions(name)
|
|
174
|
+
res = http_get("https://rubygems.org/api/v1/versions/#{name}.json")
|
|
175
|
+
return [] unless res.is_a?(Net::HTTPSuccess)
|
|
176
|
+
JSON.parse(res.body)
|
|
177
|
+
rescue JSON::ParserError
|
|
178
|
+
[]
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def version_runtime_dependencies(name, version)
|
|
182
|
+
@version_deps_cache ||= {}
|
|
183
|
+
key = "#{name}@#{version}"
|
|
184
|
+
return @version_deps_cache[key] if @version_deps_cache.key?(key)
|
|
185
|
+
res = http_get("https://rubygems.org/api/v2/rubygems/#{name}/versions/#{version}.json")
|
|
186
|
+
@version_deps_cache[key] =
|
|
187
|
+
begin
|
|
188
|
+
res.is_a?(Net::HTTPSuccess) ? (JSON.parse(res.body).dig("dependencies", "runtime") || []) : nil
|
|
189
|
+
rescue JSON::ParserError
|
|
190
|
+
nil
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def gem_version(str)
|
|
195
|
+
Gem::Version.new(str.to_s.sub(/\Av/i, "").gsub("-", "."))
|
|
196
|
+
rescue ArgumentError
|
|
197
|
+
nil
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def requirement_satisfied?(requirement_str, version)
|
|
201
|
+
return true if requirement_str.nil? || requirement_str.to_s.empty?
|
|
202
|
+
Gem::Requirement.new(*requirement_str.split(",").map(&:strip)).satisfied_by?(version)
|
|
203
|
+
rescue StandardError
|
|
204
|
+
true
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def depends_on_rails_family?(runtime_deps)
|
|
208
|
+
Array(runtime_deps).any? { |d| RAILS_FAMILY.include?(d["name"]) }
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def rails_requirement_satisfied?(runtime_deps, rails_version)
|
|
212
|
+
return true unless rails_version
|
|
213
|
+
rails_deps = Array(runtime_deps).select { |d| RAILS_FAMILY.include?(d["name"]) }
|
|
214
|
+
return true if rails_deps.empty?
|
|
215
|
+
rails_deps.all? { |d| requirement_satisfied?(d["requirements"], rails_version) }
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Latest stable version compatible with the project's Ruby and, if the gem
|
|
219
|
+
# depends on any Rails component, also with the project's Rails version.
|
|
220
|
+
# Returns nil if none of the latest MAX_RAILS_CHECKS ruby-compatible
|
|
221
|
+
# versions satisfies the Rails requirement.
|
|
222
|
+
def latest_compatible(name, versions, ruby_version, rails_version, latest_runtime_deps)
|
|
223
|
+
ruby = ruby_version && gem_version(ruby_version)
|
|
224
|
+
rails = rails_version && gem_version(rails_version)
|
|
225
|
+
|
|
226
|
+
candidates = versions.reject { |v| v["prerelease"] }
|
|
227
|
+
.select { |v| v["platform"].nil? || v["platform"] == "ruby" }
|
|
228
|
+
candidates = candidates.select do |v|
|
|
229
|
+
ruby.nil? || requirement_satisfied?(v["ruby_version"], ruby)
|
|
230
|
+
end
|
|
231
|
+
candidates = candidates.map { |v| [gem_version(v["number"]), v["number"]] }
|
|
232
|
+
.select { |gv, _| gv }
|
|
233
|
+
.sort_by { |gv, _| gv }
|
|
234
|
+
.reverse
|
|
235
|
+
|
|
236
|
+
return candidates.first&.first unless rails && depends_on_rails_family?(latest_runtime_deps)
|
|
237
|
+
|
|
238
|
+
candidates.first(MAX_RAILS_CHECKS).each do |gv, number|
|
|
239
|
+
deps = version_runtime_dependencies(name, number)
|
|
240
|
+
next if deps.nil?
|
|
241
|
+
return gv if rails_requirement_satisfied?(deps, rails)
|
|
242
|
+
end
|
|
243
|
+
nil
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Reasons why the ALREADY INSTALLED version fails to meet the project's
|
|
247
|
+
# current Ruby/Rails: if any are found, this gem must be updated — it's
|
|
248
|
+
# not just a recommendation (unlike the "updatable" categories).
|
|
249
|
+
def mandatory_update_reasons(name, installed, versions, ruby_version, rails_version, latest_runtime_deps)
|
|
250
|
+
return [] unless installed
|
|
251
|
+
entry = versions.find { |v| gem_version(v["number"]) == installed }
|
|
252
|
+
return [] unless entry # installed version not indexed on rubygems.org (yanked, etc.)
|
|
253
|
+
|
|
254
|
+
reasons = []
|
|
255
|
+
|
|
256
|
+
if ruby_version
|
|
257
|
+
ruby = gem_version(ruby_version)
|
|
258
|
+
req = entry["ruby_version"]
|
|
259
|
+
unless req.nil? || req.to_s.empty? || requirement_satisfied?(req, ruby)
|
|
260
|
+
reasons << "requires Ruby #{req}, project uses #{ruby_version}"
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
if rails_version && depends_on_rails_family?(latest_runtime_deps)
|
|
265
|
+
deps = version_runtime_dependencies(name, entry["number"])
|
|
266
|
+
if deps
|
|
267
|
+
rails = gem_version(rails_version)
|
|
268
|
+
Array(deps).select { |d| RAILS_FAMILY.include?(d["name"]) }.each do |d|
|
|
269
|
+
unless requirement_satisfied?(d["requirements"], rails)
|
|
270
|
+
reasons << "#{d['name']} requires #{d['requirements']}, project uses Rails #{rails_version}"
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
reasons
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# --- Repository / deprecation status -----------------------------------------
|
|
280
|
+
|
|
281
|
+
def github_repo(url)
|
|
282
|
+
return nil unless url && !url.empty?
|
|
283
|
+
m = url.match(%r{github\.com/([^/]+)/([^/#?]+)})
|
|
284
|
+
m && "#{m[1]}/#{m[2].sub(/\.git\z/, '')}"
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def repo_url_for(info)
|
|
288
|
+
repo = github_repo(info["source_code_uri"]) ||
|
|
289
|
+
github_repo(info.dig("metadata", "source_code_uri")) ||
|
|
290
|
+
github_repo(info["homepage_uri"]) ||
|
|
291
|
+
github_repo(info["changelog_uri"])
|
|
292
|
+
return ["https://github.com/#{repo}", repo] if repo
|
|
293
|
+
|
|
294
|
+
[info["source_code_uri"] || info["homepage_uri"], nil]
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def deprecated?(info, repo_info)
|
|
298
|
+
return true if repo_info && repo_info["archived"]
|
|
299
|
+
text = "#{info['info']} #{info['description']}"
|
|
300
|
+
!!(text =~ /\bdeprecat/i)
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# --- Classification -----------------------------------------------------------
|
|
304
|
+
|
|
305
|
+
CATEGORY_ORDER = %i[mandatory compatible deprecated updatable_safe updatable_breaking not_found].freeze
|
|
306
|
+
|
|
307
|
+
CATEGORY_TITLES = {
|
|
308
|
+
mandatory: "❗ Mandatory update (installed version is no longer compatible with your Ruby/Rails)",
|
|
309
|
+
compatible: "✅ Compatible with Ruby and Rails",
|
|
310
|
+
deprecated: "🗑️ Deprecated / archived",
|
|
311
|
+
updatable_safe: "⬆️ Safely updatable (same major version line)",
|
|
312
|
+
updatable_breaking: "⚠️ Updatable with changes (major version bump, or requires a newer Ruby/Rails)",
|
|
313
|
+
not_found: "❔ No data (not found on rubygems.org)"
|
|
314
|
+
}.freeze
|
|
315
|
+
|
|
316
|
+
# In SemVer, when major is 0 it's the minor that marks breaking changes
|
|
317
|
+
# (0.MAJOR.MINOR), as is common across the Rails gem ecosystem.
|
|
318
|
+
def same_compat_line?(installed, candidate)
|
|
319
|
+
if installed.segments.first.zero? && candidate.segments.first.zero?
|
|
320
|
+
installed.segments[1] == candidate.segments[1]
|
|
321
|
+
else
|
|
322
|
+
installed.segments.first == candidate.segments.first
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def classify(r)
|
|
327
|
+
return :not_found unless r[:found]
|
|
328
|
+
return :mandatory if r[:mandatory_reasons] && !r[:mandatory_reasons].empty?
|
|
329
|
+
return :deprecated if r[:deprecated]
|
|
330
|
+
return :compatible unless r[:installed] && r[:latest]
|
|
331
|
+
|
|
332
|
+
if r[:compatible] && r[:compatible] > r[:installed]
|
|
333
|
+
same_compat_line?(r[:installed], r[:compatible]) ? :updatable_safe : :updatable_breaking
|
|
334
|
+
elsif r[:latest] > r[:installed] && (r[:compatible].nil? || r[:compatible] <= r[:installed])
|
|
335
|
+
# A newer version exists, but none was found compatible with the
|
|
336
|
+
# current Ruby/Rails: updating means upgrading the framework too.
|
|
337
|
+
:updatable_breaking
|
|
338
|
+
else
|
|
339
|
+
:compatible
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
# --- Report -------------------------------------------------------------------
|
|
344
|
+
|
|
345
|
+
def format_gem_line(r)
|
|
346
|
+
parts = ["**#{r[:name]}**"]
|
|
347
|
+
parts << "installed `#{r[:installed]}`" if r[:installed]
|
|
348
|
+
if r[:compatible] && r[:installed] && r[:compatible] > r[:installed]
|
|
349
|
+
parts << "recommended `#{r[:compatible]}`"
|
|
350
|
+
end
|
|
351
|
+
if r[:latest] && (r[:compatible].nil? || r[:latest] > r[:compatible])
|
|
352
|
+
parts << "latest published `#{r[:latest]}`"
|
|
353
|
+
end
|
|
354
|
+
line = "- #{parts.join(' · ')}"
|
|
355
|
+
line += " — [repository](#{r[:repo_url]})" if r[:repo_url]
|
|
356
|
+
line += "\n\n > #{r[:error]}" if r[:error]
|
|
357
|
+
Array(r[:mandatory_reasons]).each { |reason| line += "\n\n > #{reason}" }
|
|
358
|
+
line
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
def build_report(project, ruby_version, rails_version, results, options)
|
|
362
|
+
grouped = results.group_by { |r| classify(r) }
|
|
363
|
+
|
|
364
|
+
lines = []
|
|
365
|
+
lines << "# Gems in #{project}"
|
|
366
|
+
lines << ""
|
|
367
|
+
lines << "Generated: #{Time.now.strftime('%Y-%m-%d %H:%M')} · Ruby: #{ruby_version || 'unknown'} · " \
|
|
368
|
+
"Rails: #{rails_version || 'unknown'} · #{results.size} direct gems"
|
|
369
|
+
lines << ""
|
|
370
|
+
lines << "## Summary"
|
|
371
|
+
lines << ""
|
|
372
|
+
lines << "| Category | Gems |"
|
|
373
|
+
lines << "|---|---|"
|
|
374
|
+
CATEGORY_ORDER.each do |cat|
|
|
375
|
+
count = grouped[cat]&.size || 0
|
|
376
|
+
lines << "| #{CATEGORY_TITLES[cat]} | #{count} |"
|
|
377
|
+
end
|
|
378
|
+
lines << ""
|
|
379
|
+
lines << "---"
|
|
380
|
+
|
|
381
|
+
CATEGORY_ORDER.each do |cat|
|
|
382
|
+
items = grouped[cat]
|
|
383
|
+
next if items.nil? || items.empty?
|
|
384
|
+
lines << ""
|
|
385
|
+
lines << "## #{CATEGORY_TITLES[cat]}"
|
|
386
|
+
lines << ""
|
|
387
|
+
items.each { |r| lines << format_gem_line(r) }
|
|
388
|
+
end
|
|
389
|
+
lines.join("\n") + "\n"
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
# --- Per-gem processing ------------------------------------------------------
|
|
393
|
+
|
|
394
|
+
def process_gem(name, installed_str, ruby_version, rails_version)
|
|
395
|
+
installed = gem_version(installed_str)
|
|
396
|
+
info = rubygems_info(name)
|
|
397
|
+
unless info
|
|
398
|
+
return { name: name, installed: installed, found: false,
|
|
399
|
+
error: "Couldn't find `#{name}` on rubygems.org (private gem, or installed from git/path?)." }
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
latest = gem_version(info["version"])
|
|
403
|
+
versions = rubygems_versions(name)
|
|
404
|
+
latest_runtime_deps = info.dig("dependencies", "runtime") || []
|
|
405
|
+
compatible = latest_compatible(name, versions, ruby_version, rails_version, latest_runtime_deps)
|
|
406
|
+
mandatory_reasons = mandatory_update_reasons(name, installed, versions, ruby_version, rails_version, latest_runtime_deps)
|
|
407
|
+
|
|
408
|
+
url, repo = repo_url_for(info)
|
|
409
|
+
repo_info = repo && github_api("/repos/#{repo}")
|
|
410
|
+
|
|
411
|
+
{
|
|
412
|
+
name: name,
|
|
413
|
+
found: true,
|
|
414
|
+
installed: installed,
|
|
415
|
+
latest: latest,
|
|
416
|
+
compatible: compatible,
|
|
417
|
+
repo_url: url,
|
|
418
|
+
deprecated: deprecated?(info, repo_info),
|
|
419
|
+
mandatory_reasons: mandatory_reasons
|
|
420
|
+
}
|
|
421
|
+
end
|
|
422
|
+
end
|
|
423
|
+
end
|
data/lib/gem_radar.rb
ADDED
metadata
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: gem_radar
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Laura Jaime
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: exe
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-08-26 00:00:00.000000000 Z
|
|
12
|
+
dependencies: []
|
|
13
|
+
description: Reads a project's Gemfile.lock and reports, for each direct gem, whether
|
|
14
|
+
it's compatible with the project's Ruby/Rails, needs a mandatory update, is deprecated
|
|
15
|
+
or archived, or can be upgraded safely vs. with breaking changes.
|
|
16
|
+
email:
|
|
17
|
+
executables:
|
|
18
|
+
- gem_radar
|
|
19
|
+
extensions: []
|
|
20
|
+
extra_rdoc_files: []
|
|
21
|
+
files:
|
|
22
|
+
- ".gitignore"
|
|
23
|
+
- CHANGELOG.md
|
|
24
|
+
- LICENSE
|
|
25
|
+
- README.md
|
|
26
|
+
- assets/banner.png
|
|
27
|
+
- exe/gem_radar
|
|
28
|
+
- gem_radar.gemspec
|
|
29
|
+
- lib/gem_radar.rb
|
|
30
|
+
- lib/gem_radar/cli.rb
|
|
31
|
+
- lib/gem_radar/version.rb
|
|
32
|
+
homepage: https://github.com/laurajaime/gem_radar
|
|
33
|
+
licenses:
|
|
34
|
+
- MIT
|
|
35
|
+
metadata:
|
|
36
|
+
changelog_uri: https://github.com/laurajaime/gem_radar/blob/main/CHANGELOG.md
|
|
37
|
+
post_install_message:
|
|
38
|
+
rdoc_options: []
|
|
39
|
+
require_paths:
|
|
40
|
+
- lib
|
|
41
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
42
|
+
requirements:
|
|
43
|
+
- - ">="
|
|
44
|
+
- !ruby/object:Gem::Version
|
|
45
|
+
version: 3.0.0
|
|
46
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
47
|
+
requirements:
|
|
48
|
+
- - ">="
|
|
49
|
+
- !ruby/object:Gem::Version
|
|
50
|
+
version: '0'
|
|
51
|
+
requirements: []
|
|
52
|
+
rubygems_version: 3.3.7
|
|
53
|
+
signing_key:
|
|
54
|
+
specification_version: 4
|
|
55
|
+
summary: Audits your Ruby/Rails project's gems and reports their compatibility and
|
|
56
|
+
update status.
|
|
57
|
+
test_files: []
|