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.
data/lib/vetra/cli.rb ADDED
@@ -0,0 +1,64 @@
1
+ require "optparse"
2
+ require_relative "../vetra"
3
+
4
+ module Vetra
5
+ class CLI
6
+ def self.run(arguments, out: $stdout, err: $stderr)
7
+ options = { root: Dir.pwd, sources: false, format: nil, fail_on_breaking: false }
8
+ parser = OptionParser.new do |flags|
9
+ flags.banner = "Uso: vetra graph:build|graph:check|graph:link [--root diretório]\n vetra graph:validate [arquivo] [--root diretório] [--sources]\n vetra graph:diff ANTERIOR POSTERIOR [--root diretório] [--format text|json] [--fail-on-breaking]"
10
+ flags.on('--root PATH', 'Raiz da aplicação') { |value| options[:root] = value }
11
+ flags.on('--sources', 'Também verificar manifesto em graph:validate') { options[:sources] = true }
12
+ flags.on('--format FORMAT', %w[text json], 'Formato de graph:diff: text ou json') { |value| options[:format] = value }
13
+ flags.on('--fail-on-breaking', 'Sair com código 3 se o diff apontar quebra potencial') { options[:fail_on_breaking] = true }
14
+ flags.on('-h', '--help', 'Mostrar ajuda') { out.puts(flags); return 0 }
15
+ flags.on('--version', 'Mostrar versão do protótipo') { out.puts(VERSION); return 0 }
16
+ end
17
+ args = parser.parse(arguments.dup)
18
+ command = args.shift
19
+ unless %w[graph:build graph:check graph:validate graph:diff graph:link].include?(command)
20
+ err.puts(parser)
21
+ return 2
22
+ end
23
+ if command == 'graph:diff'
24
+ raise OptionParser::InvalidArgument, 'graph:diff exige duas entradas e não aceita --sources' unless args.size == 2 && !options[:sources]
25
+ source = Graph::DiffSource.new(root: options[:root])
26
+ report = Graph::Diff.new.compare(*args.map { |input| source.read(input) })
27
+ report.data['warnings'].each do |warning|
28
+ label = warning['side'] == 'before' ? 'anterior' : 'posterior'
29
+ diagnostic = Graph::Diagnostic.new(code: warning['code'], message: warning['message'], pointer: warning['pointer'], source: warning['source'], severity: :warning)
30
+ err.puts("[#{label}] #{diagnostic}")
31
+ end
32
+ out.write(options[:format] == 'json' ? report.to_json : report.to_text)
33
+ return options[:fail_on_breaking] && report.breaking? ? 3 : 0
34
+ end
35
+ if options[:format] || options[:fail_on_breaking] || (command != 'graph:validate' && (!args.empty? || options[:sources])) || args.size > 1
36
+ raise OptionParser::InvalidArgument, 'argumentos incompatíveis com o comando'
37
+ end
38
+ compiler = Graph::Compiler.new(root: options[:root])
39
+ result = case command
40
+ when 'graph:build' then compiler.write
41
+ when 'graph:check' then compiler.check
42
+ when 'graph:validate'
43
+ compiler.validate_file(args.first || compiler.artifact, sources: options[:sources])
44
+ when 'graph:link' then compiler.link
45
+ end
46
+ result.warnings.each { |diagnostic| err.puts(diagnostic) }
47
+ if command == 'graph:link'
48
+ out.puts("#{command}: OK — #{result.implementation_ids.size} blocos Ruby vinculados; #{result.builtin_bindings.size} descritores de primitivas sem executor; #{result.document.fetch('digest')}")
49
+ else
50
+ out.puts("#{command}: OK — #{result.document.fetch('nodes').size} nós, #{result.document.fetch('digest')}")
51
+ end
52
+ 0
53
+ rescue Graph::Error => e
54
+ e.diagnostics.each { |diagnostic| err.puts(diagnostic) }
55
+ 1
56
+ rescue OptionParser::ParseError => e
57
+ err.puts(e.message)
58
+ 2
59
+ rescue SystemCallError => e
60
+ err.puts("[filesystem] #{e.class}: não foi possível ler ou gravar o projeto.")
61
+ 1
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,94 @@
1
+ require "prism"
2
+
3
+ module Vetra
4
+ module Graph
5
+ class Analysis
6
+ DYNAMIC = %i[send public_send __send__ const_get eval instance_eval class_eval module_eval define_method instance_variable_get].freeze
7
+ PROCESS_CALLS = %i[system exec spawn fork open sleep gets puts print warn rand srand require require_relative load exit abort at_exit trap autoload].freeze
8
+ WRITE_CALLS = %i[create! update! delete! insert update delete save! destroy! add_member! remove_member!].freeze
9
+ EFFECT_CLASSES = /\A(?:File|IO|Socket|TCPSocket|UDPSocket|Process|Thread|Random|SecureRandom|Net::HTTP|OpenURI|ENV|Sequel|ActiveRecord|PG)(?:::|\z)/
10
+
11
+ def initialize(manifest)
12
+ @manifest = manifest
13
+ @trees = {}
14
+ end
15
+
16
+ def inspect_block(block, declared_uses, declaration_source:, declaration_kind:)
17
+ path, line = block.source_location
18
+ source = @manifest.source(path, line)
19
+ parsed = @trees[source['path']] ||= Prism.parse(@manifest.read(source['path']))
20
+ declarations = descendants(parsed.value).select do |node|
21
+ node.is_a?(Prism::CallNode) && node.name.to_s == declaration_kind && node.block.is_a?(Prism::BlockNode) && node.block.location.start_line == declaration_source['line']
22
+ end
23
+ unless source['path'] == declaration_source['path'] && declarations.size == 1
24
+ raise Error.single('analysis_binding', 'O bloco deve pertencer à declaração exata da operação.', source: declaration_source)
25
+ end
26
+ candidates = descendants(declarations.first.block.body).select do |node|
27
+ node.is_a?(Prism::CallNode) && node.name == :call && node.block.is_a?(Prism::BlockNode) && node.block.location.start_line == line
28
+ end
29
+ unless candidates.size == 1
30
+ raise Error.single("analysis_binding", "O call deve estar dentro da declaração da operação, em uma linha própria e sem ambiguidade.", source: declaration_source)
31
+ end
32
+ parameters = block.parameters
33
+ expected = declared_uses.map { |name| name.split('.').last }
34
+ unless parameters.size == expected.size + 1 && parameters.all? { |kind, name| %i[opt req].include?(kind) && name } && parameters.drop(1).map { |_, name| name.to_s } == expected
35
+ raise Error.single("implementation_parameters", "call exige um parâmetro de entrada seguido das capacidades na ordem de uses: #{expected.join(', ')}.", source: source)
36
+ end
37
+ constants, dynamic, forbidden, writes = [], [], [], []
38
+ descendants(candidates.first.block.body).each do |node|
39
+ if node.is_a?(Prism::ConstantReadNode) || node.is_a?(Prism::ConstantPathNode)
40
+ constants << node.location.slice.delete_prefix('::')
41
+ elsif node.is_a?(Prism::XStringNode) || node.is_a?(Prism::InterpolatedXStringNode)
42
+ forbidden << 'Kernel#`'
43
+ elsif node.is_a?(Prism::CallNode)
44
+ receiver = node.receiver
45
+ constant = if receiver.is_a?(Prism::ConstantReadNode) || receiver.is_a?(Prism::ConstantPathNode)
46
+ receiver.location.slice.delete_prefix('::')
47
+ end
48
+ name = node.name
49
+ if constant&.match?(EFFECT_CLASSES) || (%w[Time DateTime].include?(constant) && %i[now new].include?(name)) || (constant == 'Date' && name == :today) || (constant == 'Kernel' && PROCESS_CALLS.include?(name)) || (!receiver && PROCESS_CALLS.include?(name))
50
+ effect = "#{constant || 'Kernel'}.#{name}"
51
+ forbidden << effect
52
+ constants << effect
53
+ end
54
+ dynamic << name.to_s if DYNAMIC.include?(name)
55
+ if !receiver && ![:fail!].include?(name) && !PROCESS_CALLS.include?(name)
56
+ dynamic << "unresolved_call:#{name}"
57
+ end
58
+ if receiver.is_a?(Prism::LocalVariableReadNode) && WRITE_CALLS.include?(name)
59
+ attributes = node.arguments&.arguments&.select { |arg| arg.is_a?(Prism::KeywordHashNode) || arg.is_a?(Prism::HashNode) } || []
60
+ values = attributes.flat_map(&:elements).filter_map do |pair|
61
+ next unless pair.is_a?(Prism::AssocNode) && pair.key.is_a?(Prism::SymbolNode)
62
+ known, value = literal(pair.value)
63
+ { 'name' => pair.key.unescaped, 'known' => known, 'value' => value }
64
+ end
65
+ writes << { 'receiver' => receiver.name.to_s, 'fields' => values, 'line' => node.location.start_line }
66
+ end
67
+ end
68
+ end
69
+ {
70
+ 'source' => source, 'forbidden' => forbidden.uniq.sort, 'writes' => writes,
71
+ 'analysis' => { 'status' => dynamic.empty? ? 'complete' : 'incomplete', 'constants' => constants.uniq.sort, 'dynamic' => dynamic.uniq.sort }
72
+ }
73
+ end
74
+
75
+ private
76
+
77
+ def descendants(node)
78
+ return [] unless node
79
+ [node, *node.compact_child_nodes.flat_map { |child| descendants(child) }]
80
+ end
81
+
82
+ def literal(node)
83
+ case node
84
+ when Prism::StringNode, Prism::SymbolNode then [true, node.unescaped]
85
+ when Prism::IntegerNode then [true, node.value]
86
+ when Prism::TrueNode then [true, true]
87
+ when Prism::FalseNode then [true, false]
88
+ when Prism::NilNode then [true, nil]
89
+ else [false, nil]
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,52 @@
1
+ require "json"
2
+ require "digest"
3
+
4
+ module Vetra
5
+ module Graph
6
+ # RFC 8785 for the IR subset: floats are forbidden, so ECMAScript number
7
+ # rounding is unnecessary. Object keys sort by UTF-16 code units, not UTF-8.
8
+ module Canonical
9
+ MAX_INTEGER = (2**53) - 1
10
+
11
+ module_function
12
+
13
+ def parse(bytes)
14
+ JSON.parse(bytes, allow_duplicate_key: false, create_additions: false, allow_nan: false)
15
+ rescue JSON::ParserError, EncodingError, ArgumentError => e
16
+ raise Error.single("invalid_json", "JSON inválido (#{e.class}).")
17
+ end
18
+
19
+ def dump(value)
20
+ case value
21
+ when Hash
22
+ unless value.keys.all? { |key| key.is_a?(String) }
23
+ raise Error.single("invalid_data", "Chaves da IR devem ser strings.")
24
+ end
25
+ keys = value.keys.sort_by { |key| key.encode(Encoding::UTF_16BE).bytes }
26
+ "{#{keys.map { |key| "#{dump(key)}:#{dump(value.fetch(key))}" }.join(',')}}"
27
+ when Array
28
+ "[#{value.map { |child| dump(child) }.join(',')}]"
29
+ when String
30
+ text = value.encode(Encoding::UTF_8)
31
+ raise Error.single("invalid_data", "String UTF-8 inválida.") unless text.valid_encoding?
32
+ JSON.generate(text)
33
+ when Integer
34
+ raise Error.single("invalid_data", "Inteiro fora do intervalo seguro da IR.") if value.abs > MAX_INTEGER
35
+ value.to_s
36
+ when TrueClass then "true"
37
+ when FalseClass then "false"
38
+ when NilClass then "null"
39
+ else
40
+ raise Error.single("invalid_data", "Tipo #{value.class} não permitido na IR; decimais usam strings.")
41
+ end
42
+ rescue EncodingError => e
43
+ raise Error.single("invalid_data", "Codificação inválida (#{e.class}).")
44
+ end
45
+
46
+ def digest(document)
47
+ payload = document.reject { |key, _| key == "digest" }
48
+ "sha256:#{Digest::SHA256.hexdigest(dump(payload))}"
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,22 @@
1
+ module Vetra
2
+ module Graph
3
+ module Capabilities
4
+ module_function
5
+
6
+ def reference(name, domain:, operation_kind:, nodes:, source: nil)
7
+ id = name.include?('.') ? name : "#{domain}.#{name}"
8
+ capability = nodes[['capability', id]]
9
+ unless capability
10
+ raise Error.single('reference_missing', "Declare effect ou repositório #{id} antes de usá-lo.", source: source)
11
+ end
12
+ if %w[query handler].include?(operation_kind) && capability.dig('config', 'type') == 'repository' && capability.dig('config', 'access') == 'read_write'
13
+ id = "#{id}_read"
14
+ end
15
+ unless nodes.key?(['capability', id])
16
+ raise Error.single('reference_missing', "Capacidade resolvida não declarada: #{id}.", source: source)
17
+ end
18
+ Data.reference('capability', id)
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,119 @@
1
+ require "tempfile"
2
+
3
+ module Vetra
4
+ module Graph
5
+ Result = Struct.new(:document, :warnings, :implementation_ids, keyword_init: true) do
6
+ def bytes
7
+ "#{Canonical.dump(document)}\n"
8
+ end
9
+ end
10
+
11
+ class Compiler
12
+ attr_reader :root
13
+
14
+ def initialize(root: Dir.pwd)
15
+ @root = Pathname.new(root).realpath
16
+ rescue Errno::ENOENT, Errno::ENOTDIR
17
+ raise Error.single('project_root', 'Diretório do projeto não existe.')
18
+ end
19
+
20
+ def build
21
+ manifest = Manifest.new(root)
22
+ document, registry, warnings = compile(manifest)
23
+ Manifest.new(root).verify!(document)
24
+ Data.freeze(Result.new(document: document, warnings: warnings, implementation_ids: registry.bindings.keys.sort))
25
+ end
26
+
27
+ def write
28
+ result = build
29
+ directory = root.join('.vetra')
30
+ directory.mkdir unless directory.exist?
31
+ unless directory.realpath.to_s.start_with?("#{root}/") && !directory.symlink?
32
+ raise Error.single('artifact_path', '.vetra deve ser um diretório dentro do projeto.')
33
+ end
34
+ Tempfile.create(['graph-', '.json'], directory.to_s) do |file|
35
+ file.binmode
36
+ file.write(result.bytes)
37
+ file.flush
38
+ file.fsync
39
+ file.chmod(0o666 & ~File.umask)
40
+ file.close
41
+ File.rename(file.path, artifact.to_s)
42
+ end
43
+ result
44
+ end
45
+
46
+ def check
47
+ result = build
48
+ unless artifact.file? && !artifact.symlink? && artifact.binread == result.bytes
49
+ raise Error.single('graph_stale', 'IR ausente ou desatualizada; execute graph:build e revise .vetra/graph.json.')
50
+ end
51
+ result
52
+ end
53
+
54
+ def validate_file(path, sources: false)
55
+ document = Canonical.parse(File.binread(path))
56
+ validator = Validator.new
57
+ validator.validate!(document)
58
+ Manifest.new(root).verify!(document) if sources
59
+ Data.freeze(Result.new(document: document, warnings: validator.warnings, implementation_ids: []))
60
+ rescue Errno::ENOENT, Errno::ENOTDIR
61
+ raise Error.single('graph_missing', 'Arquivo da IR não existe.')
62
+ end
63
+
64
+ def artifact
65
+ root.join('.vetra/graph.json')
66
+ end
67
+
68
+ def link
69
+ bytes = artifact_bytes
70
+ document = Canonical.parse(bytes)
71
+ Validator.new.validate!(document)
72
+ unless bytes == "#{Canonical.dump(document)}\n"
73
+ raise Error.single('graph_not_canonical', 'O artefato deve usar JSON canônico seguido de LF; execute graph:build no pipeline.')
74
+ end
75
+ producer = document.fetch('producer')
76
+ unless producer['version'] == Vetra::VERSION && producer['ruby_version'] == RUBY_VERSION
77
+ raise Error.single('producer_mismatch', 'Versões do Vetra/Ruby diferem do build; carregue o pacote com as versões registradas.', pointer: '/producer')
78
+ end
79
+ manifest = Manifest.new(root)
80
+ manifest.verify!(document)
81
+ Data.freeze(document)
82
+
83
+ # Registration is needed to recreate Ruby closures in this process. The
84
+ # normalized declaration is an audit only: it never replaces the artifact.
85
+ audit, registry, warnings = compile(manifest, ir_version: document.fetch('ir_version'))
86
+ unless Canonical.dump(audit) == Canonical.dump(document)
87
+ raise Error.single('graph_model_mismatch', 'As declarações carregadas diferem da IR aprovada; reconstrua e revise o pacote.')
88
+ end
89
+ linked = Linker.new(document, manifest).link(registry, warnings: warnings)
90
+ Manifest.new(root).verify!(document)
91
+ unless artifact_bytes == bytes
92
+ raise Error.single('graph_changed', 'O artefato mudou durante a vinculação; use um pacote imutável.')
93
+ end
94
+ linked
95
+ end
96
+
97
+ private
98
+
99
+ def compile(manifest, ir_version: Schema::VERSION)
100
+ registry = DeclarationLoader.new(manifest).load
101
+ document = Normalizer.new(registry, manifest, ir_version: ir_version).document
102
+ warnings = Validator.new.validate!(document)
103
+ [document, registry, warnings]
104
+ end
105
+
106
+ def artifact_bytes
107
+ unless artifact.file? && !artifact.symlink?
108
+ raise Error.single('graph_missing', 'IR ausente ou não regular; publique .vetra/graph.json junto ao pacote.')
109
+ end
110
+ if artifact.dirname.symlink? || !artifact.dirname.realpath.to_s.start_with?("#{root}/")
111
+ raise Error.single('artifact_path', '.vetra deve ser um diretório dentro do projeto.')
112
+ end
113
+ artifact.binread
114
+ rescue Errno::ENOENT, Errno::ENOTDIR
115
+ raise Error.single('graph_missing', 'Arquivo da IR não existe.')
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,28 @@
1
+ module Vetra
2
+ module Graph
3
+ module Currency
4
+ # ISO 4217 List One (SIX), published 2026-09-17; checked 2026-10-31.
5
+ # https://www.six-group.com/dam/download/financial-information/data-center/iso-currrency/lists/list-one.xml
6
+ # Source SHA-256: 33139b438657d1cee116ba737807ea71d19d6de4b90f799a09c56f0cc6a1b0ff
7
+ # Only codes with a numeric CcyMnrUnts are included. N.A. is not zero.
8
+ EXPONENTS = Data.freeze({
9
+ 0 => %w[BIF CLP DJF GNF ISK JPY KMF KRW PYG RWF UGX UYI VND VUV XAF XOF XPF],
10
+ 2 => %w[
11
+ AED AFN ALL AMD AOA ARS AUD AWG AZN BAM BBD BDT BMD BND BOB BOV BRL BSD BTN BWP BYN BZD
12
+ CAD CDF CHE CHF CHW CNY COP COU CRC CUP CVE CZK DKK DOP DZD EGP ERN ETB EUR FJD FKP GBP
13
+ GEL GHS GIP GMD GTQ GYD HKD HNL HTG HUF IDR ILS INR IRR JMD KES KGS KHR KPW KYD KZT
14
+ LAK LBP LKR LRD LSL MAD MDL MGA MKD MMK MNT MOP MRU MUR MVR MWK MXN MXV MYR MZN NAD
15
+ NGN NIO NOK NPR NZD PAB PEN PGK PHP PKR PLN QAR RON RSD RUB SAR SBD SCR SDG SEK SGD SHP
16
+ SLE SOS SRD SSP STN SVC SYP SZL THB TJS TMT TOP TRY TTD TWD TZS UAH USD USN UYU UZS
17
+ VED VES WST XAD XCD XCG YER ZAR ZMW ZWG
18
+ ],
19
+ 3 => %w[BHD IQD JOD KWD LYD OMR TND],
20
+ 4 => %w[CLF UYW]
21
+ }.flat_map { |exponent, codes| codes.map { |code| [code, exponent] } }.to_h)
22
+
23
+ def self.exponent(code)
24
+ EXPONENTS[code]
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,48 @@
1
+ module Vetra
2
+ module Graph
3
+ module Data
4
+ module_function
5
+
6
+ def freeze(value)
7
+ case value
8
+ when Hash
9
+ value.each { |key, child| freeze(key); freeze(child) }
10
+ when Array
11
+ value.each { |child| freeze(child) }
12
+ when Struct
13
+ value.each { |child| freeze(child) }
14
+ end
15
+ value.freeze
16
+ end
17
+
18
+ def copy(value)
19
+ case value
20
+ when Hash then value.to_h { |key, child| [key.dup, copy(child)] }
21
+ when Array then value.map { |child| copy(child) }
22
+ when String then value.dup
23
+ else value
24
+ end
25
+ end
26
+
27
+ def reference(kind, id)
28
+ { "kind" => kind, "id" => id }
29
+ end
30
+
31
+ def references(value, pointer = "", &block)
32
+ case value
33
+ when Hash
34
+ if value.keys.sort == %w[id kind]
35
+ yield value, pointer
36
+ elsif value["kind"] != "literal"
37
+ value.each do |key, child|
38
+ next if key == "annotations"
39
+ references(child, "#{pointer}/#{key.gsub('~', '~0').gsub('/', '~1')}", &block)
40
+ end
41
+ end
42
+ when Array
43
+ value.each_with_index { |child, i| references(child, "#{pointer}/#{i}", &block) }
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,62 @@
1
+ module Vetra
2
+ module Graph
3
+ # Evaluates trusted project declarations once into a fresh, sealed registry.
4
+ # Both compilation and linking use this path; neither executes a call body.
5
+ class DeclarationLoader
6
+ KEYWORD_ERROR = /\A(unknown|missing) keywords?: (.+)\z/
7
+
8
+ def initialize(manifest)
9
+ @manifest = manifest
10
+ end
11
+
12
+ def load
13
+ registry = Registry.new
14
+ paths = @manifest.entries.map { |entry| entry['path'] }
15
+ declarations = paths.grep(/\Aapp\/domains\/.*\.rb\z/).sort_by do |path|
16
+ [path.split('/')[2], File.basename(path) == 'domain.rb' ? 0 : 1, path]
17
+ end
18
+ ['config/app.rb', *declarations].each do |path|
19
+ domain = path == 'config/app.rb' ? nil : path.split('/')[2]
20
+ origin = { 'path' => path, 'line' => 1 }
21
+ if domain && !domain.match?(/\A[a-z][a-z0-9_]*\z/)
22
+ raise Error.single('domain_name', 'Use nomes de domínio em snake_case ASCII.', source: origin)
23
+ end
24
+ context = DSL::Context.new(registry, @manifest, domain, origin)
25
+ previous = Thread.current[:vetra_graph_context]
26
+ begin
27
+ Thread.current[:vetra_graph_context] = context
28
+ context.evaluate_source(@manifest.read(path), @manifest.root.join(path).to_s)
29
+ rescue SyntaxError, ArgumentError, TypeError, NameError => error
30
+ raise(keyword_error(error, path, origin) ||
31
+ Error.single('dsl_error', "Declaração inválida (#{error.class}): #{error.message.lines.first&.strip}", source: origin))
32
+ ensure
33
+ Thread.current[:vetra_graph_context] = previous
34
+ end
35
+ end
36
+ registry.seal!
37
+ end
38
+
39
+ private
40
+
41
+ # Uma palavra-chave desconhecida ou ausente numa declaração vira um diagnóstico que nomeia a opção, o método e a
42
+ # linha da declaração, em vez do ArgumentError do Ruby apontando a linha 1. Qualquer outro erro continua sendo
43
+ # dsl_error.
44
+ def keyword_error(error, path, origin)
45
+ return unless error.is_a?(ArgumentError)
46
+ match = KEYWORD_ERROR.match(error.message.lines.first.to_s.strip) or return
47
+ names = match[2].split(', ').map { |name| name.delete_prefix(':') }.join(', ')
48
+ locations = error.backtrace_locations || []
49
+ method = locations.first&.base_label
50
+ place = method&.match?(/\A[a-z_]+[!?]?\z/) ? " em #{method}" : ''
51
+ file = @manifest.root.join(path).to_s
52
+ frame = locations.find { |location| (location.absolute_path || location.path) == file }
53
+ source = frame ? origin.merge('line' => frame.lineno) : origin
54
+ if match[1] == 'unknown'
55
+ Error.single('unsupported_option', "Opções não implementadas#{place}: #{names}.", source: source)
56
+ else
57
+ Error.single('missing_option', "Opções obrigatórias ausentes#{place}: #{names}.", source: source)
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,23 @@
1
+ module Vetra
2
+ module Graph
3
+ Diagnostic = Struct.new(:code, :message, :pointer, :source, :severity, keyword_init: true) do
4
+ def to_s
5
+ origin = source ? "#{source.fetch('path')}:#{source.fetch('line')} " : ""
6
+ "#{origin}[#{code}] #{pointer || '/'}: #{message}"
7
+ end
8
+ end
9
+
10
+ class Error < StandardError
11
+ attr_reader :diagnostics
12
+
13
+ def initialize(diagnostics)
14
+ @diagnostics = diagnostics
15
+ super(diagnostics.map(&:to_s).join("\n"))
16
+ end
17
+
18
+ def self.single(code, message, source: nil, pointer: "/")
19
+ new([Diagnostic.new(code: code, message: message, source: source, pointer: pointer, severity: :error)])
20
+ end
21
+ end
22
+ end
23
+ end