gemcp 0.0.1

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: b3fbb27e1ba6c5a3784a08a182ebf231f88ab2a4cefe032393c69f0629964e2e
4
+ data.tar.gz: 3a38ad87ee84faddd0422d9fe909ae8fd58ba0344842516d07f37df169a8a318
5
+ SHA512:
6
+ metadata.gz: 37310c5845ad72a4124a148b6401b76b06fc8e3ee5475a724b4822a76a39af976afba151877f5bb532833b9b5cee0522a0b6c373f95668d3376a2bcc7105ac62
7
+ data.tar.gz: 2dbc82a8d0ba76f31e09a0569f0aada7a1a50fc6c0d2a59b3e5ae8c3691af6ab78aa129b60a6ed820d315986baf83765ad2b3494a1d3eb61d18c861a0f5234a5
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Brandon Weaver
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,126 @@
1
+ # gemcp
2
+
3
+ MCP tools for RubyGems package intelligence. Ask about gems, versions, dependencies, and ownership. Get structured, provenance-tagged answers an agent can trust.
4
+
5
+ `gemcp` reads from installed gemspecs first, a persistent disk cache second, and the RubyGems.org API only when neither has what you need.
6
+
7
+ ## Tools
8
+
9
+ | Tool | Purpose |
10
+ |---|---|
11
+ | `search_gems` | Search installed gems first, then RubyGems.org |
12
+ | `get_gem` | Current gem metadata, preferring an installed gemspec |
13
+ | `list_gem_versions` | Installed versions first, registry versions otherwise |
14
+ | `get_gem_version` | Exact local version/platform first, API v2 otherwise |
15
+ | `get_gem_owners` | Public owner/maintainer data from RubyGems.org |
16
+ | `get_dependency_graph` | Bounded exploratory graph with constraints on edges |
17
+ | `get_gem_context` | Aggregated context: metadata + recent versions + owners |
18
+ | `check_compatibility` | Detect dependency conflicts between two gem versions |
19
+ | `get_releases_between` | List all releases between two versions of a gem |
20
+ | `reverse_dependencies` | Find gems that depend on a given gem |
21
+ | `compare_versions` | Diff dependency requirements between two versions |
22
+
23
+ ## Example questions
24
+
25
+ These are the kinds of questions gemcp helps an agent answer with authoritative data:
26
+
27
+ - "Is sidekiq still actively maintained? Who owns it on RubyGems?"
28
+ - "What Ruby version does nokogiri 1.18.8 require?"
29
+ - "Can authlogic 3.6 coexist with Rails 8?"
30
+ - "What changed in authlogic's dependencies between version 3.6.1 and 6.6.0?"
31
+ - "What gems depend on rack?"
32
+ - "Show me every release of rails between 7.0.0 and 8.0.0"
33
+ - "Compare puma and unicorn — versions, owners, dependency counts"
34
+ - "What are the runtime dependencies of rails 8.1?"
35
+ - "Build me a dependency graph for sidekiq at depth 2"
36
+ - "Find me gems for background job processing"
37
+
38
+ ## Quick start
39
+
40
+ ```bash
41
+ bundle install
42
+ bundle exec bin/gemcp
43
+ ```
44
+
45
+ Add to your MCP client config:
46
+
47
+ ```json
48
+ {
49
+ "mcpServers": {
50
+ "gemcp": {
51
+ "command": "bundle",
52
+ "args": ["exec", "/absolute/path/bin/gemcp"]
53
+ }
54
+ }
55
+ }
56
+ ```
57
+
58
+ Or run over HTTP:
59
+
60
+ ```bash
61
+ bundle exec rackup -p 9292
62
+ ```
63
+
64
+ ## How it resolves data
65
+
66
+ ```text
67
+ installed gemspecs
68
+ ↓ miss
69
+ fresh disk cache
70
+ ↓ stale/miss
71
+ conditional RubyGems.org request (ETag / If-Modified-Since)
72
+
73
+ refresh cache
74
+ ```
75
+
76
+ Local results include `metadata_source: "local"`. On network failure with a stale cache entry, the stale data is served rather than raising.
77
+
78
+ Search is conservative: when installed gems satisfy a query, no network call is made.
79
+
80
+ ## Configuration
81
+
82
+ | Environment variable | Default | Meaning |
83
+ |---|---:|---|
84
+ | `GEMCP_LOCAL_FIRST` | `true` | Prefer installed gemspecs before network |
85
+ | `GEMCP_CACHE_DIR` | `~/.cache/gemcp` | Persistent JSON response cache |
86
+ | `GEMCP_CACHE_TTL` | `3600` | Cache freshness lifetime in seconds |
87
+ | `GEMCP_REQUESTS_PER_SECOND` | `2` | Outbound request rate limit |
88
+ | `RUBYGEMS_API_BASE_URL` | `https://rubygems.org` | API origin (useful for tests or mirrors) |
89
+
90
+ ## Architecture
91
+
92
+ Tools receive a `Client` via constructor injection. Each tool wraps its operation in `dry-monads` (`Success`/`Failure`) and renders the result to MCP response format. No global state.
93
+
94
+ ```text
95
+ Tool.new(client:) → with_errors { client.do_thing } → Success(data) / Failure(error:, message:) → render → MCP response
96
+ ```
97
+
98
+ The cache uses atomic temp-file + rename writes. The rate limiter uses a Mutex to serialize sleeps between outbound requests.
99
+
100
+ ## Dependency graph limitations
101
+
102
+ `get_dependency_graph` is exploratory, not authoritative. It follows declared dependencies using the latest release of each, preserving requirement constraints on edges. It does not consider lockfiles, Ruby versions, platforms, or source configuration. It is not a substitute for Bundler.
103
+
104
+ ## Compatibility checking limitations
105
+
106
+ `check_compatibility` tests whether two gems' declared dependency constraints overlap by sampling candidate versions against both `Gem::Requirement` objects. It catches common conflicts (e.g., `< 5.0` vs `= 8.0.0`) but is not a full constraint solver. It does not resolve transitive dependencies.
107
+
108
+ ## Trust model
109
+
110
+ Registry facts (versions, checksums, dependencies, ownership, yanked state) are structured and authoritative. README, changelog, and documentation content is untrusted prose and never overrides registry facts.
111
+
112
+ ## Dependencies
113
+
114
+ - [`fast-mcp`](https://github.com/yjacquin/fast-mcp) — MCP server framework (instance-based tools, Rack transport)
115
+ - [`dry-monads`](https://github.com/dry-rb/dry-monads) — `Success`/`Failure` result types for tool error handling
116
+ - `rack` — HTTP transport
117
+
118
+ ## Test
119
+
120
+ ```bash
121
+ bundle exec rake
122
+ ```
123
+
124
+ ## License
125
+
126
+ MIT
data/bin/gemcp ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "gemcp"
5
+
6
+ server = GemCP::Server.build
7
+ server.start
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "fileutils"
5
+ require "json"
6
+ require "securerandom"
7
+
8
+ module GemCP
9
+ # Persistent JSON file cache with conditional-request metadata (ETag, Last-Modified).
10
+ # Uses atomic temp+rename writes to prevent partial reads on POSIX systems.
11
+ class Cache
12
+ # A single cached response with freshness metadata.
13
+ Entry = Data.define(:body, :etag, :last_modified, :stored_at) do
14
+ # @param ttl [Integer] freshness lifetime in seconds
15
+ # @param now [Time] current time (injectable for testing)
16
+ # @return [Boolean] whether this entry is younger than ttl seconds
17
+ def fresh?(ttl:, now: Time.now)
18
+ now.to_i - stored_at.to_i < ttl
19
+ end
20
+ end
21
+
22
+ # @param directory [String] path to the cache directory (created if missing)
23
+ def initialize(directory:)
24
+ @directory = directory
25
+ FileUtils.mkdir_p(directory)
26
+ end
27
+
28
+ # @param key [String] cache key (typically a full URL)
29
+ # @return [Entry, nil] the cached entry, or nil if missing/corrupt
30
+ def read(key)
31
+ path = path_for(key)
32
+ return unless File.file?(path)
33
+
34
+ data = JSON.parse(File.read(path))
35
+ Entry.new(
36
+ body: data.fetch("body"),
37
+ etag: data["etag"],
38
+ last_modified: data["last_modified"],
39
+ stored_at: Time.at(data.fetch("stored_at"))
40
+ )
41
+ rescue JSON::ParserError, KeyError, Errno::ENOENT
42
+ nil
43
+ end
44
+
45
+ # @param key [String] cache key
46
+ # @param body [Hash, Array] parsed JSON response body
47
+ # @param etag [String, nil] ETag header from the response
48
+ # @param last_modified [String, nil] Last-Modified header from the response
49
+ # @return [Entry] the newly written entry
50
+ def write(key, body:, etag: nil, last_modified: nil)
51
+ path = path_for(key)
52
+ temp = "#{path}.#{SecureRandom.hex(8)}.tmp"
53
+ now = Time.now
54
+
55
+ payload = {
56
+ body: body,
57
+ etag: etag,
58
+ last_modified: last_modified,
59
+ stored_at: now.to_i
60
+ }
61
+
62
+ File.write(temp, JSON.generate(payload))
63
+ File.rename(temp, path)
64
+
65
+ Entry.new(body: body, etag: etag, last_modified: last_modified, stored_at: now)
66
+ ensure
67
+ File.delete(temp) if temp && File.exist?(temp)
68
+ end
69
+
70
+ # Re-writes an entry with a fresh timestamp, preserving body and headers.
71
+ #
72
+ # @param key [String] cache key
73
+ # @param entry [Entry] the stale entry to refresh
74
+ # @return [Entry] the refreshed entry
75
+ def touch(key, entry)
76
+ write(key, body: entry.body, etag: entry.etag, last_modified: entry.last_modified)
77
+ end
78
+
79
+ # Artificially age a cache entry by the given number of seconds.
80
+ # Intended for testing staleness without coupling to internal storage format.
81
+ #
82
+ # @param key [String] cache key
83
+ # @param seconds [Integer] number of seconds to subtract from stored_at
84
+ # @return [void]
85
+ def backdate(key, seconds:)
86
+ path = path_for(key)
87
+ return unless File.file?(path)
88
+
89
+ data = JSON.parse(File.read(path))
90
+ data["stored_at"] = data["stored_at"] - seconds
91
+ File.write(path, JSON.generate(data))
92
+ end
93
+
94
+ private
95
+
96
+ # @param key [String]
97
+ # @return [String] filesystem path for this cache key
98
+ def path_for(key)
99
+ File.join(@directory, "#{Digest::SHA256.hexdigest(key)}.json")
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+
7
+ module GemCP
8
+ # HTTP client for the RubyGems.org API with local-first resolution,
9
+ # conditional caching (ETag/If-Modified-Since), and rate limiting.
10
+ class Client
11
+ DEFAULT_BASE_URL = "https://rubygems.org"
12
+ USER_AGENT = "gemcp/#{VERSION} (+https://github.com/baweaver/gemcp)"
13
+
14
+ # @param base_url [String] RubyGems API origin
15
+ # @param open_timeout [Integer] TCP connect timeout in seconds
16
+ # @param read_timeout [Integer] response read timeout in seconds
17
+ # @param config [Config, nil] configuration (built from env if nil)
18
+ # @param local_repository [LocalRepository, nil]
19
+ # @param cache [Cache, nil]
20
+ # @param rate_limiter [RateLimiter, nil]
21
+ def initialize(
22
+ base_url: ENV.fetch("RUBYGEMS_API_BASE_URL", DEFAULT_BASE_URL),
23
+ open_timeout: 3,
24
+ read_timeout: 10,
25
+ config: nil,
26
+ local_repository: nil,
27
+ cache: nil,
28
+ rate_limiter: nil
29
+ )
30
+ @config = config || Config.new
31
+ @local_repository = local_repository || LocalRepository.new
32
+ @cache = cache || Cache.new(directory: @config.cache_dir)
33
+ @rate_limiter = rate_limiter || RateLimiter.new(requests_per_second: @config.requests_per_second)
34
+ @base_url = base_url.delete_suffix("/")
35
+ @open_timeout = open_timeout
36
+ @read_timeout = read_timeout
37
+ end
38
+
39
+ # Search for gems by name or description.
40
+ #
41
+ # @param query [String] search text
42
+ # @return [Array<Hash>] list of gem metadata hashes
43
+ def search(query)
44
+ local = @config.local_first ? @local_repository.search(query) : []
45
+ return local if local.any?
46
+
47
+ get_json("/api/v1/search.json", query: { query: query })
48
+ end
49
+
50
+ # Get metadata for the latest version of a gem.
51
+ #
52
+ # @param name [String] gem name
53
+ # @return [Hash] gem metadata including version, downloads, URIs, dependencies
54
+ # @raise [NotFound] if the gem does not exist
55
+ def gem(name)
56
+ local = @config.local_first ? @local_repository.gem(name) : nil
57
+ return local if local
58
+
59
+ get_json("/api/v1/gems/#{escape(name)}.json")
60
+ end
61
+
62
+ # List all published versions of a gem.
63
+ #
64
+ # @param name [String] gem name
65
+ # @return [Array<Hash>] version metadata ordered newest-first
66
+ # @raise [NotFound] if the gem does not exist
67
+ def versions(name)
68
+ local = @config.local_first ? @local_repository.versions(name) : []
69
+ return local if local.any?
70
+
71
+ get_json("/api/v1/versions/#{escape(name)}.json")
72
+ end
73
+
74
+ # Get the latest version number for a gem.
75
+ #
76
+ # @param name [String] gem name
77
+ # @return [Hash] hash with "version" key
78
+ # @raise [NotFound] if the gem does not exist
79
+ def latest_version(name)
80
+ local = @config.local_first ? @local_repository.gem(name) : nil
81
+ return { "version" => local.fetch("version"), "metadata_source" => "local" } if local
82
+
83
+ get_json("/api/v1/versions/#{escape(name)}/latest.json")
84
+ end
85
+
86
+ # Get metadata for a specific version and optional platform.
87
+ #
88
+ # @param name [String] gem name
89
+ # @param number [String] version number
90
+ # @param platform [String, nil] platform filter (e.g. "x86_64-linux")
91
+ # @return [Hash] version metadata
92
+ # @raise [NotFound] if the version does not exist
93
+ def version(name, number, platform: nil)
94
+ local = @config.local_first ? @local_repository.version(name, number, platform: platform) : nil
95
+ return local if local
96
+
97
+ query = platform ? { platform: platform } : nil
98
+ get_json("/api/v2/rubygems/#{escape(name)}/versions/#{escape(number)}.json", query: query)
99
+ end
100
+
101
+ # Get public owners/maintainers for a gem.
102
+ #
103
+ # @param name [String] gem name
104
+ # @return [Array<Hash>] owner records with id, handle, role
105
+ # @raise [NotFound] if the gem does not exist
106
+ def owners(name)
107
+ get_json("/api/v1/gems/#{escape(name)}/owners.json")
108
+ end
109
+
110
+ # Get gems that depend on this gem (reverse dependencies).
111
+ #
112
+ # @param name [String] gem name
113
+ # @return [Array<String>] names of gems that declare a runtime dependency on this gem
114
+ # @raise [NotFound] if the gem does not exist
115
+ def reverse_dependencies(name)
116
+ get_json("/api/v1/gems/#{escape(name)}/reverse_dependencies.json")
117
+ end
118
+
119
+ private
120
+
121
+ # Fetch JSON from the RubyGems API with caching and conditional requests.
122
+ #
123
+ # @param path [String] API path (e.g. "/api/v1/gems/rails.json")
124
+ # @param query [Hash, nil] query string parameters
125
+ # @return [Hash, Array] parsed JSON response
126
+ # @raise [NotFound, UpstreamError, InvalidResponse]
127
+ def get_json(path, query: nil)
128
+ uri = URI("#{@base_url}#{path}")
129
+ uri.query = URI.encode_www_form(query) if query
130
+ key = uri.to_s
131
+ cached = @cache.read(key)
132
+ return cached.body if cached&.fresh?(ttl: @config.cache_ttl)
133
+
134
+ request = Net::HTTP::Get.new(uri)
135
+ request["Accept"] = "application/json"
136
+ request["User-Agent"] = USER_AGENT
137
+ request["If-None-Match"] = cached.etag if cached&.etag
138
+ request["If-Modified-Since"] = cached.last_modified if cached&.last_modified
139
+
140
+ @rate_limiter.wait
141
+ response = Net::HTTP.start(
142
+ uri.host,
143
+ uri.port,
144
+ use_ssl: uri.scheme == "https",
145
+ open_timeout: @open_timeout,
146
+ read_timeout: @read_timeout
147
+ ) { |http| http.request(request) }
148
+
149
+ case response
150
+ when Net::HTTPNotModified
151
+ @cache.touch(key, cached).body
152
+ when Net::HTTPSuccess
153
+ body = JSON.parse(response.body)
154
+ @cache.write(
155
+ key,
156
+ body: body,
157
+ etag: response["ETag"],
158
+ last_modified: response["Last-Modified"]
159
+ ).body
160
+ when Net::HTTPTooManyRequests
161
+ raise UpstreamError, "RubyGems.org rate limited gemcp; retry later"
162
+ when Net::HTTPNotFound
163
+ raise NotFound, "RubyGems resource not found: #{uri.path}"
164
+ else
165
+ raise UpstreamError, "RubyGems.org returned HTTP #{response.code} for #{uri.path}"
166
+ end
167
+ rescue JSON::ParserError => e
168
+ raise InvalidResponse, "RubyGems.org returned invalid JSON: #{e.message}"
169
+ rescue Timeout::Error, SocketError, SystemCallError => e
170
+ return cached.body if cached
171
+ raise UpstreamError, "RubyGems.org request failed: #{e.class}: #{e.message}"
172
+ end
173
+
174
+ # @param value [String] value to URL-encode
175
+ # @return [String]
176
+ def escape(value)
177
+ URI.encode_www_form_component(value.to_s)
178
+ end
179
+ end
180
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GemCP
4
+ # Immutable configuration for a GemCP client. Reads from environment
5
+ # variables at construction time, with sensible defaults.
6
+ class Config
7
+ # @return [String] path to the persistent JSON cache directory
8
+ attr_reader :cache_dir
9
+
10
+ # @return [Integer] seconds before a cache entry is considered stale
11
+ attr_reader :cache_ttl
12
+
13
+ # @return [Float] maximum outbound requests per second
14
+ attr_reader :requests_per_second
15
+
16
+ # @return [Boolean] whether to prefer installed gemspecs over network
17
+ attr_reader :local_first
18
+
19
+ # @param cache_dir [String]
20
+ # @param cache_ttl [Integer]
21
+ # @param requests_per_second [Float]
22
+ # @param local_first [Boolean]
23
+ def initialize(
24
+ cache_dir: ENV.fetch("GEMCP_CACHE_DIR", File.join(Dir.home, ".cache", "gemcp")),
25
+ cache_ttl: Integer(ENV.fetch("GEMCP_CACHE_TTL", "3600")),
26
+ requests_per_second: Float(ENV.fetch("GEMCP_REQUESTS_PER_SECOND", "2")),
27
+ local_first: ENV.fetch("GEMCP_LOCAL_FIRST", "true") != "false"
28
+ )
29
+ @cache_dir = cache_dir
30
+ @cache_ttl = cache_ttl
31
+ @requests_per_second = requests_per_second
32
+ @local_first = local_first
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ module GemCP
6
+ # Builds a bounded exploratory dependency graph by traversing RubyGems metadata.
7
+ # This is NOT a lockfile resolver — it follows each dependency's latest release
8
+ # and preserves version constraints on edges for informational purposes only.
9
+ class DependencyGraph
10
+ MAX_DEPTH = 4
11
+ MAX_NODES = 100
12
+
13
+ # @param client [Client] the API client to fetch version metadata
14
+ def initialize(client:)
15
+ @client = client
16
+ end
17
+
18
+ # Build the dependency graph starting from a root gem.
19
+ #
20
+ # @param name [String] root gem name
21
+ # @param version [String, nil] root version (defaults to latest)
22
+ # @param platform [String, nil] platform filter for the root
23
+ # @param depth [Integer] traversal depth (clamped to 0..MAX_DEPTH)
24
+ # @param include_development [Boolean] whether to follow dev dependencies
25
+ # @return [Hash] graph with :root, :nodes, :edges, :truncated, :limits
26
+ def build(name:, version: nil, platform: nil, depth: 1, include_development: false)
27
+ depth = Integer(depth).clamp(0, MAX_DEPTH)
28
+ root_version = version || @client.latest_version(name).fetch("version")
29
+ state = { nodes: {}, edges: [], visited: Set.new, truncated: false }
30
+
31
+ visit(
32
+ name: name,
33
+ version: root_version,
34
+ platform: platform,
35
+ depth_remaining: depth,
36
+ include_development: include_development,
37
+ state: state
38
+ )
39
+
40
+ {
41
+ root: { name: name, version: root_version, platform: platform },
42
+ nodes: state[:nodes].values,
43
+ edges: state[:edges],
44
+ truncated: state[:truncated],
45
+ limits: { max_depth: MAX_DEPTH, max_nodes: MAX_NODES }
46
+ }
47
+ end
48
+
49
+ private
50
+
51
+ # @param name [String]
52
+ # @param version [String]
53
+ # @param platform [String, nil]
54
+ # @return [String] unique node identifier (e.g. "rails@8.0.0@ruby")
55
+ def node_key(name, version, platform)
56
+ [name, version, platform || "ruby"].join("@")
57
+ end
58
+
59
+ # Recursively visit a node and its dependencies.
60
+ #
61
+ # @param name [String] gem name
62
+ # @param version [String] version to fetch
63
+ # @param platform [String, nil] platform filter
64
+ # @param depth_remaining [Integer] remaining traversal depth
65
+ # @param include_development [Boolean]
66
+ # @param state [Hash] mutable accumulator for nodes, edges, visited set
67
+ # @return [void]
68
+ def visit(name:, version:, platform:, depth_remaining:, include_development:, state:)
69
+ key = node_key(name, version, platform)
70
+ return if state[:visited].include?(key)
71
+
72
+ if state[:nodes].length >= MAX_NODES
73
+ state[:truncated] = true
74
+ return
75
+ end
76
+
77
+ state[:visited] << key
78
+ metadata = @client.version(name, version, platform: platform)
79
+ state[:nodes][key] = summarize(metadata)
80
+ return if depth_remaining.zero?
81
+
82
+ dependency_groups = ["runtime"]
83
+ dependency_groups << "development" if include_development
84
+
85
+ dependency_groups.each do |kind|
86
+ Array(metadata.dig("dependencies", kind)).each do |dependency|
87
+ dep_name = dependency.fetch("name")
88
+ edge = {
89
+ from: key,
90
+ to: dep_name,
91
+ requirements: dependency["requirements"],
92
+ kind: kind
93
+ }
94
+ state[:edges] << edge
95
+
96
+ child_version = @client.latest_version(dep_name).fetch("version")
97
+ visit(
98
+ name: dep_name,
99
+ version: child_version,
100
+ platform: nil,
101
+ depth_remaining: depth_remaining - 1,
102
+ include_development: include_development,
103
+ state: state
104
+ )
105
+ rescue NotFound, UpstreamError => e
106
+ edge[:unresolved] = true
107
+ edge[:error] = e.message
108
+ end
109
+ end
110
+ end
111
+
112
+ # @param metadata [Hash] raw version metadata from the API
113
+ # @return [Hash] summarized node for the graph output
114
+ def summarize(metadata)
115
+ {
116
+ id: node_key(metadata["name"], metadata["version"], metadata["platform"]),
117
+ name: metadata["name"],
118
+ version: metadata["version"],
119
+ platform: metadata["platform"],
120
+ summary: metadata["summary"],
121
+ licenses: metadata["licenses"],
122
+ ruby_version: metadata["ruby_version"],
123
+ yanked: metadata["yanked"],
124
+ sha: metadata["sha"],
125
+ project_uri: metadata["project_uri"],
126
+ source_code_uri: metadata["source_code_uri"]
127
+ }
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GemCP
4
+ # Base error class for all GemCP exceptions.
5
+ class Error < StandardError
6
+ # @return [String] machine-readable error type for structured responses
7
+ def error_type = raise NotImplementedError
8
+ end
9
+
10
+ # Raised when a gem, version, or resource is not found.
11
+ class NotFound < Error
12
+ def error_type = "NotFound"
13
+ end
14
+
15
+ # Raised when RubyGems.org returns an unexpected status or is unreachable.
16
+ class UpstreamError < Error
17
+ def error_type = "UpstreamError"
18
+ end
19
+
20
+ # Raised when RubyGems.org returns a body that cannot be parsed as JSON.
21
+ class InvalidResponse < Error
22
+ def error_type = "InvalidResponse"
23
+ end
24
+ end