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.
- checksums.yaml +7 -0
- data/.cursor/rules/services/RULE.md +269 -0
- data/.cursor/rules/services-rspec/RULE.md +354 -0
- data/.github/dependabot.yml +11 -0
- data/.github/workflows/ci.yml +77 -0
- data/.gitignore +25 -0
- data/.rspec +3 -0
- data/.rubocop.yml +134 -0
- data/.ruby-version +1 -0
- data/.vscode/cspell.json +18 -0
- data/.vscode/project-words.txt +12 -0
- data/AGENTS.md +139 -0
- data/CHANGELOG.md +111 -0
- data/CLAUDE.md +139 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/Gemfile +28 -0
- data/Gemfile.lock +149 -0
- data/LICENSE.txt +21 -0
- data/README.md +172 -0
- data/Rakefile +8 -0
- data/bin/console +15 -0
- data/bin/setup +8 -0
- data/config/default.yml +57 -0
- data/docs/README.md +105 -0
- data/docs/SUMMARY.md +31 -0
- data/docs/arguments.md +275 -0
- data/docs/best-practices.md +153 -0
- data/docs/callbacks.md +475 -0
- data/docs/concepts.md +79 -0
- data/docs/configuration.md +218 -0
- data/docs/context.md +128 -0
- data/docs/crud.md +525 -0
- data/docs/errors.md +331 -0
- data/docs/generators.md +250 -0
- data/docs/outputs.md +150 -0
- data/docs/pundit-authorization.md +320 -0
- data/docs/quickstart.md +133 -0
- data/docs/recipes.md +14 -0
- data/docs/rubocop.md +430 -0
- data/docs/ruby-lsp.md +121 -0
- data/docs/service-rendering.md +222 -0
- data/docs/sorbet-runtime.md +283 -0
- data/docs/steps.md +438 -0
- data/docs/tapioca.md +188 -0
- data/docs/testing.md +548 -0
- data/lib/generators/operandi/install/USAGE +15 -0
- data/lib/generators/operandi/install/install_generator.rb +45 -0
- data/lib/generators/operandi/install/templates/application_service.rb.tt +8 -0
- data/lib/generators/operandi/install/templates/application_service_spec.rb.tt +7 -0
- data/lib/generators/operandi/install/templates/initializer.rb.tt +30 -0
- data/lib/generators/operandi/service/USAGE +21 -0
- data/lib/generators/operandi/service/service_generator.rb +80 -0
- data/lib/generators/operandi/service/templates/service.rb.tt +48 -0
- data/lib/generators/operandi/service/templates/service_spec.rb.tt +40 -0
- data/lib/operandi/base.rb +230 -0
- data/lib/operandi/base_with_context.rb +57 -0
- data/lib/operandi/callbacks.rb +353 -0
- data/lib/operandi/collection.rb +166 -0
- data/lib/operandi/concerns/execution.rb +80 -0
- data/lib/operandi/concerns/parent_service.rb +32 -0
- data/lib/operandi/concerns/state_management.rb +34 -0
- data/lib/operandi/config.rb +142 -0
- data/lib/operandi/constants.rb +96 -0
- data/lib/operandi/dsl/arguments_dsl.rb +83 -0
- data/lib/operandi/dsl/outputs_dsl.rb +79 -0
- data/lib/operandi/dsl/steps_dsl.rb +206 -0
- data/lib/operandi/dsl/validation.rb +171 -0
- data/lib/operandi/exceptions.rb +66 -0
- data/lib/operandi/message.rb +52 -0
- data/lib/operandi/messages.rb +185 -0
- data/lib/operandi/rspec/matchers/define_argument.rb +172 -0
- data/lib/operandi/rspec/matchers/define_output.rb +145 -0
- data/lib/operandi/rspec/matchers/define_step.rb +223 -0
- data/lib/operandi/rspec/matchers/execute_step.rb +228 -0
- data/lib/operandi/rspec/matchers/have_error_on.rb +144 -0
- data/lib/operandi/rspec/matchers/have_warning_on.rb +146 -0
- data/lib/operandi/rspec/matchers/trigger_callback.rb +136 -0
- data/lib/operandi/rspec.rb +15 -0
- data/lib/operandi/rubocop/cop/operandi/argument_type_required.rb +52 -0
- data/lib/operandi/rubocop/cop/operandi/condition_method_exists.rb +173 -0
- data/lib/operandi/rubocop/cop/operandi/deprecated_accessors.rb +113 -0
- data/lib/operandi/rubocop/cop/operandi/deprecated_methods.rb +113 -0
- data/lib/operandi/rubocop/cop/operandi/dsl_order.rb +181 -0
- data/lib/operandi/rubocop/cop/operandi/missing_private_keyword.rb +102 -0
- data/lib/operandi/rubocop/cop/operandi/no_direct_instantiation.rb +66 -0
- data/lib/operandi/rubocop/cop/operandi/no_hash_argument.rb +101 -0
- data/lib/operandi/rubocop/cop/operandi/output_type_required.rb +52 -0
- data/lib/operandi/rubocop/cop/operandi/prefer_fail_method.rb +112 -0
- data/lib/operandi/rubocop/cop/operandi/prefer_optional_over_default_nil.rb +124 -0
- data/lib/operandi/rubocop/cop/operandi/redundant_optional.rb +103 -0
- data/lib/operandi/rubocop/cop/operandi/reserved_name.rb +56 -0
- data/lib/operandi/rubocop/cop/operandi/step_method_exists.rb +134 -0
- data/lib/operandi/rubocop.rb +22 -0
- data/lib/operandi/settings/field.rb +147 -0
- data/lib/operandi/settings/step.rb +105 -0
- data/lib/operandi/utils.rb +36 -0
- data/lib/operandi/version.rb +5 -0
- data/lib/operandi.rb +11 -0
- data/lib/ruby_lsp/operandi/addon.rb +37 -0
- data/lib/ruby_lsp/operandi/definition.rb +134 -0
- data/lib/ruby_lsp/operandi/indexing_enhancement.rb +224 -0
- data/lib/tapioca/dsl/compilers/operandi.rb +378 -0
- data/operandi.gemspec +33 -0
- data/rbi/operandi.rbi +197 -0
- data/sorbet/cache/data.mdb +0 -0
- data/sorbet/cache/lock.mdb +0 -0
- 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
|