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,251 @@
1
+ # Code Smells: Recognition and Remedies
2
+
3
+ ## What Are Code Smells?
4
+
5
+ Code smells are surface indicators that usually correspond to deeper design problems. They're not bugs — the code works — but they signal that the code will be increasingly painful to maintain, extend, and test. Recognizing smells is the first step; the refactoring that fixes them is the second.
6
+
7
+ ## Bloaters
8
+
9
+ Smells where code has grown too large to work with effectively.
10
+
11
+ ### Long Method
12
+
13
+ **Smell:** A method longer than ~10 lines, especially if it has comments explaining sections.
14
+
15
+ ```ruby
16
+ # SMELL: 30+ lines doing multiple things
17
+ def process_order(params)
18
+ # Validate
19
+ return error("Missing address") if params[:address].blank?
20
+ return error("No items") if params[:items].empty?
21
+
22
+ # Create order
23
+ order = Order.new(address: params[:address], user: current_user)
24
+ params[:items].each do |item|
25
+ product = Product.find(item[:id])
26
+ order.line_items.build(product: product, quantity: item[:qty], price: product.price)
27
+ end
28
+
29
+ # Calculate totals
30
+ order.subtotal = order.line_items.sum { |li| li.quantity * li.price }
31
+ order.tax = order.subtotal * 0.08
32
+ order.total = order.subtotal + order.tax
33
+
34
+ # Save and notify
35
+ order.save!
36
+ OrderMailer.confirmation(order).deliver_later
37
+ WarehouseService.notify(order)
38
+ order
39
+ end
40
+ ```
41
+
42
+ **Fix:** Extract Method. Each comment-delimited section becomes a named method. Or better — each section becomes a service object.
43
+
44
+ ### Large Class
45
+
46
+ **Smell:** A class with 200+ lines, 15+ methods, or 7+ instance variables. In Rails, models that include 5+ concerns.
47
+
48
+ **Fix:** Extract Class. Identify clusters of methods that work together and move them into collaborator objects (service objects, value objects, query objects).
49
+
50
+ ### Long Parameter List
51
+
52
+ **Smell:** A method with 4+ parameters, especially positional ones.
53
+
54
+ ```ruby
55
+ # SMELL
56
+ def create_user(email, name, role, company_name, plan, referral_code, notify)
57
+
58
+ # FIX: Introduce Parameter Object or use keyword arguments
59
+ def create_user(email:, name:, role:, company_name:, plan:, referral_code: nil, notify: true)
60
+
61
+ # BETTER FIX: If parameters are always passed together, create a value object
62
+ RegistrationParams = Data.define(:email, :name, :role, :company_name, :plan, :referral_code)
63
+ def create_user(params, notify: true)
64
+ ```
65
+
66
+ ### Primitive Obsession
67
+
68
+ **Smell:** Using strings, integers, or hashes where a domain object would be clearer.
69
+
70
+ ```ruby
71
+ # SMELL: Money as a float, address as a hash
72
+ order.total = 19.99
73
+ order.address = { street: "123 Main", city: "Austin", state: "TX", zip: "78701" }
74
+
75
+ # FIX: Replace Data Value with Object
76
+ order.total = Money.new(19_99, "USD")
77
+ order.address = Address.new(street: "123 Main", city: "Austin", state: "TX", zip: "78701")
78
+ ```
79
+
80
+ Value objects have behavior — `money.to_s`, `address.full`, `money + other_money` — that primitives don't.
81
+
82
+ ## Couplers
83
+
84
+ Smells where classes are too intertwined.
85
+
86
+ ### Feature Envy
87
+
88
+ **Smell:** A method that uses more data from another object than from its own.
89
+
90
+ ```ruby
91
+ # SMELL: This method on OrderPresenter mostly accesses user attributes
92
+ class OrderPresenter
93
+ def shipping_label(order)
94
+ "#{order.user.name}\n#{order.user.address.street}\n#{order.user.address.city}, #{order.user.address.state} #{order.user.address.zip}"
95
+ end
96
+ end
97
+
98
+ # FIX: Move Method — the method belongs on Address or User
99
+ class Address
100
+ def to_label(name)
101
+ "#{name}\n#{street}\n#{city}, #{state} #{zip}"
102
+ end
103
+ end
104
+
105
+ # Usage
106
+ order.user.address.to_label(order.user.name)
107
+ ```
108
+
109
+ ### Message Chains (Law of Demeter Violation)
110
+
111
+ **Smell:** `order.user.company.billing_address.country.tax_rate` — a long chain of navigating object relationships.
112
+
113
+ ```ruby
114
+ # SMELL: Caller knows the entire object graph
115
+ tax_rate = order.user.company.billing_address.country.tax_rate
116
+
117
+ # FIX: Hide Delegate — each object only talks to its immediate neighbors
118
+ class Order
119
+ delegate :tax_rate, to: :user, prefix: false
120
+
121
+ # Or a dedicated method
122
+ def applicable_tax_rate
123
+ user.billing_tax_rate
124
+ end
125
+ end
126
+
127
+ class User
128
+ def billing_tax_rate
129
+ company.billing_tax_rate
130
+ end
131
+ end
132
+
133
+ class Company
134
+ def billing_tax_rate
135
+ billing_address.country_tax_rate
136
+ end
137
+ end
138
+
139
+ # Usage
140
+ order.applicable_tax_rate
141
+ ```
142
+
143
+ ### Inappropriate Intimacy
144
+
145
+ **Smell:** Two classes that access each other's internal details excessively.
146
+
147
+ ```ruby
148
+ # SMELL: Service reaches into order's internals
149
+ class ShippingCalculator
150
+ def calculate(order)
151
+ weight = order.instance_variable_get(:@total_weight) # Accessing internals!
152
+ order.line_items.each { |li| li.instance_variable_set(:@shipping_cost, weight * 0.5) }
153
+ end
154
+ end
155
+
156
+ # FIX: Use public interfaces
157
+ class ShippingCalculator
158
+ def calculate(order)
159
+ weight = order.total_weight # Public method
160
+ weight * shipping_rate_per_kg
161
+ end
162
+ end
163
+ ```
164
+
165
+ ## Dispensables
166
+
167
+ Smells where something isn't needed.
168
+
169
+ ### Dead Code
170
+
171
+ **Smell:** Methods, variables, classes, or branches that are never executed.
172
+
173
+ ```ruby
174
+ # SMELL: Method hasn't been called since 2023
175
+ def legacy_import(csv_path)
176
+ # 40 lines of import logic
177
+ end
178
+
179
+ # SMELL: Unreachable branch
180
+ def status_label
181
+ case status
182
+ when "active" then "Active"
183
+ when "inactive" then "Inactive"
184
+ when "deleted" then "Deleted" # status is never "deleted" — soft delete uses discarded_at
185
+ end
186
+ end
187
+ ```
188
+
189
+ **Fix:** Delete it. Version control has the history if you ever need it. Dead code creates confusion ("is this still used?"), false grep results, and maintenance burden.
190
+
191
+ ### Speculative Generality
192
+
193
+ **Smell:** Abstractions, hooks, parameters, or classes that exist "in case we need them later" but have no current use.
194
+
195
+ ```ruby
196
+ # SMELL: AbstractNotificationFactory that only has one subclass
197
+ class AbstractNotificationFactory
198
+ def build(type, **opts)
199
+ raise NotImplementedError
200
+ end
201
+ end
202
+
203
+ class EmailNotificationFactory < AbstractNotificationFactory
204
+ def build(type, **opts)
205
+ # ... this is the only implementation
206
+ end
207
+ end
208
+ ```
209
+
210
+ **Fix:** Delete the abstraction. When you actually need a second factory, extract the interface then. YAGNI (You Ain't Gonna Need It).
211
+
212
+ ### Duplicate Code
213
+
214
+ **Smell:** The same code structure in two or more places.
215
+
216
+ ```ruby
217
+ # SMELL: Same pattern in two controllers
218
+ class OrdersController < ApplicationController
219
+ def index
220
+ @orders = current_user.orders
221
+ @orders = @orders.where(status: params[:status]) if params[:status].present?
222
+ @orders = @orders.where("created_at >= ?", params[:from]) if params[:from].present?
223
+ @orders = @orders.page(params[:page])
224
+ end
225
+ end
226
+
227
+ class InvoicesController < ApplicationController
228
+ def index
229
+ @invoices = current_user.invoices
230
+ @invoices = @invoices.where(status: params[:status]) if params[:status].present?
231
+ @invoices = @invoices.where("created_at >= ?", params[:from]) if params[:from].present?
232
+ @invoices = @invoices.page(params[:page])
233
+ end
234
+ end
235
+ ```
236
+
237
+ **Fix:** Extract the filtering logic into a query object or a concern that both controllers use:
238
+
239
+ ```ruby
240
+ class FilteredQuery
241
+ def self.call(scope, params)
242
+ scope = scope.where(status: params[:status]) if params[:status].present?
243
+ scope = scope.where("created_at >= ?", params[:from]) if params[:from].present?
244
+ scope.page(params[:page])
245
+ end
246
+ end
247
+ ```
248
+
249
+ ## How Rubyn Uses This
250
+
251
+ When analyzing code, Rubyn identifies these smells and suggests the specific refactoring to fix them. The recommendation always includes the smell name, why it matters, and the concrete transformation — not just "this method is too long."
@@ -0,0 +1,166 @@
1
+ # Refactoring: Separate Query from Modifier (CQS)
2
+
3
+ ## Pattern
4
+
5
+ A method should either return a value (query) or change state (command), but not both. When a method does both — returns data AND has side effects — split it into two methods. This is the Command-Query Separation (CQS) principle.
6
+
7
+ ```ruby
8
+ # BEFORE: Method both modifies state AND returns a value
9
+ class ShoppingCart
10
+ def add_item(product, quantity: 1)
11
+ item = @items.find { |i| i.product == product }
12
+ if item
13
+ item.quantity += quantity
14
+ else
15
+ @items << CartItem.new(product: product, quantity: quantity)
16
+ end
17
+ calculate_total # Returns the new total — side effect + return value mixed
18
+ end
19
+
20
+ def remove_expired_items
21
+ expired = @items.select { |item| item.product.expired? }
22
+ @items -= expired
23
+ expired # Returns removed items AND modifies the cart
24
+ end
25
+ end
26
+
27
+ # Usage is confusing — does this return something? Change something? Both?
28
+ total = cart.add_item(widget)
29
+ removed = cart.remove_expired_items
30
+ ```
31
+
32
+ ```ruby
33
+ # AFTER: Commands modify state. Queries return data. They don't overlap.
34
+ class ShoppingCart
35
+ # COMMANDS: modify state, return nothing meaningful (or self for chaining)
36
+ def add_item(product, quantity: 1)
37
+ item = @items.find { |i| i.product == product }
38
+ if item
39
+ item.quantity += quantity
40
+ else
41
+ @items << CartItem.new(product: product, quantity: quantity)
42
+ end
43
+ nil # Or `self` for chaining
44
+ end
45
+
46
+ def remove_expired_items
47
+ @items.reject! { |item| item.product.expired? }
48
+ nil
49
+ end
50
+
51
+ # QUERIES: return data, never modify state
52
+ def total
53
+ @items.sum { |item| item.quantity * item.product.price }
54
+ end
55
+
56
+ def expired_items
57
+ @items.select { |item| item.product.expired? }
58
+ end
59
+
60
+ def item_count
61
+ @items.sum(&:quantity)
62
+ end
63
+
64
+ def empty?
65
+ @items.empty?
66
+ end
67
+ end
68
+
69
+ # Usage is clear — commands do, queries ask
70
+ cart.add_item(widget, quantity: 2)
71
+ cart.remove_expired_items
72
+ puts cart.total
73
+ puts cart.expired_items.map(&:product_name)
74
+ ```
75
+
76
+ ### CQS in Rails
77
+
78
+ ```ruby
79
+ # BEFORE: Scope that modifies data (violates CQS)
80
+ class Order < ApplicationRecord
81
+ scope :archive_old, -> {
82
+ where(created_at: ...90.days.ago).update_all(archived: true)
83
+ # This is a command disguised as a scope — scopes should be queries
84
+ }
85
+ end
86
+
87
+ # AFTER: Scope queries, service commands
88
+ class Order < ApplicationRecord
89
+ scope :archivable, -> { where(created_at: ...90.days.ago, archived: false) }
90
+ end
91
+
92
+ class Orders::ArchiveService
93
+ def self.call
94
+ count = Order.archivable.update_all(archived: true, archived_at: Time.current)
95
+ Result.new(success: true, count: count)
96
+ end
97
+ end
98
+
99
+ # Usage
100
+ puts "#{Order.archivable.count} orders to archive" # Query
101
+ Orders::ArchiveService.call # Command
102
+ ```
103
+
104
+ ```ruby
105
+ # BEFORE: Method that checks permission AND logs the attempt
106
+ class Authorization
107
+ def authorized?(user, action)
108
+ allowed = user.permissions.include?(action)
109
+ AuditLog.create!(user: user, action: action, allowed: allowed) # Side effect!
110
+ allowed
111
+ end
112
+ end
113
+
114
+ # Calling code doesn't expect a query to write to the database
115
+ if auth.authorized?(user, :delete_order) # Surprise! This created an audit record
116
+ order.destroy!
117
+ end
118
+
119
+ # AFTER: Separated
120
+ class Authorization
121
+ def authorized?(user, action)
122
+ user.permissions.include?(action)
123
+ end
124
+
125
+ def check_and_log(user, action)
126
+ allowed = authorized?(user, action)
127
+ AuditLog.create!(user: user, action: action, allowed: allowed)
128
+ allowed
129
+ end
130
+ end
131
+ ```
132
+
133
+ ## Why This Is Good
134
+
135
+ - **Queries are safe to call anywhere.** If `total` only reads data, calling it in a view, a test, or a debug session never changes state. No surprises.
136
+ - **Commands are explicit about mutation.** When you see `cart.add_item(widget)`, you know state is changing. When you see `cart.total`, you know it's read-only.
137
+ - **Easier to test.** Queries are tested with simple assertions on return values. Commands are tested by checking state before and after. When they're mixed, you have to assert both.
138
+ - **Easier to reason about.** In concurrent systems, queries are safe to parallelize. Commands need synchronization. Knowing which is which matters.
139
+ - **Caching is safe for queries.** You can cache `cart.total` because calling it doesn't change anything. If `total` also triggered a recalculation and saved to the database, caching it would be dangerous.
140
+
141
+ ## When To Apply
142
+
143
+ - **Methods that both return a value AND modify state.** These are CQS violations. Split them.
144
+ - **ActiveRecord scopes that modify data.** Scopes should query. Services should command.
145
+ - **Methods named like queries that have side effects.** `user.authorized?` shouldn't write to an audit log. `user.full_name` shouldn't trigger a name parsing service.
146
+ - **APIs where calling a "getter" triggers unexpected behavior.** If reading a property sends an HTTP request, logs to a database, or increments a counter — separate the read from the write.
147
+
148
+ ## When NOT To Apply
149
+
150
+ - **`save` and `update` return a boolean.** Rails' `order.save` both modifies state and returns true/false. This is a pragmatic CQS violation that Rails developers expect. Don't fight it.
151
+ - **`pop` and `shift` on arrays.** These both modify the array and return the removed element. They're standard Ruby and universally understood.
152
+ - **Idempotent cache operations.** `Rails.cache.fetch(key) { compute }` both reads and writes, but it's idempotent and universally expected. Don't split it.
153
+ - **The split would make code significantly harder to use.** CQS is a guideline for clarity. If separating a method makes the API confusing, keep them together and document the behavior.
154
+
155
+ ## Edge Cases
156
+
157
+ **`find_or_create_by` is a deliberate CQS violation:**
158
+ ```ruby
159
+ user = User.find_or_create_by(email: "alice@example.com") do |u|
160
+ u.name = "Alice"
161
+ end
162
+ ```
163
+ This queries and potentially creates. It's a Rails convention that everyone understands. Don't wrap it in a service object for CQS purity.
164
+
165
+ **The "Tell, Don't Ask" tension:**
166
+ CQS says "separate queries from commands." Tell Don't Ask says "don't query an object then act on the result — tell the object to act." These can conflict. In practice, CQS applies to individual methods, and Tell Don't Ask applies to object interactions. Both are guidelines, not laws.
@@ -0,0 +1,125 @@
1
+ # Refactoring: Encapsulate Collection
2
+
3
+ ## Pattern
4
+
5
+ When a class exposes a raw collection (array, hash) through a getter, external code can modify it without the owning class knowing. Encapsulate the collection by providing specific methods for adding, removing, and querying — never exposing the raw collection.
6
+
7
+ ```ruby
8
+ # BEFORE: Exposed collection — anyone can mutate it
9
+ class Order
10
+ attr_accessor :line_items
11
+
12
+ def initialize
13
+ @line_items = []
14
+ end
15
+
16
+ def total
17
+ @line_items.sum { |item| item.quantity * item.unit_price }
18
+ end
19
+ end
20
+
21
+ order = Order.new
22
+ order.line_items << LineItem.new(quantity: 2, unit_price: 10_00)
23
+ order.line_items.delete_at(0) # External code mutates the collection
24
+ order.line_items = [] # External code replaces the entire collection
25
+ order.line_items.clear # External code empties it
26
+ # The Order has no control over its own state
27
+ ```
28
+
29
+ ```ruby
30
+ # AFTER: Encapsulated — Order controls all access
31
+ class Order
32
+ def initialize
33
+ @line_items = []
34
+ end
35
+
36
+ def add_item(product:, quantity:)
37
+ raise ArgumentError, "Quantity must be positive" unless quantity > 0
38
+
39
+ existing = @line_items.find { |li| li.product == product }
40
+ if existing
41
+ existing.quantity += quantity
42
+ else
43
+ @line_items << LineItem.new(product: product, quantity: quantity, unit_price: product.price)
44
+ end
45
+ end
46
+
47
+ def remove_item(product)
48
+ @line_items.reject! { |li| li.product == product }
49
+ end
50
+
51
+ def line_items
52
+ @line_items.dup.freeze # Return a frozen copy — mutations don't affect the original
53
+ end
54
+
55
+ def item_count
56
+ @line_items.sum(&:quantity)
57
+ end
58
+
59
+ def empty?
60
+ @line_items.empty?
61
+ end
62
+
63
+ def total
64
+ @line_items.sum { |li| li.quantity * li.unit_price }
65
+ end
66
+ end
67
+
68
+ order = Order.new
69
+ order.add_item(product: widget, quantity: 2) # Controlled: validates, merges duplicates
70
+ order.remove_item(widget) # Controlled: uses Order's own method
71
+ order.line_items # Returns frozen copy — can read but not mutate
72
+ order.line_items << something # FrozenError — can't modify the copy
73
+ ```
74
+
75
+ ## Why This Is Good
76
+
77
+ - **Invariants are enforced.** `add_item` validates quantity, merges duplicates, and sets unit price from the product. Raw `<<` skips all of this.
78
+ - **Change notification is possible.** If `add_item` needs to recalculate totals, trigger events, or update caches, it can. Raw mutation bypasses all hooks.
79
+ - **The collection can't be replaced.** No `order.line_items = []` wiping the data. The only way to modify is through the Order's intentional interface.
80
+ - **Frozen copies enable safe reads.** Callers can iterate, map, and filter the returned collection without accidentally modifying the Order's state.
81
+
82
+ ## When To Apply
83
+
84
+ - **Any class that owns a collection.** If a class has an `attr_accessor` or `attr_reader` for an Array or Hash, encapsulate it.
85
+ - **When the collection has rules.** No duplicates, maximum size, items must be valid, items must belong to the parent — these rules belong in the owning class, not scattered across callers.
86
+ - **Domain objects and value objects.** `Cart`, `Order`, `Playlist`, `Team` — anything with a "contains items" relationship.
87
+
88
+ ## When NOT To Apply
89
+
90
+ - **ActiveRecord associations.** `has_many :line_items` is already encapsulated by Rails with callbacks, validations, and scoping. Don't wrap it in another layer.
91
+ - **Simple data transfer objects.** A Struct or Data class that just carries data doesn't need encapsulation — it's intentionally transparent.
92
+ - **Internal implementation details.** If the collection is only used inside the class and never exposed, encapsulation isn't needed.
93
+
94
+ ## Edge Cases
95
+
96
+ **Exposing an iterator instead of the collection:**
97
+
98
+ ```ruby
99
+ def each_item(&block)
100
+ @line_items.each(&block)
101
+ end
102
+ include Enumerable # Now Order is iterable but the array isn't exposed
103
+ ```
104
+
105
+ **Hash encapsulation:**
106
+
107
+ ```ruby
108
+ class Configuration
109
+ def initialize
110
+ @settings = {}
111
+ end
112
+
113
+ def set(key, value)
114
+ @settings[key.to_sym] = value
115
+ end
116
+
117
+ def get(key, default: nil)
118
+ @settings.fetch(key.to_sym, default)
119
+ end
120
+
121
+ def to_h
122
+ @settings.dup.freeze
123
+ end
124
+ end
125
+ ```
@@ -0,0 +1,138 @@
1
+ # Refactoring: Extract Class
2
+
3
+ ## Pattern
4
+
5
+ When a class has too many responsibilities — groups of data and methods that logically belong together — extract them into a new class. The original class delegates to the extracted class.
6
+
7
+ ```ruby
8
+ # BEFORE: User model handles profile, settings, AND billing
9
+ class User < ApplicationRecord
10
+ # Profile concern
11
+ def full_name = "#{first_name} #{last_name}"
12
+ def initials = "#{first_name[0]}#{last_name[0]}".upcase
13
+ def display_name = nickname.presence || full_name
14
+ def avatar_url = avatar.attached? ? avatar.url : gravatar_url
15
+ def gravatar_url = "https://gravatar.com/avatar/#{Digest::MD5.hexdigest(email)}"
16
+
17
+ # Billing concern
18
+ def active_subscription = subscriptions.active.last
19
+ def plan_name = active_subscription&.plan || "free"
20
+ def credit_balance = credit_ledger_entries.sum(:amount)
21
+ def can_afford?(credits) = credit_balance >= credits
22
+ def deduct_credits!(amount)
23
+ credit_ledger_entries.create!(amount: -amount, description: "Usage")
24
+ end
25
+ def billing_email = billing_email_override.presence || email
26
+ def billing_address = addresses.find_by(type: "billing")
27
+
28
+ # Settings concern
29
+ def notification_preferences = settings.dig("notifications") || {}
30
+ def email_notifications? = notification_preferences.fetch("email", true)
31
+ def theme = settings.dig("appearance", "theme") || "system"
32
+ def timezone = settings.dig("timezone") || "UTC"
33
+ def locale = settings.dig("locale") || "en"
34
+ end
35
+ ```
36
+
37
+ ```ruby
38
+ # AFTER: Extracted into focused collaborators
39
+
40
+ class User < ApplicationRecord
41
+ has_one :profile, dependent: :destroy
42
+ has_one :billing_account, dependent: :destroy
43
+ has_one :user_settings, dependent: :destroy
44
+
45
+ delegate :full_name, :initials, :display_name, :avatar_url, to: :profile
46
+ delegate :credit_balance, :can_afford?, :deduct_credits!, :plan_name, to: :billing_account
47
+ delegate :email_notifications?, :theme, :timezone, :locale, to: :user_settings
48
+ end
49
+
50
+ class Profile < ApplicationRecord
51
+ belongs_to :user
52
+
53
+ def full_name = "#{user.first_name} #{user.last_name}"
54
+ def initials = "#{user.first_name[0]}#{user.last_name[0]}".upcase
55
+ def display_name = nickname.presence || full_name
56
+ def avatar_url = avatar.attached? ? avatar.url : gravatar_url
57
+
58
+ private
59
+
60
+ def gravatar_url = "https://gravatar.com/avatar/#{Digest::MD5.hexdigest(user.email)}"
61
+ end
62
+
63
+ class BillingAccount < ApplicationRecord
64
+ belongs_to :user
65
+ has_many :credit_ledger_entries
66
+ has_many :subscriptions
67
+
68
+ def active_subscription = subscriptions.active.last
69
+ def plan_name = active_subscription&.plan || "free"
70
+ def credit_balance = credit_ledger_entries.sum(:amount)
71
+ def can_afford?(credits) = credit_balance >= credits
72
+
73
+ def deduct_credits!(amount)
74
+ credit_ledger_entries.create!(amount: -amount, description: "Usage")
75
+ end
76
+ end
77
+
78
+ class UserSettings < ApplicationRecord
79
+ belongs_to :user
80
+
81
+ def email_notifications? = preferences.dig("notifications", "email") != false
82
+ def theme = preferences.dig("appearance", "theme") || "system"
83
+ def timezone = preferences.dig("timezone") || "UTC"
84
+ def locale = preferences.dig("locale") || "en"
85
+ end
86
+ ```
87
+
88
+ ## Why This Is Good
89
+
90
+ - **Each class has one reason to change.** Billing rule changes touch `BillingAccount`. Display changes touch `Profile`. Notification settings touch `UserSettings`. The `User` model stays stable.
91
+ - **Smaller classes are easier to understand.** `BillingAccount` has 5 methods about billing. Reading it, you grasp the entire billing interface in 30 seconds.
92
+ - **Better testing.** Test `BillingAccount#deduct_credits!` without loading profile logic, settings, or 20 other user methods.
93
+ - **`delegate` maintains the interface.** Callers still call `user.credit_balance`. The extraction is invisible to external code.
94
+
95
+ # Refactoring: Move Method
96
+
97
+ ## Pattern
98
+
99
+ When a method uses more features of another class than the class it's defined on, move it to where the data lives.
100
+
101
+ ```ruby
102
+ # BEFORE: Method on Order that mostly accesses User data
103
+ class Order < ApplicationRecord
104
+ def customer_summary
105
+ "#{user.name} (#{user.email}) — #{user.plan_name} plan, #{user.orders.count} orders, " \
106
+ "member since #{user.created_at.year}"
107
+ end
108
+ end
109
+
110
+ # AFTER: Method moved to User where the data lives
111
+ class User < ApplicationRecord
112
+ def customer_summary
113
+ "#{name} (#{email}) — #{plan_name} plan, #{orders.count} orders, member since #{created_at.year}"
114
+ end
115
+ end
116
+
117
+ # Order delegates or the caller accesses directly
118
+ order.user.customer_summary
119
+ ```
120
+
121
+ ## When To Apply Extract Class
122
+
123
+ - **A class has 200+ lines.** Look for clusters of related methods to extract.
124
+ - **You can describe the class with "and."** "User handles authentication AND billing AND settings" → extract billing and settings.
125
+ - **Multiple developers frequently edit the same file.** Different teams own different responsibilities → different classes.
126
+ - **A group of methods share the same instance variables.** Methods that all use `@subscription` and `@credit_entries` are a billing class waiting to be extracted.
127
+
128
+ ## When To Apply Move Method
129
+
130
+ - **Feature Envy.** A method references another object 3+ times and its own object 0-1 times.
131
+ - **After Extract Class.** Once you identify a cluster, move the methods to the new class.
132
+ - **When adding `delegate` chains.** If `User` delegates 5 methods to `BillingAccount` and then adds `billing_` prefix methods, maybe those callers should reference `BillingAccount` directly.
133
+
134
+ ## When NOT To Apply
135
+
136
+ - **Don't extract prematurely.** A User model with 80 lines and 8 methods is fine. Extract when it grows past 150-200 lines or when the clusters become obvious.
137
+ - **Don't create single-method classes.** A `UserGreeter` with just `def greet` is over-extraction. The method can live on User.
138
+ - **Delegate is fine for 3-5 methods.** If User delegates 15 methods to a single collaborator, callers should reference the collaborator directly.