Module: StillActive::EcosystemsClient

Extended by:
EcosystemsClient
Included in:
EcosystemsClient
Defined in:
lib/still_active/ecosystems_client.rb

Overview

Tokenless repo signals (archived?, last-commit date) for github.com-hosted gems, sourced from ecosyste.ms instead of the GitHub API. Used as the fallback when no GitHub token is configured, so an unauthenticated run isn't capped at GitHub's 60 req/hr (ecosyste.ms allows 5000 anonymous) -- the difference between "works on a large Gemfile" and "dies after a handful".

GitHub-only by design: ecosyste.ms's repos service doesn't populate commit recency for GitLab/Codeberg (its repo crawler is GitHub-centric), so those hosts keep their own live clients.

Data is CC-BY-SA 4.0 (https://creativecommons.org/licenses/by-sa/4.0/); still_active queries it live (no redistribution) and attributes ecosyste.ms in the README data sources.

Constant Summary collapse

BASE_URI =
URI("https://repos.ecosyste.ms/")
PACKAGES_BASE_URI =

The packages service is a distinct host from the repos service above; it carries per-version declared dependency constraints (the poison-pill input).

URI("https://packages.ecosyste.ms/")
USER_AGENT =

ecosyste.ms asks consumers to identify themselves for its "polite pool".

"still_active/#{StillActive::VERSION} (+https://github.com/SeanLF/still_active)"
RUNTIME_KINDS =

ecosyste.ms dependency-kind labels that ship at RUNTIME (so a cap on one holds the consumer's tree hostage). It is registry-specific vocabulary: rubygems/npm/pypi say "runtime", but cargo says "normal" (its "dev"/"build" kinds are excluded). An ALLOWLIST, not a denylist: an unrecognised kind is dropped rather than risk flagging a dev/build dep and breaking the FP discipline. Compared case-insensitively (rubygems's dev kind is "Development").

["runtime", "normal"].freeze

Instance Method Summary collapse

Instance Method Details

#declared_dependencies(name:, version:, registry: "rubygems.org") ⇒ Object

The runtime dependency constraints a package version declares: an array of { package_name:, requirements: } for runtime-shipped deps only (see RUNTIME_KINDS). Dev/build/test deps are dropped -- they don't cap the consumer's tree, so they can't be a poison-pill. requirements is the raw constraint string ("< 5.0, >= 4.0.1") ConstraintHelper reads. registry is the ecosyste.ms registry name (rubygems.org, pypi.org, npmjs.org, crates.io), defaulting to Ruby.

Returns [] whenever the version can't be read (unindexed, 404, timeout, schema drift), so a caller degrades to "no constraints known" rather than crashing the per-gem audit.



72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
# File 'lib/still_active/ecosystems_client.rb', line 72

def declared_dependencies(name:, version:, registry: "rubygems.org")
  return [] if name.nil? || version.nil?

  path = "/api/v1/registries/#{encode(registry)}/packages/#{encode(name)}/versions/#{encode(version)}"
  body = HttpHelper.get_json(PACKAGES_BASE_URI, path, headers: { "User-Agent" => USER_AGENT }, params: politeness_params)
  return [] unless body.is_a?(Hash)

  dependencies = body["dependencies"]
  return [] unless dependencies.is_a?(Array)

  dependencies.filter_map do |dep|
    next unless dep.is_a?(Hash) && RUNTIME_KINDS.include?(dep["kind"].to_s.downcase)

    package_name = dep["package_name"]
    requirements = dep["requirements"]
    next if package_name.nil? || requirements.nil?

    { package_name: package_name, requirements: requirements }
  end
end

#repo_signals(owner:, name:) ⇒ Object

archived + last-commit date from a single repository call. ecosyste.ms's pushed_at mirrors GitHub's, so this returns the same shape as GithubClient. Returns {} when the repo can't be read, so the caller leaves both blank.



34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
# File 'lib/still_active/ecosystems_client.rb', line 34

def repo_signals(owner:, name:)
  return {} if owner.nil? || name.nil?

  path = "/api/v1/hosts/GitHub/repositories/#{encode_repo(owner, name)}"
  body = HttpHelper.get_json(BASE_URI, path, headers: { "User-Agent" => USER_AGENT }, params: politeness_params)
  # A non-Hash 200 body (error envelope rendered as an array, schema drift)
  # would otherwise raise on indexing and vanish the gem from the audit via
  # the workflow's rescue; degrade to "no signal" like any other read failure.
  return {} unless body.is_a?(Hash)

  signals = { last_commit_date: parse_time(body["pushed_at"], owner, name) }
  # Only assert archived when the field is actually present. A missing field
  # must read as unknown, not be invented as false -- otherwise a partial
  # crawl could silently mask the most actionable verdict (gem is archived).
  signals[:archived] = body["archived"] == true if body.key?("archived")
  signals
end