operandi 5.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 (107) hide show
  1. checksums.yaml +7 -0
  2. data/.cursor/rules/services/RULE.md +269 -0
  3. data/.cursor/rules/services-rspec/RULE.md +354 -0
  4. data/.github/dependabot.yml +11 -0
  5. data/.github/workflows/ci.yml +77 -0
  6. data/.gitignore +25 -0
  7. data/.rspec +3 -0
  8. data/.rubocop.yml +134 -0
  9. data/.ruby-version +1 -0
  10. data/.vscode/cspell.json +18 -0
  11. data/.vscode/project-words.txt +12 -0
  12. data/AGENTS.md +139 -0
  13. data/CHANGELOG.md +111 -0
  14. data/CLAUDE.md +139 -0
  15. data/CODE_OF_CONDUCT.md +74 -0
  16. data/Gemfile +28 -0
  17. data/Gemfile.lock +149 -0
  18. data/LICENSE.txt +21 -0
  19. data/README.md +172 -0
  20. data/Rakefile +8 -0
  21. data/bin/console +15 -0
  22. data/bin/setup +8 -0
  23. data/config/default.yml +57 -0
  24. data/docs/README.md +105 -0
  25. data/docs/SUMMARY.md +31 -0
  26. data/docs/arguments.md +275 -0
  27. data/docs/best-practices.md +153 -0
  28. data/docs/callbacks.md +475 -0
  29. data/docs/concepts.md +79 -0
  30. data/docs/configuration.md +218 -0
  31. data/docs/context.md +128 -0
  32. data/docs/crud.md +525 -0
  33. data/docs/errors.md +331 -0
  34. data/docs/generators.md +250 -0
  35. data/docs/outputs.md +150 -0
  36. data/docs/pundit-authorization.md +320 -0
  37. data/docs/quickstart.md +133 -0
  38. data/docs/recipes.md +14 -0
  39. data/docs/rubocop.md +430 -0
  40. data/docs/ruby-lsp.md +121 -0
  41. data/docs/service-rendering.md +222 -0
  42. data/docs/sorbet-runtime.md +283 -0
  43. data/docs/steps.md +438 -0
  44. data/docs/tapioca.md +188 -0
  45. data/docs/testing.md +548 -0
  46. data/lib/generators/operandi/install/USAGE +15 -0
  47. data/lib/generators/operandi/install/install_generator.rb +45 -0
  48. data/lib/generators/operandi/install/templates/application_service.rb.tt +8 -0
  49. data/lib/generators/operandi/install/templates/application_service_spec.rb.tt +7 -0
  50. data/lib/generators/operandi/install/templates/initializer.rb.tt +30 -0
  51. data/lib/generators/operandi/service/USAGE +21 -0
  52. data/lib/generators/operandi/service/service_generator.rb +80 -0
  53. data/lib/generators/operandi/service/templates/service.rb.tt +48 -0
  54. data/lib/generators/operandi/service/templates/service_spec.rb.tt +40 -0
  55. data/lib/operandi/base.rb +230 -0
  56. data/lib/operandi/base_with_context.rb +57 -0
  57. data/lib/operandi/callbacks.rb +353 -0
  58. data/lib/operandi/collection.rb +166 -0
  59. data/lib/operandi/concerns/execution.rb +80 -0
  60. data/lib/operandi/concerns/parent_service.rb +32 -0
  61. data/lib/operandi/concerns/state_management.rb +34 -0
  62. data/lib/operandi/config.rb +142 -0
  63. data/lib/operandi/constants.rb +96 -0
  64. data/lib/operandi/dsl/arguments_dsl.rb +83 -0
  65. data/lib/operandi/dsl/outputs_dsl.rb +79 -0
  66. data/lib/operandi/dsl/steps_dsl.rb +206 -0
  67. data/lib/operandi/dsl/validation.rb +171 -0
  68. data/lib/operandi/exceptions.rb +66 -0
  69. data/lib/operandi/message.rb +52 -0
  70. data/lib/operandi/messages.rb +185 -0
  71. data/lib/operandi/rspec/matchers/define_argument.rb +172 -0
  72. data/lib/operandi/rspec/matchers/define_output.rb +145 -0
  73. data/lib/operandi/rspec/matchers/define_step.rb +223 -0
  74. data/lib/operandi/rspec/matchers/execute_step.rb +228 -0
  75. data/lib/operandi/rspec/matchers/have_error_on.rb +144 -0
  76. data/lib/operandi/rspec/matchers/have_warning_on.rb +146 -0
  77. data/lib/operandi/rspec/matchers/trigger_callback.rb +136 -0
  78. data/lib/operandi/rspec.rb +15 -0
  79. data/lib/operandi/rubocop/cop/operandi/argument_type_required.rb +52 -0
  80. data/lib/operandi/rubocop/cop/operandi/condition_method_exists.rb +173 -0
  81. data/lib/operandi/rubocop/cop/operandi/deprecated_accessors.rb +113 -0
  82. data/lib/operandi/rubocop/cop/operandi/deprecated_methods.rb +113 -0
  83. data/lib/operandi/rubocop/cop/operandi/dsl_order.rb +181 -0
  84. data/lib/operandi/rubocop/cop/operandi/missing_private_keyword.rb +102 -0
  85. data/lib/operandi/rubocop/cop/operandi/no_direct_instantiation.rb +66 -0
  86. data/lib/operandi/rubocop/cop/operandi/no_hash_argument.rb +101 -0
  87. data/lib/operandi/rubocop/cop/operandi/output_type_required.rb +52 -0
  88. data/lib/operandi/rubocop/cop/operandi/prefer_fail_method.rb +112 -0
  89. data/lib/operandi/rubocop/cop/operandi/prefer_optional_over_default_nil.rb +124 -0
  90. data/lib/operandi/rubocop/cop/operandi/redundant_optional.rb +103 -0
  91. data/lib/operandi/rubocop/cop/operandi/reserved_name.rb +56 -0
  92. data/lib/operandi/rubocop/cop/operandi/step_method_exists.rb +134 -0
  93. data/lib/operandi/rubocop.rb +22 -0
  94. data/lib/operandi/settings/field.rb +147 -0
  95. data/lib/operandi/settings/step.rb +105 -0
  96. data/lib/operandi/utils.rb +36 -0
  97. data/lib/operandi/version.rb +5 -0
  98. data/lib/operandi.rb +11 -0
  99. data/lib/ruby_lsp/operandi/addon.rb +37 -0
  100. data/lib/ruby_lsp/operandi/definition.rb +134 -0
  101. data/lib/ruby_lsp/operandi/indexing_enhancement.rb +224 -0
  102. data/lib/tapioca/dsl/compilers/operandi.rb +378 -0
  103. data/operandi.gemspec +33 -0
  104. data/rbi/operandi.rbi +197 -0
  105. data/sorbet/cache/data.mdb +0 -0
  106. data/sorbet/cache/lock.mdb +0 -0
  107. metadata +151 -0
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Operandi
4
+ module RSpec
5
+ module Matchers
6
+ # Matcher for testing output definitions on a service class
7
+ #
8
+ # @example Basic usage
9
+ # expect(MyService).to define_output(:result)
10
+ #
11
+ # @example With type constraint
12
+ # expect(MyService).to define_output(:product).with_type(Product)
13
+ #
14
+ # @example With optional flag
15
+ # expect(MyService).to define_output(:message).optional
16
+ #
17
+ # @example With default value
18
+ # expect(MyService).to define_output(:count).with_default(0)
19
+ #
20
+ # @example Combined
21
+ # expect(MyService).to define_output(:data).with_type(Hash).optional.with_default({})
22
+ def define_output(name)
23
+ DefineOutputMatcher.new(name)
24
+ end
25
+
26
+ class DefineOutputMatcher
27
+ def initialize(name)
28
+ @name = name
29
+ @expected_type = nil
30
+ @expected_optional = nil
31
+ @expected_default = nil
32
+ @check_default = false
33
+ end
34
+
35
+ def with_type(type)
36
+ @expected_type = type
37
+ self
38
+ end
39
+
40
+ def optional(value = true)
41
+ @expected_optional = value
42
+ self
43
+ end
44
+
45
+ def required
46
+ @expected_optional = false
47
+ self
48
+ end
49
+
50
+ def with_default(default)
51
+ @check_default = true
52
+ @expected_default = default
53
+ self
54
+ end
55
+
56
+ def matches?(service_class)
57
+ @service_class = service_class
58
+ @actual_class = service_class.is_a?(Class) ? service_class : service_class.class
59
+
60
+ return false unless output_defined?
61
+ return false unless type_matches?
62
+ return false unless optional_matches?
63
+ return false unless default_matches?
64
+
65
+ true
66
+ end
67
+
68
+ def failure_message
69
+ return "expected #{@actual_class} to define output :#{@name}" unless output_defined?
70
+ return type_failure_message unless type_matches?
71
+ return optional_failure_message unless optional_matches?
72
+ return default_failure_message unless default_matches?
73
+
74
+ ""
75
+ end
76
+
77
+ def failure_message_when_negated
78
+ "expected #{@actual_class} not to define output :#{@name}"
79
+ end
80
+
81
+ def description
82
+ desc = "define output :#{@name}"
83
+ desc += " with type #{@expected_type}" if @expected_type
84
+ desc += " as optional" if @expected_optional == true
85
+ desc += " as required" if @expected_optional == false
86
+ desc += " with default #{@expected_default.inspect}" if @check_default
87
+ desc
88
+ end
89
+
90
+ private
91
+
92
+ def output_defined?
93
+ @actual_class.respond_to?(:outputs) && @actual_class.outputs.key?(@name)
94
+ end
95
+
96
+ def output
97
+ @output ||= @actual_class.outputs[@name]
98
+ end
99
+
100
+ def type_matches?
101
+ return true if @expected_type.nil?
102
+
103
+ actual_type = output.instance_variable_get(:@type)
104
+ actual_type == @expected_type
105
+ end
106
+
107
+ def optional_matches?
108
+ return true if @expected_optional.nil?
109
+
110
+ output.optional == @expected_optional
111
+ end
112
+
113
+ def default_matches?
114
+ return true unless @check_default
115
+
116
+ output.default_exists && output.default == @expected_default
117
+ end
118
+
119
+ def type_failure_message
120
+ actual_type = output.instance_variable_get(:@type)
121
+ "expected #{@actual_class} output :#{@name} to have type #{@expected_type}, " \
122
+ "but it has type #{actual_type.inspect}"
123
+ end
124
+
125
+ def optional_failure_message
126
+ if @expected_optional
127
+ "expected #{@actual_class} output :#{@name} to be optional, but it is required"
128
+ else
129
+ "expected #{@actual_class} output :#{@name} to be required, but it is optional"
130
+ end
131
+ end
132
+
133
+ def default_failure_message
134
+ if output.default_exists
135
+ "expected #{@actual_class} output :#{@name} to have default #{@expected_default.inspect}, " \
136
+ "but it has default #{output.default.inspect}"
137
+ else
138
+ "expected #{@actual_class} output :#{@name} to have default #{@expected_default.inspect}, " \
139
+ "but no default is defined"
140
+ end
141
+ end
142
+ end
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,223 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Operandi
4
+ module RSpec
5
+ module Matchers
6
+ # Matcher for testing step definitions on a service class
7
+ #
8
+ # @example Basic usage
9
+ # expect(MyService).to define_step(:validate)
10
+ #
11
+ # @example With always flag
12
+ # expect(MyService).to define_step(:cleanup).with_always(true)
13
+ #
14
+ # @example With if condition
15
+ # expect(MyService).to define_step(:notify).with_if(:should_notify?)
16
+ #
17
+ # @example With unless condition
18
+ # expect(MyService).to define_step(:skip_validation).with_unless(:production?)
19
+ #
20
+ # @example Check multiple steps
21
+ # expect(MyService).to define_steps(:validate, :process, :save)
22
+ #
23
+ # @example Check step order
24
+ # expect(MyService).to define_steps_in_order(:validate, :process, :save)
25
+ def define_step(name)
26
+ DefineStepMatcher.new(name)
27
+ end
28
+
29
+ def define_steps(*names)
30
+ DefineStepsMatcher.new(names, ordered: false)
31
+ end
32
+
33
+ def define_steps_in_order(*names)
34
+ DefineStepsMatcher.new(names, ordered: true)
35
+ end
36
+
37
+ class DefineStepMatcher
38
+ def initialize(name)
39
+ @name = name
40
+ @expected_always = nil
41
+ @expected_if = nil
42
+ @expected_unless = nil
43
+ end
44
+
45
+ def with_always(value = true)
46
+ @expected_always = value
47
+ self
48
+ end
49
+
50
+ def with_if(condition)
51
+ @expected_if = condition
52
+ self
53
+ end
54
+
55
+ def with_unless(condition)
56
+ @expected_unless = condition
57
+ self
58
+ end
59
+
60
+ def matches?(service_class)
61
+ @service_class = service_class
62
+ @actual_class = service_class.is_a?(Class) ? service_class : service_class.class
63
+
64
+ return false unless step_defined?
65
+ return false unless always_matches?
66
+ return false unless if_matches?
67
+ return false unless unless_matches?
68
+
69
+ true
70
+ end
71
+
72
+ def failure_message
73
+ return "expected #{@actual_class} to define step :#{@name}" unless step_defined?
74
+ return always_failure_message unless always_matches?
75
+ return if_failure_message unless if_matches?
76
+ return unless_failure_message unless unless_matches?
77
+
78
+ ""
79
+ end
80
+
81
+ def failure_message_when_negated
82
+ "expected #{@actual_class} not to define step :#{@name}"
83
+ end
84
+
85
+ def description
86
+ desc = "define step :#{@name}"
87
+ desc += " with always: #{@expected_always}" unless @expected_always.nil?
88
+ desc += " with if: #{@expected_if.inspect}" if @expected_if
89
+ desc += " with unless: #{@expected_unless.inspect}" if @expected_unless
90
+ desc
91
+ end
92
+
93
+ private
94
+
95
+ def step_defined?
96
+ @actual_class.respond_to?(:steps) && @actual_class.steps.key?(@name)
97
+ end
98
+
99
+ def step
100
+ @step ||= @actual_class.steps[@name]
101
+ end
102
+
103
+ def always_matches?
104
+ return true if @expected_always.nil?
105
+
106
+ step.always == @expected_always
107
+ end
108
+
109
+ def if_matches?
110
+ return true if @expected_if.nil?
111
+
112
+ actual_if = step.instance_variable_get(:@if)
113
+ actual_if == @expected_if
114
+ end
115
+
116
+ def unless_matches?
117
+ return true if @expected_unless.nil?
118
+
119
+ actual_unless = step.instance_variable_get(:@unless)
120
+ actual_unless == @expected_unless
121
+ end
122
+
123
+ def always_failure_message
124
+ "expected #{@actual_class} step :#{@name} to have always: #{@expected_always}, " \
125
+ "but it has always: #{step.always.inspect}"
126
+ end
127
+
128
+ def if_failure_message
129
+ actual_if = step.instance_variable_get(:@if)
130
+ "expected #{@actual_class} step :#{@name} to have if: #{@expected_if.inspect}, " \
131
+ "but it has if: #{actual_if.inspect}"
132
+ end
133
+
134
+ def unless_failure_message
135
+ actual_unless = step.instance_variable_get(:@unless)
136
+ "expected #{@actual_class} step :#{@name} to have unless: #{@expected_unless.inspect}, " \
137
+ "but it has unless: #{actual_unless.inspect}"
138
+ end
139
+ end
140
+
141
+ class DefineStepsMatcher
142
+ def initialize(names, ordered:)
143
+ @names = names
144
+ @ordered = ordered
145
+ end
146
+
147
+ def matches?(service_class)
148
+ @service_class = service_class
149
+ @actual_class = service_class.is_a?(Class) ? service_class : service_class.class
150
+ @missing_steps = []
151
+ @actual_order = []
152
+
153
+ return false unless all_steps_defined?
154
+ return false unless order_matches?
155
+
156
+ true
157
+ end
158
+
159
+ def failure_message
160
+ return missing_steps_failure_message unless all_steps_defined?
161
+ return order_failure_message unless order_matches?
162
+
163
+ ""
164
+ end
165
+
166
+ def failure_message_when_negated
167
+ if @ordered
168
+ "expected #{@actual_class} not to define steps #{@names.inspect} in that order"
169
+ else
170
+ "expected #{@actual_class} not to define steps #{@names.inspect}"
171
+ end
172
+ end
173
+
174
+ def description
175
+ if @ordered
176
+ "define steps #{@names.inspect} in order"
177
+ else
178
+ "define steps #{@names.inspect}"
179
+ end
180
+ end
181
+
182
+ private
183
+
184
+ def all_steps_defined?
185
+ return false unless @actual_class.respond_to?(:steps)
186
+
187
+ actual_step_names = @actual_class.steps.keys
188
+ @missing_steps = @names - actual_step_names
189
+ @missing_steps.empty?
190
+ end
191
+
192
+ def order_matches?
193
+ return true unless @ordered
194
+
195
+ actual_step_names = @actual_class.steps.keys
196
+ @actual_order = @names.select { |name| actual_step_names.include?(name) }
197
+
198
+ # Check if the expected steps appear in the same order in actual steps
199
+ last_index = -1
200
+ @names.all? do |name|
201
+ current_index = actual_step_names.index(name)
202
+ return false unless current_index
203
+ return false unless current_index > last_index
204
+
205
+ last_index = current_index
206
+ true
207
+ end
208
+ end
209
+
210
+ def missing_steps_failure_message
211
+ "expected #{@actual_class} to define steps #{@names.inspect}, " \
212
+ "but missing: #{@missing_steps.inspect}"
213
+ end
214
+
215
+ def order_failure_message
216
+ actual_step_names = @actual_class.steps.keys
217
+ "expected #{@actual_class} to define steps #{@names.inspect} in that order, " \
218
+ "but actual order is: #{actual_step_names.inspect}"
219
+ end
220
+ end
221
+ end
222
+ end
223
+ end
@@ -0,0 +1,228 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Operandi
4
+ module RSpec
5
+ module Matchers
6
+ # Matcher for testing step execution on a service instance
7
+ # NOTE: This matcher requires the service to track executed steps.
8
+ # Add a callback in your service to track execution:
9
+ #
10
+ # after_step_run do |service, step_name|
11
+ # service.executed_steps << step_name
12
+ # end
13
+ #
14
+ # @example Basic usage (requires executed_steps tracking)
15
+ # expect(service).to execute_step(:validate)
16
+ #
17
+ # @example Check step was skipped
18
+ # expect(service).to skip_step(:notify)
19
+ #
20
+ # @example Check multiple steps executed
21
+ # expect(service).to execute_steps(:validate, :process, :save)
22
+ #
23
+ # @example Check execution order
24
+ # expect(service).to execute_steps_in_order(:validate, :process, :save)
25
+ def execute_step(name)
26
+ ExecuteStepMatcher.new(name)
27
+ end
28
+
29
+ def skip_step(name)
30
+ SkipStepMatcher.new(name)
31
+ end
32
+
33
+ def execute_steps(*names)
34
+ ExecuteStepsMatcher.new(names, ordered: false)
35
+ end
36
+
37
+ def execute_steps_in_order(*names)
38
+ ExecuteStepsMatcher.new(names, ordered: true)
39
+ end
40
+
41
+ class ExecuteStepMatcher
42
+ def initialize(name)
43
+ @name = name
44
+ end
45
+
46
+ def matches?(service)
47
+ @service = service
48
+
49
+ return false unless service_tracks_steps?
50
+ return false unless step_executed?
51
+
52
+ true
53
+ end
54
+
55
+ def failure_message
56
+ return tracking_not_available_message unless service_tracks_steps?
57
+
58
+ "expected service to execute step :#{@name}, " \
59
+ "but executed steps were: #{executed_steps.inspect}"
60
+ end
61
+
62
+ def failure_message_when_negated
63
+ "expected service not to execute step :#{@name}"
64
+ end
65
+
66
+ def description
67
+ "execute step :#{@name}"
68
+ end
69
+
70
+ private
71
+
72
+ def service_tracks_steps?
73
+ @service.respond_to?(:executed_steps)
74
+ end
75
+
76
+ def executed_steps
77
+ @service.executed_steps
78
+ end
79
+
80
+ def step_executed?
81
+ executed_steps.include?(@name)
82
+ end
83
+
84
+ def tracking_not_available_message
85
+ "cannot verify step execution because service does not track executed steps. " \
86
+ "Add `after_step_run { |service, step| service.executed_steps << step }` to your service."
87
+ end
88
+ end
89
+
90
+ class SkipStepMatcher
91
+ def initialize(name)
92
+ @name = name
93
+ end
94
+
95
+ def matches?(service)
96
+ @service = service
97
+
98
+ return false unless service_tracks_steps?
99
+ return false unless step_skipped?
100
+
101
+ true
102
+ end
103
+
104
+ def failure_message
105
+ return tracking_not_available_message unless service_tracks_steps?
106
+
107
+ "expected service to skip step :#{@name}, but it was executed. " \
108
+ "Executed steps: #{executed_steps.inspect}"
109
+ end
110
+
111
+ def failure_message_when_negated
112
+ "expected service not to skip step :#{@name} (expected it to execute)"
113
+ end
114
+
115
+ def description
116
+ "skip step :#{@name}"
117
+ end
118
+
119
+ private
120
+
121
+ def service_tracks_steps?
122
+ @service.respond_to?(:executed_steps)
123
+ end
124
+
125
+ def executed_steps
126
+ @service.executed_steps
127
+ end
128
+
129
+ def step_skipped?
130
+ !executed_steps.include?(@name)
131
+ end
132
+
133
+ def tracking_not_available_message
134
+ "cannot verify step execution because service does not track executed steps. " \
135
+ "Add `after_step_run { |service, step| service.executed_steps << step }` to your service."
136
+ end
137
+ end
138
+
139
+ class ExecuteStepsMatcher
140
+ def initialize(names, ordered:)
141
+ @names = names
142
+ @ordered = ordered
143
+ end
144
+
145
+ def matches?(service)
146
+ @service = service
147
+ @missing_steps = []
148
+
149
+ return false unless service_tracks_steps?
150
+ return false unless all_steps_executed?
151
+ return false unless order_matches?
152
+
153
+ true
154
+ end
155
+
156
+ def failure_message
157
+ return tracking_not_available_message unless service_tracks_steps?
158
+ return missing_steps_failure_message unless all_steps_executed?
159
+ return order_failure_message unless order_matches?
160
+
161
+ ""
162
+ end
163
+
164
+ def failure_message_when_negated
165
+ if @ordered
166
+ "expected service not to execute steps #{@names.inspect} in that order"
167
+ else
168
+ "expected service not to execute steps #{@names.inspect}"
169
+ end
170
+ end
171
+
172
+ def description
173
+ if @ordered
174
+ "execute steps #{@names.inspect} in order"
175
+ else
176
+ "execute steps #{@names.inspect}"
177
+ end
178
+ end
179
+
180
+ private
181
+
182
+ def service_tracks_steps?
183
+ @service.respond_to?(:executed_steps)
184
+ end
185
+
186
+ def executed_steps
187
+ @service.executed_steps
188
+ end
189
+
190
+ def all_steps_executed?
191
+ @missing_steps = @names.reject { |name| executed_steps.include?(name) }
192
+ @missing_steps.empty?
193
+ end
194
+
195
+ def order_matches?
196
+ return true unless @ordered
197
+
198
+ # Check if the expected steps appear in the same order in executed steps
199
+ last_index = -1
200
+ @names.all? do |name|
201
+ current_index = executed_steps.index(name)
202
+ return false unless current_index
203
+ return false unless current_index > last_index
204
+
205
+ last_index = current_index
206
+ true
207
+ end
208
+ end
209
+
210
+ def missing_steps_failure_message
211
+ "expected service to execute steps #{@names.inspect}, " \
212
+ "but missing: #{@missing_steps.inspect}. " \
213
+ "Executed steps: #{executed_steps.inspect}"
214
+ end
215
+
216
+ def order_failure_message
217
+ "expected service to execute steps #{@names.inspect} in that order, " \
218
+ "but actual execution order was: #{executed_steps.inspect}"
219
+ end
220
+
221
+ def tracking_not_available_message
222
+ "cannot verify step execution because service does not track executed steps. " \
223
+ "Add `after_step_run { |service, step| service.executed_steps << step }` to your service."
224
+ end
225
+ end
226
+ end
227
+ end
228
+ end