igniter 0.3.1 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +25 -0
- data/README.md +238 -218
- data/docs/DISTRIBUTED_CONTRACTS_V1.md +493 -0
- 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/distributed_workflow.rb +52 -0
- data/examples/effects.rb +184 -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/compiled_graph.rb +12 -0
- data/lib/igniter/compiler/validation_pipeline.rb +3 -1
- data/lib/igniter/compiler/validators/await_validator.rb +53 -0
- data/lib/igniter/compiler/validators/callable_validator.rb +21 -3
- data/lib/igniter/compiler/validators/dependencies_validator.rb +41 -1
- data/lib/igniter/compiler/validators/remote_validator.rb +58 -0
- data/lib/igniter/compiler.rb +2 -0
- data/lib/igniter/contract.rb +59 -8
- 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 +74 -4
- data/lib/igniter/effect.rb +91 -0
- data/lib/igniter/effect_registry.rb +78 -0
- data/lib/igniter/errors.rb +17 -2
- 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/invariants.rb +116 -0
- data/lib/igniter/extensions/provenance.rb +45 -0
- data/lib/igniter/extensions/saga.rb +74 -0
- data/lib/igniter/integrations/agents.rb +18 -0
- data/lib/igniter/integrations/llm/config.rb +69 -0
- data/lib/igniter/integrations/llm/context.rb +74 -0
- data/lib/igniter/integrations/llm/executor.rb +159 -0
- data/lib/igniter/integrations/llm/providers/anthropic.rb +148 -0
- data/lib/igniter/integrations/llm/providers/base.rb +33 -0
- data/lib/igniter/integrations/llm/providers/ollama.rb +137 -0
- data/lib/igniter/integrations/llm/providers/openai.rb +153 -0
- data/lib/igniter/integrations/llm.rb +59 -0
- data/lib/igniter/integrations/rails/cable_adapter.rb +49 -0
- data/lib/igniter/integrations/rails/contract_job.rb +76 -0
- data/lib/igniter/integrations/rails/generators/contract/contract_generator.rb +22 -0
- data/lib/igniter/integrations/rails/generators/install/install_generator.rb +33 -0
- data/lib/igniter/integrations/rails/railtie.rb +25 -0
- data/lib/igniter/integrations/rails/webhook_concern.rb +49 -0
- data/lib/igniter/integrations/rails.rb +12 -0
- data/lib/igniter/invariant.rb +50 -0
- data/lib/igniter/model/await_node.rb +21 -0
- data/lib/igniter/model/effect_node.rb +37 -0
- data/lib/igniter/model/remote_node.rb +26 -0
- data/lib/igniter/model.rb +3 -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/execution.rb +2 -2
- data/lib/igniter/runtime/input_validator.rb +5 -3
- data/lib/igniter/runtime/resolver.rb +58 -1
- data/lib/igniter/runtime/stores/active_record_store.rb +13 -1
- data/lib/igniter/runtime/stores/file_store.rb +50 -2
- data/lib/igniter/runtime/stores/memory_store.rb +55 -2
- data/lib/igniter/runtime/stores/redis_store.rb +13 -1
- 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/server/client.rb +123 -0
- data/lib/igniter/server/config.rb +27 -0
- data/lib/igniter/server/handlers/base.rb +105 -0
- data/lib/igniter/server/handlers/contracts_handler.rb +15 -0
- data/lib/igniter/server/handlers/event_handler.rb +28 -0
- data/lib/igniter/server/handlers/execute_handler.rb +37 -0
- data/lib/igniter/server/handlers/health_handler.rb +32 -0
- data/lib/igniter/server/handlers/status_handler.rb +27 -0
- data/lib/igniter/server/http_server.rb +109 -0
- data/lib/igniter/server/rack_app.rb +35 -0
- data/lib/igniter/server/registry.rb +56 -0
- data/lib/igniter/server/router.rb +75 -0
- data/lib/igniter/server.rb +67 -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 +14 -0
- metadata +92 -2
|
@@ -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
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module Differential
|
|
5
|
+
# Executes two contract classes with identical inputs and builds a Report.
|
|
6
|
+
#
|
|
7
|
+
# Uses Thread.current[:igniter_skip_shadow] to prevent recursive shadow
|
|
8
|
+
# execution when a contract with shadow_with is run inside the runner.
|
|
9
|
+
class Runner
|
|
10
|
+
def initialize(primary_class, candidate_class, tolerance: nil)
|
|
11
|
+
@primary_class = primary_class
|
|
12
|
+
@candidate_class = candidate_class
|
|
13
|
+
@tolerance = tolerance
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Execute both contracts fresh from +inputs+ and compare outputs.
|
|
17
|
+
def run(inputs)
|
|
18
|
+
primary_exec, primary_error = execute(@primary_class, inputs)
|
|
19
|
+
candidate_exec, candidate_error = execute(@candidate_class, inputs)
|
|
20
|
+
|
|
21
|
+
primary_outputs = primary_exec ? extract_outputs(primary_exec) : {}
|
|
22
|
+
candidate_outputs = candidate_exec ? extract_outputs(candidate_exec) : {}
|
|
23
|
+
|
|
24
|
+
build_report(primary_outputs, candidate_outputs, inputs, primary_error, candidate_error)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Compare using an already-resolved primary execution (avoids re-running
|
|
28
|
+
# the primary contract and its side effects a second time).
|
|
29
|
+
def run_with_primary_execution(primary_execution, inputs)
|
|
30
|
+
primary_outputs = extract_outputs(primary_execution)
|
|
31
|
+
candidate_exec, candidate_error = execute(@candidate_class, inputs)
|
|
32
|
+
candidate_outputs = candidate_exec ? extract_outputs(candidate_exec) : {}
|
|
33
|
+
|
|
34
|
+
build_report(primary_outputs, candidate_outputs, inputs, nil, candidate_error)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
# Execute +klass+ with +inputs+, suppressing shadow execution to prevent
|
|
40
|
+
# recursive comparisons. Returns [execution, nil] on success or
|
|
41
|
+
# [nil, error] if the contract raises.
|
|
42
|
+
def execute(klass, inputs)
|
|
43
|
+
Thread.current[:igniter_skip_shadow] = true
|
|
44
|
+
contract = klass.new(inputs)
|
|
45
|
+
contract.resolve_all
|
|
46
|
+
[contract.execution, nil]
|
|
47
|
+
rescue Igniter::Error => e
|
|
48
|
+
[nil, e]
|
|
49
|
+
ensure
|
|
50
|
+
Thread.current[:igniter_skip_shadow] = nil
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Read all output values from a resolved execution's cache.
|
|
54
|
+
# Output nodes live in graph.outputs (not graph.nodes).
|
|
55
|
+
# Each output node's source_root (Symbol) names the computation node in cache.
|
|
56
|
+
def extract_outputs(execution)
|
|
57
|
+
graph = execution.compiled_graph
|
|
58
|
+
cache = execution.cache
|
|
59
|
+
|
|
60
|
+
graph.outputs.each_with_object({}) do |node, acc|
|
|
61
|
+
state = cache.fetch(node.source_root)
|
|
62
|
+
acc[node.name] = normalize_value(state&.value)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Flatten Runtime wrapper objects to plain Ruby values so that structural
|
|
67
|
+
# equality works across independently-resolved executions.
|
|
68
|
+
def normalize_value(val)
|
|
69
|
+
case val
|
|
70
|
+
when Runtime::Result then val.to_h
|
|
71
|
+
when Runtime::CollectionResult then val.summary
|
|
72
|
+
when Runtime::DeferredResult then { pending: true, event: val.waiting_on }
|
|
73
|
+
else val
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def build_report(primary_outputs, candidate_outputs, inputs, primary_error, candidate_error) # rubocop:disable Metrics/MethodLength
|
|
78
|
+
common = primary_outputs.keys & candidate_outputs.keys
|
|
79
|
+
divergences = compare_common(primary_outputs, candidate_outputs, common)
|
|
80
|
+
primary_only = slice_missing(primary_outputs, candidate_outputs)
|
|
81
|
+
candidate_only = slice_missing(candidate_outputs, primary_outputs)
|
|
82
|
+
|
|
83
|
+
Report.new(
|
|
84
|
+
primary_class: @primary_class,
|
|
85
|
+
candidate_class: @candidate_class,
|
|
86
|
+
inputs: inputs,
|
|
87
|
+
divergences: divergences,
|
|
88
|
+
primary_only: primary_only,
|
|
89
|
+
candidate_only: candidate_only,
|
|
90
|
+
primary_error: primary_error,
|
|
91
|
+
candidate_error: candidate_error
|
|
92
|
+
)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Build Divergence objects for keys present in both hashes but with
|
|
96
|
+
# differing values.
|
|
97
|
+
def compare_common(primary, candidate, keys) # rubocop:disable Metrics/MethodLength
|
|
98
|
+
keys.filter_map do |key|
|
|
99
|
+
pval = primary[key]
|
|
100
|
+
cval = candidate[key]
|
|
101
|
+
next if values_match?(pval, cval)
|
|
102
|
+
|
|
103
|
+
Divergence.new(
|
|
104
|
+
output_name: key,
|
|
105
|
+
primary_value: pval,
|
|
106
|
+
candidate_value: cval,
|
|
107
|
+
kind: divergence_kind(pval, cval)
|
|
108
|
+
)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Returns a hash of keys that exist in +source+ but are absent in +other+.
|
|
113
|
+
def slice_missing(source, other)
|
|
114
|
+
(source.keys - other.keys).each_with_object({}) { |k, h| h[k] = source[k] }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def values_match?(lhs, rhs)
|
|
118
|
+
return true if lhs == rhs
|
|
119
|
+
return false unless @tolerance
|
|
120
|
+
return false unless lhs.is_a?(Numeric) && rhs.is_a?(Numeric)
|
|
121
|
+
|
|
122
|
+
(lhs - rhs).abs <= @tolerance
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def divergence_kind(lhs, rhs)
|
|
126
|
+
lhs.instance_of?(rhs.class) ? :value_mismatch : :type_mismatch
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
@@ -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
|
|
@@ -3,12 +3,13 @@
|
|
|
3
3
|
module Igniter
|
|
4
4
|
module DSL
|
|
5
5
|
class ContractBuilder
|
|
6
|
-
def self.compile(name: "AnonymousContract", &block)
|
|
7
|
-
new(name: name).tap { |builder| builder.instance_eval(&block) }.compile
|
|
6
|
+
def self.compile(name: "AnonymousContract", correlation_keys: [], &block)
|
|
7
|
+
new(name: name, correlation_keys: correlation_keys).tap { |builder| builder.instance_eval(&block) }.compile
|
|
8
8
|
end
|
|
9
9
|
|
|
10
|
-
def initialize(name:)
|
|
10
|
+
def initialize(name:, correlation_keys: [])
|
|
11
11
|
@name = name
|
|
12
|
+
@correlation_keys = correlation_keys
|
|
12
13
|
@nodes = []
|
|
13
14
|
@sequence = 0
|
|
14
15
|
@scope_stack = []
|
|
@@ -228,8 +229,60 @@ module Igniter
|
|
|
228
229
|
)
|
|
229
230
|
end
|
|
230
231
|
|
|
232
|
+
def remote(name, contract:, node:, inputs:, timeout: 30, **metadata) # rubocop:disable Metrics/MethodLength
|
|
233
|
+
raise CompileError, "remote :#{name} requires inputs: Hash" unless inputs.is_a?(Hash)
|
|
234
|
+
raise CompileError, "remote :#{name} requires a contract: name" if contract.nil? || contract.to_s.strip.empty?
|
|
235
|
+
raise CompileError, "remote :#{name} requires a node: URL" if node.nil? || node.to_s.strip.empty?
|
|
236
|
+
|
|
237
|
+
add_node(
|
|
238
|
+
Model::RemoteNode.new(
|
|
239
|
+
id: next_id,
|
|
240
|
+
name: name.to_sym,
|
|
241
|
+
contract_name: contract.to_s,
|
|
242
|
+
node_url: node.to_s,
|
|
243
|
+
input_mapping: inputs,
|
|
244
|
+
timeout: timeout,
|
|
245
|
+
path: scoped_path(name),
|
|
246
|
+
metadata: with_source_location(metadata)
|
|
247
|
+
)
|
|
248
|
+
)
|
|
249
|
+
end
|
|
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
|
+
|
|
266
|
+
def await(name, event:, **metadata)
|
|
267
|
+
add_node(
|
|
268
|
+
Model::AwaitNode.new(
|
|
269
|
+
id: next_id,
|
|
270
|
+
name: name.to_sym,
|
|
271
|
+
path: scoped_path(name),
|
|
272
|
+
event_name: event,
|
|
273
|
+
metadata: with_source_location(metadata)
|
|
274
|
+
)
|
|
275
|
+
)
|
|
276
|
+
end
|
|
277
|
+
|
|
231
278
|
def compile
|
|
232
|
-
Compiler::GraphCompiler.call(
|
|
279
|
+
Compiler::GraphCompiler.call(
|
|
280
|
+
Model::Graph.new(
|
|
281
|
+
name: @name,
|
|
282
|
+
nodes: @nodes,
|
|
283
|
+
metadata: { correlation_keys: @correlation_keys || [] }
|
|
284
|
+
)
|
|
285
|
+
)
|
|
233
286
|
end
|
|
234
287
|
|
|
235
288
|
private
|
|
@@ -268,6 +321,23 @@ module Igniter
|
|
|
268
321
|
Array(dependencies)
|
|
269
322
|
end
|
|
270
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
|
+
|
|
271
341
|
def build_guard_matcher(matcher_name, matcher_value, dependency)
|
|
272
342
|
case matcher_name
|
|
273
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
|