rails-hyperdrive 0.3.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.
Files changed (55) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +295 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +229 -0
  5. data/Rakefile +6 -0
  6. data/SECURITY.md +43 -0
  7. data/config/routes.rb +5 -0
  8. data/lib/generators/hyperdrive/content_sync_support.rb +54 -0
  9. data/lib/generators/hyperdrive/discover/discover_generator.rb +103 -0
  10. data/lib/generators/hyperdrive/gitignore_support.rb +28 -0
  11. data/lib/generators/hyperdrive/install/USAGE +22 -0
  12. data/lib/generators/hyperdrive/install/install_generator.rb +168 -0
  13. data/lib/generators/hyperdrive/install/templates/initializer.rb.tt +3 -0
  14. data/lib/generators/hyperdrive/install_summary.rb +70 -0
  15. data/lib/generators/hyperdrive/sync/USAGE +21 -0
  16. data/lib/generators/hyperdrive/sync/sync_generator.rb +40 -0
  17. data/lib/generators/hyperdrive/sync_runner.rb +70 -0
  18. data/lib/rails/hyperdrive/artifact_status.rb +86 -0
  19. data/lib/rails/hyperdrive/audit_header.rb +83 -0
  20. data/lib/rails/hyperdrive/auto_install.rb +98 -0
  21. data/lib/rails/hyperdrive/bundler_artifact_discovery.rb +320 -0
  22. data/lib/rails/hyperdrive/claude_md_import.rb +69 -0
  23. data/lib/rails/hyperdrive/companion_discovery.rb +232 -0
  24. data/lib/rails/hyperdrive/console_executor.rb +64 -0
  25. data/lib/rails/hyperdrive/data/gem_categories.yml +52 -0
  26. data/lib/rails/hyperdrive/drift_verdict.rb +42 -0
  27. data/lib/rails/hyperdrive/eager_footprint.rb +48 -0
  28. data/lib/rails/hyperdrive/engine.rb +22 -0
  29. data/lib/rails/hyperdrive/index_document.rb +49 -0
  30. data/lib/rails/hyperdrive/install_layout.rb +45 -0
  31. data/lib/rails/hyperdrive/install_pipeline.rb +415 -0
  32. data/lib/rails/hyperdrive/install_plan.rb +78 -0
  33. data/lib/rails/hyperdrive/install_shell.rb +43 -0
  34. data/lib/rails/hyperdrive/lock_file.rb +171 -0
  35. data/lib/rails/hyperdrive/mcp_server.rb +80 -0
  36. data/lib/rails/hyperdrive/resources/skill.rb +63 -0
  37. data/lib/rails/hyperdrive/resources/stack_profile.rb +32 -0
  38. data/lib/rails/hyperdrive/safety/rack_middleware.rb +54 -0
  39. data/lib/rails/hyperdrive/skill_template.rb +52 -0
  40. data/lib/rails/hyperdrive/sql_safety.rb +28 -0
  41. data/lib/rails/hyperdrive/stack_profile.rb +176 -0
  42. data/lib/rails/hyperdrive/tools/base.rb +39 -0
  43. data/lib/rails/hyperdrive/tools/describe_app.rb +21 -0
  44. data/lib/rails/hyperdrive/tools/list_models.rb +76 -0
  45. data/lib/rails/hyperdrive/tools/list_routes.rb +33 -0
  46. data/lib/rails/hyperdrive/tools/locate_source.rb +86 -0
  47. data/lib/rails/hyperdrive/tools/lookup_doc.rb +60 -0
  48. data/lib/rails/hyperdrive/tools/run_ruby.rb +31 -0
  49. data/lib/rails/hyperdrive/tools/run_sql.rb +49 -0
  50. data/lib/rails/hyperdrive/tools/tail_logs.rb +65 -0
  51. data/lib/rails/hyperdrive/version.rb +5 -0
  52. data/lib/rails/hyperdrive.rb +37 -0
  53. data/lib/rails-hyperdrive.rb +2 -0
  54. data/lib/tasks/hyperdrive.rake +22 -0
  55. metadata +161 -0
@@ -0,0 +1,232 @@
1
+ require "json"
2
+ require "net/http"
3
+ require "openssl"
4
+ require "timeout"
5
+ require "uri"
6
+ require "time"
7
+ require "fileutils"
8
+ require "bundler"
9
+
10
+ module Rails
11
+ module Hyperdrive
12
+ class CompanionDiscovery
13
+ TARGETS_KEY = "rails_hyperdrive_targets".freeze
14
+ ARTIFACTS_KEY = "rails_hyperdrive_artifacts".freeze
15
+ # Field-scoped metadata search is an undocumented passthrough to the
16
+ # rubygems search backend, so it fails open: if it ever stops matching, the
17
+ # query returns an empty 200 page and discovery reports no suggestions.
18
+ SEARCH_QUERY = "metadata.#{TARGETS_KEY}:*".freeze
19
+ SEARCH_ENDPOINT = "https://rubygems.org/api/v1/search.json".freeze
20
+ CACHE_RELATIVE_PATH = ".hyperdrive/discover_cache.json".freeze
21
+ PER_PAGE = 30
22
+ MAX_PAGES = 34 # ~1000 companions; a runaway-pagination backstop
23
+ CACHE_TTL = 24 * 60 * 60
24
+ OPEN_TIMEOUT = 5
25
+ READ_TIMEOUT = 5
26
+
27
+ # matched_target is nil for a universal "*" companion.
28
+ Suggestion = Struct.new(
29
+ :gem_name, :version, :targets, :artifacts,
30
+ :matched_target, :matched_version, :installed,
31
+ keyword_init: true
32
+ )
33
+
34
+ # Outcome of a run.
35
+ # status: :online — served live or fresh-cached results
36
+ # :stale — network failed; served an expired cache
37
+ # :unavailable — network failed and no cache to fall back to
38
+ Result = Struct.new(:suggestions, :warnings, :status, :age_seconds, :detail, keyword_init: true)
39
+
40
+ class Unavailable < StandardError
41
+ attr_reader :retry_after
42
+
43
+ def initialize(message, retry_after: nil)
44
+ super(message)
45
+ @retry_after = retry_after
46
+ end
47
+ end
48
+
49
+ Response = Struct.new(:code, :body, :retry_after, keyword_init: true)
50
+
51
+ class NetHttpFetcher
52
+ def get(uri)
53
+ uri = URI(uri)
54
+ http = Net::HTTP.new(uri.host, uri.port)
55
+ http.use_ssl = uri.scheme == "https"
56
+ http.open_timeout = OPEN_TIMEOUT
57
+ http.read_timeout = READ_TIMEOUT
58
+ resp = http.get(uri.request_uri)
59
+ Response.new(code: resp.code.to_i, body: resp.body.to_s, retry_after: resp["retry-after"])
60
+ end
61
+ end
62
+
63
+ def initialize(lockfile_path:, cache_path:, refresh: false, fetcher: NetHttpFetcher.new, clock: -> { Time.now.utc })
64
+ @lockfile_path = lockfile_path.to_s
65
+ @cache_path = cache_path.to_s
66
+ @refresh = refresh
67
+ @fetcher = fetcher
68
+ @clock = clock
69
+ end
70
+
71
+ def run
72
+ warnings = []
73
+ now = @clock.call
74
+ candidates, status, age, detail = resolve_candidates(now)
75
+
76
+ return Result.new(suggestions: [], warnings: warnings, status: :unavailable, detail: detail) if status == :unavailable
77
+
78
+ suggestions = match(candidates, warnings)
79
+ Result.new(suggestions: suggestions, warnings: warnings, status: status, age_seconds: age, detail: detail)
80
+ end
81
+
82
+ private
83
+
84
+ def resolve_candidates(now)
85
+ cache = read_cache
86
+ if !@refresh && cache && fresh?(cache, now)
87
+ return [cache[:candidates], :online, age_of(cache, now), nil]
88
+ end
89
+
90
+ begin
91
+ candidates = fetch_all
92
+ write_cache(candidates, now)
93
+ [candidates, :online, 0, nil]
94
+ rescue Unavailable => e
95
+ detail = unavailable_detail(e)
96
+ if cache
97
+ [cache[:candidates], :stale, age_of(cache, now), detail]
98
+ else
99
+ [[], :unavailable, nil, detail]
100
+ end
101
+ end
102
+ end
103
+
104
+ def unavailable_detail(error)
105
+ if error.retry_after
106
+ "rubygems rate-limited the request (retry after #{error.retry_after}s)"
107
+ else
108
+ "rubygems unreachable (#{error.message})"
109
+ end
110
+ end
111
+
112
+ def fetch_all
113
+ candidates = []
114
+ page = 1
115
+ loop do
116
+ batch = fetch_page(page)
117
+ candidates.concat(batch)
118
+ break if batch.size < PER_PAGE
119
+ page += 1
120
+ break if page > MAX_PAGES
121
+ end
122
+ candidates
123
+ rescue SocketError, SystemCallError, Timeout::Error, OpenSSL::SSL::SSLError, IOError => e
124
+ raise Unavailable, e.message
125
+ end
126
+
127
+ def fetch_page(page)
128
+ uri = "#{SEARCH_ENDPOINT}?query=#{URI.encode_www_form_component(SEARCH_QUERY)}&page=#{page}"
129
+ resp = @fetcher.get(uri)
130
+
131
+ case resp.code
132
+ when 200
133
+ parse_search(resp.body)
134
+ when 429
135
+ raise Unavailable.new("rate limited", retry_after: resp.retry_after)
136
+ else
137
+ raise Unavailable, "HTTP #{resp.code}"
138
+ end
139
+ end
140
+
141
+ def parse_search(body)
142
+ data = JSON.parse(body)
143
+ return [] unless data.is_a?(Array)
144
+ data.map { |gem| { name: gem["name"].to_s, version: gem["version"].to_s, metadata: gem["metadata"] || {} } }
145
+ rescue JSON::ParserError => e
146
+ raise Unavailable, "malformed search response (#{e.message})"
147
+ end
148
+
149
+ def match(candidates, warnings)
150
+ installed = installed_gems
151
+ candidates.filter_map { |c| build_suggestion(c, installed, warnings) }
152
+ .sort_by { |s| [s.installed ? 0 : 1, s.gem_name] }
153
+ end
154
+
155
+ def build_suggestion(candidate, installed, warnings)
156
+ name = candidate[:name]
157
+ metadata = candidate[:metadata] || {}
158
+ targets = parse_list(metadata[TARGETS_KEY])
159
+
160
+ if targets.empty?
161
+ warnings << "#{name} #{candidate[:version]}: no targets declared (skipped)"
162
+ return nil
163
+ end
164
+
165
+ matched_target, matched_version =
166
+ if targets.include?("*")
167
+ [nil, nil]
168
+ else
169
+ hit = targets.find { |t| installed.key?(t) }
170
+ return nil unless hit
171
+ [hit, installed[hit]]
172
+ end
173
+
174
+ Suggestion.new(
175
+ gem_name: name,
176
+ version: candidate[:version],
177
+ targets: targets,
178
+ artifacts: parse_list(metadata[ARTIFACTS_KEY]),
179
+ matched_target: matched_target,
180
+ matched_version: matched_version,
181
+ installed: installed.key?(name)
182
+ )
183
+ end
184
+
185
+ def parse_list(raw)
186
+ raw.to_s.split(",").map(&:strip).reject(&:empty?)
187
+ end
188
+
189
+ def installed_gems
190
+ return {} unless File.exist?(@lockfile_path)
191
+ parser = ::Bundler::LockfileParser.new(File.read(@lockfile_path))
192
+ parser.specs.each_with_object({}) { |s, h| h[s.name.to_s] = s.version.to_s }
193
+ rescue StandardError
194
+ # A malformed/unreadable lockfile must not break discovery — treat the
195
+ # stack as empty (only `*`-target companions will then match).
196
+ {}
197
+ end
198
+
199
+ def read_cache
200
+ return nil unless File.exist?(@cache_path)
201
+ data = JSON.parse(File.read(@cache_path))
202
+ fetched_at = Time.iso8601(data["fetched_at"].to_s)
203
+ candidates = Array(data["candidates"]).map do |c|
204
+ { name: c["name"].to_s, version: c["version"].to_s, metadata: c["metadata"] || {} }
205
+ end
206
+ { fetched_at: fetched_at, candidates: candidates }
207
+ rescue JSON::ParserError, ArgumentError, TypeError
208
+ nil
209
+ end
210
+
211
+ def write_cache(candidates, now)
212
+ FileUtils.mkdir_p(File.dirname(@cache_path))
213
+ payload = {
214
+ "fetched_at" => now.iso8601,
215
+ "candidates" => candidates.map { |c| { "name" => c[:name], "version" => c[:version], "metadata" => c[:metadata] } }
216
+ }
217
+ File.write(@cache_path, JSON.pretty_generate(payload) + "\n")
218
+ rescue SystemCallError
219
+ # A non-writable cache dir must not break discovery.
220
+ nil
221
+ end
222
+
223
+ def fresh?(cache, now)
224
+ age_of(cache, now) < CACHE_TTL
225
+ end
226
+
227
+ def age_of(cache, now)
228
+ now - cache[:fetched_at]
229
+ end
230
+ end
231
+ end
232
+ end
@@ -0,0 +1,64 @@
1
+ require "stringio"
2
+ require "timeout"
3
+
4
+ module Rails
5
+ module Hyperdrive
6
+ module ConsoleExecutor
7
+ DEFAULT_TIMEOUT_SECONDS = 30
8
+
9
+ Result = Struct.new(:result, :stdout, :stderr, :elapsed_ms, :exception, keyword_init: true) do
10
+ def to_h
11
+ {
12
+ result: result.inspect,
13
+ stdout: stdout,
14
+ stderr: stderr,
15
+ elapsed_ms: elapsed_ms,
16
+ exception: exception && {
17
+ class: exception.class.name,
18
+ message: exception.message,
19
+ backtrace: Array(exception.backtrace).first(20)
20
+ }
21
+ }
22
+ end
23
+ end
24
+
25
+ module_function
26
+
27
+ def eval(code, timeout: DEFAULT_TIMEOUT_SECONDS)
28
+ original_stdout = $stdout
29
+ original_stderr = $stderr
30
+ captured_out = StringIO.new
31
+ captured_err = StringIO.new
32
+ $stdout = captured_out
33
+ $stderr = captured_err
34
+
35
+ started_at = monotonic_now
36
+ result = nil
37
+ exception = nil
38
+
39
+ begin
40
+ ::Timeout.timeout(timeout.to_f) do
41
+ result = TOPLEVEL_BINDING.eval(code, "(hyperdrive run_ruby)", 1)
42
+ end
43
+ rescue Exception => e # rubocop:disable Lint/RescueException
44
+ exception = e
45
+ end
46
+
47
+ Result.new(
48
+ result: result,
49
+ stdout: captured_out.string,
50
+ stderr: captured_err.string,
51
+ elapsed_ms: ((monotonic_now - started_at) * 1000).round,
52
+ exception: exception
53
+ )
54
+ ensure
55
+ $stdout = original_stdout
56
+ $stderr = original_stderr
57
+ end
58
+
59
+ def monotonic_now
60
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,52 @@
1
+ # Category assignment is reporting metadata only; nothing here is executed.
2
+
3
+ test:
4
+ - rspec
5
+ - rspec-rails
6
+ - minitest
7
+ - minitest-rails
8
+ - test-unit
9
+ - capybara
10
+ - cucumber
11
+ - cucumber-rails
12
+
13
+ jobs:
14
+ - sidekiq
15
+ - resque
16
+ - delayed_job
17
+ - good_job
18
+ - solid_queue
19
+ - sucker_punch
20
+ - shoryuken
21
+ - que
22
+
23
+ auth:
24
+ - devise
25
+ - clearance
26
+ - sorcery
27
+ - authlogic
28
+ - rodauth
29
+ - rodauth-rails
30
+
31
+ authz:
32
+ - pundit
33
+ - cancancan
34
+ - action_policy
35
+
36
+ frontend:
37
+ - turbo-rails
38
+ - stimulus-rails
39
+ - importmap-rails
40
+ - jsbundling-rails
41
+ - cssbundling-rails
42
+ - view_component
43
+ - phlex
44
+ - phlex-rails
45
+ - hotwire-rails
46
+
47
+ db:
48
+ - pg
49
+ - mysql2
50
+ - sqlite3
51
+ - trilogy
52
+ - sequel
@@ -0,0 +1,42 @@
1
+ require "digest"
2
+ require "rails/hyperdrive/audit_header"
3
+
4
+ module Rails
5
+ module Hyperdrive
6
+ # The lock's source_sha is computed over the install-ready body before any
7
+ # header injection, so hashing an installed file's comparable bytes
8
+ # (disk_sha) reproduces it exactly for an unedited file.
9
+ module DriftVerdict
10
+ STATES = %i[current outdated edited missing orphaned].freeze
11
+
12
+ module_function
13
+
14
+ # The install-ready body's hash — what the lock records as source_sha.
15
+ def body_sha(content)
16
+ Digest::SHA256.hexdigest(content.to_s)
17
+ end
18
+
19
+ # AuditHeader.strip's line matching can raise on binary content, so
20
+ # skill_support files compare as raw bytes.
21
+ def disk_sha(file, kind:)
22
+ return body_sha(File.binread(file)) if kind.to_s == "skill_support"
23
+ body_sha(AuditHeader.strip(File.read(file)))
24
+ end
25
+
26
+ # A file counts as unedited when its comparable bytes still hash to what
27
+ # the lock recorded at install time, not to what the gem ships now.
28
+ def unedited?(file, lock_entry:)
29
+ disk_sha(file, kind: lock_entry.kind) == lock_entry.source_sha
30
+ end
31
+
32
+ # gem_sha nil means the bundle no longer offers this destination.
33
+ def verdict(file:, kind:, lock_entry:, gem_sha:)
34
+ return File.exist?(file) ? :orphaned : :missing if gem_sha.nil?
35
+ return :missing unless File.exist?(file)
36
+ return :edited if lock_entry.nil?
37
+ return :edited if disk_sha(file, kind: kind) != lock_entry.source_sha
38
+ lock_entry.source_sha == gem_sha ? :current : :outdated
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,48 @@
1
+ require "rails/hyperdrive/install_layout"
2
+
3
+ module Rails
4
+ module Hyperdrive
5
+ # Sizes the always-in-context set — the index-included guidelines — and
6
+ # returns the report as lines for a shell to print.
7
+ module EagerFootprint
8
+ # Soft cap on the assembled eager set — roughly six at-the-limit
9
+ # guidelines, or ~5% of a 200k context window.
10
+ TOTAL_WARN_TOKENS = 10_000
11
+
12
+ Line = Struct.new(:status, :message, :color, keyword_init: true)
13
+
14
+ module_function
15
+
16
+ def approx_tokens(body)
17
+ (body.to_s.length / 4.0).ceil
18
+ end
19
+
20
+ def lines(guidelines:)
21
+ return [] if guidelines.empty?
22
+
23
+ tokens = guidelines.sum { |e| approx_tokens(e[:body]) }
24
+
25
+ result = [Line.new(
26
+ status: :eager,
27
+ message: "#{guidelines.size} guideline(s), ~#{tokens} tokens always in context",
28
+ color: :cyan
29
+ )]
30
+ result << over_budget_line(guidelines) if tokens > TOTAL_WARN_TOKENS
31
+ result
32
+ end
33
+
34
+ def over_budget_line(entries)
35
+ top = entries.sort_by { |e| -approx_tokens(e[:body]) }.first(2)
36
+ .map { |e| "#{e[:name]} ~#{approx_tokens(e[:body])}" }
37
+ Line.new(
38
+ status: :warn,
39
+ message: "eager context is over the ~#{TOTAL_WARN_TOKENS} token budget (largest: #{top.join(", ")}); " \
40
+ "trim them, or drop a line from #{InstallLayout::INDEX_PATH} to opt one out",
41
+ color: :yellow
42
+ )
43
+ end
44
+
45
+ private_class_method :over_budget_line
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,22 @@
1
+ require "rails/engine"
2
+
3
+ module Rails
4
+ module Hyperdrive
5
+ # Loads in every environment so an accidental non-dev bundle group doesn't
6
+ # break boot.
7
+ class Engine < ::Rails::Engine
8
+ isolate_namespace Rails::Hyperdrive
9
+
10
+ initializer "hyperdrive.warn_outside_development" do
11
+ unless Rails::Hyperdrive.dev_mode?
12
+ msg = "[hyperdrive] loaded outside development (Rails.env=#{::Rails.env}); MCP endpoints will return 403"
13
+ if ::Rails.logger
14
+ ::Rails.logger.warn(msg)
15
+ else
16
+ warn(msg)
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,49 @@
1
+ module Rails
2
+ module Hyperdrive
3
+ # Renders index.md — the list of `@`-includes that decides which guidelines
4
+ # load eagerly. Pure: the caller supplies the existing content and writes
5
+ # the result.
6
+ module IndexDocument
7
+ GUIDELINE_PREFIX = "@guidelines/".freeze
8
+
9
+ Rendered = Struct.new(:content, :guidelines, keyword_init: true)
10
+
11
+ module_function
12
+
13
+ # previously_installed are the guideline basenames the lock already
14
+ # records. A guideline whose line the user deleted from an existing
15
+ # index.md is not re-added.
16
+ def render(guidelines:, existing:, previously_installed:)
17
+ included = guidelines.select do |g|
18
+ next true if existing.nil?
19
+ next true unless previously_installed.include?(g[:base])
20
+ existing.include?("#{GUIDELINE_PREFIX}#{g[:base]}")
21
+ end
22
+
23
+ lines = included.map { |g| "#{GUIDELINE_PREFIX}#{g[:base]}" }.sort
24
+
25
+ Rendered.new(
26
+ content: lines.empty? ? "" : lines.join("\n") + "\n",
27
+ # Only guidelines referenced by index.md load into context, so they
28
+ # alone make up the eager footprint.
29
+ guidelines: included.map { |g| { name: g[:base], body: g[:body] } }
30
+ )
31
+ end
32
+
33
+ # Amends an existing index in place, returning nil when there is nothing
34
+ # to add. Existing lines carry over untouched, so an orphan keeps its
35
+ # inclusion.
36
+ def amend(existing:, added:)
37
+ return nil if existing.nil?
38
+
39
+ lines = added.map { |base| "#{GUIDELINE_PREFIX}#{base}" }
40
+ return nil if lines.empty?
41
+
42
+ current = existing.split("\n").map(&:strip).reject(&:empty?)
43
+ return nil if (lines - current).empty?
44
+
45
+ (current + lines).uniq.sort.join("\n") + "\n"
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,45 @@
1
+ module Rails
2
+ module Hyperdrive
3
+ # The one place install paths and artifact names are built and parsed.
4
+ # Must stay require-free: the MCP server loads it and must not drag in the
5
+ # install machinery.
6
+ module InstallLayout
7
+ SKILLS_DIR = ".claude/skills".freeze
8
+ HYPERDRIVE_DIR = ".claude/hyperdrive".freeze
9
+ INDEX_PATH = "#{HYPERDRIVE_DIR}/index.md".freeze
10
+ LOCK_PATH = ".hyperdrive/lock.yml".freeze
11
+
12
+ ARTIFACT_TYPES = { "skill" => :skill, "skill_support" => :skill_support, "guideline" => :guideline }.freeze
13
+
14
+ module_function
15
+
16
+ def dest_for(type, final_name)
17
+ case type
18
+ when :skill then "#{SKILLS_DIR}/#{final_name}/SKILL.md"
19
+ when :guideline then "#{HYPERDRIVE_DIR}/guidelines/#{final_name}.md"
20
+ end
21
+ end
22
+
23
+ def installed_name(type, dest)
24
+ case type
25
+ when :skill then File.basename(File.dirname(dest))
26
+ when :skill_support then dest.split("/")[2] # .claude/skills/<name>/<relpath>
27
+ when :guideline then File.basename(dest, ".md")
28
+ end
29
+ end
30
+
31
+ def skill_dir_of(dest)
32
+ segments = dest.split("/")
33
+ File.join(*segments[0, 3]) # .claude/skills/<name>
34
+ end
35
+
36
+ def postfixed_name(name, source_gem)
37
+ "#{name}--#{source_gem}"
38
+ end
39
+
40
+ def base_name(name)
41
+ name.split("--").first.to_s
42
+ end
43
+ end
44
+ end
45
+ end