keela 0.4.1 → 0.5.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 +4 -4
- data/CHANGELOG.md +22 -0
- data/README.md +53 -14
- data/exe/keela +5 -3
- data/lib/keela/scanner.rb +15 -4
- data/lib/keela/source.rb +107 -0
- data/lib/keela/strategies/attributes.rb +30 -10
- data/lib/keela/strategies/delegations.rb +27 -0
- data/lib/keela/strategies/partials.rb +246 -0
- data/lib/keela/strategy.rb +20 -0
- data/lib/keela/version.rb +1 -1
- data/lib/keela.rb +2 -0
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0755e73eaeeb61f9f97ffa109fd8c421316e7725b83ef2502a37c5ba0111858f
|
|
4
|
+
data.tar.gz: d66e1092dd578f28ae3d727b179b601fcd7068671550f9186e53c76a543d1763
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: bf117842ed4d8891671dac92ee34c48b5433f50795f9a90963211c1e650df11b31e72c2d142a6fc489f3dfb040989341764ab6eba4d2ea70432828bd6ed1c1dc
|
|
7
|
+
data.tar.gz: ac2d7352393d70240f7ad1541f2ff476e5ed7c8def8b13212721a527df4e7c285b2c31519333ebe712aab31e9a186a997420598e7e7662af181353cf9f8b96ca
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.5.0] - 2026-09-21
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **`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))
|
|
15
|
+
- **`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))
|
|
16
|
+
- `Keela::Source`, which holds the concatenated source and its case-folded view
|
|
17
|
+
- `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
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
- 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
|
|
22
|
+
- **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
|
|
23
|
+
- **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
|
|
24
|
+
- **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
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
|
|
28
|
+
- **`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))
|
|
29
|
+
- 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))
|
|
30
|
+
- 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))
|
|
31
|
+
|
|
10
32
|
## [0.4.1] - 2026-08-21
|
|
11
33
|
|
|
12
34
|
### 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
|
|
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
|
-
🔍
|
|
9
|
-
🎯 Baseline mode
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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`
|
|
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
|
|
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
|
|
254
|
+
# ❌ Interpolated symbols: what method does this call?
|
|
237
255
|
public_send(:"add_#{role}", user)
|
|
238
256
|
|
|
239
|
-
# ❌ Variable method names
|
|
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
|
|
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"
|
|
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,35 @@ module Keela
|
|
|
208
208
|
end
|
|
209
209
|
|
|
210
210
|
def find_unused(definitions, show_progress: false)
|
|
211
|
-
|
|
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
|
+
# Build the source views the strategy needs here, in the parent, so that
|
|
218
|
+
# the workers Parallel forks below inherit them instead of each building
|
|
219
|
+
# its own copy of a string the size of the whole codebase.
|
|
220
|
+
strategy.prepare(source)
|
|
221
|
+
|
|
217
222
|
progress_label = show_progress ? "Checking #{strategy.name}" : nil
|
|
218
223
|
|
|
219
224
|
unused = Parallel.flat_map(definitions, progress: progress_label) do |definition|
|
|
220
225
|
# Check if marked as used by additional detection
|
|
221
226
|
next [] if additional_used.include?(definition[:name])
|
|
222
227
|
|
|
223
|
-
|
|
224
|
-
regex.match?(source_code) ? [] : definition
|
|
228
|
+
strategy.used?(definition[:name], source) ? [] : definition
|
|
225
229
|
end
|
|
226
230
|
|
|
231
|
+
# A single logical definition can be extracted from multiple lines
|
|
232
|
+
# (e.g. the same method delegated twice, or a constant declared and
|
|
233
|
+
# referenced), so guard against listing the same name twice per file.
|
|
234
|
+
#
|
|
227
235
|
unused.each do |unused_def|
|
|
228
|
-
@unused_collection[unused_def[:file]]
|
|
236
|
+
names = @unused_collection[unused_def[:file]]
|
|
237
|
+
next if names.include?(unused_def[:name])
|
|
238
|
+
|
|
239
|
+
names << unused_def[:name]
|
|
229
240
|
if unused_def[:line]
|
|
230
241
|
@source_locations["#{unused_def[:file]}:#{unused_def[:name]}"] = unused_def[:line]
|
|
231
242
|
end
|
data/lib/keela/source.rb
ADDED
|
@@ -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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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,246 @@
|
|
|
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
|
+
# The render family we recognize: render, plus render_to_string and
|
|
59
|
+
# render_to_body, which render partials the same way (Rails/GitLab use both
|
|
60
|
+
# off-request, e.g. in mailers and background jobs). An optional opening
|
|
61
|
+
# paren after the method lets us match both the no-parens same-line form
|
|
62
|
+
# (render_to_string partial: "x/y") and the contiguous parens form
|
|
63
|
+
# (render_to_string(partial: "x/y")). Multi-line detached partial: args are
|
|
64
|
+
# out of scope; see issue #84.
|
|
65
|
+
#
|
|
66
|
+
RENDER_METHOD = /render(?:_to_string|_to_body)?\s*\(?\s*/
|
|
67
|
+
|
|
68
|
+
# A bareword render: render "form" / render 'form' / render partial: "form"
|
|
69
|
+
# / render layout: "form". layout: renders a partial as a layout, so it
|
|
70
|
+
# counts as a use just like partial:. Only barewords (no slash) are
|
|
71
|
+
# captured here; paths with a slash are handled by #usage_regex.
|
|
72
|
+
#
|
|
73
|
+
# The leading negative lookbehind anchors the method on its left so that
|
|
74
|
+
# prerender/foo_render(_to_string) do not count as a render.
|
|
75
|
+
#
|
|
76
|
+
# This uses RENDER_METHOD, not #render_method_pattern, on purpose:
|
|
77
|
+
# configured render_helpers must never enable bareword-relative resolution.
|
|
78
|
+
# A bare 'form' passed to an arbitrary app helper is too ambiguous to
|
|
79
|
+
# safely resolve against the caller's directory.
|
|
80
|
+
#
|
|
81
|
+
BAREWORD_RENDER_REGEX = /(?<!\w)#{RENDER_METHOD}(?:(?:partial|layout):\s*)?["']([^"'\/]+)["']/
|
|
82
|
+
|
|
83
|
+
def name
|
|
84
|
+
"partials"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def default_definition_file_pattern
|
|
88
|
+
DEFINITION_FILE_REGEX
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Definitions come from filenames, not contents.
|
|
92
|
+
def extract_definitions_from_file(filepath, _lines)
|
|
93
|
+
return [] unless filepath =~ NAME_PATH_REGEX
|
|
94
|
+
|
|
95
|
+
# "users/_form" -> "users/form"; "_form" -> "form"
|
|
96
|
+
name = Regexp.last_match(1).sub(%r{(^|/)_}, '\1')
|
|
97
|
+
|
|
98
|
+
[{ name: name, file: filepath }]
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def extract_definition(_line)
|
|
102
|
+
# Not used; definitions come from #extract_definitions_from_file.
|
|
103
|
+
nil
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def usage_regex(name)
|
|
107
|
+
# render "users/form", render 'users/form',
|
|
108
|
+
# render partial: "users/form", render layout: "users/form"
|
|
109
|
+
# (single/double quotes, tolerant space). layout: renders the partial as
|
|
110
|
+
# a layout, so it counts as a use. render_to_string / render_to_body are
|
|
111
|
+
# recognized too via RENDER_METHOD.
|
|
112
|
+
#
|
|
113
|
+
# The leading negative lookbehind anchors the method on its left so that
|
|
114
|
+
# prerender/foo_render do not count as a render.
|
|
115
|
+
#
|
|
116
|
+
# Configured render_helpers extend the method token here (explicit-path
|
|
117
|
+
# matching only) via #render_method_pattern.
|
|
118
|
+
#
|
|
119
|
+
/(?<!\w)#{render_method_pattern}(?:(?:partial|layout):\s*)?["']#{Regexp.quote(name)}["']/
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def skip_comments?
|
|
123
|
+
true
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Build the comment-stripped view once in the parent so workers inherit it
|
|
127
|
+
# copy-on-write instead of each rebuilding a codebase-sized string.
|
|
128
|
+
#
|
|
129
|
+
def prepare(source)
|
|
130
|
+
@stripped_source = source
|
|
131
|
+
@stripped_text = strip_erb_comments(source.text)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Match against a view with single-line ERB comments removed, so a render
|
|
135
|
+
# inside <%# ... %> does not count as a usage. Falls back to stripping on
|
|
136
|
+
# demand if #prepare was not run for this source (defensive: #used? can be
|
|
137
|
+
# called directly in tests, or on a different source than #prepare saw).
|
|
138
|
+
#
|
|
139
|
+
def used?(name, source)
|
|
140
|
+
usage_regex(name).match?(stripped_text_for(source))
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Resolve bareword renders against the calling file's directory so that a
|
|
144
|
+
# short render marks the fully-qualified partial used.
|
|
145
|
+
#
|
|
146
|
+
def additional_used_names(source_files)
|
|
147
|
+
used = Set.new
|
|
148
|
+
|
|
149
|
+
source_files.each do |filepath, lines|
|
|
150
|
+
content = strip_erb_comments(lines.join("\n"))
|
|
151
|
+
|
|
152
|
+
# Positional-underscore paths are absolute logical names, so they are
|
|
153
|
+
# resolved regardless of the caller's directory. "shared/notes/_note"
|
|
154
|
+
# -> "shared/notes/note" (strip the leading _ of the basename only).
|
|
155
|
+
#
|
|
156
|
+
content.scan(positional_underscore_regex).each do |(path)|
|
|
157
|
+
used << path.sub(%r{/_([^/]+)$}, '/\1')
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
dir = caller_directory(filepath)
|
|
161
|
+
next unless dir
|
|
162
|
+
|
|
163
|
+
content.scan(BAREWORD_RENDER_REGEX).each do |(bareword)|
|
|
164
|
+
used << "#{dir}/#{bareword}"
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
used
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
private
|
|
172
|
+
|
|
173
|
+
# The comment-stripped text for +source+, using the view #prepare built
|
|
174
|
+
# when it saw this same source, otherwise stripping on demand.
|
|
175
|
+
#
|
|
176
|
+
def stripped_text_for(source)
|
|
177
|
+
return @stripped_text if defined?(@stripped_source) && @stripped_source.equal?(source)
|
|
178
|
+
|
|
179
|
+
strip_erb_comments(source.text)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Blank out single-line ERB comment tags so renders inside them are not
|
|
183
|
+
# matched. Matches <% (optional -) then #, up to %> (optional -), on one
|
|
184
|
+
# line; a <%= output %> or <% code %> tag has no # after <% and is left
|
|
185
|
+
# intact. Multi-line ERB comments and HAML comments are out of scope: the
|
|
186
|
+
# dot does not cross newlines, so they are not matched.
|
|
187
|
+
#
|
|
188
|
+
# Replaced with same-length spaces (via the block form, so the length is
|
|
189
|
+
# the matched span's, not a global) to preserve positions and line
|
|
190
|
+
# structure.
|
|
191
|
+
#
|
|
192
|
+
def strip_erb_comments(text)
|
|
193
|
+
text.gsub(/<%\s*-?\s*#.*?-?\s*%>/) { |match| " " * match.length }
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# The directory a bareword render resolves against: the view's own dir, or
|
|
197
|
+
# the controller-derived dir. Returns nil for files that render nothing
|
|
198
|
+
# resolvable this way.
|
|
199
|
+
#
|
|
200
|
+
def caller_directory(filepath)
|
|
201
|
+
return Regexp.last_match(1) if filepath =~ VIEW_DIR_REGEX
|
|
202
|
+
return Regexp.last_match(1) if filepath =~ CONTROLLER_REGEX
|
|
203
|
+
|
|
204
|
+
nil
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# A positional string whose basename starts with _ names a partial file
|
|
208
|
+
# directly: render_to_string("shared/notes/_note") uses shared/notes/note.
|
|
209
|
+
# There is no partial:/layout: keyword here, so it is handled separately
|
|
210
|
+
# from #usage_regex and the bareword path. Only the leading _ of the
|
|
211
|
+
# BASENAME is stripped; intermediate path segments are kept verbatim.
|
|
212
|
+
#
|
|
213
|
+
# A method (not a constant) so configured render_helpers extend the
|
|
214
|
+
# recognized method token via #render_method_pattern.
|
|
215
|
+
#
|
|
216
|
+
def positional_underscore_regex
|
|
217
|
+
/(?<!\w)#{render_method_pattern}["']([^"']*\/_[^"'\/]+)["']/
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# The recognized render-method token for EXPLICIT-PATH matching. Defaults
|
|
221
|
+
# to RENDER_METHOD; when the partials strategy is configured with
|
|
222
|
+
# render_helpers, each helper name is added to the alternation so an
|
|
223
|
+
# app-specific helper that renders a partial from a string path is treated
|
|
224
|
+
# like render for explicit-path detection.
|
|
225
|
+
#
|
|
226
|
+
# strategies:
|
|
227
|
+
# partials:
|
|
228
|
+
# render_helpers:
|
|
229
|
+
# - view_to_html_string
|
|
230
|
+
# - tabs_json
|
|
231
|
+
#
|
|
232
|
+
# Helper names are Regexp.quote'd so metacharacters match literally, and
|
|
233
|
+
# the caller keeps the (?<!\w) left-anchor so tabs_json does not match
|
|
234
|
+
# my_tabs_json. Returns RENDER_METHOD unchanged when none are configured,
|
|
235
|
+
# so default behavior is byte-identical to today.
|
|
236
|
+
#
|
|
237
|
+
def render_method_pattern
|
|
238
|
+
helpers = Keela.configuration.options_for(name)["render_helpers"]
|
|
239
|
+
return RENDER_METHOD unless helpers.is_a?(Array) && helpers.any?
|
|
240
|
+
|
|
241
|
+
quoted = helpers.map { |h| Regexp.quote(h.to_s) }
|
|
242
|
+
/(?:render(?:_to_string|_to_body)?|#{quoted.join('|')})\s*\(?\s*/
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
end
|
data/lib/keela/strategy.rb
CHANGED
|
@@ -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
|
data/lib/keela/version.rb
CHANGED
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
|
+
version: 0.5.0
|
|
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.
|
|
116
|
+
version: 3.3.0
|
|
115
117
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
116
118
|
requirements:
|
|
117
119
|
- - ">="
|