still_active 1.6.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.
@@ -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.6.0"
4
+ VERSION = "2.0.0"
5
5
  end
@@ -1,6 +1,9 @@
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"
6
9
  require_relative "../helpers/activity_helper"
@@ -14,6 +17,7 @@ require_relative "../helpers/vulnerability_helper"
14
17
  require "async"
15
18
  require "async/barrier"
16
19
  require "async/semaphore"
20
+ require "cgi"
17
21
  require "gems"
18
22
 
19
23
  module StillActive
@@ -39,6 +43,8 @@ module StillActive
39
43
  gem_version: gem[:version],
40
44
  source_type: gem[:source_type] || :rubygems,
41
45
  source_uri: gem[:source_uri],
46
+ direct: gem.fetch(:direct, true),
47
+ dependency_path: gem[:dependency_path],
42
48
  advisory_db: advisory_db,
43
49
  catalog: catalog,
44
50
  )
@@ -54,7 +60,10 @@ module StillActive
54
60
  end
55
61
  end
56
62
  barrier.wait
57
- 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
58
67
  end
59
68
  task.wait
60
69
  end
@@ -65,8 +74,9 @@ module StillActive
65
74
 
66
75
  private
67
76
 
68
- def gem_info(gem_name:, result_object:, gem_version: nil, source_type: :rubygems, source_uri: nil, advisory_db: nil, catalog: nil)
69
- 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
70
80
  result_object[gem_name][:version_used] = gem_version if gem_version
71
81
 
72
82
  case source_type
@@ -87,17 +97,14 @@ module StillActive
87
97
 
88
98
  def gem_info_rubygems(gem_name:, gem_version:, result_object:, source_uri:, advisory_db: nil)
89
99
  vs = versions(gem_name: gem_name, source_uri: source_uri)
90
- repo_info = repository_info(gem_name: gem_name, versions: vs)
91
- commit_date = last_commit_date(
92
- source: repo_info[:source],
93
- repository_owner: repo_info[:owner],
94
- repository_name: repo_info[:name],
95
- )
96
- archived = repo_archived(
100
+ repo_info = repository_info(gem_name: gem_name, versions: vs, source_uri: source_uri)
101
+ signals = repo_signals(
97
102
  source: repo_info[:source],
98
103
  repository_owner: repo_info[:owner],
99
104
  repository_name: repo_info[:name],
100
105
  )
106
+ commit_date = signals[:last_commit_date]
107
+ archived = signals[:archived]
101
108
  last_release = VersionHelper.find_version(versions: vs, pre_release: false)
102
109
  last_pre_release = VersionHelper.find_version(versions: vs, pre_release: true)
103
110
  deps_dev = fetch_deps_dev_info(
@@ -122,6 +129,15 @@ module StillActive
122
129
  result_object[gem_name][:ruby_gems_url] = "https://rubygems.org/gems/#{gem_name}"
123
130
  end
124
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
+
125
141
  if gem_version
126
142
  version_used = VersionHelper.find_version(versions: vs, version_string: gem_version)
127
143
  result_object[gem_name].merge!({
@@ -150,16 +166,21 @@ module StillActive
150
166
  # Fall back to repo-derived project_id for scorecard when deps.dev doesn't have the version
151
167
  deps_dev[:scorecard_score] ||= DepsDevClient.project_scorecard(project_id: repo_info[:project_id])&.dig(:score)
152
168
 
169
+ signals = repo_signals(source:, repository_owner: owner, repository_name: name)
153
170
  result_object[gem_name].merge!({
154
171
  repository_url: repo_info[:url],
155
- last_commit_date: last_commit_date(source:, repository_owner: owner, repository_name: name),
156
- archived: repo_archived(source:, repository_owner: owner, repository_name: name),
172
+ last_commit_date: signals[:last_commit_date],
173
+ archived: signals[:archived],
157
174
  **deps_dev,
158
175
  })
159
176
  end
160
177
 
161
178
  def attach_alternatives(gem_name:, result_object:, catalog:)
162
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]
163
184
  return unless [:archived, :critical].include?(ActivityHelper.activity_level(result_object[gem_name]))
164
185
 
165
186
  leads = AlternativesHelper.leads_for(gem_name: gem_name, index: catalog)
@@ -185,6 +206,11 @@ module StillActive
185
206
  def versions(gem_name:, source_uri: nil)
186
207
  if github_packages_uri?(source_uri)
187
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
+ []
188
214
  else
189
215
  Gems.versions(gem_name)
190
216
  end
@@ -201,10 +227,36 @@ module StillActive
201
227
  false
202
228
  end
203
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
+
204
256
  def fetch_github_packages_versions(gem_name:, source_uri:)
205
257
  base = URI(source_uri.chomp("/"))
206
258
  namespace_path = base.path
207
- path = "#{namespace_path}/api/v1/gems/#{gem_name}/versions.json"
259
+ path = "#{namespace_path}/api/v1/gems/#{CGI.escape(gem_name)}/versions.json"
208
260
  token = StillActive.config.github_oauth_token
209
261
  headers = token ? { "Authorization" => "Bearer #{token}" } : {}
210
262
  HttpHelper.get_json(base, path, headers: headers) || []
@@ -214,32 +266,47 @@ module StillActive
214
266
  valid_repository_url =
215
267
  [source_uri, *installed_gem_urls(gem_name: gem_name)].find { |url| Repository.valid?(url: url) }
216
268
  repo = Repository.url_with_owner_and_name(url: valid_repository_url)
217
- project_id = if repo[:url]
218
- host = repo[:source] == :gitlab ? "gitlab.com" : "github.com"
219
- "#{host}/#{repo[:owner]}/#{repo[:name]}"
220
- end
221
- repo.merge(project_id: project_id)
269
+ repo.merge(project_id: deps_dev_project_id(repo))
222
270
  end
223
271
 
224
272
  def repository_info_from_installed_gem(gem_name:)
225
273
  valid_repository_url =
226
274
  installed_gem_urls(gem_name: gem_name).find { |url| Repository.valid?(url: url) }
227
275
  repo = Repository.url_with_owner_and_name(url: valid_repository_url)
228
- project_id = if repo[:url]
229
- host = repo[:source] == :gitlab ? "gitlab.com" : "github.com"
230
- "#{host}/#{repo[:owner]}/#{repo[:name]}"
231
- end
232
- repo.merge(project_id: project_id)
276
+ repo.merge(project_id: deps_dev_project_id(repo))
277
+ end
278
+
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]}"
233
289
  end
234
290
 
235
- def repository_info(gem_name:, versions:)
291
+ def repository_info(gem_name:, versions:, source_uri: nil)
236
292
  valid_repository_url =
237
293
  installed_gem_urls(gem_name: gem_name).find { |url| Repository.valid?(url: url) } ||
238
294
  rubygems_versions_repository_url(versions: versions).find { |url| Repository.valid?(url: url) } ||
239
- 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)
240
296
  Repository.url_with_owner_and_name(url: valid_repository_url)
241
297
  end
242
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
+
243
310
  def installed_gem_urls(gem_name:)
244
311
  info = Gem::Dependency.new(gem_name).matching_specs.first
245
312
  return [] if info.nil?
@@ -268,40 +335,31 @@ module StillActive
268
335
  []
269
336
  end
270
337
 
271
- 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)
272
344
  case source
273
- when :github
274
- repo = StillActive.config.github_client.repository("#{repository_owner}/#{repository_name}")
275
- repo&.archived
276
- when :gitlab
277
- GitlabClient.archived(owner: repository_owner, name: repository_name)
345
+ when :github then GithubClient
346
+ when :gitlab then GitlabClient
347
+ when :forgejo then ForgejoClient
278
348
  end
279
- rescue Octokit::Error, Faraday::Error => e
280
- $stderr.puts("warning: archived check failed for #{repository_owner}/#{repository_name}: #{e.class}")
281
- nil
282
349
  end
283
350
 
284
- def last_commit_date(source:, repository_owner:, repository_name:)
285
- case source
286
- when :github
287
- commit = StillActive.config.github_client.commits("#{repository_owner}/#{repository_name}", per_page: 1)&.first
288
- date = commit&.commit&.author&.date
289
- case date
290
- when Time then date
291
- when String
292
- begin
293
- Time.parse(date)
294
- rescue ArgumentError
295
- $stderr.puts("warning: could not parse commit date for #{repository_owner}/#{repository_name}: #{date.inspect}")
296
- nil
297
- end
298
- end
299
- when :gitlab
300
- GitlabClient.last_commit_date(owner: repository_owner, name: repository_name)
301
- end
302
- rescue Octokit::Error, Faraday::Error => e
303
- $stderr.puts("warning: last commit check failed for #{repository_owner}/#{repository_name}: #{e.class}")
304
- 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)
305
363
  end
306
364
  end
307
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"
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.6.0
4
+ version: 2.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sean Floyd
@@ -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:
@@ -221,25 +224,33 @@ files:
221
224
  - lib/helpers/emoji_helper.rb
222
225
  - lib/helpers/http_helper.rb
223
226
  - lib/helpers/libyear_helper.rb
227
+ - lib/helpers/lockfile_dependency_parser.rb
224
228
  - lib/helpers/lockfile_indexer.rb
229
+ - lib/helpers/markdown_escape.rb
225
230
  - lib/helpers/markdown_helper.rb
226
231
  - lib/helpers/ruby_advisory_db.rb
227
232
  - lib/helpers/ruby_helper.rb
228
233
  - lib/helpers/sarif_helper.rb
234
+ - lib/helpers/summary_helper.rb
229
235
  - lib/helpers/terminal_helper.rb
230
236
  - lib/helpers/version_helper.rb
231
237
  - lib/helpers/vulnerability_helper.rb
232
238
  - lib/still_active.rb
239
+ - lib/still_active/artifactory_client.rb
233
240
  - lib/still_active/cli.rb
234
241
  - lib/still_active/config.rb
242
+ - lib/still_active/config_file.rb
235
243
  - lib/still_active/core_ext.rb
236
244
  - lib/still_active/deps_dev_client.rb
237
245
  - lib/still_active/diff.rb
238
246
  - lib/still_active/errors.rb
247
+ - lib/still_active/forgejo_client.rb
248
+ - lib/still_active/github_client.rb
239
249
  - lib/still_active/gitlab_client.rb
240
250
  - lib/still_active/options.rb
241
251
  - lib/still_active/repository.rb
242
252
  - lib/still_active/sarif/rules.rb
253
+ - lib/still_active/suppressions.rb
243
254
  - lib/still_active/version.rb
244
255
  - lib/still_active/workflow.rb
245
256
  - still_active.gemspec