keela 0.3.0 → 0.4.1
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 +28 -0
- data/README.md +167 -7
- data/exe/keela +78 -24
- data/lib/keela/config_file.rb +19 -0
- data/lib/keela/configuration.rb +20 -0
- data/lib/keela/exclusion_validator.rb +160 -0
- data/lib/keela/formatters/base.rb +33 -0
- data/lib/keela/formatters/json.rb +22 -0
- data/lib/keela/formatters/toon.rb +22 -0
- data/lib/keela/formatters.rb +22 -0
- data/lib/keela/reporter.rb +35 -6
- data/lib/keela/scanner.rb +24 -6
- data/lib/keela/strategies/attributes.rb +1 -1
- data/lib/keela/strategies/constants.rb +7 -3
- data/lib/keela/strategies/delegations.rb +3 -7
- data/lib/keela/strategies/i18n_keys.rb +91 -9
- data/lib/keela/strategies/methods.rb +12 -4
- data/lib/keela/strategies/scopes.rb +1 -1
- data/lib/keela/strategy.rb +34 -1
- data/lib/keela/version.rb +1 -1
- data/lib/keela.rb +2 -0
- metadata +20 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1b1c9fad8b959b91c3af1518da72d36f9f82bd649b4c21a6a0cf261246cbbcdb
|
|
4
|
+
data.tar.gz: 24c7b946587cb844ae9c08c921ffecacfb8b262404b2c177f0368d803cc52861
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e0fcd131e331240b852f7d657113aa0e9610aa4a7dd465b78576478c11abdeca5cd4140c9037cbbd378f53e23e4137d6a255eeb8d28950f0f82f6ad0eeabc7fd
|
|
7
|
+
data.tar.gz: 7d71ea4b1a2c601f0aba8901fbe31f95ba055eb784614213afa386321e096bb2364ecd832b60356f08c8cc42ed2e07e9bdcfc4c3a794fe218931834ac7df7c39
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.4.1] - 2026-08-21
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- Constants used as hash keys (`CONST => value`) or in rescue splats (`rescue *ERRORS => e`) are now correctly detected as used ([#63](https://github.com/kerrizor/keela/pull/63))
|
|
15
|
+
- Methods referenced as literal symbols (`:method_name`) are now detected as used, including Rails callbacks, `send(:method)`, `validate :method`, etc. ([#64](https://github.com/kerrizor/keela/issues/64))
|
|
16
|
+
- I18n pluralization keys (`one`, `other`, `zero`, etc.) are now detected as used when the parent key is called with `t('key', count: n)` ([#69](https://github.com/kerrizor/keela/pull/69))
|
|
17
|
+
- I18n lazy lookup (`t('.title')` in views) now correctly resolves to the full key based on the view path ([#17](https://github.com/kerrizor/keela/issues/17))
|
|
18
|
+
|
|
19
|
+
## [0.4.0] - 2026-08-14
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
|
|
23
|
+
- **Verbose mode** via `--verbose` flag to show files being scanned, glob patterns, and configuration for debugging ([#53](https://github.com/kerrizor/keela/pull/53))
|
|
24
|
+
- **Source location** via `--source-location` flag to show line numbers in reports for easier navigation ([#55](https://github.com/kerrizor/keela/pull/55))
|
|
25
|
+
- **Exclusion validation** via `--test-exclusions` flag to find stale entries in exclusion files ([#56](https://github.com/kerrizor/keela/pull/56))
|
|
26
|
+
- **TOON output format** via `--format toon` for token-efficient LLM-friendly output ([#58](https://github.com/kerrizor/keela/pull/58))
|
|
27
|
+
- **Configurable definition paths** per strategy via `strategies.<name>.definition_paths` in config file ([#59](https://github.com/kerrizor/keela/pull/59))
|
|
28
|
+
|
|
29
|
+
### Changed
|
|
30
|
+
|
|
31
|
+
- **`--quiet` now suppresses all output**, not just the progress bar. Use exit code for success/failure in scripts. ([#54](https://github.com/kerrizor/keela/pull/54))
|
|
32
|
+
|
|
33
|
+
### Fixed
|
|
34
|
+
|
|
35
|
+
- Multi-method delegate declarations now detect all methods, not just the first ([#51](https://github.com/kerrizor/keela/pull/51))
|
|
36
|
+
- Class methods (`def self.foo`) are now correctly detected as unused ([#60](https://github.com/kerrizor/keela/pull/60))
|
|
37
|
+
|
|
10
38
|
## [0.3.0] - 2026-08-05
|
|
11
39
|
|
|
12
40
|
### Added
|
data/README.md
CHANGED
|
@@ -66,6 +66,27 @@ If a `.keela_baseline.yml` file exists, Keela compares the current scan against
|
|
|
66
66
|
|
|
67
67
|
This lets you gradually pay down tech debt while preventing new dead code from sneaking in.
|
|
68
68
|
|
|
69
|
+
The baseline file is organized by strategy:
|
|
70
|
+
|
|
71
|
+
```yaml
|
|
72
|
+
# .keela_baseline.yml
|
|
73
|
+
methods:
|
|
74
|
+
app/models/user.rb:
|
|
75
|
+
- legacy_method
|
|
76
|
+
- old_callback
|
|
77
|
+
app/helpers/application_helper.rb:
|
|
78
|
+
- unused_helper
|
|
79
|
+
scopes:
|
|
80
|
+
app/models/user.rb:
|
|
81
|
+
- inactive
|
|
82
|
+
- archived
|
|
83
|
+
constants:
|
|
84
|
+
app/models/user.rb:
|
|
85
|
+
- OLD_STATUS
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
**Note:** The baseline file only stores names, not line numbers. This prevents false positives when code moves within a file.
|
|
89
|
+
|
|
69
90
|
### Report Mode
|
|
70
91
|
|
|
71
92
|
If no baseline exists (or you use `--report`), Keela shows all unused code:
|
|
@@ -74,6 +95,32 @@ If no baseline exists (or you use `--report`), Keela shows all unused code:
|
|
|
74
95
|
keela --report
|
|
75
96
|
```
|
|
76
97
|
|
|
98
|
+
## What "Unused" Means
|
|
99
|
+
|
|
100
|
+
Keela defines "unused" as **unused in production code** — not just unused anywhere.
|
|
101
|
+
|
|
102
|
+
By default, Keela scans `app/`, `lib/`, and `config/` directories. It intentionally
|
|
103
|
+
excludes `spec/` and `test/` directories because:
|
|
104
|
+
|
|
105
|
+
1. **Tests aren't usage** — Code that only exists to be tested isn't providing
|
|
106
|
+
application value. If you delete the unused code, you delete its tests too.
|
|
107
|
+
|
|
108
|
+
2. **Tests can cover dead code** — A method with 100% test coverage can still be
|
|
109
|
+
dead if nothing in the application calls it.
|
|
110
|
+
|
|
111
|
+
3. **Cleaner signal** — Including test files would hide genuinely unused code
|
|
112
|
+
behind "but it has tests!" false negatives.
|
|
113
|
+
|
|
114
|
+
If Keela flags something that's only used in tests, consider whether the code
|
|
115
|
+
(and its tests) can be removed entirely. If the code is intentionally test-only
|
|
116
|
+
(e.g., test helpers defined in `app/`), add it to your exclusion file.
|
|
117
|
+
|
|
118
|
+
To include test directories in usage scanning (not recommended), use `--include`:
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
keela --include 'test/**/*.rb' --include 'spec/**/*.rb'
|
|
122
|
+
```
|
|
123
|
+
|
|
77
124
|
## Command Line Options
|
|
78
125
|
|
|
79
126
|
```bash
|
|
@@ -114,10 +161,19 @@ keela --config path/to/keela.yml
|
|
|
114
161
|
# Output as JSON (for CI integrations)
|
|
115
162
|
keela --format json
|
|
116
163
|
|
|
117
|
-
# Suppress
|
|
164
|
+
# Suppress all output (useful for CI and scripting, rely on exit code)
|
|
118
165
|
keela --quiet
|
|
119
166
|
keela -q
|
|
120
167
|
|
|
168
|
+
# Show verbose debugging output (files scanned, patterns used)
|
|
169
|
+
keela --verbose
|
|
170
|
+
|
|
171
|
+
# Show source location (file:line) for each unused item
|
|
172
|
+
keela --source-location
|
|
173
|
+
|
|
174
|
+
# Validate exclusion file entries (find stale exclusions)
|
|
175
|
+
keela --test-exclusions
|
|
176
|
+
|
|
121
177
|
# Show version
|
|
122
178
|
keela --version
|
|
123
179
|
```
|
|
@@ -143,13 +199,67 @@ Run all strategies (default) or target specific ones with `--type`.
|
|
|
143
199
|
|
|
144
200
|
The `i18n_keys` strategy is **beta** and may produce false positives. It cannot detect:
|
|
145
201
|
|
|
146
|
-
- **Lazy lookup** - `t('.title')` in views resolves based on the view path
|
|
147
202
|
- **Dynamic keys** - `t("users.#{action}.title")` with interpolated segments
|
|
148
203
|
- **Model translations** - `User.human_attribute_name(:email)` and `User.model_name.human`
|
|
149
|
-
|
|
204
|
+
|
|
205
|
+
The following patterns ARE now supported:
|
|
206
|
+
- **Lazy lookup** - `t('.title')` in views resolves based on the view path
|
|
207
|
+
- **Pluralization siblings** - `t('key', count: n)` marks all plural forms as used
|
|
150
208
|
|
|
151
209
|
Review results carefully and use the exclusion file for known false positives.
|
|
152
210
|
|
|
211
|
+
## Limitations
|
|
212
|
+
|
|
213
|
+
Keela uses static analysis — it reads your code without executing it. This means some patterns are **fundamentally undetectable**.
|
|
214
|
+
|
|
215
|
+
### What Keela CAN Detect
|
|
216
|
+
|
|
217
|
+
Keela detects **literal references** to methods, constants, etc.:
|
|
218
|
+
|
|
219
|
+
```ruby
|
|
220
|
+
# ✅ Direct calls
|
|
221
|
+
user.save
|
|
222
|
+
User.find(1)
|
|
223
|
+
|
|
224
|
+
# ✅ Literal symbol references
|
|
225
|
+
before_save :ensure_token
|
|
226
|
+
validate :check_valid
|
|
227
|
+
send(:process_data)
|
|
228
|
+
respond_to?(:optional_method)
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
### What Keela CANNOT Detect
|
|
232
|
+
|
|
233
|
+
**Dynamic dispatch** with interpolation or variables cannot be analyzed statically:
|
|
234
|
+
|
|
235
|
+
```ruby
|
|
236
|
+
# ❌ Interpolated symbols — what method does this call?
|
|
237
|
+
public_send(:"add_#{role}", user)
|
|
238
|
+
|
|
239
|
+
# ❌ Variable method names — could be anything
|
|
240
|
+
send(method_name)
|
|
241
|
+
|
|
242
|
+
# ❌ Computed method names
|
|
243
|
+
define_method(compute_name) { }
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
To detect these, Keela would need to trace all possible runtime values — essentially becoming a Ruby interpreter. This is not a bug; it's a fundamental limitation of static analysis.
|
|
247
|
+
|
|
248
|
+
### Handling False Positives
|
|
249
|
+
|
|
250
|
+
When Keela reports a method as unused but it's actually called dynamically, add it to your exclusion file:
|
|
251
|
+
|
|
252
|
+
```yaml
|
|
253
|
+
# .keela/excluded.yml
|
|
254
|
+
methods:
|
|
255
|
+
app/models/project_team.rb:
|
|
256
|
+
- add_owner: "Called via public_send(:\"add_\#{role}\")"
|
|
257
|
+
- add_maintainer: "Called via public_send(:\"add_\#{role}\")"
|
|
258
|
+
- add_developer: "Called via public_send(:\"add_\#{role}\")"
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
**Tip:** If you see `send`, `public_send`, or `define_method` with interpolation in a file, expect some false positives for methods in that file.
|
|
262
|
+
|
|
153
263
|
## Configuration File
|
|
154
264
|
|
|
155
265
|
Create a `keela.yml` or `.keela.yml` in your project root:
|
|
@@ -170,10 +280,39 @@ include_patterns:
|
|
|
170
280
|
|
|
171
281
|
excluded_path: ".keela_excluded.yml"
|
|
172
282
|
baseline_path: ".keela_baseline.yml"
|
|
283
|
+
|
|
284
|
+
# Per-strategy configuration
|
|
285
|
+
strategies:
|
|
286
|
+
methods:
|
|
287
|
+
definition_paths:
|
|
288
|
+
- app/helpers
|
|
289
|
+
- app/models
|
|
290
|
+
- lib/
|
|
173
291
|
```
|
|
174
292
|
|
|
175
293
|
Keela automatically loads `keela.yml` or `.keela.yml` from the current directory. Use `--config` to specify a different path.
|
|
176
294
|
|
|
295
|
+
### Customizing Definition Paths Per Strategy
|
|
296
|
+
|
|
297
|
+
By default, each strategy looks for definitions in specific directories (e.g., `methods` looks in `app/helpers` and `app/models`). You can customize this per-strategy:
|
|
298
|
+
|
|
299
|
+
```yaml
|
|
300
|
+
# keela.yml
|
|
301
|
+
strategies:
|
|
302
|
+
methods:
|
|
303
|
+
definition_paths:
|
|
304
|
+
- app/helpers
|
|
305
|
+
- app/models
|
|
306
|
+
- lib/
|
|
307
|
+
- ee/app/models
|
|
308
|
+
scopes:
|
|
309
|
+
definition_paths:
|
|
310
|
+
- app/models
|
|
311
|
+
- ee/app/models
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
This is useful when your project has code in non-standard locations (like `lib/` or enterprise edition directories) that you want Keela to check for unused definitions.
|
|
315
|
+
|
|
177
316
|
### Customizing Which Files to Scan
|
|
178
317
|
|
|
179
318
|
There are two approaches:
|
|
@@ -238,14 +377,17 @@ The workflow:
|
|
|
238
377
|
2. **CI runs**: `keela` compares against baseline, fails on new dead code
|
|
239
378
|
3. **After cleanup**: Run `keela --update-baseline` to update the baseline
|
|
240
379
|
|
|
241
|
-
###
|
|
380
|
+
### Structured Output Formats
|
|
242
381
|
|
|
243
|
-
Use `--format
|
|
382
|
+
Use `--format` for machine-readable output:
|
|
244
383
|
|
|
245
384
|
```bash
|
|
246
|
-
keela --format json --report
|
|
385
|
+
keela --format json --report # JSON output
|
|
386
|
+
keela --format toon --report # TOON output (token-efficient for LLMs)
|
|
247
387
|
```
|
|
248
388
|
|
|
389
|
+
#### JSON
|
|
390
|
+
|
|
249
391
|
```json
|
|
250
392
|
{
|
|
251
393
|
"strategies": ["methods", "scopes"],
|
|
@@ -267,7 +409,25 @@ keela --format json --report
|
|
|
267
409
|
}
|
|
268
410
|
```
|
|
269
411
|
|
|
270
|
-
|
|
412
|
+
#### TOON
|
|
413
|
+
|
|
414
|
+
[TOON (Token-Oriented Object Notation)](https://toonformat.dev/) is a compact format optimized for LLM prompts, using ~40-50% fewer tokens than JSON:
|
|
415
|
+
|
|
416
|
+
```
|
|
417
|
+
strategies[2]: methods,scopes
|
|
418
|
+
unused:
|
|
419
|
+
methods:
|
|
420
|
+
"app/models/user.rb"[2]: unused_method,old_helper
|
|
421
|
+
scopes:
|
|
422
|
+
"app/models/post.rb"[1]: inactive
|
|
423
|
+
summary:
|
|
424
|
+
total: 3
|
|
425
|
+
by_strategy:
|
|
426
|
+
methods: 2
|
|
427
|
+
scopes: 1
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
These formats are useful for integrating with other tools, generating reports, processing results programmatically, or including in LLM context windows.
|
|
271
431
|
|
|
272
432
|
## Exclusion File
|
|
273
433
|
|
data/exe/keela
CHANGED
|
@@ -43,7 +43,7 @@ OptionParser.new do |opts|
|
|
|
43
43
|
options[:update_baseline] = true
|
|
44
44
|
end
|
|
45
45
|
|
|
46
|
-
opts.on("--format FORMAT", %i[text json], "Output format: text (default) or
|
|
46
|
+
opts.on("--format FORMAT", %i[text json toon], "Output format: text (default), json, or toon") do |format|
|
|
47
47
|
options[:format] = format
|
|
48
48
|
end
|
|
49
49
|
|
|
@@ -82,6 +82,18 @@ OptionParser.new do |opts|
|
|
|
82
82
|
options[:quiet] = true
|
|
83
83
|
end
|
|
84
84
|
|
|
85
|
+
opts.on("--verbose", "Show detailed debugging output (files scanned, patterns used)") do
|
|
86
|
+
options[:verbose] = true
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
opts.on("--source-location", "Show file:line for each unused item in reports") do
|
|
90
|
+
options[:source_location] = true
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
opts.on("--test-exclusions", "Validate that exclusion file entries match actual definitions") do
|
|
94
|
+
options[:test_exclusions] = true
|
|
95
|
+
end
|
|
96
|
+
|
|
85
97
|
opts.on("-h", "--help", "Show this help") do
|
|
86
98
|
puts opts
|
|
87
99
|
exit
|
|
@@ -99,9 +111,30 @@ end
|
|
|
99
111
|
# Apply quiet mode if requested
|
|
100
112
|
Keela.configuration.show_progress = false if options[:quiet]
|
|
101
113
|
|
|
114
|
+
# Apply verbose mode if requested
|
|
115
|
+
Keela.configuration.verbose = true if options[:verbose]
|
|
116
|
+
|
|
117
|
+
# Apply source location mode if requested
|
|
118
|
+
Keela.configuration.source_location = true if options[:source_location]
|
|
119
|
+
|
|
102
120
|
# Set default baseline path if not specified
|
|
103
121
|
Keela.configuration.baseline_path ||= ".keela_baseline.yml"
|
|
104
122
|
|
|
123
|
+
# Handle --test-exclusions mode
|
|
124
|
+
if options[:test_exclusions]
|
|
125
|
+
excluded_path = Keela.configuration.excluded_path ||
|
|
126
|
+
Keela::Scanner::DEFAULT_EXCLUDED_PATHS.find { |p| File.exist?(p) }
|
|
127
|
+
|
|
128
|
+
unless excluded_path
|
|
129
|
+
warn "Error: No exclusion file found. Specify with --excluded PATH"
|
|
130
|
+
exit 1
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
validator = Keela::ExclusionValidator.new(excluded_path)
|
|
134
|
+
success = validator.validate
|
|
135
|
+
exit(success ? 0 : 1)
|
|
136
|
+
end
|
|
137
|
+
|
|
105
138
|
STRATEGY_MAP = {
|
|
106
139
|
methods: Keela::Strategies::Methods,
|
|
107
140
|
scopes: Keela::Strategies::Scopes,
|
|
@@ -141,13 +174,46 @@ baseline = Keela::Baseline.new(Keela.configuration.baseline_path)
|
|
|
141
174
|
# Load source files once and share across all strategies
|
|
142
175
|
source_files = Keela::Scanner.load_source_files
|
|
143
176
|
|
|
177
|
+
# Show verbose output if requested
|
|
178
|
+
if options[:verbose]
|
|
179
|
+
config = Keela.configuration
|
|
180
|
+
globs = Keela::Scanner.build_file_globs(config)
|
|
181
|
+
|
|
182
|
+
puts Rainbow("=== Verbose Mode ===").magenta.bright
|
|
183
|
+
puts
|
|
184
|
+
puts Rainbow("Configuration:").yellow
|
|
185
|
+
puts " Extensions: #{config.extensions.join(', ')}"
|
|
186
|
+
puts " Directory patterns: #{config.directory_patterns.join(', ')}"
|
|
187
|
+
puts " Include patterns: #{config.include_patterns.empty? ? '(none)' : config.include_patterns.join(', ')}"
|
|
188
|
+
puts " Exclude patterns: #{config.exclude_patterns.empty? ? '(none)' : config.exclude_patterns.join(', ')}"
|
|
189
|
+
puts
|
|
190
|
+
puts Rainbow("Resolved globs:").yellow
|
|
191
|
+
globs.each { |g| puts " #{g}" }
|
|
192
|
+
puts
|
|
193
|
+
sorted_files = source_files.keys.sort
|
|
194
|
+
file_count = sorted_files.size
|
|
195
|
+
max_display = 50
|
|
196
|
+
|
|
197
|
+
puts Rainbow("Files to scan (#{file_count}):").yellow
|
|
198
|
+
# Show all files when piping (not a TTY), truncate for interactive use
|
|
199
|
+
if file_count <= max_display || !$stdout.tty?
|
|
200
|
+
sorted_files.each { |f| puts " #{f}" }
|
|
201
|
+
else
|
|
202
|
+
sorted_files.first(max_display).each { |f| puts " #{f}" }
|
|
203
|
+
puts " ... and #{file_count - max_display} more files"
|
|
204
|
+
puts " (pipe output to see full list: keela --verbose > files.txt)"
|
|
205
|
+
end
|
|
206
|
+
puts
|
|
207
|
+
end
|
|
208
|
+
|
|
144
209
|
success = true
|
|
145
210
|
results = {}
|
|
146
211
|
|
|
147
|
-
|
|
212
|
+
structured_output = Keela::Formatters.structured_format?(options[:format])
|
|
213
|
+
silent_output = structured_output || options[:quiet]
|
|
148
214
|
|
|
149
215
|
strategies.each_with_index do |strategy, index|
|
|
150
|
-
unless
|
|
216
|
+
unless silent_output
|
|
151
217
|
puts Rainbow("=== Sniffing for unused #{strategy.name} ===").cyan.bright if strategies.size > 1
|
|
152
218
|
end
|
|
153
219
|
|
|
@@ -155,13 +221,13 @@ strategies.each_with_index do |strategy, index|
|
|
|
155
221
|
success &&= scanner.run(
|
|
156
222
|
force_report: options[:force_report],
|
|
157
223
|
update_baseline: options[:update_baseline],
|
|
158
|
-
silent:
|
|
224
|
+
silent: silent_output
|
|
159
225
|
)
|
|
160
226
|
|
|
161
|
-
# Collect results for
|
|
227
|
+
# Collect results for structured output formats
|
|
162
228
|
results[strategy.name] = scanner.unused_collection.transform_values(&:to_a)
|
|
163
229
|
|
|
164
|
-
unless
|
|
230
|
+
unless silent_output
|
|
165
231
|
puts if strategies.size > 1 && index < strategies.size - 1
|
|
166
232
|
end
|
|
167
233
|
end
|
|
@@ -169,26 +235,14 @@ end
|
|
|
169
235
|
# Save baseline after all strategies have run
|
|
170
236
|
if options[:update_baseline]
|
|
171
237
|
baseline.save
|
|
172
|
-
puts Rainbow("Updated #{baseline.path}").green.bright unless
|
|
238
|
+
puts Rainbow("Updated #{baseline.path}").green.bright unless silent_output
|
|
173
239
|
end
|
|
174
240
|
|
|
175
|
-
# Output
|
|
176
|
-
if
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
by_strategy = results.transform_values { |files| files.values.flatten.size }
|
|
181
|
-
|
|
182
|
-
output = {
|
|
183
|
-
strategies: strategies.map(&:name),
|
|
184
|
-
unused: results.reject { |_, v| v.empty? },
|
|
185
|
-
summary: {
|
|
186
|
-
total: total,
|
|
187
|
-
by_strategy: by_strategy.reject { |_, v| v.zero? }
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
puts JSON.pretty_generate(output)
|
|
241
|
+
# Output structured format if requested
|
|
242
|
+
if structured_output
|
|
243
|
+
formatter_class = Keela::Formatters.for(options[:format])
|
|
244
|
+
formatter = formatter_class.new(results: results, strategies: strategies)
|
|
245
|
+
puts formatter.format
|
|
192
246
|
end
|
|
193
247
|
|
|
194
248
|
exit(success ? 0 : 1)
|
data/lib/keela/config_file.rb
CHANGED
|
@@ -17,6 +17,7 @@ module Keela
|
|
|
17
17
|
# - excluded_path: Path to YAML file of excluded items
|
|
18
18
|
# - baseline_path: Path to baseline YAML file
|
|
19
19
|
# - required_directory: Directory that must exist for scanning to proceed
|
|
20
|
+
# - strategies: Per-strategy configuration (see below)
|
|
20
21
|
#
|
|
21
22
|
# Example:
|
|
22
23
|
# # keela.yml
|
|
@@ -29,6 +30,15 @@ module Keela
|
|
|
29
30
|
# - rb
|
|
30
31
|
# - haml
|
|
31
32
|
# - erb
|
|
33
|
+
# strategies:
|
|
34
|
+
# methods:
|
|
35
|
+
# definition_paths:
|
|
36
|
+
# - app/helpers
|
|
37
|
+
# - app/models
|
|
38
|
+
# - lib/
|
|
39
|
+
# scopes:
|
|
40
|
+
# definition_paths:
|
|
41
|
+
# - app/models
|
|
32
42
|
#
|
|
33
43
|
module ConfigFile
|
|
34
44
|
CONFIG_FILENAMES = %w[.keela/config.yml keela.yml .keela.yml].freeze
|
|
@@ -74,6 +84,15 @@ module Keela
|
|
|
74
84
|
value = config[key]
|
|
75
85
|
configuration.public_send("#{key}=", value)
|
|
76
86
|
end
|
|
87
|
+
|
|
88
|
+
apply_strategy_options(config["strategies"]) if config.key?("strategies")
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def apply_strategy_options(strategies)
|
|
92
|
+
return unless strategies.is_a?(Hash)
|
|
93
|
+
|
|
94
|
+
configuration = Keela.configuration
|
|
95
|
+
configuration.strategy_options = strategies
|
|
77
96
|
end
|
|
78
97
|
end
|
|
79
98
|
end
|
data/lib/keela/configuration.rb
CHANGED
|
@@ -26,6 +26,18 @@ module Keela
|
|
|
26
26
|
# Additional directory patterns to include (added to directory_patterns)
|
|
27
27
|
attr_accessor :include_patterns
|
|
28
28
|
|
|
29
|
+
# Whether to show verbose debugging output
|
|
30
|
+
attr_accessor :verbose
|
|
31
|
+
|
|
32
|
+
# Whether to show source location (file:line) in reports
|
|
33
|
+
attr_accessor :source_location
|
|
34
|
+
|
|
35
|
+
# Per-strategy configuration options
|
|
36
|
+
# Hash of strategy_name => { option => value }
|
|
37
|
+
# Supported options:
|
|
38
|
+
# - definition_file_pattern: Regex pattern string for files containing definitions
|
|
39
|
+
attr_accessor :strategy_options
|
|
40
|
+
|
|
29
41
|
def initialize
|
|
30
42
|
@extensions = %w[rb haml erb].freeze
|
|
31
43
|
@directory_patterns = %w[
|
|
@@ -39,6 +51,14 @@ module Keela
|
|
|
39
51
|
@show_progress = true
|
|
40
52
|
@exclude_patterns = []
|
|
41
53
|
@include_patterns = []
|
|
54
|
+
@verbose = false
|
|
55
|
+
@source_location = false
|
|
56
|
+
@strategy_options = {}
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Get options for a specific strategy
|
|
60
|
+
def options_for(strategy_name)
|
|
61
|
+
strategy_options[strategy_name.to_s] || {}
|
|
42
62
|
end
|
|
43
63
|
end
|
|
44
64
|
end
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "rainbow"
|
|
5
|
+
|
|
6
|
+
module Keela
|
|
7
|
+
class ExclusionValidator
|
|
8
|
+
STRATEGY_MAP = {
|
|
9
|
+
methods: Strategies::Methods,
|
|
10
|
+
scopes: Strategies::Scopes,
|
|
11
|
+
constants: Strategies::Constants,
|
|
12
|
+
delegations: Strategies::Delegations,
|
|
13
|
+
attributes: Strategies::Attributes,
|
|
14
|
+
i18n_keys: Strategies::I18nKeys
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
attr_reader :excluded_path, :results
|
|
18
|
+
|
|
19
|
+
def initialize(excluded_path)
|
|
20
|
+
@excluded_path = excluded_path
|
|
21
|
+
@results = { valid: [], stale: [] }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def validate
|
|
25
|
+
unless File.exist?(excluded_path)
|
|
26
|
+
puts Rainbow("Exclusion file not found: #{excluded_path}").red
|
|
27
|
+
return false
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
all_excluded = YAML.load_file(excluded_path, symbolize_names: true) || {}
|
|
31
|
+
|
|
32
|
+
if all_excluded.empty?
|
|
33
|
+
puts Rainbow("Exclusion file is empty: #{excluded_path}").yellow
|
|
34
|
+
return true
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
puts "Validating exclusions in #{excluded_path}...\n\n"
|
|
38
|
+
|
|
39
|
+
# Detect format: strategy-aware or legacy flat
|
|
40
|
+
if strategy_aware_format?(all_excluded)
|
|
41
|
+
validate_strategy_aware(all_excluded)
|
|
42
|
+
else
|
|
43
|
+
validate_legacy_flat(all_excluded)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
print_results
|
|
47
|
+
results[:stale].empty?
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def strategy_aware_format?(data)
|
|
53
|
+
data.keys.any? { |k| STRATEGY_MAP.key?(k) }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def validate_strategy_aware(all_excluded)
|
|
57
|
+
all_excluded.each do |strategy_name, files|
|
|
58
|
+
next unless STRATEGY_MAP.key?(strategy_name)
|
|
59
|
+
|
|
60
|
+
strategy = STRATEGY_MAP[strategy_name].new
|
|
61
|
+
validate_files(strategy, strategy_name, files || {})
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def validate_legacy_flat(all_excluded)
|
|
66
|
+
# Legacy format applies to all strategies, but we'll check against methods
|
|
67
|
+
strategy = Strategies::Methods.new
|
|
68
|
+
validate_files(strategy, :methods, all_excluded)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def validate_files(strategy, strategy_name, files)
|
|
72
|
+
files.each do |file_path, entries|
|
|
73
|
+
file_str = file_path.to_s
|
|
74
|
+
entries ||= []
|
|
75
|
+
|
|
76
|
+
entries.each do |entry|
|
|
77
|
+
name = entry.keys.first.to_s
|
|
78
|
+
reason = entry.values.first
|
|
79
|
+
|
|
80
|
+
if !File.exist?(file_str)
|
|
81
|
+
results[:stale] << {
|
|
82
|
+
strategy: strategy_name,
|
|
83
|
+
file: file_str,
|
|
84
|
+
name: name,
|
|
85
|
+
reason: reason,
|
|
86
|
+
error: "file not found"
|
|
87
|
+
}
|
|
88
|
+
elsif !definition_exists?(strategy, file_str, name)
|
|
89
|
+
results[:stale] << {
|
|
90
|
+
strategy: strategy_name,
|
|
91
|
+
file: file_str,
|
|
92
|
+
name: name,
|
|
93
|
+
reason: reason,
|
|
94
|
+
error: "no definition found"
|
|
95
|
+
}
|
|
96
|
+
else
|
|
97
|
+
results[:valid] << {
|
|
98
|
+
strategy: strategy_name,
|
|
99
|
+
file: file_str,
|
|
100
|
+
name: name,
|
|
101
|
+
reason: reason
|
|
102
|
+
}
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def definition_exists?(strategy, file_path, name)
|
|
109
|
+
return false unless File.exist?(file_path)
|
|
110
|
+
|
|
111
|
+
lines = File.readlines(file_path)
|
|
112
|
+
|
|
113
|
+
# Check custom extraction first (for I18n YAML files)
|
|
114
|
+
custom_defs = strategy.extract_definitions_from_file(file_path, lines)
|
|
115
|
+
if custom_defs
|
|
116
|
+
return custom_defs.any? { |d| d[:name] == name }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Default line-by-line extraction
|
|
120
|
+
lines.any? do |line|
|
|
121
|
+
next if strategy.skip_comments? && line.strip.start_with?("#")
|
|
122
|
+
|
|
123
|
+
result = strategy.extract_definition(line)
|
|
124
|
+
Array(result).compact.include?(name)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def print_results
|
|
129
|
+
if results[:valid].any?
|
|
130
|
+
puts Rainbow("✅ Valid exclusions (#{results[:valid].size}):").green.bright
|
|
131
|
+
group_by_strategy(results[:valid]).each do |strategy, entries|
|
|
132
|
+
puts " #{strategy}:"
|
|
133
|
+
entries.each do |e|
|
|
134
|
+
puts " #{e[:file]}:#{e[:name]}"
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
puts
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
if results[:stale].any?
|
|
141
|
+
puts Rainbow("⚠️ Stale exclusions found (#{results[:stale].size}):").yellow.bright
|
|
142
|
+
puts
|
|
143
|
+
group_by_strategy(results[:stale]).each do |strategy, entries|
|
|
144
|
+
puts " #{strategy}:"
|
|
145
|
+
entries.each do |e|
|
|
146
|
+
puts " #{e[:file]}:#{e[:name]} — #{e[:error]}"
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
puts
|
|
150
|
+
puts "Consider removing stale exclusions from #{excluded_path}"
|
|
151
|
+
else
|
|
152
|
+
puts Rainbow("All exclusions are valid!").green.bright
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def group_by_strategy(entries)
|
|
157
|
+
entries.group_by { |e| e[:strategy] }
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Keela
|
|
4
|
+
module Formatters
|
|
5
|
+
class Base
|
|
6
|
+
attr_reader :results, :strategies
|
|
7
|
+
|
|
8
|
+
def initialize(results:, strategies:)
|
|
9
|
+
@results = results
|
|
10
|
+
@strategies = strategies
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def format
|
|
14
|
+
raise NotImplementedError, "Subclasses must implement #format"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def total_count
|
|
20
|
+
results.values.flat_map(&:values).flatten.size
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def by_strategy_counts
|
|
24
|
+
results.transform_values { |files| files.values.flatten.size }
|
|
25
|
+
.reject { |_, v| v.zero? }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def non_empty_results
|
|
29
|
+
results.reject { |_, v| v.empty? }
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Keela
|
|
6
|
+
module Formatters
|
|
7
|
+
class Json < Base
|
|
8
|
+
def format
|
|
9
|
+
output = {
|
|
10
|
+
strategies: strategies.map(&:name),
|
|
11
|
+
unused: non_empty_results,
|
|
12
|
+
summary: {
|
|
13
|
+
total: total_count,
|
|
14
|
+
by_strategy: by_strategy_counts
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
JSON.pretty_generate(output)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "toon"
|
|
4
|
+
|
|
5
|
+
module Keela
|
|
6
|
+
module Formatters
|
|
7
|
+
class Toon < Base
|
|
8
|
+
def format
|
|
9
|
+
output = {
|
|
10
|
+
"strategies" => strategies.map(&:name),
|
|
11
|
+
"unused" => non_empty_results.transform_keys(&:to_s),
|
|
12
|
+
"summary" => {
|
|
13
|
+
"total" => total_count,
|
|
14
|
+
"by_strategy" => by_strategy_counts.transform_keys(&:to_s)
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
::Toon.encode(output)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "formatters/base"
|
|
4
|
+
require_relative "formatters/json"
|
|
5
|
+
require_relative "formatters/toon"
|
|
6
|
+
|
|
7
|
+
module Keela
|
|
8
|
+
module Formatters
|
|
9
|
+
REGISTRY = {
|
|
10
|
+
json: Json,
|
|
11
|
+
toon: Toon
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
def self.for(format)
|
|
15
|
+
REGISTRY[format]
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.structured_format?(format)
|
|
19
|
+
REGISTRY.key?(format)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
data/lib/keela/reporter.rb
CHANGED
|
@@ -11,12 +11,12 @@ module Keela
|
|
|
11
11
|
@strategy_name = strategy_name
|
|
12
12
|
end
|
|
13
13
|
|
|
14
|
-
def print_full_report(unused_collection, elapsed_time)
|
|
14
|
+
def print_full_report(unused_collection, elapsed_time, source_locations: {})
|
|
15
15
|
unused_count = unused_collection.values.flatten.size
|
|
16
16
|
|
|
17
17
|
if unused_count > 0
|
|
18
18
|
puts "\nFound #{unused_count} unused #{strategy_name}:\n\n"
|
|
19
|
-
puts
|
|
19
|
+
puts format_report(unused_collection, source_locations)
|
|
20
20
|
puts "\n"
|
|
21
21
|
else
|
|
22
22
|
puts Rainbow("No unused #{strategy_name} were found.").green.bright
|
|
@@ -25,8 +25,8 @@ module Keela
|
|
|
25
25
|
puts "Finished in #{elapsed_time.round(2)} seconds."
|
|
26
26
|
end
|
|
27
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?
|
|
28
|
+
def print_diff_report(new_unused, removed, excluded_path:, baseline_path:, source_locations: {})
|
|
29
|
+
print_new_unused(new_unused, excluded_path, source_locations) unless new_unused.empty?
|
|
30
30
|
|
|
31
31
|
if new_unused.size + removed.size > 0
|
|
32
32
|
puts Rainbow("~" * 80).white.bright
|
|
@@ -36,13 +36,42 @@ module Keela
|
|
|
36
36
|
print_removed(removed, baseline_path) unless removed.empty?
|
|
37
37
|
end
|
|
38
38
|
|
|
39
|
+
def format_report(collection, source_locations = {})
|
|
40
|
+
if source_locations.empty?
|
|
41
|
+
format_yaml(collection)
|
|
42
|
+
else
|
|
43
|
+
format_with_locations(collection, source_locations)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
39
47
|
def format_yaml(collection)
|
|
40
48
|
indent_yaml_list_items(collection.sort.to_h.to_yaml)
|
|
41
49
|
end
|
|
42
50
|
|
|
43
51
|
private
|
|
44
52
|
|
|
45
|
-
def
|
|
53
|
+
def format_with_locations(collection, source_locations)
|
|
54
|
+
# Calculate max name length for alignment
|
|
55
|
+
all_names = collection.values.flatten
|
|
56
|
+
max_name_len = all_names.map(&:length).max || 0
|
|
57
|
+
padding = max_name_len + 6 # " - " prefix + 2 spaces
|
|
58
|
+
|
|
59
|
+
lines = ["---"]
|
|
60
|
+
collection.sort.each do |file, names|
|
|
61
|
+
lines << "#{file}:"
|
|
62
|
+
names.each do |name|
|
|
63
|
+
line_num = source_locations["#{file}:#{name}"]
|
|
64
|
+
if line_num
|
|
65
|
+
lines << " - #{name}".ljust(padding) + "#{file}:#{line_num}"
|
|
66
|
+
else
|
|
67
|
+
lines << " - #{name}"
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
lines.join("\n")
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def print_new_unused(new_unused, excluded_path, source_locations = {})
|
|
46
75
|
error = <<~MESSAGE
|
|
47
76
|
We have detected #{new_unused.size} newly unused #{strategy_name}.
|
|
48
77
|
|
|
@@ -50,7 +79,7 @@ module Keela
|
|
|
50
79
|
MESSAGE
|
|
51
80
|
|
|
52
81
|
puts Rainbow(error).red.bright
|
|
53
|
-
puts Rainbow(
|
|
82
|
+
puts Rainbow(format_report(parse_diff(new_unused), source_locations)).red.bright
|
|
54
83
|
end
|
|
55
84
|
|
|
56
85
|
def print_removed(removed, baseline_path)
|
data/lib/keela/scanner.rb
CHANGED
|
@@ -5,7 +5,7 @@ require "yaml"
|
|
|
5
5
|
|
|
6
6
|
module Keela
|
|
7
7
|
class Scanner
|
|
8
|
-
attr_reader :strategy, :configuration, :baseline, :source_files, :unused_collection, :new_unused, :removed
|
|
8
|
+
attr_reader :strategy, :configuration, :baseline, :source_files, :unused_collection, :source_locations, :new_unused, :removed
|
|
9
9
|
|
|
10
10
|
DEFAULT_EXCLUDED_PATHS = [".keela/excluded.yml", "keela_excluded.yml"].freeze
|
|
11
11
|
DEFAULT_BASELINE_PATHS = [".keela/baseline.yml", "keela_baseline.yml"].freeze
|
|
@@ -48,6 +48,7 @@ module Keela
|
|
|
48
48
|
@source_files = source_files || {}
|
|
49
49
|
@source_files_preloaded = !source_files.nil?
|
|
50
50
|
@unused_collection = Hash.new { |hash, key| hash[key] = [] }
|
|
51
|
+
@source_locations = {} # { "file:name" => line_number }
|
|
51
52
|
@new_unused = []
|
|
52
53
|
@removed = []
|
|
53
54
|
end
|
|
@@ -70,7 +71,7 @@ module Keela
|
|
|
70
71
|
|
|
71
72
|
if report_mode
|
|
72
73
|
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
|
|
73
|
-
reporter.print_full_report(unused_collection, elapsed) unless silent
|
|
74
|
+
reporter.print_full_report(unused_collection, elapsed, source_locations: source_locations) unless silent
|
|
74
75
|
if update_baseline
|
|
75
76
|
baseline.set(strategy.name, unused_collection)
|
|
76
77
|
# Note: caller is responsible for calling baseline.save after all strategies run
|
|
@@ -85,7 +86,8 @@ module Keela
|
|
|
85
86
|
new_unused,
|
|
86
87
|
removed,
|
|
87
88
|
excluded_path: resolve_excluded_path || ".keela/excluded.yml",
|
|
88
|
-
baseline_path: baseline.path
|
|
89
|
+
baseline_path: baseline.path,
|
|
90
|
+
source_locations: source_locations
|
|
89
91
|
)
|
|
90
92
|
end
|
|
91
93
|
|
|
@@ -155,11 +157,17 @@ module Keela
|
|
|
155
157
|
next custom_definitions if custom_definitions
|
|
156
158
|
|
|
157
159
|
# Default: line-by-line parsing
|
|
158
|
-
|
|
160
|
+
track_lines = configuration.source_location
|
|
161
|
+
lines.each_with_index.flat_map do |line, index|
|
|
159
162
|
next [] if strategy.skip_comments? && line.strip.start_with?("#")
|
|
160
163
|
|
|
161
|
-
|
|
162
|
-
|
|
164
|
+
result = strategy.extract_definition(line)
|
|
165
|
+
# Support both single name (String) and multiple names (Array)
|
|
166
|
+
Array(result).compact.map do |name|
|
|
167
|
+
definition = { name: name, file: filename }
|
|
168
|
+
definition[:line] = index + 1 if track_lines
|
|
169
|
+
definition
|
|
170
|
+
end
|
|
163
171
|
end
|
|
164
172
|
end
|
|
165
173
|
end
|
|
@@ -202,15 +210,25 @@ module Keela
|
|
|
202
210
|
def find_unused(definitions, show_progress: false)
|
|
203
211
|
source_code = source_files.values.flatten.join
|
|
204
212
|
|
|
213
|
+
# Get additional used names from strategy-specific detection
|
|
214
|
+
# (e.g., I18n lazy lookup)
|
|
215
|
+
additional_used = strategy.additional_used_names(source_files)
|
|
216
|
+
|
|
205
217
|
progress_label = show_progress ? "Checking #{strategy.name}" : nil
|
|
206
218
|
|
|
207
219
|
unused = Parallel.flat_map(definitions, progress: progress_label) do |definition|
|
|
220
|
+
# Check if marked as used by additional detection
|
|
221
|
+
next [] if additional_used.include?(definition[:name])
|
|
222
|
+
|
|
208
223
|
regex = strategy.usage_regex(definition[:name])
|
|
209
224
|
regex.match?(source_code) ? [] : definition
|
|
210
225
|
end
|
|
211
226
|
|
|
212
227
|
unused.each do |unused_def|
|
|
213
228
|
@unused_collection[unused_def[:file]] << unused_def[:name]
|
|
229
|
+
if unused_def[:line]
|
|
230
|
+
@source_locations["#{unused_def[:file]}:#{unused_def[:name]}"] = unused_def[:line]
|
|
231
|
+
end
|
|
214
232
|
end
|
|
215
233
|
end
|
|
216
234
|
|
|
@@ -7,7 +7,7 @@ module Keela
|
|
|
7
7
|
"constants"
|
|
8
8
|
end
|
|
9
9
|
|
|
10
|
-
def
|
|
10
|
+
def default_definition_file_pattern
|
|
11
11
|
# Match app/ and lib/ directories, but exclude spec/ and test/
|
|
12
12
|
%r{(?:^|/)(?:ee/)?(?:app|lib)/}
|
|
13
13
|
end
|
|
@@ -41,8 +41,12 @@ module Keela
|
|
|
41
41
|
# uppercase letters/digits/underscores (partial match)
|
|
42
42
|
# Uses negative lookahead to avoid:
|
|
43
43
|
# - partial matches (followed by uppercase letters/digits/underscores)
|
|
44
|
-
# - definitions (followed by optional whitespace then =, but not ==)
|
|
45
|
-
|
|
44
|
+
# - definitions (followed by optional whitespace then =, but not == or =>)
|
|
45
|
+
#
|
|
46
|
+
# The pattern (?!\s*=(?![=>])) means:
|
|
47
|
+
# - Don't match if followed by optional whitespace, then =
|
|
48
|
+
# - UNLESS that = is followed by = (comparison) or > (hash rocket)
|
|
49
|
+
/(?<![A-Z0-9_])#{Regexp.quote(name)}(?![A-Z0-9_])(?!\s*=(?![=>]))/
|
|
46
50
|
end
|
|
47
51
|
|
|
48
52
|
def skip_comments?
|
|
@@ -7,7 +7,7 @@ module Keela
|
|
|
7
7
|
"delegations"
|
|
8
8
|
end
|
|
9
9
|
|
|
10
|
-
def
|
|
10
|
+
def default_definition_file_pattern
|
|
11
11
|
# Match app/models/ directories (including concerns), but exclude spec/test
|
|
12
12
|
%r{(?:^|/)(?:ee/)?app/models/}
|
|
13
13
|
end
|
|
@@ -44,12 +44,8 @@ module Keela
|
|
|
44
44
|
methods = methods.map { |m| "#{prefix}_#{m}" }
|
|
45
45
|
end
|
|
46
46
|
|
|
47
|
-
# Return
|
|
48
|
-
|
|
49
|
-
# The scanner will create one definition entry per extract_definition call
|
|
50
|
-
# To handle multiple delegations per line, we'd need to change the scanner
|
|
51
|
-
# For now, return just the first method
|
|
52
|
-
methods.first
|
|
47
|
+
# Return all methods (scanner handles both single string and array)
|
|
48
|
+
methods.length == 1 ? methods.first : methods
|
|
53
49
|
end
|
|
54
50
|
|
|
55
51
|
def usage_regex(name)
|
|
@@ -15,14 +15,21 @@ module Keela
|
|
|
15
15
|
# - t(:key)
|
|
16
16
|
# - .human_attribute_name(:attr)
|
|
17
17
|
#
|
|
18
|
-
#
|
|
18
|
+
# Pluralization keys (zero, one, two, few, many, other) are grouped:
|
|
19
|
+
# if t("items.count", count: n) is called, all siblings are considered used.
|
|
20
|
+
#
|
|
21
|
+
# Lazy lookup is supported: t('.title') in app/views/users/show.html.erb
|
|
22
|
+
# resolves to 'users.show.title'.
|
|
19
23
|
#
|
|
20
24
|
class I18nKeys < Strategy
|
|
25
|
+
PLURAL_SUFFIXES = %w[zero one two few many other].freeze
|
|
26
|
+
VIEW_PATH_REGEX = %r{(?:ee/)?app/views/(.+)\.html\.(?:erb|haml|slim)$}.freeze
|
|
27
|
+
LAZY_LOOKUP_REGEX = /(?:I18n\.)?t\s*\(\s*['"](\.[^'"]+)['"]/
|
|
21
28
|
def name
|
|
22
29
|
"i18n_keys"
|
|
23
30
|
end
|
|
24
31
|
|
|
25
|
-
def
|
|
32
|
+
def default_definition_file_pattern
|
|
26
33
|
# Match locale YAML files
|
|
27
34
|
%r{config/locales/.*\.ya?ml$}
|
|
28
35
|
end
|
|
@@ -32,10 +39,19 @@ module Keela
|
|
|
32
39
|
return [] unless File.exist?(filepath)
|
|
33
40
|
|
|
34
41
|
content = YAML.load_file(filepath, permitted_classes: [Symbol]) || {}
|
|
35
|
-
flatten_keys(content).map do |key|
|
|
42
|
+
keys = flatten_keys(content).map do |key|
|
|
36
43
|
# Remove the locale prefix (e.g., "en.users.show" -> "users.show")
|
|
37
|
-
|
|
38
|
-
|
|
44
|
+
key.sub(/^[a-z]{2}(-[A-Z]{2})?\./, "")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# For pluralization keys, also add the parent key so that
|
|
48
|
+
# t("items.count", count: n) marks all siblings as used
|
|
49
|
+
parent_keys = keys.filter_map do |key|
|
|
50
|
+
parent_key_for_pluralization(key)
|
|
51
|
+
end.uniq
|
|
52
|
+
|
|
53
|
+
(keys + parent_keys).uniq.map do |key|
|
|
54
|
+
{ name: key, file: filepath }
|
|
39
55
|
end
|
|
40
56
|
rescue Psych::SyntaxError => e
|
|
41
57
|
warn "Warning: Could not parse #{filepath}: #{e.message}"
|
|
@@ -55,20 +71,86 @@ module Keela
|
|
|
55
71
|
# t('users.show.title')
|
|
56
72
|
# t(:users_show_title) - symbol form (underscored)
|
|
57
73
|
#
|
|
58
|
-
#
|
|
59
|
-
# t(".
|
|
74
|
+
# For pluralization keys, also match the parent key:
|
|
75
|
+
# t("items.count", count: n) should match items.count.one, items.count.other, etc.
|
|
60
76
|
quoted_name = Regexp.quote(name)
|
|
61
77
|
|
|
62
|
-
#
|
|
63
|
-
|
|
78
|
+
# Check if this is a pluralization key and build alternate pattern
|
|
79
|
+
parent_key = parent_key_for_pluralization(name)
|
|
80
|
+
if parent_key
|
|
81
|
+
quoted_parent = Regexp.quote(parent_key)
|
|
82
|
+
# Match either the exact key OR the parent key
|
|
83
|
+
/(?:I18n\.)?t\s*\(\s*["':]+(?:#{quoted_name}|#{quoted_parent})["']?\s*[,)]/
|
|
84
|
+
else
|
|
85
|
+
# Build pattern that matches the key in quotes or as a symbol
|
|
86
|
+
/(?:I18n\.)?t\s*\(\s*["':]+#{quoted_name}["']?\s*[,)]/
|
|
87
|
+
end
|
|
64
88
|
end
|
|
65
89
|
|
|
66
90
|
def skip_comments?
|
|
67
91
|
true
|
|
68
92
|
end
|
|
69
93
|
|
|
94
|
+
# Convert a view file path to its I18n prefix
|
|
95
|
+
# "app/views/users/show.html.erb" -> "users.show"
|
|
96
|
+
# "app/views/users/_form.html.erb" -> "users.form"
|
|
97
|
+
# "ee/app/views/users/show.html.erb" -> "users.show"
|
|
98
|
+
def view_path_to_prefix(path)
|
|
99
|
+
return nil unless path =~ VIEW_PATH_REGEX
|
|
100
|
+
|
|
101
|
+
view_path = Regexp.last_match(1)
|
|
102
|
+
|
|
103
|
+
# Split into parts and process
|
|
104
|
+
parts = view_path.split("/")
|
|
105
|
+
|
|
106
|
+
# Handle partials: _form -> form
|
|
107
|
+
parts[-1] = parts[-1].sub(/^_/, "")
|
|
108
|
+
|
|
109
|
+
parts.join(".")
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Extract lazy lookup keys from view content
|
|
113
|
+
# Returns array of keys like [".title", ".description"]
|
|
114
|
+
def extract_lazy_keys(content)
|
|
115
|
+
content.scan(LAZY_LOOKUP_REGEX).flatten
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Build a set of expanded lazy lookup keys from view files
|
|
119
|
+
# Called by the scanner to detect usage via lazy lookup
|
|
120
|
+
def additional_used_names(source_files)
|
|
121
|
+
expanded = Set.new
|
|
122
|
+
|
|
123
|
+
source_files.each do |filepath, lines|
|
|
124
|
+
prefix = view_path_to_prefix(filepath)
|
|
125
|
+
next unless prefix
|
|
126
|
+
|
|
127
|
+
content = lines.join("\n")
|
|
128
|
+
lazy_keys = extract_lazy_keys(content)
|
|
129
|
+
|
|
130
|
+
lazy_keys.each do |lazy_key|
|
|
131
|
+
# .title -> users.show.title
|
|
132
|
+
full_key = "#{prefix}#{lazy_key}"
|
|
133
|
+
expanded << full_key
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
expanded
|
|
138
|
+
end
|
|
139
|
+
|
|
70
140
|
private
|
|
71
141
|
|
|
142
|
+
# Returns the parent key if this is a pluralization key, nil otherwise
|
|
143
|
+
# "items.count.one" -> "items.count"
|
|
144
|
+
# "users.show.title" -> nil
|
|
145
|
+
def parent_key_for_pluralization(key)
|
|
146
|
+
PLURAL_SUFFIXES.each do |suffix|
|
|
147
|
+
if key.end_with?(".#{suffix}")
|
|
148
|
+
return key.sub(/\.#{suffix}$/, "")
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
nil
|
|
152
|
+
end
|
|
153
|
+
|
|
72
154
|
# Flatten nested hash to dot-notation keys
|
|
73
155
|
# { "en" => { "users" => { "title" => "..." } } }
|
|
74
156
|
# becomes ["en.users.title"]
|
|
@@ -7,7 +7,7 @@ module Keela
|
|
|
7
7
|
"methods"
|
|
8
8
|
end
|
|
9
9
|
|
|
10
|
-
def
|
|
10
|
+
def default_definition_file_pattern
|
|
11
11
|
%r{app/helpers|app/models}
|
|
12
12
|
end
|
|
13
13
|
|
|
@@ -18,12 +18,20 @@ module Keela
|
|
|
18
18
|
end
|
|
19
19
|
|
|
20
20
|
def usage_regex(name)
|
|
21
|
+
method_name = Regexp.quote(name.sub(/^self\./, ""))
|
|
22
|
+
|
|
21
23
|
if name.end_with?("=")
|
|
22
24
|
# Setter method: match assignment usage
|
|
23
|
-
/(?<!def )#{
|
|
25
|
+
/(?<!def |def self\.)#{method_name.chomp("=")}\W=*/
|
|
24
26
|
else
|
|
25
|
-
# Regular method: match calls
|
|
26
|
-
|
|
27
|
+
# Regular method: match calls and symbol references
|
|
28
|
+
# Matches:
|
|
29
|
+
# - Direct calls: foo(arg), obj.foo
|
|
30
|
+
# - Symbol references: :foo, :foo! (callbacks, send, etc.)
|
|
31
|
+
# Excludes:
|
|
32
|
+
# - Definitions: def foo, def self.foo
|
|
33
|
+
# - Partial matches: :foobar, before_foo (via word boundary lookbehind)
|
|
34
|
+
/(?<!def |def self\.)(?<![A-Za-z0-9_]):?#{method_name}(?:\W|$)/
|
|
27
35
|
end
|
|
28
36
|
end
|
|
29
37
|
|
data/lib/keela/strategy.rb
CHANGED
|
@@ -14,8 +14,23 @@ module Keela
|
|
|
14
14
|
|
|
15
15
|
# Regex pattern to match files that may contain definitions
|
|
16
16
|
# (e.g., /app\/models/ for scopes)
|
|
17
|
+
#
|
|
18
|
+
# Can be overridden via configuration:
|
|
19
|
+
# strategies:
|
|
20
|
+
# methods:
|
|
21
|
+
# definition_paths:
|
|
22
|
+
# - app/helpers
|
|
23
|
+
# - app/models
|
|
24
|
+
# - lib/
|
|
25
|
+
#
|
|
17
26
|
def definition_file_pattern
|
|
18
|
-
|
|
27
|
+
configured_pattern || default_definition_file_pattern
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Default pattern when no configuration override is provided.
|
|
31
|
+
# Subclasses should implement this instead of definition_file_pattern.
|
|
32
|
+
def default_definition_file_pattern
|
|
33
|
+
raise NotImplementedError, "#{self.class} must implement #default_definition_file_pattern"
|
|
19
34
|
end
|
|
20
35
|
|
|
21
36
|
# Extract a definition name from a line of code, or nil if no definition found
|
|
@@ -39,5 +54,23 @@ module Keela
|
|
|
39
54
|
def extract_definitions_from_file(_filepath, _lines)
|
|
40
55
|
nil
|
|
41
56
|
end
|
|
57
|
+
|
|
58
|
+
# Override this method for strategies that need to detect usage through
|
|
59
|
+
# patterns that can't be expressed as a simple regex (e.g., I18n lazy lookup).
|
|
60
|
+
# Returns a Set of definition names that are considered "used".
|
|
61
|
+
# Called with the source_files hash { filepath => [lines] }.
|
|
62
|
+
def additional_used_names(_source_files)
|
|
63
|
+
Set.new
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def configured_pattern
|
|
69
|
+
paths = Keela.configuration.options_for(name)["definition_paths"]
|
|
70
|
+
return nil unless paths.is_a?(Array) && paths.any?
|
|
71
|
+
|
|
72
|
+
escaped = paths.map { |p| Regexp.escape(p.to_s) }
|
|
73
|
+
Regexp.new(escaped.join("|"))
|
|
74
|
+
end
|
|
42
75
|
end
|
|
43
76
|
end
|
data/lib/keela/version.rb
CHANGED
data/lib/keela.rb
CHANGED
|
@@ -13,6 +13,8 @@ require_relative "keela/strategies/i18n_keys"
|
|
|
13
13
|
require_relative "keela/reporter"
|
|
14
14
|
require_relative "keela/baseline"
|
|
15
15
|
require_relative "keela/scanner"
|
|
16
|
+
require_relative "keela/exclusion_validator"
|
|
17
|
+
require_relative "keela/formatters"
|
|
16
18
|
|
|
17
19
|
module Keela
|
|
18
20
|
class Error < StandardError; end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: keela
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Kerri Miller
|
|
@@ -51,6 +51,20 @@ dependencies:
|
|
|
51
51
|
- - "~>"
|
|
52
52
|
- !ruby/object:Gem::Version
|
|
53
53
|
version: '1.11'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: toon-ruby
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - "~>"
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '0.1'
|
|
61
|
+
type: :runtime
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - "~>"
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '0.1'
|
|
54
68
|
description: Like the famous CSI dog who found what others missed, Keela detects unused
|
|
55
69
|
methods and scopes in your Ruby codebase.
|
|
56
70
|
email: kerrizor@kerrizor.com
|
|
@@ -67,6 +81,11 @@ files:
|
|
|
67
81
|
- lib/keela/baseline.rb
|
|
68
82
|
- lib/keela/config_file.rb
|
|
69
83
|
- lib/keela/configuration.rb
|
|
84
|
+
- lib/keela/exclusion_validator.rb
|
|
85
|
+
- lib/keela/formatters.rb
|
|
86
|
+
- lib/keela/formatters/base.rb
|
|
87
|
+
- lib/keela/formatters/json.rb
|
|
88
|
+
- lib/keela/formatters/toon.rb
|
|
70
89
|
- lib/keela/reporter.rb
|
|
71
90
|
- lib/keela/scanner.rb
|
|
72
91
|
- lib/keela/strategies/attributes.rb
|