keela 0.5.0 → 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: 0755e73eaeeb61f9f97ffa109fd8c421316e7725b83ef2502a37c5ba0111858f
4
- data.tar.gz: d66e1092dd578f28ae3d727b179b601fcd7068671550f9186e53c76a543d1763
3
+ metadata.gz: 40ac5a23ccbc726eeea66f99e4a6d9cb109360b987daa26e54c43c6d566c448b
4
+ data.tar.gz: 5c6b44f161613c7dea1aae79490918ce8233572345863febcf34a9417710bf60
5
5
  SHA512:
6
- metadata.gz: bf117842ed4d8891671dac92ee34c48b5433f50795f9a90963211c1e650df11b31e72c2d142a6fc489f3dfb040989341764ab6eba4d2ea70432828bd6ed1c1dc
7
- data.tar.gz: ac2d7352393d70240f7ad1541f2ff476e5ed7c8def8b13212721a527df4e7c285b2c31519333ebe712aab31e9a186a997420598e7e7662af181353cf9f8b96ca
6
+ metadata.gz: 69de532e979e8bb052281e2fc58f2c4c6b9039f6a157b88139badff12c4f48abc0df134c817a44f6a6b1dcad131c2fe86c7a667fd51e36d1314184d5444b620d
7
+ data.tar.gz: fc86b2efb01f42db61d20f2a65d029082802a09c480bc6aeda5a6739f4b13c4ebfbb5d061f98520bb0248d10d15b9f4237bd18b8a5a0925b384553915d5f6ed4
data/CHANGELOG.md CHANGED
@@ -7,6 +7,12 @@ 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
+
10
16
  ## [0.5.0] - 2026-09-21
11
17
 
12
18
  ### Added
data/lib/keela/scanner.rb CHANGED
@@ -214,6 +214,17 @@ module Keela
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
+
217
228
  # Build the source views the strategy needs here, in the parent, so that
218
229
  # the workers Parallel forks below inherit them instead of each building
219
230
  # its own copy of a string the size of the whole codebase.
@@ -228,10 +239,16 @@ module Keela
228
239
  strategy.used?(definition[:name], source) ? [] : definition
229
240
  end
230
241
 
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
- #
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)
235
252
  unused.each do |unused_def|
236
253
  names = @unused_collection[unused_def[:file]]
237
254
  next if names.include?(unused_def[:name])
@@ -55,6 +55,13 @@ module Keela
55
55
  #
56
56
  CONTROLLER_REGEX = %r{(?:ee/)?app/controllers/(.+)_controller\.rb$}.freeze
57
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
+
58
65
  # The render family we recognize: render, plus render_to_string and
59
66
  # render_to_body, which render partials the same way (Rails/GitLab use both
60
67
  # off-request, e.g. in mailers and background jobs). An optional opening
@@ -123,6 +130,14 @@ module Keela
123
130
  true
124
131
  end
125
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
+
126
141
  # Build the comment-stripped view once in the parent so workers inherit it
127
142
  # copy-on-write instead of each rebuilding a codebase-sized string.
128
143
  #
@@ -136,12 +151,41 @@ module Keela
136
151
  # demand if #prepare was not run for this source (defensive: #used? can be
137
152
  # called directly in tests, or on a different source than #prepare saw).
138
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
+ #
139
160
  def used?(name, source)
140
161
  usage_regex(name).match?(stripped_text_for(source))
141
162
  end
142
163
 
143
- # Resolve bareword renders against the calling file's directory so that a
144
- # short render marks the fully-qualified partial used.
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.
145
189
  #
146
190
  def additional_used_names(source_files)
147
191
  used = Set.new
@@ -149,6 +193,14 @@ module Keela
149
193
  source_files.each do |filepath, lines|
150
194
  content = strip_erb_comments(lines.join("\n"))
151
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
+
152
204
  # Positional-underscore paths are absolute logical names, so they are
153
205
  # resolved regardless of the caller's directory. "shared/notes/_note"
154
206
  # -> "shared/notes/note" (strip the leading _ of the basename only).
@@ -161,7 +213,10 @@ module Keela
161
213
  next unless dir
162
214
 
163
215
  content.scan(BAREWORD_RENDER_REGEX).each do |(bareword)|
164
- used << "#{dir}/#{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}")
165
220
  end
166
221
  end
167
222
 
@@ -193,17 +248,34 @@ module Keela
193
248
  text.gsub(/<%\s*-?\s*#.*?-?\s*%>/) { |match| " " * match.length }
194
249
  end
195
250
 
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.
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.
199
255
  #
200
256
  def caller_directory(filepath)
201
257
  return Regexp.last_match(1) if filepath =~ VIEW_DIR_REGEX
202
258
  return Regexp.last_match(1) if filepath =~ CONTROLLER_REGEX
259
+ return "" if filepath =~ ROOT_VIEW_REGEX
203
260
 
204
261
  nil
205
262
  end
206
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
+
207
279
  # A positional string whose basename starts with _ names a partial file
208
280
  # directly: render_to_string("shared/notes/_note") uses shared/notes/note.
209
281
  # There is no partial:/layout: keyword here, so it is handled separately
@@ -83,6 +83,20 @@ module Keela
83
83
  Set.new
84
84
  end
85
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
+
86
100
  private
87
101
 
88
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.5.0"
4
+ VERSION = "0.5.1"
5
5
  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.5.0
4
+ version: 0.5.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kerri Miller