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.
Files changed (235) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +620 -0
  4. data/db/migrations/000_create_schema_migrations.sql +4 -0
  5. data/db/migrations/001_create_sessions.sql +16 -0
  6. data/db/migrations/002_create_messages.sql +16 -0
  7. data/db/migrations/003_create_tasks.sql +17 -0
  8. data/db/migrations/004_create_task_dependencies.sql +8 -0
  9. data/db/migrations/005_create_memories.sql +44 -0
  10. data/db/migrations/006_create_cost_records.sql +16 -0
  11. data/db/migrations/007_create_hooks.sql +12 -0
  12. data/db/migrations/008_create_skills_cache.sql +8 -0
  13. data/db/migrations/009_create_teams.sql +27 -0
  14. data/db/migrations/010_create_instincts.sql +15 -0
  15. data/exe/rubyn-code +6 -0
  16. data/lib/rubyn_code/agent/conversation.rb +193 -0
  17. data/lib/rubyn_code/agent/loop.rb +517 -0
  18. data/lib/rubyn_code/agent/loop_detector.rb +78 -0
  19. data/lib/rubyn_code/auth/oauth.rb +174 -0
  20. data/lib/rubyn_code/auth/server.rb +126 -0
  21. data/lib/rubyn_code/auth/token_store.rb +153 -0
  22. data/lib/rubyn_code/autonomous/daemon.rb +233 -0
  23. data/lib/rubyn_code/autonomous/idle_poller.rb +111 -0
  24. data/lib/rubyn_code/autonomous/task_claimer.rb +100 -0
  25. data/lib/rubyn_code/background/job.rb +19 -0
  26. data/lib/rubyn_code/background/notifier.rb +44 -0
  27. data/lib/rubyn_code/background/worker.rb +146 -0
  28. data/lib/rubyn_code/cli/app.rb +118 -0
  29. data/lib/rubyn_code/cli/input_handler.rb +79 -0
  30. data/lib/rubyn_code/cli/renderer.rb +205 -0
  31. data/lib/rubyn_code/cli/repl.rb +519 -0
  32. data/lib/rubyn_code/cli/spinner.rb +100 -0
  33. data/lib/rubyn_code/cli/stream_formatter.rb +149 -0
  34. data/lib/rubyn_code/config/defaults.rb +43 -0
  35. data/lib/rubyn_code/config/project_config.rb +120 -0
  36. data/lib/rubyn_code/config/settings.rb +127 -0
  37. data/lib/rubyn_code/context/auto_compact.rb +81 -0
  38. data/lib/rubyn_code/context/compactor.rb +89 -0
  39. data/lib/rubyn_code/context/manager.rb +91 -0
  40. data/lib/rubyn_code/context/manual_compact.rb +87 -0
  41. data/lib/rubyn_code/context/micro_compact.rb +135 -0
  42. data/lib/rubyn_code/db/connection.rb +176 -0
  43. data/lib/rubyn_code/db/migrator.rb +146 -0
  44. data/lib/rubyn_code/db/schema.rb +106 -0
  45. data/lib/rubyn_code/hooks/built_in.rb +124 -0
  46. data/lib/rubyn_code/hooks/registry.rb +99 -0
  47. data/lib/rubyn_code/hooks/runner.rb +88 -0
  48. data/lib/rubyn_code/hooks/user_hooks.rb +90 -0
  49. data/lib/rubyn_code/learning/extractor.rb +191 -0
  50. data/lib/rubyn_code/learning/injector.rb +138 -0
  51. data/lib/rubyn_code/learning/instinct.rb +172 -0
  52. data/lib/rubyn_code/llm/client.rb +218 -0
  53. data/lib/rubyn_code/llm/message_builder.rb +116 -0
  54. data/lib/rubyn_code/llm/streaming.rb +203 -0
  55. data/lib/rubyn_code/mcp/client.rb +139 -0
  56. data/lib/rubyn_code/mcp/config.rb +83 -0
  57. data/lib/rubyn_code/mcp/sse_transport.rb +225 -0
  58. data/lib/rubyn_code/mcp/stdio_transport.rb +196 -0
  59. data/lib/rubyn_code/mcp/tool_bridge.rb +164 -0
  60. data/lib/rubyn_code/memory/models.rb +62 -0
  61. data/lib/rubyn_code/memory/search.rb +181 -0
  62. data/lib/rubyn_code/memory/session_persistence.rb +194 -0
  63. data/lib/rubyn_code/memory/store.rb +199 -0
  64. data/lib/rubyn_code/observability/budget_enforcer.rb +159 -0
  65. data/lib/rubyn_code/observability/cost_calculator.rb +61 -0
  66. data/lib/rubyn_code/observability/models.rb +29 -0
  67. data/lib/rubyn_code/observability/token_counter.rb +42 -0
  68. data/lib/rubyn_code/observability/usage_reporter.rb +140 -0
  69. data/lib/rubyn_code/output/diff_renderer.rb +212 -0
  70. data/lib/rubyn_code/output/formatter.rb +120 -0
  71. data/lib/rubyn_code/permissions/deny_list.rb +49 -0
  72. data/lib/rubyn_code/permissions/policy.rb +59 -0
  73. data/lib/rubyn_code/permissions/prompter.rb +80 -0
  74. data/lib/rubyn_code/permissions/tier.rb +22 -0
  75. data/lib/rubyn_code/protocols/interrupt_handler.rb +95 -0
  76. data/lib/rubyn_code/protocols/plan_approval.rb +67 -0
  77. data/lib/rubyn_code/protocols/shutdown_handshake.rb +109 -0
  78. data/lib/rubyn_code/skills/catalog.rb +70 -0
  79. data/lib/rubyn_code/skills/document.rb +80 -0
  80. data/lib/rubyn_code/skills/loader.rb +57 -0
  81. data/lib/rubyn_code/sub_agents/runner.rb +168 -0
  82. data/lib/rubyn_code/sub_agents/summarizer.rb +57 -0
  83. data/lib/rubyn_code/tasks/dag.rb +208 -0
  84. data/lib/rubyn_code/tasks/manager.rb +212 -0
  85. data/lib/rubyn_code/tasks/models.rb +31 -0
  86. data/lib/rubyn_code/teams/mailbox.rb +128 -0
  87. data/lib/rubyn_code/teams/manager.rb +175 -0
  88. data/lib/rubyn_code/teams/teammate.rb +38 -0
  89. data/lib/rubyn_code/tools/background_run.rb +41 -0
  90. data/lib/rubyn_code/tools/base.rb +84 -0
  91. data/lib/rubyn_code/tools/bash.rb +81 -0
  92. data/lib/rubyn_code/tools/bundle_add.rb +53 -0
  93. data/lib/rubyn_code/tools/bundle_install.rb +41 -0
  94. data/lib/rubyn_code/tools/compact.rb +57 -0
  95. data/lib/rubyn_code/tools/db_migrate.rb +52 -0
  96. data/lib/rubyn_code/tools/edit_file.rb +49 -0
  97. data/lib/rubyn_code/tools/executor.rb +62 -0
  98. data/lib/rubyn_code/tools/git_commit.rb +97 -0
  99. data/lib/rubyn_code/tools/git_diff.rb +61 -0
  100. data/lib/rubyn_code/tools/git_log.rb +59 -0
  101. data/lib/rubyn_code/tools/git_status.rb +59 -0
  102. data/lib/rubyn_code/tools/glob.rb +44 -0
  103. data/lib/rubyn_code/tools/grep.rb +81 -0
  104. data/lib/rubyn_code/tools/load_skill.rb +41 -0
  105. data/lib/rubyn_code/tools/memory_search.rb +77 -0
  106. data/lib/rubyn_code/tools/memory_write.rb +52 -0
  107. data/lib/rubyn_code/tools/rails_generate.rb +54 -0
  108. data/lib/rubyn_code/tools/read_file.rb +38 -0
  109. data/lib/rubyn_code/tools/read_inbox.rb +64 -0
  110. data/lib/rubyn_code/tools/registry.rb +48 -0
  111. data/lib/rubyn_code/tools/review_pr.rb +145 -0
  112. data/lib/rubyn_code/tools/run_specs.rb +75 -0
  113. data/lib/rubyn_code/tools/schema.rb +59 -0
  114. data/lib/rubyn_code/tools/send_message.rb +53 -0
  115. data/lib/rubyn_code/tools/spawn_agent.rb +154 -0
  116. data/lib/rubyn_code/tools/spawn_teammate.rb +168 -0
  117. data/lib/rubyn_code/tools/task.rb +148 -0
  118. data/lib/rubyn_code/tools/web_fetch.rb +108 -0
  119. data/lib/rubyn_code/tools/web_search.rb +196 -0
  120. data/lib/rubyn_code/tools/write_file.rb +30 -0
  121. data/lib/rubyn_code/version.rb +5 -0
  122. data/lib/rubyn_code.rb +203 -0
  123. data/skills/code_quality/fits_in_your_head.md +189 -0
  124. data/skills/code_quality/naming_conventions.md +213 -0
  125. data/skills/code_quality/null_object.md +205 -0
  126. data/skills/code_quality/technical_debt.md +135 -0
  127. data/skills/code_quality/value_objects.md +216 -0
  128. data/skills/code_quality/yagni.md +176 -0
  129. data/skills/design_patterns/adapter.md +191 -0
  130. data/skills/design_patterns/bridge_memento_visitor.md +254 -0
  131. data/skills/design_patterns/builder.md +158 -0
  132. data/skills/design_patterns/command.md +126 -0
  133. data/skills/design_patterns/composite.md +147 -0
  134. data/skills/design_patterns/decorator.md +204 -0
  135. data/skills/design_patterns/facade.md +133 -0
  136. data/skills/design_patterns/factory_method.md +169 -0
  137. data/skills/design_patterns/iterator.md +116 -0
  138. data/skills/design_patterns/mediator.md +133 -0
  139. data/skills/design_patterns/observer.md +177 -0
  140. data/skills/design_patterns/proxy.md +140 -0
  141. data/skills/design_patterns/singleton.md +124 -0
  142. data/skills/design_patterns/state.md +207 -0
  143. data/skills/design_patterns/strategy.md +127 -0
  144. data/skills/design_patterns/template_method.md +173 -0
  145. data/skills/gems/devise.md +365 -0
  146. data/skills/gems/dry_rb.md +186 -0
  147. data/skills/gems/factory_bot.md +268 -0
  148. data/skills/gems/faraday.md +263 -0
  149. data/skills/gems/graphql_ruby.md +514 -0
  150. data/skills/gems/pundit.md +446 -0
  151. data/skills/gems/redis.md +219 -0
  152. data/skills/gems/rubocop.md +257 -0
  153. data/skills/gems/sidekiq.md +360 -0
  154. data/skills/gems/stripe.md +224 -0
  155. data/skills/minitest/assertions.md +185 -0
  156. data/skills/minitest/fixtures.md +238 -0
  157. data/skills/minitest/integration_tests.md +210 -0
  158. data/skills/minitest/mailers_and_jobs.md +218 -0
  159. data/skills/minitest/mocking_stubbing.md +202 -0
  160. data/skills/minitest/service_tests_and_performance.md +246 -0
  161. data/skills/minitest/structure_and_conventions.md +169 -0
  162. data/skills/minitest/system_tests.md +237 -0
  163. data/skills/rails/action_cable.md +160 -0
  164. data/skills/rails/active_record_basics.md +174 -0
  165. data/skills/rails/active_storage.md +242 -0
  166. data/skills/rails/api_design.md +212 -0
  167. data/skills/rails/associations.md +182 -0
  168. data/skills/rails/background_jobs.md +212 -0
  169. data/skills/rails/caching.md +158 -0
  170. data/skills/rails/callbacks.md +135 -0
  171. data/skills/rails/concerns_controllers.md +218 -0
  172. data/skills/rails/concerns_models.md +280 -0
  173. data/skills/rails/controllers.md +190 -0
  174. data/skills/rails/engines.md +201 -0
  175. data/skills/rails/form_objects.md +168 -0
  176. data/skills/rails/hotwire.md +229 -0
  177. data/skills/rails/internationalization.md +192 -0
  178. data/skills/rails/logging.md +198 -0
  179. data/skills/rails/mailers.md +180 -0
  180. data/skills/rails/migrations.md +200 -0
  181. data/skills/rails/multitenancy.md +207 -0
  182. data/skills/rails/n_plus_one.md +151 -0
  183. data/skills/rails/presenters.md +244 -0
  184. data/skills/rails/query_objects.md +177 -0
  185. data/skills/rails/routing.md +194 -0
  186. data/skills/rails/scopes.md +187 -0
  187. data/skills/rails/security.md +233 -0
  188. data/skills/rails/serializers.md +243 -0
  189. data/skills/rails/service_objects.md +184 -0
  190. data/skills/rails/testing_strategy.md +258 -0
  191. data/skills/rails/validations.md +206 -0
  192. data/skills/refactoring/code_smells.md +251 -0
  193. data/skills/refactoring/command_query_separation.md +166 -0
  194. data/skills/refactoring/encapsulate_collection.md +125 -0
  195. data/skills/refactoring/extract_class.md +138 -0
  196. data/skills/refactoring/extract_method.md +185 -0
  197. data/skills/refactoring/replace_conditional.md +211 -0
  198. data/skills/refactoring/value_objects.md +246 -0
  199. data/skills/rspec/build_stubbed.md +199 -0
  200. data/skills/rspec/factory_design.md +206 -0
  201. data/skills/rspec/let_vs_let_bang.md +161 -0
  202. data/skills/rspec/mocking_stubbing.md +209 -0
  203. data/skills/rspec/request_specs.md +212 -0
  204. data/skills/rspec/service_specs.md +262 -0
  205. data/skills/rspec/shared_examples.md +244 -0
  206. data/skills/rspec/system_specs.md +286 -0
  207. data/skills/rspec/test_performance.md +215 -0
  208. data/skills/ruby/blocks_procs_lambdas.md +204 -0
  209. data/skills/ruby/classes.md +155 -0
  210. data/skills/ruby/concurrency.md +194 -0
  211. data/skills/ruby/data_struct_openstruct.md +158 -0
  212. data/skills/ruby/debugging_profiling.md +204 -0
  213. data/skills/ruby/enumerable_patterns.md +168 -0
  214. data/skills/ruby/exception_handling.md +199 -0
  215. data/skills/ruby/file_io.md +217 -0
  216. data/skills/ruby/hashes.md +195 -0
  217. data/skills/ruby/metaprogramming.md +170 -0
  218. data/skills/ruby/modules.md +210 -0
  219. data/skills/ruby/pattern_matching.md +177 -0
  220. data/skills/ruby/regular_expressions.md +166 -0
  221. data/skills/ruby/result_objects.md +200 -0
  222. data/skills/ruby/strings.md +177 -0
  223. data/skills/ruby_project/bundler_dependencies.md +181 -0
  224. data/skills/ruby_project/cli_tools.md +224 -0
  225. data/skills/ruby_project/rake_tasks.md +146 -0
  226. data/skills/ruby_project/structure.md +261 -0
  227. data/skills/sinatra/application_structure.md +241 -0
  228. data/skills/sinatra/middleware_and_deployment.md +221 -0
  229. data/skills/sinatra/testing.md +233 -0
  230. data/skills/solid/dependency_inversion.md +195 -0
  231. data/skills/solid/interface_segregation.md +237 -0
  232. data/skills/solid/liskov_substitution.md +263 -0
  233. data/skills/solid/open_closed.md +212 -0
  234. data/skills/solid/single_responsibility.md +183 -0
  235. 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
+ ```