gempilot 0.2.0 → 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: 2af205d18b1882cd54aef57cc2196eecd566dc4e960d8aa03935859dadb94845
4
- data.tar.gz: c412a708efa730b78865b98b70cda6a8b968430b31015edc93034acba7cb0dc6
3
+ metadata.gz: 48657265841f0000324b7cfca38d98ed1a45a59e7906810660a8e9e047042286
4
+ data.tar.gz: 9a084c85bfeec3ea094da9e7b7b245a0e30a050631db84acf8d434e88470b948
5
5
  SHA512:
6
- metadata.gz: a58637783d6a69c9dc5cb2c85220612959a47a234fd8e392ed4006311c9ce1b31d4534339a6c55df05066c91eba56bd013ef600f988e2865481b8ac1e82c6d35
7
- data.tar.gz: adc65d18871b31d1853b4f1c705dcb5d2efd24ecbab16d6d5010131c5d32ad7158e05ea356e4fa4ab7166a0a47b7484f64be711954465a87efe84727d03cadb0
6
+ metadata.gz: 677fa36cce5bf93834debebf366296289b5fe69baf1ecb325d2169aa03140e928373a36052d4e4accfff37aebdfdb5562bbdda09244c5e6e15b1f51b982397c9
7
+ data.tar.gz: 8afb56703f50f6f42c56b7049167c498656576c8c4e5061cdd11883b9d32efe0ea7906203391dc809d4197c222f654010439fd38ead128ce9783446dafa50e54
data/.ruby-version CHANGED
@@ -1 +1 @@
1
- 3.4.8
1
+ 4.0.1
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.
data/Rakefile CHANGED
@@ -1,5 +1,4 @@
1
1
  require "bundler/gem_tasks"
2
- Bundler::GemHelper.install_tasks name: "gempilot"
3
2
 
4
3
  require "minitest/test_task"
5
4
  Minitest::TestTask.create
@@ -7,7 +6,7 @@ Minitest::TestTask.create
7
6
  require "rspec/core/rake_task"
8
7
  RSpec::Core::RakeTask.new(:spec)
9
8
 
10
- require_relative 'lib/gempilot'
9
+ require_relative "lib/gempilot"
11
10
 
12
11
  namespace :spec do
13
12
  desc "Prints the specification suite in documentation format and exits"
@@ -30,12 +29,12 @@ end
30
29
  require "rubocop/rake_task"
31
30
  RuboCop::RakeTask.new do |t|
32
31
  t.patterns = [
33
- 'lib/*.rb',
34
- 'lib/**/*.rb',
35
- 'spec/*.rb',
36
- 'spec/**/*.rb',
37
- 'test/*.rb',
38
- 'test/**/*.rb',
32
+ "lib/*.rb",
33
+ "lib/**/*.rb",
34
+ "spec/*.rb",
35
+ "spec/**/*.rb",
36
+ "test/*.rb",
37
+ "test/**/*.rb",
39
38
  ]
40
39
  end
41
40
 
@@ -3,6 +3,7 @@ source "https://rubygems.org"
3
3
  gemspec
4
4
 
5
5
  gem "gempilot", require: false
6
+ gem "irb"
6
7
  <% if @test_framework == :minitest -%>
7
8
  gem "minitest"
8
9
  gem "minitest-reporters"
@@ -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.
data/issues.rec CHANGED
@@ -11,16 +11,16 @@
11
11
  %mandatory: Title Description
12
12
 
13
13
  Id: 6F683910-2EFA-4356-B1FE-3E4FE3E20A38
14
- Updated: Mon, 02 Mar 2026 10:31:07 -0500
14
+ Updated: Fri, 05 Jun 2026 00:00:00 -0400
15
15
  Title: Rake build triggers gem to be built twice?
16
16
  Description: Why am I seeing 2 messages about gempilot being built to pkg/gempilot-0.1.0.gem
17
- Status: open
17
+ Status: closed
18
18
 
19
19
  Id: 011FB117-BC52-453F-A8E8-6955FE3EBCE8
20
- Updated: Mon, 02 Mar 2026 10:31:39 -0500
20
+ Updated: Sat, 06 Jun 2026 00:00:00 -0400
21
21
  Title: rubocop-rake is not active
22
22
  Description: It should be active for this project
23
- Status: open
23
+ Status: closed
24
24
 
25
25
  Id: F0833E66-A35C-4054-884B-F90C81F08838
26
26
  Updated: Mon, 02 Mar 2026 10:37:33 -0500
@@ -35,10 +35,10 @@ Description:
35
35
  Status: open
36
36
 
37
37
  Id: 131EA3CE-2F3D-4E37-9D9B-A25E0A651F1B
38
- Updated: Tue, 10 Mar 2026 17:40:50 -0400
38
+ Updated: Fri, 05 Jun 2026 00:00:00 -0400
39
39
  Title: irb is required now as a gem
40
40
  Description: Needs to go in template gemfile
41
- Status: open
41
+ Status: closed
42
42
 
43
43
  Id: 3178AC5C-09A9-44A9-ACE1-11BED0BA7276
44
44
  Updated: Mon, 06 Apr 2026 00:00:00 -0400
@@ -53,16 +53,16 @@ Description: The commit should be tagged with git tag upon committing, as this i
53
53
  Status: closed
54
54
 
55
55
  Id: 7825507C-ABD2-4618-A591-BCDB54152C6B
56
- Updated: Sat, 28 Mar 2026 12:48:05 -0400
56
+ Updated: Fri, 05 Jun 2026 00:00:00 -0400
57
57
  Title: Rake task version:bump should support "dev increments", like "0.2.1.dev3". Perhaps a task like "version:bump:dev" is created to allow for this
58
58
  Description: open
59
- Status: open
59
+ Status: closed
60
60
 
61
61
  Id: 5E4D51AB-F9BF-4C72-8165-F1B900799FA7
62
- Updated: Sat, 28 Mar 2026 12:55:14 -0400
62
+ Updated: Sat, 06 Jun 2026 00:00:00 -0400
63
63
  Title: Hundreds of rubocop failures exist
64
64
  Description: These need to be addressed
65
- Status: open
65
+ Status: closed
66
66
 
67
67
  Id: 74D0FD90-0421-4452-8591-2798EE6FD23E
68
68
  Updated: Sat, 28 Mar 2026 12:59:40 -0400
@@ -75,3 +75,69 @@ Updated: Mon, 06 Apr 2026 00:00:00 -0400
75
75
  Title: Replace 4.0.1 with 3.4.8
76
76
  Description: Need to move ruby version down to 3.4.8
77
77
  Status: closed
78
+
79
+ Id: 776F52C8-390E-11F1-8D46-FE6CB9572C2F
80
+ Updated: Fri, 05 Jun 2026 00:00:00 -0400
81
+ Title: `gempilot new` => `3) command` => `Codeball::Command::Filter` fails
82
+ Description: Generating commands with gempilot new is failing with the following error:
83
+ + gempilot new command 'Codeball::Command::Filter'
84
+ +
85
+ + Oops! Looks like you have found a bug. Please report it!
86
+ + https://github.com/dgillis/gempilot/issues/new
87
+ +
88
+ + ```
89
+ + gempilot new: /Users/davidgillis/.rbenv/versions/4.0.1/lib/ruby/gems/4.0.0/gems/gempilot-0.2.0/lib/core_ext/string/inflection_methods.rb:33:in 'scan_word_token': cannot convert string to underscored: "Codeball::Command::Filter" (ArgumentError)
90
+ +
91
+ + raise(ArgumentError, "cannot convert string to underscored: #{scanner.string.inspect}")
92
+ Status: closed
93
+
94
+ Id: 4EB6E51C-4322-11F1-B1B2-FE6CB9572C2F
95
+ Updated: Tue, 28 Apr 2026 12:50:05 -0400
96
+ Title: Existing git repo on gempilot create needs new commit message
97
+ Description: Currently it commits "Initial commit". Inappropriate for a git repo that already has a history
98
+ Status: open
99
+
100
+ Id: 0411D4DA-4323-11F1-847C-FE6CB9572C2F
101
+ Updated: Tue, 28 Apr 2026 12:55:09 -0400
102
+ Title: rubocop config should not exclude rakelib
103
+ Description: it is supplied by gempilot. Should not be eliminating author files
104
+ Status: open
105
+
106
+ Id: C72CD130-4324-11F1-ACAE-FE6CB9572C2F
107
+ Updated: Tue, 28 Apr 2026 13:07:46 -0400
108
+ Title: Make zeitwerk setup a block
109
+ Description: e.g.
110
+ + Zeitwerk::Loader.for_gem.tap do |l|
111
+ + l.setup
112
+ + end
113
+ +
114
+ +
115
+ Status: open
116
+
117
+ Id: 5618BB20-4325-11F1-BF5A-FE6CB9572C2F
118
+ Updated: Tue, 28 Apr 2026 13:11:46 -0400
119
+ Title: Move zeitwerk check task into gempilot
120
+ Description: Can't iterate on it when its not managed by gempilot. A new feature needs to be added
121
+ Status: open
122
+
123
+ Id: 6BE168E4-4325-11F1-BE0F-FE6CB9572C2F
124
+ Updated: Tue, 28 Apr 2026 13:12:22 -0400
125
+ Title: Add zeitwerk:all task
126
+ Description: Add rake task that runs LOADER.all_expected_cpaths and prints in a nice table, similar to rails routes
127
+ Status: open
128
+
129
+ Id: 56DB9634-4327-11F1-8D0F-FE6CB9572C2F
130
+ Updated: Fri, 05 Jun 2026 00:00:00 -0400
131
+ Title: 'gempilot new' doesn't even work
132
+ Description: "
133
+ + Fully-qualified constant name (e.g., Television::Services::Authentication).
134
+ + Constant: Television::CLI::Screen
135
+ +
136
+ + Oops! Looks like you have found a bug. Please report it!
137
+ + https://github.com/dgillis/gempilot/issues/new
138
+ +
139
+ + ```
140
+ + gempilot new: /Users/davidgillis/.rbenv/versions/4.0.1/lib/ruby/gems/4.0.0/gems/gempilot-0.2.0/lib/core_ext/string/inflection_methods.rb:33:in 'scan_word_token': cannot convert string to underscored: "Television::CLI::Screen" (ArgumentError)
141
+ + ```
142
+ + "
143
+ Status: closed
@@ -36,6 +36,8 @@ class String
36
36
  def scan_underscore_separator(scanner)
37
37
  if (sep = scanner.scan(/[_-]+/))
38
38
  "_" * sep.length
39
+ elsif scanner.scan("::")
40
+ "/"
39
41
  elsif scanner.eos?
40
42
  ""
41
43
  else
@@ -58,7 +60,7 @@ class String
58
60
 
59
61
  if scanner.scan(/[_-]+/)
60
62
  nil
61
- elsif scanner.scan(%r{/})
63
+ elsif scanner.scan(%r{/|::})
62
64
  buffer << "::"
63
65
  else
64
66
  raise(ArgumentError, "cannot convert string to CamelCase: #{scanner.string.inspect}")
@@ -87,11 +87,23 @@ module Gempilot
87
87
  initialize_git_repo
88
88
  end
89
89
 
90
+ GEM_NAME_PATTERN = /\A[a-z][a-z0-9]*([_-][a-z0-9]+)*\z/
91
+ private_constant :GEM_NAME_PATTERN
92
+
90
93
  def collect_gem_name(gem_name)
91
94
  @gem_name = gem_name || begin
92
95
  puts colors.bright_black("The gem name determines the directory, require path, and gemspec name.")
93
96
  ask(colors.green("Gem name"), required: true)
94
97
  end
98
+ validate_gem_name
99
+ end
100
+
101
+ def validate_gem_name
102
+ return if @gem_name.match?(GEM_NAME_PATTERN)
103
+
104
+ puts colors.red("Invalid gem name '#{@gem_name}'.")
105
+ puts colors.red("Use lowercase letters, digits, hyphens, or underscores (e.g., my_gem or my-gem).")
106
+ exit 1
95
107
  end
96
108
 
97
109
  def derive_naming
@@ -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,15 +69,14 @@ 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
92
77
 
93
78
  def destroy_command(name)
79
+ name = name.split("::").last if name.include?("::")
94
80
  file_name = name.underscore
95
81
  file_path = File.join("lib", @require_path, "cli", "commands", "#{file_name}.rb")
96
82
  remove_file(file_path)
@@ -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,34 +84,27 @@ 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)
107
+ name = name.split("::").last if name.include?("::")
117
108
  file_name = name.underscore
118
109
  command_name = name.camelize
119
110
  file_path = File.join("lib", @require_path, "cli", "commands", "#{file_name}.rb")
@@ -178,14 +169,6 @@ module Gempilot
178
169
  create_file(test_path, content)
179
170
  end
180
171
 
181
- def class_test_path(segments)
182
- if @test_framework == :rspec
183
- "#{File.join("spec", @require_path, *segments[1..])}_spec.rb"
184
- else
185
- "#{File.join("test", @require_path, *segments[1..])}_test.rb"
186
- end
187
- end
188
-
189
172
  def rspec_class_content(namespaces, class_name)
190
173
  <<~RUBY
191
174
  require "spec_helper"
@@ -210,13 +193,13 @@ module Gempilot
210
193
  RUBY
211
194
  end
212
195
 
213
- def add_test_file(namespaces, class_name, segments)
214
- test_path = class_test_path(segments)
196
+ def add_test_file(constant)
197
+ test_path = constant.test_path(@test_framework)
215
198
  ensure_directory(File.dirname(test_path))
216
199
  content = if @test_framework == :rspec
217
- rspec_class_content(namespaces, class_name)
200
+ rspec_class_content(constant.namespaces, constant.name)
218
201
  else
219
- minitest_class_content(namespaces, class_name)
202
+ minitest_class_content(constant.namespaces, constant.name)
220
203
  end
221
204
  create_file(test_path, content)
222
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.0".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.0
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.9
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: []