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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +196 -0
- data/bin/vetra +6 -0
- data/docs/ir/v0.1.schema.json +941 -0
- data/docs/ir/v0.2.schema.json +951 -0
- data/docs/ir/v0.3.schema.json +961 -0
- data/lib/vetra/cli.rb +64 -0
- data/lib/vetra/graph/analysis.rb +94 -0
- data/lib/vetra/graph/canonical.rb +52 -0
- data/lib/vetra/graph/capabilities.rb +22 -0
- data/lib/vetra/graph/compiler.rb +119 -0
- data/lib/vetra/graph/currency.rb +28 -0
- data/lib/vetra/graph/data.rb +48 -0
- data/lib/vetra/graph/declaration_loader.rb +62 -0
- data/lib/vetra/graph/diagnostic.rb +23 -0
- data/lib/vetra/graph/diff.rb +412 -0
- data/lib/vetra/graph/diff_source.rb +51 -0
- data/lib/vetra/graph/dsl.rb +444 -0
- data/lib/vetra/graph/linked_graph.rb +26 -0
- data/lib/vetra/graph/linker.rb +95 -0
- data/lib/vetra/graph/live_dependencies.rb +48 -0
- data/lib/vetra/graph/manifest.rb +78 -0
- data/lib/vetra/graph/normalizer.rb +689 -0
- data/lib/vetra/graph/registry.rb +50 -0
- data/lib/vetra/graph/schema.rb +78 -0
- data/lib/vetra/graph/validator.rb +600 -0
- data/lib/vetra/version.rb +3 -0
- data/lib/vetra.rb +29 -0
- metadata +141 -0
|
@@ -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
|
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
|