loading
Generated 2025-03-04T00:22:00+00:00

All Files ( 87.3% covered at 111973.56 hits/line )

4 files in total.
126 relevant lines, 110 lines covered and 16 lines missed. ( 87.3% )
39 total branches, 25 branches covered and 14 branches missed. ( 64.1% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line Branch Coverage Branches Covered branches Missed branches
lib/gempath.rb 100.00 % 11 6 6 0 1.00 100.00 % 0 0 0
lib/gempath/analyzer.rb 95.24 % 103 42 40 2 335903.40 81.25 % 16 13 3
lib/gempath/cli.rb 81.58 % 197 76 62 14 9.45 52.17 % 23 12 11
lib/gempath/error.rb 100.00 % 5 2 2 0 1.00 100.00 % 0 0 0

lib/gempath.rb

100.0% lines covered

100.0% branches covered

6 relevant lines. 6 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require 'bundler'
  3. 1 require 'json'
  4. 1 require_relative 'gempath/version'
  5. 1 require_relative 'gempath/error'
  6. 1 require_relative 'gempath/analyzer'
  7. 1 module Gempath
  8. # Main module
  9. end

lib/gempath/analyzer.rb

95.24% lines covered

81.25% branches covered

42 relevant lines. 40 lines covered and 2 lines missed.
16 total branches, 13 branches covered and 3 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require 'bundler'
  3. 1 module Gempath
  4. 1 class Analyzer
  5. 1 def initialize(lockfile_path = 'Gemfile.lock')
  6. 15 @lockfile_path = lockfile_path
  7. 15 else: 14 then: 1 raise Gempath::Error, "No Gemfile.lock found at '#{lockfile_path}'" unless File.exist?(lockfile_path)
  8. 14 @lockfile = Bundler::LockfileParser.new(File.read(lockfile_path))
  9. end
  10. 1 def analyze(gem_name = nil)
  11. 69 then: 65 if gem_name
  12. 3665 spec = @lockfile.specs.find { |s| s.name == gem_name }
  13. 65 else: 64 then: 1 raise Gempath::Error, "Gem '#{gem_name}' not found in Gemfile.lock" unless spec
  14. {
  15. 64 gem_name => {
  16. 'name' => spec.name,
  17. 'version' => spec.version.to_s,
  18. 198 'dependencies' => spec.dependencies.each_with_object({}) { |d, h| h[d.name] = d.requirement.to_s },
  19. 'source' => extract_source(spec),
  20. 'consumer_paths' => find_consumer_paths(spec),
  21. 'direct_consumers' => find_direct_consumers(spec)
  22. }
  23. }
  24. else: 4 else
  25. 4 @lockfile.specs.each_with_object({}) do |spec, result|
  26. 302 result[spec.name] = {
  27. 'name' => spec.name,
  28. 'version' => spec.version.to_s,
  29. 392 'dependencies' => spec.dependencies.each_with_object({}) { |d, h| h[d.name] = d.requirement.to_s },
  30. 'source' => extract_source(spec),
  31. 'consumer_paths' => find_consumer_paths(spec),
  32. 'direct_consumers' => find_direct_consumers(spec)
  33. }
  34. end
  35. end
  36. end
  37. 1 private
  38. 1 def extract_source(spec)
  39. 366 source = spec.source
  40. 366 case source
  41. when: 0 when Bundler::Source::Git
  42. {
  43. 'type' => 'git',
  44. 'remote' => source.uri,
  45. 'ref' => source.ref,
  46. 'branch' => source.branch
  47. }
  48. when: 7 when Bundler::Source::Path
  49. {
  50. 7 'type' => 'path',
  51. 'remote' => source.path
  52. }
  53. when: 359 when Bundler::Source::Rubygems
  54. {
  55. 359 'type' => 'rubygems',
  56. 'remotes' => source.remotes.map(&:to_s)
  57. }
  58. else: 0 else
  59. {
  60. 'type' => 'unknown'
  61. }
  62. end
  63. end
  64. 1 def find_consumer_paths(spec)
  65. 366 paths = Set.new
  66. 366 @lockfile.specs.each do |s|
  67. 40146 paths.merge(find_paths_to_spec(s, spec.name))
  68. end
  69. 366 paths.to_a
  70. end
  71. 1 def find_paths_to_spec(from_spec, target_name, path = [])
  72. 250336 then: 0 else: 250336 return Set.new if path.include?(from_spec.name)
  73. 250336 paths = Set.new
  74. 250336 from_spec.dependencies.each do |dep|
  75. 212063 then: 1819 if dep.name == target_name
  76. 1819 paths.add([*path, from_spec.name, target_name].join(' -> '))
  77. else: 210244 else
  78. 12122001 dep_spec = @lockfile.specs.find { |s| s.name == dep.name }
  79. 210244 then: 210190 else: 54 if dep_spec
  80. 210190 sub_paths = find_paths_to_spec(dep_spec, target_name, [*path, from_spec.name])
  81. 210190 paths.merge(sub_paths)
  82. end
  83. end
  84. end
  85. 250336 paths
  86. end
  87. 1 def find_direct_consumers(spec)
  88. 92937 @lockfile.specs.select { |s| s.dependencies.any? { |d| d.name == spec.name } }.map(&:name)
  89. end
  90. end
  91. end

lib/gempath/cli.rb

81.58% lines covered

52.17% branches covered

76 relevant lines. 62 lines covered and 14 lines missed.
23 total branches, 12 branches covered and 11 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require 'thor'
  3. 1 require 'gempath'
  4. 1 module Gempath
  5. 1 class CLI < Thor
  6. 1 class << self
  7. 1 def exit_on_failure?
  8. true
  9. end
  10. end
  11. 1 def help(*)
  12. super
  13. end
  14. # Global options available to all commands
  15. 1 class_option :help, aliases: '-h', type: :boolean, desc: 'Display usage information'
  16. 1 class_option :filepath, aliases: '-f', type: :string,
  17. desc: 'Path to Gemfile.lock (default: ./Gemfile.lock)',
  18. default: 'Gemfile.lock'
  19. 1 desc 'analyze [OPTIONS]', 'Analyze dependencies in Gemfile.lock'
  20. 1 method_option :name, aliases: '-n', type: :string,
  21. desc: 'Name of the gem to analyze'
  22. 1 long_desc <<~LONGDESC
  23. `gempath analyze` analyzes dependencies and relationships in your Gemfile.lock.
  24. When run without options, it shows information for all gems.
  25. When given a gem name with --name, it shows detailed information just for that gem.
  26. For each gem, it shows:\n
  27. * Version being used\n
  28. * All direct dependencies and their versions\n
  29. * Source (rubygems.org, git, or path)\n
  30. * Direct consumers (gems that depend on it)\n
  31. * All dependency paths leading to this gem\n
  32. Examples:
  33. # Analyze all gems using Gemfile.lock in current directory:
  34. gempath analyze
  35. # Analyze aws-sdk-core using Gemfile.lock in current directory:
  36. gempath analyze --name aws-sdk-core
  37. # Analyze rails using a specific Gemfile.lock:
  38. gempath analyze --name rails --filepath /path/to/Gemfile.lock
  39. LONGDESC
  40. 1 def analyze(*)
  41. # Thor will handle unknown arguments automatically
  42. 2 analyzer = Gempath::Analyzer.new(options[:filepath])
  43. 2 result = analyzer.analyze(options[:name])
  44. 2 puts JSON.pretty_generate(result)
  45. rescue Gempath::Error => e
  46. raise Thor::Error,
  47. "#{e.message}\n\nTo specify a different Gemfile.lock location:\n gempath analyze -f /path/to/Gemfile.lock"
  48. end
  49. 1 desc 'generate [OPTIONS]', 'Generate a minimal Gemfile for a gem and its dependencies'
  50. 1 method_option :name, aliases: '-n', type: :string, required: true,
  51. desc: 'Name of the gem to generate Gemfile for'
  52. 1 method_option :filepath, aliases: '-f', type: :string, default: 'Gemfile.lock',
  53. desc: 'Path to Gemfile.lock (default: ./Gemfile.lock)'
  54. 1 method_option :ruby_version, aliases: '-r', type: :string,
  55. desc: 'Ruby version to use (default: current Ruby version)'
  56. 1 long_desc <<~LONGDESC
  57. `gempath generate` creates a minimal working Gemfile for a gem and its dependencies.
  58. This is useful for troubleshooting gem compatibility issues by creating an isolated
  59. environment with just the gem and its dependencies.
  60. The generated Gemfile will include:
  61. * Ruby version (if specified)
  62. * Source information (e.g. rubygems.org or custom gem server)
  63. * The target gem and its version
  64. * All direct dependencies and their versions
  65. * Any git or path-based dependencies
  66. Examples:
  67. # Generate Gemfile for aws-sdk-core using current directory's Gemfile.lock:
  68. gempath generate --name aws-sdk-core
  69. # Generate Gemfile for rails with specific Ruby version:
  70. gempath generate --name rails --ruby-version 3.2.0
  71. # Generate Gemfile using dependencies from a specific Gemfile.lock:
  72. gempath generate --name puppet --filepath /path/to/Gemfile.lock
  73. LONGDESC
  74. 1 no_commands do
  75. 1 def group_gems_by_source(_name, _version, source_info)
  76. 59 then: 59 else: 0 case source_info&.dig('source', 'type')
  77. when: 0 when 'git'
  78. [:git, source_info['source']['remote']]
  79. when: 1 when 'path'
  80. 1 [:path, source_info['source']['remote']]
  81. when: 58 when 'rubygems'
  82. 58 source = source_info['source']['remotes'].first
  83. 58 then: 0 else: 58 source == 'https://rubygems.org' ? :default : [:source, source]
  84. else: 0 else
  85. :default
  86. end
  87. end
  88. end
  89. 1 def generate(*)
  90. 6 analyzer = Gempath::Analyzer.new(options[:filepath])
  91. 6 result = analyzer.analyze(options[:name])
  92. 6 gem_info = result[options[:name]]
  93. 6 then: 2 else: 4 raise Thor::Error, "Gem '#{options[:name]}' not found in #{options[:filepath]}" if gem_info.nil?
  94. # Start building the Gemfile content
  95. 4 gemfile_content = []
  96. # Add Ruby version if specified
  97. 4 then: 1 else: 3 if options[:ruby_version]
  98. 1 gemfile_content << "ruby '#{options[:ruby_version]}'"
  99. 1 gemfile_content << ''
  100. end
  101. # Default source is always rubygems.org
  102. 4 gemfile_content << "source 'https://rubygems.org'"
  103. 4 gemfile_content << ''
  104. # Group gems by their source
  105. 4 gems_by_source = {}
  106. # Helper to add a gem to a source group
  107. 4 add_to_group = lambda { |name, version, source_info|
  108. 59 source_key = group_gems_by_source(name, version, source_info)
  109. 59 gems_by_source[source_key] ||= []
  110. 59 gems_by_source[source_key] << [name, version, source_info]
  111. }
  112. # Add main gem to its source group
  113. 4 add_to_group.call(gem_info['name'], gem_info['version'], gem_info)
  114. # Add dependencies to their source groups
  115. 4 then: 4 else: 0 gem_info['dependencies']&.each do |dep_name, dep_version|
  116. 55 dep_result = analyzer.analyze(dep_name)
  117. 55 dep_info = dep_result[dep_name]
  118. 55 add_to_group.call(dep_name, dep_version, dep_info)
  119. end
  120. # Output gems grouped by source
  121. 4 gems_by_source.each do |source_key, gems|
  122. 7 else: 0 case source_key
  123. when :default
  124. when: 0 # Output default source gems directly
  125. gems.each do |name, version, _|
  126. gemfile_content << "gem '#{name}', '#{version}'"
  127. end
  128. else: 0 then: 0 gemfile_content << '' unless gems.empty?
  129. when: 7 when Array
  130. 7 source_type, source_value = source_key
  131. 7 else: 0 case source_type
  132. when: 6 when :source
  133. 6 gemfile_content << "source '#{source_value}' do"
  134. 6 gems.each do |name, version, _|
  135. 58 gemfile_content << " gem '#{name}', '#{version}'"
  136. end
  137. 6 gemfile_content << 'end'
  138. 6 gemfile_content << ''
  139. when: 0 when :git
  140. gemfile_content << "git '#{source_value}' do"
  141. gems.each do |name, _version, _|
  142. gemfile_content << " gem '#{name}'"
  143. end
  144. gemfile_content << 'end'
  145. gemfile_content << ''
  146. when: 1 when :path
  147. 1 gems.each do |name, _, _|
  148. 1 gemfile_content << "gem '#{name}', path: '#{source_value}'"
  149. end
  150. 1 gemfile_content << ''
  151. end
  152. end
  153. end
  154. 4 puts gemfile_content.join("\n").strip
  155. rescue Gempath::Error => e
  156. raise Thor::Error,
  157. "#{e.message}\n\nTo specify a different Gemfile.lock location:\n gempath generate -f /path/to/Gemfile.lock"
  158. end
  159. 1 default_task :help
  160. end
  161. end

lib/gempath/error.rb

100.0% lines covered

100.0% branches covered

2 relevant lines. 2 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 module Gempath
  3. 1 class Error < StandardError; end
  4. end