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