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
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
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
data/exe/gemxray
ADDED
|
@@ -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
|
data/lib/gemxray/cli.rb
ADDED
|
@@ -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
|