still_active 1.5.0 → 1.6.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 186f947f74db917e368e84dde7810495ae047720e9483dade42543028059530d
4
- data.tar.gz: 756e3bc581887835db75fcc175ff3654c832c2e81550e1c9447a9f753c37339d
3
+ metadata.gz: 6e5e5e599cdd630ec6e916800c4abd7df4a3c9d37e24122e80d8859accb1c4e3
4
+ data.tar.gz: 0cb0dfd2c761612f665a9d2f4e0ba20f47bf8b082c42281af09b8429335aa2a9
5
5
  SHA512:
6
- metadata.gz: 96ed9789ee0d1901d4e01beed98c094d22f800e32c23a447edb8cc11da9a751a2d6d94ca318bfee3748cce2329a0f79a5fcbc6850be18769296b3ffac07bdd19
7
- data.tar.gz: b70bac1b02082583f41b49ab2521f01c436eba2275fa437adfe29de96b14a08554e0ae05e34a73dff181130f88127ac352831bb7d0698da9231b5574697c7e13
6
+ metadata.gz: 571797c9863bf6597e7c9474e8cf718019925d37d163002e9d594b137458ca0a6be155dfb7a5069777afe83963fb4dc595026a69c8237ce1aa7d024eb254be99
7
+ data.tar.gz: 4e7ba6a7777973b6fe7f1fa54fc1fad00a06639da18944f0bc89befd6a3e794b1d55eeaca62f33b193bebb54cdb2d6d85c63ae48a73adc722e58a3f194c0aeae
data/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.6.0] - 2026-06-08
4
+
5
+ ### Added
6
+
7
+ - `--alternatives` surfaces up to three maintained alternative gems for any dependency still_active flags as archived or critically abandoned, drawn from the [rubytoolbox/catalog](https://github.com/rubytoolbox/catalog) category data and ranked by total RubyGems downloads. Presented as **leads to verify, not vetted recommendations** — same Ruby Toolbox category does not guarantee a drop-in replacement — reflecting that Ruby has no authoritative successor metadata the way npm (`deprecate`), Go (`// Deprecated:`), or NuGet (alternate-package) do. Opt-in and best-effort: the catalog is fetched once and cached under `XDG_CACHE_HOME` with a 7-day TTL, any fetch/parse failure degrades to silence, and nothing here can block a run or affect `--fail-if-*` exit codes. Leads render in terminal (a dimmed sub-line), markdown (an Alternatives section), JSON (an additive `alternatives` array), and SARIF (appended to the SA001/SA002 result messages); CycloneDX is unchanged. With the flag off, terminal output shows a one-line discoverability hint on flagged gems. Silent when the catalog has no entry for the gem (the common case for niche/long-tail gems). Closes #28.
8
+
9
+ ### Changed
10
+
11
+ - The `async` runtime dependency now requires `>= 2.2` (previously unconstrained). 2.2.0 is the verified real minimum — earlier 2.x releases hit a fiber-scheduler `io_read` bug under still_active's concurrent fan-out. A new CI job installs every runtime dependency at its declared gemspec floor and runs the suite on the minimum supported Ruby, so an under-set floor now fails loudly instead of silently.
12
+
3
13
  ## [1.5.0] - 2026-05-23
4
14
 
5
15
  ### Added
data/README.md CHANGED
@@ -53,6 +53,8 @@ The bolded rows are the gap `still_active` fills: nobody else answers "is the ma
53
53
  gem install still_active
54
54
  ```
55
55
 
56
+ **Requires an actively-maintained Ruby.** The gemspec's `required_ruby_version` floor tracks Ruby's [EOL schedule](https://endoflife.date/ruby); running a maintenance auditor on an unmaintained runtime would be a bit rich. You don't have to run it *on* the Ruby you're auditing, though: still_active reports on the version your project pins in `Gemfile.lock`, so run it from any current Ruby (locally, in CI, or via the [`still_active-action`](https://github.com/SeanLF/still_active-action)) and it will still flag an EOL target.
57
+
56
58
  ## Quick Start
57
59
 
58
60
  ```bash
@@ -99,6 +101,7 @@ Usage: still_active [options]
99
101
  --terminal Coloured terminal output (default in TTY)
100
102
  --markdown Markdown table output
101
103
  --json JSON output (default when piped)
104
+ --alternatives Suggest maintained alternatives (Ruby Toolbox leads) for archived/critical gems
102
105
  --sarif[=PATH] SARIF 2.1.0 output for GitHub Code Scanning
103
106
  --cyclonedx[=PATH] CycloneDX SBOM output (stdout, or a file path)
104
107
  --cyclonedx-version=VERSION CycloneDX spec version: 1.6 (default) or 1.7
@@ -339,6 +342,20 @@ Activity is determined by the most recent signal across last commit date, latest
339
342
  - **stale**: last activity between 1 and 3 years ago (configurable with `--warning-range-end`)
340
343
  - **critical**: last activity over 3 years ago
341
344
 
345
+ ### Alternative gem leads (opt-in)
346
+
347
+ When a gem is flagged archived or critical, `--alternatives` surfaces up to three maintained gems from the same [Ruby Toolbox](https://www.ruby-toolbox.com) category, ranked by total downloads:
348
+
349
+ ```bash
350
+ still_active --gems=paperclip --alternatives
351
+ ```
352
+
353
+ ```text
354
+ ↳ leads (Ruby Toolbox): shrine · carrierwave · kt-paperclip (verify fit)
355
+ ```
356
+
357
+ These are **leads, not recommendations**: same-category does not mean drop-in replacement, so verify fit before switching. Ruby has no authoritative "use instead" metadata (unlike npm `deprecate`, Go's `// Deprecated:`, or NuGet's alternate-package field), so this is a best-effort heuristic. It is silent when the catalog has no entry for the gem, and the feature never blocks or fails a run. Leads appear in terminal, markdown, JSON, and SARIF output. When the flag is off, terminal output shows a one-line hint on flagged gems that the option exists (other formats stay silent).
358
+
342
359
  ### Data sources
343
360
 
344
361
  - **Versions, release dates, and licenses** from [RubyGems.org](https://rubygems.org) or [GitHub Packages](https://docs.github.com/en/packages)
@@ -346,6 +363,7 @@ Activity is determined by the most recent signal across last commit date, latest
346
363
  - **OpenSSF Scorecard**, **vulnerability counts**, and **CVSS severity** from Google's [deps.dev](https://deps.dev) API
347
364
  - **Additional advisories** from [ruby-advisory-db](https://github.com/rubysec/ruby-advisory-db), merged in when `bundler-audit` is installed alongside (run `bundle audit update` to keep its checkout current)
348
365
  - **Ruby version freshness** from [endoflife.date](https://endoflife.date)
366
+ - **Alternative gem leads** (with `--alternatives`) from the [rubytoolbox/catalog](https://github.com/rubytoolbox/catalog) category data
349
367
 
350
368
  ### Configuration defaults
351
369
 
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "gems"
4
+
5
+ module StillActive
6
+ # Turns a gem's catalog siblings into ranked "leads" -- the most-downloaded
7
+ # still-published alternatives. Best-effort: a failed lookup drops that
8
+ # candidate, never the feature.
9
+ module AlternativesHelper
10
+ extend self
11
+
12
+ MAX_SIBLINGS_CONSIDERED = 40 # bound the download lookups for huge categories
13
+ DEFAULT_LIMIT = 3
14
+
15
+ def leads_for(gem_name:, index:, limit: DEFAULT_LIMIT)
16
+ return [] if index.nil?
17
+
18
+ # Bound the per-gem download lookups so a huge category can't trigger
19
+ # dozens of HTTP calls. This is a catalog-order prefix, so a very large
20
+ # category could leave a popular sibling past the cap out of the ranking;
21
+ # acceptable for best-effort leads where we only ever surface a few.
22
+ # (CatalogIndex already reduces owner/repo slugs to their gem-name tail,
23
+ # so every entry here is a plain name rankable by downloads.)
24
+ siblings = (index[gem_name] || []).first(MAX_SIBLINGS_CONSIDERED)
25
+ return [] if siblings.empty?
26
+
27
+ siblings
28
+ .filter_map { |name| (count = downloads(name)) && [name, count] }
29
+ .max_by(limit) { |_name, count| count }
30
+ .map(&:first)
31
+ end
32
+
33
+ private
34
+
35
+ def downloads(gem_name)
36
+ info = Gems.info(gem_name)
37
+ info && info["downloads"]
38
+ rescue StandardError
39
+ nil
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+ require "zlib"
5
+ require "rubygems/package"
6
+ require "yaml"
7
+ require "json"
8
+ require "open-uri"
9
+
10
+ module StillActive
11
+ # Optional source of "alternative gem" leads: the rubytoolbox/catalog repo
12
+ # (MIT) mapped to gem -> co-category siblings. Fetched once and cached; every
13
+ # path is best-effort, returning nil/empty so a miss just means no leads.
14
+ module CatalogIndex
15
+ extend self
16
+
17
+ REPO = "rubytoolbox/catalog"
18
+ CACHE_TTL_SECONDS = 7 * 24 * 60 * 60
19
+ MAX_DOWNLOAD_BYTES = 25 * 1024 * 1024 # the catalog is ~50 KB; cap to avoid surprises
20
+
21
+ # Returns { gem => [siblings] } or nil. Never raises.
22
+ def load
23
+ cached = read_cache
24
+ return cached if cached
25
+
26
+ blob = download
27
+ index = build_index(blob)
28
+ write_cache(index)
29
+ index
30
+ rescue StandardError => e
31
+ warn("still_active: could not load Ruby Toolbox catalog for alternatives (#{e.class}); skipping leads")
32
+ nil
33
+ end
34
+
35
+ # Parse a gzipped catalog tarball into { gem_name => [sibling gem names] }.
36
+ def build_index(tar_gz_blob)
37
+ categories = []
38
+
39
+ reader = Gem::Package::TarReader.new(Zlib::GzipReader.new(StringIO.new(tar_gz_blob)))
40
+ reader.each do |entry|
41
+ next unless entry.file?
42
+ next unless entry.full_name.match?(%r{/catalog/.+\.ya?ml$})
43
+ next if File.basename(entry.full_name) == "_meta.yml"
44
+
45
+ data = YAML.safe_load(entry.read)
46
+ next unless data.is_a?(Hash) && data["projects"].is_a?(Array)
47
+
48
+ categories << data["projects"].map { |p| p.to_s.split("/").last }
49
+ end
50
+
51
+ build_siblings(categories)
52
+ end
53
+
54
+ private
55
+
56
+ def cache_path
57
+ base = ENV["XDG_CACHE_HOME"]
58
+ base = File.join(Dir.home, ".cache") if base.nil? || base.empty?
59
+ File.join(base, "still_active", "catalog-siblings.json")
60
+ end
61
+
62
+ def read_cache
63
+ path = cache_path
64
+ return unless File.exist?(path)
65
+ return if Time.now - File.mtime(path) > CACHE_TTL_SECONDS
66
+
67
+ JSON.parse(File.read(path))
68
+ rescue JSON::ParserError
69
+ nil
70
+ end
71
+
72
+ def write_cache(index)
73
+ path = cache_path
74
+ require "fileutils"
75
+ FileUtils.mkdir_p(File.dirname(path))
76
+ File.write(path, JSON.dump(index))
77
+ rescue SystemCallError
78
+ nil # an unwritable cache dir must not break the feature
79
+ end
80
+
81
+ def download
82
+ url = StillActive.config.github_client.archive_link(REPO, format: "tarball", ref: "main")
83
+ URI.open(url) { |io| io.read(MAX_DOWNLOAD_BYTES) } # rubocop:disable Security/Open
84
+ end
85
+
86
+ def build_siblings(categories)
87
+ siblings = Hash.new { |h, k| h[k] = [] }
88
+
89
+ categories.each do |members|
90
+ members.each do |gem_name|
91
+ siblings[gem_name].concat(members - [gem_name])
92
+ end
93
+ end
94
+
95
+ siblings.transform_values(&:uniq)
96
+ end
97
+ end
98
+ end
@@ -84,6 +84,17 @@ module StillActive
84
84
  "| #{cells.join(" | ")} |"
85
85
  end
86
86
 
87
+ def alternatives_section(result)
88
+ flagged = result.select do |_name, data|
89
+ data[:alternatives] && !data[:alternatives].empty?
90
+ end
91
+ return "" if flagged.empty?
92
+
93
+ lines = ["", "**Alternatives** (Ruby Toolbox leads, verify fit):"]
94
+ flagged.each { |name, data| lines << "- `#{name}`: #{data[:alternatives].join(", ")}" }
95
+ lines.join("\n")
96
+ end
97
+
87
98
  private
88
99
 
89
100
  def version_with_date(text:, url:, date:)
@@ -98,7 +98,7 @@ module StillActive
98
98
  location = location_for(name, line_index, lockfile_uri)
99
99
 
100
100
  if data[:archived]
101
- out << result("SA001", name, "#{name} #{version}: upstream repository is archived#{repo_suffix(data)}.", location)
101
+ out << result("SA001", name, "#{name} #{version}: upstream repository is archived#{repo_suffix(data)}#{alternatives_suffix(data)}.", location)
102
102
  end
103
103
 
104
104
  unless data[:archived]
@@ -108,7 +108,7 @@ module StillActive
108
108
  out << result(
109
109
  "SA002",
110
110
  name,
111
- "#{name} #{version}: no commits in #{years} years (last #{last_commit.utc.strftime("%Y-%m-%d")}).",
111
+ "#{name} #{version}: no commits in #{years} years (last #{last_commit.utc.strftime("%Y-%m-%d")})#{alternatives_suffix(data)}.",
112
112
  location,
113
113
  )
114
114
  end
@@ -228,5 +228,12 @@ module StillActive
228
228
  def repo_suffix(data)
229
229
  data[:repository_url] ? " (#{data[:repository_url]})" : ""
230
230
  end
231
+
232
+ def alternatives_suffix(data)
233
+ leads = data[:alternatives]
234
+ return "" if leads.nil? || leads.empty?
235
+
236
+ " Consider: #{leads.join(", ")}"
237
+ end
231
238
  end
232
239
  end
@@ -13,13 +13,18 @@ module StillActive
13
13
  HEADERS = ["Name", "Version", "Activity", "OpenSSF", "Vulns", "License"].freeze
14
14
 
15
15
  def render(result, ruby_info: nil)
16
- rows = result.keys.sort.map { |name| build_row(name, result[name]) }
16
+ names = result.keys.sort
17
+ rows = names.map { |name| build_row(name, result[name]) }
17
18
  widths = column_widths(rows)
18
19
 
19
20
  lines = []
20
21
  lines << header_line(widths)
21
22
  lines << separator_line(widths)
22
- rows.each { |row| lines << row_line(row, widths) }
23
+ names.each_with_index do |name, i|
24
+ lines << row_line(rows[i], widths)
25
+ extra = alternatives_line(result[name])
26
+ lines << extra if extra
27
+ end
23
28
  lines << ""
24
29
  lines << summary_line(result)
25
30
  lines << ruby_summary_line(ruby_info) if ruby_info
@@ -125,6 +130,18 @@ module StillActive
125
130
  .join
126
131
  end
127
132
 
133
+ def alternatives_line(data)
134
+ level = ActivityHelper.activity_level(data)
135
+ return unless [:archived, :critical].include?(level)
136
+
137
+ leads = data[:alternatives]
138
+ if leads && !leads.empty?
139
+ AnsiHelper.dim(" ↳ leads (Ruby Toolbox): #{leads.join(" · ")} (verify fit)")
140
+ elsif !StillActive.config.alternatives
141
+ AnsiHelper.dim(" ↳ run with --alternatives for maintained replacements")
142
+ end
143
+ end
144
+
128
145
  def ruby_summary_line(ruby_info)
129
146
  version = ruby_info[:version]
130
147
  latest = ruby_info[:latest_version]
@@ -190,6 +190,8 @@ module StillActive
190
190
 
191
191
  puts MarkdownHelper.markdown_table_body_line(gem_name: name, data: gem_data)
192
192
  end
193
+ alternatives = MarkdownHelper.alternatives_section(result)
194
+ puts alternatives unless alternatives.empty?
193
195
  if ruby_info
194
196
  puts ""
195
197
  puts MarkdownHelper.ruby_line(ruby_info)
@@ -7,7 +7,8 @@ require "open3"
7
7
  module StillActive
8
8
  class Config
9
9
  attr_writer :github_oauth_token, :gitlab_token, :gemfile_path
10
- attr_accessor :baseline_path,
10
+ attr_accessor :alternatives,
11
+ :baseline_path,
11
12
  :critical_warning_emoji,
12
13
  :cyclonedx_path,
13
14
  :cyclonedx_version,
@@ -28,6 +29,7 @@ module StillActive
28
29
  :warning_range_end
29
30
 
30
31
  def initialize
32
+ @alternatives = false
31
33
  @fail_if_critical = false
32
34
  @fail_if_outdated = nil
33
35
  @fail_if_vulnerable = nil
@@ -64,6 +64,7 @@ module StillActive
64
64
  opts.on("--terminal", "Coloured terminal output (default in TTY)") { StillActive.config { |config| config.output_format = :terminal } }
65
65
  opts.on("--markdown", "Markdown table output") { StillActive.config { |config| config.output_format = :markdown } }
66
66
  opts.on("--json", "JSON output (default when piped)") { StillActive.config { |config| config.output_format = :json } }
67
+ opts.on("--alternatives", "Suggest maintained alternatives (Ruby Toolbox leads) for archived/critical gems") { StillActive.config { |config| config.alternatives = true } }
67
68
  opts.on("--sarif[=PATH]", "SARIF 2.1.0 output for GitHub Code Scanning (default path: still_active.sarif.json; '-' for stdout). Overrides --terminal/--markdown/--json.") do |value|
68
69
  StillActive.config { |config| config.sarif_path = value || "still_active.sarif.json" }
69
70
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module StillActive
4
- VERSION = "1.5.0"
4
+ VERSION = "1.6.0"
5
5
  end
@@ -3,6 +3,9 @@
3
3
  require_relative "deps_dev_client"
4
4
  require_relative "gitlab_client"
5
5
  require_relative "repository"
6
+ require_relative "../helpers/activity_helper"
7
+ require_relative "../helpers/alternatives_helper"
8
+ require_relative "../helpers/catalog_index"
6
9
  require_relative "../helpers/libyear_helper"
7
10
  require_relative "../helpers/ruby_advisory_db"
8
11
  require_relative "../helpers/ruby_helper"
@@ -22,6 +25,7 @@ module StillActive
22
25
  # Load the optional ruby-advisory-db once, before the fan-out, so the
23
26
  # read-only Database is shared across fibers rather than reloaded per gem.
24
27
  advisory_db = RubyAdvisoryDb.load
28
+ catalog = StillActive.config.alternatives ? CatalogIndex.load : nil
25
29
  barrier = Async::Barrier.new
26
30
  semaphore = Async::Semaphore.new(StillActive.config.parallelism, parent: barrier)
27
31
  result_object = {}
@@ -36,6 +40,7 @@ module StillActive
36
40
  source_type: gem[:source_type] || :rubygems,
37
41
  source_uri: gem[:source_uri],
38
42
  advisory_db: advisory_db,
43
+ catalog: catalog,
39
44
  )
40
45
  rescue Octokit::TooManyRequests
41
46
  $stderr.print("\r\e[K") if on_progress
@@ -60,7 +65,7 @@ module StillActive
60
65
 
61
66
  private
62
67
 
63
- def gem_info(gem_name:, result_object:, gem_version: nil, source_type: :rubygems, source_uri: nil, advisory_db: nil)
68
+ def gem_info(gem_name:, result_object:, gem_version: nil, source_type: :rubygems, source_uri: nil, advisory_db: nil, catalog: nil)
64
69
  result_object[gem_name] = { source_type: source_type }
65
70
  result_object[gem_name][:version_used] = gem_version if gem_version
66
71
 
@@ -76,6 +81,8 @@ module StillActive
76
81
  advisory_db: advisory_db,
77
82
  )
78
83
  end
84
+
85
+ attach_alternatives(gem_name: gem_name, result_object: result_object, catalog: catalog)
79
86
  end
80
87
 
81
88
  def gem_info_rubygems(gem_name:, gem_version:, result_object:, source_uri:, advisory_db: nil)
@@ -151,6 +158,16 @@ module StillActive
151
158
  })
152
159
  end
153
160
 
161
+ def attach_alternatives(gem_name:, result_object:, catalog:)
162
+ return if catalog.nil?
163
+ return unless [:archived, :critical].include?(ActivityHelper.activity_level(result_object[gem_name]))
164
+
165
+ leads = AlternativesHelper.leads_for(gem_name: gem_name, index: catalog)
166
+ result_object[gem_name][:alternatives] = leads unless leads.empty?
167
+ rescue StandardError
168
+ nil # cosmetic best-effort: lead-fetching must never break the core audit
169
+ end
170
+
154
171
  def fetch_deps_dev_info(gem_name:, version:, advisory_db: nil)
155
172
  info = DepsDevClient.version_info(gem_name: gem_name, version: version)
156
173
  scorecard = DepsDevClient.project_scorecard(project_id: info&.dig(:project_id))
data/still_active.gemspec CHANGED
@@ -45,7 +45,11 @@ Gem::Specification.new do |spec|
45
45
  spec.add_development_dependency("rubocop-rspec")
46
46
  spec.add_development_dependency("rubocop-shopify")
47
47
 
48
- spec.add_runtime_dependency("async")
48
+ # 2.0/2.1 ship a scheduler that breaks our fan-out (io_read); 2.2 is the
49
+ # verified floor (checked against Ruby 3.3 in Docker). octokit/faraday-retry/
50
+ # gems work down to ancient versions, so they stay unpinned rather than
51
+ # carry an artificial floor.
52
+ spec.add_runtime_dependency("async", ">= 2.2")
49
53
  spec.add_runtime_dependency("bundler", ">= 2.0")
50
54
  spec.add_runtime_dependency("faraday-retry")
51
55
  spec.add_runtime_dependency("gems")
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: still_active
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.5.0
4
+ version: 1.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sean Floyd
@@ -127,14 +127,14 @@ dependencies:
127
127
  requirements:
128
128
  - - ">="
129
129
  - !ruby/object:Gem::Version
130
- version: '0'
130
+ version: '2.2'
131
131
  type: :runtime
132
132
  prerelease: false
133
133
  version_requirements: !ruby/object:Gem::Requirement
134
134
  requirements:
135
135
  - - ">="
136
136
  - !ruby/object:Gem::Version
137
- version: '0'
137
+ version: '2.2'
138
138
  - !ruby/object:Gem::Dependency
139
139
  name: bundler
140
140
  requirement: !ruby/object:Gem::Requirement
@@ -211,9 +211,11 @@ files:
211
211
  - README.md
212
212
  - bin/still_active
213
213
  - lib/helpers/activity_helper.rb
214
+ - lib/helpers/alternatives_helper.rb
214
215
  - lib/helpers/ansi_helper.rb
215
216
  - lib/helpers/bot_context.rb
216
217
  - lib/helpers/bundler_helper.rb
218
+ - lib/helpers/catalog_index.rb
217
219
  - lib/helpers/cyclonedx_helper.rb
218
220
  - lib/helpers/diff_markdown_helper.rb
219
221
  - lib/helpers/emoji_helper.rb