still_active 1.4.2 → 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: 32a0544a8668d86b73edc4296a246b875e3c5bfbc5016544ea2b1133e1332bb8
4
- data.tar.gz: bdbbaa17281dac2425cbe96a386164ae608566b3a4dd594e7e8d3d262a10f206
3
+ metadata.gz: 6e5e5e599cdd630ec6e916800c4abd7df4a3c9d37e24122e80d8859accb1c4e3
4
+ data.tar.gz: 0cb0dfd2c761612f665a9d2f4e0ba20f47bf8b082c42281af09b8429335aa2a9
5
5
  SHA512:
6
- metadata.gz: a63140b613dc6eb0f7fe618a4845db833524993c00ab6773699dbbd150fead81ff5f74fad283444d6d84d5aa07ef79f8cba7ab055ae3e81d720bcbeaee52f800
7
- data.tar.gz: 00f898a6edefc7405e4e5d9a35b37c803b185b1e4e7edcbb76c8faf7eb9e93ef3e18660b1719b9db5b6d9961a4300dc1c3b017cd366bb195f6ea3e33b0f584bc
6
+ metadata.gz: 571797c9863bf6597e7c9474e8cf718019925d37d163002e9d594b137458ca0a6be155dfb7a5069777afe83963fb4dc595026a69c8237ce1aa7d024eb254be99
7
+ data.tar.gz: 4e7ba6a7777973b6fe7f1fa54fc1fad00a06639da18944f0bc89befd6a3e794b1d55eeaca62f33b193bebb54cdb2d6d85c63ae48a73adc722e58a3f194c0aeae
data/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
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
+
13
+ ## [1.5.0] - 2026-05-23
14
+
15
+ ### Added
16
+
17
+ - `--cyclonedx[=PATH]` emits a CycloneDX SBOM (stdout by default, or to a file) so the dependency graph plus still_active's signals flow into Trivy / Dependency-Track / Snyk. Emits **1.6 by default** — the version mainstream consumers ingest today (`cyclonedx-core-java` / Dependency-Track and `cyclonedx-go` / Trivy both cap at 1.6 as of 2026) — with `--cyclonedx-version=1.7` to opt into the latest. Gem name/version/purl/licenses map to native fields; maintenance signals (archived, OpenSSF score, libyear, last commit, yanked) ride in `still_active:`-namespaced `properties`; vulnerabilities map to the top-level `vulnerabilities[]`. The `serialNumber` is content-derived (two SBOMs of the same lockfile are byte-identical apart from the generation timestamp), so SBOMs diff cleanly.
18
+ - Dependabot/Renovate awareness: when a run is detected as bot-authored (primarily via the PR author in the GitHub event payload — `pull_request.user.login`, the same authoritative signal `dependabot/fetch-metadata` uses, which unlike `GITHUB_ACTOR` survives a human re-running the workflow — falling back to `GITHUB_ACTOR`, a `dependabot/`/`renovate/` branch, or the commit subject including Dependabot's default unprefixed `Bump X from Y to Z`), output leads with a narrative header (markdown/terminal/baseline-diff: "Dependabot bump: rack 2.0.0 → 2.0.6") and JSON gains a top-level additive `pr_context` (`{ bot, bumps: [{ gem, from, to }] }`). Bump extraction tolerates any configured `commit-message.prefix`/scope (`chore(deps):`, `deps:`, …) once the bot is confirmed, while detection stays conservative to avoid false positives on human commits. Best-effort: false negatives lose only the narrative, never a finding; SARIF is unaffected. See `docs/schema.md`.
19
+ - A warning is emitted when mutually-exclusive output flags are combined (`--baseline`/`--sarif`/`--cyclonedx`), naming which one wins, and when `--cyclonedx-version` is set without `--cyclonedx`.
20
+ - Dual-source vulnerability data: when `bundler-audit` is installed (with a current `bundle audit update` checkout), still_active reads the `rubysec/ruby-advisory-db` advisories through bundler-audit's own loader and merges them with deps.dev results, deduplicating on shared identifiers. Each advisory carries a `source` field (`deps.dev`, `ruby-advisory-db`, or `merged`); deps.dev is preferred for CVSS/title/vector and ruby-advisory-db fills gaps. Opt-in by composition — no second source unless `bundler-audit` is present; falls back silently to deps.dev only otherwise (with a one-line hint to run `bundle audit update`). Closes the "why do bundler-audit and still_active disagree?" gap. See `docs/schema.md` and `docs/rules.md` (SA003).
21
+ - Gem license surfaced from the RubyGems versions payload we already fetch (no extra request). Shows as a `License` column in terminal and markdown output and as an additive `license` field (SPDX identifier, comma-joined when a gem declares more than one) on the JSON per-gem record. `nil`/`-` for git/path sources where no RubyGems metadata exists. See `docs/schema.md`. Read-only metadata only — license *policy* (allow/deny gating) stays the domain of `license_finder`.
22
+
3
23
  ## [1.4.2] - 2026-05-22
4
24
 
5
25
  ### Fixed
data/README.md CHANGED
@@ -4,6 +4,8 @@
4
4
 
5
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
+ Findings ship as **terminal / markdown / JSON / SARIF / CycloneDX** — SARIF lands in your GitHub Security tab and as inline PR annotations on `Gemfile.lock`; CycloneDX feeds Trivy / Dependency-Track / Snyk. PR mode (`--baseline=FILE`) reports only what got worse since main, so reviewers see one line ("`vcr` newly archived") instead of an absolute snapshot of every dep.
8
+
7
9
  [![Gem Version](https://badge.fury.io/rb/still_active.svg)](https://badge.fury.io/rb/still_active)
8
10
  [![GitHub Action](https://img.shields.io/badge/Marketplace-still__active--action-2ea44f?logo=github)](https://github.com/marketplace/actions/still_active)
9
11
  ![Code Quality analysis](https://github.com/SeanLF/still_active/actions/workflows/codeql-analysis.yml/badge.svg)
@@ -11,15 +13,15 @@
11
13
  ![Rubocop analysis](https://github.com/SeanLF/still_active/actions/workflows/rubocop-analysis.yml/badge.svg)
12
14
 
13
15
  ```
14
- Name Version Activity OpenSSF Vulns
15
- ───────────────────────────────────────────────────────────────────
16
- async 2.36.0 (latest) ok 7.1/10 0
17
- backbone-rails 1.2.3 (latest) archived 3.6/10 0
18
- bootstrap-slider-rails 9.8.0 (latest) critical - 0
19
- gitlab-markup 2.0.0 (latest) ok - 0
20
- local_gem 0.1.0 (path) - - 0
21
- nested_form 0.3.2 (git) archived 3.3/10 0
22
- remotipart 1.4.4 (git) critical 3.1/10 0
16
+ Name Version Activity OpenSSF Vulns License
17
+ ──────────────────────────────────────────────────────────────────────────────
18
+ async 2.36.0 (latest) ok 7.1/10 0 MIT
19
+ backbone-rails 1.2.3 (latest) archived 3.6/10 0 MIT
20
+ bootstrap-slider-rails 9.8.0 (latest) critical - 0 MIT
21
+ gitlab-markup 2.0.0 (latest) ok - 0 MIT
22
+ local_gem 0.1.0 (path) - - 0 -
23
+ nested_form 0.3.2 (git) archived 3.3/10 0 MIT
24
+ remotipart 1.4.4 (git) critical 3.1/10 0 MIT
23
25
 
24
26
  7 gems: 4 up to date, 0 outdated · 2 active, 2 stale, 2 archived · 0 vulnerabilities
25
27
  Ruby 4.0.1 (latest)
@@ -32,7 +34,7 @@ Ruby 4.0.1 (latest)
32
34
  | | `bundle outdated` | `bundler-audit` | `libyear-bundler` | **`still_active`** |
33
35
  | ---------------------------- | ----------------- | ---------------------- | ----------------- | ------------------------ |
34
36
  | Outdated versions | Yes | - | Yes | Yes |
35
- | Known vulnerabilities (CVEs) | - | Yes (ruby-advisory-db) | - | Yes (deps.dev) |
37
+ | Known vulnerabilities (CVEs) | - | Yes (ruby-advisory-db) | - | Yes (deps.dev + ruby-advisory-db) |
36
38
  | Libyear drift | - | - | Yes | Yes |
37
39
  | **Last commit activity** | - | - | - | **Yes** |
38
40
  | **Archived repo detection** | - | - | - | **Yes** |
@@ -41,9 +43,9 @@ Ruby 4.0.1 (latest)
41
43
  | **Ruby version freshness** | - | - | - | **Yes** (EOL + libyear) |
42
44
  | GitLab support | - | - | - | Yes |
43
45
  | CI quality gates | - | Exit code | - | Yes (4 flags) |
44
- | Output formats | Text | Text | Text | Terminal, JSON, Markdown |
46
+ | Output formats | Text | Text | Text | Terminal, JSON, Markdown, SARIF, CycloneDX |
45
47
 
46
- The bolded rows are the gap `still_active` fills: nobody else answers "is the maintainer still around?" The CVE column is worth a closer look: `bundler-audit` and `still_active` use **different data sources** (`ruby-advisory-db` vs `deps.dev`), so coverage isn't identical. If you care about CVEs in CI, keep running `bundler-audit` alongside `still_active`.
48
+ The bolded rows are the gap `still_active` fills: nobody else answers "is the maintainer still around?" The CVE column is worth a closer look: `bundler-audit` reads `ruby-advisory-db` and `still_active` reads `deps.dev`, which sometimes diverge. **If `bundler-audit` is installed alongside `still_active`, we read its `ruby-advisory-db` checkout too and merge the results** (deduplicated, each advisory tagged with its `source`) so running both no longer means reconciling two different vuln counts by hand.
47
49
 
48
50
  ## Installation
49
51
 
@@ -51,6 +53,8 @@ The bolded rows are the gap `still_active` fills: nobody else answers "is the ma
51
53
  gem install still_active
52
54
  ```
53
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
+
54
58
  ## Quick Start
55
59
 
56
60
  ```bash
@@ -97,7 +101,10 @@ Usage: still_active [options]
97
101
  --terminal Coloured terminal output (default in TTY)
98
102
  --markdown Markdown table output
99
103
  --json JSON output (default when piped)
104
+ --alternatives Suggest maintained alternatives (Ruby Toolbox leads) for archived/critical gems
100
105
  --sarif[=PATH] SARIF 2.1.0 output for GitHub Code Scanning
106
+ --cyclonedx[=PATH] CycloneDX SBOM output (stdout, or a file path)
107
+ --cyclonedx-version=VERSION CycloneDX spec version: 1.6 (default) or 1.7
101
108
  --baseline=PATH Compare current state to baseline JSON; emit markdown deltas
102
109
  --github-oauth-token=TOKEN GitHub OAuth token to make API calls
103
110
  --gitlab-token=TOKEN GitLab personal access token for API calls
@@ -141,6 +148,7 @@ still_active --json --gemfile=spec/still_active/edge_case_gemfile/Gemfile
141
148
  "archived": false,
142
149
  "scorecard_score": 7.1,
143
150
  "vulnerability_count": 0,
151
+ "license": "MIT",
144
152
  "libyear": 0.0
145
153
  },
146
154
  "nested_form": {
@@ -174,15 +182,25 @@ still_active --json --gemfile=spec/still_active/edge_case_gemfile/Gemfile
174
182
  still_active --markdown
175
183
  ```
176
184
 
177
- | activity | up to date? | OpenSSF | vulns | name | version used | latest version | latest pre-release | last commit | libyear |
178
- | -------- | ----------- | ------- | ----- | ------------------------------------------------------------ | -------------------------------------------------------------------------- | -------------------------------------------------------------------------- | ------------------ | ----------------------------------------------------- | ------- |
179
- | | ✅ | 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 |
180
- | 🚩 | ✅ | 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 |
181
- | ❓ | ❓ | ❓ | ✅ | local_gem | 0.1.0 (path) | ❓ | ❓ | ❓ | - |
182
- | 🚩 | ❓ | 3.3/10 | ✅ | [nested_form](https://github.com/ryanb/nested_form) | 0.3.2 (git) | ❓ | ❓ | [2021/12](https://github.com/ryanb/nested_form) | - |
185
+ | activity | up to date? | OpenSSF | vulns | name | version used | latest version | latest pre-release | last commit | libyear | license |
186
+ | -------- | ----------- | ------- | ----- | ------------------------------------------------------------ | -------------------------------------------------------------------------- | -------------------------------------------------------------------------- | ------------------ | ----------------------------------------------------- | ------- | ------- |
187
+ | | ✅ | 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 | MIT |
188
+ | 🚩 | ✅ | 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 | MIT |
189
+ | ❓ | ❓ | ❓ | ✅ | local_gem | 0.1.0 (path) | ❓ | ❓ | ❓ | - | - |
190
+ | 🚩 | ❓ | 3.3/10 | ✅ | [nested_form](https://github.com/ryanb/nested_form) | 0.3.2 (git) | ❓ | ❓ | [2021/12](https://github.com/ryanb/nested_form) | - | MIT |
183
191
 
184
192
  **Ruby 4.0.1** (latest) ✅
185
193
 
194
+ **CycloneDX** -- a standards-track SBOM so your dependency graph and still_active's signals flow into Trivy, Dependency-Track, or Snyk:
195
+
196
+ ```bash
197
+ still_active --cyclonedx # CycloneDX 1.6 to stdout
198
+ still_active --cyclonedx=sbom.json # write to a file
199
+ still_active --cyclonedx --cyclonedx-version=1.7
200
+ ```
201
+
202
+ Emits **1.6 by default** — the version mainstream consumers ingest today (`cyclonedx-core-java`/Dependency-Track and `cyclonedx-go`/Trivy both cap at 1.6 as of 2026); `--cyclonedx-version=1.7` opts into the latest. Gem name/version/`purl`/licenses and vulnerabilities map to native CycloneDX fields; maintenance signals with no native home (archived, OpenSSF score, libyear, last commit, yanked) ride in `still_active:`-namespaced `properties`. The `serialNumber` is content-derived, so two SBOMs of the same lockfile differ only by their generation timestamp.
203
+
186
204
  ### SARIF output (GitHub Code Scanning)
187
205
 
188
206
  Emit findings as SARIF 2.1.0 — they show up in the GitHub Security tab and as inline annotations on `Gemfile.lock` in pull requests.
@@ -245,6 +263,53 @@ In CI, capture a baseline on main and compare on PR branches. Exits 1 if any reg
245
263
 
246
264
  The diff supersedes `--sarif`, `--terminal`, `--markdown`, and `--json` when set.
247
265
 
266
+ When a run is detected as Dependabot- or Renovate-authored (via `GITHUB_ACTOR`, a `dependabot/`/`renovate/` branch, or the commit subject), the report leads with a one-line narrative — "Dependabot bump: `rack` 2.0.0 → 2.0.6" — and `--json` gains a top-level `pr_context`. Detection is best-effort and conservative: it never produces a false positive on an ordinary commit, and a miss costs only the narrative line.
267
+
268
+ ### Alongside `dependency-review-action`
269
+
270
+ GitHub's first-party [`dependency-review-action`](https://github.com/actions/dependency-review-action) runs server-side on PRs and surfaces **vulnerabilities, licenses, and OpenSSF Scorecard** scores from GitHub's dependency-graph diff. It does not surface maintenance signals — last-commit activity, archived repos, libyear, Ruby EOL, or yanked versions — and is GitHub.com / GHES only. `still_active` is the complement, not a replacement:
271
+
272
+ | | `dependency-review-action` | `still_active` |
273
+ | ---------------------------- | ---------------------------------- | ------------------------------------------- |
274
+ | Platform | GitHub.com / GHES only | Any CI |
275
+ | Languages | Multi (GitHub dep graph) | Ruby |
276
+ | Vulnerabilities | GHSA | deps.dev + ruby-advisory-db (merged) |
277
+ | Licenses | Yes (allow/deny gating) | Surfaced (no gating) |
278
+ | OpenSSF Scorecard | Yes (display) | Yes (display + threshold) |
279
+ | **Last-commit activity** | - | **Yes** |
280
+ | **Archived repo detection** | - | **Yes** |
281
+ | **Libyear drift** | - | **Yes** |
282
+ | **Ruby EOL detection** | - | **Yes** |
283
+ | **Yanked version detection** | - | **Yes** |
284
+ | Diff vs base | Native (GitHub API) | `--baseline=FILE` |
285
+ | Output | Inline PR annotations | Terminal / Markdown / JSON / SARIF / CycloneDX |
286
+
287
+ Run both: let `dependency-review-action` gate CVEs and licenses, and `still_active` add the maintenance lens on the same PR.
288
+
289
+ ```yaml
290
+ on: pull_request
291
+
292
+ jobs:
293
+ dependency-review:
294
+ runs-on: ubuntu-latest
295
+ steps:
296
+ - uses: actions/checkout@v4
297
+ - uses: actions/dependency-review-action@v4
298
+ with:
299
+ fail-on-severity: high
300
+ show-openssf-scorecard: true
301
+
302
+ maintenance-review:
303
+ runs-on: ubuntu-latest
304
+ steps:
305
+ - uses: actions/checkout@v4
306
+ - uses: ruby/setup-ruby@v1
307
+ with: { ruby-version: ".ruby-version", bundler-cache: true }
308
+ - uses: SeanLF/still_active-action@v0
309
+ with:
310
+ fail-if-critical: true
311
+ ```
312
+
248
313
  ### CI quality gating
249
314
 
250
315
  Use exit-code flags to fail CI pipelines based on dependency status:
@@ -277,12 +342,28 @@ Activity is determined by the most recent signal across last commit date, latest
277
342
  - **stale**: last activity between 1 and 3 years ago (configurable with `--warning-range-end`)
278
343
  - **critical**: last activity over 3 years ago
279
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
+
280
359
  ### Data sources
281
360
 
282
- - **Versions and release dates** from [RubyGems.org](https://rubygems.org) or [GitHub Packages](https://docs.github.com/en/packages)
361
+ - **Versions, release dates, and licenses** from [RubyGems.org](https://rubygems.org) or [GitHub Packages](https://docs.github.com/en/packages)
283
362
  - **Last commit date and archived status** from the [GitHub](https://docs.github.com/en/rest) or [GitLab](https://docs.gitlab.com/ee/api/) API
284
363
  - **OpenSSF Scorecard**, **vulnerability counts**, and **CVSS severity** from Google's [deps.dev](https://deps.dev) API
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)
285
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
286
367
 
287
368
  ### Configuration defaults
288
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,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "open3"
5
+
6
+ module StillActive
7
+ # Best-effort detection of a Dependabot/Renovate-authored run, so --baseline
8
+ # reports can lead with a narrative ("Dependabot bump: rack 2.0.0 → 2.0.6")
9
+ # instead of an unattributed list. Detection is heuristic: false negatives are
10
+ # fine (we just lose the narrative), false positives are not, so the subject
11
+ # patterns are anchored and require the literal bump/update keyword.
12
+ module BotContext
13
+ extend self
14
+
15
+ # Dependabot's *default* subject is "Bump X from Y to Z" (capitalized, no
16
+ # prefix). The `from … to …` skeleton rarely occurs in human commits, so it
17
+ # is safe unprefixed. The conventional-commit prefix only appears when configured.
18
+ DEPENDABOT_SUBJECT = /\A(?:build\(deps(?:-dev)?\):\s*)?bump (\S+) from (\S+) to (\S+)/i
19
+
20
+ # Renovate's default is "Update dependency X to vN.…" — note the **required**
21
+ # `v`+digit version. Matching a bare "to <word>" would false-positive on ordinary
22
+ # commits ("Update README to mention SARIF"), so we anchor on the v-prefixed
23
+ # version. False negatives (a no-`v` Renovate config) are acceptable; false
24
+ # positives are not. The `v` is consumed, so the captured version excludes it.
25
+ RENOVATE_SUBJECT = /\A(?:(?:chore|fix|build)\(deps(?:-dev)?\):\s*)?update (?:dependency )?(\S+) to v(\d[\w.\-]*)/i
26
+
27
+ # Unanchored variants used only to EXTRACT the bump *after* a bot is already
28
+ # confirmed (via GITHUB_ACTOR / branch / the anchored subject above). Because
29
+ # detection has already happened, these can ignore whatever commit-message
30
+ # prefix or scope Dependabot/Renovate is configured with and just find the
31
+ # "bump X from Y to Z" / "update X to vN" skeleton anywhere in the subject.
32
+ DEPENDABOT_BUMP = /bump (\S+) from (\S+) to (\S+)/i
33
+ RENOVATE_BUMP = /update (?:dependency )?(\S+) to v(\d[\w.\-]*)/i
34
+
35
+ # Returns { bot: "dependabot" | "renovate", bumps: [{ gem:, from:, to: }] }
36
+ # or nil when no bot signal is present. `bumps` is parsed from the head
37
+ # commit subject; a grouped or unparseable subject yields an empty list.
38
+ def detect(env: ENV, head_subject: head_commit_subject)
39
+ bot = detect_bot(env: env, head_subject: head_subject)
40
+ return if bot.nil?
41
+
42
+ { bot: bot, bumps: bumps_from(bot, head_subject) }
43
+ end
44
+
45
+ # A one-line, format-agnostic narrative for the detected context.
46
+ def summary(context)
47
+ label = context[:bot] == "renovate" ? "Renovate" : "Dependabot"
48
+ bumps = context[:bumps]
49
+
50
+ case bumps.length
51
+ when 0 then "#{label} dependency update"
52
+ when 1 then single_bump_summary(label, bumps.first)
53
+ else "#{label}: #{bumps.length} dependency updates"
54
+ end
55
+ end
56
+
57
+ private
58
+
59
+ def detect_bot(env:, head_subject:)
60
+ # The PR author from the event payload is the authoritative signal — it's
61
+ # what `dependabot/fetch-metadata` keys on, and unlike GITHUB_ACTOR it
62
+ # doesn't flip to a human who re-runs the workflow or pushes to the branch.
63
+ login = pr_author_login(env)
64
+ return "dependabot" if login == "dependabot[bot]"
65
+ return "renovate" if login == "renovate[bot]"
66
+
67
+ actor = env["GITHUB_ACTOR"]
68
+ return "dependabot" if actor == "dependabot[bot]"
69
+ return "renovate" if actor == "renovate[bot]"
70
+
71
+ ref = env["GITHUB_HEAD_REF"] || current_branch
72
+ return "dependabot" if ref&.start_with?("dependabot/")
73
+ return "renovate" if ref&.start_with?("renovate/", "renovate-bot/")
74
+
75
+ return "dependabot" if head_subject&.match?(DEPENDABOT_SUBJECT)
76
+ return "renovate" if head_subject&.match?(RENOVATE_SUBJECT)
77
+
78
+ nil
79
+ end
80
+
81
+ # Reads pull_request.user.login from the GitHub Actions event payload
82
+ # (GITHUB_EVENT_PATH). Returns nil off Actions, on non-PR events, or if the
83
+ # file is missing/unreadable/malformed — all of which just fall through to
84
+ # the weaker signals. TypeError covers a payload that parses but has the
85
+ # wrong shape (e.g. a top-level array, or pull_request/user not a Hash);
86
+ # this method must never raise, since detect runs unguarded and a cosmetic
87
+ # narrative must not be able to abort the audit.
88
+ def pr_author_login(env)
89
+ path = env["GITHUB_EVENT_PATH"]
90
+ return if path.nil? || !File.file?(path)
91
+
92
+ JSON.parse(File.read(path)).dig("pull_request", "user", "login")
93
+ rescue JSON::ParserError, SystemCallError, TypeError
94
+ nil
95
+ end
96
+
97
+ def bumps_from(bot, subject)
98
+ return [] if subject.nil?
99
+
100
+ if bot == "dependabot" && (match = subject.match(DEPENDABOT_BUMP))
101
+ [{ gem: match[1], from: match[2], to: match[3] }]
102
+ elsif bot == "renovate" && (match = subject.match(RENOVATE_BUMP))
103
+ [{ gem: match[1], from: nil, to: match[2] }]
104
+ else
105
+ []
106
+ end
107
+ end
108
+
109
+ def single_bump_summary(label, bump)
110
+ arrow = bump[:from] ? "#{bump[:from]} → #{bump[:to]}" : "→ #{bump[:to]}"
111
+ verb = label == "Renovate" ? "update" : "bump"
112
+ "#{label} #{verb}: #{bump[:gem]} #{arrow}"
113
+ end
114
+
115
+ # SystemCallError (not just Errno::ENOENT) so a git that's missing *or*
116
+ # unlaunchable can't crash a run over a cosmetic narrative. git *logic*
117
+ # failures surface as a non-zero status, not an exception, and yield nil.
118
+ def current_branch
119
+ out, _, status = Open3.capture3("git", "rev-parse", "--abbrev-ref", "HEAD")
120
+ status.success? ? out.strip : nil
121
+ rescue SystemCallError
122
+ nil
123
+ end
124
+
125
+ def head_commit_subject
126
+ out, _, status = Open3.capture3("git", "log", "-1", "--pretty=%s")
127
+ status.success? ? out.strip : nil
128
+ rescue SystemCallError
129
+ nil
130
+ end
131
+ end
132
+ 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