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,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