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,221 @@
1
+ # Sinatra: Middleware, Configuration, and Deployment
2
+
3
+ ## Pattern
4
+
5
+ Sinatra apps are Rack apps. Use Rack middleware for cross-cutting concerns, environment-specific configuration for different stages, and standard deployment patterns for production.
6
+
7
+ ### Middleware Stack
8
+
9
+ ```ruby
10
+ # app/api.rb
11
+ module MyApp
12
+ class Api < Sinatra::Base
13
+ # Request/Response middleware
14
+ use Rack::JSONBodyParser # Parse JSON bodies into params
15
+ use Rack::Cors do # CORS for API clients
16
+ allow do
17
+ origins "*"
18
+ resource "/api/*", headers: :any, methods: [:get, :post, :put, :delete]
19
+ end
20
+ end
21
+
22
+ # Custom middleware
23
+ use RequestLogger # Log every request
24
+ use RateLimiter, limit: 100, period: 60 # 100 req/min
25
+ end
26
+ end
27
+ ```
28
+
29
+ ```ruby
30
+ # app/middleware/request_logger.rb
31
+ class RequestLogger
32
+ def initialize(app)
33
+ @app = app
34
+ @logger = Logger.new($stdout)
35
+ end
36
+
37
+ def call(env)
38
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
39
+ status, headers, body = @app.call(env)
40
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
41
+
42
+ @logger.info("#{env['REQUEST_METHOD']} #{env['PATH_INFO']} → #{status} (#{(elapsed * 1000).round}ms)")
43
+
44
+ [status, headers, body]
45
+ end
46
+ end
47
+ ```
48
+
49
+ ```ruby
50
+ # app/middleware/rate_limiter.rb
51
+ class RateLimiter
52
+ def initialize(app, limit: 60, period: 60)
53
+ @app = app
54
+ @limit = limit
55
+ @period = period
56
+ @store = {} # Use Redis in production
57
+ end
58
+
59
+ def call(env)
60
+ key = client_key(env)
61
+ count = increment(key)
62
+
63
+ if count > @limit
64
+ [429, { "Content-Type" => "application/json", "Retry-After" => @period.to_s },
65
+ ['{"error":"Rate limited"}']]
66
+ else
67
+ @app.call(env)
68
+ end
69
+ end
70
+
71
+ private
72
+
73
+ def client_key(env)
74
+ ip = env["HTTP_X_FORWARDED_FOR"]&.split(",")&.first || env["REMOTE_ADDR"]
75
+ token = env["HTTP_AUTHORIZATION"]&.split(" ")&.last
76
+ "rate:#{token || ip}:#{(Time.now.to_i / @period)}"
77
+ end
78
+
79
+ def increment(key)
80
+ @store[key] = (@store[key] || 0) + 1
81
+ end
82
+ end
83
+ ```
84
+
85
+ ### Environment Configuration
86
+
87
+ ```ruby
88
+ # app/api.rb
89
+ module MyApp
90
+ class Api < Sinatra::Base
91
+ configure do
92
+ set :root, File.dirname(__FILE__)
93
+ set :views, File.join(root, "views")
94
+ set :public_folder, File.join(root, "..", "public")
95
+
96
+ # Don't show raw errors to users
97
+ set :show_exceptions, false
98
+ set :raise_errors, false
99
+
100
+ enable :logging
101
+ end
102
+
103
+ configure :development do
104
+ set :show_exceptions, :after_handler
105
+ enable :reloader # Reloads code on changes (with sinatra-contrib)
106
+ end
107
+
108
+ configure :test do
109
+ set :raise_errors, true # Let errors propagate to tests
110
+ end
111
+
112
+ configure :production do
113
+ enable :logging
114
+
115
+ # Force SSL
116
+ use Rack::SslEnforcer if ENV["FORCE_SSL"]
117
+ end
118
+ end
119
+ end
120
+ ```
121
+
122
+ ### Database Setup (ActiveRecord or Sequel)
123
+
124
+ ```ruby
125
+ # With sinatra-activerecord gem
126
+ # Gemfile
127
+ gem "sinatra-activerecord"
128
+ gem "pg"
129
+
130
+ # config/database.yml
131
+ development:
132
+ adapter: postgresql
133
+ database: my_app_development
134
+
135
+ test:
136
+ adapter: postgresql
137
+ database: my_app_test
138
+
139
+ production:
140
+ url: <%= ENV["DATABASE_URL"] %>
141
+
142
+ # Rakefile
143
+ require_relative "config/environment"
144
+ require "sinatra/activerecord/rake"
145
+
146
+ # Now you get: rake db:create, db:migrate, db:seed, etc.
147
+ ```
148
+
149
+ ```ruby
150
+ # With Sequel (lightweight alternative)
151
+ # Gemfile
152
+ gem "sequel"
153
+ gem "pg"
154
+
155
+ # config/environment.rb
156
+ DB = Sequel.connect(ENV.fetch("DATABASE_URL", "postgres://localhost/my_app_dev"))
157
+ Sequel.extension :migration
158
+
159
+ # db/migrate/001_create_orders.rb
160
+ Sequel.migration do
161
+ change do
162
+ create_table(:orders) do
163
+ primary_key :id
164
+ String :reference, null: false, unique: true
165
+ Integer :total, null: false
166
+ String :status, default: "pending"
167
+ DateTime :created_at
168
+ DateTime :updated_at
169
+ end
170
+ end
171
+ end
172
+ ```
173
+
174
+ ### Deployment (Puma)
175
+
176
+ ```ruby
177
+ # config/puma.rb
178
+ workers ENV.fetch("WEB_CONCURRENCY", 2).to_i
179
+ threads_count = ENV.fetch("MAX_THREADS", 5).to_i
180
+ threads threads_count, threads_count
181
+
182
+ port ENV.fetch("PORT", 3000)
183
+ environment ENV.fetch("RACK_ENV", "development")
184
+
185
+ preload_app!
186
+ ```
187
+
188
+ ```ruby
189
+ # Procfile (for Heroku/DigitalOcean App Platform)
190
+ web: bundle exec puma -C config/puma.rb
191
+
192
+ # Docker
193
+ FROM ruby:3.3-slim
194
+ WORKDIR /app
195
+ COPY Gemfile Gemfile.lock ./
196
+ RUN bundle install --without development test
197
+ COPY . .
198
+ EXPOSE 3000
199
+ CMD ["bundle", "exec", "puma", "-C", "config/puma.rb"]
200
+ ```
201
+
202
+ ## Why This Is Good
203
+
204
+ - **Middleware is composable.** Each middleware handles one concern: logging, CORS, rate limiting, SSL. Stack them in any order.
205
+ - **Same Rack ecosystem as Rails.** Rack::Cors, Rack::Attack, and other middleware work identically in Sinatra and Rails.
206
+ - **Lightweight deployment.** A Sinatra app starts in milliseconds and uses ~30MB of RAM. Perfect for microservices and sidecar APIs.
207
+ - **Standard tooling.** Puma, Procfile, Docker — the same deployment stack as Rails. No special Sinatra knowledge needed.
208
+
209
+ ## When To Choose Sinatra
210
+
211
+ - **Focused API services** — webhook receivers, proxy APIs, embedding service wrappers
212
+ - **Microservices** — small, single-purpose services with 5-15 endpoints
213
+ - **Internal tools** — health dashboards, admin APIs, CLI backend services
214
+ - **When boot time matters** — Lambda functions, short-lived containers
215
+
216
+ ## When To Choose Rails Instead
217
+
218
+ - **Full-stack web apps** — forms, views, sessions, asset pipeline, mailers
219
+ - **Complex data models** — 20+ tables with associations, migrations, seeds
220
+ - **Team projects** — Rails conventions mean less decision-making and easier onboarding
221
+ - **Rapid prototyping** — Rails generators and scaffolds are faster for CRUD
@@ -0,0 +1,233 @@
1
+ # Sinatra: Testing with Rack::Test
2
+
3
+ ## Pattern
4
+
5
+ Test Sinatra apps using `rack-test`, which provides HTTP method helpers that hit your app directly (no real HTTP server needed). Tests are fast, isolated, and exercise the full Rack middleware stack.
6
+
7
+ ```ruby
8
+ # Gemfile
9
+ group :test do
10
+ gem "rack-test"
11
+ gem "minitest"
12
+ gem "database_cleaner-active_record"
13
+ end
14
+ ```
15
+
16
+ ```ruby
17
+ # test/test_helper.rb
18
+ ENV["RACK_ENV"] = "test"
19
+
20
+ require_relative "../config/environment"
21
+ require "minitest/autorun"
22
+ require "minitest/pride"
23
+ require "rack/test"
24
+
25
+ class Minitest::Test
26
+ include Rack::Test::Methods
27
+
28
+ def app
29
+ MyApp::Api
30
+ end
31
+
32
+ def json_body
33
+ JSON.parse(last_response.body, symbolize_names: true)
34
+ end
35
+
36
+ def auth_header(user)
37
+ { "HTTP_AUTHORIZATION" => "Bearer #{user.api_token}" }
38
+ end
39
+
40
+ def post_json(path, body, headers = {})
41
+ post path, body.to_json, headers.merge("CONTENT_TYPE" => "application/json")
42
+ end
43
+ end
44
+ ```
45
+
46
+ ```ruby
47
+ # test/routes/health_test.rb
48
+ require "test_helper"
49
+
50
+ class HealthTest < Minitest::Test
51
+ def test_returns_ok
52
+ get "/health"
53
+
54
+ assert_equal 200, last_response.status
55
+ assert_equal "ok", json_body[:status]
56
+ end
57
+ end
58
+ ```
59
+
60
+ ```ruby
61
+ # test/routes/orders_test.rb
62
+ require "test_helper"
63
+
64
+ class OrdersTest < Minitest::Test
65
+ def setup
66
+ @user = User.create!(email: "alice@example.com", name: "Alice", api_token: "test-token-123")
67
+ @order = Order.create!(user: @user, reference: "ORD-001", shipping_address: "123 Main", status: "pending", total: 50_00)
68
+ end
69
+
70
+ def teardown
71
+ DatabaseCleaner.clean
72
+ end
73
+
74
+ # INDEX
75
+ def test_index_returns_orders
76
+ get "/orders", {}, auth_header(@user)
77
+
78
+ assert_equal 200, last_response.status
79
+ assert_equal 1, json_body[:orders].length
80
+ assert_equal "ORD-001", json_body[:orders].first[:reference]
81
+ end
82
+
83
+ def test_index_requires_auth
84
+ get "/orders"
85
+
86
+ assert_equal 401, last_response.status
87
+ assert_equal "Unauthorized", json_body[:error]
88
+ end
89
+
90
+ def test_index_only_returns_current_users_orders
91
+ other_user = User.create!(email: "bob@example.com", name: "Bob", api_token: "bob-token")
92
+ Order.create!(user: other_user, reference: "ORD-002", shipping_address: "456 Oak", status: "pending", total: 25_00)
93
+
94
+ get "/orders", {}, auth_header(@user)
95
+
96
+ references = json_body[:orders].map { |o| o[:reference] }
97
+ assert_includes references, "ORD-001"
98
+ refute_includes references, "ORD-002"
99
+ end
100
+
101
+ # SHOW
102
+ def test_show_returns_order
103
+ get "/orders/#{@order.id}", {}, auth_header(@user)
104
+
105
+ assert_equal 200, last_response.status
106
+ assert_equal "ORD-001", json_body[:order][:reference]
107
+ end
108
+
109
+ def test_show_returns_404_for_missing_order
110
+ get "/orders/999999", {}, auth_header(@user)
111
+
112
+ assert_equal 404, last_response.status
113
+ end
114
+
115
+ # CREATE
116
+ def test_create_with_valid_params
117
+ post_json "/orders", {
118
+ shipping_address: "789 Elm St",
119
+ line_items: [{ product_id: 1, quantity: 2 }]
120
+ }, auth_header(@user)
121
+
122
+ assert_equal 201, last_response.status
123
+ assert json_body[:order][:id].present?
124
+ assert_equal "pending", json_body[:order][:status]
125
+ end
126
+
127
+ def test_create_with_invalid_params
128
+ post_json "/orders", { shipping_address: "" }, auth_header(@user)
129
+
130
+ assert_equal 422, last_response.status
131
+ assert json_body[:details].any?
132
+ end
133
+
134
+ def test_create_requires_auth
135
+ post_json "/orders", { shipping_address: "123 Main" }
136
+
137
+ assert_equal 401, last_response.status
138
+ end
139
+
140
+ # DELETE
141
+ def test_delete_removes_order
142
+ count_before = Order.count
143
+
144
+ delete "/orders/#{@order.id}", {}, auth_header(@user)
145
+
146
+ assert_equal 200, last_response.status
147
+ assert_equal count_before - 1, Order.count
148
+ end
149
+
150
+ def test_delete_cannot_remove_other_users_order
151
+ other_user = User.create!(email: "bob@example.com", name: "Bob", api_token: "bob-token")
152
+ bobs_order = Order.create!(user: other_user, reference: "ORD-BOB", shipping_address: "456", status: "pending", total: 10_00)
153
+
154
+ delete "/orders/#{bobs_order.id}", {}, auth_header(@user)
155
+
156
+ assert_equal 404, last_response.status
157
+ assert Order.exists?(bobs_order.id) # Still exists
158
+ end
159
+ end
160
+ ```
161
+
162
+ Testing services (framework-independent):
163
+
164
+ ```ruby
165
+ # test/services/orders/create_service_test.rb
166
+ require "test_helper"
167
+
168
+ class Orders::CreateServiceTest < Minitest::Test
169
+ def setup
170
+ @user = User.create!(email: "alice@example.com", name: "Alice", api_token: "token")
171
+ end
172
+
173
+ def test_creates_order_with_valid_params
174
+ result = Orders::CreateService.call({ shipping_address: "123 Main" }, @user)
175
+
176
+ assert result.success?
177
+ assert_instance_of Order, result.order
178
+ assert result.order.persisted?
179
+ end
180
+
181
+ def test_returns_failure_for_invalid_params
182
+ result = Orders::CreateService.call({ shipping_address: "" }, @user)
183
+
184
+ refute result.success?
185
+ assert result.order.errors[:shipping_address].any?
186
+ end
187
+
188
+ def teardown
189
+ DatabaseCleaner.clean
190
+ end
191
+ end
192
+ ```
193
+
194
+ ## Why This Is Good
195
+
196
+ - **No HTTP server needed.** `rack-test` calls the app directly through Rack. Tests run in milliseconds, not seconds.
197
+ - **Full middleware stack.** Authentication middleware, JSON parsing, error handling — all exercised just like production.
198
+ - **`last_response` gives you everything.** Status code, body, headers, content type. Assert on any of them.
199
+ - **Service tests are framework-agnostic.** `Orders::CreateService.call(params, user)` is tested identically whether it's used in Sinatra, Rails, or a CLI tool.
200
+
201
+ ## Key Methods
202
+
203
+ | Method | Purpose |
204
+ |---|---|
205
+ | `get "/path"` | GET request |
206
+ | `post "/path", body, headers` | POST request |
207
+ | `put "/path", body, headers` | PUT request |
208
+ | `delete "/path"` | DELETE request |
209
+ | `last_response.status` | HTTP status code |
210
+ | `last_response.body` | Response body string |
211
+ | `last_response.headers` | Response headers hash |
212
+ | `last_response.ok?` | Status is 200? |
213
+ | `last_response.redirect?` | Status is 3xx? |
214
+ | `follow_redirect!` | Follow a redirect |
215
+
216
+ ## Anti-Pattern
217
+
218
+ Testing by starting a real HTTP server:
219
+
220
+ ```ruby
221
+ # BAD: Starting a server for tests
222
+ def setup
223
+ @server = Thread.new { MyApp::Api.run! port: 4567 }
224
+ sleep 1 # Wait for server to start
225
+ end
226
+
227
+ def test_health
228
+ response = Net::HTTP.get(URI("http://localhost:4567/health"))
229
+ # Slow, flaky, port conflicts
230
+ end
231
+ ```
232
+
233
+ Use `rack-test` — it's faster, more reliable, and doesn't need network ports.
@@ -0,0 +1,195 @@
1
+ # SOLID: Dependency Inversion Principle (DIP)
2
+
3
+ ## Pattern
4
+
5
+ High-level modules should not depend on low-level modules. Both should depend on abstractions. In Ruby, this means: depend on duck-typed interfaces (what an object *does*), not on concrete classes (what an object *is*). Inject dependencies rather than hardcoding them.
6
+
7
+ ```ruby
8
+ # GOOD: High-level service depends on an injected abstraction, not a concrete class
9
+
10
+ class Ai::CompletionService
11
+ # Depends on: any object that responds to .complete(messages, model:, max_tokens:)
12
+ # Does NOT depend on: Anthropic::Client specifically
13
+ def initialize(client:)
14
+ @client = client
15
+ end
16
+
17
+ def call(prompt, context:)
18
+ messages = build_messages(prompt, context)
19
+ response = @client.complete(messages, model: "claude-haiku-4-5-20251001", max_tokens: 4096)
20
+
21
+ Result.new(
22
+ content: response.content,
23
+ input_tokens: response.input_tokens,
24
+ output_tokens: response.output_tokens
25
+ )
26
+ end
27
+
28
+ private
29
+
30
+ def build_messages(prompt, context)
31
+ [
32
+ { role: "system", content: context },
33
+ { role: "user", content: prompt }
34
+ ]
35
+ end
36
+ end
37
+
38
+ # Production: real Anthropic client
39
+ client = Anthropic::Client.new(api_key: ENV["ANTHROPIC_API_KEY"])
40
+ service = Ai::CompletionService.new(client: client)
41
+
42
+ # Tests: fake client — no HTTP, no API key needed
43
+ fake_client = FakeCompletionClient.new(response: "Here is your refactored code...")
44
+ service = Ai::CompletionService.new(client: fake_client)
45
+
46
+ # Future: OpenAI, Ollama, or any LLM that implements .complete
47
+ ollama_client = Ollama::Client.new(base_url: "http://localhost:11434")
48
+ service = Ai::CompletionService.new(client: ollama_client)
49
+ ```
50
+
51
+ Configuring dependencies at the application level:
52
+
53
+ ```ruby
54
+ # config/initializers/dependencies.rb
55
+ Rails.application.config.after_initialize do
56
+ # Wire up production dependencies
57
+ embedding_client = Embeddings::HttpClient.new(
58
+ base_url: ENV.fetch("EMBEDDING_SERVICE_URL")
59
+ )
60
+
61
+ Rails.application.config.x.embedding_client = embedding_client
62
+ Rails.application.config.x.ai_client = Anthropic::Client.new(
63
+ api_key: ENV.fetch("ANTHROPIC_API_KEY")
64
+ )
65
+ end
66
+
67
+ # Services pull from config or accept injection
68
+ class Embeddings::IndexService
69
+ def initialize(client: Rails.application.config.x.embedding_client)
70
+ @client = client
71
+ end
72
+
73
+ def call(project, files)
74
+ files.each do |path, content|
75
+ vectors = @client.embed([content])
76
+ project.code_embeddings.create!(file_path: path, embedding: vectors.first)
77
+ end
78
+ end
79
+ end
80
+ ```
81
+
82
+ DIP with Ruby blocks — the lightest-weight dependency injection:
83
+
84
+ ```ruby
85
+ class Orders::ExportService
86
+ # The formatter is an injected dependency via block
87
+ def call(orders, &formatter)
88
+ formatter ||= method(:default_format)
89
+ orders.map { |order| formatter.call(order) }
90
+ end
91
+
92
+ private
93
+
94
+ def default_format(order)
95
+ "#{order.reference}: $#{order.total}"
96
+ end
97
+ end
98
+
99
+ # Different formats without modifying ExportService
100
+ Orders::ExportService.new.call(orders) { |o| o.to_json }
101
+ Orders::ExportService.new.call(orders) { |o| [o.reference, o.total].join(",") }
102
+ Orders::ExportService.new.call(orders) # Uses default
103
+ ```
104
+
105
+ ## Why This Is Good
106
+
107
+ - **Swappable dependencies.** Production uses Anthropic, tests use a fake, future uses Ollama — `CompletionService` never changes. The high-level business logic is isolated from low-level API details.
108
+ - **Testable without infrastructure.** Tests inject fakes or doubles. No HTTP calls, no API keys, no external services. Tests run in milliseconds.
109
+ - **Framework-independent business logic.** `CompletionService` doesn't know about Rails, HTTP, or JSON parsing. It knows about messages and responses. The concrete client handles the transport.
110
+ - **Default injection balances convenience and flexibility.** `client: Rails.application.config.x.embedding_client` provides a sensible default while allowing test overrides. Production code doesn't need to specify the client every time.
111
+
112
+ ## Anti-Pattern
113
+
114
+ Hardcoded dependencies — high-level logic directly instantiates low-level classes:
115
+
116
+ ```ruby
117
+ class Ai::CompletionService
118
+ def call(prompt, context:)
119
+ # HARDCODED: directly creates the concrete client
120
+ client = Anthropic::Client.new(api_key: ENV["ANTHROPIC_API_KEY"])
121
+
122
+ messages = build_messages(prompt, context)
123
+ response = client.messages.create(
124
+ model: "claude-haiku-4-5-20251001",
125
+ max_tokens: 4096,
126
+ messages: messages
127
+ )
128
+
129
+ Result.new(
130
+ content: response.content.first.text,
131
+ input_tokens: response.usage.input_tokens,
132
+ output_tokens: response.usage.output_tokens
133
+ )
134
+ end
135
+ end
136
+ ```
137
+
138
+ ```ruby
139
+ # Another violation: service directly calls a specific external API
140
+ class Embeddings::IndexService
141
+ def call(project, files)
142
+ files.each do |path, content|
143
+ # HARDCODED: knows the exact URL, HTTP method, headers, and response format
144
+ response = Faraday.post(
145
+ "http://embedding-service:8000/embed",
146
+ { texts: [content] }.to_json,
147
+ "Content-Type" => "application/json"
148
+ )
149
+ vector = JSON.parse(response.body)["embeddings"].first
150
+ project.code_embeddings.create!(file_path: path, embedding: vector)
151
+ end
152
+ end
153
+ end
154
+ ```
155
+
156
+ ## Why This Is Bad
157
+
158
+ - **Can't swap the provider.** Moving from Anthropic to OpenAI requires rewriting `CompletionService`. The business logic (building messages, processing responses) is tangled with the transport (HTTP client, API format).
159
+ - **Can't test without the real service.** Testing `CompletionService` requires either a running Anthropic API (slow, expensive, flaky) or complex WebMock stubs that mirror the exact API format. A fake client is simpler.
160
+ - **URL, headers, and JSON parsing inside business logic.** `IndexService` knows about Faraday, URLs, JSON parsing, and response structure. These are transport concerns that belong in a client class, not in the indexing logic.
161
+ - **Environment coupling.** `ENV["ANTHROPIC_API_KEY"]` is read every time the service is called. In tests, you must set the environment variable or the service breaks. With injection, tests pass a fake and never touch ENV.
162
+
163
+ ## When To Apply
164
+
165
+ - **External services.** API clients, email services, payment gateways, embedding services — always inject these. They're the most common source of hard-to-test, hard-to-swap dependencies.
166
+ - **Cross-cutting concerns.** Logging, caching, metrics — inject them so you can swap implementations (stdout logger vs CloudWatch vs null logger for tests).
167
+ - **Strategy selection.** When behavior varies at runtime (different AI models, different export formats, different notification channels), inject the strategy.
168
+ - **Configuration that varies by environment.** Database connections, API URLs, feature flags — inject via Rails config or environment, not hardcoded values.
169
+
170
+ ## When NOT To Apply
171
+
172
+ - **Don't inject Ruby standard library classes.** `Array.new`, `Hash.new`, `Time.current` — these are stable, universal dependencies. Injecting them adds ceremony with no benefit.
173
+ - **Don't inject ActiveRecord models.** `User.find(id)` is fine. You don't need to inject a "UserRepository" in Rails — that's Java-style over-abstraction.
174
+ - **Don't inject everything.** Inject *boundaries* — the edges where your code meets external systems. Internal collaborators (one service calling another within your app) can be directly referenced if they're stable.
175
+
176
+ ## Edge Cases
177
+
178
+ **Circular dependencies:**
179
+ If ServiceA depends on ServiceB and ServiceB depends on ServiceA, you have a design problem. Extract the shared logic into a third class that both depend on.
180
+
181
+ **Default values vs mandatory injection:**
182
+ Use default values for production dependencies, mandatory injection for things that should always be explicit:
183
+
184
+ ```ruby
185
+ # Default for convenience — production always uses the real client
186
+ def initialize(client: Anthropic::Client.new(api_key: ENV["ANTHROPIC_API_KEY"]))
187
+
188
+ # Mandatory — caller must choose a strategy
189
+ def initialize(processor:)
190
+ raise ArgumentError, "processor is required" unless processor
191
+ end
192
+ ```
193
+
194
+ **Rails' built-in DIP mechanisms:**
195
+ Rails already uses DIP in many places: `config.active_job.queue_adapter`, `config.cache_store`, `config.active_storage.service`. These are configuration-based dependency injection. Follow the same pattern for your own services.