keela 0.4.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 +9 -0
- data/README.md +56 -2
- data/lib/keela/scanner.rb +7 -0
- data/lib/keela/strategies/constants.rb +6 -2
- data/lib/keela/strategies/i18n_keys.rb +90 -8
- data/lib/keela/strategies/methods.rb +8 -3
- data/lib/keela/strategy.rb +8 -0
- data/lib/keela/version.rb +1 -1
- metadata +1 -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,15 @@ 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
|
+
|
|
10
19
|
## [0.4.0] - 2026-08-14
|
|
11
20
|
|
|
12
21
|
### Added
|
data/README.md
CHANGED
|
@@ -199,13 +199,67 @@ Run all strategies (default) or target specific ones with `--type`.
|
|
|
199
199
|
|
|
200
200
|
The `i18n_keys` strategy is **beta** and may produce false positives. It cannot detect:
|
|
201
201
|
|
|
202
|
-
- **Lazy lookup** - `t('.title')` in views resolves based on the view path
|
|
203
202
|
- **Dynamic keys** - `t("users.#{action}.title")` with interpolated segments
|
|
204
203
|
- **Model translations** - `User.human_attribute_name(:email)` and `User.model_name.human`
|
|
205
|
-
|
|
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
|
|
206
208
|
|
|
207
209
|
Review results carefully and use the exclusion file for known false positives.
|
|
208
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
|
+
|
|
209
263
|
## Configuration File
|
|
210
264
|
|
|
211
265
|
Create a `keela.yml` or `.keela.yml` in your project root:
|
data/lib/keela/scanner.rb
CHANGED
|
@@ -210,9 +210,16 @@ module Keela
|
|
|
210
210
|
def find_unused(definitions, show_progress: false)
|
|
211
211
|
source_code = source_files.values.flatten.join
|
|
212
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
|
+
|
|
213
217
|
progress_label = show_progress ? "Checking #{strategy.name}" : nil
|
|
214
218
|
|
|
215
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
|
+
|
|
216
223
|
regex = strategy.usage_regex(definition[:name])
|
|
217
224
|
regex.match?(source_code) ? [] : definition
|
|
218
225
|
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?
|
|
@@ -15,9 +15,16 @@ 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
|
|
@@ -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"]
|
|
@@ -24,9 +24,14 @@ module Keela
|
|
|
24
24
|
# Setter method: match assignment usage
|
|
25
25
|
/(?<!def |def self\.)#{method_name.chomp("=")}\W=*/
|
|
26
26
|
else
|
|
27
|
-
# Regular method: match calls
|
|
28
|
-
#
|
|
29
|
-
|
|
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|$)/
|
|
30
35
|
end
|
|
31
36
|
end
|
|
32
37
|
|
data/lib/keela/strategy.rb
CHANGED
|
@@ -55,6 +55,14 @@ module Keela
|
|
|
55
55
|
nil
|
|
56
56
|
end
|
|
57
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
|
+
|
|
58
66
|
private
|
|
59
67
|
|
|
60
68
|
def configured_pattern
|
data/lib/keela/version.rb
CHANGED