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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +151 -0
- data/Rakefile +8 -0
- data/data/rails_changes.yml +6 -0
- data/exe/gemxray +6 -0
- data/lib/gemxray/analyzers/base.rb +59 -0
- data/lib/gemxray/analyzers/redundant_analyzer.rb +72 -0
- data/lib/gemxray/analyzers/unused_analyzer.rb +45 -0
- data/lib/gemxray/analyzers/version_analyzer.rb +44 -0
- data/lib/gemxray/cli.rb +229 -0
- data/lib/gemxray/code_scanner.rb +143 -0
- data/lib/gemxray/config.rb +215 -0
- data/lib/gemxray/dependency_resolver.rb +48 -0
- data/lib/gemxray/editors/gemfile_editor.rb +99 -0
- data/lib/gemxray/editors/github_api_client.rb +56 -0
- data/lib/gemxray/editors/github_pr.rb +222 -0
- data/lib/gemxray/formatters/json.rb +13 -0
- data/lib/gemxray/formatters/terminal.rb +32 -0
- data/lib/gemxray/formatters/yaml.rb +13 -0
- data/lib/gemxray/gem_entry.rb +64 -0
- data/lib/gemxray/gem_metadata_resolver.rb +179 -0
- data/lib/gemxray/gemfile_parser.rb +133 -0
- data/lib/gemxray/gemfile_source_parser.rb +151 -0
- data/lib/gemxray/rails_knowledge.rb +42 -0
- data/lib/gemxray/report.rb +35 -0
- data/lib/gemxray/result.rb +86 -0
- data/lib/gemxray/scanner.rb +74 -0
- data/lib/gemxray/stdgems_client.rb +175 -0
- data/lib/gemxray/version.rb +5 -0
- data/lib/gemxray.rb +36 -0
- metadata +76 -0
|
@@ -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
|
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: []
|