keela 0.5.1 → 0.6.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 40ac5a23ccbc726eeea66f99e4a6d9cb109360b987daa26e54c43c6d566c448b
4
- data.tar.gz: 5c6b44f161613c7dea1aae79490918ce8233572345863febcf34a9417710bf60
3
+ metadata.gz: b149f85c049e9f0a669ccdef6c9f70f4bcd627d9bec77a543303fae76592cfde
4
+ data.tar.gz: 3ddae5519560492fbd7fc223065047f787f08bf9c8e99da35efdcbfec0e8e1c7
5
5
  SHA512:
6
- metadata.gz: 69de532e979e8bb052281e2fc58f2c4c6b9039f6a157b88139badff12c4f48abc0df134c817a44f6a6b1dcad131c2fe86c7a667fd51e36d1314184d5444b620d
7
- data.tar.gz: fc86b2efb01f42db61d20f2a65d029082802a09c480bc6aeda5a6739f4b13c4ebfbb5d061f98520bb0248d10d15b9f4237bd18b8a5a0925b384553915d5f6ed4
6
+ metadata.gz: c02762a44f0cab25a0c3711001bcc1891cd016c489318b55c837b4a6be87973551c7f073a29b3ef97691fadf5e6d5c2b040b873f3b2a37a0f1c58bdcf6fc78d7
7
+ data.tar.gz: 529a21fb0e9e8c1e603f48b6f3565a6b8f85c81a939a7120301476c444ce0478d2aac842eaa2f4689afc80803847f6f1f577e8010842f3517c8a4b20fa6fc8d6
data/CHANGELOG.md CHANGED
@@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.6.0] - 2026-10-30
11
+
12
+ ### Added
13
+
14
+ - **Stale exclusions are now reported during a normal run, not just under `--test-exclusions`.** An entry in the excluded YAML whose definition no longer exists (removed, renamed, or its file deleted) was previously only caught by the separate `--test-exclusions` command, so the exclusions file silently rotted. A normal run now flags these inline, elevated to the same behavior as newly unused findings: in baseline mode it fails the run (exit 1), and structured output (`--format json|toon`) carries a `stale_exclusions` section and a `stale_total` summary count. Detection is scoped to the current strategy's section; legacy flat-format entries are skipped to avoid cross-strategy false positives ([#103](https://github.com/kerrizor/keela/issues/103))
15
+ - **New `--normalize-exclusions` flag rewrites the exclusions file in sorted, normalized order.** An explicit write mode (modeled on `--update-baseline`) that deep-sorts the excluded YAML: strategy keys, then file keys within each strategy, then the `name: reason` entries by name. It is reorder-only and non-destructive, it never prunes stale entries, and it preserves every reason verbatim (reasons containing colons stay quoted). Both the strategy-aware and legacy flat formats are handled, output is idempotent, and the file is rewritten with a fixed header (freeform comments are not preserved) ([#93](https://github.com/kerrizor/keela/issues/93))
16
+
17
+ ### Changed
18
+
19
+ - Relaxed the `parallel` dependency from `~> 1.20` to `>= 1.20, < 3`, so keela no longer forces a downgrade in projects already on parallel 2.x, while still supporting 1.x ([#101](https://github.com/kerrizor/keela/pull/101))
20
+
21
+ ### Fixed
22
+
23
+ - **`has_scope :name` is no longer swallowed as a false positive by the `scopes` strategy.** The usage regex excluded any line ending in `scope :` to skip the `scope :name` definition, but `has_scope :name` (common in Rails controllers and ActiveAdmin) also ends in `scope :`, so real usages were rejected and the scope was wrongly reported unused. The lookbehind now requires a whitespace character before `scope`, so an indented definition is still excluded while `has_scope` (preceded by `_`) counts as usage ([#100](https://github.com/kerrizor/keela/issues/100))
24
+ - **A method named only in a comment is no longer counted as used.** Usage detection matched its regex against the whole source, comments included, so a method whose name appeared in a comment was treated as called and genuinely dead code was never flagged. Whole-line Ruby comments (`.rb`, `.rake`, `.builder`) are now dropped from the usage corpus before matching. HAML is left alone (a leading `#` there is an element id), and `#{}` interpolation and trailing comments are kept, since telling a real `#` from one inside a string or regexp needs a tokenizer ([#104](https://github.com/kerrizor/keela/pull/104))
25
+ - Run every selected strategy in baseline mode even when an earlier strategy fails, so text and structured reports include all unused code findings ([#102](https://github.com/kerrizor/keela/pull/102)).
26
+ - **Lazy i18n lookups (`t(".key")`) in mailers, controllers, and `.text.` mailer templates are no longer reported as false-positive unused keys.** The `i18n_keys` strategy only expanded lazy lookups inside `.html.` view templates, so a lazy key used in a mailer/controller Ruby method or a `.text.erb` template was flagged unused. The view-path matcher now also covers `.text.` templates, and a pragmatic line scan of `app/mailers` and `app/controllers` (plus `ee/` variants) resolves each lazy `t(".x")` against its enclosing class and method scope, keeping `_mailer` for mailers and dropping `_controller` for controllers ([#73](https://github.com/kerrizor/keela/issues/73))
27
+ - **Multi-line `delegate` declarations now detect every delegated method, not just the one on the first line.** The `delegations` strategy parsed files line by line, so a `delegate` wrapped across several lines only matched the methods on the opening line; the continuation lines did not match `delegate` and the opening line had no `to:` for target/prefix detection. The strategy now joins each `delegate` statement, across continuation lines while a line ends in a comma (after a trailing comment is stripped), into one logical statement before extracting, so target, `prefix:`, and every method resolve correctly. Single-line delegates and comment skipping are preserved ([#52](https://github.com/kerrizor/keela/issues/52))
28
+
10
29
  ## [0.5.1] - 2026-10-06
11
30
 
12
31
  ### Changed
data/README.md CHANGED
@@ -63,6 +63,7 @@ If a `.keela_baseline.yml` file exists, Keela compares the current scan against
63
63
  | No changes from baseline | ✅ Pass (silent, exit 0) |
64
64
  | NEW unused code detected | ❌ Fail (shows new items) |
65
65
  | Code REMOVED from baseline | ❌ Fail (prompts to update baseline) |
66
+ | STALE exclusion (its definition is gone) | ❌ Fail (shows stale entries to remove) |
66
67
 
67
68
  This lets you gradually pay down tech debt while preventing new dead code from sneaking in.
68
69
 
@@ -178,6 +179,9 @@ keela --source-location
178
179
  # Validate exclusion file entries (find stale exclusions)
179
180
  keela --test-exclusions
180
181
 
182
+ # Rewrite the exclusion file in sorted, normalized order
183
+ keela --normalize-exclusions
184
+
181
185
  # Show version
182
186
  keela --version
183
187
  ```
@@ -193,8 +197,8 @@ Keela detects several types of unused code:
193
197
  | **constants** | Unused constants | `UNUSED_CONSTANT = 'value'` |
194
198
  | **delegations** | Unused delegate declarations | `delegate :unused, to: :target` |
195
199
  | **attributes** | Unused attr_* declarations | `attr_accessor :unused_attr` |
196
- | **i18n_keys** | Unused translation keys | `en.users.unused_key` in locale YAML |
197
- | **partials** | Unused Rails view partials | `app/views/users/_unused.html.erb` |
200
+ | **i18n_keys** _(beta)_ | Unused translation keys | `en.users.unused_key` in locale YAML |
201
+ | **partials** _(beta)_ | Unused Rails view partials | `app/views/users/_unused.html.erb` |
198
202
 
199
203
  Run all strategies (default) or target specific ones with `--type`.
200
204
 
@@ -508,6 +512,18 @@ Or configure in `keela.yml`:
508
512
  excluded_path: ".keela/excluded.yml"
509
513
  ```
510
514
 
515
+ ### Normalizing the Exclusion File
516
+
517
+ As the exclusion file grows, entries tend to land in whatever order they were added, which makes diffs noisy. Rewrite it in a sorted, canonical order with:
518
+
519
+ ```bash
520
+ keela --normalize-exclusions
521
+ ```
522
+
523
+ This sorts the strategy sections, the files within each section, and the entries within each file, then rewrites the file with a fixed header. It is reorder-only: no entry is removed, and every `name: reason` pair is preserved (use `--test-exclusions` to find stale entries). Running it twice produces identical output.
524
+
525
+ Freeform comments are not preserved: the rewrite regenerates a fixed header, so any hand-written comments in the file are dropped.
526
+
511
527
  ## Ruby API
512
528
 
513
529
  ```ruby
data/exe/keela CHANGED
@@ -94,6 +94,10 @@ OptionParser.new do |opts|
94
94
  options[:test_exclusions] = true
95
95
  end
96
96
 
97
+ opts.on("--normalize-exclusions", "Rewrite the exclusions file in sorted, normalized order") do
98
+ options[:normalize_exclusions] = true
99
+ end
100
+
97
101
  opts.on("-h", "--help", "Show this help") do
98
102
  puts opts
99
103
  exit
@@ -135,6 +139,21 @@ if options[:test_exclusions]
135
139
  exit(success ? 0 : 1)
136
140
  end
137
141
 
142
+ # Handle --normalize-exclusions mode
143
+ if options[:normalize_exclusions]
144
+ excluded_path = Keela.configuration.excluded_path ||
145
+ Keela::Scanner::DEFAULT_EXCLUDED_PATHS.find { |p| File.exist?(p) }
146
+
147
+ unless excluded_path
148
+ warn "Error: No exclusion file found. Specify with --excluded PATH"
149
+ exit 1
150
+ end
151
+
152
+ normalizer = Keela::ExclusionNormalizer.new(excluded_path)
153
+ success = normalizer.normalize
154
+ exit(success ? 0 : 1)
155
+ end
156
+
138
157
  STRATEGY_MAP = {
139
158
  methods: Keela::Strategies::Methods,
140
159
  scopes: Keela::Strategies::Scopes,
@@ -210,6 +229,7 @@ end
210
229
 
211
230
  success = true
212
231
  results = {}
232
+ stale_exclusions = {}
213
233
 
214
234
  structured_output = Keela::Formatters.structured_format?(options[:format])
215
235
  silent_output = structured_output || options[:quiet]
@@ -220,14 +240,16 @@ strategies.each_with_index do |strategy, index|
220
240
  end
221
241
 
222
242
  scanner = Keela::Scanner.new(strategy: strategy, baseline: baseline, source_files: source_files)
223
- success &&= scanner.run(
243
+ strategy_success = scanner.run(
224
244
  force_report: options[:force_report],
225
245
  update_baseline: options[:update_baseline],
226
246
  silent: silent_output
227
247
  )
248
+ success &&= strategy_success
228
249
 
229
250
  # Collect results for structured output formats
230
251
  results[strategy.name] = scanner.unused_collection.transform_values(&:to_a)
252
+ stale_exclusions[strategy.name] = scanner.stale_exclusions.transform_values(&:to_a)
231
253
 
232
254
  unless silent_output
233
255
  puts if strategies.size > 1 && index < strategies.size - 1
@@ -243,7 +265,7 @@ end
243
265
  # Output structured format if requested
244
266
  if structured_output
245
267
  formatter_class = Keela::Formatters.for(options[:format])
246
- formatter = formatter_class.new(results: results, strategies: strategies)
268
+ formatter = formatter_class.new(results: results, strategies: strategies, stale_exclusions: stale_exclusions)
247
269
  puts formatter.format
248
270
  end
249
271
 
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "rainbow"
5
+
6
+ module Keela
7
+ # Rewrites an exclusion file in sorted, normalized order.
8
+ #
9
+ # Reorder-only and non-destructive: it never prunes stale entries, it only
10
+ # deep-sorts the existing data and re-dumps it with a fixed header. Freeform
11
+ # YAML comments are not preserved (Psych drops them on load).
12
+ #
13
+ class ExclusionNormalizer
14
+ STRATEGY_MAP = {
15
+ methods: Strategies::Methods,
16
+ scopes: Strategies::Scopes,
17
+ constants: Strategies::Constants,
18
+ delegations: Strategies::Delegations,
19
+ attributes: Strategies::Attributes,
20
+ i18n_keys: Strategies::I18nKeys
21
+ }.freeze
22
+
23
+ attr_reader :excluded_path
24
+
25
+ def initialize(excluded_path)
26
+ @excluded_path = excluded_path
27
+ end
28
+
29
+ def normalize
30
+ unless File.exist?(excluded_path)
31
+ puts Rainbow("Exclusion file not found: #{excluded_path}").red
32
+ return false
33
+ end
34
+
35
+ data = YAML.load_file(excluded_path, symbolize_names: true) || {}
36
+
37
+ sorted = strategy_aware_format?(data) ? deep_sort_strategy_aware(data) : deep_sort_flat(data)
38
+
39
+ File.write(excluded_path, serialize(sorted))
40
+ puts Rainbow("Normalized #{excluded_path}").green.bright
41
+ true
42
+ end
43
+
44
+ private
45
+
46
+ def strategy_aware_format?(data)
47
+ data.keys.any? { |k| STRATEGY_MAP.key?(k) }
48
+ end
49
+
50
+ def deep_sort_strategy_aware(data)
51
+ data.sort_by { |key, _| key.to_s }.to_h do |strategy_name, files|
52
+ [strategy_name.to_s, sort_files(files || {})]
53
+ end
54
+ end
55
+
56
+ def deep_sort_flat(data)
57
+ sort_files(data)
58
+ end
59
+
60
+ def sort_files(files)
61
+ files.sort_by { |key, _| key.to_s }.to_h do |file_path, entries|
62
+ [file_path.to_s, sort_entries(entries || [])]
63
+ end
64
+ end
65
+
66
+ # Sorts the name:reason entry array by name, converting the symbolized
67
+ # key back to a string so the dumped YAML uses string keys.
68
+ #
69
+ def sort_entries(entries)
70
+ entries
71
+ .map { |entry| { entry.keys.first.to_s => entry.values.first } }
72
+ .sort_by { |entry| entry.keys.first }
73
+ end
74
+
75
+ def serialize(data)
76
+ if data.empty? || data.values.all? { |v| v.respond_to?(:empty?) && v.empty? }
77
+ "#{header}---\n{}\n"
78
+ else
79
+ "#{header}#{format_yaml(data)}"
80
+ end
81
+ end
82
+
83
+ def header
84
+ <<~HEADER
85
+ # Excluded items that Keela should not flag as unused.
86
+ # These are known to be in use (e.g. called via metaprogramming).
87
+ #
88
+ # This file is normalized by `keela --normalize-exclusions`.
89
+ # Freeform comments are not preserved when it is rewritten.
90
+ #
91
+ HEADER
92
+ end
93
+
94
+ def format_yaml(hash)
95
+ indent_yaml_list_items(hash.to_yaml)
96
+ end
97
+
98
+ # Indents YAML list items that are not already indented, matching the
99
+ # style Baseline uses for its output.
100
+ #
101
+ def indent_yaml_list_items(yaml_string)
102
+ yaml_string.gsub(/\n-(\s+\S)/, "\n -\\1")
103
+ end
104
+ end
105
+ end
@@ -3,11 +3,12 @@
3
3
  module Keela
4
4
  module Formatters
5
5
  class Base
6
- attr_reader :results, :strategies
6
+ attr_reader :results, :strategies, :stale_exclusions
7
7
 
8
- def initialize(results:, strategies:)
8
+ def initialize(results:, strategies:, stale_exclusions: {})
9
9
  @results = results
10
10
  @strategies = strategies
11
+ @stale_exclusions = stale_exclusions
11
12
  end
12
13
 
13
14
  def format
@@ -28,6 +29,14 @@ module Keela
28
29
  def non_empty_results
29
30
  results.reject { |_, v| v.empty? }
30
31
  end
32
+
33
+ def non_empty_stale_exclusions
34
+ stale_exclusions.reject { |_, v| v.empty? }
35
+ end
36
+
37
+ def stale_total
38
+ stale_exclusions.values.flat_map(&:values).flatten.size
39
+ end
31
40
  end
32
41
  end
33
42
  end
@@ -9,9 +9,11 @@ module Keela
9
9
  output = {
10
10
  strategies: strategies.map(&:name),
11
11
  unused: non_empty_results,
12
+ stale_exclusions: non_empty_stale_exclusions,
12
13
  summary: {
13
14
  total: total_count,
14
- by_strategy: by_strategy_counts
15
+ by_strategy: by_strategy_counts,
16
+ stale_total: stale_total
15
17
  }
16
18
  }
17
19
 
@@ -9,9 +9,11 @@ module Keela
9
9
  output = {
10
10
  "strategies" => strategies.map(&:name),
11
11
  "unused" => non_empty_results.transform_keys(&:to_s),
12
+ "stale_exclusions" => non_empty_stale_exclusions.transform_keys(&:to_s),
12
13
  "summary" => {
13
14
  "total" => total_count,
14
- "by_strategy" => by_strategy_counts.transform_keys(&:to_s)
15
+ "by_strategy" => by_strategy_counts.transform_keys(&:to_s),
16
+ "stale_total" => stale_total
15
17
  }
16
18
  }
17
19
 
@@ -36,6 +36,20 @@ module Keela
36
36
  print_removed(removed, baseline_path) unless removed.empty?
37
37
  end
38
38
 
39
+ def print_stale_exclusions(stale, excluded_path)
40
+ return if stale.empty?
41
+
42
+ count = stale.values.flatten.size
43
+ message = <<~MESSAGE
44
+ We have detected #{count} stale #{strategy_name} exclusion(s).
45
+
46
+ Their definitions no longer exist; remove them from #{excluded_path}.
47
+ MESSAGE
48
+
49
+ puts Rainbow(message).red.bright
50
+ puts Rainbow(format_yaml(stale)).red.bright
51
+ end
52
+
39
53
  def format_report(collection, source_locations = {})
40
54
  if source_locations.empty?
41
55
  format_yaml(collection)
data/lib/keela/scanner.rb CHANGED
@@ -1,11 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "parallel"
4
+ require "set"
4
5
  require "yaml"
5
6
 
6
7
  module Keela
7
8
  class Scanner
8
- attr_reader :strategy, :configuration, :baseline, :source_files, :unused_collection, :source_locations, :new_unused, :removed
9
+ attr_reader :strategy, :configuration, :baseline, :source_files, :unused_collection, :source_locations, :new_unused, :removed, :stale_exclusions
9
10
 
10
11
  DEFAULT_EXCLUDED_PATHS = [".keela/excluded.yml", "keela_excluded.yml"].freeze
11
12
  DEFAULT_BASELINE_PATHS = [".keela/baseline.yml", "keela_baseline.yml"].freeze
@@ -51,6 +52,7 @@ module Keela
51
52
  @source_locations = {} # { "file:name" => line_number }
52
53
  @new_unused = []
53
54
  @removed = []
55
+ @stale_exclusions = Hash.new { |hash, key| hash[key] = [] }
54
56
  end
55
57
 
56
58
  def run(force_report: false, update_baseline: false, silent: false)
@@ -71,7 +73,10 @@ module Keela
71
73
 
72
74
  if report_mode
73
75
  elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
74
- reporter.print_full_report(unused_collection, elapsed, source_locations: source_locations) unless silent
76
+ unless silent
77
+ reporter.print_full_report(unused_collection, elapsed, source_locations: source_locations)
78
+ reporter.print_stale_exclusions(stale_exclusions, resolve_excluded_path) unless stale_exclusions.empty?
79
+ end
75
80
  if update_baseline
76
81
  baseline.set(strategy.name, unused_collection)
77
82
  # Note: caller is responsible for calling baseline.save after all strategies run
@@ -91,7 +96,9 @@ module Keela
91
96
  )
92
97
  end
93
98
 
94
- new_unused.empty? && removed.empty?
99
+ reporter.print_stale_exclusions(stale_exclusions, resolve_excluded_path) unless silent || stale_exclusions.empty?
100
+
101
+ new_unused.empty? && removed.empty? && stale_exclusions.empty?
95
102
  end
96
103
 
97
104
  def file_globs
@@ -181,18 +188,37 @@ module Keela
181
188
  # Support both formats:
182
189
  # 1. Strategy-aware (new): { methods: { "file.rb": [{ name: "reason" }] } }
183
190
  # 2. Flat (legacy): { "file.rb": [{ name: "reason" }] }
184
- excluded = if all_excluded.key?(strategy.name.to_sym)
191
+ strategy_aware = all_excluded.key?(strategy.name.to_sym)
192
+ excluded = if strategy_aware
185
193
  all_excluded[strategy.name.to_sym] || {}
186
194
  else
187
195
  all_excluded # Legacy flat format
188
196
  end
189
197
 
198
+ # Stale detection is only safe for the current strategy's section. A legacy
199
+ # flat entry may belong to another strategy, and keela runs one strategy per
200
+ # Scanner, so flagging it here would be a false positive. Skip flat format.
201
+ #
202
+ detect_stale_exclusions(excluded, definitions) if strategy_aware
203
+
190
204
  definitions.reject do |h|
191
205
  excluded_for_file = excluded[h[:file].to_sym]
192
206
  excluded_for_file&.flat_map(&:keys)&.include?(h[:name].to_sym)
193
207
  end
194
208
  end
195
209
 
210
+ def detect_stale_exclusions(excluded, definitions)
211
+ present = definitions.map { |h| [h[:file].to_sym, h[:name].to_sym] }.to_set
212
+
213
+ excluded.each do |file, entries|
214
+ Array(entries).flat_map(&:keys).each do |name|
215
+ next if present.include?([file, name])
216
+
217
+ @stale_exclusions[file.to_s] << name.to_s
218
+ end
219
+ end
220
+ end
221
+
196
222
  def resolve_excluded_path
197
223
  return configuration.excluded_path if configuration.excluded_path && File.exist?(configuration.excluded_path)
198
224
 
data/lib/keela/source.rb CHANGED
@@ -14,6 +14,11 @@ module Keela
14
14
  # control characters the range ends in.
15
15
  ASCII_RANGE = [0x00, 0x7F].map { |code_point| code_point.chr(Encoding::UTF_8) }.join("-").freeze
16
16
 
17
+ # Extensions whose "#" begins a line comment. HAML is excluded on purpose:
18
+ # there "#id" is an element id, not a comment.
19
+ #
20
+ RUBY_EXTENSIONS = %w[.rb .rake .builder].freeze
21
+
17
22
  attr_reader :text
18
23
 
19
24
  def initialize(text)
@@ -29,8 +34,23 @@ module Keela
29
34
  # no single file (issue #83). Empty files contribute nothing.
30
35
  #
31
36
  def self.from_source_files(source_files)
32
- new(source_files.values.map { |lines| terminate(lines.join) }.join)
37
+ new(source_files.map { |filename, lines| terminate(strip_comments(filename, lines).join) }.join)
38
+ end
39
+
40
+ # Drop whole-line Ruby comments from the usage corpus.
41
+ #
42
+ # A method name mentioned in a comment is not a call, but the usage regex
43
+ # matches it anyway and would hide genuinely dead code. Only whole-line
44
+ # comments are dropped: telling a real "#" from one inside a string, a
45
+ # regexp, or "#{" interpolation needs a tokenizer, not worth it for the
46
+ # trailing-comment case.
47
+ #
48
+ def self.strip_comments(filename, lines)
49
+ return lines unless RUBY_EXTENSIONS.include?(File.extname(filename))
50
+
51
+ lines.reject { |line| line.match?(/\A\s*#(?!\{)/) }
33
52
  end
53
+ private_class_method :strip_comments
34
54
 
35
55
  def self.terminate(text)
36
56
  return text if text.empty? || text.end_with?("\n")
@@ -48,6 +48,62 @@ module Keela
48
48
  methods.length == 1 ? methods.first : methods
49
49
  end
50
50
 
51
+ # Own extraction for delegation files so multi-line delegate statements
52
+ # are detected in full. The scanner's default line-by-line path sees only
53
+ # the first line of a wrapped delegate, missing every continuation method.
54
+ # We join each statement into one logical line, then reuse
55
+ # #extract_definition on the result so target/prefix detection is unchanged.
56
+ #
57
+ # Because returning non-nil makes the scanner skip its default path, this
58
+ # must also replicate that path's comment-skipping and its line-gating on
59
+ # configuration.source_location.
60
+ #
61
+ def extract_definitions_from_file(filepath, lines)
62
+ track_lines = Keela.configuration.source_location
63
+ definitions = []
64
+ index = 0
65
+
66
+ while index < lines.length
67
+ line = lines[index]
68
+
69
+ if skip_comments? && line.strip.start_with?("#")
70
+ index += 1
71
+ next
72
+ end
73
+
74
+ unless line =~ /^\s*delegate\s+/
75
+ index += 1
76
+ next
77
+ end
78
+
79
+ start_index = index
80
+ statement = strip_comment(line)
81
+
82
+ # A delegate statement continues onto the next line while the current
83
+ # line, ignoring a trailing comment and trailing whitespace, ends in a
84
+ # comma. It stops at the first line that does not (the options line).
85
+ # Strip inline comments as we join so #extract_definition sees the same
86
+ # clean text the scanner's default (comment-skipping) path would.
87
+ #
88
+ while continuation?(lines[index])
89
+ index += 1
90
+ break if index >= lines.length
91
+
92
+ statement += strip_comment(lines[index])
93
+ end
94
+
95
+ index += 1
96
+
97
+ Array(extract_definition(statement)).compact.each do |name|
98
+ definition = { name: name, file: filepath }
99
+ definition[:line] = start_index + 1 if track_lines
100
+ definitions << definition
101
+ end
102
+ end
103
+
104
+ definitions
105
+ end
106
+
51
107
  def usage_regex(name)
52
108
  # Match usage of the delegated method, but not the delegate declaration
53
109
  # Uses negative lookbehind to avoid matching:
@@ -85,6 +141,25 @@ module Keela
85
141
 
86
142
  private
87
143
 
144
+ # Whether +line+ hands off to a continuation line: true when it ends in a
145
+ # comma after a trailing comment and whitespace are stripped. A `#` inside
146
+ # a delegate statement can only start a comment, since method symbols
147
+ # cannot contain one, so dropping from the first `#` is safe here.
148
+ #
149
+ def continuation?(line)
150
+ return false unless line
151
+
152
+ strip_comment(line).rstrip.end_with?(",")
153
+ end
154
+
155
+ # Drop a trailing line comment, preserving the newline. A `#` inside a
156
+ # delegate statement can only start a comment, since method symbols cannot
157
+ # contain one, so dropping from the first `#` is safe here.
158
+ #
159
+ def strip_comment(line)
160
+ line.sub(/#[^\n]*/, "")
161
+ end
162
+
88
163
  def folded_usage_regex(name)
89
164
  /(?<!:)(?<!delegate\s)(?<![a-z_])#{Regexp.quote(name.downcase(:fold))}(?!\w)/
90
165
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "yaml"
4
+ require "set"
4
5
 
5
6
  module Keela
6
7
  module Strategies
@@ -21,9 +22,17 @@ module Keela
21
22
  # Lazy lookup is supported: t('.title') in app/views/users/show.html.erb
22
23
  # resolves to 'users.show.title'.
23
24
  #
25
+ # Lazy lookup in mailer/controller Ruby methods is also supported, e.g.
26
+ # t('.greeting') in UserMailer#welcome resolves to 'user_mailer.welcome.greeting',
27
+ # and t('.notice') in UsersController#create resolves to 'users.create.notice'.
28
+ #
24
29
  class I18nKeys < Strategy
25
30
  PLURAL_SUFFIXES = %w[zero one two few many other].freeze
26
- VIEW_PATH_REGEX = %r{(?:ee/)?app/views/(.+)\.html\.(?:erb|haml|slim)$}.freeze
31
+ VIEW_PATH_REGEX = %r{(?:ee/)?app/views/(.+)\.(?:html|text)\.(?:erb|haml|slim)$}.freeze
32
+ RUBY_SCOPE_PATH_REGEX = %r{(?:ee/)?app/(?:mailers|controllers)/.+\.rb$}.freeze
33
+ CLASS_DEF_REGEX = /^\s*class\s+([A-Z][\w:]*)/.freeze
34
+ MODULE_DEF_REGEX = /^\s*module\s+([A-Z][\w:]*)/.freeze
35
+ METHOD_DEF_REGEX = /^\s*def\s+(?:self\.)?([a-z_][\w]*[?!=]?)/.freeze
27
36
  LAZY_LOOKUP_REGEX = /(?:I18n\.)?t\s*\(\s*['"](\.[^'"]+)['"]/
28
37
  def name
29
38
  "i18n_keys"
@@ -95,6 +104,7 @@ module Keela
95
104
  # "app/views/users/show.html.erb" -> "users.show"
96
105
  # "app/views/users/_form.html.erb" -> "users.form"
97
106
  # "ee/app/views/users/show.html.erb" -> "users.show"
107
+ # "app/views/user_mailer/welcome.text.erb" -> "user_mailer.welcome"
98
108
  def view_path_to_prefix(path)
99
109
  return nil unless path =~ VIEW_PATH_REGEX
100
110
 
@@ -115,22 +125,21 @@ module Keela
115
125
  content.scan(LAZY_LOOKUP_REGEX).flatten
116
126
  end
117
127
 
118
- # Build a set of expanded lazy lookup keys from view files
119
- # Called by the scanner to detect usage via lazy lookup
128
+ # Build a set of expanded lazy lookup keys from view files,
129
+ # mailers, and controllers. Called by the scanner to detect
130
+ # usage via lazy lookup.
120
131
  def additional_used_names(source_files)
121
132
  expanded = Set.new
122
133
 
123
134
  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
135
+ if (prefix = view_path_to_prefix(filepath))
136
+ content = lines.join("\n")
137
+ extract_lazy_keys(content).each do |lazy_key|
138
+ # .title -> users.show.title
139
+ expanded << "#{prefix}#{lazy_key}"
140
+ end
141
+ elsif filepath =~ RUBY_SCOPE_PATH_REGEX
142
+ expand_ruby_lazy_keys(lines).each { |key| expanded << key }
134
143
  end
135
144
  end
136
145
 
@@ -139,6 +148,70 @@ module Keela
139
148
 
140
149
  private
141
150
 
151
+ # Scan a mailer/controller Ruby file line by line, tracking the current
152
+ # class scope and method, and expand each lazy t('.x') to its full key.
153
+ #
154
+ # This is a pragmatic line scan (no AST) matching keela's regex style.
155
+ # It tracks the last-seen `def` as the enclosing method and does NOT
156
+ # attempt precise `end` matching. A lazy call that appears after a method
157
+ # body but before the next `def` is still attributed to the last method.
158
+ # This over-association can only mark an EXTRA key as used, never cause a
159
+ # false "unused" flag, so it is a safe conservative tradeoff. A lazy call
160
+ # seen before any `def` has no enclosing method and is skipped.
161
+ #
162
+ def expand_ruby_lazy_keys(lines)
163
+ keys = []
164
+ namespace = []
165
+ class_scope = nil
166
+ current_method = nil
167
+
168
+ lines.each do |line|
169
+ if (m = line.match(MODULE_DEF_REGEX))
170
+ namespace << m[1]
171
+ elsif (m = line.match(CLASS_DEF_REGEX))
172
+ class_scope = ruby_class_scope(namespace, m[1])
173
+ current_method = nil
174
+ elsif (m = line.match(METHOD_DEF_REGEX))
175
+ current_method = m[1]
176
+ end
177
+
178
+ next unless class_scope && current_method
179
+
180
+ line.scan(LAZY_LOOKUP_REGEX).flatten.each do |lazy_key|
181
+ # .greeting -> user_mailer.welcome.greeting
182
+ keys << "#{class_scope}.#{current_method}#{lazy_key}"
183
+ end
184
+ end
185
+
186
+ keys
187
+ end
188
+
189
+ # Derive the i18n scope for a class in a mailer/controller file.
190
+ # Combines any enclosing `module` nesting with the class name (which may
191
+ # itself be compact, e.g. `Admin::UserMailer`), underscores the full path,
192
+ # converts "/" to ".", and drops a trailing "_controller" for controllers
193
+ # while keeping "_mailer" for mailers.
194
+ #
195
+ # [], "PaymentOrderMailer" -> "payment_order_mailer"
196
+ # ["Admin"], "UserMailer" -> "admin.user_mailer"
197
+ # [], "Admin::UserMailer" -> "admin.user_mailer"
198
+ # [], "UsersController" -> "users"
199
+ # ["Admin"], "UsersController" -> "admin.users"
200
+ #
201
+ def ruby_class_scope(namespace, class_name)
202
+ full_path = (namespace + class_name.split("::")).map { |part| underscore(part) }.join("/")
203
+ full_path = full_path.delete_suffix("_controller") if full_path.end_with?("_controller")
204
+ full_path.tr("/", ".")
205
+ end
206
+
207
+ # Convert a CamelCase constant segment to snake_case.
208
+ # "PaymentOrderMailer" -> "payment_order_mailer"
209
+ def underscore(word)
210
+ word.gsub(/([A-Z\d]+)([A-Z][a-z])/, '\1_\2')
211
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
212
+ .downcase
213
+ end
214
+
142
215
  # Returns the parent key if this is a pluralization key, nil otherwise
143
216
  # "items.count.one" -> "items.count"
144
217
  # "users.show.title" -> nil
@@ -18,7 +18,11 @@ module Keela
18
18
  end
19
19
 
20
20
  def usage_regex(name)
21
- /(?<!scope :)(?<!def )#{Regexp.quote(name)}\W/
21
+ # Whitespace-anchoring the lookbehind distinguishes a `scope :name`
22
+ # definition (preceded by indentation whitespace) from `has_scope :name`
23
+ # usage (preceded by `_`), which must count as a real usage.
24
+ #
25
+ /(?<!\sscope :)(?<!def )#{Regexp.quote(name)}\W/
22
26
  end
23
27
 
24
28
  def skip_comments?
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.5.1"
4
+ VERSION = "0.6.0"
5
5
  end
data/lib/keela.rb CHANGED
@@ -16,6 +16,7 @@ require_relative "keela/reporter"
16
16
  require_relative "keela/baseline"
17
17
  require_relative "keela/scanner"
18
18
  require_relative "keela/exclusion_validator"
19
+ require_relative "keela/exclusion_normalizer"
19
20
  require_relative "keela/formatters"
20
21
 
21
22
  module Keela
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.5.1
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kerri Miller
@@ -13,16 +13,22 @@ dependencies:
13
13
  name: parallel
14
14
  requirement: !ruby/object:Gem::Requirement
15
15
  requirements:
16
- - - "~>"
16
+ - - ">="
17
17
  - !ruby/object:Gem::Version
18
18
  version: '1.20'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '3'
19
22
  type: :runtime
20
23
  prerelease: false
21
24
  version_requirements: !ruby/object:Gem::Requirement
22
25
  requirements:
23
- - - "~>"
26
+ - - ">="
24
27
  - !ruby/object:Gem::Version
25
28
  version: '1.20'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '3'
26
32
  - !ruby/object:Gem::Dependency
27
33
  name: rainbow
28
34
  requirement: !ruby/object:Gem::Requirement
@@ -81,6 +87,7 @@ files:
81
87
  - lib/keela/baseline.rb
82
88
  - lib/keela/config_file.rb
83
89
  - lib/keela/configuration.rb
90
+ - lib/keela/exclusion_normalizer.rb
84
91
  - lib/keela/exclusion_validator.rb
85
92
  - lib/keela/formatters.rb
86
93
  - lib/keela/formatters/base.rb