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,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,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module GemXray
6
+ module Formatters
7
+ class Json
8
+ def render(report)
9
+ JSON.pretty_generate(report.to_h)
10
+ end
11
+ end
12
+ end
13
+ 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,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module GemXray
6
+ module Formatters
7
+ class Yaml
8
+ def render(report)
9
+ ::YAML.dump(report.to_h)
10
+ end
11
+ end
12
+ end
13
+ 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