igniter 0.4.0 → 0.4.5
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 +25 -0
- data/README.md +238 -218
- data/docs/LLM_V1.md +335 -0
- data/docs/PATTERNS.md +189 -0
- data/docs/SERVER_V1.md +313 -0
- data/examples/README.md +129 -0
- data/examples/agents.rb +150 -0
- data/examples/differential.rb +161 -0
- data/examples/distributed_server.rb +94 -0
- data/examples/effects.rb +184 -0
- data/examples/incremental.rb +142 -0
- data/examples/invariants.rb +179 -0
- data/examples/order_pipeline.rb +163 -0
- data/examples/provenance.rb +122 -0
- data/examples/saga.rb +110 -0
- data/lib/igniter/agent/mailbox.rb +96 -0
- data/lib/igniter/agent/message.rb +21 -0
- data/lib/igniter/agent/ref.rb +86 -0
- data/lib/igniter/agent/runner.rb +129 -0
- data/lib/igniter/agent/state_holder.rb +23 -0
- data/lib/igniter/agent.rb +155 -0
- data/lib/igniter/compiler/validators/callable_validator.rb +21 -3
- data/lib/igniter/differential/divergence.rb +29 -0
- data/lib/igniter/differential/formatter.rb +96 -0
- data/lib/igniter/differential/report.rb +86 -0
- data/lib/igniter/differential/runner.rb +130 -0
- data/lib/igniter/differential.rb +51 -0
- data/lib/igniter/dsl/contract_builder.rb +32 -0
- data/lib/igniter/effect.rb +91 -0
- data/lib/igniter/effect_registry.rb +78 -0
- data/lib/igniter/errors.rb +11 -1
- data/lib/igniter/execution_report/builder.rb +54 -0
- data/lib/igniter/execution_report/formatter.rb +50 -0
- data/lib/igniter/execution_report/node_entry.rb +24 -0
- data/lib/igniter/execution_report/report.rb +65 -0
- data/lib/igniter/execution_report.rb +32 -0
- data/lib/igniter/extensions/differential.rb +114 -0
- data/lib/igniter/extensions/execution_report.rb +27 -0
- data/lib/igniter/extensions/incremental.rb +50 -0
- data/lib/igniter/extensions/invariants.rb +116 -0
- data/lib/igniter/extensions/provenance.rb +45 -0
- data/lib/igniter/extensions/saga.rb +74 -0
- data/lib/igniter/incremental/formatter.rb +81 -0
- data/lib/igniter/incremental/result.rb +69 -0
- data/lib/igniter/incremental/tracker.rb +108 -0
- data/lib/igniter/incremental.rb +50 -0
- data/lib/igniter/integrations/agents.rb +18 -0
- data/lib/igniter/invariant.rb +50 -0
- data/lib/igniter/model/effect_node.rb +37 -0
- data/lib/igniter/model.rb +1 -0
- data/lib/igniter/property_testing/formatter.rb +66 -0
- data/lib/igniter/property_testing/generators.rb +115 -0
- data/lib/igniter/property_testing/result.rb +45 -0
- data/lib/igniter/property_testing/run.rb +43 -0
- data/lib/igniter/property_testing/runner.rb +47 -0
- data/lib/igniter/property_testing.rb +64 -0
- data/lib/igniter/provenance/builder.rb +97 -0
- data/lib/igniter/provenance/lineage.rb +82 -0
- data/lib/igniter/provenance/node_trace.rb +65 -0
- data/lib/igniter/provenance/text_formatter.rb +70 -0
- data/lib/igniter/provenance.rb +29 -0
- data/lib/igniter/registry.rb +67 -0
- data/lib/igniter/runtime/cache.rb +35 -6
- data/lib/igniter/runtime/execution.rb +8 -2
- data/lib/igniter/runtime/node_state.rb +7 -2
- data/lib/igniter/runtime/resolver.rb +84 -15
- data/lib/igniter/saga/compensation.rb +31 -0
- data/lib/igniter/saga/compensation_record.rb +20 -0
- data/lib/igniter/saga/executor.rb +85 -0
- data/lib/igniter/saga/formatter.rb +49 -0
- data/lib/igniter/saga/result.rb +47 -0
- data/lib/igniter/saga.rb +56 -0
- data/lib/igniter/stream_loop.rb +80 -0
- data/lib/igniter/supervisor.rb +167 -0
- data/lib/igniter/version.rb +1 -1
- data/lib/igniter.rb +10 -0
- metadata +63 -1
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
class Agent
|
|
5
|
+
# External handle to a running agent.
|
|
6
|
+
#
|
|
7
|
+
# Callers interact with agents exclusively through Ref — they never touch
|
|
8
|
+
# the Mailbox, StateHolder, or Thread directly. This allows the Supervisor
|
|
9
|
+
# to swap out internals on restart without invalidating existing Ref objects.
|
|
10
|
+
class Ref
|
|
11
|
+
def initialize(thread:, mailbox:, state_holder:)
|
|
12
|
+
@thread = thread
|
|
13
|
+
@mailbox = mailbox
|
|
14
|
+
@state_holder = state_holder
|
|
15
|
+
@mutex = Mutex.new
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Asynchronous fire-and-forget. Returns self.
|
|
19
|
+
def send(type, payload = {})
|
|
20
|
+
mailbox.push(Message.new(type: type.to_sym, payload: payload))
|
|
21
|
+
self
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Synchronous request-reply. Blocks until the handler responds or timeout
|
|
25
|
+
# elapses. Raises Igniter::Agent::TimeoutError on timeout.
|
|
26
|
+
def call(type, payload = {}, timeout: 5)
|
|
27
|
+
reply_box = Mailbox.new(capacity: 1, overflow: :drop_newest)
|
|
28
|
+
mailbox.push(Message.new(type: type.to_sym, payload: payload, reply_to: reply_box))
|
|
29
|
+
reply = reply_box.pop(timeout: timeout)
|
|
30
|
+
raise TimeoutError, "Agent did not reply within #{timeout}s" unless reply
|
|
31
|
+
|
|
32
|
+
reply.payload[:value]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Request graceful shutdown. Closes the mailbox so the runner exits after
|
|
36
|
+
# processing any in-flight message. Blocks until the thread finishes.
|
|
37
|
+
def stop(timeout: 5)
|
|
38
|
+
mailbox.close
|
|
39
|
+
thread&.join(timeout)
|
|
40
|
+
self
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Forcefully terminate the agent thread.
|
|
44
|
+
def kill
|
|
45
|
+
thread&.kill
|
|
46
|
+
mailbox.close
|
|
47
|
+
self
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def alive?
|
|
51
|
+
thread&.alive? || false
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Read the current state snapshot without blocking the agent.
|
|
55
|
+
def state
|
|
56
|
+
state_holder.get
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Supervisor-internal: swap out internals when the agent restarts.
|
|
60
|
+
# Callers keep the same Ref object; the new thread/mailbox/state_holder
|
|
61
|
+
# are transparently injected.
|
|
62
|
+
def rebind(thread:, mailbox:, state_holder:)
|
|
63
|
+
@mutex.synchronize do
|
|
64
|
+
@thread = thread
|
|
65
|
+
@mailbox = mailbox
|
|
66
|
+
@state_holder = state_holder
|
|
67
|
+
end
|
|
68
|
+
self
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def thread
|
|
74
|
+
@mutex.synchronize { @thread }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def mailbox
|
|
78
|
+
@mutex.synchronize { @mailbox }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def state_holder
|
|
82
|
+
@mutex.synchronize { @state_holder }
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
class Agent
|
|
5
|
+
# Runs an agent's message loop in a dedicated Ruby Thread.
|
|
6
|
+
#
|
|
7
|
+
# Responsibilities:
|
|
8
|
+
# - Pop messages from the Mailbox and dispatch to registered handlers
|
|
9
|
+
# - Fire scheduled timers when their interval elapses
|
|
10
|
+
# - Apply handler return-value semantics to StateHolder
|
|
11
|
+
# - Invoke the on_crash callback when the thread dies unexpectedly
|
|
12
|
+
# - Fire lifecycle hooks (after_start, after_crash, after_stop)
|
|
13
|
+
#
|
|
14
|
+
# Handler return-value semantics:
|
|
15
|
+
# Hash → replace agent state with the returned hash
|
|
16
|
+
# :stop → close the mailbox and exit the loop cleanly
|
|
17
|
+
# nil → leave state unchanged (no reply sent to sync caller)
|
|
18
|
+
# other → leave state unchanged; if message has reply_to, send value as reply
|
|
19
|
+
class Runner
|
|
20
|
+
def initialize(agent_class:, mailbox:, state_holder:, on_crash: nil)
|
|
21
|
+
@agent_class = agent_class
|
|
22
|
+
@mailbox = mailbox
|
|
23
|
+
@state_holder = state_holder
|
|
24
|
+
@on_crash = on_crash
|
|
25
|
+
@thread = nil
|
|
26
|
+
@timers = build_timers
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Start the message loop in a background thread. Returns the Thread.
|
|
30
|
+
def start
|
|
31
|
+
@thread = Thread.new { run_loop }
|
|
32
|
+
@thread.abort_on_exception = false
|
|
33
|
+
@thread
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
attr_reader :thread
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def run_loop # rubocop:disable Metrics/MethodLength
|
|
41
|
+
fire_hooks(:start)
|
|
42
|
+
loop do
|
|
43
|
+
delay = nearest_timer_delay
|
|
44
|
+
message = @mailbox.pop(timeout: delay)
|
|
45
|
+
fire_due_timers
|
|
46
|
+
break if message.nil? && @mailbox.closed?
|
|
47
|
+
|
|
48
|
+
dispatch(message) if message
|
|
49
|
+
end
|
|
50
|
+
rescue StandardError => e
|
|
51
|
+
@on_crash&.call(e)
|
|
52
|
+
fire_hooks(:crash, error: e)
|
|
53
|
+
ensure
|
|
54
|
+
fire_hooks(:stop)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def dispatch(message) # rubocop:disable Metrics/MethodLength
|
|
58
|
+
handler = @agent_class.handlers[message.type]
|
|
59
|
+
|
|
60
|
+
unless handler
|
|
61
|
+
# Unknown message type — send nil reply if caller is waiting
|
|
62
|
+
send_reply(message, nil)
|
|
63
|
+
return
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
state = @state_holder.get
|
|
67
|
+
result = handler.call(state: state, payload: message.payload)
|
|
68
|
+
|
|
69
|
+
case result
|
|
70
|
+
when Hash
|
|
71
|
+
@state_holder.set(result)
|
|
72
|
+
send_reply(message, nil)
|
|
73
|
+
when :stop
|
|
74
|
+
send_reply(message, nil)
|
|
75
|
+
@mailbox.close
|
|
76
|
+
when nil
|
|
77
|
+
send_reply(message, nil)
|
|
78
|
+
else
|
|
79
|
+
# Non-state return value — treat as sync reply payload
|
|
80
|
+
send_reply(message, result)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def send_reply(message, value)
|
|
85
|
+
return unless message.reply_to
|
|
86
|
+
|
|
87
|
+
message.reply_to.push(
|
|
88
|
+
Message.new(type: :reply, payload: { value: value })
|
|
89
|
+
)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Returns seconds until the next timer fires, or nil if no timers.
|
|
93
|
+
def nearest_timer_delay
|
|
94
|
+
return nil if @timers.empty?
|
|
95
|
+
|
|
96
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
97
|
+
next_at = @timers.map { |t| t[:next_at] }.min
|
|
98
|
+
[next_at - now, 0].max
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def fire_due_timers
|
|
102
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
103
|
+
@timers.each do |timer|
|
|
104
|
+
next if timer[:next_at] > now
|
|
105
|
+
|
|
106
|
+
state = @state_holder.get
|
|
107
|
+
result = timer[:handler].call(state: state)
|
|
108
|
+
@state_holder.set(result) if result.is_a?(Hash)
|
|
109
|
+
timer[:next_at] = now + timer[:interval]
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def build_timers
|
|
114
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
115
|
+
@agent_class.timers.map do |t|
|
|
116
|
+
t.merge(next_at: now + t[:interval])
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def fire_hooks(type, **args)
|
|
121
|
+
@agent_class.hooks[type]&.each do |hook|
|
|
122
|
+
hook.call(**args)
|
|
123
|
+
rescue StandardError
|
|
124
|
+
nil # hooks must not crash the runner
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
class Agent
|
|
5
|
+
# Mutex-guarded wrapper around an agent's current state hash.
|
|
6
|
+
# The state is always stored as a frozen Hash so callers can read it
|
|
7
|
+
# without holding the lock.
|
|
8
|
+
class StateHolder
|
|
9
|
+
def initialize(initial_state)
|
|
10
|
+
@state = initial_state.freeze
|
|
11
|
+
@mutex = Mutex.new
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def get
|
|
15
|
+
@mutex.synchronize { @state }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def set(new_state)
|
|
19
|
+
@mutex.synchronize { @state = new_state.freeze }
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "agent/message"
|
|
4
|
+
require_relative "agent/mailbox"
|
|
5
|
+
require_relative "agent/state_holder"
|
|
6
|
+
require_relative "agent/runner"
|
|
7
|
+
require_relative "agent/ref"
|
|
8
|
+
|
|
9
|
+
module Igniter
|
|
10
|
+
# Base class for stateful, message-driven actors.
|
|
11
|
+
#
|
|
12
|
+
# Subclass Agent and use the class-level DSL to declare:
|
|
13
|
+
# - `initial_state` — default state hash for new instances
|
|
14
|
+
# - `on` — handler for a named message type
|
|
15
|
+
# - `schedule` — recurring timer handler
|
|
16
|
+
# - `mailbox_size` — queue capacity (default: 256)
|
|
17
|
+
# - `mailbox_overflow` — policy when queue is full (:block/:drop_oldest/
|
|
18
|
+
# :drop_newest/:error, default: :block)
|
|
19
|
+
# - `after_start` — hook called when the agent thread begins
|
|
20
|
+
# - `after_crash` — hook called with the error when the thread crashes
|
|
21
|
+
# - `after_stop` — hook called when the agent thread exits
|
|
22
|
+
#
|
|
23
|
+
# Handler return-value semantics:
|
|
24
|
+
# Hash → new state (replaces current state)
|
|
25
|
+
# :stop → shut down the agent cleanly
|
|
26
|
+
# nil → unchanged state
|
|
27
|
+
# other → unchanged state; sent as reply to sync `call()` callers
|
|
28
|
+
#
|
|
29
|
+
# Example:
|
|
30
|
+
#
|
|
31
|
+
# class CounterAgent < Igniter::Agent
|
|
32
|
+
# initial_state counter: 0
|
|
33
|
+
#
|
|
34
|
+
# on :increment do |state:, payload:, **|
|
|
35
|
+
# state.merge(counter: state[:counter] + payload.fetch(:by, 1))
|
|
36
|
+
# end
|
|
37
|
+
#
|
|
38
|
+
# on :count do |state:, **|
|
|
39
|
+
# state[:counter] # returned as sync reply
|
|
40
|
+
# end
|
|
41
|
+
# end
|
|
42
|
+
#
|
|
43
|
+
# ref = CounterAgent.start
|
|
44
|
+
# ref.send(:increment, by: 5)
|
|
45
|
+
# ref.call(:count) # => 5
|
|
46
|
+
# ref.stop
|
|
47
|
+
#
|
|
48
|
+
class Agent
|
|
49
|
+
# ── Custom error types ────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
class MailboxFullError < Igniter::Error; end
|
|
52
|
+
class TimeoutError < Igniter::Error; end
|
|
53
|
+
|
|
54
|
+
# ── Class-level defaults ──────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
@handlers = {}
|
|
57
|
+
@timers = []
|
|
58
|
+
@default_state = {}
|
|
59
|
+
@mailbox_capacity = Mailbox::DEFAULT_CAPACITY
|
|
60
|
+
@mailbox_overflow = :block
|
|
61
|
+
@hooks = { start: [], crash: [], stop: [] }
|
|
62
|
+
|
|
63
|
+
class << self
|
|
64
|
+
attr_reader :handlers, :timers, :mailbox_capacity, :hooks
|
|
65
|
+
|
|
66
|
+
# ── Inheritance ─────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
def inherited(subclass)
|
|
69
|
+
super
|
|
70
|
+
subclass.instance_variable_set(:@handlers, {})
|
|
71
|
+
subclass.instance_variable_set(:@timers, [])
|
|
72
|
+
subclass.instance_variable_set(:@default_state, {})
|
|
73
|
+
subclass.instance_variable_set(:@mailbox_capacity, Mailbox::DEFAULT_CAPACITY)
|
|
74
|
+
subclass.instance_variable_set(:@mailbox_overflow, :block)
|
|
75
|
+
subclass.instance_variable_set(:@hooks, { start: [], crash: [], stop: [] })
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# ── DSL ─────────────────────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
# Set the initial state hash. Pass a plain Hash or a block returning one.
|
|
81
|
+
def initial_state(hash = nil, &block)
|
|
82
|
+
if block
|
|
83
|
+
@default_state_proc = block
|
|
84
|
+
else
|
|
85
|
+
@default_state = (hash || {}).freeze
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Return the resolved default state (evaluated fresh when a block was given).
|
|
90
|
+
def default_state
|
|
91
|
+
@default_state_proc ? @default_state_proc.call : @default_state
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Register a handler for messages of the given +type+.
|
|
95
|
+
def on(type, &handler)
|
|
96
|
+
@handlers[type.to_sym] = handler
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Register a recurring timer. The handler is called every +every+ seconds.
|
|
100
|
+
# Returning a Hash from the handler updates state; nil leaves it unchanged.
|
|
101
|
+
def schedule(name, every:, &handler)
|
|
102
|
+
@timers << { name: name.to_sym, interval: every.to_f, handler: handler }
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Maximum number of messages in the mailbox before overflow policy applies.
|
|
106
|
+
def mailbox_size(capacity)
|
|
107
|
+
@mailbox_capacity = capacity
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Overflow policy when the mailbox is full.
|
|
111
|
+
# One of: :block (default), :drop_oldest, :drop_newest, :error
|
|
112
|
+
def mailbox_overflow(policy)
|
|
113
|
+
@mailbox_overflow = policy
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def after_start(&hook)
|
|
117
|
+
@hooks[:start] << hook
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def after_crash(&hook)
|
|
121
|
+
@hooks[:crash] << hook
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def after_stop(&hook)
|
|
125
|
+
@hooks[:stop] << hook
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# ── Factory ─────────────────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
# Start the agent and return a Ref. The agent runs in a background Thread.
|
|
131
|
+
#
|
|
132
|
+
# Options:
|
|
133
|
+
# initial_state: Hash — override class-level default state
|
|
134
|
+
# on_crash: callable(error) — supervisor crash hook
|
|
135
|
+
# name: Symbol/String — register in Igniter::Registry under this name
|
|
136
|
+
#
|
|
137
|
+
def start(initial_state: nil, on_crash: nil, name: nil) # rubocop:disable Metrics/MethodLength
|
|
138
|
+
state_holder = StateHolder.new(initial_state || default_state)
|
|
139
|
+
mailbox = Mailbox.new(capacity: mailbox_capacity, overflow: @mailbox_overflow)
|
|
140
|
+
runner = Runner.new(
|
|
141
|
+
agent_class: self,
|
|
142
|
+
mailbox: mailbox,
|
|
143
|
+
state_holder: state_holder,
|
|
144
|
+
on_crash: on_crash
|
|
145
|
+
)
|
|
146
|
+
thread = runner.start
|
|
147
|
+
ref = Ref.new(thread: thread, mailbox: mailbox, state_holder: state_holder)
|
|
148
|
+
|
|
149
|
+
Igniter::Registry.register!(name, ref) if name
|
|
150
|
+
|
|
151
|
+
ref
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
@@ -14,14 +14,32 @@ module Igniter
|
|
|
14
14
|
|
|
15
15
|
def call
|
|
16
16
|
@context.runtime_nodes.each do |node|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
if node.kind == :compute
|
|
18
|
+
validate_callable_signature!(node)
|
|
19
|
+
elsif node.kind == :effect
|
|
20
|
+
validate_effect_adapter!(node)
|
|
21
|
+
end
|
|
20
22
|
end
|
|
21
23
|
end
|
|
22
24
|
|
|
23
25
|
private
|
|
24
26
|
|
|
27
|
+
def validate_effect_adapter!(node)
|
|
28
|
+
adapter = node.adapter_class
|
|
29
|
+
unless adapter.is_a?(Class) && adapter <= Igniter::Effect
|
|
30
|
+
raise @context.validation_error(
|
|
31
|
+
node,
|
|
32
|
+
"Effect '#{node.name}' adapter must be a subclass of Igniter::Effect"
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
validate_parameters_signature!(
|
|
37
|
+
node,
|
|
38
|
+
adapter.instance_method(:call).parameters,
|
|
39
|
+
adapter.name || "effect"
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
|
|
25
43
|
def validate_callable_signature!(node)
|
|
26
44
|
callable = node.callable
|
|
27
45
|
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module Differential
|
|
5
|
+
# Captures a single output that differed between primary and candidate.
|
|
6
|
+
class Divergence
|
|
7
|
+
attr_reader :output_name, :primary_value, :candidate_value, :kind
|
|
8
|
+
|
|
9
|
+
# @param output_name [Symbol]
|
|
10
|
+
# @param primary_value [Object]
|
|
11
|
+
# @param candidate_value [Object]
|
|
12
|
+
# @param kind [Symbol] :value_mismatch | :type_mismatch
|
|
13
|
+
def initialize(output_name:, primary_value:, candidate_value:, kind:)
|
|
14
|
+
@output_name = output_name
|
|
15
|
+
@primary_value = primary_value
|
|
16
|
+
@candidate_value = candidate_value
|
|
17
|
+
@kind = kind
|
|
18
|
+
freeze
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Numeric difference (candidate − primary). nil for non-numeric values.
|
|
22
|
+
def delta
|
|
23
|
+
return nil unless primary_value.is_a?(Numeric) && candidate_value.is_a?(Numeric)
|
|
24
|
+
|
|
25
|
+
candidate_value - primary_value
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module Differential
|
|
5
|
+
# Renders a Differential::Report as a human-readable text block.
|
|
6
|
+
#
|
|
7
|
+
# Example:
|
|
8
|
+
#
|
|
9
|
+
# Primary: PricingV1
|
|
10
|
+
# Candidate: PricingV2
|
|
11
|
+
# Match: NO
|
|
12
|
+
#
|
|
13
|
+
# DIVERGENCES (1):
|
|
14
|
+
# :tax
|
|
15
|
+
# primary: 15.0
|
|
16
|
+
# candidate: 22.5
|
|
17
|
+
# delta: +7.5
|
|
18
|
+
#
|
|
19
|
+
# CANDIDATE ONLY (1):
|
|
20
|
+
# :discount = 10.0
|
|
21
|
+
#
|
|
22
|
+
module Formatter
|
|
23
|
+
VALUE_MAX = 60
|
|
24
|
+
|
|
25
|
+
class << self
|
|
26
|
+
def format(report) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
27
|
+
lines = []
|
|
28
|
+
lines << "Primary: #{report.primary_class.name}"
|
|
29
|
+
lines << "Candidate: #{report.candidate_class.name}"
|
|
30
|
+
lines << "Match: #{report.match? ? "YES" : "NO"}"
|
|
31
|
+
|
|
32
|
+
if report.primary_error
|
|
33
|
+
lines << ""
|
|
34
|
+
lines << "PRIMARY ERROR: #{report.primary_error.message}"
|
|
35
|
+
return lines.join("\n")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
if report.candidate_error
|
|
39
|
+
lines << ""
|
|
40
|
+
lines << "CANDIDATE ERROR: #{report.candidate_error.message}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
lines << ""
|
|
44
|
+
|
|
45
|
+
if report.divergences.empty? && report.primary_only.empty? && report.candidate_only.empty?
|
|
46
|
+
lines << "All shared outputs match."
|
|
47
|
+
else
|
|
48
|
+
append_divergences(report, lines)
|
|
49
|
+
append_only_section("CANDIDATE ONLY", report.candidate_only, lines)
|
|
50
|
+
append_only_section("PRIMARY ONLY", report.primary_only, lines)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
lines.join("\n")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def append_divergences(report, lines) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
59
|
+
return if report.divergences.empty?
|
|
60
|
+
|
|
61
|
+
lines << "DIVERGENCES (#{report.divergences.size}):"
|
|
62
|
+
report.divergences.each do |div|
|
|
63
|
+
lines << " :#{div.output_name}"
|
|
64
|
+
lines << " primary: #{fmt(div.primary_value)}"
|
|
65
|
+
lines << " candidate: #{fmt(div.candidate_value)}"
|
|
66
|
+
next unless div.delta
|
|
67
|
+
|
|
68
|
+
d = div.delta
|
|
69
|
+
lines << " delta: #{d >= 0 ? "+#{d}" : d}"
|
|
70
|
+
end
|
|
71
|
+
lines << ""
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def append_only_section(label, hash, lines)
|
|
75
|
+
return if hash.empty?
|
|
76
|
+
|
|
77
|
+
lines << "#{label} (#{hash.size}):"
|
|
78
|
+
hash.each { |name, val| lines << " :#{name} = #{fmt(val)}" }
|
|
79
|
+
lines << ""
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def fmt(value) # rubocop:disable Metrics/CyclomaticComplexity
|
|
83
|
+
str = case value
|
|
84
|
+
when nil then "nil"
|
|
85
|
+
when String then value.inspect
|
|
86
|
+
when Symbol then value.inspect
|
|
87
|
+
when Hash then "{#{value.map { |k, v| "#{k}: #{v.inspect}" }.join(", ")}}"
|
|
88
|
+
when Array then "[#{value.map(&:inspect).join(", ")}]"
|
|
89
|
+
else value.inspect
|
|
90
|
+
end
|
|
91
|
+
str.length > VALUE_MAX ? "#{str[0, VALUE_MAX - 3]}..." : str
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module Differential
|
|
5
|
+
# Structured result of comparing two contract implementations.
|
|
6
|
+
#
|
|
7
|
+
# Attributes:
|
|
8
|
+
# primary_class — the reference contract class
|
|
9
|
+
# candidate_class — the contract being validated against the primary
|
|
10
|
+
# inputs — Hash of inputs used for both executions
|
|
11
|
+
# divergences — Array<Divergence> for outputs that differ in value
|
|
12
|
+
# primary_only — Hash{ Symbol => value } outputs absent in candidate
|
|
13
|
+
# candidate_only — Hash{ Symbol => value } outputs absent in primary
|
|
14
|
+
# primary_error — Igniter::Error raised by primary (usually nil)
|
|
15
|
+
# candidate_error — Igniter::Error raised by candidate (nil on success)
|
|
16
|
+
class Report
|
|
17
|
+
attr_reader :primary_class, :candidate_class, :inputs,
|
|
18
|
+
:divergences, :primary_only, :candidate_only,
|
|
19
|
+
:primary_error, :candidate_error
|
|
20
|
+
|
|
21
|
+
def initialize( # rubocop:disable Metrics/ParameterLists
|
|
22
|
+
primary_class:, candidate_class:, inputs:,
|
|
23
|
+
divergences:, primary_only:, candidate_only:,
|
|
24
|
+
primary_error: nil, candidate_error: nil
|
|
25
|
+
)
|
|
26
|
+
@primary_class = primary_class
|
|
27
|
+
@candidate_class = candidate_class
|
|
28
|
+
@inputs = inputs
|
|
29
|
+
@divergences = divergences.freeze
|
|
30
|
+
@primary_only = primary_only.freeze
|
|
31
|
+
@candidate_only = candidate_only.freeze
|
|
32
|
+
@primary_error = primary_error
|
|
33
|
+
@candidate_error = candidate_error
|
|
34
|
+
freeze
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# True when candidate produces identical outputs with no errors.
|
|
38
|
+
def match?
|
|
39
|
+
divergences.empty? &&
|
|
40
|
+
primary_only.empty? &&
|
|
41
|
+
candidate_only.empty? &&
|
|
42
|
+
primary_error.nil? &&
|
|
43
|
+
candidate_error.nil?
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# One-line summary suitable for logging.
|
|
47
|
+
def summary # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
48
|
+
if match?
|
|
49
|
+
"match"
|
|
50
|
+
else
|
|
51
|
+
parts = []
|
|
52
|
+
parts << "#{divergences.size} value(s) differ" if divergences.any?
|
|
53
|
+
parts << "#{primary_only.size} output(s) only in primary" if primary_only.any?
|
|
54
|
+
parts << "#{candidate_only.size} output(s) only in candidate" if candidate_only.any?
|
|
55
|
+
parts << "candidate error: #{candidate_error.message}" if candidate_error
|
|
56
|
+
parts << "primary error: #{primary_error.message}" if primary_error
|
|
57
|
+
"diverged — #{parts.join(", ")}"
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Human-readable ASCII report.
|
|
62
|
+
def explain
|
|
63
|
+
Formatter.format(self)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
alias to_s explain
|
|
67
|
+
|
|
68
|
+
# Structured (serialisable) representation.
|
|
69
|
+
def to_h # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
70
|
+
{
|
|
71
|
+
primary: primary_class.name,
|
|
72
|
+
candidate: candidate_class.name,
|
|
73
|
+
match: match?,
|
|
74
|
+
divergences: divergences.map do |d|
|
|
75
|
+
{ output: d.output_name, primary: d.primary_value, candidate: d.candidate_value,
|
|
76
|
+
kind: d.kind, delta: d.delta }
|
|
77
|
+
end,
|
|
78
|
+
primary_only: primary_only,
|
|
79
|
+
candidate_only: candidate_only,
|
|
80
|
+
primary_error: primary_error&.message,
|
|
81
|
+
candidate_error: candidate_error&.message
|
|
82
|
+
}
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|