Module: StillActive::EcosystemLens

Extended by:
EcosystemLens
Included in:
EcosystemLens
Defined in:
lib/still_active/ecosystem_lens.rb

Overview

The cross-ecosystem maintenance lens. Given one SBOM-derived dependency (name, version), it assembles the same maintenance signals the native Ruby path produces -- latest release date, archived, advisories, scorecard -- into a gem_data hash StatusHelper.gem_status can read directly. The breadth counterpart to the depth Bundler path gives Ruby.

Security: the SBOM supplies only ecosystem/name/version. The repository is discovered from deps.dev (a public source still_active opts into), never from a lockfile-derived URL, so a hostile SBOM can't redirect a lookup. Anything unresolvable (a private package deps.dev doesn't index) degrades to nil signals -> :unknown, never a fabricated "ok".

Constant Summary collapse

ECOSYSTEM_REGISTRIES =

still_active ecosystem symbol -> ecosyste.ms registry name for the declared dependency (poison-pill) lookup. Only the ecosystems where the ecosyste.ms registry name and the deps.dev package name align cleanly are wired; maven (group:artifact) and go (module paths) need name-format handling and are left out for now, so they simply carry no constraint signal rather than a wrong one.

{
  rubygems: "rubygems.org",
  npm: "npmjs.org",
  pypi: "pypi.org",
  cargo: "crates.io",
}.freeze
FLAT_RESOLUTION_ECOSYSTEMS =

A transitive cap only holds the WHOLE tree hostage where the ecosystem forces one version per package. Ruby/Bundler and pip do (flat resolution); npm nests multiple versions and cargo coexists majors, so a below-latest cap there pins a duplicate copy in one subtree -- not a tree-wide block. The one npm case that DOES block (peerDependencies / de-facto singletons) isn't visible in ecosyste.ms data (its parser drops peerDependencies), so emitting poison for npm/cargo would over-claim on the non-blocking cases and miss the blocking ones. Suppress it there until peer requirements are sourced from deps.dev; silence beats a false "holds your tree hostage".

[:rubygems, :pypi].freeze

Instance Method Summary collapse

Instance Method Details

#assess(ecosystem:, name:, version:, constraint_cache: {}, python_range: nil) ⇒ Object



55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
# File 'lib/still_active/ecosystem_lens.rb', line 55

def assess(ecosystem:, name:, version:, constraint_cache: {}, python_range: nil)
  info = DepsDevClient.version_info(gem_name: name, version: version, system: ecosystem)
  default = DepsDevClient.default_version_info(name: name, system: ecosystem)
  # Retry the version lookup once when it came back empty but the package DID
  # resolve: HttpHelper collapses a genuine 404 and a transient network blip to
  # the same nil, and a healthy version must not be mis-flagged as unresolved
  # (below) on a one-off miss. A real 404 stays nil; a blip recovers the data.
  info ||= DepsDevClient.version_info(gem_name: name, version: version, system: ecosystem) if default
  # Recover the repo from the default version when the exact locked version
  # isn't indexed (yanked/normalization mismatch): otherwise its project link
  # vanishes and a still-fresh package date would read a false :ok with
  # archived/scorecard silently dropped. The native Bundler path resolves the
  # repo independently of deps.dev's per-version record; this gives the lens
  # the same resilience.
  # The pinned version isn't indexed by deps.dev (info nil) while the package IS
  # (default present, so the feed is up): the version is yanked/nonexistent, not a
  # transient miss. Flag it so status reads :unknown rather than letting the still-
  # fresh PACKAGE date report a nonexistent version as :ok.
  version_unresolved = info.nil? && !default.nil?
  project_id = info&.dig(:project_id) || project_id_from(name, ecosystem, default)
  vulnerabilities = vulnerabilities_for(info)
  # Enrich with OSV: a real GHSA severity label (deps.dev can't score a CVSS-4-only
  # advisory) and the fixed-version ranges the "capped below the fix" signal needs.
  OsvClient.enrich(vulnerabilities, ecosystem: ecosystem, name: name)
  scorecard = DepsDevClient.project_scorecard(project_id: project_id)
  repo = repo_signals(project_id)

  used_release_date = info&.dig(:published_at)
  latest_release_date = default&.dig(:published_at)
  latest_version = default&.dig(:version)
  gem_data = {
    ecosystem: ecosystem,
    name: name,
    version_used: version,
    version_used_release_date: used_release_date,
    latest_version_release_date: latest_release_date,
    # libyear parity with the native path: how far behind latest the locked
    # version is, in release-years. Both dates come from deps.dev responses
    # already fetched above (no extra call); nil when either date is missing.
    libyear: LibyearHelper.gem_libyear(
      version_used_release_date: parse_time(used_release_date),
      latest_version_release_date: parse_time(latest_release_date),
    ),
    # Whether the pinned version is at or ahead of deps.dev's latest stable, so a
    # prerelease or an ahead-of-stable pin reads current (true), not "behind" --
    # parity with the native path's `>=` comparison. nil when latest is unknown.
    up_to_date: version_current?(version, latest_version),
    repository_url: project_id && "https://#{project_id}",
    last_commit_date: repo[:last_commit_date],
    archived: repo[:archived],
    scorecard_score: scorecard&.dig(:score),
    scorecard_maintained: scorecard&.dig(:maintained),
    vulnerability_count: vulnerabilities.length,
    vulnerabilities: vulnerabilities,
  }
  gem_data[:version_unresolved] = true if version_unresolved
  attach_constraints(gem_data, ecosystem: ecosystem, name: name, version: version, cache: constraint_cache)
  attach_language_ceiling(gem_data, ecosystem: ecosystem, name: name, version: version, latest_version: default&.dig(:version), python_range: python_range)
  gem_data
end