create-ruby-app 1.1.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +13 -0
- data/CLAUDE.md +74 -0
- data/CODE_REVIEW.md +1659 -0
- data/LICENSE +13 -21
- data/README.md +5 -5
- data/REFACTORING_PLAN.md +543 -0
- data/bin/create-ruby-app +1 -3
- data/lib/create_ruby_app/actions/create_directories.rb +10 -2
- data/lib/create_ruby_app/actions/generate_files.rb +7 -4
- data/lib/create_ruby_app/actions/install_gems.rb +10 -2
- data/lib/create_ruby_app/actions/make_script_executable.rb +10 -2
- data/lib/create_ruby_app/actions/set_ruby_implementation.rb +52 -0
- data/lib/create_ruby_app/app.rb +9 -8
- data/lib/create_ruby_app/cli.rb +58 -41
- data/lib/create_ruby_app/templates/Gemfile.erb +1 -3
- data/lib/create_ruby_app/templates/lib_file.erb +0 -2
- data/lib/create_ruby_app/templates/script_file.erb +0 -2
- data/lib/create_ruby_app/templates/spec_helper.erb +0 -2
- data/lib/create_ruby_app/version.rb +1 -3
- data/lib/create_ruby_app.rb +1 -3
- data/spec/integration/app_creation_spec.rb +170 -0
- data/spec/lib/create_ruby_app/actions/create_directories_spec.rb +1 -3
- data/spec/lib/create_ruby_app/actions/generate_files_spec.rb +13 -20
- data/spec/lib/create_ruby_app/actions/install_gems_spec.rb +1 -3
- data/spec/lib/create_ruby_app/actions/make_script_executable_spec.rb +1 -3
- data/spec/lib/create_ruby_app/actions/set_ruby_implementation_spec.rb +194 -0
- data/spec/lib/create_ruby_app/app_spec.rb +4 -4
- data/spec/lib/create_ruby_app/cli_spec.rb +112 -0
- data/spec/spec_helper.rb +6 -2
- metadata +52 -20
- data/lib/create_ruby_app/actions/null_action.rb +0 -9
data/CODE_REVIEW.md
ADDED
@@ -0,0 +1,1659 @@
|
|
1
|
+
# Code Review & Improvement Plan for create-ruby-app
|
2
|
+
|
3
|
+
## Overview
|
4
|
+
|
5
|
+
The codebase is well-structured with a clean action-based architecture.
|
6
|
+
Test coverage is at 64.71% (target: 90%). The code follows Ruby idioms
|
7
|
+
reasonably well but has opportunities for improvement in immutability,
|
8
|
+
side effects, DRY principles, and SOLID adherence.
|
9
|
+
|
10
|
+
---
|
11
|
+
|
12
|
+
## Critical Issues
|
13
|
+
|
14
|
+
### 1. Mutation of App State
|
15
|
+
|
16
|
+
**Location:** `lib/create_ruby_app/actions/set_ruby_implementation.rb:23`
|
17
|
+
|
18
|
+
**Problem:** `SetRubyImplementation` mutates the `app` object by calling
|
19
|
+
`app.version=`, violating immutability principles and creating hidden
|
20
|
+
side effects.
|
21
|
+
|
22
|
+
**Impact:** Makes reasoning about state difficult, complicates testing,
|
23
|
+
breaks functional programming principles.
|
24
|
+
|
25
|
+
**Before:**
|
26
|
+
```ruby
|
27
|
+
def call
|
28
|
+
return app if app.version
|
29
|
+
|
30
|
+
RUBY_IMPLEMENTATIONS.find do |ruby_implementation|
|
31
|
+
stdout, status = fetch_ruby_implementation(ruby_implementation)
|
32
|
+
|
33
|
+
if status.success? && !stdout.empty?
|
34
|
+
app.version = transform_output_to_ruby_version(stdout)
|
35
|
+
return app
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
raise NoRubyImplementationFoundError
|
40
|
+
end
|
41
|
+
```
|
42
|
+
|
43
|
+
**After:**
|
44
|
+
```ruby
|
45
|
+
def call
|
46
|
+
return app.version if app.version
|
47
|
+
|
48
|
+
RUBY_IMPLEMENTATIONS.each do |ruby_implementation|
|
49
|
+
stdout, status = fetch_ruby_implementation(ruby_implementation)
|
50
|
+
|
51
|
+
if status.success? && !stdout.empty?
|
52
|
+
return transform_output_to_ruby_version(stdout)
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
raise NoRubyImplementationFoundError
|
57
|
+
end
|
58
|
+
|
59
|
+
# In App#run!:
|
60
|
+
def run!
|
61
|
+
@version = Actions::SetRubyImplementation.call(self) unless @version
|
62
|
+
# ... rest of actions
|
63
|
+
end
|
64
|
+
```
|
65
|
+
|
66
|
+
**Benefit:** Pure function, testable, no side effects, clear data flow.
|
67
|
+
|
68
|
+
---
|
69
|
+
|
70
|
+
### 2. Test Coverage Gap (64.71% vs 90% target)
|
71
|
+
|
72
|
+
**Problem:** Missing test coverage for:
|
73
|
+
- `CLI.call` integration path
|
74
|
+
- `App#with_logger` private method behavior
|
75
|
+
- Error handling paths in actions
|
76
|
+
- Template rendering edge cases
|
77
|
+
|
78
|
+
**Impact:** Insufficient confidence in refactoring, potential bugs in
|
79
|
+
uncovered code paths.
|
80
|
+
|
81
|
+
**Example Missing Test:**
|
82
|
+
```ruby
|
83
|
+
# spec/lib/create_ruby_app/actions/install_gems_spec.rb
|
84
|
+
RSpec.describe CreateRubyApp::Actions::InstallGems do
|
85
|
+
describe ".call" do
|
86
|
+
context "when bundle install fails" do
|
87
|
+
it "raises a GemInstallationError" do
|
88
|
+
app = instance_double(CreateRubyApp::App, name: "test_app")
|
89
|
+
|
90
|
+
allow(Dir).to receive(:chdir).and_yield
|
91
|
+
allow_any_instance_of(Object).to receive(:system)
|
92
|
+
.and_return(false)
|
93
|
+
|
94
|
+
expect { described_class.call(app) }
|
95
|
+
.to raise_error(
|
96
|
+
CreateRubyApp::Actions::GemInstallationError
|
97
|
+
)
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
102
|
+
```
|
103
|
+
|
104
|
+
**Benefit:** Safer refactoring, fewer production bugs, meets project
|
105
|
+
standards.
|
106
|
+
|
107
|
+
---
|
108
|
+
|
109
|
+
## Design & Architecture Issues
|
110
|
+
|
111
|
+
### 3. Inconsistent Action Interface Pattern
|
112
|
+
|
113
|
+
**Location:** All action files in
|
114
|
+
`lib/create_ruby_app/actions/*.rb:11-13`
|
115
|
+
|
116
|
+
**Problem:** Actions have both class method `.call(app)` and instance
|
117
|
+
method `#call`, creating unnecessary indirection. The class method
|
118
|
+
instantiates then immediately calls instance method.
|
119
|
+
|
120
|
+
**Impact:** Boilerplate in every action, unclear whether to use class
|
121
|
+
or instance, harder to test.
|
122
|
+
|
123
|
+
**Before:**
|
124
|
+
```ruby
|
125
|
+
class CreateDirectories
|
126
|
+
def initialize(app)
|
127
|
+
@app = app
|
128
|
+
end
|
129
|
+
|
130
|
+
def self.call(app)
|
131
|
+
new(app).call
|
132
|
+
end
|
133
|
+
|
134
|
+
def call
|
135
|
+
[
|
136
|
+
Pathname.new("#{app.name}/bin"),
|
137
|
+
Pathname.new("#{app.name}/lib/#{app.name}"),
|
138
|
+
Pathname.new("#{app.name}/spec/lib/#{app.name}")
|
139
|
+
].each(&FileUtils.method(:mkdir_p))
|
140
|
+
end
|
141
|
+
|
142
|
+
attr_reader :app
|
143
|
+
end
|
144
|
+
```
|
145
|
+
|
146
|
+
**After:**
|
147
|
+
```ruby
|
148
|
+
class CreateDirectories
|
149
|
+
def self.call(app)
|
150
|
+
[
|
151
|
+
Pathname.new("#{app.name}/bin"),
|
152
|
+
Pathname.new("#{app.name}/lib/#{app.name}"),
|
153
|
+
Pathname.new("#{app.name}/spec/lib/#{app.name}")
|
154
|
+
].each(&FileUtils.method(:mkdir_p))
|
155
|
+
end
|
156
|
+
end
|
157
|
+
```
|
158
|
+
|
159
|
+
**Benefit:** Less boilerplate, clearer API, easier to understand and
|
160
|
+
test.
|
161
|
+
|
162
|
+
---
|
163
|
+
|
164
|
+
### 4. God Method: App#run! Violates Single Responsibility Principle
|
165
|
+
|
166
|
+
**Location:** `lib/create_ruby_app/app.rb:17-27`
|
167
|
+
|
168
|
+
**Problem:** `run!` method is hardcoded with specific actions and
|
169
|
+
logging messages. Adding/removing/reordering actions requires modifying
|
170
|
+
this method. Mixing orchestration with logging concerns.
|
171
|
+
|
172
|
+
**Impact:** Violates Open/Closed Principle, difficult to extend, tight
|
173
|
+
coupling, hard to test individual flow variations.
|
174
|
+
|
175
|
+
**Before:**
|
176
|
+
```ruby
|
177
|
+
def run!
|
178
|
+
with_logger("Creating directories...", Actions::CreateDirectories)
|
179
|
+
with_logger(
|
180
|
+
"Setting Ruby implementations...",
|
181
|
+
Actions::SetRubyImplementation
|
182
|
+
)
|
183
|
+
with_logger("Generating files...", Actions::GenerateFiles)
|
184
|
+
with_logger("Making script executable...", Actions::MakeScriptExecutable)
|
185
|
+
with_logger("Installing gems...", Actions::InstallGems)
|
186
|
+
with_logger("Happy hacking!", ->(_) {})
|
187
|
+
end
|
188
|
+
```
|
189
|
+
|
190
|
+
**After:**
|
191
|
+
```ruby
|
192
|
+
def run!
|
193
|
+
pipeline.each do |step|
|
194
|
+
logger.info(step[:message])
|
195
|
+
step[:action].call(self)
|
196
|
+
end
|
197
|
+
end
|
198
|
+
|
199
|
+
def pipeline
|
200
|
+
@pipeline ||= [
|
201
|
+
{
|
202
|
+
message: "Creating directories...",
|
203
|
+
action: Actions::CreateDirectories
|
204
|
+
},
|
205
|
+
{
|
206
|
+
message: "Setting Ruby implementations...",
|
207
|
+
action: Actions::SetRubyImplementation
|
208
|
+
},
|
209
|
+
{
|
210
|
+
message: "Generating files...",
|
211
|
+
action: Actions::GenerateFiles
|
212
|
+
},
|
213
|
+
{
|
214
|
+
message: "Making script executable...",
|
215
|
+
action: Actions::MakeScriptExecutable
|
216
|
+
},
|
217
|
+
{
|
218
|
+
message: "Installing gems...",
|
219
|
+
action: Actions::InstallGems
|
220
|
+
}
|
221
|
+
]
|
222
|
+
end
|
223
|
+
|
224
|
+
def log_completion
|
225
|
+
logger.info("Happy hacking!")
|
226
|
+
end
|
227
|
+
```
|
228
|
+
|
229
|
+
**Benefit:** Extensible, testable, follows SOLID, allows dependency
|
230
|
+
injection of action pipeline.
|
231
|
+
|
232
|
+
---
|
233
|
+
|
234
|
+
### 5. Logging Responsibility Misplaced
|
235
|
+
|
236
|
+
**Location:** `lib/create_ruby_app/app.rb:38-41`
|
237
|
+
|
238
|
+
**Problem:** `App#with_logger` couples logging to action execution.
|
239
|
+
Actions don't know they're being logged, but App controls this as a
|
240
|
+
cross-cutting concern in a non-standard way.
|
241
|
+
|
242
|
+
**Impact:** Violates Single Responsibility, makes it hard to change
|
243
|
+
logging strategy, reduces flexibility.
|
244
|
+
|
245
|
+
**Before:**
|
246
|
+
```ruby
|
247
|
+
def with_logger(text, action)
|
248
|
+
logger.info(text)
|
249
|
+
action.call(self)
|
250
|
+
end
|
251
|
+
```
|
252
|
+
|
253
|
+
**After (Option 1 - Decorator Pattern):**
|
254
|
+
```ruby
|
255
|
+
class LoggedAction
|
256
|
+
def initialize(action, message, logger)
|
257
|
+
@action = action
|
258
|
+
@message = message
|
259
|
+
@logger = logger
|
260
|
+
end
|
261
|
+
|
262
|
+
def call(app)
|
263
|
+
@logger.info(@message)
|
264
|
+
@action.call(app)
|
265
|
+
end
|
266
|
+
end
|
267
|
+
|
268
|
+
# Usage in App#run!:
|
269
|
+
def run!
|
270
|
+
actions.each { |action| action.call(self) }
|
271
|
+
logger.info("Happy hacking!")
|
272
|
+
end
|
273
|
+
|
274
|
+
def actions
|
275
|
+
[
|
276
|
+
LoggedAction.new(
|
277
|
+
Actions::CreateDirectories,
|
278
|
+
"Creating directories...",
|
279
|
+
logger
|
280
|
+
),
|
281
|
+
# ... etc
|
282
|
+
]
|
283
|
+
end
|
284
|
+
```
|
285
|
+
|
286
|
+
**After (Option 2 - Pipeline Executor):**
|
287
|
+
```ruby
|
288
|
+
class ActionPipeline
|
289
|
+
def initialize(steps, logger)
|
290
|
+
@steps = steps
|
291
|
+
@logger = logger
|
292
|
+
end
|
293
|
+
|
294
|
+
def execute(app)
|
295
|
+
@steps.each do |step|
|
296
|
+
@logger.info(step[:message])
|
297
|
+
step[:action].call(app)
|
298
|
+
end
|
299
|
+
end
|
300
|
+
end
|
301
|
+
|
302
|
+
# Usage:
|
303
|
+
def run!
|
304
|
+
ActionPipeline.new(pipeline, logger).execute(self)
|
305
|
+
end
|
306
|
+
```
|
307
|
+
|
308
|
+
**Benefit:** Separation of concerns, flexible logging strategies,
|
309
|
+
cleaner App class.
|
310
|
+
|
311
|
+
---
|
312
|
+
|
313
|
+
### 6. Tight Coupling: Actions Know Too Much About App Structure
|
314
|
+
|
315
|
+
**Location:** All action classes
|
316
|
+
|
317
|
+
**Problem:** Actions directly access `app.name`, `app.gems`,
|
318
|
+
`app.version`. Changes to App internal structure ripple through all
|
319
|
+
actions.
|
320
|
+
|
321
|
+
**Impact:** High coupling, difficult to refactor App, violates Law of
|
322
|
+
Demeter.
|
323
|
+
|
324
|
+
**Before:**
|
325
|
+
```ruby
|
326
|
+
class CreateDirectories
|
327
|
+
def self.call(app)
|
328
|
+
[
|
329
|
+
Pathname.new("#{app.name}/bin"),
|
330
|
+
Pathname.new("#{app.name}/lib/#{app.name}"),
|
331
|
+
Pathname.new("#{app.name}/spec/lib/#{app.name}")
|
332
|
+
].each(&FileUtils.method(:mkdir_p))
|
333
|
+
end
|
334
|
+
end
|
335
|
+
```
|
336
|
+
|
337
|
+
**After (Option 1 - Pass Only Required Data):**
|
338
|
+
```ruby
|
339
|
+
class CreateDirectories
|
340
|
+
def self.call(project_name:)
|
341
|
+
[
|
342
|
+
Pathname.new("#{project_name}/bin"),
|
343
|
+
Pathname.new("#{project_name}/lib/#{project_name}"),
|
344
|
+
Pathname.new("#{project_name}/spec/lib/#{project_name}")
|
345
|
+
].each(&FileUtils.method(:mkdir_p))
|
346
|
+
end
|
347
|
+
end
|
348
|
+
|
349
|
+
# In App#run!:
|
350
|
+
Actions::CreateDirectories.call(project_name: name)
|
351
|
+
```
|
352
|
+
|
353
|
+
**After (Option 2 - Context Object):**
|
354
|
+
```ruby
|
355
|
+
class AppContext
|
356
|
+
attr_reader :project_name, :ruby_version, :gems, :paths
|
357
|
+
|
358
|
+
def initialize(name:, version:, gems:)
|
359
|
+
@project_name = name
|
360
|
+
@ruby_version = version
|
361
|
+
@gems = gems
|
362
|
+
@paths = PathBuilder.new(name)
|
363
|
+
end
|
364
|
+
end
|
365
|
+
|
366
|
+
class CreateDirectories
|
367
|
+
def self.call(context)
|
368
|
+
context.paths.directories.each(&FileUtils.method(:mkdir_p))
|
369
|
+
end
|
370
|
+
end
|
371
|
+
```
|
372
|
+
|
373
|
+
**Benefit:** Loose coupling, easier to refactor, clearer dependencies,
|
374
|
+
better testability.
|
375
|
+
|
376
|
+
---
|
377
|
+
|
378
|
+
## Code Quality Issues
|
379
|
+
|
380
|
+
### 7. Magic Empty Lambda as Final Action
|
381
|
+
|
382
|
+
**Location:** `lib/create_ruby_app/app.rb:26`
|
383
|
+
|
384
|
+
**Problem:** `with_logger("Happy hacking!", ->(_) {})` uses empty
|
385
|
+
lambda to show completion message. Unclear intent, breaks action
|
386
|
+
pattern consistency.
|
387
|
+
|
388
|
+
**Impact:** Confusing, fragile, violates Principle of Least Surprise.
|
389
|
+
|
390
|
+
**Before:**
|
391
|
+
```ruby
|
392
|
+
def run!
|
393
|
+
with_logger("Creating directories...", Actions::CreateDirectories)
|
394
|
+
# ... other actions
|
395
|
+
with_logger("Happy hacking!", ->(_) {})
|
396
|
+
end
|
397
|
+
```
|
398
|
+
|
399
|
+
**After (Option 1):**
|
400
|
+
```ruby
|
401
|
+
def run!
|
402
|
+
with_logger("Creating directories...", Actions::CreateDirectories)
|
403
|
+
# ... other actions
|
404
|
+
log_completion
|
405
|
+
end
|
406
|
+
|
407
|
+
private
|
408
|
+
|
409
|
+
def log_completion
|
410
|
+
logger.info("Happy hacking!")
|
411
|
+
end
|
412
|
+
```
|
413
|
+
|
414
|
+
**After (Option 2):**
|
415
|
+
```ruby
|
416
|
+
class CompletionAction
|
417
|
+
def self.call(_app)
|
418
|
+
# No-op action for consistency
|
419
|
+
end
|
420
|
+
end
|
421
|
+
|
422
|
+
def run!
|
423
|
+
with_logger("Creating directories...", Actions::CreateDirectories)
|
424
|
+
# ... other actions
|
425
|
+
with_logger("Happy hacking!", CompletionAction)
|
426
|
+
end
|
427
|
+
```
|
428
|
+
|
429
|
+
**Benefit:** Clear intent, consistent pattern, self-documenting code.
|
430
|
+
|
431
|
+
---
|
432
|
+
|
433
|
+
### 8. Hardcoded Constants Should Be Configurable
|
434
|
+
|
435
|
+
**Location:**
|
436
|
+
`lib/create_ruby_app/actions/set_ruby_implementation.rb:41-47`
|
437
|
+
|
438
|
+
**Problem:** `RUBY_IMPLEMENTATIONS` array is hardcoded. No way to
|
439
|
+
extend or modify Ruby implementation search order without modifying
|
440
|
+
source.
|
441
|
+
|
442
|
+
**Impact:** Not extensible for new Ruby implementations or custom
|
443
|
+
search orders.
|
444
|
+
|
445
|
+
**Before:**
|
446
|
+
```ruby
|
447
|
+
RUBY_IMPLEMENTATIONS = %w[
|
448
|
+
ruby
|
449
|
+
truffleruby
|
450
|
+
jruby
|
451
|
+
mruby
|
452
|
+
rubinius
|
453
|
+
].freeze
|
454
|
+
```
|
455
|
+
|
456
|
+
**After:**
|
457
|
+
```ruby
|
458
|
+
class SetRubyImplementation
|
459
|
+
DEFAULT_IMPLEMENTATIONS = %w[
|
460
|
+
ruby
|
461
|
+
truffleruby
|
462
|
+
jruby
|
463
|
+
mruby
|
464
|
+
rubinius
|
465
|
+
].freeze
|
466
|
+
|
467
|
+
def self.call(app, implementations: DEFAULT_IMPLEMENTATIONS)
|
468
|
+
return app.version if app.version
|
469
|
+
|
470
|
+
implementations.each do |ruby_implementation|
|
471
|
+
stdout, status = fetch_ruby_implementation(ruby_implementation)
|
472
|
+
|
473
|
+
if status.success? && !stdout.empty?
|
474
|
+
return transform_output_to_ruby_version(stdout)
|
475
|
+
end
|
476
|
+
end
|
477
|
+
|
478
|
+
raise NoRubyImplementationFoundError
|
479
|
+
end
|
480
|
+
end
|
481
|
+
|
482
|
+
# In App initialization:
|
483
|
+
def initialize(
|
484
|
+
name: "app",
|
485
|
+
gems: [],
|
486
|
+
version: nil,
|
487
|
+
logger: Logger.new($stdout),
|
488
|
+
ruby_implementations: nil
|
489
|
+
)
|
490
|
+
@name = name
|
491
|
+
@gems = gems
|
492
|
+
@version = version
|
493
|
+
@logger = logger
|
494
|
+
@ruby_implementations = ruby_implementations
|
495
|
+
end
|
496
|
+
```
|
497
|
+
|
498
|
+
**Benefit:** Flexible, extensible, follows Open/Closed Principle.
|
499
|
+
|
500
|
+
---
|
501
|
+
|
502
|
+
### 9. Non-functional String Manipulation in GenerateFiles#gemfile
|
503
|
+
|
504
|
+
**Location:** `lib/create_ruby_app/actions/generate_files.rb:38-44`
|
505
|
+
|
506
|
+
**Problem:** Inline block with sorting, mapping, and joining makes code
|
507
|
+
harder to read. The logic for formatting gems is mixed with template
|
508
|
+
data preparation.
|
509
|
+
|
510
|
+
**Impact:** Reduced readability, difficult to test gem formatting
|
511
|
+
independently, violates Single Responsibility.
|
512
|
+
|
513
|
+
**Before:**
|
514
|
+
```ruby
|
515
|
+
def gemfile
|
516
|
+
generate_file(file: "Gemfile.erb", locals: {
|
517
|
+
gems: app
|
518
|
+
.gems
|
519
|
+
.sort
|
520
|
+
.map {|gem| "gem \"#{gem}\"" }.join("\n")
|
521
|
+
})
|
522
|
+
end
|
523
|
+
```
|
524
|
+
|
525
|
+
**After:**
|
526
|
+
```ruby
|
527
|
+
def gemfile
|
528
|
+
generate_file(
|
529
|
+
file: "Gemfile.erb",
|
530
|
+
locals: { gems: format_gems_for_gemfile(app.gems) }
|
531
|
+
)
|
532
|
+
end
|
533
|
+
|
534
|
+
private
|
535
|
+
|
536
|
+
def format_gems_for_gemfile(gems)
|
537
|
+
gems
|
538
|
+
.sort
|
539
|
+
.map { |gem| "gem \"#{gem}\"" }
|
540
|
+
.join("\n")
|
541
|
+
end
|
542
|
+
```
|
543
|
+
|
544
|
+
**Benefit:** Readable, testable, follows Single Responsibility
|
545
|
+
Principle.
|
546
|
+
|
547
|
+
---
|
548
|
+
|
549
|
+
### 10. Path Construction Scattered Across Actions
|
550
|
+
|
551
|
+
**Location:** Multiple files:
|
552
|
+
- `lib/create_ruby_app/actions/create_directories.rb:17-19`
|
553
|
+
- `lib/create_ruby_app/actions/make_script_executable.rb:15`
|
554
|
+
- `lib/create_ruby_app/actions/generate_files.rb:56`
|
555
|
+
- `lib/create_ruby_app/actions/install_gems.rb:13`
|
556
|
+
|
557
|
+
**Problem:** String interpolation like `"#{app.name}/bin/#{app.name}"`
|
558
|
+
appears in multiple actions.
|
559
|
+
|
560
|
+
**Impact:** DRY violation, inconsistent path handling, error-prone,
|
561
|
+
hard to change directory structure.
|
562
|
+
|
563
|
+
**Before:**
|
564
|
+
```ruby
|
565
|
+
# In CreateDirectories:
|
566
|
+
Pathname.new("#{app.name}/bin")
|
567
|
+
Pathname.new("#{app.name}/lib/#{app.name}")
|
568
|
+
|
569
|
+
# In MakeScriptExecutable:
|
570
|
+
FileUtils.chmod("+x", "#{app.name}/bin/#{app.name}")
|
571
|
+
|
572
|
+
# In GenerateFiles:
|
573
|
+
File.write("#{app.name}/#{path}", content)
|
574
|
+
|
575
|
+
# In InstallGems:
|
576
|
+
Dir.chdir(app.name) { system("bundle", "install") }
|
577
|
+
```
|
578
|
+
|
579
|
+
**After:**
|
580
|
+
```ruby
|
581
|
+
# In App class:
|
582
|
+
def project_root
|
583
|
+
@project_root ||= Pathname.new(name)
|
584
|
+
end
|
585
|
+
|
586
|
+
def bin_dir
|
587
|
+
project_root.join("bin")
|
588
|
+
end
|
589
|
+
|
590
|
+
def bin_script_path
|
591
|
+
bin_dir.join(name)
|
592
|
+
end
|
593
|
+
|
594
|
+
def lib_dir
|
595
|
+
project_root.join("lib")
|
596
|
+
end
|
597
|
+
|
598
|
+
def lib_subdir
|
599
|
+
lib_dir.join(name)
|
600
|
+
end
|
601
|
+
|
602
|
+
def spec_dir
|
603
|
+
project_root.join("spec")
|
604
|
+
end
|
605
|
+
|
606
|
+
def spec_lib_dir
|
607
|
+
spec_dir.join("lib", name)
|
608
|
+
end
|
609
|
+
|
610
|
+
def file_path(relative_path)
|
611
|
+
project_root.join(relative_path)
|
612
|
+
end
|
613
|
+
|
614
|
+
# Usage in actions:
|
615
|
+
class CreateDirectories
|
616
|
+
def self.call(app)
|
617
|
+
[
|
618
|
+
app.bin_dir,
|
619
|
+
app.lib_subdir,
|
620
|
+
app.spec_lib_dir
|
621
|
+
].each(&FileUtils.method(:mkdir_p))
|
622
|
+
end
|
623
|
+
end
|
624
|
+
|
625
|
+
class MakeScriptExecutable
|
626
|
+
def self.call(app)
|
627
|
+
FileUtils.chmod("+x", app.bin_script_path)
|
628
|
+
end
|
629
|
+
end
|
630
|
+
|
631
|
+
class GenerateFiles
|
632
|
+
def create_file(path:, content:)
|
633
|
+
File.write(app.file_path(path), content)
|
634
|
+
end
|
635
|
+
end
|
636
|
+
|
637
|
+
class InstallGems
|
638
|
+
def self.call(app)
|
639
|
+
Dir.chdir(app.project_root) { system("bundle", "install") }
|
640
|
+
end
|
641
|
+
end
|
642
|
+
```
|
643
|
+
|
644
|
+
**Benefit:** DRY, consistent paths, single source of truth, easier to
|
645
|
+
test and modify.
|
646
|
+
|
647
|
+
---
|
648
|
+
|
649
|
+
### 11. Mixed Concerns in GenerateFiles
|
650
|
+
|
651
|
+
**Location:** `lib/create_ruby_app/actions/generate_files.rb`
|
652
|
+
|
653
|
+
**Problem:** Class handles both file generation logic AND template
|
654
|
+
rendering AND path construction. Methods like `script_file`, `lib_file`
|
655
|
+
are public but only used internally.
|
656
|
+
|
657
|
+
**Impact:** Violates Single Responsibility, unclear API, difficult to
|
658
|
+
test components independently.
|
659
|
+
|
660
|
+
**Before:**
|
661
|
+
```ruby
|
662
|
+
class GenerateFiles
|
663
|
+
def call
|
664
|
+
generate_files
|
665
|
+
end
|
666
|
+
|
667
|
+
def script_file
|
668
|
+
generate_file(file: "script_file.erb", locals: {})
|
669
|
+
end
|
670
|
+
|
671
|
+
def lib_file
|
672
|
+
generate_file(file: "lib_file.erb", locals: { app: app.classify_name })
|
673
|
+
end
|
674
|
+
|
675
|
+
# ... more public methods that should be private
|
676
|
+
|
677
|
+
private
|
678
|
+
|
679
|
+
def generate_file(file:, locals: [])
|
680
|
+
ERB
|
681
|
+
.new(read_file(file), trim_mode: TRIM_MODE)
|
682
|
+
.result_with_hash(locals: locals)
|
683
|
+
end
|
684
|
+
|
685
|
+
def read_file(file)
|
686
|
+
Pathname.new(__FILE__)
|
687
|
+
.dirname
|
688
|
+
.join("#{TEMPLATES_DIR}/#{file}")
|
689
|
+
.read
|
690
|
+
end
|
691
|
+
|
692
|
+
def files
|
693
|
+
{
|
694
|
+
"bin/#{app.name}" => script_file,
|
695
|
+
"lib/#{app.name}.rb" => lib_file,
|
696
|
+
# ...
|
697
|
+
}
|
698
|
+
end
|
699
|
+
end
|
700
|
+
```
|
701
|
+
|
702
|
+
**After:**
|
703
|
+
```ruby
|
704
|
+
# Separate template renderer:
|
705
|
+
class TemplateRenderer
|
706
|
+
TRIM_MODE = "<>"
|
707
|
+
TEMPLATES_DIR = File.expand_path("../templates", __dir__)
|
708
|
+
|
709
|
+
def self.render(template_name, locals = {})
|
710
|
+
template_path = File.join(TEMPLATES_DIR, "#{template_name}.erb")
|
711
|
+
template_content = File.read(template_path)
|
712
|
+
|
713
|
+
ERB
|
714
|
+
.new(template_content, trim_mode: TRIM_MODE)
|
715
|
+
.result_with_hash(locals: locals)
|
716
|
+
end
|
717
|
+
|
718
|
+
private_constant :TRIM_MODE, :TEMPLATES_DIR
|
719
|
+
end
|
720
|
+
|
721
|
+
# Simplified GenerateFiles:
|
722
|
+
class GenerateFiles
|
723
|
+
def self.call(app)
|
724
|
+
files(app).each do |path, content|
|
725
|
+
File.write(app.file_path(path), content)
|
726
|
+
end
|
727
|
+
end
|
728
|
+
|
729
|
+
def self.files(app)
|
730
|
+
{
|
731
|
+
"bin/#{app.name}" => script_file,
|
732
|
+
"lib/#{app.name}.rb" => lib_file(app),
|
733
|
+
"spec/spec_helper.rb" => spec_helper_file(app),
|
734
|
+
".ruby-version" => ruby_version_file(app),
|
735
|
+
"Gemfile" => gemfile(app)
|
736
|
+
}
|
737
|
+
end
|
738
|
+
|
739
|
+
private_class_method def self.script_file
|
740
|
+
TemplateRenderer.render("script_file")
|
741
|
+
end
|
742
|
+
|
743
|
+
private_class_method def self.lib_file(app)
|
744
|
+
TemplateRenderer.render("lib_file", app: app.classify_name)
|
745
|
+
end
|
746
|
+
|
747
|
+
private_class_method def self.spec_helper_file(app)
|
748
|
+
TemplateRenderer.render("spec_helper", app: app.name)
|
749
|
+
end
|
750
|
+
|
751
|
+
private_class_method def self.ruby_version_file(app)
|
752
|
+
TemplateRenderer.render("ruby-version", version: app.version)
|
753
|
+
end
|
754
|
+
|
755
|
+
private_class_method def self.gemfile(app)
|
756
|
+
TemplateRenderer.render(
|
757
|
+
"Gemfile",
|
758
|
+
gems: format_gems(app.gems)
|
759
|
+
)
|
760
|
+
end
|
761
|
+
|
762
|
+
private_class_method def self.format_gems(gems)
|
763
|
+
gems.sort.map { |gem| "gem \"#{gem}\"" }.join("\n")
|
764
|
+
end
|
765
|
+
end
|
766
|
+
```
|
767
|
+
|
768
|
+
**Benefit:** Single Responsibility, clearer separation, easier to test,
|
769
|
+
better encapsulation.
|
770
|
+
|
771
|
+
---
|
772
|
+
|
773
|
+
### 12. Error Handling Inconsistency
|
774
|
+
|
775
|
+
**Location:** Various action files
|
776
|
+
|
777
|
+
**Problem:** Only `SetRubyImplementation` has custom error class. Other
|
778
|
+
actions fail silently or raise system errors (FileUtils, System calls).
|
779
|
+
|
780
|
+
**Impact:** Inconsistent error handling, difficult to rescue specific
|
781
|
+
failures, poor user experience.
|
782
|
+
|
783
|
+
**Before:**
|
784
|
+
```ruby
|
785
|
+
# Only one custom error:
|
786
|
+
class NoRubyImplementationFoundError < StandardError; end
|
787
|
+
|
788
|
+
# Other actions have no error handling:
|
789
|
+
class InstallGems
|
790
|
+
def self.call(app)
|
791
|
+
Dir.chdir(app.name) { system("bundle", "install") }
|
792
|
+
end
|
793
|
+
end
|
794
|
+
```
|
795
|
+
|
796
|
+
**After:**
|
797
|
+
```ruby
|
798
|
+
# lib/create_ruby_app/errors.rb
|
799
|
+
module CreateRubyApp
|
800
|
+
class Error < StandardError; end
|
801
|
+
|
802
|
+
class NoRubyImplementationFoundError < Error; end
|
803
|
+
class DirectoryCreationError < Error; end
|
804
|
+
class FileGenerationError < Error; end
|
805
|
+
class GemInstallationError < Error; end
|
806
|
+
class ScriptExecutableError < Error; end
|
807
|
+
end
|
808
|
+
|
809
|
+
# Usage in actions:
|
810
|
+
class InstallGems
|
811
|
+
def self.call(app)
|
812
|
+
Dir.chdir(app.project_root) do
|
813
|
+
success = system("bundle", "install")
|
814
|
+
|
815
|
+
unless success
|
816
|
+
raise GemInstallationError,
|
817
|
+
"Failed to install gems for #{app.name}. " \
|
818
|
+
"Please check your Gemfile and try again."
|
819
|
+
end
|
820
|
+
end
|
821
|
+
rescue Errno::ENOENT => e
|
822
|
+
raise GemInstallationError,
|
823
|
+
"Bundle command not found: #{e.message}"
|
824
|
+
end
|
825
|
+
end
|
826
|
+
|
827
|
+
class CreateDirectories
|
828
|
+
def self.call(app)
|
829
|
+
[
|
830
|
+
app.bin_dir,
|
831
|
+
app.lib_subdir,
|
832
|
+
app.spec_lib_dir
|
833
|
+
].each do |dir|
|
834
|
+
FileUtils.mkdir_p(dir)
|
835
|
+
end
|
836
|
+
rescue Errno::EACCES => e
|
837
|
+
raise DirectoryCreationError,
|
838
|
+
"Permission denied creating directories: #{e.message}"
|
839
|
+
rescue StandardError => e
|
840
|
+
raise DirectoryCreationError,
|
841
|
+
"Failed to create directories: #{e.message}"
|
842
|
+
end
|
843
|
+
end
|
844
|
+
```
|
845
|
+
|
846
|
+
**Benefit:** Consistent error handling, better user experience, easier
|
847
|
+
to debug, more robust.
|
848
|
+
|
849
|
+
---
|
850
|
+
|
851
|
+
### 13. System Call Without Error Handling
|
852
|
+
|
853
|
+
**Location:** `lib/create_ruby_app/actions/install_gems.rb:13`
|
854
|
+
|
855
|
+
**Problem:** `system("bundle", "install")` doesn't check return value.
|
856
|
+
If bundler fails, execution continues silently.
|
857
|
+
|
858
|
+
**Impact:** Creates incomplete/broken projects, silent failures, poor
|
859
|
+
user experience.
|
860
|
+
|
861
|
+
**Before:**
|
862
|
+
```ruby
|
863
|
+
class InstallGems
|
864
|
+
def self.call(app)
|
865
|
+
Dir.chdir(app.name) { system("bundle", "install") }
|
866
|
+
end
|
867
|
+
end
|
868
|
+
```
|
869
|
+
|
870
|
+
**After:**
|
871
|
+
```ruby
|
872
|
+
class InstallGems
|
873
|
+
def self.call(app)
|
874
|
+
Dir.chdir(app.project_root) do
|
875
|
+
success = system("bundle", "install")
|
876
|
+
|
877
|
+
unless success
|
878
|
+
raise GemInstallationError,
|
879
|
+
"Bundle install failed with exit code #{$?.exitstatus}. " \
|
880
|
+
"Please check your Gemfile and ensure bundler is installed."
|
881
|
+
end
|
882
|
+
end
|
883
|
+
rescue Errno::ENOENT
|
884
|
+
raise GemInstallationError,
|
885
|
+
"Bundler is not installed. Please run: gem install bundler"
|
886
|
+
end
|
887
|
+
end
|
888
|
+
```
|
889
|
+
|
890
|
+
**Benefit:** Catches failures early, better user experience, more
|
891
|
+
robust.
|
892
|
+
|
893
|
+
---
|
894
|
+
|
895
|
+
### 14. Template Constants Are Implementation Details
|
896
|
+
|
897
|
+
**Location:** `lib/create_ruby_app/actions/generate_files.rb:82-83`
|
898
|
+
|
899
|
+
**Problem:** `TRIM_MODE` and `TEMPLATES_DIR` are public constants but
|
900
|
+
are implementation details.
|
901
|
+
|
902
|
+
**Impact:** Pollutes constant namespace, can be accidentally overridden,
|
903
|
+
unclear why they're public.
|
904
|
+
|
905
|
+
**Before:**
|
906
|
+
```ruby
|
907
|
+
class GenerateFiles
|
908
|
+
# ... methods ...
|
909
|
+
|
910
|
+
TRIM_MODE = "<>"
|
911
|
+
TEMPLATES_DIR = "../templates"
|
912
|
+
end
|
913
|
+
```
|
914
|
+
|
915
|
+
**After:**
|
916
|
+
```ruby
|
917
|
+
class GenerateFiles
|
918
|
+
TRIM_MODE = "<>"
|
919
|
+
TEMPLATES_DIR = "../templates"
|
920
|
+
|
921
|
+
private_constant :TRIM_MODE, :TEMPLATES_DIR
|
922
|
+
|
923
|
+
# ... methods ...
|
924
|
+
end
|
925
|
+
```
|
926
|
+
|
927
|
+
**Benefit:** Better encapsulation, clearer API, prevents accidental
|
928
|
+
modification.
|
929
|
+
|
930
|
+
---
|
931
|
+
|
932
|
+
### 15. Unused Empty Class
|
933
|
+
|
934
|
+
**Location:** `lib/create_ruby_app.rb:11-12`
|
935
|
+
|
936
|
+
**Problem:** `CreateRubyApp::CreateRubyApp` class is empty and unused.
|
937
|
+
|
938
|
+
**Impact:** Dead code, confusing namespace, unclear purpose.
|
939
|
+
|
940
|
+
**Before:**
|
941
|
+
```ruby
|
942
|
+
module CreateRubyApp
|
943
|
+
class CreateRubyApp
|
944
|
+
end
|
945
|
+
end
|
946
|
+
```
|
947
|
+
|
948
|
+
**After:**
|
949
|
+
```ruby
|
950
|
+
# Remove the empty class entirely
|
951
|
+
```
|
952
|
+
|
953
|
+
**Benefit:** Cleaner codebase, less confusion, follows YAGNI principle.
|
954
|
+
|
955
|
+
---
|
956
|
+
|
957
|
+
## Minor Improvements
|
958
|
+
|
959
|
+
### 16. classify_name Could Use ActiveSupport-style Method
|
960
|
+
|
961
|
+
**Location:** `lib/create_ruby_app/app.rb:29-31`
|
962
|
+
|
963
|
+
**Problem:** Manual string manipulation. Ruby/Rails has established
|
964
|
+
patterns for this.
|
965
|
+
|
966
|
+
**Impact:** Minor: reinventing the wheel, less idiomatic.
|
967
|
+
|
968
|
+
**Before:**
|
969
|
+
```ruby
|
970
|
+
def classify_name
|
971
|
+
name.split("_").collect(&:capitalize).join
|
972
|
+
end
|
973
|
+
```
|
974
|
+
|
975
|
+
**After:**
|
976
|
+
```ruby
|
977
|
+
def classify_name
|
978
|
+
name.split("_").map(&:capitalize).join
|
979
|
+
end
|
980
|
+
|
981
|
+
# Or extract to a separate concern:
|
982
|
+
module StringClassifier
|
983
|
+
def self.classify(string)
|
984
|
+
string.split("_").map(&:capitalize).join
|
985
|
+
end
|
986
|
+
end
|
987
|
+
|
988
|
+
def classify_name
|
989
|
+
StringClassifier.classify(name)
|
990
|
+
end
|
991
|
+
```
|
992
|
+
|
993
|
+
**Benefit:** More idiomatic, potentially more robust edge case handling.
|
994
|
+
|
995
|
+
---
|
996
|
+
|
997
|
+
### 17. Gemspec Has Outdated Date
|
998
|
+
|
999
|
+
**Location:** `create_ruby_app.gemspec:8`
|
1000
|
+
|
1001
|
+
**Problem:** `s.date = "2019-03-11"` is hardcoded and outdated.
|
1002
|
+
|
1003
|
+
**Impact:** Misleading metadata, appears unmaintained.
|
1004
|
+
|
1005
|
+
**Before:**
|
1006
|
+
```ruby
|
1007
|
+
Gem::Specification.new do |s|
|
1008
|
+
s.name = "create-ruby-app"
|
1009
|
+
s.version = CreateRubyApp::VERSION
|
1010
|
+
s.date = "2019-03-11"
|
1011
|
+
# ...
|
1012
|
+
end
|
1013
|
+
```
|
1014
|
+
|
1015
|
+
**After:**
|
1016
|
+
```ruby
|
1017
|
+
Gem::Specification.new do |s|
|
1018
|
+
s.name = "create-ruby-app"
|
1019
|
+
s.version = CreateRubyApp::VERSION
|
1020
|
+
# Let RubyGems set the date automatically
|
1021
|
+
# ...
|
1022
|
+
end
|
1023
|
+
```
|
1024
|
+
|
1025
|
+
**Benefit:** Accurate metadata, appears maintained.
|
1026
|
+
|
1027
|
+
---
|
1028
|
+
|
1029
|
+
### 18. Dry::CLI::Registry Could Use Module Inclusion
|
1030
|
+
|
1031
|
+
**Location:** `lib/create_ruby_app/cli.rb:4-5`
|
1032
|
+
|
1033
|
+
**Problem:** `Commands` module extends `Dry::CLI::Registry`, but it's
|
1034
|
+
not immediately clear this is necessary vs include.
|
1035
|
+
|
1036
|
+
**Impact:** Minor: potential confusion for maintainers unfamiliar with
|
1037
|
+
dry-cli.
|
1038
|
+
|
1039
|
+
**Before:**
|
1040
|
+
```ruby
|
1041
|
+
module Commands
|
1042
|
+
extend Dry::CLI::Registry
|
1043
|
+
|
1044
|
+
class Version < Dry::CLI::Command
|
1045
|
+
# ...
|
1046
|
+
end
|
1047
|
+
end
|
1048
|
+
```
|
1049
|
+
|
1050
|
+
**After:**
|
1051
|
+
```ruby
|
1052
|
+
module Commands
|
1053
|
+
# Extend (not include) is required for Dry::CLI registry pattern
|
1054
|
+
extend Dry::CLI::Registry
|
1055
|
+
|
1056
|
+
class Version < Dry::CLI::Command
|
1057
|
+
# ...
|
1058
|
+
end
|
1059
|
+
end
|
1060
|
+
```
|
1061
|
+
|
1062
|
+
**Benefit:** Clearer intent, easier maintenance.
|
1063
|
+
|
1064
|
+
---
|
1065
|
+
|
1066
|
+
### 19. parse_gems Method Could Be More Functional
|
1067
|
+
|
1068
|
+
**Location:** `lib/create_ruby_app/cli.rb:53-57`
|
1069
|
+
|
1070
|
+
**Problem:** Multiple transformations chained, but early return with
|
1071
|
+
ternary operator breaks functional flow.
|
1072
|
+
|
1073
|
+
**Impact:** Minor: slightly less readable, mixed paradigms.
|
1074
|
+
|
1075
|
+
**Before:**
|
1076
|
+
```ruby
|
1077
|
+
def parse_gems(gems_string)
|
1078
|
+
return [] if gems_string.nil? || gems_string.strip.empty?
|
1079
|
+
|
1080
|
+
gems_string.split(",").map(&:strip).reject(&:empty?)
|
1081
|
+
end
|
1082
|
+
```
|
1083
|
+
|
1084
|
+
**After:**
|
1085
|
+
```ruby
|
1086
|
+
def parse_gems(gems_string)
|
1087
|
+
gems_string
|
1088
|
+
.to_s
|
1089
|
+
.split(",")
|
1090
|
+
.map(&:strip)
|
1091
|
+
.reject(&:empty?)
|
1092
|
+
end
|
1093
|
+
|
1094
|
+
# Or more Ruby-idiomatic with safe navigation:
|
1095
|
+
def parse_gems(gems_string)
|
1096
|
+
return [] unless gems_string
|
1097
|
+
|
1098
|
+
gems_string
|
1099
|
+
.split(",")
|
1100
|
+
.map(&:strip)
|
1101
|
+
.reject(&:empty?)
|
1102
|
+
end
|
1103
|
+
```
|
1104
|
+
|
1105
|
+
**Benefit:** More functional, concise, Ruby-idiomatic.
|
1106
|
+
|
1107
|
+
---
|
1108
|
+
|
1109
|
+
### 20. Logger Deprecation Warning
|
1110
|
+
|
1111
|
+
**Location:** `lib/create_ruby_app/app.rb:1`
|
1112
|
+
|
1113
|
+
**Problem:** Test output shows: "logger was loaded from the standard
|
1114
|
+
library, but will no longer be part of the default gems starting from
|
1115
|
+
Ruby 3.5.0."
|
1116
|
+
|
1117
|
+
**Impact:** Future compatibility issue, will break in Ruby 3.5+.
|
1118
|
+
|
1119
|
+
**Before:**
|
1120
|
+
```ruby
|
1121
|
+
# In create_ruby_app.gemspec:
|
1122
|
+
s.add_development_dependency "rake", "~> 13.3.0"
|
1123
|
+
s.add_development_dependency "rspec", "~> 3.13.1"
|
1124
|
+
s.add_development_dependency "simplecov", "~> 0.22.0"
|
1125
|
+
```
|
1126
|
+
|
1127
|
+
**After:**
|
1128
|
+
```ruby
|
1129
|
+
# In create_ruby_app.gemspec:
|
1130
|
+
s.add_runtime_dependency "logger", "~> 1.6"
|
1131
|
+
|
1132
|
+
s.add_development_dependency "rake", "~> 13.3.0"
|
1133
|
+
s.add_development_dependency "rspec", "~> 3.13.1"
|
1134
|
+
s.add_development_dependency "simplecov", "~> 0.22.0"
|
1135
|
+
```
|
1136
|
+
|
1137
|
+
**Benefit:** Future-proof, no warnings, follows Ruby 3.5+ requirements.
|
1138
|
+
|
1139
|
+
---
|
1140
|
+
|
1141
|
+
## Refactoring Opportunities
|
1142
|
+
|
1143
|
+
### 21. Extract Action Pipeline Pattern
|
1144
|
+
|
1145
|
+
**Current:** Actions hardcoded in `App#run!`
|
1146
|
+
|
1147
|
+
**Before:**
|
1148
|
+
```ruby
|
1149
|
+
def run!
|
1150
|
+
with_logger("Creating directories...", Actions::CreateDirectories)
|
1151
|
+
with_logger(
|
1152
|
+
"Setting Ruby implementations...",
|
1153
|
+
Actions::SetRubyImplementation
|
1154
|
+
)
|
1155
|
+
with_logger("Generating files...", Actions::GenerateFiles)
|
1156
|
+
with_logger("Making script executable...", Actions::MakeScriptExecutable)
|
1157
|
+
with_logger("Installing gems...", Actions::InstallGems)
|
1158
|
+
with_logger("Happy hacking!", ->(_) {})
|
1159
|
+
end
|
1160
|
+
```
|
1161
|
+
|
1162
|
+
**After:**
|
1163
|
+
```ruby
|
1164
|
+
# lib/create_ruby_app/action_pipeline.rb
|
1165
|
+
module CreateRubyApp
|
1166
|
+
class ActionPipeline
|
1167
|
+
Step = Struct.new(:message, :action, keyword_init: true)
|
1168
|
+
|
1169
|
+
def initialize(steps, logger)
|
1170
|
+
@steps = steps
|
1171
|
+
@logger = logger
|
1172
|
+
end
|
1173
|
+
|
1174
|
+
def execute(context)
|
1175
|
+
@steps.each do |step|
|
1176
|
+
@logger.info(step.message)
|
1177
|
+
step.action.call(context)
|
1178
|
+
end
|
1179
|
+
end
|
1180
|
+
end
|
1181
|
+
end
|
1182
|
+
|
1183
|
+
# In App class:
|
1184
|
+
def run!
|
1185
|
+
ActionPipeline.new(pipeline_steps, logger).execute(self)
|
1186
|
+
logger.info("Happy hacking!")
|
1187
|
+
end
|
1188
|
+
|
1189
|
+
def pipeline_steps
|
1190
|
+
[
|
1191
|
+
ActionPipeline::Step.new(
|
1192
|
+
message: "Creating directories...",
|
1193
|
+
action: Actions::CreateDirectories
|
1194
|
+
),
|
1195
|
+
ActionPipeline::Step.new(
|
1196
|
+
message: "Setting Ruby implementations...",
|
1197
|
+
action: Actions::SetRubyImplementation
|
1198
|
+
),
|
1199
|
+
ActionPipeline::Step.new(
|
1200
|
+
message: "Generating files...",
|
1201
|
+
action: Actions::GenerateFiles
|
1202
|
+
),
|
1203
|
+
ActionPipeline::Step.new(
|
1204
|
+
message: "Making script executable...",
|
1205
|
+
action: Actions::MakeScriptExecutable
|
1206
|
+
),
|
1207
|
+
ActionPipeline::Step.new(
|
1208
|
+
message: "Installing gems...",
|
1209
|
+
action: Actions::InstallGems
|
1210
|
+
)
|
1211
|
+
]
|
1212
|
+
end
|
1213
|
+
```
|
1214
|
+
|
1215
|
+
**Benefit:** Testable pipeline, configurable actions, follows Strategy
|
1216
|
+
pattern, Open/Closed Principle.
|
1217
|
+
|
1218
|
+
---
|
1219
|
+
|
1220
|
+
### 22. Introduce Value Objects
|
1221
|
+
|
1222
|
+
**Current:** Primitive strings for `name`, `version`
|
1223
|
+
|
1224
|
+
**Before:**
|
1225
|
+
```ruby
|
1226
|
+
def initialize(name: "app", gems: [], version: nil, logger: Logger.new($stdout))
|
1227
|
+
@name = name
|
1228
|
+
@gems = gems
|
1229
|
+
@version = version
|
1230
|
+
@logger = logger
|
1231
|
+
end
|
1232
|
+
```
|
1233
|
+
|
1234
|
+
**After:**
|
1235
|
+
```ruby
|
1236
|
+
# lib/create_ruby_app/value_objects/app_name.rb
|
1237
|
+
module CreateRubyApp
|
1238
|
+
class AppName
|
1239
|
+
attr_reader :value
|
1240
|
+
|
1241
|
+
def initialize(value)
|
1242
|
+
@value = normalize(value)
|
1243
|
+
validate!
|
1244
|
+
end
|
1245
|
+
|
1246
|
+
def to_s
|
1247
|
+
value
|
1248
|
+
end
|
1249
|
+
|
1250
|
+
def classify
|
1251
|
+
value.split("_").map(&:capitalize).join
|
1252
|
+
end
|
1253
|
+
|
1254
|
+
private
|
1255
|
+
|
1256
|
+
def normalize(name)
|
1257
|
+
name.to_s.tr("-", "_")
|
1258
|
+
end
|
1259
|
+
|
1260
|
+
def validate!
|
1261
|
+
if value.empty?
|
1262
|
+
raise ArgumentError, "App name cannot be empty"
|
1263
|
+
end
|
1264
|
+
|
1265
|
+
unless value.match?(/\A[a-z_][a-z0-9_]*\z/)
|
1266
|
+
raise ArgumentError,
|
1267
|
+
"App name must be valid Ruby identifier: #{value}"
|
1268
|
+
end
|
1269
|
+
end
|
1270
|
+
end
|
1271
|
+
end
|
1272
|
+
|
1273
|
+
# lib/create_ruby_app/value_objects/ruby_version.rb
|
1274
|
+
module CreateRubyApp
|
1275
|
+
class RubyVersion
|
1276
|
+
attr_reader :implementation, :version
|
1277
|
+
|
1278
|
+
def initialize(version_string)
|
1279
|
+
@implementation, @version = parse(version_string)
|
1280
|
+
validate!
|
1281
|
+
end
|
1282
|
+
|
1283
|
+
def to_s
|
1284
|
+
"#{implementation}-#{version}"
|
1285
|
+
end
|
1286
|
+
|
1287
|
+
private
|
1288
|
+
|
1289
|
+
def parse(version_string)
|
1290
|
+
parts = version_string.to_s.split("-", 2)
|
1291
|
+
[parts[0], parts[1]]
|
1292
|
+
end
|
1293
|
+
|
1294
|
+
def validate!
|
1295
|
+
if implementation.nil? || version.nil?
|
1296
|
+
raise ArgumentError, "Invalid Ruby version format"
|
1297
|
+
end
|
1298
|
+
end
|
1299
|
+
end
|
1300
|
+
end
|
1301
|
+
|
1302
|
+
# Usage in App:
|
1303
|
+
def initialize(
|
1304
|
+
name: "app",
|
1305
|
+
gems: [],
|
1306
|
+
version: nil,
|
1307
|
+
logger: Logger.new($stdout)
|
1308
|
+
)
|
1309
|
+
@name = AppName.new(name)
|
1310
|
+
@gems = gems
|
1311
|
+
@version = version ? RubyVersion.new(version) : nil
|
1312
|
+
@logger = logger
|
1313
|
+
end
|
1314
|
+
```
|
1315
|
+
|
1316
|
+
**Benefit:** Type safety, validation in one place, clearer domain model,
|
1317
|
+
follows Value Object pattern.
|
1318
|
+
|
1319
|
+
---
|
1320
|
+
|
1321
|
+
### 23. Extract Path Management
|
1322
|
+
|
1323
|
+
**Current:** Path strings constructed everywhere
|
1324
|
+
|
1325
|
+
**Before:**
|
1326
|
+
```ruby
|
1327
|
+
# Scattered throughout actions:
|
1328
|
+
Pathname.new("#{app.name}/bin")
|
1329
|
+
"#{app.name}/lib/#{app.name}"
|
1330
|
+
FileUtils.chmod("+x", "#{app.name}/bin/#{app.name}")
|
1331
|
+
```
|
1332
|
+
|
1333
|
+
**After:**
|
1334
|
+
```ruby
|
1335
|
+
# lib/create_ruby_app/project_structure.rb
|
1336
|
+
module CreateRubyApp
|
1337
|
+
class ProjectStructure
|
1338
|
+
attr_reader :project_name
|
1339
|
+
|
1340
|
+
def initialize(project_name)
|
1341
|
+
@project_name = project_name
|
1342
|
+
end
|
1343
|
+
|
1344
|
+
def root
|
1345
|
+
@root ||= Pathname.new(project_name)
|
1346
|
+
end
|
1347
|
+
|
1348
|
+
def bin_dir
|
1349
|
+
root.join("bin")
|
1350
|
+
end
|
1351
|
+
|
1352
|
+
def bin_script
|
1353
|
+
bin_dir.join(project_name)
|
1354
|
+
end
|
1355
|
+
|
1356
|
+
def lib_dir
|
1357
|
+
root.join("lib")
|
1358
|
+
end
|
1359
|
+
|
1360
|
+
def lib_subdir
|
1361
|
+
lib_dir.join(project_name)
|
1362
|
+
end
|
1363
|
+
|
1364
|
+
def lib_main_file
|
1365
|
+
lib_dir.join("#{project_name}.rb")
|
1366
|
+
end
|
1367
|
+
|
1368
|
+
def spec_dir
|
1369
|
+
root.join("spec")
|
1370
|
+
end
|
1371
|
+
|
1372
|
+
def spec_lib_dir
|
1373
|
+
spec_dir.join("lib", project_name)
|
1374
|
+
end
|
1375
|
+
|
1376
|
+
def spec_helper
|
1377
|
+
spec_dir.join("spec_helper.rb")
|
1378
|
+
end
|
1379
|
+
|
1380
|
+
def ruby_version_file
|
1381
|
+
root.join(".ruby-version")
|
1382
|
+
end
|
1383
|
+
|
1384
|
+
def gemfile
|
1385
|
+
root.join("Gemfile")
|
1386
|
+
end
|
1387
|
+
|
1388
|
+
def directories
|
1389
|
+
[bin_dir, lib_subdir, spec_lib_dir]
|
1390
|
+
end
|
1391
|
+
end
|
1392
|
+
end
|
1393
|
+
|
1394
|
+
# Usage in App:
|
1395
|
+
def paths
|
1396
|
+
@paths ||= ProjectStructure.new(name)
|
1397
|
+
end
|
1398
|
+
|
1399
|
+
# Usage in actions:
|
1400
|
+
class CreateDirectories
|
1401
|
+
def self.call(app)
|
1402
|
+
app.paths.directories.each(&FileUtils.method(:mkdir_p))
|
1403
|
+
end
|
1404
|
+
end
|
1405
|
+
```
|
1406
|
+
|
1407
|
+
**Benefit:** DRY, testable, single source of truth, easier to modify
|
1408
|
+
structure.
|
1409
|
+
|
1410
|
+
---
|
1411
|
+
|
1412
|
+
### 24. Consider Result Objects Instead of Exceptions
|
1413
|
+
|
1414
|
+
**Current:** Exceptions for control flow in `SetRubyImplementation`
|
1415
|
+
|
1416
|
+
**Before:**
|
1417
|
+
```ruby
|
1418
|
+
def call
|
1419
|
+
return app.version if app.version
|
1420
|
+
|
1421
|
+
RUBY_IMPLEMENTATIONS.each do |ruby_implementation|
|
1422
|
+
stdout, status = fetch_ruby_implementation(ruby_implementation)
|
1423
|
+
|
1424
|
+
if status.success? && !stdout.empty?
|
1425
|
+
return transform_output_to_ruby_version(stdout)
|
1426
|
+
end
|
1427
|
+
end
|
1428
|
+
|
1429
|
+
raise NoRubyImplementationFoundError,
|
1430
|
+
"No version of Ruby is found or provided"
|
1431
|
+
end
|
1432
|
+
```
|
1433
|
+
|
1434
|
+
**After (using dry-monads):**
|
1435
|
+
```ruby
|
1436
|
+
require "dry/monads"
|
1437
|
+
|
1438
|
+
class SetRubyImplementation
|
1439
|
+
include Dry::Monads[:result]
|
1440
|
+
|
1441
|
+
def self.call(app)
|
1442
|
+
return Success(app.version) if app.version
|
1443
|
+
|
1444
|
+
result = DEFAULT_IMPLEMENTATIONS.lazy.map do |impl|
|
1445
|
+
detect_ruby_implementation(impl)
|
1446
|
+
end.find(&:success?)
|
1447
|
+
|
1448
|
+
result || Failure(
|
1449
|
+
:no_ruby_found,
|
1450
|
+
"No version of Ruby is found or provided"
|
1451
|
+
)
|
1452
|
+
end
|
1453
|
+
|
1454
|
+
def self.detect_ruby_implementation(implementation)
|
1455
|
+
stdout, status = fetch_ruby_implementation(implementation)
|
1456
|
+
|
1457
|
+
if status.success? && !stdout.empty?
|
1458
|
+
Success(transform_output_to_ruby_version(stdout))
|
1459
|
+
else
|
1460
|
+
Failure(:not_found, implementation)
|
1461
|
+
end
|
1462
|
+
end
|
1463
|
+
|
1464
|
+
# ... rest of methods
|
1465
|
+
end
|
1466
|
+
|
1467
|
+
# In App#run!:
|
1468
|
+
result = Actions::SetRubyImplementation.call(self)
|
1469
|
+
|
1470
|
+
result.fmap { |version| @version = version }
|
1471
|
+
.or { |error| raise NoRubyImplementationFoundError, error }
|
1472
|
+
```
|
1473
|
+
|
1474
|
+
**Benefit:** More functional, explicit error handling, better
|
1475
|
+
performance, clearer control flow.
|
1476
|
+
|
1477
|
+
---
|
1478
|
+
|
1479
|
+
## Testing Improvements Needed
|
1480
|
+
|
1481
|
+
### 25. Missing Tests
|
1482
|
+
|
1483
|
+
**Examples of missing test coverage:**
|
1484
|
+
|
1485
|
+
```ruby
|
1486
|
+
# spec/lib/create_ruby_app/app_spec.rb - Add:
|
1487
|
+
RSpec.describe CreateRubyApp::App do
|
1488
|
+
describe "#with_logger" do
|
1489
|
+
it "logs message before executing action" do
|
1490
|
+
logger = instance_double(Logger)
|
1491
|
+
app = described_class.new(
|
1492
|
+
name: "test",
|
1493
|
+
version: "ruby-3.4.0",
|
1494
|
+
logger: logger
|
1495
|
+
)
|
1496
|
+
action = ->(_) {}
|
1497
|
+
|
1498
|
+
expect(logger).to receive(:info).with("Test message").ordered
|
1499
|
+
expect(action).to receive(:call).with(app).ordered
|
1500
|
+
|
1501
|
+
app.send(:with_logger, "Test message", action)
|
1502
|
+
end
|
1503
|
+
end
|
1504
|
+
end
|
1505
|
+
|
1506
|
+
# spec/lib/create_ruby_app/actions/create_directories_spec.rb - Add:
|
1507
|
+
RSpec.describe CreateRubyApp::Actions::CreateDirectories do
|
1508
|
+
describe ".call" do
|
1509
|
+
context "when directory creation fails due to permissions" do
|
1510
|
+
it "raises DirectoryCreationError" do
|
1511
|
+
app = instance_double(
|
1512
|
+
CreateRubyApp::App,
|
1513
|
+
name: "test_app"
|
1514
|
+
)
|
1515
|
+
|
1516
|
+
allow(FileUtils).to receive(:mkdir_p)
|
1517
|
+
.and_raise(Errno::EACCES, "Permission denied")
|
1518
|
+
|
1519
|
+
expect { described_class.call(app) }
|
1520
|
+
.to raise_error(
|
1521
|
+
CreateRubyApp::DirectoryCreationError,
|
1522
|
+
/Permission denied/
|
1523
|
+
)
|
1524
|
+
end
|
1525
|
+
end
|
1526
|
+
end
|
1527
|
+
end
|
1528
|
+
```
|
1529
|
+
|
1530
|
+
### 26. Test Improvements
|
1531
|
+
|
1532
|
+
**Reduce duplication in set_ruby_implementation_spec.rb:**
|
1533
|
+
|
1534
|
+
**Before:**
|
1535
|
+
```ruby
|
1536
|
+
context "when no version if provided, but an `mruby` implementation is found" do
|
1537
|
+
let(:app) do
|
1538
|
+
instance_double(CreateRubyApp::App, name: "mruby_app", version: nil)
|
1539
|
+
end
|
1540
|
+
|
1541
|
+
it "returns the local version" do
|
1542
|
+
%w[ruby truffleruby jruby rubinius].each do |ruby_implementation|
|
1543
|
+
allow(Open3).to receive(:capture3).with(
|
1544
|
+
"#{ruby_implementation} -v"
|
1545
|
+
).and_return(
|
1546
|
+
["", "", instance_double(Process::Status, success?: false)]
|
1547
|
+
)
|
1548
|
+
end
|
1549
|
+
|
1550
|
+
allow(Open3).to receive(:capture3).with("mruby -v").and_return(
|
1551
|
+
["mruby 3.3.0", "", instance_double(Process::Status, success?: true)]
|
1552
|
+
)
|
1553
|
+
|
1554
|
+
# ... more repetitive setup
|
1555
|
+
end
|
1556
|
+
end
|
1557
|
+
```
|
1558
|
+
|
1559
|
+
**After:**
|
1560
|
+
```ruby
|
1561
|
+
RSpec.describe CreateRubyApp::Actions::SetRubyImplementation do
|
1562
|
+
describe ".call" do
|
1563
|
+
let(:app) { instance_double(CreateRubyApp::App, version: nil) }
|
1564
|
+
|
1565
|
+
def stub_ruby_implementations(except:, found_version:)
|
1566
|
+
implementations = %w[ruby truffleruby jruby mruby rubinius]
|
1567
|
+
|
1568
|
+
implementations.each do |impl|
|
1569
|
+
if impl == except
|
1570
|
+
allow(Open3).to receive(:capture3).with("#{impl} -v")
|
1571
|
+
.and_return([
|
1572
|
+
found_version,
|
1573
|
+
"",
|
1574
|
+
instance_double(Process::Status, success?: true)
|
1575
|
+
])
|
1576
|
+
else
|
1577
|
+
allow(Open3).to receive(:capture3).with("#{impl} -v")
|
1578
|
+
.and_return([
|
1579
|
+
"",
|
1580
|
+
"",
|
1581
|
+
instance_double(Process::Status, success?: false)
|
1582
|
+
])
|
1583
|
+
end
|
1584
|
+
end
|
1585
|
+
end
|
1586
|
+
|
1587
|
+
context "when mruby implementation is found" do
|
1588
|
+
it "returns the mruby version" do
|
1589
|
+
stub_ruby_implementations(
|
1590
|
+
except: "mruby",
|
1591
|
+
found_version: "mruby 3.3.0"
|
1592
|
+
)
|
1593
|
+
|
1594
|
+
expect(described_class.call(app)).to eq("mruby-3.3.0")
|
1595
|
+
end
|
1596
|
+
end
|
1597
|
+
|
1598
|
+
context "when jruby implementation is found" do
|
1599
|
+
it "returns the jruby version" do
|
1600
|
+
stub_ruby_implementations(
|
1601
|
+
except: "jruby",
|
1602
|
+
found_version: "jruby 9.4.14.0"
|
1603
|
+
)
|
1604
|
+
|
1605
|
+
expect(described_class.call(app)).to eq("jruby-9.4.14.0")
|
1606
|
+
end
|
1607
|
+
end
|
1608
|
+
end
|
1609
|
+
end
|
1610
|
+
```
|
1611
|
+
|
1612
|
+
---
|
1613
|
+
|
1614
|
+
## Priority Ranking (High Impact, Low Effort First)
|
1615
|
+
|
1616
|
+
1. **Add logger gem to gemspec** (High impact, 1 line, immediate
|
1617
|
+
benefit)
|
1618
|
+
2. **Fix system call error handling in InstallGems** (High impact, 3
|
1619
|
+
lines, prevents silent failures)
|
1620
|
+
3. **Remove empty CreateRubyApp::CreateRubyApp class** (Low effort, 2
|
1621
|
+
lines, clean code)
|
1622
|
+
4. **Extract gem formatting logic in GenerateFiles** (Medium effort,
|
1623
|
+
improves readability significantly)
|
1624
|
+
5. **Make SetRubyImplementation pure** (High impact, medium effort,
|
1625
|
+
core FP principle)
|
1626
|
+
6. **Extract path management methods to App** (Medium effort, high DRY
|
1627
|
+
improvement)
|
1628
|
+
7. **Standardize action interface** (Medium effort, reduces boilerplate
|
1629
|
+
significantly)
|
1630
|
+
8. **Add missing error handling and custom exceptions** (High impact,
|
1631
|
+
prevents silent failures)
|
1632
|
+
9. **Extract action pipeline pattern** (Higher effort, major
|
1633
|
+
architectural improvement)
|
1634
|
+
10. **Increase test coverage to 90%** (Higher effort, but critical for
|
1635
|
+
confidence)
|
1636
|
+
|
1637
|
+
---
|
1638
|
+
|
1639
|
+
## Summary
|
1640
|
+
|
1641
|
+
The codebase is solid but has room for improvement in:
|
1642
|
+
|
1643
|
+
- **Immutability:** Reduce mutations, make functions pure
|
1644
|
+
- **SOLID:** Better separation of concerns, dependency inversion,
|
1645
|
+
Open/Closed
|
1646
|
+
- **DRY:** Extract repeated path logic and patterns
|
1647
|
+
- **Error Handling:** Consistent, comprehensive error handling
|
1648
|
+
- **Testability:** Improve coverage and reduce coupling for easier
|
1649
|
+
testing
|
1650
|
+
- **Functional Programming:** More pure functions, less side effects
|
1651
|
+
|
1652
|
+
**Total Issues Identified:** 26
|
1653
|
+
**Estimated Effort to Address All:** Medium (2-3 days of focused work)
|
1654
|
+
**Risk Level of Changes:** Low to Medium (good test coverage enables
|
1655
|
+
safe refactoring)
|
1656
|
+
|
1657
|
+
The improvements are incremental and can be tackled independently,
|
1658
|
+
making this a manageable refactoring effort with significant quality
|
1659
|
+
gains.
|