appydave-tools 0.37.0 → 0.38.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.
@@ -0,0 +1,712 @@
1
+ # Code Quality Implementation Plan
2
+
3
+ **Based on:** [report-2025-01-21.md](./report-2025-01-21.md)
4
+
5
+ ## Overview
6
+
7
+ This document outlines the step-by-step implementation plan for addressing code quality issues identified in the Jan 21, 2025 retrospective analysis.
8
+
9
+ ## Implementation Strategy
10
+
11
+ ### Phase 1: Quick Wins (1-2 days)
12
+ Extract duplicated utility code and establish patterns.
13
+
14
+ ### Phase 2: Architectural Improvements (2-3 days)
15
+ Address core architectural issues (brand resolution, git helpers).
16
+
17
+ ### Phase 3: Test Improvements (1 day)
18
+ Refactor tests to use consistent patterns.
19
+
20
+ ### Phase 4: Documentation & Cleanup (1 day)
21
+ Document patterns and create guidelines.
22
+
23
+ ---
24
+
25
+ ## Phase 1: Quick Wins (Priority 1)
26
+
27
+ ### Task 1.1: Extract FileUtils Module
28
+ **Effort:** 1 hour | **Priority:** Medium | **Risk:** Low
29
+
30
+ **Goal:** Consolidate directory size calculation into single module.
31
+
32
+ **Steps:**
33
+ 1. Create `lib/appydave/tools/dam/file_utils.rb`
34
+ 2. Extract methods:
35
+ - `calculate_directory_size(path)` - from project_listing.rb:154, s3_operations.rb:770
36
+ - `format_size(bytes)` - from project_listing.rb:176, sync_from_ssd.rb:270
37
+ 3. Update callers:
38
+ - `lib/appydave/tools/dam/project_listing.rb` (2 locations)
39
+ - `lib/appydave/tools/dam/s3_operations.rb` (1 location)
40
+ - `lib/appydave/tools/dam/sync_from_ssd.rb` (1 location)
41
+ 4. Write specs: `spec/appydave/tools/dam/file_utils_spec.rb`
42
+ 5. Run tests: `bundle exec rspec spec/appydave/tools/dam/`
43
+
44
+ **Implementation Example:**
45
+ ```ruby
46
+ # lib/appydave/tools/dam/file_utils.rb
47
+ module Appydave
48
+ module Tools
49
+ module Dam
50
+ module FileUtils
51
+ module_function
52
+
53
+ # Calculate total size of directory in bytes
54
+ # @param path [String] Directory path
55
+ # @return [Integer] Size in bytes
56
+ def calculate_directory_size(path)
57
+ return 0 unless Dir.exist?(path)
58
+
59
+ total = 0
60
+ Find.find(path) do |file_path|
61
+ total += File.size(file_path) if File.file?(file_path)
62
+ rescue StandardError
63
+ # Skip files we can't read
64
+ end
65
+ total
66
+ end
67
+
68
+ # Format bytes into human-readable size
69
+ # @param bytes [Integer] Size in bytes
70
+ # @return [String] Formatted size (e.g., "1.5 GB")
71
+ def format_size(bytes)
72
+ return '0 B' if bytes.zero?
73
+
74
+ units = %w[B KB MB GB TB]
75
+ exp = (Math.log(bytes) / Math.log(1024)).to_i
76
+ exp = [exp, units.length - 1].min
77
+
78
+ format('%.1f %s', bytes.to_f / (1024**exp), units[exp])
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
84
+ ```
85
+
86
+ **Testing Strategy:**
87
+ ```ruby
88
+ RSpec.describe Appydave::Tools::Dam::FileUtils do
89
+ describe '.calculate_directory_size' do
90
+ it 'returns 0 for non-existent directory'
91
+ it 'calculates size of directory with files'
92
+ it 'handles permission errors gracefully'
93
+ end
94
+
95
+ describe '.format_size' do
96
+ it 'formats bytes correctly' do
97
+ expect(described_class.format_size(0)).to eq('0 B')
98
+ expect(described_class.format_size(1024)).to eq('1.0 KB')
99
+ expect(described_class.format_size(1_048_576)).to eq('1.0 MB')
100
+ end
101
+ end
102
+ end
103
+ ```
104
+
105
+ **Success Criteria:**
106
+ - [ ] All tests pass
107
+ - [ ] 4 files updated to use new module
108
+ - [ ] ~40 lines of duplication eliminated
109
+
110
+ ---
111
+
112
+ ### Task 1.2: Define DAM Exception Hierarchy
113
+ **Effort:** 2 hours | **Priority:** Medium | **Risk:** Low
114
+
115
+ **Goal:** Create consistent error handling pattern for DAM module.
116
+
117
+ **Steps:**
118
+ 1. Create `lib/appydave/tools/dam/errors.rb`
119
+ 2. Define exception classes
120
+ 3. Update error raises in:
121
+ - `project_resolver.rb:19` (string raise → ProjectNotFoundError)
122
+ - `config.rb:40` (string raise → BrandNotFoundError)
123
+ - Other locations using string raises
124
+ 4. Write specs
125
+ 5. Update CLAUDE.md with error handling guidelines
126
+
127
+ **Implementation:**
128
+ ```ruby
129
+ # lib/appydave/tools/dam/errors.rb
130
+ module Appydave
131
+ module Tools
132
+ module Dam
133
+ # Base error for all DAM operations
134
+ class DamError < StandardError; end
135
+
136
+ # Raised when brand directory not found
137
+ class BrandNotFoundError < DamError
138
+ def initialize(brand, available_brands = [])
139
+ message = "Brand directory not found: #{brand}"
140
+ if available_brands.any?
141
+ message += "\n\nAvailable brands:\n#{available_brands.join("\n")}"
142
+ end
143
+ super(message)
144
+ end
145
+ end
146
+
147
+ # Raised when project not found in brand
148
+ class ProjectNotFoundError < DamError; end
149
+
150
+ # Raised when configuration invalid or missing
151
+ class ConfigurationError < DamError; end
152
+
153
+ # Raised when S3 operation fails
154
+ class S3OperationError < DamError; end
155
+
156
+ # Raised when git operation fails
157
+ class GitOperationError < DamError; end
158
+ end
159
+ end
160
+ end
161
+ ```
162
+
163
+ **Migration Example:**
164
+ ```ruby
165
+ # BEFORE (project_resolver.rb:19)
166
+ raise '❌ Project name is required' if project_hint.nil? || project_hint.empty?
167
+
168
+ # AFTER
169
+ raise ProjectNotFoundError, 'Project name is required' if project_hint.nil? || project_hint.empty?
170
+ ```
171
+
172
+ **Success Criteria:**
173
+ - [ ] All string raises replaced with typed exceptions
174
+ - [ ] CLI error handling in bin/dam still works
175
+ - [ ] Error messages remain user-friendly
176
+
177
+ ---
178
+
179
+ ## Phase 2: Architectural Improvements (Priority 2)
180
+
181
+ ### Task 2.1: Extract GitHelper Module
182
+ **Effort:** 3 hours | **Priority:** High | **Risk:** Medium
183
+
184
+ **Goal:** Eliminate 90 lines of git command duplication.
185
+
186
+ **Steps:**
187
+ 1. Create `lib/appydave/tools/dam/git_helper.rb`
188
+ 2. Extract git methods from:
189
+ - `status.rb:243-275` (4 methods)
190
+ - `repo_status.rb:109-144` (6 methods)
191
+ - `repo_push.rb:107-119` (3 methods)
192
+ 3. Make module methods accept `repo_path` parameter
193
+ 4. Include module in classes
194
+ 5. Update all callers
195
+ 6. Write comprehensive specs
196
+ 7. Run full test suite
197
+
198
+ **Implementation:**
199
+ ```ruby
200
+ # lib/appydave/tools/dam/git_helper.rb
201
+ module Appydave
202
+ module Tools
203
+ module Dam
204
+ # Git operations helper for DAM classes
205
+ # Provides reusable git command wrappers
206
+ module GitHelper
207
+ module_function
208
+
209
+ # Get current branch name
210
+ # @param repo_path [String] Path to git repository
211
+ # @return [String] Branch name or 'unknown' if error
212
+ def current_branch(repo_path)
213
+ `git -C "#{repo_path}" rev-parse --abbrev-ref HEAD 2>/dev/null`.strip
214
+ rescue StandardError
215
+ 'unknown'
216
+ end
217
+
218
+ # Get git remote URL
219
+ # @param repo_path [String] Path to git repository
220
+ # @return [String, nil] Remote URL or nil if not configured
221
+ def remote_url(repo_path)
222
+ result = `git -C "#{repo_path}" remote get-url origin 2>/dev/null`.strip
223
+ result.empty? ? nil : result
224
+ rescue StandardError
225
+ nil
226
+ end
227
+
228
+ # Count commits ahead of remote
229
+ # @param repo_path [String] Path to git repository
230
+ # @return [Integer] Number of commits ahead
231
+ def commits_ahead(repo_path)
232
+ `git -C "#{repo_path}" rev-list --count @{upstream}..HEAD 2>/dev/null`.strip.to_i
233
+ rescue StandardError
234
+ 0
235
+ end
236
+
237
+ # Count commits behind remote
238
+ # @param repo_path [String] Path to git repository
239
+ # @return [Integer] Number of commits behind
240
+ def commits_behind(repo_path)
241
+ `git -C "#{repo_path}" rev-list --count HEAD..@{upstream} 2>/dev/null`.strip.to_i
242
+ rescue StandardError
243
+ 0
244
+ end
245
+
246
+ # Count modified files
247
+ # @param repo_path [String] Path to git repository
248
+ # @return [Integer] Number of modified files
249
+ def modified_files_count(repo_path)
250
+ `git -C "#{repo_path}" status --porcelain 2>/dev/null | grep -E "^.M|^M" | wc -l`.strip.to_i
251
+ rescue StandardError
252
+ 0
253
+ end
254
+
255
+ # Count untracked files
256
+ # @param repo_path [String] Path to git repository
257
+ # @return [Integer] Number of untracked files
258
+ def untracked_files_count(repo_path)
259
+ `git -C "#{repo_path}" status --porcelain 2>/dev/null | grep -E "^\\?\\?" | wc -l`.strip.to_i
260
+ rescue StandardError
261
+ 0
262
+ end
263
+
264
+ # Check if repository has uncommitted changes
265
+ # @param repo_path [String] Path to git repository
266
+ # @return [Boolean] true if changes exist
267
+ def uncommitted_changes?(repo_path)
268
+ system("git -C \"#{repo_path}\" diff-index --quiet HEAD -- 2>/dev/null")
269
+ !$CHILD_STATUS.success?
270
+ rescue StandardError
271
+ false
272
+ end
273
+
274
+ # Fetch from remote
275
+ # @param repo_path [String] Path to git repository
276
+ # @return [Boolean] true if successful
277
+ def fetch(repo_path)
278
+ system("git -C \"#{repo_path}\" fetch 2>/dev/null")
279
+ $CHILD_STATUS.success?
280
+ rescue StandardError
281
+ false
282
+ end
283
+ end
284
+ end
285
+ end
286
+ end
287
+ ```
288
+
289
+ **Migration Example:**
290
+ ```ruby
291
+ # BEFORE (status.rb)
292
+ class Status
293
+ def current_branch
294
+ `git -C "#{brand_path}" rev-parse --abbrev-ref HEAD 2>/dev/null`.strip
295
+ end
296
+ end
297
+
298
+ # AFTER (status.rb)
299
+ class Status
300
+ include GitHelper
301
+
302
+ def current_branch
303
+ GitHelper.current_branch(brand_path)
304
+ end
305
+ end
306
+ ```
307
+
308
+ **Testing Strategy:**
309
+ ```ruby
310
+ RSpec.describe Appydave::Tools::Dam::GitHelper do
311
+ let(:temp_repo) { Dir.mktmpdir }
312
+
313
+ before do
314
+ # Initialize git repo for testing
315
+ system("git init #{temp_repo} 2>/dev/null")
316
+ end
317
+
318
+ after { FileUtils.rm_rf(temp_repo) }
319
+
320
+ describe '.current_branch' do
321
+ it 'returns branch name'
322
+ it 'handles non-git directory gracefully'
323
+ end
324
+
325
+ # ... similar for other methods
326
+ end
327
+ ```
328
+
329
+ **Success Criteria:**
330
+ - [ ] All 3 classes using GitHelper
331
+ - [ ] All tests pass
332
+ - [ ] ~90 lines of duplication eliminated
333
+ - [ ] Git commands centralized
334
+
335
+ ---
336
+
337
+ ### Task 2.2: Create BrandResolver Class
338
+ **Effort:** 6-8 hours | **Priority:** High | **Risk:** High
339
+
340
+ **Goal:** Centralize brand name transformation logic to prevent ongoing bugs.
341
+
342
+ **Steps:**
343
+ 1. Create `lib/appydave/tools/dam/brand_resolver.rb`
344
+ 2. Move logic from:
345
+ - `config.rb:91-116` (expand_brand)
346
+ - `project_resolver.rb:118-121` (strip v- prefix)
347
+ 3. Define clear API:
348
+ - `expand(shortcut)` - appydave → v-appydave
349
+ - `normalize(brand)` - v-appydave → appydave
350
+ - `validate(brand)` - raises if invalid
351
+ - `to_config_key(brand)` - always key form (no v-)
352
+ - `to_display(brand)` - always display form (v-)
353
+ 4. Update all callers (10+ files)
354
+ 5. Write comprehensive specs
355
+ 6. Document API in class comments
356
+
357
+ **Implementation:**
358
+ ```ruby
359
+ # lib/appydave/tools/dam/brand_resolver.rb
360
+ module Appydave
361
+ module Tools
362
+ module Dam
363
+ # Centralized brand name resolution and transformation
364
+ #
365
+ # Handles conversion between:
366
+ # - Shortcuts: 'appydave', 'ad', 'joy', 'ss'
367
+ # - Config keys: 'appydave', 'beauty-and-joy', 'supportsignal'
368
+ # - Display names: 'v-appydave', 'v-beauty-and-joy', 'v-supportsignal'
369
+ #
370
+ # @example
371
+ # BrandResolver.expand('ad') # => 'v-appydave'
372
+ # BrandResolver.normalize('v-voz') # => 'voz'
373
+ # BrandResolver.to_config_key('ad') # => 'appydave'
374
+ # BrandResolver.to_display('voz') # => 'v-voz'
375
+ class BrandResolver
376
+ class << self
377
+ # Expand shortcut or key to full display name
378
+ # @param shortcut [String] Brand shortcut or key
379
+ # @return [String] Full brand name with v- prefix
380
+ def expand(shortcut)
381
+ return shortcut if shortcut.to_s.start_with?('v-')
382
+
383
+ key = to_config_key(shortcut)
384
+ "v-#{key}"
385
+ end
386
+
387
+ # Normalize brand name to config key (strip v- prefix)
388
+ # @param brand [String] Brand name (with or without v-)
389
+ # @return [String] Config key without v- prefix
390
+ def normalize(brand)
391
+ brand.to_s.sub(/^v-/, '')
392
+ end
393
+
394
+ # Convert to config key (handles shortcuts)
395
+ # @param input [String] Shortcut, key, or display name
396
+ # @return [String] Config key
397
+ def to_config_key(input)
398
+ # Strip v- prefix first
399
+ normalized = normalize(input)
400
+
401
+ # Look up from brands.json
402
+ Appydave::Tools::Configuration::Config.configure
403
+ brands_config = Appydave::Tools::Configuration::Config.brands
404
+
405
+ # Check if matches brand key
406
+ brand = brands_config.brands.find { |b| b.key.downcase == normalized.downcase }
407
+ return brand.key if brand
408
+
409
+ # Check if matches shortcut
410
+ brand = brands_config.brands.find { |b| b.shortcut.downcase == normalized.downcase }
411
+ return brand.key if brand
412
+
413
+ # Fall back to hardcoded shortcuts (backward compatibility)
414
+ case normalized.downcase
415
+ when 'joy' then 'beauty-and-joy'
416
+ when 'ss' then 'supportsignal'
417
+ else
418
+ normalized
419
+ end
420
+ end
421
+
422
+ # Convert to display name (always v- prefix)
423
+ # @param input [String] Shortcut, key, or display name
424
+ # @return [String] Display name with v- prefix
425
+ def to_display(input)
426
+ expand(input)
427
+ end
428
+
429
+ # Validate brand exists
430
+ # @param brand [String] Brand to validate
431
+ # @raise [BrandNotFoundError] if brand invalid
432
+ # @return [String] Config key if valid
433
+ def validate(brand)
434
+ key = to_config_key(brand)
435
+ brand_path = Config.brand_path(key)
436
+
437
+ unless Dir.exist?(brand_path)
438
+ available = Config.available_brands_display
439
+ raise BrandNotFoundError.new(brand, available)
440
+ end
441
+
442
+ key
443
+ rescue StandardError => e
444
+ raise BrandNotFoundError, e.message
445
+ end
446
+ end
447
+ end
448
+ end
449
+ end
450
+ end
451
+ ```
452
+
453
+ **Migration Plan:**
454
+ ```ruby
455
+ # PHASE 1: Create BrandResolver with tests
456
+ # PHASE 2: Update Config class to use BrandResolver
457
+ # PHASE 3: Update ProjectResolver to use BrandResolver
458
+ # PHASE 4: Update CLI (bin/dam) to use BrandResolver
459
+ # PHASE 5: Update remaining callers
460
+ # PHASE 6: Remove old methods from Config (mark deprecated first)
461
+ ```
462
+
463
+ **Testing Strategy:**
464
+ ```ruby
465
+ RSpec.describe Appydave::Tools::Dam::BrandResolver do
466
+ include_context 'with vat filesystem and brands', brands: %w[appydave voz]
467
+
468
+ describe '.expand' do
469
+ it 'expands shortcut to display name' do
470
+ expect(described_class.expand('appydave')).to eq('v-appydave')
471
+ expect(described_class.expand('ad')).to eq('v-appydave')
472
+ end
473
+
474
+ it 'leaves display names unchanged' do
475
+ expect(described_class.expand('v-appydave')).to eq('v-appydave')
476
+ end
477
+ end
478
+
479
+ describe '.normalize' do
480
+ it 'strips v- prefix' do
481
+ expect(described_class.normalize('v-appydave')).to eq('appydave')
482
+ end
483
+
484
+ it 'leaves normalized names unchanged' do
485
+ expect(described_class.normalize('appydave')).to eq('appydave')
486
+ end
487
+ end
488
+
489
+ describe '.to_config_key' do
490
+ it 'converts shortcuts to config keys' do
491
+ expect(described_class.to_config_key('ad')).to eq('appydave')
492
+ expect(described_class.to_config_key('joy')).to eq('beauty-and-joy')
493
+ expect(described_class.to_config_key('ss')).to eq('supportsignal')
494
+ end
495
+
496
+ it 'handles display names' do
497
+ expect(described_class.to_config_key('v-appydave')).to eq('appydave')
498
+ end
499
+ end
500
+
501
+ describe '.validate' do
502
+ it 'validates existing brand' do
503
+ expect { described_class.validate('appydave') }.not_to raise_error
504
+ end
505
+
506
+ it 'raises for invalid brand' do
507
+ expect { described_class.validate('invalid') }.to raise_error(BrandNotFoundError)
508
+ end
509
+ end
510
+ end
511
+ ```
512
+
513
+ **Success Criteria:**
514
+ - [ ] All brand transformation logic centralized
515
+ - [ ] Clear API with documented responsibilities
516
+ - [ ] All callers updated (10+ files)
517
+ - [ ] All tests pass
518
+ - [ ] No regression in brand resolution
519
+
520
+ **Rollback Plan:**
521
+ If issues arise, keep old methods and mark BrandResolver as experimental. Gradually migrate one file at a time.
522
+
523
+ ---
524
+
525
+ ## Phase 3: Test Improvements (Priority 3)
526
+
527
+ ### Task 3.1: Refactor ProjectListing Specs
528
+ **Effort:** 4 hours | **Priority:** Medium | **Risk:** Low
529
+
530
+ **Goal:** Standardize test patterns using shared filesystem contexts.
531
+
532
+ **Steps:**
533
+ 1. Analyze `spec/appydave/tools/dam/project_listing_spec.rb`
534
+ 2. Identify all Config mocks (20 lines)
535
+ 3. Replace with `include_context 'with vat filesystem and brands'`
536
+ 4. Update test expectations
537
+ 5. Verify all tests pass
538
+ 6. Document pattern in spec/support/README.md
539
+
540
+ **Before:**
541
+ ```ruby
542
+ # Heavy mocking
543
+ allow(Appydave::Tools::Dam::Config).to receive_messages(projects_root: temp_root)
544
+ allow(Appydave::Tools::Dam::Config).to receive(:brand_path).with('appydave').and_return(brand1_path)
545
+ # ... 18 more allow() statements
546
+ ```
547
+
548
+ **After:**
549
+ ```ruby
550
+ include_context 'with vat filesystem and brands', brands: %w[appydave voz]
551
+
552
+ before do
553
+ # Create real test projects
554
+ FileUtils.mkdir_p(File.join(appydave_path, 'b65-test'))
555
+ FileUtils.mkdir_p(File.join(voz_path, 'boy-baker'))
556
+ end
557
+ ```
558
+
559
+ **Success Criteria:**
560
+ - [ ] Mock count reduced from 20 to <5
561
+ - [ ] All tests pass
562
+ - [ ] Test setup clearer and more maintainable
563
+
564
+ ---
565
+
566
+ ## Phase 4: Documentation & Cleanup (Priority 4)
567
+
568
+ ### Task 4.1: Document Configuration Loading
569
+ **Effort:** 1 hour | **Priority:** Low | **Risk:** None
570
+
571
+ **Goal:** Add inline documentation explaining Config.configure memoization.
572
+
573
+ **Steps:**
574
+ 1. Add module-level comment to `lib/appydave/tools/configuration/config.rb`
575
+ 2. Document `configure` method behavior
576
+ 3. Add examples to CLAUDE.md
577
+
578
+ **Implementation:**
579
+ ```ruby
580
+ # lib/appydave/tools/configuration/config.rb
581
+ module Appydave
582
+ module Tools
583
+ module Configuration
584
+ # Central configuration management for appydave-tools
585
+ #
586
+ # Thread-safe singleton pattern with memoization.
587
+ # Calling `Config.configure` multiple times is safe and idempotent.
588
+ #
589
+ # @example Basic usage
590
+ # Config.configure # Load config (idempotent)
591
+ # Config.settings.video_projects_root
592
+ # Config.brands.get_brand('appydave')
593
+ #
594
+ # @example DAM module usage
595
+ # # Config.configure called once at module load
596
+ # # All subsequent calls are no-ops (memoized)
597
+ class Config
598
+ class << self
599
+ # Load configuration from JSON files
600
+ # Safe to call multiple times (idempotent/memoized)
601
+ # @return [void]
602
+ def configure
603
+ # Implementation uses @@configured flag for memoization
604
+ # ...
605
+ end
606
+ end
607
+ end
608
+ end
609
+ end
610
+ end
611
+ ```
612
+
613
+ ---
614
+
615
+ ## Implementation Timeline
616
+
617
+ ### Week 1 (Days 1-2): Quick Wins
618
+ - **Day 1 Morning:** Task 1.1 - Extract FileUtils
619
+ - **Day 1 Afternoon:** Task 1.2 - Define exception hierarchy
620
+ - **Day 2:** Review and test Phase 1 changes
621
+
622
+ ### Week 2 (Days 3-5): Architectural Improvements
623
+ - **Day 3-4:** Task 2.1 - Extract GitHelper (test thoroughly)
624
+ - **Day 5:** Task 2.2 - Start BrandResolver
625
+
626
+ ### Week 3 (Days 6-8): BrandResolver & Tests
627
+ - **Day 6-7:** Task 2.2 - Complete BrandResolver migration
628
+ - **Day 8:** Task 3.1 - Refactor specs
629
+
630
+ ### Week 4 (Day 9): Documentation
631
+ - **Day 9:** Task 4.1 - Documentation and final review
632
+
633
+ ---
634
+
635
+ ## Risk Management
636
+
637
+ ### High-Risk Tasks
638
+ 1. **BrandResolver refactor** - Touches many files, complex logic
639
+ - Mitigation: Implement gradually, one file at a time
640
+ - Keep old methods during transition
641
+ - Extensive testing at each step
642
+
643
+ 2. **GitHelper extraction** - Changes behavior of 3 classes
644
+ - Mitigation: Write tests first
645
+ - Test each class individually after migration
646
+ - Run full test suite after each change
647
+
648
+ ### Medium-Risk Tasks
649
+ 1. **Test refactoring** - Could break existing tests
650
+ - Mitigation: One test file at a time
651
+ - Keep git commits small for easy rollback
652
+
653
+ ### Low-Risk Tasks
654
+ 1. **FileUtils extraction** - Pure utility code
655
+ 2. **Exception hierarchy** - Additive changes
656
+ 3. **Documentation** - No code changes
657
+
658
+ ---
659
+
660
+ ## Success Metrics
661
+
662
+ ### Code Quality Metrics (Before → After)
663
+ - **Duplicated lines:** 150 → <30 (80% reduction)
664
+ - **Mock density in tests:** 8% → <3%
665
+ - **Files with brand logic:** 5 → 1 (BrandResolver)
666
+ - **Test coverage:** Maintain >90%
667
+
668
+ ### Behavioral Metrics
669
+ - [ ] All existing tests pass
670
+ - [ ] No regression in CLI behavior
671
+ - [ ] No breaking changes to public API
672
+ - [ ] Gem version: Minor bump (backward compatible)
673
+
674
+ ---
675
+
676
+ ## Commit Strategy
677
+
678
+ Use semantic commit messages for automated versioning:
679
+
680
+ ```bash
681
+ # Phase 1
682
+ kfeat "extract FileUtils module for directory size calculations"
683
+ kfeat "add DAM exception hierarchy for consistent error handling"
684
+
685
+ # Phase 2
686
+ kfeat "extract GitHelper module to eliminate 90 lines of duplication"
687
+ kfeat "create BrandResolver to centralize brand transformation logic"
688
+
689
+ # Phase 3
690
+ kfix "refactor project_listing_spec to use shared filesystem contexts"
691
+
692
+ # Phase 4
693
+ kdocs "add configuration loading documentation and memoization notes"
694
+ ```
695
+
696
+ ---
697
+
698
+ ## Getting Help
699
+
700
+ If you encounter issues:
701
+ 1. **Check report:** [report-2025-01-21.md](./report-2025-01-21.md)
702
+ 2. **Run tests:** `bundle exec rspec`
703
+ 3. **Verify changes:** `git diff`
704
+ 4. **Rollback:** `git reset --hard HEAD`
705
+
706
+ **Need clarification?** Ask before implementing high-risk changes.
707
+
708
+ ---
709
+
710
+ **Plan Created:** 2025-01-21
711
+ **Estimated Total Effort:** 18-24 hours across 2 weeks
712
+ **Risk Level:** Medium (manageable with phased approach)