typed_operation 1.0.0.beta3 → 1.0.0.beta4

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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +76 -724
  3. data/lib/generators/typed_operation/install/install_generator.rb +3 -0
  4. data/lib/generators/typed_operation_generator.rb +8 -4
  5. data/lib/typed_operation/action_policy_auth.rb +44 -24
  6. data/lib/typed_operation/base.rb +4 -1
  7. data/lib/typed_operation/callable_resolver.rb +30 -0
  8. data/lib/typed_operation/chains/chained_operation.rb +27 -0
  9. data/lib/typed_operation/chains/fallback_chain.rb +32 -0
  10. data/lib/typed_operation/chains/map_chain.rb +37 -0
  11. data/lib/typed_operation/chains/sequence_chain.rb +54 -0
  12. data/lib/typed_operation/chains/smart_chain.rb +161 -0
  13. data/lib/typed_operation/chains/splat_chain.rb +53 -0
  14. data/lib/typed_operation/configuration.rb +52 -0
  15. data/lib/typed_operation/context.rb +193 -0
  16. data/lib/typed_operation/curried.rb +14 -1
  17. data/lib/typed_operation/explainable.rb +14 -0
  18. data/lib/typed_operation/immutable_base.rb +4 -1
  19. data/lib/typed_operation/instrumentation/trace.rb +71 -0
  20. data/lib/typed_operation/instrumentation/tree_formatter.rb +141 -0
  21. data/lib/typed_operation/instrumentation.rb +214 -0
  22. data/lib/typed_operation/operations/composition.rb +41 -0
  23. data/lib/typed_operation/operations/executable.rb +27 -1
  24. data/lib/typed_operation/operations/introspection.rb +9 -1
  25. data/lib/typed_operation/operations/lifecycle.rb +4 -1
  26. data/lib/typed_operation/operations/parameters.rb +11 -5
  27. data/lib/typed_operation/operations/partial_application.rb +4 -0
  28. data/lib/typed_operation/operations/property_builder.rb +46 -22
  29. data/lib/typed_operation/partially_applied.rb +33 -10
  30. data/lib/typed_operation/pipeline/builder.rb +88 -0
  31. data/lib/typed_operation/pipeline/chainable_wrapper.rb +23 -0
  32. data/lib/typed_operation/pipeline/empty_pipeline_chain.rb +25 -0
  33. data/lib/typed_operation/pipeline/step_wrapper.rb +94 -0
  34. data/lib/typed_operation/pipeline.rb +176 -0
  35. data/lib/typed_operation/prepared.rb +13 -0
  36. data/lib/typed_operation/railtie.rb +4 -0
  37. data/lib/typed_operation/result/adapters/built_in.rb +28 -0
  38. data/lib/typed_operation/result/adapters/dry_monads.rb +36 -0
  39. data/lib/typed_operation/result/failure.rb +78 -0
  40. data/lib/typed_operation/result/mixin.rb +24 -0
  41. data/lib/typed_operation/result/success.rb +75 -0
  42. data/lib/typed_operation/result.rb +39 -0
  43. data/lib/typed_operation/version.rb +5 -1
  44. data/lib/typed_operation.rb +18 -1
  45. metadata +27 -3
  46. data/lib/typed_operation/operations/callable.rb +0 -23
@@ -0,0 +1,193 @@
1
+ # rbs_inline: enabled
2
+ # frozen_string_literal: true
3
+
4
+ module TypedOperation
5
+ # Context objects are for passing data through pipelines and chains.
6
+ #
7
+ # This particular implementation wraps a Hash but provides dot access for cleaner syntax.
8
+ # Its underlying store is mutable.
9
+ #
10
+ # Users can subclass or provide their own context that implements the same interface.
11
+ #
12
+ # Required interface for custom contexts:
13
+ # - #[](key) - read a value
14
+ # - #[]=(key, value) - write a value (if mutable)
15
+ # - #key?(key) - check if key exists
16
+ # - #to_h - convert to Hash (for kwargs extraction)
17
+ # - #merge(other) - combine with another context/hash, returns new context
18
+ #
19
+ # Dot access is not required. It's up to you if you prefer to have it in your operation usage.
20
+ #
21
+ class Context
22
+ # @rbs @data: Hash[Symbol, untyped]
23
+
24
+ #: (?Hash[Symbol | String, untyped] | Context | _ToH) -> void
25
+ def initialize(data = {})
26
+ @data = coerce_to_hash(data).transform_keys(&:to_sym)
27
+ end
28
+
29
+ #: (Symbol | String) -> untyped
30
+ def [](key)
31
+ read_value(key.to_sym)
32
+ end
33
+
34
+ #: (Symbol | String, untyped) -> untyped
35
+ def []=(key, value)
36
+ write_value(key.to_sym, value)
37
+ end
38
+
39
+ #: (Symbol | String) -> bool
40
+ def key?(key)
41
+ @data.key?(key.to_sym)
42
+ end
43
+ alias_method :has_key?, :key?
44
+
45
+ #: () -> Hash[Symbol, untyped]
46
+ def to_h
47
+ @data.dup
48
+ end
49
+ alias_method :to_hash, :to_h
50
+
51
+ #: (Hash[Symbol | String, untyped] | Context | _ToH) -> Context
52
+ def merge(other)
53
+ self.class.new(@data.merge(coerce_to_hash(other)))
54
+ end
55
+
56
+ #: (Symbol | String) -> untyped
57
+ #: [T] (Symbol | String, T) -> (untyped | T)
58
+ #: [T] (Symbol | String) { (Symbol) -> T } -> (untyped | T)
59
+ def fetch(key, *args, &block)
60
+ @data.fetch(key.to_sym, *args, &block)
61
+ end
62
+
63
+ #: () -> Array[Symbol]
64
+ def keys
65
+ @data.keys
66
+ end
67
+
68
+ #: () -> Array[untyped]
69
+ def values
70
+ @data.values
71
+ end
72
+
73
+ #: () -> Enumerator[[Symbol, untyped], self]
74
+ #: () { ([Symbol, untyped]) -> void } -> self
75
+ def each(&block)
76
+ return enum_for(:each) unless block_given?
77
+ @data.each(&block)
78
+ self
79
+ end
80
+
81
+ #: () { (Symbol, untyped) -> boolish } -> Context
82
+ def select(&block)
83
+ self.class.new(@data.select(&block))
84
+ end
85
+
86
+ #: () { (Symbol, untyped) -> boolish } -> Context
87
+ def reject(&block)
88
+ self.class.new(@data.reject(&block))
89
+ end
90
+
91
+ #: () { (untyped) -> untyped } -> Context
92
+ def transform_values(&block)
93
+ self.class.new(@data.transform_values(&block))
94
+ end
95
+
96
+ #: (*(Symbol | String)) -> Context
97
+ def slice(*keys)
98
+ self.class.new(@data.slice(*keys.map(&:to_sym)))
99
+ end
100
+
101
+ #: (*(Symbol | String)) -> Context
102
+ def except(*keys)
103
+ self.class.new(@data.except(*keys.map(&:to_sym)))
104
+ end
105
+
106
+ #: () -> bool
107
+ def empty?
108
+ @data.empty?
109
+ end
110
+
111
+ #: () -> Integer
112
+ def size
113
+ @data.size
114
+ end
115
+ alias_method :length, :size
116
+
117
+ #: (untyped) -> bool
118
+ def ==(other)
119
+ case other
120
+ when Context
121
+ @data == other.to_h
122
+ when Hash
123
+ @data == other.transform_keys(&:to_sym)
124
+ else
125
+ false
126
+ end
127
+ end
128
+
129
+ # Provide dot access for reading and writing values.
130
+ #: (Symbol, *untyped) ?{ (*untyped) -> untyped } -> untyped
131
+ def method_missing(name, *args, &block)
132
+ name_str = name.to_s
133
+
134
+ if name_str.end_with?("=")
135
+ key = name_str.chomp("=").to_sym
136
+ write_value(key, args.first)
137
+ elsif name_str.end_with?("?")
138
+ key = name_str.chomp("?").to_sym
139
+ !!read_value(key)
140
+ elsif args.empty? && !block
141
+ read_value(name)
142
+ else
143
+ super
144
+ end
145
+ end
146
+
147
+ #: (Symbol, ?bool) -> bool
148
+ def respond_to_missing?(name, include_private = false)
149
+ name_str = name.to_s
150
+ name_str.end_with?("=", "?") ||
151
+ key?(name) ||
152
+ super
153
+ end
154
+
155
+ #: () -> String
156
+ def inspect
157
+ "#<#{self.class.name} #{@data.inspect}>"
158
+ end
159
+
160
+ alias_method :to_s, :inspect
161
+
162
+ private
163
+
164
+ #: (Symbol) -> untyped
165
+ def read_value(key)
166
+ @data[key]
167
+ end
168
+
169
+ #: (Symbol, untyped) -> untyped
170
+ def write_value(key, value)
171
+ @data[key] = value
172
+ end
173
+
174
+ # @rbs!
175
+ # interface _ToH
176
+ # def to_h: () -> Hash[untyped, untyped]
177
+ # end
178
+
179
+ #: (untyped) -> Hash[untyped, untyped]
180
+ def coerce_to_hash(obj)
181
+ case obj
182
+ when Hash
183
+ obj
184
+ when Context
185
+ obj.to_h
186
+ when ->(o) { o.respond_to?(:to_h) }
187
+ obj.to_h
188
+ else
189
+ raise ArgumentError, "Cannot coerce #{obj.class} to Hash"
190
+ end
191
+ end
192
+ end
193
+ end
@@ -1,19 +1,29 @@
1
+ # rbs_inline: enabled
1
2
  # frozen_string_literal: true
2
3
 
3
4
  module TypedOperation
5
+ # Wraps an operation to support automatic currying, allowing parameters to be
6
+ # provided one at a time. Returns a new Curried instance until all required
7
+ # parameters are satisfied, then executes the operation.
4
8
  class Curried
9
+ # @rbs @operation_class: untyped
10
+ # @rbs @partial_operation: PartiallyApplied | Prepared
11
+
12
+ #: (untyped, ?(PartiallyApplied | Prepared)) -> void
5
13
  def initialize(operation_class, partial_operation = nil)
6
14
  @operation_class = operation_class
7
15
  @partial_operation = partial_operation || operation_class.with
8
16
  end
9
17
 
18
+ #: (untyped) -> (Curried | untyped)
10
19
  def call(arg)
11
20
  raise ArgumentError, "A prepared operation should not be curried" if @partial_operation.prepared?
12
21
 
13
22
  next_partially_applied = if next_parameter_positional?
14
23
  @partial_operation.with(arg)
15
24
  else
16
- @partial_operation.with(next_keyword_parameter => arg)
25
+ key = next_keyword_parameter or raise ArgumentError, "No keyword parameter available"
26
+ @partial_operation.with(key => arg)
17
27
  end
18
28
  if next_partially_applied.prepared?
19
29
  next_partially_applied.call
@@ -22,17 +32,20 @@ module TypedOperation
22
32
  end
23
33
  end
24
34
 
35
+ #: () -> Proc
25
36
  def to_proc
26
37
  method(:call).to_proc
27
38
  end
28
39
 
29
40
  private
30
41
 
42
+ #: () -> Symbol?
31
43
  def next_keyword_parameter
32
44
  remaining = @operation_class.required_keyword_parameters - @partial_operation.keyword_args.keys
33
45
  remaining.first
34
46
  end
35
47
 
48
+ #: () -> bool
36
49
  def next_parameter_positional?
37
50
  @partial_operation.positional_args.size < @operation_class.required_positional_parameters.size
38
51
  end
@@ -0,0 +1,14 @@
1
+ # rbs_inline: enabled
2
+ # frozen_string_literal: true
3
+
4
+ module TypedOperation
5
+ # Include this module in your operation base class to enable the `.explain` method
6
+ # for runtime tracing and debugging.
7
+ module Explainable
8
+ #: (Module) -> void
9
+ def self.included(base)
10
+ base.extend(Instrumentation::Explainable)
11
+ base.prepend(Instrumentation::Traceable)
12
+ end
13
+ end
14
+ end
@@ -1,13 +1,16 @@
1
+ # rbs_inline: enabled
1
2
  # frozen_string_literal: true
2
3
 
3
4
  module TypedOperation
5
+ # Immutable base class for operations, built on Literal::Data.
6
+ # Use this when operation state should not be modified after initialization.
4
7
  class ImmutableBase < Literal::Data
5
8
  extend Operations::Introspection
6
9
  extend Operations::Parameters
7
10
  extend Operations::PartialApplication
11
+ extend Operations::Composition
8
12
 
9
13
  include Operations::Lifecycle
10
- include Operations::Callable
11
14
  include Operations::Executable
12
15
  end
13
16
  end
@@ -0,0 +1,71 @@
1
+ # rbs_inline: enabled
2
+ # frozen_string_literal: true
3
+
4
+ module TypedOperation
5
+ module Instrumentation
6
+ # Represents a single operation execution trace with timing and result data.
7
+ # Supports nested traces for operations that call other operations.
8
+ class Trace
9
+ attr_reader :operation_class #: untyped
10
+ attr_reader :params #: Hash[Symbol, untyped]
11
+ attr_reader :start_time #: Float
12
+ attr_reader :children #: Array[Trace]
13
+ attr_accessor :end_time #: Float?
14
+ attr_accessor :result #: untyped
15
+ attr_accessor :exception #: Exception?
16
+ attr_accessor :pass_mode #: Symbol?
17
+ attr_accessor :extracted_params #: Array[Symbol]?
18
+ attr_accessor :fallback_used #: bool
19
+
20
+ #: (operation_class: untyped, ?params: Hash[Symbol, untyped]) -> void
21
+ def initialize(operation_class:, params: {})
22
+ @operation_class = operation_class
23
+ @params = params
24
+ @start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
25
+ @end_time = nil
26
+ @result = nil
27
+ @exception = nil
28
+ @children = []
29
+ @pass_mode = nil
30
+ @extracted_params = nil
31
+ @fallback_used = false
32
+ end
33
+
34
+ #: (?result: untyped, ?exception: Exception?) -> self
35
+ def finish!(result: nil, exception: nil)
36
+ @end_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
37
+ @result = result
38
+ @exception = exception
39
+ self
40
+ end
41
+
42
+ #: () -> (Float | Integer | nil)
43
+ def duration_ms
44
+ return nil unless end_time
45
+ ((end_time - start_time) * 1000).round(2)
46
+ end
47
+
48
+ #: () -> bool
49
+ def success?
50
+ return false if exception
51
+ return result.success? if result.respond_to?(:success?)
52
+ true
53
+ end
54
+
55
+ #: () -> bool
56
+ def failure?
57
+ !success?
58
+ end
59
+
60
+ #: (Trace) -> void
61
+ def add_child(trace)
62
+ @children << trace
63
+ end
64
+
65
+ #: () -> String
66
+ def operation_name
67
+ operation_class.name || operation_class.to_s
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,141 @@
1
+ # rbs_inline: enabled
2
+ # frozen_string_literal: true
3
+
4
+ module TypedOperation
5
+ module Instrumentation
6
+ # Formats execution traces as a visual chain for console output.
7
+ # Shows operation flow with arrows and pass mode indicators.
8
+ class TreeFormatter
9
+ ARROW = "→" #: String
10
+ FALLBACK_ARROW = "⤷" #: String
11
+
12
+ SUCCESS_MARK = "✓" #: String
13
+ FAILURE_MARK = "✗" #: String
14
+ SKIPPED_MARK = "○" #: String
15
+
16
+ PASS_MODES = {
17
+ kwargs_splat: "**ctx",
18
+ single_value: "ctx",
19
+ transform: "transform",
20
+ fallback: "fallback"
21
+ }.freeze #: Hash[Symbol, String]
22
+
23
+ COLORS = {
24
+ green: "\e[32m",
25
+ red: "\e[31m",
26
+ yellow: "\e[33m",
27
+ cyan: "\e[36m",
28
+ dim: "\e[2m",
29
+ bold: "\e[1m",
30
+ reset: "\e[0m"
31
+ }.freeze #: Hash[Symbol, String]
32
+
33
+ # @rbs @color: bool
34
+
35
+ #: (?color: bool?) -> void
36
+ def initialize(color: nil)
37
+ @color = color.nil? ? detect_color_support : color
38
+ end
39
+
40
+ #: (Trace) -> String
41
+ def format(trace)
42
+ lines = [] #: Array[String]
43
+ format_chain(trace, lines)
44
+ lines.join("\n")
45
+ end
46
+
47
+ private
48
+
49
+ #: (Trace, Array[String], ?depth: Integer, ?is_fallback: bool) -> void
50
+ def format_chain(trace, lines, depth: 0, is_fallback: false)
51
+ indent = " " * depth
52
+
53
+ status = format_status(trace)
54
+ duration = format_duration(trace.duration_ms)
55
+ pass_info = format_pass_mode(trace)
56
+
57
+ op_name = trace.operation_name
58
+ if is_fallback
59
+ op_name = "#{colorize(FALLBACK_ARROW, :yellow)} #{op_name}"
60
+ end
61
+
62
+ line = "#{indent}#{op_name} #{duration} #{status}"
63
+
64
+ if pass_info
65
+ line = "#{line} #{pass_info}"
66
+ end
67
+
68
+ extracted = trace.extracted_params
69
+ if extracted&.any?
70
+ params_str = extracted.join(", ")
71
+ line = "#{line} #{colorize("← [#{params_str}]", :dim)}"
72
+ end
73
+
74
+ lines << line
75
+
76
+ if trace.exception
77
+ lines << "#{indent} #{colorize("└ Exception: #{trace.exception.class}: #{trace.exception.message}", :red)}"
78
+ elsif trace.failure? && trace.result.respond_to?(:failure)
79
+ failure_info = trace.result.failure
80
+ lines << "#{indent} #{colorize("└ Failure: #{failure_info.inspect}", :red)}"
81
+ end
82
+
83
+ trace.children.each_with_index do |child, index|
84
+ is_child_fallback = child.fallback_used
85
+
86
+ if index == 0 || is_child_fallback
87
+ arrow = is_child_fallback ? FALLBACK_ARROW : ARROW
88
+ arrow_color = is_child_fallback ? :yellow : :dim
89
+ lines << "#{indent} #{colorize(arrow, arrow_color)}"
90
+ else
91
+ lines << "#{indent} #{colorize(ARROW, :dim)}"
92
+ end
93
+
94
+ format_chain(child, lines, depth: depth, is_fallback: is_child_fallback)
95
+ end
96
+ end
97
+
98
+ #: (Trace) -> String
99
+ def format_status(trace)
100
+ if trace.exception
101
+ colorize(FAILURE_MARK, :red)
102
+ elsif trace.failure?
103
+ colorize(FAILURE_MARK, :red)
104
+ else
105
+ colorize(SUCCESS_MARK, :green)
106
+ end
107
+ end
108
+
109
+ #: (Float | Integer | nil) -> String
110
+ def format_duration(ms)
111
+ return colorize("[--ms]", :dim) unless ms
112
+ colorize("[#{ms}ms]", :dim)
113
+ end
114
+
115
+ #: (Trace) -> String?
116
+ def format_pass_mode(trace)
117
+ return nil unless trace.pass_mode
118
+ mode_str = PASS_MODES[trace.pass_mode] || trace.pass_mode.to_s
119
+ colorize("(#{mode_str})", :cyan)
120
+ end
121
+
122
+ #: (Exception?) -> String
123
+ def format_exception(exception)
124
+ return "" unless exception
125
+ colorize("(#{exception.class}: #{exception.message})", :red)
126
+ end
127
+
128
+ #: (String, Symbol) -> String
129
+ def colorize(text, color)
130
+ return text unless @color
131
+ "#{COLORS[color]}#{text}#{COLORS[:reset]}"
132
+ end
133
+
134
+ #: () -> bool
135
+ def detect_color_support
136
+ return false unless $stdout.respond_to?(:tty?)
137
+ $stdout.tty? && ENV["TERM"] != "dumb" && !ENV["NO_COLOR"]
138
+ end
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,214 @@
1
+ # rbs_inline: enabled
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "instrumentation/trace"
5
+ require_relative "instrumentation/tree_formatter"
6
+
7
+ module TypedOperation
8
+ # Provides runtime tracing and debugging capabilities for operations.
9
+ #
10
+ # Usage:
11
+ # # Trace a single operation call
12
+ # result = MyOperation.explain(param: value)
13
+ # # Prints execution tree to stdout, returns the operation result
14
+ #
15
+ # # Trace with partial application
16
+ # result = MyOperation.with(partial: value).explain(rest: value)
17
+ #
18
+ # # Trace a block of code (captures all instrumented operations)
19
+ # TypedOperation::Instrumentation.explaining do
20
+ # MyOperation.call(params)
21
+ # AnotherOperation.call(other_params)
22
+ # end
23
+ #
24
+ # # Configure output and color
25
+ # TypedOperation::Instrumentation.with_output(my_io, color: false) do
26
+ # MyOperation.explain(param: value)
27
+ # end
28
+ #
29
+ module Instrumentation
30
+ THREAD_KEY = :typed_operation_trace_stack #: Symbol
31
+ CHAIN_CONTEXT_KEY = :typed_operation_chain_context #: Symbol
32
+ OUTPUT_KEY = :typed_operation_output #: Symbol
33
+ COLOR_KEY = :typed_operation_color #: Symbol
34
+
35
+ class << self
36
+ # Execute a block with custom output and color settings.
37
+ #: [T] (IO, ?color: bool?) { () -> T } -> T
38
+ def with_output(io, color: nil, &block)
39
+ old_output = Thread.current[OUTPUT_KEY]
40
+ old_color = Thread.current[COLOR_KEY]
41
+ Thread.current[OUTPUT_KEY] = io
42
+ Thread.current[COLOR_KEY] = color
43
+ yield
44
+ ensure
45
+ Thread.current[OUTPUT_KEY] = old_output
46
+ Thread.current[COLOR_KEY] = old_color
47
+ end
48
+
49
+ # Execute a block with tracing enabled, printing the trace tree afterward.
50
+ #: [T] () { () -> T } -> T
51
+ def explaining(&block)
52
+ trace_root = Trace.new(operation_class: BlockExecution, params: {})
53
+ push_trace(trace_root)
54
+
55
+ result = nil
56
+ exception = nil
57
+ begin
58
+ result = block.call
59
+ rescue => e
60
+ exception = e
61
+ end
62
+
63
+ trace_root.finish!(result: result, exception: exception)
64
+ pop_trace
65
+
66
+ formatter = TreeFormatter.new(color: color)
67
+ trace_root.children.each do |child_trace|
68
+ output.puts formatter.format(child_trace)
69
+ end
70
+
71
+ raise exception if exception
72
+ result
73
+ end
74
+
75
+ #: () -> IO
76
+ def output
77
+ Thread.current[OUTPUT_KEY] || $stdout
78
+ end
79
+
80
+ #: () -> bool
81
+ def color
82
+ val = Thread.current[COLOR_KEY]
83
+ val.nil? || val
84
+ end
85
+
86
+ #: () -> Trace?
87
+ def current_trace
88
+ stack = Thread.current[THREAD_KEY]
89
+ stack&.last
90
+ end
91
+
92
+ #: () -> bool
93
+ def tracing?
94
+ !current_trace.nil?
95
+ end
96
+
97
+ #: (Trace) -> void
98
+ def push_trace(trace)
99
+ Thread.current[THREAD_KEY] ||= []
100
+ parent = current_trace
101
+ parent&.add_child(trace)
102
+ Thread.current[THREAD_KEY].push(trace)
103
+ end
104
+
105
+ #: () -> Trace?
106
+ def pop_trace
107
+ stack = Thread.current[THREAD_KEY]
108
+ stack&.pop
109
+ Thread.current[THREAD_KEY] = nil if stack&.empty?
110
+ end
111
+
112
+ #: () -> void
113
+ def clear_trace!
114
+ Thread.current[THREAD_KEY] = nil
115
+ Thread.current[CHAIN_CONTEXT_KEY] = nil
116
+ end
117
+
118
+ #: (pass_mode: Symbol, ?extracted_params: Array[Symbol]?, ?fallback_used: bool) -> void
119
+ def set_chain_context(pass_mode:, extracted_params: nil, fallback_used: false)
120
+ Thread.current[CHAIN_CONTEXT_KEY] = {
121
+ pass_mode: pass_mode,
122
+ extracted_params: extracted_params,
123
+ fallback_used: fallback_used
124
+ }
125
+ end
126
+
127
+ #: () -> Hash[Symbol, untyped]?
128
+ def take_chain_context
129
+ ctx = Thread.current[CHAIN_CONTEXT_KEY]
130
+ Thread.current[CHAIN_CONTEXT_KEY] = nil
131
+ ctx
132
+ end
133
+ end
134
+
135
+ # Placeholder class for block-based tracing
136
+ class BlockExecution; end
137
+
138
+ # Module to be prepended to operation classes to enable tracing.
139
+ # This is automatically included when using `explain`.
140
+ module Traceable
141
+ #: () -> untyped
142
+ def execute_operation
143
+ return super unless Instrumentation.tracing?
144
+
145
+ trace = Trace.new(
146
+ operation_class: self.class,
147
+ params: extract_params_for_trace
148
+ )
149
+
150
+ # Apply chain context if available
151
+ if (chain_ctx = Instrumentation.take_chain_context)
152
+ trace.pass_mode = chain_ctx[:pass_mode]
153
+ trace.extracted_params = chain_ctx[:extracted_params]
154
+ trace.fallback_used = chain_ctx[:fallback_used]
155
+ end
156
+
157
+ Instrumentation.push_trace(trace)
158
+
159
+ result = nil
160
+ exception = nil
161
+ begin
162
+ result = super
163
+ rescue => e
164
+ exception = e
165
+ end
166
+
167
+ trace.finish!(result: result, exception: exception)
168
+ Instrumentation.pop_trace
169
+
170
+ raise exception if exception
171
+ result
172
+ end
173
+
174
+ private
175
+
176
+ #: () -> Hash[Symbol, untyped]
177
+ def extract_params_for_trace
178
+ self.class.literal_properties.keys.each_with_object({}) do |key, hash|
179
+ hash[key] = send(key)
180
+ end
181
+ rescue
182
+ {}
183
+ end
184
+ end
185
+
186
+ # Class methods added to operations for the `explain` entry point.
187
+ module Explainable
188
+ #: (*untyped, **untyped) ?{ () -> untyped } -> untyped
189
+ def explain(*args, **kwargs, &block)
190
+ trace = Trace.new(operation_class: self, params: kwargs.merge(positional: args))
191
+ Instrumentation.push_trace(trace)
192
+
193
+ result = nil
194
+ exception = nil
195
+ begin
196
+ result = call(*args, **kwargs, &block)
197
+ rescue => e
198
+ exception = e
199
+ end
200
+
201
+ trace.finish!(result: result, exception: exception)
202
+ Instrumentation.pop_trace
203
+
204
+ formatter = TreeFormatter.new(color: Instrumentation.color)
205
+ Instrumentation.output.puts formatter.format(trace)
206
+
207
+ Instrumentation.clear_trace!
208
+
209
+ raise exception if exception
210
+ result
211
+ end
212
+ end
213
+ end
214
+ end