dexkit 0.6.0 → 0.8.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.
@@ -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
@@ -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
@@ -214,6 +214,29 @@ module Dex
214
214
  "Expected #{model_class.name} count to increase, but it stayed at #{count_before}"
215
215
  end
216
216
 
217
+ # --- Guard assertions ---
218
+
219
+ def assert_callable(*args, **params)
220
+ klass = _dex_resolve_subject(args)
221
+ result = klass.callable(**params)
222
+ assert result.ok?, "Expected operation to be callable, but guards failed:\n#{_dex_format_err(result)}"
223
+ result
224
+ end
225
+
226
+ def refute_callable(*args, **params)
227
+ klass_args, codes = _dex_split_class_and_symbols(args)
228
+ klass = _dex_resolve_subject(klass_args)
229
+ code = codes.first
230
+ result = klass.callable(**params)
231
+ refute result.ok?, "Expected operation to NOT be callable, but all guards passed"
232
+ if code
233
+ failed_codes = result.details.map { |f| f[:guard] }
234
+ assert_includes failed_codes, code,
235
+ "Expected guard :#{code} to fail, but it didn't.\n Failed guards: #{failed_codes.inspect}"
236
+ end
237
+ result
238
+ end
239
+
217
240
  # --- Batch assertions ---
218
241
 
219
242
  def assert_all_succeed(*args, params_list:)
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
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dex
4
+ module TypeSerializer
5
+ module_function
6
+
7
+ # --- Human-readable string ---
8
+
9
+ def to_string(type)
10
+ case type
11
+ when Dex::RefType
12
+ "Ref(#{type.model_class.name})"
13
+ when Literal::Types::NilableType
14
+ "Nilable(#{to_string(type.type)})"
15
+ when Literal::Types::ArrayType
16
+ "Array(#{to_string(type.type)})"
17
+ when Literal::Types::UnionType
18
+ _union_string(type)
19
+ when Literal::Types::ConstraintType
20
+ _constraint_string(type)
21
+ when Literal::Types::BooleanType
22
+ "Boolean"
23
+ when Class
24
+ type.name || type.to_s
25
+ else
26
+ type.inspect
27
+ end
28
+ end
29
+
30
+ # --- JSON Schema ---
31
+
32
+ BIGDECIMAL_PATTERN = '^-?\d+\.?\d*$'
33
+
34
+ def to_json_schema(type, desc: nil)
35
+ schema = _type_to_schema(type)
36
+ schema[:description] = desc if desc
37
+ schema
38
+ end
39
+
40
+ def _type_to_schema(type) # rubocop:disable Metrics/MethodLength,Metrics/CyclomaticComplexity
41
+ case type
42
+ when Dex::RefType
43
+ { type: "string", description: "#{type.model_class.name} ID" }
44
+ when Literal::Types::NilableType
45
+ { oneOf: [_type_to_schema(type.type), { type: "null" }] }
46
+ when Literal::Types::ArrayType
47
+ { type: "array", items: _type_to_schema(type.type) }
48
+ when Literal::Types::UnionType
49
+ _union_schema(type)
50
+ when Literal::Types::ConstraintType
51
+ _constraint_schema(type)
52
+ when Literal::Types::BooleanType
53
+ { type: "boolean" }
54
+ when Class
55
+ _class_schema(type)
56
+ else
57
+ {}
58
+ end
59
+ end
60
+
61
+ def _union_string(type)
62
+ items = if type.primitives&.any?
63
+ type.primitives.map(&:inspect)
64
+ else
65
+ type.types.map { |t| to_string(t) }
66
+ end
67
+ "Union(#{items.join(", ")})"
68
+ end
69
+
70
+ def _constraint_string(type)
71
+ constraints = type.object_constraints
72
+ return "{}" if constraints.empty?
73
+
74
+ base = constraints.first
75
+ rest = constraints[1..]
76
+ base_str = base.is_a?(Class) ? (base.name || base.to_s) : base.inspect
77
+ if rest.empty?
78
+ base_str
79
+ else
80
+ "#{base_str}(#{rest.map(&:inspect).join(", ")})"
81
+ end
82
+ end
83
+
84
+ def _union_schema(type)
85
+ if type.primitives&.any?
86
+ { enum: type.primitives.to_a }
87
+ else
88
+ { oneOf: type.types.map { |t| _type_to_schema(t) } }
89
+ end
90
+ end
91
+
92
+ def _constraint_schema(type)
93
+ constraints = type.object_constraints
94
+ return {} if constraints.empty?
95
+
96
+ base = constraints.first
97
+ schema = base.is_a?(Class) ? _class_schema(base) : {}
98
+ _apply_range_constraints(schema, constraints[1..])
99
+ schema
100
+ end
101
+
102
+ def _apply_range_constraints(schema, constraints)
103
+ constraints.each do |c|
104
+ next unless c.is_a?(Range)
105
+
106
+ schema[:minimum] = c.begin if c.begin
107
+ if c.end
108
+ schema[c.exclude_end? ? :exclusiveMaximum : :maximum] = c.end
109
+ end
110
+ end
111
+ end
112
+
113
+ def _class_schema(type) # rubocop:disable Metrics/MethodLength
114
+ case type.name
115
+ when "String" then { type: "string" }
116
+ when "Integer" then { type: "integer" }
117
+ when "Float" then { type: "number" }
118
+ when "TrueClass", "FalseClass" then { type: "boolean" }
119
+ when "Symbol" then { type: "string" }
120
+ when "Hash" then { type: "object" }
121
+ when "Date" then { type: "string", format: "date" }
122
+ when "Time" then { type: "string", format: "date-time" }
123
+ when "DateTime" then { type: "string", format: "date-time" }
124
+ when "BigDecimal" then { type: "string", pattern: BIGDECIMAL_PATTERN }
125
+ else {}
126
+ end
127
+ end
128
+
129
+ private_class_method :_type_to_schema, :_union_string, :_constraint_string,
130
+ :_union_schema, :_constraint_schema, :_apply_range_constraints, :_class_schema
131
+ end
132
+ end
data/lib/dex/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Dex
4
- VERSION = "0.6.0"
4
+ VERSION = "0.8.0"
5
5
  end
data/lib/dexkit.rb CHANGED
@@ -14,12 +14,16 @@ require_relative "dex/concern"
14
14
  require_relative "dex/ref_type"
15
15
  require_relative "dex/type_coercion"
16
16
  require_relative "dex/props_setup"
17
+ require_relative "dex/context_setup"
17
18
  require_relative "dex/error"
18
19
  require_relative "dex/settings"
19
20
  require_relative "dex/pipeline"
20
21
  require_relative "dex/executable"
22
+ require_relative "dex/registry"
23
+ require_relative "dex/type_serializer"
21
24
  require_relative "dex/operation"
22
25
  require_relative "dex/event"
26
+ require_relative "dex/tool"
23
27
  require_relative "dex/form"
24
28
  require_relative "dex/query"
25
29
 
@@ -71,5 +75,22 @@ module Dex
71
75
  def transaction_adapter=(adapter)
72
76
  configuration.transaction_adapter = adapter
73
77
  end
78
+
79
+ CONTEXT_KEY = :_dex_context
80
+ EMPTY_CONTEXT = {}.freeze
81
+
82
+ def context
83
+ Fiber[CONTEXT_KEY] || EMPTY_CONTEXT
84
+ end
85
+
86
+ def with_context(**values)
87
+ previous = Fiber[CONTEXT_KEY]
88
+ Fiber[CONTEXT_KEY] = (previous || {}).merge(values)
89
+ yield
90
+ ensure
91
+ Fiber[CONTEXT_KEY] = previous
92
+ end
74
93
  end
75
94
  end
95
+
96
+ require_relative "dex/railtie" if defined?(Rails::Railtie)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dexkit
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jacek Galanciak
@@ -166,10 +166,12 @@ files:
166
166
  - guides/llm/OPERATION.md
167
167
  - guides/llm/QUERY.md
168
168
  - lib/dex/concern.rb
169
+ - lib/dex/context_setup.rb
169
170
  - lib/dex/error.rb
170
171
  - lib/dex/event.rb
171
172
  - lib/dex/event/bus.rb
172
173
  - lib/dex/event/execution_state.rb
174
+ - lib/dex/event/export.rb
173
175
  - lib/dex/event/handler.rb
174
176
  - lib/dex/event/metadata.rb
175
177
  - lib/dex/event/processor.rb
@@ -186,8 +188,12 @@ files:
186
188
  - lib/dex/operation/async_proxy.rb
187
189
  - lib/dex/operation/async_wrapper.rb
188
190
  - lib/dex/operation/callback_wrapper.rb
191
+ - lib/dex/operation/explain.rb
192
+ - lib/dex/operation/export.rb
193
+ - lib/dex/operation/guard_wrapper.rb
189
194
  - lib/dex/operation/jobs.rb
190
195
  - lib/dex/operation/lock_wrapper.rb
196
+ - lib/dex/operation/once_wrapper.rb
191
197
  - lib/dex/operation/outcome.rb
192
198
  - lib/dex/operation/record_backend.rb
193
199
  - lib/dex/operation/record_wrapper.rb
@@ -202,14 +208,18 @@ files:
202
208
  - lib/dex/query/backend.rb
203
209
  - lib/dex/query/filtering.rb
204
210
  - lib/dex/query/sorting.rb
211
+ - lib/dex/railtie.rb
205
212
  - lib/dex/ref_type.rb
213
+ - lib/dex/registry.rb
206
214
  - lib/dex/settings.rb
207
215
  - lib/dex/test_helpers.rb
208
216
  - lib/dex/test_helpers/assertions.rb
209
217
  - lib/dex/test_helpers/execution.rb
210
218
  - lib/dex/test_helpers/stubbing.rb
211
219
  - lib/dex/test_log.rb
220
+ - lib/dex/tool.rb
212
221
  - lib/dex/type_coercion.rb
222
+ - lib/dex/type_serializer.rb
213
223
  - lib/dex/version.rb
214
224
  - lib/dexkit.rb
215
225
  homepage: https://dex.razorjack.net/