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,412 @@
|
|
|
1
|
+
module Vetra
|
|
2
|
+
module Graph
|
|
3
|
+
class Diff
|
|
4
|
+
NODE_METADATA = %w[source annotations generated_from previous_ids].freeze
|
|
5
|
+
REVIEW_CATEGORIES = %w[permissions isolation privacy transitions capabilities storage implementation events dependencies surface].freeze
|
|
6
|
+
CONTRACT_KINDS = %w[entity contract command query event route].freeze
|
|
7
|
+
TEXT_SETS = %w[actions previous_ids previous_names requires constants dynamic hosts].freeze
|
|
8
|
+
LABELS = { 'breaking' => 'QUEBRA POTENCIAL', 'review' => 'REVISÃO', 'additive' => 'ADIÇÃO', 'metadata' => 'METADADOS' }.freeze
|
|
9
|
+
CATEGORY_LABELS = {
|
|
10
|
+
'privacy' => 'PRIVACIDADE', 'transitions' => 'TRANSIÇÕES', 'isolation' => 'ISOLAMENTO',
|
|
11
|
+
'capabilities' => 'CAPACIDADES', 'surface' => 'SUPERFÍCIE'
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
class Report
|
|
15
|
+
attr_reader :data
|
|
16
|
+
|
|
17
|
+
def initialize(data)
|
|
18
|
+
@data = Data.freeze(data)
|
|
19
|
+
freeze
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def breaking?
|
|
23
|
+
data.dig('summary', 'breaking').positive?
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def changed?
|
|
27
|
+
data.dig('summary', 'total').positive?
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def to_h
|
|
31
|
+
data
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def to_json
|
|
35
|
+
"#{Canonical.dump(data)}\n"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def to_text
|
|
39
|
+
counts = data.fetch('summary')
|
|
40
|
+
lines = ["graph:diff: #{data.dig('before', 'app')} → #{data.dig('after', 'app')}"]
|
|
41
|
+
if counts['total'].zero?
|
|
42
|
+
lines << 'Sem alterações no modelo ou nos metadados comparados.'
|
|
43
|
+
else
|
|
44
|
+
architectural = counts['total'] - counts['metadata']
|
|
45
|
+
lines << "#{architectural} alterações arquiteturais; #{counts['breaking']} quebras potenciais; #{counts['requires_review']} exigem revisão; #{counts['metadata']} metadados."
|
|
46
|
+
%w[architecture metadata].each do |group|
|
|
47
|
+
changes = data['changes'].select { |change| (change['impact'] == 'metadata') == (group == 'metadata') }
|
|
48
|
+
next if changes.empty?
|
|
49
|
+
lines << '' << (group == 'metadata' ? 'Metadados de origem/build:' : 'Mudanças no modelo:')
|
|
50
|
+
changes.each do |change|
|
|
51
|
+
labels = [LABELS.fetch(change['impact'])]
|
|
52
|
+
labels.concat(change['categories'].filter_map { |category| CATEGORY_LABELS[category] })
|
|
53
|
+
labels << 'REVISÃO NECESSÁRIA' if change['impact'] == 'additive' && change['requires_review']
|
|
54
|
+
labels << 'IMPACTO DE PERMISSÃO INDETERMINADO' if change['permission_impact']
|
|
55
|
+
lines << "[#{labels.join(' | ')}] #{change['kind']} #{change['id']} #{change['path']}: #{change['description']}"
|
|
56
|
+
# Full snapshots remain available in JSON. Security changes always
|
|
57
|
+
# show both sides; ordinary node additions need only the summary.
|
|
58
|
+
security = change['permission_impact'] || (change['categories'] & CATEGORY_LABELS.keys).any?
|
|
59
|
+
if change['impact'] != 'metadata' && (security || change['change'] == 'modified')
|
|
60
|
+
lines << " antes: #{change['before_present'] ? Canonical.dump(change['before']) : '(ausente)'}"
|
|
61
|
+
lines << " depois: #{change['after_present'] ? Canonical.dump(change['after']) : '(ausente)'}"
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
if data['changes'].any? { |change| change['impact'] == 'metadata' && (change['path'].start_with?('/inputs/', '/implementation/') || change['path'] == '/producer') }
|
|
67
|
+
lines << '' << 'Fontes/build mudaram; a comparação não avalia equivalência do código Ruby.'
|
|
68
|
+
end
|
|
69
|
+
"#{lines.join("\n")}\n"
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def compare(before, after)
|
|
74
|
+
warnings = validate(before, 'before') + validate(after, 'after')
|
|
75
|
+
@changes = []
|
|
76
|
+
@before, @after = normalize(before), normalize(after)
|
|
77
|
+
@before_usage, @after_usage = contract_usage(@before), contract_usage(@after)
|
|
78
|
+
old_nodes = @before['nodes'].to_h { |node| [node.values_at('kind', 'id'), node] }
|
|
79
|
+
new_nodes = @after['nodes'].to_h { |node| [node.values_at('kind', 'id'), node] }
|
|
80
|
+
pairs, removed, added = match(old_nodes, new_nodes, 'previous_ids', node_ids: true)
|
|
81
|
+
@renames = pairs.to_h { |old_node, new_node| [old_node.values_at('kind', 'id'), new_node['id']] }
|
|
82
|
+
|
|
83
|
+
compare_application
|
|
84
|
+
pairs.each { |old_node, new_node| compare_node(old_node, new_node) }
|
|
85
|
+
removed.each { |node| node_change(node, 'removed') }
|
|
86
|
+
added.each { |node| node_change(node, 'added') }
|
|
87
|
+
@changes.sort_by! { |change| [change['impact'] == 'metadata' ? 1 : 0, change['kind'], change['id'], change['path'], change['change']] }
|
|
88
|
+
counts = %w[breaking review additive metadata].to_h { |impact| [impact, @changes.count { |change| change['impact'] == impact }] }
|
|
89
|
+
counts.merge!('total' => @changes.size, 'requires_review' => @changes.count { |change| change['requires_review'] })
|
|
90
|
+
Report.new(
|
|
91
|
+
'format_version' => '1',
|
|
92
|
+
'before' => header(@before), 'after' => header(@after),
|
|
93
|
+
'summary' => counts, 'changes' => @changes, 'warnings' => warnings
|
|
94
|
+
)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
def validate(document, side)
|
|
100
|
+
Validator.new.validate!(document).map do |warning|
|
|
101
|
+
{ 'side' => side, 'code' => warning.code, 'message' => warning.message, 'pointer' => warning.pointer, 'source' => Data.copy(warning.source) }
|
|
102
|
+
end
|
|
103
|
+
rescue Error => error
|
|
104
|
+
label = side == 'before' ? 'Grafo anterior' : 'Grafo posterior'
|
|
105
|
+
raise Error.new(error.diagnostics.map do |diagnostic|
|
|
106
|
+
Diagnostic.new(**diagnostic.to_h.merge(message: "#{label}: #{diagnostic.message}"))
|
|
107
|
+
end)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def header(document)
|
|
111
|
+
{ 'app' => document.dig('app', 'name'), 'ir_version' => document['ir_version'], 'digest' => document['digest'] }
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def normalize(value, property = nil, parent = nil)
|
|
115
|
+
case value
|
|
116
|
+
when Hash
|
|
117
|
+
return Data.copy(value) if property == 'annotations' || value['kind'] == 'literal'
|
|
118
|
+
value.to_h { |key, child| [key.dup, normalize(child, key, value)] }
|
|
119
|
+
when Array
|
|
120
|
+
children = value.map { |child| normalize(child) }
|
|
121
|
+
if children.all? { |child| child.is_a?(Hash) && child.keys.sort == %w[id kind] }
|
|
122
|
+
children.sort_by { |child| child.values_at('kind', 'id') }
|
|
123
|
+
elsif %w[fields relations indexes].include?(property) && children.all? { |child| child.is_a?(Hash) && child.key?('name') }
|
|
124
|
+
children.sort_by { |child| child['name'] }
|
|
125
|
+
elsif property == 'fields' && !parent&.key?('unique') && children.all? { |child| child.is_a?(String) }
|
|
126
|
+
children.sort
|
|
127
|
+
elsif TEXT_SETS.include?(property)
|
|
128
|
+
children.sort
|
|
129
|
+
elsif property == 'emits'
|
|
130
|
+
children.sort_by { |child| Canonical.dump(child) }
|
|
131
|
+
elsif property == 'guarded'
|
|
132
|
+
children.sort_by { |child| child['value'] }
|
|
133
|
+
else
|
|
134
|
+
children
|
|
135
|
+
end
|
|
136
|
+
else Data.copy(value)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def rewrite_references(value)
|
|
141
|
+
case value
|
|
142
|
+
when Hash
|
|
143
|
+
return Data.copy(value) if value['kind'] == 'literal'
|
|
144
|
+
if value.keys.sort == %w[id kind]
|
|
145
|
+
Data.reference(value['kind'], @renames.fetch(value.values_at('kind', 'id'), value['id']))
|
|
146
|
+
else
|
|
147
|
+
value.to_h { |key, child| [key, key == 'annotations' ? Data.copy(child) : rewrite_references(child)] }
|
|
148
|
+
end
|
|
149
|
+
when Array then value.map { |child| rewrite_references(child) }
|
|
150
|
+
else Data.copy(value)
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def match(old_items, new_items, aliases, node_ids: false, owner: nil)
|
|
155
|
+
remaining = old_items.dup
|
|
156
|
+
pairs, added = [], []
|
|
157
|
+
new_items.keys.sort.each do |key|
|
|
158
|
+
current = new_items.fetch(key)
|
|
159
|
+
if remaining.key?(key)
|
|
160
|
+
pairs << [remaining.delete(key), current]
|
|
161
|
+
next
|
|
162
|
+
end
|
|
163
|
+
candidates = current.fetch(aliases, []).filter_map do |previous|
|
|
164
|
+
previous_key = node_ids ? [current['kind'], previous] : previous
|
|
165
|
+
previous_key if remaining.key?(previous_key)
|
|
166
|
+
end
|
|
167
|
+
if candidates.size > 1
|
|
168
|
+
raise Error.single('diff_rename_ambiguous', 'Renomeação corresponde a mais de uma identidade anterior; uma fusão não é um rename.', source: (owner || current)['source'])
|
|
169
|
+
elsif candidates.size == 1
|
|
170
|
+
pairs << [remaining.delete(candidates.first), current]
|
|
171
|
+
else
|
|
172
|
+
added << current
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
[pairs, remaining.values, added]
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def contract_usage(document)
|
|
179
|
+
uses = Hash.new { |hash, key| hash[key] = [] }
|
|
180
|
+
contracts = document['nodes'].select { |node| node['kind'] == 'contract' }.to_h { |node| [node['id'], node] }
|
|
181
|
+
mark = lambda do |id, context|
|
|
182
|
+
return unless id
|
|
183
|
+
return if uses[id].include?(context)
|
|
184
|
+
uses[id] << context
|
|
185
|
+
contracts.fetch(id)['fields'].each do |field|
|
|
186
|
+
child = field.dig('type', 'contract', 'id')
|
|
187
|
+
mark.call(child, context) if child
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
document['nodes'].each do |node|
|
|
191
|
+
mark.call(node.dig('input', 'id'), 'input') if node['input']
|
|
192
|
+
mark.call(node.dig('output', 'id'), 'output') if node['output']
|
|
193
|
+
mark.call(node.dig('payload', 'id'), 'event') if node['kind'] == 'event'
|
|
194
|
+
end
|
|
195
|
+
uses
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def compare_application
|
|
199
|
+
owner = { 'kind' => 'application', 'id' => @after.dig('app', 'name') }
|
|
200
|
+
old_app = rewrite_references(@before['app'])
|
|
201
|
+
compare_properties(old_app, @after['app'], owner, '/app')
|
|
202
|
+
property(@before, @after, 'ir_version', owner, '/ir_version', ['contract'], 'review')
|
|
203
|
+
property(@before, @after, 'requires', owner, '/requires', ['dependencies'], 'review')
|
|
204
|
+
%w[producer annotations].each { |key| property(@before, @after, key, owner, "/#{key}", ['build'], 'metadata') }
|
|
205
|
+
old_inputs = @before['inputs'].to_h { |input| [input['path'], input] }
|
|
206
|
+
new_inputs = @after['inputs'].to_h { |input| [input['path'], input] }
|
|
207
|
+
(old_inputs.keys | new_inputs.keys).sort.each do |path|
|
|
208
|
+
property(old_inputs, new_inputs, path, owner, "/inputs/#{escape(path)}", ['build'], 'metadata')
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def compare_node(before, after)
|
|
213
|
+
owner = after.slice('kind', 'id', 'source')
|
|
214
|
+
if before['id'] != after['id']
|
|
215
|
+
emit(owner, '/id', 'renamed', before['id'], after['id'], node_categories(after) + ['identity'], CONTRACT_KINDS.include?(after['kind']) ? 'breaking' : 'review', 'Identidade renomeada explicitamente')
|
|
216
|
+
end
|
|
217
|
+
old = normalize(rewrite_references(before))
|
|
218
|
+
# This string is a binding identity rather than a typed graph reference.
|
|
219
|
+
if before['id'] != after['id'] && old.dig('implementation', 'kind') == 'ruby'
|
|
220
|
+
old['implementation']['ref'] = "#{after['id']}#call"
|
|
221
|
+
end
|
|
222
|
+
NODE_METADATA.each { |key| property(old, after, key, owner, "/#{key}", ['origin'], 'metadata') }
|
|
223
|
+
%w[path digest].each do |key|
|
|
224
|
+
property(old.fetch('implementation', {}), after.fetch('implementation', {}), key, owner, "/implementation/#{key}", ['build'], 'metadata')
|
|
225
|
+
end
|
|
226
|
+
old_content = node_content(old)
|
|
227
|
+
new_content = node_content(after)
|
|
228
|
+
contexts = if after['kind'] == 'contract'
|
|
229
|
+
(@before_usage[before['id']] + @after_usage[after['id']]).uniq
|
|
230
|
+
else
|
|
231
|
+
['entity']
|
|
232
|
+
end
|
|
233
|
+
contexts = ['unknown'] if contexts.empty?
|
|
234
|
+
compare_properties(old_content, new_content, owner, '', contexts)
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def node_content(node)
|
|
238
|
+
node.reject { |key, _| (NODE_METADATA + %w[kind id]).include?(key) }.to_h do |key, value|
|
|
239
|
+
if key == 'implementation'
|
|
240
|
+
[key, value.reject { |property, _| %w[path digest].include?(property) }]
|
|
241
|
+
else
|
|
242
|
+
[key, value]
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def compare_properties(before, after, owner, prefix, contexts = [])
|
|
248
|
+
(before.keys | after.keys).sort.each do |key|
|
|
249
|
+
next if before.key?(key) == after.key?(key) && before[key] == after[key]
|
|
250
|
+
path = "#{prefix}/#{escape(key)}"
|
|
251
|
+
if key == 'fields' && %w[entity contract].include?(owner['kind']) && prefix.empty?
|
|
252
|
+
compare_fields(before.fetch(key, []), after.fetch(key, []), owner, contexts)
|
|
253
|
+
elsif %w[relations indexes].include?(key) && owner['kind'] == 'entity' && prefix.empty?
|
|
254
|
+
compare_named(before.fetch(key, []), after.fetch(key, []), owner, path)
|
|
255
|
+
else
|
|
256
|
+
categories = categories(owner, key, prefix)
|
|
257
|
+
impact = impact(owner, key, before[key], after[key], contexts, prefix)
|
|
258
|
+
property(before, after, key, owner, path, categories, impact)
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def compare_fields(before, after, owner, contexts)
|
|
264
|
+
old_items = before.to_h { |field| [field['name'], field] }
|
|
265
|
+
new_items = after.to_h { |field| [field['name'], field] }
|
|
266
|
+
pairs, removed, added = match(old_items, new_items, 'previous_names', owner: owner)
|
|
267
|
+
removed.each do |field|
|
|
268
|
+
emit(owner, "/fields/#{escape(field['name'])}", 'removed', field, nil, field_categories(owner, field), 'breaking', 'Campo removido', after_present: false)
|
|
269
|
+
end
|
|
270
|
+
added.each do |field|
|
|
271
|
+
breaking = contexts.any? { |context| %w[input event].include?(context) && field['required'] }
|
|
272
|
+
breaking ||= contexts.include?('entity') && !field['nullable'] && !field.key?('default') && !field.key?('generate')
|
|
273
|
+
impact = breaking ? 'breaking' : (contexts.include?('unknown') ? 'review' : 'additive')
|
|
274
|
+
emit(owner, "/fields/#{escape(field['name'])}", 'added', nil, field, field_categories(owner, field), impact, 'Campo adicionado', before_present: false)
|
|
275
|
+
end
|
|
276
|
+
pairs.each do |old, current|
|
|
277
|
+
path = "/fields/#{escape(current['name'])}"
|
|
278
|
+
if old['name'] != current['name']
|
|
279
|
+
emit(owner, "#{path}/name", 'renamed', old['name'], current['name'], field_categories(owner, current) + ['identity'], 'breaking', 'Campo renomeado explicitamente')
|
|
280
|
+
end
|
|
281
|
+
property(old, current, 'previous_names', owner, "#{path}/previous_names", ['origin'], 'metadata')
|
|
282
|
+
compare_properties(old.reject { |key, _| %w[name previous_names].include?(key) }, current.reject { |key, _| %w[name previous_names].include?(key) }, owner, path, contexts)
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def compare_named(before, after, owner, prefix)
|
|
287
|
+
old = before.to_h { |item| [item['name'], item] }
|
|
288
|
+
current = after.to_h { |item| [item['name'], item] }
|
|
289
|
+
(old.keys | current.keys).sort.each do |name|
|
|
290
|
+
property(old, current, name, owner, "#{prefix}/#{escape(name)}", ['storage'], 'review')
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def field_categories(owner, field)
|
|
295
|
+
result = ['contract']
|
|
296
|
+
result << 'storage' if owner['kind'] == 'entity'
|
|
297
|
+
result << 'privacy' if field.dig('privacy', 'personal')
|
|
298
|
+
result << 'transitions' if field['set_by'] || field['guarded']
|
|
299
|
+
result
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def node_categories(node)
|
|
303
|
+
result = case node['kind']
|
|
304
|
+
when 'role', 'policy' then ['permissions']
|
|
305
|
+
when 'entity' then %w[model storage]
|
|
306
|
+
when 'contract' then ['contract']
|
|
307
|
+
when 'command', 'query' then %w[contract permissions capabilities]
|
|
308
|
+
when 'capability' then ['capabilities']
|
|
309
|
+
when 'event' then %w[events contract]
|
|
310
|
+
when 'handler' then node.fetch('calls', []).empty? ? %w[events capabilities] : %w[events capabilities permissions]
|
|
311
|
+
when 'route' then %w[surface contract permissions]
|
|
312
|
+
when 'application' then ['configuration']
|
|
313
|
+
else ['dependencies']
|
|
314
|
+
end
|
|
315
|
+
result << 'isolation' if node['tenant']
|
|
316
|
+
result << 'dependencies' if node['kind'] == 'query' && node['live']
|
|
317
|
+
if %w[entity contract].include?(node['kind'])
|
|
318
|
+
result += node.fetch('fields', []).flat_map { |field| field_categories(node, field) } - ['contract', 'storage']
|
|
319
|
+
end
|
|
320
|
+
result.uniq.sort
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def categories(owner, key, prefix)
|
|
324
|
+
return %w[dependencies permissions] if key == 'live' && owner['kind'] == 'query'
|
|
325
|
+
return ['privacy'] if key == 'privacy' || key == 'history'
|
|
326
|
+
return ['transitions'] if %w[guarded set_by transitions].include?(key)
|
|
327
|
+
return %w[isolation permissions] if key == 'tenant'
|
|
328
|
+
return ['permissions'] if key == 'authorize'
|
|
329
|
+
return ['permissions'] if key == 'calls' && owner['kind'] == 'handler'
|
|
330
|
+
return ['capabilities'] if key == 'uses'
|
|
331
|
+
return ['dependencies'] if key == 'domain' || key == 'depends_on'
|
|
332
|
+
return ['implementation'] if %w[implementation unsafe_effect].include?(key)
|
|
333
|
+
return owner['kind'] == 'entity' ? %w[contract storage] : ['contract'] if prefix.start_with?('/fields/')
|
|
334
|
+
node_categories(owner)
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def impact(owner, key, before, after, contexts, prefix)
|
|
338
|
+
return before && !after ? 'breaking' : 'review' if owner['kind'] == 'query' && key == 'live'
|
|
339
|
+
return 'breaking' if owner['kind'] == 'contract' && key == 'collection' && prefix.empty?
|
|
340
|
+
return 'review' if %w[privacy guarded set_by transitions history tenant authorize calls uses domain depends_on implementation unsafe_effect].include?(key)
|
|
341
|
+
return 'breaking' if owner['kind'] == 'route'
|
|
342
|
+
return 'breaking' if %w[input output payload storage].include?(key)
|
|
343
|
+
if prefix.start_with?('/fields/')
|
|
344
|
+
return type_impact(before, after, contexts) if key == 'type'
|
|
345
|
+
if key == 'required'
|
|
346
|
+
return 'breaking' if contexts.include?('event') || (contexts.include?('input') && after) || (contexts.include?('output') && !after) || (contexts.include?('entity') && after)
|
|
347
|
+
return contexts.include?('unknown') ? 'review' : 'additive'
|
|
348
|
+
end
|
|
349
|
+
if key == 'nullable'
|
|
350
|
+
return 'breaking' if contexts.include?('event') || contexts.include?('entity') || (contexts.include?('input') && !after) || (contexts.include?('output') && after)
|
|
351
|
+
return contexts.include?('unknown') ? 'review' : 'additive'
|
|
352
|
+
end
|
|
353
|
+
if key == 'constraints'
|
|
354
|
+
return constraints_impact(before || {}, after || {}, contexts)
|
|
355
|
+
end
|
|
356
|
+
return 'review'
|
|
357
|
+
end
|
|
358
|
+
'review'
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
def type_impact(before, after, contexts)
|
|
362
|
+
return 'breaking' unless before && after && before['name'] == 'enum' && after['name'] == 'enum'
|
|
363
|
+
return 'breaking' unless before.reject { |key, _| %w[values groups].include?(key) } == after.reject { |key, _| %w[values groups].include?(key) }
|
|
364
|
+
removed = before['values'] - after['values']
|
|
365
|
+
added = after['values'] - before['values']
|
|
366
|
+
return 'review' if (removed.empty? && added.empty?) || contexts.include?('unknown')
|
|
367
|
+
return 'breaking' if contexts.include?('event') || (contexts.include?('input') && removed.any?) || (contexts.include?('output') && added.any?) || (contexts.include?('entity') && removed.any?)
|
|
368
|
+
return 'review' if before['groups'] != after['groups']
|
|
369
|
+
'additive'
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
def constraints_impact(before, after, contexts)
|
|
373
|
+
return 'breaking' if contexts.include?('event') || (after['unique'] && !before['unique'])
|
|
374
|
+
input_tighter = after.fetch('min_length', 0) > before.fetch('min_length', 0)
|
|
375
|
+
input_tighter ||= after.key?('max_length') && (!before.key?('max_length') || after['max_length'] < before['max_length'])
|
|
376
|
+
output_wider = after.fetch('min_length', 0) < before.fetch('min_length', 0)
|
|
377
|
+
output_wider ||= before.key?('max_length') && (!after.key?('max_length') || after['max_length'] > before['max_length'])
|
|
378
|
+
return 'breaking' if ((contexts & %w[input entity]).any? && input_tighter) || (contexts.include?('output') && output_wider)
|
|
379
|
+
return 'review' if contexts.include?('unknown') || before['unique'] != after['unique']
|
|
380
|
+
'additive'
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
def node_change(node, change)
|
|
384
|
+
before, after = change == 'added' ? [nil, node_content(node).merge(node.slice('kind', 'id'))] : [node_content(node).merge(node.slice('kind', 'id')), nil]
|
|
385
|
+
impact = change == 'removed' && CONTRACT_KINDS.include?(node['kind']) ? 'breaking' : (change == 'added' ? 'additive' : 'review')
|
|
386
|
+
emit(node, '/', change, before, after, node_categories(node), impact, change == 'added' ? 'Nó adicionado' : 'Nó removido', before_present: change != 'added', after_present: change != 'removed')
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
def property(before, after, key, owner, path, categories, impact)
|
|
390
|
+
return if before.key?(key) == after.key?(key) && before[key] == after[key]
|
|
391
|
+
change = !before.key?(key) ? 'added' : (!after.key?(key) ? 'removed' : 'modified')
|
|
392
|
+
emit(owner, path, change, before[key], after[key], categories, impact, 'Propriedade alterada', before_present: before.key?(key), after_present: after.key?(key))
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
def emit(owner, path, change, before, after, categories, impact, description, before_present: true, after_present: true)
|
|
396
|
+
categories = categories.uniq.sort
|
|
397
|
+
record = owner.slice('kind', 'id').merge(
|
|
398
|
+
'path' => path, 'change' => change, 'categories' => categories, 'impact' => impact,
|
|
399
|
+
'description' => description, 'before_present' => before_present, 'after_present' => after_present,
|
|
400
|
+
'before' => Data.copy(before), 'after' => Data.copy(after),
|
|
401
|
+
'requires_review' => impact != 'metadata' && (impact != 'additive' || (categories & REVIEW_CATEGORIES).any?)
|
|
402
|
+
)
|
|
403
|
+
record['permission_impact'] = 'indeterminate' if impact != 'metadata' && (categories & %w[permissions isolation]).any?
|
|
404
|
+
@changes << record
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
def escape(value)
|
|
408
|
+
value.gsub('~', '~0').gsub('/', '~1')
|
|
409
|
+
end
|
|
410
|
+
end
|
|
411
|
+
end
|
|
412
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
require "open3"
|
|
2
|
+
|
|
3
|
+
module Vetra
|
|
4
|
+
module Graph
|
|
5
|
+
class DiffSource
|
|
6
|
+
def initialize(root: Dir.pwd)
|
|
7
|
+
@root = Pathname.new(root).realpath
|
|
8
|
+
rescue Errno::ENOENT, Errno::ENOTDIR
|
|
9
|
+
raise Error.single('project_root', 'Diretório do projeto não existe.')
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def read(input)
|
|
13
|
+
bytes = input.start_with?('git:') ? read_revision(input.delete_prefix('git:')) : File.binread(input)
|
|
14
|
+
Canonical.parse(bytes)
|
|
15
|
+
rescue Errno::ENOENT, Errno::ENOTDIR
|
|
16
|
+
raise Error.single('graph_missing', "Arquivo da IR não existe: #{input}.")
|
|
17
|
+
rescue Error => error
|
|
18
|
+
raise Error.new(error.diagnostics.map do |diagnostic|
|
|
19
|
+
Diagnostic.new(**diagnostic.to_h.merge(message: "Entrada #{input}: #{diagnostic.message}"))
|
|
20
|
+
end)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def git(*arguments)
|
|
26
|
+
Open3.capture3('git', '-C', @root.to_s, *arguments)
|
|
27
|
+
rescue Errno::ENOENT
|
|
28
|
+
raise Error.single('git_unavailable', 'Git não está disponível; forneça dois arquivos JSON.')
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def read_revision(reference)
|
|
32
|
+
raise Error.single('git_reference', 'Informe uma revisão depois de git:.') if reference.empty?
|
|
33
|
+
@prefix ||= begin
|
|
34
|
+
output, _error, status = git('rev-parse', '--show-prefix')
|
|
35
|
+
raise Error.single('git_repository', '--root deve apontar para uma aplicação dentro de um repositório Git.') unless status.success?
|
|
36
|
+
output.delete_suffix("\n")
|
|
37
|
+
end
|
|
38
|
+
commit, _error, status = git('rev-parse', '--verify', '--end-of-options', "#{reference}^{commit}")
|
|
39
|
+
unless status.success? && commit.delete_suffix("\n").match?(/\A(?:[0-9a-f]{40}|[0-9a-f]{64})\z/)
|
|
40
|
+
raise Error.single('git_reference', "Revisão Git inválida: #{reference}.")
|
|
41
|
+
end
|
|
42
|
+
path = "#{@prefix}.vetra/graph.json"
|
|
43
|
+
bytes, _error, status = git('cat-file', 'blob', "#{commit.delete_suffix("\n")}:#{path}")
|
|
44
|
+
unless status.success?
|
|
45
|
+
raise Error.single('git_graph_missing', "IR ausente em #{reference}:#{path}; confira a revisão e --root.")
|
|
46
|
+
end
|
|
47
|
+
bytes
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|