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,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "etc"
4
+ require "find"
5
+ require "set"
6
+
7
+ module GemXray
8
+ class CodeScanner
9
+ SCAN_EXTENSIONS = %w[.rb .erb .haml .slim .rake .thor .gemspec .ru].freeze
10
+ REQUIRE_PATTERN = /
11
+ (?:
12
+ \brequire(?:_relative)?\s*\(?\s*["']([^"']+)["']
13
+ |
14
+ send\(\s*:require\s*,\s*["']([^"']+)["']
15
+ )
16
+ /x.freeze
17
+ CONSTANT_PATTERN = /\b(?:[A-Z][A-Za-z0-9]*)(?:::[A-Z][A-Za-z0-9]*)*\b/.freeze
18
+ GEMSPEC_DEPENDENCY_PATTERN = /\badd_(?:runtime_)?dependency\s+["']([^"']+)["']/.freeze
19
+
20
+ class Snapshot
21
+ attr_reader :requires, :constants, :dependency_names, :files
22
+
23
+ def initialize(requires:, constants:, dependency_names:, files:)
24
+ @requires = requires
25
+ @constants = constants
26
+ @dependency_names = dependency_names
27
+ @files = files
28
+ end
29
+
30
+ def require_used?(candidates)
31
+ Array(candidates).any? do |candidate|
32
+ requires.any? { |reference| reference == candidate || reference.start_with?("#{candidate}/") }
33
+ end
34
+ end
35
+
36
+ def constant_used?(candidates)
37
+ !(constants & candidates.to_set).empty?
38
+ end
39
+
40
+ def dependency_used?(gem_name)
41
+ dependency_names.include?(gem_name)
42
+ end
43
+ end
44
+
45
+ def initialize(config)
46
+ @config = config
47
+ end
48
+
49
+ def scan
50
+ requires = Set.new
51
+ constants = Set.new
52
+ dependency_names = Set.new
53
+ files = scan_files
54
+
55
+ scan_payloads(files).each do |payload|
56
+ payload[:requires].each { |value| requires << value }
57
+ payload[:constants].each { |value| constants << value }
58
+ payload[:dependency_names].each { |value| dependency_names << value }
59
+ end
60
+
61
+ Snapshot.new(
62
+ requires: requires,
63
+ constants: constants,
64
+ dependency_names: dependency_names,
65
+ files: files
66
+ )
67
+ end
68
+
69
+ private
70
+
71
+ attr_reader :config
72
+
73
+ def scan_payloads(files)
74
+ count = [files.length, worker_count].min
75
+ return files.filter_map { |path| scan_file(path) } if count <= 1
76
+
77
+ queue = Queue.new
78
+ files.each { |path| queue << path }
79
+ count.times { queue << nil }
80
+
81
+ Array.new(count) do
82
+ Thread.new do
83
+ payloads = []
84
+
85
+ while (path = queue.pop)
86
+ payload = scan_file(path)
87
+ payloads << payload if payload
88
+ end
89
+
90
+ payloads
91
+ end
92
+ end.flat_map(&:value)
93
+ end
94
+
95
+ def scan_files
96
+ root = config.project_root
97
+ paths = []
98
+
99
+ config.scan_dirs.each do |relative_dir|
100
+ absolute_dir = File.join(root, relative_dir)
101
+ next unless Dir.exist?(absolute_dir)
102
+
103
+ Find.find(absolute_dir) do |path|
104
+ next if File.directory?(path)
105
+ next unless SCAN_EXTENSIONS.include?(File.extname(path))
106
+
107
+ paths << path
108
+ end
109
+ end
110
+
111
+ %w[Gemfile Rakefile].each do |filename|
112
+ absolute_path = File.join(root, filename)
113
+ paths << absolute_path if File.exist?(absolute_path)
114
+ end
115
+
116
+ Dir.glob(File.join(root, "*.gemspec")).each { |path| paths << path }
117
+
118
+ paths.uniq
119
+ end
120
+
121
+ def scan_file(path)
122
+ content = File.read(path, encoding: "utf-8")
123
+ {
124
+ requires: extract_requires(content),
125
+ constants: content.scan(CONSTANT_PATTERN),
126
+ dependency_names: content.scan(GEMSPEC_DEPENDENCY_PATTERN).flatten
127
+ }
128
+ rescue ArgumentError, Errno::ENOENT
129
+ nil
130
+ end
131
+
132
+ def worker_count
133
+ cpu_count = Etc.nprocessors
134
+ [[cpu_count, 2].max, 8].min
135
+ rescue StandardError
136
+ 4
137
+ end
138
+
139
+ def extract_requires(content)
140
+ content.scan(REQUIRE_PATTERN).map { |left, right| left || right }.compact
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,215 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module GemXray
6
+ class Config
7
+ DEFAULT_CONFIG_PATH = ".gemxray.yml"
8
+ DEFAULT_SCAN_DIRS = %w[app lib config db script bin exe spec test tasks].freeze
9
+ DEFAULTS = {
10
+ gemfile_path: "Gemfile",
11
+ format: "terminal",
12
+ only: nil,
13
+ severity: "info",
14
+ auto_fix: false,
15
+ dry_run: false,
16
+ ci: false,
17
+ comment: false,
18
+ bundle_install: false,
19
+ whitelist: [],
20
+ scan_dirs: [],
21
+ overrides: {},
22
+ redundant_depth: 2,
23
+ github: {
24
+ base_branch: "main",
25
+ labels: %w[dependencies cleanup],
26
+ reviewers: [],
27
+ per_gem: false,
28
+ bundle_install: true
29
+ }
30
+ }.freeze
31
+ SEVERITY_ORDER = { danger: 0, warning: 1, info: 2 }.freeze
32
+ TEMPLATE = <<~YAML.freeze
33
+ version: 1
34
+
35
+ whitelist:
36
+ - bootsnap
37
+ - tzinfo-data
38
+
39
+ scan_dirs:
40
+ - engines/billing/app
41
+ - engines/billing/lib
42
+
43
+ overrides:
44
+ puma:
45
+ severity: ignore
46
+
47
+ github:
48
+ base_branch: main
49
+ labels:
50
+ - dependencies
51
+ - cleanup
52
+ reviewers: []
53
+ per_gem: false
54
+ bundle_install: true
55
+ YAML
56
+
57
+ attr_reader :config_path, :gemfile_path, :format, :only, :severity_threshold, :whitelist,
58
+ :scan_dirs, :overrides, :redundant_depth, :github
59
+
60
+ def self.load(options = {})
61
+ raw_options = symbolize_keys(options)
62
+ config_path = raw_options.delete(:config_path) || DEFAULT_CONFIG_PATH
63
+ file_options = load_file_config(config_path)
64
+ merged = deep_merge(DEFAULTS, deep_merge(file_options, raw_options))
65
+
66
+ new(merged, config_path: config_path)
67
+ end
68
+
69
+ def self.load_file_config(path)
70
+ return {} unless path && File.exist?(path)
71
+
72
+ symbolize_keys(YAML.safe_load(File.read(path), aliases: true) || {})
73
+ end
74
+
75
+ def self.symbolize_keys(value)
76
+ case value
77
+ when Hash
78
+ value.each_with_object({}) do |(key, nested), result|
79
+ result[key.to_sym] = symbolize_keys(nested)
80
+ end
81
+ when Array
82
+ value.map { |item| symbolize_keys(item) }
83
+ else
84
+ value
85
+ end
86
+ end
87
+
88
+ def self.deep_merge(left, right)
89
+ left.merge(right) do |_key, left_value, right_value|
90
+ if left_value.is_a?(Hash) && right_value.is_a?(Hash)
91
+ deep_merge(left_value, right_value)
92
+ elsif left_value.is_a?(Array) && right_value.is_a?(Array)
93
+ (left_value + right_value).uniq
94
+ else
95
+ right_value
96
+ end
97
+ end
98
+ end
99
+
100
+ def initialize(options, config_path:)
101
+ @config_path = config_path
102
+ @gemfile_path = File.expand_path(options.fetch(:gemfile_path))
103
+ @format = options.fetch(:format).to_s
104
+ @only = normalize_only(options[:only])
105
+ @severity_threshold = normalize_severity(options.fetch(:severity))
106
+ @whitelist = Array(options[:whitelist]).map(&:to_s).uniq
107
+ @scan_dirs = (DEFAULT_SCAN_DIRS + Array(options[:scan_dirs]).map(&:to_s)).uniq
108
+ @overrides = options.fetch(:overrides, {})
109
+ @redundant_depth = options.fetch(:redundant_depth).to_i
110
+ @github = options.fetch(:github)
111
+ @auto_fix = truthy?(options[:auto_fix])
112
+ @dry_run = truthy?(options[:dry_run])
113
+ @ci = truthy?(options[:ci])
114
+ @comment = truthy?(options[:comment])
115
+ @bundle_install = truthy?(options[:bundle_install])
116
+ end
117
+
118
+ def lockfile_path
119
+ "#{gemfile_path}.lock"
120
+ end
121
+
122
+ def project_root
123
+ File.dirname(gemfile_path)
124
+ end
125
+
126
+ def auto_fix?
127
+ @auto_fix
128
+ end
129
+
130
+ def dry_run?
131
+ @dry_run
132
+ end
133
+
134
+ def ci?
135
+ @ci
136
+ end
137
+
138
+ def comment?
139
+ @comment
140
+ end
141
+
142
+ def bundle_install?
143
+ @bundle_install
144
+ end
145
+
146
+ def whitelisted?(gem_name)
147
+ whitelist.include?(gem_name.to_s)
148
+ end
149
+
150
+ def override_for(gem_name)
151
+ overrides[gem_name.to_sym] || overrides[gem_name.to_s]
152
+ end
153
+
154
+ def ignore_gem?(gem_name)
155
+ override = override_for(gem_name)
156
+ override && override[:severity].to_s == "ignore"
157
+ end
158
+
159
+ def override_severity_for(gem_name)
160
+ override = override_for(gem_name)
161
+ severity = override && override[:severity]
162
+ return nil if severity.nil? || severity.to_s == "ignore"
163
+
164
+ normalize_severity(severity)
165
+ end
166
+
167
+ def severity_in_scope?(severity)
168
+ SEVERITY_ORDER.fetch(severity) <= SEVERITY_ORDER.fetch(severity_threshold)
169
+ end
170
+
171
+ def github_base_branch
172
+ github.fetch(:base_branch, "main")
173
+ end
174
+
175
+ def github_labels
176
+ Array(github.fetch(:labels, []))
177
+ end
178
+
179
+ def github_reviewers
180
+ Array(github.fetch(:reviewers, []))
181
+ end
182
+
183
+ def github_per_gem?
184
+ truthy?(github.fetch(:per_gem, false))
185
+ end
186
+
187
+ def github_bundle_install?
188
+ truthy?(github.fetch(:bundle_install, true))
189
+ end
190
+
191
+ private
192
+
193
+ def normalize_only(value)
194
+ items =
195
+ case value
196
+ when nil then nil
197
+ when String then value.split(",")
198
+ else Array(value)
199
+ end
200
+
201
+ items&.map { |item| item.to_s.strip }&.reject(&:empty?)&.map(&:to_sym)
202
+ end
203
+
204
+ def normalize_severity(value)
205
+ key = value.to_s.strip.downcase.to_sym
206
+ return key if SEVERITY_ORDER.key?(key)
207
+
208
+ raise Error, "unknown severity: #{value}"
209
+ end
210
+
211
+ def truthy?(value)
212
+ value == true || value.to_s == "true"
213
+ end
214
+ end
215
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ module GemXray
6
+ class DependencyResolver
7
+ def initialize(dependency_tree)
8
+ @dependency_tree = dependency_tree
9
+ end
10
+
11
+ def find_parent(target:, roots:, max_depth:)
12
+ roots.each do |root|
13
+ next if root == target
14
+
15
+ path = find_path(root, target, max_depth)
16
+ return path if path
17
+ end
18
+
19
+ nil
20
+ end
21
+
22
+ private
23
+
24
+ attr_reader :dependency_tree
25
+
26
+ def find_path(root, target, max_depth)
27
+ visited = Set.new([root])
28
+ queue = [[root, [root], [], 0]]
29
+
30
+ until queue.empty?
31
+ current, path, edges, depth = queue.shift
32
+ next if depth >= max_depth
33
+
34
+ Array(dependency_tree[current]).each do |edge|
35
+ child = edge.name
36
+ next if visited.include?(child) && child != target
37
+
38
+ return { gems: path + [child], edges: edges + [edge] } if child == target
39
+
40
+ visited << child
41
+ queue << [child, path + [child], edges + [edge], depth + 1]
42
+ end
43
+ end
44
+
45
+ nil
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module GemXray
6
+ module Editors
7
+ class GemfileEditor
8
+ EditResult = Struct.new(:removed, :skipped, :dry_run, :backup_path, :preview, keyword_init: true)
9
+
10
+ def initialize(gemfile_path)
11
+ @gemfile_path = File.expand_path(gemfile_path)
12
+ end
13
+
14
+ def apply(results, dry_run:, comment:, backup: true)
15
+ lines = File.readlines(gemfile_path, chomp: false)
16
+ preview_hunks = []
17
+ removed = []
18
+ skipped = []
19
+
20
+ results.sort_by { |result| -(result.gemfile_line || 0) }.each do |result|
21
+ line_number = result.gemfile_line
22
+ end_line = result.gemfile_end_line || line_number
23
+ if !line_number || !gem_line?(lines[line_number - 1], result.gem_name)
24
+ skipped << result.gem_name
25
+ next
26
+ end
27
+
28
+ replacement =
29
+ if comment
30
+ ["#{leading_whitespace(lines[line_number - 1])}# Removed by gemxray: #{comment_summary(result)}\n"]
31
+ else
32
+ []
33
+ end
34
+ original = lines[(line_number - 1)..(end_line - 1)]
35
+ preview_hunks << build_preview_hunk(result, original, replacement)
36
+
37
+ if comment
38
+ lines[(line_number - 1)..(end_line - 1)] = replacement
39
+ else
40
+ lines[(line_number - 1)..(end_line - 1)] = replacement
41
+ end
42
+
43
+ removed << result.gem_name
44
+ end
45
+
46
+ backup_path = nil
47
+ unless dry_run || removed.empty?
48
+ if backup
49
+ backup_path = "#{gemfile_path}.bak"
50
+ File.write(backup_path, File.read(gemfile_path))
51
+ end
52
+ File.write(gemfile_path, lines.join)
53
+ end
54
+
55
+ EditResult.new(
56
+ removed: removed.reverse,
57
+ skipped: skipped.uniq,
58
+ dry_run: dry_run,
59
+ backup_path: backup_path,
60
+ preview: preview_hunks.reverse.join("\n")
61
+ )
62
+ end
63
+
64
+ def bundle_install!
65
+ stdout, stderr, status = Open3.capture3("bundle", "install", chdir: project_root)
66
+ return stdout if status.success?
67
+
68
+ raise Error, "bundle install failed: #{stderr.strip.empty? ? stdout.strip : stderr.strip}"
69
+ end
70
+
71
+ private
72
+
73
+ attr_reader :gemfile_path
74
+
75
+ def gem_line?(line, gem_name)
76
+ line && line.match?(/^\s*gem\s+["']#{Regexp.escape(gem_name)}["']/)
77
+ end
78
+
79
+ def comment_summary(result)
80
+ "#{result.gem_name} - #{result.reasons.map(&:detail).join(' / ')}"
81
+ end
82
+
83
+ def build_preview_hunk(result, original_lines, replacement_lines)
84
+ header = "@@ #{File.basename(gemfile_path)}:#{result.gemfile_line}-#{result.gemfile_end_line || result.gemfile_line} #{result.gem_name} @@"
85
+ removed = Array(original_lines).map { |line| "-#{line.chomp}" }
86
+ added = Array(replacement_lines).map { |line| "+#{line.chomp}" }
87
+ ([header] + removed + added).join("\n")
88
+ end
89
+
90
+ def leading_whitespace(line)
91
+ line[/\A\s*/] || ""
92
+ end
93
+
94
+ def project_root
95
+ File.dirname(gemfile_path)
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+
7
+ module GemXray
8
+ module Editors
9
+ class GithubApiClient
10
+ API_BASE = URI("https://api.github.com").freeze
11
+
12
+ def initialize(token:, repository:)
13
+ @token = token
14
+ @repository = repository
15
+ end
16
+
17
+ def create_pull_request(base:, head:, title:, body:, labels:, reviewers:)
18
+ pr = post_json("/repos/#{repository}/pulls", {
19
+ title: title,
20
+ head: head,
21
+ base: base,
22
+ body: body
23
+ })
24
+
25
+ issue_number = pr.fetch("number")
26
+ post_json("/repos/#{repository}/issues/#{issue_number}/labels", { labels: labels }) unless labels.empty?
27
+ post_json("/repos/#{repository}/pulls/#{issue_number}/requested_reviewers", { reviewers: reviewers }) unless reviewers.empty?
28
+
29
+ pr.fetch("html_url")
30
+ end
31
+
32
+ private
33
+
34
+ attr_reader :token, :repository
35
+
36
+ def post_json(path, payload)
37
+ uri = API_BASE.dup
38
+ uri.path = path
39
+
40
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
41
+ request = Net::HTTP::Post.new(uri)
42
+ request["Accept"] = "application/vnd.github+json"
43
+ request["Authorization"] = "Bearer #{token}"
44
+ request["X-GitHub-Api-Version"] = "2022-11-28"
45
+ request["Content-Type"] = "application/json"
46
+ request.body = JSON.dump(payload)
47
+ http.request(request)
48
+ end
49
+
50
+ raise Error, "GitHub API request failed: #{response.code} #{response.body}" unless response.is_a?(Net::HTTPSuccess)
51
+
52
+ JSON.parse(response.body)
53
+ end
54
+ end
55
+ end
56
+ end