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,222 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require "pathname"
|
|
5
|
+
|
|
6
|
+
module GemXray
|
|
7
|
+
module Editors
|
|
8
|
+
class GithubPr
|
|
9
|
+
attr_reader :config
|
|
10
|
+
|
|
11
|
+
def initialize(config)
|
|
12
|
+
@config = config
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def create(results, per_gem: config.github_per_gem?, bundle_install: config.github_bundle_install?, comment: false)
|
|
16
|
+
ensure_git_repository!
|
|
17
|
+
ensure_clean_worktree!
|
|
18
|
+
|
|
19
|
+
pull_requests =
|
|
20
|
+
if per_gem
|
|
21
|
+
create_per_gem_pull_requests(results, bundle_install: bundle_install, comment: comment)
|
|
22
|
+
else
|
|
23
|
+
[create_single_pull_request(results, bundle_install: bundle_install, comment: comment)]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
raise Error, "no Gemfile changes were created" if pull_requests.empty?
|
|
27
|
+
|
|
28
|
+
primary = pull_requests.first
|
|
29
|
+
{
|
|
30
|
+
branch: primary[:branch],
|
|
31
|
+
commits: primary[:commits],
|
|
32
|
+
pr_url: primary[:pr_url],
|
|
33
|
+
pull_requests: pull_requests
|
|
34
|
+
}
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def create_single_pull_request(results, bundle_install:, comment:)
|
|
40
|
+
branch_name = branch_name_for
|
|
41
|
+
create_branch_from_base!(branch_name)
|
|
42
|
+
|
|
43
|
+
editor = GemfileEditor.new(config.gemfile_path)
|
|
44
|
+
commits = [create_single_commit(editor, results, comment: comment)].compact
|
|
45
|
+
lockfile_commit = install_and_commit_lockfile(editor) if bundle_install
|
|
46
|
+
commits << lockfile_commit if lockfile_commit
|
|
47
|
+
|
|
48
|
+
raise Error, "no Gemfile changes were created" if commits.empty?
|
|
49
|
+
|
|
50
|
+
push_branch!(branch_name)
|
|
51
|
+
pr_url = create_pull_request!(results, branch_name)
|
|
52
|
+
{ branch: branch_name, commits: commits, pr_url: pr_url, gem_names: results.map(&:gem_name) }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def create_per_gem_pull_requests(results, bundle_install:, comment:)
|
|
56
|
+
results.filter_map do |result|
|
|
57
|
+
branch_name = branch_name_for(result.gem_name)
|
|
58
|
+
create_branch_from_base!(branch_name)
|
|
59
|
+
|
|
60
|
+
editor = GemfileEditor.new(config.gemfile_path)
|
|
61
|
+
commits = [create_single_commit(editor, [result], comment: comment)].compact
|
|
62
|
+
lockfile_commit = install_and_commit_lockfile(editor) if bundle_install
|
|
63
|
+
commits << lockfile_commit if lockfile_commit
|
|
64
|
+
next if commits.empty?
|
|
65
|
+
|
|
66
|
+
push_branch!(branch_name)
|
|
67
|
+
pr_url = create_pull_request!([result], branch_name)
|
|
68
|
+
{
|
|
69
|
+
gem_name: result.gem_name,
|
|
70
|
+
branch: branch_name,
|
|
71
|
+
commits: commits,
|
|
72
|
+
pr_url: pr_url
|
|
73
|
+
}
|
|
74
|
+
ensure
|
|
75
|
+
checkout_base_branch!
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def install_and_commit_lockfile(editor)
|
|
80
|
+
editor.bundle_install!
|
|
81
|
+
lockfile = "#{config.gemfile_path}.lock"
|
|
82
|
+
return nil unless File.exist?(lockfile)
|
|
83
|
+
return nil unless tracked_changes?(relative_path(lockfile))
|
|
84
|
+
|
|
85
|
+
stage_and_commit!("chore: refresh Gemfile.lock after gem sweep", relative_path(lockfile))
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def stage_and_commit!(message, *files)
|
|
89
|
+
run!("git", "add", *files)
|
|
90
|
+
run!("git", "commit", "-m", message)
|
|
91
|
+
message
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def create_single_commit(editor, results, comment:)
|
|
95
|
+
outcome = editor.apply(results, dry_run: false, comment: comment, backup: false)
|
|
96
|
+
return nil if outcome.removed.empty?
|
|
97
|
+
|
|
98
|
+
commit_message = if outcome.removed.one?
|
|
99
|
+
"chore: remove #{outcome.removed.first} from Gemfile"
|
|
100
|
+
else
|
|
101
|
+
"chore: sweep redundant gems"
|
|
102
|
+
end
|
|
103
|
+
stage_and_commit!(commit_message, relative_path(config.gemfile_path))
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def create_pull_request!(results, branch_name)
|
|
107
|
+
title = "chore: gemxray cleanup"
|
|
108
|
+
body = build_pr_body(results, branch_name)
|
|
109
|
+
create_pull_request_with_gh(title: title, body: body, branch_name: branch_name) || create_pull_request_with_api(
|
|
110
|
+
title: title,
|
|
111
|
+
body: body,
|
|
112
|
+
branch_name: branch_name
|
|
113
|
+
)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def build_pr_body(results, branch_name)
|
|
117
|
+
<<~BODY
|
|
118
|
+
## Summary
|
|
119
|
+
|
|
120
|
+
gemxray generated this cleanup on branch `#{branch_name}`.
|
|
121
|
+
|
|
122
|
+
## Removed Gems
|
|
123
|
+
|
|
124
|
+
#{results.map { |result| "- `#{result.gem_name}`" }.join("\n")}
|
|
125
|
+
|
|
126
|
+
## Detection Grounds
|
|
127
|
+
|
|
128
|
+
#{results.map { |result| "- `#{result.gem_name}`: #{result.reasons.map(&:detail).join(' / ')}" }.join("\n")}
|
|
129
|
+
|
|
130
|
+
## Checklist
|
|
131
|
+
|
|
132
|
+
- [ ] App boot check
|
|
133
|
+
- [ ] Primary workflow check
|
|
134
|
+
- [ ] `bundle exec rake spec`
|
|
135
|
+
BODY
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def ensure_git_repository!
|
|
139
|
+
run!("git", "rev-parse", "--git-dir")
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def ensure_clean_worktree!
|
|
143
|
+
raise Error, "git worktree must be clean before creating a cleanup PR" if tracked_changes?
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def create_branch_from_base!(branch_name)
|
|
147
|
+
checkout_base_branch!
|
|
148
|
+
run!("git", "switch", "-c", branch_name)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def checkout_base_branch!
|
|
152
|
+
run!("git", "switch", config.github_base_branch)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def push_branch!(branch_name)
|
|
156
|
+
run!("git", "push", "-u", "origin", branch_name)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def run!(*command)
|
|
160
|
+
stdout, stderr, status = Open3.capture3(*command, chdir: config.project_root)
|
|
161
|
+
return stdout if status.success?
|
|
162
|
+
|
|
163
|
+
message = [stderr, stdout].map(&:strip).reject(&:empty?).first
|
|
164
|
+
raise Error, "#{command.join(' ')} failed: #{message}"
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def relative_path(path)
|
|
168
|
+
Pathname.new(path).relative_path_from(Pathname.new(config.project_root)).to_s
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def tracked_changes?(*paths)
|
|
172
|
+
args = ["git", "status", "--short"]
|
|
173
|
+
args.concat(paths) unless paths.empty?
|
|
174
|
+
!run!(*args).strip.empty?
|
|
175
|
+
rescue Error
|
|
176
|
+
true
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def create_pull_request_with_gh(title:, body:, branch_name:)
|
|
180
|
+
command = ["gh", "pr", "create", "--base", config.github_base_branch, "--head", branch_name, "--title", title,
|
|
181
|
+
"--body", body]
|
|
182
|
+
config.github_labels.each { |label| command += ["--label", label] }
|
|
183
|
+
config.github_reviewers.each { |reviewer| command += ["--reviewer", reviewer] }
|
|
184
|
+
run!(*command).strip
|
|
185
|
+
rescue Error
|
|
186
|
+
nil
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def create_pull_request_with_api(title:, body:, branch_name:)
|
|
190
|
+
token = ENV["GH_TOKEN"] || ENV["GITHUB_TOKEN"]
|
|
191
|
+
raise Error, "gh is unavailable and no GitHub token is configured for API fallback" if token.to_s.empty?
|
|
192
|
+
|
|
193
|
+
client = GithubApiClient.new(token: token, repository: repository_slug)
|
|
194
|
+
client.create_pull_request(
|
|
195
|
+
base: config.github_base_branch,
|
|
196
|
+
head: branch_name,
|
|
197
|
+
title: title,
|
|
198
|
+
body: body,
|
|
199
|
+
labels: config.github_labels,
|
|
200
|
+
reviewers: config.github_reviewers
|
|
201
|
+
)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def repository_slug
|
|
205
|
+
return ENV["GITHUB_REPOSITORY"] unless ENV["GITHUB_REPOSITORY"].to_s.empty?
|
|
206
|
+
|
|
207
|
+
remote = run!("git", "remote", "get-url", "origin").strip
|
|
208
|
+
remote[%r{github\.com[:/](.+?)(?:\.git)?$}, 1] || raise(Error, "cannot determine GitHub repository from git remote")
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def branch_name_for(gem_name = nil)
|
|
212
|
+
suffix = sanitize_branch_component(gem_name)
|
|
213
|
+
base = "gemxray/cleanup-#{Time.now.strftime('%Y%m%d')}"
|
|
214
|
+
suffix.empty? ? base : "#{base}-#{suffix}"
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def sanitize_branch_component(value)
|
|
218
|
+
value.to_s.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-+|-+\z/, "")
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GemXray
|
|
4
|
+
module Formatters
|
|
5
|
+
class Terminal
|
|
6
|
+
HEADER = "🧹 gemxray scan results"
|
|
7
|
+
SEPARATOR = "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
8
|
+
|
|
9
|
+
def render(report)
|
|
10
|
+
lines = [HEADER, SEPARATOR, ""]
|
|
11
|
+
|
|
12
|
+
if report.results.empty?
|
|
13
|
+
lines << "No issues found."
|
|
14
|
+
else
|
|
15
|
+
report.results.each do |result|
|
|
16
|
+
lines << "[#{result.severity.to_s.upcase}] #{result.gem_name} (#{result.type_label})"
|
|
17
|
+
result.reasons.each_with_index do |reason, index|
|
|
18
|
+
marker = index == result.reasons.length - 1 ? "└─" : "├─"
|
|
19
|
+
lines << " #{marker} #{reason.detail}"
|
|
20
|
+
end
|
|
21
|
+
lines << ""
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
summary = report.summary
|
|
26
|
+
lines << SEPARATOR
|
|
27
|
+
lines << "検出: #{summary[:total]}件 (DANGER: #{summary[:danger]}, WARNING: #{summary[:warning]}, INFO: #{summary[:info]})"
|
|
28
|
+
lines.join("\n")
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GemXray
|
|
4
|
+
class GemEntry
|
|
5
|
+
attr_reader :name, :version, :groups, :line_number, :end_line, :source_line, :autorequire, :options
|
|
6
|
+
|
|
7
|
+
def initialize(name:, version: nil, groups: [], line_number: nil, end_line: nil, source_line: nil, autorequire: nil,
|
|
8
|
+
options: {})
|
|
9
|
+
@name = name
|
|
10
|
+
@version = normalize_version(version)
|
|
11
|
+
@groups = Array(groups).map(&:to_sym).reject { |group| group == :default }.uniq
|
|
12
|
+
@line_number = line_number
|
|
13
|
+
@end_line = end_line || line_number
|
|
14
|
+
@source_line = source_line
|
|
15
|
+
@autorequire = autorequire
|
|
16
|
+
@options = options
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def pinned_version?
|
|
20
|
+
!version.nil?
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def development_group?
|
|
24
|
+
!(groups & %i[development test]).empty?
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def gemfile_group
|
|
28
|
+
return nil if groups.empty?
|
|
29
|
+
return groups.first.to_s if groups.one?
|
|
30
|
+
|
|
31
|
+
groups.map(&:to_s)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def line_range
|
|
35
|
+
return nil unless line_number
|
|
36
|
+
|
|
37
|
+
line_number..(end_line || line_number)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def require_names
|
|
41
|
+
case autorequire
|
|
42
|
+
when false
|
|
43
|
+
[]
|
|
44
|
+
when nil
|
|
45
|
+
default_require_names
|
|
46
|
+
else
|
|
47
|
+
Array(autorequire).compact.map(&:to_s).uniq
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def normalize_version(value)
|
|
54
|
+
return nil if value.nil?
|
|
55
|
+
|
|
56
|
+
text = value.to_s.strip
|
|
57
|
+
text.empty? || text == ">= 0" ? nil : text
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def default_require_names
|
|
61
|
+
[name, name.tr("-", "/"), name.tr("-", "_")].uniq
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "rubygems/package"
|
|
5
|
+
require "rubygems/remote_fetcher"
|
|
6
|
+
require "rubygems/spec_fetcher"
|
|
7
|
+
|
|
8
|
+
module GemXray
|
|
9
|
+
class GemMetadataResolver
|
|
10
|
+
CONSTANT_PATTERN = /^\s*(?:class|module)\s+([A-Z][A-Za-z0-9_:]*)/.freeze
|
|
11
|
+
RemotePackage = Struct.new(:full_gem_path, :require_paths, keyword_init: true)
|
|
12
|
+
|
|
13
|
+
def initialize(cache_dir: File.join(Dir.home, ".gemxray", "cache", "gem_metadata"),
|
|
14
|
+
spec_fetcher: Gem::SpecFetcher.fetcher,
|
|
15
|
+
remote_fetcher: Gem::RemoteFetcher.fetcher)
|
|
16
|
+
@cache_dir = cache_dir
|
|
17
|
+
@spec_fetcher = spec_fetcher
|
|
18
|
+
@remote_fetcher = remote_fetcher
|
|
19
|
+
@constant_cache = {}
|
|
20
|
+
@railtie_cache = {}
|
|
21
|
+
@remote_package_cache = {}
|
|
22
|
+
@remote_fetch_available = true
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def constant_candidates_for(gem_name, version_requirement: nil)
|
|
26
|
+
@constant_cache[cache_key(gem_name, version_requirement)] ||= begin
|
|
27
|
+
defaults = default_constant_candidates(gem_name)
|
|
28
|
+
discovered = gem_sources_for(gem_name, version_requirement).flat_map do |spec|
|
|
29
|
+
extract_constants(spec, gem_name)
|
|
30
|
+
end
|
|
31
|
+
(defaults + discovered).uniq
|
|
32
|
+
rescue StandardError
|
|
33
|
+
default_constant_candidates(gem_name)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def railtie?(gem_name, version_requirement: nil)
|
|
38
|
+
key = cache_key(gem_name, version_requirement)
|
|
39
|
+
return @railtie_cache[key] if @railtie_cache.key?(key)
|
|
40
|
+
|
|
41
|
+
@railtie_cache[key] = gem_sources_for(gem_name, version_requirement).any? do |spec|
|
|
42
|
+
all_ruby_files_for(spec, gem_name).any? do |path|
|
|
43
|
+
File.read(path, encoding: "utf-8").match?(/Rails::(?:Railtie|Engine)/)
|
|
44
|
+
rescue StandardError
|
|
45
|
+
false
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
rescue StandardError
|
|
49
|
+
@railtie_cache[key] = false
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
attr_reader :cache_dir, :spec_fetcher, :remote_fetcher
|
|
55
|
+
|
|
56
|
+
def cache_key(gem_name, version_requirement)
|
|
57
|
+
"#{gem_name}@#{version_requirement || 'latest'}"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def gem_sources_for(gem_name, version_requirement)
|
|
61
|
+
installed = installed_specs_for(gem_name, version_requirement)
|
|
62
|
+
return installed unless installed.empty?
|
|
63
|
+
|
|
64
|
+
remote_package = remote_package_for(gem_name, version_requirement)
|
|
65
|
+
remote_package ? [remote_package] : []
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def installed_specs_for(gem_name, version_requirement)
|
|
69
|
+
requirement = build_requirement(version_requirement)
|
|
70
|
+
Gem::Specification.find_all_by_name(gem_name).select do |spec|
|
|
71
|
+
requirement.satisfied_by?(spec.version)
|
|
72
|
+
end
|
|
73
|
+
rescue StandardError
|
|
74
|
+
[]
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def remote_package_for(gem_name, version_requirement)
|
|
78
|
+
return nil unless @remote_fetch_available
|
|
79
|
+
|
|
80
|
+
key = cache_key(gem_name, version_requirement)
|
|
81
|
+
return @remote_package_cache[key] if @remote_package_cache.key?(key)
|
|
82
|
+
|
|
83
|
+
dependency = Gem::Dependency.new(gem_name, version_requirement || ">= 0")
|
|
84
|
+
found, errors = spec_fetcher.spec_for_dependency(dependency)
|
|
85
|
+
if found.empty?
|
|
86
|
+
@remote_fetch_available = false if errors.any?
|
|
87
|
+
return @remote_package_cache[key] = nil
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
spec, source = found.max_by { |(remote_spec, _)| remote_spec.version }
|
|
91
|
+
gem_path = remote_fetcher.download(spec, source.uri, cache_download_dir)
|
|
92
|
+
unpacked_path = unpack_gem(spec, gem_path)
|
|
93
|
+
@remote_package_cache[key] = RemotePackage.new(full_gem_path: unpacked_path, require_paths: Array(spec.require_paths))
|
|
94
|
+
rescue Gem::RemoteFetcher::FetchError, Gem::GemNotFoundException
|
|
95
|
+
@remote_fetch_available = false
|
|
96
|
+
@remote_package_cache[key] = nil
|
|
97
|
+
rescue StandardError
|
|
98
|
+
@remote_package_cache[key] = nil
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def unpack_gem(spec, gem_path)
|
|
102
|
+
unpacked_path = File.join(cache_extract_dir, spec.full_name)
|
|
103
|
+
marker_path = File.join(unpacked_path, ".gemxray-extracted")
|
|
104
|
+
return unpacked_path if File.exist?(marker_path)
|
|
105
|
+
|
|
106
|
+
FileUtils.rm_rf(unpacked_path)
|
|
107
|
+
FileUtils.mkdir_p(unpacked_path)
|
|
108
|
+
Gem::Package.new(gem_path).extract_files(unpacked_path)
|
|
109
|
+
File.write(marker_path, spec.full_name)
|
|
110
|
+
unpacked_path
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def cache_download_dir
|
|
114
|
+
File.join(cache_dir, "downloads")
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def cache_extract_dir
|
|
118
|
+
File.join(cache_dir, "extracted")
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def build_requirement(version_requirement)
|
|
122
|
+
Gem::Requirement.new(version_requirement || ">= 0")
|
|
123
|
+
rescue ArgumentError
|
|
124
|
+
Gem::Requirement.default
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def default_constant_candidates(gem_name)
|
|
128
|
+
segments = gem_name.split(%r{[/_-]}).reject(&:empty?)
|
|
129
|
+
return [] if segments.empty?
|
|
130
|
+
|
|
131
|
+
camelized = segments.map { |segment| camelize(segment) }
|
|
132
|
+
[camelized.join, camelized.join("::")].uniq
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def extract_constants(spec, gem_name)
|
|
136
|
+
gem_files_for(spec, gem_name).flat_map do |path|
|
|
137
|
+
File.readlines(path, chomp: true, encoding: "utf-8").filter_map do |line|
|
|
138
|
+
line[CONSTANT_PATTERN, 1]
|
|
139
|
+
end
|
|
140
|
+
rescue StandardError
|
|
141
|
+
[]
|
|
142
|
+
end.uniq
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def gem_files_for(spec, gem_name)
|
|
146
|
+
require_roots = spec.require_paths.map { |path| File.join(spec.full_gem_path, path) }
|
|
147
|
+
candidates = preferred_entry_files(require_roots, gem_name)
|
|
148
|
+
return candidates unless candidates.empty?
|
|
149
|
+
|
|
150
|
+
all_ruby_files_for(spec, gem_name)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def all_ruby_files_for(spec, gem_name)
|
|
154
|
+
require_roots = spec.require_paths.map { |path| File.join(spec.full_gem_path, path) }
|
|
155
|
+
preferred_entry_files(require_roots, gem_name) + require_roots.flat_map do |root|
|
|
156
|
+
Dir.glob(File.join(root, "**/*.rb"))
|
|
157
|
+
end.take(100)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def preferred_entry_files(require_roots, gem_name)
|
|
161
|
+
basenames = [
|
|
162
|
+
gem_name,
|
|
163
|
+
gem_name.tr("-", "/"),
|
|
164
|
+
gem_name.tr("-", "_")
|
|
165
|
+
].uniq
|
|
166
|
+
|
|
167
|
+
basenames.flat_map do |basename|
|
|
168
|
+
require_roots.filter_map do |root|
|
|
169
|
+
path = File.join(root, "#{basename}.rb")
|
|
170
|
+
path if File.exist?(path)
|
|
171
|
+
end
|
|
172
|
+
end.uniq
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def camelize(value)
|
|
176
|
+
value.split("_").map { |part| part[0]&.upcase.to_s + part[1..] }.join
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler"
|
|
4
|
+
|
|
5
|
+
module GemXray
|
|
6
|
+
class GemfileParser
|
|
7
|
+
DependencyEdge = Struct.new(:name, :requirement, keyword_init: true)
|
|
8
|
+
|
|
9
|
+
attr_reader :gemfile_path, :lockfile_path
|
|
10
|
+
|
|
11
|
+
def initialize(gemfile_path)
|
|
12
|
+
@gemfile_path = File.expand_path(gemfile_path)
|
|
13
|
+
@lockfile_path = "#{@gemfile_path}.lock"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def parse
|
|
17
|
+
@parse ||= begin
|
|
18
|
+
dependencies = bundler_dependencies
|
|
19
|
+
metadata = source_metadata.group_by(&:name)
|
|
20
|
+
|
|
21
|
+
if dependencies.empty?
|
|
22
|
+
source_metadata.map { |entry| build_entry_from_metadata(entry, []) }
|
|
23
|
+
else
|
|
24
|
+
dependencies.map do |dependency|
|
|
25
|
+
build_entry_from_dependency(dependency, metadata)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def dependency_tree
|
|
32
|
+
parser = lockfile_parser
|
|
33
|
+
return {} unless parser
|
|
34
|
+
|
|
35
|
+
parser.specs.each_with_object({}) do |spec, tree|
|
|
36
|
+
tree[spec.name] = spec.dependencies.map do |dependency|
|
|
37
|
+
DependencyEdge.new(name: dependency.name, requirement: dependency.requirement)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def ruby_version
|
|
43
|
+
parser = lockfile_parser
|
|
44
|
+
return RUBY_VERSION unless parser && parser.respond_to?(:ruby_version) && parser.ruby_version
|
|
45
|
+
|
|
46
|
+
text = parser.ruby_version.to_s
|
|
47
|
+
text[/\d+\.\d+(?:\.\d+)?/] || RUBY_VERSION
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def rails_version(entries = parse)
|
|
51
|
+
parser = lockfile_parser
|
|
52
|
+
if parser
|
|
53
|
+
rails_spec = parser.specs.find { |spec| spec.name == "rails" }
|
|
54
|
+
return rails_spec.version.to_s if rails_spec
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
dependency = entries.find { |entry| entry.name == "rails" || entry.name == "railties" }
|
|
58
|
+
return nil unless dependency
|
|
59
|
+
|
|
60
|
+
dependency.version.to_s[/\d+\.\d+(?:\.\d+)?/]
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def resolved_version(gem_name)
|
|
64
|
+
parser = lockfile_parser
|
|
65
|
+
return nil unless parser
|
|
66
|
+
|
|
67
|
+
parser.specs.find { |spec| spec.name == gem_name }&.version
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def bundler_dependencies
|
|
73
|
+
Bundler::Dsl.evaluate(gemfile_path, nil, {}).dependencies
|
|
74
|
+
rescue StandardError
|
|
75
|
+
[]
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def build_entry_from_dependency(dependency, metadata)
|
|
79
|
+
declaration = metadata.fetch(dependency.name, []).shift
|
|
80
|
+
declaration ||= GemfileSourceParser::Metadata.new(name: dependency.name, options: {})
|
|
81
|
+
GemEntry.new(
|
|
82
|
+
name: dependency.name,
|
|
83
|
+
version: normalized_requirement(dependency.requirement),
|
|
84
|
+
groups: dependency.groups.empty? ? declaration.groups : dependency.groups,
|
|
85
|
+
line_number: declaration.line_number,
|
|
86
|
+
end_line: declaration.end_line,
|
|
87
|
+
source_line: declaration.source_line,
|
|
88
|
+
autorequire: declaration.options.fetch(:require, dependency.autorequire),
|
|
89
|
+
options: declaration.options
|
|
90
|
+
)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def build_entry_from_metadata(metadata_entry, default_groups)
|
|
94
|
+
options = metadata_entry.options
|
|
95
|
+
GemEntry.new(
|
|
96
|
+
name: metadata_entry.name,
|
|
97
|
+
version: metadata_entry.version,
|
|
98
|
+
groups: default_groups + metadata_entry.groups.to_a + extract_inline_groups(options),
|
|
99
|
+
line_number: metadata_entry.line_number,
|
|
100
|
+
end_line: metadata_entry.end_line,
|
|
101
|
+
source_line: metadata_entry.source_line,
|
|
102
|
+
autorequire: options[:require],
|
|
103
|
+
options: options
|
|
104
|
+
)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def normalized_requirement(requirement)
|
|
108
|
+
return nil unless requirement
|
|
109
|
+
|
|
110
|
+
text = Array(requirement.as_list).join(", ").strip
|
|
111
|
+
text.empty? || text == ">= 0" ? nil : text
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def source_metadata
|
|
115
|
+
@source_metadata ||= GemfileSourceParser.new(gemfile_path).parse
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def extract_inline_groups(options)
|
|
119
|
+
values = []
|
|
120
|
+
values.concat(Array(options[:group]))
|
|
121
|
+
values.concat(Array(options[:groups]))
|
|
122
|
+
values.map { |value| value.to_s.delete_prefix(":").to_sym }
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def lockfile_parser
|
|
126
|
+
return nil unless File.exist?(lockfile_path)
|
|
127
|
+
|
|
128
|
+
@lockfile_parser ||= Bundler::LockfileParser.new(Bundler.read_file(lockfile_path))
|
|
129
|
+
rescue StandardError
|
|
130
|
+
nil
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|