appydave-tools 0.76.6 → 0.76.8

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,653 @@
1
+ # AGENTS.md — AppyDave Tools / library-boundary-cleanup campaign
2
+
3
+ > Operational knowledge for every background agent. Self-contained — you receive only this file + your work unit prompt.
4
+ > Inherited from: extract-vat-cli campaign (2026-03-19)
5
+ > Updated for: library-boundary-cleanup campaign (2026-03-19)
6
+
7
+ ---
8
+
9
+ ## Project Overview
10
+
11
+ **What:** Ruby gem providing CLI productivity tools for AppyDave's YouTube content creation workflow.
12
+ **Stack:** Ruby 3.4.2, Bundler 2.6.2, RSpec, RuboCop, semantic-release CI/CD.
13
+ **Active campaign:** library-boundary-cleanup — fixing two boundary violations left by extract-vat-cli:
14
+ 1. `exit 1` calls inside library code (S3ScanCommand + S3ArgParser)
15
+ 2. `ENV['BRAND_PATH']` side-effect inside library argument parser (S3ArgParser)
16
+ **Commits:** Trigger automated semantic versioning via GitHub Actions. Always use `kfeat`/`kfix` — never `git commit`.
17
+
18
+ ---
19
+
20
+ ## ⚠️ Pre-Commit Check (Mandatory Every Commit)
21
+
22
+ Before running `kfix`, always run:
23
+ ```bash
24
+ git status
25
+ ```
26
+ Confirm ONLY the files you intentionally changed are staged. If unexpected files appear, run `git diff` to investigate before proceeding. Never commit files you didn't intentionally change.
27
+
28
+ **Why:** Prior campaign accidentally staged a pre-existing uncommitted change when running `kfix`. Required a follow-up fix commit.
29
+
30
+ ---
31
+
32
+ ## Build & Run Commands
33
+
34
+ ```bash
35
+ # Initialize rbenv (required if rbenv not in PATH)
36
+ eval "$(rbenv init -)"
37
+
38
+ # Run tests
39
+ bundle exec rspec # All tests
40
+ bundle exec rspec spec/path/to/file_spec.rb # Single file
41
+ RUBYOPT="-W0" bundle exec rspec # Suppress Ruby 3.4 platform warnings
42
+
43
+ # Lint
44
+ bundle exec rubocop --format clang # Standard lint check (matches CI)
45
+
46
+ # Commit (never use git commit directly)
47
+ kfeat "add feature description" # Minor version bump
48
+ kfix "fix bug description" # Patch version bump
49
+ ```
50
+
51
+ **Baseline (start of library-boundary-cleanup):** 847 examples, 0 failures, ~85.92% line coverage
52
+
53
+ ---
54
+
55
+ ## Directory Structure
56
+
57
+ ```
58
+ bin/
59
+ dam Main DAM CLI entry point (VatCLI class — 1,600 lines, being reduced)
60
+ exe/ Thin wrappers for gem installation (no .rb extension)
61
+ lib/appydave/tools/
62
+ dam/ Digital Asset Management — main active area
63
+ brand_resolver.rb Centralizes ALL brand name transformations (appydave ↔ v-appydave)
64
+ errors.rb Custom exception hierarchy — ADD UsageError here in B034
65
+ fuzzy_matcher.rb Levenshtein distance for "did you mean?" suggestions
66
+ git_helper.rb Extracted git command wrappers
67
+ file_helper.rb File utility methods (format_size, calculate_directory_size, format_age)
68
+ config.rb Delegates brand resolution to BrandResolver; memoized Config loading
69
+ project_resolver.rb Project name resolution with regex pattern matching
70
+ project_listing.rb Table display for `dam list` command
71
+ s3_operations.rb S3 upload/download/status with MD5 comparison
72
+ s3_scanner.rb S3 bucket scanner for s3-scan command
73
+ status.rb Project git/S3 status display
74
+ manifest_generator.rb Video project manifest
75
+ sync_from_ssd.rb SSD sync operations
76
+ ssd_status.rb SSD backup status
77
+ share_operations.rb Pre-signed URL generation
78
+ config_loader.rb Loads .video-tools.env per brand
79
+ local_sync_status.rb Enriches project data with local s3-staging sync status
80
+ s3_scan_command.rb S3 scan orchestration + display — HAS exit 1 AT LINE 55 (B034 target)
81
+ s3_arg_parser.rb CLI argument parsing for S3 commands — HAS exit 1 + ENV side-effects (B034/B035 targets)
82
+ lib/appydave/tools.rb Require file — already includes s3_scan_command and s3_arg_parser
83
+ spec/
84
+ appydave/tools/dam/ One spec file per dam/ class
85
+ support/
86
+ dam_filesystem_helpers.rb Shared contexts for DAM filesystem testing
87
+ ```
88
+
89
+ ---
90
+
91
+ ## This Campaign: What We're Fixing
92
+
93
+ ### The Two Problems
94
+
95
+ **Problem 1: `exit 1` in library code** — Library classes must not terminate the process. VatCLI's `rescue StandardError` already catches and prints errors. Fix: replace `exit 1` with typed exceptions.
96
+
97
+ **Problem 2: `ENV['BRAND_PATH']` side-effect in library** — `S3ArgParser` sets a process-wide env var during argument parsing. Unsafe for parallelism. Fix: return `brand_path:` in result hash; move the `ENV[]=` call to VatCLI.
98
+
99
+ ### Work Unit Dependencies
100
+
101
+ ```
102
+ B034: extract-exit-calls (no deps — do first)
103
+ B035: extract-env-side-effect (DEPENDS ON B034 — both touch s3_arg_parser.rb)
104
+ B036: tests-s3-scan-command (DEPENDS ON B034 — can't test exceptions until they exist)
105
+ B037: tests-local-sync-status (no deps — but run after B035 for clean baseline)
106
+
107
+ Wave 1: B034
108
+ Wave 2: B035
109
+ Wave 3: B036 + B037 (parallel — different spec files)
110
+ ```
111
+
112
+ ---
113
+
114
+ ## B034: extract-exit-calls
115
+
116
+ **Files touched:** `lib/appydave/tools/dam/errors.rb`, `lib/appydave/tools/dam/s3_scan_command.rb`, `lib/appydave/tools/dam/s3_arg_parser.rb`
117
+
118
+ ### Step 1 — Add UsageError to errors.rb
119
+
120
+ After the existing `GitOperationError` line, add:
121
+
122
+ ```ruby
123
+ # Raised when CLI arguments are invalid or missing
124
+ class UsageError < DamError; end
125
+ ```
126
+
127
+ ### Step 2 — Fix S3ScanCommand (line 55)
128
+
129
+ Replace:
130
+ ```ruby
131
+ unless File.exist?(manifest_path)
132
+ puts "❌ Manifest not found: #{manifest_path}"
133
+ puts " Run: dam manifest #{brand_key}"
134
+ puts " Then retry: dam s3-scan #{brand_key}"
135
+ exit 1
136
+ end
137
+ ```
138
+
139
+ With:
140
+ ```ruby
141
+ unless File.exist?(manifest_path)
142
+ raise Appydave::Tools::Dam::ConfigurationError,
143
+ "Manifest not found: #{manifest_path}. Run: dam manifest #{brand_key}"
144
+ end
145
+ ```
146
+
147
+ Remove the `puts` lines above — the exception message carries the information. VatCLI's rescue prints it.
148
+
149
+ ### Step 3 — Fix S3ArgParser (4 exit locations)
150
+
151
+ **parse_s3 — PWD auto-detect fail (around line 25):**
152
+
153
+ Replace:
154
+ ```ruby
155
+ if brand.nil? || project_id.nil?
156
+ puts '❌ Could not auto-detect brand/project from current directory'
157
+ puts "Usage: dam #{command} <brand> <project> [--dry-run]"
158
+ exit 1
159
+ end
160
+ ```
161
+
162
+ With:
163
+ ```ruby
164
+ raise Appydave::Tools::Dam::UsageError,
165
+ "Could not auto-detect brand/project from current directory. Usage: dam #{command} <brand> <project> [--dry-run]" if brand.nil? || project_id.nil?
166
+ ```
167
+
168
+ **parse_s3 — invalid brand (around line 43):**
169
+
170
+ Replace:
171
+ ```ruby
172
+ unless valid_brand?(brand_arg)
173
+ puts "❌ Invalid brand: '#{brand_arg}'"
174
+ puts ''
175
+ puts 'Valid brands:'
176
+ # ... puts lines ...
177
+ puts "Usage: dam #{command} <brand> <project> [--dry-run]"
178
+ exit 1
179
+ end
180
+ ```
181
+
182
+ With:
183
+ ```ruby
184
+ unless valid_brand?(brand_arg)
185
+ raise Appydave::Tools::Dam::UsageError,
186
+ "Invalid brand: '#{brand_arg}'. Valid brands: appydave, voz, aitldr, kiros, joy, ss. Usage: dam #{command} <brand> <project> [--dry-run]"
187
+ end
188
+ ```
189
+
190
+ **parse_discover — missing args (around line 100):**
191
+
192
+ Replace:
193
+ ```ruby
194
+ if brand_arg.nil? || project_arg.nil?
195
+ puts 'Usage: dam s3-discover <brand> <project> [--shareable]'
196
+ # ... puts lines ...
197
+ exit 1
198
+ end
199
+ ```
200
+
201
+ With:
202
+ ```ruby
203
+ raise Appydave::Tools::Dam::UsageError,
204
+ 'Usage: dam s3-discover <brand> <project> [--shareable]' if brand_arg.nil? || project_arg.nil?
205
+ ```
206
+
207
+ **show_share_usage_and_exit — rename and replace:**
208
+
209
+ Rename the method to `raise_share_usage_error`. Replace:
210
+ ```ruby
211
+ def show_share_usage_and_exit
212
+ puts 'Usage: dam s3-share ...'
213
+ # ... puts lines ...
214
+ exit 1
215
+ end
216
+ ```
217
+
218
+ With:
219
+ ```ruby
220
+ def raise_share_usage_error
221
+ raise Appydave::Tools::Dam::UsageError,
222
+ 'Usage: dam s3-share <brand> <project> <file> [--expires 7d] [--download]'
223
+ end
224
+ ```
225
+
226
+ Update the caller in `parse_share`:
227
+ ```ruby
228
+ # Before:
229
+ show_share_usage_and_exit if brand_arg.nil? || project_arg.nil? || file_arg.nil?
230
+ # After:
231
+ raise_share_usage_error if brand_arg.nil? || project_arg.nil? || file_arg.nil?
232
+ ```
233
+
234
+ ### Done when B034 is complete
235
+ - `errors.rb` contains `UsageError < DamError`
236
+ - 0 occurrences of `exit 1` in `s3_scan_command.rb` or `s3_arg_parser.rb`
237
+ - `show_share_usage_and_exit` renamed to `raise_share_usage_error`
238
+ - `bundle exec rubocop --format clang` → 0 offenses
239
+ - `RUBYOPT="-W0" bundle exec rspec` → 847 examples, 0 failures (no new specs in this WU)
240
+ - `git status` clean before `kfix`
241
+
242
+ ---
243
+
244
+ ## B035: extract-env-side-effect
245
+
246
+ **Files touched:** `lib/appydave/tools/dam/s3_arg_parser.rb`, `bin/dam`
247
+
248
+ **Prerequisite:** B034 complete.
249
+
250
+ ### Step 1 — Remove ENV side-effects from S3ArgParser
251
+
252
+ In `parse_s3` (around line 51): remove `ENV['BRAND_PATH'] = ...`. Add `brand_path:` to returned hash:
253
+
254
+ ```ruby
255
+ # Before:
256
+ ENV['BRAND_PATH'] = Appydave::Tools::Dam::Config.brand_path(brand)
257
+ { brand: brand_key, project: project_id, dry_run: dry_run, force: force }
258
+
259
+ # After (auto-detect path):
260
+ brand_path = Appydave::Tools::Dam::Config.brand_path(brand_key)
261
+ { brand: brand_key, project: project_id, dry_run: dry_run, force: force, brand_path: brand_path }
262
+ ```
263
+
264
+ Note: In the `brand_arg.nil?` branch, `brand` is set from `ProjectResolver.detect_from_pwd` — use `brand` not `brand_arg` for path resolution there.
265
+
266
+ In `parse_share` (around line 82): remove `ENV['BRAND_PATH'] = ...`. Add `brand_path:` to returned hash:
267
+
268
+ ```ruby
269
+ # Before:
270
+ ENV['BRAND_PATH'] = Appydave::Tools::Dam::Config.brand_path(brand)
271
+ { brand: brand_key, project: project_id, file: file_arg, expires: expires, download: download }
272
+
273
+ # After:
274
+ brand_path = Appydave::Tools::Dam::Config.brand_path(brand)
275
+ { brand: brand_key, project: project_id, file: file_arg, expires: expires, download: download, brand_path: brand_path }
276
+ ```
277
+
278
+ In `parse_discover` (around line 108): same pattern:
279
+
280
+ ```ruby
281
+ # Before:
282
+ ENV['BRAND_PATH'] = Appydave::Tools::Dam::Config.brand_path(brand)
283
+ { brand_key: brand_key, project_id: project_id, shareable: shareable }
284
+
285
+ # After:
286
+ brand_path = Appydave::Tools::Dam::Config.brand_path(brand)
287
+ { brand_key: brand_key, project_id: project_id, shareable: shareable, brand_path: brand_path }
288
+ ```
289
+
290
+ ### Step 2 — Update VatCLI callers in bin/dam
291
+
292
+ Search for every call to `S3ArgParser.parse_s3`, `S3ArgParser.parse_share`, `S3ArgParser.parse_discover` in `bin/dam`. After each call, add:
293
+
294
+ ```ruby
295
+ options = Appydave::Tools::Dam::S3ArgParser.parse_s3(args, 's3-up')
296
+ ENV['BRAND_PATH'] = options[:brand_path]
297
+ ```
298
+
299
+ Same pattern for `parse_share` and `parse_discover` callers.
300
+
301
+ **Affected VatCLI methods** (grep `S3ArgParser` in bin/dam to confirm):
302
+ - `s3_up_command` → uses `parse_s3`
303
+ - `s3_down_command` → uses `parse_s3`
304
+ - `s3_status_command` → uses `parse_s3`
305
+ - `s3_cleanup_remote_command` → uses `parse_s3`
306
+ - `s3_cleanup_local_command` → uses `parse_s3`
307
+ - `archive_command` → uses `parse_s3`
308
+ - `s3_share_command` → uses `parse_share`
309
+ - `s3_discover_command` → uses `parse_discover`
310
+
311
+ ### Done when B035 is complete
312
+ - 0 occurrences of `ENV['BRAND_PATH'] =` in `s3_arg_parser.rb`
313
+ - All 3 parse methods return `brand_path:` in their result hash
314
+ - All VatCLI callers set `ENV['BRAND_PATH'] = options[:brand_path]` after parsing
315
+ - `bundle exec rubocop --format clang` → 0 offenses
316
+ - `RUBYOPT="-W0" bundle exec rspec` → 847 examples, 0 failures
317
+ - `git status` clean before `kfix`
318
+
319
+ ---
320
+
321
+ ## B036: tests-s3-scan-command
322
+
323
+ **File touched:** `spec/appydave/tools/dam/s3_scan_command_spec.rb`
324
+
325
+ **Prerequisite:** B034 complete (exceptions must exist before testing them).
326
+
327
+ Current state: 2 smoke tests (`respond_to` checks). Replace with real behaviour tests.
328
+
329
+ ### Target: 8–10 examples covering
330
+
331
+ ```ruby
332
+ RSpec.describe Appydave::Tools::Dam::S3ScanCommand do
333
+ include_context 'with vat filesystem and brands', brands: %w[appydave]
334
+
335
+ let(:scanner) { described_class.new }
336
+ let(:brand_key) { 'appydave' }
337
+
338
+ before do
339
+ allow(Appydave::Tools::Dam::S3Scanner).to receive(:new).and_return(mock_s3_scanner)
340
+ allow(Appydave::Tools::Configuration::Config).to receive(:configure)
341
+ allow(Appydave::Tools::Configuration::Config).to receive(:brands).and_return(mock_brands_config)
342
+ end
343
+
344
+ describe '#scan_single' do
345
+ context 'when manifest exists and S3 returns results' do
346
+ # Write a fixture projects.json to brand_path
347
+ # Mock scanner.scan_all_projects to return { 'b65-test' => { file_count: 3, total_bytes: 1000, last_modified: '...' } }
348
+ # Expect: manifest updated, updated_count == 1, no error
349
+ end
350
+
351
+ context 'when manifest not found' do
352
+ # Do NOT write projects.json
353
+ # Mock scanner to return results
354
+ # expect { scanner.scan_single(brand_key) }.to raise_error(Appydave::Tools::Dam::ConfigurationError, /Manifest not found/)
355
+ end
356
+
357
+ context 'when S3 returns empty results' do
358
+ # Mock scanner.scan_all_projects to return {}
359
+ # Expect: early return (no raise, no manifest write)
360
+ end
361
+
362
+ context 'when S3 has orphaned projects' do
363
+ # projects.json has ['b65-test'], S3 returns { 'b65-test' => ..., 'b99-orphan' => ... }
364
+ # Expect: orphaned_projects contains 'b99-orphan', matched_projects contains 'b65-test'
365
+ end
366
+ end
367
+
368
+ describe '#scan_all' do
369
+ context 'when one brand fails' do
370
+ # brands_config.brands returns [appydave, voz]
371
+ # scan_single raises for voz
372
+ # Expect: results array has appydave success: true, voz success: false
373
+ # Expect: no re-raise (scan_all rescues per-brand)
374
+ end
375
+ end
376
+ end
377
+ ```
378
+
379
+ **Fixture pattern for projects.json:**
380
+ ```ruby
381
+ manifest = {
382
+ config: { last_updated: Time.now.utc.iso8601, note: 'test' },
383
+ projects: [{ id: 'b65-test-project', storage: { s3: {} } }]
384
+ }
385
+ File.write(File.join(brand_path, 'projects.json'), JSON.generate(manifest))
386
+ ```
387
+
388
+ ### Done when B036 is complete
389
+ - `spec/appydave/tools/dam/s3_scan_command_spec.rb` has 8+ real behaviour examples
390
+ - `respond_to` smoke tests removed or replaced
391
+ - `RUBYOPT="-W0" bundle exec rspec` → 855+ examples, 0 failures
392
+ - `bundle exec rubocop --format clang` → 0 offenses
393
+ - `git status` clean before `kfix`
394
+
395
+ ---
396
+
397
+ ## B037: tests-local-sync-status
398
+
399
+ **File touched:** `spec/appydave/tools/dam/local_sync_status_spec.rb`
400
+
401
+ **No prerequisites** — but run after B035 for clean baseline.
402
+
403
+ ### Gaps to fill
404
+
405
+ **1. `:partial` case in `#enrich!`**
406
+
407
+ Set up `s3-staging` folder with 2 files but `file_count: 3` in S3 data:
408
+ ```ruby
409
+ context 'when local s3-staging has fewer files than S3' do
410
+ before do
411
+ FileUtils.mkdir_p(File.join(appydave_path, 'b65-test', 's3-staging'))
412
+ FileUtils.touch(File.join(appydave_path, 'b65-test', 's3-staging', 'file1.mp4'))
413
+ FileUtils.touch(File.join(appydave_path, 'b65-test', 's3-staging', 'file2.mp4'))
414
+ end
415
+
416
+ it 'sets status to :partial' do
417
+ matched = { 'b65-test' => { file_count: 3 } }
418
+ described_class.enrich!(matched, 'appydave')
419
+ expect(matched['b65-test'][:local_status]).to eq(:partial)
420
+ end
421
+
422
+ it 'sets local_file_count to 2' do
423
+ matched = { 'b65-test' => { file_count: 3 } }
424
+ described_class.enrich!(matched, 'appydave')
425
+ expect(matched['b65-test'][:local_file_count]).to eq(2)
426
+ end
427
+ end
428
+ ```
429
+
430
+ **2. `local_file_count` assertion in existing `:synced` test**
431
+
432
+ Find the existing synced context and add:
433
+ ```ruby
434
+ it 'sets local_file_count correctly' do
435
+ expect(data[:local_file_count]).to eq(3) # adjust to match your fixture
436
+ end
437
+ ```
438
+
439
+ **3. Zone.Identifier exclusion**
440
+
441
+ Windows NTFS streams appear as `filename:Zone.Identifier` in samba mounts. These must NOT count as local files:
442
+ ```ruby
443
+ context 'when s3-staging contains Zone.Identifier files' do
444
+ before do
445
+ FileUtils.mkdir_p(File.join(appydave_path, 'b65-test', 's3-staging'))
446
+ FileUtils.touch(File.join(appydave_path, 'b65-test', 's3-staging', 'file1.mp4'))
447
+ FileUtils.touch(File.join(appydave_path, 'b65-test', 's3-staging', 'file1.mp4:Zone.Identifier'))
448
+ FileUtils.touch(File.join(appydave_path, 'b65-test', 's3-staging', 'file2.mp4'))
449
+ end
450
+
451
+ it 'excludes Zone.Identifier files from local_file_count' do
452
+ matched = { 'b65-test' => { file_count: 2 } }
453
+ described_class.enrich!(matched, 'appydave')
454
+ expect(matched['b65-test'][:local_file_count]).to eq(2)
455
+ expect(matched['b65-test'][:local_status]).to eq(:synced)
456
+ end
457
+ end
458
+ ```
459
+
460
+ **4. Unknown status guard in `#format`**
461
+
462
+ ```ruby
463
+ describe '.format' do
464
+ it 'returns Unknown for unrecognised status' do
465
+ expect(described_class.format(:unknown, nil, 0)).to eq('Unknown')
466
+ end
467
+ end
468
+ ```
469
+
470
+ Check the actual `format` implementation first — if it already has an `else` branch returning `'Unknown'`, this is a documentation test. If it has no `else`, add one to `local_sync_status.rb` before writing this spec.
471
+
472
+ ### Done when B037 is complete
473
+ - `:partial` case tested with `local_file_count` assertion
474
+ - `:synced` case has `local_file_count` assertion
475
+ - `Zone.Identifier` exclusion tested
476
+ - `format(:unknown, ...)` tested
477
+ - `RUBYOPT="-W0" bundle exec rspec` → 851+ examples, 0 failures
478
+ - `bundle exec rubocop --format clang` → 0 offenses
479
+ - `git status` clean before `kfix`
480
+
481
+ ---
482
+
483
+ ## Success Criteria (Every Work Unit)
484
+
485
+ Every work unit must satisfy ALL of the following before marking `[x]`:
486
+
487
+ - [ ] `RUBYOPT="-W0" bundle exec rspec` — 847+ examples, 0 failures (rising with each WU)
488
+ - [ ] `bundle exec rubocop --format clang` — 0 offenses
489
+ - [ ] Line coverage stays ≥ 85.92%
490
+ - [ ] Any new `.rb` files start with `# frozen_string_literal: true`
491
+ - [ ] `git status` confirmed clean before `kfix`
492
+
493
+ ---
494
+
495
+ ## Reference Patterns
496
+
497
+ ### Shared Context for DAM Specs — THE STANDARD PATTERN
498
+
499
+ ```ruby
500
+ # spec/appydave/tools/dam/some_class_spec.rb
501
+ # frozen_string_literal: true
502
+
503
+ require 'spec_helper'
504
+
505
+ RSpec.describe Appydave::Tools::Dam::SomeClass do
506
+ include_context 'with vat filesystem and brands', brands: %w[appydave voz]
507
+
508
+ before do
509
+ FileUtils.mkdir_p(File.join(appydave_path, 'b65-test-project'))
510
+ end
511
+
512
+ describe '.some_method' do
513
+ it 'does the thing' do
514
+ expect(described_class.some_method('appydave')).to eq('expected')
515
+ end
516
+ end
517
+ end
518
+ ```
519
+
520
+ **Available from shared context:**
521
+ - `temp_folder` — temp root (auto-cleaned after each example)
522
+ - `projects_root` — `/path/to/temp/video-projects`
523
+ - `appydave_path`, `voz_path`, etc. — brand dirs (created on demand)
524
+ - `SettingsConfig#video_projects_root` is mocked to return `projects_root`
525
+
526
+ ### FileHelper — Use These, Don't Duplicate
527
+
528
+ ```ruby
529
+ Appydave::Tools::Dam::FileHelper.format_size(bytes) # "1.5 GB"
530
+ Appydave::Tools::Dam::FileHelper.calculate_directory_size(path) # Integer bytes
531
+ Appydave::Tools::Dam::FileHelper.format_age(time) # "3d", "2w", etc.
532
+ ```
533
+
534
+ ### BrandResolver — All Brand Name Transformations
535
+
536
+ ```ruby
537
+ BrandResolver.expand('appydave') # => 'v-appydave'
538
+ BrandResolver.expand('ad') # => 'v-appydave' (shortcut)
539
+ BrandResolver.normalize('v-appydave') # => 'appydave'
540
+ BrandResolver.validate('appydave') # => 'appydave' or raises BrandNotFoundError
541
+ ```
542
+
543
+ ### Typed Exception Pattern
544
+
545
+ ```ruby
546
+ raise Appydave::Tools::Dam::ConfigurationError, 'Manifest not found: /path'
547
+ raise Appydave::Tools::Dam::UsageError, 'Usage: dam s3-up <brand> <project>'
548
+ raise ProjectNotFoundError, 'Project name is required' if project_hint.nil?
549
+ raise BrandNotFoundError.new(brand, available_brands, fuzzy_suggestions)
550
+ ```
551
+
552
+ ### Config.brand_path — Correct Call
553
+
554
+ ```ruby
555
+ # brand_key is the short form ('appydave'), brand is expanded ('v-appydave')
556
+ brand_path = Appydave::Tools::Dam::Config.brand_path(brand_key)
557
+ # OR:
558
+ brand_path = Appydave::Tools::Dam::Config.brand_path(brand)
559
+ # Both work — Config.brand_path resolves via BrandResolver internally
560
+ ```
561
+
562
+ ---
563
+
564
+ ## Anti-Patterns to Avoid
565
+
566
+ - ❌ `exit 1` in library code — use typed exceptions from `errors.rb` instead
567
+ - ❌ `ENV['BRAND_PATH'] = ...` in library code — return `brand_path:` in result hash; set ENV in VatCLI
568
+ - ❌ Inline brand transformations — never write `"v-#{brand}"` outside BrandResolver
569
+ - ❌ `format_bytes` — does not exist; use `FileHelper.format_size`
570
+ - ❌ Mocking Config class methods in DAM specs — use shared filesystem context instead
571
+ - ❌ Multiple `before` blocks in same RSpec context — merge them (triggers RSpec/ScatteredSetup)
572
+ - ❌ `$?` for subprocess status — use `$CHILD_STATUS` (rubocop Style/SpecialGlobalVars)
573
+ - ❌ `raise 'string error'` in DAM module — use typed exceptions from `errors.rb`
574
+ - ❌ `include FileUtils` — use dam's `FileHelper` instead
575
+ - ❌ Hardcoded header strings for table output — always use `format()` matching data row format
576
+ - ❌ Adding new `Config.configure` calls — memoized but called redundantly; don't spread further
577
+
578
+ ---
579
+
580
+ ## Mock Patterns
581
+
582
+ ### ENV Stubbing (if needed in specs)
583
+
584
+ ```ruby
585
+ allow(ENV).to receive(:[]).and_call_original
586
+ allow(ENV).to receive(:[]).with('BRAND_PATH').and_return('/tmp/test/v-appydave')
587
+ ```
588
+
589
+ **Do NOT use climate_control gem** — project doesn't have it.
590
+
591
+ ### S3Scanner Mocking (for B036)
592
+
593
+ ```ruby
594
+ let(:mock_s3_scanner) { instance_double(Appydave::Tools::Dam::S3Scanner) }
595
+
596
+ before do
597
+ allow(Appydave::Tools::Dam::S3Scanner).to receive(:new).with(brand_key).and_return(mock_s3_scanner)
598
+ allow(mock_s3_scanner).to receive(:scan_all_projects).and_return({
599
+ 'b65-test-project' => { file_count: 3, total_bytes: 1_500_000, last_modified: '2025-01-01T00:00:00Z' }
600
+ })
601
+ end
602
+ ```
603
+
604
+ ### Config::Brands Mocking (for B036 scan_all)
605
+
606
+ ```ruby
607
+ let(:mock_brands_config) { instance_double(Appydave::Tools::Configuration::BrandsConfig) }
608
+ let(:mock_brand_info) { instance_double(Appydave::Tools::Configuration::BrandInfo, key: 'appydave') }
609
+
610
+ before do
611
+ allow(mock_brands_config).to receive(:brands).and_return([mock_brand_info])
612
+ end
613
+ ```
614
+
615
+ ---
616
+
617
+ ## Quality Gates
618
+
619
+ - **Tests:** `RUBYOPT="-W0" bundle exec rspec` — 847+ examples, 0 failures
620
+ - **Lint:** `bundle exec rubocop --format clang` — 0 offenses (CI will reject)
621
+ - **Coverage:** ≥ 85.92% line coverage
622
+ - **frozen_string_literal:** Required on every new `.rb` file
623
+ - **Commit format:** `kfeat`/`kfix` only — triggers semantic versioning + CI wait
624
+ - **Pre-commit:** Always run `git status` before `kfix` — confirm staged files
625
+
626
+ ---
627
+
628
+ ## Learnings
629
+
630
+ ### From DAM Enhancement Sprint (Jan 2025)
631
+
632
+ - **BrandResolver is the critical path.** All `dam` commands flow through it. Any change to brand resolution must be tested with all shortcuts.
633
+ - **`Regexp.last_match` is reset by `.sub()` calls.** Always capture regex groups BEFORE any string transformation.
634
+ - **`Config.configure` is memoized but called redundantly.** Don't add new calls.
635
+ - **Table format() pattern is non-obvious.** Headers misaligned 3 times in UAT. Always verify with real data.
636
+
637
+ ### From micro-cleanup (2026-03-19)
638
+
639
+ - **Dirty working tree + kfix = accidental staging.** Always run `git status` before committing.
640
+ - **Pre-existing "already fixed" items:** Check B-items aren't already done before acting (B031, B033, B015, B019 were all already committed).
641
+
642
+ ### From Architectural Review (2026-03-19)
643
+
644
+ - **bin/dam is the primary DAM CLI entry point.** Regressions here affect real workflows. Test every command path after extraction.
645
+ - **`ENV['BRAND_PATH']` is set in 5 places in bin/dam.** Three are in parse methods (being extracted to B035). Two remain in `generate_single_manifest` and `sync_ssd_command` — out of scope for this campaign.
646
+ - **Do NOT attempt B020 (split S3Operations) in this campaign.** Different class, different risk profile.
647
+
648
+ ### From extract-vat-cli (2026-03-19)
649
+
650
+ - **valid_brand? needs Config.brands mock.** The shared filesystem context only mocks `SettingsConfig`. When testing code that calls `Config.brands`, mock it separately: `allow(Appydave::Tools::Configuration::Config).to receive(:brands).and_return(...)`.
651
+ - **rubocop-disable directives become redundant when methods move.** After extracting to a library class, check the VatCLI file for orphaned disable/enable pairs and remove them.
652
+ - **VatCLI rescue blocks already catch StandardError.** No changes to VatCLI rescue needed when replacing `exit 1` with exceptions — the existing rescue chain handles it.
653
+ - **S3ScanCommand#scan_all already rescues per-brand.** The `rescue StandardError` inside `scan_all` means per-brand failures are isolated. Once `scan_single` raises instead of calling `exit`, `scan_all` catches it correctly — no structural change needed.