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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 8a6d939f389ed77e696ee646dbde1ab0bb7a8ceedc557f842d28c18c98db4ce5
4
+ data.tar.gz: 34b0b557679c2fa045833d3c2058e5b31277a01f06d60a0ebcc178a7b244b695
5
+ SHA512:
6
+ metadata.gz: e1acb06ca5efd7b530d81293f4560c02948301073abb51611a2bd34d6fa473329436bf07e354e0d22923a53b19183738375a1a82c85effedf181d4f2f6ba2ffe
7
+ data.tar.gz: bb4421e1c58449dd6143afa8520a60bd38b4f5011aa5451b65f17737cffa8c7ecd8d10bc36be84cc12fb1993b489aea55a8600d4f045dd34d025acb972b0527e
data/CHANGELOG.md ADDED
@@ -0,0 +1,7 @@
1
+ # Changelog
2
+
3
+ ## Unreleased
4
+
5
+ ## 0.1.0 (2026-03-27)
6
+
7
+ - Initial release.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Yudai Takada
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,151 @@
1
+ # GemXray
2
+
3
+ `gemxray` is a CLI that highlights gems you can likely remove from a Ruby project's `Gemfile`.
4
+
5
+ It combines three analyzers:
6
+
7
+ 1. `unused`: no `require`, constant reference, gemspec dependency, or Rails autoload signal was found.
8
+ 2. `redundant`: another top-level gem already brings the gem in through `Gemfile.lock`.
9
+ 3. `version-redundant`: the gem is already covered by your Ruby or Rails version.
10
+
11
+ If you run `gemxray` without a command, it defaults to `scan`.
12
+
13
+ ## Installation
14
+
15
+ Add the gem to your toolchain:
16
+
17
+ ```bash
18
+ bundle add gemxray --group development
19
+ ```
20
+
21
+ Or install it directly:
22
+
23
+ ```bash
24
+ gem install gemxray
25
+ ```
26
+
27
+ If you install the gem globally, replace `bundle exec gemxray` with `gemxray` in the examples below.
28
+
29
+ ## Quick Start
30
+
31
+ Generate a starter config:
32
+
33
+ ```bash
34
+ bundle exec gemxray init
35
+ ```
36
+
37
+ Scan the current project:
38
+
39
+ ```bash
40
+ bundle exec gemxray scan
41
+ ```
42
+
43
+ Use structured output for CI or scripts:
44
+
45
+ ```bash
46
+ bundle exec gemxray scan --format json --ci
47
+ bundle exec gemxray scan --only unused,version --severity warning
48
+ ```
49
+
50
+ Preview or apply Gemfile cleanup:
51
+
52
+ ```bash
53
+ bundle exec gemxray clean --dry-run
54
+ bundle exec gemxray clean
55
+ bundle exec gemxray clean --auto-fix
56
+ ```
57
+
58
+ Create a cleanup branch and open a pull request:
59
+
60
+ ```bash
61
+ bundle exec gemxray pr
62
+ bundle exec gemxray pr --per-gem --no-bundle
63
+ ```
64
+
65
+ Target a different project by passing a Gemfile path:
66
+
67
+ ```bash
68
+ bundle exec gemxray scan --gemfile path/to/Gemfile
69
+ ```
70
+
71
+ ## Commands
72
+
73
+ | Command | Purpose | Useful options |
74
+ | --- | --- | --- |
75
+ | `scan` | Analyze the Gemfile and print findings. | `--format`, `--only`, `--severity`, `--ci`, `--gemfile`, `--config` |
76
+ | `clean` | Remove selected gems from `Gemfile`. | `--dry-run`, `--auto-fix`, `--comment`, `--[no-]bundle` |
77
+ | `pr` | Create a branch, commit the cleanup, and open a GitHub PR. | `--per-gem`, `--[no-]bundle`, `--comment` |
78
+ | `init` | Write a starter `.gemxray.yml`. | `--force` |
79
+ | `version` | Print the installed gemxray version. | none |
80
+
81
+ ## Severity
82
+
83
+ - `danger`: high-confidence removal candidate. `clean --auto-fix` only removes `danger` findings.
84
+ - `warning`: likely removable, but worth a quick review.
85
+ - `info`: informative hint, often tied to pinned versions or lower-confidence redundancy.
86
+
87
+ ## Configuration
88
+
89
+ `gemxray` reads `.gemxray.yml` from the working directory unless you pass `--config PATH`.
90
+
91
+ ```yaml
92
+ version: 1
93
+
94
+ whitelist:
95
+ - bootsnap
96
+ - tzinfo-data
97
+
98
+ scan_dirs:
99
+ - engines/billing/app
100
+ - engines/billing/lib
101
+
102
+ redundant_depth: 2
103
+
104
+ overrides:
105
+ puma:
106
+ severity: ignore
107
+
108
+ github:
109
+ base_branch: main
110
+ labels:
111
+ - dependencies
112
+ - cleanup
113
+ reviewers: []
114
+ per_gem: false
115
+ bundle_install: true
116
+ ```
117
+
118
+ Config fields:
119
+
120
+ - `whitelist`: gems to skip entirely.
121
+ - `scan_dirs`: extra directories to scan in addition to the defaults: `app`, `lib`, `config`, `db`, `script`, `bin`, `exe`, `spec`, `test`, and `tasks`.
122
+ - `redundant_depth`: maximum dependency depth for redundant gem detection.
123
+ - `overrides.<gem>.severity`: override a finding severity with `ignore`, `info`, `warning`, or `danger`.
124
+ - `github.*`: defaults used by `pr`.
125
+
126
+ ## Notes
127
+
128
+ - `clean` writes `Gemfile.bak` before editing the file.
129
+ - `clean` removes the full source range for multiline gem declarations.
130
+ - `clean --bundle` runs `bundle install` after editing.
131
+ - `pr` runs `bundle install` before committing by default. Use `pr --no-bundle` to skip it.
132
+ - `pr` requires a clean git worktree before it creates branches or commits.
133
+ - `pr` switches to `github.base_branch` before creating the cleanup branch.
134
+ - If `gh` is unavailable, `pr` falls back to the GitHub API when `GH_TOKEN` or `GITHUB_TOKEN` is set.
135
+ - Ruby default and bundled gem checks use cached stdgems data when available and bundled offline data otherwise.
136
+ - Rails version hints come from the bundled `data/rails_changes.yml` dataset.
137
+
138
+ ## Development
139
+
140
+ Install dependencies and run the test suite:
141
+
142
+ ```bash
143
+ bundle install
144
+ bundle exec rspec
145
+ ```
146
+
147
+ Run the executable locally:
148
+
149
+ ```bash
150
+ ruby exe/gemxray scan --format terminal
151
+ ```
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
@@ -0,0 +1,6 @@
1
+ versions:
2
+ "6.0":
3
+ removals:
4
+ - gem: zeitwerk
5
+ reason: "Rails 6 enables Zeitwerk mode by default, so an explicit gem entry is usually unnecessary"
6
+ source: "https://guides.rubyonrails.org/upgrading_ruby_on_rails.html"
data/exe/gemxray ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../lib/gemxray"
5
+
6
+ exit GemXray::CLI.start(ARGV)
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GemXray
4
+ module Analyzers
5
+ class Base
6
+ AUTOLOADED_GEMS = %w[
7
+ bootsnap devise sidekiq sidekiq-cron delayed_job_active_record good_job
8
+ letter_opener_web rack-mini-profiler spring sprockets-rails turbo-rails
9
+ stimulus-rails importmap-rails propshaft web-console solid_queue
10
+ solid_cache solid_cable
11
+ ].freeze
12
+
13
+ def initialize(config:, gemfile_parser:, code_snapshot: nil, dependency_resolver: nil, stdgems_client: nil,
14
+ rails_knowledge: nil, gem_metadata_resolver: GemMetadataResolver.new)
15
+ @config = config
16
+ @gemfile_parser = gemfile_parser
17
+ @code_snapshot = code_snapshot
18
+ @dependency_resolver = dependency_resolver
19
+ @stdgems_client = stdgems_client
20
+ @rails_knowledge = rails_knowledge
21
+ @gem_metadata_resolver = gem_metadata_resolver
22
+ end
23
+
24
+ private
25
+
26
+ attr_reader :config, :gemfile_parser, :code_snapshot, :dependency_resolver, :stdgems_client, :rails_knowledge,
27
+ :gem_metadata_resolver
28
+
29
+ def skipped?(gem_entry)
30
+ config.whitelisted?(gem_entry.name) || config.ignore_gem?(gem_entry.name)
31
+ end
32
+
33
+ def build_result(gem_entry:, type:, severity:, detail:)
34
+ Result.new(
35
+ gem_name: gem_entry.name,
36
+ gemfile_line: gem_entry.line_number,
37
+ gemfile_end_line: gem_entry.end_line,
38
+ gemfile_group: gem_entry.gemfile_group,
39
+ reasons: [Result::Reason.new(type: type, detail: detail, severity: severity)],
40
+ severity: severity
41
+ )
42
+ end
43
+
44
+ def require_candidates(gem_entry)
45
+ gem_entry.require_names.uniq
46
+ end
47
+
48
+ def constant_candidates(gem_entry)
49
+ gem_metadata_resolver.constant_candidates_for(gem_entry.name, version_requirement: gem_entry.version)
50
+ end
51
+
52
+ def autoloaded_gem?(gem_entry)
53
+ return true if AUTOLOADED_GEMS.include?(gem_entry.name)
54
+
55
+ gem_metadata_resolver.railtie?(gem_entry.name, version_requirement: gem_entry.version)
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GemXray
4
+ module Analyzers
5
+ class RedundantAnalyzer < Base
6
+ def analyze(gems)
7
+ gem_names = gems.map(&:name)
8
+
9
+ gems.filter_map do |gem_entry|
10
+ next if skipped?(gem_entry)
11
+
12
+ path = dependency_resolver.find_parent(
13
+ target: gem_entry.name,
14
+ roots: gem_names - [gem_entry.name],
15
+ max_depth: config.redundant_depth
16
+ )
17
+ next unless path
18
+ next unless compatible_dependency?(gem_entry, path[:edges].last)
19
+
20
+ detail = "already installed as a dependency of #{path[:gems].first}"
21
+ detail = "#{detail} (#{path[:gems].join(' -> ')})" if path[:gems].length > 2
22
+
23
+ result = build_result(
24
+ gem_entry: gem_entry,
25
+ type: :redundant,
26
+ severity: gem_entry.pinned_version? ? :info : :warning,
27
+ detail: detail
28
+ )
29
+
30
+ if gem_entry.pinned_version?
31
+ result.add_reason(
32
+ type: :redundant,
33
+ severity: :info,
34
+ detail: "version is pinned in Gemfile (#{gem_entry.version}), so this may be intentional"
35
+ )
36
+ end
37
+
38
+ result
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ def compatible_dependency?(gem_entry, edge)
45
+ return true unless gem_entry.pinned_version?
46
+
47
+ requirement = edge.requirement
48
+ return true unless requirement
49
+
50
+ pinned_requirement = Gem::Requirement.new(gem_entry.version)
51
+ resolved_version = gemfile_parser.resolved_version(gem_entry.name)
52
+ if resolved_version
53
+ return pinned_requirement.satisfied_by?(resolved_version) && requirement.satisfied_by?(resolved_version)
54
+ end
55
+
56
+ allowed_versions = sample_versions_for(gem_entry.version)
57
+
58
+ allowed_versions.any? do |version|
59
+ pinned_requirement.satisfied_by?(version) && requirement.satisfied_by?(version)
60
+ end
61
+ rescue ArgumentError
62
+ false
63
+ end
64
+
65
+ def sample_versions_for(requirement_string)
66
+ versions = requirement_string.scan(/\d+(?:\.\d+)+/).map { |value| Gem::Version.new(value) }
67
+ versions << Gem::Version.new(requirement_string[/\d+(?:\.\d+)+/] || "0")
68
+ versions.uniq
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GemXray
4
+ module Analyzers
5
+ class UnusedAnalyzer < Base
6
+ KNOWN_DEV_TOOLS = %w[
7
+ brakeman bundler-audit byebug capistrano capistrano-rails codecov
8
+ debug factory_bot factory_bot_rails faker overcommit pry pry-rails
9
+ rake rspec rspec-core rspec-rails rubocop rubocop-performance
10
+ rubocop-rails rubocop-rspec simplecov
11
+ ].freeze
12
+
13
+ def analyze(gems)
14
+ gems.filter_map do |gem_entry|
15
+ next if skipped?(gem_entry)
16
+ next if KNOWN_DEV_TOOLS.include?(gem_entry.name)
17
+ next if gem_used?(gem_entry)
18
+
19
+ detail =
20
+ if gem_entry.development_group?
21
+ "no usage found in code (group :development / :test)"
22
+ else
23
+ "no require or constant reference was found in the scanned code"
24
+ end
25
+
26
+ build_result(
27
+ gem_entry: gem_entry,
28
+ type: :unused,
29
+ severity: gem_entry.development_group? ? :warning : :danger,
30
+ detail: detail
31
+ )
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ def gem_used?(gem_entry)
38
+ code_snapshot.require_used?(require_candidates(gem_entry)) ||
39
+ code_snapshot.constant_used?(constant_candidates(gem_entry)) ||
40
+ code_snapshot.dependency_used?(gem_entry.name) ||
41
+ autoloaded_gem?(gem_entry)
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GemXray
4
+ module Analyzers
5
+ class VersionAnalyzer < Base
6
+ def analyze(gems)
7
+ ruby_version = gemfile_parser.ruby_version
8
+ rails_version = gemfile_parser.rails_version(gems)
9
+ default_gems = stdgems_client.default_gems_for(ruby_version)
10
+ bundled_gems = stdgems_client.bundled_gems_for(ruby_version)
11
+
12
+ gems.each_with_object([]) do |gem_entry, results|
13
+ next if skipped?(gem_entry)
14
+
15
+ if default_gems.include?(gem_entry.name) && !gem_entry.pinned_version?
16
+ results << build_result(
17
+ gem_entry: gem_entry,
18
+ type: :version_redundant,
19
+ severity: :warning,
20
+ detail: "Ruby #{ruby_version} already ships this as a default gem"
21
+ )
22
+ elsif bundled_gems.include?(gem_entry.name) && !gem_entry.pinned_version?
23
+ results << build_result(
24
+ gem_entry: gem_entry,
25
+ type: :version_redundant,
26
+ severity: :warning,
27
+ detail: "Ruby #{ruby_version} already ships this as a bundled gem"
28
+ )
29
+ end
30
+
31
+ change = rails_knowledge.find_removal(gem_entry.name, rails_version)
32
+ next unless change
33
+
34
+ results << build_result(
35
+ gem_entry: gem_entry,
36
+ type: :version_redundant,
37
+ severity: :warning,
38
+ detail: "since Rails #{change.since}, #{change.reason}"
39
+ )
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,229 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module GemXray
6
+ class CLI
7
+ HelpRequested = Class.new(StandardError)
8
+ COMMANDS = %w[scan clean pr init version help].freeze
9
+
10
+ def self.start(argv = ARGV, out: $stdout, err: $stderr, stdin: $stdin)
11
+ new(argv, out: out, err: err, stdin: stdin).run
12
+ end
13
+
14
+ def initialize(argv, out:, err:, stdin:)
15
+ @argv = argv.dup
16
+ @out = out
17
+ @err = err
18
+ @stdin = stdin
19
+ end
20
+
21
+ def run
22
+ command = extract_command
23
+
24
+ case command
25
+ when "scan" then run_scan(@argv)
26
+ when "clean" then run_clean(@argv)
27
+ when "pr" then run_pr(@argv)
28
+ when "init" then run_init(@argv)
29
+ when "version"
30
+ out.puts(GemXray::VERSION)
31
+ 0
32
+ else
33
+ out.puts(help_text)
34
+ 0
35
+ end
36
+ rescue HelpRequested
37
+ 0
38
+ rescue OptionParser::ParseError => e
39
+ err.puts(e.message)
40
+ err.puts(help_text)
41
+ 1
42
+ rescue Error => e
43
+ err.puts("Error: #{e.message}")
44
+ 1
45
+ end
46
+
47
+ private
48
+
49
+ attr_reader :out, :err, :stdin
50
+
51
+ def extract_command
52
+ return "scan" if @argv.empty?
53
+ return "help" if %w[-h --help].include?(@argv.first)
54
+ return "scan" if @argv.first.start_with?("-")
55
+
56
+ return @argv.shift if COMMANDS.include?(@argv.first)
57
+
58
+ "scan"
59
+ end
60
+
61
+ def run_scan(argv)
62
+ config = Config.load(parse_scan_options(argv))
63
+ report = Scanner.new(config).run
64
+ out.puts(formatter_for(config.format).render(report))
65
+ config.ci? && report.results.any? ? 1 : 0
66
+ end
67
+
68
+ def run_clean(argv)
69
+ options = parse_clean_options(argv)
70
+ config = Config.load(options)
71
+ report = Scanner.new(config).run
72
+ candidates = config.auto_fix? ? report.results.select(&:danger?) : interactive_selection(report.results)
73
+
74
+ if candidates.empty?
75
+ out.puts("No removable gems were selected.")
76
+ return 0
77
+ end
78
+
79
+ editor = Editors::GemfileEditor.new(config.gemfile_path)
80
+ outcome = editor.apply(candidates, dry_run: config.dry_run?, comment: config.comment?, backup: true)
81
+ out.puts("Candidates: #{candidates.map(&:gem_name).join(', ')}")
82
+ out.puts(outcome.preview) if config.dry_run? && !outcome.preview.to_s.empty?
83
+ out.puts("Removed: #{outcome.removed.join(', ')}") unless outcome.removed.empty?
84
+ out.puts("Skipped: #{outcome.skipped.join(', ')}") unless outcome.skipped.empty?
85
+ if config.bundle_install? && !config.dry_run? && outcome.removed.any?
86
+ out.puts(editor.bundle_install!)
87
+ end
88
+ 0
89
+ end
90
+
91
+ def run_pr(argv)
92
+ options = parse_pr_options(argv)
93
+ config = Config.load(options)
94
+ report = Scanner.new(config).run
95
+ raise Error, "no PR candidates were found" if report.results.empty?
96
+
97
+ result = Editors::GithubPr.new(config).create(
98
+ report.results,
99
+ per_gem: options.fetch(:per_gem, config.github_per_gem?),
100
+ bundle_install: options.fetch(:bundle_install, config.github_bundle_install?),
101
+ comment: config.comment?
102
+ )
103
+
104
+ pull_requests = Array(result[:pull_requests])
105
+ if pull_requests.length <= 1
106
+ out.puts("Branch: #{result[:branch]}")
107
+ out.puts("PR: #{result[:pr_url]}")
108
+ else
109
+ out.puts("Created #{pull_requests.length} PRs:")
110
+ pull_requests.each do |pull_request|
111
+ label = pull_request[:gem_name] || Array(pull_request[:gem_names]).join(", ")
112
+ out.puts("#{label}: #{pull_request[:pr_url]} (#{pull_request[:branch]})")
113
+ end
114
+ end
115
+ 0
116
+ end
117
+
118
+ def run_init(argv)
119
+ options = { force: false }
120
+ OptionParser.new do |parser|
121
+ parser.banner = "Usage: gemxray init [--force]"
122
+ parser.on("--force", "overwrite an existing config file") { options[:force] = true }
123
+ parser.on("-h", "--help", "show help") do
124
+ out.puts(parser)
125
+ raise HelpRequested
126
+ end
127
+ end.parse!(argv)
128
+
129
+ path = File.expand_path(Config::DEFAULT_CONFIG_PATH)
130
+ if File.exist?(path) && !options[:force]
131
+ raise Error, "#{Config::DEFAULT_CONFIG_PATH} already exists"
132
+ end
133
+
134
+ File.write(path, Config::TEMPLATE)
135
+ out.puts("created #{Config::DEFAULT_CONFIG_PATH}")
136
+ 0
137
+ end
138
+
139
+ def parse_scan_options(argv)
140
+ options = {}
141
+
142
+ OptionParser.new do |parser|
143
+ parser.banner = "Usage: gemxray scan [options]"
144
+ common_options(parser, options)
145
+ end.parse!(argv)
146
+
147
+ options
148
+ end
149
+
150
+ def parse_clean_options(argv)
151
+ options = {}
152
+
153
+ OptionParser.new do |parser|
154
+ parser.banner = "Usage: gemxray clean [options]"
155
+ common_options(parser, options)
156
+ parser.on("--auto-fix", "remove only danger level gems without prompting") { options[:auto_fix] = true }
157
+ parser.on("--dry-run", "show targets without writing Gemfile") { options[:dry_run] = true }
158
+ parser.on("--comment", "leave a comment in place of the removed gem line") { options[:comment] = true }
159
+ parser.on("--[no-]bundle", "run bundle install after editing") { |value| options[:bundle_install] = value }
160
+ end.parse!(argv)
161
+
162
+ options
163
+ end
164
+
165
+ def parse_pr_options(argv)
166
+ options = {}
167
+
168
+ OptionParser.new do |parser|
169
+ parser.banner = "Usage: gemxray pr [options]"
170
+ common_options(parser, options)
171
+ parser.on("--[no-]bundle", "run bundle install before committing (default: yes)") do |value|
172
+ options[:bundle_install] = value
173
+ end
174
+ parser.on("--comment", "leave comments in Gemfile instead of deleting lines") { options[:comment] = true }
175
+ parser.on("--per-gem", "create one PR per gem") { options[:per_gem] = true }
176
+ end.parse!(argv)
177
+
178
+ options
179
+ end
180
+
181
+ def common_options(parser, options)
182
+ parser.on("-f", "--format FORMAT", %w[terminal json yaml], "output format") { |value| options[:format] = value }
183
+ parser.on("-g", "--gemfile PATH", "path to Gemfile") { |value| options[:gemfile_path] = value }
184
+ parser.on("--only LIST", "comma separated analyzers (unused,redundant,version)") do |value|
185
+ options[:only] = value.split(",")
186
+ end
187
+ parser.on("--severity LEVEL", %w[info warning danger], "minimum severity to report") do |value|
188
+ options[:severity] = value
189
+ end
190
+ parser.on("--ci", "exit with status 1 when issues are found") { options[:ci] = true }
191
+ parser.on("--config PATH", "path to .gemxray.yml") { |value| options[:config_path] = value }
192
+ parser.on("-h", "--help", "show help") do
193
+ out.puts(parser)
194
+ raise HelpRequested
195
+ end
196
+ end
197
+
198
+ def formatter_for(format)
199
+ case format
200
+ when "terminal" then Formatters::Terminal.new
201
+ when "json" then Formatters::Json.new
202
+ when "yaml" then Formatters::Yaml.new
203
+ else
204
+ raise Error, "unknown format: #{format}"
205
+ end
206
+ end
207
+
208
+ def interactive_selection(results)
209
+ results.filter_map do |result|
210
+ out.print("Remove #{result.gem_name} (#{result.severity})? [y/N]: ")
211
+ answer = stdin.gets.to_s.strip.downcase
212
+ result if answer == "y" || answer == "yes"
213
+ end
214
+ end
215
+
216
+ def help_text
217
+ <<~TEXT
218
+ gemxray [COMMAND] [OPTIONS]
219
+
220
+ Commands:
221
+ scan Analyze Gemfile and report removable gems
222
+ clean Interactively remove reported gems from Gemfile
223
+ pr Create a cleanup branch, commit, and open a GitHub PR
224
+ init Generate .gemxray.yml
225
+ version Print gemxray version
226
+ TEXT
227
+ end
228
+ end
229
+ end