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,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Operandi
6
+ # Ensures that all `output` declarations in Operandi include a `type:` option.
7
+ #
8
+ # @example
9
+ # # bad
10
+ # output :result
11
+ # output :data, optional: true
12
+ # output :count, default: 0
13
+ #
14
+ # # good
15
+ # output :result, type: Hash
16
+ # output :data, type: Hash, optional: true
17
+ # output :count, type: Integer, default: 0
18
+ #
19
+ class OutputTypeRequired < Base
20
+ MSG = "Output `%<name>s` must have a `type:` option."
21
+
22
+ RESTRICT_ON_SEND = [:output].freeze
23
+
24
+ # @!method output_call?(node)
25
+ def_node_matcher :output_call?, <<~PATTERN
26
+ (send nil? :output (sym $_) ...)
27
+ PATTERN
28
+
29
+ def on_send(node)
30
+ output_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
+ # output :name (no options)
41
+ return false if node.arguments.size == 1
42
+
43
+ # output :name, type: Foo or output :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,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Operandi
6
+ # Detects `errors.add(:base, "message")` and suggests using `fail!("message")` instead.
7
+ #
8
+ # This cop checks calls inside service classes that inherit from
9
+ # Operandi::Base or any configured base service classes.
10
+ #
11
+ # @safety
12
+ # This cop's autocorrection is safe as `fail!` is a wrapper method
13
+ # that calls `errors.add(:base, message)` internally.
14
+ #
15
+ # @example
16
+ # # bad
17
+ # class User::Create < ApplicationService
18
+ # step :process
19
+ #
20
+ # private
21
+ #
22
+ # def process
23
+ # errors.add(:base, "user is required")
24
+ # end
25
+ # end
26
+ #
27
+ # # good
28
+ # class User::Create < ApplicationService
29
+ # step :process
30
+ #
31
+ # private
32
+ #
33
+ # def process
34
+ # fail!("user is required")
35
+ # end
36
+ # end
37
+ #
38
+ class PreferFailMethod < Base
39
+ extend AutoCorrector
40
+
41
+ MSG = "Use `fail!(...)` instead of `errors.add(:base, ...)`."
42
+
43
+ def_node_matcher :errors_add_base?, <<~PATTERN
44
+ (send
45
+ (send nil? :errors) :add
46
+ (sym :base)
47
+ ...)
48
+ PATTERN
49
+
50
+ DEFAULT_BASE_CLASSES = ["ApplicationService"].freeze
51
+
52
+ def on_class(node)
53
+ @in_service_class = service_class?(node)
54
+ end
55
+
56
+ def after_class(_node)
57
+ @in_service_class = false
58
+ end
59
+
60
+ def on_send(node)
61
+ return unless @in_service_class
62
+ return unless node.method_name == :add
63
+
64
+ return unless errors_add_base?(node)
65
+
66
+ # Only flag if there's a message argument after :base
67
+ # errors.add(:base) without a message is invalid Operandi syntax
68
+ message_args = node.arguments[1..]
69
+ return if message_args.empty?
70
+
71
+ add_offense(node, message: MSG) do |corrector|
72
+ autocorrect(corrector, node, message_args)
73
+ end
74
+ end
75
+
76
+ private
77
+
78
+ def autocorrect(corrector, node, message_args)
79
+ # Get the source code for all arguments after the :base symbol
80
+ args_source = message_args.map(&:source).join(", ")
81
+ replacement = "fail!(#{args_source})"
82
+
83
+ corrector.replace(node, replacement)
84
+ end
85
+
86
+ def service_class?(node)
87
+ return false unless node.parent_class
88
+
89
+ parent_class_name = extract_class_name(node.parent_class)
90
+ return false unless parent_class_name
91
+
92
+ # Check for direct Operandi::Base inheritance
93
+ return true if parent_class_name == "Operandi::Base"
94
+
95
+ # Check against configured base service classes
96
+ base_classes = cop_config.fetch("BaseServiceClasses", DEFAULT_BASE_CLASSES)
97
+ base_classes.include?(parent_class_name)
98
+ end
99
+
100
+ def extract_class_name(node)
101
+ case node.type
102
+ when :const
103
+ node.const_name
104
+ when :send
105
+ # For namespaced constants like Operandi::Base
106
+ node.source
107
+ end
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Operandi
6
+ # Detects when `default: nil` is used instead of `optional: true`.
7
+ # Using `optional: true` is the preferred way to indicate an optional field
8
+ # with no default value.
9
+ #
10
+ # @safety
11
+ # This cop is safe to autocorrect.
12
+ #
13
+ # @example
14
+ # # bad
15
+ # arg :user, type: User, default: nil
16
+ # output :result, type: Hash, default: nil
17
+ #
18
+ # # good
19
+ # arg :user, type: User, optional: true
20
+ # output :result, type: Hash, optional: true
21
+ #
22
+ # # bad - redundant default: nil with optional: true
23
+ # arg :user, type: User, optional: true, default: nil
24
+ #
25
+ # # good
26
+ # arg :user, type: User, optional: true
27
+ #
28
+ class PreferOptionalOverDefaultNil < Base
29
+ extend AutoCorrector
30
+
31
+ MSG = "Prefer `optional: true` over `default: nil` for `%<name>s`."
32
+ MSG_REDUNDANT = "`default: nil` is redundant when `optional: true` is specified for `%<name>s`."
33
+
34
+ RESTRICT_ON_SEND = [:arg, :output].freeze
35
+
36
+ # @!method field_call?(node)
37
+ def_node_matcher :field_call?, <<~PATTERN
38
+ (send nil? {:arg :output} (sym $_) ...)
39
+ PATTERN
40
+
41
+ def on_send(node)
42
+ field_call?(node) do |name|
43
+ default_nil_pair = find_default_nil_pair(node)
44
+ return unless default_nil_pair
45
+
46
+ optional_pair = find_optional_true_pair(node)
47
+
48
+ if optional_pair
49
+ # Both optional: true and default: nil - remove default: nil
50
+ add_offense(node, message: format(MSG_REDUNDANT, name: name)) do |corrector|
51
+ remove_hash_pair(corrector, node.arguments[1], default_nil_pair)
52
+ end
53
+ else
54
+ # Only default: nil - replace with optional: true
55
+ add_offense(node, message: format(MSG, name: name)) do |corrector|
56
+ corrector.replace(default_nil_pair.source_range, "optional: true")
57
+ end
58
+ end
59
+ end
60
+ end
61
+
62
+ private
63
+
64
+ def options_hash(node)
65
+ return nil if node.arguments.size == 1
66
+
67
+ opts = node.arguments[1]
68
+ opts if opts&.hash_type?
69
+ end
70
+
71
+ def find_default_nil_pair(node)
72
+ opts = options_hash(node)
73
+ return nil unless opts
74
+
75
+ opts.pairs.find { |pair| default_nil_pair?(pair) }
76
+ end
77
+
78
+ def default_nil_pair?(pair)
79
+ pair.key.sym_type? && pair.key.value == :default && pair.value.nil_type?
80
+ end
81
+
82
+ def find_optional_true_pair(node)
83
+ opts = options_hash(node)
84
+ return nil unless opts
85
+
86
+ opts.pairs.find { |pair| optional_true_pair?(pair) }
87
+ end
88
+
89
+ def optional_true_pair?(pair)
90
+ pair.key.sym_type? && pair.key.value == :optional && pair.value.true_type?
91
+ end
92
+
93
+ def remove_hash_pair(corrector, hash_node, pair_to_remove)
94
+ pairs = hash_node.pairs
95
+ pair_index = pairs.index(pair_to_remove)
96
+
97
+ if last_pair?(pairs, pair_index)
98
+ remove_last_pair(corrector, pairs, pair_index, pair_to_remove)
99
+ else
100
+ remove_non_last_pair(corrector, pairs, pair_index, pair_to_remove)
101
+ end
102
+ end
103
+
104
+ def last_pair?(pairs, pair_index)
105
+ pair_index == pairs.size - 1
106
+ end
107
+
108
+ def remove_last_pair(corrector, pairs, pair_index, pair_to_remove)
109
+ prev_pair = pairs[pair_index - 1]
110
+ corrector.remove(range_between(prev_pair.source_range.end_pos, pair_to_remove.source_range.end_pos))
111
+ end
112
+
113
+ def remove_non_last_pair(corrector, pairs, pair_index, pair_to_remove)
114
+ next_pair = pairs[pair_index + 1]
115
+ corrector.remove(range_between(pair_to_remove.source_range.begin_pos, next_pair.source_range.begin_pos))
116
+ end
117
+
118
+ def range_between(start_pos, end_pos)
119
+ Parser::Source::Range.new(processed_source.buffer, start_pos, end_pos)
120
+ end
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Operandi
6
+ # Detects when `optional: true` is used together with `default:` option.
7
+ # Having a default value implies the argument/output is optional, making
8
+ # `optional: true` redundant.
9
+ #
10
+ # @safety
11
+ # This cop is safe to autocorrect.
12
+ #
13
+ # @example
14
+ # # bad
15
+ # arg :name, type: String, optional: true, default: "guest"
16
+ # output :count, type: Integer, optional: true, default: 0
17
+ #
18
+ # # good
19
+ # arg :name, type: String, default: "guest"
20
+ # output :count, type: Integer, default: 0
21
+ #
22
+ class RedundantOptional < Base
23
+ extend AutoCorrector
24
+
25
+ MSG = "`optional: true` is redundant when `default:` is specified for `%<name>s`."
26
+
27
+ RESTRICT_ON_SEND = [:arg, :output].freeze
28
+
29
+ # @!method field_call?(node)
30
+ def_node_matcher :field_call?, <<~PATTERN
31
+ (send nil? {:arg :output} (sym $_) ...)
32
+ PATTERN
33
+
34
+ def on_send(node)
35
+ field_call?(node) do |name|
36
+ optional_pair = find_optional_true_pair(node)
37
+ return unless optional_pair && has_default_option?(node)
38
+
39
+ add_offense(node, message: format(MSG, name: name)) do |corrector|
40
+ remove_hash_pair(corrector, node.arguments[1], optional_pair)
41
+ end
42
+ end
43
+ end
44
+
45
+ private
46
+
47
+ def options_hash(node)
48
+ return nil if node.arguments.size == 1
49
+
50
+ opts = node.arguments[1]
51
+ opts if opts&.hash_type?
52
+ end
53
+
54
+ def find_optional_true_pair(node)
55
+ opts = options_hash(node)
56
+ return nil unless opts
57
+
58
+ opts.pairs.find { |pair| optional_true_pair?(pair) }
59
+ end
60
+
61
+ def optional_true_pair?(pair)
62
+ pair.key.sym_type? && pair.key.value == :optional && pair.value.true_type?
63
+ end
64
+
65
+ def has_default_option?(node)
66
+ opts = options_hash(node)
67
+ return false unless opts
68
+
69
+ opts.pairs.any? { |pair| pair.key.sym_type? && pair.key.value == :default }
70
+ end
71
+
72
+ def remove_hash_pair(corrector, hash_node, pair_to_remove)
73
+ pairs = hash_node.pairs
74
+ pair_index = pairs.index(pair_to_remove)
75
+
76
+ if last_pair?(pairs, pair_index)
77
+ remove_last_pair(corrector, pairs, pair_index, pair_to_remove)
78
+ else
79
+ remove_non_last_pair(corrector, pairs, pair_index, pair_to_remove)
80
+ end
81
+ end
82
+
83
+ def last_pair?(pairs, pair_index)
84
+ pair_index == pairs.size - 1
85
+ end
86
+
87
+ def remove_last_pair(corrector, pairs, pair_index, pair_to_remove)
88
+ prev_pair = pairs[pair_index - 1]
89
+ corrector.remove(range_between(prev_pair.source_range.end_pos, pair_to_remove.source_range.end_pos))
90
+ end
91
+
92
+ def remove_non_last_pair(corrector, pairs, pair_index, pair_to_remove)
93
+ next_pair = pairs[pair_index + 1]
94
+ corrector.remove(range_between(pair_to_remove.source_range.begin_pos, next_pair.source_range.begin_pos))
95
+ end
96
+
97
+ def range_between(start_pos, end_pos)
98
+ Parser::Source::Range.new(processed_source.buffer, start_pos, end_pos)
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../../../operandi/constants"
4
+
5
+ module RuboCop
6
+ module Cop
7
+ module Operandi
8
+ # Ensures that `arg`, `step`, and `output` declarations do not use reserved names
9
+ # that would conflict with Operandi methods.
10
+ #
11
+ # @example
12
+ # # bad
13
+ # arg :errors, type: Array
14
+ # arg :output, type: Hash
15
+ # step :call
16
+ # output :success?, type: [TrueClass, FalseClass]
17
+ #
18
+ # # good
19
+ # arg :validation_errors, type: Array
20
+ # arg :result_output, type: Hash
21
+ # step :execute
22
+ # output :succeeded, type: [TrueClass, FalseClass]
23
+ #
24
+ class ReservedName < Base
25
+ include RuboCop::Cop::RangeHelp
26
+
27
+ MSG = "`%<name>s` is a reserved name and cannot be used as %<field_type>s. " \
28
+ "It conflicts with Operandi methods."
29
+
30
+ SEVERITY = :error
31
+
32
+ RESTRICT_ON_SEND = [:arg, :step, :output].freeze
33
+
34
+ FIELD_TYPE_NAMES = {
35
+ arg: "an argument",
36
+ step: "a step",
37
+ output: "an output",
38
+ }.freeze
39
+
40
+ # @!method dsl_call?(node)
41
+ def_node_matcher :dsl_call?, <<~PATTERN
42
+ (send nil? ${:arg :step :output} (sym $_) ...)
43
+ PATTERN
44
+
45
+ def on_send(node)
46
+ dsl_call?(node) do |method_name, name|
47
+ return unless ::Operandi::ReservedNames::ALL.include?(name)
48
+
49
+ field_type = FIELD_TYPE_NAMES[method_name]
50
+ add_offense(node, message: format(MSG, name: name, field_type: field_type), severity: SEVERITY)
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Operandi
6
+ # Ensures that all `step` declarations have a corresponding method defined in the same class.
7
+ #
8
+ # Note: This cop only checks for methods defined in the same file/class. It cannot detect
9
+ # methods inherited from parent classes. Use `parent: true`, the `ExcludedSteps` option,
10
+ # or `rubocop:disable` comments for inherited steps.
11
+ #
12
+ # @example
13
+ # # bad
14
+ # class MyService < ApplicationService
15
+ # step :validate
16
+ # step :process
17
+ #
18
+ # private
19
+ #
20
+ # def validate
21
+ # # only validate is defined, process is missing
22
+ # end
23
+ # end
24
+ #
25
+ # # good
26
+ # class MyService < ApplicationService
27
+ # step :validate
28
+ # step :process
29
+ #
30
+ # private
31
+ #
32
+ # def validate
33
+ # # validation logic
34
+ # end
35
+ #
36
+ # def process
37
+ # # processing logic
38
+ # end
39
+ # end
40
+ #
41
+ # @example parent: true - step method is defined in a parent class
42
+ # # good - step method is inherited from the parent class
43
+ # class User::Create < CreateService
44
+ # step :initialize_entity, parent: true
45
+ # step :assign_attributes, parent: true
46
+ # step :send_welcome_email
47
+ #
48
+ # private
49
+ #
50
+ # def send_welcome_email
51
+ # # only this method needs to be defined
52
+ # end
53
+ # end
54
+ #
55
+ # @example ExcludedSteps: ['initialize_entity', 'assign_attributes'] (default: [])
56
+ # # good - these steps are excluded from checking
57
+ # class User::Create < CreateService
58
+ # step :initialize_entity
59
+ # step :assign_attributes
60
+ # step :send_welcome_email
61
+ #
62
+ # private
63
+ #
64
+ # def send_welcome_email
65
+ # # only this method needs to be defined
66
+ # end
67
+ # end
68
+ #
69
+ class StepMethodExists < Base
70
+ MSG = "Step `%<name>s` has no corresponding method. " \
71
+ "For inherited steps, use `parent: true`, disable this line, or add to ExcludedSteps."
72
+
73
+ def on_class(_node)
74
+ @step_calls = []
75
+ @defined_methods = []
76
+ end
77
+
78
+ def on_send(node)
79
+ return unless step_call?(node)
80
+
81
+ step_name = node.arguments.first&.value
82
+ return unless step_name
83
+ return if parent_step?(node)
84
+
85
+ @step_calls ||= []
86
+ @step_calls << { name: step_name, node: node }
87
+ end
88
+
89
+ def on_def(node)
90
+ @defined_methods ||= []
91
+ @defined_methods << node.method_name
92
+ end
93
+
94
+ def after_class(_node)
95
+ return unless @step_calls&.any?
96
+
97
+ @step_calls.each do |step|
98
+ next if @defined_methods&.include?(step[:name])
99
+ next if excluded_step?(step[:name])
100
+
101
+ add_offense(step[:node], message: format(MSG, name: step[:name]))
102
+ end
103
+ end
104
+
105
+ private
106
+
107
+ def step_call?(node)
108
+ node.send_type? &&
109
+ node.method_name == :step &&
110
+ node.receiver.nil? &&
111
+ node.arguments.first&.sym_type?
112
+ end
113
+
114
+ def parent_step?(node)
115
+ opts = node.arguments[1]
116
+ return false unless opts&.hash_type?
117
+
118
+ opts.pairs.any? do |pair|
119
+ pair.key.sym_type? && pair.key.value == :parent &&
120
+ pair.value.true_type?
121
+ end
122
+ end
123
+
124
+ def excluded_step?(step_name)
125
+ excluded_steps.include?(step_name.to_s)
126
+ end
127
+
128
+ def excluded_steps
129
+ cop_config.fetch("ExcludedSteps", [])
130
+ end
131
+ end
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rubocop"
4
+
5
+ # Inject default configuration
6
+ default_config = File.expand_path("../../config/default.yml", __dir__)
7
+ RuboCop::ConfigLoader.inject_defaults!(default_config)
8
+
9
+ require_relative "rubocop/cop/operandi/argument_type_required"
10
+ require_relative "rubocop/cop/operandi/condition_method_exists"
11
+ require_relative "rubocop/cop/operandi/deprecated_accessors"
12
+ require_relative "rubocop/cop/operandi/deprecated_methods"
13
+ require_relative "rubocop/cop/operandi/dsl_order"
14
+ require_relative "rubocop/cop/operandi/no_hash_argument"
15
+ require_relative "rubocop/cop/operandi/missing_private_keyword"
16
+ require_relative "rubocop/cop/operandi/no_direct_instantiation"
17
+ require_relative "rubocop/cop/operandi/output_type_required"
18
+ require_relative "rubocop/cop/operandi/prefer_optional_over_default_nil"
19
+ require_relative "rubocop/cop/operandi/prefer_fail_method"
20
+ require_relative "rubocop/cop/operandi/redundant_optional"
21
+ require_relative "rubocop/cop/operandi/reserved_name"
22
+ require_relative "rubocop/cop/operandi/step_method_exists"