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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3448ca0297d2fb01cbe430bf8d1686ae28871bfa36666815120241664ed95821
4
- data.tar.gz: 05a96216af6eea62b31ee0e6d6ceb8e82326946ff8eecd35f3eeb07637d220d8
3
+ metadata.gz: 1b1c9fad8b959b91c3af1518da72d36f9f82bd649b4c21a6a0cf261246cbbcdb
4
+ data.tar.gz: 24c7b946587cb844ae9c08c921ffecacfb8b262404b2c177f0368d803cc52861
5
5
  SHA512:
6
- metadata.gz: fc7d5f1e2f1844dc9f67d51e96de17b23e3d040ce767b51529d23c1619f96ae86ea1bf82a028e2e09cea4d074fcba001dab20b1a29725f54f71c366b68a2de17
7
- data.tar.gz: 177309a39a1fa56d97d6a3693ff674cd94b92cb81e00ea50a6b889823e4d68fd2fdf0601c7d7157e10f48d50f66f940bb92a695808b424d30f4b5ee14bd22623
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
- - **Pluralization siblings** - If `one:` is used, `other:` may appear unused
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
- /(?<![A-Z0-9_])#{Regexp.quote(name)}(?![A-Z0-9_])(?!\s*=(?!=))/
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
- # Note: Lazy lookup (t('.title') in views) is not yet supported.
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
- key_without_locale = key.sub(/^[a-z]{2}(-[A-Z]{2})?\./, "")
38
- { name: key_without_locale, file: filepath }
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
- # Also match partial keys for lazy lookup support:
59
- # t(".title") in a view could match "users.show.title"
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
- # Build pattern that matches the key in quotes or as a symbol
63
- /(?:I18n\.)?t\s*\(\s*["':]+#{quoted_name}["']?\s*[,)]/
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
- # Exclude both "def foo" and "def self.foo" definitions
29
- /(?<!def |def self\.)#{method_name}\W/
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
 
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Keela
4
- VERSION = "0.4.0"
4
+ VERSION = "0.4.1"
5
5
  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.0
4
+ version: 0.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kerri Miller