still_active 3.0.0.rc1 → 3.0.0.rc3

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.
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../helpers/http_helper"
4
+ require_relative "../helpers/version_helper"
4
5
 
5
6
  module StillActive
6
7
  module DepsDevClient
@@ -61,12 +62,36 @@ module StillActive
61
62
  versions = body["versions"]
62
63
  return unless versions.is_a?(Array) && !versions.empty?
63
64
 
64
- entry = versions.find { |v| v.is_a?(Hash) && v["isDefault"] } || newest_version(versions)
65
+ # deps.dev's `isDefault` is NOT reliably the latest release: cargo/wasi flags
66
+ # 0.7.0 (2019) while 0.14.7 (2025) ships, and pypi/httpx flags a 1.0.0.dev3
67
+ # prerelease over the 0.28.1 stable. Trusting it reads an active package as
68
+ # years-stale (a false SA002 "abandoned") and paints a downgrade as an
69
+ # upgrade. So rank by version and take the newest STABLE release; fall back to
70
+ # isDefault, then newest-by-date, only when no stable version parses (a
71
+ # genuinely prerelease-only package still reads active, not dormant).
72
+ entry = latest_stable_version(versions) ||
73
+ versions.find { |v| v.is_a?(Hash) && v["isDefault"] } ||
74
+ newest_version(versions)
65
75
  return if entry.nil?
66
76
 
67
77
  { version: entry.dig("versionKey", "version"), published_at: entry["publishedAt"] }
68
78
  end
69
79
 
80
+ # The newest non-prerelease version by version number (not publishedAt: a
81
+ # backported patch on an old line can post-date the latest major). nil when no
82
+ # version parses as a stable release.
83
+ def latest_stable_version(versions)
84
+ versions
85
+ .filter_map do |v|
86
+ next unless v.is_a?(Hash) && !v["isDeprecated"]
87
+
88
+ gem_version = VersionHelper.comparable(v.dig("versionKey", "version"))
89
+ [gem_version, v] if gem_version && !gem_version.prerelease?
90
+ end
91
+ .max_by(&:first)
92
+ &.last
93
+ end
94
+
70
95
  # The package's most recent release date (ISO8601 string), or nil. This is
71
96
  # the cross-ecosystem freshness signal, and deps.dev's publishedAt is more
72
97
  # reliable than ecosyste.ms's latest_release_published_at, which can lag badly
@@ -96,6 +121,21 @@ module StillActive
96
121
  }
97
122
  end
98
123
 
124
+ # The NuGet target framework monikers a version declares (net6.0, net45,
125
+ # netstandard2.0, ...), or [] when unknown. On the NuGet-specific
126
+ # `:requirements` endpoint, not the version endpoint. The language-runtime
127
+ # ceiling (SA009) reads these to detect a package that targets only EOL .NET
128
+ # runtimes. Degrades to [] on 404/failure, never raising.
129
+ def target_frameworks(name:, version:)
130
+ return [] if name.nil? || version.nil?
131
+
132
+ path = "/v3alpha/systems/nuget/packages/#{encode(name)}/versions/#{encode(version)}:requirements"
133
+ body = HttpHelper.get_json(BASE_URI, path)
134
+ return [] if body.nil?
135
+
136
+ Array(body.dig("nuget", "targetFrameworks")).grep(String)
137
+ end
138
+
99
139
  def advisory_detail(advisory_id:)
100
140
  return if advisory_id.nil?
101
141
 
@@ -176,6 +216,14 @@ module StillActive
176
216
  # Trims a repo URL's path to just the project. On GitLab the project path
177
217
  # ends at the "/-/" separator (before tree/blob/etc) and may be nested;
178
218
  # elsewhere it's owner/repo. Drops trailing slashes and a ".git" suffix.
219
+ # github.com top-level paths that are not user/org repositories; deps.dev
220
+ # occasionally returns one as SOURCE_REPO (a github.com/sponsors/<user> funding
221
+ # link). Parsing it as owner/repo yields a 404 and a blank repo cell, so treat a
222
+ # reserved first segment as "no repo" rather than a bogus lookup.
223
+ GITHUB_RESERVED_PATHS = [
224
+ "sponsors", "orgs", "marketplace", "apps", "topics", "settings", "notifications", "about", "pricing", "features", "security", "contact",
225
+ ].freeze
226
+
179
227
  def repo_path_segments(host, segments)
180
228
  segments = segments.reject(&:empty?)
181
229
 
@@ -183,6 +231,8 @@ module StillActive
183
231
  separator = segments.index("-")
184
232
  segments = segments[0...separator] if separator
185
233
  else
234
+ return [] if host.to_s.casecmp?("github.com") && GITHUB_RESERVED_PATHS.include?(segments.first&.downcase)
235
+
186
236
  segments = segments.first(2)
187
237
  end
188
238
 
@@ -196,7 +246,7 @@ module StillActive
196
246
  # is chronological), and a dated release always wins over an undated one.
197
247
  def newest_version(versions)
198
248
  versions
199
- .select { |v| v.is_a?(Hash) && v["publishedAt"] }
249
+ .select { |v| v.is_a?(Hash) && v["publishedAt"] && !v["isDeprecated"] }
200
250
  .max_by { |v| v["publishedAt"].to_s }
201
251
  end
202
252
 
@@ -8,6 +8,7 @@ require_relative "pypi_client"
8
8
  require "time"
9
9
  require_relative "../helpers/activity_helper"
10
10
  require_relative "../helpers/constraint_helper"
11
+ require_relative "../helpers/dotnet_helper"
11
12
  require_relative "../helpers/libyear_helper"
12
13
  require_relative "../helpers/pep440_helper"
13
14
  require_relative "../helpers/runtime_ceiling_helper"
@@ -52,7 +53,7 @@ module StillActive
52
53
  # deps.dev; silence beats a false "holds your tree hostage".
53
54
  FLAT_RESOLUTION_ECOSYSTEMS = [:rubygems, :pypi].freeze
54
55
 
55
- def assess(ecosystem:, name:, version:, constraint_cache: {}, python_range: nil)
56
+ def assess(ecosystem:, name:, version:, constraint_cache: {}, runtime_ranges: {})
56
57
  info = DepsDevClient.version_info(gem_name: name, version: version, system: ecosystem)
57
58
  default = DepsDevClient.default_version_info(name: name, system: ecosystem)
58
59
  # Retry the version lookup once when it came back empty but the package DID
@@ -87,6 +88,10 @@ module StillActive
87
88
  name: name,
88
89
  version_used: version,
89
90
  version_used_release_date: used_release_date,
91
+ # The latest stable version string, so the shared formatters can render
92
+ # the "behind X"/up-to-date delta cross-ecosystem the same way the native
93
+ # path does. nil when deps.dev has no default version for the package.
94
+ latest_version: latest_version,
90
95
  latest_version_release_date: latest_release_date,
91
96
  # libyear parity with the native path: how far behind latest the locked
92
97
  # version is, in release-years. Both dates come from deps.dev responses
@@ -109,7 +114,7 @@ module StillActive
109
114
  }
110
115
  gem_data[:version_unresolved] = true if version_unresolved
111
116
  attach_constraints(gem_data, ecosystem: ecosystem, name: name, version: version, cache: constraint_cache)
112
- attach_language_ceiling(gem_data, ecosystem: ecosystem, name: name, version: version, latest_version: default&.dig(:version), python_range: python_range)
117
+ attach_language_ceiling(gem_data, ecosystem: ecosystem, name: name, version: version, latest_version: default&.dig(:version), runtime_ranges: runtime_ranges)
113
118
  gem_data
114
119
  end
115
120
 
@@ -124,9 +129,17 @@ module StillActive
124
129
  # pypi package; other ecosystems carry no ceiling here rather than a wrong one
125
130
  # (cargo's rust_version is a soft MSRV hint, not an install wall). Best-effort:
126
131
  # a nil range (endoflife feed down) or absent requires_python yields nothing.
127
- def attach_language_ceiling(gem_data, ecosystem:, name:, version:, latest_version:, python_range:)
132
+ def attach_language_ceiling(gem_data, ecosystem:, name:, version:, latest_version:, runtime_ranges:)
133
+ finding =
134
+ case ecosystem
135
+ when :pypi then python_ceiling(name: name, version: version, latest_version: latest_version, python_range: runtime_ranges[:python])
136
+ when :nuget then dotnet_ceiling(name: name, version: version, latest_version: latest_version, dotnet: runtime_ranges[:dotnet], dotnetfx: runtime_ranges[:dotnetfx])
137
+ end
138
+ gem_data[:language_ceiling] = finding if finding
139
+ end
140
+
141
+ def python_ceiling(name:, version:, latest_version:, python_range:)
128
142
  return if python_range.nil?
129
- return unless ecosystem == :pypi
130
143
 
131
144
  requirement = Pep440Helper.to_gem_requirement_string(PypiClient.requires_python(name: name, version: version))
132
145
  finding = requirement && RuntimeCeilingHelper.analyze(requirement: requirement, support_window: python_range)
@@ -134,7 +147,36 @@ module StillActive
134
147
 
135
148
  finding[:runtime] = "Python"
136
149
  finding[:fixed_by_upgrade] = python_ceiling_lifted_by_upgrade?(name: name, version: version, latest_version: latest_version, python_range: python_range)
137
- gem_data[:language_ceiling] = finding
150
+ finding
151
+ end
152
+
153
+ # The .NET ceiling: a NuGet package whose ONLY runtime targets are end-of-life
154
+ # frameworks caps you onto a dead runtime (a restore-time NU1202 wall). Unlike
155
+ # Python's requires_python range, .NET declares a SET of target frameworks;
156
+ # DotnetHelper carries the set logic and the netstandard escape-hatch rule.
157
+ def dotnet_ceiling(name:, version:, latest_version:, dotnet:, dotnetfx:)
158
+ return if dotnet.nil? && dotnetfx.nil?
159
+
160
+ frameworks = DepsDevClient.target_frameworks(name: name, version: version)
161
+ return if frameworks.empty?
162
+
163
+ finding = DotnetHelper.analyze(target_frameworks: frameworks, dotnet: dotnet, dotnetfx: dotnetfx)
164
+ return if finding.nil?
165
+
166
+ finding[:fixed_by_upgrade] = dotnet_ceiling_lifted_by_upgrade?(name: name, version: version, latest_version: latest_version, dotnet: dotnet, dotnetfx: dotnetfx)
167
+ finding
168
+ end
169
+
170
+ # Positive confirmation, like the Python check: only claim an upgrade lifts the
171
+ # ceiling when the latest version's targets ACTUALLY clear it (a fresh fetch),
172
+ # never inferring "safe to bump" from a failed lookup.
173
+ def dotnet_ceiling_lifted_by_upgrade?(name:, version:, latest_version:, dotnet:, dotnetfx:)
174
+ return false if latest_version.nil? || latest_version == version
175
+
176
+ latest_frameworks = DepsDevClient.target_frameworks(name: name, version: latest_version)
177
+ return false if latest_frameworks.empty?
178
+
179
+ DotnetHelper.analyze(target_frameworks: latest_frameworks, dotnet: dotnet, dotnetfx: dotnetfx).nil?
138
180
  end
139
181
 
140
182
  # Does upgrading the package to its latest release lift the cap? Requires
@@ -62,7 +62,7 @@ module StillActive
62
62
  end
63
63
 
64
64
  def add_sbom_option(opts)
65
- opts.on("--sbom=PATH", String, "audit a CycloneDX SBOM cross-ecosystem (npm/pypi/cargo/go/maven/nuget) instead of a Gemfile; JSON output") do |value|
65
+ opts.on("--sbom=PATH", String, "audit a CycloneDX SBOM cross-ecosystem (npm/pypi/cargo/go/maven/nuget) instead of a Gemfile; honours --sarif/--markdown/--terminal/--json") do |value|
66
66
  options[:provided_sbom] = true
67
67
  StillActive.config { |config| config.sbom_path = value }
68
68
  end
@@ -4,6 +4,7 @@ require_relative "ecosystem_lens"
4
4
  require_relative "ceiling_reconciler"
5
5
  require_relative "poison_security_correlator"
6
6
  require_relative "../helpers/python_helper"
7
+ require_relative "../helpers/dotnet_helper"
7
8
  require "async"
8
9
  require "async/barrier"
9
10
  require "async/semaphore"
@@ -32,16 +33,15 @@ module StillActive
32
33
  def call(sbom_result, &on_progress)
33
34
  dependencies = sbom_result.dependencies
34
35
  Async do
35
- # The Python runtime support window, fetched once for the whole SBOM (the
36
- # language-ceiling input for pypi deps). Guarded like the native Ruby path:
37
- # a feed failure degrades to "no ceiling checks", never aborts the audit.
38
- python_range =
39
- begin
40
- PythonHelper.supported_python_range
41
- rescue StandardError => e
42
- $stderr.puts("warning: Python support window lookup failed: #{e.class} (#{e.message}); skipping language-ceiling checks")
43
- nil
44
- end
36
+ # The language-runtime support windows, fetched once for the whole SBOM
37
+ # (pypi reads :python; nuget reads :dotnet/:dotnetfx). Guarded per feed like
38
+ # the native Ruby path: a feed failure degrades to "no ceiling checks" for
39
+ # that runtime, never aborts the audit.
40
+ runtime_ranges = {
41
+ python: safe_support_window("Python") { PythonHelper.supported_python_range },
42
+ dotnet: safe_support_window(".NET") { DotnetHelper.supported_dotnet_range },
43
+ dotnetfx: safe_support_window(".NET Framework") { DotnetHelper.supported_dotnetfx_range },
44
+ }
45
45
  barrier = Async::Barrier.new
46
46
  semaphore = Async::Semaphore.new(StillActive.config.parallelism, parent: barrier)
47
47
  result = {}
@@ -61,7 +61,7 @@ module StillActive
61
61
  # when the SBOM actually marked it (SbomReader leaves the key absent
62
62
  # otherwise): absent stays "unknown", never a false that reads as test-only.
63
63
  result["#{dep[:ecosystem]}/#{dep[:name]}@#{dep[:version]}"] =
64
- EcosystemLens.assess(ecosystem: dep[:ecosystem], name: dep[:name], version: dep[:version], constraint_cache: constraint_cache, python_range: python_range)
64
+ EcosystemLens.assess(ecosystem: dep[:ecosystem], name: dep[:name], version: dep[:version], constraint_cache: constraint_cache, runtime_ranges: runtime_ranges)
65
65
  .merge(dep.slice(:production))
66
66
  rescue StandardError => e
67
67
  # One dependency's failure must not abort the audit, but it must not
@@ -92,5 +92,16 @@ module StillActive
92
92
  )
93
93
  end.wait
94
94
  end
95
+
96
+ private
97
+
98
+ # Fetch one runtime's support window, degrading a feed failure to nil (no
99
+ # ceiling checks for that runtime) with a warning, never aborting the audit.
100
+ def safe_support_window(label)
101
+ yield
102
+ rescue StandardError => e
103
+ $stderr.puts("warning: #{label} support window lookup failed: #{e.class} (#{e.message}); skipping language-ceiling checks")
104
+ nil
105
+ end
95
106
  end
96
107
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module StillActive
4
- VERSION = "3.0.0.rc1"
4
+ VERSION = "3.0.0.rc3"
5
5
  end
@@ -153,7 +153,10 @@ module StillActive
153
153
  commit_date = signals[:last_commit_date]
154
154
  archived = signals[:archived]
155
155
  last_release = VersionHelper.find_version(versions: vs, pre_release: false)
156
- last_pre_release = VersionHelper.find_version(versions: vs, pre_release: true)
156
+ last_pre_release = VersionHelper.upcoming_pre_release(
157
+ pre_release: VersionHelper.find_version(versions: vs, pre_release: true),
158
+ release: last_release,
159
+ )
157
160
  deps_dev = fetch_deps_dev_info(
158
161
  gem_name: gem_name,
159
162
  version: gem_version || VersionHelper.gem_version(version_hash: last_release),
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: 3.0.0.rc1
4
+ version: 3.0.0.rc3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sean Floyd
@@ -254,7 +254,9 @@ files:
254
254
  - lib/helpers/constraint_helper.rb
255
255
  - lib/helpers/cvss_helper.rb
256
256
  - lib/helpers/cyclonedx_helper.rb
257
+ - lib/helpers/dependency_helper.rb
257
258
  - lib/helpers/diff_markdown_helper.rb
259
+ - lib/helpers/dotnet_helper.rb
258
260
  - lib/helpers/emoji_helper.rb
259
261
  - lib/helpers/endoflife_helper.rb
260
262
  - lib/helpers/http_helper.rb