keela 0.1.0 → 0.2.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 +21 -0
- data/README.md +141 -2
- data/exe/keela +103 -15
- data/lib/keela/baseline.rb +82 -0
- data/lib/keela/config_file.rb +79 -0
- data/lib/keela/configuration.rb +8 -0
- data/lib/keela/scanner.rb +60 -44
- data/lib/keela/strategies/attributes.rb +44 -0
- data/lib/keela/strategies/constants.rb +53 -0
- data/lib/keela/strategies/delegations.rb +70 -0
- data/lib/keela/strategies/i18n_keys.rb +88 -0
- data/lib/keela/strategy.rb +7 -0
- data/lib/keela/version.rb +1 -1
- data/lib/keela.rb +9 -0
- metadata +8 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 920e953982643da9bb58171ea626a130d27a3cc41f6ccfb3511eabefbb0b41c8
|
|
4
|
+
data.tar.gz: 8c7e5d91900e962efca4d08f242e9582ad9192c1d07401e45ce545df5af68d2a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 01efcb8620dd29060d5758ef41def0dd450f62d59d46cb6de8873a18b1a69014895bd64bd68aa263731cb6edbc8640dc6b24f3a9281a865ff650b9717693dd1f
|
|
7
|
+
data.tar.gz: d9e8650985b2ca2a618201ecc2ecc8ced5e275564c37e84658207b498ba70b2279d281486d2bd1127cf9696bc278cff5121a411b42ef27287bf94f5b5d157da6
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.2.0] - 2026-07-17
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- `Baseline` class for managing multi-strategy baseline files ([#1](https://github.com/kerrizor/keela/pull/1))
|
|
15
|
+
- Single baseline file now supports multiple strategy sections (methods, scopes)
|
|
16
|
+
- **Constants strategy** for detecting unused constant definitions (`SCREAMING_SNAKE_CASE = value`)
|
|
17
|
+
- **Delegations strategy** for detecting unused `delegate :method, to: :target` declarations
|
|
18
|
+
- **Attributes strategy** for detecting unused `attr_accessor`, `attr_reader`, `attr_writer` declarations
|
|
19
|
+
- **Config file support** (`keela.yml` or `.keela.yml`) for project-specific settings
|
|
20
|
+
- **Exclude patterns** via `--exclude` CLI flag or `exclude_patterns` in config file
|
|
21
|
+
- **Include patterns** via `--include` CLI flag or `include_patterns` in config file (adds to default directory patterns)
|
|
22
|
+
- **Configuration validation** - raises `ConfigurationError` if `directory_patterns` is customized while also using `include_patterns` or `exclude_patterns`
|
|
23
|
+
- **I18n keys strategy (beta)** for detecting unused translation keys in locale files (`--type i18n_keys`)
|
|
24
|
+
- **Multiple types** can now be specified with `--type methods,scopes,constants`
|
|
25
|
+
- **JSON output** via `--format json` for machine-readable results (useful for CI integrations)
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
|
|
29
|
+
- Running strategies separately no longer overwrites previous strategy data ([#1](https://github.com/kerrizor/keela/pull/1))
|
|
30
|
+
|
|
10
31
|
## [0.1.0] - 2026-07-16
|
|
11
32
|
|
|
12
33
|
### Added
|
data/README.md
CHANGED
|
@@ -69,13 +69,16 @@ keela --report
|
|
|
69
69
|
## Command Line Options
|
|
70
70
|
|
|
71
71
|
```bash
|
|
72
|
-
# Scan for all unused code (methods
|
|
72
|
+
# Scan for all unused code (methods, scopes, constants, delegations, attributes)
|
|
73
73
|
keela
|
|
74
74
|
|
|
75
75
|
# Scan for specific types
|
|
76
76
|
keela --type methods
|
|
77
77
|
keela --type scopes
|
|
78
78
|
|
|
79
|
+
# Combine multiple types
|
|
80
|
+
keela --type methods,scopes,constants
|
|
81
|
+
|
|
79
82
|
# Force report mode (ignore baseline)
|
|
80
83
|
keela --report
|
|
81
84
|
|
|
@@ -91,10 +94,113 @@ keela --excluded config/keela_excluded.yml
|
|
|
91
94
|
# Custom file extensions
|
|
92
95
|
keela --extensions rb,rake,haml
|
|
93
96
|
|
|
97
|
+
# Exclude files matching patterns
|
|
98
|
+
keela --exclude 'vendor/**/*' --exclude 'tmp/**/*'
|
|
99
|
+
|
|
100
|
+
# Include additional directories (adds to defaults)
|
|
101
|
+
keela --include 'engines/**/*.rb' --include 'custom/**/*.rb'
|
|
102
|
+
|
|
103
|
+
# Use a custom config file
|
|
104
|
+
keela --config path/to/keela.yml
|
|
105
|
+
|
|
106
|
+
# Output as JSON (for CI integrations)
|
|
107
|
+
keela --format json
|
|
108
|
+
|
|
94
109
|
# Show version
|
|
95
110
|
keela --version
|
|
96
111
|
```
|
|
97
112
|
|
|
113
|
+
## Detection Strategies
|
|
114
|
+
|
|
115
|
+
Keela detects several types of unused code:
|
|
116
|
+
|
|
117
|
+
| Strategy | Detects | Example |
|
|
118
|
+
|----------|---------|---------|
|
|
119
|
+
| **methods** | Unused method definitions | `def unused_method` |
|
|
120
|
+
| **scopes** | Unused ActiveRecord scopes | `scope :unused_scope, -> { }` |
|
|
121
|
+
| **constants** | Unused constants | `UNUSED_CONSTANT = 'value'` |
|
|
122
|
+
| **delegations** | Unused delegate declarations | `delegate :unused, to: :target` |
|
|
123
|
+
| **attributes** | Unused attr_* declarations | `attr_accessor :unused_attr` |
|
|
124
|
+
| **i18n_keys** | Unused translation keys | `en.users.unused_key` in locale YAML |
|
|
125
|
+
|
|
126
|
+
Run all strategies (default) or target specific ones with `--type`.
|
|
127
|
+
|
|
128
|
+
**Note:** The `i18n_keys` strategy is not included in `--type all` because it requires scanning YAML locale files. Run it explicitly with `--type i18n_keys`.
|
|
129
|
+
|
|
130
|
+
### I18n Keys (Beta)
|
|
131
|
+
|
|
132
|
+
The `i18n_keys` strategy is **beta** and may produce false positives. It cannot detect:
|
|
133
|
+
|
|
134
|
+
- **Lazy lookup** - `t('.title')` in views resolves based on the view path
|
|
135
|
+
- **Dynamic keys** - `t("users.#{action}.title")` with interpolated segments
|
|
136
|
+
- **Model translations** - `User.human_attribute_name(:email)` and `User.model_name.human`
|
|
137
|
+
- **Pluralization siblings** - If `one:` is used, `other:` may appear unused
|
|
138
|
+
|
|
139
|
+
Review results carefully and use the exclusion file for known false positives.
|
|
140
|
+
|
|
141
|
+
## Configuration File
|
|
142
|
+
|
|
143
|
+
Create a `keela.yml` or `.keela.yml` in your project root:
|
|
144
|
+
|
|
145
|
+
```yaml
|
|
146
|
+
# keela.yml
|
|
147
|
+
extensions:
|
|
148
|
+
- rb
|
|
149
|
+
- haml
|
|
150
|
+
- erb
|
|
151
|
+
|
|
152
|
+
exclude_patterns:
|
|
153
|
+
- "vendor/**/*"
|
|
154
|
+
- "tmp/**/*"
|
|
155
|
+
|
|
156
|
+
include_patterns:
|
|
157
|
+
- "engines/**/*.%<ext>s"
|
|
158
|
+
|
|
159
|
+
excluded_path: ".keela_excluded.yml"
|
|
160
|
+
baseline_path: ".keela_baseline.yml"
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Keela automatically loads `keela.yml` or `.keela.yml` from the current directory. Use `--config` to specify a different path.
|
|
164
|
+
|
|
165
|
+
### Customizing Which Files to Scan
|
|
166
|
+
|
|
167
|
+
There are two approaches:
|
|
168
|
+
|
|
169
|
+
**1. Tweak the defaults** with `--include` and `--exclude` (or `include_patterns`/`exclude_patterns` in config):
|
|
170
|
+
|
|
171
|
+
```bash
|
|
172
|
+
# Add engines/ to the default app/, lib/, config/ directories
|
|
173
|
+
keela --include 'engines/**/*.rb'
|
|
174
|
+
|
|
175
|
+
# Exclude vendor files from scanning
|
|
176
|
+
keela --exclude 'vendor/**/*'
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
**2. Full control** with `directory_patterns` - replaces the defaults entirely:
|
|
180
|
+
|
|
181
|
+
```yaml
|
|
182
|
+
# keela.yml - scan ONLY these directories
|
|
183
|
+
directory_patterns:
|
|
184
|
+
- "src/**/*.%<ext>s"
|
|
185
|
+
- "custom/**/*.%<ext>s"
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
Use `directory_patterns` when you need complete control. Use `--include`/`--exclude` when you just want to tweak the defaults.
|
|
189
|
+
|
|
190
|
+
**Note:** Mixing both approaches raises a `ConfigurationError`. Choose one or the other.
|
|
191
|
+
|
|
192
|
+
**Available options:**
|
|
193
|
+
|
|
194
|
+
| Key | Description | Default |
|
|
195
|
+
|-----|-------------|---------|
|
|
196
|
+
| `directory_patterns` | Glob patterns for files to scan (replaces defaults) | `app/`, `lib/`, `config/` |
|
|
197
|
+
| `extensions` | File extensions to scan | `rb`, `haml`, `erb` |
|
|
198
|
+
| `include_patterns` | Additional patterns to scan (added to defaults) | `[]` |
|
|
199
|
+
| `exclude_patterns` | Patterns for files to exclude | `[]` |
|
|
200
|
+
| `excluded_path` | Path to YAML file of excluded items | `nil` |
|
|
201
|
+
| `baseline_path` | Path to baseline YAML file | `.keela_baseline.yml` |
|
|
202
|
+
| `required_directory` | Directory that must exist for scanning to proceed | `nil` |
|
|
203
|
+
|
|
98
204
|
## CI Integration
|
|
99
205
|
|
|
100
206
|
Keela is designed for CI pipelines. Add it to catch dead code before it merges:
|
|
@@ -120,6 +226,37 @@ The workflow:
|
|
|
120
226
|
2. **CI runs**: `keela` compares against baseline, fails on new dead code
|
|
121
227
|
3. **After cleanup**: Run `keela --update-baseline` to update the baseline
|
|
122
228
|
|
|
229
|
+
### JSON Output
|
|
230
|
+
|
|
231
|
+
Use `--format json` for machine-readable output:
|
|
232
|
+
|
|
233
|
+
```bash
|
|
234
|
+
keela --format json --report
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
```json
|
|
238
|
+
{
|
|
239
|
+
"strategies": ["methods", "scopes"],
|
|
240
|
+
"unused": {
|
|
241
|
+
"methods": {
|
|
242
|
+
"app/models/user.rb": ["unused_method", "old_helper"]
|
|
243
|
+
},
|
|
244
|
+
"scopes": {
|
|
245
|
+
"app/models/post.rb": ["inactive"]
|
|
246
|
+
}
|
|
247
|
+
},
|
|
248
|
+
"summary": {
|
|
249
|
+
"total": 3,
|
|
250
|
+
"by_strategy": {
|
|
251
|
+
"methods": 2,
|
|
252
|
+
"scopes": 1
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
This is useful for integrating with other tools, generating reports, or processing results programmatically.
|
|
259
|
+
|
|
123
260
|
## Exclusion File
|
|
124
261
|
|
|
125
262
|
Some code appears unused but is actually called dynamically. Exclude it:
|
|
@@ -151,6 +288,8 @@ Keela.configure do |config|
|
|
|
151
288
|
app/**/*.%<ext>s
|
|
152
289
|
lib/**/*.%<ext>s
|
|
153
290
|
]
|
|
291
|
+
config.include_patterns = %w[engines/**/*.%<ext>s]
|
|
292
|
+
config.exclude_patterns = %w[vendor/**/* tmp/**/*]
|
|
154
293
|
config.excluded_path = '.keela_excluded.yml'
|
|
155
294
|
config.baseline_path = '.keela_baseline.yml'
|
|
156
295
|
end
|
|
@@ -200,7 +339,7 @@ scanner.run(force_report: true)
|
|
|
200
339
|
|
|
201
340
|
## About the Name
|
|
202
341
|
|
|
203
|
-
|
|
342
|
+
Keela was a famous English Springer Spaniel known as the "CSI dog." She could detect microscopic traces of blood that other forensic methods missed, and worked on many high-profile cases. Like her namesake, this gem finds the unused code that other tools miss.
|
|
204
343
|
|
|
205
344
|
## License
|
|
206
345
|
|
data/exe/keela
CHANGED
|
@@ -7,7 +7,9 @@ require "keela"
|
|
|
7
7
|
options = {
|
|
8
8
|
type: :all,
|
|
9
9
|
force_report: false,
|
|
10
|
-
update_baseline: false
|
|
10
|
+
update_baseline: false,
|
|
11
|
+
config_path: nil,
|
|
12
|
+
format: :text
|
|
11
13
|
}
|
|
12
14
|
|
|
13
15
|
OptionParser.new do |opts|
|
|
@@ -25,8 +27,8 @@ OptionParser.new do |opts|
|
|
|
25
27
|
opts.separator ""
|
|
26
28
|
opts.separator "Options:"
|
|
27
29
|
|
|
28
|
-
opts.on("--type
|
|
29
|
-
options[:
|
|
30
|
+
opts.on("--type TYPES", "Comma-separated types to detect: methods, scopes, constants, delegations, attributes, i18n_keys, all (default: all)") do |types|
|
|
31
|
+
options[:types] = types.split(",").map { |t| t.strip.to_sym }
|
|
30
32
|
end
|
|
31
33
|
|
|
32
34
|
opts.on("--report", "-r", "Force report mode: show all unused code (ignore baseline)") do
|
|
@@ -37,6 +39,10 @@ OptionParser.new do |opts|
|
|
|
37
39
|
options[:update_baseline] = true
|
|
38
40
|
end
|
|
39
41
|
|
|
42
|
+
opts.on("--format FORMAT", %i[text json], "Output format: text (default) or json") do |format|
|
|
43
|
+
options[:format] = format
|
|
44
|
+
end
|
|
45
|
+
|
|
40
46
|
opts.on("--excluded PATH", "Path to YAML file of excluded items") do |path|
|
|
41
47
|
Keela.configuration.excluded_path = path
|
|
42
48
|
end
|
|
@@ -49,6 +55,18 @@ OptionParser.new do |opts|
|
|
|
49
55
|
Keela.configuration.extensions = exts.split(",").map(&:strip)
|
|
50
56
|
end
|
|
51
57
|
|
|
58
|
+
opts.on("--exclude PATTERN", "Glob pattern for files to exclude (can be used multiple times)") do |pattern|
|
|
59
|
+
Keela.configuration.exclude_patterns << pattern
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
opts.on("--include PATTERN", "Additional glob pattern to scan (can be used multiple times)") do |pattern|
|
|
63
|
+
Keela.configuration.include_patterns << pattern
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
opts.on("--config PATH", "-c", "Path to config file (default: keela.yml or .keela.yml)") do |path|
|
|
67
|
+
options[:config_path] = path
|
|
68
|
+
end
|
|
69
|
+
|
|
52
70
|
opts.on("--version", "-v", "Show version") do
|
|
53
71
|
puts "keela #{Keela::VERSION}"
|
|
54
72
|
exit
|
|
@@ -60,27 +78,97 @@ OptionParser.new do |opts|
|
|
|
60
78
|
end
|
|
61
79
|
end.parse!
|
|
62
80
|
|
|
81
|
+
# Load config file (keela.yml or .keela.yml) if present
|
|
82
|
+
# CLI options override config file settings
|
|
83
|
+
Keela::ConfigFile.load(path: options[:config_path])
|
|
84
|
+
|
|
63
85
|
# Set default baseline path if not specified
|
|
64
86
|
Keela.configuration.baseline_path ||= ".keela_baseline.yml"
|
|
65
87
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
88
|
+
STRATEGY_MAP = {
|
|
89
|
+
methods: Keela::Strategies::Methods,
|
|
90
|
+
scopes: Keela::Strategies::Scopes,
|
|
91
|
+
constants: Keela::Strategies::Constants,
|
|
92
|
+
delegations: Keela::Strategies::Delegations,
|
|
93
|
+
attributes: Keela::Strategies::Attributes,
|
|
94
|
+
i18n_keys: Keela::Strategies::I18nKeys
|
|
95
|
+
}.freeze
|
|
96
|
+
|
|
97
|
+
# Default strategies for "all" (excludes i18n_keys which requires different config)
|
|
98
|
+
DEFAULT_STRATEGIES = %i[methods scopes constants delegations attributes].freeze
|
|
99
|
+
|
|
100
|
+
VALID_TYPES = (STRATEGY_MAP.keys + [:all]).freeze
|
|
101
|
+
|
|
102
|
+
def build_strategies(types)
|
|
103
|
+
types = types || [:all]
|
|
104
|
+
|
|
105
|
+
# Validate types
|
|
106
|
+
invalid = types - VALID_TYPES
|
|
107
|
+
unless invalid.empty?
|
|
108
|
+
warn "Error: Invalid type(s): #{invalid.join(', ')}"
|
|
109
|
+
warn "Valid types: #{VALID_TYPES.join(', ')}"
|
|
110
|
+
exit 1
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Expand :all to default strategies
|
|
114
|
+
expanded = types.flat_map { |t| t == :all ? DEFAULT_STRATEGIES : t }.uniq
|
|
115
|
+
|
|
116
|
+
expanded.map { |t| STRATEGY_MAP[t].new }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
strategies = build_strategies(options[:types])
|
|
120
|
+
|
|
121
|
+
# Share a single baseline across all strategies
|
|
122
|
+
baseline = Keela::Baseline.new(Keela.configuration.baseline_path)
|
|
74
123
|
|
|
75
124
|
success = true
|
|
125
|
+
results = {}
|
|
126
|
+
|
|
127
|
+
json_mode = options[:format] == :json
|
|
76
128
|
|
|
77
129
|
strategies.each_with_index do |strategy, index|
|
|
78
|
-
|
|
130
|
+
unless json_mode
|
|
131
|
+
puts Rainbow("=== Sniffing for unused #{strategy.name} ===").cyan.bright if strategies.size > 1
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
scanner = Keela::Scanner.new(strategy: strategy, baseline: baseline)
|
|
135
|
+
success &&= scanner.run(
|
|
136
|
+
force_report: options[:force_report],
|
|
137
|
+
update_baseline: options[:update_baseline],
|
|
138
|
+
silent: json_mode
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
# Collect results for JSON output
|
|
142
|
+
results[strategy.name] = scanner.unused_collection.transform_values(&:to_a)
|
|
143
|
+
|
|
144
|
+
unless json_mode
|
|
145
|
+
puts if strategies.size > 1 && index < strategies.size - 1
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Save baseline after all strategies have run
|
|
150
|
+
if options[:update_baseline]
|
|
151
|
+
baseline.save
|
|
152
|
+
puts Rainbow("Updated #{baseline.path}").green.bright unless json_mode
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Output JSON if requested
|
|
156
|
+
if json_mode
|
|
157
|
+
require "json"
|
|
158
|
+
|
|
159
|
+
total = results.values.flat_map(&:values).flatten.size
|
|
160
|
+
by_strategy = results.transform_values { |files| files.values.flatten.size }
|
|
79
161
|
|
|
80
|
-
|
|
81
|
-
|
|
162
|
+
output = {
|
|
163
|
+
strategies: strategies.map(&:name),
|
|
164
|
+
unused: results.reject { |_, v| v.empty? },
|
|
165
|
+
summary: {
|
|
166
|
+
total: total,
|
|
167
|
+
by_strategy: by_strategy.reject { |_, v| v.zero? }
|
|
168
|
+
}
|
|
169
|
+
}
|
|
82
170
|
|
|
83
|
-
puts
|
|
171
|
+
puts JSON.pretty_generate(output)
|
|
84
172
|
end
|
|
85
173
|
|
|
86
174
|
exit(success ? 0 : 1)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module Keela
|
|
6
|
+
# Handles reading and writing baseline files with multiple strategy sections.
|
|
7
|
+
#
|
|
8
|
+
# File format:
|
|
9
|
+
# methods:
|
|
10
|
+
# app/models/user.rb:
|
|
11
|
+
# - unused_method
|
|
12
|
+
# scopes:
|
|
13
|
+
# app/models/user.rb:
|
|
14
|
+
# - unused_scope
|
|
15
|
+
#
|
|
16
|
+
class Baseline
|
|
17
|
+
attr_reader :path
|
|
18
|
+
|
|
19
|
+
def initialize(path)
|
|
20
|
+
@path = path
|
|
21
|
+
@data = nil
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def exists?
|
|
25
|
+
path && File.exist?(path)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def load
|
|
29
|
+
return {} unless exists?
|
|
30
|
+
|
|
31
|
+
@data = YAML.load_file(path) || {}
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def data
|
|
35
|
+
@data ||= load
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def get(strategy_name)
|
|
39
|
+
data[strategy_name] || {}
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def set(strategy_name, unused_collection)
|
|
43
|
+
# Ensure we've loaded existing data before setting
|
|
44
|
+
load if @data.nil? && exists?
|
|
45
|
+
@data ||= {}
|
|
46
|
+
@data[strategy_name] = unused_collection.sort.to_h
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def save
|
|
50
|
+
return unless path
|
|
51
|
+
|
|
52
|
+
header = <<~HEADER
|
|
53
|
+
# Unused code identified by Keela.
|
|
54
|
+
# These are potential targets for removal.
|
|
55
|
+
#
|
|
56
|
+
# If an item listed here is actually in use,
|
|
57
|
+
# remove it from this file and add it to your excluded file.
|
|
58
|
+
#
|
|
59
|
+
HEADER
|
|
60
|
+
|
|
61
|
+
sorted_data = data.sort.to_h
|
|
62
|
+
yaml_content = if sorted_data.empty? || sorted_data.values.all?(&:empty?)
|
|
63
|
+
"#{header}---\n{}\n"
|
|
64
|
+
else
|
|
65
|
+
"#{header}#{format_yaml(sorted_data)}"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
File.write(path, yaml_content)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def format_yaml(hash)
|
|
74
|
+
indent_yaml_list_items(hash.to_yaml)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Indents YAML list items that are not already indented.
|
|
78
|
+
def indent_yaml_list_items(yaml_string)
|
|
79
|
+
yaml_string.gsub(/\n-(\s+\S)/, "\n -\\1")
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module Keela
|
|
6
|
+
# Loads configuration from a YAML file.
|
|
7
|
+
#
|
|
8
|
+
# Looks for config files in this order:
|
|
9
|
+
# 1. keela.yml
|
|
10
|
+
# 2. .keela.yml
|
|
11
|
+
#
|
|
12
|
+
# Supported keys:
|
|
13
|
+
# - extensions: Array of file extensions to scan
|
|
14
|
+
# - directory_patterns: Array of glob patterns for directories to scan
|
|
15
|
+
# - exclude_patterns: Array of glob patterns for files to exclude
|
|
16
|
+
# - excluded_path: Path to YAML file of excluded items
|
|
17
|
+
# - baseline_path: Path to baseline YAML file
|
|
18
|
+
# - required_directory: Directory that must exist for scanning to proceed
|
|
19
|
+
#
|
|
20
|
+
# Example:
|
|
21
|
+
# # keela.yml
|
|
22
|
+
# directory_patterns:
|
|
23
|
+
# - "app/**/*.%<ext>s"
|
|
24
|
+
# - "lib/**/*.%<ext>s"
|
|
25
|
+
# - "ee/app/**/*.%<ext>s"
|
|
26
|
+
# - "ee/lib/**/*.%<ext>s"
|
|
27
|
+
# extensions:
|
|
28
|
+
# - rb
|
|
29
|
+
# - haml
|
|
30
|
+
# - erb
|
|
31
|
+
#
|
|
32
|
+
module ConfigFile
|
|
33
|
+
CONFIG_FILENAMES = %w[keela.yml .keela.yml].freeze
|
|
34
|
+
|
|
35
|
+
ALLOWED_KEYS = %w[
|
|
36
|
+
extensions
|
|
37
|
+
directory_patterns
|
|
38
|
+
include_patterns
|
|
39
|
+
exclude_patterns
|
|
40
|
+
excluded_path
|
|
41
|
+
baseline_path
|
|
42
|
+
required_directory
|
|
43
|
+
].freeze
|
|
44
|
+
|
|
45
|
+
class << self
|
|
46
|
+
# Load configuration from a YAML file.
|
|
47
|
+
#
|
|
48
|
+
# @param path [String, nil] Optional path to config file. If nil, searches
|
|
49
|
+
# for keela.yml or .keela.yml in the current directory.
|
|
50
|
+
# @return [Boolean] true if a config file was loaded, false otherwise
|
|
51
|
+
#
|
|
52
|
+
def load(path: nil)
|
|
53
|
+
config_path = path || find_config_file
|
|
54
|
+
return false unless config_path && File.exist?(config_path)
|
|
55
|
+
|
|
56
|
+
config = YAML.load_file(config_path) || {}
|
|
57
|
+
apply_config(config)
|
|
58
|
+
true
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def find_config_file
|
|
64
|
+
CONFIG_FILENAMES.find { |filename| File.exist?(filename) }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def apply_config(config)
|
|
68
|
+
configuration = Keela.configuration
|
|
69
|
+
|
|
70
|
+
ALLOWED_KEYS.each do |key|
|
|
71
|
+
next unless config.key?(key)
|
|
72
|
+
|
|
73
|
+
value = config[key]
|
|
74
|
+
configuration.public_send("#{key}=", value)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
data/lib/keela/configuration.rb
CHANGED
|
@@ -20,6 +20,12 @@ module Keela
|
|
|
20
20
|
# Whether to show progress during scanning
|
|
21
21
|
attr_accessor :show_progress
|
|
22
22
|
|
|
23
|
+
# Glob patterns for files to exclude from scanning
|
|
24
|
+
attr_accessor :exclude_patterns
|
|
25
|
+
|
|
26
|
+
# Additional directory patterns to include (added to directory_patterns)
|
|
27
|
+
attr_accessor :include_patterns
|
|
28
|
+
|
|
23
29
|
def initialize
|
|
24
30
|
@extensions = %w[rb haml erb].freeze
|
|
25
31
|
@directory_patterns = %w[
|
|
@@ -31,6 +37,8 @@ module Keela
|
|
|
31
37
|
@baseline_path = nil
|
|
32
38
|
@required_directory = nil
|
|
33
39
|
@show_progress = true
|
|
40
|
+
@exclude_patterns = []
|
|
41
|
+
@include_patterns = []
|
|
34
42
|
end
|
|
35
43
|
end
|
|
36
44
|
end
|
data/lib/keela/scanner.rb
CHANGED
|
@@ -5,20 +5,23 @@ require "yaml"
|
|
|
5
5
|
|
|
6
6
|
module Keela
|
|
7
7
|
class Scanner
|
|
8
|
-
attr_reader :strategy, :configuration, :source_files, :unused_collection, :new_unused, :removed
|
|
8
|
+
attr_reader :strategy, :configuration, :baseline, :source_files, :unused_collection, :new_unused, :removed
|
|
9
9
|
|
|
10
|
-
def initialize(strategy:, configuration: Keela.configuration)
|
|
10
|
+
def initialize(strategy:, configuration: Keela.configuration, baseline: nil)
|
|
11
11
|
@strategy = strategy
|
|
12
12
|
@configuration = configuration
|
|
13
|
+
@baseline = baseline || Baseline.new(configuration.baseline_path)
|
|
13
14
|
@source_files = {}
|
|
14
15
|
@unused_collection = Hash.new { |hash, key| hash[key] = [] }
|
|
15
16
|
@new_unused = []
|
|
16
17
|
@removed = []
|
|
17
18
|
end
|
|
18
19
|
|
|
19
|
-
def run(force_report: false, update_baseline: false)
|
|
20
|
+
def run(force_report: false, update_baseline: false, silent: false)
|
|
20
21
|
return true unless should_run?
|
|
21
22
|
|
|
23
|
+
validate_configuration!
|
|
24
|
+
|
|
22
25
|
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
23
26
|
|
|
24
27
|
load_source_files
|
|
@@ -26,60 +29,96 @@ module Keela
|
|
|
26
29
|
definitions = filter_excluded(definitions)
|
|
27
30
|
|
|
28
31
|
# Determine mode: report if forced, updating baseline, or no baseline exists
|
|
29
|
-
report_mode = force_report || update_baseline || !
|
|
32
|
+
report_mode = force_report || update_baseline || !baseline.exists?
|
|
30
33
|
|
|
31
|
-
find_unused(definitions, show_progress: report_mode)
|
|
34
|
+
find_unused(definitions, show_progress: report_mode && !silent)
|
|
32
35
|
|
|
33
36
|
if report_mode
|
|
34
37
|
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
|
|
35
|
-
reporter.print_full_report(unused_collection, elapsed)
|
|
36
|
-
|
|
38
|
+
reporter.print_full_report(unused_collection, elapsed) unless silent
|
|
39
|
+
if update_baseline
|
|
40
|
+
baseline.set(strategy.name, unused_collection)
|
|
41
|
+
# Note: caller is responsible for calling baseline.save after all strategies run
|
|
42
|
+
end
|
|
37
43
|
return true
|
|
38
44
|
end
|
|
39
45
|
|
|
40
46
|
# Baseline mode: compare against known unused code
|
|
41
47
|
compare_with_baseline
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
+
unless silent
|
|
49
|
+
reporter.print_diff_report(
|
|
50
|
+
new_unused,
|
|
51
|
+
removed,
|
|
52
|
+
excluded_path: configuration.excluded_path || "excluded.yml",
|
|
53
|
+
baseline_path: baseline.path
|
|
54
|
+
)
|
|
55
|
+
end
|
|
48
56
|
|
|
49
57
|
new_unused.empty? && removed.empty?
|
|
50
58
|
end
|
|
51
59
|
|
|
52
60
|
def file_globs
|
|
61
|
+
all_patterns = configuration.directory_patterns + configuration.include_patterns
|
|
62
|
+
|
|
53
63
|
configuration.extensions.flat_map do |ext|
|
|
54
|
-
|
|
64
|
+
all_patterns.map { |pattern| format(pattern, ext: ext) }
|
|
55
65
|
end
|
|
56
66
|
end
|
|
57
67
|
|
|
58
68
|
private
|
|
59
69
|
|
|
60
|
-
def baseline_exists?
|
|
61
|
-
configuration.baseline_path && File.exist?(configuration.baseline_path)
|
|
62
|
-
end
|
|
63
|
-
|
|
64
70
|
def should_run?
|
|
65
71
|
return true unless configuration.required_directory
|
|
66
72
|
|
|
67
73
|
Dir.exist?(configuration.required_directory)
|
|
68
74
|
end
|
|
69
75
|
|
|
76
|
+
def validate_configuration!
|
|
77
|
+
custom_directory_patterns = configuration.directory_patterns != default_directory_patterns
|
|
78
|
+
has_include_patterns = !configuration.include_patterns.empty?
|
|
79
|
+
has_exclude_patterns = !configuration.exclude_patterns.empty?
|
|
80
|
+
|
|
81
|
+
return unless custom_directory_patterns && (has_include_patterns || has_exclude_patterns)
|
|
82
|
+
|
|
83
|
+
raise ConfigurationError,
|
|
84
|
+
"Cannot use include_patterns or exclude_patterns with custom directory_patterns. " \
|
|
85
|
+
"Use directory_patterns for full control, OR use include/exclude to tweak the defaults."
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def default_directory_patterns
|
|
89
|
+
Keela::Configuration.new.directory_patterns
|
|
90
|
+
end
|
|
91
|
+
|
|
70
92
|
def reporter
|
|
71
93
|
@reporter ||= Reporter.new(strategy.name)
|
|
72
94
|
end
|
|
73
95
|
|
|
74
96
|
def load_source_files
|
|
75
97
|
Dir.glob(file_globs).each do |filename|
|
|
98
|
+
next if excluded_file?(filename)
|
|
99
|
+
|
|
76
100
|
@source_files[filename] = File.readlines(filename)
|
|
77
101
|
end
|
|
78
102
|
end
|
|
79
103
|
|
|
104
|
+
def excluded_file?(filename)
|
|
105
|
+
return false if configuration.exclude_patterns.empty?
|
|
106
|
+
|
|
107
|
+
configuration.exclude_patterns.any? do |pattern|
|
|
108
|
+
File.fnmatch?(pattern, filename, File::FNM_PATHNAME | File::FNM_EXTGLOB)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
80
112
|
def find_definitions
|
|
81
113
|
source_files.keys.grep(strategy.definition_file_pattern).flat_map do |filename|
|
|
82
|
-
source_files[filename]
|
|
114
|
+
lines = source_files[filename]
|
|
115
|
+
|
|
116
|
+
# Allow strategies to override file parsing (e.g., for YAML files)
|
|
117
|
+
custom_definitions = strategy.extract_definitions_from_file(filename, lines)
|
|
118
|
+
next custom_definitions if custom_definitions
|
|
119
|
+
|
|
120
|
+
# Default: line-by-line parsing
|
|
121
|
+
lines.flat_map do |line|
|
|
83
122
|
next [] if strategy.skip_comments? && line.strip.start_with?("#")
|
|
84
123
|
|
|
85
124
|
name = strategy.extract_definition(line)
|
|
@@ -116,36 +155,13 @@ module Keela
|
|
|
116
155
|
end
|
|
117
156
|
|
|
118
157
|
def compare_with_baseline
|
|
119
|
-
|
|
120
|
-
baseline_items =
|
|
158
|
+
baseline_for_strategy = baseline.get(strategy.name)
|
|
159
|
+
baseline_items = baseline_for_strategy.flat_map { |f, names| [f].product(names) }
|
|
121
160
|
|
|
122
161
|
current_items = unused_collection.flat_map { |f, names| [f].product(names) }
|
|
123
162
|
|
|
124
163
|
@new_unused = current_items - baseline_items
|
|
125
164
|
@removed = baseline_items - current_items
|
|
126
165
|
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
166
|
end
|
|
151
167
|
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Keela
|
|
4
|
+
module Strategies
|
|
5
|
+
class Attributes < Strategy
|
|
6
|
+
def name
|
|
7
|
+
"attributes"
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def definition_file_pattern
|
|
11
|
+
# Match app/ and lib/ directories, but exclude spec/ and test/
|
|
12
|
+
%r{(?:^|/)(?:ee/)?(?:app|lib)/}
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def extract_definition(line)
|
|
16
|
+
# Match attr_accessor, attr_reader, attr_writer declarations
|
|
17
|
+
# But NOT other attr_* DSLs like attr_encrypted, attr_spammable, etc.
|
|
18
|
+
return nil unless line =~ /^\s*attr_(accessor|reader|writer)\s+/
|
|
19
|
+
|
|
20
|
+
# Extract the first symbol after the attr_* declaration
|
|
21
|
+
return nil unless line =~ /attr_(?:accessor|reader|writer)\s+:(\w+)/
|
|
22
|
+
|
|
23
|
+
Regexp.last_match(1)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def usage_regex(name)
|
|
27
|
+
# Match usage of the attribute:
|
|
28
|
+
# - Getter: obj.name, name (without receiver)
|
|
29
|
+
# - Setter: obj.name = value, self.name = value
|
|
30
|
+
# - Instance variable: @name (direct access)
|
|
31
|
+
#
|
|
32
|
+
# Exclude:
|
|
33
|
+
# - Symbol notation (:name)
|
|
34
|
+
# - The attr_* definition itself
|
|
35
|
+
# - Partial word matches (username shouldn't match name)
|
|
36
|
+
/(?:(?<!:)(?<!attr_accessor\s)(?<!attr_reader\s)(?<!attr_writer\s)(?<![a-z_])#{Regexp.quote(name)}(?!\w)|@#{Regexp.quote(name)}(?!\w))/
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def skip_comments?
|
|
40
|
+
true
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Keela
|
|
4
|
+
module Strategies
|
|
5
|
+
class Constants < Strategy
|
|
6
|
+
def name
|
|
7
|
+
"constants"
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def definition_file_pattern
|
|
11
|
+
# Match app/ and lib/ directories, but exclude spec/ and test/
|
|
12
|
+
%r{(?:^|/)(?:ee/)?(?:app|lib)/}
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def extract_definition(line)
|
|
16
|
+
# Match constant definitions like:
|
|
17
|
+
# MAX_SIZE = 100
|
|
18
|
+
# ALLOWED_TYPES = %w[foo bar].freeze
|
|
19
|
+
# OPTIONS = { foo: 1 }.freeze
|
|
20
|
+
#
|
|
21
|
+
# Must start with uppercase letter followed by uppercase letters,
|
|
22
|
+
# digits, or underscores, then = (with optional whitespace)
|
|
23
|
+
#
|
|
24
|
+
# Avoid matching:
|
|
25
|
+
# - Comparisons: MAX_SIZE == 100
|
|
26
|
+
# - Namespaced access: Foo::BAR
|
|
27
|
+
# - Class/module definitions
|
|
28
|
+
|
|
29
|
+
# First check it's not a comparison
|
|
30
|
+
return nil if line =~ /[!=]=/
|
|
31
|
+
|
|
32
|
+
# Match the constant definition pattern
|
|
33
|
+
return nil unless line =~ /^\s*([A-Z][A-Z0-9_]*)\s*=/
|
|
34
|
+
|
|
35
|
+
Regexp.last_match(1)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def usage_regex(name)
|
|
39
|
+
# Match usage of the constant, but not its definition
|
|
40
|
+
# Uses negative lookbehind to avoid matching when preceded by
|
|
41
|
+
# uppercase letters/digits/underscores (partial match)
|
|
42
|
+
# Uses negative lookahead to avoid:
|
|
43
|
+
# - partial matches (followed by uppercase letters/digits/underscores)
|
|
44
|
+
# - definitions (followed by optional whitespace then =, but not ==)
|
|
45
|
+
/(?<![A-Z0-9_])#{Regexp.quote(name)}(?![A-Z0-9_])(?!\s*=(?!=))/
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def skip_comments?
|
|
49
|
+
true
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Keela
|
|
4
|
+
module Strategies
|
|
5
|
+
class Delegations < Strategy
|
|
6
|
+
def name
|
|
7
|
+
"delegations"
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def definition_file_pattern
|
|
11
|
+
# Match app/models/ directories (including concerns), but exclude spec/test
|
|
12
|
+
%r{(?:^|/)(?:ee/)?app/models/}
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def extract_definition(line)
|
|
16
|
+
# Match delegate declarations like:
|
|
17
|
+
# delegate :name, to: :user
|
|
18
|
+
# delegate :name, :email, to: :user
|
|
19
|
+
# delegate :name, to: :user, prefix: true
|
|
20
|
+
# delegate :name, to: :user, prefix: :owner
|
|
21
|
+
# delegate :name, to: :user, allow_nil: true
|
|
22
|
+
return nil unless line =~ /^\s*delegate\s+/
|
|
23
|
+
|
|
24
|
+
# Extract the target for prefix detection
|
|
25
|
+
target = line[/to:\s*:[@]?(\w+)/, 1]
|
|
26
|
+
|
|
27
|
+
# Check for prefix option
|
|
28
|
+
prefix = if line =~ /prefix:\s*:(\w+)/
|
|
29
|
+
Regexp.last_match(1)
|
|
30
|
+
elsif line =~ /prefix:\s*true/
|
|
31
|
+
target
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Extract all method symbols from the delegate call
|
|
35
|
+
# Match :symbol patterns before 'to:'
|
|
36
|
+
# Include ? and ! for predicate and bang methods
|
|
37
|
+
delegate_part = line.split(/,\s*to:/)[0]
|
|
38
|
+
methods = delegate_part.scan(/:(\w+[?!]?)/).flatten
|
|
39
|
+
|
|
40
|
+
return nil if methods.empty?
|
|
41
|
+
|
|
42
|
+
# Apply prefix if present
|
|
43
|
+
if prefix
|
|
44
|
+
methods = methods.map { |m| "#{prefix}_#{m}" }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Return single string for single method (scanner expects this)
|
|
48
|
+
# For multiple methods, return first one only
|
|
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
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def usage_regex(name)
|
|
56
|
+
# Match usage of the delegated method, but not the delegate declaration
|
|
57
|
+
# Uses negative lookbehind to avoid matching:
|
|
58
|
+
# - Symbol notation (:name)
|
|
59
|
+
# - Part of delegate declaration
|
|
60
|
+
# Uses word boundary to avoid partial matches
|
|
61
|
+
# Note: Regexp.quote handles ? and ! in method names
|
|
62
|
+
/(?<!:)(?<!delegate\s)(?<![a-z_])#{Regexp.quote(name)}(?!\w)/i
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def skip_comments?
|
|
66
|
+
true
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module Keela
|
|
6
|
+
module Strategies
|
|
7
|
+
# Detects unused I18n translation keys in locale files.
|
|
8
|
+
#
|
|
9
|
+
# Definitions are extracted from YAML locale files (config/locales/*.yml)
|
|
10
|
+
# and flattened to dot notation (e.g., "users.show.title").
|
|
11
|
+
#
|
|
12
|
+
# Usage is detected by searching for:
|
|
13
|
+
# - I18n.t("key") or I18n.t('key')
|
|
14
|
+
# - t("key") or t('key')
|
|
15
|
+
# - t(:key)
|
|
16
|
+
# - .human_attribute_name(:attr)
|
|
17
|
+
#
|
|
18
|
+
# Note: Lazy lookup (t('.title') in views) is not yet supported.
|
|
19
|
+
#
|
|
20
|
+
class I18nKeys < Strategy
|
|
21
|
+
def name
|
|
22
|
+
"i18n_keys"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def definition_file_pattern
|
|
26
|
+
# Match locale YAML files
|
|
27
|
+
%r{config/locales/.*\.ya?ml$}
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Override: I18n keys need special YAML parsing, not line-by-line
|
|
31
|
+
def extract_definitions_from_file(filepath, _lines)
|
|
32
|
+
return [] unless File.exist?(filepath)
|
|
33
|
+
|
|
34
|
+
content = YAML.load_file(filepath, permitted_classes: [Symbol]) || {}
|
|
35
|
+
flatten_keys(content).map do |key|
|
|
36
|
+
# Remove the locale prefix (e.g., "en.users.show" -> "users.show")
|
|
37
|
+
key_without_locale = key.sub(/^[a-z]{2}(-[A-Z]{2})?\./, "")
|
|
38
|
+
{ name: key_without_locale, file: filepath }
|
|
39
|
+
end
|
|
40
|
+
rescue Psych::SyntaxError => e
|
|
41
|
+
warn "Warning: Could not parse #{filepath}: #{e.message}"
|
|
42
|
+
[]
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def extract_definition(_line)
|
|
46
|
+
# Not used - we override extract_definitions_from_file instead
|
|
47
|
+
nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def usage_regex(name)
|
|
51
|
+
# Match various I18n lookup patterns:
|
|
52
|
+
# I18n.t("users.show.title")
|
|
53
|
+
# I18n.t('users.show.title')
|
|
54
|
+
# t("users.show.title")
|
|
55
|
+
# t('users.show.title')
|
|
56
|
+
# t(:users_show_title) - symbol form (underscored)
|
|
57
|
+
#
|
|
58
|
+
# Also match partial keys for lazy lookup support:
|
|
59
|
+
# t(".title") in a view could match "users.show.title"
|
|
60
|
+
quoted_name = Regexp.quote(name)
|
|
61
|
+
|
|
62
|
+
# Build pattern that matches the key in quotes or as a symbol
|
|
63
|
+
/(?:I18n\.)?t\s*\(\s*["':]+#{quoted_name}["']?\s*[,)]/
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def skip_comments?
|
|
67
|
+
true
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
# Flatten nested hash to dot-notation keys
|
|
73
|
+
# { "en" => { "users" => { "title" => "..." } } }
|
|
74
|
+
# becomes ["en.users.title"]
|
|
75
|
+
def flatten_keys(hash, prefix = nil)
|
|
76
|
+
hash.flat_map do |key, value|
|
|
77
|
+
full_key = [prefix, key].compact.join(".")
|
|
78
|
+
case value
|
|
79
|
+
when Hash
|
|
80
|
+
flatten_keys(value, full_key)
|
|
81
|
+
else
|
|
82
|
+
[full_key]
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
data/lib/keela/strategy.rb
CHANGED
|
@@ -32,5 +32,12 @@ module Keela
|
|
|
32
32
|
def skip_comments?
|
|
33
33
|
false
|
|
34
34
|
end
|
|
35
|
+
|
|
36
|
+
# Override this method for strategies that need custom file parsing
|
|
37
|
+
# (e.g., YAML files for I18n keys).
|
|
38
|
+
# Returns an array of { name:, file: } hashes, or nil to use default line-by-line parsing.
|
|
39
|
+
def extract_definitions_from_file(_filepath, _lines)
|
|
40
|
+
nil
|
|
41
|
+
end
|
|
35
42
|
end
|
|
36
43
|
end
|
data/lib/keela/version.rb
CHANGED
data/lib/keela.rb
CHANGED
|
@@ -2,13 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "keela/version"
|
|
4
4
|
require_relative "keela/configuration"
|
|
5
|
+
require_relative "keela/config_file"
|
|
5
6
|
require_relative "keela/strategy"
|
|
6
7
|
require_relative "keela/strategies/methods"
|
|
7
8
|
require_relative "keela/strategies/scopes"
|
|
9
|
+
require_relative "keela/strategies/constants"
|
|
10
|
+
require_relative "keela/strategies/delegations"
|
|
11
|
+
require_relative "keela/strategies/attributes"
|
|
12
|
+
require_relative "keela/strategies/i18n_keys"
|
|
8
13
|
require_relative "keela/reporter"
|
|
14
|
+
require_relative "keela/baseline"
|
|
9
15
|
require_relative "keela/scanner"
|
|
10
16
|
|
|
11
17
|
module Keela
|
|
18
|
+
class Error < StandardError; end
|
|
19
|
+
class ConfigurationError < Error; end
|
|
20
|
+
|
|
12
21
|
class << self
|
|
13
22
|
attr_writer :configuration
|
|
14
23
|
|
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.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Kerri Miller
|
|
@@ -64,9 +64,15 @@ files:
|
|
|
64
64
|
- README.md
|
|
65
65
|
- exe/keela
|
|
66
66
|
- lib/keela.rb
|
|
67
|
+
- lib/keela/baseline.rb
|
|
68
|
+
- lib/keela/config_file.rb
|
|
67
69
|
- lib/keela/configuration.rb
|
|
68
70
|
- lib/keela/reporter.rb
|
|
69
71
|
- lib/keela/scanner.rb
|
|
72
|
+
- lib/keela/strategies/attributes.rb
|
|
73
|
+
- lib/keela/strategies/constants.rb
|
|
74
|
+
- lib/keela/strategies/delegations.rb
|
|
75
|
+
- lib/keela/strategies/i18n_keys.rb
|
|
70
76
|
- lib/keela/strategies/methods.rb
|
|
71
77
|
- lib/keela/strategies/scopes.rb
|
|
72
78
|
- lib/keela/strategy.rb
|
|
@@ -93,7 +99,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
93
99
|
- !ruby/object:Gem::Version
|
|
94
100
|
version: '0'
|
|
95
101
|
requirements: []
|
|
96
|
-
rubygems_version: 4.0.
|
|
102
|
+
rubygems_version: 4.0.10
|
|
97
103
|
specification_version: 4
|
|
98
104
|
summary: Sniff out unused code in your Ruby codebase
|
|
99
105
|
test_files: []
|