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