light-services 3.0.0 → 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.
- checksums.yaml +4 -4
- data/.rubocop.yml +7 -1
- data/CHANGELOG.md +15 -0
- data/CLAUDE.md +1 -1
- data/Gemfile.lock +1 -1
- data/README.md +11 -11
- data/docs/arguments.md +23 -0
- data/docs/concepts.md +2 -2
- data/docs/configuration.md +36 -0
- data/docs/errors.md +31 -1
- data/docs/outputs.md +23 -0
- data/docs/quickstart.md +1 -1
- data/docs/readme.md +12 -11
- data/docs/rubocop.md +285 -0
- data/docs/ruby-lsp.md +133 -0
- data/docs/steps.md +62 -8
- data/docs/summary.md +2 -0
- data/docs/testing.md +1 -1
- data/lib/light/services/base.rb +109 -7
- data/lib/light/services/base_with_context.rb +23 -1
- data/lib/light/services/callbacks.rb +59 -5
- data/lib/light/services/collection.rb +50 -2
- data/lib/light/services/concerns/execution.rb +3 -0
- data/lib/light/services/config.rb +83 -3
- data/lib/light/services/constants.rb +3 -0
- data/lib/light/services/dsl/arguments_dsl.rb +1 -0
- data/lib/light/services/dsl/outputs_dsl.rb +1 -0
- data/lib/light/services/dsl/validation.rb +30 -0
- data/lib/light/services/exceptions.rb +19 -1
- data/lib/light/services/message.rb +28 -3
- data/lib/light/services/messages.rb +74 -2
- data/lib/light/services/rubocop/cop/light_services/argument_type_required.rb +52 -0
- data/lib/light/services/rubocop/cop/light_services/condition_method_exists.rb +173 -0
- data/lib/light/services/rubocop/cop/light_services/deprecated_methods.rb +113 -0
- data/lib/light/services/rubocop/cop/light_services/dsl_order.rb +176 -0
- data/lib/light/services/rubocop/cop/light_services/missing_private_keyword.rb +102 -0
- data/lib/light/services/rubocop/cop/light_services/no_direct_instantiation.rb +66 -0
- data/lib/light/services/rubocop/cop/light_services/output_type_required.rb +52 -0
- data/lib/light/services/rubocop/cop/light_services/step_method_exists.rb +109 -0
- data/lib/light/services/rubocop.rb +12 -0
- data/lib/light/services/settings/field.rb +33 -5
- data/lib/light/services/settings/step.rb +23 -5
- data/lib/light/services/version.rb +1 -1
- data/lib/ruby_lsp/light_services/addon.rb +36 -0
- data/lib/ruby_lsp/light_services/definition.rb +132 -0
- data/lib/ruby_lsp/light_services/indexing_enhancement.rb +263 -0
- metadata +15 -1
|
@@ -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
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RuboCop
|
|
4
|
+
module Cop
|
|
5
|
+
module LightServices
|
|
6
|
+
# Ensures that all `output` declarations in Light::Services 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,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RuboCop
|
|
4
|
+
module Cop
|
|
5
|
+
module LightServices
|
|
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 the `ExcludedSteps` option or `rubocop:disable`
|
|
10
|
+
# 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 ExcludedSteps: ['initialize_entity', 'assign_attributes'] (default: [])
|
|
42
|
+
# # good - these steps are excluded from checking
|
|
43
|
+
# class User::Create < CreateService
|
|
44
|
+
# step :initialize_entity
|
|
45
|
+
# step :assign_attributes
|
|
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
|
+
class StepMethodExists < Base
|
|
56
|
+
MSG = "Step `%<name>s` has no corresponding method. " \
|
|
57
|
+
"For inherited steps, disable this line or add to ExcludedSteps."
|
|
58
|
+
|
|
59
|
+
def on_class(_node)
|
|
60
|
+
@step_calls = []
|
|
61
|
+
@defined_methods = []
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def on_send(node)
|
|
65
|
+
return unless step_call?(node)
|
|
66
|
+
|
|
67
|
+
step_name = node.arguments.first&.value
|
|
68
|
+
return unless step_name
|
|
69
|
+
|
|
70
|
+
@step_calls ||= []
|
|
71
|
+
@step_calls << { name: step_name, node: node }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def on_def(node)
|
|
75
|
+
@defined_methods ||= []
|
|
76
|
+
@defined_methods << node.method_name
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def after_class(_node)
|
|
80
|
+
return unless @step_calls&.any?
|
|
81
|
+
|
|
82
|
+
@step_calls.each do |step|
|
|
83
|
+
next if @defined_methods&.include?(step[:name])
|
|
84
|
+
next if excluded_step?(step[:name])
|
|
85
|
+
|
|
86
|
+
add_offense(step[:node], message: format(MSG, name: step[:name]))
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def step_call?(node)
|
|
93
|
+
node.send_type? &&
|
|
94
|
+
node.method_name == :step &&
|
|
95
|
+
node.receiver.nil? &&
|
|
96
|
+
node.arguments.first&.sym_type?
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def excluded_step?(step_name)
|
|
100
|
+
excluded_steps.include?(step_name.to_s)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def excluded_steps
|
|
104
|
+
cop_config.fetch("ExcludedSteps", [])
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rubocop"
|
|
4
|
+
|
|
5
|
+
require_relative "rubocop/cop/light_services/argument_type_required"
|
|
6
|
+
require_relative "rubocop/cop/light_services/condition_method_exists"
|
|
7
|
+
require_relative "rubocop/cop/light_services/deprecated_methods"
|
|
8
|
+
require_relative "rubocop/cop/light_services/dsl_order"
|
|
9
|
+
require_relative "rubocop/cop/light_services/missing_private_keyword"
|
|
10
|
+
require_relative "rubocop/cop/light_services/no_direct_instantiation"
|
|
11
|
+
require_relative "rubocop/cop/light_services/output_type_required"
|
|
12
|
+
require_relative "rubocop/cop/light_services/step_method_exists"
|
|
@@ -1,12 +1,36 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
# Unified settings class for arguments and outputs
|
|
4
3
|
module Light
|
|
5
4
|
module Services
|
|
6
5
|
module Settings
|
|
6
|
+
# Stores configuration for a single argument or output field.
|
|
7
|
+
# Created automatically when using the `arg` or `output` DSL methods.
|
|
7
8
|
class Field
|
|
8
|
-
|
|
9
|
-
|
|
9
|
+
# @return [Symbol] the field name
|
|
10
|
+
attr_reader :name
|
|
11
|
+
|
|
12
|
+
# @return [Boolean] true if a default value was specified
|
|
13
|
+
attr_reader :default_exists
|
|
14
|
+
|
|
15
|
+
# @return [Object, Proc, nil] the default value or proc
|
|
16
|
+
attr_reader :default
|
|
17
|
+
|
|
18
|
+
# @return [Boolean, nil] true if this is a context argument
|
|
19
|
+
attr_reader :context
|
|
20
|
+
|
|
21
|
+
# @return [Boolean, nil] true if nil values are allowed
|
|
22
|
+
attr_reader :optional
|
|
23
|
+
|
|
24
|
+
# Initialize a new field definition.
|
|
25
|
+
#
|
|
26
|
+
# @param name [Symbol] the field name
|
|
27
|
+
# @param service_class [Class] the service class this field belongs to
|
|
28
|
+
# @param opts [Hash] field options
|
|
29
|
+
# @option opts [Class, Array<Class>] :type type(s) to validate against
|
|
30
|
+
# @option opts [Boolean] :optional whether nil is allowed
|
|
31
|
+
# @option opts [Object, Proc] :default default value or proc
|
|
32
|
+
# @option opts [Boolean] :context whether to pass to child services
|
|
33
|
+
# @option opts [Symbol] :field_type :argument or :output
|
|
10
34
|
def initialize(name, service_class, opts = {})
|
|
11
35
|
@name = name
|
|
12
36
|
@service_class = service_class
|
|
@@ -21,8 +45,12 @@ module Light
|
|
|
21
45
|
define_methods
|
|
22
46
|
end
|
|
23
47
|
|
|
24
|
-
# Validate
|
|
25
|
-
#
|
|
48
|
+
# Validate a value against the field's type definition.
|
|
49
|
+
# Supports both Ruby class types and dry-types.
|
|
50
|
+
#
|
|
51
|
+
# @param value [Object] the value to validate
|
|
52
|
+
# @return [Object] the value (possibly coerced by dry-types)
|
|
53
|
+
# @raise [ArgTypeError] if the value doesn't match the expected type
|
|
26
54
|
def validate_type!(value)
|
|
27
55
|
return value unless @type
|
|
28
56
|
|