ibex 0.3.0 → 0.4.0
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 +4 -4
- data/README.md +7 -2
- data/docs/declarative-configuration.md +58 -46
- data/docs/development.md +17 -0
- data/docs/direct-ielr-decision.md +4 -4
- data/docs/error-ux-round2-review-status-v1.json +1 -1
- data/docs/error-ux-round2-v1.json +39 -3
- data/docs/impact.md +101 -0
- data/docs/maturity.md +3 -2
- data/docs/runtime-abi-evolution.md +11 -8
- data/docs/stability.md +2 -1
- data/lib/ibex/analysis/sets.rb +14 -0
- data/lib/ibex/cli/impact.rb +393 -0
- data/lib/ibex/cli.rb +5 -0
- data/lib/ibex/diff.rb +2 -1
- data/lib/ibex/impact/action_impact.rb +148 -0
- data/lib/ibex/impact/automaton_impact.rb +57 -0
- data/lib/ibex/impact/coverage_impact.rb +53 -0
- data/lib/ibex/impact/graph.rb +132 -0
- data/lib/ibex/impact/propagation.rb +147 -0
- data/lib/ibex/impact/report.rb +273 -0
- data/lib/ibex/impact/seeds.rb +69 -0
- data/lib/ibex/impact/severity.rb +139 -0
- data/lib/ibex/impact.rb +16 -0
- data/lib/ibex/version.rb +1 -1
- data/lib/ibex.rb +1 -0
- data/schema/impact-v1.schema.json +208 -0
- data/sig/ibex/analysis/sets.rbs +10 -0
- data/sig/ibex/cli/impact.rbs +95 -0
- data/sig/ibex/impact/action_impact.rbs +58 -0
- data/sig/ibex/impact/automaton_impact.rbs +37 -0
- data/sig/ibex/impact/coverage_impact.rbs +28 -0
- data/sig/ibex/impact/graph.rbs +67 -0
- data/sig/ibex/impact/propagation.rbs +56 -0
- data/sig/ibex/impact/report.rbs +107 -0
- data/sig/ibex/impact/seeds.rbs +36 -0
- data/sig/ibex/impact/severity.rbs +55 -0
- data/sig/ibex/impact.rbs +6 -0
- metadata +25 -3
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module Ibex
|
|
5
|
+
module Impact
|
|
6
|
+
# Maps propagated grammar symbols to parser states and conflict changes.
|
|
7
|
+
class AutomatonImpact
|
|
8
|
+
# @rbs @automaton: IR::Automaton
|
|
9
|
+
# @rbs @symbol_ids: Array[Integer]
|
|
10
|
+
# @rbs @production_ids: Array[Integer]
|
|
11
|
+
attr_reader :affected_states #: Array[Integer]
|
|
12
|
+
attr_reader :production_ids #: Array[Integer]
|
|
13
|
+
attr_reader :conflict_states #: Array[Integer]
|
|
14
|
+
|
|
15
|
+
# @rbs (IR::Automaton automaton, Array[Integer]) -> void
|
|
16
|
+
def initialize(automaton, symbol_ids)
|
|
17
|
+
@automaton = automaton
|
|
18
|
+
@symbol_ids = symbol_ids.uniq.sort #: Array[Integer]
|
|
19
|
+
@production_ids = affected_productions
|
|
20
|
+
@affected_states = affected_state_ids
|
|
21
|
+
@conflict_states = conflict_state_ids
|
|
22
|
+
freeze
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# @rbs (Hash[Symbol, Object?] diff) -> Hash[Symbol, Object?]
|
|
26
|
+
def conflict_changes(diff)
|
|
27
|
+
diff.fetch(:conflicts) #: Hash[Symbol, Object?]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# @rbs () -> Hash[Symbol, Object?]
|
|
31
|
+
def to_h
|
|
32
|
+
{ states: @affected_states, productions: @production_ids, conflict_states: @conflict_states }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
# @rbs () -> Array[Integer]
|
|
38
|
+
def affected_productions
|
|
39
|
+
@automaton.grammar.productions.filter_map do |production|
|
|
40
|
+
production.id if @symbol_ids.include?(production.lhs) || production.rhs.any? { |id| @symbol_ids.include?(id) }
|
|
41
|
+
end.sort
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# @rbs () -> Array[Integer]
|
|
45
|
+
def affected_state_ids
|
|
46
|
+
@automaton.states.filter_map do |state|
|
|
47
|
+
state.id if state.items.any? { |item| @production_ids.include?(item.production) }
|
|
48
|
+
end.sort
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @rbs () -> Array[Integer]
|
|
52
|
+
def conflict_state_ids
|
|
53
|
+
@automaton.states.filter_map { |state| state.id unless state.conflicts.empty? }.sort
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module Ibex
|
|
5
|
+
module Impact
|
|
6
|
+
# Selects separated runtime coverage reports for affected productions.
|
|
7
|
+
class CoverageImpact
|
|
8
|
+
attr_reader :status #: String
|
|
9
|
+
attr_reader :reports #: Array[Hash[Symbol, Object?]]
|
|
10
|
+
attr_reader :warnings #: Array[String]
|
|
11
|
+
|
|
12
|
+
# @rbs (IR::Automaton automaton, Array[Integer], Array[[String, Coverage::Report]]) -> void
|
|
13
|
+
def initialize(automaton, production_ids, reports)
|
|
14
|
+
@automaton = automaton
|
|
15
|
+
@production_ids = production_ids.sort
|
|
16
|
+
@warnings = []
|
|
17
|
+
@reports = select_reports(reports)
|
|
18
|
+
@status = coverage_status(reports)
|
|
19
|
+
freeze
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# @rbs () -> Hash[Symbol, Object?]
|
|
23
|
+
def to_h
|
|
24
|
+
{ status: @status, reports: @reports }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
# @rbs (Array[[String, Coverage::Report]]) -> Array[Hash[Symbol, Object?]]
|
|
30
|
+
def select_reports(reports)
|
|
31
|
+
selected = reports.filter_map do |path, report|
|
|
32
|
+
unless report.grammar_digest == @automaton.grammar_digest
|
|
33
|
+
@warnings << "#{path}: coverage grammar digest does not match the analysis input; report ignored"
|
|
34
|
+
next
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
hits = report.production_hits.keys & @production_ids
|
|
38
|
+
next if hits.empty?
|
|
39
|
+
|
|
40
|
+
{ path: path, productions: hits.sort }
|
|
41
|
+
end
|
|
42
|
+
selected.sort_by { |entry| entry.fetch(:path) }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# @rbs (Array[[String, Coverage::Report]]) -> String
|
|
46
|
+
def coverage_status(reports)
|
|
47
|
+
return "not_requested" if reports.empty?
|
|
48
|
+
|
|
49
|
+
@reports.empty? ? "unmatched" : "matched"
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
require_relative "../analysis"
|
|
5
|
+
|
|
6
|
+
module Ibex
|
|
7
|
+
module Impact
|
|
8
|
+
# A grammar dependency edge with enough origin information for a witness.
|
|
9
|
+
class Edge
|
|
10
|
+
attr_reader :source #: Integer
|
|
11
|
+
attr_reader :target #: Integer
|
|
12
|
+
attr_reader :kind #: Symbol
|
|
13
|
+
attr_reader :production #: Integer?
|
|
14
|
+
attr_reader :position #: Integer?
|
|
15
|
+
|
|
16
|
+
# @rbs (source: Integer, target: Integer, kind: Symbol, production: Integer?, position: Integer?) -> void
|
|
17
|
+
def initialize(source:, target:, kind:, production: nil, position: nil)
|
|
18
|
+
@source = source
|
|
19
|
+
@target = target
|
|
20
|
+
@kind = kind.to_sym
|
|
21
|
+
@production = production
|
|
22
|
+
@position = position
|
|
23
|
+
freeze
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# @rbs () -> Array[Integer | Symbol | nil]
|
|
27
|
+
def sort_key
|
|
28
|
+
[@source, @target, @production || -1, @position || -1, @kind]
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Combines reference, FIRST, and FOLLOW propagation dependencies.
|
|
33
|
+
class Graph
|
|
34
|
+
EDGE_KINDS = %i[reference first follow_lhs follow_first].freeze #: Array[Symbol]
|
|
35
|
+
KIND_ALIASES = {
|
|
36
|
+
all: EDGE_KINDS, reference: [:reference], first: [:first], follow: %i[follow_lhs follow_first],
|
|
37
|
+
follow_lhs: [:follow_lhs], follow_first: [:follow_first]
|
|
38
|
+
}.freeze #: Hash[Symbol, Array[Symbol]]
|
|
39
|
+
|
|
40
|
+
attr_reader :grammar #: IR::Grammar
|
|
41
|
+
attr_reader :sets #: Analysis::Sets
|
|
42
|
+
|
|
43
|
+
# @rbs (IR::Grammar grammar, ?sets: Analysis::Sets) -> void
|
|
44
|
+
def initialize(grammar, sets: nil)
|
|
45
|
+
@grammar = grammar
|
|
46
|
+
@sets = sets || Analysis::Sets.new(grammar)
|
|
47
|
+
@edges = build_edges
|
|
48
|
+
freeze_edges
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @rbs (?Symbol kind) -> untyped
|
|
52
|
+
def edges(kind = :all)
|
|
53
|
+
selected = KIND_ALIASES.fetch(kind.to_sym) { raise ArgumentError, "unknown impact edge kind #{kind}" }
|
|
54
|
+
return selected.flat_map { |name| @edges.fetch(name) }.sort_by(&:sort_key) if selected.length > 1
|
|
55
|
+
|
|
56
|
+
@edges.fetch(selected.fetch(0))
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# @rbs (Symbol kind) -> Array[Array[Integer]]
|
|
60
|
+
def adjacency(kind)
|
|
61
|
+
result = Array.new(@grammar.symbols.length) { [] }
|
|
62
|
+
edges(kind).each { |edge| result[edge.source] << edge.target }
|
|
63
|
+
result.each(&:uniq!)
|
|
64
|
+
result
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
# @rbs () -> Hash[Symbol, Array[Edge]]
|
|
70
|
+
def build_edges
|
|
71
|
+
result = EDGE_KINDS.to_h { |kind| [kind, []] }
|
|
72
|
+
@grammar.productions.each do |production|
|
|
73
|
+
add_production_edges(result, production)
|
|
74
|
+
end
|
|
75
|
+
result.each_value { |edges| edges.sort_by!(&:sort_key) }
|
|
76
|
+
result
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# @rbs (Hash[Symbol, Array[Edge]], IR::Production) -> void
|
|
80
|
+
def add_production_edges(result, production)
|
|
81
|
+
production.rhs.each_with_index do |symbol_id, position|
|
|
82
|
+
next unless @grammar.symbol_by_id(symbol_id)&.nonterminal?
|
|
83
|
+
|
|
84
|
+
result[:reference] << edge(symbol_id, production.lhs, :reference, production, position)
|
|
85
|
+
add_first_edge(result, production, symbol_id, position)
|
|
86
|
+
add_follow_edges(result, production, symbol_id, position)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# @rbs (Hash[Symbol, Array[Edge]], IR::Production, Integer, Integer) -> void
|
|
91
|
+
def add_first_edge(result, production, symbol_id, position)
|
|
92
|
+
prefix = production.rhs[0...position] || []
|
|
93
|
+
return unless @sets.sequence_nullable?(prefix)
|
|
94
|
+
return unless @sets.first_dependencies.fetch(symbol_id).include?(production.lhs)
|
|
95
|
+
|
|
96
|
+
result[:first] << edge(symbol_id, production.lhs, :first, production, position)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# @rbs (Hash[Symbol, Array[Edge]], IR::Production, Integer, Integer) -> void
|
|
100
|
+
def add_follow_edges(result, production, symbol_id, position)
|
|
101
|
+
suffix = production.rhs[(position + 1)..] || []
|
|
102
|
+
if @sets.sequence_nullable?(suffix) && @sets.follow_dependencies.fetch(production.lhs).include?(symbol_id)
|
|
103
|
+
result[:follow_lhs] << edge(production.lhs, symbol_id, :follow_lhs, production, position)
|
|
104
|
+
end
|
|
105
|
+
add_follow_first_edges(result, production, symbol_id, position)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# @rbs (Hash[Symbol, Array[Edge]], IR::Production, Integer, Integer) -> void
|
|
109
|
+
def add_follow_first_edges(result, production, symbol_id, position)
|
|
110
|
+
suffix = production.rhs[(position + 1)..] || []
|
|
111
|
+
suffix.each_with_index do |candidate, offset|
|
|
112
|
+
candidate_prefix = suffix[0...offset] || []
|
|
113
|
+
break unless @sets.sequence_nullable?(candidate_prefix)
|
|
114
|
+
break unless @grammar.symbol_by_id(candidate)&.nonterminal?
|
|
115
|
+
|
|
116
|
+
result[:follow_first] << edge(candidate, symbol_id, :follow_first, production, position + 1 + offset)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# @rbs (Integer source, Integer target, Symbol kind, IR::Production production, Integer position) -> Edge
|
|
121
|
+
def edge(source, target, kind, production, position)
|
|
122
|
+
Edge.new(source: source, target: target, kind: kind, production: production.id, position: position)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# @rbs () -> void
|
|
126
|
+
def freeze_edges
|
|
127
|
+
@edges.each_value(&:freeze)
|
|
128
|
+
@edges.freeze
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
require_relative "graph"
|
|
5
|
+
|
|
6
|
+
module Ibex
|
|
7
|
+
module Impact
|
|
8
|
+
# One shortest witness for a symbol reached by dependency propagation.
|
|
9
|
+
class Node
|
|
10
|
+
attr_reader :symbol #: Integer
|
|
11
|
+
attr_reader :distance #: Integer
|
|
12
|
+
attr_reader :witness #: Array[Edge]
|
|
13
|
+
attr_reader :kind #: Symbol
|
|
14
|
+
attr_reader :component #: Array[Integer]
|
|
15
|
+
|
|
16
|
+
# @rbs (symbol: Integer, distance: Integer, witness: Array[Edge], kind: Symbol, component: Array[Integer]) -> void
|
|
17
|
+
def initialize(symbol:, distance:, witness:, kind:, component:)
|
|
18
|
+
@symbol = symbol
|
|
19
|
+
@distance = distance
|
|
20
|
+
@witness = witness.freeze
|
|
21
|
+
@kind = kind
|
|
22
|
+
@component = component.freeze
|
|
23
|
+
freeze
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Performs deterministic forward propagation over a dependency graph.
|
|
28
|
+
class Propagation
|
|
29
|
+
# @rbs @graph: Graph
|
|
30
|
+
# @rbs (Graph graph) -> void
|
|
31
|
+
def initialize(graph)
|
|
32
|
+
@graph = graph
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# @rbs (Array[Integer] seeds, Symbol kind, ?max_depth: Integer?) -> Hash[Integer, Node]
|
|
36
|
+
def propagate(seeds, kind = :all, max_depth: nil)
|
|
37
|
+
validate_depth(max_depth)
|
|
38
|
+
selected = normalize_seeds(seeds)
|
|
39
|
+
adjacency = @graph.adjacency(kind)
|
|
40
|
+
components = Analysis::Digraph.send(:strongly_connected_components, adjacency)
|
|
41
|
+
component_for = component_index(components, adjacency.length)
|
|
42
|
+
component_edges = component_adjacency(adjacency, component_for, components.length)
|
|
43
|
+
component_nodes = traverse_components(selected, component_for, component_edges, max_depth)
|
|
44
|
+
witnesses = symbol_witnesses(selected, adjacency, kind)
|
|
45
|
+
build_nodes(component_nodes, components, kind, witnesses)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
alias call propagate
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
# @rbs (Integer?) -> void
|
|
53
|
+
def validate_depth(max_depth)
|
|
54
|
+
return if max_depth.nil? || (max_depth.is_a?(Integer) && max_depth >= 0)
|
|
55
|
+
|
|
56
|
+
raise ArgumentError, "impact depth must be a non-negative integer"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# @rbs (Array[Integer]) -> Array[Integer]
|
|
60
|
+
def normalize_seeds(seeds)
|
|
61
|
+
seeds.uniq.sort.each do |id|
|
|
62
|
+
unless @graph.grammar.symbol_by_id(id)
|
|
63
|
+
raise ArgumentError,
|
|
64
|
+
"impact seed #{id.inspect} is not a grammar symbol"
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# @rbs (Array[Array[Integer]], Integer) -> Array[Integer]
|
|
70
|
+
def component_index(components, size)
|
|
71
|
+
result = Array.new(size, 0) #: Array[Integer]
|
|
72
|
+
components.each_with_index { |members, id| members.each { |member| result[member] = id } }
|
|
73
|
+
result
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# @rbs (Array[Array[Integer]], Array[Integer], Integer) -> Array[Array[Integer]]
|
|
77
|
+
def component_adjacency(adjacency, component_for, component_count)
|
|
78
|
+
result = Array.new(component_count) { [] } #: Array[Array[Integer]]
|
|
79
|
+
adjacency.each_with_index do |successors, source|
|
|
80
|
+
source_component = component_for.fetch(source)
|
|
81
|
+
successors.each do |target|
|
|
82
|
+
target_component = component_for.fetch(target)
|
|
83
|
+
next if source_component == target_component
|
|
84
|
+
|
|
85
|
+
result[source_component] << target_component
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
result.each do |successors|
|
|
89
|
+
successors.uniq!
|
|
90
|
+
successors.sort!
|
|
91
|
+
end
|
|
92
|
+
result
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# @rbs (Array[Integer], Array[Integer], Array[Array[Integer]], Integer?) -> Hash[Integer, Integer]
|
|
96
|
+
def traverse_components(seeds, component_for, component_edges, max_depth)
|
|
97
|
+
queue = seeds.uniq.sort.map do |seed|
|
|
98
|
+
[component_for.fetch(seed), 0]
|
|
99
|
+
end #: Array[[Integer, Integer]]
|
|
100
|
+
result = {} #: Hash[Integer, Integer]
|
|
101
|
+
until queue.empty?
|
|
102
|
+
component, distance = queue.shift
|
|
103
|
+
next if result.key?(component)
|
|
104
|
+
next if max_depth && distance > max_depth
|
|
105
|
+
|
|
106
|
+
result[component] = distance
|
|
107
|
+
component_edges.fetch(component).each do |target|
|
|
108
|
+
queue << [target, distance + 1]
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
result
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# @rbs (Array[Integer], Array[Array[Integer]], Symbol) -> Hash[Integer, Array[Edge]]
|
|
115
|
+
def symbol_witnesses(seeds, adjacency, kind)
|
|
116
|
+
edges = @graph.edges(kind).group_by { |edge| [edge.source, edge.target] }
|
|
117
|
+
queue = seeds.uniq.sort.map { |seed| [seed, []] } #: Array[[Integer, Array[Edge]]]
|
|
118
|
+
result = {} #: Hash[Integer, Array[Edge]]
|
|
119
|
+
until queue.empty?
|
|
120
|
+
symbol, witness = queue.shift
|
|
121
|
+
next if result.key?(symbol)
|
|
122
|
+
|
|
123
|
+
result[symbol] = witness
|
|
124
|
+
adjacency.fetch(symbol).each do |target|
|
|
125
|
+
edge = edges.fetch([symbol, target]).fetch(0)
|
|
126
|
+
queue << [target, witness + [edge]]
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
result
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# @rbs (Hash[Integer, Integer], Array[Array[Integer]], Symbol, Hash[Integer, Array[Edge]]) -> Hash[Integer, Node]
|
|
133
|
+
def build_nodes(component_nodes, components, kind, witnesses)
|
|
134
|
+
result = {} #: Hash[Integer, Node]
|
|
135
|
+
component_nodes.each do |component, distance|
|
|
136
|
+
members = components.fetch(component).sort
|
|
137
|
+
members.each do |symbol|
|
|
138
|
+
result[symbol] = Node.new(
|
|
139
|
+
symbol: symbol, distance: distance, witness: witnesses.fetch(symbol), kind: kind, component: members
|
|
140
|
+
)
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
result.sort.to_h
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
require "digest"
|
|
5
|
+
require_relative "../ir/serialize"
|
|
6
|
+
|
|
7
|
+
module Ibex
|
|
8
|
+
module Impact
|
|
9
|
+
# Builds the deterministic public impact-v1 document.
|
|
10
|
+
class Report
|
|
11
|
+
# @rbs @mode: String
|
|
12
|
+
# @rbs @algorithm: String
|
|
13
|
+
# @rbs @grammar: IR::Grammar
|
|
14
|
+
# @rbs @before: IR::Automaton?
|
|
15
|
+
# @rbs @after: IR::Automaton?
|
|
16
|
+
# @rbs @seeds: Array[Hash[Symbol, Object?]]
|
|
17
|
+
# @rbs @nodes: Hash[Integer, Node]
|
|
18
|
+
# @rbs @symbol_kinds: Hash[Integer, Array[String]]
|
|
19
|
+
# @rbs @set_changes: Hash[String, Hash[Symbol, Object?]]
|
|
20
|
+
# @rbs @metadata_names: Array[String]
|
|
21
|
+
# @rbs @automaton: Hash[Symbol, Object?]
|
|
22
|
+
# @rbs @actions: Array[Hash[Symbol, Object?]]
|
|
23
|
+
# @rbs @coverage: Hash[Symbol, Object?]
|
|
24
|
+
# @rbs @minimum: String
|
|
25
|
+
# @rbs @warnings: Array[String]
|
|
26
|
+
# @rbs (mode: String, algorithm: String, grammar: IR::Grammar, before: IR::Automaton?, after: IR::Automaton?,
|
|
27
|
+
# seeds: Array[Hash[Symbol, Object?]], nodes: Hash[Integer, Node], symbol_kinds: Hash[Integer, Array[String]],
|
|
28
|
+
# set_changes: Hash[String, Hash[Symbol, Object?]], automaton: Hash[Symbol, Object?],
|
|
29
|
+
# actions: Array[Hash[Symbol, Object?]], coverage: Hash[Symbol, Object?], ?metadata_names: Array[String],
|
|
30
|
+
# ?minimum: String,
|
|
31
|
+
# ?warnings: Array[String]) -> void
|
|
32
|
+
# rubocop:disable Metrics/ParameterLists
|
|
33
|
+
def initialize(mode:, algorithm:, grammar:, before:, after:, seeds:, nodes:, symbol_kinds:, set_changes:,
|
|
34
|
+
automaton:,
|
|
35
|
+
actions:, coverage:, metadata_names: [], minimum: "info", warnings: [])
|
|
36
|
+
@mode = mode
|
|
37
|
+
@algorithm = algorithm
|
|
38
|
+
@grammar = grammar
|
|
39
|
+
@before = before
|
|
40
|
+
@after = after
|
|
41
|
+
@seeds = seeds
|
|
42
|
+
@nodes = nodes
|
|
43
|
+
@symbol_kinds = symbol_kinds
|
|
44
|
+
@set_changes = set_changes
|
|
45
|
+
@metadata_names = metadata_names.uniq.sort
|
|
46
|
+
@automaton = automaton #: Hash[Symbol, Object?]
|
|
47
|
+
@actions = actions #: Array[Hash[Symbol, Object?]]
|
|
48
|
+
@coverage = coverage #: Hash[Symbol, Object?]
|
|
49
|
+
@minimum = minimum
|
|
50
|
+
@warnings = warnings
|
|
51
|
+
end
|
|
52
|
+
# rubocop:enable Metrics/ParameterLists
|
|
53
|
+
|
|
54
|
+
# @rbs (?minimum: String) -> Hash[Symbol, Object?]
|
|
55
|
+
def to_h(minimum: @minimum)
|
|
56
|
+
symbols = symbol_documents(minimum)
|
|
57
|
+
action_records = action_documents(minimum)
|
|
58
|
+
{
|
|
59
|
+
ibex_report: "impact", schema_version: 1, mode: @mode, algorithm: @algorithm,
|
|
60
|
+
grammar_digest: { before: stable_grammar_digest(@before), after: stable_grammar_digest(@after) },
|
|
61
|
+
seeds: @seeds.map { |seed| seed.slice(:symbol, :origin, :nullable_boundary) },
|
|
62
|
+
symbols: symbols, automaton: @automaton, actions: action_records,
|
|
63
|
+
coverage: coverage_document, warnings: stable_warnings,
|
|
64
|
+
totals: totals(symbols, action_records, @automaton)
|
|
65
|
+
}
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
# @rbs (String minimum) -> Array[Hash[Symbol, Object?]]
|
|
71
|
+
def symbol_documents(minimum)
|
|
72
|
+
names = (@nodes.keys.filter_map { |id| @grammar.symbol_by_id(id)&.name } + @set_changes.keys + @metadata_names)
|
|
73
|
+
.uniq.sort
|
|
74
|
+
documents = names.filter_map { |name| symbol_document(name) }
|
|
75
|
+
documents.select { |document| Severity::RANK.fetch(severity_of(document)) >= Severity::RANK.fetch(minimum) }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# @rbs (String) -> Hash[Symbol, Object?]?
|
|
79
|
+
def symbol_document(name)
|
|
80
|
+
id = @grammar.symbol(name)&.id
|
|
81
|
+
node = id && @nodes[id]
|
|
82
|
+
return unless reportable_symbol?(name)
|
|
83
|
+
|
|
84
|
+
kinds = symbol_kinds_for(name, id)
|
|
85
|
+
kinds = ["reference"] if kinds.empty? && node
|
|
86
|
+
severity = document_severity(name, kinds)
|
|
87
|
+
{
|
|
88
|
+
symbol: name, severity: severity, kinds: kinds, distance: node&.distance || 0,
|
|
89
|
+
component: component_names(node, name),
|
|
90
|
+
sets: @set_changes.fetch(name, { first: empty_change, follow: empty_change, nullable: nil }),
|
|
91
|
+
witness: witness_documents(node&.witness || [])
|
|
92
|
+
}
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# @rbs (String) -> bool
|
|
96
|
+
def reportable_symbol?(name)
|
|
97
|
+
!!(@grammar.symbol(name) || @set_changes.key?(name) || @metadata_names.include?(name))
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# @rbs (String, Integer?) -> Array[String]
|
|
101
|
+
def symbol_kinds_for(name, id)
|
|
102
|
+
kinds = id ? (@symbol_kinds[id] || []).dup : [] #: Array[String]
|
|
103
|
+
kinds << "metadata" if @metadata_names.include?(name)
|
|
104
|
+
kinds.concat(change_kinds(name))
|
|
105
|
+
kinds.uniq.sort
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# @rbs (String) -> Array[String]
|
|
109
|
+
def change_kinds(name)
|
|
110
|
+
change = @set_changes[name]
|
|
111
|
+
return [] unless change
|
|
112
|
+
|
|
113
|
+
kinds = [] #: Array[String]
|
|
114
|
+
first = change.fetch(:first, empty_change)
|
|
115
|
+
follow = change.fetch(:follow, empty_change)
|
|
116
|
+
kinds << "first" unless first.fetch(:added).empty? && first.fetch(:removed).empty?
|
|
117
|
+
kinds << "follow" unless follow.fetch(:added).empty? && follow.fetch(:removed).empty?
|
|
118
|
+
kinds << "nullable" if change.fetch(:nullable, nil)
|
|
119
|
+
kinds
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# @rbs (String, Array[String]) -> String
|
|
123
|
+
def document_severity(name, kinds)
|
|
124
|
+
level = kinds.reduce("info") { |current, kind| Severity.max(current, kind_severity(kind)) }
|
|
125
|
+
action_severity(name, level)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# @rbs (Node?, String) -> Array[String]
|
|
129
|
+
def component_names(node, name)
|
|
130
|
+
return [name] unless node
|
|
131
|
+
|
|
132
|
+
node.component.sort.map { |member| @grammar.symbol_by_id(member)&.name }.compact.sort
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# @rbs (String kind) -> String
|
|
136
|
+
def kind_severity(kind)
|
|
137
|
+
return "high" if %w[first follow nullable action_arity].include?(kind)
|
|
138
|
+
return "medium" if %w[reference precedence].include?(kind)
|
|
139
|
+
return "low" if kind == "metadata"
|
|
140
|
+
|
|
141
|
+
"info"
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# @rbs (String name, String current) -> String
|
|
145
|
+
def action_severity(name, current)
|
|
146
|
+
findings = @actions.select { |action| action_production(action).start_with?("#{name} ->") }
|
|
147
|
+
findings.reduce(current) { |level, finding| Severity.max(level, severity_of(finding)) }
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# @rbs (Hash[Symbol, Object?] record) -> String
|
|
151
|
+
def severity_of(record)
|
|
152
|
+
record.fetch(:severity) #: String
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# @rbs () -> Hash[Symbol, Array[String]]
|
|
156
|
+
def empty_change
|
|
157
|
+
{ added: [], removed: [] }
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# @rbs (Array[Edge]) -> Array[Hash[Symbol, Object?]]
|
|
161
|
+
def witness_documents(witness)
|
|
162
|
+
witness.map do |edge|
|
|
163
|
+
production = edge.production && @grammar.productions[edge.production]
|
|
164
|
+
location = production&.origin&.fetch(:loc, nil) #: IR::location?
|
|
165
|
+
{
|
|
166
|
+
from: symbol_name(edge.source), to: symbol_name(edge.target), production: production_shape(production),
|
|
167
|
+
loc: stable_location(location)
|
|
168
|
+
}
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# @rbs (Integer) -> String
|
|
173
|
+
def symbol_name(id)
|
|
174
|
+
@grammar.symbol_by_id(id)&.name || id.to_s
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# @rbs (IR::Production?) -> String?
|
|
178
|
+
def production_shape(production)
|
|
179
|
+
return unless production
|
|
180
|
+
|
|
181
|
+
lhs = symbol_name(production.lhs)
|
|
182
|
+
rhs = production.rhs.map { |id| symbol_name(id) }
|
|
183
|
+
"#{lhs} -> #{rhs.join(' ')}"
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# @rbs (Array[Hash[Symbol, Object?]], Array[Hash[Symbol, Object?]],
|
|
187
|
+
# Hash[Symbol, Object?]) -> Hash[Symbol, Integer]
|
|
188
|
+
def totals(symbols, actions, automaton)
|
|
189
|
+
counts = Severity::LEVELS.to_h { |level| [level.to_sym, 0] }
|
|
190
|
+
symbols.each { |item| counts[item.fetch(:severity).to_s.to_sym] += 1 }
|
|
191
|
+
actions.each { |item| counts[item.fetch(:severity).to_s.to_sym] += 1 }
|
|
192
|
+
conflicts = automaton.dig(:conflicts, :added) || [] #: Array[Hash[Symbol, Object?]]
|
|
193
|
+
counts[:critical] += conflicts.length
|
|
194
|
+
unreachable = automaton.fetch(:unreachable, []) #: Array[Integer]
|
|
195
|
+
counts[:critical] += unreachable.length
|
|
196
|
+
unreachable_nonterminals = automaton.fetch(:unreachable_nonterminals, []) #: Array[String]
|
|
197
|
+
counts[:critical] += unreachable_nonterminals.length
|
|
198
|
+
counts
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# @rbs (String minimum) -> Array[Hash[Symbol, Object?]]
|
|
202
|
+
def action_documents(minimum)
|
|
203
|
+
documents = @actions.sort_by { |action| action_production(action) }.map do |action|
|
|
204
|
+
production = action_production(action)
|
|
205
|
+
severity = action.fetch(:severity) #: String
|
|
206
|
+
reason = action.fetch(:reason) #: String
|
|
207
|
+
context_length = action.fetch(:context_length) #: Hash[Symbol, Integer]
|
|
208
|
+
location = action.fetch(:loc, nil) #: IR::location?
|
|
209
|
+
{
|
|
210
|
+
production: production, severity: severity, reason: reason,
|
|
211
|
+
context_length: context_length, loc: stable_location(location)
|
|
212
|
+
}
|
|
213
|
+
end
|
|
214
|
+
documents.select { |action| Severity::RANK.fetch(severity_of(action)) >= Severity::RANK.fetch(minimum) }
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# @rbs (Hash[Symbol, Object?] action) -> String
|
|
218
|
+
def action_production(action)
|
|
219
|
+
action.fetch(:production) #: String
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# @rbs () -> Hash[Symbol, Object?]
|
|
223
|
+
def coverage_document
|
|
224
|
+
empty_reports = [] #: Array[Hash[Symbol, Object?]]
|
|
225
|
+
reports = @coverage.fetch(:reports, empty_reports) #: Array[Hash[Symbol, Object?]]
|
|
226
|
+
status = @coverage.fetch(:status) #: String
|
|
227
|
+
{ status: status,
|
|
228
|
+
reports: reports.sort_by { |report| coverage_report_path(report) }.map do |report|
|
|
229
|
+
productions = report.fetch(:productions) #: Array[Integer]
|
|
230
|
+
{ path: stable_path(coverage_report_path(report)), productions: productions.sort }
|
|
231
|
+
end }
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# @rbs (Hash[Symbol, Object?] report) -> String
|
|
235
|
+
def coverage_report_path(report)
|
|
236
|
+
report.fetch(:path) #: String
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# @rbs () -> Array[String]
|
|
240
|
+
def stable_warnings
|
|
241
|
+
empty_reports = [] #: Array[Hash[Symbol, Object?]]
|
|
242
|
+
paths = @coverage.fetch(:reports, empty_reports).map { |report| coverage_report_path(report) }
|
|
243
|
+
@warnings.map do |warning|
|
|
244
|
+
paths.reduce(warning) { |current, path| current.gsub(path, stable_path(path)) }
|
|
245
|
+
end.sort.uniq
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# @rbs (IR::location?) -> Hash[Symbol, Integer]?
|
|
249
|
+
def stable_location(location)
|
|
250
|
+
return unless location
|
|
251
|
+
|
|
252
|
+
{ line: location[:line], column: location[:column] }
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# @rbs (IR::Automaton?) -> String?
|
|
256
|
+
def stable_grammar_digest(automaton)
|
|
257
|
+
return unless automaton
|
|
258
|
+
|
|
259
|
+
serialized = IR::Serialize.dump(automaton.grammar)
|
|
260
|
+
canonical = serialized.gsub(/"file":\s*"[^"]*"/, '"file": "<source>"')
|
|
261
|
+
canonical = canonical.gsub(/"root":\s*"[^"]*"/, '"root": null')
|
|
262
|
+
"sha256:#{Digest::SHA256.hexdigest(canonical)}"
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# @rbs (String) -> String
|
|
266
|
+
def stable_path(path)
|
|
267
|
+
return path unless path.start_with?(File::SEPARATOR)
|
|
268
|
+
|
|
269
|
+
File.basename(path)
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
end
|