anima-core 0.2.1 → 1.0.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 (280) hide show
  1. checksums.yaml +4 -4
  2. data/.reek.yml +27 -1
  3. data/CHANGELOG.md +19 -0
  4. data/README.md +213 -43
  5. data/agents/codebase-analyzer.md +88 -0
  6. data/agents/codebase-pattern-finder.md +83 -0
  7. data/agents/documentation-researcher.md +59 -0
  8. data/agents/thoughts-analyzer.md +102 -0
  9. data/agents/web-search-researcher.md +71 -0
  10. data/anima-core.gemspec +3 -0
  11. data/app/channels/session_channel.rb +195 -45
  12. data/app/decorators/user_message_decorator.rb +16 -5
  13. data/app/jobs/agent_request_job.rb +55 -2
  14. data/app/jobs/analytical_brain_job.rb +33 -0
  15. data/app/jobs/count_event_tokens_job.rb +15 -4
  16. data/app/models/concerns/event/broadcasting.rb +81 -0
  17. data/app/models/event.rb +20 -1
  18. data/app/models/goal.rb +91 -0
  19. data/app/models/session.rb +366 -21
  20. data/config/application.rb +2 -0
  21. data/config/initializers/event_subscribers.rb +0 -1
  22. data/config/routes.rb +0 -6
  23. data/db/migrate/20260313010000_add_status_to_events.rb +8 -0
  24. data/db/migrate/20260313020000_add_processing_to_sessions.rb +7 -0
  25. data/db/migrate/20260314075248_add_subagent_support_to_sessions.rb +6 -0
  26. data/db/migrate/20260314112417_add_granted_tools_to_sessions.rb +5 -0
  27. data/db/migrate/20260314140000_add_name_to_sessions.rb +7 -0
  28. data/db/migrate/20260314150000_add_viewport_event_ids_to_sessions.rb +7 -0
  29. data/db/migrate/20260315100000_add_active_skills_to_sessions.rb +7 -0
  30. data/db/migrate/20260315140843_create_goals.rb +16 -0
  31. data/db/migrate/20260315144837_add_completed_at_to_goals.rb +5 -0
  32. data/db/migrate/20260315191105_add_active_workflow_to_sessions.rb +5 -0
  33. data/lib/agent_loop.rb +65 -6
  34. data/lib/agents/definition.rb +116 -0
  35. data/lib/agents/registry.rb +106 -0
  36. data/lib/analytical_brain/runner.rb +276 -0
  37. data/lib/analytical_brain/tools/activate_skill.rb +52 -0
  38. data/lib/analytical_brain/tools/deactivate_skill.rb +43 -0
  39. data/lib/analytical_brain/tools/deactivate_workflow.rb +34 -0
  40. data/lib/analytical_brain/tools/everything_is_ready.rb +28 -0
  41. data/lib/analytical_brain/tools/finish_goal.rb +62 -0
  42. data/lib/analytical_brain/tools/read_workflow.rb +58 -0
  43. data/lib/analytical_brain/tools/rename_session.rb +63 -0
  44. data/lib/analytical_brain/tools/set_goal.rb +60 -0
  45. data/lib/analytical_brain/tools/update_goal.rb +60 -0
  46. data/lib/analytical_brain.rb +23 -0
  47. data/lib/anima/cli/mcp/secrets.rb +76 -0
  48. data/lib/anima/cli/mcp.rb +197 -0
  49. data/lib/anima/cli.rb +5 -40
  50. data/lib/anima/installer.rb +168 -0
  51. data/lib/anima/settings.rb +226 -0
  52. data/lib/anima/version.rb +1 -1
  53. data/lib/anima.rb +9 -0
  54. data/lib/credential_store.rb +103 -0
  55. data/lib/environment_probe.rb +232 -0
  56. data/lib/events/subscribers/persister.rb +1 -0
  57. data/lib/events/user_message.rb +17 -0
  58. data/lib/llm/client.rb +29 -10
  59. data/lib/mcp/client_manager.rb +86 -0
  60. data/lib/mcp/config.rb +213 -0
  61. data/lib/mcp/health_check.rb +77 -0
  62. data/lib/mcp/secrets.rb +73 -0
  63. data/lib/mcp/stdio_transport.rb +206 -0
  64. data/lib/providers/anthropic.rb +11 -20
  65. data/lib/shell_session.rb +11 -10
  66. data/lib/skills/definition.rb +97 -0
  67. data/lib/skills/registry.rb +105 -0
  68. data/lib/tools/edit.rb +226 -0
  69. data/lib/tools/mcp_tool.rb +114 -0
  70. data/lib/tools/read.rb +151 -0
  71. data/lib/tools/registry.rb +14 -12
  72. data/lib/tools/request_feature.rb +121 -0
  73. data/lib/tools/return_result.rb +81 -0
  74. data/lib/tools/spawn_specialist.rb +109 -0
  75. data/lib/tools/spawn_subagent.rb +111 -0
  76. data/lib/tools/subagent_prompts.rb +12 -0
  77. data/lib/tools/web_get.rb +8 -9
  78. data/lib/tools/write.rb +86 -0
  79. data/lib/tui/app.rb +985 -26
  80. data/lib/tui/cable_client.rb +69 -31
  81. data/lib/tui/message_store.rb +103 -8
  82. data/lib/tui/screens/chat.rb +293 -45
  83. data/lib/workflows/definition.rb +97 -0
  84. data/lib/workflows/registry.rb +89 -0
  85. data/skills/activerecord/SKILL.md +255 -0
  86. data/skills/activerecord/examples/associations/association_extensions.rb +298 -0
  87. data/skills/activerecord/examples/associations/basic_associations.rb +118 -0
  88. data/skills/activerecord/examples/associations/counter_caches.rb +215 -0
  89. data/skills/activerecord/examples/associations/polymorphic_associations.rb +217 -0
  90. data/skills/activerecord/examples/associations/self_referential.rb +302 -0
  91. data/skills/activerecord/examples/associations/through_associations.rb +203 -0
  92. data/skills/activerecord/examples/basics/crud_operations.rb +209 -0
  93. data/skills/activerecord/examples/basics/dirty_tracking.rb +218 -0
  94. data/skills/activerecord/examples/basics/inheritance.rb +377 -0
  95. data/skills/activerecord/examples/basics/type_casting.rb +317 -0
  96. data/skills/activerecord/examples/callbacks/alternatives_to_callbacks.rb +447 -0
  97. data/skills/activerecord/examples/callbacks/conditional_callbacks.rb +353 -0
  98. data/skills/activerecord/examples/callbacks/lifecycle_callbacks.rb +280 -0
  99. data/skills/activerecord/examples/callbacks/transaction_callbacks.rb +340 -0
  100. data/skills/activerecord/examples/migrations/indexes_and_constraints.rb +337 -0
  101. data/skills/activerecord/examples/migrations/reversible_patterns.rb +403 -0
  102. data/skills/activerecord/examples/migrations/safe_patterns.rb +420 -0
  103. data/skills/activerecord/examples/migrations/schema_changes.rb +277 -0
  104. data/skills/activerecord/examples/querying/batch_processing.rb +226 -0
  105. data/skills/activerecord/examples/querying/eager_loading.rb +259 -0
  106. data/skills/activerecord/examples/querying/finder_methods.rb +170 -0
  107. data/skills/activerecord/examples/querying/optimization.rb +275 -0
  108. data/skills/activerecord/examples/querying/scopes.rb +260 -0
  109. data/skills/activerecord/examples/validations/built_in_validators.rb +277 -0
  110. data/skills/activerecord/examples/validations/conditional_validations.rb +288 -0
  111. data/skills/activerecord/examples/validations/custom_validators.rb +381 -0
  112. data/skills/activerecord/examples/validations/database_constraints.rb +432 -0
  113. data/skills/activerecord/examples/validations/validation_contexts.rb +367 -0
  114. data/skills/activerecord/references/associations.md +709 -0
  115. data/skills/activerecord/references/basics.md +622 -0
  116. data/skills/activerecord/references/callbacks.md +738 -0
  117. data/skills/activerecord/references/migrations.md +657 -0
  118. data/skills/activerecord/references/querying.md +655 -0
  119. data/skills/activerecord/references/validations.md +596 -0
  120. data/skills/dragonruby/SKILL.md +250 -0
  121. data/skills/dragonruby/examples/audio/audio_events.rb +55 -0
  122. data/skills/dragonruby/examples/audio/background_music.rb +29 -0
  123. data/skills/dragonruby/examples/audio/crossfade.rb +51 -0
  124. data/skills/dragonruby/examples/audio/music_controls.rb +51 -0
  125. data/skills/dragonruby/examples/audio/sound_effects.rb +30 -0
  126. data/skills/dragonruby/examples/core/coordinate_system.rb +27 -0
  127. data/skills/dragonruby/examples/core/hello_world.rb +24 -0
  128. data/skills/dragonruby/examples/core/labels.rb +22 -0
  129. data/skills/dragonruby/examples/core/sprites.rb +35 -0
  130. data/skills/dragonruby/examples/core/state_management.rb +29 -0
  131. data/skills/dragonruby/examples/distribution/background_pause.rb +42 -0
  132. data/skills/dragonruby/examples/distribution/build_workflow.sh +26 -0
  133. data/skills/dragonruby/examples/distribution/cvars_production.txt +16 -0
  134. data/skills/dragonruby/examples/distribution/game_metadata_hd.txt +23 -0
  135. data/skills/dragonruby/examples/distribution/game_metadata_minimal.txt +9 -0
  136. data/skills/dragonruby/examples/distribution/game_metadata_mobile.txt +31 -0
  137. data/skills/dragonruby/examples/distribution/platform_detection.rb +36 -0
  138. data/skills/dragonruby/examples/distribution/steam_metadata.txt +19 -0
  139. data/skills/dragonruby/examples/entities/collision_detection.rb +43 -0
  140. data/skills/dragonruby/examples/entities/entity_lifecycle.rb +68 -0
  141. data/skills/dragonruby/examples/entities/entity_storage.rb +38 -0
  142. data/skills/dragonruby/examples/entities/factory_methods.rb +45 -0
  143. data/skills/dragonruby/examples/entities/random_spawning.rb +50 -0
  144. data/skills/dragonruby/examples/game-logic/reset_patterns.rb +98 -0
  145. data/skills/dragonruby/examples/game-logic/save_load.rb +101 -0
  146. data/skills/dragonruby/examples/game-logic/scoring.rb +104 -0
  147. data/skills/dragonruby/examples/game-logic/state_transitions.rb +103 -0
  148. data/skills/dragonruby/examples/game-logic/timers.rb +87 -0
  149. data/skills/dragonruby/examples/input/action_triggers.rb +36 -0
  150. data/skills/dragonruby/examples/input/analog_movement.rb +28 -0
  151. data/skills/dragonruby/examples/input/controller_input.rb +28 -0
  152. data/skills/dragonruby/examples/input/directional_input.rb +24 -0
  153. data/skills/dragonruby/examples/input/keyboard_input.rb +28 -0
  154. data/skills/dragonruby/examples/input/mouse_click.rb +26 -0
  155. data/skills/dragonruby/examples/input/movement_with_bounds.rb +22 -0
  156. data/skills/dragonruby/examples/input/normalized_movement.rb +32 -0
  157. data/skills/dragonruby/examples/rendering/frame_animation.rb +32 -0
  158. data/skills/dragonruby/examples/rendering/labels.rb +32 -0
  159. data/skills/dragonruby/examples/rendering/layering.rb +51 -0
  160. data/skills/dragonruby/examples/rendering/solids.rb +61 -0
  161. data/skills/dragonruby/examples/rendering/sprites.rb +33 -0
  162. data/skills/dragonruby/examples/rendering/spritesheet_animation.rb +39 -0
  163. data/skills/dragonruby/examples/scenes/case_dispatch.rb +60 -0
  164. data/skills/dragonruby/examples/scenes/class_based.rb +150 -0
  165. data/skills/dragonruby/examples/scenes/pause_overlay.rb +100 -0
  166. data/skills/dragonruby/examples/scenes/safe_transitions.rb +68 -0
  167. data/skills/dragonruby/examples/scenes/scene_transitions.rb +98 -0
  168. data/skills/dragonruby/examples/scenes/send_dispatch.rb +88 -0
  169. data/skills/dragonruby/references/audio.md +396 -0
  170. data/skills/dragonruby/references/core.md +385 -0
  171. data/skills/dragonruby/references/distribution.md +434 -0
  172. data/skills/dragonruby/references/entities.md +516 -0
  173. data/skills/dragonruby/references/game-logic/persistence.md +386 -0
  174. data/skills/dragonruby/references/game-logic/state.md +389 -0
  175. data/skills/dragonruby/references/input.md +414 -0
  176. data/skills/dragonruby/references/rendering/animation.md +467 -0
  177. data/skills/dragonruby/references/rendering/primitives.md +403 -0
  178. data/skills/dragonruby/references/scenes.md +443 -0
  179. data/skills/draper-decorators/SKILL.md +344 -0
  180. data/skills/draper-decorators/examples/application_decorator.rb +61 -0
  181. data/skills/draper-decorators/examples/decorator_spec.rb +253 -0
  182. data/skills/draper-decorators/examples/model_decorator.rb +152 -0
  183. data/skills/draper-decorators/references/anti-patterns.md +640 -0
  184. data/skills/draper-decorators/references/patterns.md +507 -0
  185. data/skills/draper-decorators/references/testing.md +559 -0
  186. data/skills/gh-issue.md +182 -0
  187. data/skills/mcp-server/SKILL.md +177 -0
  188. data/skills/mcp-server/examples/dynamic_tools.rb +36 -0
  189. data/skills/mcp-server/examples/file_manager_tool.rb +85 -0
  190. data/skills/mcp-server/examples/http_client.rb +48 -0
  191. data/skills/mcp-server/examples/http_server.rb +97 -0
  192. data/skills/mcp-server/examples/rails_integration.rb +88 -0
  193. data/skills/mcp-server/examples/stdio_server.rb +108 -0
  194. data/skills/mcp-server/examples/streaming_client.rb +95 -0
  195. data/skills/mcp-server/references/gotchas.md +183 -0
  196. data/skills/mcp-server/references/prompts.md +98 -0
  197. data/skills/mcp-server/references/resources.md +53 -0
  198. data/skills/mcp-server/references/server.md +140 -0
  199. data/skills/mcp-server/references/tools.md +146 -0
  200. data/skills/mcp-server/references/transport.md +104 -0
  201. data/skills/ratatui-ruby/SKILL.md +315 -0
  202. data/skills/ratatui-ruby/references/core-concepts.md +340 -0
  203. data/skills/ratatui-ruby/references/events.md +387 -0
  204. data/skills/ratatui-ruby/references/frameworks.md +522 -0
  205. data/skills/ratatui-ruby/references/layout.md +423 -0
  206. data/skills/ratatui-ruby/references/styling.md +268 -0
  207. data/skills/ratatui-ruby/references/testing.md +433 -0
  208. data/skills/ratatui-ruby/references/widgets.md +532 -0
  209. data/skills/rspec/SKILL.md +340 -0
  210. data/skills/rspec/examples/core/basic_structure.rb +69 -0
  211. data/skills/rspec/examples/core/configuration.rb +126 -0
  212. data/skills/rspec/examples/core/hooks.rb +126 -0
  213. data/skills/rspec/examples/core/memoized_helpers.rb +139 -0
  214. data/skills/rspec/examples/core/metadata_filtering.rb +144 -0
  215. data/skills/rspec/examples/core/shared_examples.rb +145 -0
  216. data/skills/rspec/examples/factory_bot/associations.rb +314 -0
  217. data/skills/rspec/examples/factory_bot/build_strategies.rb +272 -0
  218. data/skills/rspec/examples/factory_bot/callbacks.rb +320 -0
  219. data/skills/rspec/examples/factory_bot/custom_construction.rb +328 -0
  220. data/skills/rspec/examples/factory_bot/factory_definition.rb +191 -0
  221. data/skills/rspec/examples/factory_bot/inheritance.rb +314 -0
  222. data/skills/rspec/examples/factory_bot/traits.rb +293 -0
  223. data/skills/rspec/examples/factory_bot/transients.rb +229 -0
  224. data/skills/rspec/examples/matchers/change.rb +115 -0
  225. data/skills/rspec/examples/matchers/collections.rb +154 -0
  226. data/skills/rspec/examples/matchers/comparisons.rb +79 -0
  227. data/skills/rspec/examples/matchers/composing.rb +155 -0
  228. data/skills/rspec/examples/matchers/custom_matchers.rb +197 -0
  229. data/skills/rspec/examples/matchers/equality.rb +58 -0
  230. data/skills/rspec/examples/matchers/errors.rb +136 -0
  231. data/skills/rspec/examples/matchers/output.rb +103 -0
  232. data/skills/rspec/examples/matchers/predicates.rb +87 -0
  233. data/skills/rspec/examples/matchers/truthiness.rb +101 -0
  234. data/skills/rspec/examples/matchers/types.rb +82 -0
  235. data/skills/rspec/examples/matchers/yield.rb +147 -0
  236. data/skills/rspec/examples/mocks/any_instance.rb +172 -0
  237. data/skills/rspec/examples/mocks/argument_matchers.rb +206 -0
  238. data/skills/rspec/examples/mocks/constants.rb +177 -0
  239. data/skills/rspec/examples/mocks/doubles.rb +139 -0
  240. data/skills/rspec/examples/mocks/expectations.rb +137 -0
  241. data/skills/rspec/examples/mocks/message_chains.rb +173 -0
  242. data/skills/rspec/examples/mocks/ordering.rb +144 -0
  243. data/skills/rspec/examples/mocks/receive_counts.rb +181 -0
  244. data/skills/rspec/examples/mocks/responses.rb +223 -0
  245. data/skills/rspec/examples/mocks/spies.rb +149 -0
  246. data/skills/rspec/examples/mocks/stubbing.rb +133 -0
  247. data/skills/rspec/examples/rails/channels.rb +250 -0
  248. data/skills/rspec/examples/rails/controller_specs.rb +302 -0
  249. data/skills/rspec/examples/rails/helper_specs.rb +245 -0
  250. data/skills/rspec/examples/rails/job_specs.rb +256 -0
  251. data/skills/rspec/examples/rails/mailer_specs.rb +228 -0
  252. data/skills/rspec/examples/rails/matchers.rb +374 -0
  253. data/skills/rspec/examples/rails/model_specs.rb +193 -0
  254. data/skills/rspec/examples/rails/request_specs.rb +275 -0
  255. data/skills/rspec/examples/rails/routing_specs.rb +276 -0
  256. data/skills/rspec/examples/rails/system_specs.rb +294 -0
  257. data/skills/rspec/examples/rails/transactions.rb +254 -0
  258. data/skills/rspec/examples/rails/view_specs.rb +252 -0
  259. data/skills/rspec/references/core.md +816 -0
  260. data/skills/rspec/references/factory_bot.md +641 -0
  261. data/skills/rspec/references/matchers.md +516 -0
  262. data/skills/rspec/references/mocks.md +381 -0
  263. data/skills/rspec/references/rails.md +528 -0
  264. data/templates/soul.md +40 -0
  265. data/workflows/commit.md +45 -0
  266. data/workflows/create_handoff.md +98 -0
  267. data/workflows/create_note.md +82 -0
  268. data/workflows/create_plan.md +457 -0
  269. data/workflows/decompose_ticket.md +109 -0
  270. data/workflows/feature.md +91 -0
  271. data/workflows/implement_plan.md +87 -0
  272. data/workflows/iterate_plan.md +247 -0
  273. data/workflows/research_codebase.md +210 -0
  274. data/workflows/resume_handoff.md +217 -0
  275. data/workflows/review_pr.md +320 -0
  276. data/workflows/thoughts_init.md +71 -0
  277. data/workflows/validate_plan.md +166 -0
  278. metadata +290 -3
  279. data/app/controllers/api/sessions_controller.rb +0 -25
  280. data/lib/events/subscribers/action_cable_bridge.rb +0 -59
@@ -0,0 +1,137 @@
1
+ # RSpec Mocks: Message Expectations (expect) Examples
2
+ # Source: rspec-mocks gem features/basics/expecting_messages.feature
3
+
4
+ # expect(...).to receive - message must be called
5
+ RSpec.describe "message expectations" do
6
+ describe "basic expectations" do
7
+ it "fails if message is not received" do
8
+ dbl = double("collaborator")
9
+ expect(dbl).to receive(:foo)
10
+
11
+ # Without calling dbl.foo, this would fail with:
12
+ # "(Double 'collaborator').foo(*(any args)) expected: 1 time..."
13
+ dbl.foo
14
+ end
15
+
16
+ it "passes when message is received" do
17
+ dbl = double("collaborator")
18
+ expect(dbl).to receive(:foo)
19
+ dbl.foo
20
+ end
21
+ end
22
+
23
+ describe "negative expectations" do
24
+ it "fails if forbidden message is received" do
25
+ dbl = double("collaborator")
26
+ expect(dbl).not_to receive(:foo)
27
+
28
+ # Calling dbl.foo here would fail immediately
29
+ end
30
+ end
31
+
32
+ describe "custom failure messages" do
33
+ it "provides context when expectation fails" do
34
+ dbl = double("collaborator")
35
+ expect(dbl).to receive(:foo), "dbl should call :foo during authentication"
36
+ dbl.foo
37
+ end
38
+ end
39
+
40
+ describe "expect vs allow" do
41
+ it "allow permits but doesn't require calls" do
42
+ dbl = double("collaborator")
43
+ allow(dbl).to receive(:foo)
44
+ # Not calling foo is fine
45
+ end
46
+
47
+ it "expect requires calls" do
48
+ dbl = double("collaborator")
49
+ expect(dbl).to receive(:foo)
50
+ dbl.foo # Must be called
51
+ end
52
+ end
53
+ end
54
+
55
+ # Practical example: verifying collaborator interactions
56
+ RSpec.describe Account do
57
+ subject(:account) { build(:account, logger:, balance: 1000) }
58
+
59
+ let(:logger) { instance_double("Logger") }
60
+
61
+ describe "#close" do
62
+ it "logs the closure event" do
63
+ expect(logger).to receive(:info).with("Account closed")
64
+ account.close
65
+ end
66
+
67
+ it "logs the final balance" do
68
+ expect(logger).to receive(:info).with(/Balance: \d+/)
69
+ account.close
70
+ end
71
+ end
72
+
73
+ describe "#withdraw" do
74
+ context "with sufficient funds" do
75
+ it "does not log warnings" do
76
+ allow(logger).to receive(:info)
77
+ expect(logger).not_to receive(:warn)
78
+
79
+ account.withdraw(100)
80
+ end
81
+ end
82
+
83
+ context "with insufficient funds" do
84
+ it "logs a warning" do
85
+ allow(logger).to receive(:info)
86
+ expect(logger).to receive(:warn).with(/insufficient funds/i)
87
+
88
+ account.withdraw(10_000)
89
+ end
90
+ end
91
+ end
92
+ end
93
+
94
+ # Practical example: event publishing
95
+ RSpec.describe OrderService do
96
+ subject(:service) { build(:order_service, publisher:) }
97
+
98
+ let(:publisher) { instance_double("EventPublisher") }
99
+ let(:order) { build(:order) }
100
+
101
+ describe "#complete" do
102
+ it "publishes order completed event" do
103
+ expect(publisher).to receive(:publish).with(
104
+ "order.completed",
105
+ hash_including(order_id: order.id)
106
+ )
107
+
108
+ service.complete(order)
109
+ end
110
+
111
+ it "publishes exactly one event" do
112
+ expect(publisher).to receive(:publish).once
113
+ service.complete(order)
114
+ end
115
+ end
116
+ end
117
+
118
+ # Combining expect and allow on same double
119
+ RSpec.describe "mixed expectations and stubs" do
120
+ subject(:processor) { build(:payment_processor, gateway:, logger:) }
121
+
122
+ let(:gateway) { instance_double("PaymentGateway") }
123
+ let(:logger) { instance_double("Logger") }
124
+
125
+ describe "#process" do
126
+ it "charges gateway and logs result" do
127
+ # Stub the gateway response
128
+ allow(gateway).to receive(:charge).and_return(success: true)
129
+
130
+ # Expect logging to happen
131
+ expect(logger).to receive(:info).with(/Payment processed/)
132
+
133
+ processor.process(amount: 100)
134
+ end
135
+ end
136
+ end
137
+
@@ -0,0 +1,173 @@
1
+ # RSpec Mocks: Message Chains Examples
2
+ # Source: rspec-mocks gem features/working_with_legacy_code/message_chains.feature
3
+
4
+ # WARNING: receive_message_chain violates Law of Demeter.
5
+ # It often indicates:
6
+ # - Code knows too much about collaborator structure
7
+ # - Need for facade or wrapper
8
+ # - Missing encapsulation
9
+ #
10
+ # Use only for legacy code. Prefer proper encapsulation.
11
+
12
+ # receive_message_chain - stub chained method calls
13
+ RSpec.describe "receive_message_chain" do
14
+ describe "syntax forms" do
15
+ it "accepts dot-separated string" do
16
+ dbl = double("collaborator")
17
+ allow(dbl).to receive_message_chain("foo.bar.baz") { :result }
18
+
19
+ expect(dbl.foo.bar.baz).to eq(:result)
20
+ end
21
+
22
+ it "accepts symbols with hash for final return" do
23
+ dbl = double("collaborator")
24
+ allow(dbl).to receive_message_chain(:foo, :bar, baz: :result)
25
+
26
+ expect(dbl.foo.bar.baz).to eq(:result)
27
+ end
28
+
29
+ it "accepts symbols with block" do
30
+ dbl = double("collaborator")
31
+ allow(dbl).to receive_message_chain(:foo, :bar, :baz) { :result }
32
+
33
+ expect(dbl.foo.bar.baz).to eq(:result)
34
+ end
35
+ end
36
+
37
+ describe "intermediate objects" do
38
+ it "creates intermediates automatically" do
39
+ dbl = double("collaborator")
40
+ allow(dbl).to receive_message_chain(:a, :b, :c) { "end" }
41
+
42
+ # Each call returns a new double
43
+ intermediate = dbl.a.b
44
+ expect(intermediate.c).to eq("end")
45
+ end
46
+ end
47
+
48
+ describe "with any_instance_of" do
49
+ it "stubs chains on any instance" do
50
+ allow_any_instance_of(User).to receive_message_chain("account.balance") { 100 }
51
+
52
+ user = User.new
53
+ expect(user.account.balance).to eq(100)
54
+ end
55
+ end
56
+ end
57
+
58
+ # ActiveRecord example - common legacy pattern
59
+ RSpec.describe "ActiveRecord chaining" do
60
+ # Legacy code might do: Article.recent.published.limit(5)
61
+ describe "stubbing scope chains" do
62
+ it "stubs the entire chain" do
63
+ articles = build_list(:article, 3)
64
+ allow(Article).to receive_message_chain("recent.published.limit") { articles }
65
+
66
+ expect(Article.recent.published.limit(5)).to eq(articles)
67
+ end
68
+ end
69
+ end
70
+
71
+ # Better alternatives
72
+ RSpec.describe "alternatives to message chains" do
73
+ # PROBLEM: Code uses deep chains
74
+ class LegacyReportGenerator
75
+ def generate
76
+ User.active.verified.with_orders.map(&:email)
77
+ end
78
+ end
79
+
80
+ # BAD: Stubbing chain
81
+ describe LegacyReportGenerator do
82
+ subject(:generator) { LegacyReportGenerator.new }
83
+
84
+ it "generates report with message chain" do
85
+ users = build_list(:user, 2, email: "test@example.com")
86
+ allow(User).to receive_message_chain("active.verified.with_orders") { users }
87
+
88
+ expect(generator.generate).to eq(["test@example.com", "test@example.com"])
89
+ end
90
+ end
91
+
92
+ # BETTER: Extract scope into model method
93
+ class User < ApplicationRecord
94
+ def self.eligible_for_report
95
+ active.verified.with_orders
96
+ end
97
+ end
98
+
99
+ class BetterReportGenerator
100
+ def initialize(user_scope: User)
101
+ @user_scope = user_scope
102
+ end
103
+
104
+ def generate
105
+ @user_scope.eligible_for_report.map(&:email)
106
+ end
107
+ end
108
+
109
+ describe BetterReportGenerator do
110
+ subject(:generator) { BetterReportGenerator.new(user_scope:) }
111
+
112
+ let(:user_scope) { class_double("User") }
113
+
114
+ it "generates report with single stub" do
115
+ users = build_list(:user, 2, email: "test@example.com")
116
+ allow(user_scope).to receive(:eligible_for_report).and_return(users)
117
+
118
+ expect(generator.generate).to eq(["test@example.com", "test@example.com"])
119
+ end
120
+ end
121
+
122
+ # BEST: Inject the query result directly
123
+ class CleanReportGenerator
124
+ def initialize(user_repository:)
125
+ @user_repository = user_repository
126
+ end
127
+
128
+ def generate
129
+ @user_repository.eligible_users.map(&:email)
130
+ end
131
+ end
132
+
133
+ describe CleanReportGenerator do
134
+ subject(:generator) { CleanReportGenerator.new(user_repository:) }
135
+
136
+ let(:user_repository) { instance_double("UserRepository") }
137
+
138
+ it "generates report with injected dependency" do
139
+ users = build_list(:user, 2)
140
+ allow(users).to receive(:map).and_return(["a@test.com", "b@test.com"])
141
+ allow(user_repository).to receive(:eligible_users).and_return(users)
142
+
143
+ result = generator.generate
144
+ expect(result).to eq(["a@test.com", "b@test.com"])
145
+ end
146
+ end
147
+ end
148
+
149
+ # When message chains might be acceptable
150
+ RSpec.describe "acceptable chain usage" do
151
+ describe "null object chains" do
152
+ it "stubs deep configuration access" do
153
+ # Configuration objects often have deep chains
154
+ config = double("config").as_null_object
155
+ allow(config).to receive_message_chain("database.connection.pool_size") { 5 }
156
+
157
+ expect(config.database.connection.pool_size).to eq(5)
158
+ end
159
+ end
160
+
161
+ describe "test setup helpers" do
162
+ # In test setup, brevity might outweigh purity
163
+ it "quickly stubs Rails request chain" do
164
+ controller = double("controller")
165
+ allow(controller).to receive_message_chain("request.headers.[]")
166
+ .with("Authorization")
167
+ .and_return("Bearer token123")
168
+
169
+ expect(controller.request.headers["Authorization"]).to eq("Bearer token123")
170
+ end
171
+ end
172
+ end
173
+
@@ -0,0 +1,144 @@
1
+ # RSpec Mocks: Message Order Examples
2
+ # Source: rspec-mocks gem features/setting_constraints/message_order.feature
3
+
4
+ # NOTE: Ordered expectations can make specs brittle.
5
+ # Use only when message order is truly important.
6
+
7
+ # .ordered - enforce message sequence
8
+ RSpec.describe "message ordering" do
9
+ describe "basic ordering" do
10
+ it "requires messages in declared order" do
11
+ dbl = double("collaborator")
12
+
13
+ expect(dbl).to receive(:step_1).ordered
14
+ expect(dbl).to receive(:step_2).ordered
15
+ expect(dbl).to receive(:step_3).ordered
16
+
17
+ dbl.step_1
18
+ dbl.step_2
19
+ dbl.step_3
20
+ end
21
+ end
22
+
23
+ describe "across multiple doubles" do
24
+ it "enforces order across collaborators" do
25
+ collaborator_1 = double("first")
26
+ collaborator_2 = double("second")
27
+
28
+ expect(collaborator_1).to receive(:step_1).ordered
29
+ expect(collaborator_2).to receive(:step_2).ordered
30
+ expect(collaborator_1).to receive(:step_3).ordered
31
+
32
+ collaborator_1.step_1
33
+ collaborator_2.step_2
34
+ collaborator_1.step_3
35
+ end
36
+ end
37
+
38
+ describe "with have_received" do
39
+ it "verifies order using spies" do
40
+ invitation = spy("invitation")
41
+
42
+ invitation.prepare
43
+ invitation.send_email
44
+ invitation.log_delivery
45
+
46
+ expect(invitation).to have_received(:prepare).ordered
47
+ expect(invitation).to have_received(:send_email).ordered
48
+ expect(invitation).to have_received(:log_delivery).ordered
49
+ end
50
+ end
51
+
52
+ describe "partial ordering" do
53
+ it "only ordered expectations must be in sequence" do
54
+ dbl = double("collaborator")
55
+
56
+ allow(dbl).to receive(:any_time) # Not ordered
57
+ expect(dbl).to receive(:first).ordered
58
+ expect(dbl).to receive(:second).ordered
59
+
60
+ dbl.any_time # Can be called anytime
61
+ dbl.first
62
+ dbl.any_time # Still fine
63
+ dbl.second
64
+ dbl.any_time # Still fine
65
+ end
66
+ end
67
+ end
68
+
69
+ # Practical example: transaction workflow
70
+ RSpec.describe PaymentProcessor do
71
+ subject(:processor) { build(:payment_processor, gateway:, ledger:) }
72
+
73
+ let(:gateway) { instance_double("PaymentGateway") }
74
+ let(:ledger) { instance_double("Ledger") }
75
+ let(:payment) { build(:payment, amount: 100) }
76
+
77
+ describe "#process" do
78
+ context "when order matters for consistency" do
79
+ it "validates before charging" do
80
+ # Must validate first, then charge
81
+ expect(gateway).to receive(:validate).ordered.and_return(true)
82
+ expect(gateway).to receive(:charge).ordered.and_return(true)
83
+ allow(ledger).to receive(:record)
84
+
85
+ processor.process(payment)
86
+ end
87
+
88
+ it "records in ledger after successful charge" do
89
+ allow(gateway).to receive(:validate).and_return(true)
90
+
91
+ expect(gateway).to receive(:charge).ordered.and_return(true)
92
+ expect(ledger).to receive(:record).ordered
93
+
94
+ processor.process(payment)
95
+ end
96
+ end
97
+ end
98
+ end
99
+
100
+ # Practical example: state machine transitions
101
+ RSpec.describe OrderStateMachine do
102
+ subject(:machine) { build(:order_state_machine, logger:) }
103
+
104
+ let(:logger) { spy("logger") }
105
+
106
+ describe "#complete" do
107
+ it "transitions through states in order" do
108
+ machine.complete
109
+
110
+ expect(logger).to have_received(:log).with("pending -> processing").ordered
111
+ expect(logger).to have_received(:log).with("processing -> shipped").ordered
112
+ expect(logger).to have_received(:log).with("shipped -> delivered").ordered
113
+ end
114
+ end
115
+ end
116
+
117
+ # When NOT to use ordering
118
+ RSpec.describe "prefer unordered when possible" do
119
+ subject(:notifier) { build(:multi_channel_notifier, email:, sms:, push:) }
120
+
121
+ let(:email) { spy("email") }
122
+ let(:sms) { spy("sms") }
123
+ let(:push) { spy("push") }
124
+
125
+ describe "#notify_all" do
126
+ # BAD: Using ordered when order doesn't matter
127
+ # it "sends all notifications in order" do
128
+ # expect(email).to receive(:send).ordered
129
+ # expect(sms).to receive(:send).ordered
130
+ # expect(push).to receive(:send).ordered
131
+ # notifier.notify_all
132
+ # end
133
+
134
+ # GOOD: Just verify all are called
135
+ it "sends all notifications" do
136
+ notifier.notify_all
137
+
138
+ expect(email).to have_received(:send)
139
+ expect(sms).to have_received(:send)
140
+ expect(push).to have_received(:send)
141
+ end
142
+ end
143
+ end
144
+
@@ -0,0 +1,181 @@
1
+ # RSpec Mocks: Receive Counts Examples
2
+ # Source: rspec-mocks gem features/setting_constraints/receive_counts.feature
3
+
4
+ # NOTE: Default expectation is once unless specified
5
+
6
+ # Exact counts
7
+ RSpec.describe "exact receive counts" do
8
+ describe "once (default)" do
9
+ it "expects exactly one call" do
10
+ dbl = double("collaborator")
11
+ expect(dbl).to receive(:foo).once
12
+ dbl.foo
13
+ end
14
+ end
15
+
16
+ describe "twice" do
17
+ it "expects exactly two calls" do
18
+ dbl = double("collaborator")
19
+ expect(dbl).to receive(:foo).twice
20
+ dbl.foo
21
+ dbl.foo
22
+ end
23
+ end
24
+
25
+ describe "exactly(n).times" do
26
+ it "expects specific number of calls" do
27
+ dbl = double("collaborator")
28
+ expect(dbl).to receive(:foo).exactly(3).times
29
+ 3.times { dbl.foo }
30
+ end
31
+
32
+ it "uses .time for singular" do
33
+ dbl = double("collaborator")
34
+ expect(dbl).to receive(:foo).exactly(1).time
35
+ dbl.foo
36
+ end
37
+ end
38
+ end
39
+
40
+ # Minimum counts
41
+ RSpec.describe "at_least counts" do
42
+ describe "at_least(:once)" do
43
+ it "passes with one or more calls" do
44
+ dbl = double("collaborator")
45
+ expect(dbl).to receive(:foo).at_least(:once)
46
+ dbl.foo
47
+ dbl.foo # Additional calls are fine
48
+ end
49
+ end
50
+
51
+ describe "at_least(:twice)" do
52
+ it "passes with two or more calls" do
53
+ dbl = double("collaborator")
54
+ expect(dbl).to receive(:foo).at_least(:twice)
55
+ dbl.foo
56
+ dbl.foo
57
+ dbl.foo # Additional calls are fine
58
+ end
59
+ end
60
+
61
+ describe "at_least(n).times" do
62
+ it "passes with n or more calls" do
63
+ dbl = double("collaborator")
64
+ expect(dbl).to receive(:foo).at_least(3).times
65
+ 5.times { dbl.foo }
66
+ end
67
+ end
68
+ end
69
+
70
+ # Maximum counts
71
+ RSpec.describe "at_most counts" do
72
+ describe "at_most(:once)" do
73
+ it "passes with zero or one call" do
74
+ dbl = double("collaborator")
75
+ expect(dbl).to receive(:foo).at_most(:once)
76
+ dbl.foo
77
+ end
78
+
79
+ it "also passes with zero calls" do
80
+ dbl = double("collaborator")
81
+ expect(dbl).to receive(:foo).at_most(:once)
82
+ # Not called - still passes
83
+ end
84
+ end
85
+
86
+ describe "at_most(:twice)" do
87
+ it "passes with up to two calls" do
88
+ dbl = double("collaborator")
89
+ expect(dbl).to receive(:foo).at_most(:twice)
90
+ dbl.foo
91
+ end
92
+ end
93
+
94
+ describe "at_most(n).times" do
95
+ it "passes with n or fewer calls" do
96
+ dbl = double("collaborator")
97
+ expect(dbl).to receive(:foo).at_most(3).times
98
+ 2.times { dbl.foo }
99
+ end
100
+ end
101
+ end
102
+
103
+ # have_received with counts
104
+ RSpec.describe "have_received with counts" do
105
+ it "verifies exact count" do
106
+ invitation = spy("invitation")
107
+ invitation.deliver
108
+ invitation.deliver
109
+
110
+ expect(invitation).to have_received(:deliver).twice
111
+ end
112
+
113
+ it "verifies minimum count" do
114
+ invitation = spy("invitation")
115
+ 5.times { invitation.deliver }
116
+
117
+ expect(invitation).to have_received(:deliver).at_least(3).times
118
+ end
119
+
120
+ it "verifies maximum count" do
121
+ invitation = spy("invitation")
122
+ 2.times { invitation.deliver }
123
+
124
+ expect(invitation).to have_received(:deliver).at_most(5).times
125
+ end
126
+ end
127
+
128
+ # Practical example
129
+ RSpec.describe BankAccount do
130
+ subject(:account) { build(:bank_account, logger:) }
131
+
132
+ let(:logger) { instance_double("Logger") }
133
+
134
+ describe "#transfer" do
135
+ context "successful transfer" do
136
+ before do
137
+ allow(logger).to receive(:info)
138
+ end
139
+
140
+ it "logs exactly twice (start and end)" do
141
+ expect(logger).to receive(:info).twice
142
+
143
+ account.transfer(to: build(:bank_account), amount: 100)
144
+ end
145
+ end
146
+ end
147
+
148
+ describe "#batch_transfer" do
149
+ let(:recipients) { build_list(:bank_account, 5) }
150
+
151
+ before do
152
+ allow(logger).to receive(:info)
153
+ end
154
+
155
+ it "logs at least once per recipient" do
156
+ expect(logger).to receive(:info).at_least(5).times
157
+
158
+ account.batch_transfer(recipients:, amount: 50)
159
+ end
160
+ end
161
+ end
162
+
163
+ # Practical example: rate limiting
164
+ RSpec.describe RateLimitedClient do
165
+ subject(:client) { build(:rate_limited_client, api:) }
166
+
167
+ let(:api) { instance_double("ExternalApi") }
168
+
169
+ describe "#fetch_all" do
170
+ before do
171
+ allow(api).to receive(:fetch).and_return(data: [])
172
+ end
173
+
174
+ it "makes at most 10 API calls" do
175
+ expect(api).to receive(:fetch).at_most(10).times
176
+
177
+ client.fetch_all(max_pages: 20)
178
+ end
179
+ end
180
+ end
181
+