igniter 0.2.0 → 0.3.1

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 (80) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +21 -0
  3. data/README.md +224 -1
  4. data/docs/API_V2.md +296 -1
  5. data/docs/BACKLOG.md +166 -0
  6. data/docs/BRANCHES_V1.md +213 -0
  7. data/docs/COLLECTIONS_V1.md +303 -0
  8. data/docs/EXECUTION_MODEL_V2.md +79 -0
  9. data/docs/PATTERNS.md +222 -0
  10. data/docs/STORE_ADAPTERS.md +126 -0
  11. data/examples/README.md +127 -0
  12. data/examples/async_store.rb +47 -0
  13. data/examples/collection.rb +43 -0
  14. data/examples/collection_partial_failure.rb +50 -0
  15. data/examples/marketing_ergonomics.rb +57 -0
  16. data/examples/ringcentral_routing.rb +269 -0
  17. data/lib/igniter/compiler/compiled_graph.rb +90 -0
  18. data/lib/igniter/compiler/graph_compiler.rb +12 -2
  19. data/lib/igniter/compiler/type_resolver.rb +54 -0
  20. data/lib/igniter/compiler/validation_context.rb +61 -0
  21. data/lib/igniter/compiler/validation_pipeline.rb +30 -0
  22. data/lib/igniter/compiler/validator.rb +1 -187
  23. data/lib/igniter/compiler/validators/callable_validator.rb +107 -0
  24. data/lib/igniter/compiler/validators/dependencies_validator.rb +153 -0
  25. data/lib/igniter/compiler/validators/outputs_validator.rb +66 -0
  26. data/lib/igniter/compiler/validators/type_compatibility_validator.rb +84 -0
  27. data/lib/igniter/compiler/validators/uniqueness_validator.rb +60 -0
  28. data/lib/igniter/compiler.rb +8 -0
  29. data/lib/igniter/contract.rb +152 -4
  30. data/lib/igniter/diagnostics/auditing/report/console_formatter.rb +80 -0
  31. data/lib/igniter/diagnostics/auditing/report/markdown_formatter.rb +22 -0
  32. data/lib/igniter/diagnostics/introspection/formatters/mermaid_formatter.rb +58 -0
  33. data/lib/igniter/diagnostics/introspection/formatters/text_tree_formatter.rb +44 -0
  34. data/lib/igniter/diagnostics/report.rb +186 -11
  35. data/lib/igniter/dsl/contract_builder.rb +271 -5
  36. data/lib/igniter/dsl/schema_builder.rb +73 -0
  37. data/lib/igniter/dsl.rb +1 -0
  38. data/lib/igniter/errors.rb +11 -0
  39. data/lib/igniter/events/bus.rb +5 -0
  40. data/lib/igniter/events/event.rb +29 -0
  41. data/lib/igniter/executor.rb +74 -0
  42. data/lib/igniter/executor_registry.rb +44 -0
  43. data/lib/igniter/extensions/auditing/timeline.rb +4 -0
  44. data/lib/igniter/extensions/introspection/graph_formatter.rb +33 -3
  45. data/lib/igniter/extensions/introspection/plan_formatter.rb +55 -0
  46. data/lib/igniter/extensions/introspection/runtime_formatter.rb +18 -3
  47. data/lib/igniter/extensions/introspection.rb +1 -0
  48. data/lib/igniter/extensions/reactive/engine.rb +49 -2
  49. data/lib/igniter/extensions/reactive/reaction.rb +3 -2
  50. data/lib/igniter/model/branch_node.rb +46 -0
  51. data/lib/igniter/model/collection_node.rb +31 -0
  52. data/lib/igniter/model/composition_node.rb +2 -2
  53. data/lib/igniter/model/compute_node.rb +58 -2
  54. data/lib/igniter/model/input_node.rb +2 -2
  55. data/lib/igniter/model/output_node.rb +24 -4
  56. data/lib/igniter/model.rb +2 -0
  57. data/lib/igniter/runtime/cache.rb +64 -25
  58. data/lib/igniter/runtime/collection_result.rb +111 -0
  59. data/lib/igniter/runtime/deferred_result.rb +40 -0
  60. data/lib/igniter/runtime/execution.rb +261 -11
  61. data/lib/igniter/runtime/input_validator.rb +2 -24
  62. data/lib/igniter/runtime/invalidator.rb +1 -1
  63. data/lib/igniter/runtime/job_worker.rb +18 -0
  64. data/lib/igniter/runtime/node_state.rb +20 -0
  65. data/lib/igniter/runtime/planner.rb +126 -0
  66. data/lib/igniter/runtime/resolver.rb +310 -15
  67. data/lib/igniter/runtime/result.rb +14 -2
  68. data/lib/igniter/runtime/runner_factory.rb +20 -0
  69. data/lib/igniter/runtime/runners/inline_runner.rb +21 -0
  70. data/lib/igniter/runtime/runners/store_runner.rb +29 -0
  71. data/lib/igniter/runtime/runners/thread_pool_runner.rb +37 -0
  72. data/lib/igniter/runtime/stores/active_record_store.rb +41 -0
  73. data/lib/igniter/runtime/stores/file_store.rb +43 -0
  74. data/lib/igniter/runtime/stores/memory_store.rb +40 -0
  75. data/lib/igniter/runtime/stores/redis_store.rb +44 -0
  76. data/lib/igniter/runtime.rb +12 -0
  77. data/lib/igniter/type_system.rb +44 -0
  78. data/lib/igniter/version.rb +1 -1
  79. data/lib/igniter.rb +23 -0
  80. metadata +43 -2
@@ -17,6 +17,7 @@ module Igniter
17
17
  outputs: serialize_outputs,
18
18
  errors: serialize_errors,
19
19
  nodes: summarize_nodes,
20
+ collection_nodes: summarize_collection_nodes,
20
21
  events: summarize_events
21
22
  }
22
23
  end
@@ -31,8 +32,9 @@ module Igniter
31
32
  lines << "Diagnostics #{report[:graph]}"
32
33
  lines << "Execution #{report[:execution_id]}"
33
34
  lines << "Status: #{report[:status]}"
34
- lines << format_outputs(report[:outputs])
35
+ lines << format_outputs(presented_outputs)
35
36
  lines << format_nodes(report[:nodes])
37
+ lines << format_collection_nodes(report[:collection_nodes])
36
38
  lines << format_errors(report[:errors])
37
39
  lines << format_events(report[:events])
38
40
  lines.compact.join("\n")
@@ -45,8 +47,11 @@ module Igniter
45
47
  lines << ""
46
48
  lines << "- Execution: `#{report[:execution_id]}`"
47
49
  lines << "- Status: `#{report[:status]}`"
48
- lines << "- Outputs: #{inline_hash(report[:outputs])}"
50
+ lines << "- Outputs: #{inline_hash(presented_outputs)}"
49
51
  lines << "- Nodes: total=#{report[:nodes][:total]}, succeeded=#{report[:nodes][:succeeded]}, failed=#{report[:nodes][:failed]}, stale=#{report[:nodes][:stale]}"
52
+ unless report[:collection_nodes].empty?
53
+ lines << "- Collections: #{report[:collection_nodes].map { |node| "#{node[:node_name]} total=#{node[:total]} succeeded=#{node[:succeeded]} failed=#{node[:failed]} status=#{node[:status]}" }.join('; ')}"
54
+ end
50
55
  lines << "- Events: total=#{report[:events][:total]}, latest=#{report[:events][:latest_type] || 'none'}"
51
56
 
52
57
  unless report[:errors].empty?
@@ -57,6 +62,17 @@ module Igniter
57
62
  end
58
63
  end
59
64
 
65
+ unless report[:collection_nodes].empty?
66
+ lines << ""
67
+ lines << "## Collections"
68
+ report[:collection_nodes].each do |node|
69
+ lines << "- `#{node[:node_name]}`: total=#{node[:total]}, succeeded=#{node[:succeeded]}, failed=#{node[:failed]}, status=#{node[:status]}"
70
+ node[:failed_items].each do |item|
71
+ lines << "- `#{node[:node_name]}[#{item[:key]}]` failed: #{item[:message]}"
72
+ end
73
+ end
74
+ end
75
+
60
76
  lines.join("\n")
61
77
  end
62
78
 
@@ -76,36 +92,52 @@ module Igniter
76
92
 
77
93
  def status
78
94
  return :failed if execution.cache.values.any?(&:failed?)
95
+ return :pending if execution.cache.values.any?(&:pending?)
79
96
  return :stale if execution.cache.values.any?(&:stale?)
80
97
 
81
98
  :succeeded
82
99
  end
83
100
 
84
101
  def serialize_errors
85
- execution_result.errors.map do |node_name, error|
102
+ execution.cache.values.filter_map do |state|
103
+ next unless state.failed?
104
+
86
105
  {
87
- node_name: node_name,
88
- type: error.class.name,
89
- message: error.message,
90
- context: error.respond_to?(:context) ? error.context : {}
106
+ node_name: state.node.name,
107
+ type: state.error.class.name,
108
+ message: state.error.message,
109
+ context: state.error.respond_to?(:context) ? state.error.context : {}
91
110
  }
92
111
  end
93
112
  end
94
113
 
95
114
  def serialize_outputs
96
115
  execution.compiled_graph.outputs.each_with_object({}) do |output_node, memo|
97
- state = execution.cache.fetch(output_node.source)
98
- memo[output_node.name] = serialize_output_state(state)
116
+ state = execution.cache.fetch(output_node.source_root)
117
+ memo[output_node.name] = serialize_output_value(output_node, state)
99
118
  end
100
119
  end
101
120
 
102
- def serialize_output_state(state)
121
+ def serialize_output_value(output_node, state)
103
122
  return nil unless state
104
123
  return { error: state.error.message, status: state.status } if state.failed?
105
124
 
125
+ if output_node.composition_output?
126
+ return serialize_output_from_child(output_node, state.value)
127
+ end
128
+
106
129
  serialize_value(state.value)
107
130
  end
108
131
 
132
+ def serialize_output_from_child(output_node, child_result)
133
+ return nil unless child_result.is_a?(Runtime::Result)
134
+
135
+ child_errors = child_result.execution.cache.values.select(&:failed?)
136
+ return { error: child_errors.first.error.message, status: :failed } unless child_errors.empty?
137
+
138
+ child_result.public_send(output_node.child_output_name)
139
+ end
140
+
109
141
  def summarize_nodes
110
142
  states = execution.states
111
143
 
@@ -113,6 +145,7 @@ module Igniter
113
145
  total: states.size,
114
146
  succeeded: states.values.count { |state| state[:status] == :succeeded },
115
147
  failed: states.values.count { |state| state[:status] == :failed },
148
+ pending: states.values.count { |state| state[:status] == :pending },
116
149
  stale: states.values.count { |state| state[:status] == :stale },
117
150
  failed_nodes: states.filter_map do |node_name, state|
118
151
  next unless state[:status] == :failed
@@ -137,8 +170,16 @@ module Igniter
137
170
  "Outputs: #{inline_hash(outputs)}"
138
171
  end
139
172
 
173
+ def presented_outputs
174
+ @presented_outputs ||= execution.compiled_graph.outputs.each_with_object({}) do |output_node, memo|
175
+ raw_value = to_h[:outputs][output_node.name]
176
+ memo[output_node.name] = present_output(output_node.name, raw_value)
177
+ end
178
+ end
179
+
140
180
  def format_nodes(nodes)
141
181
  line = "Nodes: total=#{nodes[:total]}, succeeded=#{nodes[:succeeded]}, failed=#{nodes[:failed]}, stale=#{nodes[:stale]}"
182
+ line = "Nodes: total=#{nodes[:total]}, succeeded=#{nodes[:succeeded]}, failed=#{nodes[:failed]}, pending=#{nodes[:pending]}, stale=#{nodes[:stale]}"
142
183
  return line if nodes[:failed_nodes].empty?
143
184
 
144
185
  failures = nodes[:failed_nodes].map { |node| "#{node[:node_name]}(#{node[:error]})" }.join(", ")
@@ -151,24 +192,158 @@ module Igniter
151
192
  "Errors: #{errors.map { |error| "#{error[:node_name]}=#{error[:type]}" }.join(', ')}"
152
193
  end
153
194
 
195
+ def format_collection_nodes(collection_nodes)
196
+ return nil if collection_nodes.empty?
197
+
198
+ summaries = collection_nodes.map do |node|
199
+ summary = "#{node[:node_name]} total=#{node[:total]} succeeded=#{node[:succeeded]} failed=#{node[:failed]} status=#{node[:status]}"
200
+ next summary if node[:failed_items].empty?
201
+
202
+ "#{summary} failed_items=#{node[:failed_items].map { |item| "#{item[:key]}(#{item[:message]})" }.join(', ')}"
203
+ end
204
+
205
+ "Collections: #{summaries.join('; ')}"
206
+ end
207
+
154
208
  def format_events(events)
155
209
  "Events: total=#{events[:total]}, latest=#{events[:latest_type] || 'none'}"
156
210
  end
157
211
 
158
212
  def inline_hash(hash)
159
- hash.map { |key, value| "#{key}=#{value.inspect}" }.join(", ")
213
+ hash.map { |key, value| "#{key}=#{inline_value(value)}" }.join(", ")
214
+ end
215
+
216
+ def present_output(output_name, raw_value)
217
+ presenter = execution.contract_instance.class.output_presenters[output_name.to_sym]
218
+ return raw_value unless presenter
219
+
220
+ if presenter.is_a?(Symbol) || presenter.is_a?(String)
221
+ execution.contract_instance.public_send(
222
+ presenter,
223
+ value: raw_value,
224
+ contract: execution.contract_instance,
225
+ execution: execution
226
+ )
227
+ else
228
+ presenter.call(
229
+ value: raw_value,
230
+ contract: execution.contract_instance,
231
+ execution: execution
232
+ )
233
+ end
160
234
  end
161
235
 
162
236
  def serialize_value(value)
163
237
  case value
238
+ when Runtime::DeferredResult
239
+ value.as_json
164
240
  when Runtime::Result
165
241
  value.to_h
242
+ when Runtime::CollectionResult
243
+ value.as_json
166
244
  when Array
167
245
  value.map { |item| serialize_value(item) }
168
246
  else
169
247
  value
170
248
  end
171
249
  end
250
+
251
+ def inline_value(value)
252
+ case value
253
+ when Hash
254
+ return summarize_serialized_collection_hash(value) if serialized_collection_hash?(value)
255
+ return summarize_serialized_collection_items_hash(value) if serialized_collection_items_hash?(value)
256
+
257
+ "{#{value.map { |key, nested| "#{key}: #{inline_value(nested)}" }.join(', ')}}"
258
+ when Array
259
+ "[#{value.map { |item| inline_value(item) }.join(', ')}]"
260
+ when Runtime::Result
261
+ summarize_nested_result(value)
262
+ when Runtime::CollectionResult
263
+ summarize_collection_result(value)
264
+ else
265
+ value.inspect
266
+ end
267
+ end
268
+
269
+ def summarize_nested_result(result)
270
+ outputs = result.to_h.keys
271
+ "{graph=#{result.execution.compiled_graph.name.inspect}, status=#{nested_result_status(result).inspect}, outputs=#{outputs.inspect}}"
272
+ end
273
+
274
+ def summarize_collection_result(result)
275
+ summary = result.summary
276
+ failed_keys = result.failures.keys
277
+ "{mode=#{result.mode.inspect}, total=#{summary[:total]}, succeeded=#{summary[:succeeded]}, failed=#{summary[:failed]}, status=#{summary[:status].inspect}, keys=#{result.keys.inspect}, failed_keys=#{failed_keys.inspect}}"
278
+ end
279
+
280
+ def serialized_collection_hash?(value)
281
+ value.key?(:mode) && value.key?(:summary) && value.key?(:items)
282
+ end
283
+
284
+ def summarize_serialized_collection_hash(value)
285
+ summary = value[:summary] || {}
286
+ items = value[:items] || {}
287
+ failed_keys = items.each_with_object([]) do |(key, item), memo|
288
+ memo << key if item[:status] == :failed || item["status"] == :failed
289
+ end
290
+
291
+ "{mode=#{value[:mode].inspect}, total=#{summary[:total]}, succeeded=#{summary[:succeeded]}, failed=#{summary[:failed]}, status=#{summary[:status].inspect}, keys=#{items.keys.inspect}, failed_keys=#{failed_keys.inspect}}"
292
+ end
293
+
294
+ def serialized_collection_items_hash?(value)
295
+ return false if value.empty?
296
+
297
+ value.values.all? do |item|
298
+ item.is_a?(Hash) && (item.key?(:key) || item.key?("key")) && (item.key?(:status) || item.key?("status"))
299
+ end
300
+ end
301
+
302
+ def summarize_serialized_collection_items_hash(value)
303
+ failed_keys = value.each_with_object([]) do |(key, item), memo|
304
+ status = item[:status] || item["status"]
305
+ memo << key if status == :failed || status == "failed"
306
+ end
307
+
308
+ total = value.size
309
+ failed = failed_keys.size
310
+ succeeded = total - failed
311
+ status = failed.zero? ? :succeeded : :partial_failure
312
+
313
+ "{mode=:collect, total=#{total}, succeeded=#{succeeded}, failed=#{failed}, status=#{status.inspect}, keys=#{value.keys.inspect}, failed_keys=#{failed_keys.inspect}}"
314
+ end
315
+
316
+ def nested_result_status(result)
317
+ return :failed if result.failed?
318
+ return :pending if result.pending?
319
+
320
+ :succeeded
321
+ end
322
+
323
+ def summarize_collection_nodes
324
+ execution.cache.values.filter_map do |state|
325
+ next unless state.value.is_a?(Runtime::CollectionResult)
326
+
327
+ result = state.value
328
+ {
329
+ node_name: state.node.name,
330
+ path: state.node.path,
331
+ mode: result.mode,
332
+ total: result.items.size,
333
+ succeeded: result.successes.size,
334
+ failed: result.failures.size,
335
+ status: result.failures.empty? ? :succeeded : :partial_failure,
336
+ failed_items: result.failures.values.map do |item|
337
+ {
338
+ key: item.key,
339
+ type: item.error.class.name,
340
+ message: item.error.message,
341
+ context: item.error.respond_to?(:context) ? item.error.context : {}
342
+ }
343
+ end
344
+ }
345
+ end
346
+ end
172
347
  end
173
348
  end
174
349
  end
@@ -11,9 +11,13 @@ module Igniter
11
11
  @name = name
12
12
  @nodes = []
13
13
  @sequence = 0
14
+ @scope_stack = []
14
15
  end
15
16
 
16
17
  UNDEFINED_INPUT_DEFAULT = :__igniter_undefined__
18
+ UNDEFINED_CONST_VALUE = :__igniter_const_undefined__
19
+ UNDEFINED_GUARD_MATCHER = :__igniter_guard_matcher_undefined__
20
+ UNDEFINED_PROJECT_OPTION = :__igniter_project_undefined__
17
21
 
18
22
  def input(name, type: nil, required: nil, default: UNDEFINED_INPUT_DEFAULT, **metadata)
19
23
  input_metadata = with_source_location(metadata)
@@ -25,33 +29,141 @@ module Igniter
25
29
  Model::InputNode.new(
26
30
  id: next_id,
27
31
  name: name,
32
+ path: scoped_path(name),
28
33
  metadata: input_metadata
29
34
  )
30
35
  )
31
36
  end
32
37
 
33
- def compute(name, depends_on:, call: nil, **metadata, &block)
34
- callable = call || block
38
+ def compute(name, depends_on: nil, with: nil, call: nil, executor: nil, **metadata, &block)
39
+ callable, resolved_metadata = resolve_compute_callable(call: call, executor: executor, metadata: metadata, block: block)
35
40
  raise CompileError, "compute :#{name} requires a callable" unless callable
36
- raise CompileError, "compute :#{name} cannot accept both `call:` and a block" if call && block
37
41
 
38
42
  add_node(
39
43
  Model::ComputeNode.new(
40
44
  id: next_id,
41
45
  name: name,
42
- dependencies: Array(depends_on),
46
+ dependencies: normalize_dependencies(depends_on: depends_on, with: with),
43
47
  callable: callable,
44
- metadata: with_source_location(metadata)
48
+ path: scoped_path(name),
49
+ metadata: with_source_location(resolved_metadata)
45
50
  )
46
51
  )
47
52
  end
48
53
 
54
+ def const(name, value = UNDEFINED_CONST_VALUE, **metadata, &block)
55
+ raise CompileError, "const :#{name} cannot accept both a value and a block" if !block.nil? && value != UNDEFINED_CONST_VALUE
56
+ raise CompileError, "const :#{name} requires a value or a block" if block.nil? && value == UNDEFINED_CONST_VALUE
57
+
58
+ callable = if block
59
+ block
60
+ else
61
+ proc { value }
62
+ end
63
+
64
+ compute(name, with: [], call: callable, **metadata.merge(kind: :const))
65
+ end
66
+
67
+ def lookup(name, depends_on: nil, with: nil, call: nil, executor: nil, **metadata, &block)
68
+ compute(name, depends_on: depends_on, with: with, call: call, executor: executor, **{ category: :lookup }.merge(metadata), &block)
69
+ end
70
+
71
+ def map(name, from:, call: nil, executor: nil, **metadata, &block)
72
+ compute(name, with: from, call: call, executor: executor, **{ category: :map }.merge(metadata), &block)
73
+ end
74
+
75
+ def project(name, from:, key: UNDEFINED_PROJECT_OPTION, dig: UNDEFINED_PROJECT_OPTION, default: UNDEFINED_PROJECT_OPTION, **metadata)
76
+ if key != UNDEFINED_PROJECT_OPTION && dig != UNDEFINED_PROJECT_OPTION
77
+ raise CompileError, "project :#{name} cannot use both `key:` and `dig:`"
78
+ end
79
+
80
+ if key == UNDEFINED_PROJECT_OPTION && dig == UNDEFINED_PROJECT_OPTION
81
+ raise CompileError, "project :#{name} requires either `key:` or `dig:`"
82
+ end
83
+
84
+ callable = proc do |**values|
85
+ source = values.fetch(from.to_sym)
86
+ extract_projected_value(
87
+ source,
88
+ key: key,
89
+ dig: dig,
90
+ default: default,
91
+ node_name: name
92
+ )
93
+ end
94
+
95
+ compute(name, with: from, call: callable, **{ category: :project }.merge(metadata))
96
+ end
97
+
98
+ def aggregate(name, depends_on: nil, with: nil, call: nil, executor: nil, **metadata, &block)
99
+ compute(name, depends_on: depends_on, with: with, call: call, executor: executor, **{ category: :aggregate }.merge(metadata), &block)
100
+ end
101
+
102
+ def guard(name, depends_on: nil, with: nil, call: nil, executor: nil, message: nil,
103
+ eq: UNDEFINED_GUARD_MATCHER, in: UNDEFINED_GUARD_MATCHER, matches: UNDEFINED_GUARD_MATCHER,
104
+ **metadata, &block)
105
+ matcher_options = {
106
+ eq: eq,
107
+ in: binding.local_variable_get(:in),
108
+ matches: matches
109
+ }.reject { |_key, value| value == UNDEFINED_GUARD_MATCHER }
110
+
111
+ if matcher_options.any?
112
+ raise CompileError, "guard :#{name} cannot combine matcher options with `call:`, `executor:`, or a block" if call || executor || block
113
+ raise CompileError, "guard :#{name} supports only one matcher option at a time" if matcher_options.size > 1
114
+
115
+ dependencies = normalize_dependencies(depends_on: depends_on, with: with)
116
+ raise CompileError, "guard :#{name} with matcher options requires exactly one dependency" unless dependencies.size == 1
117
+
118
+ dependency = dependencies.first
119
+ matcher_name, matcher_value = matcher_options.first
120
+
121
+ call = build_guard_matcher(matcher_name, matcher_value, dependency)
122
+ end
123
+
124
+ compute(
125
+ name,
126
+ depends_on: depends_on,
127
+ with: with,
128
+ call: call,
129
+ executor: executor,
130
+ **metadata.merge(kind: :guard, guard: true, guard_message: message || "Guard '#{name}' failed"),
131
+ &block
132
+ )
133
+ end
134
+
135
+ def export(*names, from:, **metadata)
136
+ names.each do |name|
137
+ output(name, from: "#{from}.#{name}", **metadata)
138
+ end
139
+ end
140
+
141
+ def expose(*sources, as: nil, **metadata)
142
+ raise CompileError, "expose cannot use `as:` with multiple sources" if as && sources.size != 1
143
+
144
+ sources.each do |source|
145
+ output(as || source, from: source, **metadata)
146
+ end
147
+ end
148
+
149
+ def scope(name, &block)
150
+ raise CompileError, "scope requires a block" unless block
151
+
152
+ @scope_stack << name.to_s
153
+ instance_eval(&block)
154
+ ensure
155
+ @scope_stack.pop
156
+ end
157
+
158
+ alias namespace scope
159
+
49
160
  def output(name, from: nil, **metadata)
50
161
  add_node(
51
162
  Model::OutputNode.new(
52
163
  id: next_id,
53
164
  name: name,
54
165
  source: (from || name),
166
+ path: scoped_output_path(name),
55
167
  metadata: with_source_location(metadata)
56
168
  )
57
169
  )
@@ -66,6 +178,51 @@ module Igniter
66
178
  name: name,
67
179
  contract_class: contract,
68
180
  input_mapping: inputs,
181
+ path: scoped_path(name),
182
+ metadata: with_source_location(metadata)
183
+ )
184
+ )
185
+ end
186
+
187
+ def branch(name, with:, inputs: nil, depends_on: nil, map_inputs: nil, using: nil, **metadata, &block)
188
+ raise CompileError, "branch :#{name} requires a block" unless block
189
+ raise CompileError, "branch :#{name} requires either `inputs:` or `map_inputs:`/`using:`" if inputs.nil? && map_inputs.nil? && using.nil?
190
+ raise CompileError, "branch :#{name} cannot combine `inputs:` with `map_inputs:` or `using:`" if inputs && (map_inputs || using)
191
+ raise CompileError, "branch :#{name} cannot use both `map_inputs:` and `using:`" if map_inputs && using
192
+ raise CompileError, "branch :#{name} requires an `inputs:` hash" if inputs && !inputs.is_a?(Hash)
193
+
194
+ definition = BranchBuilder.build(&block)
195
+
196
+ add_node(
197
+ Model::BranchNode.new(
198
+ id: next_id,
199
+ name: name,
200
+ selector_dependency: with,
201
+ cases: definition[:cases],
202
+ default_contract: definition[:default_contract],
203
+ input_mapping: inputs || {},
204
+ context_dependencies: normalize_dependencies(depends_on: depends_on, with: nil),
205
+ input_mapper: map_inputs || using,
206
+ path: scoped_path(name),
207
+ metadata: with_source_location(metadata)
208
+ )
209
+ )
210
+ end
211
+
212
+ def collection(name, with:, each:, key:, mode: :collect, depends_on: nil, map_inputs: nil, using: nil, **metadata)
213
+ raise CompileError, "collection :#{name} cannot use both `map_inputs:` and `using:`" if map_inputs && using
214
+
215
+ add_node(
216
+ Model::CollectionNode.new(
217
+ id: next_id,
218
+ name: name,
219
+ source_dependency: with,
220
+ contract_class: each,
221
+ key_name: key,
222
+ mode: mode,
223
+ context_dependencies: normalize_dependencies(depends_on: depends_on, with: nil),
224
+ input_mapper: map_inputs || using,
225
+ path: scoped_path(name),
69
226
  metadata: with_source_location(metadata)
70
227
  )
71
228
  )
@@ -90,6 +247,115 @@ module Igniter
90
247
  def with_source_location(metadata)
91
248
  metadata.merge(source_location: caller_locations(2, 1).first&.to_s)
92
249
  end
250
+
251
+ def resolve_compute_callable(call:, executor:, metadata:, block:)
252
+ raise CompileError, "compute cannot accept both `call:` and `executor:`" if call && executor
253
+ raise CompileError, "compute cannot accept both `call:` and a block" if call && block
254
+ raise CompileError, "compute cannot accept both `executor:` and a block" if executor && block
255
+
256
+ if executor
257
+ definition = Igniter.executor_registry.fetch(executor)
258
+ return [definition.executor_class, definition.metadata.merge(metadata).merge(executor_key: definition.key)]
259
+ end
260
+
261
+ [call || block, metadata]
262
+ end
263
+
264
+ def normalize_dependencies(depends_on:, with:)
265
+ raise CompileError, "Use either `depends_on:` or `with:`, not both" if depends_on && with
266
+
267
+ dependencies = depends_on || with
268
+ Array(dependencies)
269
+ end
270
+
271
+ def build_guard_matcher(matcher_name, matcher_value, dependency)
272
+ case matcher_name
273
+ when :eq
274
+ proc do |**values|
275
+ values.fetch(dependency) == matcher_value
276
+ end
277
+ when :in
278
+ allowed_values = Array(matcher_value)
279
+ proc do |**values|
280
+ allowed_values.include?(values.fetch(dependency))
281
+ end
282
+ when :matches
283
+ matcher = matcher_value
284
+ raise CompileError, "`matches:` expects a Regexp" unless matcher.is_a?(Regexp)
285
+
286
+ proc do |**values|
287
+ values.fetch(dependency).to_s.match?(matcher)
288
+ end
289
+ else
290
+ raise CompileError, "Unsupported guard matcher: #{matcher_name}"
291
+ end
292
+ end
293
+
294
+ def extract_projected_value(source, key:, dig:, default:, node_name:)
295
+ if key != UNDEFINED_PROJECT_OPTION
296
+ return fetch_project_value(source, key, default, node_name)
297
+ end
298
+
299
+ current = source
300
+ Array(dig).each do |part|
301
+ current = fetch_project_value(current, part, default, node_name)
302
+ end
303
+ current
304
+ end
305
+
306
+ def fetch_project_value(source, part, default, node_name)
307
+ if source.is_a?(Hash)
308
+ return source.fetch(part) if source.key?(part)
309
+ return source.fetch(part.to_s) if source.key?(part.to_s)
310
+ return source.fetch(part.to_sym) if source.key?(part.to_sym)
311
+ elsif source.respond_to?(part)
312
+ return source.public_send(part)
313
+ end
314
+
315
+ return default unless default == UNDEFINED_PROJECT_OPTION
316
+
317
+ raise ResolutionError, "project :#{node_name} could not extract #{part.inspect}"
318
+ end
319
+
320
+ def scoped_path(name)
321
+ return name.to_s if @scope_stack.empty?
322
+
323
+ "#{@scope_stack.join('.')}.#{name}"
324
+ end
325
+
326
+ def scoped_output_path(name)
327
+ return "output.#{name}" if @scope_stack.empty?
328
+
329
+ "#{@scope_stack.join('.')}.output.#{name}"
330
+ end
331
+
332
+ class BranchBuilder
333
+ def self.build(&block)
334
+ new.tap { |builder| builder.instance_eval(&block) }.to_h
335
+ end
336
+
337
+ def initialize
338
+ @cases = []
339
+ @default_contract = nil
340
+ end
341
+
342
+ def on(match, contract:)
343
+ @cases << { match: match, contract: contract }
344
+ end
345
+
346
+ def default(contract:)
347
+ raise CompileError, "branch can define only one `default`" if @default_contract
348
+
349
+ @default_contract = contract
350
+ end
351
+
352
+ def to_h
353
+ {
354
+ cases: @cases,
355
+ default_contract: @default_contract
356
+ }
357
+ end
358
+ end
93
359
  end
94
360
  end
95
361
  end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module DSL
5
+ class SchemaBuilder
6
+ def self.compile(schema, name: nil)
7
+ new(schema, name: name).compile
8
+ end
9
+
10
+ def initialize(schema, name: nil)
11
+ @schema = symbolize(schema)
12
+ @name = name || @schema[:name] || "AnonymousContract"
13
+ end
14
+
15
+ def compile
16
+ schema = @schema
17
+
18
+ ContractBuilder.compile(name: @name) do
19
+ Array(schema[:inputs]).each do |input_config|
20
+ config = input_config
21
+ input(
22
+ config.fetch(:name),
23
+ type: config[:type],
24
+ required: config[:required],
25
+ default: config.fetch(:default, ContractBuilder::UNDEFINED_INPUT_DEFAULT),
26
+ **config.fetch(:metadata, {})
27
+ )
28
+ end
29
+
30
+ Array(schema[:compositions]).each do |composition_config|
31
+ config = composition_config
32
+ compose(
33
+ config.fetch(:name),
34
+ contract: config.fetch(:contract),
35
+ inputs: config.fetch(:inputs),
36
+ **config.fetch(:metadata, {})
37
+ )
38
+ end
39
+
40
+ Array(schema[:computes]).each do |compute_config|
41
+ config = compute_config
42
+ options = {
43
+ depends_on: Array(config.fetch(:depends_on)).map(&:to_sym)
44
+ }
45
+ options[:call] = config[:call] if config.key?(:call)
46
+ options[:executor] = config[:executor] if config.key?(:executor)
47
+ compute(config.fetch(:name), **options, **config.fetch(:metadata, {}))
48
+ end
49
+
50
+ Array(schema[:outputs]).each do |output_config|
51
+ config = output_config
52
+ output(config.fetch(:name), from: config[:from], **config.fetch(:metadata, {}))
53
+ end
54
+ end
55
+ end
56
+
57
+ private
58
+
59
+ def symbolize(value)
60
+ case value
61
+ when Hash
62
+ value.each_with_object({}) do |(key, nested), memo|
63
+ memo[key.to_sym] = symbolize(nested)
64
+ end
65
+ when Array
66
+ value.map { |item| symbolize(item) }
67
+ else
68
+ value
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end