igniter-extensions 0.5.2

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 (100) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +381 -0
  3. data/lib/igniter/extensions/contracts/aggregate_pack.rb +103 -0
  4. data/lib/igniter/extensions/contracts/audit/builder.rb +132 -0
  5. data/lib/igniter/extensions/contracts/audit/event.rb +34 -0
  6. data/lib/igniter/extensions/contracts/audit/snapshot.rb +44 -0
  7. data/lib/igniter/extensions/contracts/audit_pack.rb +60 -0
  8. data/lib/igniter/extensions/contracts/branch_pack.rb +199 -0
  9. data/lib/igniter/extensions/contracts/capabilities/declaration.rb +31 -0
  10. data/lib/igniter/extensions/contracts/capabilities/error.rb +35 -0
  11. data/lib/igniter/extensions/contracts/capabilities/policy.rb +20 -0
  12. data/lib/igniter/extensions/contracts/capabilities/report.rb +47 -0
  13. data/lib/igniter/extensions/contracts/capabilities/violation.rb +30 -0
  14. data/lib/igniter/extensions/contracts/capabilities_pack.rb +146 -0
  15. data/lib/igniter/extensions/contracts/collection_pack.rb +212 -0
  16. data/lib/igniter/extensions/contracts/commerce_pack.rb +91 -0
  17. data/lib/igniter/extensions/contracts/compose_pack.rb +213 -0
  18. data/lib/igniter/extensions/contracts/content_addressing/cache.rb +59 -0
  19. data/lib/igniter/extensions/contracts/content_addressing/content_key.rb +63 -0
  20. data/lib/igniter/extensions/contracts/content_addressing/declaration.rb +47 -0
  21. data/lib/igniter/extensions/contracts/content_addressing_pack.rb +90 -0
  22. data/lib/igniter/extensions/contracts/creator/profile.rb +196 -0
  23. data/lib/igniter/extensions/contracts/creator/report.rb +85 -0
  24. data/lib/igniter/extensions/contracts/creator/scaffold.rb +461 -0
  25. data/lib/igniter/extensions/contracts/creator/scope.rb +79 -0
  26. data/lib/igniter/extensions/contracts/creator/wizard.rb +269 -0
  27. data/lib/igniter/extensions/contracts/creator/workflow.rb +189 -0
  28. data/lib/igniter/extensions/contracts/creator/workflow_step.rb +51 -0
  29. data/lib/igniter/extensions/contracts/creator/write_result.rb +48 -0
  30. data/lib/igniter/extensions/contracts/creator/write_step.rb +63 -0
  31. data/lib/igniter/extensions/contracts/creator/writer.rb +131 -0
  32. data/lib/igniter/extensions/contracts/creator_pack.rb +128 -0
  33. data/lib/igniter/extensions/contracts/dataflow/aggregate_operators.rb +119 -0
  34. data/lib/igniter/extensions/contracts/dataflow/aggregate_state.rb +60 -0
  35. data/lib/igniter/extensions/contracts/dataflow/builder.rb +66 -0
  36. data/lib/igniter/extensions/contracts/dataflow/collection_result.rb +70 -0
  37. data/lib/igniter/extensions/contracts/dataflow/diff.rb +37 -0
  38. data/lib/igniter/extensions/contracts/dataflow/item_result.rb +44 -0
  39. data/lib/igniter/extensions/contracts/dataflow/result.rb +58 -0
  40. data/lib/igniter/extensions/contracts/dataflow/session.rb +173 -0
  41. data/lib/igniter/extensions/contracts/dataflow/window_filter.rb +49 -0
  42. data/lib/igniter/extensions/contracts/dataflow_pack.rb +66 -0
  43. data/lib/igniter/extensions/contracts/debug/pack_audit.rb +181 -0
  44. data/lib/igniter/extensions/contracts/debug/pack_snapshot.rb +46 -0
  45. data/lib/igniter/extensions/contracts/debug/profile_snapshot.rb +50 -0
  46. data/lib/igniter/extensions/contracts/debug/report.rb +50 -0
  47. data/lib/igniter/extensions/contracts/debug_pack.rb +115 -0
  48. data/lib/igniter/extensions/contracts/differential/divergence.rb +37 -0
  49. data/lib/igniter/extensions/contracts/differential/formatter.rb +85 -0
  50. data/lib/igniter/extensions/contracts/differential/report.rb +83 -0
  51. data/lib/igniter/extensions/contracts/differential/runner.rb +136 -0
  52. data/lib/igniter/extensions/contracts/differential_pack.rb +61 -0
  53. data/lib/igniter/extensions/contracts/execution_report_pack.rb +38 -0
  54. data/lib/igniter/extensions/contracts/incremental/formatter.rb +60 -0
  55. data/lib/igniter/extensions/contracts/incremental/node_state.rb +30 -0
  56. data/lib/igniter/extensions/contracts/incremental/result.rb +65 -0
  57. data/lib/igniter/extensions/contracts/incremental/session.rb +146 -0
  58. data/lib/igniter/extensions/contracts/incremental_pack.rb +40 -0
  59. data/lib/igniter/extensions/contracts/invariants/builder.rb +27 -0
  60. data/lib/igniter/extensions/contracts/invariants/cases_report.rb +47 -0
  61. data/lib/igniter/extensions/contracts/invariants/error.rb +34 -0
  62. data/lib/igniter/extensions/contracts/invariants/invariant.rb +30 -0
  63. data/lib/igniter/extensions/contracts/invariants/report.rb +45 -0
  64. data/lib/igniter/extensions/contracts/invariants/suite.rb +36 -0
  65. data/lib/igniter/extensions/contracts/invariants/violation.rb +39 -0
  66. data/lib/igniter/extensions/contracts/invariants_pack.rb +88 -0
  67. data/lib/igniter/extensions/contracts/journal_pack.rb +55 -0
  68. data/lib/igniter/extensions/contracts/language/formula_pack.rb +185 -0
  69. data/lib/igniter/extensions/contracts/language/piecewise_pack.rb +166 -0
  70. data/lib/igniter/extensions/contracts/language/scale_pack.rb +147 -0
  71. data/lib/igniter/extensions/contracts/lookup_pack.rb +50 -0
  72. data/lib/igniter/extensions/contracts/mcp/creator_session.rb +105 -0
  73. data/lib/igniter/extensions/contracts/mcp/tool_argument.rb +35 -0
  74. data/lib/igniter/extensions/contracts/mcp/tool_definition.rb +33 -0
  75. data/lib/igniter/extensions/contracts/mcp/tool_result.rb +28 -0
  76. data/lib/igniter/extensions/contracts/mcp_pack.rb +335 -0
  77. data/lib/igniter/extensions/contracts/provenance/builder.rb +80 -0
  78. data/lib/igniter/extensions/contracts/provenance/lineage.rb +59 -0
  79. data/lib/igniter/extensions/contracts/provenance/node_trace.rb +53 -0
  80. data/lib/igniter/extensions/contracts/provenance/text_formatter.rb +62 -0
  81. data/lib/igniter/extensions/contracts/provenance_pack.rb +52 -0
  82. data/lib/igniter/extensions/contracts/reactive/builder.rb +43 -0
  83. data/lib/igniter/extensions/contracts/reactive/dispatch_result.rb +59 -0
  84. data/lib/igniter/extensions/contracts/reactive/engine.rb +79 -0
  85. data/lib/igniter/extensions/contracts/reactive/event.rb +36 -0
  86. data/lib/igniter/extensions/contracts/reactive/matcher.rb +20 -0
  87. data/lib/igniter/extensions/contracts/reactive/plan.rb +58 -0
  88. data/lib/igniter/extensions/contracts/reactive/subscription.rb +29 -0
  89. data/lib/igniter/extensions/contracts/reactive_pack.rb +169 -0
  90. data/lib/igniter/extensions/contracts/saga/compensation.rb +25 -0
  91. data/lib/igniter/extensions/contracts/saga/compensation_record.rb +28 -0
  92. data/lib/igniter/extensions/contracts/saga/compensation_set.rb +47 -0
  93. data/lib/igniter/extensions/contracts/saga/formatter.rb +39 -0
  94. data/lib/igniter/extensions/contracts/saga/result.rb +56 -0
  95. data/lib/igniter/extensions/contracts/saga/runner.rb +124 -0
  96. data/lib/igniter/extensions/contracts/saga_pack.rb +56 -0
  97. data/lib/igniter/extensions/contracts.rb +445 -0
  98. data/lib/igniter/extensions.rb +6 -0
  99. data/lib/igniter-extensions.rb +3 -0
  100. metadata +152 -0
@@ -0,0 +1,212 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Extensions
5
+ module Contracts
6
+ module CollectionPack
7
+ INTERNAL_SOURCE = :__collection_items__
8
+
9
+ Invocation = Struct.new(:operation, :items, :inputs, :compiled_graph, :profile, :key_name, :window,
10
+ keyword_init: true) do
11
+ def initialize(operation:, items:, inputs:, compiled_graph:, profile:, key_name:, window:)
12
+ super(
13
+ operation: operation,
14
+ items: Array(items),
15
+ inputs: inputs.transform_keys(&:to_sym).freeze,
16
+ compiled_graph: compiled_graph,
17
+ profile: profile,
18
+ key_name: key_name.to_sym,
19
+ window: window
20
+ )
21
+ end
22
+ end
23
+
24
+ module LocalInvoker
25
+ module_function
26
+
27
+ def call(invocation:)
28
+ environment = Igniter::Contracts::Environment.new(profile: invocation.profile)
29
+ session = Igniter::Extensions::Contracts::DataflowPack.session(
30
+ environment,
31
+ source: INTERNAL_SOURCE,
32
+ key: invocation.key_name,
33
+ context: invocation.inputs.keys,
34
+ window: invocation.window,
35
+ compiled_graph: invocation.compiled_graph
36
+ )
37
+
38
+ result = session.run(inputs: invocation.inputs.merge(INTERNAL_SOURCE => invocation.items))
39
+ result.processed
40
+ end
41
+ end
42
+
43
+ class << self
44
+ def manifest
45
+ Igniter::Contracts::PackManifest.new(
46
+ name: :extensions_collection,
47
+ node_contracts: [Igniter::Contracts::PackManifest.node(:collection)],
48
+ registry_contracts: [
49
+ Igniter::Contracts::PackManifest.validator(:collection_dependencies),
50
+ Igniter::Contracts::PackManifest.validator(:collection_contracts),
51
+ Igniter::Contracts::PackManifest.validator(:collection_invokers)
52
+ ],
53
+ requires_packs: [DataflowPack, IncrementalPack],
54
+ metadata: { category: :orchestration },
55
+ provides_capabilities: %i[collection keyed_sessions incremental_collection]
56
+ )
57
+ end
58
+
59
+ def install_into(kernel)
60
+ kernel.nodes.register(:collection,
61
+ Igniter::Contracts::NodeType.new(kind: :collection,
62
+ metadata: { category: :orchestration }))
63
+ kernel.dsl_keywords.register(:collection, collection_keyword)
64
+ kernel.validators.register(:collection_dependencies, method(:validate_collection_dependencies))
65
+ kernel.validators.register(:collection_contracts, method(:validate_collection_contracts))
66
+ kernel.validators.register(:collection_invokers, method(:validate_collection_invokers))
67
+ kernel.runtime_handlers.register(:collection, method(:handle_collection))
68
+ kernel
69
+ end
70
+
71
+ def collection_keyword
72
+ Igniter::Contracts::DslKeyword.new(:collection) do |name, from:, key:, builder:, inputs: {}, window: nil, contract: nil, via: nil, &block|
73
+ compiled_graph = compile_contract(name: name, contract: contract, profile: builder.profile, block: block)
74
+ input_map = normalize_inputs(inputs)
75
+ source_name = from.to_sym
76
+
77
+ builder.add_operation(
78
+ kind: :collection,
79
+ name: name,
80
+ from: source_name,
81
+ key_name: key.to_sym,
82
+ depends_on: [source_name, *extract_dependencies(input_map)].uniq,
83
+ inputs: input_map,
84
+ window: window,
85
+ compiled_graph: compiled_graph,
86
+ invoker: via
87
+ )
88
+ end
89
+ end
90
+
91
+ def validate_collection_dependencies(operations:, profile: nil) # rubocop:disable Lint/UnusedMethodArgument
92
+ available = operations.reject(&:output?).map(&:name)
93
+ missing = operations.select { |operation| operation.kind == :collection }
94
+ .flat_map { |operation| Array(operation.attributes[:depends_on]) }
95
+ .map(&:to_sym)
96
+ .reject { |name| available.include?(name) }
97
+ .uniq
98
+ return [] if missing.empty?
99
+
100
+ [Igniter::Contracts::ValidationFinding.new(
101
+ code: :missing_collection_dependencies,
102
+ message: "collection dependencies are not defined: #{missing.map(&:to_s).join(", ")}",
103
+ subjects: missing
104
+ )]
105
+ end
106
+
107
+ def validate_collection_contracts(operations:, profile:)
108
+ collection_operations = operations.select { |operation| operation.kind == :collection }
109
+ findings = []
110
+
111
+ invalid_contracts = collection_operations.reject do |operation|
112
+ operation.attributes[:compiled_graph].is_a?(Igniter::Contracts::CompiledGraph)
113
+ end
114
+ if invalid_contracts.any?
115
+ findings << Igniter::Contracts::ValidationFinding.new(
116
+ code: :invalid_collection_contract,
117
+ message: "collection nodes require a compiled item graph: #{invalid_contracts.map(&:name).join(", ")}",
118
+ subjects: invalid_contracts.map(&:name)
119
+ )
120
+ end
121
+
122
+ mismatched = collection_operations.select do |operation|
123
+ compiled_graph = operation.attributes[:compiled_graph]
124
+ compiled_graph.is_a?(Igniter::Contracts::CompiledGraph) &&
125
+ compiled_graph.profile_fingerprint != profile.fingerprint
126
+ end
127
+ if mismatched.any?
128
+ findings << Igniter::Contracts::ValidationFinding.new(
129
+ code: :collection_profile_mismatch,
130
+ message: "collection item graphs were compiled against a different profile: #{mismatched.map(&:name).join(", ")}",
131
+ subjects: mismatched.map(&:name)
132
+ )
133
+ end
134
+
135
+ findings
136
+ end
137
+
138
+ def validate_collection_invokers(operations:, profile: nil) # rubocop:disable Lint/UnusedMethodArgument
139
+ invalid = operations.select { |operation| operation.kind == :collection }
140
+ .reject do |operation|
141
+ invoker = operation.attributes[:invoker]
142
+ invoker.nil? || invoker.respond_to?(:call)
143
+ end
144
+ return [] if invalid.empty?
145
+
146
+ [Igniter::Contracts::ValidationFinding.new(
147
+ code: :invalid_collection_invoker,
148
+ message: "collection via: must be callable: #{invalid.map(&:name).join(", ")}",
149
+ subjects: invalid.map(&:name)
150
+ )]
151
+ end
152
+
153
+ def handle_collection(operation:, state:, profile:, **)
154
+ items = state.fetch(operation.attributes.fetch(:from))
155
+ invocation = Invocation.new(
156
+ operation: operation,
157
+ items: items,
158
+ inputs: resolve_inputs(operation, state: state),
159
+ compiled_graph: operation.attributes.fetch(:compiled_graph),
160
+ profile: profile,
161
+ key_name: operation.attributes.fetch(:key_name),
162
+ window: operation.attributes[:window]
163
+ )
164
+ invoker = operation.attributes[:invoker] || LocalInvoker
165
+ result = invoker.call(invocation: invocation)
166
+ unless result.is_a?(Igniter::Extensions::Contracts::Dataflow::CollectionResult)
167
+ raise Igniter::Contracts::Error,
168
+ "collection invoker for #{operation.name} must return a CollectionResult"
169
+ end
170
+
171
+ result
172
+ end
173
+
174
+ private
175
+
176
+ def compile_contract(name:, contract:, profile:, block:)
177
+ if contract && block
178
+ raise ArgumentError,
179
+ "collection :#{name} accepts either contract: or a block, not both"
180
+ end
181
+
182
+ source = contract || block
183
+ raise ArgumentError, "collection :#{name} requires contract: or a block" unless source
184
+
185
+ return source if source.is_a?(Igniter::Contracts::CompiledGraph)
186
+ return Igniter::Contracts.compile(profile: profile, &source) if source.respond_to?(:call)
187
+
188
+ raise ArgumentError, "collection :#{name} contract must be a compiled graph or callable"
189
+ end
190
+
191
+ def normalize_inputs(inputs)
192
+ raise ArgumentError, "collection inputs: must be a Hash" unless inputs.is_a?(Hash)
193
+
194
+ inputs.each_with_object({}) do |(key, value), memo|
195
+ memo[key.to_sym] = value
196
+ end.freeze
197
+ end
198
+
199
+ def extract_dependencies(input_map)
200
+ input_map.values.grep(Symbol).map(&:to_sym).uniq
201
+ end
202
+
203
+ def resolve_inputs(operation, state:)
204
+ operation.attributes.fetch(:inputs).each_with_object({}) do |(key, source), memo|
205
+ memo[key.to_sym] = source.is_a?(Symbol) ? state.fetch(source.to_sym) : source
206
+ end
207
+ end
208
+ end
209
+ end
210
+ end
211
+ end
212
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Extensions
5
+ module Contracts
6
+ module CommercePack
7
+ class << self
8
+ def manifest
9
+ Igniter::Contracts::PackManifest.new(
10
+ name: :extensions_commerce,
11
+ requires_packs: [LookupPack, AggregatePack],
12
+ registry_contracts: [
13
+ Igniter::Contracts::PackManifest.dsl_keyword(:order_items),
14
+ Igniter::Contracts::PackManifest.dsl_keyword(:subtotal),
15
+ Igniter::Contracts::PackManifest.dsl_keyword(:tax_amount),
16
+ Igniter::Contracts::PackManifest.dsl_keyword(:grand_total)
17
+ ]
18
+ )
19
+ end
20
+
21
+ def install_into(kernel)
22
+ install_dsl_keywords(kernel)
23
+ kernel
24
+ end
25
+
26
+ def install_dsl_keywords(kernel)
27
+ kernel.dsl_keywords.register(:order_items, order_items_keyword)
28
+ kernel.dsl_keywords.register(:subtotal, subtotal_keyword)
29
+ kernel.dsl_keywords.register(:tax_amount, tax_amount_keyword)
30
+ kernel.dsl_keywords.register(:grand_total, grand_total_keyword)
31
+ end
32
+
33
+ def order_items_keyword
34
+ Igniter::Contracts::DslKeyword.new(:order_items) do |name = :items, from:, builder:, key: :items|
35
+ builder.profile.dsl_keyword(:lookup).call(
36
+ name,
37
+ from: from.to_sym,
38
+ key: key.to_sym,
39
+ builder: builder
40
+ )
41
+ end
42
+ end
43
+
44
+ def subtotal_keyword
45
+ Igniter::Contracts::DslKeyword.new(:subtotal) do |name = :subtotal, from:, builder:, amount_key: :amount|
46
+ builder.profile.dsl_keyword(:sum).call(
47
+ name,
48
+ from: from.to_sym,
49
+ using: amount_key.to_sym,
50
+ builder: builder
51
+ )
52
+ end
53
+ end
54
+
55
+ def tax_amount_keyword
56
+ Igniter::Contracts::DslKeyword.new(:tax_amount) do |name = :tax, amount:, rate:, builder:|
57
+ dependencies = [amount.to_sym, rate.to_sym]
58
+ builder.add_operation(
59
+ kind: :compute,
60
+ name: name,
61
+ depends_on: dependencies,
62
+ callable: lambda do |**values|
63
+ values.fetch(amount.to_sym) * values.fetch(rate.to_sym)
64
+ end
65
+ )
66
+ end
67
+ end
68
+
69
+ def grand_total_keyword
70
+ Igniter::Contracts::DslKeyword.new(:grand_total) do |name = :grand_total, subtotal:, builder:, tax: nil, shipping: nil, discount: nil|
71
+ dependency_names = [subtotal, tax, shipping, discount].compact.map(&:to_sym)
72
+
73
+ builder.add_operation(
74
+ kind: :compute,
75
+ name: name,
76
+ depends_on: dependency_names,
77
+ callable: lambda do |**values|
78
+ total = values.fetch(subtotal.to_sym)
79
+ total += values.fetch(tax.to_sym) if tax
80
+ total += values.fetch(shipping.to_sym) if shipping
81
+ total -= values.fetch(discount.to_sym) if discount
82
+ total
83
+ end
84
+ )
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,213 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Extensions
5
+ module Contracts
6
+ module ComposePack
7
+ Invocation = Struct.new(:operation, :compiled_graph, :inputs, :profile, keyword_init: true) do
8
+ def initialize(operation:, compiled_graph:, inputs:, profile:)
9
+ super(
10
+ operation: operation,
11
+ compiled_graph: compiled_graph,
12
+ inputs: inputs,
13
+ profile: profile
14
+ )
15
+ end
16
+ end
17
+
18
+ module LocalInvoker
19
+ module_function
20
+
21
+ def call(invocation:)
22
+ Igniter::Contracts.execute(
23
+ invocation.compiled_graph,
24
+ inputs: invocation.inputs,
25
+ profile: invocation.profile
26
+ )
27
+ end
28
+ end
29
+
30
+ class << self
31
+ def manifest
32
+ Igniter::Contracts::PackManifest.new(
33
+ name: :extensions_compose,
34
+ node_contracts: [Igniter::Contracts::PackManifest.node(:compose)],
35
+ registry_contracts: [
36
+ Igniter::Contracts::PackManifest.validator(:compose_dependencies),
37
+ Igniter::Contracts::PackManifest.validator(:compose_contracts),
38
+ Igniter::Contracts::PackManifest.validator(:compose_invokers)
39
+ ],
40
+ metadata: { category: :orchestration },
41
+ provides_capabilities: %i[subgraph_invocation nested_contracts]
42
+ )
43
+ end
44
+
45
+ def install_into(kernel)
46
+ kernel.nodes.register(:compose,
47
+ Igniter::Contracts::NodeType.new(kind: :compose,
48
+ metadata: { category: :orchestration }))
49
+ kernel.dsl_keywords.register(:compose, compose_keyword)
50
+ kernel.validators.register(:compose_dependencies, method(:validate_compose_dependencies))
51
+ kernel.validators.register(:compose_contracts, method(:validate_compose_contracts))
52
+ kernel.validators.register(:compose_invokers, method(:validate_compose_invokers))
53
+ kernel.runtime_handlers.register(:compose, method(:handle_compose))
54
+ kernel
55
+ end
56
+
57
+ def compose_keyword
58
+ Igniter::Contracts::DslKeyword.new(:compose) do |name, builder:, contract: nil, inputs: {}, output: nil, via: nil, &block|
59
+ compiled_graph = compile_contract(name: name, contract: contract, profile: builder.profile, block: block)
60
+ input_map = normalize_inputs(inputs)
61
+
62
+ builder.add_operation(
63
+ kind: :compose,
64
+ name: name,
65
+ depends_on: extract_dependencies(input_map),
66
+ inputs: input_map,
67
+ compiled_graph: compiled_graph,
68
+ output_name: output&.to_sym,
69
+ invoker: via
70
+ )
71
+ end
72
+ end
73
+
74
+ def validate_compose_dependencies(operations:, profile: nil) # rubocop:disable Lint/UnusedMethodArgument
75
+ available = operations.reject(&:output?).map(&:name)
76
+ missing = operations.select { |operation| operation.kind == :compose }
77
+ .flat_map { |operation| Array(operation.attributes[:depends_on]) }
78
+ .map(&:to_sym)
79
+ .reject { |name| available.include?(name) }
80
+ .uniq
81
+ return [] if missing.empty?
82
+
83
+ [Igniter::Contracts::ValidationFinding.new(
84
+ code: :missing_compose_dependencies,
85
+ message: "compose dependencies are not defined: #{missing.map(&:to_s).join(", ")}",
86
+ subjects: missing
87
+ )]
88
+ end
89
+
90
+ def validate_compose_contracts(operations:, profile:)
91
+ compose_operations = operations.select { |operation| operation.kind == :compose }
92
+ findings = []
93
+
94
+ invalid_contracts = compose_operations.reject do |operation|
95
+ operation.attributes[:compiled_graph].is_a?(Igniter::Contracts::CompiledGraph)
96
+ end
97
+ if invalid_contracts.any?
98
+ findings << Igniter::Contracts::ValidationFinding.new(
99
+ code: :invalid_compose_contract,
100
+ message: "compose nodes require a compiled contract graph: #{invalid_contracts.map(&:name).join(", ")}",
101
+ subjects: invalid_contracts.map(&:name)
102
+ )
103
+ end
104
+
105
+ mismatched = compose_operations.select do |operation|
106
+ compiled_graph = operation.attributes[:compiled_graph]
107
+ compiled_graph.is_a?(Igniter::Contracts::CompiledGraph) &&
108
+ compiled_graph.profile_fingerprint != profile.fingerprint
109
+ end
110
+ if mismatched.any?
111
+ findings << Igniter::Contracts::ValidationFinding.new(
112
+ code: :compose_profile_mismatch,
113
+ message: "compose contracts were compiled against a different profile: #{mismatched.map(&:name).join(", ")}",
114
+ subjects: mismatched.map(&:name)
115
+ )
116
+ end
117
+
118
+ missing_outputs = compose_operations.filter_map do |operation|
119
+ output_name = operation.attributes[:output_name]
120
+ next if output_name.nil?
121
+
122
+ compiled_graph = operation.attributes[:compiled_graph]
123
+ next if compiled_graph.is_a?(Igniter::Contracts::CompiledGraph) && compose_output_names(compiled_graph).include?(output_name)
124
+
125
+ operation.name
126
+ end
127
+ if missing_outputs.any?
128
+ findings << Igniter::Contracts::ValidationFinding.new(
129
+ code: :unknown_compose_output,
130
+ message: "compose output selections are not defined in the nested contract: #{missing_outputs.map(&:to_s).join(", ")}",
131
+ subjects: missing_outputs
132
+ )
133
+ end
134
+
135
+ findings
136
+ end
137
+
138
+ def validate_compose_invokers(operations:, profile: nil) # rubocop:disable Lint/UnusedMethodArgument
139
+ invalid = operations.select { |operation| operation.kind == :compose }
140
+ .reject do |operation|
141
+ invoker = operation.attributes[:invoker]
142
+ invoker.nil? || invoker.respond_to?(:call)
143
+ end
144
+ return [] if invalid.empty?
145
+
146
+ [Igniter::Contracts::ValidationFinding.new(
147
+ code: :invalid_compose_invoker,
148
+ message: "compose via: must be callable: #{invalid.map(&:name).join(", ")}",
149
+ subjects: invalid.map(&:name)
150
+ )]
151
+ end
152
+
153
+ def handle_compose(operation:, state:, profile:, **)
154
+ nested_inputs = resolve_inputs(operation, state: state)
155
+ invocation = Invocation.new(
156
+ operation: operation,
157
+ compiled_graph: operation.attributes.fetch(:compiled_graph),
158
+ inputs: nested_inputs,
159
+ profile: profile
160
+ )
161
+ invoker = operation.attributes[:invoker] || LocalInvoker
162
+ result = invoker.call(invocation: invocation)
163
+ unless result.is_a?(Igniter::Contracts::ExecutionResult)
164
+ raise Igniter::Contracts::Error,
165
+ "compose invoker for #{operation.name} must return an ExecutionResult"
166
+ end
167
+
168
+ output_name = operation.attributes[:output_name]
169
+ return result if output_name.nil?
170
+
171
+ result.output(output_name)
172
+ end
173
+
174
+ private
175
+
176
+ def compile_contract(name:, contract:, profile:, block:)
177
+ raise ArgumentError, "compose :#{name} accepts either contract: or a block, not both" if contract && block
178
+
179
+ source = contract || block
180
+ raise ArgumentError, "compose :#{name} requires contract: or a block" unless source
181
+
182
+ return source if source.is_a?(Igniter::Contracts::CompiledGraph)
183
+ return Igniter::Contracts.compile(profile: profile, &source) if source.respond_to?(:call)
184
+
185
+ raise ArgumentError, "compose :#{name} contract must be a compiled graph or callable"
186
+ end
187
+
188
+ def normalize_inputs(inputs)
189
+ raise ArgumentError, "compose inputs: must be a Hash" unless inputs.is_a?(Hash)
190
+
191
+ inputs.each_with_object({}) do |(key, value), memo|
192
+ memo[key.to_sym] = value
193
+ end.freeze
194
+ end
195
+
196
+ def extract_dependencies(input_map)
197
+ input_map.values.grep(Symbol).map(&:to_sym).uniq
198
+ end
199
+
200
+ def resolve_inputs(operation, state:)
201
+ operation.attributes.fetch(:inputs).each_with_object({}) do |(key, source), memo|
202
+ memo[key.to_sym] = source.is_a?(Symbol) ? state.fetch(source.to_sym) : source
203
+ end
204
+ end
205
+
206
+ def compose_output_names(compiled_graph)
207
+ compiled_graph.operations.select(&:output?).map(&:name)
208
+ end
209
+ end
210
+ end
211
+ end
212
+ end
213
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Extensions
5
+ module Contracts
6
+ module ContentAddressing
7
+ class Cache
8
+ def initialize
9
+ @store = {}
10
+ @hits = 0
11
+ @misses = 0
12
+ @mutex = Mutex.new
13
+ end
14
+
15
+ def fetch(key)
16
+ @mutex.synchronize do
17
+ entry = @store[key.hex]
18
+ if entry.nil?
19
+ @misses += 1
20
+ nil
21
+ else
22
+ @hits += 1
23
+ entry
24
+ end
25
+ end
26
+ end
27
+
28
+ def store(key, value)
29
+ @mutex.synchronize do
30
+ @store[key.hex] = value
31
+ end
32
+ end
33
+
34
+ def clear
35
+ @mutex.synchronize do
36
+ @store.clear
37
+ @hits = 0
38
+ @misses = 0
39
+ end
40
+ end
41
+
42
+ def size
43
+ @mutex.synchronize { @store.size }
44
+ end
45
+
46
+ def stats
47
+ @mutex.synchronize do
48
+ {
49
+ size: @store.size,
50
+ hits: @hits,
51
+ misses: @misses
52
+ }
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Igniter
6
+ module Extensions
7
+ module Contracts
8
+ module ContentAddressing
9
+ class ContentKey
10
+ attr_reader :hex
11
+
12
+ def initialize(hex)
13
+ @hex = hex.freeze
14
+ freeze
15
+ end
16
+
17
+ def to_s
18
+ "ca:#{hex}"
19
+ end
20
+
21
+ def ==(other)
22
+ other.is_a?(self.class) && other.hex == hex
23
+ end
24
+
25
+ alias eql? ==
26
+
27
+ def hash
28
+ hex.hash
29
+ end
30
+
31
+ class << self
32
+ def compute(fingerprint:, inputs:)
33
+ payload = stable_serialize(inputs)
34
+ new(Digest::SHA256.hexdigest("#{fingerprint}\x00#{payload}")[0..23])
35
+ end
36
+
37
+ private
38
+
39
+ def stable_serialize(value)
40
+ case value
41
+ when Hash
42
+ pairs = value.sort_by { |key, _entry| key.to_s }.map do |key, entry|
43
+ "#{key}:#{stable_serialize(entry)}"
44
+ end
45
+ "{#{pairs.join(",")}}"
46
+ when Array
47
+ "[#{value.map { |entry| stable_serialize(entry) }.join(",")}]"
48
+ when String
49
+ value.inspect
50
+ when Symbol
51
+ ":#{value}"
52
+ when Numeric, NilClass, TrueClass, FalseClass
53
+ value.inspect
54
+ else
55
+ value.respond_to?(:to_h) ? stable_serialize(value.to_h) : value.hash.to_s
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Extensions
5
+ module Contracts
6
+ module ContentAddressing
7
+ class Declaration
8
+ attr_reader :callable, :fingerprint, :cache, :capabilities
9
+
10
+ def initialize(callable:, fingerprint:, cache:, capabilities:)
11
+ @callable = callable
12
+ @fingerprint = fingerprint.to_s.freeze
13
+ @cache = cache
14
+ @capabilities = Array(capabilities).map(&:to_sym).uniq.freeze
15
+ freeze
16
+ end
17
+
18
+ def call(**kwargs)
19
+ key = content_key(kwargs)
20
+ cached = cache.fetch(key)
21
+ return cached unless cached.nil?
22
+
23
+ value = callable.call(**kwargs)
24
+ cache.store(key, value)
25
+ value
26
+ end
27
+
28
+ def declared_capabilities
29
+ capabilities
30
+ end
31
+
32
+ def pure?
33
+ capabilities.include?(:pure)
34
+ end
35
+
36
+ def content_fingerprint
37
+ fingerprint
38
+ end
39
+
40
+ def content_key(inputs)
41
+ ContentKey.compute(fingerprint: fingerprint, inputs: inputs)
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end