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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b2532684d9e7c3cca0411cbe55c3d944265236d159c287f8b719a778695872ed
4
- data.tar.gz: '08a678fa6105ae093817dba3ce3632f25d173cae617db34aa267bd524b77e2ee'
3
+ metadata.gz: 920e953982643da9bb58171ea626a130d27a3cc41f6ccfb3511eabefbb0b41c8
4
+ data.tar.gz: 8c7e5d91900e962efca4d08f242e9582ad9192c1d07401e45ce545df5af68d2a
5
5
  SHA512:
6
- metadata.gz: 8b0574c28fcec2f29a78463c08d20ccf58401d81b67c60878571fef4c381dc88bd4beca3b802c72edb5b456ec9c8bbf7b1f46b3bd2c6ed68380994ac04267330
7
- data.tar.gz: 90e50d394dff39875fad354923381b9490994a9d2a4d3673f4c1a0d6df15be85c21bb54a24fee6a4860553bcea580ac589d4ed57f0e5fa44c8c2e80ba6742765
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 and scopes)
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
- [Keela](https://en.wikipedia.org/wiki/Keela_(dog)) was a famous English Springer Spaniel known as the "CSI dog." She could detect microscopic traces of blood that other methods missed, and worked on many high-profile forensic cases including the Madeleine McCann investigation. Like her namesake, this gem finds the unused code that other tools miss.
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 TYPE", %i[methods scopes all], "Type to detect: methods, scopes, all (default: all)") do |type|
29
- options[:type] = type
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
- strategies = case options[:type]
67
- when :all
68
- [Keela::Strategies::Methods.new, Keela::Strategies::Scopes.new]
69
- when :methods
70
- [Keela::Strategies::Methods.new]
71
- when :scopes
72
- [Keela::Strategies::Scopes.new]
73
- end
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
- puts Rainbow("=== Sniffing for unused #{strategy.name} ===").cyan.bright if strategies.size > 1
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
- scanner = Keela::Scanner.new(strategy: strategy)
81
- success &&= scanner.run(force_report: options[:force_report], update_baseline: options[:update_baseline])
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 if strategies.size > 1 && index < strategies.size - 1
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
@@ -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 || !baseline_exists?
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
- write_baseline_file if update_baseline
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
- reporter.print_diff_report(
43
- new_unused,
44
- removed,
45
- excluded_path: configuration.excluded_path || "excluded.yml",
46
- baseline_path: configuration.baseline_path
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
- configuration.directory_patterns.map { |pattern| format(pattern, ext: ext) }
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].flat_map do |line|
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
- baseline = YAML.load_file(configuration.baseline_path) || {}
120
- baseline_items = baseline.flat_map { |f, names| [f].product(names) }
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
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Keela
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
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.1.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.15
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: []