keela 0.2.3 → 0.4.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: 47038e1bf08a377dba99ae8fe91134d51726c3ee20f12a24083ee4f6ecd421db
4
- data.tar.gz: 429275504d8993d585671dde3cb8b09f374aa315080667d3272958656a38fff3
3
+ metadata.gz: 3448ca0297d2fb01cbe430bf8d1686ae28871bfa36666815120241664ed95821
4
+ data.tar.gz: 05a96216af6eea62b31ee0e6d6ceb8e82326946ff8eecd35f3eeb07637d220d8
5
5
  SHA512:
6
- metadata.gz: 146b50c0ec50be23c122a63481ba8a6185234dc9234577530178753346f1da8f30bccbecfc4d414a014bb056bf1d2adbcbb80531391ebb7f1185a266497b9ffc
7
- data.tar.gz: d3250b7c810ae53389e0a18551d1c408f7218b1437f5047b76bc36e36e4fdf069d1166cd6e0ac64356366278ed73b986d0e511ad1cbf531814bdab3093fbf233
6
+ metadata.gz: fc7d5f1e2f1844dc9f67d51e96de17b23e3d040ce767b51529d23c1619f96ae86ea1bf82a028e2e09cea4d074fcba001dab20b1a29725f54f71c366b68a2de17
7
+ data.tar.gz: 177309a39a1fa56d97d6a3693ff674cd94b92cb81e00ea50a6b889823e4d68fd2fdf0601c7d7157e10f48d50f66f940bb92a695808b424d30f4b5ee14bd22623
data/CHANGELOG.md CHANGED
@@ -7,6 +7,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.4.0] - 2026-08-14
11
+
12
+ ### Added
13
+
14
+ - **Verbose mode** via `--verbose` flag to show files being scanned, glob patterns, and configuration for debugging ([#53](https://github.com/kerrizor/keela/pull/53))
15
+ - **Source location** via `--source-location` flag to show line numbers in reports for easier navigation ([#55](https://github.com/kerrizor/keela/pull/55))
16
+ - **Exclusion validation** via `--test-exclusions` flag to find stale entries in exclusion files ([#56](https://github.com/kerrizor/keela/pull/56))
17
+ - **TOON output format** via `--format toon` for token-efficient LLM-friendly output ([#58](https://github.com/kerrizor/keela/pull/58))
18
+ - **Configurable definition paths** per strategy via `strategies.<name>.definition_paths` in config file ([#59](https://github.com/kerrizor/keela/pull/59))
19
+
20
+ ### Changed
21
+
22
+ - **`--quiet` now suppresses all output**, not just the progress bar. Use exit code for success/failure in scripts. ([#54](https://github.com/kerrizor/keela/pull/54))
23
+
24
+ ### Fixed
25
+
26
+ - Multi-method delegate declarations now detect all methods, not just the first ([#51](https://github.com/kerrizor/keela/pull/51))
27
+ - Class methods (`def self.foo`) are now correctly detected as unused ([#60](https://github.com/kerrizor/keela/pull/60))
28
+
29
+ ## [0.3.0] - 2026-08-05
30
+
31
+ ### Added
32
+
33
+ - **Strategy-aware exclusion file format** - Exclusion files can now be organized by strategy (methods, scopes, etc.) to match the baseline file format. This allows excluding the same name in one strategy while flagging it in another. The legacy flat format is still supported for backward compatibility. ([#35](https://github.com/kerrizor/keela/pull/35))
34
+
35
+ ### Changed
36
+
37
+ - **Performance: Share source files across strategies** - When scanning multiple strategies, source files are now loaded once and shared, reducing scan time by ~33% on large codebases. ([#37](https://github.com/kerrizor/keela/pull/37))
38
+
39
+ ### Fixed
40
+
41
+ - CLI options now correctly override config file settings ([#36](https://github.com/kerrizor/keela/pull/36))
42
+
10
43
  ## [0.2.3] - 2026-07-31
11
44
 
12
45
  ### Fixed
data/README.md CHANGED
@@ -66,6 +66,27 @@ If a `.keela_baseline.yml` file exists, Keela compares the current scan against
66
66
 
67
67
  This lets you gradually pay down tech debt while preventing new dead code from sneaking in.
68
68
 
69
+ The baseline file is organized by strategy:
70
+
71
+ ```yaml
72
+ # .keela_baseline.yml
73
+ methods:
74
+ app/models/user.rb:
75
+ - legacy_method
76
+ - old_callback
77
+ app/helpers/application_helper.rb:
78
+ - unused_helper
79
+ scopes:
80
+ app/models/user.rb:
81
+ - inactive
82
+ - archived
83
+ constants:
84
+ app/models/user.rb:
85
+ - OLD_STATUS
86
+ ```
87
+
88
+ **Note:** The baseline file only stores names, not line numbers. This prevents false positives when code moves within a file.
89
+
69
90
  ### Report Mode
70
91
 
71
92
  If no baseline exists (or you use `--report`), Keela shows all unused code:
@@ -74,6 +95,32 @@ If no baseline exists (or you use `--report`), Keela shows all unused code:
74
95
  keela --report
75
96
  ```
76
97
 
98
+ ## What "Unused" Means
99
+
100
+ Keela defines "unused" as **unused in production code** — not just unused anywhere.
101
+
102
+ By default, Keela scans `app/`, `lib/`, and `config/` directories. It intentionally
103
+ excludes `spec/` and `test/` directories because:
104
+
105
+ 1. **Tests aren't usage** — Code that only exists to be tested isn't providing
106
+ application value. If you delete the unused code, you delete its tests too.
107
+
108
+ 2. **Tests can cover dead code** — A method with 100% test coverage can still be
109
+ dead if nothing in the application calls it.
110
+
111
+ 3. **Cleaner signal** — Including test files would hide genuinely unused code
112
+ behind "but it has tests!" false negatives.
113
+
114
+ If Keela flags something that's only used in tests, consider whether the code
115
+ (and its tests) can be removed entirely. If the code is intentionally test-only
116
+ (e.g., test helpers defined in `app/`), add it to your exclusion file.
117
+
118
+ To include test directories in usage scanning (not recommended), use `--include`:
119
+
120
+ ```bash
121
+ keela --include 'test/**/*.rb' --include 'spec/**/*.rb'
122
+ ```
123
+
77
124
  ## Command Line Options
78
125
 
79
126
  ```bash
@@ -114,10 +161,19 @@ keela --config path/to/keela.yml
114
161
  # Output as JSON (for CI integrations)
115
162
  keela --format json
116
163
 
117
- # Suppress progress bar (useful for CI and scripting)
164
+ # Suppress all output (useful for CI and scripting, rely on exit code)
118
165
  keela --quiet
119
166
  keela -q
120
167
 
168
+ # Show verbose debugging output (files scanned, patterns used)
169
+ keela --verbose
170
+
171
+ # Show source location (file:line) for each unused item
172
+ keela --source-location
173
+
174
+ # Validate exclusion file entries (find stale exclusions)
175
+ keela --test-exclusions
176
+
121
177
  # Show version
122
178
  keela --version
123
179
  ```
@@ -170,10 +226,39 @@ include_patterns:
170
226
 
171
227
  excluded_path: ".keela_excluded.yml"
172
228
  baseline_path: ".keela_baseline.yml"
229
+
230
+ # Per-strategy configuration
231
+ strategies:
232
+ methods:
233
+ definition_paths:
234
+ - app/helpers
235
+ - app/models
236
+ - lib/
173
237
  ```
174
238
 
175
239
  Keela automatically loads `keela.yml` or `.keela.yml` from the current directory. Use `--config` to specify a different path.
176
240
 
241
+ ### Customizing Definition Paths Per Strategy
242
+
243
+ By default, each strategy looks for definitions in specific directories (e.g., `methods` looks in `app/helpers` and `app/models`). You can customize this per-strategy:
244
+
245
+ ```yaml
246
+ # keela.yml
247
+ strategies:
248
+ methods:
249
+ definition_paths:
250
+ - app/helpers
251
+ - app/models
252
+ - lib/
253
+ - ee/app/models
254
+ scopes:
255
+ definition_paths:
256
+ - app/models
257
+ - ee/app/models
258
+ ```
259
+
260
+ This is useful when your project has code in non-standard locations (like `lib/` or enterprise edition directories) that you want Keela to check for unused definitions.
261
+
177
262
  ### Customizing Which Files to Scan
178
263
 
179
264
  There are two approaches:
@@ -238,14 +323,17 @@ The workflow:
238
323
  2. **CI runs**: `keela` compares against baseline, fails on new dead code
239
324
  3. **After cleanup**: Run `keela --update-baseline` to update the baseline
240
325
 
241
- ### JSON Output
326
+ ### Structured Output Formats
242
327
 
243
- Use `--format json` for machine-readable output:
328
+ Use `--format` for machine-readable output:
244
329
 
245
330
  ```bash
246
- keela --format json --report
331
+ keela --format json --report # JSON output
332
+ keela --format toon --report # TOON output (token-efficient for LLMs)
247
333
  ```
248
334
 
335
+ #### JSON
336
+
249
337
  ```json
250
338
  {
251
339
  "strategies": ["methods", "scopes"],
@@ -267,25 +355,68 @@ keela --format json --report
267
355
  }
268
356
  ```
269
357
 
270
- This is useful for integrating with other tools, generating reports, or processing results programmatically.
358
+ #### TOON
359
+
360
+ [TOON (Token-Oriented Object Notation)](https://toonformat.dev/) is a compact format optimized for LLM prompts, using ~40-50% fewer tokens than JSON:
361
+
362
+ ```
363
+ strategies[2]: methods,scopes
364
+ unused:
365
+ methods:
366
+ "app/models/user.rb"[2]: unused_method,old_helper
367
+ scopes:
368
+ "app/models/post.rb"[1]: inactive
369
+ summary:
370
+ total: 3
371
+ by_strategy:
372
+ methods: 2
373
+ scopes: 1
374
+ ```
375
+
376
+ These formats are useful for integrating with other tools, generating reports, processing results programmatically, or including in LLM context windows.
271
377
 
272
378
  ## Exclusion File
273
379
 
274
- Some code appears unused but is actually called dynamically. Exclude it:
380
+ Some code appears unused but is actually called dynamically. Exclude it using the **strategy-aware format** (recommended):
275
381
 
276
382
  ```yaml
277
- # .keela_excluded.yml
383
+ # .keela/excluded.yml (strategy-aware format)
384
+ methods:
385
+ app/models/user.rb:
386
+ - legacy_method: "Called via metaprogramming"
387
+ - callback_method: "Used as ActiveRecord callback"
388
+ app/helpers/application_helper.rb:
389
+ - helper_method: "Called from views dynamically"
390
+ scopes:
391
+ app/models/user.rb:
392
+ - active: "Called dynamically via send()"
393
+ ```
394
+
395
+ This format mirrors the baseline file structure and allows you to exclude the same name in one strategy while flagging it in another. For example, if you have both `scope :active` and `def active` in the same file, you can exclude them independently.
396
+
397
+ ### Legacy Flat Format
398
+
399
+ The flat format is still supported for backward compatibility:
400
+
401
+ ```yaml
402
+ # .keela_excluded.yml (legacy flat format)
278
403
  app/models/user.rb:
279
404
  - legacy_method: "Called via metaprogramming"
280
405
  - callback_method: "Used as ActiveRecord callback"
281
- app/helpers/application_helper.rb:
282
- - helper_method: "Called from views dynamically"
283
406
  ```
284
407
 
285
- Then run with:
408
+ **Note:** With the flat format, an exclusion applies to ALL strategies. If you exclude `active`, both the method and scope named `active` will be excluded.
409
+
410
+ ### Usage
286
411
 
287
412
  ```bash
288
- keela --excluded .keela_excluded.yml
413
+ keela --excluded .keela/excluded.yml
414
+ ```
415
+
416
+ Or configure in `keela.yml`:
417
+
418
+ ```yaml
419
+ excluded_path: ".keela/excluded.yml"
289
420
  ```
290
421
 
291
422
  ## Ruby API
data/exe/keela CHANGED
@@ -13,6 +13,9 @@ options = {
13
13
  quiet: false
14
14
  }
15
15
 
16
+ # Store CLI overrides separately so they can be applied after config file loads
17
+ cli_overrides = {}
18
+
16
19
  OptionParser.new do |opts|
17
20
  opts.banner = "Usage: keela [options]"
18
21
 
@@ -40,28 +43,30 @@ OptionParser.new do |opts|
40
43
  options[:update_baseline] = true
41
44
  end
42
45
 
43
- opts.on("--format FORMAT", %i[text json], "Output format: text (default) or json") do |format|
46
+ opts.on("--format FORMAT", %i[text json toon], "Output format: text (default), json, or toon") do |format|
44
47
  options[:format] = format
45
48
  end
46
49
 
47
50
  opts.on("--excluded PATH", "Path to YAML file of excluded items") do |path|
48
- Keela.configuration.excluded_path = path
51
+ cli_overrides[:excluded_path] = path
49
52
  end
50
53
 
51
54
  opts.on("--baseline PATH", "Path to YAML baseline file (default: .keela_baseline.yml)") do |path|
52
- Keela.configuration.baseline_path = path
55
+ cli_overrides[:baseline_path] = path
53
56
  end
54
57
 
55
58
  opts.on("--extensions EXTS", "Comma-separated file extensions to scan (default: rb,haml,erb)") do |exts|
56
- Keela.configuration.extensions = exts.split(",").map(&:strip)
59
+ cli_overrides[:extensions] = exts.split(",").map(&:strip)
57
60
  end
58
61
 
59
62
  opts.on("--exclude PATTERN", "Glob pattern for files to exclude (can be used multiple times)") do |pattern|
60
- Keela.configuration.exclude_patterns << pattern
63
+ cli_overrides[:exclude_patterns] ||= []
64
+ cli_overrides[:exclude_patterns] << pattern
61
65
  end
62
66
 
63
67
  opts.on("--include PATTERN", "Additional glob pattern to scan (can be used multiple times)") do |pattern|
64
- Keela.configuration.include_patterns << pattern
68
+ cli_overrides[:include_patterns] ||= []
69
+ cli_overrides[:include_patterns] << pattern
65
70
  end
66
71
 
67
72
  opts.on("--config PATH", "-c", "Path to config file (default: keela.yml or .keela.yml)") do |path|
@@ -77,6 +82,18 @@ OptionParser.new do |opts|
77
82
  options[:quiet] = true
78
83
  end
79
84
 
85
+ opts.on("--verbose", "Show detailed debugging output (files scanned, patterns used)") do
86
+ options[:verbose] = true
87
+ end
88
+
89
+ opts.on("--source-location", "Show file:line for each unused item in reports") do
90
+ options[:source_location] = true
91
+ end
92
+
93
+ opts.on("--test-exclusions", "Validate that exclusion file entries match actual definitions") do
94
+ options[:test_exclusions] = true
95
+ end
96
+
80
97
  opts.on("-h", "--help", "Show this help") do
81
98
  puts opts
82
99
  exit
@@ -84,15 +101,40 @@ OptionParser.new do |opts|
84
101
  end.parse!
85
102
 
86
103
  # Load config file (keela.yml or .keela.yml) if present
87
- # CLI options override config file settings
88
104
  Keela::ConfigFile.load(path: options[:config_path])
89
105
 
106
+ # Apply CLI overrides (these take precedence over config file)
107
+ cli_overrides.each do |key, value|
108
+ Keela.configuration.public_send("#{key}=", value)
109
+ end
110
+
90
111
  # Apply quiet mode if requested
91
112
  Keela.configuration.show_progress = false if options[:quiet]
92
113
 
114
+ # Apply verbose mode if requested
115
+ Keela.configuration.verbose = true if options[:verbose]
116
+
117
+ # Apply source location mode if requested
118
+ Keela.configuration.source_location = true if options[:source_location]
119
+
93
120
  # Set default baseline path if not specified
94
121
  Keela.configuration.baseline_path ||= ".keela_baseline.yml"
95
122
 
123
+ # Handle --test-exclusions mode
124
+ if options[:test_exclusions]
125
+ excluded_path = Keela.configuration.excluded_path ||
126
+ Keela::Scanner::DEFAULT_EXCLUDED_PATHS.find { |p| File.exist?(p) }
127
+
128
+ unless excluded_path
129
+ warn "Error: No exclusion file found. Specify with --excluded PATH"
130
+ exit 1
131
+ end
132
+
133
+ validator = Keela::ExclusionValidator.new(excluded_path)
134
+ success = validator.validate
135
+ exit(success ? 0 : 1)
136
+ end
137
+
96
138
  STRATEGY_MAP = {
97
139
  methods: Keela::Strategies::Methods,
98
140
  scopes: Keela::Strategies::Scopes,
@@ -129,27 +171,63 @@ strategies = build_strategies(options[:types])
129
171
  # Share a single baseline across all strategies
130
172
  baseline = Keela::Baseline.new(Keela.configuration.baseline_path)
131
173
 
174
+ # Load source files once and share across all strategies
175
+ source_files = Keela::Scanner.load_source_files
176
+
177
+ # Show verbose output if requested
178
+ if options[:verbose]
179
+ config = Keela.configuration
180
+ globs = Keela::Scanner.build_file_globs(config)
181
+
182
+ puts Rainbow("=== Verbose Mode ===").magenta.bright
183
+ puts
184
+ puts Rainbow("Configuration:").yellow
185
+ puts " Extensions: #{config.extensions.join(', ')}"
186
+ puts " Directory patterns: #{config.directory_patterns.join(', ')}"
187
+ puts " Include patterns: #{config.include_patterns.empty? ? '(none)' : config.include_patterns.join(', ')}"
188
+ puts " Exclude patterns: #{config.exclude_patterns.empty? ? '(none)' : config.exclude_patterns.join(', ')}"
189
+ puts
190
+ puts Rainbow("Resolved globs:").yellow
191
+ globs.each { |g| puts " #{g}" }
192
+ puts
193
+ sorted_files = source_files.keys.sort
194
+ file_count = sorted_files.size
195
+ max_display = 50
196
+
197
+ puts Rainbow("Files to scan (#{file_count}):").yellow
198
+ # Show all files when piping (not a TTY), truncate for interactive use
199
+ if file_count <= max_display || !$stdout.tty?
200
+ sorted_files.each { |f| puts " #{f}" }
201
+ else
202
+ sorted_files.first(max_display).each { |f| puts " #{f}" }
203
+ puts " ... and #{file_count - max_display} more files"
204
+ puts " (pipe output to see full list: keela --verbose > files.txt)"
205
+ end
206
+ puts
207
+ end
208
+
132
209
  success = true
133
210
  results = {}
134
211
 
135
- json_mode = options[:format] == :json
212
+ structured_output = Keela::Formatters.structured_format?(options[:format])
213
+ silent_output = structured_output || options[:quiet]
136
214
 
137
215
  strategies.each_with_index do |strategy, index|
138
- unless json_mode
216
+ unless silent_output
139
217
  puts Rainbow("=== Sniffing for unused #{strategy.name} ===").cyan.bright if strategies.size > 1
140
218
  end
141
219
 
142
- scanner = Keela::Scanner.new(strategy: strategy, baseline: baseline)
220
+ scanner = Keela::Scanner.new(strategy: strategy, baseline: baseline, source_files: source_files)
143
221
  success &&= scanner.run(
144
222
  force_report: options[:force_report],
145
223
  update_baseline: options[:update_baseline],
146
- silent: json_mode
224
+ silent: silent_output
147
225
  )
148
226
 
149
- # Collect results for JSON output
227
+ # Collect results for structured output formats
150
228
  results[strategy.name] = scanner.unused_collection.transform_values(&:to_a)
151
229
 
152
- unless json_mode
230
+ unless silent_output
153
231
  puts if strategies.size > 1 && index < strategies.size - 1
154
232
  end
155
233
  end
@@ -157,26 +235,14 @@ end
157
235
  # Save baseline after all strategies have run
158
236
  if options[:update_baseline]
159
237
  baseline.save
160
- puts Rainbow("Updated #{baseline.path}").green.bright unless json_mode
238
+ puts Rainbow("Updated #{baseline.path}").green.bright unless silent_output
161
239
  end
162
240
 
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 }
169
-
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
- }
178
-
179
- puts JSON.pretty_generate(output)
241
+ # Output structured format if requested
242
+ if structured_output
243
+ formatter_class = Keela::Formatters.for(options[:format])
244
+ formatter = formatter_class.new(results: results, strategies: strategies)
245
+ puts formatter.format
180
246
  end
181
247
 
182
248
  exit(success ? 0 : 1)
@@ -17,6 +17,7 @@ module Keela
17
17
  # - excluded_path: Path to YAML file of excluded items
18
18
  # - baseline_path: Path to baseline YAML file
19
19
  # - required_directory: Directory that must exist for scanning to proceed
20
+ # - strategies: Per-strategy configuration (see below)
20
21
  #
21
22
  # Example:
22
23
  # # keela.yml
@@ -29,6 +30,15 @@ module Keela
29
30
  # - rb
30
31
  # - haml
31
32
  # - erb
33
+ # strategies:
34
+ # methods:
35
+ # definition_paths:
36
+ # - app/helpers
37
+ # - app/models
38
+ # - lib/
39
+ # scopes:
40
+ # definition_paths:
41
+ # - app/models
32
42
  #
33
43
  module ConfigFile
34
44
  CONFIG_FILENAMES = %w[.keela/config.yml keela.yml .keela.yml].freeze
@@ -74,6 +84,15 @@ module Keela
74
84
  value = config[key]
75
85
  configuration.public_send("#{key}=", value)
76
86
  end
87
+
88
+ apply_strategy_options(config["strategies"]) if config.key?("strategies")
89
+ end
90
+
91
+ def apply_strategy_options(strategies)
92
+ return unless strategies.is_a?(Hash)
93
+
94
+ configuration = Keela.configuration
95
+ configuration.strategy_options = strategies
77
96
  end
78
97
  end
79
98
  end
@@ -26,6 +26,18 @@ module Keela
26
26
  # Additional directory patterns to include (added to directory_patterns)
27
27
  attr_accessor :include_patterns
28
28
 
29
+ # Whether to show verbose debugging output
30
+ attr_accessor :verbose
31
+
32
+ # Whether to show source location (file:line) in reports
33
+ attr_accessor :source_location
34
+
35
+ # Per-strategy configuration options
36
+ # Hash of strategy_name => { option => value }
37
+ # Supported options:
38
+ # - definition_file_pattern: Regex pattern string for files containing definitions
39
+ attr_accessor :strategy_options
40
+
29
41
  def initialize
30
42
  @extensions = %w[rb haml erb].freeze
31
43
  @directory_patterns = %w[
@@ -39,6 +51,14 @@ module Keela
39
51
  @show_progress = true
40
52
  @exclude_patterns = []
41
53
  @include_patterns = []
54
+ @verbose = false
55
+ @source_location = false
56
+ @strategy_options = {}
57
+ end
58
+
59
+ # Get options for a specific strategy
60
+ def options_for(strategy_name)
61
+ strategy_options[strategy_name.to_s] || {}
42
62
  end
43
63
  end
44
64
  end
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "rainbow"
5
+
6
+ module Keela
7
+ class ExclusionValidator
8
+ STRATEGY_MAP = {
9
+ methods: Strategies::Methods,
10
+ scopes: Strategies::Scopes,
11
+ constants: Strategies::Constants,
12
+ delegations: Strategies::Delegations,
13
+ attributes: Strategies::Attributes,
14
+ i18n_keys: Strategies::I18nKeys
15
+ }.freeze
16
+
17
+ attr_reader :excluded_path, :results
18
+
19
+ def initialize(excluded_path)
20
+ @excluded_path = excluded_path
21
+ @results = { valid: [], stale: [] }
22
+ end
23
+
24
+ def validate
25
+ unless File.exist?(excluded_path)
26
+ puts Rainbow("Exclusion file not found: #{excluded_path}").red
27
+ return false
28
+ end
29
+
30
+ all_excluded = YAML.load_file(excluded_path, symbolize_names: true) || {}
31
+
32
+ if all_excluded.empty?
33
+ puts Rainbow("Exclusion file is empty: #{excluded_path}").yellow
34
+ return true
35
+ end
36
+
37
+ puts "Validating exclusions in #{excluded_path}...\n\n"
38
+
39
+ # Detect format: strategy-aware or legacy flat
40
+ if strategy_aware_format?(all_excluded)
41
+ validate_strategy_aware(all_excluded)
42
+ else
43
+ validate_legacy_flat(all_excluded)
44
+ end
45
+
46
+ print_results
47
+ results[:stale].empty?
48
+ end
49
+
50
+ private
51
+
52
+ def strategy_aware_format?(data)
53
+ data.keys.any? { |k| STRATEGY_MAP.key?(k) }
54
+ end
55
+
56
+ def validate_strategy_aware(all_excluded)
57
+ all_excluded.each do |strategy_name, files|
58
+ next unless STRATEGY_MAP.key?(strategy_name)
59
+
60
+ strategy = STRATEGY_MAP[strategy_name].new
61
+ validate_files(strategy, strategy_name, files || {})
62
+ end
63
+ end
64
+
65
+ def validate_legacy_flat(all_excluded)
66
+ # Legacy format applies to all strategies, but we'll check against methods
67
+ strategy = Strategies::Methods.new
68
+ validate_files(strategy, :methods, all_excluded)
69
+ end
70
+
71
+ def validate_files(strategy, strategy_name, files)
72
+ files.each do |file_path, entries|
73
+ file_str = file_path.to_s
74
+ entries ||= []
75
+
76
+ entries.each do |entry|
77
+ name = entry.keys.first.to_s
78
+ reason = entry.values.first
79
+
80
+ if !File.exist?(file_str)
81
+ results[:stale] << {
82
+ strategy: strategy_name,
83
+ file: file_str,
84
+ name: name,
85
+ reason: reason,
86
+ error: "file not found"
87
+ }
88
+ elsif !definition_exists?(strategy, file_str, name)
89
+ results[:stale] << {
90
+ strategy: strategy_name,
91
+ file: file_str,
92
+ name: name,
93
+ reason: reason,
94
+ error: "no definition found"
95
+ }
96
+ else
97
+ results[:valid] << {
98
+ strategy: strategy_name,
99
+ file: file_str,
100
+ name: name,
101
+ reason: reason
102
+ }
103
+ end
104
+ end
105
+ end
106
+ end
107
+
108
+ def definition_exists?(strategy, file_path, name)
109
+ return false unless File.exist?(file_path)
110
+
111
+ lines = File.readlines(file_path)
112
+
113
+ # Check custom extraction first (for I18n YAML files)
114
+ custom_defs = strategy.extract_definitions_from_file(file_path, lines)
115
+ if custom_defs
116
+ return custom_defs.any? { |d| d[:name] == name }
117
+ end
118
+
119
+ # Default line-by-line extraction
120
+ lines.any? do |line|
121
+ next if strategy.skip_comments? && line.strip.start_with?("#")
122
+
123
+ result = strategy.extract_definition(line)
124
+ Array(result).compact.include?(name)
125
+ end
126
+ end
127
+
128
+ def print_results
129
+ if results[:valid].any?
130
+ puts Rainbow("✅ Valid exclusions (#{results[:valid].size}):").green.bright
131
+ group_by_strategy(results[:valid]).each do |strategy, entries|
132
+ puts " #{strategy}:"
133
+ entries.each do |e|
134
+ puts " #{e[:file]}:#{e[:name]}"
135
+ end
136
+ end
137
+ puts
138
+ end
139
+
140
+ if results[:stale].any?
141
+ puts Rainbow("⚠️ Stale exclusions found (#{results[:stale].size}):").yellow.bright
142
+ puts
143
+ group_by_strategy(results[:stale]).each do |strategy, entries|
144
+ puts " #{strategy}:"
145
+ entries.each do |e|
146
+ puts " #{e[:file]}:#{e[:name]} — #{e[:error]}"
147
+ end
148
+ end
149
+ puts
150
+ puts "Consider removing stale exclusions from #{excluded_path}"
151
+ else
152
+ puts Rainbow("All exclusions are valid!").green.bright
153
+ end
154
+ end
155
+
156
+ def group_by_strategy(entries)
157
+ entries.group_by { |e| e[:strategy] }
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Keela
4
+ module Formatters
5
+ class Base
6
+ attr_reader :results, :strategies
7
+
8
+ def initialize(results:, strategies:)
9
+ @results = results
10
+ @strategies = strategies
11
+ end
12
+
13
+ def format
14
+ raise NotImplementedError, "Subclasses must implement #format"
15
+ end
16
+
17
+ private
18
+
19
+ def total_count
20
+ results.values.flat_map(&:values).flatten.size
21
+ end
22
+
23
+ def by_strategy_counts
24
+ results.transform_values { |files| files.values.flatten.size }
25
+ .reject { |_, v| v.zero? }
26
+ end
27
+
28
+ def non_empty_results
29
+ results.reject { |_, v| v.empty? }
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Keela
6
+ module Formatters
7
+ class Json < Base
8
+ def format
9
+ output = {
10
+ strategies: strategies.map(&:name),
11
+ unused: non_empty_results,
12
+ summary: {
13
+ total: total_count,
14
+ by_strategy: by_strategy_counts
15
+ }
16
+ }
17
+
18
+ JSON.pretty_generate(output)
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "toon"
4
+
5
+ module Keela
6
+ module Formatters
7
+ class Toon < Base
8
+ def format
9
+ output = {
10
+ "strategies" => strategies.map(&:name),
11
+ "unused" => non_empty_results.transform_keys(&:to_s),
12
+ "summary" => {
13
+ "total" => total_count,
14
+ "by_strategy" => by_strategy_counts.transform_keys(&:to_s)
15
+ }
16
+ }
17
+
18
+ ::Toon.encode(output)
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "formatters/base"
4
+ require_relative "formatters/json"
5
+ require_relative "formatters/toon"
6
+
7
+ module Keela
8
+ module Formatters
9
+ REGISTRY = {
10
+ json: Json,
11
+ toon: Toon
12
+ }.freeze
13
+
14
+ def self.for(format)
15
+ REGISTRY[format]
16
+ end
17
+
18
+ def self.structured_format?(format)
19
+ REGISTRY.key?(format)
20
+ end
21
+ end
22
+ end
@@ -11,12 +11,12 @@ module Keela
11
11
  @strategy_name = strategy_name
12
12
  end
13
13
 
14
- def print_full_report(unused_collection, elapsed_time)
14
+ def print_full_report(unused_collection, elapsed_time, source_locations: {})
15
15
  unused_count = unused_collection.values.flatten.size
16
16
 
17
17
  if unused_count > 0
18
18
  puts "\nFound #{unused_count} unused #{strategy_name}:\n\n"
19
- puts format_yaml(unused_collection)
19
+ puts format_report(unused_collection, source_locations)
20
20
  puts "\n"
21
21
  else
22
22
  puts Rainbow("No unused #{strategy_name} were found.").green.bright
@@ -25,8 +25,8 @@ module Keela
25
25
  puts "Finished in #{elapsed_time.round(2)} seconds."
26
26
  end
27
27
 
28
- def print_diff_report(new_unused, removed, excluded_path:, baseline_path:)
29
- print_new_unused(new_unused, excluded_path) unless new_unused.empty?
28
+ def print_diff_report(new_unused, removed, excluded_path:, baseline_path:, source_locations: {})
29
+ print_new_unused(new_unused, excluded_path, source_locations) unless new_unused.empty?
30
30
 
31
31
  if new_unused.size + removed.size > 0
32
32
  puts Rainbow("~" * 80).white.bright
@@ -36,13 +36,42 @@ module Keela
36
36
  print_removed(removed, baseline_path) unless removed.empty?
37
37
  end
38
38
 
39
+ def format_report(collection, source_locations = {})
40
+ if source_locations.empty?
41
+ format_yaml(collection)
42
+ else
43
+ format_with_locations(collection, source_locations)
44
+ end
45
+ end
46
+
39
47
  def format_yaml(collection)
40
48
  indent_yaml_list_items(collection.sort.to_h.to_yaml)
41
49
  end
42
50
 
43
51
  private
44
52
 
45
- def print_new_unused(new_unused, excluded_path)
53
+ def format_with_locations(collection, source_locations)
54
+ # Calculate max name length for alignment
55
+ all_names = collection.values.flatten
56
+ max_name_len = all_names.map(&:length).max || 0
57
+ padding = max_name_len + 6 # " - " prefix + 2 spaces
58
+
59
+ lines = ["---"]
60
+ collection.sort.each do |file, names|
61
+ lines << "#{file}:"
62
+ names.each do |name|
63
+ line_num = source_locations["#{file}:#{name}"]
64
+ if line_num
65
+ lines << " - #{name}".ljust(padding) + "#{file}:#{line_num}"
66
+ else
67
+ lines << " - #{name}"
68
+ end
69
+ end
70
+ end
71
+ lines.join("\n")
72
+ end
73
+
74
+ def print_new_unused(new_unused, excluded_path, source_locations = {})
46
75
  error = <<~MESSAGE
47
76
  We have detected #{new_unused.size} newly unused #{strategy_name}.
48
77
 
@@ -50,7 +79,7 @@ module Keela
50
79
  MESSAGE
51
80
 
52
81
  puts Rainbow(error).red.bright
53
- puts Rainbow(format_yaml(parse_diff(new_unused))).red.bright
82
+ puts Rainbow(format_report(parse_diff(new_unused), source_locations)).red.bright
54
83
  end
55
84
 
56
85
  def print_removed(removed, baseline_path)
data/lib/keela/scanner.rb CHANGED
@@ -5,17 +5,50 @@ require "yaml"
5
5
 
6
6
  module Keela
7
7
  class Scanner
8
- attr_reader :strategy, :configuration, :baseline, :source_files, :unused_collection, :new_unused, :removed
8
+ attr_reader :strategy, :configuration, :baseline, :source_files, :unused_collection, :source_locations, :new_unused, :removed
9
9
 
10
10
  DEFAULT_EXCLUDED_PATHS = [".keela/excluded.yml", "keela_excluded.yml"].freeze
11
11
  DEFAULT_BASELINE_PATHS = [".keela/baseline.yml", "keela_baseline.yml"].freeze
12
12
 
13
- def initialize(strategy:, configuration: Keela.configuration, baseline: nil)
13
+ # Load source files once for sharing across multiple Scanner instances.
14
+ # Returns a hash of { filename => [lines] }.
15
+ def self.load_source_files(configuration: Keela.configuration)
16
+ source_files = {}
17
+ file_globs = build_file_globs(configuration)
18
+
19
+ Dir.glob(file_globs).each do |filename|
20
+ next if excluded_file?(filename, configuration)
21
+
22
+ source_files[filename] = File.readlines(filename)
23
+ end
24
+
25
+ source_files
26
+ end
27
+
28
+ def self.build_file_globs(configuration)
29
+ all_patterns = configuration.directory_patterns + configuration.include_patterns
30
+
31
+ configuration.extensions.flat_map do |ext|
32
+ all_patterns.map { |pattern| format(pattern, ext: ext) }
33
+ end
34
+ end
35
+
36
+ def self.excluded_file?(filename, configuration)
37
+ return false if configuration.exclude_patterns.empty?
38
+
39
+ configuration.exclude_patterns.any? do |pattern|
40
+ File.fnmatch?(pattern, filename, File::FNM_PATHNAME | File::FNM_EXTGLOB)
41
+ end
42
+ end
43
+
44
+ def initialize(strategy:, configuration: Keela.configuration, baseline: nil, source_files: nil)
14
45
  @strategy = strategy
15
46
  @configuration = configuration
16
47
  @baseline = baseline || Baseline.new(resolve_baseline_path)
17
- @source_files = {}
48
+ @source_files = source_files || {}
49
+ @source_files_preloaded = !source_files.nil?
18
50
  @unused_collection = Hash.new { |hash, key| hash[key] = [] }
51
+ @source_locations = {} # { "file:name" => line_number }
19
52
  @new_unused = []
20
53
  @removed = []
21
54
  end
@@ -38,7 +71,7 @@ module Keela
38
71
 
39
72
  if report_mode
40
73
  elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
41
- reporter.print_full_report(unused_collection, elapsed) unless silent
74
+ reporter.print_full_report(unused_collection, elapsed, source_locations: source_locations) unless silent
42
75
  if update_baseline
43
76
  baseline.set(strategy.name, unused_collection)
44
77
  # Note: caller is responsible for calling baseline.save after all strategies run
@@ -53,7 +86,8 @@ module Keela
53
86
  new_unused,
54
87
  removed,
55
88
  excluded_path: resolve_excluded_path || ".keela/excluded.yml",
56
- baseline_path: baseline.path
89
+ baseline_path: baseline.path,
90
+ source_locations: source_locations
57
91
  )
58
92
  end
59
93
 
@@ -97,6 +131,8 @@ module Keela
97
131
  end
98
132
 
99
133
  def load_source_files
134
+ return if @source_files_preloaded
135
+
100
136
  Dir.glob(file_globs).each do |filename|
101
137
  next if excluded_file?(filename)
102
138
 
@@ -121,11 +157,17 @@ module Keela
121
157
  next custom_definitions if custom_definitions
122
158
 
123
159
  # Default: line-by-line parsing
124
- lines.flat_map do |line|
160
+ track_lines = configuration.source_location
161
+ lines.each_with_index.flat_map do |line, index|
125
162
  next [] if strategy.skip_comments? && line.strip.start_with?("#")
126
163
 
127
- name = strategy.extract_definition(line)
128
- name ? [{ name: name, file: filename }] : []
164
+ result = strategy.extract_definition(line)
165
+ # Support both single name (String) and multiple names (Array)
166
+ Array(result).compact.map do |name|
167
+ definition = { name: name, file: filename }
168
+ definition[:line] = index + 1 if track_lines
169
+ definition
170
+ end
129
171
  end
130
172
  end
131
173
  end
@@ -134,7 +176,16 @@ module Keela
134
176
  path = resolve_excluded_path
135
177
  return definitions unless path
136
178
 
137
- excluded = YAML.load_file(path, symbolize_names: true) || {}
179
+ all_excluded = YAML.load_file(path, symbolize_names: true) || {}
180
+
181
+ # Support both formats:
182
+ # 1. Strategy-aware (new): { methods: { "file.rb": [{ name: "reason" }] } }
183
+ # 2. Flat (legacy): { "file.rb": [{ name: "reason" }] }
184
+ excluded = if all_excluded.key?(strategy.name.to_sym)
185
+ all_excluded[strategy.name.to_sym] || {}
186
+ else
187
+ all_excluded # Legacy flat format
188
+ end
138
189
 
139
190
  definitions.reject do |h|
140
191
  excluded_for_file = excluded[h[:file].to_sym]
@@ -168,6 +219,9 @@ module Keela
168
219
 
169
220
  unused.each do |unused_def|
170
221
  @unused_collection[unused_def[:file]] << unused_def[:name]
222
+ if unused_def[:line]
223
+ @source_locations["#{unused_def[:file]}:#{unused_def[:name]}"] = unused_def[:line]
224
+ end
171
225
  end
172
226
  end
173
227
 
@@ -7,7 +7,7 @@ module Keela
7
7
  "attributes"
8
8
  end
9
9
 
10
- def definition_file_pattern
10
+ def default_definition_file_pattern
11
11
  # Match app/ and lib/ directories, but exclude spec/ and test/
12
12
  %r{(?:^|/)(?:ee/)?(?:app|lib)/}
13
13
  end
@@ -7,7 +7,7 @@ module Keela
7
7
  "constants"
8
8
  end
9
9
 
10
- def definition_file_pattern
10
+ def default_definition_file_pattern
11
11
  # Match app/ and lib/ directories, but exclude spec/ and test/
12
12
  %r{(?:^|/)(?:ee/)?(?:app|lib)/}
13
13
  end
@@ -7,7 +7,7 @@ module Keela
7
7
  "delegations"
8
8
  end
9
9
 
10
- def definition_file_pattern
10
+ def default_definition_file_pattern
11
11
  # Match app/models/ directories (including concerns), but exclude spec/test
12
12
  %r{(?:^|/)(?:ee/)?app/models/}
13
13
  end
@@ -44,12 +44,8 @@ module Keela
44
44
  methods = methods.map { |m| "#{prefix}_#{m}" }
45
45
  end
46
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
47
+ # Return all methods (scanner handles both single string and array)
48
+ methods.length == 1 ? methods.first : methods
53
49
  end
54
50
 
55
51
  def usage_regex(name)
@@ -22,7 +22,7 @@ module Keela
22
22
  "i18n_keys"
23
23
  end
24
24
 
25
- def definition_file_pattern
25
+ def default_definition_file_pattern
26
26
  # Match locale YAML files
27
27
  %r{config/locales/.*\.ya?ml$}
28
28
  end
@@ -7,7 +7,7 @@ module Keela
7
7
  "methods"
8
8
  end
9
9
 
10
- def definition_file_pattern
10
+ def default_definition_file_pattern
11
11
  %r{app/helpers|app/models}
12
12
  end
13
13
 
@@ -18,12 +18,15 @@ module Keela
18
18
  end
19
19
 
20
20
  def usage_regex(name)
21
+ method_name = Regexp.quote(name.sub(/^self\./, ""))
22
+
21
23
  if name.end_with?("=")
22
24
  # Setter method: match assignment usage
23
- /(?<!def )#{Regexp.quote(name.sub(/^self\./, "").chomp("="))}\W=*/
25
+ /(?<!def |def self\.)#{method_name.chomp("=")}\W=*/
24
26
  else
25
27
  # Regular method: match calls
26
- /(?<!def )#{Regexp.quote(name.sub(/^self\./, ""))}\W/
28
+ # Exclude both "def foo" and "def self.foo" definitions
29
+ /(?<!def |def self\.)#{method_name}\W/
27
30
  end
28
31
  end
29
32
 
@@ -7,7 +7,7 @@ module Keela
7
7
  "scopes"
8
8
  end
9
9
 
10
- def definition_file_pattern
10
+ def default_definition_file_pattern
11
11
  %r{app/models}
12
12
  end
13
13
 
@@ -14,8 +14,23 @@ module Keela
14
14
 
15
15
  # Regex pattern to match files that may contain definitions
16
16
  # (e.g., /app\/models/ for scopes)
17
+ #
18
+ # Can be overridden via configuration:
19
+ # strategies:
20
+ # methods:
21
+ # definition_paths:
22
+ # - app/helpers
23
+ # - app/models
24
+ # - lib/
25
+ #
17
26
  def definition_file_pattern
18
- raise NotImplementedError, "#{self.class} must implement #definition_file_pattern"
27
+ configured_pattern || default_definition_file_pattern
28
+ end
29
+
30
+ # Default pattern when no configuration override is provided.
31
+ # Subclasses should implement this instead of definition_file_pattern.
32
+ def default_definition_file_pattern
33
+ raise NotImplementedError, "#{self.class} must implement #default_definition_file_pattern"
19
34
  end
20
35
 
21
36
  # Extract a definition name from a line of code, or nil if no definition found
@@ -39,5 +54,15 @@ module Keela
39
54
  def extract_definitions_from_file(_filepath, _lines)
40
55
  nil
41
56
  end
57
+
58
+ private
59
+
60
+ def configured_pattern
61
+ paths = Keela.configuration.options_for(name)["definition_paths"]
62
+ return nil unless paths.is_a?(Array) && paths.any?
63
+
64
+ escaped = paths.map { |p| Regexp.escape(p.to_s) }
65
+ Regexp.new(escaped.join("|"))
66
+ end
42
67
  end
43
68
  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.2.3"
4
+ VERSION = "0.4.0"
5
5
  end
data/lib/keela.rb CHANGED
@@ -13,6 +13,8 @@ require_relative "keela/strategies/i18n_keys"
13
13
  require_relative "keela/reporter"
14
14
  require_relative "keela/baseline"
15
15
  require_relative "keela/scanner"
16
+ require_relative "keela/exclusion_validator"
17
+ require_relative "keela/formatters"
16
18
 
17
19
  module Keela
18
20
  class Error < StandardError; end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: keela
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.3
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kerri Miller
@@ -51,6 +51,20 @@ dependencies:
51
51
  - - "~>"
52
52
  - !ruby/object:Gem::Version
53
53
  version: '1.11'
54
+ - !ruby/object:Gem::Dependency
55
+ name: toon-ruby
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '0.1'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '0.1'
54
68
  description: Like the famous CSI dog who found what others missed, Keela detects unused
55
69
  methods and scopes in your Ruby codebase.
56
70
  email: kerrizor@kerrizor.com
@@ -67,6 +81,11 @@ files:
67
81
  - lib/keela/baseline.rb
68
82
  - lib/keela/config_file.rb
69
83
  - lib/keela/configuration.rb
84
+ - lib/keela/exclusion_validator.rb
85
+ - lib/keela/formatters.rb
86
+ - lib/keela/formatters/base.rb
87
+ - lib/keela/formatters/json.rb
88
+ - lib/keela/formatters/toon.rb
70
89
  - lib/keela/reporter.rb
71
90
  - lib/keela/scanner.rb
72
91
  - lib/keela/strategies/attributes.rb