dexkit 0.7.0 → 0.9.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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +45 -0
  3. data/README.md +40 -7
  4. data/gemfiles/mongoid_no_ar.gemfile +10 -0
  5. data/gemfiles/mongoid_no_ar.gemfile.lock +232 -0
  6. data/guides/llm/EVENT.md +60 -5
  7. data/guides/llm/FORM.md +3 -3
  8. data/guides/llm/OPERATION.md +127 -18
  9. data/guides/llm/QUERY.md +3 -3
  10. data/lib/dex/event/bus.rb +7 -0
  11. data/lib/dex/event/export.rb +56 -0
  12. data/lib/dex/event/handler.rb +33 -0
  13. data/lib/dex/event/test_helpers.rb +88 -0
  14. data/lib/dex/event.rb +27 -0
  15. data/lib/dex/event_test_helpers.rb +1 -86
  16. data/lib/dex/form/uniqueness_validator.rb +17 -1
  17. data/lib/dex/operation/async_proxy.rb +1 -0
  18. data/lib/dex/operation/explain.rb +208 -0
  19. data/lib/dex/operation/export.rb +144 -0
  20. data/lib/dex/operation/guard_wrapper.rb +15 -4
  21. data/lib/dex/operation/lock_wrapper.rb +15 -2
  22. data/lib/dex/operation/once_wrapper.rb +23 -15
  23. data/lib/dex/operation/record_backend.rb +25 -0
  24. data/lib/dex/operation/record_wrapper.rb +29 -4
  25. data/lib/dex/operation/test_helpers/assertions.rb +335 -0
  26. data/lib/dex/operation/test_helpers/execution.rb +30 -0
  27. data/lib/dex/operation/test_helpers/stubbing.rb +61 -0
  28. data/lib/dex/operation/test_helpers.rb +150 -0
  29. data/lib/dex/operation/transaction_adapter.rb +29 -68
  30. data/lib/dex/operation/transaction_wrapper.rb +10 -16
  31. data/lib/dex/operation.rb +46 -2
  32. data/lib/dex/props_setup.rb +25 -2
  33. data/lib/dex/query/backend.rb +13 -0
  34. data/lib/dex/query.rb +9 -5
  35. data/lib/dex/railtie.rb +84 -0
  36. data/lib/dex/ref_type.rb +4 -0
  37. data/lib/dex/registry.rb +63 -0
  38. data/lib/dex/test_helpers.rb +4 -139
  39. data/lib/dex/tool.rb +115 -0
  40. data/lib/dex/type_coercion.rb +4 -1
  41. data/lib/dex/type_serializer.rb +132 -0
  42. data/lib/dex/version.rb +1 -1
  43. data/lib/dexkit.rb +11 -5
  44. metadata +16 -5
  45. data/lib/dex/test_helpers/assertions.rb +0 -333
  46. data/lib/dex/test_helpers/execution.rb +0 -28
  47. data/lib/dex/test_helpers/stubbing.rb +0 -59
  48. /data/lib/dex/{event_test_helpers → event/test_helpers}/assertions.rb +0 -0
@@ -7,10 +7,10 @@ module Dex
7
7
  DEFERRED_CALLBACKS_KEY = :_dex_after_commit_queue
8
8
 
9
9
  def _transaction_wrap
10
- deferred = Thread.current[DEFERRED_CALLBACKS_KEY]
10
+ deferred = Fiber[DEFERRED_CALLBACKS_KEY]
11
11
  outermost = deferred.nil?
12
- Thread.current[DEFERRED_CALLBACKS_KEY] = [] if outermost
13
- snapshot = Thread.current[DEFERRED_CALLBACKS_KEY].length
12
+ Fiber[DEFERRED_CALLBACKS_KEY] = [] if outermost
13
+ snapshot = Fiber[DEFERRED_CALLBACKS_KEY].length
14
14
 
15
15
  result, interceptor = if _transaction_enabled?
16
16
  _transaction_run_adapter(snapshot) { yield }
@@ -23,16 +23,16 @@ module Dex
23
23
  interceptor&.rethrow!
24
24
  result
25
25
  rescue # rubocop:disable Style/RescueStandardError -- explicit for clarity
26
- Thread.current[DEFERRED_CALLBACKS_KEY]&.slice!(snapshot..)
26
+ Fiber[DEFERRED_CALLBACKS_KEY]&.slice!(snapshot..)
27
27
  raise
28
28
  ensure
29
- Thread.current[DEFERRED_CALLBACKS_KEY] = nil if outermost
29
+ Fiber[DEFERRED_CALLBACKS_KEY] = nil if outermost
30
30
  end
31
31
 
32
32
  def after_commit(&block)
33
33
  raise ArgumentError, "after_commit requires a block" unless block
34
34
 
35
- deferred = Thread.current[DEFERRED_CALLBACKS_KEY]
35
+ deferred = Fiber[DEFERRED_CALLBACKS_KEY]
36
36
  if deferred
37
37
  deferred << block
38
38
  else
@@ -40,8 +40,6 @@ module Dex
40
40
  end
41
41
  end
42
42
 
43
- TRANSACTION_KNOWN_ADAPTERS = %i[active_record mongoid].freeze
44
-
45
43
  module ClassMethods
46
44
  def transaction(enabled_or_options = nil, **options)
47
45
  validate_options!(options, %i[adapter], :transaction)
@@ -66,11 +64,7 @@ module Dex
66
64
  def _transaction_validate_adapter!(adapter)
67
65
  return if adapter.nil?
68
66
 
69
- unless TransactionWrapper::TRANSACTION_KNOWN_ADAPTERS.include?(adapter.to_sym)
70
- raise ArgumentError,
71
- "unknown transaction adapter: #{adapter.inspect}. " \
72
- "Known: #{TransactionWrapper::TRANSACTION_KNOWN_ADAPTERS.map(&:inspect).join(", ")}"
73
- end
67
+ Operation::TransactionAdapter.normalize_name(adapter)
74
68
  end
75
69
  end
76
70
 
@@ -85,7 +79,7 @@ module Dex
85
79
  end
86
80
 
87
81
  if interceptor&.error?
88
- Thread.current[DEFERRED_CALLBACKS_KEY]&.slice!(snapshot..)
82
+ Fiber[DEFERRED_CALLBACKS_KEY]&.slice!(snapshot..)
89
83
  interceptor.rethrow!
90
84
  end
91
85
 
@@ -96,7 +90,7 @@ module Dex
96
90
  interceptor = Operation::HaltInterceptor.new { yield }
97
91
 
98
92
  if interceptor.error?
99
- Thread.current[DEFERRED_CALLBACKS_KEY]&.slice!(snapshot..)
93
+ Fiber[DEFERRED_CALLBACKS_KEY]&.slice!(snapshot..)
100
94
  interceptor.rethrow!
101
95
  end
102
96
 
@@ -117,7 +111,7 @@ module Dex
117
111
  end
118
112
 
119
113
  def _transaction_flush_deferred
120
- callbacks = Thread.current[DEFERRED_CALLBACKS_KEY]
114
+ callbacks = Fiber[DEFERRED_CALLBACKS_KEY]
121
115
  return if callbacks.empty?
122
116
 
123
117
  flush = -> { callbacks.each(&:call) }
data/lib/dex/operation.rb CHANGED
@@ -49,7 +49,32 @@ module Dex
49
49
  include TypeCoercion
50
50
  include ContextSetup
51
51
 
52
- Contract = Data.define(:params, :success, :errors, :guards)
52
+ Contract = Data.define(:params, :success, :errors, :guards) do
53
+ attr_reader :source_class
54
+
55
+ def initialize(params:, success:, errors:, guards:, source_class: nil)
56
+ @source_class = source_class
57
+ super(params: params, success: success, errors: errors, guards: guards)
58
+ end
59
+
60
+ def to_h
61
+ if @source_class
62
+ Operation::Export.build_hash(@source_class, self)
63
+ else
64
+ super
65
+ end
66
+ end
67
+
68
+ def to_json_schema(**options)
69
+ unless @source_class
70
+ raise ArgumentError, "to_json_schema requires a source_class (use OperationClass.contract.to_json_schema)"
71
+ end
72
+
73
+ Operation::Export.build_json_schema(@source_class, self, **options)
74
+ end
75
+ end
76
+
77
+ extend Registry
53
78
 
54
79
  class << self
55
80
  def contract
@@ -57,10 +82,25 @@ module Dex
57
82
  params: _contract_params,
58
83
  success: _success_type,
59
84
  errors: _declared_errors,
60
- guards: _contract_guards
85
+ guards: _contract_guards,
86
+ source_class: self
61
87
  )
62
88
  end
63
89
 
90
+ def export(format: :hash, **options)
91
+ unless %i[hash json_schema].include?(format)
92
+ raise ArgumentError, "unknown format: #{format.inspect}. Known: :hash, :json_schema"
93
+ end
94
+
95
+ sorted = registry.sort_by(&:name)
96
+ sorted.map do |klass|
97
+ case format
98
+ when :hash then klass.contract.to_h
99
+ when :json_schema then klass.contract.to_json_schema(**options)
100
+ end
101
+ end
102
+ end
103
+
64
104
  private
65
105
 
66
106
  def _contract_params
@@ -116,6 +156,10 @@ require_relative "operation/async_proxy"
116
156
  require_relative "operation/record_backend"
117
157
  require_relative "operation/transaction_adapter"
118
158
  require_relative "operation/jobs"
159
+ require_relative "operation/explain"
160
+ require_relative "operation/export"
161
+
162
+ Dex::Operation.extend(Dex::Operation::Explain)
119
163
 
120
164
  # Top-level aliases (depend on Operation::Ok/Err)
121
165
  require_relative "match"
@@ -17,10 +17,17 @@ module Dex
17
17
  end
18
18
 
19
19
  module ClassMethods
20
- def prop(name, type, kind = :keyword, **options, &block)
20
+ def prop(name, type, kind = :keyword, desc: nil, **options, &block)
21
21
  if const_defined?(:RESERVED_PROP_NAMES) && self::RESERVED_PROP_NAMES.include?(name)
22
22
  raise ArgumentError, "Property :#{name} is reserved."
23
23
  end
24
+ if !desc.nil?
25
+ raise ArgumentError, "desc: must be a String, got #{desc.class}" unless desc.is_a?(String)
26
+
27
+ _prop_desc_own[name] = desc
28
+ elsif superclass.respond_to?(:prop_descriptions) && superclass.prop_descriptions.key?(name)
29
+ _prop_desc_own[name] = nil
30
+ end
24
31
  options[:reader] = :public unless options.key?(:reader)
25
32
  if type.is_a?(Dex::RefType) && !block
26
33
  ref = type
@@ -29,7 +36,12 @@ module Dex
29
36
  super(name, type, kind, **options, &block)
30
37
  end
31
38
 
32
- def prop?(name, type, kind = :keyword, **options, &block)
39
+ def prop?(name, type, kind = :keyword, desc: nil, **options, &block)
40
+ if !desc.nil?
41
+ raise ArgumentError, "desc: must be a String, got #{desc.class}" unless desc.is_a?(String)
42
+
43
+ _prop_desc_own[name] = desc
44
+ end
33
45
  options[:reader] = :public unless options.key?(:reader)
34
46
  options[:default] = nil unless options.key?(:default)
35
47
  if type.is_a?(Dex::RefType) && !block
@@ -39,9 +51,20 @@ module Dex
39
51
  prop(name, _Nilable(type), kind, **options, &block)
40
52
  end
41
53
 
54
+ def prop_descriptions
55
+ parent = superclass.respond_to?(:prop_descriptions) ? superclass.prop_descriptions : {}
56
+ parent.merge(_prop_desc_own).compact
57
+ end
58
+
42
59
  def _Ref(model_class, lock: false) # rubocop:disable Naming/MethodName
43
60
  Dex::RefType.new(model_class, lock: lock)
44
61
  end
62
+
63
+ private
64
+
65
+ def _prop_desc_own
66
+ @_prop_desc_own ||= {}
67
+ end
45
68
  end
46
69
  end
47
70
  end
@@ -76,16 +76,29 @@ module Dex
76
76
  module_function
77
77
 
78
78
  def apply_strategy(scope, strategy, column, value)
79
+ scope = normalize_scope(scope)
79
80
  adapter_for(scope).apply(scope, strategy, column, value)
80
81
  end
81
82
 
82
83
  def adapter_for(scope)
84
+ scope = normalize_scope(scope)
85
+
83
86
  if defined?(Mongoid::Criteria) && scope.is_a?(Mongoid::Criteria)
84
87
  MongoidAdapter
85
88
  else
86
89
  ActiveRecordAdapter
87
90
  end
88
91
  end
92
+
93
+ def normalize_scope(scope)
94
+ return scope unless defined?(Mongoid::Criteria)
95
+ return scope if scope.is_a?(Mongoid::Criteria)
96
+
97
+ criteria = scope.criteria if scope.respond_to?(:criteria)
98
+ criteria.is_a?(Mongoid::Criteria) ? criteria : scope
99
+ rescue
100
+ scope
101
+ end
89
102
  end
90
103
  end
91
104
  end
data/lib/dex/query.rb CHANGED
@@ -209,9 +209,10 @@ module Dex
209
209
  end
210
210
 
211
211
  def resolve
212
- base = _evaluate_scope
212
+ base = Query::Backend.normalize_scope(_evaluate_scope)
213
213
  base = _merge_injected_scope(base)
214
214
  base = _apply_filters(base)
215
+ base = Query::Backend.normalize_scope(base)
215
216
  _apply_sort(base)
216
217
  end
217
218
 
@@ -253,19 +254,22 @@ module Dex
253
254
  def _merge_injected_scope(base)
254
255
  return base unless @_injected_scope
255
256
 
257
+ base = Query::Backend.normalize_scope(base)
258
+ injected_scope = Query::Backend.normalize_scope(@_injected_scope)
259
+
256
260
  unless base.respond_to?(:klass)
257
261
  raise ArgumentError, "Scope block must return a queryable scope (ActiveRecord relation or Mongoid criteria), got #{base.class}."
258
262
  end
259
263
 
260
- unless @_injected_scope.respond_to?(:klass)
264
+ unless injected_scope.respond_to?(:klass)
261
265
  raise ArgumentError, "Injected scope must be a queryable scope (ActiveRecord relation or Mongoid criteria), got #{@_injected_scope.class}."
262
266
  end
263
267
 
264
- unless base.klass == @_injected_scope.klass
265
- raise ArgumentError, "Scope model mismatch: expected #{base.klass}, got #{@_injected_scope.klass}."
268
+ unless base.klass == injected_scope.klass
269
+ raise ArgumentError, "Scope model mismatch: expected #{base.klass}, got #{injected_scope.klass}."
266
270
  end
267
271
 
268
- base.merge(@_injected_scope)
272
+ base.merge(injected_scope)
269
273
  end
270
274
  end
271
275
  end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dex
4
+ class Railtie < Rails::Railtie
5
+ rake_tasks do
6
+ namespace :dex do
7
+ desc "Export operation/event/handler contracts (FORMAT=hash|json_schema SECTION=operations|events|handlers FILE=path)"
8
+ task export: :environment do
9
+ Rails.application.eager_load!
10
+
11
+ format = (ENV["FORMAT"] || "hash").to_sym
12
+ section = ENV["SECTION"] || "operations"
13
+ file = ENV["FILE"]
14
+
15
+ data = case section
16
+ when "operations" then Dex::Operation.export(format: format)
17
+ when "events" then Dex::Event.export(format: format)
18
+ when "handlers"
19
+ if format != :hash
20
+ raise "Handlers only support FORMAT=hash (got #{format})"
21
+ end
22
+
23
+ Dex::Event::Handler.export(format: format)
24
+ else
25
+ raise "Unknown SECTION=#{section}. Known: operations, events, handlers"
26
+ end
27
+
28
+ json = JSON.pretty_generate(data)
29
+
30
+ if file
31
+ File.write(file, json)
32
+ puts "Wrote #{data.size} #{section} to #{file}"
33
+ else
34
+ puts json
35
+ end
36
+ end
37
+
38
+ desc "Install LLM guides as AGENTS.md in app directories (FORCE=1 to overwrite app-owned files)"
39
+ task :guides do
40
+ gem_root = File.expand_path("../..", __dir__)
41
+ guide_dir = File.join(gem_root, "guides", "llm")
42
+ marker = "<!-- dexkit v#{Dex::VERSION} | Auto-generated by: rake dex:guides -->"
43
+ force = ENV["FORCE"] == "1"
44
+ written = 0
45
+
46
+ # [source_file, env_var, default_path]
47
+ mapping = [
48
+ ["OPERATION.md", "OPERATIONS_PATH", "app/operations"],
49
+ ["EVENT.md", "EVENTS_PATH", "app/events"],
50
+ ["EVENT.md", "EVENT_HANDLERS_PATH", "app/event_handlers"],
51
+ ["FORM.md", "FORMS_PATH", "app/forms"],
52
+ ["QUERY.md", "QUERIES_PATH", "app/queries"]
53
+ ]
54
+
55
+ mapping.each do |source_file, env_var, default_path|
56
+ target_dir = ENV[env_var] || default_path
57
+
58
+ unless File.directory?(target_dir)
59
+ puts " #{target_dir}/AGENTS.md (skipped — directory not found)"
60
+ next
61
+ end
62
+
63
+ target = File.join(target_dir, "AGENTS.md")
64
+
65
+ if File.exist?(target) && !force
66
+ first_line = File.open(target, &:readline).chomp rescue "" # rubocop:disable Style/RescueModifier
67
+ unless first_line.start_with?("<!-- dexkit v")
68
+ puts " #{target} (skipped — not generated by dexkit, use FORCE=1 to overwrite)"
69
+ next
70
+ end
71
+ end
72
+
73
+ source = File.join(guide_dir, source_file)
74
+ File.write(target, "#{marker}\n\n#{File.read(source)}")
75
+ written += 1
76
+ puts " #{target} ← #{source_file}"
77
+ end
78
+
79
+ puts "\n#{written} guide(s) installed."
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
data/lib/dex/ref_type.rb CHANGED
@@ -7,6 +7,10 @@ module Dex
7
7
  attr_reader :model_class, :lock
8
8
 
9
9
  def initialize(model_class, lock: false)
10
+ if lock && !model_class.respond_to?(:lock)
11
+ raise ArgumentError, "_Ref(lock: true) requires a model class that responds to .lock"
12
+ end
13
+
10
14
  @model_class = model_class
11
15
  @lock = lock
12
16
  end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dex
4
+ module Registry
5
+ def self.extended(base)
6
+ base.instance_variable_set(:@_registry, Set.new)
7
+ end
8
+
9
+ def inherited(subclass)
10
+ super
11
+ _dex_registry.add(subclass)
12
+ end
13
+
14
+ def registry
15
+ live = _dex_registry.select { |k| k.name && _dex_reachable?(k) }
16
+ _dex_registry.replace(live)
17
+ live.to_set.freeze
18
+ end
19
+
20
+ def deregister(klass)
21
+ _dex_registry.delete(klass)
22
+ end
23
+
24
+ def clear!
25
+ _dex_registry.clear
26
+ end
27
+
28
+ def description(text = nil)
29
+ if !text.nil?
30
+ raise ArgumentError, "description must be a String" unless text.is_a?(String)
31
+
32
+ @_description = text
33
+ else
34
+ defined?(@_description) ? @_description : _dex_parent_description
35
+ end
36
+ end
37
+
38
+ private
39
+
40
+ def _dex_reachable?(klass)
41
+ Object.const_get(klass.name) == klass
42
+ rescue NameError
43
+ false
44
+ end
45
+
46
+ def _dex_registry
47
+ if instance_variable_defined?(:@_registry)
48
+ @_registry
49
+ elsif superclass.respond_to?(:_dex_registry, true)
50
+ superclass.send(:_dex_registry)
51
+ else
52
+ @_registry = Set.new
53
+ end
54
+ end
55
+
56
+ def _dex_parent_description
57
+ return nil unless superclass.respond_to?(:description)
58
+ return nil if superclass.instance_variable_defined?(:@_registry)
59
+
60
+ superclass.description
61
+ end
62
+ end
63
+ end
@@ -1,148 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "test_log"
3
+ require_relative "operation/test_helpers"
4
+ require_relative "event/test_helpers"
4
5
 
5
6
  module Dex
6
- module TestWrapper
7
- @_installed = false
8
-
9
- class << self
10
- def install!
11
- return if @_installed
12
-
13
- Dex::Operation.prepend(self)
14
- @_installed = true
15
- end
16
-
17
- def installed?
18
- @_installed
19
- end
20
-
21
- # Stub registry
22
-
23
- def stubs
24
- @_stubs ||= {}
25
- end
26
-
27
- def find_stub(klass)
28
- stubs[klass]
29
- end
30
-
31
- def register_stub(klass, **options)
32
- stubs[klass] = options
33
- end
34
-
35
- def clear_stub(klass)
36
- stubs.delete(klass)
37
- end
38
-
39
- def clear_all_stubs!
40
- stubs.clear
41
- end
42
- end
43
-
44
- def call
45
- stub = Dex::TestWrapper.find_stub(self.class)
46
- return _test_apply_stub(stub) if stub
47
-
48
- started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
49
- result = nil
50
- err = nil
51
-
52
- begin
53
- result = super
54
- rescue Exception => e # rubocop:disable Lint/RescueException
55
- err = e
56
- raise
57
- ensure
58
- duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at
59
- _test_record_to_log(result, err, duration)
60
- end
61
-
62
- result
63
- end
64
-
65
- private
66
-
67
- def _test_apply_stub(stub)
68
- if stub[:error]
69
- err_opts = stub[:error]
70
- case err_opts
71
- when Symbol
72
- raise Dex::Error.new(err_opts)
73
- when Hash
74
- raise Dex::Error.new(err_opts[:code], err_opts[:message], details: err_opts[:details])
75
- end
76
- else
77
- stub[:returns]
78
- end
79
- end
80
-
81
- def _test_safe_params
82
- respond_to?(:to_h) ? to_h : {}
83
- rescue
84
- {}
85
- end
86
-
87
- def _test_record_to_log(result, err, duration)
88
- safe_result = if err
89
- dex_err = if err.is_a?(Dex::Error)
90
- err
91
- else
92
- Dex::Error.new(:exception, err.message, details: { exception_class: err.class.name })
93
- end
94
- Dex::Operation::Err.new(dex_err)
95
- else
96
- Dex::Operation::Ok.new(result)
97
- end
98
-
99
- entry = Dex::TestLog::Entry.new(
100
- type: "Operation",
101
- name: self.class.name || self.class.to_s,
102
- operation_class: self.class,
103
- params: _test_safe_params,
104
- result: safe_result,
105
- duration: duration,
106
- caller_location: caller_locations(4, 1)&.first
107
- )
108
- Dex::TestLog.record(entry)
109
- end
110
- end
111
-
112
7
  module TestHelpers
113
- extend Dex::Concern
114
-
115
8
  def self.included(base)
116
- Dex::TestWrapper.install!
117
- super
118
- end
119
-
120
- def setup
121
- super
122
- Dex::TestLog.clear!
123
- Dex::TestWrapper.clear_all_stubs!
124
- end
125
-
126
- module ClassMethods
127
- def testing(klass)
128
- @_dex_test_subject = klass
129
- end
130
-
131
- def _dex_test_subject
132
- return @_dex_test_subject if defined?(@_dex_test_subject) && @_dex_test_subject
133
-
134
- superclass._dex_test_subject if superclass.respond_to?(:_dex_test_subject)
135
- end
136
- end
137
-
138
- private
139
-
140
- def _dex_test_subject
141
- self.class._dex_test_subject
9
+ base.include(Dex::Operation::TestHelpers)
10
+ base.include(Dex::Event::TestHelpers)
142
11
  end
143
12
  end
144
13
  end
145
-
146
- require_relative "test_helpers/execution"
147
- require_relative "test_helpers/assertions"
148
- require_relative "test_helpers/stubbing"
data/lib/dex/tool.rb ADDED
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dex
4
+ module Tool
5
+ module_function
6
+
7
+ def from(operation_class)
8
+ _require_ruby_llm!
9
+ _build_tool(operation_class)
10
+ end
11
+
12
+ def all
13
+ _require_ruby_llm!
14
+ Operation.registry.sort_by(&:name).map { |klass| _build_tool(klass) }
15
+ end
16
+
17
+ def from_namespace(namespace)
18
+ _require_ruby_llm!
19
+ prefix = "#{namespace}::"
20
+ Operation.registry
21
+ .select { |op| op.name&.start_with?(prefix) }
22
+ .sort_by(&:name)
23
+ .map { |klass| _build_tool(klass) }
24
+ end
25
+
26
+ def explain_tool
27
+ _require_ruby_llm!
28
+ _build_explain_tool
29
+ end
30
+
31
+ def _require_ruby_llm!
32
+ require "ruby_llm"
33
+ rescue LoadError
34
+ raise LoadError,
35
+ "Dex::Tool requires the ruby-llm gem. Add `gem 'ruby_llm'` to your Gemfile."
36
+ end
37
+
38
+ def _build_tool(operation_class) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize
39
+ op = operation_class
40
+ schema = op.contract.to_json_schema
41
+ tool_description = _tool_description(op)
42
+
43
+ Class.new(RubyLLM::Tool) do
44
+ define_method(:name) { "dex_#{op.name.gsub("::", "_").downcase}" }
45
+ define_method(:description) { tool_description }
46
+ define_method(:params_schema) { schema }
47
+
48
+ define_method(:execute) do |**params|
49
+ coerced = params.transform_keys(&:to_sym)
50
+ result = op.new(**coerced).safe.call
51
+ case result
52
+ when Dex::Operation::Ok
53
+ value = result.value
54
+ value.respond_to?(:as_json) ? value.as_json : value
55
+ when Dex::Operation::Err
56
+ { error: result.code, message: result.message, details: result.details }
57
+ end
58
+ end
59
+ end.new
60
+ end
61
+
62
+ def _tool_description(op)
63
+ parts = []
64
+ desc = op.description
65
+ parts << desc if desc
66
+ parts << op.name unless desc
67
+
68
+ guards = op.contract.guards
69
+ if guards.any?
70
+ messages = guards.map { |g| g[:message] || g[:name].to_s }
71
+ parts << "Preconditions: #{messages.join("; ")}."
72
+ end
73
+
74
+ errors = op.contract.errors
75
+ parts << "Errors: #{errors.join(", ")}." if errors.any?
76
+
77
+ parts.join("\n")
78
+ end
79
+
80
+ def _build_explain_tool # rubocop:disable Metrics/MethodLength
81
+ Class.new(RubyLLM::Tool) do
82
+ define_method(:name) { "dex_explain" }
83
+ define_method(:description) { "Check if an operation can be executed with given params, without running it." }
84
+ define_method(:params_schema) do
85
+ {
86
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
87
+ type: "object",
88
+ properties: {
89
+ "operation" => { type: "string", description: "Operation class name (e.g. 'Order::Place')" },
90
+ "params" => { type: "object", description: "Params to check" }
91
+ },
92
+ required: ["operation"],
93
+ additionalProperties: false
94
+ }
95
+ end
96
+
97
+ define_method(:execute) do |operation:, params: {}|
98
+ op_class = Dex::Operation.registry.find { |klass| klass.name == operation }
99
+ return { error: "unknown_operation", message: "Operation '#{operation}' not found in registry" } unless op_class
100
+
101
+ coerced = params.transform_keys(&:to_sym)
102
+ info = op_class.explain(**coerced)
103
+ {
104
+ callable: info[:callable],
105
+ guards: info[:guards],
106
+ once: info[:once],
107
+ lock: info[:lock]
108
+ }
109
+ end
110
+ end.new
111
+ end
112
+
113
+ private_class_method :_require_ruby_llm!, :_build_tool, :_tool_description, :_build_explain_tool
114
+ end
115
+ end