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