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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +45 -0
- data/README.md +40 -7
- data/gemfiles/mongoid_no_ar.gemfile +10 -0
- data/gemfiles/mongoid_no_ar.gemfile.lock +232 -0
- data/guides/llm/EVENT.md +60 -5
- data/guides/llm/FORM.md +3 -3
- data/guides/llm/OPERATION.md +127 -18
- data/guides/llm/QUERY.md +3 -3
- data/lib/dex/event/bus.rb +7 -0
- data/lib/dex/event/export.rb +56 -0
- data/lib/dex/event/handler.rb +33 -0
- data/lib/dex/event/test_helpers.rb +88 -0
- data/lib/dex/event.rb +27 -0
- data/lib/dex/event_test_helpers.rb +1 -86
- data/lib/dex/form/uniqueness_validator.rb +17 -1
- data/lib/dex/operation/async_proxy.rb +1 -0
- data/lib/dex/operation/explain.rb +208 -0
- data/lib/dex/operation/export.rb +144 -0
- data/lib/dex/operation/guard_wrapper.rb +15 -4
- data/lib/dex/operation/lock_wrapper.rb +15 -2
- data/lib/dex/operation/once_wrapper.rb +23 -15
- data/lib/dex/operation/record_backend.rb +25 -0
- data/lib/dex/operation/record_wrapper.rb +29 -4
- data/lib/dex/operation/test_helpers/assertions.rb +335 -0
- data/lib/dex/operation/test_helpers/execution.rb +30 -0
- data/lib/dex/operation/test_helpers/stubbing.rb +61 -0
- data/lib/dex/operation/test_helpers.rb +150 -0
- data/lib/dex/operation/transaction_adapter.rb +29 -68
- data/lib/dex/operation/transaction_wrapper.rb +10 -16
- data/lib/dex/operation.rb +46 -2
- data/lib/dex/props_setup.rb +25 -2
- data/lib/dex/query/backend.rb +13 -0
- data/lib/dex/query.rb +9 -5
- data/lib/dex/railtie.rb +84 -0
- data/lib/dex/ref_type.rb +4 -0
- data/lib/dex/registry.rb +63 -0
- data/lib/dex/test_helpers.rb +4 -139
- data/lib/dex/tool.rb +115 -0
- data/lib/dex/type_coercion.rb +4 -1
- data/lib/dex/type_serializer.rb +132 -0
- data/lib/dex/version.rb +1 -1
- data/lib/dexkit.rb +11 -5
- metadata +16 -5
- data/lib/dex/test_helpers/assertions.rb +0 -333
- data/lib/dex/test_helpers/execution.rb +0 -28
- data/lib/dex/test_helpers/stubbing.rb +0 -59
- /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 =
|
|
10
|
+
deferred = Fiber[DEFERRED_CALLBACKS_KEY]
|
|
11
11
|
outermost = deferred.nil?
|
|
12
|
-
|
|
13
|
-
snapshot =
|
|
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
|
-
|
|
26
|
+
Fiber[DEFERRED_CALLBACKS_KEY]&.slice!(snapshot..)
|
|
27
27
|
raise
|
|
28
28
|
ensure
|
|
29
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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"
|
data/lib/dex/props_setup.rb
CHANGED
|
@@ -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
|
data/lib/dex/query/backend.rb
CHANGED
|
@@ -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
|
|
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 ==
|
|
265
|
-
raise ArgumentError, "Scope model mismatch: expected #{base.klass}, got #{
|
|
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(
|
|
272
|
+
base.merge(injected_scope)
|
|
269
273
|
end
|
|
270
274
|
end
|
|
271
275
|
end
|
data/lib/dex/railtie.rb
ADDED
|
@@ -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
|
data/lib/dex/registry.rb
ADDED
|
@@ -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
|
data/lib/dex/test_helpers.rb
CHANGED
|
@@ -1,148 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative "
|
|
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::
|
|
117
|
-
|
|
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
|