keela 0.0.2 → 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 +4 -4
- data/CHANGELOG.md +20 -0
- data/LICENSE.txt +21 -0
- data/README.md +207 -0
- data/exe/keela +86 -0
- data/lib/keela/configuration.rb +36 -0
- data/lib/keela/reporter.rb +84 -0
- data/lib/keela/scanner.rb +151 -0
- data/lib/keela/strategies/methods.rb +35 -0
- data/lib/keela/strategies/scopes.rb +29 -0
- data/lib/keela/strategy.rb +36 -0
- data/lib/keela/version.rb +5 -0
- data/lib/keela.rb +23 -1
- metadata +64 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b2532684d9e7c3cca0411cbe55c3d944265236d159c287f8b719a778695872ed
|
|
4
|
+
data.tar.gz: '08a678fa6105ae093817dba3ce3632f25d173cae617db34aa267bd524b77e2ee'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8b0574c28fcec2f29a78463c08d20ccf58401d81b67c60878571fef4c381dc88bd4beca3b802c72edb5b456ec9c8bbf7b1f46b3bd2c6ed68380994ac04267330
|
|
7
|
+
data.tar.gz: 90e50d394dff39875fad354923381b9490994a9d2a4d3673f4c1a0d6df15be85c21bb54a24fee6a4860553bcea580ac589d4ed57f0e5fa44c8c2e80ba6742765
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.1.0] - 2026-07-16
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Initial release
|
|
15
|
+
- `Scanner` class for detecting unused code
|
|
16
|
+
- Built-in strategies for methods and scopes detection
|
|
17
|
+
- CLI tool with `--report` and `--update-baseline` modes
|
|
18
|
+
- Configurable file extensions and directory patterns
|
|
19
|
+
- Baseline comparison for CI integration
|
|
20
|
+
- Exclusion file support for false positives
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kerri Miller
|
|
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 all
|
|
13
|
+
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 THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# Keela 🐕
|
|
2
|
+
|
|
3
|
+
Like the famous CSI dog who found what others missed, Keela sniffs out unused code in your Ruby codebase.
|
|
4
|
+
|
|
5
|
+
## Why Remove Unused Code?
|
|
6
|
+
|
|
7
|
+
Dead code isn't harmless — it's actively costly:
|
|
8
|
+
|
|
9
|
+
- **Cognitive overhead**: Developers read and try to understand code that doesn't matter, slowing down onboarding and feature work
|
|
10
|
+
- **CI minutes**: Tests for unused methods still run, burning compute time on every pipeline
|
|
11
|
+
- **False confidence**: Test coverage metrics include dead code, masking gaps in the code that actually runs
|
|
12
|
+
- **Refactoring friction**: Unused code creates dependencies that make refactoring harder ("wait, is this called somewhere?")
|
|
13
|
+
- **Security surface**: More code means more potential vulnerabilities, even in paths users never hit
|
|
14
|
+
|
|
15
|
+
Most codebases accumulate dead code gradually — a feature flag that's always on, a method replaced but never deleted, a scope that lost its last caller. Keela helps you find it and clean it up.
|
|
16
|
+
|
|
17
|
+
## Installation
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
gem install keela
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Or add to your Gemfile:
|
|
24
|
+
|
|
25
|
+
```ruby
|
|
26
|
+
gem 'keela', group: :development
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Quick Start
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
# First time: generate a baseline of current unused code
|
|
33
|
+
keela --update-baseline
|
|
34
|
+
|
|
35
|
+
# This creates .keela_baseline.yml in your project root
|
|
36
|
+
|
|
37
|
+
# From now on, just run:
|
|
38
|
+
keela
|
|
39
|
+
|
|
40
|
+
# Keela will fail if:
|
|
41
|
+
# - NEW unused code is detected (someone added dead code)
|
|
42
|
+
# - Previously unused code was REMOVED (time to update the baseline!)
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## How It Works
|
|
46
|
+
|
|
47
|
+
Keela operates in two modes:
|
|
48
|
+
|
|
49
|
+
### Baseline Mode (Default)
|
|
50
|
+
|
|
51
|
+
If a `.keela_baseline.yml` file exists, Keela compares the current scan against it:
|
|
52
|
+
|
|
53
|
+
| Scenario | Result |
|
|
54
|
+
|----------|--------|
|
|
55
|
+
| No changes from baseline | ✅ Pass (silent, exit 0) |
|
|
56
|
+
| NEW unused code detected | ❌ Fail (shows new items) |
|
|
57
|
+
| Code REMOVED from baseline | ❌ Fail (prompts to update baseline) |
|
|
58
|
+
|
|
59
|
+
This lets you gradually pay down tech debt while preventing new dead code from sneaking in.
|
|
60
|
+
|
|
61
|
+
### Report Mode
|
|
62
|
+
|
|
63
|
+
If no baseline exists (or you use `--report`), Keela shows all unused code:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
keela --report
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Command Line Options
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
# Scan for all unused code (methods and scopes)
|
|
73
|
+
keela
|
|
74
|
+
|
|
75
|
+
# Scan for specific types
|
|
76
|
+
keela --type methods
|
|
77
|
+
keela --type scopes
|
|
78
|
+
|
|
79
|
+
# Force report mode (ignore baseline)
|
|
80
|
+
keela --report
|
|
81
|
+
|
|
82
|
+
# Update the baseline file
|
|
83
|
+
keela --update-baseline
|
|
84
|
+
|
|
85
|
+
# Use a custom baseline path
|
|
86
|
+
keela --baseline config/unused_baseline.yml
|
|
87
|
+
|
|
88
|
+
# Specify excluded items file
|
|
89
|
+
keela --excluded config/keela_excluded.yml
|
|
90
|
+
|
|
91
|
+
# Custom file extensions
|
|
92
|
+
keela --extensions rb,rake,haml
|
|
93
|
+
|
|
94
|
+
# Show version
|
|
95
|
+
keela --version
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## CI Integration
|
|
99
|
+
|
|
100
|
+
Keela is designed for CI pipelines. Add it to catch dead code before it merges:
|
|
101
|
+
|
|
102
|
+
```yaml
|
|
103
|
+
# .gitlab-ci.yml
|
|
104
|
+
unused_code:
|
|
105
|
+
script:
|
|
106
|
+
- bundle exec keela
|
|
107
|
+
rules:
|
|
108
|
+
- if: $CI_MERGE_REQUEST_IID
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
```yaml
|
|
112
|
+
# .github/workflows/ci.yml
|
|
113
|
+
- name: Check for unused code
|
|
114
|
+
run: bundle exec keela
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
The workflow:
|
|
118
|
+
|
|
119
|
+
1. **Initial setup**: Run `keela --update-baseline` and commit `.keela_baseline.yml`
|
|
120
|
+
2. **CI runs**: `keela` compares against baseline, fails on new dead code
|
|
121
|
+
3. **After cleanup**: Run `keela --update-baseline` to update the baseline
|
|
122
|
+
|
|
123
|
+
## Exclusion File
|
|
124
|
+
|
|
125
|
+
Some code appears unused but is actually called dynamically. Exclude it:
|
|
126
|
+
|
|
127
|
+
```yaml
|
|
128
|
+
# .keela_excluded.yml
|
|
129
|
+
app/models/user.rb:
|
|
130
|
+
- legacy_method: "Called via metaprogramming"
|
|
131
|
+
- callback_method: "Used as ActiveRecord callback"
|
|
132
|
+
app/helpers/application_helper.rb:
|
|
133
|
+
- helper_method: "Called from views dynamically"
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Then run with:
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
keela --excluded .keela_excluded.yml
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## Ruby API
|
|
143
|
+
|
|
144
|
+
```ruby
|
|
145
|
+
require 'keela'
|
|
146
|
+
|
|
147
|
+
# Configure Keela
|
|
148
|
+
Keela.configure do |config|
|
|
149
|
+
config.extensions = %w[rb haml erb]
|
|
150
|
+
config.directory_patterns = %w[
|
|
151
|
+
app/**/*.%<ext>s
|
|
152
|
+
lib/**/*.%<ext>s
|
|
153
|
+
]
|
|
154
|
+
config.excluded_path = '.keela_excluded.yml'
|
|
155
|
+
config.baseline_path = '.keela_baseline.yml'
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Run a scan
|
|
159
|
+
strategy = Keela::Strategies::Methods.new
|
|
160
|
+
scanner = Keela::Scanner.new(strategy: strategy)
|
|
161
|
+
success = scanner.run
|
|
162
|
+
|
|
163
|
+
# Access results
|
|
164
|
+
scanner.unused_collection # Hash of file => [unused_names]
|
|
165
|
+
scanner.new_unused # Items not in baseline
|
|
166
|
+
scanner.removed # Items in baseline but no longer unused
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
## Custom Strategies
|
|
170
|
+
|
|
171
|
+
Detect other patterns by creating your own strategy:
|
|
172
|
+
|
|
173
|
+
```ruby
|
|
174
|
+
class CallbackStrategy < Keela::Strategy
|
|
175
|
+
def name
|
|
176
|
+
"callbacks"
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def definition_file_pattern
|
|
180
|
+
%r{app/models}
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def extract_definition(line)
|
|
184
|
+
# Match: before_save :do_something
|
|
185
|
+
line =~ /(?:before|after|around)_\w+\s+:(\w+)/ ? Regexp.last_match(1) : nil
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def usage_regex(name)
|
|
189
|
+
/def #{Regexp.quote(name)}\b/
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def skip_comments?
|
|
193
|
+
true
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
scanner = Keela::Scanner.new(strategy: CallbackStrategy.new)
|
|
198
|
+
scanner.run(force_report: true)
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
## About the Name
|
|
202
|
+
|
|
203
|
+
[Keela](https://en.wikipedia.org/wiki/Keela_(dog)) was a famous English Springer Spaniel known as the "CSI dog." She could detect microscopic traces of blood that other methods missed, and worked on many high-profile forensic cases including the Madeleine McCann investigation. Like her namesake, this gem finds the unused code that other tools miss.
|
|
204
|
+
|
|
205
|
+
## License
|
|
206
|
+
|
|
207
|
+
MIT License. See [LICENSE.txt](LICENSE.txt).
|
data/exe/keela
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "optparse"
|
|
5
|
+
require "keela"
|
|
6
|
+
|
|
7
|
+
options = {
|
|
8
|
+
type: :all,
|
|
9
|
+
force_report: false,
|
|
10
|
+
update_baseline: false
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
OptionParser.new do |opts|
|
|
14
|
+
opts.banner = "Usage: keela [options]"
|
|
15
|
+
|
|
16
|
+
opts.separator ""
|
|
17
|
+
opts.separator "Keela sniffs out unused code in your Ruby codebase."
|
|
18
|
+
opts.separator ""
|
|
19
|
+
opts.separator "By default, if a baseline file exists, Keela runs in diff mode:"
|
|
20
|
+
opts.separator " - Fails if NEW unused code is detected"
|
|
21
|
+
opts.separator " - Fails if previously unused code was REMOVED (update baseline)"
|
|
22
|
+
opts.separator " - Passes if unchanged from baseline"
|
|
23
|
+
opts.separator ""
|
|
24
|
+
opts.separator "If no baseline exists, shows a full report of all unused code."
|
|
25
|
+
opts.separator ""
|
|
26
|
+
opts.separator "Options:"
|
|
27
|
+
|
|
28
|
+
opts.on("--type TYPE", %i[methods scopes all], "Type to detect: methods, scopes, all (default: all)") do |type|
|
|
29
|
+
options[:type] = type
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
opts.on("--report", "-r", "Force report mode: show all unused code (ignore baseline)") do
|
|
33
|
+
options[:force_report] = true
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
opts.on("--update-baseline", "-u", "Update the baseline file with current unused code") do
|
|
37
|
+
options[:update_baseline] = true
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
opts.on("--excluded PATH", "Path to YAML file of excluded items") do |path|
|
|
41
|
+
Keela.configuration.excluded_path = path
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
opts.on("--baseline PATH", "Path to YAML baseline file (default: .keela_baseline.yml)") do |path|
|
|
45
|
+
Keela.configuration.baseline_path = path
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
opts.on("--extensions EXTS", "Comma-separated file extensions to scan (default: rb,haml,erb)") do |exts|
|
|
49
|
+
Keela.configuration.extensions = exts.split(",").map(&:strip)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
opts.on("--version", "-v", "Show version") do
|
|
53
|
+
puts "keela #{Keela::VERSION}"
|
|
54
|
+
exit
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
opts.on("-h", "--help", "Show this help") do
|
|
58
|
+
puts opts
|
|
59
|
+
exit
|
|
60
|
+
end
|
|
61
|
+
end.parse!
|
|
62
|
+
|
|
63
|
+
# Set default baseline path if not specified
|
|
64
|
+
Keela.configuration.baseline_path ||= ".keela_baseline.yml"
|
|
65
|
+
|
|
66
|
+
strategies = case options[:type]
|
|
67
|
+
when :all
|
|
68
|
+
[Keela::Strategies::Methods.new, Keela::Strategies::Scopes.new]
|
|
69
|
+
when :methods
|
|
70
|
+
[Keela::Strategies::Methods.new]
|
|
71
|
+
when :scopes
|
|
72
|
+
[Keela::Strategies::Scopes.new]
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
success = true
|
|
76
|
+
|
|
77
|
+
strategies.each_with_index do |strategy, index|
|
|
78
|
+
puts Rainbow("=== Sniffing for unused #{strategy.name} ===").cyan.bright if strategies.size > 1
|
|
79
|
+
|
|
80
|
+
scanner = Keela::Scanner.new(strategy: strategy)
|
|
81
|
+
success &&= scanner.run(force_report: options[:force_report], update_baseline: options[:update_baseline])
|
|
82
|
+
|
|
83
|
+
puts if strategies.size > 1 && index < strategies.size - 1
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
exit(success ? 0 : 1)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Keela
|
|
4
|
+
class Configuration
|
|
5
|
+
# File extensions to scan for code usage
|
|
6
|
+
attr_accessor :extensions
|
|
7
|
+
|
|
8
|
+
# Directory patterns to scan (use %<ext>s as placeholder for extension)
|
|
9
|
+
attr_accessor :directory_patterns
|
|
10
|
+
|
|
11
|
+
# Path to YAML file listing excluded items (won't be flagged as unused)
|
|
12
|
+
attr_accessor :excluded_path
|
|
13
|
+
|
|
14
|
+
# Path to YAML file tracking known unused items (baseline)
|
|
15
|
+
attr_accessor :baseline_path
|
|
16
|
+
|
|
17
|
+
# Optional directory that must exist for scanning to proceed (e.g., "ee" for GitLab)
|
|
18
|
+
attr_accessor :required_directory
|
|
19
|
+
|
|
20
|
+
# Whether to show progress during scanning
|
|
21
|
+
attr_accessor :show_progress
|
|
22
|
+
|
|
23
|
+
def initialize
|
|
24
|
+
@extensions = %w[rb haml erb].freeze
|
|
25
|
+
@directory_patterns = %w[
|
|
26
|
+
app/**/*.%<ext>s
|
|
27
|
+
lib/**/*.%<ext>s
|
|
28
|
+
config/**/*.%<ext>s
|
|
29
|
+
].freeze
|
|
30
|
+
@excluded_path = nil
|
|
31
|
+
@baseline_path = nil
|
|
32
|
+
@required_directory = nil
|
|
33
|
+
@show_progress = true
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rainbow"
|
|
4
|
+
require "yaml"
|
|
5
|
+
|
|
6
|
+
module Keela
|
|
7
|
+
class Reporter
|
|
8
|
+
attr_reader :strategy_name
|
|
9
|
+
|
|
10
|
+
def initialize(strategy_name)
|
|
11
|
+
@strategy_name = strategy_name
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def print_full_report(unused_collection, elapsed_time)
|
|
15
|
+
unused_count = unused_collection.values.flatten.size
|
|
16
|
+
|
|
17
|
+
if unused_count > 0
|
|
18
|
+
puts "\nFound #{unused_count} unused #{strategy_name}:\n\n"
|
|
19
|
+
puts format_yaml(unused_collection)
|
|
20
|
+
puts "\n"
|
|
21
|
+
else
|
|
22
|
+
puts Rainbow("No unused #{strategy_name} were found.").green.bright
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
puts "Finished in #{elapsed_time.round(2)} seconds."
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def print_diff_report(new_unused, removed, excluded_path:, baseline_path:)
|
|
29
|
+
print_new_unused(new_unused, excluded_path) unless new_unused.empty?
|
|
30
|
+
|
|
31
|
+
if new_unused.size + removed.size > 0
|
|
32
|
+
puts Rainbow("~" * 80).white.bright
|
|
33
|
+
puts "\n"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
print_removed(removed, baseline_path) unless removed.empty?
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def format_yaml(collection)
|
|
40
|
+
indent_yaml_list_items(collection.sort.to_h.to_yaml)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def print_new_unused(new_unused, excluded_path)
|
|
46
|
+
error = <<~MESSAGE
|
|
47
|
+
We have detected #{new_unused.size} newly unused #{strategy_name}.
|
|
48
|
+
|
|
49
|
+
Please remove these #{strategy_name}, or if in use, add to #{excluded_path}.
|
|
50
|
+
MESSAGE
|
|
51
|
+
|
|
52
|
+
puts Rainbow(error).red.bright
|
|
53
|
+
puts Rainbow(format_yaml(parse_diff(new_unused))).red.bright
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def print_removed(removed, baseline_path)
|
|
57
|
+
message = <<~MESSAGE
|
|
58
|
+
It appears you have removed unused #{strategy_name}. Thank you!
|
|
59
|
+
|
|
60
|
+
Please update #{File.basename(baseline_path)} and remove entries for these #{strategy_name}.
|
|
61
|
+
MESSAGE
|
|
62
|
+
|
|
63
|
+
puts Rainbow(message).yellow.bright
|
|
64
|
+
puts Rainbow(format_yaml(parse_diff(removed))).yellow.bright
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def parse_diff(diff_to_parse)
|
|
68
|
+
result = Hash.new { |hash, key| hash[key] = [] }
|
|
69
|
+
|
|
70
|
+
diff_to_parse.each do |file_name, name|
|
|
71
|
+
result[file_name] << name
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
result
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Indents YAML list items that are not already indented.
|
|
78
|
+
# Ruby's to_yaml outputs list items without indentation (e.g., "- item"),
|
|
79
|
+
# but we want 2-space indentation (e.g., " - item").
|
|
80
|
+
def indent_yaml_list_items(yaml_string)
|
|
81
|
+
yaml_string.gsub(/\n-(\s+\S)/, "\n -\\1")
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "parallel"
|
|
4
|
+
require "yaml"
|
|
5
|
+
|
|
6
|
+
module Keela
|
|
7
|
+
class Scanner
|
|
8
|
+
attr_reader :strategy, :configuration, :source_files, :unused_collection, :new_unused, :removed
|
|
9
|
+
|
|
10
|
+
def initialize(strategy:, configuration: Keela.configuration)
|
|
11
|
+
@strategy = strategy
|
|
12
|
+
@configuration = configuration
|
|
13
|
+
@source_files = {}
|
|
14
|
+
@unused_collection = Hash.new { |hash, key| hash[key] = [] }
|
|
15
|
+
@new_unused = []
|
|
16
|
+
@removed = []
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def run(force_report: false, update_baseline: false)
|
|
20
|
+
return true unless should_run?
|
|
21
|
+
|
|
22
|
+
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
23
|
+
|
|
24
|
+
load_source_files
|
|
25
|
+
definitions = find_definitions
|
|
26
|
+
definitions = filter_excluded(definitions)
|
|
27
|
+
|
|
28
|
+
# Determine mode: report if forced, updating baseline, or no baseline exists
|
|
29
|
+
report_mode = force_report || update_baseline || !baseline_exists?
|
|
30
|
+
|
|
31
|
+
find_unused(definitions, show_progress: report_mode)
|
|
32
|
+
|
|
33
|
+
if report_mode
|
|
34
|
+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
|
|
35
|
+
reporter.print_full_report(unused_collection, elapsed)
|
|
36
|
+
write_baseline_file if update_baseline
|
|
37
|
+
return true
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Baseline mode: compare against known unused code
|
|
41
|
+
compare_with_baseline
|
|
42
|
+
reporter.print_diff_report(
|
|
43
|
+
new_unused,
|
|
44
|
+
removed,
|
|
45
|
+
excluded_path: configuration.excluded_path || "excluded.yml",
|
|
46
|
+
baseline_path: configuration.baseline_path
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
new_unused.empty? && removed.empty?
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def file_globs
|
|
53
|
+
configuration.extensions.flat_map do |ext|
|
|
54
|
+
configuration.directory_patterns.map { |pattern| format(pattern, ext: ext) }
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def baseline_exists?
|
|
61
|
+
configuration.baseline_path && File.exist?(configuration.baseline_path)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def should_run?
|
|
65
|
+
return true unless configuration.required_directory
|
|
66
|
+
|
|
67
|
+
Dir.exist?(configuration.required_directory)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def reporter
|
|
71
|
+
@reporter ||= Reporter.new(strategy.name)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def load_source_files
|
|
75
|
+
Dir.glob(file_globs).each do |filename|
|
|
76
|
+
@source_files[filename] = File.readlines(filename)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def find_definitions
|
|
81
|
+
source_files.keys.grep(strategy.definition_file_pattern).flat_map do |filename|
|
|
82
|
+
source_files[filename].flat_map do |line|
|
|
83
|
+
next [] if strategy.skip_comments? && line.strip.start_with?("#")
|
|
84
|
+
|
|
85
|
+
name = strategy.extract_definition(line)
|
|
86
|
+
name ? [{ name: name, file: filename }] : []
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def filter_excluded(definitions)
|
|
92
|
+
return definitions unless configuration.excluded_path
|
|
93
|
+
return definitions unless File.exist?(configuration.excluded_path)
|
|
94
|
+
|
|
95
|
+
excluded = YAML.load_file(configuration.excluded_path, symbolize_names: true) || {}
|
|
96
|
+
|
|
97
|
+
definitions.reject do |h|
|
|
98
|
+
excluded_for_file = excluded[h[:file].to_sym]
|
|
99
|
+
excluded_for_file&.flat_map(&:keys)&.include?(h[:name].to_sym)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def find_unused(definitions, show_progress: false)
|
|
104
|
+
source_code = source_files.values.flatten.join
|
|
105
|
+
|
|
106
|
+
progress_label = show_progress ? "Checking #{strategy.name}" : nil
|
|
107
|
+
|
|
108
|
+
unused = Parallel.flat_map(definitions, progress: progress_label) do |definition|
|
|
109
|
+
regex = strategy.usage_regex(definition[:name])
|
|
110
|
+
regex.match?(source_code) ? [] : definition
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
unused.each do |unused_def|
|
|
114
|
+
@unused_collection[unused_def[:file]] << unused_def[:name]
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def compare_with_baseline
|
|
119
|
+
baseline = YAML.load_file(configuration.baseline_path) || {}
|
|
120
|
+
baseline_items = baseline.flat_map { |f, names| [f].product(names) }
|
|
121
|
+
|
|
122
|
+
current_items = unused_collection.flat_map { |f, names| [f].product(names) }
|
|
123
|
+
|
|
124
|
+
@new_unused = current_items - baseline_items
|
|
125
|
+
@removed = baseline_items - current_items
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def write_baseline_file
|
|
129
|
+
return unless configuration.baseline_path
|
|
130
|
+
|
|
131
|
+
header = <<~HEADER
|
|
132
|
+
# The #{strategy.name} listed here have been identified as "unused" by Keela,
|
|
133
|
+
# and are potential targets for future removal.
|
|
134
|
+
#
|
|
135
|
+
# If a #{strategy.name.chomp('s')} listed here is actually in use,
|
|
136
|
+
# remove it from this file and add it to your excluded file.
|
|
137
|
+
#
|
|
138
|
+
HEADER
|
|
139
|
+
|
|
140
|
+
yaml_content = if unused_collection.empty?
|
|
141
|
+
"#{header}---\n{}\n"
|
|
142
|
+
else
|
|
143
|
+
sorted_collection = unused_collection.sort.to_h
|
|
144
|
+
"#{header}#{reporter.format_yaml(sorted_collection)}"
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
File.write(configuration.baseline_path, yaml_content)
|
|
148
|
+
puts Rainbow("Updated #{configuration.baseline_path}").green.bright
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Keela
|
|
4
|
+
module Strategies
|
|
5
|
+
class Methods < Strategy
|
|
6
|
+
def name
|
|
7
|
+
"methods"
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def definition_file_pattern
|
|
11
|
+
%r{app/helpers|app/models}
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def extract_definition(line)
|
|
15
|
+
return nil unless line =~ /def ([^(;\s]+)/
|
|
16
|
+
|
|
17
|
+
Regexp.last_match(1).chomp
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def usage_regex(name)
|
|
21
|
+
if name.end_with?("=")
|
|
22
|
+
# Setter method: match assignment usage
|
|
23
|
+
/(?<!def )#{Regexp.quote(name.sub(/^self\./, "").chomp("="))}\W=*/
|
|
24
|
+
else
|
|
25
|
+
# Regular method: match calls
|
|
26
|
+
/(?<!def )#{Regexp.quote(name.sub(/^self\./, ""))}\W/
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def skip_comments?
|
|
31
|
+
false
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Keela
|
|
4
|
+
module Strategies
|
|
5
|
+
class Scopes < Strategy
|
|
6
|
+
def name
|
|
7
|
+
"scopes"
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def definition_file_pattern
|
|
11
|
+
%r{app/models}
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def extract_definition(line)
|
|
15
|
+
return nil unless line =~ /\bscope\s+:(\w+)/
|
|
16
|
+
|
|
17
|
+
Regexp.last_match(1)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def usage_regex(name)
|
|
21
|
+
/(?<!scope :)(?<!def )#{Regexp.quote(name)}\W/
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def skip_comments?
|
|
25
|
+
true
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Keela
|
|
4
|
+
# Base class for detection strategies.
|
|
5
|
+
#
|
|
6
|
+
# Subclasses define how to find definitions and detect usage for
|
|
7
|
+
# different types of code (methods, scopes, etc.)
|
|
8
|
+
#
|
|
9
|
+
class Strategy
|
|
10
|
+
# Human-readable name for this strategy (e.g., "methods", "scopes")
|
|
11
|
+
def name
|
|
12
|
+
raise NotImplementedError, "#{self.class} must implement #name"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Regex pattern to match files that may contain definitions
|
|
16
|
+
# (e.g., /app\/models/ for scopes)
|
|
17
|
+
def definition_file_pattern
|
|
18
|
+
raise NotImplementedError, "#{self.class} must implement #definition_file_pattern"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Extract a definition name from a line of code, or nil if no definition found
|
|
22
|
+
def extract_definition(line)
|
|
23
|
+
raise NotImplementedError, "#{self.class} must implement #extract_definition"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Build a regex to detect usage of the given definition name
|
|
27
|
+
def usage_regex(name)
|
|
28
|
+
raise NotImplementedError, "#{self.class} must implement #usage_regex"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Whether to skip lines that start with # (comments)
|
|
32
|
+
def skip_comments?
|
|
33
|
+
false
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
data/lib/keela.rb
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "keela/version"
|
|
4
|
+
require_relative "keela/configuration"
|
|
5
|
+
require_relative "keela/strategy"
|
|
6
|
+
require_relative "keela/strategies/methods"
|
|
7
|
+
require_relative "keela/strategies/scopes"
|
|
8
|
+
require_relative "keela/reporter"
|
|
9
|
+
require_relative "keela/scanner"
|
|
10
|
+
|
|
3
11
|
module Keela
|
|
4
|
-
|
|
12
|
+
class << self
|
|
13
|
+
attr_writer :configuration
|
|
14
|
+
|
|
15
|
+
def configuration
|
|
16
|
+
@configuration ||= Configuration.new
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def configure
|
|
20
|
+
yield(configuration)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def reset_configuration!
|
|
24
|
+
@configuration = Configuration.new
|
|
25
|
+
end
|
|
26
|
+
end
|
|
5
27
|
end
|
metadata
CHANGED
|
@@ -1,26 +1,84 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: keela
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.0
|
|
4
|
+
version: 0.1.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Kerri Miller
|
|
8
|
-
bindir:
|
|
8
|
+
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
10
|
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
-
dependencies:
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: parallel
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '1.20'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '1.20'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: rainbow
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '3.0'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '3.0'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: ruby-progressbar
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '1.11'
|
|
47
|
+
type: :runtime
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '1.11'
|
|
12
54
|
description: Like the famous CSI dog who found what others missed, Keela detects unused
|
|
13
55
|
methods and scopes in your Ruby codebase.
|
|
14
56
|
email: kerrizor@kerrizor.com
|
|
15
|
-
executables:
|
|
57
|
+
executables:
|
|
58
|
+
- keela
|
|
16
59
|
extensions: []
|
|
17
60
|
extra_rdoc_files: []
|
|
18
61
|
files:
|
|
62
|
+
- CHANGELOG.md
|
|
63
|
+
- LICENSE.txt
|
|
64
|
+
- README.md
|
|
65
|
+
- exe/keela
|
|
19
66
|
- lib/keela.rb
|
|
67
|
+
- lib/keela/configuration.rb
|
|
68
|
+
- lib/keela/reporter.rb
|
|
69
|
+
- lib/keela/scanner.rb
|
|
70
|
+
- lib/keela/strategies/methods.rb
|
|
71
|
+
- lib/keela/strategies/scopes.rb
|
|
72
|
+
- lib/keela/strategy.rb
|
|
73
|
+
- lib/keela/version.rb
|
|
20
74
|
homepage: https://github.com/kerrizor/keela
|
|
21
75
|
licenses:
|
|
22
76
|
- MIT
|
|
23
|
-
metadata:
|
|
77
|
+
metadata:
|
|
78
|
+
homepage_uri: https://github.com/kerrizor/keela
|
|
79
|
+
source_code_uri: https://github.com/kerrizor/keela
|
|
80
|
+
changelog_uri: https://github.com/kerrizor/keela/blob/main/CHANGELOG.md
|
|
81
|
+
bug_tracker_uri: https://github.com/kerrizor/keela/issues
|
|
24
82
|
rdoc_options: []
|
|
25
83
|
require_paths:
|
|
26
84
|
- lib
|
|
@@ -35,7 +93,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
35
93
|
- !ruby/object:Gem::Version
|
|
36
94
|
version: '0'
|
|
37
95
|
requirements: []
|
|
38
|
-
rubygems_version: 4.0.
|
|
96
|
+
rubygems_version: 4.0.15
|
|
39
97
|
specification_version: 4
|
|
40
98
|
summary: Sniff out unused code in your Ruby codebase
|
|
41
99
|
test_files: []
|