rubyn-code 0.1.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 +7 -0
- data/LICENSE +21 -0
- data/README.md +620 -0
- data/db/migrations/000_create_schema_migrations.sql +4 -0
- data/db/migrations/001_create_sessions.sql +16 -0
- data/db/migrations/002_create_messages.sql +16 -0
- data/db/migrations/003_create_tasks.sql +17 -0
- data/db/migrations/004_create_task_dependencies.sql +8 -0
- data/db/migrations/005_create_memories.sql +44 -0
- data/db/migrations/006_create_cost_records.sql +16 -0
- data/db/migrations/007_create_hooks.sql +12 -0
- data/db/migrations/008_create_skills_cache.sql +8 -0
- data/db/migrations/009_create_teams.sql +27 -0
- data/db/migrations/010_create_instincts.sql +15 -0
- data/exe/rubyn-code +6 -0
- data/lib/rubyn_code/agent/conversation.rb +193 -0
- data/lib/rubyn_code/agent/loop.rb +517 -0
- data/lib/rubyn_code/agent/loop_detector.rb +78 -0
- data/lib/rubyn_code/auth/oauth.rb +174 -0
- data/lib/rubyn_code/auth/server.rb +126 -0
- data/lib/rubyn_code/auth/token_store.rb +153 -0
- data/lib/rubyn_code/autonomous/daemon.rb +233 -0
- data/lib/rubyn_code/autonomous/idle_poller.rb +111 -0
- data/lib/rubyn_code/autonomous/task_claimer.rb +100 -0
- data/lib/rubyn_code/background/job.rb +19 -0
- data/lib/rubyn_code/background/notifier.rb +44 -0
- data/lib/rubyn_code/background/worker.rb +146 -0
- data/lib/rubyn_code/cli/app.rb +118 -0
- data/lib/rubyn_code/cli/input_handler.rb +79 -0
- data/lib/rubyn_code/cli/renderer.rb +205 -0
- data/lib/rubyn_code/cli/repl.rb +519 -0
- data/lib/rubyn_code/cli/spinner.rb +100 -0
- data/lib/rubyn_code/cli/stream_formatter.rb +149 -0
- data/lib/rubyn_code/config/defaults.rb +43 -0
- data/lib/rubyn_code/config/project_config.rb +120 -0
- data/lib/rubyn_code/config/settings.rb +127 -0
- data/lib/rubyn_code/context/auto_compact.rb +81 -0
- data/lib/rubyn_code/context/compactor.rb +89 -0
- data/lib/rubyn_code/context/manager.rb +91 -0
- data/lib/rubyn_code/context/manual_compact.rb +87 -0
- data/lib/rubyn_code/context/micro_compact.rb +135 -0
- data/lib/rubyn_code/db/connection.rb +176 -0
- data/lib/rubyn_code/db/migrator.rb +146 -0
- data/lib/rubyn_code/db/schema.rb +106 -0
- data/lib/rubyn_code/hooks/built_in.rb +124 -0
- data/lib/rubyn_code/hooks/registry.rb +99 -0
- data/lib/rubyn_code/hooks/runner.rb +88 -0
- data/lib/rubyn_code/hooks/user_hooks.rb +90 -0
- data/lib/rubyn_code/learning/extractor.rb +191 -0
- data/lib/rubyn_code/learning/injector.rb +138 -0
- data/lib/rubyn_code/learning/instinct.rb +172 -0
- data/lib/rubyn_code/llm/client.rb +218 -0
- data/lib/rubyn_code/llm/message_builder.rb +116 -0
- data/lib/rubyn_code/llm/streaming.rb +203 -0
- data/lib/rubyn_code/mcp/client.rb +139 -0
- data/lib/rubyn_code/mcp/config.rb +83 -0
- data/lib/rubyn_code/mcp/sse_transport.rb +225 -0
- data/lib/rubyn_code/mcp/stdio_transport.rb +196 -0
- data/lib/rubyn_code/mcp/tool_bridge.rb +164 -0
- data/lib/rubyn_code/memory/models.rb +62 -0
- data/lib/rubyn_code/memory/search.rb +181 -0
- data/lib/rubyn_code/memory/session_persistence.rb +194 -0
- data/lib/rubyn_code/memory/store.rb +199 -0
- data/lib/rubyn_code/observability/budget_enforcer.rb +159 -0
- data/lib/rubyn_code/observability/cost_calculator.rb +61 -0
- data/lib/rubyn_code/observability/models.rb +29 -0
- data/lib/rubyn_code/observability/token_counter.rb +42 -0
- data/lib/rubyn_code/observability/usage_reporter.rb +140 -0
- data/lib/rubyn_code/output/diff_renderer.rb +212 -0
- data/lib/rubyn_code/output/formatter.rb +120 -0
- data/lib/rubyn_code/permissions/deny_list.rb +49 -0
- data/lib/rubyn_code/permissions/policy.rb +59 -0
- data/lib/rubyn_code/permissions/prompter.rb +80 -0
- data/lib/rubyn_code/permissions/tier.rb +22 -0
- data/lib/rubyn_code/protocols/interrupt_handler.rb +95 -0
- data/lib/rubyn_code/protocols/plan_approval.rb +67 -0
- data/lib/rubyn_code/protocols/shutdown_handshake.rb +109 -0
- data/lib/rubyn_code/skills/catalog.rb +70 -0
- data/lib/rubyn_code/skills/document.rb +80 -0
- data/lib/rubyn_code/skills/loader.rb +57 -0
- data/lib/rubyn_code/sub_agents/runner.rb +168 -0
- data/lib/rubyn_code/sub_agents/summarizer.rb +57 -0
- data/lib/rubyn_code/tasks/dag.rb +208 -0
- data/lib/rubyn_code/tasks/manager.rb +212 -0
- data/lib/rubyn_code/tasks/models.rb +31 -0
- data/lib/rubyn_code/teams/mailbox.rb +128 -0
- data/lib/rubyn_code/teams/manager.rb +175 -0
- data/lib/rubyn_code/teams/teammate.rb +38 -0
- data/lib/rubyn_code/tools/background_run.rb +41 -0
- data/lib/rubyn_code/tools/base.rb +84 -0
- data/lib/rubyn_code/tools/bash.rb +81 -0
- data/lib/rubyn_code/tools/bundle_add.rb +53 -0
- data/lib/rubyn_code/tools/bundle_install.rb +41 -0
- data/lib/rubyn_code/tools/compact.rb +57 -0
- data/lib/rubyn_code/tools/db_migrate.rb +52 -0
- data/lib/rubyn_code/tools/edit_file.rb +49 -0
- data/lib/rubyn_code/tools/executor.rb +62 -0
- data/lib/rubyn_code/tools/git_commit.rb +97 -0
- data/lib/rubyn_code/tools/git_diff.rb +61 -0
- data/lib/rubyn_code/tools/git_log.rb +59 -0
- data/lib/rubyn_code/tools/git_status.rb +59 -0
- data/lib/rubyn_code/tools/glob.rb +44 -0
- data/lib/rubyn_code/tools/grep.rb +81 -0
- data/lib/rubyn_code/tools/load_skill.rb +41 -0
- data/lib/rubyn_code/tools/memory_search.rb +77 -0
- data/lib/rubyn_code/tools/memory_write.rb +52 -0
- data/lib/rubyn_code/tools/rails_generate.rb +54 -0
- data/lib/rubyn_code/tools/read_file.rb +38 -0
- data/lib/rubyn_code/tools/read_inbox.rb +64 -0
- data/lib/rubyn_code/tools/registry.rb +48 -0
- data/lib/rubyn_code/tools/review_pr.rb +145 -0
- data/lib/rubyn_code/tools/run_specs.rb +75 -0
- data/lib/rubyn_code/tools/schema.rb +59 -0
- data/lib/rubyn_code/tools/send_message.rb +53 -0
- data/lib/rubyn_code/tools/spawn_agent.rb +154 -0
- data/lib/rubyn_code/tools/spawn_teammate.rb +168 -0
- data/lib/rubyn_code/tools/task.rb +148 -0
- data/lib/rubyn_code/tools/web_fetch.rb +108 -0
- data/lib/rubyn_code/tools/web_search.rb +196 -0
- data/lib/rubyn_code/tools/write_file.rb +30 -0
- data/lib/rubyn_code/version.rb +5 -0
- data/lib/rubyn_code.rb +203 -0
- data/skills/code_quality/fits_in_your_head.md +189 -0
- data/skills/code_quality/naming_conventions.md +213 -0
- data/skills/code_quality/null_object.md +205 -0
- data/skills/code_quality/technical_debt.md +135 -0
- data/skills/code_quality/value_objects.md +216 -0
- data/skills/code_quality/yagni.md +176 -0
- data/skills/design_patterns/adapter.md +191 -0
- data/skills/design_patterns/bridge_memento_visitor.md +254 -0
- data/skills/design_patterns/builder.md +158 -0
- data/skills/design_patterns/command.md +126 -0
- data/skills/design_patterns/composite.md +147 -0
- data/skills/design_patterns/decorator.md +204 -0
- data/skills/design_patterns/facade.md +133 -0
- data/skills/design_patterns/factory_method.md +169 -0
- data/skills/design_patterns/iterator.md +116 -0
- data/skills/design_patterns/mediator.md +133 -0
- data/skills/design_patterns/observer.md +177 -0
- data/skills/design_patterns/proxy.md +140 -0
- data/skills/design_patterns/singleton.md +124 -0
- data/skills/design_patterns/state.md +207 -0
- data/skills/design_patterns/strategy.md +127 -0
- data/skills/design_patterns/template_method.md +173 -0
- data/skills/gems/devise.md +365 -0
- data/skills/gems/dry_rb.md +186 -0
- data/skills/gems/factory_bot.md +268 -0
- data/skills/gems/faraday.md +263 -0
- data/skills/gems/graphql_ruby.md +514 -0
- data/skills/gems/pundit.md +446 -0
- data/skills/gems/redis.md +219 -0
- data/skills/gems/rubocop.md +257 -0
- data/skills/gems/sidekiq.md +360 -0
- data/skills/gems/stripe.md +224 -0
- data/skills/minitest/assertions.md +185 -0
- data/skills/minitest/fixtures.md +238 -0
- data/skills/minitest/integration_tests.md +210 -0
- data/skills/minitest/mailers_and_jobs.md +218 -0
- data/skills/minitest/mocking_stubbing.md +202 -0
- data/skills/minitest/service_tests_and_performance.md +246 -0
- data/skills/minitest/structure_and_conventions.md +169 -0
- data/skills/minitest/system_tests.md +237 -0
- data/skills/rails/action_cable.md +160 -0
- data/skills/rails/active_record_basics.md +174 -0
- data/skills/rails/active_storage.md +242 -0
- data/skills/rails/api_design.md +212 -0
- data/skills/rails/associations.md +182 -0
- data/skills/rails/background_jobs.md +212 -0
- data/skills/rails/caching.md +158 -0
- data/skills/rails/callbacks.md +135 -0
- data/skills/rails/concerns_controllers.md +218 -0
- data/skills/rails/concerns_models.md +280 -0
- data/skills/rails/controllers.md +190 -0
- data/skills/rails/engines.md +201 -0
- data/skills/rails/form_objects.md +168 -0
- data/skills/rails/hotwire.md +229 -0
- data/skills/rails/internationalization.md +192 -0
- data/skills/rails/logging.md +198 -0
- data/skills/rails/mailers.md +180 -0
- data/skills/rails/migrations.md +200 -0
- data/skills/rails/multitenancy.md +207 -0
- data/skills/rails/n_plus_one.md +151 -0
- data/skills/rails/presenters.md +244 -0
- data/skills/rails/query_objects.md +177 -0
- data/skills/rails/routing.md +194 -0
- data/skills/rails/scopes.md +187 -0
- data/skills/rails/security.md +233 -0
- data/skills/rails/serializers.md +243 -0
- data/skills/rails/service_objects.md +184 -0
- data/skills/rails/testing_strategy.md +258 -0
- data/skills/rails/validations.md +206 -0
- data/skills/refactoring/code_smells.md +251 -0
- data/skills/refactoring/command_query_separation.md +166 -0
- data/skills/refactoring/encapsulate_collection.md +125 -0
- data/skills/refactoring/extract_class.md +138 -0
- data/skills/refactoring/extract_method.md +185 -0
- data/skills/refactoring/replace_conditional.md +211 -0
- data/skills/refactoring/value_objects.md +246 -0
- data/skills/rspec/build_stubbed.md +199 -0
- data/skills/rspec/factory_design.md +206 -0
- data/skills/rspec/let_vs_let_bang.md +161 -0
- data/skills/rspec/mocking_stubbing.md +209 -0
- data/skills/rspec/request_specs.md +212 -0
- data/skills/rspec/service_specs.md +262 -0
- data/skills/rspec/shared_examples.md +244 -0
- data/skills/rspec/system_specs.md +286 -0
- data/skills/rspec/test_performance.md +215 -0
- data/skills/ruby/blocks_procs_lambdas.md +204 -0
- data/skills/ruby/classes.md +155 -0
- data/skills/ruby/concurrency.md +194 -0
- data/skills/ruby/data_struct_openstruct.md +158 -0
- data/skills/ruby/debugging_profiling.md +204 -0
- data/skills/ruby/enumerable_patterns.md +168 -0
- data/skills/ruby/exception_handling.md +199 -0
- data/skills/ruby/file_io.md +217 -0
- data/skills/ruby/hashes.md +195 -0
- data/skills/ruby/metaprogramming.md +170 -0
- data/skills/ruby/modules.md +210 -0
- data/skills/ruby/pattern_matching.md +177 -0
- data/skills/ruby/regular_expressions.md +166 -0
- data/skills/ruby/result_objects.md +200 -0
- data/skills/ruby/strings.md +177 -0
- data/skills/ruby_project/bundler_dependencies.md +181 -0
- data/skills/ruby_project/cli_tools.md +224 -0
- data/skills/ruby_project/rake_tasks.md +146 -0
- data/skills/ruby_project/structure.md +261 -0
- data/skills/sinatra/application_structure.md +241 -0
- data/skills/sinatra/middleware_and_deployment.md +221 -0
- data/skills/sinatra/testing.md +233 -0
- data/skills/solid/dependency_inversion.md +195 -0
- data/skills/solid/interface_segregation.md +237 -0
- data/skills/solid/liskov_substitution.md +263 -0
- data/skills/solid/open_closed.md +212 -0
- data/skills/solid/single_responsibility.md +183 -0
- metadata +397 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# Code Quality: Null Object Pattern
|
|
2
|
+
|
|
3
|
+
## Pattern
|
|
4
|
+
|
|
5
|
+
Instead of returning `nil` and forcing callers to check for it, return a special Null Object that implements the same interface with safe, neutral behavior. Eliminates `nil` checks scattered throughout the codebase.
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
# The real object
|
|
9
|
+
class User < ApplicationRecord
|
|
10
|
+
def display_name = name.presence || email
|
|
11
|
+
def plan_name = active_subscription&.plan || "free"
|
|
12
|
+
def credit_balance = credit_ledger_entries.sum(:amount)
|
|
13
|
+
def can_use_feature?(feature) = plan_features.include?(feature)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# The Null Object — same interface, safe defaults
|
|
17
|
+
class GuestUser
|
|
18
|
+
def id = nil
|
|
19
|
+
def display_name = "Guest"
|
|
20
|
+
def email = nil
|
|
21
|
+
def plan_name = "none"
|
|
22
|
+
def credit_balance = 0
|
|
23
|
+
def can_use_feature?(_feature) = false
|
|
24
|
+
def admin? = false
|
|
25
|
+
def persisted? = false
|
|
26
|
+
def orders = Order.none # Returns an empty ActiveRecord relation
|
|
27
|
+
def projects = Project.none
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Controller — no nil checks anywhere
|
|
31
|
+
class ApplicationController < ActionController::Base
|
|
32
|
+
def current_user
|
|
33
|
+
@current_user ||= User.find_by(id: session[:user_id]) || GuestUser.new
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Views work without nil checks
|
|
38
|
+
<%= current_user.display_name %> <!-- "Guest" for non-logged-in users -->
|
|
39
|
+
<% if current_user.can_use_feature?(:export) %>
|
|
40
|
+
<%= link_to "Export", export_path %>
|
|
41
|
+
<% end %>
|
|
42
|
+
|
|
43
|
+
# Services work without nil checks
|
|
44
|
+
class Orders::ListService
|
|
45
|
+
def call(user)
|
|
46
|
+
user.orders.recent.page(1) # GuestUser returns Order.none — empty relation
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Another example — missing configuration:
|
|
52
|
+
|
|
53
|
+
```ruby
|
|
54
|
+
# Instead of nil for missing config
|
|
55
|
+
class AppConfig
|
|
56
|
+
def self.feature_flags
|
|
57
|
+
@feature_flags ||= load_flags || NullFeatureFlags.new
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
class NullFeatureFlags
|
|
62
|
+
def enabled?(_flag) = false
|
|
63
|
+
def percentage(_flag) = 0
|
|
64
|
+
def variant(_flag) = "control"
|
|
65
|
+
def to_h = {}
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Callers never check for nil
|
|
69
|
+
if AppConfig.feature_flags.enabled?(:new_dashboard)
|
|
70
|
+
render_new_dashboard
|
|
71
|
+
end
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Null Object for associations:
|
|
75
|
+
|
|
76
|
+
```ruby
|
|
77
|
+
class Order < ApplicationRecord
|
|
78
|
+
belongs_to :discount, optional: true
|
|
79
|
+
|
|
80
|
+
def effective_discount
|
|
81
|
+
discount || NullDiscount.new
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
class NullDiscount
|
|
86
|
+
def code = "none"
|
|
87
|
+
def percentage = 0
|
|
88
|
+
def calculate(subtotal) = 0
|
|
89
|
+
def active? = false
|
|
90
|
+
def to_s = "No discount"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# No nil checks in calculation
|
|
94
|
+
class Orders::TotalCalculator
|
|
95
|
+
def call(order)
|
|
96
|
+
subtotal = order.line_items.sum(&:total)
|
|
97
|
+
discount_amount = order.effective_discount.calculate(subtotal)
|
|
98
|
+
subtotal - discount_amount
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Why This Is Good
|
|
104
|
+
|
|
105
|
+
- **Eliminates nil checks.** No more `if current_user.present?`, `user&.name`, or `user.try(:email)`. Every method call is safe because the Null Object responds to everything.
|
|
106
|
+
- **Views are cleaner.** No `<% if current_user %>` guards wrapping every personalized element. The GuestUser provides sensible defaults.
|
|
107
|
+
- **Polymorphic behavior.** The code treats real users and guest users identically. The difference is in the object, not in every caller.
|
|
108
|
+
- **Prevents NoMethodError on nil.** The #1 runtime error in Ruby apps is calling a method on `nil`. Null Objects make this impossible for the wrapped concept.
|
|
109
|
+
|
|
110
|
+
## Anti-Pattern
|
|
111
|
+
|
|
112
|
+
Nil checks scattered throughout the codebase:
|
|
113
|
+
|
|
114
|
+
```ruby
|
|
115
|
+
# Controller
|
|
116
|
+
def show
|
|
117
|
+
@order = current_user&.orders&.find_by(id: params[:id])
|
|
118
|
+
redirect_to root_path unless @order
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# View
|
|
122
|
+
<% if current_user %>
|
|
123
|
+
Welcome, <%= current_user.name || "User" %>
|
|
124
|
+
<% if current_user.active_subscription %>
|
|
125
|
+
Plan: <%= current_user.active_subscription.plan %>
|
|
126
|
+
<% else %>
|
|
127
|
+
Plan: Free
|
|
128
|
+
<% end %>
|
|
129
|
+
<% else %>
|
|
130
|
+
Welcome, Guest
|
|
131
|
+
<% end %>
|
|
132
|
+
|
|
133
|
+
# Service
|
|
134
|
+
def calculate_discount(order)
|
|
135
|
+
return 0 unless order.discount
|
|
136
|
+
return 0 unless order.discount.active?
|
|
137
|
+
order.discount.calculate(order.subtotal)
|
|
138
|
+
end
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
## Why This Is Bad
|
|
142
|
+
|
|
143
|
+
- **Nil checks multiply.** Every new feature that touches `current_user` needs its own nil guard. Across 50 views and 20 services, that's hundreds of `if present?` checks.
|
|
144
|
+
- **Forgetting one check causes a crash.** One missed `&.` or `if present?` and you get `NoMethodError: undefined method 'name' for nil:NilClass` in production.
|
|
145
|
+
- **Duplicated default logic.** `"Guest"` as a fallback appears in the view. `"Free"` as a default plan appears in both the view and a service. Change one, forget the others.
|
|
146
|
+
|
|
147
|
+
## When To Apply
|
|
148
|
+
|
|
149
|
+
- **Optional associations.** `belongs_to :discount, optional: true` → return a `NullDiscount` instead of nil.
|
|
150
|
+
- **Current user / authentication.** Non-logged-in users → `GuestUser` instead of nil.
|
|
151
|
+
- **Configuration that might not exist.** Missing feature flags, missing settings, missing integrations → Null Object with safe defaults.
|
|
152
|
+
- **Any method that currently returns nil and forces callers to check.** If 3+ callers check for nil from the same source, introduce a Null Object.
|
|
153
|
+
|
|
154
|
+
## When NOT To Apply
|
|
155
|
+
|
|
156
|
+
- **When nil is meaningful.** `User.find_by(email: email)` returning nil means "not found" — the caller needs to know this to show an error or create the user. A Null Object would hide the absence.
|
|
157
|
+
- **When the absence should be an error.** `Order.find(params[:id])` should raise `RecordNotFound`, not return a NullOrder. The request is invalid.
|
|
158
|
+
- **One or two nil checks.** If only one caller checks for nil, a simple `|| default` is clearer than a Null Object class.
|
|
159
|
+
- **Don't create Null Objects for every model.** Focus on the 2-3 concepts where nil checks are pervasive (current_user, optional associations used in calculations).
|
|
160
|
+
|
|
161
|
+
## Edge Cases
|
|
162
|
+
|
|
163
|
+
**Null Object with ActiveRecord::Relation behavior:**
|
|
164
|
+
Use `.none` to return an empty-but-chainable relation:
|
|
165
|
+
|
|
166
|
+
```ruby
|
|
167
|
+
class GuestUser
|
|
168
|
+
def orders
|
|
169
|
+
Order.none # Returns an ActiveRecord relation that's always empty
|
|
170
|
+
# .where, .count, .page all work — they just return 0/empty
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# This works: GuestUser.new.orders.recent.page(1).count => 0
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
**Testing with Null Objects:**
|
|
178
|
+
|
|
179
|
+
```ruby
|
|
180
|
+
RSpec.describe GuestUser do
|
|
181
|
+
subject { described_class.new }
|
|
182
|
+
|
|
183
|
+
it "responds to the same interface as User" do
|
|
184
|
+
user_methods = %i[display_name email plan_name credit_balance can_use_feature? admin?]
|
|
185
|
+
user_methods.each do |method|
|
|
186
|
+
expect(subject).to respond_to(method)
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
it "returns safe defaults" do
|
|
191
|
+
expect(subject.display_name).to eq("Guest")
|
|
192
|
+
expect(subject.credit_balance).to eq(0)
|
|
193
|
+
expect(subject.can_use_feature?(:anything)).to be false
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
**Combine with `#presence` for simple cases:**
|
|
199
|
+
For one-off nil handling, Ruby's `#presence` and `||` are sufficient:
|
|
200
|
+
|
|
201
|
+
```ruby
|
|
202
|
+
name = user.name.presence || "Anonymous"
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
Reserve the full Null Object pattern for when nil checks are pervasive.
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# Code Quality: Technical Debt
|
|
2
|
+
|
|
3
|
+
## Core Principle
|
|
4
|
+
|
|
5
|
+
Technical debt is the gap between the code you have and the code you'd write if you had unlimited time. Like financial debt, it accrues interest — every feature built on top of debt takes longer and introduces more bugs. The goal isn't zero debt (that's impossible) — it's managing it deliberately.
|
|
6
|
+
|
|
7
|
+
## Types of Technical Debt
|
|
8
|
+
|
|
9
|
+
### Deliberate, Prudent ("We'll ship this shortcut and clean it up next sprint")
|
|
10
|
+
```ruby
|
|
11
|
+
# We know this should be a service object, but shipping the feature matters more today
|
|
12
|
+
def create
|
|
13
|
+
@order = current_user.orders.build(order_params)
|
|
14
|
+
@order.total = @order.line_items.sum { |li| li.quantity * li.unit_price }
|
|
15
|
+
# TODO: Extract to Orders::CreateService — ticket PROJ-123
|
|
16
|
+
if @order.save
|
|
17
|
+
OrderMailer.confirmation(@order).deliver_later
|
|
18
|
+
redirect_to @order
|
|
19
|
+
else
|
|
20
|
+
render :new, status: :unprocessable_entity
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
```
|
|
24
|
+
This is fine IF you track it and pay it back. The TODO references a real ticket. The code works. The shortcut is documented.
|
|
25
|
+
|
|
26
|
+
### Deliberate, Reckless ("We don't have time for tests")
|
|
27
|
+
```ruby
|
|
28
|
+
# No tests, no error handling, bare rescue, hardcoded values
|
|
29
|
+
def process_payment
|
|
30
|
+
Stripe::Charge.create(amount: params[:amount], source: params[:token])
|
|
31
|
+
redirect_to success_path
|
|
32
|
+
rescue
|
|
33
|
+
redirect_to failure_path
|
|
34
|
+
end
|
|
35
|
+
```
|
|
36
|
+
This debt compounds fast. The bare rescue hides bugs. No tests means no safety net for changes. Hardcoded Stripe calls can't be tested.
|
|
37
|
+
|
|
38
|
+
### Inadvertent ("We didn't know better at the time")
|
|
39
|
+
```ruby
|
|
40
|
+
# Written before the team learned about service objects
|
|
41
|
+
# 200-line controller that grew organically
|
|
42
|
+
class OrdersController < ApplicationController
|
|
43
|
+
def create
|
|
44
|
+
# 50 lines of business logic
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def update
|
|
48
|
+
# 40 lines of business logic
|
|
49
|
+
end
|
|
50
|
+
# ...
|
|
51
|
+
end
|
|
52
|
+
```
|
|
53
|
+
This isn't bad intent — it's a natural consequence of learning. The team knows better now. Refactoring it is an investment, not a punishment.
|
|
54
|
+
|
|
55
|
+
## When to Pay Down Debt
|
|
56
|
+
|
|
57
|
+
### Pay now (before the next feature):
|
|
58
|
+
- **You're about to modify the same code.** If the next ticket touches `OrdersController#create`, refactor it first. The boy scout rule: leave the code better than you found it.
|
|
59
|
+
- **The debt blocks the feature.** If you can't add pagination because the query is a mess, fix the query.
|
|
60
|
+
- **It's causing production incidents.** The bare rescue silently swallowing errors? Fix it before the next outage.
|
|
61
|
+
- **It's slowing down every developer.** A 500-line model that everyone edits — refactoring it saves cumulative hours across the team.
|
|
62
|
+
|
|
63
|
+
### Pay later (track it, don't fix it now):
|
|
64
|
+
- **The code works and isn't being modified.** A messy module that nobody touches doesn't accrue interest.
|
|
65
|
+
- **The refactoring is large and risky.** Rewriting the authentication system requires planning, not a drive-by fix.
|
|
66
|
+
- **You're about to delete the feature.** Don't polish code that's being removed next month.
|
|
67
|
+
|
|
68
|
+
### Don't pay at all:
|
|
69
|
+
- **Speculative generality.** "We should make this more flexible" — but nobody has asked for flexibility. Don't refactor toward imagined future requirements.
|
|
70
|
+
- **Style preferences.** Rewriting working code because "I'd write it differently" isn't paying debt — it's churn.
|
|
71
|
+
|
|
72
|
+
## Tracking Debt
|
|
73
|
+
|
|
74
|
+
```ruby
|
|
75
|
+
# In code: TODO with a ticket reference
|
|
76
|
+
# TODO: Extract discount calculation to DiscountService — PROJ-456
|
|
77
|
+
# TODO: Replace N+1 query with includes — PROJ-789
|
|
78
|
+
|
|
79
|
+
# NOT useful: TODOs without context
|
|
80
|
+
# TODO: Fix this
|
|
81
|
+
# TODO: Refactor later
|
|
82
|
+
# TODO: This is bad
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### Debt Inventory (for the team)
|
|
86
|
+
|
|
87
|
+
| Location | Smell | Impact | Effort | Priority |
|
|
88
|
+
|---|---|---|---|---|
|
|
89
|
+
| `OrdersController#create` | Fat controller (50 lines) | Medium — every order change touches this | Small — extract to service | **Next sprint** |
|
|
90
|
+
| `User` model | 300 lines, 5 concerns | High — every dev edits this daily | Large — needs planning | **Schedule** |
|
|
91
|
+
| `spec/` | 40% use `create` where `build_stubbed` works | Medium — slow CI | Medium — incremental | **Boy scout** |
|
|
92
|
+
| `Legacy::Importer` | No tests, bare rescue | Low — runs once per month | Medium | **Track, don't fix** |
|
|
93
|
+
|
|
94
|
+
## Refactoring Strategies
|
|
95
|
+
|
|
96
|
+
### Boy Scout Rule (Incremental)
|
|
97
|
+
Every PR that touches a file leaves it slightly better. Rename a variable, extract a method, add a missing test. Small improvements compound.
|
|
98
|
+
|
|
99
|
+
### Strangler Fig (Gradual Replacement)
|
|
100
|
+
Build the new system alongside the old one. Route new traffic to the new system. Eventually shut off the old one. Works for large rewrites (new API version, new auth system).
|
|
101
|
+
|
|
102
|
+
```ruby
|
|
103
|
+
# Old: everything in the controller
|
|
104
|
+
class OrdersController
|
|
105
|
+
def create
|
|
106
|
+
# 50 lines of legacy code
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# New: service object handles new code paths
|
|
111
|
+
class OrdersController
|
|
112
|
+
def create
|
|
113
|
+
if Feature.enabled?(:new_order_flow, current_user)
|
|
114
|
+
result = Orders::CreateService.call(order_params, current_user)
|
|
115
|
+
# ...
|
|
116
|
+
else
|
|
117
|
+
# Legacy path — will be removed once new flow is stable
|
|
118
|
+
# ...
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
### Dedicated Refactoring Sprint
|
|
125
|
+
Reserve 10-20% of sprint capacity for debt reduction. Pick the highest-impact items from the debt inventory. This works for teams that can't justify "refactoring PRs" individually but can justify a planned investment.
|
|
126
|
+
|
|
127
|
+
## Rubyn's Role in Debt Management
|
|
128
|
+
|
|
129
|
+
When Rubyn reviews code, it identifies debt using the code smells vocabulary (Long Method, Feature Envy, Shotgun Surgery, etc.) and gives each finding a severity. This turns vague "this code is messy" feelings into specific, actionable items that can be tracked and prioritized.
|
|
130
|
+
|
|
131
|
+
When Rubyn refactors, it pays down the specific debt you point it at — extracting the service object, fixing the N+1, replacing the bare rescue — while preserving behavior. It's a tool for incremental improvement, not a magic "fix everything" button.
|
|
132
|
+
|
|
133
|
+
## The Key Insight
|
|
134
|
+
|
|
135
|
+
The most expensive code isn't code with debt — it's code with *untracked* debt. A TODO with a ticket is managed. A 500-line controller that everyone complains about but nobody documents is a slowly growing crisis. Track it, prioritize it, pay it down incrementally.
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
# Code Quality: Value Objects
|
|
2
|
+
|
|
3
|
+
## Pattern
|
|
4
|
+
|
|
5
|
+
Replace primitive values (strings, integers, floats) that carry domain meaning with small, immutable objects that encapsulate the value AND its behavior. Value objects are equal by their attributes, not by identity.
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
# Ruby 3.2+ Data class — the easiest way to create value objects
|
|
9
|
+
Money = Data.define(:amount_cents, :currency) do
|
|
10
|
+
def initialize(amount_cents:, currency: "USD")
|
|
11
|
+
super(amount_cents: Integer(amount_cents), currency: currency.to_s.upcase.freeze)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def to_f = amount_cents / 100.0
|
|
15
|
+
def to_s = format("$%.2f %s", to_f, currency)
|
|
16
|
+
def zero? = amount_cents.zero?
|
|
17
|
+
|
|
18
|
+
def +(other)
|
|
19
|
+
raise ArgumentError, "Currency mismatch: #{currency} vs #{other.currency}" unless currency == other.currency
|
|
20
|
+
self.class.new(amount_cents: amount_cents + other.amount_cents, currency: currency)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def -(other)
|
|
24
|
+
raise ArgumentError, "Currency mismatch" unless currency == other.currency
|
|
25
|
+
self.class.new(amount_cents: amount_cents - other.amount_cents, currency: currency)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def *(factor)
|
|
29
|
+
self.class.new(amount_cents: (amount_cents * factor).round, currency: currency)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def >(other) = amount_cents > other.amount_cents
|
|
33
|
+
def <(other) = amount_cents < other.amount_cents
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Usage
|
|
37
|
+
price = Money.new(amount_cents: 19_99)
|
|
38
|
+
tax = price * 0.08
|
|
39
|
+
total = price + tax
|
|
40
|
+
puts total # => "$21.59 USD"
|
|
41
|
+
puts total > price # => true
|
|
42
|
+
|
|
43
|
+
# Equality by value, not identity
|
|
44
|
+
Money.new(amount_cents: 100) == Money.new(amount_cents: 100) # => true
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
```ruby
|
|
48
|
+
# Email value object — validates and normalizes
|
|
49
|
+
Email = Data.define(:address) do
|
|
50
|
+
EMAIL_REGEX = URI::MailTo::EMAIL_REGEXP
|
|
51
|
+
|
|
52
|
+
def initialize(address:)
|
|
53
|
+
normalized = address.to_s.downcase.strip
|
|
54
|
+
raise ArgumentError, "Invalid email: #{address}" unless normalized.match?(EMAIL_REGEX)
|
|
55
|
+
super(address: normalized.freeze)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def domain = address.split("@").last
|
|
59
|
+
def local_part = address.split("@").first
|
|
60
|
+
def to_s = address
|
|
61
|
+
def personal? = !corporate?
|
|
62
|
+
def corporate? = !domain.match?(/gmail|yahoo|hotmail|outlook/i)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
email = Email.new(address: " Alice@Example.COM ")
|
|
66
|
+
email.address # => "alice@example.com" (normalized)
|
|
67
|
+
email.domain # => "example.com"
|
|
68
|
+
email.corporate? # => true
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
```ruby
|
|
72
|
+
# DateRange value object — common in reporting
|
|
73
|
+
DateRange = Data.define(:start_date, :end_date) do
|
|
74
|
+
def initialize(start_date:, end_date:)
|
|
75
|
+
start_date = Date.parse(start_date.to_s) unless start_date.is_a?(Date)
|
|
76
|
+
end_date = Date.parse(end_date.to_s) unless end_date.is_a?(Date)
|
|
77
|
+
raise ArgumentError, "start_date must be before end_date" if start_date > end_date
|
|
78
|
+
super(start_date: start_date, end_date: end_date)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def days = (end_date - start_date).to_i
|
|
82
|
+
def include?(date) = (start_date..end_date).cover?(date)
|
|
83
|
+
def to_range = start_date..end_date
|
|
84
|
+
def overlap?(other) = start_date <= other.end_date && end_date >= other.start_date
|
|
85
|
+
def to_s = "#{start_date.iso8601} to #{end_date.iso8601}"
|
|
86
|
+
|
|
87
|
+
def self.last_n_days(n) = new(start_date: n.days.ago.to_date, end_date: Date.today)
|
|
88
|
+
def self.this_month = new(start_date: Date.today.beginning_of_month, end_date: Date.today)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
period = DateRange.last_n_days(30)
|
|
92
|
+
orders = Order.where(created_at: period.to_range)
|
|
93
|
+
puts "#{period.days} days: #{orders.count} orders"
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
```ruby
|
|
97
|
+
# FileHash — wraps a checksum with comparison behavior
|
|
98
|
+
FileHash = Data.define(:digest) do
|
|
99
|
+
def self.from_content(content)
|
|
100
|
+
new(digest: Digest::SHA256.hexdigest(content))
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def changed_from?(other)
|
|
104
|
+
digest != other.digest
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def to_s = digest[0..7] # Short display
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
current = FileHash.from_content(file_content)
|
|
111
|
+
stored = FileHash.new(digest: embedding.file_hash)
|
|
112
|
+
reindex_file if current.changed_from?(stored)
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## Why This Is Good
|
|
116
|
+
|
|
117
|
+
- **Impossible to have invalid values.** `Email.new(address: "not-an-email")` raises immediately. You can't pass an invalid email deeper into the system. Validation is at construction, not scattered across consumers.
|
|
118
|
+
- **Behavior lives with the data.** `money + other_money` handles currency matching. `email.domain` extracts the domain. Without value objects, this logic is duplicated wherever the primitive is used.
|
|
119
|
+
- **Self-documenting types.** `def charge(amount:)` accepting a `Money` is clearer than accepting an `Integer` (is it cents? dollars? what currency?). The type IS the documentation.
|
|
120
|
+
- **Immutable by default.** `Data.define` produces frozen objects. No accidental mutation, no defensive copying, no shared-state bugs.
|
|
121
|
+
- **Equality by value.** Two `Money` objects with the same amount and currency are equal. This makes them work correctly in Sets, as Hash keys, and with `==`.
|
|
122
|
+
|
|
123
|
+
## Anti-Pattern
|
|
124
|
+
|
|
125
|
+
Using primitives with scattered validation and formatting:
|
|
126
|
+
|
|
127
|
+
```ruby
|
|
128
|
+
class Order < ApplicationRecord
|
|
129
|
+
validates :total, numericality: { greater_than: 0 }
|
|
130
|
+
|
|
131
|
+
def formatted_total
|
|
132
|
+
"$#{'%.2f' % (total / 100.0)}"
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
class Invoice < ApplicationRecord
|
|
137
|
+
validates :amount, numericality: { greater_than: 0 }
|
|
138
|
+
|
|
139
|
+
def formatted_amount
|
|
140
|
+
"$#{'%.2f' % (amount / 100.0)}"
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# In a service
|
|
145
|
+
def apply_discount(total_cents, discount_percentage)
|
|
146
|
+
discount = (total_cents * discount_percentage / 100.0).round
|
|
147
|
+
total_cents - discount
|
|
148
|
+
# Wait — is total_cents in cents or dollars? The variable name says cents
|
|
149
|
+
# but the discount_percentage calculation suggests... ?
|
|
150
|
+
end
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
## Why This Is Bad
|
|
154
|
+
|
|
155
|
+
- **Duplicated formatting.** `"$#{'%.2f' % (total / 100.0)}"` appears in Order, Invoice, and probably 5 other places. Change the format in one place, forget the others.
|
|
156
|
+
- **No currency safety.** Adding USD and EUR produces a meaningless number. With `Money`, it raises `ArgumentError`.
|
|
157
|
+
- **Ambiguous units.** Is `total` in cents or dollars? Is `discount_percentage` 10 or 0.10? Primitives don't communicate their units.
|
|
158
|
+
- **Validation scattered.** Every model independently validates numericality. With `Money`, the value object enforces validity at construction.
|
|
159
|
+
|
|
160
|
+
## When To Apply
|
|
161
|
+
|
|
162
|
+
- **Whenever a primitive carries domain meaning.** Money, email, phone number, URL, date range, coordinates, file hash, API key, color code.
|
|
163
|
+
- **When the same formatting/parsing appears in 2+ places.** That's behavior that belongs on a value object.
|
|
164
|
+
- **When you find yourself naming variables with units.** `amount_cents`, `distance_km`, `duration_seconds` — these are value objects screaming to be born.
|
|
165
|
+
- **When invalid values cause bugs.** If a negative amount, empty email, or swapped date range would cause downstream problems, make it impossible to construct.
|
|
166
|
+
|
|
167
|
+
## When NOT To Apply
|
|
168
|
+
|
|
169
|
+
- **Simple strings with no behavior.** A user's `first_name` is just a string — no formatting, validation, or arithmetic needed.
|
|
170
|
+
- **IDs and foreign keys.** These are database primitives. Wrapping `user_id` in a `UserId` value object is over-engineering.
|
|
171
|
+
- **Ephemeral values in a single method.** A loop counter or a temporary sum doesn't need a value object.
|
|
172
|
+
|
|
173
|
+
## Edge Cases
|
|
174
|
+
|
|
175
|
+
**Value objects in ActiveRecord:**
|
|
176
|
+
Store as a primitive in the DB, cast to a value object in Ruby:
|
|
177
|
+
|
|
178
|
+
```ruby
|
|
179
|
+
class Order < ApplicationRecord
|
|
180
|
+
def total_money
|
|
181
|
+
Money.new(amount_cents: total_cents, currency: currency)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def total_money=(money)
|
|
185
|
+
self.total_cents = money.amount_cents
|
|
186
|
+
self.currency = money.currency
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Or use ActiveRecord::Attributes for automatic casting
|
|
191
|
+
class MoneyType < ActiveRecord::Type::Value
|
|
192
|
+
def cast(value)
|
|
193
|
+
case value
|
|
194
|
+
when Money then value
|
|
195
|
+
when Hash then Money.new(**value.symbolize_keys)
|
|
196
|
+
when Integer then Money.new(amount_cents: value)
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def serialize(value)
|
|
201
|
+
value&.amount_cents
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
**Pre-Ruby 3.2 (no Data class):**
|
|
207
|
+
Use `Struct` with freeze:
|
|
208
|
+
|
|
209
|
+
```ruby
|
|
210
|
+
Money = Struct.new(:amount_cents, :currency, keyword_init: true) do
|
|
211
|
+
def initialize(amount_cents:, currency: "USD")
|
|
212
|
+
super
|
|
213
|
+
freeze
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
```
|