keela 0.3.0 → 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: 4fcf07cf3f0adee173c2dfaef9e79f256f6a6318c5dd843935b6113fede4dea4
4
- data.tar.gz: 454fa3bc9a9839dcf7d5f40a6d2fa2160daeb3a2d113d2f372da6ce15f9a49b0
3
+ metadata.gz: 3448ca0297d2fb01cbe430bf8d1686ae28871bfa36666815120241664ed95821
4
+ data.tar.gz: 05a96216af6eea62b31ee0e6d6ceb8e82326946ff8eecd35f3eeb07637d220d8
5
5
  SHA512:
6
- metadata.gz: f9105a9cc47b95443d527772bd1dc44b5578d3b88829ec0144109e1ce82cc03d3414be62de9c8e4f8a7ab308b742e3beea343e6aefd2b4e3184f694847bba711
7
- data.tar.gz: b583a1e03b6b460d4393ed687ef8796315c6fc4e8f9e4afde3a4edce26581536ef0b72dbef7215237b97835263b12a52b55879f391d901efe5409007ae407982
6
+ metadata.gz: fc7d5f1e2f1844dc9f67d51e96de17b23e3d040ce767b51529d23c1619f96ae86ea1bf82a028e2e09cea4d074fcba001dab20b1a29725f54f71c366b68a2de17
7
+ data.tar.gz: 177309a39a1fa56d97d6a3693ff674cd94b92cb81e00ea50a6b889823e4d68fd2fdf0601c7d7157e10f48d50f66f940bb92a695808b424d30f4b5ee14bd22623
data/CHANGELOG.md CHANGED
@@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.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
+
10
29
  ## [0.3.0] - 2026-08-05
11
30
 
12
31
  ### Added
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,7 +355,25 @@ 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
 
data/exe/keela CHANGED
@@ -43,7 +43,7 @@ OptionParser.new do |opts|
43
43
  options[:update_baseline] = true
44
44
  end
45
45
 
46
- 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|
47
47
  options[:format] = format
48
48
  end
49
49
 
@@ -82,6 +82,18 @@ OptionParser.new do |opts|
82
82
  options[:quiet] = true
83
83
  end
84
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
+
85
97
  opts.on("-h", "--help", "Show this help") do
86
98
  puts opts
87
99
  exit
@@ -99,9 +111,30 @@ end
99
111
  # Apply quiet mode if requested
100
112
  Keela.configuration.show_progress = false if options[:quiet]
101
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
+
102
120
  # Set default baseline path if not specified
103
121
  Keela.configuration.baseline_path ||= ".keela_baseline.yml"
104
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
+
105
138
  STRATEGY_MAP = {
106
139
  methods: Keela::Strategies::Methods,
107
140
  scopes: Keela::Strategies::Scopes,
@@ -141,13 +174,46 @@ baseline = Keela::Baseline.new(Keela.configuration.baseline_path)
141
174
  # Load source files once and share across all strategies
142
175
  source_files = Keela::Scanner.load_source_files
143
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
+
144
209
  success = true
145
210
  results = {}
146
211
 
147
- json_mode = options[:format] == :json
212
+ structured_output = Keela::Formatters.structured_format?(options[:format])
213
+ silent_output = structured_output || options[:quiet]
148
214
 
149
215
  strategies.each_with_index do |strategy, index|
150
- unless json_mode
216
+ unless silent_output
151
217
  puts Rainbow("=== Sniffing for unused #{strategy.name} ===").cyan.bright if strategies.size > 1
152
218
  end
153
219
 
@@ -155,13 +221,13 @@ strategies.each_with_index do |strategy, index|
155
221
  success &&= scanner.run(
156
222
  force_report: options[:force_report],
157
223
  update_baseline: options[:update_baseline],
158
- silent: json_mode
224
+ silent: silent_output
159
225
  )
160
226
 
161
- # Collect results for JSON output
227
+ # Collect results for structured output formats
162
228
  results[strategy.name] = scanner.unused_collection.transform_values(&:to_a)
163
229
 
164
- unless json_mode
230
+ unless silent_output
165
231
  puts if strategies.size > 1 && index < strategies.size - 1
166
232
  end
167
233
  end
@@ -169,26 +235,14 @@ end
169
235
  # Save baseline after all strategies have run
170
236
  if options[:update_baseline]
171
237
  baseline.save
172
- puts Rainbow("Updated #{baseline.path}").green.bright unless json_mode
238
+ puts Rainbow("Updated #{baseline.path}").green.bright unless silent_output
173
239
  end
174
240
 
175
- # Output JSON if requested
176
- if json_mode
177
- require "json"
178
-
179
- total = results.values.flat_map(&:values).flatten.size
180
- by_strategy = results.transform_values { |files| files.values.flatten.size }
181
-
182
- output = {
183
- strategies: strategies.map(&:name),
184
- unused: results.reject { |_, v| v.empty? },
185
- summary: {
186
- total: total,
187
- by_strategy: by_strategy.reject { |_, v| v.zero? }
188
- }
189
- }
190
-
191
- 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
192
246
  end
193
247
 
194
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,7 +5,7 @@ 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
@@ -48,6 +48,7 @@ module Keela
48
48
  @source_files = source_files || {}
49
49
  @source_files_preloaded = !source_files.nil?
50
50
  @unused_collection = Hash.new { |hash, key| hash[key] = [] }
51
+ @source_locations = {} # { "file:name" => line_number }
51
52
  @new_unused = []
52
53
  @removed = []
53
54
  end
@@ -70,7 +71,7 @@ module Keela
70
71
 
71
72
  if report_mode
72
73
  elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
73
- reporter.print_full_report(unused_collection, elapsed) unless silent
74
+ reporter.print_full_report(unused_collection, elapsed, source_locations: source_locations) unless silent
74
75
  if update_baseline
75
76
  baseline.set(strategy.name, unused_collection)
76
77
  # Note: caller is responsible for calling baseline.save after all strategies run
@@ -85,7 +86,8 @@ module Keela
85
86
  new_unused,
86
87
  removed,
87
88
  excluded_path: resolve_excluded_path || ".keela/excluded.yml",
88
- baseline_path: baseline.path
89
+ baseline_path: baseline.path,
90
+ source_locations: source_locations
89
91
  )
90
92
  end
91
93
 
@@ -155,11 +157,17 @@ module Keela
155
157
  next custom_definitions if custom_definitions
156
158
 
157
159
  # Default: line-by-line parsing
158
- lines.flat_map do |line|
160
+ track_lines = configuration.source_location
161
+ lines.each_with_index.flat_map do |line, index|
159
162
  next [] if strategy.skip_comments? && line.strip.start_with?("#")
160
163
 
161
- name = strategy.extract_definition(line)
162
- 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
163
171
  end
164
172
  end
165
173
  end
@@ -211,6 +219,9 @@ module Keela
211
219
 
212
220
  unused.each do |unused_def|
213
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
214
225
  end
215
226
  end
216
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.3.0"
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.3.0
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