gemxray 0.1.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.
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ripper"
4
+
5
+ module GemXray
6
+ class GemfileSourceParser
7
+ Metadata = Struct.new(
8
+ :name,
9
+ :version,
10
+ :groups,
11
+ :options,
12
+ :line_number,
13
+ :end_line,
14
+ :source_line,
15
+ :statement,
16
+ keyword_init: true
17
+ )
18
+
19
+ def initialize(gemfile_path)
20
+ @gemfile_path = gemfile_path
21
+ end
22
+
23
+ def parse
24
+ lines = File.readlines(gemfile_path, chomp: false)
25
+ entries = []
26
+ block_stack = []
27
+ index = 0
28
+
29
+ while index < lines.length
30
+ stripped = lines[index].strip
31
+
32
+ if group_block_start?(stripped)
33
+ block_stack << { type: :group, groups: parse_group_names(stripped) }
34
+ index += 1
35
+ next
36
+ end
37
+
38
+ if generic_block_start?(stripped)
39
+ block_stack << { type: :block, groups: [] }
40
+ index += 1
41
+ next
42
+ end
43
+
44
+ if stripped == "end"
45
+ block_stack.pop
46
+ index += 1
47
+ next
48
+ end
49
+
50
+ unless gem_statement_start?(lines[index])
51
+ index += 1
52
+ next
53
+ end
54
+
55
+ start_index = index
56
+ statement_lines = [lines[index]]
57
+ until syntax_complete?(statement_lines.join)
58
+ index += 1
59
+ break if index >= lines.length
60
+
61
+ statement_lines << lines[index]
62
+ end
63
+
64
+ metadata = parse_statement(statement_lines.join, start_index + 1, current_groups(block_stack))
65
+ entries << metadata if metadata
66
+ index += 1
67
+ end
68
+
69
+ entries
70
+ end
71
+
72
+ private
73
+
74
+ attr_reader :gemfile_path
75
+
76
+ def gem_statement_start?(line)
77
+ stripped = line.lstrip
78
+ return false if stripped.start_with?("#")
79
+
80
+ stripped.match?(/\Agem(?:\s|\()/)
81
+ end
82
+
83
+ def syntax_complete?(statement)
84
+ !Ripper.sexp("begin\n#{statement}\nend\n").nil?
85
+ end
86
+
87
+ def parse_statement(statement, start_line, groups)
88
+ recorder = GemInvocationRecorder.new
89
+ recorder.instance_eval(statement, gemfile_path, start_line)
90
+ invocation = recorder.invocation
91
+ return nil unless invocation
92
+
93
+ Metadata.new(
94
+ name: invocation.fetch(:name),
95
+ version: invocation[:version],
96
+ groups: groups,
97
+ options: invocation[:options],
98
+ line_number: start_line,
99
+ end_line: start_line + statement.lines.size - 1,
100
+ source_line: statement.lines.first&.chomp,
101
+ statement: statement
102
+ )
103
+ rescue StandardError
104
+ nil
105
+ end
106
+
107
+ class GemInvocationRecorder
108
+ attr_reader :invocation
109
+
110
+ def gem(name, *args)
111
+ options = args.last.is_a?(Hash) ? args.pop.dup : {}
112
+ @invocation = {
113
+ name: name.to_s,
114
+ version: args.find { |value| value.is_a?(String) || value.is_a?(Gem::Requirement) }&.to_s,
115
+ options: symbolize_keys(options)
116
+ }
117
+ end
118
+
119
+ private
120
+
121
+ def symbolize_keys(hash)
122
+ hash.each_with_object({}) do |(key, value), result|
123
+ result[key.to_sym] = value
124
+ end
125
+ end
126
+ end
127
+
128
+ def group_block_start?(line)
129
+ line.match?(/\Agroup\s+.+\s+do\s*\z/)
130
+ end
131
+
132
+ def generic_block_start?(line)
133
+ return false if line.start_with?("#")
134
+ return false if group_block_start?(line)
135
+
136
+ line.match?(/\bdo\b\s*(\|.*\|)?\s*\z/)
137
+ end
138
+
139
+ def parse_group_names(line)
140
+ line
141
+ .sub(/\Agroup\s+/, "")
142
+ .sub(/\s+do\s*\z/, "")
143
+ .split(",")
144
+ .map { |item| item.strip.delete_prefix(":").delete_prefix('"').delete_suffix('"').delete_prefix("'").delete_suffix("'").to_sym }
145
+ end
146
+
147
+ def current_groups(block_stack)
148
+ block_stack.flat_map { |entry| entry[:groups] }.uniq
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module GemXray
6
+ class RailsKnowledge
7
+ Change = Struct.new(:gem_name, :since, :reason, :source, keyword_init: true)
8
+
9
+ def initialize(data_path: File.join(GemXray.root, "data", "rails_changes.yml"))
10
+ @data_path = data_path
11
+ end
12
+
13
+ def changes_for(rails_version)
14
+ return [] unless rails_version
15
+
16
+ data.fetch("versions", {}).each_with_object([]) do |(since, payload), changes|
17
+ next if Gem::Version.new(rails_version) < Gem::Version.new(since)
18
+
19
+ Array(payload["removals"]).each do |item|
20
+ changes << Change.new(
21
+ gem_name: item.fetch("gem"),
22
+ since: since,
23
+ reason: item.fetch("reason"),
24
+ source: item["source"]
25
+ )
26
+ end
27
+ end
28
+ rescue ArgumentError
29
+ []
30
+ end
31
+
32
+ def find_removal(gem_name, rails_version)
33
+ changes_for(rails_version).reverse.find { |change| change.gem_name == gem_name }
34
+ end
35
+
36
+ private
37
+
38
+ def data
39
+ @data ||= YAML.safe_load(File.read(@data_path))
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GemXray
4
+ class Report
5
+ attr_reader :version, :ruby_version, :rails_version, :scanned_at, :results
6
+
7
+ def initialize(version:, ruby_version:, rails_version:, scanned_at:, results:)
8
+ @version = version
9
+ @ruby_version = ruby_version
10
+ @rails_version = rails_version
11
+ @scanned_at = scanned_at
12
+ @results = results
13
+ end
14
+
15
+ def summary
16
+ {
17
+ total: results.length,
18
+ danger: results.count { |result| result.severity == :danger },
19
+ warning: results.count { |result| result.severity == :warning },
20
+ info: results.count { |result| result.severity == :info }
21
+ }
22
+ end
23
+
24
+ def to_h
25
+ {
26
+ version: version,
27
+ ruby_version: ruby_version,
28
+ rails_version: rails_version,
29
+ scanned_at: scanned_at,
30
+ results: results.map(&:to_h),
31
+ summary: summary
32
+ }
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GemXray
4
+ class Result
5
+ SEVERITIES = { danger: 0, warning: 1, info: 2 }.freeze
6
+
7
+ Reason = Struct.new(:type, :detail, :severity, keyword_init: true) do
8
+ def to_h
9
+ { type: type.to_s, detail: detail }
10
+ end
11
+ end
12
+
13
+ attr_reader :gem_name, :gemfile_line, :gemfile_end_line, :gemfile_group, :suggestion, :reasons
14
+ attr_accessor :severity
15
+
16
+ def initialize(gem_name:, gemfile_line: nil, gemfile_end_line: nil, gemfile_group: nil, suggestion: nil,
17
+ reasons: [], severity: nil)
18
+ @gem_name = gem_name
19
+ @gemfile_line = gemfile_line
20
+ @gemfile_end_line = gemfile_end_line || gemfile_line
21
+ @gemfile_group = gemfile_group
22
+ @suggestion = suggestion || "Consider removing this entry from Gemfile"
23
+ @reasons = reasons.dup
24
+ @severity = severity || infer_severity
25
+ end
26
+
27
+ def add_reason(type:, detail:, severity:)
28
+ reasons << Reason.new(type: type.to_sym, detail: detail, severity: severity.to_sym)
29
+ self.severity = infer_severity
30
+ self
31
+ end
32
+
33
+ def merge!(other)
34
+ other.reasons.each do |reason|
35
+ add_reason(type: reason.type, detail: reason.detail, severity: reason.severity)
36
+ end
37
+ self.gemfile_line ||= other.gemfile_line
38
+ self.gemfile_end_line ||= other.gemfile_end_line
39
+ self.gemfile_group ||= other.gemfile_group
40
+ self.suggestion ||= other.suggestion
41
+ self
42
+ end
43
+
44
+ def severity_order
45
+ SEVERITIES.fetch(severity)
46
+ end
47
+
48
+ def reason_types
49
+ reasons.map(&:type).uniq
50
+ end
51
+
52
+ def type_label
53
+ reason_types.map { |type| type.to_s.tr("_", "-") }.join(" + ")
54
+ end
55
+
56
+ def danger?
57
+ severity == :danger
58
+ end
59
+
60
+ def info?
61
+ severity == :info
62
+ end
63
+
64
+ def to_h
65
+ {
66
+ gem_name: gem_name,
67
+ severity: severity.to_s,
68
+ reasons: reasons.map(&:to_h),
69
+ gemfile_line: gemfile_line,
70
+ gemfile_group: gemfile_group,
71
+ suggestion: suggestion
72
+ }
73
+ end
74
+
75
+ protected
76
+
77
+ attr_writer :gemfile_line, :gemfile_end_line, :gemfile_group, :suggestion
78
+
79
+ private
80
+
81
+ def infer_severity
82
+ severities = reasons.map(&:severity)
83
+ severities.min_by { |value| SEVERITIES.fetch(value) } || :info
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GemXray
4
+ class Scanner
5
+ ANALYZERS = {
6
+ unused: GemXray::Analyzers::UnusedAnalyzer,
7
+ redundant: GemXray::Analyzers::RedundantAnalyzer,
8
+ version: GemXray::Analyzers::VersionAnalyzer
9
+ }.freeze
10
+
11
+ def initialize(config)
12
+ @config = config
13
+ @gemfile_parser = GemfileParser.new(config.gemfile_path)
14
+ end
15
+
16
+ def run
17
+ gems = gemfile_parser.parse
18
+ results = build_analyzers.flat_map { |analyzer| analyzer.analyze(gems) }
19
+ merged_results = merge_results(results)
20
+
21
+ merged_results.each do |result|
22
+ override = config.override_severity_for(result.gem_name)
23
+ result.severity = override if override
24
+ end
25
+
26
+ filtered = merged_results.select { |result| config.severity_in_scope?(result.severity) }
27
+ sorted = filtered.sort_by { |result| [result.severity_order, result.gem_name] }
28
+
29
+ Report.new(
30
+ version: GemXray::VERSION,
31
+ ruby_version: gemfile_parser.ruby_version,
32
+ rails_version: gemfile_parser.rails_version(gems),
33
+ scanned_at: Time.now.iso8601,
34
+ results: sorted
35
+ )
36
+ end
37
+
38
+ private
39
+
40
+ attr_reader :config, :gemfile_parser
41
+
42
+ def build_analyzers
43
+ selected = config.only || ANALYZERS.keys
44
+ code_snapshot = CodeScanner.new(config).scan if selected.include?(:unused)
45
+ dependency_resolver = DependencyResolver.new(gemfile_parser.dependency_tree)
46
+ stdgems_client = StdgemsClient.new
47
+ rails_knowledge = RailsKnowledge.new
48
+ gem_metadata_resolver = GemMetadataResolver.new
49
+
50
+ selected.map do |type|
51
+ ANALYZERS.fetch(type).new(
52
+ config: config,
53
+ gemfile_parser: gemfile_parser,
54
+ code_snapshot: code_snapshot,
55
+ dependency_resolver: dependency_resolver,
56
+ stdgems_client: stdgems_client,
57
+ rails_knowledge: rails_knowledge,
58
+ gem_metadata_resolver: gem_metadata_resolver
59
+ )
60
+ end
61
+ end
62
+
63
+ def merge_results(results)
64
+ results.each_with_object({}) do |result, merged|
65
+ merged[result.gem_name] =
66
+ if merged.key?(result.gem_name)
67
+ merged[result.gem_name].merge!(result)
68
+ else
69
+ result
70
+ end
71
+ end.values
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "net/http"
6
+ require "uri"
7
+
8
+ module GemXray
9
+ class StdgemsClient
10
+ CACHE_TTL = 86_400
11
+ DEFAULT_GEMS_URI = URI("https://stdgems.org/default_gems.json")
12
+ BUNDLED_GEMS_URI = URI("https://stdgems.org/bundled_gems.json")
13
+ FALLBACK_DEFAULT_GEMS = {
14
+ "3.1" => %w[
15
+ abbrev base64 benchmark bigdecimal cgi csv date delegate did_you_mean
16
+ digest drb english erb error_highlight fileutils find io-console
17
+ irb json logger mutex_m net-http net-imap net-pop net-protocol
18
+ net-smtp observer open-uri open3 openssl optparse ostruct pp prettyprint
19
+ prime pstore psych rake rdoc readline resolv rexml rss ruby2_keywords
20
+ securerandom set shell socket stringio strscan tempfile time timeout
21
+ tmpdir tsort typeprof un uri weakref yaml zlib
22
+ ],
23
+ "3.2" => %w[
24
+ abbrev base64 benchmark bigdecimal cgi csv date delegate did_you_mean
25
+ digest drb english erb error_highlight fileutils find io-console
26
+ irb json logger mutex_m net-http net-imap net-pop net-protocol
27
+ net-smtp observer open-uri open3 openssl optparse ostruct pp prettyprint
28
+ prime pstore psych rake rdoc readline resolv rexml rss securerandom
29
+ set shell socket stringio strscan syntax_suggest tempfile time timeout
30
+ tmpdir tsort un uri weakref yaml zlib
31
+ ],
32
+ "3.3" => %w[
33
+ abbrev base64 benchmark bigdecimal cgi csv date delegate did_you_mean
34
+ digest drb english erb error_highlight fileutils find io-console
35
+ irb json logger mutex_m net-http net-imap net-pop net-protocol
36
+ net-smtp observer open-uri open3 openssl optparse ostruct pp prettyprint
37
+ prime pstore psych rake rdoc readline resolv rexml rss securerandom
38
+ set shell socket stringio strscan syntax_suggest tempfile time timeout
39
+ tmpdir tsort un uri weakref yaml zlib
40
+ ],
41
+ "4.0" => Gem::Specification.select { |spec| spec.respond_to?(:default_gem?) && spec.default_gem? }.map(&:name).sort
42
+ }.freeze
43
+ FALLBACK_BUNDLED_GEMS = {
44
+ "3.1" => %w[debug matrix minitest power_assert racc rake rbs rexml test-unit typeprof],
45
+ "3.2" => %w[debug matrix minitest power_assert racc rake rbs rexml test-unit typeprof],
46
+ "3.3" => %w[debug matrix minitest power_assert racc rake rbs rexml test-unit typeprof],
47
+ "4.0" => %w[debug matrix minitest power_assert racc rake rbs rexml test-unit typeprof]
48
+ }.freeze
49
+
50
+ def initialize(cache_dir: File.join(Dir.home, ".gemxray", "cache"))
51
+ @cache_dir = cache_dir
52
+ end
53
+
54
+ def default_gems_for(version)
55
+ version_key = normalize_version(version)
56
+ extracted = extract_gems(payload_for(:default), version_key, collection_keys: %w[default_gems])
57
+
58
+ return extracted if extracted && !extracted.empty?
59
+
60
+ fallback_default_gems(version_key)
61
+ end
62
+
63
+ def bundled_gems_for(version)
64
+ version_key = normalize_version(version)
65
+ extracted = extract_gems(payload_for(:bundled), version_key, collection_keys: %w[bundled_gems default_gems])
66
+
67
+ return extracted if extracted && !extracted.empty?
68
+
69
+ fallback_bundled_gems(version_key)
70
+ end
71
+
72
+ private
73
+
74
+ attr_reader :cache_dir
75
+
76
+ def cache_path(kind)
77
+ File.join(cache_dir, "#{kind}_gems.json")
78
+ end
79
+
80
+ def normalize_version(version)
81
+ version.to_s[/\d+\.\d+/] || RUBY_VERSION[/\d+\.\d+/]
82
+ end
83
+
84
+ def payload_for(kind)
85
+ cached_payload(kind) || fetch_and_cache_payload(kind)
86
+ end
87
+
88
+ def cached_payload(kind)
89
+ return nil unless File.exist?(cache_path(kind))
90
+ return nil if Time.now - File.mtime(cache_path(kind)) > CACHE_TTL
91
+
92
+ JSON.parse(File.read(cache_path(kind)))
93
+ rescue StandardError
94
+ nil
95
+ end
96
+
97
+ def fetch_and_cache_payload(kind)
98
+ response = Net::HTTP.get_response(kind == :bundled ? BUNDLED_GEMS_URI : DEFAULT_GEMS_URI)
99
+ return nil unless response.is_a?(Net::HTTPSuccess)
100
+
101
+ FileUtils.mkdir_p(cache_dir)
102
+ File.write(cache_path(kind), response.body)
103
+ JSON.parse(response.body)
104
+ rescue StandardError
105
+ nil
106
+ end
107
+
108
+ def extract_gems(payload, version_key, collection_keys:)
109
+ return nil unless payload
110
+
111
+ case payload
112
+ when Hash
113
+ extract_from_hash(payload, version_key, collection_keys)
114
+ when Array
115
+ extract_from_array(payload, version_key)
116
+ end
117
+ end
118
+
119
+ def extract_from_hash(payload, version_key, collection_keys)
120
+ candidates = [payload[version_key]]
121
+ collection_keys.each do |key|
122
+ candidates << payload.dig(key, version_key)
123
+ candidates << payload.dig(version_key, key)
124
+ end
125
+ candidates.compact!
126
+
127
+ return normalize_payload_list(candidates.first) unless candidates.empty?
128
+
129
+ payload.each do |key, value|
130
+ return normalize_payload_list(value) if key.to_s.start_with?(version_key)
131
+ end
132
+
133
+ nil
134
+ end
135
+
136
+ def extract_from_array(payload, version_key)
137
+ matched = payload.select do |item|
138
+ next false unless item.is_a?(Hash)
139
+
140
+ item_version = item["ruby_version"] || item["version"]
141
+ item_version.to_s.start_with?(version_key)
142
+ end
143
+
144
+ names = matched.filter_map { |item| item["name"] || item["gem"] }.uniq
145
+ names.empty? ? nil : names.sort
146
+ end
147
+
148
+ def normalize_payload_list(value)
149
+ case value
150
+ when Array
151
+ value.map do |item|
152
+ item.is_a?(Hash) ? (item["name"] || item["gem"]) : item
153
+ end.compact.map(&:to_s).sort
154
+ when Hash
155
+ value.keys.map(&:to_s).sort
156
+ end
157
+ end
158
+
159
+ def fallback_default_gems(version_key)
160
+ exact = FALLBACK_DEFAULT_GEMS[version_key]
161
+ return exact if exact
162
+
163
+ major_minor = FALLBACK_DEFAULT_GEMS.keys.sort.reverse.find { |key| key <= version_key }
164
+ FALLBACK_DEFAULT_GEMS[major_minor] || []
165
+ end
166
+
167
+ def fallback_bundled_gems(version_key)
168
+ exact = FALLBACK_BUNDLED_GEMS[version_key]
169
+ return exact if exact
170
+
171
+ major_minor = FALLBACK_BUNDLED_GEMS.keys.sort.reverse.find { |key| key <= version_key }
172
+ FALLBACK_BUNDLED_GEMS[major_minor] || []
173
+ end
174
+ end
175
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GemXray
4
+ VERSION = "0.1.0"
5
+ end
data/lib/gemxray.rb ADDED
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ require_relative "gemxray/version"
6
+ require_relative "gemxray/gem_entry"
7
+ require_relative "gemxray/config"
8
+ require_relative "gemxray/result"
9
+ require_relative "gemxray/report"
10
+ require_relative "gemxray/gemfile_source_parser"
11
+ require_relative "gemxray/gemfile_parser"
12
+ require_relative "gemxray/dependency_resolver"
13
+ require_relative "gemxray/code_scanner"
14
+ require_relative "gemxray/gem_metadata_resolver"
15
+ require_relative "gemxray/stdgems_client"
16
+ require_relative "gemxray/rails_knowledge"
17
+ require_relative "gemxray/analyzers/base"
18
+ require_relative "gemxray/analyzers/unused_analyzer"
19
+ require_relative "gemxray/analyzers/redundant_analyzer"
20
+ require_relative "gemxray/analyzers/version_analyzer"
21
+ require_relative "gemxray/scanner"
22
+ require_relative "gemxray/formatters/terminal"
23
+ require_relative "gemxray/formatters/json"
24
+ require_relative "gemxray/formatters/yaml"
25
+ require_relative "gemxray/editors/gemfile_editor"
26
+ require_relative "gemxray/editors/github_api_client"
27
+ require_relative "gemxray/editors/github_pr"
28
+ require_relative "gemxray/cli"
29
+
30
+ module GemXray
31
+ class Error < StandardError; end
32
+
33
+ def self.root
34
+ File.expand_path("..", __dir__)
35
+ end
36
+ end
metadata ADDED
@@ -0,0 +1,76 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: gemxray
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Yudai Takada
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: gemxray scans Gemfile, code references, and Gemfile.lock to find unused,
13
+ redundant, or version-redundant gems.
14
+ email:
15
+ - t.yudai92@gmail.com
16
+ executables:
17
+ - gemxray
18
+ extensions: []
19
+ extra_rdoc_files: []
20
+ files:
21
+ - CHANGELOG.md
22
+ - LICENSE.txt
23
+ - README.md
24
+ - Rakefile
25
+ - data/rails_changes.yml
26
+ - exe/gemxray
27
+ - lib/gemxray.rb
28
+ - lib/gemxray/analyzers/base.rb
29
+ - lib/gemxray/analyzers/redundant_analyzer.rb
30
+ - lib/gemxray/analyzers/unused_analyzer.rb
31
+ - lib/gemxray/analyzers/version_analyzer.rb
32
+ - lib/gemxray/cli.rb
33
+ - lib/gemxray/code_scanner.rb
34
+ - lib/gemxray/config.rb
35
+ - lib/gemxray/dependency_resolver.rb
36
+ - lib/gemxray/editors/gemfile_editor.rb
37
+ - lib/gemxray/editors/github_api_client.rb
38
+ - lib/gemxray/editors/github_pr.rb
39
+ - lib/gemxray/formatters/json.rb
40
+ - lib/gemxray/formatters/terminal.rb
41
+ - lib/gemxray/formatters/yaml.rb
42
+ - lib/gemxray/gem_entry.rb
43
+ - lib/gemxray/gem_metadata_resolver.rb
44
+ - lib/gemxray/gemfile_parser.rb
45
+ - lib/gemxray/gemfile_source_parser.rb
46
+ - lib/gemxray/rails_knowledge.rb
47
+ - lib/gemxray/report.rb
48
+ - lib/gemxray/result.rb
49
+ - lib/gemxray/scanner.rb
50
+ - lib/gemxray/stdgems_client.rb
51
+ - lib/gemxray/version.rb
52
+ homepage: https://github.com/ydah/gemxray
53
+ licenses:
54
+ - MIT
55
+ metadata:
56
+ allowed_push_host: https://rubygems.org
57
+ homepage_uri: https://github.com/ydah/gemxray
58
+ source_code_uri: https://github.com/ydah/gemxray
59
+ rdoc_options: []
60
+ require_paths:
61
+ - lib
62
+ required_ruby_version: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - ">="
65
+ - !ruby/object:Gem::Version
66
+ version: 3.1.0
67
+ required_rubygems_version: !ruby/object:Gem::Requirement
68
+ requirements:
69
+ - - ">="
70
+ - !ruby/object:Gem::Version
71
+ version: '0'
72
+ requirements: []
73
+ rubygems_version: 4.0.6
74
+ specification_version: 4
75
+ summary: CLI to detect removable gems in a Gemfile.
76
+ test_files: []