ruby_reactor 0.8.1 → 0.8.2

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0d5c03b684aeac0b3d682581e9746e59219622f07752a5b5e30b5381582c09c9
4
- data.tar.gz: bccbbdf3eec2187481fba0c5d724b1b2b61d96a32c93139dcf5fcd20e42ff491
3
+ metadata.gz: 31b3ac286396e1d29f1bad40e87f4f55b8ecc3e6c715e6cfd2ca0496d9c8dd0c
4
+ data.tar.gz: d518e5aceb0e5fa6dd43007e1f324612c84a53528e73a76463db7ecb652f0a95
5
5
  SHA512:
6
- metadata.gz: ab06166bc3462aa2cf8f4569015575feb7c37ae025022ae84b886dcc171040c2a89a88dbc69208c8b1ec84be6dd19c08c418915856a510506402e7ab72f26044
7
- data.tar.gz: cfb0973443827d2d50572f834b1d9d2760464eab4290097b38c73465fcb7268f9e381d211b4a2fe1815f6bb410ac56ffa64c4f61ed17f0a765966392c50494a4
6
+ metadata.gz: 2e91db7e2c548b834f285fe514747bc407b29fbb4cdb10e4aef44b05ff556d518ccf77ad4d02fc3e097907d7298843bc32b7e5a57d4f4a8ae687795dc702fa71
7
+ data.tar.gz: 6a2252e7fdf4740f1d297f2855bb60f07c4b66e0a551c8d90e4394fd1acafd829098323d40afb4f9bc650c76a53818d76566c70f65c89c4b60ed11f9018a3d48
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "0.8.1"
2
+ ".": "0.8.2"
3
3
  }
data/CHANGELOG.md CHANGED
@@ -129,6 +129,13 @@
129
129
  ordinary `nil` success, and `result(:step)` hands the reader the `Halt` — the same way it
130
130
  already hands over a `Failure`.
131
131
 
132
+ ## [0.8.2](https://github.com/arturictus/ruby_reactor/compare/v0.8.1...v0.8.2) (2026-09-22)
133
+
134
+
135
+ ### Features
136
+
137
+ * Enhance mocking capabilities for nested reactors with scoped APIs and examples ([#58](https://github.com/arturictus/ruby_reactor/issues/58)) ([a462265](https://github.com/arturictus/ruby_reactor/commit/a462265b233f94f07fc39628b59a2bb038744fc9))
138
+
132
139
  ## [0.8.1](https://github.com/arturictus/ruby_reactor/compare/v0.8.0...v0.8.1) (2026-09-22)
133
140
 
134
141
 
data/README.md CHANGED
@@ -1411,7 +1411,7 @@ end
1411
1411
  RubyReactor provides testing utilities for RSpec. See the [Testing with RSpec](documentation/testing.md) guide for comprehensive documentation — including [unit-testing class-based steps](documentation/testing.md#testing-step-classes) directly.
1412
1412
 
1413
1413
  ```ruby
1414
- RSpec.describe PaymentReactor do
1414
+ RSpec.describe PaymentReactor, type: :reactor do
1415
1415
  it "processes payment successfully" do
1416
1416
  subject = test_reactor(PaymentReactor, order_id: 123, amount: 99.99)
1417
1417
 
@@ -2,9 +2,10 @@
2
2
 
3
3
  module RubyReactor
4
4
  module RSpec
5
- # Globally-included helpers. Only methods whose names clearly belong to
6
- # RubyReactor's test surface live here (`test_reactor`). Sidekiq-coupled
7
- # helpers live in `SidekiqHelpers` and are scoped to `type: :reactor`.
5
+ # Entry-point helpers (`test_reactor`), auto-included into examples tagged
6
+ # `type: :reactor`. Specs that want them outside that tag should
7
+ # `include RubyReactor::RSpec::Helpers` explicitly. Sidekiq-coupled helpers
8
+ # live in `SidekiqHelpers`, scoped the same way.
8
9
  module Helpers
9
10
  # Build a `TestSubject` around a reactor invocation. Captures the run for
10
11
  # later introspection via matchers; runs the reactor lazily on first
@@ -48,44 +48,93 @@ module RubyReactor
48
48
  self
49
49
  end
50
50
 
51
- # Fluent API for mocking nested map steps
52
- # @example
53
- # reactor.map(:my_map).mock_step(:inner_step) { ... }
51
+ # Fluent/scoped API for mocking nested map steps.
52
+ #
53
+ # Without a block, returns a proxy scoped to this map step — chain
54
+ # `mock_step`/`failing_at`/`map`/`composed` on it to configure several
55
+ # things inside the same nested reactor:
56
+ # reactor.map(:my_map).mock_step(:a) { ... }.mock_step(:b) { ... }
57
+ #
58
+ # With a block, the proxy is yielded and the *outer* subject is
59
+ # returned, so sibling nested reactors can be configured in the same
60
+ # chain without leaking scope between them:
61
+ # test_reactor(Parent, params)
62
+ # .map(:my_map) { |m| m.mock_step(:a) { ... } }
63
+ # .composed(:other_child) { |c| c.mock_step(:b) { ... } }
54
64
  def map(step_name)
55
- StepProxy.new(self, step_name)
65
+ proxy = StepProxy.new(self, [step_name])
66
+ return proxy unless block_given?
67
+
68
+ yield proxy
69
+ self
56
70
  end
57
71
 
58
- # Fluent API for mocking nested compose steps
72
+ # Fluent/scoped API for mocking nested compose steps. See `#map` for
73
+ # the block vs. no-block behavior.
59
74
  # @example
60
- # reactor.compose(:my_sub_reactor).mock_step(:inner_step) { ... }
75
+ # reactor.composed(:my_sub_reactor).mock_step(:inner_step) { ... }
61
76
  def composed(step_name)
62
77
  # If already executed, return the traversed subject
63
78
  return traverse_composed(step_name) if @executed
64
79
 
65
- # Otherwise return a configuration proxy
66
- StepProxy.new(self, step_name)
80
+ proxy = StepProxy.new(self, [step_name])
81
+ return proxy unless block_given?
82
+
83
+ yield proxy
84
+ self
67
85
  end
68
86
  alias compose composed
69
87
 
70
- # Proxy class for fluent mocking configuration
88
+ # Proxy scoped to a nested step path (e.g. [:parent_compose, :child_map]).
89
+ # All mutating calls stay scoped to this path: `mock_step`/`failing_at`
90
+ # return the proxy itself (not the outer subject) so several inner
91
+ # steps of the *same* nested reactor can be chained without resetting
92
+ # scope back to the parent.
71
93
  class StepProxy
72
- def initialize(subject, step_name)
94
+ def initialize(subject, step_path)
73
95
  @subject = subject
74
- @step_name = step_name
96
+ @step_path = step_path
97
+ end
98
+
99
+ def mock_step(inner_step_name, *nested_steps, element_index: nil, &block)
100
+ @subject.mock_step(*@step_path, inner_step_name, *nested_steps, element_index: element_index, &block)
101
+ self
75
102
  end
76
103
 
77
- def mock_step(inner_step_name, *nested_steps, &block)
78
- @subject.mock_step(@step_name, inner_step_name, *nested_steps, &block)
79
- @subject # Return subject to allow chaining or calling run
104
+ def failing_at(inner_step_name, *nested_steps, element_index: nil, &block)
105
+ @subject.failing_at(*@step_path, inner_step_name, *nested_steps, element_index: element_index, &block)
106
+ self
80
107
  end
81
108
 
82
- # Support deep nesting?
83
109
  def map(inner_step_name)
84
- StepProxy.new(@subject, [@step_name, inner_step_name].flatten)
110
+ proxy = self.class.new(@subject, @step_path + [inner_step_name])
111
+ return proxy unless block_given?
112
+
113
+ yield proxy
114
+ self
85
115
  end
86
116
 
87
117
  def composed(inner_step_name)
88
- StepProxy.new(@subject, [@step_name, inner_step_name].flatten)
118
+ proxy = self.class.new(@subject, @step_path + [inner_step_name])
119
+ return proxy unless block_given?
120
+
121
+ yield proxy
122
+ self
123
+ end
124
+ alias compose composed
125
+
126
+ # Delegate everything else (run, success?, result, step_result, ...)
127
+ # to the outer subject, so a proxy can stand in for it wherever a
128
+ # `TestSubject` is expected (e.g. `expect(subject).to be_success`
129
+ # right after a non-block `.composed(:x).mock_step(:y)` chain).
130
+ def method_missing(name, ...)
131
+ return @subject.public_send(name, ...) if @subject.respond_to?(name)
132
+
133
+ super
134
+ end
135
+
136
+ def respond_to_missing?(name, include_private = false)
137
+ @subject.respond_to?(name, include_private) || super
89
138
  end
90
139
  end
91
140
 
@@ -491,12 +540,13 @@ module RubyReactor
491
540
  end
492
541
 
493
542
  def prepare_execution_class
543
+ @force_sync = @async == false
544
+
494
545
  # Even if no interceptors, we might need to subclass to force the whole
495
546
  # reactor to run in-process.
496
- return @reactor_class if @interceptors.empty? && @async != false
547
+ return @reactor_class if @interceptors.empty? && !@force_sync
497
548
 
498
549
  interceptors = @interceptors
499
- force_sync = @async == false
500
550
 
501
551
  execution_class = Class.new(@reactor_class) do
502
552
  # 1. Copy configuration from parent
@@ -514,19 +564,17 @@ module RubyReactor
514
564
  unique_name = "#{superclass.name}Mock#{object_id}"
515
565
  define_singleton_method(:name) { unique_name }
516
566
  RubyReactor::Registry.register(unique_name, self)
567
+ end
517
568
 
518
- # 3. `async: false` / `run_async(false)` means "run this reactor's full
519
- # logic here, in one process". Under the new DSL that is three things:
520
- # suppress the `background` hand-off, and run `async_step` /
521
- # `async_reactor` units inline instead of dispatching them.
522
- if force_sync
523
- @background_handoff = nil
524
- @steps.each do |name, config|
525
- next unless config.respond_to?(:async_dispatch?) && config.async_dispatch?
526
-
527
- @steps[name] = config.clone.tap { |c| c.instance_variable_set(:@async_dispatch, nil) }
528
- end
529
- end
569
+ # 3. `async: false` / `run_async(false)` means "run this reactor's full
570
+ # logic here, in one process" — all the way down. Suppress this
571
+ # class's own `background` hand-off and `async_step`/`async_reactor`
572
+ # dispatch, then recursively do the same for every `compose`d/`map`ped
573
+ # child reactor (`apply_nested_interceptors` below clones from
574
+ # whatever class ends up wired in here, so it inherits this).
575
+ if @force_sync
576
+ strip_background_and_async!(execution_class)
577
+ force_sync_nested_reactors!(execution_class)
530
578
  end
531
579
 
532
580
  # 4. Apply Interceptors
@@ -535,6 +583,79 @@ module RubyReactor
535
583
  execution_class
536
584
  end
537
585
 
586
+ # Clears the background hand-off and any `async_dispatch` flags on a
587
+ # class's OWN steps. Nested composed/map children are separate classes,
588
+ # handled by `force_sync_nested_reactors!`.
589
+ def strip_background_and_async!(klass)
590
+ klass.instance_variable_set(:@background_handoff, nil)
591
+ klass.steps.each do |name, config|
592
+ next unless config.respond_to?(:async_dispatch?) && config.async_dispatch?
593
+
594
+ klass.steps[name] = config.clone.tap { |c| c.instance_variable_set(:@async_dispatch, nil) }
595
+ end
596
+ end
597
+
598
+ # Rewrites every `compose`/`map` step to target a subclass that has
599
+ # itself been force-synced (background/async stripped, and recursively,
600
+ # its own nested composed/map children), so `run_async(false)` reaches
601
+ # every level of a reactor tree instead of only the top one.
602
+ def force_sync_nested_reactors!(klass)
603
+ klass.steps.each do |step_name, step_config|
604
+ arg_key, source = nested_reactor_source(step_config)
605
+ next unless source
606
+
607
+ forced_child = build_forced_sync_class(source.value)
608
+
609
+ new_args = step_config.arguments.dup
610
+ new_args[arg_key] = new_args[arg_key].merge(source: RubyReactor::Template::Value.new(forced_child))
611
+ # A fan-out map is NOT an `async_dispatch` step — its hand-off lives in
612
+ # the `fan_out` argument, so `strip_background_and_async!` never sees
613
+ # it. Turn it off here (on the clone, leaving the original config
614
+ # untouched for normal runs) or `run_async(false)` would still
615
+ # dispatch element jobs and leave the reactor parked at the map.
616
+ if arg_key == :mapped_reactor_class && new_args[:fan_out]
617
+ new_args[:fan_out] = new_args[:fan_out].merge(source: RubyReactor::Template::Value.new(false))
618
+ end
619
+
620
+ new_step_config = step_config.clone
621
+ new_step_config.instance_variable_set(:@arguments, new_args)
622
+ klass.steps[step_name] = new_step_config
623
+ end
624
+ end
625
+
626
+ def nested_reactor_source(step_config)
627
+ return [nil, nil] unless step_config.respond_to?(:arguments)
628
+
629
+ args = step_config.arguments
630
+ # `async_reactor` is included: clearing its dispatch marker makes the
631
+ # child run inline, but inline means `child_class.run(...)` on the
632
+ # ORIGINAL class — whose own background/async steps would dispatch
633
+ # again. The child has to be force-synced too.
634
+ %i[mapped_reactor_class composed_reactor_class async_reactor_class].each do |arg_key|
635
+ source = args[arg_key]&.[](:source)
636
+ return [arg_key, source] if source.is_a?(RubyReactor::Template::Value)
637
+ end
638
+
639
+ [nil, nil]
640
+ end
641
+
642
+ def build_forced_sync_class(original_child_reactor)
643
+ child_class = Class.new(original_child_reactor) do
644
+ define_singleton_method(:name) { original_child_reactor.name }
645
+ @steps = superclass.steps.dup
646
+ @inputs = superclass.inputs.dup
647
+ @input_validations = superclass.input_validations.dup
648
+ @middlewares = superclass.middlewares.dup
649
+ @return_step = superclass.return_step
650
+ @background_handoff = superclass.background_handoff
651
+ @retry_defaults = superclass.instance_variable_get(:@retry_defaults)
652
+ end
653
+
654
+ strip_background_and_async!(child_class)
655
+ force_sync_nested_reactors!(child_class)
656
+ child_class
657
+ end
658
+
538
659
  def apply_interceptors(klass, interceptors)
539
660
  # Group interceptors by the current level step
540
661
  grouped = interceptors.group_by { |i| i[:step_path].first }
@@ -564,7 +685,7 @@ module RubyReactor
564
685
  direct_interceptors.each do |interceptor|
565
686
  case interceptor[:type]
566
687
  when :failure
567
- apply_failure_interceptor(step_config, target_step)
688
+ apply_failure_interceptor(step_config, target_step, step_config_orig, interceptor)
568
689
  when :mock
569
690
  apply_mock_interceptor(step_config, target_step, step_config_orig, interceptor)
570
691
  end
@@ -595,9 +716,19 @@ module RubyReactor
595
716
 
596
717
  original_child_reactor = target_reactor_class_source.value
597
718
 
598
- # Dynamically subclass the child reactor
719
+ # Dynamically subclass the child reactor.
720
+ #
721
+ # It needs a resolvable identity of its OWN: a fan-out map serializes
722
+ # only `mapped_reactor_class.name` into each element job, and the worker
723
+ # resolves that name back through `const_get` first. Keeping the
724
+ # original name would therefore resolve to the original, unmocked class
725
+ # in every element. Registering a unique name makes the fan-out payload
726
+ # (and the `element_reactor_class` that `#map_elements` traverses) point
727
+ # at THIS class.
599
728
  mocked_child_reactor = Class.new(original_child_reactor) do
600
- define_singleton_method(:name) { original_child_reactor.name }
729
+ unique_name = "#{original_child_reactor.name || "AnonymousReactor"}Mock#{object_id}"
730
+ define_singleton_method(:name) { unique_name }
731
+ RubyReactor::Registry.register(unique_name, self)
601
732
  # Copy configuration
602
733
  @steps = superclass.steps.dup
603
734
  @inputs = superclass.inputs.dup
@@ -629,9 +760,40 @@ module RubyReactor
629
760
  step_config.arguments[arg_key][:source] = RubyReactor::Template::Value.new(mocked_child_reactor)
630
761
  end
631
762
 
632
- def apply_failure_interceptor(step_config, target_step)
633
- failure_impl = lambda do |_input, _context|
634
- RubyReactor::Failure("Simulated failure at #{target_step}")
763
+ # The original run behavior for a step, used both as the fallback when
764
+ # `element_index` excludes the current map element and as the `original`
765
+ # callable a mock block can invoke.
766
+ def original_impl_for(step_config_orig, target_step)
767
+ if step_config_orig.has_run_block?
768
+ step_config_orig.run_block
769
+ elsif step_config_orig.has_impl?
770
+ ->(args, ctx) { step_config_orig.impl.run(args, ctx) }
771
+ else
772
+ ->(_, _) { raise "No implementation found for #{target_step}" }
773
+ end
774
+ end
775
+
776
+ # A map element's own context carries `map_metadata[:index]`. When an
777
+ # interceptor is scoped to one element via `element_index:`, only that
778
+ # element's context matches — everything else falls through to the
779
+ # original implementation.
780
+ def matches_element_index?(context, element_index)
781
+ return true if element_index.nil?
782
+
783
+ meta = context.map_metadata
784
+ meta && (meta[:index] == element_index || meta["index"] == element_index)
785
+ end
786
+
787
+ def apply_failure_interceptor(step_config, target_step, step_config_orig, interceptor)
788
+ element_index = interceptor[:conditions][:element_index]
789
+ original_impl = original_impl_for(step_config_orig, target_step)
790
+
791
+ failure_impl = lambda do |input, context|
792
+ if matches_element_index?(context, element_index)
793
+ RubyReactor::Failure("Simulated failure at #{target_step}")
794
+ else
795
+ original_impl.call(input, context)
796
+ end
635
797
  end
636
798
 
637
799
  step_config.instance_variable_set(:@run_block, failure_impl)
@@ -639,18 +801,13 @@ module RubyReactor
639
801
 
640
802
  def apply_mock_interceptor(step_config, target_step, step_config_orig, interceptor)
641
803
  mock_block = interceptor[:conditions][:block]
642
-
643
- # Prepare original implementation call
644
- original_impl = if step_config_orig.has_run_block?
645
- step_config_orig.run_block
646
- elsif step_config_orig.has_impl?
647
- ->(args, ctx) { step_config_orig.impl.run(args, ctx) }
648
- else
649
- ->(_, _) { raise "No implementation found for #{target_step}" }
650
- end
804
+ element_index = interceptor[:conditions][:element_index]
805
+ original_impl = original_impl_for(step_config_orig, target_step)
651
806
 
652
807
  # Create the new implementation that wraps the user block
653
808
  wrapper_impl = lambda do |args, context|
809
+ next original_impl.call(args, context) unless matches_element_index?(context, element_index)
810
+
654
811
  if mock_block.arity == 3
655
812
  mock_block.call(args, context, original_impl)
656
813
  else
@@ -21,7 +21,14 @@ module RubyReactor
21
21
  def self.configure(config)
22
22
  require_relative "rspec/step_executor_patch"
23
23
 
24
- config.include RubyReactor::RSpec::Helpers
24
+ # `test_reactor` and the async-job helpers are only auto-included into
25
+ # examples tagged `type: :reactor`, so a host app's other specs keep a
26
+ # clean namespace. Specs that want them without the tag can
27
+ # `include RubyReactor::RSpec::Helpers` explicitly.
28
+ #
29
+ # Matchers stay unconditional: `::RSpec::Matchers.define` registers them
30
+ # globally at load time, so gating this include would not scope anything.
31
+ config.include RubyReactor::RSpec::Helpers, REACTOR_METADATA
25
32
  config.include RubyReactor::RSpec::Matchers
26
33
  config.include RubyReactor::RSpec::SidekiqHelpers, REACTOR_METADATA
27
34
 
@@ -91,8 +91,8 @@ module RubyReactor
91
91
  results = []
92
92
  fail_fast = inputs[:fail_fast].nil? || inputs[:fail_fast]
93
93
 
94
- inputs[:source].each do |element|
95
- result = execute_single_element(element)
94
+ inputs[:source].each_with_index do |element, index|
95
+ result = execute_single_element(element, index)
96
96
 
97
97
  # An element-level Halt propagates as a run halt: stop immediately
98
98
  # rather than being collected as a (nil) value.
@@ -109,7 +109,7 @@ module RubyReactor
109
109
  results
110
110
  end
111
111
 
112
- def execute_single_element(element)
112
+ def execute_single_element(element, index)
113
113
  mapped_inputs = self.class.build_mapped_inputs(inputs[:argument_mappings] || {}, context, element)
114
114
  child_context = RubyReactor::Context.new(mapped_inputs, inputs[:mapped_reactor_class])
115
115
 
@@ -123,7 +123,7 @@ module RubyReactor
123
123
  child_context.map_metadata = {
124
124
  map_id: map_id,
125
125
  parent_reactor_class_name: context.reactor_class.name,
126
- index: nil # Inline map execution doesn't track index in metadata currently, but could
126
+ index: index
127
127
  }
128
128
 
129
129
  # Store reference in composed_contexts so the UI knows where to find elements
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RubyReactor
4
- VERSION = "0.8.1"
4
+ VERSION = "0.8.2"
5
5
  end
@@ -0,0 +1,158 @@
1
+ # Scoping the RSpec matchers to `type: :reactor`
2
+
3
+ ## Goal
4
+
5
+ `RubyReactor::RSpec.configure(config)` should add **nothing** to a host app's
6
+ example groups unless they opt in with `type: :reactor`. Helpers already work
7
+ that way. The 19 matchers do not — they are global the moment
8
+ `ruby_reactor/rspec` is required, and moving them behind the tag needs a
9
+ one-line-per-matcher change plus a migration.
10
+
11
+ ## Current state
12
+
13
+ [lib/ruby_reactor/rspec.rb:20-42](../lib/ruby_reactor/rspec.rb#L20-L42):
14
+
15
+ | Piece | Scoped to `type: :reactor`? |
16
+ | --- | --- |
17
+ | `Helpers` (`test_reactor`) | yes — `config.include …, REACTOR_METADATA` |
18
+ | `SidekiqHelpers` (`drain_async_jobs`, `pending_async_jobs`) | yes |
19
+ | `before(:each)` setup (fake mode, storage wipe, snooze reset) | yes |
20
+ | `Matchers` (19 matchers) | **no** |
21
+ | `StorageReset.install!`, `StepExecutor.prepend` | no — load-time monkeypatches, out of scope for this doc |
22
+
23
+ ## Why the `config.include` line does nothing today
24
+
25
+ [matchers.rb](../lib/ruby_reactor/rspec/matchers.rb) declares every matcher as:
26
+
27
+ ```ruby
28
+ module Matchers
29
+ ::RSpec::Matchers.define :be_success do
30
+ ...
31
+ end
32
+ end
33
+ ```
34
+
35
+ `define` is [`RSpec::Matchers::DSL#define`](https://github.com/rspec/rspec-expectations/blob/main/lib/rspec/matchers/dsl.rb),
36
+ and its entire body is a `define_method` **on the receiver**:
37
+
38
+ ```ruby
39
+ def define(name, &declarations)
40
+ warn_about_block_args(name, declarations)
41
+ define_method name do |*expected, &block_arg|
42
+ RSpec::Matchers::DSL::Matcher.new(name, declarations, self, *expected, &block_arg)
43
+ end
44
+ end
45
+ alias_method :matcher, :define
46
+ ```
47
+
48
+ The receiver is `::RSpec::Matchers`, which RSpec includes into *every* example
49
+ group. So the matcher methods land in the global spec namespace at require
50
+ time, and `RubyReactor::RSpec::Matchers` ends up an empty module — the
51
+ `config.include RubyReactor::RSpec::Matchers` line in
52
+ [rspec.rb:32](../lib/ruby_reactor/rspec.rb#L32) includes nothing. Adding
53
+ `REACTOR_METADATA` to that line scopes nothing either.
54
+
55
+ ## The change
56
+
57
+ Extend the DSL into **our** module, so `define_method` targets
58
+ `RubyReactor::RSpec::Matchers`:
59
+
60
+ ```ruby
61
+ module RubyReactor
62
+ module RSpec
63
+ module Matchers
64
+ extend ::RSpec::Matchers::DSL # <- add
65
+
66
+ matcher :be_success do # <- was ::RSpec::Matchers.define
67
+ ...
68
+ end
69
+ end
70
+ end
71
+ end
72
+ ```
73
+
74
+ Then gate the include:
75
+
76
+ ```ruby
77
+ config.include RubyReactor::RSpec::Matchers, REACTOR_METADATA
78
+ ```
79
+
80
+ Mechanically: one `extend` line, 19 `::RSpec::Matchers.define` →
81
+ `matcher` rewrites (`sed` handles it), one changed line in `rspec.rb`, and the
82
+ comment at [rspec.rb:29-30](../lib/ruby_reactor/rspec.rb#L29-L30) — which
83
+ currently explains why matchers *can't* be scoped — deleted.
84
+
85
+ Nothing inside the matcher bodies changes. `Matchers.coordination_adapter`
86
+ ([matchers.rb:260](../lib/ruby_reactor/rspec/matchers.rb#L260)) is called with
87
+ an explicit receiver and resolves lexically at match time, so it is unaffected.
88
+
89
+ ### Verified
90
+
91
+ A probe against this repo's `spec_helper`, with one matcher declared each way:
92
+
93
+ ```
94
+ UNTAGGED scoped: expected :nope to respond to `probe_scoped?` # not included -> falls through
95
+ UNTAGGED global: GLOBAL-MATCHER-RAN: expected :ok, got :nope # still global
96
+ TAGGED scoped: SCOPED-MATCHER-RAN: expected :ok, got :nope # real matcher, chains and all
97
+ ```
98
+
99
+ Note the first line: scoping a matcher out does **not** produce a
100
+ `NoMethodError`. See below.
101
+
102
+ ## The trap: `be_*` / `have_*` fall through to a predicate matcher
103
+
104
+ All 19 matcher names start with `be_` or `have_`, which is exactly the prefix
105
+ RSpec's dynamic predicate matchers claim. Once a matcher is no longer in scope,
106
+ `expect(x).to be_success` stops meaning "RubyReactor's `be_success`" and starts
107
+ meaning "call `x.success?`". Two consequences:
108
+
109
+ 1. **`respond_to?(:be_success)` is not a scoping test.** It returns `true` in
110
+ any example group for any `be_*`/`have_*` name, because
111
+ `RSpec::Matchers#respond_to_missing?` claims the whole prefix. Test scoping
112
+ by running an expectation and reading the failure message (as the probe
113
+ above does), not by probing `respond_to?`.
114
+ 2. **Some call sites degrade silently rather than breaking.** `TestSubject` and
115
+ the result objects define `success?`, `failure?`, `paused?`, `halted?` and
116
+ `skipped?` ([test_subject.rb:388-405](../lib/ruby_reactor/rspec/test_subject.rb#L388-L405),
117
+ [ruby_reactor.rb:50-62](../lib/ruby_reactor.rb#L50-L62)), so an untagged
118
+ `expect(result).to be_success` keeps passing via the predicate — with a
119
+ generic failure message instead of the rich one in
120
+ [matchers.rb:14-23](../lib/ruby_reactor/rspec/matchers.rb#L14-L23), and
121
+ without the `ensure_executed!` nudge the real matcher does. Anything with a
122
+ chain (`have_run_step(:x).returning(y)`, `be_halted.because(:period)`,
123
+ `be_locked.by(owner)`) or a non-predicate name fails loudly instead.
124
+
125
+ So the migration cost is not "find the red specs" — a suite can go green while
126
+ quietly asserting something weaker. Grep for the matcher names rather than
127
+ trusting a passing run.
128
+
129
+ ## Migration
130
+
131
+ In this repo, 34 spec files use these matchers; 24 of them are not tagged
132
+ `type: :reactor`. Most only need the tag added to the `RSpec.describe` line, the
133
+ same edit made for `test_reactor` in `0a3ebdab`. Order of work:
134
+
135
+ 1. Rewrite `matchers.rb` to the DSL-extend form, leave the include ungated —
136
+ suite must stay green (proves the rewrite alone changes nothing).
137
+ 2. Gate the include, run the suite, tag the files that fail.
138
+ 3. Grep the 24 untagged files for matcher names and tag the rest by hand —
139
+ step 2 will not catch the silent-degradation cases above.
140
+ 4. Update [documentation/testing.md](../documentation/testing.md#L17-L35),
141
+ whose Setup section currently says matchers are available everywhere, and
142
+ the comment at [rspec.rb:29-30](../lib/ruby_reactor/rspec.rb#L29-L30).
143
+
144
+ ## Breaking change
145
+
146
+ Downstream suites calling these matchers in untagged groups are affected, with
147
+ the same silent-degradation caveat: a user's suite may stay green while
148
+ asserting less. This warrants a `BREAKING CHANGE:` footer and a release note
149
+ that names the fallback behavior explicitly, not just "matchers are now
150
+ scoped".
151
+
152
+ ## Alternative considered
153
+
154
+ Keep matchers global, on the grounds that `be_success` / `have_run_step` are
155
+ already ambiguous with RSpec's predicate matchers and that a host app gains
156
+ little from gating them. This costs nothing and breaks nobody — worth taking if
157
+ the namespace complaint is theoretical rather than something a host app has
158
+ actually hit.
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby_reactor
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.1
4
+ version: 0.8.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Artur
@@ -289,6 +289,7 @@ files:
289
289
  - specs/deferred-003-step-lock-declarations/spec.md
290
290
  - specs/deferred-003-step-lock-declarations/tasks.md
291
291
  - specs/possible_feature.md
292
+ - specs/scoped_rspec_matchers.md
292
293
  - teley/Dockerfile
293
294
  homepage: https://github.com/arturictus/ruby_reactor
294
295
  licenses: []