still_active 1.1.0 → 1.2.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: ebdc7af759c1dfe0bf4172000aab61f100e7dc8463374c5cf12458c69a3ff7ad
4
- data.tar.gz: 5988d28e7fa84d686786ddf56e7da557d4935d8c5c305c33105593e859216941
3
+ metadata.gz: 8c861ae8a727347f9b276576f3da48d806fbc25ab05020d849e5e92c8de49732
4
+ data.tar.gz: bc3e5d7429e17adc0b6b0e955f50a6ddb9e7f157f1c656bf8c9044113cd5a97e
5
5
  SHA512:
6
- metadata.gz: a0c5bd6fabfc32a72dcb6169b89bfe3ffb93879117be4b3489705844f4d84ffc226ed4ba42b1bc86a2d253416de94503ffd3263777d172b1fb9d2fbc85ff687b
7
- data.tar.gz: 05be629f02347fe1594351884a06135acf34d843a8611e073bce41fbae29164492bd343468cac5c6ed95651c775e2d93ea58d408c5879fd3f7253f2724954318
6
+ metadata.gz: e922835a769aeb9817dd27e99bf194c39e21e43794ee66d6d7d1db4c2c9ccaf11cf40d75ac251302f014f3df539998ab4e285f446fb68b6aaa080259421cc1fb
7
+ data.tar.gz: 932b7e3dbd72cd02492070eced16a77e1a8a1649bb86132f2a7f827339d3f42138633139cbfd5134e31b1c4a92d18b28758afafc74674402f72a9ed2d7512399
data/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.2.0] - 2026-02-20
4
+
5
+ ### Added
6
+
7
+ - `--fail-if-vulnerable[=SEVERITY]` flag: exit 1 if any gem has known vulnerabilities, optionally filtered by severity (low/medium/high/critical)
8
+ - `--fail-if-outdated=LIBYEARS` flag: exit 1 if any gem exceeds the given libyear threshold
9
+ - Coloured OpenSSF column in terminal output: green for strong practices (7.0+), yellow for notably weak (below 4.0)
10
+
11
+ ### Changed
12
+
13
+ - Removed composite health score (0-100) and Health column from terminal, markdown, and JSON output; individual columns (vulns, OpenSSF, activity, version) communicate these signals without collapsing them into one number
14
+ - Replaced `--fail-below-score` with `--fail-if-vulnerable` and `--fail-if-outdated` for targeted CI gating
15
+
16
+ ### Fixed
17
+
18
+ - Repository URLs with `.git` suffix (e.g. `socketry/async.git`) caused 404s against GitHub/GitLab APIs
19
+ - GitLab 301 redirects for renamed projects silently failed; now follows up to 3 redirects with trusted host check
20
+ - Network errors (`ECONNRESET`, timeouts, etc.) during RubyGems version lookup or HTTP API calls dropped the entire gem from results instead of warning
21
+ - GitHub Packages URI check used substring match, allowing crafted URLs to bypass host validation; now parses URI and compares host exactly
22
+ - Tri-state `archived?` predicate renamed to `archived` to honestly reflect `true`/`false`/`nil` return contract
23
+ - Rubocop offences from code scanning (WordArray, IfInsideElse, MultilineHash, frozen_string_literal)
24
+
3
25
  ## [1.1.0] - 2026-02-20
4
26
 
5
27
  ### Added
data/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  **How do you know if your Ruby dependencies are still maintained?**
4
4
 
5
- `bundle outdated` tells you version drift. `bundler-audit` catches known CVEs. Neither tells you whether anyone is still working on the thing. `still_active` checks maintenance activity, version freshness, security scores, vulnerabilities, libyear drift, and archived repos for every gem in your Gemfile -- with a composite health score per gem.
5
+ `bundle outdated` tells you version drift. `bundler-audit` catches known CVEs. Neither tells you whether anyone is still working on the thing. `still_active` checks maintenance activity, version freshness, security scores, vulnerabilities, libyear drift, and archived repos for every gem in your Gemfile.
6
6
 
7
7
  [![Gem Version](https://badge.fury.io/rb/still_active.svg)](https://badge.fury.io/rb/still_active)
8
8
  ![Code Quality analysis](https://github.com/SeanLF/still_active/actions/workflows/codeql-analysis.yml/badge.svg)
@@ -10,16 +10,17 @@
10
10
  ![Rubocop analysis](https://github.com/SeanLF/still_active/actions/workflows/rubocop-analysis.yml/badge.svg)
11
11
 
12
12
  ```
13
- Name Version Activity OpenSSF Vulns Health
14
- ───────────────────────────────────────────────────────────────────────────
15
- code-scanning-rubocop 0.6.1 (latest) stale 3.1/10 0 71/100
16
- debug 1.11.1 (latest) ok 5.2/10 0 90/100
17
- faker 3.6.0 (latest) ok 7.4/10 0 95/100
18
- rake 13.3.1 (latest) ok 5.3/10 0 91/100
19
- rspec 3.13.2 (latest) ok 6.9/10 0 94/100
20
- rubocop 1.84.2 (latest) ok 5.9/10 0 92/100
21
-
22
- 12 gems: 11 up to date, 0 outdated · 11 active, 1 stale · 0 vulnerabilities · health 93/100
13
+ Name Version Activity OpenSSF Vulns
14
+ ───────────────────────────────────────────────────────────────────
15
+ async 2.36.0 (latest) ok 7.1/10 0
16
+ backbone-rails 1.2.3 (latest) archived 3.6/10 0
17
+ bootstrap-slider-rails 9.8.0 (latest) critical - 0
18
+ gitlab-markup 2.0.0 (latest) ok - 0
19
+ local_gem 0.1.0 (path) - - 0
20
+ nested_form 0.3.2 (git) archived 3.3/10 0
21
+ remotipart 1.4.4 (git) critical 3.1/10 0
22
+
23
+ 7 gems: 4 up to date, 0 outdated · 2 active, 2 stale, 2 archived · 0 vulnerabilities
23
24
  Ruby 4.0.1 (latest)
24
25
  ```
25
26
 
@@ -34,13 +35,12 @@ Most dependency tools answer one question. `still_active` answers all of them at
34
35
  | OpenSSF Scorecard | - | - | - | **Yes** |
35
36
  | Last commit activity | - | - | - | **Yes** |
36
37
  | Libyear drift | - | - | Yes | **Yes** |
37
- | Composite health score | - | - | - | **Yes** (0-100) |
38
38
  | Archived repo detection | - | - | - | **Yes** |
39
39
  | Yanked version detection | - | - | - | **Yes** |
40
40
  | Ruby version freshness | - | - | - | **Yes** (EOL + libyear) |
41
41
  | Git/path/GH Packages sources | - | - | - | **Yes** |
42
42
  | GitLab support | - | - | - | **Yes** |
43
- | CI quality gates | - | Exit code | - | **Yes** (4 modes) |
43
+ | CI quality gates | - | Exit code | - | **Yes** (5 modes) |
44
44
  | Multiple output formats | - | - | - | **Terminal, JSON, Markdown** |
45
45
  | Single command | Yes | Yes | Yes | **Yes** |
46
46
 
@@ -61,8 +61,8 @@ still_active
61
61
  # check specific gems
62
62
  still_active --gems=rails,nokogiri,sidekiq
63
63
 
64
- # CI pipeline: fail if any gem is critically stale or has low health
65
- still_active --fail-if-critical --fail-below-score=50
64
+ # CI pipeline: fail if any gem is critically stale or has vulnerabilities
65
+ still_active --fail-if-critical --fail-if-vulnerable
66
66
 
67
67
  # ignore specific gems in CI checks
68
68
  still_active --fail-if-warning --ignore=legacy_gem,internal_gem
@@ -96,7 +96,9 @@ Usage: still_active [options]
96
96
  --warning-range-end=YEARS maximum years since last activity that triggers a warning (beyond this is critical)
97
97
  --fail-if-critical Exit 1 if any gem has critical activity warning
98
98
  --fail-if-warning Exit 1 if any gem has warning or critical activity warning
99
- --fail-below-score=SCORE Exit 1 if any gem health score is below threshold
99
+ --fail-if-vulnerable[=SEVERITY]
100
+ Exit 1 if any gem has vulnerabilities (optionally at or above SEVERITY)
101
+ --fail-if-outdated=LIBYEARS Exit 1 if any gem exceeds LIBYEARS behind latest
100
102
  --ignore=GEM,GEM2,... Exclude gems from pass/fail checks (still shown in output)
101
103
  --critical-warning-emoji=EMOJI
102
104
  --futurist-emoji=EMOJI
@@ -114,31 +116,37 @@ Usage: still_active [options]
114
116
  **JSON** (default when piped) -- structured data for automation:
115
117
 
116
118
  ```bash
117
- still_active --json --gems=rails,nokogiri
119
+ still_active --json --gemfile=spec/still_active/edge_case_gemfile/Gemfile
118
120
  ```
119
121
 
120
122
  ```json
121
123
  {
122
124
  "gems": {
123
- "rails": {
125
+ "async": {
124
126
  "source_type": "rubygems",
125
- "latest_version": "8.1.2",
126
- "repository_url": "https://github.com/rails/rails",
127
- "last_commit_date": "2026-02-19 09:39:03 UTC",
127
+ "version_used": "2.36.0",
128
+ "latest_version": "2.36.0",
129
+ "repository_url": "https://github.com/socketry/async",
130
+ "last_commit_date": "2026-01-22 04:09:48 UTC",
128
131
  "archived": false,
129
- "scorecard_score": 5.7,
132
+ "scorecard_score": 7.1,
130
133
  "vulnerability_count": 0,
131
- "health_score": 88
134
+ "libyear": 0.0
132
135
  },
133
- "nokogiri": {
134
- "source_type": "rubygems",
135
- "latest_version": "1.19.1",
136
- "repository_url": "https://github.com/sparklemotion/nokogiri",
137
- "last_commit_date": "2026-02-17 19:13:22 UTC",
138
- "archived": false,
139
- "scorecard_score": 6.5,
140
- "vulnerability_count": 0,
141
- "health_score": 90
136
+ "nested_form": {
137
+ "source_type": "git",
138
+ "version_used": "0.3.2",
139
+ "repository_url": "https://github.com/ryanb/nested_form",
140
+ "last_commit_date": "2021-12-11 21:47:02 UTC",
141
+ "archived": true,
142
+ "scorecard_score": 3.3,
143
+ "vulnerability_count": 0
144
+ },
145
+ "local_gem": {
146
+ "source_type": "path",
147
+ "version_used": "0.1.0",
148
+ "scorecard_score": null,
149
+ "vulnerability_count": 0
142
150
  }
143
151
  },
144
152
  "ruby": {
@@ -156,17 +164,18 @@ still_active --json --gems=rails,nokogiri
156
164
  still_active --markdown
157
165
  ```
158
166
 
159
- | activity | up to date? | OpenSSF | vulns | name | version used | latest version | latest pre-release | last commit | libyear | health |
160
- | -------- | ----------- | ------- | ----- | -------- | ------------ | ---------------- | -------------------- | ----------- | ------- | ------ |
161
- | | | 5.2/10 | ✅ | debug | ❓ | 1.11.1 (2025/12) | 1.0.0.rc2 (2021/09) | 2025/12 | - | 86/100 |
162
- | | | 6.5/10 | ✅ | nokogiri | ❓ | 1.19.1 (2026/02) | 1.18.0.rc1 (2024/12) | 2026/02 | - | 90/100 |
163
- | | ❓ | 5.7/10 | ✅ | rails | ❓ | 8.1.2 (2026/01) | 8.1.0.rc1 (2025/10) | 2026/02 | - | 88/100 |
167
+ | activity | up to date? | OpenSSF | vulns | name | version used | latest version | latest pre-release | last commit | libyear |
168
+ | -------- | ----------- | ------- | ----- | ------------------------------------------------------------ | -------------------------------------------------------------------------- | -------------------------------------------------------------------------- | ------------------ | ----------------------------------------------------- | ------- |
169
+ | | | 7.1/10 | ✅ | [async](https://github.com/socketry/async) | [2.36.0](https://rubygems.org/gems/async/versions/2.36.0) (2026/01) | [2.36.0](https://rubygems.org/gems/async/versions/2.36.0) (2026/01) | | [2026/01](https://github.com/socketry/async) | 0.0y |
170
+ | 🚩 | | 3.6/10 | ✅ | [backbone-rails](https://github.com/aflatter/backbone-rails) | [1.2.3](https://rubygems.org/gems/backbone-rails/versions/1.2.3) (2016/02) | [1.2.3](https://rubygems.org/gems/backbone-rails/versions/1.2.3) (2016/02) | ❓ | [2016/02](https://github.com/aflatter/backbone-rails) | 0.0y |
171
+ || ❓ | | ✅ | local_gem | 0.1.0 (path) | ❓ | | | - |
172
+ | 🚩 | ❓ | 3.3/10 | ✅ | [nested_form](https://github.com/ryanb/nested_form) | 0.3.2 (git) | ❓ | ❓ | [2021/12](https://github.com/ryanb/nested_form) | - |
164
173
 
165
174
  **Ruby 4.0.1** (latest) ✅
166
175
 
167
176
  ### CI quality gating
168
177
 
169
- Use exit-code flags to fail CI pipelines based on dependency health:
178
+ Use exit-code flags to fail CI pipelines based on dependency status:
170
179
 
171
180
  ```bash
172
181
  # fail on critically stale or archived gems
@@ -175,11 +184,17 @@ still_active --fail-if-critical --json
175
184
  # fail on any stale, critical, or archived gem
176
185
  still_active --fail-if-warning --json
177
186
 
178
- # fail if any gem's health score drops below a threshold
179
- still_active --fail-below-score=50 --json
187
+ # fail if any gem has known vulnerabilities
188
+ still_active --fail-if-vulnerable --json
189
+
190
+ # fail only on high/critical severity vulnerabilities
191
+ still_active --fail-if-vulnerable=high --json
192
+
193
+ # fail if any gem is more than 3 libyears behind
194
+ still_active --fail-if-outdated=3 --json
180
195
 
181
196
  # combine flags and exclude known exceptions
182
- still_active --fail-if-warning --fail-below-score=50 --ignore=legacy_gem --json
197
+ still_active --fail-if-warning --fail-if-vulnerable --ignore=legacy_gem --json
183
198
  ```
184
199
 
185
200
  ### Activity thresholds
@@ -5,6 +5,9 @@ require "json"
5
5
 
6
6
  module StillActive
7
7
  module HttpHelper
8
+ TRUSTED_HOSTS = ["github.com", "gitlab.com", "api.deps.dev", "endoflife.date", "rubygems.pkg.github.com"].freeze
9
+ MAX_REDIRECTS = 3
10
+
8
11
  extend self
9
12
 
10
13
  def get_json(base_uri, path, headers: {}, params: {})
@@ -12,22 +15,40 @@ module StillActive
12
15
  uri.path = path
13
16
  uri.query = URI.encode_www_form(params) unless params.empty?
14
17
 
15
- http = Net::HTTP.new(uri.host, uri.port)
16
- http.use_ssl = true
17
- http.open_timeout = 10
18
- http.read_timeout = 10
18
+ MAX_REDIRECTS.times do
19
+ http = Net::HTTP.new(uri.host, uri.port)
20
+ http.use_ssl = true
21
+ http.open_timeout = 10
22
+ http.read_timeout = 10
23
+
24
+ request = Net::HTTP::Get.new(uri)
25
+ headers.each { |key, value| request[key] = value }
26
+
27
+ response = http.request(request)
19
28
 
20
- request = Net::HTTP::Get.new(uri)
21
- headers.each { |key, value| request[key] = value }
29
+ if response.is_a?(Net::HTTPRedirection)
30
+ redirect_uri = uri + response["Location"]
31
+ unless TRUSTED_HOSTS.include?(redirect_uri.host)
32
+ $stderr.puts("warning: #{uri.host}#{uri.path} redirected to untrusted host #{redirect_uri.host}, skipping")
33
+ return
34
+ end
35
+ $stderr.puts("warning: #{uri.host}#{uri.path} redirected to #{redirect_uri.host}#{redirect_uri.path} (stale metadata?)")
36
+ headers = {} if redirect_uri.host != uri.host
37
+ uri = redirect_uri
38
+ next
39
+ end
22
40
 
23
- response = http.request(request)
24
- unless response.is_a?(Net::HTTPSuccess)
25
- $stderr.puts("warning: #{uri.host}#{uri.path} returned HTTP #{response.code}") unless response.is_a?(Net::HTTPNotFound)
26
- return
41
+ unless response.is_a?(Net::HTTPSuccess)
42
+ $stderr.puts("warning: #{uri.host}#{uri.path} returned HTTP #{response.code}") unless response.is_a?(Net::HTTPNotFound)
43
+ return
44
+ end
45
+
46
+ return JSON.parse(response.body)
27
47
  end
28
48
 
29
- JSON.parse(response.body)
30
- rescue Net::OpenTimeout, Net::ReadTimeout, SocketError, Errno::ECONNREFUSED => e
49
+ $stderr.puts("warning: #{uri.host}#{uri.path} too many redirects")
50
+ nil
51
+ rescue Net::OpenTimeout, Net::ReadTimeout, SocketError, Errno::ECONNREFUSED, Errno::ECONNRESET => e
31
52
  $stderr.puts("warning: #{uri.host}#{uri.path} failed: #{e.class} (#{e.message})")
32
53
  nil
33
54
  rescue JSON::ParserError => e
@@ -26,8 +26,8 @@ module StillActive
26
26
  end
27
27
 
28
28
  def markdown_table_header_line
29
- "| activity | up to date? | OpenSSF | vulns | name | version used | latest version | latest pre-release | last commit | libyear | health |\n" \
30
- "| -------- | ----------- | ------- | ----- | ---- | ------------ | -------------- | ------------------ | ----------- | ------- | ------ |"
29
+ "| activity | up to date? | OpenSSF | vulns | name | version used | latest version | latest pre-release | last commit | libyear |\n" \
30
+ "| -------- | ----------- | ------- | ----- | ---- | ------------ | -------------- | ------------------ | ----------- | ------- |"
31
31
  end
32
32
 
33
33
  def markdown_table_body_line(gem_name:, data:)
@@ -78,7 +78,6 @@ module StillActive
78
78
  formatted_latest_pre_release || unsure,
79
79
  formatted_last_commit || unsure,
80
80
  format_libyear(data[:libyear]),
81
- format_health(data[:health_score]),
82
81
  ]
83
82
 
84
83
  "| #{cells.join(" | ")} |"
@@ -108,12 +107,6 @@ module StillActive
108
107
  "#{score}/10"
109
108
  end
110
109
 
111
- def format_health(score)
112
- return "-" if score.nil?
113
-
114
- "#{score}/100"
115
- end
116
-
117
110
  def format_libyear(value)
118
111
  return "-" if value.nil?
119
112
 
@@ -2,7 +2,6 @@
2
2
 
3
3
  require_relative "activity_helper"
4
4
  require_relative "ansi_helper"
5
- require_relative "health_score_helper"
6
5
  require_relative "libyear_helper"
7
6
  require_relative "version_helper"
8
7
  require_relative "vulnerability_helper"
@@ -11,7 +10,7 @@ module StillActive
11
10
  module TerminalHelper
12
11
  extend self
13
12
 
14
- HEADERS = ["Name", "Version", "Activity", "OpenSSF", "Vulns", "Health"].freeze
13
+ HEADERS = ["Name", "Version", "Activity", "OpenSSF", "Vulns"].freeze
15
14
 
16
15
  def render(result, ruby_info: nil)
17
16
  rows = result.keys.sort.map { |name| build_row(name, result[name]) }
@@ -36,7 +35,6 @@ module StillActive
36
35
  format_activity(data),
37
36
  format_scorecard(data[:scorecard_score]),
38
37
  format_vulns(data),
39
- format_health(data[:health_score]),
40
38
  ]
41
39
  end
42
40
 
@@ -73,7 +71,16 @@ module StillActive
73
71
  end
74
72
 
75
73
  def format_scorecard(score)
76
- score.nil? ? AnsiHelper.dim("-") : "#{score}/10"
74
+ return AnsiHelper.dim("-") if score.nil?
75
+
76
+ text = "#{score}/10"
77
+ if score >= 7.0
78
+ AnsiHelper.green(text)
79
+ elsif score < 4.0
80
+ AnsiHelper.yellow(text)
81
+ else
82
+ text
83
+ end
77
84
  end
78
85
 
79
86
  def format_vulns(data)
@@ -86,17 +93,6 @@ module StillActive
86
93
  AnsiHelper.red(label)
87
94
  end
88
95
 
89
- def format_health(score)
90
- return AnsiHelper.dim("-") if score.nil?
91
-
92
- text = "#{score}/100"
93
- case score
94
- when 80..100 then AnsiHelper.green(text)
95
- when 50..79 then AnsiHelper.yellow(text)
96
- else AnsiHelper.red(text)
97
- end
98
- end
99
-
100
96
  def column_widths(rows)
101
97
  return HEADERS.map { |h| h.length + 2 } if rows.empty?
102
98
 
@@ -166,8 +162,6 @@ module StillActive
166
162
  parts << "#{vulns} vulnerabilities"
167
163
  total_libyear = LibyearHelper.total_libyear(result)
168
164
  parts << "#{total_libyear.round(1)} libyears behind" if total_libyear > 0
169
- avg = HealthScoreHelper.system_average(result)
170
- parts << "health #{avg}/100" if avg
171
165
  parts.join(" · ")
172
166
  end
173
167
  end
@@ -4,6 +4,8 @@ module StillActive
4
4
  module VulnerabilityHelper
5
5
  extend self
6
6
 
7
+ SEVERITY_ORDER = ["low", "medium", "high", "critical"].freeze
8
+
7
9
  def highest_severity(vulnerabilities)
8
10
  return if vulnerabilities.nil? || vulnerabilities.empty?
9
11
 
@@ -13,6 +15,13 @@ module StillActive
13
15
  severity_label(max_score)
14
16
  end
15
17
 
18
+ def severity_at_or_above?(vulnerabilities, threshold)
19
+ highest = highest_severity(vulnerabilities)
20
+ return false if highest.nil?
21
+
22
+ SEVERITY_ORDER.index(highest) >= SEVERITY_ORDER.index(threshold)
23
+ end
24
+
16
25
  private
17
26
 
18
27
  def severity_label(score)
@@ -7,6 +7,7 @@ require_relative "../helpers/emoji_helper"
7
7
  require_relative "../helpers/markdown_helper"
8
8
  require_relative "../helpers/terminal_helper"
9
9
  require_relative "../helpers/version_helper"
10
+ require_relative "../helpers/vulnerability_helper"
10
11
  require_relative "workflow"
11
12
 
12
13
  module StillActive
@@ -73,7 +74,7 @@ module StillActive
73
74
 
74
75
  def check_exit_status(result)
75
76
  config = StillActive.config
76
- return unless config.fail_if_critical || config.fail_if_warning || config.fail_below_score
77
+ return unless config.fail_if_critical || config.fail_if_warning || config.fail_if_vulnerable || config.fail_if_outdated
77
78
 
78
79
  ignored = config.ignored_gems
79
80
  checked = result.reject { |name, _| ignored.include?(name) }
@@ -84,8 +85,17 @@ module StillActive
84
85
  exit(1) if config.fail_if_critical && levels.intersect?([:critical, :archived])
85
86
  end
86
87
 
87
- if (threshold = config.fail_below_score)
88
- exit(1) if checked.each_value.any? { |d| d[:health_score] && d[:health_score] < threshold }
88
+ if (vuln_setting = config.fail_if_vulnerable)
89
+ checked.each_value do |d|
90
+ next unless d[:vulnerability_count]&.positive?
91
+
92
+ exit(1) if vuln_setting == true
93
+ exit(1) if VulnerabilityHelper.severity_at_or_above?(d[:vulnerabilities], vuln_setting)
94
+ end
95
+ end
96
+
97
+ if (threshold = config.fail_if_outdated)
98
+ exit(1) if checked.each_value.any? { |d| d[:libyear] && d[:libyear] > threshold }
89
99
  end
90
100
  end
91
101
  end
@@ -13,7 +13,8 @@ module StillActive
13
13
  :gems,
14
14
  :github_oauth_token,
15
15
  :gitlab_token,
16
- :fail_below_score,
16
+ :fail_if_outdated,
17
+ :fail_if_vulnerable,
17
18
  :ignored_gems,
18
19
  :output_format,
19
20
  :parallelism,
@@ -24,8 +25,9 @@ module StillActive
24
25
  :warning_range_end
25
26
 
26
27
  def initialize
27
- @fail_below_score = nil
28
28
  @fail_if_critical = false
29
+ @fail_if_outdated = nil
30
+ @fail_if_vulnerable = nil
29
31
  @fail_if_warning = false
30
32
  @gemfile_path = Bundler.default_gemfile.to_s
31
33
  @gems = []
@@ -9,7 +9,7 @@ module StillActive
9
9
 
10
10
  BASE_URI = URI("https://gitlab.com/")
11
11
 
12
- def archived?(owner:, name:)
12
+ def archived(owner:, name:)
13
13
  return if owner.nil? || name.nil?
14
14
 
15
15
  path = "/api/v4/projects/#{encode_project(owner, name)}"
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "optparse"
4
+ require_relative "../helpers/vulnerability_helper"
4
5
 
5
6
  module StillActive
6
7
  class Options
@@ -100,10 +101,18 @@ module StillActive
100
101
  opts.on("--fail-if-warning", "Exit 1 if any gem has warning or critical activity warning") do
101
102
  StillActive.config { |config| config.fail_if_warning = true }
102
103
  end
103
- opts.on("--fail-below-score=SCORE", Integer, "Exit 1 if any gem's health score is below SCORE (0-100)") do |value|
104
- raise ArgumentError, "--fail-below-score must be between 0 and 100 (got #{value})" unless (0..100).cover?(value)
105
-
106
- StillActive.config { |config| config.fail_below_score = value }
104
+ opts.on("--fail-if-vulnerable[=SEVERITY]", "Exit 1 if any gem has vulnerabilities (optionally at or above SEVERITY: low, medium, high, critical)") do |value|
105
+ if value
106
+ valid = VulnerabilityHelper::SEVERITY_ORDER
107
+ raise ArgumentError, "--fail-if-vulnerable severity must be one of: #{valid.join(", ")} (got #{value})" unless valid.include?(value)
108
+
109
+ StillActive.config { |config| config.fail_if_vulnerable = value }
110
+ else
111
+ StillActive.config { |config| config.fail_if_vulnerable = true }
112
+ end
113
+ end
114
+ opts.on("--fail-if-outdated=LIBYEARS", Float, "Exit 1 if any gem exceeds LIBYEARS behind latest") do |value|
115
+ StillActive.config { |config| config.fail_if_outdated = value }
107
116
  end
108
117
  end
109
118
 
@@ -16,7 +16,10 @@ module StillActive
16
16
  match = url&.match(REPO_REGEX)
17
17
  return { source: :unhandled, owner: nil, name: nil } unless match
18
18
 
19
- { url: match[1], source: match[2].to_sym, owner: match[3], name: match[4] }
19
+ url = match[1].delete_suffix(".git")
20
+ name = match[4].delete_suffix(".git")
21
+
22
+ { url: url, source: match[2].to_sym, owner: match[3], name: name }
20
23
  end
21
24
  end
22
25
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module StillActive
4
- VERSION = "1.1.0"
4
+ VERSION = "1.2.0"
5
5
  end
@@ -3,7 +3,6 @@
3
3
  require_relative "deps_dev_client"
4
4
  require_relative "gitlab_client"
5
5
  require_relative "repository"
6
- require_relative "../helpers/health_score_helper"
7
6
  require_relative "../helpers/libyear_helper"
8
7
  require_relative "../helpers/ruby_helper"
9
8
  require_relative "../helpers/version_helper"
@@ -61,7 +60,7 @@ module StillActive
61
60
 
62
61
  case source_type
63
62
  when :path, :git
64
- gem_info_non_rubygems(gem_name: gem_name, result_object: result_object)
63
+ gem_info_non_rubygems(gem_name: gem_name, gem_version: gem_version, result_object: result_object, source_uri: source_uri)
65
64
  else
66
65
  gem_info_rubygems(
67
66
  gem_name: gem_name,
@@ -70,8 +69,6 @@ module StillActive
70
69
  source_uri: source_uri,
71
70
  )
72
71
  end
73
-
74
- result_object[gem_name][:health_score] = HealthScoreHelper.gem_score(result_object[gem_name])
75
72
  end
76
73
 
77
74
  def gem_info_rubygems(gem_name:, gem_version:, result_object:, source_uri:)
@@ -82,7 +79,7 @@ module StillActive
82
79
  repository_owner: repo_info[:owner],
83
80
  repository_name: repo_info[:name],
84
81
  )
85
- archived = repo_archived?(
82
+ archived = repo_archived(
86
83
  source: repo_info[:source],
87
84
  repository_owner: repo_info[:owner],
88
85
  repository_name: repo_info[:name],
@@ -129,15 +126,19 @@ module StillActive
129
126
  end
130
127
  end
131
128
 
132
- def gem_info_non_rubygems(gem_name:, result_object:)
133
- repo_info = repository_info_from_installed_gem(gem_name: gem_name)
129
+ def gem_info_non_rubygems(gem_name:, gem_version:, result_object:, source_uri: nil)
130
+ repo_info = repository_info_for_non_rubygems(gem_name: gem_name, source_uri: source_uri)
134
131
  source, owner, name = repo_info.values_at(:source, :owner, :name)
132
+ deps_dev = gem_version ? fetch_deps_dev_info(gem_name: gem_name, version: gem_version) : {}
133
+
134
+ # Fall back to repo-derived project_id for scorecard when deps.dev doesn't have the version
135
+ deps_dev[:scorecard_score] ||= DepsDevClient.project_scorecard(project_id: repo_info[:project_id])&.dig(:score)
135
136
 
136
137
  result_object[gem_name].merge!({
137
138
  repository_url: repo_info[:url],
138
139
  last_commit_date: last_commit_date(source:, repository_owner: owner, repository_name: name),
139
- archived: repo_archived?(source:, repository_owner: owner, repository_name: name),
140
- scorecard_score: DepsDevClient.project_scorecard(project_id: repo_info[:project_id])&.dig(:score),
140
+ archived: repo_archived(source:, repository_owner: owner, repository_name: name),
141
+ **deps_dev,
141
142
  })
142
143
  end
143
144
 
@@ -161,10 +162,15 @@ module StillActive
161
162
  end
162
163
  rescue Gems::NotFound
163
164
  []
165
+ rescue Errno::ECONNRESET, Errno::ECONNREFUSED, Net::OpenTimeout, Net::ReadTimeout, SocketError => e
166
+ $stderr.puts("warning: rubygems.org versions lookup failed for #{gem_name}: #{e.class} (#{e.message})")
167
+ []
164
168
  end
165
169
 
166
170
  def github_packages_uri?(uri)
167
- uri.is_a?(String) && uri.include?("rubygems.pkg.github.com")
171
+ uri.is_a?(String) && URI(uri).host == "rubygems.pkg.github.com"
172
+ rescue URI::InvalidURIError
173
+ false
168
174
  end
169
175
 
170
176
  def fetch_github_packages_versions(gem_name:, source_uri:)
@@ -176,6 +182,17 @@ module StillActive
176
182
  HttpHelper.get_json(base, path, headers: headers) || []
177
183
  end
178
184
 
185
+ def repository_info_for_non_rubygems(gem_name:, source_uri: nil)
186
+ valid_repository_url =
187
+ [source_uri, *installed_gem_urls(gem_name: gem_name)].find { |url| Repository.valid?(url: url) }
188
+ repo = Repository.url_with_owner_and_name(url: valid_repository_url)
189
+ project_id = if repo[:url]
190
+ host = repo[:source] == :gitlab ? "gitlab.com" : "github.com"
191
+ "#{host}/#{repo[:owner]}/#{repo[:name]}"
192
+ end
193
+ repo.merge(project_id: project_id)
194
+ end
195
+
179
196
  def repository_info_from_installed_gem(gem_name:)
180
197
  valid_repository_url =
181
198
  installed_gem_urls(gem_name: gem_name).find { |url| Repository.valid?(url: url) }
@@ -223,13 +240,13 @@ module StillActive
223
240
  []
224
241
  end
225
242
 
226
- def repo_archived?(source:, repository_owner:, repository_name:)
243
+ def repo_archived(source:, repository_owner:, repository_name:)
227
244
  case source
228
245
  when :github
229
246
  repo = StillActive.config.github_client.repository("#{repository_owner}/#{repository_name}")
230
247
  repo&.archived
231
248
  when :gitlab
232
- GitlabClient.archived?(owner: repository_owner, name: repository_name)
249
+ GitlabClient.archived(owner: repository_owner, name: repository_name)
233
250
  end
234
251
  rescue Octokit::Error, Faraday::Error => e
235
252
  $stderr.puts("warning: archived check failed for #{repository_owner}/#{repository_name}: #{e.class}")
data/still_active.gemspec CHANGED
@@ -12,10 +12,10 @@ Gem::Specification.new do |spec|
12
12
  spec.description = "Analyses your Gemfile for dependency health: checks if gems are actively maintained " \
13
13
  "(last commit dates via GitHub and GitLab, release dates), outdated versions, archived repos, " \
14
14
  "OpenSSF Scorecard security scores, known vulnerabilities via deps.dev, and libyear drift. " \
15
- "Composite health score (0-100) per gem. Ruby version freshness with EOL detection. " \
15
+ "Ruby version freshness with EOL detection. " \
16
16
  "Handles rubygems, git, path, and GitHub Packages sources. " \
17
17
  "Outputs coloured terminal tables, markdown, or JSON. " \
18
- "CI quality gates with --fail-if-critical, --fail-if-warning, --fail-below-score, and --ignore. " \
18
+ "CI quality gates with --fail-if-critical, --fail-if-warning, --fail-if-vulnerable, --fail-if-outdated, and --ignore. " \
19
19
  "A comprehensive alternative to running bundle outdated, bundler-audit, and libyear-bundler separately."
20
20
  spec.homepage = "https://github.com/SeanLF/still_active"
21
21
  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.1.0
4
+ version: 1.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sean Floyd
@@ -166,11 +166,11 @@ dependencies:
166
166
  description: 'Analyses your Gemfile for dependency health: checks if gems are actively
167
167
  maintained (last commit dates via GitHub and GitLab, release dates), outdated versions,
168
168
  archived repos, OpenSSF Scorecard security scores, known vulnerabilities via deps.dev,
169
- and libyear drift. Composite health score (0-100) per gem. Ruby version freshness
170
- with EOL detection. Handles rubygems, git, path, and GitHub Packages sources. Outputs
171
- coloured terminal tables, markdown, or JSON. CI quality gates with --fail-if-critical,
172
- --fail-if-warning, --fail-below-score, and --ignore. A comprehensive alternative
173
- to running bundle outdated, bundler-audit, and libyear-bundler separately.'
169
+ and libyear drift. Ruby version freshness with EOL detection. Handles rubygems,
170
+ git, path, and GitHub Packages sources. Outputs coloured terminal tables, markdown,
171
+ or JSON. CI quality gates with --fail-if-critical, --fail-if-warning, --fail-if-vulnerable,
172
+ --fail-if-outdated, and --ignore. A comprehensive alternative to running bundle
173
+ outdated, bundler-audit, and libyear-bundler separately.'
174
174
  email:
175
175
  - contact@seanfloyd.dev
176
176
  executables:
@@ -186,7 +186,6 @@ files:
186
186
  - lib/helpers/ansi_helper.rb
187
187
  - lib/helpers/bundler_helper.rb
188
188
  - lib/helpers/emoji_helper.rb
189
- - lib/helpers/health_score_helper.rb
190
189
  - lib/helpers/http_helper.rb
191
190
  - lib/helpers/libyear_helper.rb
192
191
  - lib/helpers/markdown_helper.rb
@@ -1,81 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "activity_helper"
4
-
5
- module StillActive
6
- module HealthScoreHelper
7
- extend self
8
-
9
- WEIGHTS = {
10
- version_freshness: 30,
11
- activity: 25,
12
- scorecard: 20,
13
- vulnerabilities: 25,
14
- }.freeze
15
-
16
- def gem_score(gem_data)
17
- components = {
18
- version_freshness: version_freshness_score(gem_data),
19
- activity: activity_score(gem_data),
20
- scorecard: scorecard_score(gem_data),
21
- vulnerabilities: vulnerability_score(gem_data),
22
- }
23
-
24
- available = components.compact
25
- return if available.empty?
26
-
27
- total_weight = available.keys.sum { |k| WEIGHTS[k] }
28
- weighted_sum = available.sum { |k, v| WEIGHTS[k] * v }
29
- (weighted_sum.to_f / total_weight).round
30
- end
31
-
32
- def system_average(result)
33
- scores = result.each_value.filter_map { |d| d[:health_score] }
34
- return if scores.empty?
35
-
36
- (scores.sum.to_f / scores.size).round
37
- end
38
-
39
- private
40
-
41
- def version_freshness_score(gem_data)
42
- return 0 if gem_data[:version_yanked]
43
-
44
- libyear = gem_data[:libyear]
45
- return if libyear.nil?
46
-
47
- [100 - (libyear * 20), 0].max.round
48
- end
49
-
50
- def activity_score(gem_data)
51
- return 0 if gem_data[:archived]
52
-
53
- level = ActivityHelper.activity_level(gem_data)
54
- case level
55
- when :ok then 100
56
- when :stale then 40
57
- when :critical then 10
58
- when :unknown then nil
59
- end
60
- end
61
-
62
- def scorecard_score(gem_data)
63
- score = gem_data[:scorecard_score]
64
- return if score.nil?
65
-
66
- (score * 10).round
67
- end
68
-
69
- def vulnerability_score(gem_data)
70
- count = gem_data[:vulnerability_count]
71
- return if count.nil?
72
-
73
- case count
74
- when 0 then 100
75
- when 1 then 40
76
- when 2 then 20
77
- else 0
78
- end
79
- end
80
- end
81
- end