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 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
@@ -0,0 +1,4 @@
1
+ *.md
2
+ !README.md
3
+ !CHANGELOG.md
4
+ *.gem
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 (26-08-2026) - Brazil
4
+
5
+ Initial public release.
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
+ ![gem_radar](assets/banner.png)
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
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../lib/gem_radar"
5
+
6
+ GemRadar::CLI.run(ARGV)
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
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GemRadar
4
+ VERSION = "0.1.0"
5
+ CODENAME = "Brazil"
6
+ end
data/lib/gem_radar.rb ADDED
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "gem_radar/version"
4
+ require_relative "gem_radar/cli"
5
+
6
+ module GemRadar
7
+ end
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: []