bug_bunny 4.6.1 → 4.8.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 (71) hide show
  1. checksums.yaml +4 -4
  2. data/.agents/skills/rabbitmq-expert/SKILL.md +1555 -0
  3. data/.claude/commands/gem-ai-setup.md +174 -0
  4. data/.claude/commands/pr.md +53 -0
  5. data/.claude/commands/release.md +52 -0
  6. data/.claude/commands/rubocop.md +22 -0
  7. data/.claude/commands/service-ai-setup.md +168 -0
  8. data/.claude/commands/test.md +28 -0
  9. data/.claude/commands/yard.md +46 -0
  10. data/CHANGELOG.md +50 -15
  11. data/CLAUDE.md +240 -0
  12. data/README.md +154 -221
  13. data/Rakefile +19 -3
  14. data/docs/_index.md +50 -0
  15. data/docs/ai/_index.md +56 -0
  16. data/docs/ai/antipatterns.md +166 -0
  17. data/docs/ai/api.md +251 -0
  18. data/docs/ai/architecture.md +92 -0
  19. data/docs/ai/errors.md +158 -0
  20. data/docs/ai/faq_external.md +133 -0
  21. data/docs/ai/faq_internal.md +86 -0
  22. data/docs/ai/glossary.md +45 -0
  23. data/docs/concepts.md +140 -0
  24. data/docs/howto/controller.md +194 -0
  25. data/docs/howto/middleware_client.md +119 -0
  26. data/docs/howto/middleware_consumer.md +127 -0
  27. data/docs/howto/rails.md +214 -0
  28. data/docs/howto/resource.md +200 -0
  29. data/docs/howto/routing.md +133 -0
  30. data/docs/howto/testing.md +259 -0
  31. data/docs/howto/tracing.md +119 -0
  32. data/lib/bug_bunny/client.rb +45 -21
  33. data/lib/bug_bunny/configuration.rb +63 -0
  34. data/lib/bug_bunny/consumer.rb +51 -37
  35. data/lib/bug_bunny/consumer_middleware.rb +14 -5
  36. data/lib/bug_bunny/controller.rb +39 -18
  37. data/lib/bug_bunny/exception.rb +5 -1
  38. data/lib/bug_bunny/middleware/raise_error.rb +3 -3
  39. data/lib/bug_bunny/observability.rb +28 -6
  40. data/lib/bug_bunny/producer.rb +11 -13
  41. data/lib/bug_bunny/railtie.rb +8 -7
  42. data/lib/bug_bunny/request.rb +3 -11
  43. data/lib/bug_bunny/resource.rb +81 -41
  44. data/lib/bug_bunny/routing/route.rb +6 -1
  45. data/lib/bug_bunny/routing/route_set.rb +60 -22
  46. data/lib/bug_bunny/session.rb +18 -11
  47. data/lib/bug_bunny/version.rb +1 -1
  48. data/lib/bug_bunny.rb +4 -2
  49. data/lib/generators/bug_bunny/install/install_generator.rb +45 -5
  50. data/lib/tasks/bug_bunny.rake +50 -0
  51. data/plan_test.txt +63 -0
  52. data/skills-lock.json +10 -0
  53. data/spec/integration/client_spec.rb +117 -0
  54. data/spec/integration/consumer_middleware_spec.rb +86 -0
  55. data/spec/integration/controller_spec.rb +140 -0
  56. data/spec/integration/error_handling_spec.rb +57 -0
  57. data/spec/integration/infrastructure_spec.rb +52 -0
  58. data/spec/integration/resource_spec.rb +113 -0
  59. data/spec/spec_helper.rb +70 -0
  60. data/spec/support/bunny_mocks.rb +18 -0
  61. data/spec/support/integration_helper.rb +87 -0
  62. data/spec/unit/client_session_pool_spec.rb +159 -0
  63. data/spec/unit/configuration_spec.rb +164 -0
  64. data/spec/unit/consumer_middleware_spec.rb +129 -0
  65. data/spec/unit/consumer_spec.rb +90 -0
  66. data/spec/unit/controller_after_action_spec.rb +155 -0
  67. data/spec/unit/observability_spec.rb +167 -0
  68. data/spec/unit/resource_attributes_spec.rb +69 -0
  69. data/spec/unit/session_spec.rb +98 -0
  70. metadata +50 -3
  71. data/sig/bug_bunny.rbs +0 -4
@@ -25,8 +25,7 @@ module BugBunny
25
25
  define_model_callbacks :save, :create, :update, :destroy
26
26
 
27
27
  attr_reader :remote_attributes
28
- attr_accessor :persisted
29
- attr_accessor :routing_key, :exchange, :exchange_type
28
+ attr_accessor :persisted, :routing_key, :exchange, :exchange_type
30
29
 
31
30
  # @return [Hash] Opciones específicas de instancia para exchange y queue.
32
31
  attr_accessor :exchange_options, :queue_options
@@ -38,7 +37,9 @@ module BugBunny
38
37
  attr_writer :exchange_options, :queue_options
39
38
 
40
39
  # @api private
41
- def thread_config(key); Thread.current["bb_#{object_id}_#{key}"]; end
40
+ def thread_config(key)
41
+ Thread.current["bb_#{object_id}_#{key}"]
42
+ end
42
43
 
43
44
  # Resuelve la configuración buscando en el hilo, luego en la jerarquía de clases.
44
45
  # @param key [Symbol] Clave en el Thread.current.
@@ -47,29 +48,41 @@ module BugBunny
47
48
  def resolve_config(key, instance_var)
48
49
  val = thread_config(key)
49
50
  return val if val
51
+
50
52
  target = self
51
53
  while target <= BugBunny::Resource
52
54
  value = target.instance_variable_get(instance_var)
53
55
  return value.respond_to?(:call) ? value.call : value unless value.nil?
56
+
54
57
  target = target.superclass
55
58
  end
56
59
  nil
57
60
  end
58
61
 
59
62
  # @return [ConnectionPool, nil]
60
- def connection_pool; resolve_config(:pool, :@connection_pool); end
63
+ def connection_pool
64
+ resolve_config(:pool, :@connection_pool)
65
+ end
61
66
 
62
67
  # @return [String] Nombre del exchange actual.
63
- def current_exchange; resolve_config(:exchange, :@exchange) || raise(ArgumentError, "Exchange not defined for #{name}"); end
68
+ def current_exchange
69
+ resolve_config(:exchange, :@exchange) || raise(ArgumentError, "Exchange not defined for #{name}")
70
+ end
64
71
 
65
72
  # @return [String] Tipo de exchange ('direct', 'topic', 'fanout').
66
- def current_exchange_type; resolve_config(:exchange_type, :@exchange_type) || 'direct'; end
73
+ def current_exchange_type
74
+ resolve_config(:exchange_type, :@exchange_type) || 'direct'
75
+ end
67
76
 
68
77
  # @return [Hash] Opciones de exchange específicas (Nivel 3 de la cascada).
69
- def current_exchange_options; resolve_config(:exchange_options, :@exchange_options) || {}; end
78
+ def current_exchange_options
79
+ resolve_config(:exchange_options, :@exchange_options) || {}
80
+ end
70
81
 
71
82
  # @return [Hash] Opciones de cola específicas.
72
- def current_queue_options; resolve_config(:queue_options, :@queue_options) || {}; end
83
+ def current_queue_options
84
+ resolve_config(:queue_options, :@queue_options) || {}
85
+ end
73
86
 
74
87
  # @return [String] Nombre del recurso para la construcción de rutas.
75
88
  def resource_name
@@ -126,7 +139,8 @@ module BugBunny
126
139
  # @param pool [ConnectionPool] Pool de conexiones.
127
140
  # @param exchange_options [Hash] Opciones de infraestructura.
128
141
  # @param queue_options [Hash] Opciones de cola.
129
- def with(exchange: nil, routing_key: nil, exchange_type: nil, pool: nil, exchange_options: nil, queue_options: nil)
142
+ def with(exchange: nil, routing_key: nil, exchange_type: nil, pool: nil, exchange_options: nil,
143
+ queue_options: nil)
130
144
  keys = {
131
145
  exchange: "bb_#{object_id}_exchange",
132
146
  exchange_type: "bb_#{object_id}_exchange_type",
@@ -163,9 +177,7 @@ module BugBunny
163
177
  end
164
178
 
165
179
  def method_missing(method, *args, &block)
166
- if @used
167
- ::Kernel.raise ::BugBunny::Error, "ScopeProxy is single-use. Call .with again for a new context."
168
- end
180
+ ::Kernel.raise ::BugBunny::Error, 'ScopeProxy is single-use. Call .with again for a new context.' if @used
169
181
  @used = true
170
182
  @target.public_send(method, *args, &block)
171
183
  ensure
@@ -176,11 +188,13 @@ module BugBunny
176
188
  # Calcula la routing key final.
177
189
  # @param id [String, nil] ID del recurso.
178
190
  # @return [String]
179
- def calculate_routing_key(id = nil)
191
+ def calculate_routing_key(_id = nil)
180
192
  manual_rk = thread_config(:routing_key)
181
193
  return manual_rk if manual_rk
194
+
182
195
  static_rk = resolve_config(:routing_key, :@routing_key)
183
196
  return static_rk if static_rk.present?
197
+
184
198
  resource_name
185
199
  end
186
200
 
@@ -206,6 +220,7 @@ module BugBunny
206
220
  )
207
221
 
208
222
  return [] unless response['body'].is_a?(Array)
223
+
209
224
  response['body'].map do |attrs|
210
225
  inst = new(attrs)
211
226
  inst.persisted = true
@@ -218,7 +233,9 @@ module BugBunny
218
233
 
219
234
  # Devuelve todos los registros.
220
235
  # @return [Array<BugBunny::Resource>]
221
- def all; where({}); end
236
+ def all
237
+ where({})
238
+ end
222
239
 
223
240
  # Busca un registro por ID (GET).
224
241
  # Mapea un 404 (NotFound) devolviendo un objeto nulo.
@@ -264,8 +281,8 @@ module BugBunny
264
281
  # Inicializa el recurso.
265
282
  # @param attributes [Hash]
266
283
  def initialize(attributes = {})
267
- @remote_attributes = {}.with_indifferent_access
268
- @dynamic_changes = Set.new # Rastreo manual para atributos dinámicos
284
+ @extra_attributes = {}.with_indifferent_access
285
+ @dynamic_changes = Set.new
269
286
  @persisted = false
270
287
 
271
288
  # Contexto de infraestructura
@@ -275,41 +292,61 @@ module BugBunny
275
292
  @exchange_options = self.class.thread_config(:exchange_options) || self.class.current_exchange_options
276
293
  @queue_options = self.class.thread_config(:queue_options) || self.class.current_queue_options
277
294
 
278
- super()
279
- assign_attributes(attributes)
295
+ super
280
296
  end
281
297
 
282
- # Limpia tanto el rastreo de ActiveModel como nuestro rastreo dinámico.
298
+ # Limpia el rastreo de ActiveModel y nuestro rastreo dinámico interno.
283
299
  def clear_changes_information
284
300
  super
285
301
  @dynamic_changes.clear
286
302
  end
287
303
 
304
+ # @return [Boolean] true si hay cambios nativos o dinámicos.
305
+ def changed?
306
+ super || @dynamic_changes.any?
307
+ end
308
+
309
+ # @return [Array<String>] Lista de atributos que han cambiado.
310
+ def changed
311
+ (super + @dynamic_changes.to_a).uniq
312
+ end
313
+
288
314
  # Serialización combinada.
289
315
  # @return [Hash]
290
316
  def attributes_for_serialization
291
- @remote_attributes.merge(attributes)
317
+ @extra_attributes.merge(attributes)
292
318
  end
293
319
 
294
320
  # @return [String]
295
- def calculate_routing_key(id=nil); @routing_key || self.class.calculate_routing_key(id); end
321
+ def calculate_routing_key(id = nil)
322
+ @routing_key || self.class.calculate_routing_key(id)
323
+ end
296
324
 
297
325
  # @return [String]
298
- def current_exchange; @exchange || self.class.current_exchange; end
326
+ def current_exchange
327
+ @exchange || self.class.current_exchange
328
+ end
299
329
 
300
330
  # @return [String]
301
- def current_exchange_type; @exchange_type || self.class.current_exchange_type; end
331
+ def current_exchange_type
332
+ @exchange_type || self.class.current_exchange_type
333
+ end
302
334
 
303
335
  # @return [BugBunny::Client]
304
- def bug_bunny_client; self.class.bug_bunny_client; end
336
+ def bug_bunny_client
337
+ self.class.bug_bunny_client
338
+ end
305
339
 
306
340
  # @return [Boolean]
307
- def persisted?; !!@persisted; end
341
+ def persisted?
342
+ !!@persisted
343
+ end
308
344
 
309
345
  # Asignación masiva de atributos.
310
346
  # @param new_attributes [Hash]
311
347
  def assign_attributes(new_attributes)
312
348
  return if new_attributes.nil?
349
+
313
350
  new_attributes.each { |k, v| public_send("#{k}=", v) }
314
351
  end
315
352
 
@@ -324,57 +361,58 @@ module BugBunny
324
361
  # Retorna el hash combinado de cambios (Tipados + Dinámicos).
325
362
  # @return [Hash]
326
363
  def changes_to_send
327
- # 1. Cambios de ActiveModel (Tipados)
328
- payload = changes.transform_values(&:last)
364
+ # 1. Obtener los nombres de todos los atributos que han cambiado (incluyendo dinámicos vía attribute_will_change!)
365
+ changed_keys = changed
329
366
 
330
- # 2. Cambios Dinámicos (Manuales)
331
- @dynamic_changes.each do |key|
332
- payload[key] = @remote_attributes[key]
367
+ # 2. Construir el payload con los valores actuales de esas keys
368
+ payload = {}
369
+ changed_keys.each do |key|
370
+ payload[key] = public_send(key)
333
371
  end
334
372
 
335
373
  return payload unless payload.empty?
336
374
 
337
- # Fallback: Si no hay cambios detectados, enviamos todo (útil para create)
375
+ # Fallback: Si no hay cambios detectados (ej: en un create), enviamos todo
338
376
  attributes_for_serialization.except('id', 'ID', 'Id', '_id')
339
377
  end
340
378
 
341
- # Intercepta asignaciones dinámicas y las marca como sucias.
379
+ # Intercepta asignaciones dinámicas y las registra como cambios.
342
380
  def method_missing(method_name, *args, &block)
343
381
  attribute_name = method_name.to_s
344
382
  if attribute_name.end_with?('=')
345
383
  key = attribute_name.chop
346
384
  val = args.first
347
385
 
348
- if @remote_attributes[key] != val
386
+ if @extra_attributes[key] != val
349
387
  @dynamic_changes << key
388
+ @extra_attributes[key] = val
350
389
  end
351
-
352
- @remote_attributes[key] = val
353
390
  else
354
- @remote_attributes.key?(attribute_name) ? @remote_attributes[attribute_name] : super
391
+ @extra_attributes.key?(attribute_name) ? @extra_attributes[attribute_name] : super
355
392
  end
356
393
  end
357
394
 
358
395
  def respond_to_missing?(method_name, include_private = false)
359
- @remote_attributes.key?(method_name.to_s.sub(/=$/, '')) || super
396
+ @extra_attributes.key?(method_name.to_s.sub(/=$/, '')) || super
360
397
  end
361
398
 
362
399
  # @return [Object] Valor del ID buscando en múltiples nomenclaturas.
363
400
  def id
364
- attributes['id'] || @remote_attributes['id'] || @remote_attributes['ID'] || @remote_attributes['Id'] || @remote_attributes['_id']
401
+ attributes['id'] || @extra_attributes['id'] || @extra_attributes['ID'] || @extra_attributes['Id'] || @extra_attributes['_id']
365
402
  end
366
403
 
367
404
  def id=(value)
368
405
  if self.class.attribute_names.include?('id')
369
- super(value)
406
+ super
370
407
  else
371
- @remote_attributes['id'] = value
408
+ @dynamic_changes << 'id' if @extra_attributes['id'] != value
409
+ @extra_attributes['id'] = value
372
410
  end
373
411
  end
374
412
 
375
413
  def read_attribute_for_validation(attr)
376
414
  attr_s = attr.to_s
377
- self.class.attribute_names.include?(attr_s) ? attribute(attr_s) : @remote_attributes[attr_s]
415
+ self.class.attribute_names.include?(attr_s) ? attribute(attr_s) : @extra_attributes[attr_s]
378
416
  end
379
417
 
380
418
  # @!group Persistencia
@@ -423,6 +461,7 @@ module BugBunny
423
461
  # @return [Boolean]
424
462
  def destroy
425
463
  return false unless persisted?
464
+
426
465
  run_callbacks(:destroy) do
427
466
  path = "#{self.class.resource_name}/#{id}"
428
467
  rk = calculate_routing_key(id)
@@ -449,6 +488,7 @@ module BugBunny
449
488
  # Carga errores remotos en el objeto local (utilizado al recibir 422).
450
489
  def load_remote_rabbit_errors(errors_hash)
451
490
  return if errors_hash.nil? || errors_hash.empty?
491
+
452
492
  if errors_hash.is_a?(String)
453
493
  errors.add(:base, errors_hash)
454
494
  else
@@ -17,6 +17,9 @@ module BugBunny
17
17
  # @return [String] El nombre del controlador en formato snake_case (ej. 'api/v1/metrics').
18
18
  attr_reader :controller
19
19
 
20
+ # @return [String, nil] El namespace del controlador si existe (ej. 'Api::V1').
21
+ attr_reader :namespace
22
+
20
23
  # @return [String] El nombre de la acción a ejecutar (ej. 'show').
21
24
  attr_reader :action
22
25
 
@@ -25,10 +28,12 @@ module BugBunny
25
28
  # @param http_method [String, Symbol] Verbo HTTP (ej. :get, 'POST').
26
29
  # @param path_pattern [String] Patrón de la URL. Los parámetros dinámicos deben iniciar con ':' (ej. 'users/:id').
27
30
  # @param to [String] Destino en formato 'controlador#accion' (ej. 'users#show').
31
+ # @param namespace [String, nil] El namespace del controlador (ej: 'Api::V1').
28
32
  # @raise [ArgumentError] Si el formato del destino `to` es inválido.
29
- def initialize(http_method, path_pattern, to:)
33
+ def initialize(http_method, path_pattern, to:, namespace: nil)
30
34
  @http_method = http_method.to_s.upcase
31
35
  @path_pattern = normalize_path(path_pattern)
36
+ @namespace = namespace
32
37
 
33
38
  parse_destination!(to)
34
39
  compile_regex!
@@ -57,30 +57,57 @@ module BugBunny
57
57
  # Registra una ruta para el verbo GET.
58
58
  # @param path [String, Symbol] Patrón de la URL.
59
59
  # @param to [String, nil] Destino (controlador#accion). Si es nil, se infiere del scope.
60
- def get(path, to: nil); add_route('GET', path, to: to); end
60
+ def get(path, to: nil)
61
+ add_route('GET', path, to: to)
62
+ end
61
63
 
62
64
  # Registra una ruta para el verbo POST.
63
65
  # @param path [String, Symbol] Patrón de la URL.
64
66
  # @param to [String, nil] Destino (controlador#accion). Si es nil, se infiere del scope.
65
- def post(path, to: nil); add_route('POST', path, to: to); end
67
+ def post(path, to: nil)
68
+ add_route('POST', path, to: to)
69
+ end
66
70
 
67
71
  # Registra una ruta para el verbo PUT.
68
72
  # @param path [String, Symbol] Patrón de la URL.
69
73
  # @param to [String, nil] Destino (controlador#accion). Si es nil, se infiere del scope.
70
- def put(path, to: nil); add_route('PUT', path, to: to); end
74
+ def put(path, to: nil)
75
+ add_route('PUT', path, to: to)
76
+ end
71
77
 
72
78
  # Registra una ruta para el verbo PATCH.
73
79
  # @param path [String, Symbol] Patrón de la URL.
74
80
  # @param to [String, nil] Destino (controlador#accion). Si es nil, se infiere del scope.
75
- def patch(path, to: nil); add_route('PATCH', path, to: to); end
81
+ def patch(path, to: nil)
82
+ add_route('PATCH', path, to: to)
83
+ end
76
84
 
77
85
  # Registra una ruta para el verbo DELETE.
78
86
  # @param path [String, Symbol] Patrón de la URL.
79
87
  # @param to [String, nil] Destino (controlador#accion). Si es nil, se infiere del scope.
80
- def delete(path, to: nil); add_route('DELETE', path, to: to); end
88
+ def delete(path, to: nil)
89
+ add_route('DELETE', path, to: to)
90
+ end
81
91
 
82
92
  # @!endgroup
83
93
 
94
+ # Define un bloque de namespace para organizar controladores en módulos.
95
+ # Los namespaces pueden anidarse y se acumulan (ej: `namespace :api { namespace :v1 }`
96
+ # resulta en el namespace "Api::V1").
97
+ #
98
+ # @param name [Symbol, String] Nombre del namespace (ej: :api, :v1).
99
+ # @yield Bloque conteniendo las definiciones de rutas dentro de este namespace.
100
+ # @return [void]
101
+ # @example
102
+ # namespace :api do
103
+ # resources :users # Busca Api::UsersController
104
+ # end
105
+ def namespace(name, &block)
106
+ with_scope({ type: :namespace, name: name.to_s.camelize }) do
107
+ instance_eval(&block)
108
+ end
109
+ end
110
+
84
111
  # Macro que genera automáticamente las rutas CRUD para un recurso RESTful.
85
112
  # Soporta filtrado mediante `only` y `except`, y acepta un bloque para rutas anidadas.
86
113
  #
@@ -96,7 +123,7 @@ module BugBunny
96
123
  resource_path = name.to_s
97
124
 
98
125
  # Todas las acciones estándar disponibles
99
- actions = [:index, :show, :create, :update, :destroy]
126
+ actions = %i[index show create update destroy]
100
127
 
101
128
  # Aplicamos los filtros si existen
102
129
  if only
@@ -114,10 +141,10 @@ module BugBunny
114
141
  delete "#{resource_path}/:id", to: "#{resource_path}#destroy" if actions.include?(:destroy)
115
142
 
116
143
  # Si se pasa un bloque, abrimos un Scope de Recurso para rutas anidadas
117
- if block_given?
118
- with_scope({ type: :resource, name: resource_path }) do
119
- instance_eval(&block)
120
- end
144
+ return unless block_given?
145
+
146
+ with_scope({ type: :resource, name: resource_path }) do
147
+ instance_eval(&block)
121
148
  end
122
149
  end
123
150
 
@@ -177,18 +204,19 @@ module BugBunny
177
204
  #
178
205
  # @param method [String] Verbo HTTP entrante (ej. 'GET').
179
206
  # @param path [String] URL entrante (ej. 'nodes/123/drain').
180
- # @return [Hash, nil] Hash con `:controller`, `:action` y `:params`, o `nil` si no hay match.
207
+ # @return [Hash, nil] Hash con `:controller`, `:action`, `:params` y `:namespace`, o `nil` si no hay match.
181
208
  def recognize(method, path)
182
209
  @routes.each do |route|
183
- if route.match?(method, path)
184
- extracted_params = route.extract_params(path)
185
-
186
- return {
187
- controller: route.controller,
188
- action: route.action,
189
- params: extracted_params
190
- }
191
- end
210
+ next unless route.match?(method, path)
211
+
212
+ extracted_params = route.extract_params(path)
213
+
214
+ return {
215
+ controller: route.controller,
216
+ action: route.action,
217
+ params: extracted_params,
218
+ namespace: route.namespace
219
+ }
192
220
  end
193
221
 
194
222
  # Si llegamos aquí, es un 404 seguro.
@@ -220,10 +248,11 @@ module BugBunny
220
248
  end
221
249
 
222
250
  if final_to.nil?
223
- raise ArgumentError, "Falta el destino 'to:' para la ruta #{method} '#{final_path}'. Usa la sintaxis 'controlador#accion'"
251
+ raise ArgumentError,
252
+ "Falta el destino 'to:' para la ruta #{method} '#{final_path}'. Usa la sintaxis 'controlador#accion'"
224
253
  end
225
254
 
226
- @routes << Route.new(method, final_path, to: final_to)
255
+ @routes << Route.new(method, final_path, to: final_to, namespace: current_namespace)
227
256
  end
228
257
 
229
258
  # --- LÓGICA DE SCOPES INTERNOS ---
@@ -263,6 +292,15 @@ module BugBunny
263
292
  end
264
293
  nil
265
294
  end
295
+
296
+ # Calcula el namespace acumulado recorriendo la pila de scopes.
297
+ #
298
+ # @return [String, nil] El namespace (ej: "Api::V1") o nil si no hay.
299
+ # @api private
300
+ def current_namespace
301
+ parts = @scopes.select { |s| s[:type] == :namespace }.map { |s| s[:name] }
302
+ parts.empty? ? nil : parts.join('::')
303
+ end
266
304
  end
267
305
  end
268
306
  end
@@ -9,6 +9,7 @@ module BugBunny
9
9
  # @api private
10
10
  class Session
11
11
  include BugBunny::Observability
12
+
12
13
  # @!group Opciones por Defecto (Nivel 1: Gema)
13
14
 
14
15
  # Opciones predeterminadas de la gema para Exchanges.
@@ -32,6 +33,7 @@ module BugBunny
32
33
  @connection = connection
33
34
  @publisher_confirms = publisher_confirms
34
35
  @channel = nil
36
+ @channel_mutex = Mutex.new
35
37
  @logger = BugBunny.configuration.logger
36
38
  end
37
39
 
@@ -43,12 +45,17 @@ module BugBunny
43
45
  # @return [Bunny::Channel] Un canal abierto y configurado.
44
46
  # @raise [BugBunny::CommunicationError] Si no se puede restablecer la conexión.
45
47
  def channel
46
- # Si el canal existe y está abierto, lo devolvemos rápido.
48
+ # Fast path: canal abierto, sin adquirir el mutex.
47
49
  return @channel if @channel&.open?
48
50
 
49
- # Si no, intentamos asegurar la conexión y crear el canal.
50
- ensure_connection!
51
- create_channel!
51
+ # Slow path: adquirimos el mutex y verificamos de nuevo (double-checked locking).
52
+ # Evita que múltiples threads creen canales simultáneamente cuando el canal cae.
53
+ @channel_mutex.synchronize do
54
+ return @channel if @channel&.open?
55
+
56
+ ensure_connection!
57
+ create_channel!
58
+ end
52
59
 
53
60
  @channel
54
61
  end
@@ -98,8 +105,10 @@ module BugBunny
98
105
  # Cierra el canal asociado a esta sesión de forma segura.
99
106
  # @return [void]
100
107
  def close
101
- @channel&.close if @channel&.open?
102
- @channel = nil
108
+ @channel_mutex.synchronize do
109
+ @channel&.close if @channel&.open?
110
+ @channel = nil
111
+ end
103
112
  end
104
113
 
105
114
  private
@@ -113,9 +122,7 @@ module BugBunny
113
122
 
114
123
  @channel.confirm_select if @publisher_confirms
115
124
 
116
- if BugBunny.configuration.channel_prefetch
117
- @channel.prefetch(BugBunny.configuration.channel_prefetch)
118
- end
125
+ @channel.prefetch(BugBunny.configuration.channel_prefetch) if BugBunny.configuration.channel_prefetch
119
126
  rescue StandardError => e
120
127
  raise BugBunny::CommunicationError, "Failed to create channel: #{e.message}"
121
128
  end
@@ -127,10 +134,10 @@ module BugBunny
127
134
  def ensure_connection!
128
135
  return if @connection.open?
129
136
 
130
- safe_log(:warn, "session.reconnect_attempt")
137
+ safe_log(:warn, 'session.reconnect_attempt')
131
138
  @connection.start
132
139
  rescue StandardError => e
133
- safe_log(:error, "session.reconnect_failed", **exception_metadata(e))
140
+ safe_log(:error, 'session.reconnect_failed', **exception_metadata(e))
134
141
  raise BugBunny::CommunicationError, "Could not reconnect to RabbitMQ: #{e.message}"
135
142
  end
136
143
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module BugBunny
4
- VERSION = "4.6.1"
4
+ VERSION = '4.8.0'
5
5
  end
data/lib/bug_bunny.rb CHANGED
@@ -25,6 +25,7 @@ require_relative 'bug_bunny/railtie' if defined?(Rails)
25
25
  # Actúa como espacio de nombres y punto de configuración global.
26
26
  module BugBunny
27
27
  extend BugBunny::Observability
28
+
28
29
  private_class_method :safe_log, :exception_metadata, :observability_name
29
30
 
30
31
  class << self
@@ -56,6 +57,7 @@ module BugBunny
56
57
  def self.configure
57
58
  self.configuration ||= Configuration.new
58
59
  yield(configuration)
60
+ configuration.validate!
59
61
  end
60
62
 
61
63
  # Crea e inicia una nueva conexión a RabbitMQ utilizando la gema Bunny.
@@ -94,9 +96,9 @@ module BugBunny
94
96
 
95
97
  @global_connection.close if @global_connection.open?
96
98
  @global_connection = nil
97
-
99
+
98
100
  @logger = configuration.logger
99
- safe_log(:info, "bug_bunny.disconnect")
101
+ safe_log(:info, 'bug_bunny.disconnect')
100
102
  end
101
103
 
102
104
  # @api private
@@ -20,7 +20,7 @@ module BugBunny
20
20
  # @api private
21
21
  source_root File.expand_path('templates', __dir__)
22
22
 
23
- desc "Instala la configuración inicial de BugBunny y crea la estructura de directorios."
23
+ desc 'Instala la configuración inicial de BugBunny y crea la estructura de directorios.'
24
24
 
25
25
  # Genera el archivo de configuración inicial.
26
26
  # Copia la plantilla `initializer.rb` a `config/initializers/bug_bunny.rb` en la app destino.
@@ -33,15 +33,55 @@ module BugBunny
33
33
  # Crea la estructura de carpetas necesaria para el patrón MVC de BugBunny.
34
34
  #
35
35
  # Genera:
36
- # * `app/rabbit/controllers/`: Directorio donde vivirán los controladores de consumidores.
36
+ # * `app/bug_bunny/controllers/`: Directorio donde vivirán los controladores de consumidores.
37
37
  # * `.keep`: Archivo marcador para asegurar que Git rastree la carpeta aunque esté vacía.
38
38
  #
39
39
  # @return [void]
40
40
  def create_directories
41
- empty_directory "app/rabbit/controllers"
42
- create_file "app/rabbit/controllers/.keep", ""
41
+ empty_directory 'app/bug_bunny/controllers'
42
+ create_file 'app/bug_bunny/controllers/.keep', ''
43
+ end
44
+
45
+ # Escribe el bloque inicial de BugBunny en CLAUDE.md del proyecto consumidor.
46
+ #
47
+ # Si CLAUDE.md no existe, crea uno mínimo con la sección correspondiente.
48
+ # Si ya existe, agrega la sección `## Gemas internas` con el bloque de bug_bunny.
49
+ # En ambos casos, el rake task `bug_bunny:sync` se encarga de las actualizaciones futuras.
50
+ #
51
+ # @return [void]
52
+ def update_claude_md
53
+ spec = Gem::Specification.find_by_name('bug_bunny')
54
+ version = spec.version.to_s
55
+ docs_path = File.join(spec.gem_dir, 'docs', 'ai')
56
+
57
+ block = <<~BLOCK
58
+ ## Gemas internas
59
+
60
+ ### bug_bunny
61
+ - **Version:** #{version}
62
+ - **Docs:** #{docs_path}
63
+ - **Updated:** #{Time.now.strftime('%Y-%m-%d')}
64
+ BLOCK
65
+
66
+ claude_md = File.join(destination_root, 'CLAUDE.md')
67
+
68
+ if File.exist?(claude_md)
69
+ content = File.read(claude_md)
70
+ if content.include?('### bug_bunny')
71
+ say_status :skip, 'CLAUDE.md already has a bug_bunny block — run `rake bug_bunny:sync` to update'
72
+ elsif content.include?('## Gemas internas')
73
+ inject_into_file 'CLAUDE.md', "\n#{block.lines.drop(2).join}", after: "## Gemas internas\n"
74
+ say_status :update, 'Added bug_bunny block to existing ## Gemas internas section in CLAUDE.md'
75
+ else
76
+ append_to_file 'CLAUDE.md', "\n#{block}"
77
+ say_status :update, 'Added ## Gemas internas section to CLAUDE.md'
78
+ end
79
+ else
80
+ create_file 'CLAUDE.md', "# #{Rails.application.class.module_parent_name}\n\n#{block}"
81
+ say_status :create, 'CLAUDE.md created with bug_bunny block'
82
+ end
43
83
 
44
- puts "🐰 BugBunny structure created successfully!"
84
+ say ' Add `bundle exec rake bug_bunny:sync` to bin/setup to keep this block up to date.'
45
85
  end
46
86
  end
47
87
  end