claude_memory 0.1.0 → 0.2.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/.claude/.mind.mv2.aLCUZd +0 -0
- data/.claude/memory.sqlite3 +0 -0
- data/.claude/rules/claude_memory.generated.md +7 -1
- data/.claude/settings.json +0 -4
- data/.claude/settings.local.json +4 -1
- data/.claude-plugin/plugin.json +1 -1
- data/.claude.json +11 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +62 -11
- data/CLAUDE.md +87 -24
- data/README.md +76 -159
- data/docs/EXAMPLES.md +436 -0
- data/docs/RELEASE_NOTES_v0.2.0.md +179 -0
- data/docs/RUBY_COMMUNITY_POST_v0.2.0.md +582 -0
- data/docs/SOCIAL_MEDIA_v0.2.0.md +420 -0
- data/docs/architecture.md +360 -0
- data/docs/expert_review.md +1718 -0
- data/docs/feature_adoption_plan.md +1241 -0
- data/docs/feature_adoption_plan_revised.md +2374 -0
- data/docs/improvements.md +1325 -0
- data/docs/quality_review.md +1544 -0
- data/docs/review_summary.md +480 -0
- data/lefthook.yml +10 -0
- data/lib/claude_memory/cli.rb +16 -844
- data/lib/claude_memory/commands/base_command.rb +95 -0
- data/lib/claude_memory/commands/changes_command.rb +39 -0
- data/lib/claude_memory/commands/conflicts_command.rb +37 -0
- data/lib/claude_memory/commands/db_init_command.rb +40 -0
- data/lib/claude_memory/commands/doctor_command.rb +147 -0
- data/lib/claude_memory/commands/explain_command.rb +65 -0
- data/lib/claude_memory/commands/help_command.rb +37 -0
- data/lib/claude_memory/commands/hook_command.rb +106 -0
- data/lib/claude_memory/commands/ingest_command.rb +47 -0
- data/lib/claude_memory/commands/init_command.rb +218 -0
- data/lib/claude_memory/commands/promote_command.rb +30 -0
- data/lib/claude_memory/commands/publish_command.rb +36 -0
- data/lib/claude_memory/commands/recall_command.rb +61 -0
- data/lib/claude_memory/commands/registry.rb +55 -0
- data/lib/claude_memory/commands/search_command.rb +43 -0
- data/lib/claude_memory/commands/serve_mcp_command.rb +16 -0
- data/lib/claude_memory/commands/sweep_command.rb +36 -0
- data/lib/claude_memory/commands/version_command.rb +13 -0
- data/lib/claude_memory/configuration.rb +38 -0
- data/lib/claude_memory/core/fact_id.rb +41 -0
- data/lib/claude_memory/core/null_explanation.rb +47 -0
- data/lib/claude_memory/core/null_fact.rb +30 -0
- data/lib/claude_memory/core/result.rb +143 -0
- data/lib/claude_memory/core/session_id.rb +37 -0
- data/lib/claude_memory/core/token_estimator.rb +33 -0
- data/lib/claude_memory/core/transcript_path.rb +37 -0
- data/lib/claude_memory/domain/conflict.rb +51 -0
- data/lib/claude_memory/domain/entity.rb +51 -0
- data/lib/claude_memory/domain/fact.rb +70 -0
- data/lib/claude_memory/domain/provenance.rb +48 -0
- data/lib/claude_memory/hook/exit_codes.rb +18 -0
- data/lib/claude_memory/hook/handler.rb +7 -2
- data/lib/claude_memory/index/index_query.rb +89 -0
- data/lib/claude_memory/index/index_query_logic.rb +41 -0
- data/lib/claude_memory/index/query_options.rb +67 -0
- data/lib/claude_memory/infrastructure/file_system.rb +29 -0
- data/lib/claude_memory/infrastructure/in_memory_file_system.rb +32 -0
- data/lib/claude_memory/ingest/content_sanitizer.rb +42 -0
- data/lib/claude_memory/ingest/ingester.rb +3 -0
- data/lib/claude_memory/ingest/privacy_tag.rb +48 -0
- data/lib/claude_memory/mcp/tools.rb +174 -1
- data/lib/claude_memory/publish.rb +29 -20
- data/lib/claude_memory/recall.rb +164 -16
- data/lib/claude_memory/resolve/resolver.rb +41 -37
- data/lib/claude_memory/shortcuts.rb +56 -0
- data/lib/claude_memory/store/store_manager.rb +35 -32
- data/lib/claude_memory/templates/hooks.example.json +0 -4
- data/lib/claude_memory/version.rb +1 -1
- data/lib/claude_memory.rb +59 -21
- metadata +55 -1
|
@@ -0,0 +1,1544 @@
|
|
|
1
|
+
# Code Quality Review - Ruby Best Practices
|
|
2
|
+
|
|
3
|
+
**Reviewed by perspectives of:** Sandi Metz, Jeremy Evans, Kent Beck, Avdi Grimm, Gary Bernhardt
|
|
4
|
+
|
|
5
|
+
**Review Date:** 2026-01-21
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Executive Summary
|
|
10
|
+
|
|
11
|
+
This codebase demonstrates good fundamentals with frozen string literals, consistent use of Sequel, and reasonable test coverage. However, there are significant opportunities for improvement in object-oriented design, separation of concerns, and adherence to Ruby idioms. The most critical issues center around:
|
|
12
|
+
|
|
13
|
+
1. **CLI God Object** - 867-line class with too many responsibilities
|
|
14
|
+
2. **Mixed Concerns** - I/O interleaved with business logic throughout
|
|
15
|
+
3. **Inconsistent Database Practices** - Mix of Sequel datasets and raw SQL
|
|
16
|
+
4. **Lack of Domain Objects** - Primitive obsession with hashes
|
|
17
|
+
5. **State Management** - Mutable instance variables where immutability preferred
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## 1. Sandi Metz Perspective (POODR)
|
|
22
|
+
|
|
23
|
+
### Focus Areas
|
|
24
|
+
- Single Responsibility Principle
|
|
25
|
+
- Small, focused methods
|
|
26
|
+
- Clear dependencies
|
|
27
|
+
- DRY principle
|
|
28
|
+
- High test coverage
|
|
29
|
+
|
|
30
|
+
### Critical Issues
|
|
31
|
+
|
|
32
|
+
#### 🔴 CLI God Object (cli.rb:1-867)
|
|
33
|
+
|
|
34
|
+
**Problem:** The CLI class has 867 lines and handles parsing, validation, execution, database management, configuration, output formatting, and error handling.
|
|
35
|
+
|
|
36
|
+
**Violations:**
|
|
37
|
+
- Single Responsibility Principle violated
|
|
38
|
+
- Too many public methods (18+ commands)
|
|
39
|
+
- Too many private methods (20+)
|
|
40
|
+
- Methods > 10 lines (doctor_cmd, init_local, configure_global_hooks, etc.)
|
|
41
|
+
|
|
42
|
+
**Example:**
|
|
43
|
+
```ruby
|
|
44
|
+
# cli.rb:689-743 - doctor_cmd does too much
|
|
45
|
+
def doctor_cmd
|
|
46
|
+
issues = []
|
|
47
|
+
warnings = []
|
|
48
|
+
|
|
49
|
+
# Database checking
|
|
50
|
+
# File system checking
|
|
51
|
+
# Config validation
|
|
52
|
+
# Conflict detection
|
|
53
|
+
# Output formatting
|
|
54
|
+
# Error handling
|
|
55
|
+
end
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
**Recommended Fix:**
|
|
59
|
+
Extract command objects:
|
|
60
|
+
```ruby
|
|
61
|
+
# lib/claude_memory/commands/doctor.rb
|
|
62
|
+
module ClaudeMemory
|
|
63
|
+
module Commands
|
|
64
|
+
class Doctor
|
|
65
|
+
def initialize(store_manager, reporter:)
|
|
66
|
+
@store_manager = store_manager
|
|
67
|
+
@reporter = reporter
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def call
|
|
71
|
+
checks = [
|
|
72
|
+
DatabaseCheck.new(@store_manager),
|
|
73
|
+
SnapshotCheck.new,
|
|
74
|
+
HooksCheck.new
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
results = checks.map(&:call)
|
|
78
|
+
@reporter.report(results)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
#### 🔴 Long Methods Throughout
|
|
86
|
+
|
|
87
|
+
**Problem:** Many methods exceed 10-15 lines, making them hard to understand and test.
|
|
88
|
+
|
|
89
|
+
**Examples:**
|
|
90
|
+
- `cli.rb:689-743` - `doctor_cmd` (55 lines)
|
|
91
|
+
- `cli.rb:536-565` - `init_local` (30 lines)
|
|
92
|
+
- `cli.rb:586-601` - `configure_global_hooks` (16 lines)
|
|
93
|
+
- `recall.rb:58-78` - `query_dual` (21 lines)
|
|
94
|
+
|
|
95
|
+
**Recommended Fix:**
|
|
96
|
+
Break into smaller, well-named private methods:
|
|
97
|
+
```ruby
|
|
98
|
+
def doctor_cmd
|
|
99
|
+
results = run_health_checks
|
|
100
|
+
display_results(results)
|
|
101
|
+
exit_code_from(results)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
def run_health_checks
|
|
107
|
+
[
|
|
108
|
+
check_global_database,
|
|
109
|
+
check_project_database,
|
|
110
|
+
check_snapshot,
|
|
111
|
+
check_hooks
|
|
112
|
+
]
|
|
113
|
+
end
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
#### 🟡 Duplicated Attribute Readers (store_manager.rb:47-49)
|
|
117
|
+
|
|
118
|
+
**Problem:**
|
|
119
|
+
```ruby
|
|
120
|
+
attr_reader :global_store, :project_store, :project_path # line 8
|
|
121
|
+
|
|
122
|
+
# ... later ...
|
|
123
|
+
|
|
124
|
+
attr_reader :global_db_path # line 47
|
|
125
|
+
attr_reader :project_db_path # line 49
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
**Fix:** Consolidate at the top of the class.
|
|
129
|
+
|
|
130
|
+
#### 🟡 Multiple Responsibilities in Recall Class
|
|
131
|
+
|
|
132
|
+
**Problem:** Recall handles both legacy single-store mode and dual-database mode (recall.rb:9-20).
|
|
133
|
+
|
|
134
|
+
**Violations:**
|
|
135
|
+
- Two modes = two responsibilities
|
|
136
|
+
- Conditional logic based on mode throughout
|
|
137
|
+
- Hard to reason about which path executes
|
|
138
|
+
|
|
139
|
+
**Recommended Fix:**
|
|
140
|
+
Create separate classes:
|
|
141
|
+
```ruby
|
|
142
|
+
class LegacyRecall
|
|
143
|
+
# Single store logic only
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
class DualRecall
|
|
147
|
+
# Dual store logic only
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Factory
|
|
151
|
+
def self.build(store_or_manager)
|
|
152
|
+
if store_or_manager.is_a?(Store::StoreManager)
|
|
153
|
+
DualRecall.new(store_or_manager)
|
|
154
|
+
else
|
|
155
|
+
LegacyRecall.new(store_or_manager)
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
#### 🟡 Inconsistent Visibility (sqlite_store.rb:204)
|
|
161
|
+
|
|
162
|
+
**Problem:**
|
|
163
|
+
```ruby
|
|
164
|
+
private # line 59
|
|
165
|
+
|
|
166
|
+
# ... private methods ...
|
|
167
|
+
|
|
168
|
+
public # line 204
|
|
169
|
+
|
|
170
|
+
def upsert_content_item(...)
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
**Recommended:** Keep all public methods together at the top, all private at the bottom.
|
|
174
|
+
|
|
175
|
+
---
|
|
176
|
+
|
|
177
|
+
## 2. Jeremy Evans Perspective (Sequel Expert)
|
|
178
|
+
|
|
179
|
+
### Focus Areas
|
|
180
|
+
- Proper Sequel usage patterns
|
|
181
|
+
- Database performance
|
|
182
|
+
- Schema design
|
|
183
|
+
- Connection management
|
|
184
|
+
|
|
185
|
+
### Critical Issues
|
|
186
|
+
|
|
187
|
+
#### 🔴 Raw SQL Instead of Sequel Datasets (cli.rb:752-764)
|
|
188
|
+
|
|
189
|
+
**Problem:**
|
|
190
|
+
```ruby
|
|
191
|
+
fact_count = store.db.execute("SELECT COUNT(*) FROM facts").first.first
|
|
192
|
+
content_count = store.db.execute("SELECT COUNT(*) FROM content_items").first.first
|
|
193
|
+
conflict_count = store.db.execute("SELECT COUNT(*) FROM conflicts WHERE status = 'open'").first.first
|
|
194
|
+
last_ingest = store.db.execute("SELECT MAX(ingested_at) FROM content_items").first.first
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
**Violations:**
|
|
198
|
+
- Bypasses Sequel's dataset API
|
|
199
|
+
- Inconsistent with rest of codebase
|
|
200
|
+
- No type casting or safety checks
|
|
201
|
+
- Raw SQL is harder to test
|
|
202
|
+
|
|
203
|
+
**Recommended Fix:**
|
|
204
|
+
```ruby
|
|
205
|
+
fact_count = store.facts.count
|
|
206
|
+
content_count = store.content_items.count
|
|
207
|
+
conflict_count = store.conflicts.where(status: 'open').count
|
|
208
|
+
last_ingest = store.content_items.max(:ingested_at)
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
#### 🔴 No Transaction Wrapping (store_manager.rb:79-122)
|
|
212
|
+
|
|
213
|
+
**Problem:** `promote_fact` performs multiple database writes without transaction:
|
|
214
|
+
```ruby
|
|
215
|
+
def promote_fact(fact_id)
|
|
216
|
+
ensure_both!
|
|
217
|
+
|
|
218
|
+
fact = @project_store.facts.where(id: fact_id).first
|
|
219
|
+
# ... multiple inserts across two databases
|
|
220
|
+
global_fact_id = @global_store.insert_fact(...)
|
|
221
|
+
copy_provenance(fact_id, global_fact_id)
|
|
222
|
+
|
|
223
|
+
global_fact_id
|
|
224
|
+
end
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
**Risk:** If `copy_provenance` fails, you have orphaned fact in global database.
|
|
228
|
+
|
|
229
|
+
**Recommended Fix:**
|
|
230
|
+
```ruby
|
|
231
|
+
def promote_fact(fact_id)
|
|
232
|
+
ensure_both!
|
|
233
|
+
|
|
234
|
+
@global_store.db.transaction do
|
|
235
|
+
fact = @project_store.facts.where(id: fact_id).first
|
|
236
|
+
return nil unless fact
|
|
237
|
+
|
|
238
|
+
# ... inserts ...
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
**Note:** Cross-database transactions are not atomic, but at least wrap single-DB operations.
|
|
244
|
+
|
|
245
|
+
#### 🔴 String Timestamps Instead of Time Objects
|
|
246
|
+
|
|
247
|
+
**Problem:** Throughout the codebase:
|
|
248
|
+
```ruby
|
|
249
|
+
String :created_at, null: false # sqlite_store.rb:127
|
|
250
|
+
now = Time.now.utc.iso8601 # sqlite_store.rb:211
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
**Issues:**
|
|
254
|
+
- String comparison for dates is fragile
|
|
255
|
+
- No timezone enforcement at DB level
|
|
256
|
+
- Manual ISO8601 conversion everywhere
|
|
257
|
+
- Harder to query by date ranges
|
|
258
|
+
|
|
259
|
+
**Recommended Fix:**
|
|
260
|
+
```ruby
|
|
261
|
+
# Use DateTime columns
|
|
262
|
+
DateTime :created_at, null: false
|
|
263
|
+
|
|
264
|
+
# Use Sequel's timestamp plugin
|
|
265
|
+
Sequel.extension :date_arithmetic
|
|
266
|
+
plugin :timestamps, update_on_create: true
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
#### 🟡 No Connection Pooling Configuration
|
|
270
|
+
|
|
271
|
+
**Problem:** SQLite connections created without pooling options (sqlite_store.rb:15):
|
|
272
|
+
```ruby
|
|
273
|
+
@db = Sequel.sqlite(db_path)
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
**Recommendation:**
|
|
277
|
+
```ruby
|
|
278
|
+
@db = Sequel.connect(
|
|
279
|
+
adapter: 'sqlite',
|
|
280
|
+
database: db_path,
|
|
281
|
+
max_connections: 4,
|
|
282
|
+
pool_timeout: 5
|
|
283
|
+
)
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
#### 🟡 Manual Schema Migrations (sqlite_store.rb:68-91)
|
|
287
|
+
|
|
288
|
+
**Problem:** Hand-rolled migration system instead of Sequel's migration framework.
|
|
289
|
+
|
|
290
|
+
**Issues:**
|
|
291
|
+
- No rollback support
|
|
292
|
+
- No migration history
|
|
293
|
+
- Schema changes mixed with initialization
|
|
294
|
+
|
|
295
|
+
**Recommended:**
|
|
296
|
+
Use Sequel's migration extension:
|
|
297
|
+
```ruby
|
|
298
|
+
# db/migrations/001_initial_schema.rb
|
|
299
|
+
Sequel.migration do
|
|
300
|
+
up do
|
|
301
|
+
create_table(:entities) do
|
|
302
|
+
primary_key :id
|
|
303
|
+
String :type, null: false
|
|
304
|
+
# ...
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
down do
|
|
309
|
+
drop_table(:entities)
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# In code:
|
|
314
|
+
Sequel::Migrator.run(@db, 'db/migrations')
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
#### 🟡 Sequel Plugins Not Used
|
|
318
|
+
|
|
319
|
+
**Problem:** No use of helpful Sequel plugins:
|
|
320
|
+
- `timestamps` - automatic created_at/updated_at
|
|
321
|
+
- `validation_helpers` - model validations
|
|
322
|
+
- `json_serializer` - better JSON handling
|
|
323
|
+
- `association_dependencies` - cascade deletes
|
|
324
|
+
|
|
325
|
+
**Example Benefit:**
|
|
326
|
+
```ruby
|
|
327
|
+
class Fact < Sequel::Model
|
|
328
|
+
plugin :timestamps
|
|
329
|
+
plugin :validation_helpers
|
|
330
|
+
|
|
331
|
+
many_to_one :subject, class: :Entity
|
|
332
|
+
one_to_many :provenance_records, class: :Provenance
|
|
333
|
+
|
|
334
|
+
def validate
|
|
335
|
+
super
|
|
336
|
+
validates_presence [:subject_entity_id, :predicate]
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
---
|
|
342
|
+
|
|
343
|
+
## 3. Kent Beck Perspective (TDD, XP, Simple Design)
|
|
344
|
+
|
|
345
|
+
### Focus Areas
|
|
346
|
+
- Test-first design
|
|
347
|
+
- Simple solutions
|
|
348
|
+
- Revealing intent
|
|
349
|
+
- Small steps
|
|
350
|
+
- Clear boundaries
|
|
351
|
+
|
|
352
|
+
### Critical Issues
|
|
353
|
+
|
|
354
|
+
#### 🔴 CLI Methods Untestable in Isolation
|
|
355
|
+
|
|
356
|
+
**Problem:** CLI methods create their own dependencies:
|
|
357
|
+
```ruby
|
|
358
|
+
def ingest
|
|
359
|
+
opts = parse_ingest_options
|
|
360
|
+
return 1 unless opts
|
|
361
|
+
|
|
362
|
+
store = ClaudeMemory::Store::SQLiteStore.new(opts[:db]) # Created here!
|
|
363
|
+
ingester = ClaudeMemory::Ingest::Ingester.new(store) # Created here!
|
|
364
|
+
|
|
365
|
+
result = ingester.ingest(...)
|
|
366
|
+
# ...
|
|
367
|
+
end
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
**Testing Issues:**
|
|
371
|
+
- Can't inject test double for store
|
|
372
|
+
- Must use real database for tests
|
|
373
|
+
- Slow integration tests required
|
|
374
|
+
- Hard to test error paths
|
|
375
|
+
|
|
376
|
+
**Recommended Fix:**
|
|
377
|
+
```ruby
|
|
378
|
+
def ingest(store: default_store)
|
|
379
|
+
opts = parse_ingest_options
|
|
380
|
+
return 1 unless opts
|
|
381
|
+
|
|
382
|
+
ingester = ClaudeMemory::Ingest::Ingester.new(store)
|
|
383
|
+
result = ingester.ingest(...)
|
|
384
|
+
# ...
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
private
|
|
388
|
+
|
|
389
|
+
def default_store
|
|
390
|
+
@default_store ||= ClaudeMemory::Store::SQLiteStore.new(opts[:db])
|
|
391
|
+
end
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
#### 🔴 Methods Don't Reveal Intent
|
|
395
|
+
|
|
396
|
+
**Problem:** `run` method is a giant case statement (cli.rb:14-58):
|
|
397
|
+
```ruby
|
|
398
|
+
def run
|
|
399
|
+
command = @args.first || "help"
|
|
400
|
+
|
|
401
|
+
case command
|
|
402
|
+
when "help", "-h", "--help"
|
|
403
|
+
print_help
|
|
404
|
+
0
|
|
405
|
+
when "version", "-v", "--version"
|
|
406
|
+
print_version
|
|
407
|
+
0
|
|
408
|
+
# ... 15 more cases
|
|
409
|
+
end
|
|
410
|
+
end
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
**Issues:**
|
|
414
|
+
- Doesn't reveal what the CLI does
|
|
415
|
+
- Adding commands requires modifying this method
|
|
416
|
+
- No clear command structure
|
|
417
|
+
|
|
418
|
+
**Recommended Fix:**
|
|
419
|
+
```ruby
|
|
420
|
+
def run
|
|
421
|
+
command_name = extract_command_name
|
|
422
|
+
command = find_command(command_name)
|
|
423
|
+
command.call(arguments)
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
private
|
|
427
|
+
|
|
428
|
+
def find_command(name)
|
|
429
|
+
COMMANDS.fetch(name) { UnknownCommand.new(name) }
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
COMMANDS = {
|
|
433
|
+
'help' => Commands::Help.new(@stdout),
|
|
434
|
+
'ingest' => Commands::Ingest.new(@stdout, @stderr),
|
|
435
|
+
# ...
|
|
436
|
+
}
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
#### 🔴 Complex Boolean Logic (cli.rb:124-125)
|
|
440
|
+
|
|
441
|
+
**Problem:**
|
|
442
|
+
```ruby
|
|
443
|
+
opts[:global] = true if !opts[:global] && !opts[:project]
|
|
444
|
+
opts[:project] = true if !opts[:global] && !opts[:project]
|
|
445
|
+
```
|
|
446
|
+
|
|
447
|
+
**Issues:**
|
|
448
|
+
- Double negative logic
|
|
449
|
+
- Duplicate condition
|
|
450
|
+
- Intent unclear (setting both to true?)
|
|
451
|
+
- Bug: both will be true after these lines!
|
|
452
|
+
|
|
453
|
+
**Fix:**
|
|
454
|
+
```ruby
|
|
455
|
+
if !opts[:global] && !opts[:project]
|
|
456
|
+
opts[:global] = true
|
|
457
|
+
opts[:project] = true
|
|
458
|
+
end
|
|
459
|
+
```
|
|
460
|
+
|
|
461
|
+
Better:
|
|
462
|
+
```ruby
|
|
463
|
+
opts[:global] = opts[:project] = true if opts.values_at(:global, :project).none?
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
#### 🟡 Side Effects Hidden in Constructor (index/lexical_fts.rb:6-10)
|
|
467
|
+
|
|
468
|
+
**Problem:**
|
|
469
|
+
```ruby
|
|
470
|
+
def initialize(store)
|
|
471
|
+
@store = store
|
|
472
|
+
@db = store.db
|
|
473
|
+
ensure_fts_table! # Side effect!
|
|
474
|
+
end
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
**Issues:**
|
|
478
|
+
- Constructor has side effect (creates table)
|
|
479
|
+
- Violates Command-Query Separation
|
|
480
|
+
- Can't instantiate without modifying database
|
|
481
|
+
- Hard to test
|
|
482
|
+
|
|
483
|
+
**Recommended Fix:**
|
|
484
|
+
```ruby
|
|
485
|
+
def initialize(store)
|
|
486
|
+
@store = store
|
|
487
|
+
@db = store.db
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
def index_content_item(content_item_id, text)
|
|
491
|
+
ensure_fts_table! # Lazy initialization
|
|
492
|
+
# ...
|
|
493
|
+
end
|
|
494
|
+
```
|
|
495
|
+
|
|
496
|
+
Or better: separate schema setup from usage.
|
|
497
|
+
|
|
498
|
+
#### 🟡 No Clear Separation of Concerns
|
|
499
|
+
|
|
500
|
+
**Problem:** Parser, validator, executor, formatter all in one method:
|
|
501
|
+
```ruby
|
|
502
|
+
def recall_cmd
|
|
503
|
+
# Parse
|
|
504
|
+
query = @args[1]
|
|
505
|
+
|
|
506
|
+
# Validate
|
|
507
|
+
unless query
|
|
508
|
+
@stderr.puts "Usage: ..."
|
|
509
|
+
return 1
|
|
510
|
+
end
|
|
511
|
+
|
|
512
|
+
# Parse options
|
|
513
|
+
opts = {limit: 10, scope: "all"}
|
|
514
|
+
OptionParser.new do |o|
|
|
515
|
+
# ...
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
# Execute
|
|
519
|
+
manager = ClaudeMemory::Store::StoreManager.new
|
|
520
|
+
recall = ClaudeMemory::Recall.new(manager)
|
|
521
|
+
results = recall.query(query, limit: opts[:limit], scope: opts[:scope])
|
|
522
|
+
|
|
523
|
+
# Format
|
|
524
|
+
if results.empty?
|
|
525
|
+
@stdout.puts "No facts found."
|
|
526
|
+
else
|
|
527
|
+
results.each do |result|
|
|
528
|
+
print_fact(result[:fact])
|
|
529
|
+
# ...
|
|
530
|
+
end
|
|
531
|
+
end
|
|
532
|
+
|
|
533
|
+
# Cleanup
|
|
534
|
+
manager.close
|
|
535
|
+
0
|
|
536
|
+
end
|
|
537
|
+
```
|
|
538
|
+
|
|
539
|
+
**Recommended:** Extract to separate objects (Parser, Validator, Executor, Formatter).
|
|
540
|
+
|
|
541
|
+
---
|
|
542
|
+
|
|
543
|
+
## 4. Avdi Grimm Perspective (Confident Ruby)
|
|
544
|
+
|
|
545
|
+
### Focus Areas
|
|
546
|
+
- Confident code
|
|
547
|
+
- Tell, don't ask
|
|
548
|
+
- Null object pattern
|
|
549
|
+
- Duck typing
|
|
550
|
+
- Meaningful return values
|
|
551
|
+
|
|
552
|
+
### Critical Issues
|
|
553
|
+
|
|
554
|
+
#### 🔴 Nil Checks Throughout (recall.rb)
|
|
555
|
+
|
|
556
|
+
**Problem:**
|
|
557
|
+
```ruby
|
|
558
|
+
def explain(fact_id, scope: nil)
|
|
559
|
+
# ...
|
|
560
|
+
explain_from_store(store, fact_id)
|
|
561
|
+
end
|
|
562
|
+
|
|
563
|
+
def explain_from_store(store, fact_id)
|
|
564
|
+
fact = find_fact_from_store(store, fact_id)
|
|
565
|
+
return nil unless fact # Returning nil!
|
|
566
|
+
|
|
567
|
+
{
|
|
568
|
+
fact: fact,
|
|
569
|
+
receipts: find_receipts_from_store(store, fact_id),
|
|
570
|
+
# ...
|
|
571
|
+
}
|
|
572
|
+
end
|
|
573
|
+
```
|
|
574
|
+
|
|
575
|
+
**Issues:**
|
|
576
|
+
- Caller must check for nil
|
|
577
|
+
- Forces defensive programming everywhere
|
|
578
|
+
- No clear "not found" semantics
|
|
579
|
+
|
|
580
|
+
**Recommended Fix:**
|
|
581
|
+
```ruby
|
|
582
|
+
class NullExplanation
|
|
583
|
+
def fact
|
|
584
|
+
NullFact.new
|
|
585
|
+
end
|
|
586
|
+
|
|
587
|
+
def receipts
|
|
588
|
+
[]
|
|
589
|
+
end
|
|
590
|
+
|
|
591
|
+
def present?
|
|
592
|
+
false
|
|
593
|
+
end
|
|
594
|
+
end
|
|
595
|
+
|
|
596
|
+
def explain_from_store(store, fact_id)
|
|
597
|
+
fact = find_fact_from_store(store, fact_id)
|
|
598
|
+
return NullExplanation.new unless fact
|
|
599
|
+
|
|
600
|
+
Explanation.new(
|
|
601
|
+
fact: fact,
|
|
602
|
+
receipts: find_receipts_from_store(store, fact_id),
|
|
603
|
+
# ...
|
|
604
|
+
)
|
|
605
|
+
end
|
|
606
|
+
```
|
|
607
|
+
|
|
608
|
+
#### 🔴 Inconsistent Return Values
|
|
609
|
+
|
|
610
|
+
**Problem:** Different methods return different types:
|
|
611
|
+
```ruby
|
|
612
|
+
# Returns integer exit code
|
|
613
|
+
def ingest
|
|
614
|
+
# ...
|
|
615
|
+
0
|
|
616
|
+
end
|
|
617
|
+
|
|
618
|
+
# Returns hash
|
|
619
|
+
def promote_fact(fact_id)
|
|
620
|
+
# ...
|
|
621
|
+
global_fact_id
|
|
622
|
+
end
|
|
623
|
+
|
|
624
|
+
# Returns nil or hash
|
|
625
|
+
def explain_from_store(store, fact_id)
|
|
626
|
+
return nil unless fact
|
|
627
|
+
{ fact: fact, ... }
|
|
628
|
+
end
|
|
629
|
+
```
|
|
630
|
+
|
|
631
|
+
**Issues:**
|
|
632
|
+
- No consistent interface
|
|
633
|
+
- Callers can't rely on duck typing
|
|
634
|
+
- Some return success/failure, others return values
|
|
635
|
+
|
|
636
|
+
**Recommended Fix:**
|
|
637
|
+
Use result objects:
|
|
638
|
+
```ruby
|
|
639
|
+
class Result
|
|
640
|
+
def self.success(value)
|
|
641
|
+
Success.new(value)
|
|
642
|
+
end
|
|
643
|
+
|
|
644
|
+
def self.failure(error)
|
|
645
|
+
Failure.new(error)
|
|
646
|
+
end
|
|
647
|
+
end
|
|
648
|
+
|
|
649
|
+
def promote_fact(fact_id)
|
|
650
|
+
ensure_both!
|
|
651
|
+
|
|
652
|
+
fact = @project_store.facts.where(id: fact_id).first
|
|
653
|
+
return Result.failure("Fact not found") unless fact
|
|
654
|
+
|
|
655
|
+
global_fact_id = # ... promotion logic
|
|
656
|
+
Result.success(global_fact_id)
|
|
657
|
+
end
|
|
658
|
+
```
|
|
659
|
+
|
|
660
|
+
#### 🔴 Ask-Then-Do Pattern (publish.rb:165-171)
|
|
661
|
+
|
|
662
|
+
**Problem:**
|
|
663
|
+
```ruby
|
|
664
|
+
def should_write?(path, content)
|
|
665
|
+
return true unless File.exist?(path)
|
|
666
|
+
|
|
667
|
+
existing_hash = Digest::SHA256.file(path).hexdigest
|
|
668
|
+
new_hash = Digest::SHA256.hexdigest(content)
|
|
669
|
+
existing_hash != new_hash
|
|
670
|
+
end
|
|
671
|
+
|
|
672
|
+
# Usage:
|
|
673
|
+
if should_write?(path, content)
|
|
674
|
+
File.write(path, content)
|
|
675
|
+
end
|
|
676
|
+
```
|
|
677
|
+
|
|
678
|
+
**Issues:**
|
|
679
|
+
- Asking for permission, then doing action
|
|
680
|
+
- Should just "tell" the object to write
|
|
681
|
+
|
|
682
|
+
**Recommended Fix:**
|
|
683
|
+
```ruby
|
|
684
|
+
class SmartWriter
|
|
685
|
+
def write_if_changed(path, content)
|
|
686
|
+
return :unchanged if unchanged?(path, content)
|
|
687
|
+
|
|
688
|
+
File.write(path, content)
|
|
689
|
+
:written
|
|
690
|
+
end
|
|
691
|
+
|
|
692
|
+
private
|
|
693
|
+
|
|
694
|
+
def unchanged?(path, content)
|
|
695
|
+
File.exist?(path) &&
|
|
696
|
+
Digest::SHA256.file(path).hexdigest == Digest::SHA256.hexdigest(content)
|
|
697
|
+
end
|
|
698
|
+
end
|
|
699
|
+
```
|
|
700
|
+
|
|
701
|
+
#### 🟡 Early Returns Scattered (resolver.rb:60-73)
|
|
702
|
+
|
|
703
|
+
**Problem:**
|
|
704
|
+
```ruby
|
|
705
|
+
def resolve_fact(fact_data, entity_ids, content_item_id, occurred_at)
|
|
706
|
+
# ...
|
|
707
|
+
if PredicatePolicy.single?(predicate) && existing_facts.any?
|
|
708
|
+
matching = existing_facts.find { |f| values_match?(f, object_val, object_entity_id) }
|
|
709
|
+
if matching
|
|
710
|
+
add_provenance(matching[:id], content_item_id, fact_data)
|
|
711
|
+
outcome[:provenance] = 1
|
|
712
|
+
return outcome # Early return 1
|
|
713
|
+
elsif supersession_signal?(fact_data)
|
|
714
|
+
supersede_facts(existing_facts, occurred_at)
|
|
715
|
+
outcome[:superseded] = existing_facts.size
|
|
716
|
+
else
|
|
717
|
+
create_conflict(existing_facts.first[:id], fact_data, subject_id, content_item_id, occurred_at)
|
|
718
|
+
outcome[:conflicts] = 1
|
|
719
|
+
return outcome # Early return 2
|
|
720
|
+
end
|
|
721
|
+
end
|
|
722
|
+
|
|
723
|
+
# ... continues
|
|
724
|
+
end
|
|
725
|
+
```
|
|
726
|
+
|
|
727
|
+
**Issues:**
|
|
728
|
+
- Multiple exit points make flow hard to follow
|
|
729
|
+
- Hard to ensure cleanup
|
|
730
|
+
- Nested conditionals
|
|
731
|
+
|
|
732
|
+
**Recommended Fix:**
|
|
733
|
+
Extract to guard clauses at top:
|
|
734
|
+
```ruby
|
|
735
|
+
def resolve_fact(fact_data, entity_ids, content_item_id, occurred_at)
|
|
736
|
+
outcome = build_outcome
|
|
737
|
+
|
|
738
|
+
return handle_matching_fact(...) if matching_fact_exists?(...)
|
|
739
|
+
return handle_conflict(...) if conflicts_with_existing?(...)
|
|
740
|
+
|
|
741
|
+
create_new_fact(...)
|
|
742
|
+
end
|
|
743
|
+
```
|
|
744
|
+
|
|
745
|
+
#### 🟡 Primitive Obsession
|
|
746
|
+
|
|
747
|
+
**Problem:** Domain concepts represented as hashes:
|
|
748
|
+
```ruby
|
|
749
|
+
fact = {
|
|
750
|
+
subject_name: "repo",
|
|
751
|
+
predicate: "uses_database",
|
|
752
|
+
object_literal: "PostgreSQL",
|
|
753
|
+
status: "active",
|
|
754
|
+
confidence: 1.0
|
|
755
|
+
}
|
|
756
|
+
```
|
|
757
|
+
|
|
758
|
+
**Issues:**
|
|
759
|
+
- No domain behavior
|
|
760
|
+
- No validation
|
|
761
|
+
- No encapsulation
|
|
762
|
+
- Hard to refactor
|
|
763
|
+
|
|
764
|
+
**Recommended Fix:**
|
|
765
|
+
```ruby
|
|
766
|
+
class Fact
|
|
767
|
+
attr_reader :subject_name, :predicate, :object_literal, :status, :confidence
|
|
768
|
+
|
|
769
|
+
def initialize(subject_name:, predicate:, object_literal:, status: "active", confidence: 1.0)
|
|
770
|
+
@subject_name = subject_name
|
|
771
|
+
@predicate = predicate
|
|
772
|
+
@object_literal = object_literal
|
|
773
|
+
@status = status
|
|
774
|
+
@confidence = confidence
|
|
775
|
+
|
|
776
|
+
validate!
|
|
777
|
+
end
|
|
778
|
+
|
|
779
|
+
def active?
|
|
780
|
+
status == "active"
|
|
781
|
+
end
|
|
782
|
+
|
|
783
|
+
def superseded?
|
|
784
|
+
status == "superseded"
|
|
785
|
+
end
|
|
786
|
+
|
|
787
|
+
private
|
|
788
|
+
|
|
789
|
+
def validate!
|
|
790
|
+
raise ArgumentError, "predicate required" if predicate.nil?
|
|
791
|
+
raise ArgumentError, "confidence must be 0-1" unless (0..1).cover?(confidence)
|
|
792
|
+
end
|
|
793
|
+
end
|
|
794
|
+
```
|
|
795
|
+
|
|
796
|
+
---
|
|
797
|
+
|
|
798
|
+
## 5. Gary Bernhardt Perspective (Boundaries, Fast Tests)
|
|
799
|
+
|
|
800
|
+
### Focus Areas
|
|
801
|
+
- Functional core, imperative shell
|
|
802
|
+
- Fast unit tests
|
|
803
|
+
- Clear boundaries
|
|
804
|
+
- Separation of I/O and logic
|
|
805
|
+
- Value objects
|
|
806
|
+
|
|
807
|
+
### Critical Issues
|
|
808
|
+
|
|
809
|
+
#### 🔴 I/O Mixed with Logic Throughout CLI
|
|
810
|
+
|
|
811
|
+
**Problem:** Every CLI method mixes computation with I/O:
|
|
812
|
+
```ruby
|
|
813
|
+
def recall_cmd
|
|
814
|
+
query = @args[1]
|
|
815
|
+
unless query
|
|
816
|
+
@stderr.puts "Usage: ..." # I/O
|
|
817
|
+
return 1
|
|
818
|
+
end
|
|
819
|
+
|
|
820
|
+
opts = {limit: 10, scope: "all"} # Logic
|
|
821
|
+
OptionParser.new do |o| # I/O (arg parsing)
|
|
822
|
+
o.on("--limit N", Integer) { |v| opts[:limit] = v }
|
|
823
|
+
end
|
|
824
|
+
|
|
825
|
+
manager = ClaudeMemory::Store::StoreManager.new # I/O (database)
|
|
826
|
+
recall = ClaudeMemory::Recall.new(manager)
|
|
827
|
+
results = recall.query(query, limit: opts[:limit], scope: opts[:scope]) # Logic
|
|
828
|
+
|
|
829
|
+
if results.empty?
|
|
830
|
+
@stdout.puts "No facts found." # I/O
|
|
831
|
+
else
|
|
832
|
+
@stdout.puts "Found #{results.size} fact(s):\n\n" # I/O
|
|
833
|
+
results.each do |result|
|
|
834
|
+
print_fact(result[:fact]) # I/O
|
|
835
|
+
end
|
|
836
|
+
end
|
|
837
|
+
|
|
838
|
+
manager.close # I/O
|
|
839
|
+
0
|
|
840
|
+
end
|
|
841
|
+
```
|
|
842
|
+
|
|
843
|
+
**Issues:**
|
|
844
|
+
- Can't test logic without I/O
|
|
845
|
+
- Slow tests (database required)
|
|
846
|
+
- Hard to test error cases
|
|
847
|
+
- Can't reuse logic in different contexts
|
|
848
|
+
|
|
849
|
+
**Recommended Fix:**
|
|
850
|
+
Functional core:
|
|
851
|
+
```ruby
|
|
852
|
+
module ClaudeMemory
|
|
853
|
+
module Core
|
|
854
|
+
class RecallQuery
|
|
855
|
+
def self.call(query:, limit:, scope:, facts_repository:)
|
|
856
|
+
facts = facts_repository.search(query, limit: limit, scope: scope)
|
|
857
|
+
|
|
858
|
+
{
|
|
859
|
+
found: facts.any?,
|
|
860
|
+
count: facts.size,
|
|
861
|
+
facts: facts.map { |f| FactPresenter.new(f) }
|
|
862
|
+
}
|
|
863
|
+
end
|
|
864
|
+
end
|
|
865
|
+
end
|
|
866
|
+
end
|
|
867
|
+
```
|
|
868
|
+
|
|
869
|
+
Imperative shell:
|
|
870
|
+
```ruby
|
|
871
|
+
def recall_cmd
|
|
872
|
+
params = RecallParams.parse(@args)
|
|
873
|
+
return usage_error unless params.valid?
|
|
874
|
+
|
|
875
|
+
manager = StoreManager.new
|
|
876
|
+
result = Core::RecallQuery.call(
|
|
877
|
+
query: params.query,
|
|
878
|
+
limit: params.limit,
|
|
879
|
+
scope: params.scope,
|
|
880
|
+
facts_repository: FactsRepository.new(manager)
|
|
881
|
+
)
|
|
882
|
+
|
|
883
|
+
output_result(result)
|
|
884
|
+
manager.close
|
|
885
|
+
0
|
|
886
|
+
end
|
|
887
|
+
```
|
|
888
|
+
|
|
889
|
+
**Benefits:**
|
|
890
|
+
- Core logic is pure (no I/O)
|
|
891
|
+
- Fast unit tests for core
|
|
892
|
+
- Shell handles all I/O
|
|
893
|
+
- Easy to test edge cases
|
|
894
|
+
|
|
895
|
+
#### 🔴 No Value Objects
|
|
896
|
+
|
|
897
|
+
**Problem:** Primitive types used everywhere:
|
|
898
|
+
```ruby
|
|
899
|
+
def ingest(source:, session_id:, transcript_path:, project_path: nil)
|
|
900
|
+
# All strings - no domain meaning
|
|
901
|
+
end
|
|
902
|
+
```
|
|
903
|
+
|
|
904
|
+
**Issues:**
|
|
905
|
+
- No type safety
|
|
906
|
+
- Easy to swap arguments
|
|
907
|
+
- No validation
|
|
908
|
+
- No domain behavior
|
|
909
|
+
|
|
910
|
+
**Recommended Fix:**
|
|
911
|
+
```ruby
|
|
912
|
+
class SessionId
|
|
913
|
+
attr_reader :value
|
|
914
|
+
|
|
915
|
+
def initialize(value)
|
|
916
|
+
@value = value
|
|
917
|
+
validate!
|
|
918
|
+
end
|
|
919
|
+
|
|
920
|
+
def to_s
|
|
921
|
+
value
|
|
922
|
+
end
|
|
923
|
+
|
|
924
|
+
private
|
|
925
|
+
|
|
926
|
+
def validate!
|
|
927
|
+
raise ArgumentError, "Session ID cannot be empty" if value.nil? || value.empty?
|
|
928
|
+
end
|
|
929
|
+
end
|
|
930
|
+
|
|
931
|
+
class TranscriptPath
|
|
932
|
+
attr_reader :value
|
|
933
|
+
|
|
934
|
+
def initialize(value)
|
|
935
|
+
@value = Pathname.new(value)
|
|
936
|
+
validate!
|
|
937
|
+
end
|
|
938
|
+
|
|
939
|
+
def exist?
|
|
940
|
+
value.exist?
|
|
941
|
+
end
|
|
942
|
+
|
|
943
|
+
private
|
|
944
|
+
|
|
945
|
+
def validate!
|
|
946
|
+
raise ArgumentError, "Path cannot be nil" if value.nil?
|
|
947
|
+
end
|
|
948
|
+
end
|
|
949
|
+
|
|
950
|
+
# Usage:
|
|
951
|
+
def ingest(source:, session_id:, transcript_path:, project_path: nil)
|
|
952
|
+
session_id = SessionId.new(session_id) unless session_id.is_a?(SessionId)
|
|
953
|
+
transcript_path = TranscriptPath.new(transcript_path) unless transcript_path.is_a?(TranscriptPath)
|
|
954
|
+
|
|
955
|
+
# Now have type safety and validation
|
|
956
|
+
end
|
|
957
|
+
```
|
|
958
|
+
|
|
959
|
+
#### 🔴 Direct File I/O in Business Logic
|
|
960
|
+
|
|
961
|
+
**Problem:** Publish class directly reads/writes files:
|
|
962
|
+
```ruby
|
|
963
|
+
def should_write?(path, content)
|
|
964
|
+
return true unless File.exist?(path) # Direct file I/O
|
|
965
|
+
|
|
966
|
+
existing_hash = Digest::SHA256.file(path).hexdigest # Direct file I/O
|
|
967
|
+
# ...
|
|
968
|
+
end
|
|
969
|
+
|
|
970
|
+
def ensure_import_exists(mode, path)
|
|
971
|
+
if File.exist?(claude_md) # Direct file I/O
|
|
972
|
+
content = File.read(claude_md) # Direct file I/O
|
|
973
|
+
# ...
|
|
974
|
+
end
|
|
975
|
+
end
|
|
976
|
+
```
|
|
977
|
+
|
|
978
|
+
**Issues:**
|
|
979
|
+
- Can't test without filesystem
|
|
980
|
+
- Slow tests
|
|
981
|
+
- Hard to test error conditions
|
|
982
|
+
|
|
983
|
+
**Recommended Fix:**
|
|
984
|
+
Inject file system adapter:
|
|
985
|
+
```ruby
|
|
986
|
+
class FileSystem
|
|
987
|
+
def exist?(path)
|
|
988
|
+
File.exist?(path)
|
|
989
|
+
end
|
|
990
|
+
|
|
991
|
+
def read(path)
|
|
992
|
+
File.read(path)
|
|
993
|
+
end
|
|
994
|
+
|
|
995
|
+
def write(path, content)
|
|
996
|
+
File.write(path, content)
|
|
997
|
+
end
|
|
998
|
+
|
|
999
|
+
def file_hash(path)
|
|
1000
|
+
Digest::SHA256.file(path).hexdigest
|
|
1001
|
+
end
|
|
1002
|
+
end
|
|
1003
|
+
|
|
1004
|
+
class InMemoryFileSystem
|
|
1005
|
+
def initialize
|
|
1006
|
+
@files = {}
|
|
1007
|
+
end
|
|
1008
|
+
|
|
1009
|
+
def exist?(path)
|
|
1010
|
+
@files.key?(path)
|
|
1011
|
+
end
|
|
1012
|
+
|
|
1013
|
+
def read(path)
|
|
1014
|
+
@files.fetch(path) { raise Errno::ENOENT }
|
|
1015
|
+
end
|
|
1016
|
+
|
|
1017
|
+
def write(path, content)
|
|
1018
|
+
@files[path] = content
|
|
1019
|
+
end
|
|
1020
|
+
|
|
1021
|
+
def file_hash(path)
|
|
1022
|
+
content = read(path)
|
|
1023
|
+
Digest::SHA256.hexdigest(content)
|
|
1024
|
+
end
|
|
1025
|
+
end
|
|
1026
|
+
|
|
1027
|
+
class Publish
|
|
1028
|
+
def initialize(store, file_system: FileSystem.new)
|
|
1029
|
+
@store = store
|
|
1030
|
+
@file_system = file_system
|
|
1031
|
+
end
|
|
1032
|
+
|
|
1033
|
+
def should_write?(path, content)
|
|
1034
|
+
return true unless @file_system.exist?(path)
|
|
1035
|
+
|
|
1036
|
+
existing_hash = @file_system.file_hash(path)
|
|
1037
|
+
new_hash = Digest::SHA256.hexdigest(content)
|
|
1038
|
+
existing_hash != new_hash
|
|
1039
|
+
end
|
|
1040
|
+
end
|
|
1041
|
+
```
|
|
1042
|
+
|
|
1043
|
+
**Test:**
|
|
1044
|
+
```ruby
|
|
1045
|
+
RSpec.describe Publish do
|
|
1046
|
+
it "writes when file doesn't exist" do
|
|
1047
|
+
fs = InMemoryFileSystem.new
|
|
1048
|
+
store = double(:store)
|
|
1049
|
+
publish = Publish.new(store, file_system: fs)
|
|
1050
|
+
|
|
1051
|
+
# Fast, no real filesystem
|
|
1052
|
+
end
|
|
1053
|
+
end
|
|
1054
|
+
```
|
|
1055
|
+
|
|
1056
|
+
#### 🔴 State Stored in Instance Variables (resolver.rb:10-13)
|
|
1057
|
+
|
|
1058
|
+
**Problem:**
|
|
1059
|
+
```ruby
|
|
1060
|
+
def apply(extraction, content_item_id: nil, occurred_at: nil, project_path: nil, scope: "project")
|
|
1061
|
+
occurred_at ||= Time.now.utc.iso8601
|
|
1062
|
+
@current_project_path = project_path # Mutable state!
|
|
1063
|
+
@current_scope = scope # Mutable state!
|
|
1064
|
+
|
|
1065
|
+
# Used in private methods
|
|
1066
|
+
end
|
|
1067
|
+
|
|
1068
|
+
def resolve_fact(fact_data, entity_ids, content_item_id, occurred_at)
|
|
1069
|
+
# ... uses @current_project_path and @current_scope
|
|
1070
|
+
fact_scope = fact_data[:scope_hint] || @current_scope
|
|
1071
|
+
fact_project = (fact_scope == "global") ? nil : @current_project_path
|
|
1072
|
+
end
|
|
1073
|
+
```
|
|
1074
|
+
|
|
1075
|
+
**Issues:**
|
|
1076
|
+
- Hidden coupling between methods
|
|
1077
|
+
- Stateful object (not thread-safe)
|
|
1078
|
+
- Hard to reason about
|
|
1079
|
+
- Side effects on instance
|
|
1080
|
+
|
|
1081
|
+
**Recommended Fix:**
|
|
1082
|
+
Pass as parameters:
|
|
1083
|
+
```ruby
|
|
1084
|
+
def apply(extraction, content_item_id: nil, occurred_at: nil, project_path: nil, scope: "project")
|
|
1085
|
+
occurred_at ||= Time.now.utc.iso8601
|
|
1086
|
+
|
|
1087
|
+
context = ResolutionContext.new(
|
|
1088
|
+
project_path: project_path,
|
|
1089
|
+
scope: scope,
|
|
1090
|
+
occurred_at: occurred_at
|
|
1091
|
+
)
|
|
1092
|
+
|
|
1093
|
+
result = build_result
|
|
1094
|
+
|
|
1095
|
+
extraction.facts.each do |fact_data|
|
|
1096
|
+
outcome = resolve_fact(fact_data, entity_ids, content_item_id, context)
|
|
1097
|
+
merge_outcome!(result, outcome)
|
|
1098
|
+
end
|
|
1099
|
+
|
|
1100
|
+
result
|
|
1101
|
+
end
|
|
1102
|
+
|
|
1103
|
+
def resolve_fact(fact_data, entity_ids, content_item_id, context)
|
|
1104
|
+
# Uses context parameter instead of instance variables
|
|
1105
|
+
fact_scope = fact_data[:scope_hint] || context.scope
|
|
1106
|
+
fact_project = (fact_scope == "global") ? nil : context.project_path
|
|
1107
|
+
end
|
|
1108
|
+
```
|
|
1109
|
+
|
|
1110
|
+
#### 🟡 No Clear Layer Boundaries
|
|
1111
|
+
|
|
1112
|
+
**Problem:** Classes don't follow clear architectural layers:
|
|
1113
|
+
```
|
|
1114
|
+
CLI → creates Store directly
|
|
1115
|
+
CLI → creates Ingester directly
|
|
1116
|
+
Ingester → creates FTS index
|
|
1117
|
+
Publish → reads files
|
|
1118
|
+
Hook::Handler → creates dependencies
|
|
1119
|
+
```
|
|
1120
|
+
|
|
1121
|
+
**Recommended Architecture:**
|
|
1122
|
+
```
|
|
1123
|
+
Presentation Layer (CLI, HTTP)
|
|
1124
|
+
↓
|
|
1125
|
+
Application Layer (Use Cases / Commands)
|
|
1126
|
+
↓
|
|
1127
|
+
Domain Layer (Core business logic - pure)
|
|
1128
|
+
↓
|
|
1129
|
+
Infrastructure Layer (Database, Files, External APIs)
|
|
1130
|
+
```
|
|
1131
|
+
|
|
1132
|
+
**Example:**
|
|
1133
|
+
```ruby
|
|
1134
|
+
# Domain Layer - Pure logic
|
|
1135
|
+
module ClaudeMemory
|
|
1136
|
+
module Domain
|
|
1137
|
+
class Fact
|
|
1138
|
+
# Pure domain object
|
|
1139
|
+
end
|
|
1140
|
+
|
|
1141
|
+
class FactRepository
|
|
1142
|
+
# Interface (abstract)
|
|
1143
|
+
def find(id)
|
|
1144
|
+
raise NotImplementedError
|
|
1145
|
+
end
|
|
1146
|
+
|
|
1147
|
+
def save(fact)
|
|
1148
|
+
raise NotImplementedError
|
|
1149
|
+
end
|
|
1150
|
+
end
|
|
1151
|
+
end
|
|
1152
|
+
end
|
|
1153
|
+
|
|
1154
|
+
# Infrastructure Layer
|
|
1155
|
+
module ClaudeMemory
|
|
1156
|
+
module Infrastructure
|
|
1157
|
+
class SequelFactRepository < Domain::FactRepository
|
|
1158
|
+
def initialize(db)
|
|
1159
|
+
@db = db
|
|
1160
|
+
end
|
|
1161
|
+
|
|
1162
|
+
def find(id)
|
|
1163
|
+
# Sequel-specific implementation
|
|
1164
|
+
end
|
|
1165
|
+
|
|
1166
|
+
def save(fact)
|
|
1167
|
+
# Sequel-specific implementation
|
|
1168
|
+
end
|
|
1169
|
+
end
|
|
1170
|
+
end
|
|
1171
|
+
end
|
|
1172
|
+
|
|
1173
|
+
# Application Layer
|
|
1174
|
+
module ClaudeMemory
|
|
1175
|
+
module Application
|
|
1176
|
+
class PromoteFact
|
|
1177
|
+
def initialize(fact_repository:, event_publisher:)
|
|
1178
|
+
@fact_repository = fact_repository
|
|
1179
|
+
@event_publisher = event_publisher
|
|
1180
|
+
end
|
|
1181
|
+
|
|
1182
|
+
def call(fact_id)
|
|
1183
|
+
fact = @fact_repository.find(fact_id)
|
|
1184
|
+
return Result.failure("Not found") unless fact
|
|
1185
|
+
|
|
1186
|
+
promoted = fact.promote_to_global
|
|
1187
|
+
@fact_repository.save(promoted)
|
|
1188
|
+
@event_publisher.publish(FactPromoted.new(fact_id))
|
|
1189
|
+
|
|
1190
|
+
Result.success(promoted.id)
|
|
1191
|
+
end
|
|
1192
|
+
end
|
|
1193
|
+
end
|
|
1194
|
+
end
|
|
1195
|
+
|
|
1196
|
+
# Presentation Layer
|
|
1197
|
+
class CLI
|
|
1198
|
+
def promote_cmd
|
|
1199
|
+
fact_id = @args[1]&.to_i
|
|
1200
|
+
return usage_error unless valid_fact_id?(fact_id)
|
|
1201
|
+
|
|
1202
|
+
result = @promote_fact_use_case.call(fact_id)
|
|
1203
|
+
|
|
1204
|
+
if result.success?
|
|
1205
|
+
@stdout.puts "Promoted fact ##{fact_id}"
|
|
1206
|
+
0
|
|
1207
|
+
else
|
|
1208
|
+
@stderr.puts result.error
|
|
1209
|
+
1
|
|
1210
|
+
end
|
|
1211
|
+
end
|
|
1212
|
+
end
|
|
1213
|
+
```
|
|
1214
|
+
|
|
1215
|
+
---
|
|
1216
|
+
|
|
1217
|
+
## 6. General Ruby Idioms and Style Issues
|
|
1218
|
+
|
|
1219
|
+
### 🟡 Inconsistent Method Call Parentheses
|
|
1220
|
+
|
|
1221
|
+
**Problem:**
|
|
1222
|
+
```ruby
|
|
1223
|
+
@stdout.puts "Message" # No parens
|
|
1224
|
+
print_help # No parens
|
|
1225
|
+
manager.close # No parens
|
|
1226
|
+
opts = {limit: 10, scope: "all"} # No parens
|
|
1227
|
+
|
|
1228
|
+
OptionParser.new do |o| # Parens with block
|
|
1229
|
+
o.on("--limit N", Integer) { |v| opts[:limit] = v } # Parens
|
|
1230
|
+
end
|
|
1231
|
+
|
|
1232
|
+
manager = ClaudeMemory::Store::StoreManager.new # Parens
|
|
1233
|
+
```
|
|
1234
|
+
|
|
1235
|
+
**Recommendation:** Be consistent. Common Ruby style:
|
|
1236
|
+
- Use parens for methods with arguments
|
|
1237
|
+
- Omit for methods without arguments
|
|
1238
|
+
- Omit for keywords (`puts`, `print`, `raise`)
|
|
1239
|
+
|
|
1240
|
+
### 🟡 Long Parameter Lists
|
|
1241
|
+
|
|
1242
|
+
**Problem:**
|
|
1243
|
+
```ruby
|
|
1244
|
+
def upsert_content_item(source:, text_hash:, byte_len:, session_id: nil, transcript_path: nil,
|
|
1245
|
+
project_path: nil, occurred_at: nil, raw_text: nil, metadata: nil)
|
|
1246
|
+
# 9 parameters!
|
|
1247
|
+
end
|
|
1248
|
+
|
|
1249
|
+
def insert_fact(subject_entity_id:, predicate:, object_entity_id: nil, object_literal: nil,
|
|
1250
|
+
datatype: nil, polarity: "positive", valid_from: nil, status: "active",
|
|
1251
|
+
confidence: 1.0, created_from: nil, scope: "project", project_path: nil)
|
|
1252
|
+
# 12 parameters!
|
|
1253
|
+
end
|
|
1254
|
+
```
|
|
1255
|
+
|
|
1256
|
+
**Recommendation:** Use parameter objects:
|
|
1257
|
+
```ruby
|
|
1258
|
+
class ContentItemParams
|
|
1259
|
+
attr_reader :source, :text_hash, :byte_len, :session_id, :transcript_path,
|
|
1260
|
+
:project_path, :occurred_at, :raw_text, :metadata
|
|
1261
|
+
|
|
1262
|
+
def initialize(source:, text_hash:, byte_len:, **optional)
|
|
1263
|
+
@source = source
|
|
1264
|
+
@text_hash = text_hash
|
|
1265
|
+
@byte_len = byte_len
|
|
1266
|
+
@session_id = optional[:session_id]
|
|
1267
|
+
# ... etc
|
|
1268
|
+
end
|
|
1269
|
+
end
|
|
1270
|
+
|
|
1271
|
+
def upsert_content_item(params)
|
|
1272
|
+
# Much cleaner
|
|
1273
|
+
end
|
|
1274
|
+
```
|
|
1275
|
+
|
|
1276
|
+
### 🟡 Mixed Hash Access (Symbols vs Strings)
|
|
1277
|
+
|
|
1278
|
+
**Problem:**
|
|
1279
|
+
```ruby
|
|
1280
|
+
# MCP Server
|
|
1281
|
+
request["id"] # String key
|
|
1282
|
+
request["method"] # String key
|
|
1283
|
+
|
|
1284
|
+
# Domain
|
|
1285
|
+
fact[:subject_name] # Symbol key
|
|
1286
|
+
fact[:predicate] # Symbol key
|
|
1287
|
+
```
|
|
1288
|
+
|
|
1289
|
+
**Recommendation:** Be consistent. Use symbols for internal hashes, strings for external JSON.
|
|
1290
|
+
|
|
1291
|
+
### 🟡 Rescue Without Specific Exception
|
|
1292
|
+
|
|
1293
|
+
**Problem:**
|
|
1294
|
+
```ruby
|
|
1295
|
+
begin
|
|
1296
|
+
store = ClaudeMemory::Store::SQLiteStore.new(db_path)
|
|
1297
|
+
# ...
|
|
1298
|
+
rescue => e # Catches everything!
|
|
1299
|
+
issues << "#{label} database error: #{e.message}"
|
|
1300
|
+
end
|
|
1301
|
+
```
|
|
1302
|
+
|
|
1303
|
+
**Recommendation:** Catch specific exceptions:
|
|
1304
|
+
```ruby
|
|
1305
|
+
rescue Sequel::DatabaseError, SQLite3::Exception => e
|
|
1306
|
+
issues << "#{label} database error: #{e.message}"
|
|
1307
|
+
end
|
|
1308
|
+
```
|
|
1309
|
+
|
|
1310
|
+
### 🟡 ENV Access Scattered Throughout
|
|
1311
|
+
|
|
1312
|
+
**Problem:**
|
|
1313
|
+
```ruby
|
|
1314
|
+
# claude_memory.rb:28
|
|
1315
|
+
home = env["HOME"] || File.expand_path("~")
|
|
1316
|
+
|
|
1317
|
+
# store_manager.rb:11
|
|
1318
|
+
@project_path = project_path || env["CLAUDE_PROJECT_DIR"] || Dir.pwd
|
|
1319
|
+
|
|
1320
|
+
# hook/handler.rb:16
|
|
1321
|
+
session_id = payload["session_id"] || @env["CLAUDE_SESSION_ID"]
|
|
1322
|
+
```
|
|
1323
|
+
|
|
1324
|
+
**Recommendation:** Centralize environment access:
|
|
1325
|
+
```ruby
|
|
1326
|
+
module ClaudeMemory
|
|
1327
|
+
class Configuration
|
|
1328
|
+
def initialize(env = ENV)
|
|
1329
|
+
@env = env
|
|
1330
|
+
end
|
|
1331
|
+
|
|
1332
|
+
def home_dir
|
|
1333
|
+
@env["HOME"] || File.expand_path("~")
|
|
1334
|
+
end
|
|
1335
|
+
|
|
1336
|
+
def project_dir
|
|
1337
|
+
@env["CLAUDE_PROJECT_DIR"] || Dir.pwd
|
|
1338
|
+
end
|
|
1339
|
+
|
|
1340
|
+
def session_id
|
|
1341
|
+
@env["CLAUDE_SESSION_ID"]
|
|
1342
|
+
end
|
|
1343
|
+
end
|
|
1344
|
+
end
|
|
1345
|
+
```
|
|
1346
|
+
|
|
1347
|
+
### 🟡 Boolean Traps
|
|
1348
|
+
|
|
1349
|
+
**Problem:**
|
|
1350
|
+
```ruby
|
|
1351
|
+
opts = {global: false, project: false}
|
|
1352
|
+
|
|
1353
|
+
# What does this mean?
|
|
1354
|
+
manager.ensure_global!
|
|
1355
|
+
manager.ensure_project!
|
|
1356
|
+
|
|
1357
|
+
# What does true/false mean here?
|
|
1358
|
+
if opts[:global]
|
|
1359
|
+
# ...
|
|
1360
|
+
end
|
|
1361
|
+
```
|
|
1362
|
+
|
|
1363
|
+
**Recommendation:** Use explicit values:
|
|
1364
|
+
```ruby
|
|
1365
|
+
scope = opts[:scope] # :global, :project, or :both
|
|
1366
|
+
|
|
1367
|
+
case scope
|
|
1368
|
+
when :global
|
|
1369
|
+
manager.ensure_global!
|
|
1370
|
+
when :project
|
|
1371
|
+
manager.ensure_project!
|
|
1372
|
+
when :both
|
|
1373
|
+
manager.ensure_both!
|
|
1374
|
+
end
|
|
1375
|
+
```
|
|
1376
|
+
|
|
1377
|
+
### 🟡 No Use of Ruby 3 Features
|
|
1378
|
+
|
|
1379
|
+
**Observations:**
|
|
1380
|
+
- No pattern matching (available since Ruby 2.7)
|
|
1381
|
+
- No rightward assignment
|
|
1382
|
+
- No endless method definitions
|
|
1383
|
+
- No type annotations (RBS/Sorbet)
|
|
1384
|
+
|
|
1385
|
+
**Opportunities:**
|
|
1386
|
+
```ruby
|
|
1387
|
+
# Current
|
|
1388
|
+
case command
|
|
1389
|
+
when "help", "-h", "--help"
|
|
1390
|
+
print_help
|
|
1391
|
+
when "version", "-v", "--version"
|
|
1392
|
+
print_version
|
|
1393
|
+
end
|
|
1394
|
+
|
|
1395
|
+
# With pattern matching
|
|
1396
|
+
case command
|
|
1397
|
+
in "help" | "-h" | "--help"
|
|
1398
|
+
print_help
|
|
1399
|
+
in "version" | "-v" | "--version"
|
|
1400
|
+
print_version
|
|
1401
|
+
in unknown
|
|
1402
|
+
handle_unknown(unknown)
|
|
1403
|
+
end
|
|
1404
|
+
|
|
1405
|
+
# Current
|
|
1406
|
+
def valid?(fact)
|
|
1407
|
+
fact[:predicate] && fact[:subject_entity_id]
|
|
1408
|
+
end
|
|
1409
|
+
|
|
1410
|
+
# Endless method
|
|
1411
|
+
def valid?(fact) = fact[:predicate] && fact[:subject_entity_id]
|
|
1412
|
+
```
|
|
1413
|
+
|
|
1414
|
+
---
|
|
1415
|
+
|
|
1416
|
+
## 7. Positive Observations
|
|
1417
|
+
|
|
1418
|
+
Despite the issues above, this codebase has several strengths:
|
|
1419
|
+
|
|
1420
|
+
### ✅ Good Practices
|
|
1421
|
+
|
|
1422
|
+
1. **Frozen String Literals** - Every file has `# frozen_string_literal: true`
|
|
1423
|
+
2. **Consistent Sequel Usage** - Most of the time uses Sequel datasets properly
|
|
1424
|
+
3. **Explicit Dependencies** - Constructor injection used (though inconsistently)
|
|
1425
|
+
4. **Module Namespacing** - Good use of nested modules
|
|
1426
|
+
5. **Test Coverage** - Spec files exist for most modules
|
|
1427
|
+
6. **Documentation** - Good README and CLAUDE.md files
|
|
1428
|
+
7. **Schema Versioning** - Database has schema version tracking
|
|
1429
|
+
8. **Error Classes** - Custom error classes defined
|
|
1430
|
+
9. **Keyword Arguments** - Modern Ruby style with keyword arguments
|
|
1431
|
+
10. **FTS Integration** - Good use of SQLite's FTS5 capabilities
|
|
1432
|
+
|
|
1433
|
+
---
|
|
1434
|
+
|
|
1435
|
+
## 8. Priority Refactoring Recommendations
|
|
1436
|
+
|
|
1437
|
+
### High Priority (Week 1-2)
|
|
1438
|
+
|
|
1439
|
+
1. **Extract CLI Command Objects**
|
|
1440
|
+
- Target: Reduce cli.rb from 867 lines to < 200
|
|
1441
|
+
- Extract each command to separate class
|
|
1442
|
+
- Use command pattern
|
|
1443
|
+
|
|
1444
|
+
2. **Add Transaction Safety**
|
|
1445
|
+
- Wrap `promote_fact` in transaction
|
|
1446
|
+
- Wrap resolver operations in transactions
|
|
1447
|
+
- Add rollback tests
|
|
1448
|
+
|
|
1449
|
+
3. **Fix Raw SQL in doctor_cmd**
|
|
1450
|
+
- Replace with Sequel dataset methods
|
|
1451
|
+
- Ensures consistency
|
|
1452
|
+
|
|
1453
|
+
4. **Separate I/O from Logic in Core Classes**
|
|
1454
|
+
- Start with Recall, Publish
|
|
1455
|
+
- Extract functional core
|
|
1456
|
+
- Make imperativeshell thin
|
|
1457
|
+
|
|
1458
|
+
### Medium Priority (Week 3-4)
|
|
1459
|
+
|
|
1460
|
+
5. **Introduce Value Objects**
|
|
1461
|
+
- SessionId, TranscriptPath, FactId
|
|
1462
|
+
- Adds type safety
|
|
1463
|
+
- Documents domain
|
|
1464
|
+
|
|
1465
|
+
6. **Replace Nil Returns with Null Objects**
|
|
1466
|
+
- NullExplanation, NullFact
|
|
1467
|
+
- Enables confident code
|
|
1468
|
+
- Reduces nil checks
|
|
1469
|
+
|
|
1470
|
+
7. **Extract Repository Pattern**
|
|
1471
|
+
- FactRepository, EntityRepository
|
|
1472
|
+
- Abstracts data access
|
|
1473
|
+
- Enables testing without database
|
|
1474
|
+
|
|
1475
|
+
8. **Split Recall into Legacy/Dual**
|
|
1476
|
+
- Remove conditional mode logic
|
|
1477
|
+
- Clearer single responsibility
|
|
1478
|
+
- Easier to maintain
|
|
1479
|
+
|
|
1480
|
+
### Low Priority (Week 5+)
|
|
1481
|
+
|
|
1482
|
+
9. **Add Domain Models**
|
|
1483
|
+
- Fact, Entity, Provenance classes
|
|
1484
|
+
- Rich domain behavior
|
|
1485
|
+
- Replace primitive hashes
|
|
1486
|
+
|
|
1487
|
+
10. **Introduce Proper Migrations**
|
|
1488
|
+
- Use Sequel migration framework
|
|
1489
|
+
- Versioned, reversible
|
|
1490
|
+
- Development/production parity
|
|
1491
|
+
|
|
1492
|
+
11. **Add Type Annotations**
|
|
1493
|
+
- Consider RBS or Sorbet
|
|
1494
|
+
- Better IDE support
|
|
1495
|
+
- Catches type errors early
|
|
1496
|
+
|
|
1497
|
+
12. **Centralize Configuration**
|
|
1498
|
+
- Configuration class
|
|
1499
|
+
- Environment variable access
|
|
1500
|
+
- Testable, mockable
|
|
1501
|
+
|
|
1502
|
+
---
|
|
1503
|
+
|
|
1504
|
+
## 9. Conclusion
|
|
1505
|
+
|
|
1506
|
+
This codebase shows solid Ruby fundamentals but suffers from common growing pains: God Objects, mixed concerns, and lack of architectural boundaries. The issues are fixable and follow predictable patterns.
|
|
1507
|
+
|
|
1508
|
+
**Key Takeaways:**
|
|
1509
|
+
1. **CLI needs major refactoring** - Extract command objects
|
|
1510
|
+
2. **Separate I/O from logic** - Enable fast tests
|
|
1511
|
+
3. **Use transactions** - Data integrity
|
|
1512
|
+
4. **Introduce domain objects** - Replace primitive hashes
|
|
1513
|
+
5. **Adopt null object pattern** - Reduce nil checks
|
|
1514
|
+
|
|
1515
|
+
**Estimated Refactoring Effort:**
|
|
1516
|
+
- High priority: 2 weeks (1 developer)
|
|
1517
|
+
- Medium priority: 2 weeks (1 developer)
|
|
1518
|
+
- Low priority: 1-2 weeks (1 developer)
|
|
1519
|
+
- Total: 5-6 weeks for comprehensive refactoring
|
|
1520
|
+
|
|
1521
|
+
**Risk Assessment:** Low-to-medium risk. Changes are incremental and testable. Existing test suite provides safety net.
|
|
1522
|
+
|
|
1523
|
+
---
|
|
1524
|
+
|
|
1525
|
+
## Appendix A: Recommended Reading
|
|
1526
|
+
|
|
1527
|
+
1. **Sandi Metz** - _Practical Object-Oriented Design in Ruby_ (POODR)
|
|
1528
|
+
2. **Jeremy Evans** - _Sequel Documentation_ and _Roda Book_
|
|
1529
|
+
3. **Kent Beck** - _Test-Driven Development: By Example_
|
|
1530
|
+
4. **Avdi Grimm** - _Confident Ruby_
|
|
1531
|
+
5. **Gary Bernhardt** - _Boundaries_ talk, _Destroy All Software_ screencasts
|
|
1532
|
+
6. **Martin Fowler** - _Refactoring: Ruby Edition_
|
|
1533
|
+
|
|
1534
|
+
## Appendix B: Quick Wins (Can Do Today)
|
|
1535
|
+
|
|
1536
|
+
1. Fix raw SQL in `doctor_cmd` (20 minutes)
|
|
1537
|
+
2. Consolidate `attr_reader` in StoreManager (5 minutes)
|
|
1538
|
+
3. Fix boolean logic in `parse_db_init_options` (10 minutes)
|
|
1539
|
+
4. Move `public` declaration in SQLiteStore (2 minutes)
|
|
1540
|
+
5. Extract long methods in CLI (1 hour per method)
|
|
1541
|
+
|
|
1542
|
+
---
|
|
1543
|
+
|
|
1544
|
+
**Review completed:** 2026-01-21
|