gempilot 0.2.1 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9d85c949ecc31934a9f30013cf3216e6f5560f83f4847caf1ac7d54fad31556b
4
- data.tar.gz: b27b62a0b5491c32152ef170b300fe2a1bebcce9b848936d6472688bacce545f
3
+ metadata.gz: 48657265841f0000324b7cfca38d98ed1a45a59e7906810660a8e9e047042286
4
+ data.tar.gz: 9a084c85bfeec3ea094da9e7b7b245a0e30a050631db84acf8d434e88470b948
5
5
  SHA512:
6
- metadata.gz: dcda741a835bb6c586adb888ec6e790edd7c7c72029034d161da046b15e187c7934fbe7bfad65481a056a981bf86bda3b4d910ea7181d67fee0968faa423a4dc
7
- data.tar.gz: 4f7ac2bad5ab9f332eec6de811d705ea825587a3d4dfbc0dae860e6bdc9520da30fe89dcca3ae3c57d094c0eab2617b535b7d7f17aa7cb9f270ea37b5dbb7f29
6
+ metadata.gz: 677fa36cce5bf93834debebf366296289b5fe69baf1ecb325d2169aa03140e928373a36052d4e4accfff37aebdfdb5562bbdda09244c5e6e15b1f51b982397c9
7
+ data.tar.gz: 8afb56703f50f6f42c56b7049167c498656576c8c4e5061cdd11883b9d32efe0ea7906203391dc809d4197c222f654010439fd38ead128ce9783446dafa50e54
data/CLAUDE.md CHANGED
@@ -20,6 +20,7 @@ A CLI tool for creating and managing Ruby gems, built on CommandKit.
20
20
  - Uses CommandKit::Inflector for name inflection
21
21
  - Generator module (`lib/gempilot/cli/generator.rb`) provides template rendering via ERB
22
22
  - GemContext module (`lib/gempilot/cli/gem_context.rb`) shared by new, destroy, release, console
23
+ - `GemConstant` value object (`lib/gempilot/gem_constant.rb`) owns constant→namespace/path resolution for `new`/`destroy`; constants are rooted at the gem module by construction
23
24
  - CommandKit::Commands::AutoLoad maps filenames in `commands/` to command names
24
25
 
25
26
  ### Testing
@@ -34,7 +35,7 @@ A CLI tool for creating and managing Ruby gems, built on CommandKit.
34
35
  - RuboCop with framework-specific plugins
35
36
  - GitHub Actions CI workflow (`.github/workflows/ci.yml`)
36
37
  - `git ls-files`-based gemspec with glob fallback for non-git repos
37
- - Version management rake tasks in `rakelib/version.rake` (current, bump, commit, revert, release:full)
38
+ - Version lifecycle rake tasks installed via `Gempilot::VersionTask.new` (a `Rake::TaskLib`): `version:current/bump/commit/tag/untag/reset/revert`, composite `version:release`/`version:unrelease`, and `version:github:release/unrelease/list`
38
39
 
39
40
  ### Notes
40
41
  - AutoLoad uses block form in `cli.rb` with explicit `summary:` per command (lazy loading means descriptions aren't available at help time without this)
@@ -42,4 +43,4 @@ A CLI tool for creating and managing Ruby gems, built on CommandKit.
42
43
 
43
44
  ## ISSUES
44
45
 
45
- 1. RESOLVED — `exe/gempilot` had unconditional `ENV["BUNDLE_GEMFILE"]` and `require "bundler/setup"`, causing `Gemfile not found` after `gem install`. Removed both lines; RubyGems handles load paths for installed gems. (Ronin avoids this with a conditional `Gemfile.lock` check, but gempilot's `exe/`+`bin/` separation makes that unnecessary.)
46
+ 1. RESOLVED — `exe/gempilot` previously had an unconditional `ENV["BUNDLE_GEMFILE"]` + `require "bundler/setup"`, breaking `gem install` usage. It now guards both behind `if File.exist?(gemfile)` (the Ronin pattern), so installed gems skip Bundler while in-repo runs still use it.
@@ -0,0 +1,679 @@
1
+ # Address Code-Review Critiques 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:** Retire a class of namespace/path bugs and the `new`/`destroy` duplication by introducing a `GemConstant` value object, plus small polish to the version tasks, framework detection, and stale docs.
6
+
7
+ **Architecture:** Extract the duplicated "constant within a gem" logic (qualify, namespaces, lib/test paths) out of `new.rb`, `destroy.rb`, and `gem_context.rb` into one immutable `Gempilot::GemConstant` (a `Data` subclass), mirroring the existing `Project::Version` value object. Commands build a `GemConstant` via a shared `GemContext#gem_constant` factory and read paths off it. Smaller, independent fixes round out the other critiques.
8
+
9
+ **Tech Stack:** Ruby 4.0, CommandKit, Zeitwerk, RSpec 3.13 + Minitest, RuboCop (+ rubocop-rspec).
10
+
11
+ ---
12
+
13
+ ## Environment note
14
+
15
+ `bundle exec` is broken on this box (vendored gems are macOS-built; see memory `gempilot-test-running-env`). Run tools directly against system gems:
16
+ - RSpec: `rspec <path>` (full suite: `rspec`)
17
+ - Minitest: `ruby -Itest -Ilib -e 'Dir["test/**/*_test.rb"].each { |f| require File.expand_path(f) }'`
18
+ - RuboCop: `rubocop <paths>`
19
+
20
+ In a normal environment these are `bundle exec rspec` / `bundle exec rake test` / `bundle exec rake rubocop`.
21
+
22
+ ## File structure
23
+
24
+ | File | Responsibility | Change |
25
+ |---|---|---|
26
+ | `lib/gempilot/gem_constant.rb` | Value object: a class/module constant within a gem (qualify + paths) | **Create** |
27
+ | `spec/gempilot/gem_constant_spec.rb` | Unit spec for `GemConstant` | **Create** |
28
+ | `lib/gempilot/cli/gem_context.rb` | Shared command context; `gem_constant` factory + framework detection | Modify |
29
+ | `lib/gempilot/cli/commands/new.rb` | `new` command, now delegating constant logic to `GemConstant` | Modify |
30
+ | `lib/gempilot/cli/commands/destroy.rb` | `destroy` command, same delegation | Modify |
31
+ | `lib/gempilot/version_tag.rb` | Version git ops: consistent error + named constant | Modify |
32
+ | `spec/gempilot/version_tag_spec.rb` | Update the non-bump assertion expectation | Modify |
33
+ | `spec/gempilot/cli/commands/new_namespace_spec.rb` | Document unified non-interactive rooting | Modify |
34
+ | `CLAUDE.md` | Fix stale architecture/feature notes | Modify |
35
+
36
+ **Design decision (documented in `GemConstant`):** every class/module constant is rooted at the gem's module *by construction* — bare input is prefixed, input already starting with the gem's first segment is left as-is. This makes interactive and non-interactive behavior consistent and removes the half-enforced `validate_gem_root!` (no test depends on its rejection). Rooting matches on the first segment only, preserving extension-gem support (a `My::Gem` gem accepts any `My::…`).
37
+
38
+ ---
39
+
40
+ ## Part A — Version task polish
41
+
42
+ ### Task A1: Make the non-bump guard `raise` instead of `abort`
43
+
44
+ **Files:**
45
+ - Modify: `lib/gempilot/version_tag.rb:50`
46
+ - Test: `spec/gempilot/version_tag_spec.rb:109-118`
47
+
48
+ - [ ] **Step 1: Update the spec to expect a `RuntimeError`** (consistent with `#create`'s dirty-staging error)
49
+
50
+ In `spec/gempilot/version_tag_spec.rb`, replace the block at lines 109-118 with:
51
+
52
+ ```ruby
53
+ describe "#tag when last commit is not a version bump" do
54
+ before do
55
+ File.write("README.md", "init")
56
+ system("git add README.md && git commit -m 'Not a version bump' --quiet")
57
+ end
58
+
59
+ it "raises an error" do
60
+ expect { version_tag.tag }.to raise_error(RuntimeError, /does not appear to be a version bump/)
61
+ end
62
+ end
63
+ ```
64
+
65
+ - [ ] **Step 2: Run the spec, verify it fails**
66
+
67
+ Run: `rspec spec/gempilot/version_tag_spec.rb -e "not a version bump"`
68
+ Expected: FAIL — `expected RuntimeError, got #<SystemExit: exit>` (code still calls `abort`).
69
+
70
+ - [ ] **Step 3: Change `abort` to `raise`**
71
+
72
+ In `lib/gempilot/version_tag.rb`, in `assert_last_commit_is_bump!` (line 50), change:
73
+
74
+ ```ruby
75
+ abort "Last commit does not appear to be a version bump." unless message.start_with?("Bump version to ")
76
+ ```
77
+ to:
78
+ ```ruby
79
+ raise "Last commit does not appear to be a version bump." unless message.start_with?("Bump version to ")
80
+ ```
81
+
82
+ - [ ] **Step 4: Run the spec, verify it passes**
83
+
84
+ Run: `rspec spec/gempilot/version_tag_spec.rb`
85
+ Expected: PASS (all examples).
86
+
87
+ - [ ] **Step 5: Commit**
88
+
89
+ ```bash
90
+ git add lib/gempilot/version_tag.rb spec/gempilot/version_tag_spec.rb
91
+ git commit -m "Raise instead of abort in version bump guard"
92
+ ```
93
+
94
+ ### Task A2: Extract the bump-message prefix into a named constant
95
+
96
+ **Files:**
97
+ - Modify: `lib/gempilot/version_tag.rb`
98
+
99
+ - [ ] **Step 1: Confirm current behavior is covered, run the spec**
100
+
101
+ Run: `rspec spec/gempilot/version_tag_spec.rb`
102
+ Expected: PASS. (`#create` asserts the commit message; the guard test asserts the prefix check — both cover this refactor.)
103
+
104
+ - [ ] **Step 2: Add the constant and use it in both places**
105
+
106
+ In `lib/gempilot/version_tag.rb`, add the constant just below `include StrictShell`:
107
+
108
+ ```ruby
109
+ include StrictShell
110
+
111
+ ## Commit-message prefix written for a version bump; the guard below
112
+ ## matches on it, so the two must stay in sync.
113
+ BUMP_MESSAGE_PREFIX = "Bump version to ".freeze
114
+ ```
115
+
116
+ In `#create`, change the commit line to:
117
+
118
+ ```ruby
119
+ sh "git", "commit", "-m", "#{BUMP_MESSAGE_PREFIX}#{version.value}"
120
+ ```
121
+
122
+ In `assert_last_commit_is_bump!`, change the guard to:
123
+
124
+ ```ruby
125
+ raise "Last commit does not appear to be a version bump." unless message.start_with?(BUMP_MESSAGE_PREFIX)
126
+ ```
127
+
128
+ - [ ] **Step 3: Run the spec, verify still green**
129
+
130
+ Run: `rspec spec/gempilot/version_tag_spec.rb`
131
+ Expected: PASS (commit message is byte-identical: `"Bump version to <value>"`).
132
+
133
+ - [ ] **Step 4: Lint and commit**
134
+
135
+ ```bash
136
+ rubocop lib/gempilot/version_tag.rb
137
+ git add lib/gempilot/version_tag.rb
138
+ git commit -m "Name the version bump commit-message prefix"
139
+ ```
140
+
141
+ ---
142
+
143
+ ## Part B — `GemConstant` value object + de-duplicate new/destroy
144
+
145
+ ### Task B1: Create the `GemConstant` value object
146
+
147
+ **Files:**
148
+ - Create: `lib/gempilot/gem_constant.rb`
149
+ - Test: `spec/gempilot/gem_constant_spec.rb`
150
+
151
+ - [ ] **Step 1: Write the unit spec**
152
+
153
+ Create `spec/gempilot/gem_constant_spec.rb`:
154
+
155
+ ```ruby
156
+ require "spec_helper"
157
+
158
+ RSpec.describe Gempilot::GemConstant do
159
+ def constant(input, gem_module: "MyGem", require_path: "my_gem")
160
+ described_class.new(input: input, gem_module: gem_module, require_path: require_path)
161
+ end
162
+
163
+ describe "#qualified" do
164
+ it "prepends the gem module to a bare suffix" do
165
+ expect(constant("Services::Auth").qualified).to eq("MyGem::Services::Auth")
166
+ end
167
+
168
+ it "leaves an already-rooted constant unchanged" do
169
+ expect(constant("MyGem::Services::Auth").qualified).to eq("MyGem::Services::Auth")
170
+ end
171
+
172
+ it "matches on the root segment for multi-segment modules" do
173
+ c = constant("My::Widget", gem_module: "My::Gem", require_path: "my/gem")
174
+ expect(c.qualified).to eq("My::Widget")
175
+ end
176
+
177
+ it "prepends the full module to a bare suffix under a multi-segment module" do
178
+ c = constant("Widget", gem_module: "My::Gem", require_path: "my/gem")
179
+ expect(c.qualified).to eq("My::Gem::Widget")
180
+ end
181
+ end
182
+
183
+ describe "#namespaces / #name" do
184
+ it "splits the qualified constant", :aggregate_failures do
185
+ c = constant("Services::Auth")
186
+ expect(c.namespaces).to eq(%w[MyGem Services])
187
+ expect(c.name).to eq("Auth")
188
+ end
189
+ end
190
+
191
+ describe "#lib_path" do
192
+ it "builds the underscored source path" do
193
+ expect(constant("Services::Auth").lib_path).to eq("lib/my_gem/services/auth.rb")
194
+ end
195
+ end
196
+
197
+ describe "#test_path" do
198
+ it "builds the rspec path" do
199
+ expect(constant("Services::Auth").test_path(:rspec)).to eq("spec/my_gem/services/auth_spec.rb")
200
+ end
201
+
202
+ it "builds the minitest path" do
203
+ expect(constant("Services::Auth").test_path(:minitest)).to eq("test/my_gem/services/auth_test.rb")
204
+ end
205
+
206
+ it "does not duplicate segments for multi-segment (hyphenated) modules" do
207
+ c = constant("Widget", gem_module: "My::Gem", require_path: "my/gem")
208
+ expect(c.test_path(:minitest)).to eq("test/my/gem/widget_test.rb")
209
+ end
210
+ end
211
+ end
212
+ ```
213
+
214
+ - [ ] **Step 2: Run the spec, verify it fails**
215
+
216
+ Run: `rspec spec/gempilot/gem_constant_spec.rb`
217
+ Expected: FAIL — `uninitialized constant Gempilot::GemConstant`.
218
+
219
+ - [ ] **Step 3: Create the value object**
220
+
221
+ Create `lib/gempilot/gem_constant.rb`:
222
+
223
+ ```ruby
224
+ module Gempilot
225
+ ## A class or module constant resolved within a gem's namespace.
226
+ ##
227
+ ## Wraps raw user input (a bare suffix like +Services::Auth+ or a
228
+ ## fully-qualified +MyGem::Services::Auth+) together with the gem's module
229
+ ## and require path, and derives the qualified constant, file paths, and
230
+ ## namespace pieces from a single parse.
231
+ ##
232
+ ## Every constant is rooted at the gem's module: bare input is prefixed with
233
+ ## it, while input already starting with the gem's root segment is left as
234
+ ## is. Rooting is matched on the first segment only, so an extension gem
235
+ ## whose module is +My::Gem+ accepts any +My::...+ constant.
236
+ class GemConstant < Data.define(:input, :gem_module, :require_path)
237
+ using String::Inflectable
238
+
239
+ ## The fully-qualified constant, rooted at the gem module.
240
+ def qualified
241
+ input.start_with?("#{root_segment}::") ? input : "#{gem_module}::#{input}"
242
+ end
243
+
244
+ ## Namespace segments preceding the final constant name.
245
+ def namespaces
246
+ parts[0...-1]
247
+ end
248
+
249
+ ## The final class or module name.
250
+ def name
251
+ parts.last
252
+ end
253
+
254
+ ## Path to the constant's source file, e.g. +lib/my_gem/services/auth.rb+.
255
+ def lib_path
256
+ "#{File.join("lib", *path_segments)}.rb"
257
+ end
258
+
259
+ ## Path to the constant's test file for +framework+ (+:rspec+ or
260
+ ## +:minitest+); correct for multi-segment (hyphenated) gem modules.
261
+ def test_path(framework)
262
+ rest = path_segments.drop(require_path.split("/").length)
263
+ if framework == :rspec
264
+ "#{File.join("spec", require_path, *rest)}_spec.rb"
265
+ else
266
+ "#{File.join("test", require_path, *rest)}_test.rb"
267
+ end
268
+ end
269
+
270
+ private
271
+
272
+ def root_segment
273
+ gem_module.split("::").first
274
+ end
275
+
276
+ def parts
277
+ qualified.split("::")
278
+ end
279
+
280
+ def path_segments
281
+ parts.map(&:underscore)
282
+ end
283
+ end
284
+ end
285
+ ```
286
+
287
+ - [ ] **Step 4: Run the spec, verify it passes**
288
+
289
+ Run: `rspec spec/gempilot/gem_constant_spec.rb`
290
+ Expected: PASS (all examples). If you see `undefined method 'underscore'`, confirm `String::Inflectable` is loaded at boot (it is for `new.rb`); the file-top `using` activates it for the whole file.
291
+
292
+ - [ ] **Step 5: Lint and commit**
293
+
294
+ ```bash
295
+ rubocop lib/gempilot/gem_constant.rb spec/gempilot/gem_constant_spec.rb
296
+ git add lib/gempilot/gem_constant.rb spec/gempilot/gem_constant_spec.rb
297
+ git commit -m "Add GemConstant value object for gem-rooted constants"
298
+ ```
299
+
300
+ ### Task B2: Add a `gem_constant` factory to `GemContext`
301
+
302
+ **Files:**
303
+ - Modify: `lib/gempilot/cli/gem_context.rb`
304
+
305
+ - [ ] **Step 1: Add the factory method** (additive; `parse_constant`/`validate_gem_root!` stay for now so nothing breaks)
306
+
307
+ In `lib/gempilot/cli/gem_context.rb`, add this method inside `module GemContext` (after `detect_gem_context`):
308
+
309
+ ```ruby
310
+ def gem_constant(input)
311
+ GemConstant.new(input: input, gem_module: @gem_module, require_path: @require_path)
312
+ end
313
+ ```
314
+
315
+ - [ ] **Step 2: Verify nothing is broken**
316
+
317
+ Run: `rspec`
318
+ Expected: PASS (additive change; existing suite unaffected).
319
+
320
+ - [ ] **Step 3: Commit**
321
+
322
+ ```bash
323
+ git add lib/gempilot/cli/gem_context.rb
324
+ git commit -m "Add GemContext#gem_constant factory"
325
+ ```
326
+
327
+ ### Task B3: Refactor `new` to use `GemConstant`
328
+
329
+ **Files:**
330
+ - Modify: `lib/gempilot/cli/commands/new.rb`
331
+ - Test: `spec/gempilot/cli/commands/new_namespace_spec.rb`
332
+
333
+ - [ ] **Step 1: Add a spec for unified non-interactive rooting** (documents the now-consistent behavior: a bare name passed as an argument is rooted, instead of being rejected)
334
+
335
+ In `spec/gempilot/cli/commands/new_namespace_spec.rb`, add this context inside the top-level `describe` (the file already has the tmpdir `around` + `include FileUtils` + `my_gem` gemspec fixture):
336
+
337
+ ```ruby
338
+ describe "non-interactive rooting" do
339
+ before { mkdir_p("lib/my_gem") }
340
+
341
+ it "roots a bare class name under the gem module" do
342
+ command.main(["class", "Services::Auth"])
343
+
344
+ expect(File).to exist("lib/my_gem/services/auth.rb")
345
+ end
346
+ end
347
+ ```
348
+
349
+ - [ ] **Step 2: Run it, verify it fails**
350
+
351
+ Run: `rspec spec/gempilot/cli/commands/new_namespace_spec.rb -e "roots a bare class name"`
352
+ Expected: FAIL — current code calls `validate_gem_root!("Services")` and exits, so the file is not created (`SystemExit`).
353
+
354
+ - [ ] **Step 3: Refactor `new.rb`**
355
+
356
+ In `lib/gempilot/cli/commands/new.rb`:
357
+
358
+ Replace `prompt_for_path` (it no longer prepends — `GemConstant#qualified` owns that):
359
+
360
+ ```ruby
361
+ def prompt_for_path(type)
362
+ ask(colors.green(type), required: true)
363
+ end
364
+ ```
365
+
366
+ Replace `dispatch_add` so class/module build a `GemConstant`:
367
+
368
+ ```ruby
369
+ def dispatch_add(type, path)
370
+ case type
371
+ when "class" then add_class(gem_constant(path))
372
+ when "module" then add_module(gem_constant(path))
373
+ when "command" then add_command(path)
374
+ else
375
+ puts colors.red("Unknown type '#{type}'. Use class, module, or command.")
376
+ exit 1
377
+ end
378
+ end
379
+ ```
380
+
381
+ Delete `prepare_constant` and `class_test_path` entirely. Replace `add_class`, `add_module`, and `add_test_file` with:
382
+
383
+ ```ruby
384
+ def add_class(constant)
385
+ print_adding_banner("class", constant.qualified)
386
+ ensure_directory(File.dirname(constant.lib_path))
387
+ source = build_nested_source(constant.namespaces, "class", constant.name)
388
+ create_file(constant.lib_path, source)
389
+ add_test_file(constant)
390
+ end
391
+
392
+ def add_module(constant)
393
+ print_adding_banner("module", constant.qualified)
394
+ ensure_directory(File.dirname(constant.lib_path))
395
+ source = build_nested_source(constant.namespaces, "module", constant.name)
396
+ create_file(constant.lib_path, source)
397
+ end
398
+
399
+ def add_test_file(constant)
400
+ test_path = constant.test_path(@test_framework)
401
+ ensure_directory(File.dirname(test_path))
402
+ content = if @test_framework == :rspec
403
+ rspec_class_content(constant.namespaces, constant.name)
404
+ else
405
+ minitest_class_content(constant.namespaces, constant.name)
406
+ end
407
+ create_file(test_path, content)
408
+ end
409
+ ```
410
+
411
+ Leave `add_command`, `command_test_path`, `add_command_test_file`, `rspec_class_content`, `minitest_class_content`, `rspec_command_content`, `minitest_command_content`, `build_nested_source`, `build_namespace_lines`, `build_closing_lines`, `print_adding_banner`, and `ensure_directory` unchanged.
412
+
413
+ - [ ] **Step 4: Run the new test, the command specs, and the minitest suite**
414
+
415
+ Run: `rspec spec/gempilot/cli/commands/new_namespace_spec.rb spec/gempilot/cli/commands/new_interactive_spec.rb`
416
+ Expected: PASS (interactive + hyphenated cases still green; new rooting test green).
417
+
418
+ Run: `ruby -Itest -Ilib -e 'require File.expand_path("test/gempilot/cli/new_command_test.rb")'`
419
+ Expected: PASS, 0 failures (full constants are unchanged by `qualified`).
420
+
421
+ - [ ] **Step 5: Lint and commit**
422
+
423
+ ```bash
424
+ rubocop lib/gempilot/cli/commands/new.rb spec/gempilot/cli/commands/new_namespace_spec.rb
425
+ git add lib/gempilot/cli/commands/new.rb spec/gempilot/cli/commands/new_namespace_spec.rb
426
+ git commit -m "Refactor new command onto GemConstant"
427
+ ```
428
+
429
+ ### Task B4: Refactor `destroy` to use `GemConstant`
430
+
431
+ **Files:**
432
+ - Modify: `lib/gempilot/cli/commands/destroy.rb`
433
+
434
+ - [ ] **Step 1: Run the destroy specs (baseline green)**
435
+
436
+ Run: `rspec spec/gempilot/cli/commands/destroy_namespace_spec.rb spec/gempilot/cli/commands/destroy_interactive_spec.rb`
437
+ Expected: PASS.
438
+
439
+ - [ ] **Step 2: Refactor `destroy.rb`**
440
+
441
+ In `lib/gempilot/cli/commands/destroy.rb`:
442
+
443
+ Replace `prompt_for_path`:
444
+
445
+ ```ruby
446
+ def prompt_for_path(type)
447
+ ask(colors.green(type), required: true)
448
+ end
449
+ ```
450
+
451
+ Replace `dispatch_destroy`:
452
+
453
+ ```ruby
454
+ def dispatch_destroy(type, path)
455
+ case type
456
+ when "class" then destroy_class(gem_constant(path))
457
+ when "module" then destroy_module(gem_constant(path))
458
+ when "command" then destroy_command(path)
459
+ else
460
+ puts colors.red("Unknown type '#{type}'. Use class, module, or command.")
461
+ exit 1
462
+ end
463
+ end
464
+ ```
465
+
466
+ Delete `test_path_for`. Replace `destroy_class` and `destroy_module`:
467
+
468
+ ```ruby
469
+ def destroy_class(constant)
470
+ lib_path = constant.lib_path
471
+ test_path = constant.test_path(@test_framework)
472
+
473
+ remove_file(lib_path)
474
+ remove_file(test_path)
475
+ cleanup_empty_dirs(lib_path, test_path)
476
+ end
477
+
478
+ def destroy_module(constant)
479
+ lib_path = constant.lib_path
480
+
481
+ remove_file(lib_path)
482
+ remove_empty_parents(File.dirname(lib_path), File.join("lib", @require_path))
483
+ end
484
+ ```
485
+
486
+ Leave `destroy_command`, `cleanup_empty_dirs`, `remove_empty_parents`, `remove_file`, `print_remove`, and `print_skip` unchanged.
487
+
488
+ - [ ] **Step 3: Run destroy specs + minitest**
489
+
490
+ Run: `rspec spec/gempilot/cli/commands/destroy_namespace_spec.rb spec/gempilot/cli/commands/destroy_interactive_spec.rb`
491
+ Expected: PASS.
492
+
493
+ Run: `ruby -Itest -Ilib -e 'require File.expand_path("test/gempilot/cli/destroy_command_test.rb")'`
494
+ Expected: PASS, 0 failures.
495
+
496
+ - [ ] **Step 4: Lint and commit**
497
+
498
+ ```bash
499
+ rubocop lib/gempilot/cli/commands/destroy.rb
500
+ git add lib/gempilot/cli/commands/destroy.rb
501
+ git commit -m "Refactor destroy command onto GemConstant"
502
+ ```
503
+
504
+ ### Task B5: Remove the now-dead `parse_constant` and `validate_gem_root!`
505
+
506
+ **Files:**
507
+ - Modify: `lib/gempilot/cli/gem_context.rb`
508
+
509
+ - [ ] **Step 1: Confirm they're unused**
510
+
511
+ Run: `grep -rn "parse_constant\|validate_gem_root" lib`
512
+ Expected: only the two definitions in `gem_context.rb` (no call sites remain after B3/B4).
513
+
514
+ - [ ] **Step 2: Delete both methods**
515
+
516
+ In `lib/gempilot/cli/gem_context.rb`, delete the entire `parse_constant` method and the entire `validate_gem_root!` method. Keep `using String::Inflectable` (still used by `detect_gem_context`'s `camelize`), `detect_gem_context`, and `gem_constant`.
517
+
518
+ - [ ] **Step 3: Run the full suite + minitest**
519
+
520
+ Run: `rspec`
521
+ Expected: PASS (whole RSpec suite).
522
+
523
+ Run: `ruby -Itest -Ilib -e 'Dir["test/**/*_test.rb"].each { |f| require File.expand_path(f) }'`
524
+ Expected: only the known environmental `create_command_test` bundler-subprocess failure (see memory `gempilot-test-running-env`); 0 other failures.
525
+
526
+ - [ ] **Step 4: Lint and commit**
527
+
528
+ ```bash
529
+ rubocop lib/gempilot/cli/gem_context.rb
530
+ git add lib/gempilot/cli/gem_context.rb
531
+ git commit -m "Drop parse_constant/validate_gem_root! superseded by GemConstant"
532
+ ```
533
+
534
+ ---
535
+
536
+ ## Part C — Robust test-framework detection
537
+
538
+ ### Task C1: Detect rspec by its config files, not a bare `spec/` directory
539
+
540
+ **Files:**
541
+ - Modify: `lib/gempilot/cli/gem_context.rb`
542
+ - Test: `spec/gempilot/cli/commands/new_interactive_spec.rb`
543
+
544
+ - [ ] **Step 1: Add a spec proving a stray `spec/` dir doesn't force rspec**
545
+
546
+ In `spec/gempilot/cli/commands/new_interactive_spec.rb`, add inside `describe "interactive mode"`:
547
+
548
+ ```ruby
549
+ context "when a spec/ directory exists but there is no rspec config" do
550
+ before { mkdir_p("spec") }
551
+
552
+ it "still scaffolds a minitest test file" do
553
+ generate("1\nServices::Auth\n")
554
+
555
+ expect(File).to exist("test/my_gem/services/auth_test.rb")
556
+ end
557
+ end
558
+ ```
559
+
560
+ - [ ] **Step 2: Run it, verify it fails**
561
+
562
+ Run: `rspec spec/gempilot/cli/commands/new_interactive_spec.rb -e "stray"` (or `-e "no rspec config"`)
563
+ Expected: FAIL — current `File.directory?("spec")` returns true, so an rspec spec file is generated under `spec/` and the expected `test/...` file does not exist.
564
+
565
+ - [ ] **Step 3: Replace the detection**
566
+
567
+ In `lib/gempilot/cli/gem_context.rb`, change the last line of `detect_gem_context` from:
568
+
569
+ ```ruby
570
+ @test_framework = File.directory?("spec") ? :rspec : :minitest
571
+ ```
572
+ to:
573
+ ```ruby
574
+ @test_framework = detect_test_framework
575
+ ```
576
+
577
+ Add this private method (next to `gem_constant`):
578
+
579
+ ```ruby
580
+ # Detect rspec by its canonical config files rather than the mere
581
+ # presence of a spec/ directory, which a minitest project may also have.
582
+ def detect_test_framework
583
+ return :rspec if File.exist?(".rspec") || File.exist?(File.join("spec", "spec_helper.rb"))
584
+
585
+ :minitest
586
+ end
587
+ ```
588
+
589
+ - [ ] **Step 4: Run the spec + full suite**
590
+
591
+ Run: `rspec spec/gempilot/cli/commands/new_interactive_spec.rb`
592
+ Expected: PASS.
593
+
594
+ Run: `rspec`
595
+ Expected: PASS (whole suite).
596
+
597
+ - [ ] **Step 5: Lint and commit**
598
+
599
+ ```bash
600
+ rubocop lib/gempilot/cli/gem_context.rb spec/gempilot/cli/commands/new_interactive_spec.rb
601
+ git add lib/gempilot/cli/gem_context.rb spec/gempilot/cli/commands/new_interactive_spec.rb
602
+ git commit -m "Detect rspec by config files, not a bare spec/ dir"
603
+ ```
604
+
605
+ ---
606
+
607
+ ## Part D — CLAUDE.md accuracy pass
608
+
609
+ ### Task D1: Correct the stale architecture and feature notes
610
+
611
+ **Files:**
612
+ - Modify: `CLAUDE.md`
613
+
614
+ - [ ] **Step 1: Fix the version-tasks note**
615
+
616
+ In `CLAUDE.md`, under "Generated Gem Features", replace:
617
+
618
+ ```
619
+ - Version management rake tasks in `rakelib/version.rake` (current, bump, commit, revert, release:full)
620
+ ```
621
+ with:
622
+ ```
623
+ - Version lifecycle rake tasks installed via `Gempilot::VersionTask.new` (a `Rake::TaskLib`): `version:current/bump/commit/tag/untag/reset/revert`, composite `version:release`/`version:unrelease`, and `version:github:release/unrelease/list`
624
+ ```
625
+
626
+ - [ ] **Step 2: Fix the `exe/` note in the ISSUES section**
627
+
628
+ In `CLAUDE.md`, update issue 1 to reflect reality — `exe/gempilot` keeps a *conditional* `bundler/setup` (only when a sibling `Gemfile` exists), it was not removed. Replace the issue body with:
629
+
630
+ ```
631
+ 1. RESOLVED — `exe/gempilot` previously had an unconditional `ENV["BUNDLE_GEMFILE"]` + `require "bundler/setup"`, breaking `gem install` usage. It now guards both behind `if File.exist?(gemfile)` (the Ronin pattern), so installed gems skip Bundler while in-repo runs still use it.
632
+ ```
633
+
634
+ - [ ] **Step 3: Note the constant model under Architecture**
635
+
636
+ In `CLAUDE.md`, under "Architecture", add a bullet:
637
+
638
+ ```
639
+ - `GemConstant` value object (`lib/gempilot/gem_constant.rb`) owns constant→namespace/path resolution for `new`/`destroy`; constants are rooted at the gem module by construction
640
+ ```
641
+
642
+ - [ ] **Step 4: Commit**
643
+
644
+ ```bash
645
+ git add CLAUDE.md
646
+ git commit -m "Refresh CLAUDE.md: version tasks, exe note, GemConstant"
647
+ ```
648
+
649
+ ---
650
+
651
+ ## Final verification
652
+
653
+ - [ ] **Full RSpec suite**
654
+
655
+ Run: `rspec`
656
+ Expected: PASS (all examples, including the new `gem_constant_spec` and added cases).
657
+
658
+ - [ ] **Minitest suite**
659
+
660
+ Run: `ruby -Itest -Ilib -e 'Dir["test/**/*_test.rb"].each { |f| require File.expand_path(f) }'`
661
+ Expected: only the known environmental `create_command_test` bundler failure; 0 others.
662
+
663
+ - [ ] **RuboCop (real source dirs)**
664
+
665
+ Run: `rubocop lib spec test exe Rakefile`
666
+ Expected: no offenses.
667
+
668
+ - [ ] **Confirm net code reduction**
669
+
670
+ Run: `git diff --stat master -- lib/gempilot/cli/commands/new.rb lib/gempilot/cli/commands/destroy.rb lib/gempilot/cli/gem_context.rb`
671
+ Expected: `new.rb`/`destroy.rb`/`gem_context.rb` shrink (constant logic centralized in `gem_constant.rb`).
672
+
673
+ ---
674
+
675
+ ## Self-review notes (done while writing)
676
+
677
+ - **Spec coverage:** each critique maps to a task — duplication + bug class → B1–B5; `abort`/`raise` → A1; magic string → A2; fragile framework detection → C1; CLAUDE.md drift → D1. The `validate_gem_root!` leniency is resolved as a documented design decision in B1.
678
+ - **Type consistency:** `GemConstant#qualified/namespaces/name/lib_path/test_path(framework)` are used with the same names/signatures across B3/B4; the factory is `gem_constant(input)` everywhere.
679
+ - **Behavior change called out:** B3 makes non-interactive bare names root under the gem (previously rejected). No existing test asserted the rejection; B3 Step 1 adds a test documenting the new behavior.
@@ -27,7 +27,7 @@ module Gempilot
27
27
  def run(type = nil, path = nil)
28
28
  type ||= prompt_for_type
29
29
  detect_gem_context
30
- path ||= prompt_for_path
30
+ path ||= prompt_for_path(type)
31
31
  dispatch_destroy(type, path)
32
32
  end
33
33
 
@@ -38,16 +38,14 @@ module Gempilot
38
38
  ask_multiple_choice(colors.green("Type"), %w[class module command])
39
39
  end
40
40
 
41
- def prompt_for_path
42
- puts
43
- puts colors.bright_black("Fully-qualified constant name (e.g., #{@gem_module}::Services::Authentication).")
44
- ask(colors.green("Constant"), required: true)
41
+ def prompt_for_path(type)
42
+ ask(colors.green(type), required: true)
45
43
  end
46
44
 
47
45
  def dispatch_destroy(type, path)
48
46
  case type
49
- when "class" then destroy_class(path)
50
- when "module" then destroy_module(path)
47
+ when "class" then destroy_class(gem_constant(path))
48
+ when "module" then destroy_module(gem_constant(path))
51
49
  when "command" then destroy_command(path)
52
50
  else
53
51
  puts colors.red("Unknown type '#{type}'. Use class, module, or command.")
@@ -56,25 +54,14 @@ module Gempilot
56
54
  end
57
55
 
58
56
  def destroy_class(constant)
59
- namespaces, _class_name, segments = parse_constant(constant)
60
- validate_gem_root!(namespaces.first)
61
-
62
- lib_path = "#{File.join("lib", *segments)}.rb"
63
- test_path = test_path_for(segments)
57
+ lib_path = constant.lib_path
58
+ test_path = constant.test_path(@test_framework)
64
59
 
65
60
  remove_file(lib_path)
66
61
  remove_file(test_path)
67
62
  cleanup_empty_dirs(lib_path, test_path)
68
63
  end
69
64
 
70
- def test_path_for(segments)
71
- if @test_framework == :rspec
72
- "#{File.join("spec", @require_path, *segments[1..])}_spec.rb"
73
- else
74
- "#{File.join("test", @require_path, *segments[1..])}_test.rb"
75
- end
76
- end
77
-
78
65
  def cleanup_empty_dirs(lib_path, test_path)
79
66
  remove_empty_parents(File.dirname(lib_path), File.join("lib", @require_path))
80
67
  test_root = @test_framework == :rspec ? File.join("spec", @require_path) : File.join("test", @require_path)
@@ -82,10 +69,8 @@ module Gempilot
82
69
  end
83
70
 
84
71
  def destroy_module(constant)
85
- namespaces, _mod_name, segments = parse_constant(constant)
86
- validate_gem_root!(namespaces.first)
72
+ lib_path = constant.lib_path
87
73
 
88
- lib_path = "#{File.join("lib", *segments)}.rb"
89
74
  remove_file(lib_path)
90
75
  remove_empty_parents(File.dirname(lib_path), File.join("lib", @require_path))
91
76
  end
@@ -29,7 +29,7 @@ module Gempilot
29
29
  def run(type = nil, path = nil)
30
30
  type ||= prompt_for_type
31
31
  detect_gem_context
32
- path ||= prompt_for_path
32
+ path ||= prompt_for_path(type)
33
33
  dispatch_add(type, path)
34
34
  end
35
35
 
@@ -40,16 +40,14 @@ module Gempilot
40
40
  ask_multiple_choice(colors.green("Type"), %w[class module command])
41
41
  end
42
42
 
43
- def prompt_for_path
44
- puts
45
- puts colors.bright_black("Fully-qualified constant name (e.g., #{@gem_module}::Services::Authentication).")
46
- ask(colors.green("Constant"), required: true)
43
+ def prompt_for_path(type)
44
+ ask(colors.green(type), required: true)
47
45
  end
48
46
 
49
47
  def dispatch_add(type, path)
50
48
  case type
51
- when "class" then add_class(path)
52
- when "module" then add_module(path)
49
+ when "class" then add_class(gem_constant(path))
50
+ when "module" then add_module(gem_constant(path))
53
51
  when "command" then add_command(path)
54
52
  else
55
53
  puts colors.red("Unknown type '#{type}'. Use class, module, or command.")
@@ -86,31 +84,23 @@ module Gempilot
86
84
  puts
87
85
  end
88
86
 
89
- def prepare_constant(constant)
90
- namespaces, name, segments = parse_constant(constant)
91
- validate_gem_root!(namespaces.first)
92
- file_path = "#{File.join("lib", *segments)}.rb"
93
- ensure_directory(File.dirname(file_path))
94
- [namespaces, name, segments, file_path]
95
- end
96
-
97
87
  def ensure_directory(dir)
98
88
  mkdir(dir) unless File.directory?(dir)
99
89
  end
100
90
 
101
91
  def add_class(constant)
102
- namespaces, class_name, segments, file_path = prepare_constant(constant)
103
- print_adding_banner("class", "#{namespaces.join("::")}::#{class_name}")
104
- source = build_nested_source(namespaces, "class", class_name)
105
- create_file(file_path, source)
106
- add_test_file(namespaces, class_name, segments)
92
+ print_adding_banner("class", constant.qualified)
93
+ ensure_directory(File.dirname(constant.lib_path))
94
+ source = build_nested_source(constant.namespaces, "class", constant.name)
95
+ create_file(constant.lib_path, source)
96
+ add_test_file(constant)
107
97
  end
108
98
 
109
99
  def add_module(constant)
110
- namespaces, mod_name, _segments, file_path = prepare_constant(constant)
111
- print_adding_banner("module", "#{namespaces.join("::")}::#{mod_name}")
112
- source = build_nested_source(namespaces, "module", mod_name)
113
- create_file(file_path, source)
100
+ print_adding_banner("module", constant.qualified)
101
+ ensure_directory(File.dirname(constant.lib_path))
102
+ source = build_nested_source(constant.namespaces, "module", constant.name)
103
+ create_file(constant.lib_path, source)
114
104
  end
115
105
 
116
106
  def add_command(name)
@@ -179,14 +169,6 @@ module Gempilot
179
169
  create_file(test_path, content)
180
170
  end
181
171
 
182
- def class_test_path(segments)
183
- if @test_framework == :rspec
184
- "#{File.join("spec", @require_path, *segments[1..])}_spec.rb"
185
- else
186
- "#{File.join("test", @require_path, *segments[1..])}_test.rb"
187
- end
188
- end
189
-
190
172
  def rspec_class_content(namespaces, class_name)
191
173
  <<~RUBY
192
174
  require "spec_helper"
@@ -211,13 +193,13 @@ module Gempilot
211
193
  RUBY
212
194
  end
213
195
 
214
- def add_test_file(namespaces, class_name, segments)
215
- test_path = class_test_path(segments)
196
+ def add_test_file(constant)
197
+ test_path = constant.test_path(@test_framework)
216
198
  ensure_directory(File.dirname(test_path))
217
199
  content = if @test_framework == :rspec
218
- rspec_class_content(namespaces, class_name)
200
+ rspec_class_content(constant.namespaces, constant.name)
219
201
  else
220
- minitest_class_content(namespaces, class_name)
202
+ minitest_class_content(constant.namespaces, constant.name)
221
203
  end
222
204
  create_file(test_path, content)
223
205
  end
@@ -17,23 +17,19 @@ module Gempilot
17
17
  @gem_name = File.basename(gemspec, ".gemspec")
18
18
  @require_path = @gem_name.tr("-", "/")
19
19
  @gem_module = @require_path.camelize
20
- @test_framework = File.directory?("spec") ? :rspec : :minitest
20
+ @test_framework = detect_test_framework
21
21
  end
22
22
 
23
- def parse_constant(constant)
24
- parts = constant.split("::")
25
- namespaces = parts[0...-1]
26
- name = parts.last
27
- segments = parts.map(&:underscore)
28
- [namespaces, name, segments]
29
- end
23
+ # Detect rspec by its canonical config files rather than the mere
24
+ # presence of a spec/ directory, which a minitest project may also have.
25
+ def detect_test_framework
26
+ return :rspec if File.exist?(".rspec") || File.exist?(File.join("spec", "spec_helper.rb"))
30
27
 
31
- def validate_gem_root!(root)
32
- expected = @gem_module.split("::").first
33
- return if root == expected
28
+ :minitest
29
+ end
34
30
 
35
- puts colors.red("Expected constant to start with #{expected}, got #{root}")
36
- exit 1
31
+ def gem_constant(input)
32
+ GemConstant.new(input: input, gem_module: @gem_module, require_path: @require_path)
37
33
  end
38
34
  end
39
35
  end
@@ -0,0 +1,61 @@
1
+ module Gempilot
2
+ ## A class or module constant resolved within a gem's namespace.
3
+ ##
4
+ ## Wraps raw user input (a bare suffix like +Services::Auth+ or a
5
+ ## fully-qualified +MyGem::Services::Auth+) together with the gem's module
6
+ ## and require path, and derives the qualified constant, file paths, and
7
+ ## namespace pieces from a single parse.
8
+ ##
9
+ ## Every constant is rooted at the gem's module: bare input is prefixed with
10
+ ## it, while input already starting with the gem's root segment is left as
11
+ ## is. Rooting is matched on the first segment only, so an extension gem
12
+ ## whose module is +My::Gem+ accepts any +My::...+ constant.
13
+ GemConstant = Data.define(:input, :gem_module, :require_path) do
14
+ using String::Inflectable
15
+
16
+ ## The fully-qualified constant, rooted at the gem module.
17
+ def qualified
18
+ input.start_with?("#{root_segment}::") ? input : "#{gem_module}::#{input}"
19
+ end
20
+
21
+ ## Namespace segments preceding the final constant name.
22
+ def namespaces
23
+ parts[0...-1]
24
+ end
25
+
26
+ ## The final class or module name.
27
+ def name
28
+ parts.last
29
+ end
30
+
31
+ ## Path to the constant's source file, e.g. +lib/my_gem/services/auth.rb+.
32
+ def lib_path
33
+ "#{File.join("lib", *path_segments)}.rb"
34
+ end
35
+
36
+ ## Path to the constant's test file for +framework+ (+:rspec+ or
37
+ ## +:minitest+); correct for multi-segment (hyphenated) gem modules.
38
+ def test_path(framework)
39
+ rest = path_segments.drop(require_path.split("/").length)
40
+ if framework == :rspec
41
+ "#{File.join("spec", require_path, *rest)}_spec.rb"
42
+ else
43
+ "#{File.join("test", require_path, *rest)}_test.rb"
44
+ end
45
+ end
46
+
47
+ private
48
+
49
+ def root_segment
50
+ gem_module.split("::").first
51
+ end
52
+
53
+ def parts
54
+ qualified.split("::")
55
+ end
56
+
57
+ def path_segments
58
+ parts.map(&:underscore)
59
+ end
60
+ end
61
+ end
@@ -1,3 +1,3 @@
1
1
  module Gempilot
2
- VERSION = "0.2.1".freeze
2
+ VERSION = "0.2.2".freeze
3
3
  end
@@ -5,6 +5,10 @@ module Gempilot
5
5
  class VersionTag
6
6
  include StrictShell
7
7
 
8
+ ## Commit-message prefix written for a version bump; the guard below
9
+ ## matches on it, so the two must stay in sync.
10
+ BUMP_MESSAGE_PREFIX = "Bump version to ".freeze
11
+
8
12
  attr_reader :version
9
13
 
10
14
  def initialize(version)
@@ -16,7 +20,7 @@ module Gempilot
16
20
  raise "Cannot proceed, staging area must be clean" unless status.success?
17
21
 
18
22
  sh "git", "add", version.path.to_s
19
- sh "git", "commit", "-m", "Bump version to #{version.value}"
23
+ sh "git", "commit", "-m", "#{BUMP_MESSAGE_PREFIX}#{version.value}"
20
24
  end
21
25
 
22
26
  def tag
@@ -47,7 +51,7 @@ module Gempilot
47
51
  raise "Failed to read last commit message" unless status.success?
48
52
 
49
53
  message.strip!
50
- abort "Last commit does not appear to be a version bump." unless message.start_with?("Bump version to ")
54
+ raise "Last commit does not appear to be a version bump." unless message.start_with?(BUMP_MESSAGE_PREFIX)
51
55
  end
52
56
  end
53
57
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: gempilot
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.1
4
+ version: 0.2.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Gillis
@@ -114,6 +114,7 @@ files:
114
114
  - docs/superpowers/plans/2026-04-06-inflection-tests-and-erb-rename.md
115
115
  - docs/superpowers/plans/2026-04-06-integrate-version-tools.md
116
116
  - docs/superpowers/plans/2026-04-06-new-readme.md
117
+ - docs/superpowers/plans/2026-06-09-address-review-critiques.md
117
118
  - docs/version-management-redesign.md
118
119
  - exe/gempilot
119
120
  - issues.rec
@@ -131,6 +132,7 @@ files:
131
132
  - lib/gempilot/cli/gem_builder.rb
132
133
  - lib/gempilot/cli/gem_context.rb
133
134
  - lib/gempilot/cli/generator.rb
135
+ - lib/gempilot/gem_constant.rb
134
136
  - lib/gempilot/github_release.rb
135
137
  - lib/gempilot/project.rb
136
138
  - lib/gempilot/project/version.rb
@@ -159,7 +161,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
159
161
  - !ruby/object:Gem::Version
160
162
  version: '0'
161
163
  requirements: []
162
- rubygems_version: 4.0.13
164
+ rubygems_version: 4.0.14
163
165
  specification_version: 4
164
166
  summary: A toolkit for creating, managing, and releasing your own rubygems
165
167
  test_files: []