gempilot 0.3.1 → 0.3.3

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.
@@ -0,0 +1,911 @@
1
+ # Zeitwerk Task Inflector Fix Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Make `rake zeitwerk:validate` / `zeitwerk:all` (and the version tasks that share the same `Project` introspection) work for gems whose loader inflects the module name (`ECS`, not `Ecs`), by letting Zeitwerk itself say which loader manages the gem instead of guessing a constant name; and bring the `ZeitwerkTask` documentation (and, for consistency, every doc block under `lib/`) to rdoc's canonical form.
6
+
7
+ **Architecture:** Three cooperating changes. (1) A new `Gempilot::ProjectLoader` value object finds, in Zeitwerk's loader registry, the loader whose root directories include the project's autoload root — no constant name involved, so the gem's own `inflector.inflect` rules stay in charge. (2) `ZeitwerkTask`'s child scripts require the gem, then use `ProjectLoader` for `eager_load(force: true)` and `all_expected_cpaths`. (3) `Project` stops deriving a module name at all: it reads `VERSION` by `load`ing `version.rb` under an anonymous module and walking that private module tree, which also removes the constant-redefinition warnings `refresh_version!` used to trigger, so the `warning` runtime dependency goes away. `Project#module_name` / `#klass` are deleted (they were camelize guesses and are exactly the bug).
8
+
9
+ **Tech Stack:** Ruby 4.0, Zeitwerk 2.8 (`Zeitwerk::Registry.loaders`, `Loader#dirs`, `Loader#eager_load(force:)`, `Loader#all_expected_cpaths`, `Loader#cpath_expected_at`), Rake `TaskLib`, RSpec (`spec/`), RuboCop with rubocop-claude.
10
+
11
+ **Issue:** `93D7E88E-B6EB-11F1-8AF9-FE6CB9572C2E` — "Problems with zeitwerk_task.rb" (1. documentation formatting, 2. broken for inflected namespaces; no regexes).
12
+
13
+ ## Global Constraints
14
+
15
+ - Ruby `>= 4.0`; `it` block parameter is used throughout; `Data.define` and `load(path, Module)` (Ruby 3.1+) are fine.
16
+ - No regex-based parsing of the user's loader file or inflections anywhere in this plan (issue requirement). The only regex-free way to learn the real namespace is to ask the loader Zeitwerk registered when the gem was required.
17
+ - Double-quoted strings (`Style/StringLiterals: double_quotes`); NO `# frozen_string_literal:` comments; trailing commas in multiline literals/arguments.
18
+ - rdoc doc blocks in the canonical form: a bare `##` line, then `# ...` lines, no blank line before the definition. Never put a line that reads like Ruby code inside a comment (`Claude/NoCommentedCode`, `MinLines: 1`, flags `require "..."`, assignments, bare identifiers): show usage in prose with `<tt>...</tt>`. Never use `TODO`/`NOTE` without attribution (`Claude/TaggedComments`). Regexes longer than a few characters in specs must be short or named (`Claude/MysteryRegex`).
19
+ - Zeitwerk: new file `lib/gempilot/project_loader.rb` MUST define `Gempilot::ProjectLoader` (`spec/zeitwerk_spec.rb` eager-loads gempilot).
20
+ - Verification: `bundle exec rspec <files>` for focused runs, `bundle exec rubocop <files>` per task, `bundle exec rake default` (= `test` + `spec` + `rubocop`) for the full gate.
21
+ - Baseline (verified 2026-09-23): RSpec `192 examples, 0 failures`; RuboCop `no offenses` on `lib`, `spec`, `test`; minitest `109 runs, 4 failures` — those 4 are the Gemfile-template ordering regression fixed by Task 1 of `docs/superpowers/plans/2026-09-23-land-betterleaks-jruby.md`. Apply that task first (one-line template change) to get a fully green gate; otherwise expect exactly those 4 create-command failures throughout.
22
+ - Commit messages: plain imperative sentences, no conventional-commit prefixes (repo style: "Own the Zeitwerk rake tasks; use tap(&:setup) loader form").
23
+
24
+ ---
25
+
26
+ ### Task 1: `Gempilot::ProjectLoader`
27
+
28
+ **Files:**
29
+ - Create: `lib/gempilot/project_loader.rb`
30
+ - Test: `spec/gempilot/project_loader_spec.rb`
31
+
32
+ **Interfaces:**
33
+ - Consumes: `Gempilot::Error` (defined in `lib/gempilot.rb`), `Zeitwerk::Registry.loaders` (a `Zeitwerk::Registry::Loaders` with `#each`; the same collection `Zeitwerk::Loader.eager_load_all` broadcasts over), `Zeitwerk::Loader#dirs`, `#unregister`.
34
+ - Produces: `Gempilot::ProjectLoader.new(root_dir) -> ProjectLoader` (`root_dir` is any path; stored as `File.realpath`), `#root_dir -> String`, `#loader -> Zeitwerk::Loader` raising `Gempilot::ProjectLoader::NotFound` (a `Gempilot::Error`) when no registered loader manages the directory. Task 3 relies on exactly `ProjectLoader.new(path).loader`.
35
+
36
+ - [ ] **Step 1: Write the failing spec**
37
+
38
+ Create `spec/gempilot/project_loader_spec.rb`:
39
+
40
+ ```ruby
41
+ require "spec_helper"
42
+
43
+ RSpec.describe Gempilot::ProjectLoader do
44
+ around do |example|
45
+ Dir.mktmpdir("project_loader_spec") { |dir| Dir.chdir(dir) { example.run } }
46
+ end
47
+
48
+ def register_loader(dir)
49
+ FileUtils.mkdir_p(dir)
50
+ Zeitwerk::Loader.new.tap do |loader|
51
+ loader.push_dir(dir)
52
+ yield loader if block_given?
53
+ loader.setup
54
+ end
55
+ end
56
+
57
+ describe "#loader" do
58
+ let!(:loader) { register_loader("lib") }
59
+
60
+ after { loader.unregister }
61
+
62
+ it "returns the registered loader managing the directory" do
63
+ expect(described_class.new("lib").loader).to be(loader)
64
+ end
65
+
66
+ it "resolves symlinks before comparing directories" do
67
+ File.symlink("lib", "linked")
68
+ expect(described_class.new("linked").loader).to be(loader)
69
+ end
70
+
71
+ it "raises NotFound when no loader manages the directory" do
72
+ FileUtils.mkdir_p("other")
73
+ expect { described_class.new("other").loader }.to raise_error(described_class::NotFound, /other/)
74
+ end
75
+ end
76
+
77
+ describe "#loader for an inflected namespace" do
78
+ let!(:loader) do
79
+ register_loader("lib") do |l|
80
+ File.write("lib/ecs.rb", "module ECS; end\n")
81
+ l.inflector.inflect("ecs" => "ECS")
82
+ end
83
+ end
84
+
85
+ after { loader.unregister }
86
+
87
+ it "hands back the loader carrying the project's own inflections" do
88
+ found = described_class.new("lib").loader
89
+ expect(found.cpath_expected_at("lib/ecs.rb")).to eq("ECS")
90
+ end
91
+ end
92
+ end
93
+ ```
94
+
95
+ The `after { loader.unregister }` matters: loaders live in Zeitwerk's global registry, and a stale loader pointing at a deleted tmpdir would make later `File.realpath` calls raise.
96
+
97
+ - [ ] **Step 2: Run it to verify it fails**
98
+
99
+ Run: `bundle exec rspec spec/gempilot/project_loader_spec.rb --no-color`
100
+ Expected: FAIL to load — `NameError: uninitialized constant Gempilot::ProjectLoader` (Zeitwerk has no file to autoload it from).
101
+
102
+ - [ ] **Step 3: Create the class**
103
+
104
+ Create `lib/gempilot/project_loader.rb`:
105
+
106
+ ```ruby
107
+ module Gempilot
108
+ ##
109
+ # The Zeitwerk loader that manages a project's autoload root, located
110
+ # through Zeitwerk's loader registry once the project has been required.
111
+ #
112
+ # Zeitwerk knows which loader owns a directory, and a project's constant
113
+ # names are an inflection performed by that loader, not the other way
114
+ # around. Looking the loader up by directory therefore keeps the project's
115
+ # own inflections in charge: a gem whose entry point sets up +ECS+ rather
116
+ # than +Ecs+ is found without anyone guessing its module name.
117
+ class ProjectLoader
118
+ ##
119
+ # Raised when no registered loader manages the directory.
120
+ class NotFound < Error; end
121
+
122
+ ##
123
+ # Absolute, symlink-resolved path of the directory the loader must manage.
124
+ attr_reader :root_dir
125
+
126
+ def initialize(root_dir)
127
+ @root_dir = File.realpath(root_dir)
128
+ end
129
+
130
+ ##
131
+ # Returns the registered loader whose root directories include
132
+ # +root_dir+, raising NotFound when there is none.
133
+ def loader
134
+ @loader ||= registered_loaders.find { manages?(it) } || raise(NotFound, not_found_message)
135
+ end
136
+
137
+ private
138
+
139
+ def manages?(loader)
140
+ loader.dirs.any? { File.realpath(it) == root_dir }
141
+ end
142
+
143
+ def registered_loaders
144
+ Zeitwerk::Registry.loaders.to_enum(:each)
145
+ end
146
+
147
+ def not_found_message
148
+ "No Zeitwerk loader manages #{root_dir}; set one up with Zeitwerk::Loader.for_gem"
149
+ end
150
+ end
151
+ end
152
+ ```
153
+
154
+ Notes for the implementer:
155
+ - `Zeitwerk::Registry` is marked `:nodoc:` upstream, but `Registry.loaders` is the collection `Zeitwerk::Loader.eager_load_all` iterates and has been stable since Zeitwerk 2.0; `to_enum(:each)` is used because `Registry::Loaders` only defines `each` (rubocop's `Style/MapIntoArray` rejects the `each { array << it }` form).
156
+ - `ObjectSpace.each_object(Zeitwerk::Loader)` was rejected because JRuby disables `ObjectSpace` by default and gempilot is used under JRuby (issue 986E0100).
157
+ - `File.realpath` on both sides makes macOS `/var` vs `/private/var` tmpdirs compare equal.
158
+
159
+ - [ ] **Step 4: Run the spec to verify it passes, then rubocop**
160
+
161
+ Run: `bundle exec rspec spec/gempilot/project_loader_spec.rb --no-color && bundle exec rubocop lib/gempilot/project_loader.rb spec/gempilot/project_loader_spec.rb`
162
+ Expected: `4 examples, 0 failures`; `2 files inspected, no offenses detected`.
163
+
164
+ - [ ] **Step 5: Commit**
165
+
166
+ ```bash
167
+ git add lib/gempilot/project_loader.rb spec/gempilot/project_loader_spec.rb
168
+ git commit -m "Add ProjectLoader to find a gem's Zeitwerk loader by directory"
169
+ ```
170
+
171
+ ---
172
+
173
+ ### Task 2: `Project` reads VERSION without a module name
174
+
175
+ **Files:**
176
+ - Modify: `lib/gempilot/project.rb` (full rewrite below)
177
+ - Modify: `gempilot.gemspec:30` (remove `spec.add_dependency "warning"`), `Gemfile.lock` (via `bundle install`)
178
+ - Test: `spec/gempilot/project_spec.rb` (full rewrite below)
179
+
180
+ **Interfaces:**
181
+ - Consumes: `Gempilot::Project::Version` (`Data.define(:path, :value)`, unchanged), `SegmentedVersion` (unchanged).
182
+ - Produces: `Project#autoload_root -> Pathname` (parent of `lib_project`: `lib` for `my_gem`, `lib/my_gem` for `my_gem-extension`); `Project#version` unchanged in signature but no longer needs `module_name`; `Project#klass` is removed. `Project#module_name` stays in this task (ZeitwerkTask still calls it) and is removed in Task 3.
183
+
184
+ - [ ] **Step 1: Rewrite the project spec**
185
+
186
+ Replace the entire contents of `spec/gempilot/project_spec.rb` with:
187
+
188
+ ```ruby
189
+ require "spec_helper"
190
+
191
+ RSpec.describe Gempilot::Project do
192
+ include FileUtils
193
+
194
+ subject(:project) { described_class.new(Dir.pwd) }
195
+
196
+ def in_tempdir
197
+ Dir.mktmpdir("project_spec") do |tmpdir|
198
+ chdir tmpdir do
199
+ yield tmpdir
200
+ end
201
+ end
202
+ end
203
+
204
+ shared_examples "a gem project" do
205
+ describe "#name" do
206
+ it "derives the gem name from the lib layout" do
207
+ expect(project.name).to eq(expected_name)
208
+ end
209
+ end
210
+
211
+ describe "#require_path" do
212
+ it "joins the lib segments with slashes" do
213
+ expect(project.require_path).to eq(expected_require_path)
214
+ end
215
+ end
216
+
217
+ describe "#autoload_root" do
218
+ it "is the directory the gem's Zeitwerk loader manages" do
219
+ expect(project.autoload_root.to_s).to end_with(expected_autoload_root)
220
+ end
221
+ end
222
+
223
+ describe "#version" do
224
+ it "reads the version value from version.rb" do
225
+ expect(project.version.value).to eq("1.2.3")
226
+ end
227
+
228
+ it "points at the version.rb file" do
229
+ expect(project.version.path.to_s).to end_with(version_file)
230
+ end
231
+
232
+ it "does not define the gem's modules in the real namespace" do
233
+ project.version
234
+ expect(Object).not_to be_const_defined(root_constant)
235
+ end
236
+ end
237
+
238
+ describe "#refresh_version!" do
239
+ it "re-reads the version from disk after a file change" do
240
+ project.version
241
+ File.write(version_file, File.read(version_file).sub("1.2.3", "1.2.4"))
242
+ project.refresh_version!
243
+ expect(project.version.value).to eq("1.2.4")
244
+ end
245
+ end
246
+
247
+ describe "#increment_version" do
248
+ it "returns the next patch version" do
249
+ expect(project.increment_version.value).to eq("1.2.4")
250
+ end
251
+ end
252
+
253
+ describe "#write_version!" do
254
+ it "replaces the old version string in the file" do
255
+ old_version = project.version
256
+ new_version = project.increment_version
257
+ project.write_version!(old_version, new_version)
258
+ expect(File.read(version_file)).to include("1.2.4")
259
+ end
260
+ end
261
+ end
262
+
263
+ describe "a regular gem" do
264
+ let(:expected_name) { "my_gem" }
265
+ let(:expected_require_path) { "my_gem" }
266
+ let(:expected_autoload_root) { "/lib" }
267
+ let(:root_constant) { :MyGem }
268
+ let(:version_file) { "lib/my_gem/version.rb" }
269
+
270
+ around do |example|
271
+ in_tempdir do
272
+ Pathname("lib/my_gem").mkpath
273
+ File.write "lib/my_gem.rb", "module MyGem; end\n"
274
+ File.write version_file, <<~RUBY
275
+ module MyGem
276
+ VERSION = "1.2.3".freeze
277
+ end
278
+ RUBY
279
+ example.run
280
+ end
281
+ end
282
+
283
+ it_behaves_like "a gem project"
284
+
285
+ context "when lib has a .rb file with no matching gem directory" do
286
+ before do
287
+ rm_rf("lib/my_gem")
288
+ rm("lib/my_gem.rb")
289
+ File.write("lib/standalone.rb", "# no matching dir\n")
290
+ end
291
+
292
+ it "raises ProjectIntrospectionError" do
293
+ expect { project.name }.to raise_error(Gempilot::Project::ProjectIntrospectionError)
294
+ end
295
+ end
296
+
297
+ context "when lib has more than one candidate gem" do
298
+ before do
299
+ Pathname("lib/other").mkpath
300
+ File.write("lib/other.rb", "module Other; end\n")
301
+ end
302
+
303
+ it "raises ProjectIntrospectionError naming the ambiguity" do
304
+ expect { project.name }
305
+ .to raise_error(Gempilot::Project::ProjectIntrospectionError, /more than one/)
306
+ end
307
+ end
308
+
309
+ context "when version.rb defines no VERSION constant" do
310
+ before { File.write(version_file, "module MyGem\nend\n") }
311
+
312
+ it "raises ProjectIntrospectionError naming the file" do
313
+ expect { project.version }
314
+ .to raise_error(Gempilot::Project::ProjectIntrospectionError, /VERSION constant/)
315
+ end
316
+ end
317
+ end
318
+
319
+ describe "a gem extension" do
320
+ let(:expected_name) { "my_gem-extension" }
321
+ let(:expected_require_path) { "my_gem/extension" }
322
+ let(:expected_autoload_root) { "/lib/my_gem" }
323
+ let(:root_constant) { :MyGem }
324
+ let(:version_file) { "lib/my_gem/extension/version.rb" }
325
+
326
+ around do |example|
327
+ in_tempdir do
328
+ Pathname("lib/my_gem/extension").mkpath
329
+ File.write "lib/my_gem/extension.rb", <<~RUBY
330
+ module MyGem
331
+ module Extension
332
+ end
333
+ end
334
+ RUBY
335
+ File.write version_file, <<~RUBY
336
+ module MyGem
337
+ module Extension
338
+ VERSION = "1.2.3".freeze
339
+ end
340
+ end
341
+ RUBY
342
+ example.run
343
+ end
344
+ end
345
+
346
+ it_behaves_like "a gem project"
347
+ end
348
+
349
+ describe "a gem whose module name is inflected" do
350
+ let(:version_file) { "lib/ecs/version.rb" }
351
+
352
+ around do |example|
353
+ in_tempdir do
354
+ Pathname("lib/ecs").mkpath
355
+ File.write "lib/ecs.rb", "module ECS; end\n"
356
+ File.write version_file, "module ECS\n VERSION = \"1.2.3\".freeze\nend\n"
357
+ example.run
358
+ end
359
+ end
360
+
361
+ it "reads the version without guessing the module name" do
362
+ expect(project.version.value).to eq("1.2.3")
363
+ end
364
+ end
365
+ end
366
+ ```
367
+
368
+ (Compared with the old spec: the `#module_name` and `#klass` examples are gone, `#autoload_root`, "does not define the gem's modules in the real namespace", the missing-VERSION error, and the inflected-gem example are new. The regex `/VERSION constant/` is deliberately short: `Claude/MysteryRegex` rejects longer inline regexes.)
369
+
370
+ - [ ] **Step 2: Run it to verify it fails for the right reasons**
371
+
372
+ Run: `bundle exec rspec spec/gempilot/project_spec.rb --no-color`
373
+ Expected: failures — `NoMethodError: undefined method 'autoload_root'` (×2), "does not define the gem's modules" fails because today `load` defines `::MyGem` (×2), the inflected gem raises `NameError: uninitialized constant Ecs` from `Object.const_get("Ecs")`, and the missing-VERSION example gets `NameError` instead of `ProjectIntrospectionError`. Everything else passes.
374
+
375
+ - [ ] **Step 3: Rewrite Project**
376
+
377
+ Replace the entire contents of `lib/gempilot/project.rb` with:
378
+
379
+ ```ruby
380
+ module Gempilot
381
+ ##
382
+ # Introspects a gem project to discover its name, require path, autoload
383
+ # root, and version. Works for both regular gems (+lib/my_gem.rb+) and
384
+ # extension gems whose entry point nests deeper (+lib/my_gem/extension.rb+
385
+ # for +my_gem-extension+).
386
+ #
387
+ # The version is read by loading +version.rb+ under a throwaway module, so
388
+ # the project's module name is never needed and reloading after a bump
389
+ # never redefines a real constant.
390
+ class Project
391
+ class ProjectIntrospectionError < StandardError; end
392
+
393
+ using String::Inflectable
394
+
395
+ attr_reader :root
396
+
397
+ def initialize(root = Dir.pwd)
398
+ @root = Pathname(root)
399
+ @verifications = Set.new
400
+ end
401
+
402
+ def lib
403
+ root.join("lib")
404
+ .tap { verify_existence! it }
405
+ end
406
+
407
+ def lib_project
408
+ @lib_project ||= fetch_lib_project
409
+ end
410
+
411
+ def name
412
+ project_segments.join("-")
413
+ end
414
+
415
+ def require_path
416
+ project_segments.join("/")
417
+ end
418
+
419
+ def module_name
420
+ project_segments.map(&:camelize).join("::")
421
+ end
422
+
423
+ ##
424
+ # The directory a loader set up with +for_gem+ or +for_gem_extension+
425
+ # manages: the parent of the project's namespace directory.
426
+ def autoload_root
427
+ lib_project.parent
428
+ end
429
+
430
+ def version
431
+ @version ||= fetch_version
432
+ end
433
+
434
+ def refresh_version!
435
+ @version = fetch_version
436
+ end
437
+
438
+ def increment_version
439
+ version.next_version
440
+ end
441
+
442
+ def version_tag = version.tag
443
+
444
+ def version_value = version.value
445
+
446
+ def write_version!(old_version, new_version)
447
+ with_version_file do |f|
448
+ source = f.read
449
+
450
+ unless source.match?(Regexp.escape(old_version.value))
451
+ abort "Expected to find #{old_version.value} in #{f.path} but did not"
452
+ end
453
+
454
+ f.rewind
455
+ f.write source.gsub(old_version.value, new_version.value)
456
+ end
457
+ end
458
+
459
+ private
460
+
461
+ def project_segments
462
+ lib_project.relative_path_from(lib).each_filename.to_a
463
+ end
464
+
465
+ def with_version_file
466
+ version.path.open(File::RDWR, 0o644) do |f|
467
+ f.flock File::LOCK_EX
468
+ yield f
469
+ f.truncate(f.pos)
470
+ end
471
+ end
472
+
473
+ def fetch_lib_project
474
+ dirs = shallowest_entry_dirs
475
+ case dirs.count
476
+ in 0 then raise ProjectIntrospectionError, "Could not identify project dir"
477
+ in (2..)
478
+ msg = "Found more than one possible project name:\n - #{dirs.join("\n - ")}"
479
+ raise ProjectIntrospectionError, msg
480
+ in 1 then dirs.first
481
+ end
482
+ end
483
+
484
+ def shallowest_entry_dirs
485
+ entry_dirs.group_by { depth_below_lib(it) }
486
+ .min_by(&:first)
487
+ &.last || []
488
+ end
489
+
490
+ def entry_dirs
491
+ lib.glob("**/*.rb")
492
+ .map { it.sub_ext("") }
493
+ .select(&:directory?)
494
+ end
495
+
496
+ def depth_below_lib(path)
497
+ path.relative_path_from(lib).each_filename.count
498
+ end
499
+
500
+ def fetch_version
501
+ path = lib_project.join("version.rb").tap { verify_existence! it }
502
+ Version.new(path:, value: version_defined_in(path))
503
+ end
504
+
505
+ # Loads the version file under an anonymous module so the modules it opens
506
+ # live there instead of in the real namespace, then walks that private
507
+ # module tree down to VERSION.
508
+ def version_defined_in(path)
509
+ sandbox = Module.new
510
+ load path.to_s, sandbox
511
+ version_in(sandbox) || raise(ProjectIntrospectionError, "Expected #{path} to define a VERSION constant")
512
+ end
513
+
514
+ def version_in(mod)
515
+ return mod.const_get(:VERSION, false) if mod.const_defined?(:VERSION, false)
516
+
517
+ mod.constants(false)
518
+ .map { mod.const_get(it, false) }
519
+ .grep(Module)
520
+ .filter_map { version_in(it) }
521
+ .first
522
+ end
523
+
524
+ def verify_existence!(path)
525
+ return true if @verifications.member? path
526
+
527
+ raise ProjectIntrospectionError, "Expected #{path} to exist but does not" unless path.exist?
528
+
529
+ @verifications.add path
530
+ end
531
+ end
532
+ end
533
+ ```
534
+
535
+ What changed: `require "warning"`, the two `*_WARNING` constants and the `Warning.ignore` calls are gone (each load uses a fresh anonymous module, so nothing is ever redefined); `klass` is gone; `fetch_version` delegates to `version_defined_in` / `version_in`; `autoload_root` is new; `module_name` is kept only until Task 3. `load(path, module)` runs the file with the module as its lexical scope, so `module MyGem` inside `version.rb` creates `sandbox::MyGem`, never touching `::MyGem` (verified with plain Ruby on 2026-09-23, including the nested extension shape and a pre-existing top-level `MyGem`).
536
+
537
+ - [ ] **Step 4: Run the spec and the neighbours that depend on Project**
538
+
539
+ Run: `bundle exec rspec spec/gempilot/project_spec.rb spec/gempilot/version_task_spec.rb spec/gempilot/zeitwerk_task_spec.rb spec/zeitwerk_spec.rb --no-color && bundle exec rubocop lib/gempilot/project.rb spec/gempilot/project_spec.rb`
540
+ Expected: all examples pass (`version_task_spec` still bumps `1.0.0.dev3` correctly; `zeitwerk_task_spec` still passes because `module_name` still exists); `2 files inspected, no offenses detected`.
541
+
542
+ - [ ] **Step 5: Drop the warning dependency**
543
+
544
+ In `gempilot.gemspec` delete the line:
545
+
546
+ ```ruby
547
+ spec.add_dependency "warning"
548
+ ```
549
+
550
+ Then run `bundle install 2>&1 | tail -2` (expected `Bundle complete!`; `git diff Gemfile.lock` removes `warning` from the `gempilot` PATH spec and, since nothing else depends on it, from the `GEM` specs and `CHECKSUMS`).
551
+
552
+ Verify nothing else references it: `grep -rn "warning" lib gempilot.gemspec Gemfile` must print nothing.
553
+
554
+ - [ ] **Step 6: Full gate and commit**
555
+
556
+ Run: `bundle exec rake default 2>&1 | tail -8`
557
+ Expected: RSpec `198 examples, 0 failures` (196 after Task 1, plus the two net-new project examples), RuboCop no offenses, minitest unchanged from baseline.
558
+
559
+ ```bash
560
+ git add lib/gempilot/project.rb spec/gempilot/project_spec.rb gempilot.gemspec Gemfile.lock
561
+ git commit -m "Read VERSION in a sandbox instead of guessing the module name"
562
+ ```
563
+
564
+ ---
565
+
566
+ ### Task 3: `ZeitwerkTask` asks Zeitwerk for the loader
567
+
568
+ **Files:**
569
+ - Modify: `lib/gempilot/zeitwerk_task.rb` (full rewrite below)
570
+ - Modify: `lib/gempilot/project.rb` (delete `module_name` and the now-unused `using String::Inflectable`)
571
+ - Test: `spec/gempilot/zeitwerk_task_spec.rb` (full rewrite below)
572
+
573
+ **Interfaces:**
574
+ - Consumes: `Gempilot::ProjectLoader.new(root_dir).loader` (Task 1), `Project#autoload_root` and `Project#require_path` (Task 2).
575
+ - Produces: unchanged task names `zeitwerk:validate` and `zeitwerk:all`; child scripts that `require "gempilot"` and the gem, then use the found loader. `Project#module_name` no longer exists after this task (grep confirms it has no other callers).
576
+
577
+ - [ ] **Step 1: Rewrite the task spec with an inflected-gem fixture**
578
+
579
+ Replace the entire contents of `spec/gempilot/zeitwerk_task_spec.rb` with:
580
+
581
+ ```ruby
582
+ require "spec_helper"
583
+
584
+ RSpec.describe Gempilot::ZeitwerkTask do
585
+ around do |example|
586
+ old_app = Rake.application
587
+ Rake.application = Rake::Application.new
588
+ Dir.mktmpdir("zeitwerk_task_spec") do |tmpdir|
589
+ Dir.chdir(tmpdir) { example.run }
590
+ end
591
+ ensure
592
+ Rake.application = old_app
593
+ end
594
+
595
+ def write_gem(name:, mod:, inflections: {})
596
+ FileUtils.mkdir_p("lib/#{name}")
597
+ File.write("#{name}.gemspec", %(Gem::Specification.new { |s| s.name = "#{name}" }))
598
+ File.write("lib/#{name}.rb", <<~RUBY)
599
+ require "zeitwerk"
600
+ module #{mod}
601
+ LOADER = Zeitwerk::Loader.for_gem.tap do |l|
602
+ l.inflector.inflect(#{inflections.inspect})
603
+ l.setup
604
+ end
605
+ end
606
+ RUBY
607
+ File.write("lib/#{name}/version.rb", "module #{mod}\n VERSION = \"1.0.0\".freeze\nend\n")
608
+ end
609
+
610
+ describe "task definitions" do
611
+ before do
612
+ write_gem(name: "my_gem", mod: "MyGem")
613
+ described_class.new(root: Dir.pwd)
614
+ end
615
+
616
+ it "defines zeitwerk:validate" do
617
+ expect(Rake::Task).to be_task_defined("zeitwerk:validate")
618
+ end
619
+
620
+ it "defines zeitwerk:all" do
621
+ expect(Rake::Task).to be_task_defined("zeitwerk:all")
622
+ end
623
+ end
624
+
625
+ describe "zeitwerk:validate" do
626
+ before do
627
+ write_gem(name: "my_gem", mod: "MyGem")
628
+ described_class.new(root: Dir.pwd)
629
+ end
630
+
631
+ it "passes for a conventionally-named gem" do
632
+ expect { Rake::Task["zeitwerk:validate"].invoke }.not_to raise_error
633
+ end
634
+
635
+ context "when a file breaks the naming convention" do
636
+ before { File.write("lib/my_gem/oops.rb", "module MyGem; class Correct; end; end\n") }
637
+
638
+ it "fails" do
639
+ expect { Rake::Task["zeitwerk:validate"].invoke }.to raise_error(RuntimeError)
640
+ end
641
+ end
642
+ end
643
+
644
+ describe "zeitwerk:all" do
645
+ before do
646
+ write_gem(name: "my_gem", mod: "MyGem")
647
+ described_class.new(root: Dir.pwd)
648
+ end
649
+
650
+ it "lists the constants the loader expects" do
651
+ expect { Rake::Task["zeitwerk:all"].invoke }.to output(/MyGem::VERSION/).to_stdout_from_any_process
652
+ end
653
+ end
654
+
655
+ describe "a gem whose loader inflects its module name" do
656
+ before do
657
+ write_gem(name: "ecs", mod: "ECS", inflections: { "ecs" => "ECS" })
658
+ described_class.new(root: Dir.pwd)
659
+ end
660
+
661
+ it "validates through the gem's own loader" do
662
+ expect { Rake::Task["zeitwerk:validate"].invoke }.not_to raise_error
663
+ end
664
+
665
+ it "lists constants under the inflected namespace" do
666
+ expect { Rake::Task["zeitwerk:all"].invoke }.to output(/ECS::VERSION/).to_stdout_from_any_process
667
+ end
668
+ end
669
+ end
670
+ ```
671
+
672
+ `to_stdout_from_any_process` is required because the tasks print from a child `ruby` process.
673
+
674
+ - [ ] **Step 2: Run it to verify the inflected examples fail**
675
+
676
+ Run: `bundle exec rspec spec/gempilot/zeitwerk_task_spec.rb --no-color`
677
+ Expected: `7 examples, 2 failures` — both examples in "a gem whose loader inflects its module name" fail: the child process prints `uninitialized constant Ecs (NameError)` and Rake's `ruby` raises `RuntimeError: Command failed with status (1)`. The five `my_gem` examples pass.
678
+
679
+ - [ ] **Step 3: Rewrite ZeitwerkTask**
680
+
681
+ Replace the entire contents of `lib/gempilot/zeitwerk_task.rb` with:
682
+
683
+ ```ruby
684
+ require "rake/tasklib"
685
+ require_relative "../gempilot"
686
+
687
+ module Gempilot
688
+ ##
689
+ # Rake tasks for validating and inspecting a gem's Zeitwerk loader.
690
+ #
691
+ # Owned by gempilot and consumed by generated gems, whose Rakefile requires
692
+ # <tt>gempilot/zeitwerk_task</tt> and instantiates <tt>Gempilot::ZeitwerkTask.new</tt>,
693
+ # so the logic rolls forward on a gempilot bump instead of being copied into
694
+ # every gem's Rakefile.
695
+ #
696
+ # Each task boots a clean child process that requires the gem and then asks
697
+ # Zeitwerk for the loader managing the gem's autoload root (see
698
+ # ProjectLoader). The gem's own inflections stay in charge, so a gem whose
699
+ # entry point sets up +ECS+ rather than +Ecs+ validates like any other, and
700
+ # eager loading never pollutes the Rake process.
701
+ class ZeitwerkTask < Rake::TaskLib
702
+ attr_reader :project
703
+
704
+ def initialize(root: Dir.pwd)
705
+ super()
706
+ @project = Project.new(root)
707
+ define_tasks
708
+ end
709
+
710
+ private
711
+
712
+ def define_tasks
713
+ namespace :zeitwerk do
714
+ define_validate_task
715
+ define_all_task
716
+ end
717
+ end
718
+
719
+ def define_validate_task
720
+ desc "Verify all files follow Zeitwerk naming conventions"
721
+ task(:validate) { ruby "-Ilib", "-e", validate_script }
722
+ end
723
+
724
+ def define_all_task
725
+ desc "List every constant Zeitwerk manages and the file it expects"
726
+ task(:all) { ruby "-Ilib", "-e", all_script }
727
+ end
728
+
729
+ def loader_script
730
+ <<~RUBY
731
+ require "gempilot"
732
+ require #{project.require_path.inspect}
733
+ loader = Gempilot::ProjectLoader.new(#{project.autoload_root.to_s.inspect}).loader
734
+ RUBY
735
+ end
736
+
737
+ def validate_script
738
+ <<~RUBY
739
+ #{loader_script}
740
+ loader.eager_load(force: true)
741
+ puts "Zeitwerk: All files loaded successfully."
742
+ RUBY
743
+ end
744
+
745
+ def all_script
746
+ <<~RUBY
747
+ #{loader_script}
748
+ rows = loader.all_expected_cpaths.sort_by(&:last)
749
+ width = rows.map { |_path, cpath| cpath.length }.max || 0
750
+ rows.each { |path, cpath| puts format("%-\#{width}s %s", cpath, path) }
751
+ RUBY
752
+ end
753
+ end
754
+ end
755
+ ```
756
+
757
+ This is also the documentation fix from the issue: the class doc is now a canonical rdoc block (bare `##`, then `#` lines, paragraphs separated by `#`) and the usage is prose with two `<tt>` spans instead of a two-statement snippet crammed into one `<tt>` with a semicolon. A verbatim (indented) code sample was rejected on purpose: `Claude/NoCommentedCode` flags a comment line such as `# require "gempilot/zeitwerk_task"` as commented-out code. The child requires `gempilot` (already in every generated gem's bundle, since the Rakefile requires `gempilot/zeitwerk_task`) so that `ProjectLoader` is available in the child; `require_path` and `autoload_root` are interpolated with `inspect` so any path is quoted correctly.
758
+
759
+ - [ ] **Step 4: Delete `module_name` from Project**
760
+
761
+ In `lib/gempilot/project.rb` remove these two pieces (nothing else calls them; confirm with `grep -rn "module_name\|Inflectable" lib`, which must only show `gem_constant.rb`, `gem_context.rb`, `create.rb`, `new.rb`, `destroy.rb` afterwards):
762
+
763
+ ```ruby
764
+ using String::Inflectable
765
+ ```
766
+
767
+ ```ruby
768
+ def module_name
769
+ project_segments.map(&:camelize).join("::")
770
+ end
771
+ ```
772
+
773
+ - [ ] **Step 5: Run the specs and rubocop**
774
+
775
+ Run: `bundle exec rspec spec/gempilot/zeitwerk_task_spec.rb spec/gempilot/project_spec.rb spec/gempilot/project_loader_spec.rb spec/zeitwerk_spec.rb --no-color && bundle exec rubocop lib/gempilot/zeitwerk_task.rb lib/gempilot/project.rb spec/gempilot/zeitwerk_task_spec.rb`
776
+ Expected: all pass (`7 examples` in the task spec, including both `ECS` examples); `3 files inspected, no offenses detected`.
777
+
778
+ - [ ] **Step 6: Full gate and commit**
779
+
780
+ Run: `bundle exec rake default 2>&1 | tail -8`
781
+ Expected: RSpec `0 failures`, RuboCop no offenses, minitest as in the baseline.
782
+
783
+ ```bash
784
+ git add lib/gempilot/zeitwerk_task.rb lib/gempilot/project.rb spec/gempilot/zeitwerk_task_spec.rb
785
+ git commit -m "Locate the gem's Zeitwerk loader through Zeitwerk instead of a guessed constant"
786
+ ```
787
+
788
+ ---
789
+
790
+ ### Task 4: Documentation updates
791
+
792
+ **Files:**
793
+ - Modify: `CLAUDE.md` (Architecture list; Generated Gem Features list)
794
+ - Modify: `README.md` (Generated Gem Features list)
795
+
796
+ **Interfaces:**
797
+ - Consumes: final behaviour from Tasks 1–3. No code.
798
+
799
+ - [ ] **Step 1: Update CLAUDE.md**
800
+
801
+ In the Architecture list, replace the `GemConstant` bullet's neighbour context by adding, after the `SegmentedVersion` bullet:
802
+
803
+ ```markdown
804
+ - `ProjectLoader` (`lib/gempilot/project_loader.rb`) finds the Zeitwerk loader that manages a directory through `Zeitwerk::Registry`; `ZeitwerkTask` child scripts use it, so a gem's own `inflector.inflect` rules (e.g. `ECS`) are honoured and no module name is ever guessed from a path
805
+ - `Project` (`lib/gempilot/project.rb`) knows a gem's name, require path, autoload root, and version; the version is read by `load`ing `version.rb` under an anonymous module, so `Project` never derives or needs the gem's module name (there is deliberately no `module_name`/`klass`)
806
+ ```
807
+
808
+ In the Generated Gem Features list, change the Zeitwerk bullet to:
809
+
810
+ ```markdown
811
+ - `rake zeitwerk:validate` / `rake zeitwerk:all` tasks provided by `Gempilot::ZeitwerkTask`, which locate the gem's loader through Zeitwerk (inflected namespaces such as `ECS` work)
812
+ ```
813
+
814
+ - [ ] **Step 2: Update README.md**
815
+
816
+ In "Generated Gem Features", change the Zeitwerk bullet to:
817
+
818
+ ```markdown
819
+ - **Zeitwerk autoloading** with `LOADER` constant, `rake zeitwerk:validate`, and
820
+ `rake zeitwerk:all`; the tasks find the loader through Zeitwerk itself, so
821
+ custom inflections (`loader.inflector.inflect("ecs" => "ECS")`) just work
822
+ ```
823
+
824
+ - [ ] **Step 3: Commit**
825
+
826
+ ```bash
827
+ git add CLAUDE.md README.md
828
+ git commit -m "Document ProjectLoader and the module-name-free Project"
829
+ ```
830
+
831
+ ---
832
+
833
+ ### Task 5: Bring every doc block under lib/ to rdoc's canonical form
834
+
835
+ The issue's first point ("documentation is improperly formatted, sizing is all over the place") comes from the `## text` on-every-line style used throughout `lib/`: renderers that treat the comment body as Markdown (IDE hovers) turn each `## ...` line into a heading. rdoc's own convention, already used by every ERB template gempilot generates (`data/templates/gem/lib/gem_name.rb.erb`) and by the idiomatic-ruby skill, is a bare `##` marker followed by `# ` lines. Tasks 1–3 wrote their files that way; this task converts the remaining 22 files mechanically.
836
+
837
+ **Files:**
838
+ - Modify: every `lib/**/*.rb` still containing lines that start with `## ` (22 files; `grep -rln "^\s*## " lib` lists them)
839
+ - Test: existing suite + `bundle exec rubocop lib` + an rdoc render
840
+
841
+ **Interfaces:**
842
+ - Consumes/produces: comments only; no code changes.
843
+
844
+ - [ ] **Step 1: Run the conversion script**
845
+
846
+ Save this as a scratch file outside the repo (it is a one-off tool, not committed), then run it from the repo root with `ruby <path>/doc_sweep.rb`:
847
+
848
+ ```ruby
849
+ # Converts per-line "## text" doc blocks under lib/ to rdoc's canonical form:
850
+ # a bare "##" marker line followed by "# text" lines. Blocks that already
851
+ # start with a bare "##" are left alone.
852
+ Dir.glob("lib/**/*.rb").sort.each do |path|
853
+ lines = File.readlines(path)
854
+ out = []
855
+ i = 0
856
+ while i < lines.size
857
+ line = lines[i]
858
+ indent = line[/\A\s*/]
859
+ if line.start_with?("#{indent}## ")
860
+ out << "#{indent}##\n"
861
+ while i < lines.size && (lines[i].start_with?("#{indent}## ") || lines[i].chomp == "#{indent}##")
862
+ out << lines[i].sub("##", "#")
863
+ i += 1
864
+ end
865
+ else
866
+ out << line
867
+ i += 1
868
+ end
869
+ end
870
+ File.write(path, out.join)
871
+ end
872
+ ```
873
+
874
+ A per-line block such as
875
+
876
+ ```ruby
877
+ ## Commit-message prefix written for a version bump; the guard below
878
+ ## matches on it, so the two must stay in sync.
879
+ BUMP_MESSAGE_PREFIX = "Bump version to ".freeze
880
+ ```
881
+
882
+ becomes
883
+
884
+ ```ruby
885
+ ##
886
+ # Commit-message prefix written for a version bump; the guard below
887
+ # matches on it, so the two must stay in sync.
888
+ BUMP_MESSAGE_PREFIX = "Bump version to ".freeze
889
+ ```
890
+
891
+ - [ ] **Step 2: Check nothing was missed and nothing else changed**
892
+
893
+ Run: `grep -rn "^\s*## " lib || echo none` (expected: `none`), then `git diff --stat | tail -1` (expected: `22 files changed`; every hunk replaces `## text` lines with `# text` lines and adds one bare `##` marker line per block, so insertions exceed deletions by the number of blocks). Spot-check `git diff lib/gempilot/gem_constant.rb`: every changed hunk touches comment lines only.
894
+
895
+ - [ ] **Step 3: Run rubocop on lib, the full suite, and an rdoc render**
896
+
897
+ Run: `bundle exec rubocop lib`
898
+ Expected: `27 files inspected, no offenses detected`. (`Claude/NoCommentedCode` now sees the prose for the first time because a `## text` line used to reach the cop as `# text`; when this sweep was rehearsed on 2026-09-23 no line tripped it. If a future line does, reword that sentence so it does not start like a bare identifier, assignment, or `require`.)
899
+
900
+ Run: `rdoc --quiet --op /tmp/gempilot-rdoc lib && ruby -e 'puts File.read("/tmp/gempilot-rdoc/Gempilot/ZeitwerkTask.html")[/<section class="description">.*?<\/section>/m]'`
901
+ Expected: three `<p>` paragraphs, the second containing `<code>gempilot/zeitwerk_task</code>` and a link to `ZeitwerkTask.new`, the third linking `ProjectLoader`. Delete `/tmp/gempilot-rdoc` afterwards.
902
+
903
+ Run: `bundle exec rake default 2>&1 | tail -8`
904
+ Expected: unchanged counts, no failures, no offenses.
905
+
906
+ - [ ] **Step 4: Commit**
907
+
908
+ ```bash
909
+ git add lib
910
+ git commit -m "Bring lib doc comments to rdoc's canonical form"
911
+ ```