operandi 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. checksums.yaml +7 -0
  2. data/.cursor/rules/services/RULE.md +269 -0
  3. data/.cursor/rules/services-rspec/RULE.md +354 -0
  4. data/.github/dependabot.yml +11 -0
  5. data/.github/workflows/ci.yml +77 -0
  6. data/.gitignore +25 -0
  7. data/.rspec +3 -0
  8. data/.rubocop.yml +134 -0
  9. data/.ruby-version +1 -0
  10. data/.vscode/cspell.json +18 -0
  11. data/.vscode/project-words.txt +12 -0
  12. data/AGENTS.md +139 -0
  13. data/CHANGELOG.md +111 -0
  14. data/CLAUDE.md +139 -0
  15. data/CODE_OF_CONDUCT.md +74 -0
  16. data/Gemfile +28 -0
  17. data/Gemfile.lock +149 -0
  18. data/LICENSE.txt +21 -0
  19. data/README.md +172 -0
  20. data/Rakefile +8 -0
  21. data/bin/console +15 -0
  22. data/bin/setup +8 -0
  23. data/config/default.yml +57 -0
  24. data/docs/README.md +105 -0
  25. data/docs/SUMMARY.md +31 -0
  26. data/docs/arguments.md +275 -0
  27. data/docs/best-practices.md +153 -0
  28. data/docs/callbacks.md +475 -0
  29. data/docs/concepts.md +79 -0
  30. data/docs/configuration.md +218 -0
  31. data/docs/context.md +128 -0
  32. data/docs/crud.md +525 -0
  33. data/docs/errors.md +331 -0
  34. data/docs/generators.md +250 -0
  35. data/docs/outputs.md +150 -0
  36. data/docs/pundit-authorization.md +320 -0
  37. data/docs/quickstart.md +133 -0
  38. data/docs/recipes.md +14 -0
  39. data/docs/rubocop.md +430 -0
  40. data/docs/ruby-lsp.md +121 -0
  41. data/docs/service-rendering.md +222 -0
  42. data/docs/sorbet-runtime.md +283 -0
  43. data/docs/steps.md +438 -0
  44. data/docs/tapioca.md +188 -0
  45. data/docs/testing.md +548 -0
  46. data/lib/generators/operandi/install/USAGE +15 -0
  47. data/lib/generators/operandi/install/install_generator.rb +45 -0
  48. data/lib/generators/operandi/install/templates/application_service.rb.tt +8 -0
  49. data/lib/generators/operandi/install/templates/application_service_spec.rb.tt +7 -0
  50. data/lib/generators/operandi/install/templates/initializer.rb.tt +30 -0
  51. data/lib/generators/operandi/service/USAGE +21 -0
  52. data/lib/generators/operandi/service/service_generator.rb +80 -0
  53. data/lib/generators/operandi/service/templates/service.rb.tt +48 -0
  54. data/lib/generators/operandi/service/templates/service_spec.rb.tt +40 -0
  55. data/lib/operandi/base.rb +230 -0
  56. data/lib/operandi/base_with_context.rb +57 -0
  57. data/lib/operandi/callbacks.rb +353 -0
  58. data/lib/operandi/collection.rb +166 -0
  59. data/lib/operandi/concerns/execution.rb +80 -0
  60. data/lib/operandi/concerns/parent_service.rb +32 -0
  61. data/lib/operandi/concerns/state_management.rb +34 -0
  62. data/lib/operandi/config.rb +142 -0
  63. data/lib/operandi/constants.rb +96 -0
  64. data/lib/operandi/dsl/arguments_dsl.rb +83 -0
  65. data/lib/operandi/dsl/outputs_dsl.rb +79 -0
  66. data/lib/operandi/dsl/steps_dsl.rb +206 -0
  67. data/lib/operandi/dsl/validation.rb +171 -0
  68. data/lib/operandi/exceptions.rb +66 -0
  69. data/lib/operandi/message.rb +52 -0
  70. data/lib/operandi/messages.rb +185 -0
  71. data/lib/operandi/rspec/matchers/define_argument.rb +172 -0
  72. data/lib/operandi/rspec/matchers/define_output.rb +145 -0
  73. data/lib/operandi/rspec/matchers/define_step.rb +223 -0
  74. data/lib/operandi/rspec/matchers/execute_step.rb +228 -0
  75. data/lib/operandi/rspec/matchers/have_error_on.rb +144 -0
  76. data/lib/operandi/rspec/matchers/have_warning_on.rb +146 -0
  77. data/lib/operandi/rspec/matchers/trigger_callback.rb +136 -0
  78. data/lib/operandi/rspec.rb +15 -0
  79. data/lib/operandi/rubocop/cop/operandi/argument_type_required.rb +52 -0
  80. data/lib/operandi/rubocop/cop/operandi/condition_method_exists.rb +173 -0
  81. data/lib/operandi/rubocop/cop/operandi/deprecated_accessors.rb +113 -0
  82. data/lib/operandi/rubocop/cop/operandi/deprecated_methods.rb +113 -0
  83. data/lib/operandi/rubocop/cop/operandi/dsl_order.rb +181 -0
  84. data/lib/operandi/rubocop/cop/operandi/missing_private_keyword.rb +102 -0
  85. data/lib/operandi/rubocop/cop/operandi/no_direct_instantiation.rb +66 -0
  86. data/lib/operandi/rubocop/cop/operandi/no_hash_argument.rb +101 -0
  87. data/lib/operandi/rubocop/cop/operandi/output_type_required.rb +52 -0
  88. data/lib/operandi/rubocop/cop/operandi/prefer_fail_method.rb +112 -0
  89. data/lib/operandi/rubocop/cop/operandi/prefer_optional_over_default_nil.rb +124 -0
  90. data/lib/operandi/rubocop/cop/operandi/redundant_optional.rb +103 -0
  91. data/lib/operandi/rubocop/cop/operandi/reserved_name.rb +56 -0
  92. data/lib/operandi/rubocop/cop/operandi/step_method_exists.rb +134 -0
  93. data/lib/operandi/rubocop.rb +22 -0
  94. data/lib/operandi/settings/field.rb +147 -0
  95. data/lib/operandi/settings/step.rb +105 -0
  96. data/lib/operandi/utils.rb +36 -0
  97. data/lib/operandi/version.rb +5 -0
  98. data/lib/operandi.rb +11 -0
  99. data/lib/ruby_lsp/operandi/addon.rb +37 -0
  100. data/lib/ruby_lsp/operandi/definition.rb +134 -0
  101. data/lib/ruby_lsp/operandi/indexing_enhancement.rb +224 -0
  102. data/lib/tapioca/dsl/compilers/operandi.rb +378 -0
  103. data/operandi.gemspec +33 -0
  104. data/rbi/operandi.rbi +197 -0
  105. data/sorbet/cache/data.mdb +0 -0
  106. data/sorbet/cache/lock.mdb +0 -0
  107. metadata +151 -0
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Operandi
4
+ module Settings
5
+ # Stores configuration for a single argument or output field.
6
+ # Created automatically when using the `arg` or `output` DSL methods.
7
+ class Field
8
+ # @return [Symbol] the field name
9
+ attr_reader :name
10
+
11
+ # @return [Boolean] true if a default value was specified
12
+ attr_reader :default_exists
13
+
14
+ # @return [Object, Proc, nil] the default value or proc
15
+ attr_reader :default
16
+
17
+ # @return [Boolean, nil] true if this is a context argument
18
+ attr_reader :context
19
+
20
+ # @return [Boolean, nil] true if nil values are allowed
21
+ attr_reader :optional
22
+
23
+ # Initialize a new field definition.
24
+ #
25
+ # @param name [Symbol] the field name
26
+ # @param service_class [Class] the service class this field belongs to
27
+ # @param opts [Hash] field options
28
+ # @option opts [Class, Array<Class>] :type type(s) to validate against
29
+ # @option opts [Boolean] :optional whether nil is allowed
30
+ # @option opts [Object, Proc] :default default value or proc
31
+ # @option opts [Boolean] :context whether to pass to child services
32
+ # @option opts [Symbol] :field_type :argument or :output
33
+ def initialize(name, service_class, opts = {})
34
+ @name = name
35
+ @service_class = service_class
36
+ @field_type = opts.delete(:field_type) || :argument
37
+
38
+ @type = opts.delete(:type)
39
+ @context = opts.delete(:context)
40
+ @default_exists = opts.key?(:default)
41
+ @default = opts.delete(:default)
42
+ @optional = opts.delete(:optional)
43
+
44
+ define_methods
45
+ end
46
+
47
+ # Validate a value against the field's type definition.
48
+ # Supports Ruby class types and Sorbet runtime types.
49
+ #
50
+ # @param value [Object] the value to validate
51
+ # @return [Object] the validated value
52
+ # @raise [ArgTypeError] if the value doesn't match the expected type
53
+ def validate_type!(value)
54
+ return value unless @type
55
+
56
+ if sorbet_type?(@type) || (sorbet_available? && plain_class_type?(@type))
57
+ validate_sorbet_type!(value)
58
+ else
59
+ validate_ruby_type!(value)
60
+ value
61
+ end
62
+ end
63
+
64
+ FIELD_TYPE_TO_IVAR = {
65
+ FieldTypes::ARGUMENT => :@arg,
66
+ FieldTypes::OUTPUT => :@output,
67
+ }.freeze
68
+
69
+ private
70
+
71
+ # Check if sorbet-runtime is available
72
+ def sorbet_available?
73
+ defined?(T::Types::Base)
74
+ end
75
+
76
+ # Check if the type is a plain Ruby class (not a Sorbet type)
77
+ def plain_class_type?(type)
78
+ type.is_a?(Class) || type.is_a?(Module)
79
+ end
80
+
81
+ # Check if the type is a Sorbet runtime type
82
+ def sorbet_type?(type)
83
+ return false unless defined?(T::Types::Base)
84
+
85
+ type.is_a?(T::Types::Base)
86
+ end
87
+
88
+ # Validate value against Sorbet runtime types
89
+ # Note: Sorbet types only validate, they do not coerce values
90
+ # Automatically coerces plain Ruby classes to Sorbet types when needed
91
+ # @return [Object] the original value if valid
92
+ # @raise [ArgTypeError] if the value doesn't match the expected type
93
+ def validate_sorbet_type!(value)
94
+ sorbet_type = sorbet_type?(@type) ? @type : T::Utils.coerce(@type)
95
+ return value if sorbet_type.valid?(value)
96
+
97
+ enum_value = deserialize_enum_argument(value)
98
+ return enum_value if enum_value && sorbet_type.valid?(enum_value)
99
+
100
+ message = "#{@service_class} #{@field_type} `#{@name}` expected #{sorbet_type.name}, " \
101
+ "but got #{value.class} with value: #{value.inspect}"
102
+
103
+ raise Operandi::ArgTypeError.new(message, service_class: @service_class)
104
+ end
105
+
106
+ def deserialize_enum_argument(value)
107
+ return nil unless @field_type == FieldTypes::ARGUMENT
108
+ return nil unless sorbet_enum_class?(@type)
109
+
110
+ @type.try_deserialize(value)
111
+ end
112
+
113
+ def sorbet_enum_class?(type)
114
+ defined?(T::Enum) && type.is_a?(Class) && type < T::Enum
115
+ end
116
+
117
+ # Validate value against Ruby class types
118
+ def validate_ruby_type!(value)
119
+ return if [*@type].any? { |type| value.is_a?(type) }
120
+
121
+ raise Operandi::ArgTypeError.new(type_error_message(value), service_class: @service_class)
122
+ end
123
+
124
+ def type_error_message(value)
125
+ expected_types = [*@type].map(&:to_s).join(" or ")
126
+ "#{@service_class} #{@field_type} `#{@name}` must be #{expected_types}, \" \\
127
+ \"but got #{value.class} with value: #{value.inspect}"
128
+ end
129
+
130
+ def define_methods
131
+ name = @name
132
+ collection_instance_var = FIELD_TYPE_TO_IVAR.fetch(@field_type)
133
+
134
+ @service_class.define_method(@name) { instance_variable_get(collection_instance_var).get(name) }
135
+ @service_class.define_method(:"#{@name}?") { !!instance_variable_get(collection_instance_var).get(name) }
136
+ @service_class.define_method(:"#{@name}=") do |value|
137
+ instance_variable_get(collection_instance_var).set(name, value)
138
+ end
139
+ @service_class.send(:private, "#{@name}=")
140
+ end
141
+ end
142
+
143
+ # Aliases for backwards compatibility
144
+ Argument = Field
145
+ Output = Field
146
+ end
147
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Operandi
4
+ module Settings
5
+ # Stores configuration for a single service step.
6
+ # Created automatically when using the `step` DSL method.
7
+ class Step
8
+ # @return [Symbol] the step name (method to call)
9
+ attr_reader :name
10
+
11
+ # @return [Boolean, nil] true if step runs even after errors/warnings
12
+ attr_reader :always
13
+
14
+ # Initialize a new step definition.
15
+ #
16
+ # @param name [Symbol] the step name (must match a method)
17
+ # @param service_class [Class] the service class this step belongs to
18
+ # @param opts [Hash] step options
19
+ # @option opts [Symbol, Proc] :if condition to run the step
20
+ # @option opts [Symbol, Proc] :unless condition to skip the step
21
+ # @option opts [Boolean] :always run even after errors/warnings
22
+ # @raise [Error] if both :if and :unless are specified
23
+ def initialize(name, service_class, opts = {})
24
+ @name = name
25
+ @service_class = service_class
26
+
27
+ @if = opts[:if]
28
+ @unless = opts[:unless]
29
+ @always = opts[:always]
30
+
31
+ if @if && @unless
32
+ raise Operandi::Error, "#{service_class} `if` and `unless` cannot be specified " \
33
+ "for the step `#{name}` at the same time"
34
+ end
35
+ end
36
+
37
+ # Execute the step on the given service instance.
38
+ #
39
+ # @param instance [Base] the service instance
40
+ # @return [Boolean] true if the step was executed, false if skipped
41
+ # @raise [RuntimeError] if the step method is not defined
42
+ def run(instance) # rubocop:disable Naming/PredicateMethod
43
+ return false unless run?(instance)
44
+
45
+ unless instance.respond_to?(name, true)
46
+ available_steps = @service_class.steps.keys.join(", ")
47
+ message = "Step method `#{name}` is not defined in #{@service_class}. " \
48
+ "Defined steps: [#{available_steps}]"
49
+ raise Operandi::RuntimeError.new(message, service: instance)
50
+ end
51
+
52
+ execute_with_callbacks(instance)
53
+ true
54
+ end
55
+
56
+ private
57
+
58
+ def execute_with_callbacks(instance)
59
+ errors_count_before = instance.errors.count
60
+
61
+ instance.run_callbacks(:before_step_run, instance, name)
62
+
63
+ instance.run_callbacks(:around_step_run, instance, name) do
64
+ instance.send(name)
65
+ end
66
+
67
+ instance.run_callbacks(:after_step_run, instance, name)
68
+
69
+ if instance.errors.count > errors_count_before
70
+ instance.run_callbacks(:on_step_failure, instance, name)
71
+ else
72
+ instance.run_callbacks(:on_step_success, instance, name)
73
+ end
74
+ rescue StandardError => e
75
+ instance.run_callbacks(:on_step_crash, instance, name, e)
76
+ raise e
77
+ end
78
+
79
+ def run?(instance)
80
+ return false if instance.stopped?
81
+
82
+ if @if
83
+ check_condition(@if, instance)
84
+ elsif @unless
85
+ !check_condition(@unless, instance)
86
+ else
87
+ true
88
+ end
89
+ end
90
+
91
+ def check_condition(condition, instance)
92
+ case condition
93
+ when Symbol
94
+ instance.send(condition)
95
+ when Proc
96
+ instance.instance_exec(&condition)
97
+ else
98
+ message = "#{@service_class} condition should be a Symbol or Proc " \
99
+ "for the step `#{@name}` (currently: #{condition.class})"
100
+ raise Operandi::RuntimeError.new(message, service: instance)
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Operandi
4
+ # Utility module providing helper methods for the Operandi library
5
+ module Utils
6
+ module_function
7
+
8
+ # Creates a deep copy of an object to prevent mutation of shared references.
9
+ #
10
+ # @param object [Object] the object to duplicate
11
+ # @return [Object] a deep copy of the object
12
+ #
13
+ # @example Deep duping a hash
14
+ # original = { a: { b: 1 } }
15
+ # copy = Utils.deep_dup(original)
16
+ # copy[:a][:b] = 2
17
+ # original[:a][:b] # => 1
18
+ #
19
+ # @example Deep duping an array
20
+ # original = [[1, 2], [3, 4]]
21
+ # copy = Utils.deep_dup(original)
22
+ # copy[0] << 5
23
+ # original[0] # => [1, 2]
24
+ #
25
+ def deep_dup(object)
26
+ # Use ActiveSupport's deep_dup if available (preferred for Rails apps)
27
+ return object.deep_dup if object.respond_to?(:deep_dup)
28
+
29
+ # Fallback to Marshal for objects that support serialization
30
+ Marshal.load(Marshal.dump(object))
31
+ rescue TypeError
32
+ # Last resort: use dup if available, otherwise return original
33
+ object.respond_to?(:dup) ? object.dup : object
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Operandi
4
+ VERSION = "5.0.0"
5
+ end
data/lib/operandi.rb ADDED
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "operandi/config"
4
+ require "operandi/version"
5
+ require "operandi/exceptions"
6
+ require "operandi/utils"
7
+ require "operandi/callbacks"
8
+ require "operandi/base"
9
+
10
+ module Operandi
11
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../operandi/version"
4
+ require_relative "indexing_enhancement"
5
+ require_relative "definition"
6
+
7
+ # Declare version compatibility without runtime dependency on ruby-lsp
8
+ RubyLsp::Addon.depend_on_ruby_lsp!("~> 0.26")
9
+
10
+ module RubyLsp
11
+ module Operandi
12
+ class Addon < ::RubyLsp::Addon
13
+ def activate(global_state, message_queue)
14
+ @global_state = global_state
15
+ @message_queue = message_queue
16
+ end
17
+
18
+ def deactivate; end
19
+
20
+ def name
21
+ "Ruby LSP Operandi"
22
+ end
23
+
24
+ def version
25
+ ::Operandi::VERSION
26
+ end
27
+
28
+ # Called on every "go to definition" request
29
+ # Provides navigation from step DSL symbols to their method definitions
30
+ def create_definition_listener(response_builder, uri, node_context, dispatcher)
31
+ return unless @global_state
32
+
33
+ Definition.new(response_builder, uri, node_context, @global_state.index, dispatcher)
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLsp
4
+ module Operandi
5
+ class Definition
6
+ # Condition options that reference methods
7
+ CONDITION_OPTIONS = [:if, :unless].freeze
8
+
9
+ def initialize(response_builder, uri, node_context, index, dispatcher)
10
+ @response_builder = response_builder
11
+ @uri = uri
12
+ @node_context = node_context
13
+ @index = index
14
+
15
+ # Register for symbol nodes - this is what gets triggered when user clicks on :method_name
16
+ dispatcher.register(self, :on_symbol_node_enter)
17
+ end
18
+
19
+ # Called when cursor is on a symbol node (e.g., :validate in `step :validate`)
20
+ def on_symbol_node_enter(node)
21
+ # Check if this symbol is part of a step call by examining the call context
22
+ call_node = find_parent_step_call
23
+ return unless call_node
24
+
25
+ method_name = determine_method_name(node, call_node)
26
+ return unless method_name
27
+
28
+ find_and_append_method_location(method_name)
29
+ end
30
+
31
+ private
32
+
33
+ # Find the parent step call node from the node context
34
+ # The node_context.call_node returns the enclosing call if cursor is on an argument
35
+ def find_parent_step_call
36
+ call_node = @node_context.call_node
37
+ return unless call_node
38
+ return unless call_node.name == :step
39
+
40
+ call_node
41
+ end
42
+
43
+ # Determine which method name to look up based on where the symbol appears
44
+ # Returns nil if this symbol is not a method reference we should handle
45
+ def determine_method_name(symbol_node, call_node)
46
+ symbol_value = symbol_node.value.to_sym
47
+
48
+ # Check if this is the first argument (step method name)
49
+ first_arg = call_node.arguments&.arguments&.first
50
+ if first_arg.is_a?(Prism::SymbolNode) && first_arg.value.to_sym == symbol_value && same_location?(
51
+ first_arg,
52
+ symbol_node,
53
+ )
54
+ # Verify the symbol node location matches (same node, not just same value)
55
+ return symbol_value.to_s
56
+ end
57
+
58
+ # Check if this is a condition option (if: or unless:)
59
+ keyword_hash = find_keyword_hash(call_node)
60
+ return unless keyword_hash
61
+
62
+ CONDITION_OPTIONS.each do |option_name|
63
+ condition_symbol = find_symbol_option(keyword_hash, option_name)
64
+ next unless condition_symbol
65
+ next unless same_location?(condition_symbol, symbol_node)
66
+
67
+ return condition_symbol.value.to_s
68
+ end
69
+
70
+ nil
71
+ end
72
+
73
+ # Check if two nodes have the same location (are the same node)
74
+ def same_location?(node1, node2)
75
+ node1.location.start_offset == node2.location.start_offset &&
76
+ node1.location.end_offset == node2.location.end_offset
77
+ end
78
+
79
+ # Find the keyword hash in call arguments
80
+ def find_keyword_hash(node)
81
+ node.arguments&.arguments&.find { |arg| arg.is_a?(Prism::KeywordHashNode) }
82
+ end
83
+
84
+ # Find a symbol value for a specific option in the keyword hash
85
+ # Returns the SymbolNode if found and value is a symbol, nil otherwise
86
+ def find_symbol_option(keyword_hash, option_name)
87
+ element = keyword_hash.elements.find do |elem|
88
+ elem.is_a?(Prism::AssocNode) &&
89
+ elem.key.is_a?(Prism::SymbolNode) &&
90
+ elem.key.value.to_sym == option_name
91
+ end
92
+
93
+ return unless element
94
+ return unless element.value.is_a?(Prism::SymbolNode)
95
+
96
+ element.value
97
+ end
98
+
99
+ # Find method definition in index and append location to response
100
+ def find_and_append_method_location(method_name)
101
+ owner_name = @node_context.nesting.join("::")
102
+ return if owner_name.empty?
103
+
104
+ # Look up method entries in the index
105
+ method_entries = @index.resolve_method(method_name, owner_name)
106
+ return unless method_entries&.any?
107
+
108
+ method_entries.each { |entry| append_location(entry) }
109
+
110
+ true
111
+ end
112
+
113
+ def append_location(entry)
114
+ @response_builder << Interface::Location.new(
115
+ uri: URI::Generic.from_path(path: entry.file_path).to_s,
116
+ range: build_range(entry.location),
117
+ )
118
+ end
119
+
120
+ def build_range(location)
121
+ Interface::Range.new(
122
+ start: Interface::Position.new(
123
+ line: location.start_line - 1,
124
+ character: location.start_column,
125
+ ),
126
+ end: Interface::Position.new(
127
+ line: location.end_line - 1,
128
+ character: location.end_column,
129
+ ),
130
+ )
131
+ end
132
+ end
133
+ end
134
+ end