keela 0.4.1 → 0.5.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: 1b1c9fad8b959b91c3af1518da72d36f9f82bd649b4c21a6a0cf261246cbbcdb
4
- data.tar.gz: 24c7b946587cb844ae9c08c921ffecacfb8b262404b2c177f0368d803cc52861
3
+ metadata.gz: 40ac5a23ccbc726eeea66f99e4a6d9cb109360b987daa26e54c43c6d566c448b
4
+ data.tar.gz: 5c6b44f161613c7dea1aae79490918ce8233572345863febcf34a9417710bf60
5
5
  SHA512:
6
- metadata.gz: e0fcd131e331240b852f7d657113aa0e9610aa4a7dd465b78576478c11abdeca5cd4140c9037cbbd378f53e23e4137d6a255eeb8d28950f0f82f6ad0eeabc7fd
7
- data.tar.gz: 7d71ea4b1a2c601f0aba8901fbe31f95ba055eb784614213afa386321e096bb2364ecd832b60356f08c8cc42ed2e07e9bdcfc4c3a794fe218931834ac7df7c39
6
+ metadata.gz: 69de532e979e8bb052281e2fc58f2c4c6b9039f6a157b88139badff12c4f48abc0df134c817a44f6a6b1dcad131c2fe86c7a667fd51e36d1314184d5444b620d
7
+ data.tar.gz: fc86b2efb01f42db61d20f2a65d029082802a09c480bc6aeda5a6739f4b13c4ebfbb5d061f98520bb0248d10d15b9f4237bd18b8a5a0925b384553915d5f6ed4
data/CHANGELOG.md CHANGED
@@ -7,6 +7,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.5.1] - 2026-10-06
11
+
12
+ ### Changed
13
+
14
+ - **The `partials` strategy is dramatically faster on large codebases, up to ~27× less CPU.** It now collects every rendered partial in a single pass over the source instead of re-scanning the whole codebase once per partial. Measured against a large reference Rails monolith, partials went from the slowest strategy to effectively free, and no longer meaningfully slows a combined `--type all,partials` run. The report is unchanged ([#96](https://github.com/kerrizor/keela/pull/96))
15
+
16
+ ## [0.5.0] - 2026-09-21
17
+
18
+ ### Added
19
+
20
+ - **`partials` strategy**, which detects unused Rails view partials (`_*.html.{erb,haml,slim}`) in `app/views` and `ee/app/views`. It resolves explicit renders (`render "users/form"`, `render partial:`/`layout:`), relative bareword renders against the calling view or controller directory, `render_to_string`/`render_to_body`, and positional underscore paths (`render_to_string("shared/notes/_note")`). Opt-in via `--type partials` (not in the default set) while its false-positive rate is validated. Collection renders (`render @users`) and dynamic renders are documented limitations ([#82](https://github.com/kerrizor/keela/pull/82))
21
+ - **`partials` strategy: configurable custom render-helper allowlist.** App-specific helpers that render a partial from an explicit string path (e.g. GitLab's `view_to_html_string`, `tabs_json`) can be registered under `strategies.partials.render_helpers`, so they are recognized as usages instead of false-flagging the partial as unused. Configured helpers are matched only for explicit paths (positional path strings, positional underscore paths, and `partial:`/`layout:` keys), never for bareword-relative resolution, which stays limited to `render`/`render_to_string`/`render_to_body`. Default behavior is unchanged when unset ([#87](https://github.com/kerrizor/keela/pull/87))
22
+ - `Keela::Source`, which holds the concatenated source and its case-folded view
23
+ - `Strategy#used?` and `Strategy#prepare`, so a strategy can give a cheaper equivalent of its `usage_regex`. `Strategy#usage_regex` is unchanged, so existing custom strategies keep working
24
+
25
+ ### Changed
26
+
27
+ - CI now tests against Ruby 3.3, 3.4, and 4.0; dropped end-of-life Ruby 3.1 and 3.2. Minimum required Ruby is now 3.3.0
28
+ - **Usage detection is faster.** On a 47.5 MB codebase of 24,783 files and 42,053 definitions, a full run went from 41s to 18.5s of wall time, and from 438s to 130s of CPU time. The report is unchanged
29
+ - **The `attributes` strategy checks its two patterns separately.** It matched one pattern built as an alternation of a bare reference and an `@ivar` reference. An alternation has no single mandatory literal, so the regexp engine cannot fast-forward to a candidate position and scans the whole source instead. Each pattern now keeps its literal
30
+ - **The `delegations` strategy folds the source instead of matching case-insensitively.** Only three ASCII fold targets have a non-ASCII source: U+017F folds to `s`, U+212A folds to `k`, and the eszett folds to `ss`. So once the scanned source is not entirely 7-bit ASCII, a `//i` pattern whose literal begins with `s` or `k` can no longer be found with a plain byte search, and takes 100x longer or worse. A single non-ASCII character in any scanned file is enough to put a whole run on that path, and method names beginning with `s` are common, so delegations such as `squash_never?` and `sentry_issue` each cost about a second. The source is now case-folded once per run and matched with a case-sensitive pattern. It falls back to the case-insensitive pattern when folding would not give the same answer, which only happens with non-ASCII source, for example a character that folds to two characters, or a non-ASCII character that folds into ASCII
31
+
32
+ ### Fixed
33
+
34
+ - **`partials` strategy: a render inside a single-line ERB comment no longer counts as a usage.** A commented-out `<%# render "users/old_form" %>` (including the trim variants `<%-# ... %>` and `<%# ... -%>`) previously marked the partial used, hiding effectively dead partials. Single-line ERB comment tags are now stripped before render-matching in both the explicit-path and bareword/positional-underscore paths. Multi-line ERB comments and HAML comments remain out of scope and are still counted ([#88](https://github.com/kerrizor/keela/pull/88))
35
+ - Usage matches can no longer span a file boundary. The concatenated source joined files with no separator, so a file whose last line lacked a trailing newline glued onto the next file's first line, letting a match exist across a gap that is in no single file. This silently marked some unused code as used. Each file is now newline-terminated before concatenation, affecting every strategy ([#86](https://github.com/kerrizor/keela/pull/86))
36
+ - Unused entries are no longer listed more than once per file in reports and baselines when a single definition is extracted from multiple lines (e.g. the same method delegated twice, or a constant declared and referenced) ([#72](https://github.com/kerrizor/keela/pull/72))
37
+
10
38
  ## [0.4.1] - 2026-08-21
11
39
 
12
40
  ### Fixed
data/README.md CHANGED
@@ -2,17 +2,17 @@
2
2
 
3
3
  **Your Ruby codebase's forensic investigator.**
4
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.
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
6
 
7
7
  ```
8
- 🔍 6 strategies: methods, scopes, constants, delegates, attrs, i18n
9
- 🎯 Baseline mode — only bark at NEW dead code
8
+ 🔍 7 strategies: methods, scopes, constants, delegates, attrs, i18n, partials
9
+ 🎯 Baseline mode: only bark at NEW dead code
10
10
  📊 JSON output for CI pipelines
11
11
  ```
12
12
 
13
13
  ## Why Remove Unused Code?
14
14
 
15
- Dead code isn't harmless — it's actively costly:
15
+ Dead code isn't harmless. It's actively costly:
16
16
 
17
17
  - **Cognitive overhead**: Developers read and try to understand code that doesn't matter, slowing down onboarding and feature work
18
18
  - **CI minutes**: Tests for unused methods still run, burning compute time on every pipeline
@@ -20,7 +20,7 @@ Dead code isn't harmless — it's actively costly:
20
20
  - **Refactoring friction**: Unused code creates dependencies that make refactoring harder ("wait, is this called somewhere?")
21
21
  - **Security surface**: More code means more potential vulnerabilities, even in paths users never hit
22
22
 
23
- Most codebases accumulate dead code gradually — a feature flag that's always on, a method replaced but never deleted, a scope that lost its last caller. Keela helps you find it and clean it up.
23
+ Most codebases accumulate dead code gradually: a feature flag that's always on, a method replaced but never deleted, a scope that lost its last caller. Keela helps you find it and clean it up.
24
24
 
25
25
  ## Installation
26
26
 
@@ -97,18 +97,18 @@ keela --report
97
97
 
98
98
  ## What "Unused" Means
99
99
 
100
- Keela defines "unused" as **unused in production code** — not just unused anywhere.
100
+ Keela defines "unused" as **unused in production code**, not just unused anywhere.
101
101
 
102
102
  By default, Keela scans `app/`, `lib/`, and `config/` directories. It intentionally
103
103
  excludes `spec/` and `test/` directories because:
104
104
 
105
- 1. **Tests aren't usage** — Code that only exists to be tested isn't providing
105
+ 1. **Tests aren't usage.** Code that only exists to be tested isn't providing
106
106
  application value. If you delete the unused code, you delete its tests too.
107
107
 
108
- 2. **Tests can cover dead code** — A method with 100% test coverage can still be
108
+ 2. **Tests can cover dead code.** A method with 100% test coverage can still be
109
109
  dead if nothing in the application calls it.
110
110
 
111
- 3. **Cleaner signal** — Including test files would hide genuinely unused code
111
+ 3. **Cleaner signal.** Including test files would hide genuinely unused code
112
112
  behind "but it has tests!" false negatives.
113
113
 
114
114
  If Keela flags something that's only used in tests, consider whether the code
@@ -131,6 +131,10 @@ keela
131
131
  keela --type methods
132
132
  keela --type scopes
133
133
 
134
+ # Opt-in strategies (not in the default run)
135
+ keela --type i18n_keys
136
+ keela --type partials
137
+
134
138
  # Combine multiple types
135
139
  keela --type methods,scopes,constants
136
140
 
@@ -190,10 +194,11 @@ Keela detects several types of unused code:
190
194
  | **delegations** | Unused delegate declarations | `delegate :unused, to: :target` |
191
195
  | **attributes** | Unused attr_* declarations | `attr_accessor :unused_attr` |
192
196
  | **i18n_keys** | Unused translation keys | `en.users.unused_key` in locale YAML |
197
+ | **partials** | Unused Rails view partials | `app/views/users/_unused.html.erb` |
193
198
 
194
199
  Run all strategies (default) or target specific ones with `--type`.
195
200
 
196
- **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`.
201
+ **Note:** The `i18n_keys` and `partials` strategies are not included in `--type all`. Run them explicitly with `--type i18n_keys` or `--type partials`.
197
202
 
198
203
  ### I18n Keys (Beta)
199
204
 
@@ -208,9 +213,22 @@ The following patterns ARE now supported:
208
213
 
209
214
  Review results carefully and use the exclusion file for known false positives.
210
215
 
216
+ ### Partials (Beta)
217
+
218
+ The `partials` strategy is **beta** and may produce false positives. It detects unused Rails view partials (`_*.html.{erb,haml,slim}`) in `app/views` and `ee/app/views`, and resolves usage through `render` (and `render_to_string`/`render_to_body`) calls in views and controllers: explicit paths (`render "users/form"`, `render partial:`/`layout:`), relative bareword renders against the calling file's directory, and positional underscore paths (`render_to_string("shared/notes/_note")`).
219
+
220
+ It cannot detect:
221
+
222
+ - **Collection/object rendering** - `render @users` infers the partial from the object's class at runtime, which static analysis cannot resolve
223
+ - **Dynamic rendering** - `render partial_name` where the partial name is a variable
224
+
225
+ **Custom render helpers** (project-specific methods that render a partial from a string path, e.g. `view_to_html_string`) are not recognized by default, but you can register them with the `render_helpers` option so their partials are not false-flagged. See [Customizing Render Helpers Per Strategy](#customizing-render-helpers-per-strategy).
226
+
227
+ Partials only reached via the undetectable patterns above may be reported as unused. Review results carefully and use the exclusion file (or baseline) for known false positives.
228
+
211
229
  ## Limitations
212
230
 
213
- Keela uses static analysis — it reads your code without executing it. This means some patterns are **fundamentally undetectable**.
231
+ Keela uses static analysis: it reads your code without executing it. This means some patterns are **fundamentally undetectable**.
214
232
 
215
233
  ### What Keela CAN Detect
216
234
 
@@ -233,17 +251,17 @@ respond_to?(:optional_method)
233
251
  **Dynamic dispatch** with interpolation or variables cannot be analyzed statically:
234
252
 
235
253
  ```ruby
236
- # ❌ Interpolated symbols — what method does this call?
254
+ # ❌ Interpolated symbols: what method does this call?
237
255
  public_send(:"add_#{role}", user)
238
256
 
239
- # ❌ Variable method names — could be anything
257
+ # ❌ Variable method names: could be anything
240
258
  send(method_name)
241
259
 
242
260
  # ❌ Computed method names
243
261
  define_method(compute_name) { }
244
262
  ```
245
263
 
246
- To detect these, Keela would need to trace all possible runtime values — essentially becoming a Ruby interpreter. This is not a bug; it's a fundamental limitation of static analysis.
264
+ To detect these, Keela would need to trace all possible runtime values, essentially becoming a Ruby interpreter. This is not a bug; it's a fundamental limitation of static analysis.
247
265
 
248
266
  ### Handling False Positives
249
267
 
@@ -313,6 +331,23 @@ strategies:
313
331
 
314
332
  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.
315
333
 
334
+ ### Customizing Render Helpers Per Strategy
335
+
336
+ The `partials` strategy recognizes `render`, `render_to_string`, and `render_to_body` when detecting partial usage. If your project renders partials through custom helper methods that take a string path, register them under `render_helpers` so their partials are not reported as unused:
337
+
338
+ ```yaml
339
+ # keela.yml
340
+ strategies:
341
+ partials:
342
+ render_helpers:
343
+ - view_to_html_string
344
+ - tabs_json
345
+ ```
346
+
347
+ With the above, a call like `view_to_html_string("shared/notes/_note")` marks `shared/notes/note` as used.
348
+
349
+ Configured helpers are matched for **explicit paths only** (positional path strings, positional underscore paths, and `partial:`/`layout:` keys). They are deliberately not used for relative bareword resolution (`view_to_html_string("form")` is not resolved against the calling file's directory), since an arbitrary helper name is too ambiguous to safely resolve a bare name. Relative bareword resolution stays limited to `render`/`render_to_string`/`render_to_body`.
350
+
316
351
  ### Customizing Which Files to Scan
317
352
 
318
353
  There are two approaches:
@@ -542,6 +577,10 @@ Like her namesake, this gem finds the dead code that `grep` and your IDE missed.
542
577
 
543
578
  *Good girl, Keela.* 🦴
544
579
 
580
+ ## Changelog
581
+
582
+ See [CHANGELOG.md](CHANGELOG.md) for a list of notable changes to each release.
583
+
545
584
  ## License
546
585
 
547
586
  MIT License. See [LICENSE.txt](LICENSE.txt).
data/exe/keela CHANGED
@@ -31,7 +31,7 @@ OptionParser.new do |opts|
31
31
  opts.separator ""
32
32
  opts.separator "Options:"
33
33
 
34
- opts.on("--type TYPES", "Comma-separated types to detect: methods, scopes, constants, delegations, attributes, i18n_keys, all (default: all)") do |types|
34
+ opts.on("--type TYPES", "Comma-separated types to detect: methods, scopes, constants, delegations, attributes, i18n_keys, partials, all (default: all)") do |types|
35
35
  options[:types] = types.split(",").map { |t| t.strip.to_sym }
36
36
  end
37
37
 
@@ -141,10 +141,12 @@ STRATEGY_MAP = {
141
141
  constants: Keela::Strategies::Constants,
142
142
  delegations: Keela::Strategies::Delegations,
143
143
  attributes: Keela::Strategies::Attributes,
144
- i18n_keys: Keela::Strategies::I18nKeys
144
+ i18n_keys: Keela::Strategies::I18nKeys,
145
+ partials: Keela::Strategies::Partials
145
146
  }.freeze
146
147
 
147
- # Default strategies for "all" (excludes i18n_keys which requires different config)
148
+ # Default strategies for "all". Excludes i18n_keys and partials, which are
149
+ # opt-in via --type until we're confident in their false-positive rate.
148
150
  DEFAULT_STRATEGIES = %i[methods scopes constants delegations attributes].freeze
149
151
 
150
152
  VALID_TYPES = (STRATEGY_MAP.keys + [:all]).freeze
data/lib/keela/scanner.rb CHANGED
@@ -208,24 +208,52 @@ module Keela
208
208
  end
209
209
 
210
210
  def find_unused(definitions, show_progress: false)
211
- source_code = source_files.values.flatten.join
211
+ source = Source.from_source_files(source_files)
212
212
 
213
213
  # Get additional used names from strategy-specific detection
214
214
  # (e.g., I18n lazy lookup)
215
215
  additional_used = strategy.additional_used_names(source_files)
216
216
 
217
+ # A strategy whose #additional_used_names enumerates every used name in one
218
+ # pass (e.g. partials) decides purely by Set membership. Skip building the
219
+ # derived source views and the per-definition #used? scan entirely: that
220
+ # per-definition scan is O(N x whole-codebase) and is the hot path we are
221
+ # avoiding here.
222
+ #
223
+ if strategy.detects_all_usage?
224
+ unused = definitions.reject { |definition| additional_used.include?(definition[:name]) }
225
+ return record_unused(unused)
226
+ end
227
+
228
+ # Build the source views the strategy needs here, in the parent, so that
229
+ # the workers Parallel forks below inherit them instead of each building
230
+ # its own copy of a string the size of the whole codebase.
231
+ strategy.prepare(source)
232
+
217
233
  progress_label = show_progress ? "Checking #{strategy.name}" : nil
218
234
 
219
235
  unused = Parallel.flat_map(definitions, progress: progress_label) do |definition|
220
236
  # Check if marked as used by additional detection
221
237
  next [] if additional_used.include?(definition[:name])
222
238
 
223
- regex = strategy.usage_regex(definition[:name])
224
- regex.match?(source_code) ? [] : definition
239
+ strategy.used?(definition[:name], source) ? [] : definition
225
240
  end
226
241
 
242
+ record_unused(unused)
243
+ end
244
+
245
+ # Collect the unused definitions into @unused_collection / @source_locations.
246
+ #
247
+ # A single logical definition can be extracted from multiple lines (e.g. the
248
+ # same method delegated twice, or a constant declared and referenced), so
249
+ # guard against listing the same name twice per file.
250
+ #
251
+ def record_unused(unused)
227
252
  unused.each do |unused_def|
228
- @unused_collection[unused_def[:file]] << unused_def[:name]
253
+ names = @unused_collection[unused_def[:file]]
254
+ next if names.include?(unused_def[:name])
255
+
256
+ names << unused_def[:name]
229
257
  if unused_def[:line]
230
258
  @source_locations["#{unused_def[:file]}:#{unused_def[:name]}"] = unused_def[:line]
231
259
  end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Keela
4
+ # The concatenated source that usage detection is matched against, plus
5
+ # lazily-derived views of it.
6
+ #
7
+ # Built once and shared by every strategy in a run. Derived views are memoized
8
+ # because they cost about as much as a single usage check but are identical for
9
+ # every definition in the run.
10
+ #
11
+ class Source
12
+ # The ASCII block as a String#delete range. Built from its code points
13
+ # rather than written as a literal so that this file stays free of the
14
+ # control characters the range ends in.
15
+ ASCII_RANGE = [0x00, 0x7F].map { |code_point| code_point.chr(Encoding::UTF_8) }.join("-").freeze
16
+
17
+ attr_reader :text
18
+
19
+ def initialize(text)
20
+ @text = text
21
+ end
22
+
23
+ # Build from the scanner's { filename => [lines] } hash.
24
+ #
25
+ # Each file's lines are joined as-is, then a newline is guaranteed at the
26
+ # end of every non-empty file before files are concatenated. Without that
27
+ # boundary a file whose last line lacks a trailing newline glues onto the
28
+ # next file's first line, letting a usage match span a gap that exists in
29
+ # no single file (issue #83). Empty files contribute nothing.
30
+ #
31
+ def self.from_source_files(source_files)
32
+ new(source_files.values.map { |lines| terminate(lines.join) }.join)
33
+ end
34
+
35
+ def self.terminate(text)
36
+ return text if text.empty? || text.end_with?("\n")
37
+
38
+ "#{text}\n"
39
+ end
40
+ private_class_method :terminate
41
+
42
+ # Unicode full case folding of #text.
43
+ #
44
+ # Lets a strategy drop //i from its pattern and match case-sensitively
45
+ # against this instead, which avoids a sharp performance cliff.
46
+ #
47
+ # Only three ASCII fold targets have a non-ASCII source: U+017F folds to
48
+ # "s", U+212A folds to "k", and the eszett folds to "ss". So once the string
49
+ # being searched is not entirely 7-bit ASCII, a //i pattern whose literal
50
+ # BEGINS with "s" or "k" can no longer be found with a plain byte search,
51
+ # and takes 100x longer or worse. A literal with "s" in the middle is partly
52
+ # affected; one ending in "s" is not.
53
+ #
54
+ # A single non-ASCII character in any scanned file is enough to put a whole
55
+ # run on that path, and method names beginning with "s" are common, so this
56
+ # is easy to hit by accident.
57
+ #
58
+ # Full folding, not #downcase, because //i folds too: /strasse/i matches the
59
+ # eszett spelling of "STRASSE".
60
+ #
61
+ def folded
62
+ @folded ||= text.downcase(:fold)
63
+ end
64
+
65
+ # Whether matching a folded ASCII literal against #folded decides exactly
66
+ # what matching that literal against #text under //i would decide.
67
+ #
68
+ # Two things can break the equivalence, both only reachable with non-ASCII
69
+ # text:
70
+ #
71
+ # - A character that folds to more than one character (an eszett folds to
72
+ # "ss") shifts everything after it, so a pattern's lookarounds see
73
+ # different neighbours than they would in #text. Folding never maps a
74
+ # character to nothing, so an unchanged total length proves every
75
+ # character mapped to exactly one and positions are preserved.
76
+ #
77
+ # - A character that folds into ASCII (U+212A KELVIN SIGN folds to "k",
78
+ # U+017F LATIN SMALL LETTER LONG S folds to "s") puts ASCII letters in the
79
+ # folded view where #text had none. A guard such as (?<![a-z_]) would then
80
+ # see a letter that //i never matches in #text.
81
+ #
82
+ # Neither occurs in ASCII-only text, so this holds for essentially all code.
83
+ # Strategies fall back to their //i pattern when it does not.
84
+ #
85
+ def fold_preserves_matching?
86
+ return @fold_preserves_matching if defined?(@fold_preserves_matching)
87
+
88
+ @fold_preserves_matching = folded.length == text.length && !non_ascii_folds_into_ascii?
89
+ end
90
+
91
+ private
92
+
93
+ def non_ascii_folds_into_ascii?
94
+ non_ascii_characters.downcase(:fold).match?(/[[:ascii:]]/)
95
+ end
96
+
97
+ # Only the non-ASCII characters of #text, which is a tiny string even for a
98
+ # large codebase.
99
+ #
100
+ # #delete with a character range rather than a regexp: a regexp scan of a
101
+ # string this size costs seconds, this costs milliseconds.
102
+ #
103
+ def non_ascii_characters
104
+ text.delete(ASCII_RANGE)
105
+ end
106
+ end
107
+ end
@@ -24,16 +24,36 @@ module Keela
24
24
  end
25
25
 
26
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))/
27
+ Regexp.union(*usage_regexes(name))
28
+ end
29
+
30
+ # The two ways an attribute can be used, as separate patterns:
31
+ # - bare reference: obj.name, name, self.name = value
32
+ # - instance variable: @name
33
+ #
34
+ # Excluded by the lookarounds:
35
+ # - symbol notation (:name)
36
+ # - the attr_* declaration itself
37
+ # - partial word matches (username must not match name)
38
+ #
39
+ # These are kept apart rather than combined into one alternation because
40
+ # an alternation has no single mandatory literal, so the regexp engine
41
+ # cannot fast-forward to a candidate position and scans the whole source
42
+ # instead. Checked separately, each pattern keeps its literal.
43
+ #
44
+ def usage_regexes(name)
45
+ quoted = Regexp.quote(name)
46
+
47
+ [
48
+ /(?<!:)(?<!attr_accessor\s)(?<!attr_reader\s)(?<!attr_writer\s)(?<![a-z_])#{quoted}(?!\w)/,
49
+ /@#{quoted}(?!\w)/
50
+ ]
51
+ end
52
+
53
+ # Equivalent to matching #usage_regex, because the caller asks only
54
+ # whether a match exists and not where it is.
55
+ def used?(name, source)
56
+ usage_regexes(name).any? { |regex| regex.match?(source.text) }
37
57
  end
38
58
 
39
59
  def skip_comments?
@@ -58,9 +58,36 @@ module Keela
58
58
  /(?<!:)(?<!delegate\s)(?<![a-z_])#{Regexp.quote(name)}(?!\w)/i
59
59
  end
60
60
 
61
+ def prepare(source)
62
+ source.fold_preserves_matching?
63
+ end
64
+
65
+ # Equivalent to matching #usage_regex, with the case folding moved from
66
+ # the pattern onto the source so that the pattern can drop //i. See
67
+ # Source#folded for why //i is the expensive part.
68
+ #
69
+ # The two decide alike because every guard in the pattern is
70
+ # case-symmetric: (?<!:) and (?!\w) do not involve case, and under //i
71
+ # both (?<![a-z_]) and (?<!delegate\s) already match their uppercase
72
+ # forms, which a folded source no longer contains. That argument needs an
73
+ # ASCII literal and a source whose folding preserves positions, so fall
74
+ # back to the //i pattern when either does not hold.
75
+ #
76
+ def used?(name, source)
77
+ return usage_regex(name).match?(source.text) unless name.ascii_only? && source.fold_preserves_matching?
78
+
79
+ folded_usage_regex(name).match?(source.folded)
80
+ end
81
+
61
82
  def skip_comments?
62
83
  true
63
84
  end
85
+
86
+ private
87
+
88
+ def folded_usage_regex(name)
89
+ /(?<!:)(?<!delegate\s)(?<![a-z_])#{Regexp.quote(name.downcase(:fold))}(?!\w)/
90
+ end
64
91
  end
65
92
  end
66
93
  end
@@ -0,0 +1,318 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Keela
4
+ module Strategies
5
+ # Detects unused Rails view partials (_*.html.{erb,haml,slim}) under
6
+ # app/views/** and ee/app/views/**.
7
+ #
8
+ # Definitions come from filenames, not file contents (like i18n_keys), so
9
+ # this strategy overrides #extract_definitions_from_file. The logical name
10
+ # is the path with the app/views/ (or ee/app/views/) prefix, leading _, and
11
+ # extension stripped: app/views/users/_form.html.erb -> "users/form".
12
+ #
13
+ # Usage is detected two ways:
14
+ # 1. Explicit paths via #usage_regex: render "users/form",
15
+ # render partial: "users/form", render layout: "users/form". layout:
16
+ # renders a partial as a layout, so it counts as a use.
17
+ # 2. Relative (bareword) renders via #additional_used_names: render "form"
18
+ # (and render partial:/layout: "form") is resolved against the CALLING
19
+ # file's directory. In a view, the directory is the view's own dir; in a
20
+ # controller, it is derived from the controller name
21
+ # (users_controller.rb -> users).
22
+ #
23
+ # Known limitations (not implemented, by design):
24
+ # - Collection/object render (render @users -> users/_user) is NOT
25
+ # resolved. A singularize heuristic could silently hide dead code on a
26
+ # wrong guess, so these partials may be reported as unused; suppress via
27
+ # the baseline.
28
+ # - Dynamic render with a variable (render partial_name) is statically
29
+ # unsolvable, so it is not resolved (same posture as send for methods).
30
+ # - shared/* partials are treated like any other partial; false positives
31
+ # belong in the baseline rather than a hard-coded skip.
32
+ #
33
+ class Partials < Strategy
34
+ # The intermediate directory is optional so a partial directly under
35
+ # app/views/ (app/views/_foo.html.erb) matches too; without this the file
36
+ # filter and #extract_definitions_from_file disagree and root partials are
37
+ # silently skipped.
38
+ #
39
+ DEFINITION_FILE_REGEX = %r{(?:ee/)?app/views/(?:.*/)?_[^/]+\.html\.(?:erb|haml|slim)$}.freeze
40
+
41
+ # Captures the path under app/views/ so we can derive a partial name:
42
+ # app/views/users/_form.html.erb -> "users/_form"
43
+ #
44
+ NAME_PATH_REGEX = %r{(?:ee/)?app/views/(.+)\.html\.(?:erb|haml|slim)$}.freeze
45
+
46
+ # The directory of a calling VIEW, used to resolve bareword renders.
47
+ # app/views/users/show.html.erb -> "users"
48
+ #
49
+ VIEW_DIR_REGEX = %r{(?:ee/)?app/views/(.+)/[^/]+\.html\.(?:erb|haml|slim)$}.freeze
50
+
51
+ # A controller path, used to derive the implicit view directory for
52
+ # bareword renders made from controllers.
53
+ # app/controllers/users_controller.rb -> "users"
54
+ # app/controllers/admin/users_controller.rb -> "admin/users"
55
+ #
56
+ CONTROLLER_REGEX = %r{(?:ee/)?app/controllers/(.+)_controller\.rb$}.freeze
57
+
58
+ # A view directly under app/views/ with no intermediate directory, e.g.
59
+ # app/views/index.html.erb. Its caller directory is the root (""), so a
60
+ # bareword render "foo" from here resolves to the root-level partial "foo".
61
+ # VIEW_DIR_REGEX requires an intermediate dir and so never matches these.
62
+ #
63
+ ROOT_VIEW_REGEX = %r{(?:ee/)?app/views/[^/]+\.html\.(?:erb|haml|slim)$}.freeze
64
+
65
+ # The render family we recognize: render, plus render_to_string and
66
+ # render_to_body, which render partials the same way (Rails/GitLab use both
67
+ # off-request, e.g. in mailers and background jobs). An optional opening
68
+ # paren after the method lets us match both the no-parens same-line form
69
+ # (render_to_string partial: "x/y") and the contiguous parens form
70
+ # (render_to_string(partial: "x/y")). Multi-line detached partial: args are
71
+ # out of scope; see issue #84.
72
+ #
73
+ RENDER_METHOD = /render(?:_to_string|_to_body)?\s*\(?\s*/
74
+
75
+ # A bareword render: render "form" / render 'form' / render partial: "form"
76
+ # / render layout: "form". layout: renders a partial as a layout, so it
77
+ # counts as a use just like partial:. Only barewords (no slash) are
78
+ # captured here; paths with a slash are handled by #usage_regex.
79
+ #
80
+ # The leading negative lookbehind anchors the method on its left so that
81
+ # prerender/foo_render(_to_string) do not count as a render.
82
+ #
83
+ # This uses RENDER_METHOD, not #render_method_pattern, on purpose:
84
+ # configured render_helpers must never enable bareword-relative resolution.
85
+ # A bare 'form' passed to an arbitrary app helper is too ambiguous to
86
+ # safely resolve against the caller's directory.
87
+ #
88
+ BAREWORD_RENDER_REGEX = /(?<!\w)#{RENDER_METHOD}(?:(?:partial|layout):\s*)?["']([^"'\/]+)["']/
89
+
90
+ def name
91
+ "partials"
92
+ end
93
+
94
+ def default_definition_file_pattern
95
+ DEFINITION_FILE_REGEX
96
+ end
97
+
98
+ # Definitions come from filenames, not contents.
99
+ def extract_definitions_from_file(filepath, _lines)
100
+ return [] unless filepath =~ NAME_PATH_REGEX
101
+
102
+ # "users/_form" -> "users/form"; "_form" -> "form"
103
+ name = Regexp.last_match(1).sub(%r{(^|/)_}, '\1')
104
+
105
+ [{ name: name, file: filepath }]
106
+ end
107
+
108
+ def extract_definition(_line)
109
+ # Not used; definitions come from #extract_definitions_from_file.
110
+ nil
111
+ end
112
+
113
+ def usage_regex(name)
114
+ # render "users/form", render 'users/form',
115
+ # render partial: "users/form", render layout: "users/form"
116
+ # (single/double quotes, tolerant space). layout: renders the partial as
117
+ # a layout, so it counts as a use. render_to_string / render_to_body are
118
+ # recognized too via RENDER_METHOD.
119
+ #
120
+ # The leading negative lookbehind anchors the method on its left so that
121
+ # prerender/foo_render do not count as a render.
122
+ #
123
+ # Configured render_helpers extend the method token here (explicit-path
124
+ # matching only) via #render_method_pattern.
125
+ #
126
+ /(?<!\w)#{render_method_pattern}(?:(?:partial|layout):\s*)?["']#{Regexp.quote(name)}["']/
127
+ end
128
+
129
+ def skip_comments?
130
+ true
131
+ end
132
+
133
+ # #additional_used_names collects every rendered partial name in a single
134
+ # pass, so the scanner decides by Set membership and skips the per-definition
135
+ # #used? scan. See the note on #additional_used_names.
136
+ #
137
+ def detects_all_usage?
138
+ true
139
+ end
140
+
141
+ # Build the comment-stripped view once in the parent so workers inherit it
142
+ # copy-on-write instead of each rebuilding a codebase-sized string.
143
+ #
144
+ def prepare(source)
145
+ @stripped_source = source
146
+ @stripped_text = strip_erb_comments(source.text)
147
+ end
148
+
149
+ # Match against a view with single-line ERB comments removed, so a render
150
+ # inside <%# ... %> does not count as a usage. Falls back to stripping on
151
+ # demand if #prepare was not run for this source (defensive: #used? can be
152
+ # called directly in tests, or on a different source than #prepare saw).
153
+ #
154
+ # Retained as the Strategy contract and for direct callers (tests), but the
155
+ # scanner no longer drives detection through it: #additional_used_names now
156
+ # collects every used name in a single pass over the corpus, so the scanner
157
+ # decides via that Set instead of calling #used? per definition. See the
158
+ # note on #additional_used_names.
159
+ #
160
+ def used?(name, source)
161
+ usage_regex(name).match?(stripped_text_for(source))
162
+ end
163
+
164
+ # The set of partial names used anywhere in +source_files+, collected in a
165
+ # SINGLE pass over the corpus.
166
+ #
167
+ # This is the whole-run detection path for partials. The scanner checks a
168
+ # definition against this Set and never calls #used? for a name that is in
169
+ # it, so extracting every used name here (not just the bareword/positional
170
+ # ones) turns detection from O(N_partials x codebase) per-definition regex
171
+ # scans into O(codebase + N_partials): we scan each file once, collect the
172
+ # rendered names, then membership-test each definition. This is the fix for
173
+ # the partials strategy being CPU-bound on large codebases.
174
+ #
175
+ # Three render forms are collected, each the single-pass twin of a branch
176
+ # that used to be matched per-definition:
177
+ #
178
+ # 1. Explicit slashed paths (the #usage_regex cases): render "users/form",
179
+ # render partial:/layout: "users/form", render_to_string/_to_body, and
180
+ # configured render_helpers. Captured verbatim as the logical name.
181
+ # 2. Positional-underscore paths: render "shared/notes/_note" names the
182
+ # file directly -> "shared/notes/note" (strip the basename's leading _).
183
+ # 3. Barewords resolved against the caller's directory: render "form" in
184
+ # app/views/users/* or users_controller.rb -> "users/form".
185
+ #
186
+ # All three share the comment-stripped content and the same left word-anchor
187
+ # and render-method rules as #usage_regex, so the collected strings are
188
+ # exactly what the per-name regex would have matched.
189
+ #
190
+ def additional_used_names(source_files)
191
+ used = Set.new
192
+
193
+ source_files.each do |filepath, lines|
194
+ content = strip_erb_comments(lines.join("\n"))
195
+
196
+ # Explicit slashed paths: the single-pass twin of #usage_regex. A
197
+ # basename leading _ is stripped so "users/_form" and "users/form"
198
+ # agree with the positional-underscore form and with definition names.
199
+ #
200
+ content.scan(explicit_path_regex).each do |(path)|
201
+ used << path.sub(%r{(^|/)_([^/]+)$}, '\1\2')
202
+ end
203
+
204
+ # Positional-underscore paths are absolute logical names, so they are
205
+ # resolved regardless of the caller's directory. "shared/notes/_note"
206
+ # -> "shared/notes/note" (strip the leading _ of the basename only).
207
+ #
208
+ content.scan(positional_underscore_regex).each do |(path)|
209
+ used << path.sub(%r{/_([^/]+)$}, '/\1')
210
+ end
211
+
212
+ dir = caller_directory(filepath)
213
+ next unless dir
214
+
215
+ content.scan(BAREWORD_RENDER_REGEX).each do |(bareword)|
216
+ # dir is "" for a root-level view, so join without a separator to
217
+ # resolve render "foo" -> "foo" rather than "/foo".
218
+ #
219
+ used << (dir.empty? ? bareword : "#{dir}/#{bareword}")
220
+ end
221
+ end
222
+
223
+ used
224
+ end
225
+
226
+ private
227
+
228
+ # The comment-stripped text for +source+, using the view #prepare built
229
+ # when it saw this same source, otherwise stripping on demand.
230
+ #
231
+ def stripped_text_for(source)
232
+ return @stripped_text if defined?(@stripped_source) && @stripped_source.equal?(source)
233
+
234
+ strip_erb_comments(source.text)
235
+ end
236
+
237
+ # Blank out single-line ERB comment tags so renders inside them are not
238
+ # matched. Matches <% (optional -) then #, up to %> (optional -), on one
239
+ # line; a <%= output %> or <% code %> tag has no # after <% and is left
240
+ # intact. Multi-line ERB comments and HAML comments are out of scope: the
241
+ # dot does not cross newlines, so they are not matched.
242
+ #
243
+ # Replaced with same-length spaces (via the block form, so the length is
244
+ # the matched span's, not a global) to preserve positions and line
245
+ # structure.
246
+ #
247
+ def strip_erb_comments(text)
248
+ text.gsub(/<%\s*-?\s*#.*?-?\s*%>/) { |match| " " * match.length }
249
+ end
250
+
251
+ # The directory a bareword render resolves against: the view's own dir, the
252
+ # controller-derived dir, or "" for a root-level view (render "foo" from
253
+ # app/views/index.html.erb resolves to the root partial "foo"). Returns nil
254
+ # for files that render nothing resolvable this way.
255
+ #
256
+ def caller_directory(filepath)
257
+ return Regexp.last_match(1) if filepath =~ VIEW_DIR_REGEX
258
+ return Regexp.last_match(1) if filepath =~ CONTROLLER_REGEX
259
+ return "" if filepath =~ ROOT_VIEW_REGEX
260
+
261
+ nil
262
+ end
263
+
264
+ # The single-pass twin of #usage_regex: instead of matching one known name,
265
+ # CAPTURE the rendered path so one scan collects every explicitly-rendered
266
+ # partial. Same left word-anchor, same optional partial:/layout: keyword,
267
+ # same #render_method_pattern (so configured render_helpers are honored),
268
+ # as #usage_regex.
269
+ #
270
+ # The captured path must contain at least one slash: barewords (no slash)
271
+ # are resolved relative to the caller by #additional_used_names via
272
+ # BAREWORD_RENDER_REGEX, exactly as #usage_regex only ever matched slashed
273
+ # logical names. Requiring a slash here keeps the two paths disjoint.
274
+ #
275
+ def explicit_path_regex
276
+ %r{(?<!\w)#{render_method_pattern}(?:(?:partial|layout):\s*)?["']([^"'/]+(?:/[^"'/]+)+)["']}
277
+ end
278
+
279
+ # A positional string whose basename starts with _ names a partial file
280
+ # directly: render_to_string("shared/notes/_note") uses shared/notes/note.
281
+ # There is no partial:/layout: keyword here, so it is handled separately
282
+ # from #usage_regex and the bareword path. Only the leading _ of the
283
+ # BASENAME is stripped; intermediate path segments are kept verbatim.
284
+ #
285
+ # A method (not a constant) so configured render_helpers extend the
286
+ # recognized method token via #render_method_pattern.
287
+ #
288
+ def positional_underscore_regex
289
+ /(?<!\w)#{render_method_pattern}["']([^"']*\/_[^"'\/]+)["']/
290
+ end
291
+
292
+ # The recognized render-method token for EXPLICIT-PATH matching. Defaults
293
+ # to RENDER_METHOD; when the partials strategy is configured with
294
+ # render_helpers, each helper name is added to the alternation so an
295
+ # app-specific helper that renders a partial from a string path is treated
296
+ # like render for explicit-path detection.
297
+ #
298
+ # strategies:
299
+ # partials:
300
+ # render_helpers:
301
+ # - view_to_html_string
302
+ # - tabs_json
303
+ #
304
+ # Helper names are Regexp.quote'd so metacharacters match literally, and
305
+ # the caller keeps the (?<!\w) left-anchor so tabs_json does not match
306
+ # my_tabs_json. Returns RENDER_METHOD unchanged when none are configured,
307
+ # so default behavior is byte-identical to today.
308
+ #
309
+ def render_method_pattern
310
+ helpers = Keela.configuration.options_for(name)["render_helpers"]
311
+ return RENDER_METHOD unless helpers.is_a?(Array) && helpers.any?
312
+
313
+ quoted = helpers.map { |h| Regexp.quote(h.to_s) }
314
+ /(?:render(?:_to_string|_to_body)?|#{quoted.join('|')})\s*\(?\s*/
315
+ end
316
+ end
317
+ end
318
+ end
@@ -43,6 +43,26 @@ module Keela
43
43
  raise NotImplementedError, "#{self.class} must implement #usage_regex"
44
44
  end
45
45
 
46
+ # Whether +name+ is used anywhere in +source+ (a Keela::Source).
47
+ #
48
+ # Defaults to matching #usage_regex against the raw source. Override when an
49
+ # equivalent formulation is cheaper to match. An override must decide
50
+ # exactly what #usage_regex would decide.
51
+ #
52
+ def used?(name, source)
53
+ usage_regex(name).match?(source.text)
54
+ end
55
+
56
+ # Force any derived Keela::Source view this strategy needs in #used?.
57
+ #
58
+ # The scanner calls this once before it forks its workers, so the views are
59
+ # built in the parent and inherited copy-on-write rather than being rebuilt
60
+ # by every worker.
61
+ #
62
+ def prepare(_source)
63
+ nil
64
+ end
65
+
46
66
  # Whether to skip lines that start with # (comments)
47
67
  def skip_comments?
48
68
  false
@@ -63,6 +83,20 @@ module Keela
63
83
  Set.new
64
84
  end
65
85
 
86
+ # Whether #additional_used_names already collects EVERY used name in a single
87
+ # pass, making the per-definition #used? scan redundant.
88
+ #
89
+ # Defaults to false: the scanner checks #additional_used_names first and then
90
+ # falls back to #used? per definition. A strategy that returns true here is
91
+ # declaring its #additional_used_names is complete, so the scanner skips the
92
+ # per-definition scan entirely. This turns O(N_definitions x codebase) regex
93
+ # scanning into a single O(codebase) pass plus Set membership tests, for
94
+ # strategies whose usage can be enumerated in one sweep (e.g. partials).
95
+ #
96
+ def detects_all_usage?
97
+ false
98
+ end
99
+
66
100
  private
67
101
 
68
102
  def configured_pattern
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.4.1"
4
+ VERSION = "0.5.1"
5
5
  end
data/lib/keela.rb CHANGED
@@ -3,6 +3,7 @@
3
3
  require_relative "keela/version"
4
4
  require_relative "keela/configuration"
5
5
  require_relative "keela/config_file"
6
+ require_relative "keela/source"
6
7
  require_relative "keela/strategy"
7
8
  require_relative "keela/strategies/methods"
8
9
  require_relative "keela/strategies/scopes"
@@ -10,6 +11,7 @@ require_relative "keela/strategies/constants"
10
11
  require_relative "keela/strategies/delegations"
11
12
  require_relative "keela/strategies/attributes"
12
13
  require_relative "keela/strategies/i18n_keys"
14
+ require_relative "keela/strategies/partials"
13
15
  require_relative "keela/reporter"
14
16
  require_relative "keela/baseline"
15
17
  require_relative "keela/scanner"
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.4.1
4
+ version: 0.5.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kerri Miller
@@ -88,11 +88,13 @@ files:
88
88
  - lib/keela/formatters/toon.rb
89
89
  - lib/keela/reporter.rb
90
90
  - lib/keela/scanner.rb
91
+ - lib/keela/source.rb
91
92
  - lib/keela/strategies/attributes.rb
92
93
  - lib/keela/strategies/constants.rb
93
94
  - lib/keela/strategies/delegations.rb
94
95
  - lib/keela/strategies/i18n_keys.rb
95
96
  - lib/keela/strategies/methods.rb
97
+ - lib/keela/strategies/partials.rb
96
98
  - lib/keela/strategies/scopes.rb
97
99
  - lib/keela/strategy.rb
98
100
  - lib/keela/version.rb
@@ -111,7 +113,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
111
113
  requirements:
112
114
  - - ">="
113
115
  - !ruby/object:Gem::Version
114
- version: 3.1.0
116
+ version: 3.3.0
115
117
  required_rubygems_version: !ruby/object:Gem::Requirement
116
118
  requirements:
117
119
  - - ">="