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.
- checksums.yaml +7 -0
- data/.cursor/rules/services/RULE.md +269 -0
- data/.cursor/rules/services-rspec/RULE.md +354 -0
- data/.github/dependabot.yml +11 -0
- data/.github/workflows/ci.yml +77 -0
- data/.gitignore +25 -0
- data/.rspec +3 -0
- data/.rubocop.yml +134 -0
- data/.ruby-version +1 -0
- data/.vscode/cspell.json +18 -0
- data/.vscode/project-words.txt +12 -0
- data/AGENTS.md +139 -0
- data/CHANGELOG.md +111 -0
- data/CLAUDE.md +139 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/Gemfile +28 -0
- data/Gemfile.lock +149 -0
- data/LICENSE.txt +21 -0
- data/README.md +172 -0
- data/Rakefile +8 -0
- data/bin/console +15 -0
- data/bin/setup +8 -0
- data/config/default.yml +57 -0
- data/docs/README.md +105 -0
- data/docs/SUMMARY.md +31 -0
- data/docs/arguments.md +275 -0
- data/docs/best-practices.md +153 -0
- data/docs/callbacks.md +475 -0
- data/docs/concepts.md +79 -0
- data/docs/configuration.md +218 -0
- data/docs/context.md +128 -0
- data/docs/crud.md +525 -0
- data/docs/errors.md +331 -0
- data/docs/generators.md +250 -0
- data/docs/outputs.md +150 -0
- data/docs/pundit-authorization.md +320 -0
- data/docs/quickstart.md +133 -0
- data/docs/recipes.md +14 -0
- data/docs/rubocop.md +430 -0
- data/docs/ruby-lsp.md +121 -0
- data/docs/service-rendering.md +222 -0
- data/docs/sorbet-runtime.md +283 -0
- data/docs/steps.md +438 -0
- data/docs/tapioca.md +188 -0
- data/docs/testing.md +548 -0
- data/lib/generators/operandi/install/USAGE +15 -0
- data/lib/generators/operandi/install/install_generator.rb +45 -0
- data/lib/generators/operandi/install/templates/application_service.rb.tt +8 -0
- data/lib/generators/operandi/install/templates/application_service_spec.rb.tt +7 -0
- data/lib/generators/operandi/install/templates/initializer.rb.tt +30 -0
- data/lib/generators/operandi/service/USAGE +21 -0
- data/lib/generators/operandi/service/service_generator.rb +80 -0
- data/lib/generators/operandi/service/templates/service.rb.tt +48 -0
- data/lib/generators/operandi/service/templates/service_spec.rb.tt +40 -0
- data/lib/operandi/base.rb +230 -0
- data/lib/operandi/base_with_context.rb +57 -0
- data/lib/operandi/callbacks.rb +353 -0
- data/lib/operandi/collection.rb +166 -0
- data/lib/operandi/concerns/execution.rb +80 -0
- data/lib/operandi/concerns/parent_service.rb +32 -0
- data/lib/operandi/concerns/state_management.rb +34 -0
- data/lib/operandi/config.rb +142 -0
- data/lib/operandi/constants.rb +96 -0
- data/lib/operandi/dsl/arguments_dsl.rb +83 -0
- data/lib/operandi/dsl/outputs_dsl.rb +79 -0
- data/lib/operandi/dsl/steps_dsl.rb +206 -0
- data/lib/operandi/dsl/validation.rb +171 -0
- data/lib/operandi/exceptions.rb +66 -0
- data/lib/operandi/message.rb +52 -0
- data/lib/operandi/messages.rb +185 -0
- data/lib/operandi/rspec/matchers/define_argument.rb +172 -0
- data/lib/operandi/rspec/matchers/define_output.rb +145 -0
- data/lib/operandi/rspec/matchers/define_step.rb +223 -0
- data/lib/operandi/rspec/matchers/execute_step.rb +228 -0
- data/lib/operandi/rspec/matchers/have_error_on.rb +144 -0
- data/lib/operandi/rspec/matchers/have_warning_on.rb +146 -0
- data/lib/operandi/rspec/matchers/trigger_callback.rb +136 -0
- data/lib/operandi/rspec.rb +15 -0
- data/lib/operandi/rubocop/cop/operandi/argument_type_required.rb +52 -0
- data/lib/operandi/rubocop/cop/operandi/condition_method_exists.rb +173 -0
- data/lib/operandi/rubocop/cop/operandi/deprecated_accessors.rb +113 -0
- data/lib/operandi/rubocop/cop/operandi/deprecated_methods.rb +113 -0
- data/lib/operandi/rubocop/cop/operandi/dsl_order.rb +181 -0
- data/lib/operandi/rubocop/cop/operandi/missing_private_keyword.rb +102 -0
- data/lib/operandi/rubocop/cop/operandi/no_direct_instantiation.rb +66 -0
- data/lib/operandi/rubocop/cop/operandi/no_hash_argument.rb +101 -0
- data/lib/operandi/rubocop/cop/operandi/output_type_required.rb +52 -0
- data/lib/operandi/rubocop/cop/operandi/prefer_fail_method.rb +112 -0
- data/lib/operandi/rubocop/cop/operandi/prefer_optional_over_default_nil.rb +124 -0
- data/lib/operandi/rubocop/cop/operandi/redundant_optional.rb +103 -0
- data/lib/operandi/rubocop/cop/operandi/reserved_name.rb +56 -0
- data/lib/operandi/rubocop/cop/operandi/step_method_exists.rb +134 -0
- data/lib/operandi/rubocop.rb +22 -0
- data/lib/operandi/settings/field.rb +147 -0
- data/lib/operandi/settings/step.rb +105 -0
- data/lib/operandi/utils.rb +36 -0
- data/lib/operandi/version.rb +5 -0
- data/lib/operandi.rb +11 -0
- data/lib/ruby_lsp/operandi/addon.rb +37 -0
- data/lib/ruby_lsp/operandi/definition.rb +134 -0
- data/lib/ruby_lsp/operandi/indexing_enhancement.rb +224 -0
- data/lib/tapioca/dsl/compilers/operandi.rb +378 -0
- data/operandi.gemspec +33 -0
- data/rbi/operandi.rbi +197 -0
- data/sorbet/cache/data.mdb +0 -0
- data/sorbet/cache/lock.mdb +0 -0
- metadata +151 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Operandi
|
|
4
|
+
module RSpec
|
|
5
|
+
module Matchers
|
|
6
|
+
# Matcher for testing errors on a service instance
|
|
7
|
+
#
|
|
8
|
+
# @example Basic usage
|
|
9
|
+
# expect(service).to have_error_on(:name)
|
|
10
|
+
#
|
|
11
|
+
# @example With specific message
|
|
12
|
+
# expect(service).to have_error_on(:name).with_message("can't be blank")
|
|
13
|
+
#
|
|
14
|
+
# @example With message matching regex
|
|
15
|
+
# expect(service).to have_error_on(:base).with_message(/invalid/)
|
|
16
|
+
#
|
|
17
|
+
# @example Check multiple error keys
|
|
18
|
+
# expect(service).to have_errors_on(:name, :email)
|
|
19
|
+
def have_error_on(key)
|
|
20
|
+
HaveErrorOnMatcher.new(key)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def have_errors_on(*keys)
|
|
24
|
+
HaveErrorsOnMatcher.new(keys)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
class HaveErrorOnMatcher
|
|
28
|
+
def initialize(key)
|
|
29
|
+
@key = key
|
|
30
|
+
@expected_message = nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def with_message(message)
|
|
34
|
+
@expected_message = message
|
|
35
|
+
self
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def matches?(service)
|
|
39
|
+
@service = service
|
|
40
|
+
|
|
41
|
+
return false unless has_error_key?
|
|
42
|
+
return false unless message_matches?
|
|
43
|
+
|
|
44
|
+
true
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def failure_message
|
|
48
|
+
return "expected service to have error on :#{@key}, but errors were: #{errors_summary}" unless has_error_key?
|
|
49
|
+
return message_failure_message unless message_matches?
|
|
50
|
+
|
|
51
|
+
""
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def failure_message_when_negated
|
|
55
|
+
if @expected_message
|
|
56
|
+
"expected service not to have error on :#{@key} with message #{@expected_message.inspect}"
|
|
57
|
+
else
|
|
58
|
+
"expected service not to have error on :#{@key}"
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def description
|
|
63
|
+
desc = "have error on :#{@key}"
|
|
64
|
+
desc += " with message #{@expected_message.inspect}" if @expected_message
|
|
65
|
+
desc
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def has_error_key?
|
|
71
|
+
@service.errors.key?(@key)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def message_matches?
|
|
75
|
+
return true if @expected_message.nil?
|
|
76
|
+
|
|
77
|
+
error_messages = @service.errors[@key].map(&:to_s)
|
|
78
|
+
|
|
79
|
+
case @expected_message
|
|
80
|
+
when Regexp
|
|
81
|
+
error_messages.any? { |msg| msg.match?(@expected_message) }
|
|
82
|
+
else
|
|
83
|
+
error_messages.include?(@expected_message.to_s)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def message_failure_message
|
|
88
|
+
actual_messages = @service.errors[@key].map(&:to_s)
|
|
89
|
+
"expected service error on :#{@key} to include message #{@expected_message.inspect}, " \
|
|
90
|
+
"but messages were: #{actual_messages.inspect}"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def errors_summary
|
|
94
|
+
if @service.errors.empty?
|
|
95
|
+
"empty"
|
|
96
|
+
else
|
|
97
|
+
@service.errors.to_h.inspect
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
class HaveErrorsOnMatcher
|
|
103
|
+
def initialize(keys)
|
|
104
|
+
@keys = keys
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def matches?(service)
|
|
108
|
+
@service = service
|
|
109
|
+
@missing_keys = []
|
|
110
|
+
|
|
111
|
+
@keys.each do |key|
|
|
112
|
+
@missing_keys << key unless @service.errors.key?(key)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
@missing_keys.empty?
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def failure_message
|
|
119
|
+
"expected service to have errors on #{@keys.inspect}, " \
|
|
120
|
+
"but missing errors on: #{@missing_keys.inspect}. " \
|
|
121
|
+
"Actual errors: #{errors_summary}"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def failure_message_when_negated
|
|
125
|
+
"expected service not to have errors on #{@keys.inspect}"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def description
|
|
129
|
+
"have errors on #{@keys.inspect}"
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
private
|
|
133
|
+
|
|
134
|
+
def errors_summary
|
|
135
|
+
if @service.errors.empty?
|
|
136
|
+
"empty"
|
|
137
|
+
else
|
|
138
|
+
@service.errors.to_h.inspect
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Operandi
|
|
4
|
+
module RSpec
|
|
5
|
+
module Matchers
|
|
6
|
+
# Matcher for testing warnings on a service instance
|
|
7
|
+
#
|
|
8
|
+
# @example Basic usage
|
|
9
|
+
# expect(service).to have_warning_on(:name)
|
|
10
|
+
#
|
|
11
|
+
# @example With specific message
|
|
12
|
+
# expect(service).to have_warning_on(:name).with_message("was replaced")
|
|
13
|
+
#
|
|
14
|
+
# @example With message matching regex
|
|
15
|
+
# expect(service).to have_warning_on(:base).with_message(/deprecated/)
|
|
16
|
+
#
|
|
17
|
+
# @example Check multiple warning keys
|
|
18
|
+
# expect(service).to have_warnings_on(:name, :email)
|
|
19
|
+
def have_warning_on(key)
|
|
20
|
+
HaveWarningOnMatcher.new(key)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def have_warnings_on(*keys)
|
|
24
|
+
HaveWarningsOnMatcher.new(keys)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
class HaveWarningOnMatcher
|
|
28
|
+
def initialize(key)
|
|
29
|
+
@key = key
|
|
30
|
+
@expected_message = nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def with_message(message)
|
|
34
|
+
@expected_message = message
|
|
35
|
+
self
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def matches?(service)
|
|
39
|
+
@service = service
|
|
40
|
+
|
|
41
|
+
return false unless has_warning_key?
|
|
42
|
+
return false unless message_matches?
|
|
43
|
+
|
|
44
|
+
true
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def failure_message
|
|
48
|
+
unless has_warning_key?
|
|
49
|
+
return "expected service to have warning on :#{@key}, but warnings were: #{warnings_summary}"
|
|
50
|
+
end
|
|
51
|
+
return message_failure_message unless message_matches?
|
|
52
|
+
|
|
53
|
+
""
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def failure_message_when_negated
|
|
57
|
+
if @expected_message
|
|
58
|
+
"expected service not to have warning on :#{@key} with message #{@expected_message.inspect}"
|
|
59
|
+
else
|
|
60
|
+
"expected service not to have warning on :#{@key}"
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def description
|
|
65
|
+
desc = "have warning on :#{@key}"
|
|
66
|
+
desc += " with message #{@expected_message.inspect}" if @expected_message
|
|
67
|
+
desc
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def has_warning_key?
|
|
73
|
+
@service.warnings.key?(@key)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def message_matches?
|
|
77
|
+
return true if @expected_message.nil?
|
|
78
|
+
|
|
79
|
+
warning_messages = @service.warnings[@key].map(&:to_s)
|
|
80
|
+
|
|
81
|
+
case @expected_message
|
|
82
|
+
when Regexp
|
|
83
|
+
warning_messages.any? { |msg| msg.match?(@expected_message) }
|
|
84
|
+
else
|
|
85
|
+
warning_messages.include?(@expected_message.to_s)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def message_failure_message
|
|
90
|
+
actual_messages = @service.warnings[@key].map(&:to_s)
|
|
91
|
+
"expected service warning on :#{@key} to include message #{@expected_message.inspect}, " \
|
|
92
|
+
"but messages were: #{actual_messages.inspect}"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def warnings_summary
|
|
96
|
+
if @service.warnings.empty?
|
|
97
|
+
"empty"
|
|
98
|
+
else
|
|
99
|
+
@service.warnings.to_h.inspect
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
class HaveWarningsOnMatcher
|
|
105
|
+
def initialize(keys)
|
|
106
|
+
@keys = keys
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def matches?(service)
|
|
110
|
+
@service = service
|
|
111
|
+
@missing_keys = []
|
|
112
|
+
|
|
113
|
+
@keys.each do |key|
|
|
114
|
+
@missing_keys << key unless @service.warnings.key?(key)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
@missing_keys.empty?
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def failure_message
|
|
121
|
+
"expected service to have warnings on #{@keys.inspect}, " \
|
|
122
|
+
"but missing warnings on: #{@missing_keys.inspect}. " \
|
|
123
|
+
"Actual warnings: #{warnings_summary}"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def failure_message_when_negated
|
|
127
|
+
"expected service not to have warnings on #{@keys.inspect}"
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def description
|
|
131
|
+
"have warnings on #{@keys.inspect}"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
private
|
|
135
|
+
|
|
136
|
+
def warnings_summary
|
|
137
|
+
if @service.warnings.empty?
|
|
138
|
+
"empty"
|
|
139
|
+
else
|
|
140
|
+
@service.warnings.to_h.inspect
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Operandi
|
|
4
|
+
module RSpec
|
|
5
|
+
module Matchers
|
|
6
|
+
# Matcher for testing callback execution on a service instance
|
|
7
|
+
# NOTE: This matcher requires the service to track callback execution.
|
|
8
|
+
# Add tracking in your callbacks:
|
|
9
|
+
#
|
|
10
|
+
# before_service_run do |service|
|
|
11
|
+
# service.callback_log << :before_service_run
|
|
12
|
+
# end
|
|
13
|
+
#
|
|
14
|
+
# after_step_run do |service, step_name|
|
|
15
|
+
# service.callback_log << [:after_step_run, step_name]
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
# @example Basic callback check (requires callback_log tracking)
|
|
19
|
+
# expect(service).to trigger_callback(:before_service_run)
|
|
20
|
+
#
|
|
21
|
+
# @example Step-specific callback
|
|
22
|
+
# expect(service).to trigger_callback(:after_step_run).for_step(:validate)
|
|
23
|
+
#
|
|
24
|
+
# @example Check callback was not triggered
|
|
25
|
+
# expect(service).not_to trigger_callback(:on_service_failure)
|
|
26
|
+
def trigger_callback(callback_name)
|
|
27
|
+
TriggerCallbackMatcher.new(callback_name)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
class TriggerCallbackMatcher
|
|
31
|
+
VALID_CALLBACKS = [
|
|
32
|
+
:before_service_run,
|
|
33
|
+
:after_service_run,
|
|
34
|
+
:around_service_run,
|
|
35
|
+
:on_service_success,
|
|
36
|
+
:on_service_failure,
|
|
37
|
+
:before_step_run,
|
|
38
|
+
:after_step_run,
|
|
39
|
+
:around_step_run,
|
|
40
|
+
:on_step_success,
|
|
41
|
+
:on_step_failure,
|
|
42
|
+
:on_step_crash,
|
|
43
|
+
].freeze
|
|
44
|
+
|
|
45
|
+
STEP_CALLBACKS = [
|
|
46
|
+
:before_step_run,
|
|
47
|
+
:after_step_run,
|
|
48
|
+
:around_step_run,
|
|
49
|
+
:on_step_success,
|
|
50
|
+
:on_step_failure,
|
|
51
|
+
:on_step_crash,
|
|
52
|
+
].freeze
|
|
53
|
+
|
|
54
|
+
def initialize(callback_name)
|
|
55
|
+
@callback_name = callback_name
|
|
56
|
+
@step_name = nil
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def for_step(step_name)
|
|
60
|
+
@step_name = step_name
|
|
61
|
+
self
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def matches?(service)
|
|
65
|
+
@service = service
|
|
66
|
+
|
|
67
|
+
return false unless service_tracks_callbacks?
|
|
68
|
+
return false unless callback_triggered?
|
|
69
|
+
|
|
70
|
+
true
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def failure_message
|
|
74
|
+
return tracking_not_available_message unless service_tracks_callbacks?
|
|
75
|
+
|
|
76
|
+
if @step_name
|
|
77
|
+
"expected service to trigger callback :#{@callback_name} for step :#{@step_name}, " \
|
|
78
|
+
"but callback log was: #{callback_log.inspect}"
|
|
79
|
+
else
|
|
80
|
+
"expected service to trigger callback :#{@callback_name}, " \
|
|
81
|
+
"but callback log was: #{callback_log.inspect}"
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def failure_message_when_negated
|
|
86
|
+
if @step_name
|
|
87
|
+
"expected service not to trigger callback :#{@callback_name} for step :#{@step_name}"
|
|
88
|
+
else
|
|
89
|
+
"expected service not to trigger callback :#{@callback_name}"
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def description
|
|
94
|
+
desc = "trigger callback :#{@callback_name}"
|
|
95
|
+
desc += " for step :#{@step_name}" if @step_name
|
|
96
|
+
desc
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
def service_tracks_callbacks?
|
|
102
|
+
@service.respond_to?(:callback_log)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def callback_log
|
|
106
|
+
@service.callback_log
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def callback_triggered?
|
|
110
|
+
if @step_name
|
|
111
|
+
# Look for step-specific callback entry like [:after_step_run, :validate]
|
|
112
|
+
callback_log.include?([@callback_name, @step_name])
|
|
113
|
+
else
|
|
114
|
+
# Look for service-level callback or any occurrence of the callback name
|
|
115
|
+
callback_log.any? do |entry|
|
|
116
|
+
case entry
|
|
117
|
+
when Symbol
|
|
118
|
+
entry == @callback_name
|
|
119
|
+
when Array
|
|
120
|
+
entry.first == @callback_name
|
|
121
|
+
else
|
|
122
|
+
false
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def tracking_not_available_message
|
|
129
|
+
"cannot verify callback execution because service does not track callbacks. " \
|
|
130
|
+
"Add callback tracking to your service, e.g.: " \
|
|
131
|
+
"`before_service_run { |service| service.callback_log << :before_service_run }`"
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "operandi"
|
|
4
|
+
|
|
5
|
+
require_relative "rspec/matchers/define_argument"
|
|
6
|
+
require_relative "rspec/matchers/define_output"
|
|
7
|
+
require_relative "rspec/matchers/define_step"
|
|
8
|
+
require_relative "rspec/matchers/have_error_on"
|
|
9
|
+
require_relative "rspec/matchers/have_warning_on"
|
|
10
|
+
require_relative "rspec/matchers/execute_step"
|
|
11
|
+
require_relative "rspec/matchers/trigger_callback"
|
|
12
|
+
|
|
13
|
+
RSpec.configure do |config|
|
|
14
|
+
config.include Operandi::RSpec::Matchers
|
|
15
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RuboCop
|
|
4
|
+
module Cop
|
|
5
|
+
module Operandi
|
|
6
|
+
# Ensures that all `arg` declarations in Operandi include a `type:` option.
|
|
7
|
+
#
|
|
8
|
+
# @example
|
|
9
|
+
# # bad
|
|
10
|
+
# arg :user_id
|
|
11
|
+
# arg :params, default: {}
|
|
12
|
+
# arg :name, optional: true
|
|
13
|
+
#
|
|
14
|
+
# # good
|
|
15
|
+
# arg :user_id, type: Integer
|
|
16
|
+
# arg :params, type: Hash, default: {}
|
|
17
|
+
# arg :name, type: String, optional: true
|
|
18
|
+
#
|
|
19
|
+
class ArgumentTypeRequired < Base
|
|
20
|
+
MSG = "Argument `%<name>s` must have a `type:` option."
|
|
21
|
+
|
|
22
|
+
RESTRICT_ON_SEND = [:arg].freeze
|
|
23
|
+
|
|
24
|
+
# @!method arg_call?(node)
|
|
25
|
+
def_node_matcher :arg_call?, <<~PATTERN
|
|
26
|
+
(send nil? :arg (sym $_) ...)
|
|
27
|
+
PATTERN
|
|
28
|
+
|
|
29
|
+
def on_send(node)
|
|
30
|
+
arg_call?(node) do |name|
|
|
31
|
+
return if has_type_option?(node)
|
|
32
|
+
|
|
33
|
+
add_offense(node, message: format(MSG, name: name))
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def has_type_option?(node)
|
|
40
|
+
# arg :name (no options)
|
|
41
|
+
return false if node.arguments.size == 1
|
|
42
|
+
|
|
43
|
+
# arg :name, type: Foo or arg :name, { type: Foo }
|
|
44
|
+
opts_node = node.arguments[1]
|
|
45
|
+
return false unless opts_node&.hash_type?
|
|
46
|
+
|
|
47
|
+
opts_node.pairs.any? { |pair| pair.key.sym_type? && pair.key.value == :type }
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RuboCop
|
|
4
|
+
module Cop
|
|
5
|
+
module Operandi
|
|
6
|
+
# Ensures that symbol conditions in `step` declarations (`:if` and `:unless`)
|
|
7
|
+
# have corresponding methods defined in the same class.
|
|
8
|
+
#
|
|
9
|
+
# This cop automatically recognizes:
|
|
10
|
+
# - Methods defined with `def`
|
|
11
|
+
# - Predicate methods from `arg` and `output` (e.g., `arg :user` creates `user` and `user?`)
|
|
12
|
+
# - Methods from `attr_reader`, `attr_accessor`, and `attr_writer`
|
|
13
|
+
#
|
|
14
|
+
# @example
|
|
15
|
+
# # bad
|
|
16
|
+
# class MyService < ApplicationService
|
|
17
|
+
# step :process, if: :should_process?
|
|
18
|
+
#
|
|
19
|
+
# private
|
|
20
|
+
#
|
|
21
|
+
# def process
|
|
22
|
+
# # should_process? is missing
|
|
23
|
+
# end
|
|
24
|
+
# end
|
|
25
|
+
#
|
|
26
|
+
# # good - explicit method
|
|
27
|
+
# class MyService < ApplicationService
|
|
28
|
+
# step :process, if: :should_process?
|
|
29
|
+
#
|
|
30
|
+
# private
|
|
31
|
+
#
|
|
32
|
+
# def process; end
|
|
33
|
+
# def should_process?; true; end
|
|
34
|
+
# end
|
|
35
|
+
#
|
|
36
|
+
# # good - predicate from arg
|
|
37
|
+
# class MyService < ApplicationService
|
|
38
|
+
# arg :user, type: User, optional: true
|
|
39
|
+
#
|
|
40
|
+
# step :greet, if: :user? # user? is auto-generated by arg :user
|
|
41
|
+
#
|
|
42
|
+
# private
|
|
43
|
+
#
|
|
44
|
+
# def greet; end
|
|
45
|
+
# end
|
|
46
|
+
#
|
|
47
|
+
# # good - attr_reader
|
|
48
|
+
# class MyService < ApplicationService
|
|
49
|
+
# attr_reader :enabled
|
|
50
|
+
#
|
|
51
|
+
# step :process, if: :enabled
|
|
52
|
+
#
|
|
53
|
+
# private
|
|
54
|
+
#
|
|
55
|
+
# def process; end
|
|
56
|
+
# end
|
|
57
|
+
#
|
|
58
|
+
# @example ExcludedMethods: ['admin?', 'guest?'] (default: [])
|
|
59
|
+
# # good - these methods are excluded from checking
|
|
60
|
+
# class MyService < ApplicationService
|
|
61
|
+
# step :admin_action, if: :admin? # excluded via config
|
|
62
|
+
# end
|
|
63
|
+
#
|
|
64
|
+
class ConditionMethodExists < Base
|
|
65
|
+
MSG = "Condition method `%<name>s` has no corresponding method. " \
|
|
66
|
+
"For inherited methods, disable this line or add to ExcludedMethods."
|
|
67
|
+
|
|
68
|
+
CONDITION_KEYS = [:if, :unless].freeze
|
|
69
|
+
ATTR_METHODS = [:attr_reader, :attr_accessor, :attr_writer].freeze
|
|
70
|
+
|
|
71
|
+
def on_class(_node)
|
|
72
|
+
@condition_methods = []
|
|
73
|
+
@defined_methods = []
|
|
74
|
+
@dsl_predicates = []
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def on_send(node)
|
|
78
|
+
if step_call?(node)
|
|
79
|
+
collect_condition_methods(node)
|
|
80
|
+
elsif arg_or_output_call?(node)
|
|
81
|
+
collect_dsl_predicate(node)
|
|
82
|
+
elsif attr_method_call?(node)
|
|
83
|
+
collect_attr_methods(node)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def on_def(node)
|
|
88
|
+
@defined_methods ||= []
|
|
89
|
+
@defined_methods << node.method_name
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def after_class(_node)
|
|
93
|
+
return unless @condition_methods&.any?
|
|
94
|
+
|
|
95
|
+
@condition_methods.each do |condition|
|
|
96
|
+
next if method_available?(condition[:name])
|
|
97
|
+
next if excluded_method?(condition[:name])
|
|
98
|
+
|
|
99
|
+
add_offense(condition[:node], message: format(MSG, name: condition[:name]))
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
def step_call?(node)
|
|
106
|
+
node.send_type? &&
|
|
107
|
+
node.method_name == :step &&
|
|
108
|
+
node.receiver.nil? &&
|
|
109
|
+
node.arguments.first&.sym_type?
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def arg_or_output_call?(node)
|
|
113
|
+
node.send_type? &&
|
|
114
|
+
[:arg, :output].include?(node.method_name) &&
|
|
115
|
+
node.receiver.nil? &&
|
|
116
|
+
node.arguments.first&.sym_type?
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def attr_method_call?(node)
|
|
120
|
+
node.send_type? &&
|
|
121
|
+
ATTR_METHODS.include?(node.method_name) &&
|
|
122
|
+
node.receiver.nil?
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def collect_condition_methods(node)
|
|
126
|
+
@condition_methods ||= []
|
|
127
|
+
|
|
128
|
+
opts_node = node.arguments[1]
|
|
129
|
+
return unless opts_node&.hash_type?
|
|
130
|
+
|
|
131
|
+
opts_node.pairs.each do |pair|
|
|
132
|
+
key = pair.key
|
|
133
|
+
value = pair.value
|
|
134
|
+
|
|
135
|
+
next unless key.sym_type? && CONDITION_KEYS.include?(key.value)
|
|
136
|
+
next unless value.sym_type?
|
|
137
|
+
|
|
138
|
+
@condition_methods << { name: value.value, node: value }
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def collect_dsl_predicate(node)
|
|
143
|
+
@dsl_predicates ||= []
|
|
144
|
+
|
|
145
|
+
field_name = node.arguments.first.value
|
|
146
|
+
@dsl_predicates += [field_name, :"#{field_name}?"]
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def collect_attr_methods(node)
|
|
150
|
+
@defined_methods ||= []
|
|
151
|
+
|
|
152
|
+
node.arguments.each do |arg|
|
|
153
|
+
next unless arg.sym_type?
|
|
154
|
+
|
|
155
|
+
@defined_methods << arg.value
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def method_available?(method_name)
|
|
160
|
+
@defined_methods&.include?(method_name) || @dsl_predicates&.include?(method_name)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def excluded_method?(method_name)
|
|
164
|
+
excluded_methods.include?(method_name.to_s)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def excluded_methods
|
|
168
|
+
cop_config.fetch("ExcludedMethods", [])
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|