light-services 2.2.1 → 3.1.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 (89) hide show
  1. checksums.yaml +4 -4
  2. data/.github/config/rubocop_linter_action.yml +4 -4
  3. data/.github/workflows/ci.yml +12 -12
  4. data/.gitignore +1 -0
  5. data/.rubocop.yml +83 -7
  6. data/CHANGELOG.md +38 -0
  7. data/CLAUDE.md +139 -0
  8. data/Gemfile +16 -11
  9. data/Gemfile.lock +53 -27
  10. data/README.md +84 -21
  11. data/docs/arguments.md +290 -0
  12. data/docs/best-practices.md +153 -0
  13. data/docs/callbacks.md +476 -0
  14. data/docs/concepts.md +80 -0
  15. data/docs/configuration.md +204 -0
  16. data/docs/context.md +128 -0
  17. data/docs/crud.md +525 -0
  18. data/docs/errors.md +280 -0
  19. data/docs/generators.md +250 -0
  20. data/docs/outputs.md +158 -0
  21. data/docs/pundit-authorization.md +320 -0
  22. data/docs/quickstart.md +134 -0
  23. data/docs/readme.md +101 -0
  24. data/docs/recipes.md +14 -0
  25. data/docs/rubocop.md +285 -0
  26. data/docs/ruby-lsp.md +133 -0
  27. data/docs/service-rendering.md +222 -0
  28. data/docs/steps.md +391 -0
  29. data/docs/summary.md +21 -0
  30. data/docs/testing.md +549 -0
  31. data/lib/generators/light_services/install/USAGE +15 -0
  32. data/lib/generators/light_services/install/install_generator.rb +41 -0
  33. data/lib/generators/light_services/install/templates/application_service.rb.tt +8 -0
  34. data/lib/generators/light_services/install/templates/application_service_spec.rb.tt +7 -0
  35. data/lib/generators/light_services/install/templates/initializer.rb.tt +30 -0
  36. data/lib/generators/light_services/service/USAGE +21 -0
  37. data/lib/generators/light_services/service/service_generator.rb +68 -0
  38. data/lib/generators/light_services/service/templates/service.rb.tt +48 -0
  39. data/lib/generators/light_services/service/templates/service_spec.rb.tt +40 -0
  40. data/lib/light/services/base.rb +134 -122
  41. data/lib/light/services/base_with_context.rb +23 -1
  42. data/lib/light/services/callbacks.rb +157 -0
  43. data/lib/light/services/collection.rb +145 -0
  44. data/lib/light/services/concerns/execution.rb +79 -0
  45. data/lib/light/services/concerns/parent_service.rb +34 -0
  46. data/lib/light/services/concerns/state_management.rb +30 -0
  47. data/lib/light/services/config.rb +82 -16
  48. data/lib/light/services/constants.rb +100 -0
  49. data/lib/light/services/dsl/arguments_dsl.rb +85 -0
  50. data/lib/light/services/dsl/outputs_dsl.rb +81 -0
  51. data/lib/light/services/dsl/steps_dsl.rb +205 -0
  52. data/lib/light/services/dsl/validation.rb +162 -0
  53. data/lib/light/services/exceptions.rb +25 -2
  54. data/lib/light/services/message.rb +28 -3
  55. data/lib/light/services/messages.rb +92 -32
  56. data/lib/light/services/rspec/matchers/define_argument.rb +174 -0
  57. data/lib/light/services/rspec/matchers/define_output.rb +147 -0
  58. data/lib/light/services/rspec/matchers/define_step.rb +225 -0
  59. data/lib/light/services/rspec/matchers/execute_step.rb +230 -0
  60. data/lib/light/services/rspec/matchers/have_error_on.rb +148 -0
  61. data/lib/light/services/rspec/matchers/have_warning_on.rb +148 -0
  62. data/lib/light/services/rspec/matchers/trigger_callback.rb +138 -0
  63. data/lib/light/services/rspec.rb +15 -0
  64. data/lib/light/services/rubocop/cop/light_services/argument_type_required.rb +52 -0
  65. data/lib/light/services/rubocop/cop/light_services/condition_method_exists.rb +173 -0
  66. data/lib/light/services/rubocop/cop/light_services/deprecated_methods.rb +113 -0
  67. data/lib/light/services/rubocop/cop/light_services/dsl_order.rb +176 -0
  68. data/lib/light/services/rubocop/cop/light_services/missing_private_keyword.rb +102 -0
  69. data/lib/light/services/rubocop/cop/light_services/no_direct_instantiation.rb +66 -0
  70. data/lib/light/services/rubocop/cop/light_services/output_type_required.rb +52 -0
  71. data/lib/light/services/rubocop/cop/light_services/step_method_exists.rb +109 -0
  72. data/lib/light/services/rubocop.rb +12 -0
  73. data/lib/light/services/settings/field.rb +114 -0
  74. data/lib/light/services/settings/step.rb +53 -20
  75. data/lib/light/services/utils.rb +38 -0
  76. data/lib/light/services/version.rb +1 -1
  77. data/lib/light/services.rb +2 -0
  78. data/lib/ruby_lsp/light_services/addon.rb +36 -0
  79. data/lib/ruby_lsp/light_services/definition.rb +132 -0
  80. data/lib/ruby_lsp/light_services/indexing_enhancement.rb +263 -0
  81. data/light-services.gemspec +6 -8
  82. metadata +68 -26
  83. data/lib/light/services/class_based_collection/base.rb +0 -86
  84. data/lib/light/services/class_based_collection/mount.rb +0 -33
  85. data/lib/light/services/collection/arguments.rb +0 -34
  86. data/lib/light/services/collection/base.rb +0 -59
  87. data/lib/light/services/collection/outputs.rb +0 -16
  88. data/lib/light/services/settings/argument.rb +0 -68
  89. data/lib/light/services/settings/output.rb +0 -34
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module LightServices
6
+ # Ensures that all `arg` declarations in Light::Services 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 LightServices
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
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module LightServices
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
+ # Light::Services::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 Light::Services::Base inheritance
90
+ return true if parent_class_name == "Light::Services::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 Light::Services::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,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module LightServices
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
+ end
67
+
68
+ def check_order
69
+ highest_order_seen = -1
70
+ highest_method_seen = nil
71
+ has_offense = false
72
+
73
+ @dsl_calls.each do |call|
74
+ current_order = DSL_ORDER[call[:method]]
75
+
76
+ if current_order < highest_order_seen
77
+ has_offense = true
78
+ add_offense(
79
+ call[:node],
80
+ message: format(MSG, current: call[:method], previous: highest_method_seen),
81
+ ) do |corrector|
82
+ reorder_dsl_declarations(corrector) unless @corrected
83
+ @corrected = true
84
+ end
85
+ elsif current_order > highest_order_seen
86
+ highest_order_seen = current_order
87
+ highest_method_seen = call[:method]
88
+ end
89
+ end
90
+
91
+ @corrected = false if has_offense
92
+ end
93
+
94
+ def reorder_dsl_declarations(corrector) # rubocop:disable Metrics/AbcSize
95
+ # Collect all DSL nodes with their source including leading comments
96
+ dsl_sources = @dsl_calls.map do |call|
97
+ {
98
+ method: call[:method],
99
+ node: call[:node],
100
+ source: source_with_leading_comment(call[:node]),
101
+ }
102
+ end
103
+
104
+ # Sort by expected order
105
+ sorted_sources = dsl_sources.sort_by { |item| DSL_ORDER[item[:method]] }
106
+
107
+ # Group by type to add blank lines between groups
108
+ grouped_source = build_grouped_source(sorted_sources)
109
+
110
+ # Calculate the range to replace (from first DSL to last DSL)
111
+ first_node = @dsl_calls.min_by { |c| c[:node].loc.expression.begin_pos }[:node]
112
+ last_node = @dsl_calls.max_by { |c| c[:node].loc.expression.end_pos }[:node]
113
+
114
+ # Get the range including leading comments of first node
115
+ start_pos = first_node.loc.expression.begin_pos
116
+ leading_comment = leading_comment_for(first_node)
117
+ start_pos = leading_comment.loc.expression.begin_pos if leading_comment
118
+
119
+ # Find the beginning of the line for proper replacement
120
+ start_pos = beginning_of_line(start_pos)
121
+ end_pos = end_of_line(last_node.loc.expression.end_pos)
122
+
123
+ range = range_between(start_pos, end_pos)
124
+ corrector.replace(range, grouped_source)
125
+ end
126
+
127
+ def source_with_leading_comment(node)
128
+ comment = leading_comment_for(node)
129
+ indent = " " * node.loc.column
130
+
131
+ if comment
132
+ "#{indent}#{comment.text}\n#{indent}#{node.source}"
133
+ else
134
+ "#{indent}#{node.source}"
135
+ end
136
+ end
137
+
138
+ def leading_comment_for(node)
139
+ processed_source.comments.find do |comment|
140
+ comment.loc.line == node.loc.line - 1
141
+ end
142
+ end
143
+
144
+ def build_grouped_source(sorted_sources)
145
+ result = []
146
+ current_type = nil
147
+
148
+ sorted_sources.each do |item|
149
+ # Add blank line when switching to a new DSL type
150
+ result << "" if current_type && current_type != item[:method]
151
+ current_type = item[:method]
152
+ result << item[:source]
153
+ end
154
+
155
+ result.join("\n")
156
+ end
157
+
158
+ def beginning_of_line(pos)
159
+ source = processed_source.buffer.source
160
+ pos -= 1 while pos > 0 && source[pos - 1] != "\n"
161
+ pos
162
+ end
163
+
164
+ def end_of_line(pos)
165
+ source = processed_source.buffer.source
166
+ pos += 1 while pos < source.length && source[pos] != "\n"
167
+ pos
168
+ end
169
+
170
+ def range_between(start_pos, end_pos)
171
+ Parser::Source::Range.new(processed_source.buffer, start_pos, end_pos)
172
+ end
173
+ end
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module LightServices
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 LightServices
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