still_active 1.5.0 → 2.0.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.
@@ -2,7 +2,15 @@
2
2
 
3
3
  module StillActive
4
4
  module Repository
5
- REPO_REGEX = %r{(https?://(?:www\.)?(github|gitlab)\.com/([\w.\-]+)/([\w.\-]+))}i
5
+ # codeberg.org is the one Forgejo/Gitea host wired up today; it speaks the
6
+ # same Gitea API as any self-hosted instance, so its source is :forgejo (the
7
+ # forge software, what ForgejoClient handles), not :codeberg (the host).
8
+ SOURCE_BY_HOST = {
9
+ "github.com" => :github,
10
+ "gitlab.com" => :gitlab,
11
+ "codeberg.org" => :forgejo,
12
+ }.freeze
13
+ REPO_REGEX = %r{(?<url>https?://(?:www\.)?(?<host>github\.com|gitlab\.com|codeberg\.org)/(?<owner>[\w.\-]+)/(?<name>[\w.\-]+))}i
6
14
 
7
15
  extend self
8
16
 
@@ -16,10 +24,10 @@ module StillActive
16
24
  match = url&.match(REPO_REGEX)
17
25
  return { source: :unhandled, owner: nil, name: nil } unless match
18
26
 
19
- url = match[1].delete_suffix(".git")
20
- name = match[4].delete_suffix(".git")
27
+ clean_url = match[:url].delete_suffix(".git")
28
+ name = match[:name].delete_suffix(".git")
21
29
 
22
- { url: url, source: match[2].to_sym, owner: match[3], name: name }
30
+ { url: clean_url, source: SOURCE_BY_HOST.fetch(match[:host].downcase), owner: match[:owner], name: name }
23
31
  end
24
32
  end
25
33
  end
@@ -48,8 +48,8 @@ module StillActive
48
48
  {
49
49
  id: "SA002",
50
50
  name: "AbandonedGem",
51
- short: "Gem has had no commits for over 2 years",
52
- full: "The gem's source repository shows no commit activity for over 2 years. Not formally archived, but a strong dormancy signal.",
51
+ short: "Gem has had no release for over 3 years",
52
+ full: "The gem's latest release is over 3 years old. Not formally archived, but a strong abandonment signal: a consumer cannot pull fixes that were never released. For a gem with no releases at all (e.g. git-sourced), the last commit date is used instead.",
53
53
  help_text: "Verify the gem still works on supported Ruby versions and consider a maintained alternative.",
54
54
  level: "warning",
55
55
  security_severity: nil,
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+
5
+ module StillActive
6
+ # Granular, auditable suppression of individual findings, loaded from the
7
+ # `ignore:` block of a committed .still_active.yml. Each entry silences one
8
+ # signal (activity / libyear) or one advisory for one gem, optionally until an
9
+ # expiry date, replacing the all-or-nothing whole-gem --ignore. A bare gem
10
+ # name (or a gem-only mapping) keeps the old whole-gem behaviour so --ignore
11
+ # can union into the same list.
12
+ #
13
+ # Two guardrails keep suppression from hiding live risk: a lapsed entry stops
14
+ # applying so the finding re-surfaces, and a vulnerability suppression must
15
+ # name an explicit advisory id, so a newly disclosed CVE on the same gem is
16
+ # never pre-silenced.
17
+ class Suppressions
18
+ GATEABLE_SIGNALS = [:activity, :vulnerability, :libyear].freeze
19
+
20
+ Entry = Struct.new(:gem, :advisory, :signal, :reason, :expires, keyword_init: true) do
21
+ def whole_gem?
22
+ signal.nil? && advisory.nil?
23
+ end
24
+
25
+ def expired?(today)
26
+ !expires.nil? && expires < today
27
+ end
28
+
29
+ def covers?(signal:, advisory:, aliases:)
30
+ return true if whole_gem?
31
+
32
+ if self.signal == :vulnerability
33
+ [advisory, *aliases].compact.include?(self.advisory)
34
+ else
35
+ self.signal == signal
36
+ end
37
+ end
38
+ end
39
+
40
+ class << self
41
+ def from(raw, today: Date.today)
42
+ warnings = []
43
+ entries = Array(raw).filter_map { |item| parse_entry(item, warnings) }
44
+ new(entries, warnings, today)
45
+ end
46
+
47
+ private
48
+
49
+ def parse_entry(item, warnings)
50
+ return Entry.new(gem: item, advisory: nil, signal: nil, reason: nil, expires: nil) if item.is_a?(String)
51
+
52
+ unless item.is_a?(Hash)
53
+ warnings << "ignoring suppression entry #{item.inspect}: expected a gem name or a mapping"
54
+ return
55
+ end
56
+
57
+ gem = item["gem"]
58
+ advisory = item["advisory"]
59
+ signal = item["signal"]&.to_sym
60
+ signal ||= :vulnerability if advisory
61
+ expires = parse_expires(item["expires"], item, warnings)
62
+ return if expires == :invalid
63
+
64
+ label = gem || advisory || "entry"
65
+ return unless valid?(gem:, advisory:, signal:, label:, warnings:)
66
+
67
+ Entry.new(gem:, advisory:, signal:, reason: item["reason"], expires:)
68
+ end
69
+
70
+ # Returns true when the entry is well-formed, otherwise records a warning
71
+ # and returns false so the caller skips (does not apply) the entry.
72
+ def valid?(gem:, advisory:, signal:, label:, warnings:)
73
+ if gem.nil? && signal.nil? && advisory.nil?
74
+ warnings << "ignoring suppression entry: needs a gem, signal, or advisory"
75
+ return false
76
+ end
77
+ if signal && !GATEABLE_SIGNALS.include?(signal)
78
+ warnings << "ignoring suppression for #{label}: unknown signal #{signal.inspect} (expected activity, vulnerability, or libyear)"
79
+ return false
80
+ end
81
+ if signal == :vulnerability && advisory.nil?
82
+ warnings << "ignoring vulnerability suppression for #{label}: must name an advisory id so newly disclosed advisories still surface"
83
+ return false
84
+ end
85
+ if [:activity, :libyear].include?(signal) && gem.nil?
86
+ warnings << "ignoring #{signal} suppression: must name a gem"
87
+ return false
88
+ end
89
+
90
+ true
91
+ end
92
+
93
+ def parse_expires(value, item, warnings)
94
+ return if value.nil?
95
+ return value if value.is_a?(Date)
96
+
97
+ Date.parse(value.to_s)
98
+ rescue ArgumentError, TypeError
99
+ warnings << "ignoring suppression #{item.inspect}: unparseable expires #{value.inspect}"
100
+ :invalid
101
+ end
102
+ end
103
+
104
+ attr_reader :warnings
105
+
106
+ def initialize(entries, warnings, today)
107
+ @entries = entries
108
+ @warnings = warnings
109
+ @today = today
110
+ end
111
+
112
+ def suppressed?(gem:, signal:, advisory: nil, aliases: [])
113
+ !match(gem:, signal:, advisory:, aliases:).nil?
114
+ end
115
+
116
+ # Warnings for live entries that name a gem absent from the audited set: they
117
+ # can never match, so they are dead config (a typo, or a gem removed since
118
+ # the suppression was written). This is the presence axis of suppression rot;
119
+ # `expired?` already covers the time axis, so an expired entry isn't
120
+ # re-reported here, and a gem-agnostic advisory entry (gem nil) is skipped
121
+ # since it applies across the whole graph.
122
+ def stale_gem_warnings(present_gems)
123
+ @entries.filter_map do |entry|
124
+ next if entry.expired?(@today)
125
+ next unless entry.gem && !present_gems.include?(entry.gem)
126
+
127
+ "suppression for #{entry.gem} never applies: it is not in the audited dependencies (typo, or removed since it was suppressed?)"
128
+ end
129
+ end
130
+
131
+ # The first live entry covering this finding, or nil. Used by SARIF to carry
132
+ # the suppression's reason as the native suppressions[] justification.
133
+ def match(gem:, signal:, advisory: nil, aliases: [])
134
+ @entries.find do |entry|
135
+ next false if entry.expired?(@today)
136
+ next false unless entry.gem.nil? || entry.gem == gem
137
+
138
+ entry.covers?(signal:, advisory:, aliases:)
139
+ end
140
+ end
141
+ end
142
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module StillActive
4
- VERSION = "1.5.0"
4
+ VERSION = "2.0.0"
5
5
  end
@@ -1,8 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "artifactory_client"
3
4
  require_relative "deps_dev_client"
5
+ require_relative "forgejo_client"
6
+ require_relative "github_client"
4
7
  require_relative "gitlab_client"
5
8
  require_relative "repository"
9
+ require_relative "../helpers/activity_helper"
10
+ require_relative "../helpers/alternatives_helper"
11
+ require_relative "../helpers/catalog_index"
6
12
  require_relative "../helpers/libyear_helper"
7
13
  require_relative "../helpers/ruby_advisory_db"
8
14
  require_relative "../helpers/ruby_helper"
@@ -11,6 +17,7 @@ require_relative "../helpers/vulnerability_helper"
11
17
  require "async"
12
18
  require "async/barrier"
13
19
  require "async/semaphore"
20
+ require "cgi"
14
21
  require "gems"
15
22
 
16
23
  module StillActive
@@ -22,6 +29,7 @@ module StillActive
22
29
  # Load the optional ruby-advisory-db once, before the fan-out, so the
23
30
  # read-only Database is shared across fibers rather than reloaded per gem.
24
31
  advisory_db = RubyAdvisoryDb.load
32
+ catalog = StillActive.config.alternatives ? CatalogIndex.load : nil
25
33
  barrier = Async::Barrier.new
26
34
  semaphore = Async::Semaphore.new(StillActive.config.parallelism, parent: barrier)
27
35
  result_object = {}
@@ -35,7 +43,10 @@ module StillActive
35
43
  gem_version: gem[:version],
36
44
  source_type: gem[:source_type] || :rubygems,
37
45
  source_uri: gem[:source_uri],
46
+ direct: gem.fetch(:direct, true),
47
+ dependency_path: gem[:dependency_path],
38
48
  advisory_db: advisory_db,
49
+ catalog: catalog,
39
50
  )
40
51
  rescue Octokit::TooManyRequests
41
52
  $stderr.print("\r\e[K") if on_progress
@@ -49,7 +60,10 @@ module StillActive
49
60
  end
50
61
  end
51
62
  barrier.wait
52
- result_object
63
+ # Gems are inserted as their async tasks finish, so the natural order is
64
+ # nondeterministic completion order. Sort by name once here so every
65
+ # consumer (JSON, SARIF, the baseline diff) gets a stable, diffable order.
66
+ result_object.sort_by { |name, _| name }.to_h
53
67
  end
54
68
  task.wait
55
69
  end
@@ -60,8 +74,9 @@ module StillActive
60
74
 
61
75
  private
62
76
 
63
- def gem_info(gem_name:, result_object:, gem_version: nil, source_type: :rubygems, source_uri: nil, advisory_db: nil)
64
- result_object[gem_name] = { source_type: source_type }
77
+ def gem_info(gem_name:, result_object:, gem_version: nil, source_type: :rubygems, source_uri: nil, direct: true, dependency_path: nil, advisory_db: nil, catalog: nil)
78
+ result_object[gem_name] = { source_type: source_type, direct: direct }
79
+ result_object[gem_name][:dependency_path] = dependency_path if dependency_path
65
80
  result_object[gem_name][:version_used] = gem_version if gem_version
66
81
 
67
82
  case source_type
@@ -76,21 +91,20 @@ module StillActive
76
91
  advisory_db: advisory_db,
77
92
  )
78
93
  end
94
+
95
+ attach_alternatives(gem_name: gem_name, result_object: result_object, catalog: catalog)
79
96
  end
80
97
 
81
98
  def gem_info_rubygems(gem_name:, gem_version:, result_object:, source_uri:, advisory_db: nil)
82
99
  vs = versions(gem_name: gem_name, source_uri: source_uri)
83
- repo_info = repository_info(gem_name: gem_name, versions: vs)
84
- commit_date = last_commit_date(
85
- source: repo_info[:source],
86
- repository_owner: repo_info[:owner],
87
- repository_name: repo_info[:name],
88
- )
89
- archived = repo_archived(
100
+ repo_info = repository_info(gem_name: gem_name, versions: vs, source_uri: source_uri)
101
+ signals = repo_signals(
90
102
  source: repo_info[:source],
91
103
  repository_owner: repo_info[:owner],
92
104
  repository_name: repo_info[:name],
93
105
  )
106
+ commit_date = signals[:last_commit_date]
107
+ archived = signals[:archived]
94
108
  last_release = VersionHelper.find_version(versions: vs, pre_release: false)
95
109
  last_pre_release = VersionHelper.find_version(versions: vs, pre_release: true)
96
110
  deps_dev = fetch_deps_dev_info(
@@ -115,6 +129,15 @@ module StillActive
115
129
  result_object[gem_name][:ruby_gems_url] = "https://rubygems.org/gems/#{gem_name}"
116
130
  end
117
131
 
132
+ if StillActive.config.unreleased_commits
133
+ result_object[gem_name][:unreleased_commits] = unreleased_commits(
134
+ source: repo_info[:source],
135
+ repository_owner: repo_info[:owner],
136
+ repository_name: repo_info[:name],
137
+ version: VersionHelper.gem_version(version_hash: last_release),
138
+ )
139
+ end
140
+
118
141
  if gem_version
119
142
  version_used = VersionHelper.find_version(versions: vs, version_string: gem_version)
120
143
  result_object[gem_name].merge!({
@@ -143,14 +166,29 @@ module StillActive
143
166
  # Fall back to repo-derived project_id for scorecard when deps.dev doesn't have the version
144
167
  deps_dev[:scorecard_score] ||= DepsDevClient.project_scorecard(project_id: repo_info[:project_id])&.dig(:score)
145
168
 
169
+ signals = repo_signals(source:, repository_owner: owner, repository_name: name)
146
170
  result_object[gem_name].merge!({
147
171
  repository_url: repo_info[:url],
148
- last_commit_date: last_commit_date(source:, repository_owner: owner, repository_name: name),
149
- archived: repo_archived(source:, repository_owner: owner, repository_name: name),
172
+ last_commit_date: signals[:last_commit_date],
173
+ archived: signals[:archived],
150
174
  **deps_dev,
151
175
  })
152
176
  end
153
177
 
178
+ def attach_alternatives(gem_name:, result_object:, catalog:)
179
+ return if catalog.nil?
180
+ # Direct-only by design: "replace gem X with better-maintained Y" is
181
+ # incoherent for a transitive gem the user never chose (#60). The
182
+ # path-to-parent points them at the direct gem they can actually swap.
183
+ return unless result_object[gem_name][:direct]
184
+ return unless [:archived, :critical].include?(ActivityHelper.activity_level(result_object[gem_name]))
185
+
186
+ leads = AlternativesHelper.leads_for(gem_name: gem_name, index: catalog)
187
+ result_object[gem_name][:alternatives] = leads unless leads.empty?
188
+ rescue StandardError
189
+ nil # cosmetic best-effort: lead-fetching must never break the core audit
190
+ end
191
+
154
192
  def fetch_deps_dev_info(gem_name:, version:, advisory_db: nil)
155
193
  info = DepsDevClient.version_info(gem_name: gem_name, version: version)
156
194
  scorecard = DepsDevClient.project_scorecard(project_id: info&.dig(:project_id))
@@ -168,6 +206,11 @@ module StillActive
168
206
  def versions(gem_name:, source_uri: nil)
169
207
  if github_packages_uri?(source_uri)
170
208
  fetch_github_packages_versions(gem_name: gem_name, source_uri: source_uri)
209
+ elsif ArtifactoryClient.artifactory_uri?(source_uri)
210
+ ArtifactoryClient.versions(gem_name: gem_name, source_uri: source_uri)
211
+ elsif unqueryable_private_source?(source_uri)
212
+ warn_unqueryable_private_source(gem_name: gem_name, source_uri: source_uri)
213
+ []
171
214
  else
172
215
  Gems.versions(gem_name)
173
216
  end
@@ -184,10 +227,36 @@ module StillActive
184
227
  false
185
228
  end
186
229
 
230
+ # A rubygems-type source that isn't public rubygems.org and that we have no
231
+ # client for (Gemfury, Gemstash, geminabox, a private mirror). We must NOT
232
+ # fall through to Gems.versions, which always hits public rubygems.org: that
233
+ # would silently report a public name-collision's data, or blanks, as if it
234
+ # were the private gem's. github_packages/artifactory are handled above, so
235
+ # anything left with a non-rubygems.org host is unqueryable. Refs #43.
236
+ def unqueryable_private_source?(source_uri)
237
+ return false unless source_uri.is_a?(String)
238
+
239
+ # Hostnames are case-insensitive; a trailing dot (FQDN form) is equivalent.
240
+ host = URI(source_uri).host&.downcase&.chomp(".")
241
+ return false if host.nil?
242
+
243
+ host != "rubygems.org" && !host.end_with?(".rubygems.org")
244
+ rescue URI::InvalidURIError
245
+ false
246
+ end
247
+
248
+ def warn_unqueryable_private_source(gem_name:, source_uri:)
249
+ host = URI(source_uri).host
250
+ $stderr.puts(
251
+ "warning: #{gem_name} resolves from a private source (#{host}) still_active cannot query; " \
252
+ "reporting no version/latest/libyear data for it rather than substituting public rubygems.org data",
253
+ )
254
+ end
255
+
187
256
  def fetch_github_packages_versions(gem_name:, source_uri:)
188
257
  base = URI(source_uri.chomp("/"))
189
258
  namespace_path = base.path
190
- path = "#{namespace_path}/api/v1/gems/#{gem_name}/versions.json"
259
+ path = "#{namespace_path}/api/v1/gems/#{CGI.escape(gem_name)}/versions.json"
191
260
  token = StillActive.config.github_oauth_token
192
261
  headers = token ? { "Authorization" => "Bearer #{token}" } : {}
193
262
  HttpHelper.get_json(base, path, headers: headers) || []
@@ -197,32 +266,47 @@ module StillActive
197
266
  valid_repository_url =
198
267
  [source_uri, *installed_gem_urls(gem_name: gem_name)].find { |url| Repository.valid?(url: url) }
199
268
  repo = Repository.url_with_owner_and_name(url: valid_repository_url)
200
- project_id = if repo[:url]
201
- host = repo[:source] == :gitlab ? "gitlab.com" : "github.com"
202
- "#{host}/#{repo[:owner]}/#{repo[:name]}"
203
- end
204
- repo.merge(project_id: project_id)
269
+ repo.merge(project_id: deps_dev_project_id(repo))
205
270
  end
206
271
 
207
272
  def repository_info_from_installed_gem(gem_name:)
208
273
  valid_repository_url =
209
274
  installed_gem_urls(gem_name: gem_name).find { |url| Repository.valid?(url: url) }
210
275
  repo = Repository.url_with_owner_and_name(url: valid_repository_url)
211
- project_id = if repo[:url]
212
- host = repo[:source] == :gitlab ? "gitlab.com" : "github.com"
213
- "#{host}/#{repo[:owner]}/#{repo[:name]}"
214
- end
215
- repo.merge(project_id: project_id)
276
+ repo.merge(project_id: deps_dev_project_id(repo))
216
277
  end
217
278
 
218
- def repository_info(gem_name:, versions:)
279
+ # deps.dev scorecards index github.com and gitlab.com only. A Forgejo/Codeberg
280
+ # repo has no deps.dev project, so leave its project_id nil rather than minting
281
+ # a bogus github.com/owner/name that would fetch the wrong (or no) scorecard.
282
+ DEPS_DEV_HOST_BY_SOURCE = { github: "github.com", gitlab: "gitlab.com" }.freeze
283
+
284
+ def deps_dev_project_id(repo)
285
+ host = DEPS_DEV_HOST_BY_SOURCE[repo[:source]]
286
+ return unless repo[:url] && host
287
+
288
+ "#{host}/#{repo[:owner]}/#{repo[:name]}"
289
+ end
290
+
291
+ def repository_info(gem_name:, versions:, source_uri: nil)
219
292
  valid_repository_url =
220
293
  installed_gem_urls(gem_name: gem_name).find { |url| Repository.valid?(url: url) } ||
221
294
  rubygems_versions_repository_url(versions: versions).find { |url| Repository.valid?(url: url) } ||
222
- rubygems_gem_repository_url(gem_name: gem_name).find { |url| Repository.valid?(url: url) }
295
+ public_rubygems_repository_url(gem_name: gem_name, source_uri: source_uri)
223
296
  Repository.url_with_owner_and_name(url: valid_repository_url)
224
297
  end
225
298
 
299
+ # Locally-installed gem metadata and the gem's own version payload are
300
+ # source-accurate. This public rubygems.org Gems.info lookup is the last
301
+ # resort, and is skipped for an unqueryable private source: otherwise a
302
+ # public name-collision's repo/archived/last-commit data would stand in for
303
+ # the private gem, the same substitution #43 prevents for versions.
304
+ def public_rubygems_repository_url(gem_name:, source_uri:)
305
+ return if unqueryable_private_source?(source_uri)
306
+
307
+ rubygems_gem_repository_url(gem_name: gem_name).find { |url| Repository.valid?(url: url) }
308
+ end
309
+
226
310
  def installed_gem_urls(gem_name:)
227
311
  info = Gem::Dependency.new(gem_name).matching_specs.first
228
312
  return [] if info.nil?
@@ -251,40 +335,31 @@ module StillActive
251
335
  []
252
336
  end
253
337
 
254
- def repo_archived(source:, repository_owner:, repository_name:)
338
+ # The repo-signal provider for a source, or nil for an unhandled host. Every
339
+ # provider answers archived/last_commit_date; richer signals (e.g.
340
+ # commits_since_release) are duck-typed and dispatched by respond_to?, so a
341
+ # provider opts into them by defining the method, with no base class and no
342
+ # assumption that every source supports every signal.
343
+ def provider_for(source)
255
344
  case source
256
- when :github
257
- repo = StillActive.config.github_client.repository("#{repository_owner}/#{repository_name}")
258
- repo&.archived
259
- when :gitlab
260
- GitlabClient.archived(owner: repository_owner, name: repository_name)
345
+ when :github then GithubClient
346
+ when :gitlab then GitlabClient
347
+ when :forgejo then ForgejoClient
261
348
  end
262
- rescue Octokit::Error, Faraday::Error => e
263
- $stderr.puts("warning: archived check failed for #{repository_owner}/#{repository_name}: #{e.class}")
264
- nil
265
349
  end
266
350
 
267
- def last_commit_date(source:, repository_owner:, repository_name:)
268
- case source
269
- when :github
270
- commit = StillActive.config.github_client.commits("#{repository_owner}/#{repository_name}", per_page: 1)&.first
271
- date = commit&.commit&.author&.date
272
- case date
273
- when Time then date
274
- when String
275
- begin
276
- Time.parse(date)
277
- rescue ArgumentError
278
- $stderr.puts("warning: could not parse commit date for #{repository_owner}/#{repository_name}: #{date.inspect}")
279
- nil
280
- end
281
- end
282
- when :gitlab
283
- GitlabClient.last_commit_date(owner: repository_owner, name: repository_name)
284
- end
285
- rescue Octokit::Error, Faraday::Error => e
286
- $stderr.puts("warning: last commit check failed for #{repository_owner}/#{repository_name}: #{e.class}")
287
- nil
351
+ # One provider call yields both archived and the last-activity date (the
352
+ # repo object carries both), so a gem's repo signals cost a single request
353
+ # instead of two. Returns {} for an unhandled host.
354
+ def repo_signals(source:, repository_owner:, repository_name:)
355
+ provider_for(source)&.repo_signals(owner: repository_owner, name: repository_name) || {}
356
+ end
357
+
358
+ def unreleased_commits(source:, repository_owner:, repository_name:, version:)
359
+ provider = provider_for(source)
360
+ return unless provider.respond_to?(:commits_since_release)
361
+
362
+ provider.commits_since_release(owner: repository_owner, name: repository_name, version: version)
288
363
  end
289
364
  end
290
365
  end
data/still_active.gemspec CHANGED
@@ -9,13 +9,15 @@ Gem::Specification.new do |spec|
9
9
  spec.email = ["contact@seanfloyd.dev"]
10
10
 
11
11
  spec.summary = "Audit your Ruby dependencies for maintenance health, outdated versions, vulnerabilities, and abandoned gems."
12
- spec.description = "Analyses your Gemfile for dependency health: checks if gems are actively maintained " \
13
- "(last commit dates via GitHub and GitLab, release dates), outdated versions, archived repos, " \
14
- "OpenSSF Scorecard security scores, known vulnerabilities via deps.dev, and libyear drift. " \
15
- "Ruby version freshness with EOL detection. " \
16
- "Handles rubygems, git, path, and GitHub Packages sources. " \
17
- "Outputs coloured terminal tables, markdown, or JSON. " \
18
- "CI quality gates with --fail-if-critical, --fail-if-warning, --fail-if-vulnerable, --fail-if-outdated, and --ignore. " \
12
+ spec.description = "Analyses your Gemfile.lock for dependency health across the full transitive graph: " \
13
+ "whether each gem is actively maintained (last activity on GitHub, GitLab, or Codeberg/Forgejo, plus " \
14
+ "release recency), outdated versions, archived repos, OpenSSF Scorecard scores, known vulnerabilities " \
15
+ "(deps.dev merged with ruby-advisory-db), and libyear drift. Ruby version freshness with EOL detection. " \
16
+ "Handles rubygems, git, path, GitHub Packages, and JFrog Artifactory sources. " \
17
+ "Outputs coloured terminal tables, markdown, JSON (with a versioned, contract-tested schema), " \
18
+ "SARIF for GitHub code scanning, and a CycloneDX SBOM. " \
19
+ "CI quality gates (--fail-if-critical / -warning / -vulnerable / -outdated) with granular, committed " \
20
+ "suppression via .still_active.yml. " \
19
21
  "A comprehensive alternative to running bundle outdated, bundler-audit, and libyear-bundler separately."
20
22
  spec.homepage = "https://github.com/SeanLF/still_active"
21
23
  spec.license = "MIT"
@@ -45,7 +47,11 @@ Gem::Specification.new do |spec|
45
47
  spec.add_development_dependency("rubocop-rspec")
46
48
  spec.add_development_dependency("rubocop-shopify")
47
49
 
48
- spec.add_runtime_dependency("async")
50
+ # 2.0/2.1 ship a scheduler that breaks our fan-out (io_read); 2.2 is the
51
+ # verified floor (checked against Ruby 3.3 in Docker). octokit/faraday-retry/
52
+ # gems work down to ancient versions, so they stay unpinned rather than
53
+ # carry an artificial floor.
54
+ spec.add_runtime_dependency("async", ">= 2.2")
49
55
  spec.add_runtime_dependency("bundler", ">= 2.0")
50
56
  spec.add_runtime_dependency("faraday-retry")
51
57
  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: 2.0.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
@@ -191,14 +191,17 @@ dependencies:
191
191
  - - ">="
192
192
  - !ruby/object:Gem::Version
193
193
  version: '0'
194
- description: 'Analyses your Gemfile for dependency health: checks if gems are actively
195
- maintained (last commit dates via GitHub and GitLab, release dates), outdated versions,
196
- archived repos, OpenSSF Scorecard security scores, known vulnerabilities via deps.dev,
194
+ description: 'Analyses your Gemfile.lock for dependency health across the full transitive
195
+ graph: whether each gem is actively maintained (last activity on GitHub, GitLab,
196
+ or Codeberg/Forgejo, plus release recency), outdated versions, archived repos, OpenSSF
197
+ Scorecard scores, known vulnerabilities (deps.dev merged with ruby-advisory-db),
197
198
  and libyear drift. Ruby version freshness with EOL detection. Handles rubygems,
198
- git, path, and GitHub Packages sources. Outputs coloured terminal tables, markdown,
199
- or JSON. CI quality gates with --fail-if-critical, --fail-if-warning, --fail-if-vulnerable,
200
- --fail-if-outdated, and --ignore. A comprehensive alternative to running bundle
201
- outdated, bundler-audit, and libyear-bundler separately.'
199
+ git, path, GitHub Packages, and JFrog Artifactory sources. Outputs coloured terminal
200
+ tables, markdown, JSON (with a versioned, contract-tested schema), SARIF for GitHub
201
+ code scanning, and a CycloneDX SBOM. CI quality gates (--fail-if-critical / -warning
202
+ / -vulnerable / -outdated) with granular, committed suppression via .still_active.yml.
203
+ A comprehensive alternative to running bundle outdated, bundler-audit, and libyear-bundler
204
+ separately.'
202
205
  email:
203
206
  - contact@seanfloyd.dev
204
207
  executables:
@@ -211,33 +214,43 @@ files:
211
214
  - README.md
212
215
  - bin/still_active
213
216
  - lib/helpers/activity_helper.rb
217
+ - lib/helpers/alternatives_helper.rb
214
218
  - lib/helpers/ansi_helper.rb
215
219
  - lib/helpers/bot_context.rb
216
220
  - lib/helpers/bundler_helper.rb
221
+ - lib/helpers/catalog_index.rb
217
222
  - lib/helpers/cyclonedx_helper.rb
218
223
  - lib/helpers/diff_markdown_helper.rb
219
224
  - lib/helpers/emoji_helper.rb
220
225
  - lib/helpers/http_helper.rb
221
226
  - lib/helpers/libyear_helper.rb
227
+ - lib/helpers/lockfile_dependency_parser.rb
222
228
  - lib/helpers/lockfile_indexer.rb
229
+ - lib/helpers/markdown_escape.rb
223
230
  - lib/helpers/markdown_helper.rb
224
231
  - lib/helpers/ruby_advisory_db.rb
225
232
  - lib/helpers/ruby_helper.rb
226
233
  - lib/helpers/sarif_helper.rb
234
+ - lib/helpers/summary_helper.rb
227
235
  - lib/helpers/terminal_helper.rb
228
236
  - lib/helpers/version_helper.rb
229
237
  - lib/helpers/vulnerability_helper.rb
230
238
  - lib/still_active.rb
239
+ - lib/still_active/artifactory_client.rb
231
240
  - lib/still_active/cli.rb
232
241
  - lib/still_active/config.rb
242
+ - lib/still_active/config_file.rb
233
243
  - lib/still_active/core_ext.rb
234
244
  - lib/still_active/deps_dev_client.rb
235
245
  - lib/still_active/diff.rb
236
246
  - lib/still_active/errors.rb
247
+ - lib/still_active/forgejo_client.rb
248
+ - lib/still_active/github_client.rb
237
249
  - lib/still_active/gitlab_client.rb
238
250
  - lib/still_active/options.rb
239
251
  - lib/still_active/repository.rb
240
252
  - lib/still_active/sarif/rules.rb
253
+ - lib/still_active/suppressions.rb
241
254
  - lib/still_active/version.rb
242
255
  - lib/still_active/workflow.rb
243
256
  - still_active.gemspec