igniter 0.4.0 → 0.4.3

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 (69) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +25 -0
  3. data/README.md +238 -218
  4. data/docs/LLM_V1.md +335 -0
  5. data/docs/PATTERNS.md +189 -0
  6. data/docs/SERVER_V1.md +313 -0
  7. data/examples/README.md +129 -0
  8. data/examples/agents.rb +150 -0
  9. data/examples/differential.rb +161 -0
  10. data/examples/distributed_server.rb +94 -0
  11. data/examples/effects.rb +184 -0
  12. data/examples/invariants.rb +179 -0
  13. data/examples/order_pipeline.rb +163 -0
  14. data/examples/provenance.rb +122 -0
  15. data/examples/saga.rb +110 -0
  16. data/lib/igniter/agent/mailbox.rb +96 -0
  17. data/lib/igniter/agent/message.rb +21 -0
  18. data/lib/igniter/agent/ref.rb +86 -0
  19. data/lib/igniter/agent/runner.rb +129 -0
  20. data/lib/igniter/agent/state_holder.rb +23 -0
  21. data/lib/igniter/agent.rb +155 -0
  22. data/lib/igniter/compiler/validators/callable_validator.rb +21 -3
  23. data/lib/igniter/differential/divergence.rb +29 -0
  24. data/lib/igniter/differential/formatter.rb +96 -0
  25. data/lib/igniter/differential/report.rb +86 -0
  26. data/lib/igniter/differential/runner.rb +130 -0
  27. data/lib/igniter/differential.rb +51 -0
  28. data/lib/igniter/dsl/contract_builder.rb +32 -0
  29. data/lib/igniter/effect.rb +91 -0
  30. data/lib/igniter/effect_registry.rb +78 -0
  31. data/lib/igniter/errors.rb +11 -1
  32. data/lib/igniter/execution_report/builder.rb +54 -0
  33. data/lib/igniter/execution_report/formatter.rb +50 -0
  34. data/lib/igniter/execution_report/node_entry.rb +24 -0
  35. data/lib/igniter/execution_report/report.rb +65 -0
  36. data/lib/igniter/execution_report.rb +32 -0
  37. data/lib/igniter/extensions/differential.rb +114 -0
  38. data/lib/igniter/extensions/execution_report.rb +27 -0
  39. data/lib/igniter/extensions/invariants.rb +116 -0
  40. data/lib/igniter/extensions/provenance.rb +45 -0
  41. data/lib/igniter/extensions/saga.rb +74 -0
  42. data/lib/igniter/integrations/agents.rb +18 -0
  43. data/lib/igniter/invariant.rb +50 -0
  44. data/lib/igniter/model/effect_node.rb +37 -0
  45. data/lib/igniter/model.rb +1 -0
  46. data/lib/igniter/property_testing/formatter.rb +66 -0
  47. data/lib/igniter/property_testing/generators.rb +115 -0
  48. data/lib/igniter/property_testing/result.rb +45 -0
  49. data/lib/igniter/property_testing/run.rb +43 -0
  50. data/lib/igniter/property_testing/runner.rb +47 -0
  51. data/lib/igniter/property_testing.rb +64 -0
  52. data/lib/igniter/provenance/builder.rb +97 -0
  53. data/lib/igniter/provenance/lineage.rb +82 -0
  54. data/lib/igniter/provenance/node_trace.rb +65 -0
  55. data/lib/igniter/provenance/text_formatter.rb +70 -0
  56. data/lib/igniter/provenance.rb +29 -0
  57. data/lib/igniter/registry.rb +67 -0
  58. data/lib/igniter/runtime/resolver.rb +15 -0
  59. data/lib/igniter/saga/compensation.rb +31 -0
  60. data/lib/igniter/saga/compensation_record.rb +20 -0
  61. data/lib/igniter/saga/executor.rb +85 -0
  62. data/lib/igniter/saga/formatter.rb +49 -0
  63. data/lib/igniter/saga/result.rb +47 -0
  64. data/lib/igniter/saga.rb +56 -0
  65. data/lib/igniter/stream_loop.rb +80 -0
  66. data/lib/igniter/supervisor.rb +167 -0
  67. data/lib/igniter/version.rb +1 -1
  68. data/lib/igniter.rb +10 -0
  69. metadata +57 -1
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors"
4
+ require_relative "differential/divergence"
5
+ require_relative "differential/report"
6
+ require_relative "differential/formatter"
7
+ require_relative "differential/runner"
8
+
9
+ module Igniter
10
+ # Differential Execution — compare two contract implementations output-by-output.
11
+ #
12
+ # Usage:
13
+ #
14
+ # require "igniter/extensions/differential"
15
+ #
16
+ # # Standalone comparison
17
+ # report = Igniter::Differential.compare(
18
+ # primary: PricingV1,
19
+ # candidate: PricingV2,
20
+ # inputs: { price: 50.0, quantity: 3 }
21
+ # )
22
+ # puts report.explain
23
+ # puts report.match? # => false
24
+ # puts report.divergences # => [#<Divergence ...>]
25
+ #
26
+ # # Per-instance diff (primary already resolved)
27
+ # contract = PricingV1.new(price: 50.0, quantity: 3)
28
+ # contract.resolve_all
29
+ # report = contract.diff_against(PricingV2)
30
+ #
31
+ # # Shadow mode (runs candidate alongside primary automatically)
32
+ # class PricingContract < Igniter::Contract
33
+ # shadow_with PricingV2, on_divergence: ->(r) { Rails.logger.warn(r.summary) }
34
+ # define { ... }
35
+ # end
36
+ #
37
+ module Differential
38
+ class DifferentialError < Igniter::Error; end
39
+
40
+ # Compare +primary+ and +candidate+ contract classes on the given +inputs+.
41
+ #
42
+ # @param primary [Class<Igniter::Contract>]
43
+ # @param candidate [Class<Igniter::Contract>]
44
+ # @param inputs [Hash]
45
+ # @param tolerance [Numeric, nil] optional allowable numeric difference
46
+ # @return [Report]
47
+ def self.compare(primary:, candidate:, inputs:, tolerance: nil)
48
+ Runner.new(primary, candidate, tolerance: tolerance).run(inputs)
49
+ end
50
+ end
51
+ end
@@ -248,6 +248,21 @@ module Igniter
248
248
  )
249
249
  end
250
250
 
251
+ def effect(name, uses:, depends_on: nil, with: nil, **metadata)
252
+ adapter_class = resolve_effect_adapter(name, uses)
253
+
254
+ add_node(
255
+ Model::EffectNode.new(
256
+ id: next_id,
257
+ name: name,
258
+ dependencies: normalize_dependencies(depends_on: depends_on, with: with),
259
+ adapter_class: adapter_class,
260
+ path: scoped_path(name),
261
+ metadata: with_source_location(metadata)
262
+ )
263
+ )
264
+ end
265
+
251
266
  def await(name, event:, **metadata)
252
267
  add_node(
253
268
  Model::AwaitNode.new(
@@ -306,6 +321,23 @@ module Igniter
306
321
  Array(dependencies)
307
322
  end
308
323
 
324
+ def resolve_effect_adapter(name, uses)
325
+ case uses
326
+ when Symbol, String
327
+ Igniter.effect_registry.fetch(uses.to_sym).adapter_class
328
+ when Class
329
+ unless uses <= Igniter::Effect
330
+ raise CompileError,
331
+ "effect :#{name} `uses:` must be an Igniter::Effect subclass or a registered effect name"
332
+ end
333
+
334
+ uses
335
+ else
336
+ raise CompileError,
337
+ "effect :#{name} `uses:` must be an Igniter::Effect subclass or a registered effect name"
338
+ end
339
+ end
340
+
309
341
  def build_guard_matcher(matcher_name, matcher_value, dependency)
310
342
  case matcher_name
311
343
  when :eq
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ # Base class for side-effect adapters.
5
+ #
6
+ # An Effect is a first-class node in the computation graph that encapsulates
7
+ # an external interaction — database, HTTP call, cache write, queue publish, etc.
8
+ #
9
+ # Effects are declared in contracts via the `effect` DSL keyword:
10
+ #
11
+ # class UserRepository < Igniter::Effect
12
+ # effect_type :database
13
+ # idempotent false
14
+ #
15
+ # def call(user_id:)
16
+ # { id: user_id, name: DB.find(user_id) }
17
+ # end
18
+ #
19
+ # compensate do |inputs:, value:|
20
+ # DB.delete(value[:id])
21
+ # end
22
+ # end
23
+ #
24
+ # class MyContract < Igniter::Contract
25
+ # define do
26
+ # input :user_id
27
+ # effect :user_data, uses: UserRepository, depends_on: :user_id
28
+ # output :user_data
29
+ # end
30
+ # end
31
+ #
32
+ # Effects participate fully in the graph:
33
+ # - Dependency resolution and topological ordering
34
+ # - Execution reports (shown as `effect:database`)
35
+ # - Saga compensations (built-in or contract-level)
36
+ # - Provenance tracing
37
+ class Effect < Executor
38
+ class << self
39
+ def inherited(subclass)
40
+ super
41
+ subclass.instance_variable_set(:@effect_type, @effect_type)
42
+ subclass.instance_variable_set(:@idempotent, @idempotent || false)
43
+ subclass.instance_variable_set(:@_built_in_compensation, @_built_in_compensation)
44
+ end
45
+
46
+ # Declares the category of side effect (e.g., :database, :http, :cache).
47
+ # Shown in execution reports as `effect:<type>`.
48
+ #
49
+ # @param value [Symbol, nil] — omit to read current value
50
+ # @return [Symbol]
51
+ def effect_type(value = nil)
52
+ return @effect_type || :generic if value.nil?
53
+
54
+ @effect_type = value.to_sym
55
+ end
56
+
57
+ # Marks this effect as idempotent — safe to retry without side effects.
58
+ # Informational metadata; does not change execution behaviour.
59
+ #
60
+ # @param value [Boolean]
61
+ def idempotent(value = true) # rubocop:disable Style/OptionalBooleanParameter
62
+ @idempotent = value
63
+ end
64
+
65
+ # @return [Boolean]
66
+ def idempotent?
67
+ @idempotent || false
68
+ end
69
+
70
+ # Declares a built-in compensating action for this effect.
71
+ #
72
+ # Called automatically during a Saga rollback when this effect succeeded
73
+ # but a downstream node failed. A contract-level `compensate :node_name`
74
+ # block takes precedence over the built-in one.
75
+ #
76
+ # The block receives:
77
+ # inputs: — Hash of the node's dependency values when it ran
78
+ # value: — the value produced by this effect (now being undone)
79
+ def compensate(&block)
80
+ raise ArgumentError, "Effect.compensate requires a block" unless block
81
+
82
+ @_built_in_compensation = block
83
+ end
84
+
85
+ # @return [Proc, nil]
86
+ def built_in_compensation
87
+ @_built_in_compensation
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ # Registry for named effect adapters.
5
+ #
6
+ # Allows registering effects by symbolic keys and resolving them in the DSL:
7
+ #
8
+ # Igniter.register_effect(:users_db, UserRepository)
9
+ #
10
+ # # In a contract:
11
+ # effect :user_data, uses: :users_db, depends_on: :user_id
12
+ #
13
+ # This decouples contracts from concrete adapter classes and enables
14
+ # environment-specific swaps (e.g. mock adapters in tests):
15
+ #
16
+ # # In spec_helper.rb:
17
+ # Igniter.effect_registry.clear
18
+ # Igniter.register_effect(:users_db, FakeUserRepository)
19
+ class EffectRegistry
20
+ Registration = Struct.new(:key, :adapter_class, :metadata, keyword_init: true)
21
+
22
+ def initialize
23
+ @entries = {}
24
+ end
25
+
26
+ # Register an effect adapter under a symbolic key.
27
+ #
28
+ # @param key [Symbol, String]
29
+ # @param adapter_class [Class] must be a subclass of Igniter::Effect
30
+ # @param metadata [Hash] optional arbitrary metadata
31
+ # @return [self]
32
+ def register(key, adapter_class, **metadata)
33
+ key = key.to_sym
34
+ unless adapter_class.is_a?(Class) && adapter_class <= Igniter::Effect
35
+ raise ArgumentError, "#{adapter_class.inspect} must be a subclass of Igniter::Effect"
36
+ end
37
+
38
+ @entries[key] = Registration.new(key: key, adapter_class: adapter_class, metadata: metadata.freeze)
39
+ self
40
+ end
41
+
42
+ # Fetch a registration by key.
43
+ #
44
+ # @param key [Symbol, String]
45
+ # @return [Registration]
46
+ # @raise [KeyError] if not registered
47
+ def fetch(key)
48
+ @entries.fetch(key.to_sym) do
49
+ raise KeyError,
50
+ "Effect '#{key}' is not registered. " \
51
+ "Use Igniter.register_effect(:#{key}, AdapterClass) before compiling."
52
+ end
53
+ end
54
+
55
+ # @param key [Symbol, String]
56
+ # @return [Boolean]
57
+ def registered?(key)
58
+ @entries.key?(key.to_sym)
59
+ end
60
+
61
+ # @return [Array<Registration>]
62
+ def all
63
+ @entries.values.freeze
64
+ end
65
+
66
+ # @return [Integer]
67
+ def size
68
+ @entries.size
69
+ end
70
+
71
+ # Remove all registrations. Useful in tests.
72
+ # @return [self]
73
+ def clear
74
+ @entries.clear
75
+ self
76
+ end
77
+ end
78
+ end
@@ -45,7 +45,7 @@ module Igniter
45
45
 
46
46
  return message if details.empty?
47
47
 
48
- "#{message} [#{details.join(', ')}]"
48
+ "#{message} [#{details.join(", ")}]"
49
49
  end
50
50
  end
51
51
 
@@ -58,6 +58,7 @@ module Igniter
58
58
  class ResolutionError < Error; end
59
59
  class CompositionError < Error; end
60
60
  class BranchSelectionError < Error; end
61
+
61
62
  class PendingDependencyError < Error
62
63
  attr_reader :deferred_result
63
64
 
@@ -66,4 +67,13 @@ module Igniter
66
67
  super(message, context: context)
67
68
  end
68
69
  end
70
+
71
+ class InvariantError < Error
72
+ attr_reader :violations
73
+
74
+ def initialize(message = nil, violations: [], context: {})
75
+ @violations = violations.freeze
76
+ super(message, context: context)
77
+ end
78
+ end
69
79
  end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module ExecutionReport
5
+ # Builds an ExecutionReport::Report by reading the compiled graph's
6
+ # resolution order and matching each node against the execution cache.
7
+ #
8
+ # Works for both successful and failed executions — nodes that never
9
+ # ran (because a dependency failed) appear with status :pending.
10
+ class Builder
11
+ class << self
12
+ def build(contract)
13
+ new(contract).build
14
+ end
15
+ end
16
+
17
+ def initialize(contract)
18
+ @contract = contract
19
+ @graph = contract.execution.compiled_graph
20
+ @cache = contract.execution.cache
21
+ end
22
+
23
+ def build
24
+ entries = @graph.resolution_order.map { |node| entry_for(node) }
25
+ Report.new(contract_class: @contract.class, entries: entries)
26
+ end
27
+
28
+ private
29
+
30
+ def entry_for(node) # rubocop:disable Metrics/MethodLength
31
+ state = @cache.fetch(node.name)
32
+
33
+ status = if state.nil?
34
+ :pending
35
+ elsif state.succeeded?
36
+ :succeeded
37
+ elsif state.failed?
38
+ :failed
39
+ else
40
+ :pending
41
+ end
42
+
43
+ NodeEntry.new(
44
+ name: node.name,
45
+ kind: node.kind,
46
+ status: status,
47
+ value: state&.value,
48
+ error: state&.error,
49
+ effect_type: node.respond_to?(:effect_type) ? node.effect_type : nil
50
+ )
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module ExecutionReport
5
+ # Formats an ExecutionReport::Report as human-readable text.
6
+ #
7
+ # Example:
8
+ #
9
+ # Contract: OrderWorkflow
10
+ # Success: NO
11
+ #
12
+ # [ok] input :order_id
13
+ # [ok] input :amount
14
+ # [ok] compute :reserve_stock
15
+ # [fail] compute :charge_card
16
+ # error: Insufficient funds
17
+ # [pend] compute :send_confirmation
18
+ #
19
+ module Formatter
20
+ class << self
21
+ def format(report)
22
+ lines = []
23
+ lines << "Contract: #{report.contract_class.name}"
24
+ lines << "Success: #{report.success? ? "YES" : "NO"}"
25
+ lines << ""
26
+
27
+ report.entries.each do |entry|
28
+ append_entry(entry, lines)
29
+ end
30
+
31
+ lines.join("\n")
32
+ end
33
+
34
+ private
35
+
36
+ def append_entry(entry, lines)
37
+ tag = case entry.status
38
+ when :succeeded then "[ok] "
39
+ when :failed then "[fail]"
40
+ else "[pend]"
41
+ end
42
+ kind_label = entry.effect_type ? "effect:#{entry.effect_type}" : entry.kind.to_s
43
+ kind_str = kind_label.ljust(10)
44
+ lines << " #{tag} #{kind_str} :#{entry.name}"
45
+ lines << " error: #{entry.error.message}" if entry.failed? && entry.error
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module ExecutionReport
5
+ # Snapshot of a single node's execution state.
6
+ class NodeEntry
7
+ attr_reader :name, :kind, :status, :value, :error, :effect_type
8
+
9
+ def initialize(name:, kind:, status:, value: nil, error: nil, effect_type: nil) # rubocop:disable Metrics/ParameterLists
10
+ @name = name
11
+ @kind = kind
12
+ @status = status
13
+ @value = value
14
+ @error = error
15
+ @effect_type = effect_type
16
+ freeze
17
+ end
18
+
19
+ def succeeded? = status == :succeeded
20
+ def failed? = status == :failed
21
+ def pending? = !succeeded? && !failed?
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module ExecutionReport
5
+ # Structured post-hoc report of what happened during contract execution.
6
+ #
7
+ # Built from the compiled graph's resolution order + execution cache state.
8
+ # Can be generated any time after resolve_all (including after an error).
9
+ #
10
+ # Attributes:
11
+ # contract_class — the contract class that was executed
12
+ # entries — Array<NodeEntry> in resolution order
13
+ class Report
14
+ attr_reader :contract_class, :entries
15
+
16
+ def initialize(contract_class:, entries:)
17
+ @contract_class = contract_class
18
+ @entries = entries.freeze
19
+ freeze
20
+ end
21
+
22
+ # True when no nodes failed.
23
+ def success?
24
+ entries.none?(&:failed?)
25
+ end
26
+
27
+ # Symbol names of nodes that succeeded.
28
+ def resolved_nodes
29
+ entries.select(&:succeeded?).map(&:name)
30
+ end
31
+
32
+ # Symbol names of nodes that failed.
33
+ def failed_nodes
34
+ entries.select(&:failed?).map(&:name)
35
+ end
36
+
37
+ # Symbol names of nodes that never ran.
38
+ def pending_nodes
39
+ entries.select(&:pending?).map(&:name)
40
+ end
41
+
42
+ # Map of { node_name => error } for failed nodes.
43
+ def errors
44
+ entries.select(&:failed?).each_with_object({}) { |e, h| h[e.name] = e.error }
45
+ end
46
+
47
+ # Human-readable execution report.
48
+ def explain
49
+ Formatter.format(self)
50
+ end
51
+
52
+ alias to_s explain
53
+
54
+ def to_h
55
+ {
56
+ contract: contract_class.name,
57
+ success: success?,
58
+ nodes: entries.map do |e|
59
+ { name: e.name, kind: e.kind, status: e.status, error: e.error&.message }
60
+ end
61
+ }
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors"
4
+ require_relative "execution_report/node_entry"
5
+ require_relative "execution_report/formatter"
6
+ require_relative "execution_report/report"
7
+ require_relative "execution_report/builder"
8
+
9
+ module Igniter
10
+ # Post-hoc execution report — answers "what ran, what succeeded, what failed?"
11
+ #
12
+ # Reconstructs a structured timeline from the compiled graph's resolution
13
+ # order and the execution cache. Works regardless of whether the contract
14
+ # succeeded or raised an error.
15
+ #
16
+ # Usage:
17
+ #
18
+ # require "igniter/extensions/execution_report"
19
+ #
20
+ # contract = MyContract.new(inputs)
21
+ # contract.resolve_all rescue nil # run regardless of outcome
22
+ #
23
+ # report = contract.execution_report
24
+ # report.success? # => false
25
+ # report.failed_nodes # => [:charge_card]
26
+ # report.pending_nodes # => [:send_confirmation]
27
+ # puts report.explain # formatted table
28
+ #
29
+ module ExecutionReport
30
+ class ExecutionReportError < Igniter::Error; end
31
+ end
32
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "igniter"
4
+ require "igniter/differential"
5
+
6
+ module Igniter
7
+ module Extensions
8
+ # Patches Igniter::Contract with:
9
+ # - Class method: shadow_with(candidate, async:, on_divergence:, tolerance:)
10
+ # - Instance method: diff_against(candidate_class, tolerance:)
11
+ # - Automatic shadow execution after resolve_all (when shadow_with is declared)
12
+ #
13
+ # This module is applied globally via:
14
+ # Igniter::Contract.include(Igniter::Extensions::Differential)
15
+ #
16
+ module Differential
17
+ def self.included(base)
18
+ base.extend(ClassMethods)
19
+ base.prepend(InstanceMethods)
20
+ end
21
+
22
+ # ── Class-level DSL ────────────────────────────────────────────────────
23
+
24
+ module ClassMethods
25
+ # Declare a shadow candidate that runs alongside every resolve_all call.
26
+ #
27
+ # @param candidate_class [Class<Igniter::Contract>]
28
+ # @param async [Boolean] run the shadow in a background Thread
29
+ # @param on_divergence [#call, nil] invoked with a Report when outputs differ
30
+ # @param tolerance [Numeric, nil] passed to the differential runner
31
+ def shadow_with(candidate_class, async: false, on_divergence: nil, tolerance: nil)
32
+ @_shadow_candidate = candidate_class
33
+ @_shadow_async = async
34
+ @_shadow_on_divergence = on_divergence
35
+ @_shadow_tolerance = tolerance
36
+ end
37
+
38
+ def shadow_candidate = @_shadow_candidate
39
+ def shadow_async? = @_shadow_async || false
40
+ def shadow_on_divergence = @_shadow_on_divergence
41
+ def shadow_tolerance = @_shadow_tolerance
42
+ end
43
+
44
+ # ── Instance override + new public method ─────────────────────────────
45
+
46
+ module InstanceMethods
47
+ # Intercepts resolve_all to trigger shadow execution when shadow_with
48
+ # has been declared. Uses a thread-local flag to prevent recursive
49
+ # shadow calls when the runner itself invokes resolve_all internally.
50
+ def resolve_all(...)
51
+ result = super
52
+ run_shadow unless Thread.current[:igniter_skip_shadow]
53
+ result
54
+ end
55
+
56
+ # Compare the already-executed primary contract against +candidate_class+.
57
+ # Avoids re-running the primary (reads outputs from the existing cache).
58
+ #
59
+ # Raises DifferentialError if resolve_all has not been called yet.
60
+ #
61
+ # @return [Igniter::Differential::Report]
62
+ def diff_against(candidate_class, tolerance: nil)
63
+ unless execution.cache.values.any?
64
+ raise Igniter::Differential::DifferentialError,
65
+ "Contract has not been executed — call resolve_all first"
66
+ end
67
+
68
+ inputs = extract_inputs_from_execution
69
+ runner = Igniter::Differential::Runner.new(self.class, candidate_class, tolerance: tolerance)
70
+ runner.run_with_primary_execution(execution, inputs)
71
+ end
72
+
73
+ private
74
+
75
+ def run_shadow # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
76
+ candidate = self.class.shadow_candidate
77
+ return unless candidate
78
+
79
+ on_divergence = self.class.shadow_on_divergence
80
+ tolerance = self.class.shadow_tolerance
81
+ inputs = extract_inputs_from_execution
82
+
83
+ task = lambda do
84
+ runner = Igniter::Differential::Runner.new(self.class, candidate, tolerance: tolerance)
85
+ report = runner.run_with_primary_execution(execution, inputs)
86
+ on_divergence.call(report) if on_divergence && !report.match?
87
+ end
88
+
89
+ if self.class.shadow_async?
90
+ Thread.new { task.call }
91
+ else
92
+ task.call
93
+ end
94
+ end
95
+
96
+ # Reads input node values from the resolved cache for re-use in
97
+ # secondary executions (shadow / diff_against).
98
+ def extract_inputs_from_execution
99
+ graph = execution.compiled_graph
100
+ cache = execution.cache
101
+
102
+ graph.nodes.each_with_object({}) do |node, acc|
103
+ next unless node.kind == :input
104
+
105
+ state = cache.fetch(node.name)
106
+ acc[node.name] = state&.value
107
+ end
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
113
+
114
+ Igniter::Contract.include(Igniter::Extensions::Differential)
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "igniter"
4
+ require "igniter/execution_report"
5
+
6
+ module Igniter
7
+ module Extensions
8
+ # Adds execution_report method to all Igniter contracts.
9
+ #
10
+ # Applied globally via:
11
+ # Igniter::Contract.include(Igniter::Extensions::ExecutionReport)
12
+ #
13
+ module ExecutionReport
14
+ # Build a structured execution report from the current execution state.
15
+ #
16
+ # Can be called after resolve_all succeeds OR after it raises — in both
17
+ # cases the cache contains partial or full execution state.
18
+ #
19
+ # @return [Igniter::ExecutionReport::Report]
20
+ def execution_report
21
+ Igniter::ExecutionReport::Builder.build(self)
22
+ end
23
+ end
24
+ end
25
+ end
26
+
27
+ Igniter::Contract.include(Igniter::Extensions::ExecutionReport)