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,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