gempilot 0.3.1 → 0.3.2
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/.ruby-version +1 -1
- data/data/templates/gem/Gemfile.erb +1 -0
- data/docs/superpowers/plans/2026-09-23-command-generation-bootstrap.md +810 -0
- data/docs/superpowers/plans/2026-09-23-land-betterleaks-jruby.md +262 -0
- data/docs/superpowers/plans/2026-09-23-multi-gem-rubygems-push.md +318 -0
- data/docs/superpowers/plans/2026-09-23-zeitwerk-task-inflector.md +911 -0
- data/issues.rec +18 -1
- data/lib/gempilot/cli/command.rb +1 -1
- data/lib/gempilot/version.rb +1 -1
- data/vendor/vendored.gemv +0 -0
- metadata +6 -2
|
@@ -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
|
+
```
|