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,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Operandi
6
+ # Detects deprecated `arguments` and `outputs` accessor calls and suggests
7
+ # using `arg` and `output` instead.
8
+ #
9
+ # This cop checks calls inside service classes that inherit from
10
+ # Operandi::Base or any configured base service classes.
11
+ #
12
+ # @safety
13
+ # This cop's autocorrection is safe as `arguments` and `outputs` are
14
+ # direct wrappers for `arg` and `output`.
15
+ #
16
+ # @example
17
+ # # bad
18
+ # class User::Create < ApplicationService
19
+ # step :process
20
+ #
21
+ # private
22
+ #
23
+ # def process
24
+ # arguments[:name]
25
+ # outputs[:result]
26
+ # end
27
+ # end
28
+ #
29
+ # # good
30
+ # class User::Create < ApplicationService
31
+ # step :process
32
+ #
33
+ # private
34
+ #
35
+ # def process
36
+ # arg[:name]
37
+ # output[:result]
38
+ # end
39
+ # end
40
+ #
41
+ class DeprecatedAccessors < Base
42
+ extend AutoCorrector
43
+
44
+ MSG_ARGUMENTS = "Use `arg` instead of deprecated `arguments`."
45
+ MSG_OUTPUTS = "Use `output` instead of deprecated `outputs`."
46
+
47
+ RESTRICT_ON_SEND = [:arguments, :outputs].freeze
48
+
49
+ REPLACEMENTS = {
50
+ arguments: :arg,
51
+ outputs: :output,
52
+ }.freeze
53
+
54
+ DEFAULT_BASE_CLASSES = ["ApplicationService"].freeze
55
+
56
+ def on_class(node)
57
+ @in_service_class = service_class?(node)
58
+ end
59
+
60
+ def after_class(_node)
61
+ @in_service_class = false
62
+ end
63
+
64
+ def on_send(node)
65
+ return unless @in_service_class
66
+ return unless RESTRICT_ON_SEND.include?(node.method_name)
67
+ return if node.receiver && !self_receiver?(node)
68
+
69
+ message = node.method_name == :arguments ? MSG_ARGUMENTS : MSG_OUTPUTS
70
+ replacement = REPLACEMENTS[node.method_name]
71
+
72
+ add_offense(node, message: message) do |corrector|
73
+ if node.receiver
74
+ corrector.replace(node, "self.#{replacement}")
75
+ else
76
+ corrector.replace(node, replacement.to_s)
77
+ end
78
+ end
79
+ end
80
+
81
+ private
82
+
83
+ def service_class?(node)
84
+ return false unless node.parent_class
85
+
86
+ parent_class_name = extract_class_name(node.parent_class)
87
+ return false unless parent_class_name
88
+
89
+ # Check for direct Operandi::Base inheritance
90
+ return true if parent_class_name == "Operandi::Base"
91
+
92
+ # Check against configured base service classes
93
+ base_classes = cop_config.fetch("BaseServiceClasses", DEFAULT_BASE_CLASSES)
94
+ base_classes.include?(parent_class_name)
95
+ end
96
+
97
+ def extract_class_name(node)
98
+ case node.type
99
+ when :const
100
+ node.const_name
101
+ when :send
102
+ # For namespaced constants like Operandi::Base
103
+ node.source
104
+ end
105
+ end
106
+
107
+ def self_receiver?(node)
108
+ node.receiver&.self_type?
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Operandi
6
+ # Detects deprecated `done!` and `done?` method calls and suggests
7
+ # using `stop!` and `stopped?` instead.
8
+ #
9
+ # This cop checks calls inside service classes that inherit from
10
+ # Operandi::Base or any configured base service classes.
11
+ #
12
+ # @safety
13
+ # This cop's autocorrection is safe as `done!` and `done?` are
14
+ # direct aliases for `stop!` and `stopped?`.
15
+ #
16
+ # @example
17
+ # # bad
18
+ # class User::Create < ApplicationService
19
+ # step :process
20
+ #
21
+ # private
22
+ #
23
+ # def process
24
+ # done! if condition_met?
25
+ # return if done?
26
+ # end
27
+ # end
28
+ #
29
+ # # good
30
+ # class User::Create < ApplicationService
31
+ # step :process
32
+ #
33
+ # private
34
+ #
35
+ # def process
36
+ # stop! if condition_met?
37
+ # return if stopped?
38
+ # end
39
+ # end
40
+ #
41
+ class DeprecatedMethods < Base
42
+ extend AutoCorrector
43
+
44
+ MSG_DONE_BANG = "Use `stop!` instead of deprecated `done!`."
45
+ MSG_DONE_QUERY = "Use `stopped?` instead of deprecated `done?`."
46
+
47
+ RESTRICT_ON_SEND = [:done!, :done?].freeze
48
+
49
+ REPLACEMENTS = {
50
+ done!: :stop!,
51
+ done?: :stopped?,
52
+ }.freeze
53
+
54
+ DEFAULT_BASE_CLASSES = ["ApplicationService"].freeze
55
+
56
+ def on_class(node)
57
+ @in_service_class = service_class?(node)
58
+ end
59
+
60
+ def after_class(_node)
61
+ @in_service_class = false
62
+ end
63
+
64
+ def on_send(node)
65
+ return unless @in_service_class
66
+ return unless RESTRICT_ON_SEND.include?(node.method_name)
67
+ return if node.receiver && !self_receiver?(node)
68
+
69
+ message = node.method_name == :done! ? MSG_DONE_BANG : MSG_DONE_QUERY
70
+ replacement = REPLACEMENTS[node.method_name]
71
+
72
+ add_offense(node, message: message) do |corrector|
73
+ if node.receiver
74
+ corrector.replace(node, "self.#{replacement}")
75
+ else
76
+ corrector.replace(node, replacement.to_s)
77
+ end
78
+ end
79
+ end
80
+
81
+ private
82
+
83
+ def service_class?(node)
84
+ return false unless node.parent_class
85
+
86
+ parent_class_name = extract_class_name(node.parent_class)
87
+ return false unless parent_class_name
88
+
89
+ # Check for direct Operandi::Base inheritance
90
+ return true if parent_class_name == "Operandi::Base"
91
+
92
+ # Check against configured base service classes
93
+ base_classes = cop_config.fetch("BaseServiceClasses", DEFAULT_BASE_CLASSES)
94
+ base_classes.include?(parent_class_name)
95
+ end
96
+
97
+ def extract_class_name(node)
98
+ case node.type
99
+ when :const
100
+ node.const_name
101
+ when :send
102
+ # For namespaced constants like Operandi::Base
103
+ node.source
104
+ end
105
+ end
106
+
107
+ def self_receiver?(node)
108
+ node.receiver&.self_type?
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,181 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Operandi
6
+ # Enforces a consistent order for DSL declarations in service classes.
7
+ #
8
+ # The expected order is: `config` → `arg` → `step` → `output`
9
+ #
10
+ # @safety
11
+ # This cop's autocorrection is safe but may change the visual grouping of your code.
12
+ #
13
+ # @example
14
+ # # bad
15
+ # class MyService < ApplicationService
16
+ # step :process
17
+ # arg :name, type: String
18
+ # output :result, type: Hash
19
+ # config raise_on_error: true
20
+ # end
21
+ #
22
+ # # good
23
+ # class MyService < ApplicationService
24
+ # config raise_on_error: true
25
+ #
26
+ # arg :name, type: String
27
+ #
28
+ # step :process
29
+ #
30
+ # output :result, type: Hash
31
+ # end
32
+ #
33
+ class DslOrder < Base
34
+ extend AutoCorrector
35
+
36
+ MSG = "`%<current>s` should come before `%<previous>s`. " \
37
+ "Expected order: config → arg → step → output."
38
+
39
+ DSL_METHODS = [:config, :arg, :step, :output].freeze
40
+ DSL_ORDER = { config: 0, arg: 1, step: 2, output: 3 }.freeze
41
+
42
+ def on_class(node)
43
+ @dsl_calls = []
44
+ @class_node = node
45
+ end
46
+
47
+ def on_send(node)
48
+ return unless dsl_call?(node)
49
+
50
+ @dsl_calls ||= []
51
+ @dsl_calls << { method: node.method_name, node: node }
52
+ end
53
+
54
+ def after_class(_node)
55
+ return unless @dsl_calls&.any?
56
+
57
+ check_order
58
+ end
59
+
60
+ private
61
+
62
+ def dsl_call?(node)
63
+ node.send_type? &&
64
+ node.receiver.nil? &&
65
+ DSL_METHODS.include?(node.method_name) &&
66
+ class_level?(node)
67
+ end
68
+
69
+ def class_level?(node)
70
+ node.each_ancestor(:def, :defs, :block).none?
71
+ end
72
+
73
+ def check_order
74
+ highest_order_seen = -1
75
+ highest_method_seen = nil
76
+ has_offense = false
77
+
78
+ @dsl_calls.each do |call|
79
+ current_order = DSL_ORDER[call[:method]]
80
+
81
+ if current_order < highest_order_seen
82
+ has_offense = true
83
+ add_offense(
84
+ call[:node],
85
+ message: format(MSG, current: call[:method], previous: highest_method_seen),
86
+ ) do |corrector|
87
+ reorder_dsl_declarations(corrector) unless @corrected
88
+ @corrected = true
89
+ end
90
+ elsif current_order > highest_order_seen
91
+ highest_order_seen = current_order
92
+ highest_method_seen = call[:method]
93
+ end
94
+ end
95
+
96
+ @corrected = false if has_offense
97
+ end
98
+
99
+ def reorder_dsl_declarations(corrector) # rubocop:disable Metrics/AbcSize
100
+ # Collect all DSL nodes with their source including leading comments
101
+ dsl_sources = @dsl_calls.map do |call|
102
+ {
103
+ method: call[:method],
104
+ node: call[:node],
105
+ source: source_with_leading_comment(call[:node]),
106
+ }
107
+ end
108
+
109
+ # Sort by expected order
110
+ sorted_sources = dsl_sources.sort_by { |item| DSL_ORDER[item[:method]] }
111
+
112
+ # Group by type to add blank lines between groups
113
+ grouped_source = build_grouped_source(sorted_sources)
114
+
115
+ # Calculate the range to replace (from first DSL to last DSL)
116
+ first_node = @dsl_calls.min_by { |c| c[:node].loc.expression.begin_pos }[:node]
117
+ last_node = @dsl_calls.max_by { |c| c[:node].loc.expression.end_pos }[:node]
118
+
119
+ # Get the range including leading comments of first node
120
+ start_pos = first_node.loc.expression.begin_pos
121
+ leading_comment = leading_comment_for(first_node)
122
+ start_pos = leading_comment.loc.expression.begin_pos if leading_comment
123
+
124
+ # Find the beginning of the line for proper replacement
125
+ start_pos = beginning_of_line(start_pos)
126
+ end_pos = end_of_line(last_node.loc.expression.end_pos)
127
+
128
+ range = range_between(start_pos, end_pos)
129
+ corrector.replace(range, grouped_source)
130
+ end
131
+
132
+ def source_with_leading_comment(node)
133
+ comment = leading_comment_for(node)
134
+ indent = " " * node.loc.column
135
+
136
+ if comment
137
+ "#{indent}#{comment.text}\n#{indent}#{node.source}"
138
+ else
139
+ "#{indent}#{node.source}"
140
+ end
141
+ end
142
+
143
+ def leading_comment_for(node)
144
+ processed_source.comments.find do |comment|
145
+ comment.loc.line == node.loc.line - 1
146
+ end
147
+ end
148
+
149
+ def build_grouped_source(sorted_sources)
150
+ result = []
151
+ current_type = nil
152
+
153
+ sorted_sources.each do |item|
154
+ # Add blank line when switching to a new DSL type
155
+ result << "" if current_type && current_type != item[:method]
156
+ current_type = item[:method]
157
+ result << item[:source]
158
+ end
159
+
160
+ result.join("\n")
161
+ end
162
+
163
+ def beginning_of_line(pos)
164
+ source = processed_source.buffer.source
165
+ pos -= 1 while pos > 0 && source[pos - 1] != "\n"
166
+ pos
167
+ end
168
+
169
+ def end_of_line(pos)
170
+ source = processed_source.buffer.source
171
+ pos += 1 while pos < source.length && source[pos] != "\n"
172
+ pos
173
+ end
174
+
175
+ def range_between(start_pos, end_pos)
176
+ Parser::Source::Range.new(processed_source.buffer, start_pos, end_pos)
177
+ end
178
+ end
179
+ end
180
+ end
181
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Operandi
6
+ # Ensures that step methods are defined as private.
7
+ # Step methods are implementation details and should not be part of the public API.
8
+ #
9
+ # @example
10
+ # # bad
11
+ # class MyService < ApplicationService
12
+ # step :process
13
+ # step :notify
14
+ #
15
+ # def process
16
+ # # This should be private
17
+ # end
18
+ #
19
+ # def notify
20
+ # # This should be private
21
+ # end
22
+ # end
23
+ #
24
+ # # good
25
+ # class MyService < ApplicationService
26
+ # step :process
27
+ # step :notify
28
+ #
29
+ # private
30
+ #
31
+ # def process
32
+ # # Now private
33
+ # end
34
+ #
35
+ # def notify
36
+ # # Now private
37
+ # end
38
+ # end
39
+ #
40
+ class MissingPrivateKeyword < Base
41
+ MSG = "Step method `%<name>s` should be private."
42
+
43
+ def on_class(_node)
44
+ @step_names = []
45
+ @private_section_started = false
46
+ @public_step_methods = []
47
+ end
48
+
49
+ def on_send(node)
50
+ if step_call?(node)
51
+ step_name = node.arguments.first&.value
52
+ @step_names ||= []
53
+ @step_names << step_name if step_name
54
+ elsif private_declaration?(node)
55
+ @private_section_started = true
56
+ elsif public_declaration?(node)
57
+ @private_section_started = false
58
+ end
59
+ end
60
+
61
+ def on_def(node)
62
+ return unless @step_names&.include?(node.method_name)
63
+ return if @private_section_started
64
+
65
+ @public_step_methods ||= []
66
+ @public_step_methods << node
67
+ end
68
+
69
+ def after_class(_node)
70
+ return unless @public_step_methods&.any?
71
+
72
+ @public_step_methods.each do |node|
73
+ add_offense(node, message: format(MSG, name: node.method_name))
74
+ end
75
+ end
76
+
77
+ private
78
+
79
+ def step_call?(node)
80
+ node.send_type? &&
81
+ node.method_name == :step &&
82
+ node.receiver.nil? &&
83
+ node.arguments.first&.sym_type?
84
+ end
85
+
86
+ def private_declaration?(node)
87
+ node.send_type? &&
88
+ node.method_name == :private &&
89
+ node.receiver.nil? &&
90
+ node.arguments.empty?
91
+ end
92
+
93
+ def public_declaration?(node)
94
+ node.send_type? &&
95
+ node.method_name == :public &&
96
+ node.receiver.nil? &&
97
+ node.arguments.empty?
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Operandi
6
+ # Prevents direct instantiation of service classes with `.new`.
7
+ # Services should be called using `.run`, `.run!`, or `.call`.
8
+ #
9
+ # @example
10
+ # # bad
11
+ # UserService.new(name: "John")
12
+ # User::Create.new(params: {})
13
+ #
14
+ # # good
15
+ # UserService.run(name: "John")
16
+ # UserService.run!(name: "John")
17
+ # UserService.call(name: "John")
18
+ # User::Create.run(params: {})
19
+ #
20
+ # @example ServicePattern: 'Service$' (default)
21
+ # # Matches class names ending with "Service"
22
+ # UserService.new # offense
23
+ # UserCreator.new # no offense (doesn't match pattern)
24
+ #
25
+ # @example ServicePattern: '(Service|Creator)$'
26
+ # # Matches class names ending with "Service" or "Creator"
27
+ # UserService.new # offense
28
+ # UserCreator.new # offense
29
+ #
30
+ class NoDirectInstantiation < Base
31
+ MSG = "Use `.run`, `.run!`, or `.call` instead of `.new` for service classes."
32
+
33
+ RESTRICT_ON_SEND = [:new].freeze
34
+
35
+ def on_send(node)
36
+ return unless node.method_name == :new
37
+ return unless service_class?(node.receiver)
38
+
39
+ add_offense(node)
40
+ end
41
+
42
+ private
43
+
44
+ def service_class?(node)
45
+ return false unless node
46
+
47
+ class_name = extract_class_name(node)
48
+ return false unless class_name
49
+
50
+ pattern = cop_config.fetch("ServicePattern", "Service$")
51
+ class_name.match?(Regexp.new(pattern))
52
+ end
53
+
54
+ def extract_class_name(node)
55
+ case node.type
56
+ when :const
57
+ node.const_name
58
+ when :send
59
+ # For chained constants like User::Create
60
+ node.source
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Operandi
6
+ # Detects when `.run` or `.run!` is called with a hash argument instead of keyword arguments.
7
+ #
8
+ # Since Operandi services now only accept keyword arguments, passing a hash variable
9
+ # or hash expression will cause an error. Use keyword splatting (`**`) to convert
10
+ # hash arguments to keyword arguments.
11
+ #
12
+ # @safety
13
+ # This cop is disabled by default because it may produce false positives
14
+ # when `.run` is called on non-Operandi classes.
15
+ #
16
+ # @example
17
+ # # bad
18
+ # UserService.run(args)
19
+ # UserService.run!(params)
20
+ # UserService.run(args.merge(new: true))
21
+ # UserService.run({ name: "John" })
22
+ # Auth::SignIn.run(service_args)
23
+ #
24
+ # # good
25
+ # UserService.run(name: "John")
26
+ # UserService.run(**args)
27
+ # UserService.run(**args.merge(new: true))
28
+ # UserService.run(**args, new: true)
29
+ # Auth::SignIn.run(**service_args)
30
+ #
31
+ # @example ServicePattern: nil (default - checks all classes)
32
+ # # Checks all .run and .run! calls
33
+ # UserService.run(args) # offense
34
+ # Auth::SignIn.run(args) # offense
35
+ # SomeClass.run(args) # offense
36
+ #
37
+ # @example ServicePattern: 'Service$'
38
+ # # Only matches class names ending with "Service"
39
+ # UserService.run(args) # offense
40
+ # Auth::SignIn.run(args) # no offense (doesn't match pattern)
41
+ #
42
+ class NoHashArgument < Base
43
+ MSG = "Use keyword arguments or `**` splat instead of hash argument for `.%<method>s`."
44
+
45
+ RESTRICT_ON_SEND = [:run, :run!].freeze
46
+
47
+ def on_send(node)
48
+ return unless RESTRICT_ON_SEND.include?(node.method_name)
49
+ return unless service_class?(node.receiver)
50
+ return if node.arguments.empty?
51
+ return if valid_arguments?(node.arguments)
52
+
53
+ add_offense(node, message: format(MSG, method: node.method_name), severity: :fatal)
54
+ end
55
+
56
+ private
57
+
58
+ def valid_arguments?(arguments)
59
+ arguments.all? do |arg|
60
+ case arg.type
61
+ when :block_pass
62
+ # Block pass (&block) is always valid
63
+ true
64
+ when :hash
65
+ # Hash node can be:
66
+ # - Implicit hash for keyword args: run(foo: bar) - braces? returns false
67
+ # - Explicit hash literal: run({ foo: bar }) - braces? returns true
68
+ # Only implicit hash (keyword args) is valid
69
+ !arg.braces?
70
+ else
71
+ # Any other type (lvar, send, ivar, etc.) is a hash variable - invalid
72
+ false
73
+ end
74
+ end
75
+ end
76
+
77
+ def service_class?(node)
78
+ return false unless node
79
+
80
+ class_name = extract_class_name(node)
81
+ return false unless class_name
82
+
83
+ pattern = cop_config["ServicePattern"]
84
+ return true if pattern.nil? || pattern.empty?
85
+
86
+ class_name.match?(Regexp.new(pattern))
87
+ end
88
+
89
+ def extract_class_name(node)
90
+ case node.type
91
+ when :const
92
+ node.const_name
93
+ when :send
94
+ # For chained constants like User::Create or method calls
95
+ node.source
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end