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,600 @@
1
+ require "date"
2
+ require "time"
3
+
4
+ module Vetra
5
+ module Graph
6
+ class Validator
7
+ attr_reader :warnings
8
+
9
+ def initialize
10
+ @schema = Schema.new
11
+ end
12
+
13
+ def validate!(document, verify_digest: true)
14
+ @warnings, @errors = [], []
15
+ @document = document
16
+ @schema.validate!(document)
17
+ Canonical.dump(document)
18
+ @nodes = {}
19
+ @positions = {}
20
+ document.fetch("nodes").each_with_index do |node, i|
21
+ key = node.values_at("kind", "id")
22
+ issue("duplicate_id", "Identidade repetida: #{key.join(' ')}.", node) if @nodes.key?(key)
23
+ @nodes[key], @positions[node.object_id] = node, "/nodes/#{i}"
24
+ end
25
+ inputs = document.fetch("inputs")
26
+ @inputs = inputs.to_h { |input| input.values_at("path", "digest") }
27
+ issue("duplicate_input", "Caminho repetido no manifesto.", pointer: "/inputs") unless @inputs.size == inputs.size
28
+ Data.references(document.fetch('app')) do |reference, pointer|
29
+ issue('reference_missing', 'Tenant da aplicação referencia entidade não declarada.', pointer: "/app#{pointer}") unless @nodes.key?(reference.values_at('kind', 'id'))
30
+ end
31
+ inputs.each_with_index do |input, i|
32
+ if input['path'].split('/').any? { |part| part == '.vetra' || part.match?(/\A(?:secrets|credentials)(?:\.|\z)|\A\.env|\.(?:key|pem|enc)\z/) }
33
+ issue('input_path', 'Cache e segredos não pertencem ao manifesto.', pointer: "/inputs/#{i}/path")
34
+ end
35
+ end
36
+ document.fetch("nodes").each { |node| common(node) }
37
+ ruby_nodes = document['nodes'].select { |node| node.dig('implementation', 'kind') == 'ruby' }
38
+ ruby_nodes.group_by { |node| node.dig('implementation', 'ref') }.each_value do |nodes|
39
+ issue('duplicate_implementation', 'Referência de implementação usada por mais de uma operação.', nodes.last, suffix: '/implementation/ref') if nodes.size > 1
40
+ end
41
+ fail_if_errors!
42
+ prepare_contracts
43
+ @currency_exponent = Currency.exponent(document.dig('app', 'currency'))
44
+ money_owner = document['nodes'].find do |node|
45
+ %w[entity contract].include?(node['kind']) && node['fields'].any? { |field| field.dig('type', 'name') == 'money' }
46
+ end
47
+ if money_owner && !@currency_exponent
48
+ issue('money_currency', 'A moeda da aplicação não tem expoente na tabela ISO 4217 distribuída; use uma moeda com unidade mínima definida.', money_owner, pointer: '/app/currency')
49
+ end
50
+ document.fetch("nodes").each do |node|
51
+ case node['kind']
52
+ when "domain" then domain(node)
53
+ when "entity" then entity(node)
54
+ when "contract" then contract(node)
55
+ when "policy" then policy(node)
56
+ when "command", "query", "handler" then operation(node)
57
+ when "event" then event(node)
58
+ when "route" then route(node)
59
+ when "dependency"
60
+ issue("dependency_unclassified", "Classifique a gem #{node['name']} antes de confiar nos efeitos.", node, warning: true) if %w[unclassified outside_guarantee].include?(node['trust'])
61
+ end
62
+ end
63
+ domain_cycles
64
+ historical_ids
65
+ route_collisions
66
+ if verify_digest && document.fetch("digest") != Canonical.digest(document)
67
+ issue("digest_mismatch", "Digest não corresponde ao documento; reconstrua a IR.", pointer: "/digest")
68
+ end
69
+ fail_if_errors!
70
+ @warnings.freeze
71
+ end
72
+
73
+ private
74
+
75
+ def issue(code, message, node = nil, suffix: "", pointer: nil, warning: false)
76
+ diagnostic = Diagnostic.new(code: code, message: message, pointer: pointer || "#{@positions[node&.object_id]}#{suffix}", source: node && node['source'], severity: warning ? :warning : :error)
77
+ (warning ? @warnings : @errors) << diagnostic
78
+ end
79
+
80
+ def fail_if_errors!
81
+ raise Error.new(@errors) unless @errors.empty?
82
+ end
83
+
84
+ def resolve(reference)
85
+ @nodes.fetch(reference.values_at("kind", "id"))
86
+ end
87
+
88
+ def each(kind)
89
+ @nodes.each_value.select { |node| node['kind'] == kind }
90
+ end
91
+
92
+ def field_map(node)
93
+ node.fetch("fields").to_h { |field| [field.fetch('name'), field] }
94
+ end
95
+
96
+ def common(node)
97
+ unless @inputs.key?(node.dig('source', 'path'))
98
+ issue("source_manifest", "A origem deve constar no manifesto.", node, suffix: "/source/path")
99
+ end
100
+ valid_id = if node['kind'] == "domain"
101
+ node['id'].match?(/\A[a-z][a-z0-9_]*\z/)
102
+ else
103
+ node['id'].start_with?("#{node.dig('domain', 'id')}.")
104
+ end
105
+ issue("node_namespace", "ID deve pertencer ao namespace do domínio.", node, suffix: "/id") unless valid_id
106
+ implementation = node['implementation']
107
+ if implementation && implementation['kind'] == 'builtin'
108
+ action = implementation['name'].delete_prefix('crud.')
109
+ expected_kind = action == 'read' ? 'query' : 'command'
110
+ unless Schema::CRUD_ACTIONS.include?(action) && implementation['name'] == "crud.#{action}" && node['kind'] == expected_kind
111
+ issue('builtin_implementation', 'Primitiva interna desconhecida ou incompatível com o nó.', node, suffix: '/implementation')
112
+ end
113
+ end
114
+ if implementation && implementation['kind'] == "ruby"
115
+ unless implementation['digest'] == @inputs[implementation['path']]
116
+ issue("implementation_manifest", "Digest da implementação difere do manifesto.", node, suffix: "/implementation")
117
+ end
118
+ unless implementation['ref'] == "#{node['id']}#call"
119
+ issue("implementation_ref", "A referência deve identificar exatamente este nó e seu call.", node, suffix: "/implementation/ref")
120
+ end
121
+ if implementation.dig('analysis', 'status') == "incomplete"
122
+ issue("analysis_incomplete", "Análise incompleta; dependências dinâmicas continuam fora da garantia estrutural.", node, warning: true)
123
+ end
124
+ end
125
+ issue("unsafe_effect", "Exceção motivada de efeitos: operação fora da garantia de repetição/sombra.", node, warning: true) if node['unsafe_effect']
126
+ Data.references(node) do |reference, pointer|
127
+ target = @nodes[reference.values_at("kind", "id")]
128
+ unless target
129
+ issue("reference_missing", "Declare #{reference['kind']} #{reference['id']} ou corrija a referência.", node, suffix: pointer)
130
+ next
131
+ end
132
+ owner = node['kind'] == "domain" ? node['id'] : node.dig('domain', 'id')
133
+ destination = target['kind'] == "domain" ? target['id'] : target.dig('domain', 'id')
134
+ dependencies = @nodes[["domain", owner]]&.fetch("depends_on")
135
+ if owner != destination && dependencies && !dependencies.any? { |entry| entry['id'] == destination }
136
+ issue("domain_dependency", "Declare depends_on :#{destination} no domínio #{owner}.", node, suffix: pointer)
137
+ end
138
+ end
139
+ end
140
+
141
+ def domain(node)
142
+ issue("domain_unrestricted", "Declare depends_on para verificar a fronteira deste domínio.", node, warning: true) if node['depends_on'].nil?
143
+ end
144
+
145
+ def prepare_contracts
146
+ @contracts = each('contract').to_h { |node| [node['id'], node] }
147
+ @contract_children = @contracts.transform_values do |node|
148
+ node['fields'].filter_map { |field| field.dig('type', 'contract', 'id') }.uniq
149
+ end
150
+ states = {}
151
+ visit = lambda do |id|
152
+ if states[id] == :visiting
153
+ issue('contract_cycle', 'Contratos de saída não podem formar ciclos.', @contracts.fetch(id))
154
+ return
155
+ end
156
+ return if states[id] == :done
157
+ states[id] = :visiting
158
+ @contract_children.fetch(id).each { |child| visit.call(child) }
159
+ states[id] = :done
160
+ end
161
+ @contracts.each_key { |id| visit.call(id) }
162
+ fail_if_errors!
163
+ @query_outputs = []
164
+ collect = lambda do |id|
165
+ return if @query_outputs.include?(id)
166
+ @query_outputs << id
167
+ @contract_children.fetch(id).each { |child| collect.call(child) }
168
+ end
169
+ each('query').each { |query| collect.call(query.dig('output', 'id')) }
170
+ @restricted_contracts = @nodes.values.flat_map do |node|
171
+ [node.dig('input', 'id'), node['kind'] == 'command' && node.dig('output', 'id'), node['kind'] == 'event' && node.dig('payload', 'id')]
172
+ end.select { |id| id.is_a?(String) }.uniq
173
+ @contract_privacy = {}
174
+ end
175
+
176
+ def privacy_rank(privacy)
177
+ return 0 unless privacy['personal']
178
+ privacy['classification'] == 'sensitive' ? 2 : 1
179
+ end
180
+
181
+ def contract_privacy_rank(id)
182
+ @contract_privacy[id] ||= @contracts.fetch(id)['fields'].map do |field|
183
+ child = field.dig('type', 'contract', 'id')
184
+ [privacy_rank(field['privacy']), child ? contract_privacy_rank(child) : 0].max
185
+ end.max || 0
186
+ end
187
+
188
+ def structured_output?(node)
189
+ node['kind'] == 'contract' && @query_outputs.include?(node['id']) && !@restricted_contracts.include?(node['id'])
190
+ end
191
+
192
+ def require_structured_outputs(node, suffix)
193
+ unless @document['requires'].include?('vetra.structured_outputs')
194
+ issue('ir_feature', 'Retornos estruturados exigem vetra.structured_outputs.', node, suffix: suffix)
195
+ end
196
+ end
197
+
198
+ def contract(node)
199
+ fields(node)
200
+ return unless node['collection']
201
+ require_structured_outputs(node, '/collection')
202
+ issue('output_shape', 'Coleção raiz é permitida somente na saída de consultas.', node, suffix: '/collection') unless structured_output?(node)
203
+ end
204
+
205
+ def structured_field(node, field, at)
206
+ require_structured_outputs(node, "#{at}/type")
207
+ unless structured_output?(node) && (field.keys & %w[default set_by guarded generate normalize constraints]).empty?
208
+ issue('output_shape', 'Registros e coleções são campos de saída de consulta, sem regras de gravação ou geração.', node, suffix: "#{at}/type")
209
+ end
210
+ target = resolve(field.dig('type', 'contract'))
211
+ issue('output_shape', 'Um campo estruturado deve referenciar contrato de objeto, não uma coleção raiz.', node, suffix: "#{at}/type/contract") if target['collection']
212
+ if privacy_rank(field['privacy']) < contract_privacy_rank(target['id'])
213
+ issue('output_privacy', 'A classificação do campo composto não pode rebaixar a do contrato referenciado.', node, suffix: "#{at}/privacy")
214
+ end
215
+ end
216
+
217
+ def fields(node)
218
+ names = node['fields'].map { |field| field['name'] }
219
+ issue("duplicate_field", "Nomes de campos devem ser únicos.", node, suffix: "/fields") unless names.uniq == names
220
+ node['fields'].each_with_index do |field, i|
221
+ at = "/fields/#{i}"
222
+ type = field['type']
223
+ structured_field(node, field, at) if %w[record collection].include?(type['name'])
224
+ if %w[decimal money].include?(type['name']) && type['scale'] > type['precision']
225
+ issue("decimal_scale", "scale não pode exceder precision.", node, suffix: "#{at}/type")
226
+ end
227
+ if type['name'] == 'money' && @currency_exponent && type['scale'] < @currency_exponent
228
+ issue('money_scale', "scale de money não pode ser menor que o expoente #{@currency_exponent} de #{@document.dig('app', 'currency')}.", node, suffix: "#{at}/type/scale")
229
+ end
230
+ if type['groups'] && type['groups'].values.flatten.any? { |value| !type['values'].include?(value) }
231
+ issue("enum_group", "Grupo contém valor ausente do enum.", node, suffix: "#{at}/type/groups")
232
+ end
233
+ constraints = field.fetch("constraints", {})
234
+ if constraints['min_length'] && constraints['max_length'] && constraints['min_length'] > constraints['max_length']
235
+ issue("field_constraints", "Tamanho mínimo excede o máximo.", node, suffix: "#{at}/constraints")
236
+ end
237
+ if field['default'] && field.dig('default', 'kind') == "literal" && !literal_valid?(field.dig('default', 'value'), field)
238
+ issue("field_default", "Default incompatível com tipo, moeda, nulabilidade ou precisão do campo #{field['name']}.", node, suffix: "#{at}/default")
239
+ end
240
+ if field.dig('default', 'kind') == 'context' && type['name'] != 'id'
241
+ issue('field_default', 'Defaults de ator/tenant exigem campo id.', node, suffix: "#{at}/default")
242
+ end
243
+ if field.dig('generate', 'kind') == 'slug' && !names.include?(field.dig('generate', 'from'))
244
+ issue("field_generation", "Campo de origem da geração não existe.", node, suffix: "#{at}/generate")
245
+ end
246
+ if field.dig('generate', 'kind') == 'timestamp'
247
+ unless @document['requires'].include?('vetra.timestamps')
248
+ issue('ir_feature', 'Geração de timestamp exige vetra.timestamps.', node, suffix: "#{at}/generate")
249
+ end
250
+ valid = node['kind'] == 'entity' && field['name'] == 'created_at' && type == { 'name' => 'timestamp' } && field['required'] && !field['nullable']
251
+ valid &&= (field.keys & %w[default set_by guarded normalize constraints]).empty?
252
+ issue('timestamp_field', 'Geração de timestamp exige created_at de entidade, obrigatório, não nulo e sem outras regras de atribuição.', node, suffix: at) unless valid
253
+ end
254
+ guards = field.fetch("guarded", [])
255
+ issue("duplicate_guard", "Cada valor protegido tem uma única declaração de donos.", node, suffix: "#{at}/guarded") unless guards.map { |guard| guard['value'] }.uniq.size == guards.size
256
+ guards.each do |guard|
257
+ issue("guard_value", "Valor protegido não pertence ao enum.", node, suffix: "#{at}/guarded") unless type.fetch('values', []).include?(guard['value'])
258
+ if field.dig('default', 'kind') == 'literal' && field.dig('default', 'value') == guard['value']
259
+ issue("guard_default", "Valor protegido não pode ser default; use o comando dono.", node, suffix: "#{at}/default")
260
+ end
261
+ end
262
+ previous = field.fetch('previous_names', [])
263
+ if (previous & names).any? || previous.uniq != previous
264
+ issue("field_rename", "Identidade anterior colide com campo atual.", node, suffix: "#{at}/previous_names")
265
+ end
266
+ end
267
+ aliases = node['fields'].flat_map { |field| field.fetch('previous_names', []) }
268
+ issue("field_rename", "Identidade anterior pertence a mais de um campo.", node) unless aliases.uniq == aliases
269
+ end
270
+
271
+ def literal_valid?(value, field)
272
+ return field['nullable'] if value.nil?
273
+ type = field['type']
274
+ case type['name']
275
+ when 'boolean' then value == true || value == false
276
+ when 'integer' then value.is_a?(Integer)
277
+ when 'enum' then type['values'].include?(value)
278
+ when 'decimal', 'money'
279
+ if type['name'] == 'money'
280
+ exponent = Currency.exponent(value['currency'])
281
+ return false unless exponent && type['scale'] >= exponent
282
+ end
283
+ amount = type['name'] == 'money' ? value['amount'] : value
284
+ return false unless amount.is_a?(String) && amount.match?(/\A-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?\z/)
285
+ whole, fraction = amount.delete_prefix('-').split('.', 2)
286
+ whole.sub(/\A0+/, '').size <= type['precision'] - type['scale'] && (fraction || '').size <= type['scale']
287
+ when 'date' then value.is_a?(String) && value.match?(/\A\d{4}-\d{2}-\d{2}\z/) && !!Date.iso8601(value)
288
+ when 'timestamp' then value.is_a?(String) && !!Time.iso8601(value)
289
+ when 'json' then true
290
+ else value.is_a?(String)
291
+ end
292
+ rescue ArgumentError, TypeError
293
+ false
294
+ end
295
+
296
+ def entity(node)
297
+ fields(node)
298
+ map = field_map(node)
299
+ if @document['requires'].include?('vetra.timestamps') && map.dig('created_at', 'generate') != { 'kind' => 'timestamp', 'on' => 'create' }
300
+ issue('timestamp_field', 'Entidade com vetra.timestamps exige created_at gerado na criação.', node, suffix: '/fields')
301
+ end
302
+ check_names(node, node.dig('storage', 'primary_key'), map, '/storage/primary_key')
303
+ tenant = node['tenant']
304
+ application_tenant = @document.dig('app', 'tenant')
305
+ if tenant
306
+ if tenant != application_tenant
307
+ issue("tenant_mismatch", "Tenant da entidade deve coincidir com o tenant da aplicação.", node, suffix: '/tenant')
308
+ end
309
+ tenant_field = map[tenant['field']]
310
+ if !tenant_field || tenant_field['nullable'] || tenant_field['type'] != { 'name' => 'id', 'entity' => tenant['entity'] }
311
+ issue("tenant_field", "Declare o identificador obrigatório do tenant com a referência correta.", node, suffix: '/tenant')
312
+ end
313
+ elsif application_tenant && node['id'] != application_tenant.dig('entity', 'id')
314
+ issue("tenant_missing", "Entidade global além da entidade de tenant não é suportada neste protótipo.", node, suffix: '/tenant')
315
+ end
316
+ if application_tenant && node['id'] == application_tenant.dig('entity', 'id') && tenant
317
+ issue("tenant_root", "A entidade que identifica tenants deve ser global.", node, suffix: '/tenant')
318
+ end
319
+ relation_names = node['relations'].map { |relation| relation['name'] }
320
+ issue("duplicate_relation", "Relações devem ter nomes únicos.", node) unless relation_names.uniq == relation_names
321
+ node['relations'].each_with_index do |relation, index|
322
+ target = resolve(relation['target'])
323
+ check_names(node, relation['local_fields'], map, "/relations/#{index}/local_fields")
324
+ check_names(node, relation['target_fields'], field_map(target), "/relations/#{index}/target_fields")
325
+ if relation['local_fields'].size != relation['target_fields'].size
326
+ issue("relation_key", "As duas partes da chave devem ter o mesmo tamanho.", node)
327
+ end
328
+ if relation['same_tenant']
329
+ unless tenant && target['tenant'] == tenant && relation['local_fields'].first == tenant['field'] && relation['target_fields'].first == tenant['field']
330
+ issue("same_tenant", "same_tenant exige tenants iguais e FK composta começando pelo tenant.", node, suffix: "/relations/#{index}")
331
+ end
332
+ elsif tenant && target['tenant']
333
+ issue("same_tenant", "Relação entre entidades multiempresa deve declarar same_tenant.", node, suffix: "/relations/#{index}")
334
+ end
335
+ if relation['kind'] == 'many_to_many' && !relation['through']
336
+ issue("relation_join", "Relação muitos-para-muitos exige entidade de junção.", node, suffix: "/relations/#{index}")
337
+ end
338
+ if relation['kind'] == 'many_to_many' && relation['through']
339
+ junction = resolve(relation['through'])
340
+ owner_links = junction['relations'].select { |other| other['kind'] == 'belongs_to' && other.dig('target', 'id') == node['id'] }
341
+ target_links = junction['relations'].select { |other| other['kind'] == 'belongs_to' && other.dig('target', 'id') == relation.dig('target', 'id') }
342
+ keys = owner_links.size == 1 && target_links.size == 1 && owner_links.first != target_links.first &&
343
+ owner_links.first['target_fields'] == relation['local_fields'] && target_links.first['target_fields'] == relation['target_fields']
344
+ issue("relation_through", "A junção exige um belongs_to para cada lado, com as chaves da relação; auto-relações não são suportadas.", node, suffix: "/relations/#{index}/through") unless keys
345
+ elsif relation['through']
346
+ issue("relation_through", "through só é válido em relação muitos-para-muitos.", node, suffix: "/relations/#{index}/through")
347
+ end
348
+ if relation['include_on_create']
349
+ included = relation['kind'] == 'many_to_many' && relation['include_on_create'].all? do |name|
350
+ node['relations'].any? { |other| other['name'] == name && other['kind'] == 'belongs_to' && other.dig('target', 'id') == relation.dig('target', 'id') }
351
+ end
352
+ issue("relation_include", "include_on_create deve citar belongs_to da entidade que apontem para o alvo da relação muitos-para-muitos.", node, suffix: "/relations/#{index}/include_on_create") unless included
353
+ end
354
+ if relation['kind'] == 'has_many'
355
+ inverse = target['relations'].any? do |other|
356
+ other['kind'] == 'belongs_to' && other.dig('target', 'id') == node['id'] && other['local_fields'] == relation['target_fields'] && other['target_fields'] == relation['local_fields']
357
+ end
358
+ issue("relation_inverse", "has_many exige o belongs_to inverso, com a mesma chave, na entidade de destino.", node, suffix: "/relations/#{index}") unless inverse
359
+ end
360
+ end
361
+ names = node['indexes'].map { |index| index['name'] }
362
+ issue("duplicate_index", "Índices devem ter nomes únicos.", node) unless names.uniq == names
363
+ node['indexes'].each_with_index do |index, i|
364
+ check_names(node, index['fields'], map, "/indexes/#{i}/fields")
365
+ issue("tenant_index", "Índice deve começar pelo campo de tenant.", node, suffix: "/indexes/#{i}") if tenant && index['fields'].first != tenant['field']
366
+ end
367
+ unless each('policy').any? { |policy| policy.dig('entity', 'id') == node['id'] }
368
+ issue("entity_no_policy", "Entidade sem política de acesso; permanece sem concessão implícita.", node, warning: true)
369
+ end
370
+ end
371
+
372
+ def check_names(node, names, map, suffix)
373
+ missing = names - map.keys
374
+ issue("field_missing", "Campos não declarados: #{missing.join(', ')}.", node, suffix: suffix) unless missing.empty?
375
+ end
376
+
377
+ def policy(node)
378
+ target = resolve(node['entity'])
379
+ check_names(node, node['fields'], field_map(target), '/fields') if node['fields'].is_a?(Array)
380
+ dependencies = [node['entity']]
381
+ predicate(node['predicate'], target, dependencies, node)
382
+ unless (dependencies.uniq - node['reads']).empty?
383
+ issue("policy_reads", "reads omite entidades usadas pelo predicado; inclua todas as dependências.", node, suffix: '/reads')
384
+ end
385
+ end
386
+
387
+ def predicate(tree, target, dependencies, owner)
388
+ case tree['op']
389
+ when 'and', 'or' then tree['terms'].each { |term| predicate(term, target, dependencies, owner) }
390
+ when 'not' then predicate(tree['term'], target, dependencies, owner)
391
+ when 'exists'
392
+ destination = follow_relations(tree['relation'], target, dependencies, owner)
393
+ predicate(tree['where'], destination, dependencies, owner) if destination
394
+ when 'eq', 'in'
395
+ %w[left right].each do |side|
396
+ operand = tree[side]
397
+ case operand['kind']
398
+ when 'field'
399
+ parts = operand['path']
400
+ destination = follow_relations(parts[0...-1], target, dependencies, owner)
401
+ if destination && !field_map(destination).key?(parts.last)
402
+ issue("predicate_field", "Campo do predicado não existe: #{parts.join('.')}.", owner)
403
+ end
404
+ when 'actor'
405
+ unless operand['path'] == ['id']
406
+ issue("actor_binding", "Este protótipo só conhece actor.id; atributos materializados exigem mapeamento de dependências ainda não implementado.", owner)
407
+ end
408
+ when 'input'
409
+ issue("predicate_input", "Predicados de política dependentes da entrada ainda não são suportados.", owner)
410
+ end
411
+ end
412
+ end
413
+ end
414
+
415
+ def follow_relations(path, target, dependencies, owner)
416
+ path.each do |name|
417
+ relation = target['relations'].find { |entry| entry['name'] == name }
418
+ unless relation
419
+ issue("predicate_relation", "Relação do predicado não existe: #{name}.", owner)
420
+ return nil
421
+ end
422
+ dependencies << relation['through'] if relation['through']
423
+ dependencies << relation['target']
424
+ target = resolve(relation['target'])
425
+ end
426
+ target
427
+ end
428
+
429
+ def operation(node)
430
+ node['uses'].each do |reference|
431
+ capability = resolve(reference)
432
+ config = capability['config']
433
+ forbidden = case node['kind']
434
+ when 'command' then config['type'] == 'external'
435
+ when 'query' then config['type'] == 'external' || config['access'] == 'read_write'
436
+ when 'handler' then config['access'] == 'read_write'
437
+ end
438
+ issue("capability_boundary", "#{node['kind']} não pode usar #{reference['id']} com esse tipo de efeito/acesso.", node, suffix: '/uses') if forbidden
439
+ issue("sql_boundary", "SQL explícito fica fora da garantia estrutural; RLS continua obrigatório.", node, warning: true) if config['type'] == 'sql'
440
+ end
441
+ if node['authorize']
442
+ input = field_map(resolve(node['input']))
443
+ record = node.dig('authorize', 'record')
444
+ if record && (!input[record] || input[record].dig('type', 'name') != 'id')
445
+ issue("authorize_record", "record deve apontar a um campo id do contrato de entrada.", node, suffix: '/authorize/record')
446
+ end
447
+ if record && input[record]&.dig('type', 'entity') && input[record].dig('type', 'entity') != node.dig('authorize', 'entity')
448
+ issue('authorize_record', 'O id autorizado deve referenciar a entidade declarada.', node, suffix: '/authorize/record')
449
+ end
450
+ end
451
+ crud_contract(node) if node.dig('implementation', 'kind') == 'builtin'
452
+ live(node) if node['kind'] == 'query' && node['live']
453
+ node.fetch('emits', []).each do |emission|
454
+ target = resolve(emission['from'])
455
+ payload = resolve(resolve(emission['event'])['payload'])
456
+ source_fields = field_map(target)
457
+ check_names(node, emission['fields'], source_fields, '/emits')
458
+ if emission['fields'].sort != payload['fields'].map { |field| field['name'] }.sort
459
+ issue("event_payload", "Projeção do evento difere do contrato de payload.", node, suffix: '/emits')
460
+ end
461
+ payload['fields'].each do |field|
462
+ actual = source_fields[field['name']]
463
+ next unless actual
464
+ unless actual['type'] == field['type'] && (!actual['nullable'] || field['nullable']) && actual['privacy'] == field['privacy']
465
+ issue("event_payload", "Tipo, nulabilidade ou privacidade divergente no payload #{field['name']}.", node, suffix: '/emits')
466
+ end
467
+ end
468
+ end
469
+ end
470
+
471
+ def live(node)
472
+ unless @document['requires'].include?('vetra.live')
473
+ issue('ir_feature', 'Consulta live exige vetra.live em requires.', node, suffix: '/live')
474
+ end
475
+ if LiveDependencies.outside_boundary?(node, nodes: @nodes)
476
+ issue('live_boundary', 'Live aceita repositórios e capacidades determinísticas, sem unsafe_effect.', node, suffix: '/live')
477
+ end
478
+ declared = node.dig('live', 'depends_on')
479
+ missing = LiveDependencies.required(node, nodes: @nodes, extra: declared) - declared
480
+ unless missing.empty?
481
+ issue('live_dependencies', "depends_on de live omite entidades conhecidas: #{missing.map { |reference| reference['id'] }.join(', ')}.", node, suffix: '/live/depends_on')
482
+ end
483
+ end
484
+
485
+ def crud_contract(node)
486
+ action = node.dig('implementation', 'name').delete_prefix('crud.')
487
+ target = resolve(node.dig('authorize', 'entity'))
488
+ unless node.dig('authorize', 'action') == action
489
+ issue('crud_authorization', 'Ação autorizada deve coincidir com a primitiva CRUD.', node)
490
+ end
491
+ repositories = node['uses'].map { |reference| resolve(reference)['config'] }
492
+ unless repositories.size == 1 && repositories.first['type'] == 'repository' && repositories.first['entity'] == node.dig('authorize', 'entity')
493
+ issue('crud_repository', 'CRUD deve usar o repositório da entidade autorizada.', node)
494
+ end
495
+ return unless %w[create update].include?(action)
496
+ source = field_map(target)
497
+ field_map(resolve(node['input'])).each do |name, input|
498
+ next if action == 'update' && name == 'id'
499
+ field = source[name]
500
+ if !field || name == 'id' || name == target.dig('tenant', 'field') || field['set_by'] || field.dig('generate', 'kind') == 'timestamp'
501
+ issue('crud_contract', "Campo #{name} não pode ser gravável pelo CRUD genérico.", node)
502
+ next
503
+ end
504
+ guarded = field.fetch('guarded', []).map { |guard| guard['value'] }
505
+ source_type = field['type'].reject { |key, _| %w[values groups].include?(key) }
506
+ input_type = input['type'].reject { |key, _| %w[values groups].include?(key) }
507
+ if source_type != input_type || (input['nullable'] && !field['nullable']) || input['privacy'] != field['privacy']
508
+ issue('crud_contract', 'Contrato genérico alterou tipo, nulabilidade ou privacidade do campo.', node)
509
+ end
510
+ if field.dig('type', 'name') == 'enum' && (input.dig('type', 'values').to_a - field.dig('type', 'values')).any?
511
+ issue('crud_contract', 'Contrato genérico introduziu valor ausente do enum.', node)
512
+ end
513
+ if (guarded & input.dig('type', 'values').to_a).any?
514
+ issue('crud_contract', 'Contrato genérico reintroduz valor protegido.', node)
515
+ end
516
+ if action == 'update' && (input['required'] || input.key?('default'))
517
+ issue('crud_contract', 'PATCH exige campos opcionais sem defaults de criação.', node)
518
+ end
519
+ end
520
+ end
521
+
522
+ def event(node)
523
+ if node['visibility'] == 'internal' && node['consumer_required'] && !each('handler').any? { |handler| handler['on'].include?(Data.reference('event', node['id'])) }
524
+ issue("event_consumer", "Evento interno exige consumidor; declare um handler on #{node['id']}.", node)
525
+ end
526
+ end
527
+
528
+ def route(node)
529
+ write = node.dig('target', 'kind') == 'command'
530
+ if node['surface'] == 'job' && !write
531
+ issue('route_target', 'Job deve chamar um comando; consultas são expostas por HTTP de leitura, CLI ou live.', node, suffix: '/target')
532
+ end
533
+ if %w[cli job].include?(node['surface']) && node.dig('config', 'name').strip.empty?
534
+ issue('route_format', 'CLI e job exigem um nome não vazio.', node, suffix: '/config/name')
535
+ end
536
+ if %w[http webhook].include?(node['surface'])
537
+ method = node.dig('config', 'method')
538
+ if (write && %w[GET HEAD OPTIONS].include?(method)) || (!write && !%w[GET HEAD].include?(method))
539
+ issue("route_target", "Método HTTP incompatível com leitura/escrita do destino.", node)
540
+ end
541
+ params = node.dig('config', 'path').scan(/:([a-z][a-z0-9_]*)/).flatten
542
+ target = resolve(node['target'])
543
+ check_names(node, params, field_map(resolve(target['input'])), '/config/path')
544
+ end
545
+ if node['surface'] == 'webhook' && (node['idempotency'] != 'required' || !write)
546
+ issue("webhook_idempotency", "Webhook deve chamar comando com idempotência obrigatória.", node)
547
+ end
548
+ end
549
+
550
+ def route_collisions
551
+ seen = []
552
+ each('route').each do |node|
553
+ config = node['config']
554
+ key = if %w[http webhook].include?(node['surface'])
555
+ ['http', config['method'] == 'HEAD' ? 'GET' : config['method'], config['path'].gsub(/:[a-z][a-z0-9_]*/, ':param')]
556
+ else
557
+ [node['surface'], config['name']]
558
+ end
559
+ conflict = seen.find do |previous, _id|
560
+ if key.first == 'http' && previous.first == 'http' && key[1] == previous[1]
561
+ left, right = key[2].split('/'), previous[2].split('/')
562
+ left.size == right.size && left.zip(right).all? { |a, b| a == b || a == ':param' || b == ':param' }
563
+ else
564
+ key == previous
565
+ end
566
+ end
567
+ issue("route_collision", "Rota ambígua com #{conflict.last}.", node) if conflict
568
+ seen << [key, node['id']]
569
+ end
570
+ end
571
+
572
+ def historical_ids
573
+ aliases = {}
574
+ @nodes.each_value do |node|
575
+ node.fetch('previous_ids', []).each do |previous|
576
+ key = [node['kind'], previous]
577
+ issue("identity_rename", "Identidade anterior colide com nó atual ou outro rename.", node) if @nodes.key?(key) || aliases.key?(key)
578
+ aliases[key] = node['id']
579
+ end
580
+ end
581
+ end
582
+
583
+ def domain_cycles
584
+ visited, visiting = {}, {}
585
+ walk = lambda do |node|
586
+ return if visited[node['id']]
587
+ if visiting[node['id']]
588
+ issue("domain_cycle", "Dependências entre domínios formam ciclo.", node)
589
+ return
590
+ end
591
+ visiting[node['id']] = true
592
+ node.fetch('depends_on', nil)&.each { |reference| walk.call(resolve(reference)) }
593
+ visiting.delete(node['id'])
594
+ visited[node['id']] = true
595
+ end
596
+ each('domain').each { |node| walk.call(node) }
597
+ end
598
+ end
599
+ end
600
+ end
@@ -0,0 +1,3 @@
1
+ module Vetra
2
+ VERSION = "0.1.0-alpha.4"
3
+ end
data/lib/vetra.rb ADDED
@@ -0,0 +1,29 @@
1
+ require_relative "vetra/version"
2
+ require_relative "vetra/graph/diagnostic"
3
+ require_relative "vetra/graph/data"
4
+ require_relative "vetra/graph/currency"
5
+ require_relative "vetra/graph/canonical"
6
+ require_relative "vetra/graph/manifest"
7
+ require_relative "vetra/graph/schema"
8
+ require_relative "vetra/graph/live_dependencies"
9
+ require_relative "vetra/graph/validator"
10
+ require_relative "vetra/graph/registry"
11
+ require_relative "vetra/graph/dsl"
12
+ require_relative "vetra/graph/declaration_loader"
13
+ require_relative "vetra/graph/analysis"
14
+ require_relative "vetra/graph/capabilities"
15
+ require_relative "vetra/graph/normalizer"
16
+ require_relative "vetra/graph/linked_graph"
17
+ require_relative "vetra/graph/linker"
18
+ require_relative "vetra/graph/compiler"
19
+ require_relative "vetra/graph/diff_source"
20
+ require_relative "vetra/graph/diff"
21
+
22
+ module Vetra
23
+ def self.app(name, &block)
24
+ context = Thread.current[:vetra_graph_context]
25
+ raise Graph::Error.single("dsl_context", "Carregue Vetra.app pelo compilador.") unless context
26
+
27
+ context.app(name, &block)
28
+ end
29
+ end