keela 0.1.0 → 0.2.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: b2532684d9e7c3cca0411cbe55c3d944265236d159c287f8b719a778695872ed
4
- data.tar.gz: '08a678fa6105ae093817dba3ce3632f25d173cae617db34aa267bd524b77e2ee'
3
+ metadata.gz: a2bb8c13b7ef3c4a38029a71e02f46a3d572cc9f27e2d625f779b39bef07197a
4
+ data.tar.gz: b6bfcdd221a08c429eac645c01598817c5c1bd01304b937da143d6a5c0c9eb26
5
5
  SHA512:
6
- metadata.gz: 8b0574c28fcec2f29a78463c08d20ccf58401d81b67c60878571fef4c381dc88bd4beca3b802c72edb5b456ec9c8bbf7b1f46b3bd2c6ed68380994ac04267330
7
- data.tar.gz: 90e50d394dff39875fad354923381b9490994a9d2a4d3673f4c1a0d6df15be85c21bb54a24fee6a4860553bcea580ac589d4ed57f0e5fa44c8c2e80ba6742765
6
+ metadata.gz: db79ae6b9b332d6bef3b2472f347f7b015805b70aef92ad58e5b788b8c33dac3424b9242aee60747b5eb6048a645140665e96f6dc27d6424a5160c4afe54e22f
7
+ data.tar.gz: fc15ddd72010545b39df3e03a7f7c28dea0fa6b14c3d5f2a8d9a9223e01e67336254864514ff9941195457e7d5cba90535da217b6a86b7d067f03f874464c3c6
data/CHANGELOG.md CHANGED
@@ -7,6 +7,40 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.1] - 2026-07-27
11
+
12
+ ### Added
13
+
14
+ - **Quiet mode** via `--quiet` / `-q` CLI flag to suppress progress bar output ([#26](https://github.com/kerrizor/keela/issues/26))
15
+
16
+ ### Changed
17
+
18
+ - Default suggested excluded filename changed from `excluded.yml` to `keela_excluded.yml` for consistency with `keela_baseline.yml` ([#28](https://github.com/kerrizor/keela/pull/28))
19
+ - Default file locations now support `.keela/` directory with fallback to root ([#29](https://github.com/kerrizor/keela/pull/29))
20
+ - Excluded: `.keela/excluded.yml` → `keela_excluded.yml`
21
+ - Baseline: `.keela/baseline.yml` → `keela_baseline.yml`
22
+
23
+ ## [0.2.0] - 2026-07-17
24
+
25
+ ### Added
26
+
27
+ - `Baseline` class for managing multi-strategy baseline files ([#1](https://github.com/kerrizor/keela/pull/1))
28
+ - Single baseline file now supports multiple strategy sections (methods, scopes)
29
+ - **Constants strategy** for detecting unused constant definitions ([#2](https://github.com/kerrizor/keela/pull/2))
30
+ - **Delegations strategy** for detecting unused `delegate :method, to: :target` declarations ([#4](https://github.com/kerrizor/keela/pull/4))
31
+ - **Attributes strategy** for detecting unused `attr_accessor`, `attr_reader`, `attr_writer` declarations ([#5](https://github.com/kerrizor/keela/pull/5))
32
+ - **Config file support** (`keela.yml` or `.keela.yml`) for project-specific settings ([#11](https://github.com/kerrizor/keela/pull/11))
33
+ - **Exclude patterns** via `--exclude` CLI flag or `exclude_patterns` in config file ([#12](https://github.com/kerrizor/keela/pull/12))
34
+ - **Include patterns** via `--include` CLI flag or `include_patterns` in config file ([#14](https://github.com/kerrizor/keela/pull/14))
35
+ - **Configuration validation** - raises `ConfigurationError` if `directory_patterns` is customized while also using `include_patterns` or `exclude_patterns` ([#15](https://github.com/kerrizor/keela/pull/15))
36
+ - **I18n keys strategy (beta)** for detecting unused translation keys in locale files ([#16](https://github.com/kerrizor/keela/pull/16))
37
+ - **Multiple types** can now be specified with `--type methods,scopes,constants` ([#18](https://github.com/kerrizor/keela/pull/18))
38
+ - **JSON output** via `--format json` for machine-readable results ([#19](https://github.com/kerrizor/keela/pull/19))
39
+
40
+ ### Fixed
41
+
42
+ - Running strategies separately no longer overwrites previous strategy data ([#1](https://github.com/kerrizor/keela/pull/1))
43
+
10
44
  ## [0.1.0] - 2026-07-16
11
45
 
12
46
  ### Added
data/README.md CHANGED
@@ -1,6 +1,14 @@
1
1
  # Keela 🐕
2
2
 
3
- Like the famous CSI dog who found what others missed, Keela sniffs out unused code in your Ruby codebase.
3
+ **Your Ruby codebase's forensic investigator.**
4
+
5
+ Like her namesake — the famous springer spaniel who helped solve cases by finding microscopic traces that eluded forensic teams — Keela sniffs out the dead code that `grep` missed.
6
+
7
+ ```
8
+ 🔍 6 strategies: methods, scopes, constants, delegates, attrs, i18n
9
+ 🎯 Baseline mode — only bark at NEW dead code
10
+ 📊 JSON output for CI pipelines
11
+ ```
4
12
 
5
13
  ## Why Remove Unused Code?
6
14
 
@@ -69,13 +77,16 @@ keela --report
69
77
  ## Command Line Options
70
78
 
71
79
  ```bash
72
- # Scan for all unused code (methods and scopes)
80
+ # Scan for all unused code (methods, scopes, constants, delegations, attributes)
73
81
  keela
74
82
 
75
83
  # Scan for specific types
76
84
  keela --type methods
77
85
  keela --type scopes
78
86
 
87
+ # Combine multiple types
88
+ keela --type methods,scopes,constants
89
+
79
90
  # Force report mode (ignore baseline)
80
91
  keela --report
81
92
 
@@ -91,10 +102,117 @@ keela --excluded config/keela_excluded.yml
91
102
  # Custom file extensions
92
103
  keela --extensions rb,rake,haml
93
104
 
105
+ # Exclude files matching patterns
106
+ keela --exclude 'vendor/**/*' --exclude 'tmp/**/*'
107
+
108
+ # Include additional directories (adds to defaults)
109
+ keela --include 'engines/**/*.rb' --include 'custom/**/*.rb'
110
+
111
+ # Use a custom config file
112
+ keela --config path/to/keela.yml
113
+
114
+ # Output as JSON (for CI integrations)
115
+ keela --format json
116
+
117
+ # Suppress progress bar (useful for CI and scripting)
118
+ keela --quiet
119
+ keela -q
120
+
94
121
  # Show version
95
122
  keela --version
96
123
  ```
97
124
 
125
+ ## Detection Strategies
126
+
127
+ Keela detects several types of unused code:
128
+
129
+ | Strategy | Detects | Example |
130
+ |----------|---------|---------|
131
+ | **methods** | Unused method definitions | `def unused_method` |
132
+ | **scopes** | Unused ActiveRecord scopes | `scope :unused_scope, -> { }` |
133
+ | **constants** | Unused constants | `UNUSED_CONSTANT = 'value'` |
134
+ | **delegations** | Unused delegate declarations | `delegate :unused, to: :target` |
135
+ | **attributes** | Unused attr_* declarations | `attr_accessor :unused_attr` |
136
+ | **i18n_keys** | Unused translation keys | `en.users.unused_key` in locale YAML |
137
+
138
+ Run all strategies (default) or target specific ones with `--type`.
139
+
140
+ **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`.
141
+
142
+ ### I18n Keys (Beta)
143
+
144
+ The `i18n_keys` strategy is **beta** and may produce false positives. It cannot detect:
145
+
146
+ - **Lazy lookup** - `t('.title')` in views resolves based on the view path
147
+ - **Dynamic keys** - `t("users.#{action}.title")` with interpolated segments
148
+ - **Model translations** - `User.human_attribute_name(:email)` and `User.model_name.human`
149
+ - **Pluralization siblings** - If `one:` is used, `other:` may appear unused
150
+
151
+ Review results carefully and use the exclusion file for known false positives.
152
+
153
+ ## Configuration File
154
+
155
+ Create a `keela.yml` or `.keela.yml` in your project root:
156
+
157
+ ```yaml
158
+ # keela.yml
159
+ extensions:
160
+ - rb
161
+ - haml
162
+ - erb
163
+
164
+ exclude_patterns:
165
+ - "vendor/**/*"
166
+ - "tmp/**/*"
167
+
168
+ include_patterns:
169
+ - "engines/**/*.%<ext>s"
170
+
171
+ excluded_path: ".keela_excluded.yml"
172
+ baseline_path: ".keela_baseline.yml"
173
+ ```
174
+
175
+ Keela automatically loads `keela.yml` or `.keela.yml` from the current directory. Use `--config` to specify a different path.
176
+
177
+ ### Customizing Which Files to Scan
178
+
179
+ There are two approaches:
180
+
181
+ **1. Tweak the defaults** with `--include` and `--exclude` (or `include_patterns`/`exclude_patterns` in config):
182
+
183
+ ```bash
184
+ # Add engines/ to the default app/, lib/, config/ directories
185
+ keela --include 'engines/**/*.rb'
186
+
187
+ # Exclude vendor files from scanning
188
+ keela --exclude 'vendor/**/*'
189
+ ```
190
+
191
+ **2. Full control** with `directory_patterns` - replaces the defaults entirely:
192
+
193
+ ```yaml
194
+ # keela.yml - scan ONLY these directories
195
+ directory_patterns:
196
+ - "src/**/*.%<ext>s"
197
+ - "custom/**/*.%<ext>s"
198
+ ```
199
+
200
+ Use `directory_patterns` when you need complete control. Use `--include`/`--exclude` when you just want to tweak the defaults.
201
+
202
+ **Note:** Mixing both approaches raises a `ConfigurationError`. Choose one or the other.
203
+
204
+ **Available options:**
205
+
206
+ | Key | Description | Default |
207
+ |-----|-------------|---------|
208
+ | `directory_patterns` | Glob patterns for files to scan (replaces defaults) | `app/`, `lib/`, `config/` |
209
+ | `extensions` | File extensions to scan | `rb`, `haml`, `erb` |
210
+ | `include_patterns` | Additional patterns to scan (added to defaults) | `[]` |
211
+ | `exclude_patterns` | Patterns for files to exclude | `[]` |
212
+ | `excluded_path` | Path to YAML file of excluded items | `nil` |
213
+ | `baseline_path` | Path to baseline YAML file | `.keela_baseline.yml` |
214
+ | `required_directory` | Directory that must exist for scanning to proceed | `nil` |
215
+
98
216
  ## CI Integration
99
217
 
100
218
  Keela is designed for CI pipelines. Add it to catch dead code before it merges:
@@ -120,6 +238,37 @@ The workflow:
120
238
  2. **CI runs**: `keela` compares against baseline, fails on new dead code
121
239
  3. **After cleanup**: Run `keela --update-baseline` to update the baseline
122
240
 
241
+ ### JSON Output
242
+
243
+ Use `--format json` for machine-readable output:
244
+
245
+ ```bash
246
+ keela --format json --report
247
+ ```
248
+
249
+ ```json
250
+ {
251
+ "strategies": ["methods", "scopes"],
252
+ "unused": {
253
+ "methods": {
254
+ "app/models/user.rb": ["unused_method", "old_helper"]
255
+ },
256
+ "scopes": {
257
+ "app/models/post.rb": ["inactive"]
258
+ }
259
+ },
260
+ "summary": {
261
+ "total": 3,
262
+ "by_strategy": {
263
+ "methods": 2,
264
+ "scopes": 1
265
+ }
266
+ }
267
+ }
268
+ ```
269
+
270
+ This is useful for integrating with other tools, generating reports, or processing results programmatically.
271
+
123
272
  ## Exclusion File
124
273
 
125
274
  Some code appears unused but is actually called dynamically. Exclude it:
@@ -151,6 +300,8 @@ Keela.configure do |config|
151
300
  app/**/*.%<ext>s
152
301
  lib/**/*.%<ext>s
153
302
  ]
303
+ config.include_patterns = %w[engines/**/*.%<ext>s]
304
+ config.exclude_patterns = %w[vendor/**/* tmp/**/*]
154
305
  config.excluded_path = '.keela_excluded.yml'
155
306
  config.baseline_path = '.keela_baseline.yml'
156
307
  end
@@ -200,7 +351,11 @@ scanner.run(force_report: true)
200
351
 
201
352
  ## About the Name
202
353
 
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.
354
+ Keela was a famous English Springer Spaniel known as the "CSI dog." She could detect microscopic traces of blood that other forensic methods missed, helping solve cases that had gone cold for decades. Her nose was so sensitive she found evidence that luminol and DNA testing couldn't.
355
+
356
+ Like her namesake, this gem finds the dead code that `grep` and your IDE missed.
357
+
358
+ *Good girl, Keela.* 🦴
204
359
 
205
360
  ## License
206
361
 
data/exe/keela CHANGED
@@ -7,7 +7,10 @@ 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,
13
+ quiet: false
11
14
  }
12
15
 
13
16
  OptionParser.new do |opts|
@@ -25,8 +28,8 @@ OptionParser.new do |opts|
25
28
  opts.separator ""
26
29
  opts.separator "Options:"
27
30
 
28
- opts.on("--type TYPE", %i[methods scopes all], "Type to detect: methods, scopes, all (default: all)") do |type|
29
- options[:type] = type
31
+ opts.on("--type TYPES", "Comma-separated types to detect: methods, scopes, constants, delegations, attributes, i18n_keys, all (default: all)") do |types|
32
+ options[:types] = types.split(",").map { |t| t.strip.to_sym }
30
33
  end
31
34
 
32
35
  opts.on("--report", "-r", "Force report mode: show all unused code (ignore baseline)") do
@@ -37,6 +40,10 @@ OptionParser.new do |opts|
37
40
  options[:update_baseline] = true
38
41
  end
39
42
 
43
+ opts.on("--format FORMAT", %i[text json], "Output format: text (default) or json") do |format|
44
+ options[:format] = format
45
+ end
46
+
40
47
  opts.on("--excluded PATH", "Path to YAML file of excluded items") do |path|
41
48
  Keela.configuration.excluded_path = path
42
49
  end
@@ -49,38 +56,127 @@ OptionParser.new do |opts|
49
56
  Keela.configuration.extensions = exts.split(",").map(&:strip)
50
57
  end
51
58
 
59
+ opts.on("--exclude PATTERN", "Glob pattern for files to exclude (can be used multiple times)") do |pattern|
60
+ Keela.configuration.exclude_patterns << pattern
61
+ end
62
+
63
+ opts.on("--include PATTERN", "Additional glob pattern to scan (can be used multiple times)") do |pattern|
64
+ Keela.configuration.include_patterns << pattern
65
+ end
66
+
67
+ opts.on("--config PATH", "-c", "Path to config file (default: keela.yml or .keela.yml)") do |path|
68
+ options[:config_path] = path
69
+ end
70
+
52
71
  opts.on("--version", "-v", "Show version") do
53
72
  puts "keela #{Keela::VERSION}"
54
73
  exit
55
74
  end
56
75
 
76
+ opts.on("--quiet", "-q", "Suppress progress bar output (useful for CI and scripting)") do
77
+ options[:quiet] = true
78
+ end
79
+
57
80
  opts.on("-h", "--help", "Show this help") do
58
81
  puts opts
59
82
  exit
60
83
  end
61
84
  end.parse!
62
85
 
86
+ # Load config file (keela.yml or .keela.yml) if present
87
+ # CLI options override config file settings
88
+ Keela::ConfigFile.load(path: options[:config_path])
89
+
90
+ # Apply quiet mode if requested
91
+ Keela.configuration.show_progress = false if options[:quiet]
92
+
63
93
  # Set default baseline path if not specified
64
94
  Keela.configuration.baseline_path ||= ".keela_baseline.yml"
65
95
 
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
96
+ STRATEGY_MAP = {
97
+ methods: Keela::Strategies::Methods,
98
+ scopes: Keela::Strategies::Scopes,
99
+ constants: Keela::Strategies::Constants,
100
+ delegations: Keela::Strategies::Delegations,
101
+ attributes: Keela::Strategies::Attributes,
102
+ i18n_keys: Keela::Strategies::I18nKeys
103
+ }.freeze
104
+
105
+ # Default strategies for "all" (excludes i18n_keys which requires different config)
106
+ DEFAULT_STRATEGIES = %i[methods scopes constants delegations attributes].freeze
107
+
108
+ VALID_TYPES = (STRATEGY_MAP.keys + [:all]).freeze
109
+
110
+ def build_strategies(types)
111
+ types = types || [:all]
112
+
113
+ # Validate types
114
+ invalid = types - VALID_TYPES
115
+ unless invalid.empty?
116
+ warn "Error: Invalid type(s): #{invalid.join(', ')}"
117
+ warn "Valid types: #{VALID_TYPES.join(', ')}"
118
+ exit 1
119
+ end
120
+
121
+ # Expand :all to default strategies
122
+ expanded = types.flat_map { |t| t == :all ? DEFAULT_STRATEGIES : t }.uniq
123
+
124
+ expanded.map { |t| STRATEGY_MAP[t].new }
125
+ end
126
+
127
+ strategies = build_strategies(options[:types])
128
+
129
+ # Share a single baseline across all strategies
130
+ baseline = Keela::Baseline.new(Keela.configuration.baseline_path)
74
131
 
75
132
  success = true
133
+ results = {}
134
+
135
+ json_mode = options[:format] == :json
76
136
 
77
137
  strategies.each_with_index do |strategy, index|
78
- puts Rainbow("=== Sniffing for unused #{strategy.name} ===").cyan.bright if strategies.size > 1
138
+ unless json_mode
139
+ puts Rainbow("=== Sniffing for unused #{strategy.name} ===").cyan.bright if strategies.size > 1
140
+ end
141
+
142
+ scanner = Keela::Scanner.new(strategy: strategy, baseline: baseline)
143
+ success &&= scanner.run(
144
+ force_report: options[:force_report],
145
+ update_baseline: options[:update_baseline],
146
+ silent: json_mode
147
+ )
148
+
149
+ # Collect results for JSON output
150
+ results[strategy.name] = scanner.unused_collection.transform_values(&:to_a)
151
+
152
+ unless json_mode
153
+ puts if strategies.size > 1 && index < strategies.size - 1
154
+ end
155
+ end
156
+
157
+ # Save baseline after all strategies have run
158
+ if options[:update_baseline]
159
+ baseline.save
160
+ puts Rainbow("Updated #{baseline.path}").green.bright unless json_mode
161
+ end
162
+
163
+ # Output JSON if requested
164
+ if json_mode
165
+ require "json"
166
+
167
+ total = results.values.flat_map(&:values).flatten.size
168
+ by_strategy = results.transform_values { |files| files.values.flatten.size }
79
169
 
80
- scanner = Keela::Scanner.new(strategy: strategy)
81
- success &&= scanner.run(force_report: options[:force_report], update_baseline: options[:update_baseline])
170
+ output = {
171
+ strategies: strategies.map(&:name),
172
+ unused: results.reject { |_, v| v.empty? },
173
+ summary: {
174
+ total: total,
175
+ by_strategy: by_strategy.reject { |_, v| v.zero? }
176
+ }
177
+ }
82
178
 
83
- puts if strategies.size > 1 && index < strategies.size - 1
179
+ puts JSON.pretty_generate(output)
84
180
  end
85
181
 
86
182
  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,26 @@ 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
+ DEFAULT_EXCLUDED_PATHS = [".keela/excluded.yml", "keela_excluded.yml"].freeze
11
+ DEFAULT_BASELINE_PATHS = [".keela/baseline.yml", "keela_baseline.yml"].freeze
12
+
13
+ def initialize(strategy:, configuration: Keela.configuration, baseline: nil)
11
14
  @strategy = strategy
12
15
  @configuration = configuration
16
+ @baseline = baseline || Baseline.new(resolve_baseline_path)
13
17
  @source_files = {}
14
18
  @unused_collection = Hash.new { |hash, key| hash[key] = [] }
15
19
  @new_unused = []
16
20
  @removed = []
17
21
  end
18
22
 
19
- def run(force_report: false, update_baseline: false)
23
+ def run(force_report: false, update_baseline: false, silent: false)
20
24
  return true unless should_run?
21
25
 
26
+ validate_configuration!
27
+
22
28
  start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
23
29
 
24
30
  load_source_files
@@ -26,60 +32,96 @@ module Keela
26
32
  definitions = filter_excluded(definitions)
27
33
 
28
34
  # Determine mode: report if forced, updating baseline, or no baseline exists
29
- report_mode = force_report || update_baseline || !baseline_exists?
35
+ report_mode = force_report || update_baseline || !baseline.exists?
30
36
 
31
- find_unused(definitions, show_progress: report_mode)
37
+ find_unused(definitions, show_progress: report_mode && !silent && configuration.show_progress)
32
38
 
33
39
  if report_mode
34
40
  elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
35
- reporter.print_full_report(unused_collection, elapsed)
36
- write_baseline_file if update_baseline
41
+ reporter.print_full_report(unused_collection, elapsed) unless silent
42
+ if update_baseline
43
+ baseline.set(strategy.name, unused_collection)
44
+ # Note: caller is responsible for calling baseline.save after all strategies run
45
+ end
37
46
  return true
38
47
  end
39
48
 
40
49
  # Baseline mode: compare against known unused code
41
50
  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
- )
51
+ unless silent
52
+ reporter.print_diff_report(
53
+ new_unused,
54
+ removed,
55
+ excluded_path: resolve_excluded_path || ".keela/excluded.yml",
56
+ baseline_path: baseline.path
57
+ )
58
+ end
48
59
 
49
60
  new_unused.empty? && removed.empty?
50
61
  end
51
62
 
52
63
  def file_globs
64
+ all_patterns = configuration.directory_patterns + configuration.include_patterns
65
+
53
66
  configuration.extensions.flat_map do |ext|
54
- configuration.directory_patterns.map { |pattern| format(pattern, ext: ext) }
67
+ all_patterns.map { |pattern| format(pattern, ext: ext) }
55
68
  end
56
69
  end
57
70
 
58
71
  private
59
72
 
60
- def baseline_exists?
61
- configuration.baseline_path && File.exist?(configuration.baseline_path)
62
- end
63
-
64
73
  def should_run?
65
74
  return true unless configuration.required_directory
66
75
 
67
76
  Dir.exist?(configuration.required_directory)
68
77
  end
69
78
 
79
+ def validate_configuration!
80
+ custom_directory_patterns = configuration.directory_patterns != default_directory_patterns
81
+ has_include_patterns = !configuration.include_patterns.empty?
82
+ has_exclude_patterns = !configuration.exclude_patterns.empty?
83
+
84
+ return unless custom_directory_patterns && (has_include_patterns || has_exclude_patterns)
85
+
86
+ raise ConfigurationError,
87
+ "Cannot use include_patterns or exclude_patterns with custom directory_patterns. " \
88
+ "Use directory_patterns for full control, OR use include/exclude to tweak the defaults."
89
+ end
90
+
91
+ def default_directory_patterns
92
+ Keela::Configuration.new.directory_patterns
93
+ end
94
+
70
95
  def reporter
71
96
  @reporter ||= Reporter.new(strategy.name)
72
97
  end
73
98
 
74
99
  def load_source_files
75
100
  Dir.glob(file_globs).each do |filename|
101
+ next if excluded_file?(filename)
102
+
76
103
  @source_files[filename] = File.readlines(filename)
77
104
  end
78
105
  end
79
106
 
107
+ def excluded_file?(filename)
108
+ return false if configuration.exclude_patterns.empty?
109
+
110
+ configuration.exclude_patterns.any? do |pattern|
111
+ File.fnmatch?(pattern, filename, File::FNM_PATHNAME | File::FNM_EXTGLOB)
112
+ end
113
+ end
114
+
80
115
  def find_definitions
81
116
  source_files.keys.grep(strategy.definition_file_pattern).flat_map do |filename|
82
- source_files[filename].flat_map do |line|
117
+ lines = source_files[filename]
118
+
119
+ # Allow strategies to override file parsing (e.g., for YAML files)
120
+ custom_definitions = strategy.extract_definitions_from_file(filename, lines)
121
+ next custom_definitions if custom_definitions
122
+
123
+ # Default: line-by-line parsing
124
+ lines.flat_map do |line|
83
125
  next [] if strategy.skip_comments? && line.strip.start_with?("#")
84
126
 
85
127
  name = strategy.extract_definition(line)
@@ -89,10 +131,10 @@ module Keela
89
131
  end
90
132
 
91
133
  def filter_excluded(definitions)
92
- return definitions unless configuration.excluded_path
93
- return definitions unless File.exist?(configuration.excluded_path)
134
+ path = resolve_excluded_path
135
+ return definitions unless path
94
136
 
95
- excluded = YAML.load_file(configuration.excluded_path, symbolize_names: true) || {}
137
+ excluded = YAML.load_file(path, symbolize_names: true) || {}
96
138
 
97
139
  definitions.reject do |h|
98
140
  excluded_for_file = excluded[h[:file].to_sym]
@@ -100,6 +142,20 @@ module Keela
100
142
  end
101
143
  end
102
144
 
145
+ def resolve_excluded_path
146
+ return configuration.excluded_path if configuration.excluded_path && File.exist?(configuration.excluded_path)
147
+
148
+ # Check default locations in order of preference
149
+ DEFAULT_EXCLUDED_PATHS.find { |path| File.exist?(path) }
150
+ end
151
+
152
+ def resolve_baseline_path
153
+ return configuration.baseline_path if configuration.baseline_path && File.exist?(configuration.baseline_path)
154
+
155
+ # Check default locations in order of preference
156
+ DEFAULT_BASELINE_PATHS.find { |path| File.exist?(path) }
157
+ end
158
+
103
159
  def find_unused(definitions, show_progress: false)
104
160
  source_code = source_files.values.flatten.join
105
161
 
@@ -116,36 +172,13 @@ module Keela
116
172
  end
117
173
 
118
174
  def compare_with_baseline
119
- baseline = YAML.load_file(configuration.baseline_path) || {}
120
- baseline_items = baseline.flat_map { |f, names| [f].product(names) }
175
+ baseline_for_strategy = baseline.get(strategy.name)
176
+ baseline_items = baseline_for_strategy.flat_map { |f, names| [f].product(names) }
121
177
 
122
178
  current_items = unused_collection.flat_map { |f, names| [f].product(names) }
123
179
 
124
180
  @new_unused = current_items - baseline_items
125
181
  @removed = baseline_items - current_items
126
182
  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
183
  end
151
184
  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.1"
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.1
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: []