vetra 0.1.0.pre.alpha.4

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.
@@ -0,0 +1,444 @@
1
+ module Vetra
2
+ module Graph
3
+ module DSL
4
+ class Base < BasicObject
5
+ def self.const_missing(name)
6
+ ::Object.const_get(name)
7
+ end
8
+
9
+ def initialize(registry, manifest, domain, source)
10
+ @registry, @manifest, @domain, @source = registry, manifest, domain, source
11
+ end
12
+
13
+ def evaluate(block)
14
+ instance_eval(&block) if block
15
+ self
16
+ end
17
+
18
+ def evaluate_source(declaration_source, declaration_filename)
19
+ instance_eval(declaration_source, declaration_filename, 1)
20
+ end
21
+
22
+ def method_missing(name, *_args, **_options, &_block)
23
+ error("unsupported_declaration", "Declaração #{name} não implementada no protótipo; nenhum dado foi descartado.")
24
+ end
25
+
26
+ def error(code, message, origin: @source)
27
+ ::Kernel.raise Error.single(code, message, source: origin)
28
+ end
29
+
30
+ def source(block)
31
+ return @manifest.source(*block.source_location) if block
32
+ filename = @manifest.root.join(@source['path']).to_s
33
+ frame = ::Kernel.caller_locations.find { |entry| (entry.absolute_path || entry.path) == filename }
34
+ frame ? @manifest.source(filename, frame.lineno) : @source
35
+ end
36
+
37
+ def qualify(name)
38
+ value = name.to_s
39
+ value.include?(".") ? value : "#{@domain}.#{value}"
40
+ end
41
+
42
+ def declaration(kind, name, attributes)
43
+ error("domain_required", "Declare nós dentro de app/domains/<domínio>/.") unless @domain
44
+ { "kind" => kind, "id" => qualify(name), "domain" => @domain, "source" => @source, **attributes }
45
+ end
46
+
47
+ def options!(options, allowed)
48
+ unknown = options.keys - allowed
49
+ error("unsupported_option", "Opções não implementadas: #{unknown.join(', ')}.") unless unknown.empty?
50
+ end
51
+ end
52
+
53
+ class App < Base
54
+ attr_reader :attributes
55
+
56
+ def initialize(*args, name:)
57
+ super(*args)
58
+ @attributes = { "name" => name.to_s, "currency" => "BRL", "locale" => "pt-BR", "tenant" => nil, "source" => @source }
59
+ end
60
+
61
+ def currency(value, **options)
62
+ options!(options, [])
63
+ @attributes["currency"] = value.to_s
64
+ end
65
+
66
+ def locale(value)
67
+ @attributes["locale"] = value.to_s
68
+ end
69
+
70
+ def tenant(value)
71
+ @attributes["tenant"] = value.to_s
72
+ end
73
+ end
74
+
75
+ class Domain < Base
76
+ attr_reader :dependencies
77
+
78
+ def initialize(*args)
79
+ super
80
+ @dependencies = nil
81
+ end
82
+
83
+ def depends_on(*names)
84
+ @dependencies = names.map(&:to_s)
85
+ end
86
+ end
87
+
88
+ class Fields < Base
89
+ attr_reader :fields
90
+ TYPES = %w[string text email boolean integer date timestamp json id decimal money].freeze
91
+
92
+ def initialize(*args)
93
+ super
94
+ @fields = []
95
+ end
96
+
97
+ TYPES.each do |type|
98
+ define_method(type) { |name, **options| field(name, type, **options) }
99
+ end
100
+
101
+ def enum(name, values, **options)
102
+ field(name, "enum", **options.merge(values: values))
103
+ end
104
+
105
+ def slug(name, from:, **options)
106
+ field(name, "string", **options.merge(generate: { "kind" => "slug", "from" => from.to_s }))
107
+ end
108
+
109
+ def field(name, type, **options)
110
+ options!(options, %i[required optional nullable default personal classification set_by guarded values groups precision scale entity min max length unique normalize generate was])
111
+ error("duplicate_field", "Campo repetido: #{name}.") if fields.any? { |entry| entry['name'] == name.to_s }
112
+ optional = options.fetch(:optional, false)
113
+ descriptor = { "name" => type.to_s }
114
+ descriptor["entity"] = options[:entity].to_s if options.key?(:entity)
115
+ descriptor["values"] = options.fetch(:values).map(&:to_s) if type.to_s == "enum"
116
+ descriptor["groups"] = options[:groups].to_h { |key, values| [key.to_s, values.map(&:to_s)] } if options[:groups]
117
+ if %w[money decimal].include?(type.to_s)
118
+ descriptor["precision"] = options.fetch(:precision, 19)
119
+ # Money's implicit scale depends on the application, resolved during normalization.
120
+ descriptor["scale"] = options.fetch(:scale, 2) if type.to_s == "decimal" || options.key?(:scale)
121
+ end
122
+ personal = options.fetch(:personal, false)
123
+ privacy = { "personal" => personal }
124
+ privacy["classification"] = options.fetch(:classification, "personal").to_s if personal
125
+ entry = {
126
+ "name" => name.to_s, "type" => descriptor,
127
+ "required" => options.fetch(:required, !optional && !options.key?(:default) && !options.key?(:generate)),
128
+ "nullable" => options.fetch(:nullable, optional || (type.to_s == "timestamp" && options.key?(:set_by))),
129
+ "privacy" => privacy
130
+ }
131
+ if options.key?(:default)
132
+ value = options[:default]
133
+ entry["default"] = if %i[current_actor current_tenant].include?(value)
134
+ { "kind" => "context", "name" => value.to_s }
135
+ else
136
+ { "kind" => "literal", "value" => json_value(value) }
137
+ end
138
+ end
139
+ entry["set_by"] = ::Kernel.Array(options[:set_by]).map(&:to_s) if options.key?(:set_by)
140
+ entry["guarded"] = options[:guarded].map { |value, commands| { "value" => value.to_s, "commands" => ::Kernel.Array(commands).map(&:to_s) } } if options[:guarded]
141
+ entry["previous_names"] = ::Kernel.Array(options[:was]).map(&:to_s) if options.key?(:was)
142
+ entry["normalize"] = ::Kernel.Array(options[:normalize]).map(&:to_s) if options.key?(:normalize)
143
+ entry["generate"] = options[:generate] if options.key?(:generate)
144
+ constraints = {}
145
+ constraints["min_length"] = options[:min] if options.key?(:min)
146
+ constraints["max_length"] = options[:max] if options.key?(:max)
147
+ if options[:length]
148
+ range = options[:length]
149
+ error("unsupported_option", "length exige Range de inteiros inclusivo.") unless range.is_a?(::Range) && !range.exclude_end?
150
+ constraints.merge!("min_length" => range.begin, "max_length" => range.end)
151
+ end
152
+ constraints["unique"] = options[:unique] if options.key?(:unique)
153
+ entry["constraints"] = constraints unless constraints.empty?
154
+ @fields << entry
155
+ end
156
+
157
+ def json_value(value)
158
+ case value
159
+ when ::Hash then value.to_h { |key, child| [key.to_s, json_value(child)] }
160
+ when ::Array then value.map { |child| json_value(child) }
161
+ when ::Symbol then value.to_s
162
+ else value
163
+ end
164
+ end
165
+ end
166
+
167
+ class Entity < Fields
168
+ attr_reader :attributes
169
+
170
+ def initialize(*args, options:)
171
+ super(*args)
172
+ options!(options, %i[table repository was])
173
+ @attributes = { "fields" => fields, "relations" => [], "indexes" => [], "history" => "full", "transitions" => { "enforce" => "application" }, "options" => options }
174
+ end
175
+
176
+ def tenant(value)
177
+ @attributes["tenant"] = value == false ? false : value.to_s
178
+ end
179
+
180
+ def belongs_to(name, entity: name, optional: false, same_tenant: false, **options)
181
+ options!(options, %i[default])
182
+ field("#{name}_id", "id", entity: entity, optional: optional, **options)
183
+ @attributes["relations"] << { "name" => name.to_s, "target" => entity.to_s, "kind" => "belongs_to", "same_tenant" => same_tenant }
184
+ end
185
+
186
+ def has_many(name, entity:, by:)
187
+ @attributes["relations"] << { "name" => name.to_s, "target" => entity.to_s, "kind" => "has_many", "by" => by.to_s, "same_tenant" => false }
188
+ end
189
+
190
+ # `entity:` e `through:` são opcionais: sem `entity:`, o alvo é a entidade do mesmo domínio com o nome da
191
+ # relação no singular; sem `through:`, o normalizador gera a entidade de junção (ver Normalizer).
192
+ def many_to_many(name, entity: nil, through: nil, include: nil)
193
+ @attributes["relations"] << {
194
+ "name" => name.to_s, "target" => entity&.to_s, "kind" => "many_to_many", "through" => through&.to_s,
195
+ "include" => ::Kernel.Array(include).map(&:to_s), "same_tenant" => false
196
+ }
197
+ end
198
+
199
+ def memberships(of:, actor:, role:)
200
+ error("duplicate_memberships", "Declare memberships uma única vez por entidade.") if @attributes.key?("memberships")
201
+ @attributes["memberships"] = { "of" => of.to_s, "actor" => actor.to_s, "role" => role.to_s }
202
+ end
203
+
204
+ def index(*names, unique: false)
205
+ @attributes["indexes"] << { "fields" => names.map(&:to_s), "unique" => unique }
206
+ end
207
+
208
+ def crud(scope: nil)
209
+ @attributes["crud"] = { "scope" => scope&.to_s }
210
+ end
211
+
212
+ def history(value)
213
+ @attributes["history"] = value.to_s
214
+ end
215
+
216
+ def transitions(enforce:)
217
+ @attributes["transitions"] = { "enforce" => enforce.to_s }
218
+ end
219
+ end
220
+
221
+ class Output < Fields
222
+ def record(name, entity:, optional: false)
223
+ field(name, 'record', entity: entity, optional: optional)
224
+ end
225
+
226
+ def collection(name, entity:, optional: false)
227
+ field(name, 'collection', entity: entity, optional: optional)
228
+ end
229
+ end
230
+
231
+ class Operation < Fields
232
+ attr_reader :attributes, :implementation
233
+
234
+ def initialize(*args)
235
+ super
236
+ @attributes = { "fields" => fields, "uses" => [], "emits" => [], "exposures" => [], "on" => [], "calls" => [] }
237
+ end
238
+
239
+ def input(name, type, **options)
240
+ field(name, type.to_s, **options)
241
+ end
242
+
243
+ def returns(entity = nil, collection: false, &block)
244
+ error('output_contract', 'Declare returns uma única vez, com entidade ou bloco.') if @attributes['returns'] || (block && entity) || (!block && !entity)
245
+ error('output_contract', 'collection exige true ou false e não pode acompanhar um bloco.') unless [true, false].include?(collection) && !(block && collection)
246
+ @attributes['returns'] = if block
247
+ builder = Output.new(@registry, @manifest, @domain, source(block)).evaluate(block)
248
+ { 'fields' => builder.fields }
249
+ elsif collection
250
+ { 'entity' => entity.to_s, 'collection' => true }
251
+ else
252
+ entity.to_s
253
+ end
254
+ end
255
+
256
+ def authorize(action, entity, record: nil)
257
+ @attributes["authorize"] = { "action" => action.to_s, "entity" => entity.to_s }
258
+ @attributes["authorize"]["record"] = record.to_s if record
259
+ end
260
+
261
+ def uses(*names)
262
+ @attributes["uses"].concat(names.map(&:to_s))
263
+ end
264
+
265
+ def live(value = true, **options)
266
+ origin = source(nil)
267
+ if @attributes.key?('live') || ![true, false].include?(value)
268
+ error('live_declaration', 'Declare live uma única vez, com true ou false.', origin: origin)
269
+ end
270
+ unknown = options.keys - %i[mode depends_on]
271
+ error('unsupported_option', "Opções não implementadas em live: #{unknown.join(', ')}.", origin: origin) unless unknown.empty?
272
+ error('live_declaration', 'live false não aceita opções adicionais.', origin: origin) if !value && !options.empty?
273
+ mode = options.fetch(:mode, :strict).to_s
274
+ error('live_declaration', 'live exige mode: :strict ou :balanced.', origin: origin) unless %w[strict balanced].include?(mode)
275
+ declaration = { 'enabled' => value, 'mode' => mode }
276
+ if options.key?(:depends_on)
277
+ names = options[:depends_on]
278
+ unless names.is_a?(::Array) && !names.empty? && names.all? { |name| (name.is_a?(::String) || name.is_a?(::Symbol)) && !name.to_s.strip.empty? }
279
+ error('live_dependencies', 'depends_on de live exige uma lista não vazia de entidades.', origin: origin)
280
+ end
281
+ declaration['depends_on'] = names.map(&:to_s)
282
+ end
283
+ @attributes['live'] = declaration
284
+ end
285
+
286
+ def emits(event, with:, from: nil)
287
+ @attributes["emits"] << { "event" => event.to_s, "fields" => with.map(&:to_s), "from" => from&.to_s }
288
+ end
289
+
290
+ def expose(idempotency: :optional, **surfaces)
291
+ origin = source(nil)
292
+ unknown = surfaces.keys - %i[http cli job webhook]
293
+ unless unknown.empty?
294
+ ::Kernel.raise Error.single('unsupported_option', "Opções não implementadas em expose: #{unknown.join(', ')}.", source: origin)
295
+ end
296
+ if surfaces.empty?
297
+ ::Kernel.raise Error.single('missing_option', 'Declare ao menos uma superfície em expose: http, cli, job ou webhook.', source: origin)
298
+ end
299
+ surfaces.each do |surface, value|
300
+ named = %i[cli job].include?(surface)
301
+ valid = value.is_a?(::String) || (named && value.is_a?(::Symbol))
302
+ unless valid && !value.to_s.strip.empty?
303
+ format = named ? 'um nome não vazio (String ou Symbol)' : '"MÉTODO /caminho" (String)'
304
+ ::Kernel.raise Error.single('route_format', "expose #{surface} exige #{format}.", source: origin)
305
+ end
306
+ @attributes['exposures'] << { 'surface' => surface.to_s, 'value' => value.to_s, 'idempotency' => idempotency.to_s, 'source' => origin }
307
+ end
308
+ end
309
+
310
+ def call(&block)
311
+ error("duplicate_implementation", "Declare apenas um bloco call.") if @implementation
312
+ @implementation = block
313
+ end
314
+
315
+ def unsafe_effect(reason)
316
+ error("unsafe_reason", "unsafe_effect exige motivo não vazio.") if reason.to_s.strip.empty?
317
+ @attributes["unsafe_effect"] = reason.to_s
318
+ end
319
+
320
+ def on(*events)
321
+ @attributes["on"].concat(events.map(&:to_s))
322
+ end
323
+
324
+ def calls(*commands)
325
+ @attributes["calls"].concat(commands.map(&:to_s))
326
+ end
327
+
328
+ def idempotent_by(value)
329
+ @attributes["idempotent_by"] = value.to_s
330
+ end
331
+ end
332
+
333
+ class Role < Base
334
+ attr_reader :permissions
335
+
336
+ def initialize(*args, scope: nil)
337
+ super(*args)
338
+ @scope = scope
339
+ @permissions = []
340
+ end
341
+
342
+ def can(*actions, on:, where: {}, fields: "*", scope: @scope, **options)
343
+ options!(options, [])
344
+ ::Kernel.Array(on).each do |entity|
345
+ @permissions << { "entity" => entity.to_s, "actions" => actions.map(&:to_s), "where" => where, "fields" => fields == "*" ? "*" : fields.map(&:to_s), "scope" => scope&.to_s }
346
+ end
347
+ end
348
+ end
349
+
350
+ class Roles < Base
351
+ def initialize(*args, scope: nil)
352
+ super(*args)
353
+ @scope = scope
354
+ end
355
+
356
+ def role(name, anonymous: false, &block)
357
+ origin = source(block)
358
+ builder = Role.new(@registry, @manifest, @domain, origin, scope: @scope).evaluate(block)
359
+ @registry.add(declaration("role", name, "source" => origin, "anonymous" => anonymous, "permissions" => builder.permissions))
360
+ end
361
+
362
+ def visitor(&block)
363
+ role(:visitor, anonymous: true, &block)
364
+ end
365
+
366
+ def authenticated(&block)
367
+ role(:authenticated, &block)
368
+ end
369
+ end
370
+
371
+ class Effect < Base
372
+ attr_reader :attributes
373
+
374
+ def initialize(*args)
375
+ super
376
+ @attributes = { "adapter" => "app.effect", "retry_safe" => false, "simulation" => "none", "config" => { "type" => "external", "idempotency_key" => false, "hosts" => [], "timeout_ms" => 5000 } }
377
+ end
378
+
379
+ def adapter(value)
380
+ @attributes["adapter"] = value.to_s
381
+ end
382
+
383
+ def retry_safe(value)
384
+ @attributes["retry_safe"] = value
385
+ end
386
+
387
+ def simulate(value)
388
+ @attributes["simulation"] = value.to_s
389
+ end
390
+
391
+ def idempotency_key(value)
392
+ @attributes["config"]["idempotency_key"] = value
393
+ end
394
+
395
+ def hosts(*values)
396
+ @attributes["config"]["hosts"] = values
397
+ end
398
+
399
+ def timeout(seconds)
400
+ @attributes["config"]["timeout_ms"] = seconds * 1000
401
+ end
402
+ end
403
+
404
+ class Context < Base
405
+ def app(name, &block)
406
+ builder = App.new(@registry, @manifest, nil, source(block), name: name).evaluate(block)
407
+ @registry.app(builder.attributes)
408
+ end
409
+
410
+ def domain(name, &block)
411
+ error("domain_mismatch", "Domínio deve coincidir com o diretório #{@domain}.") unless name.to_s == @domain
412
+ builder = Domain.new(@registry, @manifest, @domain, source(block)).evaluate(block)
413
+ @registry.add({ "kind" => "domain", "id" => @domain, "source" => source(block), "depends_on" => builder.dependencies })
414
+ end
415
+
416
+ def entity(name, **options, &block)
417
+ builder = Entity.new(@registry, @manifest, @domain, source(block), options: options).evaluate(block)
418
+ @registry.add(declaration("entity", name, builder.attributes.merge("source" => source(block))))
419
+ end
420
+
421
+ %w[command query handler].each do |kind|
422
+ define_method(kind) do |name, &block|
423
+ builder = Operation.new(@registry, @manifest, @domain, source(block)).evaluate(block)
424
+ error("missing_implementation", "Declare call em #{name}.") unless builder.implementation
425
+ @registry.add(declaration(kind, name, builder.attributes.merge("source" => source(block))), block: builder.implementation)
426
+ end
427
+ end
428
+
429
+ def roles(scope: nil, &block)
430
+ Roles.new(@registry, @manifest, @domain, source(block), scope: scope).evaluate(block)
431
+ end
432
+
433
+ def effect(name, &block)
434
+ builder = Effect.new(@registry, @manifest, @domain, source(block)).evaluate(block)
435
+ @registry.add(declaration("capability", name, builder.attributes.merge("source" => source(block))))
436
+ end
437
+
438
+ def event(name, from:, fields:, visibility: :internal, consumer_required: false, version: 1)
439
+ @registry.add(declaration("event", name, "source" => source(nil), "from" => from.to_s, "fields" => fields.map(&:to_s), "visibility" => visibility.to_s, "consumer_required" => consumer_required, "version" => version))
440
+ end
441
+ end
442
+ end
443
+ end
444
+ end
@@ -0,0 +1,26 @@
1
+ module Vetra
2
+ module Graph
3
+ class LinkedGraph
4
+ attr_reader :document, :warnings, :bindings, :implementation_ids, :builtin_bindings
5
+
6
+ def initialize(document:, warnings:, bindings:, implementations:)
7
+ @document = Data.freeze(document)
8
+ @warnings = Data.freeze(warnings)
9
+ @bindings = Data.freeze(bindings.sort_by { |binding| binding['operation'].values_at('kind', 'id') })
10
+ @implementation_ids = Data.freeze(implementations.keys.sort)
11
+ @builtin_bindings = Data.freeze(@bindings.select { |binding| binding['kind'] == 'builtin' })
12
+ @binding_index = @bindings.to_h { |binding| [binding['operation'].values_at('kind', 'id').freeze, binding] }.freeze
13
+ # Executable closures stay private. Their captured state is not made
14
+ # shareable by freezing a Proc; only the data views are shareable.
15
+ @implementations = implementations.dup.freeze
16
+ freeze
17
+ end
18
+
19
+ def binding_for(kind, id)
20
+ @binding_index.fetch([kind, id]) do
21
+ raise Error.single('binding_missing', "Vínculo não encontrado: #{kind} #{id}.")
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,95 @@
1
+ module Vetra
2
+ module Graph
3
+ # Internal binder. Compiler#link performs artifact and package validation
4
+ # before constructing this object or evaluating project declarations.
5
+ class Linker
6
+ def initialize(document, manifest)
7
+ @document, @manifest = document, manifest
8
+ @nodes = document.fetch('nodes').to_h { |node| [node.values_at('kind', 'id'), node] }
9
+ @analysis = Analysis.new(manifest)
10
+ end
11
+
12
+ def link(registry, warnings: [])
13
+ unless registry.frozen? && registry.bindings.frozen? && registry.declarations.frozen?
14
+ raise Error.single('registry_unsealed', 'Sele o registro antes de vincular implementações.')
15
+ end
16
+ operations = @document['nodes'].select { |node| node.key?('implementation') }
17
+ ruby_nodes = operations.select { |node| node.dig('implementation', 'kind') == 'ruby' }
18
+ expected = ruby_nodes.map { |node| node.dig('implementation', 'ref') }
19
+ if expected.uniq != expected
20
+ raise Error.single('duplicate_implementation', 'Mais de uma operação usa a mesma referência de implementação.')
21
+ end
22
+ missing = expected - registry.bindings.keys
23
+ extra = registry.bindings.keys - expected
24
+ unless missing.empty?
25
+ raise Error.single('implementation_missing', "Implementações ausentes no registro: #{missing.sort.join(', ')}.")
26
+ end
27
+ unless extra.empty?
28
+ raise Error.single('implementation_unexpected', "Implementações sem operação na IR: #{extra.sort.join(', ')}.")
29
+ end
30
+ declarations = registry.declarations.group_by { |entry| entry.values_at('kind', 'id') }
31
+ bindings = operations.map do |node|
32
+ if node.dig('implementation', 'kind') == 'ruby'
33
+ ruby_binding(node, declarations, registry.bindings)
34
+ else
35
+ builtin_binding(node)
36
+ end
37
+ end
38
+ LinkedGraph.new(document: @document, warnings: warnings, bindings: bindings, implementations: registry.bindings)
39
+ end
40
+
41
+ private
42
+
43
+ def fail!(code, message, node)
44
+ raise Error.single(code, message, source: node['source'])
45
+ end
46
+
47
+ def ruby_binding(node, declarations, implementations)
48
+ descriptor = node['implementation']
49
+ candidates = declarations.fetch(node.values_at('kind', 'id'), [])
50
+ unless candidates.size == 1 && candidates.first['source'] == node['source'] && candidates.first['domain'] == node.dig('domain', 'id')
51
+ fail!('implementation_declaration', 'O vínculo exige uma única declaração correspondente à origem da operação.', node)
52
+ end
53
+ declaration = candidates.first
54
+ block = implementations.fetch(descriptor['ref'])
55
+ unless block.is_a?(Proc) && block.frozen? && block.source_location
56
+ fail!('implementation_source', 'A implementação deve ser um bloco Ruby congelado com origem verificável.', node)
57
+ end
58
+ begin
59
+ origin = @manifest.source(*block.source_location)
60
+ rescue Error, SystemCallError
61
+ fail!('implementation_source', 'O bloco não pertence aos fontes verificados do pacote.', node)
62
+ end
63
+ unless origin['path'] == descriptor['path'] && @manifest.entries.any? { |input| input['path'] == origin['path'] && input['digest'] == descriptor['digest'] }
64
+ fail!('implementation_source', 'Arquivo ou digest do bloco difere da implementação declarada na IR.', node)
65
+ end
66
+ report = @analysis.inspect_block(block, declaration['uses'], declaration_source: node['source'], declaration_kind: node['kind'])
67
+ unless report['analysis'] == descriptor['analysis']
68
+ fail!('implementation_analysis', 'Análise do bloco difere do artefato; reconstrua e revise o pacote.', node)
69
+ end
70
+ arguments = declaration['uses'].each_with_index.map do |name, i|
71
+ reference = Capabilities.reference(name, domain: declaration['domain'], operation_kind: node['kind'], nodes: @nodes, source: node['source'])
72
+ { 'position' => i + 1, 'parameter' => name.split('.').last, 'capability' => reference }
73
+ end
74
+ references = arguments.map { |argument| argument['capability'] }.sort_by { |reference| reference.values_at('kind', 'id') }
75
+ unless references == node['uses'].sort_by { |reference| reference.values_at('kind', 'id') }
76
+ fail!('implementation_capabilities', 'As capacidades dos parâmetros diferem do conjunto uses da IR.', node)
77
+ end
78
+ {
79
+ 'kind' => 'ruby', 'operation' => Data.reference(node['kind'], node['id']),
80
+ 'ref' => descriptor['ref'], 'source' => report['source'],
81
+ 'parameters' => block.parameters.map { |_, name| name.to_s }, 'arguments' => arguments
82
+ }
83
+ end
84
+
85
+ def builtin_binding(node)
86
+ name = node.dig('implementation', 'name')
87
+ action = name.delete_prefix('crud.')
88
+ unless Schema::CRUD_ACTIONS.include?(action) && name == "crud.#{action}" && node['kind'] == (action == 'read' ? 'query' : 'command')
89
+ fail!('builtin_implementation', 'Primitiva desconhecida ou incompatível com a operação.', node)
90
+ end
91
+ { 'kind' => 'builtin', 'operation' => Data.reference(node['kind'], node['id']), 'name' => name, 'executor' => 'pending' }
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,48 @@
1
+ module Vetra
2
+ module Graph
3
+ module LiveDependencies
4
+ module_function
5
+
6
+ def outside_boundary?(query, nodes:)
7
+ query.key?('unsafe_effect') || query['uses'].any? do |reference|
8
+ !%w[repository deterministic].include?(nodes.fetch(reference.values_at('kind', 'id')).dig('config', 'type'))
9
+ end
10
+ end
11
+
12
+ def required(query, nodes:, extra: [])
13
+ entities = [query.dig('authorize', 'entity'), *extra]
14
+ query['uses'].each do |reference|
15
+ config = nodes.fetch(reference.values_at('kind', 'id'))['config']
16
+ entities << config['entity'] if config['type'] == 'repository'
17
+ end
18
+ pending_contracts = [query.dig('output', 'id')]
19
+ contracts = {}
20
+ until pending_contracts.empty?
21
+ id = pending_contracts.pop
22
+ next if contracts[id]
23
+ contracts[id] = true
24
+ contract = nodes.fetch(['contract', id])
25
+ origin = contract['generated_from']
26
+ entities << origin if origin && origin['kind'] == 'entity'
27
+ Data.references(contract['fields']) do |reference, _pointer|
28
+ entities << reference if reference['kind'] == 'entity'
29
+ pending_contracts << reference['id'] if reference['kind'] == 'contract'
30
+ end
31
+ end
32
+ policies = nodes.values.select { |node| node['kind'] == 'policy' }.group_by { |node| node.dig('entity', 'id') }
33
+ found = {}
34
+ until entities.empty?
35
+ reference = entities.pop
36
+ id = reference.fetch('id')
37
+ next if found[id]
38
+ found[id] = reference
39
+ policies.fetch(id, []).each do |policy|
40
+ authorized = id == query.dig('authorize', 'entity', 'id') && policy['actions'].include?(query.dig('authorize', 'action'))
41
+ entities.concat(policy['reads']) if authorized || policy['actions'].include?('read')
42
+ end
43
+ end
44
+ found.values.sort_by { |reference| reference.values_at('kind', 'id') }
45
+ end
46
+ end
47
+ end
48
+ end